Skip to main content

下部

C++ 虚函数表

虚函数表在 C++ 面试中出现频率非常高,常常以各种形式的问题出现

  1. C++ 多态了解么?C++ 多态的实现原理?
  2. 虚函数是什么?虚函数的实现原理?
  3. 虚表是什么?虚表的内存结构布局如何?虚表的第一项(或第二项)是什么?

1. C++对象模型

这些问题最终基本上都指向了对 C++的内存模型虚函数表部分的理解。

对 C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的,简称为V-Table。

在这个表中,存放的是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。

这样,这个类的实例内存中都有一个虚函数表的指针,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

class roint (
public:
Point(float xval );
virtoal ~Point();
float x() const,
static int PointCount();
protected:
virtual ostream& print( ostream &os ) const;
float _x;
static int _point_count;
};

比如上面这个类,它的对象模型如下:

img

上图就是《深度探索C++对象模型》这本书第一章介绍的 C++的对象模型,也就是 C++ 是如何存储一个对象的数据(成员函数、成员变量、静态变量、虚函数等等)。

建议学习C++的同学都看下这本书,需要获取这本书 PDF可以看下这个仓库: [https://github.com/imarvinle/awesome-cs-books](https://github.com/imarvinle/awesome-cs-books(opens)

在上面的示例中,意思就是一个对象在内存中一般由成员变量(非静态)、虚函数表指针(vptr)构成。

虚函数表指针指向一个数组,数组的元素就是各个虚函数的地址,通过函数的索引,我们就能直接访问对应的虚函数。

下面看个实际的例子,我们通过直接访问虚函数表来调用各个虚函数(注意,这种行为是未定义的,因为每个编译器/平台上,虚函数表的位置/实现都有可能不同)

假如我们有这样一个类:

#include <iostream>

// 函数指针
typedef void(*Func)(void);

class MyBaseClass {
public:
virtual void virtualMethod1() {
std::cout << "BaseClass::virtualMethod1()" << std::endl;
}
virtual void virtualMethod2() {
std::cout << "BaseClass::virtualMethod2()" << std::endl;
}
virtual void virtualMethod3() {
std::cout << "BaseClass::virtualMethod3()" << std::endl;
}

};

class MyDerivedClass : public MyBaseClass {
public:
virtual void virtualMethod3() {
std::cout << "DerivedClass::virtualMethod3()" << std::endl;
}
virtual void virtualMethod4() {
std::cout << "DerivedClass::virtualMethod4()" << std::endl;
}
virtual void virtualMethod5() {
std::cout << "DerivedClass::virtualMethod5()" << std::endl;
}
};

void PrintVtable(void** vtable) {
// 输出虚函数表中每个函数的地址
for (int i = 0; vtable[i] != nullptr; i++) {
// 最多调用五个函数,怕访问到虚函数表非法的地址,因为就五个函数
if (i >= 5) {
return;
}
std::cout << "Function " << i << ": " << vtable[i] << std::endl;
// 将虚函数表中的虚函数转换为函数指针,然后进行调用
Func func = (Func) vtable[i];
func();
}
}

int main() {
MyDerivedClass obj;

// 取对象的首地址,然后转换为的指针,就取到了虚函数表指针,指向 obj 对象的虚函数表
// 因为大多数实现上,虚函数表指针一般都放在对象第一个位置
void** vtable = *(void***)(&obj);
std::cout << "DerivedClass Vetable:" << std::endl;
// 打印子类的虚函数表
PrintVtable(vtable);

std::cout << std::endl << "BaseClass Vetable:" << std::endl;
MyBaseClass base_obj;
void** vbtable = *(void***)(&base_obj);
// 打印父类的虚函数表
PrintVtable(vbtable);
return 0;
}

运行的结果如下:

DerivedClass Vetable:

Function 0: 0x10d675050

BaseClass::virtualMethod1()

Function 1: 0x10d675090

BaseClass::virtualMethod2()

Function 2: 0x10d6750d0

DerivedClass::virtualMethod3()

Function 3: 0x10d675110

DerivedClass::virtualMethod4()

Function 4: 0x10d675150

DerivedClass::virtualMethod5()

BaseClass Vetable:

Function 0: 0x10d675050

BaseClass::virtualMethod1()

Function 1: 0x10d675090

BaseClass::virtualMethod2()

Function 2: 0x10d675190

BaseClass::virtualMethod3()

注意到,这个结果说明了,子类的虚函数表中 virtualMethod3 的地址和父类中 virtualMethod3 地址不同,这就是虚函数实现多态的底层原理,就是子类的虚函数表中用子类重写的函数地址去取代父类的函数地址。

当然虚函数表遇到继承、多重继承等就稍微复杂一些,但是大体的原理都像上面解释的这样,理解到这里基本也就理解了虚机制。

关于虚函数表的更多知识就不细讲了,感兴趣可以看下这篇博客: https://coolshell.cn/articles/12165.html

2. 动态多态底层原理

正如这篇C++实现多态的方式 (opens new window)文章所说,C++ 中的动态多态性是通过虚函数实现的。

当基类指针或引用指向一个派生类对象时,调用虚函数时,实际上会调用派生类中的虚函数,而不是基类中的虚函数。

在底层,当一个类声明一个虚函数时,编译器会为该类创建一个虚函数表(Virtual Table)。 这个表存储着该类的虚函数指针,这些指针指向实际实现该虚函数的代码地址。

每个对象都包含一个指向该类的虚函数表的指针,这个指针在对象创建时被初始化,通常是作为对象的第一个成员变量。

当调用一个虚函数时,编译器会通过对象的虚函数指针查找到该对象所属的类的虚函数表,并根据函数的索引值(通常是函数在表中的位置,编译时就能确定)来找到对应的虚函数地址。

然后将控制转移到该地址,实际执行该函数的代码。

对于派生类,其虚函数表通常是在基类的虚函数表的基础上扩展而来的。

在派生类中,如果重写了基类的虚函数,那么该函数在派生类的虚函数表中的地址会被更新为指向派生类中实际实现该函数的代码地址。

Shape *shape;
Rectangle rec(10,7);
shape = &rec;
shape->area();

在上面的示例中,通过基类指针 shape 去调用 area() 方法时,实际调用到的就是虚函数表中被替换后的子类方法。

C++的动态多态必须满足两个条件:

  • 必须通过基类的指针或者引用调用虚函数
  • 被调用的函数是虚函数,且必须完成对基类虚函数的重写

其中第一条很重要,当我们使用派生类的指针去访问/调用虚函数时,实际上并未发生动态多态,因为编译时就能确定对象类型为派生类型,然后直接生成调用派生类虚函数的代码即可,这种叫做静态绑定。

通过基类的指针或引用调用虚函数才能构成多态,因为这种情况下运行时才能确定对象的实际类型,这种称为动态绑定。

C++ 纯虚函数是什么?

1. 定义

纯虚函数是一种在基类中声明但没有实现的虚函数。

它的作用是定义了一种接口,这个接口需要由派生类来实现。(C++ 中没有接口,纯虚函数可以提供类似的功能

包含纯虚函数的类称为抽象类(Abstract Class))

抽象类仅仅提供了一些接口,但是没有实现具体的功能。

作用就是制定各种接口,通过派生类来实现不同的功能,从而实现代码的复用和可扩展性。

另外,抽象类无法实例化,也就是无法创建对象。

原因很简单,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。

2. 例子

#include <iostream>
using namespace std;

class Shape {
public:
// 纯虚函数
virtual void draw() = 0;
};

class Circle : public Shape {
public:
void draw() {
cout << "画一个圆形" << endl;
}
};

class Square : public Shape {
public:
void draw() {
cout << "画一个正方形" << endl;
}
};

int main() {
Circle circle;
Square square;

Shape *pShape1 = &circle;
Shape *pShape2 = &square;

pShape1->draw();
pShape2->draw();

return 0;
}

在上面的代码中,定义了一个抽象类 Shape,它包含了一个纯虚函数 draw()。

Circle 和 Square 是 Shape 的两个派生类,它们必须实现 draw() 函数,否则它们也会是一个抽象类。

在 main() 函数中,创建了 Circle 和 Square 的实例,并且使用指向基类 Shape 的指针来调用 draw() 函数。

由于 Shape 是一个抽象类,不能创建 Shape 的实例,但是可以使用 Shape 类型指针来指向派生类,从而实现多态。

为什么 C++ 构造函数不能是虚函数?

这个题经常会在C++面试中遇到,背后的原理会涉及到 C++ 的虚函数机制实现。

1. 从语法层面来说

虚函数的主要目的是实现多态,即允许在派生类中覆盖基类的成员函数。但是,构造函数负责初始化类的对象,每个类都应该有自己的构造函数。

在派生类中,基类的构造函数会被自动调用,用于初始化基类的成员。因此,构造函数没有被覆盖的必要,不需要使用虚函数来实现多态。

2. 从虚函数表机制回答

虚函数使用了一种称为虚函数表(vtable)的机制。然而,在调用构造函数时,对象还没有完全创建和初始化,所以虚函数表可能尚未设置。

这意味着在构造函数中使用虚函数表会导致未定义的行为。

只有执行完了对象的构造,虚函数表才会被正确的初始化。

为什么 C++ 基类析构函数需要是虚函数?

1. 析构函数作用

首先我们需要知道析构函数的作用是什么。

析构函数是进行类的清理工作,比如释放内存、关闭DB链接、关闭Socket等等。

前面我们在介绍虚函数的时候就说到,为实现多态性,可以通过基类的指针或引用访问派生类的成员。

也就是说,声明一个基类指针,这个基类指针可以指向派生类对象。

#include <iostream>

class Base {
public:
// 注意,这里的析构函数没有定义为虚函数
~Base() {
std::cout << "Base destructor called." << std::endl;
}
};

class Derived : public Base {
public:
Derived() {
resource = new int[100]; // 分配资源
}

~Derived() {
std::cout << "Derived destructor called." << std::endl;
delete[] resource; // 释放资源
}

private:
int* resource; // 存储资源的指针
};

int main() {
Base* ptr = new Derived();
delete ptr; // class Base {
public:
virtual ~Base() {
std::cout << "Base destructor called." << std::endl;
}
};

class Derived : public Base {
public:
~Derived() {
std::cout << "Derived destructor called." << std::endl;
}
};

int main() {
Base* ptr = new Derived();
delete ptr; // 调用Derived的析构函数,然后调用Base的析构函数
return 0;
},Derived的析构函数不会被调用
return 0;
}

这个例子执行的输出结果为:

Base destructor called.

由于基类Base的析构函数没有定义为虚函数,当创建一个派生类Derived的对象,并通过基类指针ptr删除它时,只有基类Base的析构函数被调用(因为这里没有多态,构造多态的必要条件就是虚函数)。

派生类Derived的析构函数不会被调用,导致资源(这里是resource)没有被释放,从而产生资源泄漏。

再来看一个将基类定义为虚函数的例子:

class Base {
public:
virtual ~Base() {
std::cout << "Base destructor called." << std::endl;
}
};

class Derived : public Base {
public:
~Derived() {
std::cout << "Derived destructor called." << std::endl;
}
};

int main() {
Base* ptr = new Derived();
delete ptr; // 调用Derived的析构函数,然后调用Base的析构函数
return 0;
}

输出结果为:

Derived destructor called.

Base destructor called.

在这个例子中,基类Base的析构函数是虚函数,所以当删除ptr时,会首先调用派生类Derived的析构函数,然后调用基类Base的析构函数,这样可以确保对象被正确销毁。

2. 为什么默认的析构函数不是虚函数?

那么既然基类的析构函数如此有必要被定义成虚函数,为何类的默认析构函数却是非虚函数呢?

因为,虚函数不同于普通成员函数,当类中有虚成员函数时,类会自动进行一些额外工作。

这些额外的工作包括生成虚函数表和虚表指针,虚表指针指向虚函数表。

每个类都有自己的虚函数表,虚函数表的作用就是保存本类中虚函数的地址,我们可以把虚函数表形象地看成一个数组,这个数组的每个元素存放的就是各个虚函数的地址。

这样一来,就会占用额外的内存,当们定义的类不被其他类继承时,这种内存开销无疑是浪费的。

这样一说,问题就不言而喻了。当我们创建一个类时,系统默认我们不会将该类作为基类,所以就将默认的析构函数定义成非虚函数,这样就不会占用额外的内存空间。

同时,系统也相信程序开发者在定义一个基类时,会显示地将基类的析构函数定义成虚函数,此时该类才会维护虚函数表和虚表指针。

3. 零成本抽象原则

3.1. 定义与起源

零成本抽象原则起源于C++,由C++创始人Bjarne Stroustrup提出。它指的是,在使用抽象时,如果不使用某个特定的抽象功能,那么就不应该为该功能承担任何成本。换句话说,抽象的使用应该是“免费的”,即不会引入任何不必要的性能开销或运行时成本。

3.2. 核心要求

  1. 没有全局成本:零成本抽象不应该对不使用该功能的程序的性能产生负面影响。例如,它不能要求每个程序都携带一个沉重的语言运行时(runtime),以使唯一使用该功能的程序模块受益。
  2. 最佳性能:一个零成本的抽象应该编译成相当于底层指令编写的最佳实现。它不能引入额外的成本,而这些成本在没有抽象的情况下是可以避免的。这意味着抽象的使用应该尽可能地接近手动编写的代码的性能。
  3. 改善开发者体验:抽象的意义在于提供一个新的工具,由底层组件组装而成,让开发者更容易写出他们想写的代码。零成本抽象应该比其他方法提供更好的使用体验,使开发者能够更高效地编写代码。

4. 总结

在C++中,基类的析构函数需要定义为虚函数,以确保在通过基类指针或引用删除派生类对象时,能够正确地调用派生类的析构函数,否则派生类的析构函数不会被调用,这部分资源也就并无法被释放。

将基类的析构函数定义为虚函数后,C++运行时会自动识别指向的对象的实际类型,并确保调用正确的派生类析构函数。

当派生类析构函数执行完毕后,基类的析构函数也会自动调用,以确保对象完全销毁。

为什么C++的成员模板函数不能是 virtual 的

这个题目在面试时问得倒不是很多(一些面试官其实也答不上来),但是却值得了解,背后的原理会涉及到 C++ 的一些语法机制实现。

1. 问题含义

问题的意思是,为什么在C++里面,一个类的成员函数不能既是 template 又是 virtual 的。比如,下面的代码编译会报错:

class Animal{
public:
template<typename T>
virtual void make_sound(){
//...
}
};

2. 为什么?

这个问题涉及到一点 C++ 的实现机制(C++中模板是如何工作的、虚拟函数是如何实现的、编译器和链接器如何从源代码生成可执行文件),所以很少人能一下子答上来。

具体理由如下:

因为C++的编译与链接模型是"分离"的。

从Unix/C开始,一个C/C++程序就可以被分开编译,然后用一个linker链接起来。这种模型有一个问题,就是各个编译单元可能对另一个编译单元一无所知。

一个 function template最后到底会被 instantiate 为多少个函数,要等整个程序(所有的编译单元)全部被编译完成才知道。

同时,virtual function的实现大多利用了一个"虚函数表"(参考: 虚函数机制 (opens new window))的东西,这种实现中,一个类的内存布局(或者说虚函数表的内存布局)需要在这个类编译完成的时候就被完全确定。

所以当一个虚拟函数是模板函数时,编译器在编译时无法为其生成一个确定的虚函数表条目,因为模板函数可以有无数个实例。但是编译时无法确定需要调用哪个特定的模板实例。因此,C++标准规定member function 不能既是 template 又是 virtual 的。

参考:

https://www.zhihu.com/question/60911582/answer/182045051

sizeof 一个空类大小是多大

也就是下面这个输出多少:

class Empty {};

int main() {
Empty e1;
Empty e2;
std::cout << "Size of Empty class: " << sizeof(Empty) << std::endl;
}

大多数情况下 sizeof(Empty) = 1

1. 原因

这是因为C++标准要求每个对象都必须具有独一无二的内存地址。

为了满足这一要求,编译器会给每个空类分配一定的空间,通常是1字节。

这样,即使是空类,也能保证每个实例都有不同的地址。

2. C++之父的解释

更多可以看 C++ 之父对于这个问题的解释 (https://www.stroustrup.com/bs_faq2.html#sizeof-empty):

img

翻译下就是:

为了确保两个不同对象的地址不同。出于相同的原因,“new”总是返回指向不同对象的指针。

考虑以下示例:

class Empty { };

void f()
{
Empty a, b;
if (&a == &b) cout << "impossible: report error to compiler supplier";

Empty* p1 = new Empty;
Empty* p2 = new Empty;
if (p1 == p2) cout << "impossible: report error to compiler supplier";
}

有一个有趣的规则是:如果一个空类做基类,那么在派生类中不需要用一个单独的字节来表示,例如:

struct X : Empty {
int a;
// ...
};

void f(X* p)
{
void* p1 = p;
void* p2 = &p->a;
if (p1 == p2) cout << "nice: good optimizer";
}

上面说明了 p1 和 p2(成员变量a的地址)所指向相同的地方,也就是父类没有占空间。

这种优化允许使用空类来表示一些简单的概念,而且无需额外开销。

大多数编译器都提供了这种“空基类优化”。