继承

虚函数与多态

virtual 关键字

class Base {
public:
    virtual void use() const { std::cout << "Base::use()" << std::endl; }
    virtual ~Base() noexcept { std::cout << "Base destructed." << std::endl; }
};

class Derived : public Base {
public:
    virtual void use() const override { std::cout << "Derived::use()" << std::endl; }    
    virtual ~Derived() noexcept override { std::cout << "Derived destructed" << std::endl; }
};

int main() {
    Base* pb = new Derived;
    pb->use();
    delete pb;
}

基类中 use() 方法标记为 virtual 后,就成为了虚函数。 在派生的子类中即便不使用 virtual 关键字标记 use()use() 仍保持为虚函数。

需要特别注意的是,当一个类出现虚函数后,该类 必须 同时包含虚析构函数。 子类的虚构函数尽管名称与基类虚构函数不同,但子类虚构函数仍然是对父类虚构函数的重载。 析构时,会先调用子类的析构函数,再调用基类的析构函数。

基类派生出子类后,子类实际上是在基类中又新建了一个更具体的作用域。考虑这样的例子:

class Base {
public:
    void use() { std::cout << "Base gets used." << std::endl; }
};

class Derived : public Base {
public:
    void use() { std::cout << "Derived gets used." << std::endl; }
};

int main() {
    Derived obj;
    Derived& derived = obj;
    Base& base = derived;
    obj.use(); // 输出:Derived gets used.
    derived.use(); // 输出:Derived gets used.
    base.use(); // 输出:Base gets used.
}

通过引用或指针对非虚函数调用的解析是在编译期就完成的,对虚函数的调用则在运行时解析。对于非虚的成员函数,对其调用的解析是在编译期就完成的,其中 derived.use() 被解析为 derived.Derived::use()base.use() 被解析为 base.Base::use()

当通过基类的引用或指针访问虚函数时,调用的解析是在运行时完成的。运行时,通过查找虚函数表确定需要访问的虚函数地址,进而正确调用子类中重载的 use(),实现 动态绑定(dynamic binding) ,或称 运行时绑定(run-time binding) ,这种特性常被称作 多态(Polymorphism)。因此可以说,多态是运行期的行为,而非编译期的行为。

需要注意的是,上面提到的作用域覆盖现象在定义虚函数时仍然存在,Derived::use() 仍然覆盖了 Base::use()

override 标识符

在实际开发中,应使用 override 标识符在子类中显式地标记对基类函数的重载,这样在编译期就能够检查类似于下面这样的问题:

override 是标识符而非关键字。

class FooException : public std::exception {
    virtual const char* what() { return "Foo Exception"; }
};

int main() {
    try {
        throw FooException();
    } catch (std::exception& e) {
        std::cout << e.what() << std::endl; // 输出 std::exception,不会正确调用重载的版本
    }
}

在基类 std::exception 中,what() 的真正原型实际上是 const char* what() const;,因此 FooException::what() 并没有重载基类的 what(),而是 隐藏 了基类的 what(),随之而来的问题是没有办法在运行时实现多态。现代的编译器针对上述情况会发出警告:

基类的 what() 被覆盖
基类的 what() 被覆盖

注意,编译器告诉我们的是基类中原有的 what() 函数被子类作用域下的 what() 覆盖。

如果显式地指定 override 标识符,则意味着该函数重载了基类的某个虚函数,而基类中并不存在原型为 const char* what() 的虚函数,编译器会直接报错:

使用 override 后报错
使用 override 后报错

虚表

需要指出的是,类的成员函数并占用类实例的存储空间。 在前面的章节已经提及,任何非静态的成员函数、构造函数、析构函数都包含一个隐含的 this 指针参数,用于指向当前实例的基址。 因此,任一个类的成员函数在整个程序中都只有一份,并由所有类的实例共享。 对于包含虚函数的类,编译器会为其生成一个 虚函数表(virtual table, vtable) ,该表中存储了类的虚函数地址。 在运行时,通过类的指针或引用调用虚函数时,程序会先查找该类的虚函数表,然后根据表中的地址调用相应的虚函数实现动态绑定。 同时,编译器还会在类的定义中新增一个隐藏的指针成员,指向该类的虚函数表。 在调用构造函数时,该指针会被初始化为指向该类的虚函数表地址。下面的代码可以验证这一点:

#include <iostream>

class Foo {
    std::int64_t number;
public:
    std::int64_t get() const noexcept { return number; }
    void set(std::int64_t num) { this->number = num; }
};

class Bar {
    std::int64_t number;
public:
    virtual void run() { std::cout << number << std::endl; }
    virtual std::int64_t get() const noexcept { return number; }
    virtual void set(std::int64_t num) { this->number = num; }
};

class Baz : public Bar {

};

int main () {
    std::cout << "size of Foo: " << sizeof(Foo) << std::endl; // 8
    std::cout << "size of Bar: " << sizeof(Bar) << std::endl; // 16
    std::cout << "size of Baz: " << sizeof(Baz) << std::endl; // 16
    return 0;
}

可以看到,在 64 位平台下由于虚表指针的引入,类的大小扩大了 8 个字节。

为了进一步说明问题,再编写一个更加简洁的样例,由 MSVC 提供的 clang++ 生成的 IR 来观察虚函数表的实现:

在 x86_64 平台下,C++ 有两种主要的 ABI 实现,分别为 Itanium ABI(这里的 Itanium 最初源于 Itanium 处理器架构,但现在已经广泛应用于其他 x86_64 平台)和 MSVC ABI。这里使用 MSVC 提供的 clang++ 编译器生成的 IR 来观察虚函数表的实现细节,其他编译器(如 GCC、Clang)生成的 IR 不尽相同,但实现思路基本是一致的。

// foo.cpp
class Foo {
public:
    virtual void run() const noexcept {};
    virtual ~Foo() = default;
private:
    int foo;
};

class Bar : public Foo {
public:
    virtual void run() const noexcept override {};
    virtual ~Bar() = default;
private:
    float bar;
};

int main () {
    Bar bar;
    Foo& foo = bar;
    foo.run();
    return 0;
}

使用下面的命令生成 IR 代码:

clang++ -S -emit-llvm foo.cpp -o foo.ll

在生成的 IR 中,FooBar 这两个类的定义如下:

%class.Bar = type { %class.Foo, float }
%class.Foo = type { ptr, i32 }

除了类型为 i32foo 成员,Foo 还包含一个类型为 ptr 的隐藏成员,该成员即为指向虚函数表的指针。Bar 继承自 Foo,因此 Bar 的定义中包含了一个 Foo 类型的成员(即 Foo 的所有成员),以及一个类型为 floatbar 成员。从这个意义上说,我们只需要截取 Bar 的前面部分,就可以得到 Foo 的定义。

; Function Attrs: mustprogress noinline norecurse nounwind optnone uwtable
define dso_local noundef i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca %class.Bar, align 8
  %3 = alloca ptr, align 8
  store i32 0, ptr %1, align 4
  %4 = call noundef ptr @"??0Bar@@QEAA@XZ"(ptr noundef nonnull align 8 dereferenceable(24) %2) #3
  store ptr %2, ptr %3, align 8
  %5 = load ptr, ptr %3, align 8
  %6 = load ptr, ptr %5, align 8
  %7 = getelementptr inbounds ptr, ptr %6, i64 0
  %8 = load ptr, ptr %7, align 8
  call void %8(ptr noundef nonnull align 8 dereferenceable(16) %5) #3
  store i32 0, ptr %1, align 4
  call void @"??1Bar@@UEAA@XZ"(ptr noundef nonnull align 8 dereferenceable(24) %2) #3
  %9 = load i32, ptr %1, align 4
  ret i32 %9
}

使用 alloc 在栈上分配了 Bar 实例的内存空间后,将实例的基址存储到 %2 中,并调用构造函数 @"??0Bar@@QEAA@XZ" 进行初始化。

; Function Attrs: mustprogress noinline nounwind optnone uwtable
define linkonce_odr dso_local noundef ptr @"??0Bar@@QEAA@XZ"(ptr noundef nonnull returned align 8 dereferenceable(24) %0) unnamed_addr #1 comdat align 2 {
  %2 = alloca ptr, align 8
  store ptr %0, ptr %2, align 8
  %3 = load ptr, ptr %2, align 8
  %4 = call noundef ptr @"??0Foo@@QEAA@XZ"(ptr noundef nonnull align 8 dereferenceable(16) %3) #3
  store ptr @"??_7Bar@@6B@", ptr %3, align 8
  ret ptr %3
}

在构造函数 @"??0Bar@@QEAA@XZ" 中,store ptr @"??_7Bar@@6B@", ptr %3, align 8,该行代码将虚函数表的地址存储到了隐藏的虚表指针成员中。我们可以据此找到虚表的定义:

@0 = private unnamed_addr constant { [3 x ptr] } { [3 x ptr] [ptr @"??_R4Bar@@6B@", ptr @"?run@Bar@@UEBAXXZ", ptr @"??_GBar@@UEAAPEAXI@Z"] }, comdat($"??_7Bar@@6B@")
@"??_R4Bar@@6B@" = linkonce_odr constant %rtti.CompleteObjectLocator { i32 1, i32 0, i32 0, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R0?AVBar@@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R3Bar@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R4Bar@@6B@" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat
@"??_7type_info@@6B@" = external constant ptr
@"??_R0?AVBar@@@8" = linkonce_odr global %rtti.TypeDescriptor9 { ptr @"??_7type_info@@6B@", ptr null, [10 x i8] c".?AVBar@@\00" }, comdat
@__ImageBase = external dso_local constant i8
@"??_R3Bar@@8" = linkonce_odr constant %rtti.ClassHierarchyDescriptor { i32 0, i32 0, i32 2, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R2Bar@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat
@"??_R2Bar@@8" = linkonce_odr constant [3 x i32] [i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R1A@?0A@EA@Bar@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R1A@?0A@EA@Foo@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 0], comdat
@"??_R1A@?0A@EA@Bar@@8" = linkonce_odr constant %rtti.BaseClassDescriptor { i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R0?AVBar@@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 1, i32 0, i32 -1, i32 0, i32 64, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R3Bar@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat
@"??_R1A@?0A@EA@Foo@@8" = linkonce_odr constant %rtti.BaseClassDescriptor { i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R0?AVFoo@@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 0, i32 0, i32 -1, i32 0, i32 64, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R3Foo@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat
@"??_R0?AVFoo@@@8" = linkonce_odr global %rtti.TypeDescriptor9 { ptr @"??_7type_info@@6B@", ptr null, [10 x i8] c".?AVFoo@@\00" }, comdat
@"??_R3Foo@@8" = linkonce_odr constant %rtti.ClassHierarchyDescriptor { i32 0, i32 0, i32 1, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R2Foo@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat
@"??_R2Foo@@8" = linkonce_odr constant [2 x i32] [i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R1A@?0A@EA@Foo@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 0], comdat
@1 = private unnamed_addr constant { [3 x ptr] } { [3 x ptr] [ptr @"??_R4Foo@@6B@", ptr @"?run@Foo@@UEBAXXZ", ptr @"??_GFoo@@UEAAPEAXI@Z"] }, comdat($"??_7Foo@@6B@")
@"??_R4Foo@@6B@" = linkonce_odr constant %rtti.CompleteObjectLocator { i32 1, i32 0, i32 0, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R0?AVFoo@@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R3Foo@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R4Foo@@6B@" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat

@"??_7Bar@@6B@" = unnamed_addr alias ptr, getelementptr inbounds ({ [3 x ptr] }, ptr @0, i32 0, i32 0, i32 1)
@"??_7Foo@@6B@" = unnamed_addr alias ptr, getelementptr inbounds ({ [3 x ptr] }, ptr @1, i32 0, i32 0, i32 1)

从上面的定义可以看到,@"??_7Bar@@6B@"@"??_7Foo@@6B@" 分别是 BarFoo 类的虚函数表地址 @0@1 别名,而虚函数表本身则定义为常量数组,数组中的每个元素即为虚函数的地址。

在源代码中,我们定义了两个虚函数,分别为 run 和析构函数。但在上述两个虚表的定义中,我们看到实际上表中有三个表项。以 @0 为例,其中以一个表项 @"??_R4Bar@@6B@" 的定义为:

@"??_R4Bar@@6B@" = linkonce_odr constant %rtti.CompleteObjectLocator { i32 1, i32 0, i32 0, i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R0?AVBar@@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R3Bar@@8" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32), i32 trunc (i64 sub nuw nsw (i64 ptrtoint (ptr @"??_R4Bar@@6B@" to i64), i64 ptrtoint (ptr @__ImageBase to i64)) to i32) }, comdat

其中有一个关键的 %rtti.CompleteObjectLocator,它用于支持运行时类型识别(RTTI, Run-Time Type Information),是 C++ RTTI 机制的一部分。

构造完成后,虚表指针成员已经指向了 Bar 类的虚函数表地址。因此,不论是通过 Bar 类的指针还是通过基类 Foo 类的指针调用虚函数时,程序都可通过虚表指针找到该类对应的虚表,进而调用正确的虚函数实现多态:

  store ptr %2, ptr %3, align 8
  %5 = load ptr, ptr %3, align 8
  %6 = load ptr, ptr %5, align 8
  %7 = getelementptr inbounds ptr, ptr %6, i64 0
  %8 = load ptr, ptr %7, align 8
  call void %8(ptr noundef nonnull align 8 dereferenceable(16) %5) #3