尝试一篇文章让你理解 C++ 中的虚函数

序言

在正式进入虚函数的内容之前,强烈建议大家可以先学习一下 C++ 继承,本篇文章会涉及到其中的知识点 。在 C++ 继承 的学习中,我们知道 基类指针或者是引用是可以通过切片的方式指向派生类对象的,了解了这个知识之后,请看下段代码:

class A {
public:
	~A() {
		cout << "~A()" << endl;
	}
};

class B : public A {
public:
	~B() {
		cout << "~B()" << endl;
	}
};

void test_1() {
	A* p1 = new B;
	B* p2 = new B;

	delete p1;
	delete p2;
}

请让大家思考一下最后的打印结果是什么?
答案如下。大家答对了吗?

~A()
~B()
~A()

我们尝试理解打印的逻辑。我们先不看 p1 指针,我们先看 p2 指针。我们通过在 C++继承 学习可知道,派生类调用析构函数时会先调用自己的析构函数再调用基类的析构函数。所以倒数的 ~A()~B() 肯定是 p2 打印的结果,这说明 p1 只调用了基类的析构函数,p2 调用了派生类和基类的析构函数。
问题来了,如果我们的派生类中存在动态分配的资源(如 new),但是指针只调用了基类的析构函数,那岂不是造成了 内存泄漏
所以归根结底就是,我们需要基类指针或引用根据指向的对象来调用相应类型的析构函数,以确保所有从该类派生的对象都能够正确释放其资源,避免内存泄漏。那怎么实现呢?🤔


1. 什么是虚函数

1.1 虚函数的概念

虚函数是一个在基类中被声明为 virtual 的成员函数。其主要目的是为了在派生类中能够 重写 该函数,从而实现运行时 多态性。当通过基类指针或引用来调用一个虚函数时,将调用与指针或引用实际指向的对象类型相对应的函数版本,而不仅仅是基类中的版本。
虚函数总结具有以下特点:

  • 虚函数是实现多态性的关键。多态性允许使用基类的指针或引用来操作派生类对象,并调用派生类中重写的虚函数;
  • 与虚函数相关的函数调用是在 运行时确定 ,而不是在编译时。这意味着编译器在编译时不会直接确定调用哪个版本的函数,而是在运行时根据对象的实际类型来确定。
  • 如果基类中的函数被声明为虚函数,那么派生类中的 同名函数(具有相同的参数列表,返回值) 也会自动成为虚函数,构成 重写

1.2 虚函数的定义

使用 virtual 修饰该成员函数:

class A{
	virtual void Print() {}
};

2. 虚函数的作用

由虚函数的概念可知,虚函数的目的就是 指针或者是引用根据指向的对象来调用相应的函数,而不是根据自己的类型。就比如:

class A {
public:
	virtual void Print() {
		cout << "This is A." << endl;
	}
};

class B : public A {
public:
	virtual void Print() {
		cout << "This is B." << endl;
	}
};

void test_1() {
	A* p1 = new A;
	p1->Print();
	A* p2 = new B;
	p2->Print();
}

这里打印的结果是:

This is A.
This is B.


2.1 重写

在基类中我们定义虚函数的目的,就是为了在派生类中重写该函数构成多态。两个函数需要构成重写的条件是较为严格的:

  • 一父一子:派生类提供一个与基类中的虚函数相同的函数的行为
  • 函数名相同:重写的函数必须与基类中的虚函数有相同的函数名(析构函数为例外)
  • 参数列表相同:两者的参数类型、数量和顺序需要相同
  • 返回类型相同或协变:返回类型必须相同,或者是基类的返回类型的子类(协变)

这里的 Print() 函数就是一个简单的重写

class A {
public:
	virtual void Print() {
		cout << "This is A." << endl;
	}
};

class B : public A {
public:
	virtual void Print() {
		cout << "This is B." << endl;
	}
};

2.2 解决析构函数的缺陷

在序言中我们提出了一个问题,怎么能够让 指向派生类对象的基类指针调用派生类的析构函数。现在有办法了,把基类的析构函数定义为虚函数,让两者的析构函数构成重写。
有人肯定就有疑问了,构成重写不是需要函数名相同吗?析构函数必须命名为 ~classname 怎么构成重写?你能想到的问题,肯定有人踩过坑了。所以,在编译的时候会将析构函数的名称替换为 destructor,这样就保证了重写的条件。
那我们试验一下序言中的程序,再次运行:

class A {
public:
	virtual ~A() {
		cout << "~A()" << endl;
	}
};

class B : public A {
	......
};

void test_1() {
	A* p1 = new B;
	B* p2 = new B;

	delete p1;
	delete p2;
}

这次的结果是:

~B()
~A()
~B()
~A()

成功实现了我们的需求,根据指向的对象调用相应的析构函数 😀。所以,继承时,如果派生类中存在动态分配的资源,最好将析构函数构造为虚函数。

2.3 关键词 final 和 override

final 修饰一个成员函数,代表该函数不可以被重写。就比如:

class A {
public:
	virtual void Print() final {
		cout << "This is A." << endl;
	}
};

如果继承了 A 的类想要重写 Print() 就会报错,表明该方法不可重写。
override 修饰一个成员函数,代表你想要重写该函数,如果没有达到重写的条件就会报错。就比如:

class A {
public:
	virtual void Print() {
		cout << "This is A." << endl;
	}
};

class B : public A {
public:
	virtual int Print() override{
		cout << "This is B." << endl;
	}
};

我想要将 AB 中的 Print() 构成重写,但是不小心将返回值写成了 int。编译器就会提醒我,返回类型不同也没有构成协变无法构成重写。


3. 虚函数的原理

如果你只是想要知道虚函数的使用,那么上述的内容应该能满足你的需求 😊。但是我觉得如果你能深层次的理解其中背后的原理,肯定会让你更加的了解他,并且产生新的认识😎。

3.1 虚表

这里有两个类,一个类包含了虚函数,一个类不包含虚函数:

class A {
public:
	virtual void Print() {
		cout << "This is A." << endl;
	}

	int _a = 0;
};

class B{
public:
	int _b = 0;
};

void test_1() {
	A a;
	B b;
}

我们调试一下,查看一下两个对象在内存中有什么不同:
在这里插入图片描述
可以看出含有虚函数的对象多存储了一个 _vfptr。这里就不让大家猜了,这个东西叫做虚表指针。当类的成员函数中包含虚函数的时候,会将该虚函数的地址放到虚函数表当中,而虚表指针就是指向该数组的指针。
当你调用虚函数时,就会通过指针到虚函数表中寻找该函数地址,调用该函数。

3.2 继承时虚表内容的变化

我们现在丰富一下代码:

class A {
public:
	virtual void Func1() {
		cout << "This is Func1." << endl;
	}

	virtual void Func2() {
		cout << "This is Func2." << endl;
	}

	int _a = 0;
};

class B : public A{
public:
	virtual void Func1() {
		cout << "This is Func1, but in B." << endl;
	}

	virtual void Func3() {
		cout << "This is Func3, but in B." << endl;
	}

	int _b = 0;
};

void test_1() {
	A a;
	B b;
}

我们再次继续调试查看这两个对象在内存中的区别:
在这里插入图片描述
我们现在梳理一下A 和 B 中的三个成员函数,帮助我们更好的理解虚表中的内容:

  • Func1() 在基类和派生类中都存在,并且被重写
  • Func2() 只存在于基类,没有被重写
  • Func3() 只存在于派生类

现在我们再来看虚表中的内容:

  • Func1() 在基类和派生类虚表中存储的函数地址不同
  • Func2() 在基类和派生类需表中存储的函数地址相同
  • Func3() 没有在虚表中看见

通过思考,我们不难得出:

  • 基类虚表中的内容会继承给派生类
  • 如果派生类重写了基类的某个虚函数,派生类重写那个函数的地址会覆盖基类函数的地址

咦🤔?是不是漏了一点,派生类特有的虚函数不会写入虚表。错!是会写入的,只是调试窗口不显示而已,我们通过内存查看:
在这里插入图片描述
其实是包含了派生类中特有的虚函数了的。

3.3 切片

切片这个具体的概念在 理解 C++ 中的继承 这一章中已经很详细的讲过了,所以我们在这里就不再阐述了。
通过切片,基类的指针或者引用会在派生类对象中获取基类的部分。因为是以派生类为基础进行切割,基类指针自然拿到的是派生类的虚表指针。
所以,基类指针调用重写的函数时,通过虚表调用的当然是覆盖了之后的派生类的函数。


4. 拓展内容 — 分清 虚基表指针 和 虚表指针

这里的虚基表指针其实就是 理解 C++ 中的继承 这篇文章中,存储距离基类变量偏移量的地址。

虚表指针(vptr)虚基表指针(vbptr)
定义指向类中虚函数表的指针,用于动态多态性指向虚基类表的指针,用于解决多重继承中的二义性和数据冗余问题
用途实现运行时动态绑定,根据对象类型调用正确的虚函数在多重继承中,指向虚基类的偏移地址,以便访问虚基类成员
存在位置在含有虚函数的类的实例化对象中在使用虚继承的派生类对象中
关联结构虚函数表(vtable),包含虚函数地址虚基类表(vbtable),包含虚基类与派生类的偏移地址
使用场景当类声明了虚函数,并且需要通过基类指针或引用调用派生类的虚函数时当类使用虚继承来继承其他类时,特别是在多重继承中,为了避免二义性和数据冗余
多态性是实现C++多态性的关键机制之一与多态性不直接相关,但有助于在多重继承中维护多态性
示例class A { virtual void Func(); };class B : virtual public A {};

5. 总结

虚函数服务于多态,通过虚函数构造重写来实现多态,这允许我们以统一的方式处理不同类型的对象,从而提高代码的可扩展性、可维护性和可重用性。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值