Типы классов против объектных интерфейсов

33

Я не думаю, что понимаю классы типов. Я где-то читал, что думать о классах типов как о «интерфейсах» (из ОО), которые реализует тип, неправильно и вводит в заблуждение. Проблема в том, что я испытываю проблемы, видя их как нечто иное, и как это неправильно.

Например, если у меня есть класс типов (в синтаксисе Haskell)

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

Чем это отличается от интерфейса [1] (в синтаксисе Java)

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

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

Одно возможное различие, о котором я могу подумать, состоит в том, что реализация класса типов, используемая при определенном вызове, не указывается, а скорее определяется из среды - скажем, исследуя доступные модули для реализации для этого типа. Похоже, это артефакт реализации, который может быть решен на языке ОО; подобно тому, как компилятор (или среда выполнения) может сканировать оболочку / extender / monkey-patcher, который предоставляет необходимый интерфейс для типа.

Чего мне не хватает?

[1] Обратите внимание, что f aаргумент был удален, fmapпоскольку, поскольку это язык ОО, вы вызываете этот метод для объекта. Этот интерфейс предполагает, что f aаргумент был исправлен.

oconnor0
источник

Ответы:

46

В своей основной форме классы типов несколько похожи на объектные интерфейсы. Однако во многих отношениях они носят гораздо более общий характер.

  1. Отправка осуществляется по типам, а не по значениям. Никакого значения не требуется для его выполнения. Например, можно выполнить диспетчеризацию для типа результата результата, как с Readклассом Haskell :

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

    Такая отправка явно невозможна в обычных ОО.

  2. Классы типов естественным образом распространяются на множественную диспетчеризацию, просто предоставляя несколько параметров:

    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. Определения экземпляров не зависят от определений классов и типов, что делает их более модульными. Тип T из модуля A можно переоборудовать в класс C из модуля M2 без изменения определения любого из них, просто предоставив экземпляр в модуле M3. В ОО для этого требуются более эзотерические (и менее понятные) языковые возможности, такие как методы расширения.

  4. Классы типов основаны на параметрическом полиморфизме, а не на подтипах. Это позволяет более точно печатать. Рассмотрим, например,

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

    против

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

    В первом случае, применение pick '\0' 'x'имеет тип Char, тогда как во втором случае все, что вы знали бы о результате, это то, что это Enum. (Это также причина, по которой большинство языков OO в наши дни объединяют параметрический полиморфизм.)

  5. Тесно связана проблема бинарных методов. Они полностью естественны с типами классов:

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

    С одним только подтипом Ordинтерфейс невозможно выразить. Вам нужна более сложная, рекурсивная форма или параметрический полиморфизм, называемый F-ограниченной квантификацией, чтобы сделать это точно. Сравните Java Comparableи его использование:

    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;
    }
    

С другой стороны, основанные на List<C>подтипах интерфейсы, естественно, допускают формирование гетерогенных коллекций, например, список типов может содержать элементы, которые имеют различные подтипы C(хотя восстановить их точный тип невозможно, кроме как с помощью понижений). Чтобы сделать то же самое на основе классов типов, вам нужны экзистенциальные типы в качестве дополнительной функции.

Андреас Россберг
источник
Ах, это имеет большой смысл. Диспетчер типа против значения, вероятно, является большой вещью, о которой я не думал должным образом. Проблема параметрического полиморфизма и более специфической типизации имеет смысл. Я только что собрал воедино этот интерфейс и интерфейсы на основе подтипов (очевидно, я думаю в Java: - /).
oconnor0
Являются ли экзистенциальные типы чем-то похожим на создание подтипов Cбез присутствия downcast?
oconnor0
Что-то вроде. Они являются средством сделать тип абстрактным, то есть скрыть его представление. В Haskell, если вы также прикрепите к нему ограничения класса, вы все равно сможете использовать методы этих классов, но не более того. - Даункасты на самом деле являются функцией, которая отделена как от подтипов, так и от экзистенциальной количественной оценки, и в принципе может быть добавлена ​​и в присутствии последних. Так же, как есть ОО-языки, которые этого не предоставляют.
Андреас Россберг
PS: FWIW, подстановочные типы в Java являются экзистенциальными типами, хотя и довольно ограниченными и специальными (что может быть частью причины, по которой они несколько сбивают с толку).
Андреас Россберг
1
@didierc, это будет ограничено случаями, которые могут быть полностью разрешены статически. Более того, для сопоставления классов типов потребуется форма разрешения перегрузки, которая может различаться только на основе возвращаемого типа (см. Пункт 1).
Андреас Россберг
6

В дополнение к превосходному ответу Андреаса, имейте в виду, что классы типов предназначены для упрощения перегрузки , которая влияет на глобальное пространство имен. В Haskell нет перегрузки, кроме той, которую вы можете получить с помощью классов типов. Напротив, когда вы используете объектные интерфейсы, только те функции, которые объявлены для получения аргументов этого интерфейса, должны будут беспокоиться об именах функций в этом интерфейсе. Итак, интерфейсы предоставляют локальные пространства имен.

Например, у вас был fmapобъектный интерфейс под названием «Functor». Было бы прекрасно иметь fmapдругой интерфейс, скажем «Structor». Каждый объект (или класс) может выбирать, какой интерфейс он хочет реализовать. Напротив, в Haskell у вас может быть только один fmapв определенном контексте. Вы не можете импортировать классы типов Functor и Structor в один и тот же контекст.

Интерфейсы объектов больше похожи на стандартные подписи ML, чем на классы типов.

Удай Редди
источник
и все же, кажется, существует тесная связь между модулями ML и классами типов Haskell. cse.unsw.edu.au/~chak/papers/DHC07.html
Стивен Шоу
1

В вашем конкретном примере (с классом типа Functor) реализации Haskell и Java ведут себя по-разному. Представьте, что у вас есть тип данных Maybe и вы хотите, чтобы он был Functor (это действительно популярный тип данных в Haskell, который вы также можете легко реализовать в Java). В вашем примере с Java вы заставите класс Maybe реализовать ваш интерфейс Functor. Таким образом, вы можете написать следующее (просто псевдокод, потому что у меня только фон c #):

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

Обратите внимание, что resимеет тип Functor, а не Maybe. Таким образом, это делает реализацию Java практически непригодной, потому что вы теряете конкретную информацию о типе, и вам необходимо выполнять приведения. (по крайней мере, мне не удалось написать такую ​​реализацию, где типы все еще присутствовали). С классами типа Haskell вы получите Maybe Int.

struhtanov
источник
Я думаю, что эта проблема связана с тем, что Java не поддерживает типы с более высоким родом, и не связана с обсуждением интерфейсов и классов типов. Если бы у Java были более высокие виды, то fmap вполне мог бы вернуть a Maybe<Int>.
dcastro