描述符指南

作者:

Raymond Hettinger

聯絡方式:

<python at rcn dot com>

描述符 允許物件自定義屬性查詢、儲存和刪除。

本指南分為四個主要部分

  1. “入門”部分提供了一個基本概述,從簡單的示例開始,一次新增一個功能。如果你是描述符的新手,請從這裡開始。

  2. 第二部分展示了一個完整的、實用的描述符示例。如果你已經瞭解了基礎知識,請從這裡開始。

  3. 第三部分提供了更技術性的教程,深入探討了描述符的工作機制。大多數人不需要如此詳細的程度。

  4. 最後一部分包含用 C 語言編寫的內建描述符的純 Python 等價物。如果你好奇函式如何變成繫結方法,或者想了解 classmethod()staticmethod()property()__slots__ 等常用工具的實現,請閱讀此部分。

入門

在本入門部分,我們從最基本的示例開始,然後逐一新增新功能。

簡單示例:返回常量的描述符

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

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

要使用描述符,它必須作為類變數儲存在另一個類中。

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

互動式會話顯示了普通屬性查詢和描述符查詢之間的區別。

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

a.x 屬性查詢中,點運算子在類字典中找到 'x': 5。在 a.y 查詢中,點運算子找到一個描述符例項,它透過其 __get__ 方法識別。呼叫該方法返回 10

請注意,值 10 既不儲存在類字典中,也不儲存在例項字典中。相反,值 10 是按需計算的。

這個示例展示了一個簡單的描述符是如何工作的,但它並不是很有用。對於檢索常量,普通的屬性查詢會更好。

在下一節中,我們將建立一個更有用的東西,一個動態查詢。

動態查詢

有趣的描述符通常執行計算而不是返回常量。

import os

class DirectorySize:

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

class Directory:

    size = DirectorySize()              # Descriptor instance

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

互動式會話顯示查詢是動態的——每次都會計算出不同、更新的答案。

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

除了展示描述符如何執行計算之外,這個示例還揭示了 __get__() 方法引數的用途。self 引數是 size,一個 DirectorySize 的例項。obj 引數是 gs,一個 Directory 的例項。正是 obj 引數讓 __get__() 方法知道目標目錄。objtype 引數是類 Directory

管理屬性

描述符的一個流行用途是管理例項資料的訪問。描述符被賦值給類字典中的公共屬性,而實際資料作為私有屬性儲存在例項字典中。當訪問公共屬性時,描述符的 __get__()__set__() 方法會被觸發。

在以下示例中,age 是公共屬性,而 _age 是私有屬性。當訪問公共屬性時,描述符會記錄查詢或更新。

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAgeAccess:

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

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

class Person:

    age = LoggedAgeAccess()             # Descriptor instance

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

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

互動式會話顯示,對管理屬性 age 的所有訪問都被記錄下來,但常規屬性 name 沒有被記錄。

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

這個示例的一個主要問題是私有名稱 _ageLoggedAgeAccess 類中是硬編碼的。這意味著每個例項只能有一個被記錄的屬性,並且其名稱不可更改。在下一個示例中,我們將解決這個問題。

自定義名稱

當一個類使用描述符時,它可以通知每個描述符使用了哪個變數名。

在這個示例中,Person 類有兩個描述符例項,nameage。當 Person 類被定義時,它會向 LoggedAccess 中的 __set_name__() 發出回撥,以便可以記錄欄位名稱,為每個描述符提供其自己的 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

互動式會話顯示 Person 類已經呼叫了 __set_name__(),以便記錄欄位名稱。這裡我們呼叫 vars() 來查詢描述符而不觸發它。

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

新類現在記錄對 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

這兩個 Person 例項只包含私有名稱。

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

結語

描述符 是我們稱之為任何定義了 __get__()__set__()__delete__() 的物件。

可選地,描述符可以有一個 __set_name__() 方法。這隻用於描述符需要知道其建立的類或其被賦值的類變數名稱的情況。(如果存在,即使類不是描述符,也會呼叫此方法。)

描述符在屬性查詢期間由點運算子呼叫。如果描述符透過 vars(some_class)[descriptor_name] 間接訪問,則返回描述符例項而不呼叫它。

描述符僅在用作類變數時才起作用。當放在例項中時,它們沒有效果。

描述符的主要動機是提供一個鉤子,允許儲存在類變數中的物件控制屬性查詢期間發生的事情。

傳統上,呼叫類控制查詢期間發生的事情。描述符顛倒了這種關係,並允許被查詢的資料在這方面有發言權。

描述符在整個語言中都有使用。函式就是這樣變成繫結方法的。像 classmethod()staticmethod()property()functools.cached_property() 等常用工具都是作為描述符實現的。

完整的實際示例

在這個示例中,我們建立了一個實用且功能強大的工具,用於定位臭名昭著的難以發現的資料損壞錯誤。

驗證器類

驗證器是用於管理屬性訪問的描述符。在儲存任何資料之前,它會驗證新值是否符合各種型別和範圍限制。如果這些限制未滿足,它會引發異常以防止資料來源頭損壞。

這個 Validator 類既是 抽象基類,又是管理屬性描述符。

from abc import ABC, abstractmethod

class Validator(ABC):

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

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

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

    @abstractmethod
    def validate(self, value):
        pass

自定義驗證器需要繼承自 Validator,並且必須提供一個 validate() 方法來根據需要測試各種限制。

自定義驗證器

以下是三個實用的資料驗證工具。

  1. OneOf 驗證一個值是否是受限選項集中的一個。

  2. Number 驗證一個值是 int 還是 float。可選地,它驗證一個值是否在給定的最小值或最大值之間。

  3. String 驗證一個值是否是 str。可選地,它驗證給定的最小或最大長度。它還可以驗證使用者定義的 謂詞

class OneOf(Validator):

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

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

class Number(Validator):

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

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

class String(Validator):

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

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

實際應用

以下是如何在實際類中使用資料驗證器。

class Component:

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

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

描述符阻止建立無效例項。

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

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

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

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

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

技術教程

接下來是關於描述符工作機制和細節的更技術性的教程。

摘要

定義了描述符,總結了協議,並展示瞭如何呼叫描述符。提供了一個示例,展示了物件關係對映的工作原理。

瞭解描述符不僅可以讓你使用更廣泛的工具集,還可以讓你更深入地理解 Python 的工作原理。

定義和介紹

通常,描述符是具有描述符協議中某個方法的屬性值。這些方法是 __get__()__set__()__delete__()。如果為屬性定義了這些方法中的任何一個,則稱其為 描述符

屬性訪問的預設行為是從物件的字典中獲取、設定或刪除屬性。例如,a.x 的查詢鏈從 a.__dict__['x'] 開始,然後是 type(a).__dict__['x'],並繼續透過 type(a) 的方法解析順序。如果查詢的值是定義了描述符方法之一的物件,那麼 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__() 方法足以使其成為資料描述符。

描述符呼叫概述

描述符可以直接透過 desc.__get__(obj)desc.__get__(None, cls) 呼叫。

但更常見的是描述符在屬性訪問時自動呼叫。

表示式 obj.xobj 的名稱空間鏈中查詢屬性 x。如果搜尋在例項 __dict__ 之外找到一個描述符,則其 __get__() 方法將根據下面列出的優先順序規則被呼叫。

呼叫的細節取決於 obj 是物件、類還是 super 的例項。

從例項呼叫

例項查詢掃描名稱空間鏈,其中資料描述符具有最高優先順序,其次是例項變數,然後是非資料描述符,然後是類變數,最後是 __getattr__()(如果提供了)。

如果找到 a.x 的描述符,則使用 desc.__get__(a, type(a)) 呼叫它。

點式查詢的邏輯位於 object.__getattribute__() 中。這是一個純 Python 等價物。

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

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

注意,在 __getattribute__() 程式碼中沒有 __getattr__() 鉤子。這就是為什麼直接呼叫 __getattribute__() 或使用 super().__getattribute__ 將完全繞過 __getattr__()

相反,是點運算子和 getattr() 函式負責在 __getattribute__() 引發 AttributeError 時呼叫 __getattr__()。它們的邏輯封裝在一個輔助函式中。

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)             # __getattr__

從類呼叫

A.x 等點式查詢的邏輯位於 type.__getattribute__() 中。步驟類似於 object.__getattribute__(),但例項字典查詢被替換為透過類的 方法解析順序 進行搜尋。

如果找到描述符,則使用 desc.__get__(None, A) 呼叫它。

完整的 C 實現可以在 Objects/typeobject.c 中的 type_getattro()_PyType_Lookup() 中找到。

從 super 呼叫

super 的點式查詢邏輯在 super() 返回的物件的 __getattribute__() 方法中。

諸如 super(A, obj).m 之類的點式查詢會在 obj.__class__.__mro__ 中搜索緊跟在 A 之後的基類 B,然後返回 B.__dict__['m'].__get__(obj, A)。如果不是描述符,則 m 不變地返回。

完整的 C 實現可以在 Objects/typeobject.c 中的 super_getattro() 中找到。純 Python 等價物可以在 Guido 的教程中找到。

呼叫邏輯總結

描述符的機制嵌入在 objecttypesuper()__getattribute__() 方法中。

要記住的要點是

  • 描述符由 __getattribute__() 方法呼叫。

  • 類從 objecttypesuper() 繼承此機制。

  • 覆蓋 __getattribute__() 會阻止自動描述符呼叫,因為所有描述符邏輯都在該方法中。

  • object.__getattribute__()type.__getattribute__()__get__() 進行不同的呼叫。第一個包括例項,並可能包括類。第二個將 None 放入例項,並始終包括類。

  • 資料描述符總是覆蓋例項字典。

  • 非資料描述符可能被例項字典覆蓋。

自動名稱通知

有時,描述符希望知道它被分配到的類變數名稱。當建立一個新類時,type 元類會掃描新類的字典。如果任何條目是描述符並且它們定義了 __set_name__(),則會呼叫該方法,並帶有兩個引數。owner 是使用描述符的類,而 name 是描述符被分配到的類變數。

實現細節在 Objects/typeobject.c 中的 type_new()set_names() 中。

由於更新邏輯在 type.__new__() 中,因此通知只在類建立時發生。如果描述符隨後新增到類中,則需要手動呼叫 __set_name__()

ORM 示例

以下程式碼是一個簡化的框架,展示瞭如何使用資料描述符來實現 物件關係對映

核心思想是資料儲存在外部資料庫中。Python 例項只持有資料庫表的鍵。描述符負責查詢或更新。

class Field:

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

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

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

我們可以使用 Field 類來定義描述資料庫中每個表的模式的 模型

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

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

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

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

要使用模型,首先連線到資料庫。

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

互動式會話展示瞭如何從資料庫中檢索資料以及如何更新資料。

>>> 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 等價物

描述符協議簡單且提供了激動人心的可能性。有幾個用例非常常見,因此它們已被預打包到內建工具中。屬性、繫結方法、靜態方法、類方法和 __slots__ 都基於描述符協議。

屬性

呼叫 property() 是構建資料描述符的簡潔方法,它在訪問屬性時觸發函式呼叫。其簽名是

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

文件顯示了定義管理屬性 x 的典型用法。

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

要了解 property() 如何根據描述符協議實現,這裡有一個純 Python 等價物,它實現了大部分核心功能。

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

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

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

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

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

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

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

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

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

當用戶介面授予了屬性訪問權,隨後需要方法介入時,內建的 property() 有助於解決這種情況。

例如,一個電子表格類可以透過 Cell('b10').value 授予對單元格值的訪問。程式後續的改進要求每次訪問時都重新計算單元格;但是,程式設計師不希望影響直接訪問屬性的現有客戶端程式碼。解決方案是將對值屬性的訪問封裝在屬性資料描述符中。

class Cell:
    ...

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

在這個示例中,內建的 property() 或我們的 Property() 等價物都適用。

函式和方法

Python 的面向物件特性建立在基於函式的環境之上。使用非資料描述符,兩者無縫地融合在一起。

儲存在類字典中的函式在被呼叫時會變成方法。方法與常規函式的唯一區別在於,物件例項被新增到其他引數之前。按照慣例,例項被稱為 self,但也可以稱為 this 或任何其他變數名。

方法可以使用 types.MethodType 手動建立,它大致等同於

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

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

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

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

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

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

為了支援方法的自動建立,函式包含 __get__() 方法,用於在屬性訪問期間繫結方法。這意味著函式是非資料描述符,在從例項進行點式查詢時返回繫結方法。工作原理如下:

class Function:
    ...

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

在直譯器中執行以下類,可以看到函式描述符在實踐中是如何工作的。

class D:
    def f(self):
         return self

class D2:
    pass

該函式具有 限定名 屬性以支援自省。

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

透過類字典訪問函式不會呼叫 __get__()。相反,它只返回底層的函式物件。

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

從類進行點式訪問會呼叫 __get__(),它只是不變地返回底層函式。

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

有趣的行為發生在從例項進行點式訪問時。點式查詢呼叫 __get__(),它返回一個繫結方法物件。

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

在內部,繫結方法儲存了底層函式和繫結例項。

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

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

如果你曾好奇常規方法中的 self 或類方法中的 cls 來自何處,這就是答案!

方法的種類

非資料描述符提供了一種簡單的機制,用於在繫結函式到方法的常見模式上進行變體。

回顧一下,函式有一個 __get__() 方法,以便在作為屬性訪問時可以轉換為方法。非資料描述符將 obj.f(*args) 呼叫轉換為 f(obj, *args)。呼叫 cls.f(*args) 變為 f(*args)

這張圖總結了繫結及其兩個最有用的變體。

轉換

從物件呼叫

從類呼叫

函式

f(obj, *args)

f(*args)

staticmethod

f(*args)

f(*args)

classmethod

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.9332Sample.erf(1.5) --> 0.9332

由於靜態方法返回底層函式而沒有進行任何更改,因此示例呼叫並不令人興奮。

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

使用非資料描述符協議,staticmethod() 的純 Python 版本將如下所示:

import functools

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

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

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

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

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

functools.update_wrapper() 呼叫添加了一個 __wrapped__ 屬性,它引用了底層函式。它還將使包裝器看起來像被包裝函式所需的屬性向前傳遞,包括 __name____qualname____doc__

類方法

與靜態方法不同,類方法在呼叫函式之前將類引用新增到引數列表中。這種格式對於呼叫者是物件還是類都是相同的。

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

當方法只需要一個類引用而不依賴於儲存在特定例項中的資料時,這種行為很有用。類方法的一個用途是建立備用類建構函式。例如,類方法 dict.fromkeys() 從鍵列表建立一個新字典。純 Python 等價物是:

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

現在可以像這樣構造一個新字典的唯一鍵。

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

使用非資料描述符協議,classmethod() 的純 Python 版本將如下所示:

import functools

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

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

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        return MethodType(self.f, cls)

ClassMethod 中的 functools.update_wrapper() 呼叫添加了一個 __wrapped__ 屬性,它引用了底層函式。它還將使包裝器看起來像被包裝函式所需的屬性向前傳遞:__name____qualname____doc____annotations__

成員物件和 __slots__

當一個類定義了 __slots__ 時,它會將例項字典替換為固定長度的槽值陣列。從使用者的角度來看,這有幾個效果:

1. 提供即時檢測由於拼寫錯誤屬性賦值導致的錯誤。只允許在 __slots__ 中指定的屬性名稱。

class Vehicle:
    __slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
    ...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'

2. 有助於建立不可變物件,其中描述符管理對儲存在 __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. 節省記憶體。在 64 位 Linux 構建上,一個具有兩個屬性的例項使用 __slots__ 時佔用 48 位元組,不使用時佔用 152 位元組。這種 享元設計模式 可能只在需要建立大量例項時才重要。

4. 提高速度。使用 __slots__ 讀取例項變數速度提高 35%(根據在 Apple M1 處理器上使用 Python 3.10 測量)。

5. 阻止像 functools.cached_property() 這樣需要例項字典才能正常工作的工具。

from functools import cached_property

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

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

不可能建立一個完全等價的純 Python 版 __slots__,因為它需要直接訪問 C 結構和控制物件記憶體分配。但是,我們可以構建一個大部分忠實的模擬,其中槽的實際 C 結構由私有 _slotvalues 列表模擬。對該私有結構的讀寫由成員描述符管理。

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
        if obj is None:
            return self
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        return value

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

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

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

type.__new__() 方法負責向類變數新增成員物件。

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

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

object.__new__() 方法負責建立具有槽而不是例項字典的例項。這是一個純 Python 的大致模擬。

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

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

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

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

要在實際類中使用該模擬,只需從 Object 繼承並設定 元類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

此時,元類已為 xy 載入了成員物件。

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

當建立例項時,它們有一個儲存屬性的 slot_values 列表。

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

拼寫錯誤或未賦值的屬性將引發異常。

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