在 Haskell 中表达对 class header 或 class 方法的约束?

In Haskell, express constraints on class header or class methods?

我最近意识到了这件事:

一方面:

在 class header 上指定的约束必须在该 class 的实例上再次指定,但在其他地方使用 class 作为约束不需要重新导入 class 约束。他们暗自满意。

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
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

另一方面:

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

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
class OtherClassB a where otherMethodB :: a -> Bool 
instance (ClassB a, Ord a) => OtherClassB a where otherMethodB = methodB -- compiler forced me to add (Ord a)

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

更具体地说,当我有一组条件时,我知道类型 class 中的所有方法都应该满足,我应该在类型 class header 中编写条件,或者在每个方法前面? 我是否应该在类型 class 定义中编写约束?

简答

这是我对 class 声明和实例定义中的约束的一般建议。请参阅下文以获得更长的解释和示例的详细描述。

  1. 如果你有 class 具有逻辑关系的类型使得一个类型属于 class Base 而不属于 [=] 在逻辑上是不可能的272=] Super,在class声明中使用约束,像这样:

     class Super a => Base a where ...
    

    一些例子:

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

     -- don't do this
     instance SomeClass1 a
     -- or this
     instance (Eq a) => SomeClass1 a
    

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

     instance (Ord a, Ord b) => Ord (a, b) where
       compare (x1,x2) (y1,y2) = case compare x1 x2 of
         LT -> LT
         GT -> GT
         EQ -> compare x2 y2
    
  3. 不要对 class 方法使用约束,除非 class 应根据可用的约束支持不同类型的不同方法子集。

长答案

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

class (Big a) => Small a

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

举个具体的例子,在现代Haskell中,FunctorApplicative的超class,是[=53的超class =].所有Monad都是Applicative,所有Applicative都是Functor,所以这个superclass关系反映了这些类型集合之间的逻辑关系,并且还提供了能够使用 monad(do-notation、>>=return)、applicative(pure<*>)和 functor 的便利(fmap<$>) 接口仅使用 Monad m 约束。

这种超级class关系的结果是任何Monad实例必须伴随着ApplicativeFunctor 实例向编译器提供证据,表明满足必要的 superclass 约束。

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

instance Ord a => Ord [a] where ...

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

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

instance SomeClass a where ...

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

尽管如此,实际情况是:

class (Ord a) => ClassA a

引入了一个逻辑类型级别的事实,即 class ClassA 的所有类型也是 class Ord 的类型。然后,您将展示适用于所有类型的 ClassA 实例:

instance ClassA a

但是,这给编译器带来了问题。您的 class 声明已经声明所有类型的 ClassA 也属于 class Ord 在逻辑上是必要的,并且编译器需要 Ord a 约束的证明对于您定义的任何实例 ClassA a。通过编写 instance ClassA a,您大胆地声称所有类型都是 ClassA,但编译器没有证据表明所有 class 都有必要的 Ord a 实例。为此,你必须写:

instance (Ord a) => ClassA a

换句话说,“所有类型 a 都有一个 ClassA 的实例 提供 一个 Ord a 实例也可用” .编译器接受这个作为你只为那些类型 a 定义实例的证据,这些类型具有必需的 Ord a 实例。

当你继续定义:

class OtherClassA a where
  otherMethodA :: a -> Bool
instance (ClassA a) => OtherClassA a where
  otherMethodA x = x <= x && methodA x

因为 OtherClassA 没有 superclass,因此 class 的类型也属于 class Ord,这在逻辑上没有必然性,并且编译器不需要证明这一点。但是,在您的实例 definition 中,您定义了一个适用于所有类型的实例,其 实现 需要 Ord a,以及 ClassA a.幸运的是,您提供了一个 ClassA a 约束,并且因为 OrdClassA 的超 class,所以任何 a 具有ClassA a 约束也有一个 Ord a 约束,因此编译器满足 a 具有两个必需的实例。

当你写:

class ClassB a where
  methodB :: (Ord a) => a -> Bool

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

instance ClassB (Int -> Int) where
  methodB _ = False

为函数 Int -> Int 定义了一个实例(此类型没有 Ord 实例)。但是,任何对此类类型 use methodB 的尝试都需要一个 Ord 实例:

> methodB (*(2::Int))
...  • No instance for (Ord (Int -> Int)) ...

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

class Seq s a where
  fromList :: [a] -> s a
  elem     :: Eq a => a -> s a -> Bool

您可以定义序列 Seq s a,而元素 a 没有逻辑上的可比性。但是,如果没有 Eq a,您只能使用这些方法的一个子集。如果您尝试将需要 Eq a 的方法与没有此类实例的类型 a 一起使用,则会出现错误。

无论如何,您的实例:

instance ClassB a where
  methodB x = x <= x

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

在你的最后一个例子中:

class OtherClassB a where
  otherMethodB :: a -> Bool

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

instance OtherClassB a where
  otherMethodB _ = False

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

instance OtherClassB a where
  otherMethodB = methodB

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

instance (ClassB a) => OtherClassB a where
  otherMethodB = methodB

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

instance (ClassB a, Ord a) => OtherClassB a where
  otherMethodB = methodB

您提供的实例需要 ClassB a(至 运行 methodB)和 Ord a(因为 methodB 将其作为附加要求) , 所以你需要告诉编译器这个实例适用于所有类型 a provided ClassB aOrd a 实例都可用。编译器对此很满意。

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

从您的示例和后续评论来看,您似乎在(错误地)使用类型 classes 来支持一种特定的编程风格,这种编程风格避免在绝对必要之前提交具体类型。

(顺便说一句,我曾经认为这种风格是个好主意,但我逐渐认为它几乎没有意义。Haskell 的类型系统使重构变得如此容易以至于几乎没有致力于具体类型的风险,具体程序往往比抽象程序更容易阅读和编写。但是,许多人已经使用这种编程风格获利,我可以想到至少有一个高质量的库 (lens) 可以非常有效地将其发挥到极致。所以,没有判断力!)

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

class (Ord a) => ClassA where method :: a -> Bool
instance (Ord a) => ClassA where methodA x = x <= x

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

methodA :: (Ord a) => a -> Bool
methodA x = x <= x

因为 class 和实例没有任何用处。 classes 类型的要点是提供临时多态性,允许您拥有一个函数 (methodA),该函数对不同类型具有不同的实现。如果所有类型只有一个实现,那只是一个普通的旧参数多态函数,不需要类型 class。

如果有多个方法,则不会发生任何变化,如果有多个约束,通常也不会发生任何变化。如果您的哲学是数据类型应该仅通过它们满足的属性而不是它们是什么来表征,那么另一方面,函数的类型应该只要求它们的参数类型具有它们需要的属性。如果他们要求的比他们需要的多,他们就会过早地承诺一个比必要的更具体的类型。

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

class (Ord a, Num a, Show a) => Key a where
  firstKey :: a
  nextKey :: a -> a
  sortKeys :: [a] -> [a]
  keyLength :: a -> Int

和一个实例:

instance (Ord a, Num a, Show a) => Key a where
  firstKey = 1
  nextKey x = x + 1
  sortKeys xs = sort xs
  keyLength k = length (show k)

更习惯地写成一组函数,这些函数仅根据它们需要的属性来限制类型:

firstKey :: (Num key) => key
firstKey = 1

nextKey :: (Num key) => key -> key
nextKey = (+1)

sortKeys :: (Ord key) => [key] -> [key]
sortKeys = sort

keyLength :: (Show key) => key -> Int
keyLength = length . show

另一方面,如果您发现为抽象类型使用正式的“名称”会有所帮助,并且希望编译器帮助强制使用此类型,而不是仅仅使用类型变量,如“key " 具有令人回味的名称,我想您可以为此目的使用类型 classes。但是,您的类型 classes 可能不应该有任何方法。你想写:

class (Ord a, Num a, Show a) => Key a

然后是一堆使用类型 class.

的顶级函数
firstKey :: (Key k) => k
firstKey = 1

nextKey :: (Key k) => k -> k
nextKey = (+1)

sortKeys :: (Key k) => [k] -> [k]
sortKeys = sort

keyLength :: (Show k) => k -> Int
keyLength = length . show

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

instance Key Int
main = print (nextKey firstKey :: Int)

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