Effective Modern C++
难绷的被下一届的同学拉去给下下届讲 Modern C++ (不过笔者咕咕了). 想了半天, 最后还是决定讲讲 modern effective C++, 也算是笔者的启蒙读本了… 那也是 23 年春节的回忆, 转眼就过去两年了, 这两年笔者又学了些什么呢…
anyway, 不说废话了, 管理每小节开头给出所有用到的 cppreference
或者其他相关链接.
一些笔者平时看的链接: cppreference, rust-lang, cppweekly 群友版
笔者写 C++ 的核心要义就这些:
- best effort 尽力优化性能, 但是不要试图认为自己比编译器聪明. 养成习惯就是随手的事情了.
- 做好抽象和封装, 把一些内部的逻辑/可能是不安全的操作封装起来, 对外只暴露必要的接口 API, 不要留任何可能带来问题的后门, 这些都是给自己埋雷…
- 尽量做好解耦, 每个模块只干自己的事情. 最好的办法就是模块内的变量/函数不要对外公开, 多分函数/作用域尽量减少变量名泄露的到处都是, 多用 lambda 函数且只捕获需要的.
- 做好防御式编程, 可以开一个只在 debug 模式验证的宏, 做好边界检查.
永远用最新的 C++ 标准
从值类型到类型推导
参考内容 Effective Modern C++ Item 1, 23, 24. 虽然本文基本都是笔者口胡.
左值和右值
Remark: 请牢记: C++ 移动在语义希望达成的是, 所有权的转移. 推荐读者在实践中, 要么是 fallback 到 copy, 要么就是移走资源.
学完程序设计课之后, 大部分人都知道了 C++11 出了左值和右值这个东西, 但大概率是没有分太清楚的. 这里会简单介绍一下.
首先, 笔者假定读者能区分一个表达式的返回类型是不是值类型, 比如字面量 (除了字符串), 整数加减法 a + b
, 或者返回类型是不含引用 &
的函数, 又或者是 static_cast
为值类型, 这些表达式返回的一定是值类型.
区分不同的值类型, 一个最简单的规则是, 有名字的东西一定是左值引用 lvalue
, std::move
几乎必须作用于一个有名字的变量, 返回的是右值引用 xvalue
, 而其他的返回值的表达式, 返回的是纯右值 prvalue
, 函数结果则完全视其返回类型. 我们一般把后两者统称 rvalue
, 前两者统称 glvalue
. 这三个东西有什么区别呢? 如果你学过 rust, 你就知道有一个概念叫做生命周期, 而这里可以用生命周期来不严谨的解释一下: 对于 lvalue
和 xvalue
, 他们都不涉及生命周期的相关的事情, 但对于 prvalue
, 在整个表达式被执行结束之后, 它返回的值类型需要被析构.
1 |
|
特别注意的是, 右值引用的变量, 它看起来是一个 rvalue
之类的东西, 但根据我们的 “有名字” 原则, 它返回的其实是一个 lvalue
. 事实上, 所谓的右值引用, 和左值引用, 他们起到的只是影响重载决议的作用. 两者都是引用, 实现上几乎 100% 是由指针实现 (事实上, 笔者一直把引用当作一种保证非空的指针的语法糖). 换句话说, 只有在编译期选择对应的函数的时候, 这两个东西才会起到区分的作用. 只是一般来说, 大家会认为接收到右值引用的时候, 传入的对象即将被析构, 所以可以 “移走” 它的资源, 故称作 move
.
所以, 读者想必可以理解为什么 std::move
几乎一定作用在一个有名字的变量上, 它的作用就是告诉编译器, 请选择右值引用相关的重载. 实际上, 他就等于 static_cast<T &&>
强行转化, 算是标准库提供的语法糖. 一般来说, 通过右值进行的移动构造, 因为允许从传入的对象中接管资源 (e.g. std::vector
的内部数据的指针, std::shared_ptr
的指针), 往往会比 copy 更加高效. (当然, 这也只是君子协议, 如果用户没有实现移动构造, 或者故意在移动构造里面 while(1)
卡住, 那也是没有办法的事, 毕竟编译器只负责帮你选择应该调用的函数.)
特别需要注意的是, std::move
无法改变 const
属性, 如果你希望通过 move
收益, 请确保入参没有 const
.
1 | // A sample implementation of std::move |
在铺垫了这么多值类型之后, 可以来讲讲模板的类型推导了. 在这里, 笔者将忽略 volatile
这个病态的东西.
推导的类型是一个引用
1 | template <typename T> |
这个 case 相对比较简单, func
要求传入的值一定是一个 lvalue
, T
会保留传入参数是否是 const
, 并且是不含引用的值类型. 而 cfunc
则比较特殊, 它可以接受任何类型的值, T
会被推导为不含 const
和引用的类型. 读者或许可以联想到上学期讲到的 const &
可以绑定一切并延长 prvalue
的生命周期, 道理的确是类似的.
推导的类型是一个指针
1 | template <typename T> |
规则几乎和引用完全一样. 唯一特殊的是, 入参必须是指针类型, 并且不存在 const *
可以延长生命周期之类的, 这是因为我们不存在左右指针这回事.
特别注意的是, 只有 lvalue
可以被 take address, xvalue
和 prvalue
(即 rvalue
) 是不行的. 举例:
1 | int x = 0; |
推导的类型是一个通用引用
1 | template <typename T> |
这是一个非常特殊的 case, 要理解这个 case, 我们首先要了解万能引用, 以及理解 std::forward
, 以及引用折叠.
考虑以下的引用场景: 你为用户提供了一个包装函数, 你可能会把用户的入参传给内部调用的另一个函数. 同时, 你希望有更好的泛化性, 用的是模板. 自然, 用户有的时候传入的是左值, 有的时候是右值, 你希望把这个左还是右的信息原封不动的传给内部调用的那个函数. 这时候, 就需要用到 std::forward
.
简单来说, std::forward
功能是, 如果传入的是 lvalue reference
, 那么就返回一个 lvalue
; 如果传入的是 rvalue reference
, 那就返回一个 xvalue
, 此时类似 std::move
. (需要注意, 入参不做任何操作的话, 一定是被当作 lvalue
的, 因为它 “有名字”)
而入参同时接受左/右值, 这就需要用到 通用引用, 或者说 万能引用. 在上述代码中, 如果传入的是一个 lvalue
类型比如 int &
, 那么 T
会被推导为 int &
, 同时 param
的类型应当是 int & &&
. 这看起来很奇怪, 因为这里需要 引用折叠 的概念. 简单来说, 在推导的过程中, 这里 param
的类型会被折叠为 int &
. 而如果传入的是一个 rvalue
类型比如 int &&
或者 int
, 那么 T
会被推导为 int
本身, param
就是 int &&
类型, 没有什么歧义.
那 std::forward
是怎么工作的呢, 我们需要在 func
内部调用 std::forward<T>
. 当 T
是引用类型的时候, 他会返回一个 lvalue
; 反之, 则类似 std::move
返回一个 xvalue
. 这其实就对应了入参推导 T
的两种 case. 参考实现如下:
1 | template<typename _Tp> |
现在来看看 Modern Effective C++ Item 1 具体的例子:
1 | template<typename T> |
推导的类型是一个值类型
1 | template<typename T> |
这个非常特别, 这意味着无论入参是什么, 如果是推导的话一定会拷贝一份新的对象, 并且 T
类型推导出来不会含有引用和 const
. (当然, 用户也可以强行指定模板的类型 T
为含有引用的类型).
需要注意, 这里推导出来不会含有 const
指的是值类型本身, 如果传入的类型是 const int *
之类的 const
指针, 指向内容的 const
性自然是不能变的.
边角料
比较恶心的是数组实参和函数实参.
在推导值类型或者指针类型的时候, 数组会退化为指针, 函数同理. 在推导引用相关类型的时候, 数组会被推导为特殊的数组引用, 函数同理.
auto 推导
auto
作为 C++11 的一大亮点, 自然是不会拉下的. auto
的类型推导规则几乎和函数一致, 例如 auto
对应的是推导值类型, auto &
和 const auto &
是推导一般的引用类型, 而 auto &&
则是万能引用推导. 特别地, 相信大家也在程序设计课上了解过, const auto &
可以接受一个 rvalue
对象. 更加特别地, 如果 const auto &
或者 auto &&
绑定的是一个 prvalue
, 那么它可以延长这个 prvalue
的生命周期, 直到 auto
的这个变量离开作用域之后, 才析构. 对于 xvalue
, 由于其并非返回一个临时的值, 编译器不会去管它的生命周期, 因此也不存在延长不延长一说.
比较恶心的是, auto
如果用初始化列表初始化, 会有一些奇怪的行为. 对于一个函数模板推导, 传入一个初始化列表一样的东西比如 {1, 2, 3}
是无法工作的. 但对于 auto
声明变量, 这个是允许的.
1 | auto x = {1, 2, 3}; // deduced as std::initializer_list<int> |
特别地, C++14 以后也允许了 auto
作为返回类型 (即不含尾置的返回类型), C++11 lambda 函数不写返回类型的话也是默认以 auto
返回, 此时会按照类似的规则进行推导. 如果是返回的是 auto &
则同理. lambda 函数的参数中的 auto
和 C++20 以后函数参数中的 auto
也同理.
1 | auto func(int &x, int &y) -> auto & { |
语言特性
inline 和 static
inline
和 static
都属于是语言中存在很久的关键词了, 早在 C 里面就已经存在. 然而, 很多人对这两个关键词存在一定的误区.
inline
关键词 在 C++ 中和所谓的内联优化可以说没有一点关系. 这么说可能比较绝对, 但是为了便于读者区分, 建议读者也这么来理解. inline
的作用是告诉编译器, 这个符号允许被多次定义, 即在多个编译单元中出现.
这里首先要铺垫一下, 编译单元是什么. 在传统的算法竞赛题里面, 只有一个 main.cpp
, 那么编译单元就只有这一个 main.cpp
. 其他的文件都是被 #include
加进来的, 而众所周知, #include
其实就是文本替换, 把代码里面的文本复制了进来. 而一般大一点的 C++ 项目, 我们往往会看到一个 CMakeLists.txt
, 其中经常会列出若干 cpp
文件, 例如 src/1.cpp, src/2.cpp
. 这时候, 其中每个 .cpp
都是一个独立的编译单元, 在处理不同的单元的时候, 编译器可以并行编译, 这样在一个多核心的服务器上并行编译可以大大的减少编译的时间. 在多文件编译的时候, 往往编译器先会编译到 .o
文件, 然后把多个编译单元生成的多个 .o
文件链接为一个二进制可执行文件例如 .exe
, .out
. 在这个过程中, 每一个全局变量/函数都会生成一个符号, 其他的编译单元如果调用了一个声明的符号, 需要在链接期间找到符号对应的变量/函数的地址.
如果多个编译单元都看到了某个函数的声明和定义, 那么在编译到 .o
的过程中, 这些单元都会把这个函数的符号记下来, 读者可以认为是每个单元都维护了一个符号表 map
, 而 map
里面 key
为这个函数名字的一项记录了这个函数的地址 (这是一个不严谨的说法, 请不要细究细节). 而在链接的阶段, 不同的编译单元的符号表需要合并, 但如果合并的时候发现某一个 key
有两个对应的记录, 那么就会报错. 事实上, C/C++ 要求最后所有编译单元的结果中, 每个符号 (包括全局变量/函数) 只有一处定义, 这也就是所谓的 One Definition Rule (ODR), 即一个函数只能有一个定义.
然而, 很多时候, 对于一些简单的函数, 比如 int add_1(int x) { return x + 1; }
, 我们想把它放到头文件里面, 而不是某个 .cpp
里面. 一般情况下, 当多个编译单元包含了这个文件的时候, 这会违反 ODR. 这时候, 我们就需要用到 inline
关键词. inline
关键词的作用是, 在一个符号在多个编译单元里面出现时, 编译器随机保留其中的一份, 丢弃其他的. 因为多个编译单元中包括的是同一个头文件, 看到的也是同一个函数的实现 (比如上述例子中的 add_1
), 因此保留哪一份不会影响正确性. 特别地, C++ 默认类内提供实现的成员函数都是 inline
的, 所以大家多用面向对象吧.
1 | struct MyStructTest { |
static
修饰一个 class
成员函数/变量比较特殊, 这里讨论的是 static
修饰一个全局变量/函数. static
要求修饰的这个符号变成内部符号, 即最终这个符号不会对外暴露, 当前编译单元内所有用到这个符号的地方, 都会变成对内部符号的调用. 换句话说, 它不会在最终 .o
里面的符号表里面出现. 因此, 别的编译单元无论如何都无法直接调用这一个函数.
总结一下, inline
是允许多个定义, 编译器保留其中任意一份, 而 static
是让符号变成内部符号, 类似 private, 不再对外暴露. 对于在头文件中提供了定义的全局函数, 笔者建议使用 inline
. 对于没有在 .h
中声明, 仅仅用于当前编译单元 (.cpp
) 的一些内部辅助函数, 笔者建议使用 static
. 当然, 同样的概念不仅仅适用于函数, 同样也适用于变量 (需要 C++17). 以下是一些样例代码.
1 | // test.h |
1 | // test.cpp |
1 | // main.cpp |
感兴趣的读者可以再自行去了解一下匿名 namespace
的概念, 详情请参考 cppreference. 简单来说, 它让其中的所有符号都变成 static
的, 非常适合放在 .cpp
文件中.
1 | namespace { |
using
一些笔者想到的常用功能:
using enum
: C++20 引入的, 可以把enum
的作用域引入当前作用域, 非常适用于switch
内部.using namespace xxx
: 不推荐, 除非是using std::literals::chrono_literals
来引入s
这类用户定义字面量.using ns::xxx
: 引入 namespacens
中的xxx
, 适用于一个封闭作用域内部 (比如函数).using A = B
: 非常常见, 请全面禁用typedef
, 我们不应该兼容C++11
之前的代码.
结构化绑定
在 C++17 中, 引入了结构化绑定: 对于一个聚合类 (即没有基类, 没有用户声明的构造函数) 或者按照 特殊规则 重载了对应的函数的类, 我们可以类似 python 中 tuple
解包的形式写代码. 比如 std::pair
, std::tuple
, std::array
以及原生的数组都是支持的. 语法如下, 需要注意的是必须用 auto
:
1 | std::tuple<int, float, std::string> t; |
如果你对于自定义的非聚合类也想使用结构化绑定, 那么你需要提供一个 get
函数, 并且特化 std::tuple_size
和 std::tuple_element
两个模板. 例如:
1 | struct A { |
这里你可能会好奇了: 编译器为什么知道调用的是哪个 get
函数? 为什么不是 std::get
? 这里就涉及 C++ 中 Argument Dependent Lookup (ADL) 的知识了. 考虑到 ADL 对于一般读者还是过于抽象和晦涩, 这里笔者只给出一个 cppref 链接, 笔者也是在 23 年暑假花了整整一个暑假才嚼明白, 并且在漫长的实践中才真正理解.
if/switch
在 C++17 中, 引入了 if
初始化语句. 简单来说, 你可以在判断一个条件的同时把结果放在 if
内部 (注意, 条件判断不一定返回的是 bool
, 只要可以 static_cast<bool>
即可), 或者为 if
额外的添加初始化语句, 从而避免变量名泄露, 把变量的生命周期限制在 if
语句内部. 这对 switch
也是类似的.
1 | auto foo() -> std::shared_ptr<int>; |
1 | // after |
这个设计最大的好处是, 可以干净的, 只为 if
所在的作用域声明变量, 避免变量名泄露, 严格控制变量的生命周期. 笔者认为这是现代语言必须的一个特点, 即闭包化, 每个模块尽量解耦, 严格限制模块之间潜在的耦合, 进而写出更高质量的代码.
constexpr/consteval/constinit
在 C++20 中, 推出了两个有趣的关键词: consteval
和 constinit
. 前者要求函数必须在编译期求值, 后者要求变量必须在编译期初始化.
constexpr
constexpr
是 C++11 引入的, 当修饰变量的时候, 要求变量在编译期初始化, 当修饰函数的时候, 表示函数允许在编译期求值. 这些你应该都在课上已经了解了. 在逐渐发展的过程中, constexpr
也在变得更强. 在 C++20 中, constexpr
修饰的函数甚至允许动态分配内存, 仅仅要求在编译期确定大小以及释放 (虽然这是编译器对 std::allocator
开洞…).
1 | constexpr auto find_the_kth_prime(int k) -> int { |
事实上, 传统很多使用模板元编程的奇技淫巧基本都可以用 constexpr
来替换了, 这种写法可读性更高, 也更加直观.
consteval
consteval
的核心在于: 函数必须在编译期求值. 例如:
1 | consteval auto must_be_0(int x) -> int { |
在上述函数中, 如果你的入参不是 0
, 那么编译器会报错 (因为 throw 无法在编译期执行), 代码无法通过编译. 这有两点:
- 强制某些函数的计算在编译期完成. 虽然 C++ 编译器有着非常强大的优化能力, 但是对于极其复杂的函数, 并不保证能够优化出来, 即使被标上了
constexpr
, 只要不是赋值给constexpr
的变量, 编译器也不会强制在编译期求值 (虽然大多数情况下会). 这时候,consteval
就派上用场了. - 给编译期间的错误提供了更多的可能. 例如上述代码, 如果你传入了一个非 0 的值, 编译器会报错. 我们因此可以实现类似功能: 只要当函数的输入类型/参数满足特定条件的时候, 才能通过编译 (参考
std::format
).
需要注意的是, consteval
和重载决议无关. 编译器在选择了正确的函数之后, 如果该函数不满足 consteval
的要求, 编译器会报错. 你可能会联想到 SFINAE, 但是遗憾的是, SFINAE 只会影响重载的选择, 而 consteval
是在重载选择之后才会起作用的, 因此两者并无关系.
constinit
有的人可能认为 constinit
就是 constexpr
, 但是这是错误的. constinit
是要求变量在编译期初始化, 变量本身可以是非 const
的, 而 constexpr
则暗含了 1. 变量是 const
的, 2. 变量在编译期初始化. 例如:
1 | constinit int x = 0; |
上述代码是合法的, 因为 x
是 constinit
, 而不是 const
. 但是如果你把 constinit
换成 constexpr
, 那么编译器会报错. 好奇的你可能想问: 那这个关键词有什么用呢? 事实上, 它是为了解决一个问题而生的: 静态变量的初始化顺序问题.
在 C++ 中, 静态变量(你可以理解为全局变量)的初始化顺序是不确定的, 如果你有两个静态变量, 他们之间有依赖关系, 那么你可能会遇到问题. 例如:
1 | constexpr auto f() -> int { return 0; } |
这个初始化顺序是可能不确定的 (虽然实测几乎没出过错, 可能主要还是在跨编译单元的时候, 容易出问题), 有可能 x
先初始化, 也有可能 y
先初始化. 这时候, 你可以使用 constinit
来解决这个问题:
1 | consteval auto f() -> int { return 0; } |
同时, constinit
也可以支持 extern
的变量, 这是 constexpr
做不到的.
memory-safe
Modern C++ 一个突出的特点是, 内存安全. 其中, RAII 给我们的实现提供了巨大的便利. 而 memory safe
的实现, 其靠的就是 smart pointer. 在大多数的情况下, 我们都应该用智能指针替代裸指针.
unique_ptr
只能有一个拥有者的指针.shared_ptr
可以被多处拥有的指针, 需要注意防止循环引用.
说实话, 这些智能指针其实最大的便利不是访问的安全性, 用户依然可以随便就写出访问空指针的代码, 它们最大的好处还是, 实现了内存资源的自动回收. 这其实本质就是 RAII 思想的运用, 构造处获取资源, 析构处回收资源, 而移动语义则一般表示资源的转交 (比如 unique_ptr
, 在移动构造/赋值之后, 被移动的对象会被重置为空指针). 这些先进的理念也被后来很多更加先进的编程语言所采纳, 比如 rust
.
unique_ptr
Item 18, unique_ptr, make_unique, make_unique_for_overwrite
主要截取自 item 18. 对于独占 (尤其是不可复制) 的资源, 我们会用 unique_ptr
来管理内存.
一般来说, 如果使用 unique_ptr
我们会更加倾向于用一个工厂函数来构建这个 unique_ptr
对象, 例如标准库提供的 std::make_unique
. 当然, 如果你想支持一些更加复杂的功能, 特别是自定义删除器的时候, 很多时候需要自己手写, 可以用到 C++14 的 auto 返回类型 + lambda 函数来实现一些优雅的功能. 下面例子节选自 item 18.
1 | template <typename... Ts> |
当然, 如果你不是特别关心效率, 但是非常关心泛化性, 你也可以用 std::function<void(T *)>
来作为删除器, 支持任意的仿函数作为删除器, 缺点是会增加存储的空间. 同时, unique_ptr
也可以很方便的转化为 shared_ptr
, 提供了不少的便利性.
特别地, unique_ptr
允许指针转化为派生类或者基类的 unique_ptr
, 不过这很容易带来内存泄露的问题, 析构的时候可能会找到错误的析构函数. 在笔者的日常使用中, unique_ptr
经常会搭配抽象基类 (virtual class
), 虚类的 virtual
析构函数可以让 unique_ptr
在 destroy 指针指向的对象的时候找到正确析构, 避免资源泄露.
最后, 以 item 18 的 notes 总结一下:
std::unique_ptr
是轻量级、快速的、只可移动(move-only)
的管理专有所有权语义资源的智能指针- 默认情况,资源销毁通过
delete
实现, 但是支持自定义删除器. 有状态的删除器和函数指针会增加std::unique_ptr
对象的大小 - 将
std::unique_ptr
转化为std::shared_ptr
非常简单 std::unique_ptr
在大部分的使用情况, 性能和裸指针无异 (一句话, 我相信编译器优化).
Remark: 一般来说, 笔者不鼓励使用传入一个
new
出来的指针构造unique_ptr
, 除非有特殊的删除器, 否则笔者推荐全部用std::make_unique
来构造.
shared_ptr
Item 19, shared_ptr, enable_shared_from_this, make_shared, make_shared_for_overwrite
比起 unique_ptr
, shared_ptr
会更加灵活, 其通过了引用计数来控制了对象的生命周期, 允许高效的拷贝, 当然缺点就是可能成环导致资源泄露. 笔者并不经常使用 shared_ptr
, 这里就不过多介绍了. 为了避免循环引用, 需要把可能的循环中的一部分设置为 weak_ptr
, 具体用法请参考 cppreference.
shared_ptr
更加灵活的一点是, 它支持传入一个自定义的删除器作为构造参数的一部分, 而不需要像 unique_ptr
那样在模板里面显式指出来. 正因如此, 你可以放心的把派生类的 shared_ptr
转化为基类的 shared_ptr
, 不用担心基类不是 virtual
可能会带来资源泄露, 这是因为 shared_ptr
在一开始已经把对应指针类型的删除函数给 “记下来了”. 这自然是有开销的, 但是也能带来不小的便利.
特别地, 使用 shared_ptr
的时候, 一个常见的问题是把一个指针, 由两个 shared_ptr
来接管 (包括 unique_ptr
也会有这种问题), 或者错误的由 this
指针来构造一个 shared_ptr
(这并不会正确的构造一个指向同一个引用计数块的 shared_ptr
). 因此, 笔者认为无论如何, 在没有自定义删除器的情况下, 请尽一切可能使用 std::make_shared
来构造一个 shared_ptr
. 同时, 对于前面提到的从 this
构造 shared_ptr
的例子, 正确的做法是继承一个 std::enable_shared_from_this<T>
, 然后调用基类的 shared_from_this
函数. 下面的例子改变自 cppreference:
1 |
|
如果以上这些说法都没法说服你用 std::make_shared
, 那么笔者可以再告诉你一个有趣的小细节. shared_ptr
由两部分组成: 对象指针和控制块指针 (常见 gcc
和 clang
的实现, sizeof(shared_ptr<T>)
都是 16
即两个指针). 控制块比较特殊, 管理了对象的析构函数, 引用计数等一系列东西. 而如果你用 std::make_shared
, 那么在一般的实现中 (比如 gcc
), 它的控制块和对象会共用一大块存储, 而不需要申请两次空间. 试着运行一下以下的代码吧.
1 |
|
总结一下, shared_ptr
是一个非常强大而方便的管理指针数据的工具, 它维护了指针的析构器和引用计数, 它的引用计数甚至是线程安全的. 自然, 这会带来一些不必要的开销, 但是在很多时候, 灵活性带来的好处远胜于一些微不足道的开销带来的弊端.
一个非常非常非常常见的 use case 是 pimpl
. 具体来说, 我们在对象中维护了一个 std::shared_ptr<Impl>
, 但是 Impl
只有声明 (即 struct Impl
), 而实现处放在了 .cpp
文件中而不是在 .h
. 这是因为头文件在编译的时候, #include
的内容会被替换到文件里面, 而一个类的实现可能会包含很多其他的依赖 (比如标准库里面的 vector, unordered_set
, 或者是第三方库的一些代码). 如果我们把类的实现 (注意, 是类的实现, 而不仅仅是类的成员的实现) 放在了 .h
里面, 那么一旦你的类的结构发生了任何改变 (比如添加了一个函数, 或者删除了一个成员), 编译的时候, 所有依赖这个 .h
的 .cpp
文件都需要重新编译. 在一个庞大的项目里面, 这会极大地拖垮编译速度, 可能会慢到无法接受, 1 ~ 2 个小时都是有可能的. 这时候, 把类的结构完全分离到 .cpp
中 (包括内部成员和所有成员函数), 只对外暴露一些必要的接口或者说 API, 不仅可以极大地提升编译速度, 还能强制把接口和实现分离, 提高了代码的可读性.
这个问题的解决方案有一个是前向声明一个不完整类型 struct Impl;
, 而在传参的时候尽量用引用类型比如 const &
(虽然指针也行, 但是 Modern C++ 不提倡指针) (这里传值需要看到类型的完整定义). 同时, 对于一个类型的对象, 总需要有一个持有者吧. 这时候 shared_ptr
就可以作为这个持有者. 下面是著名开源项目 xgrammar 里面的一些代码:
1 | /*! |
1 | /*! |
smart pointer 的遗憾
smart pointer 在笔者看来, 已经极大地解决了 memory safe
的问题. 但它并没有解决一个信任链的问题. 那就是, 这个指针到底会不会是一个空指针. 无论是 shared_ptr
还是 unique_ptr
, 都有一个 “指向空” 的默认状态, 这就导致用户永远可能担心: 这个指针是不是空啊?
诚然, 这样似乎显得有些做作, 但这在一个超级大的多人协作的项目中, 是非常重要的. 你需要知道一个返回值的精确语义. 在大部分情况下, 多加一个 if (ptr != nullptr)
这样的判断并不会有太大的开销, 但是还是有很多性能非常重要的场景, 我们想要保证一个对象, 它维护了一个指向非空的指针, 同时维护了对象的所有权. 前者我们一般会用引用来直接代替 (引用的语义基本就是, 一个指向对象而非空的指针), 后者我们会用智能指针来管理. 那么两者兼顾呢? 似乎并没有一个工具能实现这一点. 事实上, C++ 中完成这个几乎注定是不可能的: 考虑一个这样的智能非空指针被移动之后的状态, 它不再持有所有权, 那么它的指针指向什么呢? 指向原来的对象将会带来垂悬引用, 这是极其危险的. 事实上, 除非编译器提供支持, 在编译期间做出静态检查, 否则我们永远无法跳出 “这个智能指针可能是空” 的难题, 要么通过高质量的代码和注释来清楚的告诉开发者 “这里一定不是空”, 要么就是强迫开发者使用前判断这个指针是否为空 (当然, 你也可以自己包一层智能指针, 所有涉及解引用的操作前插入检查, 如果为空则抛出异常, 相当于防御式编程). 这时候, rust 生命周期那套相关的东西, 以及强制的边界检查, 或许可能可以帮你避免 C++ 这边 “指针是不是空” 的心智负担, 而那些现代语言提供的语法糖 (比如结构化匹配 match
), 也能有助于读者写出更有可读性、更易于维护的代码.
总之, 笔者强烈建议所有写 Modern C++ 的读者认真的去了解、体验一下 rust, 这一定会在你的编程生涯留下浓墨重彩的一笔.
non-owning views
其实就是 std::span
和 std::string_view
. std::span
表示对于一个内存上连续区域的视图, 类似一个裸指针 + 区间长度, 而 std::string_view
则几乎就是 std::span<const char>
. 需要注意 non-owning 不代表元素不能修改, 只是表明这个区间的元素不是由持有 span
或者 string_view
的人来析构, 保证在持有 span
和 string_view
时区间尚未被析构而已. 要彻底搞明白生命周期, 笔者还是建议读者亲自实践一下 rust
.
笔者强烈建议尽可能用 std::span
替换所有的 const std::vector<T> &
, 用 std::string_view
替换一切的 const std::string &
(除非要求 null-terminated string). 这不仅是写法更加 modern 代码语义更精确, 它有时还能稍微提升一点代码性能, 并且比起裸指针, 提供了更好的封装.
Remark: 我想要
std::optional <T&>
, 请参考后文 optional 一章
type-safe
Modern C++ 一个突出的特点是, 我们要保证类型安全, 避免危险的 reinterpret_cast
防止错误的内存访问. 而标准库也提供了不少容器来帮助我们实现这一点.
需要注意的是, 这些实现并不一定是最高效的, 相信读者自然能想出更加 memory efficient 的实现, 但是在大部分不是那么关心性能/memory 的场景, 尤其是短小的、几乎一定会被 “内联优化” 的函数里面, 以下这些标准库组件能给用户带来极大的便利.
function
std::function
传入一个函数签名作为模板参数, 其是裸函数指针的一个替代品, 但是更加灵活. 对于任何一个实现了 operator()
并且参数满足函数签名的一个对象, 我们称之为仿函数 (functor), 这是重载运算符给我们带来的便利. 如果这个对象满足可以被复制 (e.g. 函数指针, 常见的 lambda 函数等等), 那么 std::function
就可以对应的构造.
1 | std::function<void(int)> f; // a function that takes in as an int as argument, return void |
这自然不是免费的午餐, 代价是它类似函数指针, 会引入间接跳转的开销, 而且会拷贝/移动一份对象, 这中间可能涉及堆上内存的分配 (虽然 gcc
和 clang
都有做 small object optimization). 同时, 经典的 std::function
要求对象满足可以复制的条件, 这也并不是适用于所有对象 (比如持有类似 std::unique_ptr
类似的唯一资源的对象), 这是因为 std::function
为了保证本身可以复制所做出的牺牲.
幸运的是, 如果我们希望得到一个内部对象只可移动 (即转交所有权) 而不需要可复制的 std::function
, 在 C++23 中有 move_only_function
可供选择.
事实上, std::function
内部需要持有一份对象, 这本身其实暗含了一种所有权, 也因此不可避免地需要构造/拷贝一份. 那么你可能会好奇了, 如果我们想有一种类似 std::string_view
或 std::span
那种视图一样不含所有权的结构, 应该怎么解决呢? 在 C
语言中, 常见的一种解决是传入一个内容指针 context
, 以及一个回调的函数指针 func
:
1 | void f(void *context, void(*func)(void *)) { |
在 C++ 中, 我们自然也可以自己实现一个类似的 function_view
, 只需要在涉及右值的时候处理好生命周期即可 (我们不应该保存右值的视图, 因为 rvalue
可能是 prvalue
, 在表达式结束后生命周期就结束被析构了). 这玩意网上的参考实现也很多, 这里就不多介绍了.
optional & variant
optional, variant, get, get_if, visit, holds_alternative
Remark: 需要 C++17
optional
表示一个值可能是不存在, 也可能是存在的. 常见的场景是查找一个元素是否存在, 如果存在则返回这个元素, 否则返回一个特殊的状态, 表示不存在.variant
则表示存储的 可以且必须 是某几种值中的一个, 可以认为是加强版的optional
.
可以看出, 这两个东西的存在就是为了取代 C 里面 union
的存在 (如果你还不知道, 可以自己先去了解一下). union
最大的问题, 是 RAII 资源管理相关的. 假如 union
里面的成员有析构函数, 那么在析构的时候, 应该调用哪个成员的析构函数呢? 处理不当, 非常容易造成资源泄露. 这时候, 我们就可以用到 variant
来管理了. 特别地, 如果只有 “有” 和 “没有” 两种状态, 那么我们可以用 optional
, 它提供了更准确的语义.
当然, 虽然笔者一直提倡使用标准库, 但标准库也不是十全十美的. 比如 optional
里面, 标准禁止了其直接存引用类型例如 std::optional<int>
(至少截至 C++23 如此). 仔细思考一下引用的语义是什么: 引用一个对象, 语义上等价于保证非空的指针解引用. 因此, optional
引用可以只存一个指针, 如果为空则表示 “没有引用”, 否则表示 “合法的引用”, 这完全是合情合理的. 它不会引入额外的开销 (甚至还能减少存储空间), 能提供更好的封装 (比起裸指针), 只可惜尚未进入标准库, 不过已经有 提案.
对于 optional
, 笔者推荐结合其成员函数 .and_then
, .or_else
, .transform
之类使用, 以获得 monad 的效果. 当然, 你也可以用 if (auto opt = func())
来分别实现 optional
非空和空的逻辑. optional
的解引用并没有做边界检查 (非空与 type safety 无关), 如果想要做检查, 请使用 .value
函数来获取内部得引用.
对于 variant
, 笔者推荐使用 std::visit
来遍历类型. std::hold_alternatives
一般只适用于 variant
里面类型不多, 或者只需要特判是不是某一两种特殊的类型 case. 通过 std::get
来访问 variant
是 type-safe 的, 不用担心访问到错误类型.
在构造 optional
的时候, 可以用 std::nullopt
表示空, 或者直接花括号 {}
默认构造为空, 或者用一个对应存储的类型. 如果你想要给已有的一个 optional
更新它的值, 除了可以用 =
, 也可以用 emplace
原地构造.
variant
类似, 但是默认构造会调用 variant
里面第一次类型的构造函数 (不一定存在, 此时 variant
不可默认构造). 一般来说, 如果希望 variant
也存在某种类似的 “空” 的状态, 我们会用 std::monostate
. 赋值和 emplace
类似 optional
.
1 | std::variant<std::monostate, int, float, std::string> v {}; // default monostate |
需要注意, optional
和 variant
不涉及堆内存分配, 所有数据都存在内部.
any
Remark: 需要 C++17
当你完全不确定可能的类型, 并且希望进一步增加未来的可拓展性, 完全 “擦除” 类型的时候, 你可以用 void *
. 它直接把类型完全抹去了, 但对应的, 在调用处, 你为了获取其确切类型, 只能用 std::any_cast
一个一个去判断.
any_cast
当传入的是 std::any
的指针的时候, 会返回一个指针, 如果为空表示 any
存储的不是这个类型的, 否则为指向对象的指针. 当传入的是 std::any
的左值或右值引用时, 如果不是这个类型则会抛出异常, 否则返回存储类型的值. any_cast
对于传入引用的情况, 会自动地选择返回时候是进行移动构造还是复制构造.
1 | std::any x; |
在赋值一个 any
的时候, 除了常见的 =
之外, 你也可以类似 optional
和 variant
, 使用 emplace
来原地构造, 减少潜在的移动和复制. 当然, 直接构造也可以用 std::make_any
.
由于不确定对象的大小, std::any
的构造往往涉及堆内存的分配, 不过编译器一般都有 small object optimization.
format
Remark: 需要 C++20
由于时间限制, 简单的介绍可以参考: C++ 20 部分特性尝鲜. 进阶请自行 cppref.
类型和模板的魔法
模板是 C++ 的核心特性. 模板本身就是图灵完备的, 它的功能非常强大. 当和 C 语言的宏结合在一起的时候, 他几乎能创造一切的其他语言. 当然, 这稍微有点夸张了, 但是模板的力量是非常强大的. 结合 C++17 的折叠表达式, 以及 C++11 的 lambda 函数, 你可以写出非常优雅的代码.
从 format 到模板推导
模板推导是非常令人头疼的一部分. 举例:
1 | template <typename T> |
你可能预期的是, T
能够自己转化为 int
(带来的是 1.0
被 cast 为 1
), 但是事实上, 这是不可能的. 遗憾的是, 这里的 vector<T>
和 T
共同参与了类型的推导, 因此 T
的类型不相同, 无法通过编译.
一个简单粗暴的解决方案是: 第二个参数也使用模板. 但这不是我们今天的主题. 事实上, 第二个类型可能也是依赖推导的模板类型, 比如 list<T>
, 但是实现了类型转化函数或者有其他的特殊要求等等. 针对我们现在的场景, 我们希望类型推导完全由 vector<T>
来决定. 这时候, 我们可以用到 std::type_identity_t
.
1 | template <typename T> |
他的原理是: std::type_identity_t
是一个模板别名, 实际是 type_identity<T>::type
. 而这里作为类的成员类型, 并不会参与推导, 因此 T
的类型完全由 vector<T>
决定. 这样, 我们就可以正确的推导出 T
的类型了. 这部分实际非常复杂, 具体请参考 cppreference CTAD. 这个在实践中的确被用到了, 可以参考 std::format
的实现.
在 std::format
中, format_string
是 consteval
的, 并且其含有实际 format 的类型作为模板参数, 这是为了编译期做出类型检查. 如果暴力的写, 它可能长这样:
1 | template <typename ...Args> |
这里, 我们需要避免 format_string
参与模板类型推导, 因为 Args
完全是由入参决定的. 这时候, 注意观察 format_string
的定义:
1 | template<typename... Args> |
这意味着, 在 using
的内层, 它用到了 type_identity_t
来避免了推导, 笔者可以在这里把 using
直接理解为 #define
, 即直接替换为 basic_format_string<char, type_identity_t<Args>...>
.
模板递归
一般来说, 模板递归需要用到特化, 这样的代码非常啰嗦.
1 | auto f() -> void { |
幸运的是, 在 C++17 中, 我们有了 if constexpr
, 这在一定程度上能减轻我们的负担:
1 | // helper class |
当然, 不要忘记了我们还有 lambda 函数和折叠表达式:
1 | template <typename _Tp, typename... _Args> |
模板 + constexpr
模板还可以和强大的 constexpr
协同工作. 通过 if constexpr
, 我们可以允许在输入模板参数不同的时候返回完全不一样的类型. 结合 decltype
, 我们甚至可以更方便的写出根据某些常量来推导类型, 从而写出比 std::conditional_t
更加直观的代码.
1 | template <int N> |
模板 + concept
SFINAE
是一个老功能了. 他的全称是: Substitution Failure Is Not An Error
. 他的作用是: 当模板参数推导失败时, 不会报错, 而是会继续尝试其他的模板. 例如:
1 | struct A { |
在这里, 如果模板类型 T
没有 type
成员, 那么第一个 f
会被忽略, 而继续尝试第二个 f
. 这就是 SFINAE 的作用. 其可以用于很多场景, 例如: 检查类型是否有某个成员, 检查类型是否满足某个特定的条件等等. 常见的搭配有 std::enable_if_t
, std::void_t
等等. 如果 SFINAE 匹配到多个成功的模板, 会选择特化程度最厉害的, 这个说法一听就不是很严谨, 具体细节还是请参考 cppreference.
然而, 在大部分情况下这样的代码可读性极差. 例如:
1 | // ensure T is an integral type, otherwise try other templates. |
幸运的是, C++20 的 concept
能够解决大部分这类问题, 其依然遵循的是 SFINAE
的原则, 但是使用更加直观的 requires
语句来明确指定模板的约束. concept
部分可以参考 这篇文章, 但是更推荐 cppreference.
对于上面那个例子, 可以简写为:
1 | template <std::integral T> |
无论如何, 可读性都比无 concept
的 SFINAE
强太多了. 关于 concept
的四种写法, 除了 cppreference, 也可以参考 C++ 20 部分特性尝鲜.