1

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

 3 years ago
source link: https://www.starky.ltd/2021/07/06/basic-haskell-user-defined-types/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

发表于 2021-07-06

| 分类于 Program

|

| 阅读次数: 1

字数统计: 10k

|

阅读时长 ≈ 0:10

ADT (Algebraic data types)

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

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

data Bool = False | True

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

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

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

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

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

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

Prelude> :t Circle
Circle :: Float -> Float -> Float -> Shape
Prelude> :t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape

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

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


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 这类命令时,会报出如下错误:

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。语法如下:

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

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

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 等操作。
比如可以使用如下代码创建一系列半径不同的同心圆:

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 类型的一部分,从而更加容易理解:

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

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

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

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

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

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

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

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


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

Record 语法

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

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 对象中的某个字段的值时,可能就需要借助如下形式的代码:

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 语法。

data Person = Person { firstName :: String
, lastName :: String
, age :: Int
, height :: Float
, phoneNumber :: String
, flavor :: String
} deriving (Show)

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

Prelude> :t flavor
flavor :: Person -> String
Prelude> :t firstName
firstName :: Person -> String

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

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

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"] ++ []

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

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。

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 类型:

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

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

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

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

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

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

Prelude> False < True
True

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

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

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

Prelude> Wednesday
Wednesday
Prelude> show Wednesday
"Wednesday"
Prelude> read "Saturday" :: Day
Saturday

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

Prelude> Saturday == Sunday
False
Prelude> Saturday == Saturday
True
Prelude> Saturday > Friday
True
Prelude> Monday `compare` Wednesday
LT

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

Prelude> minBound :: Day
Monday
Prelude> maxBound :: Day
Sunday

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

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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK