<译> 简单的代数数据类型

原文见 http://bartoszmilewski.com/20...c++

-> 上一篇:『积与余积编程

咱们已经了解了两种类型组合方式:积与余积。编程中,仅经过这两种类型就能够构造出大部分数据结构。正是由于这一点,保证了数据结构的许多性质都是可复合的。例如,若是知道如何比较两种基本类型的值是否相等,而且知道如何将这种比较方法推广到积与余积类型,那么你就可以自动派生出支持相等运算的复合类型。在 Haskell 中,大部分复合类型可以自动继承相等比较、大小比较以及与字符串的相互转换等运算能力。segmentfault

如今,咱们先近距离的看看编程中的积类型与和类型。数据结构

积类型

编程语言中,对于两个类型的积,官方实现是序对(Pair)。在 Haskell 中,序对是基本的类型构造子;在 C++ 中,积表现为标准库中所定义的相对复杂一些的模板。编程语言

序对

序对并不是严格可交换的:不能用序对 (Bool, Int) 去替换序对 (Int, Bool),即便它们承载相同的信息。然而,在同构意义上,序对是可交换的——swap 可给出这种同构关系:函数

swap :: (a, b) -> (b, a)
swqp (x, y) = (y, x)

你能够将这两个序对看做是采用了不一样的格式存储了相同的数据,这有点像大根堆 vs 小根堆。atom

能够经过序对的嵌套,将任意个数的类型组合到积内,可是更简单的方法是利用:嵌套的序对与元组(Tuple)相等。由于嵌套的序对与元组是同构的。若是你想将 a, b, 与 c 这三种类型组合为积,有两种办法能够作到:spa

((a, b), c)

翻译

(a, (b, c))

这两种类型是不一样的——你不能将其中一种类型传递给一个须要另外一种类型的函数——可是它们所包含的元素是壹壹对应的。有一个函数能够将其中一种类型映射为另外一种:指针

alpha :: ((a, b), c) -> (a, (b, c))
alpha ((x, y), z) = (x, (y, z))

而且,这个函数是可逆的:

alpha_inv :: (a, (b, c)) -> ((a, b), c)
alpha_inv  (x, (y, z)) = ((x, y), z)

所以,这是一种同构,即用了不一样的方式封装了相同的数据。

能够将积类型的构造解释为做用于类型的一种二元运算。从这个角度来看,上述的同构关系看上去有些像幺半群中的结合律:

(a * b) * c = a * (b * c)

只不过,在幺半群的状况中,这两种复合为积的方法是等价的,而上述积类型的构造方法只是在『同构条件下』等价。

若是是在同构的条件下,再也不坚持严格的相等性的话,咱们能够走的更远,并且还能揭示 unit 类型 () 相似于数字乘法中的 1。类型 a 的值与一个 unit 值所构成的序对,除了 a 值以外,什么也没有包含。所以,这种类型

(a, ())

a 是同构的。若是不相信,咱们能够为它们构造出同构关系:

rho :: (a, ()) -> a
rho (x, ()) = x

rho_inv :: a -> (a, ())
rho_inv x = (x, ())

若是用形式化语言描述这些现象,能够说 Set(集合的范畴)是一个幺半群范畴,亦即这种范畴也是一个幺半群,这意味着你可让对象相乘(在此,就是笛卡尔积)。之后我会再多讲讲幺半群范畴,并给出完整的定义。

在 Haskell 中有一种更通用的办法来定义积类型——特别是,过一会就能够看到,这种积类型可由加类型复合而成。这种积类型能够用带有多个参数的构造子来定义,例如可将序对定义为:

data Pair a b = P a b

在此,Pair a b 是由 ab 参数化的类型的名字;p 是数据构造子的名字。要定义一个序对类型,只需向 Pair 类型构造子传递两个类型。要构造一个序对类型的值,只需向数据构造子 P 传递两个合适类型的值。例如,定义一个序对类型的值 stmt,其对应的序对类型为 StringBool 的积:

stmt :: Pair String Bool
stmt = P "This statements is" False

上述代码的第一行是类型声明,即便用 StringBool 类型替换了 Pair 泛型定义中的 ab。第二行向数据构造子 P 传递了具体的字符串与布尔值,从而定义了一个序对类型的值 stmt。类型构造子用于构造类型;数据构造子用于构造值。

由于类型构造子与数据构造子的命名空间是彼此独立的,所以两者能够同名:

data Pair a b = Pair a b

若是你足够学究,就能够发现 Haskell 内建的序对类型只是这种声明的一种变体,前者将 数据构造子 Pair 替换为一个二元运算符 (,)。若是将这个二元运算符像正常的数据构造子那样用,照样能建立序对类型的值,例如:

stmt = (,) "This statement is" False

相似的,能够用 (,,) 来建立三元组,以此类推。

若是不用泛型序对或元组,也能够定义特定的有名字的积类型,例如:

data Stmt = Stmt String Bool

这个类型虽然也是 StringBool 的积,可是它有本身的名字与构造子。这种风格的类型声明,其优点在于能够为相同的内容定义不一样的类型,使之在名称上具有不一样的意义与功能,以免彼此混淆。

在编程中,只依赖元组以及多参数的构造子会让代码混乱,容易出错,由于只能靠咱们的脑壳来记忆各个数据成员的含义。如果能对数据成员进行命名,结果会好一些。支持数据成员命名的积类型也是有的,在 Haskell 中称为记录,在 C 语言中称为 struct

记录

来看一个简单的例子:用两个字符串表示化学元素的名称与符号,用一个整型数表示原子量,将这些信息组合到一个数据结构中。能够用元组 (String, String, Int) 来表示这个数据结构,只不过须要咱们记住每一个数据成员的含义。如今要用一个函数检查化学元素符号是否为其名称的前缀(例如 He 是否为 Helium 的前缀),这须要经过模式匹配从元组中抽取数据成员:

startsWithSymbol :: (String, String, Int) -> Bool
startsWithSymbol (name, symbol, _) = isPrefixOf symbol name

这样的代码很容易出错,而且也难于理解与维护。更好的办法是用记录:

data Element = Element { name         :: String
                       , symbol       :: String
                       , atomicNumber :: Int }

上述的元组与记录是同构的,由于两者之间存在可逆的转换函数:

tupleToElem :: (String, String, Int) -> Element
tupleToElem (n, s, a) = Element { name = n
                                , symbol = s
                                , atomicNumber = a }

elemToTuple :: Element -> (String, String, Int)
elemToTuple e = (name e, symbol e, atomicNumber e)

注意,记录的数据成员的名字自己也是访问这些数据成员的函数。例如,atomicNumber ee 中检出 atomicNumber 的值。也就是说,atomicNumber 是个函数:

atomicNumber :: Eelement -> Int

使用 Element 的记录语法去实现 startsWithSymbol 函数,可读性更好:

startsWithSymbol :: Element -> Bool
startsWithSymbol e = isPrefixOf (symbol e) (name e)

还能够用一下 Haskell 的小花招,将 isPrefixOf 做为中缀运算符,可让 startsWithSymbol 的实现几乎接近人类的语言:

startsWithSymbol e = symbol e `isPrefixOf` name e

原来的两对括号被省略了,由于中缀运算符的优先级低于函数调用。

和类型

就像集合的范畴中的积能产生积类型那样,余积能产生和类型。Haskell 官方实现的和类型以下:

data Either a b = Left a | Right b

相似序对,Either 在同构意义下是可交换的,也是可嵌套的,并且在同构意义下,嵌套的顺序不重要。所以,咱们能够定义与三元组相等的和类型:

data OneOfThree a b c = Sinistral a | Medial b | Dextral c

能够证实 Set 对于余积而言也是个(对称的)幺半群范畴。二元运算由不相交和(Disjoint Sum)来承担,unit 元素由初始对象来扮演。用类型术语来讲,咱们能够将 Either 做为幺半群运算符,将 Void 做为中立元素。也就是说,能够认为 Either 相似于运算符 +,而 Void 相似于 0。这与事实是相符的,将 Void 加到和类型上,不会对和类型有任何影响。例如:

Either a Void

a 同构。这是由于没法为这种类型构造 Right 版本的值——不存在类型为 Void 的值。Either a Void 只能经过 Left 构造子产生值,这个值只是简单的封装了一个类型为 a 的值。这就相似于 a + 0 = a

在 Haskell 中,和类型至关广泛,而 C++ 中的与之相对应的联合(Union)与变体(Variant)类型则不是那么常见。这是有缘由的。

首先,最简单的和类型是枚举,在 C++ 中能够用 enum 来实现,在 Haskell 中与之等价的和类型是:

data Color = Red | Green | Blue

在 C++ 中相应的枚举类型为:

enum {Red, Green, Blue};

其实 Haskell 中还有更简单的和类型:

data Bool = True | False

它与 C++ 中内建的 bool 类型等价。

在 C++ 中,要控制一个值在和类型中出现与否,有各类各样的实现,须要借助一些特殊技巧以及一些『不可能』的值,例如空字串、负数、空指针等等。在 Haskell 只需使用 Maybe 类型便可解决这一问题:

data Maybe a = Nothing | Just a

Maybe 类型是两个类型的和。能够将两个构造子分开来看。只观察第一个构造子,其形态以下:

data NothingType = Nothing

它是一个叫作 Nothing 的单值的枚举。换句话说,它是一个单例,与 unit 类型 () 等价。

第二个构造子

data JustType a = Just a

的做用就是封装类型 a

因而,咱们能够将 Maybe 表示为:

data Maybe a = Either () a

在 C++ 中,更复杂一些的和类型每每要使用指针。一个指针能够为空,也能够指向某种特定类型。例如,Haskell 中的列表类型,它被定义为一个(递归)的和类型:

List a = Nil | Cons a (List a)

要将它翻译为 C++ 代码,须要使用空指针来模拟空列表:

template<class A> 
class List {
    Node<A> * _head;
public:
    List() : _head(nullptr) {}  // Nil
    List(A a, List<A> l)        // Cons
      : _head(new Node<A>(a, l))
    {}
};

注意,上述 Haskell 代码中的构造子 NilCons,在 C++ 代码中对应于 List 构造函数的重载。虽然 List 类不须要用标签来区分和类型的两个成员,可是它须要用 nullptr_head 弄成相似 Nil 这样的东西。

Haskell 与 C++ 类型之间主要的区别是 Haskell 的数据结构不可变。若是你使用特定的构造子建立了一个对象,这个对象会永远记住它是由哪一个构造子建立的,以及这个构造子接受的参数是什么。所以由 Just "energy" 建立的 Maybe 对象,永远也不会变成 Nothing。相似的,一个空的列表永远都是空的,一个有三个元素的列表永远有相同的三个元素。

正是这种数据的不变性使得数据的构造是可逆的。给定一个对象,你老是可以使用它的构造函数将它大卸八块。这种解构过程能够经过模式匹配来实现。

List 类型有两个构造子,所以任意一个 List 的解构可使用与这两个构造子相对应的模式匹配来实现。一个模式能够匹配 Nil 列表,另外一个模式可匹配 Cons 构造的列表。例如,下面是一个做用于 List 的简单函数:

maybeTail :: List a -> Maybe (List a)
maybeTail Nil = Nothing
maybeTail (Cons _ t) = Just t

maybeTail 定义的第一部分使用了 Nil 构造子做为模式,以 Nothing 做为结果返回。第二部分使用了 Cons 构造子做为模式,并使用通配符 _ 对其第一个参数进行匹配,之因此如此,是由于咱们对第一个参数不感兴趣。Cons 的第二个参数值会被绑定到变量 t 上(虽然称之为变量,但事实上它们是永远都不变的:一旦它们被绑定到表达式,它们就不会再变化了),返回结果是 Just t。你的 List 是如何建立的,maybeTail 总有一个『从句』可以与之相匹配。若是 List 是由 Cons 建立,那么 Cons 所接受的两个参数就会被 maybeTail 检出(第一个参数会被忽略)。

C++ 中能够经过多态类的继承方式实现更复杂一些的和类型。有共同祖先的类族能够看做是一个可变的类型,虚函数表的扮演了一个隐含的标签的角色。C++ 多态类是经过虚函数表查找要被调用的虚函数来实现多态,而 Haskell 老是能够基于构造子的模式匹配来完成一样的事。

不多看到 C++ 代码中会使用联合类型做为和类型,由于联合类型存在不少限制。譬如,你不能将 std::string 放入联合中,由于字符串具备 copy 构造函数。

类型代数

积类型与和类型,单独使用其中一个就能够定义一些有用的数据结构,可是两者组合起来或更增强大。咱们再度祭出复合的能量。

先总结一下到如今为止已经见识过的东西。咱们已经见识了类型系统中两种可交换的幺半群结构:用 Void 做为中性元素的和类型,用 () 做为中性元素的积类型。能够将这两种类型比喻为加法与乘法。在这个比喻中,Void 至关于 0,而 () 至关于 1

如今来思考如何加强这个比喻。例如,与 0 相乘的结果为 0 么?换句话说,一个积类型中的一个成员是 Void,那么这个积类型与 Void 同构么?例如,是否存在一个 IntVoid 构成的序对?

要建立序对,须要两个值。Int 值很容易得到,可是 Void 却没有值。所以,对于任意类型 a 而言,(a, Void) 是不存在的——它没有值——所以它等价于 Void。换句话说,a * 0 = 0

另一件事,加法与乘法在一块儿的时候,存在着分配律:

a * (b + c) = a * b + a * c

那么对于积类型与和类型而言,是否也存在分配律?是的,不过通常是在同构意义下存在。上式的左半部分至关于:

(a, Either b c)

右半部分至关于:

Either (a, b) (a, c)

下面给出一个方向的转换函数:

prodToSum :: (a, Either b c) -> Either (a, b) (a, c)
prodToSum (x, e) = 
    case e of
      Left  y -> Left  (x, y)
      Right z -> Right (x, z)

另外一个方向的转换函数为:

sumToProd :: Either (a, b) (a, c) -> (a, Either b c)
sumToProd e = 
    case e of
      Left  (x, y) -> (x, Left  y)
      Right (x, z) -> (x, Right z)

case of 语句用于函数内部的模式匹配。每一个模式后面是箭头所指向的可求值的表达式。例如,若是将下面的值做为 proToSum 的参数:

prod1 :: (Int, Either String Float)
prod1 = (2, Left "Hi!")

case e of 中的 e 就等于 Left "Hi!",它会匹配到模式 Left y,因此 y 就会被绑定到 "Hi!"。由于 x 已经匹配到 2 了,所以 case of 从句的结果,也就是整个函数的结果将会是 Left (2, "Hi!")

我不打算证实上面的这两个函数互逆,不过若是你对此有所疑虑,能够确定的说,它们确定互逆!由于它们只不过是将相同的数据用了不一样的形式进行了封装。

数学家们为这种相互纠缠的幺半群取了个名字:半环(Semiring)。之因此不叫它们全环,是由于咱们没法定义类型的减法。这就是为什么半环有时也被称为 rig 的缘由,由于『Ring without an n(negative,负数)』。不过,在此不关心这些问题,咱们关心的是怎么描述天然数运算与类型运算之间的对应关系。这里有一个表,给出了一些有趣的对应关系:

天然数运算对应的类型运算

列表类型至关有趣,由于它被定义为一个方程的解,由于咱们要定义的类型出如今方程两侧:

List a = Nil | Cons a (List a)

若是将 List a 换成 x,就能够获得这样的方程:

x = 1 + a * x

不过,咱们不能使用传统的代数方法去求解这个方程,由于对于类型,咱们没有相应的减法与除法运算。不过,能够用一系列的替换,即不断的用 (1 + a*x) 来替换方程右侧的 x,并使用分配律,这样就有了下面的结果:

x = 1 + a*x
x = 1 + a*(1 + a*x) = 1 + a + a*a*x
x = 1 + a + a*a*(1 + a*x) = 1 + a + a*a + a*a*a*x
...
x = 1 + a + a*a + a*a*a + a*a*a*a...

最终会是一个积(元组)的无限和,这个结果可被解释为:一个列表,要么是空的,即 1;要么是一个单例 a;要么是一个序对 a*a;要么是一个三元组 a*a*a;……以此类推,结果就是一个由 a 构成的字符串。

对于列表而言,还有更有趣的东西,等咱们了解函子与不动点以后,再来观察它们以及其余的递归数据结构。

用符号变量来解方程,这就是代数!所以上面出现的这些数据类型被称为:代数数据类型。

最后,我应该谈一下类型代数的一个很是重要的解释。注意,类型 a 与类型 b 的积必须包含类型 a 的值与类型 b 的值,这意味着这两种类型都是有值的;两种类型的和则要么包含类型 a 的值,要么包含类型 b 的值,所以只要两者有一个有值便可。逻辑运算 andor 也能造成半环,它们也能映射到类型理论:

逻辑运算对应的类型运算

这是更深入的类比,也是逻辑与类型理论之间的 Curry-Howard 同构的基础。之后在讨论函数类型时,对此再做探讨。

挑战

1. 证实 Maybe aEither () a 同构。

2. 用 Haskell 定义一个和类型:

data Shape = Circle Float
           | Rect Float Float

要为 Shape 定义一个 area 函数,能够用 Shape 的两个构造子造成的模式匹配:

area :: Shape -> Float
area (Circle r) = pi * r * r
area (Rect d h) = d * h

请在 C++ 或 Java 中以接口的形式实现 Shape,而后建立两个类 CircleRect,并以虚函数的形式实现 area

3. 继续上一题,在不改变 Shape 定义的条件下,很容易增长一个新函数 circ,用于计算 Shape 的周长:

circ :: Shape -> Float
circ (Circle r) = 2.0 * pi * r
circ (Rect d h) = 2.0 * (d + h)

请为你的 C++ 或 Java 实现也增长 circ 函数。原来的代码有哪部分不得不做修改?

4. 继续上一题,在 Shape 中增长一个新的形状 Square,并对代码做相应的更新。在 Haskell vs C++ 或 Java 的实现中,有哪些代码须要变更?(即便你并不是 Haskell 程序猿,代码的变更应该也是至关明显的。)

5. 证实 a + a = 2 * a 在类型系统中(在同构意义下)也成立。记住,在上面给出的表中,2 至关于 Bool

致谢

感谢 Gershom Bazerman 审阅此文并提出了宝贵意见。

-> 下一篇:『函子

相关文章
相关标签/搜索