[譯] 不用 Class,如何寫一個類

玉兒Qi發表於2019-03-03

不用 Class,如何寫一個類

前言

Python 的物件模型令人難以置信的強大;實際上,你可以重寫所有(物件),或者向任何人分發奇怪的物件,並讓他們像對待正常的物件的那樣接受它。

Python 的物件導向是 smalltalk 物件導向的一個後裔。在 Python 中,一切都是物件,甚至物件集和物件型別都是如此;特別的,函式也是物件。這讓我很好奇:不使用類建立一個類是否可能?

程式碼

這個想法的關鍵性程式碼如下所示。這是一個很基礎的實現,但它支援 __call__ 這樣的邊緣情況(但不支援其他魔術方法,因為他們需要載入依賴)。後文將會解說。

有沒有搞錯?

這是一些很先進的 Python 輪子,它用一種和物件的設計初衷絕不相同的方法使用了一些物件。我們將分段解說程式碼。

第一個 helper

def _suspend_self(namespace, suspended):  
複製程式碼

這是個讓人有點害怕的函式名。暫停?這可不好,但我們是可以解決問題的。_suspend_self 函式是 functools.partial 的一個簡單應用,它的工作原理是:通過從外部函式作用域中捕獲 namespace,並把它懸停在內部函式中。

    def suspender(*args, **kwargs):
        return suspended(namespace, *args, **kwargs)
複製程式碼

接下來,這個內部的函式呼叫了和第一個引數 namespace 一起傳遞進來的函式 suspended,實際上這是將方法又包了一層,這樣它就可以應用在一個普通的 Python 類上。_suspend_self 餘下的部分就只是設定一些屬性,這些屬性在某些時候可能會被對映(reflection)用到(我可能漏掉一些內容)。

猛獸(beast)

下一個函式是 make_class。從它的簽名中我們能知道什麼?

def make_class(locals: dict):  
    """
    在被呼叫者的本地建立一個類。
    引數 locals:建立類的本地。
    """
複製程式碼

如果其他方法請求或者直接取得了你的本地變數,可不是什麼好事。通常情況下,這是為了在之前的棧中搜尋什麼東西,或者就是在黑你的本機。我們當前的例項屬於前面一種,搜尋本地函式並加入到類中。

    # 試著找到一個 `__call__` 來執行 call 函式
    # 它將作為一個函式,這樣名稱空間和被呼叫者可以引用彼此
    def call_maker():
        if `__call__` in locals and callable(locals[`__call__`]):
            return _suspend_self(namespace, locals[`__call__`])

        def _not_callable(*args, **kwargs):
            raise TypeError(`This is not callable`)

        return _not_callable
複製程式碼

這個函式相當簡單,它是一個將函式作為返回值的函式!
它實際上做了如下這些事:

  • 在函式類中檢查你是否已經定義過 __call__
  • 如果有,就像上文介紹過的那樣,用 _suspend_self 函式“掛載” namespace 來用 __call__ 生成一個方法。
  • 如果沒有,就和預設的 __call__ 一樣,返回一個會發起錯誤的樁函式(stub function)。

名稱空間 namespace

namespace 是關鍵的部分,然而我還沒有解說。類中的每一個(或者絕大部分)方法都會將 self 作為第一個引數,這個 self 就是函式執行的時候類的例項。

一個類的例項實際上就是一個你可以用 . 符號而不是數字索引訪問其內容的字典。所以需要一個可以傳入我們期望的函式的物件來模仿這個字典。於是我們就說,這個例項是一個 namespace,我們在 namespace 上設定變數等等。後文提到 namespace 的地方,就把它當作我們的例項。通過呼叫類的物件自身,你可以獲取這個類的例項:obb = SomeClass()

標準的建立點式訪問的字典的方法是 attrdict:

attrdict = type("attrdict", (dict,), {"__getattr__": dict.__getitem__, "__setattr__": dict.__setitem__})  
複製程式碼

但是既然它建立了一個類,這就有點欺騙性了。其他的方法包括 typing.SimpleNamespace,或者建立一個無哨兵(sentinel)的類。但是這兩種方法都還是欺騙性的建立了類,我們都不能用。

解決方案

namespace 的解決方案是另一個函式。函式的行為可以像可呼叫的點式訪問字典,所以我們就簡單的建立一個 namespace 函式,假設它就是 self。

    # 這個就充當了 self 物件
    # 所有的屬性都建立在此之上
    def namespace():
        return called()
複製程式碼

需要注意呼叫 called() 的用法 – 這是為了正常模擬例項上 __call__ 的行為。

建立 __init__

Python 中的所有類都有 __init__(不包括預設提供空 init 的類),所以我們需要去模仿這一點並確保使用者定義的 init 被呼叫。

    # 建立一個 init 的替代方法
    def new_class(*args, **kwargs):
        init = locals.get("__init__")
        if init is not None:
            init(namespace, *args, **kwargs)

        return namespace
複製程式碼

這段程式碼就是簡單的從本地獲取使用者定義的 __init__,如果找到了,就呼叫它。然後,它返回 namespace(就是假的例項),有效地模擬了迴圈:(metaclass.)__call__ -> __new__ -> __init__

清理

接下來要做的就是在類的基礎上建立方法,這可以用超級簡單的迴圈掃描來完成:

    # 更新 namespace
    for name, item in locals.items():
        if callable(item):
            fn = _suspend_self(namespace, item)
            setattr(namespace, name, fn)
複製程式碼

和上文提到的相似,所有可呼叫的函式都被 _suspend_self 包裹來將函式變成類的方法,在 namespace 完成設定。

獲取到類

最後要做的就是簡單的 return new_class。獲取到類的例項的最後一輪迴圈是:

  • 使用者的程式碼定義了一個類函式
  • 當類函式被呼叫,該函式呼叫 make_class 來設定 namespace(新增 @make 修飾符,這一步就能自動完成)
  • make_class 函式設定例項,使其為後續的初始化做好準備
  • make_class 函式返回另一個函式,呼叫這個函式就能獲取到例項並完成它的初始化。

現在我們就得到它了,一個完全沒用類的類。打賭你會實際應用它。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章