Klasy typów a interfejsy obiektowe

33

Nie sądzę, że rozumiem klasy typów. Czytałem gdzieś, że myślenie o klasach typów jako „interfejsach” (od OO), które implementuje typ, jest błędne i wprowadza w błąd. Problem polega na tym, że mam problem z postrzeganiem ich jako czegoś innego i jak to jest złe.

Na przykład, jeśli mam klasę typu (w składni Haskell)

class Functor f where
  fmap :: (a -> b) -> f a -> f b

Czym różni się to od interfejsu [1] (w składni Java)

interface Functor<A> {
  <B> Functor<B> fmap(Function<B, A> fn)
}

interface Function<Return, Argument> {
  Return apply(Argument arg);
}

Jedną z możliwych różnic, o których mogę pomyśleć, jest to, że implementacja klasy typu zastosowana przy pewnym wywołaniu nie jest określona, ​​ale raczej określona na podstawie środowiska - powiedzmy, sprawdzając dostępne moduły dla implementacji dla tego typu. Wydaje się, że jest to artefakt implementacyjny, który można rozwiązać w języku OO; tak jak kompilator (lub środowisko wykonawcze) może skanować w poszukiwaniu wrappera / przedłużacza / monkey-patchera, który udostępnia wymagany interfejs dla danego typu.

czego mi brakuje?

[1] Uwaga: f aargument został usunięty, fmapponieważ ponieważ jest to język OO, wywołałbyś tę metodę na obiekcie. Ten interfejs zakłada, że f aargument został naprawiony.

oconnor0
źródło

Odpowiedzi:

46

W swojej podstawowej formie klasy typów są nieco podobne do interfejsów obiektowych. Jednak pod wieloma względami są one znacznie bardziej ogólne.

  1. Wysyłka dotyczy typów, a nie wartości. Nie jest wymagana żadna wartość do jej wykonania. Na przykład możliwe jest wysyłanie według typu wyniku funkcji, tak jak w przypadku Readklasy Haskell :

    class Read a where
      readsPrec :: Int -> String -> [(a, String)]
      ...
    

    Taka wysyłka jest wyraźnie niemożliwa w konwencjonalnym OO.

  2. Klasy typów naturalnie rozciągają się na wiele wysyłek, po prostu przez podanie wielu parametrów:

    class Mul a b c where
      (*) :: a -> b -> c
    
    instance Mul Int Int Int where ...
    instance Mul Int Vec Vec where ...
    instance Mul Vec Vec Int where ...
    
  3. Definicje instancji są niezależne od definicji klas i typów, co czyni je bardziej modułowymi. Typ T z modułu A może być zmodernizowany do klasy C z modułu M2 bez modyfikowania definicji jednego z nich, po prostu poprzez podanie wystąpienia w module M3. W OO wymaga to bardziej ezoterycznych (i mniej OO) funkcji języka, takich jak metody rozszerzeń.

  4. Klasy typów oparte są na polimorfizmie parametrycznym, a nie na podtypach. Umożliwia to dokładniejsze pisanie. Zastanów się np

    pick :: Enum a => a -> a -> a
    pick x y = if fromEnum x == 0 then y else x
    

    vs.

    pick(x : Enum, y : Enum) : Enum = if x.fromEnum() == 0 then y else x
    

    W pierwszym przypadku zastosowanie pick '\0' 'x'ma typ Char, podczas gdy w drugim przypadku wszystko, co wiesz o wyniku, to to, że jest to Enum. (To jest również powód, dla którego większość języków OO obecnie integruje parametryczny polimorfizm).

  5. Ściśle związany jest problem metod binarnych. Są całkowicie naturalne z klasami typów:

    class Ord a where
      (<) :: a -> a -> Bool
      ...
    
    min :: Ord a => a -> a -> a
    min x y = if x < y then x else y
    

    W przypadku samego podtypu Ordinterfejs jest niemożliwy do wyrażenia. Potrzebujesz dokładniejszej, rekurencyjnej formy lub parametrycznego polimorfizmu zwanego „kwantyfikacją F”, aby zrobić to dokładnie. Porównaj Javę Comparablei jej użycie:

    interface Comparable<T> {
      int compareTo(T y);
    };
    
    <T extends Comparable<T>> T min(T x, T y) {
      if (x.compareTo(y) < 0)
        return x;
      else
        return y;
    }
    

Z drugiej strony interfejsy oparte na List<C>podtypach naturalnie pozwalają na tworzenie heterogenicznych kolekcji, np. Lista typów może zawierać elementy, które mają różne podtypy C(chociaż nie jest możliwe odzyskanie ich dokładnego typu, z wyjątkiem użycia downcastów). Aby zrobić to samo w oparciu o klasy typów, potrzebujesz typów egzystencjalnych jako dodatkowej funkcji.

Andreas Rossberg
źródło
Ach, to ma sens. Wysyłka oparta na typie i wartości jest prawdopodobnie najważniejszą rzeczą, o której nie myślałem właściwie. Sens ma parametryczny polimorfizm i bardziej szczegółowe pisanie. Właśnie włączyłem sobie w to interfejs oparty na podtypach (najwyraźniej myślę w Javie: - /).
oconnor0
Czy typy egzystencjalne są czymś podobnym do tworzenia podtypów Cbez obecności downcastów?
oconnor0
Rodzaj. Są środkiem do tworzenia abstraktu typu, tj. Ukrywania jego reprezentacji. W Haskell, jeśli dołączysz do niego również ograniczenia klas, nadal możesz używać na nich metod tych klas, ale nic więcej. - Downcasty są w rzeczywistości cechą, która jest odrębna zarówno od podtytułu, jak i kwantyzacji egzystencjalnej, i co do zasady mogłaby zostać dodana także w obecności tej drugiej. Tak jak istnieją języki OO, które tego nie zapewniają.
Andreas Rossberg
PS: FWIW, typy symboli wieloznacznych w Javie są typami egzystencjalnymi, choć raczej ograniczonymi i ad hoc (co może być jednym z powodów, dla których są nieco mylące).
Andreas Rossberg
1
@didierc, które byłyby ograniczone do przypadków, które można w pełni rozwiązać statycznie. Ponadto, aby dopasować klasy typów, wymagałoby to rozwiązania polegającego na przeciążeniu, które jest w stanie rozróżnić na podstawie samego typu zwrotu (patrz punkt 1).
Andreas Rossberg,
6

Oprócz doskonałej odpowiedzi Andreasa, należy pamiętać, że klasy typów mają na celu usprawnienie przeciążenia , które wpływa na globalną przestrzeń nazw. W Haskell nie ma przeciążenia poza tym, co można uzyskać za pomocą klas typów. W przeciwieństwie do tego, gdy używasz interfejsów obiektowych, tylko funkcje zadeklarowane jako argumenty tego interfejsu będą musiały martwić się o nazwy funkcji w tym interfejsie. Interfejsy zapewniają więc lokalne przestrzenie nazw.

Na przykład fmapw interfejsie obiektowym o nazwie „Functor”. Byłoby całkowicie w porządku mieć inny fmapw innym interfejsie, np. „Structor”. Każdy obiekt (lub klasa) może wybrać interfejs, który chce zaimplementować. Natomiast w Haskell możesz mieć tylko jedną fmapw określonym kontekście. Nie można importować zarówno klas typu Functor, jak i Structor do tego samego kontekstu.

Interfejsy obiektowe są bardziej podobne do standardowych sygnatur ML niż do klas typów.

Uday Reddy
źródło
a jednak wydaje się, że istnieje ścisły związek między modułami ML a klasami typu Haskell. cse.unsw.edu.au/~chak/papers/DHC07.html
Steven Shaw
1

W konkretnym przykładzie (z klasą typu Functor) implementacje Haskell i Java zachowują się inaczej. Wyobraź sobie, że masz typ danych Może i chcesz, aby był Functor (jest to bardzo popularny typ danych w Haskell, który możesz łatwo zaimplementować również w Javie). W swoim przykładzie Java sprawisz, że klasa może zaimplementować interfejs Functor. Więc możesz napisać następujące (tylko pseudo kod, ponieważ mam tylko tło c #):

Maybe<Int> val = new Maybe<Int>(5);
Functor<Int> res = val.fmap(someFunctionHere);

Zauważ, że resma typ Functor, a nie może. To sprawia, że ​​implementacja Java jest prawie bezużyteczna, ponieważ tracisz informacje o konkretnym typie i musisz wykonywać rzutowania. (przynajmniej nie napisałem takiej implementacji, w której typy były nadal obecne). Z klasami typu Haskell otrzymasz w rezultacie Może Int.

struhtanov
źródło
Myślę, że ten problem jest spowodowany tym, że Java nie obsługuje wyższych typów, i nie jest związany z dyskusją o typach interfejsów Vs. Gdyby Java miała wyższe rodzaje, fmap mógłby bardzo dobrze zwrócić a Maybe<Int>.
dcastro,