一个例子加深Python元类与描述符类理解

Author Avatar
patrickcty 10月 27, 2020

通过函数注解来实现方法重载

最近在看 《Python Cookbook》,9.20 的示例涉及到非常多高级用法,有必要专门拿出来整理一下避免遗忘。这一节的目的是通过函数注解来实现方法重载,由于 Python 中对参数类型是没有硬性要求的,因此在 Python 中也没有方法重载这一特性。虽然函数注解能提示用户输入变量应该是什么类型,但实际上并没有类型检查与硬性的约束。在这一节之中就是用元类 + 描述符来实现这个功能。

定义描述符类

首先定义一个描述符类,用来将参数类型与对应的函数引用进行绑定,同时也可以通过传入的参数获取到函数引用。前者是使用一个注册函数来实现的,传入的是一个函数,得到这个函数可能的参数列表,然后将参数列表与函数绑定到一个字典中;后者是通过重写 __call__ 方法来从输出参数找到对应函数,重写 __get__ 方法将函数绑定 self 参数。

import types
import inspect

class MultiMethod:
    '''
    Represents a single multimethod.
    '''
    def __init__(self, name):
        self._methods = {}  # 绑定参数类型与函数引用
        self.__name__ = name

    def register(self, meth):
        '''
        Register a new method as a multimethod
        '''
        sig = inspect.signature(meth)  # 用来获取函数的参数信息

        # Build a type-signature from the method's annotations
        types = []
        for name, parm in sig.parameters.items():
            if name == 'self':  # 忽视掉 self 参数
                continue
            if parm.annotation is inspect.Parameter.empty:  # 必须要有函数注解
                raise TypeError(
                    'Argument {} must be annotated with a type'.format(name)
                    )
            if not isinstance(parm.annotation, type):  # 函数注解必须要是类型
                raise TypeError(
                    'Argument {} annotation must be a type'.format(name)
                    )
            # 如果遇到有默认值的参数,那么在输入的时候不带这一项也可以
            # 因此每一个都要单独作为一个参数类型的入口
            if parm.default is not inspect.Parameter.empty:
                self._methods[tuple(types)] = meth
            # 因为不支持关键字参数,因此一旦传入了某个参数
            # 其前面所有参数都得传入
            types.append(parm.annotation)

        self._methods[tuple(types)] = meth

    def __call__(self, *args):
        '''
        Call a method based on type signature of the arguments
        这样创建实例之后实例直接就是一个可调用对象了。
        '''
        # 首先将传入参数转换为类型元组,忽视掉 self 参数
        types = tuple(type(arg) for arg in args[1:])
        # 然后通过元组来获取对应的方法引用
        meth = self._methods.get(types, None)
        # 找到了就表明是支持的参数列表
        if meth:
            return meth(*args)
        else:  # 否则类型就不对应
            raise TypeError('No matching method for types {}'.format(types))
        
    def __get__(self, instance, cls):
        '''
        Descriptor method needed to make calls work in a class
        这里主要是为了绑定 self,不然直接调用会提示少一个参数
        '''
        if instance is not None:
            return types.MethodType(self, instance)
        else:
            return self

定义元类

接下来就是要使用元类把上面的集成到类中,最好的方法就是在创建的时候能通过描述符来绑定方法。这可以通过元类中的 clsdict 来实现。本文中的实现方法是修改 clsdict 的行为,在绑定方法的时候合并同名不同参数的函数。

class MultiDict(dict):
    '''
    Special dictionary to build multimethods in a metaclass
    '''
    def __setitem__(self, key, value):
        if key in self:
            # If key already exists, it must be a multimethod or callable
            current_value = self[key]
            if isinstance(current_value, MultiMethod):
                # 某个名字的方法出现第三次,这次就直接注册了
                current_value.register(value)
            else:
                # 某个名字的方法出现第二次,那么首先先创建一个描述符
                mvalue = MultiMethod(key)
                # 分别注册这两个方法
                mvalue.register(current_value)
                mvalue.register(value)
                # 将描述符绑定到类上
                super().__setitem__(key, mvalue)
        else:  
            # 如果是第一次见到的,那直接设置属性
            # 因为这个时候不会出现同名方法
            super().__setitem__(key, value)

class MultipleMeta(type):
    '''
    Metaclass that allows multiple dispatch of methods
    '''
    def __new__(cls, clsname, bases, clsdict):
        # 这里的 clsdict 就是下面的 MultiDict
        # 在 __new__ 中会创建类,也就是会将方法绑定到 clsdict 中
        # 在绑定的时候,由于 clsdict 重写了 __setitem__
        # 因此不会直接绑定方法,而是会绑定到描述符类
        return type.__new__(cls, clsname, bases, dict(clsdict))

    @classmethod
    def __prepare__(cls, clsname, bases):
        # 这个函数在调用 __new__ 之前调用,返回一个映射对象
        return MultiDict()

使用

# Some example classes that use multiple dispatch
class Spam(metaclass=MultipleMeta):
    def bar(self, x:int, y:int):
        print('Bar 1:', x, y)
    def bar(self, s:str, n:int = 0):
        print('Bar 2:', s, n)

# Example: overloaded __init__
import time
class Date(metaclass=MultipleMeta):
    def __init__(self, year: int, month:int, day:int):
        self.year = year
        self.month = month
        self.day = day

    def __init__(self):
        t = time.localtime()
        self.__init__(t.tm_year, t.tm_mon, t.tm_mday)

s = Spam()
s.bar(2, 3)
s.bar('hello')
s.bar('hello', 5)
try:
    s.bar(2, 'hello')
except TypeError as e:
    print(e)

# Overloaded __init__
d = Date(2012, 12, 21)
print(d.year, d.month, d.day)
# Get today's date
e = Date()
print(e.year, e.month, e.day)

输出结果

Bar 1: 2 3
Bar 2: hello 0
Bar 2: hello 5
No matching method for types (<class 'int'>, <class 'str'>)
2012 12 21
2020 10 27

其他

>>> b = s.bar
>>> b  # 绑定方法
<bound method bar of <example1.Spam object at 0x10aa00d30>>
>>> b.__self__  # 类实例对象
<example1.Spam object at 0x10aa00d30>
>>> b.__func__  # 实际的函数与描述符绑定
<example1.MultiMethod object at 0x10aaa8850>

缺点

正如作者所说,这种实现方法还是存在很多问题的,比如不支持关键字参数,对于继承也支持有限。因此在 Python 中还是使用更简单的方法,比如取不同的名字来实现比较好,不然也违背了 Python 设计的初衷。

总结

描述符

描述符是 Python 中的一类对象,它重写了 __get__, __set__, __delete__ 中的一个或者多个。一般用于自定义的数据类型,可以在获取或者设置属性的时候加一些特殊的操作,比如类型检查、输出 log 等。在这里主要是通过 __call__ 来从输入参数映射到不同的方法。通常情况下描述符和装饰器是可以相互转换的,在这一节中也给出了示例:

class multimethod:
    def __init__(self, func):
        self._methods = {}
        self.__name__ = func.__name__
        self._default = func

    # 装饰器函数,这里传入函数不需要知道参数
    # 但是装饰器需要传入类别作为参数,因此只有两层
    def match(self, *types):
        def register(func):
            ndefaults = len(func.__defaults__) if func.__defaults__ else 0
            for n in range(ndefaults+1):
                # 目的和上面一样,也是为了处理默认参数
                self._methods[types[:len(types) - n]] = func
            return self
        return register

    def __call__(self, *args):
        # 还是要忽视掉 self 参数
        types = tuple(type(arg) for arg in args[1:])
        meth = self._methods.get(types, None)
        if meth:
            return meth(*args)
        else:
            return self._default(*args)
        
    def __get__(self, instance, cls):
        if instance is not None:
            return types.MethodType(self, instance)
        else:
            return self

# Example use
class Spam:
    # 相当于先初始化实例
    # 这里定义好像不能指定函数参数类别
    # 因此定义一个接受任意参数的函数作为缺省值来报错
    @multimethod  
    def bar(self, *args):
        # Default method called if no match
        raise TypeError('No matching method for bar')

    # 然后再通过类型进行绑定
    # 这里就不用手动从参数转化到类型了
    @bar.match(int, int)  
    def bar(self, x, y):
        print('Bar 1:', x, y)

    @bar.match(str, int)
    def bar(self, s, n = 0):
        print('Bar 2:', s, n)

元类

元类是用来创建类的,可以在其中定义创建类时候的各种操作,比如这里就修改了绑定方法的地方,不直接将方法绑定到类中,而是将方法绑定到描述符中,然后通过描述符再绑定到类中。