一个例子加深Python元类与描述符类理解
通过函数注解来实现方法重载
最近在看 《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)
元类
元类是用来创建类的,可以在其中定义创建类时候的各种操作,比如这里就修改了绑定方法的地方,不直接将方法绑定到类中,而是将方法绑定到描述符中,然后通过描述符再绑定到类中。