









书接
上回
,前一篇文章讲了 Lisp 中的基本数据结构列表的构造方式(cons pair),以及由此衍生出的关联列表(alist)和属性列表(plist),之后我就跑偏了,谈起了编译器和解释器中的「特例」以及宏,为什么看起来像是函数的形式实际上不是也不能是函数,最后浅谈了函数式编程和 let 的实现方式。
今天的文章继续跑偏,来关注 Common Lisp 的另一部分语言特性,即面向对象编程,也借此来谈谈我对 OOP 多少有些复杂的看法。我将逐个审视 OOP 的三大要素——多态、封装和继承——再来反思面向对象设计模式。
Common Lisp 在这点上与 Clojure 类似,提供了非常相似的 defgeneric(CL)和 defmulti(Clojure),它们都能定义「抽象函数」,也就是不包含具体实现的函数。之后可以使用 defmethod(两门语言相同)定义方法,也就是抽象函数的具体实现。
以下是来自 Common Lisp 文档 的例子:
(defgeneric description (object)
(:documentation "Return a description of an object."))
(defmethod description ((object integer))
(format nil "The integer ~D" object))
(defmethod description ((object float))
(format nil "The float ~3,3f" object))
defgeneric 定义了一个名为 description(描述)的抽象函数,或者用 CL 的术语来说,应该叫作泛函数(generic function)。后面的代码用 defmethod 定义了两个 description 的具体实现(或者说方法),分别对应整数型(integer)和浮点型(float)。这两个 description 方法共享一个符号名,调用时没有任何区别,但根据传入的数据类型不同,被实际调用的方法也不同。
(description 10)
;; => "The integer 10"
(description 3.14)
;; => "The float 3.140"
此时还没有类参与,如你所见,CLOS1 中的方法不属于类。这只是多态(polymorphism)而已,其实 Java 等面向对象编程语言的多态也不需要类参与,只不过 Java 里的方法必须放在类里。
[1]
Common Lisp Object System(Common Lisp 对象系统) ↩︎
public class Descriptor {
public void description(int number) {
System.out.format("The integer %d", number);
}
public void description(float number) {
System.out.format("The float %f", number);
}
}
descriptor.description(10)
// => "The integer 10"
descriptor.description(3.14)
// => "The float 3.14"
当然,上面这段代码是违反开闭原则的,如果要添加新的类型,就需要修改 Descriptor 类。大部分情况下,Java 程序其实长这样:
public interface Descriptor {
void description();
}
public class IntegerDescriptor implements Descriptor {
private int number;
public void description() {
System.out.format("The integer %d", number);
}
}
public class FloatDescriptor implements Descriptor {
private float number;
public void description() {
System.out.format("The float %f", number);
}
}
如此一来,添加新的类型只需要添加新的 Descriptor 实现类,程序对修改关闭,对拓展开放。看起来很美好,不过后果是我们写了三个文件,因为 Java 限制一个公开类只能放在一个文件里。并且,调用 description 方法时还需要先创建对应的 Descriptor 对象,把要描述的数据写入这个对象的成员变量中,然后再调用 description 方法。
而 Common Lisp 这边只需要写 (description data) 就好了,方法定义可以放在一起,也可以不放在一起,还可以在运行时动态地添加新的方法定义。
(defmethod description ((x integer) y)
1)
(description 1 2.0)
;; => 1
(defmethod description ((x integer) (y float))
2)
(description 1 2.0)
;; => 2
上述代码并没有覆盖原先的方法,而是在名为 description 的方法集中添加了新的方法。尽管方法调用的参数完全一致,但在运行时添加了新的方法定义之后,这个方法调用被分发到了新的定义上。听起来很酷,不过看起来也容易出现时序耦合之类的问题,而且也失去了编译时的保护,需要谨慎使用,要保证系统的行为是可预测的。
这是把类和方法分开的好处之一,由于方法不属于类,程序员不总是需要先实例化类再调用方法(除非是静态方法,但很少用),也不需要把方法都放在同一个地方,在编译之后还能动态地添加新的方法。
再者,CLOS 是多分发(multiple dispatch)的系统,一般的 OOP 语言是单分发的。例如在 Java 中,我们只能根据对象的类型这单个“参数”分发方法,floatDescriptor.description() 对应一个方法,integerDescriptor.description() 对应另一个方法;在 CLOS 中,方法不绑定在对象上,只要多个参数中有一个参数的类型不同,都被视作不同的态,比如 (description 1 1) 对应一个方法,(description 1 1.0) 对应另一个方法。
以上大体是 Wikipedia 和一些 Reddit 用户的观点,他们貌似忽略了 Java 也可以在方法的参数层面做到多分发,在一个类中可以定义多个名字一样但形参不同的方法。调用类的方法时,先是通过对象的类型进行单分发,然后再根据形参进行多分发。不过我也在前面提到了,由于 Java 的语言限制,方法必须和类绑定且必须定义在类的内部,依赖多分发,容易违背开闭原则,所以 Java 编程一般使用基于类的分发,忽略了基于函数签名的多分发,况且在静态 OOP 语言里通过在运行时传入类型不同的参数来分发方法,听起来有些大逆不道。
尽管最后得出的结论类似,但我想有必要澄清一下:Java 的确支持多分发,而且有一个专门的设计模式就利用了这个特性,叫作 Double Dispatch (双分发)。
一边在学校被迫学习 Java 设计模式,一边自己摸索 Lisp 编程,我愈发觉得各种面向接口编程和花里胡哨的设计模式,其实都是对 Java 语言限制的妥协。这是一门很经典的 OOP 语言,但它不够灵活,而且啰唆,往往要创建很多类,以提升系统复杂度为代价去满足设计原则,但实际上这些原则可以在其他语言里用更简单的方式被满足。
讲完多态,接下来是 OOP 的第二个要点:封装。
前文提到 Java 的啰唆、复杂是其语言限制导致的,具体来说,这个限制就是严格的封装(encapsulation)以及封闭类(closed class)。请允许我用长篇大论批评 Java 这门语言,然后再来对比 Common Lisp 的做法。
所有 Java 程序员应该都写过在我如今看来依旧非常神经的 getter 和 setter 方法,也就是定义一个类和它的私有成员变量,保证外部类无法直接访问其变量,只能调用它的公开方法设置和获取变量,以此避免破坏类的封装性。所谓的不能破坏封装性,就是防止外部类用意想不到的方式访问其数据,比方说,商品类中包含价格这一变量,价格不能为负数,如果价格变量是公开的,那么外部类有可能把价格修改为负数,但如果不公开这个变量,而是公开 setPrice() 方法,那么就可以在这个方法里加上判断,如果传入的参数小于 0,就报错。
听起来好像很美好,但 Java 里的实体类基本上都长这样:
public class Employee {
private Department department;
private String name;
private String ID;
private Gender gender;
private double salary;
// --- Constructors ---
public Employee(Department department, String name, String ID, Gender gender, double salary) {
this.department = department;
this.name = name;
this.ID = ID;
this.gender = gender;
this.salary = salary;
}
public Employee() {
}
// --- Getters ---
public Department getDepartment() {
return department;
}
public String getName() {
return name;
}
public String getID() {
return ID;
}
public Gender getGender() {
return gender;
}
public double getSalary() {
return salary;
}
// --- Setters ---
public void setDepartment(Department department) {
this.department = department;
}
public void setName(String name) {
this.name = name;
}
public void setID(String ID) {
this.ID = ID;
}
public void setGender(Gender gender) {
this.gender = gender;
}
public void setSalary(double salary) {
this.salary = salary;
}
}
难道 Java 程序员写这么多重复的方法不觉得烦人吗?——当然!所以他们爱用的 IDEA 里面有代码生成器,用来自动补全 getter 和 setter 方法,还可以生成包含指定参数的构造方法。我想他们使用 Alt + Insert + Enter 已经熟练到不觉得这有什么问题了。另外还有名为 Lombok 的库,提供了
@Data
注解自动生成这些东西,你知道这个用来生成模板代码的库还有付费的企业版吗?人们在花钱解决本就不该存在的问题。
你可能会说,不想这么做的话,把实体类的属性设置成 public 就好了,不提供 getter 和 setter 方法不行吗?
不行。这种拥有完整构造函数和 getter/setter 方法,封装良好的 Java 类,有一个专门的名字:Java Bean。主流的框架 Spring 就基于 Bean 构建(例如
BeanFactory
接口),不用 Bean 的话就用不了 Spring 框架,不用 Spring 框架的话…… 天哪,那你为什么还要在 Java 生态里受苦?除非你要开发 Minecraft Mod,那样的话,我表示敬佩。
为什么我讨厌 Java Bean 呢?封装性有什么坏处吗?在回答这个问题之前,需要反思封装是用来干什么的。
封装,简单来说,就是定义一系列数据以及可以作用在数据上的一系列操作。假设我们有一些零散的数据,姓名、年龄、身高、体重等等,把它们组合在一起,再定义改名、增长年龄、长高、变瘦等操作,把数据组合和对数据的操作放在一个叫作「人」的「容器」里,这就叫作封装。假设不把这些数据封装成类和对象,那么在操作数据的时候,就需要暴露很多细节。比方说,「人」的身高增长速度可能是年龄、性别等各种因素的函数,如果不封装的话,这个函数就暴露给程序的其他部分了,而其他部分大概率根本不需要关心这个细节。
封装的另一个意义是限制对数据的直接访问,比方说,如果「身高增长速度」不是函数而是状态,要是这个状态没有被封装隐藏起来,被外部代码修改过后,整个对象的数据完整性(integrity)就可能出错。
都是很合理的考量,但这和前面那几十行但逻辑含量为 0 的恐怖模板代码有什么关系?那些 getter/setter 方法根本没做任何检查,大部分时候都可以直接读写,和没有封装的区别在哪?强迫所有实体类都这样封装,仅仅是为了很小一部分的特例吗?为什么 Java 没有提供更好的封装方式?
我认为这就是设计缺陷,因为 CLOS 也用 getter/setter,写起来没有这么难受。准确来说,CLOS 使用的是 accessor,它既是 getter,也是 setter,并且和槽位(slot)一起定义。这个槽和成员变量类似。
(defclass person ()
((name
:initarg :name
:accessor person-name)
(age
:initform 0
:accessor person-age)))
person 类定义了两个槽,name 和 age,每个槽都有一些选项,比如 :initarg、:initform 和 :accessor。这些选项不必要,对象定义可以写成 (defclass person () ((name) (age)))。
:initform 接收一个形式,求值后作为槽的初始值。:initarg 接收一个关键词,在实例化类的时候作为参数使用,如下:
(defvar p1
(make-instance 'person :name "Tom"))
(person-name p1)
;; => "Tom"
(person-age p1)
;; => 0 (初始值)
如果没有默认初始值,也没有在 make-instance 时传入初始值,那槽的值就是未绑定的(unbound),试图访问就会报错。
你可能发现 :accessor 的作用了,它在没有声明函数或方法的情况下,仅仅通过指定符号名,就创建了 getter 方法。它同时也是 setter 方法,可以搭配 setf 使用。
(person-name p1)
;; => "Tom"
(setf (person-name p1) "Ben")
(person-name p1)
;; => "Ben"
这看起来只是 Java Bean 的简写版,但 CLOS 不强制封装,也就是说,即便没有 :accessor,也可以通过 slot-value 访问对象的槽。
(slot-value p1 'name)
;; => "Tom"
这在 Java 开发里是「破坏封装性」的做法,实际上,有相当多应用在 Java 中的设计模式都围绕「封装性」展开。Java 类的封装是神圣不可侵犯的。
比方说, 备忘录模式 (Memento Pattern)。

这个设计模式把源发器(Originator)在某个时刻的状态保存在单独的备忘录(Memento)里,源发器可以创建备忘录,也可以用备忘录恢复状态。这种设计模式用来实现撤回和版本管理等操作。
之所以需要单独的备忘录类,而且必须让源发器自己创建备忘录,正是因为「不能破坏类的封装性」。源发器的属性是私有的,其他类不能访问,所以需要 createMemento() 方法来创建备忘录,其他类也不能给它的私有变量赋值,所以需要 restore() 方法。
值得注意的是,这里的 state 仅仅是简化描述,实际上源发器可能有许多内部状态,这些内部状态要被保存在备忘录类里的话,备忘录就要有一模一样的成员变量才行。这不仅涉及到重复代码,源发器和备忘录还是紧密耦合的。
所有这些复杂性的引入,仅仅是因为:我们不能破坏类的封装性。
Java 的另一个语言限制是封闭类。「封装性不可侵犯」更多是生态和社区方面的共识,而封闭类则是实打实的语言限制。封闭类的意思是说,类的变量和方法,只能在类当中定义,并且类定义只能写在一个文件里。
我们还在某一个设计原则里见过「封闭」这个词,就是开闭原则(Open-Close Principle)——程序应该对修改封闭,对拓展开放。默认情况下,封闭类对拓展是封闭的,除非利用继承创建新的类;要增加类的行为(比方说添加新的方法),就必须修改类定义。
当然,围绕着这个限制,也有一个可怕的设计模式出现了—— 访问者模式 (Visitor Pattern)。

教我设计模式的老师在讲解这个模式时不断称赞其优雅,可我一直皱眉头。注意看,Visitor 接口定义了两个方法,分别是 visitElementA() 和 visitElementB()。你注意到问题在哪了吗?
在
整洁的架构
中,抽象的不应该依赖具体的,因为抽象的是稳定的,具体的是多变的,抽象类若是依赖具体的类,具体类的修改就会影响抽象类,使得系统中的修改变多,增加维护难度。显然,Visitor 作为抽象的接口,不应该依赖 Element 的具体实现。若是编写代码实现上述 UML 类图,Visitor 接口的定义中必然会包含 ElementA 和 ElementB 的源代码引用。
就算只让 Visitor 依赖同样抽象的 Element 接口,让 Visitor 的实现类去依赖具体的 ElementA 或 ElementB,这两个接口仍然是紧密耦合的,就和备忘录模式一样。就像源发器需要定义 createMemento() 和 restore() 方法,被访问的元素类也需要 accept() 方法,来允许访问者访问其内部数据。如果不这么做,就要破坏封装性。
为什么被访问者自身不能提供访问数据的方法呢?比方说,toString()、toJSON() 和 toTOML() 等等。问题在于,在 Java 里,这违背了开闭原则。如果需求变更,需要添加将数据导出为 YAML 格式的能力,就不得不违背开闭原则,修改类定义,添加 toYAML() 方法。
为了让程序可拓展,尊重开闭原则,访问者模式就诞生了。因为不能在不修改类定义的情况下添加新的方法,所以就只能实现新的类来拓展功能。
如果类不是封闭的呢?如果可以在别的文件、别的模块里给类添加新的方法,那不就没必要创造与实体类紧密耦合的访问者类了吗?实际上 Go 语言就可以做到,详情可以阅读 这篇文章 ,不过 Go 语言因为缺少继承,不算是真正的 OOP 语言。
在 Common Lisp 中,由于方法不属于类,是独立存在的,再加上封装性不是强制的,可以用 slot-value 访问没有 accessor 的槽,所以导出数据的操作就不需要被访问类亲自动手,也不需要与一个访问者类耦合,提供必须的 accept 方法。
CLOS 中方法与类分离、不强制封装的设计,使得模块之间能保持松散的耦合,访问者与被访问之间不需要相互了解,数据完全可以不必得知访问者的存在,访问者无需复杂的操作就能直接获取对象的内部数据。这并不可怕,除非对数据安全有极其严苛的要求,然而这在大部分时候不适用。
封装和封闭不应该是不可变的祖宗之法。
说实话,不少 Java 设计模式自己都讨厌继承这一 OOP 的基本机制,更常用的模式是编写抽象接口,然后再编写接口的实现类。翻看四人帮的 23 个经典设计模式,几乎没有一个模式用到了继承。
因为在那本《设计模式》里,重要的原则之一就是: Composition over inheritance (组合优先于继承)——也就是 CRP(组合复用原则)。
一方面的原因是继承不够灵活,行为和结构是自上而下预先定义好的,所有数据和所有操作都绑定在一起,不利于复用其中的某些部分。比方说,假设有多种类型的账户,这些账户在计算价格时有不同的算法逻辑,比起创建 Account 类和各种类型的子类,更利于复用的模式是将算法和数据分离开来,创建 Strategy 接口和各种实现类,计算价格时由 Account 提供 Strategy 的实例,再调用其中的方法进行计算——这个叫
策略模式
(Strategy Pattern)。
另一方面,也是更重要的方面,是 Java 的另一个语言限制:不支持多继承。从某种程度上讲,策略模式的出现也是因为单继承的限制,组合复用更灵活;另一个原因是旧版本 Java 不支持函数式编程,而现在有了 Lambda 表达式也很少使用,因为 Lambda 不能用类定义。
既然在专门写给面向对象编程的书里,都出于复用性考虑,拒绝使用继承,那继承对 OOP 真的还必要吗?Go 语言支持 OOP,但并不支持继承(也因此有人认为 Go 不是面向对象编程语言),并且 Go 语言创始人 Rob Pike 本人也强调 用更小的接口实现更强的抽象 。
噢等等,Rob Pike 还在这场演讲中提到了 Java,我们来看看他怎么说的:
Now, if you come from Java, where everything is bigger, you think of interfaces typically having lots of methods.
如果你来自 Java(那里什么东西都更大),你印象中的接口通常有很多方法。
谁会不喜欢 Java 笑话呢?
使用继承时若是稍不注意,就容易违背里氏替换原则(LSP),比如经典的圆形/椭圆形问题(也叫正方形/矩形问题),指的是圆形和正方形作为椭圆形和矩形的子类,会违背里氏替换原则。具体的解释可以阅读 这篇文章 。
继承的目的是行为复用(behavior reuse),子类可以复用(或重写,或拓展)父类的行为。在经典的四人帮设计模式中,继承被基于接口的组合复用取代了,他们更倾向于把行为从类中分离出来。在一些动态语言中,类的概念被取消了,取而代之的是原型(prototype),可以现有的对象作为原型,复用其属性和行为,不必先有类才能实例化,这叫做 基于原型的编程 ,可以理解为没有类的面向对象。实际上 JavaScript 就支持原型编程。
看起来,继承难用到不少人都在找新的解决方案以避免和它打交道。
话说回 Common Lisp,这门语言的对象系统实际上支持继承,而且是多继承,子类可以组合复用多个父类的行为,这实际上也和四人帮的「组合优先于(单)继承」不谋而合,只要类足够小且内聚。多继承的语法也简单得要命。
(defclass baby (child person)
())
;; baby 类同时继承了 child 类和 person 类
要实现灵活的组合复用,可以选择多继承,也可以应用接口隔离原则(ISP),创建更小的接口。CLOS 里没有常规的接口,只有在文章一开始提到的 defgeneric 泛函数,再加上另一个语言特性,也能实现灵活的组合复用。这个特性我在
第 82 期周刊
提到过,让我从那篇文章里选个例子。这段代码来自
Artyom Bologov
。
(defmethod (setf url) :around (value (buffer document-buffer))
(call-next-method)
(set-window-title))
这段方法定义实际上覆盖了 (setf url) (但只会分发给以这个类的 url 槽为参数的方法),当 url 这个槽被更新(setf),就调用更新 UI 的函数。这有点像面向切面编程,在 Java 等 OOP 语言里,子类也经常对父类做这种操作。行为复用的目的经常是拓展原有的行为。
如果要在 Java 里实现「更新 URL 后也同时刷新 UI」,由于要避免继承,就只能求助于 装饰器模式 和 观察者模式 了。
简单来说,行为复用不要求继承,更不是只有 Java 的单继承模式才能做到。甚者,经典的面向对象设计模式常常绕过继承,通过接口实现组合复用,而在 CLOS 里,多继承和 :around 等语言特性,使得行为复用变得更简单灵活。
这个观点可能不新了,在 Peter Norvig 的 Design Patterns in Dynamic Languages (动态语言中的设计模式)演讲中,他提到在包括 Common Lisp 在内的多种动态语言里,23 中设计模式中的 16 种要么消失了,要么被简化——我得提醒读者,这些设计模式里还有 解释器模式 ,用于嵌入脚本语言,属于和架构关联较弱的模式,这是剩下的 7 种之一。
这个演讲第一次呈现是 1996 年五月,而刚好三十年过去了,这二十多种面向对象设计模式仍然被无数的 Java(以及其他传统 OOP 语言)开发者奉为圭臬。我想这并不是他们想不到这个观点,而是因为他们没有或者很少接触其他的语言。我也在和人打交道的过程中逐渐意识到,大部分求职者和从业者对编程并无热情,Java 也好,C++ 也好,都是他们求职的工具而已,编程语言对本身而言并不有趣,所以不会去探索更多的语言;或者,另一部分人更关注业务,成为了管理者,与技术保持着微妙的距离。
而在他们眼里,设计模式的确有帮助,甚至是「优雅」的(不得不说,「优雅」是非常主观的评价),因为这的确让编写软件变得方便了。只不过方便的源头,是本身就存在的语言限制,而设计模式仅仅是提供了这些限制的规避方案而已——这些限制在一开始可以不存在。
不过论题可能会被转移到语言的动态与静态上,动态语言的名声在我看来是两极分化的。吹捧者(包括我)认为动态语言限制很少,写起来令人舒适,语法也往往更简洁,需要维护的代码量更少;反对者可能认为动态语言容易出错,不少类型检查都发生在运行时,缺少编译时保护——我对此的反驳是,要保证软件质量和安全性, 写测试就好了 ;况且静态类型不代表类型安全,这是两个概念,同理,动态语言也可以做到类型安全。
话说到最后,究竟是选用灵活但效率和安全性可能更低的动态语言,还是在静态语言系统里用复杂的设计模式规避限制,都是开发者自己的权衡取舍。
Happy Lisping!
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。