多态

在C++中,多态是面向对象编程的一个重要特性,它允许不同的对象对同一消息做出不同的响应。

[TOC]

多态性可以提高代码的可扩展性和可维护性,主要分为静态多态和动态多态。

  • 静态多态:模板、函数重载

  • 动态多态:虚函数、继承

静态多态

静态多态也称为编译时多态,它在编译阶段就确定了要调用的函数。主要通过模板函数重载来实现。

模板与泛型编程

模板是C++中实现泛型编程的重要工具,它允许编写与类型无关的代码。模板分为函数模板和类模板。

泛型函数的参数由关键字 requires 定义。

  1. 使用模板提升代码抽象层级

正确使用 requires 关键字避免过度约束。

泛型编程的目标是针对具有相似语义属性的类型集合,高效地泛化操作/算法。

  • 算法约束:算法必须通过概念 requires 明确约束模板参数的要求,避免直接使用运算符而不检查类型支持性。

template<typename T>
requires Arithmetic<T>  // 明确约束T必须支持算术运算
T sum(vector<T>& v, T s) {
    for (auto x : v) s += x;
    return s;
}
  • 概念构建:允许定义简单概念作为构建块,但算法应使用足够强的概念来确保类型安全性。

  1. 使用模板表达适用于多种参数类型的算法

STL 的基础就是使用模板来适用不同参数类型的算法实现。

除非确实需要支持多种模板参数类型,否则不要使用模板。避免过度抽象。

  1. 使用模板来表达容器元素类型

容器需要元素类型,将其作为模板参数表达具有通用性、可复用性和类型安全性。

C 语言在容器中通常使用 void* 来保存不同元素类型,并通过设置元素第一个字节为参数类型进行区分。

  1. 静态多态和动态多态的互补

使用静态多态实现动态多态:

提供通用、便捷的静态绑定接口,但内部进行动态分发,从而实现统一的对象布局。例如std::shared_ptr的删除器所采用的类型擦除技术(但需避免过度使用类型擦除)。

类型擦除通过在独立编译边界后隐藏类型信息,会引入额外的间接层开销。

函数重载

同一作用域内,可以定义多个同名但参数列表不同的函数,编译器根据调用的实参类型和数量选择合适的函数。

  1. 优先使用默认参数而非函数重载

默认参数仅为单一实现提供替代接口,而一组重载函数无法保证始终实现相同的语义。使用默认参数可避免代码重复。

当一组函数用于对不同类型执行语义等价的操作时,不存在这种选择。

动态多态

动态多态也称为运行时多态,它在运行时才确定要调用的函数。主要通过虚继承虚函数来实现。

虚函数

虚函数是实现多态的核心机制,允许通过基类指针或引用调用子类中重写的方法,从而在运行时决定实际调用哪个函数,也称为动态绑定(runtime dispatch)。

当一个类中含有虚函数时:

  1. 编译器为它创建一张虚函数表(vtable);

  2. 每个对象内部会有一个隐藏的虚表指针(vptr)指向对应的虚表;

  3. 当调用虚函数时,运行时根据 vptr 找到虚表,再找到对应函数指针并调用它。

纯虚函数

纯虚函数在虚函数声明后添加 =0,强制派生类提供自己的实现。

核心作用:

  • 定义接口规范,强制派生类实现特定方法。

  • 使基类称为抽象类,无法实例化对象,只能作为接口被继承。

常见问题

  1. 构造函数能否是虚函数?

不能。

(1)虚函数调用依赖于对象的完整性

虚函数需要通过对象的虚表指针 vptr 调用,但在构造函数执行过程中,对象还不是完整的子类实例,虚表指针 vptr 还未初始化指向子类的虚表。所以构造函数执行期间无法实现动态绑定。

(2)构造函数的调用方式是静态的

构造函数无法通过基类指针或引用间接调用,不符合虚函数的动态绑定语义。

  1. 析构函数能否是虚函数?

基类指针指向派生类对象时,基类析构函数应当是虚函数。

如果没有将基类析构函数声明 virtual ,析构函数调用会采用静态绑定,通过基类指针删除派生类对象时,编译器仅调用基类的析构函数,导致派生类资源无法释放。

将基类析构函数声明为虚函数,确保删除基类指针时,先调用派生类析构函数,再调用基类析构函数

虚析构函数使对象的虚表包含析构函数的地址,删除基类指针时,通过虚表找到实际对象类型的析构函数并调用。

  1. 虚函数可以用默认参数吗?

虚函数可以有默认参数,但是默认参数的选择是在编译器静态绑定的,虚函数的调用属于动态绑定。

编译器在编译期间通过指针类型来确定默认参数值。

编译器遇到函数的处理机制:

  • 参数解析(编译期间):确定函数签名;解析默认参数值;进行类型检查和转换。

  • 函数调用(运行期间):通过虚函数表(vtable)确定实际调用的函数;执行响应的函数体。

在虚函数中使用默认参数可能会产生误导,应该避免使用。

可以通过函数重载、模板和策略模式来替代默认参数。

相关问题:

  • 虚析构函数也可以有默认参数,但是没有人会这样使用。

  • 纯虚函数可以有默认参数,结果同虚函数。

  • 多重继承中的复杂情况,具体要看调用的指针类型。

  1. 能否使用数组保存多态虚基类?

使用数组保存存在虚函数的类是非常危险的操作。

数组是一块连续的内存,而虚基类和继承类的内存如下:

两个类的内存大小并不相同,如果用数组保存 Base 类,可能会导致访问错误的内存地址引发段错误。

替代方法:

  • 指针数组:数组保存基类指针。

  • 智能指针容器:std::vector<std::uniquee_ptr<Base>>

  • 现代 C++ 特性:std::variantconcepts 等。

虚继承

虚继承(Virtual Inheritance) 是一种用于解决菱形继承缺陷的机制。

多继承

一个派生类可以同时继承多个基类。

  • 命名冲突:多个基类有同名的成员(变量或者方法),派生类无法直接调用该成员,需要明确基类成员。

  • 设计复杂:违背单一职责原则,代码可读性、维护性下降。

  • 构造、析构顺序问题:多继承基类的构造函数按继承声明顺序执行,机构函数按相反顺序执行。逻辑容易出错导致资源泄露。

菱形继承

菱形继承时多继承的特殊情况,两个中间基类同时继承顶层基类,底层派生类又同时继承两个中间基类。

  • 数据冗余:顶层基类的成员在底层派生类中存在多份副本,浪费内存。

  • 二义性:直接访问顶层基类成员,编译器无法确定访问哪份副本,导致编译错误。

当一个派生类通过多条路径间接继承自同一个基类时,会导致基类成员在派生类中出现多份副本,引发二义性和数据冗余。

间接基类通过关键字 virtual 虚继承顶层基类,让间接基类在派生类中只保留一份实例,解决了这一问题。

虚继承原理:

  • 虚继承的类添加虚基类表指针,指向虚基类表。

  • 虚基类表存储当前类到虚基类的偏移量,中间基类访问时都指向同一份虚基类成员。

  • 只需要额外的指针开销,访问虚基类成员需要计算偏移量。

最后更新于