Skip to main content

虚函数的作用以及底层实现原理

虚函数是C++中⼀个⾮常重要的概念,它是实现多态的⼀个重要⼯具。在C++中,多态是通过虚函数来实现的。虚函数是在基类中声明的,它在派⽣类中可以被重新定义。在基类中,虚函数的声明⽤virtual关键字来标识。在派⽣类中,虚函数的重新定义也⽤virtual关键字来标识。

虚函数的作⽤是实现多态,即在基类中定义⼀个虚函数,然后在派⽣类中重新定义这个函数,这样,当⽤基类指针指向派⽣类对象,并调⽤这个函数时,会根据指针指向的对象的类型来调⽤相应的函数。

1. 虚函数的实现

在有虚函数的类中,类的最开始部分是⼀个虚函数表的指针,这个指针指向⼀个虚函数表, 表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当⼦类继承了⽗类的时候也会继承其虚函数表, 当⼦类重写⽗类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使⽤了虚函数,会增加访问内存开销,降低效率 。

tip

什么是代码段(.text)? 代码段是存放程序执行代码的地方,代码段是只读的,不允许写入。其是⼀个⼤的连续的内存区域,⽤来存放程序的指令。

纯虚函数是⼀种特殊的虚函数,它在基类中没有实现(或者说实现为空),并且在声明时被赋予⼀个 "=0" 的初始值。纯虚函数的存在意味着派⽣类必须为这个函数提供实现。如果⼀个类包含⾄少⼀个纯虚函数,那 么这个类被称为抽象类。抽象类不能实例化,只能作为基类为其他派⽣类提供接⼝。纯虚函数的⽬的是为了定义⼀个通⽤的接⼝,让派⽣类提供具体的实现

2. 访问普通成员函数和虚函数哪个更快

访问普通成员函数更快,因为普通成员函数的地址在编译阶段就已确定,因此在访问时直接调⽤对应地址的函数,⽽虚函数在调⽤时,需要⾸先在虚函数表中寻找虚函数所在地址,然后再调⽤该地址处的函数,因此访问虚函数的速度要慢于访问普通成员函数。

3. 析构函数是虚函数的情况

析构函数是虚函数的原因是为了⽀持多态。当⽤基类指针指向派⽣类对象,并调⽤析构函数时,如果析构函数不是虚函数,那么只会调⽤基类的析构函数,⽽不会调⽤派⽣类的析构函数,这样就会导致派⽣类对象中的资源没有得到释放,从⽽造成内存泄漏。因此,为了⽀持多态,析构函数必须是虚函数。

C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占⽤额外的内存。⽽对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,⽽是只有当需要当作⽗类时,设置为虚函数。

note

内联函数、构造函数、静态成员函数可以是虚函数吗?

都不可以!

内联函数是在编译阶段直接展开,⽽虚函数是在运⾏时动态绑定的,因此内联函数不能是虚函数。

构造函数不能是虚函数,因为构造函数是在对象创建时调⽤的,⽽虚函数是在对象创建后调⽤的,因此构造函数不能是虚函数。

静态成员函数不能是虚函数,因为静态成员函数是属于类的,不属于对象,⽽虚函数是属于对象的,因此静态成员函数不能是虚函数。静态函数没有this指针, static成员不属于任何类对象或类实例。虚函数需要通过 this ->vptr->vtable->func

4. 虚函数的弊端

  1. 虚函数会增加内存开销,因为每个对象都需要存储⼀个虚函数表指针。
  2. 虚函数会降低程序的运⾏效率,因为虚函数的调⽤是动态绑定的,需要在运⾏时查找虚函数表,然后再调⽤对应的函数。
  3. 虚函数会使得内存布局变得复杂,因为虚函数表是⼀个全局的数据结构,⽽不是对象的⼀部分,因此会使得内存布局变得复杂。

5. 虚函数可以不加virtul吗

不可以,虚函数必须使用 virtual 关键字进行声明,否则编译器将不会将其视为虚函数,动态绑定的时候会出错误。

下面是一个会出错的例子:

#include <iostream>

class Base {
public:
void func() {
std::cout << "Base::func()" << std::endl;
}
};

class Derived : public Base {
public:
void func() {
std::cout << "Derived::func()" << std::endl;
}
};

在这个例子中,Base 类中的 func 函数没有使用 virtual 关键字进行声明,因此在使用基类指针指向派生类对象时,调用 func 函数时,会调用基类的 func 函数,而不是派生类的 func 函数。

6. 虚函数表是怎么实现的

编译器为每个包含虚函数的类生成一个虚函数表,其中存储了该类中所有虚函数的地址。虚函数表通常是一个数组,每个元素存储一个虚函数的地址。

对象的内存布局中添加一个指向虚函数表的指针,称为虚函数指针(VPointer)。虚函数指针通常是对象的第一个成员变量,因为它需要在对象的构造函数中进行初始化。

在调用虚函数时,编译器会根据虚函数指针找到对应的虚函数表,并根据函数在虚函数表中的位置来调用正确的函数。具体来说,编译器会将虚函数调用转换为对虚函数指针所指向的虚函数表中的函数地址的调用。

6.1 用 C 语言实现虚函数表

下面是一个用 C 语言实现虚函数表的例子:

#include <stdio.h>
#include <stdlib.h>

// 函数指针类型, 用于存储虚函数地址
typedef void (*FuncPtr)();

// 基类
typedef struct
{
FuncPtr *vtable;
int data;
} Base;

void Base_func()
{
printf("Base::func()\n");
}

void Base_init(Base *base)
{
// 初始化虚函数表
base->vtable = (FuncPtr *)malloc(sizeof(FuncPtr));
// 将虚函数地址写入虚函数表
base->vtable[0] = Base_func;
base->data = 0;
}

// 派生类
typedef struct
{
Base base;
int data;
} Derived;

void Derived_func()
{
printf("Derived::func()\n");
}

void Derived_init(Derived *derived)
{
Base_init((Base *)derived); // 调用基类的初始化函数
derived->base.vtable[0] = Derived_func; // 重写虚函数
derived->data = 0;
}

int main()
{
Derived derived;
Derived_init(&derived);
((FuncPtr)(derived.base.vtable[0]))();
return 0;
}

在这个例子中,我们定义了一个基类 Base 和一个派生类 Derived,并为它们分别实现了 func 函数。在 Base 类中,我们定义了一个虚函数表 vtable,并在 Base_init 函数中初始化了虚函数表。在 Derived 类中,我们继承了 Base 类,并在 Derived_init 函数中重写了 func 函数,并修改了虚函数表中的函数地址。在 main 函数中,我们创建了一个 Derived 类的对象,并调用了 func 函数。

7. 类的成员模版函数可以是虚函数吗

类的成员模板函数可以是虚函数。在C++中,虚函数是一种特殊的成员函数,用于实现多态性。如果一个类中的函数被声明为虚函数,那么在该类的派生类中,可以通过重写该函数来实现不同的行为。而成员模板函数是一种泛型编程机制,可以根据不同的类型参数生成不同的代码。因此,成员模板函数可以与虚函数结合使用,实现更加灵活和通用的代码。

8. 虚表存放的位置

虚表存放的位置和虚表的大小是由编译器和操作系统决定的,不同的编译器和操作系统可能有不同的实现方式。一般来说,虚表存放在对象的内存布局中,通常是在对象的开头或结尾处。

note

虚函数表存在哪里?一个类一个还是一个对象一个?

虚函数表存放在类的内存布局中,通常是在类的开头或结尾处。每个对象都包含一个指向虚函数表的指针,该指针指向类的虚函数表。当调用虚函数时,编译器会根据对象的虚函数表指针找到对应的虚函数表,并根据函数的索引在虚函数表中查找函数的地址,然后调用该函数。

因为每个类都有一个对应的虚函数表,所以虚函数表是类级别的,而不是对象级别的。也就是说,所有属于同一个类的对象共享同一个虚函数表。这是因为虚函数表中存储的是类的虚函数地址,而不是对象的虚函数地址。因此,无论创建多少个对象,它们都共享同一个虚函数表。

9. 虚函数的大小

虚表的大小取决于类中声明的虚函数的数量和类型,每个虚函数在虚表中占用一个指针大小的空间。