0%

流畅的 Python 第 2 版(24):类元编程

类元编程指在运行时创建或定制类的技艺。在 Python 中,类是一等对象,因此任何时候都可以使用函数新建类,无须使用 class 关键字。类装饰器也是函数,不过能够审查、修改,甚至把被装饰的类替换成另一个类。最后,元类是类元编程最高级的工具:使用元类可以创建具有某种特质的全新类,例如前面讲过的抽象基类。

这篇文章按照复杂度从易到难讲解类元编程技术。一般为了可读性和可维护性,或许应该避免在应用程序代码中使用这些技术。反之,如果想编写下一个引起轰动的 Python 框架,那么肯定离不开这些工具。

身为对象的类

与Python中的很多程序实体一样,类也是对象。Python 数据模型为每个类定义了很多属性:

  • cls.__bases__:由类的基类构成的元组
  • cls.__qualname__:类或函数的限定名称,即从模块的全局作用域到类的点分路径,在一个类中定义另一个类时会用到这个属性
  • cls.__subclasses__():这个方法会返回包含类的直接子类的列表,其实现使用弱引用,以防止在超类和子类之间出现循环引用(子类在__bases__属性中储存指向超类的强引用)​。这个方法返回的列表中是内存里现存的子类,不含尚未导入的模块中的子类
  • cls.mro() 构建类时,如果需要获取储存在类属性 __mro__ 中的超类元组,那么解释器就会调用这个方法。元类可以覆盖这个方法,定制要构建的类解析方法的顺序

既然类是对象,那么类的类是什么呢?

type:内置的类工厂函数

我们通常认为 type 是一个函数,会返回对象所属的类,即 type(my_object) 返回my_object.__class__。然而,type 是一个类,在调用时会传入 3 个参数,创建一个新类

例如对于如下简单类:

1
2
3
4
5
class MyClass(MySuperClass, MyMixin):
x = 42

def x2(self):
return self.x * 2

使用 type 构造函数,可以在运行时创建 MyClass 类:

1
2
3
4
MyClass = type('MyClass',
(MySuperClass, MyMixin),
{'x': 42, 'x2': lambda self: self.x * 2},
)

这个 type 调用与前面的 class MyClass... 语句块在功能上是等同的。Python 读取 class 语句时会调用 type 构建类对象,传入的参数如下所示:

  • name:class 关键字后的标识符,例如 MyClass
  • bases:类标识符后面圆括号内提供的超类元组,如果class语句没有提到超类,则为 (object,)
  • dict:属性名称到值的映射,可调用对象变成方法​,其他值变成类属性

type 构造函数还接受一些可选的关键字参数,type 自身忽略这些参数,但是会原封不动地传给__init_subclass__ 方法,该方法必须使用它们。

type 类是一个元类,即构建类的类。也就是说,type 类的实例还是类。标准库还提供了一些其他元类,不过 type 是默认的元类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> type(7)
<class 'int'>

>>> type(int)
<class 'type'>

>>> type(OSError)
<class 'type'>

>>> class Whatever:
... pass
...
>>> type(Whatever)
<class 'type'>

类工厂函数

我们已经多次使用了标准库中的一个类工厂函数 collections.namedtupletyping.NamedTuple@dataclass 都用到了这里的技术。

我们将构建一个特别简单的工厂函数,用于创建可变对象的类——算是 @dataclass 最简单的替代品。如下是一个例子:

1
2
3
4
5
class Dog:
def __init__(self, name, weight, owner):
self.name = name
self.weight = weight
self.owner = owner

这个代码里,各个字段名称写了 3 次。参考 collections.namedtuple,下面创建一个record_factory函数,即时创建 Dog 这种简单的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from typing import Union, Any
from collections.abc import Iterable, Iterator

FieldNames = Union[str, Iterable[str]]

def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]:

slots = parse_identifiers(field_names)

def __init__(self, *args, **kwargs) -> None:
attrs = dict(zip(self.__slots__, args))
attrs.update(kwargs)
for name, value in attrs.items():
setattr(self, name, value)

def __iter__(self) -> Iterator[Any]:
for name in self.__slots__:
yield getattr(self, name)

def __repr__(self):
values = ', '.join(f'{name}={value!r}'
for name, value in zip(self.__slots__, self))
cls_name = self.__class__.__name__
return f'{cls_name}({values})'

cls_attrs = dict(
__slots__=slots,
__init__=__init__,
__iter__=__iter__,
__repr__=__repr__,
)

return type(cls_name, (object,), cls_attrs)


def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
if isinstance(names, str):
names = names.replace(',', ' ').split()
if not all(s.isidentifier() for s in names):
raise ValueError('names must all be valid identifiers')
return tuple(names)
  • 用户可以使用一整个字符串或者产出字符串的可迭代对象提供字段名称
  • 使用属性名构建一个元组,这将成为新建类的 __slots__ 属性的值
  • 组建类属性字典后,调用 type 构造函数,构建新类,然后将其返回
  • 这里的返回类型注解为 type[tuple],表示返回的是一个类型(即 type 类型的实例,即某个类),而且返回的类是 tuple 的子类。

__init__subclass__

之前我们学习过,借助 typing.NamedTuple@dataclass,程序员可以使用 class 语句为新类指定属性,然后由类构建器自动添加 __init____repr____eq__ 等基本的方法,增强新类的功能。这两个类构建器均读取用户在 class 语句中添加的类型提示,以增强类的功能。静态类型检查工具还能通过那些类型提示验证用于设置或获取属性的代码。然而,NamedTuple 和 @dataclass 在运行时不能利用类型提示验证属性。

我们将创建一个 Checked 类型,使用方法类似于 typing.NamedTuple

1
2
3
4
5
6
7
8
9
10
>>> class Movie(Checked):
... title: str
... year: int
... box_office: float
...
>>> movie = Movie(title='The Godfather', year=1972, box_office=137)
>>> movie.title
'The Godfather'
>>> movie
Movie(title='The Godfather', year=1972, box_office=137.0)

属性的类型提示所使用的构造函数可以是任何可调用对象,该对象接受零个或一个参数,返回一个符合字段类型的值,或者抛出 TypeError 或 ValueError,拒绝传入的参数。上面例子中的类型注解都是内置类型,这意味着类型的构造函数必须能够接受提供的值,也就是说:

  • 对于 int,不管传入的 x 是什么,int(x)必须返回一个int值
  • 对于 str,运行时可接受任何值,因为在 Python 中,str(x) 可处理任何 x 值

如果调用时没有传入参数,那么构造函数应该返回相应类型的默认值。Python 内置类型构造函数的标准行为如下所示:

1
2
>>> int(), float(), bool(), str(), list(), dict(), set()
(0, 0.0, False, '', [], {}, set())

如下开始实现 Checked 类型,首先实现 Field 描述符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from collections.abc import Callable
from typing import Any, NoReturn, get_type_hints


class Field:
def __init__(self, name: str, constructor: Callable) -> None:
if not callable(constructor) or constructor is type(None):
raise TypeError(f'{name!r} type hint must be callable')
self.name = name
self.constructor = constructor

def __set__(self, instance: Any, value: Any) -> None:
if value is ...:
value = self.constructor()
else:
try:
value = self.constructor(value)
except (TypeError, ValueError) as e:
type_name = self.constructor.__name__
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
raise TypeError(msg) from e
instance.__dict__[self.name] = value
  • 使用内置函数 callable 做运行时检查,判断 constructor 是否可调用
  • 如果 Checked.__init__value 设为 ...(内置对象 Ellipsis)​,就调用无参数的 constructor
  • 否则,传入提供的 value,调用 constructor
  • 如果 constructor 抛出其中一个异常,那么我们就抛出 TypeError,并提供一条包含字段和构造函数名称的有用消息
  • 如果没有异常抛出,就把 value 存入 instance.__dict__

如下则是 Checked 类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Checked:
@classmethod
def _fields(cls) -> dict[str, type]:
return get_type_hints(cls)

def __init_subclass__(subclass) -> None:
super().__init_subclass__()
for name, constructor in subclass._fields().items():
setattr(subclass, name, Field(name, constructor))

def __init__(self, **kwargs: Any) -> None:
for name in self._fields():
value = kwargs.pop(name, ...)
setattr(self, name, value)
if kwargs:
self.__flag_unknown_attrs(*kwargs)

def __setattr__(self, name: str, value: Any) -> None:
if name in self._fields():
cls = self.__class__

descriptor = getattr(cls, name)
descriptor.__set__(self, value)
else:
self.__flag_unknown_attrs(name)

def __flag_unknown_attrs(self, *names: str) -> NoReturn:
plural = 's' if len(names) > 1 else ''
extra = ', '.join(f'{name!r}' for name in names)
cls_name = repr(self.__class__.__name__)
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

def _asdict(self) -> dict[str, Any]:
return {
name: getattr(self, name)
for name, attr in self.__class__.__dict__.items()
if isinstance(attr, Field)
}

def __repr__(self) -> str:
kwargs = ', '.join(
f'{key}={value!r}' for key, value in self._asdict().items()
)
return f'{self.__class__.__name__}({kwargs})'
  • __init_subclass__ 方法在定义当前类的子类时调用。该方法的第一个参数是新定义的子类,因此把参数命名为 subclass,而不是以往的 cls
  • 迭代各个字段的 name 和 constructor,在 subclass 上创建一个名为 name 的属性,绑定以 name 和 constructor 参数化的 Field 描述符。这段代码完成了子类中各个描述符(类属性)的设置
  • __init__ 方法中,遍历类字段中的各个 name,从 kwargs 中获取对应的 value,并把 value 从kwargs 中删除。...(Ellipsis 对象)作为 value 的默认值是为了区分传入 None 值和未提供值两种情况
  • setattr 调用触发 Checked.__setattr__
  • __setattr__ 方法中,截获一切设置实例属性的操作:
    • 为了避免设置未知属性,需要这个方法。如果属性名称是已知的,就获取对应的描述符
    • 一般无须显式调用描述符的 __set__ 方法。但是这里需要,因为一切设置实例属性的操作均被__setattr__ 方法截获了,即使存在诸如 Field 之类的覆盖型描述符
    • 否则,属性名称是未知的,由 __flag_unknown_attrs 抛出异常

__init_subclass__ 不需要使用 @classmethod 装饰器,这没什么好意外的,特殊方法 __new__ 也不用 @classmethod 装饰器,但是它的行为与类方法一样。Python 传给 __init_subclass__ 方法的第一个参数是一个类,但不是实现 __init_subclass__ 方法的类,而是那个类的子类。Python 只在已经构建类之后调用 __init_subclass__ 方法。

Checked 演示了当实现阻碍实例化之后随意设置属性的 __setattr__ 方法时如何处理覆盖型描述符。对于这个示例,是否需要实现 __setattr__ 方法,存在争议。如果不实现,那么 movie.director = 'Greta Gerwig' 可以成功,但是 director 属性无法以任何形式检查,而且不会出现在字符串表示形式中,也不会出现在 _asdict 返回的字典中。

使用类装饰器增强类的功能

类装饰器是一种可调用对象,行为与函数装饰器类似:以被装饰的类为参数,返回一个类,取代被装饰的类。类装饰器通常返回被装饰的类,不过会通过属性赋值注入更多方法。选择使用类装饰器,最常见的原因应该是想避免妨碍其他类功能,例如继承和元类。

如下以类装饰器的方式实现于 Checked 相似的功能,使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
>>> @checked
... class Movie:
... title: str
... year: int
... box_office: float
...
>>> movie = Movie(title='The Godfather', year=1972, box_office=137)
>>> movie.title
'The Godfather'
>>> movie
Movie(title='The Godfather', year=1972, box_office=137.0)
  • 这里使用 @checked 装饰,而不是子类化 Checked,除此之外,对外行为是一致的

如下是类装饰器 checked 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def checked(cls: type) -> type:
for name, constructor in _fields(cls).items():
setattr(cls, name, Field(name, constructor))

cls._fields = classmethod(_fields) # type: ignore

instance_methods = (
__init__,
__repr__,
__setattr__,
_asdict,
__flag_unknown_attrs,
)
for method in instance_methods:
setattr(cls, method.__name__, method)

return cls
  • 注意,类是 type 的实例。这些类型提示充分表明这是一个类装饰器:接受一个类,返回一个类
  • _fields 构建为类方法,添加到被装饰的类中
  • 其他方法则作为被装饰类的实例方法

__fields__init__ 这些方法的实现与之前在 Checked 类中实现的一样,这里就不再展示。

导入时和运行时比较

对类做元编程时,必须知晓在构造类的过程中 Python 解释器何时求解各个代码块。Python程序员会区分 导入时运行时​,不过这两个术语没有严格的定义,而且二者之间存在灰色地带。

在导入时,解释器执行以下操作:

  • 从上到下一次性解析完 .py 模块的源码。此时可能抛出 SyntaxError
  • 编译生成用于执行的字节码
  • 执行编译后的模块中的顶层代码

如果本地的 __pycache__ 文件夹中有最新的 .pyc 文件,则解释器会跳过解析和编译步骤,因为已经有供运行的字节码了。

解析和编译肯定是 导入时 活动,不过那个时期还会做些其他事,因为Python中的语句大部分是可执行的,也就是说语句可能会运行用户代码,修改用户程序的状态。需要特别注意的是:

  • import语句不只是声明而已,首次把模块导入进程时,所导入模块中的全部顶层代码都将运行
  • 后续再导入相同的模块将使用缓存,唯一需要做的事情是把导入的对象绑定到客户模块中的名称上
  • 顶层代码可以做任何事,包括通常在 运行时 做的事,例如写入日志或连接数据库

因此,导入时运行时 之间的界线是模糊的:import 语句可以触发各种 运行时 行为。反过来,​导入时 也可能深埋在运行时中,因为任何常规函数中都可以使用 import 语句和内置函数 __import__()

下面将通过一个例子说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
print('@ builderlib module start')

class Builder:
print('@ Builder body')

def __init_subclass__(cls):
print(f'@ Builder.__init_subclass__({cls!r})')

def inner_0(self):
print(f'@ SuperA.__init_subclass__:inner_0({self!r})')

cls.method_a = inner_0

def __init__(self):
super().__init__()
print(f'@ Builder.__init__({self!r})')


def deco(cls):
print(f'@ deco({cls!r})')
def inner_1(self):
print(f'@ deco:inner_1({self!r})')

cls.method_b = inner_1
return cls

class Descriptor:
print('@ Descriptor body')

def __init__(self):
print(f'@ Descriptor.__init__({self!r})')

def __set_name__(self, owner, name):
args = (self, owner, name)
print(f'@ Descriptor.__set_name__{args!r}')

def __set__(self, instance, value):
args = (self, instance, value)
print(f'@ Descriptor.__set__{args!r}')

def __repr__(self):
return '<Descriptor instance>'


print('@ builderlib module end')

直接在 Python 控制台中导入这个模块:

1
2
3
4
5
6
>>> import buildlib
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end

接下来则编写一个 Python 脚本,触发 builderlib.py 模块中的特殊方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/env python3

from builderlib import Builder, deco, Descriptor

print('# evaldemo module start')

@deco
class Klass(Builder):
print('# Klass body')

attr = Descriptor()

def __init__(self):
super().__init__()
print(f'# Klass.__init__({self!r})')

def __repr__(self):
return '<Klass instance>'

print('# Klass body end')


def main():
obj = Klass()
obj.method_a()
obj.method_b()
obj.attr = 999

if __name__ == '__main__':
main()

print('# evaldemo module end')

接下来在控制台中导入这个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import evaldemo
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
# evaldemo module start
# Klass body
@ Descriptor.__init__(<Descriptor instance>)
# Klass body end
@ Descriptor.__set_name__(<Descriptor instance>, <class 'evaldemo.Klass'>, 'attr')
@ Builder.__init_subclass__(<class 'evaldemo.Klass'>)
@ deco(<class 'evaldemo.Klass'>)
# evaldemo module end
  • 在开始定义 main 函数前,Python 内置的 type.__new__ 方法已经创建 Klass 类型对象,这就可以在描述符类的各个实例上调用 __set_name__ ,把 Klass 传给 owner 参数
  • 然后,type.__new__ 在 Klass的超类上调用 __init_subclass__,传入的第一个参数是 Klass
  • type.__new__ 返回类对象后,Python 应用装饰器。在这个示例中,deco 返回的类绑定到模块命名空间中的 Klass 上

以上操作都是在 导入时 触发的。如果直接运行 evaldemo.py 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
# evaldemo module start
# Klass body
@ Descriptor.__init__(<Descriptor instance>)
# Klass body end
@ Descriptor.__set_name__(<Descriptor instance>, <class '__main__.Klass'>, 'attr')
@ Builder.__init_subclass__(<class '__main__.Klass'>)
@ deco(<class '__main__.Klass'>)
@ Builder.__init__(<Klass instance>)
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)
@ deco:inner_1(<Klass instance>)
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)
# evaldemo module end

实现了 __init_subclass__ 的基类和类装饰器都是强大的工具,但是仅适用于背后已由 type.__new__ 构建的类。如果想调整传给 type.__new__ 的参数(十分少见),则需要使用元类。

元类

元类是制造类的工厂,不过不是函数​,而是类。也就是说,元类也是类,其实例还是类。根据 Python 对象模型,类是对象,因此类肯定是另外某个类的实例。默认情况下,Python 中的类是 type 的实例。也就是说,type 是大多数内置的类和用户定义的类的元类。

1
2
3
4
5
6
7
8
9
10
>>> str.__class__
<class 'type'>

>>> class A: pass
...
>>> A.__class__
<class 'type'>

>>> type.__class__
<class 'type'>

为了避免无限回溯,type 的类是其自身。注意,并不是说 str 或自定义类 A 是 type 的子类。str 和 A 是 type 的实例,这两个类是 object 的子类。

  • object 类和 type 类之间的关系很独特:object 是 type 的实例,而 type 是 object 的子类

以下代码片段表明

1
2
3
4
5
6
7
>>> from collections.abc import Iterable
>>> Iterable.__class__
<class 'abc.ABCMeta'>
>>> import abc
>>> from abc import ABCMeta
>>> ABCMeta.__class__
<class 'type'>
  • collections.Iterable 所属的类是 abc.ABCMeta。注意,Iterable 是抽象类, 而 ABCMeta 是具体类——然而,Iterable 是 ABCMeta 的实例

  • 向上追溯,ABCMeta 最终所属的类也是 type

  • 所有类都直接或间接地是 type 的实例,不过只有元类同时也是 type 的子类

如果想理解元类,那么一定要知道这种关系:元类(例如 ABCMeta)从 type 类继承了构造类的能力

元类是 type 的子类,因此可以作为制造类的工厂。元类通过实现特殊方法定制实例。

元类如何定制类

使用元类之前,务必理解 __new__ 方法对一个类的作用。元类创建的实例是类,因此同样的机制也适用于 层。以下面的声明为例:

1
2
3
4
class Klass(SuperKlass, metaclass=MetaKlass):
x = 42
def __init__(self, y):
self.y = y

为了处理这个 class 语句,Python 调用 MetaKlass.__new__(因为 Klass 的元类是 MetaKlass),传入以下参数

  • meta_cls:元类自身(MetaKlass),因为 __new__ 被当作类方法使用
  • cls_name:字符串 Klass
  • bases 只有一个元素的元素(SuperKlass,),如果是多重继承则有多个元素
  • cls_dict 类似下面的映射
1
{x: 42, `__init__`: <function __init__ at 0x1009c4040>}

实现 MetaKlass.__new__ 时,可以对参数进行审查和修改,然后传给 super().__new__,最终调用 type._new__ 创建新的类对象。super().__new__ 返回之后,还可以进一步处理新创建的类,返回给 Python。

随后,Python调用 SuperKlass.__init_subclass__,传入新创建的类,如果有类装饰器的话,还会应用类装饰器。最后,Python 把类对象绑定给所在命名空间中的名称:class 语句是顶层语句时,所在的命名空间通常是模块全局命名空间。

在元类的 __new__ 方法中,最常执行的操作是向 cls_dict 中添加项,或者替换其中的项。cls_dict 是一个映射,表示待构造的类的命名空间:

  • 例如,调用 super().__new__ 之前,可以向 cls_dict 中添加函数,为待构造的类注入方法
  • 然而,请注意,方法也可以在构建类之后添加,不然 __init_subclass__类装饰器 就失去存在意义了
  • 有一个属性必须在运行 type.__new__ 之前添加到 cls_dict 中,即 __slots____init_subclass__ 和类装饰器均不能动态配置 __slots__,因为它们只在创建类之后发挥作用,此时无法再设置 __slots__ 属性了

一个友好的元类示例

接下来我们基于元类实现一个数据类基类 Bunch,它的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
>>> class Point(Bunch):
... x = 0.0
... y = 0.0
... color = 'gray'
...
>>> Point(x=1.2, y=3, color='green')
Point(x=1.2, y=3, color='green')
>>> p = Point()
>>> p.x, p.y, p.color
(0.0, 0.0, 'gray')
>>> p
Point()

Bunch 的子类中包含的是类属性(相比于之前 Checked 的子类中包含的只是类型注解,因为没有设置值),类属性是有值的,这些值将变成实例属性的默认值。

MetaBunch(Bunch的元类)根据用户在自己定义的类中声明的类属性为新类生成 __slots__。如此一来,实例化和后续赋值都不能使用未声明的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MetaBunch(type):
def __new__(meta_cls, cls_name, bases, cls_dict):

defaults = {}

def __init__(self, **kwargs):
for name, default in defaults.items():
setattr(self, name, kwargs.pop(name, default))
if kwargs:
extra = ', '.join(kwargs)
raise AttributeError(f'No slots left for: {extra!r}')

def __repr__(self):
rep = ', '.join(f'{name}={value!r}'
for name, default in defaults.items()
if (value := getattr(self, name)) != default)
return f'{cls_name}({rep})'

new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__)

for name, value in cls_dict.items():
if name.startswith('__') and name.endswith('__'):
if name in new_dict:
raise AttributeError(f"Can't set {name!r} in {cls_name!r}")
new_dict[name] = value
else:
new_dict['__slots__'].append(name)
defaults[name] = value
return super().__new__(meta_cls, cls_name, bases, new_dict)


class Bunch(metaclass=MetaBunch):
pass
  • 继承 type,创建新元类
  • __new__ 被当作类方法使用,但是我们是在元类中定义,因此可以把第一个参数命名为 meta_cls(也常用mcs)。余下 3 个参数与直接调用 type() 创建类时传入的 3 个参数一样
  • defaults 用于存放属性名称到默认值的映射
  • 使用 new_dict 构造类的新命名空间,设置 __slots____init____repr__ 等方法
  • 迭代用户定义的类的命名空间,如果 name 是带双下划线的名称,就把对应的项复制到新类的命名空间中,除非该项已经存在。如果 name 不是带双下划线的名称,就追加到 __slots__ 中,并把对应的值存入 defaults
  • 最后通过 super().__new__ 创建新类

为了让用户无需关注 MetaBunch,定义基类 Bunch 类,用户只需要继承 Bunch 类即可。

MetaBunch 行之有效,因为它能在调用 super().__new__ 构建最终的类之前配置 __slots__。做元编程时,一定要理解各个操作的执行顺序。

元类求解时间实验

接下来继续通过之前的实验例子来理解元类的求解时间,现在 Klass 的元类设置为了 MetaKlass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
print('% metalib module start')

import collections

class NosyDict(collections.UserDict):
def __setitem__(self, key, value):
args = (self, key, value)
print(f'% NosyDict.__setitem__{args!r}')
super().__setitem__(key, value)

def __repr__(self):
return '<NosyDict instance>'

class MetaKlass(type):
print('% MetaKlass body')

@classmethod
def __prepare__(meta_cls, cls_name, bases):
args = (meta_cls, cls_name, bases)
print(f'% MetaKlass.__prepare__{args!r}')
return NosyDict()

def __new__(meta_cls, cls_name, bases, cls_dict):
args = (meta_cls, cls_name, bases, cls_dict)
print(f'% MetaKlass.__new__{args!r}')
def inner_2(self):

print(f'% MetaKlass.__new__:inner_2({self!r})')

cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data)

cls.method_c = inner_2

return cls

def __repr__(cls):
cls_name = cls.__name__
return f"<class {cls_name!r} built by MetaKlass>"

print('% metalib module end')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3

from builderlib import Builder, deco, Descriptor
from metalib import MetaKlass

print('# evaldemo_meta module start')

@deco
class Klass(Builder, metaclass=MetaKlass):
print('# Klass body')

attr = Descriptor()

def __init__(self):
super().__init__()
print(f'# Klass.__init__({self!r})')

def __repr__(self):
return '<Klass instance>'


def main():
obj = Klass()
obj.method_a()
obj.method_b()
obj.method_c()
obj.attr = 999


if __name__ == '__main__':
main()

print('# evaldemo_meta module end')
  • 这个元类 MetaKlass 实现了特殊方法 __prepare__,这是 Python 只在元类上调用的一个类方法。__prepare__ 方法是影响创建新类过程最早的机会
  • __prepare__ 应声明为类方法。__prepare__ 不是实例方法,因为当 Python 调用它时待构造的类尚不存在
  • Python 在元类上调用 __prepare__ 方法,获取存储待构造的类的命名空间的映射
  • __prepare__ 方法使用 NosyDict 实例存放待构造类的命名空间,进一步揭示 Python 的内部机制。因此 cls_dict 参数的值是 __prepare__ 方法返回的 NosyDict 实例
  • type.__new__ 的最后一个参数必须是真正的字典,因此传入 NosyDictUserDict 继承的 data 属性
  • 在元类中定义 __repr__ 方法,方便定制类对象的字符串表示形式
  • 编写元类时,采用以下约定命名特殊方法的参数很有用:
    • 实例方法中的 self 换成 cls,因为得到的实例是类
    • 类方法中的 cls 换成 meta_cls,因为定义的类是元类。注意,即使没有 @classmethod 装饰器,__new__ 的行为也等同类方法

现在在控制台中使用 evaldemo_meta.py 做实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>>> import evaldemo_meta
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
% metalib module start
% MetaKlass body
% metalib module end
# evaldemo_meta module start
% MetaKlass.__prepare__(<class 'metalib.MetaKlass'>, 'Klass', (<class 'builderlib.Builder'>,))
% NosyDict.__setitem__(<NosyDict instance>, '__module__', 'evaldemo_meta')
% NosyDict.__setitem__(<NosyDict instance>, '__qualname__', 'Klass')
# Klass body
@ Descriptor.__init__(<Descriptor instance>)
% NosyDict.__setitem__(<NosyDict instance>, 'attr', <Descriptor instance>)
% NosyDict.__setitem__(<NosyDict instance>, '__init__', <function Klass.__init__ at 0x7fa4412e7880>)
% NosyDict.__setitem__(<NosyDict instance>, '__repr__', <function Klass.__repr__ at 0x7fa4412e7ba0>)
% NosyDict.__setitem__(<NosyDict instance>, '__classcell__', <cell at 0x7fa4412d6890: empty>)
% MetaKlass.__new__(<class 'metalib.MetaKlass'>, 'Klass', (<class 'builderlib.Builder'>,), <NosyDict instance>)
@ Descriptor.__set_name__(<Descriptor instance>, <class 'Klass' built by MetaKlass>, 'attr')
@ Builder.__init_subclass__(<class 'Klass' built by MetaKlass>)
@ deco(<class 'Klass' built by MetaKlass>)
# evaldemo_meta module end
  • Python 调用 __prepare__,开始处理 class 语句
  • 解析类主体之前,Python把 __module____qualname__ 添加到待构造类的命名空间中
  • 解析类主体
  • 将 attr、__init____repr__ 添加到命名空间
  • 处理完类主体之后,Python 调用 MetaKlass.__new__
  • 元类的 __new__ 方法返回新构造的类之后,依序调用 __set_name____init_subclass__ 和装饰器
  • 元类的 __prepare__ 方法和 __new__ 方法均在__init_subclass__和类装饰器之前调用,这为深入定制类提供了机会(例如设置 __slots__ 属性)

我们已经在这篇文章的 Klass 上运用了 3 种不同的元编程技术:一个装饰器、一个使用 __init_subclass__ 实现的基类和一个自定义的元类,从而展示这些元编程技术的执行顺序。

使用元类实现 Checked 类

接下来我们将使用元类来实现 Checked 子类,它的用法如下:

1
2
3
4
5
6
7
8
9
10
11
from checkedlib import Checked

class Movie(Checked):
title: str
year: int
box_office: float

if __name__ == '__main__':
movie = Movie(title='The Godfather', year=1972, box_office=137)
print(movie)
print(movie.title)
  • Movie 类继是 Checked 的子类,由于 Checked 子类是 CheckedMeta 的实例,因此也可以认为 Movie 类也是 CheckedMeta 的实例
  • Movie 的类属性 title、year 和 box_office 是 3 个独立的Field实例
  • 每个 Movie 实例都有自己的 _title 属性、_year 属性和 _box_office 属性,存储相应字段的值

首先展示描述符类 Field 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Field:
def __init__(self, name: str, constructor: Callable) -> None:
if not callable(constructor) or constructor is type(None):
raise TypeError(f'{name!r} type hint must be callable')
self.name = name
self.storage_name = '_' + name
self.constructor = constructor

def __get__(self, instance, owner=None):
if instance is None:
return self
return getattr(instance, self.storage_name)

def __set__(self, instance: Any, value: Any) -> None:
if value is ...:
value = self.constructor()
else:
try:
value = self.constructor(value)
except (TypeError, ValueError) as e:
type_name = self.constructor.__name__
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
raise TypeError(msg) from e
setattr(instance, self.storage_name, value)
  • 在前面的示例中,各个 Field 描述符实例把值存在托管实例的同名属性中。例如,在 Movie 类中,title 描述符把字段的值存在托管实例的 title 属性中。因此,Field 不能提供 __get__ 方法(在 __get__ 方法内直接使用 instance.__dict__ 访问同名实例属性是否可行?)
  • 然而,使用 __slots__ 之后,对于 Movie 这样的类,类属性和实例属性不能同名。每个描述符实例都是一个类属性,而为了让各个实例单独存储属性,选择在托管实例的属性名前加上 _ 作为前缀

元类的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CheckedMeta(type):

def __new__(meta_cls, cls_name, bases, cls_dict):
if '__slots__' not in cls_dict:
slots = []
type_hints = cls_dict.get('__annotations__', {})
for name, constructor in type_hints.items():
field = Field(name, constructor)
cls_dict[name] = field
slots.append(field.storage_name)

cls_dict['__slots__'] = slots

return super().__new__(
meta_cls, cls_name, bases, cls_dict)
  • __new__ 是 CheckedMeta 唯一实现的方法
  • 仅当类的 cls_dict 中不含 __slots__ 时才增强类的功能。如果存在 __slots__,则认为是 Checked 基类本身
  • 为了获取类型提示,之前使用的是 typing.get_type_hints,但是该函数的第一个参数必须是现有的类。现在,我们配置的类尚不存在,因此需要直接从 cls_dict 中获取 __annotations__
  • cls_dict 是待构造类的命名空间,Python 把它作为最后一个参数传给元类的 __new__ 方法
  • 为每个带注解的属性构建一个 Field 实例
  • 同时把字段的 storage_name 追加到一个列表中,用于填充 cls_dict(待构造类的命名空间)中的 __slots_
  • 最后,调用 super().__new__

最后定义 Checked 基类,库的用户通过子类化该基类增强自己定义的类,例如 Movie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Checked(metaclass=CheckedMeta):
__slots__= () # 跳过CheckedMeta.__new__的处理

@classmethod
def _fields(cls) -> dict[str, type]:
return get_type_hints(cls)

def __init__(self, **kwargs: Any) -> None:
for name in self._fields():
value = kwargs.pop(name, ...)
setattr(self, name, value)
if kwargs:
self.__flag_unknown_attrs(*kwargs)

def __flag_unknown_attrs(self, *names: str) -> NoReturn:
plural = 's' if len(names) > 1 else ''
extra = ', '.join(f'{name!r}' for name in names)
cls_name = repr(self.__class__.__name__)
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

def _asdict(self) -> dict[str, Any]:
return {
name: getattr(self, name)
for name, attr in self.__class__.__dict__.items()
if isinstance(attr, Field)
}

def __repr__(self) -> str:
kwargs = ', '.join(
f'{key}={value!r}' for key, value in self._asdict().items()
)
return f'{self.__class__.__name__}({kwargs})'
  • 添加一个空的 __slots__ 属性,告诉 CheckedMeta.__new__,这个类不需要特殊处理
  • 无需 __setattr__。为用户定义的类添加 __slots__ 之后,无法设置未声明的属性了,因此这个方法也就多余了

元类的实际运用

元类功能强大,想正确使用却不容易。着手实现元类之前,请考虑以下几点。

  • 随着新语言功能的推出,元类的多个常见用途已显多余:

    • 类装饰器:比元类更易于理解,而且导致基类与元类产生冲突的可能性更小
    • __set__name__:无须自定义元类逻辑就能自动设置描述符的名称
    • __init__subclass__:提供一种自定义类创建过程的方式,对终端用户透明,而且比装饰器更简单,但是遇到复杂的类层次结构可能产生冲突
    • 内置的 dict 保留键的插入顺序:不使用 __prepare__ 的首要原因。以前定义 __prepare__ 是为了使用 OrderedDict 存储待构造类的命名空间
  • 一个类只能有一个元类

  • 元类应作为实现细节

    • 除了 type,整个 Python 3.9 标准库中仅有 6 个元类。最为人熟知的元类应该是 abc.ABCMetatyping.NamedTupleMetaenum.EnumMeta
    • 但是,在用户的代码中,不应该显式使用其中任何一个。应把元类当作实现细节
    • 尽管使用元类可以做一些古怪的元编程,但是最好严守 最小惊讶 原则,让大多数用户真正把元类当作实现细节
    • 为了不让对外的 API 过时,最简单的方法是提供一个常规的类供用户子类化,让用户使用元类提供的功能,就像前面例子中那样