diff options
| author | eltrac <hi@eltr.ac> | 2026-06-04 14:50:02 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-06-04 14:50:02 +0800 |
| commit | 9b8237aa19ce966c55f24c34e2c4bbcfdf95d534 (patch) | |
| tree | 5b0c434807a809e6c29ad77337bf29cf1c8758d3 /content/article | |
| parent | 4626bf21b70ca51ebce4ccd00d64e3002da8b346 (diff) | |
[draft] update draft
Diffstat (limited to 'content/article')
| -rw-r--r-- | content/article/common-lisp-oop.md | 161 |
1 files changed, 156 insertions, 5 deletions
diff --git a/content/article/common-lisp-oop.md b/content/article/common-lisp-oop.md index 4b4bf0f0..a8c3c3ac 100644 --- a/content/article/common-lisp-oop.md +++ b/content/article/common-lisp-oop.md @@ -1,6 +1,6 @@ +++ title = 'Just A Common Lisper II' -subtitle = '' +subtitle = 'or an enthusiatic Java hater' slug = '' tags = [] categories = '' @@ -113,15 +113,19 @@ public class FloatDescriptor implements Descriptor { 再者,CLOS 是**多分发**(multiple dispatch)的系统,一般的 OOP 语言是单分发的。例如在 Java 中,我们只能根据对象的类型这单个“参数”分发方法,`floatDescriptor.description()` 对应一个方法,`integerDescriptor.description()` 对应另一个方法;在 CLOS 中,方法不绑定在对象上,只要多个参数中有一个参数的类型不同,都被视作不同的态,比如 `(description 1 1)` 对应一个方法,`(description 1 1.0)` 对应另一个方法。 -以上大体是 [Wikipedia](https://en.wikipedia.org/wiki/Common_Lisp_Object_System) 和一些 Reddit 用户的观点,他们貌似忽略了 Java 也可以在方法的参数层面做到多分发,在一个类中可以定义多个名字一样但形参不同的方法。调用类的方法时,先是通过对象的类型进行单分发,然后再根据形参进行多分发。不过我也在前面提到了,由于 Java 的**语言限制**,方法必须和类绑定且必须定义在类的内部,依赖多分发,容易违背开闭原则,所以 Java 编程一般使用基于类的分发,忽略了基于函数签名的多分发,*况且在 OOP 语言里通过在运行时传入类型不同的参数来分发方法,听起来有些大逆不道*。尽管最后得出的结论类似,但我想有必要澄清一下:Java 的确支持多分发。 +以上大体是 [Wikipedia](https://en.wikipedia.org/wiki/Common_Lisp_Object_System) 和一些 Reddit 用户的观点,他们貌似忽略了 Java 也可以在方法的参数层面做到多分发,在一个类中可以定义多个名字一样但形参不同的方法。调用类的方法时,先是通过对象的类型进行单分发,然后再根据形参进行多分发。不过我也在前面提到了,由于 Java 的**语言限制**,方法必须和类绑定且必须定义在类的内部,依赖多分发,容易违背开闭原则,所以 Java 编程一般使用基于类的分发,忽略了基于函数签名的多分发,*况且在静态 OOP 语言里通过在运行时传入类型不同的参数来分发方法,听起来有些大逆不道*。 + +尽管最后得出的结论类似,但我想有必要澄清一下:Java 的确支持多分发,而且有一个专门的设计模式就利用了这个特性,叫作 [Double Dispatch](https://java-design-patterns.com/patterns/double-dispatch/)(双分发)。 一边在学校*被迫*学习 Java 设计模式,一边自己摸索 Lisp 编程,我愈发觉得各种面向接口编程和花里胡哨的设计模式,其实都是对 Java 语言限制的妥协。这是一门很经典的 OOP 语言,但它不够灵活,而且啰唆,往往要创建很多类,以提升系统复杂度为代价去满足设计原则,但实际上这些原则可以在其他语言里用更简单的方式被满足。 -## To close, or to CLOS? +## 封装与封闭的两难 讲完多态,接下来是 OOP 的第二个要点:封装。 -前文提到 Java 的啰唆、复杂是其语言限制导致的,具体来说,这个限制就是严格的封装(encapsulation)以及封闭类(closed class)。 +前文提到 Java 的啰唆、复杂是其语言限制导致的,具体来说,这个限制就是严格的封装(encapsulation)以及封闭类(closed class)。请允许我用长篇大论批评 Java 这门语言,然后再来对比 Common Lisp 的做法。 + +### To encapsulate, or to indulge? 所有 Java 程序员应该都写过*在我如今看来依旧非常神经的* getter 和 setter 方法,也就是定义一个类和它的私有成员变量,保证外部类无法直接访问其变量,只能调用它的公开方法设置和获取变量,以此避免破坏类的封装性。所谓的不能破坏封装性,就是防止外部类用意想不到的方式访问其数据,比方说,商品类中包含价格这一变量,价格不能为负数,如果价格变量是公开的,那么外部类有可能把价格修改为负数,但如果不公开这个变量,而是公开 `setPrice()` 方法,那么就可以在这个方法里加上判断,如果传入的参数小于 0,就报错。 @@ -194,12 +198,159 @@ public class Employee { } ``` -天哪!难道 Java 程序员写这么多重复的方法不觉得烦人吗?——当然!所以他们爱用的 IDEA 里面有代码生成器,用来自动补全 getter 和 setter 方法,还可以生成包含指定参数的构造方法。我想他们使用 `Alt + Insert + Enter` 已经熟练到不觉得这有什么问题了。另外还有名为 Lombok 的库,提供了 [@Data](https://projectlombok.org/features/Data) 注解自动生成这些东西,*你知道这个用来生成模板代码的库还有付费的企业版吗?* +难道 Java 程序员写这么多重复的方法不觉得烦人吗?——当然!所以他们爱用的 IDEA 里面有代码生成器,用来自动补全 getter 和 setter 方法,还可以生成包含指定参数的构造方法。我想他们使用 `Alt + Insert + Enter` 已经熟练到不觉得这有什么问题了。另外还有名为 Lombok 的库,提供了 [@Data](https://projectlombok.org/features/Data) 注解自动生成这些东西,*你知道这个用来生成模板代码的库还有付费的企业版吗?人们在花钱解决本就不该存在的问题。* + +你可能会说,不想这么做的话,把实体类的属性设置成 `public` 就好了,不提供 getter 和 setter 方法不行吗? + +不行。这种拥有完整构造函数和 getter/setter 方法,封装良好的 Java 类,有一个专门的名字:Java Bean。主流的框架 Spring 就基于 Bean 构建(例如 [BeanFactory](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/BeanFactory.html) 接口),不用 Bean 的话就用不了 Spring 框架,不用 Spring 框架的话…… *天哪,那你为什么还要在 Java 生态里受苦?* + +为什么我讨厌 Java Bean 呢?封装性有什么坏处吗?在回答这个问题之前,需要反思封装是用来干什么的。 + +封装,简单来说,就是定义一系列数据以及可以作用在数据上的一系列操作。假设我们有一些零散的数据,姓名、年龄、身高、体重等等,把它们组合在一起,再定义改名、增长年龄、长高、变瘦等操作,把数据组合和对数据的操作放在一个叫作「人」的「容器」里,这就叫作封装。假设不把这些数据封装成类和对象,那么在操作数据的时候,就需要暴露很多细节。比方说,「人」的身高增长速度可能是年龄、性别等各种因素的函数,如果不封装的话,这个函数就暴露给程序的其他部分了,而其他部分大概率根本不需要关心这个细节。 + +封装的另一个意义是限制对数据的直接访问,比方说,如果「身高增长速度」不是函数而是状态,要是这个状态没有被封装隐藏起来,被外部代码修改过后,整个对象的数据完整性(integrity)就可能出错。 + +都是很合理的考量,但这和前面那几十行但逻辑含量为 0 的恐怖模板代码有什么关系?那些 getter/setter 方法根本没做任何检查,大部分时候都可以直接读写,和没有封装的区别在哪?强迫所有实体类都这样封装,仅仅是为了很小一部分的特例吗?为什么 Java 没有提供更好的封装方式? + +我认为这就是设计缺陷,因为 CLOS 也用 getter/setter,写起来没有这么难受。准确来说,CLOS 使用的是 `accessor`,它既是 getter,也是 setter,并且和槽位(slot)一起定义。这个槽和成员变量类似。 + +```lisp +(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` 接收一个关键词,在实例化类的时候作为参数使用,如下: + +```lisp +(defvar p1 + (make-instance 'person :name "Tom")) + +(person-name p1) +;; => "Tom" + +(person-age) +;; => 0 (初始值) +``` + +如果没有默认初始值,也没有在 `make-instance` 时传入初始值,那槽的值就是未绑定的(unbound),试图访问就会报错。 + +你可能发现 `:accessor` 的作用了,它在没有声明函数或方法的情况下,仅仅通过指定符号名,就创建了 getter 方法。它同时也是 setter 方法,可以搭配 `setf` 使用。 + +```lisp +(person-name p1) +;; => "Tom" + +(setf (person-name p1) "Ben") +(person-name p1) +;; => "Ben" +``` + +这看起来只是 Java Bean 的简写版,但 CLOS 不强制封装,也就是说,即便没有 `:accessor`,也可以通过 `slot-value` 访问对象的槽。 + +```lisp +(slot-value p1 'name) +;; => "Tom" +``` + +这在 Java 开发里是「破坏封装性」的做法,实际上,有相当多应用在 Java 中的设计模式都围绕「封装性」展开。Java 类的封装是*神圣不可侵犯*的。 + +比方说,[备忘录模式](https://en.wikipedia.org/wiki/Memento_pattern)(Memento Pattern)。 + +") + +这个设计模式把源发器(Originator)在某个时刻的状态保存在单独的备忘录(Memento)里,源发器可以创建备忘录,也可以用备忘录恢复状态。这种设计模式用来实现撤回和版本管理等操作。 + +之所以需要单独的备忘录类,而且必须让源发器自己创建备忘录,正是因为「不能破坏类的封装性」。源发器的属性是私有的,其他类不能访问,所以需要 `createMemento()` 方法来创建备忘录,其他类也不能给它的私有变量赋值,所以需要 `restore()` 方法。 + +值得注意的是,这里的 `state` 仅仅是简化描述,实际上源发器可能有许多内部状态,这些内部状态要被保存在备忘录类里的话,备忘录就要有一模一样的成员变量才行。这不仅涉及到「复制代码」,源发器和备忘录还是紧密耦合的。 + +所有这些复杂性的引入,仅仅是因为:我们不能破坏类的封装性。 +### To close, or to CLOS? +Java 的另一个语言限制是封闭类。「封装性不可侵犯」更多是生态和社区方面的共识,而封闭类则是实打实的语言限制。封闭类的意思是说,类的变量和方法,只能在类当中定义,并且类定义只能写在一个文件里。 + +我们还在某一个设计原则里见过「封闭」这个词,就是开闭原则(Open-Close Principle)——程序应该对修改封闭,对拓展开放。默认情况下,封闭类对拓展是封闭的,除非利用继承创建新的类;要增加类的行为(比方说添加新的方法),就必须修改类定义。 + +当然,围绕着这个限制,也有一个可怕的设计模式出现了——[访问者模式](https://en.wikipedia.org/wiki/Visitor_pattern)(Visitor Pattern)。 + + + +教我设计模式的老师在讲解这个模式时不断称赞其优雅,可我一直皱眉头。注意看,`Visitor` 接口定义了两个方法,分别是 `visitElementA()` 和 `visitElementB()`。你注意到问题在哪了吗? + +在[整洁的架构](/library/2026/clean-architecture/)中,抽象的不应该依赖具体的,因为抽象的是稳定的,具体的是多变的,抽象类若是依赖具体的类,具体类的修改就会影响抽象类,使得系统中的修改变多,增加维护难度。显然,`Visitor` 作为抽象的接口,不应该依赖 `Element` 的具体实现。若是编写代码实现上述 UML 类图,`Visitor` 接口的定义中必然会包含 `ElementA` 和 `ElementB` 的源代码引用。 + +就算只让 `Visitor` 依赖同样抽象的 `Element` 接口,让 `Visitor` 的实现类去依赖具体的 `ElementA` 或 `ElementB`,这两个接口仍然是紧密耦合的,就和备忘录模式一样。就像源发器需要定义 `createMemento()` 和 `restore()` 方法,被访问的元素类也需要 `accept()` 方法,来允许访问者访问其内部数据。如果不这么做,就要破坏封装性。 + +为什么被访问者自身不能提供访问数据的方法呢?比方说,`toString()`、`toJSON()` 和 `toTOML()` 等等。问题在于,*在 Java 里*,这违背了开闭原则。如果需求变更,需要添加将数据导出为 YAML 格式的能力,就不得不违背开闭原则,修改类定义,添加 `toYAML()` 方法。 + +为了让程序**可拓展**,尊重开闭原则,访问者模式就诞生了。因为不能在不修改类定义的情况下添加新的方法,所以就只能实现新的类来拓展功能。 + +如果类不是封闭的呢?如果可以在别的文件、别的模块里给类添加新的方法,那不就没必要创造与实体类紧密耦合的访问者类了吗?实际上 Go 语言就可以做到,详情可以阅读[这篇文章](/library/2026/functional-design/#封闭类),不过 Go 语言因为缺少继承,不算是真正的 OOP 语言。 + +在 Common Lisp 中,由于方法不属于类,是独立存在的,再加上封装性不是强制的,可以用 `slot-value` 访问没有 `accessor` 的槽,所以导出数据的操作就不需要被访问类亲自动手,也不需要与一个访问者类耦合,提供必须的 `accept` 方法。 + +CLOS 中方法与类分离、不强制封装的设计,使得模块之间能保持松散的耦合,访问者与被访问之间不需要相互了解,数据完全可以不必得知访问者的存在,访问者无需复杂的操作就能直接获取对象的内部数据。这并不可怕,除非对数据安全有极其严苛的要求,然而这在大部分时候不适用。 + +封装和封闭不应该是不可变的祖宗之法。 ## 继承是必要的吗? +说实话,不少 Java 设计模式自己都讨厌继承这一 OOP 的基本机制,更常用的模式是编写抽象接口,然后再编写接口的实现类。翻看四人帮的 23 个经典设计模式,几乎没有一个模式用到了继承。 + +因为在那本《设计模式》里,重要的原则之一就是:[Composition over inheritance](https://en.wikipedia.org/wiki/Composition_over_inheritance)(组合优先于继承)——也就是 CRP(组合复用原则)。 + +一方面的原因是继承不够灵活,行为和结构是预先定义好的,不能像接口那样组合复用。比方说,假设有多种类型的账户,这些账户在计算价格时有不同的算法逻辑,比起创建 `Account` 类和各种类型的子类,更利于复用的模式是将算法和数据分离开来,创建 `Strategy` 接口和各种实现类,计算价格时由 `Account` 提供 `Strategy` 的实例,再调用其中的方法进行计算——这个叫[策略模式](https://en.wikipedia.org/wiki/Strategy_pattern)。 + +另一方面,也是更重要的方面,是 Java 的**另一个语言限制**:不支持多继承。从某种程度上讲,策略模式的出现也是因为单继承的限制,组合复用比单向的继承更灵;另一个原因是 Java 不是函数式编程语言,不支持将函数作为第一公民(而且反射机制更麻烦吧)。 + +既然在专门写给面向对象编程的书里,都出于复用性考虑,拒绝使用继承,那继承对 OOP 真的还必要吗?Go 语言支持 OOP,但并不支持继承(也因此有人认为 Go 不是面向对象编程语言),并且 Go 语言创始人 Rob Pike 本人也强调[用更小的接口实现更强的抽象](https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s)。 + +噢等等,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),比如经典的圆形/椭圆形问题(也叫正方形/矩形问题),指的是圆形和正方形作为椭圆形和矩形的子类,会违背里氏替换原则。具体的解释可以阅读[这篇文章](http://localhost:1313/library/2026/clean-architecture/#:~:text=%E6%89%80%E8%B0%93%E7%9A%84%E9%87%8C%E6%B0%8F,assert%20%E4%BC%9A%E5%A4%B1%E8%B4%A5%E3%80%82)。 + +继承的目的其实是行为复用(behavior reuse),子类可以复用(或重写,或拓展)父类的行为。在经典的四人帮设计模式中,继承被基于接口的组合复用取代了,他们更倾向于把行为从类中分离出来。在一些动态语言中,类的概念被取消了,取而代之的是原型(prototype),可以现有的对象作为原型,复用其属性和行为,不必先有类才能实例化,这叫做[基于原型的编程](https://zh.wikipedia.org/wiki/%E5%9F%BA%E4%BA%8E%E5%8E%9F%E5%9E%8B%E7%BC%96%E7%A8%8B),可以理解为没有类的面向对象。实际上 JavaScript 就支持原型编程。 + +看起来,继承难用到不少人都在找新的解决方案以避免使用继承。 + +话说回 Common Lisp,这门语言的对象系统实际上支持继承,而且是多继承,子类可以组合复用多个父类的行为,这实际上也和四人帮的「组合优先于继承」不谋而合。多继承的语法也简单得要命。 + +```lisp +(defclass baby (child person) + ()) +;; baby 类同时继承了 child 类和 person 类 +``` + +要实现灵活的组合复用,可以选择多继承,也可以应用接口隔离原则(ISP),创建更小的接口。CLOS 里没有常规的接口,只有在文章一开始提到的 `defgeneric` 泛函数,再加上另一个语言特性,也能实现灵活的组合复用。这个特性我在[第 82 期周刊](/weekly/82/#lisp-设计模式)提到过,让我从那篇文章里选个例子。这段代码来自 [Artyom Bologov](https://aartaka.me/lisp-design-patterns.html)。 + +```lisp +(defmethod (setf url) :around (value (buffer document-buffer)) + (call-next-method) + (set-window-title)) +``` + +这段方法定义实际上覆盖了 `(setf url)` 函数,当 `url` 这个槽被更新(`setf`),就调用更新 UI 的函数。这有点像面向切面编程,在 Java 等 OOP 语言里,子类也经常对父类做这种操作。行为复用的目的经常是拓展原有的行为。 + +简单来说,行为复用不要求继承一定存在,更不是只有 Java 的单继承模式才能做到。甚者,经典的面向对象设计模式常常绕过继承,通过接口实现组合复用,而在 CLOS 里,多继承和 `:around` 等语言特性,使得行为复用变得更简单灵活。 + +## 设计模式,还是规避方案? + + + ## Footnotes [^1]: Common Lisp Object System(Common Lisp 对象系统) |
