Following system colour scheme - Python 增強提案 Selected dark colour scheme - Python 增強提案 Selected light colour scheme - Python 增強提案

Python 增強提案

PEP 487 – 更簡單的類建立自定義

作者:
Martin Teichmann <lkb.teichmann at gmail.com>
狀態:
最終版
型別:
標準跟蹤
建立日期:
2015年2月27日
Python 版本:
3.6
釋出歷史:
2015年2月27日,2016年2月5日,2016年6月24日,2016年7月2日,2016年7月13日
取代:
422
決議:
Python-Dev 訊息

目錄

摘要

目前,定製類建立需要使用自定義元類。這個自定義元類隨後會持續整個類的生命週期,從而產生虛假元類衝突的可能性。

本PEP提議透過在類主體中引入新的 __init_subclass__ 鉤子以及一個用於初始化屬性的鉤子來支援各種自定義場景。

新的機制應該比實現自定義元類更容易理解和使用,因此應該能更溫和地引入Python元類機制的全部功能。

背景

元類是定製類建立的強大工具。然而,它們存在一個問題,即沒有自動組合元類的方法。如果想要為一個類使用兩個元類,則需要建立一個新的組合了這兩個元類的元類,通常是手動建立。

這種需求常常讓使用者感到意外:從兩個來自不同庫的基類繼承突然需要手動建立一個組合元類,而通常人們根本不關心這些庫的細節。如果一個庫開始使用以前沒有使用過的元類,情況會變得更糟。雖然庫本身繼續完美執行,但突然間,所有將這些類與另一個庫的類組合的程式碼都會失敗。

提案

雖然使用元類的方式有很多,但絕大多數用例只屬於三類:類建立後執行的某些初始化程式碼,描述符的初始化以及保持類屬性的定義順序。

前兩類可以透過在類建立中加入簡單的鉤子輕鬆實現

  1. 一個 __init_subclass__ 鉤子,用於初始化給定類的所有子類。
  2. 在類建立時,會在類中定義的所有屬性(描述符)上呼叫 __set_name__ 鉤子,並且

第三類是另一個PEP的主題,PEP 520

例如,第一個用例如下

>>> class QuestBase:
...    # this is implicitly a @classmethod (see below for motivation)
...    def __init_subclass__(cls, swallow, **kwargs):
...        cls.swallow = swallow
...        super().__init_subclass__(**kwargs)

>>> class Quest(QuestBase, swallow="african"):
...    pass

>>> Quest.swallow
'african'

基類 object 包含一個空的 __init_subclass__ 方法,作為協作多重繼承的終點。請注意,此方法沒有關鍵字引數,這意味著所有更專門的方法都必須處理所有關鍵字引數。

這個通用提案並非新想法(它首次被建議納入語言定義 10多年前,並且Zope的ExtensionClass長期以來一直支援類似的機制),但近年來情況發生了足夠大的變化,這個想法值得重新考慮。

提案的第二部分為類屬性添加了一個 __set_name__ 初始化器,特別是當它們是描述符時。描述符在類主體中定義,但它們對該類一無所知,甚至不知道它們被訪問的名稱。一旦呼叫 __get__,它們就會知道它們的擁有者,但仍然不知道它們的名稱。這很不幸,例如,它們不能將它們關聯的值放入它們物件的 __dict__ 中以它們的名稱命名,因為它們不知道該名稱。這個問題已經解決了很多次,是庫中擁有元類的最重要原因之一。雖然使用提案的第一部分很容易實現這種機制,但為這個問題提供一個通用的解決方案是有意義的。

舉個例子,假設一個描述符表示弱引用的值

import weakref

class WeakAttribute:
    def __get__(self, instance, owner):
        return instance.__dict__[self.name]()

    def __set__(self, instance, value):
        instance.__dict__[self.name] = weakref.ref(value)

    # this is the new initializer:
    def __set_name__(self, owner, name):
        self.name = name

例如,這樣的 WeakAttribute 可以用於樹形結構中,以避免透過父級產生迴圈引用。

class TreeNode:
    parent = WeakAttribute()

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

請注意,parent 屬性的使用方式與普通屬性一樣,但樹中不包含迴圈引用,因此在不再使用時可以輕鬆進行垃圾回收。parent 屬性一旦父級不存在就神奇地變為 None

雖然這個例子看起來非常簡單,但應該指出,到目前為止,如果沒有元類的使用,這樣的屬性是無法定義的。鑑於這樣的元類會使生活變得非常困難,這種屬性還不存在。

描述符的初始化可以在 __init_subclass__ 鉤子中簡單完成。但這將意味著描述符只能在具有適當鉤子的類中使用,像示例中那樣的通用版本將無法通用。也可以從 object.__init_subclass__ 的基本實現中呼叫 __set_name__。但是,鑑於忘記呼叫 super() 是一個常見的錯誤,描述符未初始化的情況會經常發生。

主要優點

更容易繼承定義時行為

理解 Python 的元類需要對型別系統和類構建過程有深入的理解。這被認為是具有挑戰性的,因為需要將多個動態部分(程式碼、元類提示、實際元類、類物件、類物件的例項)在你的腦海中清晰地區分開來。即使你瞭解規則,如果你不極其小心,仍然很容易犯錯。

理解所提議的隱式類初始化鉤子只需要普通的 方法繼承,這並不是一項令人生畏的任務。新的鉤子為理解類定義過程中涉及的所有階段提供了一個更漸進的路徑。

減少元類衝突的可能性

使庫作者不願使用元類(即使它們是合適的)的主要問題之一是元類衝突的風險。當類定義所需的父類使用兩個不相關的元類時,就會發生這種情況。這種風險也使得在以前沒有元類的類中**新增**元類變得非常困難。

相比之下,向現有型別新增 __init_subclass__ 方法所帶來的風險與新增 __init__ 方法的風險類似:從技術上講,存在破壞實現不佳的子類的風險,但當這種情況發生時,它被認為是子類中的一個錯誤,而不是庫作者違反了向後相容性保證。

使用類的新方法

子類註冊

特別是在編寫外掛系統時,人們喜歡註冊外掛基類的新子類。這可以透過以下方式完成

class PluginBase:
    subclasses = []

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.subclasses.append(cls)

在這個例子中,PluginBase.subclasses 將包含整個繼承樹中所有子類的純列表。應該注意的是,這作為混入類也很好用。

特性描述符

野外有許多 Python 描述符的設計,例如,它們檢查值的邊界。通常,這些“特性”需要元類的一些支援才能工作。透過此 PEP,這會是這樣

class Trait:
    def __init__(self, minimum, maximum):
        self.minimum = minimum
        self.maximum = maximum

    def __get__(self, instance, owner):
        return instance.__dict__[self.key]

    def __set__(self, instance, value):
        if self.minimum < value < self.maximum:
            instance.__dict__[self.key] = value
        else:
            raise ValueError("value not in range")

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

實施細節

鉤子按以下順序呼叫:type.__new__ 在新類初始化後,在描述符上呼叫 __set_name__ 鉤子。然後,它在基類上(確切地說,是在 super() 上)呼叫 __init_subclass__。這意味著子類初始化器已經看到了完全初始化的描述符。這樣,__init_subclass__ 的使用者可以在需要時再次修復所有描述符。

另一個選擇是在 object.__init_subclass__ 的基本實現中呼叫 __set_name__。這樣甚至可以阻止 __set_name__ 被呼叫。然而,大多數情況下,這種阻止是偶然的,因為經常會忘記呼叫 super()

作為第三個選項,所有工作都可以在 type.__init__ 中完成。大多數元類在 __new__ 中完成它們的工作,因為文件推薦這樣做。許多元類在將引數傳遞給 super().__new__ 之前會修改它們的引數。為了與這些型別的類相容,鉤子應該從 __new__ 中呼叫。

還應該進行一個小改動:在 CPython 當前的實現中,type.__init__ 明確禁止使用關鍵字引數,而 type.__new__ 允許其屬性作為關鍵字引數傳遞。這奇怪地不一致,因此應該禁止。雖然可以保留當前行為,但最好修復它,因為它可能根本沒有被使用:唯一的用例是元類以 *name*、*bases* 和 *dict*(是的,*dict*,而不是現代元類中大多使用的 *namespace* 或 *ns*)作為關鍵字引數呼叫其 super().__new__。這不應該這樣做。這個小改動顯著簡化了本 PEP 的實現,同時提高了 Python 的整體一致性。

作為第二個更改,新的 type.__init__ 只忽略關鍵字引數。目前,它堅持不給出關鍵字引數。如果在一個類宣告中給出關鍵字引數而元類不處理它們,這將導致一個(預期的)錯誤。想要接受關鍵字引數的元類作者必須透過覆蓋 __init__ 來過濾掉它們。

在新程式碼中,抱怨關鍵字引數的不是 __init__,而是 __init_subclass__,它的預設實現不帶任何引數。在利用方法解析順序的經典繼承方案中,每個 __init_subclass__ 可能會取出它的關鍵字引數,直到沒有剩餘,這由 __init_subclass__ 的預設實現進行檢查。

對於喜歡閱讀 Python 而非英語的讀者,本 PEP 提議用以下內容替換當前的 typeobject

class NewType(type):
    def __new__(cls, *args, **kwargs):
        if len(args) != 3:
            return super().__new__(cls, *args)
        name, bases, ns = args
        init = ns.get('__init_subclass__')
        if isinstance(init, types.FunctionType):
            ns['__init_subclass__'] = classmethod(init)
        self = super().__new__(cls, name, bases, ns)
        for k, v in self.__dict__.items():
            func = getattr(v, '__set_name__', None)
            if func is not None:
                func(self, k)
        super(self, self).__init_subclass__(**kwargs)
        return self

    def __init__(self, name, bases, ns, **kwargs):
        super().__init__(name, bases, ns)

class NewObject(object):
    @classmethod
    def __init_subclass__(cls):
        pass

參考實現

本 PEP 的參考實現附在 issue 27366 上。

向後相容性問題

type.__new__ 中的確切呼叫序列略有改變,這引發了對向後相容性的擔憂。應該透過測試確保常見用例的行為符合預期。

以下類定義(除了定義元類的那個)在傳遞多餘的類引數時仍會因 TypeError 而失敗

class MyMeta(type):
    pass

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

MyMeta("MyClass", (), otherargs=1)

import types
types.new_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))
types.prepare_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))

現在,只定義一個對關鍵字引數感興趣的 __new__ 方法的元類不再需要定義 __init__ 方法,因為預設的 type.__init__ 會忽略關鍵字引數。這與元類中推薦覆蓋 __new__ 而不是 __init__ 的建議非常一致。以下程式碼不再失敗

class MyMeta(type):
    def __new__(cls, name, bases, namespace, otherarg):
        return super().__new__(cls, name, bases, namespace)

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

如果給出關鍵字引數,在元類中只定義 __init__ 方法仍然會引發 TypeError

class MyMeta(type):
    def __init__(self, name, bases, namespace, otherarg):
        super().__init__(name, bases, namespace)

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

同時定義 __init____new__ 仍然可以正常工作。

唯一停止工作的是將 type.__new__ 的引數作為關鍵字引數傳遞

class MyMeta(type):
    def __new__(cls, name, bases, namespace):
        return super().__new__(cls, name=name, bases=bases,
                               dict=namespace)

class MyClass(metaclass=MyMeta):
    pass

這現在會引發 TypeError,但這是一種奇怪的程式碼,即使有人使用了這個特性,也容易修復。

已拒絕的設計方案

在類本身上呼叫鉤子

新增一個將在類本身上呼叫的 __autodecorate__ 鉤子是 PEP 422 的提議。如果鉤子只在嚴格的子類上呼叫,大多數示例的工作方式相同甚至更好。一般來說,顯式地在定義它的類上呼叫鉤子(選擇這種行為)比選擇退出(透過在鉤子體中記住檢查 cls is __class)要容易得多,這意味著不希望鉤子在定義它的類上呼叫。

當所討論的類被設計為混入類時,這一點最為明顯:混入類的程式碼不太可能在混入類本身上執行,因為它本身不應該是一個完整的類。

最初的提案還對類初始化過程進行了重大修改,使其無法將該提案回溯到舊的 Python 版本。

當需要同時在基類上呼叫鉤子時,有兩種機制可用

  1. 引入一個額外的混入類,僅用於存放 __init_subclass__ 的實現。原始的“基”類可以將其第一個父類列為新的混入類。
  2. 將所需的行為實現為一個獨立的類裝飾器,並將其顯式應用於基類,然後透過 __init_subclass__ 隱式應用於子類。

從類裝飾器顯式呼叫 __init_subclass__ 通常是不受歡迎的,因為這通常也會在父類上第二次呼叫 __init_subclass__,這不太可能是期望的行為。

呼叫鉤子的其他變體

還提出了鉤子的其他名稱,即 __decorate____autodecorate__。本提案選擇 __init_subclass__,因為它非常接近 __init__ 方法,只是針對子類,而它與裝飾器不太接近,因為它不返回類。

對於 __set_name__ 鉤子,也提出了其他名稱,__set_owner____set_ownership____init_descriptor__

要求 __init_subclass__ 上有顯式裝飾器

人們可能要求在 __init_subclass__ 裝飾器上顯式使用 @classmethod。它被設定為隱式,因為沒有合理的解釋可以省略它,而且無論如何都需要檢測這種情況才能給出有用的錯誤訊息。

在注意到定義 __prepare__ 但忘記 @classmethod 方法裝飾器的使用者體驗極其難以理解後,這一決定得到了加強(特別是由於 PEP 3115 將其記錄為普通方法,而當前文件對此沒有明確說明)。

更像 __new__ 的鉤子

PEP 422 中,鉤子的工作方式更像 __new__ 方法而不是 __init__ 方法,這意味著它返回一個類而不是修改一個類。這提供了一些更大的靈活性,但代價是實現難度更大且存在不希望的副作用。

新增一個帶有屬性順序的類屬性

這有了自己的 PEP 520

歷史

這曾經是 Alyssa Coghlan 和 Daniel Urban 提出的與 PEP 422 競爭的提案。PEP 422 旨在實現與本 PEP 相同的目標,但採用不同的實現方式。與此同時,PEP 422 已被撤回,以支援此方法。


來源:https://github.com/python/peps/blob/main/peps/pep-0487.rst

最後修改:2025-02-01 08:59:27 GMT