{T}

编程范式篇

← 03 编程语言篇 | 05 设计原则篇 →

一句话概述:编程范式决定了做设计时可用的元素和需要遵守的约束——当今的趋势是多范式融合,将面向对象用于组织程序、函数式编程用于接口设计、结构化编程用于局部实现。


知识图谱

图表渲染中…

图注:编程范式篇八大主题——从范式总览到结构化编程的基石作用,再到面向对象的三大支柱(封装/继承/多态)和函数式编程的核心能力(一等公民/组合性/不变性)。


4.1 编程范式总览

章节概述

编程范式(Programming Paradigm)指的是程序的编写模式,它决定了在设计时可用的元素有哪些,以及需要遵守哪些约束。理解编程范式的关键不在于"可以做什么",而在于"不应做什么"——每种范式通过限制某些能力来引导开发者写出更可维护的代码。当今的趋势是多范式融合:取各家之长,将面向对象用于组织程序、函数式编程用于接口设计、结构化编程用于局部实现。

从一段代码评审说起

在一次代码评审中,小李兴致勃勃地给大家讲解自己用心编写的一段代码。这段代码不仅实现了业务功能,还考虑了许多异常场景。面对同事们提出的各种问题,小李能够应对自如。讲解完毕,技术负责人老赵站了起来:"小李啊!你这段代码从功能上来说,考虑得已经很全面了,这段时间你确实进步很大啊!"

得到老赵的肯定,对小李来说是莫大的荣耀。然而,老赵接着说:"但是啊,写代码不能只考虑功能,你看你这代码写的,虽然用的是Java,但写出来的简直就是C代码。"

小李使用的是Java,一门标准的面向对象程序设计语言,为何被评价为编写的是C代码?老赵指出:"所有的代码都是把字段取出来计算,然后,再塞回去。各种不同层面的业务计算混在一起,将来有一点调整,所有的代码都得跟着变。"

老赵的评价并非指小李的代码等同于C代码,而是指出采用Java编写的代码应当体现Java的语言风格,而小李的代码却处处体现着C的风格。此处所谓的代码风格,即编程范式

编程范式(Programming Paradigm),指的是程序的编写模式。采用了何种编程范式,通常意味着主要采用的是什么样的代码结构。从设计的角度而言,编程范式决定了在设计时可用的元素有哪些。

三大主流编程范式

当前主流的编程范式主要有三种,每种范式都提供了特定的编程元素,同时也施加了相应的约束:

  • 结构化编程(Structured Programming):是多数开发者最为熟悉的编程范式,它通过一些结构化的控制结构进行程序的构建。最典型的结构化编程语言是C语言。C语言控制结构的影响极其深远,成为了许多程序设计语言的基础。

  • 面向对象编程(Object-Oriented Programming):是当前最主流的编程范式,其核心概念即为对象。以面向对象风格编写的程序,本质上就是一堆对象之间的交互。面向对象编程提供了一种管理程序复杂性的方式,其中最重要的概念就是多态(Polymorphism)。当前主流的程序设计语言几乎都提供面向对象编程能力,其中最典型的代表当属Java。

  • 函数式编程(Functional Programming):是近些年重新崛起的编程范式。其核心概念是函数,但此函数源自数学中的函数,与常规理解的函数存在极大不同:不变性。换言之,一个符号一旦创建便不再改变。函数式编程的代表性语言是LISP。

图表渲染中…

图注:三大范式各有约束——限制goto/函数指针/赋值。现代趋势是多范式融合,取各家之长。

提供什么 vs 约束什么

编程范式不仅仅是提供了一系列的概念,更重要的是,它对开发者的能力施加了约束。理解这一区分至关重要:

编程范式提供的元素:

范式提供的控制流提供的数据组织方式提供的抽象机制
结构化编程顺序/选择/循环函数/过程/模块功能分解
面向对象编程消息传递/方法调用对象/类/接口封装/继承/多态
函数式编程函数组合/递归不可变值/代数数据类型高阶函数/Lambda

编程范式施加的约束:

  • 结构化编程:限制使用goto语句,它是对程序控制权的直接转移施加了约束。
  • 面向对象编程:限制使用函数指针,它是对程序控制权的间接转移施加了约束。
  • 函数式编程:限制使用赋值语句,它是对程序中的赋值施加了约束。

与其说这些编程范式是指导如何编写程序,毋宁说它们规定了不应如何行事。理解这一点,才算是真正理解了这些编程范式。

三大主流范式对比

维度结构化编程面向对象编程函数式编程
核心概念控制结构对象函数
约束什么goto(直接控制转移)函数指针(间接控制转移)赋值语句(可变性)
典型语言CJavaHaskell / LISP
擅长什么局部实现细节程序的组织接口设计/数据处理
局限不能隔离变化继承陷阱学习门槛高
抽象级别低(指令封装)中(对象封装)高(数学抽象)

多范式融合趋势

从理论上讲,编程范式与具体语言的关系不大,这就好比思考与用什么语言表达是无关的。但在实际情况中,每一种语言都有自己的主流编程范式。例如,C语言主要是结构化编程,而Java主要是面向对象编程。

不过,虽然每种语言都有自己的主流编程范式,但丝毫不妨碍开发者在学习多种编程范式之后,打破"次元壁",将不同编程范式中的优秀元素吸纳进来。此处的重点是"优秀",而非"全部"。

以Linux的虚拟文件系统(Virtual File System,简称VFS)为例,它可以理解成一个文件系统的接口。在所有的接口中,最主要的是file_operations,它对应着各种文件操作:

c
struct file_operations {
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  int (*open) (struct inode *, struct file *);
  int (*flush) (struct file *, fl_owner_t id);
  int (*release) (struct inode *, struct file *);
  ...
};

如果要开发一个自定义文件系统,只需将支持的接口逐一实现,即给该结构体的字段赋值。换个角度分析,该结构体主要的字段均为函数指针,文件系统展现的行为与这些函数的赋值密切相关。只要给该结构体的字段赋予不同的参数,即将不同的函数关联上,该文件系统便会呈现不同的行为。若熟悉面向对象编程,会发现这即是多态的体现。

C是一门典型的结构化编程语言,而VFS的设计展现出的却是面向对象编程的特点,编程范式的"次元壁"在此处被打破了。

类似的设计还有许多。例如,Java里有一个著名的基础库Guava,它提供了函数式编程的基础设施。在Java 8之前,Java在语法上并不支持函数式编程,但这并不妨碍通过类模拟出函数。配合Guava提供的基础设施,很早就可以将函数式编程的方式应用于Java中。同样,C++有一个functor的概念,即函数对象,通过重载()运算符,让对象模拟函数的行为。

无论是在以结构化编程为主的语言中引入面向对象编程,还是在面向对象为主的语言中引入函数式编程,在一个程序中应用多种编程范式已经成为了一个日益明显的趋势。

不仅仅是在设计中,现在越来越多的程序设计语言开始将不同编程范式的内容融合起来。Java从Java 8开始引入了Lambda语法,现在可以更优雅地写出函数式编程的代码了。同样,C++ 11开始,语法上也开始支持Lambda了。

之所以多范式编程会越来越多,是因为关注点在于做出好的设计,写出更容易维护的代码,因而,会尝试将不同编程风格中优秀的元素整合在一起。例如,采用面向对象来组织程序,而在每个类具体的接口设计上,采用函数式编程的风格,在具体的实现中使用结构化编程提供的控制结构

回过头来分析小李的代码,老赵之所以批评小李,关键在于小李并没有将各编程范式中优秀的元素有效整合。Java提供对面向对象的支持,面向对象的强项在于程序的组织,其核心设计元素应为对象,程序应依靠对象的组合来完成,而小李却将其编写成了平铺直叙的结构化代码,这当然是不值得鼓励的。

对于当今的开发者而言,学习不同的编程范式,将不同编程范式中的优秀元素应用于日常软件设计之中,已经由原来的可选项变成了现在的必选项。否则,即便拥有强大的现代化工具,也只能发挥出有限的作用。

核心要点

  • 编程范式指的是程序的编写模式,决定了设计时可用的元素和需要遵守的约束
  • 理解编程范式的关键在于"哪些事情不要做",而非"可以做什么"
  • 三大主流范式:结构化编程(限制goto)、面向对象编程(限制函数指针)、函数式编程(限制赋值)
  • 多范式融合是当代趋势:面向对象组织程序、函数式设计接口、结构化写局部实现
  • 打破语言的"次元壁"——在一种语言中运用多种范式的优秀元素

4.2 结构化编程

章节概述

结构化编程是现代编程范式的基石,它通过三种基本控制结构(顺序、选择、循环)替代了非结构化编程中goto语句的随意跳转,并引入了功能分解的思想,将大问题拆解为小问题。然而,结构化编程存在本质局限:无法有效隔离变化——数据和操作数据的行为分离导致修改扩散。因此,结构化编程注定要成为其他编程范式的基石,而非终局方案。

Goto 是有害的

许多人学习编程都是从C语言起步的,C语言就是一种典型的结构化编程语言。C的结构化编程也渗透进了后来的程序设计语言之中,例如C++、Java、C#等等。

谈及结构化编程,一定会联想到那些典型的控制结构,例如:顺序结构、选择结构和循环结构,还会想到函数(如果用术语来讲,应该叫subroutine)和代码块(block)。这几乎是开发者们每天都在使用的工具。

但是,是否思考过这样一个问题?面向对象编程之所以称为面向对象,是因为其中主要的概念是对象,而函数式编程主要的概念是函数。可结构化编程为什么叫结构化呢,难道它的主要概念是结构?这似乎并非如此。

事实上,所谓结构化,是相对于非结构化编程而言的。因此,要想真正了解结构化编程,就要回到非结构化的古老年代,看看那时候是怎么编写程序的。换言之,只有了解结构化编程的发展历程,才能更好地认清结构化编程的不足。

1968年,迪杰斯特拉(Edsger W. Dijkstra)——1972年的图灵奖获得者——在ACM通讯上发表了一篇具有里程碑意义的文章,题目叫做《Goto是有害的》(Go To Statement Considered Harmful),这篇文章引起了轩然大波。

Dijkstra的核心论点是:goto语句使程序的控制流变得不可预测,程序的静态结构与动态执行轨迹之间失去了清晰的对应关系,这使得程序的正确性证明变得几乎不可能。他提出,应该仅使用三种基本的控制结构来组织程序。

并非所有人都会接受这种新想法,那些习惯了自由放纵的开发者对Dijkstra进行了无情的冷嘲热讽。他们认为,按照结构化的方式编写效率太低。现在可能很难想象,C语言初问世之际,遭到最大的质疑是效率低。没错,C语言被质疑效率低,和Java面世之初遇到的挑战如出一辙。

提出这种质疑的人只看到了新生事物初生时的不足,却忽略了它们强大的可能性。他们不知道,一旦构建起新的模型,底层实现是可以不断优化的。更重要的是,有了新的更高级却也更简单的模型,入门门槛就大幅度降低了,更多的人就可以加入进来,进一步促进这门语言的发展。开发者数量的增多,就可以证明这一点。

现在的许多开发者实际上对底层知识的了解并不多,但丝毫不妨碍他们完成基本的业务功能。只要使用者足够多,人们就会有更强的驱动力去优化底层实现。时至今日,已经很少有人敢说自己手写的汇编性能一定优于编译器优化后的结果。

最终这场争论逐渐平息,新的结构逐渐普及,也证明了Dijkstra是对的。goto语句的重要性逐渐降低,一些现代程序设计语言干脆在设计上就把goto语句移除了。

三种基本控制结构

那么,结构化编程究竟提供了哪三种基本控制结构呢?

顺序结构:代码按照编写的顺序执行。这是最基础的结构,也是所有编程范式共有的基础。

选择结构:即if/else语句。执行一个表达式,然后根据该表达式返回值是真还是假,决定执行if后面的代码,还是else后面的代码。在汇编层面,这是通过比较指令和条件跳转实现的:先执行一段代码,把执行结果和0比较;如果不等于0就接着执行,等于0就跳转到另外一个地方执行。

循环结构:即while/for/do-while语句。在判断之后,决定跳到另外一个地方,还是继续执行下面的代码。如果执行下面的代码,执行到后面就会有一个跳转指令让我们跳回来,再作一次判断。

了解了上述内容,再加上汇编语言本身的顺序执行,最熟悉的控制结构就都回来了。因此,即便是用汇编,依然可以放心地以原来的方式编写代码。

图表渲染中…

图注:结构化编程用三种控制结构替代goto,带来了功能分解的能力,但不能有效隔离变化——这是它作为"基石"而非"终局"的原因。

原来的程序员面对的确是这些汇编指令,但是他们站在直接使用指令的角度去思考。因此,他们更习惯按照自己的逻辑去编写,这其中最方便的写法当然就是需要用到哪块逻辑,就goto到哪里执行一段代码,然后,再goto到另外一个地方。

这种编写起来自由自在的方式,在维护时却会遇到极大的挑战,因为很难预测代码的执行结果。有人可能只是图个方便,就goto到一个地方继续执行。可只要代码规模稍微一大,就几乎难以维护了,这便是非结构化的编程方式

功能分解

这种结构化编程的思想最初是为了证明程序正确性而诞生的。

Dijkstra很早就得出一个结论:编程是一项难度很大的活动。因为一个程序会包含非常多的细节,远超一个人的认知能力范围,任何一个细微的错误都会导致整个程序出现问题。

因此,他提出goto语句是有害的,还有一个重要的原因是,Dijkstra为了证明程序的正确性,借助数学推导的方法,将大问题拆分成小问题,逐步递归下去,拆分成更小的、可证明的单元时,他发现goto语句的存在影响了问题的递归拆分,导致问题无法被拆分。

这就是结构化编程另一个重要的方面:功能分解

功能分解就是将模块按照功能进行拆分。这样一来,一个大问题就会被拆解成一系列高级函数的组合,而这些高级函数各自再进一步拆分,拆分成一系列的低一级的函数,如此一步步拆分下去,每一个函数都需要按照结构化编程的方式进行开发。这一思想符合人们解决问题的直觉,对软件开发产生了深远的影响。

以此为基础,后来出现了各种结构化分析和结构化设计的方法。将大型系统拆分成模块和组件,这些模块和组件再做进一步的拆分,这些都是来自结构化编程的设计思想。在今天看来,这一切简直再正常不过了,几乎融入了每个开发者的日常话语体系之中。

结构化编程的局限

介绍完结构化编程的发展历程,自然也就能看出它的不足之处了。

虽然结构化编程是比汇编更高层次的抽象,开发者们有了更强大的工具,但人们从来不会就此满足,随之而来的是,程序规模越来越大。这时,结构化编程就显得力不从心了。用一个设计上的说法形容结构化编程就是"抽象级别不够高"。

这就好比拿着一个显微镜去观察,如果观察的目标是细菌,它能够很好地完成工作,但如果用它观察一个人,恐怕就很难去掌握全貌了。结构化编程是为了封装低层的指令而生的,而随着程序规模的膨胀,它组织程序的方式就显得很僵硬,因为它是自上而下进行分解的。

一旦需求变动,经常是牵一发而动全身,关联的模块由于依赖关系的存在都需要变动,无法有效隔离变化。显然,如何有效地组织这么大规模的程序并不是它的强项,因此,结构化编程注定要成为其它编程范式的基石。

如果站在今天的角度看,结构化编程还存在一个问题,即可测试性不够,原因同上,它的依赖关系太强,很难拆出来单独测试一个模块。

因此,仅仅掌握结构化编程,并不足以做出好的设计,必须把它与其他编程范式结合起来,才能应对已经日益膨胀的软件规模。

核心要点

  • 结构化编程相对于非结构化编程(goto随意跳转),限制了程序控制权的直接转移
  • Dijkstra 1968年发表《Goto是有害的》,引发了结构化编程的革命
  • 三种基本控制结构:顺序、选择(if/else)、循环(while/for)——证明goto非必需
  • 功能分解思想深远:将大问题拆成小问题,每个小问题可独立解决和验证
  • 结构化编程的不足:模块依赖关系太强,牵一发而动全身,不能有效隔离变化
  • 可测试性也不够——依赖太强,难以单独测试
  • 结构化编程是其他编程范式的基石,但不是终局方案

4.3 面向对象之封装

章节概述

封装是面向对象的根基。封装的本质不是简单的"隐藏数据",而是"基于行为来封装"——对象之间只能通过消息(方法调用)来通信,重点在于对象提供了哪些行为,而不是有哪些数据。迪米特法则(最少知识原则)要求一个对象不应知道太多其他对象的内部结构。"Tell, Don't Ask"原则强调行为应该归属到它所操作的数据上。封装与数据隐藏是两个相关但不等同的概念:封装关注的是行为的聚合,而数据隐藏只是封装的实现手段之一。

封装的宏观视角

随着程序规模的逐渐膨胀,结构化编程在解决问题上的局限也越发凸显出来。因为在它提供的解决方案中,各模块的依赖关系太强,不能有效地将变化隔离开来。这时候,面向对象编程登上了舞台,它提供了更好的组织程序的方式。

在一些从结构化编程起步的开发者的视角里,面向对象就是数据加函数。虽然这种理解不算完全错误,但理解的程度远远不够。结构化编程的思考方式类似于用显微镜看世界,这种思考方式会让人只能看到局部。而想要用好面向对象编程,则需要有一个更宏观的视角。

谈到面向对象,可能会想到面向对象的三个特点:封装、继承和多态。在接下来的三节,分别探讨面向对象的这三个特点。

面向对象是解决更大规模应用开发的一种尝试,它提升了开发者管理程序的尺度。

封装,则是面向对象的根基。它把紧密相关的信息放在一起,形成一个单元。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上。

为了更好地理解这个过程,先回到面向对象的最初。"面向对象"这个词是由Alan Kay创造的,他是2003年图灵奖的获得者。在他最初的构想中,对象就是一个细胞。当细胞一点一点组织起来,就可以组成身体的各个器官,再一点一点组织起来,就构成了人体。而当观察人的时候,就不用再去考虑每个细胞是怎样的。因此,面向对象给了我们一个更宏观的思考方式。

但是,这一切的前提是,每个对象都要构建好,也就是封装要做好,这就像每个细胞都有细胞壁将它与外界隔离开来,形成了一个完整的个体。

基于行为封装

在Alan Kay关于面向对象的描述中,他强调对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。在Alan Kay的构想中,他甚至想把数据去掉。

因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。换言之,即便把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如一直强调的那样,接口是稳定的,实现是易变的。

理解了这一点,来看一个许多人都有的日常编程习惯。他们编写一个类的方法时,把这个类有哪些字段写出来,然后,生成一大堆getter和setter,将这些字段的访问暴露出去。这种做法的错误就在于把数据当成了设计的核心,这一堆的getter和setter,就等于把实现细节暴露了出去。

一个正确的做法应该是,设计一个类,先要考虑其对象应该提供哪些行为。然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。

请注意,方法的命名体现的是意图,而不是具体怎么做。因此,getXXX和setXXX绝对不是一个好的命名。举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:

java
class User {
  private String username;
  private String password;

  ...

  // 修改密码 —— 数据驱动命名
  public void setPassword(final String password) {
    this.password = password;
  }
}

但我们鼓励的做法是,把意图表现出来:

java
class User {
  private String username;
  private String password;

  ...

  // 修改密码 —— 行为驱动命名
  public void changePassword(final String password) {
    this.password = password;
  }
}

这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做。将意图与实现分离开来,这是一个优秀设计必须要考虑的问题。

图表渲染中…

图注:面向对象封装的正确思路——行为驱动而非数据驱动。方法命名体现意图,而非实现方式。

不过,在真实的项目中,有时确实需要暴露一些数据,因此,等到确实需要暴露的时候,再去写getter也不迟,一定要问问自己为什么要加getter。至于setter,首先,大概率是用错了名字,应该用一个表示意图的名字;其次,setter通常意味着修改,这是不鼓励的。后面讲函数式编程时,会讲到不变性,可变的对象会带来很多的问题。因此,设计中更好的做法是设计不变类。

迪米特法则(最少知识原则)

封装还有一个重要的指导原则,即迪米特法则(Law of Demeter,简称LoD),也称最少知识原则(Principle of Least Knowledge)。该法则指出:一个对象应当对尽可能少的其他对象有 knowledge——换言之,一个对象不应知道太多其他对象的内部结构。

违反迪米特法则的一个典型例子是"火车链式调用"(Train Wreck):

java
// ❌ 违反迪米特法则 —— 对象A知道太多关于B、C、D的内部结构
String cityName = user.getAddress().getCity().getName();

在这段代码中,user对象不仅知道address的存在,还知道address有city属性,city又有name属性。这意味着user对象与address、city、name形成了紧密的耦合。一旦其中任何一层的内部结构发生变化(比如city不再直接持有name,而是通过某个方法获取),user的使用方代码就需要相应修改。

正确的做法是将这种行为封装到适当的对象中:

java
// ✅ 符合迪米特法则 —— user只与address交互
String cityName = user.getCityName();

// 在User类内部:
public String getCityName() {
    return address.getCityName();
}

// 在Address类内部:
public String getCityName() {
    return city.getName();
}

Tell, Don't Ask(告诉,不要询问)

迪米特法则在实践中常体现为 "Tell, Don't Ask" 原则:告诉对象你要做什么,而不是询问对象的状态后再自行决定做什么。

java
// ❌ Ask 风格 —— 询问状态后自己处理
if (user.canLogin()) {
    user.login();
}

// ✅ Tell 风格 —— 直接告诉对象你的意图
user.attemptLogin();

在Ask风格的代码中,调用方需要知道user的canLogin判断逻辑,并且需要自行决定是否调用login。这暴露了user的内部决策逻辑。而在Tell风格中,调用方只需要表达意图,具体的判断和处理逻辑被封装在了user对象内部。

Tell, Don't Ask 的核心思想是:行为应该归属到它所操作的数据上。 如果发现自己在写"检查某对象的状态,然后根据状态做某事"这样的代码,就应该停下来思考:这个行为是否应该移到那个对象内部去?

封装 vs 数据隐藏

封装和数据隐藏是两个经常被混淆的概念:

维度封装(Encapsulation)数据隐藏(Data Hiding)
核心含义将相关的行为和数据组织在一起限制对外部代码的数据访问
关注点行为的聚合与内聚访问控制的实现手段
目标创建稳定、高内聚的单元保护对象的不变量
实现方式类的设计、接口定义public/private/protected
层次设计层面的概念语言机制层面的概念

封装是一个设计层面的概念,关注的是如何将相关的行为和数据有机地组织在一起,形成一个内聚的单元。数据隐藏则是封装的一种实现手段——通过访问控制修饰符来防止外部代码直接操纵对象的内部状态。

仅仅做到数据隐藏(比如把所有字段设为private并提供getter/setter)并不意味着良好的封装。真正的封装要求:对象暴露的是行为(做什么),而非数据(有什么)。getter和setter往往意味着数据泄漏到了外部,破坏了封装的本质。

最小化接口暴露

之所以需要封装,就是要构建一个内聚的单元。因此,要减少这个单元对外的暴露。这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,减少对外暴露的接口

一般面向对象程序设计语言都支持public、private这样的修饰符。开发者在日常开发中,经常会很草率地给一个方法加上public,从而不经意间将一些本来应该是内部实现的部分暴露出去。举个例子,一个服务要停下来的时候,可能要把一些任务都停下来,代码可能会这样写:

java
class Service {
  public void shutdownTimerTask() {
    // 停止定时器任务
  }

  public void shutdownPollTask() {
    // 停止轮询服务
  }
}

别人调用时,可能会这样调用这段代码:

java
class Application {
  private Service service;

  public void onShutdown() {
    service.shutdownTimerTask();
    service.shutdownPollTask();
  }
}

突然有一天,发现停止轮询任务必须在停止定时器任务之前,就不得不要求别人改代码。而这一切就是因为很草率地给那两个方法加上了public,让别人有机会看到了这两个方法。

从设计的角度来说,必须谨慎地问一下,这个方法真的有必要暴露出去吗?

以该为例,我们可以仅仅暴露一个方法:

java
class Service {
  private void shutdownTimerTask() {
    // 停止定时器任务
  }

  private void shutdownPollTask() {
    // 停止轮询服务
  }

  public void shutdown() {
    this.shutdownTimerTask();
    this.shutdownPollTask();
  }
}

调用代码也会简单很多:

java
class Application {
  private Service service;

  public void onShutdown() {
    service.shutdown();
  }
}

尽可能减少接口暴露,这个原则不仅仅适用于类的设计,同样适用于系统设计。在很多团队中,非常随意地在系统里面添加接口,一个看似不那么复杂的系统里,随随便便就有成百上千个接口。

如果想改造系统去掉一些接口时,很有可能会造成线上故障,因为根本不知道哪个团队在什么时候用到了它。因此,在软件设计中,暴露接口需要非常谨慎。

关于这一点,可以有一个统一的原则:最小化接口暴露。换言之,每增加一个接口,都要找到一个合适的理由。

核心要点

  • 封装是面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的
  • 封装的本质是基于行为来封装,而非简单的"隐藏数据"
  • 迪米特法则(最少知识原则):一个对象不应知道太多其他对象的内部结构
  • Tell, Don't Ask:行为应该归属到它所操作的数据上——告诉对象做什么,不要询问后自行处理
  • 封装 ≠ 数据隐藏:封装是设计层面的行为聚合,数据隐藏是实现手段
  • 函数是接口(稳定的),数据是实现(易变的)——应最小化接口暴露
  • getter和setter是暴露实现细节的,尽可能不提供,尤其是setter
  • 设计一个类的方法:先考虑行为,再设计方法,最后才考虑字段

4.4 面向对象之继承

章节概述

继承的本质是一种 IS-A 关系,但在实践中常被误用为代码复用的工具。继承的主要问题在于:父类的变更会影响所有子类(白盒复用)、脆弱基类问题(Fragile Base Class Problem)、以及继承层次过深导致的理解困难。组合优于继承(Composition Over Inheritance) 是面向对象设计的黄金法则——组合是黑盒复用,继承是白盒复用。Ruby 的 Mixin 和 Scala 的 Trait 提供了多重代码复用的替代方案。面向组合编程将视角转换为:类由多个小模块(Role/Trait/Mixin)组合而成,每个模块对应一个关注点。

继承的两种视角

谈到继承,许多讲面向对象的教材一般会这样描述,画一棵树,父类是根节点,而子类是叶子节点,显然,一个父类可以有许多个子类。

父类的作用是什么呢?就是把一些公共代码放进去,之后在实现其他子类时,可以少写一些代码。讲程序库的时候,说过,设计的职责之一就是消除重复,代码复用。因此,在许多人的印象中,继承就是一种代码复用的方式。

如果把继承理解成一种代码复用方式,更多的是站在子类的角度向上看。在客户端代码使用的时候,面对的是子类,这种继承叫实现继承

java
Child object = new Child();

实际上,还有一种看待继承的角度,就是从父类的角度往下看,客户端使用的时候,面对的是父类,这种继承叫接口继承

java
Parent object = new Child();

不过,接口继承更多是与多态相关,暂且放一放,留到下一节再来讨论。这一节,主要来说说实现继承。事实上,实现继承并不是一种好的做法

换言之,把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法。一方面,继承是很宝贵的,尤其是Java这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承了,再做接口继承就很难了。

另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有许多程序设计语言,即使不使用继承,也有自己的代码复用方式。

继承的问题

实现继承作为代码复用方式,存在以下核心问题:

白盒复用问题:继承是"白盒复用"——子类可以看到并依赖于父类的内部实现细节。父类的任何变更都可能波及所有子类。这与组合的"黑盒复用"形成鲜明对比:组合时,被组合的对象只暴露接口,内部实现完全不可见。

脆弱基类问题(Fragile Base Class Problem):当基类发生修改时,即使修改看起来是完全合法的,也可能导致子类出现意想不到的行为。这是因为子类可能与父类的实现细节产生了隐式的耦合——子类可能重写了某个方法并假设了父类的某种行为,而父类的修改打破了这个假设。

继承层次的膨胀:当系统中存在多个可变的维度时,继承会导致类的数量爆炸式增长。这是一个典型的 M×N 问题。

举个例子,有个字体类(Font),现在的需求是,字体能够加粗(Bold)、能够有下划线(Underline)、还要支持斜体(Italic),而且这些能力之间是任意组合的。

如果采用继承的方式,那就要有8个类:

图表渲染中…

图注:3个属性的组合——继承需要8(2³)个类,4个属性就需要16个类;组合只需要3个维度,再加一个维度只增加一个布尔属性。继承是M×N问题,组合是M+N问题。

而采用组合的方式,字体类(Font)只要有三个独立的维度,即是否加粗(Bold)、是否有下划线(Underline)、是否是斜体(Italic)。这还不是终局,如果再来一种其他的要求,由3种要求变成4种,采用继承的方式,类的数量就会膨胀到16个类,而组合的方式只需要再增加一个维度就好。把一个M×N的问题,通过设计转变成了M+N的问题,复杂度的差别一望便知。

组合优于继承

假设,要做一个产品报表服务,其中有个服务是要查询产品信息,这个查询过程是通用的,别的服务也可以用,因此,把它放到父类里面。这就是代码复用的做法,代码用Java写出来是这样的:

java
class BaseService {
  // 获取相应的产品信息
  protected List<Product> getProducts(List<String> product) {
    ...
  }
}

// 生成报表服务
class ReportService extends BaseService {
  public void report() {
    List<Product> product = getProduct(...);
    // 生成报表
    ...
  }
}

如果采用Ruby的mixin机制,还可以这样实现,先定义一个模块(module):

ruby
module ProductFetcher
  # 获取相应的产品信息
  def getProducts(products)
    ...
  end
end

然后,在自己的类定义中,将它包含(include)进来:

ruby
# 生成报表服务
class ReportService
  include ProductFetcher

  def report
    products = getProducts(...)
    # 生成报表
    ..
  end
end

在这个例子中,ReportService并没有继承任何类,获取产品信息的代码也是可以复用的,也就是这里的ProductFetcher这个模块。这样一来,如果需要有一个获取产品信息的地方,它不必非得是什么服务,无需继承任何类。

这是Ruby的做法,类似的语言特性还有Scala里的trait。

在C++中,虽然语法并没有严格地区分实现继承,但《Effective C++》这本行业的名著,给出了一个实用的建议:实现继承采用私有继承的方式实现:

cpp
class ReportService: private ProductFetcher {
  ...
}

请注意,在这个实现里,私有继承类名是ProductFetcher。没错,它并不需要和这个报表服务有什么直接的关系,使用私有继承,就是为了复用它的代码。

从前面的分析中,也不难看出,获取产品信息和生成报表服务本来就是两件事。只是在生成报表的过程中,需要获取产品信息,因此,它有了一个基类。

事实上,在Java里面,不用继承的方式也能实现,代码可以写成这样:

java
class ProductFetcher {
  // 获取相应的产品信息
  public List<Product> getProducts(List<String> product) {
    ...
  }
}

// 生成报表服务
class ReportService {
  private ProductFetcher fetcher;

  public void report() {
    List<Product> product = fetcher.getProducts(...);
    // 生成报表
    ...
  }
}

这种实现方案叫作组合,即ReportService里组合进一个ProductFetcher。在设计上,有一个通用的原则叫做:组合优于继承。换言之,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。

因此,要编写继承的代码时,先问自己,这是接口继承,还是实现继承?如果是实现继承,那是不是可以写成组合?

方式语言支持特点复用类型
实现继承Java单继承限制,概念混淆白盒复用
Mixin / TraitRuby / Scala多模块组合,不占继承位黑盒复用
组合所有语言最灵活,符合"分离关注点"黑盒复用

Mixin 与 Trait

Mixin 是 Ruby 提供的一种多重代码复用机制。Module 可以被 include 到类中,从而将该 module 中定义的方法混入到目标类。与继承不同,Mixin 不占用类的继承位置,一个类可以 include 多个 Module,从而获得多重代码复用的能力。

Trait 是 Scala 提供的类似机制。与 Ruby 的 Mixin 相似,Trait 允许将一组方法和字段定义打包成一个单元,然后混入到类中。Scala 的 Trait 还支持线性化(linearization)来解决菱形继承问题。

这两种机制的共同特点是:它们提供了比继承更灵活的代码复用方式,同时避免了多重继承带来的复杂性。

面向组合编程

之所以可以用组合的方式实现,本质的原因是,获取产品信息和生成报表服务本来就是两件事。还记得在"分离关注点"里讲过的吗?如果能看出它们是两件事,就不会把它们放到一起了。

还讲过,分解是设计的第一步,而且分解的粒度越小越好。当可以分解出来多个关注点,每一个关注点就应该是一个独立的模块。最终的类是由这些一个一个的小模块组合而成,这种编程的方式就是面向组合编程。它相当于换了一个视角:类是由多个小模块组合而成。

还以前面的报表服务为例,如果使用Java,按照面向组合的思路写出来,大概是下面这样的。其中,为了增加复杂度,增加了一个报表生成器(ReportGenerator),在获取产品信息之后,还要生成报表:

java
class ReportService {
  private ProductFetcher fetcher;
  private ReportGenerator generator;

  public void report() {
    List<Product> product = fetcher.getProducts(...);
    // 生成报表
    generator.generate(product);
  }
}

请注意,在前面的表述中,故意用了模块这个词,而不是类。因为ProductFetcher和ReportGenerator只是因为我们用的是Java,才写成了类;如果用Ruby,它们的表现形式就会是一个module;而在Scala里,就会成为一个trait。我们再用Ruby示意一下:

ruby
class ReportService
  include ProductFetcher
  include ReportGenerator

  def report
    products = getProducts(...)
    # 生成报表
    generateReport(products)
  end
end

而使用C++的话,表现形式则会是私有继承:

cpp
class ReportService: private ProductFetcher, private ReportGenerator {
  ...
}

有一位C++的高手朋友,把这种做法称之为"小类大对象",这里面的小类就是一个一个的模块,而最终的大对象是最终组合出来的类生成的对象。

关于面向对象,有一点还没有提及,就是面向对象面向的是"对象",不是类。许多开发者习惯把对象理解成类的附属品,但在Alan Kay的理解中,对象本身就是一个独立的个体。因此,有些程序设计语言可以直接支持在对象上进行操作。

还是前面的例子,想给报表服务增加一个接口,对产品信息做一下处理。用Ruby写出来会是这样:

ruby
module ProductEnhancer
  def enhance
    # 处理一下产品信息
  end
end

service = ReportService.new
# 增加了 ProductEnhancer
service.extend(ProductEnhancer)

# 可以调用 enhance 方法
service.enhance

这样的处理只会影响这里的一个对象,而同样是这个ReportService的其他实例,则完全不受影响。这样做的好处是,不必写那么多类,而是根据需要在程序运行时组合出不同的对象。

在这里,相信又一次意识到了学习多种程序设计语言的重要性。Java只有类这种组织方式,所以,许多有差异的概念只能用类这一个概念表示出来,思维就会受到限制,而不同的语言则提供了不同的表现形式,让概念更加清晰。

核心要点

  • 继承分为两种:实现继承(站在子类视角,白盒复用)和接口继承(站在父类视角)
  • 实现继承不是好的代码复用方式,应该用组合替代
  • 组合优于继承:能用组合就用组合——组合是黑盒复用,继承是白盒复用
  • 继承的三大问题:脆弱基类、父类变更影响所有子类、M×N 类爆炸
  • Mixin(Ruby)和 Trait(Scala):多重代码复用的替代方案,不占继承位
  • 面向组合编程:类由多个小模块组合而成,每个小模块对应一个关注点
  • 继承是M×N问题,组合是M+N问题——组合在复杂度上更优

4.5 面向对象之多态

章节概述

多态(Polymorphism)是面向对象编程中最强大的特性,也是区分"基于对象编程"与"面向对象编程"的分水岭。多态的定义是:同一接口,不同实现。它是依赖倒置原则(DIP)的基础,使高层模块不依赖底层具体实现。接口多态(面向接口编程)优于继承多态(基于继承体系的多态)。策略模式、工厂模式等经典设计模式都是多态驱动的实际应用。Linux 的 VFS(虚拟文件系统)展示了如何在 C 语言中利用函数指针实现多态——这再次证明了多态不依赖于特定语言特性。

多态:面向对象的分水岭

前面两节,讲了面向对象的两个特点:封装和继承,但真正让面向对象华丽蜕变的是它的第三个特点:多态。

有一次,在一个C++的开发团队里做了一个小调查。问题很简单:你用过virtual吗?下面坐着几十个C++开发者,只有寥寥数人举起了手。

在C++里,virtual表示这个函数是在父类中声明的,然后在子类中改写(Override)过。这不就是多态吗?这个调查说明了一件事,许多开发者虽然在用支持面向对象的程序设计语言,但根本没有用过多态。

只使用封装和继承的编程方式,称之为基于对象(Object Based)编程,而只有把多态加进来,才能称之为面向对象(Object Oriented)编程。换言之,多态是一个分水岭,将基于对象与面向对象区分开来,可以说,没写过多态的代码,就是没写过面向对象的代码。

对于面向对象而言,多态至关重要,正是因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。软件设计是一门关注长期变化的学问,只有当开始理解了多态,才真正踏入应对长期变化的大门。

多态的定义与价值

多态(Polymorphism),顾名思义,一个接口,多种形态。同样是一个绘图(draw)的方法,如果以正方形调用,则绘制出一个正方形;如果以圆形调用,则画出的是圆形:

java
interface Shape {
  // 绘图接口
  void draw();
}

class Square implements Shape {
  void draw() {
    // 画一个正方形
  }
}

class Circle implements Shape {
  void draw() {
    // 画一个圆形
  }
}

上一节说过,继承有两种,实现继承和接口继承。其中,实现继承尽可能用组合的方式替代继承。而接口继承,主要是给多态用的。

这里面面的重点在于,这个继承体系的使用者,主要考虑的是父类,而非子类。就像下面这段代码里,不必考虑具体的形状是什么,只要调用它的绘图方法即可:

java
Shape shape = new Square();
shape.draw();

这种做法的好处就在于,一旦有了新的变化,例如,需要将正方形替换成圆形,除了变量初始化,其他的代码并不需要修改。

那么,问题来了。既然多态这么好,为什么许多开发者不能在自己的代码中很好地运用多态呢?因为多态需要构建出一个抽象。

构建抽象,需要找出不同事物的共同点,而这是最有挑战的部分。而遮住开发者们双眼的,往往就是他们眼里的不同之处。在他们眼中,鸡就是鸡,鸭就是鸭。

寻找共同点这件事,地基还是在分离关注点上。只有能看出来,鸡和鸭都有羽毛,都养在家里,才有机会识别出一个叫做"家禽"的概念。此处,又一次强调了分离关注点的重要性。

构建出来的抽象会以接口的方式体现出来,强调一点,这里的接口不一定是一个语法,而是一个类型的约束。因此,在这个关于多态的讨论中,接口、抽象类、父类等几个概念都是等价的,为了叙述方便,这里统一采用接口的说法。

接口多态 vs 继承多态

在构建抽象上,接口扮演着重要的角色。

首先,接口将变的部分和不变的部分隔离开来。不变的部分就是接口的约定,而变的部分就是子类各自的实现。

在软件开发中,对系统影响最大的就是变化。有时候需求一来,代码就要跟着改,一个可能的原因就是各种代码混在了一起。例如,一个通信协议的调整需要改业务逻辑,这明显就是不合理的。对开发者来说,识别出变与不变,是一种很重要的能力。

其次,接口是一个边界。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。

许多开发者在接口中添加方法显得很随意,因为在他们心目中,并不存在实现者和使用者之间的角色差异。这也就造成了边界意识的欠缺,没有一个清晰的边界,其结果就是模块定义的随意,彼此之间互相影响也就在所难免。

接口多态指的是通过显式定义的接口(interface)来实现的多态,客户端代码依赖的是接口类型,而非具体实现类。继承多态则是指通过继承体系的虚函数/覆写机制实现的多态,客户端代码依赖的是父类类型。

接口多态优于继承多态,原因在于:

  • 接口更加纯粹,只声明行为契约,不含任何实现
  • 一个类可以实现多个接口,但只能继承一个父类(在单继承语言中)
  • 接口天然满足迪米特法则——使用者只知道接口的方法签名
  • 接口变更的成本更低——新增方法不会影响已有实现(除非是default method)
图表渲染中…

图注:多态让客户端代码只依赖接口——新增实现时客户端代码不需要修改,这就是OCP的落地。

至此,已经对多态和接口有了一个基本的认识。就能很好地理解一个编程原则了:面向接口编程。面向接口编程的价值就根植于多态,也正是因为有了多态,一些设计原则,例如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。

多态驱动的实际应用

多态在实际项目中有着广泛的应用,许多经典的设计模式都是建立在多态基础之上的:

策略模式(Strategy Pattern):定义一系列算法,将每个算法封装起来,并使它们可以相互替换。策略模式让算法的变化独立于使用它的客户端。例如,不同的排序算法(快速排序、归并排序、堆排序)可以通过统一的排序接口进行替换。

工厂模式(Factory Pattern):定义一个创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。例如,根据配置创建不同类型的数据库连接器。

模板方法模式(Template Method Pattern):定义一个操作中的算法骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

这些模式的共同点是:它们都依赖于多态来将变化的实现与不变的框架隔离开来。

VFS 案例:C 语言中的多态

还记得在编程范式那一讲留下的问题吗?面向对象编程,会限制使用函数指针,它是对程序控制权的间接转移施加了约束。理解这一点,就要理解多态是怎么实现的。

讲多范式编程时,举了Linux文件系统的例子,它是用C实现了面向对象编程,而它的做法就是用了函数指针。再来回顾一下:

c
struct file_operations {
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  int (*open) (struct inode *, struct file *);
  int (*flush) (struct file *, fl_owner_t id);
  int (*release) (struct inode *, struct file *);
  ...
};

假设写一个HelloFS,那可以这样给它赋值:

c
const struct file_operations hellofs_file_operations = {
    .read = hellofs_read,
    .write = hellofs_write,
};

只要给这个结构体赋上不同的值,就可以实现不同的文件系统。但是,这种做法有一个非常不安全的地方。既然是一个结构体的字段,那就有可能改写了它,像下面这样:

c
void silly_operation(struct file_operations* operations) {
  operations.read = sillyfs_read;
}

如此一来,本来应该在hellofs_read运行的代码,就跑到了sillyfs_read里,程序很容易就崩溃了。对于C这种非常灵活的语言来说,根本禁止不了这种操作,只能靠人为的规定和代码检查。

到了面向对象程序设计语言这里,这种做法由一种编程结构变成了一种语法。给函数指针赋值的操作下沉到了运行时去实现。如果了解运行时的实现,它就是一个查表的过程:

一个类在编译时,会给其中的函数在虚拟函数表中找到一个位置,把函数指针地址写进去,不同的子类对应不同的虚拟表。当用接口去调用对应的函数时,实际上完成的就是在对应的虚拟函数表的一个偏移,不管现在面对的是哪个子类,都可以找到相应的实现函数。

还记得在开头提的那个问题吗?问C++开发者是否用过virtual。在C++这种比较注重运行时消耗的语言中,只有virtual的函数会出现在虚拟函数表里,而普通函数就是直接的函数调用,以此减少消耗。对于Java开发者而言,可以通过给无需改写的方法添加final帮助运行时做优化。

当多态成了一种语法,函数指针的使用就得到了限制,犯错误的几率就大大降低了,程序行为的可预期性就大大提高了。

没有继承的多态

回到Alan Kay关于面向对象的思考中,他考虑过封装,考虑过多态。至于继承,却不是一个必然的选项。只要能够遵循相同的接口,就可以表现出来多态,因此,多态并不一定要依赖于继承。

例如,在动态语言中,有一个常见的说法,叫Duck Typing,就是说,如果走起来像鸭子,叫起来像鸭子,那它就是鸭子。两个类可以不在同一个继承体系之下,但是,只要有同样的方法接口,就是一种多态。

像下面这段代码,Duck和FakeDuck并不在一棵继承树上,但make_quack调用的时候,它们俩都可以传进去:

ruby
class Duck
  def quack
    # 鸭子叫
  end
end

class FakeDuck
  def quack
    # 模拟鸭子叫
  end
end

def make_quack(quackable)
  quackable.quack
end

make_quack(Duck.new)
make_quack(FakeDuck.new)

许多软件都有插件能力,而插件结构本身就是一种多态的表现。例如,著名的开源图形处理软件GIMP,它自身是用C开发的,为它编写插件就需要按照它规定的结构去编写代码:

c
struct GimpPlugInInfo
{
  /* GIMP 应用初始启动时调用 */
  GimpInitProc  init_proc;

  /* GIMP 应用退出时调用 */
  GimpQuitProc  quit_proc;

  /* GIMP 查询插件能力时调用 */
  GimpQueryProc query_proc;

  /* 插件安装之后,开始运行时调用*/
  GimpRunProc   run_proc;
};

所需做的就是按照这个结构声明出PLUG_IN_INFO,这是隐藏的名字,将插件的能力注册给GIMP这个应用:

c
GimpPlugInInfo PLUG_IN_INFO = {
  init,
  quit,
  query,
  run
};

这里用到的是C语言,一种连面向对象都不支持的语言,但它依然能够很好地表现出多态。

现在应该理解了,多态依赖于继承,这只是某些程序设计语言自身的特点。也看出来了,在面向对象本身的体系之中,封装和多态才是重中之重,而继承则处于一个很尴尬的位置。

核心要点

  • 多态是基于对象和面向对象的分水岭——没写过多态,就是没写过面向对象的代码
  • 多态的定义:同一接口,不同实现
  • 多态是依赖倒置原则(DIP)的基础——使高层模块不依赖底层具体实现
  • 接口多态优于继承多态:接口更纯粹、更灵活、变更成本更低
  • 接口将变的部分和不变的部分隔离开来,是一个边界
  • 面向接口编程是很多设计原则和设计模式的基础
  • 多态不一定要依赖于继承实现(如Duck Typing、VFS函数指针、插件机制)
  • 在面向对象中,封装和多态是重中之重,继承处于较尴尬的位置

4.6 函数式编程概述

章节概述

函数式编程(Functional Programming)是一种以函数为核心元素的编程范式。其核心特征包括:函数是一等公民(First-class Function)——函数可以像值一样被创建、存储、传递和返回;引用透明性(Referential Transparency)——相同的输入永远产生相同的输出,无副作用;声明式编程(Declarative Programming)——描述"做什么"而非"怎么做"。函数式编程与面向对象编程并非对立关系,而是互补关系:可以用面向对象搭建系统结构,用函数式编程设计函数接口。

函数式编程的引入

前面几节,讲了结构化编程和面向对象编程,对于大多数开发者来说,这些内容还是比较熟悉的。接下来,要讨论的函数式编程,对一些人来说就要陌生一些。

Java和C++已经引入了Lambda,目的就是为了支持函数式编程。因为,函数式编程里有许多优秀的元素,例如,组合式编程、不变性等等,都是值得在日常设计中借鉴的。即使使用的是面向对象编程语言,也可以将这些函数式编程的做法运用到日常工作中,这已经成为大势所趋。

但是,许多人学习函数式编程,刚刚知道了概念,就碰上了函数式编程的起源,遇到许多数学概念,然后,就放弃了。为什么学习函数式编程这么困难呢?主要是因为它有一些不同的思维逻辑,同时人们也缺少一个更好的入门方式。

因此,在这一节中,站在一个更实用的角度,做一个函数式编程的入门。等有了基础之后,后面两节,再来讨论函数式编程中优秀的设计理念。

函数是一等公民

函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)。一等公民是什么意思呢?

  • 它可以按需创建;
  • 它可以存储在数据结构中;
  • 它可以当作实参传给另一个函数;
  • 它可以当作另一个函数的返回值。

对象,是面向对象程序设计语言的一等公民,它就满足所有上面的这些条件。在函数式编程语言里,函数就是一等公民。函数式编程语言有很多,经典的有LISP、Haskell、Scheme等,后来也出现了一批与新平台结合紧密的函数式编程语言,例如:Clojure、F#、Scala等。

许多语言虽然不把自己归入函数式编程语言,但它们也提供了函数式编程的支持,例如支持了Lambda的,这类语言像Ruby、JavaScript等。

如果语言没有这种一等公民的函数支持,完全可以用某种方式模拟出来。在前面的例子里,就用对象模拟出了一个函数,也就是Predicate。在旧版本的C++中,也可以用functor(函数对象)当作一等公民的函数。在这两个例子中,既然函数是用对象模拟出来的,自然就符合一等公民的定义,可以方便将其传来传去。

图表渲染中…

图注:即使语言没有一等公民函数,也可以用接口+匿名类模拟;Lambda让表达更简洁。

Lambda 与高阶函数

Lambda 来源于 Alonzo Church 发明的 Lambda 演算(λ-calculus),可以简单地理解为匿名函数——没有名称的函数表达式。Lambda 使得在需要一个函数的地方可以直接内联定义,而不必单独声明一个具名函数。

高阶函数(Higher-order Function) 是指满足以下至少一个条件的函数:

  • 接收一个或多个函数作为参数;
  • 返回一个函数作为结果。

常见的高阶函数包括 map、filter、reduce 等:

  • map:将一个函数应用到集合的每一个元素上,返回新的集合。例如 list.map(x => x * 2) 将列表中每个元素乘以2。
  • filter:根据谓词函数过滤集合中的元素,保留使谓词返回 true 的元素。例如 list.filter(x => x > 2) 保留大于2的元素。
  • reduce:将集合中的元素按照指定的二元运算归约为单个值。例如 list.reduce((a, b) => a + b) 将所有元素求和。

从一个实用的例子出发。假设有一组学生,如果需要按照姓名找出其中一个:

java
Student findByName(final String name) {
  for (Student student : students) {
    if (name.equals(student.getName())) {
        return student;
    }
  }
  return null;
}

这时候,新需求来了,准备按照学号来找人,代码也许就会这么写:

java
Student findBySno(final long sno) {
  for (Student student : students) {
    if (sno == student.getSno()) {
        return student;
    }
  }
  return null;
}

看完这两段代码,发现问题了吗?除了查询的条件不一样,剩下的结构几乎一模一样,这就是一种重复。那么,要怎么消除这个重复呢?可以引入查询条件这个概念:

java
interface Predicate<T> {
  boolean test(T t);
}

有了查询条件,可以改造一下查询方法,把条件作为参数传进去:

java
Student find(final Predicate<Student> predicate) {
  for (Student student : students) {
    if (predicate.test(student)) {
        return student;
    }
  }
  return null;
}

于是,按名字查找就会变成下面这个样子:

java
Student findByName(final String name) {
  return find(new Predicate<Student>() {
    @Override
    public boolean test(final Student student) {
      return name.equals(student.getName());
    }
  });
}

这样是很好,但会发现,每次有一个新的查询,就要做一层这个封装。为了省去这层封装,可以把查询条件做成一个方法:

java
static Predicate<Student> byName(final String name) {
  return new Predicate<Student>() {
    @Override
    public boolean test(final Student student) {
      return name.equals(student.getName());
    }
  };
}

其他几个字段也可以做类似的封装,这样一来,要查询什么就由使用方自己决定了:

java
find(byName(name));
find(bySno(sno));
find(byId(id));

现在想用名字和学号同时查询,完全可以已有的两个方法组合出一个新查询来:

java
find(and(byName(name), bySno(sno)));

这里面多出一个and方法,它要怎么实现呢?事实上也不难,按照正常的and逻辑写一个就好。类似地,还可以写出or和not的逻辑,这样,使用方能够使用的查询条件一下子就多了起来,完全可以按照自己的需要任意组合。

直到现在,所用的代码都是常规的Java代码,却产生了神奇的效应。这段代码的作者只提供了各种基本元素(动作和条件),而这段代码的用户通过组合这些基本的元素完成真正的需求。这种做法完全不同于常规的面向对象的做法,其背后的思想就源自函数式编程。在上面这个例子里,让代码产生质变的地方就在于Predicate的引入,而它实际上就是一个函数。

引用透明性

函数式编程中的函数源自数学中的函数,可以回想一下高中数学学到的那個 f(x)。同习惯的函数相比,它要规避状态和副作用。换言之,同样的输入一定会给出相同的输出。这就是引用透明性(Referential Transparency)

一个表达式被称为引用透明的,如果它可以在不改变程序行为的前提下被其求值结果替换。换句话说,对于相同的输入,函数总是返回相同的输出,并且没有任何可观察的副作用。

引用透明性的好处:

  1. 推理更容易:由于相同输入必定产生相同输出,开发者可以在脑中"代入求值",而不必追踪复杂的执行状态。
  2. 缓存成为可能:纯函数的结果可以被安全地缓存(Memoization),避免重复计算。
  3. 并行化变得自然:纯函数之间没有共享状态,可以安全地在多个线程/节点上并行执行。
  4. 测试变得简单:纯函数不需要 mock 任何外部依赖,给定输入断言输出即可。

声明式 vs 命令式

**命令式编程(Imperative Programming)**描述的是"怎么做"(How):一步一步地指示计算机执行具体的操作——先取这个值,再判断条件,然后循环,接着修改状态……

**声明式编程(Declarative Programming)**描述的是"做什么"(What):表达想要达到的目标,而将具体的实现细节交给底层的抽象层来处理。

对比一下两种风格:

java
// 命令式:描述"怎么做"
long countMale = 0;
for (Student student : students) {
  if (student.getGender() == Gender.MALE) {
    countMale++;
  }
}

// 声明式:描述"做什么"
long countMale = students.stream()
    .filter(student -> student.getGender() == Gender.MALE)
    .count();

声明式代码的优势在于:

  • 更简洁:减少了样板代码(boilerplate code)
  • 更具表达性:代码本身就说明了意图
  • 更易于优化:底层实现可以在不影响上层代码的前提下进行优化(如并行执行)

函数式与面向对象的互补关系

至此,已经学习了函数式编程的基本概念。可能会有一个疑问,之前在讲面向对象的时候,也谈到了组合,这里讲函数式编程,又谈到了组合。这两种组合之间是什么关系呢?

事实上,对比一下代码,就不难发现了,面向对象组合的元素是类和对象,而函数式编程组合的是函数。

这也就牵扯到在实际工作中,如何将面向对象和函数式编程两种不同的编程范式组合运用的问题。可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计。可以把它理解成盖楼,用面向对象编程搭建大楼的骨架,用函数式编程设计门窗。

图表渲染中…

图注:两种组合方式——对象组合侧重行为聚合,函数组合侧重处理链式变换。实际项目中二者常结合使用。

核心要点

  • 函数式编程的编程元素是函数——来源于数学的函数,同样的输入必定给出相同的输出
  • 函数是一等公民:可创建、可存储、可传参、可返回
  • Lambda = 匿名函数,简化函数的创建和使用
  • 高阶函数:接收函数或返回函数,用于行为的组合(map/filter/reduce)
  • 引用透明性:相同输入=相同输出,无副作用——推理、缓存、并行、测试都变得更容易
  • 声明式 vs 命令式:描述"做什么"而非"怎么做"
  • 即使语言不支持,也可以用对象模拟函数(如Java的Predicate)
  • 实践建议:用OO搭建系统结构,用FP设计函数接口

4.7 函数式编程之组合性

章节概述

函数式编程的组合性是其最迷人的特性之一。小函数可以像 Unix 管道一样组合成复杂函数,每个函数只做一件事,通过组合构建出强大的处理能力。Monad 是函数式编程中处理上下文(如可能失败、异步、状态)组合的核心抽象。Point-free 风格强调数据流动而非中间变量,使代码更加简洁。Currying(柯里化) 将多元函数转换为一元函数链,便于部分应用和组合。Optional/Maybe 处理空值、Either 处理错误——这些实用模式已经在 Java、Scala 等语言中得到广泛应用。

函数的组合

在函数式编程中,有一类比较特殊的函数,它们可以接收函数作为输入,或者返回一个函数作为输出。这种函数叫做高阶函数(High-order function)

听上去稍微有点复杂,如果回想一下高中数学里有一个复合函数的概念,也就是f(g(x)),把一个函数和另一个函数组合起来,这么一类比,是不是就好接受一点了。

那么,高阶函数有什么用呢?它的一个重要作用在于,可以用它去做行为的组合。再来回顾一下上一节写过的一段代码:

java
find(byName(name).and(bySno(sno)));

在这里面,find的方法就扮演了一个高阶函数的角色。它接收了一个函数作为参数,由此,一些处理逻辑就可以外置出去。这段代码的使用者,就可以按照自己的需要任意组合。

可能注意到了,这里的find方法只是一个普通的Java函数。是这样的,如果不需要把这个函数传来传去,普通的Java函数也可以扮演高阶函数的角色。

可以这么说,高阶函数的出现,让程序的编写方式出现了质变。按照传统的方式,程序库的提供者要提供一个又一个的完整功能,就像findByNameAndBySno这样,但按照函数式编程的理念,提供者提供的就变成了一个又一个的构造块,像find、byName、bySno这样。然后,使用者可以根据自己的需要进行组合,非常灵活,甚至可以创造出未曾想过的组合方式。

这就是典型的函数式编程风格。模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用。就这样,模型之间一层又一层地逐步叠加,最终构建起整个应用。

前面讲过,一个好模型的设计就是逐层叠加。函数式编程的组合性,就是一种好的设计方式

但是,能把模型拆解成多个可以组合的构造块,这个过程非常考验人的洞察力,也是"分离关注点"的能力,但是这个过程可以让人得到一种智力上的愉悦。为什么函数式编程一直处于整个IT行业的角落里,还能吸引一大批优秀的开发者前赴后继地投入其中呢?这种智力上的愉悦就是一个重要的原因。

列表转换思维

说过,早期的函数式编程探索是从LISP语言开始的。LISP这个名字源自"List Processing",这个名字指明了这个语言中的一个核心概念:List,也就是列表。开发者对List并不陌生,这是一种最为常用的数据结构,现在的程序语言几乎都提供了各自List的实现。

LISP的一个洞见就是,大部分操作最后都可以归结成列表转换,换言之,数据经过一系列的列表转换会得到一个结果。

想要理解这一系列的转换,就要先对每个基础的转换有所了解。最基础的列表转换有三种典型模式,分别是map、filter和reduce。如果能够正确理解它们,基本上就可以把for循环抛之脑后了。做过大数据相关工作的同学一定听说过一个概念:MapReduce,这是最早的一个大数据处理框架,这里的map和reduce就是源自函数式编程里列表转换的模式。

接下来,就来一个一个地看看它们分别是什么。

map:把一组数据通过一个函数映射为另一组数据。例如,有一组数[1、2、3、4],然后做了一个map操作,这里用作映射的函数是乘以2,即这组数里面的每个元素都乘以2,这样就得到了一组新的数[2、4、6、8]。

filter:把一组数据按照某个条件进行过滤,只有满足条件的数据才会留下。同样[1、2、3、4]为例,做一个filter操作,过滤的函数是大于2,即只有大于2的数才会留下,得到的结果就是[3、4]。

reduce:把一组数据按照某个规则,归约为一个数据。还是[1、2、3、4],如果做一个reduce操作,其归约函数是一个加法操作,即这组数里面的每个元素相加,最终会得到一个结果,也就是1+2+3+4=10。

图表渲染中…

图注:三种基础列表转换操作——map映射、filter过滤、reduce归约。

有了基础之后,就可以利用这些最基础的转换模式去尝试解决问题了。例如,上一节讲了一个学生的例子,现在,想知道这些学生里男生的总数。可以给Student类增加一个性别的字段:

java
class Student {
  ...
  // 性别
  private Gender gender;
}

要想知道男生的总数,传统做法应该是这么做:

java
long countMale() {
  long count = 0;
  for (Student student : students) {
    if (Gender.MALE == student.getGender()) {
        count++;
    }
  }
  return count;
}

按照列表转换的思维来做的话,该怎么做呢?首先,要把这个过程做一个分解:

  • 取出性别字段;
  • 判别性别是否为男性;
  • 计数加1。

这三步刚好对应着map、filter和reduce:

  • 取出性别字段,对应着map,其映射函数是取出学生的性别字段;
  • 判别性别是否为男性,对应filter,其过滤函数是,性别为男性;
  • 计数加1,对应着reduce,其归约函数是,加1。

有了这个分解的结果,再把它映射到代码上。Java 8对于函数式编程的支持,除了Lambda之外,它也增加了对列表转换的支持。为了兼容原有的API,它提供了一个新的接口:Stream,可以把它理解成List的另一种表现形式。如果把上面的步骤用Java 8的Stream方式写出来,代码应该是这样的:

java
long countMale() {
    return students.stream()
            .map(student -> student.getGender())
            .filter(gender -> gender == Gender.MALE)
            .map(gender -> 1L)
            .reduce(0L, (sum, element) -> sum + element);
}

这基本和上面操作步骤是一一对应的,只是多了一步将性别转换成1,便于后面的计算。

map、filter和reduce只是最基础的三个操作,列表转换可以提供的操作远远比这个要多。不过,可以这么理解,大多数都是在这三个基础上进行了封装,提供一种快捷方式。例如,上面代码的最后两步map和reduce,在Java 8的Stream接口提供了一个count方式:

java
long countMale() {
    return students.stream()
            .map(Student::getGender)
            .filter(byGender(Gender.MALE))
            .count();

static Predicate<Gender> byGender(final Gender target) {
    return gender -> gender == target;
}
}

一方面,用了方法引用(Student::getGender),这是Java提供的简化代码编写的一种方式。另一方面,还把按照性别比较提取了出来,如此一来,代码的可读性就提升了,基本上可以把它同前面写的操作步骤完全对应起来了。

同样是一组数据的处理,更鼓励使用函数式的列表转换,而不是传统的for循环。一方面因为它是一种更有表达性的写法,从前面的代码就可以看到,它几乎和想做的事是一一对应的。另一方面,这里面提取出来比较性别的方法,它就是一个可以用作组合的基础接口,可以在多种场合复用。

Monad:处理上下文组合的核心抽象

在函数式编程中,Monad 是一个非常重要但也常常令人困惑的概念。通俗地说,Monad 是一种设计模式,用于处理带有上下文(context)的值的组合。

什么是"带有上下文的值"?举例说明:

  • Maybe/Optional:值可能存在,也可能不存在(空值上下文)
  • Either:计算可能成功(Right),也可能失败携带错误信息(Left)
  • Future/Promise:值可能在当前不可用,需要异步获取(异步上下文)
  • List:值可能有零个、一个或多个(多值上下文)
  • State:计算依赖于某个可变状态(状态上下文)

这些上下文都有一个共同的特点:如果我们直接对上下文中的值进行普通的函数组合,就需要手动处理上下文的展开和包装。Monad 提供了一套统一的抽象,使得我们可以用一致的方式来组合这些"带上下文的值"。

以 Optional 为例,展示 Monad 如何简化代码:

java
// 没有 Monad 思维:层层嵌套的空值检查
public String getCountry(User user) {
    if (user != null) {
        Address address = user.getAddress();
        if (address != null) {
            City city = address.getCity();
            if (city != null) {
                Country country = city.getCountry();
                if (country != null) {
                    return country.getName();
                }
            }
        }
    }
    return "Unknown";
}

// 使用 Optional(Monad)的思维:扁平化的链式调用
public String getCountry(User user) {
    return Optional.ofNullable(user)
            .map(User::getAddress)
            .map(Address::getCity)
            .map(City::getCountry)
            .map(Country::getName)
            .orElse("Unknown");
}

Monad 的核心操作有两个:

  1. unit(也称为 return/pure/of):将普通值包装进上下文中(如 Optional.of(value)
  2. bind(也称为 flatMap/flatMap):将一个返回上下文的函数应用到上下文中的值上,自动处理上下文的嵌套

Point-free 风格

Point-free 风格(也叫 Tacit programming)是一种编程风格,强调函数的组合而不显式提及函数的参数。在这种风格下,代码更像是在描述数据的流动过程,而非操作的步骤。

对比一下:

java
// Pointful 风格:显式提及参数
public static Predicate<Student> isAdultMale(String name) {
    return student ->
        student.getName().equals(name) &&
        student.getAge() >= 18 &&
        student.getGender() == Gender.MALE;
}

// Point-free 风格:强调组合,不提中间参数
public static Predicate<Student> isAdultMale(String name) {
    return byName(name).and(isAdult()).and(isMale());
}

Point-free 风格的优势:

  • 代码更简洁,减少了中间变量的噪音
  • 更容易看出数据处理的流水线结构
  • 每个函数都是可独立测试和复用的构造块

Currying(柯里化)

柯里化(Currying) 是将一个接收多个参数的函数转换为一系列接收单个参数的函数的过程。这个名字来源于逻辑学家 Haskell Curry。

java
// 普通:两元函数
public int add(int a, int b) {
    return a + b;
}

// 柯里化后:一元函数链
public Function<Integer, Function<Integer, Integer>> add() {
    return a -> b -> a + b;
}

// 使用
Function<Integer, Integer> add5 = add().apply(5);  // 固定第一个参数
int result = add5.apply(3);  // 结果: 8

柯里化的好处:

  • 部分应用(Partial Application):固定部分参数,生成一个新的专用函数
  • 便于组合:一元函数更容易与其他函数组合
  • 提高复用性:通用的多元函数可以被派生出多个专用的一元函数

Practical 示例:Optional 与 Either

Optional/Maybe 用于优雅地处理可能缺失的值:

java
// Java 8 Optional 的实际用法
Optional<User> findUser(Long id) {
    return userRepository.findById(id);
}

// 安全地链式操作
String userName = findUser(userId)
    .map(User::getProfile)
    .map(Profile::getDisplayName)
    .orElse("Guest");

Either 用于处理可能失败的操作,同时携带成功结果或错误信息:

scala
// Scala Either 的示例
def divide(a: Int, b: Int): Either[String, Int] = {
  if (b == 0) Left("Division by zero")
  else Right(a / b)
}

// 使用
divide(10, 2) match {
  case Right(result) => println(s"Result: $result")
  case Left(error)   => println(s"Error: $error")
}

这些模式已经在工业界得到了广泛应用。Java 8 引入的 Optional 就是 Maybe Monad 的实现;Vavr(原名 Javaslang)库为 Java 提供了 Either、Try、Future 等更多函数式数据类型。

核心要点

  • 函数组合:小函数组合成复杂函数,类似 Unix 管道——每个函数只做一件事
  • 高阶函数:接收函数或返回函数,是函数组合的技术基础
  • 列表转换思维:map(映射)、filter(过滤)、reduce(归约)是三种基础操作
  • Monad:处理带上下文的值的组合的核心抽象(Optional/Either/Future/List 都是 Monad)
  • Point-free 风格:强调数据流动而非中间变量,代码更简洁
  • Currying(柯里化):多元函数→一元函数链,便于部分应用和组合
  • Optional/Maybe 处理空值、Either 处理错误——已在 Java/Scala 等语言中广泛应用

4.8 函数式编程之不变性

章节概述

可变状态是软件开发中诸多问题的根源:并发竞争、调试困难、推理困难、隐藏耦合。不变性(Immutability) 从根本上解决了这些问题——不可变对象创建后状态不可修改,天然线程安全、天然可预测、天然可测试。持久化数据结构(Persistent Data Structure) 通过共享未变部分高效实现不可变性,避免了深拷贝的性能开销。副作用隔离(如 IO Monad)将不可避免的副作用限制在系统边界。不变性不仅是函数式编程的约束,更是架构设计的重要考量——Event Sourcing、CQRS 等架构模式都深度依赖不变性。

可变状态的代价

经过前几节的介绍,已经认识到了函数式编程的能力,函数以及函数之间的组合很好地体现出了函数式编程的巧妙之处。不过,在讲编程范式时说过,学习编程范式不仅要看它提供了什么,还要看它约束了什么。这一节,就来看看函数式编程对我们施加的约束。

在软件开发中,有一类Bug是很让人头疼的,就是代码怎么看都没问题,可是运行起来就是出问题了。曾经就遇到过这样的麻烦,有一次用C写了一个程序,怎么运行都不对。翻来覆去地看代码,看了很多遍都没发现问题,不得已,只能一步一步跟踪代码。最后,发现代码调用到一个程序库时,出现了与预期不符的结果。

这个程序库是其他人封装的,只是拿过来用。按理说,调用的这个函数逻辑也不是特别复杂,不应该出现什么问题。不过,为了更快定位问题,还是打开了这个程序库的源代码。经过一番挖掘,发现在这个函数底层实现中,出现了一个全局变量。

分析之后,发现正是这个全局变量引起了这场麻烦,因为在代码执行过程中,有别的程序会调用另外的函数,修改这个全局变量的值,最终,导致了程序执行失败。从表面上看,调用的这个函数和另外那个函数八竿子都打不到,但是,它们却通过一个底层的全局变量,产生了相互的影响。

这就是一类非常让人头疼的Bug。有人认为这是全局变量使用不当造成的,在Java设计中,甚至取消了全局变量,但类似的问题并没有因此减少,只是以不同面貌展现出来而已,例如,static变量。

那么造成这类问题的真正原因是什么呢?真正原因就在于变量是可变的

可变对象的具体问题

可能会好奇,难道变量不就应该是变的吗?为了更好地理解这一类问题,来看一段代码:

java
class Sample1 {
  private static final DateFormat format =
      new SimpleDateFormat("yyyy.MM.dd");

  public String getCurrentDateText() {
    return format.format(new Date());
  }
}

如果不熟悉JDK的SimpleDateFormat,可能会觉得这段代码看上去还不错。然而,这段代码在多线程环境下就会出问题。正确的用法应该是这样:

java
public class Sample2 {
  public String getCurrentDateText() {
    DateFormat format = new SimpleDateFormat("yyyy.MM.dd");
    return format.format(new Date());
  }
}

两段代码最大的区别就在于,SimpleDateFormat在哪里构建。一个是被当作了一个字段,另一个则是在函数内部构建出来。这两种不同做法的根本差别就在于,SimpleDateFormat对象是否共享。

为什么这个对象共享会有问题呢?翻看format方法的源码,会发现这样一句:

java
calendar.setTime(date);

这里的calendar是SimpleDateFormat这个类的一个字段,正是因为在format的过程中修改了calendar字段,所以,它才会出问题。

来看看这种问题是怎么出现的:

  • A线程把变量的值修改成自己需要的值;
  • 这时发生线程切换,B线程开始执行,将变量的值修改成它所需要的值;
  • 线程切换回来,A线程继续执行,但此时变量已经不是自己设置的值了,因此,执行会出错。

回到SimpleDateFormat上,问题是一样的,calendar就是那个共享的变量。一个线程刚刚设置的值,可能会被另外一个线程修改掉,因此会造成结果的不正确。而在Sample2的写法中,通过每次创建一个新的SimpleDateFormat对象,将二者之间的共享解开,规避了这个问题。

那如果还是想按照Sample1的写法写,SimpleDateFormat这个库应该怎么改写呢?可能会想,SimpleDateFormat的作者没写好,如果换我写,我就会给它加上一个同步(synchronized)或者加上锁(Lock)。甚至都没有注意,轻易地将多线程的复杂性引入了进来。还记得在分离关注点那节讨论的问题吗,多线程是另外一个关注点,能少用,尽量少用。

一个更好的办法是将calendar变成局部变量,这样一来,不同线程之间共享变量的问题就得到了根本的解决。但是,这类非常头疼的问题在函数式编程中却几乎不存在,这就依赖于函数式编程的不变性。

图表渲染中…

图注:不变性从根本上消除了可变对象带来的四大风险。

不变性与不可变对象

函数式编程的不变性主要体现在值和纯函数上。,可以将它理解为一个初始化之后就不再改变的量,换言之,当使用一个值的时候,值是不会变的。纯函数,是符合下面两点的函数:

  • 对于相同的输入,给出相同的输出;
  • 没有副作用。

把值和纯函数合起来看,值保证不会显式改变一个量而纯函数保证的是,不会隐式改变一个量

说过,函数式编程中的函数源自数学中的函数。在这个语境里,函数就是纯函数,一个函数计算之后是不会产生额外的改变的,而函数中用到的一个一个量就是值,它们是不会随着计算改变的。因此,在函数式编程中,计算天然就是不变的。

正是由于不变性的存在,在前面遇到的那些问题也就不再是问题了。一方面,如果拿到一个量,这次的值是1,下一次它还是1,完全不用担心它会改变。另一方面,调用一个函数,传进去同样的参数,它保证给出同样的结果,行为是完全可以预期的,不会碰触到其他部分。即便是在多线程的情况下,也不必考虑同步的问题,后续一系列的问题也就不存在了。

这与习惯的方式有着非常大的区别,因为传统方式的基础是面向内存单元的,改来改去甚至已经成为了开发者的本能。因此,对counter = counter + 1这种代码习以为常,而初学编程的人总会觉得这在数学上是不成立的。

在之前的讨论中,说过,传统的编程方式占优的地方是执行效率,而现如今,这个优点则越来越不明显,反而是因为到处可变而带来了更多的问题。相较之下,更应该在现在的设计中,考虑借鉴函数式编程的思路,把不变性更多地应用在代码之中。

如何应用不变性

那怎么应用呢?首先是值。可以编写不变类,就是对象一旦构造出来就不能改变,Java开发者最熟悉的不变类应该就是String类,怎样编写不变类呢?

  • 所有的字段只在构造函数中初始化;
  • 所有的方法都是纯函数;
  • 如果需要有改变,返回一个新的对象,而不是修改已有字段。

前面两点可能还好理解,最后一点,可以看一下Java String类的replace方法签名:

java
String replace(char oldChar, char newChar);

在这里,会用一个新的字符(newChar)替换掉这个字符串中原有的字符(oldChar),但并不是直接修改已有的这个字符串,而是创建一个新的字符串对象返回。这样一来,使用原来这个字符串的类并不用担心自己引用的内容会随之变化。

有了这个基础,等后面学习领域驱动设计的时候,就很容易理解值对象(Value Object)是怎么回事了。

再来看纯函数。编写纯函数的重点是,不修改任何字段,也不调用修改字段内容的方法。因为在实际的工作中,使用的大多数都是传统的程序设计语言,而不是严格的函数式编程语言,不是所有用到的量都是值。因此,站在实用性的角度,如果要使用变量,就使用局部变量。

还有一个实用性的编程建议,就是使用语法中不变的修饰符,例如,Java就尽可能多使用final,C/C++就多写const。无论是修饰变量还是方法,它们的主要作用就是让编译器提醒,要多从不变的角度思考问题。

当有了用不变性思考问题的角度,会发现之前的许多编程习惯是极其糟糕的,例如,Java开发者最喜欢写的setter,它就是提供了一个接口,修改一个对象内部的值。

不过,纯粹的函数式编程是很困难的,只能把编程原则设定为尽可能编写不变类和纯函数。但仅仅是这么来看,也会发现,从前写的许多代码,尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。

绝大多数涉及到可变或者副作用的代码,应该都是与外部系统打交道的。能够把大多数代码写成不变的,这已经是一个巨大的进步,也会减少许多后期维护的成本。

而正是不变性的优势,有些新的程序设计语言默认选项不再是变量,而是值。例如,在Rust里,这么声明的是一个值,因为一旦初始化了,将无法修改它:

rust
let result = 1;

而如果想声明一个变量,必须显式地告诉编译器:

rust
let mut result = 1;

Java也在尝试将值类型引入语言,有一个专门的Valhalla项目就是做这个的。不变性,是减少程序问题的一个重要努力方向。

现在回过头来看编程范式那一讲里说的约束:

函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。

理解了不变性,应该知道这句话的含义了,一旦初始化好一个量,就不要随便给它赋值了。

持久化数据结构

提到不变性,一个常见的疑虑是性能:每次修改都创建新对象,会不会带来大量的内存复制开销?持久化数据结构(Persistent Data Structure) 回答了这个问题。

持久化数据结构的核心理念是:修改操作不改变原有数据,而是创建包含修改的新版本,同时与新版本共享未改变的部分。这意味着:

  • 旧版本的数据依然可用(持久化)
  • 不需要完整复制整个数据结构
  • 修改操作的时间复杂度通常接近于可变数据结构

以不可变链表(Immutable Linked List)为例:

scala
// 原始列表: 1 -> 2 -> 3 -> 4
val list1 = List(1, 2, 3, 4)

// 在头部添加 0: 只需创建一个新节点指向 list1
val list2 = 0 :: list1  // 0 -> 1 -> 2 -> 3 -> 4
// list1 仍然完好: 1 -> 2 -> 3 -> 4
// 新旧列表共享 1->2->3->4 这部分

Clojure 的所有默认数据结构都是持久化的,这使得 Clojure 在保持函数式纯粹性的同时,也能获得不错的性能表现。Scala 的 scala.collection.immutable 包也提供了丰富的持久化数据结构。

副作用隔离

在实际的软件系统中,完全消除副作用是不可能的——读取数据库、调用网络API、写入文件、获取当前时间……这些都是副作用。函数式编程的处理策略不是消灭副作用,而是将副作用隔离在系统的边界处

IO Monad 是 Haskell 处理副作用的经典方式。在 Haskell 中,纯函数和 IO 操作的类型系统层面就被区分开了——如果一个函数执行了 IO 操作,它的类型签名中就会显式标注 IO。这使得开发者可以从类型签名一眼看出哪些函数是纯的、哪些有副作用。

在不具备强大类型系统的语言中,副作用隔离的最佳实践包括:

  • 将 IO 操作集中在特定的层(如 Repository 层、Gateway 层)
  • 业务逻辑层保持纯净——只做计算,不做 IO
  • 依赖注入——将副作用操作作为参数传入纯函数
  • 明确的命名约定——如 executesavefetch 等动词暗示副作用

不变性与架构设计

不变性不仅在代码层面有价值,在架构设计层面同样有着深刻的应用:

Event Sourcing(事件溯源):不存储实体的当前状态,而是存储导致状态变化的所有事件序列。当前状态可以通过重放事件序列来重建。事件是不可变的——一旦发生,就不能被修改或删除。这天然符合不变性原则。

CQRS(命令查询职责分离):将系统的读操作和写操作分离为独立的模型。写模型通过事件来记录变更(不变的事件日志),读模型可以根据需要从事件构建最优化的视图。不变性保证了事件日志的可信度和可追溯性。

这两种架构模式都在分布式系统和领域驱动设计(DDD)中得到广泛应用,它们的可靠性基础正是不变性。

核心要点

  • 可变状态是并发问题的根源——不变性从根本上解决了这个问题
  • 不变性体现在(初始化后不变)和纯函数(相同输入=相同输出,无副作用)
  • 不变对象天然可共享、天然线程安全、天然可测试、状态确定
  • 持久化数据结构:修改时共享未变部分,高效实现不可变性
  • 副作用隔离:将 IO 等副作用限制在系统边界(IO Monad、分层架构)
  • setter通常意味着修改——设计中更好的做法是设计不变类
  • 实践建议:尽量使用final/const、使用局部变量、编写不变类和纯函数
  • 不变性与架构设计:Event Sourcing、CQRS 都深度依赖不变性
  • Rust 默认值、Java Valhalla 项目——不变性是行业发展趋势

本篇核心概念关系

图表渲染中…

图注:编程范式的演进路径、面向对象的三大支柱、函数式编程的三要素,以及当代多范式融合的实践方向。


与其他知识点的关联

本篇知识点关联知识点关联说明
关注点分离(功能分解)01-认知篇功能分解是关注点分离在结构化编程中的体现
封装(最小化接口暴露)02-设计分析篇封装质量直接影响模块的内聚性和耦合度分析
封装(基于行为封装)05-设计原则篇(SRP)SRP 要求一个模块对一类行为者负责——封装是 SRP 的基础
Tell, Don't Ask05-设计原则篇(Demeter/ISP)迪米特法则和 ISP 都强调减少对外部内部结构的了解
组合优于继承05-设计原则篇(OCP/LSP)组合更好地支持 OCP(黑盒复用);LSP 规范继承的正确用法
多态 / 面向接口编程05-设计原则篇(OCP/DIP)多态是 OCP 的技术支撑;DIP 要求依赖抽象(接口)
多态06-设计模式篇策略模式、工厂模式、模板方法等多态驱动的模式
函数式组合性03-编程语言篇(DSL)函数组合是实现内部 DSL 的强大工具
不变性09-扩展补充篇(响应式编程)响应式流大量依赖不变性和函数组合
副作用隔离08-设计实战篇分层架构中业务逻辑层与基础设施层的分离
Event Sourcing / CQRS07-领域驱动设计篇DDD 中的聚合根和事件设计深度依赖不变性
多范式融合所有篇章当代软件设计的通用实践——在各篇章中均有体现

与其他知识点的关联


本篇核心启示:学习不同的编程范式,将其中优秀的元素运用在日常工作中,已经由原来的可选项变成了现在的必选项。面向对象用于组织程序、函数式编程用于接口设计、结构化编程用于局部实现——三者不是替代关系,而是互补关系。理解每种范式提供了什么、约束了什么,才能在实际项目中做出明智的选择和有效的融合。