Typeclasses: функция с реализацией по умолчанию против отдельной функции

При определении класса типов, как вы решаете между включением/исключением функции в определении typeclass? Например, каковы различия между этими двумя случаями:

class Graph g where
    ...

    insertNode :: g -> Node -> g
    insertNode graph node = ...

vs

class Graph g where
    ...

insertNode :: (Graph g) => g -> Node -> g
insertNode graph node = ...

Ответ 1

Я думаю, что здесь есть несколько элементов напряженности. Там общая идея, что определения типа класса должны быть минимальными и содержать только независимые функции. Как объясняет ответ bhelkir, если ваш класс поддерживает функции a, b и c, но c может быть реализован в терминах a и b, то аргумент для определения c вне класс.

Но эта общая идея сталкивается с несколькими другими конфликтующими проблемами.

Во-первых, часто существует более одного минимального набора операций, которые могут одинаково определять один и тот же класс. Классическое определение Monad в Haskell - это (очищено немного):

class Monad m where
    return :: a -> m a
    (>>=) :: m a -> (a -> m b) -> m b

Но хорошо известно, что существуют альтернативные определения, вроде этого:

class Applicative m => Monad m where
    join :: m (m a) -> m a

return и >>= достаточны для реализации join, но fmap, pure и join также достаточны для реализации >>=.

Аналогичная вещь с Applicative. Это каноническое определение Haskell:

class Functor f => Applicative f where
    pure  :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b

Но любое из следующего эквивалентно:

class Functor f => Applicative f where
    unit  :: f ()
    (<*>) :: f (a -> b) -> f a -> f b

class Functor f => Applicative f where
    pure  :: a -> f a
    fpair :: f a -> f b -> f (a, b)

class Functor f => Applicative f where
    unit  :: f ()
    fpair :: f a -> f b -> f (a, b)

class Functor f => Applicative f where
    unit  :: f ()
    liftA2 :: (a -> b -> c) -> f a -> f b -> f c

Для любого из этих определений классов вы можете написать любой из методов в любом из других как производная функция вне класса. Почему первый выбрал? Я не могу ответить авторитетно, но я думаю, что это приводит нас к третьему вопросу: соображения производительности. Операция fpair во многих из них объединяет значения f a и f b, создавая кортежи, но для большинства применений класса Applicative мы фактически не хотим этих кортежей, мы просто хотим объединить значения, полученные из f a и f b; каноническое определение позволяет нам выбрать, какую функцию выполнить эту комбинацию с.

Еще одно соображение производительности состоит в том, что даже если некоторые методы в классе могут быть определены в терминах других, эти общие определения могут быть не оптимальными для всех экземпляров класса. Если мы возьмем Foldable в качестве примера, foldMap и foldr являются взаимоопределяемыми, но некоторые типы поддерживают еще один эффективный, чем другой. Поэтому у нас есть не минимальные определения классов, позволяющие экземплярам предоставлять оптимизированные реализации методов.

Ответ 2

Включение функции в определение класса typeclass означает, что она может быть переопределена. В этом случае вам нужно, чтобы он находился внутри класса Graph, так как он возвращает Graph g => g, и каждый конкретный экземпляр Graph должен знать, как построить это значение. Кроме того, вы можете указать функцию в классе типов с целью построения значений типа Graph g => g, а затем insertNode может использовать эту функцию в своем результате.

Сохранение функции за пределами класса typeclass означает, что она не может быть изменена, но также и то, что она не загромождает класс. Рассмотрим в качестве примера функцию mapM. Там нет необходимости в том, чтобы это было в классе Monad, и вы, вероятно, не хотите, чтобы люди записывали свои собственные реализации mapM, он должен делать то же самое во всех контекстах. В качестве другого примера рассмотрим функцию

-- f(x) = 1 + 3x^2 - 5x^3 + 10x^4
aPoly :: Num a => a -> a
aPoly x = 1 + 3 * x * x - 5 * x * x * x + 10 * x * x * x * x

Очевидно, что aPoly не должно быть частью класса Num, это просто случайная функция, использующая методы Num. Это не имеет никакого отношения к тому, что значит быть Num.

Действительно, это сводится к дизайну. Функции обычно указываются в классе типов, если они являются неотъемлемой частью того, что означает быть экземпляром этого класса. Иногда функции включаются в класс типов, но с определением по умолчанию, так что определенный тип может перегружать его, чтобы сделать его более эффективным, но по большей части имеет смысл держать членов класса как минимум. Один из способов взглянуть на него - это задать вопрос "Можно ли реализовать эту функцию только с ограничением класса?" Если ответ отрицательный, он должен быть в классе. Если да, то подавляющее большинство времени означает, что функция должна быть перемещена за пределы класса. Только когда есть ценность, полученная от возможности перегрузки, она должна быть перенесена в класс. Если перегрузка этой функции может нарушить другой код, который ожидает, что он будет вести себя определенным образом, тогда он не должен быть перегружен.

Еще один случай, который следует учитывать, - это когда у вас есть функции в вашем классе, которые имеют нормальные значения по умолчанию, но эти значения по умолчанию взаимно зависимы. В качестве примера возьмем класс Num, у вас есть

class Num a where
    (+) :: a -> a -> a
    (*) :: a -> a -> a
    (-) :: a -> a -> a
    a - b = a + negate b
    negate :: a -> a
    negate a = 0 - a
    abs :: a -> a
    signum :: a -> a
    fromInteger :: Integer -> a

Обратите внимание, что (-) и negate оба реализованы в терминах друг друга. Если вы создаете свой собственный числовой тип, вам нужно будет реализовать один или оба из (-) и negate, так как в противном случае у вас будет бесконечный цикл на ваших руках. Это полезные функции для перегрузки, поэтому они оба остаются внутри класса.