代数数据类型与领域驱动设计
代数数据类型(Algebraic Data Type,ADT)和抽象数据类型(Abstract Data Type,ADT)都与数据类型设计有关,但是两者的设计思想是不同的。抽象数据类型是OOP领域常用的设计思想,注重对实体对象的状态变化的封装和抽象;而代数数据类型则是组合式的设计思想,注重对数据类型的组合数进行分析,在函数式程序设计中的领域建模中应用较多。在下面的文章中,ADT仅仅指代数数据类型,抽象数据类型会直接使用中文名称。
大部分代数数据类型的文章或者书籍中,这个术语都与另外一个概念紧密相连,也就是领域驱动设计(Domain-Driven Design,DDD)。领域驱动设计目的在于解决从领域知识向程序代码的转换问题,而代数数据类型则是领域驱动设计的一种实现方式。DDD试图在利益相关方、领域专家、程序设计师之间建立一个共享的模型,并用各方都能理解的语言予以描述,代码与这个共享模型直接相关。
领域驱动设计DDD中的共享模型
DDD的核心是领域模型,领域模型是对领域知识的抽象和建模,是领域知识的一种表达方式。领域模型是一个共享的模型,是领域专家、程序设计师、投资方之间的共识,是一种共同的语言。
领域模型的建立是一个迭代的过程,领域专家和程序设计师之间的交流是一个双向的过程,领域专家需要向程序设计师传递领域知识,程序设计师需要向领域专家传递程序设计的思想。
代数数据类型与领域模型
在函数式编程中,ADT就被用于领域模型的建立,ADT是一种组合式的数据类型,它是由其他数据类型组合而成的,这些组合的方式是有限的,可以通过数学的方式进行分析。ADT的组合方式可以用来描述领域模型中的实体和实体之间的关系。
在ADT中,将类型分为两类,和类型与积类型。这里的和与积描述的就是类型的组合方式,和类型是一种或者的关系,积类型是一种并且的关系。所以和类型也称为sum类型或者OR类型,积类型也称为product类型或者AND类型。
以F#为例,和类型可以用|
来表示,积类型可以用*
来表示。例如:
type Shape =
| Circle of float
| Rectangle of float * float
这里的Shape
类型就是一个和类型,它由Circle
和Rectangle
两个类型组成,Circle
和Rectangle
是两个积类型,Circle
是一个积类型,它由一个float
类型组成,Rectangle
也是一个积类型,它由两个float
类型组成。
这样很直接的就能计算出各类型的组合数(这里用C(type)
来表示),也就是类型代表的对象可能的状态数。因为实际上的现实领域的对象是有限的(或者说是可数的),所以这个组合数的分析就能够明确可能的状态,并在建模的过程中是的非法状态是不可表示的。这个不可表示非法状态的概念的ADT是一个非常核心的要素。也是基于ADT的函数式编程的一个重要特征。
Shape
类型的组合数是C(R) + 2*C(R) = 3*C(R)
,Circle
类型的组合数是C(R)
,Rectangle
类型的组合数是2*C(R)
。当然浮点数的组合数C(R)
是无限的,考虑现实的精度要求和取值范围后,这个组合数也是有限。例如,如果精度要求是小数点后两位,取值范围是[0, 100]
,那么组合数就是10000
。
C ( Ω ) ∼ u b − l b δ , Ω ← [ l b , u b ] ∈ R , δ ← precision C(\Omega) \sim \frac{ub-lb}{\delta}, \Omega \gets [lb, ub]\in \R, \delta \gets \text{precision} C(Ω)∼δub−lb,Ω←[lb,ub]∈R,δ←precision
比如一个可选的整数类型int option
,它的组合数是1 + C(N)
,None
和Some 1
。在现实中,通常整数
N
\N
N 也在有限的范围内取值。
而一个包含若干字段的记录类型type data = {a: type1; b: type2; c: type3}
,它的组合数是C(type1) * C(type2) * C(type3)
,这里的函数C(type)
表示type
类型的组合数。
集合类型type list = [] | :: of 'a * 'a list
,它的组合数是1 + C('a) * C('a list)
。这是一个递归定义,当然在函数式编程中,是一个很自然的事情。
ADT有什么作用?
前面说过,ADT是一种组合式的数据类型描述,它试图提供一种数学的方式来描述现实世界的对象和对象之间的关系。ADT是由其他ADT组合而成的,这种组合方式是有限的,可以通过数学的方式进行分析。通过这种分析,特别有效的一点就是对合法状态和非法状态的区分,这是ADT的一个重要特征。
ADT的合法状态是有限或者可数的,非法状态是无法表示的。
这与OOP中的对象类型和抽象数据类型采取了不同思路。在OOP中,对象状态的有效性通过不变性分析来保证。封装是抽象数据类型的一个重要特征,它可以保证对象状态的有效性。而ADT则是通过类型的组合方式来只表达有效状态,从而保证对象状态的有效性。
- OOP,抽象数据类型,封装,不变性
- FP,代数数据类型,组合,有效状态
这里可以看到,FP的思路更加注重透明和直观,数据类型、可能性、约束都将被显式表达出来,这也是为什么FP和ADT更好的与领域驱动设计相结合的原因。显示表达才能更容易得到各个不同领域的专家的理解和认可。
这又是函数式程序设计的一个核心的特点。函数式程序设计中ADT的应用使得程序的正确性可以通过代数分析的方法来保证。这种方法是一种数学的方法,是一种严格的方法,是一种可以计算的方法。
这也给ADT的应用提了一个醒,不能盲目地信任ADT和所谓的正确性,因为现实的情况通常是复杂的。
函数和ADT
当ADT和函数式编程的值类型相结合,就可以很好的应用于领域模型和程序代码库之间的映射,也能够很好地有助于领域模型的建立。
当把所有的值都当做ADT来看待,函数就是从一个ADT到另一个ADT的映射。
f : ADT 1 ↦ ADT 2 f: \text{ADT}_1 \mapsto \text{ADT}_2 f:ADT1↦ADT2
多个参数的函数就是多个ADT到一个ADT的映射。
f : ADT 1 ADT 2 ⋯ ADT n − 1 ↦ ADT n f: \text{ADT}_1~\text{ADT}_2~ \cdots ~\text{ADT}_{n-1} \mapsto \text{ADT}_n f:ADT1 ADT2 ⋯ ADTn−1↦ADTn
前面关于ADT的组合数的分析在这里就能够派上用场了,函数的参数和返回值都是ADT,它们的组合数都是有限的,这样就能够很好地分析函数的参数和返回值的组合数,从而分析函数的行为。
当然,多参数的函数通常在函数式编程中可以看做是一系列单参数函数的组合。
f : ADT 1 ↦ ADT 2 ↦ ⋯ ADT n − 1 ↦ ADT n f: \text{ADT}_1 \mapsto \text{ADT}_2 \mapsto \cdots \text{ADT}_{n-1} \mapsto \text{ADT}_n f:ADT1↦ADT2↦⋯ADTn−1↦ADTn
f 1 = f ADT 1 : ADT 2 ↦ ⋯ ADT n − 1 ↦ ADT n f 2 = f 1 ADT 2 : ADT 3 ⋯ ADT n − 1 ↦ ADT n … f n − 2 = f n − 3 ADT n − 2 : ADT n − 1 ↦ ADT n \begin{aligned} & f_1 = f~\text{ADT}_1 : \text{ADT}_2 \mapsto \cdots \text{ADT}_{n-1} \mapsto \text{ADT}_n \\ & f_2 = f_1 ~\text{ADT}_2 : \text{ADT}_3 \cdots \text{ADT}_{n-1} \mapsto \text{ADT}_n \\ & \ldots \\ & f_{n-2} = f_{n-3} ~\text{ADT}_{n-2} : \text{ADT}_{n-1} \mapsto \text{ADT}_n \\ \end{aligned} f1=f ADT1:ADT2↦⋯ADTn−1↦ADTnf2=f1 ADT2:ADT3⋯ADTn−1↦ADTn…fn−2=fn−3 ADTn−2:ADTn−1↦ADTn
这里迅速就能够得到一个好玩的结论,在排列函数的参数时,应该把组合数最小的参数放在前面,把组合数最大的参数放在后面。
这样在分析部分应用所得到的系列函数 f i f_i fi 的行为时,比较符合直觉,也比较容易分析。
另外一个,就是函数本身。是否能够把函数当做一个ADT来分析?肯定是可以的。在数学上,函数空间是一个常见的概念。对于
f
:
ADT
1
↦
ADT
2
f: \text{ADT}_1 \mapsto \text{ADT}_2
f:ADT1↦ADT2 ,其组合数是什么呢?不去约束函数的性质,应该是
C
(
ADT
2
)
C
(
ADT
1
)
C(\text{ADT}_2) ^{C(\text{ADT}_1)}
C(ADT2)C(ADT1) ,也即每个输入的映射都有输出类型的组合数中可能。这肯定不是数学上的函数的概念,但是函数编程的代数概念中,应该是这样的。
总结
- ADT与DDD是一对好朋友;
- ADT是函数式编程中值类型的核心;
- 函数式编程的核心是函数和ADT;
- ADT的组合数分析是ADT的一个重要特征;
- 函数的参数和返回值都是ADT,函数本身也是一个ADT。