目标
[掌握]- 现代C++核心的语言特性及使用场景[掌握]- 通过编译器报错信息定位问题的能力[熟悉]- 通过文档和cppreference解决C++中不熟悉问题的能力[了解]- 如何参与技术社区 - 开源项目的使用、提问题、参与讨论或贡献
快速开始
在线代码练习
点击下面按钮 即可在云端自动完成配置, 并进入练习代码检测模式
搭建本地练习环境
尝试
Code -> Book -> Video -> X -> Code
点击查看xlings安装命令
Linux/MacOS
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash
Windows - PowerShell
irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex
注: xlings工具 -> 详情
xlings install d2x -y
d2x install d2mcpp
cd d2mcpp
d2x checker
👉 更多细节...
社区
- 即时交流: 167535744、1067245099
- 论坛版块: 问题反馈、练习代码、技术交流和讨论
- 社区活动: 📣 MSCP - mcpp项目学习与贡献者培养计划
注: 复杂性问题(技术、环境搭建等问题)推荐在论坛发帖, 并详细描述问题细节, 能更有效于问题的解决和复用
参与贡献
- 参与社区交流: 反馈问题、参与社区问题讨论、帮助社区新用户解决问题
- 参与项目维护和开发: 参与社区中问题处理、修复Bug、多语言支持、加入MSCP活动小组、开发&优化新功能/模块
📑开源协议与贡献许可(License & CLA)
- 本项目欢迎自由使用与分发!你可以在 Apache License 2.0 和 CC-BY-NC-SA 4.0 协议下免费使用、修改和分享本项目的代码与文档内容
- 如希望参与贡献代码或文档,请先阅读贡献者许可协议(CLA)
👥贡献者
序言
d2mcpp是一个开源、强调代码实践的现代C++核心语言特性教程项目。项目的总体结构为[Book + Video + Code + X]。为使用者提供了 在线电子书、对应的讲解视频、配套练习代码, 同时也提供了用于讨论交流的论坛和定期的学习活动...
语言支持
活动 | 📣 MSCP - mcpp项目学习与贡献者培养计划
MSCP是一款基于d2mcpp开源项目开发的"地球Online"风格的角色扮演游戏。在游戏中你将扮演一个"编程初学者", 为了入门"现代C++"并揭露其背后的真相, 踏上了一条充满挑战和惊奇的现代C++学习之路...
价格:免费开发者:Sunrisepeak发行商:MOGA发行时间:2025年10月游戏体量:100H - 200H之间标签:类魂系列、模拟人生、🌍Online、程序员、C++、开源、费曼学习法- -> 游戏详情
使用说明
d2mcpp是一个强调动手实践的现代C++核心语言特性教程项目。基于xlings(d2x)工具搭建了一套编译器驱动开发模式的代码练习, 可以自动化的检测练习代码的状态和跳转到下一个练习...
0.xlings工具安装
xlings包含教程项目所需的工具 - 更多工具细节
Linux
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash
or
wget https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh -O - | bash
Windows - PowerShell
Invoke-Expression (Invoke-Webrequest 'https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1' -UseBasicParsing).Content
1.获取项目及自动配置环境
下载项目到当前目录并自动配置本地环境
d2x install d2mcpp
本地电子书
可以在项目目录执行
d2x book命令, 打开本地文档(包含使用说明和电子书)
d2x book
练习代码自动检测
进入项目目录
d2mcpp运行checker命令, 进入练习代码自动检测程序
d2x checker
指定练习进行检测
d2x checker [name]
注: 练习名支持模糊匹配
同步最新的练习代码
由于项目处于持续更新阶段, 可以使用下面的命令进行自动同步(如果同步失败, 可能需要手动用git进行更新项目代码)
d2x update
2.自动化检测程序简介
使用d2x checker进入自动化代码练习环境后, 工具会自动定位打开对应的练习代码文件, 并在控制台输出提示编译器的错误及提示信息。一般检测程序分两个检测阶段: 第一个是编译期检测, 即你需要通过练习代码中的提示信息和控制台编译器的报错, 修复代码的编译错误; 第二个是运行时检测, 即当前代码运行时是否能通过所有检查点。当修复编译错误并通过所有检查点时, 控制台就会显示当前练习通过并提示你进入下一个练习
代码练习文件示例
// d2mcpp: https://github.com/mcpp-community/d2mcpp
// license: Apache-2.0
// file: dslings/hello-mcpp.cpp
//
// Exercise/练习: 自动化代码练习使用教学
//
// Tips/提示:
// 该项目是使用xlings工具搭建的自动化代码练习项目, 通过在项目根目录下
// 执行 d2x checker 进入"编译器驱动开发模式"的练习代码自动检测.
// 你需要根据控制台的报错和提示信息, 修改代码中的错误. 当修复所有编译错误和
// 运行时检查点后, 你可以删除或注释掉代码中的 D2X_WAIT 宏, 会自动进入下一个练习.
//
// - D2X_WAIT: 该宏用于隔离不同练习, 你可以删除或注释掉该宏, 进入下一个练习.
// - d2x_assert_eq: 该宏用于运行时检查点, 你需要修复代码中的错误, 使得所有
// - D2X_YOUR_ANSWER: 该宏用于提示你需要修改的代码, 一般用于代码填空(即用正确的代码替换这个宏)
//
// Auto-Checker/自动检测命令:
//
// d2x checker hello-mcpp
//
#include <d2x/cpp/common.hpp>
// 修改代码时可以观察到控制台"实时"的变化
int main() {
std::cout << "hello, mcpp!" << std:endl; // 0.修复这个编译错误
int a = 1.1; // 1.修复这个运行时错误, 修改int为double, 通过检查
d2x_assert_eq(a, 1.1); // 2.运行时检查点, 需要修复代码通过所有检查点(不能直接删除检查点代码)
D2X_YOUR_ANSWER b = a; // 3.修复这个编译错误, 给b一个合适的类型
d2x_assert_eq(b, 1); // 4.运行时检查点2
D2X_WAIT // 5.删除或注释掉这个宏, 进入下一个练习(项目正式代码练习)
return 0;
}
控制台输出及解释
🌏Progress: [>----------] 0/10 -->> 显示当前的练习进度
[Target: 00-0-hello-mcpp] - normal -->> 当前的练习名
❌ Error: Compilation/Running failed for dslings/hello-mcpp.cpp -->> 显示检测状态
The code exist some error!
---------C-Output--------- - 编译器输出信息
[HONLY LOGW]: main: dslings/hello-mcpp.cpp:24 - ❌ | a == 1.1 (1 == 1.100000) -->> 错误提示及位置(24行)
[HONLY LOGW]: main: dslings/hello-mcpp.cpp:26 - 🥳 Delete the D2X_WAIT to continue...
AI-Tips-Config: https://xlings.d2learn.org/documents/d2x/intro.html -->> AI提示(需要配置大模型的key, 可不使用)
---------E-Files---------
dslings/hello-mcpp.cpp -->> 当前检测的文件
-------------------------
Homepage: https://github.com/openxlings/xlings
3.配置项目(可选)
配置语言
编辑项目配置文件.d2x.json中的lang属性, zh对应中文, en对应英文
{
"version": "0.1.1",
"buildtools": "xmake d2x-buildtools",
"lang": "en", < -- 修改这里
...
}
自定义编辑器 - 以nvim编辑器为例
如果你希望使用 Neovim 编辑器并获得 LSP(clangd)支持, 可以按如下步骤进行配置
1.编辑项目配置文件config.xlings中的editor属性, 设置为nvim (或zed)
{
"version": "0.1.1",
"buildtools": "xmake d2x-buildtools",
"lang": "en", < -- 修改这里
...
}
2.在项目根目录运行一键依赖安装和环境配置命令
xlings install
3.在项目目录, 重新运行检测命令 d2x checker 就会使用nvim打开对应练习文件, 并具备练习自动跳转/切换功能
注: nvim编辑器下的"实时检测功能"的触发时机, 将会对应到
:w命令. 即修改代码后, 在nvim的命令行模式对文件进行保存(:w)时, d2x就会更新检测结果
4.资源于交流
交流群(Q): 167535744
教程讨论版块: https://forum.d2learn.org/category/20
xlings: https://github.com/openxlings/xlings
教程仓库: https://github.com/mcpp-community/d2mcpp
教程视频合集: https://space.bilibili.com/65858958/lists/5208246
类型自动推导 - auto和decltype
auto 和 decltype 是C++11引入的强有力的类型自动推导工具. 不仅让代码变的更加简洁, 还增强了模板和泛型的表达能力
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-auto / cppreference-decltype / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决类型声明过于复杂的问题
- 模板应用中, 获取对象或表达式类型的需求
- 为lambda表达式的定义做支撑
auto和decltype有什么区别?
- auto常常用于变量定义, 推导的类型可能丢失const或引用(可显示指定进行保留auto &)
- decltype获取表达式的精确类型
- auto通常无法作为模板类型参数使用
一、基础用法和场景
声明定义
充当类型站位符, 辅助变量的定义或声明。使用auto时变量必须要初始化, decltype可以不用初始化
int b = 2;
auto b1 = b;
decltype(b) b2 = b;
decltype(b) b3; // 可以不用初始化
表达式类型推导
常常用于复杂表达式的类型推导, 确保计算精度
int a = 1;
auto b1 = a + 2;
decltype(a + 2 + 1.1) b2 = a + 2 + 1.1;
auto c1 = a + '0';
decltype(2 + 'a') c2 = 2 + 'a';
复杂类型推导
迭代器类型推导
std::vector<int> v = {1, 2, 3};
auto it = v.begin(); // 自动推导it类型
// decltype(v.begin()) it = v.begin();
for (; it != v.end(); ++it) {
std::cout << *it << " ";
}
函数类型推导
对于函数或lambda表达式这种复杂类型, 常常使用auto和decltype. 一般, lambda定义用auto, 模板类型参数用decltype
int add_func(int a, int b) {
return a + b;
}
int main() {
auto minus_func = [](int a, int b) { return a - b; };
std::vector<std::function<decltype(add_func)>> funcVec = {
add_func,
minus_func
};
funcVec[0](1, 2);
funcVec[1](1, 2);
//...
}
函数返回值类型推导
语法糖用法
auto为后置返回类型函数定义写法做支持, 并可以配合decltype进行返回类型推导使用
auto main() -> int {
return 0;
}
auto add(int a, double b) -> decltype(a + b) {
return a + b;
}
函数模板返回值类型推导
当无法确定模板返回值时可以用auto + decltype做推导, 可以让add支持一般类型int, double,... 和 复杂类型 Point, Vec,... 增强泛型的表达能力. (c++14中可以省略decltype)
template<typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a + b) {
return a + b;
}
类/结构体成员类型推导
struct Object {
const int a;
double b;
Object() : a(1), b(2.0) { }
};
int main() {
const Object obj;
auto a = obj.a;
std::vector<decltype(obj.b)> vec;
}
二、注意事项 - 括号带来的影响
decltype(obj) 和 decltype( (obj) )的区别
- 一般
decltype(obj)获取的时其声明类型 - 而
decltype( (obj) )获取的是(obj)这个表达式的类型(左值表达式)
int a = 1;
decltype(a) b; // 推导结果为a的声明类型int
decltype( (a) ) c; // 推导结果为(a)这个左值表达式的类型 int &
decltype(obj.b) 和 decltype( (obj.b) )的区别
decltype( (obj.b) ): 从表达式视角做类型推导, obj定义类型会影响推导结果. 例如, 如果obj被const修饰时, const会限定obj.b的访问为constdecltype(obj.b): 由于推导的是成员声明类型, 所以不会受obj定义的影响
struct Object {
const int a;
double b;
Object() : a(1), b(2.0) { }
};
int main() {
Object obj;
const Object obj1;
decltype(obj.b) // double
decltype(obj1.b) // double
decltype( (obj.b) ) // double &
decltype( (obj1.b) ) // 受obj1定义的const修饰影响, 所以是 const double &
}
右值引用变量, 在表达式中是左值
int &&b = 1;
decltype(b) // 推导结果是声明类型 int &&
decltype( (b) ) // 推导结果是 int &
三、其他
default 和 delete - 显式控制特殊成员函数
= default 和 = delete 是 C++11 引入的两种 函数定义方式, 让程序员可以在源码层面显式声明 "这个特殊成员我要编译器生成默认实现" 或 "这个函数禁止被调用", 把原本只能靠编译器隐式规则推断的特殊成员控制权重新交还给设计者
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 在 C++11 之前, 只要类里写了任何一个用户定义的构造函数, 编译器就会停止生成默认构造函数, 没有显式办法把它"找回来"
- 没有标准方式表达 "这个特殊成员我故意不要 - 谁调用就编译错". 老办法是 "把拷贝构造声明成 private 且不实现", 错误信息晦涩、还要等到链接期才能暴露
- 不同编译器对 "什么时候自动生成 / 自动删除特殊成员" 的隐式规则容易让人记错, 显式标记可以让意图直接写在代码里
= default 和 = delete 的语义?
= default: 让编译器按规则生成这个特殊成员的默认实现 (默认构造 / 析构 / 拷贝 / 移动 / [C++20] 比较运算符 等), 等价于 "我要这个生成版本, 别因为我写了别的成员就把它隐式删掉"= delete: 显式禁止某个函数 - 任何对它的调用 / 重载解析选中它, 都会在 编译期 直接报错, 错误信息明确指向 "use of deleted function"
一、基础用法和场景
显式 default - 把被屏蔽的默认构造要回来
只要类里出现任何用户定义的构造函数, 编译器就不再为它合成默认构造. 下面 B 的写法就直接禁掉了 B b; 这种调用
struct B {
B(int x) { std::cout << "B(int x)" << std::endl; }
};
B b; // 错误: 没有默认构造函数
B b2(10); // ok
用 = default 显式声明一份默认构造, 就能把它要回来, 又不影响已经写好的有参构造
struct B {
B() = default; // 显式要求生成默认构造
B(int x) { std::cout << "B(int x)" << std::endl; } // 用户定义的有参构造
};
B b; // ok
B b2(10); // ok
类似的, C 里如果同时有无参构造和带默认值的有参构造, 这两者会在 C c; 的重载解析中产生 二义性. 把无参版本写成 = default 并去掉有参的默认值, 意图就清晰了
struct C {
C() = default;
C(int x) { std::cout << "C(int x): " << x << std::endl; }
};
C c1; // 调用 C()
C c2(1); // 调用 C(int)
显式 delete - 实现不可拷贝对象
std::unique_ptr 最关键的语义就是 "独占所有权 -> 不可拷贝, 但可移动". 自己手写一个简化版, 只要把拷贝相关的两个特殊成员 = delete 掉、把移动相关的两个写成 = default 即可
struct UniquePtr {
void *dataPtr;
UniquePtr() = default;
UniquePtr(const UniquePtr&) = delete; // 禁止拷贝构造
UniquePtr& operator=(const UniquePtr&) = delete; // 禁止拷贝赋值
UniquePtr(UniquePtr&&) = default; // 允许移动构造
UniquePtr& operator=(UniquePtr&&) = default; // 允许移动赋值
};
UniquePtr a;
UniquePtr b = a; // 错误: copy ctor 已被 delete
UniquePtr c = std::move(a); // ok: move ctor
可以用类型萃取在编译期直接验证语义是否符合预期
static_assert(std::is_copy_constructible<UniquePtr>::value == false, "");
static_assert(std::is_copy_assignable<UniquePtr>::value == false, "");
static_assert(std::is_move_constructible<UniquePtr>::value == true, "");
static_assert(std::is_move_assignable<UniquePtr>::value == true, "");
用 = delete 在重载集中"屏蔽"特定参数类型
= delete 不止能用在特殊成员上, 任何普通函数的某个重载都可以删掉. 一个常见模式是 阻止隐式转换, 让调用者用 "错误" 的参数类型时直接编译失败
void func(int x) {
std::cout << "x = " << x << std::endl;
}
// 显式删除 float 参数的重载, 否则 func(1.1f) 会被隐式转换成 int
void func(float) = delete;
func(1); // ok: 走 int 重载
func(1.1f); // 错误: 调用了 deleted function
如果不写这个 deleted 重载, func(1.1f) 会悄悄发生 float -> int 的窄化转换, 截断掉 0.1. 用 = delete 把它从重载集中移除后, 错误信息就明确多了: "use of deleted function 'void func(float)'"
default / delete 适用的成员清单
= default 可以用于编译器原本就能合成的 "特殊成员函数":
- 默认构造 (无参)
- 析构
- 拷贝构造 / 拷贝赋值
- 移动构造 / 移动赋值
- (C++20)
<=>等比较运算符
= delete 则没有这种限制 - 任何函数声明 (普通函数、成员函数、模板特化、特殊成员) 都可以写 = delete
二、注意事项
= default 不一定意味着 "trivial"
写了 = default 只是表示 "由编译器生成", 但生成出来的版本是否是 trivial / 是否是 noexcept, 取决于这个类的基类和成员. 例如基类的拷贝构造非 trivial, 那么派生类即使写 = default, 它的拷贝构造也是 non-trivial
struct HasString {
std::string s; // string 的 copy ctor 不是 trivial
HasString(const HasString&) = default;
};
static_assert(!std::is_trivially_copy_constructible<HasString>::value, "");
如果你的代码依赖 "trivial" 这个属性 (例如 memcpy 拷贝、放入 union), 不要只看到 = default 就直接下结论, 用 std::is_trivially_* 类型萃取去验证
delete 也可以用在普通函数上
= delete 不是特殊成员的专利. 任何普通函数都可以删掉, 既能用来禁止某个重载, 也能用在函数模板里禁止某些特化
template <typename T>
void only_int(T) = delete; // 默认全部禁掉
template <>
void only_int<int>(int x) { // 只允许 int
std::cout << x << std::endl;
}
only_int(1); // ok
only_int(1.0); // 错误: 调用 deleted function
不要把 deleted 函数放成 private
老式的 "禁拷贝" 写法是把 copy ctor / copy assign 声明成 private 且不实现. 这个写法在 C++11 之后已经过时, 应该统一改成 = delete (放在 public 区), 原因:
= delete在重载解析阶段就报错, 错误信息更明确; private + 未实现要等到链接期才暴露- 放在 public 让所有访问者拿到的错误信息一致, 不会因为 friend / 同类成员里调用而出现不同的错误形态
Rule of 0 / 3 / 5 - 设计类时的指导
= default / = delete 真正的设计价值, 是配合 Rule of 0 / 3 / 5:
- Rule of 0: 类不直接管理资源, 全靠成员的 RAII (例如
std::string,std::vector,std::unique_ptr) -> 不写任何特殊成员, 让编译器自动合成 - Rule of 3 (C++98): 如果实现了拷贝构造、拷贝赋值、析构中的任何一个, 通常另外两个也要实现
- Rule of 5 (C++11): 引入移动语义后, 把移动构造和移动赋值也加进来 - 一旦你显式定义/删除/默认了其中一个, 最好把全部 5 个都写出来, 避免被编译器的隐式规则坑到
三、练习代码
练习代码主题
- 0 - 显式指定构造函数生成行为
- 1 - 实现不可拷贝但可移动的 UniquePtr
- 2 - 用 = delete 屏蔽特定参数类型的重载
练习代码自动检测命令
d2x checker default-and-delete
d2x checker default-and-delete-1
d2x checker default-and-delete-2
四、其他
final 和 override - 虚函数重写控制
final 和 override 是 C++11 引入的两个 上下文相关标识符, 用于在虚函数继承中显式表达 重写 和 封口 的意图, 让编译器在编译期就暴露原本只能在运行期才能发现的多态错位问题
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-final / cppreference-override / markdown | 视频解读 | 练习代码 |
为什么引入?
- 在 C++11 之前, "派生类有没有真的重写到基类的虚函数" 完全靠程序员自己核对签名, 一个参数写错就会从重写变成隐藏, 编译器不会报错
- 缺少标准方式来表达 "这个类型 / 这条多态链到此为止" 的设计意图
- 让虚函数的设计契约变得可读、可校验
两者的语义区别?
- override: 加在派生类成员函数后, 显式声明 "这个函数是在重写基类的虚函数", 让编译器协助检查
- final: 加在虚函数后表示 "这个虚函数不能再被派生类重写", 加在类后表示 "这个类不能再被继承"
一、基础用法和场景
override - 显式声明重写
不加 override 时, 派生类哪怕签名写错也只是 "新增了一个普通函数", 多态行为悄悄丢失
struct Base {
virtual void func(int) { }
};
struct Derived : Base {
void func(double) { } // 本来想重写, 但参数类型不一致, 实际是新声明了一个函数
};
加上 override 后, 同样的错误会在编译期被拒绝
struct Derived : Base {
void func(double) override; // 错误: 没有签名匹配的基类虚函数
};
只有签名 (返回类型 + 参数列表 + cv 限定 + 引用限定) 完全匹配的基类虚函数, 才会让 override 通过
struct Base {
virtual void func(int);
};
struct Derived : Base {
void func(int) override; // ok
};
final - 禁止后续重写或派生
final 有两种用法, 作用对象不同
修饰虚函数 - 截断多态链
struct A {
virtual void func() final { }
};
struct B : A {
void func() override; // 错误: A::func 已被 final, 不能再重写
};
修饰类 - 禁止派生
struct B final { };
struct C : B { }; // 错误: B 已被 final, 不能被继承
final + 纯虚 - 不可改写的模板方法 (NVI)
把外层接口用 virtual ... final 锁住, 把可定制的步骤暴露成纯虚函数, 就得到一个 "执行顺序不可改、但每一步可定制" 的稳定接口. 这是 非虚接口惯用法 (Non-Virtual Interface) 的一种简洁写法
struct AudioPlayer {
virtual void play() final { // 子类不能改写 play 的整体流程
init_audio_params();
play_audio();
}
private:
virtual void init_audio_params() = 0; // 留给子类定制
virtual void play_audio() = 0;
};
struct WAVPlayer : AudioPlayer {
void init_audio_params() override { /* ... */ }
void play_audio() override { /* ... */ }
};
struct MP3Player : AudioPlayer {
void init_audio_params() override { /* ... */ }
void play_audio() override { /* ... */ }
};
调用方拿到的是统一的 AudioPlayer::play(), 不同格式的播放器只需实现两个钩子. 这种结构在做插件式 / 协议式接口时很常见
上下文相关标识符
override 和 final 既不是保留字, 也不是关键字, 而是 上下文相关标识符 (context-sensitive identifiers). 只有出现在虚函数声明或类声明的特定位置时才被解释为这两种语义, 普通位置可以正常用作变量名、类型名、命名空间名等
B override; // ok: 这里 override 只是个普通变量名
B final; // ok: 这里 final 只是个普通变量名
这也是 C++ 标准为了向后兼容做的折中: 已有代码里用 override / final 当标识符的, 不会因为升级到 C++11 而编译失败
二、注意事项
override 必须有签名一致的基类虚函数
派生类成员函数加上 override 后, 编译器会要求在某个基类中存在 签名一致的虚函数, 否则编译报错. 这是 override 最核心的价值: 把 "重写错位" 这种 silent bug 从运行期提前到编译期
struct A {
virtual void func1() { }
void func2() { } // 注意: 不是 virtual
};
struct B : A {
void func1() override; // ok
void func2() override; // 错误: A::func2 不是 virtual
};
final 类是 "封口" - 慎用
final 类不能被继承, 哪怕只是想派生一个加几个辅助方法的子类也不行. 给一个类加 final, 实际上是在做 "这个类型在设计上就是叶子节点" 的承诺, 需要谨慎. 经验法则:
- 业务上明确不希望被继续派生 (例如错误类型 / 框架内部实现类 / 单例) -> 适合 final
- 通用基类 / 框架预留的可扩展点 -> 不要轻易加 final
final 只能用在虚函数上
普通成员函数本来就不能被 override, 给它加 final 没有意义, 编译器也会拒绝
struct A {
void func() final; // 错误: 非虚函数上不能用 final
};
override 和 final 可以同时出现
如果某个虚函数既要重写基类版本, 又要禁止再被派生类继续重写, 可以两者一起加
struct B : A {
void func() override final; // 重写 A::func, 同时禁止 C 再重写
};
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker final-and-override
四、其他
尾置返回类型 - trailing return type
尾置返回类型是 C++11 引入的一种新的函数声明语法 auto func(...) -> ReturnType, 把返回类型从函数名前面挪到参数列表之后. 它解决了 "返回类型依赖参数" 在传统语法下根本写不出来的问题, 同时也是 lambda 显式指定返回类型的统一语法形式
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 传统语法下, 返回类型写在函数名前面, 此时参数还没有出现在作用域中, 无法用参数去推导返回类型
- 模板编程中经常需要 "返回类型 = 某个表达式的类型", 没有这种语法, decltype 推导依赖参数的返回类型几乎不可写
- 让函数签名的形态更统一: lambda、普通函数、模板函数都能用同一种
auto ... -> T形式
和传统返回类型语法有什么区别?
- 传统写法:
ReturnType func(Args...), 返回类型在最前面 - 尾置写法:
auto func(Args...) -> ReturnType, 用 auto 占位, 真正的返回类型放在->之后 - 两者大多数情况下完全等价, 但只有尾置写法能在
->之后引用参数名, 这是它真正不可替代的能力
一、基础用法和场景
基本语法 - auto + ->
把返回类型从函数名前移到参数列表之后, 中间用 -> 连接. 函数名前用 auto 占位, 表示 "返回类型在后面写"
// 传统写法
int add(double a, int b) {
return a + b;
}
// 尾置返回类型写法 - 等价
auto add(double a, int b) -> int {
return a + b;
}
这两种写法在编译结果上完全一致. 单看普通函数, 尾置写法只是风格差异, 并没有带来新的能力
配合 decltype 推导依赖参数的返回类型
尾置返回类型真正的杀手锏在模板里. 写一个泛型 add, 想让它支持 int、double、Point 等任意可相加的类型, 返回值是 a + b 的类型, 但具体是什么取决于 T1 和 T2
传统写法写不出来, 因为返回类型出现时, a 和 b 还没在作用域里
// 写不出来 - 此处 a, b 还未声明
decltype(a + b) add(T1 a, T2 b);
尾置语法把返回类型移到参数之后, 此时 a、b 已经在作用域中, 就可以用 decltype 直接推导
template<typename T1, typename T2>
auto add(const T1 &a, const T2 &b) -> decltype(a + b) {
return a + b;
}
add(1, 2); // 返回 int
add(1.1, 2); // 返回 double
add(1, 2.1); // 返回 double
这是尾置返回类型唯一不可被替代的核心场景: 让返回类型表达式可以引用参数名
在 C++14 之后, 普通函数可以直接写
auto add(...)让编译器从 return 语句推导, 大多数情况不再需要写-> decltype(a + b). 但 C++11 仍然必须用尾置语法
lambda 显式指定返回类型
lambda 没有名字, 也就没有 "返回类型写哪里" 的选择 - 它从一开始就只能用尾置语法
auto add = [](double a, double b) -> int {
return a + b; // 显式截断为 int
};
add(1.1, 2.1); // 3, 不是 3.2
不写 -> int 时, lambda 会自己推导返回类型为 double; 显式标注后, 返回值会被转换成指定的类型. 这是 lambda 控制返回类型的标准做法
嵌套类型 / 成员类型作为返回值
当返回类型是某个类的内嵌类型时, 传统写法需要在前面写完整限定 typename Class::Inner, 模板里还要加 typename 关键字, 又长又啰嗦
template<typename T>
typename std::vector<T>::iterator find_first(std::vector<T> &v, T x);
尾置写法可以让函数名先出现, 在 -> 之后写返回类型. 在某些类成员函数定义中, 可以省去重复的类名前缀
struct Box {
struct Inner { /* ... */ };
auto make() -> Inner; // 返回类型只写 Inner
};
auto Box::make() -> Inner { // 此时已经在 Box 的作用域内
return Inner{};
}
二、注意事项
auto 在尾置语法中只是占位符
尾置写法里的 auto 不是类型推导, 它只是一个语法占位符, 真正的类型由 -> 之后给出. 这点和 C++14 的 auto func() { return ...; } 不同, 后者才是真正让编译器去推导
auto add(double a, int b) -> int { // C++11: 返回类型由 -> int 显式给出
return a + b;
}
auto add(double a, int b) { // C++14: 返回类型由编译器从 return 语句推导
return a + b;
}
C++11 中如果只写 auto func() 而省略 ->, 是编译错误
何时用尾置, 何时用传统
不要把所有函数都改成尾置写法. 经验法则:
- 返回类型依赖参数 (decltype(a + b) 之类) -> 必须用尾置
- lambda 想显式指定返回类型 -> 必须用尾置
- 类成员函数返回内嵌类型, 想省掉
Class::前缀 -> 尾置更简洁 - 普通函数, 返回类型简单 (int / void / std::string) -> 传统写法可读性更好, 没必要换
不是所有 "auto func" 写法都是尾置返回类型
auto func() -> int (C++11 尾置) 和 auto func() { return 1; } (C++14 返回类型推导) 写起来都以 auto 开头, 但语义完全不同
- 前者: auto 是占位, 真实类型由
->给出, 编译器不需要看函数体 - 后者: 编译器必须看 return 语句才能推导出返回类型, 函数声明和定义就不能完全分离 (头文件里只放声明就拿不到返回类型)
在写需要在头文件中声明、源文件中实现的函数时, 这个区别会直接影响是否能正常编译
返回类型仍然受常规规则约束
尾置返回类型只是位置变了, 类型本身的规则没变:
- 不能返回函数 / 数组 (要返回函数指针 / 数组指针)
- decltype 推导出来的引用 / const 限定都会保留
- 派生类重写虚函数时, 返回类型仍要满足协变规则 (covariant return)
int arr[3];
// auto func() -> int[3]; // 错误: 不能返回数组
auto func() -> int(*)[3]; // ok: 返回数组指针
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker trailing-return-type
四、其他
右值引用 - rvalue reference
右值引用 T&& 是 C++11 引入的一种新的引用类型, 用来精确绑定到右值/将亡值, 让编译器在重载决议时能区分 "可借走资源的临时对象" 和 "需要保留的具名对象", 是后续移动语义和完美转发的语法基石
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 在 C++11 之前, 想绑定一个临时对象只能用
const T&, 拿到的是只读视图, 无法在不复制的前提下复用它的资源 - 缺少一种能在重载层面 "认出右值" 的机制, 编译器无法把 "构造自临时对象" 和 "构造自普通对象" 这两条路径分开
- 为移动语义 (
std::move) 和完美转发 (std::forward) 提供语法基础, 让 "把资源从将亡对象搬走" 成为可表达的语义
左值和右值的区别?
- 左值 (lvalue): 有名字、有持久存储位置、可以取地址的表达式, 例如已声明的变量
- 右值 (rvalue): 通常是字面量、临时对象、函数返回的非引用值, 生命周期短、不能直接取地址
- 经验判断: 能放在
=左边、并能&取地址 的一般是左值; 反之多为右值
一、基础用法和场景
左值 / 右值的判断
判断一个表达式是左值还是右值, 看它是不是有 "名字 + 持久身份"
int a = 1; // a 是左值
int b = a + 1; // a + 1 是右值 (没有名字, 临时计算结果)
&a; // ok: 左值可以取地址
// &(a + 1); // 错误: 右值不能取地址
int &lref = a; // ok: 左值引用绑定左值
// int &lref2 = a + 1; // 错误: 普通左值引用不能绑定右值
右值引用的声明和绑定
T&& 是右值引用的语法, 它只能绑定到右值
int &&rref1 = 10; // ok: 字面量是右值
int &&rref2 = a + 1; // ok: 临时计算结果是右值
// int &&rref3 = a; // 错误: 右值引用不能直接绑定左值
绑定后, 临时对象的生命周期会被延长到这个引用变量的作用域结束, 和 const T& 延长生命周期的规则一致, 但右值引用拿到的是可写视图
struct Object {
int data = 0;
};
const Object &cref = Object(); // 延长生命周期, 但只读
// cref.data = 1; // 错误: 通过 const 引用不能修改
Object &&rref = Object(); // 延长生命周期, 且可写
rref.data = 1; // ok
这正是练习代码要验证的核心点: 让 objRef.data = 1; 能编译通过, 同时保证 &objRef 仍指向那个被延长生命周期的临时对象
函数重载中区分左右值
右值引用作为函数参数, 可以让编译器把 "传左值" 和 "传右值" 走两条不同的重载路径
struct Object {
Object() { std::cout << "Object()\n"; }
Object(const Object&) { std::cout << "Object(const Object&)\n"; }
Object(Object&&) { std::cout << "Object(Object&&)\n"; }
};
void use(const Object&) { std::cout << "use lvalue\n"; }
void use(Object&&) { std::cout << "use rvalue\n"; }
int main() {
Object a;
use(a); // -> use lvalue (a 是左值)
use(Object()); // -> use rvalue (临时对象是右值)
}
练习里的 Object 同时定义了拷贝构造 Object(const Object&) 和移动构造 Object(Object&&), 就是为了通过打印来观察 "走的是哪条路径"
临时对象生命周期延长
下面是练习题的简化场景: 用引用绑定 Object() 这个纯右值, 临时对象的析构会被推迟到引用变量离开作用域
{
Object &&objRef = Object(); // 临时对象生命周期延长到这里
objRef.data = 1; // 通过右值引用修改它
} // 此处才析构
如果换成 const Object &objRef = Object();, 生命周期同样会延长, 但 objRef.data = 1; 这一行就编译失败了 — 这是 const T& 和 T&& 在这个场景下最直观的区别
二、注意事项
右值引用变量本身是左值
int &&rref = 10; 中, rref 这个变量名是有名字、可取地址的, 所以它在表达式里是左值, 不再是右值
void use(const Object&) { std::cout << "lvalue path\n"; }
void use(Object&&) { std::cout << "rvalue path\n"; }
Object &&rref = Object();
use(rref); // -> lvalue path (rref 在表达式中是左值!)
如果想把它再当右值传出去, 需要用 std::move(rref) 显式转换, 这也是后续 "移动语义" 章节的入口
const 引用和右值引用的重载选择
当同时存在 const T& 和 T&& 两个重载时, 编译器对右值实参会优先选择 T&& 版本
void f(const Object&) { std::cout << "const &\n"; }
void f(Object&&) { std::cout << "&&\n"; }
f(Object()); // -> && (右值优先匹配右值引用)
Object a;
f(a); // -> const & (左值匹配 const 左值引用)
这条规则是 STL 容器 (如 std::vector::push_back) 能针对左右值分别走 "拷贝" 和 "移动" 路径的基础
不要对右值引用变量名再施加 &&
T&& 在模板参数推导场景下会变成 "万能引用 / 转发引用", 行为和这里的纯右值引用不同 — 同样的语法在 template<typename T> void f(T&&) 里既能绑左值也能绑右值. 这部分属于完美转发的内容, 在后续章节展开, 这里只需记住: 非模板上下文中的 T&& 是 "只接受右值" 的右值引用
void g(Object&& o); // 只接受右值
template<typename T> void h(T&&); // 万能引用, 左右值都接受
右值引用是移动语义的入口, 不是终点
本章重点是 值类别 + 引用绑定 这一层机制. 真正利用右值引用 "把资源搬走" 的部分 (移动构造 / 移动赋值 / std::move) 在 ch05 展开. 不过练习代码里 Object(Object&&) 这个移动构造的打印, 已经能让你直观看到 "右值实参 -> 走移动路径" 的全过程
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker rvalue-references
四、其他
移动语义 - move semantics
移动语义是 C++11 在右值引用基础上引入的一种资源所有权转移机制, 让对象之间在传递时可以"搬资源"而不是"复制资源", 显著降低了带堆分配 / 文件句柄 / 大块缓冲区类型的拷贝开销
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-move / cppreference-move-ctor / markdown | 视频解读 | 练习代码 |
为什么引入?
- 在 C++11 之前, 临时对象 / 函数返回值 / 中间结果在传递时只能走拷贝构造 + 析构, 即使原对象立刻就要销毁, 也要付出一次完整的深拷贝
- 像
std::vector,std::string, 文件句柄, 自管理 buffer 这类拥有堆资源的类型, 拷贝代价和资源大小成正比, 在容器扩容 / 函数返回时尤其明显 - 需要一种语言层面的方式, 让"反正马上要扔掉的对象"把自己持有的资源直接交给新对象, 而不是复制一份再销毁
移动和拷贝的区别?
- 拷贝: 新对象自己分配一块新资源, 然后把源对象的资源逐字节复制进来, 源对象保持不变
- 移动: 新对象直接接管源对象内部的指针 / 句柄, 源对象被"掏空"成一个 valid-but-unspecified 的状态, 通常只剩析构能安全调用
- 拷贝是 O(资源大小), 移动一般是 O(1) — 只是几次指针赋值
一、基础用法和场景
std::move 是什么 — 它是个 cast, 不是真的"移动"
std::move 这个名字非常容易误导. 它没有移动任何东西, 也不会修改对象, 它做的事情只是把一个左值强制转换 (cast) 成右值引用类型, 让重载决议优先选中接收 T&& 的那个版本
近似实现是这样的
template <typename T>
typename std::remove_reference<T>::type&& move(T&& v) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(v);
}
实际把"资源转移"做掉的是移动构造函数 / 移动赋值运算符. std::move 只是负责把对象贴上一个"我可以被掏空"的标签, 真正掏空它的是后面的构造或赋值
Buffer a;
Buffer b = std::move(a); // 这里 std::move(a) 只是把 a 转成 Buffer&&,
// 真正的资源转移发生在 Buffer 的移动构造函数里
如果一个类型没有定义移动构造 / 移动赋值, std::move 会安静地退化成拷贝, 编译器不会报错. 这是初学最常踩的坑
移动构造函数 / 移动赋值运算符的形态
二者的签名固定为接收一个本类型的右值引用 T&&. 标准做法是: 把源对象的资源指针偷过来, 再把源对象那边置空, 这样析构两次也不会重复释放
struct Buffer {
int *data;
Buffer() : data { new int[2] {0, 1} } { }
// 移动构造: 接管 other 的资源, 然后把 other 置空
Buffer(Buffer&& other) noexcept : data { other.data } {
other.data = nullptr;
}
// 移动赋值: 先释放自己的旧资源, 再接管 other 的资源, 最后把 other 置空
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
other.data = nullptr;
}
return *this;
}
~Buffer() {
if (data) delete[] data;
}
};
注意三个细节
noexcept几乎是必须的:std::vector等容器在扩容时只有在移动构造声明 noexcept 的前提下才会用移动, 否则为了强异常保证会退回到拷贝- 移动赋值要做 self-assign 检查 + 释放旧资源, 顺序不能反
- 析构函数要能容忍
data == nullptr, 因为被移动后的对象就是这种状态
编译器何时自动生成 / 不生成移动
编译器在满足下列条件时, 会自动生成默认的移动构造和移动赋值
- 用户没有自己声明拷贝构造 / 拷贝赋值 / 析构函数 / 移动构造 / 移动赋值
只要用户显式声明了上面任何一个, 移动操作就不会被自动生成 (这就是所谓"Rule of 5"提醒你: 一旦自定义其一, 其它五个相关函数都要重新审视)
struct Foo {
std::vector<int> v;
// 没声明任何特殊成员 -> 编译器自动生成移动构造/赋值, 直接转发给 v
};
struct Bar {
std::vector<int> v;
~Bar() { /* 任何自定义内容 */ }
// 自定义析构 -> 移动构造/赋值不会自动生成, 拷贝时只能走深拷贝
};
如果默认的逐成员移动语义已经够用 (比如成员都是 std::vector / std::unique_ptr 这种本身已支持移动的类型), 不要写自己的. 想强制要求时可以用 = default 显式声明
struct Bar {
std::vector<int> v;
~Bar() { }
Bar(Bar&&) = default;
Bar& operator=(Bar&&) = default;
};
资源所有权转移的实际意义
回到开头那个例子, Buffer 持有一块堆缓冲. 没有移动语义时, 走 process(Buffer()) 这条链路要发生多次"分配 + 拷贝 + 析构"
Buffer process(Buffer buff) { // 入参一次构造
return buff; // 返回值一次构造
}
Buffer b = process(Buffer()); // 临时实参又一次构造
加上移动构造之后, 临时对象 / 局部变量在被消耗的位置自动走 Buffer&& 重载, 整条链路只做一次 new int[2], 所有中间对象共享同一块缓冲, 直到最后一个对象析构时才 delete[] 一次. 这正是练习 05-move-semantics-0.cpp 想让你通过编译器输出亲眼看到的现象
同样的思路推广到任何"持有外部资源"的类型: std::unique_ptr, 文件句柄包装, 网络连接 RAII, 大块图像 / 音频缓冲, 都靠移动语义把"传递成本"压到接近 0
二、注意事项
被移动后的对象处于 "valid-but-unspecified" 状态
标准对一个被移动后的对象只保证两点
- 析构函数能安全调用
- 满足类型不变量的最低要求 (这个由你的实现决定)
但不保证它还有"原来的内容"或"任何特定状态". 在练习 05-move-semantics-2.cpp 里你会看到, 被移动后 b1.data_ptr() == nullptr — 这是因为我们的实现主动把它置空了, 不是语言强行规定. 一旦移动结束, 不要再去读取被移动对象的内容, 只允许赋新值或让它析构
Buffer b1;
Buffer b2 = std::move(b1);
// b1 现在是 valid-but-unspecified 状态:
b1.data_ptr(); // 不要这样用 b1 的"业务数据"
b1 = Buffer(); // ok: 重新赋值
// 离开作用域时 b1 析构也是安全的
"Rule of 0 / 3 / 5"
- Rule of 0: 优先把资源管理交给已经支持移动的标准类型 (
std::vector,std::unique_ptr,std::string...), 自己一个特殊成员都不写, 编译器会替你处理好一切 - Rule of 3 (C++98): 一旦自定义了析构 / 拷贝构造 / 拷贝赋值之一, 通常另两个也要自定义
- Rule of 5 (C++11): 在 Rule of 3 的基础上, 再加上移动构造 / 移动赋值 — 一旦你介入了资源管理, 这五个特殊成员要一起设计, 否则会出现"能拷贝不能移动"或"能移动但移动后状态错乱"的不一致情况
最稳妥的策略仍然是 Rule of 0: 让标准库类型来当资源持有者, 你的类只负责组合它们
不要滥用 std::move (尤其是返回局部对象时)
直接 return localObj; 时, 编译器有 NRVO / RVO (具名 / 非具名返回值优化), 能直接在调用方栈帧上构造对象, 连一次移动都不需要. 主动写 return std::move(localObj); 反而会抑制 NRVO, 使得编译器只能走移动, 性能反而更差
Buffer good() {
Buffer b;
return b; // ok: 优先 NRVO, 次选移动
}
Buffer bad() {
Buffer b;
return std::move(b); // 不推荐: 抑制 NRVO, 强制走移动
}
类似的, 传值参数已经是个新对象, 在函数内部把它进一步传出去时才考虑 std::move. 对const 对象用 std::move 也是无效的, std::move 出来的还是 const 右值引用, 重载决议会回退到拷贝构造
三、练习代码
练习代码主题
- 0 - 移动构造与触发时机 - 让 buff 传递只做一次资源分配
- 1 - 移动赋值与触发时机 - 临时对象 / 中间对象 / 显式 std::move 三种场景
- 2 - 移动的是资源而不是对象 - 对比对象地址和 data 指针
练习代码自动检测命令
d2x checker move-semantics
d2x checker move-semantics-2
四、其他
强类型枚举 - scoped enums
scoped enum (enum class / enum struct) 是 C++11 引入的强类型枚举, 用于解决传统 enum 名字泄漏到外层作用域、隐式转换为 int、底层类型不可控等问题, 让枚举值真正成为一个独立的、类型安全的离散集合
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 传统 enum 的枚举值会泄漏到外层作用域, 容易和其他名字冲突
- 传统 enum 会隐式转换成 int, 容易出现不安全的算术 / 比较
- 无法显式指定底层类型, 跨平台 / 跨编译器尺寸不确定
和传统 enum 的区别?
- enum class 的枚举值不会泄漏到外层作用域, 必须通过
EnumName::Value访问, 同名值在不同枚举中可以共存 - enum class 不会隐式转换为整型, 想要数值时必须用
static_cast, 不同枚举之间也不能互相比较 - enum class 可以显式指定底层类型 (例如
: uint8_t), 内存布局可控, 同时也支持只声明不定义的 前向声明
一、基础用法和场景
enum class 的基本语法
enum class 是 scoped enum 的关键字组合 (enum struct 等价), 在传统 enum 后面加 class 即可
enum class Color {
RED,
GREEN,
BLUE,
ORANGE
};
enum class Fruit {
Apple,
Banana,
ORANGE // 和 Color::ORANGE 同名也没关系, 各自有独立作用域
};
如果换成传统 enum, 上面这两份定义放在同一作用域里就会因为 ORANGE 重复定义而编译失败
显式作用域 - 通过 EnumName::Value 访问
scoped enum 的枚举值不会暴露到外层, 访问时必须带上枚举名作为前缀
Color color = Color::ORANGE; // ok
Fruit fruit = Fruit::ORANGE; // ok, 和 Color::ORANGE 是两个不同的值
// Color c = ORANGE; // 错误: ORANGE 在当前作用域里不存在
这种 "强制带前缀" 的访问方式让阅读代码时能立刻看出某个常量属于哪个枚举, 也彻底消除了符号冲突
不再隐式转换为 int - 比较和算术更安全
传统 enum 会隐式转成 int, 所以下面这种 "颜色 == 水果" 的比较会被静默接受
enum Color { RED, GREEN, BLUE };
enum Fruit { Apple, Banana };
Color c = RED;
if (c == Apple) { /* 编译通过! 实际是 0 == 0, 永远成立 */ }
scoped enum 直接在编译期把这种错误拦下来
enum class Color { RED, GREEN, BLUE };
enum class Fruit { Apple, Banana };
Color c = Color::RED;
// if (c == Fruit::Apple) { } // 错误: 不同枚举类型不能比较
// int n = c; // 错误: 不能隐式转 int
int n = static_cast<int>(c); // ok: 必须显式 cast
同一个 enum class 内部的两个值之间可以用 == / != 比较, 但和其他枚举、和整型之间的比较都会被拒绝
显式指定底层类型 - enum class X : uint8_t
scoped enum 默认底层类型是 int, 可以在枚举名后加 : 类型 来显式指定, 从而精确控制内存占用
enum class Color { // 默认底层类型是 int
RED, GREEN, BLUE
};
enum class Color8Bit : int8_t { // 显式指定为 int8_t
RED, GREEN, BLUE, ORANGE
};
static_assert(sizeof(Color) == sizeof(int), "");
static_assert(sizeof(Color8Bit) == sizeof(int8_t), "");
枚举值也可以显式指定数值, 没指定的部分会从前一个值 +1 顺延
enum class ErrorCode : int {
OK = 0,
ERROR_1, // 1
ERROR_2 = -2,
ERROR_3 = 3 // 显式指定为 3
};
static_cast<int>(ErrorCode::ERROR_3); // 3
配合协议、网络包、嵌入式寄存器这类对内存布局敏感的场景非常有用
前向声明的支持
由于 scoped enum 的底层类型在声明时就已确定 (默认 int 或显式指定), 所以可以只声明而不给出枚举值列表, 实现前向声明
// header
enum class Status : uint8_t; // 前向声明 ok
void handle(Status s); // 接口里就能用上
// .cpp
enum class Status : uint8_t {
Ok, Pending, Failed
};
传统 enum 因为底层类型要靠枚举值范围推断, 所以不能这样前向声明 (除非也显式指定底层类型, 但那已经是 C++11 之后的扩展用法)
二、注意事项
当你确实需要数值时, 用 static_cast
scoped enum 不会自动转 int, 一旦需要把枚举值喂给数组下标、序列化、日志、整型 API, 都必须显式转换
enum class Color { RED, GREEN, BLUE };
Color c = Color::GREEN;
// int idx = c; // 错误
int idx = static_cast<int>(c); // ok
std::cout << static_cast<int>(c); // ok: 否则 << 也找不到匹配的重载
反过来, 从整型构造枚举值同样要显式 cast: Color c = static_cast<Color>(1);. 这一步是有意为之的 "摩擦力", 提醒你确认这次转换是必要且安全的
scoped enum 不能直接用作位掩码 - 要么 cast 要么定义 operator|
传统 enum 经常被当成位标志, 因为可以直接 FLAG_A | FLAG_B. scoped enum 因为没有隐式转 int, 这种写法是不通的
enum class Perm : uint32_t {
Read = 1 << 0,
Write = 1 << 1,
Exec = 1 << 2
};
// auto p = Perm::Read | Perm::Write; // 错误: 没有 operator|
两种常见的处理方式:
// 方式 1: 在使用点 cast
auto p = static_cast<Perm>(
static_cast<uint32_t>(Perm::Read) |
static_cast<uint32_t>(Perm::Write)
);
// 方式 2: 给这个枚举重载 operator|
constexpr Perm operator|(Perm a, Perm b) {
return static_cast<Perm>(
static_cast<uint32_t>(a) | static_cast<uint32_t>(b)
);
}
方式 2 之后 Perm::Read | Perm::Write 就能正常使用, 同时还保留了类型安全
传统 enum 的迁移建议
老代码里大量使用传统 enum 是常见情况, 不必一次全部改写, 可以按这些策略逐步收敛:
- 新代码默认用
enum class - 改老代码时优先处理 "明显容易冲突的命名" 和 "依赖隐式转 int 的可疑比较"
- 对内存布局敏感的枚举, 顺手补上
: uint8_t/: uint16_t等显式底层类型 - 需要位标志语义的枚举, 选择 cast 或重载
operator|/operator&, 不要回退到传统 enum
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker scoped-enums
d2x checker scoped-enums-1
四、其他
常量表达式 - constexpr
constexpr 是 C++11 引入的关键字, 用于把"原本要等到运行期才能算出的结果"提前到 编译期 就完成, 让编译器在生成代码前就拿到确定值, 同时还保留这段代码在运行期被正常调用的能力
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 把可以在编译期完成的计算从运行期搬到编译期, 减少运行期开销
- 让编译器有更强的不变量保证 (例如数组大小、模板非类型参数 等强制要求编译期常量的位置, 可以填一段函数计算的结果)
- 配合模板元编程 / static_assert / enum 等场景, 显式表达 "这个值就是个编译期已知量"
constexpr 和 const 的区别?
- const: "我不会改它" — 值可能要等到运行期才知道, 只要求一旦初始化就不可修改
- constexpr: "这个值在编译期就确定" — 是更强的约束, 由编译器保证可在编译期求值
- 所有 constexpr 变量都是 const, 但不是所有 const 都能放到
constexpr要求的位置 (例如数组维度、模板非类型参数)
一、基础用法和场景
constexpr 变量 — 必须用编译期常量初始化
constexpr 变量的初始化表达式必须能被编译器在编译期求出, 否则直接编译报错. 它的核心定位是"编译期常量"
int n = 10;
const int a = n + 10; // ok: a 是 const, 但值是运行期才确定的
constexpr int b = 10 * 3; // ok: b 在编译期就是 30
// constexpr int c = n; // 错误: n 是运行期变量, 不能用来初始化 constexpr 变量
const 只承诺"不再修改", 而 constexpr 进一步要求"现在就能算出"
编译期常量 vs 运行期常量 — 用在数组维度上的差异
C++ 中数组维度要求是编译期常量. 在这个位置, 普通 int 和 "值由运行期变量推导出的 const" 都不能用, 只有 constexpr 才稳
int size1 = 10;
const int size2 = size1 + 10;
constexpr int size3 = 10 * 3;
int arr1[size3]; // ok: size3 是编译期常量
// int arr2[size1]; // 错误: size1 是运行期变量
// int arr3[size2]; // 取决于编译器: size2 由 size1 推导, 不一定是编译期常量
练习 0 中需要在 arr1[sizex] 里挑出唯一一个能稳定保证编译期已知的维度, 答案就是 size3
constexpr 函数 — 既能编译期用, 也能运行期用
constexpr 函数最大的特点是 双形态: 给它编译期常量参数, 它就在编译期算; 给它运行期变量参数, 它就退化成普通函数在运行期算
constexpr int sum_for_1_to(int n) {
return n == 1 ? 1 : n + sum_for_1_to(n - 1);
}
int main() {
constexpr int s1 = sum_for_1_to(4); // 编译期算出 10
int n = 5;
int s2 = sum_for_1_to(n); // 运行期算
}
注意: 一个函数即使加了 constexpr, 也不强制它每次都在编译期被调用 — 是否走编译期, 取决于 使用位置 和 传入的参数
在需要"必须编译期常量"的位置使用 constexpr 函数
数组维度、模板非类型参数、static_assert、case 标签 — 这些位置都要求编译期常量. 把计算逻辑封装进 constexpr 函数, 就能在这些位置直接调用
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int fact_10 = factorial(10);
int arr[factorial(5)]; // 数组维度: ok
static_assert(factorial(5) == 120, ""); // 静态断言: ok
配合模板做编译期计算
模板的非类型参数 (template <int N>) 也要求编译期常量, constexpr 函数 / constexpr 变量都能填进去
template <int N>
struct Sum {
static constexpr int value = Sum<N - 1>::value + N;
};
template <>
struct Sum<1> { static constexpr int value = 1; };
constexpr int sum_4 = Sum<4>::value; // 编译期得到 10
把 factorial 和 Sum 组合起来还能在编译期解一些小问题 — 例如练习 1 里 "value 取多少时 value! + (1+2+..+value) > 10000" 这个问题, 全程不需要运行期参与
constexpr int value = 8;
constexpr int f = factorial(value);
constexpr int s = Sum<value>::value;
constexpr int ans = f + s;
static_assert(ans > 10000, "ans should be > 10000");
C++11 的 constexpr 函数限制
C++11 对 constexpr 函数的函数体限制比较严格:
- 函数体本质上只能是 一条 return 语句 (可以用三目
?:替代分支) - 不能写循环 (要靠递归代替)
- 不能有局部变量定义、不能改值
// ok: C++11 风格的 constexpr 函数 — 单 return + 三目 + 递归
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
C++14 起放宽了这些限制, 允许局部变量、循环、多语句, 写起来跟普通函数几乎一样. 但作为 C++11 章节的内容, 这里仍以 C++11 的"单 return + 递归"为基本写法
二、注意事项
constexpr 函数被传"运行期参数"时, 它就是普通运行期函数 — 不会报错
constexpr 是一种 能力声明, 不是 使用要求. 同一个 constexpr 函数, 在 constexpr 变量初始化处会被强制走编译期, 在普通赋值处则按运行期函数执行
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
int n = 5;
int a = factorial(n); // ok: 运行期调用 (n 是运行期变量)
constexpr int b = factorial(5); // ok: 编译期调用
// constexpr int c = factorial(n); // 错误: 编译期上下文需要常量, 但 n 不是
函数内部调用的 constexpr 函数, 也必须是 constexpr
如果一个 constexpr 函数内部调用了非 constexpr 函数, 那它在编译期上下文里就用不了 — 编译器会拒绝把整段表达式当成常量表达式
double pow(double base, int exp) { // 非 constexpr
return exp == 0 ? 1.0 : base * pow(base, exp - 1);
}
constexpr double mysin(double x) {
return x - pow(x, 3) / 6.0; // 错误: 用了非 constexpr 的 pow
}
把 pow 改成 constexpr double pow(...) 后, mysin 才能真正在编译期算出值. 练习 1 里的 mysin(30.0) 就是这种"打表"风格 — 修好后整个 sin 值由编译器算完直接写进二进制, 运行期复杂度 O(1)
不要为了 constexpr 而 constexpr
constexpr 把计算搬到编译期, 也意味着错误诊断、调试栈信息全要在编译期处理, 一旦逻辑变复杂, 编译错误信息会非常吓人. 经验法则:
- 真正需要 "在数组维度 / 模板参数 / static_assert 里直接用上" 的计算 → 用 constexpr
- 普通的工具函数, 即使能写成 constexpr 也不必非加 — 让编译期 / 运行期成本更可控
constexpr 不等于 inline 也不等于 noexcept
但有一个细节常被忽略: constexpr 函数是隐式 inline 的. 这意味着多个翻译单元里包含同一个 constexpr 函数定义不会触发 ODR 冲突, 头文件里直接定义即可, 不需要再额外加 inline
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker constexpr
四、其他
字面值类型 - literal type
字面值类型 (LiteralType) 是 C++11 引入的一类 "可以参与编译期计算" 的类型. 它和 ch07 的 constexpr 是一对搭档 - constexpr 决定 "函数 / 变量能否在编译期被求值", 而字面值类型决定 "什么样的值能进入这套编译期世界". 同时, C++11 还顺势放开了 用户自定义字面值 (42_km, "abc"_s), 让自定义类型也能写出像内置字面量一样的语法
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-LiteralType / cppreference-user-literal / markdown | 视频解读 | 练习代码 |
为什么引入?
- 配合 constexpr 把 "用户自定义类型" 也能参与编译期计算, 而不仅限于 int / double 这些内置类型
- 给单位 / 度量 / 强类型整数提供更可读的字面值写法 (例如
1_km,3_sec,42ms) - 让 "在编译期就能算出结果" 的代码范围从内置标量扩展到 Vector / Point / Color 这类领域类型
什么样的类型能算字面值类型?
- 内置标量类型 (int / double / pointer / nullptr_t / 枚举 等) 自动满足
- 数组: 元素是字面值类型的数组本身也是字面值类型
- 用户类型: 至少一个 constexpr 构造函数 (而且不是拷贝 / 移动构造) + 平凡或 constexpr 的析构 + 所有非静态数据成员都是字面值类型 + 所有基类都是字面值类型
一、基础用法和场景
内置类型的字面值
内置标量类型天然就是字面值类型, 直接配合 constexpr 即可在编译期参与计算
constexpr char c = 'A';
constexpr int a = 1;
constexpr double pi = 3.14;
constexpr int sum = a + 2 + 3; // 编译期就算好
把用户类型变成字面值类型 (添加 constexpr 构造)
默认写一个普通的 Vector 类, 它不是字面值类型, 也就没法在 constexpr 上下文里使用
struct Vector {
int x, y;
Vector(int x_, int y_) : x(x_), y(y_) { } // 普通构造, 非 constexpr
};
constexpr Vector v{1, 2}; // 错误: Vector 没有 constexpr 构造函数
只要把构造函数标记为 constexpr, 就把 Vector 升级成了字面值类型, 可以放进 constexpr 函数里做编译期组合
struct Vector {
int x, y;
constexpr Vector(int x_, int y_) : x(x_), y(y_) { } // 关键: constexpr 构造
};
constexpr Vector add(const Vector& a, const Vector& b) {
return Vector(a.x + b.x, a.y + b.y);
}
constexpr Vector v1{1, 2}, v2{2, 3};
constexpr Vector v3 = add(v1, v2); // 编译期就算出 {3, 5}
字面值类型 + constexpr 函数 = 编译期就能算的小型计算
把字面值类型和 constexpr 函数结合起来, 可以把过去只能写在运行时的 "小型业务逻辑" 整体迁移到编译期. 例如下面把字符串拆成数组 / 把数组求和都在编译期完成
constexpr std::array<int, 3> to_array(const char *str) {
return { str[0] - '0', str[1] - '0', str[2] - '0' };
}
constexpr auto arr = to_array("123");
constexpr int sum = arr[0] + arr[1] + arr[2]; // 编译期就是 6
用户自定义字面值 - operator"" _suffix
C++11 允许定义形如 42_km / "abc"_s 的字面量, 写法是重载 operator"" _suffix. 后缀名必须以下划线开头 - 不带下划线的后缀是标准库保留的
struct Length {
long double meters;
};
// 浮点字面量后缀: 1.5_km
constexpr Length operator"" _km(long double v) {
return Length{ v * 1000.0L };
}
// 整型字面量后缀: 200_m
constexpr Length operator"" _m(unsigned long long v) {
return Length{ static_cast<long double>(v) };
}
constexpr Length d1 = 1.5_km; // 1500 m
constexpr Length d2 = 200_m; // 200 m
字符串字面量也能定义后缀, 例如自定义一个 _s 转 std::string
std::string operator"" _s(const char* str, std::size_t len) {
return std::string(str, len);
}
auto greet = "hello"_s; // std::string
二、注意事项
自定义字面值后缀必须以下划线开头
不带下划线的后缀 (例如 s, min, if) 是标准库保留的. 用户自定义字面值如果不加下划线, 行为是未定义的, 编译器一般也会直接给警告
// 错误示范: 没有下划线
long double operator"" km(long double v); // 警告 / 未定义
// 正确写法
long double operator"" _km(long double v); // ok
"字面值类型" 不等于 "编译期常量"
字面值类型说的是 类型本身有没有资格参与 constexpr, 不代表这个类型的每个值都是编译期已知的. 一个普通的 int 变量也是字面值类型, 但它的值完全可能是运行时输入
int x;
std::cin >> x; // x 的值是运行时确定的
// 但 int 仍然是字面值类型 - 类型资格 != 值已知
要让一个值真正在编译期就能用, 还需要给变量加上 constexpr (或 const + 编译期初始化)
cooked 字面值 vs raw 字面值
用户自定义字面值有两种形式
- cooked (已处理): 编译器把字面量先按内置规则解析, 再传给你的 operator. 大多数场景用这种, 例如
operator"" _km(long double)收到的就是已经被解析好的浮点数 - raw (原始): 编译器把字面量的原始字符序列传给你, 由你自己解析. 写法是
operator"" _suf(const char* str), 适合需要绕过内置规则的特殊场合 (例如自定义大整数解析)
// cooked: 收到 long double
constexpr long double operator"" _km(long double v) { return v * 1000.0L; }
// raw: 收到 "1500" 这串字符
constexpr long long operator"" _bigint(const char* str) {
long long n = 0;
for (auto p = str; *p; ++p) n = n * 10 + (*p - '0');
return n;
}
析构函数: C++11 要平凡, C++20 放宽到 constexpr
C++11 的字面值类型要求析构函数必须是 平凡的 (trivial). 这就是为什么早期 std::string 不是字面值类型 - 它要在析构时释放堆内存. C++20 把这条放宽到 "析构可以是 constexpr", 所以 C++20 之后 std::string 也能进 constexpr 上下文了
// C++11 / C++17 中 std::string 不是字面值类型
constexpr std::string s = "abc"; // C++17 下报错
constexpr 构造函数不能是拷贝 / 移动构造
LiteralType 要求至少有一个 不是拷贝 / 移动 的 constexpr 构造函数. 也就是说光给拷贝构造加 constexpr 是不够的, 必须有一个 "能从原始数据构造出对象" 的 constexpr 构造
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker literal-type-0
d2x checker literal-type-1
四、其他
列表初始化
列表初始是一种用{ arg1, arg2, ... }列表(大括号), 初始化对象的一种初始化风格, 并且可以用于几乎所有的对象初始化场景, 所以也常常称他为统一初始化。此外, 他还增加了列表成员的类型检查功能, 防止一些窄化问题
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决初始化语法风格不统一问题
- 禁止隐式转换造成的窄化问题
- 方便容器类型的初始化
- 解决默认初始化语法陷阱
一、基础用法和场景
统一初始化风格
c++11之前不同场景有不同的初始化的方式
int a = 5; // 拷贝初始化
int b(5); // 直接初始化
int arr[3] = {1, 2, 3}; // 数组初始化
Object obj1; // 默认构造
Object obj2(obj1); // 拷贝构造
他们可以用{ }进行风格统一
int a = { 5 }; // 拷贝初始化
int b { 5 }; // 直接初始化
int arr[3] = {1, 2, 3}; // 数组初始化
Object obj1 { }; // 默认初始化
Object obj2 { obj1 }; // 拷贝构造
避免隐式类型转换和窄化问题
一般传统的初始化方法, 是默认C语言隐式类型转换规则风格. 例如, 用double类型初始化int类型变量的时候会自动丢掉小数位. 而列表初始化会增加额外的编译期类型检查来避免隐式类型转换和精度丢失问题. 在现代C++中, 除非有意的需要这种隐式类型转换, 大多数时候使用列表初始化是更好的选择
int a = 3.3; // ok
int a = { 3.3 }; // error
constexpr double b { 3.3 }; // ok
int c(b); // ok -> 3
int c { b }; // error: 类型不匹配
数组初始化中的窄化检查
int arr[] { 1, 2, 3.3, 4 }; // error: 3.3会发生窄化
int arr[] = { 1, 2, b, 4 }; // error: b会发生窄化
注: 如果b是运行时变量, 编译期可能只会触发窄化警告而不会报错
提高容器初始化的简洁性
对于容器类型的初始化, 老C++中常常会分成两个步骤。第一步, 创建一个元素数组; 第二步, 使用这个数组来初始化容器
int arr[5] = {1, 2, 3, 4, 5};
std::vector<int> v(arr, arr + sizeof(arr) / sizeof(int));
而列表初始化的引入, 能让我们把两步合为一个步骤, 大幅度提高了容器初始化的简洁性
std::vector<int> v1 {1, 2, 3};
std::vector<int> v2 {1, 2, 3, 4, 3};
并且, 可以通过std::initializer_list让我们的自定义类型也能支持这种不定长的列表初始化方式
class MyVector {
public:
MyVector() = default;
MyVector(std::initializer_list<int> list) {
for (auto it = list.begin(); it != list.end(); it++) {
// *it ...
}
}
};
MyVector v1 {1, 2, 3};
MyVector v2 {1, 2, 3, 4, 3};
避免初始化语法陷阱
使用{ }调用默认构造函数, 避免语法陷阱
#include <iostream>
struct Object {
Object() { std::cout << "Constructor called!" << std::endl; }
};
int main() {
Object obj1 { };
Object obj2(); // obj2是函数, 而不是Object对象
}
二、注意事项
数组类型列表初始化
数组类型的定义里面的值一般是不确定的, 但是列表初始化的方式会做默认值的初始化, 并支持自动补0
普通数组
int arr[4]; // arr[0] 不确定
int arr[4] { }; // arr[0] = 0
int arr[4] { 1, 2 }; // arr[2] / arr[3] 会自动补成0
数组容器
std::array<int, 4> arr; // arr[0] 不确定/可能是随机值
std::array<int, 4> arr { }; // arr[0] == 0
std::array<int, 4> arr { 1, 2 }; // arr[0] == 1, arr[2] 会自动补成0
成员初始化问题
列表初始化支持直接对 聚合类型的成员做初始化, 但需要注意添加构造函数后必须要匹配构造函数才可以
struct Point {
int x, y;
// Point(int x, int y) { ... }
};
Point { 1, 2 };
Point p1 { 2, 3 }; // p1 { x: 2, y: 3}
优先匹配std::initializer_list的构造函数
class MyVector {
public:
MyVector() = default;
MyVector(int x, int y) { }
MyVector(std::initializer_list<int> list) {
for (auto it = list.begin(); it != list.end(); it++) {
// *it ...
}
}
};
MyVector v1 { 1, 2 }; // 会优先匹配 MyVector(std::initializer_list<int> list)
MyVector v1(1, 2); // 匹配MyVector(int x, int y)
三、其他
委托构造函数
委托构造是C++11中引入的语法糖, 通过简单的语法, 可以在不影响性能的情况下, 来避免过多重复代码的编写, 实现构造逻辑复用
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 构造函数重载中, 避免重复代码的编写
- 方便代码的维护
一、基础用法和场景
复用构造逻辑
当一个类需要编写重载的构造函数时, 很容易造成大量的重复代码, 例如:
class Account {
string id;
string name;
string coin;
public:
Account(string id_) {
id = id_;
name = "momo";
coin = "0元";
}
Account(string id_, string name_) {
id = id_;
name = name_;
coin = "0元";
}
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
这里3个构造函数中的初始化代码, 很明显是重复了(实际的初始化可能要更复杂)。 有了委托构造的支持后, 通过在构造函数成员初始化列表的位置以: Account(xxx)的形式来委托其他更加完整实现的构造函数进行构造, 这样就可以只保留一份代码
class Account {
string id;
string name;
string coin;
public:
Account(string id_) : Account(id_, "momo") { }
Account(string id_, string name_) : Account(id_, name_, 0) { }
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
上面的两个构造函数, 通过委托构造的方式, 最后都会转发到Account(string id_, string name_, int coin_)
为什么更方便维护?
可以假设, 如果上面货币的单位或名称需要修改时, 重复的代码实现不仅没有遵循复用原则, 而且修改构造逻辑时也要重复多次的修改, 提高了维护成本
而通过委托构造的方式, 把构造逻辑放到了一个地方, 这样修改和维护时也变的更加方便
例如, 我们需要把元改成原石时, 只要修改一次即可
class Account {
// ...
Account(string id_, string name_, int coin_) {
//...
//coin = std::to_string(coin_) + "元";
coin = std::to_string(coin_) + "原石";
}
};
和封装成一个init函数的区别
一些朋友可能会想到, 如果把构造逻辑写成一个init函数, 不就是也可以实现代码复用的效果吗? 为什么还要搞一个新的写法, 作为特性添加到标准中. 是不是有点多余并且让C++变的更加复杂了
class Account {
// ...
init(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
public:
Account(string id_) { init(id_, "momo", 0); }
Account(string id_, string name_) { init(id_, name_, 0); }
Account(string id_, string name_, int coin_) {
init(id_, name_, coin_);
}
};
实际, 从性能角度考虑。大多数时候, 单独封装一个init函数的性能是低于委托构造的。因为成员的构造, 一般会经历两个阶段:
- 第一步: 执行 默认初始化 或 成员初始化列表
- 第二步: 运行构造函数体中的构造逻辑
class Account {
// ...
public:
Account(string id_, string name_, int coin_)
/* : 1 - 成员初始化列表 */
{
// 2 - 执行构造函数的函数体
init(id_, name_, coin_);
}
};
这就导致使用init函数, 实际上成员被"初始化"了两次, 而委托构造可以通过成员初始化列表来避免这个问题
class Account {
// ...
public:
Account(string id_, string name_, int coin_)
: id { id_ }, name { name_ }, coin { std::to_string(coin_) + "元" }
{
// ...
}
};
二、注意事项
临时对象误会
在一些不使用委托构造的场景中, 一个构造函数体中调用另外一个构造函数, 他实际只是创建了一个临时对象
- 调用普通函数
init: 初始化的是本对象的成员 - 调用另外一个构造函数: 在本对象外, 创建了一个新的临时对象
class Account {
// ...
public:
Account(string id_, string name_) {
Account(id_, name_, 0); // 创建的是临时对象
// init(id_, name_, 0);
// this->Account(id_, name_, 0); // error
}
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
不能重复初始化
当使用委托构造时, 就不能使用初始化列表去初始化其他成员, 这样的限制可以避免重复的初始化, 保证了数据成员只会被初始化一次
例如, 如果下面的语法被允许 coin 将会被初始化多次且可能会造成歧义
class Account {
// ...
public:
Account(string id_)
: Account(id_, "momo"), coin { "0元" } // error
{
}
};
三、其他
继承构造函数
继承构造函数是C++11 引入的一个语法特性 - 解决了在类继承结构中 派生类重复定义基类构造函数 的繁琐问题
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | Bili / Youtube | 练习代码 |
为什么引入?
- 减少重复代码, 避免手动转发
- 提高代码的表达能力
一、基础用法和场景
复用基类的构造函数
在继承构造函数这个特性引入之前, 即使基类和派生的构造函数形式没有任何区别, 也需要重新定义, 这不仅造成了一定程度的代码重复, 而且也不够简洁。例如, 下面的MyObject就对每个Base中的构造函数做了重新实现
class ObjectBase {
//...
public:
ObjectBase(int) {}
ObjectBase(double) {}
};
class MyObject : public ObjectBase {
public:
MyObject(int x) : ObjectBase(x) {}
MyObject(double y) : ObjectBase(y) {}
//...
};
而用这个特性, 可以通过using ObjectBase::ObjectBase;直接继承基类中的构造函数, 避免这个手动转发的过程
class MyObject : public ObjectBase {
public:
using ObjectBase::ObjectBase;
//...
};
这里需要注意的是, 构造函数继承 的编译期隐式代码生成, 不仅仅是对构造函数的"单纯"复制, 而且在派生类中还有类似"自动重命名的效果 ObjectBase -> MyObject "。即:
class MyObject : public ObjectBase {
public:
// 可能的生成代码
MyObject(int x) : ObjectBase(x) {}
MyObject(double y) : ObjectBase(y) {}
};
类型的功能扩展
在很多特殊的场景下, 我们可能只想给某个类型追加额外的行为/方法, 而不改变其构造行为。这个时候就可使用继承构造
class ObjectXXX : public Object {
public:
using Object::Object;
void your_method() { /* ... */ }
};
对一些类型做测试或调试时, 我们常常期望可以使用像to_string()之类的一些接口。如果在不方便直接修改源代码的情况下, 就可以使用 继承构造函数 的性质创建一个"具有一样接口"的新类型, 并追加一些方便调试的接口函数, 从而在有更方便的调试函数下实现间接测试。例如下面有个Student类:
class Student {
protected:
//...
double score;
public:
string id;
string name;
uint age;
Student(string id, string name);
Student(string id, string name, uint age);
Student(string id, ...);
};
通过实现StudentDebug并增加一些辅助函数, 这样更方便来获取调试信息
class StudentDebug : public Student {
public:
using Student::Student;
std::string to_string() const {
return "{ id: " + id + ", name: " + name
+ ", age: " + std::to_string(age) + " }";
}
void dump() const { /* 一些成绩细节 ... */ }
void assert_valid() const {
assert(score >= 0 && score <= 100);
// ...
}
};
同时, 在使用StudentDebug的时候, 不管是对象的创建还有原方法的使用都和Student保持了一致。所以对于这种 只是增加行为, 而不改变原类型对象的构造形式的需求, 使用继承构造能很大程度的简化代码
注: 一般这种方式可以保持同基类一样的 对象构造 + 行为/方法调用形式。但并不一定有一样的内存布局(例如新增虚方法), 并且类型判断上(RTTI)是不相等的
异常或错误类型标识和转发
在错误和异常处理时, 我们可以只定义一个基础的错误类型
class ErrorBase {
public:
ErrorBase() { }
ErrorBase(const char *) { }
ErrorBase(std::string) { }
//...
};
在定义多个标识场景的错误类型时, 通过使用继承构造函数, 可以轻松的让他们保持和基础错误类型一样的构造形式。例如:
class ConfigError : public ErrorBase {
public:
using ErrorBase::ErrorBase;
};
class RuntimeError : public ErrorBase {
public:
using ErrorBase::ErrorBase;
};
class IoError : public ErrorBase {
public:
using ErrorBase::ErrorBase;
};
每个场景的错误, 对应一个错误类型, 不仅保持了错误对象构造的统一, 也非常适合配合C++的重载机制做错误类型的自动转发和处理。例如, 我们可以给每个错误类型实现对应的处理函数, 没有实现的类型将会使用基础类型对应的处理函数, 非常像很多编程语言中异常捕获和处理的设计。例如下面自定义的错误处理器:
struct MyErrProcessor {
static void process(ErrorBase err) { /* 基础处理 */ }
static void process(ConfigError err) { /* 配置错误处理 */ }
// ...
};
MyErrProcessor::process(errObj); // 自动匹配对应的错误处理函数
泛型装饰器和行为约束
继承构造函数不仅可以用于普通的继承中, 他还可以用于模板类型。例如, 下面定义的NoCopy中, 使用了using T::T对泛型T中的构造函数做继承。他的作用是在不改变目标对象的构造形式和使用接口下, 做一定的行为约束
template <typename T>
class NoCopy : public T {
public:
using T::T;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
// ...
};
在一些模块或场景中, 我们期望再对象创想创建后, 不能再复制的方式创建其他对象时, 就可以在定义时使用这个NoCopy装饰器/包装器, 通过包装器中的delete显示告诉编译器删除了拷贝构造和拷贝赋值, 也意味着对象不在拥有拷贝语义。例如:
class Point {
double mX, mY;
public:
Point() : mX { 0 }, mY { 0 } { }
Point(double x, double y) : mX { x }, mY { y } { }
string to_string() const {
return "{ " + std::to_string(mX)
+ ", " + std::to_string(mY) + " }";
}
};
Point p1(1, 2);
NoCopy<Point> p2(2, 3);
这个时候p1和p2在接口的使用上都是一样的, 但是p2相对p1就少了可拷贝的属性
p1.to_string(); // ok
p2.to_string(); // ok
auto p3 = p1; // ok (拷贝构造)
auto p4 = p2; // error (不能拷贝)
二、注意事项
优先考虑继承还是组合
由于本章是介绍继承构造函数的特性和使用方式, 它是和继承性质绑定的。所以, 从实现上是倾向用继承的方式来实现的。 但是从于目标功能上考虑, 往往使用继承和组合都是可以实现的, 他们更偏向是手段而不是目的, 所以选择需要结合具体的应用场景。
例如, 对于一些测试环境, 或仅功能函数扩展, 无数据结构变动的场景下, 使用继承配合继承构造函数是比较方便的, 还可以避免大量的函数转发。但是, 对于一些 要对少量特定接口做"拦截"或较复杂的场景, 现在(2025)主流是更倾向用组合代替继承的
- 复杂场景或要加一个中间层做特殊处理 -> 一般组合优于继承
- 简单功能扩展, 且需保留接口使用的一致 -> 一般继承优于组合
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker inherited-constructors
四、其他
nullptr - 指针字面量
nullptr 是C++11引入的指针字面量,用于表示空指针。它解决了传统空指针表示方式(如NULL和0)在类型安全性和重载解析方面的不足。
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决
NULL宏和整数0在重载解析中的歧义问题 - 提供类型安全的空指针表示方式
- 明确区分指针和整数类型
- 支持模板编程中的类型推导
nullptr和NULL有什么区别?
nullptr是C++11引入的关键字,类型为std::nullptr_tNULL是预处理宏,通常定义为整数0或(void*)0nullptr在重载解析中更精确,不会与整数类型混淆
一、基础用法和场景
替代NULL和0
用于指针变量的初始化和赋值,替代传统的
NULL和0
int* ptr1 = nullptr; // 推荐用法
int* ptr2 = NULL; // 传统用法
int* ptr3 = 0; // 不推荐
// 检查指针是否为空
if (ptr1 == nullptr) {
// 处理空指针情况
}
解决重载歧义问题
在函数调用中明确传递空指针,
nulltpr能避免重载歧义问题, 并且避免与整数类型的混淆
void func(int* ptr) {
if (ptr != nullptr) {
*ptr = 42;
}
}
void func(int value) {
// 处理整数参数
}
int main() {
func(nullptr); // 明确调用指针版本
func(0); // 可能调用整数版本,产生歧义
func(NULL); // 可能调用整数版本,产生歧义
}
例如上面的代码中,调用func(NULL)就会报重载歧义错误
main.cpp: In function 'int main()':
main.cpp:16:9: error: call of overloaded 'func(NULL)' is ambiguous
16 | func(NULL); // 可能调用整数版本,产生歧义
| ~~~~^~~~~~
确保模板编程中的类型安全
在模板函数和类中,
nullptr提供更好的类型推导和安全性
// https://en.cppreference.com/w/cpp/language/nullptr.html
template<class T>
constexpr T clone(const T& t) {
return t;
}
void g(int*) {
std::cout << "Function g called\n";
}
int main() {
g(nullptr); // ok
g(NULL); // ok
g(0); // ok
g(clone(nullptr)); // ok
g(clone(NULL)); // ERROR: NULL可能会被推导成非"指针"类型
g(clone(0)); // ERROR: 0会被推导成非"指针"类型
}
当使用函数模板时, NULL和0通过会被推导成非"指针"类型, 而nullptr可以避免这个问题
main.cpp:19:12: error: invalid conversion from 'int' to 'int*' [-fpermissive]
19 | g(clone(0)); // ERROR: 0会被推导成非"指针"类型
| ~~~~~^~~
| |
| int
智能指针和容器
与现代C++特性(如智能指针、STL容器)配合使用
#include <memory>
#include <vector>
int main() {
std::shared_ptr<int> sp1 = nullptr;
std::unique_ptr<int> up1 = nullptr;
std::vector<int*> vec;
vec.push_back(nullptr);
// 检查智能指针是否为空
if (sp1 == nullptr) {
sp1 = std::make_shared<int>(42);
}
}
二、注意事项
类型推导和std::nullptr_t
nullptr的类型是std::nullptr_t,这是一个特殊的类型,可以 隐式 转换为任何指针类型:
#include <cstddef> // 包含std::nullptr_t的定义
void func(int*) {}
void func(double*) {}
void func(std::nullptr_t) {}
int main() {
auto ptr = nullptr; // ptr的类型是std::nullptr_t
func(nullptr); // 调用std::nullptr_t版本
func(ptr); // 调用std::nullptr_t版本
int* intPtr = nullptr;
func(intPtr); // 调用int*版本
}
与布尔类型的隐式转换
nullptr可以隐式转换为bool类型,在条件判断中非常方便:
int* ptr = nullptr;
if (ptr) { // 等价于 if (ptr != nullptr)
// 指针非空
} else {
// 指针为空
}
bool isEmpty = (ptr == nullptr); // true
三、练习代码
练习代码主题
- 0 - nullptr基础用法
- 1 - nullptr的函数重载
- 2 - nullptr在模板编程中的优势
练习代码自动检测命令
d2x checker nullptr
四、其他
long long - 64位整数类型
long long 是C++11引入的64位整数类型,用于表示更大范围的整数值。它解决了传统整数类型在表示大整数时的范围限制问题。
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决传统整数类型范围不足的问题
- 提供统一的64位整数类型标准
long long和传统整数类型有什么区别?
long long保证至少64位宽度,范围至少为 -2^63 到 2^63-1int通常为32位,范围约为 -21亿到21亿long在32位系统上为32位,在64位系统上通常为64位(但标准只保证至少32位)
一、基础用法和场景
基本声明和初始化
支持有符号和无符号, 以及字面量后缀标识
// 有符号long long
long long val1 = 1;
long long val2 = -1;
// 无符号long long
unsigned long long uVal1 = 1;
// 字面量标识 + 类型推导
auto longlong = 1LL:
auto ulonglong = 1ULL;
大整数应用和边界值
处理超出传统整数类型范围的计算,基于边界值获取
//#include <limits>
// 使用long long处理大数计算(超过int表示范围)
long long population = 7800000000LL; // 世界人口
// 获取整数类型边界
int maxInt = std::numeric_limits<int>::max();
long long maxLL = std::numeric_limits<long long>::max();
auto minLL = std::numeric_limits<long long>::min();
二、注意事项
类型推导和字面量后缀
使用LL或ll后缀明确指定long long字面量,使用ULL或ull指定无符号版本
auto num1 = 10000000000; // 类型可能是int或long,取决于编译器
auto num2 = 10000000000LL; // 明确为long long辅助类型推导
类型转换和精度问题
注意不同整数类型之间的转换可能导致的精度损失
long long bigValue = 3000000000LL;
int smallValue = bigValue; // 可能溢出
std::cout << "bigValue: " << bigValue << std::endl;
std::cout << "smallValue: " << smallValue << std::endl; // 可能不正确
// 安全转换检查
if (bigValue > std::numeric_limits<int>::max() || bigValue < std::numeric_limits<int>::min()) {
std::cout << "转换会导致溢出!" << std::endl;
}
位宽疑惑 - 标准中为什么不固定位宽?
原因
- 硬件差异问题: 不同架构“自然字长”不同:16/32/64 位都有,大量嵌入式甚至只有 8/16 位乘除指令.若强行规定(long为64位),一些机器(32 位 MCU)上会造成巨大的性能损失
- 例如: 在8位机器上做64位计算, 但没有相关的机器指令。所以需要通过算法模拟的方式实现, 进而导
指令周期攀升
- 例如: 在8位机器上做64位计算, 但没有相关的机器指令。所以需要通过算法模拟的方式实现, 进而导
- 历史与 ABI 兼容: C/C++ 起源早于现代 32/64 位普及,许多平台的系统接口、文件格式、调用约定都已把 int/long 的大小写进了 ABI。标准若强制改变,会破坏二进制兼容和生态
- 零成本抽象: C/C++ 标准面向“与机器高效映射”的抽象机,只规定行为与最小范围,让实现能选择对该平台最自然的宽度,从而获得零开销抽象或接近零开销
解决方案
- C/C++提供了可选方案: 需要精确位宽时,可以使用
<cstdint>/<stdint.h>里的int8_t、int16_t、int32_t、int64_t... - 不假设位宽和静态断言: 开发时不假设类型的位宽, 从而提高可移植性. 如果部分代码做了位宽假设, 可以通过静态断言来保证位宽符合预期
static_assert(sizeof(T)==N)

三、练习代码
练习代码主题
- 0 - long long基础用法
- 1 - long long大数应用和边界值
练习代码自动检测命令
d2x checker long-long
四、其他
类型别名和别名模板
类型别名和别名模板是C++11引入的重要特性,用于为现有类型创建新的名称,增强泛型编程的表达能力,提高代码的可读性和可维护性。
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-type-alias / markdown | 视频解读 | 练习代码 |
注:
using关键字在C++11之前就已经存在, 但当时主要是作为命名空间和类成员声明来使用的
- 声明命名空间:
using namespace std;- 类成员声明:
struct B : A { using A::member; };
为什么引入?
- 替代传统的
typedef语法,提供更直观的类型别名定义方式 - 支持模板别名,增强泛型编程的表达能力
- 提高代码可读性,特别是对于复杂类型
- 与
using声明语法保持一致
类型别名和typedef有什么区别?
- 语法更直观:
using NewType = OldType;vstypedef OldType NewType; - 支持模板别名,而
typedef不支持 - 在模板编程中更加灵活和强大
一、基础用法和场景
基本类型别名
为现有类型创建新的名称,提高代码可读性, 并且可以取代传统
typedef定义别名的方式
typedef int Integer; // 传统typedef方式
using Integer = int; // C++11 using方式
// 使用别名
Integer i = 1;
int j = 2;
类型别名并不是一个新的类型, 而是其他复合类型的一个别名, 本质是一样的。像上的代码中Integer的本质就是int, 常用于简化类型名
复杂类型别名
为复杂类型(如函数指针、嵌套类型)创建别名
// 函数指针别名
using FuncPtr = void(*)(int, int);
using StringVector = std::vector<std::string>;
// 嵌套类型别名
struct Container {
using ValueType = int;
using Iterator = std::vector<ValueType>::iterator;
};
void example(int a, int b) {
// 函数实现
}
int main() {
FuncPtr func = example; // 等价: void(*func)(int, int) = example;
StringVector strings = {"hello", "world"}; // 等价: std::vector<std::string> strings...
Container::ValueType value = 100; // 等价: int value = 100;
return 0;
}
对于void (*func) (int, int) = example;这样的代码很多人看到可能都要迟疑一下才能反应过来, 它是定义了一个函数指针。通过使用using给个复杂类型起一个类型别名FuncPtr, 使用FuncPtr func = example;就能让人快速获取代码意图了
别名模板
为模板类型创建别名,增强泛型编程能力
// 别名模板
template <typename T>
using Vec = std::vector<T>;
// 基于泛型, 创建其"子集合"别名类型
template <typename T>
using Vec3 = std:Array<T, 3>;
template <typename T>
using Vec4 = std:Array<T, 4>;
// 带默认参数的别名模板
template <typename T, typename Compare = std::less<T>>
using Heap = std::priority_queue<T, std::vector<T>, Compare>;
int main() {
Vec<int> numbers = {1, 2, 3};
Vec3<float> v3 = {1.0f, 2.0f, 3.0f};
Vec4<float> v4 = {1.0f, 2.0f, 3.0f, 4.0f};
Heap<int> minHeap;
Heap<int, std::greater<int>> maxHeap;
return 0;
}
除了给复杂创建别名外, 还支持给模板类型创建别名, 并且通过模板参数还能实现对原模板类型的参数/属性进行控制 - 默认参数、分配器类型、长度、比较器等. 在上面的代码中我们分别创建了动态Vec类型别名; 也通过指定长度, 创建了定长的Vec3、Vec4服务于特殊场景(向量、矩阵计算)的类型别名; 还用模板参数默认指创建了Heap类型, 底层默认使用vector作为数据结构, 并支持默认最小堆, 和通过指定模板参数的方式设置最大堆
标准库中_t风格的模板
在STL中有一些模板, 会提供_t的版本, 来节省手动获取类型和取值的过程。使用类型别名可以轻松的实现他们(_v风格需要C++17的[inline variables + variable templates]的支持)
std:remove_const_t的参考实现
// remove_const的实现和原理解释可参考: https://zhuanlan.zhihu.com/p/352972564
template <typename T>
using my_remove_const_t = typename std::remove_const<T>::type;
int main() {
const int a = 10;
my_remove_const_t<decltype(a)> b = a; // b的类型为int,而不是const int
return 0;
}
二、注意事项
别名不是新类型
类型别名只是现有类型的同义词,不会创建新类型
using MyInt = int;
using YourInt = int;
int main() {
MyInt a = 10;
YourInt b = 20;
a = b; // 可以赋值,因为都是int类型
static_assert(std::is_same<MyInt, YourInt>::value, "Types are the same");
return 0;
}
模板别名的作用域
别名模板必须在类作用域或命名空间作用域中声明
namespace MyNamespace {
template<typename T>
using MyVector = std::vector<T>;
}
class MyClass {
public:
template<typename T>
using Ptr = T*;
};
// 错误:不能在函数作用域中声明别名模板
// void func() {
// template<typename T>
// using LocalAlias = T; // 编译错误
// }
递归别名限制
别名模板不能直接或间接引用自身
template<typename T>
struct A;
// 错误:递归别名
// template<typename T>
// using B = typename A<T>::U;
template<typename T>
struct A {
// typedef B<T> U; // 这会导致递归定义错误
};
三、练习代码
练习代码主题
- 0 - 基本类型别名
- 1 - 复杂类型和函数指针别名
- 2 - 别名模板基础
- 3 - 标准库中的别名模板应用
练习代码自动检测命令
d2x checker type-alias
四、其他
可变参数模板 - variadic templates
可变参数模板是 C++11 引入的一项核心模板特性, 允许函数模板和类模板接受 任意数量、任意类型 的参数, 让 printf 风格的多参数接口在 C++ 中第一次具备了类型安全和编译期可检查的实现方式
| Book | Video | Code | X |
|---|---|---|---|
| cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- C++11 之前处理任意数量参数只能依赖 C 风格的可变参数宏 (
.../__VA_ARGS__) 或者重载 / 宏生成大量模板, 既不类型安全也难以维护 - 标准库需要一种统一的机制实现
make_shared,tuple,function等可变模板组件 - 与右值引用 / 完美转发结合, 才能真正写出 "通用、零开销" 的转发型接口
和 C 风格可变参数 / 模板硬展开 的区别?
- 可变参数宏 (
__VA_ARGS__) 只是文本替换, 没有类型信息, 也无法在运行期遍历参数 -- 错一个格式符就崩溃 - 模板重载硬编码 (1, 2, 3, ... N 个参数各写一份) 代码冗余、扩展性差, 通常还要靠宏批量生成
- 可变参数模板由编译器在编译期展开参数包, 完整保留每个参数的 类型 / 引用类别 / cv 限定, 并能与
std::forward协作完成完美转发
一、基础用法和场景
历史背景 - C++11 之前的方案
C++11 引入可变参数模板之前, 通常使用 可变参数宏 或 模板重载 + 宏生成 处理可变参数. 这两种方案都存在明显的缺陷, 这里以实现一个支持任意参数的输出函数为例对比
可变参数宏
继承自 C 语言, 使用 ... 表示宏定义的可变参数, 使用 __VA_ARGS__ 访问宏调用时传入的参数
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)
LOG("x = %d, y = %f\n", 10, 3.14); // 展开为 printf("x = %d, y = %f\n", 10, 3.14);
LOG("Hello"); // 展开为 printf("Hello");
可变参数宏使用简单但场景受限, 难以与现代 C++ 代码结合:
- 无法实现类型安全检查
LOG("%s", 42); // 编译通过, 但运行时崩溃或输出垃圾
- 无法处理引用 / 移动语义、无法保存参数包
- 无法遍历
__VA_ARGS__并对每个参数做不同处理
模板重载与硬编码
最直观也最笨拙的方法 -- 手动为 1 个、2 个、... N 个参数分别编写重载版本. 代码冗余极大, 维护和扩展都很困难
宏与模板生成
Boost.Preprocessor 广泛使用这种技巧 -- 让编译器自动生成 template<typename T1> void print(T1), template<typename T1, typename T2> void print(T1, T2), ... 对应的重载
#define TP_PARAM(n) typename T##n
#define FN_PARAM(n) T##n p##n
#define PRINT_BODY(n) std::cout << p##n << " ";
#define REPEAT_TP_3 typename T1, typename T2, typename T3
#define REPEAT_FN_3 T1 p1, T2 p2, T3 p3
#define REPEAT_PRINT_3 PRINT_BODY(1) PRINT_BODY(2) PRINT_BODY(3)
#define DEFINE_LOG_FUNCTION(n) \
template<REPEAT_TP_##n> \
void log(REPEAT_FN_##n) { REPEAT_PRINT_##n std::cout << std::endl; }
DEFINE_LOG_FUNCTION(3)
// 展开为:
// template <typename T1, typename T2, typename T3>
// void log(T1 p1, T2 p2, T3 p3) {
// std::cout << p1 << " "; std::cout << p2 << " "; std::cout << p3 << " ";
// std::cout << std::endl;
// }
这种方式扩展性差、编译速度慢, 也不便调试 -- 这正是可变参数模板要解决的问题
模板参数包和函数参数包的语法
C++11 用 ... 在模板中表示参数包. 以最经典的 print 为例:
// 递归终止函数
void print() { std::cout << std::endl; }
// 展开参数包的模板
template<typename T, typename... Args>
void print(T first, Args... args) {
std::cout << first << " ";
print(args...); // 递归调用, 每次剥离一个参数
}
// print(1, "Hello", 3.14, 'A'); // 完美运行, 类型安全
... 在 三个位置 出现, 含义都不同:
template<typename T, typename... Args>-- 修饰typename, 表示 模板参数包 (类型可变)void print(T first, Args... args)-- 修饰Args, 表示 函数参数包 (函数形参列表可变)print(args...)-- 修饰参数名, 表示 参数包展开 (在调用点把参数依次铺开)
参数包的展开 - 递归剥离
C++11 没有直接遍历参数包的语法, 通常采用递归方式 -- 每次模板调用从参数包中 "剥" 出一个参数处理, 剩余的继续往下递归. 递归终止函数通常为 同名的非模板函数 (重载决议时优先使用非模板版本) 或 单参数模板特化
// 终止: 单参数版本
template<typename T>
T sum(T x) { return x; }
// 展开: 多参数版本
template<typename T, typename... Args>
T sum(T first, Args... args) {
return first + sum(args...);
}
与完美转发结合 - make_shared
可变参数模板真正强大的地方在于 和万能引用 + 完美转发结合, 把任意参数原样转发给目标构造函数. 标准库里的 std::make_shared 就是教科书式的用法
template <typename T, typename... Args>
std::shared_ptr<T> make_shared(Args&&... args) {
T* ptr = new T(std::forward<Args>(args)...);
return std::shared_ptr<T>(ptr);
}
Args&&... 是 "参数包形式的万能引用", std::forward<Args>(args)... 在展开时为参数包中每一个元素分别套上对应的 std::forward, 完整保留每个参数的左值 / 右值类别
sizeof... 与 C++14 的 index_sequence
C++14 没有给可变参数模板加新语法, 但通过 std::index_sequence / std::make_index_sequence 提供了 不靠递归 也能展开参数包的能力 -- 在编译期生成 0, 1, ..., N-1 的整数序列, 然后用一次模板特化把序列展开
template <typename T, std::size_t... Is>
void print_impl(T&& t, std::index_sequence<Is...>) {
// 用逗号表达式 + 初始化列表展开参数包, 逐个打印
using expander = int[];
(void)expander{ 0,
((std::cout << "Arg " << Is << ": "
<< std::get<Is>(std::forward<T>(t)) << '\n'), 0)... };
}
template <typename... Args>
void print_args(Args&&... args) {
auto t = std::make_tuple(std::forward<Args>(args)...);
print_impl(t, std::make_index_sequence<sizeof...(Args)>{});
}
sizeof...(Args) 返回参数包中元素的 个数, 是写可变参数模板时几乎必用的运算符
C++17 折叠表达式 - 取代递归
C++17 引入 折叠表达式 (Fold Expressions), 把递归展开换成更直观的语法, 用一个二元运算符把参数包整个 "折" 起来, 极大减少了样板代码. 折叠表达式有四种形式:
- 一元左折叠:
(... op pack)--((p1 op p2) op p3) op ... - 一元右折叠:
(pack op ...)--p1 op (p2 op (p3 op ...)) - 二元左折叠:
(init op ... op pack)-- 比一元多一个初值 - 二元右折叠:
(pack op ... op init)
// 一元右折叠 - 求和
template <typename... Args>
auto sum(Args... args) { return (args + ...); }
// 一元左折叠 - 减法 (注意求值顺序)
template <typename... Args>
auto sub(Args... args) { return (... - args); }
// 二元左折叠 - 带初值的减法: (((init - a1) - a2) - ... - aN)
template<typename T, typename... Args>
auto sub_with_init_left(T init, Args... args) { return (init - ... - args); }
配合 if constexpr, 还能在编译期对参数包做条件分支, 不再需要专门写一个空的递归终止函数
template<typename T, typename... Args>
void process_args(T first_arg, Args... rest_args) {
process_value(first_arg);
if constexpr (sizeof...(rest_args) > 0) { // 编译期检查
process_args(rest_args...);
}
}
二、注意事项
递归终止函数必须独立可见
C++11 风格的递归展开依赖重载决议在 "继续递归" 和 "终止递归" 之间挑选合适的版本. 终止函数 (空参数或单参数) 必须 在递归模板之前可见, 否则会出现找不到匹配的编译错误
递归深度受编译器限制
每剥离一个参数就多一层模板实例化, 编译器默认的实例化深度上限通常是 1024 (可通过 -ftemplate-depth=N 调高). 参数极多 / 嵌套极深的展开建议改用 index_sequence 或 C++17 折叠表达式, 既快又浅
完美转发参数包的固定写法
参数包形式的万能引用必须写成 Args&&... args, 转发时必须写成 std::forward<Args>(args)... -- 把 forward<Args>(args) 整体作为模式, 末尾的 ... 才会对每个元素分别展开. 写成 std::forward<Args...>(args...) 是常见错误
参数包不能直接被 "保存"
参数包本身不是一等公民, 不能赋值给某个变量留到以后用. 常见做法是把它打包进 std::tuple, 之后用 std::index_sequence 展开消费
template <typename... Args>
auto save(Args&&... args) {
return std::make_tuple(std::forward<Args>(args)...); // 打包
}
优先用 C++17 折叠表达式 / 标准库设施
如果项目允许 C++17, 写新代码应该首选折叠表达式 + if constexpr, 避免 "终止函数 + 递归" 的样板. C++11 风格的递归展开主要在 维护老代码 或 限定 C++11 标准 时才需要
三、练习代码
练习代码主题
- 0 - 可变参数模板基础 - 递归展开 print
- 1 - 可变参数模板求和 - sum
练习代码自动检测命令
d2x checker variadic-templates
四、其他
广义联合体
在C++11之后引入了generalized (non-trivial) unions,广义非平凡联合体。
联合体的数据成员共享内存。 联合体的大小至少容纳最大的数据成员。
| Book | Video | Code | X |
|---|---|---|---|
| cppreference-union / markdown | 视频解读 | 练习代码 |
为什么引入
- 可以直接放入诸如
std::string的对象,不需要使用指针。 - 更好的管理数据成员的生命周期。
现在的联合与之前有什么区别
- 最多只有一个变体成员可以具有默认成员初始化器
- 联合体可以包含具有非平凡特殊成员函数的非静态数据成员。
union S {
int a;
float b;
std::string str; // C++11 之前是不能直接放入这种数据成员的,或者使用静态成员
S() {}
~S() {}
}
一、基础的用法和场景
普通联合体的使用
只有一个值是有效的
union M {
int a;
double b;
char *str;
}
广义联合体的使用
联合体的大小就是数据成员所占用的最大空间,其是随着数据成员的变化而动态变化的。
#include <iostream>
#include <string>
#include <vector>
union M {
int a;
int b;
std::string str;
std::vector<int> arr;
M(int a) : b(a) { }
M(const std::string &s) : str(s) { }
M(const std::vector<int> &a) : arr(a) { }
~M() { } // 需要知道哪个数据成员是有效的才能正确析构
};
int main() {
M m("123456");
std::cout << "m.str = " << m.str<< std::endl;
m.arr = { 1, 2, 3, 4, 5, 6 };
std::cout << "m.arr = ";
for(int v: m.arr) {
std::cout << v << " ";
}
std::cout << std::endl;
return 0;
}
生命周期
成员的生命周期从有效时开始,无效时结束。
#include <iostream>
struct Life {
Life() { std::cout << "----Life(" << this << ") Start----" << std::endl; }
~Life() { std::cout << "----Life(" << this << ") End----" << std::endl; }
};
union M {
int a;
Life l;
M(int n) : a(n) { }
M(const Life &life) : l(life) { }
~M() { } // 需要知道哪个数据成员是有效的才能析构
};
int main() {
M m = 1;
std::cout << "Life 1 time one Start" << std::endl;
m = Life();
// 该Life的生命周期会在失效前结束
std::cout << "Life 1 time one End" << std::endl;
m = 2;
std::cout << "Life 2 time one Start" << std::endl;
m = Life();
std::cout << "Life 2 time one Start" << std::endl;
m = 3;
return 0;
}
匿名联合体
int main() {
union {
int a;
const char *b;
};
a = 1;
b = "Jerry";
}
二、注意事项
可访问性
union和struct一样,默认的数据成员都是public。
联合体的析构
联合体的析构一般不指定,因为联合体本身是没办法得知自身的有效数据是哪个的。
union M {
char* str1;
char* str2;
~M() {
delete str1; // 如果有效数据是str2的话就会错误
}
};
匿名联合体的限制
匿名联合体是无法包含成员函数和静态数据成员。
union {
int a;
static int b; // 错误:不能有静态数据成员
int print() {...}; // 错误:不能有成员函数
};
未定义行为
如果访问无效的数据成员,会发生不可预知的行为。
union M {
int a;
double b;
};
M m;
m.a = 1;
double c = m.b; // 错误:未定义行为
三、练习代码
TODO
四、其他
POD(Plain Old Data)
在 C 语言中,常见大量仅包含数据成员、没有构造或析构语义、可以被视为按位存储的结构体。 C++ 早期标准为了描述这类“行为上接近 C struct 的类型”,引入了 POD(Plain Old Data) 这一概念。
| 书籍 | 视频 | 代码 | 交流 |
|---|---|---|---|
| cppreference-PODType / markdown | 视频解读 | 练习代码 | 论坛讨论 |
注意:从 C++20 开始,标准中的 “PODType” 概念已被标记为弃用。标准库更倾向于使用更细化的类别,如
TrivialType、StandardLayoutType、ScalarType等来描述相关需求。
为什么引入 POD
- C++ 需要兼容大量已有的 C 风格数据结构;
- 早期标准希望提供一种“近似 C struct”的类型分类,用于描述:
- 对象是否具有简单、可预测的内存布局;
- 对象是否不涉及复杂的构造、析构语义;
- 在当时,尚未引入细粒度的类型特征(type traits),POD 被用作一种方便但不精确的标签。
POD 与其他类型类别的关系
- 所有**标量类型(ScalarType)**都是 POD,例如:
- 内置算术类型:
int、double、char等; - 枚举类型
enum; - 各种指针类型。
- 内置算术类型:
- 对于类类型,标准引入以下概念:
- 平凡类型(TrivialType):所有特殊成员函数(构造、拷贝、移动、析构)都是平凡的(编译器自动生成或
=default,且不涉及虚函数等); - 标准布局类型(StandardLayoutType):对象的内存布局规则简单、可预测(例如继承关系单一、访问控制集中等)。
- 平凡类型(TrivialType):所有特殊成员函数(构造、拷贝、移动、析构)都是平凡的(编译器自动生成或
- 一个类是 POD 类,当且仅当:
- 它是平凡类型,并且
- 它是标准布局类型。
例如
struct A {
int x;
double y;
}; // POD:仅包含内置类型成员,且未定义任何特殊成员函数
struct B {
A a;
int z;
}; // 仍然是 POD:所有成员均为 POD 类型
struct C {
virtual void foo();
int x;
}; // 不是 POD:含有虚函数,破坏了平凡性和标准布局
struct D {
int x;
private:
int y;
}; // 不是 POD:私有与公有成员混排,破坏了标准布局(除非所有成员访问控制相同)
一、基础用法与典型场景
与 C 接口交互
使用 POD 结构体描述 C 接口所需的二进制数据布局,可直接按字节读写。
struct Packet {
std::uint32_t len;
std::uint16_t type;
std::uint16_t flags;
}; // 典型的 POD 结构体
int main() {
Packet p{};
// 假设 fd 是已打开的文件或 socket
read(fd, &p, sizeof(p));
write(fd, &p, sizeof(p));
}
这里用到的是它的 standardLayout 性质。
简单的内存快照
POD 类型可被视为“字节数组”进行拷贝,在同一平台与相同编译设置下通常是安全的。
struct Point {
float x;
float y;
}; // POD
void copy_points(const Point* src, Point* dst, std::size_t n) {
std::memcpy(dst, src, n * sizeof(Point)); // 按字节拷贝
}
与类型特征(type traits)配合
早期代码常使用
std::is_pod约束模板参数;现代 C++ 更推荐使用更细化的 traits。
template <typename T>
void pod_only_copy(const T& src, T& dst) {
static_assert(std::is_pod<T>::value, "T must be POD");
std::memcpy(&dst, &src, sizeof(T));
}
在新代码中,建议根据具体需求使用以下约束之一:
std::is_trivially_copyable<T>(可平凡拷贝)std::is_standard_layout<T>(标准布局)std::is_scalar<T>(标量类型)
二、注意事项
POD 概念在 C++20 中已被弃用
- 自 C++20 起,标准将 “PODType” 标记为 deprecated;
- 新增或修改接口时,建议直接使用更精确的特征检查:
std::is_trivial<T>/std::is_trivially_copyable<T>std::is_standard_layout<T>std::is_scalar<T>
POD 不保证跨平台/跨编译设置的二进制兼容性
- 不同平台、编译器或编译选项可能导致不同的内存对齐与填充(padding);
- 若将 POD 二进制数据持久化(如存入文件或网络传输),并在另一环境中直接
reinterpret_cast,可能会因字节序、对齐方式等差异而导致错误。
过度使用 POD 会影响程序灵活性
- 许多现代 C++ 类型(如
std::string、std::vector)并非 POD,但它们提供了更安全、更高效的抽象; - 通常仅在需要与底层 C 接口交互、进行二进制序列化或直接内存操作的模块中,才需要考虑 POD 或类似约束;其他场景应优先使用现代 C++ 的抽象与安全性特性。
三、练习代码
练习代码主题
- 0 - 使用类型特征判断 POD / trivial / standard layout(
17-pod-type-0.cpp) - 1 - 模拟按字节拷贝 POD 结构体,体会其行为(
17-pod-type-1.cpp) - 2 - 为 C 接口传入合适的 POD 类型数据(
17-pod-type-2.cpp)
练习代码自动检测命令
d2x checker pod-type
四、其他资源
d2mcpp更新日志
2025/11
C++11 - 13 - long long - 64位整数类型
C++11 - 12 - nullptr - 指针字面量
2025/09
C++11 - 11 - 继承构造造函数
2025/08
C++11 - 11 - 继承构造造函数
C++11 - 10 - 委托构造函数
练习检测命令
d2x checker delegating-constructors
常见问题
更多问题和反馈 -> 教程论坛交流版块