Haskell中的代数数据类型介绍
大多数编程语言都有一种方法来制作复合数据类型。在Haskell中,我们可以通过代数数据类型做到这一点。尽管这个名字一开始听起来很吓人,但它只是一种构造类型的方法。
这篇文章将向你介绍代数数据类型的概念,并告诉你如何使用它们。
进一步阅读了解:
- 如何创建你自己的Haskell数据类型。
- 什么是积和类型?
- 为什么代数数据类型被称为代数型。
- 如何使用常见的Haskell ADT,如
Maybe和Either。 - 为什么函数被称为指数型类型。
产品类型
如何在Haskell中定义一个新的数据类型?
让我们从创建一个二维点的数据类型开始。
新的数据类型是通过data 关键字创建的。要创建一个Point 数据类型,我们需要提供一个类型构造函数(我们类型的名称)和一个数据构造函数(用于构造类型的新实例),然后是我们的类型将包含的类型。
-- [1] [2] [3]
data Point = Point Double Double
deriving (Show, Eq)
-- [1]: Type constructor.
-- [2]: Data constructor.
-- [3]: Types wrapped.
关于这段代码的一些说明:
首先,类型构造函数和数据构造函数之间是有区别的。在我们的例子中,它们的叫法是一样的,但它们可以是Point 和Point2D ,比如说。这经常使初学者感到困惑。
| 类型构造器 | 数据构造器 |
| 类型的名称。 | 用来构造一个类型的实例。 |
| 每个类型只能有一个类型构造函数。 | 每个类型可以有多个数据构造函数(在和类型的情况下)。 |
其次,在上面的类型定义中添加deriving (Show, Eq) ,使得打印该类型的值和比较它们是否相等成为可能。你可以在这篇博文中阅读更多关于派生的内容。
让我们在GHCi中玩玩我们的Point 类型。
我们可以通过数据构造函数创建这个类型的新值。
*Main> a = Point 3 4
*Main> a
Point 3.0 4.0
我们还可以创建函数,对构造函数和里面的值进行模式匹配。
*Main> distance (Point x1 y1) (Point x2 y2) = sqrt ((x1 - x2) ^ 2 + (y1 - y2) ^ 2)
*Main> a = Point 3 4
*Main> b = Point 1 2
*Main> distance a b
2.8284271247461903
一个产品类型的定义
我们把Point (以及所有具有类似结构的类型)称为产品类型。所有的产品类型都结合了同时在数据结构中的多个元素。这就等于说你需要这个类型和那个类型。
多态的数据类型
我们之前创建的Point 数据类型只能包含双精度的浮点数。
在某些情况下,我们会希望它也能与其他数字一起工作。如果是这样,我们需要使它具有多态性(能够与多种不同的数据类型一起工作)。
data PPoint a = PPoint a a
deriving (Show, Eq)
在这里,我们为类型构造函数提供了一个类型变量a ,以后我们可以在类型的定义中使用它。与我们之前的Point 类型相比,PPoint 是一个需要通过为它提供一个具体类型来 "完成 "的类型。
为了更好地说明这一事实,我们可以看一下这两个函数的种类。虽然在这篇文章中不可能完全解释种类,但你可以把它们看作是类型的类型签名。
如果你想阅读更多关于种类的文章,我建议阅读Diogo Castro的这篇文章。
我们可以看到,Point 是一个具体的类型。
*Main> :kind Point
Point :: *
相反,PPoint 是一个接收一个类型并返回一个具体类型的函数。
*Main> :kind PPoint
PPoint :: * -> *
多态产品类型的另一个典型例子是元组类型。
*Main> :info (,)
type (,) :: * -> * -> *
data (,) a b = (,) a b
它接收两个类型--a 和b --并返回一个类型,这个类型的第一槽是a ,第二槽是b 。
记录
我们的Point 类型的各个类型都没有命名。虽然它现在并没有真正增加任何困难,但与像Person String String String String 这样的东西一起工作会让人困惑。
一个替代方法是使用记录,它有字段标签。
data Point = Point
{ x :: Double
, y :: Double
}
deriving (Show, Eq)
*Main> a = Point 3 4
*Main> a
Point {x = 3.0, y = 4.0}
记录也免费为我们提供了getter函数。这些getter的名字和字段名是一样的。
*Main> x a
3.0
*Main> y a
4.0
你可以通过提供你想更新的字段来更新一个记录(其余的保持不变)。
*Main> b = a {x = 4}
*Main> b
Point {x = 4.0, y = 4.0}
而且你可以把这两件事放在一起,以创建功能性的记录更新。
*Main> moveUp point = point {y = y point + 1}
*Main> c = moveUp a
*Main> c
Point {x = 3.0, y = 5.0}
当然,你也可以像处理基本产品类型一样,通过模式匹配来处理记录。
*Main> getX (Point x _) = x
*Main> getX a
3.0
总和类型
还有另一种类型的味道--总和类型--它列出了一个类型可能具有的几种变体。你可能在枚举或联合类型的名称下遇到过类似的东西。
和类型的最简单的例子是Bool 。
-- [1] [2] [3] [4]
data Bool = False | True
-- [1]: Type constructor.
-- [2, 4]: Data constructors.
-- [3]: The pipe operator that separates data constructors.
Bool 可以通过 或 来构建。True False
我们可以制作一些函数,比如对Bool.的值进行否定的函数。
neg :: Bool -> Bool
neg True = False
neg False = True
在野外有很多和类型,你甚至不一定能认出它们。虽然没有这样的定义,但一个Int 可以被认为是对[-2^29 .. 2^29-1] 中所有条目的枚举,比如说。
和类型的一个更不简单的例子是一个既适合二维又适合三维点的数据类型。
data Point = Point2D Double Double | Point3D Double Double Double
deriving (Show, Eq)
现在我们可以写一个函数,通过对数据构造函数的模式匹配来接受两种类型的点。
pointToList :: Point -> [Double]
pointToList (Point2D x y) = [x, y]
pointToList (Point3D x y z) = [x, y, z]
下面是它的一个使用例子。
*Main> a = Point2D 3 4
*Main> b = Point3D 3 4 5
*Main> pointToList a
[3.0,4.0]
*Main> pointToList b
[3.0,4.0,5.0]
和类型的定义
和产品类型一样,和类型也是将基本类型放在一起以创建更复杂类型的一种方式。但与产品类型相比,这些类型中只有一个可以出现在类型的任何给定实例中。
换句话说,使用和类型就像说你需要类型a或类型b:"我需要真或假","我需要一个2D点或一个3D点",等等。
产品类型与总和类型
这里有一个小表格,帮助你记住这两组类型之间的区别。
| 产品类型 | 总和类型 | |
| 例子 | data (,) a b = (,) a b | data Bool = False | True |
| 直觉 | 给我a和b | 给我a或b |
代数数据类型
那么为什么这些类型被称为积和类型呢?让我们来了解一下。
如果你还记得学校的数学课,你曾与数字(111,22 2,33 3,等等)、变量(xxx,yy y,zz z,等等)和运算符(+++,-- -, ∗*∗,等等)一起工作。在代数数据类型中,我们的数字是一个类型可能具有的值的数量,我们的运算符是| 和数据构造器。
对类型进行求和
如果我们在一个类型的定义中使用| ,该类型可以从运算符两侧的类型的值中得到一个值。因此,它可能拥有的值的数量是这些类型拥有的值的数量之和。
例如,False 只包含一个值。True 也只包含一个值。Bool = False | True 包含1+11+ 1 1+ 1 个值。如果我们在 Bool 中增加一个Unknown 的值,我们将有一个有三个可能值的类型,以此类推。
类型的乘法
如果我们使用一个数据构造器,我们的类型可以有我们提供的所有可能的值集的组合。因此,它的可能值的数量是那些类型的值的数量的乘积。
例如,如果我们的类型由两个布尔运算组成,例如一个人是否在回程航班的两个部分都办理了登机手续,它将有2∗2=42*2=4 2∗ 2 = 4个可能值。
data CheckedInStatus = CheckedInStatus Bool Bool
CheckedInStatus 的可能值:
True True
True False
False True
False False
代数数据类型的定义
通过将和与积类型放在一起,我们可以从简单的构件中构造出复杂的类型。
而这正是代数数据类型的工作内容。它们是一个或多个数据构造器的集合,用| 操作符组成。换句话说,它们是积的总和。
-- [1] [2] [3]
data Point = Point2D Double Double | Point3D Double Double Double
-- The Point data type is a sum ([2]) of products ([1], [3]).
常见的ADTs
现在,让我们看一下Haskell中两个常用的ADT:Maybe 和Either 。
也许
我们要讨论的第一个ADT是 [Maybe](https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Maybe.html),你可能在其他语言中遇到过它,即Optional 。
*Main> :info Maybe
type Maybe :: * -> *
data Maybe a = Nothing | Just a
有时,一个函数可能无法为某个输入返回一个值。在这种情况下,我们可以使用Maybe 类型。它有两个可能的数据构造函数:Just 或Nothing 。如果函数成功了,我们就把结果包在Just 中。否则,我们返回Nothing ,它象征着与null类似的东西。
例如,Prelude 有一个可怕的函数叫做head ,它对列表有效,但不是所有的列表。
如果我们用一个空列表调用它,我们会得到一个异常。
*Main> head []
*** Exception: Prelude.head: empty list
我们可以通过对列表的内容进行模式匹配,使它对每个输入都给出一个结果,在空列表的情况下返回Nothing 。
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x : _) = Just x
*Main> safeHead []
Nothing
*Main> safeHead [1, 2, 3]
Just 1
总而言之,你可以认为Maybe 是 null 的一个更安全的替代品。
要么
现在,如果你想知道是什么导致了函数的失败呢?
在这种情况下,我们可以使用另一种数据类型--. [Either](https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Either.html).它的功能类似于其他语言中所说的Result 。
*Main> :info Either
type Either :: * -> * -> *
data Either a b = Left a | Right b
与Maybe 相比,它可以在左边存储一些东西,比如说错误信息。
让我们用Either 重写我们的safeHead 函数。
safeHead :: [a] -> Either String a
safeHead [] = Left "I have no head."
safeHead (x : _) = Right x
*Main> safeHead []
Left "I have no head."
*Main> safeHead [1, 2, 3]
Right 1
总而言之,你可以把Either 作为异常的一个更安全的替代品。
指数类型(函数)
最后让你大吃一惊:函数也可以增加我们的 "类型代数",因为它们也有类型。
想象一下,我们有一个交通灯的数据类型。
data Light = Green | Yellow | Red
类型Light -> Bool 中有多少可能的值?(我们可以想象,它们编码了所有可能的规则,即什么时候过马路是合法的。)
让我们试着把它们都写出来:
- 如果是绿色则为真,如果是黄色或红色则为假。
- 如果是绿色或黄色则为真,如果是红色则为假。
- 如果是绿色、黄色或红色,则为真。
- 如果是黄色或红色,则为真,如果是绿色,则为假。
- 如果是红色则为真,如果是绿色或黄色则为假。
- 如果是绿色、黄色或红色,则为假。
- 如果是绿色或红色则为真,如果是黄色则为假。
- 如果是黄色则为真,如果是绿色或红色则为假。
最后的数字是88 8,或232^3 2 3。
结果是,如果我们有两个类型aa a和bb b,这些类型里面的数值量是∣a∣|a|∣a∣和∣b∣|b| ∣b∣。∣b∣a∣|b|^{|a|}∣b∣∣a∣函数在从aaa到bb b的可能函数集合中。
结论
在这篇文章中,我们探讨了在Haskell中定义自己的数据类型的常用方法。我们研究了乘积类型和和类型,以及它们是如何一起工作来创建代数数据类型的。我们还看了常见的数据类型,如Maybe 和Either ,并看到了函数是如何成为指数数据类型的。
练习
如果你想对代数数据类型进行一些练习,这里有几个快速练习。
-
创建一个名为
Person的数据类型,用于存储一个人的全名、地址和电话号码。创建一个函数来获取一个人的姓名,并创建一个函数来改变他们的电话号码。 -
将练习1中创建的数据类型转换为一个记录。
-
给出一个星期的数据类型。
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday, 写两个函数。isWednesday, 取一个星期中的某一天,如果是星期三则返回True,否则返回False。nextDay,接收一个星期的日期,并返回它后面的星期的日期。
-
回忆一下我们前面讲到的
Maybe数据类型。为一个列表写一个 "尾巴 "函数,其类型签名为safeTail :: [a] -> Maybe [a]。它应该接收一个列表,并返回不带第一个元素的列表,用Just包装。如果不可能,它应该返回Nothing。其行为的一些例子:
* safeTail [ ] -> Nothing * safeTail [1] -> Just [] * safeTail [1,2,3,4,5] -> Just [2,3,4,5]
附录
这里有一个方便的表格,列出了我们在本文中所涉及的一些类型,以及如何计算这些类型的cardinality(该类型有多少成员),假设其组成部分的cardinality是已知的。
注意: ∣a∣|a|∣a∣在表中指出了类型aa的cardinalitya。
| 名称 | Haskell | Cardinality |
| Bool | data Bool = False | True | 1+11+ 11+ 1 |
| 也许 | data Maybe a = Nothing | Just a | 1+∣a∣1+ |a|1+ ∣a∣ |
| 要么 | data Either a b = Left a | Right b | ∣a∣+∣b∣|a|+|b|∣a∣+ ∣b∣ |
| 元组 | (a, b) | ∣a∣∗∣b∣|a|*|b|∣a∣∗ ∣b∣ |
| 函数 | a -> b | ∣b∣a∣|b|^{|a|}∣b∣∣a∣ |
| 二维点 | data Point = Point Double Double | ∣Double∣ ∣Double∣|Double|*|Double|∣Double∣∣ ∣Double∣ |
| 二维或三维点 | data Point = Point2D Double Double | Point3D Double Double Double | ∣Double∣∣Double∣+∣Double∣∣Double∣∣Double|* |Double|+ |Double|* |Double| * |Double|∣Double∣∗ ∣Double∣+ ∣Double∣∗ ∣Double∣∗ ∣Double∣ |