PEP 688 – 讓緩衝區協定在 Python 中可存取
- 作者:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 討論於:
- Discourse 討論串
- 狀態:
- 最終 (Final)
- 類型:
- 標準軌跡 (Standards Track)
- 主題:
- 類型標註 (Typing)
- 建立日期:
- 2022年4月23日
- Python 版本:
- 3.12
- 公告歷史:
- 2022年4月23日, 2022年4月25日, 2022年10月6日, 2022年10月26日
- 決議:
- 2023年3月7日
摘要
本 PEP 提議為緩衝區協定建立 Python 層級的 API,該協定目前僅供 C 程式碼存取。這使得類型檢查器能夠評估物件是否實作了此協定。
動機
CPython C API 提供了一種通用的機制來存取物件的底層記憶體——即 PEP 3118 中引入的緩衝區協定。接受二進位資料的函式通常被設計為能處理任何實作了緩衝區協定的物件。例如,撰寫本文時,CPython 中約有 130 個函式使用 Argument Clinic 的 Py_buffer 類型,該類型接受緩衝區協定。
目前,Python 程式碼無法檢測物件是否支援緩衝區協定。此外,靜態類型系統也沒有提供代表此協定的類型註解。這是在編寫接受通用緩衝區的程式碼時,進行類型註解的一個常見問題。
同樣地,以 Python 編寫的類別也無法實作緩衝區協定。Python 中的緩衝區類別將使用戶能夠輕鬆封裝 C 語言的緩衝區物件,或測試使用緩衝區協定的 API 行為。當然,這並非特別常見的需求。然而,自 2012 年以來,一直有一個關於支援 Python 編寫緩衝區類別的 CPython 功能請求處於開啟狀態。
原理
當前選項
目前有兩種已知的權宜之計來在類型系統中註解緩衝區類型,但兩者皆不夠完善。
首先,目前 typeshed 中針對緩衝區類型的權宜之計是一個類型別名,列出了標準函式庫中眾所周知的緩衝區類型,例如 bytes、bytearray、memoryview 和 array.array。這種方法適用於標準函式庫,但無法擴展到第三方緩衝區類型。
其次,typing.ByteString 的說明文件目前指出:
此類型代表位元組序列的bytes、bytearray與memoryview類型。作為此類型的簡寫,
bytes可用於註解上述任何類型的參數。
儘管這句話自 2015 年以來就一直存在於說明文件中,但使用 bytes 來包含其他類型並未在任何 Typing PEP 中規範。此外,此機制存在許多問題。它未包含所有可能的緩衝區類型,且使得 bytes 類型在類型註解中變得模糊。畢竟,許多操作對 bytes 物件有效,但對 memoryview 物件無效;一個函式完全可能接受 bytes 但不接受 memoryview。一位 mypy 用戶回報稱,這個捷徑已對 psycopg 專案造成了重大問題。
緩衝區種類
C 緩衝區協定支援許多選項,影響跨距 (strides)、連續性 (contiguity) 以及對寫入緩衝區的支援。其中一些選項在類型系統中將會非常有用。例如,typeshed 目前為可寫入和唯讀緩衝區提供了獨立的類型別名。
然而,在 C 緩衝區協定中,大多數這些選項無法直接在類型物件上查詢。要判斷物件是否支援特定旗標,唯一的方法就是實際請求緩衝區。對於某些類型(如 memoryview),支援的旗標取決於執行個體。因此,要在類型系統中表達對這些旗標的支援是很困難的。
規範
Python 層級的緩衝區協定
我們提議加入兩個 Python 層級的特殊方法:__buffer__ 與 __release_buffer__。實作這些方法的 Python 類別可被 C 程式碼當作緩衝區使用。反之,實作緩衝區協定的 C 類別將獲得可從 Python 程式碼存取的合成方法。
__buffer__ 方法被呼叫以從 Python 物件建立緩衝區,例如透過 memoryview() 建構子。它對應於 bf_getbuffer C 插槽。此方法的 Python 簽章為 def __buffer__(self, flags: int, /) -> memoryview: ...。此方法必須回傳一個 memoryview 物件。若在具有 __buffer__ 方法的 Python 類別上呼叫 bf_getbuffer 插槽,解譯器會從該方法回傳的 memoryview 中提取底層的 Py_buffer 並回傳給 C 呼叫者。同樣地,若 Python 程式碼在實作了 bf_getbuffer 的 C 類別執行個體上呼叫 __buffer__ 方法,則回傳的緩衝區將被封裝在 memoryview 中以供 Python 程式碼使用。
當呼叫者不再需要 __buffer__ 回傳的緩衝區時,應呼叫 __release_buffer__ 方法。它對應於 bf_releasebuffer C 插槽。這是緩衝區協定中的選用部分。此方法的 Python 簽章為 def __release_buffer__(self, buffer: memoryview, /) -> None: ...。待釋放的緩衝區被封裝在 memoryview 中。當此方法透過 CPython 的緩衝區 API 呼叫時(例如在 __buffer__ 回傳的 memoryview 上呼叫 memoryview.release),傳入的 memoryview 就是 __buffer__ 回傳的同一個物件。也可以在實作了 bf_releasebuffer 的 C 類別上呼叫 __release_buffer__。
若物件存在 __release_buffer__,則直接在該物件上呼叫 __buffer__ 的 Python 程式碼,在使用完緩衝區後,必須對同一物件呼叫 __release_buffer__。否則,該物件使用的資源可能無法回收。同樣地,若未先呼叫 __buffer__ 就呼叫 __release_buffer__,或對單次 __buffer__ 呼叫執行多次釋放,均屬於程式錯誤。對於實作 C 緩衝區協定的物件,若傳入 __release_buffer__ 的引數不是封裝同一個物件的 memoryview,將會引發例外。在有效的 __release_buffer__ 呼叫之後,該 memoryview 即告失效(如同呼叫了其 release() 方法),任何後續使用相同 memoryview 的 __release_buffer__ 呼叫都將引發例外。解譯器會確保對 Python API 的誤用不會破壞 C 層級的恆定性——例如,不會導致記憶體安全性違規。
inspect.BufferFlags
為了協助 __buffer__ 的實作,我們加入 inspect.BufferFlags,它是 enum.IntFlag 的子類別。此列舉包含了 C 緩衝區協定中定義的所有旗標。例如,inspect.BufferFlags.SIMPLE 與 PyBUF_SIMPLE 常數具有相同的值。
collections.abc.Buffer
我們加入了新的抽象基底類別 collections.abc.Buffer,其要求實作 __buffer__ 方法。此類別主要旨在用於類型註解。
def need_buffer(b: Buffer) -> memoryview:
return memoryview(b)
need_buffer(b"xy") # ok
need_buffer("xy") # rejected by static type checkers
它也可用於 isinstance 與 issubclass 的檢查。
>>> from collections.abc import Buffer
>>> isinstance(b"xy", Buffer)
True
>>> issubclass(bytes, Buffer)
True
>>> issubclass(memoryview, Buffer)
True
>>> isinstance("xy", Buffer)
False
>>> issubclass(str, Buffer)
False
在 typeshed 的 stub 檔案中,該類別應定義為 Protocol,遵循 collections.abc 中其他簡單 ABC(如 collections.abc.Iterable 或 collections.abc.Sized)的先例。
範例
以下是一個實作了緩衝區協定的 Python 類別範例:
import contextlib
import inspect
class MyBuffer:
def __init__(self, data: bytes):
self.data = bytearray(data)
self.view = None
def __buffer__(self, flags: int) -> memoryview:
if flags != inspect.BufferFlags.FULL_RO:
raise TypeError("Only BufferFlags.FULL_RO supported")
if self.view is not None:
raise RuntimeError("Buffer already held")
self.view = memoryview(self.data)
return self.view
def __release_buffer__(self, view: memoryview) -> None:
assert self.view is view # guaranteed to be true
self.view.release()
self.view = None
def extend(self, b: bytes) -> None:
if self.view is not None:
raise RuntimeError("Cannot extend held buffer")
self.data.extend(b)
buffer = MyBuffer(b"capybara")
with memoryview(buffer) as view:
view[0] = ord("C")
with contextlib.suppress(RuntimeError):
buffer.extend(b"!") # raises RuntimeError
buffer.extend(b"!") # ok, buffer is no longer held
with memoryview(buffer) as view:
assert view.tobytes() == b"Capybara!"
舊版 Python 的等效方案
新的類型功能通常會向後移植到舊版 Python 的 typing_extensions 套件中。由於緩衝區協定目前僅可在 C 中存取,本 PEP 無法在像 typing_extensions 這樣的純 Python 套件中完整實作。作為暫時的權宜之計,對於沒有 collections.abc.Buffer 的 Python 版本,將提供一個抽象基底類別 typing_extensions.Buffer。
在本 PEP 實作後,繼承 collections.abc.Buffer 將不再是宣告物件支援緩衝區協定的必要條件。然而,在舊版 Python 中,仍需顯式繼承 typing_extensions.Buffer 以向類型檢查器指示該類別支援緩衝區協定,因為支援緩衝區協定的物件將不會有 __buffer__ 方法。預計這主要發生在 stub 檔案中,因為緩衝區類別必然是以 C 程式碼實作的,無法在行內定義類型。對於執行時期的使用,可使用 ABC.register API 將緩衝區類別註冊到 typing_extensions.Buffer。
bytes 沒有特殊含義
關於 bytes 可作為其他 ByteString 類型簡寫的特例說明,將從 typing 的說明文件中移除。隨著 collections.abc.Buffer 作為替代方案,將沒有正當理由允許將 bytes 作為簡寫。目前實作此行為的類型檢查器應對其進行棄用,並最終移除。
回溯相容性
__buffer__ 與 __release_buffer__ 屬性
由於本 PEP 中的執行時期變更僅增加了新功能,因此幾乎沒有向後相容性的疑慮。
然而,為了其他目的而使用 __buffer__ 或 __release_buffer__ 屬性的程式碼可能會受到影響。雖然所有的雙底線名稱(dunder)在技術上都是為語言保留的,但確保新的雙底線名稱不會干擾過多現有程式碼,特別是廣泛使用的套件,仍然是一種良好的實踐。對可公開取得的程式碼進行調查後發現:
- PyPy 支援一個語意與本 PEP 提議相容的
__buffer__方法。一位 PyPy 核心開發者表達了他對此 PEP 的支持。 - pyzmq 實作了一個與 PyPy 相容的
__buffer__方法。 - mpi4py 定義了一個
SupportsBuffer協定,這與本 PEP 的collections.abc.Buffer是等效的。 - NumPy 過去有一種未記載的行為,即它會存取
__buffer__屬性(而非方法)來獲取物件的緩衝區。這在 2019 年發佈的 NumPy 1.17 中已被移除。該行為最後一次運作是在 NumPy 1.16,該版本僅支援 Python 3.7 及更早版本。到本 PEP 預期實作時,Python 3.7 將已達生命週期終點。
因此,本 PEP 對 __buffer__ 方法的使用將改善與 PyPy 的互通性,且不會干擾任何主流 Python 套件的當前版本。
沒有可公開取得的程式碼使用名稱 __release_buffer__。
移除 bytes 特例
另外,建議類型檢查器移除 bytes 特殊行為的建議,確實會對使用者產生向後相容性影響。一項針對 mypy 的實驗顯示,若移除 bytes 的自動轉換 (promotion),幾個使用它進行類型檢查的主要開源專案將會出現新的錯誤。許多這類錯誤可以透過改進 typeshed 中的 stubs 來修復,正如針對 builtins、binascii、pickle 與 re 模組所做的那樣。目前正在進行對 typeshed 中所有 bytes 類型用法的審查。總體而言,這項變更提高了類型安全性,並使類型系統更加一致,因此我們認為遷徙成本是值得的。
如何教學
我們會在文件中的適當位置(如 typing.python.org 和 mypy 備忘單)加入指向 collections.abc.Buffer 的註釋。類型檢查器可以在錯誤訊息中提供額外的提示。例如,當它們遇到將緩衝區物件傳遞給僅接受 bytes 的註解函式時,錯誤訊息可以包含建議改用 collections.abc.Buffer 的註釋。
參考實作
本 PEP 的實作可在作者的 fork 中取得。
否決的想法
types.Buffer
本 PEP 的早期版本提議加入一個新的 types.Buffer 類型,並以 C 實作 __instancecheck__,以便能使用 isinstance() 檢查類型是否實作了緩衝區協定。這避免了將完整緩衝區協定暴露給 Python 程式碼的複雜性,同時仍允許類型系統檢查緩衝區協定。
然而,該方法與類型系統的其他部分整合得不夠好,因為 types.Buffer 將會是一個名義類型 (nominal type),而非結構類型 (structural type)。例如,將無法表達「既支援緩衝區協定又支援 __len__ 的物件」。採用當前提議,__buffer__ 就跟其他任何特殊方法一樣,因此可以定義一個結合它與另一個方法的 Protocol。
更廣泛地說,Python 沒有其他部分像所提議的 types.Buffer 那樣運作。當前提議與語言的其他部分更為一致,即 C 層級的插槽通常具有相應的 Python 層級特殊方法。
保持 bytearray 與 bytes 的相容性
曾有人建議移除 memoryview 總是與 bytes 相容的特殊情況,但保留其與 bytearray 的相容性,因為這兩種類型具有非常相似的介面。然而,幾個標準函式庫函式(例如 re.compile、socket.getaddrinfo 以及大多數接受類路徑參數的函式)接受 bytes 但不接受 bytearray。在大多數程式碼庫中,bytearray 也不是一個非常常見的類型。我們傾向於讓使用者明確列出接受的類型(若僅需要特定的方法集,則使用 PEP 544 的 Protocol)。提案的這個面向已在 typing-sig 郵件論壇上經過特別討論,且未受到來自 Typing 社群的強烈反對。
區分可變與不可變緩衝區
緩衝區類型中最常使用的區分是緩衝區是否可變。有些函式僅接受可變緩衝區(例如 bytearray、部分 memoryview 物件),其他則接受所有緩衝區。
本 PEP 的早期版本提議使用 bf_releasebuffer 插槽的存在與否來判斷緩衝區類型是否可變。此規則適用於大多數標準函式庫的緩衝區類型,但可變性與此插槽存在與否之間的關係並非絕對。例如,numpy 陣列是可變的,但沒有此插槽。
當前的緩衝區協定無法提供任何可靠的方法來判斷緩衝區類型是否代表可變或不可變緩衝區。因此,本 PEP 不在類型系統中支援此區分。若未來緩衝區協定增強以提供靜態內省支援,可再重新審視此問題。目前已有一個針對此類機制的草案。
致謝
許多人為本 PEP 的草稿提供了有益的反饋。Petr Viktorin 在增進我對緩衝區協定細微差別的理解方面特別有幫助。
版權
本文件已進入公有領域或遵循 CC0-1.0-Universal 授權,以較寬鬆者為準。
來源:https://github.com/python/peps/blob/main/peps/pep-0688.rst