Czy polimorfizm typu zwrotnego (tylko) w Haskell jest dobrą rzeczą?


29

Jedną z rzeczy, z którą nigdy nie do końca się pogodziłem w Haskell, jest to, w jaki sposób można uzyskać stałe polimorficzne i funkcje, których typu zwrotu nie można określić na podstawie ich typu wejściowego, np.

class Foo a where
    foo::Int -> a

Niektóre z powodów, że mi się nie podoba:

Referencyjna przejrzystość:

„W Haskell, przy tym samym wejściu, funkcja zawsze zwróci to samo wyjście”, ale czy to naprawdę prawda? read "3"zwraca 3, gdy jest używany w Intkontekście, ale generuje błąd, gdy jest używany w, powiedzmy, (Int,Int)kontekście. Tak, możesz argumentować, że readbierze również parametr type, ale niejawność parametru type powoduje, że moim zdaniem traci on trochę swojego piękna.

Ograniczenie monomorfizmu:

Jedna z najbardziej irytujących rzeczy w Haskell. Popraw mnie, jeśli się mylę, ale głównym powodem MR jest to, że obliczenia, które wyglądają na współużytkowane, mogą nie wynikać z tego, że parametr type jest niejawny.

Typ domyślny:

Znów jedna z najbardziej irytujących rzeczy w Haskell. Zdarza się np. Jeśli przekazujesz wynik funkcji polimorficznych na wyjściu do funkcji polimorficznych na ich wejściu. Ponownie popraw mnie, jeśli się mylę, ale nie byłoby to konieczne bez funkcji, których typu zwrotu nie można określić na podstawie ich typu wejściowego (i stałych polimorficznych).

Więc moje pytanie brzmi (ryzykując, że zostaniemy ostemplowani jako „pytanie do dyskusji”): Czy byłoby możliwe stworzenie języka podobnego do Haskella, w którym moduł sprawdzania typów nie zezwala na tego rodzaju definicje? Jeśli tak, jakie byłyby zalety / wady tego ograniczenia?

Widzę kilka bezpośrednich problemów:

Jeśli, powiedzmy, 2miałby tylko typ Integer, 2/3nie chciałbym już sprawdzać typu z obecną definicją /. Ale w tym przypadku uważam, że na ratunek mogą przyjść klasy typu z zależnościami funkcjonalnymi (tak, wiem, że jest to rozszerzenie). Co więcej, myślę, że o wiele bardziej intuicyjne jest posiadanie funkcji, które mogą przyjmować różne typy danych wejściowych, niż posiadanie funkcji, które są ograniczone w typach danych wejściowych, ale po prostu przekazujemy im wartości polimorficzne.

Wpisywanie wartości takich jak []i Nothingwydaje mi się trudniejsze do złamania. Nie pomyślałem o dobrym sposobie radzenia sobie z nimi.

Odpowiedzi:


37

Myślę, że polimorfizm typu zwracanego jest jedną z najlepszych cech klas typów. Po dłuższym użyciu czasami ciężko mi wrócić do modelowania w stylu OOP, w którym go nie mam.

Rozważ kodowanie algebry. W Haskell mamy klasę typów Monoid(ignorowanie mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Jak możemy zakodować to jako interfejs w języku OO? Krótka odpowiedź brzmi: nie możemy. To dlatego, że typem memptyjest (Monoid a) => aaka, polimorfizm typu zwracanego. Możliwość modelowania algebry jest niezwykle przydatna w IMO. *

Zaczynasz swój post od skargi dotyczącej „Przejrzystości referencyjnej”. Rodzi to ważną kwestię: Haskell jest językiem zorientowanym na wartości. Wyrażenia takie jak read 3nie muszą być rozumiane jako rzeczy, które obliczają wartości, mogą być również rozumiane jako wartości. Oznacza to, że prawdziwym problemem nie jest polimorfizm typu powrotu: są to wartości typu polimorficznego ( []i Nothing). Jeśli język powinien je mieć, to tak naprawdę musi mieć polimorficzne typy zwracania dla zachowania spójności.

Czy powinniśmy być w stanie powiedzieć, że []jest typu forall a. [a]? Chyba tak. Te funkcje są bardzo przydatne i znacznie upraszczają język.

Gdyby Haskell miał podtyp, polimorfizm []mógłby być podtypem dla wszystkich [a]. Problem polega na tym, że nie znam sposobu kodowania, który bez typu pustej listy byłby polimorficzny. Zastanów się, jak można to zrobić w Scali (jest to krótsze niż w kanonicznym języku OOP o typie statycznym, Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Nawet tutaj Nil()jest obiektem typu Nil[A]**

Kolejną zaletą polimorfizmu typu powrotu jest to, że sprawia, że ​​osadzanie Curry-Howarda jest znacznie prostsze.

Rozważ następujące logiczne twierdzenia:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Możemy w prosty sposób uchwycić je jako twierdzenia w Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Podsumowując: lubię polimorfizm typu zwracanego i uważam, że łamie on przejrzystość referencyjną, jeśli masz ograniczone pojęcie wartości (chociaż jest to mniej przekonujące w przypadku klasy typu ad hoc). Z drugiej strony, znajduję twoje uwagi na temat MR i wpisuję domyślnie przekonujące.


*. W komentarzach ysdx wskazuje, że nie jest to do końca prawdą: moglibyśmy ponownie zaimplementować klasy typów, modelując algebrę jako inny typ. Jak java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Następnie musisz przekazać ze sobą obiekty tego typu. Scala ma pojęcie niejawnych parametrów, które pozwalają uniknąć niektórych, ale z mojego doświadczenia nie wszystkich, kosztów związanych z jawnym zarządzaniem tymi rzeczami. Umieszczenie metod użyteczności (metody fabryczne, metody binarne itp.) Na osobnym typie ograniczonym literą F okazuje się być niewiarygodnie dobrym sposobem zarządzania rzeczami w języku OO, który ma wsparcie dla generycznych. To powiedziawszy, nie jestem pewien, czy wymyśliłbym ten wzór, gdybym nie miał doświadczenia w modelowaniu rzeczy za pomocą klas, i nie jestem pewien, czy inni to zrobią.

Ma również ograniczenia, po wyjęciu z pudełka nie ma sposobu na uzyskanie obiektu, który implementuje klasę dla dowolnego typu. Musisz albo przekazać wartości jawnie, użyć czegoś takiego jak implikacje Scali, lub zastosować jakąś formę technologii wstrzykiwania zależności. Życie staje się brzydkie. Z drugiej strony fajnie jest mieć wiele implementacji dla tego samego typu. Coś może być monoidą na wiele sposobów. Ponadto noszenie tych struktur oddzielnie ma dla IMO bardziej matematycznie nowoczesny, konstruktywny charakter. Tak więc, chociaż generalnie wolę sposób Haskella, prawdopodobnie przesadziłem z moją sprawą.

Klasy typów z polimorfizmem typu zwrotnego sprawiają, że jest to łatwe w obsłudze. To nie oznacza, że ​​to najlepszy sposób, aby to zrobić.

**. Jörg W Mittag podkreśla, że tak naprawdę nie jest to kanoniczny sposób robienia tego w Scali. Zamiast tego zastosowalibyśmy standardową bibliotekę z czymś bardziej podobnym do:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Wykorzystuje to wsparcie Scali dla typów dennych, a także paramaterii typu kowariantnego. Tak, Niljest typu Nilnie Nil[A]. W tym momencie jesteśmy dość daleko od Haskell, ale warto zauważyć, że Haskell reprezentuje typ dna

undefined :: forall a. a

Oznacza to, że nie jest podtypem wszystkich typów, jest polimorficznie (sp) członkiem wszystkich typów.
Jeszcze więcej polimorfizmu typu zwrotnego.


4
„Jak możemy zakodować to jako interfejs w języku OO?” Możesz użyć pierwszej klasy algebry: interface Monoid <X> {X empty (); Dołącz X (X, X); } Jednak musisz to przekazać jawnie (odbywa się to za sceną w Haskell / GHC).
ysdx

@ysdx To prawda. A w językach, które obsługują parametry niejawne, otrzymujesz coś bardzo zbliżonego do klas typów haskell (jak w Scali). Byłem tego świadomy. Chodzi mi jednak o to, że to czyni życie bardzo trudnym: muszę używać pojemników, które przechowują „typeclass” w każdym miejscu (fuj!). Mimo to prawdopodobnie powinienem był mniej hiperboliczny w swojej odpowiedzi.
Philip JF,

+1, świetna odpowiedź. Jedna nieistotna nitpick: Nilprawdopodobnie powinna być, a case objectnie case class.
Jörg W Mittag,

@ Jörg W Mittag Nie jest to całkowicie bez znaczenia. Zredagowałem, aby poprawić twój komentarz.
Philip JF,

1
Dziękuję za bardzo miłą odpowiedź. Prawdopodobnie zajmie mi to trochę czasu, aby to przetrawić / zrozumieć.
dainichi

12

Wystarczy zauważyć błędne przekonanie:

„W Haskell, przy takim samym wejściu, funkcja zawsze zwróci to samo wyjście”, ale czy to naprawdę prawda? odczyt „3” zwraca 3, gdy jest używany w kontekście Int, ale generuje błąd, gdy jest używany w kontekście powiedzmy (Int, Int).

To, że moja żona jeździ Subaru, a ja jeżdżę Subaru, nie oznacza, że ​​jedziemy tym samym samochodem. To, że nazywa się 2 funkcje read, nie oznacza, że ​​jest to ta sama funkcja. Rzeczywiście read :: String -> Intjest zdefiniowany w instancji Read Int, gdzie read :: String (Int, Int)jest zdefiniowany w instancji Read (Int, Int). Dlatego są to zupełnie różne funkcje.

Zjawisko to jest powszechne w językach programowania i zwykle nazywa się przeciążeniem .


6
Myślę, że trochę przegapiłeś sedno pytania. W większości języków, w których występuje polimorfizm ad-hoc, czyli przeciążenie, wybór funkcji do wywołania zależy tylko od typów parametrów, a nie od typu zwracanego. Ułatwia to rozumowanie znaczenia wywołań funkcji: po prostu zacznij od liści drzewa wyrażeń i idź „w górę”. W Haskell (i niewielkiej liczbie innych języków, które obsługują przeciążenie typu powrotu) potencjalnie musisz wziąć pod uwagę całe drzewo wyrażeń naraz, nawet aby zrozumieć znaczenie małego podwyrażenia.
Laurence Gonsalves,

1
Myślę, że idealnie trafiłeś w sedno pytania. Nawet Szekspir powiedział: „Funkcja o dowolnej nazwie mogłaby równie dobrze funkcjonować”. +1
Thomas Eding

@Laurence Gonsalves - Wnioskowanie o typach w Haskell nie jest referencyjnie przezroczyste. Znaczenie kodu może zależeć od kontekstu, w którym jest używany, ze względu na wnioskowanie o typie ciągnące informacje do wewnątrz. Nie ogranicza się to do problemów związanych ze zwrotem. Haskell skutecznie ma Prolog wbudowany w swój system typów. Może to sprawić, że kod będzie mniej wyraźny, ale ma również duże zalety. Osobiście uważam, że rodzaj przejrzystości odniesienia, który ma największe znaczenie, to to, co dzieje się w czasie wykonywania, ponieważ bez niego lenistwo byłoby niemożliwe.
Steve314,

@ Steve314 Wydaje mi się, że jeszcze nie widziałem sytuacji, w której brak wnioskowania o przezroczystości referencyjnej nie powoduje, że kod jest mniej przejrzysty. Aby rozumować coś złożonego, trzeba umieć mentalnie „porcjować” rzeczy. Jeśli powiesz mi, że masz kota, nie myślę o chmurze miliardów atomów lub pojedynczych komórek. Podobnie podczas odczytywania wyrażeń partycjonujących kod I do ich podwyrażeń. Haskell pokonuje to na dwa sposoby: wnioskowanie typu „w niewłaściwy sposób” i nadmiernie skomplikowane przeciążenie operatora. Społeczność Haskell ma również niechęć do parens, co jeszcze bardziej go pogarsza.
Laurence Gonsalves

1
@LaurenceGonsalves Masz rację, że ta infixfunkcja może być nadużywana. Ale to jest zatem awaria użytkowników. OTOH, restrykcyjność, podobnie jak w Javie, nie jest właściwą drogą. Aby to zobaczyć, nie szukaj dalej niż jakiś kod zajmujący się BigIntegers.
Ingo

7

Naprawdę chciałbym, aby nigdy nie powstał termin „polimorfizm typu powrotnego”. Zachęca do niezrozumienia tego, co się dzieje. Wystarczy powiedzieć, że eliminacja „polimorfizmu typu powrotu” byłaby niezwykle doraźną i zabójczą ekspresją, nie rozwiązałaby nawet problemów poruszonych w pytaniu.

Typ zwrotu nie jest w żaden sposób wyjątkowy. Rozważać:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Ten kod powoduje wszystkie te same problemy, co „polimorfizm typu zwracanego” i wykazuje te same różnice w stosunku do OOP. Nikt jednak nie mówi o „pierwszym argumencie Może wpisz polimorfizm”.

Kluczową ideą jest to, że implementacja używa typów w celu rozstrzygnięcia, której instancji użyć. Jakiekolwiek wartości (wykonawcze) nie mają z tym nic wspólnego. Rzeczywiście działałoby to nawet dla typów, które nie mają wartości. W szczególności programy Haskell nie mają znaczenia bez ich typów. (Jak na ironię, to sprawia, że ​​Haskell jest językiem kościelnym w przeciwieństwie do języka curry. Mam artykuł na blogu w pracach na ten temat.)


„Ten kod powoduje wszystkie te same problemy, co„ polimorfizm typu zwrotnego ””. Nie, nie ma. Mogę spojrzeć na „foo Nic” i ustalić, jaki jest jego typ. To jest Bool. Nie trzeba patrzeć na kontekst.
dainichi

4
W rzeczywistości kod nie sprawdza typu, ponieważ kompilator nie wie, co ajest w przypadku „typu zwracanego”. Ponownie nie ma nic specjalnego w typach zwrotów. Musimy znać rodzaj wszystkich podwyrażeń. Zastanów się let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins,

2
Nie wspominając o „polimorfizmie drugiego argumentu”; funkcją taką Int -> a -> Booljest, curry, faktycznie Int -> (a -> Bool)i proszę bardzo, polimorfizm w wartości zwracanej. Jeśli pozwalasz na to gdziekolwiek, musi być wszędzie.
Ryan Reich

4

Jeśli chodzi o pytanie o przejrzystość referencyjną wartości polimorficznych, oto coś, co może pomóc.

Rozważ nazwę 1 . Często oznacza różne (ale naprawione) obiekty:

  • 1 jak w liczbie całkowitej
  • 1 jak w realu
  • 1 jak w kwadratowej matrycy tożsamości
  • 1 jak w funkcji tożsamości

Tutaj ważne jest, aby pamiętać, że pod każdym kontekście , 1jest wartością stałą. Innymi słowy, każda para nazwa-kontekst oznacza unikalny obiekt. Bez kontekstu nie wiemy, który obiekt oznaczamy. W związku z tym kontekst należy wywnioskować (jeśli to możliwe) lub wyraźnie podać. Miłą zaletą (oprócz wygodnej notacji) jest możliwość wyrażania kodu w ogólnych kontekstach.

Ale ponieważ jest to tylko notacja, moglibyśmy zamiast tego użyć następującej notacji:

  • integer1 jak w liczbie całkowitej
  • real1 jak w realu
  • matrixIdentity1 jak w kwadratowej matrycy tożsamości
  • functionIdentity1 jak w funkcji tożsamości

Tutaj otrzymujemy wyraźne nazwy. To pozwala nam wyprowadzić kontekst tylko z nazwy. Zatem tylko nazwa obiektu jest potrzebna do pełnego oznaczenia obiektu.

Klasy typów Haskell wybrały poprzedni schemat notacji. Teraz jest światło na końcu tunelu:

readjest nazwą, a nie wartością (nie ma kontekstu), ale read :: String -> ajest wartością (ma zarówno nazwę, jak i kontekst). Zatem funkcje read :: String -> Inti read :: String -> (Int, Int)dosłownie różne funkcje. Zatem jeśli nie zgadzają się co do swoich danych wejściowych, przejrzystość referencyjna nie jest naruszana.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.