新聞中心
《流暢的Python》一書(shū)值得反復(fù)回看,可以溫故知新。最近我偶然翻到書(shū)中一個(gè)有點(diǎn)詭異的知識(shí)點(diǎn),因此準(zhǔn)備來(lái)聊一聊這個(gè)話(huà)題——子類(lèi)化內(nèi)置類(lèi)型可能會(huì)出問(wèn)題?!

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


咨詢(xún)
建站咨詢(xún)
