Skip to Content

谈谈C++为什么使用虚函数表的个人理解

Table of Contents

虚函数表是C++面试常考问题之一了,看了下《深度探索C++对象模型》,个人感觉了解虚函数表对理解C++对象在内存中的存储有一定帮助,但是对提高代码质量的意义貌似仅仅在于关注对象中虚函数指针的存在。

面向对象中的多态与运行时捆绑

多态

多态是面向对象编程的基本特征之一,在C++中表现为通过虚函数和继承,实现基类指针的调用实现不同的行为,如下例。

Logger为作为接口的基类,只有一个函数log()用于日志的记录,子类FileLogger和DataBaseLogger分别实现将日志写入到文件和数据库两种行为,代码如下。


// 基类接口,调用log函数记录日志
class Logger{
  public:
  virtual void log(string str) = 0;
}

class FileLogger{
  public:
  void log(string str){ /* 日志写入文件的实现 */ }
}

class DataBaseLogger{
  public:
  void log(string str){ /* 日志写入数据库的实现 */};
}    

通过传入不同的对象,可以实现不同的行为,如下面的代码:

void writeLog(Logger *logger){
  logger.log();
}

// 仅举例,省略内存管理
Logger *logger = new FileLogger();
writeLog(logger);
Logger *logger2 = new FileLogger();
writeLog(logger2);

运行时捆绑(runtime binding)

在一个典型的工厂模式中,可以通过 Logger *logger = LoggerFactory.getLogger() 的方式,在工厂类中根据具体的需要创建对象。

比如:

class LoggerFactory{
  public:
    // 忽略内存管理,通过loggerId获取对应的子类对象
    static Logger* getLogger(int loggerId = 0){
      if(loggerId == 0)
        return new FileLogger();
      else
        return new DataBaseLogger();
    }
}

Logger *logger = LoggerFactory.getLogger();
writeLog(logger);

例子中的getLogger()直接使用了默认的参数,**而实际的工程代码可以通过配置文件或其它的输入方式得到,也就是在代码的编译器无法预测具体绑定的子类类型。**这种情况可以被看作是运行时捆绑。

使用运行时捆绑在代码实现上的优点主要在于代码可以将基类和子类的编译作为不同的模块分开,子类通过基类接口实现基本的代码逻辑后单独编译,子类的修改不会导致基类的重新编译(但基类的修改仍会导致子类的重新编译)。并且在具体的实现中可以通过修改配置文件等方式修改具体的实现逻辑,而不需要像早捆绑(early binding)那样重新编译代码。在大的工程项目中,这两种模块化隔离的方式成为很大的优势。

C++类对象在内存中的存储

简单类存储

一个简单的类如下:

class A{
  int m;
  void fun(){cout<<m<<endl;}
}

对于对象A a1 = new A(),在内存中怎么存储?

假设基于A类型new了a1、a2、a3个对象,每个对象中存储的m都相对独立,因此m肯定需要单独存储。fun()函数在每个对象中执行的方式相同,因此可以共用。C++使用了这一存储方式,类A的每一个对象在内存中都只存储1个int类型的值(忽略字节对齐),成员函数作为共用的对象和static对象存储一次。

虚函数的存储问题

上面的简单类A中没有包含虚函数,不需要考虑多态的问题,使用到的对象成员变量和成员函数编译器都能明确得到分配的地址。而在前文提到的writeLog(Logger logger)函数实现中,由于运行时绑定的要求,编译器需要在不知道子类实现的情况下编译这部分代码,编译器只能通过参数中的logger地址得到具体的实现过程。注解代码如下:

// 为了满足运行时绑定要求,这一函数需要能在不知道子类的情况下单独编译
void writeLog(Logger *logger){
  logger.log("a log");  
  // 这个地方由于Logger类中的log函数标记为virtual类型,会调用子类的函数
  // 而此处的子类类型只能在运行时获取,编译器独立编译此模块时并不知道具体的子类实现
  // 因此编译器需要通过logger这一对象的地址得到子类对应的虚函数实现
}

虚函数表的存储方式

c++的标准里并没有定义具体的实现方式,虚函数表只是大多数c++编译器选择的实现方式之一。

如果通过上面的简单类存储方法,每个类对象在内存中只存储成员变量,在获取对象地址后仅通过成员变量值显然无法得到具体的子类函数或子类类型。c++通过虚函数表(virtual table,vtbl)的形式实现,当类中涉及虚函数时,每个对象会额外存储一个虚函数指针(vptr)。vptr在对象构建时赋值,指向每个类的虚函数表存储在内存中的地址。

logger.log("a log") 调用时,会通过logger指向的子类对象获取对象的vptr,得到虚函数表所在的地址,然后在虚函数表中通过偏移量找到需要调用的具体函数地址。

虚函数表的优点和缺点

通过虚函数表的方式实现多态,主要优点在于上面的运行时绑定,实现程序在运行时根据具体的需要选择具体的实现子类,并且避免了子类的改变导致整个代码的重新编译。

付出的开销也很明显,对虚函数的调用需要额外的一次寻址,并且在每个包含虚函数的对象里都需要额外的存储一个指针。当不使用多态时,成员函数的调用可以在编译器直接得到成员函数的地址,因此在函数调用中可以直接跳到对应的位置;而虚函数表的方式,需要先读取vptr中的地址,跳到对应的函数地址,增加了一次寻址开销。