c++ 虛函數(shù)工作原理
1.為什么調(diào)用普通函數(shù)比調(diào)用虛函數(shù)的效率高?
因?yàn)槠胀ê瘮?shù)是靜態(tài)聯(lián)編的,而調(diào)用虛函數(shù)是動(dòng)態(tài)聯(lián)編的。
聯(lián)編的作用:程序調(diào)用函數(shù),編譯器決定使用哪個(gè)可執(zhí)行代碼塊。(所謂聯(lián)編就是將函數(shù)名和函數(shù)體的程序連接到一起的過(guò)程)
靜態(tài)聯(lián)編 :在編譯的時(shí)候就確定了函數(shù)的地址,然后call就調(diào)用了。
(靜態(tài)聯(lián)編本質(zhì)是系統(tǒng)用實(shí)參與形參進(jìn)行匹配,對(duì)于重名的重載函數(shù)根據(jù)參數(shù)上的差異進(jìn)行區(qū)分,然后進(jìn)行聯(lián)編,從而實(shí)現(xiàn)編譯時(shí)的多態(tài)。函數(shù)的選擇基于指向?qū)ο蟮闹羔橆愋突蛘咭妙愋?。?/p>
動(dòng)態(tài)聯(lián)編 : 首先需要取到對(duì)象的首地址,然后再解引用取到虛函數(shù)表的首地址后,再加上偏移量才能找到要調(diào)的虛函數(shù),然后call調(diào)用。
(動(dòng)態(tài)聯(lián)編本質(zhì)上是運(yùn)行階段執(zhí)行的聯(lián)編,當(dāng)程序調(diào)用某一個(gè)函數(shù)時(shí),系統(tǒng)會(huì)根據(jù)當(dāng)前的對(duì)象類型去尋找和連接其程序的代碼。函數(shù)的選擇基于對(duì)象的類型。)
2.為什么要用虛函數(shù)表(存函數(shù)指針的數(shù)組)?
同一個(gè)類的多個(gè)對(duì)象的虛函數(shù)表是同一個(gè),所以這樣就可以節(jié)省空間,一個(gè)類自己的虛函數(shù)和繼承的虛函數(shù)還有重寫(xiě)父類的虛函數(shù)都會(huì)存在自己的虛函數(shù)表。同時(shí),虛函數(shù)表本質(zhì)是一個(gè)地圖導(dǎo)航,可以清楚告訴一個(gè)想要操作子類的父類指針到底該使用哪個(gè)函數(shù)。
3.為什么要把基類的析構(gòu)函數(shù)定義為虛函數(shù)?
在用基類操作派生類時(shí),為了防止執(zhí)行基類的析構(gòu)函數(shù),不執(zhí)行派生類的析構(gòu)函數(shù)。因?yàn)檫@樣的刪除只能夠刪除基類對(duì)象, 而不能刪除子類對(duì)象, 形成了刪除一半形象, 會(huì)造成內(nèi)存泄漏.
4.虛函數(shù)可以是內(nèi)聯(lián)的嗎?
要多態(tài)的時(shí)候不內(nèi)聯(lián),不多態(tài)的時(shí)候(也就是非指針、引用,也就是傳值)可以內(nèi)聯(lián)。
函數(shù)調(diào)用捆綁
要想深刻理解虛函數(shù)機(jī)理,首先要了解函數(shù)調(diào)用捆綁機(jī)制。捆綁指的是將標(biāo)識(shí)符(如變量名與函數(shù)名)轉(zhuǎn)化為地址。這里我們僅僅關(guān)注有關(guān)函數(shù)調(diào)用的捆綁。我們知道每個(gè)函數(shù)在編譯的過(guò)程中是存在一個(gè)唯一的地址的。如果我們?cè)诔绦蚨卫锩嬷苯诱{(diào)用某個(gè)函數(shù),那么編譯器或者鏈接器會(huì)直接將函數(shù)標(biāo)識(shí)符替換為一個(gè)機(jī)器地址。這種方式是早捆綁,或者說(shuō)是靜態(tài)捆綁。因?yàn)槔壥窃诔绦蜻\(yùn)行之前完成的。
調(diào)用都是直接使用函數(shù)名,采用早捆綁的方式。編譯器會(huì)將每個(gè)函數(shù)調(diào)用替換為一個(gè)跳轉(zhuǎn)指令,這個(gè)指令告訴CPU跳轉(zhuǎn)到函數(shù)的地址來(lái)執(zhí)行。
但是有時(shí)候,我們?cè)诔绦蜻\(yùn)行前并不知道調(diào)用哪個(gè)函數(shù),此時(shí)必須使用晚捆綁或者動(dòng)態(tài)捆綁。晚綁定的一個(gè)例子就是使用函數(shù)指針。
使用函數(shù)指針來(lái)間接調(diào)用函數(shù),編譯器在編譯階段并不知道函數(shù)指針到底指向哪個(gè)函數(shù),所以必須使用動(dòng)態(tài)捆綁的方式。
動(dòng)態(tài)綁定看起來(lái)更靈活,但是其是有代價(jià)的。靜態(tài)捆綁時(shí),CUP可以直接跳轉(zhuǎn)到函數(shù)地址。但是動(dòng)態(tài)捆綁,CPU必須先提取指針的地址,然后再跳轉(zhuǎn)到指向的函數(shù)地址。這多了一個(gè)步驟!
虛函數(shù)表(Vtable)
C++使用了一種稱為“虛表”的晚捆綁技術(shù)來(lái)實(shí)現(xiàn)虛函數(shù)。虛表是一個(gè)函數(shù)查詢表,以動(dòng)態(tài)捆綁的方式解析函數(shù)調(diào)用。每個(gè)具有一個(gè)或者多個(gè)虛函數(shù)的類都有一張?zhí)摫恚@個(gè)表是在編譯階段建立的靜態(tài)數(shù)組,其中包含了每個(gè)虛方法的函數(shù)指針,這些指針指向的是該類可見(jiàn)的派生最遠(yuǎn)的函數(shù)實(shí)現(xiàn)。其次,編譯器會(huì)在基類對(duì)象都會(huì)添加一個(gè)隱含指針,這里我們稱為*__vptr
。這個(gè)指針當(dāng)然能夠被派生類所繼承,這相當(dāng)重要。當(dāng)類的實(shí)例被創(chuàng)建時(shí),這個(gè)指針指向該類所對(duì)應(yīng)的虛表。這樣,當(dāng)使用某個(gè)對(duì)象調(diào)用虛方法時(shí),通過(guò)該指針查找虛表,然后根據(jù)實(shí)際的對(duì)象類型執(zhí)行正確版本的方法調(diào)用。
純虛函數(shù)與抽象基類
有時(shí)候,基類的某個(gè)虛方法并不需要實(shí)現(xiàn),但是希望派生類能夠提供重寫(xiě)的版本。這個(gè)時(shí)候,你需要定義純虛函數(shù)。純虛函數(shù)在類的定義中顯示說(shuō)明該方法不需要實(shí)現(xiàn),其作用在于指明派生類必須要重寫(xiě)它。純虛函數(shù)的定義很簡(jiǎn)單:方法聲明后緊跟著=0。如果一個(gè)類中至少含有一個(gè)純虛函數(shù),那么這個(gè)類是抽象基類,因?yàn)檫@個(gè)類無(wú)法實(shí)例化。當(dāng)繼承一個(gè)抽象類時(shí),必須重寫(xiě)所有純虛函數(shù),否則繼承出來(lái)的類也是一個(gè)抽象類。
抽象類至少包含一個(gè)純虛方法,抽象類提供了一種禁止其他代碼直接實(shí)例化對(duì)象的方法,但是重寫(xiě)純虛方法的派生類可以實(shí)例化。
接口類
接口是一個(gè)抽象的概念,使用者只關(guān)注功能而不要求了解實(shí)現(xiàn)。一個(gè)接口類可以看成一些純虛方法的集合,這意味著接口類僅有定義功能,而沒(méi)有具體的實(shí)現(xiàn)。C++ 其實(shí)沒(méi)有單獨(dú)的接口概念,而在Java和C#等語(yǔ)言中接口是與類相區(qū)別的。但是 C++ 仍然可以使用接口類實(shí)現(xiàn)類似的效果。有時(shí)候,我們也稱接口類為純抽象類,因?yàn)檫@個(gè)類中全是虛方法。
可以看到Instrument是一個(gè)純抽象類,其只提供方法的聲明,具體卻沒(méi)有實(shí)現(xiàn)。但是它的兩個(gè)派生類分別重寫(xiě)了這些純虛方法,因此可以實(shí)例化。并且兩個(gè)函數(shù)可以接收任意繼承了Instrument的類實(shí)例對(duì)象。進(jìn)一步說(shuō),這兩個(gè)函數(shù)僅關(guān)注接收的對(duì)象是否提供了Instrument所要求的接口,但是不關(guān)注具體是怎么實(shí)現(xiàn)的。純抽象類提供了更高級(jí)的抽象!這符合OOP的思想。
虛基類
虛基類主要是用來(lái)解決菱形層次結(jié)構(gòu)中的歧義基類問(wèn)題。菱形層次結(jié)構(gòu)是多重繼承中的一個(gè)典例。
利用虛基類,可以解決上面多重繼承中歧義基類問(wèn)題,基類僅被繼承一次。但是要注意的是此時(shí)的虛基類由派生最遠(yuǎn)的類負(fù)責(zé)創(chuàng)建(可以看成該類的直接基類),因?yàn)镻oweredDevice并沒(méi)有無(wú)參構(gòu)造函數(shù),所以在Copier構(gòu)造函數(shù)初始化列表中必須加上PoweredDevice的有參構(gòu)造函數(shù)調(diào)用!
說(shuō)點(diǎn)題外話,盡管虛基類可以解決多重繼承中的菱形層次結(jié)構(gòu),但是看起來(lái)還是很抽象與復(fù)雜。實(shí)際上,多重繼承本來(lái)就是一個(gè)很有爭(zhēng)議的話題,因?yàn)槭褂枚嘀乩^承會(huì)使得繼承體系變得復(fù)雜,而且產(chǎn)生一系列問(wèn)題,像Java和C#這類語(yǔ)言,是不允許多重繼承的,但是其單獨(dú)提供了接口,類可以繼承多個(gè)接口,這也相當(dāng)于多重繼承了。而且好處是接口的繼承相當(dāng)于組合,這也是比較推崇的!
對(duì)象切片
前面講過(guò),實(shí)現(xiàn)虛函數(shù)及多態(tài)性必須要用傳地址的方式(引用或者指針)。一般,地址具有相同的長(zhǎng)度,這意味著派生類對(duì)象的地址與基類對(duì)象的地址也是相同,盡管派生類對(duì)象所占的內(nèi)存一般要高過(guò)基類對(duì)象。所以,傳地址的方式不會(huì)導(dǎo)致類型信息損失,進(jìn)而可以實(shí)現(xiàn)多態(tài)性。
可以看到使用引用或者指針的方式,多態(tài)性都能夠?qū)崿F(xiàn),但是傳值的方式就存在問(wèn)題。當(dāng)我們將一個(gè)派生類對(duì)象直接賦值給基類對(duì)象時(shí),僅僅基類的部分被復(fù)制,派生類的那部分信息將丟失。我們稱這種現(xiàn)象為“對(duì)象切片”:對(duì)象丟失了自己原有的部分信息。使用對(duì)象本身并沒(méi)有問(wèn)題,但是處理不當(dāng),會(huì)造成很多問(wèn)題。
動(dòng)態(tài)轉(zhuǎn)型
前面的例子,我們都是將派生類對(duì)象復(fù)制給基類對(duì)象,不管是通過(guò)傳地址的方式還是對(duì)象切片方式。這些都是向上轉(zhuǎn)型——在類層次中向上移動(dòng)。我們不禁會(huì)想,肯定會(huì)存在可以向下移動(dòng)的向下轉(zhuǎn)型。一般來(lái)說(shuō),派生類包含基類信息,所以向上轉(zhuǎn)型是容易的。但是,反過(guò)來(lái)可能會(huì)失??!因?yàn)闊o(wú)法保證基類對(duì)象實(shí)際上存儲(chǔ)的是派生類對(duì)象。
process函數(shù)接收一個(gè)基類指針,但是在內(nèi)部使用static_cast
向下轉(zhuǎn)型為派生類指針,然后進(jìn)行后序處理。如果送入process函數(shù)的指針實(shí)際上就是指向派生類對(duì)象,那么上面的代碼是沒(méi)有問(wèn)題的。但是,如果僅僅傳入就是指向基類對(duì)象的指針,或者指向其他派生類的指針,那么函數(shù)內(nèi)部的轉(zhuǎn)型將存在問(wèn)題:由于static_cast
在運(yùn)行時(shí)是不檢查對(duì)象實(shí)際類型的,這將導(dǎo)致不可控行為!
為了解決這樣的隱患,C++引入了運(yùn)行時(shí)的動(dòng)態(tài)類型轉(zhuǎn)化操作符dynamic_cast
。dynamic_cast
在運(yùn)行時(shí)檢測(cè)底層對(duì)象的類型信息。如果類型轉(zhuǎn)換沒(méi)有意義,那么它將返回一個(gè)空指針(對(duì)于指針類型)或者拋出一個(gè)std::bad_cast
異常(對(duì)于引用類型)。