描述器指南

  • 作者:
  • Raymond Hettinger(译者:wh2099 at outlook dot com)

  • 联系方式:

目录

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

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

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

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

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

  • 最后一部分有对内置描述器(用 C 编写)的纯 Python 等价实现。如果您想了解函数如何变成绑定方法或对 classmethod()staticmethod()property()slots 这类常见工具的实现感兴趣,请阅读此部分。

入门

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

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

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

  1. class Ten:
  2. def __get__(self, obj, objtype=None):
  3. return 10

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

  1. class A:
  2. x = 5 # 常规类属性
  3. y = Ten() # 描述器实例

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

  1. >>> a = A() # 创建一个类 A 的实例
  2. >>> a.x # 正常属性查找
  3. 5
  4. >>> a.y # 描述器查找
  5. 10

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

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

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

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

动态查找

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

  1. import os
  2.  
  3. class DirectorySize:
  4.  
  5. def __get__(self, obj, objtype=None):
  6. return len(os.listdir(obj.dirname))
  7.  
  8. class Directory:
  9.  
  10. size = DirectorySize() # 描述器实例
  11.  
  12. def __init__(self, dirname):
  13. self.dirname = dirname # 常规实例属性

交互式会话显示查找是动态的,每次都会计算不同的,经过更新的返回值:

  1. >>> s = Directory('songs')
  2. >>> g = Directory('games')
  3. >>> s.size # songs 目录有二十个文件
  4. 20
  5. >>> g.size # games 目录有三个文件
  6. 3
  7. >>> os.remove('games/chess') # 删除一个 game
  8. >>> g.size # 文件计数将自动更新
  9. 2

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

托管属性

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

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

  1. import logging
  2.  
  3. logging.basicConfig(level=logging.INFO)
  4.  
  5. class LoggedAgeAccess:
  6.  
  7. def __get__(self, obj, objtype=None):
  8. value = obj._age
  9. logging.info('Accessing %r giving %r', 'age', value)
  10. return value
  11.  
  12. def __set__(self, obj, value):
  13. logging.info('Updating %r to %r', 'age', value)
  14. obj._age = value
  15.  
  16. class Person:
  17.  
  18. age = LoggedAgeAccess() # 描述器实例
  19.  
  20. def __init__(self, name, age):
  21. self.name = name # 常规实例属性
  22. self.age = age # 调用 __set__()
  23.  
  24. def birthday(self):
  25. self.age += 1 # 调用 __get__() 和 __set__()

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

  1. >>> mary = Person('Mary M', 30) # 初始年龄更新会被记录
  2. INFO:root:Updating 'age' to 30
  3. >>> dave = Person('David D', 40)
  4. INFO:root:Updating 'age' to 40
  5.  
  6. >>> vars(mary) # 私有属性中的实际数据
  7. {'name': 'Mary M', '_age': 30}
  8. >>> vars(dave)
  9. {'name': 'David D', '_age': 40}
  10.  
  11. >>> mary.age # 访问数据并记录查找操作
  12. INFO:root:Accessing 'age' giving 30
  13. 30
  14. >>> mary.birthday() # 更新也会被记录
  15. INFO:root:Accessing 'age' giving 30
  16. INFO:root:Updating 'age' to 31
  17.  
  18. >>> dave.name # 常规属性查找不会被记录
  19. 'David D'
  20. >>> dave.age # 只有被管理的属性会被记录
  21. INFO:root:Accessing 'age' giving 40
  22. 40

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

定制名称

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

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

  1. import logging
  2.  
  3. logging.basicConfig(level=logging.INFO)
  4.  
  5. class LoggedAccess:
  6.  
  7. def __set_name__(self, owner, name):
  8. self.public_name = name
  9. self.private_name = '_' + name
  10.  
  11. def __get__(self, obj, objtype=None):
  12. value = getattr(obj, self.private_name)
  13. logging.info('Accessing %r giving %r', self.public_name, value)
  14. return value
  15.  
  16. def __set__(self, obj, value):
  17. logging.info('Updating %r to %r', self.public_name, value)
  18. setattr(obj, self.private_name, value)
  19.  
  20. class Person:
  21.  
  22. name = LoggedAccess() # 第一个描述器实例
  23. age = LoggedAccess() # 第二个描述器实例
  24.  
  25. def __init__(self, name, age):
  26. self.name = name # 调用第一个描述器
  27. self.age = age # 调用第二个描述器
  28.  
  29. def birthday(self):
  30. self.age += 1

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

  1. >>> vars(vars(Person)['name'])
  2. {'public_name': 'name', 'private_name': '_name'}
  3. >>> vars(vars(Person)['age'])
  4. {'public_name': 'age', 'private_name': '_age'}

现在,新类会记录对 nameage 二者的访问:

  1. >>> pete = Person('Peter P', 10)
  2. INFO:root:Updating 'name' to 'Peter P'
  3. INFO:root:Updating 'age' to 10
  4. >>> kate = Person('Catherine C', 20)
  5. INFO:root:Updating 'name' to 'Catherine C'
  6. INFO:root:Updating 'age' to 20

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

  1. >>> vars(pete)
  2. {'_name': 'Peter P', '_age': 10}
  3. >>> vars(kate)
  4. {'_name': 'Catherine C', '_age': 20}

结束语

descriptor 是指任何定义了 __get__(), __set__()__delete__() 的对象。

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

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

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

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

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

描述器的使用贯穿了整个语言。就是它让函数变成绑定方法。常见工具诸如 classmethod()staticmethod()property()functools.cached_property() 都作为描述器实现。

完整的实际例子

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

验证器类

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

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

  1. from abc import ABC, abstractmethod
  2.  
  3. class Validator(ABC):
  4.  
  5. def __set_name__(self, owner, name):
  6. self.private_name = '_' + name
  7.  
  8. def __get__(self, obj, objtype=None):
  9. return getattr(obj, self.private_name)
  10.  
  11. def __set__(self, obj, value):
  12. self.validate(value)
  13. setattr(obj, self.private_name, value)
  14.  
  15. @abstractmethod
  16. def validate(self, value):
  17. pass

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

自定义验证器

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

  1. class OneOf(Validator):
  2.  
  3. def __init__(self, *options):
  4. self.options = set(options)
  5.  
  6. def validate(self, value):
  7. if value not in self.options:
  8. raise ValueError(
  9. f'Expected {value!r} to be one of {self.options!r}'
  10. )
  11.  
  12. class Number(Validator):
  13.  
  14. def __init__(self, minvalue=None, maxvalue=None):
  15. self.minvalue = minvalue
  16. self.maxvalue = maxvalue
  17.  
  18. def validate(self, value):
  19. if not isinstance(value, (int, float)):
  20. raise TypeError(f'Expected {value!r} to be an int or float')
  21. if self.minvalue is not None and value < self.minvalue:
  22. raise ValueError(
  23. f'Expected {value!r} to be at least {self.minvalue!r}'
  24. )
  25. if self.maxvalue is not None and value > self.maxvalue:
  26. raise ValueError(
  27. f'Expected {value!r} to be no more than {self.maxvalue!r}'
  28. )
  29.  
  30. class String(Validator):
  31.  
  32. def __init__(self, minsize=None, maxsize=None, predicate=None):
  33. self.minsize = minsize
  34. self.maxsize = maxsize
  35. self.predicate = predicate
  36.  
  37. def validate(self, value):
  38. if not isinstance(value, str):
  39. raise TypeError(f'Expected {value!r} to be an str')
  40. if self.minsize is not None and len(value) < self.minsize:
  41. raise ValueError(
  42. f'Expected {value!r} to be no smaller than {self.minsize!r}'
  43. )
  44. if self.maxsize is not None and len(value) > self.maxsize:
  45. raise ValueError(
  46. f'Expected {value!r} to be no bigger than {self.maxsize!r}'
  47. )
  48. if self.predicate is not None and not self.predicate(value):
  49. raise ValueError(
  50. f'Expected {self.predicate} to be true for {value!r}'
  51. )

实际应用

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

  1. class Component:
  2.  
  3. name = String(minsize=3, maxsize=10, predicate=str.isupper)
  4. kind = OneOf('wood', 'metal', 'plastic')
  5. quantity = Number(minvalue=0)
  6.  
  7. def __init__(self, name, kind, quantity):
  8. self.name = name
  9. self.kind = kind
  10. self.quantity = quantity

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

  1. >>> Component('Widget', 'metal', 5) # 阻止: 'Widget' 不是全大写
  2. Traceback (most recent call last): ...
  3. ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'
  4.  
  5. >>> Component('WIDGET', 'metle', 5) # 阻止: 'metle' 拼写错误
  6. Traceback (most recent call last): ...
  7. ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}
  8.  
  9. >>> Component('WIDGET', 'metal', -5) # 阻止: -5 为负数
  10. Traceback (most recent call last): ...
  11. ValueError: Expected -5 to be at least 0
  12.  
  13. >>> Component('WIDGET', 'metal', 'V') # 阻止: 'V' 不是数字
  14. Traceback (most recent call last): ...
  15. TypeError: Expected 'V' to be an int or float
  16.  
  17. >>> c = Component('WIDGET', 'metal', 5) # 允许: 输入有效

技术教程

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

摘要

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

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

定义与介绍

一般而言,描述器是具有描述器协议中的方法之一的属性值。 这些方法是 __get__(), __set__()__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)

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

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

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

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

描述器调用概述

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

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

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

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

通过实例调用

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

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

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

  1. def find_name_in_mro(cls, name, default):
  2. "Emulate PyTypeLookup() in Objects/typeobject.c"
  3. for base in cls.__mro__:
  4. if name in vars(base):
  5. return vars(base)[name]
  6. return default
  7.  
  8. def object_getattribute(obj, name):
  9. "Emulate PyObject_GenericGetAttr() in Objects/object.c"
  10. null = object()
  11. objtype = type(obj)
  12. cls_var = find_name_in_mro(objtype, name, null)
  13. descr_get = getattr(type(cls_var), '__get__', null)
  14. if descr_get is not null:
  15. if (hasattr(type(cls_var), '__set__')
  16. or hasattr(type(cls_var), '__delete__')):
  17. return descr_get(cls_var, obj, objtype) # 数据描述器
  18. if hasattr(obj, '__dict__') and name in vars(obj):
  19. return vars(obj)[name] # 实例变量
  20. if descr_get is not null:
  21. return descr_get(cls_var, obj, objtype) # 非数据描述器
  22. if cls_var is not null:
  23. return cls_var # 类变量
  24. raise AttributeError(name)

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

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

  1. def getattr_hook(obj, name):
  2. "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
  3. try:
  4. return obj.__getattribute__(name)
  5. except AttributeError:
  6. if not hasattr(type(obj), '__getattr__'):
  7. raise
  8. return type(obj).__getattr__(obj, name) # __getattr__

通过类调用

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

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

完整的 C 实现可在 Objects/typeobject.c [https://github.com/python/cpython/tree/3.13/Objects/typeobject.c] 里的 type_getattro()PyTypeLookup() 中找到。

通过 super 调用

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

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

完整的 C 实现可在 Objects/typeobject.c [https://github.com/python/cpython/tree/3.13/Objects/typeobject.c] 里的 super_getattro() 中找到。 纯 Python 的等价实现可在 Guido 的教程 [https://www.python.org/download/releases/2.2.3/descrintro/#cooperation] 中找到。

调用逻辑总结

描述器的机制嵌入在 object, typesuper()__getattribute__() 方法中。

要记住的重要点是:

  • 描述器将由 __getattribute__() 方法唤起。

  • 类从 objecttypesuper() 继承此机制。

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

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

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

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

自动名称通知

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

实现的细节在 Objects/typeobject.c [https://github.com/python/cpython/tree/3.13/Objects/typeobject.c] 里的 type_new()set_names() 中。

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

ORM (对象关系映射)示例

以下代码展示了如何使用数据描述器来实现简单的 对象关系映射 [https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping] 框架。

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

  1. class Field:
  2.  
  3. def __set_name__(self, owner, name):
  4. self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
  5. self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'
  6.  
  7. def __get__(self, obj, objtype=None):
  8. return conn.execute(self.fetch, [obj.key]).fetchone()[0]
  9.  
  10. def __set__(self, obj, value):
  11. conn.execute(self.store, [value, obj.key])
  12. conn.commit()

我们可以使用 Field 类来定义描述了数据库中每张表的结构的 模型 [https://en.wikipedia.org/wiki/Database_model]:

  1. class Movie:
  2. table = 'Movies' # 表名
  3. key = 'title' # 主键
  4. director = Field()
  5. year = Field()
  6.  
  7. def __init__(self, key):
  8. self.key = key
  9.  
  10. class Song:
  11. table = 'Music'
  12. key = 'title'
  13. artist = Field()
  14. year = Field()
  15. genre = Field()
  16.  
  17. def __init__(self, key):
  18. self.key = key

要使用模型,首先要连接到数据库:

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

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

  1. >>> Movie('Star Wars').director
  2. 'George Lucas'
  3. >>> jaws = Movie('Jaws')
  4. >>> f'Released in {jaws.year} by {jaws.director}'
  5. 'Released in 1975 by Steven Spielberg'
  6.  
  7. >>> Song('Country Roads').artist
  8. 'John Denver'
  9.  
  10. >>> Movie('Star Wars').director = 'J.J. Abrams'
  11. >>> Movie('Star Wars').director
  12. 'J.J. Abrams'

纯 Python 等价实现

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

属性

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

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

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

  1. class C:
  2. def getx(self): return self.__x
  3. def setx(self, value): self.__x = value
  4. def delx(self): del self.__x
  5. x = property(getx, setx, delx, "I'm the 'x' property.")

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

  1. class Property:
  2. "Emulate PyProperty_Type() in Objects/descrobject.c"
  3.  
  4. def __init__(self, fget=None, fset=None, fdel=None, doc=None):
  5. self.fget = fget
  6. self.fset = fset
  7. self.fdel = fdel
  8. if doc is None and fget is not None:
  9. doc = fget.__doc__
  10. self.__doc__ = doc
  11.  
  12. def __set_name__(self, owner, name):
  13. self.__name__ = name
  14.  
  15. def __get__(self, obj, objtype=None):
  16. if obj is None:
  17. return self
  18. if self.fget is None:
  19. raise AttributeError
  20. return self.fget(obj)
  21.  
  22. def __set__(self, obj, value):
  23. if self.fset is None:
  24. raise AttributeError
  25. self.fset(obj, value)
  26.  
  27. def __delete__(self, obj):
  28. if self.fdel is None:
  29. raise AttributeError
  30. self.fdel(obj)
  31.  
  32. def getter(self, fget):
  33. return type(self)(fget, self.fset, self.fdel, self.__doc__)
  34.  
  35. def setter(self, fset):
  36. return type(self)(self.fget, fset, self.fdel, self.__doc__)
  37.  
  38. def deleter(self, fdel):
  39. return type(self)(self.fget, self.fset, fdel, self.__doc__)

这个内置的 property() 每当用户访问属性时生效,随后的变化需要一个方法的参与。

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

  1. class Cell:
  2. ...
  3.  
  4. @property
  5. def value(self):
  6. "Recalculate the cell before returning value"
  7. self.recalc()
  8. return self._value

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

函数和方法

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

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

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

  1. class MethodType:
  2. "Emulate PyMethod_Type in Objects/classobject.c"
  3.  
  4. def __init__(self, func, obj):
  5. self.__func__ = func
  6. self.__self__ = obj
  7.  
  8. def __call__(self, *args, **kwargs):
  9. func = self.__func__
  10. obj = self.__self__
  11. return func(obj, *args, **kwargs)
  12.  
  13. def __getattribute__(self, name):
  14. "Emulate method_getset() in Objects/classobject.c"
  15. if name == '__doc__':
  16. return self.__func__.__doc__
  17. return object.__getattribute__(self, name)
  18.  
  19. def __getattr__(self, name):
  20. "Emulate method_getattro() in Objects/classobject.c"
  21. return getattr(self.__func__, name)
  22.  
  23. def __get__(self, obj, objtype=None):
  24. "Emulate method_descr_get() in Objects/classobject.c"
  25. return self

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

  1. class Function:
  2. ...
  3.  
  4. def __get__(self, obj, objtype=None):
  5. "Simulate func_descr_get() in Objects/funcobject.c"
  6. if obj is None:
  7. return self
  8. return MethodType(self, obj)

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

  1. class D:
  2. def f(self):
  3. return self
  4.  
  5. class D2:
  6. pass

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

  1. >>> D.f.__qualname__
  2. 'D.f'

通过类字典访问函数不会唤起 __get__()。 相反,它只是返回下层的函数对象:

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

通过类进行点号访问调用 __get__(),它将只原样返回下层的函数:

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

有趣的行为发生在通过实例进行点号访问期间。 点号查找调用 __get__(),它将返回绑定的方法对象:

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

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

  1. >>> d.f.__func__
  2. <function D.f at 0x00C45070>
  3.  
  4. >>> d.f.__self__
  5. <__main__.D object at 0x00B18C90>

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

方法的种类

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

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

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

转换形式

通过对象调用

通过类调用

function — 函数

f(obj, args)

f(args)

静态方法

f(args)

f(args)

类方法

f(type(obj), args)

f(cls, args)

静态方法

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

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

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

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

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

使用非数据描述器,纯 Python 版本的 staticmethod() 如下所示:

  1. import functools
  2.  
  3. class StaticMethod:
  4. "Emulate PyStaticMethod_Type() in Objects/funcobject.c"
  5.  
  6. def __init__(self, f):
  7. self.f = f
  8. functools.update_wrapper(self, f)
  9.  
  10. def __get__(self, obj, objtype=None):
  11. return self.f
  12.  
  13. def __call__(self, *args, **kwds):
  14. return self.f(*args, **kwds)

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

类方法

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

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

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

  1. class Dict(dict):
  2. @classmethod
  3. def fromkeys(cls, iterable, value=None):
  4. "Emulate dict_fromkeys() in Objects/dictobject.c"
  5. d = cls()
  6. for key in iterable:
  7. d[key] = value
  8. return d

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

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

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

  1. import functools
  2.  
  3. class ClassMethod:
  4. "Emulate PyClassMethod_Type() in Objects/funcobject.c"
  5.  
  6. def __init__(self, f):
  7. self.f = f
  8. functools.update_wrapper(self, f)
  9.  
  10. def __get__(self, obj, cls=None):
  11. if cls is None:
  12. cls = type(obj)
  13. return MethodType(self.f, cls)

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

成员对象和 slots

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

  1. Provides immediate detection of bugs due to misspelled attribute assignments. Only attribute names specified in __slots__ are allowed:
  1. class Vehicle:
  2. __slots__ = ('id_number', 'make', 'model')
  1. >>> auto = Vehicle()
  2. >>> auto.id_nubmer = 'VYE483814LQEX'
  3. Traceback (most recent call last): ...
  4. AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
  1. Helps create immutable objects where descriptors manage access to private attributes stored in __slots__:
  1. class Immutable:
  2.  
  3. __slots__ = ('_dept', '_name') # 替代实例字典
  4.  
  5. def __init__(self, dept, name):
  6. self._dept = dept # 保存到私有属性
  7. self._name = name # 保存到私有属性
  8.  
  9. @property # 只读描述器
  10. def dept(self):
  11. return self._dept
  12.  
  13. @property
  14. def name(self): # 只读描述器
  15. return self._name
  1. >>> mark = Immutable('Botany', 'Mark Watney')
  2. >>> mark.dept
  3. 'Botany'
  4. >>> mark.dept = 'Space Pirate'
  5. Traceback (most recent call last): ...
  6. AttributeError: property 'dept' of 'Immutable' object has no setter
  7. >>> mark.location = 'Mars'
  8. Traceback (most recent call last): ...
  9. AttributeError: 'Immutable' object has no attribute 'location'
  1. Saves memory. On a 64-bit Linux build, an instance with two attributes takes 48 bytes with __slots__ and 152 bytes without. This flyweight design pattern [https://en.wikipedia.org/wiki/Flyweight_pattern] likely only matters when a large number of instances are going to be created.

  2. Improves speed. Reading instance variables is 35% faster with __slots__ (as measured with Python 3.10 on an Apple M1 processor).

  3. Blocks tools like functools.cached_property() which require an instance dictionary to function correctly:

  1. from functools import cached_property
  2.  
  3. class CP:
  4. __slots__ = () # 去除实例字典
  5.  
  6. @cached_property # 需要一个实例字典
  7. def pi(self):
  8. return 4 * sum((-1.0)**n / (2.0*n + 1.0)
  9. for n in reversed(range(100_000)))
  1. >>> CP().pi
  2. Traceback (most recent call last): ...
  3. TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.

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

  1. null = object()
  2.  
  3. class Member:
  4.  
  5. def __init__(self, name, clsname, offset):
  6. 'Emulate PyMemberDef in Include/structmember.h'
  7. # 另请参阅 Objects/descrobject.c 中的 descr_new()
  8. self.name = name
  9. self.clsname = clsname
  10. self.offset = offset
  11.  
  12. def __get__(self, obj, objtype=None):
  13. 'Emulate member_get() in Objects/descrobject.c'
  14. # 另请参阅 Python/structmember.c 中的 PyMember_GetOne()
  15. if obj is None:
  16. return self
  17. value = obj._slotvalues[self.offset]
  18. if value is null:
  19. raise AttributeError(self.name)
  20. return value
  21.  
  22. def __set__(self, obj, value):
  23. 'Emulate member_set() in Objects/descrobject.c'
  24. obj._slotvalues[self.offset] = value
  25.  
  26. def __delete__(self, obj):
  27. 'Emulate member_delete() in Objects/descrobject.c'
  28. value = obj._slotvalues[self.offset]
  29. if value is null:
  30. raise AttributeError(self.name)
  31. obj._slotvalues[self.offset] = null
  32.  
  33. def __repr__(self):
  34. 'Emulate member_repr() in Objects/descrobject.c'
  35. return f'<Member {self.name!r} of {self.clsname!r}>'

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

  1. class Type(type):
  2. 'Simulate how the type metaclass adds member objects for slots'
  3.  
  4. def __new__(mcls, clsname, bases, mapping, **kwargs):
  5. 'Emulate type_new() in Objects/typeobject.c'
  6. # type_new() 将调用 PyTypeReady(),后者将调用 add_methods()
  7. slot_names = mapping.get('slot_names', [])
  8. for offset, name in enumerate(slot_names):
  9. mapping[name] = Member(name, clsname, offset)
  10. return type.__new__(mcls, clsname, bases, mapping, **kwargs)

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

  1. class Object:
  2. 'Simulate how object.__new__() allocates memory for __slots__'
  3.  
  4. def __new__(cls, *args, **kwargs):
  5. 'Emulate object_new() in Objects/typeobject.c'
  6. inst = super().__new__(cls)
  7. if hasattr(cls, 'slot_names'):
  8. empty_slots = [null] * len(cls.slot_names)
  9. object.__setattr__(inst, '_slotvalues', empty_slots)
  10. return inst
  11.  
  12. def __setattr__(self, name, value):
  13. 'Emulate PyObjectGenericSetAttrWithDict() Objects/object.c'
  14. cls = type(self)
  15. if hasattr(cls, 'slot_names') and name not in cls.slot_names:
  16. raise AttributeError(
  17. f'{cls.__name__!r} object has no attribute {name!r}'
  18. )
  19. super().__setattr__(name, value)
  20.  
  21. def __delattr__(self, name):
  22. 'Emulate PyObjectGenericSetAttrWithDict() Objects/object.c'
  23. cls = type(self)
  24. if hasattr(cls, 'slot_names') and name not in cls.slot_names:
  25. raise AttributeError(
  26. f'{cls.__name__!r} object has no attribute {name!r}'
  27. )
  28. super().__delattr__(name)

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

  1. class H(Object, metaclass=Type):
  2. 'Instance variables stored in slots'
  3.  
  4. slot_names = ['x', 'y']
  5.  
  6. def __init__(self, x, y):
  7. self.x = x
  8. self.y = y

这时,metaclass 已经为 xy 加载了成员对象:

  1. >>> from pprint import pp
  2. >>> pp(dict(vars(H)))
  3. {'__module__': '__main__',
  4. '__doc__': 'Instance variables stored in slots',
  5. 'slot_names': ['x', 'y'],
  6. '__init__': <function H.__init__ at 0x7fb5d302f9d0>,
  7. 'x': <Member 'x' of 'H'>,
  8. 'y': <Member 'y' of 'H'>}

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

  1. >>> h = H(10, 20)
  2. >>> vars(h)
  3. {'_slotvalues': [10, 20]}
  4. >>> h.x = 55
  5. >>> vars(h)
  6. {'_slotvalues': [55, 20]}

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

  1. >>> h.xz
  2. Traceback (most recent call last): ...
  3. AttributeError: 'H' object has no attribute 'xz'