為什么繼承 Python 內(nèi)置類型會出問題?!
《流暢的Python》一書值得反復(fù)回看,可以溫故知新。最近我偶然翻到書中一個有點詭異的知識點,因此準(zhǔn)備來聊一聊這個話題——子類化內(nèi)置類型可能會出問題?!
1、內(nèi)置類型有哪些?
在正式開始之前,我們首先要科普一下:哪些是 Python 的內(nèi)置類型?
根據(jù)官方文檔的分類,內(nèi)置類型(Built-in Types)主要包含如下內(nèi)容:
詳細文檔:https://docs.python.org/3/library/stdtypes.html
其中,有大家熟知的數(shù)字類型、序列類型、文本類型、映射類型等等,當(dāng)然還有我們之前介紹過的布爾類型、...對象 等等。
在這么多內(nèi)容里,本文只關(guān)注那些作為可調(diào)用對象(callable)的內(nèi)置類型,也就是跟內(nèi)置函數(shù)(built-in function)在表面上相似的那些:int、str、list、tuple、range、set、dict……
這些類型(type)可以簡單理解成其它語言中的類(class),但是 Python 在此并沒有用習(xí)慣上的大駝峰命名法,因此容易讓人產(chǎn)生一些誤解。
在 Python 2.2 之后,這些內(nèi)置類型可以被子類化(subclassing),也就是可以被繼承(inherit)。
2、內(nèi)置類型的子類化
眾所周知,對于某個普通對象 x,Python 中求其長度需要用到公共的內(nèi)置函數(shù) len(x),它不像 Java 之類的面向?qū)ο笳Z言,后者的對象一般擁有自己的 x.length() 方法。(PS:關(guān)于這兩種設(shè)計風(fēng)格的分析,推薦閱讀 這篇文章)
現(xiàn)在,假設(shè)我們要定義一個列表類,希望它擁有自己的 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
前面提到的其它內(nèi)置類型,也可以這樣作子類化,應(yīng)該不難理解。
順便發(fā)散一下,內(nèi)置類型的子類化有何好處/使用場景呢?
有一個很直觀的例子,當(dāng)我們在自定義的類里面,需要頻繁用到一個列表對象時(給它添加/刪除元素、作為一個整體傳遞……),這時候如果我們的類繼承自 list,就可以直接寫 self.append()、self.pop(),或者將 self 作為一個對象傳遞,從而不用額外定義一個列表對象,在寫法上也會簡潔一些。
還有其它的好處/使用場景么?歡迎大家留言討論~~
3、內(nèi)置類型子類化的“問題”
終于要進入本文的正式主題了:)
通常而言,在我們教科書式的認(rèn)知中,子類中的方法會覆蓋父類的同名方法,也就是說,子類方法的查找優(yōu)先級要高于父類方法。
下面看一個例子,父類 Cat,子類 PythonCat,都有一個 say() 方法,作用是說出當(dāng)前對象的 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 "喵喵"
當(dāng)我們創(chuàng)建子類 PythonCat 的對象時,它的 say() 方法會優(yōu)先取到自己定義出的 inner_voice() 方法,而不是 Cat 父類的 inner_voice() 方法:
- my_cat = PythonCat()
- # 下面的結(jié)果符合預(yù)期
- print(my_cat.inner_voice()) # 輸出:喵喵
- print(my_cat.say()) # 輸出:喵喵
這是編程語言約定俗成的慣例,是一個基本原則,學(xué)過面向?qū)ο缶幊袒A(chǔ)的同學(xué)都應(yīng)該知道。
然而,當(dāng) Python 在實現(xiàn)繼承時,似乎不完全會按照上述的規(guī)則運作。它分為兩種情況:
- 符合常識:對于用 Python 實現(xiàn)的類,它們會遵循“子類先于父類”的原則
- 違背常識:對于實際是用 C 實現(xiàn)的類(即str、list、dict等等這些內(nèi)置類型),在顯式調(diào)用子類方法時,會遵循“子類先于父類”的原則;但是,**在存在隱式調(diào)用時,**它們似乎會遵循“父類先于子類”的原則,即通常的繼承規(guī)則會在此失效
對照 PythonCat 的例子,相當(dāng)于說,直接調(diào)用 my_cat.inner_voice() 時,會得到正確的“喵喵”結(jié)果,但是在調(diào)用 my_cat.say() 時,則會得到超出預(yù)期的“喵”結(jié)果。
下面是《流暢的Python》中給出的例子(12.1章節(jié)):
- 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'] 會直接調(diào)用子類的__setitem__()方法,所以結(jié)果符合預(yù)期。如果其它測試也符合預(yù)期的話,最終結(jié)果會是{'three': [3, 3], 'one': [1, 1], 'two': [2, 2]}。
然而,初始化和 update() 直接調(diào)用的分別是從父類繼承的__init__()和__update__(),再由它們隱式地調(diào)用__setitem__()方法,此時卻并沒有調(diào)用子類的方法,而是調(diào)用了父類的方法,導(dǎo)致結(jié)果超出預(yù)期!
官方 Python 這種實現(xiàn)雙重規(guī)則的做法,有點違背大家的常識,如果不加以注意,搞不好就容易踩坑。
那么,為什么會出現(xiàn)這種例外的情況呢?
4、內(nèi)置類型的方法的真面目
我們知道了內(nèi)置類型不會隱式地調(diào)用子類覆蓋的方法,接著,就是Python貓的刨根問底時刻:為什么它不去調(diào)用呢?
《流暢的Python》書中沒有繼續(xù)追問,不過,我試著胡亂猜測一下(應(yīng)該能從源碼中得到驗證):內(nèi)置類型的方法都是用 C 語言實現(xiàn)的,事實上它們彼此之間并不存在著相互調(diào)用,所以就不存在調(diào)用時的查找優(yōu)先級問題。
也就是說,前面的“__init__()和__update__()會隱式地調(diào)用__setitem__()方法”這種說法并不準(zhǔn)確!
這幾個魔術(shù)方法其實是相互獨立的!__init__()有自己的 setitem 實現(xiàn),并不會調(diào)用父類的__setitem__(),當(dāng)然跟子類的__setitem__()就更沒有關(guān)系了。
從邏輯上理解,字典的__init__()方法中包含__setitem__()的功能,因此我們以為前者會調(diào)用后者,**這是慣性思維的體現(xiàn),**然而實際的調(diào)用關(guān)系可能是這樣的:
左側(cè)的方法打開語言界面之門進入右側(cè)的世界,在那里實現(xiàn)它的所有使命,并不會折返回原始界面查找下一步的指令(即不存在圖中的紅線路徑)。不折返的原因很簡單,即 C 語言間代碼調(diào)用效率更高,實現(xiàn)路徑更短,實現(xiàn)過程更簡單。
同理,dict 類型的 get() 方法與__getitem__()也不存在調(diào)用關(guān)系,如果子類只覆蓋了__getitem__()的話,當(dāng)子類調(diào)用 get() 方法時,實際會使用到父類的 get() 方法。(PS:關(guān)于這一點,《流暢的Python》及 PyPy 文檔的描述都不準(zhǔn)確,它們誤以為 get() 方法會調(diào)用__getitem__())
也就是說,Python 內(nèi)置類型的方法本身不存在調(diào)用關(guān)系,盡管它們在底層 C 語言實現(xiàn)時,可能存在公共的邏輯或能被復(fù)用的方法。
我想到了“Python為什么”系列曾分析過的《Python 為什么能支持任意的真值判斷?》。在我們寫if xxx時,它似乎會隱式地調(diào)用__bool__()和__len__()魔術(shù)方法,然而實際上程序依據(jù) POP_JUMP_IF_FALSE 指令,會直接進入純 C 代碼的邏輯,并不存在對這倆魔術(shù)方法的調(diào)用!
因此,在意識到 C 實現(xiàn)的特殊方法間相互獨立之后,我們再回頭看內(nèi)置類型的子類化,就會有新的發(fā)現(xiàn):
父類的__init__()魔術(shù)方法會打破語言界面實現(xiàn)自己的使命,然而它跟子類的__setitem__()并不存在通路,即圖中紅線路徑不可達。
特殊方法間各行其是,由此,我們會得出跟前文不同的結(jié)論:實際上 Python 嚴(yán)格遵循了“子類方法先于父類方法”繼承原則,并沒有破壞常識!
最后值得一提的是,__missing__()是一個特例。《流暢的Python》僅僅簡單而含糊地寫了一句,沒有過多展開。
經(jīng)過初步實驗,我發(fā)現(xiàn)當(dāng)子類定義了此方法時,get() 讀取不存在的 key 時,正常返回 None;但是 __getitem__() 和 dd['xxx'] 讀取不存在的 key 時,都會按子類定義的__missing__()進行處理。
我還沒空深入分析,懇請知道答案的同學(xué)給我留言。
5、內(nèi)置類型子類化的最佳實踐
綜上所述,內(nèi)置類型子類化時并沒有出問題,只是由于我們沒有認(rèn)清特殊方法(C 語言實現(xiàn)的方法)的真面目,才會導(dǎo)致結(jié)果偏差。
那么,這又召喚出了一個新的問題:如果非要繼承內(nèi)置類型,最佳的實踐方式是什么呢?
首先,如果在繼承內(nèi)置類型后,并不重寫(overwrite)它的特殊方法的話,子類化就不會有任何問題。
其次,如果繼承后要重寫特殊方法的話,記得要把所有希望改變的方法都重寫一遍,例如,如果想改變 get() 方法,就要重寫 get() 方法,如果想改變 __getitem__()方法,就要重寫它……
但是,如果我們只是想重寫某種邏輯(即 C 語言的部分),以便所有用到該邏輯的特殊方法都發(fā)生改變的話,例如重寫__setitem__()的邏輯,同時令初始化和update()等操作跟著改變,那么該怎么辦呢?
我們已知特殊方法間不存在復(fù)用,也就是說單純定義新的__setitem__()是不夠的,那么,怎么才能對多個方法同時產(chǎn)生影響呢?
PyPy 這個非官方的 Python 版本發(fā)現(xiàn)了這個問題,它的做法是令內(nèi)置類型的特殊方法發(fā)生調(diào)用,建立它們之間的連接通路。
官方 Python 當(dāng)然也意識到了這么問題,不過它并沒有改變內(nèi)置類型的特性,而是提供出了新的方案:UserString、UserList、UserDict……
除了名字不一樣,基本可以認(rèn)為它們等同于內(nèi)置類型。
這些類的基本邏輯是用 Python 實現(xiàn)的,相當(dāng)于是把前文 C 語言界面的某些邏輯搬到了 Python 界面,在左側(cè)建立起調(diào)用鏈,如此一來,就解決了某些特殊方法的復(fù)用問題。
對照前文的例子,采用新的繼承方式后,結(jié)果就符合預(yù)期了:
- 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庫提供的那幾個類。
6、小結(jié)
寫了這么多,是時候作 ending 了~~
在本系列的前一篇文章中,Python貓從查找順序與運行速度兩方面,分析了“為什么內(nèi)置函數(shù)/內(nèi)置類型不是萬能的”,本文跟它一脈相承,也是揭示了內(nèi)置類型的某種神秘的看似是缺陷的行為特征。
本文雖然是從《流暢的Python》書中獲得的靈感,然而在語言表象之外,我們還多追問了一個“為什么”,從而更進一步地分析出了現(xiàn)象背后的原理。
簡而言之,內(nèi)置類型的特殊方法是由 C 語言獨立實現(xiàn)的,它們在 Python 語言界面中不存在調(diào)用關(guān)系,因此在內(nèi)置類型子類化時,被重寫的特殊方法只會影響該方法本身,不會影響其它特殊方法的效果。
如果我們對特殊方法間的關(guān)系有錯誤的認(rèn)知,就可能會認(rèn)為 Python 破壞了“子類方法先于父類方法”的基本繼承原則。(很遺憾《流暢的Python》和 PyPy 都有此錯誤的認(rèn)知)
為了迎合大家對內(nèi)置類型的普遍預(yù)期,Python 在標(biāo)準(zhǔn)庫中提供了 UserString、UserList、UserDict 這些擴展類,方便程序員來繼承這些基本的數(shù)據(jù)類型。
本文轉(zhuǎn)載自微信公眾號「Python貓」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系Python貓公眾號。