F#奇妙游(28):ADT中简单值的F#实现

简单值的ADT

在领域建模中,我们尝尝会遇到一些简单的值,比如人的名字、人的编号、物品的代码。如果过早进行程序设计,这些值很容易就会变成程序设计语言中的基本量,stringint这些,就比如人的标号和物品的编号,很容易就被记录为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中开发的领域模型不一致的错误。但是,跟使用原始数据类型intstring相比,这样的确会带来性能损失。

因此,你懂的……

在这里插入图片描述

而且,性能的一点点提升,在某些时候就是会让应用变得更加丝滑,用户的体验也的确是可能得到改善。那么.NET的灵活多变、功能全面在这个时候就起到作用了。

[<Struct>]
type UnitQuantity = private UnitQuantity of int

注意,这个语法仅仅能够在F# 4.1之后使用。但是我回头看了一下日历,现在是2023年,F#的版本已经是7.0。哦,那没事了。

这样做之后,可能还是会有结构体访问所带来的性能损失,但是在使用一系列UnitQuantity时内存对齐的特性就相对友好多了。

结论

  1. type xxx = xxx of int这种简单类型的定义对于DDD和ADT来说是常规操作;
  2. F#提供了很好的语法支持来进行ADT的实现。
posted @ 2023-09-05 15:37  大福是小强  阅读(19)  评论(0编辑  收藏  举报  来源