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

推荐订阅源

G
Google Developers Blog
阮一峰的网络日志
阮一峰的网络日志
IT之家
IT之家
人人都是产品经理
人人都是产品经理
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 【当耐特】
WordPress大学
WordPress大学
Hugging Face - Blog
Hugging Face - Blog
博客园 - 叶小钗
罗磊的独立博客
宝玉的分享
宝玉的分享
月光博客
月光博客
V
V2EX
博客园 - 司徒正美
Vercel News
Vercel News
量子位
Y
Y Combinator Blog
美团技术团队
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
T
Tailwind CSS Blog
博客园 - Franky
小众软件
小众软件
I
InfoQ
A
About on SuperTechFans

極客死亡計劃

Coffee Break Clojure, Vol.1 Coffee Break Clojure, Vol.0 可怖的沉默 大脑充血 Vol.98 第一个人 Token 应译作「符」 大脑充血 Vol.97 少年承载了太多年长者的恶臭投射 现象学导论 Writing following the F-pattern is a horrible horrible idea Generated work should not be published. 大脑充血 Vol.96 给我发邮件吧,放轻松 计算机网络如何帮我理解「人们难以相互理解」? 大脑充血 Vol.95 骷髅编程 诺兰的《奥德赛》采取了什么样的改编策略? III 诺兰的《奥德赛》采取了什么样的改编策略? II 大脑充血 Vol.94 我的世界一直下雨 诺兰的《奥德赛》采取了什么样的改编策略? 大脑充血 Vol.93 新知识分子的新庸俗 艾尔特拉克在岣琅 大脑充血 Vol.92 川渝人在山东吃到没有辣味的麻辣香锅和红油水饺之后产生的哲学思考 Are We Interfacing Yet? 大脑充血 Vol.91 如何用宝可梦属性玩剪刀石头布? 什么是工程问题?
从 CLOS 审视 Java 面向对象编程
Eltrac · 2026-06-05 · via 極客死亡計劃

书接 上回 ,前一篇文章讲了 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 的做法。

To encapsulate, or to indulge?

所有 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 类定义了两个槽,nameage,每个槽都有一些选项,比如 :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)。

描述备忘录模式的 UML 类图和时序图。类图中有 Caretaker、Originator 和 Memento;Caretaker 访问 Originator,Originator 创建 Memento;Originator 和 Memento 都有 state 属性,Originator 有创建备忘录和通过备忘录恢复状态的方法,而 Memento 只包含相关的 getter/setter 方法。

图源: Wikipedia

这个设计模式把源发器(Originator)在某个时刻的状态保存在单独的备忘录(Memento)里,源发器可以创建备忘录,也可以用备忘录恢复状态。这种设计模式用来实现撤回和版本管理等操作。

之所以需要单独的备忘录类,而且必须让源发器自己创建备忘录,正是因为「不能破坏类的封装性」。源发器的属性是私有的,其他类不能访问,所以需要 createMemento() 方法来创建备忘录,其他类也不能给它的私有变量赋值,所以需要 restore() 方法。

值得注意的是,这里的 state 仅仅是简化描述,实际上源发器可能有许多内部状态,这些内部状态要被保存在备忘录类里的话,备忘录就要有一模一样的成员变量才行。这不仅涉及到重复代码,源发器和备忘录还是紧密耦合的。

所有这些复杂性的引入,仅仅是因为:我们不能破坏类的封装性。

To close, or to CLOS?

Java 的另一个语言限制是封闭类。「封装性不可侵犯」更多是生态和社区方面的共识,而封闭类则是实打实的语言限制。封闭类的意思是说,类的变量和方法,只能在类当中定义,并且类定义只能写在一个文件里。

我们还在某一个设计原则里见过「封闭」这个词,就是开闭原则(Open-Close Principle)——程序应该对修改封闭,对拓展开放。默认情况下,封闭类对拓展是封闭的,除非利用继承创建新的类;要增加类的行为(比方说添加新的方法),就必须修改类定义。

当然,围绕着这个限制,也有一个可怕的设计模式出现了—— 访问者模式 (Visitor Pattern)。

访问者模式的类图和时序图,其中定义了 Visitor 和 Element 接口,有 ElementA、ElementB 和 Visitor1 实现类,值得注意的是,Visitor 接口中包含了 visitElementA() 和 visitElementB() 方法。

教我设计模式的老师在讲解这个模式时不断称赞其优雅,可我一直皱眉头。注意看,Visitor 接口定义了两个方法,分别是 visitElementA()visitElementB()。你注意到问题在哪了吗?

整洁的架构 中,抽象的不应该依赖具体的,因为抽象的是稳定的,具体的是多变的,抽象类若是依赖具体的类,具体类的修改就会影响抽象类,使得系统中的修改变多,增加维护难度。显然,Visitor 作为抽象的接口,不应该依赖 Element 的具体实现。若是编写代码实现上述 UML 类图,Visitor 接口的定义中必然会包含 ElementAElementB 的源代码引用。

就算只让 Visitor 依赖同样抽象的 Element 接口,让 Visitor 的实现类去依赖具体的 ElementAElementB,这两个接口仍然是紧密耦合的,就和备忘录模式一样。就像源发器需要定义 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!