返回文章列表

文章

Get Programming with Haskell-14

你将更深入地了解如何实现已有的类型类。这将使你能够为你自定义的新类型实现类型类,从而利用大量现有的函数功能。

目录
  1. 4. 默认实现和最小完整定义
  2. 5. 实现 Ord
  3. 6. 是否使用 deriving(自动派生)
  4. 你希望输出的是完整的英文州名,而不是构造器名,这就必须手动实现。
  5. 🎯 结论
  6. 7. 更复杂类型的类型类
  7. 8. 类型类路线图
  8. 9. Summary
  9. 📎 参考文章
# 使用类型类 ## 1. 一个需要类型类的类型 你将从建模一个六面的骰子开始。一个不错的默认实现方式是定义一个类似于 `Bool` 的类型,只不过它有六个值而不是两个。你可以将数据构造器命名为 `S1` 到 `S6`,用来表示骰子的六个面。 ```haskell data SixSidedDie = S1 | S2 | S3 | S4 | S5 | S6 ``` 接下来你会想要实现一些有用的类型类。其中也许最重要的类型类是 `Show`,因为你几乎总是希望能够轻松地显示你定义的类型的实例,尤其是在 GHCi 中使用时。在第13课中我们提到,你可以使用 `deriving` 关键字来自动为某个类型类创建实例。你可以像下面这样定义 `SixSidedDie` 类型,然后就不再管它了。 ```haskell data SixSidedDie = S1 | S2 | S3 | S4 | S5 | S6 deriving (Show) ``` 如果你在 GHCi 中使用这个类型,当你输入它们时,会得到一个简单的文本版本的数据构造器作为返回值: ![](https://prod-files-secure.s3.us-west-2.amazonaws.com/4b1b167f-4b52-4031-8233-f4f2689af574/d1c0408a-cbf2-446c-888a-27906a4118f7/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RFNXEDLH%2F20260827%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260827T175110Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEHEaCXVzLXdlc3QtMiJHMEUCIHDuQ5Zd02VAy6A77JCua1XGDCKb0rnAW8mGZ%2F%2BEgvf4AiEAzB8%2Bs5GWMAab6b01pXOu%2BUVMwOBwq0J7j2pyv%2BQEz5Uq%2FwMIOhAAGgw2Mzc0MjMxODM4MDUiDMGb4%2FcmZF76PXQTQyrcA2i9giZ2dCIWzs%2FLU5DDKXj2HhIjPkBhrEOxHXnZ2ymDz2p43eM9nOdXpqRuu%2FUweXsBjmRv6%2Fz%2FFnV%2F%2Bo9VSV2iRuvSImlFsaH%2BcmOKV2IuKTDYY82R0dmLdQ1aRMPWCu%2BNlikGyGmS38iC0TDK0EnJOkVYJM9Vsq6DWa57lacVqSwRskLI18afWy3K%2BgkhOyWuLX3E9tWfb78vsOcx%2BuQzMRgto2%2BU%2B0mQIqJX9HVL16cNmOq3zJZ9TJpPXLE49ZNfx37yoLzDnReOxX9MYXJX%2BtyElNd4VEOagv6zC43gJ69fCV6VgprH4nEpXn7ef%2FgeECYwYM4jdjjm8jbVnWdeNl1D18nsUjLPlgH6EPdUlxOO4XCEk8zf4%2BfTa%2BFOXvU%2Fq2TvETyeZRjZi3Drg%2BJatzldjeg7L6oy6EnfCOj38hETKEQJe1d56iOtKic8n9AcwwwOIV3NNGEVc2sBLwLoVTWXObCUuWQtgDcwArFhuOZrpR9y2%2F439h2IrKcFXi6vC86VGGKAcSgL6NPBFKv32QrEM%2BVTTTiK2hGEpBXM5uQqE7yOWdouYbPg8tft6DQPj4ZVrGgBuZgdprI6bsiptYFiH4mw8%2F3K9LojXNys4N1eZ03pyI2QS0mwMPjMwdQGOqUB2nBISGP9DQIajWbj61WBacDKwmCZXwuo2VrwIFxRjPOAe%2FePaJcXIHt3u4vaisXhap%2Ftll%2FRij31UILDN8zXJEkHE6AjF6diqTklyYBFbjdr0blTDOjvG7vIfj0zAvxDeccPqkJmbwBzSrMDOKK95kGtOSsDecfD56HKrtFQ7Kje%2BBQ3sxoIN8Pl6LmICOK%2BYE%2FWIAy7Fzu2ofNkOt9%2Fpry%2B50Jv&X-Amz-Signature=646cecaf740f171e6ad5d675393dc82c8b3f51aec3b2c30ca02ad82053ab1511&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) 这有点无聊,因为你只是打印出数据构造器,从实现的角度来看它们比从可读性角度更有意义。相反,我们可以打印出每个数字的英文单词。 ## 2. 实现`Show` 要做到这一点,你需要实现你的第一个类型类 `Show`。只需要实现一个函数(在类型类中,我们称之为方法),这个函数就是 `show`。 下面是如何实现你的类型类的方式: ```haskell instance Show SixSidedDie where show S1 = "One" show S2 = "Two" show S3 = "Three" show S4 = "Four" show S5 = "Five" show S6 = "Six"
在这里,我们为 `SixSidedDie` 类型定义了 `show` 方法,并为每个数据构造器(S1 到 S6)返回了相应的英文单词表示。这样,当你在 GHCi 中打印 `SixSidedDie` 类型的值时,它将输出更具可读性的英文单词,而不是数据构造器的名字。
![](https://prod-files-secure.s3.us-west-2.amazonaws.com/4b1b167f-4b52-4031-8233-f4f2689af574/74984343-d7ac-40fa-9d87-37659a402329/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466RFNXEDLH%2F20260827%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260827T175110Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEHEaCXVzLXdlc3QtMiJHMEUCIHDuQ5Zd02VAy6A77JCua1XGDCKb0rnAW8mGZ%2F%2BEgvf4AiEAzB8%2Bs5GWMAab6b01pXOu%2BUVMwOBwq0J7j2pyv%2BQEz5Uq%2FwMIOhAAGgw2Mzc0MjMxODM4MDUiDMGb4%2FcmZF76PXQTQyrcA2i9giZ2dCIWzs%2FLU5DDKXj2HhIjPkBhrEOxHXnZ2ymDz2p43eM9nOdXpqRuu%2FUweXsBjmRv6%2Fz%2FFnV%2F%2Bo9VSV2iRuvSImlFsaH%2BcmOKV2IuKTDYY82R0dmLdQ1aRMPWCu%2BNlikGyGmS38iC0TDK0EnJOkVYJM9Vsq6DWa57lacVqSwRskLI18afWy3K%2BgkhOyWuLX3E9tWfb78vsOcx%2BuQzMRgto2%2BU%2B0mQIqJX9HVL16cNmOq3zJZ9TJpPXLE49ZNfx37yoLzDnReOxX9MYXJX%2BtyElNd4VEOagv6zC43gJ69fCV6VgprH4nEpXn7ef%2FgeECYwYM4jdjjm8jbVnWdeNl1D18nsUjLPlgH6EPdUlxOO4XCEk8zf4%2BfTa%2BFOXvU%2Fq2TvETyeZRjZi3Drg%2BJatzldjeg7L6oy6EnfCOj38hETKEQJe1d56iOtKic8n9AcwwwOIV3NNGEVc2sBLwLoVTWXObCUuWQtgDcwArFhuOZrpR9y2%2F439h2IrKcFXi6vC86VGGKAcSgL6NPBFKv32QrEM%2BVTTTiK2hGEpBXM5uQqE7yOWdouYbPg8tft6DQPj4ZVrGgBuZgdprI6bsiptYFiH4mw8%2F3K9LojXNys4N1eZ03pyI2QS0mwMPjMwdQGOqUB2nBISGP9DQIajWbj61WBacDKwmCZXwuo2VrwIFxRjPOAe%2FePaJcXIHt3u4vaisXhap%2Ftll%2FRij31UILDN8zXJEkHE6AjF6diqTklyYBFbjdr0blTDOjvG7vIfj0zAvxDeccPqkJmbwBzSrMDOKK95kGtOSsDecfD56HKrtFQ7Kje%2BBQ3sxoIN8Pl6LmICOK%2BYE%2FWIAy7Fzu2ofNkOt9%2Fpry%2B50Jv&X-Amz-Signature=9af3853b1bcebe5c89873dd5d946024c23c74095be71097c4e251590417164e8&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject)
## 3. 类型类(type classes)和多态性(polymorphism)
一个可能会出现的问题是,为什么必须这样定义`show`?为什么需要声明一个类型类的实例?令人惊讶的是,如果你移除早期的实例声明,以下代码仍然可以正常编译。
```haskell
show :: SixSidedDie -> String 
show S1 = "one"
show S2 = "two"
show S3 = "three"
show S4 = "four"
show S5 = "five"
show S6 = "six"

但是,如果你将这段代码加载到 GHCi 中,你会遇到两个问题。首先,GHCi 不再默认打印你的数据构造函数。其次,即使你手动使用 show,也会出现一个错误: “Ambiguous occurrence 'show'” 你还没有学习 Haskell 的模块系统,但问题在于你刚才为 show 编写的定义与类型类中定义的另一个 show 冲突了。当你创建一个 TwoSidedDie 类型并尝试为其编写 show 时,你可以看到真正的问题。

data TwoSideDie = One | Two
show :: TwoSideDie -> String
show One = "one"
show Two = "two"

编译错误,我们定义了多个”show” 问题在于,默认情况下,你希望根据使用的类型为 show 提供不同的行为。你在这里需要的就是多态性。多态性意味着同一个函数根据它处理的数据类型表现出不同的行为。多态性在面向对象编程中非常重要,在 Haskell 中也是如此。在面向对象编程中,与 show 等效的就是 toString 方法,它是所有可以转化为字符串的类共有的方法。类型类是 Haskell 中实现多态性的方式,如图所示。

4. 默认实现和最小完整定义#

现在你可以为你的 SixSidedDie 类型生成有趣的字符串了,接下来,你可能需要判断两个骰子是否相同。这意味着你需要实现 Eq 类。这也很有用,因为 Eq 是 Ord 的超类。在上一章中简单提到过这个关系,但没有给它命名。说 Eq 是 Ord 的超类意味着每一个 Ord 的实例必须也是 Eq 的实例。最终,你希望能够比较 SixSidedDie 的数据构造函数,这就需要实现 Ord,所以首先你需要实现 Eq。 在 GHCi 中使用 :info 命令,你可以查看 Eq 类的定义:

class Eq a where
	(==) :: a -> a -> Bool 
	(/=) :: a -> a -> Bool

你只需要实现两个方法:等于方法 (==) 和不等于方法 (/=)。考虑到 Haskell 到目前为止的智能,这似乎是多余的工作。毕竟,如果你知道 (==) 的定义,那么 (/=) 的定义就应该是 (==) 的反义词。当然,可能有一些例外情况,但在绝大多数情况下,如果你知道其中一个,就可以推导出另一个。 事实证明,Haskell 足够聪明,能够自动推导这一点。类型类可以为方法提供默认实现。如果你定义了 (==),Haskell 就可以自动推导出 (/=) 的含义,而无需额外帮助。

instance Eq SixSidedDie where
    (==) S6 S6 = True
    (==) S5 S5 = True
    (==) S4 S4 = True
    (==) S3 S3 = True
    (==) S2 S2 = True
    (==) S1 S1 = True
    (==) _ _ = False

这很有用,但你怎么知道究竟需要实现哪些方法呢?:info 命令是一个很好的信息来源,可以让你随时查看,但它并不是完整的文档。一个更为详细的信息来源是 Hackage,Haskell 的中央包库。Hackage 可以在网上找到,网址是 https://hackage.haskell.org。如果你访问 Eq 在 Hackage 上的页面 (https://hackage.haskell.org/package/base/docs/Data-Eq.html),你可以获得更多关于 Eq 的信息(可能比你想要的还要多!)。对于我们的目的,最重要的部分是一个叫做“Minimum complete definition”(最小完整定义) 的部分。对于 Eq,你会看到如下内容: (==) | (/=) 这更有帮助!要实现 Eq 类型类,你只需要定义 (==) 或 (/=) 之一。就像在数据声明中,| 代表“或者”。如果你提供了这两个选项中的任何一个,Haskell 就能为你推导出剩下的部分。

**Hackage **和 Hoogle 尽管 Hackage 是 Haskell 信息的中央仓库,但你可能会发现搜索特定类型有点麻烦。为了解决这个问题,Hackage 可以通过一个非常棒的接口进行搜索,这个接口叫做 Hoogle。Hoogle 可以在 https://hoogle.haskell.org/ 找到。Hoogle 允许你通过类型和类型签名进行搜索。例如,如果你搜索 a -> String,你将获得 show 函数以及其他各种函数的结果。仅仅是 Hoogle 就足以让你爱上 Haskell 的类型系统。

5. 实现 Ord#

骰子最重要的特性之一是它们的各个面之间是有顺序的。Ord 类型类定义了一些用于比较类型的有用函数:

class Eq a => Ord a where
    compare :: a -> a -> Ordering 
    (<) :: a -> a -> Bool 
    (<=) :: a -> a -> Bool 
    (>) :: a -> a -> Bool 
    (>=) :: a -> a -> Bool 
    max :: a -> a -> a 
    min :: a -> a -> a

幸运的是,在 Hackage 上你可以发现,实现 Ord 类型类时只需要定义 compare 方法。 compare 方法接受你的类型的两个值,并返回一个 Ordering 类型的结果。 你在开始章节中学习 sort 函数时曾简要见过 Ordering 类型。它和 Bool 类似,但有三个数据构造器。下面是它的定义:

data Ordering = LT | EQ | GT

下面是 compare 的一个部分定义示例:

compare :: a -> a -> Ordering

  • LT 代表小于(Less Than)
  • EQ 代表等于(Equal)
  • GT 代表大于(Greater Than) 这使你可以在实现 Ord 类型类时,轻松比较你定义的数据类型的不同值。

6. 是否使用 deriving(自动派生)#