从建造者模式到闭包和作用域

这是「从函数式角度看设计模式」的第六篇。

之所以写这一篇,也是因为在最近几年乃至最近的项目实践里,感觉到了一些有趣的东西呢,这时候回想到之前学校里写项目的时候,会有人「去学习和使用Builder pattern来解决问题」。

那么故事先从Kotlin里面的一个神秘的类型开始:

1
2
3
fun play(manager: AbstractManager, initializer: SimulatteBuilder.() -> Unit): SimulatteBuilder {
return SimulatteBuilder(manager).apply(initializer)
}

在我的上古项目里面,有一个这样的函数,里面传入的参数是这样的A.(B) → C的类型结构。写下这个代码的时候,对Kotlin还算是一知半解,虽然连蒙带猜地用它写出了功能,但对它背后的原理算是不太清楚。

后来,知道了这个特殊的语法,在Kotlin里面,叫做「Receiver type」:

Function types can optionally have an additional receiver type, which is specified before the dot in the notation: the type A.(B) -> Crepresents functions that can be called on a receiver object A with a parameter B and return a value CFunction literals with receiver are often used along with these types.

Kotlin官方也有专门的页面讲解这个特殊类型的典型应用——Type safe builder,例如这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}

fun result() =
html {
head {
title {+"XML encoding with Kotlin"}
}
body {
h1 {+"XML encoding with Kotlin"}
p {+"this format can be used as an alternative markup to XML"}

...
}
}

看起来跟作为经典设计模式的Builder模式密切相关。那么,Builder模式又是什么呢。

按照定义,「生成器模式是一种创建型设计模式, 使你能够分步骤创建复杂对象。 该模式允许你使用相同的创建代码生成不同类型和形式的对象」。

「该模式会将对象构造过程划分为一组步骤, 比如 build­Walls创建墙壁和 build­Door创建房门创建房门等。 每次创建对象时, 你都需要通过生成器对象执行一系列步骤。 重点在于你无需调用所有步骤,而只需调用创建特定对象配置所需的那些步骤即可。」

在Java里面的实现大概长这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface Builder {
void reset();
void setSeats(...);
void setEngine(...);
void setTripComputer(...);
void setGPS(...);
}

class CarBuilder implements Builder {
private Car car;

public CarBuilder() {
this.reset()
}

void reset() {
this.car = new Car();
}

void setSeats(...) {
...
return this;
}

void setEngine(...) ...

void setTripComputer(...) ...

void setGPS(...) ...

...
}

但是仔细看的话,这个builder有好几个问题。首先,它高度依赖可变状态,本质上,可以理解为就是一个容器,里面有不同的字段,每个build或者set函数说白了就是修改这些字段然后返回这个容器自身来表达「链式调用」。

且不说高度依赖副作用的做法和lambda calculus的许多基本理念的背道而驰,这个做法似乎也没提供什么表达力。Kotlin里面的这个html的例子虽然看起来很简单,但简单扩展一下,就可以引入ADT和一些类型上的操作,结合函数组合的概念,让builder变得类型安全。

但是反过来说,builder模式就难以为力了吧?因为每个函数的返回类型都是Builder容器本身,等于说没有任何标签可以标记「这次构建是否正确」,最后只能退化为运行时,在拿到builder产物的时候才抛出错误。

换句话说,builder本身混淆了作用域和第0函数,而且也因此失去了类型安全。我们知道在Kotlin或Scala里面,type-safe的HTML每次构建后大概会产生什么样的AST,但是,对于一个Java代码,new CarBuilder().setSeats(…).setEngine(…),实在没有什么能够让人安全地去知道它长啥样,是否被正确构建的机制。

再进一步说,如果我调用new CarBuilder().setSeats(…).setSeats(…)两次,或者,如果我需要先setEngine,因为我的seats依赖于engine的产物呢?

在纯函数式的角度,ADT的组合可以让我们相对简单地拆分builder的每个阶段,并且保证整个管线大致正确。显然,Java里面,要么,我们得写上极度臃肿的代码,要么,就完全失去了这种安全保障。有关注这个系列之前的文章的话,你应该也能猜到在纯函数式视角,大概会写出什么样的伪代码了。

进一步地说,还可以使用GADT让方法参数也能在编译时做到类型安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
data Expr a where
IVal :: Int -> Expr Int
BVal :: Bool -> Expr Bool
Add :: Expr Int -> Expr Int -> Expr Int
Equal :: Expr Int -> Expr Int -> Expr Bool

eval :: Expr a -> a
eval (IVal n) = n
eval (BVal b) = b
eval (Add e1 e2) = eval e1 + eval e2
eval (Equal e1 e2) = eval e1 == eval e2

someExpr :: Expr Bool
someExpr = Equal (Add (IVal 3) (IVal 5)) (IVal 8)

result = eval someExpr -- 结果是true

wrongExpr = Equal (BVal True) (BVal False) -- Equal要求的是Int而不是Bool,会直接在编译时报错

说到底,就是确定性和可预测性的问题了。这大概也是Kotlin强调类型安全的地方。

不过话说过来,这个type safe其实也并没有很特殊。因为从FP的角度讲,这就是lambda、闭包、作用域自然而然就引入的内容。

也就是构建DSL的能力。

说起来,提到GADT和DSL,之前也有在讨论visitor pattern的时候提到呢。


从建造者模式到闭包和作用域
http://inori.moe/2025/02/24/from-builder-to-dsl/
作者
inori
发布于
2025年2月24日
许可协议