Wikipedysta:Bla~plwikibooks/brudnopis
Abstract
edytujTekst, który widzisz wciąż jest nieskończony i wymaga na pewno poprawek. Nie miałem zamiaru stworzenia pełnego opisu języka, raczej miał to być wstęp, zarys, który miał ułatwić rozpoczęcie zabawy (i pracy!) z językiem OCaml. Przedstawić jego cechy, mechanizmy i tym samym zaciekawić.
Wikipedysta:Bla/brudnopis:Spis treści
Historia pewnej znajomości
edytujJakiś czas temu (końcówka 2006 roku) poczułem potrzebę nauczenia się jakiegoś języka funkcyjnego. Pomimo paru lat programowania w językach takich jak C/C++, PHP, Ruby, języki funkcyjne były dla mnie wciąż absolutnie obce -- nie rozumiałem co mają takiego, czego nie ma w języku C (W C przecież są funkcje, nie?). No ale już wiem.
Zacząłem od przeglądu istniejących języków tego typu. Na pierwszy ogień padł
Haskell i Lisp, ale po rozmowach na FreeNode, paru próbach napisania w nich
prostych programów, przejrzeniu tutoriali -- oba odpadły. Trudno mi na dziś
dzień przypomnieć sobie dlaczego, ale nie sprawiły wrażenia języków, w
których chciałbym cokolwiek napisać.
Objective Caml (w skrócie OCaml lub po prostu Caml) natomiast miał parę
własności, które były interesujące:
- Języki z rodziny ML (Szczególnie często używanymi są Standard ML oraz OCaml) są jednymi
z najszybszych języków (w rankingach często w top3). OCaml można kompilować do przenośnego kodu bajtowego (ang. bytecodu) tak jak np. Java, do szybkich natywnych binarek (jak C), a także można w nim pisać skrypty, które są kompilowane przed uruchomieniem.
- Umożliwia programowanie funkcyjne, obiektowe i imperatywne.
- Jest językiem silnie typowanym (silniej niż C), co ma tak wielu zwolenników co przeciwników. W każdym razie na pewno zmniejsza ilość błędów popełnianych podczas pisania programu oraz umożliwia o wiele doskonalszą optymalizację kodu.
- Zawiera bardzo efektywny garbage collector.
- Ponadto ma dość miłą składnię, choć dla programisty, który nigdy w języku funkcyjnym nie pisał z pewnością na początku będzie absolutnie obca.
Strona główna projektu znajduje się pod adresem: <a href="http://caml.inria.fr/">http://caml.inria.fr/</a>. Można znaleźć tam trochę dokładniejszej dokumentacji, opis bibliotek języka oraz paczki do pobrania.
Środowisko pracy
edytujUżywam systemu GNU/Linux i większość komentarzy będę odnosił do tego systemu jednak pod innymi systemami wiele zagadnień będzie wyglądało dokładnie tak samo.
Po miłej i szybkiej instalacji (której tu opisywać nie ma sensu) powinniśmy móc korzystać z paru aplikacji. W szczególności interesujące będą dla nas:
- ocamlc -- kompilator do bytecodu. Kod bajtowy ocamla uruchamia inny
program o nazwie ocamlrun. W systemach unixowych jest to uproszczone przez automatyczne dodanie linii #!/usr/bin/ocamlrun do tworzonych plików.
- ocamlopt -- kompilator generujący natywne pliki wykonywalne.
- ocaml -- wygodne środowisko działające na zasadzie
wczytaj-wykonaj-wyświetl. Bardzo wygodne do nauki i szybkiego próbowania fragmentów kodu w Ocamlu.
Większość nauki radzę przeprowadzić sobie za pomocą trzeciego programu, który jednak posiada pewną wadę. Ze względu na przenośność ma bardzo uproszczony interfejs i w oryginalnej wersji nie posiada ani historii komend, ani możliwości poprawienia wcześniej wpisanego tekstu bez kasowania reszty.
Przynajmniej w Linuxie znalazłem parę rozwiązań tego problemu. Możemy np. skorzystać z wrapperów biblioteki readline. Jeden z nich nazywa się "rlwrap" i nie działał mi dobrze. Drugi, o nazwie "ledit" zadziałał wprost idealnie. Od razu do .bashrc wrzuciłem linijkę:
alias ocaml="ledit ocaml"
I na tym skończyły się moje niewygody z "ocaml". W razie czego zalecam spróbowanie obu wrapperów.
Istnieje też bardziej eleganckie, choć też bardziej skomplikowane rozwiązanie polegające na zainstalowaniu tuareg-mode do edytora Emacs. Prócz podświetlania składni, wcinania kodu udostępnia on też możliwość interaktywnego uruchomienia "ocaml" z historią komend i podświetlaniem składni na żywo.
Krótki opis najważniejszych cech języka
edytujCaml jak już wspomniałem jest językiem statycznie typowanym, co oznacza tyle, że każda użyta w nim nazwa (funkcji, zmiennej, argumentu funkcji) ma określony ścisły typ, który podczas działania programu nie ulega zmianie. Ponadto programista nie musi (prawie nigdy) definiować typów używanych wyrażeń. W takim razie, można by spytać -- jak to działa? Zmienne mają określone typy, ale nikt nigdzie nie stwierdza jakie to są typy? Tak, dokładnie tak to wygląda.
Caml w trakcie kompilacji przeprowadza tzw. "type inferring", na podstawie sposobów w jaki używano zmiennej ustala jej typ. To wydawać się może bardzo proste, ale działa również dla typów złożonych, takich jak listy, rekordy, listy rekordów, obiekty. Działa również pomiędzy osobnymi plikami, bibliotekami Camla i modułami. Pomimo, że technika ta pozwala na uniknięcie wielu częstych błędów pojawiających się podczas pisania programów w językach bez silnego typowania (np. w PHP), trochę przyspiesza tworzenie programów (zwalniając programistę z obowiązku deklarowania zmiennych), przyspiesza wykonywanie programu przez dokładniejsze przeprowadzenie optymalizacji to wprowadza też jednak pewne niedogodności.
Jak i w wielu innych językach, mamy do dyspozycji zestaw podstawowych typów, jakimi są: int, float, char, string, bool oraz typ unit (oznaczany często dwoma nawiasami "()"), będący odpowiednikiem void z języka C. Podobnie jak w wielu innych językach pojedyncze znaki (typu char) otoczone są pojedynczym cudzysłowem, np. 'a', ciągi znaków podwójnym. Z typów podstawowych można zbudować inne typy takie jak listy, krotki (ang. tuples), rekordy, tablice, typy wariacyjne.
Aby móc rozróżnić pomiędzy typami całkowitymi i zmiennoprzecinkowymi w Camlu programista używa dwóch zestawów symboli dla operacji arytmetycznych. Jest to "+ - * /" dla liczb całkowitych (typ int), oraz te same symbole z dodaną do nich kropką: "+. -. *. /." dla operacji na typach float. W Camlu w przeciwieństwie do C nie istnieje tzw. "implicit casting", czyli automatyczne rzutowanie (konwersja) typów z jednego na drugi. Aby w C skonwertować integer na float, użylibyśmy zwykłego przypisania
float a; int b = 10;
a = b;
Co najwyżej możemy ręcznie uczynić to rzutowaniem jawnym (explicit cast), dodając:
a = (float) b;
Wielu programistów może nie zdawać sobie sprawy z tego, że konwersja liczby całkowitej na zmiennoprzecinkową nie jest taką całkiem tanią operacją. W tym języku wszystkie rzutowania są przeprowadzane jawnie. W standardowym module (który jest zawsze załączony do programu i nazywa się "Pervasives") dostępny jest szereg funkcji w postaci: string_of_int (konwertuje ciąg znaków na liczbę), int_of_string (w przeciwnym kierunku), int_of_float, float_of_int, itp.
Język funkcyjny charakteryzuje się tym, że funkcje są w nim obywatelami pierwszej klasy ("first-class citizens"). Można je w dowolnym miejscu tworzyć, przekazywać jak dowolne inne dane do innych funkcji. Poza tym każde wyrażenie używane w języku (np. warunki if) posiada swój typ.
To tyle jeśli chodzi o obowiązkowy wstęp. Resztę wiedzy najchętniej przekazałbym poprzez przykłady, mi przynajmniej najszybciej się zawsze z nich uczy.
"Step by step you're gonna make yourself code better"
edytujJeśli mamy przygotowane środowisko programistyczne do nauki, możemy przerobić parę prostych programów. Uruchamiamy "ocaml" i wpisujemy pierwsze banalne przykłady:
$ ocaml
Objective Caml version 3.09.3
# 5 + 5;;
- : int = 10
# 6.3 *. 2.5;;
- : float = 15.75
# 5 > 6;;
- : bool = false
# 'a';;
- : char = 'a'
# "abc";;
- : string = "abc"
# ();;
- : unit = ()
Fragmenty kodu są wpisane za znakiem zachęty '#' i w tym przypadku jest to pojedyncze wyrażenie "5 + 5", za którym znajduje się separator ";;". Caml używa podwójnego średnika do odseparowywania wyrażeń na najwyższym "poziomie" kodu (ang. top-level). Znak ten w wielu przypadkach się pomija, a wewnątrz funkcji do odseparowywania poszczególnych instrukcji używa się pojedynczego średnika.
Ocaml w odpowiedzi na nasz pierwszy przykład zwrócił taką informację: "- : int = 10", która zawiera wynikowy (ang. inferred) typ wyrażenia (int) oraz jego wartość (10). Myślnik oznacza, że wyrażeniu nie nadaliśmy żadnej nazwy. Gdyby jednak chcieć je jakoś nazwać?
# let a = 5 + 5;;
val a : int = 10
Ha! Teraz posiadamy "zmienną" o nazwie "a" i wartości 10. Ważny jest tutaj cudzysłów dookoła słowa "zmienna". Tak na prawdę powyższe wyrażenie nie jest taką zmienną jaką znamy z języków imperatywnych (czyli np. z C). Jest to tylko nazwane "globalne wyrażenie". Wiąże się to z pewnymi istotnymi cechami. Nie możemy dla przykładu zmienić wartości "a" bez stworzenia nowej etykiety (choćby o tej samej nazwie, ale co jak łatwo się przekonać jest czymś zupełnie innym). Caml zwraca uwagę na wielkość liter; wyrażenia/etykiety zaczynające się z dużej litery są zarezerwowane dla nazw modułów i np. konstruktorów typów wariacyjnych (o czym się przekonamy!).
W bardzo podobny sposób możemy w Camlu stworzyć funkcje:
# let add a b = a + b;;
val add : int -> int -> int = <fun>
# add 5 6;;
- : int = 11
# add 5 (a * 5 + 5);;
- : int = 60
# let addfive = add 5;;
val addfive : int -> int = <fun>
# addfive 40;;
- : int = 45
# let add2 a b =
let sum = a + b in
sum
;;
val add2 : int -> int -> int = <fun>
Tym razem wydedukowany typ wyrażenia z linii pierwszej jest trochę bardziej złożony: "int -> int -> int = <fun>". Oznacza on ni mniej ni więcej tylko funkcję, która przyjmuje dwa inty i zwraca int.
W linii czwartej widać sposób w jaki wywołujemy funkcje -- nie używamy nawiasów wokół argumentów i nie oddzielamy ich przecinkami tak jak to ma miejsce w C. Nawiasów użyjemy w następnym przypadku, gdy będziemy chcieli podać jako argument funkcji wynik innego wyrażenia. Jeśliby ich tam zbrakło Ocaml wywołałby funkcję z parametrami 5 i a, po czym pomnożył wynik przez 5 i dodał 5.
Okrągłe nawiasy w Camlu spełniają podobną funkcję co klamry w C -- odgradzają bloki kodu grupując wyrażenia. Zauważmy więc, że te operacje arytmetyczne w nawiasie, stanowią pewne wyrażenie, któremu podczas wartościowania nadany zostaje typ int o konkretnej wartości. W wielu przypadkach zamiast nawiasów można użyć również słów begin i end jeśli zwiększają one czytelność. Nieraz wyglądało by to jednak po prostu śmiesznie:
# add 5 begin a*5+5 end;;
- : int = 60
Wracając do naszego poprzedniego sporego przykładu; w linii 10 możemy zauważyć coś dziwnego. Wywołaliśmy naszą dwuargumentową funkcję tylko z jednym argumentem. I co? Błędu nie ma! Co więcej, wyrażenie zwróciło nam "coś" co zapisaliśmy pod etykietką addfive. Z wydedukowanego typu "int -> int = <fun>", widzimy, że add, wywołane z jednym argumentem zwróciło funkcję, która przyjmuje jeden argument typu int, i zwraca jednego inta!
Co więcej, jak się później okazało nasza nowa funkcja dodaje do podanego argumentu piątkę. Niesamowite! Co lepsze, ta charakterystyczna dla języków funkcyjnych cecha ma pewne praktyczne zastosowania. Przykładowo możemy stworzyć ogólną funkcję, z której następnie przez podanie paru argumentów stworzymy drugą pochodną funkcję. Możemy ją z kolei podać do innej funkcji, która przy jakiejś okazji ją wywoła. Uruchamianie funkcji bez podanych wszystkich argumentów nazywać będziemy częściową aplikacją/częściowym aplikowaniem (ang. partial application).
Ostatni przykład ilustruje sposób w jaki tworzy się lokalne wyrażenia, czyli
np. etykiety lub funkcje wewnątrz innych funkcji. Używamy bardzo podobnego
wyrażenia do let a = b;;
, tylko zakończonego słowem "in" zamiast
średników.
Funkcje jako wartości
edytujPrzed nami rozdział opisujący główne cechy, które Caml zawdzięcza swoim funkcyjnym korzeniom. Ich dokładnie rozumienie często nie jest niezbędne do tworzenia prostych programów, ale z pewnością może to ułatwić.
(* Tworzymy funkcję pobierającą 3 argumenty *)
# let add_three a b c = a + b + c;;
val add_three : int -> int -> int -> int = <fun>
(* Jeśli wywołamy ją z mniejszą liczbą argumentów, zwróci
* nam nową funkcję "odejmując" z typu jedno "int ->"
* z lewej strony *)
# add_three 1;;
- : int -> int -> int = <fun>
# add_three 1 2;;
- : int -> int = <fun>
# add_three 1 2 3;;
- : int = 6
(* Może więc przy zwykłym wywoływaniu
* dzieje się tak samo? *)
# (((add_three 1) 2) 3);;
- : int = 6
Proces sprowadzania funkcji wieloargumentowych do jednoargumentowych w matematyce i informatyce (rachunku lambda) nazywa się "currying". Możemy też stworzyć funkcję z paru funkcji jednoargumentowych:
# let add_three = fun a -> fun b -> fun c -> a + b + c;;
val add_three : int -> int -> int -> int = <fun>
Nie znam wprawdzie bezpośredniego zastosowania takiej metody deklarowania funkcji, lecz słowa kluczowego "fun" (lub "function") używa się dość często. Wtedy kiedy chcemy podkreślić, że typem zwracanym przez funkcję jest inna funkcja, jeśli chcemy stworzyć "funkcję anonimową", lub gdy chcemy stworzyć funkcję, która ma przeprowadzać dopasowywanie (O tym za chwilkę).
# let deriv f dx = fun x -> ( f(x +. dx) -. f(x) ) /. dx;;
val deriv : (float -> float)->float->float->float = <fun>
# let sin' = deriv sin 1e-6;;
val sin' : float -> float = <fun>
# sin' 3.141592653589793;;
- : float = -1.00000000013961143
W tym przykładzie stworzyliśmy funkcję, która dla dowolnej podanej funkcji typu float -> float zwraca funkcję (float -> float), która oblicza przybliżenie jej matematycznej pochodnej. Definicja funkcji deriv w tym przykładzie używała słowa "fun". Możemy zapisać ją bez użycia tego słowa kluczowego w sposób absolutnie równoważny:
# let deriv f dx x = ( f(x +. dx) -. f(x) ) /. dx;;
val deriv : (float -> float)->float->float->float = <fun>
Wydaje mi się, że ta metoda mniej wskazuje na sposób w jaki funkcja powinna być wywoływana. Wcześniej miała 2 argumenty i zwracała funkcję. Teraz ma 3 i zwraca wartość pochodnej w punkcie... chyba, że zastosujemy częściową aplikację.
Można również prosto zdefiniować funkcję służącą do składania funkcji:
# let compose f g = function x -> f(g(x));;
val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun>
# let sqrt_cos = compose sqrt cos;;
val sqrt_cos : float -> float = <fun>
Znane nam już operacje dodawania, odejmowania, mnożenia i dzielenia to również zwykłe dwuargumentowe funkcje zdefiniowane w module Pervasives. Programista bardzo łatwo może definiować swoje własne operatory. Właściwie tak jak w typowo obiektowych językach (Ruby) gdzie wszystko jest obiektem tutaj praktycznie wszystko jest funkcją.
Rekurencja, iteracje
edytujW językach funkcyjnych bardzo naturalną techniką jest rekurencja. Tworząc funkcje rekurencyjne dodajemy do definicji słówko "rec", bez niego funkcja nie będzie mogła wywoływać samej siebie. Dla przykładu prostą pętlę można rozwiązać w następujący sposób:
# let rec loop i =
print_endline "Looping!";
if (i>1) then loop (i-1)
;;
val loop : int -> unit = <fun>
# loop 3;;
Looping!
Looping!
Looping!
- : unit = ()
Funkcje "tail-recursive" to taki przypadek rekurencyjnych funkcji, w których ponowne wejście do funkcji znajduje się na ich końcu. Caml optymalizuje wszystkie takie przypadki generując assembler lub kod bajtowy w postaci normalnej iteracji. Dlatego stosowanie rekurencji do zapętlania programu nie jest obciążone żadnym większym zużyciem pamięci ani czasem potrzebnym na wywołanie funkcji jakby to miało miejsce w języku C i podobnych.
Istnieją również implementacje pętli for i while, lecz zazwyczaj się z nich nie korzysta. W Camlu nie istnieją wyrażenia break i continue. Jeśli jednak zajdzie potrzeba przerwania rekurencji lub pętli używany jest wyjątek Exit (zdefiniowany w Pervasives). W modułach zdefiniowane jest również wiele "iteratorów" -- funkcji służących do wykonywania cyklicznych operacji na np. elementach list, które opiszemy później.
Rekurencji ciąg dalszy:
# let rec silnia n =
if n = 0 then 1 else n * silnia (n - 1);;
# let rec fib n =
if (n < 2) then
1
else fib (n - 1) + fib (n - 2);;
val fib : int -> int = <fun>
Pierwsza funkcja, jak każdy się pewnie domyślił, pozwala obliczyć silnię z n. Druga zwraca n-ty wyraz ciągu Fibonacciego (licząc od zera). Widzimy tu, wiele opisanych wcześniej elementów. Ostatnie wyrażenie w "bloku kodu" zwraca wartość, oraz że każdy blok posiada jakiś typ. Jeśli np. w else zechcielibyśmy zwrócić typ float (zamiast "1" umieszczając tam "1.0") otrzymamy informację, że używane przez nas wyrażenie o typie float, jest używane jako int.
Funkcja fib nie jest funkcją "tail-recursive", dlatego możemy ją efektywniej napisać stosując funkcję pomocniczą, stworzoną za pomocą konstrukcji let ... in
# let fib x =
(* Funkcja pomocnicza *)
let rec loop a b i =
if i <= 2 then
b
else let next = a + b in
loop b next (i-1)
in
loop 1 1 x (* Jej wywołanie *)
;;
val fib : int -> int = <fun>
(* Wspomniana pętla for. Wyświetla początek
* ciągu fibonacciego: *)
# for i = 1 to 10 do
print_int (fib i);
print_newline ();
done;;
1
1
2
3
5
8
(...)
- : unit = ()
(* Wyrażenie w pętli "for" musi zwracać typ unit. *)
Listy i polimorfia
edytujListy zbudowane są z wielu elementów tego samego typu. Pustą listę oznacza się symbolem [], natomiast operator :: doczepia na początku listy nowy element. Można je tworzyć na dwa sposoby; rekurencyjnie -- doczepiając do pustej listy kolejne elementy, lub w równoważny sposób -- podając w kwadratowym nawiasie elementy oddzielone średnikami. Spójrzmy na poniższy przykład:
# [1;2;3;4;5];;
- : int list = [1; 2; 3; 4; 5]
# 1 :: 2 :: 4 :: 5 :: [];;
- : int list = [1; 2; 4; 5]
# ['a'; 'b'; 'c'; 'd'];;
- : char list = ['a'; 'b'; 'c'; 'd']
# [];;
- : 'a list = []
Typem ogólnym listy jest 'a list
, gdzie 'a jest
"typem polimorficznym", który oznacza "dowolny typ". Szczególnymi przypadkami
list może być lista intów: int list
lub znaków: char
list
. Do łączenia dwóch list w jedną używany jest operator '@'.
Jeśli OCaml nie będzie w stanie określić typu któregoś z argumentów funkcji, stworzy funkcję polimorficzną, która może przyjąć argument o dowolnym typie. Wiele z funkcji z biblioteki standardowej jest właśnie tak zbudowana. Dla przykładu rozważmy funkcję List.map:
# List.map;;
- : ('a -> 'b) -> 'a list -> 'b list = <fun>
# List.map (fun a -> a + 5) [1; 2; 3];;
- : int list = [6; 7; 8]
Funkcja ta przyjmuje dwa argumenty: Funkcję ('a -> 'b) przyjmującą jeden argument dowolnego typu, i zwracającą argument innego typu (lub tego samego, ale niekoniecznie) oraz 'a list, czyli listę tego samego typu, który przyjmuje funkcja i zwraca listę elementów ('b list) zwracanych przez funkcję. Trochę to było skomplikowane ale wybrnęliśmy.
W tym przykładzie również możemy zauważyć zastosowanie funkcji anonimowej: (fun a-> a+5), która została stworzona specjalnie na potrzeby wywołania List.map -- nie posiada własnej nazwy i nie może być później użyta ponownie.
Krotki i tablice
edytujKrotki (ang. tuples) to typ danych, który składa się z kilku elementów o tym samym lub różnym typie. Są to odpowiednio pary, trójki, czwórki etc. Do oddzielenia ich poszczególnych elementów używane są przecinki (w przeciwieństwie do średników używanych w listach). Dla zwiększenia czytelności często otacza się je nawiasami. W opisie typów pojawia się zamiast przecinka symbol gwiazdki '*'.
Mogą być używane do zwracania dwóch zmiennych przez funkcje, do definiowania list par elementów i w wielu innych zastosowaniach. Przykładowo:
# let func a = (a, "tekst");;
val func : 'a -> 'a * string = <fun>
(* Funkcja przyjmuje dowolny parametr; zwraca krotkę. *)
# func 5;;
- : int * string = (5, "tekst")
# let one, two = func "inny";;
val one : string = "inny"
val two : string = "tekst"
# (4, 5.0) :: (10, 12.32) :: [];;
- : (int * float) list = [(4, 5.); (10, 12.32)]
Tablice są przykładem imperatywnych elementów w języku OCaml. Można by zapytać: ,,Co w nich takiego imperatywnego?. Większość typów w Camlu jest niezmienna (ang. immutable). Dla przykładu listy. Jeśli raz stworzymy listę i potem zachcemy zmienić w niej jeden element musimy całą listę zbudować od nowa zmieniając jeden element. Właściwie podobnie jest z etykietami. Tablice natomiast można dowolnie zmieniać. Można także w ten sam sposób postąpić z rekordami (używając słowa kluczowego mutable).
# let arr = [| "Hello"; "World" |];;
val arr : string array = [|"Hello"; "World"|]
# arr.(0);;
- : string = "Hello"
# arr.(0) <- "test";;
- : unit = ()
# arr.(1).[4];;
- : char = 'd'
# arr.(1).[4] <- 'X';;
- : unit = ()
# arr;;
- : string array = [|"test"; "WorlX"|]
W powyższym przykładzie w linii pierwszej stworzona została tablica z przypisaną jej nazwą "arr". Następnie odwołaliśmy się do pierwszego elementu tablicy (o indeksie zero) i zmieniliśmy jego wartość na ciąg "test".
W linii 10, odwołaliśmy się do pojedynczego znaku ciągu. Jak widać robi się to z wykorzystaniem zapisu: ciąg.(indeks), czyli używając nawiasu okrągłego -- nie kwadratowego jak to się dzieje w przypadku tablic. Następnie zmieniliśmy ów znak ciągu na inny. Ciągi znaków też mają imperatywną charakterystykę w Camlu -- jeśli ktoś potrzebuje czysto funkcyjne ciągi tworzy je jako listę znaków. Są trochę wolniejsze o tych wbudowanych natywnie język. W module Pervasives jest zdefiniowany operator '^' -- funkcja, która pobiera dwa ciągi i zwraca jeden, będący sklejeniem ciągów składowych.
Typy wariacyjne i dopasowywanie
edytujNa 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żywany 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 definicje 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 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.
Rekordy i zmienne
edytujO rekordach można myśleć jak o krotkach ze zdefiniowanymi nazwami pól. Żeby z nich skorzystać najpierw musimy zdefiniować typ rekordowy, następnie gdziekolwiek w programie użyjemy nazw pasujących do tej definicji zostanie użyty ten typ:
# type record = { name : string; age : int };;
type record = { name : string; age : int; }
# let osoba = { name="Tomasz"; age=20 };;
val osoba : record = {name = "Tomasz"; age = 20}
# osoba.name;;
- : string = "Tomasz"
# osoba.name <- "Jurek";;
The record field label name is not mutable
Jak widać żeby pola rekordów były modyfikowalne musimy użyć odpowiedniego słowa kluczowego:
# type record2 = { mutable name : string };;
type record2 = { mutable name : string }
# let osoba = { name="Tomasz" };;
val osoba : record2 = {name = "Tomasz" }
# osoba.name <- "Jurek";;
- : unit = ()
# osoba;;
- : record2 = {name = "Jurek" }
Za pewne ograniczenie może zostać uznany fakt, że nie można używać w jednym programie (w jednej przestrzeni nazw) dwóch rekordów o tych samych nazwach pól, ale z różnymi typami. Ostatnio zdefiniowany rekord zasłoni wszystkie poprzednie definicje.
Skoro doszliśmy tak daleko to pewnie warto dodać słów kilka o zmiennych. Caml zmiennych jako tako wbudowanych nie posiada -- implementuje zmienne jako rekordy z jednym modyfikowalnym polem "content":
# let variable = ref 0;;
val variable : int ref = {contents = 0}
# variable := 5;;
- : unit = ()
# !variable;;
- : int = 5
Tak zdefiniowana została zmienna o nazwie "variable". Robi się to za pomocą słowa "ref" po którym następuje początkowa wartość zmiennej, której nie można pominąć. Ażeby przypisać takiej zmiennej nową wartość używa się operatora ":=", aby zaś odczytać wartość zmiennej wstawiamy przed jej nazwę wykrzyknik. Proste!
Skoro już znamy zmienne możemy pokusić się o przykład na pętlę while:
# let i = ref 0;;
val i : int ref = {contents = 0}
# while (!i < 5) do
printf "Looping, i=%d\n" !i;
i := !i + 1;
done;;
Looping, i=1
Looping, i=2
Looping, i=3
Looping, i=4
- : unit = ()
Większy przykład, I/O i programy "stand-alone"
edytujTym razem program będzie na tyle spory, że proponuję umieścić go w pliku main.ml (lub o innej nazwie), który będziemy kompilować komendą ocamlc -o main main.ml i uruchamiać już klasycznie: ./main
(* Bez tych linijek musielibyśmy pisać *
* Printf.printf i Scanf.scanf *)
open Printf
open Scanf
(* Typ opisujący rozwiązanie równania;
* Zwracany przez funkcję *)
type solution =
| Zero (* Brak rozwiązań*)
| One of float (* Jedno *)
| Two of (float * float)(* Dwa *)
(* Funkcja znajdująca pierwiastki
* równania kwadratowego *)
let solve a b c =
let delta =
b *. b
-. 4.0 *. a *. c in
if (delta < 0.0) then
Zero
else
let d = sqrt(delta) in
let denom = 2.0 *. a in
if (d == 0.0) then
One (-.b /. denom)
else Two ((-.b +. d) /. denom,
(-.b -. d) /. denom)
let get_data =
(* Funkcja wczytująca dla scanf *)
let read a b c = (a, b, c) in
printf "Proszę podać współczynniki równania:\n";
flush stdout;
scanf "%f %f %f" read (* Zwraca krotkę *)
(* Wczytaj dane *)
let a,b,c = get_data;;
match (solve a b c) with
| Zero -> printf "Brak rozwiązań\n";
| One x -> printf "Jedno rozwiązanie: %.2f" x
| Two (x0, x1) ->
printf "Dwa rozwiązania: %.2f i %.2f\n" x0 x1
Jak już wspomniałem bardzo wiele funkcji buduje się poprzez wielokrotne wywoływanie let ... in, a następnie kwitowanie tego zwróceniem tak utworzonego wyrażenia (czyli napisaniem jego nazwy na końcu). Takie budowane funkcji również pomaga kompilatorowi w znajdywaniu i optymalizowaniu wspólnych wyrażeń.
W podobny sposób zbudowana jest funkcja solve, na początku obliczamy jakieś wstępne dane i nadajemy im etykiety. W późniejszych obliczeniach wykorzystujemy poprzednie etykiety by ostatecznie zwrócić typ zawierający wynik naszych obliczeń.
Wykorzystanie funkcji printf nie powinno sprawić problemów programistom C. Ze względu na brak zmiennych jako takich w języku trochę inaczej wygląda wywołanie funkcji scanf. Oczekuje ona jako argumentu funkcji, która pobierze odczytane przez scanf dane i zrobi z nimi to, czego życzy sobie użytkownik. Wygodne do zastosowania są tutaj funkcje anonimowe.
Prócz funkcji printf/scanf w module Pervasives dostępne są funkcje niższego poziomu; część z nich używaliśmy już wcześniej:
- val print_char : char -> unit
- val print_string : string -> unit
- val print_int : int -> unit
- val print_float : float -> unit
- val print_endline : string -> unit
- val print_newline : unit -> unit
Oraz funkcje do wczytywania danych:
- val read_line : unit -> string
- >val read_int : unit -> int
- val read_float : unit -> float
Bardzo dobrze sprawują się jeśli chcemy wyświetlić coś, czego nie musimy dodatkowo formatować. Są prawdopodobnie szybsze od funkcji printf, choć moim zdaniem mają nieporęcznie długie nazwy.
Wstęp do wyjątków
edytujCaml swoją implementację wyjątków oparł o mechanizm dopasowywania. W module Pervasives znajdziemy definicję funkcji failwith i invalid_arg, które tworzą i "podnoszą" (ang. raise) wyjątki Failure i Invalid_argument z podanym ciągiem znaków. Ponadto znajdziemy tam zdefiniowany wyjątek "Exit", który może być użyty do opuszczania pętli i rekurencji. Prosto możemy tworzyć własne wyjątki.
(* Stwórz wyjątek *)
# exception Empty;;
exception Empty
# let get_head lst =
match lst with
| [] -> raise Empty
| hd :: tl -> hd;;
val head : 'a list -> 'a = <fun>
# get_head [1; 2];;
- : int = 1
# get_head [];;
Exception: Empty.
# let head =
try
get_head []
with
| Empty_list ->
print_endline "Niestety lista pusta!";
0; (* Ustaw głowę na zero *)
Jak widać, wyjątki tworzy się przy wykorzystaniu słowa kluczowego
exception
. Jeśli będziemy chcieli by wyjątek posiadał jakiś
określony typ, stworzymy go następująco:
# exception Error of int;;
exception Error of int
# raise (Error 10);;
Exception: Error 10.
Podczas dopasowywania wartości wyjątków odczytujemy tak jak to ma miejsce przy normalnym dopasowywaniu. Wiele funkcji z biblioteki standardowej używa wyjątków żeby poinformować programistę o niemożności wykonania niektórych zadań.
Nazwane i opcjonalne argumenty funkcji
edytujJak każdy nowoczesny język Caml udostępnia programiście możliwość nazywania argumentów funkcji oraz tworzenia opcjonalnych argumentów. Również moduły biblioteki o nazwie zakończonej na "Label" udostępniają funkcje z nazwanymi argumentami. Ułatwia to zarządzanie i dokumentowanie kodu.
# let example ~x ~y =
printf "%d %.2f\n" x y;;
val example : x:int -> y:float -> unit = <fun>
# example ~x:4 ~y:10.0;;
4 10.00
- : unit = ()
Ze względu na to, że nie podawanie części argumentów (tzw. częściowa aplikacja) ma w języku specjalne znaczenie, programista musi pokazać kiedy ma zostać użyty opcjonalny argument. Dlatego za opcjonalnymi argumentami musi występować przynajmniej jeden nieopcjonalny i nienazwany. Najczęściej stosuje się typ unit.
# let incr ?(by=1) x = x + by;;
val incr : ?by:int -> int -> int = <fun>
# incr 5;;
- : int = 6
# incr ~by:10 3;;
- : int = 13
(* Są zaimplementowane jako typ wariacyjny.
* Powyższe można w analogiczny sposób zapisać tak: *)
# let incr ?by x =
match by with
| None -> x + 1
| Some step -> x + step;
;;
val incr : ?by:int -> int -> int = <fun>
(* Ich typ to: *)
type 'a option = None | Some of 'a
(* Jeszcze jeden przykład: *)
# let test ?(x = 0) ?(y = 0) () = (x, y);;
val test : ?x:int -> ?y:int -> unit -> int * int = <fun>
# test ();;
- : int * int = (0, 0)
# test ~x:4 ();;
- : int * int = (4, 0)
# test ~x:4 ~y:5 ();;
- : int * int = (4, 5)
# test ~x:4 ~y:5 ;;
- : unit -> int * int = <fun>
Dzięki temu, że funkcja pobiera argument unit
kompilator języka
wie, kiedy chcemy ją ostatecznie zastosować.
Przydatne linki zewnętrzne
edytujBardzo dobry tekst, który pozwala zgłębić co nieco tajniki OCamla: OCaml Tutorial
Klasyczny wstęp, również bardzo przydatny: User Manual
Opis bibliotek języka OCaml: Language Reference
Zapraszamy do kodowania. ;-)