爲何繼承 Python 內置類型會出問題?!

△點擊上方「Python貓」關注 ,回覆「1」領取電子書html

做者:豌豆花下貓python

來源:Python貓程序員

不久前,Python貓 給你們推薦了一本書《流暢的Python》(點擊可跳轉閱讀),那篇文章有比較多的「溢美之詞」,顯得比較空泛……web

可是,《流暢的Python》一書值得反覆回看,能夠溫故知新。最近我偶然翻到書中一個有點詭異的知識點,所以準備來聊一聊這個話題——子類化內置類型可能會出問題?!編程

一、內置類型有哪些?

在正式開始以前,咱們首先要科普一下:哪些是 Python 的內置類型?微信

根據官方文檔的分類,內置類型(Built-in Types)主要包含以下內容:多線程

詳細文檔:https://docs.python.org/3/library/stdtypes.htmlapp

其中,有你們熟知的數字類型、序列類型、文本類型、映射類型等等,固然還有咱們以前介紹過的布爾類型...對象 等等。編程語言

在這麼多內容裏,本文只關注那些做爲可調用對象(callable)的內置類型,也就是跟內置函數(built-in function)在表面上類似的那些:int、str、list、tuple、range、set、dict……編輯器

這些類型(type)能夠簡單理解成其它語言中的類(class),可是 Python 在此並無用習慣上的大駝峯命名法,所以容易讓人產生一些誤解。

在 Python 2.2 以後,這些內置類型能夠被子類化(subclassing),也就是能夠被繼承(inherit)。

二、內置類型的子類化

衆所周知,對於某個普通對象 x,Python 中求其長度須要用到公共的內置函數 len(x),它不像 Java 之類的面嚮對象語言,後者的對象通常擁有本身的 x.length() 方法。(PS:關於這兩種設計風格的分析,推薦閱讀 這篇文章

如今,假設咱們要定義一個列表類,但願它擁有本身的 length() 方法,同時保留普通列表該有的全部特性。

實驗性的代碼以下(僅做演示):

# 定義一個list的子類
class MyList(list):
    def length(self):
        return len(self)

咱們令 MyList這個自定義類繼承 list,同時新定義一個 length() 方法。這樣一來,MyList 就擁有 append()、pop() 等等方法,同時還擁有 length() 方法。

# 添加兩個元素
ss = MyList()
ss.append("Python")
ss.append("貓")

print(ss.length())   # 輸出:2

前面提到的其它內置類型,也能夠這樣做子類化,應該不難理解。

順便發散一下,內置類型的子類化有何好處/使用場景呢?

有一個很直觀的例子,當咱們在自定義的類裏面,須要頻繁用到一個列表對象時(給它添加/刪除元素、做爲一個總體傳遞……),這時候若是咱們的類繼承自 list,就能夠直接寫 self.append()、self.pop(),或者將 self 做爲一個對象傳遞,從而不用額外定義一個列表對象,在寫法上也會簡潔一些。

還有其它的好處/使用場景麼?歡迎你們留言討論~~

三、內置類型子類化的「問題」

終於要進入本文的正式主題了:)

一般而言,在咱們教科書式的認知中,子類中的方法會覆蓋父類的同名方法,也就是說,子類方法的查找優先級要高於父類方法。

下面看一個例子,父類 Cat,子類 PythonCat,都有一個 say() 方法,做用是說出當前對象的 inner_voice:

# Python貓是一隻貓
class Cat():
    def say(self):
        return self.inner_voice()
    def inner_voice(self):
        return "喵"
class PythonCat(Cat):
    def inner_voice(self):
        return "喵喵"

當咱們建立子類 PythonCat 的對象時,它的 say() 方法會優先取到本身定義出的 inner_voice() 方法,而不是 Cat 父類的 inner_voice() 方法:

my_cat = PythonCat()
# 下面的結果符合預期
print(my_cat.inner_voice()) # 輸出:喵喵
print(my_cat.say())         # 輸出:喵喵

這是編程語言約定俗成的慣例,是一個基本原則,學過面向對象編程基礎的同窗都應該知道。

然而,當 Python 在實現繼承時,彷佛不徹底會按照上述的規則運做。它分爲兩種狀況:

  • 符合常識:對於用 Python 實現的類,它們會遵循「子類先於父類」的原則
  • 違背常識:對於實際是用 C 實現的類(即str、list、dict等等這些內置類型),在顯式調用子類方法時,會遵循「子類先於父類」的原則;可是,**在存在隱式調用時,**它們彷佛會遵循「父類先於子類」的原則,即一般的繼承規則會在此失效

對照 PythonCat 的例子,至關於說,直接調用 my_cat.inner_voice() 時,會獲得正確的「喵喵」結果,可是在調用 my_cat.say() 時,則會獲得超出預期的「喵」結果。

下面是《流暢的Python》中給出的例子(12.1章節):

class DoppelDict(dict): 
    def __setitem__(self, key, value): 
        super().__setitem__(key, [value] * 2)

dd = DoppelDict(one=1)  # {'one': 1}
dd['two'] = 2           # {'one': 1, 'two': [2, 2]}
dd.update(three=3)      # {'three': 3, 'one': 1, 'two': [2, 2]}

在這個例子中,dd['two'] 會直接調用子類的__setitem__()方法,因此結果符合預期。若是其它測試也符合預期的話,最終結果會是{'three': [3, 3], 'one': [1, 1], 'two': [2, 2]}。

然而,初始化和 update() 直接調用的分別是從父類繼承的__init__()和__update__(),再由它們隱式地調用__setitem__()方法,此時卻並無調用子類的方法,而是調用了父類的方法,致使結果超出預期!

官方 Python 這種實現雙重規則的作法,有點違揹你們的常識,若是不加以注意,搞很差就容易踩坑。

那麼,爲何會出現這種例外的狀況呢?

四、內置類型的方法的真面目

咱們知道了內置類型不會隱式地調用子類覆蓋的方法,接着,就是Python貓的刨根問底時刻:爲何它不去調用呢?

流暢的Python》書中沒有繼續追問,不過,我試着胡亂猜想一下(應該能從源碼中獲得驗證):內置類型的方法都是用 C 語言實現的,事實上它們彼此之間並不存在着相互調用,因此就不存在調用時的查找優先級問題。

也就是說,前面的「__init__()和__update__()會隱式地調用__setitem__()方法」這種說法並不許確!

這幾個魔術方法實際上是相互獨立的!__init__()有本身的 setitem 實現,並不會調用父類的__setitem__(),固然跟子類的__setitem__()就更沒有關係了。

從邏輯上理解,字典的__init__()方法中包含__setitem__()的功能,所以咱們覺得前者會調用後者,**這是慣性思惟的體現,**然而實際的調用關係多是這樣的:

左側的方法打開語言界面之門進入右側的世界,在那裏實現它的全部使命,並不會折返回原始界面查找下一步的指令(即不存在圖中的紅線路徑)。不折返的緣由很簡單,即 C 語言間代碼調用效率更高,實現路徑更短,實現過程更簡單。

同理,dict 類型的 get() 方法與__getitem__()也不存在調用關係,若是子類只覆蓋了__getitem__()的話,當子類調用 get() 方法時,實際會使用到父類的 get() 方法。(PS:關於這一點,《流暢的Python》及 PyPy 文檔的描述都不許確,它們誤覺得 get() 方法會調用__getitem__())

也就是說,Python 內置類型的方法自己不存在調用關係,儘管它們在底層 C 語言實現時,可能存在公共的邏輯或能被複用的方法。

我想到了「Python爲何」系列曾分析過的《Python 爲何能支持任意的真值判斷?》。在咱們寫if xxx時,它彷佛會隱式地調用__bool__()和__len__()魔術方法,然而實際上程序依據 POP_JUMP_IF_FALSE 指令,會直接進入純 C 代碼的邏輯,並不存在對這倆魔術方法的調用!

所以,在乎識到 C 實現的特殊方法間相互獨立以後,咱們再回頭看內置類型的子類化,就會有新的發現:

父類的__init__()魔術方法會打破語言界面實現本身的使命,然而它跟子類的__setitem__()並不存在通路,即圖中紅線路徑不可達。

特殊方法間各行其是,由此,咱們會得出跟前文不一樣的結論:實際上 Python 嚴格遵循了「子類方法先於父類方法」繼承原則,並無破壞常識!

最後值得一提的是,__missing__()是一個特例。《流暢的Python》僅僅簡單而含糊地寫了一句,沒有過多展開。

通過初步實驗,我發現當子類定義了此方法時,get() 讀取不存在的 key 時,正常返回 None;可是 __getitem__() 和 dd['xxx'] 讀取不存在的 key 時,都會按子類定義的__missing__()進行處理。

我還沒空深刻分析,懇請知道答案的同窗給我留言。

五、內置類型子類化的最佳實踐

綜上所述,內置類型子類化時並無出問題,只是因爲咱們沒有認清特殊方法(C 語言實現的方法)的真面目,纔會致使結果誤差。

那麼,這又召喚出了一個新的問題:若是非要繼承內置類型,最佳的實踐方式是什麼呢?

首先,若是在繼承內置類型後,並不重寫(overwrite)它的特殊方法的話,子類化就不會有任何問題。

其次,若是繼承後要重寫特殊方法的話,記得要把全部但願改變的方法都重寫一遍,例如,若是想改變 get() 方法,就要重寫 get() 方法,若是想改變 __getitem__()方法,就要重寫它……

可是,若是咱們只是想重寫某種邏輯(即 C 語言的部分),以便全部用到該邏輯的特殊方法都發生改變的話,例如重寫__setitem__()的邏輯,同時令初始化和update()等操做跟着改變,那麼該怎麼辦呢?

咱們已知特殊方法間不存在複用,也就是說單純定義新的__setitem__()是不夠的,那麼,怎麼才能對多個方法同時產生影響呢?

PyPy 這個非官方的 Python 版本發現了這個問題,它的作法是令內置類型的特殊方法發生調用,創建它們之間的鏈接通路。

官方 Python 固然也意識到了這麼問題,不過它並無改變內置類型的特性,而是提供出了新的方案:UserString、UserList、UserDict……

除了名字不同,基本能夠認爲它們等同於內置類型。

這些類的基本邏輯是用 Python 實現的,至關因而把前文 C 語言界面的某些邏輯搬到了 Python 界面,在左側創建起調用鏈,如此一來,就解決了某些特殊方法的複用問題。

對照前文的例子,採用新的繼承方式後,結果就符合預期了:

from collections import UserDict

class DoppelDict(UserDict):
    def __setitem__(self, key, value): 
        super().__setitem__(key, [value] * 2)

dd = DoppelDict(one=1)  # {'one': [1, 1]}
dd['two'] = 2           # {'one': [1, 1], 'two': [2, 2]}
dd.update(three=3)      # {'one': [1, 1], 'two': [2, 2], 'three': [3, 3]}

顯然,若是要繼承 str/list/dict 的話,最佳的實踐就是繼承collections庫提供的那幾個類。

六、小結

寫了這麼多,是時候做 ending 了~~

在本系列的前一篇文章中,Python貓從查找順序與運行速度兩方面,分析了「爲何內置函數/內置類型不是萬能的」,本文跟它一脈相承,也是揭示了內置類型的某種神祕的看似是缺陷的行爲特徵。

本文雖然是從《流暢的Python》書中得到的靈感,然而在語言表象以外,咱們還多追問了一個「爲何」,從而更進一步地分析出了現象背後的原理。

簡而言之,內置類型的特殊方法是由 C 語言獨立實現的,它們在 Python 語言界面中不存在調用關係,所以在內置類型子類化時,被重寫的特殊方法只會影響該方法自己,不會影響其它特殊方法的效果。

若是咱們對特殊方法間的關係有錯誤的認知,就可能會認爲 Python 破壞了「子類方法先於父類方法」的基本繼承原則。(很遺憾《流暢的Python》和 PyPy 都有此錯誤的認知)

爲了迎合你們對內置類型的廣泛預期,Python 在標準庫中提供了 UserString、UserList、UserDict 這些擴展類,方便程序員來繼承這些基本的數據類型。

寫在最後:本文屬於「Python爲何」系列(Python貓出品),該系列主要關注 Python 的語法、設計和發展等話題,以一個個「爲何」式的問題爲切入點,試着展示 Python 的迷人魅力。若你有其它感興趣的話題,歡迎填在《Python的十萬個爲何? 》裏的調查問卷中。

Python貓技術交流羣開放啦!羣裏既有國內一二線大廠在職員工,也有國內外高校在讀學生,既有十多年碼齡的編程老鳥,也有中小學剛剛入門的新人,學習氛圍良好!想入羣的同窗,請在公號內回覆『 交流羣』,獲取貓哥的微信 (謝絕廣告黨,非誠勿擾!)~

近期熱門文章推薦:

爲何說 Python 內置函數並非萬能的?
爲何 Python 多線程沒法利用多核?
Python 爲何不支持 switch 語句?
詳解 Python 的二元算術運算,爲何說減法只是語法糖?

本文分享自微信公衆號 - Python貓(python_cat)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索