9. 类(Classes)
- 基本定义 :类(Class)是用户自定义类型,通过
class-specifier进行定义。类成员必须在类定义中一次性全部定义完毕,无法像命名空间那样事后追加成员。 - 成员类型 :
- 数据成员:非静态数据成员(含位域)、静态数据成员。
- 成员函数:非静态成员函数、静态成员函数。
- 嵌套类型 :嵌套类、枚举、类型别名(
typedef/ 类型别名)、注入类名 。 - 枚举成员 :来自类内部所有无作用域枚举的枚举器,或通过
using/using-enum引入的枚举器。 - 成员模板:变量模板、类模板或函数模板。
成员命名限制
- 类
T的成员不能 使用T作为其名字的情况包括:静态数据成员、成员函数、成员类型、成员模板、作用域枚举的枚举器,以及成员匿名联合体内的成员。 - 唯一例外 :只要类中没有用户声明的构造函数 ,非静态数据成员允许使用与类名相同的名字
T。
类的特殊形态与运行时属性
- 多态类 :声明或继承了至少一个虚函数 的类。其实例包含运行时类型信息,支持
dynamic_cast、typeid以及动态绑定。 - 抽象类 :声明或继承了至少一个纯虚函数的类。无法实例化对象。
- 字面量类型 :拥有
constexpr构造函数的类(since C++11),其对象可在编译期通过constexpr函数进行操作。
类的关键属性与分类
- 平凡可复制类 :拥有至少一个合格的复制/移动构造或赋值操作且全部为平凡的,同时具备非删除的平凡析构函数。
- 平凡类 :属于平凡可复制类,且拥有一个或多个平凡的合格默认构造函数(自 C++26 起标记为弃用)。
- 标准布局类 :对数据成员类型、虚函数、虚基类、访问控制及基类继承结构有严格限制的类(确保与 C 语言兼容的内存布局)。
- 隐式生命周期类 :满足特定聚合条件且无用户提供/声明析构函数的类,或者拥有至少一个平凡合格构造函数及平凡非删除析构函数的类。
- POD 类 :兼具聚合、平凡与标准布局特性的类(C++11 前后定义有所演进)。
9.1 类声明
类通过 class-specifier 进行定义,主要分为两种形式:
- 命名类定义:指定类名并带有成员规范。
- 匿名类定义:无类名(通常用于声明特定对象)。
- 关键字差异 :
class、struct和union统称为class-key。其中class和struct唯一的区别在于默认的成员访问权限 和默认的基类继承访问权限 (class默认私有private,struct默认公有public)。
前向声明
- 语法 :
class-key attr identifier ; - 作用 :
- 声明一个稍后会在当前作用域进行定义的类类型。在定义出现之前,该类名具有不完整类型 。
- 允许类之间相互引用(如互为友元),并且在仅使用指针和引用的源文件中,能够有效减少
#include依赖。
- 局部作用域隐藏:若前向声明出现在局部作用域中,它会隐藏外层作用域中同名的类、变量、函数等所有声明。
成员规范:类的函数体/大括号内部包含由各类成员构成的序列,主要支持以下内容:
- 成员声明 :非静态/静态数据成员、成员函数、嵌套
typedef、枚举、嵌套类及友元声明。 - 函数定义 :直接在类体内定义成员函数或友元函数(类体内定义的函数隐式内联,除非附加到命名模块)。
- 访问说明符 :
public:、protected:和private:。 - Using 声明与推导指引:如引入基类成员、继承构造函数(C++11)、Using-enum(C++20)以及类模板的推导指引(C++17)。
- 静态断言 :
static_assert。 - 成员模板:变量模板(C++14)、类模板或函数模板。
局部类:在函数体内定义的类称为局部类。其具有一系列严格的语言限制:
- 作用域:名称仅存在于函数体内,外部不可见。
- 静态数据成员 :局部类不能拥有静态数据成员。
- 成员函数 :无链接属性(No linkage),且必须在类体内完全定义。
- 模板限制:除闭包类型外,局部类不能有成员模板、友元模板,且在 C++11 之前不能用作模板参数。
- 外部访问权限:局部类可以访问其外层函数能够访问的相同名字。
9.1.1 联合体
- 基本定义 :联合体(
union)是一种特殊的类类型,在任意时刻只能持有一个非静态数据成员。 - 内存占用 :联合体的大小至少要能够容纳其最大的成员,且所有非静态数据成员具有相同的内存地址。
- 默认访问权限 :与
struct类似,联合体内部成员的默认访问权限为public。 - 基本限制 :
- 不能拥有虚函数。
- 不能作为基类,也不能继承其他类。
- 自 C++11 起,最多允许一个变体成员拥有默认成员初始化器。
- 不能包含引用类型的非静态数据成员。
活动成员
- 活动成员规则 :在任意时刻,只有最近一次被写入的成员才是"活动"的。读取未激活的成员属于未定义行为( (尽管很多编译器作为非标准扩展允许这样做)。
- 复杂类型切换 :如果联合体成员是含有用户自定义构造/析构函数的类(如
std::string或std::vector),切换活动成员时通常需要显式调用析构函数 并通过 Placement New 来激活新成员。
匿名联合体
- 定义:未命名且不用于直接定义变量的联合体。
- 严格限制 :不能包含成员函数、静态数据成员,所有数据成员必须为
public,且只允许包含非静态数据成员和static_assert。 - 名字注入 :匿名联合体中的所有成员会被直接注入到其所在的外层作用域 中。命名空间作用域的匿名联合体必须显式声明为
static(除非位于匿名命名空间中)。
类似联合体的类与现代替代方案
- 定义 :联合体本身,或者包含至少一个匿名联合体成员的常规类(
struct/class),常用于实现标记联合 。 - 现代标准替代 :自 C++17 起,标准库引入了
std::variant,可以优雅且类型安全地替代传统的联合体和类似联合体的类。
9.2 注入类名
- 基本定义 :注入类名是指在类(或类模板)自身的内部作用域中,该类(或模板)的无限定名被隐式视为一个公共成员名。
- 声明点 :它的声明点位于类(或模板)定义开头的左大括号
{之后。 - 主要用途:允许在类内部直接使用类名来指代当前类或当前特化版本,而不必写出完整的模板参数。
在普通类与继承中的行为
- 成员隐藏 :如果在外部作用域存在同名变量或标识符,类内部的无限定查找会优先找到该变量,此时若要访问类本身需要加域解析符(如
::X)。 - 继承与访问性 :与普通成员一样,注入类名可以被继承。但在私有(
private)或保护(protected)继承下,间接基类的注入类名可能会在派生类中变得不可访问。
类模板中的双重身份(模板名 vs 类型名)
在类模板中,注入类名既可以作为模板名 使用,也可以作为类型名使用,具体取决于上下文:
- 作为模板名的情况:
- 其后紧跟
<(如X<T>)。 - 被用作模板模板参数
- 作为友元类模板声明中
elaborated class specifier的最终标识符。
- 其后紧跟
- 作为类型名的情况:
- 在其他常规语境下,它被视为类型名,并等价于该模板名后跟当前模板的参数列表(例如在
X内部直接等价于X<T1, T2>)。
- 在其他常规语境下,它被视为类型名,并等价于该模板名后跟当前模板的参数列表(例如在
- 特化与偏特化版本:在类模板的显式特化或偏特化作用域内,作为类型名时,它会自动等价于对应特化或偏特化版本的参数列表。
歧义处理、构造函数与特殊规则
- 多重继承歧义:若通过多重继承查找到多个不同的注入类名,作为类型名可能会导致歧义;但若将其作为模板名(且均指向同一模板的不同特化),则不会产生歧义。
- 与构造函数的关联 :构造函数虽然没有名字,但在声明和定义构造函数时,会使用外层类的注入类名来指代构造函数。此外,限定名
C::D若在类C内部查找到其注入类名,则该限定名被强制视为指代C的构造函数(可用于友元构造函数声明、构造函数模板实例化或继承构造函数)。
9.3 非静态数据成员
- 位置与形式:非静态数据成员在类的成员规范中声明,可包含普通变量、引用类型、位域、嵌套类型实例等。
- 禁用说明符 :
- 不允许使用
extern和register存储类说明符。 - 自 C++11 起,不允许使用
thread_local(但静态数据成员允许)。 - 不允许使用不完整类型、抽象类类型及其数组(但允许指向自身的引用
C&或指针C*)。 - 占位符类型(如
auto、decltype(auto)等)不能用于非静态数据成员声明。
- 不允许使用
- 命名冲突 :若类中存在至少一个用户声明的构造函数,非静态数据成员不能使用与类相同的名字。
内存布局
- 地址顺序 :对于非联合体类类型,非零大小的成员总是按声明顺序分配内存,即后声明的成员具有更高的内存地址(C++23 起进一步明确了无视访问控制的分配规则)。
- 对齐与填充:成员之间或最后一个成员之后可能会因对齐要求而产生字节填充
- 标准布局与公共初始序列 :满足特定条件的类属于标准布局类。两个标准布局非联合体类如果拥有相同的公共初始序列且类型/对齐匹配,则它们是 布局兼容的,允许在联合体中安全地跨类型访问公共前缀成员。此外,标准布局类型的对象指针可以
reinterpret_cast为其首个非静态数据成员的指针(反之亦然),且其首个成员之前没有填充字节。
成员初始化
- 构造函数成员初始化列表 :在构造函数后通过冒号指定。
- 默认成员初始化器 :在成员声明时直接通过
=或大括号赋值(C++11 引入)。- 优先级 :如果某个成员既有默认成员初始化器,又出现在构造函数的初始化列表中,则默认成员初始化器会被忽略。
- 限制:位域在 C++20 之前不能使用默认成员初始化器;数组类型不能通过初始化器自动推导大小;引用成员不能在初始化器中绑定到临时对象(否则会导致悬空引用或编译错误)。
三种合法使用场景
非静态数据成员(或非静态成员函数)的名字只能在以下三种情况下单独或直接出现:
- 类成员访问表达式 :作为类成员访问的一部分,包括在成员函数体、初始化列表、默认成员初始化器中隐式使用的
this->。 - 形成成员指针 :用于构建指向非静态成员的指针(如
&S::m)。 - 未求值操作数(仅限数据成员) :例如在
sizeof表达式中(如sizeof m或sizeof(S::m + 42)),即使当前没有this对象也是合法的。
9.3.1 位域
- 定义 :位域是一种特殊的类数据成员,允许以指定的二进制位数来分配存储空间。
- 语法形式 :
- C++20 前 :
identifier (optional) attr (optional) : size - C++20 起 :支持默认成员初始化器:
identifier (optional) attr (optional) : size brace-or-equal-initializer
- C++20 前 :
- 类型限制 :位域的类型只能是整型(含
bool)或(可 cv 限定的)枚举类型。未命名的位域不能带有 cv 限定符。
特殊位域与填充规则
- 未命名位域 :
- 普通未命名位域:用于引入指定数量的空白填充位
- 大小为零的未命名位域:具有特殊含义,它强制下一个位域从其分配单元的起始边界(如下一个字节或对齐边界)开始存放。
- 大小超出类型宽度:若指定的位数大于其底层类型的大小,则超出的部分作为填充位,数值范围仍受限于该类型本身。
语言限制与操作约束
- 取地址限制 :由于位域不一定对齐到字节边界,无法获取位域的地址 (即不能使用
&运算符),也不存在指向位域的指针或非常量引用。 - 常量引用绑定 :当用位域初始化一个常量引用(
const T&)时,编译器会隐式创建一个临时对象并绑定到该临时对象。 - 无 Prvalue:不存在位域纯右值(Prvalue),对位域进行左值到右值转换时,总是产生成为底层类型的值。
- 默认成员初始化器:C++20 起允许位域拥有默认成员初始化器,在此之前(C++20 前)为非法。
实现定义的行为
位域的底层实现细节高度依赖于编译器和硬件平台:
- 内存中的打包方向(从左到右或从右到左)。
- 相邻位域是否会跨越字节边界
- 有符号位域超出范围赋值时的结果。
9.4 非静态成员函数
- 定义 :在类定义中声明、没有
static或friend修饰符的函数。 - 支持的附加语法 :纯虚说明符、cv 限定符、引用限定符、
final/override说明符(C++11),以及成员初始化列表(针对构造函数)。 - 调用方式 :可通过类成员访问运算符作用于类对象或派生类对象,也可以在类(或派生类)的成员函数体内直接调用。在非自身或非派生类对象上调用会导致未定义行为。
隐式 this 转换与名字查找: 在隐式对象成员函数体内
- 数据成员 :对非类型非静态成员
e的任何未限定标识表达式,会被自动转换为成员访问表达式(*this).e(除非已处于成员访问中)。 - 静态成员/嵌套类型 :对静态成员、枚举器或嵌套类型的未限定标识,会被自动转换为对应的限定名(如
S::n)。
限定修饰符与重载
- cv 限定符 :可声明为
const、volatile或其组合。不同 cv 限定的函数具有不同的类型并可相互重载。在函数体内,*this带有相应的 cv 限定。 - 引用限定符(C++11) :
- 可使用左值引用限定符(
&)或右值引用限定符(&&),用于根据对象的左/右值属性参与重载决议。 - 注意 :与 cv 限定不同,引用限定符不会改变
*this指针本身的类型 (在&&修饰的函数内,*this仍然是一个左值表达式)。
- 可使用左值引用限定符(
显式对象成员函数 :自 C++23 起,非静态非虚成员函数可以拥有一个显式对象参数(通过前缀关键字 this 声明):
- 传参与推导 :允许按值传递(复制对象)或使用转发引用(如
this Self&&),从而实现完美的 cv/ref 完美转发,有效避免了传统const与非const成员函数的代码重复。 - 限制 :在显式对象成员函数体内无法使用
this指针,所有成员访问必须通过显式对象参数进行。 - 函数指针 :指向显式对象成员函数的指针是普通的函数指针而非传统的成员函数指针,可以通过普通函数方式调用。
特殊成员函数 :编译器在特定条件下会自动生成的成员函数,这些函数(以及 C++20 的比较运算符等)可以使用 = default 显式要求编译器生成。
- 默认构造函数
- 复制构造函数
- 移动构造函数(C++11)
- 复制赋值运算符
- 移动赋值运算符(C++11)
- 析构函数(C++20 前) / 预期析构函数(C++20 起)
9.4.1 this 指针
- 表达式属性 :
this是一个纯右值表达式,其值为当前正在被调用的隐式对象参数的地址。 - 类型推导 :
- 在类
X的普通成员函数中,this的类型为X*(指向X的指针)。 - 若成员函数带有
cv限定符(如const或volatile),则this的类型会相应变为cv X*。 - 构造函数与析构函数 :由于构造函数和析构函数不能带有
cv限定符,即使在构造或销毁一个const对象时,它们内部的this类型也永远是X*。
- 在类
合法出现的位置 :this 只能与包含它的最内层类关联,它允许出现在以下上下文中:
- 任何隐式对象成员函数的体内(包括成员初始化列表)。
- 隐式对象成员函数声明中,
cv限定符序列之后的任意位置(包括异常规范和尾随返回类型,自 C++11 起)。 - 默认成员初始化器中。
- Lambda 表达式的捕获列表(自 C++11 起)中。
类模板与依赖表达式 在类模板中,this 被视作一个依赖表达式 。当基类的成员因依赖于模板参数而在派生类中不可见时,必须显式使用 this-> 前缀(如 this->var = 1;)来强制让表达式成为依赖的,从而通过编译。
构造函数中的特殊限制(禁止指针别名)
在对象构造期间,如果通过非直接或间接从 this 指针获取的 glvalue 去访问该对象或其子对象的值,其结果是未指定 的。换句话说,在构造函数中不能对 this 指针进行别名访问 (例如不能在初始化列表中通过全局对象或外部引用来访问正在构造的成员,而应直接使用 a 或 this->a)。
delete this; 的安全使用
- 基本机制 :只要能确保对象是通过
new动态分配的,在成员函数内部执行delete this;是合法的。 - 生命周期终止 :一旦执行
delete this;,该对象即被销毁,this指针及其所有指向该对象的指针都会变为悬空指针 。此后,不能再访问任何类成员(因为这会隐式解引用this),也不能再调用其他非静态成员函数。 - 典型应用:常用于实现引用计数智能指针(如简易的引用计数类)中,当引用计数减至零时由对象自身销毁。
9.5 静态成员
- 定义 :在类定义中使用
static关键字声明的成员,不绑定到具体的类对象。 - 命名限制:静态数据成员和静态成员函数的名称必须与包含它的类名不同。
- 存储期 :静态数据成员独立于任何对象存在,在整个程序中通常只有一份实例(具有静态存储期),若使用
thread_local则每个线程有一份。
声明与定义的区别
- 关键字的使用 :
static关键字仅在类定义内部的声明中 使用,在类外进行定义时不能带static。 - 类内声明与不完整类型 :类体内的声明不是定义,因此允许声明为不完整类型(包括声明为包含该成员自身的类类型),但如果使用了
constexpr或inline(C++17 起)说明符,则必须是完整类型。 - 类外定义与 ODR 使用 :
- 普通静态数据成员或传统的
const静态数据成员如果被 ODR 使用(ODR-use),必须在命名空间作用域进行类外定义。 - 自 C++17 起,静态数据成员可以声明为
inline或constexpr(constexpr隐式内联),允许直接在类定义中初始化,不再需要类外的重复定义。
- 普通静态数据成员或传统的
静态成员函数
- 无
this指针 :静态成员函数不与任何对象关联,调用时没有this指针。 - 修饰符限制 :静态成员函数不能 被声明为
virtual、const、volatile或引用限定(ref-qualified)。 - 函数指针 :静态成员函数的地址可以存储在普通的函数指针中,而不能存储在成员函数指针中。
静态数据成员与常量初始化
- 可变性限制 :静态数据成员不能 是
mutable的。 - 整型/枚举常量初始化 :整型或枚举类型的
const静态数据成员可以在类定义内部直接用常量表达式初始化。 constexpr静态数据成员 :LiteralType 类型的constexpr静态数据成员必须在类定义内部提供初始化器(自 C++11 起)。
9.5.1 嵌套类
- 定义 :在一个类(或结构体、联合体)内部声明另一个类,即为嵌套类。
- 作用域与名字查找:嵌套类的名字存在于外层类的作用域中。在嵌套类的成员函数中进行名字查找时,会先检查嵌套类自身的作用域,然后再访问外层类的作用域。
访问权限与对外层类成员的访问
- 访问权限 :嵌套类作为外层类的成员,可以访问外层类的所有成员(包括
private、protected等)。 - 无
this指针特权 :尽管嵌套类能看到外层类的成员,但它与外层类在实例上是独立的,没有外层类的this指针 。- 非静态成员访问 :不能直接读写外层类的非静态数据成员(因为缺乏外层类实例),除非通过外层类指针/引用显式访问(如
p->x = i;),或者在未求值操作数中(如sizeof x,自 C++11 起允许)。 - 静态成员访问:可以直接访问外层类的静态成员。
- 非静态成员访问 :不能直接读写外层类的非静态数据成员(因为缺乏外层类实例),除非通过外层类指针/引用显式访问(如
友元与嵌套类的特殊限制
- 友元函数 :在嵌套类内部定义的友元函数,没有访问外层类私有成员的特殊特权(尽管嵌套类自身的成员函数可以访问)。
类外定义与前向声明
- 类外定义 :嵌套类的成员若在类外定义,其定义必须位于外层类的命名空间作用域 中(例如
int enclose::inner::x = 1;)。 - 前向声明 :嵌套类可以先进行前向声明,随后再进行定义(既可以在外层类内部定义,也可以在类外通过
enclose::nested2的形式定义)。 - 访问控制限制 :嵌套类的声明同样受访问限定符约束。若嵌套类被声明为
private,则无法在外层类作用域外部直接使用该类型名称来声明对象,但仍可操作已创建的对象。
9.6 派生类
- 继承子句 :在类声明中使用冒号
:后面跟随一个或多个逗号分隔的基类说明符 - 默认继承权限 :
- 若派生类是用
struct声明的,默认继承访问级别为public。 - 若派生类是用
class声明的,默认继承访问级别为private。
- 若派生类是用
- 对象布局与基类子对象 :每个直接和间接基类都会作为基类子对象存在于派生类对象中。空基类通常不会增加派生类的大小(得益于空基类优化,EBO)。
虚基类与多重继承
- 消除重复子对象 :当多个路径继承同一个基类并将其声明为
virtual时,整个继承体系的最底层派生对象中只包含一个该类型的共享基类子对象。 - 初始化顺序:所有的虚基类子对象都会在任何非虚基类子对象之前被初始化。因此,虚基类的构造函数只能由最底层派生类在初始化列表中直接调用。
继承方式与访问控制
- Public 继承 :
- 基类的
public和protected成员在派生类中分别保持public和protected。 - 建模面向对象的 IS-A(是一个) 关系,满足里氏替换原则(LSP)。
- 基类的
- Protected 继承 :
- 基类的
public和protected成员在派生类中均变为protected。 - 用于"受控多态",在派生类及其后续子类内部形成 IS-A 关系。
- 基类的
- Private 继承 :
- 基类的
public和protected成员在派生类中均变为private。 - 常见于策略模式、静态多态以及利用空基类优化(EBO),或用于实现"根据实现细节组合"而非公开接口继承。
- 基类的
9.6.1 Using 声明
- 核心功能:将别处定义的名称引入到当前 using 声明所在的声明区域中。
- 主要应用场景 :
- 在命名空间和块作用域中:引入其他命名空间的成员(如
using std::string;)。 - 在类定义中:引入基类的成员(如改变访问权限或暴露重载函数)。
- 引入枚举器(自 C++20 起,支持作用域枚举和非作用域枚举的枚举器)。
- 在命名空间和块作用域中:引入其他命名空间的成员(如
类定义中的行为
- 改变访问权限 :可以将基类的
protected或private成员在派生类中暴露为public(例如using B::m;)。 - 重载与隐藏 :
- 如果using声明引入了基类的某个重载函数名,该名字对应的所有基类重载版本都会被引入。
- 如果派生类定义了相同名字但不同参数列表/限定符 的成员,两者构成重载;如果签名完全相同,派生类成员会隐藏基类引入的成员。
继承构造函数(C++11)
- 基本机制 :通过
using Base::Base;可以直接将直系基类的所有构造函数引入到派生类中。 - 初始化顺序与行为 :
- 选中的继承构造函数会直接初始化对应的
Base基原子对象。 - 派生类的其他非静态数据成员和基类会按默认方式初始化(应用默认成员初始化器或默认初始化)。
- 虚基类有特殊的跳过规则:如果基类是虚拟继承的且派生类不是最底层派生对象,则继承构造函数的调用及参数求值会被省略。
- 选中的继承构造函数会直接初始化对应的
- 与隐式构造函数的冲突 :如果继承来的构造函数签名与派生类的复制/移动构造函数匹配,它不会阻止编译器隐式生成派生类的复制/移动构造函数(隐式生成的版本会将其隐藏)。
核心限制与注意事项
- 禁止的情况 :
- 无法引用命名空间本身、析构函数、用户定义转换函数成员模板的特化。
- 不能直接对模板名称使用模板参数列表(如
using B::f<int>;非法)。 - 不能结合模板消歧义符(如
using B<Y>::template f;非法)。
- C++17 变长参数包扩展 :支持在变长继承中使用包扩展(如
using Ts::operator()...;),从而无需递归即可展平多基类的重载成员。
9.6.2 空基类优化( EBO)
- 非空限制 :按照 C++ 标准,任何独立的非数组成员或对象的大小必须至少为 1 字节(即
sizeof(Empty) >= 1),以确保同一类型的不同对象具有唯一的内存地址。 - EBO 机制 :对于基类子对象,该限制不适用。如果一个类继承自一个空类(没有任何非静态数据成员的类/结构体),编译器可以将其大小完全优化为零,不占用任何额外的存储空间。
EBO 失效场景(同类型冲突限制) :当空基类的类型与第一个非静态数据成员 (或该成员的基类)类型相同时,EBO 无法应用。
- 原因:C++ 标准规定,同一对象内的两个同类型子对象(或基类子对象)必须拥有不同的内存地址。
- 后果 :此时空基类必须占据至少 1 字节,并且为了满足对齐要求,后面往往还会产生额外的填充字节),从而导致对象体积增大(例如经典反例
std::reverse_iterator)。
标准布局类的要求
- 对于标准布局类型,为了保证
reinterpret_cast能够正确将对象指针转换为其首个成员的指针,标准强制要求必须应用 EBO。 - 这也是为什么标准布局类型要求"所有非静态数据成员必须在同一类中声明(要么全在派生类,要么全在某个基类)"以及"不能有与第一个非静态数据成员相同类型的基类"。
C++20 \[no_unique_address] 属性
- 自 C++20 起,引入了
[[no_unique_address]]属性。 - 允许非静态数据成员(而不仅限于基类)被优化掉(类似于 EBO)。带有该属性的空成员变量可以不占用存储空间,但获取其地址时可能会与同对象的其他成员地址相同。
实际应用场景
- 标准库无状态分配器 :
std::vector、std::function、std::shared_ptr等标准库组件广泛利用 EBO(通过类似boost::compressed_pair的技术)来存储无状态的分配器,避免因分配器为空而白白浪费内存。
9.7 虚函数说明符
- 作用 :指定一个非静态成员函数为虚函数,支持动态分派。该关键字只能出现在类定义内部的初始声明中。
- 虚函数调用 :即使没有编译期关于对象实际类型的静态信息,通过基类的指针或引用调用虚函数时,也会自动执行派生类中重写的最终版本。
- 抑制虚调用 :通过限定名查找 (即在函数名左侧使用作用域解析符
::,如br.Base::f();)会抑制虚函数的动态分派,直接调用指定作用域的版本。
override / final
- 重写条件 :若基类
Base声明了虚函数vf,派生类中名称、参数类型列表、cv限定符以及引用限定符相同的成员函数会自动成为虚函数并重写Base::vf(无论是否显式写virtual或override)。返回值可以不同(见下文协变返回类型)。 override说明符(C++11) :显式指定函数必须重写基类的虚函数,若未成功重写(如签名不匹配),程序会编译报错。final说明符(C++11) :显式指定虚函数为最终版本,任何尝试在更底层派生类中重写该函数的行为都会导致编译报错。- 限制 :非成员函数、静态成员函数、函数模板以及带有相关约束(
requires)的函数不能被声明为虚函数。
协变返回类型 :如果 Derived::f 重写了 Base::f,两者的返回值类型可以不完全相同,但必须满足协变要求:
- 两者必须都是类类型的指针或引用(不支持多级指针或引用)。
Base::f返回的指针/引用所指向的类,必须是Derived::f返回的指针/引用所指向的类的无歧义且可访问的基类。Derived::f的返回值类型的cv限定必须等于或小于Base::f的限定符。
虚析构函数
- 多态销毁安全 :如果基类析构函数被声明为
virtual,通过基类指针删除派生类对象时,会正确触发动态分派,先调用派生类的析构函数再调用基类的析构函数。 - 未定义行为 :若基类析构函数不是 虚函数,通过基类指针
delete派生类对象会导致未定义行为 (除非使用了 C++20 的销毁操作符operator delete)。 - 黄金法则 :涉及多态销毁时,基类的析构函数应当是
public且virtual的,或者是protected且非virtual的。
构造与析构期间的虚函数行为
- 在构造函数或析构函数(包括成员初始化列表)中直接或间接调用虚函数时,多态性会被限制在当前正在构造/销毁的类及其基类范围内。
- 此时对象更底层的派生类尚未构造或已经销毁,因此虚拟调用解析到的最终版本仅限当前构造函数所属的类层级,而不会向下分派到更派生的类。
9.71. 抽象类与纯虚函数
- 纯虚说明符 :在虚函数声明的
declarator(或virt-specifier如override/final)之后加上= 0,即为纯虚函数。 - 语法限制 :
= 0不能出现在函数定义或友元声明中。析构函数也可以被声明为纯虚函数。 - 抽象类判定 :只要一个类定义了或继承了至少一个其最终覆盖者为纯虚函数 的成员函数,该类即为抽象类。
抽象类的核心限制
- 无法实例化:不能创建抽象类的对象(但可以作为派生类对象的基类子对象存在)。
- 成员变量限制:不能声明类型为抽象类的非静态数据成员。
- 函数签名限制 :抽象类型不能用作函数的参数类型、函数的返回值类型,或显式类型转换的目标类型(在函数定义和调用点进行检查)。
- 指针与引用 :可以声明指向抽象类的指针和引用,常用于实现多态接口。
纯虚函数的定义与类外实现
- 可以有定义 :纯虚函数可以(且如果是纯虚析构函数,则必须)在类外提供定义。
- 调用方式 :派生类的成员函数可以通过限定名 (如
Base::pure_func())显式调用抽象基类的纯虚函数版本。 - 构造/析构期间的纯虚调用 :在抽象类的构造函数或析构函数中,直接或间接对纯虚函数进行虚拟调用会导致未定义行为(无论该纯虚函数是否有定义)。
9.7.2 override
- 功能 :显式指定一个非静态成员函数必须重写基类中的虚函数。
- 编译期检查 :若带有
override说明符的函数实际上并未成功重写任何基类虚函数(例如基类中不存在同名同参数的虚函数、签名不匹配、或者基类对应函数根本不是虚函数),程序会被判定为 语法错误 。
语法与位置
- 出现位置 :在成员函数声明或类内定义中,
override位于声明符之后。 - 与其他说明符的组合 :
- 若有纯虚说明符(
= 0),override必须出现在= 0之前 (例如virtual int f() override = 0;)。 - 若与
final连用,可写作override final或final override。
- 若有纯虚说明符(
关键字性质
override并不是一个严格意义上的保留关键字,而是一个具有特殊含义的标识符 。它仅在成员函数声明的特定位置被赋予语法控制意义,在代码的其他地方(如变量名、普通函数名)仍然可以正常作为普通标识符使用。
9.7.3 final
final 说明符具有两种主要用途,用于在编译期强制限制继承或虚函数重写:
- 修饰虚函数 :指定该虚函数为最终版本,任何尝试在更底层的派生类中重写该函数的行为都会导致编译报错
- 修饰类 :指定该类不能被继承 (即不能出现在其他类的基类说明列表中)。若应用于
union声明,则除了影响std::is_final的判定结果外,无实际继承限制效果(因为联合体本身就不能被继承)。
语法与出现位置
- 用于虚函数 :
- 出现在类内部成员函数的声明或定义中,紧跟在声明符之后。
- 若与
override连用,可写作final override或override final。
- 用于类定义 :
- 出现在类名(包括
class、struct、union)之后 、冒号(:,即基类子句的开始)或大括号({)之前。 - 注意:
final只能在类定义时使用,不能在单纯的类前向声明中使用。
- 出现在类名(包括
关键字性质与上下文敏感性
final同样不是严格意义上的全局保留关键字,而是一个具有特殊含义的标识符 。- 它仅在成员函数声明的特定位置或类定义头部的特定位置(紧跟类名之后、
:或{之前)被赋予语法控制意义。在其他上下文中,它可以正常用作普通变量名、函数名甚至结构体名。
9.8 访问说明符
- 访问说明符种类 :
public(公开)、protected(保护)和private(私有)。 - 类内成员访问 :
- 在
class中,默认的访问级别是private。 - 在
struct中,默认的访问级别是public。 - 在
union中,默认的访问级别是public。
- 在
- 继承方式的访问控制 :
- Public 继承 :基类的
public和protected成员在派生类中保持原有访问权限。 - Protected 继承 :基类的
public和protected成员在派生类中全部变为protected。 - Private 继承 :基类的
public和protected成员在派生类中全部变为private。 - 注意 :无论何种继承方式,基类的
private成员对派生类来说永远是不可访问的。
- Public 继承 :基类的
访问级别详细规则
- Public 成员:构成类的公开接口,在程序的任何地方均可被访问。
- Protected 成员 :构成类与其派生类之间的接口。
- 仅允许该类的成员/友元,以及派生类的成员/友元进行访问。
- 严格限制:通过基类对象(或指针/引用)访问受保护成员是不被允许的,只能通过当前派生类类型(或其子类类型)的对象进行访问。
- Private 成员:构成类的内部实现。仅允许该类自身的成员和友元访问(不同实例之间可以互相访问私有成员)。
关键机制与特殊行为
- 可见性与访问检查的分离 :成员的访问权限不影响可见性 。私有成员或私有继承的成员仍然参与名字查找和重载决议。访问检查是编译器在解释完语言构造后的最后一步 (即把
private改为public绝不会改变重载决议的结果)。 - 虚函数的访问控制 :虚函数的访问检查是在调用点 根据用于表示对象的表达式类型来进行的,而最终覆盖者自身的访问权限会被忽略 。这意味着通过具有
public虚函数的基类指针,可以成功调用在派生类中被设为private的重写函数。 - 默认参数与模板参数 :默认函数参数和默认模板参数中的名字访问检查,发生在声明点而非使用点。
- 多重继承路径:如果一个名字通过多重继承图中的多条路径可访问,则其访问权限取所有路径中权限最高(最宽松)的那条。
9.8.1 友元声明
- 功能 :友元声明出现在类定义内部,用于授权某个函数或另一个类访问该类的私有(
private)和保护(protected)成员。 - 主要授权对象 :
- 非成员函数(如运算符重载)。
- 其他类的成员函数、构造函数或析构函数。
- 整个类(被授权的类可以访问当前类的私有/保护成员,也可以继承当前类的私有/保护成员)。
- 类定义内嵌实现 :在类定义中直接定义非成员函数作为友元(如
friend void f() {})时,该函数默认具有inline属性。
核心性质
- 非传递性 :你的朋友的朋友不是你的朋友。
- 非继承性 :友元关系不能被继承。你朋友的子类不是你的朋友,你的朋友也不是你子类的朋友。
- 访问说明符无关性 :
friend声明可以出现在类的private:、protected:或public:任意区域,其访问权限和效果完全相同,不受影响。 - 名字注入与命名空间:在友元声明中首次声明的函数或类,会成为该类所在的最内层命名空间的成员,但在通常的限定/非限定名查找中不可见(仅支持通过 ADL 参数依赖查找)。
模板友元
- 函数模板与类模板 :类或类模板中可以声明函数模板或类模板为友元,此时模板的每一个特化版本都会自动成为友元。
- 类模板成员友元 :友元声明可以指定类模板的特定成员(如
friend void A<T>::f();),前提是其模板参数可从简单模板标识中推导。 - 类模板的运算符重载 :为类模板声明非成员运算符(如
operator<<)时,通常有两种做法:- 直接在类体内定义一个非模板的重载函数(会为每个
T生成一个独立的非模板友元)。 - 在类体外先进行函数模板前向声明,再在类内将对应的全特化版本声明为友元。
- 直接在类体内定义一个非模板的重载函数(会为每个
9.9 构造函数与成员初始化列表
- 特殊语法 :构造函数是用于初始化类对象的非静态成员函数,没有返回值类型,且其名字必须是当前类的注入类名
- 允许的说明符 :构造函数的声明说明符中仅允许出现
friend、inline、constexpr、consteval以及explicit。 - 常量与引用语义 :在对象构造完成之前,对象的
const和volatile语义尚未生效。
成员初始化列表
- 语法形式 :位于构造函数函数体左大括号之前,以冒号
:开始,后跟一个或多个以逗号分隔的成员初始化项。 - 强制使用场景 :
- 引用类型成员 和
const类型的成员。 - 没有默认构造函数的基类 或没有默认构造函数的类类型非静态数据成员。
- 引用类型成员 和
- 与默认成员初始化器的优先级 :如果一个非静态数据成员既有默认成员初始化器(如
int n = 42;),又出现在构造函数的初始化列表中,初始化列表中的值会覆盖并忽略默认成员初始化器。 - 引用绑定限制 :在成员 initializer 列表中,引用成员不能绑定到临时对象上。
核心初始化顺序 :初始化列表中的书写顺序不决定实际的初始化顺序,实际的初始化执行顺序严格遵循以下规则:
- 虚基类:若当前是最底层派生类,虚基类会按照深度优先、左到右的遍历顺序进行初始化。
- 直接基类 :按照在类定义中基类说明列表出现的从左到右顺序初始化。
- 非静态数据成员 :按照它们在类定义中的声明顺序依次初始化(注意:与初始化列表中的书写顺序无关)。
- 函数体执行:完成上述所有子对象初始化后,才执行构造函数的函数体大括号内部代码。
特殊构造函数类型
- 委托构造函数(C++11) :
- 如果初始化列表中仅包含当前类名(如
Foo(int y) : Foo('a', y) {}),则该构造函数被称为委托构造函数。 - 目标构造函数会先被重载决议选中并执行,随后控制权返回并执行委托构造函数自身的函数体。委托构造函数不能递归。
- 如果初始化列表中仅包含当前类名(如
- 继承构造函数 :
- 通过
using Base::Base;可以将基类的构造函数直接引入派生类中。
- 通过
9.9.1 默认构造函数
- 定义:可以无参数调用的构造函数(包括没有参数的构造函数,或所有参数均带有默认值的构造函数)。
- 显式预置与删除(C++11) :
= default:显式要求编译器生成默认构造函数。= delete:显式将默认构造函数定义为已删除(禁用默认构造)。
隐式声明与隐式定义
- 隐式声明 :如果类中没有任何用户声明的构造函数或构造函数模板 ,编译器会自动隐式声明一个
inline public的默认构造函数。 - 隐式定义:当默认构造函数被 ODR 使用(ODR-use)或在常量求值中需要时,编译器会隐式定义它。其效果等同于具有空函数体和空初始化列表的用户定义构造函数(依次调用基类和非静态成员的默认构造函数)。
弃置的默认构造函数 :在以下情况下,隐式声明或显式 = default 的默认构造函数会被自动定义为 deleted(已删除)
- 类
T含有引用类型的非静态数据成员,且该成员没有默认成员初始化器。 - 类
T含有const修饰的非静态数据成员,且该成员没有默认成员初始化器(且没有提供默认构造函数的有效途径)。 - 类
T的某个基类或非静态成员的析构函数被删除或无法访问。 - 对子对象调用默认构造函数时,无法通过重载决议找到可用的默认构造函数。
平凡默认构造函数
- 定义:不执行任何实际操作的默认构造函数。
- 判定条件 :
- 构造函数是非用户提供的 (即隐式声明或显式
= default)。 - 类
T没有虚成员函数和虚基类。 - 类
T没有带默认初始化器的非静态成员(C++11)。 - 所有直接基类以及非静态类类型成员(或其数组)都拥有平凡的默认构造函数。
- 构造函数是非用户提供的 (即隐式声明或显式
- 作用:拥有平凡默认构造函数的类属于隐式生存期类型和可 trivially copyable 的类型,常与 C 兼容(POD 类型)。
9.9.2 析构函数
- 作用:当对象的生命周期结束时被调用,用于释放对象在生命周期内获取的资源。
- 语法形式 :名字为波浪号
~后跟类名(注入类名),没有返回值类型,参数列表必须为空或void。 - 允许的说明符 :
constexpr(C++11起)、friend、inline和virtual。 - 潜在析构函数(C++20) :类可以声明多个带有约束的 prospective destructors(例如配合概念或
requires子句),编译器会通过重载决议在类定义结束时选择其中一个作为该类的实际析构函数。
生命周期结束与隐式调用:析构函数会在对象的生命周期结束时被隐式调用,包括:
- 程序终止(静态存储期对象)。
- 线程退出(线程局部存储期对象)。
- 作用域结束(自动存储期对象及延长生命周期的临时对象)。
delete表达式(动态存储期对象)。- 完整表达式结束(无名临时对象)。
- 栈展开(异常导致未捕获时,离开块的自动对象)。
- 也可以显式调用(如
obj.~Foo();)。
销毁顺序 :对象销毁的实际执行顺序与其构造顺序严格相反:
- 首先执行析构函数体内的代码,并销毁在函数体内创建的自动对象。
- 按照声明的逆序 ,调用所有非静态非变体数据成员的析构函数。
- 按照基类说明列表的逆序 ,调用所有直接非虚拟基类的析构函数。
- 如果当前对象是最底层派生类,最后调用所有虚基类的析构函数。
关键特性与高级规则
- 虚析构函数 :通过基类指针删除派生类对象时,如果基类析构函数不是
virtual,会导致未定义行为 。多态基类的析构函数应当是public且virtual的(或者protected且非virtual的)。 - 纯虚析构函数 :析构函数可以被声明为纯虚函数(如
virtual ~AbstractBase() = 0;)。注意:纯虚析构函数必须提供函数体定义,因为派生类对象销毁时一定会向上调用基类的析构函数。 - 平凡析构函数 :不执行任何实际操作(非用户提供、非虚、所有基类和非静态类类型成员都拥有平凡析构函数)。拥有平凡析构函数的对象销毁时无需执行
delete表达式,直接回收内存即可。 - 析构函数与异常 :析构函数默认是
noexcept的。如果在栈展开期间有异常从析构函数中抛出且未被捕获,程序将直接调用std::terminate终止。因此,一般强烈建议不要让析构函数抛出异常。
9.9.3 拷贝构造函数
- 定义:可以接受一个同类对象作为参数(且不修改该参数)的构造函数。
- 参数格式 :
- 给定类名为
T,其第一个参数必须是T&、const T&、volatile T&或const volatile T&。 - 若存在其他参数,则所有其他参数都必须带有默认实参(否则无法作为拷贝构造函数)。
- 给定类名为
- 错误示例 :
X(X other);是非法的,因为按值传递参数会无限递归调用拷贝构造函数本身。
调用时机:只要对象通过同类型的另一个对象进行初始化(除非发生拷贝消除),就会调用拷贝构造函数:
- 对象初始化 :
T a = b;或T a(b);(其中b的类型为T)。 - 函数传参 :以传值方式传递参数(
f(a);,其中f的形参为T类型)。 - 函数返回值 :当函数返回一个
T类型对象(且该对象没有移动构造函数时)。
隐式声明与重载形式
- 隐式声明 :如果类中没有提供用户定义的拷贝构造函数,编译器会自动隐式声明一个非显式的(non-explicit)、
inline public的拷贝构造函数。 - 形参类型的选择 :
- 如果所有基类和非静态类类型成员的拷贝构造函数都接受
const B&或const M&,则隐式声明的拷贝构造函数形式为T::T(const T&)。 - 否则,其形式为
T::T(T&)(该形式无法绑定到volatile左值参数)。
- 如果所有基类和非静态类类型成员的拷贝构造函数都接受
- 多重重载 :一个类可以同时拥有多个拷贝构造函数(例如同时存在
T(const T&)和T(T&))。
弃置与平凡拷贝构造函数
- 被删除的拷贝构造函数 :
- 若类
T含有右值引用类型的非静态数据成员,或者其基类/成员的拷贝构造函数不可用/被删除,则隐式声明或= default的拷贝构造函数会被自动定义为deleted。 - 如果类
T显式声明了移动构造函数 或移动赋值运算符 ,则隐式声明的拷贝构造函数会被自动定义为deleted。
- 若类
- 平凡拷贝构造函数 :
- 当拷贝构造函数是非用户提供的(隐式定义或
= default),且类没有虚函数、虚基类,所有基类和非静态类类型成员的拷贝构造函数都是平凡的时,该拷贝构造函数为平凡的。 - 平凡的拷贝构造函数本质上执行的是简单的内存按成员复制(类似于
std::memmove),不执行任何额外的用户逻辑。
- 当拷贝构造函数是非用户提供的(隐式定义或
9.9.4 拷贝赋值运算符
- 定义 :一个名为
operator=的非模板、非静态成员函数,它接受一个同类对象作为参数,并将其内容复制到当前对象中,同时不修改原对象。 - 返回值类型 :推荐返回
T&(当前类的引用),以便支持连续赋值表达式(如a = b = c;)。 - 参数形式 :给定类名为
T,其唯一的参数类型通常为T&、const T&、volatile T&或const volatile T&(也可以按值传参,如X operator=(X other);)。
隐式声明与生成规则
- 隐式声明 :如果类中没有提供用户定义的拷贝赋值运算符,编译器会自动隐式声明一个
inline public的成员。 - 形参选择 :
- 若所有直接基类和非静态类类型成员的拷贝赋值运算符都接受
const B&或const M&,则隐式声明的形式为T& T::operator=(const T&)。 - 否则,其形式为
T& T::operator=(T&)(该形式无法绑定到volatile左值参数)。
- 若所有直接基类和非静态类类型成员的拷贝赋值运算符都接受
- 名字隐藏规则 :由于拷贝赋值运算符总是会被隐式声明,基类的赋值运算符会被派生类的同名运算符隐藏。
弃置的拷贝赋值运算符 :在以下情况下,隐式声明或显式 = default 的拷贝赋值运算符会被自动定义为 deleted(已删除) :
- 类
T含有具有 const 修饰的非类类型非静态数据成员(或其多维数组)。 - 类
T含有引用类型的非静态数据成员。 - 类
T的某个基类或非静态成员的拷贝赋值运算符不可用或被重载决议删除。 - 如果类
T显式声明了移动构造函数 或移动赋值运算符 ,则隐式声明的拷贝赋值运算符也会被自动定义为deleted。
平凡拷贝赋值运算符
- 定义 :执行类似
std::memmove的简单对象表示形式复制的拷贝赋值运算符。 - 判定条件 :
- 该运算符是非用户提供的(隐式定义或
= default)。 - 类
T没有虚成员函数和虚基类。 - 所有直接基类以及非静态类类型成员的拷贝赋值运算符都是平凡的。
- 该运算符是非用户提供的(隐式定义或
- 与移动的关系 :如果一个类同时提供了拷贝赋值和移动赋值运算符,重载决议会根据实参是右值还是左值自动选择移动或拷贝;若未提供移动赋值,拷贝赋值运算符(通常接受
const T&)可以作为移动操作不可用时的安全后备选项
9.9.5 移动构造函数
- 定义:一个接受同类对象的右值引用作为参数、能够转移其资源并可能修改原对象的构造函数。
- 参数格式 :
- 给定类名为
T,其第一个参数必须是T&&、const T&&、volatile T&&或const volatile T&&。 - 若存在其他参数,则所有其他参数都必须带有默认实参。
- 给定类名为
- 核心意图:通常用于转移资源(如接管堆内存指针、文件句柄等)而不是深拷贝,从而提高性能。被移动走的对象通常会处于合法但未指定的状态。
调用时机与重载决议
- 典型调用场景 :当对象通过同类型的右值(xvalue 或 prvalue)进行初始化时(例如
T a = std::move(b);或函数返回值为T且有移动构造函数)。 - 与拷贝构造函数的配合 :
- 如果同时提供了拷贝和移动构造函数,重载决议会根据实参是右值 还是左值自动选择移动或拷贝构造函数。
- 如果未提供 移动构造函数,但提供了接受
const T&的拷贝构造函数,右值也会安全地降级去匹配拷贝构造函数。
隐式声明与生成条件
- 隐式声明条件 :只有当类中没有 用户声明的拷贝构造函数、拷贝赋值运算符、移动赋值运算符以及析构函数时,编译器才会自动隐式声明一个
T::T(T&&)。 - 生成效果:隐式定义的移动构造函数会对所有直接基类和非静态数据成员执行成员级的移动操作(使用右值引用进行直接初始化)。
平凡移动构造函数
- 定义 :执行与平凡拷贝构造函数相同的操作(即按字节复制对象表示,类似
std::memmove),不执行实际用户逻辑的移动构造函数。 - 判定条件 :
- 该移动构造函数是非用户提供的(隐式定义或
= default)。 - 类
T没有虚成员函数和虚基类。 - 所有直接基类以及非静态类类型成员的移动构造函数都是平凡的。
- 该移动构造函数是非用户提供的(隐式定义或
9.9.6 移动赋值运算符
- 定义 :一个名为
operator=的非模板、非静态成员函数,它接受一个同类对象的右值引用作为参数,用于转移资源而不是进行深拷贝,并可能修改原对象。 - 返回值类型 :通常返回
T&(当前类的引用),以支持连续赋值。 - 参数形式 :给定类名为
T,其唯一的参数类型通常为T&&、const T&&、volatile T&&或const volatile T&&。 - 核心意图:接管右值对象持有的资源(如动态内存、文件句柄等),并将被移动的对象留在合法但未指定的状态。
隐式声明与生成条件
- 隐式声明条件 :只有当类中没有 用户声明的拷贝构造函数、移动构造函数、拷贝赋值运算符以及析构函数时,编译器才会自动隐式声明一个
T& T::operator=(T&&)。 - 生成效果:隐式定义的移动赋值运算符会对所有直接基类和非静态数据成员按声明顺序执行逐成员的移动赋值操作。
弃置的移动赋值运算符 :在以下情况下,隐式声明或显式 = default 的移动赋值运算符会被自动定义为 deleted(已删除) :
- 类
T含有带有const修饰的非类类型非静态数据成员(或其多维数组)。 - 类
T含有引用类型的非静态数据成员。 - 类
T的某个基类或非静态成员的移动赋值运算符不可用或被重载决议删除。
平凡移动赋值运算符
- 定义 :执行与平凡拷贝赋值运算符相同的操作(即按字节复制对象表示,类似
std::memmove),不执行实际用户逻辑的移动赋值运算符。 - 判定条件 :
- 该运算符是非用户提供的(隐式定义或
= default)。 - 类
T没有虚成员函数和虚基类。 - 所有直接基类以及非静态类类型成员的移动赋值运算符都是平凡的。
- 该运算符是非用户提供的(隐式定义或
9.9.7 转换构造函数
- 核心条件 :
- 未显式声明 为
explicit的构造函数。 - 可以使用单个参数进行调用的构造函数(包括仅有一个参数,或虽然有多个参数但其余参数均带有默认实参的构造函数)。
- 未显式声明 为
- 核心作用 :指定了一条从参数类型到当前类类型的隐式转换路径。
- 特殊关联 :隐式声明的以及用户定义的非 explicit 拷贝构造函数和移动构造函数,本质上也属于转换构造函数。
初始化与隐式转换中的行为
- 隐式转换支持 :与只能参与显式初始化(如
direct-initialization或static_cast)的explicit构造函数不同,转换构造函数在拷贝初始化以及用户定义转换序列中会被编译器自动考虑。 - 常见应用场景:当函数需要某个类类型参数,而实参是其可转换的兼容类型时,编译器会隐式调用转换构造函数完成类型转换并传递。
与 explicit 的对比与防护
- 隐式转换风险:未加限制的转换构造函数可能导致意料之外的隐式类型转换(例如通过单参数构造函数隐式创建临时对象),从而引发难以察觉的 Bug 或性能损耗。
- 最佳实践 :除非明确希望允许从某种类型到当前类类型的无缝隐式转换,否则通常建议为单参数构造函数添加
explicit修饰符,强制要求进行显式初始化或转型。
9.9.8 explicit
- 作用 :用于修饰构造函数 、用户定义转换函数 (C++11起)或推导指引(C++17起),指定其为显式(explicit)的。
- 核心限制 :被
explicit修饰的构造函数或转换函数不能 用于隐式转换和拷贝初始化,只能用于直接初始化及显式转型(如static_cast)。
语法形式与条件显式
- 无参形式 :
explicit------ 无条件地将函数声明为显式。 - 条件形式(C++20起) :
explicit (expression)------ 其中的expression必须是一个可上下文转换为bool类型的常量表达式。当且仅当该常量表达式求值为true时,该函数才会被视为显式。
应用场景
- 构造函数:防止单参数构造函数(或多参数但其余参数有默认值的构造函数)被编译器用于隐式类型转换。
- 用户定义转换函数(C++11起) :例如
explicit operator bool() const;,可以防止类对象在布尔语境之外发生意外的隐式整型或布尔转换(常用于智能指针等类型,如if (ptr)合法,但int x = ptr;非法)。
10. 模板
- 定义:C++ 的元编程核心实体,用于定义一族类、函数、变量、别名或概念。
- 种类 :
- 类模板
- 函数模板
- 别名模板(C++11起)
- 变量模板(C++14起)
- 概念(C++20起)
- 参数类型 :模板由一个或多个模板参数进行参数化,分为类型模板参数 、非类型模板参数(NTTP)以及模板模板参数。
实例化与特化
- 实例化 :当模板在需要完整对象类型或函数定义的语境中被引用时,编译器才会根据提供的模板实参生成并编译实际的代码。类模板的成员函数只有在被实际调用时才会被实例化。
- 特化 :
- 全特化 :为所有模板参数指定具体类型,类、变量和函数模板均支持。
- 偏特化 :为部分模板参数指定具体类型或约束,仅支持类模板和变量模板(C++14起)。
模板标识符与有效性检查
- 模板标识符 :通过
template-name<argument-list>的形式命名一个特化版本。 - 有效性要求:在编译期会进行严格检查,包括实参数量不能超过参数数量(除非有参数包)、非默认参数必须有实参提供、实参必须与形参匹配,且代入过程不能出错。若为非函数模板,还会检查 C++20 引入的关联约束是否满足。
**模版化实体
- 定义 :任何在模板定义内部定义的实体(例如类模板内部的普通成员函数、局部类、局部变量或 Lambda 表达式的闭包类型)都被称为模版化实体(或 temploid)。
- 头文件包含原则 :由于编译器在隐式实例化点必须能看到完整的模板定义,因此模板库(如大部分 Boost 库)通常采用 Header-only(纯头文件)实现。
10.1 模板参数与模板实参
模板可以由一个或多个模板参数进行参数化(通过 template <parameter-list> 指定),主要分为以下三大类:
- 类型模板参数 :
- 使用关键字
typename或class声明(两者在参数声明中完全等价)。 - C++20 起支持通过概念进行类型约束(如
My_concept T)。
- 使用关键字
- 非类型模板参数(NTTP) :
- 表示一个常量值,其类型必须是结构化类型 (如整型、指针、左值引用、枚举、
std::nullptr_t、浮点型、无捕获的 Lambda 闭包类型,以及满足特定条件的字面量类类型等)。 - C++17 起支持使用
auto进行类型推导(如template<auto n>)。
- 表示一个常量值,其类型必须是结构化类型 (如整型、指针、左值引用、枚举、
- 模板模板参数 :
- 参数本身是一个类模板或别名模板(如
template<typename> class C)。 - C++11 起支持模板参数包。
- 参数本身是一个类模板或别名模板(如
模板实参的匹配与提供:为了实例化模板,每个模板参数都必须由相应的模板实参替换。实参的来源包括:显式提供、从初始化器或上下文中推导(C++17 起对类模板适用),或使用默认实参。
- 二义性解析规则 :如果一个实参既可以被解释为
type-id(类型标识)又可以被解释为表达式,它总是被优先解释为类型(即使对应的模板参数是非类型模板参数)。 - 非类型模板实参 :
- 必须是常量求值表达式
- 传入的实参必须严格满足对应参数类型的语义约束(例如:指针/引用实参必须具有静态存储期和链接性,不能是临时对象、字符串字面量或无链接的局部变量地址------注:C++20 对类类型 NTTP 引入了模板参数对象)。
- 模板模板实参匹配(P0522 规则) :
- 从 C++17 起(多数现代编译器已支持),模板模板参数可以比实参拥有更宽松或带有默认值的参数列表,只要模板模板参数至少与实参一样特化即可。
默认模板实参
- 语法位置 :在参数列表中通过
=指定。 - 位置限制:在类模板、变量模板或别名模板中,一旦某个模板参数指定了默认实参,其后所有的参数也必须有默认实参(最后一个参数允许是参数包)。函数模板则没有此限制。
- 作用域与合并:多次声明中的默认模板实参会被合并(类似于默认函数参数),但同一作用域内的同一个参数不能被重复赋予默认实参。
- 实例化时机:默认模板实参只有在实际被需要(用到)时才会隐式实例化。
10.2 类模板
- 定义 :类模板定义了一族类,其本身并不是一个类型、对象或实体,只有通过提供模板实参将其实例化后,编译器才会生成实际的类代码。
- 语法形式 :通过
template <parameter-list>前缀后跟一个类声明(class、struct或union)来定义。C++20 起支持通过requires子句为类模板直接附加约束条件。
实例化机制
- 隐式实例化 :当代码中引用了模板的某个特化版本且上下文要求该类型为完整类型时触发(例如定义对象
Z<int> a;或通过指针调用成员函数p->f();)。- 成员按需实例化:类模板的成员函数和静态成员只有在被实际使用时才会进行实例化,若未被使用则不会实例化,因此也不强制要求给出定义。
- 显式实例化 :
- 显式实例化定义 :形式为
template class-key template-name<argument-list>;,用于强制编译器在当前编译单元中生成特定特化版本的代码。 - 显式实例化声明(C++11起) :形式为
extern template class-key template-name<argument-list>;,用于跳过隐式实例化,指示编译器在其他编译单元中寻找已有的显式实例化定义,从而有效减少重复编译开销、缩短编译时间。
- 显式实例化定义 :形式为
可见性与完整性要求
- 不完整类型 :如果类模板仅被声明但未被定义,对其进行实例化会产生不完整类型错误(例如
X<char> ch;编译报错)。 - 命名空间限制:显式实例化必须出现在模板所属的封闭命名空间中,或者使用限定名进行指定。
10.3 函数模板
-
定义:函数模板定义了一族函数。其实例化通过模板参数推导或显式指定来实现。
-
语法形式 :通过
template <parameter-list>前缀后跟一个函数声明。C++20 起支持通过requires子句或概念约束来限制模板参数。 -
简写函数模板(C++20 起) :当函数参数列表或函数模板声明中出现占位符(
auto或Concept auto)时,该声明隐式成为一个函数模板,每个占位符在模板参数列表中对应追加一个生成的虚拟模板参数。例如:arduinovoid f1(auto); // 等价于 template<class T> void f1(T);
实例化机制
- 隐式实例化:当代码中引用了函数模板且上下文要求函数定义必须存在、或者取其地址时,若未被显式实例化,则会触发隐式实例化。
- 显式实例化 :
- 显式实例化定义:强制编译器在当前编译单元中生成特定模板实参对应的函数实体(不带或带模板参数推导)。
- 显式实例化声明(C++11 起) :形式形如
extern template return-type name<argument-list>(parameter-list);,用于抑制当前编译单元中的隐式实例化,指示编译器复用其他编译单元中的定义,从而加快编译速度。
模板参数推导
- 当调用函数模板或取其地址时,若未显式指定全部模板参数,编译器会从传递的函数实参类型中自动推导缺失的模板参数。
- SFINAE 原则:如果在模板实参替换过程中发生替换失败,该函数模板会被静默地从重载集中移除,而不会导致编译错误。这一特性被广泛用于元编程。
函数模板重载、等价性与偏特化
- 等价性 :两个函数模板如果位于同一作用域、具有相同的名称、等价的模板参数列表、等价的返回类型与参数列表表达式以及等价的
requires子句,则它们被认为是等价的(即同一模板的重复声明)。如果属于"功能上等价但语法不完全等价",程序将是不合法的(无诊断要求)。 - 部分排序 :当同一个函数模板 specialization 或重载匹配多个候选模板时,编译器会进行偏特化排序,选择"更特化(即接受的类型范围更窄)"的模板。
- 重载决议规则 :在重载决议中,只有非模板函数和主模板参与竞争。显式特化版本不直接参与重载决议,而是先由重载决议选出最优的主模板后,再决定是否调用其显式特化版本。
10.4 变量模板
- 定义:变量模板定义了一族变量或静态数据成员(自 C++14 起引入)。
- 语法形式 :通过
template <parameter-list>前缀后跟一个变量声明。C++20 起支持通过requires子句附加约束条件。 - 应用场景 :可用于定义命名空间作用域的通用常量或配置(如数学常量
pi),也可在类作用域内声明为静态数据成员模板。
实例化机制
- 隐式实例化 :除非被显式特化或显式实例化,当变量模板的某个特化版本在需要变量定义存在的上下文中被引用,或者其定义的存在性会影响程序语义(例如在表达式中被需要用于常量求值,即使该求值本身未被执行)时,会触发隐式实例化。
- 静态数据成员模板:在类作用域中声明的静态数据成员模板通常需要在类外提供定义(类似于普通的静态数据成员模板或类模板的静态成员)。
注意事项与限制
- 历史演进 :在 C++14 引入变量 templates 之前,参数化变量通常通过类模板的静态数据成员或返回期望值的
constexpr函数模板来实现。 - 模板模板参数限制 :变量模板不能用作模板模板参数
10.5 成员模板
-
定义:类、结构体或联合体(非局部类)的成员说明中可以包含模板声明,涵盖成员函数模板、嵌套类模板、成员变量模板(静态数据成员模板,C++14 起)以及转换函数模板。
-
类外定义 :若外层类本身也是一个类模板,则类外定义成员模板时需要两组模板参数 ------第一组对应外层类模板,第二组对应成员模板本身。
arduinotemplate<typename T1> struct string { template<typename T2> int compare(const T2&); }; // 类外定义:先写外层类模板参数,再写成员模板参数 template<typename T1> template<typename T2> int string<T1>::compare(const T2& s) { /* ... */ }
成员函数模板
- 构造函数与析构函数 :析构函数和拷贝构造函数不能是模板。若声明了一个可以实例化为拷贝构造函数签名形式的模板构造函数,编译器仍会优先使用隐式声明的拷贝构造函数。
- 虚函数限制 :成员函数模板不能是虚函数 ,且派生类中的成员函数模板不能重写基类的虚函数。
- 与非模板成员函数的共存 :当同名非模板成员函数与模板成员函数同时存在时,若实参匹配非模板函数的签名,则默认优先调用非模板函数,除非显式提供了模板参数列表(如
ac.f<>(1))。 - 类外定义的等价性 :类外定义的成员模板必须与类内的声明严格等价,否则会被视为独立的重载函数而非同一模板的定义。
转换函数模板
- 用户定义转换 :用户定义的类型转换函数可以是一个模板(例如
template<typename T> operator T*();)。 - 重载与查找:在重载决议期间,转换函数模板的显式特化不会通过普通名称查找找到,而是由所有可见的转换函数模板通过模板参数 deduction 生成候选。
- 返回值限制 :自 C++14 起,用户定义的转换函数模板不能 使用显式占位符作为 deduced return type(如
template<class T> operator auto()会导致编译错误)。
10.6 模板实参推导
- 核心目标 :在实例化函数模板时,若未显式指定全部模板参数,编译器会尝试从传递的函数实参(Argument, A)推导缺失的模板参数类型、模板或非类型值,以匹配函数参数(Parameter, P)。
- 触发场景 :
- 函数模板调用。
- 取函数模板的地址(或初始化对应的函数指针)。
- 转换函数模板的选择、重载决议、显式特化/实例化匹配等。
- 自 C++17 起的类模板实参推导(CTAD)以及
auto类型推导。
推导前的调整与前置步骤 :在将 P 与 A 进行逐对比较前,编译器会先对两者的类型进行以下预处理调整:
- 非引用类型的调整 :若 P 不是引用类型:
- 若 A 是数组类型,发生数组到指针转换
- 若 A is 函数类型,发生函数到指针转换
- 忽略 A 的顶级 cv-qualifiers。
- P 的 cv-qualifier :忽略 P 的顶级 cv-qualifiers。
- 引用类型处理 :若 P 是引用类型,则使用其引用的底层类型进行推导。
- 转发引用 :若 P 是对 cv-unqualified 模板形参的右值引用(形如
T&&),且对应的实参 A 是左值,则 A 的类型会被调整为左值引用(即T&用于推导,这是std::forward的底层依据)。
推导失败时的备选匹配 :若常规的 P/A 直接推导失败,编译器还会额外考虑以下替代规则:
- 引用变体 :若 P 是引用类型,推导出的 A(引用指向的类型)可以比变换后的 A 带有更多的 cv-qualifiers。
- 指针/成员指针转换 :变换后的 A 可以是通过限定修饰转换(函数指针转换转为推导出的 A 的指针或成员指针类型。
- 派生类到基类 :若 P 是形如
B<T>的类模板特化或其指针/引用,则变换后的 A 可以是推导出的 A 的派生类(或指向派生类的指针)。
非推导上下文 :在某些特定语法位置中,出现在 P 中的类型、模板或非类型值不参与模板实参推导,它们只能依赖在其他地方已被成功推导或显式指定的参数。若某个模板参数仅出现在非推导上下文中且未被显式指定,则推导失败。常见的非推导上下文包括:
- 受限标识符的左侧 :嵌套名修饰符(即作用域解析运算符
::左侧的内容,如A<T>::B中的T)。 decltype表达式中的表达式。- 引用了模板参数的非类型模板参数或数组边界 (如
2 * N)。 - 带有默认实参且正在被调用的函数形参所依赖的模板参数。
- 数组类型的主数组边界 (如
int a[i][20]中的i,但如果是int (&a)[i][20]则可以推导)。
10.7 类模板实参推导
- 定义 :自 C++17 起,当声明变量、使用
new表达式或函数式强制类型转换时,如果类模板名称后没有显式给出模板参数列表,编译器会根据初始化器的类型自动推导模板参数。 - 主要触发语境 :
- 变量声明与初始化 :如
std::pair p(2, 4.5);。 - New 表达式 :如
auto y = new A{1, 2};。 - 函数式强制类型转换 :如
auto lck = std::lock_guard(mtx);。 - 非类型模板参数(C++20 起) :如
template<X x> struct Y {};。
- 变量声明与初始化 :如
推导候选的生成机制:编译器为 CTAD 构造了一组"虚拟函数模板"参与重载决议,来源主要包括:
- 隐式生成的推导指引 :
- 根据类模板中的每个构造函数或构造函数模板生成对应的虚拟函数模板。
- 复制推导候选 :针对形如
C(C)的假想构造函数生成,用于实现"优先复制而非包装"的行为(如std::vector v2{v1};优先推导为同类型而非嵌套向量)。
- 聚合推导候选 :
- 当类满足聚合类型条件、无用户定义推导指南且由非空初始化列表初始化时,编译器会自动从聚合元素类型生成推导指引。
- 用户定义的推导指南 :
-
程序员可以通过显式语法手动编写推导规则(例如将迭代器范围映射到容器的
value_type):rubytemplate<class Iter> container(Iter b, Iter e) -> container<typename std::iterator_traits<Iter>::value_type>;
-
别名模板的 CTAD(C++20 起)
- 机制 :当使用没有参数列表的别名模板进行初始化时(例如
unique_ptr_nonarray),编译器会转换并复用底层主类模板的推导指引。 - 结果过滤 :通过生成的
requires子句约束,自动过滤掉那些推导结果不匹配或违背别名约束的候选指引。
重载决议与优先级规则:当多个推导指引同时匹配时,编译器的选择偏好顺序为:
- 偏特化优先:越特化的版本优先级越高(因此复制推导候选通常优先于通用的包装构造)。
- 用户定义推导指引 优于 隐式生成的构造函数指南。
- 复制推导候选 优于 其他隐式生成的构造函数指南。
- 非模板构造函数生成的指南 优于 构造函数模板生成的指南。
10.8 显式(全)特化
-
定义:显式特化(通常也称为全特化)允许为特定的模板实参组合自定义模板代码,从而覆盖主模板的默认实现。
-
语法形式 :以
template <>前缀开头,后跟针对具体实参的声明。arduinotemplate <> // 显式特化针对 T = void struct is_void<void> : std::true_type {}; -
可特化的实体:函数模板、类模板、变量模板(C++14 起)、类模板的成员函数、静态数据成员、成员类、成员枚举、成员类模板、成员函数模板以及成员变量模板。
声明与定义规则
- 作用域与先后顺序 :显式特化可以定义在与其主模板不同的作用域中(例如在命名空间外对嵌套成员模板进行类外特化),但它必须在主模板声明之后,且必须在任何会导致该特化发生隐式实例化的使用之前声明。
- 内联与修饰符独立性 :函数或变量模板的显式特化是否为
inline、constexpr、constinit或consteval,完全由显式特化自身的声明决定,与主模板的修饰符无关。同样,主模板上的属性也不会自动继承到显式特化上。
函数模板特化的特殊规则
- 模板实参省略 :在特化函数模板时,如果可以通过函数实参推导出模板实参,则尖括号中的模板实参可以省略(例如
template<> void sort(Array<int>&);)。 - 非特化重载 :若函数具有相同的名称和相同的参数列表,但没有
template<>前缀,则它是一个普通的重载函数,而不是特化版本。 - 默认实参限制 :当类被隐式实例化时,函数模板、成员函数模板以及类模板成员函数的显式特化中不能指定默认函数参数。
- 友元限制:显式特化不能作为友元声明
类模板成员的特化
- 类外成员定义 :当在类外定义一个已被显式特化的类模板 的普通成员时,不需要使用
template<>前缀。但如果是特化一个嵌套的成员类模板或成员函数模板,则需要对应层级的template<>。 - 嵌套模板特化 :当成员或成员模板被多层外层类模板包裹时,每一个被显式特化的外层类模板层级都需要对应一个
template<>。未特化的层级则需要保留其模板参数列表(template<arguments>)。
10.9 部分特化
- 定义 :部分特化允许为类模板或变量模板(C++14 起)的某一类特定参数组合自定义实现,而不需要像全特化那样指定所有的模板参数。
- 限制 :函数模板不支持部分特化(如果需要类似效果,可以通过函数重载来实现)。
实参列表的语法约束
- 必须更具特化性:实参列表不能与主模板完全相同,且特化版本必须比主模板更特化(即接受更窄的类型范围)。
- 不允许默认参数:在部分特化的实参列表中不能指定默认模板参数。
- 参数包位置:若实参列表中存在参数包,它必须位于参数列表的最后。
- 非类型参数限制:非类型模板参数表达式中引用的模板参数,必须能够从其他地方被推导出来(不能出现在非推导上下文中)。
名称查找与偏序
- 名称查找 :部分特化不会 单独通过名称查找被直接找到。编译器总是先通过名称查找到主模板,然后才考虑该主模板底下的所有部分特化版本。
- 偏序规则 :当实例化一个模板时,若有多个部分特化版本同时匹配:
- 若只有一个匹配或存在唯一的最特化版本(即它接受的类型是其他匹配版本的子集),则选择该版本。
- 若有多个匹配但无法区分谁更特化(即产生歧义),则会导致编译错误。
- 若没有任何部分特化匹配,则回退并使用主模板。
部分特化的成员
- 独立性 :部分特化的成员与主模板的成员在逻辑上是各自独立的。如果在部分特化中声明了某个成员但未提供定义,即使主模板中有该成员的定义,直接调用也会导致链接或编译错误(如示例中的
a2.f()报错)。 - 全特化成员:可以对部分特化的某个成员进行显式(全)特化,其语法与主模板成员的显式特化相同。
10.10 模板参数包与包扩展
- 定义:包是 C++ 中能够表示零个或多个实体(类型、非类型值、模板、函数实参或捕获变量等)的语言构造。
- 主要分类 :
- 模板参数包 :接受零个或多个模板实参(类型、非类型或模板)。
- 函数参数包 :接受零个或多个函数实参。
- Lambda 初始化捕获包(C++20 起) :为初始化器中的每个包扩展元素引入一个捕获。
- 结构化绑定包(C++26 起) :在结构化绑定声明中引入零个或多个绑定。
位置约束与声明规则
-
类模板中的位置 :在主类模板的参数列表中,模板参数包必须是最后一个参数。
-
函数模板中的位置 :函数模板的参数包可以不在最后,但前提是其后面的参数能够从函数实参中被成功推导,或者带有默认实参。
arduinotemplate<typename U, typename... Ts> void valid(U, Ts...); // OK: U 可被推导
包扩展机制
- 语法与原理 :由一个包含至少一个未展开包的模式后面跟一个省略号(
...)组成。编译器会将其展开为零个或多个由逗号(或空格,如alignas)分隔的实例化列表。 - 多包并行扩展 :如果同一个模式中出现了多个包,它们必须具有相同的长度并被同时展开。
- 嵌套包扩展:当包扩展嵌套时,内层的包由最内层的扩展处理,外层的包则由外层扩展处理。
常见的展开语境: 包扩展可以出现在 C++ 的多种语法语境中,包括但不限于:
- 函数实参列表 :如
f(args...)或f(&args...)。 - 括号与大括号初始化器 :如
Class c(&args...)或int res[] = {1, args..., 2}。 - 模板参数列表 :如
container<A, B, C...>。 - 基类说明符与成员初始化列表 :如
class X : public Mixins... { X(const Mixins&... m) : Mixins(m)... {} }。 - Lambda 捕获子句 :如
[&, args...] { return g(args...); }。 - C++17 折叠表达式 与 C++26 包索引 。
10.10.1 sizeof...
- 定义 :
sizeof...是一个编译期运算符,用于查询并返回一个包中所包含的元素数量。 - 返回值 :返回一个类型为
std::size_t的编译期常量。 - 适用对象:可用于模板参数包、函数参数包等各种类型的包。
核心特性
- 编译期求值 :结果在编译期即可确定,常用于常量表达式、静态断言(
static_assert)以及模板元编程中的条件分支或长度检查。 - 不求值表达式 :与传统的
sizeof类似,sizeof...内部的操作数是一个不求值表达式,因此即使包中包含会产生副作用的表达式或函数调用,它们在求sizeof...时也不会被实际执行。
代码示例
arduino
template<class... Types>
struct count {
static const std::size_t value = sizeof...(Types); // 查询模板参数包的元素个数
};
template<class... Args>
void print_size(Args... args) {
constexpr std::size_t n = sizeof...(args); // 查询函数参数包的元素个数
static_assert(n >= 0, "Size must be non-negative");
}
10.11 折叠表达式
-
定义:自 C++17 起引入的折叠表达式允许直接对一个包使用二元运算符进行归约计算,而无需再编写繁琐的递归模板函数。
-
四种语法形式:
- 一元右折叠 :
( pack op ... )→(E1 op (... op (EN-1 op EN))) - 一元左折叠 :
( ... op pack )→(((E1 op E2) op ...) op EN) - 二元右折叠 :
( pack op ... op init )→(E1 op (... op (EN-1 op (EN op I)))) - 二元左折叠 :
( init op ... op pack )→((((I op E1) op E2) op ...) op EN)
- 一元右折叠 :
运算符支持与限制
- 支持的运算符 :总共支持 32 个二元运算符,包括算术、位操作、赋值、比较、逻辑及成员指针运算符(如
+,-,*,/,%,^,&,|,=,<,>,<<,>>,+=,-=,*=,/=,%=,^=,&=,|=,<<=,>>=,==,!=,<=,>=,&&,||,,,.*,->*)。 - 二元折叠要求 :在二元折叠中,左右两侧的
op必须完全相同。
空包处理规则 : 当一元折叠作用于一个长度为零的空包时,为了保证代码的良构性,语言标准仅允许以下三种运算符,并为其规定了默认的求值结果:
- 逻辑与(
&&) :空包时的默认返回值为true。 - 逻辑或(
||) :空包时的默认返回值为false。 - 逗号运算符(
,) :空包时的默认返回值为void()。
- 若对空包使用其他运算符(如
+或-)的一元折叠,会导致编译错误。
优先级与括号要求
- 折叠表达式外层的圆括号是必不可少的语法部分。
- 如果作为
pack或init的表达式中包含优先级低于强制类型转换(Cast)的运算符,则该子表达式必须加上括号,否则会导致编译错误(例如涉及加减乘除与赋值、条件运算符等混用时)。
10.12 包索引
- 定义 :自 C++26 起引入的包索引允许直接通过编译期常量索引 访问包中的特定元素,彻底告别了以往需要依赖递归模板或复杂元编程(如
std::tuple_element)才能提取包中单个元素的历史。 - 两大分类 :
- 包索引表达式 :形如
id-expression ...[ expression ],用于访问表达式或变量包中的特定元素。适用于非类型模板参数包 、函数参数包 、Lambda 初始化捕获包 以及结构化绑定包。 - 包索引说明符 :形如
typedef-name ...[ expression ],用于直接表示类型包中某个元素的类型。适用于类型模板参数包。
- 包索引表达式 :形如
核心语法与约束
- 常量表达式要求 :索引 I 必须是一个转换后的常量表达式 ,其类型为
std::size_t,且必须严格处于 [0,sizeof...(P)) 的有效范围内。运行时变量或非常量表达式作为下标会导致编译错误。 - 语法限制 :包索引的左侧必须是一个纯粹的标识符表达式(
id-expression)或typedef-name。直接对复杂的嵌套表达式(如(Vals * 3)...[I])进行索引是不合法的,必须将索引直接作用于包名本身(如Vals...[I] * 3)。
代码示例
arduino
// 1. 包索引表达式(函数参数包与非类型模板参数包)
template <std::size_t I, typename... Ts>
constexpr auto element_at(Ts... args) {
return args...[I]; // 直接按索引获取包中的对应实参
}
static_assert(element_at<0>(3, 5, 9) == 3);
static_assert(element_at<2>(3, 5, 9) == 9);
// 2. 包索引说明符(类型模板参数包)
template <typename... Ts>
using last_type_t = Ts...[sizeof...(Ts) - 1]; // 获取类型包中的最后一个类型
static_assert(std::is_same_v<last_type_t<float, int, bool*>, bool*>);
10.13 依赖名字(Dependent Names)
- 定义 :在模板(类模板或函数模板)的定义中,某些构造的含义可能会随着模板实参的不同而改变。如果一个类型或表达式取决于模板参数,则被称为依赖名字 。
- 查找与绑定时机 :
- 非依赖名字 :在模板定义点进行名字查找和绑定。
- 依赖名字 :其查找被推迟到模板实例化点 ,当模板实参已知时才进行实际查找。
依赖类型与依赖表达式
- 依赖类型 :包括模板参数本身、未知特化的成员、由依赖类型构造的复合类型、带有值依赖边界的数组、包含参数包的函数类型等。
- 类型依赖表达式 :其类型在编译期无法决定的表达式。例如包含依赖名字的标识符表达式、对依赖类型的转型操作、依赖的成员访问表达式等。
- 值依赖表达式 :通常出现在需要常量表达式的上下文中,其值需要等到模板实例化且实参已知后才能计算出来的表达式(例如非类型模板参数、依赖于模板参数的
sizeof运算等)。
当前实例化与未知特化
- 当前实例化 :在类模板定义内部,某些名字会被推导为指向当前正在实例化的类自身。对于当前实例化的成员,编译器可以在定义点进行基础的错误检查,并且不需要 使用
typename或template前缀。 - 未知特化 :当限定符(
::左侧)或成员访问的对象是一个依赖类型,且该类型既不是当前实例化也无法在定义点完全确定时,它就被归为未知特化。未知特化的成员始终是依赖名字。
typename 与 template 歧义消解关键字:在模板定义中,为了辅助编译器正确解析语法,必须在特定位置使用歧义消解前缀:
typename消解符 :- 作用 :当一个依赖于模板参数的限定名(如
T::X)表示的是一个类型 而不是变量或成员时,必须在前面显式加上typename。 - 示例 :
typename std::vector<T>::const_iterator it;
- 作用 :当一个依赖于模板参数的限定名(如
template消解符 :- 作用 :当一个依赖于模板参数的标识符后面紧跟尖括号
<...>(表示它是一个模板时),必须在前面加上template,以防止编译器将<误认为小于号。 - 示例 :
s.template foo<T>();或T::template foo<X>();
- 作用 :当一个依赖于模板参数的标识符后面紧跟尖括号
10.14 SFINAE(替换失败不是错误)
- 核心含义 :"Substitution Failure Is Not An Error"(简称 SFINAE)。
- 触发场景 :在函数模板的重载解析过程中,当显式指定或推导出的模板实参在代入模板参数或函数声明时发生错误,编译器不会将其视为编译报错,而是将该特化版本从重载集合中静默剔除。
发生代入的范围 :代入会在模板及其关联声明的特定类型和表达式中按词法顺序进行:
- 函数类型中的所有类型与表达式(包括返回值类型和所有形参类型)。
- 模板参数声明中的所有类型与表达式。
- 类模板或变量模板部分特化的模板实参列表。
explicit说明符中的表达式(C++20 起)。
立即上下文约束
- SFINAE 错误 :只有当错误发生在函数类型、模板参数类型或
explicit说明符的立即上下文中时,才会被作为 SFINAE 处理。 - 硬错误 :如果在代入过程中触发了某些副作用 ------例如实例化某个模板特化、隐式生成成员函数等------而在这些副作用内部发生的错误,则会被视为硬错误,导致编译直接中止。
- 注:自 C++20 起,Lambda 表达式内部的代码不被视为立即上下文的一部分。
主要分类与应用
- 类型 SFINAE :
- 试图在不具备类或枚举类型的左侧使用作用域解析符
::。 - 试图访问类型中不存在的成员,或成员类型/种类不匹配。
- 创建非法的类型(如
void引用、负数大小数组、非法的非类型模板参数类型等)。
- 试图在不具备类或枚举类型的左侧使用作用域解析符
- 表达式 SFINAE :
- 在函数返回类型或参数中使用依赖于模板参数的表达式(常配合
decltype与逗号运算符),当表达式不合法时触发替换失败。
- 在函数返回类型或参数中使用依赖于模板参数的表达式(常配合
- 部分特化中的 SFINAE :
- 在匹配类模板或变量模板的偏特化时,若推导或代入失败,该偏特化会被忽略,编译器会回退到主模板或其他可行特化。
现代替代方案 :虽然 SFINAE 是传统 C++ 模板元编程的基石(常配合 std::enable_if、std::void_t 使用),但在现代 C++ 中,通常更推荐以下更清晰、可读性更高的替代方案:
if constexpr(C++17) :用于在编译期进行条件分支控制。- 概念与约束(C++20) :现代 C++ 的首选,能以声明式的方式直接约束模板参数,彻底取代绝大多数复杂的 SFINAE 技巧。
10.15 C++20 概念与约束
- 概念 :是一个具名的编译期断言集合,用于对模板实参施加约束,属于模板接口的一部分。
- 设计意图 :旨在对语义范畴 (如
Number,Range,RegularFunction)进行建模,而不仅仅是检查语法限制。 - 主要优势:编译错误信息更加直观、清晰(在模板实例化早期即可捕获违反约束的错误)。
概念的定义与语法
-
基本形式 :
arduinotemplate <template-parameter-list> concept ConceptName = constraint-expression; -
约束应用方式 (以
Hashable为例):template<Hashable T> void f(T);template<typename T> requires Hashable<T> void f(T);template<typename T> void f(T) requires Hashable<T>;void f(Hashable auto);
约束的类型与构成: 约束是由逻辑操作符和操作数组成的序列,主要分为:
- 合取 :使用
&&连接。满足左右两边所有约束。遵循从左到右的短路求值原则。 - Disjunctions(析取) :使用
||连接。满足其中之一即可。同样遵循从左到右的短路求值原则。 - 原子约束 :由一个表达式 E 及其参数映射组成。 E 经代入后必须严格为
bool类型(不允许隐式转换)。 - 折叠扩展约束(C++20/C++26 演进) :对参数包进行折叠的约束(如
(A<T> && ...))。
约束规范与归一化
- 归一化 :将约束表达式转化为由原子约束通过合取和析取组成的标准形式的过程。
- 相同性 :两个原子约束如果由源码中的相同表达式生成且参数映射等价,则被视为相同。
约束的部分排序与重载解析
- 包容关系 :如果约束 P 能够推导出 Q(即 P 蕴含 Q),则称 P 包容 Q。
- 偏序规则 :若声明 D1 的约束包容 D2 的约束,则 D1 比 D2 更受约束 。
- 重载选择 :在重载解析时,编译器会优先选择更受约束的函数模板或特化版本,从而实现更加优雅的重载分发。
10.16 C++20 requires
- 产出 :
requires表达式是一个会产生bool类型纯右值的表达式,用于在编译期检查一组模板实参是否满足特定的语法和语义条件。 - 基本语法 :
requires { requirement-seq }requires ( parameter-list ) { requirement-seq }
四种具体要求 : 在 requires 表达式的花括号内部,可以包含以下四类要求:
-
简单要求 :
- 形式:
expression ; - 作用:断言给定的表达式是合法的、能够通过编译的(属于未求值操作数)。
- 形式:
-
类型要求 :
- 形式:
typename type-name ; - 作用:断言某个嵌套类型、类模板特化或别名模板代入是有效存在的。
- 形式:
-
复合要求 :
- 形式:
{ expression } noexcept -> type-constraint ; - 作用:对表达式的合法性、是否抛出异常(
noexcept)以及其返回类型(通过尾随的type-constraint约束)进行联合断言。
- 形式:
-
嵌套要求 :
- 形式:
requires constraint-expression ; - 作用:引入额外的约束条件或对局部参数施加更进一步的逻辑判断。
- 形式:
局部参数
- 可以在
requires表达式的圆括号中引入局部参数(如requires(T a, T b))。 - 作用:这些参数仅作为编写要求时的符号标记,不具备实际的生命周期、存储期或链接属性。
- 限制 :局部参数不能 带有默认实参,参数列表也不能 以省略号(
...)结尾。
求值与 SFINAE 行为
- 当模板实参代入
requires表达式导致不合法的类型或表达式时,整个requires表达式不会触发编译报错,而是静默求值为false。 - 检查过程按词法顺序进行,一旦某个条件能够决定最终结果便会短路终止。
- 注意:如果在每一个可能的模板实参下代入都会导致普遍的非预期错误,则程序非良构且无须诊断
11. 异常处理
- 定义:异常处理提供了一种将控制权和信息从程序执行的某一点转移到调用栈上先前通过的处理程序的机制(即沿调用栈向上回溯)。
- 核心关键字 :
throw:用于求值并抛出一个异常。try块 :必须包含能够匹配异常类型的处理器,throw表达式也必须位于try块(或其调用的函数)内。
- 异常规范 :C++11 起引入了
noexcept规范来限制函数可能抛出的异常类型(动态异常规范已于 C++17 废弃并移除)。
预期的错误处理场景:异常并非用于任意的控制流跳转,其主要设计意图是错误处理,通常被严格限制在以下场景:
- 无法满足后置条件:例如未能产生有效的返回值对象。
- 无法满足被调函数的先提条件:即前置条件不满足。
- 无法(重新)建立类的不变式(适用于非私有成员函数)。
- 宽契约函数的不合法输入 :例如
std::basic_string::at抛出越界异常。
**四大异常安全保证: 函数在报告错误后,对程序状态的额外保证通常分为四个严格超集等级:
- 不抛异常保证 :承诺绝不抛出异常。析构函数默认即为
noexcept。 - 强异常保证 :若抛出异常,程序状态会回滚到函数调用前的初始状态(例如
std::vector::push_back)。 - 基本异常保证 :若抛出异常,程序处于合法状态,没有资源泄漏,所有对象的不变式均完好。
- 无异常保证 :若抛出异常,程序可能处于不合法状态(如内存崩溃、资源泄漏等)。
- 泛型组件通常还会提供中立异常保证 :将模板参数抛出的异常原封不动地传播给调用者。
最佳实践
- 按引用捕获 :为了避免不必要的异常对象拷贝以及对象切割 ,最佳实践是始终在
catch块中使用引用 来捕获异常(例如const std::exception& e)。
11.1 try
try 块用于捕获和处理在执行过程中抛出的异常,主要分为两类:
- 普通
try块 :作为普通语句使用,由一个复合语句和一个或多个处理器序列组成。 - 函数
try块 :作为特殊的函数体存在,能够包裹整个函数定义(包括构造函数的成员初始化列表 )。
普通 try 块的控制流
- 匹配机制 :当其关联的复合语句中抛出异常时,控制流会依次与
handler-seq中的处理器进行类型匹配。 - 跳转限制 :
- 允许 :从
try块内部通过goto跳到该try内部的标签。 - 禁止 :从
try块外部使用goto跳入try内部,或从catch处理器中goto到try块内部。
- 允许 :从
- 跳出与销毁 :可以使用跳转语句(如
goto、break、return、continue)将控制权移出try块或其处理器。在此过程中,try块内部声明的变量会按照与其构造相反的顺序(词法逆序)被正确销毁。
函数 try 块
- 作用范围:能够捕获函数体内抛出的异常,对于类的构造函数和析构函数,还能捕获子对象(基类和非静态成员)在初始化或销毁过程中抛出的异常。
- 特殊限制与行为 :
- 构造函数/析构函数处理器中 :引用任何非静态成员或基类会导致未定义行为 。
- 构造函数限制 :若构造函数的函数
try块的catch处理器中出现return语句,会导致程序非良构。 - 自动重新抛出 :当控制流到达构造函数或析构函数的函数
try块处理器末尾时,当前被处理的异常会被自动重新抛出 。 - 特殊静态/线程存储期对象 :静态或线程存储期对象的构造/析构期间在
main或线程初始函数中抛出的异常,无法被函数try块捕获。
11.2 抛出异常
- 动态生命周期 :抛出异常会初始化一个具有动态存储期的对象,称为异常对象 。
- 非法类型 :若异常对象的类型是不完整类型、抽象类类型,或除
void*(可带 cv 限定符)以外指向不完整类型的指针,则程序非良构。 - 初始化与拷贝 :
- 异常对象通过对
throw的表达式进行拷贝初始化来创建。 - 会移除表达式类型顶层的 cv 限定符。
- 若类型是类类型,其构造函数必须是 odr-used 的,且析构函数必须是潜在调用的。
- 异常对象通过对
throw 表达式的两种形式
throw expression;:抛出一个新异常。该表达式本身属于类型为void的纯右值(prvalue),可作为子表达式(例如在条件运算符中)。throw;:重新抛出(Rethrow)当前正在处理的异常。- 如果在没有捕获任何异常时尝试重新抛出,会直接调用
std::terminate。 - 重新抛出时不会创建新的异常对象,而是复用现有的异常对象。
- 最佳实践 :在
catch块中重新抛出异常时,必须使用无表达式的throw;,如果使用throw e;会导致对象切割。
- 如果在没有捕获任何异常时尝试重新抛出,会直接调用
栈展开 :当异常对象构造完成后,控制流会逆序回溯调用栈以寻找匹配的 try-catch 处理器,此过程称为栈展开:
- 自动存储期对象 :逆序销毁自进入
try块以来已构造但尚未销毁的所有具有自动存储期的局部对象。 - 构造/析构中的子对象:若异常从对象的构造函数或析构函数中抛出,会逆序销毁所有已完全构造完成的非静态成员和基类。
new表达式关联 :若异常由new表达式调用的构造函数抛出,会自动调用匹配的回收(Deallocation)函数。- 致命错误(
std::terminate) : 如果在栈展开过程中(即异常对象已初始化、处理器尚未开始前),由栈展开机制直接调用的函数(如局部对象的析构函数、catch-by-value 参数的拷贝构造函数)又抛出了异常,程序会直接调用std::terminate终止。 如果异常未被任何处理器捕获(逃逸出main函数、线程初始函数或静态对象生命周期),同样会调用std::terminate。
异常对象的销毁时机
- 当捕获该异常的最后一个活动处理器以非重新抛出的方式退出时,异常对象被销毁。
- 销毁动作紧跟在处理器参数列表中声明的对象的析构之后。
- 引入
std::exception_ptr(C++11)后,若存在指向该异常对象的std::exception_ptr存活,其内存可能会被推迟到最后一个引用被销毁时才由实现进行回收。
11.3 异常捕获与处理器
异常通过 catch 块进行捕获和处理,主要有三种形式:
- 具名参数处理器 :
catch (type-specifier-seq declarator)。 - 匿名参数处理器 :
catch (type-specifier-seq abstract-declarator)(适用于只关心类型而不需使用参数的场景)。 - 通用处理器 :
catch (...),可以匹配所有类型的异常,如果存在,必须放置在处理器序列的最末尾。
- 非法参数类型 :若
catch参数被声明为不完整类型、抽象类类型、右值引用类型、指向不完整类型的指针(void*除外),或指向不完整类型的左值引用,程序非良构。数组或函数类型会被自动调整为指针类型。*
异常匹配规则 :当在 try 块中抛出异常时,编译器会按编写顺序依次在关联的处理器序列中寻找匹配项:
- 相同类型或基类规则 :若处理器类型为
T(或T&),且满足以下条件之一则匹配:E和T是相同类型(忽略顶层 cv 限定符)。T是异常对象类型E的不明确公开基类 。
- 指针/成员指针转换规则 :若
T是指针或成员指针类型,满足标准指针转换、函数指针转换(C++17)、限定符转换,或异常对象为std::nullptr_t(C++11)时可以匹配。 - 死代码警告 :如果将派生类异常的
catch块放在基类异常的catch块之后,会导致该派生类处理器永远无法执行(死代码)。
处理器参数的初始化与生命周期
- 拷贝初始化 :处理器的参数是通过异常对象(或其基类子对象)进行拷贝初始化的。
- 引用与修改 :
- 若参数声明为对象,在处理器内部对该对象的修改不会影响原异常对象。
- 若参数声明为引用 (如
const E&),在处理器内部对引用对象的修改会直接作用于异常对象,并将影响后续的重新抛出(throw;)。
- 生命周期:参数的生命周期在处理器退出时结束(在处理器内部创建的自动存储期对象销毁之后)。
其它重要特性
- 控制流限制 :
catch块的复合语句同样是控制流受限的。允许使用goto跳转到catch内部的标签,但不能从外部goto跳入。 - 未找到匹配时的行为 :若当前
try块中没有找到匹配的处理器,搜索会继续在动态外层的try块中进行。若最终仍未找到匹配项,系统会调用std::terminate(栈是否在此之前展开由实现定义)。
11.4 noexcept 说明符
noexcept 说明符用于指定一个函数是否可能抛出异常,主要有以下几种形式:
noexcept或noexcept(true):承诺该函数不会抛出任何异常。noexcept(expression):根据常量表达式expression的布尔值动态决定:若表达式结果为true,则函数不抛出异常;若为false,则函数可能抛出异常。throw():C++11 废弃、C++17 重新定义为等价于noexcept(true)的旧语法(已于 C++20 移除)。
函数的抛出属性分类:每个 C++ 函数都属于以下两种状态之一:
- 不抛出函数 :带有
noexcept(且布尔值为true)的函数,以及默认且隐式声明的析构函数、特殊成员函数(移动/拷贝构造、赋值等)、比较运算符(C++20 起)和内存释放函数。 - 可能抛出函数 :未显式指定
noexcept、显式指定noexcept(false),或其默认隐式定义中会调用潜在抛出函数的成员。
关键语言特性与约束
- 函数类型的一部分(C++17 起) :自 C++17 起,
noexcept规范正式成为函数类型系统的一部分(而在 C++17 之前不是)。因此,不能仅凭异常规范来重载函数,且非抛出函数的函数指针可以隐式转换为可能抛出的函数指针,反之则不行。 - 虚函数覆盖限制 :若基类中的虚函数是非抛出的(
noexcept),则派生类中的所有覆盖版本也必须是非抛出的,否则程序非良构。 - 运行时行为(
std::terminate) :noexcept不是 编译期检查。如果一个声明为noexcept的函数内部抛出了异常,且异常试图逃逸出该函数的最外层边界,系统会直接调用std::terminate终止程序(是否展开栈由实现决定)。 - 标准库容器优化 :诸如
std::vector等容器在进行扩容、重新分配等操作时,会检查元素类型的移动构造函数是否具有noexcept保证:若是,则安全地使用std::move移动元素;否则为了维持强异常保证,会退化为使用std::copy复制元素。
11.5 动态异常规范
- 语法形式 :
throw(type-id-list)(例如void f() throw(int, std::out_of_range);)。 - 生命周期 :
- 在 C++11 中被废弃(Deprecated) 。
- 自 C++17 起被正式移除(仅保留语法占位或部分向后兼容,等价性被取消或重定义)。
- 作用 :用于显式列出函数可能直接或间接抛出的异常类型列表。若列表为空(
throw()),则表示该函数不准抛出任何异常。
违规行为与 std::unexpected
- 当一个带有动态异常规范的函数抛出了未在列表中列出 的异常时,系统不会直接调用
std::terminate(与noexcept不同),而是转而调用std::unexpected。 std::unexpected的默认行为 :直接调用std::terminate。- 用户可以通过
std::set_unexpected自定义处理函数:该函数可以选择抛出一个被原异常规范允许的新异常(此时栈展开继续),或者抛出std::bad_exception(如果规范允许),否则最终依然会导向std::terminate。
潜在异常集合 :编译器会为每个函数和表达式计算一个潜在异常集合,用来推导隐式声明的特殊成员函数(如构造、析构、赋值等)的异常规范:
- 显式指定 :若声明了非空的动态异常规范,集合即为列出的类型;若为
throw(),集合为空。 - 无规范函数:默认被视为可能抛出所有类型的异常(即集合包含所有类型)。
noexcept(true):集合为空。
11 .6 noexcept 运算符
- 核心功能 :
noexcept运算符是一个编译期运算符,用于检查给定的表达式是否被声明为"不抛出异常"。 - 产出 :返回一个
bool类型的纯右值。 - 语法形式 :
noexcept( expression )
求值规则与判定标准
- 结果判定 :如果表达式的潜在异常集合为空(C++17 前)或者表达式被指定为不抛出 (C++17 起),则返回
true;否则返回false。 - 未求值操作数 :括号内的
expression属于未求值操作数------编译器在编译期推导其异常属性时,不会真正去执行该表达式。 - 典型应用 :常用于函数模板的
noexcept说明符中,实现根据不同模板参数类型动态决定函数是否具有不抛出保证。
重要注意事项
- 未定义行为与异常 :即使
noexcept(expr)的结果为true,在实际运行中,如果对expr的求值触发了未定义行为 ,依然可能会抛出异常或导致程序崩溃。 - 临时量实质化:自 C++17 起,若表达式的结果为 prvalue 且属于类类型(或其多维数组),会应用临时量实质化,这要求该类型的析构函数必须是可访问且未被删除的。
如果在此文章中您有所收获,请给作者一个鼓励,点个赞,谢谢支持
技术变化都很快,但基础技术、理论知识永远都是那些;作者希望在余后的生活中,对常用技术点进行基础知识分享;如果你觉得文章写的不错,请给予关注和点赞;如果文章存在错误,也请多多指教!