剖析C++中的多态

剖析C++中的多态

前言

​ C++的多态性是面向对象编程的精髓之一,它允许我们通过基类的指针或引用,来操作不同派生类的对象,从而使得同一操作可以作用于不同的对象上,产生不同的效果。这种能力不仅增强了代码的可重用性,也为设计灵活且易于扩展的系统提供了可能。在本文中,我们将探索C++多态性的深层次概念,包括虚函数、继承、finaloverride关键字,以及虚函数表和虚表指针。通过具体的代码示例,我们将揭示这些概念背后的工作原理,并展示它们如何在实际编程中发挥作用。

注意:本文章全程环境选定 VS2022编译器 Debug x86模式


一、理解多态性

​ 在C++中,多态性是一种允许不同类的对象对同一消息做出不同响应的能力。它使得我们可以使用统一的接口来操作不同的对象,并且在运行时确定具体调用哪个类的哪个方法。C++中的多态性主要有两种类型:编译时多态和运行时多态

  • 编译时多态也称为静态多态,主要通过函数重载和运算符重载实现。这种多态性在编译时就已经确定,不会在运行时改变。
  • 运行时多态也称为动态多态,是通过虚函数实现的。虚函数允许派生类重写基类中定义的方法。当通过基类的指针或引用调用虚函数时,会根据对象的实际类型来调用相应的函数,这个过程称为动态绑定或晚绑定。

以下是虚函数实现运行时多态的一个简单示例:

#include <iostream>
using namespace std;

class Base 
{
public:
    virtual void display() {
        cout << "Display Base class" << endl;
    }
};

class Derived : public Base 
{
public:
    void display() override {
        cout << "Display Derived class" << endl;
    }
};

void function(Base* base) 
{
    base->display(); // 运行时多态
}

int main() 
{
    Base b;
    Derived d;
    function(&b); // 输出 "Display Base class"
    function(&d); // 输出 "Display Derived class"
    return 0;
}

在这个例子中,Base 类有一个虚函数 display,而 Derived重写了这个函数。在 main 函数中,我们通过基类指针调用 display 函数。由于 display 是虚函数,所以调用哪个版本的函数取决于指针实际指向的对象类型,这就是运行时多态的体现。

二、虚函数与继承

虚函数重写

在C++中,虚函数是实现运行时多态的关键。当一个函数在基类中被声明为虚函数时,它可以在派生类中被重写,以提供特定于派生类的行为。

虚函数覆盖:当派生类提供一个与基类中同名的虚函数时,这个函数就覆盖了基类中的版本。这意味着,当通过基类的引用或指针指向派生类对象且调用这个函数时,实际调用的是派生类中的函数。

namespace test1		// 简单多态案例,多态的必要条件
{
	class Person
	{
	public:
		virtual void BuyTicket() { cout << "Person: 买票-全价" << endl; }
	};

	class Student : public Person
	{
	public:
		virtual void BuyTicket() { cout << "Student: 买票-半价" << endl; }
	};

	// 多态
	void Func(Person& p)
	{
		p.BuyTicket();
	}
	void test()
	{
		Person* p1 = new Person;
		Person* p2 = new Student;
		Func(*p1); // 调用Person::BuyTicket()
		Func(*p2); // 调用Student::BuyTicket()
		delete p1;
		delete p2;
	}
}

通过上面的代码案例,我们不难总结出形成多态的必要条件:

  • 条件1:虚函数的重写 -> 父子类中两个虚函数满足三同(函数名参数返回)
    • 【特别注意】
    • 这里的三同都满足才能形成多态
    • 二同返回值不同也是一种特殊多态(称之为协变) -> (详见:何为C++中的协变)
    • 析构函数函数名不同也能构成多态条件2:父类指针或引用去调用虚函数

对象切片

详见:剖析C++中的继承

虚函数和多态

当父类中有虚函数时,父类会形成一个虚表指针,指向虚表,虚表中存放的是多态调用时被调函数的地址。

下面给出一段用于测试虚表及虚表指针的代码:

namespace test2		// 对比多态调用和普通调用
{
	class Person {
	public:
		//~Person()
		virtual ~Person()
		{
			cout << "~Person()" << endl;
		}

		virtual void BuyTicket() { cout << "买票-全价" << endl; }
	};

	// 重写实现
	class Student : public Person {
	public:
		~Student()
		{
			cout << "~Student()" << endl;
		}

		void BuyTicket() { cout << "买票-半价" << endl; }
	};

	void test()
	{
		Person* p1 = new Person;
		Person* p2 = new Student;

		cout << "sizeof(Person) = " << sizeof(Person) << endl;		// 4
		cout << "sizeof(Student) = " << sizeof(Student) << endl;	// 4

		delete p1; // p1->destructor()  + operator delete(p1)
		delete p2; // p2->destructor()  + operator delete(p2)
	}

可以看到子类中重写了父类的成员函数 BuyTicket() 与析构函数,这两处都满足多态的形成条件。

打开调试窗口观察 p1、p2 指向对象内部的虚表及虚表指针:

在这里插入图片描述

如果子类继承并重写了父类中的虚函数,那么子类所含虚表指针指向的虚表内容会相对父类中的发生变化,即虚表中对应存放的不是同一个函数地址。

然而,如果子类中未能重写父类中的虚函数,子类对象会也产生一个虚表和虚表指针,只不过虚表的内容和父类虚表的内容是一致的。

在这里插入图片描述

而且,子类继承到父类的虚表中,没有重写父类虚函数,那么子类虚表指针指向的虚表中,对应的父类虚函数地址与子类中读到的相一致。

三、多态性实践

多态调用与普通调用

示例类的定义上面 namespace test2 已经给出,下面给出测试案例代码:

void test()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	// 普通调用  看指针或者引用或对象的类型
	Student s;
	s.BuyTicket();
	s.Person::BuyTicket();

	// 多态调用  看指针或者引用指向的对象
	p1->BuyTicket();
	p2->BuyTicket();

	delete p1;
	delete p2;
}

运行结果:

在这里插入图片描述

多态与析构函数

下面给出两个基础类,用于测试不同情况下析构函数的调用情况:

class A
{
public:
	A()
	{
		a = new int[10];
	}
	~A()
	{
		cout << "A 析构" << endl;
		delete a;
		a = nullptr;
	}

	int* a;
};
class B: public A
{
public:
	B() :A()
	{
		b = new char[10];
	}
	~B()
	{
		cout << "B 析构" << endl;
		delete b;
		b = nullptr;
	}
	
	char* b;
};

测试上面类的基本功能:

void test()
{
	A* p = new B;

	delete p;
	p = nullptr;
}

为了观察是否存在析构函数调用异常引起的内存泄漏,调用 vld 库即可方便查看是否有内存直到程序运行结束还未被释放。包含头文件 “vld.h”

运行结果:

在这里插入图片描述

所以我们得出结论,当两个类存在继承关系时,如果父类不将析构函数声明为虚函数,那么当父类指针指向子类对象的案例出现时,释放父类指针指向的内存虽然成功,但是仅调用了父类的析构函数,并没有调用子类析构函数来释放子类对象内部堆区成员变量的内存。

正确地,需要将父类析构函数声明为虚析构:

// ~A()
virtual ~A()
{
	cout << "A 析构" << endl;
	delete a;
	a = nullptr;
}

运行结果:

在这里插入图片描述

四、高级多态性概念

final 关键字

实现一个类,这个类不能被继承

  • 方法1:父类构造函数私有化,派生实例化不出对象
class A
{
protected:
	int _a;
private:
	A(){}
};

class B : public A
{};

简单的类对象实例化代码:

void test1()
{
	B b;
}

编译报错:

在这里插入图片描述

  • 方法2:C++11,final修饰的类为最终类,不能被继承
class A final	// 添加了final关键字
{
protected:
	int _a;
/*private:
	A(){}*/
};

class B : public A
{};

语法报错,编译报错:

在这里插入图片描述

override 关键字

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

在以下情况下,子类没有成功重写父类的虚函数,override关键字会在编译阶段进行检测并给出警告或错误:

  1. **函数签名不匹配:**如果子类中的函数签名(包括函数名、参数列表和返回类型)与父类中的虚函数不完全匹配,编译器将无法识别子类的函数是对父类函数的重写。(override 允许协变通过)
  2. **父类函数不是虚函数:**如果父类函数没有声明为虚函数,而子类使用override关键字尝试重写该函数,编译器将给出错误。
  3. **函数修饰符不匹配:**如果父类的虚函数使用了const或引用修饰符,而子类中的重写函数没有相同的修饰符,编译器将会给出警告或错误。
class Car
{
public:
	virtual Car* Drive() { return new Car; }
};
class Benz :public Car 
{
public:
	virtual Benz* Drive() override { return new Benz; }
};

子类中继承的虚函数用 override 关键字修饰但未正确重写父类虚函数,利用测试案例:

void test2()
{
	Benz b;
	b.Drive();
}

运行未报错,说明override指定的父类与子类对应函数的函数签名需一致,但构成协变的函数名不同在override关键字修饰下也不会报错。

五、虚表和虚表指针

为了便于分析叙述多态下父子类各自的虚表和虚表指针特性,给出下面简单的示例代码:

class Base
{
public:
	void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;
};

void test()
{
	Base b;
	Derive d;
	cout << "sizeof(Base):" << sizeof(b) << endl;
	cout << "sizeof(Derive):" << sizeof(d) << endl;
}

我们注意到代码中,父类中的虚函数Func2()没有在子类中被重写,父类中的Func3()不是虚函数,子类中有自身额外定义的Func4()函数。

接着我们打开监视窗口,观察父类对象和子类对象各自的虚表指针与虚表中所含的函数地址:

在这里插入图片描述

基类(Base)的虚表

  • 包含指向Base::Func1()Base::Func2()的指针。
  • 不包含非虚函数Base::Func3(),因为它不支持多态。

派生类(Derive)的虚表

  • 包含指向Derive::Func1()的指针,这是对基类虚函数的重写,具有多态特性。
  • 继承了基类的Base::Func2(),因此虚表中也包含指向该函数的指针。
  • 包含一个新的虚函数Derive::Func4(),这在派生类的虚表中是新增的。

六、常见陷阱与最佳实践

常见陷阱 – 切片问题

**对象切片:**当派生类对象赋值给基类对象时,派生类特有的成员会被“切掉”,这可能会导致数据丢失或错误的行为。

详见:基类与派生类之间赋值转换 (具体位于第二章)

切片问题通常发生在对象赋值的情况下,特别是当派生类对象被赋值给基类对象时。在这种情况下,由于基类对象只能容纳基类部分,派生类特有的属性和方法就会被 “切掉”,这就是所谓的 “对象切片”。

最佳实践 – 使用引用或指针传递多态类对象

示例代码:

class A
{
public:
	int m_aa = 1;
};
class B : public A
{
public:
	int m_bb = 2;
};

void func(A* tmp)	// 没有发生切片
{
	tmp->m_aa = 10;
	// 内部访问不到 B::m_bb
}
void test()
{
	B b;
	cout << "b.m_bb = " << b.m_bb << endl;
	func(&b);
}

此处func 函数接受一个指向 A 类的指针,并且传递了一个指向 B 类对象的指针。由于 B 是从 A 继承的,这是多态的正常使用,不会导致切片。

为什么派生类对象赋值给基类指针不会发生切片?

​ 派生类对象赋值给基类指针时不会发生切片,因为指针只是存储了对象的内存地址。当我们使用基类指针指向派生类对象时,指针本身并不包含对象的数据,它只是指向对象所在的内存位置。因此,无论指针的类型如何,对象本身都不会改变。

当我们使用指针或引用时,我们并不是在复制整个对象,而是让指针指向已经存在的对象。这样,即使是基类的指针,也能够通过它来访问派生类对象的基类部分,而不会影响到对象的完整性。这也是多态的基础,允许我们用基类的指针或引用来操作不同派生类的对象。

怎么做到指针指向子类对象但是父类指针仅能访问到父类成员?

​ 在C++中,当我们使用父类指针指向子类对象时,父类指针默认只能访问父类中定义的成员。这是因为编译器只能识别指针类型所对应的成员。这个机制是C++的静态类型绑定,也就是说,成员的访问是根据指针的类型在编译时就确定下来的。


总结

​ 我们已经深入探讨了C++中多态性的各个方面。从基本的虚函数使用,到finaloverride关键字的高级应用,再到虚函数表的内部机制,这些都是构建强大C++应用程序的基石。正确理解和运用多态性,可以让我们的代码更加灵活、高效,同时也更易于维护和扩展。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

螺蛳粉只吃炸蛋的走风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值