banner
cells

cells

为美好的世界献上 bug

虛函數機制

靜態綁定#

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 指針,指向一個包含所有虛函數地址的數組。

image-20240619090111400

編譯器會為每個包含虛函數的類生成虛函數表,該類的所有對象共享一個虛函數表,觀察下面代碼:

#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;
}

將一個較小的尋址範圍的指針轉換為較大的範圍,可能會導致內存越界,所以是不安全的。

typeiddynamic_cast 在運行階段進行類型識別時,依賴於虛函數機制。

在多態的情況下,可能會出現父類指針指向子類對象的情況。

虛函數表中的每一個指針通常指向 std::type_info 對象,可以通過 vfptr 訪問到對象的類型信息 &class_meta

虛函數機制的優缺點#

優點:

  • 動態多態性: 允許通過基類指針或引用調用派生類中重寫的虛函數。
  • 代碼重用: 基類定義公共接口,派生類可以根據需要重寫虛函數,實現不同的功能。
  • 可擴展性: 可以添加新的派生類,並重寫虛函數,而無需修改基類代碼。
  • 解耦: 虛函數機制可以幫助解耦代碼,使代碼更易於維護和修改。

缺點:

  • 內存開銷:每包含虛函數的類都有一個虛函數表,虛函數表是一個函數指針數組,在 64 位操作系統中消耗 8 字節內存。
  • 調用開銷:調用虛函數時,會通過虛函數表來查找實際需要調用函數的地址,與直接通過函數地址調用函數相比略慢。
  • 可能影響優化:靜態綁定可以在編譯階段確定調用函數的地址,編譯器可以進行內聯優化。而對於動態綁定,編譯器無法在編譯階段確定具體的函數實現,只能在運行階段通過虛函數表訪問實際調用函數的地址。
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。