无知的 tonyseek

Yet Another Seeker

在 Python 中实现 Ruby 的 Open Class 和特异方法

Ruby 中的 OpenClass 是个非常方便的特性,我们可以扩充一个已有的类,往里面添加方法。甚至还能脱离类,向实例中添加该实例独有的方法,称为“特异方法”。这种做法的前提是语言具有足够的动态特性,能够运行时更改语言结构,所谓“ 元编程 ”(Meta-Programming)能力。

Python 也有类似的能力,但是不像 Ruby 有原生的语法支持。在 Python 中实现 OpenClass 和特异方法基本原理与 Ruby 中的原理类似——“函数、方法也是一种对象”。

本文只讨论 Python 的 新式类 ,对于 old-style class 保持无视。

Ruby 中的 OpenClass 与特异方法

OpenClass 即在一个类已经完成定义之后,再次向其中添加方法。在 Ruby 中的实现方法就是定义同名类。在 Ruby 中定义同名类不会像 Java 一样被认为是编译错误,也不会像 Python 一样“新定义的类覆盖旧类”,而是把同名类中定义的方法全部附加到已定义的旧类中。以下为示例代码:

class Foo
  def m1()
    puts "m1 has been called"
  end
end

class Foo
  def m2()
    puts "m2 has been called"
  end
end

foo = Foo.new
foo.m1
foo.m2

特异方法和 OpenClass 有点类似,不过附加的方法不是附加到类中,而是附加到特定的实例中。被附加的方法仅仅在目标实例中存在,不会影响该类的其他实例。示例代码:

foo = Foo.new
def foo.m3()
  puts "m3 has been called"
end

foo.m3

Python 方法探幽

从函数到绑定方法

Python 中定义方法,和写一个函数是大致相同的,唯一的不同之处有两点:第一,作为方法的函数必须嵌套在类的结构里;第二,作为方法的函数第一个参数必须为 self。

在一个 Python 类实例化生产出一个实例的时候,类中的所有“函数”都会被包装一层再放到实例的成员字典里(装饰器模式 [1] )。包装层除了添加了一些元信息之外,还对原始的函数做了“装饰”,装饰的效果为“偏函数”。

通过为已经存在的某个函数指定数个参数,生成一个新的函数,这个函数只需要传入剩余未指定的参数就能实现原函数的全部功能,这被称为偏函数。—— AstralWind [2]

也就是说,对于类中有 n 个参数的函数,实例中会有对应的可调用(callable)对象,接受 n-1 个参数的调用。被省略的那个参数就是 self —— 在偏函数化的过程中被赋予了当前实例。

概念上来说,可以把这个包装过程用如下代码大致模拟(仅仅是概念上):

def unbound_method(self, arg1, arg2):
    pass

# 对于 obj1 中的绑定方法
def bound_method(arg1, arg2):
    return unbound_method(obj1, arg1, arg2)

掌握了 Python 的这个特点,我们就可以模拟语言内置的“方法绑定”过程,实现模拟 Ruby 的“特异方法”。

未绑定方法

如果使用的是 py3k,嵌套定义在类中的函数和定义在全局作用域的函数是没有区别的。类实例化出实例的时候,“方法绑定”的过程也是基于这种 raw function。但是 Python 2.x 的处理方式有所区别,尝试一下便知:

# in python 2.x
class Spam(object):
    def egg(self):
        pass

    print egg # output: <function egg at 0x02105630>

print Spam.egg # output: <unbound method Spam.egg>
print Spam.egg.im_func # output: <function egg at 0x02105630>

这点似乎阻碍了我们以直接向类中添加函数的方式添加函数,其实不然,如果我们写一个自由函数,然后添加到类中,这个函数就会被自动包装成未绑定方法对象。

# in python 2.x
def egg2(self):
    pass
Spam.egg2 = egg2
print Spam.egg2 # output: <unbound method Spam.egg2>

我认为是类的 __setattr__ 产生了这样的作用。无论是什么原因,我们确定了一点—— Python 2.x 的“未绑定方法”不会干扰我们以正常思维模式实现 OpenClass。

当然,如果用的是 Python 3.x,未绑定方法问题就不存在了。我们使用属性访问的方式直接向类中添加方法,是可以同时兼容 Python 2.x 和 Python 3.x 的。

实现

有了上述基础,我们可以开始编写一个 utils.py 模块,实现 OpenClass 和特异方法。

from functools import partial

def attach_method(target):
    if isinstance(target, type):
        def decorator(func):
            setattr(target, func.__name__, func)
    else:
        def decorator(func):
            setattr(target, func.__name__, partial(func, target))
    return decorator

上述代码中的 attach_method 装饰器在内部对装饰目标做出装饰之前,先用 isinstance(target, type) 判断目标是否是一个“类”,即“元类” type 的实例。如果是类,则视为 OpenClass,将被装饰函数用 setattr 放置到类中;如果不是类,则视为特异方法,手动构造一个偏函数去掉 self 参数,在放到实例中。

然后我们就可以编写简单的测试:

class Spam(object):
    pass

@attach_method(Spam)
def egg1(self, name):
    print((self, name))

spam1 = Spam()
# OpenClass 加入的方法 egg1 可用
spam1.egg1("Test1")

spam2 = Spam()
@attach_method(spam2)
def egg2(self, name, num):
    print((self, name, num))
# 因为是 OpenClass,所以 egg1 对 spam2 实例也有效
spam2.egg1("Test2")
# 特异方法 egg2
spam2.egg2("Test3", 3)

# egg2 是 spam2 的特异方法,在 spam1 中不可用
# 这里会抛出一个 AttributeError 异常
spam1.egg2("Test3", 3)

实际用途

有了这个数行的简单工具,我们就可以在其他地方展现 OpenClass 和特异方法的便捷了。例如对于 SQLAlchemy 的 Session,我们一般的用法是操作结束领域对象,然后调用 commit 方法提交工作单元。我们希望能够用 Python 的 with 上下文管理方式完成这个工作,但是 SQLAlchemy 的 Session 默认并没有实现 __enter____exit__ 协议,我们就可以通过 OpenClass 加入该实现。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from utils import attach_method

engine = create_engine('sqlite:///:memory:', echo=True)
Session = sessionmaker(bind=engine)

@attach_method(Session)
def __enter__(self):
    return self

@attach_method(Session)
def __exit__(self, err, err_type, err_tb):
    if not err:
        self.commit()
    else:
        self.rollback()

加入了 __enter____exit__ 协议后,使用起来就便捷的多了。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

with Session() as db:
    post = db.query(Post).get(post_id)
    new_comment = Comment(comment_content_text)
    post.add_comment(new_comment)
    db.add(new_comment)

特异方法也是类似的使用场景,不过特异方法针对的是特定实例而不是整个类。

[1]维基百科上关于装饰器模式的词条
[2]AstralWind 关于 Python 函数介绍的博客

Comments