註解最佳實踐

作者:

Larry Hastings

在 Python 3.10 及更高版本中訪問物件的註解字典

Python 3.10 向標準庫添加了一個新函式:inspect.get_annotations()。在 Python 3.10 及更高版本中,呼叫此函式是訪問任何支援註解的物件的註解字典的最佳實踐。此函式還可以為您“解字串化”字串化註解。

如果由於某種原因 inspect.get_annotations() 不適用於您的用例,您可以手動訪問 __annotations__ 資料成員。Python 3.10 中此操作的最佳實踐也發生了變化:從 Python 3.10 開始,保證 o.__annotations__ 始終適用於 Python 函式、類和模組。如果您確定您正在檢查的物件是這三個特定物件之一,則可以直接使用 o.__annotations__ 來訪問物件的註解字典。

但是,其他型別的可呼叫物件(例如,由 functools.partial() 建立的可呼叫物件)可能沒有定義 __annotations__ 屬性。在 Python 3.10 及更高版本中,當訪問可能未知的物件的 __annotations__ 時,最佳實踐是使用三個引數呼叫 getattr(),例如 getattr(o, '__annotations__', None)

在 Python 3.10 之前,訪問沒有定義註解但具有帶註解的父類的類將返回父類的 __annotations__。在 Python 3.10 及更高版本中,子類的註解將為空字典。

在 Python 3.9 及更低版本中訪問物件的註解字典

在 Python 3.9 及更低版本中,訪問物件的註解字典比在更高版本中複雜得多。問題在於這些舊版本 Python 中的設計缺陷,特別是與類註解有關。

訪問其他物件(函式、其他可呼叫物件和模組)的註解字典的最佳實踐與 3.10 的最佳實踐相同,假設您沒有呼叫 inspect.get_annotations():您應該使用三引數的 getattr() 來訪問物件的 __annotations__ 屬性。

不幸的是,這並不是類的最佳實踐。問題在於,由於 __annotations__ 在類上是可選的,並且由於類可以從其基類繼承屬性,因此訪問類的 __annotations__ 屬性可能會無意中返回基類的註解字典。例如

class Base:
    a: int = 3
    b: str = 'abc'

class Derived(Base):
    pass

print(Derived.__annotations__)

這將列印 Base 的註解字典,而不是 Derived 的。

如果您正在檢查的物件是類 (isinstance(o, type)),您的程式碼將必須具有單獨的程式碼路徑。在這種情況下,最佳實踐依賴於 Python 3.9 及更早版本的實現細節:如果一個類定義了註解,它們將儲存在該類的 __dict__ 字典中。由於類可能定義了也可能沒有定義註解,因此最佳實踐是呼叫類字典的 get() 方法。

總而言之,以下是一些示例程式碼,可在 Python 3.9 及更早版本中安全地訪問任意物件上的 __annotations__ 屬性

if isinstance(o, type):
    ann = o.__dict__.get('__annotations__', None)
else:
    ann = getattr(o, '__annotations__', None)

執行此程式碼後,ann 應該是一個字典或 None。我們建議您在使用 isinstance() 進一步檢查之前,仔細檢查 ann 的型別。

請注意,某些特殊的或格式錯誤的型別物件可能沒有 __dict__ 屬性,因此為了更加安全,您可能還希望使用 getattr() 來訪問 __dict__

手動解字串化字串化註解

在某些註解可能是“字串化”的情況下,並且您希望計算這些字串以生成它們所代表的 Python 值,最好呼叫 inspect.get_annotations() 來為您完成這項工作。

如果您使用的是 Python 3.9 或更早版本,或者由於某種原因無法使用 inspect.get_annotations(),則需要複製其邏輯。我們建議您檢查當前 Python 版本中 inspect.get_annotations() 的實現並遵循類似的方法。

簡而言之,如果您希望計算任意物件 o 上的字串化註解

  • 如果 o 是模組,則在呼叫 eval() 時使用 o.__dict__ 作為 globals

  • 如果 o 是一個類,則在呼叫 eval() 時,使用 sys.modules[o.__module__].__dict__ 作為 globals,使用 dict(vars(o)) 作為 locals

  • 如果 o 是使用 functools.update_wrapper()functools.wraps()functools.partial() 包裝的可呼叫物件,則透過訪問 o.__wrapped__o.func (視情況而定)迭代地解包它,直到找到根未包裝的函式。

  • 如果 o 是可呼叫物件(但不是類),則在呼叫 eval() 時,使用 o.__globals__ 作為全域性變數。

但是,並非所有用作註解的字串值都可以透過 eval() 成功轉換為 Python 值。理論上,字串值可以包含任何有效的字串,並且在實踐中,存在有效的用例,即型別提示需要使用 無法 求值的字串值進行註釋。例如:

  • PEP 604 使用 | 的聯合型別,在 Python 3.10 新增對此的支援之前。

  • 執行時不需要的定義,僅在 typing.TYPE_CHECKING 為真時匯入。

如果 eval() 嘗試求值此類值,則會失敗並引發異常。因此,在設計使用註解的庫 API 時,建議僅在呼叫方明確請求時才嘗試求值字串值。

所有 Python 版本中 __annotations__ 的最佳實踐

  • 您應避免直接分配給物件的 __annotations__ 成員。讓 Python 管理設定 __annotations__

  • 如果您確實直接分配給物件的 __annotations__ 成員,則應始終將其設定為 dict 物件。

  • 如果您直接訪問物件的 __annotations__ 成員,則應確保它是一個字典,然後再嘗試檢查其內容。

  • 您應避免修改 __annotations__ 字典。

  • 您應避免刪除物件的 __annotations__ 屬性。

__annotations__ 的怪癖

在所有 Python 3 版本中,如果該物件上未定義任何註解,則函式物件會延遲建立註解字典。您可以使用 del fn.__annotations__ 刪除 __annotations__ 屬性,但是如果您隨後訪問 fn.__annotations__,則該物件將建立一個新的空字典,並將其儲存並作為其註解返回。在函式延遲建立其註解字典之前刪除函式上的註解會丟擲 AttributeError;連續兩次使用 del fn.__annotations__ 保證始終丟擲 AttributeError

以上段落中的所有內容也適用於 Python 3.10 及更高版本中的類和模組物件。

在所有 Python 3 版本中,您可以將函式物件上的 __annotations__ 設定為 None。但是,隨後使用 fn.__annotations__ 訪問該物件上的註解將按照本節第一段的說明延遲建立一個空字典。這對於任何 Python 版本中的模組和類都適用;這些物件允許將 __annotations__ 設定為任何 Python 值,並將保留設定的任何值。

如果 Python 為您字串化註解(使用 from __future__ import annotations),並且您指定一個字串作為註解,則該字串本身將被引用。實際上,註解被引用了兩次。例如:

from __future__ import annotations
def foo(a: "str"): pass

print(foo.__annotations__)

這將列印 {'a': "'str'"}。這不應真正被視為“怪癖”;在此提及只是因為它可能令人驚訝。