简介
虚函数是 C++ 中的一种成员函数,它的开头用关键字 “virtual” 进行修饰。虚函数是用于实现运行时多态的一种方式。
虚函数的简单例子
class Base {
public:
virtual void foo();
};
class Derived : public Base {
public:
void foo();
}
在以上例子中,Base 声明了一个虚函数 foo,它的派生类 Derived,重载了这个 foo 函数。在运行时,如果指针或者引用指向的将是 Base,那么调用的将是 Base::foo;如指针或者引用指向的是 Derived 对象,那么调用的将是 Derived::foo。
运行时多态
class Base {
public:
virtual void foo() { std::cout << "Base foo" << std::endl; };
};
class Derived : public Base {
public:
void foo() { std::cout << "Derived foo" << std::endl; };
};
int main() {
Derived d;
Base *b1 = &d;
Base &b2 = d;
b1->foo();
b2.foo();
return 0;
}
Output:
Derived foo
Derived foo
在上面的例子中,我们将 Base 类型的指针和引用指向 Derived 对象,在调用 foo 的时候,实际调用的是 Derived::foo,这就是运行时多态。当有函数的接口只接受基类对象的指针类型的时候,我们可以传入实际指向子类对象的指针,并且可以成功调用子类对象的成员函数。
虚函数表
现代编译器是如何实现基于虚函数的运行时多态的呢?C++标准并没有给出具体的规范,不过大多数的编译器都是通过虚函数表来实现的。具体地说,如果基类有虚函数的话,那么编译器会给基类和派生类都生成一个指针成员变量,这个指针指向的是虚函数表。比方说 Base 类的虚函数表中会有一个记录,将 void foo() 这个函数指向 Base::foo 的函数地址;而在 Derived 类的虚函数表中会有一个记录,将 void foo() 这个函数指向 Derived::foo 的函数地址。因此,在实际调用的时候,根据实际的对象,将会得到实际的虚函数表,也就是得到实际的虚函数地址。
虚函数表增加类的内存占用
通过计算类的大小可以让我们一窥虚函数表的存在:
#include <iostream>
class Base1 {
public:
virtual void foo();
};
class Derived1 : public Base1 {
public:
void foo();
};
class Base2 {
public:
int data;
};
class Derived2 : public Base2 {};
int main() {
std::cout << "Size of void*: " << sizeof(void*) << std::endl;
std::cout << "Size of Base1: " << sizeof(Base1) << std::endl;
std::cout << "Size of Derived1: " << sizeof(Derived1) << std::endl;
std::cout << "Size of Base2: " << sizeof(Base2) << std::endl;
std::cout << "Size of Derived2: " << sizeof(Derived2) << std::endl;
return 0;
}
Output:
Size of void*: 8
Size of Base1: 8
Size of Derived1: 8
Size of Base2: 4
Size of Derived2: 4
在上面的例子中,Base1 和 Derived1 因为有虚函数表指针的存在,所以它们的大小是 8 byte;作为对比,Base2 和 Derived2 只有 int 类型的成员变量,因此它们的大小是 4 byte。
那么,如果 Derived1 不对 foo 进行重载,它还会有虚函数表指针吗?答案是肯定的,让我们看下面的例子:
#include <iostream>
class Base {
public:
virtual void foo() { std::cout << "Base foo" << std::endl; };
};
class Derived : public Base {};
int main() {
std::cout << "Size of Base: " << sizeof(Base) << std::endl;
std::cout << "Size of Derived: " << sizeof(Derived) << std::endl;
Derived d;
Base *b = &d;
b->foo();
return 0;
}
Output:
Size of Base: 8
Size of Derived: 8
Base foo
在上面的例子我们可以看到,即使没有重载虚函数 foo,Derived 的大小依然是 8 byte,说明它依然有虚函数表指针。只不过,现在 Drived 的虚函数表中指向的是唯一存在的 Base::foo。
虚析构函数
当基类有虚函数的时候,需要同时确保基类有虚析构函数,不然可能会出现未定义的结果。这是为什么呢?让我们看下面的例子:
#include <iostream>
class Base {
public:
virtual void foo() {};
~Base() { std::cout << "Base destructor" << std::endl; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destructor" << std::endl; }
};
int main() {
Derived *d = new Derived();
Base *b = d;
delete b;
return 0;
}
Output:
Base destructor
你看,如果我们把基类的指针传给 delete,delete 是不知道入参这个指针到底指向的是哪个对象的,所以 delete 只能根据入参指针的类型来调用这个类型对应的析构函数,在这个例子中,就是调用了 Base::~Base()。但是这样就有问题了,因为我们实际上创建的对象是一个 Derived 的对象,而 Derived::~Derived() 没有被调用,所以可能会出现内存泄漏或者其他未定义的问题。
为了避免这个问题,我们需要将基类的析构函数声明为虚函数,这样 delete 就可以通过入参指针的虚函数表来找到实际应该调用的析构函数。如下所示:
#include <iostream>
class Base {
public:
virtual void foo() {};
virtual ~Base() { std::cout << "Base destructor" << std::endl; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destructor" << std::endl; }
};
int main() {
Derived *d = new Derived();
Base *b = d;
delete b;
return 0;
}
Output:
Derived destructor
Base destructor
没有虚析构函数也不致命的场景
可不可以不使用虚析构函数呢?比方说以下例子:
int main() {
Derived *d = new Derived();
Base *b = d;
delete d; // pass d to delete
return 0;
}
Output:
Derived destructor
Base destructor
或者:
int main() {
Derived d; // do NOT allocate on heap, so no delete is called
Base *b = &d;
return 0;
}
Output:
Derived destructor
Base destructor
不过,作为一个好习惯,当基类有虚函数的时候,最好还是声明虚析构函数,这可以减少你很多的 debug 的时间。
未完待续
后续我们将通过 gdb/readelf/objdump 看一下虚函数表在内存中的表示,以及一种特别的虚函数:纯虚函数。
References
Demystifying Virtual Tables in C++ - Part 3 Virtual Tables
C++: Deleting destructors and virtual operator delete
C++: Deleting destructors and virtual operator delete - Eli Bendersky’s website