文章
Get Programming with Haskell-16
你将更仔细地研究一些我们已经介绍过的类型。你这样做是为了更多地了解 Haskell 类型的独特之处以及如何使用类型设计程序。到目前为止,你所看到的大多数类型都是代数数据类型。代数数据类型是通过组合其他类型而形成的任何类型。理解代数数据类型的关键在于准确地知道如何组合其他类型。幸运的是,只有两种方法。你可以使用“与”组合多种类型(例如,一个名字是一个 String 与另一个 String),或者你可以使用“或”组合类型(例如,一个 Bool 是一个 True 数据构造器 或一个 False 数据构造器)。通过使用“与”组合其他类型而形成的类型称为积类型(product types)。使用“或”组合而形成的类型称为和类型(sum types)。
## 1. 积类型(Product types)——使用“and”组合类型 积类型是通过使用“and”组合两个或多个现有类型创建的。以下是一些常见的示例: - 一个分数可以定义为一个分子 (`Integer`) *和*一个分母(另一个 `Integer`)。 - 一个街道地址可能是一个号码 (`Int`) *和*一个街道名称 (`String`)。 - 一个邮寄地址可能是一个街道地址 *和*一个城市 (`String`) *和*一个州 (`String`) *和*一个邮政编码 (`Int`)。 尽管积类型的名称可能使这种组合类型的方法听起来很复杂,但这却是所有编程语言中最常见的定义类型的方式。几乎所有的编程语言都支持积类型。最简单的例子是 C 语言中的 `struct`。以下是 C 语言中一个表示书籍和作者的 `struct` 示例。 ```c struct author_name { char *first_name; char *last_name; };
struct book { author_name author; char *isbn; char *title; int year_published; double price; };
在这个例子中,你可以看到 `author_name` 类型是通过组合两个 `String`(对于不熟悉 C 语言的人来说,`char *` 在 C 语言中表示字符数组)而构成的。`book` 类型是通过组合一个 `author_name`、两个 `String`、一个 `Int` 和一个 `Double` 而构成的。`author_name` 和 `book` 都是通过使用“与”组合其他类型而构成的。C 语言的 `struct` 是几乎所有语言(包括类和 JSON)中类似类型的前身。在 Haskell 中,我们的 `book` 示例看起来会是这样。
```haskell
-- C中的struct转换到Haskell中
data AuthorName = AuthorName String String
data Book = Author String String Int
最好使用record语法来编写一个更像 C 语言结构体的 Book 版本。
-- 使用record语法展示与C结构体的相似性
data Book = Book {
author :: AuthorName ,
isbn :: String,
title :: String,
year :: String,
price :: Double
}
Book 和 AuthorName 是积类型的示例,并且几乎每种现代编程语言中都有类似的概念。有趣的是,在大多数编程语言中,使用“and”组合类型是创建新类型的唯一方式。
1.1 积类型的诅咒:层次化设计#
仅通过组合现有类型来创建新类型会导致一种有趣的软件设计模型。由于只能通过添加来扩展概念的限制,你会被限制在自顶向下的设计中,从你能想象到的最抽象的类型表示开始。这是基于类层次结构设计软件的基础。
例如,假设你正在编写 Java 代码,并且想要开始为一家书店建模数据。你从前面的 Book 示例开始(假设 Author 类已经存在)。
public class Book {
Author author;
String isbn;String title;
int yearPublished;
double price;
}
这在你想起来你还想在书店里卖黑胶唱片之前都运行得很好。你的 VinylRecord 的默认实现看起来像这样。
public class VinylRecord {
String artist;
String title;
int yearPublished;
double price;
}
VinylRecord 与 Book 相似,但差异也足够大,从而导致问题。首先,你无法重用你的 Author 类型,因为并非所有艺术家都有名字;有时艺术家是一个乐队而不是个人。例如,你可以对 Elliott Smith 使用 Author 类型,但不能对 The Smiths 使用。在传统的层次结构设计中,对于 Author 和艺术家不匹配的这个问题,没有好的解决方案(在下一节中,你将看到如何在 Haskell 中解决这个问题)。另一个问题是黑胶唱片没有 ISBN 号。
最大的问题是你想要一个单一的类型来表示黑胶唱片和书籍,这样你就可以创建一个可搜索的库存。因为你只能通过“与”来组合类型,所以你需要开发一个抽象,描述唱片和书籍的所有共同点。然后,你将在单独的类中仅实现差异。这是继承背后的基本思想。接下来,你将创建 StoreItem 类,它是 VinylRecord 和 Book 的超类。这是重构后的 Java 代码。
public class StoreItem {
String title;
int yearPublished;
double price;
}
public class Book extends StoreItem{
Author author;
String isbn;
}
public class VinylRecord extends StoreItem{
String artist;
}
这个解决方案勉强可行。你现在可以编写所有其余的代码来处理 StoreItem,然后使用条件语句来处理 Book 和 VinylRecord。但是,假设你意识到你还订购了一系列可收藏的玩具模型来销售。以下是基本的 CollectibleToy 类。
public class CollectibleToy {
String name;
String description;
double price;
}
为了让一切正常工作,你又一次完全重构了所有代码!现在 StoreItem 只有一个 price 属性,因为这是所有物品共有的唯一值。VinylRecord 和 Book 之间的公共属性必须重新放回到它们各自的类中。或者,你可以创建一个新的类,继承自 StoreItem,并作为 VinylRecord 和 Book 的父类。那么 CollectibleToy 的 name 属性呢?它与 title 不同吗?也许你应该为所有物品创建一个接口!关键是,即使在相对简单的情况下,严格使用产品类型设计也可能很快变得复杂。
从理论上讲,创建对象层次结构是优雅的,并且能够捕捉到关于世界上所有事物如何相互关联的抽象。然而,在实践中,即使是创建微不足道的对象层次结构,也充满了设计上的挑战。所有这些挑战的根源在于,大多数语言中将类型结合在一起的唯一方式是通过“与”(and)。这迫使你从极端的抽象开始,然后再向下发展。不幸的是,现实生活充满了奇怪的边缘情况,这使得这个过程比你通常想要的要复杂得多。
2 和类型(sum types)——使用“or”组合类型#
鉴于和类型仅提供使用“或”组合两种类型的功能,它们却是一种出人意料的强大工具。以下是一些使用“或”组合类型的示例:
- 一个骰子要么是 6 面的骰子,要么是 20 面的骰子,等等。
- 一篇论文的作者要么是一个人 (
String),要么是一群人 ([String])。 - 一个列表要么是一个空列表 (
[]),要么是一个元素与另一个列表的 cons 结果 (a:[a])。 最直接的和类型是Bool。
data Bool = False | True
Bool 类型的一个实例要么是 False 数据构造器,要么是 True 数据构造器。这可能会给人一种错误的印象,认为和类型仅仅是 Haskell 创建枚举类型(存在于许多其他编程语言中)的方式。但是你已经在第11章中看到一个例子,其中和类型可以用于更强大的功能,当时你定义了两种类型的名称。
-- 使用和类型给名称建模
type FirstName = String
type LastName = String
type MiddleName = String
data Name = Name FirstName LastName
| NameWithMiddle FirstName MiddleName LastName
在这个例子中,你可以使用两个类型构造器,它们可以是包含两个 String 的 FirstName,也可以是包含三个 String 的 NameWithMiddle。在这里,在两种类型之间使用“或”可以让你富有表现力地说明类型的含义。将“或”添加到你可以用来组合类型的工具中,在 Haskell 中开启了其他没有和类型的编程语言所不具备的无限可能性。为了了解和类型有多么强大,让我们解决上一节中的一些问题。
一个有趣的起点是作者(author)和艺术家(artist)之间的区别。在我们的例子中,你需要两种类型,因为你假设每本书的作者的名字可以用一个名字和一个姓氏来表示,而制作唱片的艺术家可以用一个人的名字或一个乐队的名字来表示。仅使用积类型来解决这个问题是很棘手的。但是有了和类型,你可以相当容易地解决这个问题。你可以从一个 Creator 类型开始,它要么是 Author,要么是 Artist(你将在接下来定义这些类型)。
-- 一个 `Creator` 类型,它可以是 `Author` 也可以是 `Artist`。
data Creator = AuthorCreator Author | ArtistCreator Artist
你已经有了一个 Name 类型,所以你可以从将 Author 定义为一个 Name 开始。
data Author = Author Name
艺术家的情况稍微复杂一些;正如我们已经提到的,Artist 可以是一个人的名字,也可以是一个乐队的名字。为了解决这个问题,你将使用另一个和类型!
data Artist = Person Name | Band String
这是一个很好的解决方案,但是现实生活中总是会出现一些棘手的边缘情况,比如像 H.P. Lovecraft 这样的作者怎么办?你可以强迫自己使用 Howard Phillips Lovecraft,但为什么要让自己受限于数据模型呢?它应该是灵活的。你可以通过向 Name 添加另一个数据构造器来轻松解决这个问题。
data Name = Name FirstName LastName
| NameWithMiddle FirstName MiddleName LastName
| TwoInitialsWithLast Char Char LastName
请注意,Artist、Author 以及由此产生的 Creator 都依赖于 Name 的定义。但是你只需要更改 Name 本身的定义,而根本不需要担心任何其他使用 Name 的类型是如何定义的。与此同时,你仍然可以从代码重用中受益,因为 Artist 和 Author 类型都受益于在单个地方定义 Name。
作为所有这些的一个例子,这是我们的 H.P. Lovecraft Creator 类型。
hpLovecraft :: Creator
hpLovecraft = AuthorCreator
(Author
(TwoInitialsWithLast 'H' 'P' "Lovecraft"))
虽然本例中的数据构造器可能有些冗长,但在实践中,你很可能会使用一些函数来抽象掉大部分细节。现在想想这个解决方案与你使用积类型所要求的层次化设计可能想出的解决方案相比如何。从层次化设计的角度来看,你需要一个只有 last-name 属性的 Name 超类(因为这是所有三种名称类型唯一共享的属性)。然后你需要为你使用的每个数据构造器创建单独的子类。但是即便如此,像 Andrew W.K. 这样名字中姓氏是单个字符的情况也会完全打破这个模型。而这对于和类型来说却是一个简单的修复。
data Name = Name FirstName LastName
| NameWithMiddle FirstName MiddleName LastName
| TwoInitialsWithLast Char Char LastName
| FirstNameWithTwoInits FirstName Char Char
对于仅限积类型的方式来说,唯一的解决方案是创建一个 Name 类,其中包含一个不断增长的字段列表,而这些字段将成为未使用的属性:
public class Name {
String firstName;
String lastName;
String middleName;
char firstInitial;
char middleInitial;
char lastInitial;
}
这将需要大量的额外代码来确保一切行为正确。此外,你无法保证你的 Name 处于有效状态。如果所有这些属性都有值呢?Java 中的类型检查器无法确保 Name 对象满足你为名称指定的约束。在 Haskell 中,你可以确切地知道只有你显式定义的类型才能存在。
3. 整合你的书店#
现在让我们重新审视我们的书店问题,看看使用和类型进行思考如何能够提供帮助。有了你强大的 Creator 类型,你现在可以重写 Book 类型了。
data Book = Book {
author :: Creator ,
isbn :: String ,
bookTitle :: String ,
bookYear :: Int ,
bookPrice :: Double
}
同样的方式重写我们的VinylRecord
data VinylRecord = VinylRecord {
artist :: Creator ,
recordTitle :: String ,
recordYear :: Int ,
recordPrice :: Double
}
我们的StoreItem可以轻松定义
data StoreItem = BookItem Book
| RecordItem VinylRecord
还有我们的 CollectibleToy
-- 定义 CollectibleToy
data CollectibleToy = CollectibleToy {
name :: String,
descrption :: String ,
toyPrice :: Double
}
将 CollectibleToy 添加到StoreItem 中
data StoreItem = BookItem Book
| RecordItem VinylRecord
| ToyItem CollectibleToy
最后,我们将通过编写一个获取任何商品价格的 price 函数来演示如何构建适用于所有这些类型的函数。
-- 一个使用带有价格函数的 StoreItem 类型的示例:
price :: StoreItem -> Double
price (BookItem book) = bookPrice book
price (RecordItem record) = recordPrice record
price (ToyItem toy) = toyPrice toy
和类型让你能够极大地增强类型的表达能力,同时仍然提供创建相似类型组的便捷方式。