Haskell 基本语法(五)自定义 Types

ADT (Algebraic data types)

类似 BoolIntChar 这些都是内置的数据类型,我们可以使用 data 关键字创建自己的类型。

标准库中的 Bool 类型实际上是这样定义的:

1
data Bool = False | True

其中 = 左边部分指定类型的名称,右边部分叫做 value constructors,用来指定当前类型能够拥有的不同数值。
整个语句可以读作“Bool 类型可以使用 True 或者 False 作为它的值”。

现在思考下应该用怎样的形式表示一种形状。可以使用元组,比如圆圈可以表示为 (43.1, 55.0, 10.4)。前两项表示圆心的坐标,最后一项表示半径。
但这种形式的元组也同样可以表示一个三维向量或者其他对象。更好一点的方法是创建自定义的数据类型。

假设一个形状对象可以是圆或者矩形,则可以定义如下形式的 Shape 类型:

1
data Shape = Circle Float Float Float | Rectangle Float Float Float Float

可以这样理解,Circle value constructor 包含三个 Float 类型的字段,前两个字段是圆心的坐标,最后一个字段表示半径;Rectangle value constructor 包含四个 Float 类型的字段,前两个字段表示左上角顶点的坐标,后两个字段表示右下角的坐标。

Value constructor 实际上是一种函数,所谓的“字段”是函数的参数,最终返回特定的数据类型。

1
2
3
4
Prelude> :t Circle
Circle :: Float -> Float -> Float -> Shape
Prelude> :t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape

接下来就可以针对 Shape 类型定义一个 surface 函数,用来计算某个 Shape 的面积:

1
2
3
surface :: Shape -> Float  
surface (Circle _ _ r) = pi * r ^ 2
surface (Rectangle x1 y1 x2 y2) = (abs (x2 - x1)) * (abs (y2 - y1))

1
2
3
4
5
6
7
8
9
10
Prelude> :{
Prelude| surface :: Shape -> Float
Prelude| surface (Circle _ _ r) = pi * r ^ 2
Prelude| surface (Rectangle x1 y1 x2 y2) = (abs (x2 - x1)) * (abs (y2 - y1))
Prelude| :}
Prelude>
Prelude> surface (Circle 10 20 10)
314.15927
Prelude> surface (Rectangle 0 0 100 100)
10000.0

但是当我们在 ghci 中像调用函数那样直接执行如 Circle 10 20 5 这类命令时,会报出如下错误:

1
2
3
4
5
Prelude> (Circle 10 20 5)

<interactive>:14:1: error:
No instance for (Show Shape) arising from a use of ‘print’
In a stmt of an interactive GHCi command: print it

原因是 Haskell 不清楚如何将此处的自定义类型表示为字符串。当在 ghci 中打印一个值时,实际上 Haskell 调用了 show 函数用来获取对应值的字符串形式,并输出到命令行。
为了使我们的 Shape 类型支持打印输出,需要令其实现 Show typeclass。语法如下:

1
data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)

此时 Shape 类型即可支持打印输出操作:

1
2
3
4
5
Prelude> data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)
Prelude> Circle 10 20 5
Circle 10.0 20.0 5.0
Prelude> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0

Value constructor 实际上就是函数,也因此支持 map、partially apply 等操作。
比如可以使用如下代码创建一系列半径不同的同心圆:

1
2
Prelude> map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]

让我们再定义一个 Point 类型,并令其成为 Shape 类型的一部分,从而更加容易理解:

1
2
data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)

此时的 Circle 类型拥有两个字段,用 Point 类型表示圆圈的圆心,再加一个 Float 类型表示半径。

重新实现下之前的 surface 函数:

1
2
3
surface :: Shape -> Float
surface (Circle _ r) = pi * r ^ 2
surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs (x2 - x1)) * (abs (y2 - y1))

只需要重新定义模式匹配的部分即可。

1
2
3
Prelude> surface (Rectangle (Point 0 0) (Point 100 100))
10000.0
Prelude> surface (Circle (Point 0 0) 24)

还可以创建一个 nudge 函数用来移动某个形状对象的位置。它接收一个形状及其在 x 轴和 y 轴上的偏移量作为参数,返回一个同样大小、不同位置的形状对象。

1
2
3
nudge :: Shape -> Float -> Float -> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))

1
2
Prelude> nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0

Record 语法

假设创建一个名为 Person 的自定义数据类型。它需要包含名字、姓氏、年龄、身高、手机号和最喜欢的冰淇淋种类等字段。
可以使用如下代码实现:

1
2
3
4
Prelude> data Person = Person String String Int Float String String deriving (Show)
Prelude> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
Prelude> guy
Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"

上述代码是可以运行的,但可读性却很差。
当我们需要创建函数来获取 Person 对象中的某个字段的值时,可能就需要借助如下形式的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
firstName :: Person -> String
firstName (Person firstname _ _ _ _ _) = firstname

lastName :: Person -> String
lastName (Person _ lastname _ _ _ _) = lastname

age :: Person -> Int
age (Person _ _ age _ _ _) = age

height :: Person -> Float
height (Person _ _ _ height _ _) = height

phoneNumber :: Person -> String
phoneNumber (Person _ _ _ _ number _) = number

flavor :: Person -> String
flavor (Person _ _ _ _ _ flavor) = flavor

鉴于上述场景中的代码实现有诸多不方便的地方,Haskell 提供了另外一种创建数据类型的方法,即 Record 语法。

1
2
3
4
5
6
7
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
, height :: Float
, phoneNumber :: String
, flavor :: String
} deriving (Show)

通过上述语法,Haskell 会自动创建 firstNamelastNameageheightphoneNumberflavor 等函数,用于访问该类型对象中的对应字段。

1
2
3
4
Prelude> :t flavor
flavor :: Person -> String
Prelude> :t firstName
firstName :: Person -> String

Record 语法的另一个好处在于,Value constructor 中涉及到的所有字段都可以拥有一个有意义的名称,方便各字段之间的相互区分。

1
2
3
4
Prelude> data Car = Car {company :: String, model :: String, year :: Int} deriving (Show)
Prelude> let car = Car {company="Ford", model="Mustang", year=1967}
Prelude> car
Car {company = "Ford", model = "Mustang", year = 1967}

比如创建一个用于表示三维向量的数据类型,可以使用 data Vector = Vector Int Int Int 语句。但这样的语法对于前面的 PersonCar 来讲,其含义就不如使用 Record 语法来得清晰。

参数化类型

Value constructor 可以接收特定数量的参数来生成一个特定类型的值。比如前面的 Car 接收 3 个参数生成一个新的 car。
Type constructor 则可以接收 type 作为参数来生成一个新的类型。

比如内置的 Maybe 的实现:
data Maybe a = Nothing | Just a

其中 a 表示类型参数,Maybe 即为 type constructor。我们可以向 Maybe 传入一个 Char 作为类型参数,就可以得到一个新的 Maybe Char 类型。比如值 Just 'a' 就属于 Maybe Char 类型。
同样的方式可以得到类型 Maybe IntMaybe String 等等。

Maybe 实际上表示一种可选项,它可以是任意某种特定类型的值,也可以什么值都不包含(Nothing)。比如 Maybe Int 类型就表示该类型的值可能包含 Int(值 Just 5),也可能不包含任意类型(Nothing)。

1
2
3
4
5
6
7
8
Prelude> :t Just "Haha"
Just "Haha" :: Maybe [Char]
Prelude> :t Just 84
Just 84 :: Num a => Maybe a
Prelude> :t Nothing
Nothing :: Maybe a
Prelude> Just 10 :: Maybe Double
Just 10.0

实际上还有一种类型涉及到了类型参数,只不过借助了语法糖,其形式稍有不同。该类型就是 list。
list 类型可以接收一个类型参数生成更具体的类型。比如 [Int][Char] 甚至 [[String]] 等等。
但是没有任何值的类型可以是 []。空列表实际上可以表现得像任意类型的列表,其类型是 [a],也因此可以使用如下形式的表达式:[1,2,3] ++ []["ha","ha","ha"] ++ []

下面的代码实现了一种三维的向量类型:

1
2
3
4
5
6
7
8
9
10
data Vector a = Vector a a a deriving (Show)

vplus :: (Num t) => Vector t -> Vector t -> Vector t
(Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)

vectMult :: (Num t) => Vector t -> t -> Vector t
(Vector i j k) `vectMult` m = Vector (i*m) (j*m) (k*m)

scalarMult :: (Num t) => Vector t -> Vector t -> t
(Vector i j k) `scalarMult` (Vector l m n) = i*l + j*m + k*n

上述函数可以作用在 Vector IntVector IntegerVector Float 类型上,只要类型 Vector a 中的 a 属于 Num typeclass。

1
2
3
4
5
6
Prelude> Vector 3 5 8 `vplus` Vector 9 2 8
Vector 12 7 16
Prelude> Vector 3 9 7 `vectMult` 10.0
Vector 30.0 90.0 70.0
Prelude> Vector 4 9 5 `scalarMult` Vector 9.0 2.0 4.0
74.0

类型参数通常用在当 type constructor 中包含的类型对该类型的正常工作并不产生影响时。即我们的自定义类型表现得像某种“盒子”,里面可以放任意的特定类型的值。

派生实例

typeclass 是一种定义了某种行为的接口。若某个类型支持 typeclass 定义的行为,则该类型成为 typeclass 的实例。
比如 Eq typeclass 定义了可以被测试是否相等的行为,而整数之间可以比较是否相等,因此 Int 类型是 Eq typeclass 的实例。与此同时,作为 Eq 接口的函数如 ==/=,则可以直接调用 Int 类型的值,测试它们是否相等(或不相等)。

typeclass 经常会与其他语言如 Java 中的类相混淆。实际上在其他语言中,类可以看作创建对象(包含自身状态和行为)的蓝图;而 typeclass 则更像是接口。
在 Haskell 中,我们先创建某个数据类型,然后考虑该类型有怎样的行为。若该类型可以被排序,则令其成为 Ord typeclass 的实例。这之后该类型的值就可以被 ><compare 等比较大小的函数调用了。

现在假设两个人可以有相同的姓氏、名字和年龄,则这两个人就是“相等”的。由此创建一个可以比较是否相等的 Person 类型:

1
2
3
4
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
} deriving (Eq)

当我们使用 == 比较两个实现了 Eq typeclass 的类型实例时,Haskell 会先用 == 比较两个类型实例的 value constructor 是否相等,再比较类型实例中包含的所有字段的值是否都相等。

1
2
3
4
5
6
7
8
9
10
11
Prelude> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
Prelude> let adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}
Prelude> let mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}
Prelude> mca == adRock
False
Prelude> mikeD == adRock
False
Prelude> mikeD == mikeD
True
Prelude> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}
True

由于 Person 类型现在是 Eq typeclass 的实例,因此我们可以将其传给类型约束是 Eq a 的函数,比如 elem

1
2
3
Prelude> let beastieBoys = [mca, adRock, mikeD]
Prelude> mikeD `elem` beastieBoys
True

ShowRead typeclass 与类型值的字符串转换有关。Show 表示将类型值转换为 String,Read 则表示将 String 转换为特定类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
Prelude> :{
Prelude| data Person = Person { firstName :: String
Prelude| , lastName :: String
Prelude| , age :: Int
Prelude| } deriving (Eq, Show, Read)
Prelude| :}
Prelude> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
Prelude> mikeD
Person {firstName = "Michael", lastName = "Diamond", age = 43}
Prelude> "mikeD is: " ++ show mikeD
"mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"
Prelude> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person
Person {firstName = "Michael", lastName = "Diamond", age = 43}

对于实现了 Ord typeclass 的类型,我们可以根据 value constructor 中值出现的顺序比较同一类型不同值的大小。value constructor 中左侧的值总小于右侧的值。内置的 Bool 类型可以大概视作有如下实现:
data Bool = False | True deriving (Ord)
则在比较 FalseTrue 时,False 总小于 True

1
2
Prelude> False < True
True

借助 EnumBounded typeclass,可以很轻松地实现枚举类型的 ADT。比如:

1
2
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
deriving (Eq, Ord, Show, Read, Bounded, Enum)

由于 Day 类型实现了 ShowRead typeclass,则可以在此类型与字符串之间进行转换:

1
2
3
4
5
6
Prelude> Wednesday
Wednesday
Prelude> show Wednesday
"Wednesday"
Prelude> read "Saturday" :: Day
Saturday

又由于 Day 类型实现了 EqOrd typeclass,则可以在 Day 类型的值之间进行比较:

1
2
3
4
5
6
7
8
Prelude> Saturday == Sunday
False
Prelude> Saturday == Saturday
True
Prelude> Saturday > Friday
True
Prelude> Monday `compare` Wednesday
LT

又由于 Day 类型实现了 Bounded typeclass,我们可以获取“最低”和最高的 Day 类型值:

1
2
3
4
Prelude> minBound :: Day
Monday
Prelude> maxBound :: Day
Sunday

又由于 Day 类型实现了 Enum,因此我们可以对其进行序列类型的操作:

1
2
3
4
5
6
7
8
Prelude> succ Monday
Tuesday
Prelude> pred Saturday
Friday
Prelude> [Thursday .. Sunday]
[Thursday,Friday,Saturday,Sunday]
Prelude> [minBound .. maxBound] :: [Day]
[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]

参考资料

Learn You a Haskell for Great Good!