0%

流畅的 Python(12):特性、描述符和元编程

在 Python 中,数据属性和处理数据的方法统称为属性(attribute)。其实方法只是可调用的属性。除了这二者外,还可以创建特性(property),在不改变类接口的前提下,使用存取方法(即读值和设值方法)修改数据属性。这与统一访问原则相符:不管服务是由存储和计算实现的,一个模块提供的所有服务都应该通过统一的方式使用。

除了特性之外,Python 还提供丰富的 API,用于控制属性的访问权限,以实现动态属性。使用点号访问属性时(例如 obj.attr),Python 会调用特殊的方法(如 __getattr____setattr__)计算属性。用户自己定义的类可以通过 __getattr__ 方法实现虚拟属性:当访问不存在的属性时,即时计算属性的值。

使用动态属性转换数据

接下来实现一个 FrozenJSON 类,它可以以 obj.attr 的方式访问 Json 对象中的某个元素

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
#!/usr/bin/env python3

# Copyright (C) fuchencong.com

from collections import abc


class FrozenJSON:
def __init__(self, mapping):
self.__data = dict(mapping)

def __getattr__(self, name):
if hasattr(self.__data, name):
return getattr(self.__data, name)
else:
return FrozenJSON.build(self.__data[name])

@classmethod
def build(cls, obj):
if isinstance(obj, abc.Mapping):
return cls(obj)
elif isinstance(obj, abc.Sequence):
return [cls.build(item) for item in obj]
else:
return obj
1
2
3
4
>>> import frozen_json
>>> p = frozen_json.FrozenJSON({'age': 20})
>>> p.age
20
  • FronzenJSON 类只有两个方法(__init____getattr__)和一个实例属性(__data)。因此尝试获取其他属性会触发解释器调用 __getattr__ 方法。
  • __getattr__ 方法首先查看 __data 字典有没有指定名称的属性(而不是键),这样 FronzenJSON 实例便可以处理字典的所有方法
  • 如果 __data 没有指定名称的属性,那么 __getattr__ 方法以该名称为键,从 self.__data 中获取元素

FrozenJSON 类有一个缺陷:没有对名称为 Python 关键字的属性做特殊处理。可以在 __init__ 函数中判断 key 是否为关键字,如果是关键字,为其添加 _ 后缀:

1
2
3
4
5
6
def __init__(self, mapping):
self.__data = {}
for key, value in mapping.items():
if key.iskeyword(key):
key += '_'
self.__data[key] = value

如果 JSON 对象中的键不是有效的 Python 标识符,也会遇到类似的问题。这种问题的键在 Python 中易于检测,因为 str 类提供的 s.isidentifier() 方法能根据语言的语法来判断 s 是否有效的 Python 标识符。

通常把 __init__ 称为构造方法,这是从其他语言借鉴过来的术语。其实用于构建实例的是特殊方法 __new__:它是一个类方法(使用特殊方式处理,因此不必使用 @classmethod 装饰器),必须返回一个实例。返回的实例会作为第一个参数(即 self)传给 __init__ 方法。所以 __init__ 方法其实是初始化方法,真正的构造方法是 __new__。我们几乎不需要自己编写 __new__ 方法,因为从 object 类继承的实现已经足够了。其实 __new__ 方法也可以返回其他类的实例,此时解释器不会调用 __init__ 方法。

如下把类方法 build 中的逻辑移到 __new__ 中了:

1
2
3
4
5
6
7
def __new__(cls, arg):
if isinstance(arg, abc.Mapping):
return super().__new__(cls)
elif isinstance(arg, abc.MutableSequence):
return [cls.build(item) for item in arg]
else:
return arg

默认的行为是委托给超类的 __new__ 方法,这里调用的是 object 基类的 __new__ 方法,把唯一的参数设置为 FrozenJSON。

另外介绍一个流行的 Python 技巧。对象的 __dict__ 属性存储着对象的属性(前提是类中没有声明 slots 属性)。因此更新实例的 __dict__ 属性,把值设置为一个映射,能快速地在该实例中创建一堆属性。

使用特性验证属性

目前为止我们只介绍了使用 @property 装饰器实现只读属性。如下实现了一个 LineItem 类:

1
2
3
4
5
6
7
8
class LineItem:
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price

这个类有一个问题,用户可以随意设置 weight 属性,例如将其设置为负数,这显然是不合理的。要解决该问题,一种方法是修改 LineItem 类的接口,使用读值和设值方法管理 weight 属性。但是如果能直接设置商品的 weight 属性,显得更自然,或者代码中已经使用 item.weight 访问该属性了,因此符合 Python 风格的做法是:把数据属性设置为特性。

实现特性之后,可以使用读值和设值方法,但是 LineItem 类的使用方式不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class LineItem:
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price

@property
def weight(self):
return self._weight

@weight.setter
def weight(self, value):
if value > 0:
self._weight = value
else:
raise ValueError('value must be > 0')
  • @property 装饰读值方法。被装饰的读值方法有一个 .setter 属性,它也是装饰器,该装饰器把读值方法和设值方法绑定在一起。设值方法用来确保所创建实例的 weight 属性不能负值
  • 另外在 __init__ 函数中设置 weight 属性时,特性的设值方法已经生效了

特性全面解析

虽然内置的 propery 经常用作装饰器,但它其实是一个类。在 Python 中,函数和类可以实现互换,因为二者都是可调用的对象。只要能返回新的可调用对象,代替被装饰的函数,二者都可以用作装饰器。

property 构造方法的完整签名如下:

1
property(fget=None, fset=None, fdel=None, doc=None)

所以,如果不使用装饰器定义特性,其方法为:

1
2
3
4
5
6
7
8
9
10
def get_weight(self):
return self._weight

def set_weight(self, value):
if value > 0:
self._weight = value
else:
raise ValueError('value must be > 0')

weight = property(get_weight, set_weight)

这里构建 property 对象,然后赋值给公开的类属性。

特性都是类属性,但是特性管理的其实是实例属性的存取。之前介绍过,如果实例和所属的类有同名数据属性,那么实例属性会覆盖(或称为遮盖)类属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> class Class:
... data = 'the class data attr'
... @property
... def prop(self):
... return 'the prop value'
...
>>> obj = Class()
>>> vars(obj)
{}
>>> obj.data
'the class data attr'
>>> obj.data = 'bar'
>>> vars(obj)
{'data': 'bar'}
>>> obj.data
'bar'
>>> Class.data
'the class data attr'

这个例子使用 vars 函数返回 obj 的 __dict__ 属性。如果尝试覆盖 obj 实例的 prop 特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> Class.prop
<property object at 0x10dc65ea0>
>>> obj.prop
'the prop value'
>>> obj.prop = 'foo'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo'
>>> vars(obj)
{'data': 'bar', 'prop': 'foo'}
>>> obj.prop
'the prop value'
>>> Class.prop = 'baz'
>>> obj.prop
'foo'

可以看到,实例属性不会覆盖类特性。因为即使为实例添加了和特性同名的属性,obj.prop 执行时仍然会运行特性的读值方法。特性没有被实例属性覆盖。如下例子为 Class 类显式添加特性:

1
2
3
4
5
6
7
8
9
10
>>> obj.data
'bar'
>>> Class.data
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" prop value')
>>> obj.data
'the "data" prop value'
>>> del Class.data
>>> obj.data
'bar'
  • obj.data 第一次获取的实例属性
  • Class.data 获取的是类属性
  • 之后使用新特性覆盖 Class.data,之后 obj.data 都会被 Class.data 特性所覆盖
  • 删除特性之后,恢复原样

因此需要注意,obj.attr 这样的表达式不会从 obj 开始寻找 attr,而是从 obj.__class__ 开始。仅当类中没有名为 attr 的特性时,Python 才会在 obj 实例中寻找(__getattribute__ 实现了这些逻辑)。这条规则不仅适用于特性,还适用于一整类描述符:即覆盖型描述符。特性本质上也是覆盖型描述符

当需要显式特性的文档时,会从特性的 __doc__ 属性中提取信息。如果使用经典调用语法,为 property 对象设置文档字符串的方法是传入 doc 参数。使用装饰器创建 propery 对象时,读值方法(有 @property 装饰器的方法)的文档字符串作为一个整体,变成特性的文档。

定义一个特性工厂函数

接下来定义一个 quantity 的特性工厂函数,而新版的 LineItem 类使用这个 quantity 工厂函数创建两个特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def quantity(storage_name):
def qty_getter(instance):
return instance.__dict__[storage_name]

def qty_setter(instance, value):
if value > 0:
instance.__dict__[storage_name] = value
else:
raise ValueError('value must be > 0')
return property(qty_getter, qty_setter)


class LineItem:
weight = quantity('weight')
price = quantity('price')

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price
  • LineItem 类使用 quantity 工厂函数创建了两个自定义的特性:weight 和 price
  • 特性是类属性,用于管理实例的属性。因此需要传入 LineItem 实例属性的名称,让特性管理
  • 而在工厂函数 quantity 的定义中,getter 和 setter 的第一个参数 instance 表示所需要管理的实例。而且是从 instance.__dict__ 中获取实例属性,目的是为了跳过特性,防止无限递归

在真实的系统中,分散在多个类中的多个字段可能要做同样的验证。此时最好把 quantity 工厂函数放在实用工具模块中,以便重复使用。最终可能要重构那个简单的工厂函数,改成更易扩展的描述符类,然后使用专门的子类执行不同的验证。

处理属性删除操作

对象的属性可以使用 del 语句删除。使用 Python 编程时不常删除属性,通过特性删除属性则更少见。但是 Python 支持这么做:定义特性时,可以使用 @my_property.deleter 装饰器包装一个方法,负责删除特性管理的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BlackKnight:
def __init__(self):
self.members = ['an arm', "another arm",
'a leg', 'another leg']
self.phrases = ['Tis but a scratch.',
"It's just a flesh world",
"I'm invincible!",
"All right, we'll call it a draw"]

@property
def member(self):
print('next member is:')
return self.members[0]

@member.deleter
def member(self):
text = 'BLACK KNIGHT (loss {})\n-- {}'
print(text.format(self.members.pop(0), self.phrases.pop(0)))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> import blackknight
>>> knight = blackknight.BlackKnight()
>>> knight.member
next member is:
'an arm'
>>> del knight.member
BLACK KNIGHT (loss an arm)
-- Tis but a scratch.
>>> del knight.member
BLACK KNIGHT (loss another arm)
-- It's just a flesh world
>>> del knight.member
BLACK KNIGHT (loss a leg)
-- I'm invincible!
>>> del knight.member
BLACK KNIGHT (loss another leg)
-- All right, we'll call it a draw
>>>

在不使用装饰器的经典语法中,fdel 参数用于设置删值函数。如果不使用特性,可以实现底层特殊的 __delattr__ 方法处理删除属性的操作。

处理属性的重要属性和函数

Python 为处理动态属性提供了许多内置函数和特殊方法。

影响属性处理方式的特殊属性:

  • __class__:对象所属类的引用。即 obj.__class__ 与 type(obj) 的作用相同。Python 的某些特殊方法,例如 __getattr__,只会在对象的类中寻找,而不是实例中寻找
  • __dict__:一个映射,存储对象或类的可写属性。有 __dict__ 属性的对象,任何时候都能随意设置新属性。如果类有 __slots__ 属性,它的实例可能没有 __dict__ 属性
  • __slots__:类可以定义该属性,限制实例能有哪些属性

处理属性的内置函数:

  • dir([object]):列出对象的大多数属性。dir 函数不会列出类中的几个特殊属性,例如 __mro____bases____name__。如果没有可选的 object 参数,dir 函数会列出当前作用域中的名称
  • getattr(object, name[, default]):从 object 对象中获取 name 字符串对应的属性。获取的属性可能来自对象所属的类或超类。
  • hasattr(object, name):如果 object 对象中存在指定的属性,或者能以某种方式(例如继承)通过 object 对象获取指定的属性,返回 True
  • setattr(object, name, value):把 object 对象指定的属性设置为 value
  • vars([object]):返回 object 对象的 dict 属性。如果没有指定参数,那么 vars() 函数的作用与 locals() 函数一样:返回表示本地作用域的字典

使用点号或内置的 getattrhasattrsetattr 函数存取属性都会触发下述列表中的特殊方法。但是直接通过实例的 __dict__ 属性不会触发这些特殊方法。特殊方法是从类上获取,即便这些特殊方法操作的目标是实例。所以如果隐式调用特殊方法,仅当特殊方法在对象所属的类型上定义,而不是在对象的实例字典中定义时,才能确保特殊方法调用成功。这也说明了特殊方法不会被同名的实例属性覆盖。

  • __getattribute__(self, name):点号、getattr 和 hasattr 内置函数会触发这个方法。Class.__getattribute__(obj, 'attr')方法抛出 AttributeError 异常时,才会调用 __getattr__ 方法。
  • __getattr__(self, name):仅当搜索过 obj、Class 和 超类后,获取指定属性失败,会触发 Class.__getattr__(obj, 'name') 方法
  • __delattr__(self, name):使用 del.attr 语句会触发 Class.__delattr__(obj, 'attr') 方法
  • __setattr__(self, name, value):点号和 setattr 内置函数会触发 Class.setattr 方法
  • __dir__(self):dir(obj) 时会触发 Class.__dir__(obj) 方法

属性描述符

描述符是对多个属性运用相同存取逻辑的一种方式。描述符是实现了特定协议的类,这个协议包括 __get____set____delete__ 方法。property 类实现了完整的描述符协议。通常,可以只实现部分协议。描述符是 Python 的独有特征,不仅在应用层使用,在语言的基础设施中也有用到。

除了特性之外,使用描述符的 Python 功能还有方法及 classmethodstaticmethod 装饰器。因此理解描述符是精通 Python 的关键。

描述符示例:验证属性

在上面的例子中,特性工厂函数借助函数式编程模式避免重复编写读值方法和设值方法。特性工厂函数是高阶函数,在闭包中存储 storage_name 等设置,由参数决定创建哪种存取函数,再使用存取函数构建一个特性实例。而解决这种问题的面对对象方式是描述符类。首先定义如下术语:

  • 描述符类:实现描述符协议的类
  • 托管类:把描述符实例声明为类属性的类
  • 描述符实例:描述符类的各个实例,声明为托管类的类属性
  • 托管实例:托管类的实例
  • 储存属性:托管实例中存储自身托管属性的属性
  • 托管属性:托管类中由描述符实例处理的公开属性,值存储在储存属性中

如下代码创建了 Quantity 描述符类和新版的 LineItem 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Quantity:
def __init__(self, storage_name):
self.storage_name = storage_name

def __set__(self, instance, value):
if value > 0:
instance.__dict__[self.storage_name] = value
else:
raise ValueError('value must be > 0')


class LineItem:
weight = Quantity('weight')
price = Quantity('price')

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price
  • 描述符基于协议实现,无需创建子类
  • 尝试为托管属性赋值时,会调用 __set__ 方法。这里 self 是描述符实例,instance 是托管实例,value 是要设定的值
  • 通过直接处理托管实例的 __dict__ 属性来设值,如果使用内置的 setattr 函数,会再次触发 __set__ 方法,导致无限递归

时刻需要牢记,描述符实例是托管类的类属性,因此即使内存中有多个 LineItem 实例,也只会有两个描述符实例。这也就是说,各个托管属性的值要直接存储在自己的托管实例中,不能存储在描述符实例中。

为了避免在描述符声明语句中重复输入属性名,可以为每个 Quantity 实例的 storage_name 属性生成一个独一无二的字符串:

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
class Quantity:
__counter = 0

def __init__(self):
cls = self.__class__
prefix = cls.__name__
index = cls.__counter
self.storage_name = '_{}#{}'.format(prefix, index)
cls.__counter += 1

def __get__(self, instance, owner):
return getattr(instance, self.storage_name)

def __set__(self, instance, value):
if value > 0:
setattr(instance, self.storage_name, value)
else:
raise ValueError('value must be > 0')

class LineItem:
weight = Quantity()
price = Quantity()

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price

这里可以使用内置的高阶函数 getattrsetattr 存取值,无需使用 instance.__dict__,因为托管属性和储存属性的名称不同,所以把储存属性传给 getattr 函数不会触发描述符。

__get__ 方法有三个参数,其中第三个参数 owner 参数是托管类的引用。通过描述符从托管类中获取属性时用得到:如果使用 LineItem.weight 从类中获取托管属性,描述符的 __get__ 方法接收到的 instance 参数值是 None。此外,为了给用户提供内省和其他元编程技术支持,通过类访问托管属性时,最好让 __get__ 方法返回描述符实例。所以描述符的 __get__ 方法实现如下:

1
2
3
4
5
def __get__(self, instance, owner):
if instance is None:
return self
else:
return getattr(instance, self.storage_name)

这样如果不是通过实例访问托管属性,返回的都是描述符自身。

通常我们不会在使用描述符的模块中定义描述符,而是在一个单独的实用工具模块中定义,以便在整个应用中使用。如果是开发框架,甚至会在多个应用中使用。描述符在类中定义,因此可以利用继承重用部分代码来创建新描述符。下面重新实现了 LineItem 类:

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
46
47
48
import abc


class AutoStorage:
__counter = 0

def __init__(self):
cls = self.__class__
prefix = cls.__name__
index = cls.__counter
self.storage_name = '_{}#{}'.format(prefix, index)
cls.__counter += 1

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

def __set__(self, instance, value):
setattr(instance, self.storage_name, value)

class Validated(abc.ABC, AutoStorage):
def __set__(self, instance, value):
value = self.validate(instance, value)
super().__set__(instance, value)

@abc.abstractmethod
def validate(self, instance, value):
"""return validated value or raise ValueError"""


class Quantity(Validated):
"""a number greater than zero"""

def validate(self, instance, value):
if value <= 0:
raise ValueError('value must be > 0')
return value


class NonBlack(Validated):
"""a string with at least one non-space character"""

def validate(self, instance, value):
value = value.strip()
if len(value) == 0:
raise Value
  • AutoStorage 类提供了之前 Quantity 描述符的大部分功能,但是验证功能除外
  • Validated 是抽象类,但是也继承自 AutoStorage 类,它的 __set__ 方法中的验证操作委托给 validate 方法
  • Quantity 和 NonBlank 都继承自 Validated 类,实现具体的验证逻辑

LineItem 类可以使用 Quantity 和 NonBlank 自动验证实例属性。

以上例子演示了描述符的典型用途:管理数据属性,这种描述符也叫覆盖型描述符,因为描述符的 __set__ 方法使用托管实例中的同名属性覆盖(即插手接管)了要设置的属性,不过也有非覆盖型描述符。

覆盖型与非覆盖型描述符对比

之前介绍过,Python 存取属性的方式特别不对等:

  • 通过实例读取属性时,通常返回的是实例中定义的属性,但是如果实例中没有指定的属性,那么会获取类属性
  • 当为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类

这种不对等的处理方式对描述符也有影响。其实,根据是否定义 __set__ 方法,描述符可为两大类:覆盖型描述符和非覆盖型描述符。

覆盖型描述符:

  • 实现 __set__ 方法的描述符属于覆盖型描述符。因为虽然描述符是类属性,但是实现 __set__ 方法会覆盖对实例属性的赋值操作。特性也是覆盖型描述符:如果没有提供设值函数,property 类中的 __set__ 方法会抛出 AttributeError 异常,指明那个属性是只读的。

  • 只实现 __set__ 方法。此时只有写操作由描述符处理,通过实例读取描述符会返回描述符对象本身,因为没有处理读操作的 __get__ 方法。如果直接通过实例的 __dict__ 属性创建同名属性,以后再设置该属性时,仍然会由 __set__ 方法插手接管。但是读取那个属性的话,就会直接从实例中返回新赋予的值,而不会返回描述符对象。即实例属性会覆盖描述符,但是只有读操作是如此

非覆盖型描述符:

  • 没有实现 __set__ 方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,描述符会被覆盖,致使描述符无法处理那个实例的那个属性。方法是以非覆盖型描述符实现的。

覆盖型描述符也叫做数据描述符或强制描述符。非覆盖型描述符也叫做非数据描述符或遮盖型描述符。

依附在类上的描述符无法控制为类属性赋值的操作,这就意味着为类属性赋值能覆盖描述符属性。不管描述符是不是覆盖型,为类属性赋值都能覆盖描述符。

方法是描述符

在类中定义的函数属于绑定方法(bound method),因为用户定义的函数都有 __get__ 方法,所以依附到类上时,就相当于描述符。但是函数没有实现 __set__ 方法,因此是非覆盖型描述符。与描述符一样:

  • 通过托管类访问时,函数的 __get__ 方法会返回自身的引用
  • 通过实例访问时,函数的 __get__ 方法会返回的是绑定方法对象:一种可调用对象,里面包装着函数,并把托管实例绑定给函数的第一个参数,这与 functools.partial 函数的行为一致

绑定方法对象有一个 __self__ 属性,其值是调用这个方法的实例引用。绑定方法的 __func__ 属性是依附在托管类上的那个原始函数的引用。绑定方法队形还有一个 __call__ 方法,用于处理真正的调用过程。该方法会调用 __func__ 属性引用的原始函数,把函数的第一个参数设置为绑定方法的 __self__ 属性。这就是形参 self 的隐式绑定方式。

函数会变成绑定方法,这就是 Python 语言底层使用描述符的最好例证。

描述符用法建议

  • 使用特性以保持简单:内置的 property 类创建的其实是覆盖型描述符。__set__ 方法和 __get__ 方法都实现了,即便不定义设值方法也是如此。因此创建只读属性的最简单方式是使用特性
  • 只读描述符必须有 __set__ 方法:如果使用描述符类实现只读属性,__get____set__ 两个方法都必须定义,否则实例的同名属性会遮盖描述符。只读属性的 __set__ 方法只需要抛出 AttributeError 异常,并提供合适的错误
  • 用于验证的描述符可以只有 __set__ 方法
  • 仅有 __get__ 方法的描述符可以实现高速缓存:此时创建的是非覆盖型描述符,这种描述符可以用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果。同名实例属性会覆盖描述符,然后后续访问会直接从实例的 __dict__ 属性中获取值,而不会再次触发描述符的 __get__ 方法
  • 非特殊的方法可以被实例属性遮盖:由于函数和方法只实现了 __get__ 方法,它们不会处理同名实例属性的赋值操作。但是特殊方法不受这个问题的影响。解释器只会在类中寻找特殊的方法,也就是说:repr(x) 执行的其实是 x.__class__.__repr__(x),因此 x 的 __repr__ 属性对 repr(x) 方法调用没有影响。

描述符的文档字符串和覆盖删除操作

描述符类的文档字符串用于注解托管类中的各个描述符实例。而且在描述符类中,实现常规的的 __get____set__ 方法之外,可以实现 __delete__ 方法。

关于描述符和 __getattribte__ 的工作原理,Python 的官网文档讲的更为详细,可以参考。

类元编程

类元编程是指在运行时创建或定制类的技术。在 Python 中,类是一等对象,因此任何时候都可以使用函数新建类,而不需要使用 class 关键字。类装饰器也是函数,但是能够审查,修改、甚至把被装饰的类替换成其他类。最后,元类是类元编程最高级的工具:使用元类可以创建具有某种特质的全新类种。一般除非开发框架,否则不要编写元类。

类工厂函数

之前介绍过标准库中的一个类工厂函数:collections.namedtuple:把一个类名和几个属性名称传给这个函数,它会创建一个 tuple 的子类,其中的元素通过名称获取,还为调试提供了友好的字符串表示形式。

如下也实现了一个简单的类工厂函数:

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
def record_factory(cls_name, field_names):
try:
field_names = field_names.replace(',' ' ').split()
except AttributeError:
pass
field_names = tuple(field_names)

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

def __iter__(self):
for name in self.__slots__:
yield getattr(self, name)

def __repr__(self):
values = ', '.join('{}={!r}'.format(*i) for i
in zip(self.__slots__, self))
return '{}({})'.format(self.__class__.__name__, values)

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

return type(cls_name, (object,), cls_attrs)
  • 该函数总定义了 __slots____init____iter____repr__,这些都将成为新建类的对应属性。
  • 通过调用 type 构造方法,构建新类,然后将其返回。type 的三个参数分别是 name、bases 和 dict。最后一个参数是一个映射,指定新类的属性名和值。type 是一个类,它的实例也是一个类。

可以把 __slots__ 类属性的名称改成其他值,如果这样的话,就需要实现 __setattr__ 方法:为属性赋值时验证属性的名称,因为对于记录这样的类,我们希望属性始终是固定的几个,而且顺序是相同的。

在 Python 中做元编程时,最好不要使用 excl 和 eval 函数。如果接受的字符串来自不可信的源,那么这两个函数会带来严重的安全风险。Python 提供充足的内省工具,大多数时候都不需要使用 exec 和 eval 函数。

定制描述符的类装饰器

上一篇文章中的 LineItem 类有一个问题:存储属性的名称不具有名称性,即不能使用描述性的存储属性名称。因为实例化描述符时无法得知托管属性,但是这是可以实现的。因为一旦组建好整个类,而且把描述符绑定到类属性上之后,就可以审查类,并为描述符设置合理的储存属性名称。我们需要在创建类时设置储存属性的名称,使用类装饰器或元类可以做到这一点。

类装饰器与函数装饰器非常类似,是参数为类对象的函数,返回原来的类或修改后的类。如下重新实现了 LineItem 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@model.entity
class LineItem:
description = model.NonBlank()
weight = model.Quantity()
price = model.Quantity()

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price
  • 这个类的唯一变化是是添加了装饰器,解释器会计算 LineItem 类,把返回的类对象传给 model.entity 函数。Python 会把 LineItem 这个全局名称绑定给 model.entity 函数返回的对象

如下是 model.entity 函数的实现:它会修改类中各个描述符实例的 storage_name 属性:

1
2
3
4
5
6
def entity(cls):
for key, attr in cls.__dict__.items():
if isinstance(attr, Validated):
type_name = type(attr).__name__
attr.storage_name = '_{}#{}'.format(type_name, key)
return cls

类装饰器能够以较为简单的方式做到以前需要使用元类去做的事情:创建类时定制类。但是类装饰器也有个重大缺点:只能对直接依附的类有效。这意味着,被装饰类的子类可能继承也可能不会继承装饰器所做的改动,具体情况需要根据改动的方式而定。

导入时和运行时的区别

在导入时,解释器会从上到下一次性解析完 .py 模块的源码,然后生成用于执行的字节码。如果有语法错误,此时会生成报告。如果本地的 __pycahche__ 文件夹中有最新的 .pyc 文件,解释器会跳过该步骤。

编译肯定是导入时的活动,不过那个时期还会做其他事。因为 Python 中的语句几乎都是可执行的,也就是说语句可能会运行用户代码,修改用户程序的状态。尤其是 import 语句,它不只是声明。在进程首次导入模块时,会运行所导入模块的全部 顶层代码,以后导入相同的模块时则使用缓存,只做名称绑定。那些顶层代码可以做任何事情,包括通常在运行时做的事情。因此导入时和运行时之间的界限是模糊的,import 语句可以触发任何 运行时 行为。

导入模块时,解释器会执行顶层的 def 语句,解释器会编译函数的定义体(首次导入模块时),把函数对象绑定到对应的全局名称上,但是解释器不会执行函数定义体。这就意味着解释器在导入时定义顶层函数,但是仅当运行时调用函数时才会执行函数的定义体。但是对类而言,则有所不同:在导入时会执行每个类的定义体(包括嵌套类的定义体)。执行类定义体的结果是:定义了类的属性和方法,并构建了类对象。所以可以认为类的定义体属于 顶层代码,因为它在导入时运行。

类装饰器可能对子类没有影响。如果想定制整个类层次结构,而不是一次只定制一个类,使用元类更加高效。

元类基础知识

元类是制造类的工厂,不过不是函数,而是类。默认情况下,Python 中的类是 type 类的实例。也就是说,type 是大多数内置的类和用户定义的类的元类。为了避免无限回溯,type 是其自身的实例。另外,object 类和 type 类之间的关系很独特:object 是 type 的实例,而 type 是 object 的子类。

除了 type,标准库中还有一些别的元类,例如 ABCMeta 和 Enum。向上追溯,ABCMeta 最终所属的类也是 type。所有类都直接或间接地是 type 的实例,不过只有元类同时也是 type 的子类。如果想理解元类,一定要知道这种关系:元类(如 ABCMeta)从 type 类继承了构建类的能力

如下所示:

  • Iterable 是 object 的子类,是 ABCMeta 的实例
  • object 和 ABCMeta 都是 type 的实例
  • ABCMeta 还是 type 的子类,因为 ABCMeta 是元类

重点是:所有类都是 type 的实例,但是元类还是 type 的子类,因此可以作为制作类的工厂。具体来说,元类可以通过实现 __init__ 方法定制实例。元类的 __init__ 可以做到类装饰器能做的任何事情,但是作用更大。

如下是一个元类的实现示例:

1
2
3
4
5
6
class MetaAleph(type):
def __init__(cls, name, bases, dic):

def inner_2(self):
print('<[600]> MetaAleph.__init__:inner_2')
cls.method_z = inner_2

__init__ 方法有 4 个参数:

  • self:要初始化的类对象,通常会把该参数名写为 cls,从而表明要构建的实例是类
  • name、bases、dic:与构建类时传给 type 的参数一样

关于元类,需要注意:

  • 在执行类的定义体时,会执行其元类的 __init__ 函数。类通过 metaclass 关键字指定其元类
  • 在执行子类的定义体时,也会执行其超类的元类的 __init__ 函数

接下来我们将创建一个元类,让描述符以最佳的方式自动创建储存属性的名称。

定制描述符的元类

这里直接将 LineItem 作为 model.Entity 的子类:

1
2
3
4
5
6
7
8
9
10
11
12
class LineItem(model.entity):
description = model.NonBlank()
weight = model.Quantity()
price = model.Quantity()

def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price

def subtotal(self):
return self.weight * self.price

而 Entity 所属的元类为 EntityMeta:

1
2
3
4
5
6
7
8
9
10
11
class EntityMeta(type):
def __init__(cls, name, bases, attr_dict):
super().__init__(name, bases, attr_dict)
for key, attr in attr_dict.items():
if isinstance(attr, Validated):
type_name = type(attr).__name__
attr.storage_name = '_{}#{}'.format(type_name, key)


class Entity(metaclass=EntityMeta):
"""field with validated logic"""

元类的特殊方法 __prepare__

在某些应用中,可能需要知道类的属性定义的顺序。type 的构造方法及元类的 __new____init__ 方法都会收到要计算的类的定义体,形式是名称到属性的映射。但是由于这个映射是字典,所以元类或类装饰器获得映射时,属性在类定义体中的顺序已经丢失了。

该问题的解决方法是使用 Python3 引入的 __prepare__。该方法只在元类中有用,而且必须声明为类方法。解释器调用元类的 __new__ 方法之前会先调用 __prepare__ 方法,使用类定义体中的属性创建映射:它的第一个参数是元类,随后两个参数分别是要构建的类的名称和基类组成的元祖,返回值必须是映射。元类构建新类时,__prepare__ 方法返回的映射会传给 __new__ 方法的最后一个参数,然后再传给 __init__ 方法。

现实世界中,框架和库会使用元类执行很多任务,例如:

  • 验证属性
  • 一次把装饰器依附到多个方法上
  • 序列化对象或转换数据
  • 对象关系映射
  • 基于对象的持久存储
  • 动态转换使用其他语言编写的类结构

类作为对象

Python 数据模型为每个类定义了很多属性,之前已经介绍过 __mro____class____name__,此外还有以下属性:

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