讨论/技术交流/分享|剖析C++高频面试题 | 了解C++的多态实现,了解 vptr、vtbl 吗?/
分享|剖析C++高频面试题 | 了解C++的多态实现,了解 vptr、vtbl 吗?

在C++里面,实现多态有三种方式:

  • 动态多态,即以虚函数实现
  • 静态多态:基于函数重载、模板实现

本期,主要讲解基于虚函数实现的动态多态。

  • 本文更好的阅读体验,可以点击:走近vtpr、vtbl,揭秘动态多态
  • 更多硬核知识,vx搜一搜: look_code_art,更多硬核等你发现,
  • 也可以添加个人 vx: fibonaccii_
  • 多态:由基类指针,调用基类或者子类的成员函数
  • 动态,即无法在编译期确定是基类对象还是子类对象调用了虚成员函数vf,需要等待执行期才能确定。而将vf设置为虚成员函数,只需要在vf的声明前,加上virtual关键字。

比如,在下面的demo中,在成员函数print前面加上了virtual关键字,print函数就变成了虚成员函数。然后通过基类指针对象base来调用print函数,,即base->print()print最终输出的是 Base 还是 Derived取决于基类指针对象base指向的是基类对象还是子类对象。

class Base {
public:
  Base() = default;
  virtual 
  void print()       { std::cout <<"Actual Type:  Base" << std::endl; }
  void PointerType() { std::cout <<"Pointer Type: Base"<< std::endl;}
  virtual ~Base()    { std::cout <<"base-dtor"<< std::endl;}
};

class Derived : public Base{
public:
  Derived() = default;
  void print()       { std::cout <<"Actual Type:  Derived" << std::endl; }
  void PointerType() { std::cout <<"Pointer Type: Derived"<< std::endl;}
  ~Derived()         { std::cout <<"derived-dtor ";}
private:
   int random_{0};
};

int main(int argc, char const *argv[]) {
  Base* base = new Derived;  // base指向子类对象
  base->print();
  base->PointerType();
  delete base;

  std::cout<<"---"<<std::endl;

  base = new Base;          // base指向基类对象
  base->print();
  base->PointerType();
  delete base;
  return 0;
}

输出如下:

$ g++ virtual.cc  -o v && ./v
Actual Type: Derived
Pointer Type: Base
derived-dtor base-dtor
---
Actual Type: Base
Pointer Type: Base
base-dtor

从输出可以看出,只有加上了virtual关键字的print函数,才具有多态性质,即base->print调用的可能是基类的Base::print,也可能是子类的Derived::print,具有是哪个,由执行期base指向的对象确定。

而没有加上virtual关键字的PointerType函数,在编译期就能确定是Base::PointerType

vtbl & vptr

那么,编译器是如何让实现动态多态?

虚函数表(Virutal Function Table,vtbl)、虚函数指针(Virutal Function Pointer,vtpr)。

编译器会为每个存在虚函数(包括继承而来的虚函数)的类对象中插入一个指针vtpr,该vtpr指向存储虚函数的虚函数表vtbl。vtbl就是个数组,每个槽存储的都是虚函数的地址。

vtbl.jpg

以上面的BaseDerived类为例,他们都是空类,但是会因为vtpr的存在,对象大小变成一个指针大小(X64平台为8个字节)。

int main(int argc, char const *argv[]) {
    
  std::cout<< sizeof(Base)<< std::endl;
  std::cout<< sizeof(Derived)<< std::endl;
  return 0;
}

编译输出如下:

$ g++ virtual.cc  -o v && ./v
8
8

现在,我们知道了动态多态是通过vtpr、vtbl实现的。下面我们进一步讨论下,动态多态从编译到运行,哪些任务是在编译期完成,哪些任务是在执行期决议的。

我们仍然以上面的一段demo为例:

 Base* base = new Derived; 
 base->print();

base->print()会被编译器大致转换为:

(*base->vptr[0])(base)
  • vtpr是指向虚函数表的指针
  • 0是虚函数print在vtbl中的索引

编译期确定:索引0在编译期就能确定,即在编译期就能确定待调用函数print在vtbl中位置,进而取出print的地址。这个索引,就是按照虚函数声明的顺序确定,比如print是第一个声明的,那么它在vtbl中的索引位置就是0。

执行期确定:真正在执行期才能确定的是base指向的对象,是 Base类的对象,还是 Derived类的对象。

by the way

转换后的结果中的第二个base,代表的是this指针,因为任何类的成员函数都是要转换为非成员函数,因此要在成员函数的第一个参数位置插入this指针。

void PointerType();

// 转换后,会在第一个参数位置插入this指针
// 函数名也会经过name mangleing操作
void PointType__base(Base* this);

复现多态

下面,那我们就来获取具有虚函数的对象中的vtpr、vtbl,再来直接调用虚成员函数。

我们已经知道了vtbl是个表格数组,它的每个槽都存储的是虚函数的地址。在C/C++中,数组可以使用指针来表征,并且地址可以使用uintptr_t类型来表示。因此:

  • vtbl:vtbl的类型可以表达为uintptr_t*,表示vtbl是一个数组,数组的每个元素类型都是 uintptr_t
  • vtpr:vtpr指向vtbl,因此 vtpr的类型是uintptr_t**,表示指针vtpr指向的类型是uintptr_t*

另一方面,在GCC中,vtpr是被放置在内存模型中的第一个位置,即Derived对象的内存模型如下:

class Derived {
public:
	//...
    
private:  
  uintptr_t** vptr;
  int random_{0};
};

因此,vtpr的地址和Derived对象地址一致。因此,我们可以直接通过基类、子类对象来获取vtblvtpr并调用虚成员函数,更加详细地查看编译器的运行过程。

下面以虚成员函数print为例,通过 getVirutalFunc 函数来获取vtprvtbl进而调用print函数:

using FuncType = void (*)(); 	// print函数类型的 函数指针	

FuncType getVirutalFunc(Base* obj, uint64_t idx) { 

  uintptr_t** vptr  = reinterpret_cast<uintptr_t**>(obj);     // 1)先取出vtpr
  uintptr_t*  vtbl  = *vptr;                                  // 2)vptr指向的是vtbl,因此 vtbl 即 *vptr
  uintptr_t   func  = *vtbl;                                  // vtbl存储的第一个虚函数

  // 返回指定位置的虚函数
  return reinterpret_cast<FuncType>(func + idx);   			// 3)      
}

int main(int argc, char const *argv[]) {
  Base* base = new Derived; 
  // 编译器完成调用 
  base->print();		   
  // 我们自己调用
  auto print = getVirutalFunc(base, 0);	// 指向print函数的函数指针
  print(); 	// 调用print函数

  delete base;
  return 0;
}

编译运行的输出:

$ g++ virtual.cc  -o v && ./v
Actual Type:  Derived
Actual Type:  Derived
derived-dtor base-dtor

我们来复盘下, getVirutalFunc 函数对应着编译器从编译到执行一个虚成员函数的过程,那getVirutalFunc函数的三步中哪些是在编译器完成的呢,哪些在执行期才能完成的呢?

  • 编译期:很明显,getVirutalFunc函数的第二个参数idx在编译期就可以确定;
  • 执行期:obj指向的是基类对象还是父类对象不确定。因此,根据obj取得的vtpr不知道是基类的还是子类的,这会对后续的vtbl产生影响。

为了验证这一观点,我们让Derived的构造函数输出Derived对象地址:

class Derived : public Base{
public:
  Derived() {std::cout<<"Derived: "<<this<<std::endl;};
 //...
}

int main(int argc, char const *argv[]) {
  Base* base = new Derived; 
  std::cout<<"base:    "<< base <<std::endl;

  base->print();
  getVirutalFunc(base, 0)(); // print()

  delete base;
  return 0;
}

输出如下:

$ g++ virtual.cc  -o v && ./v
Derived: 0x7fffc2c64eb0
base:    0x7fffc2c64eb0
Actual Type:  Derived
Actual Type:  Derived
derived-dtor base-dtor

发现什么没有? main函数中创建的基类指针base指向的子类Derived对象的内存地址,这就使得 getVirutalFunc 函数中取得的vtpr、vtbl是子类的,那么就能调用子类的print函数,完成动态绑定。

我们再想一想,子类的vtbl和基类的vtbl真是不同一个虚函数表吗?

回答这个问题很简单,只是需要输出子类和基类的vtbl地址即可。

class Base { 
public:
  Base() { Base::showVtbl(this, "Base   "); };
  virtual ~Base() =default;
    
  static void showVtbl(Base* obj, const char* type) { 
    uintptr_t** vptr  = reinterpret_cast<uintptr_t**>(obj);     
    uintptr_t*  vtbl  = *vptr;
    std::cout<<type<<"  vtbl: "<<vtbl<<std::endl;
  }
};

class Derived : public Base { 
public:
  Derived(){  Base::showVtbl(this, "Derived"); }
};

int main(int argc, char const *argv[]) {

  Derived derived{};
  return 0;
}

编译并执行输出:

$ g++ vir.cc -o v && ./v
Base     vtbl: 0x7fa2f3804d20
Derived  vtbl: 0x7fa2f3804cf8

从输出结果可以看出,确实不是一个。

override

最后,再提下C++11引入的关键字override

在子类重写父类的虚函数vf时,可能会因为不小心导致子类重写的虚函数与基类的虚函数不完全一致,此时编译器会将写错(这里的错,是指函数名、参数等与基类的虚函数不一致)的函数,决议为新的函数,而不会报错,最终的结果是未预料的。

因此,为了确保子类重写的虚函数与基类的保持一致,C++11引入了override关键字,如果基类中没有这个虚函数,那么编译器就会报错。

比如下面的demo:

class Base { 
public:
  Base() = default;
  virtual ~Base() = default;

  virtual void func_1() { std::cout<<"base:::func_1"<<std::endl; }
  virtual void func_2(int i, double d) { std::cout<<"base:::func_2"<<std::endl; }
};

class Derived : public Base { 
public:
  Derived() = default;

  void func_1() override { std::cout<<"Derived::func_1"<<std::endl;}
  void func_2(int i, float f) override { std::cout<<"Derived::func_1"<<std::endl;}
};

int main(int argc, char const *argv[]) {
  Base* base = new Derived;
  delete base;
  return 0;
}

编译输出:

$ g++ vir.cc -o v && ./v
vir.cc:18:8: error: ‘void Derived::func_2(int, float)’ marked ‘override’, but does not override
   18 |   void func_2(int i, float f) override { std::cout<<"Derived::func_1"<<std::endl;}
      |        ^~~~~~

编译直接报错,基类中没有提供 func_2(int i, float f) 函数。

因此,一个良好的编码习惯,应该在每个子类重写的虚函数后,加上 override关键字,防止一些可预防的bug。


23
共 8 个回复

嘿嘿 谢谢醒佬

支持~

不客气 一起交流

行,回头我拿gdb也看看去,长知识了谢谢了

这个是可以不加的 调用默认构造函数

为了验证这一观点,我们让Derived的构造函数输出Derived对象地址:

class Derived : public Base{
public:
Derived() {std::cout<<"Derived: "<<this<<std::endl;};
//...
}

int main(int argc, char const argv[]) {
Base
base = new Derived;
请问一下最后一句是构造函数吗 为什么Derived后面不加括号

我通过gdb调试看了下 vtbl 里面只有虚函数地址 ,vtbl的最后一个元素是0x00 应该是用来标识结束的

请问一下,虚函数表里面除了虚函数地址还有什么?