第 22 章 动态属性和特性

本文介绍了Python中动态属性和特性的使用,包括通过`__getattr__`实现动态属性访问,使用`property`进行计算属性创建,并展示了如何处理无效属性名。文章还探讨了`__new__`方法在对象创建中的作用,以及如何通过特性进行属性验证,创建计算特性以处理JSON数据。最后,讨论了特性的文档、工厂函数以及属性删除的处理。
摘要由CSDN通过智能技术生成

特性的关键重要性在于,特性使您将公共数据属性作为类的公共接口的一部分公开是非常安全的,并且确实是可行的。

                                                                -----Martelli, Ravenscroft & Holden, 为什么特性很重要

数据属性和方法在 Python 中统称为属性(attribute)。方法是可调用的属性。动态属性呈现与数据属性相同的接口——即obj.attr——但需要进行计算。这遵循 Bertrand Meyer 的统一访问原则:

不管服务是由存储还是计算实现的,一个模块提供的所有服务都应该通过统一的方法使用。

有几种方法可以在 Python 中实现动态属性。本章介绍最简单的方法:@property 装饰器和 __getattr__ 特殊方法。

实现 __getattr__ 的用户定义类可以实现动态属性的变体,我称之为虚拟属性:未在类的源代码中的任何地方显式声明的属性,也不存在于实例 __dict__ 中,但在用户尝试读取一个不存在的属性(如 obj.no_such_attr)时,在其他的地方检索或进行即时计算。

编写动态和虚拟属性是框架作者所做的那种元编程。但是,在 Python 中,相关的基础技术很简单,因此我们可以在日常数据转换任务中使用。这就是我们将如何开始本章的学习。

本章新增内容

本章的大部分更新都源于对@functools.cached_property(在Python 3.8 中引入)的讨论,以及@property 与@functools.cache 的组合使用(3.9 中的新功能)。这影响了出现在“Computed Properties”中的 Record 和 Event 类的代码。我还添加了一个利用  PEP 412—Key-Sharing Dictionary的优化的重构示例。

为了在保持示例可读性的同时突出更多相关功能,我删除了一些非必要的代码——将旧的 DbRecord 类合并到 Record 中,将 shelve.Shelve 替换为 dict,并删除下载 OSCON 数据集的逻辑——这些示例现在从Fluent Python, Second Edition code repository中包含的本地文件中读取。

使用动态属性转换数据

在接下来的几个示例中,我们将利用动态属性来处理 O'Reilly 为 OSCON 2014 会议发布的 JSON 数据集。示例 22-1 显示了来自该数据集的四条记录。

示例 22-1。来自 osconfeed.json 的样本记录;一些字段内容缩写

{ "Schedule":
  { "conferences": [{"serial": 115 }],
    "events": [
      { "serial": 34505,
        "name": "Why Schools Don´t Use Open Source to Teach Programming",
        "event_type": "40-minute conference session",
        "time_start": "2014-07-23 11:30:00",
        "time_stop": "2014-07-23 12:10:00",
        "venue_serial": 1462,
        "description": "Aside from the fact that high school programming...",
        "website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505",
        "speakers": [157509],
        "categories": ["Education"] }
    ],
    "speakers": [
      { "serial": 157509,
        "name": "Robert Lefkowitz",
        "photo": null,
        "url": "http://sharewave.com/",
        "position": "CTO",
        "affiliation": "Sharewave",
        "twitter": "sharewaveteam",
        "bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..." }
    ],
    "venues": [
      { "serial": 1462,
        "name": "F151",
        "category": "Conference Venues" }
    ]
  }
}

示例 22-1 显示了 JSON 文件中 895 条记录中的 4 条。整个数据集是一个带有键“Schedule”的 JSON 对象,其值是另一个具有四个键的映射:“conferences”、“events”、“speakers”和“venues”。这四个键中的每一个都映射到一个记录列表。在完整的数据集中,“events”、“speakers”和“venues”列表有几十或数百条记录,而“conferences”只有一个示例 22-1 所示的记录。每条记录都有一个“serial”字段,它是列表中记录的唯一标识符。

我使用 Python 的控制台来探索数据集,如示例 22-2 所示。

示例 22-2。 osconfeed.json 的交互式探索

>>> import json
>>> with open('data/osconfeed.json') as fp:
...     feed = json.load(fp)  1
>>> sorted(feed['Schedule'].keys())  2
['conferences', 'events', 'speakers', 'venues']
>>> for key, value in sorted(feed['Schedule'].items()):
...     print(f'{len(value):3} {key}')  3
...
  1 conferences
484 events
357 speakers
 53 venues
>>> feed['Schedule']['speakers'][-1]['name']  4
'Carina C. Zona'
>>> feed['Schedule']['speakers'][-1]['serial']  5
141590
>>> feed['Schedule']['events'][40]['name']
'There *Will* Be Bugs'
>>> feed['Schedule']['events'][40]['speakers']  6
[3471, 5199]
  1. feed 是一个包含嵌套字典和列表的字典,具有字符串和整数值。
  2. 列出“Schedule”中的四个记录集合。
  3. 显示每个集合的记录数。
  4. 浏览嵌套的字典和列表以获取最后一位发言者的姓名。
  5. 获取同一演讲者的序列号。
  6. 每个活动都有一个“演讲者”列表,其中包含零或多个演讲者的序列号。

使用动态属性访问 JSON 类数据

示例 22-2 很简单,但语法 feed['Schedule']['events'][40]['name'] 很冗长。在 JavaScript 中,您可以用 feed.Schedule.events[40].name 来获取那个值。在 Python 中实现类似 dict 的类很容易——网络上有很多实现。我自己实现了 FrozenJSON,它比大多数实现都简单,因为它只是支持读取:它用来访问数据。FrozenJSON 也是递归的,自动处理嵌套的映射和列表。

示例 22-3 是 FrozenJSON 的演示,源代码在示例 22-4 中。

示例 22-3。示例 22-4 中的 FrozenJSON能够读取 name 等属性并调用 .keys() 和 .items() 等方法

    >>> import json
    >>> raw_feed = json.load(open('data/osconfeed.json'))
    >>> feed = FrozenJSON(raw_feed)  1
    >>> len(feed.Schedule.speakers)  2
    357
    >>> feed.keys()
    dict_keys(['Schedule'])
    >>> sorted(feed.Schedule.keys())  3
    ['conferences', 'events', 'speakers', 'venues']
    >>> for key, value in sorted(feed.Schedule.items()): 4
    ...     print(f'{len(value):3} {key}')
    ...
      1 conferences
    484 events
    357 speakers
     53 venues
    >>> feed.Schedule.speakers[-1].name  5
    'Carina C. Zona'
    >>> talk = feed.Schedule.events[40]
    >>> type(talk)  6
    <class 'explore0.FrozenJSON'>
    >>> talk.name
    'There *Will* Be Bugs'
    >>> talk.speakers  7
    [3471, 5199]
    >>> talk.flavor  8
    Traceback (most recent call last):
      ...
    KeyError: 'flavor'
  1. 从由嵌套字典和列表组成的 raw_feed 构建 FrozenJSON 实例。
  2. FrozenJSON 允许使用属性表示法遍历嵌套的字典;在这里,我们显示演讲者列表的长度。
  3. 也可以访问底层 dicts 的方法,如 .keys(),获取记录集合feed的name。
  4. 使用 items()方法,我们可以获取记录集合的名称及其内容,然后显示每个记录集合的 len()。
  5. 列表,例如 feed.Schedule.speakers,仍然是一个列表,但如果里面的元素是映射,则将其转换为 FrozenJSON。
  6. Events列表中的第 40 个元素是一个 JSON 对象;现在它是一个 FrozenJSON 实例。
  7. Events记录有一个speakers列表,其中包含演讲者序列号。
  8. 尝试读取不存在属性会抛出 KeyError,而不是通常的 AttributeError。

FrozenJSON 类的基石是 __getattr__ 方法,我们已经在“Vector Take #3: Dynamic Attribute Access”中的 Vector 示例中使用了该方法,以按字母(v.x、v.y、v.z 等)获取 Vector 分量。重要的是要记住 __getattr__ 特殊方法仅在通常的过程无法获取属性时(即,当在实例、类或其超类中找不到指定的属性时),才由解释器调用。

示例 22-3 的最后一行暴露了我的代码的一个小问题:尝试读取缺失的属性应该抛出 AttributeError异常,而不是所示的 KeyError。当我实现异常处理时, __getattr__ 方法代码量增加了一倍,而且偏离了我想要展示的最重要的逻辑。由于用户知道 FrozenJSON 是由映射和列表构建的,我认为 KeyError 异常也不会降低可读性。

示例 22-4。 explore0.py:将 JSON 数据集转换为包含嵌套 FrozenJSON 对象、列表和简单类型的 FrozenJSON

from collections import abc


class FrozenJSON:
    """
       A read-only façade for navigating a JSON-like object
       using attribute notation
    """

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

    def __getattr__(self, name):  2
        try:
            return getattr(self.__data, name)  3
        except AttributeError:
            return FrozenJSON.build(self.__data[name])  4

    def __dir__(self):  5
        return self.__data.keys()

    @classmethod
    def build(cls, obj):  6
        if isinstance(obj, abc.Mapping):  7
            return cls(obj)
        elif isinstance(obj, abc.MutableSequence):  8
            return [cls.build(item) for item in obj]
        else:  9
            return obj
  1. 从mapping参数构建一个dict。这确保我们得到一个映射或可以转换成映射的对象。 __data 上的双下划线前缀使其成为私有属性。
  2. __getattr__ 仅在没有指定名称(name)的属性时才被调用。
  3. 如果 name 匹配实例 __data dict 的属性,返回那个熟悉。这就是处理像 feed.keys() 这样的调用的方式:keys 方法是 __data 字典的一个属性。
  4. 否则,从 self.__data 中获取键为name对应的元素,并返回调用 FrozenJSON.build() 得到的结果
  5. 实现 __dir__ 支持内置的 dir() ,进而支持标准 Python 控制台以及 IPython、Jupyter Notebook 等中的自动完成。这行简单的代码将对基于 self.__data 中的键的进行递归,因为 __getattr__ 动态构建 FrozenJSON 实例 - 对于数据的交互式探索很有用。
  6. 这是一个备用构造函数,是 @classmethod 装饰器的常见用途。
  7. 如果 obj 是一个映射,则使用它构建一个 FrozenJSON。这是天鹅类型的一个例子——如果需要复习,请参阅“Goose typing”
  8. 如果obj是一个 MutableSequence,那么obj肯定是列表,所以我们通过将 obj 中的每个项传递给 .build() 进行递归来构建一个列表
  9. 如果它不是字典或列表,则原封不动的返回元素。

FrozenJSON 实例的 __data 私有实例属性实际被存储为_FrozenJSON__data ,如 “Private and “Protected” Attributes in Python”中所述。尝试通过其他名称获取属性将触发 __getattr__。此方法将首先查看 self.__data 字典是否具有该名称的属性(不是键!);这允许 FrozenJSON 实例通过委托给 self.__data.items() 来处理items等的 dict 方法。如果 self.__data 没有给定名称的属性,则 __getattr__ 使用 name 作为键从 self.__data 中获取元素,并将元素传递给 FrozenJSON.build。这允许在 JSON 数据中的嵌套结构中导航,因为每个嵌套映射都通过 build 类方法转换为另一个 FrozenJSON 实例。

注意 FrozenJSON 不会转换或缓存原始数据集。当我们遍历数据时,__getattr__ 每次都会重新创建 FrozenJSON 实例。对于这种大小的数据集以及仅用于探索或转换数据的脚本来说,这没问题。

从随机源中生成或模拟动态属性名称的脚本都必须处理一个问题:原始数据中的键可能不适合作为属性名称。下一节将解决这个问题。

处理无效属性名

FrozenJSON 代码没有对 Python 关键字的属性名称进行特殊处理。例如,如果您构建这样的对象:

>>> student = FrozenJSON({'name': 'Jim Bo', 'class': 1982})

此时无法读取 student.class,因为 class 是 Python 中的保留关键字:

>>> student.class
  File "<stdin>", line 1
    student.class
         ^
SyntaxError: invalid syntax

当然,这样做永远没有问题:

>>> getattr(student, 'class')
1982

但是 FrozenJSON 的想法是提供对数据的便捷访问,所以更好的解决方案是检查传入给 FrozenJSON.__init__ 的映射中的键是否是python关键字,如果是,则在键后面添加一个 _,因此可以像这样读取属性:

>>> student.class_
1982

这可以通过将示例 22-4 中的单行 __init__ 替换为示例 22-5 中的版本来实现。

示例 22-5。 explore1.py:在名称为 Python 关键字的属性名称后面 添加_

    def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):  1
                key += '_'
            self.__data[key] = value
  1. keyword.iskeyword(...) 函数正是我们所需要的;要使用它,必须导入keyword模块,此代码段中没有导入部分

如果 JSON 记录中的键不是有效的 Python 标识符,可能会出现类似的问题:

>>> x = FrozenJSON({'2be':'or not'})
>>> x.2be
  File "<stdin>", line 1
    x.2be
      ^
SyntaxError: invalid syntax

这些有问题的键在 Python 3 中很容易检测到,因为 str 类提供了 s.isidentifier() 方法,该方法根据语言语法告诉您 s 是否是有效的 Python 标识符。但是将不是有效标识符的键转换为有效的属性名称并非易事。一种解决方案是实现 __getitem__ 以允许使用 x['2be'] 之类的符号访问属性。为了简单起见,我不会处理这个问题。

在考虑了动态属性名称之后,让我们转向 FrozenJSON 的另一个基本特性:类方法build的逻辑。__getattr__ 使用 Frozen.JSON.build 根据被访问的属性的值返回不同类型的对象:嵌套结构被转换为 FrozenJSON 实例或 FrozenJSON 实例列表。

代替类方法,同样的逻辑可以实现为 __new__ 特殊方法,我们将在接下来看到。

使用 __new__方法 灵活地创建对象

我们经常将 __init__ 称为构造方法,但那是因为我们借鉴了其他语言的行话。在 Python 中,__init__ 将 self 作为第一个参数,因此当解释器调用 __init__ 时,对象已经存在了。此外, __init__ 不能返回任何内容。所以它实际上是一个初始化器,而不是构造器。

当调用一个类来创建一个实例时,Python 调用该类来构造一个实例的特殊方法是 __new__。这是一个类方法,但是得到了特殊处理,所以没有使用 @classmethod 装饰器。Python 获取 __new__ 返回的实例,然后将其作为 __init__ 的第一个参数 self 传递给__init__。我们很少需要编写 __new__ 代码,因为从object继承的实现足以满足绝大多数用例。

如有必要,__new__ 方法还可以返回不同类的实例。发生这种情况时,解释器不会调用 __init__。换句话说,Python 构建对象的逻辑类似于以下伪代码:

# pseudocode for object construction
def make(the_class, some_arg):
    new_object = the_class.__new__(some_arg)
    if isinstance(new_object, the_class):
        the_class.__init__(new_object, some_arg)
    return new_object

# the following statements are roughly equivalent
x = Foo('bar')
x = make(Foo, 'bar')

示例 22-6 显示了 FrozenJSON 的一种变体,其中以前的类方法build的逻辑被移动到了 __new__方法中。

示例 22-6。 explore2.py:使用 new 而不是 build 来构造可能是也可能不是 FrozenJSON 实例的新对象

from collections import abc
import keyword

class FrozenJSON:
    """A read-only façade for navigating a JSON-like object
       using attribute notation
    """

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

    def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):
                key += '_'
            self.__data[key] = value

    def __getattr__(self, name):
        try:
            return getattr(self.__data, name)
        except AttributeError:
            return FrozenJSON(self.__data[name])  4

    def __dir__(self):
        return self.__data.keys()
  1. 作为类方法,__new__ 获取的第一个参数是类本身,除了 self,其余参数与 __init__ 的参数相同。
  2. 默认行为是委托给超类的 __new__。在这种情况下,我们从对象基类调用 __new__,将 FrozenJSON 作为唯一参数传递。
  3. __new__ 的其余行与旧的build方法完全相同
  4. 这是之前调用 FrozenJSON.build 的地方;现在我们只调用 FrozenJSON 类,Python 会通过调用 FrozenJSON.__new__ 来处理它。

__new__ 方法将类作为第一个参数,因为通常创建的对象将是该类的实例。所以,在 FrozenJSON.__new__ 中,表达式 super().__new__(cls) 会调用 object.__new__(FrozenJSON) ,对象类构建的实例实际上是 FrozenJSON实例。在解释器的内部,新实例的 __class__ 属性将包含对 FrozenJSON 的引用——不过实际构造是由解释器调用C语言实现的 object.__new__ 方法执行。

OSCON JSON 数据集的结构对交互式探索没有帮助。例如,索引 第40处名 为“There *Will* Be Bugs”的活动有两个发言人,分别是 3471 和 5199。查找演讲者的名字很麻烦,因为这些是序列号,而 Schedule.speakers 列表没有被序列号进行索引。要获取每个发言者,我们必须遍历该列表,直到找到具有匹配序列号的记录。我们的下一个任务是重构数据,以便自动获取所链接的记录。

计算特性(property)

Note:

我们第一次看到@property 装饰器是在第 11 章的“A Hashable Vector2d”一节。在示例 11-7 中,我在 Vector2d 中使用了两个属性,只是为了将 x 和 y 属性设为只读。在这里,我们将看到在特性中计算值,从而讨论如何缓存这些值。

OSCON JSON 数据的“events”列表中的记录包含指向“speakers”和“venues”列表中的记录的整数序列号。例如,这是一次会议谈话的记录(带有省略的描述):

{ "serial": 33950,
  "name": "There *Will* Be Bugs",
  "event_type": "40-minute conference session",
  "time_start": "2014-07-23 14:30:00",
  "time_stop": "2014-07-23 15:10:00",
  "venue_serial": 1449,
  "description": "If you're pushing the envelope of programming...",
  "website_url": "http://oscon.com/oscon2014/public/schedule/detail/33950",
  "speakers": [3471, 5199],
  "categories": ["Python"] }

我们将实现一个带有venues和speakers属性的 Event 类,以自动返回链接数据——换句话说,“取消引用”序列号。 这是Event 实例期望的行为:

示例 22-7。读取venue和speakers返回 Record 对象。

    >>> event  1
    <Event 'There *Will* Be Bugs'>
    >>> event.venue  2
    <Record serial=1449>
    >>> event.venue.name  3
    'Portland 251'
    >>> for spkr in event.speakers:  4
    ...     print(f'{spkr.serial}: {spkr.name}')
    ...
    3471: Anna Martelli Ravenscroft
    5199: Alex Martelli
  1. 一个event实例…
  2. …读取 event.venue 返回一个 Record 对象而不是序列号。
  3. 现在很容易获得venue的名称。
  4. event.speakers 属性返回 Record 实例的列表

像往常一样,我们将逐步构建代码,从 Record 类和一个读取 JSON 数据并返回带有 Record 实例的 dict 的函数开始。

第 1 步:数据驱动的属性创建

这是指导第一步的doctest:

示例 22-8。测试驱动schedule_v1.py(例 22-9)

    >>> records = load(JSON_PATH)  1
    >>> speaker = records['speaker.3471']  2
    >>> speaker  3
    <Record serial=3471>
    >>> speaker.name, speaker.twitter  4
    ('Anna Martelli Ravenscroft', 'annaraven')
  1. 使用 JSON 数据加载一个dict
  2. record中的键是根据记录类型和序列构建的字符串。
  3. Speaker 是示例 22-9 中定义的 Record 类的一个实例。
  4. 原始 JSON 中的字段可以作为 Record 实例属性检索。

schedule_v1.py 的代码在示例 22-9 中。

示例 22-9。 schedule_v1.py:重新组织 OSCON 数据

import json

JSON_PATH = 'data/osconfeed.json'

class Record:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)  1

    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'  2

def load(path=JSON_PATH):
    records = {}  3
    with open(path) as fp:
        raw_data = json.load(fp)  4
    for collection, raw_records in raw_data['Schedule'].items():  5
        record_type = collection[:-1]  6
        for raw_record in raw_records:
            key = f'{record_type}.{raw_record["serial"]}' 7
            records[key] = Record(**raw_record)  8
    return records
  1. 这是使用从关键字参数创建的属性构建实例的常用快捷方式(详细说明如下)。
  2. 使用serial字段构建示例 22-8 中所示的自定义record的repr。
  3. load 返回 Record 实例组成的字典。
  4. 解析 JSON,返回原生 Python 对象:列表、字典、字符串、数字等。
  5. 遍历名为“conferences”、“events”、“speakers”和“venues”的四个顶级列表。
  6. record_type 是没有最后一个字符的列表名称,因此speakers变成了speaker。在 Python ≥ 3.9 中,我们可以更明确地使用 collection.removesuffix('s')--参考 PEP 616—String methods to remove prefixes and suffixes.
  7. 以“speaker.3471”格式构建key。
  8. 创建一个 Record 实例并使用 key 将其保存在records中

Record.__init__ 方法演示了一个老的python技巧。回想一下,对象的 __dict__ 是保存其属性的地方——除非在类中声明了 __slots__,正如我们在“使用 __slots__ 节省内存”中看到的那样。因此,使用映射更新实例 __dict__ 是在该实例中创建一系列属性的快速方法。

Note:

根据应用场景,Record 类可能需要处理不是有效属性名称的键,正如我们在“无效属性名称问题”中所见。处理这个问题会分散本示例的关键思想,并且这在我们正在阅读的数据集中不是问题。

示例 22-9 中 Record 的定义非常简单,您可能想知道为什么我以前没有使用它,而是使用更复杂的 FrozenJSON。首先,FrozenJSON 通过递归转换嵌套映射和列表来工作; Record 不需要,因为我们转换后的数据集没有嵌套在映射或列表中的映射。记录仅包含字符串、整数、字符串列表和整数列表。第二个原因:FrozenJSON 提供了对嵌入的 __data dict 属性的访问——我们曾经调用过 .keys() 之类的方法——现在我们也不需要该功能。

Note:

Python 标准库提供了类似于 Record 的类,其中每个实例都有一组任意属性,这些属性是从给 __init__ 的关键字参数构建的:types.SimpleNamespaceargparse.Namespace和multiprocessing.managers.Namespace。我编写了更简单的 Record 类来突出基本思想:使用__init__ 更新实例 __dict__。

重组数据集后,我们可以增强 Record 类以自动获取event记录中引用的venue和speaker记录。在接下来的示例中,我们将使用属性来做到这一点。

步骤 2:获取链接记录的属性

下一个版本的目标是:提供一个event记录,读取它的venue属性将返回一个Record。这类似于 Django ORM 在访问 ForeignKey 字段时所做的事情:获得链接的模型对象而不是键。

我们将从venue属性开始。以示例 22-10 中的部分交互为例。

示例 22-10。从 schedule_v2.py 的 doctests 中提取

    >>> event = Record.fetch('event.33950')  1
    >>> event  2
    <Event 'There *Will* Be Bugs'>
    >>> event.venue  3
    <Record serial=1449>
    >>> event.venue.name  4
    'Portland 251'
    >>> event.venue_serial  5
    1449
  1. Record.fetch 静态方法从数据集中获取 Record 或 Event。
  2. 请注意,event 是 Event 类的一个实例。
  3. 访问 event.venue 会返回一个 Record 实例。
  4. 现在很容易找到 event.venue 的名称。
  5. Event 实例还具有来自 JSON 数据的venue_serial 属性。

Event 是 Record 的子类,添加了获取链接记录的场所,以及特殊的 __repr__ 方法。

本节的代码位于 Fluent Python 2e 代码库的 schedule_v2.py 模块中。该示例有近 60 行,因此我将分部分介绍它,从增强的 Record 类开始。

示例 22-11。 schedule_v2.py:使用新的 fetch 方法的Record类。

import inspect  1
import json

JSON_PATH = 'data/osconfeed.json'

class Record:

    __index = None  2

    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)

    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'

    @staticmethod  3
    def fetch(key):
        if Record.__index is None:  4
            Record.__index = load()
        return Record.__index[key]  5
  1. inspect 将用于load,在示例 22-13 中列出。
  2. __index 私有类属性最终将持有对 load 返回的 dict 的引用。
  3. fetch 是一个静态方法,可以明确表明其效果不受调用它的实例或类的影响
  4. 如果需要,填充 Record.__index。
  5. 使用它来获取传入的key的记录。

TIP:

这是使用 staticmethod 有意义的一个示例。fetch 方法总是作用于 Record.__index 类属性,即使是从子类调用,比如 Event.fetch()——我们很快就会探索。将其生命为类方法会产生误导,因为不会使用 cls 参数。

现在我们开始使用 Event 类中的属性,如示例 22-12 所示。

示例 22-12。 schedule_v2.py:Event类

class Event(Record):  1

    def __repr__(self):
        try:
            return f'<{self.__class__.__name__} {self.name!r}>'  2
        except AttributeError:
            return super().__repr__()

    @property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)  3
  1. Event 继承自Record.
  2. 如果实例具有name属性,则它用于生成自定义的repr。否则,委托给 Record 的__repr__。
  3. venue属性从venue_serial属性构建一个key,并将其传递给从 Record 继承的 fetch 类方法(使用 self.__class__ 的原因稍后解释)

示例 22-12 的场venue方法的第二行返回 self.__class__.fetch(key)。为什么不简单地调用 self.fetch(key)?这种更简单的形式只适用于特定的 OSCON 数据集,因为没有带有“fetch”键的事件记录。但是,如果一个事件记录有一个名为“fetch”的键,那么在那个特定的 Event 实例中,引用 self.fetch 将检索该字段的值,而不是 Event 从 Record 继承的 fetch 类方法。这是一个微妙的缺陷,它很容易偷偷摸摸的通过测试,因为它依赖于具体的数据集。

WARNING

从数据创建实例属性名称时,总是存在由于类属性(例如方法)的阴影或由于意外覆盖现有实例属性而导致数据丢失而导致错误的风险。这些问题可以解释为什么 Python dicts 一开始就不像 JavaScript 对象。

如果 Record 类的行为更像一个映射,实现动态 __getitem__ 而不是动态 __getattr__,则不会存在覆盖或遮蔽的错误风险。自定义映射可能是实现 Record 的 Pythonic 方式。但如果我那么做,我们就不会研究动态属性编程的技巧和陷阱。

本示例的最后一部分是示例 22-13 中修改后的load函数。

示例 22-13。 schedule_v2.py:load函数

def load(path=JSON_PATH):
    records = {}
    with open(path) as fp:
        raw_data = json.load(fp)
    for collection, raw_records in raw_data['Schedule'].items():
        record_type = collection[:-1]  1
        cls_name = record_type.capitalize()  2
        cls = globals().get(cls_name, Record)  3
        if inspect.isclass(cls) and issubclass(cls, Record):  4
            factory = cls  5
        else:
            factory = Record  6
        for raw_record in raw_records:  7
            key = f'{record_type}.{raw_record["serial"]}'
            records[key] = factory(**raw_record)  8
    return records
  1. 到目前为止, schedule_v1.py 中的load没有变化(示例 22-9)
  2. 将record_type首字母大写以获得可能的类名;例如,“event”变为“Event”。
  3. 从模块全局范围中获取该名称的对象;如果没有这样的对象,则获取 Record 类。
  4. 如果刚刚检索到的对象是一个类,并且是 Record 的子类...
  5. …将factory名称绑定到它。这意味着factory可以是 Record 的任何子类,具体取决于 record_type。
  6. 否则,将factory名称绑定到 Record。
  7. 创建键和保存记录的 for 循环和以前一样,除了……
  8. …records中存储的对象是factory构造的,可以是Record对象,也可以是根据record_type选择的Event等子类。

请注意,唯一具有自定义类的 record_type 是 Event,但如果编写为Speaker 或 Venue 类,则在构建和保存记录时 load 将自动使用这些类,而不是默认的 Record 类。

我们现在将相同的想法应用于 Events 类中的新的Speakers特性。

第 3 步:覆盖现有属性的特性

示例 22-12 中的venue特性名称与“events”集合记录中的字段名称不匹配。它的数据来自venue_serial 字段名。相比之下,events集合中的每条记录都有一个speakers字段,其中包含序列号列表。我们希望将该信息公开为 Event 实例中的speakers特性,该特性返回 Record 实例的列表。如示例 22-14 所示,名称冲突需要特别注意。

示例 22-14。 schedule_v3.py:speakers特性

    @property
    def speakers(self):
        spkr_serials = self.__dict__['speakers']  1
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]  2
  1. 我们想要的数据在speakers属性中,但我们必须直接从实例 __dict__ 中检索它,以避免递归调用speakers特性。
  2. 返回所有记录的列表,其键对应于 spkr_serials 中的数字。

在speakers方法中,尝试读取 self.speakers 将调用特性本身,快速抛出 RecursionError。但是,如果我们通过 self.__dict__['speakers'] 读取相同的数据,则绕过 Python 检索属性的常用算法,不会调用特性,并且避免了递归。出于这个原因,直接对对象的 __dict__ 读取或写入数据是一种常见的 Python 元编程技巧。

WARNING

解释器通过首先查看 obj 的类来获取 obj.my_attr。如果该类有一个名为 my_attr 的特性,该特性会以相同的名称隐藏实例属性。““Properties Override Instance Attributes” ”中的示例将证明这一点,第 23 章将揭示特性是作为描述符实现的——一种更强大和更通用的抽象。

当我在示例 22-14 中编写列表推导式时,我的程序员的蜥蜴大脑认为“这样代价很高”。其实并不是这样,因为 OSCON 数据集中的事件的speakers数量很少,所以编码任何更复杂的东西都是过早的优化。然而,缓存特性是一种常见的需求——并且有一些要注意的地方。因此,让我们在接下来的示例中看看如何做到这一点。

第 4 步:定制特性缓存

缓存特性是一种常见的需求,因为期望像 event.venue 这样的表达式应该是廉价的。如果 Event 特性后面的 Record.fetch 方法需要查询数据库或 Web API,则可能需要进行缓存。

在 Fluent Python 第一版中,我为speakers方法编写了自定义缓存逻辑,如示例 22-15 所示。

示例 22-15。使用 hasattr 的自定义缓存逻辑禁用字典的key-sharing优化。

    @property
    def speakers(self):
        if not hasattr(self, '__speaker_objs'):  1
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs  2
  1. 如果实例没有名为 __speaker_objs 的属性,则获取speakers对象并将它们存储为__speaker_objs。
  2. 返回 self.__speaker_objs。

示例 22-15 中的手工缓存很简单,但是在实例初始化后创建属性会破坏 PEP 412—密钥共享字典优化,如“dict 工作原理的实际后果”中所述。根据数据集的大小,内存使用方法的差异可能很重要。

与密钥共享优化配合良好的类似手动解决方案需要为 Event 类编写一个 __init__方法,创建并初始化必要de __speaker_objs属性为 None ,然后在speakers方法进行校验。请参见示例 22-16。

class Event(Record):

    def __init__(self, **kwargs):
        self.__speaker_objs = None
        super().__init__(**kwargs)

# 15 lines omitted...
    @property
    def speakers(self):
        if self.__speaker_objs is None:
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs

示例 22-15 和示例 22-16 演示了在旧版 Python 代码库中相当常见的简单缓存技术。然而,在多线程程序中,人工制作的缓存会引入可能导致数据损坏的竞争条件。如果两个线程正在读取以前未缓存的属性,则第一个线程将需要计算缓存属性的数据(示例中为 __speaker_objs),而第二个线程可能会读取尚未计算完成的缓存值。

幸运的是,Python 3.8 引入了线程安全的 @functools.cached_property 装饰器。不幸的是,它带有一些副作用,接下来会解释。

第 5 步:使用 functools 缓存特性

functools 模块提供了三个用于缓存的装饰器。我们在“Memoization with functools.cache” (Chapter 9)中看到了 @cache 和 @lru_cache。 Python 3.8 引入了@cached_property。

functools.cached_property 装饰器将方法的结果缓存在具有相同名称的实例属性中。例如,在示例 22-17 中,venue 方法计算的值存储在 self.venue属性中。之后,当客户端代码尝试读取venue时,将使用新创建的venue实例属性而不是方法。

示例 22-17。 @cached_property 的简单使用。

    @cached_property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)

在“第 3 步:覆盖现有属性的特性”中,我们看到特性以相同的名称隐藏实例属性。如果是这样的话,@cached_property 如何工作?如果该特性覆盖了实例属性,则属性venue将被忽略,venue方法将始终被调用,每次都计算key并运行 fetch!

答案有点悲哀:cached_property 这个名称用词不当。@cached_property 装饰器不会创建完整的特性,它创建的是一个非覆盖描述符。描述符是管理对另一个类中的属性的访问的对象。我们将在第 23 章深入探讨描述符。property 装饰器是一个用于创建overriding_descriptor 的高级API。第 23 章将包括关于覆盖与非覆盖描述符的完整解释。

现在,让我们抛开底层实现,从用户的角度关注 cached_property 和 property 之间的区别。Raymond Hettinger 在 Python Docs 中很好地解释了二者:

        cached_property() 的机制与 property() 有些不同。如果没有定义 setter,常规特性会阻止属性写入。相反,cached_property 允许写入。

        cached_property 装饰器仅在查找且不存在同名属性时运行。当它运行时,cached_property 会写入具有相同名称的属性。随后的属性读取和写入优先于 cached_property 方法,它的工作方式与普通属性一样。

        可以通过删除属性来清除缓存的值。这样 cached_property 方法就可以再次运行。

回到我们的 Event 类:@cached_property 的特定行为使其不适合装饰speakers,因为该方法依赖于一个已有的同名的speakers属性,这个属性包含事件speakers的序列号。

Warning:

@cached_property 有一些重要的限制:

  • 如果装饰方法已经依赖于同名的实例属性,则它不能用作@property 的替代品;
  • 它不能在定义了 __slots__ 的类中使用;
  • 它破坏了实例 __dict__ 的密钥共享优化,因为它在 __init__ 之后创建了一个实例属性。
speakers = property(cache(speakers))

尽管有这些限制,@cached_property 以一种简单的方式解决了一个常见的需求,并且它是线程安全的。它的 Python 代码是使用reentrant lock的示例。

@cached_property 文档推荐了一种适用于speakers的替代解决方案:同时使用 @property 和 @cache 装饰器,如示例 22-18 所示:

示例 22-18。在@cache 上堆叠@property。

    @property  1
    @cache  2
    def speakers(self):
        spkr_serials = self.__dict__['speakers']
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]
  1. 顺序很重要:@property 排在最前面……
  2. @cache 在下面。

从“堆叠装饰器”中回想一下该语法的含义。示例 22-18 的前三行类似于:

speakers = property(cache(speakers))

@cache 应用于speakers,返回一个新函数。然后该函数由@property 修饰,它用新构造的特性进行替换。

这样我们结束了对只读特性和缓存装饰器的讨论,探索了 OSCON 数据集。在下一节中,我们将开始一系列创建读/写特性的新示例。

使用特性进行属性验证

除了计算属性值之外,属性还用于通过将公共属性更改为受 getter 和 setter 保护的属性,并且不影响客户端代码来执行业务规则。让我们看一个扩展的例子。

LineItem Take #1:订单中的订单项类

想象一个用于批量销售有机食品的商店的应用程序,顾客可以按重量订购坚果、干果或谷类食品。在该系统中,每个订单将包含一系列订单项,并且每个订单项都可以由一个如示例 22-19 所示的类的实例表示。

示例 22-19。 bulkfood_v1.py:最简单的 LineItem 类

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

这很好也很简单。也许太简单了。示例 22-20 显示了一个问题。

示例 22-20。weight为负数导致subtotal是负数

   >>> raisins = LineItem('Golden raisins', 10, 6.95)
    >>> raisins.subtotal()
    69.5
    >>> raisins.weight = -20  # garbage in...
    >>> raisins.subtotal()    # garbage out...
    -139.0

这是一个玩具示例,但并不像您想象的那么奇特。这是一个来自 Amazon.com 早期的真实故事:

        我们发现客户可以订购数量为负的书籍!我想,我们会把价格记在他们的信用卡上,等他们把书寄出去。

                                                        ----Jeff Bezos, Founder and CEO of Amazon.com

我们如何解决这个问题?我们可以更改 LineItem 的接口以使用 getter 和 setter 作为 weight 属性。那将是 Java 的方式,而且没有错。

另一方面,可以很自然地可以通过赋值来设置它的weight;也许系统正在生产中,其他部分已经直接访问 item.weight。在这种情况下,Python的 方法是用特性替换数据属性。

LineItem Take #2:一个验证的特性

实现一个特性将允许我们使用 getter 和 setter,但 LineItem 的接口不会发生变化(即,赋值weight还是用下面的方式 raisins.weight = 12)。

示例 22-21 列出了读/写weight特性的代码。

示例 22-21。 bulkfood_v2.py:具有weight特性的 LineItem

class LineItem:

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

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

    @property  2
    def weight(self):  3
        return self.__weight  4

    @weight.setter  5
    def weight(self, value):
        if value > 0:
            self.__weight = value  6
        else:
            raise ValueError('value must be > 0')  7
  1. 此处的特性设置器已在使用中,确保不能创建带有负的weight的实例。
  2. @property 装饰 getter 方法。
  3. 所有实现特性的方法共享公共属性的名称:weight。
  4. 实际值存储在私有属性 __weight 中。
  5. 被装饰的getter有一个.setter属性,它也是一个装饰器;这将 getter 和 setter 联系在一起。
  6. 如果该值大于0,赋值给私有 __weight。
  7. 否则,抛出 ValueError。

请注意现在无法创建具有无效weight的 LineItem:

>>> walnuts = LineItem('walnuts', 0, 10.00)
Traceback (most recent call last):
    ...
ValueError: value must be > 0

现在我们已经确保weight不能为负。虽然买家通常无法设置商品的价格,但笔误或错误可能会导致 LineItem 的price为负数。为了防止这种情况,我们还可以将price转换为特性,但这会导致我们的代码中出现一些重复。

请记住第 17 章中 Paul Graham 的名言:“当我在程序中看到使用模式的时候,我认为这是引入麻烦的迹象。”解决重复的方法是抽象。有两种方法可以抽象出特性定义:使用特性工厂或描述符类。描述符类方法更加灵活,我们将在第 23 章专门讨论它。特性实际上是作为描述符类本身实现的。但在这里,我们将通过将特性工厂实现为函数来继续探索特性。

但在我们实现特性工厂之前,我们需要对特性有更深入的了解。

正确看待特性

虽然经常用作装饰器,但内置的property实际上是一个类。在 Python 中,函数和类通常是可以互换的,因为两者都是可调用的,并且没有用于对象实例化的 new 运算符,因此调用构造函数与调用工厂函数没有什么不同。并且两者都可以用作装饰器,只要它们返回一个新的可调用对象,这个可调用对象是装饰可调用对象的合适替换。

这是property构造函数的完整签名:

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

所有参数都是可选的,如果函数没有传入对应参数,则生成的特性对象不允许相应的操作。

特性类型是在 Python 2.2 中添加的,但 @ 装饰器语法只出现在 Python 2.4 中,所以几年来,特性是通过将访问器函数作为前两个参数传递来定义的。

示例 22-22 演示了在没有装饰器的情况下定义属性的“经典”语法。

示例 22-22。 bulkfood_v2b.py:与示例 22-21 相同,但不使用装饰器

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

    def get_weight(self):  1
        return self.__weight

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

    weight = property(get_weight, set_weight)  3
  1. 一个普通的getter
  2. 一个普通的setter
  3. 构建特性实例并赋值给公共类属性。

经典形式在某些情况下优于装饰器语法;我们稍后将讨论的特性工厂的代码就是一个例子。另一方面,在具有许多方法的类主体中,装饰器显示说明了哪些是 getter 和 setter,而不依赖于在名称中使用 get 和 set 前缀的约定。

类中特性的存在会影响如何以一种起初可能令人惊讶的方式找到该类实例中的属性。下一节对此进行解释。

特性覆盖实例属性

特性始终是类属性,但它们实际上管理类实例中的属性访问。

在“覆盖类属性”中,我们看到当一个实例及其类都具有同名的数据属性时,实例属性会覆盖或隐藏类属性——至少在读取该实例时是这样。示例 22-23 演示了这一点。

示例 22-23。实例属性隐藏类数据属性

>>> class Class:  1
...     data = 'the class data attr'
...     @property
...     def prop(self):
...         return 'the prop value'
...
>>> obj = Class()
>>> vars(obj)  2
{}
>>> obj.data  3
'the class data attr'
>>> obj.data = 'bar' 4
>>> vars(obj)  5
{'data': 'bar'}
>>> obj.data  6
'bar'
>>> Class.data  7
'the class data attr'
  1. 使用两个类属性定义 Class:data 属性和 prop 特性。
  2. vars 返回 obj 的 __dict__,表明它没有实例属性。
  3. 读取 obj.data  返回Class.data 的值。
  4. 写入 obj.data 会创建一个实例属性。
  5. 检查实例以查看实例属性。
  6. 现在读取 obj.data返回实例属性的值。当从obj读取属性的时候,实例的data会隐藏类的data。
  7. Class.data 属性没有发生变化。

现在,让我们尝试覆盖 obj 实例的 prop 属性。继续之前的控制台会话,如示例 22-24所示。

示例 22-24。实例属性不影响类的(接示例 22-23)

>>> Class.prop  1
<property object at 0x1072b7408>
>>> obj.prop  2
'the prop value'
>>> obj.prop = 'foo'  3
Traceback (most recent call last):
  ...
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo'  4
>>> vars(obj)  5
{'data': 'bar', 'prop': 'foo'}
>>> obj.prop  6
'the prop value'
>>> Class.prop = 'baz'  7
>>> obj.prop  8
'foo'
  1. 直接从 Class 读取 prop 返回property对象本身,而不运行它的 getter 方法。

  2. 读取 obj.prop 会执行property的 getter方法。

  3. 不能设置实例prop属性

  4. 直接将'prop' 写入 obj.__dict__ 中没有问题。

  5. 我们可以看到 obj 现在有两个实例属性:data 和 prop。

  6. 但是,读取 obj.prop 仍然会运行property的 getter。该特性没有被实例属性隐藏。

  7. 覆盖 Class.prop 会破坏property对象。

  8. 现在 obj.prop 返回实例属性。 Class.prop 不再是一个特性对象,所以不再覆盖 obj.prop。

作为最后的演示,我们将向 Class 添加一个新特性,并查看它是否覆盖了实例属性。示例 22-25 在示例 22-24 暂停的地方继续。

示例 22-25。新的类特性隐藏了现有的实例属性(接示例 22-24)

>>> obj.data  1
'bar'
>>> Class.data  2
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" prop value')  3
>>> obj.data  4
'the "data" prop value'
>>> del Class.data  5
>>> obj.data  6
'bar'
  1. obj.data 返回实例data属性。
  2. Class.data 返回类data属性。
  3. 用新的property覆盖 Class.data。
  4. obj.data 现在被 Class.data 特性隐藏。
  5. 删除这个特性
  6. obj.data 现在再次读取实例data属性。

本节的要点是,像 obj.data 这样的表达式,搜索首先不是在obj中搜索data。搜索实际上从 obj.__class__ 开始,只有当类中没有名为 data 的属性时,Python 才会在 obj 实例本身中查找。这通常适用于覆盖描述符,其中特性只是其中一个例子。描述符的进一步处理必须等待第 23 章。

现在回到特性。每个 Python 代码单元——模块、函数、类、方法——都可以有一个文档字符串。下一个主题是如何将文档附加到特性。

特性文档

当控制台 help() 函数或 IDE 等工具需要显示特性的文档时,它们会从特性的 __doc__ 属性中提取信息。

如果与经典调用语法一起使用,property 可以获取doc 参数作为文档字符串 :

 weight = property(get_weight, set_weight, doc='weight in kilograms')

getter 方法的文档字符串(带有 @property 装饰器本身的文档字符串)用作整个特性的文档。图 22-1 显示了从示例 22-26 中的代码生成的帮助屏幕。

示例 22-26。特性的文档

class Foo:

    @property
    def bar(self):
        """The bar attribute"""
        return self.__dict__['bar']

    @bar.setter
    def bar(self, value):
        self.__dict__['bar'] = value

现在我们已经学习了property的基本要领,,让我们回到保护 LineItem 的 weight 和 price 属性的问题,使它们的值大于0,但是我们无需手动实现两对几乎相同的 getter/setter。

编写特性工厂

我们将创建一个工厂来创建quantity特性——之所以如此命名,是因为托管的属性代表在应用程序中不能为负数或零的数量。示例 22-27 展示了 LineItem 类的简洁外观,使用了两个quantity特性实例:一个用于管理weight属性,另一个用于管理price属性。

示例 22-27。 bulkfood_v2prop.py:使用的quantity特性工厂

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

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

    def subtotal(self):
        return self.weight * self.price  4
  1. 使用工厂将第一个自定义特性 weight 定义为类属性。
  2. 第二次调用构建了另一个自定义特性price。
  3. 这里特性已经激活,确保weight不是负数或者0
  4. 这些特性也在此处使用,检索存储在实例中的值。

回想一下,特性是类的属性。在构建每个quanlity特性时,我们需要传递将由该特定特性管理的 LineItem 属性的名字。不幸的是,必须在这一行中输入两次 weight :

 weight = quantity('weight')

但是避免这种重复很复杂,因为特性无法知道哪个类属性名称将绑定到它。记住:赋值语句的右边首先被计算,所以当quantity()被调用时,weight类属性是不存在的。

Note:

改进quantity特性以便用户不需要重新输入属性名称是一个重要的元编程问题。我们将在第 23 章解决这个问题。

示例 22-28 列出了quantity特性工厂的实现

示例 22-28。 bulkfood_v2prop.py:quantity特性工厂

def quantity(storage_name):  1

    def qty_getter(instance):  2
        return instance.__dict__[storage_name]  3

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

    return property(qty_getter, qty_setter)  6
  1. storage_name 参数确定每个特性的数据存储在哪里;对于weight来说,存储名称将是“weight”。
  2. qty_getter 的第一个参数可以命名为 self,但这会很奇怪,因为这不是类中定义的; instance 引用存储属性的 LineItem 实例。
  3. qty_getter 引用 storage_name,所以会保存在这个函数的闭包中;该值直接从 instance.__dict__ 中读取,这样可以绕过特性性以避免无限递归。
  4. 定义了 qty_setter,也将instance作为第一个参数。
  5. 该值直接存储在 instance.__dict__ 中,同样绕过了特性。
  6. 构建一个自定义特性对象并返回这个对象。

示例 22-28 中值得仔细研究的部分是围绕 storage_name 变量展开的。当您以传统方式对特性进行编码时, getter 和 setter 方法中的属性名称是硬编码的。但是在这里,qty_getter 和 qty_setter 函数是通用的,它们依赖于 storage_name 变量来知道在哪里获取/设置实例 __dict__ 中的托管属性。每次调用数量工厂来构建属性时,storage_name 都是不同的以保证唯一性。

函数 qty_getter 和 qty_setter 将被工厂函数最后一行中创建的property对象包装。当被调用时,这些函数将从它们的闭包中读取 storage_name,以确定在哪里检索/存储托管属性值。

在示例 22-29 中,我创建并检查了一个 LineItem 实例,公开了存储属性。

示例 22-29。 bulkfood_v2prop.py:探索特性和存储属性

    >>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95)
    >>> nutmeg.weight, nutmeg.price  1
    (8, 13.95)
    >>> nutmeg.__dict__  2
    {'description': 'Moluccan nutmeg', 'weight': 8, 'price': 13.95}
  1. 特性隐藏了同名实例属性,通过特性读取weight和price。
  2. 使用 vars 来检查 nutmeg 实例:这里我们看到了用于存储值的实际实例属性。

请注意我们的工厂构建特性如何利用“Properties Override Instance Attributes”:中描述的行为:weight 特性覆盖 weight 实例属性,因此对 self.weight 或 nutmeg.weight 的每个引用都由特性函数处理,绕过特性逻辑的​​唯一方法是直接访问实例 __dict__。

示例 22-28 中的代码可能有点不好理解,但这样写很简洁:代码长度与示例 22-21 中仅定义 weight 特性的修饰的 getter/setter 对的长度相同。示例 22-27 中的 LineItem 定义看起来要好得多,没有 getter/setter 的干扰。

在实际系统中,相同类型的验证可能会出现在多个字段、多个类中,并且quantity工厂在实用程序模块中以反复使用。最终,这个简单的工厂可以重构为一个扩展性更好的描述符类,并使用专门的子类执行不同的验证。我们将在第 23 章中这样做。

现在让我们以属性删除的问题结束对特性的讨论。

处理属性删除

我们使用 del 语句不仅可以删除变量,还可以删除属性:

>>> class Demo:
...    pass
...
>>> d = Demo()
>>> d.color = 'green'
>>> d.color
'green'
>>> del d.color
>>> d.color
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Demo' object has no attribute 'color'

在实践中,删除属性并不是我们使用 Python的日常工作 ,而使用特性来删除属性的要求就更不寻常了。但这样做是受支持的,我可以想一个愚蠢的例子来证明它。

在属性定义中,@my_property.deleter 装饰器包装了负责删除由特性管理的属性的方法。正如所承诺的那样,愚蠢的示例 22-30 的灵感来自“巨蟒与圣杯” 中黑骑士的场景

示例 22-30。黑骑士.py

class BlackKnight:

    def __init__(self):
        self.phrases = [
            ('an arm', "'Tis but a scratch."),
            ('another arm', "It's just a flesh wound."),
            ('a leg', "I'm invincible!"),
            ('another leg', "All right, we'll call it a draw.")
        ]

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

    @member.deleter
    def member(self):
        member, text = self.phrases.pop(0)
        print(f'BLACK KNIGHT (loses {member}) -- {text}')

blackknight.py 中的文档测试在示例 22-31 中

示例 22-31。 blackknight.py:示例 22-30 的文档测试(黑骑士从不承认失败)

    >>> knight = BlackKnight()
    >>> knight.member
    next member is:
    'an arm'
    >>> del knight.member
    BLACK KNIGHT (loses an arm) -- 'Tis but a scratch.
    >>> del knight.member
    BLACK KNIGHT (loses another arm) -- It's just a flesh wound.
    >>> del knight.member
    BLACK KNIGHT (loses a leg) -- I'm invincible!
    >>> del knight.member
    BLACK KNIGHT (loses another leg) -- All right, we'll call it a draw.

使用经典的调用语法而不是装饰器语法是这样做的, 使用fdel 参数来配置删除函数。例如,member特性在 BlackKnight 类的主体中编码如下:

    member = property(member_getter, fdel=member_deleter)

如果没有使用特性,也可以通过实现“Special Methods for Attribute Handling”中介绍的较低级别的 __delattr__ 特殊方法来处理属性删除。用 __delattr__ 编写一个愚蠢的类作为练习留给有兴趣的读者。

特性是一个强大的功能,但有时更简单或更低级别的替代方案是更实用的。在本章的最后一节,我们将回顾 Python 为动态特性编程提供的一些核心 API。

属性处理的基本属性和函数

在本章中,甚至在本书之前,我们已经使用了 Python 提供的一些内置函数和特殊方法来处理动态属性。本节对它们进行了集中概述,因为它们的文档分散在官方文档中。

影响属性处理的特殊属性

以下部分中列出的许多函数和特殊方法的行为取决于三个特殊属性:

__class__:

对对象类的引用(即 obj.__class__ 与 type(obj) 相同)。 Python 仅在对象的类中查找特殊方法,例如 __getattr__,而不在实例本身中查找。

__dict__:

存储对象或类的可写属性的映射。具有 __dict__ 的对象可以随时设置任意新属性。如果一个类有 __slots__ 属性,那么它的实例可能没有 __dict__。请参阅 __slots__(下一个)。

__slots__:

可以在类中定义以节省内存的属性。 __slots__ 是用来记录可以被命名的属性的字符串元组。如果 '__dict__' 名称不在 __slots__ 中,则该类的实例将没有自己的 __dict__,并且在这些实例中仅允许出现 __slots__ 中列出的属性。回想一下“使用 __slots__ 节省内存”以了解更多信息

用于属性处理的内置函数

这五个内置函数用于执行对象属性的读取、写入和自省:

dir([object]):

列出对象的大多数属性。official docs 说 dir 旨在用于交互式使用,因此它不提供全面的属性列表,而是提供一组“有趣”的名称。dir 可以检查各种对象,不管他们是否实现了__dict__。__dict__ 属性本身没有在dir 列出,但dir列出了 __dict__ 的全部的键。类的一些特殊属性,例如 __mro__、__bases__ 和 __name__ 也没有在 dir 中列出。正如我们在示例 22-4 中看到的那样,您可以通过实现 __dir__ 特殊方法来自定义 dir 的输出。如果没有提供可选的obj参数,则 dir 列出当前范围内的名称。

getattr(object, name[, default])

从对象中获取由name字符串标识的属性。主要用例是检索我们事先不知道名称的属性(或方法)。这个操作可以从对象的类或超类中获取属性。如果不存在这样的属性,则 getattr 抛出 AttributeError 或返回默认值(如果给定)。使用 gettatr 的一个很好的例子是标准库的 cmd 包中的 Cmd.onecmd 方法,它用于获取和执行用户定义的命令。

hasattr(object, name)

如果命名属性存在于对象中,或者可以通过对象以某种方式获取(例如,通过继承),则返回 True。文档解释说:“这是通过调用 getattr(object, name) 并查看是否抛出 AttributeError 来实现的。”

setattr(object, name, value)

如果对象允许,则赋值value给对象的命名属性。这可能会创建新属性或覆盖现有属性。

vars([object])

返回对象的__dict__; vars 不能处理定义了 __slots__ 并且没有 __dict__ 的类的实例(与能够处理此类实例的 dir 形成对比)。如果没有参数,vars() 与 locals() 的作用相同:返回一个表示本地作用域的 dict。

属性处理的特殊方法

当在用户定义的类中实现时,此处列出的特殊方法处理属性检索、设置、删除和查看。

使用点表示法或内置函数 getattr、hasattr 和 setattr 的属性访问会触发此处列出的相应特殊方法。直接在实例 __dict__ 中读取和写入属性不会触发这些特殊方法——如果需要,这是绕过特殊方法的常用方法。

 “数据模型”一章的3.3.11. Special method lookup 警告:

        对于自定义类型,特殊方法的隐式调用只有在对象类型上定义时才能保证正常工作,而不是在对象的实例字典中。

换句话说,特殊方法将在类本身上进行检索,即使他是由实例来进行调用。因此,特殊方法不会被同名的实例属性所遮蔽。

在以下示例中,假设有一个名为 Class 的类,obj 是 Class 的一个实例,attr 是 obj 的一个属性。

对于这些特殊方法中的每一种,属性访问是使用点表示法还是“用于属性处理的内置函数”中列出的内置函数都是可以的。例如,obj.attr 和 getattr(obj, 'attr', 42) 都会触发 Class.__getattribute__(obj, 'attr')。

__delattr__(self, name):

使用 del 语句删除属性时调用;例如,del obj.attr 触发 Class.__delattr__(obj, 'attr')。如果 attr 是一个特性,如果类实现了 __delattr__,则永远不会调用特性的deleter方法。

__dir__(self):

在对象上调用 dir 时调用,以提供属性列表;例如,dir(obj) 触发 Class.__dir__(obj)。也被所有现代 Python 控制台中的制表符补全使用。

__getattr__(self, name):

仅当尝试在搜索 obj、Class 及其超类之后检索命名属性失败时调用。表达式 obj.no_such_attr、getattr(obj, 'no_such_attr') 和 hasattr(obj, 'no_such_attr') 可能会触发 Class.__getattr__(obj, 'no_such_attr'),但前提是在 obj或在 Class 及其超类中中找不到该名称的属性。

__getattribute__(self, name):

当尝试直接从 Python 代码中检索命名属性时调用(解释器在某些情况下可能会绕过它,例如获取 __repr__ 方法)。点表示法以及 getattr 和 hasattr 内置函数触发此方法。__getattr__ 当且仅在 __getattribute__ 之后并且 __getattribute__ 抛出AttributeError 时调用。要在不触发无限递归的情况下检索实例 obj 的属性,__getattribute__ 的实现应该使用 super().__getattribute__(obj, name)。

__setattr__(self, name, value):

尝试为命名属性赋值时调用。点符号和内置的 setattr 触发此方法;例如,obj.attr = 42 和 setattr(obj, 'attr', 42) 都会触发 Class.__setattr__(obj, 'attr', 42)。

Warning:

在实践中,因为它们被无条件地调用并且几乎影响每个属性访问,所以 __getattribute__ 和 __setattr__ 特殊方法比 __getattr__ 更难正确使用——后者只处理不存在的属性名称。使用属性或描述符比定义这些特殊方法更容易正确使用。

这结束了我们对特性、特殊方法和其他编码动态属性的技术的深入探讨。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值