描述器使用指南?

作者

Raymond Hettinger(譯者:wh2099 at outlook dot com)

聯(lián)系方式

<python at rcn dot com>

描述器 讓對(duì)象能夠自定義屬性查找、存儲(chǔ)和刪除的操作。

本指南主要分為四個(gè)部分:

  1. “入門” 部分從簡(jiǎn)單的示例著手,逐步添加特性,從而給出基本的概述。如果你是剛接觸到描述器,請(qǐng)從這里開始。

  2. 第二部分展示了完整的、實(shí)用的描述器示例。如果您已經(jīng)掌握了基礎(chǔ)知識(shí),請(qǐng)從此處開始。

  3. 第三部分提供了更多技術(shù)教程,詳細(xì)介紹了描述器如何工作。大多數(shù)人并不需要深入到這種程度。

  4. 最后一部分有對(duì)內(nèi)置描述器(用 C 編寫)的純 Python 等價(jià)實(shí)現(xiàn)。如果您想了解函數(shù)如何變成綁定方法或?qū)?classmethod(), staticmethod()property()__slots__ 這類常見工具的實(shí)現(xiàn)感興趣,請(qǐng)閱讀此部分。

入門?

現(xiàn)在,讓我們從最基本的示例開始,然后逐步添加新功能。

簡(jiǎn)單示例:返回常量的描述器?

Ten 類是一個(gè)描述器,其 __get__() 方法總是返回常量 10

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

要使用描述器,它必須作為一個(gè)類變量存儲(chǔ)在另一個(gè)類中:

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

用交互式會(huì)話查看普通屬性查找和描述器查找之間的區(qū)別:

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

a.x 屬性查找中,點(diǎn)運(yùn)算符會(huì)找到存儲(chǔ)在類字典中的 'x': 5。 在 a.y 查找中,點(diǎn)運(yùn)算符會(huì)根據(jù)描述器實(shí)例的 __get__ 方法將其識(shí)別出來,調(diào)用該方法并返回 10

請(qǐng)注意,值 10 既不存儲(chǔ)在類字典中也不存儲(chǔ)在實(shí)例字典中。相反,值 10 是在調(diào)用時(shí)才取到的。

這個(gè)簡(jiǎn)單的例子展示了一個(gè)描述器是如何工作的,但它不是很有用。在查找常量時(shí),用常規(guī)屬性查找會(huì)更好。

在下一節(jié)中,我們將創(chuàng)建更有用的東西,即動(dòng)態(tài)查找。

動(dòng)態(tài)查找?

有趣的描述器通常運(yùn)行計(jì)算而不是返回常量:

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

交互式會(huì)話顯示查找是動(dòng)態(tài)的,每次都會(huì)計(jì)算不同的,經(jīng)過更新的返回值:

>>>
>>> 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

除了說明描述器如何運(yùn)行計(jì)算,這個(gè)例子也揭示了 __get__() 參數(shù)的目的。形參 self 接收的實(shí)參是 size,即 DirectorySize 的一個(gè)實(shí)例。形參 obj 接收的實(shí)參是 gs,即 Directory 的一個(gè)實(shí)例。而正是 obj__get__() 方法獲得了作為目標(biāo)的目錄。形參 objtype 接收的實(shí)參是 Directory 類。

托管屬性?

描述器的一種流行用法是托管對(duì)實(shí)例數(shù)據(jù)的訪問。描述器被分配給類字典中的公開屬性,而實(shí)際數(shù)據(jù)作為私有屬性存儲(chǔ)在實(shí)例字典中。當(dāng)訪問公開屬性時(shí),會(huì)觸發(fā)描述器的 __get__()__set__() 方法。

在下面的例子中,age 是公開屬性,_age 是私有屬性。當(dāng)訪問公開屬性時(shí),描述器會(huì)記錄下查找或更新的日志:

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__()

交互式會(huì)話展示中,對(duì)托管屬性 age 的所有訪問都被記錄了下來,但常規(guī)屬性 name 則未被記錄:

>>>
>>> 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

此示例的一個(gè)主要問題是私有名稱 _age 在類 LoggedAgeAccess 中是硬耦合的。這意味著每個(gè)實(shí)例只能有一個(gè)用于記錄的屬性,并且其名稱不可更改。

定制名稱?

當(dāng)一個(gè)類使用描述器時(shí),它可以告知每個(gè)描述器使用了什么變量名。

在此示例中, Person 類具有兩個(gè)描述器實(shí)例 nameage。當(dāng)類 Person 被定義的時(shí)候,他回調(diào)了 LoggedAccess 中的 __set_name__() 來記錄字段名稱,讓每個(gè)描述器擁有自己的 public_nameprivate_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

交互交互式會(huì)話顯示類 Person 調(diào)用了 __set_name__() 方法來記錄字段的名稱。在這里,我們調(diào)用 vars() 來查找描述器而不觸發(fā)它:

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

現(xiàn)在,新類會(huì)記錄對(duì) nameage 二者的訪問:

>>>
>>> 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

這兩個(gè) Person 實(shí)例僅包含私有名稱:

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

結(jié)束語?

descriptor 就是任何一個(gè)定義了 __get__(),__set__()__delete__() 的對(duì)象。

可選地,描述器可以具有 __set_name__() 方法。這僅在描述器需要知道創(chuàng)建它的類或分配給它的類變量名稱時(shí)使用。(即使該類不是描述器,只要此方法存在就會(huì)調(diào)用。)

在屬性查找期間,描述器由點(diǎn)運(yùn)算符調(diào)用。如果使用 vars(some_class)[descriptor_name] 間接訪問描述器,則返回描述器實(shí)例而不調(diào)用它。

描述器僅在用作類變量時(shí)起作用。放入實(shí)例時(shí),它們將失效。

描述器的主要目的是提供一個(gè)掛鉤,允許存儲(chǔ)在類變量中的對(duì)象控制在屬性查找期間發(fā)生的情況。

傳統(tǒng)上,調(diào)用類控制查找過程中發(fā)生的事情。描述器反轉(zhuǎn)了這種關(guān)系,并允許正在被查詢的數(shù)據(jù)對(duì)此進(jìn)行干涉。

描述器的使用貫穿了整個(gè)語言。就是它讓函數(shù)變成綁定方法。常見工具諸如 classmethod()staticmethod(),property()functools.cached_property() 都作為描述器實(shí)現(xiàn)。

完整的實(shí)際例子?

在此示例中,我們創(chuàng)建了一個(gè)實(shí)用而強(qiáng)大的工具來查找難以發(fā)現(xiàn)的數(shù)據(jù)損壞錯(cuò)誤。

驗(yàn)證器類?

驗(yàn)證器是一個(gè)用于托管屬性訪問的描述器。在存儲(chǔ)任何數(shù)據(jù)之前,它會(huì)驗(yàn)證新值是否滿足各種類型和范圍限制。如果不滿足這些限制,它將引發(fā)異常,從源頭上防止數(shù)據(jù)損壞。

這個(gè) Validator 類既是一個(gè) abstract base class 也是一個(gè)托管屬性描述器。

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

自定義驗(yàn)證器需要從 Validator 繼承,并且必須提供 validate() 方法以根據(jù)需要測(cè)試各種約束。

自定義驗(yàn)證器?

這是三個(gè)實(shí)用的數(shù)據(jù)驗(yàn)證工具:

  1. OneOf 驗(yàn)證值是一組受約束的選項(xiàng)之一。

  2. Number 驗(yàn)證值是否為 intfloat。根據(jù)可選參數(shù),它還可以驗(yàn)證值在給定的最小值或最大值之間。

  3. String 驗(yàn)證值是否為 str。根據(jù)可選參數(shù),它可以驗(yàn)證給定的最小或最大長(zhǎng)度。它還可以驗(yàn)證用戶定義的 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 an 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}'
            )

實(shí)際應(yīng)用?

這是在真實(shí)類中使用數(shù)據(jù)驗(yàn)證器的方法:

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

描述器阻止無效實(shí)例的創(chuàng)建:

>>>
>>> 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

技術(shù)教程?

接下來是專業(yè)性更強(qiáng)的技術(shù)教程,以及描述器工作原理的詳細(xì)信息。

摘要?

定義描述器,總結(jié)協(xié)議,并說明如何調(diào)用描述器。提供一個(gè)展示對(duì)象關(guān)系映射如何工作的示例。

學(xué)習(xí)描述器不僅能提供接觸到更多工具集的途徑,還能更深地理解 Python 工作的原理。

定義與介紹?

一般而言,描述器是一個(gè)包含了描述器協(xié)議中的方法的屬性值。 這些方法有 __get__(), __set__()__delete__()。 如果為某個(gè)屬性定義了這些方法中的任意一個(gè),它就可以被稱為 descriptor。

屬性訪問的默認(rèn)行為是從一個(gè)對(duì)象的字典中獲取、設(shè)置或刪除屬性。對(duì)于實(shí)例來說,a.x 的查找順序會(huì)從 a.__dict__['x'] 開始,然后是 type(a).__dict__['x'],接下來依次查找 type(a) 的方法解析順序(MRO)。 如果找到的值是定義了某個(gè)描述器方法的對(duì)象,則 Python 可能會(huì)重寫默認(rèn)行為并轉(zhuǎn)而發(fā)起調(diào)用描述器方法。這具體發(fā)生在優(yōu)先級(jí)鏈的哪個(gè)環(huán)節(jié)則要根據(jù)所定義的描述器方法及其被調(diào)用的方式來決定。

描述器是一個(gè)強(qiáng)大而通用的協(xié)議。 它們是屬性、方法、靜態(tài)方法、類方法和 super() 背后的實(shí)現(xiàn)機(jī)制。 它們?cè)?Python 內(nèi)部被廣泛使用。 描述器簡(jiǎn)化了底層的 C 代碼并為 Python 的日常程序提供了一組靈活的新工具。

描述器協(xié)議?

descr.__get__(self, obj, type=None) -> value

descr.__set__(self, obj, value) -> None

descr.__delete__(self, obj) -> None

描述器的方法就這些。一個(gè)對(duì)象只要定義了以上方法中的任何一個(gè),就被視為描述器,并在被作為屬性時(shí)覆蓋其默認(rèn)行為。

如果一個(gè)對(duì)象定義了 __set__()__delete__(),則它會(huì)被視為數(shù)據(jù)描述器。 僅定義了 __get__() 的描述器稱為非數(shù)據(jù)描述器(它們經(jīng)常被用于方法,但也可以有其他用途)。

數(shù)據(jù)和非數(shù)據(jù)描述器的不同之處在于,如何計(jì)算實(shí)例字典中條目的替代值。如果實(shí)例的字典具有與數(shù)據(jù)描述器同名的條目,則數(shù)據(jù)描述器優(yōu)先。如果實(shí)例的字典具有與非數(shù)據(jù)描述器同名的條目,則該字典條目?jī)?yōu)先。

為了使數(shù)據(jù)描述器成為只讀的,應(yīng)該同時(shí)定義 __get__()__set__() ,并在 __set__() 中引發(fā) AttributeError 。用引發(fā)異常的占位符定義 __set__() 方法使其成為數(shù)據(jù)描述器。

描述器調(diào)用概述?

描述器可以通過 d.__get__(obj)desc.__get__(None, cls) 直接調(diào)用。

但更常見的是通過屬性訪問自動(dòng)調(diào)用描述器。

表達(dá)式 obj.x 在命名空間的鏈中查找``obj`` 的屬性 x。如果搜索在實(shí)例 __dict__ 之外找到描述器,則根據(jù)下面列出的優(yōu)先級(jí)規(guī)則調(diào)用其 __get__() 方法。

調(diào)用的細(xì)節(jié)取決于 obj 是對(duì)象、類還是超類的實(shí)例。

通過實(shí)例調(diào)用?

實(shí)例查找通過命名空間鏈進(jìn)行掃描,數(shù)據(jù)描述器的優(yōu)先級(jí)最高,其次是實(shí)例變量、非數(shù)據(jù)描述器、類變量,最后是 __getattr__() (如果存在的話)。

如果 a.x 找到了一個(gè)描述器,那么將通過 desc.__get__(a, type(a)) 調(diào)用它。

點(diǎn)運(yùn)算符的查找邏輯在 object.__getattribute__() 中。這里是一個(gè)等價(jià)的純 Python 實(shí)現(xiàn):

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = getattr(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)

Note, there is no __getattr__() hook in the __getattribute__() code. That is why calling __getattribute__() directly or with super().__getattribute__ will bypass __getattr__() entirely.

Instead, it is the dot operator and the getattr() function that are responsible for invoking __getattr__() whenever __getattribute__() raises an AttributeError. Their logic is encapsulated in a helper function:

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__

通過類調(diào)用?

A.x 這樣的點(diǎn)操作符查找的邏輯在 type.__getattribute__() 中。步驟與 object.__getattribute__() 相似,但是實(shí)例字典查找改為搜索類的 method resolution order

如果找到了一個(gè)描述器,那么將通過 desc.__get__(None, A) 調(diào)用它。

完整的 C 實(shí)現(xiàn)可在 Objects/typeobject.c 中的 type_getattro()_PyType_Lookup() 找到。

通過 super 調(diào)用?

super 的點(diǎn)操作符查找的邏輯在 super() 返回的對(duì)象的 __getattribute__() 方法中。

類似 super(A, obj).m 形式的點(diǎn)分查找將在 obj.__class__.__mro__ 中搜索緊接在 A 之后的基類 B,然后返回 B.__dict__['m'].__get__(obj, A)。如果 m 不是描述器,則直接返回其值。

完整的 C 實(shí)現(xiàn)可以在 Objects/typeobject.csuper_getattro() 中找到。純 Python 等價(jià)實(shí)現(xiàn)可以在 Guido's Tutorial 中找到。

調(diào)用邏輯總結(jié)?

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

要記住的重要點(diǎn)是:

  • 描述器由 __getattribute__() 方法調(diào)用。

  • 類從 object,typesuper() 繼承此機(jī)制。

  • 由于描述器的邏輯在 __getattribute__() 中,因而重寫該方法會(huì)阻止描述器的自動(dòng)調(diào)用。

  • object.__getattribute__()type.__getattribute__() 會(huì)用不同的方式調(diào)用 __get__()。前一個(gè)會(huì)傳入實(shí)例,也可以包括類。后一個(gè)傳入的實(shí)例為 None ,并且總是包括類。

  • 數(shù)據(jù)描述器始終會(huì)覆蓋實(shí)例字典。

  • 非數(shù)據(jù)描述器會(huì)被實(shí)例字典覆蓋。

自動(dòng)名稱通知?

有時(shí),描述器想知道它分配到的具體類變量名。創(chuàng)建新類時(shí),元類 type 將掃描新類的字典。如果有描述器,并且它們定義了 __set_name__(),則使用兩個(gè)參數(shù)調(diào)用該方法。owner 是使用描述器的類,name 是分配給描述器的類變量名。

實(shí)現(xiàn)的細(xì)節(jié)在 Objects/typeobject.c 中的 type_new()set_names() 。

由于更新邏輯在 type.__new__() 中,因此通知僅在創(chuàng)建類時(shí)發(fā)生。之后如果將描述器添加到類中,則需要手動(dòng)調(diào)用 __set_name__() 。

ORM (對(duì)象關(guān)系映射)示例?

以下代碼展示了如何使用數(shù)據(jù)描述器來實(shí)現(xiàn)簡(jiǎn)單 object relational mapping 框架。

其核心思路是將數(shù)據(jù)存儲(chǔ)在外部數(shù)據(jù)庫中,Python 實(shí)例僅持有數(shù)據(jù)庫表中對(duì)應(yīng)的的鍵。描述器負(fù)責(zé)對(duì)值進(jìn)行查找或更新:

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 類來定義描述了數(shù)據(jù)庫中每張表的模式的 models

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

要使用模型,首先要連接到數(shù)據(jù)庫:

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

交互式會(huì)話顯示了如何從數(shù)據(jù)庫中檢索數(shù)據(jù)及如何對(duì)其進(jìn)行更新:

>>>
>>> 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'

純 Python 等價(jià)實(shí)現(xiàn)?

描述器協(xié)議很簡(jiǎn)單,但它提供了令人興奮的可能性。有幾個(gè)用例非常通用,以至于它們已預(yù)先打包到內(nèi)置工具中。屬性、綁定方法、靜態(tài)方法、類方法和 __slots__ 均基于描述器協(xié)議。

屬性?

調(diào)用 property() 是構(gòu)建數(shù)據(jù)描述器的簡(jiǎn)潔方式,該數(shù)據(jù)描述器在訪問屬性時(shí)觸發(fā)函數(shù)調(diào)用。它的簽名是:

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.")

要了解 property() 如何根據(jù)描述器協(xié)議實(shí)現(xiàn),這里是一個(gè)純 Python 的等價(jià)實(shí)現(xiàn):

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
        self._name = ''

    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(f"property '{self._name}' has no getter")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"property '{self._name}' has no setter")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"property '{self._name}' has no deleter")
        self.fdel(obj)

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

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

    def deleter(self, fdel):
        prop = type(self)(self.fget, self.fset, fdel, self.__doc__)
        prop._name = self._name
        return prop

這個(gè)內(nèi)置的 property() 每當(dāng)用戶訪問屬性時(shí)生效,隨后的變化需要一個(gè)方法的參與。

例如,一個(gè)電子表格類可以通過 Cell('b10').value 授予對(duì)單元格值的訪問權(quán)限。對(duì)程序的后續(xù)改進(jìn)要求每次訪問都要重新計(jì)算單元格;但是,程序員不希望影響直接訪問該屬性的現(xiàn)有客戶端代碼。解決方案是將對(duì) value 屬性的訪問包裝在屬性數(shù)據(jù)描述器中:

class Cell:
    ...

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

在此示例中,內(nèi)置的 property() 或我們實(shí)現(xiàn)的的 Property() 均適用。

函數(shù)和方法?

Python 的面向?qū)ο蠊δ苁窃诨诤瘮?shù)的環(huán)境構(gòu)建的。通過使用非數(shù)據(jù)描述器,這兩方面完成了無縫融合。

在調(diào)用時(shí),存儲(chǔ)在類詞典中的函數(shù)將被轉(zhuǎn)換為方法。方法與常規(guī)函數(shù)的不同之處僅在于對(duì)象實(shí)例被置于其他參數(shù)之前。方法與常規(guī)函數(shù)的不同之處僅在于第一個(gè)參數(shù)是為對(duì)象實(shí)例保留的。按照慣例,實(shí)例引用稱為 self ,但也可以稱為 this 或任何其他變量名稱。

可以使用 types.MethodType 手動(dòng)創(chuàng)建方法,其行為基本等價(jià)于:

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)

為了支持自動(dòng)創(chuàng)建方法,函數(shù)包含 __get__() 方法以便在屬性訪問時(shí)綁定其為方法。這意味著函數(shù)其是非數(shù)據(jù)描述器,它在通過實(shí)例進(jìn)行點(diǎn)查找時(shí)返回綁定方法,其運(yùn)作方式如下:

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)

在解釋器中運(yùn)行以下類,這顯示了函數(shù)描述器的實(shí)際工作方式:

class D:
    def f(self, x):
         return x

該函數(shù)具有 qualified name 屬性以支持自?。?/p>

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

通過類字典訪問函數(shù)不會(huì)調(diào)用 __get__()。相反,它只返回基礎(chǔ)函數(shù)對(duì)象:

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

來自類的點(diǎn)運(yùn)算符訪問會(huì)調(diào)用 __get__(),直接返回底層的函數(shù)。

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

有趣的行為發(fā)生在從實(shí)例進(jìn)行點(diǎn)訪問期間。點(diǎn)運(yùn)算符查找調(diào)用 __get__(),返回綁定的方法對(duì)象:

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

綁定方法在內(nèi)部存儲(chǔ)了底層函數(shù)和綁定的實(shí)例:

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

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

如果你曾好奇常規(guī)方法中的 self 或類方法中的 cls 是從什么地方來的,就是這里了!

方法的種類?

非數(shù)據(jù)描述器為把函數(shù)綁定為方法的通常模式提供了一種簡(jiǎn)單的機(jī)制。

概括地說,函數(shù)對(duì)象具有 __get__() 方法,以便在作為屬性訪問時(shí)可以將其轉(zhuǎn)換為方法。非數(shù)據(jù)描述器將 obj.f(*args) 的調(diào)用會(huì)被轉(zhuǎn)換為 f(obj, *args) 。調(diào)用 klass.f(*args)` 因而變成 f(*args) 。

下表總結(jié)了綁定及其兩個(gè)最有用的變體:

轉(zhuǎn)換形式

通過對(duì)象調(diào)用

通過類調(diào)用

function -- 函數(shù)

f(obj, *args)

f(*args)

靜態(tài)方法

f(*args)

f(*args)

類方法

f(type(obj), *args)

f(cls, *args)

靜態(tài)方法?

靜態(tài)方法返回底層函數(shù),不做任何更改。調(diào)用 c.fC.f 等效于通過 object.__getattribute__(c, "f")object.__getattribute__(C, "f") 查找。這樣該函數(shù)就可以從對(duì)象或類中進(jìn)行相同的訪問。

適合作為靜態(tài)方法的是那些不引用 self 變量的方法。

例如,一個(gè)統(tǒng)計(jì)用的包可能包含一個(gè)實(shí)驗(yàn)數(shù)據(jù)的容器類。該容器類提供了用于計(jì)算數(shù)據(jù)的平均值,均值,中位數(shù)和其他描述性統(tǒng)計(jì)信息的常規(guī)方法。但是,可能有在概念上相關(guān)但不依賴于數(shù)據(jù)的函數(shù)。例如, erf(x) 是在統(tǒng)計(jì)中的便捷轉(zhuǎn)換,但并不直接依賴于特定的數(shù)據(jù)集??梢詮膶?duì)象或類中調(diào)用它: s.erf(1.5) --> .9332Sample.erf(1.5) --> .9332

由于靜態(tài)方法返回的底層函數(shù)沒有任何變化,因此示例調(diào)用也是意料之中:

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

使用非數(shù)據(jù)描述器,純 Python 版本的 staticmethod() 如下所示:

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

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

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

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

類方法?

與靜態(tài)方法不同,類方法在調(diào)用函數(shù)之前將類引用放在參數(shù)列表的最前。無論調(diào)用方是對(duì)象還是類,此格式相同:

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

當(dāng)方法僅需要具有類引用并且確實(shí)依賴于存儲(chǔ)在特定實(shí)例中的數(shù)據(jù)時(shí),此行為就很有用。類方法的一種用途是創(chuàng)建備用類構(gòu)造函數(shù)。例如,類方法 dict.fromkeys() 從鍵列表創(chuàng)建一個(gè)新字典。純 Python 的等價(jià)實(shí)現(xiàn)是:

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

現(xiàn)在可以這樣構(gòu)造一個(gè)新的唯一鍵字典:

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

使用非數(shù)據(jù)描述器協(xié)議,純 Python 版本的 classmethod() 如下:

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

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

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        if hasattr(type(self.f), '__get__'):
            # This code path was added in Python 3.9
            # and was deprecated in Python 3.11.
            return self.f.__get__(cls, cls)
        return MethodType(self.f, cls)

The code path for hasattr(type(self.f), '__get__') was added in Python 3.9 and makes it possible for classmethod() to support chained decorators. For example, a classmethod and property could be chained together. In Python 3.11, this functionality was deprecated.

class G:
    @classmethod
    @property
    def __doc__(cls):
        return f'A doc for {cls.__name__!r}'
>>>
>>> G.__doc__
"A doc for 'G'"

成員對(duì)象和 __slots__?

當(dāng)一個(gè)類定義了 __slots__,它會(huì)用一個(gè)固定長(zhǎng)度的 slot 值數(shù)組來替換實(shí)例字典。 從用戶的視角看,效果是這樣的:

1. Provides immediate detection of bugs due to misspelled attribute assignments. Only attribute names specified in __slots__ are allowed:

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'

2. Helps create immutable objects where descriptors manage access to private attributes stored in __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'

3. 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 likely only matters when a large number of instances are going to be created.

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

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

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.

要?jiǎng)?chuàng)建一個(gè)一模一樣的純 Python 版的 __slots__ 是不可能的,因?yàn)樗枰苯釉L問 C 結(jié)構(gòu)體并控制對(duì)象內(nèi)存分配。 但是,我們可以構(gòu)建一個(gè)非常相似的模擬版,其中作為 slot 的實(shí)際 C 結(jié)構(gòu)體由一個(gè)私有的 _slotvalues 列表來模擬。 對(duì)該私有結(jié)構(gòu)體的讀寫操作將由成員描述器來管理:

null = object()

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/structmember.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
        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__() 方法負(fù)責(zé)將成員對(duì)象添加到類變量:

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

    def __new__(mcls, clsname, bases, mapping):
        '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)

object.__new__() 方法負(fù)責(zé)創(chuàng)建具有 slot 而非實(shí)例字典的實(shí)例。 以下是一個(gè)純 Python 的粗略模擬版:

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

    def __new__(cls, *args):
        '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'{type(self).__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'{type(self).__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

要在真實(shí)的類中使用這個(gè)模擬版,只需從 Object 繼承并將 metaclass 設(shè)為 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

這時(shí),metaclass 已經(jīng)為 xy 加載了成員對(duì)象:

>>>
>>> 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'>}

當(dāng)實(shí)例被創(chuàng)建時(shí),它們將擁有一個(gè)用于存放屬性的 slot_values 列表:

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

錯(cuò)誤拼寫或未賦值的屬性將引發(fā)一個(gè)異常:

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