c++中的虚函数到底有什么用?需要注意什么?
前言
作为一个c++的初学者,这篇文章我想讲讲我对虚函数的看法和理解,也希望对其他朋友有所帮助,如果文章中有纰漏或者不足之处也欢迎各位指出。
虚函数的定义
虚函数是面向对象编程(特别是在 C++ 等语言中)中的一个核心概念。它允许你在子类中重新定义父类的方法,并且确保在程序运行时,系统能够根据对象的实际类型(而不是定义类型)来决定调用哪个函数。这种行为被称为动态绑定或多态。
首先为什么会需要虚函数?
从上面解释可以看出来虚函数的作用是重新定义父类的方法,然后程序运行后可以根据实际对象来决定调用哪个函数(也就是说如果不使用虚函数会出现实际调用的对象函数并不是自己原本想调用的)。
出现这种情况的原因在于编译器的默认行为,C++的设计原则是“静态类型检查”和“高性能”。在编译阶段时,编译器看到一个类型的指针变量后(比如说Shape*指针),它的逻辑是:
1.这个指针将来可能指向一个Shape对象。
2.这个指针占有8个字节(64位系统),里面存储一个地址。
这个时候编译器就会去Shape类里面找对应的函数(比如说draw函数),然后把调用指令编译进去,而这个时候如果你把Shape的子类Circle对象的地址赋值给这个指针,编译器在编译过程中是并不知道这一点的(因为赋值是发生在运行期间)。所以说编译出来的指令依旧是调用Shape::draw的命令。这种在编译阶段就决定调用哪个函数的方式,叫做静态绑定(Static Binding)或早绑定,而虚函数就是为了解决这个问题设计的。
这里我们可以来看一下结果
#include <iostream> using namespace std; // 基类:形状 class Shape { public: void draw() { cout << "画一个通用的形状" << endl; } }; // 派生类:圆形 class Circle : public Shape { public: void draw(){ cout << "画一个圆形" << endl; } }; // 派生类:矩形 class Rectangle : public Shape { public: void draw() { cout << "画一个矩形" << endl; } }; int main() { Shape* s; // 一个基类指针 Circle c; // 圆形对象 Rectangle r; // 矩形对象 // 指向圆形 s = &c; s->draw(); // 输出: 画一个通用的形状 // 指向矩形 s = &r; s->draw(); // 输出: 画一个通用的形状 return 0; }可以看出来,当没有标注虚函数时,shape* 指针在调用函数时依旧使用的是Shape类的函数,而不是指针指向的实际对象,而在使用虚函数后
#include <iostream> using namespace std; // 基类:形状 class Shape { public: virtual void draw() { cout << "画一个通用的形状" << endl; } }; // 派生类:圆形 class Circle : public Shape { public: // 重写(Override)基类的 draw 函数 void draw() override { cout << "画一个圆形" << endl; } }; // 派生类:矩形 class Rectangle : public Shape { public: void draw() override { cout << "画一个矩形" << endl; } }; int main() { Shape* s; // 一个基类指针 Circle c; // 圆形对象 Rectangle r; // 矩形对象 // 指向圆形 s = &c; s->draw(); // 输出: 画一个圆形 // 指向矩形 s = &r; s->draw(); // 输出: 画一个矩形 return 0; }实际上,虚函数的作用就是通过virtual 关键字告诉编译器:"这个函数比较特殊,在程序运行时再去看实际指向对象,然后再调用方法。”由此来解决前面提到的问题。
既然编译器的静态绑定这么麻烦,为什么不直接都用动态绑定(默认都是虚函数)来解决呢?
这边涉及到了一个性能代价的问题。
1.虚函数的调用开销是很大的,在c++的设计中:
- 非虚函数:调用开销 ≈ 1-2个CPU指令,通常可以内联
- 虚函数:调用开销 ≈ 5-10个CPU指令,不能内联,阻碍编译器优化
// 假设我们有1000万个这样的调用 class Point { public: void getX() const { return x; } // 非虚函数 virtual void getY() const { return y; } // 虚函数 private: float x, y; }; void processPoints(Point* points, int count) { for(int i = 0; i < count; i++) { // 非虚调用:直接内联,可能就1条CPU指令 float x = points[i].getX(); // 虚函数调用:至少需要3步 // 1. 从对象取出vptr(内存访问) // 2. 从vtable取出函数地址(内存访问) // 3. 间接调用(不能内联,可能影响CPU分支预测) float y = points[i].getY(); } }2.内存布局的影响
设计都是虚函数的框架的话,每个对象都会多一个指针
class Empty { }; // sizeof(Empty) = 1字节(C++要求每个对象有唯一地址) class WithVirtual { virtual void foo() {} }; // sizeof(WithVirtual) = 8字节(64位系统的vptr大小) // 想象你有一个包含1000万个点的数组 Empty points1[10000000]; // 占用 10 MB WithVirtual points2[10000000]; // 占用 80 MB这在大规模项目下是非常致命的设计,会占用宝贵的缓存空间和内存空间等等。
3.C++的核心理念:"零开销原则"
C++的设计哲学是著名的"你不用为不需要的东西付出代价"。
// 如果所有函数都是虚函数,那么即使是这个简单的类 class IntWrapper { int value; public: int getValue() const { return value; } // 被迫成为虚函数 void setValue(int v) { value = v; } // 被迫成为虚函数 };这就会造成:
- 每个IntWrapper对象都多了8字节
- 每次get/set都要查虚函数表
- 完全违背了"简单包装int"的初衷
4. 设计清晰性:接口 vs 实现
虚函数是一种设计声明,它告诉其他程序员:"这个函数是设计用来被重写的"。
class DatabaseConnection { public: void connect(string url); // 非虚:具体的实现步骤 void executeQuery(string sql); // 非虚:固定的流程 virtual void log(string message); // 虚:可以自定义日志 virtual void onConnectionLost(); // 虚:可以自定义处理 };意图清晰:
- 非虚函数:"这就是我的实现方式,请直接使用"
- 虚函数:"这是一个扩展点,你可以根据需求定制"
如果所有函数都是虚的,这个重要的设计意图就丢失了。
使用虚函数需要注意哪些问题?
1.构造函数不写为虚函数
从存储空间角度:
虚函数需要用一个叫vtable的结构(虚函数表)来实现,它实际上是存储在对象内存(一般存储在对象的内存头部)中的一块指针或者表,用于动态绑定虚函数。构造函数是在对象创建(实例化)时调用的,在对象还没有创建好时,内存空间还没有分配好,这时候如果调用虚函数会发现无法找到vtable所在的地址(因为空间还没分配),所以构造函数不能是虚函数。
从使用角度:
虚函数的作用是通过父类的指针或者引用调用来动态绑定到子类的对应函数,而构造函数在对象创建时会自动调用,调用时还没有父类指针或者引用调用来指向字类对象。
实例1:虚函数调用与构造函数调用顺序
#include <iostream> using namespace std; class Base { public: Base() { cout << "Base 构造函数调用" << endl; // 这里调用虚函数 print(); } virtual void print() { cout << "Base::print()" << endl; } virtual ~Base() {} }; class Derived : public Base { public: Derived() { cout << "Derived 构造函数调用" << endl; } void print() override { cout << "Derived::print()" << endl; } }; int main() { Derived d; return 0; }运行结果:
Base 构造函数调用
Base::print()
Derived 构造函数调用
说明:
- 在构造Base时,print()被调用,但此时对象还没有成为完整的Derived对象,虚表指针还指向Base版本。
- 所以print()调用的是Base版本,而不是Derived版本。
- 这是因为构造函数调用时,对象还处于基类构造阶段,因此虚函数不表现多态。
解释: 如果构造函数是虚函数,调用时需要子类版本,但对象还没有构造完成,子类信息不可用,冲突出现。
2.析构函数为什么一般写为虚函数?
通过基类指针删除子类对象时,如果析构函数不是虚函数,程序只会调用基类的析构函数,导致子类独有的资源无法释放,造成内存泄漏。
先来看看是否为虚函数的区别:
- 静态绑定(无virtual):编译器根据指针的类型决定调用哪个函数
- 动态绑定(有virtual):程序根据对象的实际类型决定调用哪个函数
class Animal { public: ~Animal() { cout << "Animal析构" << endl; } // 非虚析构 }; class Dog : public Animal { public: Dog() { food = new string("骨头"); } // Dog特有资源 ~Dog() { delete food; // 释放Dog的资源 cout << "Dog析构" << endl; } private: string* food; }; int main() { Animal* pet = new Dog(); // 基类指针指向子类对象 delete pet; // 这里发生了什么? return 0; } //输出结果:Animal析构可以看得出来,Dog的析构函数没有被调用,food指针指向的内存永远无法被释放。
可以来看一下运行这段程序发生了什么
对象创建时(new Dog()): [Animal部分的数据] [Dog部分的vptr] [Dog特有的数据(包括food指针)] ^ ^ | | pet指向这里 Dog自己也需要这部分 对象析构时(delete pet): 情况A:析构函数不是虚函数 pet(Animal*)→ 编译器:调用Animal的析构函数 → 只清理了Animal部分 Dog的food指针 → 永远丢失了! 情况B:析构函数是虚函数 pet(Animal*)→ 运行时:通过vptr找到Dog的虚函数表 → 调用Dog::~Dog() Dog::~Dog()执行完后,自动调用Animal::~Animal() → 所有资源都被正确释放而加上virtual后:
class Animal { public: virtual ~Animal() { cout << "Animal析构" << endl; } // 虚析构! }; class Dog : public Animal { public: Dog() { food = new string("骨头"); } virtual ~Dog() { // 重写基类的虚析构函数 delete food; cout << "Dog析构" << endl; } private: string* food; }; int main() { Animal* pet = new Dog(); delete pet; return 0; }输出如下:
Dog析构
Animal析构
可以看出来,这里所有的资源都被正确释放。
析构函数的设计就像拆房子一样(从顶而下):
先清理子类:因为子类可能需要依赖父类的部分。
再清理父类:父类为基础部分。
3.什么时候析构函数不需要使用虚函数?
可以看看以下部分:
// 情况1:不需要(这是大多数情况) class Point { int x, y; public: ~Point() { } // 非虚析构就够用了 }; // 没人继承这个类,或者没人会通过基类指针删除它 // 情况2:需要(多态基类) class Shape { // 这个类设计出来就是为了被继承的 public: virtual void draw() = 0; virtual ~Shape() { } // 必须虚! }; // 情况3:特殊规则 class NoVirtualDtor { public: ~NoVirtualDtor() { } // 虽然没有虚函数,但有人可能继承它并通过基类指针删除 // 这很危险!应该避免这样使用 };总结经验就是:
- 如果类里有任何虚函数,析构函数也应该是虚函数
- 如果类被设计为基类(即使没有虚函数),最好也加上虚析构函数
- 如果类是final(不会再被继承),可以用非虚析构函数获得更好的性能
关于虚函数的部分到这里暂时就结束了,如果有需要补充后续会不定期更新。