C++----多态
一、多态的概念与分类
多态是指不同对象对同一消息产生不同响应的特性。
C++中多态分为两类:
- 静态多态(编译时多态):函数重载、运算符重载、模板
- 动态多态(运行时多态):通过虚函数机制实现,本文重点讨论
二、动态多态的实现条件
需同时满足以下三个条件:
- 存在继承关系
- 必须通过基类的指针或引用调用虚函数
- 派生类必须重写(覆盖)基类的虚函数
class Person {
public:
virtual void BuyTicket() const {
cout << "全价票" << endl;
}
};
class Student : public Person {
public:
virtual void BuyTicket() const {
cout << "半价票" << endl;
}
};
void Func(const Person& p) {
p.BuyTicket(); // 多态调用
}
int main() {
Person p;
Student s;
Func(p); // 输出:全价票
Func(s); // 输出:半价票
}
三、虚函数重写规则
1.基本规则
- 函数名、参数列表、返回值必须相同(协变例外)
- virtual关键字在派生类中可省略(建议保留)
2.协变
允许返回值类型作为基类指针/引用的派生类型:
class A {};
class B : public A {};
class Base {
public:
virtual A* Create(){
return new A;
}
};
class Derived : public Base {
public:
virtual B* Create() {
return new B;
} // 协变
};
3.析构函数必须为虚函数
在C++中,当将析构函数声明为 virtual 时,就构成了虚函数重写。这是因为在底层,类的析构函数都会被处理成统一的名字 destructor ,这样的处理方式使得派生类和基类的析构函数能够构成重写关系。
1.场景:未将析构函数设为析构函数的风险
#include <iostream>
class Person {
public:
virtual void BuyTicket() {
std::cout << "买票-全价" << std::endl;
}
~Person() {
std::cout << "~Person()" << std::endl;
}
};
class Student : public Person {
public:
void BuyTicket() override {
std::cout << "买票-半价" << std::endl;
}
~Student() {
std::cout << "~Student()" << std::endl;
delete[] ptr;
}
protected:
int* ptr = new int[10];
};
int main() {
Person* p = new Person;
p->BuyTicket();
delete p;
p = new Student;
p->BuyTicket();
delete p; // p->destructor() + operator delete(p)
return 0;
}
问题分析:在 main 函数中,当 p 指向 Student 对象并执行 delete p 时,由于 Person 类的析构函数不是虚函数,只会调用 Person 类的析构函数,而不会调用 Student 类的析构函数。这会导致 Student 类中动态分配的 ptr 所指向的内存无法被释放,最终造成内存泄露。我们期望的是,当 delete p 时, p->destructor() 能够根据 p 实际指向的对象类型,进行多态调用,而不是普通调用。
2.正确做法
将基类的析构函数声明为虚函数,使派生类的析构函数自动成为虚函数并重写基类的析构函数:
#include <iostream>
class Person {
public:
virtual void BuyTicket() {
std::cout << "买票-全价" << std::endl;
}
virtual ~Person() {
std::cout << "~Person()" << std::endl;
}
};
class Student : public Person {
public:
void BuyTicket() override {
std::cout << "买票-半价" << std::endl;
}
~Student(){
std::cout << "~Student()" << std::endl;
delete[] ptr;
}
protected:
int* ptr = new int[10];
};
int main() {
Person* p = new Person;
p->BuyTicket();
delete p;
p = new Student;
p->BuyTicket();
delete p;
return 0;
}
结果分析:在上述代码中, Person 类的析构函数被声明为虚函数。当 delete p 时,如果 p 指向 Student 对象,就会调用 Student 类的析构函数,从而正确释放 ptr 所指向的内存,避免内存泄露问题。当使用基类指针指向派生类对象,并且需要通过该指针释放对象时,一定要将基类的析构函数声明为虚函数,以确保析构函数的多态性,正确调用派生类的析构函数 。
四、C++新特性
1.override关键字:强制检查重写有效性
class Transport {
public:
virtual void StartEngine() const {
cout << "引擎启动" << endl;
}
};
class Tesla : public Transport {
public:
// 正确重写
virtual void StartEngine() const override {
cout << "电动机启动" << endl;
}
// 错误:签名不匹配(缺少const)
// virtual void StartEngine() override {}
};
2.final关键字:禁止继承或重写
禁用虚函数重写
class Car {
public:
virtual void Drive() final { // 禁止后续重写
cout << "基础驾驶模式" << endl;
}
};
class Benz : public Car {
public:
// 错误:尝试重写final函数
// void Drive() override { cout << "Benz驾驶模式" << endl; }
// 正确:新函数(参数不同构成隐藏)
void Drive(int mode) {
cout << "Benz-舒适模式" << endl;
}
};
禁止类被继承
class Vehicle final { // 最终类,禁止继承
public:
void Run() { cout << "行驶中..." << endl; }
};
// 错误:尝试继承final类
// class Car : public Vehicle {};
3.设计不可继承类的方法
方法一:利用构造函数或析构函数私有(C++98)
(一)基类构造函数私有
通过将基类的构造函数设为私有,阻止其他类通过继承来调用该构造函数,从而实现不可被继承的目的。但这样会导致无法直接创建基类对象,因此需要在基类中提供一个静态成员函数来创建对象。
class A {
public:
// 静态成员函数用于创建A类对象
static A CreateObj() {
return A();
}
private:
// 私有构造函数,防止外部直接构造对象
A() {}
};
// 尝试继承A类,编译会报错,因为无法访问A的私有构造函数
class B : public A {};
int main() {
// 通过静态函数创建A类对象
A::CreateObj();
return 0;
}
(二)基类析构函数私有
将基类析构函数设为私有,同样可以阻止继承。因为派生类对象析构时需要调用基类析构函数,而私有析构函数使得派生类无法进行这一操作。但这种方式下创建基类对象也变得复杂,通常通过指针方式创建对象,并提供一个成员函数来释放对象。
class A {
public:
// 提供一个成员函数来释放对象,这里函数名设为Destructor
void Destructor() {
delete this;
}
private:
// 私有析构函数
~A() {}
};
// 尝试继承A类,编译会报错,因为无法访问A的私有析构函数
class B : public A {};
int main() {
A* p = new A;
// 通过调用Destructor函数来释放对象
p->Destructor();
// 不能直接创建B类对象,因为B的析构需要调用A的私有析构函数
// B bb;
return 0;
}
方法二:使用final关键字(C++11及以后)
在C++11及后续版本中,可以使用 final 关键字将类标记为最终类,即不可被继承。
// 将A类标记为final,不可被继承
class A final {
public:
private:
};
// 尝试继承A类,编译会报错,因为A是final类
class B : public A {};
int main() {
return 0;
}
五、重载、覆盖、隐藏对比
特征 | 作用域 | 函数名 | 参数列表 | 返回值 | 其他要求 |
重载 | 同一类 | 相同 | 不同 | 任意 | - |
覆盖 | 基类与派生类 | 相同 | 相同 | 协变 | virtual关键字 |
隐藏 | 基类与派生类 | 相同 | 不同 | 任意 | 无virtual关键字 |
六、虚函数与多态原理
1.虚表机制
- 每个包含虚函数的类都有一个虚函数表
- 对象内存首部存放指向虚表的指针
- 虚表在编译阶段生成,存放在只读数据段
#include <iostream>
class Parent {
public:
virtual void virtualFunction() {
std::cout << "Parent's virtual function" << std::endl;
}
};
class Child : public Parent {
public:
// 重写父类的虚函数
void virtualFunction() override {
std::cout << "Child's virtual function" << std::endl;
}
};
int main() {
Parent* parentPtr = new Parent();
parentPtr->virtualFunction();
parentPtr = new Child();
parentPtr->virtualFunction();
delete parentPtr;
return 0;
}
在上述代码中, Parent 类中的 virtualFunction 是虚函数, Child 类重写了该虚函数。通过 Parent 类指针,根据其指向的对象类型( Parent 或 Child ),调用不同版本的 virtualFunction ,体现了多态性。同时, Child 类在重写虚函数时,会在其虚表中更新 virtualFunction 的地址,从而实现多态调用。
2.多态实现条件解析
(一)为什么必须是父类的指针或引用
多态实现需要通过父类的指针或引用。因为父类指针或引用具有灵活性,既可以指向父类对象,也能指向子类对象。相比之下,子类指针或引用只能局限于指向子类对象,无法满足多态中根据不同对象类型调用不同函数实现的需求。
(二)为什么不能是父类对象
对象切片与指针、引用的情况不同。在多态机制中,虚函数的调用依赖于虚表。派生类会先拷贝父类的虚表,然后将重写的虚函数地址覆盖到虚表相应位置。若使用父类对象,对象切片时只会将父类的成员拷贝到子类对象中,但父类的虚表不会拷贝过去。原因在于,如果拷贝虚表,当父类指针指向父类对象时,可能会错误调用子类的虚表,导致程序逻辑混乱。
(三)为什么要对虚函数重写
普通函数的继承是实现继承,而多态实现的是接口继承。通过重写虚函数,子类在继承相同接口的基础上,能够提供不同的实现,从而实现多态特性。
3.虚表的存储位置
虚表存储在常量区(实际上,虚表通常存储在只读数据段,不同编译器可能略有差异,但概念上类似常量区的性质,存放常量数据,程序运行期间不可修改)。
七、多继承与虚函数相关原理
1.多继承下派生类虚函数在虚表中的位置
class Base1 {
public:
virtual void func1() { std::cout << "Base1::func1" << endl; }
virtual void func2() { std::cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { std::cout << "Base2::func1" << endl; }
virtual void func2() { std::cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { std::cout << "Derive::func1" << endl; }
virtual void func3() { std::cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
Derive d;
return 0;
}
原理分析:在多继承场景下, Base1 和 Base2 分别拥有自己的虚函数表。对于派生类 Derive 新增的 func3 函数,通过验证发现其函数指针会放在 Base1 的虚表中。同时,由于 Base1 和 Base2 都有 func1 函数且在各自虚表中代表不同的函数实现,所以 func1 函数在 Base1 和 Base2 虚表中的地址是不一样的。
2.菱形继承下虚函数重写问题
class A {
public:
int _a;
};
class B : virtual public A {
public:
int _b;
};
class C : virtual public A {
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
(一)只重写A的虚函数,B和C尝试重写的情况
class A {
public:
virtual void func1() {
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : virtual public A {
public:
virtual void func1() {
cout << "B::func1" << endl;
}
public:
int _b;
};
class C : virtual public A {
public:
virtual void func1() {
cout << "C::func1" << endl;
}
public:
int _c;
};
class D : public B, public C {
public:
int _d;
};
问题
在菱形继承结构中, A 是 B 和 C 的虚基类, B 和 C 共享 A 的数据成员和虚函数。如果在 B 和 C 中同时重写 func1 函数,会导致编译报错。原因在于 B 和 C 共享 A 的虚函数 func1 ,编译器无法确定在 D 类中到底是 B 重写的 func1 生效还是 C 重写的 func1 生效,产生了歧义。
解决办法
让D去重写虚函数:
class A {
public:
virtual void func1() {
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : virtual public A {
public:
virtual void func1() {
cout << "B::func1" << endl;
}
public:
int _b;
};
class C : virtual public A {
public:
virtual void func1() {
cout << "C::func1" << endl;
}
public:
int _c;
};
class D : public B, public C {
public:
virtual void func1() {
cout << "D::func1" << endl;
}
int _d;
};
原理分析:当在 D 类中重写 func1 函数时,就明确了在 D 对象调用 func1 时的具体实现。虽然对于 D 而言, B 和 C 中重写的 func1 函数被覆盖,但对于单独定义的 B 对象和 C 对象,它们各自重写的 func1 函数仍然有意义,调用时会执行各自类中定义的 func1 函数实现。
(二)重写A的虚函数,同时B和C有单独虚函数的情况
class A {
public:
virtual void func1() {
cout << "A::func1" << endl;
}
public:
int _a;
};
class B : virtual public A {
public:
virtual void func1() {
cout << "B::func1" << endl;
}
virtual void func2() {
cout << "B::func2" << endl;
}
public:
int _b;
};
class C : virtual public A {
public:
virtual void func1() {
cout << "C::func1" << endl;
}
virtual void func2() {
cout << "C::func2" << endl;
}
public:
int _c;
};
class D : public B, public C {
public:
virtual void func1() {
cout << "D::func1" << endl;
}
public:
int _d;
};
原理分析:在这种情况下,由于 B 和 C 除了重写 A 的 func1 函数外,还各自拥有单独的虚函数 func2 。为了管理这些不同的虚函数实现, B 和 C 会各自创建一个虚表。这种结构使得虚函数的调用和管理变得更加复杂,需要在运行时根据对象的具体类型以及虚表指针来准确找到对应的虚函数实现。
八、抽象类
1.概念
包含纯虚函数的类被称为抽象类,也叫接口类。抽象类不能实例化出对象,因为它在现实世界中没有直接对应的实体,主要为派生类提供一个通用的接口框架。
2.示例
#include <iostream>
// 定义抽象类Car
class Car {
public:
// 纯虚函数Drive,使得Car类成为抽象类
virtual void Drive() = 0;
};
// Benz类继承自Car类
class Benz : public Car {
public:
// 若Benz类未重写Drive函数,Benz也会成为抽象类
void Drive() override {
std::cout << "Benz is driving." << std::endl;
}
};
// BMW类继承自Car类
class BMW : public Car {
public:
void Drive() override {
std::cout << "BMW is driving." << std::endl;
}
};
// BYD类继承自Car类
class BYD : public Car {
public:
void Drive() override {
std::cout << "BYD is driving." << std::endl;
}
};
// 函数Func接受一个Car类型的指针,并调用Drive函数
void Func(Car* p) {
if (p) {
p->Drive();
// 这里p->Func() 是错误的,Car类中没有Func函数,假设这是误写,应删除
}
}
int main() {
Func(new Benz);
Func(new BMW);
Func(new BYD);
// 以下代码会报错,因为抽象类Car不能实例化对象
// Car car;
return 0;
}
3.总结
- 纯虚函数的作用:在抽象类中定义纯虚函数,间接强制派生类重写该虚函数。派生类若不重写纯虚函数,自身也会成为抽象类,同样不能实例化对象。
- 抽象类的使用:虽然抽象类不能实例化对象,但可以定义指向抽象类的指针或引用,通过这些指针或引用调用派生类重写后的虚函数,实现多态行为。在上述代码中, Func 函数通过 Car* 指针调用不同派生类的 Drive 函数,体现了这一特性。
九、相关面试题
- 重载:在同一作用域内,函数名相同但参数列表(参数类型、个数、顺序)不同的函数称为重载函数。重载发生在编译阶段,根据调用时传入的参数来决定调用哪个重载版本,不关心函数返回值类型。
- 重写:也叫覆盖,发生在继承体系中。派生类重新定义基类中已有的虚函数,函数名、参数列表、返回值类型(协变返回类型除外)必须与基类中的虚函数完全一致,且使用override关键字标识(C++11及以后)。重写是实现动态多态的关键,运行时根据对象实际类型调用相应的重写版本。
- 重定义:也叫隐藏,指派生类定义了与基类同名的成员(函数或变量),此时基类的同名成员在派生类作用域内被隐藏。如果同名函数参数列表不同,即使基类函数不是虚函数,也会发生重定义,调用时取决于调用对象的类型。
- 静态多态:基于函数名修饰规则。编译器在编译时对重载函数的名字进行特殊修饰,使其包含函数参数类型等信息,从而在调用时能够准确识别并调用正确的函数版本。
- 动态多态:核心是虚函数表。在包含虚函数的类中,编译器会为其创建一个虚函数表,表中存储了虚函数的地址。当对象被创建时,会在对象的内存布局中设置一个指向虚函数表的指针。运行时,通过这个指针找到对应的虚函数表,并根据实际对象类型调用相应的虚函数。
- 定义:包含纯虚函数的类称为抽象类,也叫接口类。纯虚函数是一种特殊的虚函数,只声明函数原型,不提供函数体,用“= 0”标识。
- 作用:抽象类不能实例化对象,主要为派生类提供统一的接口规范和抽象的概念模型。通过强制派生类重写抽象类中的纯虚函数,实现不同的具体行为,支持多态性,方便程序的扩展和维护,提高代码的可复用性和可维护性。