F#奇妙游(28):ADT中简单值的F#实现
简单值的ADT
在领域建模中,我们尝尝会遇到一些简单的值,比如人的名字、人的编号、物品的代码。如果过早进行程序设计,这些值很容易就会变成程序设计语言中的基本量,string
、int
这些,就比如人的标号和物品的编号,很容易就被记录为int
。然而根据DDD的原则,我们应该尽可能用领域专家认可的方式来记录设计。
例如:
type PersonID =
| PersonID of int
和
type ObjectID =
| ObjectID of int
这种只有一个选项的和类型(OR类型),也可以省略写成type xxx = xxx of int
的形式。在dotnet fsi中运行,还是会识别为type ObjectID = | ObjectID of int
。这里面,第一个ObjectID
是类型名称,第二个ObjectID
是选项标签。
这两个量虽然都是用整型数据类型来表示,但是这两个量在领域内是两个不同的概念。
let p1 = PersonID 0
let obj = ObjectID 0
比如,拿这两个值进行比较,系统就会提示错误:
if p1 = obj then printfn "p1 equals obj"
error FS0001: 此表达式应具有类型
“PersonID”
而此处具有类型
“ObjectID”
更不用说用这两个值进行算术运算了。这样就很好地保证了DDD/ADT设计中,非法的状态是不可表示的。
因为这两个量的比较和算术运算是没有意义的。
此外,很自然地在调用函数时,如果参数的类型其中一个,另外一个参数也无法被错误的传入。F#的静态类型机制很好地确保了ADT设计中的原始含义。这种含义对于领域专家是至关重要的,虽然两个值都是整数,但是领域专家并不关心在计算机中如何表达,他们关心的是,在领域中的含义。
对于上面的简单类型,要使用其内部的整型变量,也是非常简单的,采用模式匹配就行。
> let (PersonID idn) = p1;;
val id: int = 0
这个时候idn
就被绑定到p1
对应的整型数据。此外,在定义函数式,也可以非常简单地利用模式匹配绑定(提取)相应的id数值。
> let printPersonId (PersonID idn) = printfn "ID = %d" idn;;
val printPersonId: PersonID -> unit
> printPersonId p1;;
ID = 0
val it: unit = ()
let
在绑定变量和定义函数时所采用的模式匹配语法,与前面ADT定义配合十分完美,保证ADT设计与语义的一一对应,又提供了相应的语法糖来使得程序实现过程十分丝滑,堪称完美。
约束
在前面ADT设计中,还提到,领域中通常会限制数据的取值范围(对应ADT的组合数)。那么约束应该如何来实现呢?在F#中,也有很好的语法构造来确保ADT实现的完整性。
例如,在领域分析设计中,我们和领域专家一起分析和建立的领域模型中包含了如下的简单值,并且确认了关于这些值的一些约束。
type WidgetCode = WidgetCode of string // 以'w'打头,加4个数字
type UnitQuantity = UnitQuantity of int // 1..1000
type KilogramQuantity = KilogramQuantity of decimal // 0.05 ~ 100.00
除了把这些限制条件写在注释和文档里,并在项目中传递,F#实际上提供了很好的语言工具。
因为函数式编程本身的特性,所有的值都是不变的,因此当一个WidgetCode
传递过程中,我们并不担心内部的值会变坏(跟面向对象设计中的类的不变性作比较)。因此唯一的可能性就是建立/定义这个值的时候。
我们就可以通过如下的方式来确保相应的限制。
第一步,我们要限制用户使用类型名 值
的方式创建这个值。
type UnitQuantity = private UnitQuantity of int
第二步,我们提供一个用来创建这个值的函数。
module UnitQuantity =
type UnitQuantity = private UnitQuantity of int
let create value =
if value < 1 then
Error "UnitQuantity can not be negative"
else if value > 1000 then
Error "UnitQuantity can not be more than 1000"
else
Ok (UnitQuantity value)
let value (UnitQuantity uq) = uq
这里要定义一个UnitQuantity.value
函数是因为,当隐藏了构造函数后,就没办法在客户代码例使用模式匹配来定义函数和绑定内部的int
量。
那么客户端的代码就可以写成这样:
let result = UnitQuantity.create 1
match result with
| Error msg -> printfn "faile to create: %s" msg
| Ok uQty ->
printfn "Successful with value : %A" uQty
let iv = UnitQuantity.value uQty
printfn "inner value: %i" iv
其实,还能够使用F#定义语言界面的fsi
文件来隐藏构造函数,详见MSDN。
性能
当然,采用上面这样ADT描述非常有利于领域专家的理解,对于DDD开发至关重要的就是形成统一的领域模型。并且,在F#中的巧妙和优雅地实现同样能够在编译阶段发现一些与DDD中开发的领域模型不一致的错误。但是,跟使用原始数据类型int
、string
相比,这样的确会带来性能损失。
因此,你懂的……
而且,性能的一点点提升,在某些时候就是会让应用变得更加丝滑,用户的体验也的确是可能得到改善。那么.NET的灵活多变、功能全面在这个时候就起到作用了。
[<Struct>]
type UnitQuantity = private UnitQuantity of int
注意,这个语法仅仅能够在F# 4.1之后使用。但是我回头看了一下日历,现在是2023年,F#的版本已经是7.0。哦,那没事了。
这样做之后,可能还是会有结构体访问所带来的性能损失,但是在使用一系列UnitQuantity
时内存对齐的特性就相对友好多了。
结论
type xxx = xxx of int
这种简单类型的定义对于DDD和ADT来说是常规操作;- F#提供了很好的语法支持来进行ADT的实现。