C++多态

多态(Polymorphism)是面向对象编程的重要特性之一,在C++中,多态使得我们可以用相同的函数名来调用不同的实现,从而使得系统更具扩展性和灵活性。

多态的概念

基本定义:在C++中,多态允许使用相同的函数名来调用不同的函数实现。例如,派生类中的函数可以覆盖基类中的虚函数,从而实现不同的行为。

面向对象中的多态:在面向对象编程中,多态性指的是向不同的对象发送同一个消息时,不同的对象会产生不同的行为。也就是说,每个对象可以以自己的方式响应相同的消息。

动态绑定

何时需要多态行为:动态绑定在运行时决定调用哪个函数,而不是在编译时。这是多态的关键特性,它使得我们可以在基类指针或引用上调用派生类的函数实现。

对象切除问题:如果你用基类指针或引用操作派生类对象时,基类中没有被声明为虚函数的成员函数会导致对象切除。即,基类指针只会看到基类的部分,忽略派生类的部分。

建议

  • 不应该重新定义一个继承而来的非虚函数。虽然编译器不会阻止你这样做,但从语义上来看,这可能会引发问题。

  • 如果某个函数在继承体系中应该保持不变(如 void Clock::ShowTime() const),那就不要重新定义它。

  • 如果函数在继承体系中的实现需要发生变化(如 void Clock::ShowInstruction() const),那么需要将其声明为虚函数。

虚函数

定义与语法

使用 virtual 关键字声明虚函数。虚函数是为了实现多态,让基类和派生类中的函数可以具有不同的实现。

class Clock {
public:
virtual void ShowInstruction() const;
};

派生类中的虚函数

派生类中重定义基类中的虚函数时,可以省略 virtual 关键字,但保持一致性和可读性使用它是推荐的。

class MediaClock : public Clock {
public:
void ShowInstruction() const override; // `virtual` 可以省略
};

虚析构函数

虚析构函数是为了防止内存泄漏,特别是在子类中有指针成员变量时。当你用基类指针删除子类对象时,虚析构函数会确保调用子类的析构函数,从而正确释放子类对象中的资源。

class Base {
public:
virtual ~Base() { /* cleanup */ }
};

class Derived : public Base {
public:
~Derived() override { /* cleanup */ }
};

虚函数工作原理

在C++中,虚函数表(vtable)是实现多态的关键机制。每个含有虚函数的类(无论是基类还是派生类)都有一个虚函数表,用于存储该类的虚函数指针。这些虚函数表使得程序在运行时能够根据对象的实际类型调用正确的虚函数实现。

虚函数表(vtable)概念

虚函数表的基本概念: 每个含有虚函数的类都有一个虚函数表(vtable)。虚函数表是一个指针数组,其中每个元素都是指向虚函数实现的指针。

虚函数表的结构

基类的虚函数表:包含基类定义的虚函数指针。

派生类的虚函数表:继承了基类的虚函数表,并包含:

  • 基类的虚函数指针(如果派生类没有覆盖这些虚函数的话)。

  • 派生类中覆盖(重写)基类虚函数的函数指针。

  • 派生类中新增的虚函数的函数指针。

虚函数构造过程

编译器的角色

  • 编译器在编译时分析类的虚函数和继承关系,根据这些信息构造虚函数表。

  • 处理虚函数覆盖和新增虚函数的过程是在编译时完成的,确保运行时能够正确调用虚函数。

虚函数调用过程

  1. 确定虚函数的偏移值: 编译器在编译时知道虚函数在虚函数表中的偏移值。

  2. 获取虚函数表指针: 在运行时,通过对象的 vptr(虚函数表指针)来找到虚函数表。

  3. 查找并调用实际的虚函数实现

示例:

  1. 编译器知道 pbB* 类型的指针,但不确定它指向的是 B 还是 D 对象。

  2. 对于 pb->bar(),编译器知道调用的是 B::bar,因为 pbB* 类型的指针。

  3. B::barD::bar 在各自虚函数表中的偏移位置相同。`

  4. 运行时,根据 pb 的实际类型和虚函数表的偏移值,能够找到正确的函数。

多重继承包含的多个虚函数表指针

D 的虚函数与 B 基类共享同一个虚函数表,因此 B 被称为 D 的主基类。虚函数替换过程中涉及多个虚函数表,需要进行额外的拷贝和替换。虚函数调用与前述类似,但基类指针可能不指向派生类对象的起始位置。

多重继承的应用场景包括使用接口类。接口类是没有成员变量、所有成员函数都是纯虚函数的类。简而言之,接口类只是声明,没有实际实现。当你需要定义子类必须实现的功能,并且希望子类提供具体的实现时,接口类非常有用。

虚继承解决菱形继承

在这个多重继承的例子中,Assistant 类从 StudentTeacher 继承,而 StudentTeacher 又都从 Person 继承。这会导致 Assistant 类中存在两份 Person 的数据,从而引发数据冗余问题。

具体来说,如果 Assistant 类中有两个 _name_id 成员变量,编译器在访问这些变量时会遇到歧义,因为它无法确定应使用哪一份数据。

Assitant成员内存布局:

在菱形继承中,使用 virtual 关键字可以解决数据冗余和访问歧义问题。其原理如下:

虚基类指针:编译器为虚基类维护一个虚基类指针(vbptr),指向虚基类子对象。所有派生类通过这个指针访问虚基类数据。

虚基类初始化:在构造派生类对象时,虚基类会被最先初始化,确保虚基类只被初始化一次,即使有多个继承路径。

虚基表:虚继承的类中会有一个虚基表指针(vbptr),指向虚基表。虚基表中记录了虚基类数据的偏移量,即表的地址到虚基类数据地址的距离。

限定符override&final

override 关键字

在 C++11 中,override 关键字用于显式声明派生类中重写基类虚函数的实现。重写时需要满足以下条件:

  1. 虚拟:基类中的成员函数必须被声明为虚拟的(virtual)。

  2. 兼容:基类和派生类中的成员函数返回类型和异常规格(exception specification)必须兼容。

  3. 完全匹配:基类和派生类中的成员函数名、形参类型、常量属性(constness)和引用限定符(reference qualifier)必须完全相同。

使用 override 时需要注意:

  • 被覆盖的方法的标志必须与被覆盖的方法的标志完全匹配。

  • 被覆盖的方法的返回值必须与被覆盖的方法的返回值一致。

  • 被覆盖的方法所抛出的异常必须与被覆盖的方法所抛出的异常一致,或者是其子类。

  • 被覆盖的方法不能是 private,否则在子类中只是新定义了一个方法,并没有对其进行覆盖。

函数的签名包括以下部分:

  1. 函数名
  2. 参数列表:函数的参数类型、顺序和数量。注意,参数的名称不影响函数签名。
  3. 常量属性(constness):如果函数是 const 成员函数,即声明为 const,则这也是函数签名的一部分。
  4. 引用限定符(reference qualifier):如果函数使用了引用限定符(如 &&&),这也是函数签名的一部分。

函数签名不包括

  • **返回类型:**返回类型不被视为函数签名的一部分。因此,即使返回类型不同,函数仍然可以被视为重载,但不能重写。

  • **函数的默认参数:**默认参数不影响函数签名,只是函数的行为。

final关键字

final 关键字用于限制继承和覆盖的行为:

  • 用于类:表示该类不能被继承。

  • 用于虚函数:表示该虚函数不能被进一步重写。

复制构造函数与虚函数

构造函数不能是虚函数:

  • 内存分配问题:构造函数在对象的内存分配后被调用,此时虚函数表(vtable)尚未初始化。如果构造函数是虚函数,则需要通过虚函数表来调用,但对象的虚函数表尚不存在,因此无法确定具体的虚函数实现。

析构函数必须是虚函数:

  • 对象销毁:析构函数必须是虚函数以确保通过基类指针删除对象时,能够正确调用派生类的析构函数,从而正确地释放所有资源。如果析构函数不是虚函数,可能导致派生类的资源未被正确释放。

解决方案:

  • Clone 方法:由于构造函数不能是虚函数,常用的解决方案是基类中定义一个虚拟的 Clone() 方法。这个方法用于创建当前对象的备份,并返回该备份对象的指针。

------------------------------- 本文结束啦❤感谢您阅读-------------------------------
赞赏一杯咖啡