OCaml/Typy wariacyjne

Na pewno spotkaliście się kiedyś w innych językach z podobną konstrukcją:

struct Dane {
	enum {TYPE_INT, TYPE_DOUBLE, TYPE_STRING} Type;
	union {
		int integer;
		double num;
		char *str;
	} u;
}

Zamierzenie jest jasne: Chcemy mieć typ, który będzie czasem ciągiem, czasem liczbą całkowitą, a w jeszcze innych przypadkach liczbą zmiennoprzecinkową. Jednak taka implementacja jaką widzimy powyżej ma szereg wad. Prócz tego, że wygląda skomplikowanie (a wyglądała by jeszcze gorzej jakby zamiast enum użyć #define) programista może się pomylić. Wpisać do unii liczbę, a ustawić, że znajduje się tam para liczb.

Analizę takiego typu najpewniej przeprowadzimy w jakimś rozbudowanym wyrażeniu typu "switch", gdzie sprawdzimy wszystkie (lub nie wszystkie — kolejny błąd) przypadki i wykonamy określone akcje. Caml udostępnia programiście dla takich zastosowań bardzo potężny i często wykorzystywany typ wariacyjny, wraz z narzędziem jakim jest dopasowywanie (ang. matching). Ale zacznijmy od początku:

(* Deklarujemy typ wariacyjny *)
# type colors = Green | Blue | Red | Yellow | Pink;;
type colors = Green | Blue | Red | Yellow | Pink

(* Wywołujemy któryś z jego konstruktorów. *)
# Green;;     
- : colors = Green

(* Mała dopasowująca funkcja *)
# let setColor color = 
	match color with
	| Green	-> print_endline "Zielony!"
	| Blue -> print_endline "Niebieski!"
	| Yellow -> print_endline "Żółty!"
	| _ -> print_endline "Inny kolor?"
;;
val setColor : colors -> unit = <fun>

# setColor Green;;
Zielony!
- : unit = ()
# setColor Pink;;
Inny kolor?
- : unit = ()

W przykładzie zdefiniowaliśmy typ wariacyjny o nazwie colors. Może on posiadać 5 "wartości" odpowiadających odpowiednim kolorom. Te wartości/etykiety nazywane są konstruktorami typu — będziemy ich używać gdy będziemy chcieli stworzyć wyrażenie o tym typie.

Następnie stworzyliśmy funkcję setColor; przyjmuje ona jeden argument, będący kolorem. Dopasowuje go i następnie wykonuje związaną z nim akcję. Podkreślenie zostało użyte do dopasowania wszystkich pozostałych kolorów. Kompilator nie pozwoliłby nam na stworzenie dopasowania, które pomijało by niektóre przypadki.

Funkcje, których jedynym zadaniem jest dopasowanie jednego lub więcej argumentów można zapisywać w krótszej formie. Nie opisuje się wtedy nazw funkcji i zamiast zapisu match [argument] with ... używamy słowa "function". Powyższy przykład był praktycznie zwykłym enum wraz z ładnie wyglądającym "switch", rozszerzmy go więc:

# type colors = Green | Yellow | RGB of (int * int * int);;             
type colors = Green | Yellow | RGB of (int * int * int)

# let color = RGB (120, 15, 166);;
val color : colors = RGB (120, 15, 166)

# let setColor = function
	| Green -> print_endline "Zielony?"
	| RGB (r,g,b) ->
		printf "Wybrałeś kolor %d %d %d\n" r g b
	| _ -> print_endline "Inny kolorek"
;;
val setColor : colors -> unit = <fun>

# setColor color;;
Wybrałeś kolor RGB 120 15 166
- : unit = ()

Teraz widać, że typ wariacyjny nie tylko pozwala na określenie nazw paru elementów, ale także pozwala im przypisać wartość dowolnego typu. W przykładzie jest to akurat krotka składająca się z 3 intów, które opisują kolor za pomocą mieszanki czerwonego, zielonego i niebieskiego. Podczas dopasowywania możemy ją sobie od razu wygodnie "rozbić" na 3 dowolnie nazwane przez nas etykiety. Do funkcji printf użytej w przykładzie wrócimy jeszcze później.

Dopasowywanie pozwala sprecyzować dodatkowe warunki, które muszą zachodzić by "wzór" pasował, np.:

match color with
| Green -> print_endline "Zielony?"
| RGB (r,g,b) when r=0 && b=0 ->
	print_endline "Wciąż zielony!"
| RGB (r,g,b) -> 
	printf "Wybrałeś kolor RGB %d %d %d\n" r g b
| _ -> print_endline "Inny kolorek"
;;

Możemy użyć dopasowywania do jeszcze innego zdefiniowania funkcji obliczającej ciąg fibonacciego. Tym razem definicja małpuje bezpośrednio definicję matematyczną:

let rec fib = function
	  1 -> 1
	| 2 -> 1
	| x -> fib (x - 1) + fib (x - 2)

Dopasowywanie działa więc nie tylko z typem wariacyjnym. Może nawet używać wyrażeń regularnych! W każdym przypadku można pomijać pierwszy znak '|', choć ze względów estetycznych zwykle go używam.

Dopasowywaniem można również bardzo wygodnie przeglądać listy:

# let data = [1; 2; 3; 4; 5; 99];;
val data : int list = [1; 2; 3; 4; 5; 99]

# let rec add_to_list lst num = 
        match lst with
        | [] -> []
        | hd :: tl -> (hd + num) :: add_to_list tl num
;;        
val add_to_list : int list -> int -> int list = <fun>

# add_to_list data 5;;
- : int list = [6; 7; 8; 9; 10; 104]

Jak to działa? Funkcja add_to_list przegląda listę od lewej do prawej składając nową listę. Pierwszy przypadek dopasowania jest banalny: jeśli dopasowujemy pustą listę, zwróć pustą listę. W drugim przypadku wzorzec dopasowania hd :: tl dzieli daną nam niepustą listę na jej pierwszy element (hd) oraz ogon (wszystkie pozostałe). Do głowy dodajemy num i doczepiamy za nim wynik rekursywnego przetwarzania dla ogona. "hd" i "tl" to są dowolne używane przez nas nazwy.

Wielkość tego Wprowadzenia nie pozwala na dokładnie omówienie wszystkich zastosowań, przedstawię wam jedynie jeszcze parę przykładów, bez dokładnego opisu

Typy wariacyjne mogą być definiowane rekurencyjnie! Możemy np. zdefiniować sobie drzewo binarne, które posiada w swoich liściach liczby całkowite:

# type drzewo = Lisc of int | Drzewo of (drzewo * drzewo);;
type drzewo = Lisc of int | Drzewo of (drzewo * drzewo)

(* I przykład małego drzewka: 
             /\
	    /\ 10
	   /\ 11
	  5 10
*)
# Drzewo (Drzewo (Lisc 5, Lisc 10), Lisc 11), (Lisc 10);;

Jeśli chcemy opisać tylko strukturę drzewa, które ma posiadać liście o różnych typach możemy posłużyć się polimorfią. Dla tak zdefiniowanego typu danych, możemy tworzyć polimorficzne funkcje (oparte np. o dopasowywanie):

# type 'a drzewo = 
	  Lisc of 'a
	| Drzewo of ('a drzewo * 'a drzewo);;
# Lisc 1.0;;  
- : float drzewo = Lisc 1.

# Lisc 1;;
- : int drzewo = Lisc 1

Nie mówiąc już o tym, że prócz zwykłych "typów wariacyjnych" istnieją również tzw. typy wariacyjne polimorficzne (ang. polymorphic variants), które zachowują się trochę inaczej. Przedstawię tutaj tylko króciutki przykładzik. W małych programach rzadko jest potrzeba sięgania po ten typ danych.

# let func a = match a with
	| (`DATA  a) -> printf "liczba: %d\n" a
	| (`STRDATA s) -> printf "ciag: %s\n" s;;

val func : [< `DATA of int | `STRDATA of string ] 
	-> unit = <fun>

# func (`DATA 4);;                                                      
liczba:4
- : unit = ()

# func (`STRDATA "test");;
ciag: test
- : unit = ()

Charakteryzują się użyciem symbolu "`" przed nazwą dla odróżnienia od zwykłych typów wariacyjnych. Nie definiuje się dla nich typu.