<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
我們有一個基礎類別 Animal。
有一個 Dog 類繼承了 Animal。
有一個 Fish 類也繼承了 Animal。
一切從上面的小例子開始講起。
假設 Animal 有一個成員函數 print,可以列印自己是什麼物種,在 Animal類中,可以這麼寫:
class Animal { public: void print() { std::cout << "我是 Animal" << std::endl; } }; class Dog: public Animal{}; class Fish: public Animal{};
上面程式碼裡Dog和Fish沒有任何新的改動,僅僅繼承了Animal而已。所以當範例化Dog或者Fish的時候,將生成的物件呼叫print函數,只能顯示出"我是 Animal"。
Dog d; d.print(); // 列印 我是 Animal Fish f; f.print(); // 列印 我是 Animal
這樣不好,我們想要更精確的列印物種資訊,所以我們在子類中重定義print函數:
class Dog: public Animal { public: void print() { std::cout << "我是 Dog" << std::endl; } } class Fish: public Animal { public: void print() { std::cout << "我是 Fish" << std::endl; } }
這樣的話,Dog類和Fish類的變數呼叫print函數的時候,就會列印相應的資訊了:
Dog d; d.print(); // 列印 我是 Dog Fish f; f.print(); // 列印 我是 Fish
到目前為止,一切都是順理成章。
請思考一下這個問題:Dog 和 Animal 之間是什麼關係?
在C++裡,Dog繼承自Animal,我們就說,Dog就是Animal。
就是說,子類就是父類別。
不是誰包含誰的關係。
這很重要,但是還是需要進一步分析,【子類就是父類別】這種關係到底在哪裡能體現出來。
舉一個例子,我們有一個函數,引數是 Animal*
, 如下:
void foo(Animal* a) { }
由於C++是一個強型別系統,大部分語法都是來限制型別的。所以我們經常可以從函數傳參來試圖理解一些比較難理解的概念,比如說【子類就是父類別】這個概念。
Animal a; Dog d; foo(&a); // 這個天經地義,完美匹配型別系統 foo(&d); // ????? 這個行不行呢
上面程式碼最後一句到底行不行?
根據【子類就是父類別】 -> 【Dog 就是 Animal】。答案很明顯,行!
我們再來看一個例子:
Animal a; Animal* pa {&a}; // 依然天經地義 Dog d; Animal* pd {&d}; // 依然?????
上面的程式碼不是函數傳參,卻與函數傳參無二,花括號裡需要填一個東西,來匹配前面的型別宣告。
很明顯,&d
的型別是Dog*
型別,完全可以當做Animal*
來使用。
小總結,【子類就是父類別】這個東西,在實踐裡,就是說,當我們需要一個【父類別指標】的變數的時候,我們完全可以把一個【子類指標變數】丟進去。
上面的總結不僅僅對於指標來說,對於參照也是同樣的。畢竟C++裡,參照本身的概念與指標類似。
這裡給個例子:
Dog d; Animal& r{d}; // 完全可以
這是為什麼呢,為什麼可以這麼做呢?
這是因為,子類物件的記憶體裡,確實包含了完整的基礎類別物件。
注意,物件之間的關係可以說包含與被包含了。
我們在使用std::vector
的時候,只能儲存同種型別的變數,比如說,我們要存的是Animal*
型別的變數,根據上面的說法,我們不僅僅能存Animal物件
的指標,也可以存Dog物件 或者 Fish物件
的指標。
這就給我們的程式碼帶來了便利,一個std::vector
可以來儲存所有Animal
子類的指標了。
否則,我們需要給每一個子類宣告一個std::vector
變數。
接著往下說,我們考慮下面的例子:
std::vector<Animal*> list; Animal a; Dog d; Fish f; list.push_back(&a); list.push_back(&d); list.push_back(&f); for (auto e : list) { e->print(); }
我們知道,這三個類,都有自己定義的print
函數,那麼這個for迴圈執行的時候,到底怎麼列印呢?
我是 Animal
我是 Animal
我是 Animal
這種結果是出乎意料,還是不出所料呢,不同的人有不同的見解。
這裡應該是不出所料的,因為,c++是一個靜態型別的語言,大部分特性都是靜態的,所謂靜態,就是編譯的時候就能確定一些事情,比如說,呼叫哪個函數。
由於e
的型別是Animal*
, 所以在編譯的時候,就已經確定好了,for迴圈
裡的print
是Animal::print
。這就是所謂靜態。
我們發現,這個std::vector
確實能儲存Animal物件指標
、 Dog物件指標
、 Fish物件指標
, 但好像一旦儲存進去了,就無法區分,誰是誰了。
這怎麼行,有一些行為,確實在子類裡覆蓋了,比如說print
的行為。
如何讓靜態的c++編譯器生成一些看起來動態的機器碼呢,比如說,上面的迴圈裡,能夠呼叫各自類裡面重新定義的print函數,而不簡單粗暴的直接使用Animal::print
呢?
虛擬函式是一種特殊的類成員函數, 這種函數在編譯器,無法確定真正的函數地址在哪裡,所以稱之為虛擬函式。
程式執行的時候,根據具體的物件是什麼,就呼叫什麼相應的版本。
用嚴格一點的話來說:呼叫該虛擬函式出現的那個類和當前物件的類,這兩個類之間,最靠下的那個版本的函數。
如何讓一個普通成員函數成為一個虛擬函式呢,在宣告的時候,前面加上virtual
就行了。
話太繞了,我們來看例子:
class L1 { }; class L2: public L1 { public: virtual void print() { std::cout << "L2" << std::endl; } }; class L3: public L2 { } class L4: public L3 { public: virtual void print() { std::cout << "L4" << std::endl; } } /// void test() { L4 l4; L1* pL1 {&l4}; pL1->print(); // 1. 列印什麼 L2* pL2 {&l4}; pL2->print(); // 2. 列印什麼 L3 l3; pL2 = &l3; pL2->print(); // 3. 列印什麼 }
我們來看上面的三個問題.
問題1: pL1->print();
。這句話其實很簡單,壓根就不能編譯,因為pL1的型別是L1*
, 而L1類裡面根本就沒有print
函數。
問題2: pL2->print();
。L2*
的身子裝了L4指標,這就很明顯了,L2和L4之間,最靠下的print,出現在L4
中,所以這裡應該列印L4
。
問題3: pL2->print();
。L2*
的身子裝了L3指標,根據我們的說法,也是很明顯的,L2
和L3
之間,最靠下的,還是L2,所以這裡應該列印L2
。
通過這三個小問題,應該稍微瞭解虛擬函式到底呼叫哪一個的問題了。
如果想要在子類中改變一個虛擬函式的行為,那麼就必須嚴格按照基礎類別中該虛擬函式的函數簽名,重新實現這個虛擬函式:
class A { public: virtual void print(){} } class B: public A { public: virtual void print(int a){} }
來看看上面的子類B中,我們給print加了一個引數,此時B中的print還是A中的那個print嗎?
答案是否定的,
B::print
和A::print
壓根就沒啥聯絡,在具體的搜尋虛擬函式進行呼叫的時候,他們被看做完全不同的兩個函數。再來看看虛擬函式的返回值型別所帶來的問題:
class A { public: virtual void print(){} } class B: public A { public: virtual int print(){return 0;} }
問,此時B::print
還是A::print
嗎?
答案,是的。。。。只不過,這個直接編譯不過。
編譯不過是好的,為什麼,因為在編譯的時候,就告訴你錯在哪了。
上面那個由於疏忽或者別的原因,給原本的虛擬函式多加了一個引數,這種才可怕呢,因為編譯通過了。
那怎麼防範生成了一個新的函數?
如果在子類裡面,我們確定要重新實現一個虛擬函式,那麼我們就在函數簽名的後面加上這個override
限定符。
class A { public: virtual void print(){}; }; class B: public A { public: void print(int a) override {}; }
看上面程式碼,B這個子類中print函數前面前面,我們去掉了virtual
, 而在花括號前面加了override
。
此時,編譯器就報錯了,邏輯是這樣的:
override
,它就認為print是從基礎類別繼承而來的一個虛擬函式,所以它去看看A::print
, 發現這個函數沒有引數。B::print(int)
帶了一個引數,編譯器直接報錯。這就讓錯誤儘早出現在編譯時期,棒!
可能會有這麼一種情況,有一個類A
,裡面有一個虛擬函式print
,你寫了一個類B
,繼承了類A
,然後override
了這個print函數
。然後別人寫了一個類C
繼承了類B
,你不想類C
擁有override
這個print函數
的許可權。
此時,在類B
中,override print 函數的地方,可以加一個final
:
class A { public: virtual void print(){}; } class B: public A { public: void print() override final {}; // 注意看,加了final } class C: public B { public: void print() override {}; // 編譯報錯 }
上面的程式碼演示了,class C
中無法繼續override print
的寫法。
還有一種極端的情況,你寫了一個類A
,你壓根就不想別人去繼承這個類A
:
class A final { }; class B: public A // oh, 直接報錯 { };
加了final之後,就可以阻止別的類來繼承了。
上面講過,一個虛擬函式,想要在子類裡override,那麼函數簽名必須一模一樣,包括返回值型別。但是有一種特殊的情況,需要考慮。看下面的例子
class A { public: void print() { std::cout << "This is A" << std::endl; } }; class B: public A { public: void print() { std::cout << "This is B" << std::endl; } }; class L1 { public: virtual A* get() { return new A{}; } }; class L2: public L1 { public: B* get() override { return new B{}; } };
我們先注意到,B和A就是兩個普通的有繼承關係的類,裡面並沒有出現virtual函數。
真正要研究的是L2和L1,get 函數
是一個virtual函數,但是L2裡get
返回值型別是B*
。
這似乎違反了virtual函數的規定,那就是函數簽名必須一致。
但是又能說的通:【子類就是父類別】。
所以上面的程式碼能編譯過嗎?
答案是能。這種特殊的情況被稱之為covariant 返回型別
,有的地方翻譯成協變返回型別。
接著看如下的程式碼:
void test() { L2 l2; l2.get()->print(); // 問題1,這裡列印什麼? L1& rl1{l2}; rl1.get()->print(); // 問題2,這裡列印什麼? }
This is B
。rl2.get()
是遵循虛擬函式的呼叫邏輯,也就是肯定呼叫的是L2::get
。L2::get
的返回型別是什麼,是B*
,所以直接得出結果應該是 B::print
, 列印This is B
。不好意思,問題2的結論是錯的。
虛擬函式不會改變原本的函數返回型別,在L1這個基礎類別中,返回型別就是A*
,即使呼叫了L2::get
,仍然返回了A*
這個型別,如果你有IDE,你可以將滑鼠懸停在
rl1.get()
->print();
get
這個地方,會顯示出,返回型別是A*
, 於是乎,最後的print其實是A::print
, 所以列印了
This is A
。
在大部分時候,我們都無需為自定的class
提供一個解構函式, 因為大部分時候自定義的class
裡面不包含需要釋放的資源,比如說記憶體,檔案等等。此時c++會提供一個預設的解構函式。
但是,如果我們的class
裡有這種動態的資源,那麼就不得不提供一個自定義的解構函式,來針對這些動態資源進行釋放。
更進一步的是,如果一個擁有動態資源的class
同時繼承了別的class
,此時最好小心一點:
這是啥意思, 來看例子:
class L1 { public: ~L1() { std::cout << "L1 正在解構" << std::endl; } }; class L2: public L1 { int* resource; public: L2():resource{new int} { } ~L2() { delete resource; } }; void test() { L2* l2{new L2}; L1* pl1{l2}; delete pl1; }
分析以上程式碼,pl1 指向了一個子類L2的物件,在delete pl1的時候,編譯器發現,L1 的解構函式是正常函數,所以編譯器在這裡指定決定呼叫L1::~L1
這個函數,然後就結束了。
我們會發現,L2 的解構函式並沒有被呼叫到,也就是說, resource 所指向的資源沒有被回收!!!
怎麼辦呢,將 L1 中的解構函式標記成virtual
:
class L1 { public: virtual ~L1(){}; }
這樣才能保證,任何繼承自L1的類中的動態資源被回收。
結論:如果寫了一個類,這個類有可能被別的類繼承的話,那麼最好將這個類的解構函式標記成virtual
的:
class A { public: virtual ~A() = default; }
關於這一點,有很多大師級人物都討論過,不同的人有不同的看法,不過,上面的結論還是穩妥的,雖然有一點效能消耗。
為什麼要有這個疑問,難道這種實現不正常嗎?
不正常,非常不正常,C++是一個靜態語言,必須先編譯再執行,執行什麼函數,一定是編譯時就決定好的。
而虛擬函式打破了這種既有的規則,而這種規則的打破依賴於函數指標。
下面來講講虛擬函式這一套邏輯到底是怎麼跑起來的。
這是一種指標,這個指標指向的是一塊程式碼,用這個指標可以進行函數呼叫:
void print_v1() { std::cout << "print_v1" <<std::endl; } void print_v2() { std::cout << "print_v2" <<std::endl; } void test() { auto f {print_v1}; f(); // 列印 print_v1 f = print_v2; f(); // 列印 print_v2 }
觀察上面的程式碼,發現,兩個f()呼叫了不同的函數,這是一種動態行為。也就是說,程式執行的時候,根據f本身的指向,才能決定真正呼叫哪一塊程式碼。
有了函數指標,使得動態行為有了可能,剩下的就是奇思妙想,讓虛擬函式邏輯跑起來。
大部分編譯器採用了所謂虛擬函式表的東西來實現虛擬函式邏輯。
這種東西文字描述不清,直接看例子:
class L1 { public: virtual void func1() { } virtual void func2() { } }; class Sub1: public L1 { public: void func1() override { } }; class Sub2: public L1 { public: void func2() override {} };
先描述一下,上面有三個class,L1是一個基礎類別,裡面有兩個virtual 函數:
然後
此時,先來考慮一個小問題,sizeof 三個 class,應該是多大呢,假如是64bit
機器。
答案是都是佔8位元組,也就是64bit。
那麼這8位元組存了啥東西?
答案就是,這8位元組其實是一個指標,指向哪,先不說,一會再來說明。
對於上面的例子來說,編譯器生成了三個虛擬函式表,也就是L1、Sub1、Sub2每個class,各一個。
注意這個虛擬函式表是每個class一個,而不是每個物件一個,一定要搞明白。
這很類似於 class 裡的靜態成員,這麼說就好理解了。
那虛擬函式表長啥樣?
其實虛擬函式表就是一個陣列,陣列裡的每一項就是一個簡單的函數指標。
我們來畫一畫上面例子的虛擬函式表:
在右邊的程式碼段裡,我們可以看見,一共有四個不同的函數,這與我們的程式碼是一致的。
再來看左邊的虛擬函式表,可以清晰的看出來,每個類裡的兩個虛擬函式都真實地指向了正確的版本。
光有這個虛擬函式表,是沒用的,在呼叫虛擬函式的地方,必須與這個虛擬函式表聯絡起來。
還記得剛才說的那個8位元組的指標嗎。
那個指標就是起到這種關聯的。
我們看下面的例子:
void test() { L2 l2; L1* p{&l2}; p->func1(); }
我們畫出上面的整個關係圖:
此時用 p->func1() 的時候,為什麼會呼叫到Sub1::func1
就一目瞭然了,一直跟著指標往下走就明白了!
我們將上面的那個8位元組指標稱做vtable指標,它的作用就是來指向相應的class的虛擬函式表的。
一般而言,這個變數是在基礎類別裡宣告的,子類是繼承了這個變數。
在物件初始化的時候,這個指標會指向真正的本class
的虛擬函式表。
比如說
我們從上面的實現可以看出,在使用虛擬函式的class裡,強行塞入了一個vtable指標,佔了8位元組,這無疑會增加記憶體的消耗。
其次,呼叫虛擬函式的時候,需要三步走。
而一般的函數只有最後一步,這無疑也是增加了一些步驟的,不過這種消耗不怎麼明顯,所以該用虛擬函式,還是儘量用吧,不要有什麼心理負擔,然後搞什麼靜多型。
對了,我們把整個虛擬函式所進行的行為稱之為多型,這是一種動態多型,因為這是執行時的行為。
至於什麼叫靜多型,那就不屬於本文所討論的了。
到此這篇關於C++虛擬函式的文章就介紹到這了,更多相關掌握C++虛擬函式內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45