C++虚函数详解

作为一个写了几年C++的程序员,虚函数这个东西算是又爱又恨。爱的是它让代码能灵活应对各种复杂场景,恨的是初学那会踩过的坑,至今想起来都脑壳疼。今天就尽量用大白话,把虚函数的来龙去脉掰扯清楚。

一、为什么需要虚函数?先从一个反例说起

假设我们要写一个图形类的程序,先定义一个基类Shape,里面有个计算面积的函数calcArea(),然后派生出Circle(圆形)和Rectangle(矩形)两个子类,各自实现自己的面积计算逻辑:

cpp

复制代码

#include

using namespace std;

class Shape {

public:

void calcArea() {

cout << "这是基类的面积计算" << endl;

}

};

class Circle : public Shape {

private:

double radius;

public:

Circle(double r) : radius(r) {}

void calcArea() {

cout << "圆形面积:" << 3.14 * radius * radius << endl;

}

};

class Rectangle : public Shape {

private:

double width, height;

public:

Rectangle(double w, double h) : width(w), height(h) {}

void calcArea() {

cout << "矩形面积:" << width * height << endl;

}

};

看起来没问题对吧?那我们写个测试函数试试:

cpp

复制代码

void printArea(Shape* shape) {

shape->calcArea();

}

int main() {

Circle c(5);

Rectangle r(4, 6);

printArea(&c);

printArea(&r);

return 0;

}

你猜输出是什么?不是我们期望的圆形和矩形面积,而是两行"这是基类的面积计算"。这就是C++默认的"静态绑定"------编译器在编译的时候,只看指针的类型是Shape*,就直接绑定了基类的calcArea(),不管实际指向的是哪个子类对象。

这显然不符合我们的需求:明明传的是圆形对象,却调用了基类的方法。这时候就需要虚函数登场了,它的核心作用就是实现"动态绑定"------程序运行时,根据指针实际指向的对象类型,调用对应的方法。

二、虚函数的基本用法:加个virtual就行

只需要在基类的函数前面加个virtual关键字,问题就解决了:

cpp

复制代码

class Shape {

public:

virtual void calcArea() { // 加virtual变成虚函数

cout << "这是基类的面积计算" << endl;

}

};

再运行刚才的测试代码,输出就变成了:

复制代码

圆形面积:78.5

矩形面积:24

完美!这时候编译器就不会在编译时绑定函数了,而是在运行时根据对象的实际类型来调用对应的calcArea()。

这里要注意几个点:

子类重写虚函数时,virtual关键字可以省略(编译器会自动识别),但建议加上,代码可读性更好。

重写的函数签名必须和基类完全一致(返回值、函数名、参数列表都要一样),不然就变成"重载"而不是"重写"了。

如果基类的虚函数是纯虚函数(后面会讲),子类必须重写它,否则子类也会变成抽象类,不能实例化对象。

三、虚函数的底层原理:虚表和虚指针

很多人用了好几年虚函数,却不知道它是怎么实现的。其实原理说穿了也简单,就是靠"虚表"(vtable)和"虚指针"(vptr)。

当一个类包含虚函数时,编译器会为这个类生成一个虚表,里面存着该类所有虚函数的地址。同时,每个对象的内存布局最前面,会多一个虚指针,指向这个类的虚表。

比如刚才的Shape类,它的虚表里存着Shape::calcArea()的地址;Circle类继承后,会重写calcArea(),所以它的虚表里存的是Circle::calcArea()的地址;Rectangle类同理。

当我们用Shape*指针指向Circle对象时,指针访问的是对象开头的虚指针,通过虚指针找到Circle类的虚表,再从虚表里取出Circle::calcArea()的地址,最后调用这个函数。这就是动态绑定的过程。

这里有个小细节:如果子类没有重写某个虚函数,那它的虚表里对应的位置,会继承基类的虚函数地址。比如如果Shape还有个draw()虚函数,Circle没重写,那Circle的虚表里draw()的地址就是Shape::draw()的地址。

四、纯虚函数和抽象类

有时候我们的基类只是个"模板",本身不需要实例化对象,比如Shape,现实中不存在一个"形状"对象,只有具体的圆形、矩形。这时候就可以把基类的虚函数定义为"纯虚函数":

cpp

复制代码

class Shape {

public:

virtual void calcArea() = 0; // 纯虚函数,=0表示没有实现

};

包含纯虚函数的类叫做"抽象类",抽象类不能实例化对象,比如Shape s;这样写编译器会报错。但可以定义抽象类的指针或引用,用来指向子类对象。

子类必须重写所有纯虚函数,否则子类也会变成抽象类。比如Circle必须实现calcArea(),不然Circle c(5);也会报错。

纯虚函数的作用就是强制子类实现特定的方法,相当于定义了一个"接口",保证所有子类都有统一的行为。

五、虚析构函数:最容易踩的坑

虚函数的另一个重要应用是虚析构函数。假设我们有这样的代码:

cpp

复制代码

class Base {

public:

~Base() {

cout << "Base析构函数" << endl;

}

};

class Derived : public Base {

private:

int* data;

public:

Derived() {

data = new int[10];

}

~Derived() {

delete[] data;

cout << "Derived析构函数" << endl;

}

};

int main() {

Base* p = new Derived();

delete p;

return 0;

}

运行后输出只有"Base析构函数",Derived的析构函数没被调用!这就导致data指向的内存泄漏了,因为delete p的时候,编译器只看到p是Base*,就只调用了基类的析构函数,子类的析构函数没执行。

解决方法就是把基类的析构函数设为虚函数:

cpp

复制代码

class Base {

public:

virtual ~Base() { // 虚析构函数

cout << "Base析构函数" << endl;

}

};

这时候再运行,输出就是:

复制代码

Derived析构函数

Base析构函数

先调用子类的析构函数,再调用基类的,内存就不会泄漏了。所以只要你的类可能被继承,并且子类有动态分配的内存,就一定要把基类的析构函数设为虚函数。

六、虚函数的常见误区

构造函数不能是虚函数:因为对象构造时,虚指针还没初始化,无法进行动态绑定。而且构造函数是用来创建对象的,虚函数的意义是运行时多态,这时候对象都还没创建,显然没必要。

静态成员函数不能是虚函数:静态成员函数属于类,不属于对象,没有this指针,而虚函数的动态绑定依赖于对象的虚指针,所以静态函数不能是虚函数。

内联函数可以是虚函数,但运行时多态时不会内联:如果直接调用对象的虚函数,编译器可能会内联;但如果通过指针或引用调用,因为要动态绑定,编译器无法确定调用哪个函数,所以不会内联。

虚函数的开销:虚函数会增加对象的内存(多了个虚指针),调用时也会多一次虚表查找的开销。但现在的编译器优化得很好,这个开销几乎可以忽略不计,除非你写的是对性能要求极高的代码(比如实时系统),否则不用太在意。

七、总结

虚函数是C++实现多态的核心机制,它通过动态绑定让程序能根据对象的实际类型调用对应的方法,大大提高了代码的灵活性和可扩展性。

记住几个关键点:

基类中加virtual的函数就是虚函数,子类可以重写它。

纯虚函数(=0)用来定义抽象类,强制子类实现方法。

基类析构函数一定要设为虚函数,避免内存泄漏。

底层靠虚表和虚指针实现动态绑定。

Copyright © 2022 ZGC网游最新活动_热门游戏资讯_玩家互动社区 All Rights Reserved.