第十一章:Common Lisp 对象系统

优质
小牛编辑
134浏览
2023-12-01

Common Lisp 对象系统,或称 CLOS,是一组用来实现面向对象编程的操作集。由于它们有着同样的历史,通常将这些操作视为一个群组。 λ 技术上来说,它们与其他部分的 Common Lisp 没什么大不同: defmethoddefun 一样,都是整合在语言中的一个部分。

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),分别名为 radiuscenter (槽类比于结构里的字段 「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-radiuscircle-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-creamtoppingstuff 的子类。

现在下面是替 combine 定义的第二个方法:

(defmethod combine ((ic ice-cream) (top topping))
  (format nil "~A ice-cream with ~A topping."
          (name ic)
          (name top)))

在这次 defmethod 的调用中,参数被特化了 (specialized):每个出现在列表里的参数都有一个类别的名字。一个方法的特化指出它是应用至何种类别的参数。我们刚定义的方法仅能在传给 combine 的参数分别是 ice-creamtopping 的实例时。

而当一个通用函数被调用时, 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)

  1. 在面向对象编程中,函数 f 通过定义拥有 f 方法的对象来隐式地定义。对象从它们的父母继承方法。
  2. 定义一个类别就像是定义一个结构,但更加啰嗦。一个共享的槽属于一整个类别。
  3. 一个类别从基类中继承槽。
  4. 一个类别的祖先被排序成一个优先级列表。理解优先级算法最好的方式就是通过视觉。
  5. 一个通用函数由一个给定名称的所有方法所组成。一个方法通过名称及特化参数来识别。参数的优先级决定了当调用一个通用函数时会使用哪个方法。
  6. 方法可以通过辅助方法来增强。标准方法组合机制意味着如果有 :around 方法的话就调用它;否则依序调用 :before ,最具体的主方法以及 :after 方法。
  7. 在操作符方法组合机制中,所有的主方法都被视为某个操作符的参数。
  8. 封装可以通过包来实现。
  1. 面向对象编程有两个模型。通用函数模型是广义的消息传递模型。

Chapter 11 练习 (Exercises)

  1. 替图 11.2 所定义的类定义访问器、 initforms 以及 initargs 。重写相关的代码使其再也不用调用 slot-value
  2. 重写图 9.5 的代码,使得球体与点为类别,而 intersectnormal 为通用函数。
  3. 假设有若干类别定义如下:
(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 ()  ...)
  1. 画出表示类别 a 祖先的网络以及列出 a 的实例归属的类别,从最相关至最不相关排列。
  2. 替类别 b 也做 (a) 小题的要求。
  1. 假定你已经有了下列函数:

precedence :接受一个对象并返回其优先级列表,列表由最具体至最不具体的类组成。

methods :接受一个通用函数并返回一个列出所有方法的列表。

specializations :接受一个方法并返回一个列出所有特化参数的列表。返回列表中的每个元素是类别或是这种形式的列表 (eql x) ,或是 t (表示该参数没有被特化)。

使用这些函数(不要使用 compute-applicable-methodsfind-method ),定义一个函数 most-spec-app-meth ,该函数接受一个通用函数及一个列出此函数被调用过的参数,如果有最相关可用的方法的话,返回它。

  1. 不要改变通用函数 area 的行为(图 11.2),
  2. 举一个只有通用函数的第一个参数被特化会很难解决的问题的例子。

脚注

[1]Initarg 的名称通常是关键字,但不需要是。
[2]我们不可能比较完所有的参数而仍有平手情形存在,因为这样我们会有两个有着同样特化的方法。这是不可能的,因为第二个的定义会覆写掉第一个。