Zależnie wpisany Haskell, teraz?
Haskell jest w niewielkim stopniu językiem zależnym od typografii. Istnieje pojęcie danych na poziomie typu, teraz dzięki bardziej rozsądnemu typowaniu DataKinds
, i są pewne środki ( GADTs
), aby nadać reprezentację danych na poziomie typu w czasie wykonywania. W związku z tym wartości elementów czasu wykonywania skutecznie pojawiają się w typach , co oznacza, że język jest wpisywany zależnie.
Proste typy danych są promowane do poziomu rodzaju, dzięki czemu wartości, które zawierają, mogą być używane w typach. Stąd archetypowy przykład
data Nat = Z | S Nat
data Vec :: Nat -> * -> * where
VNil :: Vec Z x
VCons :: x -> Vec n x -> Vec (S n) x
staje się możliwe, a wraz z nim definicje takie jak
vApply :: Vec n (s -> t) -> Vec n s -> Vec n t
vApply VNil VNil = VNil
vApply (VCons f fs) (VCons s ss) = VCons (f s) (vApply fs ss)
co jest miłe. Zauważ, że długość n
jest czysto statyczną rzeczą w tej funkcji, zapewniając, że wektory wejściowe i wyjściowe mają tę samą długość, nawet jeśli ta długość nie odgrywa żadnej roli w wykonaniu
vApply
. Natomiast to o wiele trudniejsze (czyli niemożliwe) do realizacji funkcji, które sprawia, że n
kopie dana x
(co byłoby pure
do vApply
„s <*>
)
vReplicate :: x -> Vec n x
ponieważ ważne jest, aby wiedzieć, ile kopii wykonać w czasie wykonywania. Wpisz singletony.
data Natty :: Nat -> * where
Zy :: Natty Z
Sy :: Natty n -> Natty (S n)
Dla dowolnego typu promowanego możemy zbudować rodzinę singletonów, indeksowaną na typie promowanym, zamieszkałą przez duplikaty jej wartości w czasie wykonywania. Natty n
jest typem kopii wykonawczych poziomu typu n
:: Nat
. Teraz możemy pisać
vReplicate :: Natty n -> x -> Vec n x
vReplicate Zy x = VNil
vReplicate (Sy n) x = VCons x (vReplicate n x)
Mamy więc wartość na poziomie typu połączoną z wartością czasu wykonywania: inspekcja kopii wykonawczej poprawia statyczną wiedzę o wartości na poziomie typu. Mimo że terminy i typy są rozdzielone, możemy pracować w sposób zależny od typu, używając konstrukcji singleton jako rodzaju żywicy epoksydowej, tworząc wiązania między fazami. To daleko od dopuszczenia dowolnych wyrażeń w czasie wykonywania w typach, ale to nie jest nic.
Co jest paskudne? Czego brakuje?
Naciskajmy na tę technologię i zobaczmy, co zaczyna się chwiać. Można by pomyśleć, że singletonami powinny być zarządzalne w nieco bardziej niejawny sposób
class Nattily (n :: Nat) where
natty :: Natty n
instance Nattily Z where
natty = Zy
instance Nattily n => Nattily (S n) where
natty = Sy natty
pozwalając nam pisać, powiedzmy,
instance Nattily n => Applicative (Vec n) where
pure = vReplicate natty
(<*>) = vApply
To działa, ale teraz oznacza, że nasz oryginalny Nat
typ zrodził trzy kopie: rodzaj, rodzina singletonów i klasa singleton. Mamy dość niezgrabny proces wymiany jawnych Natty n
wartości i Nattily n
słowników. Co więcej, Natty
nie jest Nat
: mamy pewien rodzaj zależności od wartości czasu wykonywania, ale nie takiego, o jakim myśleliśmy na początku. Żaden w pełni zależnie typowany język nie sprawia, że typy zależne są tak skomplikowane!
Tymczasem, choć Nat
można awansować, Vec
nie można. Nie możesz indeksować według typu indeksowanego. Pełne języków zależnie wpisywanych na maszynie nie narzucają żadnych ograniczeń, aw mojej karierze jako popisywanie się zależnie od maszynistki nauczyłem się uwzględniać przykłady indeksowania dwuwarstwowego w moich przemówieniach, aby uczyć ludzi, którzy zrobili indeksowanie jednowarstwowe Trudno, ale można nie oczekiwać, że złożę się jak domek z kart. Jaki jest problem? Równość. GADT działają, tłumacząc ograniczenia, które uzyskujesz niejawnie, gdy konstruktorowi nadasz określony typ zwracany na jawne wymagania równań. Lubię to.
data Vec (n :: Nat) (x :: *)
= n ~ Z => VNil
| forall m. n ~ S m => VCons x (Vec m x)
W każdym z naszych dwóch równań obie strony mają życzliwość Nat
.
Teraz spróbuj tego samego tłumaczenia dla czegoś indeksowanego na wektorach.
data InVec :: x -> Vec n x -> * where
Here :: InVec z (VCons z zs)
After :: InVec z ys -> InVec z (VCons y ys)
staje się
data InVec (a :: x) (as :: Vec n x)
= forall m z (zs :: Vec x m). (n ~ S m, as ~ VCons z zs) => Here
| forall m y z (ys :: Vec x m). (n ~ S m, as ~ VCons y ys) => After (InVec z ys)
a teraz tworzymy ograniczenia równościowe pomiędzy as :: Vec n x
i
VCons z zs :: Vec (S m) x
gdzie obie strony mają różne składniowo (ale dające się udowodnić równe) rodzaje. Rdzeń GHC nie jest obecnie wyposażony w taką koncepcję!
Czego jeszcze brakuje? Cóż, większości Haskell brakuje na poziomie typu. Język terminów, które możesz promować, zawiera tylko zmienne i konstruktory spoza GADT. Gdy już je masz, type family
maszyna pozwala na pisanie programów na poziomie typu: niektóre z nich mogą być całkiem podobne do funkcji, które rozważałbyś napisanie na poziomie terminu (np. Wyposażenie Nat
w dodawanie, więc możesz podać dobry typ do dołączenia Vec
) , ale to tylko zbieg okoliczności!
Inną rzeczą, której brakuje w praktyce, jest biblioteka, która wykorzystuje nasze nowe możliwości do indeksowania typów według wartości. Co robić Functor
i czym Monad
się stać w tym nowym, wspaniałym świecie? Myślę o tym, ale wciąż jest dużo do zrobienia.
Uruchamianie programów na poziomie typu
Haskell, podobnie jak większość języków programowania o typach zależnych, ma dwie
operacyjne semantyki. Istnieje sposób, w jaki system czasu wykonywania uruchamia programy (tylko wyrażenia zamknięte, po usunięciu typu, wysoce zoptymalizowany), a także sposób, w jaki narzędzie do sprawdzania typów uruchamia programy (rodziny typów, „Prolog klasy typów” z otwartymi wyrażeniami). Dla Haskella normalnie nie pomieszasz tych dwóch, ponieważ wykonywane programy są w różnych językach. Języki z typami zależnymi mają oddzielne modele wykonawcze i statyczne dla tego samego języka programów, ale nie martw się, model czasu wykonywania nadal pozwala na wymazywanie typów i, w rzeczy samej , wymazywanie dowodów: to właśnie jest ekstrakcja Coqamechanizm daje; przynajmniej to robi kompilator Edwina Brady'ego (chociaż Edwin usuwa niepotrzebnie zduplikowane wartości, a także typy i dowody). Rozróżnienie faz może już nie być rozróżnieniem na kategorię syntaktyczną
, ale jest żywe i ma się dobrze.
Języki zależnie wpisane na maszynie, będąc całkowitymi, pozwalają sprawdzaczowi typów na uruchamianie programów bez strachu przed czymś gorszym niż długie oczekiwanie. W miarę jak Haskell staje się coraz bardziej zależny, stajemy przed pytaniem, jaki powinien być jego statyczny model wykonania? Jednym podejściem mogłoby być ograniczenie wykonywania statycznego do funkcji całkowitych, co dałoby nam taką samą swobodę działania, ale mogłoby zmusić nas do rozróżnienia (przynajmniej w przypadku kodu na poziomie typu) między danymi a kodowanymi, abyśmy mogli stwierdzić, czy należy wymuszać wypowiedzenie lub produktywność. Ale to nie jedyne podejście. Możemy wybrać znacznie słabszy model wykonania, który niechętnie uruchamia programy, kosztem wykonywania mniejszej liczby równań tylko przez obliczenia. W efekcie to właśnie robi GHC. Zasady typowania dla rdzenia GHC nie wspominają o uruchamianiu
programy, ale tylko do sprawdzania dowodów dla równań. Podczas tłumaczenia do rdzenia, solver ograniczeń GHC próbuje uruchomić programy na poziomie typu, generując mały srebrzysty ślad dowodu, że dane wyrażenie jest równe swojej normalnej formie. Ta metoda generowania dowodów jest trochę nieprzewidywalna i nieuchronnie niekompletna: zwalcza na przykład przerażająco wyglądającą rekurencję i jest to prawdopodobnie rozsądne. Jedną rzeczą, o którą nie musimy się martwić, jest wykonywanie IO
obliczeń w narzędziu do sprawdzania typów: pamiętaj, że sprawdzanie typów nie musi nadawać
launchMissiles
tego samego znaczenia, co system wykonawczy!
Kultura Hindley-Milner
System typu Hindley-Milner osiąga naprawdę niesamowity zbieg okoliczności czterech odrębnych rozróżnień, z niefortunnym kulturowym efektem ubocznym, że wielu ludzi nie widzi różnicy między różnicami i zakłada, że zbieg okoliczności jest nieunikniony! O czym ja mówię?
- terminy a typy
- wyraźnie napisane rzeczy vs niejawnie pisanych rzeczy
- obecność w czasie wykonywania vs wymazywanie przed uruchomieniem
- niezależna abstrakcja a kwantyfikacja zależna
Jesteśmy przyzwyczajeni do pisania terminów i pozostawiania typów do wywnioskowania ... a następnie wymazania. Jesteśmy przyzwyczajeni do ilościowego określania zmiennych typu z odpowiednią abstrakcją typu i aplikacją działającą cicho i statycznie.
Nie musisz zbytnio oddalać się od waniliowej Hindley-Milner, zanim te rozróżnienia przestaną być wyrównane, a to nie jest złe . Na początek możemy mieć ciekawsze typy, jeśli zechcemy je napisać w kilku miejscach. W międzyczasie nie musimy pisać słowników klas typów, gdy używamy przeciążonych funkcji, ale te słowniki są z pewnością obecne (lub wbudowane) w czasie wykonywania. W językach z typami zależnymi spodziewamy się wymazania więcej niż tylko typów w czasie wykonywania, ale (tak jak w przypadku klas typów), że niektóre niejawnie wywnioskowane wartości nie zostaną usunięte. Np. vReplicate
Argument numeryczny często można wywnioskować z typu żądanego wektora, ale nadal musimy go znać w czasie wykonywania.
Które wybory językowe powinniśmy przeanalizować, ponieważ te zbiegi okoliczności już nie istnieją? Np. Czy to prawda, że Haskell nie daje możliwości forall x. t
jawnego utworzenia instancji kwantyfikatora? Jeśli kontroler typu nie może odgadnąć x
przez ujednolicenie t
, nie mamy innego sposobu, aby powiedzieć, co x
musi być.
Szerzej, nie możemy traktować „wnioskowania o typie” jako monolitycznej koncepcji, z której mamy wszystko albo nic. Na początek musimy oddzielić aspekt „uogólnienia” (reguła Milnera „pozwól”), który polega w dużej mierze na ograniczaniu istniejących typów, aby zapewnić, że głupia maszyna może je odgadnąć, od aspektu „specjalizacji” (zasada Milnera „var „reguła”), która jest tak samo skuteczna, jak narzędzie do rozwiązywania ograniczeń. Możemy spodziewać się, że typy najwyższego poziomu będą trudniejsze do wywnioskowania, ale informacje o typie wewnętrznym pozostaną dość łatwe do rozpowszechnienia.
Dalsze kroki dla Haskella
Widzimy, że typy i poziomy rodzaju rosną do siebie bardzo podobnie (i mają już wewnętrzną reprezentację w GHC). Równie dobrze możemy je połączyć. Byłoby fajnie * :: *
, gdybyśmy mogli: straciliśmy
logiczną solidność dawno temu, kiedy pozwoliliśmy na dno, ale
solidność typu jest zwykle słabszym wymaganiem. Musimy sprawdzić. Jeśli musimy mieć różne poziomy typu, rodzaju itp., Możemy przynajmniej upewnić się, że wszystko na poziomie typu i wyższym zawsze może być promowane. Byłoby wspaniale po prostu ponownie wykorzystać polimorfizm, który już mamy dla typów, zamiast ponownie wymyślać polimorfizm na poziomie rodzaju.
Powinniśmy uprościć i uogólnić obecny system ograniczeń, dopuszczając niejednorodne równania, w a ~ b
których rodzaje a
i
b
nie są syntaktycznie identyczne (ale można udowodnić, że są równe). To stara technika (w mojej pracy magisterskiej z ubiegłego wieku), która znacznie ułatwia radzenie sobie z uzależnieniem. Moglibyśmy wyrazić ograniczenia w wyrażeniach w GADT, a tym samym złagodzić ograniczenia dotyczące tego, co można promować.
Powinniśmy wyeliminować potrzebę budowy singleton przez wprowadzenie zależnego od typu funkcyjnego pi x :: s -> t
. Funkcja z takim typem może być jawnie zastosowana do dowolnego wyrażenia typu, s
które znajduje się na przecięciu języka typu i terminów (a więc zmienne, konstruktory, a więcej w przyszłości). Odpowiednia lambda i aplikacja nie zostałyby usunięte w czasie wykonywania, więc moglibyśmy pisać
vReplicate :: pi n :: Nat -> x -> Vec n x
vReplicate Z x = VNil
vReplicate (S n) x = VCons x (vReplicate n x)
bez wymiany Nat
przez Natty
. Domena pi
może być dowolnego typu, który można promować, więc jeśli można promować GADT, możemy pisać zależne sekwencje kwantyfikatorów (lub „teleskopy”, jak je nazwał de Briuijn)
pi n :: Nat -> pi xs :: Vec n x -> ...
na dowolną długość, jakiej potrzebujemy.
Celem tych kroków jest wyeliminowanie złożoności poprzez bezpośrednią pracę z bardziej ogólnymi narzędziami, zamiast zadowalać się słabymi narzędziami i niezgrabnym kodowaniem. Obecne częściowe poparcie sprawia, że korzyści wynikające z zależnych typów Haskella są droższe, niż powinny.
Zbyt trudne?
Typy zależne denerwują wielu ludzi. Denerwują mnie, ale lubię się denerwować, a przynajmniej trudno mi się nie denerwować. Ale to nie pomaga, że wokół tematu jest taka mgła ignorancji. Po części wynika to z faktu, że wszyscy wciąż musimy się wiele nauczyć. Wiadomo jednak, że zwolennicy mniej radykalnych podejść budzą strach przed typami zależnymi, nie zawsze jednak upewniając się, że fakty są całkowicie zgodne z nimi. Nie będę wymieniać imion. Te „nierozstrzygalne sprawdzanie typów”, „niekompletne Turinga”, „brak rozróżnienia fazowego”, „brak wymazywania typu”, „wszędzie dowody” itd., Utrzymują się, mimo że są śmieciami.
Z pewnością nie jest tak, że programy napisane zależnie muszą zawsze być poprawne. Można poprawić podstawową higienę swoich programów, wymuszając dodatkowe niezmienniki w typach bez konieczności przechodzenia do pełnej specyfikacji. Małe kroki w tym kierunku dość często skutkują znacznie silniejszymi gwarancjami z niewielkimi lub żadnymi dodatkowymi zobowiązaniami dowodowymi. Nie jest prawdą, że programy napisane na typie zależnym są nieuchronnie pełne dowodów, w istocie zazwyczaj biorę obecność jakichkolwiek dowodów w moim kodzie jako wskazówkę do zakwestionowania moich definicji .
Podobnie jak w przypadku każdego wzrostu artykulacji, możemy swobodnie mówić zarówno ohydne, jak i nowe rzeczy. Na przykład istnieje wiele kiepskich sposobów definiowania drzew wyszukiwania binarnego, ale to nie znaczy, że nie jest to dobry sposób . Ważne jest, aby nie zakładać, że złych doświadczeń nie da się poprawić, nawet jeśli przyznanie się do tego wypacza ego. Projektowanie definicji zależnych to nowa umiejętność, która wymaga uczenia się, a bycie programistą Haskell nie czyni cię automatycznie ekspertem! A nawet jeśli niektóre programy są obrzydliwe, dlaczego miałbyś odmawiać innym wolności do bycia sprawiedliwym?
Dlaczego nadal zawracać sobie głowę Haskellem?
Naprawdę lubię typy zależne, ale większość moich projektów hakerskich wciąż jest w Haskell. Czemu? Haskell ma klasy typów. Haskell ma przydatne biblioteki. Haskell ma praktyczne (choć dalekie od ideału) podejście do programowania z efektami. Haskell ma kompilator siły przemysłowej. Języki z typami zależnymi są na znacznie wcześniejszym etapie rozwoju społeczności i infrastruktury, ale dojdziemy do tego z prawdziwą zmianą pokoleniową w tym, co jest możliwe, np. Za pomocą metaprogramowania i typów generycznych. Ale wystarczy rozejrzeć się wokół tego, co ludzie robią w wyniku kroków Haskella w kierunku typów zależnych, aby zobaczyć, że można uzyskać wiele korzyści, popychając również obecną generację języków do przodu.