1.多態的概念
多態(polymorphism)的概念:通俗來說,就是多種形態。多態分為編譯時多態(靜態多態)和運行時多態(動態多態),這里我們重點講運行時多態,編譯時多態(靜態多態)和運行時多態(動態多態)。編譯時多態(靜態多態)主要就是我們前面講的函數重載和函數模板,他們傳不同類型的參數就可以調用不同的函數,通過參數不同達到多種形態,之所以叫編譯時多態,是因為他們實參傳給形參的參數匹配是在編譯時完成的,我們把編譯時一般歸為靜態,運行時歸為動態。
運行時多態,具體點就是去完成某個行為(函數),可以傳不同的對象就會完成不同的行為,就達到多種形態。比如買票這個行為,當普通人買票時,是全價買票;學生買票時,是優惠買票(5折或75折);軍人買票時是優先買票。再比如,同樣是動物叫的一個行為(函數),傳貓對象過去,就是”(>^w^<)喵“,傳狗對象過去,就是"汪汪"。
2.多態的定義及實現
2.1多態的構成條件
多態是一個繼承關系下的類對象,去調用同一函數,產生了不同的行為。比如Student繼承了Person。Person對象買票全價,Student對象優惠買票。
2.1.1實現多態還有兩個必須重要條件
1.必須是基類的指針或者引用調用虛函數
2.被調用的函數必須是虛函數,并且完成了虛函數重寫
說明:要實現多態效果,第一必須是基類的指針或引用,因為只有基類的指針或引用才能既指向基類對象又指向派生類對象;第二派生類必須對基類的虛函數完成重寫/覆蓋,重寫或者覆蓋了,基類和派生類之間才能有不同的函數,多態的不同形態效果才能達到。
這里提到了新名詞:虛函數,我先演示一下多態的基本使用,下面再詳細講虛函數。
這里就是實現了多態,我們可以看到雖然func的參數是Person類型的引用,但是結果卻調用了子類中的虛函數。
里面的原因就如func中所寫的那樣,跟ptr沒關系,和ptr所指向的對象有關。
指針和引用差不多,這里我就不演示了。
注意這兩個條件缺一不可:
這里就是不符合第一個條件,就沒有構成多態,此時就和ptr有關了,調用BuyTicket函數就看的是調用的類型,而ptr類型是Person,故只會調用Person中的函數。
2.1.2虛函數
類成員函數前面加virtual修飾,那么這個成員函數就稱為虛函數。注意非成員函數不能加virtual修飾。
上面例子中的BuyTicket函數就是虛函數。
2.1.3虛函數的重寫/覆蓋
虛函數的重寫/覆蓋:派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名字、參數類型完全相同),稱派生類的虛函數重寫了基類的虛函數。
注意:在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用。
上面子類中的BuyTicket就是對父類的重寫,這里注意:重寫/覆蓋的是函數的實現部分,就是括號里面的內容。
接著上面不符合第二個條件:
這里就不符合第二個條件了,此時的BuyTicket函數就不是虛函數,構不成多態,故還是調用父類的函數。
這種也是不構成多態的,virtual只能子類隱藏,父類是不能隱藏的。
講到這我們來看一道題:
問:以下程序輸出結果是什么?
A.A->0? ?B.B->1? ?C.A->1? ?D.B->0? ?E.編譯出錯? ?F.以上都不正確
大家可以思考一下這個問題的答案。
答案選擇B,這里可能很多人都不理解,這里面有倆個坑。
第一個就判斷這里到底是不是多態:我們可以看到,此時創建了一個子類對象,通過子類對象去調用test函數。這里要注意繼承,并不是把父類的函數拷貝到子類,在調用時,先在子類查找,找不到才會去父類去查找。
而這個坑的難點就是test函數中的this指針到底是A*呢,還是B*呢?
遵循上面的原則,我們在子類沒有找到test函數,接著去父類找,找到了,既然要調用父類的test函數,那this指針自然而然就是A*,那既然是基類的指針來調用虛函數,那么就構成多態。
來到第二個坑:這也是為什么這道題選B的原因。
既然上面構成多態了,那么指針指向的對象是子類對象,就該調用子類里面的func函數,正常來說應該是B->0,但是我們上面寫了,虛函數的重載/覆蓋只是針對函數實現部分,所以只是把實現部分的func給重寫了,那么既然只針對實現部分,那么參數部分的val就不會發生變化,就還是默認的缺省值1.
這里很多人出錯就是被這個缺省值給誤導了,所以我們要牢記虛函數重載/覆蓋只針對函數實現部分。
有人會有疑惑:那缺省值不是不一樣嗎,怎么會構成虛函數重寫呢?
這個問題我們要看上面虛函數重寫的概念,是函數名,返回值類型和參數類型皆相同,里面是不包含缺省值的,缺省值不同不影響。
2.1.4虛函數重寫的一些其他問題
1.協變
派生類重寫基類虛函數是,與積累虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。
以上面為例,將返回類型改成對應的指針或者引用即為協變,當然斜邊也不只一種方式:
也可以是其他類的指針或者引用做返回值,但要求是父類和子類的指針或引用。
協變的實際意義不大,這里了解一下即可。
2.析構函數的重寫
基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加virtual關鍵字,都與基類的析構函數構成重寫,雖然基類與派生類析構函數名字不同看起來不符合重寫的規則,實際上編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成destructor,所以基類的析構函數加了 vialtual修飾,派生類的析構函數就構成重寫。
由上面的代碼我們可以看到,如果~A(),不加virtual,那么delete p2時只調用的A的析構函數,沒有調用 B的析構函數,就會導致內存泄漏問題,因為~B()中在釋放資源。
原因就如上面所言,在繼承關系中析構函數的名稱會被統一處理,不加virtual就構不成多態,就只能根據類型去調用析構函數,所以盡量在析構函數前面加上virtual構成多態,避免內存泄漏。
2.1.5override和final關鍵字
從上面可以看出,C++對虛函數重寫的要求比較嚴格,但是有些情況下由于疏忽,比如上面由于函數名寫錯導致無法構成重寫,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有得到預期結果才來debug會得不償失,因此C++11提供了override,可以幫助用戶檢測是否重寫。如果我們不想讓派生類重寫這個虛函數,那么可以用final去修飾。
2.1.6重載/重寫/隱藏的對比
我們學到這里這三個概念會有人搞混了,重載和其他兩個可以很好區分開來,重載是在同一作用域下,而重寫和隱藏都是在不同作用域下。
而隱藏和重寫,這兩個而言,隱藏范圍會更大一些,畢竟同名成員變量也會構成隱藏,重寫只針對成員函數,并且要求三同(函數名,返回值類型和參數類型),隱藏只要函數名相同即可。
3.純虛函數和抽象類
在虛函數的后面寫上=0,則這個函數為純虛函數,純虛函數不需要定義實現(實現沒啥意義因為要被派生類重寫,但是語法上可以實現),只要聲明即可。包含純虛函數的類叫做抽象類,抽象類不能實例化出對象,如果派生類繼承后不重寫純虛函數,那么派生類也是抽象類。純虛函數某種程度上強制了派生類重寫虛函數,因為不重寫實例化不出對象。
以上面為例,這就是虛函數的基本使用。可以看出,此時Car中的Drive函數就是純虛函數,而含有純虛函數的類無法實例化出對象。
而如果子類沒有重寫純虛函數,也會變成抽象類:
同樣無法實例化出對象。
無法實例化處對象就意味著很多功能就無法實現,所以如果父類中有純虛函數,子類就要重寫純虛函數。
4.多態的原理
4.1虛函數表指針
大家可以思考一下在32位下b是多大。
可能有人覺得是8,因為根據對其原則先是int,接著是char的話確實是8,但其實答案是12。
為什么是12呢?
這就與虛函數表指針有關:
通過調試可以發現,再b中還含有一個叫_vfptr的變量,里面存儲的是一個地址,這個地址指向虛函數表。
而指針我們都知道,再32位下是4個字節,64位下是8個字節,我在測試的時候是在32位環境下,所以_vfptr,int和char三個加起來,根據對其原則,最后得出是12。
用圖來表示就如上圖所示,虛函數表這里先簡單提一下,下面會詳細講。
虛函數表又叫虛函數指針數組或者虛表,里面存的就是虛函數的指針。
4.2.1多態是如何實現的
依舊以上面的例子來說明,我們上面講了虛函數表指針,現在就可以來探究多態到底是如何實現的。
通過重載可以發現,三個變量中的_vfptr所包涵的地址都不一樣,這是因為重寫導致的,重寫過后,不同類型的變量中的_vfptr就指向不同的虛函數表,不同的虛函數表中指向的也是不同的虛函數。
而多態的原理就是如此,上面的例子中通過ptr來調用相應對象中_vfptr存的虛函數表的地址,再通過虛函數表中找到相應的虛函數,調用相應的虛函數,完成多態的操作。
注意,這個_vfptr是不能直接訪問的:
會直接顯示沒有這個成員。
并且虛函數表存的是當前類中的所有虛函數,不只有一個:
可以看出里面不僅存了BuyTicket函數,還存了func1函數。
4.2.2動態綁定與靜態綁定
對不滿足多態條件(指針或者引用+調用虛函數)的函數調用是在編譯時綁定,也就是編譯時確定調用函數的地址,叫做靜態綁定。
滿足多態條件的函數調用是在運行時綁定,也就是在運行時到指向對象的虛函數表中找到調用函數的地址,也就做動態綁定。
動態綁定就如上圖所示,滿足多態條件,運行時到虛函數表中找到對應虛函數進行調用。
靜態綁定就如上圖所示,不滿足多態條件,編譯時通過調用者的類型,確定函數地址進行調用。
4.2.3虛函數表
1.基類對象的虛函數表中存放基類所有虛函數的地址。同類型的對象共用同一張虛表,不同類型的對象各自有獨立的虛表,所以基類和派生類有各自獨立的虛表。
2.派生類由兩部分構成,繼承下來的基類和自己的成員,一般情況下,繼承下來的基類中有虛函數表指針,自己就不會再生成虛函數表指針。但是要注意的這里繼承下來的基類部分虛函數表指針和基類對象的虛函數表指針不是同一個,就像基類對象的成員和派生類對象中的基類對象成員也獨立的。
3.派生類中重寫的基類的虛函數,派生類的虛函數表中對應的虛函數就會被覆蓋成派生類重寫的虛函數地址。
4.派生類的虛函數表中包含,(1)基類的虛函數地址,(2)派生類重寫的虛函數地址完成覆蓋,派生類自己的虛函數地址三個部分。
5.虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個0x00000000標記。(這個C++并沒有進行規定,各個編譯器自行定義的,vs系列編譯器會再后面放個0x00000000標記,g++系列編譯不會放)
6.虛函數存在哪的?虛函數和普通函數一樣的,編譯好后是一段指令,都是存在代碼段的,只是虛函數的地址又存到了虛表中。
7.虛函數表在哪兒呢?這個問題并沒有標準答案,c++并沒有規定。
有的上面已經涉及到,這里就不過多贅述。
第二條我們上面展示的調試中就演示了,子類本身是沒有_vfptr的,只是繼承了父類的。
這里主要就是講一下如何找到虛函數表在哪兒:
再找之前呢我們先得到幾個常見的區域的地址,好拿來比較。
而找虛函數表的難點就在于_vfptr我們拿不出來,就無法拿到里面所保存的虛函數表的地址。
但是我們可以利用其他的方法,比如:再32位下,指針是四個字節,那我們只要拿到相應對象的前四個字節,在解引用,就可以拿到虛函數表的地址。
而我們如何拿到前四個字節呢?
這里可以用強轉來實現,把自定義類型的指針強轉成int*指針,在解引用即可。
因為int取4個字節,我們對int*解引用就可以拿到前四個字節。
這里就拿到了虛函數表的地址,我么通過觀察可以看出和常量區的地址最為接近,所以在vs下,虛函數表就存在常量區。
注意:這里不能直接強轉成int類型,因為強轉只能是相近類型才可以,比如int和double,int*和double*以及上面的Student*和int*,這種情況下才可以強轉。
以上就是多態的內容。