在〈遮敝父類方法〉中看到,在繼承關係下,基於 is-a,子類實例可以指定給父類型態,如果你這麼做,多數情況下想要的效果是,想以一般化的方式來操作實例,無論該實例是父類或子類實例。
例如,Role
、SwordsMan
都具有 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
的解構式是 virtual
,role
是在執行時期,依實例類型綁定解構式,就這邊就是 SwordsMan
的解構式,因此 delete role
執行的就會是 SwordsMan
定義的解構式,接著是 Role
的解構式。
絕大多數情況下,子類實例解決時,當然也想要執行子類的解構式,解構式預設並不是 virtual
,因此若定義的類別,是會被用來繼承的基礎類別,應該定義解構式為 virtual
。
如果不希望方法被子類重新定義,可以定義方法為 final
的 virtual
,例如:
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
,雖然可以令編譯器住嘴,然而錯誤的轉換會有什麼結果就無法預期了。