几个月前,我正在重新审视我编写的用于组合搜索的一段代码,并注意到有一种替代的,更简单的方法来做我以前用类型类实现的东西。
具体来说,我之前有搜索问题类型的类型类,其类型为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
类的实例(这需要更多的语言扩展) - 许多代码行来做一些相对简单的事情。在重构之后,我只有几个功能完全符合我的要求。
我现在正在查看其余的代码,试图找出我可以用类型替换类型类的实例,并获得更多的胜利。
我的问题是:这种重构不会在什么情况下起作用?在什么情况下,使用类型类而不是数据类型实际上更好,并且如何提前识别这些情况,这样您就不必经历昂贵的重构?
答案 0 :(得分:22)
考虑类型和类都存在于同一程序中的情况。类型可以是类的实例,但这相当简单。更有趣的是你可以写一个函数fromProblemClass :: (CProblem p s a) => p s a -> TProblem s a
。
您执行的重构大致相当于在构建用作fromProblemClass
实例的内容的任何位置手动内联CProblem
,并使接受CProblem
实例的每个函数都接受{{1 }}
由于此重构的唯一有趣部分是TProblem
的定义和TProblem
的实现,如果您可以为任何其他类编写类似的类型和函数,您同样可以将其重构为完全消除这个阶级。
考虑fromProblemClass
的实施。您基本上将类的每个函数部分应用于实例类型的值,并在此过程中消除对fromProblemClass
参数的任何引用(类型替换的类型)。
任何重构类型类很简单的情况都会遵循类似的模式。
想象一下p
的简化版本,只定义了Show
函数。这允许相同的重构,应用show
并用... show
替换每个实例。很明显,我们在这里丢失了一些东西 - 即能够使用原始类型并在不同点将它们转换为String
。 String
的值是它在各种不相关的类型上定义的。
根据经验,如果有许多不同的函数特定于类的实例类型,并且这些函数通常在与类函数相同的代码中使用,则延迟转换很有用。如果在单独处理类型的代码和使用该类的代码之间存在明显的分界线,则转换函数可能更适合于类型类是一种轻微的语法便利。如果类型几乎只通过类函数使用,那么类型类可能完全是多余的。
顺便提一下,这里的重构类似于OO语言中的类和接口之间的差异;类似地,无法进行重构的类型类是那些无法在许多OO语言中直接表达 at 的类型。
更重要的是,有些事情你不能轻易翻译,如果有的话,以这种方式:
类的参数仅出现在协变位置,例如函数的结果类型或非函数值。这里值得注意的违规者Show
为mempty
,Monoid
为return
。
在函数类型中多次出现的类的类型参数可能不会使这真的不可能,但它会使问题非常严重。这里值得注意的违规者包括Monad
,Eq
,以及基本上每个数字类。
非常重要的使用,我不确定如何确定其具体细节,但Ord
(>>=)
的细节值得注意罪犯在这里。另一方面,班级中的Monad
参数不是问题。
多参数类型类的非常重要的使用,我也不确定如何确定如何在实践中变得非常复杂,与OO语言中的多个调度相当。同样,你的班级在这里没有问题。
请注意,鉴于上述情况,对于许多标准类型类,这种重构甚至不是可能的,并且对于少数例外会适得其反。这不是巧合。 :
您放弃区分原始类型的能力。这听起来很明显,但它可能很重要 - 如果有任何情况你真的需要控制使用哪个原始类实例类型,那么应用这种重构会失去某种程度的类型安全性,你可以只能通过跳过其他地方使用的相同类型的箍来恢复,以确保在运行时不变量。
相反,如果在某些情况下您确实需要使各种实例类型可互换 - 您提到的复杂包装是这种情况的经典症状 - 您可以通过丢弃获得大量收益原始类型。通常情况下,您实际上并不关心原始数据本身,而是关于它如何让您对其他数据进行操作;因此,直接使用函数记录比额外的间接层更自然。
如上所述,这与OOP及其最适合的问题类型密切相关,并且与ML风格语言中的典型代表表达问题的“另一面”。
答案 1 :(得分:5)
您的重构与Luke Palmer的博客文章密切相关:"Haskell Antipattern: Existential Typeclass"。
我认为我们可以证明您的重构将始终有效。为什么?直观地说,因为如果某个类型Foo
包含足够的信息以便我们可以将它变成Problem
类的实例,我们总是可以编写一个Foo -> Problem
函数来“投射”{{1将相关信息转换为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
的{{1}} 我只是草拟样张。在(1)的情况下,假设您的canonicalSolve :: CanonicalProblem s a -> r
实例具有solve . canonicalize
类型:
Foo
然后给出Problem
,您可以通过替换来简单地证明以下内容:
instance Problem Foo s a where
initial = initialFoo
successor = successorFoo
goaltest = goaltestFoo
后者可以直接用于定义我们想要的x :: Foo s a
函数。
在(2)的情况下,对于任何函数-- 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
约束的类似类型),对于任何类型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
。(2)的具体证据要求具体定义solve x
或相关函数。一般证据可以采用以下两种方式之一:
canonicalSolve (canonicalize x)
约束的所有类型的归纳。solve
函数都可以根据函数的一小部分来编写,证明该子集具有Problem p s a
等价,并且使用它们的各种方法保留了等价。 答案 2 :(得分:1)
如果您来自OOP backaground。您可以将类型类视为java中的接口。它们通常在您希望为不同数据类型提供相同接口时使用,通常涉及每种数据类型特定的实现。
在你的情况下,不使用类型类,它只会使你的代码复杂化。 要获得更多信息,您可以随时参考haskellwiki以获得更好的理解。 http://www.haskell.org/haskellwiki/OOP_vs_type_classes
一般的经验法则是:如果您怀疑是否需要类型类,那么您可能不需要它们。