多态从入门到精通:面试必问的经典题目与解答
多态从入门到精通:面试必问的经典题目与解答
多态是C++面向对象编程中的一个重要概念,也是面试中经常被问到的话题。本文将从多态的基本概念出发,详细讲解其原理和实现方式,并通过一系列面试常见问题,帮助读者全面掌握多态的相关知识。
1.什么是多态?
这是C++多态最本质的问题:
多态分为静态多态和动态多态,静态多态也被称为编译时多态,它在编译阶段就确定了要调用的方法。主要通过方法重载和运算符重载(部分语言支持)来实现。
动态多态也称为运行时多态,它在运行阶段才确定要调用的方法。主要通过继承和方法重写(覆盖)以及向上转型来实现。
常见的静态多态就是流插入的自动识别类型,动态多态则是不同对象调用不同虚函数:
int main()
{
int a = 0;
double b = 0;
cin >> a >> b;
cout << a << b;
return 0;
}
class Person1 {
public:
virtual void BuyTicket() const { cout << "买票-全价" << endl; }
};
class Student1 : public Person1 {
public:
void BuyTicket() const { cout << "买票-半价" << endl; }
};
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
重载是指在同一个类中,允许存在多个同名的方法,但这些方法的参数列表必须不同,参数列表不同包括参数的类型、个数或顺序不同。返回值类型和访问修饰符可以不同,但仅返回值类型不同不能构成方法重载。
重写发生在子类和父类之间,子类重新定义父类中具有相同名称和参数列表的方法。重写的方法必须与父类方法具有相同的方法签名(方法名、参数列表和返回类型),
重定义:当子类定义了与父类中同名的成员变量时,父类的成员变量会被隐藏。在子类中访问该变量时,默认访问的是子类的变量,如果要访问父类的变量,需要使用特定的::访问限定符实现。
3. 多态的实现原理?
如果是静态多态,那么实现方法就是函数重载;
如果是动态多态,那么实现方法就是虚函数的重写+基类指针或引用调用。
4. inline函数可以是虚函数吗?
可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
当一个虚函数被调用时,由于需要在运行时确定具体调用的是哪个类的函数版本,编译器无法在编译阶段就确定是否可以将函数体展开,因此编译器通常会忽略inline声明。也就是说,虚函数的动态绑定特性使得inline 优化无法实现,此时这个虚函数不会被内联展开。
5. 静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表,也就无法像虚函数那样根据虚函数表的地址去调用。
虚函数是为了实现运行时多态而设计的。当通过基类的指针或引用调用虚函数时,程序会在运行时根据指针或引用所指向的对象的实际类型来决定调用哪个类的虚函数版本,这依赖于对象的虚函数表和虚指针。而静态成员函数没有this指针,也就无法获取对象的虚函数表,因此不能实现虚函数的动态绑定机制。
6. 构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。如果是虚函数则先需要有虚函数表,再把虚函数的地址放入,但现在没有虚函数表,因为没有进行初始化,这是矛盾的!
虚指针未初始化:由于在构造函数执行期间,对象的虚指针还没有被正确设置,无法通过虚指针找到虚函数表,也就无法实现虚函数的动态绑定机制。如果构造函数是虚函数,在调用构造函数时就无法确定具体要调用哪个类的构造函数,会导致程序出现混乱。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,析构函数可以声明为虚函数。当通过基类指针删除对象时,虚析构函数能确保正确调用子类的析构函数,实现资源的完整释放。
当基类指针指向子类对象时,若基类析构函数不是虚函数,delete基类指针时只会调用基类的析构函数,子类的析构函数不会执行,导致子类中分配的资源(如堆内存、文件句柄)无法释放,引发内存泄漏。
以下几种情况,请务必将析构函数加上virtual:
- 类可能被继承:若一个类设计为基类,其析构函数应声明为虚函数,确保子类资源正确释放。
- 通过基类指针管理对象:当使用基类指针指向子类对象并通过该指针释放内存时,虚析构函数是必要的。
8. 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
普通函数的调用在编译阶段就已经确定了要调用的函数地址,这是一种静态绑定。编译器在编译代码时,根据函数名、参数列表等信息,直接将函数调用处替换为函数的实际入口地址。
虚函数的调用采用动态绑定机制,其目的是实现运行时多态。每个包含虚函数的类都会有一个虚函数表,该表存储了类中所有虚函数的地址。每个该类的对象都有一个虚指针,指向所属类的虚函数表。当通过对象的指针或引用调用虚函数时,程序需要先通过对象的虚指针找到虚函数表,然后从虚函数表中根据函数的偏移量找到要调用的虚函数的实际地址,最后再跳转到该地址执行函数代码。这需要一定时间开销。
9. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段生成的。具体来说,编译器在编译包含虚函数的类时,会为该类创建一个虚函数表。
虚函数表通常存储在只读数据段中。可以理解为代码段(常量区),这是因为虚函数表中的内容在程序运行期间是固定不变的,它只包含虚函数的地址信息,不需要进行修改,将其存储在只读数据段可以保证数据的安全性和稳定性。
10. C++菱形继承的问题?虚继承的原理?
主要问题:数据冗余和二义性。
菱形继承所带来的问题:因为A类被继承了两次,分别继承到B类和C类,最后D类又继承了BC,这时D中就会有两个A类的成员变量,这便引发了数据冗余和二义性:
数据冗余:
定义:指在一个数据集合中,存在重复或不必要的数据。即相同的数据在数据库中多次存储,或者某些数据可以通过其他数据推导出来,但仍然被单独存储。
在这里,A中一摸一样的成员被继承了两次,这就是数据冗余。
二义性:
定义:指数据的含义不明确,可能有多种解释或理解方式,导致在数据处理和分析过程中产生歧义。
在这里,如果要取A中所包含的成员变量,两个一摸一样的成员变量会让编译器不知道该访问哪一个。
解决办法就是使用虚继承,即在BC两个类在继承A时在继承方式前加上关键字virtual。
虚继承的原理依赖于虚基表:
当使用虚继承时,编译器会为每个包含虚基类的派生类创建一个虚基表,同时对象中会包含一个虚基表指针,指向对应的虚基表。
虚基表主要有以下作用和工作原理:
存储偏移量信息
虚基表中存储了虚基类对象相对于派生类对象起始地址的偏移量。通过这个偏移量,程序在运行时可以准确地找到虚基类对象的位置。
查找虚基类对象
当访问虚基类的成员时,程序会先通过对象的虚基表指针找到虚基表,然后从虚基表中获取虚基类对象的偏移量,最后根据这个偏移量计算出虚基类对象在派生类对象中的实际地址,从而实现对虚基类成员的访问。
11. 什么是抽象类?抽象类的作用?
抽象类是一种不能被实例化的类,它主要用于为其他类提供一个通用的模板或基类。抽象类中通常包含至少一个纯虚函数。
// 抽象类
class Shape {
public:
// 纯虚函数
virtual double area() const = 0;
virtual void printInfo() const = 0;
};
在虚函数的后面写上=0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。