在C++编程中,多态是一种强大而重要的概念,它可以帮助我们实现更灵活和可扩展的代码。多态允许不同类型的对象对相同的消息做出不同的响应,这在实际应用中非常有用。
多态的实际应用非常广泛,尤其在面向对象的程序设计中。通过使用多态,我们可以编写通用的代码,而不需要考虑对象的具体类型。这种灵活性使得我们可以更容易地扩展和修改代码,而不会影响其他部分的功能。
在C++语法中,多态具有特殊的地位。C++通过在基类中声明虚函数,并使用动态绑定和虚函数表来实现多态。这使得C++成为一种非常适合面向对象编程的语言,能够充分发挥多态的优势。
尽管多态在编程中非常有用,但它也具有一定的难度。理解和正确使用多态需要掌握一些复杂的概念和技术,如虚函数、虚函数表和动态绑定。此外,多态还需要注意一些细节,如构造函数不能是虚函数,静态成员函数也不能是虚函数等。因此,学习和应用多态需要一定的时间和专注力。
多态的理解和应用在笔试面试中也具有重要性。很多公司在面试中会考察对多态的理解和使用能力。掌握多态不仅能够展示出对面向对象编程的深刻理解,还能够解决一些复杂的问题,并写出更优雅和高效的代码。
在本博客系列中,我们将深入探讨C++中多态的原理和实际应用。我们将介绍虚函数、动态绑定、虚函数表等概念,并通过具体的示例代码来说明多态的使用方法和技巧。无论你是初学者还是有一定经验的开发者,通过学习多态,你将能够写出更灵活和可扩展的代码,并在面试中展现自己的优势。
个人主页:Oldinjuly的个人主页
收录专栏:数据结构
欢迎各位点赞收藏⭐关注❤️
目录
1.多态的概念
2.多态的定义及实现
2.1 虚函数
2.2 虚函数重写
2.3 多态的构成条件
2.4 C++11 override和final关键字
2.5 重载,覆盖(重写),隐藏(重定义)的对比
3.抽象类
3.1.抽象类和纯虚函数
3.2.接口继承和实现继承
4.多态的原理
4.1 虚函数表
4.2 多态的原理
4.3 动态绑定与静态绑定
5.单继承和多继承关系中的虚函数表
5.1 单继承
5.2 多继承
5.3 菱形虚拟继承中的多态
6.继承和多态常见的面试题
6.1 什么是多态?
6.2 什么是重载、重写(覆盖)、重定义(隐藏)?
6.3 多态的实现原理?
6.4 inline函数可以是虚函数吗?
6.5 静态成员可以是虚函数吗?
6.6 构造函数可以是虚函数吗?
6.7 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
6.8 拷贝构造和operator=()可以是虚函数吗?
6.9 对象访问普通函数快还是虚函数更快?
6.10 虚函数表是在什么阶段生成的,存在哪的?
6.11 C++菱形继承的问题?虚继承的原理?
6.12 什么是抽象类?抽象类的作用?
多态的概念:通俗来说,就是多种形态,具体点就是去完成同一个行为,当不同的对象去完成时会产生出不同的状态。
举例:
总结:不同的对象完成同一个行为会有不同的效果。
在介绍多态的实现条件前,先引入两个概念:虚函数和虚函数重写
虚函数:即被virtual修饰的类成员函数
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),并且派生类重写了基类的函数实现,称子类的虚函数重写了基类的虚函数。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
总结:虚函数重写/覆盖条件:
- 基类和派生类都是虚函数
- 三同(函数名,函数参数,返回值相同)(返回值和参数不同,只构成隐藏)
- 派生类重写基类虚函数的实现
三个例外:
1.子类虚函数重写时可以不加virtual关键字,依旧构成重写,原理和后面要介绍的接口继承有关。
2.协变:基类和派生类虚函数的返回值类型可以不同
重写时,基类和派生类虚函数的返回值可以不同,但必须要求返回的是父子关系类型的指针或者引用(父返回父,子返回子)。这里的父子关系类型可以是其他父子关系的类,不仅仅是这里的基类和派生类。
class A {};
class B : public A {};
class Person {
public:
virtual A* f() { return new A; }
};
class Student : public Person {
public:
virtual B* f() { return new B; }
};
3.析构函数的重写:
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
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 ps;
Student st;
Func(ps);
Func(st);
return 0;
}
构成多态的两个条件:
- 必须通过基类的指针或者引用来调用虚函数。
- 被调用的必须是虚函数,并且派生类对基类的虚函数进行重写。
其实还有一个隐性支持多态的语法机制:赋值兼容转换(切片原理)。父类的指针或者引用可以指向子类对象。
那么这个调用虚函数的基类指针或者引用既可以指向基类对象,也可以指向派生类对象。基类指针或者引用指向的是谁,就会调用谁重写的虚函数。
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1.final:修饰虚函数,表示该虚函数不能被重写。(很扯淡的关键字,极少用)
2.override:检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写编译报错。
class Car {
public:
virtual void Drive() {}
};
class Benz :public Car
{
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
纯虚函数的出现,也使得派生类间接强制重写。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了对实现进行重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
所以上面所说的虚函数重写条件中,派生类的虚函数可以不加virtual,因为接口继承,已经自带virtual了。
想了解虚函数表,先来看一道常考的笔试题:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
我们发现sizeof(Base)=8(x86环境下测试)
通过监视窗口,我们来观察基类对象模型:除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,这个指针指向一个虚函数表(简称虚表),虚函数表中存放的是类中虚函数的指针。
注意几点:
派生类中的虚表又是个什么情况?
这里我们为了观察细致,类中多写几个虚函数。
// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
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;
}
通过监视窗口的观察,我们发现了以下几个问题:
总结:
1.派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是,另一部分是自己的成员。
2.基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层的叫法,覆盖是原理层的叫法。
补充:
a.同一个类型的对象共用一个虚表;
b.不管是否重写,子类虚表和父类虚表都不是同一个
3.另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
注意:只要是虚函数,就要放进虚表中。
4.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5.总结一下派生类的虚表生成:
只要重写,派生类 重写的虚函数地址 就会覆盖 从基类拷贝过来的虚函数地址,函数地址就会改变。
不重写,虚函数地址就是从基类拷贝过来的,函数地址和基类的一样。
6.虚函数存在哪的?虚表存在哪的? 注意:虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在于代码段的(后面验证)。
前面说过,派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
如何验证呢?我们可以尝试打印出虚表中的函数
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Derive : public Base1
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
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()
{
Derive d;
VFPTR* vTableb = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb);
return 0;
}
我们发现,这里的代码对指针掌握程度的要求极高,具体复习详见:指针进阶
我们针对晦涩代码进行解读:
1.函数指针类型重命名
typedef void(*VFPTR) ();
typedef对函数指针类型的重命名和创建函数指针类型的变量一样,要符合语法规范。
2.数组指针和数组名:
void PrintVTable(VFPTR vTable[]);
VFPTR* vTableb = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb);
最终我们发现:派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
上面研究了这么长时间的虚函数表,那么多态的原理到底是什么?
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
//多态调用
void Func(Person& p)
{
p.BuyTicket();
}
//普通调用
//void Func(Person p)
//{
// p.BuyTicket();
//}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
前面我们说过多态的一个条件就是父类的指针或者引用调用虚函数。而且这个父类指针或者引用可以指向父类对象自己,也可以指向子类对象。
总结:多态的本质原理:
到父类指针(或者引用)指向对象的虚表中找到要调用的虚函数地址,进行调用。
多态的函数调用和普通函数有什么区别呢?
多态调用:运行时到指向对象的虚表中找到要调用的函数地址,进行调用。
普通调用:编译链接时确定函数地址,运行时直接调用。
我们可以用汇编代码进行分析:
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;
};
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 pf = vTable[i];
pf();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&b);
VFPTR* vTableb2 = (VFPTR*)(*(int*)&d);
cout << "Base1:" << endl;
PrintVTable(vTableb1);
cout << "Derive:" << endl;
PrintVTable(vTableb2);
return 0;
}
观察下图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数。
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 d1;
};
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 pf = vTable[i];
pf();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
//VFPTR* vTableb2 = (VFPTR*)(*(int*)((Base2*)&d));//代码一
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));//代码二,和代码一效果一样
PrintVTable(vTableb1);
PrintVTable(vTableb2);
return 0;
}
(注意:监视窗口的地址和打印的地址不一样,是因为两次调用是分开的。)
如何分开打印两个虚表的内容呢?
VFPTR* vTableb2 = (VFPTR*)(*(int*)((Base2*)&d));//代码一
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));//代码二,和代码一效果一样
通过上面两个对象内存成员模型中可以看出:
细心的人会发现,两个虚表中func1的地址不一样,这是为什么?
其实我们再打印一下func1的地址,发现三个地址都不一样:
printf("func1:0X%x\n", &Derive::func1);
//注意:打印成员函数地址要加&,并且要指定类域
这是为什么呢?我么可以通过底层汇编代码进行观察:
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb1);
PrintVTable(vTableb2);
d.func1();
Base1* ptr1 = &d;
ptr1->func1();
Base2* ptr2 = &d;
ptr2->func1();
return 0;
}
通过汇编代码分析得出:其实三个地址都能调用函数,只是有的地址需要绕很多层,有的函数可以直接call来调用,形象来说,他们都能吃到蛋糕,只不过有的人只要拆一层,有的人要拆两层的道理。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强,可以去看下面的两篇链接文章。
注意:D类中一定要重写公共基类A的虚函数,因为为了解决数据冗余和二义性问题,D类中只有一个公共的A类成员,B和C都重写的A类的虚函数,A类不知道从哪里继承,所以要重写。
菱形虚拟继承的多态会出现三个虚表,相当复杂,所以不会使用菱形虚拟继承。
不同的对象完成同一件事情会产生不同的效果。
多态的构成条件:父类指针或者引用调用虚函数。派生类重写虚函数。
略
答:可以,不过编译器就忽略inline属性(inline本身也是个建议性的关键字),这个函数就不再是inline。因为虚函数的地址要放到虚表中去,但是inline函数没有地址,他是直接展开的,所以inline函数和虚函数是互斥的。多态调用中inline会失效。
答:不能,因为静态成员函数没有this指针,都是在编译时决议。无法访问虚函数表,所以静态成员函数无法放进虚函数表。
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。如果构造函数是虚函数,就要到虚函数表中找到构造函数的地址,但是此时并没有虚表指针,找不到虚表。
答:可以,并且最好把基类的析构函数定义成虚函数。(前面有介绍)
主要用于以下场景:
class Person {
public:
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,
//才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
如果这里不是用多态,delete p2时调用的是Person的析构函数,无法正确析构。
拷贝构造不可以是虚函数,答案和构造一样。
operator=() 可以,但是没有实际价值。
class A
{
public:
A()
:_a(0)
{}
virtual A& operator=(const A& aa)
{
return *this;
}
public:
int _a;
};
class B : public A
{
public:
virtual B& operator=(const B& aa)//这里不构成重写,参数不同,因此不构成多态
{
return *this;
}
virtual B& operator=(const A& aa)//基类对象可以赋值给派生类对象
{
return *this;
}
};
答:
首先如果是普通对象,是一样快的。
如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
可以用以下代码验证:
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
int main()
{
Student s;
printf("虚表:%p\n", *((int*)&s));
static int x = 0;
printf("static变量:%p\n", &x);
const char* ptr = "hello world";
printf("常量:%p\n", ptr);
int y = 0;
printf("局部变量:%p\n", &y);
printf("new变量:%p\n", new int);
return 0;
}
我们发现虚表的地址和static变量、常量的地址很相近,所以虚函数表大概率是存放在代码段了。
答:
问题:数据冗余和二义性的问题。
虚拟继承相比普通继承,普通继承会从公共基类中继承多个成员,造成了数据冗余和二义性问题。而虚拟继承的内存对象模型中只有一个公共基类的成员,从而避免了数据冗余问题;虚拟继承又会在继承体系中设置虚基表和虚基表指针,虚基表中存放着偏移量,通过偏移量可以找到对应类所继承的公共基类成员,解决二义性问题。
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类);
作用:给其他类当做接口。实现多态。约束派生类(派生类必须强制重写虚函数)。