Подтвердить что ты не робот

Когда использовать класс типа, когда использовать тип

Я пересматривал часть кода, который я написал, чтобы сделать комбинаторный поиск несколько месяцев назад, и заметил, что существует альтернативный, более простой способ сделать то, что я ранее достиг с классом типа.

В частности, я ранее имел тип типа для типа проблем поиска, которые имеют состояния типа s, действия (операции над состояниями) типа a, начальное состояние, способ получения списка пар (действие, состояние) и способ проверки, является ли состояние решением или нет:

class Problem p s a where
    initial   :: p s a -> s
    successor :: p s a -> s -> [(a,s)]
    goaltest  :: p s a -> s -> Bool

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

pathToSolution :: Problem p => p s a -> [(a,s)]

Сегодня я заметил, что могу полностью избавиться от класса и использовать вместо него следующие строки

data Problem s a {
    initial   :: s,
    successor :: s -> [(a,s)],
    goaltest  :: s -> Bool
}

Это не требует каких-либо расширений, функции подписи выглядят лучше:

pathToSolution :: Problem s a -> [(a,s)]

и, самое главное, я обнаружил, что после рефакторинга моего кода, чтобы использовать эту абстракцию вместо класса типа, я остался на 15-20% меньше строк, чем раньше.

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

Теперь я просматриваю остальную часть кода, пытаясь определить экземпляры, где я могу заменить типы классов типами и сделать больше выигрышей.

Мой вопрос: в какой ситуации будет работать этот рефакторинг не? В каких случаях лучше всего использовать класс типа, а не тип данных, и как вы можете распознать эти ситуации раньше времени, поэтому вам не нужно проходить дорогостоящий рефакторинг?

4b9b3361

Ответ 1

Рассмотрим ситуацию, когда тип и класс существуют в одной и той же программе. Тип может быть экземпляром класса, но это довольно тривиально. Более интересно то, что вы можете написать функцию fromProblemClass :: (CProblem p s a) => p s a -> TProblem s a.

Выполненный рефакторинг примерно эквивалентен ручному вложению fromProblemClass везде, где вы строите что-то, используемое как экземпляр CProblem, и делая каждую функцию, которая принимает экземпляр CProblem, вместо этого принимает TProblem.

Поскольку единственными интересными частями этого рефакторинга являются определение TProblem и реализация fromProblemClass, если вы можете написать аналогичный тип и функцию для любого другого класса, вы также можете реорганизовать его, чтобы полностью исключить класс.

Когда это работает?

Подумайте о реализации fromProblemClass. Вы по существу частично применяете каждую функцию класса к значению типа экземпляра и в процессе, исключая любую ссылку на параметр p (это то, что тип заменяет).

Любая ситуация, когда рефакторинг класса типа прост, будет следовать аналогичной схеме.

Когда это контрпродуктивно?

Представьте упрощенную версию Show, только с определенной функцией Show. Это позволяет использовать тот же рефакторинг, применяя Show и заменяя каждый экземпляр... a String. Очевидно, что мы здесь что-то потеряли, а именно, способность работать с исходными типами и преобразовать их в String в разных точках. Значение Show заключается в том, что оно определено на множестве несвязанных типов.

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

Когда это невозможно?

Кстати, рефакторинг здесь похож на разницу между классом и интерфейсом на языках OO; аналогичным образом классы типов, в которых этот рефакторинг невозможно, - это те, которые не могут быть выражены напрямую на многих языках OO.

Более того, некоторые примеры вещей, которые вы не можете легко перевести, если это вообще так:

  • Параметр типа класса отображается только в ковариантной позиции, такой как тип результата функции или как нефункциональное значение. Известные преступники здесь mempty для Monoid и return для Monad.

  • Параметр типа класса, появляющийся более одного раза в виде функции, может не сделать это по-настоящему невозможным, но это сильно осложняет ситуацию. Известные преступники здесь включают Eq, Ord и в основном каждый числовой класс.

  • Нетривиальное использование более высоких типов, о которых я не уверен, как привязывать, но (>>=) для Monad здесь является заметным преступником. С другой стороны, параметр p в вашем классе не является проблемой.

  • Нетривиальное использование классов с несколькими параметрами, которые я также не знаю, как фиксировать и ужасно усложняться на практике в любом случае, сравнимо с несколькими отправками на языках OO, Опять же, ваш класс не имеет проблемы здесь.

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

Что вы откажетесь от применения этого рефакторинга?

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

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

Как отмечалось выше, это тесно связано с ООП и типом проблем, к которым он наиболее подходит, а также представляет "другую сторону" проблемы выражений из типичных для языков стиля ML.

Ответ 2

Ваш рефакторинг тесно связан с этой записью в блоге Люком Палмером: "Haskell Antipattern: Existential Typeclass" .

Я думаю, мы сможем доказать, что ваш рефакторинг всегда будет работать. Зачем? Интуитивно, потому что, если какой-то тип Foo содержит достаточно информации, поэтому мы можем сделать его в экземпляр вашего класса Problem, мы всегда можем написать функцию Foo -> Problem, которая "проектирует" Foo соответствующую информацию в Problem который содержит именно необходимую информацию.

Немного более формально, мы можем набросать доказательство того, что ваш рефакторинг всегда работает. Во-первых, чтобы задать этап, следующий код определяет перевод экземпляра класса Problem в конкретный тип CanonicalProblem:

{-# LANGUAGE MultiParamTypeClasses, FlexibleInstances #-}

class Problem p s a where
    initial   :: p s a -> s
    successor :: p s a -> s -> [(a,s)]
    goaltest  :: p s a -> s -> Bool

data CanonicalProblem s a = CanonicalProblem {
    initial'   :: s,
    successor' :: s -> [(a,s)],
    goaltest'  :: s -> Bool
}

instance Problem CanonicalProblem s a where
    initial = initial'
    successor = successor'
    goaltest = goaltest'

canonicalize :: Problem p s a => p s a -> CanonicalProblem s a
canonicalize p = CanonicalProblem {
    initial' = initial p,
    successor' = successor p,
    goaltest' = goaltest p
}

Теперь мы хотим доказать следующее:

  • Для любого типа Foo, такого как instance Problem Foo s a, можно написать функцию canonicalizeFoo :: Foo s a -> CanonicalProblem s a, которая дает тот же результат, что и canonicalize при применении к любому Foo s a.
  • Можно переписать любую функцию, которая использует класс Problem, в эквивалентную функцию, которая вместо этого использует CanonicalProblem. Например, если у вас есть solve :: Problem p s a => p s a -> r, вы можете написать canonicalSolve :: CanonicalProblem s a -> r, эквивалентный solve . canonicalize

Я просто рисую доказательства. В случае (1) предположим, что у вас есть тип Foo с этим экземпляром Problem:

instance Problem Foo s a where
    initial = initialFoo
    successor = successorFoo
    goaltest = goaltestFoo

Тогда, учитывая x :: Foo s a, вы можете тривиально доказать следующее путем подстановки:

-- definition of canonicalize
canonicalize :: Problem p s a => p s a -> CanonicalProblem s a
canonicalize x = CanonicalProblem {
                     initial' = initial x,
                     successor' = successor x,
                     goaltest' = goaltest x
                 }

-- specialize to the Problem instance for Foo s a
canonicalize :: Foo s a -> CanonicalProblem s a
canonicalize x = CanonicalProblem {
                     initial' = initialFoo x,
                     successor' = successorFoo x,
                     goaltest' = goaltestFoo x
                 }

И последнее может быть использовано непосредственно для определения нашей желаемой функции canonicalizeFoo.

В случае (2) для любой функции solve :: Problem p s a => p s a -> r (или аналогичных типов, которые связаны с ограничениями Problem), и для любого типа Foo, что instance Problem Foo s a:

  • Определите canonicalSolve :: CanonicalProblem s a -> r', взяв определение solve и подставив все вхождения методов Problem с их определениями экземпляра CanonicalProblem.
  • Докажите, что для любого x :: Foo s a, solve x эквивалентно canonicalSolve (canonicalize x).

Конкретные доказательства (2) требуют конкретных определений solve или связанных функций. Общее доказательство могло бы идти одним из двух способов:

  • Индукция по всем типам, имеющим ограничения Problem p s a.
  • Докажите, что все функции Problem могут быть записаны в терминах небольшого подмножества функций, докажите, что это подмножество имеет CanonicalProblem эквиваленты и что различные способы их использования сохраняют эквивалентность.

Ответ 3

Если вы находитесь из OOP backaground. Вы можете думать о typeclasses как интерфейсах в java. Они обычно используются, когда вы хотите предоставить один и тот же интерфейс для разных типов данных, как правило, для конкретных типов данных для каждого из них.

В вашем случае нет использования использования typeclass, это только усложнит ваш код. для большей информации вы всегда можете обратиться к haskellwiki для лучшего понимания. http://www.haskell.org/haskellwiki/OOP_vs_type_classes

Общее эмпирическое правило: если вы сомневаетесь, нужны ли вам классы классов или нет, то вам, вероятно, они не нужны.