靜態綁定#
C++ 中函數的默認綁定是靜態綁定,也稱為早綁定和編譯期綁定。
靜態綁定將函數的查找、關聯的過程放在編譯期間完成,可以提升程序的運行時的性能。
動態綁定#
#include <iostream>
class Base {
public:
void foo() {
std::cout << "Base::foo" << std::endl;
}
};
class Derive1 : public Base {
public:
void foo() {
std::cout << "Derive1::foo" << std::endl;
}
};
int main() {
Base *base = new Derive1();
base->foo();
delete base;
return 0;
}
運行結果:
Base::foo
這裡並沒有調用派生類的函數,因為編譯器在這裡默認進行靜態綁定,編譯階段編譯器無法確定 Base 類型指針會指向什麼類型,無法根據對象指針實際指向的對象類型來進行函數調用。
所以需要將函數的綁定從編譯階段推遲到運行階段,從而使對象指針能夠指向實際指向的類型。這就是動態綁定,也稱晚綁定和運行時綁定。
將基類不進行靜態綁定的成員函數聲明為 virtual
虛函數,從而實現動態綁定。
#include <iostream>
class Base {
public:
virtual void foo() {
std::cout << "Base::foo" << std::endl;
}
virtual ~Base() {}
};
class Derive1 : public Base {
public:
void foo() {
std::cout << "Derive1::foo" << std::endl;
}
};
int main() {
Base *base = new Derive1();
base->foo();
delete base;
return 0;
}
運行結果:
Derive1::foo
Derive2::foo
虛函數表#
虛函數的動態綁定基於虛函數表(vftable)實現,當類中存在虛函數時,編譯器會在對象的第一個數據成員位置插入 vfptr 指針,指向一個包含所有虛函數地址的數組。
編譯器會為每個包含虛函數的類生成虛函數表,該類的所有對象共享一個虛函數表,觀察下面代碼:
#include <iostream>
class Base {
public:
virtual void foo() {}
};
int main() {
Base b1;
void **b1_vfptr = *(void ***)&b1;
Base b2;
void **b2_vfptr = *(void ***)&b2;
std::cout << (b1_vfptr == b2_vfptr) << std::endl;
return 0;
}
運行結果:
1
虛函數表包含該類及基類中所有虛函數的地址,觀察下面代碼:
#include <iostream>
class Base {
public:
virtual void foo() {
std::cout << "Base::foo" << std::endl;
}
virtual ~Base() {}
};
class Derive : public Base {
public:
void foo() override {
std::cout << "Derive::foo" << std::endl;
}
virtual void foo2() {
std::cout << "Derive::foo2" << std::endl;
}
};
int main() {
Base *base = new Derive;
void **vfptr = *(void ***)base;
// [0] &Base::foo
// [1] &Base::~Base
// [2] &Derive::foo
// [3] &Derive::foo2
void (*f)() = (void(*)())vfptr[3];
f();
delete base;
return 0;
}
運行結果:
Derive::foo2
虛函數表由編譯器負責初始化和銷毀,在構造函數中初始化,在析構函數中銷毀。
在多重繼承下,可能會有多個虛函數表。
#include <iostream>
class Base1 {
public:
virtual void foo() {}
};
class Base2 {
public:
virtual void foo() {}
};
class Derive : public Base1, public Base2 {
public:
void foo() override {}
};
int main() {
std::cout << sizeof(Derive) << std::endl;
return 0;
}
64 bit OS 運行結果:
16
當子類沒有重寫父類虛函數時,子類的虛函數表會繼承父類的虛函數地址。
當子類重寫了父類的虛函數,子類從父類繼承的虛函數表就會產生對應的覆蓋行為。
#include <iostream>
class Base {
public:
virtual void foo() {
std::cout << "Base::foo" << std::endl;
}
virtual ~Base() {}
};
class Derive1 : public Base {
public:
};
class Derive2 : public Base {
public:
virtual void foo() override {
std::cout << "Derive2::foo" << std::endl;
}
};
int main() {
Base *base = new Derive1();
base->foo();
delete base;
base = new Derive2();
base->foo();
delete base;
return 0;
}
運行結果:
Base::foo
Derive2::foo
運行時類型識別#
C++ 是靜態類型語言,數據類型在編譯階段確定,但在某些場景下(多態),編譯階段無法確定數據類型,需要在運行階段才能確定。
運行時類型識別(RTTI)是在運行階段確定數據類型的機制。
typeid#
typeid
運算符用於獲取變量類型,可以在編譯階段獲取變量類型,也可以在運行階段獲取變量類型。
運行階段確定變量類型:
class Base {
public:
virtual ~Base() {}
};
class Derive : public Base {
public:
};
int main() {
Base *base = new Derive;
std::cout << typeid(*base).name() << std::endl;
delete base;
return 0;
}
g++ 運行結果:
6Derive
dynamic_cast#
dynamic_cast
用於類型轉換,能夠檢測具有繼承關係的父子類型的指針、引用的類型轉換是否安全,可以在編譯階段轉換,也可以在運行階段轉換。
編譯階段轉換:
#include <iostream>
class Base {};
class Derive : public Base {};
int main() {
Derive *derive = new Derive();
Base *base = dynamic_cast<Base *>(derive);
delete base;
return 0;
}
將一個較大的尋址範圍的指針轉換為較小的範圍,不會導致內存越界,所以是安全的。
運行階段轉換:
#include <iostream>
class Base {
public:
virtual ~Base() {}
};
class Derive : public Base {};
int main() {
Base *base1 = new Base(); // base1 指向基類
Derive *derive1 = dynamic_cast<Derive *>(base1); // 基類轉換為派生類
std::cout << derive1 << std::endl; // 0 轉換失敗
Base *base2 = new Derive(); // base2 指向派生類
Derive *derive2 = dynamic_cast<Derive *>(base2); // 派生類轉換為基類
std::cout << derive2 << std::endl; // 非 0 轉換成功
delete derive1;
delete derive2;
return 0;
}
將一個較小的尋址範圍的指針轉換為較大的範圍,可能會導致內存越界,所以是不安全的。
typeid
、dynamic_cast
在運行階段進行類型識別時,依賴於虛函數機制。
在多態的情況下,可能會出現父類指針指向子類對象的情況。
虛函數表中的每一個指針通常指向 std::type_info
對象,可以通過 vfptr 訪問到對象的類型信息 &class_meta
。
虛函數機制的優缺點#
優點:
- 動態多態性: 允許通過基類指針或引用調用派生類中重寫的虛函數。
- 代碼重用: 基類定義公共接口,派生類可以根據需要重寫虛函數,實現不同的功能。
- 可擴展性: 可以添加新的派生類,並重寫虛函數,而無需修改基類代碼。
- 解耦: 虛函數機制可以幫助解耦代碼,使代碼更易於維護和修改。
缺點:
- 內存開銷:每包含虛函數的類都有一個虛函數表,虛函數表是一個函數指針數組,在 64 位操作系統中消耗 8 字節內存。
- 調用開銷:調用虛函數時,會通過虛函數表來查找實際需要調用函數的地址,與直接通過函數地址調用函數相比略慢。
- 可能影響優化:靜態綁定可以在編譯階段確定調用函數的地址,編譯器可以進行內聯優化。而對於動態綁定,編譯器無法在編譯階段確定具體的函數實現,只能在運行階段通過虛函數表訪問實際調用函數的地址。