第十一章:Common Lisp 对象系统
Common Lisp 对象系统,或称 CLOS,是一组用来实现面向对象编程的操作集。由于它们有着同样的历史,通常将这些操作视为一个群组。 λ 技术上来说,它们与其他部分的 Common Lisp 没什么大不同: defmethod
和 defun
一样,都是整合在语言中的一个部分。
11.1 面向对象编程 Object-Oriented Programming
面向对象编程意味着程序组织方式的改变。这个改变跟已经发生过的处理器运算处理能力分配的变化雷同。在 1970 年代,一个多用户的计算机系统代表着,一个或两个大型机连接到大量的λ
11.2 类与实例 (Class and Instances)
在 4.6 节时,我们看过了创建结构的两个步骤:我们调用 defstruct
来设计一个结构的形式,接着通过一个像是 make-point
这样特定的函数来创建结构。创建实例 (instances)同样需要两个类似的步骤。首先我们使用 defclass
来定义一个类别 (Class):
(defclass circle () (radius center))
这个定义说明了 circle
类别的实例会有两个槽 (slot),分别名为 radius
与 center
(槽类比于结构里的字段 「field」)。
要创建这个类的实例,我们调用通用的 make-instance
函数,而不是调用一个特定的函数,传入的第一个参数为类别名称:
> (setf c (make-instance 'circle)) #<CIRCLE #XC27496>
要给这个实例的槽赋值,我们可以使用 setf
搭配 slot-value
:
> (setf (slot-value c 'radius) 1) 1
与结构的字段类似,未初始化的槽的值是未定义的 (undefined)。
11.3 槽的属性 (Slot Properties)
传给 defclass
的第三个参数必须是一个槽定义的列表。如上例所示,最简单的槽定义是一个表示其名称的符号。在一般情况下,一个槽定义可以是一个列表,第一个是槽的名称,伴随着一个或多个属性 (property)。属性像关键字参数那样指定。
通过替一个槽定义一个访问器 (accessor),我们隐式地定义了一个可以引用到槽的函数,使我们不需要再调用 slot-value
函数。如果我们如下更新我们的 circle
类定义,
(defclass circle () ((radius :accessor circle-radius) (center :accessor circle-center)))
那我们能够分别通过 circle-radius
及 circle-center
来引用槽:
> (setf c (make-instance 'circle)) #<CIRCLE #XC5C726> > (setf (circle-radius c) 1) 1 > (circle-radius c) 1
通过指定一个 :writer
或是一个 :reader
,而不是 :accessor
,我们可以获得访问器的写入或读取行为。
要指定一个槽的缺省值,我们可以给入一个 :initform
参数。若我们想要在 make-instance
调用期间就将槽初始化,我们可以用 :initarg
定义一个参数名。 λ
11.6 通用函数 (Generic Functions)
一个通用函数 (generic function) 是由一个或多个方法组成的一个函数。方法可用 defmethod
来定义,与 defun
的定义形式类似:
(defmethod combine (x y) (list x y))
现在 combine
有一个方法。若我们在此时调用 combine
,我们会获得由传入的两个参数所组成的一个列表:
> (combine 'a 'b) (A B)
到现在我们还没有做任何一般函数做不到的事情。一个通用函数不寻常的地方是,我们可以继续替它加入新的方法。
首先,我们定义一些可以让新的方法引用的类别:
(defclass stuff () ((name :accessor name :initarg :name))) (defclass ice-cream (stuff) ()) (defclass topping (stuff) ())
这里定义了三个类别: stuff
,只是一个有名字的东西,而 ice-cream
与 topping
是 stuff
的子类。
现在下面是替 combine
定义的第二个方法:
(defmethod combine ((ic ice-cream) (top topping)) (format nil "~A ice-cream with ~A topping." (name ic) (name top)))
在这次 defmethod
的调用中,参数被特化了 (specialized):每个出现在列表里的参数都有一个类别的名字。一个方法的特化指出它是应用至何种类别的参数。我们刚定义的方法仅能在传给 combine
的参数分别是 ice-cream
与 topping
的实例时。
而当一个通用函数被调用时, Lisp 是怎么决定要用哪个方法的?Lisp 会使用参数的类别与参数的特化匹配且优先级最高的方法。这表示若我们用 ice-cream
实例与 topping
实例去调用 combine
方法,我们会得到我们刚刚定义的方法:
> (combine (make-instance 'ice-cream :name 'fig) (make-instance 'topping :name 'treacle)) "FIG ice-cream with TREACLE topping"
但使用其他参数时,我们会得到我们第一次定义的方法:
> (combine 23 'skiddoo) (23 SKIDDOO)
因为第一个方法的两个参数皆没有特化,它永远只有最低优先权,并永远是最后一个调用的方法。一个未特化的方法是一个安全手段,就像 case
表达式中的 otherwise
子句。
一个方法中,任何参数的组合都可以特化。在这个方法里,只有第一个参数被特化了:
(defmethod combine ((ic ice-cream) x) (format nil "~A ice-cream with ~A." (name ic) x))
若我们用一个 ice-cream
的实例以及一个 topping
的实例来调用 combine
,我们仍然得到特化两个参数的方法,因为它是最具体的那个:
> (combine (make-instance 'ice-cream :name 'grape) (make-instance 'topping :name 'marshmallow)) "GRAPE ice-cream with MARSHMALLOW topping"
然而若第一个参数是 ice-cream
而第二个参数不是 topping
的实例的话,我们会得到刚刚上面所定义的那个方法:
> (combine (make-instance 'ice-cream :name 'clam) 'reluctance) "CLAM ice-cream with RELUCTANCE"
当一个通用函数被调用时,参数决定了一个或多个可用的方法 (applicable methods)。如果在调用中的参数在参数的特化约定内,我们说一个方法是可用的。
如果没有可用的方法,我们会得到一个错误。如果只有一个,它会被调用。如果多于一个,最具体的会被调用。最具体可用的方法是由调用传入参数所属类别的优先级所决定的。由左往右审视参数。如果有一个可用方法的第一个参数,此参数特化给某个类,其类的优先级高于其它可用方法的第一个参数,则此方法就是最具体的可用方法。平手时比较第二个参数,以此类推。 λ
11.10 两种模型 (Two Models)
面向对象编程是一个令人疑惑的话题,部分的原因是因为有两种实现方式:消息传递模型 (message-passing model)与通用函数模型 (generic function model)。一开始先有的消息传递。通用函数是广义的消息传递。
在消息传递模型里,方法属于对象,且方法的继承与槽的继承概念一样。要找到一个物体的面积,我们传给它一个 area
消息:
tell obj area
而这调用了任何对象 obj
所拥有或继承来的 area 方法。
有时候我们需要传入额外的参数。举例来说,一个 move
方法接受一个说明要移动多远的参数。如我我们想要告诉 obj
移动 10 个单位,我们可以传下面的消息:
(move obj 10)
消息传递模型的局限性变得清晰。在消息传递模型里,我们仅特化 (specialize) 第一个参数。 牵扯到多对象时,没有规则告诉方法该如何处理 ── 而对象回应消息的这个模型使得这更加难处理了。
在消息传递模型里,方法是对象所有的,而在通用函数模型里,方法是特别为对象打造的 (specialized)。 如果我们仅特化第一个参数,那么通用函数模型和消息传递模型就是一样的。但在通用函数模型里,我们可以更进一步,要特化几个参数就几个。这也表示了,功能上来说,消息传递模型是通用函数模型的子集。如果你有通用函数模型,你可以仅特化第一个参数来模拟出消息传递模型。
Chapter 11 总结 (Summary)
- 在面向对象编程中,函数
f
通过定义拥有f
方法的对象来隐式地定义。对象从它们的父母继承方法。 - 定义一个类别就像是定义一个结构,但更加啰嗦。一个共享的槽属于一整个类别。
- 一个类别从基类中继承槽。
- 一个类别的祖先被排序成一个优先级列表。理解优先级算法最好的方式就是通过视觉。
- 一个通用函数由一个给定名称的所有方法所组成。一个方法通过名称及特化参数来识别。参数的优先级决定了当调用一个通用函数时会使用哪个方法。
- 方法可以通过辅助方法来增强。标准方法组合机制意味着如果有
:around
方法的话就调用它;否则依序调用:before
,最具体的主方法以及:after
方法。 - 在操作符方法组合机制中,所有的主方法都被视为某个操作符的参数。
- 封装可以通过包来实现。
- 面向对象编程有两个模型。通用函数模型是广义的消息传递模型。
Chapter 11 练习 (Exercises)
- 替图 11.2 所定义的类定义访问器、 initforms 以及 initargs 。重写相关的代码使其再也不用调用
slot-value
。 - 重写图 9.5 的代码,使得球体与点为类别,而
intersect
及normal
为通用函数。 - 假设有若干类别定义如下:
(defclass a (c d) ...) (defclass e () ...) (defclass b (d c) ...) (defclass f (h) ...) (defclass c () ...) (defclass g (h) ...) (defclass d (e f g) ...) (defclass h () ...)
- 画出表示类别
a
祖先的网络以及列出a
的实例归属的类别,从最相关至最不相关排列。 - 替类别
b
也做 (a) 小题的要求。
- 假定你已经有了下列函数:
precedence
:接受一个对象并返回其优先级列表,列表由最具体至最不具体的类组成。
methods
:接受一个通用函数并返回一个列出所有方法的列表。
specializations
:接受一个方法并返回一个列出所有特化参数的列表。返回列表中的每个元素是类别或是这种形式的列表 (eql x)
,或是 t
(表示该参数没有被特化)。
使用这些函数(不要使用 compute-applicable-methods
及 find-method
),定义一个函数 most-spec-app-meth
,该函数接受一个通用函数及一个列出此函数被调用过的参数,如果有最相关可用的方法的话,返回它。
- 不要改变通用函数
area
的行为(图 11.2), - 举一个只有通用函数的第一个参数被特化会很难解决的问题的例子。
脚注
[1] | Initarg 的名称通常是关键字,但不需要是。 |
[2] | 我们不可能比较完所有的参数而仍有平手情形存在,因为这样我们会有两个有着同样特化的方法。这是不可能的,因为第二个的定义会覆写掉第一个。 |