1. 程式人生 > >手把手教你學python第十三講(MRO詳解和神奇的魔法方法)

手把手教你學python第十三講(MRO詳解和神奇的魔法方法)

如果圖片刷不出來,轉到https://www.bilibili.com/read/cv286207

MRO重製

關於MRO和C3演算法,我又去看了一些文章,然後發現了講的很清楚的文章http://kaiyuan.me/2016/04/27/C3_linearization/。裡面有關於目前python3的MRO的精闢總結。其實這就是一個遍歷節點問題,我這裡就來例項演示一下(你們忘掉上一講裡的MRO演算法啊,上一講是有問題的)

就拿這個來解釋一下,我們上一講是把根畫在上面(就是最底層的子類,沒有其他類繼承它,也就是它不是任何其它類的父類),MRO順序是從左到右先順著一條線去找,上圖我們就是按照d->b->a->c->a的順序(這樣的搜尋方式對c是不利的,c裡面的屬性可能要被a覆蓋掉),但是python3的直譯器會對這個順序就行修正,檢視每一個節點是,也就是中間的每一個類,有沒有右邊路徑上的子類(什麼叫右邊路徑上的子類呢?拿上圖為例,b雖然有子類d,但是這個子類是在我們的當前路徑上的,所以不算,而a除了在目前路徑上還有c這個子類,所以python自動把a這個a從路徑裡刪除),如果有前面說的子類,就丟棄這個節點,這裡注意只是丟棄這個a,後面c->a這條路徑上,a的子類都在當前路徑的左邊,保留,所以說最後的順序是

object是所有類的父類,這個上節課也說過。那麼我們就用這套理論來看一看上節課的幾個例子

左下角c到a少畫了一條線。

第一個例子,我們來看看為什麼不能把父類a寫在子類b前面,為什麼這樣沒有辦法寫出一個MRO順序,從左到右,c的父類在左邊的是a,順序c->a->b,然後python開始檢查節點,這裡要注意一件事,搜尋的方向除了從左到右,大方向還必須是從下到上的,這個圖的上下是怎麼畫的?(左右上一講已經講過,寫在括號左邊就畫在左,右邊就畫在右)如果兩個類沒有繼承關係,那麼它們就是同一等級,如果有,子類在下,父類在上。什麼叫做大方向是從下到上呢?就拿上圖左上來說,c到達a有兩條路,左邊這條路c->b->a,右邊c->a,a在b的上面,這就叫大方向從下到上,還不明白的話看右下,從左到右的路:d-->c->b->a,d->b->a,d->a,a在b上面,a和b在c上面。而我們看右邊兩張圖都不滿足這個條件,右上c->a,c->b->a,a在b上,右下我就不寫了。

總結一下如何生成MRO,首先要判斷從左到右,從下到上的大方向是不是滿足,滿足的話下一步就要去生成路徑然後檢查節點是丟棄還是保留,不滿足直接就會報錯

然後,我們來處理上一講的遺留,注意修改的地方,start[:]

為什麼要改成淺拷貝呢?這主要還是深拷貝和列表的特性決定的,s.content=start(上一講最後是這麼寫的),已經把s.content和start指向同一個地方了,或者說貼在同一個id上了。然偶下面s.push(0)這一句是什麼意思呢?是改變s.content這個指標指向的地址的內容或者說標籤所在的id的內容,因為s.content.pop這一句嘛,因為深拷貝的原因,start也變成了[0],如果你還不是很明白,我們來複習下列表,元組,字典,字串,集合對應的特點。列表,集合,字典都是有很多內建方法來改變內容而不是id的,但是元組,字串,frozenset就不一樣了,它們是無法直接改變內容的,要想改變內容,只能改變它們指向的地址或者說把標籤從一個id轉移到另一個id上,列表,集合,字典的很多內建方法它們用不了,很好理解,因為它們沒辦法直接改變內容,所以那些改變內容的方法都不能用,但是它們也有方法,只是方法返回的是一個新的元組,字串,frozenset而已,不改變原有內容,還不理解我們上程式碼

下面開始神奇的魔法方法

魔法方法

魔法方法的特徵就是__雙下劃線,關於魔法方法自動被呼叫這個特性,我們前面再講類和物件的時候一直都在用__init__方法,應該多少都已經體會到了,雖然不需要呼叫,但是有時候我們還是需要重寫它們來實現一些功能,所以我們還是有必要學習魔法方法的。

以下圖片來自http://bbs.fishc.com/thread-48793-1-1.html

基本的魔法方法

我們看到第一個不是我們講的init方法,而是

這是有原因的,__new__其實才是類例項化時第一個被呼叫的方法,如果你不修改它,它返回的是一個例項化物件,這個例項化物件就是__init__裡面的第一個引數self,從這個意義上來說,__new__也必須先於__init__方法被呼叫。我們先來針對上面的__new__方法裡面寫的四條來一一進行嘗試,第一條已經解釋過了,我們先來解釋第四條__new__主要用來繼承元組或者字串,我們知道字串和元組都是不能改變內容的(改它指向的地址不認為是改變內容),就以元組為例吧

你可能對上面程式碼有很多疑惑,不要緊我們慢慢來。上圖的程式碼提示我們。元組除了在定義的時候可以不用小括號,其它時候最好還是帶上小括號,不然python會自動認為逗號隔開的是引數,而不是元組的元素。我們看到,這個__new__方法實現了元組的顛倒。不要對a1=a((1,2,3))感到奇怪,因為a繼承了tuple,其實就是相當於a1=tuple((1,2,3))。a1這個例項化物件就是一個元組,也許你還有下面的方法來實現這個功能,

這種方法有個什麼問題呢?首先它程式碼多,然後他沒有改變例項化物件,為什麼呢?這其實就是區域性變數和全域性變數的問題,self是a1的形式引數,如果在函式裡(方法裡也是一樣),你去改變形參的值,python會開闢一個新的記憶體來放改變後的值,或者說,self的指向的記憶體空間就變了,而不影響全域性變數的指向。也就是說你只是把形式引數元組顛倒了而沒有改變原來的例項物件,這就像

也可以這麼說,在函式裡面如果你改變全域性變數的值,這個改變是會被python遮蔽掉的(除非你用global),有聯想能力的朋友也許可以想到前面

這種情況和上面是不一樣的,這是要改變例項化物件的屬性,而沒有改變例項化物件本身。例項化物件本身指向的地址和例項化物件屬性指向的地址是分開的,這其實也很好理解,因為根本就不是一個東西嘛。我們肯定是通過例項化物件來找他的屬性的,在方法裡面self有沒有被改變,所以id(self)=id(a1),我猜測屬性這個東西是不分區域性和全域性的。

話先回過來,其實我們還可以這樣實現顛倒一個元組

這和上面的問題其實是一樣的,並沒有改變原來例項化物件的內容,只是改變了物件的屬性。而且程式碼還多,還不直接,這不是python社群的小夥伴願意看到的,於是我們就可以像最上面那樣去修改__new__方法的內容去實現對例項化物件本身的一個操作。也許會有點不習慣,因為以前的例項化物件本身都是沒什麼內容的,像這樣

但是現在例項化物件是一個元組或者其它的一個東西,它是有內容的。然後我們看第3條,__new__如果返回的不是一個例項化物件,__init__方法不會被呼叫。

我們來仔細分析上面的程式碼,第一段程式碼我們返回的是tuple.__new__(cls,t),我就來說說怎麼理解這個事情,首先你進入__new__的時候不是有兩個引數嘛,第一個引數cls據相當於面的self的作用(我前面幾講都偷了懶,把self簡寫為s,這是不規範的,我們約定俗成的還是要寫self),遇到cls我們就換成a,因為是在a的類定義裡嘛。然後tuple.__new__(cls,t)怎麼理解呢?就是返回是cls類的一個t元組作為cls的一個例項,你可能會問為什麼寫的這麼麻煩?這是因為你還在cls的定義裡面,cls這個類還沒有生成,所以我們只能通過它的父類tuple去建立這個例項物件,如果你像下面這樣寫,就會陷入無限遞迴。

其實cls這個引數其實還可以變的,比如說我先定義一個tuple的子類a,然後在定義tuple子類b,在b的類定義裡__new__返回一個a類的例項化物件,可以嗎?試一試

__new__方法很騷的是,上面b1是披著b類的外衣去例項化的,但是它確實a的例項化物件,a類方法b1都是調用不了的,b類方法可以呼叫,__init__就是個例子,因為打印出了a而不是b。這就是__new__實現的'移花接木',所以以後見到例項化的時候要小心,要看__new__有沒有被重寫,可能會有披著羊皮的狼在裡面哦。第二段程式碼__new__返回的是一個t,也就是一個元組,這裡要注意哈,元組是什麼?是a的父類對吧,我們要明白一個概念,子類的例項化物件也是父類的,但是父類的例項化物件不一定是子類的,這有點像你是你爸的孩子,但是你爸的孩子不一定是你,因為你有可能還有兄弟姐妹對吧。所以說__init__方法沒辦法呼叫,因為self引數必須是a類的例項化物件啊,沒有例項化物件怎麼呼叫?所以說return只有返回是這個類的例項化物件時,這個類的__init__才會被呼叫。然後是第二點,__new__的除了第一個引數都是傳遞給__init__的,其實在上面已經有體現了。

這裡總結一下__new__和__init__它們的區別,__new__(cls,..)顧名思義就是要建立一個新的例項化物件的意思,__init__(self,...)呢是初始化例項化物件的屬性,cls就是類的名字,self是__new__返回的例項化物件,注意只有__new__返回的是這個類的例項化物件self才有對應的引數傳入,沒有引數傳入,__init__方法就不會被呼叫。所以要想改變元組,frozenset和字串的例項化物件的內容,只能用__new__,為什麼說元組,frozenset和字串,你們心裡應該有數了。一個形象的比喻,__new__是按照圖紙把房子蓋出來,__init__是去裝修這個房子。下一個

我們上一講講過del是幹嘛的,它就是刪除標籤或者說指標,只有當沒有指標指向id26921072的時候,__del__就會自動執行,python回收機制把這個記憶體空間釋放掉。



不知道有沒有人還記得我們前面講過int,float,str,list,tuple,dict,set,frozenset都是工廠函式,但是我們沒有說什麼叫做工廠函式對吧,下面我們就來看看這些工廠函式的本質

我們看到int,str,set(float,dict,tuple)一樣的,都是type型別,而len,max,sorted都是內建的函式或方法。而我們下面定義個一個類a,發現a的型別也是type,是不是有點感覺了呢?沒錯,它們這些工廠函式其實都是類的名字。我們再來看看實錘的證據

這裡篇幅原因啊,只給了int的help。其實

都是在生產這些工廠函式類的例項化物件,還有一點就是c=2被python自動識別為c是int的一個例項化物件,同樣d=[1,2,3]自動被識別為是list的一個例項化物件。其實我們的加減乘除,整除,取餘,求冪都是呼叫了int或者float的內建的魔法方法,每一個算術運算子都有這魔法方法和它對應。如下表

當然你help(int)是都可以看到這些魔法方法的

我們來試著改幾個

中間省略很多行

我們看到我們把繼承於int的a類的)__add__方法改為返回值是int.__mul__(self,value),

也就是乘而且我們看到a1=2,a2=3而a1+a2=6,我們還可以把__add__返回a.__sub__(self,value)是不是很騷。但是要注意返回值,return self+value為什麼會導致RuntimeError:達到最大遞迴深度呢?是不是有朋友想起了我在11講https://www.bilibili.com/read/cv273778說過的函式和方法的不同呢?那時候我說的是方法不能遞迴和呼叫,那為什麼還會出現遞迴的問題?其實上面__new__方法的介紹裡也有一個無限遞迴,相信看到這裡的朋友已經明白了,第十一講那裡其實講的是不對的,因為呼叫函式和方法的形式差了很多,呼叫方法必須要有例項化物件或者類物件因為要什麼.什麼。我們這裡來修正一下

方法是可以呼叫方法的,只是呼叫方法必須要正確,也就是說方法和函式的區別也就是呼叫方法的不同。話先回過去,為什麼會出現RuntimeError,因為self+value就相當於呼叫了a.__add__方法,會一直遞迴下去。我們可以這樣改

為什麼可以出最後的結果呢?這是因為我們只修改了int的子類a__add__魔法方法,並沒有修改int的__add__方法,我們可以建立一個與int類重名繼承自int類的int類

注意這種並不是修改了int的魔法方法,除非你去原始碼那裡修改。我們上圖只是建立一個與int重名的類,當a1=int(5)時,a1就被例項化為我們自己定義的int類,所以我們看到a1+a2=2,而我們看到5+3的結果是8沒有被影響,為什麼呢?因為5和3自動地被python識別為int類,而a1,a2是我們自己定義的int類。其它的方法我就不試了,大家可以照葫蘆畫瓢。上面的__divmod__稍微說一下

返回的是一個元組,第一個元素是商,第二個元素是餘數。python還有一種反運算

有什麼算術運算就有什麼反運算,什麼叫左運算元不支援相應操作,我們看個例子

a1+a2和a2+4是正常計算的,為什麼4+a2就進入了a.__radd__方法呢?首先我們要知道做算術運算其實也是有MRO順序的,就拿上圖來說,先從兩邊物件所歸屬的類物件集合最底層的子類開始找+自動呼叫的魔法方法,對於a1+a2來說,也就是a類,而a類並沒有修改__add__方法,所以a1+a2結果是沒有問題的,a2+4類也是沒有問題,為什麼呢?因為方法的引數特性,只需要self是這個類的例項化物件,並不要求value也是例項化物件。只要你這個value是可以和int相加的就行,你加一個string自然是不行的

那麼遇到4+a2呢?前面說過,父類的例項化物件不一定是子類的例項化,所以self不能傳入,這時候就叫做左運算元不支援想應操作,那麼這時候就輪到a類的__radd__方法登場了,並且括號裡的self一定是a2,因為a2是a類的例項化物件,才能作為例項化物件傳給self,於是出現了4+a2=-2,如果兩邊的兩個引數都不是a類呢?

我們就在int裡找__add__方法和__radd__方法唄,而2是int,所以呼叫__add__,當然int裡的它們結果一樣,除非你去修改原始碼。我們再來看

如果是上面這種+兩邊沒有繼承關係,python是優先去搜尋精確度高的,也就是float的方法,其實也很容易理解,因為float的方法一定可以算int而int的方法不一定可以計算float,就比如

而我們第一段程式碼是沒有去修改float的方法的,所以結果都是對的。下面的第二段程式碼1+a1就呼叫了float的__radd__方法而a1+1就呼叫了float的__add__方法,而我們修改了float的__add__方法。再看一個例子

我們看到本質其實左邊運算元的操作優先順序比右邊高,也就是說__add__的優先順序高於__radd__的,a1+b1計算過程,我們看到繼承int的a類裡的__add__是被呼叫了,但是返回失敗了,而是進去b.__radd__,為什麼呢?因為繼承int的a類無法處理b1這個浮點數,所以呼叫了b.__radd__。而b1+a1只返回了badd,因為進到b.__add__方法,float是可以處理int的,就直接返回了。整形加浮點返回的一定是浮點。

下面的增量運算其實就是簡寫的形式,我就不舉例了。

有了上面的講解,一元操作符也很容易理解,它更簡單,不舉例了。


鴨子型別

這些小知識有時候會在每講的最後補充

其實就是一句話,鴨子型別的特點就是不關心物件的型別,而是關心例項化物件有沒有這個屬性和類物件有沒有這個方法。我們來舉個例子

我們就去看看list和str有沒有__add__方法

我們來看鴨子型別報錯的情況

因為int和tuple都沒有reverse方法。

鴨子型別可以參考http://bbs.fishc.com/thread-51471-1-1.html