头图

从 Ruby 的 method_missing 到杂鱼 Common Lisp

在 Ruby 中当调用一个对象不存在的方法时,会触发解释器调用该对象的method_missing方法。例如下面的代码

# -*- encoding: UTF-8 -*-
class A
    def method_missing(m, *args, &block)
        puts 'now you see me'
    end
end


A.new().demo()

运行到方法调用demo()时,由于该方法未定义,于是解释器会转而调用方法method_missing,并将相同的方法名(即demo)、参数列表等传递给它。其运行结果便是在标准输出中打印出now you see me这句话。

在 Python 中有method_missing的等价物——__getattr__方法。与 Ruby 不同的是,调用不存在的方法对于 Python 解释器而言,只是一次寻常的AttributeError异常,然后解释器会调用对象的__getattr__方法。与前文的 Ruby 代码类似的写法如下

class A:
    def __getattr__(self, name):
        def replacement(*args, **kwargs):
            print('now you see me')

        return replacement


A().demo()

利用__getattr__可以实现一个透明缓存。例如,假设有一个类Slow,它提供了ab,以及c等几个比较耗时的方法。那么可以实现一个类Cached,由它来代理对Slow类的实例方法的调用、将结果缓存起来加速下一次的调用,再返回给调用方,示例代码如下

import json
import time


class Slow:
    def a(self):
        time.sleep(1)
        return 2

    def b(self):
        time.sleep(1)
        return 23

    def c(self):
        time.sleep(1)
        return 233


class Cached:
    def __init__(self, slow: Slow):
        self._slow = slow

        self._cache = {}

    def __getattr__(self, name):
        f = getattr(self._slow, name)
        def replacement(*args, **kwargs):
            key = json.dumps([args, kwargs])
            if key in self._cache:
                return self._cache[key]

            v = f(*args, **kwargs)
            self._cache[key] = v
            return v

        return replacement


def run_and_timing(f, label):
    begin_at = time.time()
    v = f()
    duration = time.time() - begin_at
    print('%s 耗时 %s 秒' % (label, duration))


if __name__ == '__main__':
    cached = Cached(Slow())
    run_and_timing(lambda: cached.a(), '第一次')
    run_and_timing(lambda: cached.a(), '第二次')

在我的机器上运行的结果为

第一次 耗时 1.0018281936645508 秒
第二次 耗时 2.8848648071289062e-05 秒

在 Common Lisp 中有没有与__getattr__对应的特性呢?有的,那便是广义函数slot-missing。但可惜的是,它并不适用于调用一个不存在的方法的场景,因为在 Common Lisp 中方法并不属于作为第一个参数的实例对象,而是属于广义函数的(即 Common Lisp 不是单派发、而是多派发的,可以参见这篇文章)。所以调用一个不存在的方法不会导致调用slot-missing,而是会调用no-applicable-method。如下列代码所示

(defgeneric demo-gf (a)
  (:documentation "用于演示的广义函数。"))

(defclass A ()
  ())


(defclass B ()
  ())


(defmethod demo-gf ((a A))
  (format t "这是类 A 的实例方法。~%"))


(defmethod no-applicable-method ((gf (eql #'demo-gf)) &rest args)
  (declare (ignorable args gf))
  (format t "now you see me"))


(defun main ()
  (let ((a (make-instance 'B)))
    (demo-gf a)))


(main)

假设上述代码保存在文件no_applicable_method_demo.lisp中,可以像下面这样运行它们

$ ros run --load ./no_applicable_method_demo.lisp -q
now you see me

当代码运行到(demo-gf a)时,由于没有为广义函数demo-gf定义过参数列表的类型为(B)的方法,因此 SBCL 调用了广义函数no-applicable-method,后者有applicable 的方法,因此会调用它并打印出now you see me

如果想利用这一特性来实现透明缓存,那么必须:

  1. 为每一个需要缓存的广义函数都编写其no-applicable-method方法;
  2. 手动检查参数列表的第一个参数的类型是否为特定的类。

如下列代码所示

(defgeneric a (a))
(defgeneric b (a))
(defgeneric c (a))


(defclass Slow ()
  ())


(defclass Cached ()
  ((cache
    :accessor cached-cache
    :initform (make-hash-table :test #'equal))
   (slow
    :accessor cached-slow
    :initarg :slow)))


(defmethod a ((a Slow))
  (sleep 1)
  2)
(defmethod b ((a Slow))
  (sleep 1)
  23)
(defmethod c ((a Slow))
  (sleep 1)
  233)


(defmethod no-applicable-method ((gf (eql #'a)) &rest args)
  (let ((instance (first args)))
    (if (typep instance 'Cached)
        (let ((slow (cached-slow instance))
              (key (rest args)))
          (multiple-value-bind (v foundp)
              (gethash key (cached-cache instance))
            (if foundp
                v
                (let ((v (apply gf slow (rest args))))
                  (setf (gethash key (cached-cache instance)) v)
                  v))))
        (call-next-method))))

在我的机器上运行的结果为

CL-USER> (time (a *cached*))
Evaluation took:
  1.001 seconds of real time
  0.001527 seconds of total run time (0.000502 user, 0.001025 system)
  0.20% CPU
  2,210,843,642 processor cycles
  0 bytes consed
  
2
CL-USER> (time (a *cached*))
Evaluation took:
  0.000 seconds of real time
  0.000015 seconds of total run time (0.000014 user, 0.000001 system)
  100.00% CPU
  29,024 processor cycles
  0 bytes consed
  
2

如果想要让透明缓存对函数bc也起作用,则需要重新定义bc各自的no-applicable-method方法。通过编写一个宏可以简化这部分重复的代码,示例如下

(defmacro define-cached-method (generic-function)
  "为函数 GENERIC-FUNCTION 定义它的缓存版本的方法。"
  (let ((gf (gensym))
        (args (gensym)))
    `(defmethod no-applicable-method ((,gf (eql ,generic-function)) &rest ,args)
       (let ((instance (first ,args)))
         (if (typep instance 'Cached)
             (let ((slow (cached-slow instance))
                   (key ,args))
               (multiple-value-bind (v foundp)
                   (gethash key (cached-cache instance))
                 (if foundp
                     v
                     (let ((v (apply ,gf slow (rest ,args))))
                       (setf (gethash key (cached-cache instance)) v)
                       v))))
             (call-next-method))))))

然后就可以直接用这个新的宏来为函数abc定义相应的带缓存的方法了,示例代码如下

(define-cached-method #'a)
(define-cached-method #'b)
(define-cached-method #'c)

用函数b演示一下,效果如下

CL-USER> (time (b *cached*))

Evaluation took:
  1.003 seconds of real time
  0.002518 seconds of total run time (0.001242 user, 0.001276 system)
  0.30% CPU
  2,216,371,640 processor cycles
  334,064 bytes consed
  
23
CL-USER> (time (b *cached*))
Evaluation took:
  0.000 seconds of real time
  0.000064 seconds of total run time (0.000063 user, 0.000001 system)
  100.00% CPU
  135,008 processor cycles
  0 bytes consed
  
23

全文完。

阅读原文


用户bPGfS
169 声望3.7k 粉丝