14.1 单分发器

我经常说Python是Lisp的一个很好的子集,并且随着时间的推移,我越来越觉得这话是对的。最近我偶然发现了PEP 443(https://www.python.org/dev/peps/pep-0443/),它描述了一种与CLOS(Common Lisp Object System)提供的方式类似的泛型函数分发方式。

如果你熟悉Lisp的话,对这些应该并不陌生。Lisp对象系统是Common Lisp的一个基本组件,提供了一种很好的定义和处理方法分发的方式。这里会先展示一下Lisp中的泛型方法——尽管在一本Python书中包含Lisp代码更多是为了好玩儿!

广告:个人专属 VPN,独立 IP,流量大,速度快,连接稳定,多机房切换,每月最低仅 10 美元

一开始让我们先定义几个非常简单的类,没有任何父类和属性:

(defclass snare-drum ()
 ())
(defclass cymbal ()
 ())
(defclass stick ()
 ())
(defclass brushes ()
 ())

上面的代码定义了几个类:snare-drumsymbalstickbrushes。它们不包括任何父类和属性。这些类组成了一套架子鼓,我们可以将它们组合起来并发出声音。于是,我们定义一个play方法接收两个参数,并返回声音(以字符串形式)。

(defgeneric play (instrument accessory)
 (:documentation "Play sound with instrument and accessory."))

这只定义了一个泛型方法:它并不依附于任何类,所以还不能被调用。在这个阶段,只是通知对象系统,这个方法是个泛型方法,可以通过各种参数调用。现在我们来实现这个方法的不同版本从而模拟演奏军鼓。

(defmethod play ((instrument snare-drum) (accessory stick))
 "POC!")

(defmethod play ((instrument snare-drum) (accessory brushes))
 "SHHHH!")

现在代码中已经定义了具体方法。他们接收两个参数:instrument(乐器),它是军鼓的一个实例;accessory(附件),它是stick(鼓槌)或者brushes(刷子)的一个实例。

在这个阶段,应该可以看出这一系统和Python(或类似)的对象系统的第一个主要区别:方法并没有绑定到任何特定的类上。这个方法是通用的,并且任何类都可以实现它们。

让我们来试试。

* (play (make-instance 'snare-drum) (make-instance 'stick))
"POC!"

* (play (make-instance 'snare-drum) (make-instance 'brushes))
"SHHHH!"

* (play (make-instance 'cymbal) (make-instance 'stick))
debugger invoked on a SIMPLE-ERROR in thread
#<THREAD "main thread" RUNNING {1002ADAF23}>:
 There is no applicable method for the generic function
  #<STANDARD-GENERIC-FUNCTION PLAY (2)>
 when called with arguments
  (#<CYMBAL {1002B801D3}> #<STICK {1002B82763}>).

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.
restarts (invokable by number or by possibly-abbreviated name):
 0: [RETRY] Retry calling the generic function.
 1: [ABORT] Exit debugger, returning to top level.

((:METHOD NO-APPLICABLE-METHOD (T)) #<STANDARD-GENERIC-FUNCTION PLAY (2)> #<CYMBAL {1002B801D3}> #<STICK {1002B82763}>) [fast-method]

如你所见,调用哪个函数取决于参数的类——对象系统根据传递哪个类作为参数,为我们将函数调用分发给正确的函数。如果以对象系统不知道的实例调用play,会抛出错误。

继承同样也被支持,与Python中的super()类似的(更为强大且不那么容易出错的)实现是通过(call-next-method)

(defclass snare-drum () ())
(defclass cymbal () ())

(defclass accessory () ())
(defclass stick (accessory) ())

(defclass brushes (accessory) ())
(defmethod play ((c cymbal) (a accessory))
 "BIIING!")

(defmethod play ((c cymbal) (b brushes))
 (concatenate 'string "SSHHHH!" (call-next-method)))

在这个例子中,定义了stickbrushes两个类作为accessory的子类。play方法会返回声音BIIING!,不管用哪个附件实例去敲cymbal(铙钹),除非是用brushes实例,即最精确的方法总能确保被调用。(call-next-method)函数用来调用最接近的父类的方法,在本例中就是那个会返回"BIIING!"的方法。

* (play (make-instance 'cymbal) (make-instance 'stick))
"BIIING!"

* (play (make-instance 'cymbal) (make-instance 'brushes))
"SSHHHH!BIIING!"

注意,在CLOS中可以通过eql specializer为类的某一个特定实例定义专门的方法。

但如果你真的非常好奇CLOS提供的众多功能,建议你读一下Jeff Dalton作为发起人撰写的CLOS简明指南(http://www.aiai.ed.ac.uk/~jeff/clos-guide.html)。

Python通过singledispatch实现了这个工作流的一个简单版本,它将在Python 3.4中作为functools模块的一部分。下面是前面的Lisp程序的一个粗略的对应实现:

import functools

class SnareDrum(object): pass
class Cymbal(object): pass
class Stick(object): pass
class Brushes(object): pass

@functools.singledispatch
def play(instrument, accessory):
  raise NotImplementedError("Cannot play these")

@play.register(SnareDrum)
def _(instrument, accessory):
  if isinstance(accessory, Stick):
    return "POC!"
  if isinstance(accessory, Brushes):
    return "SHHHH!"
  raise NotImplementedError("Cannot play these")

这里定义了4个类,以及一个基本的play函数,它会抛出NotImplementedError,表明默认情况下不知道该做什么。接下来可以为特定乐器——SnareDrum(军鼓)——开发此函数的特定版本。这个函数会检查传入了哪个附件类型,并返回适当的声音。如果它无法识别这个附件,则再次抛出NotImplementedError

如果运行这个程序,它应该像下面这样工作:

>>> play(SnareDrum(), Stick())
'POC!'
>>> play(SnareDrum(), Brushes())
'SHHHH!'
>>> play(Cymbal(), Brushes())
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/home/jd/Source/cpython/Lib/functools.py", line 562, in wrapper
  return dispatch(args[0].__class__)(*args, **kw)
 File "/home/jd/sd.py", line 10, in play
  raise NotImplementedError("Cannot play these")
NotImplementedError: Cannot play these
>>> play(SnareDrum(), Cymbal())
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/home/jd/Source/cpython/Lib/functools.py", line 562, in wrapper
  return dispatch(args[0].__class__)(*args, **kw)
 File "/home/jd/sd.py", line 18, in _
  raise NotImplementedError("Cannot play these")
NotImplementedError: Cannot play these

singledispatch模块检查传入的第一个参数的类,并调用play函数的适当版本。对于object类,总是会运行函数的最先定义的版本。所以,如果传入的是未注册的乐器实例,则基函数会被调用。

如果急切地想试试它的话,singledispatch函数通过Python Package Index已经在Python 2.6到Python 3.3中提供了(https://pypi.python.org/pypi/singledispatch/)。

正如在Lisp版本的代码中所看到的,CLOS提供了可根据方法原型中定义的任意参数的类型分发的多分发器,不只是第一个参数。遗憾的是,Python中的分发器被命名为singledispatch是有原因的:因为它知道如何根据第一个参数进行分发。Guido van Rossum在几年前写了一篇名为multimethod(http://www.artima.com/weblogs/viewpost.jsp?thread=101605)的短文对此进行了解释。

此外,没办法直接调用父类的函数——既没有Lisp中的(call-next-method),也没有Python中的super()函数。只能用一些技巧绕过这个限制。

总结:泛型函数是增强对象系统的有力方式,尽管我很高兴地看到Python在朝着这个方向努力,但它仍然缺少一些CLOS所能提供的开箱即用的高级功能。