虛擬函式


在〈遮敝父類方法〉中看到,在繼承關係下,基於 is-a,子類實例可以指定給父類型態,如果你這麼做,多數情況下想要的效果是,想以一般化的方式來操作實例,無論該實例是父類或子類實例。

例如,RoleSwordsMan 都具有 to_string 方法,執行時期透過 Role 來操作 SwordsMan,是因為 SwordsMan 是一種 Role,你想要的就是操作角色的 to_string,而且如果 SwordsMan 定義了 to_string,多數情況下,希望執行的是實例重新定義後的版本。

對於父類的方法,你預期它的執行時期行為會被重新定義,也就是希望在執行時期,依照實例的型態綁定對應的方法版本,可以在父類定義方法時加上 virtual,例如:

#include <iostream>
#include <string> 
using namespace std;

class Role {
    ...略

    virtual string to_string() {
        return "(" + 
            this->name + ", " + 
            std::to_string(this->level) + ", " + 
            std::to_string(this->blood) + 
        ")";
    };

    virtual ~Role() = default;
};

class SwordsMan : public Role {
    using super = Role;

    ...略

    string to_string() override {
        return "SwordsMan" + super::to_string();
    };
};

class Magician : public Role {
    using super = Role;

public:

    ...略

    string to_string() override {
        return "Magician" + super::to_string();
    };
};

void printInfo(Role &role) {
    cout << role.to_string() << endl;
}

int main() { 
    SwordsMan swordsMan("Justin", 1, 1000);
    Magician magician("Magician", 1, 800);

    printInfo(swordsMan);
    printInfo(magician);

    return 0;
}

被定義為 virtual 的函式,若程式碼中透過父類型態參考或指標操作,會在執行時期才綁定要執行的版本,因此 printInfo 會依指定的實例,呼叫各自重新定義後的 to_string 方法,執行結果如下:

SwordsMan(Justin, 1, 1000)
Magician(Magician, 1, 800)

如果定義類別時,預期會在執行時期,以父類型態操作子類實例重新定義的方法,那麼該方法要設定為 virtual,在試圖想重新定義父類的 virtual 方法時,很容易因為不符合方法簽署,造成實際上定義了新方法而不是重新定義方法,若想避免這種情況,C++ 11 以後可以標註 override,編譯器就會檢查,目前定義的方法是否真的是重新定義了父類別的 virtual 方法。

父類中的方法若被標示 virtual,子類重新定義方法時自然就會是 virtual,因此重新定義時可以基於閱讀上的方便性,自行選擇是否標註 virtual

若類別中有方法被標示為 virtual,編譯器會隱含地在類別中加入虛擬方法表(virtual method table),表中的指標用來指向被標示為 virtual 的方法,如果子類重新定義了 virtual 方法,子類的虛擬方法表中該方法的指標,會指向重新定義的方法,繼承下來而沒有被重新定義的 virtual 方法,該方法的指標會指向父類定義的 virtual 方法。

在這邊的 printInfo,限定必須得是 Role 的子類實例,因為是以父類觀點來操作子類實例,被稱為子型態多型(subtype polymorphism),因為是執行時期才有 virtual 方法的位址,也就是執行時期才能決定綁定的方法,又稱為執行時期多型(runtime polymorphism)。

乍看之下,〈遮敝父類方法〉中談到的編譯時期多型,與這邊談到的執行時期多型,似乎有很大的重疊性,區別就只是編譯時期或執行時期綁定?例如,單就「顯示角色資訊」來說,這邊的 printInfo 與〈遮敝父類方法〉中的 printInfo,似乎都可以解決需求?

不過,你要再釐清需求,「顯示角色資訊」表示你要接受的對象是「角色」,而不是具有 to_string 的任何物件,如果需求是「顯示具有 to_string 物件的資訊」,你要使用的是模版。

另一個用來釐清需求的方式是,定義 virtual 方法時可以完全不實作,也就是執行時期,這類方法在虛擬方法表中的指標,可以指向 nullptr,這類方法稱為純虛擬方法,也被稱為抽象方法,這之後再來談。

在範例中 Role 的解構式也被定義為 virtual 了,這表示執行時期才會決定使用哪個版本的解構器,這影響的會是動態建立 Role 的子類實例後,以 delete 刪除該實例,會執行的是哪個版本的解構式。例如:

Role *role = new SwordsMan("Justin", 1, 1000);
delete role;

如果 Role 的解構式不是 virtual,那麼 role 會在編譯時期就綁定 Role 定義的解構式,delete role 執行的就只會是 Role 定義的解構式,這通常不會是你想要的結果,如果 Role 的解構式是 virtualrole 是在執行時期,依實例類型綁定解構式,就這邊就是 SwordsMan 的解構式,因此 delete role 執行的就會是 SwordsMan 定義的解構式,接著是 Role 的解構式。

絕大多數情況下,子類實例解決時,當然也想要執行子類的解構式,解構式預設並不是 virtual,因此若定義的類別,是會被用來繼承的基礎類別,應該定義解構式為 virtual

如果不希望方法被子類重新定義,可以定義方法為 finalvirtual,例如:

class Foo {
public:
    virtual void foo() final {
        cout << "foo" << endl;
    }
};

這麼一來,子類就不能定義 foo 方法了,如果類別不希望有子類,可以定義類別為 final

class Foo final {
};

若非透過父類型態參考或指標操作,只是透過複製建構式建構了父類實例罷了。例如:

SwordsMan swordsMan("Justin", 1, 1000);
Role role = swordsMan;
cout << role.to_string() << endl;

這邊的 role 實際上是建立了 Role 實例,而不是參考了 SwordsMan 實例,類似地,以下也不是:

SwordsMan swordsMan("Justin", 1, 1000);
Role role("role", 0, 0);
role = swordsMan;

也就是說,如果想使用執行時期多型,必須透過參考或指標來操作。

父類型態可以參考子類型態實例,反過來則不行,例如:

SwordsMan swordsMan("Justin", 1, 1000);
Role &role = swordsMan;
SwordsMan &swordsMan2 = role; // 編譯錯誤

道理很簡單,SwordsMan 一定是一種 Role,然而 Role 未必是 SwordsMan,當然,就上例來說,role 參考的確實是 SwordsMan 實例,雖然不鼓勵,不過還是可以明確地轉換型態:

SwordsMan swordsMan("Justin", 1, 1000);
Role &role = swordsMan;
SwordsMan &swordsMan2 = dynamic_cast<SwordsMan&>(role);

類似地,指標也可以明確地轉換型態:

SwordsMan *swordsMan = new SwordsMan("Justin", 1, 1000);
Role *role = swordsMan;
SwordsMan *swordsMan2 = dynamic_cast<SwordsMan*>(role);

dynamic_cast 用於告知編譯器,你就是要將父類別的參考或指標向下轉型為子類型態,這不單只是要編譯器住嘴,繼承體系中必須有 virtual 函式的存在,實際的型態轉換會在執行時期進行,確定轉換目標與來源是否有類別階層關係,如果是個指標,轉換成功時傳回位址,失敗的話會傳回 nullptr,如果是參考的話,轉換失敗會丟出 bad_cast 例外,這令執行時期的轉換失敗,會有機會進行處理,如果你使用 static_cast,雖然可以令編譯器住嘴,然而錯誤的轉換會有什麼結果就無法預期了。