From ea7e14e8c12908ee60c9431fceb4bfdd8ae329a3 Mon Sep 17 00:00:00 2001 From: eltrac Date: Thu, 14 May 2026 20:14:35 +0800 Subject: [publish] /library/2026/functional-design/ --- content/library/2026/functional-design.md | 149 +++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 3 deletions(-) diff --git a/content/library/2026/functional-design.md b/content/library/2026/functional-design.md index 0a83cfde..5dd308a2 100644 --- a/content/library/2026/functional-design.md +++ b/content/library/2026/functional-design.md @@ -4,9 +4,9 @@ slug = 'functional-design' tags = ['Lisp','编程'] authors = ['Robert C. Martin'] rating = 3.5 -date = 2026-05-14T11:35:24+08:00 +date = 2026-05-14 20:14:25 cover = 'https://img.eltr.ac/geedeapro/library/2026/functional-design/cover.jpg' -draft = true +draft = false toc = true toot = '' +++ @@ -98,10 +98,153 @@ func IncrementList(list []int) { ## 论类型安全 +书中有一章专门对比了面向对象编程和函数式编程解决相同问题的常用方案,鲍勃大叔从各自阵营里选出来的分别是 Java 和 Clojure。除了在编程范式上的区别,这两门语言的类型系统也大相径庭,Java 是静态类型语言而 Clojure 是动态类型语言。 +类型的静态和动态意味着什么呢?在静态类型语言里,你没办法给一个整数型变量赋值为字符串,比如 `int a = 1` 不可能突然变成 `"Hello World"`。更重要的是自定义类型,如果有一个 `Data` 对象,在静态类型语言里你可以确定 `Data` 具有特定的结构,比方说你可以确定 `Data.toString()` 或者 `Data.body` 之类的符号一定存在,否则类型系统就报错了,很多情况下在编译阶段就会出现警告和错误。这就是类型安全。 + +然而,==必须要区分「静态类型」和「类型安全」这两个概念==。C 语言是静态类型语言,但它并不是类型安全的,比方说 C 语言转换大小写字母的方式就是直接对字符类型做加减法运算,因为 ASCII 编码实际上是对字符的数字编码。字符 `A` 的十进制编码是 `65`,字符 `a` 是 `97`,要把大写字母转换为小写,只要加上 `32` 就好,于是就有 `'A' + 32 == 'a'`。[^2] + +C 语言作为静态类型语言,并没有强硬地限制能对某个类型的数据所做的操作,对字符做算术运算在语义上是错误的,但编译器不会报错,程序也能运行。如果静态类型就意味着类型安全,C 语言程序就不会那么容易出现段错误(segmentation fault)了。 + +同理,动态类型语言可以是类型安全的,程序员可以用类型不安全的编程语言写出类型安全的程序,只需要比静态类型语言程序员更小心一些。 + +利用静态类型系统进行编译时检查就能排查很多问题,而不少动态类型语言程序在很大程度上依赖运行时检查,比如用 `if` 语句判断某变量的类型,这有点像一边开车一边检查汽车的情况,看起来就很不安全。然而,到底要不要在开车前检查,其实是司机自己的选择,与这辆车没检查之前开不开得动无关。静态类型语言里也可以滥用泛型等限制较弱的类型来蒙混过关。 + +所以动态类型语言怎么保证类型安全呢? + +*写测试啊。* + +我前面跑题了,鲍勃大叔没有在书里讨论太多和类型安全相关的问题,我想是因为这部分的隐患都被他良好的开发习惯解决了。这个习惯就是先写测试,再写能够通过测试的程序,也就是测试驱动开发(TDD,Test-Driven Development),或者按照他的说法:最后一个 D 代表设计(Design)。 + +Clojure 提供了 [clojure.spec](https://clojuredocs.org/clojure.spec.alpha) 标准库,用于规定数据或函数的结构,并依次为依据进行验证、遵照、解释、描述和生成(测试用)数据。你可以用几个谓词函数(返回布尔值的函数)定义一种类型,这样调用 `valid?` 函数就能判断类型是否合格。这比写在类型定义里要灵活不少,当然,这肯定不如严格的编译器检查来得安全。 + +不过即便是有编译器检查,也可能出现运行时错误,程序的类型问题不是有了安全的类型系统就被完美解决的。那么,要怎么更彻底地保证安全呢? + +*写测试啊。* + +有编译器兜底固然很好,但有自己根据实际问题编写的测试兜底,我觉得要更好。同样是静态类型语言的 Go 语言就强调测试,测试文件甚至直接和源代码文件放在一起(比方说你有一个 `context.go` 文件,它的测试就写在同一个目录下的 `context_test.go` 里)。动态类型语言如果能做到较高的测试覆盖率(语句覆盖、条件覆盖、分支覆盖等等),又怎么会不安全呢? + +简而言之,在我看来无需纠结编程语言的类型系统,养成良好的编写测试的习惯才是最好的。 ## FOOL -## Footnotes +——Functional Object-Oriented Language.(函数式面向对象编程语言) + +或许可以这样称呼 Clojure,尽管缩略词看起来不太正经。如前文所述,这本书在讲完函数式编程是什么之后,就开始散发浓烈的面向对象的气息。虽然不至于让人捏鼻子远离,但足够引起警觉。仔细审视的话,书中的代码的确都是函数式的,但怎么这么像 OO?鲍勃大叔在第五章的最后一节也审视了这一点,这一节的标题叫作 *Postscript: OO Poison?* + +Clojure 里的确没有类和对象的概念,但在设计程序的时候也难以避免使用类似的词汇。在第三章,鲍勃大叔用函数式设计的角度审视了 SOLID 原则(我依稀记得,我的《软件工程导论》教材把 SOLID 称作面向对象设计原则)。在第五章,他回顾了许多常见的设计模式,比如抽象服务者(Abstract Server)、适配器(Adapter)、命令模式(Command)、装饰者模式(Decorator)等等,这些设计模式一开始都是以面向对象的思想提出的。 + +某种程度上,鲍勃大叔一直在书中用函数式的编程风格复现面向对象编程的设计模式。由于 Clojure 提供多态能力(`defmulti` 和 `defmethod`),所以实现类似「抽象类」和「接口」的东西并不复杂。这在架构层面的确有好处,我们在《[整洁架构之道](/library/2026/clean-architecture/)》中已经知道:高层策略不应该依赖低层细节(高层与低层指的是模块与 I/O 的距离),源代码的依赖关系应该从具体实现向抽象组件流动,最核心的业务细节应该是抽象的且不应该有太多依赖(应该让其他组件依赖核心业务逻辑,使它们成为插件)。要实现这些,多态貌似是不可或缺的,也因此很容易变成面向对象的样子。 + +有趣的是,我在读完《整洁架构之道》之后重新审视了自己的某个项目,发现我的源代码依赖关系和整洁架构是相反的,我的高层策略依赖了存储、配置等低层细节,于是我开始想办法,然后我发现我不知道该怎么办,因为 Clojure 没有接口和抽象类(至少当时我不知道还有 `Protocol` 这个东西)。 + +思考过后,我意识到使用回调函数就可以实现控制反转了。几次重构后,我得到了这样的实现:在 Main 组件里依赖 `storage.clj`,这个组件提供 `use-store` 函数,它返回一个映射,键值对中的值都是函数引用。 + +```clojure +(defn use-store [datasource] + {:append (partial append-wm datasource) + :update (partial update-wm datasource) + :delete (partial delete-wm datasource) + :get (partial get-wm datasource) + :get-by-domain (partial get-wms-by-domain datasource)}) +``` + +`partial` 用于构造新的函数,比方说 `append-wm` 本来接收 `[datasource data]` 两个参数,`(partial append-wm datasource)` 这个函数就只接收 `[data]` 一个参数。 + +然后把这个映射以参数的形式传入业务逻辑中。 + +```clojure {hl_lines=[1,5,10]} +(def store (edn/use-store (:data-path config))) + +(defn -main [& _] + ;; Start webmention processor + (processor/start wmc hooks store config) + ;; Start receiver server + ;; Check for a PORT environment variable, default to 3000 + (let [port (Integer/parseInt (or (:port config) (System/getenv "PORT") "3000"))] + (run-jetty + (app wmc store config) + {:port port :join? true}))) +``` + +然后 `processor` 和 `app` 只需要 `((:append storage) data)` 就可以调用映射里的函数了。这里用的是名为 `edn-storage` 的实现,如果需要换成 SQLite 或别的什么数据库,只需要写一个新的实现,也提供 `use-store` 方法,返回相同结构的映射,然后修改 `core.clj` 就好了,业务逻辑不需要改动,符合开闭原则。 + +几周后我就在这本书里读到了相同的设计方法,我还知道这种映射有个名字,叫 `vtable`。而且,在读到通过 `defmulti` 和 `defmethod` 实现的多态,以及用 `Protocol` 和 `record` 实现的抽象之后,我仍然更喜欢 `vtable`,原因很简单:它足够简单优雅,没有多余的语法,而且非常函数式。 + +我又跑题了。 + +鲍勃大叔在书中详细地论证了那些适用于面向对象编程的原则和设计思想,其实也能丝滑地迁移到函数式编程中,包括里氏替换原则和接口隔离原则等看起来就只属于 OOP 的原则。我想这是因为这些原则的核心就是为了管理好源代码依赖关系,而依赖关系在函数式编程中当然是存在的。 + +> The "interface" of a module is simply the list of all the access points within that module. +> +> 模块的“接口”不过是其中的一系列接入点而已。 + +对象、类和接口也不只是指代 `Object` `class` 和 `interface` 等关键词而已,一个类型被 `clojure.spec` 验证过的映射,语义明确且行为明确,难道不能称作某个“类”的“实例”吗?我们依然可以用类似的语言描述函数式程序,而这并不会改变函数式的性质,因为我们状态依旧是不可变的,系统的状态依旧通过一个接一个的递归在函数调用之间被传递。 + +面向对象编程风格和函数式编程风格,是相容的,并且互惠互利。 + +对我来说,函数式编程不是一个新阵营(尽管某一部分的我倾向于把它当作一个阵营)而是一种风格,更确切地来说是一种思想。面向对象编程也是一种思想,再仔细想想,结构化编程也广泛地存在于其他两种编程范式中,毕竟用 FP 或 OOP 都不代表你突然接受 `goto` 语句了。 + +或许这也是权衡取舍的问题,程序就应该既结构化、又面向对象、还函数式,依照语言的特性和具体问题选择最合适的方式就好。 + +## 其他有趣的想法 + +书评的主要内容就结束了,剩下的是一些我觉得有意思但塞不进前文的一些内容。 + +### 封闭类 + +鲍勃大叔在讲访问者模式(Visitor)的时候提到,这种模式很可怕,因为 Visitor 和源数据对象之间仍然是紧密耦合的,而且增加了一层复杂度,然而又不得不存在,因为程序要符合开闭原则。 + +举个例子,你有一个数据对象 `Data`,你可以直接 `Data.toString()` 把它转换为字符串,但可能客户需要 JSON 格式的输出,那你就要写一个 `Data.toJSON()` 方法,可这种需求可能是无穷的,搞不好就会频繁修改 `Data` 的源代码,就为了加上 `.toTOML()` `.toXML()` 和 `.toYAML()` 等方法。这违反了开闭原则,我们没有在拓展程序,而是在直接修改源代码。 + +更好的方式是使用访问者模式,定义 `DataVisitor` 接口,接下来就可以编写 `JSONDataVisitor` `TOMLDataVisitor` 和 `XMLDataVisitor` 等实现,让 `DataVisitor` 读取数据并输出格式,`Data` 的源代码无需修改,这样就符合开闭原则了。 + +这无疑为系统添加了很多复杂性,为了让 `DataVisitor` 访问 `Data`,我们还需编写 `Data.accept()` 方法(我觉得这有点像迭代器模式的 `Aggregate.createIterator()`),让 `DataVisitor` 读取到 `Data` 内部的私有变量和私有方法。多了一层抽象也让人觉得很不是滋味,如此大费周章只是为了访问数据吗? + +鲍勃大叔指出这些不得不忍受的复杂性,其实是 Java 等面向对象编程的语言限制导致的。 + +Java 的类是封闭的,意味着你不能在另一个源文件里给已有的类添加新的方法。要添加新方法必须修改原有类的源代码,然而这违反了开闭原则。然而,Clojure 可以在不同的源文件里给已有的“类”添加方法,C# 也可以,如果我的理解没错的话,Go 语言也可以,毕竟 Go 的类(实际上是结构体)的方法是这样定义的: + +```go +type Data struct { + // ... +} + +func (data *Data) toString() string { + return // ... +} + +// 在另一个源文件里 +func (data *Data) toTOML() string { + return // ... +} +``` + +你可能觉得这样也是在修改 `Data` 类,但我们关注的是源代码的依赖关系,这个源文件依赖的 `Data` 所在的源文件并没有被修改,其他依赖 `Data` 的组件也不需要重新被编译。 + +### 竞争条件与并发更新无关 + +还记得这句话吗? + +> You can't have race condition if you don't mutate states. + +这实际上是假的,没有并发更新也可能引发竞争条件。如果状态机(state machines)之间进行交互,那么它们失去同步总是有可能的。这里的例子是早期的电话系统。 + +用户和电话的交互流程是这样的: + +1. 用户 Bob 拿起听筒 +2. 电话播放拨号音(Dial Tone),表示可以拨号了 +3. 用户 Bob 拨号 +4. 电话向播放 Bob 铃声(Ringback),同时也向被拨号的用户 Alice 播放铃声(Ring) +5. 用户 Alice 拿起听筒 +6. 连接 + +假设在这一过程中,Alice 在铃声响起之前就拿起了电话(因为 Alice 恰好也想给 Bob 打电话)。由于 Bob 已经拨号了,所以 Alice 在拿起话筒的那一刻两个人就已经接通了,然而 Alice 没有听到拨号音(Dial Tone),以为电话没有反应,Bob 也没有听到声音。两个人可能什么也每说就挂掉了电话,或者知道发生了什么,于是开始讨论究竟是谁先拨的号。 + +同样的问题也可以用函数式程序模拟出来,而且整个过程也是顺序或者说同步的,没有并发和异步操作。 + +竞争条件只是在有可变状态的多线程程序中更常见而已,函数式编程也消除不了竞争条件的可能。 [^1]: 非 Lisp 的函数式编程语言有 Earlang、Haskell 和 F# 等。此外,很多函数式编程语言并不是「纯函数式」的,它们在某种程度上支持可变性,甚至支持面向对象。 + +[^2]: 看起来眼熟吗?你有没有想到另一门类型不安全的语言?提示:这门语言是臭名昭著的动态类型语言。是的,就是 JavaScript。在 JS 里你可以给字符串做加法运算,于是就有了 `"1" + 1 == "11"` -- cgit v1.2.3