C++深入浅出(九)—— 多态

2023-05-16

文章目录

  • 1. 多态的概念
  • 2. 多态的定义及实现
    • 🍑 多态的构成条件
    • 🍑 虚函数
    • 🍑 虚函数的重写
    • 🍑 虚函数重写的两个例外
    • 🍑 C++11的override 和 final
    • 🍑 重载、覆盖(重写)、隐藏(重定义)的对比
  • 3. 抽象类
    • 🍑 接口继承和实现继承
  • 4. 多态的原理
    • 🍑 虚函数表
    • 🍑 多态的原理
    • 🍑 动态绑定与静态绑定
  • 5. 单继承和多继承关系的虚函数表
    • 🍑 单继承中的虚函数表
    • 🍑 多继承中的虚函数表
    • 🍑 菱形继承和菱形虚拟继承
  • 6. 继承和多态常见的面试问题
    • 🍑 概念查考
    • 🍑 问答题


1. 多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

(1)示例一

比如春节回家买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

不同身份的人去买票,所产生的行为是不同的,这就是所谓的多态。

(2)示例二

为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包 - 支付 - 给奖励金的活动。

那么大家想想为什么有人扫的红包又大又新鲜 8 块、10 块…,而有人扫的红包都是 1 毛,5 毛…。其实这背后也是一个多态行为。

支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你 扫码金额 = random()%99

比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你 扫码金额 = random()%1

总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。

2. 多态的定义及实现

🍑 多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。

比如 Student 继承了 PersonPerson 对象买票全价,Student 对象买票半价。

那么在继承中要构成多态还有两个条件:

(1)必须通过基类的指针或者引用调用虚函数

(2)被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(重写有三同:函数名、参数、返回值)

在这里插入图片描述

🍑 虚函数

虚函数:即被 virtual 修饰的类成员函数称为虚函数。

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

🍑 虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

下面代码中,Person 是基类,StudentSoldier 是派生类,它们分别继承了 Person 类,并且重写了基类的虚函数

// 基类
class Person {
public:
	Person(const char* name)
		:_name(name)
	{}

	// 虚函数
	virtual void BuyTicket() 
	{ 
		cout << _name << "Person:买票-全价 100¥" << endl; 
	}

protected:
	string _name;
};

// 派生类 - 学生
class Student : public Person {
public:
	Student(const char* name)
		:Person(name)
	{}

	// 虚函数 + 函数名/参数/返回值 ==> 重写/覆盖
	virtual void BuyTicket() 
	{ 
		cout << _name << " Student:买票-半价 50 ¥" << endl; 
	}
};

// 派生类 - 军人
class Soldier : public Person {
public:
	Soldier(const char* name)
		:Person(name)
	{}

	// 虚函数 + 函数名/参数/返回值 ==> 重写/覆盖
	virtual void BuyTicket() 
	{ 
		cout << _name << " Soldier:优先买预留票-88折 88 ¥" << endl; 
	}
};

思考一下:三个类里面都有 BuyTicket,那么会不会构成隐藏呢?当然不会!

我们这里是:虚函数+相同的函数名、相同的参数、相同的返回值,那么就构成覆盖或者重写。意思就是子类里面的覆盖了父类里面的相同函数!

如果我要去调用基类的虚函数怎么办呢?有两种方法!

(1)父类指针去调用虚函数

// 父类指针去调用虚函数
void Pay(Person* ptr)
{
	ptr->BuyTicket();
}


int main()
{
	int option = 0;
	cout << "=======================================" << endl;
	do 
	{
		cout << "请选择身份:";
		cout << "1、普通人 2、学生 3、军人" << endl;
		cin >> option;
		cout << "请输入名字:";
		string name;
		cin >> name;
		switch (option)
		{
		case 1:
		{
				  Person p(name.c_str());
				  Pay(&p); // 传地址
				  break;
		}
		case 2:
		{
				  Student s(name.c_str());
				  Pay(&s);
				  break;
		}
		case 3:
		{
				  Soldier s(name.c_str());
				  Pay(&s);
				  break;
		}
		default:
			cout << "输入错误,请重新输入" << endl;
			break;
		}
		cout << "=======================================" << endl;
	} while (option != -1);

	return 0;
}

我们运行以后可以看到,当你选择不同的身份时,会去调用不同的买票函数,产生的金额也是不一样的,所以实现了函数调用的多种形态。

在这里插入图片描述

(1)父类引用去调用虚函数

// 父类引用去调用虚函数
void Pay(Person& ptr)
{
	ptr.BuyTicket();
}

int main()
{
	int option = 0;
	cout << "=======================================" << endl;
	do 
	{
		cout << "请选择身份:";
		cout << "1、普通人 2、学生 3、军人" << endl;
		cin >> option;
		cout << "请输入名字:";
		string name;
		cin >> name;
		switch (option)
		{
		case 1:
		{
				  Person p(name.c_str());
				  Pay(p); // 这里就不能传地址了
				  break;
		}
		case 2:
		{
				  Student s(name.c_str());
				  Pay(s);
				  break;
		}
		case 3:
		{
				  Soldier s(name.c_str());
				  Pay(s);
				  break;
		}
		default:
			cout << "输入错误,请重新输入" << endl;
			break;
		}
		cout << "=======================================" << endl;
	} while (option != -1);

	return 0;
}

当然,运行结果和上面也是一样的:

在这里插入图片描述

注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写,因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性。但是该种写法不是很规范,不建议这样使用!

🍑 虚函数重写的两个例外

(1)协变(基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

下面代码中,首先 A 是基类,B 继承了 A是派生类;同样 Person 是基类,Student 继承了 Person 是派生类。

Person 中的虚函数 fun 的返回值类型是基类 A 对象的指针,在派生类 Student 当中的虚函数 fun 的返回值类型是派生类 B 对象的指针。

那么此时是可以认为派生类 Student 的虚函数重写了基类 Person 的虚函数。

// 基类
class A{};

// 派生类
class B : public A {};

// 基类
class Person {
public:
	virtual A* f() { 
		cout << "virtual A* Person::f()" << endl;
		return new A; 
	}
};

// 派生类
class Student : public Person {
public:
	virtual B* f() {
		cout << "virtual B* Student::f()" << endl;
		return new B; 
	}
};

int main()
{
	Person p;
	Student s;

	Person* ptr = &p;
	ptr->f();

	ptr = &s;
	ptr->f();

	return 0;
}

我们运行可以看到,当 Person 指针指向的是基类对象时,调用的是基类的虚函数;当 Person 指针指向的是派生类对象时,调用的是派生类的虚函数。

在这里插入图片描述

注意,虚函数重写对返回值要求有一个例外:协变时,必须是父子关系指针或者引用。

也就是说返回值不管是指针还是引用,AB 必须是父子关系!

还记得我们上面说的派生类的虚函数在不加 virtual 关键字时,也可以构成重写吗?

// 基类
class A{};

// 派生类
class B : public A {};

// 基类
class Person {
public:
	virtual A* f() 
	{ 
		cout << "virtual A* Person::f()" << endl;
		return new A; 
	}
};

// 派生类
class Student : public Person {
public:
	B* f() 
	{
		cout << "virtual B* Student::f()" << endl;
		return new B; 
	}
};

int main()
{
	Person p;
	Student s;

	Person* ptr1 = &p;
	ptr1->f();

	Person* ptr2 = &s;
	ptr2->f();

	return 0;
}

此时,子类虚函数没有写 virtualf() 依旧时虚函数,因为它先继承了父类函数接口声明,运行以后结果也是正确的:

在这里插入图片描述

注意:不推荐这种写法,我们自己写的时候子类虚函数也写上 virtual

(2)析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,即使基类与派生类析构函数名字不同。

下面代码中,基类 Person 和 派生类 Student 都没有加 virtual,那么此时构成的关系就是 隐藏关系(也叫重定义)

// 基类
class Person {
public:
	virtual ~Person() 
	{ 
		cout << "~Person()" << endl; 
	}
};

// 派生类
class Student : public Person {
public:
	virtual ~Student() 
	{ 
		cout << "~Student()" << endl; 
	}
};

int main()
{
	Person p;
	Student s;

	return 0;
}

运行以后可以看到,先调用派生类 Student 对象自己的析构函数,然后 Student 会自动调用基类 Person 的析构函数清理基类成员,最后基类 Person 对象再调用自己的析构函数。

在这里插入图片描述

如果基类 Person 析构函数加了 virtual,那么此时关系就变了,从 重定义(隐藏)关系 变成了 重写(覆盖)关系

// 基类
class Person {
public:
	virtual ~Person() 
	{ 
		cout << "~Person()" << endl; 
	}
};

// 派生类
class Student : public Person {
public:
	~Student()
	{ 
		cout << "~Student()" << endl; 
	}
};

虽然,它们打印的结果还是一样滴。

虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor

那么场景下才需要子类的析构函数也写上 virtual 呢?

假设有这么一个场景:分别 new 一个父类对象和子类对象,并均用父类指针指向它们,然后分别用 delete 调用析构函数并释放对象空间。

// 基类
class Person {
public:
	~Person() 
	{ 
		cout << "~Person()" << endl; 
	}
};

// 派生类
class Student : public Person {
public:
	~Student()
	{ 
		cout << "~Student()" << endl; 
	}
};


int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;

	return 0;
}

如果不加 virtual,就可能会导致内存泄漏,因为此时 delete p1delete p2 都是调用的父类的析构函数:

在这里插入图片描述

只有派生类 Student 的析构函数重写了 Person 的析构函数,下面的 delete 对象调用析构函数,才能构成多态,才能保证 p1p2 指向的对象正确的调用析构函数。

// 基类
class Person {
public:
	virtual ~Person() 
	{ 
		cout << "~Person()" << endl; 
	}
};

// 派生类
class Student : public Person {
public:
	virtual ~Student()
	{ 
		cout << "~Student()" << endl; 
	}
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;

	return 0;
}

可以看到,p1 调用父类的析构函数,p2 调用子类的析构函数,是一种多态行为。

在这里插入图片描述

🍑 C++11的override 和 final

从上面示例中可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果再来进行 debug 会得不偿失,因此,C++11 提供了 overridefinal 两个关键字,可以帮助用户检测是否重写。

(1) final:修饰虚函数,表示该虚函数不能再被重写

代码示例

// 基类
class Car
{
public:
	// 被final修饰,该虚函数不能再被重写
	virtual void Drive() final {}
};

// 子类
class Benz :public Car
{
public:
	virtual void Drive() 
	{ 
		cout << "Benz-舒适" << endl; 
	}
};

int main()
{
	return 0;
}

基类 Car 的虚函数 Drive()final 修饰后就不能再被重写了,派生类若是重写了基类的 Drive() 函数则编译报错。

在这里插入图片描述

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

代码示例

// 基类
class Car {
public:
	virtual void Drive() {}
};

// 派生类
class Benz :public Car {
public:
	// 子类完成了父类虚函数的重写,编译通过
	virtual void Drive() override 
	{ 
		cout << "Benz-舒适" << endl; 
	}
};

// 派生类
class BMW :public Car {
public:
	// 子类没有完成了父类虚函数的重写,编译报错
	void Drive(int i) override
	{
		cout << "Benz-好开" << endl;
	}
};


int main()
{
	return 0;
}

派生类 BenzBMW 的虚函数 Driveoverride 修饰,编译时就会检查子类的这两个 Drive 函数是否重写了父类的虚函数,如果没有重写就会编译报错。

在这里插入图片描述

🍑 重载、覆盖(重写)、隐藏(重定义)的对比

总结一下这三者的含义:

在这里插入图片描述

3. 抽象类

在虚函数的后面写上 = 0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。

代码示例

//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};

int main()
{
	Car c; //抽象类不能实例化出对象,error
	return 0;
}

派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};

//派生类
class Benz : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};

//派生类
class BMV : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "BMV-操控" << endl;
	}
};

int main()
{
	//派生类重写了纯虚函数,可以实例化出对象
	Benz b1;
	BMV b2;

	//不同对象用基类指针调用Drive函数,完成不同的行为
	Car* p1 = &b1;
	Car* p2 = &b2;

	p1->Drive();  //Benz-舒适
	p2->Drive();  //BMV-操控
	return 0;
}

运行结果

在这里插入图片描述

纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

抽象类既然不能实例化出对象,那抽象类存在的意义是什么?

  • 抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
  • 抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

🍑 接口继承和实现继承

  • 实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数,继承的是函数的实现。
  • 接口继承: 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

建议: 所以如果不实现多态,就不要把函数定义成虚函数。

4. 多态的原理

🍑 虚函数表

下面是常考一道笔试题:sizeof(Base) 是多少?

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

通过观察测试,我们发现 Base 类实例化的对象 b 的大小是 8 个字节。

在这里插入图片描述

b 对象当中除了 _b 成员外,实际上还有一个 _vfptr 放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。

在这里插入图片描述

对象中的这个指针叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少都有一个虚表指针。

因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

那么虚函数表中到底放的是什么?我们接着往下分析

下面代码中 Base 类有三个成员函数,其中 Func1 和 Func2 是虚函数,Func3 是普通成员函数,子类 Derive 当中仅对父类的 Func1 函数进行了重写。

//父类
class Base
{
public:
	//虚函数
	virtual 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:
	//重写虚函数Func1
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;
	
	return 0;
}

通过调试可以发现,父类对象 b 和基类对象 d 当中除了自己的成员变量之外,父类和子类对象都有一个虚表指针,分别指向属于自己的虚表。

在这里插入图片描述

实际上虚表当中存储的就是虚函数的地址,因为父类当中的 Func1 和 Func2 都是虚函数,所以父类对象 b 的虚表当中存储的就是虚函数 Func1 和 Func2 的地址。

在这里插入图片描述

而子类虽然继承了父类的虚函数 Func1 和 Func2,但是子类对父类的虚函数 Func1 进行了重写,因此,子类对象 d 的虚表当中存储的是父类的虚函数 Func2 的地址和重写的Func1的地址。这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。

其次需要注意的是:Func2 是虚函数,所以继承下来后放进了子类的虚表,而 Func3 是普通成员函数,继承下来后不会放进子类的虚表。此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个 nullptr。

总结一下,派生类的虚表生成步骤如下:

  • 先将基类中的虚表内容拷贝一份到派生类的虚表。
  • 如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。
  • 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

这里还有很容易混淆的问题:虚函数存在哪的?虚表存在哪的?

虚表实际上是在构造函数初始化列表阶段进行初始化的,注意虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在 代码段 的,只是它的地址又存到了虚表当中。另外,对象中存的不是虚表而是指向虚表的指针。

我们可以通过下面这段代码判断虚表是存在哪里的。

int main()
{
	Base b;
	Base* p = &b;
	printf("vfptr:%p\n", *((int*)p)); 

	int i = 0;
	printf("栈上地址:%p\n", &i);       
	printf("数据段地址:%p\n", &j);     

	int* k = new int;
	printf("堆上地址:%p\n", k);   

	const char* cp = "hello world";
	printf("代码段地址:%p\n", cp);    

	return 0;
}

可以看到,代码当中打印了对象 b 当中的虚表指针,也就是虚表的地址,可以发现虚表地址与代码段的地址非常接近,由此我们可以得出虚表实际上是存在代码段的。

在这里插入图片描述

🍑 多态的原理

上面分析了这个半天了那么多态的原理到底是什么?

我们还是拿买票这个代码来说明:

// 父类
class Person {
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-全价" << endl; 
	}
	int _p = 1;
};

// 子类
class Student : public Person {
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-半价" << endl; 
	}
	int _s = 2;
};

// 调用函数
void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person Mike;
	Func(Mike);

	Student Johnson;
	Func(Johnson);

	return 0;
}

为什么当父类 Person 指针指向的是父类对象 Mike 时,调用的就是父类的 BuyTicket,当父类 Person 指针指向的是子类对象 Johnson 时,调用的就是子类的 BuyTicket?

在这里插入图片描述

通过调试可以发现,对象 Mike 中包含一个成员变量 _p 和一个虚表指针,对象 Johnson 中包含两个成员变量 _p 和 _s 以及一个虚表指针,这两个对象当中的虚表指针分别指向自己的虚表。

在这里插入图片描述

围绕此图分析便可得到多态的原理:

  • p 是指向 Mike 对象时,p->BuyTicket 在 Mike 的虚表中找到虚函数是 Person::BuyTicket
  • p 是指向 Johnson 对象时,p->BuyTicket 在 Johson 的虚表中找到虚函数是 Student::BuyTicket

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

现在想想多态构成的两个条件,一是完成虚函数的重写,二是必须使用父类的指针或者引用去调用虚函数。

必须完成虚函数的重写是因为我们需要完成子类虚表当中虚函数地址的覆盖,那 为什么必须使用父类的指针或者引用去调用虚函数呢?为什么使用父类对象去调用虚函数达不到多态的效果呢?

使用父类指针或者引用时,实际上是一种切片行为,切片时只会让父类指针或者引用得到父类对象或子类对象中切出来的那一部分。

在这里插入图片描述

因此我们现在对代码进行一下修改,当我们把父类和子类对象直接赋值给 p1 和 p2 时,再去调用,会发生什么呢?

int main()
{
	Person Mike;
	Student Johnson;

	Johnson._p = 3; //以便观察是否完成切片

	Person p1 = Mike;
	Person p2 = Johnson;

	p1.BuyTicket();
	p2.BuyTicket();

	return 0;
}

可以看到并没有实现多态,因为 p1 和 p2 调用虚函数时,p1 和 p2 通过虚表指针找到的虚表是不一样的,最终调用的函数也是不一样的。

在这里插入图片描述

使用父类对象时,切片得到部分成员变量后,会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造,而拷贝构造出来的父类对象 p1 和 p2 当中的虚表指针指向的都是父类对象的虚表。因为同类型的对象共享一张虚表,他们的虚表指针指向的虚表是一样的。

在这里插入图片描述

对象切片的时候,子类只会拷贝成员给父类对象,不会拷贝虚表指针,否则拷贝就混乱了,所以父类对象中到底是父类的虚表指针还是子类的虚表指针,是都有可能的,那么是去调用父类的虚函数还是子类的虚函数就不确定!

因此,我们用 p1 和 p2 调用虚函数时,p1 和 p2 通过虚表指针找到的虚表是一样的,最终调用的函数也是一样的,也就无法构成多态。

总结一下:

  • 构成多态,指向谁就调用谁的虚函数,跟对象有关。
  • 不构成多态,对象类型是什么就调用谁的虚函数,跟类型有关。

🍑 动态绑定与静态绑定

静态绑定: 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,比如:函数重载。

动态绑定: 动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

对于下面这段代码,我们可以通过查看汇编的方式进一步理解静态绑定和动态绑定。

//父类
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

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

我们若是按照下面方式调用 BuyTicket 函数,则不构成多态,函数的调用是在编译时确定的。


int main()
{
	Student Johnson;
	Person p = Johnson; //不构成多态

	p.BuyTicket();
	return 0;
}

将调用函数的那句代码翻译成汇编就只有以下两条汇编指令,也就是直接调用的函数。

在这里插入图片描述

而我们若是按照如下方式调用 BuyTicket 函数,则构成多态,函数的调用是在运行时确定的。

int main()
{
	Student Johnson;
	Person& p = Johnson; //构成多态

	p.BuyTicket();
	return 0;
}

相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后就变成了八条汇编指令,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。

在这里插入图片描述

这样就很好的体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的。

5. 单继承和多继承关系的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类
的虚表模型前面我们已经看过了,没什么需要特别研究的。

🍑 单继承中的虚函数表

以下列单继承关系为例,我们来看看基类和派生类的虚表模型。

// 父类
class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};

// 子类
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

其中,基类和派生类对象的虚表模型如下:

在这里插入图片描述

在单继承关系当中,派生类的虚表生成过程如下:

  • 继承基类的虚表内容到派生类的虚表。
  • 对派生类重写了的虚函数地址进行覆盖,比如 func1。
  • 虚表当中新增派生类当中新的虚函数地址,比如 func3 和 func4。

但是,通过监视窗口我们发现看不见 func3 和 func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。那么我们如何查看 d 的虚表呢?

在这里插入图片描述

(1)使用内存监视窗口

使用内存监视窗口看到的内容是最真实的,我们调出内存监视窗口,然后输入派生类对象当中的虚表指针,即可看到虚表当中存储的四个虚函数地址。

在这里插入图片描述

(2)使用代码打印虚表内容

我们可以使用以下代码,打印上述基类和派生类对象的虚表内容,在打印过程中可以顺便用虚函数地址调用对应的虚函数,从而打印出虚函数的函数名,这样可以进一步确定虚表当中存储的是哪一个函数的地址。

代码示例

// 取内存值,打印并调用,确认是否是func4
typedef void(*VFPTR) ();

// 打印虚表
void PrintVTable(VFPTR vTable[])
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址:" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址:0X%x --> ", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;

	VFPTR * vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);

	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);

	return 0;
}

我这里稍微解释一下打印虚表的代码:

  • 思路:取出 b、d 对象的头 4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个 nullptr
  • 先取 b 的地址,强转成一个 int* 的指针
  • 再解引用取值,就取到了 b 对象头 4bytes 的值,这个值就是指向虚表的指针
  • 再强转成 VFPTR*,因为虚表就是一个存 VFPTR 类型(虚函数指针类型)的数组
  • 虚表指针传递给 PrintVTable 进行打印虚表
  • 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放 nullptr,导致越界,这是编译器的问题。

运行结果如下:

在这里插入图片描述

模型图如下:

在这里插入图片描述

🍑 多继承中的虚函数表

以下列多继承关系为例,我们来看看基类和派生类的虚表模型。

// 父类1
class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int _b1;
};

// 父类2
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int _b2;
};

// 多继承子类
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int _d1;
};

其中,两个基类的虚表模型如下:

在这里插入图片描述

而派生类的虚表模型就不那么简单了,派生类的虚表模型如下:

在这里插入图片描述

在多继承关系当中,派生类的虚表生成过程如下:

  • 分别继承各个基类的虚表内容到派生类的各个虚表当中。
  • 对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖),比如 func1。
  • 在派生类第一个继承基类部分的虚表当中新增派生类当中新的虚函数地址,比如 func3。

这里在调试时,在某些编译器下也会出现显示不全的问题,此时如果我们想要看到派生类对象完整的虚表也是用那两种方法。

(1)使用内存监视窗口

直接调用内存窗口查看:

在这里插入图片描述

(2)使用代码打印虚表内容

需要注意的是,我们在派生类第一个虚表地址的基础上,向后移 sizeof(Base1) 个字节即可得到第二个虚表的地址。

// 取内存值,打印并调用,确认是否是func4
typedef void(*VFPTR) ();

// 打印虚表
void PrintVTable(VFPTR vTable[])
{
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << " 虚表地址:" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址:0X%x --> ", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base1 b1;
	Base2 b2;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&b1); 
	PrintVTable(vTableb1); // 打印基类对象b1的虚表地址及其内容
	VFPTR* vTableb2 = (VFPTR*)(*(int*)&b2);
	PrintVTable(vTableb2); // 打印基类对象b2的虚表地址及其内容

	Derive d;
	VFPTR* vTableb3 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb3); // 打印派生类对象d的第一个虚表地址及其内容

	VFPTR* vTableb4 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb4); // 打印派生类对象d的第二个虚表地址及其内容

	return 0;
}

运行结果如下:

在这里插入图片描述

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中:

在这里插入图片描述

🍑 菱形继承和菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。

但是这里可以给大家推荐两篇文章:

  • C++ 虚函数表解析
  • C++ 对象的内存布局

6. 继承和多态常见的面试问题

🍑 概念查考

  1. 下面哪种面向对象的方法可以让你变得富有?
    A. 继承 B. 封装 C. 多态 D. 抽象

  2. 什么是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
    A. 继承 B. 模板 C. 对象的自身引用 D. 动态绑定

  3. 面向对象设计中的继承和组合,下面说法错误的是?
    A. 继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用
    B. 组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用
    C. 优先使用继承,而不是组合,是面向对象设计的第二原则
    D. 继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现

  4. 以下关于纯虚函数的说法,正确的是?
    A. 声明纯虚函数的类不能实例化对象 B. 声明纯虚函数的类是虚基类
    C. 子类必须实现基类的纯虚函数 D. 纯虚函数必须是空函数

  5. 关于虚函数的描述正确的是?
    A. 派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B. 内联函数不能是虚函数
    C. 派生类必须重新定义基类的虚函数 D. 虚函数可以是一个static型的函数

  6. 关于虚表说法正确的是?
    A. 一个类只能有一张虚表
    B. 基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
    C. 虚表是在运行期间动态生成的
    D. 一个类的不同对象共享该类的虚表

  7. 假设 A 类中有虚函数,B 继承自 A,B 重写 A 中的虚函数,也没有定义任何虚函数,则
    A. A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
    B. A类对象和B类对象前4个字节存储的都是虚基表的地址
    C. A类对象和B类对象前4个字节存储的虚表地址相同
    D. A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表

  8. 下面程序输出结果是什么?

#include <iostream>
using namespace std;

class A
{
public:
	A(char* s) { cout << s << endl; }
	~A() {};
};
class B : virtual public A
{
public:
	B(char* s1, char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
class C : virtual public A
{
public:
	C(char* s1, char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
class D : public B, public C
{
public:
	D(char* s1, char* s2, char* s3, char* s4)
		:B(s1, s2)
		, C(s1, s3)
		, A(s1)
	{
		cout << s4 << endl;
	}
};
int main()
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

A. class A class B class C class D B. class D class B class C class A
C. class D class C class B class A D. class A class C class B class D

  1. 多继承中指针偏移问题?下面说法正确的是?
#include <iostream>
using namespace std;

class Base1
{
public:
	int _b1;
};

class Base2
{
public:
	int _b2;
};

class Derive : public Base1, public Base2
{
public:
	int _d;
};

int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

A. p1 == p2 == p3 B. p1 < p2 < p3 C. p1 == p3 != p2 D. p1 != p2 != p3

  1. 以下程序输出结果是什么?
#include <iostream>
using namespace std;

class A
{
public:
	virtual void func(int val = 1)
	{
		cout << "A->" << val << endl;
	}
	virtual void test()
	{
		func();
	}
};

class B : public A
{
public:
	void func(int val = 0)
	{
		cout << "B->" << val << endl;
	}
};

int main()
{
	B* p = new B;
	p->test();
	return 0;
}

A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确

答案如下:

在这里插入图片描述

🍑 问答题

(1)什么是多态?

多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态。

(2)什么是重载、重写(覆盖)、重定义(隐藏)?

重载是指两个函数在同一作用域,这两个函数的函数名相同,参数不同。

重写(覆盖)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名、参数、返回值都必须相同(协变例外),且这两个函数都是虚函数。

重定义(隐藏)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名相同。若两个基类和派生类的同名函数不构成重写就是重定义。

(3)多态的实现原理?

构成多态的父类对象和子类对象的成员当中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是该类对应的虚函数地址。

因此,当父类指针指向父类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是父类当中对应的虚函数;

当父类指针指向子类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是子类当中对应的虚函数。

(4)inline 函数可以是虚函数吗?

我们知道内联函数是会在调用的地方展开的,也就是说内联函数是没有地址的,但是内联函数是可以定义成虚函数的,当我们把内联函数定义虚函数后,编译器就忽略了该函数的内联属性,这个函数就不再是内联函数了,因为需要将虚函数的地址放到虚表中去。

(5)静态成员可以是虚函数吗?

静态成员函数不能是虚函数,因为静态成员函数没有this指针,使用类型 :: 成员函数的调用方式无法访问虚表,所以静态成员函数无法放进虚表。

(6)构造函数可以是虚函数吗?

构造函数不能是虚函数,因为对象中的虚表指针是在构造函数初始化列表阶段才初始化的。

(7)析构函数可以是虚函数吗?

析构函数可以是虚函数,并且最后把基类的析构函数定义成虚函数。若是我们分别 new 一个父类对象和一个子类对象,并均用父类指针指向它们,当我们使用 delete 调用析构函数并释放对象空间时,只有当父类的析构函数是虚函数的情况下,才能正确调用父类和子类的析构函数分别对父类和子类对象进行析构,否则当我们使用父类指针 delete 对象时,只能调用到父类的析构函数。

(8)对象访问普通函数快还是虚函数更快?

对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,那直接访问就行了,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。

(9)虚函数表是在什么阶段生成的?存在哪的?

虚表是在构造函数初始化列表阶段进行初始化的,虚表一般情况下是存在代码段(常量区)的。

(10)C++菱形继承的问题?虚继承的原理?

菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。

虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。

(11)什么是抽象类?抽线类的作用?

抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去抽象纯虚函数,因为子类若是不抽象从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型,比如:植物、人、动物等。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++深入浅出(九)—— 多态 的相关文章

  • Maven3.6.1下载安装基本使用 (初识)(自用)

    对MAVEN的粗浅认识 Apache Maven 是 项目管理与构建工具 基于POM xff08 项目对象模型 xff09 的概念 作用 提供了一套标准化的项目结构 粗浅理解就是 xff0c 通常eclipse xff0c idea等jav
  • VSCode代码格式化快捷键

    我们在编写代码和阅读别人代码的时候 xff0c 容易出现同级元素缩进没有对齐的情况 xff0c 我们需对代码进行格式化 xff0c 以方便自己和他人的阅读 在vscode中使用快捷键 Shift 43 Alt 43 F 使用示例 xff1a
  • for循环【C++】

    for循环 执行一个特定循环的控制结构 for 条件 条件判断 条件处理 执行体 xff1b 条件 条件判断和条件处理都不是必要的 xff0c 当三者都没有 xff0c 则相当于一个无限循环 条件不一定需要在括号内声明和初始化 xff0c
  • 基于深度强化学习的智能船舶航迹跟踪控制

    基于深度强化学习的智能船舶航迹跟踪控制 人工智能技术与咨询 昨天 本文来自 中国舰船研究 xff0c 作者祝亢等 关注微信公众号 xff1a 人工智能技术与咨询 了解更多咨询 xff01 0 引 言 目前 xff0c 国内外对运载工具的研究
  • 面向区块链的高效物化视图维护和可信查询

    面向区块链的高效物化视图维护和可信查询 人工智能技术与咨询 来源 xff1a 软件学报 xff0c 作者蔡 磊等 摘 要 区块链具有去中心化 不可篡改和可追溯等特性 可应用于金融 物流等诸多行业 由于所有交易数据按照交易时间顺序存储在各个区
  • 基于深度学习的磁环表面缺陷检测算法

    基于深度学习的磁环表面缺陷检测算法 人工智能技术与咨询 来源 xff1a 人工智能与机器人研究 xff0c 作者罗菁等 关键词 缺陷检测 xff1b 深度学习 xff1b 磁环 xff1b YOLOv3 xff1b 摘要 在磁环的生产制造过
  • 基于PX4的地面无人车避障系统及路径规划研究

    基于PX4的地面无人车避障系统及路径规划研究 人工智能技术与咨询 来源 xff1a 动力系统与控制 xff0c 作者姜琼阁等 关键词 地面无人车 xff1b 避障 xff1b PX4 xff1b 摘要 地面无人车避障及路径规划是指 xff0

随机推荐

  • 基于图像的数据增强方法发展现状综述

    基于图像的数据增强方法发展现状综述 人工智能技术与咨询 2022 03 22 20 57 点击蓝字 关注我们 来源 xff1a 计算机科学与应用 xff0c 作者冯晓硕等 关键词 数据增强 xff1b 图像数据集 xff1b 图像处理 xf
  • 基于改进SSD算法的小目标检测与应用

    人工智能技术与咨询 点击蓝字 关注我们 来源 xff1a 计算机科学与应用 xff0c 作者刘洋等 关键词 SSD xff1b 深度学习 xff1b 小目标检测 摘要 xff1a 摘要 针对通用目标检测方法在复杂环境下检测小目标时效果不佳
  • Excel线性回归分析

    文章目录 一 学习任务二 学习内容1 1 高尔顿数据集进行线性回归分析1 1 1 父母身高平均值和其中一个子女身高进行回归分析1 1 2 父子身高回归方程1 1 3 母子身高回归方程 1 2 Anscombe四重奏数据集进行回归分析 一 学
  • 组网雷达融合处理组件化设计与仿真

    人工智能技术与咨询 点击蓝色 关注我们 关键词 xff1a 组网雷达 点迹融合 航迹融合 组件化设计 仿真 摘要 数据融合处理是多雷达组网的核心 以典型防空雷达网为参考对象 xff0c 采用组件化设计方式 xff0c 将组网数据融合处理过程
  • 人工智能 知识图谱

    关于举办 2022年数字信息化培训项目系列 知识图谱Knowledge Graph构建与应用研修班线上课程的通知 各有关单位 一 培训目标 本次课程安排紧密结合理论与实践 xff0c 深入浅出 xff0c 循序渐进 从基本概念讲起 xff0
  • 深度学习(Deep Learning)

    知识关键点 1 人工智能 深度学习的发展历程 2 深度学习框架 3 神经网络训练方法 4 卷积神经网络 xff0c 卷积核 池化 通道 激活函数 5 循环神经网络 xff0c 长短时记忆 LSTM 门控循环单元 GRU 6 参数初始化方法
  • 基于深度学习的机器人目标识别和跟踪

    如今 xff0c 深度学习算法的发展越来越迅速 xff0c 并且在图像处理以及目标对象识别方面已经得到了较为显著的突破 xff0c 无论是对检测对象的类型判断 xff0c 亦或者对检测对象所处方位的检测 xff0c 深度学习算法都取得了远超
  • 零基础Linux版MySQL源码方式安装+配置+远程连接完整图解 无坑实录

    无论开发还是运维 xff0c 项目环境搞不定 xff0c 还真让你干不成活 xff0c MySQL在不同场景 不同平台下安装方式也不同 xff0c 本次主要分享centos7下MySQL源码rpm方式安装 xff0c 其它方式后续分享 xf
  • C++,友元,语法+示例,非常详细!!!!

    友元概念 友元的目的就是让一个函数或者类 访问另外一个类中的私有成员 友元的关键字为 friend 友元的几种实现 全局函数做 友元类做 友元成员函数做 友元重载函数做 友元 全局函数做 友元 include lt iostream gt
  • STL——STL简介、STL六大组件

    一 STL是什么 STL standard template library xff1a C 43 43 标准模板库 xff0c 是C 43 43 标准库的重要组成部分 xff0c 不仅是一个可复用的组件库 xff0c 还是一个包罗数据结构
  • 文件流指针和文件描述符

    1 文件流指针和文件描述符的产生 fopen函数打开文件成功后会返回文件流指针 open函数打开文件成功后返回的是文件描述符 他俩的相同点是通过文件流指针和文件描述符都可以对文件进行操作 2 fopen函数和open函数的介绍 fopen函
  • docker 操作

    查看容器 xff1a sudo docker ps a 删除容器 xff1a sudo docker rm NAMES 容器的名字 下载镜像 xff1a sudo docker pull rmus2022 server v1 2 0 查看镜
  • 树莓派32位系统烧录及连接

    目录 前言 一 烧录树莓派系统 1 格式化tf卡 2 烧录系统 二 连接树莓派 1 开启SSH 2 开启网络共享 3 下载Putty 三 开启图形化界面 非必须 最后 xff1a 前言 我在树莓派环境搭建的过程中 xff0c 看了几十篇博客
  • 鸢尾花Iris数据集进行SVM线性分类

    文章目录 一 学习任务二 学习内容1 鸢尾花数据集使用SVM线性分类1 1 SVM介绍1 2 LinearSVC xff08 C xff09 方式实现分类1 3 分类后的内容基础上添加上下边界 三 参考博客 一 学习任务 安装python3
  • intel realsense d435i相机标定中文文档

    intel realsense d435i相机标定中文文档 此文档参考了官方的英文文档 xff0c 原地址面向英特尔 实感 深度摄像头的 IMU 校准工具 intelrealsense com IMU概述 xff1a 惯性测量单元 imu
  • VScode-git提交 无法推送refs到远端

    在将代码同步到远端仓库时 xff0c 弹窗提醒 无法推送refs到远端 您可以试着运行 拉取 功能 xff0c 整合您的更改 但尝试后发现 拉取 功能也无法解决问题 xff0c 最后是因为文件过大原因 xff0c 在这里记录一下解决方法 x
  • VMware16虚拟机中安装OpenEuler详细教程指南

    文章目录 安装前提准备镜像创建虚拟机安装欧拉踩坑指南 x1f351 网络指南 安装前提 Windown 10VMware 16openEuler 20 03 LTS SP3 准备镜像 镜像地址 xff1a OpenEuler 直接在官网下载
  • C/C++排序算法(三)—— 冒泡排序和快速排序

    文章目录 前言1 冒泡排序 x1f351 基本思想 x1f351 图解冒泡 x1f351 动图演示 x1f351 代码实现 x1f351 代码优化 x1f351 特性总结 2 快速排序 x1f351 hoare 版本 x1f345 图解过程
  • C/C++排序算法(四)—— 归并排序和计数排序

    文章目录 前言1 归并排序 x1f351 基本思想 x1f351 算法图解 x1f345 分组 x1f345 归并 x1f345 比较 x1f351 动图演示 x1f351 代码实现 x1f351 非递归实现 x1f345 情况一 x1f3
  • C++深入浅出(九)—— 多态

    文章目录 1 多态的概念2 多态的定义及实现 x1f351 多态的构成条件 x1f351 虚函数 x1f351 虚函数的重写 x1f351 虚函数重写的两个例外 x1f351 C 43 43 11的override 和 final x1f3