37

几个月前,我重新审视了我为进行组合搜索而编写的一段代码,并注意到有一种替代的、更简单的方法可以完成我以前通过类型类实现的某些事情。

具体来说,我之前有一个用于搜索问题类型的类型类,它有一个类型的状态、一个类型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类的实例(这需要更多的语言扩展)——很多几行代码来做一些相对简单的事情。重构之后,我只有几个功能完全符合我的要求。

我现在正在查看其余代码,试图找出可以用类型替换类型类的实例,并获得更多胜利。

我的问题是:这种重构在什么情况下不起作用?在什么情况下使用类型类而不是数据类型实际上更好,您如何提前识别这些情况,这样您就不必进行昂贵的重构?

4

3 回答 3

22

考虑类型和类都存在于同一个程序中的情况。类型可以是类的一个实例,但这很简单。更有趣的是你可以写一个函数fromProblemClass :: (CProblem p s a) => p s a -> TProblem s a

您执行的重构大致相当于fromProblemClass在您构造用作CProblem实例的东西的任何地方手动内联,并使每个接受CProblem实例的函数改为 accept TProblem

由于这次重构中唯一有趣的部分是 的定义TProblem和实现fromProblemClass,如果您可以为任何其他类编写类似的类型和函数,您同样可以重构它以完全消除该类。

这什么时候起作用?

想想fromProblemClass. 您实际上将部分地将类的每个函数应用于实例类型的值,并在此过程中消除对p参数的任何引用(这是类型所替换的)。

任何可以直接重构类型类的情况都将遵循类似的模式。

什么时候会适得其反?

想象一下 的简化版本Show,只show定义了函数。这允许show使用... a 进行相同的重构、应用和替换每个实例String。显然,我们在这里失去了一些东西——即,使用原始类型并将它们String在不同点转换为 a 的能力。的价值Show在于它定义在各种不相关的类型上。

根据经验,如果有许多不同的函数特定于作为类实例的类型,并且这些函数通常与类函数在相同的代码中使用,那么延迟转换很有用。如果在单独处理类型的代码和使用类的代码之间存在明显的分界线,则转换函数可能更适合类型类,因为它的语法便利性很小。如果类型几乎完全通过类函数使用,那么类型类可能完全是多余的。

这什么时候不可能?

顺便提一下,这里的重构类似于OO语言中类和接口的区别;同样,无法进行这种重构的类型类是在许多 OO 语言中根本无法直接表达的类型类。

更重要的是,一些你无法以这种方式轻松翻译的例子:

  • 类的类型参数仅出现在协变位置,例如函数的结果类型或作为非函数值。这里的著名罪犯是memptyforMonoidreturnfor Monad

  • 类的类型参数在函数类型中多次出现可能不会使这真正不可能,但它会使事情变得非常复杂。这里值得注意的违规者包括Eq,Ord和基本上每个数字类。

  • 高级种类的非平凡使用,我不确定如何确定具体细节,但这里(>>=)Monad一个值得注意的罪犯。另一方面,p您类中的参数不是问题。

  • 多参数类型类的非平凡使用,我也不确定如何确定并且在实践中变得非常复杂,与 OO 语言中的多次调度相当。同样,您的班级在这里没有问题。

请注意,鉴于上述情况,这种重构对于许多标准类型类甚至是不可能的,并且对于少数例外情况会适得其反。这不是巧合。:]

通过应用这种重构,你放弃了什么?

你放弃了区分原始类型的能力。这听起来很明显,但它可能很重要——如果在任何情况下您确实需要控制使用了哪个原始类实例类型,那么应用此重构会失去某种程度的类型安全性,您只能通过跳过其他地方使用的相同类型的箍来确保运行时的不变量。

相反,如果在某些情况下您确实需要使各种实例类型可互换(您提到的复杂包装是这种情况的典型症状),您可以通过丢弃原始类型获得很多好处。最常见的情况是,您实际上并不关心原始数据本身,而是关心它如何让您对其他数据进行操作;因此,直接使用函数记录比额外的间接层更自然。

如上所述,这与 OOP 及其最适合的问题类型密切相关,并且代表了 ML 风格语言中典型的表达式问题的“另一面”。

于 2012-09-05T19:09:17.760 回答
5

您的重构与 Luke Palmer 的这篇博文密切相关:“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
}

现在我们要证明以下几点:

  1. 对于任何Foo这样的类型instance Problem Foo s a,都可以编写一个函数来产生与应用于 any 时canonicalizeFoo :: Foo s a -> CanonicalProblem s a相同的结果。canonicalizeFoo s a
  2. 可以将使用该类的任何函数重写为使用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等价物,并且一起使用它们的各种方式保持了等价性。
于 2012-09-05T19:53:31.330 回答
1

如果您来自 OOP 背景。您可以将类型类视为 java 中的接口。当您想为不同的数据类型提供相同的接口时,通常会使用它们,通常涉及每种数据类型的特定实现。

在您的情况下,没有使用类型类,它只会使您的代码过于复杂。有关更多信息,您可以随时参考 haskellwiki 以获得更好的理解。 http://www.haskell.org/haskellwiki/OOP_vs_type_classes

一般的经验法则是:如果您怀疑是否需要类型类,那么您可能不需要它们。

于 2012-09-05T18:06:54.100 回答