NEP 16 — 用於識別「鴨子陣列」的抽象基底類別#

作者:

Nathaniel J. Smith <njs@pobox.com>

狀態:

已撤回

類型:

標準追蹤

建立日期:

2018-03-06

決議:

numpy/numpy#12174

注意

此 NEP 已撤回,以支持 NEP 22 — NumPy 陣列的鴨子型別 — 高階概述 中描述的基於協定的方法

摘要#

我們提議新增一個抽象基底類別 AbstractArray,以便第三方類別可以宣告它們能夠「像 ndarray 一樣呱呱叫」,以及一個 asabstractarray 函數,其功能與 asarray 類似,但會讓 AbstractArray 實例保持不變。

詳細描述#

NumPy 和第三方套件中的許多函數,都以類似以下的程式碼開始

def myfunc(a, b):
    a = np.asarray(a)
    b = np.asarray(b)
    ...

這確保了 abnp.ndarray 物件,因此 myfunc 可以繼續假設它們在語義上(在 Python 層級)和記憶體儲存方式(在 C 層級)都像 ndarray 一樣運作。但是,許多這些函數僅在 Python 層級與陣列一起運作,這表示它們實際上不需要 ndarray 物件本身:它們可以與任何「像 ndarray 一樣呱呱叫」的 Python 物件(例如稀疏陣列、dask 的惰性陣列或 xarray 的標籤陣列)一樣良好地運作。

但是,目前,這些函式庫沒有辦法表達它們的物件可以像 ndarray 一樣呱呱叫,也沒有辦法讓像 myfunc 這樣的函數表達它們很樂意接受任何像 ndarray 一樣呱呱叫的東西。此 NEP 的目的是提供這兩個功能。

有時人們建議使用 np.asanyarray 來達到此目的,但不幸的是,它的語義正好相反:它保證它傳回的物件使用與 ndarray 相同的記憶體佈局,但完全沒有告訴你它的語義,這使得它在實務上幾乎不可能安全使用。實際上,與 NumPy 一起發行的兩個 ndarray 子類別 – np.matrixnp.ma.masked_array – 確實具有不相容的語義,如果它們被傳遞到像 myfunc 這樣的函數,而該函數沒有將它們作為特殊情況檢查,那麼它可能會靜默地傳回不正確的結果。

宣告物件可以像陣列一樣呱呱叫#

我們可以使用兩種基本方法來檢查物件是否像陣列一樣呱呱叫。我們可以檢查類別上是否有特殊屬性

def quacks_like_array(obj):
    return bool(getattr(type(obj), "__quacks_like_array__", False))

或者,我們可以定義一個 抽象基底類別 (ABC)

def quacks_like_array(obj):
    return isinstance(obj, AbstractArray)

如果你查看 ABC 的運作方式,這基本上等同於保留一組已宣告實作 AbstractArray 介面的全域類型,然後檢查其成員資格。

在這些方法之間,ABC 方法似乎有許多優點

  • 這是 Python 的標準、「一種顯而易見的方式」來做到這一點。

  • 可以內省 ABC(例如,help(np.AbstractArray) 會做一些有用的事情)。

  • ABC 可以提供有用的混入方法。

  • ABC 與其他功能整合,例如 mypy 類型檢查、functools.singledispatch 等。

一個明顯需要檢查的事情是,此選擇是否會影響速度。在使用附加的基準腳本在 CPython 3.7 預發行版(修訂版 c4d77a661138d,自行編譯,無 PGO)上,在執行 Linux 的 Thinkpad T450s 上,我們發現

np.asarray(ndarray_obj)      330 ns
np.asarray([])              1400 ns

Attribute check, success      80 ns
Attribute check, failure      80 ns

ABC, success via subclass    340 ns
ABC, success via register()  700 ns
ABC, failure                 370 ns

注意

  • 包含前兩行是為了將其他行放在上下文中。

  • 此處使用 3.7 是因為 getattr 和 ABC 都在此版本中獲得了實質性的優化,並且更具代表 Python 的長期未來。(失敗的 getattr 不再一定會建構例外物件,並且 ABC 在 C 中重新實作。)

  • 「成功」行是指 quacks_like_array 將傳回 True 的情況。「失敗」行是指它將傳回 False 的情況。

  • ABC 的第一個測量是像這樣定義的子類別

    class MyArray(AbstractArray):
        ...
    

    第二個測量是像這樣定義的子類別

    class MyArray:
        ...
    
    AbstractArray.register(MyArray)
    

    我不知道為什麼這些之間存在如此大的差異。

在實務上,無論哪種方式,我們都只會在首先檢查眾所周知的類型(如 ndarraylist 等)之後才執行完整測試。這是 NumPy 目前檢查其他雙底線屬性的方式,並且相同的概念適用於此處的任一種方法。因此,這些數字不會影響常見情況,只會影響我們實際擁有 AbstractArray,或者最終將通過 __array____array_interface__ 或最終成為物件陣列的另一個第三方物件的情況。

因此,總之,使用 ABC 會比使用屬性稍慢,但這不會影響最常見的路徑,並且減速的幅度相當小(在已經花費更長時間的操作中約為 250 奈秒)。此外,我們可以潛在地進一步優化此操作(例如,通過保留一個小的 LRU 快取,其中包含已知是 AbstractArray 子類別的類型,假設大多數程式碼一次只會使用這些類型中的一兩種),並且非常不清楚這是否重要 – 如果 asarray 無操作傳遞的速度是出現在效能分析中的瓶頸,那麼可能我們已經讓它們更快了!(快速路徑處理這個問題是微不足道的,但我們沒有。)

鑑於 ABC 的語義和可用性優勢,這似乎是可以接受的權衡。

asabstractarray 的規格#

給定 AbstractArrayasabstractarray 的定義很簡單

def asabstractarray(a, dtype=None):
    if isinstance(a, AbstractArray):
        if dtype is not None and dtype != a.dtype:
            return a.astype(dtype)
        return a
    return asarray(a, dtype=dtype)

需要注意的事項

  • asarray 也接受 order= 參數,但我們在此處不包含它,因為它與記憶體表示的詳細資訊有關,而此函數的重點是您使用它來宣告您不關心記憶體表示的詳細資訊。

  • 使用 astype 方法允許 a 物件決定如何為其特定類型實作轉換。

  • 為了與 asarray 嚴格相容,當 dtype 已經正確時,我們跳過呼叫 astype。比較

    >>> a = np.arange(10)
    
    # astype() always returns a view:
    >>> a.astype(a.dtype) is a
    False
    
    # asarray() returns the original object if possible:
    >>> np.asarray(a, dtype=a.dtype) is a
    True
    

如果您繼承自 AbstractArray,您究竟在承諾什麼?#

這可能會隨著時間推移而完善。當然,理想情況是您的類別應該與真正的 ndarray 沒有區別,但除了使用者的期望之外,沒有任何東西強制執行這一點。在實務上,宣告您的類別實作 AbstractArray 介面僅表示它將開始通過 asabstractarray,因此通過子類別化它,您表示如果某些程式碼適用於 ndarray 但對於您的類別中斷,那麼您願意接受有關該問題的錯誤報告。

首先,我們應該宣告 __array_ufunc__ 為抽象方法,並新增 NDArrayOperatorsMixin 方法作為混入方法。

astype 宣告為 @abstractmethod 可能也很有意義,因為 asabstractarray 會使用它。我們可能還想繼續新增一些基本屬性,例如 ndimshapedtype

新增新的抽象方法會有點棘手,因為 ABC 會在子類別化時強制執行這些方法;因此,僅僅新增一個 @abstractmethod 將會是向後相容性中斷。如果這成為一個問題,那麼我們可以使用一些技巧來實作一個 @upcoming_abstractmethod 裝飾器,該裝飾器僅在缺少方法時發出警告,並將其視為常規棄用週期。(在這種情況下,我們將棄用的東西是「支援缺少功能 X 的抽象陣列」。)

命名#

ABC 的名稱並不重要,因為它只會在極少數情況下被引用,並且在相對專業的情況下被引用。函數的名稱非常重要,因為大多數現有的 asarray 實例都應該替換為此函數,並且在未來,除非他們有特定原因要使用 asarray,否則每個人都應該預設使用它。這表示它的名稱確實應該比 asarray且更容易記住... 這很困難。我在這個草稿中使用了 asabstractarray,但我對此並不真正滿意,因為它太長了,如果沒有無休止的勸誡,人們不太可能養成習慣使用它。

一種選擇實際上是變更 asarray 的語義,以便AbstractArray 物件保持不變。但我擔心可能會有很多程式碼呼叫 asarray,然後將結果傳遞到一些 C 函數中,這些函數不會執行任何進一步的類型檢查(因為它知道其呼叫者已經使用了 asarray)。如果我們允許 asarray 傳回 AbstractArray 物件,然後有人呼叫其中一個 C 包裝函式並將 AbstractArray 物件(例如稀疏陣列)傳遞給它,那麼他們將會收到 segmentation fault。現在,在相同的情況下,asarray 將改為調用物件的 __array__ 方法,或使用緩衝區介面建立視圖,或傳遞具有物件 dtype 的陣列,或引發錯誤,或類似情況。可能在大多數情況下,這些結果實際上都不是理想的,所以也許讓它成為 segmentation fault 會是可以接受的?但鑑於我們不知道此類程式碼有多常見,這很危險。另一方面,如果我們從頭開始,那麼這可能是理想的解決方案。

我們不能使用 asanyarrayarray,因為這些名稱已被佔用。

還有其他想法嗎? np.castnp.coerce

實作#

  1. NDArrayOperatorsMixin 重新命名為 AbstractArray(留下別名以實現向後相容性)並使其成為 ABC。

  2. 新增 asabstractarray(或我們最終稱呼它的任何名稱),以及可能的 C API 等效項。

  3. 開始將 NumPy 內部函數遷移到在適當的地方使用 asabstractarray

向後相容性#

這純粹是一個新功能,因此沒有相容性問題。(除非我們決定變更 asarray 本身的語義。)

已拒絕的替代方案#

已經提出的一個建議是為陣列介面的不同子集定義多個抽象類別。本提案中的任何內容都沒有阻止 NumPy 或第三方在未來執行此操作,但是很難提前猜測哪些子集會有用。此外,「完整的 ndarray 介面」是現有函式庫被編寫為期望的東西(因為它們適用於實際的 ndarray)和測試的東西(因為它們使用實際的 ndarray 進行測試),因此它絕對是最容易開始的地方。

附錄:基準腳本#

import perf
import abc
import numpy as np

class NotArray:
    pass

class AttrArray:
    __array_implementer__ = True

class ArrayBase(abc.ABC):
    pass

class ABCArray1(ArrayBase):
    pass

class ABCArray2:
    pass

ArrayBase.register(ABCArray2)

not_array = NotArray()
attr_array = AttrArray()
abc_array_1 = ABCArray1()
abc_array_2 = ABCArray2()

# Make sure ABC cache is primed
isinstance(not_array, ArrayBase)
isinstance(abc_array_1, ArrayBase)
isinstance(abc_array_2, ArrayBase)

runner = perf.Runner()
def t(name, statement):
    runner.timeit(name, statement, globals=globals())

t("np.asarray([])", "np.asarray([])")
arrobj = np.array([])
t("np.asarray(arrobj)", "np.asarray(arrobj)")

t("attr, False",
  "getattr(not_array, '__array_implementer__', False)")
t("attr, True",
  "getattr(attr_array, '__array_implementer__', False)")

t("ABC, False", "isinstance(not_array, ArrayBase)")
t("ABC, True, via inheritance", "isinstance(abc_array_1, ArrayBase)")
t("ABC, True, via register", "isinstance(abc_array_2, ArrayBase)")