惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
CXSECURITY Database RSS Feed - CXSecurity.com
L
LINUX DO - 热门话题
S
Secure Thoughts
TaoSecurity Blog
TaoSecurity Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
T
Threat Research - Cisco Blogs
AI
AI
B
Blog RSS Feed
S
Schneier on Security
雷峰网
雷峰网
Schneier on Security
Schneier on Security
Help Net Security
Help Net Security
Cloudbric
Cloudbric
L
LINUX DO - 最新话题
罗磊的独立博客
有赞技术团队
有赞技术团队
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Apple Machine Learning Research
Apple Machine Learning Research
P
Proofpoint News Feed
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
博客园 - Franky
Attack and Defense Labs
Attack and Defense Labs
The Cloudflare Blog
Webroot Blog
Webroot Blog
Last Week in AI
Last Week in AI
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
博客园 - 叶小钗
美团技术团队
L
Lohrmann on Cybersecurity
T
The Blog of Author Tim Ferriss
The Last Watchdog
The Last Watchdog
T
Troy Hunt's Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Vercel News
Vercel News
Know Your Adversary
Know Your Adversary
O
OpenAI News
博客园 - 【当耐特】
Hacker News - Newest:
Hacker News - Newest: "LLM"
C
Cybersecurity and Infrastructure Security Agency CISA
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
www.infosecurity-magazine.com
www.infosecurity-magazine.com
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
PCI Perspectives
PCI Perspectives
H
Heimdal Security Blog
I
InfoQ
GbyAI
GbyAI
T
Threatpost
C
Cisco Blogs

極客死亡計劃

大脑充血 Vol.89 It Takes Time 卡利古拉 流量阴谋论 大脑充血 Vol.88 不做结论 阅读阿伦特如何帮我理解后室和架空文学 大脑充血 Vol.87 为什么我只跟练帕梅拉? Je déteste la vi(ll)e “现在上网哪有隐私?” 大脑充血 Vol.86 工作、消费主义和新穷人 大脑充血 Vol.85 一个人的生日 稻草人周刊 Vol.84 堕落 流放与王国 回归晨跑 从 CLOS 审视 Java 面向对象编程 Just A Common Lisper 稻草人周刊 Vol.83 当人们不再问好 人肉装饰器 稻草人周刊 Vol.82 不想啃骨头的流浪狗 稻草人周刊 Vol.81 「诚实」的机器 稻草人周刊 Vol.80 如何拥有铁一样的肠胃? 稻草人周刊 Vol.79
Functional Design
Eltrac · 2026-05-14 · via 極客死亡計劃

一本讲函数式编程以及在函数式语言中应用设计原则、设计模式和整洁架构的书,书本使用的编程语言主要是 Clojure。我得说,这本书从讲完函数式编程是什么之后就散发着面向对象的气味,书中提供的设计模式既 FP 又 OOP,但我想这是更务实的选择,真实的软件不可能是纯函数的。

我读的原文来着。其实鲍勃大叔的语言很通俗,甚至说偏口语化,读起来很轻松。

在《 整洁架构之道 》中,鲍勃大叔提及了三种编程范式,函数式编程就是其中的一种,另外两种分别是结构化编程和面向对象编程。不过,本书中的函数式编程更像是一种「风格」而非强硬的规范。

函数式程序的一个重要特征是引用透明性(Referential transparency),意思是程序员应该能够像替换数学表达式里的函数一样,把程序里的某个函数引用换成具体的值(一般是函数的返回值),并且还能保证程序的行为不改变。要做的这点,就必须要确保状态的不可变性(Immutability)。

假设函数 f 接收参数 x,返回 y,如果能把 f(x) 直接替换为 y,这部分程序就是函数式的。假设 f 读取某个全局变量 z,返回 x + z,那么系统的状态(z)就会影响 f(x) 的返回值,此时就找不到一个 y 可以在保证系统行为不改变的情况下替换 f(x) 了——这个程序不是函数式的。

状态可变会导致很多问题。首先是时序耦合(Temperal Coupling),即代码执行的先后顺序会影响程序的行为。

It is something you are probably quite familiar with. Open must be called before close. New must be called before delete. Malloc must be called before free. The list of pairs like this is endless. And in many ways, they are a bane of our existence.

你有多少次发现某个程序问题只需要调换两个函数调用的顺序就能解决?人们熟悉的垃圾回收机制(Garbage Collection)就是因为人们管理不好时序耦合、管理不好已经分配的内存,才会被广为接受。

另一个问题是并发带来的竞争条件(Race Condition),如果多个进程或线程同时读写一块内存区域(或者说系统状态),最终得到的结果极有可能是不正确的,结果依赖于进程或线程的执行顺序——仔细想想,这实际上也是时序耦合带来的,如果顺序不重要,结果就不会因为顺序改变而发生改变。一般解决这个问题的方式是加锁,而锁是痛苦的。

如果状态不可变,上述问题都不会发生。

You can’t have race condition if you don’t mutate states.

简而言之,函数式编程就是禁止(或者至少在极大程度上限制)赋值操作的编程。函数式语言里的变量实际上不可变,只能被初始化而不能被赋值,它们是常量。函数式程序不改变状态,状态变更是在一个接一个的递归函数调用中以参数的形式被传递的,过往的状态不会改变,我们只创建新的状态。

对于不熟悉函数式编程的读者,可以想象结构化编程里的循环结构,以下是 Go 语言的例子:

func IncrementList(list []int) {
    var newlist []int
    for _, num := range list {
        newnum := num + 1
        newlist = append(newlist, newnum)
    }
    return newlist
}

上面这个函数接收一个整数型 Slice(可以理解为数组),返回所有元素 +1 过后的新 Slice。显然,每次循环都会给 newlist 进行一次赋值。

以下是用 Clojure 编写的,函数式的例子:

(defn inc-list [int-list]
  (map inc int-list))

写完了,甚至不需要定义函数,(map inc int-list) 本身就足够清晰了。

如你所见,函数式编程往往不使用 for 这样的结构遍历集合数据,然后对集合中的每个元素(for each)做操作,而是直接操作集合本身。你也可以这样理解,for 循环其实是用一个迭代器(iterator)访问集合中的数据,而 map 是通过一个函数和一组数据构造了一个迭代器,并且这个迭代器不是用来访问数据的,而是用来直接得到新的数据(或者说直接访问结果)。

如果你想自增后删掉大于等于 10 的元素,Go 语言的写法需要你在 for 循环里写上 if,而 Clojure 只需要这样。

(->> int-list
     (map inc)
     (remove #(>= % 10)))

这甚至很自然地形成了一个职责链。职责链(chain-of-responsibility)是一种软件设计模式,简单来说就是由一个接一个的处理器处理同一个请求,每个处理器有各自的职责。这种设计模式的好处之一是,处理器可以随时替换、拓展和移除(甚至拦截和过滤),同时符合单一职责原则(SRP)和开闭原则(OCP)。什么?你说职责链里的处理对象应该实现 Handler 接口?可这里的处理对象都是函数啊,它们本来就是同一个类型。

函数式编程中,函数是一等公民,和面向对象编程中的类和接口处于同等地位。

实际上这两种方式在具体的执行层面区别不大,但我们拥有了更具表现力(expressiveness)的程序结构。

邱奇-图灵论题(Church–Turing thesis)表明递归、λ 演算和图灵机是等价的,即 λ 演算(lambda calculus)和图灵机程序(也就是任何用图灵完备的编程语言编写的程序)可以相互转换。然而,我们使用的计算机是有限图灵机(finite Turing machine),函数式程序是看起来像 λ 演算的程序,但实际上是在图灵机上实现的。为了实现这种编程风格,我们需要作弊(cheat)。

首先是尾部调用优化(TCO,Tail Call Optimization),因为函数式编程使用递归而非循环(如果想禁止赋值语句,那就必须递归)。如果递归的次数太多,函数调用栈就会占用大量的内存,比方说,在遍历一个很长的列表的时候。如果递归调用在函数的尾部,函数式编程语言的编译器就会优化这段代码,新的递归不会创建新的栈帧(Stack Frame),而是用 goto 语句跳转到函数顶部,复用当前的栈帧。

这实际上还是在使用循环,从执行的层面来看,这个程序和那些使用结构化编程的程序没什么区别,但从源代码来看,递归的方法就是函数式的。所以函数式编程是一种风格,出于各种现实的原因,程序实际上还是顺序执行。

很多时候,函数式程序的底层都不是纯函数的,我们只是把状态变更隔离开来,让大部分的程序看起来是函数式的。纯函数式不够务实,比方说,I/O 操作一定涉及到状态变化,如果要往屏幕上展示东西,就不得不改变显示器的状态;要解决实际问题,我们也不得不并发,不得不存储状态,不得不面对竞争条件。

这么说来,函数式编程是偏执吗?我们知道自己在作弊,也知道世界不是纯函数的,计算机程序也不需要具有数学美感,采用 OO 不是更务实吗?至少在读到这本书的前四分之一时,我发出了这样的疑问。尽管我很快迷上了 FP 并对啰唆的 OOP 颇有微词,但看起来 FP 既不能在图灵机上自如地运行(不优化的话会严重耗费计算资源),也不能完美地解决实际问题(现实是可变的,也很难避免并发),再者,函数式编程语言中有那么多 Lisp 方言1,一堆一堆的括号也吓跑了好多人。

1.

非 Lisp 的函数式编程语言有 Earlang、Haskell 和 F# 等。此外,很多函数式编程语言并不是「纯函数式」的,它们在某种程度上支持可变性,甚至支持面向对象。 ↩︎

我不知道怎么回答,我只能说,函数式编程确实很好玩。单从开发体验来看,比起一门静态类型的面向对象编程语言,我更喜欢用动态类型的函数式编程语言。

噢等等,我说了动态类型对吧?

书中有一章专门对比了面向对象编程和函数式编程解决相同问题的常用方案,鲍勃大叔从各自阵营里选出来的分别是 Java 和 Clojure。除了在编程范式上的区别,这两门语言的类型系统也大相径庭,Java 是静态类型语言而 Clojure 是动态类型语言。

类型的静态和动态意味着什么呢?在静态类型语言里,你没办法给一个整数型变量赋值为字符串,比如 int a = 1 不可能突然变成 "Hello World"。更重要的是自定义类型,如果有一个 Data 对象,在静态类型语言里你可以确定 Data 具有特定的结构,比方说你可以确定 Data.toString() 或者 Data.body 之类的符号一定存在,否则类型系统就报错了,很多情况下在编译阶段就会出现警告和错误。这就是类型安全。

然而,必须要区分「静态类型」和「类型安全」这两个概念。C 语言是静态类型语言,但它并不是类型安全的,比方说 C 语言转换大小写字母的方式就是直接对字符类型做加减法运算,因为 ASCII 编码实际上是对字符的数字编码。字符 A 的十进制编码是 65,字符 a97,要把大写字母转换为小写,只要加上 32 就好,于是就有 'A' + 32 == 'a'2

2.

看起来眼熟吗?你有没有想到另一门类型不安全的语言?提示:这门语言是臭名昭著的动态类型语言。是的,就是 JavaScript。在 JS 里你可以给字符串做加法运算,于是就有了 "1" + 1 == "11" ↩︎

C 语言作为静态类型语言,并没有强硬地限制能对某个类型的数据所做的操作,对字符做算术运算在语义上是错误的,但编译器不会报错,程序也能运行。如果静态类型就意味着类型安全,C 语言程序就不会那么容易出现段错误(segmentation fault)了。

同理,动态类型语言可以是类型安全的,程序员可以用类型不安全的编程语言写出类型安全的程序,只需要比静态类型语言程序员更小心一些。

利用静态类型系统进行编译时检查就能排查很多问题,而不少动态类型语言程序在很大程度上依赖运行时检查,比如用 if 语句判断某变量的类型,这有点像一边开车一边检查汽车的情况,看起来就很不安全。然而,到底要不要在开车前检查,其实是司机自己的选择,与这辆车没检查之前开不开得动无关。静态类型语言里也可以滥用泛型等限制较弱的类型来蒙混过关。

所以动态类型语言怎么保证类型安全呢?

写测试啊。

我前面跑题了,鲍勃大叔没有在书里讨论太多和类型安全相关的问题,我想是因为这部分的隐患都被他良好的开发习惯解决了。这个习惯就是先写测试,再写能够通过测试的程序,也就是测试驱动开发(TDD,Test-Driven Development),或者按照他的说法:最后一个 D 代表设计(Design)。

Clojure 提供了 clojure.spec 标准库,用于规定数据或函数的结构,并依次为依据进行验证、遵照、解释、描述和生成(测试用)数据。你可以用几个谓词函数(返回布尔值的函数)定义一种类型,这样调用 valid? 函数就能判断类型是否合格。这比写在类型定义里要灵活不少,当然,这肯定不如严格的编译器检查来得安全。

不过即便是有编译器检查,也可能出现运行时错误,程序的类型问题不是有了安全的类型系统就被完美解决的。那么,要怎么更彻底地保证安全呢?

写测试啊。

有编译器兜底固然很好,但有自己根据实际问题编写的测试兜底,我觉得要更好。同样是静态类型语言的 Go 语言就强调测试,测试文件甚至直接和源代码文件放在一起(比方说你有一个 context.go 文件,它的测试就写在同一个目录下的 context_test.go 里)。动态类型语言如果能做到较高的测试覆盖率(语句覆盖、条件覆盖、分支覆盖等等),又怎么会不安全呢?

简而言之,在我看来无需纠结编程语言的类型系统,养成良好的编写测试的习惯才是最好的。

——Functional Object-Oriented Language.(函数式面向对象编程语言)

或许可以这样称呼 Clojure,尽管缩略词看起来不太正经。如前文所述,这本书在讲完函数式编程是什么之后,就开始散发浓烈的面向对象的气息。虽然不至于让人捏鼻子远离,但足够引起警觉。仔细审视的话,书中的代码的确都是函数式的,但怎么这么像 OO?鲍勃大叔在第五章的最后一节也审视了这一点,这一节的标题叫作 Postscript: OO Poison?

Clojure 里的确没有类和对象的概念,但在设计程序的时候也难以避免使用类似的词汇。在第三章,鲍勃大叔用函数式设计的角度审视了 SOLID 原则(我依稀记得,我的《软件工程导论》教材把 SOLID 称作面向对象设计原则)。在第五章,他回顾了许多常见的设计模式,比如抽象服务者(Abstract Server)、适配器(Adapter)、命令模式(Command)、装饰者模式(Decorator)等等,这些设计模式一开始都是以面向对象的思想提出的。

某种程度上,鲍勃大叔一直在书中用函数式的编程风格复现面向对象编程的设计模式。由于 Clojure 提供多态能力(defmultidefmethod),所以实现类似「抽象类」和「接口」的东西并不复杂。这在架构层面的确有好处,我们在《 整洁架构之道 》中已经知道:高层策略不应该依赖低层细节(高层与低层指的是模块与 I/O 的距离),源代码的依赖关系应该从具体实现向抽象组件流动,最核心的业务细节应该是抽象的且不应该有太多依赖(应该让其他组件依赖核心业务逻辑,使它们成为插件)。要实现这些,多态貌似是不可或缺的,也因此很容易变成面向对象的样子。

有趣的是,我在读完《整洁架构之道》之后重新审视了自己的某个项目,发现我的源代码依赖关系和整洁架构是相反的,我的高层策略依赖了存储、配置等低层细节,于是我开始想办法,然后我发现我不知道该怎么办,因为 Clojure 没有接口和抽象类(至少当时我不知道还有 Protocol 这个东西)。

思考过后,我意识到使用回调函数就可以实现控制反转了。几次重构后,我得到了这样的实现:在 Main 组件里依赖 storage.clj,这个组件提供 use-store 函数,它返回一个映射,键值对中的值都是函数引用。

(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] 一个参数。

然后把这个映射以参数的形式传入业务逻辑中。

(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})))

然后 processorapp 只需要 ((:append storage) data) 就可以调用映射里的函数了。这里用的是名为 edn-storage 的实现,如果需要换成 SQLite 或别的什么数据库,只需要写一个新的实现,也提供 use-store 方法,返回相同结构的映射,然后修改 core.clj 就好了,业务逻辑不需要改动,符合开闭原则。

几周后我就在这本书里读到了相同的设计方法,我还知道这种映射有个名字,叫 vtable。而且,在读到通过 defmultidefmethod 实现的多态,以及用 Protocolrecord 实现的抽象之后,我仍然更喜欢 vtable,原因很简单:它足够简单优雅,没有多余的语法,而且非常函数式。

我又跑题了。

鲍勃大叔在书中详细地论证了那些适用于面向对象编程的原则和设计思想,其实也能丝滑地迁移到函数式编程中,包括里氏替换原则和接口隔离原则等看起来就只属于 OOP 的原则。我想这是因为这些原则的核心就是为了管理好源代码依赖关系,而依赖关系在函数式编程中当然是存在的。

The “interface” of a module is simply the list of all the access points within that module.

模块的“接口”不过是其中的一系列接入点而已。

对象、类和接口也不只是指代 Object classinterface 等关键词而已,一个类型被 clojure.spec 验证过的映射,语义明确且行为明确,难道不能称作某个“类”的“实例”吗?我们依然可以用类似的语言描述函数式程序,而这并不会改变函数式的性质,因为我们状态依旧是不可变的,系统的状态依旧通过一个接一个的递归在函数调用之间被传递。

面向对象编程风格和函数式编程风格,是相容的,并且互惠互利。

对我来说,函数式编程不是一个新阵营(尽管某一部分的我倾向于把它当作一个阵营)而是一种风格,更确切地来说是一种思想。面向对象编程也是一种思想,再仔细想想,结构化编程也广泛地存在于其他两种编程范式中,毕竟用 FP 或 OOP 都不代表你突然接受 goto 语句了。

或许这也是权衡取舍的问题,程序就应该既结构化、又面向对象、还函数式,依照语言的特性和具体问题选择最合适的方式就好。

书评的主要内容就结束了,剩下的是一些我觉得有意思但塞不进前文的一些内容。

封闭类

鲍勃大叔在讲访问者模式(Visitor)的时候提到,这种模式很可怕,因为 Visitor 和源数据对象之间仍然是紧密耦合的,而且增加了一层复杂度,然而又不得不存在,因为程序要符合开闭原则。

举个例子,你有一个数据对象 Data,你可以直接 Data.toString() 把它转换为字符串,但可能客户需要 JSON 格式的输出,那你就要写一个 Data.toJSON() 方法,可这种需求可能是无穷的,搞不好就会频繁修改 Data 的源代码,就为了加上 .toTOML() .toXML().toYAML() 等方法。这违反了开闭原则,我们没有在拓展程序,而是在直接修改源代码。

更好的方式是使用访问者模式,定义 DataVisitor 接口,接下来就可以编写 JSONDataVisitor TOMLDataVisitorXMLDataVisitor 等实现,让 DataVisitor 读取数据并输出格式,Data 的源代码无需修改,这样就符合开闭原则了。

这无疑为系统添加了很多复杂性,为了让 DataVisitor 访问 Data,我们还需编写 Data.accept() 方法(我觉得这有点像迭代器模式的 Aggregate.createIterator()),让 DataVisitor 读取到 Data 内部的私有变量和私有方法。多了一层抽象也让人觉得很不是滋味,如此大费周章只是为了访问数据吗?

鲍勃大叔指出这些不得不忍受的复杂性,其实是 Java 等面向对象编程的语言限制导致的。

Java 的类是封闭的,意味着你不能在另一个源文件里给已有的类添加新的方法。要添加新方法必须修改原有类的源代码,然而这违反了开闭原则。然而,Clojure 可以在不同的源文件里给已有的“类”添加方法,C# 也可以,如果我的理解没错的话,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 也没有听到声音。两个人可能什么也每说就挂掉了电话,或者知道发生了什么,于是开始讨论究竟是谁先拨的号。

同样的问题也可以用函数式程序模拟出来,而且整个过程也是顺序或者说同步的,没有并发和异步操作。

竞争条件只是在有可变状态的多线程程序中更常见而已,函数式编程也消除不了竞争条件的可能。