目录
多态的概念
多态的定义及实现
1.虚函数
2. 多态的实现
2.1.多态构成条件
2.2.虚函数重写的两个例外
(1)协变(基类与派生类虚函数返回值类型不同)
(2)析构函数的重写(基类与派生类析构函数的名字不同)
2.3.多态的实现
2.4.多态在析构函数中的应用
2.5.多态构成条件的题目分析
接口继承和实现继承
C++11 override 和 final
1.final:修饰虚函数,表示该虚函数不能再被重写
2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写就会编译报错。
重载、重写(覆盖)、隐藏(重定义)的对比
抽象类
注意事项:C++的多态 - 上、下文章中涉及的所有代码都是在在vs2022下的x86程序中执行的。如果要其他平台下,部分代码需要改动。比如:如果是x64程序,则需要考虑指针是8bytes问题等等。
多态的概念
(1)概念:在 C++ 中,多态是一种面向对象编程的特性,它允许不同类型的对象对相同的消息或者相同的函数接口调用做出不同的响应。简单来说,就是用基类的指针或引用来调用不同派生类中重写的虚函数,根据指针或引用所指向的实际对象类型来决定调用哪个派生类的函数版本,从而实现不同的行为。
通俗来说,多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
(2)多态案例
- 案例1:比如,在一个游戏中有不同角色,如战士、法师和盗贼。当执行 “攻击” 操作时,战士可能会使用近战武器进行强力的物理攻击,法师会释放魔法技能进行远程的魔法攻击,盗贼则可能会利用敏捷的身手进行偷袭攻击。这里 “攻击” 是相同的操作,但不同角色(不同类型的对象)执行该操作时有着不同的攻击方式和效果,这也是多态的表现。每个角色都根据自身的特点和能力来实现 “攻击” 这个行为,从而呈现出多种不同的攻击形态。
- 案例2:在现实生活中,购车票存在多种不同的情况,这很好地体现了多态的概念。
购车票是一个普遍的行为,但不同身份的人购车票会有不同的结果和方式,这就是多态的表现。比如,普通人购车票,通常是按照正常的票价支付,完成购票流程,这是一种常见的 “形态”。而学生购车票时,因为学生身份的特殊性,往往可以享受半价优惠,他们在购票时出示学生证等相关证件,就可以以较低的价格买到车票,这是与普通人不同的 “形态”。另外,军人购车票又有不同,军人可能会享受优先购票的待遇,在购票时无需像普通人那样排队等待,而是可以直接到专门的窗口优先办理购票手续,这又是一种不同的 “形态”。
同样是购车票这个行为,由于购票人的身份不同,导致了不同的行为表现和结果,这就是多态。它体现了在不同的对象上执行相同的操作(购车票),却会因为对象的不同特征(身份)而产生不同的具体行为和结果。
多态的定义及实现
1.虚函数
虚函数定义:把关键字virtual
加在类成员函数的前面,该类成员函数即为虚函数。例如:
#include <iostream>
using namespace std;class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
虚函数与虚继承虽共用virtual
关键字,但二者并无直接关联。虚继承主要用于解决菱形继承中的数据冗余和二义性问题;而虚函数是为实现多态,当多种if-else
分支处理复杂时,虚函数可更方便地实现不同对象做同一操作时产生不同结果。
2. 多态的实现
2.1.多态构成条件
(1)多态实现的两个必要条件:虚函数重写、父类指针 / 引用调用虚函数。
①虚函数重写:子类和父类中同名成员函数满足 “三同”(函数名、参数类型、返回值类型相同),且父类函数声明为虚函数。子类重写父类虚函数时,即使子类函数前不加virtual
关键字(因为继承关系,子类函数仍保持虚函数属性,但不规范,不建议),也构成重写。
②多态实现的调用规则
在满足多态的两个必要条件(虚函数重写、父类指针 / 引用调用虚函数 )后,多态实现遵循以下调用规则:
- 当父类指针 / 引用指向父类对象时,使用父类指针 / 引用调用虚函数,调用的是父类对象的虚函数。
- 当父类指针 / 引用指向子类对象时,使用父类指针 / 引用调用虚函数,调用的是子类对象重写后的虚函数。
#include <iostream>
using namespace std;//定义一个父类 Person
class Person
{
public://定义一个虚函数 BuyTicket,用于表示买票行为,默认全价virtual void BuyTicket() { cout << "买票-全价" << endl; }
};//定义一个子类 Student,继承自 Person 类
class Student : public Person
{
public://1. 在继承体系中,如果没有多态特性,当子类和父类有同名成员函数时,// 子类的成员函数会隐藏父类的成员函数,即父类的同名成员函数被屏蔽。//2. 在多态的情况下,若子类和父类的成员函数满足函数名、参数列表、返回值类型都相同,// 并且父类的该函数为虚函数,那么子类的同名函数会重写(覆盖)父类的虚函数。//3. 注意:函数重载要求函数在同一作用域内,而子类和父类属于不同作用域,// 所以子类和父类的同名函数不构成重载关系。//4. 这里子类的虚函数 BuyTicket() 重写(覆盖)了父类 Person 的同名虚函数 BuyTicket()。// 虽然在重写父类虚函数时,子类的虚函数不加 virtual 关键字也能构成重写// (因为继承后父类的虚函数属性会被继承下来),但这种写法不规范,不建议使用。virtual void BuyTicket() { cout << "买票-半价" << endl; }//void BuyTicket() { cout << "买票-半价" << endl; }
};//多态的条件:
//1、虚函数的重写 -- 三同(函数名、参数列表、返回值类型相同),且父类的函数为虚函数。
//解析:虚函数的重写是指父类和子类的同名成员函数,父类的函数前加上 virtual 关键字使其
//成为虚函数,子类的同名函数也满足相同的函数名、参数列表和返回值类型,此时子类的函数会
//重写(覆盖)父类的虚函数。
//2、通过父类指针或者引用去调用虚函数。
//解析:即不管是调用父类还是子类的同名虚函数,都必须使用父类指针或引用来调用。// 定义一个函数 Func,参数为父类的引用
// 注意:这里使用父类引用是为了实现多态
void Func(Person& p)
{//1. 在没有多态特性时,无论 Func 函数的形参 Person& p 接收的是子类对象还是父类对象,//p.BuyTicket() 都会调用父类的成员函数 BuyTicket()。//2. 在多态的情况下,//当 Func 函数的形参 Person& p 接收子类对象时,p.BuyTicket()会调用子类的//虚函数 BuyTicket();//当接收父类对象时,p.BuyTicket() 会调用父类的虚函数 BuyTicket()。//总的来说,根据传入对象的不同,调用不同的虚函数,体现了多态性。p.BuyTicket();
}int main()
{//创建一个父类对象Person ps;//创建一个子类对象Student st;//调用 Func 函数,传入父类对象,会调用父类的虚函数Func(ps); //调用 Func 函数,传入子类对象,会调用子类的虚函数Func(st); return 0;
}
(2)父类对象与多态
父类对象无法实现多态。当不满足多态的两个必要条件中的任何一个时,就无法实现多态。例如,若父类函数不是虚函数,或者没有通过父类指针 / 引用调用函数,都不能实现多态。
2.2.虚函数重写的两个例外
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
(1)协变(基类与派生类虚函数返回值类型不同)
解析:派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
注意事项:
- 在实际编程中,协变的应用场景相对较少。
- 重写虚函数存在协变返回类型的情况时,要求父类和子类的虚函数返回值类型必须都为指针类型或都为引用类型,并且返回的指针或引用所指向的类型要具有继承关系,即一个是另一个的父类或子类类型。若父类与子类的虚函数仅函数名和参数列表相同,但返回值类型不同,且返回值类型并非父子关系的指针或引用 ,编译器通常会报错,因为这不符合重写规则。
- 即便子类与父类的
operator=
赋值重载函数均返回引用,且仅函数名相同、参数不同,它们与虚函数重写(多态)通常也并无关联。这是因为赋值重载函数并不支持虚函数的特性,无法利用多态机制在运行时根据对象的实际类型来调用合适的赋值操作。虚函数重写是实现多态的重要手段,其核心在于通过基类指针或引用,在运行时根据实际对象类型调用相应的派生类函数。而赋值重载函数有其特殊性,当在子类中显式实现operator=
赋值重载函数时,可能会复用父类的operator=
赋值重载函数,这只是代码复用的一种方式,并非基于虚函数重写的多态调用。因为赋值重载函数不会被声明为虚函数,也就不会参与到虚函数的机制中。编译器在编译时就确定了要调用的赋值重载函数,而不是在运行时根据对象的实际类型来动态选择。
代码:
#include <iostream>
using namespace std;// 多态的条件:
// 1、虚函数的重写 -- 三同(函数名、参数、返回值)
// -- 例外(协变):返回值可以不同,必须是父子关系指针或者引用
// -- 例外:子类虚函数可以不加virtual
// 2、父类指针或者引用去调用虚函数//父类
class A{};
//子类
class B : public A {};//父类
class Person
{
public:virtual Person* BuyTicket()//virtual A* BuyTicket(){ cout << "买票-全价" << endl;return nullptr;}
};//子类
class Student : public Person
{
public://注意:协变对于子、父类虚函数返回值类型必须都为指针类型或都为引用类型,//并且返回的指针或引用所指向的类型要具有继承关系,即一个是另一个的父类或子类类型。//协变//父Person 、子Student类虚函数返回值类型是父子关系指针(即A*、B*),则满足协变//要求,则父、子类虚函数构成重写关系,满足多态条件。//virtual B* BuyTicket()//协变//父Person 、子Student类虚函数返回值类型是父子关系指针(即Person*、Student*),//则满足协变要求,则父、子类虚函数构成重写关系,满足多态条件。virtual Student* BuyTicket(){ cout << "买票-半价" << endl;return nullptr;}
};void Func(Person* p)
{//1、不满足多态 -- 看调用者的类型,调用这个类型的成员函数//2、满足多态 -- 看指向的对象的类型,调用这个类型的成员函数//调用传入指针p所指向对象的BuyTicket函数,实现多态调用p->BuyTicket(); //释放指针p指向的动态分配的对象,防止内存泄漏delete p;
}int main()
{//传入动态创建的Person对象地址给Func函数,调用Person类的BuyTicket函数Func(new Person);//传入动态创建的Student对象地址给Func函数,调用Student类的BuyTicket函数Func(new Student);return 0;
}
(2)析构函数的重写(基类与派生类析构函数的名字不同)
解析:如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。
代码:
2.3.多态的实现
1.满足多态的情况
当满足多态条件时,调用类成员函数会依据调用者(父类指针或引用)指向的对象类型,调用该对象类型对应的成员函数。具体有以下两种类型:
(1)类型 1:在子、父类中,同类成员函数均使用virtual
关键字声明为虚函数。
解析:若不满足多态条件,使用父类指针或引用调用类成员函数时,会按照指针或引用本身的类型来调用,而非依据其所指向对象的类型。例如,若父类指针指向子类对象,但相关函数不满足多态条件,调用函数时将调用父类的函数版本,而非子类重写后的版本。
#include <iostream>
using namespace std;//多态的条件:
//1、虚函数的重写 -- 三同(函数名、参数、返回值)
//2、父类指针或者引用去调用虚函数//父类
class Person
{
public://虚函数virtual void BuyTicket(){cout << "买票-全价" << endl;}
};//子类
class Student : public Person
{
public://虚函数//子类和父类的BuyTicket函数满足“三同”(函数名、参数、返回值),构成重写关系,//即子类的BuyTicket函数是对父类BuyTicket函数的重写。virtual void BuyTicket(){cout << "买票-半价" << endl;}
};//定义函数Func,接受Person类的引用作为参数
void Func(Person& p) //这里的p是父类Person的引用,作为调用者
{//多态的调用规则://1、不满足多态时 -- 依据调用者(即p)本身的类型,调用该类型的成员函数//2、满足多态时 -- 根据调用者(p)指向的对象的类型,调用对应类型的成员函数p.BuyTicket();//由于子、父类的BuyTicket函数满足多态条件,则p.BuyTicket()的调用结果取决于调用者p实际指向的对象类型://- 当Func函数的实参传入父类对象(如下面main函数中的Func(ps) )时,p指向父类对象,// 此时p.BuyTicket()调用的是父类Person的BuyTicket成员函数,输出“买票-全价”。//- 当Func函数的实参传入子类对象(如下面main函数中的Func(st) )时,p指向子类对象,// 此时p.BuyTicket()调用的是子类Student的BuyTicket成员函数,输出“买票-半价”。
}int main()
{Person ps; //创建父类Person对象psStudent st; //创建子类Student对象stFunc(ps); //将父类对象ps作为实参传给Func函数,调用父类的BuyTicket函数Func(st); //将子类对象st作为实参传给Func函数,调用子类的BuyTicket函数return 0;
}
(2)类型 2:仅父类的同类成员函数使用virtual
关键字声明为虚函数,默认子类三同(函数名、参数列表和返回值类型均相同,返回值存在协变特殊情况)成员函数与父类函数构成重写关系。
①结论:当父类成员函数声明为虚函数,子类存在 “三同”(函数名、参数列表、返回值类型,返回值在协变情况下可不同)成员函数时,即便子类函数未显式使用virtual
关键字,也构成重写关系并能实现多态。
- 原因解析:在继承体系中,子类继承父类的函数声明。重写时,子类针对从父类继承的函数声明,仅重新编写函数体。所以只要父类 “三同” 成员函数为虚函数,子类相应函数即便未显式声明
virtual
,也继承虚函数属性。
②注意事项:
- 在 C++ 中,重写关系仅存在于虚函数间。只有子类与父类成员函数满足函数名、返回值类型(含协变情况)、参数列表都相同,且父类函数为虚函数时,才构成重写。普通成员函数即便满足 “三同”,也不构成重写。
- 重写体现接口继承。即子类重写父类虚函数时,继承函数名、参数列表及返回值类型(协变情况允许特定调整),只需重新实现函数体逻辑。
- 重写基类虚函数时,派生类虚函数虽可不加
virtual
关键字构成重写(因继承保持虚函数属性),但不规范,不建议采用。
③案例1
②案例2:使用多态解决动态子类对象生命周期结束后,可以正常调用子类析构函数释放动态子类对象成员变量占用的资源。(注意:案例2的详细介绍参考2.4.多态在析构函数中的应用)
#include <iostream>
using namespace std;//多态的条件:
//1、虚函数的重写 -- 三同(函数名、参数、返回值)
//2、父类指针或者引用去调用虚函数
class Person
{
public://虚函数BuyTicket,用于表示买票全价的操作virtual void BuyTicket(){cout << "买票-全价" << endl;}//虚函数析构函数~Person,用于释放Person对象相关资源virtual ~Person(){cout << "~Person()" << endl;}//若父类析构函数未声明为虚函数,子、父类析构函数不构成重写关系,不满足多态条件。//当父类指针p指向动态子类对象时,执行delete p释放资源,因为不满足多态时,//调用者指针p本身的类型决定调用的析构函数类型,所以delete p不会调用子类对象析构函数,//而是调用父类类型的析构函数,释放子类对象中父类部分占用的资源。//若动态子类对象特有成员存在占用资源情况,会存在内存泄漏风险。
};class Student : public Person
{
public://子类的BuyTicket函数虽未显式使用virtual关键字,但默认继承父类虚函数属性,//子、父类的BuyTicket函数构成重写关系,满足多态条件。void BuyTicket(){cout << "买票-半价" << endl;}//子类的析构函数~Student()虽未显式使用virtual关键字,但默认继承父类析构函数的虚函数属性,//子、父类析构函数构成重写关系,满足多态条件。~Student(){cout << "~Student()" << endl;}
};void Func(Person* p)//调用者指针p类型是父类类型
{p->BuyTicket();//1、不满足多态 -- 看调用者的类型,调用这个类型的成员函数 //2、满足多态 -- 看调用者指向的对象的类型,调用这个对象类型的成员函数delete p;//3.若父类析构函数声明为虚函数,满足多态,当实参传动态父类对象,//父类指针Person* p指向父类对象,delete p会调用父类对象的析构函数p->~Person();//若父类析构函数未声明为虚函数,delete p直接调用父类析构函数//4.若父类析构函数声明为虚函数,满足多态,当实参传动态子类对象,//父类指针Person* p指向子类对象,delete p会调用子类对象的析构函数p->~Student();//若父类析构函数未声明为虚函数,delete p只会调用父类析构函数,可能导致内存泄漏
}int main()
{Func(new Person);//传动态父类对象Func(new Student);//传动态子类对象return 0;
}
2.不满足多态的情况
当不满足多态条件时,调用类成员函数依据调用者(父类指针、引用或对象)本身的类型,调用该类型的成员函数。具体如下:
(1)类型 1:若子、父类成员函数满足 “三同”,但父类 “三同” 成员函数不是虚函数,则子、父类 “三同” 成员函数不构成重写关系,无法实现多态。
解析:在不满足多态的情况下,当使用父类指针或引用去调用成员函数时,调用的是依据指针或引用本身类型所对应的函数版本,而不是根据它们所指向对象的类型来调用。例如,当父类指针指向子类对象时,由于父类的 “三同” 成员函数不是虚函数,所以依然会调用父类中该成员函数的版本,而不会调用子类中具有相同函数名、参数类型和返回值类型的函数版本,因此无法体现多态性。
(2)类型 2:即使子、父类 “三同” 成员函数声明为虚函数且构成重写关系,若通过父类对象调用虚函数,也无法实现多态。
解析:多态依赖父类指针或引用,在运行时依据实际指向对象类型动态绑定函数。而父类对象调用函数时,编译器在编译阶段就确定调用父类自身函数版本,不会根据对象实际类型动态选择。
2.4.多态在析构函数中的应用
在 C++ 的继承体系中,当涉及子类和父类的赋值转换,尤其是动态子类对象地址赋值给父类指针时,析构函数的调用会出现特定问题。多态机制在解决这类问题上发挥着关键作用。
(1)正常继承场景下析构函数调用情况
在 C++ 继承体系里,当子类对象生命周期结束(如出作用域) ,其析构过程有序进行。先是子类析构函数被调用,用以释放子类特有的成员变量所占用资源,像子类中动态分配的内存、打开的文件等。随后,编译器自动调用父类析构函数,释放子类对象从父类继承而来成员占用的资源。这确保了对象创建时获取的资源能完整、正确地释放。
(2)无多态时的内存泄漏隐患
问题描述:在继承体系中,若未使用多态机制,当动态分配的子类对象通过父类指针来管理时,会出现资源释放不完全的问题。具体表现为,当使用delete
释放父类指针(指向子类对象)时,仅会调用父类的析构函数,而子类的析构函数不会被调用。这就导致子类对象中特有的成员所占用的资源无法得到释放,最终引发内存泄漏 。
原理剖析:在 C++ 中,delete
操作符调用析构函数的行为在没有多态的情况下是基于指针的静态类型决定的。编译器在编译阶段就确定了析构函数的调用,它依据的是指针声明时的类型,而非指针实际指向对象的类型。因此,当父类指针指向子类对象时,普通的delete
操作只会触发父类析构函数的调用,这是导致上述问题的根本原因。
#include <iostream>
using namespace std;//定义父类Person
class Person
{
public://Person类的析构函数,用于释放Person类对象占用的资源~Person(){cout << "~Person()" << endl;}
};//定义子类Student,公有继承自Person类
class Student : public Person
{
public://Student类的析构函数,用于释放Student类对象特有的资源,以及继承自父类的资源~Student(){cout << "~Student()" << endl;}
};int main()
{//创建一个Person类对象,并用父类指针p1指向它。Person* p1 = new Person;//创建一个Student类对象,并用父类指针p2指向它。这里涉及到对象切片,父类指针只能访问子类对象中从父类继承的部分。Person* p2 = new Student;//释放p1指向的内存。在没有多态机制时,delete根据指针类型调用析构函数,p1是Person*类型,所以调用Person类的析构函数。delete p1;//释放p2指向的内存。在没有多态机制时,delete同样根据指针类型调用析构函数,p2是Person*类型,只会调用Person类的析构函数。//若Student类存在特有的资源(如动态分配的内存)需要释放,这种情况下就会导致内存泄漏,因为没有调用Student类的析构函数。delete p2;//代码解析://在C++中,对于析构函数,在编译层面会进行一些处理,即在继承体系下,子类和父类的析构函数名称会统一处理为destructor。// 1. delete p1; 等价于先调用析构函数 p1->~Person() ,然后调用 operator delete(p1) 释放内存。// 1.1 在没有多态的情况下,指针的类型决定了调用析构函数的类型。因为p1是父类Person类型的指针,所以p1->~Person() 调用的是父类的析构函数。// 2. delete p2; 等价于先调用析构函数 p2->~Person() ,然后调用 operator delete(p2) 释放内存。// 2.1 在没有多态的情况下,同样因为p2是父类Person类型的指针,p2->~Person() 调用的是父类的析构函数,而不是子类Student的析构函数。// 2.2 问题分析:在常规情况下,delete操作符会根据指针的静态类型来调用相应的析构函数。当父类指针指向父类对象时,这种方式没有问题,// 因为调用的就是父类的析构函数来释放资源。但当父类指针指向子类对象时,如果仅调用父类析构函数,而子类对象又有自己特有的资源需要释放(例如子类中动态分配的内存等),// 就会导致内存泄漏。// 我们期望的是,无论指针本身类型是什么,当指针指向父类对象时,调用父类析构函数;当指针指向子类对象时,调用子类析构函数。// 为实现这个期望,可以利用多态特性。在继承中,析构函数虽然名字不同(~Person() 和 ~Student() ),但在满足一定条件下可以实现多态。// 因为析构函数没有返回值和参数,若将父类析构函数声明为虚函数,子类析构函数会自动成为虚函数(即使没有显式加上virtual关键字),这就满足了虚函数重写的条件之一。// 同时,通过父类指针指向子类对象并调用析构函数,满足了多态的另一个条件(父类指针/引用调用虚函数)。// 当满足多态的两个必要条件后,delete父类指针指向子类对象时,会调用子类的析构函数;delete父类指针指向父类对象时,会调用父类的析构函数。// 总的来说,在没有多态的情况下,delete操作按指针的静态类型调用析构函数;当子、父类析构函数实现成多态后,delete操作按指针实际指向的对象类型调用析构函数。
}
(3)使用多态解决上述问题
多态解决问题的原理:多态实现要求父类析构函数声明为虚函数,而子类析构函数会自动重写(覆盖)父类虚析构函数。这样在运行时,系统会根据指针实际指向对象的类型(而非指针静态类型)决定调用哪个析构函数。当父类指针指向子类对象,并且使用delete
释放该指针时,首先会调用子类的析构函数,释放子类特有的资源,然后再调用父类的析构函数,释放从父类继承的资源,从而确保资源的完全释放,避免内存泄漏。
可以看到,当ptr2
(父类指针指向子类对象)被释放时,先调用了子类的析构函数~Student()
,再调用了父类的析构函数~Person()
,成功解决了资源释放不完全的问题。
2.5.多态构成条件的题目分析
(1)多态构成条件概述
多态是 C++ 中一项重要特性,其构成需满足特定条件:
- 虚函数重写:子、父类需满足 “三同” 规则,即函数名、参数类型、返回值类型相同(特殊情况如协变返回值等除外 ),且成员函数需声明为虚函数。在参数方面,仅需参数类型一致,参数名与缺省值可以不同。这一规则确保了子类函数能够正确覆盖父类虚函数,为多态的实现奠定基础。
- 父类指针或引用调用:通过父类指针或引用去调用子类重写的虚函数,才能触发多态行为。这是因为在运行时,程序依据指针或引用所指向对象的实际类型(而非指针本身的静态类型 )来决定调用哪个函数版本,从而实现动态绑定。
(2)注意事项
- 满足多态条件时,子类虚函数会继承父类虚函数的接口规范,并重写其函数体实现逻辑。当父类指针或引用指向子类对象,并使用该指针/引用调用虚函数时,表面上是使用父类虚函数的接口(函数声明:包括函数名、参数类型、缺省值、返回值类型等 ),但实际执行的是子类虚函数的函数体。这是因为在继承关系中,子类重写的虚函数替换了父类虚函数在虚函数表中的位置,运行时根据对象实际类型从虚函数表中找到并调用子类虚函数。
- 当使用子类对象、子类指针或子类引用单独调用子类虚函数时,调用的是子类虚函数自身的接口(函数声明),并且执行的也是子类虚函数自身的函数体。
(3)案例
①代码分析(构成多态)
#include <iostream>
using namespace std;//注意:三同规则里,仅需参数类型一致,而参数名、缺省值可不同。// 多态的条件:
// 1、虚函数的重写 -- 三同(函数名、参数、返回值)
// -- 例外(协变):返回值可以不同,必须是父子关系指针或者引用
// -- 例外:子类虚函数可以不加virtual
// 2、父类指针或者引用去调用虚函数//父类
class A
{
public://虚函数 func,这里的缺省参数 val = 1。//实际上,编译器会将其处理为 virtual void func(A* this, int val = 1)virtual void func(int val = 1){cout << "A->" << val << endl;}//虚函数 test,编译器会将其处理为 virtual void test(A* this)//指向子类对象的实参子类指针B* p,形参父类指针A* this。virtual void test(){//调用规则:// 1、不满足多态 -- 看调用者的类型,调用这个类型的成员函数// 2、满足多态 -- 看指向的对象的类型,调用这个类型的成员函数//代码解析://1.p->test() 调用时,test() 传参存在子类指针赋值给父类指针 this 的情况://指向子类对象的实参子类指针B* p,形参父类指针A*this指向子类对象中父类B部分,总的来说,形参父类指针A*this向子类对象。func();//2. 这里调用 func() 等价于父类指针 A* this->func(),且该父类指针 A* this 指向子类对象。//由于子类 B 和父类 A 的虚函数 func() 构成重写关系,并且是通过父类指针 A* this 调用虚函数,满足多态条件。//因此,func() <=> A* this->func() 表面上调用的是子类虚函数 void B::func(int val = 0) 的函数接口,//但实际上,在多态调用时,缺省参数使用的是父类虚函数的缺省参数。//也就是说,最终调用的是父类虚函数的接口(包括函数名、参数类型、缺省值、返回值类型等)void A::func(int val = 1),//执行的是子类虚函数的函数体(即实现逻辑)。所以最终整个程序的打印结果是 B->1。//原因:子类虚函数会继承父类虚函数的接口规范,并重写其函数体实现逻辑。当父类指针或引用指向子类对象,并使用该指针/引用调用虚函数时,//表面上是使用父类虚函数的接口(函数声明:包括函数名、参数类型、缺省值、返回值类型等 ),但实际执行的是子类虚函数的函数体。}
};//子类
class B : public A
{
public://子类成员函数 func() 默认继承父类虚函数属性。//由于子类和父类的虚函数 func() 构成重写关系,子类虚函数 func() 继承了父类虚函数 func() 的函数声明 virtual void func(int val = 1),//子类虚函数 func() 只是重写了父类虚函数 virtual void func(int val = 1) 的函数体实现逻辑。//缺省参数的值在多态调用时使用父类的缺省值,只有在使用非多态方式(如子类对象直接调用)时,才使用子类的缺省值。//总结:当使用父类引用/指针调用虚函数实现多态时,调用的是子类重写的函数体,但缺省参数使用父类虚函数的缺省参数。//当不使用多态方式调用时,子类函数使用自己的缺省参数。//成员函数func(虚函数),编译器会将其处理为 virtual void func(B* this, int val = 0)void func(int val = 0) {cout << "B->" << val << endl;}
};int main(int argc, char* argv[])
{//子类指针B* p = new B;//注:p->test()传参调用过程中涉及子类指针赋值给父类指针情况(即子类对象和父类对象赋值转换过程)p->test();//调用 p->test() 等价于 p->test(p),实参是子类 B* p 指针,该指针 p 指向子类对象。//在 test 函数内部调用 func 函数时,由于满足多态条件,会调用子类的 func 函数体,但使用父类的缺省参数。return 0;
}//整个程序最终打印结果:B->1
②代码变形(不构成多态)
解析:最终输出B->0
,这是因为在test
函数中调用func
函数时,由于不满足多态条件,编译器按照普通的函数调用规则,根据调用者(这里是子类B
)的类型来确定调用的函数,所以调用的是子类B
自己定义的 B::func
函数,其缺省参数为0
,因此输出B->0
。
接口继承和实现继承
-
普通函数继承:在继承体系中,普通函数继承意味着父类将自身成员函数的函数接口(函数声明)与函数体实现逻辑,完整地传递给子类。换言之,子类获取了父类成员函数的全部内容,如同将父类的成员函数原样复制到子类之中。
-
多态下的接口继承:在多态机制下,接口继承指父类仅将其虚函数的函数接口(函数声明)传递给子类。子类依据自身需求,对继承而来的父类虚函数进行重写,即重新实现函数体的逻辑。通过这种接口继承与重写的方式,不同的子类能够基于父类虚函数所提供的相同接口,根据各自的特定需求和业务逻辑,给出差异化的行为实现,进而实现运行时多态,使得程序在运行时可以依据实际的对象类型来调用相应子类的函数,增强了代码的灵活性和可扩展性。
-
总结:普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
C++11 override 和 final
1.final:修饰虚函数,表示该虚函数不能再被重写
(1)final功能:修饰虚函数时,表示该虚函数不能再被重写;修饰类时,该类无法被继承,这样的类被称为最终类。
(2)注意事项
- 当
final
修饰父类虚函数时,该虚函数仍然可以被子类继承,但是子类不能对其进行重写。 - 虚函数重写是实现多态的必要条件之一。借助多态,我们能通过父类指针或引用,在运行时根据对象的实际类型,调用相应子类的函数,极大地增强了代码的灵活性与扩展性。由于多态依赖于虚函数重写,在实际开发场景中,为充分发挥多态优势,满足多样化的业务需求,很少限制父类虚函数被子类重写。
(3)案例
①不能被继承的类
#include <iostream>
using namespace std;class A final
{
public:static A CreateObj() {return A();}private:A() {}
};//以下代码会导致编译错误,因为A类使用了final修饰,不能被继承
//class B : public A {};int main()
{A::CreateObj();return 0;
}
②不能被重写的虚函数
#include <iostream>
using namespace std;class Car
{
public:virtual void Drive() final{}
};class Benz :public Car
{
public://错误示例,试图重写被final修饰的虚函数//virtual void Drive() //{// cout << "Benz-舒适" << endl;//}
};int main()
{return 0;
}
2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写就会编译报错。
(1)解析
override
是 C++11 引入的一个关键字,主要应用于虚函数重写的场景。其功能是显式地指明派生类中的成员函数是对基类虚函数的重写。
在使用时,将 override
关键字添加在派生类成员函数声明的后面。这样做的目的是让编译器对该成员函数进行严格检查,判断其是否满足对基类虚函数重写的条件。这些条件包括函数名、参数列表和返回值类型(存在协变的特殊情况 )等方面与基类虚函数一致。
如果派生类的成员函数声明后带有 override
关键字,但却不满足对基类虚函数重写的条件,那么在编译阶段,编译器就会报错。通过这种方式,override
关键字可以有效地避免因疏忽或错误导致的重写不准确问题,提高代码的可靠性和健壮性。同时,在阅读和维护代码时,override
关键字也能让开发者更清晰地了解成员函数之间的重写关系,增强代码的可读性。
(2)注意事项
- 与不加
override
情况对比:如果派生类函数重写基类虚函数但不使用override
关键字,虽然也能构成重写(满足重写条件时 ),但缺乏编译器的严格检查。例如,若后续修改基类虚函数,导致派生类函数不再满足重写条件,编译器不会报错,可能在运行时出现意外行为。而使用override
关键字能在编译阶段就发现这类问题。 - 与
final
关键字对比:final
关键字用于阻止虚函数被进一步重写,即标记为final
的基类虚函数在派生类中不能再被重写;而override
是用于确保派生类函数是对基类虚函数的正确重写。
重载、重写(覆盖)、隐藏(重定义)的对比
virtual 关键字对同名函数关系的影响
- 情况 1:仅父类 “三同” 成员函数为虚函数:若只有父类的 “三同” 成员函数使用
virtual
关键字声明为虚函数,但子类的 “三同” 成员函数未使用virtual
关键字声明,它会继承父类该 “三同” 虚函数的虚函数属性。在此情况下,子类和父类的 “三同” 成员函数仍然构成重写(覆盖)关系,而非隐藏(重定义)关系。此时,当通过父类指针或引用调用该函数时,会在运行时根据指向对象的实际类型决定调用子类还是父类的函数。 - 情况 2:仅子类 “三同” 成员函数为虚函数:若父类的 “三同” 成员函数未使用
virtual
关键字声明为虚函数,而只有子类的 “三同” 成员函数使用virtual
关键字声明为虚函数,那么子类和父类的这对 “三同” 成员函数仅构成隐藏(重定义)关系,并不会构成重写(覆盖)关系。这是因为重写依赖于父类虚函数机制,父类函数未被声明为虚函数,就无法实现运行时根据对象实际类型调用函数,只能在编译时根据调用者的类型确定调用的函数。
抽象类
(1)抽象类概念:在 C++ 中,在虚函数声明后加上 = 0
,该函数就成为纯虚函数 ,包含纯虚函数的类被称为抽象类(也叫接口类 )。抽象类不能实例化出对象。
(2)纯虚函数定义格式
抽象类是一种特殊的类,用于表示面向对象编程中的抽象概念。抽象类中至少包含一个纯虚函数,纯虚函数在类中仅作声明,无具体函数体实现,其声明格式通常为 virtual 返回值类型 函数名(参数列表) = 0;
。注意:纯虚函数是一种特殊的虚函数。
//抽象类(注:不能实例化出对象)
//注意:一个类型在现实中没有对应的实体,我们就可以把这个类型定义为抽象类
class Car
{
public://纯虚函数 -- 抽象类 -- 不能实例化出对象virtual void Drive() = 0;//注意:纯虚函数不用写实现,只需写函数声明即可。
};
注意事项
- 纯虚函数与抽象类关系:在虚函数后面写上
= 0
,该函数即为纯虚函数,包含纯虚函数的类是抽象类,抽象类不能实例化对象。派生类继承抽象类后,若不重写纯虚函数,派生类也会成为抽象类,无法实例化;只有重写所有纯虚函数,派生类才能实例化。纯虚函数规范了派生类必须进行重写,体现了接口继承特性。 - 声明与实现:在类中声明纯虚函数时,无需提供函数体实现,仅在虚函数声明后加
= 0
表明其为纯虚函数。若要实现纯虚函数的具体功能,需在派生类中对其进行重写,给出具体的函数体实现。 - 纯虚函数的意义:使用纯虚函数定义抽象类,当一个类在现实中无对应实体,且不想让其实例化对象(仅为被复用而定义 )时,可让该类包含纯虚函数成为抽象类。
- 抽象类应用场景:当一个类型在现实中没有对应的实体时,可将其定义为抽象类。例如,“Person”(人)可作为抽象概念,不同的 “Student”(学生)、“Worker”(工人)等具体类型可继承自 “Person”;“Fruit”(水果)也是抽象类,“Apple”(苹果)、“Banana”(香蕉)等具体水果类可继承自 “Fruit” 。
(3)抽象类的特点:抽象类无法实例化
①子类未重写父类纯虚函数的情况:若子类没有重写父类的纯虚函数,会继承父类的纯虚函数,导致该子类同样成为抽象类。由于抽象类不能创建对象实例,因此该子类也无法实例化。
②子类重写父类纯虚函数的情况:当子类重写父类的纯虚函数时,子类重写的虚函数就不再是纯虚函数。这意味着子类不再包含纯虚函数,不再是抽象类,因而可以实例化出对象 。
(4)纯虚函数的意义
在 C++ 中,纯虚函数的重要意义在于强制子类对其进行重写。当一个类包含至少一个纯虚函数时,这个类就被定义为抽象类,抽象类无法实例化对象。只有当子类重写了父类的所有纯虚函数后,子类才不再是抽象类,从而能够实例化对象。从设计角度来看,实例化对象是使用类的常见方式,因此实现纯虚函数并让子类可以实例化对象,才能真正发挥子类类型的作用。
(5)override 与纯虚函数的区别
- override 关键字:
override
是在子类中使用的关键字 ,主要作用是检查子类是否正确重写了父类的虚函数。当在子类的函数声明后使用override
时,编译器会进行检查,如果该函数没有正确重写父类的虚函数(例如函数名、参数列表、返回类型等不匹配 ),编译器会报错。override
本身并不影响函数的功能实现,它只是一种安全机制,帮助开发者避免因疏忽导致的错误。 - 纯虚函数:纯虚函数是在父类中使用的概念,通过在虚函数声明后加上
= 0
来定义。纯虚函数没有具体的实现,它的存在使得父类成为抽象类。纯虚函数的目的是间接强制子类重写这些函数,因为只有子类重写了所有纯虚函数,子类才能实例化对象。当然,子类也可以选择不重写父类的纯虚函数,但这样一来,子类也会成为抽象类,无法实例化对象。 - 总结:
override
关键字主要用于检查子类对父类虚函数的重写情况,提高代码的安全性和可维护性;而纯虚函数则是从设计层面强制子类实现特定的功能,它以一种间接的方式强制子类重写父类纯虚函数,以保证子类可以实例化并使用。