【C++】多态


作者简介:一名在后端领域学习,并渴望能够学有所成的追梦人。
个人主页:不 良
系列专栏:C++  Linux
学习格言:博观而约取,厚积而薄发
欢迎进来的小伙伴,如果小伙伴们在学习的过程中,发现有需要纠正的地方,烦请指正,希望能够与诸君一同成长!


多态的概念

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

比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。这就是一种多态行为

多态的定义和实现

多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

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

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

2、被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

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();
}
int main()
{
	//实例化Person对象
	Person p;
	func(p);

	//实例化Student对象
	Student s;
	func(s);
}

打印结果:

image-20230720161446600

BuyTicket就是虚函数,派生类Student对Person类的虚函数进行重写,并且通过Person类的引用调用虚函数:

【C++】多态_第1张图片

虚函数

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

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

只有类的非静态成员函数前可以加virtual,类的静态成员函数和类外的普通函数前不能加virtual。

虚函数这里的virtual和虚继承中的virtual是同一个关键字,但是它们之间没有任何关系。虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。

虚函数的重写

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

在这里没有函数重载的关系,重载必须在同一个作用域。

class Person {
public:
	virtual void BuyTicket()
	{
		cout << "Person:买票全价" << endl;
	}
};	
class Student :public Person {
public:
  	virtual void BuyTicket()
   //void BuyTicket()
   //注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用
	{
		cout << "Student:买票半价" << endl;
	}
};

void func(Person* p)
{
    //通过父类的指针调用虚函数
	p.BuyTicket();
}

void func(Person& p)
{
    //通过父类的引用调用虚函数
	p.BuyTicket();
}
int main()
{
	//实例化Person对象
	Person p;
	//实例化Student对象
	Student s;
    func(p);
	func(s);
    
    func(&p);
    func(&s);
}

Func函数参数中既可以传父类的对象也可以传子类的对象。构成多态以后传父类调用的是父类的虚函数,传子类调用的是子类的虚函数。

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

多态的两个条件:1.虚函数的重写/覆盖(重写要满足三同:函数名、参数、返回值都相同);2.父类的指针或者引用去调用。

当我们调用的时候不使用父类的指针或者引用可以实现多态吗?不可以,测试如下:

【C++】多态_第2张图片

虚函数重写的两个例外

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

多态条件中,有一个例外(协变):就是三同中函数的返回值可以不同,但是要求返回值必须是父子关系的指针或者引用。

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

如下程序中,父类中虚函数的返回值是父类的指针,子类的虚函数中返回值是子类的指针:

class Person {
public:
    //返回值是父类的指针
	virtual Person* BuyTicket()
	{
		cout << "Person:买票全价" << endl;
		return this;
	}
};	
class Student :public Person {
public:
    //返回值是子类的指针
	virtual Student* BuyTicket()
	{
		cout << "Student:买票半价" << endl;
		return this;
	}
};
void func(Person* p)
{
	//父类的指针调用虚函数
	p->BuyTicket();
}
int main()
{
	//实例化Person对象
	Person p;
	func(p);

	//实例化Student对象
	Student s;
	func(s);
}

这里我们要注意的是只要是父子关系的类就可以,并不一定要是本身,如下,A是父类,B是A的子类,也可以使用:

class A {};
class B:public A{};
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "Person:买票全价" << endl;
		return nullptr;
	}
};	
class Student :public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "Student:买票半价" << endl;
		return nullptr;
	}
};
void func(Person* p)
{
	//父类的指针调用虚函数
	p->BuyTicket();
}
int main()
{
	//实例化Person对象
	Person p;
	func(p);

	//实例化Student对象
	Student s;
	func(s);
}

上面两段代码的打印结果都是:

image-20230721161956690

上面的两个程序都能够实现多态,我们叫做协变。只能返回父子类对象的指针或者引用,返回对象本身是不可以的。

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

我们先看下面的代码:

class Person {
public:
	~Person()
	{
		cout << "~Person()" << endl;
	}
};
class Student :public Person {
public:
	~Student()
	{
		cout << "~Student()" << endl;
	}
};
int main()
{
	Person p;
	Student s;
}

上面代码执行之后的打印结果为:

image-20230721163718812

当我们将main函数后中内容更改为:

int main()
{
	Person* ptr1 = new Person;
	Person* ptr2 = new Student;
    
	delete ptr1;
	delete ptr2;
	return 0;
}

打印结果为:

image-20230721163856332

指针ptr1指向父类,指针ptr2指向子类,指针类型都是Person类型,指向子类的指针调用的是父类的析构函数,如果子类中有一些资源需要释放,没调用到子类的析构函数可能会导致内存泄漏。

我们在继承中学过析构函数都会被重命名为destructor(),所以父类和子类中的析构函数会构成隐藏/重定义,谁的指针就调用谁的析构函数,上面两个指针都是父类的,所以将会调用父类的析构函数。

【C++】多态_第3张图片

我们期望按指向的对象类去调用对应的析构。在编译层来说析构函数的函数名相同,但是加虚函数构成多态就可以实现:

class Person {
public:
    //虚函数
	virtual ~Person()
	{
		cout << "~Person()" << endl;
	}
};
class Student :public Person {
public:
    //虚函数
    //~Student()
	virtual ~Student()
	{
		cout << "~Student()" << endl;
	}
};
int main()
{
	Person* ptr1 = new Person;
	Person* ptr2 = new Student;

	delete ptr1;
	delete ptr2;
	return 0;
}

打印结果:

image-20230721164957335

上面这种可能会造成内存泄漏的情况只有在析构函数实现多态的时候才能解决,所以往往将父类的析构函数写成虚函数。

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

多态的条件:1.虚函数的重写:三同(函数名、参数、返回值)2.父类的指针或者引用去调用。

1.不满足多态看类型–看调用者的类型,调用这个类型的成员函数。

2.满足多态 – 看指向的对象的类型,调用这个类型的成员函数。

下面的代码不构成重写,是隐藏关系(父类中的函数没有加virtual关键字):

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

什么时候能够体现出隐藏关系?

子类调用的时候才能体现出来,上面的代码中没有隐藏关系的体现,隐藏的时候如果想调用父类的成员可以指定父类作用域。

下面的代码构成重写(父类中有virtual关键字),子类可以不写关键字:也可以构成重写:

class Person {
public:
	virtual void Buyticket()
	{
		cout << "买票-全价" << endl;
	}
};
class Student :public Person {
public:
	//重写/覆盖
	 void Buyticket()
	{
		cout << "买票-半价" << endl;
	}
};

重写体现的是接口继承,就是把函数的声明继承下来重写的是函数的实现。子类不写关键字也可以,因为他继承父类的接口,重写实现。

观察下面的代码分析打印结果:

class Person {
public:
	virtual void Buyticket()
	{
		cout << "买票-全价" << endl;
	}
	~Person()
	{
		cout << " ~Person()" << endl;
	}
};
class Student :public Person {
public:
	//重写/覆盖
	void Buyticket()
	{
		cout << "买票-半价" << endl;
	}
	 ~Student()
	 {
		 cout << " ~Student()" << endl;
	 }
};
void Func(Person* p)
{
	p->Buyticket();
	delete p;
}
int main()
{
	Func(new Person);
	Func(new Student);
	return 0;
}

上面的代码中Buyticket是虚函数,在子类中也完成了重写,满足多态,满足多态时看指向的对象的类型,调用这个类型的成员函数,此时一个父类指针指向父类对象,一个父类指针指向子类对象,但是析构函数并不满足多态,所以我们要看调用者的类型,也就是指针的类型,指针类型都是父类,此时打印结果如下:

image-20230721172522889

当我们将父类的析构函数改为虚函数之后就实现了析构函数的调用,使其父类和子类的析构函数实现多态之后打印结果:

【C++】多态_第4张图片

C++11 override和final关键字

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

1、final关键字,修饰虚函数,表示该虚函数不能再被重写,在函数最后加上final。

如下代码中,在Person类中的Buyticket函数中加上final,表明该函数不能再被重写,如果再重写将会报错。

class Person {
public:
	virtual void Buyticket() final
	{
		cout << "买票-全价" << endl;
	}
};
class Student :public Person {
public:
	//重写Buyticket将会报错
	virtual void Buyticket()
	{
		cout << "买票-半价" << endl;
	}
};

2、override关键字:检查派生类虚函数是否重写了某个基类虚函数,如果没有重写编译报错。虚函数就是为重写而生的。

下面的代码中,子类Student的虚函数Buyticket被override修饰,编译时会检查Student类中的BuyTicket函数是否重写了父类的虚函数,如果没有则会编译报错。所以override关键字要放在派生类的虚函数后面。

class Person {
public:
	virtual void Buyticket() 
	{
		cout << "买票-全价" << endl;
	}
};
class Student :public Person {
public:
	//重写,报错
	virtual void Buyticket() override
	{
		cout << "买票-半价" << endl;
	}
};

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

【C++】多态_第5张图片

抽象类

概念

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

下面的类就是抽象类(接口类),不能实例化出对象:

class Car {
public:
	virtual void Drive() = 0;
};
int main()
{
	//抽象类不能实例化出对象
	Car c;//error
	return 0;
}

**纯虚函数不用写实现,直接写声明就可以。包含纯虚函数的类就叫做抽象类。**一个类型在现实中没有对应的实体,我们就可以把一个类定义为抽象类。抽象类的特点就是不能实例化出对象。

**派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承。**下面的代码中没有重写,BWM也是一个抽象类,继承了父类。

class Car {
public:
	virtual void Drive() = 0;
};
class BMW :public Car
{
public:
};
int main()
{
	//抽象类不能实例化出对象
	//Car c;//error
	//没有重写纯虚函数的派生类继承后也不能实例化出对象
	BMW b;//error
	return 0;
}

重写了之后就不是纯虚函数了,派生类就可以实例化出对象,注意基类不能实例化出对象:

class Car {
public:
	virtual void Drive() = 0;
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "My name is BMW" << endl;
	}
};
int main()
{
	BMW b;
	return 0;
}

纯虚函数和override的区别:一个是间接强制重写,一个检查是否重写。

纯虚函数强制子类去重写虚函数。

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

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

接口继承和实现继承

实现继承:普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。

接口继承:虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

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

练习:

看下面的代码选择正确答案:

#include 
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:以上都不正确

正确答案:B。在子类B中没有实现test函数,但是由于继承关系,所以可以调用A类中的成员函数test,func函数实现了多态,看指针指向的类型,是B类,所以在test函数中调用的是B类中的func函数,而且由于是接口继承,多态中实现的是函数体内容,所以缺省值还是A中的val = 1,所以最后的打印结果是B->1。

A类中的隐藏参数是A类的指针,也就是父类的指针,所以构成多态。如果将代码中的test函数放到B类中,隐藏参数类型就是B类指针,就不构成多态了:

#include 
using namespace std;
class A {
public:
	virtual void func(int val = 1) {
		cout << "A->" << val << endl;
	}
};
class B : public A
{
public:
	void func(int val = 0)
	{
		cout << "B->" << val << endl;
	}
 //隐藏的就是B类指针
 virtual void test()
	{
		func();
	}
};
int main()
{
	B* p = new B;
	p->test();
	return 0;
}

该代码的打印结果为B->0。

多态原理

虚函数表

观察下面代码,Base类实例化出对象的大小是多少?

class Base {
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
	char _ch;
};
int main()
{
	cout << sizeof(Base) << endl; //12
	return 0;
}

输出结果是12。

当我们实例化出一个Base对象b时,打开监视窗口,发现除了_b成员和_ch成员外,还有一个类型为void**的二级指针_vfptr在对象的前面(有些平台可能放在对象的最后面),我们把这个指针叫做虚函数表指针。

【C++】多态_第6张图片

对象中的这个指针叫做虚函数表指针(v代表virtual,f代表function) ,简称虚表指针。一个含有虚函数的类中至少都有一个虚表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。虚表指针指向一个虚函数表

下面代码中有一个父类Person和子类Student,其中BuyTicket函数是虚函数且构成重写:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person p;
	Func(p);

	Student s;
	Func(p);
	return 0;
}

我们实例化一个Person类对象p和一个Student类对象s,运行以后打开监视窗口:

【C++】多态_第7张图片

我们可以看到对象中都有一个虚表指针。

在多态中是怎么做到指向父类对象调用父类的函数,指向子类对象调用子类的函数的呢?

父类虚表里面存的是父类的虚函数,子类虚表里面存的是子类的虚函数。

【C++】多态_第8张图片

编译器在编译时确定是否构成多态,如果不构成多态编译时就会确定调用函数的地址,判断Person类型并且去Person类里面确定函数找到函数地址;如果构成多态,运行起来以后,调用时去指向的对象的虚表里去找函数的地址调用函数。

【C++】多态_第9张图片

构成多态和引用的对象无关,要看指向的对象,指向父类调用父类的函数,指向子类调用子类的函数。

上面的汇编代码中call那句才是调用,p.BuyTicket();并不知道自己调用的是父类还是子类,因为参数是父类的引用,如果传的是子类对象的话会切片。

函数重写也叫函数覆盖:子类继承了父类的虚函数表,如果子类对父类的虚函数进行重写,将会改变子类虚函数表中重写的虚函数的地址,也就是子类重写了虚函数以后,会把父类虚函数表里面对应位置的函数覆盖成自己的虚函数。所以我们可以理解:重写是语法层的概念,覆盖是原理层的概念。

思考:

1、多态的条件为什么是重写?

因为子类继承了父类的虚函数表,而且子类会对父类的虚函数进行重写,改变子类虚函数表中重写的虚函数的地址,此时子类的虚函数表中就是未重写的虚函数地址和重写的虚函数的地址,此时如果再调用重写的虚函数时父类和子类对象将会有不同的结果。

2、为什么实现多态的条件是父类的指针或者引用?

父类的指针和引用既可以指向子类对象也可以指向父类对象。虚函数表里存的是虚函数的地址,在对象中存一个指针(虚函数表指针也叫虚表指针)指向虚函数表,虚函数表中的指针指向虚函数。那为什么虚函数不存到对象上面?因为可能有多个虚函数,都存到对象里面不合适;其次同类型对象的虚表相同,使用一份虚表就可以。

虚函数表本质是一个函数指针数组(虚函数指针数组),一般情况下会在这个数组最后放一个nullptr。

那当父类中有多个虚函数时:

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

	Student s;
	Func(s);	
	return 0;
}

我们通过监视窗口来看:

【C++】多态_第10张图片

通过上图,我们能够发现子类重写的虚函数BuyTicket函数完成了覆盖(函数地址不同),但是子类中没有重写的虚函数Func函数就没有完成覆盖(地址没有改变)。

虚函数表是在编译时候就确定好的。虚函数表中的顺序是谁先声明谁就在虚函数表中的第一个位置,是按照函数声明顺序排序。

当我们使用父类的对象时:

void Func(Person p)
{
	p.BuyTicket();
}

为什么对象不可以实现多态?指针或者引用的切片和对象切片不一样,指针或者引用的切片是指向子类的一部分,子类的虚表不改变;如果是对象,子类的切片会拷贝给父类,但是虚表不能拷贝过去。测试监视窗口如下:

【C++】多态_第11张图片

由上图可见虚表不能拷贝过去,因为拷贝过去之后分不清对象到底是父类对象还是子类对象。使用父类对象时,切片得到部分成员变量后,在调用函数传参时会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造,而拷贝构造出来的父类对象P和p当中的虚表指针指向的都是父类对象的虚表。因为同类型的对象共享一张虚表,他们的虚表指针指向的虚表是一样的。

上述Func函数中参数是父类的对象,所以虚表一定是父类的虚表。如果是对象的切片只会拷贝成员,不会拷贝虚表。

我们再来看下使用引用的监视窗口:

void Func(Person& P)
{
	P.BuyTicket();
}

【C++】多态_第12张图片

通过上述监视窗口中的Func函数中显示出的虚表中存放的函数地址,我们可以看到引用的切片,子类的虚表不会改变。

总结:

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

目前我们可以知道派生类中的虚表生成步骤为:

1、先将基类中的虚表内容拷贝一份到派生类的虚表;

2、将派生类中重写的虚函数的地址覆盖掉继承的基类中的虚函数的地址;

3、派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

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

虚函数和普通函数的区别是什么呢?编译完成之后都存在代码段,但是虚函数的地址会被放进虚函数表。

同一个类实例化出来的对象共用同一个虚表,虚表指针一样;但是父类和子类的虚表不一样,虚表指针也就不同。

【C++】多态_第13张图片

我们可以通过下面打印地址的函数来验证一下虚表是否是存在代码段的:

class Person {
public:
	virtual void BuyTicket() {
		cout << "买票-全价" << endl;
	}
};
class Student : public Person {
public:
	virtual void BuyTicket() {
		cout << "买票-半价" << endl;
	}
};
int j = 0;//全局变量
int main()
{
	Person  p;
	Person* ptr = &p;//父类的指针或者引用
	//虚表指针在最前面,4个字节强制类型转换成int*,然后解引用就得到了虚表指针
	printf("_vfptr:%p\n", *(int*)ptr);
	int i = 0;
	printf("栈地址:%p\n", &i);
	printf("数据段地址:%p\n", &j);	

	int* pi = new int;
	printf("堆上地址:%p\n", pi);
	const char* c = "hello";
	printf("代码段地址:%p\n", c);
	return 0;
}

打印结果如下:我们发现虚表的地址和代码段的地址十分接近。

【C++】多态_第14张图片

动态绑定和静态绑定

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

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

通过下面的代码及汇编指令来认识动态绑定和静态绑定:

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

当main函数是下面代码时不构成多态:

int main()
{
	Student s;
	Person p = s;	//不构成多态
	p.BuyTicket();//打印结果为买票-全价
	return 0;
}

多态的条件:1、虚函数重写(三同:函数名、参数、返回值);2、父类的指针或者引用调用虚函数。

上面调用函数的汇编代码如下,函数的调用是在编译时就确定的,直接使用call调用函数:

【C++】多态_第15张图片

如果我们将main函数改为下面这样,使其构成多态,函数的调用是在运行时确定的:

int main()
{
	Student s;
	Person& p = s;	//不构成多态
	p.BuyTicket();//构成多态,打印结果为买票-半价
	return 0;
}

汇编代码如下:汇编指令比较多的主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数的地址,然后才能进行函数的调用。

【C++】多态_第16张图片

静态绑定是在编译时确定的,而动态绑定是在运行时确定的。

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

单继承中的虚函数表

通过下面的代码我们来看一下单继承关系中基类和派生类的虚表模型:

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:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

上面的代码中只有Func1在子类中完成了重写,我们知道虚函数的地址会进入虚表,父类和子类的虚表如下:可以理解为子类是将父类的虚表拷贝过来,然后Func1完成了重写,覆盖了虚函数表中原来的Func1虚函数地址。

【C++】多态_第17张图片

指向不同的对象,调用不同的函数。

我们将上面的代码进行修改,在子类中加了一个Func4函数:

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:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

那当我们在子类中加了Func4之后,父类中的虚函数有Func1和Func2,子类中的虚函数有Func1和Func4,Func4没有完成重写。

通过监视窗口看派生类中的虚表发现func4不在虚表里面:

【C++】多态_第18张图片

通过内存窗口查看,输入虚表指针:

【C++】多态_第19张图片

我们猜测图中0x00e49ea4就是Func4,我们可以通过程序打印虚表来查看:

typedef void(*VF_PTR)(),声明一个函数指针;VS系列在虚表(函数指针数组)的最后放了一个nullptr,g++中没有。

怎么把虚表的地址给取出来?虚表指针在类对象的前4个字节(32位)或者前8个字节(64位)上,我们可以强转成int类型的指针:PrintVFTable((VF_PTR*)(*(int*)&b));

C++中哪些支持隐式类型的转换? 只有相近类型才能转换。

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:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
	virtual void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;
};
//用
//声明一个函数指针
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR table[])
{
	for (int i = 0; table[i] != nullptr; i++)
	{	
		printf("[%d]:%p\n", i, table[i]);
	}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
	//打印Base类对象的虚表
	PrintVFTable((VF_PTR*)(*(int*)&b));//这种写法带有局限性,只能在32位下
	//转换成int*,得到前4个字节,然后解引用得到存储的内容,再强制转换得到虚表的地址
	//打印Derive类对象的虚表
	PrintVFTable((VF_PTR*)(*(int*)&d));
}

注意在这里要清理下解决方案,否则结果可能不正确。

打印结果:

【C++】多态_第20张图片

我们还可以通过:PrintVFTable(*(VF_PTR**)&b);PrintVFTable(*(VF_PTR**)&d);得到虚表的地址。

但是如果这样写PrintVFTable((VF_PTR)&d);是不正确的,得到的不是前4个字节,但是我们要传过去的地址在前4个字节。

可以通过将PrintVFTable函数修改为下面这样,通过函数指针调用函数打印出具体的函数名:

void PrintVFTable(VF_PTR table[])
{
	for (int i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p->", i, table[i]);
		VF_PTR f = table[i];
		f();//调用
	}
	cout << endl;
}

打印结果:

【C++】多态_第21张图片

由打印结果我们可以知道派生类的虚表中有Func4函数。

经过上面的学习,思考两个问题:

1.虚表什么阶段生成的?—编译

2.对象中虚表指针是什么时候初始化的?—构造函数的初始化列表阶段

3.虚表存在哪里?—虚表不在对象里,对象里存的是虚表指针; 不能在栈上,因为各个对象都要访问虚表;也不是在堆上,堆往往需要动态申请;通过上面的学习我们知道虚表一般是在常量区(代码段),有的编译器会将其放在静态区。

多继承中的虚函数表

通过下面的代码我们来看一下多继承关系中基类和派生类的虚表模型:

class Base1 {
public:
	virtual void func1() {
		cout << "Base1::func1" << endl;
	}
	virtual void func2() {
		cout << "Base1::func2" << endl; 
	}
private:
	int _b1;
};
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 _d;
};
int main()
{
	Base1 b1;
	Base2 b2;
	Derive d;
	return 0;
}

上面代码中两个基类的虚表模型如下:

【C++】多态_第22张图片

但是上面的派生类Derive中有两张虚表,分别继承了Base1和Base2,在监视窗口我们只能看到下面的内容,并不能看到Derive类对象中的虚函数Func3:

【C++】多态_第23张图片

由上可以知道Derive中有两张虚表,分别继承了Base1和Base2,两张虚表中重写了Func1函数,但是子类中增加的Func3函数在哪里呢?我们可以借助打印虚表,但是我们要怎么知道Derive对象中Base2的虚表呢?两张表不是连续的,中间可能还有成员,所以我们可以通过PrintVFTable((VF_PTR*)(*(int*)((char*)&d + sizeof(Base1))));来计算,PrintVFTable((VF_PTR*)(*(int*)(&d + sizeof(Base1))));这样计算不正确,所以改成(char*)&d,因为char指针+1是1个字节,而int类型的指针+1是向后4个字节。

将main函数的代码修改并且使用上述找虚表的方式:

int main()
{
	Derive d;
	//打印Base1的虚表
	PrintVFTable((VF_PTR*)(*(int*)&d));
    //打印Base2的虚表
	PrintVFTable((VF_PTR*)(*(int*)((char*)&d + sizeof(Base1))));
}

访问Base2虚表时还可以借助指针的偏移(切片,直接指向Base2的地址):PrintVFTable((VF_PTR*)(*(int*)(ptr)));

int main()
{
	Derive d;
	//打印Base1的虚表
	PrintVFTable((VF_PTR*)(*(int*)&d));
    //打印Base2的虚表
	Base2* ptr = &d;//切片
	PrintVFTable((VF_PTR*)(*(int*)(ptr)));
}

打印结果:

【C++】多态_第24张图片

通过上面我们发现,Func3函数被放到了Base1的虚表中,Func1既重写了Base1,也重写了Base2。但是多继承以后,虚表中重写的Func1地址不一样。但是我们发现多继承之后,虚表中重写的Func1地址不一样,我们通过下面的汇编代码来理解:

ptr1取到了虚表的地址,call,jump之后是函数指针真正的地址;ptr2调用call之后,遇到jum,跳到sub,再jmp之后调用到函数:

【C++】多态_第25张图片

ptr1的调用是一个正常调用,就是call了一个地址;但是为什么ptr2要绕了那么多层?因为有一个关键汇编指令sub ecx,8,ecx存的是this指针;Func1是子类的函数,ptr1调用子类的函数,指针没有问题;ptr2没有指向对象的开始,这个部分在修正this指针的位置。

如果先继承的是Base2,就要修正ptr1的this指针;如果增加Base1或者Base2的成员变量,也有可能影响到修正的数据。

练习题:观察下面代码选择正确答案:

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

选择C。谁先继承谁就在前面。

【C++】多态_第26张图片

菱形继承、菱形虚拟继承

通过下面代码来理解:

class Person
{
public:
	virtual void func1()
	{
		cout << "Person::func1()" << endl;
	}
private:
	int _p;
};
class Student : virtual public Person
{
public:
	virtual void func1()
	{
		cout << "Student::func1()" << endl;
	}
	virtual void func2()
	{
		cout << "Student::func2()" << endl;
	}
private:
	int _s;
};
class Teacher : virtual public Person
{
public:
	virtual void func1()
	{
		cout << "Teacher::func1()" << endl;
	}
	virtual void func3()
	{
		cout << "Teacher::func3()" << endl;
	}
private:
	int _t;
};
class Cadre : public Student, public Teacher
{
public:
	virtual void func1()
	{
		cout << "Cadre::funcA()" << endl;
	}
	virtual void func4()
	{
		cout << "Cadre::func4()" << endl;
	}
private:
	int _c;
};

代码当中的继承关系为虚继承,能够有效解决数据冗余和二义性,继承关系如下图:

【C++】多态_第27张图片

Person类当中有一个虚函数func1、一个虚表指针_vfptr以及成员变量_p,虚表当中存储的是Person类中虚函数func1函数的地址:

image-20230722204918953

Student类当中重写了虚函数func1,还有一个新的虚函数func2,以及一个虚表指针_vfptr、一个虚基表指针_vbptr、成员变量_s,继承了Person类中的成员,因为是虚继承所以Person类的成员放到最下面,通过偏移量调用,虚表当中存储的是Student类虚函数func2的地址。

虚基表当中存储的是两个偏移量,第一个是Student类虚基表指针距离Student类虚表指针的偏移量,第二个是Student类虚基表指针距离基类Person类的偏移量。

【C++】多态_第28张图片

Teacher类当中重写了虚函数func1,还有一个新的虚函数func3,以及一个虚表指针_vfptr、一个虚基表指针_vbptr、成员变量_t,继承了Person类中的成员,因为是虚继承所以Person类的成员放到最下面,通过偏移量调用,虚表当中存储的是Teacher类虚函数func3的地址。

虚基表当中存储的是两个偏移量,第一个是Teacher类虚基表指针距离Teacher类虚表指针的偏移量,第二个是Teacher类虚基表指针距离基类Person类的偏移量。

【C++】多态_第29张图片

Cadre类的继承方式是菱形虚拟继承,在Cadre类对象当中,将Person类的成员放到了最后,除此之外,Cadre类对象的成员还包括从Student类继承下来的成员、从Teacher类继承下来的成员和自己的成员变量_c。根据多继承那里我们能够知道Cadre类对象当中的虚函数func4的地址是被存储到了Student类的虚表当中。

【C++】多态_第30张图片

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面使用这样的模型访问基类成员有一定的性能损耗。

继承和多态常见习题

概念

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 
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 C class D

9、下面说法正确的是?(多继承中指针的偏移问题)

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

10、以下程序输出结果是什么?

#include 
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. A 2. D 3. C 4. A 5. B 6.D 7. D 8. A 9. C 10. B

问答

1、什么是多态?

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

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

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

3、多态的实现原理?

构成多态的父类对象和子类对象的成员当中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是类中虚函数地址。构成多态时要根据指向的对象调用函数,因此,当父类指针指向父类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是父类当中对应的虚函数;当父类指针指向子类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是子类当中对应的虚函数。

4、inline函数可以是虚函数吗?

虚函数可以使用inline修饰,但是编译器会忽略,因为虚函数将会放到虚表中。

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

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

不可以,静态函数没有this指针,所以没办法存放虚表。

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

6、构造函数可以是虚函数吗?

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

7、析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

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

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

对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,普通函数在编译期间就确定地址了,可以直接访问,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。

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

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

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

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

11、什么是抽象类?抽象类的作用?

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

抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去实现纯虚函数,因为子类若是不实现从父类继承下来的纯虚函数,那么子类也会是抽象类,不能实例化出对象。其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型。比如车等一些概念性的东西。

你可能感兴趣的:(C++,c++,开发语言,后端)