Haskell中的超类约束与示例声明

fjaof16o  于 2022-11-14  发布在  其他
关注(0)|答案(4)|浏览(164)

在Haskell中,我们可以像使用超类约束一样使用示例声明。我将使用Prelude中的SemigroupMonoid给予一个例子。下面是Semigroup的简化定义:

class Semigroup a where
        (<>) :: a -> a -> a

以下是Prelude定义Monoid的简化版本,其中使用了超类约束:

class Semigroup a => Monoid a where
        mempty  :: a
        mappend :: a -> a -> a
        mappend = (<>)

如果我没弄错的话,超类约束似乎可以用示例声明来替换,如下所示:

class Monoid a where
        mempty  :: a
        mappend :: a -> a -> a

instance Monoid a => Semigroup a where
        (<>) = mappend

Haskell有超类约束的理由吗?当一个人似乎可以用示例声明(用约束)做同样的事情时?哪一个应该是首选的?是不是只要可能就有超类约束?

n6lpvg4x

n6lpvg4x1#

在阅读示例约束的方式上有一个根本性的错误。重要的是(一开始并不明显)要记住,在*提交到示例之后*要检查示例上的约束。
要查看一个示例是否与一个给定的类型匹配,你
=>右边的东西(示例头)。如果匹配,那么编译器提交该示例,并且该示例上的约束成为类型检查器要考虑的附加要求。如果这些都失败了,你将得到一个类型错误;编译器只会在示例头完全不匹配的情况下才考虑其他示例。
这意味着instance Monoid a => Semigroup a并不意味着“任何Monoid类型都是Semigroup“,而是意味着“每个a类型都是Semigroup,但是要使用Semigroup方法和a,需要首先证明Monoid a”。
由于这些规则,不可能有其他Semigroup示例(因为instance ... => Semigroup a匹配每种类型)。这意味着您编写的示例的含义实际上是从原始类约束向后:class Semigroup a => Monoid a意味着“如果一个类型是Monoid,那么它也是Semigroup“,而instance Monoid a => Semigroup a实际上意味着“如果一个类型是Semigroup,那么它也是Monoid“。
这就是你真正需要知道的。对于为什么这两个特性存在的问题,答案是“它们不一样”,一旦你知道了它们的含义,哪个更好的答案就很清楚了;因为它们的意思不同,你必须考虑你想说的,并根据具体情况使用最合适的特性。这个答案的其余部分试图解释为什么编译器是这样工作的;我发现当你理解规则试图避免的问题时,它会更直观。
这些规则的原因基本上是为了支持模块的单独编译,同时试图保持类型类系统的一致性(这意味着,每当我们解析给定类型的示例时,我们希望能够假设它将是完全相同的示例定义,在整个多包程序中一致,无论我们如何找到它;像Data.Map这样的API基本上依赖于这个属性)编译器一次只处理一个模块(以及它的导入,所以它不想假设它能看到最终完整程序中使用的每一个示例声明。这意味着编译器永远不想做任何只有当一个示例不存在时才是正确的事情。我没见过这样的例子。
这是解释一切的指导原则:编译器只能使用关于示例的肯定信息(一个类型确实有一个示例)来编译你的程序。2它只能使用否定信息(一个示例似乎丢失了)来拒绝你的程序,永远不会接受它。
因此更具体地说,如果我们有:

module Monoid where

class Monoid a where
  mappend :: a -> a -> a
  mempty :: a

class Semigroup a where
  (<>) :: a -> a -> a

instance Monoid a => Semigroup a where
  (<>) = mappend

且还

module DataDefinition where

data Example = Constructor

最后

module DataUsage where

import DataDefinition
import Monoid

foo :: Example -> Example -> Example
foo x y = x <> y

编译器将拒绝foo,因为Example缺少Monoid示例,而不是缺少Semigroup示例。它必须使用Monoid a => Semigroup a示例解析Semigroup Example约束;因为最终程序中的另一个模块(导入MonoidDataDefinition,但不导入或由DataUsage导入)理论上可能具有Monoid Example示例,并且还使用ExampleModule a => Semigroup a示例。如果发生这种情况,那么维护整个程序始终使用T的同一个Semigroup示例这一属性的唯一方法是在DataUsage中我也必须使用Monoid a => Semigroup a。因为我们不能排除这种情况是否发生在另一个模块中,所以我们必须这样做而不管它是否真的发生了!但是这要求我们必须寻找一个Monoid T示例,我们没有找到,我们可以使用Monoid示例的不存在来报告错误。
如果我们希望T仅为Semigroup,则添加:

instance Semigroup T where
  C <> C = C

那么我们就会得到一个关于示例重叠的错误,因为instance Semigroup Tinstance Monoid a => Semigroup abar中的用法匹配。我们不能允许这种情况的逻辑有点微妙,但归结起来是一样的。可以想象,另一个模块可能在不知道的情况下使用T,该模块不会使用特定的Semigroup T示例(因为它使用的是多态类型,而不是特定的T)。但它可能使用Monoid a => Semigroup a示例,如果在T上调用了它(这需要在某个地方存在一个Monoid T示例,该示例未导入到我们现在正在编译的DataUsage模块中),则它将使用带有T的通用Semigroup示例,而不是使用特定于T的示例。因此,既然我们不能排除这种可能性,* 我们 * 也不能使用特定的示例!既然当您同时具有特定的示例和通用的示例可以匹配相同的类型时,重叠的示例被简单地报告为自身的错误(尽管我相信只有当试图解决相关的约束时;从技术上讲,如果您从不尝试将重叠示例与它们都匹配的任何类型一起使用,则可以包含重叠示例)。
GHC中的编译器扩展允许您放宽这些规则,允许通过在每个使用位置选择“最佳匹配”来使用多个重叠的示例,而不是要求只有一个可能的匹配。我将在GHC用户指南的相应章节中解释所有血淋淋的细节。
这可能是一个强大的特性,但也可能有点脆弱;如果GHC从匹配的可用示例中选择了一个与您所认为的不同的示例,这通常会导致难以诊断的细微错误。根据我的经验,重叠更常用于特殊用途的类,在这些类中,单个库提供了预期存在的所有示例,而不是像Semigroup这样的通用对象,后者预计会在许多包中广泛示例化。
我不建议您尝试使用重叠示例,除非您真的非常熟悉和熟悉正常的示例解析规则,并且知道您正在尝试做的事情没有重叠示例就无法很好地建模。特别是我不建议将重叠示例视为编写“通用”示例(如instance Monoid a => Semigroup a)的一种方式,这看起来很诱人,但我认为不值得。
1实际上,如果有其他示例的头部与之匹配,则其本身将被检测为错误!

hxzsmxv2

hxzsmxv22#

如果你写instance Monoid a => Semigroup a,你实际上是在声明SemigroupMonoid的一个同义词。有了这个示例,所有的Monoid示例都将是Semigroup示例--到目前为止,它就像真实的的超类关系。但反之亦然,任何出现在代码中任何地方的Semigroup a约束都将立即匹配示例头。也就是说,实际上你也有相反的关系:类型成为Semigroup示例的唯一方法是首先成为Monoid的示例,这当然不是您想要的。
有一种方法可以避免这种情况:您 * 可能 * 仍然有重叠的示例,这些示例示例化Semigroup而不示例化Monoid

instance {-# OVERLAPPABLE #-} Monoid a => Semigroup a

但我建议不要这样做重叠示例从根本上违反了类型类在Haskell中的工作原理,它们经常导致奇怪的意外,这是不必要的,因为Haskell已经有了一个很好的方法来声明这种关系:通过将Semigroup作为Monoid的超类!
有一个警告,这是一个公认的情况,出现了相当多:如果你不能控制class B,你写了你自己的class A,它 * 应该 * 是B的超类,但是你不能改变B。事实上,历史上确实发生过这样的事情:在GHC-8之前,the Monoid class had no superclassSemigroupdefined in a separate package
对于这种情况,我真的不知道一个好的解决方案。最好的办法是,如果你能说服B的维护者将你的A超类拉到包中,以拥有正确的A => B超类关系。

class (A x, B x) => B' x
instance (A x, B x) => B' x
thigvfpy

thigvfpy3#

这个示例有一个主要的缺点:

instance Monoid a => Semigroup a where
        (<>) = mappend

第一,积极的一面:该示例确实使每一个幺半群类型如所希望的那样成为半群。
然而,除非我们启用UndecidableInstances,否则它是不被接受的。这不是一个巨大的缺点,因为这是一个基本上良性的扩展,但它是必须记住的。
然而,主要的问题是,示例阻止了任何其他类型的半群,更准确地说,它阻止了我们定义任何不是幺半群的半群。
这是因为一旦我们有了窗体的示例

instance ... => Semigroup a where
   ...

则这将与Semigroup的任何其它示例 * 重叠 *,从而触发错误。

data T = T
instance Semigroup T where
   ...

Overlapping instances for Semigroup T
     Matching instances:
       instance Monoid a => Semigroup a
       instance Semigroup T

实际上,这将半群限制为也是幺半群的那些类型。
从理论上讲,可以取消这个限制,允许重叠示例扩展,但这通常被认为是一个坏主意。一旦我们这样做了,示例选择规则会变得更加复杂和脆弱。现在,如果你仔细考虑哪些示例在范围内,例如避免孤立示例,你可以忍受重叠示例。尽管如此,如果可能的话,最好还是避免它们。

mefy6pfw

mefy6pfw4#

在您的示例中,这两种定义是等效的,因为可以使用mappend派生<>实现。

class Semigroup a => Monoid a where ...

意味着a要成为Monoid,它必须首先成为Semigroup

class Monoid a where ...
instance Monoid a => Semigroup a where ...

意味着如果aMonoid,那么它也是Semigroup,您可以通过提供示例实现来说明这一点。在第一种情况下,如果要为自己的数据类型实现Monoid的示例,则必须同时实现Semigroup示例:

newtype PlusInt = I Int 

class Semigroup a => Monoid a where ...

instance Monoid PlusInt where 
    mempty = I 0 
    mappend (I n) (I m) = I $ n + m
-- ^ Error: No instance for (Semigroup PlusInt)

因此,使用哪一个实际上取决于具体情况:

  • 根据定义,Monoid必须是Semigroup,因此定义为class Semigroup a => Monoid a where ...
  • 根据定义,Applicative必须是Functor,因此类型类是class Functor f => Applicative f where ...
  • 如果aSemigroup,则Maybe aSemigroup,因此您拥有类似instance Semigroup a => Semigroup (Maybe a) where ...的示例声明
  • ...

相关问题