Programowanie funkcyjne/System typów
Wstęp
W tym wykładzie zamiemy się systemem typów w Ocamlu oraz zasadom, na podstawie których kompilator wnioskuje o typach. Ograniczymy się przy tym do podstawowych konstrukcji języka i uprościmy trochę składnię.
<wyrażenie> ::= <identyfikator> | <konstruktor> | 0 | 1 | ... ( <wyrażenie> { , <wyrażenie> }* ) | if <wyrażenie> then <wyrażenie> else <wyrażenie> | <wyrażenie> <wyrażenie> | function <identfikator> <wyrażenie> | let <identyfikator> = <wyrażenie> in <wyrażenie> | (<wyrażenie>) <konstruktor> ::= <Identyfikator> { <wyrażenie> }*
Niektóre konstruktory i stałe mają specjalną składnię, np. konstruktor -tki i operacje arytmetyczne. Gdy tylko nie będzie to budzić niejednoznaczności, a będzie to przydatne, będziemy traktowali konstruktory również jako nazwy procedur konstruujących wartości. Uprości to nasze rozważania, nie zmniejszając ich ogólności.
Typy i środowiska typujące
Będą nam potrzebne pojęcia typu prostego i schematu typów. Przez typy proste bedziemy rozumieli napisy postaci:
<typ prosty> ::= <zmienna typowa> | ( <typ prosty> ) | <typ prosty> -> <typ prosty> | <typ prosty> { * <typ prosty> }* | <typ prosty>1 <typ prosty>2 ... <typ prosty>n <konstruktor typu>n <konstruktor typu>0 ::= int | bool | float | unit | ... <konstruktor typu>1 ::= list | ... <zmienna typowa> ::= <zmienna typowa silna> | <zmienna typowa słaba> <zmienna typowa silna> ::= 'a | 'b | 'c | ... <zmienna typowa słaba> ::= '_a | '_b | '_c | ...
Przyjmujemy, że konstruktory typów wiążą najsilniej, * słabiej, a -> najsłabiej. Przy tym -> jest lewostronnie łączne.
Przez schematy typów będziemy rozumieli napisy następującej postaci:
<schemat typu> ::= [ <zmienna typowa silna> { , <zmienna typowa silna> }* ] . <typ prosty> | <typ prosty>
Typ prosty opisuje jeden typ, zależny od wartości ew. zawartych w nim zmiennych typowych. Schemat typów opisuje te typy, do których potrafi się przystosować wartość polimorficzna. Schemat typu 'a,'b,...,'g. możemy rozumieć jako opis zbioru wszystkich tych typów, które możemy uzyskać podstawiając w za 'a,'b,...,'g (równocześnie) dowolne typy. Silne zmienne typowe są też nazywane polimorficznymi, gdyż opisują to w jaki sposób wartości polimorficzne mogą być wielu typów.
Słabe zmienne typowe zostały wprowadzone ze względu na problemy na styku konstrukcji imperatywnych i polimorfizmu. Słaba zmienna typowa opisuje typ, który nie do końca jest znany. W szczególności, przy inkrementacyjnym trybie pracy z kompilatorem, słabe zmienne typowe mogą w trakcie interakcji być zastępowane konkretniejszymi typami. Tak więc nie opisują one polimorfizmu, lecz nie do końca rozpoznany monomorfizm. Nie mogą również być kwantyfikowane w schematach typów. Są one też nazywane monomorficznymi zmiennymi typowymi. Zajmiemy się dokładnie nimi dalej, gdy będziemy omawiać problemy na styku polimorfizmu i konstrukcji imperatywnych.
Będzie nam jeszcze potrzebne pojęcie środowiska typującego. Środowisko typujące, to skończona funkcja (mapa) przypisująca identyfikatorom typy (proste, lub schematy, w zależności od potrzeby). Przechowuje ono informacje potrzebne kompilatorowi do wyznaczania typów. Zakładamy istnienie takiego środowiska typującego wszystkie stałe i konstruktory wbudowane w język. Oczywiście typy tych stałych i konstruktorów muszą być zgodne z ich arnościami.
Niech oznacza środowisko typujące. Przez będziemy oznaczać środowisko identyczne z , z tym wyjątkiem, że nazwie przyporządkowuje typ . Typowanie wyrażeń w programie będziemy oznaczali przez stwierdzenia postaci: , co można rozumieć następująco: "zakładając typowania ze środowiska możemy stwierdzić, że jest typu .
Reguły typowania dla prostych typów
Sposób wnioskowania o typach możemy przedstawić w postaci reguł, podobnych do regół dowodzenia w systemie dowodzenia. Nad poziomą kreską wymieniamy wszystkie przesłanki, a pod kreską wynikający z nich wniosek. W naszym przypadku, takim wnioskiem nie będzie formuła logiczna, ale stwierdzenie dotyczące typowania.
Oto podstawowe reguły opisujące typowanie wyrażeń. Ograniczają się one do typów monomorficznych. Nie obejmują one polimorfizmu, rekurencji i konstrukcji imperatywnych. Tak więc chwilowo pomijamy (słabe i silne) zmienne typowe.
Definicje lokalne let-in traktujemy tu jak lukier syntaktyczny, tzn. wyrażenia postaci let in rozwijamy do (function -> )
Można wywnioskować wszystkie typy określone w środowisku.
Jeśli znane są typy współrzędnych -ki, to jej typ jest odpowiednim typem produktowym.
Pusta -ka () jest zawsze typu unit.
W wyrażeniu warunkowym if-then-else pierwszy człon musi być typu bool, a dwa pozostałe muszą być tego samego typu. Taki też jest typ całego wyrażenia.
Jeśli znany jest typ procedury i typ argumentu pasuje do typu procedury, to wynik zastosowania procedury do argumentu ma taki typ, jak to wynika z typu procedury.
Jeżeli, przy założeniu, że parametr -abstrakcji jest typu , można pokazać, że wynik -abstrakcji jest typu , to sama -abstrakcja jest typu .
Jeżeli możemy wywnioskować jaki jest typ wartości definiowanej lokalnie, to typ tej wartości możemy umieścić w środowisku typującym i o typie wyrażenia let-in wnioskować na podstawie tak wzbogaconego środowiska i drugiego członu wyrażenia.
Nawiasy nie zmieniają typowania.
Przykład Typowanie
Oto wywód tego, że:
let id = (function x -> x) in id 42
jest typu int.
W powyższym typowaniu procedura id miała typ int -> int, choć mogłaby mieć dowolny typ pasujący do schematu . Jednak jakiego byśmy typu dla id nie przyjeli, to wszystkie wystąpienia tej procedury po prawej stronie in muszą mieć ten sam typ. Tak więc, np. dla wyrażenia:
let id = (fun x -> x) in id id
nie da się wyznaczyć żadnego typu. Żeby było to możliwe, potrzebne będzie wprowadzenie schematów typów i polimorfizmu.
Polimorfizm
Chcąc wyrazić, że jakieś wyrażenie (np. id w poprzednim przykładzie) może przyjmować różne typy w różnych wystąpieniach, musimy użyć schematów typów. Wprowadzamy więc schematy typów i silne zmienne typowe. Pomijamy jednak nadal kwestię słabych zmiennych typowych. Schemat typu postaci 'a,'b,...,'g. reprezentuje zbiór wszystkich tych typów prostych, które możemy uzyskać podstawiając za 'a,'b,...,'g (równocześnie) w dowolne typy proste.
Taki schemat powinien opisywać wszystkie możliwe typy wartości polimorficznej i faktycznie jest to możliwe. Równocześnie powinniśmy być w stanie ukonkretniać taki schemat typu na różne sposoby.
Fakt
Potrzebujemy dwóch reguł, które by rozszerzyły dotychczasowe reguły o polimorfizm. Jednej, która by wprowadzała schematy typów do środowisk typujących i jednej, która by je interpretowała.
Przez oznaczamy ziór zmiennych typowych występujących w i nie związanych pod kwantyfikatorem, a przez oznaczamy ziór zmiennych typowych, które są wolne w którymkolwiek typie przypisywanym przez . Ponadto, przez oznaczamy tu wektor silnych zmiennych typowych, przez oznaczamy tu (tej samej długości) wektor typów prostych, a przez oznaczamy typ powstały przez równoczesne zastąpienie zmiennych z przez odpowiadające im typy z .
Pierwsza reguła mówi, że zmienne kwantyfikowane możemy zastąpić dowolnymi typami, i to za każdym razem innymi. Druga reguła mówi, że jeżeli potrafimy wyprowadzić dla wartości symbolu definiowanego lokalnie typ zawierający wolne zmienne typowe i to niezależne od ew. wolnych zmiennych typowych występujących w środowisku, to możemy je kwantyfikować.
Przykład Polimorfizm
Poniższe typowanie pokazuje, że wyrażenie:
let id = (function x -> x) in id id
jest typu .
Zauważmy, że mamy polimorficzną regułę dla wyrażeń let-in, ale nie mamy polimorficznej reguły dla -abstrakcji i aplikacji funkcji. Tak więc wyrażenie let-in nie jest dokładnie lukrem syntaktycznym pokrywającym -abstrakcję. Weźmy wyrażenie:
(function id -> id id)(function x -> x)
Nie da się wyznaczyć typu dla tego wyrażenia typu. Dwie reguły podane w tym punkcie nie mają zastosowania, gdyż nie jest to wyrażenie let-in.
Rekurencja
Jak możemy wnioskować o typie procedur rekurencyjnych? Sięgnijmy po technikę podobną do prezentowanej wcześniej. Definicję rekurencyjną możemy wyrazić jako punkt stały. Załóżmy, że środowisko typujące zawiera operator punktu stałego:
Definicję rekurencyjną postaci:
zamieniamy na:
Zwróćmy uwagę, że wewnątrz definicji f funkcja ta niejest polimorficzna. Może się taka stać dopiero po prawej stronie in.
Wyjątki
Uproszczona wersja --- nie wnikamy we wzorce.
Konstrukcje imperatywne i słabe zmienne typowe
Dla sekwencji instrukcji imperatywnych mamy regułę:
Gdyz tak naprawdę ; możemy zdefiniować jako:
let (;) x y = y;;
Gdzie jest problem?
let r = ref (function x -> x);;
Powinniśmy być w stanie pokazać, że:
Jeśli się zastanowimy jakiego typu są := i !, to jedyne sensowne typy, to:
Czyli, np.:
To jednak nie ma sensu!
Rozwiązaniem są tzw. słabe zmienne typowe, oznaczane jako :
- Słabe zmienne typowe nie są kwantyfikowane.
- Za słabe zmienne typowe (w trakcie rekonstrukcji typów) można podstawić typy proste zawierające tylko słabe zmienne typowe.
- Każda próba podstawienia za słabą zmienną typową jakiegoś typu powoduje, że na stałe zostaje ona zastąpiona tym typem. (Top level.)
- Uwaga: Kompilator może nazywać różne słabe zmienne typowe tak samo. Trzeba sobie zdawać sprawę kiedy dwie zmienne typowe są różne.
- Przy deklaracji let, typ stałej jest kwantyfikowany tylko wtedy, gdy wyrażenie definiujące stałą nie jest ekspansywne.
- Referencje i aplikacje są ekspansywne.
let r = ref (function x -> x);; val r : ('_a -> '_a) ref = {contents = <fun>}r := (function x -> 42);; r : (int -> int) ref let id = (function x -> x);; val id : 'a -> 'a = <fun> let idd = id id;; val idd : '_a -> '_a = <fun> let idd x = id id x;; val idd : 'a -> 'a = <fun> let p = id id;; val p : '_a -> '_a let r = ref p;; val r : ('_a -> '_a) ref p 42;; p: int -> int'r: (int -> int) ref let r = ref id;; val r : ('_a -> '_a) ref let q = ref id;; val q : ('_a -> '_a) ref !r 42 r: (int -> int) ref val q : ('_a -> '_a) ref let r = ref id;; val r : ('_a -> '_a) ref let q = ref (!r);; val q : ('_a -> '_a) ref !r 42;; r: (int -> int) ref q: (int -> int) ref let f x = let r = ref id in (r := compose !r !r; !r x);; val f : 'a -> 'a f 42;; = 42 f true;; = true let g = let r = ref id in (r := compose !r !r; !r);; val g : '_a -> '_a g 42;; = 42 g : int -> int let f x = let r = ref x in (function y -> r := compose !r !r; !r y);; [.....] let f x = let r = ref x in (function y -> r := y; !r);; val f : 'a -> 'a -> 'a
Jeśli zdefiniujemy procedurę, której typ zawiera słabe zmienne typowe, a chcemy, żeby miała silne, to możemy zastosować -ekspansję, tzn.\ dodać formalny argument procedury. Jeżeli nie ma (imperatywnych) przeciwwskazań, to uzyskamy procedurę równoważną.