C++ 之多態總結

前言

最近為了完成數據庫系統的實驗,又複習起了《C++ Primer》,上一次看這本巨著也是大二下的六月份,那時看面向對象程序編程這一章還雲里霧裡的,沒有領會多態的奧妙,學完 Java 之後回頭再看這一章發現對多態有了更好的理解。好記性不如爛筆頭,本篇博客將會對 C++ 的多態機製做一個不太詳細的總結,希望下一次不需要從頭再看一遍《C++ Primer》了 _(:з」∠)_。

多態

多態離不開繼承,首先來定義一個基類 Animal,裏面有一個虛函數 speak()

class Animal {
public:
    Animal() = default;
    Animal(string name) : m_name(name) {}
    virtual ~Animal() = default;

    virtual void speak() const { cout << "Animal speak" << endl; }
    string name() const { return m_name; }

private:
    string m_name;
};

接着定義子類 Dog,並重寫虛函數,由於構造函數無法繼承,所以使用 using 來 「繼承」 父類的構造函數。和父類相比,Dog 還多了一個 bark() 方法。

class Dog : public Animal {
public:
    using Animal::Animal;

    // 可加上 override 聲明要重寫虛函數,函數簽名必須和基類相同(除非返回類自身的指針或引用)
    void speak() const override { cout << "Dog bark" << endl; }
    void bark() const { cout << "lololo" << endl; }
};

向上轉型

我們在堆上創建一個 Dog 對象,並將地址賦給一個 Animal 類型的指針。由於指針指向的是個 Dog 對象,調用 speak() 方法時,實際上調用的是底層狗狗重寫之後的 speak() 方法,而不是基類 Animalspeak()。也就是說編譯時不會直接確定要調用的是哪個 speak() ,要在運行時綁定。

Animal* pa = new Dog("二哈");
pa->speak(); // 調用的是 Dog::speak
pa->Animal::speak(); // 強制調用基類的 speak

利用運行時綁定這一特點,我們將基類的析構函數定義為虛函數,這樣子類對象在析構的時候就能調用自己的虛函數了。

雖然 pa 指向的是一個 Dog 對象,但是不能使用 bark() 方法。因為 pa 是一個 Animal 類型的指針,在編譯時編譯器會跳過 Dog 而直接在 Animal 的作用域中尋找 bark 成員,結果發現並不存在此成員而報錯。

要實現向上轉型不止能用指針,引用同樣可以實現。但是如果寫成以下這種形式,實質上是調用了拷貝構造函數,會用 Dog 的基類部分來初始化 Animal 對象,和向上轉型沒有任何關係,之後調用的就是底層 Animal 對象的 speak() 方法:

Dog dog("二哈");
Animal animal = dog;
animal.speak(); // 調用的是 Animal::speak

向下轉型

要想調用底層 Dog 對象的 bark() 方法,我們需要將 pa 強轉為 Dog 類型的指針。一種方法是使用 static_cast 進行靜態轉換,另一種這是使用 dynamic_cast 進行運行時轉換。相比於前者,dynamic_cast<type *> 轉換失敗的時候會返回空指針,而 dynamic_cast<type &> 則會報 bad_cast 錯誤,因此更加安全。

Dog* pd_ = static_cast<Dog *>(pa);
pd_->bark();

if (Dog* pd = dynamic_cast<Dog*>(pa)) {
    pd->bark();
} else {
    cout << "轉換失敗" << endl;
}

作用域

子類的作用域是嵌套在父類裏面的,在子類的對象上查找一個成員時,會現在子類中查找,如果沒找到才回去父類中尋找。由於作用域的嵌套,會導致子類隱藏掉父類中的同名成員。比如下述代碼:

class Animal {
public:
    virtual void speak() const
    {
        cout << "Animal speak" << endl;
    }
};

class Dog : public Animal {
public:
    // void speak() const override { cout << "Dog speak" << endl; }
    void speak(string word) const
    {
        cout << "Dog bark: " + word << endl;
    }
};

int main(int argc, char const* argv[])
{
    Animal* pa = new Dog();
    Dog* pd = new Dog();
    // pd->speak(); 報錯
    pd->speak("666"); // Dog::speak 隱藏了 Animal::speak
    return 0;
}

我們在父類中定義了一個虛函數 void speak(),子類中沒有重寫它,而是定義了另一個同名但是參數不同的函數 void speak(string word)。這時候子類中的同名函數會隱藏掉父類的虛函數,如果寫成 pd->speak(),編譯器會先在子類作用域中尋找名字為 speak 的成員,由於存在 speak(string word),它就不會接着去父類中尋找了,接着進行類型檢查,發現參數列表對不上,會直接報錯。如果用了 VSCode 的 C/C++ 插件,可以看到參數列表確實只有一個,沒有提示有重載的同名函數。

隱藏父類虛函數

要想通過調用基類的 speak() 方法,有兩種方法:

  • 向上轉型,使用基類的指針 pa 來調用 pa->speak(),由於子類沒有重寫虛函數,所以在動態綁定時會調用父類的虛函數;
  • 使用作用域符強制調用父類的虛函數:pd->Animal::speak()

《C++ Primer》對名字查找做了一個非常好的總結:

名字查找

總結

在 《C++ Primer》中對於上述內容有更加詳細的講解,這裡不過是拾其牙慧罷了。看了這一章之後最大的感受就是:

你看 100 遍她的視頻,她不是你的,你看 100 遍 《C++ Primer》,知識就是你的

以上~~

Tags: