原文见 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
是由 a
与 b
参数化的类型的名字;p
是数据构造子的名字。要定义一个序对类型,只需向 Pair
类型构造子传递两个类型。要构造一个序对类型的值,只需向数据构造子 P
传递两个合适类型的值。例如,定义一个序对类型的值 stmt
,其对应的序对类型为 String
与 Bool
的积:
stmt :: Pair String Bool stmt = P "This statements is" False
上述代码的第一行是类型声明,即便用 String
与 Bool
类型替换了 Pair
泛型定义中的 a
与 b
。第二行向数据构造子 P
传递了具体的字符串与布尔值,从而定义了一个序对类型的值 stmt
。类型构造子用于构造类型;数据构造子用于构造值。
由于类型构造子与数据构造子的命名空间是彼此独立的,所以两者能够同名:
data Pair a b = Pair a b
若是你足够学究,就能够发现 Haskell 内建的序对类型只是这种声明的一种变体,前者将 数据构造子 Pair
替换为一个二元运算符 (,)
。若是将这个二元运算符像正常的数据构造子那样用,照样能建立序对类型的值,例如:
stmt = (,) "This statement is" False
相似的,能够用 (,,)
来建立三元组,以此类推。
若是不用泛型序对或元组,也能够定义特定的有名字的积类型,例如:
data Stmt = Stmt String Bool
这个类型虽然也是 String
与 Bool
的积,可是它有本身的名字与构造子。这种风格的类型声明,其优点在于能够为相同的内容定义不一样的类型,使之在名称上具有不一样的意义与功能,以免彼此混淆。
在编程中,只依赖元组以及多参数的构造子会让代码混乱,容易出错,由于只能靠咱们的脑壳来记忆各个数据成员的含义。如果能对数据成员进行命名,结果会好一些。支持数据成员命名的积类型也是有的,在 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 e
从 e
中检出 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 代码中的构造子 Nil
与 Cons
,在 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
同构么?例如,是否存在一个 Int
与 Void
构成的序对?
要建立序对,须要两个值。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
的值,所以只要两者有一个有值便可。逻辑运算 and 与 or 也能造成半环,它们也能映射到类型理论:
这是更深入的类比,也是逻辑与类型理论之间的 Curry-Howard 同构的基础。之后在讨论函数类型时,对此再做探讨。
1.
证实 Maybe a
与 Either () 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
,而后建立两个类 Circle
与 Rect
,并以虚函数的形式实现 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 审阅此文并提出了宝贵意见。
-> 下一篇:『函子』