為什麼繼承 Python 內建型別會出問題?!
阿新 • • 發佈:2020-11-15
> 本文出自“Python為什麼”系列,請檢視[全部文章](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzUyOTk2MTcwNg==&action=getalbum&album_id=1338406397180084225&subscene=0&scenenote=https%3A%2F%2Fmp.weixin.qq.com%2Fs%3F__biz%3DMzUyOTk2MTcwNg%3D%3D%26mid%3D2247485945%26idx%3D1%26sn%3D02f1ac9a690f57accefeed7a1ea1247b%26chksm%3Dfa584e7ccd2fc76af5d45ebbc43c1e4d379a0fcfeee8ce70111a8a293c4b7efc8ac7a82dfd6a%26xtrack%3D1%26scene%3D0%26subscene%3D91%26sessionid%3D1596284425%26clicktime%3D1596284604%26enterid%3D1596284604%26ascene%3D7%26devicetype%3Dandroid-28%26version%3D2700103f%26nettype%3DWIFI%26abtest_cookie%3DAAACAA%253D%253D%26lang%3Dzh_CN%26exportkey%3DAa1rI96xIRQ8cDJChmQS9BU%253D%26pass_ticket%3DP%252BUyocSqsqUN5JuCQOyjZNpQH%252Fwm0bsN6NchdKKM9CFDDEu0ZPKsRpo8Utu4BBRc%26wx_header%3D1#wechat_redirect)
不久前,`Python貓` 給大家推薦了一本書《流暢的Python》([點選可跳轉閱讀](https://mp.weixin.qq.com/s/A4_DD2fvceNk1apn9MQcXA)),那篇文章有比較多的“溢美之詞”,顯得比較空泛……
但是,《流暢的Python》一書值得反覆回看,可以溫故知新。最近我偶然翻到書中一個有點詭異的知識點,因此準備來聊一聊這個話題——**子類化內建型別可能會出問題?!**
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gk0qx3a7iuj20uq0hnq8g.jpg)
## 1、內建型別有哪些?
在正式開始之前,我們首先要科普一下:**哪些是 Python 的內建型別?**
根據官方文件的分類,內建型別(Built-in Types)主要包含如下內容:
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gk1f8ng1ozj20m90d3my7.jpg)
詳細文件:https://docs.python.org/3/library/stdtypes.html
其中,有大家熟知的[數字型別](https://mp.weixin.qq.com/s/0XpPaH53II5yO9Lfh80ZOw)、序列型別、文字型別、對映型別等等,當然還有我們之前介紹過的[布林型別](https://mp.weixin.qq.com/s/JVhXjQKcd8uds8yTUumZJw)、[...物件](https://mp.weixin.qq.com/s/SOSN_p74eDHv3tJnSJIZfg) 等等。
在這麼多內容裡,本文只關注那些作為`可呼叫物件`(callable)的內建型別,也就是跟內建函式(built-in function)在表面上相似的那些:**int、str、list、tuple、range、set、dict……**
這些型別(type)可以簡單理解成其它語言中的類(class),但是 Python 在此並沒有用習慣上的大駝峰命名法,因此容易讓人產生一些誤解。
在 Python 2.2 之後,這些內建型別可以被子類化(subclassing),也就是可以被繼承(inherit)。
## 2、內建型別的子類化
眾所周知,對於某個普通物件 x,Python 中求其長度需要用到公共的內建函式 len(x),它不像 Java 之類的面嚮物件語言,後者的物件一般擁有自己的 x.length() 方法。(PS:關於這兩種設計風格的分析,推薦閱讀 [這篇文章](https://mp.weixin.qq.com/s/pKQT5wvyaSNFvnJexiCC8w))
現在,假設我們要定義一個列表類,希望它擁有自己的 length() 方法,同時保留普通列表該有的所有特性。
實驗性的程式碼如下(僅作演示):
```python
# 定義一個list的子類
class MyList(list):
def length(self):
return len(self)
```
我們令 MyList這個自定義類繼承 list,同時新定義一個 length() 方法。這樣一來,MyList 就擁有 append()、pop() 等等方法,同時還擁有 length() 方法。
```python
# 新增兩個元素
ss = MyList()
ss.append("Python")
ss.append("貓")
print(ss.length()) # 輸出:2
```
前面提到的其它內建型別,也可以這樣作子類化,應該不難理解。
順便發散一下,**內建型別的子類化有何好處/使用場景呢?**
有一個很直觀的例子,當我們在自定義的類裡面,需要頻繁用到一個列表物件時(給它新增/刪除元素、作為一個整體傳遞……),這時候如果我們的類繼承自 list,就可以直接寫 self.append()、self.pop(),或者將 self 作為一個物件傳遞,從而不用額外定義一個列表物件,在寫法上也會簡潔一些。
還有其它的好處/使用場景麼?歡迎大家留言討論~~
## 3、內建型別子類化的“問題”
終於要進入本文的正式主題了:)
通常而言,在我們教科書式的認知中,**子類中的方法會覆蓋父類的同名方法,也就是說,子類方法的查詢優先順序要高於父類方法。**
下面看一個例子,父類 Cat,子類 PythonCat,都有一個 say() 方法,作用是說出當前物件的 inner_voice:
```python
# 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() 方法:
```python
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章節):
```python
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]}
```
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gknw0m0nzuj20q90ag758.jpg)
在這個例子中,dd['two'] 會直接呼叫子類的\_\_setitem\_\_()方法,所以結果符合預期。如果其它測試也符合預期的話,最終結果會是{'three': [3, 3], 'one': [1, 1], 'two': [2, 2]}。
然而,初始化和 update() 直接呼叫的分別是從父類繼承的\_\_init\_\_()和\_\_update\_\_(),再由它們**隱式地**呼叫_\_setitem\_\_()方法,此時卻並沒有呼叫子類的方法,而是呼叫了父類的方法,導致結果超出預期!
官方 Python 這種實現雙重規則的做法,有點違背大家的常識,如果不加以注意,搞不好就容易踩坑。
那麼,為什麼會出現這種例外的情況呢?
## 4、內建型別的方法的真面目
我們知道了內建型別不會隱式地呼叫子類覆蓋的方法,接著,就是`Python貓`的刨根問底時刻:為什麼它不去呼叫呢?
《[流暢的Python](https://mp.weixin.qq.com/s/A4_DD2fvceNk1apn9MQcXA)》書中沒有繼續追問,不過,我試著胡亂猜測一下(應該能從原始碼中得到驗證):**內建型別的方法都是用 C 語言實現的,事實上它們彼此之間並不存在著相互呼叫,所以就不存在呼叫時的查詢優先順序問題。**
也就是說,前面的“\_\_init\_\_()和\_\_update\_\_()會隱式地呼叫_\_setitem\_\_()方法”這種說法並不準確!
這幾個魔術方法其實是相互獨立的!\_\_init\_\_()有自己的 setitem 實現,並不會呼叫父類的\_\_setitem\_\_(),當然跟子類的\_\_setitem\_\_()就更沒有關係了。
從邏輯上理解,字典的\_\_init\_\_()方法中包含\_\_setitem\_\_()的功能,因此我們以為前者會呼叫後者,**這是慣性思維的體現,**然而實際的呼叫關係可能是這樣的:
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gkok0fm3ucj20u10c574u.jpg)
左側的方法開啟語言介面之門進入右側的世界,在那裡實現它的所有使命,並不會折返回原始介面查詢下一步的指令(即不存在圖中的紅線路徑)。不折返的原因很簡單,即 C 語言間程式碼呼叫效率更高,實現路徑更短,實現過程更簡單。
同理,dict 型別的 get() 方法與\_\_getitem\_\_()也不存在呼叫關係,如果子類只覆蓋了\_\_getitem\_\_()的話,當子類呼叫 get() 方法時,實際會使用到父類的 get() 方法。(PS:關於這一點,《流暢的Python》及 PyPy 文件的描述都不準確,它們誤以為 get() 方法會呼叫\_\_getitem\_\_())
也就是說,Python 內建型別的方法本身不存在呼叫關係,儘管它們在底層 C 語言實現時,可能存在公共的邏輯或能被複用的方法。
我想到了“[Python為什麼](https://github.com/chinesehuazhou/python-whydo)”系列曾分析過的《[Python 為什麼能支援任意的真值判斷?](https://mp.weixin.qq.com/s/g6jZX0IdH9xpM7BMV3-ToQ)》。在我們寫`if xxx`時,它似乎會隱式地呼叫\_\_bool\_\_()和\_\_len\_\_()魔術方法,然而實際上程式依據 POP_JUMP_IF_FALSE 指令,會直接進入純 C 程式碼的邏輯,並不存在對這倆魔術方法的呼叫!
因此,在意識到 C 實現的特殊方法間相互獨立之後,我們再回頭看內建型別的子類化,就會有新的發現:
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gkokkxoyapj20z60jcdgy.jpg)
父類的\_\_init\_\_()魔術方法會打破語言介面實現自己的使命,然而它跟子類的\_\_setitem\_\_()並不存在通路,即圖中紅線路徑不可達。
特殊方法間各行其是,由此,我們會得出跟前文不同的結論:**實際上 Python 嚴格遵循了“子類方法先於父類方法”繼承原則,並沒有破壞常識!**
最後值得一提的是,\_\_missing\_\_()是一個特例。《流暢的Python》僅僅簡單而含糊地寫了一句,沒有過多展開。
經過初步實驗,我發現當子類定義了此方法時,get() 讀取不存在的 key 時,正常返回 None;但是 \_\_getitem\_\_() 和 dd['xxx'] 讀取不存在的 key 時,都會按子類定義的\_\_missing\_\_()進行處理。
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gkosoha3krj20f70a63ys.jpg)
我還沒空深入分析,懇請知道答案的同學給我留言。
## 5、內建型別子類化的最佳實踐
綜上所述,內建型別子類化時並沒有出問題,只是由於我們沒有認清特殊方法(C 語言實現的方法)的真面目,才會導致結果偏差。
那麼,這又召喚出了一個新的問題:**如果非要繼承內建型別,最佳的實踐方式是什麼呢?**
首先,如果在繼承內建型別後,並不重寫(overwrite)它的特殊方法的話,子類化就不會有任何問題。
其次,如果繼承後要重寫特殊方法的話,記得要把所有希望改變的方法都重寫一遍,例如,如果想改變 get() 方法,就要重寫 get() 方法,如果想改變 \_\_getitem\_\_()方法,就要重寫它……
但是,如果我們只是想重寫某種邏輯(即 C 語言的部分),以便所有用到該邏輯的特殊方法都發生改變的話,例如重寫\_\_setitem\_\_()的邏輯,同時令初始化和update()等操作跟著改變,那麼該怎麼辦呢?
我們已知特殊方法間不存在複用,也就是說單純定義新的\_\_setitem\_\_()是不夠的,那麼,怎麼才能對多個方法同時產生影響呢?
PyPy 這個非官方的 Python 版本發現了這個問題,它的做法是令內建型別的特殊方法發生呼叫,建立它們之間的連線通路。
官方 Python 當然也意識到了這麼問題,不過它並沒有改變內建型別的特性,而是提供出了新的方案:UserString、UserList、UserDict……
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gkotx6gj14j20rz04taap.jpg)
除了名字不一樣,基本可以認為它們等同於內建型別。
這些類的基本邏輯是用 Python 實現的,相當於是把前文 C 語言介面的某些邏輯搬到了 Python 介面,在左側建立起呼叫鏈,如此一來,就解決了某些特殊方法的複用問題。
對照前文的例子,採用新的繼承方式後,結果就符合預期了:
```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`庫提供的那幾個類。**
## 6、小結
寫了這麼多,是時候作 ending 了~~
在本系列的前一篇文章中,Python貓從查詢順序與執行速度兩方面,分析了“[為什麼內建函式/內建型別不是萬能的](https://mp.weixin.qq.com/s/YtfPlE9JAIS3tpLBGFo5ag)”,本文跟它一脈相承,也是揭示了內建型別的某種神祕的看似是缺陷的行為特徵。
本文雖然是從《流暢的Python》書中獲得的靈感,然而在語言表象之外,我們還多追問了一個“為什麼”,從而更進一步地分析出了現象背後的原理。
簡而言之,**內建型別的特殊方法是由 C 語言獨立實現的,它們在 Python 語言介面中不存在呼叫關係,因此在內建型別子類化時,被重寫的特殊方法只會影響該方法本身,不會影響其它特殊方法的效果。**
如果我們對特殊方法間的關係有錯誤的認知,就可能會認為 Python 破壞了“子類方法先於父類方法”的基本繼承原則。(很遺憾《流暢的Python》和 PyPy 都有此錯誤的認知)
為了迎合大家對內建型別的普遍預期,Python 在標準庫中提供了 UserString、UserList、UserDict 這些擴充套件類,方便程式設計師來繼承這些基本的資料型別。
寫在最後:本文屬於“[Python為什麼](https://github.com/chinesehuazhou/python-whydo)”系列(Python貓出品),該系列主要關注 Python 的語法、設計和發展等話題,以一個個“為什麼”式的問題為切入點,試著展現 Python 的迷人魅力。若你有其它感興趣的話題,歡迎填在《[Python的十萬個為什麼?](https://mp.weixin.qq.com/s/jobdpO7BWWON0ruLNpn31Q) 》裡的調查問卷中。
![](http://ww1.sinaimg.cn/large/68b02e3bgy1gfffh3g28lj2076076q3e.jpg)
公眾號【**Python貓**】, 本號連載優質的系列文章,有Python為什麼系列、喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關