在 Haskell 中,对类头或类方法的明确约束?

J. *_* M. 2 haskell constraints typeclass

我最近意识到这一点:

一方面:

在类标头上指定的约束必须在该类的实例上再次指定,但在其他地方将该类用作约束的任何使用都不需要重新导入类约束。他们暗自满意。

class (Ord a) => ClassA a where methodA :: a -> Bool -- i decided to put constraint (Ord a) in the class header
instance (Ord a) => ClassA a where methodA x = x <= x   -- compiler forces me to add (Ord a) => in the front
Run Code Online (Sandbox Code Playgroud)
class OtherClassA a where otherMethodA :: a -> Bool 
instance (ClassA a) => OtherClassA a where otherMethodA x = x <= x && methodA x -- i don't need to specify (Ord a) so it must be brought implicitly in context
Run Code Online (Sandbox Code Playgroud)

另一方面:

在类方法中指定的约束不需要在该类的实例上再次指定,但在其他地方使用该类作为约束时,需要重新导入所使用方法的特定约束。

class ClassB a where methodB :: (Ord a) => a -> Bool -- i decided to put constraint (Ord a) in the method
instance ClassB a where methodB x = x <= x  -- i don't need to specify (Ord a) so it must be implicitly in context
Run Code Online (Sandbox Code Playgroud)
class OtherClassB a where otherMethodB :: a -> Bool 
instance (ClassB a, Ord a) => OtherClassB a where otherMethodB = methodB -- compiler forced me to add (Ord a)
Run Code Online (Sandbox Code Playgroud)

这些行为的动机是什么?始终明确约束条件不是更好吗?

更具体地说,当我有一组条件时,我知道类型类中的所有方法都应该满足,我应该将这些条件写在类型类标头中还是在每个方法前面?我应该在类型类定义中编写约束吗?

K. *_*uhr 6

简答

\n

这是我对类声明和实例定义中的约束的一般建议。请参阅下面的详细说明和示例的详细说明。

\n
    \n
  1. 如果您的类具有逻辑关系,使得类型在逻辑上不可能属于 classBase而不属于 class Super,请在类声明中使用约束,如下所示:

    \n
     class Super a => Base a where ...\n
    Run Code Online (Sandbox Code Playgroud)\n

    一些例子:

    \n
     -- all Applicatives are necessarily Functors\n class Functor f => Applicative f where ...\n -- All orderable types can also be tested for equality\n class Eq f => Ord f where ...\n -- Every HTMLDocument also supports Document methods\n class Document doc => HTMLDocument doc where ...\n
    Run Code Online (Sandbox Code Playgroud)\n
  2. \n
  3. 避免编写适用于所有类型的实例,无论是否有约束。除了少数例外,这些通常表明存在设计缺陷:

    \n
     -- don't do this\n instance SomeClass1 a\n -- or this\n instance (Eq a) => SomeClass1 a\n
    Run Code Online (Sandbox Code Playgroud)\n

    不过,高阶类型的实例是有意义的,并且使用实例编译所需的任何约束:

    \n
     instance (Ord a, Ord b) => Ord (a, b) where\n   compare (x1,x2) (y1,y2) = case compare x1 x2 of\n     LT -> LT\n     GT -> GT\n     EQ -> compare x2 y2\n
    Run Code Online (Sandbox Code Playgroud)\n
  4. \n
  5. 不要对类方法使用约束,除非类应该根据可用的约束支持不同类型的不同方法子集。

    \n
  6. \n
\n

长答案

\n

类声明和实例定义中的约束具有不同的含义和不同的目的。类声明中的约束,例如:

\n
class (Big a) => Small a\n
Run Code Online (Sandbox Code Playgroud)\n

定义Big为 的“超类”Small并表示逻辑必然性的类型级声明:任何类型的类都Small必然也是类的类型Big。拥有这样的约束可以提高类型正确性(因为任何为没有Small实例的类型定义实例的尝试(逻辑不一致)都将被编译器拒绝)和便利性,因为约束将自动提供可用除了接口之外,还有类接口。aBigSmall aBigSmall

\n

作为一个具体的例子,在现代 Haskell 中,Functor是 的超类Applicative, 是 的超类Monad。所有Monads 都是Applicatives,所有Applicatives 都是Functors,因此这种超类关系反映了这些类型集合之间的逻辑关系,并且还提供了能够使用 monad ( do-notation, >>=, and return)、applicative ( pureand <*>) 和 functor的便利(fmap<$>) 接口仅使用Monad m约束。

\n

这种超类关系的结果是任何Monad实例必须附带一个ApplicativeandFunctor实例,以向编译器提供满足必要的超类约束的证据。

\n

相反,实例定义中的约束引入了特定的、定义的实例对另一个实例的依赖性。最常见的是,我看到它用于定义高阶类型类的实例,例如Ord列表的实例:

\n
instance Ord a => Ord [a] where ...\n
Run Code Online (Sandbox Code Playgroud)\n

也就是说,Ord [a]可以a使用列表的字典顺序为任何类型定义实例,前提是类型a本身可以排序。这里的约束并不(而且实际上不能)适用于所有Ord类型。相反,实例定义通过引入对元素类型实例的依赖关系来为所有列表提供实例——它表示实例Ord [a]可用于具有可用a实例的任何类型Ord a

\n

您的示例有些不寻常,因为通常不定义实例:

\n
instance SomeClass a where ...\n
Run Code Online (Sandbox Code Playgroud)\n

适用于所有类型a,无论有或没有附加约束。

\n

尽管如此,正在发生的事情是:

\n
class (Ord a) => ClassA a\n
Run Code Online (Sandbox Code Playgroud)\n

引入了一个逻辑类型级事实,即所有类型的 classClassA也属于 class Ord。然后,您将呈现一个ClassA适用于所有类型的实例:

\n
instance ClassA a\n
Run Code Online (Sandbox Code Playgroud)\n

但是,这给编译器带来了一个问题。您的类声明已声明,从逻辑上讲,所有类型都ClassA属于 class Ord,并且编译器需要您定义的Ord a任何实例的约束证明ClassA a。通过编写instance ClassA a,您可以大胆声明所有类型都属于ClassA,但编译器没有证据表明所有类都具有必要的Ord a实例。因此,您必须写:

\n
instance (Ord a) => ClassA a\n
Run Code Online (Sandbox Code Playgroud)\n

换句话说,“所有类型a都有一个实例,ClassA 前提是实例Ord a也可用”。编译器接受这一点作为您仅为a具有必要Ord a实例的类型定义实例的证据。

\n

当你继续定义时:

\n
class OtherClassA a where\n  otherMethodA :: a -> Bool\ninstance (ClassA a) => OtherClassA a where\n  otherMethodA x = x <= x && methodA x\n
Run Code Online (Sandbox Code Playgroud)\n

由于OtherClassA没有超类,因此该类的类型也属于 class 没有逻辑上的必要性Ord,并且编译器不需要对此进行证明。然而,在实例定义中,您定义了一个适用于其实现需要Ord a、 以及的所有类型的实例ClassA a。幸运的是,您已经提供了一个ClassA a约束,并且由于Ord是 的超类,因此任何具有约束的对象也具有约束ClassA是逻辑上的必然,因此编译器会满意地拥有两个必需的实例。aClassA aOrd aa

\n

当你写:

\n
class ClassB a where\n  methodB :: (Ord a) => a -> Bool\n
Run Code Online (Sandbox Code Playgroud)\n

你正在做一些不寻常的事情,编译器会尝试通过拒绝编译来发出警告,除非你启用扩展ConstrainedClassMethodsClassB这个定义所说的是,类的类型也属于class 没有逻辑上的必要性Ord,因此您可以自由地定义缺少 require 实例的实例。例如:

\n
instance ClassB (Int -> Int) where\n  methodB _ = False\n
Run Code Online (Sandbox Code Playgroud)\n

它定义了函数的实例Int -> Int(并且该类型没有Ord实例)。但是,任何尝试使用 methodB此类类型都需要一个Ord实例:

\n
> methodB (*(2::Int))\n...  \xe2\x80\xa2 No instance for (Ord (Int -> Int)) ...\n
Run Code Online (Sandbox Code Playgroud)\n

如果有多种方法并且只有其中一些方法需要约束,那么这会很有用。GHC手册给出了以下示例:

\n
class Seq s a where\n  fromList :: [a] -> s a\n  elem     :: Eq a => a -> s a -> Bool\n
Run Code Online (Sandbox Code Playgroud)\n

您可以定义序列Seq s a,而无需逻辑上要求元素a具有可比性。但是,如果没有Eq a,您只能使用这些方法的子集。如果您尝试使用需要没有此类实例的Eq a类型的方法a,您将收到错误。

\n

无论如何,你的实例:

\n
instance ClassB a where\n  methodB x = x <= x\n
Run Code Online (Sandbox Code Playgroud)\n

为所有类型定义一个实例(不需要 的任何证据Ord a,因为这里没有逻辑上的必要性),但您只能methodB在带有实例的类型子集上使用Ord

\n

在你的最后一个例子中:

\n
class OtherClassB a where\n  otherMethodB :: a -> Bool\n
Run Code Online (Sandbox Code Playgroud)\n

类的类型OtherClassB也是类的类型没有逻辑上的必要性Ord,并且不要求otherMethodB仅与具有实例的类型一起使用Ord a。如果需要,您可以定义实例:

\n
instance OtherClassB a where\n  otherMethodB _ = False\n
Run Code Online (Sandbox Code Playgroud)\n

它会编译得很好。但是,通过定义实例:

\n
instance OtherClassB a where\n  otherMethodB = methodB\n
Run Code Online (Sandbox Code Playgroud)\n

您正在为其实现使用methodB并因此需要的所有类型提供一个实例ClassB。如果您将其修改为:

\n
instance (ClassB a) => OtherClassB a where\n  otherMethodB = methodB\n
Run Code Online (Sandbox Code Playgroud)\n

编译器仍然不满意。特定方法methodB需要一个Ord a实例,但由于Ord不是 的超类,因此约束暗示的ClassB逻辑上没有必然性,因此您必须向编译器提供额外的证据来证明实例可用。通过写:ClassB aOrd aOrd a

\n
instance (ClassB a, Ord a) => OtherClassB a where\n  otherMethodB = methodB\n
Run Code Online (Sandbox Code Playgroud)\n

您提供的实例需要ClassB a(运行methodB)和Ord a(因为methodB它是附加要求),因此您需要告诉编译器该实例适用于所有类型,a 前提ClassB aOrd a实例都可用。编译器对此感到满意。

\n

您不需要类型类来延迟具体类型

\n

从您的示例和后续评论来看,听起来您(错误)使用类型类来支持特定的编程风格,除非绝对必要,否则避免提交具体类型。

\n

(顺便说一句,我曾经认为这种风格是一个好主意,但我逐渐开始认为它几乎毫无意义。Haskell 的类型系统使重构变得如此简单,以至于承诺具体类型的风险很小,并且具体程序倾向于比抽象程序更容易阅读和编写。 然而,许多人已经使用这种编程风格并从中获利,并且我可以想到至少有一个高质量的库(lens)可以非常有效地将其发挥到绝对极端。所以,不判断!)

\n

无论如何,通过编写顶级多态函数并对函数施加所需的约束,通常可以更好地支持这种编程风格。通常没有必要(也没有意义)定义新的类型类。这就是@duplode 在评论中所说的。您可以替换:

\n
class (Ord a) => ClassA where method :: a -> Bool\ninstance (Ord a) => ClassA where methodA x = x <= x\n
Run Code Online (Sandbox Code Playgroud)\n

使用更简单的顶级函数定义:

\n
methodA :: (Ord a) => a -> Bool\nmethodA x = x <= x\n
Run Code Online (Sandbox Code Playgroud)\n

因为类和实例没有任何作用。类型类的要点是提供临时多态性,以允许您拥有针对methodA不同类型具有不同实现的单个函数 ( )。如果所有类型只有一种实现,那么这只是一个普通的旧参数多态函数,并且不需要类型类。

\n

如果有多种方法,则不会发生任何变化;如果有多个约束,通常也不会发生任何变化。如果您的理念是数据类型应该仅由它们满足的属性而不是它们本身来表征,那么另一方面是函数的类型应该仅要求其参数类型具有它们所需的属性。如果他们的要求超出了他们的需要,他们就会过早地致力于比必要的更具体的类型。

\n

因此,一个具有可打印表示形式的可排序数字键类型的类:

\n
class (Ord a, Num a, Show a) => Key a where\n  firstKey :: a\n  nextKey :: a -> a\n  sortKeys :: [a] -> [a]\n  keyLength :: a -> Int\n
Run Code Online (Sandbox Code Playgroud)\n

和单个实例:

\n
instance (Ord a, Num a, Show a) => Key a where\n  firstKey = 1\n  nextKey x = x + 1\n  sortKeys xs = sort xs\n  keyLength k = length (show k)\n
Run Code Online (Sandbox Code Playgroud)\n

更惯用的方式是写为一组函数,仅根据它们所需的属性来约束类型:

\n
firstKey :: (Num key) => key\nfirstKey = 1\n\nnextKey :: (Num key) => key -> key\nnextKey = (+1)\n\nsortKeys :: (Ord key) => [key] -> [key]\nsortKeys = sort\n\nkeyLength :: (Show key) => key -> Int\nkeyLength = length . show\n
Run Code Online (Sandbox Code Playgroud)\n

另一方面,如果您发现为抽象类型提供正式的“名称”很有帮助,并且更喜欢编译器帮助强制使用此类型,而不是仅使用诸如“”之类的带有令人回味的名称的类型变量key,我想您可以使用为此目的键入类。但是,您的类型类可能不应该有任何方法。你想写:

\n
class (Ord a, Num a, Show a) => Key a\n
Run Code Online (Sandbox Code Playgroud)\n

然后是一堆使用类型类的顶级函数。

\n
firstKey :: (Key k) => k\nfirstKey = 1\n\nnextKey :: (Key k) => k -> k\nnextKey = (+1)\n\nsortKeys :: (Key k) => [k] -> [k]\nsortKeys = sort\n\nkeyLength :: (Show k) => k -> Int\nkeyLength = length . show\n
Run Code Online (Sandbox Code Playgroud)\n

您的整个程序可以这样编写,并且在您开始选择具体类型并将它们全部记录在一个地方之前,实际上不需要任何实例。例如,在您的程序中,您可以通过提供具体类型的实例并使用它Main.hs来提交密钥:Int

\n
instance Key Int\nmain = print (nextKey firstKey :: Int)\n
Run Code Online (Sandbox Code Playgroud)\n

这个具体实例还避免了对不可判定实例和有关脆弱绑定的警告等扩展的需要。

\n