首頁 > 軟體

一篇文章帶你掌握C++虛擬函式的來龍去脈

2022-10-30 14:00:33

一切從繼承講起

我們有一個基礎類別 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

我們在使用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迴圈裡的printAnimal::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指標,根據我們的說法,也是很明顯的,L2L3之間,最靠下的,還是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::printA::print壓根就沒啥聯絡,在具體的搜尋虛擬函式進行呼叫的時候,他們被看做完全不同的兩個函數。

再來看看虛擬函式的返回值型別所帶來的問題:

class A
{
    public:
    virtual void print(){}
}
class B: public A
{
    public:
    virtual int print(){return 0;}
}

問,此時B::print還是A::print嗎?

答案,是的。。。。只不過,這個直接編譯不過。

編譯不過是好的,為什麼,因為在編譯的時候,就告訴你錯在哪了。

上面那個由於疏忽或者別的原因,給原本的虛擬函式多加了一個引數,這種才可怕呢,因為編譯通過了。

那怎麼防範生成了一個新的函數?

override 限定符

如果在子類裡面,我們確定要重新實現一個虛擬函式,那麼我們就在函數簽名的後面加上這個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) 帶了一個引數,編譯器直接報錯。

這就讓錯誤儘早出現在編譯時期,棒!

final 限定符

可能會有這麼一種情況,有一個類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之後,就可以阻止別的類來繼承了。

covariant 返回型別

上面講過,一個虛擬函式,想要在子類裡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,這裡列印什麼?
}
  • 問題1:這個地方不難,就是列印This is B
  • 問題2:我們來慢慢分析,rl1 宣告的型別是 L1& ,但是參照了一個子類物件l2。此時 rl2.get()是遵循虛擬函式的呼叫邏輯,也就是肯定呼叫的是L2::getL2::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

virtual destructor 虛解構函式

在大部分時候,我們都無需為自定的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 函數:

  • func1
  • func2

然後

  • Sub1繼承了L1, 然後override了 func1
  • Sub2繼承了L1, 然後override了 func2

此時,先來考慮一個小問題,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就一目瞭然了,一直跟著指標往下走就明白了!

vtable指標

我們將上面的那個8位元組指標稱做vtable指標,它的作用就是來指向相應的class的虛擬函式表的。

一般而言,這個變數是在基礎類別裡宣告的,子類是繼承了這個變數。

在物件初始化的時候,這個指標會指向真正的本class的虛擬函式表。

比如說

  • Sub1物件裡的vtable就會指向Sub1的虛擬函式表
  • Sub2物件裡的vtable就會指向Sub2的虛擬函式表

虛擬函式的消耗

我們從上面的實現可以看出,在使用虛擬函式的class裡,強行塞入了一個vtable指標,佔了8位元組,這無疑會增加記憶體的消耗。

其次,呼叫虛擬函式的時候,需要三步走。

  • 從vtable找到虛擬函式表
  • 從虛擬函式表找到真正的函數指標
  • 然後由函數指標找到函數,進行呼叫

而一般的函數只有最後一步,這無疑也是增加了一些步驟的,不過這種消耗不怎麼明顯,所以該用虛擬函式,還是儘量用吧,不要有什麼心理負擔,然後搞什麼靜多型。

對了,我們把整個虛擬函式所進行的行為稱之為多型,這是一種動態多型,因為這是執行時的行為。

至於什麼叫靜多型,那就不屬於本文所討論的了。

總結

到此這篇關於C++虛擬函式的文章就介紹到這了,更多相關掌握C++虛擬函式內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com