描述器指南

作者:Raymond Hettinger 联系方式:

描述器 让对象能够自定义属性查找、存储和删除的操作。

本指南主要分为四个部分:

  1. “入门”部分从简单的示例着手,逐步添加特性,从而给出基本的概述。如果你是刚接触到描述器,请从这里开始。

  2. 第二部分展示了完整的、实用的描述器示例。如果您已经掌握了基础知识,请从此处开始。

  3. 第三部分提供了更多技术教程,详细介绍了描述器如何工作。大多数人并不需要深入到这种程度。

  4. 最后一部分给出用 C 编写的内置描述器的纯 Python 等价实现。如果你好奇函数如何变成绑定方法,或想了解 classmethod、staticmethod、property 和 __slots__ 等常用工具的实现,请阅读这一部分。

入门

现在,让我们从最基本的示例开始,然后逐步添加新功能。

简单示例:返回常量的描述器

Ten 类是一个描述器,其 object.__get__ 方法始终返回常量 10:

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

要使用描述器,它必须作为一个类变量存储在另一个类中:

class A:
    x = 5                       # Regular class attribute
    y = Ten()                   # Descriptor instance

用交互式会话查看普通属性查找和描述器查找之间的区别:

>>> a = A()                     # Make an instance of class A
>>> a.x                         # Normal attribute lookup
5
>>> a.y                         # Descriptor lookup
10

在 a.x 属性查找中,点运算符会找到存储在类字典中的 'x': 5。在 a.y 查找中,点运算符会根据描述器实例的 __get__ 方法将其识别出来,调用该方法并返回 10。

请注意,值 10 既不存储在类字典中也不存储在实例字典中。相反,值 10 是在调用时才取到的。

这个简单的例子展示了一个描述器是如何工作的,但它不是很有用。在查找常量时,用常规属性查找会更好。

在下一节中,我们将创建更有用的东西,即动态查找。

动态查找

有趣的描述器通常运行计算而不是返回常量:

import os

class DirectorySize:

    def __get__(self, obj, objtype=None):
        return len(os.listdir(obj.dirname))

class Directory:

    size = DirectorySize()              # Descriptor instance

    def __init__(self, dirname):
        self.dirname = dirname          # Regular instance attribute

交互式会话表明,这种查找是动态的:每次都会重新计算,得到更新后的结果:

>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                              # The songs directory has twenty files
20
>>> g.size                              # The games directory has three files
3
>>> os.remove('games/chess')            # Delete a game
>>> g.size                              # File count is automatically updated
2

除了说明描述器如何运行计算,这个例子也揭示了传给 object.__get__ 的形参的目的。 self 形参为 size,即一个 DirectorySize 的实例。 obj 形参为 g 或 s,即一个 Directory 的实例。 obj 形参让 object.__get__ 方法获知目标目录。 objtype 形参为 Directory 类。

托管属性

描述器的一种流行用法是管理对实例数据的访问。描述器被分配给类字典中的公有属性,而实际数据则作为私有属性存储在实例字典中。描述器的 object.__get__ 和 object.__set__ 方法会在公有属性被访问时被触发。

在下面的例子中,age 是公开属性,_age 是私有属性。当访问公开属性时,描述器会记录下查找或更新的日志:

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAgeAccess:

    def __get__(self, obj, objtype=None):
        value = obj._age
        logging.info('Accessing %r giving %r', 'age', value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', 'age', value)
        obj._age = value

class Person:

    age = LoggedAgeAccess()             # Descriptor instance

    def __init__(self, name, age):
        self.name = name                # Regular instance attribute
        self.age = age                  # Calls __set__()

    def birthday(self):
        self.age += 1                   # Calls both __get__() and __set__()

交互式会话展示中,对托管属性 age 的所有访问都被记录了下来,但常规属性 name 则未被记录:

import logging, sys
logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)
>>> mary = Person('Mary M', 30)         # The initial age update is logged
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40

>>> vars(mary)                          # The actual data is in a private attribute
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}

>>> mary.age                            # Access the data and log the lookup
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday()                     # Updates are logged as well
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31

>>> dave.name                           # Regular attribute lookup isn't logged
'David D'
>>> dave.age                            # Only the managed attribute is logged
INFO:root:Accessing 'age' giving 40
40

此示例的一个主要问题是私有名称 _age 在类 LoggedAgeAccess 中是硬耦合的。这意味着每个实例只能有一个用于记录的属性,并且其名称不可更改。在下一个示例中,我们将解决这个问题。

定制名称

当一个类使用描述器时,它可以告知每个描述器使用了什么变量名。

在此示例中,Person 类具有两个描述器实例 name 和 age。当 Person 类被定义时,它将在 LoggedAccess 中执行对 object.__set_name__ 的回调以便记录字段名称,给予每个描述器自己的 public_name 和 private_name:

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAccess:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        logging.info('Accessing %r giving %r', self.public_name, value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', self.public_name, value)
        setattr(obj, self.private_name, value)

class Person:

    name = LoggedAccess()                # First descriptor instance
    age = LoggedAccess()                 # Second descriptor instance

    def __init__(self, name, age):
        self.name = name                 # Calls the first descriptor
        self.age = age                   # Calls the second descriptor

    def birthday(self):
        self.age += 1

交互式会话显示 Person 类调用了 object.__set_name__ 以使字段名称可被记录。 在这里我们调用 vars 来查找描述器而不触发它:

>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}

现在,新类会记录对 name 和 age 二者的访问:

import logging, sys
logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)
>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10
>>> kate = Person('Catherine C', 20)
INFO:root:Updating 'name' to 'Catherine C'
INFO:root:Updating 'age' to 20

这两个 Person 实例仅包含私有名称:

>>> vars(pete)
{'_name': 'Peter P', '_age': 10}
>>> vars(kate)
{'_name': 'Catherine C', '_age': 20}

结束语

descriptor 是指任何定义了 object.__get__, object.__set__ 或 object.__delete__ 的对象。

作为可选项,描述器可以有 object.__set_name__ 方法。 这仅会被用于当描述器需要知道创建它的类或它被分配的类变量名称等场合。 (此方法如果存在,那么即使所在类并不是一个描述器仍会被调用。)

在属性查找期间,描述器由点运算符调用。如果使用 vars(some_class)[descriptor_name] 间接访问描述器,则返回描述器实例而不调用它。

描述器仅在用作类变量时起作用。放入实例时,它们将失效。

描述器的主要目的是提供一个挂钩,允许存储在类变量中的对象控制在属性查找期间发生的情况。

传统上,调用类控制查找过程中发生的事情。描述器反转了这种关系,并允许正在被查询的数据对此进行干涉。

描述器贯穿整个 Python 语言,函数正是通过它变成绑定方法的。classmethod、staticmethod、property 和 functools.cached_property 等常用工具都通过描述器实现。

完整的实际例子

在此示例中,我们创建了一个实用而强大的工具来查找难以发现的数据损坏错误。

验证器类

验证器是一个用于托管属性访问的描述器。在存储任何数据之前,它会验证新值是否满足各种类型和范围限制。如果不满足这些限制,它将引发异常,从源头上防止数据损坏。

这个 Validator 类既是一个 abstract base class 也是一个被管理的属性描述器:

from abc import ABC, abstractmethod

class Validator(ABC):

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass

自定义验证器必须继承自 Validator 并且必须提供 validate 方法以根据需要测试各种约束。

自定义验证器

这是三个实用的数据验证工具:

  1. OneOf 验证值是指定的受约束选项集合中的一项。

  2. Number 验证值是否为 int 或 float。 作为可选项,它还能验证值在给定的最小值和最大值之间。

  3. String 验证值是否为 str。作为可选项,它还能验证给定的最小或最大长度。它还能验证用户定义的 predicate.

class OneOf(Validator):

    def __init__(self, *options):
        self.options = set(options)

    def validate(self, value):
        if value not in self.options:
            raise ValueError(
                f'Expected {value!r} to be one of {self.options!r}'
            )

class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )

class String(Validator):

    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be a str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(
                f'Expected {self.predicate} to be true for {value!r}'
            )

实际应用

这是在真实类中使用数据验证器的方法:

class Component:

    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)

    def __init__(self, name, kind, quantity):
        self.name = name
        self.kind = kind
        self.quantity = quantity

描述器阻止无效实例的创建:

>>> Component('Widget', 'metal', 5)      # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
    ...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)      # Blocked: 'metle' is misspelled
Traceback (most recent call last):
    ...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)     # Blocked: -5 is negative
Traceback (most recent call last):
    ...
ValueError: Expected -5 to be at least 0

>>> Component('WIDGET', 'metal', 'V')    # Blocked: 'V' isn't a number
Traceback (most recent call last):
    ...
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # Allowed:  The inputs are valid

技术教程

接下来是专业性更强的技术教程,以及描述器工作原理的详细信息。

摘要

定义描述器,总结协议,并说明如何调用描述器。提供一个展示对象关系映射如何工作的示例。

学习描述器不仅能提供接触到更多工具集的途径,还能更深地理解 Python 工作的原理。

定义与介绍

一般而言,描述器是具有描述器协议中的方法之一的属性值。这些方法是 object.__get__, object.__set__ 和 object.__delete__。 如果为某个属性定义了这些方法中的任何一个,它就被称为 descriptor。

属性访问的默认行为是从一个对象的字典中获取、设置或删除属性。对于实例来说,a.x 的查找顺序会从 a.__dict__['x'] 开始,然后是 type(a).__dict__['x'],接下来依次查找 type(a) 的方法解析顺序(MRO)。 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重写默认行为并转而唤起描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。

描述器是一种强大的,通用的协议。它们是属性、方法、静态方法、类方法和 super 背后的机制。它们在整个 Python 中都有使用。 描述器简化了底层的 C 代码并为日常的 Python 程序提供了一套灵活的新工具。

描述器协议

descr.__get__(self, obj, type=None)

descr.__set__(self, obj, value)

descr.__delete__(self, obj)

描述器的方法就这些。一个对象只要定义了以上方法中的任何一个,就被视为描述器,并在被作为属性时覆盖其默认行为。

如果一个对象定义了 object.__set__ 或 object.__delete__,它将被视为数据描述器。 仅定义了 object.__get__ 的描述器称为非数据描述器(它们经常被用于方法但也可以有其他用途)。

数据和非数据描述器的不同之处在于,如何计算实例字典中条目的替代值。如果实例的字典具有与数据描述器同名的条目,则数据描述器优先。如果实例的字典具有与非数据描述器同名的条目,则该字典条目优先。

为了使一个数据描述器只读,应同时定义 object.__get__ 和 object.__set__ 并在调用 object.__set__ 时引发 AttributeError。用引发异常的占位符定义 object.__set__ 方法就足以使其成为一个数据描述器。

描述器调用概述

描述器可以通过 desc.__get__(obj) 或 desc.__get__(None, cls) 直接调用。

但更常见的是通过属性访问自动调用描述器。

表达式 obj.x 在 obj 的命名空间链中查找属性 x。如果搜索发现了一个实例 object.__dict__ 以外的描述器,将根据下面列出的优先级规则调用其 object.__get__ 方法。

调用的细节取决于 obj 是对象、类还是超类的实例。

通过实例调用

实例查找会扫描命名空间链并给予数据描述器最高的优先级,然后是实例变量,然后是非数据描述器,最后是 object.__getattr__,如果有提供的话。

如果 a.x 找到了一个描述器,那么将通过 desc.__get__(a, type(a)) 调用它。

点运算符的查找逻辑在 object.__getattribute__ 中。这里是一个等价的纯 Python 实现:

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)
# Test the fidelity of object_getattribute() by comparing it with the
# normal object.__getattribute__().  The former will be accessed by
# square brackets and the latter by the dot operator.

class Object:

    def __getitem__(obj, name):
        try:
            return object_getattribute(obj, name)
        except AttributeError:
            if not hasattr(type(obj), '__getattr__'):
                raise
        return type(obj).__getattr__(obj, name)             # __getattr__

class DualOperator(Object):

    x = 10

    def __init__(self, z):
        self.z = z

    @property
    def p2(self):
        return 2 * self.x

    @property
    def p3(self):
        return 3 * self.x

    def m5(self, y):
        return 5 * y

    def m7(self, y):
        return 7 * y

    def __getattr__(self, name):
        return ('getattr_hook', self, name)

class DualOperatorWithSlots:

    __getitem__ = Object.__getitem__

    __slots__ = ['z']

    x = 15

    def __init__(self, z):
        self.z = z

    @property
    def p2(self):
        return 2 * self.x

    def m5(self, y):
        return 5 * y

    def __getattr__(self, name):
        return ('getattr_hook', self, name)

class D1:
    def __get__(self, obj, objtype=None):
        return type(self), obj, objtype

class U1:
    x = D1()

class U2(U1):
    pass
>>> a = DualOperator(11)
>>> vars(a).update(p3 = '_p3', m7 = '_m7')
>>> a.x == a['x'] == 10
True
>>> a.z == a['z'] == 11
True
>>> a.p2 == a['p2'] == 20
True
>>> a.p3 == a['p3'] == 30
True
>>> a.m5(100) == a.m5(100) == 500
True
>>> a.m7 == a['m7'] == '_m7'
True
>>> a.g == a['g'] == ('getattr_hook', a, 'g')
True

>>> b = DualOperatorWithSlots(22)
>>> b.x == b['x'] == 15
True
>>> b.z == b['z'] == 22
True
>>> b.p2 == b['p2'] == 30
True
>>> b.m5(200) == b['m5'](200) == 1000
True
>>> b.g == b['g'] == ('getattr_hook', b, 'g')
True

>>> u2 = U2()
>>> object_getattribute(u2, 'x') == u2.x == (D1, u2, U2)
True

注意,在 object.__getattribute__ 代码中没有 object.__getattr__ 钩子。 这就是为什么直接调用 object.__getattribute__ 或用 super().__getattribute__ 会彻底绕过 object.__getattr__。

相反,一旦 object.__getattribute__ 引发 AttributeError 则将由点运算符和 getattr 函数来负责唤起 object.__getattr__。它们的逻辑封装在一个辅助函数中:

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)             # __getattr__
>>> class ClassWithGetAttr:
...     x = 123
...     def __getattr__(self, attr):
...         return attr.upper()
...
>>> cw = ClassWithGetAttr()
>>> cw.y = 456
>>> getattr_hook(cw, 'x')
123
>>> getattr_hook(cw, 'y')
456
>>> getattr_hook(cw, 'z')
'Z'

>>> class ClassWithoutGetAttr:
...     x = 123
...
>>> cwo = ClassWithoutGetAttr()
>>> cwo.y = 456
>>> getattr_hook(cwo, 'x')
123
>>> getattr_hook(cwo, 'y')
456
>>> getattr_hook(cwo, 'z')
Traceback (most recent call last):
    ...
AttributeError: 'ClassWithoutGetAttr' object has no attribute 'z'

通过类调用

像 A.x 这样的点操作符查找的逻辑在 type.__getattribute__ 中。其步骤与 object.__getattribute__ 相似,但是实例字典查找被替换为搜索类的 method resolution order。

如果找到了一个描述器,那么将通过 desc.__get__(None, A) 调用它。

完整的 C 实现可在 Objects/typeobject.c 里的 type_getattro 和 _PyType_Lookup 中找到。

通过 super 调用

super 的点操作符查找的逻辑在 super 所返回对象的 object.__getattribute__ 方法中。

类似 super(A, obj).m 形式的点分查找将在 obj.__class__.__mro__ 中搜索紧接在 A 之后的基类 B,然后返回 B.__dict__['m'].__get__(obj, A)。如果 m 不是描述器,则直接返回其值。

完整的 C 实现可在 Objects/typeobject.c 里的 super_getattro 中找到。 纯 Python 的等价实现可在 Guido 的教程 中找到。

调用逻辑总结

描述器的机制嵌入在 object, type 和 super 的 object.__getattribute__ 方法中。

要记住的重要点是:

  • 描述器将由 object.__getattribute__ 方法唤起。

  • 类从 object,type 或 super 继承此机制。

  • 重写 object.__getattribute__ 将阻止自动的描述器调用,因为所有描述器逻辑都在该方法中。

  • object.__getattribute__ 和 type.__getattribute__ 会用不同方式调用 object.__get__。第一个会包括实例并可能包括类。第二个会将 None 作为实例并且总是包括类。

  • 数据描述器始终会覆盖实例字典。

  • 非数据描述器会被实例字典覆盖。

自动名称通知

有时描述器需要知道它被赋值到哪个变量名。当一个新类被创建时,type 元类将扫描新类的字典。 如果其中有任何条目是描述器并且它们定义了 object.__set_name__,则该方法被调用时将附带两个参数。 owner 是使用该描述器的类,而 name 是该描述器被赋值到的变量。

实现的细节在 Objects/typeobject.c 里的 type_new 和 set_names 中。

由于更新逻辑是在 type.__new__ 中,因此通知仅在类创建时发出。之后如果将描述器添加到类中,则需要手动调用 object.__set_name__.

ORM(对象关系映射)示例

以下代码展示了如何使用数据描述器来实现简单的 对象关系映射 框架。

其核心思路是将数据存储在外部数据库中,Python 实例仅持有数据库表中对应的键。描述器负责对值进行查找或更新:

class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()

我们可以使用 Field 类来定义描述了数据库中每张表的结构的 模型:

class Movie:
    table = 'Movies'                    # Table name
    key = 'title'                       # Primary key
    director = Field()
    year = Field()

    def __init__(self, key):
        self.key = key

class Song:
    table = 'Music'
    key = 'title'
    artist = Field()
    year = Field()
    genre = Field()

    def __init__(self, key):
        self.key = key

要使用这些模型,先连接数据库:

>>> import sqlite3
>>> conn = sqlite3.connect('entertainment.db')

交互式会话显示了如何从数据库中检索数据及如何对其进行更新:

song_data = [
    ('Country Roads', 'John Denver', 1972),
    ('Me and Bobby McGee', 'Janice Joplin', 1971),
    ('Coal Miners Daughter', 'Loretta Lynn', 1970),
]
movie_data = [
    ('Star Wars', 'George Lucas', 1977),
    ('Jaws', 'Steven Spielberg', 1975),
    ('Aliens', 'James Cameron', 1986),
]
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE Music (title text, artist text, year integer);')
conn.execute('CREATE INDEX MusicNdx ON Music (title);')
conn.executemany('INSERT INTO Music VALUES (?, ?, ?);', song_data)
conn.execute('CREATE TABLE Movies (title text, director text, year integer);')
conn.execute('CREATE INDEX MovieNdx ON Music (title);')
conn.executemany('INSERT INTO Movies VALUES (?, ?, ?);', movie_data)
conn.commit()
>>> Movie('Star Wars').director
'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
'Released in 1975 by Steven Spielberg'

>>> Song('Country Roads').artist
'John Denver'

>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
'J.J. Abrams'
conn.close()

纯 Python 等价实现

描述器协议很简单,但它提供了令人兴奋的可能性。有几个用例非常通用,以至于它们已预先打包到内置工具中。属性、绑定方法、静态方法、类方法和 slots 均基于描述器协议。

属性

调用 property 是构建数据描述器的简洁方式,该描述器会在访问属性时触发函数调用。其签名为:

property(fget=None, fset=None, fdel=None, doc=None) -> property

该文档显示了定义托管属性 x 的典型用法:

class C:
    def getx(self): return self.__x
    def setx(self, value): self.__x = value
    def delx(self): del self.__x
    x = property(getx, setx, delx, "I'm the 'x' property.")
>>> C.x.__doc__
"I'm the 'x' property."
>>> c.x = 2.71828
>>> c.x
2.71828
>>> del c.x
>>> c.x
Traceback (most recent call last):
  ...
AttributeError: 'C' object has no attribute '_C__x'

要了解 property 是如何按描述器协议的方式来实现的,以下是一个实现了大部分核心功能的纯 Python 等价实现:

class Property:
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __set_name__(self, owner, name):
        self.__name__ = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)
# Verify the Property() emulation

class CC:
    def getx(self):
        return self.__x
    def setx(self, value):
        self.__x = value
    def delx(self):
        del self.__x
    x = Property(getx, setx, delx, "I'm the 'x' property.")
    no_getter = Property(None, setx, delx, "I'm the 'x' property.")
    no_setter = Property(getx, None, delx, "I'm the 'x' property.")
    no_deleter = Property(getx, setx, None, "I'm the 'x' property.")
    no_doc = Property(getx, setx, delx, None)


# Now do it again but use the decorator style

class CCC:
    @Property
    def x(self):
        return self.__x
    @x.setter
    def x(self, value):
        self.__x = value
    @x.deleter
    def x(self):
        del self.__x
>>> cc = CC()
>>> hasattr(cc, 'x')
False
>>> cc.x = 33
>>> cc.x
33
>>> del cc.x
>>> hasattr(cc, 'x')
False

>>> ccc = CCC()
>>> hasattr(ccc, 'x')
False
>>> ccc.x = 333
>>> ccc.x == 333
True
>>> del ccc.x
>>> hasattr(ccc, 'x')
False

>>> cc = CC()
>>> cc.x = 33
>>> try:
...     cc.no_getter
... except AttributeError as e:
...     type(e).__name__
...
'AttributeError'

>>> try:
...     cc.no_setter = 33
... except AttributeError as e:
...     type(e).__name__
...
'AttributeError'

>>> try:
...     del cc.no_deleter
... except AttributeError as e:
...     type(e).__name__
...
'AttributeError'

>>> CC.no_doc.__doc__ is None
True

当用户接口已授权直接访问属性,但后续的变化要求通过方法来干预时,内置的 property 就能派上用场。

例如,一个电子表格类可以通过 Cell('b10').value 授予对单元格值的访问权限。对程序的后续改进要求每次访问都要重新计算单元格;但是,程序员不希望影响直接访问该属性的现有客户端代码。解决方案是将对 value 属性的访问包装在属性数据描述器中:

class Cell:
    ...

    @property
    def value(self):
        "Recalculate the cell before returning value"
        self.recalc()
        return self._value

在这个例子中内置的 property 或我们的 Property 等价实现都是可以的。

函数和方法

Python 的面向对象功能是在基于函数的环境构建的。通过使用非数据描述器,这两方面完成了无缝融合。

在调用时,存储在类字典中的函数将被转换为方法。方法与常规函数的不同之处仅在于对象实例被置于其他参数之前。按照惯例,实例引用称为 self ,但也可以称为 this 或任何其他变量名称。

可以使用 types.MethodType 手动创建方法,其行为基本等价于:

class MethodType:
    "Emulate PyMethod_Type in Objects/classobject.c"

    def __init__(self, func, obj):
        self.__func__ = func
        self.__self__ = obj

    def __call__(self, *args, **kwargs):
        func = self.__func__
        obj = self.__self__
        return func(obj, *args, **kwargs)

    def __getattribute__(self, name):
        "Emulate method_getset() in Objects/classobject.c"
        if name == '__doc__':
            return self.__func__.__doc__
        return object.__getattribute__(self, name)

    def __getattr__(self, name):
        "Emulate method_getattro() in Objects/classobject.c"
        return getattr(self.__func__, name)

    def __get__(self, obj, objtype=None):
        "Emulate method_descr_get() in Objects/classobject.c"
        return self

为支持方法的自动创建,函数会包括 object.__get__ 方法以便在属性访问期间绑定方法。 这意味着函数就是在通过实例进行点号查找期间返回所绑定方法的非数据描述器。其运作方式是这样的:

class Function:
    ...

    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        if obj is None:
            return self
        return MethodType(self, obj)

在解释器中运行以下类,这显示了函数描述器的实际工作方式:

class D:
    def f(self):
         return self

class D2:
    pass
>>> d = D()
>>> d2 = D2()
>>> d2.f = d.f.__get__(d2, D2)
>>> d2.f() is d
True

该函数具有 qualified name 属性以支持自省:

>>> D.f.__qualname__
'D.f'

通过类字典访问函数不会调用 object.__get__,而是直接返回底层函数对象:

>>> D.__dict__['f']
<function D.f at 0x00C45070>

从类进行点号访问会调用 object.__get__,该方法原样返回底层函数:

>>> D.f
<function D.f at 0x00C45070>

从实例进行点号访问时,就会出现有趣的行为。点号查找调用 object.__get__,返回一个绑定方法对象:

>>> d = D()
>>> d.f
<bound method D.f of <__main__.D object at 0x00B18C90>>

绑定方法在内部保存底层函数和绑定的实例:

>>> d.f.__func__
<function D.f at 0x00C45070>

>>> d.f.__self__
<__main__.D object at 0x00B18C90>

如果你曾好奇常规方法中的 self 或类方法中的 cls 是从什么地方来的,就是这里了!

方法的种类

非数据描述器为把函数绑定为方法的通常模式提供了一种简单的机制。

总结一下,函数具有 object.__get__ 方法以便在其作为属性被访问时可被转换为方法。非数据描述器会将 obj.f(*args) 调用转化为 f(obj, *args)。调用 cls.f(*args) 将变成 f(*args).

下表总结了绑定及其两个最有用的变体:

转换类型 从对象调用 从类调用
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(cls, *args)

静态方法

静态方法返回底层函数,不做任何更改。调用 c.f 或 C.f 等效于通过 object.__getattribute__(c, "f") 或 object.__getattribute__(C, "f") 查找。这样该函数就可以从对象或类中进行相同的访问。

适合作为静态方法的是那些不引用 self 变量的方法。

举例来说,一个统计软件包可能包括存放实验性数据的容器类。 该类提供了用于计算平均数、均值、中位数以及其他描述性的统计数据的方法。 不过,还可能存在在概念上相关但不依赖于这些数据的有用函数。 例如,erf(x) 是在统计工作中的便捷转换例程但并不直接依赖于特定的数据集。 它可以通过对象或者类来调用: s.erf(1.5) --> 0.9332 或者 Sample.erf(1.5) --> 0.9332。

由于静态方法返回的底层函数没有任何变化,因此示例调用也是意料之中:

class E:
    @staticmethod
    def f(x):
        return x * 10
>>> E.f(3)
30
>>> E().f(3)
30

使用非数据描述器协议,staticmethod 的纯 Python 版本如下:

import functools

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, objtype=None):
        return self.f

    def __call__(self, *args, **kwds):
        return self.f(*args, **kwds)

    @property
    def __annotations__(self):
        return self.f.__annotations__

functools.update_wrapper 调用增加了一个指向下层函数的 __wrapped__ 属性。 它还会向前传递必要的属性以使此包装器看起来像是被包装的函数,包括 function.__name__, function.__qualname__ 和 function.__doc__。

class E_sim:
    @StaticMethod
    def f(x: int) -> str:
        "Simple function example"
        return "!" * x

wrapped_ord = StaticMethod(ord)
>>> E_sim.f(3)
'!!!'
>>> E_sim().f(3)
'!!!'

>>> sm = vars(E_sim)['f']
>>> type(sm).__name__
'StaticMethod'
>>> f = E_sim.f
>>> type(f).__name__
'function'
>>> sm.__name__
'f'
>>> f.__name__
'f'
>>> sm.__qualname__
'E_sim.f'
>>> f.__qualname__
'E_sim.f'
>>> sm.__doc__
'Simple function example'
>>> f.__doc__
'Simple function example'
>>> sm.__annotations__
{'x': <class 'int'>, 'return': <class 'str'>}
>>> f.__annotations__
{'x': <class 'int'>, 'return': <class 'str'>}
>>> sm.__module__ == f.__module__
True
>>> sm(3)
'!!!'
>>> f(3)
'!!!'

>>> wrapped_ord('A')
65
>>> wrapped_ord.__module__ == ord.__module__
True
>>> wrapped_ord.__wrapped__ == ord
True
>>> wrapped_ord.__name__ == ord.__name__
True
>>> wrapped_ord.__qualname__ == ord.__qualname__
True
>>> wrapped_ord.__doc__ == ord.__doc__
True

类方法

与静态方法不同,类方法在调用函数之前将类引用放在参数列表的最前。无论调用方是对象还是类,此格式相同:

class F:
    @classmethod
    def f(cls, x):
        return cls.__name__, x
>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)

当方法仅需要具有类引用并且不依赖于存储在特定实例中的数据时,此行为就很有用。类方法的一种用途是创建备用类构造函数。例如,类方法 dict.fromkeys 从键列表创建一个新字典。纯 Python 的等价实现是:

class Dict(dict):
    @classmethod
    def fromkeys(cls, iterable, value=None):
        "Emulate dict_fromkeys() in Objects/dictobject.c"
        d = cls()
        for key in iterable:
            d[key] = value
        return d

现在可以这样构造一个新的唯一键字典:

>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
True
>>> d
{'a': None, 'b': None, 'r': None, 'c': None, 'd': None}

使用非数据描述器协议,classmethod 的纯 Python 版本如下:

import functools

class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        return MethodType(self.f, cls)
# Verify the emulation works
class T:
    @ClassMethod
    def cm(cls, x: int, y: str) -> tuple[str, int, str]:
        "Class method that returns a tuple"
        return (cls.__name__, x, y)
>>> T.cm(11, 22)
('T', 11, 22)

# Also call it from an instance
>>> t = T()
>>> t.cm(11, 22)
('T', 11, 22)

# Verify that T uses our emulation
>>> type(vars(T)['cm']).__name__
'ClassMethod'

# Verify that update_wrapper() correctly copied attributes
>>> T.cm.__name__
'cm'
>>> T.cm.__qualname__
'T.cm'
>>> T.cm.__doc__
'Class method that returns a tuple'
>>> T.cm.__annotations__
{'x': <class 'int'>, 'y': <class 'str'>, 'return': tuple[str, int, str]}

# Verify that __wrapped__ was added and works correctly
>>> f = vars(T)['cm'].__wrapped__
>>> type(f).__name__
'function'
>>> f.__name__
'cm'
>>> f(T, 11, 22)
('T', 11, 22)

ClassMethod 中的 functools.update_wrapper 调用增加了一个指向下层函数的 __wrapped__ 属性。它还会向前传递必要的属性以使此包装器看起来像是被包装的函数:function.__name__, function.__qualname__, function.__doc__ 以及 function.__annotations__。

成员对象和 slots

当一个类定义了 __slots__,它会用一个固定长度的 slot 值数组来替换实例字典。从用户的视角看,效果是这样的:

  1. 提供对由错误拼写的属性赋值导致的程序缺陷的即时检测。只允许在 __slots__ 中指定的属性名称:
class Vehicle:
    __slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
    ...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
  1. 帮助创建由描述器来管理对保存在 __slots__ 中的私有属性的访问的不可变对象:
class Immutable:

    __slots__ = ('_dept', '_name')          # Replace the instance dictionary

    def __init__(self, dept, name):
        self._dept = dept                   # Store to private attribute
        self._name = name                   # Store to private attribute

    @property                               # Read-only descriptor
    def dept(self):
        return self._dept

    @property
    def name(self):                         # Read-only descriptor
        return self._name
>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
Traceback (most recent call last):
    ...
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
Traceback (most recent call last):
    ...
AttributeError: 'Immutable' object has no attribute 'location'
  1. 节省内存。在 64 位 Linux 版本上,带有两个属性的实例如果使用 __slots__ 会占用 48 个字节,如果不使用则会占用 152 个字节。这种 享元设计模式 通常仅在要创建大量实例时才能显示效果。

  2. 提高速度。原作者在 Apple M1 处理器上使用 Python 3.10 测得:使用 __slots__ 读取实例变量的速度提高了 35%。

  3. 它会阻止 functools.cached_property 等依赖实例字典才能正确工作的工具:

from functools import cached_property

class CP:
    __slots__ = ()                          # Eliminates the instance dict

    @cached_property                        # Requires an instance dict
    def pi(self):
        return 4 * sum((-1.0)**n / (2.0*n + 1.0)
                       for n in reversed(range(100_000)))
>>> CP().pi
Traceback (most recent call last):
  ...
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.

要创建一个一模一样的纯 Python 版的 __slots__ 是不可能的,因为它需要直接访问 C 结构体并控制对象内存分配。 但是,我们可以构建一个非常相似的模拟版,其中作为 slot 的实际 C 结构体由一个私有的 _slotvalues 列表来模拟。 对该私有结构体的读写操作将由成员描述器来管理:

null = object()

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/descrobject.h'
        # Also see descr_new() in Objects/descrobject.c
        self.name = name
        self.clsname = clsname
        self.offset = offset

    def __get__(self, obj, objtype=None):
        'Emulate member_get() in Objects/descrobject.c'
        # Also see PyMember_GetOne() in Python/structmember.c
        if obj is None:
            return self
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        return value

    def __set__(self, obj, value):
        'Emulate member_set() in Objects/descrobject.c'
        obj._slotvalues[self.offset] = value

    def __delete__(self, obj):
        'Emulate member_delete() in Objects/descrobject.c'
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        obj._slotvalues[self.offset] = null

    def __repr__(self):
        'Emulate member_repr() in Objects/descrobject.c'
        return f'<Member {self.name!r} of {self.clsname!r}>'

type.__new__ 方法负责将成员对象添加到类变量:

class Type(type):
    'Simulate how the type metaclass adds member objects for slots'

    def __new__(mcls, clsname, bases, mapping, **kwargs):
        'Emulate type_new() in Objects/typeobject.c'
        # type_new() calls PyTypeReady() which calls add_methods()
        slot_names = mapping.get('slot_names', [])
        for offset, name in enumerate(slot_names):
            mapping[name] = Member(name, clsname, offset)
        return type.__new__(mcls, clsname, bases, mapping, **kwargs)

object.__new__ 方法负责创建具有 slot 而非实例字典的实例。以下是一个纯 Python 的粗略模拟版:

class Object:
    'Simulate how object.__new__() allocates memory for __slots__'

    def __new__(cls, *args, **kwargs):
        'Emulate object_new() in Objects/typeobject.c'
        inst = super().__new__(cls)
        if hasattr(cls, 'slot_names'):
            empty_slots = [null] * len(cls.slot_names)
            object.__setattr__(inst, '_slotvalues', empty_slots)
        return inst

    def __setattr__(self, name, value):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__setattr__(name, value)

    def __delattr__(self, name):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

要在真实的类中使用此模拟,只需从 Object 继承并将 metaclass 设为 Type:

class H(Object, metaclass=Type):
    'Instance variables stored in slots'

    slot_names = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

此时,元类已为 x 和 y 加载了成员对象:

>>> from pprint import pp
>>> pp(dict(vars(H)))
{'__module__': '__main__',
 '__doc__': 'Instance variables stored in slots',
 'slot_names': ['x', 'y'],
 '__init__': <function H.__init__ at 0x7fb5d302f9d0>,
 'x': <Member 'x' of 'H'>,
 'y': <Member 'y' of 'H'>}
# We test this separately because the preceding section is not
# doctestable due to the hex memory address for the __init__ function
>>> isinstance(vars(H)['x'], Member)
True
>>> isinstance(vars(H)['y'], Member)
True

当实例被创建时,它们将拥有一个用于存放属性的 _slotvalues 列表:

>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}

错误拼写或未赋值的属性将引发一个异常:

>>> h.xz
Traceback (most recent call last):
    ...
AttributeError: 'H' object has no attribute 'xz'
# Examples for deleted attributes are not shown because this section
# is already a bit lengthy.  We still test that code here.
>>> del h.x
>>> hasattr(h, 'x')
False

# Also test the code for uninitialized slots
>>> class HU(Object, metaclass=Type):
...     slot_names = ['x', 'y']
...
>>> hu = HU()
>>> hasattr(hu, 'x')
False
>>> hasattr(hu, 'y')
False

来源与许可

原文:Descriptor Guide。作者 Raymond Hettinger;官方简体中文翻译由 Python 文档翻译社区维护,原译者署名 wh2099。基于 Python 3.14 官方源码与对应中文 PO 全文准备,补译未译段落。文档适用 Python 文档许可,保留 Python Software Foundation 及相关贡献者版权;官方中文翻译贡献按项目说明采用 CC0。代码保持原文,仅核对结构与来源,未在本任务中运行。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容