+++ title = '从 CLOS 审视 Java 面向对象编程' subtitle = 'Just A Common Lisper II' tags = ["Lisp","编程"] categories = '代码炼金术' date = 2026-06-04 19:23:18 draft = false toc = true fedi = 'https://akk.eltr.ac/objects/2d8c1e3d-bc2b-4696-8975-bc0aeb28ff11' +++ 书接[上回](http://localhost:1313/article/common-lisp-start/),前一篇文章讲了 Lisp 中的基本数据结构列表的构造方式(*cons pair*),以及由此衍生出的关联列表(`alist`)和属性列表(`plist`),之后我就跑偏了,谈起了编译器和解释器中的「特例」以及宏,为什么看起来像是函数的形式实际上不是也不能是函数,最后浅谈了函数式编程和 `let` 的实现方式。 今天的文章~~继续跑偏~~,来关注 Common Lisp 的另一部分语言特性,即面向对象编程,也借此来谈谈我对 OOP *多少有些复杂*的看法。我将逐个审视 OOP 的三大要素——多态、封装和继承——再来反思面向对象设计模式。 ## 不属于类的多态 Common Lisp 在这点上与 Clojure 类似,提供了非常相似的 `defgeneric`(CL)和 `defmulti`(Clojure),它们都能定义「抽象函数」,也就是不包含具体实现的函数。之后可以使用 `defmethod`(两门语言相同)定义方法,也就是抽象函数的具体实现。 以下是来自 Common Lisp [文档](https://lisp-lang.org/learn/clos)的例子: ```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` 方法共享一个符号名,调用时没有任何区别,但根据传入的数据类型不同,被实际调用的方法也不同。 ```lisp (description 10) ;; => "The integer 10" (description 3.14) ;; => "The float 3.140" ``` 此时还没有类参与,如你所见,CLOS[^1] 中的方法不属于类。这只是多态(polymorphism)而已,其实 Java 等面向对象编程语言的多态也不需要类参与,只不过 Java 里的方法必须放在类里。 ```java 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); } } ``` ```java descriptor.description(10) // => "The integer 10" descriptor.description(3.14) // => "The float 3.14" ``` 当然,上面这段代码是违反开闭原则的,如果要添加新的类型,就需要修改 `Descriptor` 类。大部分情况下,Java 程序其实长这样: ```java public interface Descriptor { void description(); } ``` ```java public class IntegerDescriptor implements Descriptor { private int number; public void description() { System.out.format("The integer %d", number); } } ``` ```java 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)` 就好了,方法定义可以放在一起,也可以不放在一起,还可以在运行时动态地添加新的方法定义。 ```lisp (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](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 语言,但它不够灵活,而且啰唆,往往要创建很多类,以提升系统复杂度为代价去满足设计原则,但实际上这些原则可以在其他语言里用更简单的方式被满足。 ## 封装与封闭的两难 讲完多态,接下来是 OOP 的第二个要点:封装。 前文提到 Java 的啰唆、复杂是其语言限制导致的,具体来说,这个限制就是严格的封装(encapsulation)以及封闭类(closed class)。请允许我用长篇大论批评 Java 这门语言,然后再来对比 Common Lisp 的做法。 ### To encapsulate, or to indulge? 所有 Java 程序员应该都写过*在我如今看来依旧非常神经的* getter 和 setter 方法,也就是定义一个类和它的私有成员变量,保证外部类无法直接访问其变量,只能调用它的公开方法设置和获取变量,以此避免破坏类的封装性。所谓的不能破坏封装性,就是防止外部类用意想不到的方式访问其数据,比方说,商品类中包含价格这一变量,价格不能为负数,如果价格变量是公开的,那么外部类有可能把价格修改为负数,但如果不公开这个变量,而是公开 `setPrice()` 方法,那么就可以在这个方法里加上判断,如果传入的参数小于 0,就报错。 听起来好像很美好,但 Java 里的实体类基本上都长这样: ```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](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 生态里受苦?~~除非你要开发 Minecraft Mod,那样的话,我表示敬佩。~~* 为什么我讨厌 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 p1) ;; => 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)。 ![描述备忘录模式的 UML 类图和时序图。类图中有 Caretaker、Originator 和 Memento;Caretaker 访问 Originator,Originator 创建 Memento;Originator 和 Memento 都有 `state` 属性,Originator 有创建备忘录和通过备忘录恢复状态的方法,而 Memento 只包含相关的 getter/setter 方法。](https://img.eltr.ac/geedeapro/article/common-lisp-oop/W3sDesign_Memento_Design_Pattern_UML.jpg "图源:[Wikipedia](https://en.wikipedia.org/wiki/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 和 Element 接口,有 ElementA、ElementB 和 Visitor1 实现类,值得注意的是,Visitor 接口中包含了 visitElementA() 和 visitElementB() 方法。](https://img.eltr.ac/geedeapro/article/common-lisp-oop/W3sDesign_Visitor_Design_Pattern_UML.jpg) 教我设计模式的老师在讲解这个模式时不断称赞其优雅,可我一直皱眉头。注意看,`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)(Strategy Pattern)。 另一方面,也是更重要的方面,是 Java 的**另一个语言限制**:不支持多继承。从某种程度上讲,策略模式的出现也是因为单继承的限制,组合复用更灵活;另一个原因是旧版本 Java 不支持函数式编程,而现在有了 Lambda 表达式也很少使用,因为 Lambda 不能用类定义。 既然在专门写给面向对象编程的书里,都出于复用性考虑,拒绝使用继承,那继承对 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` 槽为参数的方法),当 `url` 这个槽被更新(`setf`),就调用更新 UI 的函数。这有点像面向切面编程,在 Java 等 OOP 语言里,子类也经常对父类做这种操作。行为复用的目的经常是拓展原有的行为。 如果要在 Java 里实现「更新 URL 后也同时刷新 UI」,由于要避免继承,就只能求助于[装饰器模式](https://en.wikipedia.org/wiki/Decorator_pattern)和[观察者模式](https://en.wikipedia.org/wiki/Observer_pattern)了。 简单来说,行为复用不要求继承,更不是只有 Java 的单继承模式才能做到。甚者,经典的面向对象设计模式常常绕过继承,通过接口实现组合复用,而在 CLOS 里,多继承和 `:around` 等语言特性,使得行为复用变得更简单灵活。 ## 设计模式,还是规避方案? 这个观点可能不新了,在 Peter Norvig 的 [Design Patterns in Dynamic Languages](https://norvig.com/design-patterns/)(动态语言中的设计模式)演讲中,他提到在包括 Common Lisp 在内的多种动态语言里,23 中设计模式中的 16 种要么消失了,要么被简化——我得提醒读者,这些设计模式里还有[解释器模式](https://en.wikipedia.org/wiki/Interpreter_pattern),用于嵌入脚本语言,属于和架构关联较弱的模式,这是剩下的 7 种之一。 这个演讲第一次呈现是 1996 年五月,而刚好三十年过去了,这二十多种面向对象设计模式仍然被无数的 Java(以及其他传统 OOP 语言)开发者奉为圭臬。我想这并不是他们想不到这个观点,而是因为他们没有或者很少接触其他的语言。我也在和人打交道的过程中逐渐意识到,大部分求职者和从业者对编程并无热情,Java 也好,C++ 也好,都是他们求职的工具而已,编程语言对本身而言并不有趣,所以不会去探索更多的语言;或者,另一部分人更关注业务,成为了管理者,与技术保持着微妙的距离。 而在他们眼里,设计模式的确有帮助,甚至是「优雅」的(不得不说,「优雅」是非常主观的评价),因为这的确让编写软件变得方便了。只不过方便的源头,是本身就存在的语言限制,而设计模式仅仅是提供了这些限制的规避方案而已——这些限制在一开始可以不存在。 不过论题可能会被转移到语言的动态与静态上,动态语言的名声在我看来是两极分化的。吹捧者(包括我)认为动态语言限制很少,写起来令人舒适,语法也往往更简洁,需要维护的代码量更少;反对者可能认为动态语言容易出错,不少类型检查都发生在运行时,缺少编译时保护——我对此的反驳是,要保证软件质量和安全性,[写测试就好了](/library/2026/functional-design/#%E8%AE%BA%E7%B1%BB%E5%9E%8B%E5%AE%89%E5%85%A8);况且静态类型不代表类型安全,这是两个概念,同理,动态语言也可以做到类型安全。 话说到最后,究竟是选用灵活但效率和安全性可能更低的动态语言,还是在静态语言系统里用复杂的设计模式规避限制,都是开发者自己的权衡取舍。 --- Happy Lisping! [^1]: Common Lisp Object System(Common Lisp 对象系统)