2025 年 7 月 8 日
编辑
这是一个不断改进的动态文档。如果它是一个开源(代码)项目,这将是 0.8 版。复制、使用、修改和创作衍生作品受 MIT 风格许可证的约束。为本项目做出贡献需要同意贡献者许可协议。有关详细信息,请参阅随附的 许可证 文件。我们将本项目提供给“友好用户”使用、复制、修改和衍生,希望能获得建设性的反馈。
我们非常欢迎对改进的意见和建议。我们计划在我们的理解和语言及可用库的改进中修改和扩展本文档。评论时,请注意 引言,其中概述了我们的目标和总体方法。贡献者列表 在此。
问题
您可以 阅读本指南的范围和结构的说明,或直接开始。
支持部分
您可以查看特定语言特性的规则示例
class
: 数据 – 不变式 – 成员 – 辅助函数 – 具体类型 – 构造函数、赋值和析构函数 – 层次结构 – 运算符重载concept
: 规则 – 在泛型编程中 – 模板参数 – 语义throw
– 默认 – 不需要 – explicit
– 委托 – virtual
class
: 何时使用 – 作为接口 – 析构函数 – 复制 – getter 和 setter – 多重继承 – 重载 – 切片 – dynamic_cast
throw
– 仅用于错误 – noexcept
– 最小化 try
– 如果没有异常怎么办?for
: 范围 for 和 for – for 和 while – for 初始器 – 空体 – 循环变量 – 循环变量类型 ???inline
: 小型函数 – 在头文件中{}
– lambda 表达式 – 默认成员初始化器 – 类成员 – 工厂函数public
, private
, 和 protected
: 信息隐藏 – 一致性 – protected
static_assert
: 编译时检查 – 和概念struct
: 用于组织数据 – 在没有不变式时使用 – 没有 private 成员template
: 抽象 – 容器 – 概念unsigned
: 和 signed – 位操作virtual
: 接口 – 非 virtual
– 析构函数 – 永远不要失败您可以查看用于表达规则的设计概念
本文档是关于如何良好使用 C++ 的一系列指南。本文档的目的是帮助人们有效利用现代 C++。我们所说的“现代 C++”是指有效使用 ISO C++ 标准(当前为 C++20,但我们的大部分建议也适用于 C++17、C++14 和 C++11)。换句话说,考虑到您现在就可以开始,您希望五年后您的代码是什么样子?十年后呢?
这些指南侧重于相对较高层级的问题,例如接口、资源管理、内存管理和并发。这些规则会影响应用程序架构和库设计。遵循这些规则将生成静态类型安全、无资源泄露且比当前代码能捕获更多编程逻辑错误的代码。而且它运行速度会很快——您可以负担得起正确做事。
我们不太关注低层级问题,例如命名约定和缩进风格。但是,任何能帮助程序员的主题都在讨论范围内。
我们最初的一套规则强调安全(各种形式)和简洁。它们很可能过于严格。我们预计不得不引入更多例外情况,以更好地适应现实世界的需求。我们也需要更多规则。
您会发现一些规则与您的期望相悖,甚至与您的经验相悖。如果我们没有建议您更改任何编码风格,我们就失败了!请尝试验证或反驳规则!特别是,我们非常希望有测量或更好的示例来支持我们的某些规则。
您会发现一些规则是显而易见的,甚至是微不足道的。请记住,指南的目的之一是帮助那些经验较少或来自不同背景或语言的人快速上手。
许多规则都是为了由分析工具支持而设计的。规则的违规将带有指向相关规则的引用(或链接)。我们不期望您在尝试编写代码之前记住所有规则。思考这些指南的一种方式是将其视为对工具的规范,而这些规范又可供人类阅读。
这些规则旨在逐步引入代码库。我们计划构建用于此的工具,并希望其他人也能做到。
我们非常欢迎对改进的意见和建议。我们计划在我们的理解和语言及可用库的改进中修改和扩展本文档。
这是一套现代 C++(当前为 C++20 和 C++17)的核心指南,同时考虑了未来可能的增强和 ISO 技术规范(TS)。目的是帮助 C++ 程序员编写更简单、更高效、更易于维护的代码。
引言摘要
所有 C++ 程序员。这包括 可能考虑 C 的程序员。
本文档的目的是帮助开发人员采用现代 C++(当前为 C++20 和 C++17),并在代码库中实现更统一的风格。
我们并不自以为是地认为所有这些规则都能有效地应用于所有代码库。升级旧系统是困难的。然而,我们确实相信,使用规则的程序比不使用规则的程序更不容易出错且更易于维护。通常,规则也导致更快/更容易的初始开发。据我们所知,这些规则生成的代码性能与旧的、更传统的技术相当或更好;它们旨在遵循零开销原则(“您不使用的,您就不付费”或“当您适当地使用抽象机制时,您的性能至少与手工编码使用低级语言构造一样好”)。将这些规则视为新代码的理想,在处理旧代码时利用的机会,并尽量接近这些理想。请记住
花时间理解指南规则对您的程序的影响。
这些指南是根据“子集超集”原则(Stroustrup05)设计的。它们不仅仅是定义了一个要使用的 C++ 子集(为了可靠性、安全性、性能等)。相反,它们强烈推荐使用一些简单的“扩展”(库组件),这些组件使 C++ 中最容易出错的特性变得多余,从而可以禁止它们(在我们的一套规则中)。
规则强调静态类型安全和资源安全。因此,它们强调范围检查、避免解引用 nullptr
、避免悬空指针以及系统化地使用异常(通过 RAII)的可能性。部分是为了实现这一点,部分是为了尽量减少晦涩的代码作为错误的来源,规则还强调简洁性和通过良好定义的接口隐藏必要的复杂性。
许多规则是规范性的。我们不满意那些仅仅说“不要这样做!”而没有提供替代方案的规则。其中一个后果是,一些规则只能通过启发式方法来支持,而不是精确且可机械验证的检查。其他规则阐述了普遍原则。对于这些更普遍的规则,更详细和具体的规则提供了部分检查。
这些指南涉及 C++ 及其使用的核心。我们预计大多数大型组织、特定应用领域,甚至大型项目都需要进一步的规则,可能还需要进一步的限制,以及更多的库支持。例如,硬实时程序员通常不能自由使用自由存储(动态内存),并且在选择库时会受到限制。我们鼓励将这些更具体的规则作为这些核心指南的附录来开发。构建您理想的小型基础库并使用它,而不是将您的编程水平降低到拟汇编代码。
这些规则旨在允许 逐步采用。
一些规则旨在提高各种形式的安全性,而另一些规则旨在降低事故的可能性,许多规则两者兼顾。旨在防止事故的规则通常会禁止完全合法的 C++。然而,当有两种表达思想的方式,其中一种已被证明是常见的错误来源,而另一种则不是,我们会尝试引导程序员转向后者。
规则不追求最小化或正交性。特别是,通用规则可能很简单,但不可执行。此外,理解通用规则的影响通常很困难。更专门的规则通常更容易理解和执行,但没有通用规则,它们只是一长串特殊情况。我们提供了旨在帮助新手以及支持专家使用的规则。有些规则可以完全执行,但有些是基于启发式方法的。
这些规则不是按顺序阅读的,就像一本书一样。您可以通过链接浏览它们。然而,它们主要预期用途是作为工具的目标。也就是说,一个工具会查找违规行为,然后返回指向被违反规则的链接。规则随后提供原因、潜在的违规后果示例以及建议的补救措施。
这些指南不旨在替代 C++ 的教程。如果您需要针对特定经验水平的教程,请参阅 参考文献。
这不是关于如何将旧 C++ 代码转换为更现代代码的指南。它旨在以具体的方式阐述新代码的思想。然而,请参阅 现代化部分了解一些可能的现代化/翻新/升级方法。重要的是,规则支持逐步采用:一次性完全转换大型代码库通常是不可行的。
这些指南不旨在在每一个语言技术细节上都是完整或精确的。关于语言定义问题的最终权威,包括所有规则的例外和所有特性,请参阅 ISO C++ 标准。
这些规则不旨在强迫您编写 C++ 的贫乏子集。它们绝不旨在定义一个,比如说,类似 Java 的 C++ 子集。它们不旨在定义一个单一的“唯一真正的 C++”语言。我们重视表达力和不受影响的性能。
规则并非价值中立。它们旨在使代码比现有的大多数 C++ 代码更简洁、更正确/更安全,而不会损失性能。它们旨在抑制那些与错误、虚假复杂性和糟糕性能相关的、完全有效的 C++ 代码。
规则的精确度不足以让一个人(或机器)在不思考的情况下遵循它们。执行部分力求做到这一点,但我们宁愿让一个规则或定义稍微模糊且开放解释,而不是精确地说明错误的东西。有时,精确性需要时间和经验。设计不是(还)一种数学。
规则并不完美。一个规则可能会因为禁止在特定情况下有用而造成损害。一个规则可能会因为未能禁止某些能够在特定情况下导致严重错误的东西而造成损害。一个规则可能会因为模糊、不明确、不可执行或允许所有解决方案而造成很大损害。完全满足“不造成伤害”的标准是不可能的。相反,我们的目标是更不雄心勃勃的:“为大多数程序员带来最大的好处”;如果您无法遵守某个规则,请反对它,忽略它,但不要将其稀释到失去意义。另外,请提出改进建议。
没有执行规则的代码库对于大型代码库来说是难以管理的。只有一小部分规则或特定用户社区才能执行所有规则。
所以,我们需要子集来满足各种需求。
我们想要帮助很多人、使代码更统一、并强烈鼓励人们更新代码的指南。我们希望鼓励最佳实践,而不是将所有选择留给个人选择和管理压力。理想情况是使用所有规则;这样可以获得最大的好处。
这加起来导致了不少困境。我们尝试通过工具来解决这些问题。每个规则都有一个执行部分,列出了执行的思路。执行可以通过代码审查、静态分析、编译器或运行时检查来完成。只要有可能,我们倾向于“机械”检查(人类缓慢、不准确且容易厌倦),以及静态检查。运行时检查只在没有其他选择的情况下才偶尔建议;我们不希望引入“分布式膨胀”。在适当的情况下,我们将规则标记(在执行部分)为相关规则组(称为“配置文件)的名称。一个规则可以属于多个配置文件,或不属于任何配置文件。作为开始,我们有几个配置文件,对应于常见需求(愿望、理想)
delete
或多次 delete
)和无对无效对象的访问(解引用 nullptr
,使用悬空引用)。配置文件旨在供工具使用,但也作为人类读者的辅助。我们不会将对执行部分的注释仅限于我们知道如何执行的内容;一些注释仅仅是希望,可能会启发一些工具构建者。
实现这些规则的工具应遵守以下语法以显式禁止某条规则
[[gsl::suppress("tag")]]
并可以选择带消息(遵循标准的 C++11 属性语法)
[[gsl::suppress("tag", justification: "message")]]
其中
"tag"
是一个字符串字面量,包含规则出现项的锚点名称(例如,对于 C.134,它是“Rh-public”),配置文件组名称(“type”、“bounds”或“lifetime”),或配置文件中的特定规则(type.4 或 bounds.2)。任何不属于这些的文本都应被拒绝。
"message"
是一个字符串字面量
每个规则(指南、建议)可以有几个部分
new
有些规则很难机械地检查,但它们都满足专家程序员无需太多麻烦就能发现许多违规行为的最低标准。我们希望“机械”工具随着时间的推移而改进,以接近专家程序员的察觉能力。此外,我们假设这些规则会随着时间的推移而进行改进,使其更加精确和可检查。
规则旨在简单,而不是仔细措辞以提及每一种替代方案和特殊情况。此类信息包含在替代方案段落和讨论部分。如果您不理解某条规则或不同意它,请访问其讨论部分。如果您觉得讨论缺失或不完整,请提交一个问题,说明您的担忧并可能附带一个相应的 PR。
示例是为了说明规则而编写的。
f
、base
和 x
之类的名称。vector
而不是 std::vector
。这不是语言手册。它旨在提供帮助,而不是完整、在技术细节上完全准确,或作为现有代码的指南。推荐的信息来源可以在 参考文献 中找到。
支持部分
这些章节不是正交的。
每个章节(例如,“P”代表“Philosophy”)和每个子章节(例如,“C.hier”代表“Class Hierarchies (OOP)”)都有一个缩写,以便于搜索和引用。主章节缩写也用于规则编号(例如,“C.11”代表“Make concrete types regular”)。
本节的规则非常笼统。
理念规则摘要
理念规则通常不能机械地检查。然而,反映这些理念的个别规则是可以的。没有理念基础,更具体/更详细/可检查的规则将缺乏基本原理。
编译器不读取注释(或设计文档),许多程序员(持之以恒地)也不读取。在代码中表达的内容具有定义明确的语义,并且(原则上)可以被编译器和其他工具检查。
class Date {
public:
Month month() const; // do
int month(); // don't
// ...
};
month 的第一个声明明确表示返回 Month,并且不修改 Date 对象的状态。第二个版本让读者猜测,并打开了更多未被捕获的 bug 的可能性。
此循环是 std::find 的受限形式
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
int index = -1; // bad, plus should use gsl::index
for (int i = 0; i < v.size(); ++i) {
if (v[i] == val) {
index = i;
break;
}
}
// ...
}
更清晰地表达意图的方式是
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
auto p = find(begin(v), end(v), val); // better
// ...
}
一个设计良好的库比直接使用语言特性更能表达意图(要做什么,而不仅仅是做什么)。
C++ 程序员应该了解标准库的基础知识,并适当地使用它。任何程序员都应该了解正在处理的项目的基础库的基础知识,并适当地使用它们。任何使用这些指南的程序员都应该了解指导支持库,并适当地使用它。
change_speed(double s); // bad: what does s signify?
// ...
change_speed(2.3);
更好的方法是明确 double 的含义(是新的速度还是旧速度的增量?),以及使用的单位
change_speed(Speed s); // better: the meaning of s is specified
// ...
change_speed(2.3); // error: no unit
change_speed(23_m / 10s); // meters per second
我们可以接受一个普通的(无单位的)double 作为增量,但这很容易出错。如果我们同时需要绝对速度和增量,我们会定义一个 Delta 类型。
总的来说非常困难。
这是一套编写 ISO 标准 C++ 的指南。
在某些环境中,扩展是必要的,例如访问系统资源。在这种情况下,应限制必要扩展的使用,并用非核心编码指南来控制其使用。如果可能,构建封装扩展的接口,以便在不支持这些扩展的系统上可以关闭或编译掉它们。
扩展通常没有严格定义的语义。即使是常见的、由多个编译器实现的扩展,也可能因为没有严格的标准定义而具有略微不同的行为和边界情况行为。只要充分使用任何此类扩展,预期的可移植性就会受到影响。
使用有效的 ISO C++ 并不保证可移植性(更不用说正确性了)。避免依赖未定义行为(例如,未定义的求值顺序),并注意具有实现定义含义的构造(例如,sizeof(int)
)。
在某些环境中,对标准 C++ 语言或库特性的使用有限制是必要的,例如,按照飞机控制软件标准的要求避免动态内存分配。在这种情况下,请使用为特定环境定制的这些编码指南的扩展来控制其(不)使用。
使用最新的 C++ 编译器(当前为 C++20 或 C++17),并设置不接受扩展的选项。
除非说明了某些代码的意图(例如,在名称或注释中),否则无法判断代码是否按预期执行。
gsl::index i = 0;
while (i < v.size()) {
// ... do something with v[i] ...
}
这里没有表达“只是”遍历 v 中元素的意图。暴露了索引的实现细节(因此可能被滥用),并且 i 的生命周期超出了循环的范围,这可能是有意或无意的。读者仅从这段代码无法得知。
更好的方式
for (const auto& x : v) { /* do something with the value of x */ }
现在,没有明确提及迭代机制,并且循环操作的是 const 元素的引用,因此不可能发生意外修改。如果需要修改,请说明
for (auto& x : v) { /* modify x */ }
有关 for 语句的更多详细信息,请参见 ES.71。有时更好的是,使用一个命名的算法。本示例使用了 Ranges TS 中的 for_each,因为它直接表达了意图
for_each(v, [](int x) { /* do something with the value of x */ });
for_each(par, v, [](int x) { /* do something with the value of x */ });
最后一种变体清楚地表明我们不关心 v 中元素的处理顺序。
程序员应该熟悉
替代表述:说明应该做什么,而不仅仅是怎样做。
一些语言构造比其他构造更能表达意图。
如果两个 int 的目的是作为 2D 点的坐标,请说明
draw_line(int, int, int, int); // obscure: (x1,y1,x2,y2)? (x,y,h,w)? ...?
// need to look up documentation to know
draw_line(Point, Point); // clearer
寻找有更好替代方案的常见模式
new
和delete
有很大的空间可以发挥创意和进行半自动化的程序转换。
理想情况下,程序应该是完全静态(编译时)类型安全的。不幸的是,这不可能。问题领域
这些领域是严重问题的根源(例如,崩溃和安全漏洞)。我们试图提供替代技术。
我们可以根据各个程序的需要和可行性,分别禁止、限制或检测个体问题类别。始终建议替代方案。例如
variant
(C++17中)span
(来自GSL)span
narrow
或narrow_cast
(来自GSL)代码清晰度和性能。您无需为在编译时捕获的错误编写错误处理程序。
// Int is an alias used for integers
int bits = 0; // don't: avoidable code
for (Int i = 1; i; i <<= 1)
++bits;
if (bits < 32)
cerr << "Int too small\n";
此示例未能实现其目标(因为溢出是未定义的),应替换为简单的static_assert
// Int is an alias used for integers
static_assert(sizeof(Int) >= 4); // do: compile-time check
或者更好的是,直接使用类型系统并将Int
替换为int32_t
。
void read(int* p, int n); // read max n integers into *p
int a[100];
read(a, 1000); // bad, off the end
更好
void read(span<int> r); // read into the range of integers r
int a[100];
read(a); // better: let the compiler figure out the number of elements
替代表述:不要将可以在编译时很好完成的事情推迟到运行时。
在程序中留下难以检测的错误,就等于是在招惹崩溃和错误的结果。
理想情况下,我们捕获所有错误(不是程序员逻辑中的错误),无论是在编译时还是运行时。无法在编译时捕获所有错误,并且通常无法承担在运行时捕获所有剩余错误。但是,我们应该努力编写原则上可以被检查的程序,只要有足够的资源(分析程序、运行时检查、机器资源、时间)。
// separately compiled, possibly dynamically loaded
extern void f(int* p);
void g(int n)
{
// bad: the number of elements is not passed to f()
f(new int[n]);
}
在这里,一个关键信息(元素的数量)被“模糊”得如此彻底,以至于静态分析可能变得不可行,而动态检查可能非常困难,当f()
是ABI的一部分时,我们无法“检测”该指针。我们可以将有用的信息嵌入自由存储区,但这需要对系统进行全局更改,甚至可能需要对编译器进行更改。我们这里有一个使错误检测非常困难的设计。
当然,我们可以将元素的数量与指针一起传递
// separately compiled, possibly dynamically loaded
extern void f2(int* p, int n);
void g2(int n)
{
f2(new int[n], m); // bad: a wrong number of elements can be passed to f()
}
将元素的数量作为参数传递比仅传递指针并依赖于某些(未说明的)约定来知道或发现元素的数量要好(也更常见)。然而(如所示),一个简单的拼写错误就可能引入一个严重的错误。f2()
两个参数之间的连接是约定俗成的,而不是显式的。
另外,f2()
应该delete
它的参数(或者调用者犯了第二个错误?),这是隐含的。
标准库资源管理指针在指向对象时未能传递大小
// separately compiled, possibly dynamically loaded
// NB: this assumes the calling code is ABI-compatible, using a
// compatible C++ compiler and the same stdlib implementation
extern void f3(unique_ptr<int[]>, int n);
void g3(int n)
{
f3(make_unique<int[]>(n), m); // bad: pass ownership and size separately
}
我们需要将指针和元素的数量作为一个整体对象传递
extern void f4(vector<int>&); // separately compiled, possibly dynamically loaded
extern void f4(span<int>); // separately compiled, possibly dynamically loaded
// NB: this assumes the calling code is ABI-compatible, using a
// compatible C++ compiler and the same stdlib implementation
void g3(int n)
{
vector<int> v(n);
f4(v); // pass a reference, retain ownership
f4(span<int>{v}); // pass a view, retain ownership
}
此设计将元素的数量作为对象的一个整体部分携带,因此不太可能出错,并且动态(运行时)检查始终可行,即使不总是负担得起。
我们如何转移所有权和验证使用所需的所有信息?
vector<int> f5(int n) // OK: move
{
vector<int> v(n);
// ... initialize v ...
return v;
}
unique_ptr<int[]> f6(int n) // bad: loses n
{
auto p = make_unique<int[]>(n);
// ... initialize *p ...
return p;
}
owner<int*> f7(int n) // bad: loses n and we might forget to delete
{
owner<int*> p = new int[n];
// ... initialize *p ...
return p;
}
避免“神秘”的崩溃。避免导致(可能未识别的)错误结果的错误。
void increment1(int* p, int n) // bad: error-prone
{
for (int i = 0; i < n; ++i) ++p[i];
}
void use1(int m)
{
const int n = 10;
int a[n] = {};
// ...
increment1(a, m); // maybe typo, maybe m <= n is supposed
// but assume that m == 20
// ...
}
这里我们在use1
中犯了一个小错误,该错误将导致数据损坏或崩溃。“(指针,计数)”风格的接口使increment1()
无法有效防御越界错误。如果我们能检查下标是否越界,那么错误要到访问p[10]
时才会发现。我们可以更早地检查并改进代码
void increment2(span<int> p)
{
for (int& x : p) ++x;
}
void use2(int m)
{
const int n = 10;
int a[n] = {};
// ...
increment2({a, m}); // maybe typo, maybe m <= n is supposed
// ...
}
现在,m <= n
可以在调用点(尽早)而不是稍后检查。如果我们只是一个拼写错误,将n
用作边界,代码可以进一步简化(消除了出错的可能性)
void use3(int m)
{
const int n = 10;
int a[n] = {};
// ...
increment2(a); // the number of elements of a need not be repeated
// ...
}
不要重复检查相同的值。不要将结构化数据作为字符串传递
Date read_date(istream& is); // read date from istream
Date extract_date(const string& s); // extract date from string
void user1(const string& date) // manipulate date
{
auto d = extract_date(date);
// ...
}
void user2()
{
Date d = read_date(cin);
// ...
user1(d.to_string());
// ...
}
日期被验证了两次(通过Date
构造函数),并作为字符字符串(非结构化数据)传递。
过度的检查可能会很昂贵。在某些情况下,尽早检查效率低下,因为您可能永远不需要该值,或者只可能需要其中一部分,而这一部分比整体更容易检查。同样,不要添加会改变接口渐近行为的有效性检查(例如,不要向平均复杂度为O(1)
的接口添加O(n)
检查)。
class Jet { // Physics says: e * e < x * x + y * y + z * z
float x;
float y;
float z;
float e;
public:
Jet(float x, float y, float z, float e)
:x(x), y(y), z(z), e(e)
{
// Should I check here that the values are physically meaningful?
}
float m() const
{
// Should I handle the degenerate case here?
return sqrt(x * x + y * y + z * z - e * e);
}
???
};
喷气式飞机的物理定律(e * e < x * x + y * y + z * z
)由于测量误差的可能性而不是不变的。
???
即使资源的缓慢增长,随着时间的推移也会耗尽这些资源的可用性。这对于长时间运行的程序尤其重要,但却是负责任的编程行为的一个基本组成部分。
void f(char* name)
{
FILE* input = fopen(name, "r");
// ...
if (something) return; // bad: if something == true, a file handle is leaked
// ...
fclose(input);
}
优先使用RAII
void f(char* name)
{
ifstream input {name};
// ...
if (something) return; // OK: no leak
// ...
}
另请参阅:资源管理部分
泄漏通常是“任何未被清理的东西”。更重要的分类是“任何无法再被清理的东西”。例如,在堆上分配一个对象,然后丢失指向该分配的最后一个指针。此规则不应被理解为要求必须在程序关闭期间返回长期存在的对象中的分配。例如,依赖系统保证的清理,如文件关闭和进程关闭时的内存去分配,可以简化代码。然而,依赖于隐式清理的抽象可以很简单,而且通常更安全。
强制执行生命周期安全配置文件可消除泄漏。当与RAII提供的资源安全结合时,它消除了对“垃圾回收”的需求(通过不生成垃圾)。将此与强制执行类型和边界配置文件结合起来,您将获得由工具保证的完整的类型和资源安全性。
owner
标记所有者。new
和delete
fopen
、malloc
和strdup
)这是C++。
您为实现目标(例如,开发速度、资源安全或简化测试)而投入的时间和空间是值得的。“追求效率的另一个好处是,这个过程迫使您更深入地理解问题。” - Alex Stepanov
struct X {
char ch;
int i;
string s;
char ch2;
X& operator=(const X& a);
X(const X&);
};
X waste(const char* p)
{
if (!p) throw Nullptr_error{};
int n = strlen(p);
auto buf = new char[n];
if (!buf) throw Allocation_error{};
for (int i = 0; i < n; ++i) buf[i] = p[i];
// ... manipulate buffer ...
X x;
x.ch = 'a';
x.s = string(n); // give x.s space for *p
for (gsl::index i = 0; i < x.s.size(); ++i) x.s[i] = buf[i]; // copy buf into x.s
delete[] buf;
return x;
}
void driver()
{
X x = waste("Typical argument");
// ...
}
是的,这是一个漫画,但我们在生产代码中看到了每一个单独的错误,甚至更糟。请注意,X
的布局保证至少浪费了6个字节(而且很可能更多)。复制操作的虚假定义禁用了移动语义,因此返回操作很慢(请注意,此处不保证返回值优化,RVO)。buf
的new
和delete
使用是多余的;如果我们确实需要一个本地字符串,我们应该使用本地string
。还有几个性能错误和不必要的复杂性。
void lower(zstring s)
{
for (int i = 0; i < strlen(s); ++i) s[i] = tolower(s[i]);
}
这实际上是来自生产代码的一个例子。我们可以看到,在我们的条件中,我们有i < strlen(s)
。这个表达式将在循环的每一次迭代中进行求值,这意味着strlen
必须在每次循环中遍历字符串来发现其长度。虽然字符串内容正在改变,但假设tolower
不会影响字符串的长度,因此最好将长度缓存到循环外部,并且不要在每次迭代中产生这种开销。
一次性的浪费很少是显著的,即使是显著的,通常也很容易被专家消除。然而,如果浪费普遍存在于代码库中,则可能非常显著,而专家并不总是像我们希望的那样容易获得。此规则(及其支持的更具体的规则)的目标是消除大多数与在C++使用相关的浪费,然后再发生。在此之后,我们可以关注与算法和需求相关的浪费,但这超出了这些指南的范围。
许多更具体的规则旨在实现简洁和消除不必要浪费的总体目标。
operator++
或operator--
函数的未使用的返回值。优先使用前缀形式。(注意:“用户定义的非默认”旨在减少噪音。如果实际使用中噪音仍然很大,请审查此强制执行。)常量比变量更容易推理。不可变的东西不会意外改变。有时不可变性可以带来更好的优化。您不能在常量上进行数据竞争。
参见常量和不可变性
混乱的代码更容易隐藏错误,编写起来也更困难。良好的接口更容易使用,也更安全。混乱的低级代码会滋生更多此类代码。
int sz = 100;
int* p = (int*) malloc(sizeof(int) * sz);
int count = 0;
// ...
for (;;) {
// ... read an int into x, exit loop if end of file is reached ...
// ... check that x is valid ...
if (count == sz)
p = (int*) realloc(p, sizeof(int) * sz * 2);
p[count++] = x;
// ...
}
这是低级的、冗长的、易于出错的。例如,我们“忘记”了测试内存耗尽并将新值赋给sz
。相反,我们可以使用vector
vector<int> v;
v.reserve(100);
// ...
for (int x; cin >> x; ) {
// ... check that x is valid ...
v.push_back(x);
}
标准库和GSL是这种哲学的例子。例如,与其处理数组、联合体、类型转换、棘手的生命周期问题、gsl::owner
等来实现vector
、span
、lock_guard
和future
等关键抽象,不如使用由比我们通常拥有的更多的时间和专业知识的人设计和实现的库。类似地,我们可以也应该设计和实现更专业的库,而不是让用户(通常是我们自己)面临反复正确处理低级代码的挑战。这是这些指南基础的超集子集原理的一个变体。
有很多事情“由机器”做得更好。计算机不会疲倦或厌烦重复的任务。我们通常有更好的事情要做,而不是重复做例行任务。
运行静态分析器以验证您的代码是否遵循您希望它遵循的指南。
参见
还有许多其他类型的工具,如源代码存储库、构建工具等,但这超出了这些指南的范围。
要小心,不要过度依赖过于复杂或过于专业的工具链。这些可能会使您本来可移植的代码变得不可移植。
使用设计良好、文档完善、支持良好的库可以节省时间和精力;它的质量和文档很可能比您花大部分时间进行实现所能达到的要好。库的成本(时间、精力、金钱等)可以由许多用户分摊。广泛使用的库比单个应用程序更有可能保持最新并移植到新系统。因此,如果您的应用程序领域存在合适的库,请使用它。
std::sort(begin(v), end(v), std::greater<>());
除非您是排序算法专家并且有充足的时间,否则这比您为特定应用程序编写的任何东西都更有可能正确并运行得更快。您需要一个不使用标准库(或您的应用程序使用的任何基础库)的理由,而不是一个使用它的理由。
默认使用
如果没有为重要领域设计良好、文档完善、支持良好的库,那么也许您应该设计和实现它,然后使用它。
接口是程序两部分之间的合同。精确说明服务的提供者和该服务的用户需要什么至关重要。拥有良好(易于理解、鼓励高效使用、不易出错、支持测试等)的接口可能是代码组织最重要的方面。
接口规则摘要
const
的全局变量Expects()
来表达前置条件Ensures()
来表达后置条件T*
)或引用(T&
)转移所有权not_null
另请参阅:
正确性。接口中未说明的假设很容易被忽略且难以测试。
通过全局(命名空间范围)变量(调用模式)来控制函数行为是隐含的且可能令人困惑。例如
int round(double d)
{
return (round_up) ? ceil(d) : d; // don't: "invisible" dependency
}
调用者不会清楚round(7.2)
两个调用的含义可能给出不同的结果。
有时我们通过环境变量来控制一组操作的细节,例如正常与详细输出或调试与优化。非局部控制的使用可能令人困惑,但仅控制已定义语义的实现细节。
通过非局部变量(例如errno
)进行报告很容易被忽略。例如
// don't: no test of fprintf's return value
fprintf(connection, "logging: %d %d %d\n", x, y, s);
如果连接中断导致没有日志输出会怎样?参见I.???。
替代方案:抛出异常。异常不能被忽略。
替代表述:避免通过非局部或隐式状态通过接口传递信息。请注意,非const
成员函数通过其对象的状态将信息传递给其他成员函数。
替代表述:接口应该是函数或一组函数。函数可以是函数模板,一组函数可以是类或类模板。
const
的全局变量非const
的全局变量会隐藏依赖关系,并使这些依赖关系容易受到不可预测的更改。
struct Data {
// ... lots of stuff ...
} data; // non-const data
void compute() // don't
{
// ... use data ...
}
void output() // don't
{
// ... use data ...
}
还有谁可能会修改data
?
警告:全局对象的初始化不是完全排序的。如果您使用全局对象,请用常量初始化它。请注意,即使对于const
对象,也可能出现未定义的初始化顺序。
全局对象通常比单例更好。
全局常量很有用。
反对全局变量的规则也适用于命名空间范围变量。
替代方案:如果您使用全局(更一般地说,命名空间范围)数据来避免复制,请考虑将数据作为const
对象的引用传递。另一个解决方案是将数据定义为某个对象的状态,并将操作定义为成员函数。
警告:注意数据竞争:如果一个线程可以访问非局部数据(或通过引用传递的数据),而另一个线程执行被调用者,我们可能会发生数据竞争。对可变数据的每个指针或引用都是潜在的数据竞争。
使用全局指针或引用来访问和更改非const、且否则是非全局的数据,并不是非const全局变量的更好替代方案,因为这不能解决隐藏的依赖关系或潜在的竞争条件问题。
您无法对不可变数据进行竞争条件。
参考:参见调用函数的规则。
规则是“避免”,而不是“不要使用”。当然会有(罕见的)例外,例如cin
、cout
和cerr
。
(简单)报告所有在命名空间范围内声明的非const
变量以及指向非const数据的全局指针/引用。
单例基本上是伪装的复杂全局对象。
class Singleton {
// ... lots of stuff to ensure that only one Singleton object is created,
// that it is initialized properly, etc.
};
单例的变体有很多。这是问题的一部分。
如果您不希望全局对象更改,请将其声明为const
或constexpr
。
您可以使用最简单的“单例”(如此简单,以至于通常不被认为是单例)来在首次使用时进行初始化(如果有的话)
X& myX()
{
static X my_x {3};
return my_x;
}
这是解决初始化顺序相关问题最有效的方法之一。在多线程环境中,静态对象的初始化不会引入竞争条件(除非您在构造函数中粗心地访问共享对象)。
请注意,局部static
的初始化并不意味着竞争条件。但是,如果X
的销毁涉及需要同步的操作,我们必须使用不那么简单的解决方案。例如
X& myX()
{
static auto p = new X {3};
return *p; // potential leak
}
现在有人必须以某种恰当的线程安全方式delete
该对象。这很容易出错,所以我们除非
myX
在多线程代码中,X
对象需要被销毁(例如,因为它释放了资源),并且X
的析构函数的代码需要同步。如果您(像许多人一样)将单例定义为一个只创建一个对象的类,那么像myX
这样的函数不是单例,并且这个有用的技术也不是“禁止单例”规则的例外。
总的来说非常困难。
singleton
的类。类型是最简单也是最好的文档,它们有明确定义的含义,从而提高了可读性,并在编译时进行检查。此外,类型精确的代码通常可以得到更好的优化。
考虑
void pass(void* data); // weak and under-qualified type void* is suspicious
调用者不确定允许什么类型,并且由于未指定const
,因此数据是否可能被修改。请注意,所有指针类型都隐式转换为void*
,因此调用者很容易提供此值。
被调用者必须static_cast
数据到一个未经验证的类型才能使用它。这很容易出错且冗长。
仅在C++中无法描述的设计中使用const void*
来传递数据。考虑改用variant
或指向基类的指针。
替代方案:通常,模板参数可以消除void*
,将其转换为T*
或T&
。对于通用代码,这些T
可以是通用或概念约束的模板参数。
考虑
draw_rect(100, 200, 100, 500); // what do the numbers specify?
draw_rect(p.x, p.y, 10, 20); // what units are 10 and 20 in?
很清楚调用者描述的是一个矩形,但是不清楚它们与哪个部分相关。此外,int
可以携带任意形式的信息,包括许多单位的值,所以我们必须猜测这四个int
的含义。最有可能的是,前两个是x
,y
坐标对,但后两个是什么?
注释和参数名称可以提供帮助,但我们可以明确一些
void draw_rectangle(Point top_left, Point bottom_right);
void draw_rectangle(Point top_left, Size height_width);
draw_rectangle(p, Point{10, 20}); // two corners
draw_rectangle(p, Size{10, 20}); // one corner and a (height, width) pair
显然,我们无法通过静态类型系统捕获所有错误(例如,第一个参数应该是左上角点这一事实留给约定(命名和注释))。
考虑
set_settings(true, false, 42); // what do the numbers specify?
参数类型及其值并未传达正在指定什么设置或这些值是什么含义。
此设计更加明确、安全且易于阅读
alarm_settings s{};
s.enabled = true;
s.displayMode = alarm_settings::mode::spinning_light;
s.frequency = alarm_settings::every_10_seconds;
set_settings(s);
对于一组布尔值,请考虑使用标志enum
;一种表达一组布尔值的模式。
enable_lamp_options(lamp_option::on | lamp_option::animate_state_transitions);
在以下示例中,接口不清楚time_to_blink
的含义:秒?毫秒?
void blink_led(int time_to_blink) // bad -- the unit is ambiguous
{
// ...
// do something with time_to_blink
// ...
}
void use()
{
blink_led(2);
}
std::chrono::duration
类型有助于明确时间持续的单位。
void blink_led(milliseconds time_to_blink) // good -- the unit is explicit
{
// ...
// do something with time_to_blink
// ...
}
void use()
{
blink_led(1500ms);
}
函数也可以这样编写,使其接受任何时间单位。
template<class rep, class period>
void blink_led(duration<rep, period> time_to_blink) // good -- accepts any unit
{
// assuming that millisecond is the smallest relevant unit
auto milliseconds_to_blink = duration_cast<milliseconds>(time_to_blink);
// ...
// do something with milliseconds_to_blink
// ...
}
void use()
{
blink_led(2s);
blink_led(1500ms);
}
void*
作为参数或返回类型。bool
参数。参数具有可能限制其在被调用者中正确使用的含义。
考虑
double sqrt(double x);
这里x
必须是非负的。类型系统不能(轻松自然地)表达这一点,所以我们必须使用其他方法。例如
double sqrt(double x); // x must be non-negative
一些前置条件可以表示为断言。例如
double sqrt(double x) { Expects(x >= 0); /* ... */ }
理想情况下,Expects(x >= 0)
应该是sqrt()
接口的一部分,但这不容易做到。目前,我们将其放在定义(函数体)中。
参考:Expects()
在GSL中进行了描述。
优先使用正式的需求规范,如Expects(p);
。如果这不可行,请在注释中使用英文文本,例如// 序列 [p:q) 使用 < 进行排序
。
大多数成员函数的前置条件是某个类不变量成立。该不变量由构造函数建立,并且必须由从类外部调用的每个成员函数在退出时重新建立。我们不需要为每个成员函数提及它。
(无法强制执行)
另请参阅:传递指针的规则。???
Expects()
来表达前置条件为了清楚地表明该条件是前置条件并启用工具使用。
int area(int height, int width)
{
Expects(height > 0 && width > 0); // good
if (height <= 0 || width <= 0) my_error(); // obscure
// ...
}
前置条件可以通过多种方式陈述,包括注释、if
语句和assert()
。这可能使它们难以与常规代码区分开,难以更新,难以被工具操作,并且可能具有不正确的语义(您是否总是希望在调试模式下中止,而在生产运行时检查内容?)。
前提条件应属于接口,而非实现。但目前我们还没有相应的语言设施。一旦语言支持可用(例如,请参阅契约提案),我们将采纳标准的版本的前置条件、后置条件和断言。
Expects()
也可用于检查算法中间的某个条件。
不,使用 unsigned
并不是规避“确保值非负”问题的良策。
(无法强制执行)发现断言前提条件的各种方法并不可行。在缺乏语言设施的情况下,对易于识别的(assert()
)进行警告的价值值得怀疑。
检测对结果的误解,并可能捕获错误的实现。
考虑
int area(int height, int width) { return height * width; } // bad
这里,我们(不加思索地)省略了前提条件说明,因此未明确 height 和 width 必须为正数。我们还省略了后置条件说明,因此显而易见,该算法(height * width
)对于大于最大整数的区域是错误的。可能会发生溢出。考虑使用
int area(int height, int width)
{
auto res = height * width;
Ensures(res > 0);
return res;
}
考虑一个著名的安全漏洞
void f() // problematic
{
char buffer[MAX];
// ...
memset(buffer, 0, sizeof(buffer));
}
没有后置条件说明缓冲区应被清零,并且优化器消除了看似冗余的 memset()
调用。
void f() // better
{
char buffer[MAX];
// ...
memset(buffer, 0, sizeof(buffer));
Ensures(buffer[0] == 0);
}
后置条件通常以注释的形式非正式陈述,说明函数的作用;Ensures()
可用于使其更加系统化、可见化和可检查。
当后置条件与返回值不直接相关时,它们尤其重要,例如使用的某个数据结构的状态。
考虑一个操作 Record
的函数,该函数使用 mutex
来避免竞态条件。
mutex m;
void manipulate(Record& r) // don't
{
m.lock();
// ... no m.unlock() ...
}
在这里,我们“忘记”说明 mutex
应该被释放,因此我们不知道未能确保释放 mutex
是一个 bug 还是一个特性。陈述后置条件会使其更清晰。
void manipulate(Record& r) // postcondition: m is unlocked upon exit
{
m.lock();
// ... no m.unlock() ...
}
bug 现在显而易见了(但仅对阅读注释的人来说)。
更好的是,使用 RAII 来确保后置条件(“必须释放锁”)在代码中得到强制执行。
void manipulate(Record& r) // best
{
lock_guard<mutex> _ {m};
// ...
}
理想情况下,后置条件应在接口/声明中陈述,以便用户可以轻松看到它们。只有与用户相关的后置条件才能在接口中陈述。仅与内部状态相关的后置条件应在定义/实现中。
(无法强制执行)这是一个哲学指南,在一般情况下无法直接检查。对于许多工具链,存在特定领域的检查器(例如,持有锁的检查器)。
Ensures()
来表达后置条件为了清楚地表明条件是一个后置条件,并支持工具使用。
void f()
{
char buffer[MAX];
// ...
memset(buffer, 0, MAX);
Ensures(buffer[0] == 0);
}
后置条件可以用多种方式陈述,包括注释、if
语句和 assert()
。这会使它们难以与常规代码区分,难以更新,难以被工具处理,并且可能具有错误的语义。
替代方案:形式为“此资源必须被释放”的后置条件最好通过 RAII 来表达。
理想情况下,Ensures
应该作为接口的一部分,但这并不容易做到。目前,我们将其放在定义(函数体)中。一旦语言支持可用(例如,请参阅契约提案),我们将采用标准版本的前置条件、后置条件和断言。
(无法强制执行)发现断言后置条件的各种方法并不可行。在缺乏语言设施的情况下,对易于识别的(assert()
)进行警告的价值值得怀疑。
使接口精确规范,并在(不太遥远的)未来进行编译时检查。
使用 C++20 风格的要求规范。例如:
template<typename Iter, typename Val>
requires input_iterator<Iter> && equality_comparable_with<iter_value_t<Iter>, Val>
Iter find(Iter first, Iter last, Val v)
{
// ...
}
如果任何非可变参数模板参数未由概念约束(在其声明中或在 requires
子句中提到),则发出警告。
不应该可以忽略错误,因为这可能导致系统或计算处于未定义(或意外)的状态。这是错误的主要来源。
int printf(const char* ...); // bad: return negative number if output fails
template<class F, class ...Args>
// good: throw system_error if unable to start the new thread
explicit thread(F&& f, Args&&... args);
什么是错误?
错误意味着函数无法实现其声明的目的(包括建立后置条件)。忽略错误的调用代码可能导致错误的结果或未定义的系统状态。例如,无法连接到远程服务器本身并不是错误:服务器可能因为各种原因拒绝连接,因此自然的做法是返回一个调用方应始终检查的结果。但是,如果无法建立连接被视为错误,那么失败就应该抛出异常。
许多传统的接口函数(例如,UNIX 信号处理程序)使用错误代码(例如,errno
)来报告实际上是状态码而不是错误。您对此没有好的替代方案,因此调用这些代码并不违反规则。
如果您无法使用异常(例如,因为您的代码充斥着旧式原始指针使用,或者因为存在硬实时约束),请考虑使用返回值对的样式。
int val;
int error_code;
tie(val, error_code) = do_something();
if (error_code) {
// ... handle the error or exit ...
}
// ... use val ...
这种样式不幸地导致了未初始化变量。自 C++17 起,“结构化绑定”功能可用于直接从返回值初始化变量。
auto [val, error_code] = do_something();
if (error_code) {
// ... handle the error or exit ...
}
// ... use val ...
我们不认为“性能”是不能使用异常的有效理由。
另请参见:I.5 和 I.7 用于报告前置条件和后置条件违反。
errno
。T*
)或引用(T&
)传递所有权如果对调用方或被调用方拥有对象的归属有任何疑问,就会发生内存泄漏或过早销毁。
考虑
X* compute(args) // don't
{
X* res = new X{};
// ...
return res;
}
谁来删除返回的 X
?如果 compute
返回一个引用,问题将更难发现。考虑按值返回结果(如果结果很大,则使用移动语义)。
vector<double> compute(args) // good
{
vector<double> res(10000);
// ...
return res;
}
替代方案:使用“智能指针”传递所有权,例如 unique_ptr
(用于独占所有权)和 shared_ptr
(用于共享所有权)。但是,这不如返回对象本身优雅,并且通常效率不如返回对象本身,因此仅在需要引用语义时才使用智能指针。
替代方案:有时,由于 ABI 兼容性要求或资源不足,无法修改旧代码。在这种情况下,使用支持库中的 owner
来标记拥有指针。
owner<X*> compute(args) // It is now clear that ownership is transferred
{
owner<X*> res = new X{};
// ...
return res;
}
这告诉分析工具 res
是一个 owner。也就是说,它的值必须被 delete
或转移到另一个 owner,就像这里通过 return
所做的那样。
owner
在资源句柄的实现中也有类似的用法。
作为原始指针(或迭代器)传递的每个对象都被假定为调用方拥有,因此其生命周期由调用方处理。换一种说法:与指针传递 API 相比,所有权转移 API 相对较少,因此默认是“无所有权转移”。
owner<T>
的原始指针进行 delete
给出警告。建议使用标准库资源句柄或 owner<T>
。reset
或显式 delete
owner
指针的情况发出警告。new
的返回值或具有 owner
返回值的函数调用的返回值被赋给原始指针或非 owner
引用,则发出警告。not_null
帮助避免解引用 nullptr
错误。通过避免重复检查 nullptr
来提高性能。
int length(const char* p); // it is not clear whether length(nullptr) is valid
length(nullptr); // OK?
int length(not_null<const char*> p); // better: we can assume that p cannot be nullptr
int length(const char* p); // we must assume that p can be nullptr
通过在源代码中说明意图,实现者和工具可以提供更好的诊断,例如通过静态分析找到某些类别的错误,并执行优化,例如删除分支和空指针测试。
not_null
定义在支持库中。
指向 char
的指针指向 C 风格字符串(以零结尾的字符字符串)的假设仍然是隐式的,并且是潜在的混淆和错误来源。优先使用 czstring
而不是 const char*
。
// we can assume that p cannot be nullptr
// we can assume that p points to a zero-terminated array of characters
int length(not_null<czstring> p);
注意:length()
当然是 std::strlen()
的伪装。
nullptr
,则警告它应该被声明为 not_null
。nullptr
,则警告返回类型应声明为 not_null
。(指针、大小)风格的接口容易出错。此外,普通指针(数组)必须依赖于某种约定,以便被调用方能够确定大小。
考虑
void copy_n(const T* p, T* q, int n); // copy from [p:p+n) to [q:q+n)
如果 q
指向的数组中的元素少于 n
个会怎样?那么,我们会覆盖一些可能无关的内存。如果 p
指向的数组中的元素少于 n
个会怎样?那么,我们会读取一些可能无关的内存。两者都是未定义行为,并且是潜在的非常棘手的 bug。
考虑使用显式 span。
void copy(span<const T> r, span<T> r2); // copy r to r2
考虑
void draw(Shape* p, int n); // poor interface; poor code
Circle arr[10];
// ...
draw(arr, 10);
将 10
作为 n
参数可能是一个错误:最常见的约定是假定 [0:n)
,但这 nowhere 已说明。更糟糕的是 draw()
调用编译了:数组到指针存在隐式转换(数组衰减),然后 Circle
到 Shape
又存在一次隐式转换。 draw()
无法安全地迭代该数组:它不知道元素的大小。
替代方案:使用支持类来确保元素数量正确,并防止危险的隐式转换。例如:
void draw2(span<Circle>);
Circle arr[10];
// ...
draw2(span<Circle>(arr)); // deduce the number of elements
draw2(arr); // deduce the element type and array size
void draw3(span<Shape>);
draw3(arr); // error: cannot convert Circle[10] to span<Shape>
这个 draw2()
将相同的信息传递给 draw()
,但明确了它应该是 Circle
范围的事实。请参阅 ???。
使用 zstring
和 czstring
来表示 C 风格的以零结尾的字符串。但是,在这样做时,请使用 std::string_view
或GSL 中的 span<char>
来防止范围错误。
复杂的初始化可能导致执行顺序不确定。
// file1.c
extern const X x;
const Y y = f(x); // read x; write y
// file2.c
extern const Y y;
const X x = g(y); // read y; write x
由于 x
和 y
在不同的翻译单元中,f()
和 g()
的调用顺序是不确定的;一个将访问未初始化的 const
。这表明全局(命名空间范围)对象的初始化顺序问题不仅限于全局变量。
初始化顺序问题在并发代码中变得特别难以处理。通常最好完全避免全局(命名空间范围)对象。
constexpr
函数的全局变量的初始化程序。extern
对象的全局变量的初始化程序。过多的参数会增加混淆的可能性。与替代方案相比,传递大量参数通常成本很高。
函数拥有过多参数的两个最常见原因是:
缺失抽象。 缺少一个抽象,因此复合值被作为单独的元素传递,而不是作为强制执行不变量的单个对象。这不仅增加了参数列表,还会导致错误,因为组件值不再受到强制不变量的保护。
违反“一个函数,一个职责”。 该函数试图完成不止一项工作,应该进行重构。
标准库 merge()
处于我们可以舒适处理的极限。
template<class InputIterator1, class InputIterator2, class OutputIterator, class Compare>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result, Compare comp);
请注意,这是因为上述问题 1 – 缺失抽象。STL 没有传递范围(抽象),而是传递了迭代器对(未封装的组件值)。
这里,我们有四个模板参数和六个函数参数。为了简化最频繁和最简单的用法,可以将比较参数默认为 <
。
template<class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result);
这并没有减少总的复杂性,但它减少了呈现给许多用户的表面复杂性。为了真正减少参数数量,我们需要将参数捆绑到更高级别的抽象中。
template<class InputRange1, class InputRange2, class OutputIterator>
OutputIterator merge(InputRange1 r1, InputRange2 r2, OutputIterator result);
将参数分组到“捆绑包”中是减少参数数量和增加检查机会的通用技术。
或者,我们可以使用标准库概念来定义需要可用于合并的三个类型。
template<class In1, class In2, class Out>
requires mergeable<In1, In2, Out>
Out merge(In1 r1, In2 r2, Out result);
安全配置文件建议替换
void f(int* some_ints, int some_ints_length); // BAD: C style, unsafe
替换为
void f(gsl::span<int> some_ints); // GOOD: safe, bounds-checked
在这里,使用抽象具有安全性和鲁棒性优势,并且自然也减少了参数数量。
有多少参数才算太多?尝试少于四个(4)参数。有些函数用四个单独的参数来表达最好,但不多。
替代方案:使用更好的抽象:将参数分组到有意义的对象中,并传递对象(按值或按引用)。
替代方案:使用默认参数或重载,以便最常见的调用形式可以使用更少的参数完成。
类型相同的相邻参数很容易被错误地交换。
考虑
void copy_n(T* p, T* q, int n); // copy from [p:p + n) to [q:q + n)
这是 K&R C 风格接口的一个棘手变种。很容易颠倒“to”和“from”参数。
对“from”参数使用 const
。
void copy_n(const T* p, T* q, int n); // copy from [p:p + n) to [q:q + n)
如果参数顺序不重要,则没有问题。
int max(int a, int b);
不要将数组作为指针传递,而是传递表示范围的对象(例如,span
)。
void copy_n(span<const T> p, span<T> q); // copy from p to q
将 struct
定义为参数类型,并相应地命名这些参数的字段。
struct SystemParams {
string config_file;
string output_path;
seconds timeout;
};
void initialize(SystemParams p);
这使得该调用的未来读者易于理解,因为参数通常是在调用站点按名称填写的。
只有接口的设计者才能充分解决此指南违规的根源。
(简单)如果两个连续的参数具有相同的类型,则发出警告。
我们仍在寻找更简单的强制执行方法。
空的抽象类(没有非静态成员数据)比具有状态的基类更可能稳定。
你就是知道 Shape
会在某个地方出现 :-)
class Shape { // bad: interface class loaded with data
public:
Point center() const { return c; }
virtual void draw() const;
virtual void rotate(int);
// ...
private:
Point c;
vector<Point> outline;
Color col;
};
这将强制每个派生类计算一个中心点——即使那不是微不足道的,并且中心点从未被使用。同样,并非所有 Shape
都有 Color
,并且许多 Shape
s 在没有由 Point
序列定义的轮廓的情况下表示效果最好。使用抽象类更好。
class Shape { // better: Shape is a pure interface
public:
virtual Point center() const = 0; // pure virtual functions
virtual void draw() const = 0;
virtual void rotate(int) = 0;
// ...
// ... no data members ...
// ...
virtual ~Shape() = default;
};
(简单)如果指向类 C
的指针/引用被赋给指向 C
的基类的指针/引用,并且基类包含数据成员,则发出警告。
不同的编译器实现了不同的类、异常处理、函数名称和其他实现细节的二进制布局。
在某些平台上,常见的 ABI 正在出现,让您可以摆脱更严苛的限制。
如果您使用单个编译器,则可以在接口中使用完整的 C++。这可能需要升级到新编译器版本后重新编译。
(无法强制执行)很难可靠地识别接口在哪里构成了 ABI 的一部分。
由于私有数据成员参与类布局,私有成员函数参与重载解析,因此对这些实现细节的更改需要重新编译使用它们的类的所有用户。持有实现指针(Pimpl)的非多态接口类可以隔离类的用户免受其实现更改的影响,但代价是额外的间接引用。
接口 (widget.h)
class widget {
class impl;
std::unique_ptr<impl> pimpl;
public:
void draw(); // public API that will be forwarded to the implementation
widget(int); // defined in the implementation file
~widget(); // defined in the implementation file, where impl is a complete type
widget(widget&&) noexcept; // defined in the implementation file
widget(const widget&) = delete;
widget& operator=(widget&&) noexcept; // defined in the implementation file
widget& operator=(const widget&) = delete;
};
实现 (widget.cpp)
class widget::impl {
int n; // private data
public:
void draw(const widget& w) { /* ... */ }
impl(int n) : n(n) {}
};
void widget::draw() { pimpl->draw(*this); }
widget::widget(int n) : pimpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) noexcept = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) noexcept = default;
有关此 idiom 的权衡和其他实现细节,请参阅 GOTW #100 和 cppreference。
(无法强制执行)很难可靠地识别接口在哪里构成了 ABI 的一部分。
为了保持代码的简洁和安全。有时,出于逻辑或性能原因,需要丑陋、不安全或容易出错的技术。如果是这样,请将其保持在局部,而不是“感染”接口,从而使更大的程序员群体不得不了解其细微差别。实现复杂性应尽可能不通过接口泄露到用户代码中。
考虑一个程序,它根据某种输入(例如,main
的参数)从文件、命令行或标准输入中消耗输入。我们可能会写:
bool owned;
owner<istream*> inp;
switch (source) {
case std_in: owned = false; inp = &cin; break;
case command_line: owned = true; inp = new istringstream{argv[2]}; break;
case file: owned = true; inp = new ifstream{argv[2]}; break;
}
istream& in = *inp;
这违反了禁止未初始化变量的规则、忽略所有权的规则以及禁止魔术常数的规则。特别是,有人必须记住在某个地方写入:
if (owned) delete inp;
我们可以通过使用具有特殊删除器的 unique_ptr
来处理这个特定示例,该删除器不对 cin
做任何事情,但这对于新手来说很复杂(他们很容易遇到这个问题),而且这个例子是一个更普遍问题的示例,其中我们希望考虑为静态的属性(这里是所有权)需要不频繁地在运行时处理。常见、最频繁和最安全的情况可以通过静态方式处理,因此我们不想为这些情况增加成本和复杂性。但是,我们也必须处理不常见、不太安全且必然成本更高的案例。此类示例在 [Str15] 中进行了讨论。
因此,我们编写了一个类:
class Istream { [[gsl::suppress("lifetime")]]
public:
enum Opt { from_line = 1 };
Istream() { }
Istream(czstring p) : owned{true}, inp{new ifstream{p}} {} // read from file
Istream(czstring p, Opt) : owned{true}, inp{new istringstream{p}} {} // read from command line
~Istream() { if (owned) delete inp; }
operator istream&() { return *inp; }
private:
bool owned = false;
istream* inp = &cin;
};
现在,istream
所有权的动态性已被封装。假设在实际代码中会添加一些检查来处理潜在的错误。
函数指定一个操作或计算,将系统从一个一致的状态带到下一个一致的状态。它是程序的基本构建块。
应该可以为函数命名,指定其参数的要求,并清楚地说明参数与结果之间的关系。实现不是规范。尝试思考函数的作用以及它如何工作。函数是大多数接口中最关键的部分,因此请参阅接口规则。
函数规则摘要
函数定义规则
constexpr
noexcept
T*
或 T&
参数而不是智能指针参数传递表达式规则
const
的引用传递const
的引用传递X&&
传递并 std::move
该参数TP&&
传递并仅 std::forward
该参数T*
而不是 T&
参数传递语义规则
T*
或 owner<T*>
来指定单个对象not_null<T>
来指示“null”不是有效值span<T>
或 span_p<T>
来指定半开序列zstring
或 not_null<zstring>
来指定 C 风格字符串unique_ptr<T>
来传递所有权shared_ptr<T>
来共享所有权T*
以指示位置(仅限)T&
T&&
int
是 main()
的返回类型T&
std::move(local)
const T
其他函数规则
this
或任何类数据成员的 lambda 时,不要使用 [=]
默认捕获va_arg
参数函数与 lambda 和函数对象有很强的相似性。
函数定义是一个函数声明,它还指定了函数的实现,即函数体。
将常用代码提取出来可以使代码更具可读性,更有可能被重用,并限制复杂代码中的错误。如果某事是一个明确定义的操作,则将其与周围代码分开并为其命名。
void read_and_print(istream& is) // read and print an int
{
int x;
if (is >> x)
cout << "the int is " << x << '\n';
else
cerr << "no int on input\n";
}
read_and_print
几乎所有方面都是错误的。它读取,它写入(到固定的 ostream
),它写入错误消息(到固定的 ostream
),它只处理 int
。没有可重用的地方,逻辑上分离的操作被混合在一起,局部变量在其逻辑使用结束后仍然在作用域内。对于一个很小的例子,这看起来还可以,但如果输入操作、输出操作和错误处理更复杂,混乱的纠缠可能会变得难以理解。
如果您编写了一个非标准的 lambda,并且可能在多个地方使用它,请通过将其赋值给一个(通常是非局部的)变量来为其命名。
sort(a, b, [](T x, T y) { return x.rank() < y.rank() && x.value() < y.value(); });
命名该 lambda 会将表达式分解为其逻辑部分,并为 lambda 的含义提供一个强烈的提示。
auto lessT = [](T x, T y) { return x.rank() < y.rank() && x.value() < y.value(); };
sort(a, b, lessT);
最短的代码不总是对性能或可维护性最好的。
循环体,包括用作循环体的 lambda,很少需要命名。然而,大的循环体(例如,几十行或几十页)可能是一个问题。规则 保持函数简短且简单 暗示“保持循环体简短”。同样,用作回调参数的 lambda 有时并非微不足道,但不太可能被重用。
执行单一操作的函数更易于理解、测试和重用。
考虑
void read_and_print() // bad
{
int x;
cin >> x;
// check for errors
cout << x << "\n";
}
这是一个与特定输入绑定的庞然大物,永远找不到另一个(不同的)用途。相反,将函数分解为合适的逻辑部分并进行参数化。
int read(istream& is) // better
{
int x;
is >> x;
// check for errors
return x;
}
void print(ostream& os, int x)
{
os << x << "\n";
}
现在可以在需要的地方组合这些。
void read_and_print()
{
auto x = read(cin);
print(cout, x);
}
如果需要,我们可以进一步模板化数据类型、I/O 机制、对错误的响应等上的 read()
和 print()
。示例
auto read = [](auto& input, auto& value) // better
{
input >> value;
// check for errors
};
void print(auto& output, const auto& value)
{
output << value << "\n";
}
tuple
。大函数难以阅读,更有可能包含复杂代码,并且更有可能在比最小范围大的范围内拥有变量。具有复杂控制结构的函数更有可能冗长,并且更有可能隐藏逻辑错误
考虑
double simple_func(double val, int flag1, int flag2)
// simple_func: takes a value and calculates the expected ASIC output,
// given the two mode flags.
{
double intermediate;
if (flag1 > 0) {
intermediate = func1(val);
if (flag2 % 2)
intermediate = sqrt(intermediate);
}
else if (flag1 == -1) {
intermediate = func1(-val);
if (flag2 % 2)
intermediate = sqrt(-intermediate);
flag1 = -flag1;
}
if (abs(flag2) > 10) {
intermediate = func2(intermediate);
}
switch (flag2 / 10) {
case 1: if (flag1 == -1) return finalize(intermediate, 1.171);
break;
case 2: return finalize(intermediate, 13.1);
default: break;
}
return finalize(intermediate, 0.);
}
这太复杂了。您怎么知道所有可能的替代方案都已正确处理?是的,它也违反了其他规则。
我们可以重构
double func1_muon(double val, int flag)
{
// ???
}
double func1_tau(double val, int flag1, int flag2)
{
// ???
}
double simple_func(double val, int flag1, int flag2)
// simple_func: takes a value and calculates the expected ASIC output,
// given the two mode flags.
{
if (flag1 > 0)
return func1_muon(val, flag2);
if (flag1 == -1)
// handled by func1_tau: flag1 = -flag1;
return func1_tau(-val, flag1, flag2);
return 0.;
}
“不适合屏幕”通常是“太大”的一个很好的实际定义。应将一到五行函数视为正常。
将大函数分解成更小、更内聚且命名的函数。简单的小函数易于内联,尤其是在函数调用的开销很大时。
constexpr
constexpr
是告诉编译器允许编译时求值的必要条件。
(臭名昭著的)阶乘
constexpr int fac(int n)
{
constexpr int max_exp = 17; // constexpr enables max_exp to be used in Expects
Expects(0 <= n && n < max_exp); // prevent silliness and overflow
int x = 1;
for (int i = 2; i <= n; ++i) x *= i;
return x;
}
这是 C++14。对于 C++11,请使用 fac()
的递归形式。
constexpr
不保证编译时求值;它仅保证如果程序员需要,或者编译器决定进行优化,则该函数可以对常量表达式参数在编译时进行求值。
constexpr int min(int x, int y) { return x < y ? x : y; }
void test(int v)
{
int m1 = min(-1, 2); // probably compile-time evaluation
constexpr int m2 = min(-1, 2); // compile-time evaluation
int m3 = min(-1, v); // run-time evaluation
constexpr int m4 = min(-1, v); // error: cannot evaluate at compile time
}
不要尝试使所有函数都 constexpr
。大多数计算最好在运行时进行。
任何可能最终依赖于高级运行时配置或业务逻辑的 API 都不应声明为 constexpr
。此类自定义无法由编译器求值,任何依赖于该 API 的 constexpr
函数都必须重构或放弃 constexpr
。
不可能也不必要。如果将一个非 constexpr
函数调用在需要常量的地方,编译器会报错。
inline
一些优化器擅长在没有程序员提示的情况下进行内联,但不要依赖它。请测量!在过去 40 多年里,我们一直被承诺编译器可以在没有人类提示的情况下比人类更好地进行内联。我们仍在等待。指定内联(显式地,或在类定义内编写成员函数时隐式地)可以鼓励编译器做得更好。
inline string cat(const string& s, const string& s2) { return s + s2; }
除非您确定它不会改变,否则不要将 inline
函数放入旨在作为稳定接口的内容中。内联函数是 ABI 的一部分。
constexpr
暗示 inline
。
在类定义中定义的成员函数默认是 inline
的。
函数模板(包括类模板的成员函数 A<T>::function()
和成员函数模板 A::function<T>()
)通常定义在头文件中,因此是内联的。
如果函数包含三个以上的语句并且可以声明为外部定义(例如类成员函数),则考虑将其声明为外部定义。
noexcept
如果预期不会抛出异常,则不能假定程序能够处理该错误,应尽快终止程序。将函数声明为 noexcept
通过减少备用执行路径的数量来帮助优化器。它还可以加快失败后的退出速度。
将 noexcept
应用于完全用 C 或任何其他没有异常的语言编写的函数。C++ 标准库对 C 标准库中的所有函数都隐式执行此操作。
constexpr
函数在运行时求值时可能会抛出异常,因此您可能需要为其中一些函数使用条件 noexcept
。
您甚至可以在可能抛出异常的函数上使用 noexcept
。
vector<string> collect(istream& is) noexcept
{
vector<string> res;
for (string s; is >> s;)
res.push_back(s);
return res;
}
如果 collect()
内存不足,程序就会崩溃。除非程序被精心设计为能够应对内存耗尽,否则这可能正是正确的做法;terminate()
可能会生成合适的错误日志信息(但内存耗尽后很难做任何巧妙的事情)。
在决定是否将函数标记为 noexcept
时,您必须了解代码运行的执行环境,特别是考虑到异常和分配的问题。旨在完全通用的代码(如标准库和其他此类实用代码)需要支持可以在其中有意义地处理 bad_alloc
异常的环境。然而,大多数程序和执行环境无法有意义地处理分配失败,而在这些情况下中止程序是对分配失败最干净、最简单的响应。如果您知道您的应用程序代码无法响应分配失败,那么即使在进行分配的函数上添加 noexcept
也是合适的。
换句话说:在大多数程序中,大多数函数都可能抛出异常(例如,因为它们使用了 new
,调用了执行此操作的函数,或者使用了通过抛出异常报告失败的库函数),因此在不考虑可能发生的异常是否可以处理的情况下,不要到处乱加 noexcept
。
noexcept
对频繁使用、低级函数最有价值(也最明确正确)。
析构函数、swap
函数、移动操作和默认构造函数永远不应抛出异常。另请参见 C.44。
对于基虚函数和公共接口的一部分函数,必须谨慎,因为声明一个函数为 noexcept
是在建立一个当前和未来实现都必须遵守的保证。对于虚函数,所有重写者也必须是 noexcept
,并且从函数中删除 noexcept
可能会破坏调用函数。
noexcept
但又不能抛出异常的低级函数。swap
、move
、析构函数和默认构造函数。T*
或 T&
参数而不是智能指针传递智能指针会转移或共享所有权,仅应在预期所有权语义时使用。不需要操作生命周期的函数应改用原始指针或引用。
通过智能指针传递会限制函数的用法,只能由使用智能指针的调用者使用。需要 widget
的函数应该能够接受任何 widget
对象,而不仅仅是那些生命周期由特定类型的智能指针管理的。
传递共享智能指针(例如 std::shared_ptr
)会产生运行时开销。
// accepts any int*
void f(int*);
// can only accept ints for which you want to transfer ownership
void g(unique_ptr<int>);
// can only accept ints for which you are willing to share ownership
void g(shared_ptr<int>);
// doesn't change ownership, but requires a particular ownership of the caller
void h(const unique_ptr<int>&);
// accepts any int
void h(int&);
// callee
void f(shared_ptr<widget>& w)
{
// ...
use(*w); // only use of w -- the lifetime is not used at all
// ...
};
// caller
shared_ptr<widget> my_widget = /* ... */;
f(my_widget);
widget stack_widget;
f(stack_widget); // error
// callee
void f(widget& w)
{
// ...
use(w);
// ...
};
// caller
shared_ptr<widget> my_widget = /* ... */;
f(*my_widget);
widget stack_widget;
f(stack_widget); // ok -- now this works
我们可以静态地捕获许多悬空指针的常见情况(请参阅 生命周期安全配置文件)。函数参数自然地在函数调用生命周期内存在,因此具有更少的生命周期问题。
operator->
或 operator*
)是可复制的,但函数仅调用 operator*
、operator->
或 get()
中的任何一个时,应发出警告。建议改用 T*
或 T&
。operator->
或 operator*
的类型),该参数是可复制/可移动的,但在函数体中从未被复制/移动过,并且从未被修改过,也没有被传递给可能这样做的另一个函数。这意味着所有权语义未被使用。建议改用 T*
或 T&
。另请参阅:
纯函数更容易推理,有时更容易优化(甚至并行化),有时可以进行记忆化。
template<class T>
auto square(T t) { return t * t; }
不可能。
可读性。抑制未使用的参数警告。
widget* find(const set<widget>& s, const widget& w, Hint); // once upon a time, a hint was used
允许参数无名是在 20 世纪 80 年代初引入以解决此问题的。
如果参数是条件未使用的,请使用 [[maybe_unused]]
属性进行声明。例如:
template <typename Value>
Value* find(const set<Value>& s, const Value& v, [[maybe_unused]] Hint h)
{
if constexpr (sizeof(Value) > CacheSize)
{
// a hint is used only if Value is of a certain size
}
}
标记已命名的未使用参数。
文档、可读性、重用机会。
struct Rec {
string name;
string addr;
int id; // unique identifier
};
bool same(const Rec& a, const Rec& b)
{
return a.id == b.id;
}
vector<Rec*> find_id(const string& name); // find all records for "name"
auto x = find_if(vr.begin(), vr.end(),
[&](Rec& r) {
if (r.name.size() != n.size()) return false; // name to compare to is in n
for (int i = 0; i < r.name.size(); ++i)
if (tolower(r.name[i]) != tolower(n[i])) return false;
return true;
}
);
这里有一个有用的函数(不区分大小写的字符串比较),因为当 lambda 参数变大时,通常会出现这种情况。
bool compare_insensitive(const string& a, const string& b)
{
if (a.size() != b.size()) return false;
for (int i = 0; i < a.size(); ++i) if (tolower(a[i]) != tolower(b[i])) return false;
return true;
}
auto x = find_if(vr.begin(), vr.end(),
[&](Rec& r) { return compare_insensitive(r.name, n); }
);
或者可能(如果您更喜欢避免对 n 的隐式名称绑定)
auto cmp_to_n = [&n](const string& a) { return compare_insensitive(a, n); };
auto x = find_if(vr.begin(), vr.end(),
[](const Rec& r) { return cmp_to_n(r.name); }
);
无论是函数、lambda 还是运算符。
for_each
和类似控制流算法的参数。这使代码简洁,并且比替代方案具有更好的局部性。
auto earlyUsersEnd = std::remove_if(users.begin(), users.end(),
[](const User &a) { return a.id > 100; });
即使 lambda 只使用一次,命名 lambda 也有助于提高清晰度。
有多种方法可以将参数传递给函数并返回值。
使用“不寻常且巧妙”的技术会引起意外,减慢其他程序员的理解速度,并鼓励错误。如果您确实觉得需要超出常用技术的优化,请进行测量以确保它确实是一种改进,并进行文档/注释,因为改进可能不具有可移植性。
下表总结了以下指南 F.16-21 中的建议。
普通参数传递
高级参数传递
仅在证明有需求后才使用高级技术,并用注释记录该需求。
关于字符序列的传递,请参阅 字符串。
要使用 shared_ptr
类型表达共享所有权,请遵循 R.34、R.35 和 R.36,而不是遵循 F.16-21。
const
的引用传递两者都让调用者知道函数不会修改参数,并且两者都允许由右值初始化。
“便宜复制”取决于机器架构,但两个或三个字(双精度数、指针、引用)通常最好按值传递。当复制便宜时,没有什么比复制的简单性和安全性更优越的了,而且对于小对象(最多两个或三个字)来说,它也比按引用传递更快,因为它不需要额外的间接访问就能从函数中访问。
void f1(const string& s); // OK: pass by reference to const; always cheap
void f2(string s); // bad: potentially expensive
void f3(int x); // OK: Unbeatable
void f4(const int& x); // bad: overhead on access in f4()
对于高级用途(仅限),当您确实需要优化传递给“仅输入”参数的右值时
&&
传递。请参阅 F.18。const&
(对于左值)传递外,还可以添加一个重载,该重载按 &&
(对于右值)传递参数,并在函数体中将其 std::move
到其目的地。本质上,这会重载一个“将要移动”;请参阅 F.18。int multiply(int, int); // just input ints, pass by value
// suffix is input-only but not as cheap as an int, pass by const&
string& concatenate(string&, const string& suffix);
void sink(unique_ptr<widget>); // input only, and moves ownership of the widget
避免“晦涩的技巧”,例如为了效率而将参数按 T&&
传递。关于按 &&
传递的性能优势的大多数传言都是错误的或不稳定的(但请参阅 F.18 和 F.19)。
可以假定引用是指向有效对象(语言规则)。没有有效的“空引用”。如果您需要可选值的概念,请使用指针、std::optional
或用于表示“无值”的特殊值。
2 * sizeof(void*)
时发出警告。建议改用对 const
的引用。const
的引用传递的参数大小小于或等于 2 * sizeof(void*)
时发出警告。建议改用按值传递。const
的引用传递的参数被 move
时发出警告。要使用 shared_ptr
类型表达共享所有权,请根据函数是否无条件接受参数的引用来遵循 R.34 或 R.36。
const
的引用传递这向调用者清楚地表明对象将被修改。
void update(Record& r); // assume that update writes to r
一些用户定义的和标准库类型,例如 span<T>
或迭代器,可以 按值便宜复制,这样做具有可修改的(in-out)引用语义。
void increment_all(span<int> a)
{
for (auto&& e : a)
++e;
}
一个 T&
参数可以向函数传递信息,也可以从函数中传出信息。因此 T&
可能是一个 in-out 参数。这本身可能是一个问题和错误的来源。
void f(string& s)
{
s = "New York"; // non-obvious error
}
void g()
{
string buffer = ".................................";
f(buffer);
// ...
}
这里,g()
的编写者为 f()
提供了一个缓冲区来填充,但 f()
只是替换了它(开销比简单的字符复制稍高)。如果 g()
的编写者错误地假定了 buffer
的大小,则可能会发生逻辑错误。
const
参数的引用,这些参数在写入之前不会被读取,并且其类型可以被便宜地返回;它们应该是“输出”返回值。const
参数被 move
时发出警告。X&&
传递并 std::move
参数这很高效,并且可以在调用站点消除错误:X&&
绑定到右值,如果传递左值,则需要在调用站点显式 std::move
。
void sink(vector<int>&& v) // sink takes ownership of whatever the argument owned
{
// usually there might be const accesses of v here
store_somewhere(std::move(v));
// usually no more use of v here; it is moved-from
}
请注意,std::move(v)
使 store_somewhere()
有可能将 v
留在一个已移动的状态。这可能很危险。
唯一可移动且便宜可移动的唯一所有权类型,例如 unique_ptr
,也可以按值传递,这样写起来更简单,并达到相同的效果。按值传递会产生一个额外的(便宜的)移动操作,但首先要优先考虑简单性和清晰性。
例如:
template<class T>
void sink(std::unique_ptr<T> p)
{
// use p ... possibly std::move(p) onward somewhere else
} // p gets destroyed
如果“将要移动”的参数是 shared_ptr
,请遵循 R.34 并按值传递 shared_ptr
。
X&&
参数(其中 X
不是模板类型参数名),其中函数体在不使用 std::move
的情况下使用它们。TP&&
传递并仅 std::forward
参数如果对象将被传递给其他代码,而不是直接由本函数使用,我们希望使本函数对参数的 const
性和右值性保持中立。
在这种情况下,并且仅在这种情况下,将参数设置为 TP&&
,其中 TP
是模板类型参数——它既 *忽略* 又 *保留* const
性和右值性。因此,任何使用 TP&&
的代码都隐式声明它本身不关心变量的 const
性和右值性(因为它被忽略),但打算将其值传递给关心 const
性和右值性(因为它被保留)的其他代码。作为参数使用时 TP&&
是安全的,因为从调用者传递的任何临时对象将在函数调用持续时间内存在。类型为 TP&&
的参数应几乎始终通过 std::forward
在函数体内传递。
通常,您在每个静态控制流路径上转发整个参数(或参数包,使用 ...
)一次。
template<class F, class... Args>
inline decltype(auto) invoke(F&& f, Args&&... args)
{
return forward<F>(f)(forward<Args>(args)...);
}
有时您可以逐个部分转发复合参数,每个子对象在每个静态控制流路径上一次。
template<class PairLike>
inline auto test(PairLike&& pairlike)
{
// ...
f1(some, args, and, forward<PairLike>(pairlike).first); // forward .first
f2(and, forward<PairLike>(pairlike).second, in, another, call); // forward .second
}
TP&&
参数(其中 TP
是一个模板类型参数名),并且除了在每个静态路径上一次 std::forward
它之外,还对它执行任何其他操作,或者在每个静态路径上一次通过不同的数据成员限定地 std::forward
它。返回值是自我文档化的,而 &
可以是 in-out 或 out-only,并且容易被误用。
这包括大型对象,如标准容器,它们使用隐式移动操作来提高性能并避免显式内存管理。
如果您有多个值要返回,请 使用元组 或类似的多个成员类型。
// OK: return pointers to elements with the value x
vector<const int*> find_all(const vector<int>&, int x);
// Bad: place pointers to elements with value x in-out
void find_all(const vector<int>&, vector<const int*>& out, int x);
包含许多(单独便宜移动的)元素的 struct
aggregate 移动起来可能很昂贵。
unique_ptr
或 shared_ptr
返回对象。array<BigTrivial>
),请考虑将其分配到自由存储区并返回一个句柄(例如 unique_ptr
),或者将其传递到一个非 const
目标对象的引用中进行填充(用作输出参数)。std::string
、std::vector
):将其视为 in/out 参数并按引用传递。假设 Matrix
具有移动操作(可能通过将其元素保留在 std::vector
中)
Matrix operator+(const Matrix& a, const Matrix& b)
{
Matrix res;
// ... fill res with the sum ...
return res;
}
Matrix x = m1 + m2; // move constructor
y = m3 + m3; // move assignment
返回值优化不处理赋值情况,但移动赋值可以。
struct Package { // exceptional case: expensive-to-move object
char header[16];
char load[2024 - 16];
};
Package fill(); // Bad: large return value
void fill(Package&); // OK
int val(); // OK
void val(int&); // Bad: Is val reading its argument
const
参数的引用,并且其类型可以被便宜地返回;它们应该作为“输出”返回值。返回值是“仅输出”值的自我文档化。请注意,C++ 确实有多个返回值,通过约定使用类似元组的类型(struct
、array
、tuple
等),可能还具有调用站点上的结构化绑定(C++17)的额外便利性。如果可能,优先使用命名的 struct
。否则,tuple
在变长模板中很有用。
// BAD: output-only parameter documented in a comment
int f(const string& input, /*output only*/ string& output_data)
{
// ...
output_data = something();
return status;
}
// GOOD: self-documenting
struct f_result { int status; string data; };
f_result f(const string& input)
{
// ...
return {status, something()};
}
C++98 的标准库在某些地方使用了这种风格,通过在一些函数中返回 pair
。例如,给定一个 set<string> my_set
,考虑:
// C++98
pair<set::iterator, bool> result = my_set.insert("Hello");
if (result.second)
do_something_with(result.first); // workaround
使用 C++17,我们可以使用“结构化绑定”为每个成员命名。
if (auto [ iter, success ] = my_set.insert("Hello"); success)
do_something_with(iter);
在现代 C++ 中,具有有意义名称的 struct
更为常见。例如,请参阅 ranges::min_max_result
、from_chars_result
等。
有时,我们需要将一个对象传递给一个函数来操作它的状态。在这种情况下,按引用传递对象 T&
通常是正确的技术。显式地将 in-out 参数作为返回值传回通常是不必要的。例如:
istream& operator>>(istream& in, string& s); // much like std::operator>>()
for (string s; in >> s; ) {
// do something with line
}
在这里,s
和 in
都用作 in-out 参数。我们将 in
按(非 const
)引用传递,以便能够操作其状态。我们传递 s
以避免重复分配。通过重用 s
(按引用传递),我们仅在需要扩展 s
的容量时才分配新内存。这种技术有时被称为“调用者分配的输出”模式,对于需要进行自由存储分配的类型(如 string
和 vector
)特别有用。
作为比较,如果我们把所有值都作为返回值来传递,我们会这样写:
struct get_string_result { istream& in; string s; };
get_string_result get_string(istream& in) // not recommended
{
string s;
in >> s;
return { in, move(s) };
}
for (auto [in, s] = get_string(cin); in; s = get_string(in).s) {
// do something with string
}
我们认为这样写不够优雅,性能也大大降低。
对于此规则(F.21)的严格解释,例外情况实际上不是例外,因为它依赖于 in-out 参数,而不是规则中提到的普通 out 参数。但是,我们更倾向于明确而不是含糊。
在大多数情况下,返回一个特定的、用户定义的类型很有用。例如:
struct Distance {
int value;
int unit = 1; // 1 means meters
};
Distance d1 = measure(obj1); // access d1.value and d1.unit
auto d2 = measure(obj2); // access d2.value and d2.unit
auto [value, unit] = measure(obj3); // access value and unit; somewhat redundant
// to people who know measure()
auto [x, y] = measure(obj4); // don't; it's likely to be confusing
过于通用的 pair
和 tuple
应仅在返回的值代表独立实体而不是抽象时使用。
另一个选择是使用 optional<T>
或 expected<T, error_code>
,而不是 pair
或 tuple
。当适当地使用这些类型时,它们传达的信息比 pair<T, bool>
或 pair<T, error_code>
更能说明成员的含义。
当要返回的对象是从昂贵复制的局部变量初始化的时,显式 move
可能有助于避免复制。
pair<LargeObject, LargeObject> f(const string& input)
{
LargeObject large1 = g(input);
LargeObject large2 = h(input);
// ...
return { move(large1), move(large2) }; // no copies
}
或者:
pair<LargeObject, LargeObject> f(const string& input)
{
// ...
return { g(input), h(input) }; // no copies, no moves
}
请注意,这与 ES.56 中的 return move(...)
反模式不同。
const
成员函数或作为非 const
对象传递的参数。pair
或 tuple
返回类型应尽可能被 struct
替换。在变长模板中,tuple
常常是不可避免的。T*
而不是 T&
指针(T*
)可以是 nullptr
,而引用(T&
)则不能,没有有效的“空引用”。有时将 nullptr
作为“无对象”的替代表示很有用,但如果不是,引用在表示法上更简单,并且可能产生更好的代码。
string zstring_to_string(zstring p) // zstring is a char*; that is a C-style string
{
if (!p) return string{}; // p might be nullptr; remember to check
return string{p};
}
void print(const vector<int>& r)
{
// r refers to a vector<int>; no check needed
}
虽然不是有效的 C++,但有可能构造一个本质上是 nullptr
的引用(例如 T* p = nullptr; T& r = *p;
)。这种错误非常罕见。
如果您倾向于使用指针表示法(->
和/或 *
而不是 .
),not_null<T*>
提供与 T&
相同的保证。
T*
或 owner<T*>
指定单个对象可读性:使普通指针的含义清晰。支持重要的工具。
在传统的 C 和 C++ 代码中,普通 T*
用于许多关联不强的目的,例如
nullptr
这使得代码的含义和预期难以理解。它增加了检查和工具支持的复杂性。
void use(int* p, int n, char* s, int* q)
{
p[n - 1] = 666; // Bad: we don't know if p points to n elements;
// assume it does not or use span<int>
cout << s; // Bad: we don't know if that s points to a zero-terminated array of char;
// assume it does not or use zstring
delete q; // Bad: we don't know if *q is allocated on the free store;
// assume it does not or use owner
}
更好
void use2(span<int> p, zstring s, owner<int*> q)
{
p[p.size() - 1] = 666; // OK, a range error can be caught
cout << s; // OK
delete q; // OK
}
owner<T*>
表示所有权,zstring
表示 C 风格字符串。
另外:假设从 T
的智能指针(例如 unique_ptr<T>
)获得的 T*
指向单个元素。
参见:支持库
not_null<T>
表示“null”不是有效值清晰度。具有 not_null<T>
参数的函数清楚地表明,函数调用者负责进行任何可能必需的 nullptr
检查。同样,具有 not_null<T>
返回值的函数清楚地表明,函数调用者无需检查 nullptr
。
not_null<T*>
使读者(人类或机器)能够清楚地知道在解引用之前无需检查 nullptr
。此外,在调试时,owner<T*>
和 not_null<T>
可以进行仪器化以检查正确性。
考虑
int length(Record* p);
当我调用 length(p)
时,我应该先检查 p
是否为 nullptr
吗?length()
的实现是否应该检查 p
是否为 nullptr
?
// it is the caller's job to make sure p != nullptr
int length(not_null<Record*> p);
// the implementor of length() must assume that p == nullptr is possible
int length(Record* p);
假定 not_null<T*>
不是 nullptr
;T*
可能是 nullptr
;两者都可以存储在内存中,作为 T*
(因此不包含运行时开销)。
not_null
不仅用于内置指针。它适用于 unique_ptr
、shared_ptr
和其他指针类类型。
nullptr
(或等效值)进行测试就进行了解引用,则发出警告,建议改用 not_null
声明。nullptr
(或等效值)进行测试后才解引用,有时却不进行测试,则发出错误。not_null
指针是否为 nullptr
,则发出警告。span<T>
或 span_p<T>
指定半开序列非正式/非显式的范围是错误的根源。
X* find(span<X> r, const X& v); // find v in r
vector<X> vec;
// ...
auto p = find({vec.begin(), vec.end()}, X{}); // find X{} in vec
范围在 C++ 代码中非常普遍。通常,它们是隐式的,并且很难确保正确使用。特别是,给定一对参数 (p, n)
来指定数组 [p:p+n)
,通常不可能知道在 *p
之后是否真的有 n
个元素可以访问。span<T>
和 span_p<T>
是简单的辅助类,分别用于指定 [p:q)
范围以及从 p
开始直到第一个满足谓词的元素结束的范围。
一个 span
表示一个元素范围,但是我们如何操作该范围内的元素呢?
void f(span<int> s)
{
// range traversal (guaranteed correct)
for (int x : s) cout << x << '\n';
// C-style traversal (potentially checked)
for (gsl::index i = 0; i < s.size(); ++i) cout << s[i] << '\n';
// random access (potentially checked)
s[7] = 9;
// extract pointers (potentially checked)
std::sort(&s[0], &s[s.size() / 2]);
}
span<T>
对象不拥有其元素,并且非常小,可以按值传递。
将 span
对象作为参数传递与传递一对指针参数或传递指针和整数计数一样高效。
参见:支持库
(复杂)当指针参数的访问受其他作为整数类型的参数限制时发出警告,并建议它们可以使用 span
替代。
zstring
或 not_null<zstring>
指定 C 风格字符串C 风格字符串无处不在。它们由约定定义:以零结尾的字符数组。我们必须区分 C 风格字符串与指向单个字符的指针或指向字符数组的旧式指针。
如果您不需要 null 终止,请使用 string_view
。
考虑
int length(const char* p);
当我调用 length(s)
时,我应该先检查 s
是否为 nullptr
吗?length()
的实现是否应该检查 p
是否为 nullptr
?
// the implementor of length() must assume that p == nullptr is possible
int length(zstring p);
// it is the caller's job to make sure p != nullptr
int length(not_null<zstring> p);
zstring
不表示所有权。
参见:支持库
unique_ptr<T>
来转移所有权使用 unique_ptr
是安全传递指针的最廉价方式。
参见:关于何时从工厂返回 shared_ptr
的C.50。
unique_ptr<Shape> get_shape(istream& is) // assemble shape from input stream
{
auto kind = read_header(is); // read header and identify the next shape on input
switch (kind) {
case kCircle:
return make_unique<Circle>(is);
case kTriangle:
return make_unique<Triangle>(is);
// ...
}
}
如果转移的是一个类层次结构中的对象,需要通过接口(基类)使用,那么您需要传递指针而不是对象。
(简单)如果函数返回一个本地分配的原始指针,则发出警告。建议改用 unique_ptr
或 shared_ptr
。
shared_ptr<T>
来共享所有权使用 std::shared_ptr
是表示共享所有权的標準方式。也就是说,最后一个所有者删除该对象。
{
shared_ptr<const Image> im { read_image(somewhere) };
std::thread t0 {shade, args0, top_left, im};
std::thread t1 {shade, args1, top_right, im};
std::thread t2 {shade, args2, bottom_left, im};
std::thread t3 {shade, args3, bottom_right, im};
// detaching threads requires extra care (e.g., to join before
// main ends), but even if we do detach the four threads here ...
}
// ... shared_ptr ensures that eventually the last thread to
// finish safely deletes the image
如果一次只有一个所有者,则优先使用 unique_ptr
而不是 shared_ptr
。shared_ptr
用于共享所有权。
请注意,shared_ptr
的普遍使用是有成本的(对 shared_ptr
的引用计数的原子操作具有可测量的聚合成本)。
让一个对象拥有共享对象(例如,一个作用域对象)并在所有用户完成后销毁它(最好是隐式地)。
(无法强制执行)这是一个过于复杂的模式,无法可靠地检测。
T*
来指示位置(仅限)这正是指针擅长之处。返回 T*
来转移所有权是一种误用。
Node* find(Node* t, const string& s) // find s in a binary tree of Nodes
{
if (!t || t->name == s) return t;
if ((auto p = find(t->left, s))) return p;
if ((auto p = find(t->right, s))) return p;
return nullptr;
}
如果返回的指针不是 nullptr
,则 find
返回的指针指示一个持有 s
的 Node
。重要的是,这并不意味着将指向的对象的所有权转移给调用者。
位置也可以通过迭代器、索引和引用来传递。引用通常是优于指针的替代方案,如果不需要使用 nullptr
或如果引用的对象不应更改。
不要返回指向调用者作用域之外的对象的指针;请参阅F.43。
参见:关于悬空指针预防的讨论
T*
的 delete
、std::free()
等。只有所有者才应该被删除。T*
的 new
、malloc()
等。只有所有者才应该负责删除。为避免由此类悬空指针使用可能导致的崩溃和数据损坏。
函数返回后,其局部对象不再存在。
int* f()
{
int fx = 9;
return &fx; // BAD
}
void g(int* p) // looks innocent enough
{
int gx;
cout << "*p == " << *p << '\n';
*p = 999;
cout << "gx == " << gx << '\n';
}
void h()
{
int* p = f();
int z = *p; // read from abandoned stack frame (bad)
g(p); // pass pointer to abandoned stack frame to function (bad)
}
在一个流行的实现中,我得到了以下输出
*p == 999
gx == 999
我期望如此,因为 g()
的调用重用了 f()
调用放弃的堆栈空间,所以 *p
指向现在被 gx
占用的空间。
fx
和 gx
是不同类型会发生什么。fx
或 gx
是具有不变式的类型会发生什么。幸运的是,大多数(所有?)现代编译器都能捕获并警告这种简单情况。
这也适用于引用。
int& f()
{
int x = 7;
// ...
return x; // Bad: returns reference to object that is about to be destroyed
}
这仅适用于非 static
局部变量。所有 static
变量(顾名思义)都是静态分配的,因此指向它们的指针不会悬空。
并非所有泄露指向局部变量的指针的示例都如此明显。
int* glob; // global variables are bad in so many ways
template<class T>
void steal(T x)
{
glob = x(); // BAD
}
void f()
{
int i = 99;
steal([&] { return &i; });
}
int main()
{
f();
cout << *glob << '\n';
}
在这里,我设法读取了 f
调用后放弃的位置。存储在 glob
中的指针可能在很久以后使用,并以不可预测的方式导致问题。
局部变量的地址可以“返回”/泄露:通过 return 语句、通过 T&
输出参数、作为返回对象的成员、作为返回数组的元素等等。
类似的示例可以构造为将指针从内部作用域“泄露”到外部作用域;此类示例等同于将指针泄露出函数。
一个稍微不同的变体是,将指针放入比指向的对象存活更长的容器中。
参见:获取悬空指针的另一种方法是指针失效。它可以被检测/预防,使用类似的技术。
T&
语言保证 T&
指向一个对象,因此不需要测试 nullptr
。
参见:返回引用不得暗示所有权转移:关于悬空指针预防的讨论和关于所有权的讨论。
class Car
{
array<wheel, 4> w;
// ...
public:
wheel& get_wheel(int i) { Expects(i < w.size()); return w[i]; }
// ...
};
void use()
{
Car c;
wheel& w0 = c.get_wheel(0); // w0 has the same lifetime as c
}
标记没有 return
表达式可以产生 nullptr
的函数。
T&&
这是要求返回对已销毁的临时对象的引用。&&
是临时对象的磁铁。
返回的右值引用在它返回到的完整表达式的末尾超出作用域。
auto&& x = max(0, 1); // OK, so far
foo(x); // Undefined behavior
这种用法是错误的常见来源,通常被错误地报告为编译器错误。函数实现者应避免为用户设置此类陷阱。
(在完全实现时)生命周期安全配置文件将捕获此类问题。
当引用的临时对象被传递给被调用者时,返回右值引用是可以的;然后,保证临时对象比函数调用存活得更长(参见F.18 和F.19)。但是,当将此类引用“向上”传递给更大的调用者作用域时,则不可以。对于通过普通引用或完美转发传递参数并通过它们返回值的前传函数,请使用简单的 auto
返回类型推导(而不是 auto&&
)。
假定 F
按值返回。
template<class F>
auto&& wrapper(F f)
{
log_call(typeid(f)); // or whatever instrumentation
return f(); // BAD: returns a reference to a temporary
}
更好的方式
template<class F>
auto wrapper(F f)
{
log_call(typeid(f)); // or whatever instrumentation
return f(); // OK
}
std::move
和 std::forward
确实返回 &&
,但它们只是强制类型转换——仅用于约定,在临时对象被销毁之前,在表达式上下文中使用,将对临时对象的引用传递下去。我们不知道任何其他返回 &&
的好例子。
如果返回类型是 &&
,则发出诊断,但 std::move
和 std::forward
除外。
main()
的返回类型为 int
这是一条语言规则,但由于“语言扩展”而经常被违反,因此值得一提。将(程序的全局 main
)声明为 void
会限制可移植性。
void main() { /* ... */ }; // bad, not C++
int main()
{
std::cout << "This is the way to do it\n";
}
我们提及这一点仅是因为社区中此错误的持久性。请注意,尽管其返回类型不是 void,但 main 函数不需要显式 return 语句。
T&
运算符重载(尤其是在具体类型上)的约定是 operator=(const T&)
执行赋值,然后返回(非 const
)*this
。这确保了与标准库类型的兼容性,并遵循“像 int 一样做”的原则。
历史上,曾有一些关于将赋值运算符返回 const T&
的指导。这主要是为了避免 (a = b) = c
形式的代码——这种代码并不常见,不值得违反与标准类型的兼容性。
class Foo
{
public:
...
Foo& operator=(const Foo& rhs)
{
// Copy members.
...
return *this;
}
};
这应该由工具强制执行,通过检查任何赋值运算符的返回类型(和返回值)。
return std::move(local)
返回局部变量本身就会隐式地移动它。显式的 std::move
总是会导致性能下降,因为它会阻止返回值优化(RVO),而 RVO 可以完全消除移动。
S bad()
{
S result;
return std::move(result);
}
S good()
{
S result;
// Named RVO: move elision at best, move construction at worst
return result;
}
这应该由工具通过检查返回表达式来强制执行。
const T
不建议返回 const
值。这种过时的建议现在已经过时;它没有增加价值,并且会干扰移动语义。
const vector<int> fct(); // bad: that "const" is more trouble than it is worth
void g(vector<int>& vx)
{
// ...
fct() = vx; // prevented by the "const"
// ...
vx = fct(); // expensive copy: move semantics suppressed by the "const"
// ...
}
为返回添加 const
的理由是它防止(非常罕见的)对临时对象的意外访问。反对的理由是它阻止了(非常频繁的)移动语义的使用。
const
值。修复方法:删除 const
以返回非 const
值。函数无法捕获局部变量或在局部作用域定义;如果您需要这些,请尽可能优先使用 lambda,如果不行,则使用手动编写的函数对象。另一方面,lambda 和函数对象不能重载;如果您需要重载,请优先使用函数(使 lambda 重载的变通方法很复杂)。如果两者都可以,则优先编写函数;使用最简单的必要工具。
// writing a function that should only take an int or a string
// -- overloading is natural
void f(int);
void f(const string&);
// writing a function object that needs to capture local state and appear
// at statement or expression scope -- a lambda is natural
vector<work> v = lots_of_work();
for (int tasknum = 0; tasknum < max; ++tasknum) {
pool.run([=, &v] {
/*
...
... process (1/max)-th of v, the tasknum-th chunk
...
*/
});
}
pool.join();
泛型 lambda 提供了一种编写函数模板的简洁方式,因此即使在普通函数模板已经足够好的情况下,它们也很有用,只需要稍多一点语法。当所有函数都能拥有 Concept 参数后,这种优势可能会消失。
auto x = [](int i) { /*...*/; };
)并且该 lambda 不捕获任何内容并出现在全局作用域时,发出警告。应改用普通函数。默认参数只是为单个实现提供替代接口。不能保证一组重载函数都实现了相同的语义。默认参数的使用可以避免代码复制。
当参数集来自相同类型的参数集时,可以在使用默认参数和重载之间进行选择。例如
void print(const string& s, format f = {});
而不是
void print(const string& s); // use default format
void print(const string& s, format f);
当一组函数用于执行与一组类型语义等效的操作时,不存在选择。例如
void print(const char&);
void print(int);
void print(zstring);
f(int)
、f(int, const string&)
、f(int, const string&, double)
),发出警告。(注意:如果在实践中过于嘈杂,请审查此强制执行。)为了效率和正确性,当在局部使用 lambda 时,几乎总是希望通过引用捕获。这包括在编写或调用并行算法时,这些算法在返回前会合并,因此是局部的。
效率方面的考虑是,大多数类型的按引用传递比按值传递更便宜。
正确性方面的考虑是,许多调用希望在调用站点上对原始对象执行副作用(请参阅下面的示例)。按值传递会阻止这种情况。
不幸的是,没有简单的方法可以通过引用捕获 const
来获得本地调用的效率,同时又能防止副作用。
这里,一个大型对象(网络消息)被传递给一个迭代算法,并且复制该消息(可能无法复制)既不高效也不正确。
std::for_each(begin(sockets), end(sockets), [&message](auto& socket)
{
socket.send(message);
});
这是一个简单的三阶段并行管道。每个 stage
对象都封装了一个工作线程和一个队列,有一个 process
函数用于将工作入队,在其析构函数中会自动阻塞,等待队列排空后再结束线程。
void send_packets(buffers& bufs)
{
stage encryptor([](buffer& b) { encrypt(b); });
stage compressor([&](buffer& b) { compress(b); encryptor.process(b); });
stage decorator([&](buffer& b) { decorate(b); compressor.process(b); });
for (auto& b : bufs) { decorator.process(b); }
} // automatically blocks waiting for pipeline to finish
标记捕获了引用的 lambda,但其用途不是在函数作用域内的局部使用,也不是通过引用传递给函数。(注意:此规则是近似的,但确实会标记通过指针传递,因为指针更有可能被被调用者存储,写入通过参数访问的堆位置,返回 lambda 等。生命周期规则也将提供通用规则来标记通过 lambda 逃逸的指针和引用。)
指向局部的指针和引用不应超出其作用域。通过引用捕获的 lambda 只是存储局部对象引用的另一种方式,如果不希望它们(或其副本)超出作用域,则不应这样做。
int local = 42;
// Want a reference to local.
// Note that after program exits this scope,
// local no longer exists, therefore
// process() call will have undefined behavior!
thread_pool.queue_work([&] { process(local); });
int local = 42;
// Want a copy of local.
// Since a copy of local is made, it will
// always be available for the call.
thread_pool.queue_work([=] { process(local); });
如果必须捕获非局部指针,请考虑使用 unique_ptr
;它同时处理生命周期和同步。
如果必须捕获 this
指针,请考虑使用 [*this]
捕获,它会创建对象本身的副本。
const
的非局部上下文时,标记。this
或任何类数据成员的 lambda 时,不要使用 [=]
默认捕获。这很令人困惑。在成员函数中编写 [=]
似乎是按值捕获,但实际上是通过引用捕获数据成员,因为它实际上是通过值捕获了不可见的 this
指针。如果您打算这样做,请显式编写 this
。
class My_class {
int x = 0;
// ...
void f()
{
int i = 0;
// ...
auto lambda = [=] { use(i, x); }; // BAD: "looks like" copy/value capture
x = 42;
lambda(); // calls use(0, 42);
x = 43;
lambda(); // calls use(0, 43);
// ...
auto lambda2 = [i, this] { use(i, x); }; // ok, most explicit and least confusing
// ...
}
};
如果您打算捕获所有类数据成员的副本,请考虑 C++17 的 [*this]
。
[=]
捕获默认值,并且还捕获了 this
(无论是显式地还是通过默认捕获和在函数体内使用 this
)的 lambda 捕获列表。va_arg
参数从 va_arg
读取假定传递了正确的类型。传递给可变参数假定会读取正确的类型。这很不稳定,因为它通常无法在语言中强制执行安全,因此依赖于程序员的自律。
int sum(...)
{
// ...
while (/*...*/)
result += va_arg(list, int); // BAD, assumes it will be passed ints
// ...
}
sum(3, 2); // ok
sum(3.14159, 2.71828); // BAD, undefined
template<class ...Args>
auto sum(Args... args) // GOOD, and much more flexible
{
return (... + args); // note: C++17 "fold expression"
}
sum(3, 2); // ok: 5
sum(3.14159, 2.71828); // ok: ~5.85987
variant
参数initializer_list
(同质)声明 ...
参数有时对于不涉及实际参数传递的技术很有用,特别是声明“接受任何内容”的函数,以便在重载集中禁用“其他所有内容”或在模板元编程中表达一个捕获所有情况。
va_list
、va_start
或 va_arg
的情况发出诊断。[[suppress("type")]]
。浅层嵌套条件使代码更易于遵循。它也使意图更清晰。应将核心代码放在最外层作用域,除非这会模糊意图。
使用守卫子句来处理异常情况并提前返回。
// Bad: Deep nesting
void foo() {
...
if (x) {
computeImportantThings(x);
}
}
// Bad: Still a redundant else.
void foo() {
...
if (!x) {
return;
}
else {
computeImportantThings(x);
}
}
// Good: Early return, no redundant else
void foo() {
...
if (!x)
return;
computeImportantThings(x);
}
// Bad: Unnecessary nesting of conditions
void foo() {
...
if (x) {
if (y) {
computeImportantThings(x);
}
}
}
// Good: Merge conditions + return early
void foo() {
...
if (!(x && y))
return;
computeImportantThings(x);
}
标记冗余的 else
。标记主体仅仅是包含一个块的条件语句的函数。
类是用户定义类型,程序员可以为其定义表示、操作和接口。类层次结构用于将相关类组织成层次结构。
类规则摘要
struct
或 class
)中class
;如果数据成员可以独立变化,则使用 struct
non-public
,则使用 class
而不是 struct
子节
struct
或 class
)中易于理解。如果数据相关(出于基本原因),则应在代码中反映该事实。
void draw(int x, int y, int x2, int y2); // BAD: unnecessary implicit relationships
void draw(Point from, Point to); // better
没有虚函数的简单类不意味着空间或时间开销。
从语言角度来看,class
和 struct
仅在成员的默认可见性上有所不同。
可能不可能。也许可以使用一种查找一起使用的数据项的启发式方法。
class
;如果数据成员可以独立变化,则使用 struct
可读性。易于理解。使用 class
会提醒程序员注意不变式的必要性。这是一个有用的约定。
不变式是对象成员的逻辑条件,构造函数必须建立该条件,以便公共成员函数可以假定它。在建立不变式后(通常由构造函数),对象的所有成员函数都可以被调用。不变式可以非正式地(例如,在注释中)或更正式地使用 Expects
来声明。
如果所有数据成员都可以独立于彼此变化,则不可能存在不变式。
struct Pair { // the members can vary independently
string name;
int volume;
};
但是
class Date {
public:
// validate that {yy, mm, dd} is a valid date and initialize
Date(int yy, Month mm, char dd);
// ...
private:
int y;
Month m;
char d; // day
};
如果一个类有任何 private
数据,用户就不能在不使用构造函数的情况下完全初始化一个对象。因此,类定义者将提供一个构造函数,并且必须指定其含义。这实际上意味着定义者需要定义一个不变式。
另请参阅:
查找具有所有私有数据的 struct
和具有公共成员的 class
。
接口和实现之间的明确区别提高了可读性并简化了维护。
class Date {
public:
Date();
// validate that {yy, mm, dd} is a valid date and initialize
Date(int yy, Month mm, char dd);
int day() const;
Month month() const;
// ...
private:
// ... some representation ...
};
例如,我们现在可以更改 Date
的表示而不影响其用户(尽管很可能需要重新编译)。
当然,使用类来区分接口和实现并不是唯一的方法。例如,我们可以使用命名空间中一组独立的函数声明、一个抽象基类或一个带有概念的函数模板来表示接口。最重要的问题是明确区分接口及其实现“细节”。理想情况下,通常接口的稳定性远高于其实现。
???
与成员函数相比,耦合度更低,可能因修改对象状态而引起麻烦的函数更少,减少了在表示更改后需要修改的函数的数量。
class Date {
// ... relatively small interface ...
};
// helper functions:
Date next_weekday(Date);
bool operator==(Date, Date);
“辅助函数”无需直接访问 Date
类的表示。
如果 C++ 引入了“统一函数调用”,这个规则会变得更好。
语言要求 virtual
函数必须是成员,并且并非所有 virtual
函数都直接访问数据。特别是抽象类的成员很少这样做。
参见多方法。
语言要求运算符 =
、()
、[]
和 ->
必须是成员。
一个重载集合可以包含一些不直接访问 private
数据的成员。
class Foobar {
public:
void foo(long x) { /* manipulate private data */ }
void foo(double x) { foo(std::lround(x)); }
// ...
private:
// ...
};
类似地,可以设计一个函数集以链式方式使用。
x.scale(0.5).rotate(45).set_color(Color::red);
通常,这些函数中的一部分(而非全部)会直接访问 private
数据。
virtual
成员函数。难点在于,许多不需要直接访问数据成员的成员函数实际上却访问了数据成员。virtual
函数。private
成员的函数。this
的函数。辅助函数是(通常由类编写者提供)不需要直接访问类表示,但被视为类有用接口一部分的函数。将它们放在与类相同的命名空间中,可以使其与类的关系更加明显,并允许通过参数依赖查找找到它们。
namespace Chrono { // here we keep time-related services
class Time { /* ... */ };
class Date { /* ... */ };
// helper functions:
bool operator==(Date, Date);
Date next_weekday(Date);
// ...
}
这对于重载运算符尤其重要。
在同一声明中混合类型定义和其他实体的定义会造成混淆且不必要。
struct Data { /*...*/ } data{ /*...*/ };
struct Data { /*...*/ };
Data data{ /*...*/ };
}
后面没有跟着 ;
,则进行标记。表示缺少了 ;
。class
而不是 struct
可读性。为了清晰地表明某些内容被隐藏/抽象。这是一个有用的约定。
struct Date {
int d, m;
Date(int i, Month m);
// ... lots of functions ...
private:
int y; // year
};
从 C++ 语言规则来看,这段代码没有错,但从设计角度来看,几乎所有地方都错了。私有数据隐藏在类声明的远离公共数据的地方。数据被分割在类声明的不同部分。不同部分的数据具有不同的访问权限。所有这些都降低了可读性并使维护复杂化。
优先将接口放在类声明的开头,参见 NL.16。
如果 struct
声明的类具有 private
或 protected
成员,则进行标记。
封装。信息隐藏。最小化意外访问的可能性。这简化了维护。
template<typename T, typename U>
struct pair {
T a;
U b;
// ...
};
无论我们在 //
部分做什么,pair
的任意用户都可以随意独立地更改其 a
和 b
。在大型代码库中,我们无法轻松找到是哪个代码对 pair
的成员进行了操作。这可能正是我们想要的,但如果我们想强制执行成员之间的关系,我们就需要将它们设为 private
,并通过构造函数和成员函数强制执行该关系(不变式)。例如:
class Distance {
public:
// ...
double meters() const { return magnitude*unit; }
void set_unit(double u)
{
// ... check that u is a factor of 10 ...
// ... change magnitude appropriately ...
unit = u;
}
// ...
private:
double magnitude;
double unit; // 1 is meters, 1000 is kilometers, 0.001 is millimeters, etc.
};
如果一组变量的直接用户无法轻松确定,那么该组的类型或用法就无法(轻易)更改/改进。对于 public
和 protected
数据,通常都是这种情况。
一个类可以为用户提供两个接口。一个用于派生类(protected
),一个用于普通用户(public
)。例如,派生类可能被允许跳过运行时检查,因为它已经保证了正确性。
class Foo {
public:
int bar(int x) { check(x); return do_bar(x); }
// ...
protected:
int do_bar(int x); // do some operation on the data
// ...
private:
// ... data ...
};
class Dir : public Foo {
//...
int mem(int x, int y)
{
/* ... do something ... */
return do_bar(x + y); // OK: derived class can bypass check
}
};
void user(Foo& x)
{
int r1 = x.bar(1); // OK, will check
int r2 = x.do_bar(2); // error: would bypass check
// ...
}
优先使用 public
成员在前,protected
成员居中,private
成员在后的顺序;参见 NL.16。
public
和 private
数据混合的情况。具体类型规则摘要。
具体类型比类层次结构中的类型从根本上更简单:更容易设计、更容易实现、更容易使用、更容易推理、更小、更快。您需要一个理由(用例)来使用层次结构。
class Point1 {
int x, y;
// ... operations ...
// ... no virtual functions ...
};
class Point2 {
int x, y;
// ... operations, some virtual ...
virtual ~Point2();
};
void use()
{
Point1 p11 {1, 2}; // make an object on the stack
Point1 p12 {p11}; // a copy
auto p21 = make_unique<Point2>(1, 2); // make an object on the free store
auto p22 = p21->clone(); // make a copy
// ...
}
如果一个类是层次结构的一部分,我们(在实际代码中,即使不一定在小的示例中)必须通过指针或引用来操作它的对象。这意味着更多的内存开销、更多的分配和释放,以及更多的运行时开销来执行由此产生的间接引用。
具体类型可以被栈分配,并成为其他类的成员。
间接引用是运行时多态接口的基础。分配/释放开销则不是(那是最常见的情况)。我们可以使用基类作为派生类作用域对象的接口。这在不允许动态分配(例如硬实时)的地方进行,并为某些类型的插件提供稳定的接口。
???
符合常规的类型比不符合常规的类型(不符合常规的类型需要额外的努力来理解和使用)更容易理解和推理。
C++ 内置类型是符合常规的,标准库类如 string
、vector
和 map
也是如此。可以定义没有赋值和相等性比较的具体类,但它们是(并且应该是)罕见的。
struct Bundle {
string name;
vector<Record> vr;
};
bool operator==(const Bundle& a, const Bundle& b)
{
return a.name == b.name && a.vr == b.vr;
}
Bundle b1 { "my bundle", {r1, r2, r3}};
Bundle b2 = b1;
if (!(b1 == b2)) error("impossible!");
b2.name = "the other bundle";
if (b1 == b2) error("No!");
特别是,如果一个具体类型是可复制的,那么最好也给它一个相等性比较运算符,并确保 a = b
暗示 a == b
。
对于打算与 C 代码共享的结构,定义 operator==
可能不可行。
无法克隆的资源句柄,例如 mutex
的 scoped_lock
,是具体类型但通常不能被复制(而是可以被移动),因此它们不能是常规类型;相反,它们往往是只能移动的。
???
const
或引用const
和引用数据成员在可复制或可移动类型中没有用处,并且由于微妙的原因,会使这些类型至少部分上不可复制/不可移动,导致难以使用。
class bad {
const int i; // bad
string& s; // bad
// ...
};
该 const
和 &
数据成员使得这个类“仅是某种程度上的可复制”——可复制构造,但不可复制赋值。
如果你需要一个成员指向某个东西,请使用指针(原始指针或智能指针,如果它不应该是 null,则使用 gsl::not_null
)而不是引用。
标记在具有任何复制或移动操作的类型中是 const
、&
或 &&
的数据成员。
这些函数控制对象的生命周期:创建、复制、移动和销毁。定义构造函数以保证和简化类的初始化。
这些是默认操作:
X()
X(const X&)
operator=(const X&)
X(X&&)
operator=(X&&)
~X()
默认情况下,编译器会在使用每个操作时定义它,但可以抑制默认值。
默认操作是一组相关的操作,它们共同实现了对象的生命周期语义。默认情况下,C++ 将类视为值类类型,但并非所有类型都是值类类型。
默认操作规则集。
析构函数规则。
T*
)或引用(T&
),请考虑它是否可能拥有所有权。noexcept
。构造函数规则。
explicit
。复制和移动规则。
virtual
,参数类型为 const&
,返回类型为非 const&
。virtual
,参数类型为 &&
,返回类型为非 const&
。noexcept
。其他默认操作规则。
=default
。=delete
。noexcept
的 swap
函数。swap
不得失败。swap
noexcept
。==
在操作数类型上对称且 noexcept
。==
。hash
noexcept
。默认情况下,语言会提供具有默认语义的默认操作。但是,程序员可以禁用或替换这些默认值。
这是最简单的方法,并提供最清晰的语义。
struct Named_map {
public:
explicit Named_map(const string& n) : name(n) {}
// no copy/move constructors
// no copy/move assignment operators
// no destructor
private:
string name;
map<int, int> rep;
};
Named_map nm("map"); // construct
Named_map nm2 {nm}; // copy construct
由于 std::map
和 string
拥有所有特殊函数,因此无需进一步工作。
这被称为“零法则”。
(不可强制执行) 虽然不可强制执行,但好的静态分析器可以检测到表明可以改进以满足此规则的模式。例如,一个带有(指针,大小)成员对和 delete
该指针的析构函数的类,很可能可以转换为 vector
。
=delete
了任何复制、移动或析构函数,请定义或 =delete
它们全部复制、移动和销毁的语义密切相关,因此如果其中一个需要声明,那么其他也很可能需要考虑。
声明任何复制/移动/析构函数,即使是 =default
或 =delete
,都会抑制移动构造函数和移动赋值运算符的隐式声明。声明移动构造函数或移动赋值运算符,即使是 =default
或 =delete
,也会导致隐式生成的复制构造函数或隐式生成的复制赋值运算符被定义为已删除。因此,一旦声明了其中任何一个,其他所有都应该被声明,以避免产生不需要的效果,例如将所有可能的移动转换为更昂贵的复制,或者使类只能移动。
struct M2 { // bad: incomplete set of copy/move/destructor operations
public:
// ...
// ... no copy or move operations ...
~M2() { delete[] rep; }
private:
pair<int, int>* rep; // zero-terminated set of pairs
};
void use()
{
M2 x;
M2 y;
// ...
x = y; // the default assignment
// ...
}
鉴于析构函数(此处是为了取消分配)需要“特别注意”,隐式定义的复制和移动赋值运算符正确的可能性很低(此处会发生双重删除)。
这被称为“五法则”。
如果您想要默认实现(在定义另一个的同时),请编写 =default
以表明您是故意为该函数这样做的。如果您不想要生成的默认函数,请使用 =delete
来抑制它。
当析构函数仅为使其 virtual
而被声明时,它可以定义为默认。
class AbstractBase {
public:
virtual void foo() = 0; // at least one abstract method to make the class abstract
virtual ~AbstractBase() = default;
// ...
};
为防止切片(如 C.67),请将复制和移动操作设为保护的或 =delete
d,并添加 clone
。
class CloneableBase {
public:
virtual unique_ptr<CloneableBase> clone() const;
virtual ~CloneableBase() = default;
CloneableBase() = default;
CloneableBase(const CloneableBase&) = delete;
CloneableBase& operator=(const CloneableBase&) = delete;
CloneableBase(CloneableBase&&) = delete;
CloneableBase& operator=(CloneableBase&&) = delete;
// ... other constructors and functions ...
};
仅定义移动操作或仅定义复制操作会产生相同的效果,但明确声明每个特殊成员会使读者更容易理解。
编译器会强制执行此规则的大部分内容,并理想情况下会警告任何违规行为。
在具有析构函数的类中依赖隐式生成的复制操作是已弃用的。
编写这些函数可能容易出错。请注意它们的参数类型。
class X {
public:
// ...
virtual ~X() = default; // destructor (virtual if X is meant to be a base class)
X(const X&) = default; // copy constructor
X& operator=(const X&) = default; // copy assignment
X(X&&) noexcept = default; // move constructor
X& operator=(X&&) noexcept = default; // move assignment
};
一个细微的错误(例如拼写错误、遗漏 const
、使用 &
而不是 &&
,或者遗漏特殊函数)都可能导致错误或警告。为避免繁琐和出错的可能性,请尝试遵循 零法则。
(简单) 一个类应该有一个声明(即使是 =delete
的)来处理所有或零个复制/移动/析构函数。
默认操作在概念上是一组匹配的操作。它们的语义是相互关联的。用户会在复制/移动构造与复制/移动赋值在逻辑上执行不同操作时感到惊讶。用户会在构造函数和析构函数未提供一致的资源管理视图时感到惊讶。用户会在复制和移动未能反映构造函数和析构函数的工作方式时感到惊讶。
class Silly { // BAD: Inconsistent copy operations
class Impl {
// ...
};
shared_ptr<Impl> p;
public:
Silly(const Silly& a) : p(make_shared<Impl>()) { *p = *a.p; } // deep copy
Silly& operator=(const Silly& a) { p = a.p; return *this; } // shallow copy
// ...
};
这些操作在复制语义上存在分歧。这将导致混淆和错误。
“这个类需要析构函数吗?”是一个令人惊讶的富有洞察力的设计问题。对于大多数类来说,答案是“否”,原因要么是类不持有任何资源,要么是析构处理由零法则完成;也就是说,它的成员可以处理好析构方面的事情。如果答案是“是”,那么类的许多设计将随之而来(参见五法则)。
析构函数在对象生命周期结束时被隐式调用。如果默认析构函数足够,请使用它。仅当类需要执行其成员析构函数中尚不存在的代码时,才定义非默认析构函数。
template<typename A>
struct final_action { // slightly simplified
A act;
final_action(A a) : act{a} {}
~final_action() { act(); }
};
template<typename A>
final_action<A> finally(A act) // deduce action type
{
return final_action<A>{act};
}
void test()
{
auto act = finally([] { cout << "Exit test\n"; }); // establish exit action
// ...
if (something) return; // act done here
// ...
} // act done here
final_action
的整个目的是在销毁时执行一段代码(通常是 lambda)。
需要用户定义析构函数的类通常分为两大类:
vector
或事务类。final_action
。class Foo { // bad; use the default destructor
public:
// ...
~Foo() { s = ""; i = 0; vi.clear(); } // clean up
private:
string s;
int i;
vector<int> vi;
};
默认析构函数做得更好、更有效率,而且不会出错。
查找可能的“隐式资源”,例如指针和引用。查找带有析构函数的类,即使它们的所有数据成员都有析构函数。
防止资源泄漏,尤其是在错误情况下。
对于表示为具有完整默认操作集的类的资源,这会自动发生。
class X {
ifstream f; // might own a file
// ... no default operations defined or =deleted ...
};
X
的 ifstream
在 X
销毁时会隐式关闭它可能打开的任何文件。
class X2 { // bad
FILE* f; // might own a file
// ... no default operations defined or =deleted ...
};
X2
可能会泄漏文件句柄。
那一个无法关闭的套接字呢?析构函数、关闭或清理操作不应失败。如果它仍然失败,我们就遇到了一个没有真正好解决方案的问题。首先,析构函数的编写者不知道析构函数为何被调用,也无法通过抛出异常来“拒绝执行”。请参阅讨论。更糟糕的是,许多“关闭/释放”操作是不可重试的。许多人试图解决这个问题,但没有已知的通用解决方案。如果可能的话,请将关闭/清理失败视为根本性的设计错误并终止。
一个类可以持有它不拥有的对象的指针和引用。显然,这些对象不应被类的析构函数 delete
。例如:
Preprocessor pp { /* ... */ };
Parser p { pp, /* ... */ };
Type_checker tc { p, /* ... */ };
这里 p
指向 pp
但不拥有它。
gsl::owner
确定为所有者),那么它们的析构函数中应该引用它们。T*
)或引用(T&
),请考虑它是否可能拥有所有权有很多代码对所有权不明确。
class legacy_class
{
foo* m_owning; // Bad: change to unique_ptr<T> or owner<T*>
bar* m_observer; // OK: keep
}
确定所有权的唯一方法可能是代码分析。
根据R.20(用于拥有指针)和R.3(用于非拥有指针),所有权在新的代码(和重构的遗留代码)中应该是清晰的。引用永远不应该拥有所有权R.4。
查看原始成员指针和成员引用的初始化,看看是否使用了分配。
被拥有的对象必须在拥有它的对象被销毁时被 delete
。
指针成员可能代表资源。 T*
不应该这样做,但在旧代码中很常见。考虑 T*
是一个可能的拥有者,因此是可疑的。
template<typename T>
class Smart_ptr {
T* p; // BAD: vague about ownership of *p
// ...
public:
// ... no user-defined default operations ...
};
void use(Smart_ptr<int> p1)
{
// error: p2.p leaked (if not nullptr and not owned by some other code)
auto p2 = p1;
}
请注意,如果您定义了析构函数,则必须定义或删除所有默认操作。
template<typename T>
class Smart_ptr2 {
T* p; // BAD: vague about ownership of *p
// ...
public:
// ... no user-defined copy operations ...
~Smart_ptr2() { delete p; } // p is an owner!
};
void use(Smart_ptr2<int> p1)
{
auto p2 = p1; // error: double deletion
}
默认复制操作只会将 p1.p
复制到 p2.p
,导致 p1.p
被双重销毁。请明确所有权。
template<typename T>
class Smart_ptr3 {
owner<T*> p; // OK: explicit about ownership of *p
// ...
public:
// ...
// ... copy and move operations ...
~Smart_ptr3() { delete p; }
};
void use(Smart_ptr3<int> p1)
{
auto p2 = p1; // OK: no double deletion
}
最简单的获取析构函数的方法通常是用智能指针(例如 std::unique_ptr
)替换指针,并让编译器隐式地安排正确的销毁。
为什么不直接要求所有拥有指针都是“智能指针”?这有时需要对代码进行非平凡的更改,并可能影响 ABI。
owner<T>
的类应定义其默认操作。以防止未定义行为。如果析构函数是公有的,那么调用代码可以尝试通过基类指针销毁派生类对象,如果基类析构函数是非虚的,结果是未定义的。如果析构函数是受保护的,那么调用代码就不能通过基类指针进行销毁,并且析构函数不需要是虚的;它需要是受保护的,而不是私有的,这样派生类的析构函数才能调用它。总的来说,基类编写者不知道销毁时应执行的适当操作。
参见讨论部分。
struct Base { // BAD: implicitly has a public non-virtual destructor
virtual void f();
};
struct D : Base {
string s {"a resource needing cleanup"};
~D() { /* ... do some cleanup ... */ }
// ...
};
void use()
{
unique_ptr<Base> p = make_unique<D>();
// ...
} // p's destruction calls ~Base(), not ~D(), which leaks D::s and possibly more
虚函数定义了一个可以无需查看派生类即可使用的接口。如果接口允许销毁,那么销毁应该是安全的。
析构函数必须是非私有的,否则会阻止使用该类型。
class X {
~X(); // private destructor
// ...
};
void use()
{
X a; // error: cannot destroy
auto p = make_unique<X>(); // error: cannot destroy
}
我们可以设想一种情况,您可能想要一个受保护的虚析构函数:当派生类型的对象(并且只有这种类型的对象)应该能够通过基类指针销毁另一个对象(不是自身)时。虽然我们实际上没有遇到过这种情况。
一般来说,如果我们编写的析构函数会失败,我们不知道如何编写无错误的代码。标准库要求它处理的所有类都具有不会因抛出异常而退出的析构函数。
class X {
public:
~X() noexcept;
// ...
};
X::~X() noexcept
{
// ...
if (cannot_release_a_resource) terminate();
// ...
}
许多人试图设计一个万无一失的方案来处理析构函数中的失败。没有人成功地提出了一个通用的方案。这可能是一个实际的难题:例如,一个无法关闭的套接字怎么办?析构函数的编写者不知道析构函数为何被调用,也无法通过抛出异常来“拒绝执行”。请参阅讨论。更糟糕的是,许多“关闭/释放”操作是不可重试的。许多人试图解决这个问题,但没有已知的通用解决方案。如果可能,请将关闭/清理失败视为根本性的设计错误并终止。
声明一个析构函数为 noexcept
。这将确保它要么正常完成,要么终止程序。
如果一个资源无法释放,并且程序不能失败,则尝试以某种方式向系统的其余部分发出失败信号(甚至可能通过修改某些全局状态并寄希望于某人注意到并能够处理该问题)。要充分意识到这种技术是专门用途且容易出错的。考虑“我的连接不会关闭”的例子。可能连接的另一端存在问题,并且只有负责连接两端的代码才能正确处理此问题。析构函数可以(以某种方式)将消息发送给系统的负责部分,将其视为已关闭连接,然后正常返回。
如果析构函数使用了可能失败的操作,它可以捕获异常,并在某些情况下仍然成功完成(例如,通过使用与抛出异常的机制不同的清理机制)。
(简单)如果析构函数可能抛出异常,则应将其声明为 noexcept
。
noexcept
析构函数不得失败。如果析构函数尝试抛出异常退出,这是一个严重的设计错误,程序最好终止。
如果一个类中的所有成员都有 noexcept
析构函数,则该类的析构函数(无论是用户定义的还是编译器生成的)都会隐式声明为 noexcept
(与其主体中的代码无关)。通过显式标记析构函数为 noexcept
,作者可以防止析构函数因添加或修改类成员而隐式变为 noexcept(false)
。
并非所有析构函数都默认是 noexcept 的;一个抛出异常的成员会“毒害”整个类层次结构。
struct X {
Details x; // happens to have a throwing destructor
// ...
~X() { } // implicitly noexcept(false); aka can throw
};
因此,如果您不确定,请将析构函数声明为 noexcept。
为什么不将所有析构函数都声明为 noexcept 呢?因为在许多情况下——尤其是简单的用例——这会造成分散注意力的混乱。
(简单)如果析构函数可能抛出异常,则应将其声明为 noexcept
。
构造函数定义了对象如何被初始化(构造)。
这就是构造函数的作用。
class Date { // a Date represents a valid date
// in the January 1, 1900 to December 31, 2100 range
Date(int dd, int mm, int yy)
:d{dd}, m{mm}, y{yy}
{
if (!is_valid(d, m, y)) throw Bad_date{}; // enforce invariant
}
// ...
private:
int d, m, y;
};
通常最好将不变量表示为构造函数上的 Ensures
。
即使类没有不变量,构造函数也可以用于方便。例如
struct Rec {
string s;
int i {0};
Rec(const string& ss) : s{ss} {}
Rec(int ii) :i{ii} {}
};
Rec r1 {7};
Rec r2 {"Foo bar"};
C++11 的初始化列表规则消除了许多构造函数的需要。例如
struct Rec2{
string s;
int i;
Rec2(const string& ss, int ii = 0) :s{ss}, i{ii} {} // redundant
};
Rec2 r1 {"Foo", 7};
Rec2 r2 {"Bar"};
Rec2
构造函数是多余的。此外,int
的默认值最好通过 类内默认成员初始化 来实现。
构造函数建立类的“不变量”。类的用户应该能够假定构造的对象是可用的。
class X1 {
FILE* f; // call init() before any other function
// ...
public:
X1() {}
void init(); // initialize f
void read(); // read from f
// ...
};
void f()
{
X1 file;
file.read(); // crash or bad read!
// ...
file.init(); // too late
// ...
}
编译器不读取注释。
如果无法通过构造函数方便地构造有效对象,请使用工厂函数。
Ensures
合约,请尝试查看它是否作为后置条件成立。如果构造函数获取一个资源(以创建有效对象),则该资源应由析构函数释放。构造函数获取资源、析构函数释放它们的这种习惯用法称为RAII(“资源获取即初始化”)。
留下一个无效的对象是自找麻烦。
class X2 {
FILE* f;
// ...
public:
X2(const string& name)
:f{fopen(name.c_str(), "r")}
{
if (!f) throw runtime_error{"could not open" + name};
// ...
}
void read(); // read from f
// ...
};
void f()
{
X2 file {"Zeno"}; // throws if file isn't open
file.read(); // fine
// ...
}
class X3 { // bad: the constructor leaves a non-valid object behind
FILE* f; // call is_valid() before any other function
bool valid;
// ...
public:
X3(const string& name)
:f{fopen(name.c_str(), "r")}, valid{false}
{
if (f) valid = true;
// ...
}
bool is_valid() { return valid; }
void read(); // read from f
// ...
};
void f()
{
X3 file {"Heraclides"};
file.read(); // crash or bad read!
// ...
if (file.is_valid()) {
file.read();
// ...
}
else {
// ... handle error ...
}
// ...
}
对于变量定义(例如,在栈上或作为另一个对象的成员),没有显式的函数调用可以返回错误代码。留下一个无效的对象并依赖用户在使用前始终检查 is_valid()
函数是繁琐、容易出错且低效的。
有些领域,例如某些硬实时系统(想想飞机控制),(在没有额外工具支持的情况下)异常处理在时间上不够可预测。在那里必须使用 is_valid()
技术。在这种情况下,请始终并立即检查 is_valid()
以模拟RAII。
如果您觉得使用某些“构造后初始化”或“两阶段初始化”的习惯用法,请尽量避免。如果您确实必须这样做,请查看工厂函数。
人们使用 init()
函数而不是在构造函数中执行初始化工作的一个原因是避免代码重复。委托构造函数和类内默认成员初始化可以更好地做到这一点。另一个原因是延迟初始化直到对象被需要;解决这个问题的方法通常是不要在可以正确初始化之前声明变量。
???
也就是说,确保如果一个具体类是可复制的,那么它也满足“半规则”的其他要求。
许多语言和库设施依赖默认构造函数来初始化它们的元素,例如 T a[10]
和 std::vector<T> v(10)
。默认构造函数通常可以简化定义类型(也具有可复制性)的合适的已移动状态的任务。
class Date { // BAD: no default constructor
public:
Date(int dd, int mm, int yyyy);
// ...
};
vector<Date> vd1(1000); // default Date needed here
vector<Date> vd2(1000, Date{7, Month::October, 1885}); // alternative
仅当没有用户声明的构造函数时,才会自动生成默认构造函数,因此无法初始化上面示例中的向量 vd1
。缺少默认值可能给用户带来意外,并使其使用复杂化,因此如果可以合理定义,则应进行定义。
选择 Date
是为了鼓励思考:没有“自然”的默认日期(宇宙大爆炸对于大多数人来说太遥远了),所以这个例子并不简单。在大多数日历系统中,{0, 0, 0}
不是一个有效的日期,所以选择它就像引入了浮点数的 NaN
。然而,大多数实际的 Date
类都有一个“第一个日期”(例如,1970 年 1 月 1 日很受欢迎),所以将其设为默认值通常很简单。
class Date {
public:
Date(int dd, int mm, int yyyy);
Date() = default; // [See also](#Rc-default)
// ...
private:
int dd {1};
int mm {1};
int yyyy {1970};
// ...
};
vector<Date> vd1(1000);
所有成员都有默认构造函数的类会隐式获得一个默认构造函数。
struct X {
string s;
vector<int> v;
};
X x; // means X{ { }, { } }; that is the empty string and the empty vector
注意,内置类型不会被正确地默认构造。
struct X {
string s;
int i;
};
void f()
{
X x; // x.s is initialized to the empty string; x.i is uninitialized
cout << x.s << ' ' << x.i << '\n';
++x.i;
}
内置类型的静态分配对象默认初始化为 0
,但局部内置变量则不会。请注意,您的编译器可能会默认初始化局部内置变量,而优化后的构建则不会。因此,上面的示例代码可能看起来有效,但它依赖于未定义的行为。假设您想要初始化,显式默认初始化会有所帮助。
struct X {
string s;
int i {}; // default initialize (to 0)
};
没有合理默认构造函数的类通常也不是可复制的,因此它们不属于此指南的范围。
例如,基类不应该是可复制的,因此不一定需要默认构造函数。
// Shape is an abstract base class, not a copyable type.
// It might or might not need a default constructor.
struct Shape {
virtual void draw() = 0;
virtual void rotate(int) = 0;
// =delete copy/move functions
// ...
};
在构造过程中必须获取调用者提供的资源的类通常无法具有默认构造函数,但它不属于此指南的范围,因为这样的类通常也不可复制。
// std::lock_guard is not a copyable type.
// It does not have a default constructor.
lock_guard g {mx}; // guard the mutex mx
lock_guard g2; // error: guarding nothing
一个必须由成员函数或用户单独处理的“特殊状态”的类会产生额外的工作(并且极有可能导致更多错误)。这样的类型可以自然地使用特殊状态作为默认构造的值,无论它是否可复制。
// std::ofstream is not a copyable type.
// It does happen to have a default constructor
// that goes along with a special "not open" state.
ofstream out {"Foobar"};
// ...
out << log(time, transaction);
类似的可复制的特殊状态类型,例如具有特殊状态“==nullptr”的可复制智能指针,应使用特殊状态作为其默认构造的值。
然而,最好有一个默认构造函数默认为有意义的状态,例如 std::string
的 ""
和 std::vector
的 {}
。
=
复制的类。==
比较但不可复制的类。能够在没有可能失败的操作的情况下将值设置为“默认值”,可以简化错误处理和对移动操作的推理。
template<typename T>
// elem points to space-elem element allocated using new
class Vector0 {
public:
Vector0() :Vector0{0} {}
Vector0(int n) :elem{new T[n]}, space{elem + n}, last{elem} {}
// ...
private:
own<T*> elem;
T* space;
T* last;
};
这很好且通用,但将 Vector0
设置为空(在错误发生后)需要分配,这可能会失败。另外,将 Vector
的默认值表示为 {new T[0], 0, 0}
似乎很浪费。例如,Vector0<int> v[100]
需要 100 次分配。
template<typename T>
// elem is nullptr or elem points to space-elem element allocated using new
class Vector1 {
public:
// sets the representation to {nullptr, nullptr, nullptr}; doesn't throw
Vector1() noexcept {}
Vector1(int n) :elem{new T[n]}, space{elem + n}, last{elem} {}
// ...
private:
own<T*> elem {};
T* space {};
T* last {};
};
使用 {nullptr, nullptr, nullptr}
使 Vector1{}
廉价,但这是一个特殊情况,并暗示了运行时检查。在检测到错误后将 Vector1
设置为空是微不足道的。
使用类内默认成员初始化器可以让编译器为您生成函数。编译器生成的函数可能更高效。
class X1 { // BAD: doesn't use member initializers
string s;
int i;
public:
X1() :s{"default"}, i{1} { }
// ...
};
class X2 {
string s {"default"};
int i {1};
public:
// use compiler-generated default constructor
// ...
};
(简单)默认构造函数的作用应该不仅仅是用常量初始化数据成员。
explicit
避免意外转换。
class String {
public:
String(int); // BAD
// ...
};
String s = 10; // surprise: string of size 10
如果您确实希望从构造函数参数类型隐式转换为类类型,请不要使用 explicit
。
class Complex {
public:
Complex(double d); // OK: we want a conversion from d to {d, 0}
// ...
};
Complex z = 10.7; // unsurprising conversion
另请参阅:关于隐式转换的讨论。
复制和移动构造函数不应设为 explicit
,因为它们不执行转换。显式的复制/移动构造函数会使按值传递和返回变得困难。
(简单)单参数构造函数应声明为 explicit
。在大多数代码库中,好的非 explicit
单参数构造函数很少见。标记所有不在“正面列表”中的。
以最小化混淆和错误。这是初始化发生的顺序(与成员初始化列表的顺序无关)。
class Foo {
int m1;
int m2;
public:
Foo(int x) :m2{x}, m1{++x} { } // BAD: misleading initializer order
// ...
};
Foo x(1); // surprise: x.m1 == x.m2 == 2
(简单)成员初始化列表应按照声明的顺序提及成员。
另请参阅:讨论。
明确表示所有构造函数都应使用相同的值。避免重复。避免维护问题。它能够生成最短、最高效的代码。
class X { // BAD
int i;
string s;
int j;
public:
X() :i{666}, s{"qqq"} { } // j is uninitialized
X(int ii) :i{ii} {} // s is "" and j is uninitialized
// ...
};
维护者如何知道 j
是否故意未初始化(这通常是坏主意),以及是否打算在一种情况下为 s
提供默认值 ""
,而在另一种情况下为 qqq
(几乎肯定是 bug)?j
的问题(忘记初始化成员)通常发生在将新成员添加到现有类时。
class X2 {
int i {666};
string s {"qqq"};
int j {0};
public:
X2() = default; // all members are initialized to their defaults
X2(int ii) :i{ii} {} // s and j initialized to their defaults
// ...
};
替代方法:我们可以通过构造函数的默认参数来获得部分好处,这在旧代码中并不少见。然而,这不够明确,需要传递更多的参数,并且当有多个构造函数时会造成重复。
class X3 { // BAD: inexplicit, argument passing overhead
int i;
string s;
int j;
public:
X3(int ii = 666, const string& ss = "qqq", int jj = 0)
:i{ii}, s{ss}, j{jj} { } // all members are initialized to their defaults
// ...
};
初始化明确表示进行了初始化而不是赋值,并且可以更优雅、更高效。防止“先使用后设置”的错误。
class A { // Good
string s1;
public:
A(czstring p) : s1{p} { } // GOOD: directly construct (and the C-string is explicitly named)
// ...
};
class B { // BAD
string s1;
public:
B(const char* p) { s1 = p; } // BAD: default constructor followed by assignment
// ...
};
class C { // UGLY, aka very bad
int* p;
public:
C() { cout << *p; p = new int{10}; } // accidental use before initialized
// ...
};
除了那些 const char*
s,我们可以使用 C++17 的 std::string_view
或 gsl::span<char>
作为 向函数传递参数的更通用方式。
class D { // Good
string s1;
public:
D(string_view v) : s1{v} { } // GOOD: directly construct
// ...
};
如果基类对象的状态必须取决于对象派生部分的状态,我们需要使用虚函数(或等效方法)来最小化滥用不完美构造对象的窗口。
工厂的返回类型通常默认为 unique_ptr
;如果某些使用是共享的,调用者可以将 unique_ptr
move
到 shared_ptr
中。但是,如果工厂作者知道返回对象的所有使用都将是共享使用,则返回 shared_ptr
并在主体中使用 make_shared
来节省一次分配。
class B {
public:
B()
{
/* ... */
f(); // BAD: C.82: Don't call virtual functions in constructors and destructors
/* ... */
}
virtual void f() = 0;
};
class B {
protected:
class Token {};
public:
explicit B(Token) { /* ... */ } // create an imperfectly initialized object
virtual void f() = 0;
template<class T>
static shared_ptr<T> create() // interface for creating shared objects
{
auto p = make_shared<T>(typename T::Token{});
p->post_initialize();
return p;
}
protected:
virtual void post_initialize() // called right after construction
{ /* ... */ f(); /* ... */ } // GOOD: virtual dispatch is safe
};
class D : public B { // some derived class
protected:
class Token {};
public:
explicit D(Token) : B{ B::Token{} } {}
void f() override { /* ... */ };
protected:
template<class T>
friend shared_ptr<T> B::create();
};
shared_ptr<D> p = D::create<D>(); // creating a D object
make_shared
要求构造函数是公共的。通过要求一个受保护的 Token
,构造函数就不能再被公开调用了,所以我们避免了不完整构造的对象逃逸到野外。通过提供工厂函数 create()
,我们使得构造(在自由存储上)变得方便。
传统的工厂函数在自由存储上分配,而不是在栈上或在包含对象中。
另请参阅:讨论。
避免重复和意外差异。
class Date { // BAD: repetitive
int d;
Month m;
int y;
public:
Date(int dd, Month mm, year yy)
:d{dd}, m{mm}, y{yy}
{ if (!valid(d, m, y)) throw Bad_date{}; }
Date(int dd, Month mm)
:d{dd}, m{mm} y{current_year()}
{ if (!valid(d, m, y)) throw Bad_date{}; }
// ...
};
常见操作写起来很费力,而且可能意外地不是共有的。
class Date2 {
int d;
Month m;
int y;
public:
Date2(int dd, Month mm, year yy)
:d{dd}, m{mm}, y{yy}
{ if (!valid(d, m, y)) throw Bad_date{}; }
Date2(int dd, Month mm)
:Date2{dd, mm, current_year()} {}
// ...
};
另请参阅:如果“重复操作”是简单的初始化,请考虑类内默认成员初始化器。
(中等)寻找类似的构造函数体。
如果您需要这些构造函数用于派生类,重新实现它们既费力又容易出错。
std::vector
有很多棘手的构造函数,所以如果我想创建自己的 vector
,我不想重新实现它们。
class Rec {
// ... data and lots of nice constructors ...
};
class Oper : public Rec {
using Rec::Rec;
// ... no data members ...
// ... lots of nice utility functions ...
};
struct Rec2 : public Rec {
int x;
using Rec::Rec;
};
Rec2 r {"foo", 7};
int val = r.x; // uninitialized
确保派生类的每个成员都已初始化。
具体类型通常应该是可复制的,而类层次结构中的接口则不应该是。资源句柄可能是可复制的,也可能不是。类型可以被定义为移动,这既是逻辑原因也是性能原因。
virtual
,参数按 const&
接收,并按非 const&
返回它简单且高效。如果您想优化右值,请提供一个接受 &&
的重载(参见 F.18)。
class Foo {
public:
Foo& operator=(const Foo& x)
{
// GOOD: no need to check for self-assignment (other than performance)
auto tmp = x;
swap(tmp); // see C.83
return *this;
}
// ...
};
Foo a;
Foo b;
Foo f();
a = b; // assign lvalue: copy
a = f(); // assign rvalue: potentially move
swap
实现技术提供了强异常安全保证。
但是,如果您可以通过不创建临时副本获得显著更好的性能怎么办?考虑一个简单的 Vector
,它用于赋值大型、相同大小 Vector
s 常见的数据域。在这种情况下,swap
实现技术隐含的元素复制可能会导致成本增加一个数量级。
template<typename T>
class Vector {
public:
Vector& operator=(const Vector&);
// ...
private:
T* elem;
int sz;
};
Vector& Vector::operator=(const Vector& a)
{
if (a.sz > sz) {
// ... use the swap technique, it can't be bettered ...
return *this;
}
// ... copy sz elements from *a.elem to elem ...
if (a.sz < sz) {
// ... destroy the surplus elements in *this and adjust size ...
}
return *this;
}
通过直接写入目标元素,我们将只获得基本异常安全保证,而不是 swap
技术提供的强异常安全保证。注意自赋值。
替代方法:如果您认为需要一个 virtual
赋值运算符,并且了解为什么这是一个非常棘手的问题,请不要称其为 operator=
。将其命名为类似于 virtual void assign(const Foo&)
的函数。参见 复制构造函数与 clone()
。
T&
以启用链式调用,而不是 const T&
等替代方案,后者会干扰可组合性以及将对象放入容器。这是普遍的假设语义。在 x = y
之后,我们应该有 x == y
。复制后 x
和 y
可以是独立的(值语义,非指针内置类型和标准库类型的工作方式)或引用共享对象(指针语义,指针的工作方式)。
class X { // OK: value semantics
public:
X();
X(const X&); // copy X
void modify(); // change the value of X
// ...
~X() { delete[] p; }
private:
T* p;
int sz;
};
bool operator==(const X& a, const X& b)
{
return a.sz == b.sz && equal(a.p, a.p + a.sz, b.p, b.p + b.sz);
}
X::X(const X& a)
:p{new T[a.sz]}, sz{a.sz}
{
copy(a.p, a.p + sz, p);
}
X x;
X y = x;
if (x != y) throw Bad{};
x.modify();
if (x == y) throw Bad{}; // assume value semantics
class X2 { // OK: pointer semantics
public:
X2();
X2(const X2&) = default; // shallow copy
~X2() = default;
void modify(); // change the pointed-to value
// ...
private:
T* p;
int sz;
};
bool operator==(const X2& a, const X2& b)
{
return a.sz == b.sz && a.p == b.p;
}
X2 x;
X2 y = x;
if (x != y) throw Bad{};
x.modify();
if (x != y) throw Bad{}; // assume pointer semantics
除非您正在构建“智能指针”,否则优先使用值语义。值语义最容易推理,也是标准库设施所期望的。
(无法强制执行)
如果 x = x
更改了 x
的值,人们会感到惊讶并导致严重的错误(通常包括泄漏)。
标准库容器可以优雅高效地处理自赋值。
std::vector<int> v = {3, 1, 4, 1, 5, 9};
v = v;
// the value of v is still {3, 1, 4, 1, 5, 9}
从正确处理自赋值的成员生成的默认赋值可以处理自赋值。
struct Bar {
vector<pair<int, int>> v;
map<string, int> m;
string s;
};
Bar b;
// ...
b = b; // correct and efficient
您可以通过显式测试自赋值来处理自赋值,但通常它更快、更优雅,无需进行此类测试(例如,使用 swap
)。
class Foo {
string s;
int i;
public:
Foo& operator=(const Foo& a);
// ...
};
Foo& Foo::operator=(const Foo& a) // OK, but there is a cost
{
if (this == &a) return *this;
s = a.s;
i = a.i;
return *this;
}
这显然是安全的,并且表面上是高效的。但是,如果我们每百万次赋值中进行一次自赋值呢?这大约是一百万次冗余测试(但由于答案几乎总是相同的,计算机的分支预测几乎总是猜对)。考虑
Foo& Foo::operator=(const Foo& a) // simpler, and probably much better
{
s = a.s;
i = a.i;
return *this;
}
std::string
对自赋值是安全的,int
也是安全的。所有成本都由(罕见的)自赋值案例承担。
(简单)赋值运算符不应包含模式 if (this == &a) return *this;
???
virtual
,参数按 &&
接收,并按非 const&
返回它简单且高效。
参见:复制赋值规则。
等同于复制赋值所做的。
T&
以启用链式调用,而不是 const T&
等替代方案,后者会干扰可组合性以及将对象放入容器。这是普遍的假设语义。在 y = std::move(x)
之后,y
的值应该是 x
原来的值,而 x
应该处于有效状态。
class X { // OK: value semantics
public:
X();
X(X&& a) noexcept; // move X
X& operator=(X&& a) noexcept; // move-assign X
void modify(); // change the value of X
// ...
~X() { delete[] p; }
private:
T* p;
int sz;
};
X::X(X&& a) noexcept
:p{a.p}, sz{a.sz} // steal representation
{
a.p = nullptr; // set to "empty"
a.sz = 0;
}
void use()
{
X x{};
// ...
X y = std::move(x);
x = X{}; // OK
} // OK: x can be destroyed
理想情况下,移动后的源应该是类型的默认值。确保这一点,除非有非常好的理由不这样做。然而,并非所有类型都有默认值,而且对于某些类型,建立默认值可能很昂贵。标准仅要求移动后的对象可以被销毁。通常,我们可以很容易且廉价地做得更好:标准库假定可以对移动后的对象进行赋值。始终将移动后的对象保留在某种(必然指定)有效状态。
除非有非常充分的理由反对,否则请使 x = std::move(y); y = z;
按照常规语义工作。
(无法强制执行)查看移动操作中的成员赋值。如果存在默认构造函数,请将这些赋值与默认构造函数中的初始化进行比较。
如果 x = x
更改了 x
的值,人们会感到惊讶并可能导致严重的错误。但是,人们通常不会直接编写一个会变成移动的自赋值,但这种情况可能发生。然而,std::swap
是使用移动操作实现的,所以如果您意外地执行 swap(a, b)
,其中 a
和 b
指向同一个对象,未能处理自移动可能会是一个严重且微妙的错误。
class Foo {
string s;
int i;
public:
Foo& operator=(Foo&& a) noexcept;
// ...
};
Foo& Foo::operator=(Foo&& a) noexcept // OK, but there is a cost
{
if (this == &a) return *this; // this line is redundant
s = std::move(a.s);
i = a.i;
return *this;
}
关于 if (this == &a) return *this;
测试的“百万分之一”的论点,在自赋值的讨论中,对于自移动来说更为相关。
没有已知的一般方法可以在不进行 if (this == &a) return *this;
测试的情况下仍然得到正确的结果(即,在 x = x
之后 x
的值保持不变)。
ISO 标准仅保证标准库容器的“有效但未指定”状态。显然,这在大约 10 年的实验和生产使用中并未成为问题。如果您发现反例,请联系编辑。这里的规则是更谨慎,并坚持完全安全。
这里有一个在没有测试的情况下移动指针的方法(想象它是移动赋值实现中的代码)。
// move from other.ptr to this->ptr
T* temp = other.ptr;
other.ptr = nullptr;
delete ptr; // in self-move, this->ptr is also null; delete is a no-op
ptr = temp; // in self-move, the original ptr is restored
delete
或设置为 nullptr
的指针成员。string
)的使用情况,并考虑它们对于普通(非生命攸关)用途是安全的。noexcept
抛出异常的移动违反了大多数人的合理假设。非抛出异常的移动将被标准库和语言设施更有效地使用。
template<typename T>
class Vector {
public:
Vector(Vector&& a) noexcept :elem{a.elem}, sz{a.sz} { a.elem = nullptr; a.sz = 0; }
Vector& operator=(Vector&& a) noexcept {
if (&a != this) {
delete elem;
elem = a.elem; a.elem = nullptr;
sz = a.sz; a.sz = 0;
}
return *this;
}
// ...
private:
T* elem;
int sz;
};
这些操作不抛出异常。
template<typename T>
class Vector2 {
public:
Vector2(Vector2&& a) noexcept { *this = a; } // just use the copy
Vector2& operator=(Vector2&& a) noexcept { *this = a; } // just use the copy
// ...
private:
T* elem;
int sz;
};
这个 Vector2
不仅效率低下,而且由于向量复制需要分配,它可能会抛出异常。
(简单)移动操作应标记为 noexcept
。
多态类是定义或继承至少一个虚函数的类。它很可能被用作具有多态行为的其他派生类的基类。如果它被意外地按值传递,并使用隐式生成的复制构造函数和赋值,我们就会面临切片:只有派生对象的基类部分会被复制,多态行为会被破坏。
如果类没有数据,则 =delete
复制/移动函数。否则,将其设为 protected
。
class B { // BAD: polymorphic base class doesn't suppress copying
public:
virtual char m() { return 'B'; }
// ... nothing about copy operations, so uses default ...
};
class D : public B {
public:
char m() override { return 'D'; }
// ...
};
void f(B& b)
{
auto b2 = b; // oops, slices the object; b2.m() will return 'B'
}
D d;
f(d);
class B { // GOOD: polymorphic class suppresses copying
public:
B() = default;
B(const B&) = delete;
B& operator=(const B&) = delete;
virtual char m() { return 'B'; }
// ...
};
class D : public B {
public:
char m() override { return 'D'; }
// ...
};
void f(B& b)
{
auto b2 = b; // ok, compiler will detect inadvertent copying, and protest
}
D d;
f(d);
如果您需要创建多态对象的深拷贝,请使用 clone()
函数:参见 C.130。
表示异常对象的类既需要是多态的,也需要是可复制构造的。
除了语言提供默认实现的那些操作之外,还有一些操作非常基础,以至于需要为它们的定义制定特定的规则:比较、swap
和 hash
。
=default
编译器更有可能正确处理默认语义,而您无法比编译器更好地实现这些函数。
class Tracer {
string message;
public:
Tracer(const string& m) : message{m} { cerr << "entering " << message << '\n'; }
~Tracer() { cerr << "exiting " << message << '\n'; }
Tracer(const Tracer&) = default;
Tracer& operator=(const Tracer&) = default;
Tracer(Tracer&&) noexcept = default;
Tracer& operator=(Tracer&&) noexcept = default;
};
因为我们定义了析构函数,所以我们必须定义复制和移动操作。= default
是做到这一点的最佳且最简单的方法。
class Tracer2 {
string message;
public:
Tracer2(const string& m) : message{m} { cerr << "entering " << message << '\n'; }
~Tracer2() { cerr << "exiting " << message << '\n'; }
Tracer2(const Tracer2& a) : message{a.message} {}
Tracer2& operator=(const Tracer2& a) { message = a.message; return *this; }
Tracer2(Tracer2&& a) noexcept :message{a.message} {}
Tracer2& operator=(Tracer2&& a) noexcept { message = a.message; return *this; }
};
写出复制和移动操作的函数体是冗长、繁琐且容易出错的。编译器做得更好。
(中等)用户定义操作的函数体不应具有与编译器生成的版本相同的语义,因为那将是冗余的。
=delete
在某些情况下,默认操作是不理想的。
class Immortal {
public:
~Immortal() = delete; // do not allow destruction
// ...
};
void use()
{
Immortal ugh; // error: ugh cannot be destroyed
Immortal* p = new Immortal{};
delete p; // error: cannot destroy *p
}
unique_ptr
可以移动,但不能复制。为了实现这一点,它的复制操作被删除了。为了避免复制,有必要从左值=delete
其复制操作。
template<class T, class D = default_delete<T>> class unique_ptr {
public:
// ...
constexpr unique_ptr() noexcept;
explicit unique_ptr(pointer p) noexcept;
// ...
unique_ptr(unique_ptr&& u) noexcept; // move constructor
// ...
unique_ptr(const unique_ptr&) = delete; // disable copy from lvalue
// ...
};
unique_ptr<int> make(); // make "something" and return it by moving
void f()
{
unique_ptr<int> pi {};
auto pi2 {pi}; // error: no move constructor from lvalue
auto pi3 {make()}; // OK, move: the result of make() is an rvalue
}
请注意,删除的函数应该是公共的。
默认操作的消除是(应该是)基于类的期望语义的。将此类视为可疑,但要维护一个“正面列表”,其中有人已断言语义是正确的。
将被调用的函数将是到目前为止构造的对象的函数,而不是派生类中可能覆盖的函数。这可能非常令人困惑。更糟糕的是,从构造函数或析构函数直接或间接调用未实现的纯虚函数会导致未定义的行为。
class Base {
public:
virtual void f() = 0; // not implemented
virtual void g(); // implemented with Base version
virtual void h(); // implemented with Base version
virtual ~Base(); // implemented with Base version
};
class Derived : public Base {
public:
void g() override; // provide Derived implementation
void h() final; // provide Derived implementation
Derived()
{
// BAD: attempt to call an unimplemented virtual function
f();
// BAD: will call Derived::g, not dispatch further virtually
g();
// GOOD: explicitly state intent to call only the visible version
Derived::g();
// ok, no qualification needed, h is final
h();
}
};
请注意,调用特定的显式限定函数不是虚调用,即使该函数是 virtual
的。
另请参阅 工厂函数,了解如何在不冒未定义行为风险的情况下实现调用派生类函数的效果。
在构造函数和析构函数中调用虚函数本身并没有什么问题。此类调用的语义是类型安全的。然而,经验表明,此类调用很少需要,容易混淆维护者,并且在使用时成为初学者的错误来源。
noexcept
的 swap 函数swap 可用于实现多种惯用法,从平滑地移动对象到轻松实现赋值,再到提供保证的提交函数,从而使调用代码具有强大的异常安全。考虑使用 swap 将复制赋值的实现基于复制构造函数。另请参见 析构函数、解分配和 swap 绝不能失败。
class Foo {
public:
void swap(Foo& rhs) noexcept
{
m1.swap(rhs.m1);
std::swap(m2, rhs.m2);
}
private:
Bar m1;
int m2;
};
为方便调用者,在与您的类型相同的命名空间中提供一个非成员 swap
函数。
void swap(Foo& a, Foo& b)
{
a.swap(b);
}
swap
成员函数时,它应该被声明为 noexcept
。swap
函数不得失败swap
被广泛使用,其方式被假定为永远不会失败,而且程序很难编写成在失败的 swap
存在的情况下正确工作。如果 swap
一个元素类型失败,标准库容器和算法将无法正常工作。
void swap(My_vector& x, My_vector& y)
{
auto tmp = x; // copy elements
x = y;
y = tmp;
}
这不仅慢,而且如果为 tmp
中的元素发生内存分配,此 swap
可能会抛出异常,并导致 STL 算法在使用它们时失败。
(简单)当一个类具有 swap
成员函数时,它应该被声明为 noexcept
。
swap
noexcept
一个swap
不应失败。如果一个swap
试图以异常退出,这是一个糟糕的设计错误,程序最好终止。
(简单)当一个类具有 swap
成员函数时,它应该被声明为 noexcept
。
==
在操作数类型和noexcept
方面具有对称性不对称地处理操作数会令人惊讶,并且在可能进行转换的地方会导致错误。==
是一项基本操作,程序员应该能够无忧虑地使用它。
struct X {
string name;
int number;
};
bool operator==(const X& a, const X& b) noexcept {
return a.name == b.name && a.number == b.number;
}
class B {
string name;
int number;
bool operator==(const B& a) const {
return name == a.name && number == a.number;
}
// ...
};
B
的比较接受对其第二个操作数的转换,但对其第一个操作数不接受。
如果一个类有一个失败状态,比如double
的NaN
,那么就会有一种趋势,使与失败状态的比较抛出异常。另一种选择是让两个失败状态相等,而任何有效状态与失败状态的比较都为false。
此规则适用于所有常见的比较运算符:!=
、<
、<=
、>
和>=
。
operator==()
,其参数类型不同;其他比较运算符也一样:!=
、<
、<=
、>
和>=
。operator==()
;其他比较运算符也一样:!=
、<
、<=
、>
和>=
。==
为层次结构编写一个万无一失且有用的==
非常困难。
class B {
string name;
int number;
public:
virtual bool operator==(const B& a) const
{
return name == a.name && number == a.number;
}
// ...
};
B
的比较接受对其第二个操作数的转换,但对其第一个操作数不接受。
class D : public B {
char character;
public:
virtual bool operator==(const D& a) const
{
return B::operator==(a) && character == a.character;
}
// ...
};
B b = ...
D d = ...
b == d; // compares name and number, ignores d's character
d == b; // compares name and number, ignores d's character
D d2;
d == d2; // compares name, number, and character
B& b2 = d2;
b2 == d; // compares name and number, ignores d2's and d's character
当然,有方法可以在层次结构中使用==
,但朴素的方法是不可扩展的。
此规则适用于所有常见的比较运算符:!=
、<
、<=
、>
、>=
和<=>
。
operator==()
;其他比较运算符也一样:!=
、<
、<=
、>
、>=
和<=>
。hash
成为noexcept
哈希容器的用户间接使用哈希,不期望简单的访问会抛出异常。这是标准库的要求。
template<>
struct hash<My_type> { // thoroughly bad hash specialization
using result_type = size_t;
using argument_type = My_type;
size_t operator()(const My_type & x) const
{
size_t xs = x.s.size();
if (xs < 4) throw Bad_My_type{}; // "Nobody expects the Spanish inquisition!"
return hash<size_t>()(x.s.size()) ^ trim(x.s);
}
};
int main()
{
unordered_map<My_type, int> m;
My_type mt{ "asdfg" };
m[mt] = 7;
cout << m[My_type{ "asdfg" }] << '\n';
}
如果你需要定义一个hash
特化,可以尝试简单地将其与标准库hash
特化结合使用^
(异或)。对于非专家来说,这比“聪明”的做法效果更好。
hash
。memset
和memcpy
标准C++构造类型实例的机制是调用其构造函数。如指南C.41所述:构造函数应创建完全初始化的对象。不需要额外的初始化,例如通过memcpy
。memcpy
是一个非平凡可复制类型,使用memcpy
复制会产生未定义行为。这通常会导致切片或数据损坏。
struct base {
virtual void update() = 0;
std::shared_ptr<int> sp;
};
struct derived : public base {
void update() override {}
};
void init(derived& a)
{
memset(&a, 0, sizeof(derived));
}
这是类型不安全的,并且会覆盖vtable。
void copy(derived& a, derived& b)
{
memcpy(&a, &b, sizeof(derived));
}
这也类型不安全,并且会覆盖vtable。
memset
或memcpy
。容器是一个持有某种类型对象序列的对象;std::vector
是典型的容器。资源句柄是一个拥有资源的类;std::vector
是典型的资源句柄;它的资源是它的元素序列。
容器规则总结
*
和->
参见:资源
STL容器对大多数C++程序员来说都很熟悉,并且是一个基本健全的设计。
当然,还有其他基本健全的设计风格,有时也有理由偏离标准库的风格,但在没有稳固理由不同时,遵循标准库对实现者和用户来说都更简单、更容易。
特别是,std::vector
和std::map
提供了有用的相对简单的模型。
// simplified (e.g., no allocators):
template<typename T>
class Sorted_vector {
using value_type = T;
// ... iterator types ...
Sorted_vector() = default;
Sorted_vector(initializer_list<T>); // initializer-list constructor: sort and store
Sorted_vector(const Sorted_vector&) = default;
Sorted_vector(Sorted_vector&&) noexcept = default;
Sorted_vector& operator=(const Sorted_vector&) = default; // copy assignment
Sorted_vector& operator=(Sorted_vector&&) noexcept = default; // move assignment
~Sorted_vector() = default;
Sorted_vector(const std::vector<T>& v); // store and sort
Sorted_vector(std::vector<T>&& v); // sort and "steal representation"
const T& operator[](int i) const { return rep[i]; }
// no non-const direct access to preserve order
void push_back(const T&); // insert in the right place (not necessarily at back)
void push_back(T&&); // insert in the right place (not necessarily at back)
// ... cbegin(), cend() ...
private:
std::vector<T> rep; // use a std::vector to hold elements
};
template<typename T> bool operator==(const Sorted_vector<T>&, const Sorted_vector<T>&);
template<typename T> bool operator!=(const Sorted_vector<T>&, const Sorted_vector<T>&);
// ...
这里遵循了STL风格,但不完全。这并不少见。只提供对特定容器有意义的功能。关键是定义常规的构造函数、赋值、析构函数和迭代器(对特定容器有意义),并具有它们常规的语义。在此基础上,可以根据需要扩展容器。这里添加了来自std::vector
的特殊构造函数。
???
常规对象比不常规对象更容易思考和推理。熟悉度。
如果可能,使容器成为Regular
(概念)。特别是,确保对象与其副本相等。
void f(const Sorted_vector<string>& v)
{
Sorted_vector<string> v2 {v};
if (v != v2)
cout << "Behavior against reason and logic.\n";
// ...
}
???
容器往往会变得很大;没有移动构造函数和复制构造函数,移动对象可能会很昂贵,从而诱使人们传递指向它的指针,并陷入资源管理问题。
Sorted_vector<int> read_sorted(istream& is)
{
vector<int> v;
cin >> v; // assume we have a read operation for vectors
Sorted_vector<int> sv = v; // sorts
return sv;
}
用户可以合理地假设返回一个类似标准的容器是便宜的。
???
人们期望能够用一组值来初始化容器。熟悉度。
Sorted_vector<int> sv {1, 3, -1, 7, 0, 0}; // Sorted_vector sorts elements as needed
???
使其成为Regular
。
vector<Sorted_sequence<string>> vs(100); // 100 Sorted_sequences each with the value ""
???
*
和->
这正是指针所期望的。熟悉度。
???
???
函数对象是一个提供重载()
的对象,这样你就可以调用它。Lambda表达式(通常简称为“lambda”)是生成函数对象的符号。函数对象应该易于复制(因此按值传递)。
摘要
const
变量的初始化类层次结构用于表示一组层次结构化的概念(仅此而已)。通常基类充当接口。层次结构有两个主要用途,通常称为实现继承和接口继承。
类层次结构规则总结
设计层次结构中类的规则总结
virtual
、override
或final
中的一个clone
,而不是公共复制构造/赋值virtual
protected
数据const
数据成员具有相同的访问级别virtual
基类来避免过于通用的基类using
为派生类及其基类创建重载集final
类访问层次结构中的对象规则总结
dynamic_cast
dynamic_cast
到引用类型dynamic_cast
到指针类型unique_ptr
或shared_ptr
,避免忘记delete
使用new
创建的对象make_unique()
构造由unique_ptr
拥有的对象make_shared()
构造由shared_ptr
拥有的对象直接在代码中表示想法可以减轻理解和维护的难度。确保基类中表示的思想与所有派生类型完全匹配,并且没有更好的方法来表达它,而不是使用继承的紧密耦合。
当仅有一个数据成员就足够时,不要使用继承。通常这意味着派生类型需要重写基类的虚函数,或者需要访问受保护成员。
class DrawableUIElement {
public:
virtual void render() const = 0;
// ...
};
class AbstractButton : public DrawableUIElement {
public:
virtual void onClick() = 0;
// ...
};
class PushButton : public AbstractButton {
void render() const override;
void onClick() override;
// ...
};
class Checkbox : public AbstractButton {
// ...
};
不要将非层次结构的域概念表示为类层次结构。
template<typename T>
class Container {
public:
// list operations:
virtual T& get() = 0;
virtual void put(T&) = 0;
virtual void insert(Position) = 0;
// ...
// vector operations:
virtual T& operator[](int) = 0;
virtual void sort() = 0;
// ...
// tree operations:
virtual void balance() = 0;
// ...
};
在这里,大多数重写类都无法很好地实现接口所需的大部分函数。因此,基类成为实现负担。此外,Container
的用户不能依赖成员函数实际执行有意义的操作并具有合理的效率;它可能会抛出异常。因此,用户必须诉诸于运行时检查和/或不使用这个(过度)泛化的接口,而偏好于通过运行时类型查询(例如dynamic_cast
)找到的特定接口。
B
的使用,其中派生类D
不重写B
中的虚函数或访问受保护成员,并且B
不是以下之一:空、D
的模板参数或参数包、由D
特化的类模板。一个类如果它不包含数据,那么它会更稳定(更不容易出错)。接口通常应该完全由公共纯虚函数和一个默认/空的虚析构函数组成。
class My_interface {
public:
// ... only pure virtual functions here ...
virtual ~My_interface() {} // or =default
};
class Goof {
public:
// ... only pure virtual functions here ...
// no virtual destructor
};
class Derived : public Goof {
string s;
// ...
};
void use()
{
unique_ptr<Goof> p {new Derived{"here we go"}};
f(p.get()); // use Derived through the Goof interface
g(p.get()); // use Derived through the Goof interface
} // leak
Derived
通过其Goof
接口被delete
d,因此它的string
发生了内存泄漏。给Goof
一个虚析构函数,一切都会好起来的。
final
)的虚函数(但不是从基类继承来的)的类。例如在ABI(链接)边界上。
struct Device {
virtual ~Device() = default;
virtual void write(span<const char> outbuf) = 0;
virtual void read(span<char> inbuf) = 0;
};
class D1 : public Device {
// ... data ...
void write(span<const char> outbuf) override;
void read(span<char> inbuf) override;
};
class D2 : public Device {
// ... different data ...
void write(span<const char> outbuf) override;
void read(span<char> inbuf) override;
};
现在用户可以通过Device
提供的接口来互换使用D1
和D2
。此外,只要所有访问都通过Device
,我们就可以更新D1
和D2
,而无需与旧版本二进制兼容。
???
抽象类通常没有任何数据供构造函数初始化。
class Shape {
public:
// no user-written constructor needed in abstract base class
virtual Point center() const = 0; // pure virtual
virtual void move(Point to) = 0;
// ... more pure virtual functions ...
virtual ~Shape() {} // destructor
};
class Circle : public Shape {
public:
Circle(Point p, int rad); // constructor in derived class
Point center() const override { return x; }
};
标记带有构造函数的抽象类。
具有虚函数的类通常(并且一般而言)是通过基类指针使用的。通常,最后一个用户需要对基类指针调用delete,通常是通过基类智能指针,因此析构函数应该是公共的并且是虚的。不太常见的是,如果打算不支持通过基类指针进行删除,则析构函数应该是受保护的而非虚的;参见C.35。
struct B {
virtual int f() = 0;
// ... no user-written destructor, defaults to public non-virtual ...
};
// bad: derived from a class without a virtual destructor
struct D : B {
string s {"default"};
// ...
};
void use()
{
unique_ptr<B> p = make_unique<D>();
// ...
} // undefined behavior, might call B::~B only and leak the string
有些人不遵循此规则,因为他们计划仅通过shared_ptr
使用该类:std::shared_ptr<B> p = std::make_shared<D>(args);
在这里,共享指针将负责删除,因此不会因不适当的基类delete
而导致泄露。那些始终这样做的人可能会得到一个误报,但该规则很重要——如果有人使用make_unique
分配了怎么办?除非B
的作者确保它永远不会被滥用,例如通过使所有构造函数私有并提供工厂函数来强制使用make_shared
进行分配,否则它是不安全的。
delete
。virtual
、override
或final
中的一个可读性。错误检测。编写显式的virtual
、override
或final
是自文档化的,并允许编译器捕获基类和派生类之间的类型和/或名称不匹配。但是,编写这三个中的一个以上既是冗余的,也是潜在的错误来源。
它简单明了
virtual
表示“这是一个新的虚函数”,仅此而已。override
表示“这是一个非最终的重载”,仅此而已。final
表示“这是一个最终的重载”,仅此而已。struct B {
void f1(int);
virtual void f2(int) const;
virtual void f3(int);
// ...
};
struct D : B {
void f1(int); // bad (hope for a warning): D::f1() hides B::f1()
void f2(int) const; // bad (but conventional and valid): no explicit override
void f3(double); // bad (hope for a warning): D::f3() hides B::f3()
// ...
};
struct Better : B {
void f1(int) override; // error (caught): Better::f1() hides B::f1()
void f2(int) const override;
void f3(double) override; // error (caught): Better::f3() hides B::f3()
// ...
};
我们要消除两类特定的错误
注意:在一个声明为final
的类上,每个单独的虚函数都应使用override
或final
;在这种情况下没有语义差异。
注意:谨慎使用final
函数。它不一定带来优化,并且它排除了进一步的重写。
override
也没有final
的重载。virtual
、override
和final
的函数声明。接口中的实现细节使得接口容易出错;也就是说,使得其用户在实现更改后容易受到重新编译的影响。基类中的数据增加了实现基类的复杂性,并可能导致代码复制。
定义
纯接口类只是一组纯虚函数;参见I.25。
在早期的OOP(例如,在20世纪80年代和90年代)中,实现继承和接口继承经常混合在一起,不良习惯难以改变。即使是现在,在旧代码库和旧式教学材料中,混合使用的情况并不少见。
保持这两种继承方式的重要性增加
class Shape { // BAD, mixed interface and implementation
public:
Shape();
Shape(Point ce = {0, 0}, Color co = none): cent{ce}, col {co} { /* ... */}
Point center() const { return cent; }
Color color() const { return col; }
virtual void rotate(int) = 0;
virtual void move(Point p) { cent = p; redraw(); }
virtual void redraw();
// ...
private:
Point cent;
Color col;
};
class Circle : public Shape {
public:
Circle(Point c, int r) : Shape{c}, rad{r} { /* ... */ }
// ...
private:
int rad;
};
class Triangle : public Shape {
public:
Triangle(Point p1, Point p2, Point p3); // calculate center
// ...
};
问题
Shape
添加更多数据,构造函数变得更难编写和维护。Triangle
的中心?我们可能永远不会使用它。Shape
添加数据成员(例如,绘图风格或画布),所有从Shape
派生的类以及所有使用Shape
的代码都需要进行审查、可能更改,并且可能重新编译。Shape::move()
的实现是实现继承的一个例子:我们为所有派生类定义了一次move()
。此类基类成员函数实现中的代码越多,并且通过将数据放在基类中共享的数据越多,我们获得的益处就越大——并且层次结构就越不稳定。
这个Shape层次结构可以使用接口继承来重写
class Shape { // pure interface
public:
virtual Point center() const = 0;
virtual Color color() const = 0;
virtual void rotate(int) = 0;
virtual void move(Point p) = 0;
virtual void redraw() = 0;
// ...
};
注意,纯接口很少有构造函数:没有什么需要构造的。
class Circle : public Shape {
public:
Circle(Point c, int r, Color c) : cent{c}, rad{r}, col{c} { /* ... */ }
Point center() const override { return cent; }
Color color() const override { return col; }
// ...
private:
Point cent;
int rad;
Color col;
};
现在接口的脆弱性降低了,但是实现成员函数的工作量增加了。例如,center
必须由每个派生自Shape
的类来实现。
我们如何从接口继承获得稳定层次结构的优势,并从实现继承获得实现重用的优势?一种流行的技术是双重层次结构。实现双重层次结构思想的方法有很多;在这里,我们使用一种多重继承变体。
首先,我们设计一个接口类层次结构
class Shape { // pure interface
public:
virtual Point center() const = 0;
virtual Color color() const = 0;
virtual void rotate(int) = 0;
virtual void move(Point p) = 0;
virtual void redraw() = 0;
// ...
};
class Circle : public virtual Shape { // pure interface
public:
virtual int radius() = 0;
// ...
};
为了使此接口有用,我们必须提供其实现类(此处命名相同,但在Impl
命名空间中)
class Impl::Shape : public virtual ::Shape { // implementation
public:
// constructors, destructor
// ...
Point center() const override { /* ... */ }
Color color() const override { /* ... */ }
void rotate(int) override { /* ... */ }
void move(Point p) override { /* ... */ }
void redraw() override { /* ... */ }
// ...
};
现在Shape
是具有实现的类的糟糕示例,但请忍耐,因为这只是一个针对更复杂层次结构的简单技术示例。
class Impl::Circle : public virtual ::Circle, public Impl::Shape { // implementation
public:
// constructors, destructor
int radius() override { /* ... */ }
// ...
};
并且我们可以通过添加Smiley类(:-))来扩展层次结构
class Smiley : public virtual Circle { // pure interface
public:
// ...
};
class Impl::Smiley : public virtual ::Smiley, public Impl::Circle { // implementation
public:
// constructors, destructor
// ...
}
现在有两个层次结构
由于每个实现都派生自其接口以及其实现基类,因此我们得到一个格子(DAG)
Smiley -> Circle -> Shape
^ ^ ^
| | |
Impl::Smiley -> Impl::Circle -> Impl::Shape
如前所述,这是构建双重层次结构的一种方法。
实现层次结构可以直接使用,而不是通过抽象接口。
void work_with_shape(Shape&);
int user()
{
Impl::Smiley my_smiley{ /* args */ }; // create concrete shape
// ...
my_smiley.some_member(); // use implementation class directly
// ...
work_with_shape(my_smiley); // use implementation through abstract interface
// ...
}
当实现类具有抽象接口中未提供的成员,或者直接使用成员提供优化机会时(例如,如果实现成员函数是final
),这会很有用。
另一个(相关的)分离接口和实现的技术是Pimpl。
通常可以选择将通用功能作为(已实现的)基类函数和独立函数(在实现命名空间中)提供。基类提供了更短的表示法和对共享数据(在基类中)的更易访问,代价是该功能仅对层次结构的用户可用。
clone
,而不是公共复制构造/赋值复制多态类由于切片问题(参见C.67)而受到不鼓励。如果您确实需要复制语义,请进行深拷贝:提供一个虚函数clone
,它将复制实际的最派生类型并返回一个指向新对象的拥有指针,然后在派生类中返回派生类型(使用协变返回类型)。
class B {
public:
B() = default;
virtual ~B() = default;
virtual gsl::owner<B*> clone() const = 0;
protected:
B(const B&) = default;
B& operator=(const B&) = default;
B(B&&) noexcept = default;
B& operator=(B&&) noexcept = default;
// ...
};
class D : public B {
public:
gsl::owner<D*> clone() const override
{
return new D{*this};
};
};
通常,建议使用智能指针来表示所有权(参见R.20)。但是,由于语言规则,协变返回类型不能是智能指针:D::clone
不能返回unique_ptr<D>
,而B::clone
返回unique_ptr<B>
。因此,您需要始终在所有重载中返回unique_ptr<B>
,或者使用Guidelines Support Library中的owner<>
实用程序。
琐碎的getter或setter不增加语义值;数据项可以同样是public
的。
class Point { // Bad: verbose
int x;
int y;
public:
Point(int xx, int yy) : x{xx}, y{yy} { }
int get_x() const { return x; }
void set_x(int xx) { x = xx; }
int get_y() const { return y; }
void set_y(int yy) { y = yy; }
// no behavioral member functions
};
考虑将此类设为struct
——也就是说,一个无行为的变量集合,所有数据都是公共的,没有成员函数。
struct Point {
int x {0};
int y {0};
};
注意,我们可以对数据成员设置默认初始化器:C.49:在构造函数中优先使用初始化而不是赋值。
此规则的关键在于getter/setter的语义是否琐碎。虽然它不是“琐碎”的完整定义,但请考虑如果getter/setter是一个公共数据成员,除了语法之外是否会有任何区别。非琐碎语义的例子包括:维护类不变量或在内部类型和接口类型之间进行转换。
标记多个get
和set
成员函数,这些函数仅在没有附加语义的情况下访问成员。
virtual
冗余的virtual
会增加运行时和目标代码的大小。虚函数可以被重写,因此在派生类中容易出错。虚函数确保在模板化层次结构中的代码复制。
template<class T>
class Vector {
public:
// ...
virtual int size() const { return sz; } // bad: what good could a derived class do?
private:
T* elem; // the elements
int sz; // number of elements
};
这种“向量”根本不应该用作基类。
protected
数据替代表述:使成员数据public
或(最好)private
。
protected
数据是复杂性和错误的来源。protected
数据使不变量的陈述复杂化。protected
数据本身就违反了关于不将数据放入基类的指导原则,这通常也导致需要处理虚拟继承。
class Shape {
public:
// ... interface functions ...
protected:
// data for use in derived classes:
Color fill_color;
Color edge_color;
Style st;
};
现在,每个派生的 Shape
都需要正确地操作受保护的数据。这曾经很流行,但也是维护问题的主要来源。在大型类层次结构中,受保护数据的持续使用很难维护,因为可能有大量代码分散在许多类中。可以访问该数据的类集是开放的:任何人都可以派生一个新类并开始操作受保护的数据。通常,无法检查类集合的完整性,因此对类表示的任何更改都变得不可行。受保护的数据没有强制性的不变量;它很像一组全局变量。受保护的数据实际上已成为大量代码的全局变量。
受保护的数据通常看起来很诱人,可以通过派生来实现任意改进。通常,您得到的是不讲原则的更改和错误。 偏好 private
数据,并具有明确定义和强制执行的不变量。或者,通常更好的是,将数据排除在任何用作接口的类之外。
受保护的成员函数可能完全没问题。
用protected
数据标记类。
const
数据成员具有相同的访问级别防止逻辑混淆导致错误。如果非 const
数据成员没有相同的访问级别,则该类型会对其正在执行的操作感到困惑。它是一个维护不变量的类型,还是仅仅是一个值集合?
核心问题是:什么代码负责维护该变量的有意义/正确的值?
数据成员只有两种类型:
A 类的数据成员应该是 public
(或者,更少见的是,protected
,如果您只想让派生类看到它们)。它们不需要封装。系统中的所有代码都可以查看并操作它们。
B 类的数据成员应该是 private
或 const
。这是因为封装很重要。如果它们不是 private
且不是 const
,则意味着对象无法控制自己的状态:类以外的无界数量的代码都需要了解不变量并参与精确地维护它——如果这些数据成员是 public
的,那么所有使用该对象的所有调用代码都将受影响;如果是 protected
的,那么所有当前和未来的派生类中的代码都将受影响。这会导致脆弱且紧密耦合的代码,这些代码很快就会变成维护的噩梦。任何无意中将数据成员设置为无效或意外值组合的代码都会损坏对象以及对象的所有后续使用。
大多数类要么是全 A,要么是全 B。
public
的。 根据约定,将此类声明为 struct
而不是 class
。const
变量都应该是私有的——它应该被封装。偶尔类会混合 A 和 B,通常是为了调试原因。封装的对象可能包含一些非 const
的调试仪器,这些仪器不是不变量的一部分,因此属于 A 类——它们也不是对象的值或有意义的可观察状态的一部分。在这种情况下,A 部分应被视为 A(设为 public
,或在较少情况下为 protected
,如果它们只应为派生类可见),而 B 部分仍应视为 B(private
或 const
)。
标记具有不同访问级别的非 const
数据成员的任何类。
并非所有类都必然支持所有接口,也并非所有调用者都必然希望处理所有操作。特别是要将单一的接口分解为给定派生类支持的行为的“方面”。
class iostream : public istream, public ostream { // very simplified
// ...
};
istream
提供输入操作的接口;ostream
提供输出操作的接口。iostream
提供 istream
和 ostream
接口的联合以及在单个流上允许两者同步的同步。
这是继承的一个非常常见的用法,因为对实现的多种不同接口的需求很常见,而且此类接口通常不容易或不自然地组织成单一根层次结构。
此类接口通常是抽象类。
???
某些形式的 mixins 具有状态,通常也有对该状态的操作。如果操作是虚拟的,则需要继承;如果不使用继承,则可以避免样板代码和转发。
class iostream : public istream, public ostream { // very simplified
// ...
};
istream
提供输入操作(和一些数据)的接口;ostream
提供输出操作(和一些数据)的接口。iostream
提供 istream
和 ostream
接口的联合以及在单个流上允许两者同步的同步。
这是一种相对罕见的用法,因为实现通常可以组织成单一根层次结构。
有时,“实现属性”更像是“mixin”,它决定了实现的行为,并注入成员以实现其所需策略的实现。例如,请参阅 std::enable_shared_from_this
或 boost.intrusive 的各种基类(例如 list_base_hook
或 intrusive_ref_counter
)。
???
virtual
基类来避免过于通用的基类允许分离共享数据和接口。避免将所有共享数据放入最终基类。
struct Interface {
virtual void f();
virtual int g();
// ... no data here ...
};
class Utility { // with data
void utility1();
virtual void utility2(); // customization point
public:
int x;
int y;
};
class Derive1 : public Interface, virtual protected Utility {
// override Interface functions
// Maybe override Utility virtual functions
// ...
};
class Derive2 : public Interface, virtual protected Utility {
// override Interface functions
// Maybe override Utility virtual functions
// ...
};
将 Utility
分解是有意义的,如果许多派生类共享重要的“实现细节”。
显然,这个例子太“理论化”了,很难找到一个小的实际示例。Interface
是接口层次结构的根,而 Utility
是实现层次结构的根。这里有一个稍微更现实的例子及其解释。
通常,层次结构的线性化是更好的解决方案。
标记混合接口和实现层次结构。
using
为派生类及其基类创建重载集没有 using 声明,派生类中的成员函数会隐藏继承的整个重载集。
#include <iostream>
class B {
public:
virtual int f(int i) { std::cout << "f(int): "; return i; }
virtual double f(double d) { std::cout << "f(double): "; return d; }
virtual ~B() = default;
};
class D: public B {
public:
int f(int i) override { std::cout << "f(int): "; return i + 1; }
};
int main()
{
D d;
std::cout << d.f(2) << '\n'; // prints "f(int): 3"
std::cout << d.f(2.3) << '\n'; // prints "f(int): 3"
}
class D: public B {
public:
int f(int i) override { std::cout << "f(int): "; return i + 1; }
using B::f; // exposes f(double)
};
此问题同时影响虚拟和非虚拟成员函数。
对于可变参数基类,C++17 引入了 using 声明的可变参数形式。
template<class... Ts>
struct Overloader : Ts... {
using Ts::operator()...; // exposes operator() from every base
};
诊断名称隐藏。
final
类用 final
类封顶层次结构很少是出于逻辑原因需要,并且可能损害层次结构的扩展性。
class Widget { /* ... */ };
// nobody will ever want to improve My_widget (or so you thought)
class My_widget final : public Widget { /* ... */ };
class My_improved_widget : public My_widget { /* ... */ }; // error: can't do that
并非所有类都设计为基类。大多数标准库类就是例子(例如,std::vector
和 std::string
不是设计用来派生的)。此规则是关于在具有虚拟函数的类上使用 final
,这些类是类层次结构的接口。
声称 final
可带来性能提升的说法应得到证实。这些说法太常基于推测或其他语言的经验。
在某些情况下,final
对于逻辑和性能原因都很重要。一个例子是编译器或语言分析工具中性能关键的 AST 层次结构。并非每年都会添加新的派生类,只有库实现者才会添加。然而,误用(或至少曾经)远为普遍。
标记类上对 final
的使用。
这会引起混淆:重载函数不继承默认参数。
class Base {
public:
virtual int multiply(int value, int factor = 2) = 0;
virtual ~Base() = default;
};
class Derived : public Base {
public:
int multiply(int value, int factor = 10) override;
};
Derived d;
Base& b = d;
b.multiply(10); // these two calls will call the same function but
d.multiply(10); // with different arguments and so different results
如果虚拟函数的默认参数在基类和派生类声明之间不同,则标记默认参数。
如果你有一个带有虚拟函数的类,你(通常)不知道是哪个类提供了要使用的函数。
struct B { int a; virtual int f(); virtual ~B() = default };
struct D : B { int b; int f() override; };
void use(B b)
{
D d;
B b2 = d; // slice
B b3 = b;
}
void use2()
{
D d;
use(d); // slice
}
两个 d
都被切片了。
你可以在其定义的作用域中安全地访问命名多态对象,只是不要对其进行切片。
void use3()
{
D d;
d.f(); // OK
}
标记所有切片。
dynamic_cast
dynamic_cast
在运行时进行检查。
struct B { // an interface
virtual void f();
virtual void g();
virtual ~B();
};
struct D : B { // a wider interface
void f() override;
virtual void h();
};
void user(B* pb)
{
if (D* pd = dynamic_cast<D*>(pb)) {
// ... use D's interface ...
}
else {
// ... make do with B's interface ...
}
}
使用其他转换可能会违反类型安全,并导致程序访问实际为 X
类型的变量,但将其视为不相关的类型 Z
。
void user2(B* pb) // bad
{
D* pd = static_cast<D*>(pb); // I know that pb really points to a D; trust me
// ... use D's interface ...
}
void user3(B* pb) // unsafe
{
if (some_condition) {
D* pd = static_cast<D*>(pb); // I know that pb really points to a D; trust me
// ... use D's interface ...
}
else {
// ... make do with B's interface ...
}
}
void f()
{
B b;
user(&b); // OK
user2(&b); // bad error
user3(&b); // OK *if* the programmer got the some_condition check right
}
与其他转换一样,dynamic_cast
被过度使用。 偏好虚拟函数而不是转换。如果可能且方便,请偏好静态多态而不是层次结构导航(无需运行时解析)。
有些人使用 dynamic_cast
而不是 typeid
,这更合适;dynamic_cast
是用于发现对象最佳接口的通用“is kind of”操作,而 typeid
是用于发现对象实际类型的“获取对象的确切类型”操作。后者是一个本质上更简单的操作,应该更快。后者(typeid
)如果需要,可以轻松手动实现(例如,如果您在 RTTI 被禁止的系统上工作),而前者(dynamic_cast
)在通用情况下要正确实现得多。
考虑
struct B {
const char* name {"B"};
// if pb1->id() == pb2->id() *pb1 is the same type as *pb2
virtual const char* id() const { return name; }
// ...
};
struct D : B {
const char* name {"D"};
const char* id() const override { return name; }
// ...
};
void use()
{
B* pb1 = new B;
B* pb2 = new D;
cout << pb1->id(); // "B"
cout << pb2->id(); // "D"
if (pb2->id() == "D") { // looks innocent
D* pd = static_cast<D*>(pb2);
// ...
}
// ...
}
结果 pb2->id() == "D"
实际上是实现定义的。我们添加它是为了警示自制 RTTI 的危险。这段代码可能会多年按预期工作,只是在新机器、新编译器或新的链接器上失败,而这些新机器、新编译器或新链接器未能统一字符文字。
如果您自己实现 RTTI,请小心。
如果您的实现提供了非常慢的 dynamic_cast
,您可能需要一个解决方法。但是,所有无法静态解析的解决方法都涉及显式转换(通常是 static_cast
)并且容易出错。您基本上是在创建自己的专用 dynamic_cast
。因此,首先确保您的 dynamic_cast
确实像您认为的那样慢(有很多未经证实的传言),并且您对 dynamic_cast
的使用确实是性能关键的。
我们认为当前 dynamic_cast
的实现不必要地慢。例如,在适当的条件下,可以在快速恒定时间内执行 dynamic_cast
。但是,兼容性使得更改变得困难,即使所有人都同意优化工作是值得的。
在极少数情况下,如果您已测量到 dynamic_cast
的开销很大,您有其他方法可以静态保证向下转换会成功(例如,您正在仔细使用 CRTP),并且没有涉及虚拟继承,请考虑有策略地使用 static_cast
,并附带一个醒目的注释和免责声明,总结此段落,并说明在维护过程中需要人工关注,因为类型系统无法验证正确性。即便如此,根据我们的经验,这种情况仍然是已知的错误来源。
考虑
template<typename B>
class Dx : B {
// ...
};
static_cast
用于向下转换的情况,包括执行 static_cast
的 C 风格转换。dynamic_cast
转换为引用类型转换为引用表示您打算得到一个有效的对象,因此转换必须成功。dynamic_cast
将在失败时抛出。
std::string f(Base& b)
{
return dynamic_cast<Derived&>(b).to_string();
}
???
dynamic_cast
转换为指针类型dynamic_cast
转换允许测试指针是否指向具有给定类在其层次结构中的多态对象。由于找不到类仅返回空值,因此可以在运行时进行测试。这允许编写根据结果选择替代路径的代码。
与 C.147 相比,在 C.147 中,失败是错误,不应用于条件执行。
下面的示例描述了一个 Shape_owner
的 add
函数,该函数拥有 Shape
对象的构造。对象还根据其几何属性进行排序。在此示例中,Shape
不继承自 Geometric_attributes
。只有它的子类才继承。
void add(Shape* const item)
{
// Ownership is always taken
owned_shapes.emplace_back(item);
// Check the Geometric_attributes and add the shape to none/one/some/all of the views
if (auto even = dynamic_cast<Even_sided*>(item))
{
view_of_evens.emplace_back(even);
}
if (auto trisym = dynamic_cast<Trilaterally_symmetrical*>(item))
{
view_of_trisyms.emplace_back(trisym);
}
}
找不到所需类将导致 dynamic_cast
返回空值,解引用空值指针将导致未定义行为。因此,dynamic_cast
的结果应始终视为可能为空,并进行测试。
dynamic_cast
结果进行了空值测试,否则在解引用指针时发出警告。unique_ptr
或 shared_ptr
来避免忘记 delete
使用 new
创建的对象避免资源泄漏。
void use(int i)
{
auto p = new int {7}; // bad: initialize local pointers with new
auto q = make_unique<int>(9); // ok: guarantee the release of the memory-allocated for 9
if (0 < i) return; // maybe return and leak
delete p; // too late
}
new
的结果初始化裸指针。delete
。make_unique()
来构造由 unique_ptr
拥有的对象请参阅 R.23。
make_shared()
来构造由 shared_ptr
拥有的对象请参阅 R.22。
对结果基类指针进行下标访问将导致无效的对象访问,并可能导致内存损坏。
struct B { int x; };
struct D : B { int y; };
void use(B*);
D a[] = { {1, 2}, {3, 4}, {5, 6} };
B* p = a; // bad: a decays to &a[0] which is converted to a B*
p[1].x = 7; // overwrite a[0].y
use(a); // bad: a decays to &a[0] which is converted to a B*
span
而不是指针传递,并且不要让数组名称在进入 span
之前发生派生到基类的转换。虚拟函数调用是安全的,而转换容易出错。虚拟函数调用会到达最派生的函数,而转换可能会到达中间类,因此会给出错误的结果(尤其是在层次结构在维护过程中被修改时)。
???
请参阅 C.146 和 ???。
您可以重载普通函数、函数模板和运算符。您不能重载函数对象。
重载规则摘要。
using
&
最大限度地减少意外。
class X {
public:
// ...
X& operator=(const X&); // member function defining assignment
friend bool operator==(const X&, const X&); // == needs access to representation
// after a = b we have a == b
// ...
};
此处,维持了传统的语义:副本相等。
X operator+(X a, X b) { return a.v - b.v; } // bad: makes + subtract
非成员运算符应该是友元函数,或者定义在与其操作数相同的命名空间中。 二元运算符应同等地对待其操作数。
可能不可能。
如果您使用成员函数,则需要两个。除非您为(例如)==
使用非成员函数,否则 a == b
和 b == a
会有细微的差别。
bool operator==(Point a, Point b) { return a.x == b.x && a.y == b.y; }
标记成员运算符函数。
对不同参数类型具有相同名称的逻辑上等效的操作会令人困惑,会导致在函数名中编码类型信息,并阻碍通用编程。
考虑
void print(int a);
void print(int a, int base);
void print(const string&);
这三个函数都打印它们的参数(恰当)。反之则
void print_int(int a);
void print_based(int a, int base);
void print_string(const string&);
这三个函数都打印它们的参数(恰当)。在名称中添加内容只会增加冗余并阻碍通用代码。
???
对逻辑上不同的函数使用相同的名称会令人困惑,并在使用通用编程时导致错误。
考虑
void open_gate(Gate& g); // remove obstacle from garage exit lane
void fopen(const char* name, const char* mode); // open file
这两个操作在根本上是不同的(且不相关),因此它们的名称不同是好的。反之则
void open(Gate& g); // remove obstacle from garage exit lane
void open(const char* name, const char* mode ="r"); // open file
这两个操作在根本上仍然是不同的(且不相关),但名称已缩减到(共同的)最小值,从而带来了混淆的可能性。幸运的是,类型系统将捕获许多此类错误。
要特别注意常用且流行的名称,例如 open
、move
、+
和 ==
。
???
隐式转换可能很重要(例如,double
到 int
),但常常会带来惊喜(例如,String
到 C 风格字符串)。
除非有严重的需求,否则偏好显式命名的转换。这里的“严重需求”是指应用程序域中的基本原因(如整数到复数转换)并且频繁需要。不要仅仅为了获得微小的便利性而引入隐式转换(通过转换运算符或非 explicit
构造函数)。
struct S1 {
string s;
// ...
operator char*() { return s.data(); } // BAD, likely to cause surprises
};
struct S2 {
string s;
// ...
explicit operator char*() { return s.data(); }
};
void f(S1 s1, S2 s2)
{
char* x1 = s1; // OK, but can cause surprises in many contexts
char* x2 = s2; // error (and that's usually a good thing)
char* x3 = static_cast<char*>(s2); // we can be explicit (on your head be it)
}
令人惊讶且可能有害的隐式转换可能发生在任意难以发现的上下文中,例如:
S1 ff();
char* g()
{
return ff();
}
ff()
返回的字符串在返回的指向它的指针可以使用之前就被销毁了。
标记所有非显式转换运算符。
using
查找定义在不同命名空间中的函数对象和函数以“自定义”通用函数。
考虑 swap
。它是一个通用的(标准库)函数,其定义几乎适用于任何类型。但是,为特定类型定义特定的 swap()
是可取的。例如,通用的 swap()
会复制被交换的两个 vector
的元素,而好的特定实现则不会复制元素。
namespace N {
My_type X { /* ... */ };
void swap(X&, X&); // optimized swap for N::X
// ...
}
void f1(N::X& a, N::X& b)
{
std::swap(a, b); // probably not what we wanted: calls std::swap()
}
f1()
中的 std::swap()
做了我们要求它做的:它调用 std
命名空间中的 swap()
。不幸的是,这可能不是我们想要的。我们如何让 N::X
被考虑在内?
void f2(N::X& a, N::X& b)
{
swap(a, b); // calls N::swap
}
但这可能不是我们想要的通用代码。在那里,我们通常希望在存在特定函数时使用特定函数,在不存在时使用通用函数。这是通过在函数查找中包含通用函数来完成的。
void f3(N::X& a, N::X& b)
{
using std::swap; // make std::swap available
swap(a, b); // calls N::swap if it exists, otherwise std::swap
}
不太可能,除了已知的自定义点,例如 swap
。问题在于,未加限定和限定查找都有其用途。
&
&
运算符是 C++ 的基本运算符。C++ 语义的许多部分都假定其默认含义。
class Ptr { // a somewhat smart pointer
Ptr(X* pp) : p(pp) { /* check */ }
X* operator->() { /* check */ return p; }
X operator[](int i);
X operator*();
private:
T* p;
};
class X {
Ptr operator&() { return Ptr{this}; }
// ...
};
如果您“弄乱”运算符 &
,请确保其定义对于结果类型的 ->
、[]
、*
和 .
具有匹配的含义。请注意,目前运算符 .
无法重载,因此完美的系统是不可能的。我们希望解决这个问题:Operator Dot (R2)。请注意,std::addressof()
始终产生内置指针。
棘手。如果 &
是用户定义的,但没有为结果类型定义 ->
,则发出警告。
可读性。约定。可重用性。支持通用代码。
void cout_my_class(const My_class& c) // confusing, not conventional, not generic
{
std::cout << /* class members here */;
}
std::ostream& operator<<(std::ostream& os, const my_class& c) // OK
{
return os << /* class members here */;
}
cout_my_class
本身是可以的,但它不能与依赖于 <<
约定进行输出的代码兼容/组合。
My_class var { /* ... */ };
// ...
cout << "var = " << var << '\n';
大多数运算符的含义都有强烈的、活跃的约定,例如:
==
、!=
、<
、<=
、>
、>=
和 <=>
),+
、-
、*
、/
和 %
).
、->
、一元 *
和 []
)=
)。不要非传统地定义它们,也不要为它们发明自己的名称。
棘手。需要语义洞察力。
可读性。能够使用 ADL 查找运算符。避免在不同命名空间中进行不一致的定义。
struct S { };
S operator+(S, S); // OK: in the same namespace as S, and even next to S
S s;
S r = s + s;
namespace N {
struct S { };
S operator+(S, S); // OK: in the same namespace as S, and even next to S
}
N::S s;
S r = s + s; // finds N::operator+() by ADL
struct S { };
S s;
namespace N {
bool operator!(S a) { return true; }
bool not_s = !s;
}
namespace M {
bool operator!(S a) { return false; }
bool not_s = !s;
}
这里,!s
的含义在 N
和 M
中不同。这可能会非常令人困惑。移除 namespace M
的定义,混淆就会被使人犯错的机会所取代。
如果一个二元运算符为定义在不同命名空间中的两个类型定义,则不能遵循此规则。例如:
Vec::Vector operator*(const Vec::Vector&, const Mat::Matrix&);
这可能最好避免。
这是“辅助函数应定义在其类的命名空间中”规则的特例。
您不能通过定义两个同名的不同 lambda 来重载。
void f(int);
void f(double);
auto f = [](char); // error: cannot overload variable and function
auto g = [](int) { /* ... */ };
auto g = [](double) { /* ... */ }; // error: cannot overload variables
auto h = [](auto) { /* ... */ }; // OK
编译器会捕获重载 lambda 的尝试。
union
是一个 struct
,其中所有成员都从同一地址开始,因此它一次只能容纳一个成员。union
不跟踪存储了哪个成员,因此程序员必须正确处理;这本身就容易出错,但有一些方法可以补偿。
类型为 union
加上一个指示当前存储哪个成员的指示符,称为标签联合、区分联合或变体。
联合体规则摘要。
union
来节省内存union
允许在不同时间将同一块内存用于不同类型的对象。因此,当我们有几个永远不会同时使用的对象时,它可用于节省内存。
union Value {
int x;
double d;
};
Value v = { 123 }; // now v holds an int
cout << v.x << '\n'; // write 123
v.d = 987.654; // now v holds a double
cout << v.d << '\n'; // write 987.654
但请注意警告:避免使用“裸露”的 union
// Short-string optimization
constexpr size_t buffer_size = 16; // Slightly larger than the size of a pointer
class Immutable_string {
public:
Immutable_string(const char* str) :
size(strlen(str))
{
if (size < buffer_size)
strcpy_s(string_buffer, buffer_size, str);
else {
string_ptr = new char[size + 1];
strcpy_s(string_ptr, size + 1, str);
}
}
~Immutable_string()
{
if (size >= buffer_size)
delete[] string_ptr;
}
const char* get_str() const
{
return (size < buffer_size) ? string_buffer : string_ptr;
}
private:
// If the string is short enough, we store the string itself
// instead of a pointer to the string.
union {
char* string_ptr;
char string_buffer[buffer_size];
};
const size_t size;
};
???
union
裸露的 union 是指没有关联的指示符来说明它包含哪个成员(如果有)的 union,因此程序员必须自行跟踪。裸露的 union 是类型错误的来源。
union Value {
int x;
double d;
};
Value v;
v.d = 987.654; // v holds a double
到目前为止一切都很好,但我们很容易滥用 union
cout << v.x << '\n'; // BAD, undefined behavior: v holds a double, but we read it as an int
请注意,类型错误发生在没有任何显式转换的情况下。当我们测试该程序时,打印的最后一个值是 1683627180
,这是 987.654
的位模式的整数值。我们这里存在一个“隐形”的类型错误,它碰巧产生了一个可能很容易看起来无害的结果。
而且,说到“隐形”,这段代码没有产生任何输出
v.x = 123;
cout << v.d << '\n'; // BAD: undefined behavior
将 union
包装在类中,并附带一个类型字段。
C++17 的 variant
类型(在 <variant>
中找到)为您完成了这项工作
variant<int, double> v;
v = 123; // v holds an int
int x = get<int>(v);
v = 123.456; // v holds a double
double w = get<double>(v);
???
union
来实现标记联合设计良好的标记联合是类型安全的。匿名 union 简化了带有(标签,联合)对的类的定义。
此示例大部分是从 TC++PL4,第 216–218 页借用的。您可以在那里查找解释。
代码有些复杂。处理具有用户定义赋值和析构函数的类型很棘手。让程序员不必编写此类代码是 variant
被包含在标准中的原因之一。
class Value { // two alternative representations represented as a union
private:
enum class Tag { number, text };
Tag type; // discriminant
union { // representation (note: anonymous union)
int i;
string s; // string has default constructor, copy operations, and destructor
};
public:
struct Bad_entry { }; // used for exceptions
~Value();
Value& operator=(const Value&); // necessary because of the string variant
Value(const Value&);
// ...
int number() const;
string text() const;
void set_number(int n);
void set_text(const string&);
// ...
};
int Value::number() const
{
if (type != Tag::number) throw Bad_entry{};
return i;
}
string Value::text() const
{
if (type != Tag::text) throw Bad_entry{};
return s;
}
void Value::set_number(int n)
{
if (type == Tag::text) {
s.~string(); // explicitly destroy string
type = Tag::number;
}
i = n;
}
void Value::set_text(const string& ss)
{
if (type == Tag::text)
s = ss;
else {
new(&s) string{ss}; // placement new: explicitly construct string
type = Tag::text;
}
}
Value& Value::operator=(const Value& e) // necessary because of the string variant
{
if (type == Tag::text && e.type == Tag::text) {
s = e.s; // usual string assignment
return *this;
}
if (type == Tag::text) s.~string(); // explicit destroy
switch (e.type) {
case Tag::number:
i = e.i;
break;
case Tag::text:
new(&s) string(e.s); // placement new: explicit construct
}
type = e.type;
return *this;
}
Value::~Value()
{
if (type == Tag::text) s.~string(); // explicit destroy
}
???
union
进行类型双关读取类型与写入时不同的 union
成员是未定义行为。这种双关是隐形的,或者至少比使用命名转换更难发现。使用 union
进行类型双关是错误的来源。
union Pun {
int x;
unsigned char c[sizeof(int)];
};
Pun
的想法是能够查看 int
的字符表示。
void bad(Pun& u)
{
u.x = 'x';
cout << u.c[0] << '\n'; // undefined behavior
}
如果您想查看 int
的字节,请使用(命名)转换
void if_you_must_pun(int& x)
{
auto p = reinterpret_cast<std::byte*>(&x);
cout << to_integer<unsigned>(p[0]) << '\n'; // OK; better
// ...
}
访问从对象的声明类型到 char*
、unsigned char*
或 std::byte*
的 reinterpret_cast
的结果是定义行为。(不鼓励使用 reinterpret_cast
,但至少我们可以看到一些棘手的事情正在发生。)
不幸的是,union
s 常用于类型双关。我们不认为“有时,它的效果符合预期”是一个决定性的论据。
C++17 引入了一个独立的类型 std::byte
来方便对原始对象表示进行操作。对于这些操作,请使用该类型而不是 unsigned char
或 char
。
???
枚举用于定义整数值集合以及为这些值集合定义类型。有两种枚举,“普通”enum
和 class enum
。
枚举规则摘要
enum class
而不是“普通”enum
ALL_CAPS
宏不遵循作用域和类型规则。此外,宏名称在预处理期间被删除,因此通常不会出现在调试器等工具中。
首先是一些旧的坏代码
// webcolors.h (third party header)
#define RED 0xFF0000
#define GREEN 0x00FF00
#define BLUE 0x0000FF
// productinfo.h
// The following define product subtypes based on color
#define RED 0
#define PURPLE 1
#define BLUE 2
int webby = BLUE; // webby == 2; probably not what was desired
改为使用 enum
enum class Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum class Product_info { red = 0, purple = 1, blue = 2 };
int webby = blue; // error: be specific
Web_color webby = Web_color::blue;
我们使用了 enum class
来避免名称冲突。
还可以考虑 constexpr
和 const inline
变量。
标记定义整数值的宏。改用 enum
或 const inline
或其他非宏替代项。
枚举显示枚举数是相关的,并且可以是一个命名类型。
enum class Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
对枚举进行 switch 操作很常见,编译器可以警告不寻常的 case 标签模式。例如
enum class Product_info { red = 0, purple = 1, blue = 2 };
void print(Product_info inf)
{
switch (inf) {
case Product_info::red: cout << "red"; break;
case Product_info::purple: cout << "purple"; break;
}
}
这种“差一”的 switch
语句通常是由于添加了枚举数和测试不足造成的。
switch
语句,其中 case
覆盖了枚举数的大部分但不是全部。此规则生成一个警报,表示未处理的枚举值。switch
语句,其中 case
覆盖了枚举数中的少数几个,但没有 default
。此规则会生成一个警报,指示缺少 default
。为尽量减少意外:传统枚举会轻易地转换为 int。
void Print_color(int color);
enum Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum Product_info { red = 0, purple = 1, blue = 2 };
Web_color webby = Web_color::blue;
// Clearly at least one of these calls is buggy.
Print_color(webby);
Print_color(Product_info::blue);
改为使用 enum class
void Print_color(int color);
enum class Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum class Product_info { red = 0, purple = 1, blue = 2 };
Web_color webby = Web_color::blue;
Print_color(webby); // Error: cannot convert Web_color to int.
Print_color(Product_info::red); // Error: cannot convert Product_info to int.
(简单)警告任何非类 enum
定义。
使用方便,避免错误。
enum class Day { mon, tue, wed, thu, fri, sat, sun };
Day& operator++(Day& d)
{
return d = (d == Day::sun) ? Day::mon : static_cast<Day>(static_cast<int>(d)+1);
}
Day today = Day::sat;
Day tomorrow = ++today;
static_cast
的使用并不好看,但是
Day& operator++(Day& d)
{
return d = (d == Day::sun) ? Day::mon : Day{++d}; // error
}
是一个无限递归,并且在没有转换的情况下编写它,使用所有情况的 switch
会冗长。
标记转换为枚举数的重复表达式。
ALL_CAPS
避免与宏冲突。
// webcolors.h (third party header)
#define RED 0xFF0000
#define GREEN 0x00FF00
#define BLUE 0x0000FF
// productinfo.h
// The following define product subtypes based on color
enum class Product_info { RED, PURPLE, BLUE }; // syntax error
标记 ALL_CAPS 枚举数。
如果你不能命名一个枚举,那么这些值就没有关联。
enum { red = 0xFF0000, scale = 4, is_signed = 1 };
这种代码在有方便的替代方法来指定整数常量之前编写的代码中并不罕见。
改用 constexpr
值。例如
constexpr int red = 0xFF0000;
constexpr short scale = 4;
constexpr bool is_signed = true;
标记无名枚举。
默认值是最容易阅读和书写的。int
是默认的整数类型。int
与 C enum
兼容。
enum class Direction : char { n, s, e, w,
ne, nw, se, sw }; // underlying type saves space
enum class Web_color : int32_t { red = 0xFF0000,
green = 0x00FF00,
blue = 0x0000FF }; // underlying type is redundant
指定底层类型对于前向声明 enum 或 enum class 是必需的
enum Flags : char;
void f(Flags);
// ....
enum Flags : char { /* ... */ };
或者确保该类型的值具有指定的位精度
enum Bitboard : uint64_t { /* ... */ };
????
这是最简单的。它避免了重复的枚举数值。默认值给出了一个连续的值集,这对于 switch
语句实现很有用。
enum class Col1 { red, yellow, blue };
enum class Col2 { red = 1, yellow = 2, blue = 2 }; // typo
enum class Month { jan = 1, feb, mar, apr, may, jun,
jul, august, sep, oct, nov, dec }; // starting with 1 is conventional
enum class Base_flag { dec = 1, oct = dec << 1, hex = dec << 2 }; // set of bits
指定值对于匹配约定俗成的值(例如 Month
)是必需的,并且在不需要连续值时(例如,获取单独的位,如 Base_flag
)是必需的。
本节包含与资源相关的规则。资源是任何需要获取和(显式或隐式)释放的东西,例如内存、文件句柄、套接字和锁。必须释放它的原因是它通常是稀缺的,因此即使延迟释放也可能造成危害。根本目标是确保我们不泄漏任何资源,并且不持有资源的时间超过我们需要的时间。负责释放资源的实体称为所有者。
在某些情况下,泄漏是可以接受的,甚至是最佳的:如果您编写的程序仅根据输入生成输出,并且所需的内存量与输入的大小成正比,那么最佳策略(为了性能和编程简易性)有时就是从不删除任何东西。如果您有足够的内存来处理最大的输入,那就继续泄漏,但请务必在出错时给出良好的错误消息。在这里,我们忽略这些情况。
资源管理规则摘要
分配和 deallocation 规则摘要
unique_ptr
或 shared_ptr
来表示所有权unique_ptr
而不是 shared_ptr
,除非您需要共享所有权make_shared()
来创建 shared_ptr
make_unique()
来创建 unique_ptr
std::weak_ptr
来打破 shared_ptr
的循环std
智能指针,请遵循 std
的基本模式unique_ptr<widget>
作为参数传递,以表示函数假定对 widget
的所有权unique_ptr<widget>&
作为参数传递,以表示函数会重新设置 widget
shared_ptr<widget>
作为参数传递,以表示共享所有权shared_ptr<widget>&
作为参数传递,以表示函数可能会重新设置共享指针const shared_ptr<widget>&
作为参数传递,以表示它可能会保留对对象的引用计数???以避免泄漏和手动资源管理的复杂性。C++ 的语言强制执行的构造函数/析构函数对称性反映了资源获取/释放函数对(如 fopen
/fclose
、lock
/unlock
和 new
/delete
)所固有的对称性。每当您处理需要配对的获取/释放函数调用的资源时,请将该资源封装在一个强制执行配对的对象中——在其构造函数中获取资源,在其析构函数中释放资源。
考虑
void send(X* x, string_view destination)
{
auto port = open_port(destination);
my_mutex.lock();
// ...
send(port, x);
// ...
my_mutex.unlock();
close_port(port);
delete x;
}
在这段代码中,您必须记住在所有路径上(无论是否发生异常)调用 unlock
、close_port
和 delete
,并且每个调用都只发生一次。此外,如果标记为 ...
的任何代码抛出异常,那么 x
将被泄漏,并且 my_mutex
将保持锁定状态。
考虑
void send(unique_ptr<X> x, string_view destination) // x owns the X
{
Port port{destination}; // port owns the PortHandle
lock_guard<mutex> guard{my_mutex}; // guard owns the lock
// ...
send(port, x);
// ...
} // automatically unlocks my_mutex and deletes the pointer in x
现在所有资源清理都是自动的,无论是否发生异常,都会在所有路径上执行一次。作为奖励,该函数现在声明它接管了指针的所有权。
什么是 Port
?一个封装资源的方便的包装器
class Port {
PortHandle port;
public:
Port(string_view destination) : port{open_port(destination)} { }
~Port() { close_port(port); }
operator PortHandle() { return port; }
// port handles can't usually be cloned, so disable copying and assignment if necessary
Port(const Port&) = delete;
Port& operator=(const Port&) = delete;
};
当资源“行为不端”,即它不表示为带有析构函数的类时,请将其包装在一个类中或使用 finally
另请参见:RAII
数组最好用容器类型(例如 vector
(所有权))或 span
(非所有权)表示。这些容器和视图包含足够的信息来进行范围检查。
void f(int* p, int n) // n is the number of elements in p[]
{
// ...
p[2] = 7; // bad: subscript raw pointer
// ...
}
编译器不读取注释,并且在不读取其他代码的情况下,您不知道 p
是否真的指向 n
个元素。请改用 span
。
void g(int* p, int fmt) // print *p using format #fmt
{
// ... uses *p and p[0] only ...
}
C 风格字符串作为指向零终止字符序列的单个指针传递。使用 zstring
而不是 char*
来表示您依赖于该约定。
许多当前使用指向单个元素的指针的地方可以改用引用。但是,当 nullptr
是可能的值时,引用可能不是合理的替代方案。
++
),该指针不属于容器、视图或迭代器。如果应用于旧代码库,此规则将产生大量误报。T*
)是非所有权指针C++ 标准或大多数代码中没有任何规定说明否则,并且大多数原始指针都是非所有权指针。我们希望所有权指针得到识别,以便我们能够可靠高效地删除所有权指针指向的对象。
void f()
{
int* p1 = new int{7}; // bad: raw owning pointer
auto p2 = make_unique<int>(7); // OK: the int is owned by a unique pointer
// ...
}
unique_ptr
通过保证删除其对象(即使在存在异常的情况下)来防止泄漏。T*
则不行。
template<typename T>
class X {
public:
T* p; // bad: it is unclear whether p is owning or not
T* q; // bad: it is unclear whether q is owning or not
// ...
};
我们可以通过使所有权显式来解决这个问题
template<typename T>
class X2 {
public:
owner<T*> p; // OK: p is owning
T* q; // OK: q is not owning
// ...
};
一大类例外是遗留代码,特别是必须仍可编译为 C 或通过 ABIs 与 C 风格的 C++ 接口的代码。存在数十亿行违反此关于所有权 T*
s 的规则的代码这一事实是不可忽视的。我们很乐意看到程序转换工具将 20 年前的“遗留代码”转换为闪亮的新代码,我们鼓励此类工具的开发、部署和使用,我们希望这些指南将有助于此类工具的开发,我们甚至为此做出了贡献(并继续贡献)到该领域的研究和开发中。但是,这需要时间:“遗留代码”的生成速度比我们翻新旧代码的速度快,而且在未来几年内还会如此。
这段代码不能全部重写(即使假设有好的代码转换软件),尤其是不能很快重写。这个问题无法(大规模)解决,因为我们需要/使用我们基本资源句柄实现中的所有权“原始指针”以及简单的指针。例如,常见的 vector
实现有一个所有权指针和两个非所有权指针。许多 ABI(以及几乎所有 C 代码的接口)使用 T*
,其中一些是所有权指针。某些接口不能简单地用 owner
进行注释,因为它们需要仍可编译为 C(尽管这对宏来说可能是一个罕见的良好用途,在 C++ 模式下仅扩展为 owner
)。
owner<T*>
除了 T*
之外没有默认语义。它可以不改变任何使用它的代码而使用,也不会影响 ABI。它只是程序员和分析工具的一个指示符。例如,如果一个类的成员是 owner<T*>
,那么该类最好有一个析构函数来 delete
它。
返回(原始)指针会给调用者带来生命周期管理的不确定性;也就是说,谁来删除被指向的对象?
Gadget* make_gadget(int n)
{
auto p = new Gadget{n};
// ...
return p;
}
void caller(int n)
{
auto p = make_gadget(n); // remember to delete p
// ...
delete p;
}
除了遭受 泄漏 的问题外,这还增加了虚假的分配和 deallocation 操作,而且不必要地冗长。如果 Gadget 从函数中移出很便宜(即,它很小或具有高效的移动操作),只需“按值”返回它(请参阅 “out”返回值)
Gadget make_gadget(int n)
{
Gadget g{n};
// ...
return g;
}
此规则适用于工厂函数。
如果需要指针语义(例如,因为返回类型需要引用基类(接口)的类层次结构),则返回“智能指针”。
owner<T>
的原始指针执行 delete
。reset
或显式 delete
owner<T>
指针。new
的返回值被赋给原始指针,则发出警告。T&
)是非所有权引用C++ 标准或大多数代码中没有任何规定说明否则,并且大多数原始引用都是非所有权引用。我们希望所有权指针得到识别,以便我们能够可靠高效地删除所有权指针指向的对象。
void f()
{
int& r = *new int{7}; // bad: raw owning reference
// ...
delete &r; // bad: violated the rule against deleting raw pointers
}
另请参见:原始指针规则
参见 原始指针规则
作用域对象是局部对象、全局对象或成员。这意味着除了已用于包含作用域或对象的成本外,没有单独的分配和 deallocation 成本。作用域对象的成员本身也是作用域的,并且作用域对象的构造函数和析构函数管理成员的生命周期。
以下示例效率低下(因为它有不必要的分配和 deallocation),容易因异常抛出和 ...
部分中的返回而产生问题(导致泄漏),并且冗长
void f(int n)
{
auto p = new Gadget{n};
// ...
delete p;
}
而是,使用局部变量
void f(int n)
{
Gadget g{n};
// ...
}
Unique_pointer
或 Shared_pointer
在其生命周期结束前未被移动、复制、重新赋值或 reset
,则发出警告(除非声明为 const
)。例外:对于指向未绑定数组的局部 Unique_pointer
,不要发出此类警告。(见下文。)如果您的堆栈空间有限,那么创建一个局部 const unique_ptr<BigObject>
来将对象存储在堆而不是堆栈上是没问题的。
const
的全局变量参见 I.2
malloc()
和 free()
malloc()
和 free()
不支持构造和析构,并且与 new
和 delete
混合效果不佳。
class Record {
int id;
string name;
// ...
};
void use()
{
// p1 might be nullptr
// *p1 is not initialized; in particular,
// that string isn't a string, but a string-sized bag of bits
Record* p1 = static_cast<Record*>(malloc(sizeof(Record)));
auto p2 = new Record;
// unless an exception is thrown, *p2 is default initialized
auto p3 = new(nothrow) Record;
// p3 might be nullptr; if not, *p3 is default initialized
// ...
delete p1; // error: cannot delete object allocated by malloc()
free(p2); // error: cannot free() object allocated by new
}
在某些实现中,delete
和 free()
可能会起作用,或者它们可能会导致运行时错误。
有些应用程序和代码段不允许异常。其中一些最好的例子是生命攸关的硬实时代码。请注意,许多禁止使用异常的禁令是基于迷信(坏的)或对具有非系统化资源管理的旧代码库的担忧(不幸的是,但有时是必要的)。在这种情况下,请考虑 new
的 nothrow
版本。
标记显式使用 malloc
和 free
。
new
和 delete
new
返回的指针应属于一个资源句柄(可以调用 delete
)。如果 new
返回的指针被赋给一个普通/裸露指针,对象可能会被泄漏。
在一个大型程序中,裸露的 delete
(即应用程序代码中的 delete
,而不是专门用于资源管理的代码的一部分)很可能是一个错误:如果您有 N 个 delete
,您如何确定您不需要 N+1 或 N-1?错误可能是潜在的:它可能仅在维护期间出现。如果您有一个裸露的 new
,您可能需要在某处有一个裸露的 delete
,所以您可能有一个错误。
(简单)警告任何显式使用 new
和 delete
。建议改用 make_unique
。
如果您不这样做,异常或返回可能会导致泄漏。
void func(const string& name)
{
FILE* f = fopen(name, "r"); // open the file
vector<char> buf(1024);
auto _ = finally([f] { fclose(f); }); // remember to close the file
// ...
}
buf
的分配可能会失败并泄漏文件句柄。
void func(const string& name)
{
ifstream f{name}; // open the file
vector<char> buf(1024);
// ...
}
文件句柄的使用(在 ifstream
中)是简单、高效且安全的。
如果您在一个语句中执行两次显式资源分配,您可能会泄漏资源,因为许多子表达式(包括函数参数)的评估顺序是不确定的。
void fun(shared_ptr<Widget> sp1, shared_ptr<Widget> sp2);
这个 fun
可以这样调用
// BAD: potential leak
fun(shared_ptr<Widget>(new Widget(a, b)), shared_ptr<Widget>(new Widget(c, d)));
这对于异常是不安全的,因为编译器可能会重新排序构建函数两个参数的两个表达式。特别是,编译器可能在两个表达式之间交错执行:内存分配(通过调用 operator new
)可以先为两个对象完成,然后尝试调用两个 Widget
构造函数。如果其中一个构造函数调用抛出异常,那么另一个对象的内存将永远不会被释放!
这个微妙的问题有一个简单的解决方案:永远不要在单个表达式语句中执行超过一次显式资源分配。例如
shared_ptr<Widget> sp1(new Widget(a, b)); // Better, but messy
fun(sp1, new Widget(c, d));
最好的解决方案是完全避免显式分配,使用返回所有权对象的工厂函数
fun(make_shared<Widget>(a, b), make_shared<Widget>(c, d)); // Best
如果还没有工厂包装器,请编写自己的工厂包装器。
[]
参数,优先使用 span
数组会退化为指针,从而丢失其大小,从而为范围错误提供了机会。使用 span
来保留大小信息。
void f(int[]); // not recommended
void f(int*); // not recommended for multiple objects
// (a pointer should point to a single object, do not subscript)
void f(gsl::span<int>); // good, recommended
标记 []
参数。改用 span
。
否则,您将得到不匹配的操作和混乱。
class X {
// ...
void* operator new(size_t s);
void operator delete(void*);
// ...
};
如果您想要无法 deallocate 的内存,请将 deallocation 操作设为 =delete
。不要让它未声明。
标记不完整的配对。
unique_ptr
或 shared_ptr
来表示所有权它们可以防止资源泄漏。
考虑
void f()
{
X* p1 { new X }; // bad, p1 will leak
auto p2 = make_unique<X>(); // good, unique ownership
auto p3 = make_shared<X>(); // good, shared ownership
}
这将泄漏用于初始化 p1
的对象(仅此而已)。
new
的返回值被赋给原始指针,则发出警告。unique_ptr
而不是 shared_ptr
,除非您需要共享所有权unique_ptr
在概念上更简单,更可预测(您知道何时发生销毁),并且速度更快(您不需要隐式维护使用计数)。
这不必要地添加和维护了引用计数。
void f()
{
shared_ptr<Base> base = make_shared<Derived>();
// use base locally, without copying it -- refcount never exceeds 1
} // destroy base
这更有效
void f()
{
unique_ptr<Base> base = make_unique<Derived>();
// use base locally
} // destroy base
(简单)如果一个函数使用 Shared_pointer
来处理函数内部分配的对象,但从不返回 Shared_pointer
或将其传递给需要 Shared_pointer
的函数,则发出警告。建议改用 unique_ptr
。
make_shared()
来创建 shared_ptr
make_shared
提供了更简洁的构造声明。它还提供了消除引用计数单独分配的机会,方法是将 shared_ptr
的使用计数放在其对象旁边。它还确保了复杂表达式中的异常安全性(在 C++17 之前的代码中)。
考虑
shared_ptr<X> p1 { new X{2} }; // bad
auto p = make_shared<X>(2); // good
make_shared()
版本只提到 X
一次,所以它通常比带显式 new
的版本更短(也更快)。
(简单) 如果 shared_ptr
是由 new
的结果而不是 make_shared
构建的,则发出警告。
make_unique()
来创建 unique_ptr
make_unique
更简洁地表达了构造。它还确保了复杂表达式中的异常安全(在 C++17 之前的代码中)。
unique_ptr<Foo> p {new Foo{7}}; // OK: but repetitive
auto q = make_unique<Foo>(7); // Better: no repetition of Foo
(简单) 如果 unique_ptr
是由 new
的结果而不是 make_unique
构建的,则发出警告。
std::weak_ptr
来打破 shared_ptr
的循环shared_ptr
依赖于引用计数,而循环结构中的引用计数永远不会变为零,因此我们需要一种机制来销毁循环结构。
#include <memory>
class bar;
class foo {
public:
explicit foo(const std::shared_ptr<bar>& forward_reference)
: forward_reference_(forward_reference)
{ }
private:
std::shared_ptr<bar> forward_reference_;
};
class bar {
public:
explicit bar(const std::weak_ptr<foo>& back_reference)
: back_reference_(back_reference)
{ }
void do_something()
{
if (auto shared_back_reference = back_reference_.lock()) {
// Use *shared_back_reference
}
}
private:
std::weak_ptr<foo> back_reference_;
};
??? (HS:很多人说“打破循环”,但我认为“临时共享所有权”更贴切。) ??? (BS:打破循环是你必须做的;临时共享所有权是你如何做到的。你只需使用另一个 shared_ptr
就可以“临时共享所有权”。)
??? 可能不可能。如果我们能够静态检测到循环,我们就无需 weak_ptr
请参见 F.7。
std
智能指针,请遵循 std
的基本模式以下部分的规则也适用于其他类型的第三方和自定义智能指针,并且对于诊断导致性能和正确性问题的常见智能指针错误非常有用。你应该让这些规则适用于你使用的所有智能指针。
任何重载了一元 *
和 ->
的类型(包括主模板或特化)都被视为智能指针。
shared_ptr
。unique_ptr
。// use Boost's intrusive_ptr
#include <boost/intrusive_ptr.hpp>
void f(boost::intrusive_ptr<widget> p) // error under rule 'sharedptrparam'
{
p->foo();
}
// use Microsoft's CComPtr
#include <atlbase.h>
void f(CComPtr<widget> p) // error under rule 'sharedptrparam'
{
p->foo();
}
这两种情况都违反了 sharedptrparam
指南:p
是一个 Shared_pointer
,但这里没有使用它的共享性,按值传递它是一种隐式的性能下降;这些函数应该只在需要参与小组件的生命周期管理时才接受智能指针。否则,如果它可以为 nullptr
,它们应该接受一个 widget*
。否则,理想情况下,函数应该接受一个 widget&
。这些智能指针匹配 Shared_pointer
概念,因此这些指南强制执行规则可以开箱即用,并暴露了这种常见的性能下降。
unique_ptr<widget>
作为参数,以表示函数假定拥有 widget
的所有权以这种方式使用 unique_ptr
可以同时记录和强制执行函数调用的所有权转移。
void sink(unique_ptr<widget>); // takes ownership of the widget
void uses(widget*); // just uses the widget
Unique_pointer<T>
参数,并且在至少一个代码路径中没有对其进行赋值或调用 reset()
,则发出警告。建议改用 T*
或 T&
。unique_ptr<widget>&
作为参数,以表示函数会重置 widget
以这种方式使用 unique_ptr
可以同时记录和强制执行函数调用的重置语义。
“重置”是指“使指针或智能指针指向另一个对象”。
void reseat(unique_ptr<widget>&); // "will" or "might" reseat pointer
Unique_pointer<T>
参数,并且在至少一个代码路径中没有对其进行赋值或调用 reset()
,则发出警告。建议改用 T*
或 T&
。shared_ptr<widget>
作为参数,以表示共享所有权这使得函数的所有权共享变得明确。
class WidgetUser
{
public:
// WidgetUser will share ownership of the widget
explicit WidgetUser(std::shared_ptr<widget> w) noexcept:
m_widget{std::move(w)} {}
// ...
private:
std::shared_ptr<widget> m_widget;
};
Shared_pointer<T>
参数,并且在至少一个代码路径中没有对其进行赋值或调用 reset()
,则发出警告。建议改用 T*
或 T&
。const
引用接受 Shared_pointer<T>
,并且在至少一个代码路径中没有将其复制或移动到另一个 Shared_pointer
,则发出警告。建议改用 T*
或 T&
。Shared_pointer<T>
,则发出警告。建议改用按值传递。shared_ptr<widget>&
作为参数,以表示函数可能会重置共享指针这使得函数的所有权重置变得明确。
“重置”是指“使引用或智能指针指向另一个对象”。
void ChangeWidget(std::shared_ptr<widget>& w)
{
// This will change the callers widget
w = std::make_shared<widget>(widget{});
}
Shared_pointer<T>
参数,并且在至少一个代码路径中没有对其进行赋值或调用 reset()
,则发出警告。建议改用 T*
或 T&
。const
引用接受 Shared_pointer<T>
,并且在至少一个代码路径中没有将其复制或移动到另一个 Shared_pointer
,则发出警告。建议改用 T*
或 T&
。Shared_pointer<T>
,则发出警告。建议改用按值传递。const shared_ptr<widget>&
作为参数,以表示它可能会保留对象的引用计数???这使得函数 ??? 的重置变得明确。
void share(shared_ptr<widget>); // share -- "will" retain refcount
void reseat(shared_ptr<widget>&); // "might" reseat ptr
void may_share(const shared_ptr<widget>&); // "might" retain refcount
Shared_pointer<T>
参数,并且在至少一个代码路径中没有对其进行赋值或调用 reset()
,则发出警告。建议改用 T*
或 T&
。const
引用接受 Shared_pointer<T>
,并且在至少一个代码路径中没有将其复制或移动到另一个 Shared_pointer
,则发出警告。建议改用 T*
或 T&
。Shared_pointer<T>
,则发出警告。建议改用按值传递。违反此规则是丢失引用计数并最终导致悬空指针的首要原因。函数应优先将原始指针和引用向下传递到调用链中。在调用树的顶部,你从一个保持对象存活的智能指针获取原始指针或引用。你需要确保智能指针不会在下面的调用树中被无意中重置或重新分配。
为了做到这一点,有时你需要获取智能指针的本地副本,这会有效地在函数和调用树的持续时间内保持对象存活。
考虑以下代码
// global (static or heap), or aliased local ...
shared_ptr<widget> g_p = ...;
void f(widget& w)
{
g();
use(w); // A
}
void g()
{
g_p = ...; // oops, if this was the last shared_ptr to that widget, destroys the widget
}
以下代码不应通过代码审查
void my_code()
{
// BAD: passing pointer or reference obtained from a non-local smart pointer
// that could be inadvertently reset somewhere inside f or its callees
f(*g_p);
// BAD: same reason, just passing it as a "this" pointer
g_p->func();
}
修复很简单——获取指针的本地副本,为你的调用树“保留引用计数”。
void my_code()
{
// cheap: 1 increment covers this entire function and all the call trees below us
auto pin = g_p;
// GOOD: passing pointer or reference obtained from a local unaliased smart pointer
f(*pin);
// GOOD: same reason
pin->func();
}
Unique_pointer
或 Shared_pointer
),或者本地但可能被别名的智能指针变量获取的指针或引用在函数调用中使用,则发出警告。如果智能指针是 Shared_pointer
,则建议获取该智能指针的本地副本,然后从中获取指针或引用。表达式和语句是表达动作和计算的最直接、最底层的方式。局部作用域中的声明也是语句。
有关命名、注释和缩进规则,请参见 NL:命名和布局。
通用规则
声明规则
ALL_CAPS
名称auto
避免类型名称的冗余重复{}
初始化语法unique_ptr<T>
来持有指针const
或 constexpr
std::array
或 stack_array
来声明数组const
变量的初始化ALL_CAPS
表达式规则
nullptr
而不是 0
或 NULL
const
std::move()
new
和 delete
delete[]
删除数组,使用 delete
删除非数组T{e}
语法进行构造语句规则
switch
语句而不是 if
语句for
语句而不是 for
语句for
语句而不是 while
语句while
语句而不是 for
语句for
语句的初始化部分声明循环变量do
语句goto
break
和 continue
switch
语句中的隐式贯穿default
来处理常见情况(仅限)==
或 !=
算术规则
unsigned
来避免负值unsigned
,优先使用 gsl::index
使用库的代码比直接使用语言特性的代码更容易编写,更短,更倾向于更高的抽象级别,而且库代码经过的测试应该已经足够。ISO C++ 标准库是知名度最高、测试最充分的库之一。它在所有 C++ 实现中都可用。
auto sum = accumulate(begin(a), end(a), 0.0); // good
一个范围版本的 accumulate
会更好
auto sum = accumulate(v, 0.0); // better
但不要手工编写一个众所周知的算法
int max = v.size(); // bad: verbose, purpose unstated
double sum = 0.0;
for (int i = 0; i < max; ++i)
sum = sum + v[i];
标准库的大部分依赖于动态分配(自由存储)。这些部分,特别是容器,但不包括算法,不适合一些硬实时和嵌入式应用。在这种情况下,可以考虑提供/使用类似的设施,例如使用池分配器实现的类似标准库风格的容器。
不容易。??? 寻找混乱的循环、嵌套循环、长函数、缺乏函数调用、未使用内置类型。环形复杂度?
“合适的抽象”(例如,库或类)比裸语言更接近应用程序的概念,能产生更短、更清晰的代码,并且可能经过更好的测试。
vector<string> read1(istream& is) // good
{
vector<string> res;
for (string s; is >> s;)
res.push_back(s);
return res;
}
更传统、更底层的近乎等价的代码更长、更混乱、更难正确编写,并且很可能更慢。
char** read2(istream& is, int maxelem, int maxstring, int* nread) // bad: verbose and incomplete
{
auto res = new char*[maxelem];
int elemcount = 0;
while (is && elemcount < maxelem) {
auto s = new char[maxstring];
is.read(s, maxstring);
res[elemcount++] = s;
}
*nread = elemcount;
return res;
}
一旦添加了溢出检查和错误处理,代码就会变得相当混乱,并且还存在记住 delete
返回的指针以及数组中包含的 C 风格字符串的问题。
不容易。??? 寻找混乱的循环、嵌套循环、长函数、缺乏函数调用、未使用内置类型。环形复杂度?
重复或冗余的代码会模糊意图,使逻辑更难理解,并且维护更困难,等等。它通常源于复制粘贴式编程。
在适当的时候使用标准算法,而不是编写自己的实现。
void func(bool flag) // Bad, duplicated code.
{
if (flag) {
x();
y();
}
else {
x();
z();
}
}
void func(bool flag) // Better, no duplicated code.
{
x();
if (flag)
y();
else
z();
}
声明是语句。声明将一个名称引入一个作用域,并可能导致命名对象的构造。
可读性。最小化资源保留。避免意外滥用值。
替代表述:不要在不必要的作用域大的地方声明名称。
void use()
{
int i; // bad: i is needlessly accessible after loop
for (i = 0; i < 20; ++i) { /* ... */ }
// no intended use of i here
for (int i = 0; i < 20; ++i) { /* ... */ } // good: i is local to for-loop
if (auto pc = dynamic_cast<Circle*>(ps)) { // good: pc is local to if-statement
// ... deal with Circle ...
}
else {
// ... handle error ...
}
}
void use(const string& name)
{
string fn = name + ".txt";
ifstream is {fn};
Record r;
is >> r;
// ... 200 lines of code without intended use of fn or is ...
}
从大多数指标来看,这个函数已经太长了,但关键是 fn
使用的资源和 is
持有的文件句柄被保留的时间远超所需,并且 is
和 fn
可能在函数后面被意外使用。在这种情况下,将读取操作分解出来可能是个好主意。
Record load_record(const string& name)
{
string fn = name + ".txt";
ifstream is {fn};
Record r;
is >> r;
return r;
}
void use(const string& name)
{
Record r = load_record(name);
// ... 200 lines of code ...
}
可读性。将循环变量的可见性限制在循环的作用域内。避免在循环后将循环变量用于其他目的。最小化资源保留。
void use()
{
for (string s; cin >> s;)
v.push_back(s);
for (int i = 0; i < 20; ++i) { // good: i is local to for-loop
// ...
}
if (auto pc = dynamic_cast<Circle*>(ps)) { // good: pc is local to if-statement
// ... deal with Circle ...
}
else {
// ... handle error ...
}
}
int j; // BAD: j is visible outside the loop
for (j = 0; j < 100; ++j) {
// ...
}
// j is still visible here and isn't needed
另请参见:不要为两个不相关的目的使用一个变量
for
语句内修改的变量在循环外声明且在循环后未使用时,发出警告。讨论:将循环变量的作用域限制在循环体内也有助于代码优化器。识别出归纳变量仅在循环体内可访问,可以释放诸如提升、强度削弱、循环不变量代码运动等优化。
注意:C++17 和 C++20 还增加了 if
、switch
和范围 for
初始化语句。这些需要 C++17 和 C++20 支持。
map<int, string> mymap;
if (auto result = mymap.insert(value); result.second) {
// insert succeeded, and result is valid for this block
use(result.first); // ok
// ...
} // result is destroyed here
可读性。降低非本地名称之间冲突的可能性。
约定俗成的短的本地名称可以提高可读性
template<typename T> // good
void print(ostream& os, const vector<T>& v)
{
for (gsl::index i = 0; i < v.size(); ++i)
os << v[i] << '\n';
}
索引约定俗成地称为 i
,在这个通用函数中没有关于向量含义的提示,所以 v
和任何名称一样好。比较一下
template<typename Element_type> // bad: verbose, hard to read
void print(ostream& target_stream, const vector<Element_type>& current_vector)
{
for (gsl::index current_element_index = 0;
current_element_index < current_vector.size();
++current_element_index
)
target_stream << current_vector[current_element_index] << '\n';
}
是的,这是一个漫画,但我们见过更糟的。
非常规且简短的非本地名称会使代码变得晦涩
void use1(const string& s)
{
// ...
tt(s); // bad: what is tt()?
// ...
}
更好的是,为非本地实体提供可读的名称
void use1(const string& s)
{
// ...
trim_tail(s); // better
// ...
}
在这里,读者有可能知道 trim_tail
的含义,并且在查找后可以记住它。
大型函数的参数名实际上是非本地的,应该有意义。
void complicated_algorithm(vector<Record>& vr, const vector<int>& vi, map<string, int>& out)
// read from events in vr (marking used Records) for the indices in
// vi placing (name, index) pairs into out
{
// ... 500 lines of code using vr, vi, and out ...
}
我们建议保持函数简短,但这条规则并不普遍遵守,命名应反映这一点。
检查本地和非本地名称的长度。还要考虑函数长度。
代码清晰度和可读性。过于相似的名称会减慢理解速度并增加出错的可能性。
if (readable(i1 + l1 + ol + o1 + o0 + ol + o1 + I0 + l0)) surprise();
不要在同一作用域中声明一个非类型名称与类型名称相同。这消除了使用 struct
或 enum
等关键字进行歧义消除的需要。它也消除了错误来源,因为 struct X
如果查找失败,可以隐式声明 X
。
struct foo { int n; };
struct foo foo(); // BAD, foo is a type already in scope
struct foo x = foo(); // requires disambiguation
过时的头文件可能在同一作用域中声明具有相同名称的非类型和类型。
ALL_CAPS
名称此类名称通常用于宏。因此,ALL_CAPS
名称容易受到意外宏替换的影响。
// somewhere in some header:
#define NE !=
// somewhere else in some other header:
enum Coord { N, NE, NW, S, SE, SW, E, W };
// somewhere third in some poor programmer's .cpp:
switch (direction) {
case N:
// ...
case NE:
// ...
// ...
}
不要仅仅因为常量以前是宏就使用 ALL_CAPS
来定义常量。
标记所有 ALL CAPS 的使用。对于旧代码,接受 ALL CAPS 作为宏名称,并标记所有非 ALL-CAPS 的宏名称。
每行一个声明可以提高可读性,并避免与 C/C++ 语法相关的错误。它也为描述性的行尾注释留下了空间。
char *p, c, a[7], *pp[7], **aa[10]; // yuck!
函数声明可以包含多个函数参数声明。
结构化绑定(C++17)专门用于引入多个变量
auto [iter, inserted] = m.insert_or_assign(k, val);
if (inserted) { /* new entry was inserted */ }
template<class InputIterator, class Predicate>
bool any_of(InputIterator first, InputIterator last, Predicate pred);
或者使用概念更好
bool any_of(input_iterator auto first, input_iterator auto last, predicate auto pred);
double scalbn(double x, int n); // OK: x * pow(FLT_RADIX, n); FLT_RADIX is usually 2
或
double scalbn( // better: x * pow(FLT_RADIX, n); FLT_RADIX is usually 2
double x, // base value
int n // exponent
);
或
// better: base * pow(FLT_RADIX, exponent); FLT_RADIX is usually 2
double scalbn(double base, int exponent);
int a = 10, b = 11, c = 12, d, e = 14, f = 15;
在长声明符列表中,很容易忽略未初始化的变量。
标记具有多个声明符的变量和常量声明(例如 int* p, q;
)
auto
避免类型名称的冗余重复auto
时,声明实体的名称在声明中处于固定位置,提高了可读性。考虑
auto p = v.begin(); // vector<DataRecord>::iterator
auto z1 = v[3]; // makes copy of DataRecord
auto& z2 = v[3]; // avoids copy
const auto& z3 = v[3]; // const and avoids copy
auto h = t.future();
auto q = make_unique<int[]>(s);
auto f = [](int x) { return x + 10; };
在每种情况下,我们都节省了编写编译器已知但程序员可能写错的长而难记的类型。
template<class T>
auto Container<T>::first() -> Iterator; // Container<T>::Iterator
避免为初始化列表或你知道确切类型且初始化器可能需要转换的情况使用 auto
。
auto lst = { 1, 2, 3 }; // lst is an initializer list
auto x{1}; // x is an int (in C++17; initializer_list in C++11)
从 C++20 开始,我们可以(也应该)使用概念来更具体地指定我们要推导的类型。
// ...
forward_iterator auto p = algo(x, y, z);
std::set<int> values;
// ...
auto [ position, newly_inserted ] = values.insert(5); // break out the members of the std::pair
标记声明中类型名称的冗余重复。
很容易混淆使用了哪个变量。可能导致维护问题。
int d = 0;
// ...
if (cond) {
// ...
d = 9;
// ...
}
else {
// ...
int d = 7;
// ...
d = value_to_be_returned;
// ...
}
return d;
如果这是一个大的 if
语句,很容易忽略在内部作用域引入了一个新的 d
。这是一个已知的 bug 来源。有时这种在内部作用域中重用名称的现象被称为“遮蔽”。
遮蔽主要是当函数太大太复杂时的一个问题。
语言不允许在最外层块中遮蔽函数参数。
void f(int x)
{
int x = 4; // error: reuse of function argument name
if (x) {
int x = 7; // allowed, but bad
// ...
}
}
将成员名称用作局部变量的重用也可能是一个问题。
struct S {
int m;
void f(int x);
};
void S::f(int x)
{
m = 7; // assign to member
if (x) {
int m = 9;
// ...
m = 99; // assign to local variable
// ...
}
}
我们经常在派生类中重用基类中的函数名。
struct B {
void f(int);
};
struct D : B {
void f(double);
using B::f;
};
这是容易出错的。例如,如果我们忘记了 using 声明,那么调用 d.f(1)
将找不到 f
的 int
版本。
??? 我们需要一个关于类层次结构中遮蔽/隐藏的具体规则吗?
避免在设置前使用错误及其相关的未定义行为。避免理解复杂初始化的麻烦。简化重构。
void use(int arg)
{
int i; // bad: uninitialized variable
// ...
i = 7; // initialize i
}
不,i = 7
并不会初始化 i
;它只是赋值给它。另外,i
可以在 ...
部分被读取。更好的做法是
void use(int arg) // OK
{
int i = 7; // OK: initialized
string s; // OK: default initialized
// ...
}
始终初始化规则故意比对象在被使用前必须被设置的语言规则更严格。后者,更宽松的规则,可以捕获技术性 bug,但
始终初始化规则是旨在提高可维护性的风格规则,同时也是一项防止在设置前使用的错误保护规则。
这里有一个例子,通常被认为是展示了对初始化需要更宽松规则的必要性。
widget i; // "widget" a type that's expensive to initialize, possibly a large trivial type
widget j;
if (cond) { // bad: i and j are initialized "late"
i = f1();
j = f2();
}
else {
i = f3();
j = f4();
}
这无法轻易地重写为用初始化器初始化 i
和 j
。请注意,对于具有默认构造函数的类型,尝试推迟初始化只会导致默认初始化然后赋值。此类示例的一个流行原因是“效率”,但能够检测我们是否制造了“在设置前使用”错误的编译器也可以消除任何冗余的双重初始化。
假设 i
和 j
之间存在逻辑关联,这种关联可能应该在代码中表达。
pair<widget, widget> make_related_widgets(bool x)
{
return (x) ? {f1(), f2()} : {f3(), f4()};
}
auto [i, j] = make_related_widgets(cond); // C++17
如果 make_related_widgets
函数本身是冗余的,我们可以使用 lambda 来消除它 ES.28。
auto [i, j] = [x] { return (x) ? pair{f1(), f2()} : pair{f3(), f4()} }(); // C++17
使用代表“未初始化”的值是问题的症状,而不是解决方案。
widget i = uninit; // bad
widget j = uninit;
// ...
use(i); // possibly used before set
// ...
if (cond) { // bad: i and j are initialized "late"
i = f1();
j = f2();
}
else {
i = f3();
j = f4();
}
现在编译器甚至无法简单地检测到“在设置前使用”。此外,我们还引入了 widget 状态空间的复杂性:哪些操作对 uninit
widget 是有效的,哪些不是?
复杂初始化几十年来一直受到精明程序员的青睐。它也是错误和复杂性的主要来源。许多此类错误是在初始实现多年后在维护过程中引入的。
此规则涵盖数据成员。
class X {
public:
X(int i, int ci) : m2{i}, cm2{ci} {}
// ...
private:
int m1 = 7;
int m2;
int m3;
const int cm1 = 7;
const int cm2;
const int cm3;
};
编译器会标记未初始化的 cm3
,因为它是一个 const
,但它不会捕获 m3
未初始化的情况。通常,罕见的偶发成员初始化值得避免因未初始化而产生的错误,并且优化器通常可以消除冗余初始化(例如,在赋值之前立即发生的初始化)。
如果你正在声明一个即将从输入中初始化的对象,初始化它会导致双重初始化。但是,请注意,这可能会在输入之后留下未初始化的数据——这一直是错误和安全漏洞的肥沃来源。
constexpr int max = 8 * 1024;
int buf[max]; // OK, but suspicious: uninitialized
f.read(buf, max);
在某些情况下,初始化该数组的成本可能很高。但是,这些示例确实倾向于使未初始化的变量可访问,因此应该以怀疑的态度对待它们。
constexpr int max = 8 * 1024;
int buf[max] = {}; // zero all elements; better in some situations
f.read(buf, max);
由于数组和 std::array
的初始化规则很严格,它们提供了需要此例外情况的最引人注目的示例。
如果可行,请使用已知不会溢出的库函数。例如
string s; // s is default initialized to ""
cin >> s; // s expands to hold the string
不要将简单的、即将接收输入操作的目标变量视为此规则的例外。
int i; // bad
// ...
cin >> i;
在输入目标和输入操作被分离(它们不应该被分离)的不罕见情况下,可能会出现“在设置前使用”的可能性。
int i2 = 0; // better, assuming that zero is an acceptable value for i2
// ...
cin >> i2;
一个好的优化器应该了解输入操作并消除冗余操作。
有时,可以使用 lambda 作为初始化器来避免未初始化的变量。
error_code ec;
Value v = [&] {
auto p = get_value(); // get_value() returns a pair<error_code, Value>
ec = p.first;
return p.second;
}();
或者也许
Value v = [] {
auto p = get_value(); // get_value() returns a pair<error_code, Value>
if (p.first) throw Bad_value{p.first};
return p.second;
}();
另请参见:ES.28
const
参数的引用传递,可以假定为对变量的写入。可读性。限制变量可以使用的范围。
int x = 7;
// ... no use of x here ...
++x;
标记距离首次使用很远的声明。
可读性。限制变量可以使用的范围。不要冒险“在设置前使用”。初始化通常比赋值更有效。
string s;
// ... no use of s here ...
s = "what a waste";
SomeLargeType var; // Hard-to-read CaMeLcAsEvArIaBlE
if (cond) // some non-trivial condition
Set(&var);
else if (cond2 || !cond3) {
var = Set2(3.14);
}
else {
var = 0;
for (auto& e : something)
var += e;
}
// use var; that this isn't done too early can be enforced statically with only control flow
如果 SomeLargeType
有一个不太昂贵的默认初始化,这将会很好。否则,程序员很可能想知道是否覆盖了所有可能的条件路径。如果不是,我们就有一个“在设置前使用”的 bug。这是一个维护陷阱。
对于中等复杂度的初始化器,包括 const
变量,请考虑使用 lambda 来表达初始化器;参见 ES.28。
{}
初始化语法优先使用 {}
。关于 {}
的初始化规则比其他形式的初始化更简单、更通用、更少歧义且更安全。
仅在确定不会发生窄化转换时才使用 =
。对于内置算术类型,仅与 auto
一起使用 =
。
避免使用 ()
初始化,因为它允许解析歧义。
int x {f(99)};
int y = x;
vector<int> v = {1, 2, 3, 4, 5, 6};
对于容器,有一个传统是用 {...}
表示元素列表,用 (...)
表示大小。
vector<int> v1(10); // vector of 10 elements with the default value 0
vector<int> v2{10}; // vector of 1 element with the value 10
vector<int> v3(1, 2); // vector of 1 element with the value 2
vector<int> v4{1, 2}; // vector of 2 elements with the values 1 and 2
{}
初始化器不允许窄化转换(这通常是件好事),并且允许显式构造函数(这没问题,我们正在有意识地初始化一个新变量)。
int x {7.9}; // error: narrowing
int y = 7.9; // OK: y becomes 7. Hope for a compiler warning
int z {gsl::narrow_cast<int>(7.9)}; // OK: you asked for it
auto zz = gsl::narrow_cast<int>(7.9); // OK: you asked for it
{}
初始化几乎可用于所有初始化;其他形式的初始化不能。
auto p = new vector<int> {1, 2, 3, 4, 5}; // initialized vector
D::D(int a, int b) :m{a, b} { // member initializer (e.g., m might be a pair)
// ...
};
X var {}; // initialize var to be empty
struct S {
int m {7}; // default initializer for a member
// ...
};
因此,{}
初始化通常被称为“统一初始化”(尽管不幸的是,仍有一些不规则性)。
使用 auto
和单个值(例如 {v}
)初始化变量,直到 C++17 才出现令人惊讶的结果。C++17 的规则有些不那么令人惊讶。
auto x1 {7}; // x1 is an int with the value 7
auto x2 = {7}; // x2 is an initializer_list<int> with an element 7
auto x11 {7, 8}; // error: two initializers
auto x22 = {7, 8}; // x22 is an initializer_list<int> with elements 7 and 8
如果您真的想要一个initializer_list<T>
,请使用={...}
。
auto fib10 = {1, 1, 2, 3, 5, 8, 13, 21, 34, 55}; // fib10 is a list
={}
进行复制初始化,而{}
进行直接初始化。就像复制初始化和直接初始化本身的区别一样,这可能会导致意外。 {}
接受 explicit
构造函数;={}
则不接受。例如:
struct Z { explicit Z() {} };
Z z1{}; // OK: direct initialization, so we use explicit constructor
Z z2 = {}; // error: copy initialization, so we cannot use the explicit constructor
除非您特别想禁用显式构造函数,否则请使用普通的{}
初始化。
template<typename T>
void f()
{
T x1(1); // T initialized with 1
T x0(); // bad: function declaration (often a mistake)
T y1 {1}; // T initialized with 1
T y0 {}; // default initialized T
// ...
}
另请参阅:讨论
=
用法。()
初始化语法用法。(许多编译器应该已经对此发出警告。)unique_ptr<T>
持有指针使用std::unique_ptr
是避免内存泄漏的最简单方法。它可靠,它让类型系统完成大部分验证所有权安全的工作,它提高了可读性,并且它具有零个或几乎零个运行时成本。
void use(bool leak)
{
auto p1 = make_unique<int>(7); // OK
int* p2 = new int{7}; // bad: might leak
// ... no assignment to p2 ...
if (leak) return;
// ... no assignment to p2 ...
vector<int> v(7);
v.at(7) = 0; // exception thrown
delete p2; // too late to prevent leaks
// ...
}
如果leak == true
,则p2
指向的对象会泄漏,而p1
指向的对象不会。当at()
抛出异常时也是如此。在这两种情况下,都不会执行delete p2
语句。
查找new
、malloc()
或可能返回此类指针的函数的指向目标的原生指针。
const
或constexpr
这样您就不会意外更改值。这样做还可以为编译器提供优化机会。
void f(int n)
{
const int bufmax = 2 * n + 2; // good: we can't change bufmax by accident
int xmax = n; // suspicious: is xmax intended to change?
// ...
}
查看变量是否确实被修改,如果没有,则标记它。不幸的是,可能无法检测到非const
何时不打算修改(相对于何时仅仅没有修改)。
可读性和安全性。
void use()
{
int i;
for (i = 0; i < 20; ++i) { /* ... */ }
for (i = 0; i < 200; ++i) { /* ... */ } // bad: i recycled
}
作为优化,您可能希望重用缓冲区作为临时区域,但即便如此,也要尽量限制变量的范围,并注意不要因已处理过的缓冲区中的数据而导致错误,因为这是安全错误的常见来源。
void write_to_file()
{
std::string buffer; // to avoid reallocations on every loop iteration
for (auto& o : objects) {
// First part of the work.
generate_first_string(buffer, o);
write_to_file(buffer);
// Second part of the work.
generate_second_string(buffer, o);
write_to_file(buffer);
// etc...
}
}
标记已处理过的变量。
std::array
或stack_array
来定义数组它们可读性好,并且不会隐式转换为指针。它们不会与非标准扩展的内置数组混淆。
const int n = 7;
int m = 9;
void f()
{
int a1[n];
int a2[m]; // error: not ISO C++
// ...
}
a1
的定义是合法的C++,并且一直都是。有很多这样的代码。但是,它容易出错,尤其是在边界非局部时。此外,它是错误的“热门”来源(缓冲区溢出、数组衰变产生的指针等)。a2
的定义是C而不是C++,并被认为存在安全风险。
const int n = 7;
int m = 9;
void f()
{
array<int, n> a1;
stack_array<int> a2(m);
// ...
}
const
变量的初始化它很好地封装了本地初始化,包括清理仅用于初始化的临时变量,而无需创建不必要的非本地但不可重用的函数。它也适用于那些应该为const
但仅在一些初始化工作之后才能const
的变量。
widget x; // should be const, but:
for (auto i = 2; i <= N; ++i) { // this could be some
x += some_obj.do_something_with(i); // arbitrarily long code
} // needed to initialize x
// from here, x should be const, but we can't say so in code in this style
const widget x = [&] {
widget val; // assume that widget has a default constructor
for (auto i = 2; i <= N; ++i) { // this could be some
val += some_obj.do_something_with(i); // arbitrarily long code
} // needed to initialize x
return val;
}();
如果可能,将条件减少为一组简单的选择(例如enum
),并且不要混淆选择和初始化。
困难。充其量是启发式方法。查找未初始化的变量,然后对其进行赋值的循环。
宏是导致错误的主要原因。宏不遵循常规的范围和类型规则。宏确保人类读者看到的内容与编译器看到的内容不同。宏会使工具构建复杂化。
#define Case break; case /* BAD */
这个看似无害的宏将一个小的c
(而不是C
)变成了糟糕的控制流错误。
此规则不禁止在#ifdef
等中使用宏进行“配置控制”。
将来,模块可能会消除在配置控制中使用宏的必要性。
此规则还旨在阻止使用#
进行字符串化和##
进行连接。与宏一样,有些用法是“几乎无害”的,但即使是这些用法也会给工具(如自动补全、静态分析器和调试器)带来问题。通常,使用花哨宏的愿望是设计过于复杂的迹象。此外,#
和##
鼓励定义和使用宏。
#define CAT(a, b) a ## b
#define STRINGIFY(a) #a
void f(int x, int y)
{
string CAT(x, y) = "asdf"; // BAD: hard for tools to handle (and ugly)
string sx2 = STRINGIFY(x);
// ...
}
对于低级字符串操作,有宏的变通方法。例如:
enum E { a, b };
template<int x>
constexpr const char* stringify()
{
switch (x) {
case a: return "a";
case b: return "b";
}
}
void f()
{
string s1 = stringify<a>();
string s2 = stringify<b>();
// ...
}
这不像定义宏那样方便,但使用起来同样简单,没有开销,并且是类型安全的和作用域安全的。
将来,静态反射可能会消除预处理器进行程序文本操作的最后需求。
看到非仅用于源代码控制(例如#ifdef
)的宏时,应发出警告。
宏是导致错误的常见原因。宏不遵循常规的作用域和类型规则。宏不遵循常规的参数传递规则。宏确保人类读者看到的内容与编译器看到的内容不同。宏会使工具构建复杂化。
#define PI 3.14
#define SQUARE(a, b) (a * b)
即使我们在SQUARE
中留下了众所周知的错误,也有更好的替代方法;例如:
constexpr double pi = 3.14;
template<typename T> T square(T a, T b) { return a * b; }
看到非仅用于源代码控制(例如#ifdef
)的宏时,应发出警告。
ALL_CAPS
约定。可读性。区分宏。
#define forever for (;;) /* very BAD */
#define FOREVER for (;;) /* Still evil, but at least visible to humans */
看到小写宏时发出警告。
宏不遵循作用域规则。
#define MYCHAR /* BAD, will eventually clash with someone else's MYCHAR*/
#define ZCORP_CHAR /* Still evil, but less likely to clash */
如果可以,请避免使用宏:ES.30、ES.31和ES.32。然而,有数十亿行代码充斥着宏,并且有使用和过度使用宏的悠久传统。如果你被迫使用宏,请使用长名称和所谓的唯一前缀(例如,你组织的名称)来降低冲突的可能性。
警告短宏名称。
类型不安全。需要混乱的转换和宏加载的代码才能正确运行。
#include <cstdarg>
// "severity" followed by a zero-terminated list of char*s; write the C-style strings to cerr
void error(int severity ...)
{
va_list ap; // a magic type for holding arguments
va_start(ap, severity); // arg startup: "severity" is the first argument of error()
for (;;) {
// treat the next var as a char*; no checking: a cast in disguise
char* p = va_arg(ap, char*);
if (!p) break;
cerr << p << ' ';
}
va_end(ap); // arg cleanup (don't forget this)
cerr << '\n';
if (severity) exit(severity);
}
void use()
{
error(7, "this", "is", "an", "error", nullptr);
error(7); // crash
error(7, "this", "is", "an", "error"); // crash
const char* is = "is";
string an = "an";
error(7, "this", is, an, "error"); // crash
}
替代方案:重载。模板。可变参数模板。
#include <iostream>
void error(int severity)
{
std::cerr << '\n';
std::exit(severity);
}
template<typename T, typename... Ts>
constexpr void error(int severity, T head, Ts... tail)
{
std::cerr << head;
error(severity, tail...);
}
void use()
{
error(7); // No crash!
error(5, "this", "is", "not", "an", "error"); // No crash!
std::string an = "an";
error(7, "this", "is", "not", an, "error"); // No crash!
error(5, "oh", "no", nullptr); // Compile error! No need for nullptr.
}
这基本上是printf
的实现方式。
#include <cstdarg>
和#include <stdarg.h>
。表达式操作值。
复杂的表达式容易出错。
// bad: assignment hidden in subexpression
while ((c = getc()) != -1)
// bad: two non-local variables assigned in sub-expressions
while ((cin >> c1, cin >> c2), c1 == c2)
// better, but possibly still too complicated
for (char c1, c2; cin >> c1 >> c2 && c1 == c2;)
// OK: if i and j are not aliased
int x = ++i + ++j;
// OK: if i != j and i != k
v[i] = v[j] + v[k];
// bad: multiple assignments "hidden" in subexpressions
x = a + (b = f()) + (c = g()) * 7;
// bad: relies on commonly misunderstood precedence rules
x = a & b + c * d && e ^ f == 7;
// bad: undefined behavior
x = x++ + x++ + ++x;
其中一些表达式是普遍糟糕的(例如,它们依赖于未定义行为)。其他则只是如此复杂和/或不寻常,以至于即使是优秀程序员也可能误解它们,或者在匆忙中忽略问题。
C++17收紧了求值顺序的规则(除了赋值中的从右到左,其他都是从左到右,函数参数的求值顺序是未指定的;参见ES.43),但这并不改变复杂表达式可能令人困惑的事实。
程序员应该了解并使用表达式的基本规则。
x = k * y + z; // OK
auto t1 = k * y; // bad: unnecessarily verbose
x = t1 + z;
if (0 <= x && x < max) // OK
auto t1 = 0 <= x; // bad: unnecessarily verbose
auto t2 = x < max;
if (t1 && t2) // ...
棘手。表达式必须有多复杂才算复杂?将计算写成每个操作一个语句也令人困惑。需要考虑的事项:
避免错误。可读性。并非每个人都记住运算符表。
const unsigned int flag = 2;
unsigned int a = flag;
if (a & flag != 0) // bad: means a&(flag != 0)
注意:我们建议程序员了解算术运算和逻辑运算的优先级表,但考虑将按位逻辑运算与其他需要括号的运算符混合。
if ((a & flag) != 0) // OK: works as intended
您应该知道,无需为以下情况使用括号:
if (a < 0 || a <= max) {
// ...
}
复杂的指针操作是错误的主要来源。
请改用gsl::span
。指针应仅指向单个对象。指针算术易错且容易出错,是许多许多严重错误和安全违规的根源。span
是一种有边界检查的安全类型,用于访问数据数组。使用常量作为下标访问具有已知边界的数组可以由编译器验证。
void f(int* p, int count)
{
if (count < 2) return;
int* q = p + 1; // BAD
ptrdiff_t d;
int n;
d = (p - &n); // OK
d = (q - p); // OK
int n = *p++; // BAD
if (count < 6) return;
p[4] = 1; // BAD
p[count - 1] = 2; // BAD
use(&p[0], 3); // BAD
}
void f(span<int> a) // BETTER: use span in the function declaration
{
if (a.size() < 2) return;
int n = a[0]; // OK
span<int> q = a.subspan(1); // OK
if (a.size() < 6) return;
a[4] = 1; // OK
a[a.size() - 1] = 2; // OK
use(a.data(), 3); // OK
}
使用变量进行下标访问对工具和人类来说都很难验证是否安全。span
是一种运行时边界检查的安全类型,用于访问数据数组。at()
是另一种确保单次访问进行边界检查的替代方法。如果需要迭代器来访问数组,请使用构造在数组上的span
的迭代器。
void f(array<int, 10> a, int pos)
{
a[pos / 2] = 1; // BAD
a[pos - 1] = 2; // BAD
a[-1] = 3; // BAD (but easily caught by tools) -- no replacement, just don't do this
a[10] = 4; // BAD (but easily caught by tools) -- no replacement, just don't do this
}
使用span
。
void f1(span<int, 10> a, int pos) // A1: Change parameter type to use span
{
a[pos / 2] = 1; // OK
a[pos - 1] = 2; // OK
}
void f2(array<int, 10> arr, int pos) // A2: Add local span and use that
{
span<int> a = {arr.data(), pos};
a[pos / 2] = 1; // OK
a[pos - 1] = 2; // OK
}
使用at()
。
void f3(array<int, 10> a, int pos) // ALTERNATIVE B: Use at() for access
{
at(a, pos / 2) = 1; // OK
at(a, pos - 1) = 2; // OK
}
void f()
{
int arr[COUNT];
for (int i = 0; i < COUNT; ++i)
arr[i] = i; // BAD, cannot use non-constant indexer
}
使用span
。
void f1()
{
int arr[COUNT];
span<int> av = arr;
for (int i = 0; i < COUNT; ++i)
av[i] = i;
}
使用span
和范围for
。
void f1a()
{
int arr[COUNT];
span<int, COUNT> av = arr;
int i = 0;
for (auto& e : av)
e = i++;
}
使用at()
进行访问。
void f2()
{
int arr[COUNT];
for (int i = 0; i < COUNT; ++i)
at(arr, i) = i;
}
使用范围for
。
void f3()
{
int arr[COUNT];
int i = 0;
for (auto& e : arr)
e = i++;
}
工具可以提供对涉及动态索引表达式的数组访问的重写,以便改用at()
。
static int a[10];
void f(int i, int j)
{
a[i + j] = 12; // BAD, could be rewritten as ...
at(a, i + j) = 12; // OK -- bounds-checked
}
将数组转换为指针(语言基本上总是这样做)会消除检查的机会,所以要避免这样做。
void g(int* p);
void f()
{
int a[5];
g(a); // BAD: are we trying to pass an array?
g(&a[0]); // OK: passing one object
}
如果您想传递数组,请说明。
void g(int* p, size_t length); // old (dangerous) code
void g1(span<int> av); // BETTER: get g() changed.
void f2()
{
int a[5];
span<int> av = a;
g(av.data(), av.size()); // OK, if you have no choice
g1(a); // OK -- no decay here, instead use implicit span ctor
}
std::array
)上的任何索引表达式,其中索引不是编译时常量表达式,并且其值介于0
和数组的上限之间。此规则是边界安全配置文件的一部分。
您不知道此类代码的作用。可移植性。即使它对您有意义,它也可能在另一个编译器(例如您的编译器的下一个版本)或不同的优化器设置上产生不同的行为。
C++17收紧了求值顺序的规则:除了赋值中的从右到左,其他都是从左到右,并且函数参数的求值顺序是未指定的。
但是,请记住,您的代码可能使用C++17之前的编译器进行编译(例如,通过复制粘贴),所以不要太聪明。
v[i] = ++i; // the result is undefined
一个好的经验法则是,您不应在表达式中读取两次值,而又对其进行写入。
可以被好的分析器检测到。
因为该顺序是未指定的。
C++17收紧了求值顺序的规则,但函数参数的求值顺序仍然是未指定的。
int i = 0;
f(++i, ++i);
在C++17之前,行为是未定义的,因此行为可以是任何结果(例如,f(2, 2)
)。自C++17以来,此代码没有未定义行为,但仍未指定先评估哪个参数。调用将是f(1, 2)
或f(2, 1)
,但您不知道是哪一个。
重载运算符可能导致求值顺序问题。
f1()->m(f2()); // m(f1(), f2())
cout << f1() << f2(); // operator<<(operator<<(cout, f1()), f2())
在C++17中,这些示例按预期工作(从左到右),并且赋值是从右到左评估的(就像=的绑定是从右到左一样)。
f1() = f2(); // undefined behavior in C++14; in C++17, f2() is evaluated before f1()
可以被好的分析器检测到。
嵌入表达式中的未命名常量很容易被忽略,并且通常难以理解。
for (int m = 1; m <= 12; ++m) // don't: magic constant 12
cout << month[m] << '\n';
不,我们并非都知道一年有12个月,编号为1..12。更好。
// months are indexed 1..12
constexpr int first_month = 1;
constexpr int last_month = 12;
for (int m = first_month; m <= last_month; ++m) // better
cout << month[m] << '\n';
更好的是,不要暴露常量。
for (auto m : month)
cout << m << '\n';
标记代码中的字面量。允许0
、1
、nullptr
、\n
、""
以及其他列表中的项。
窄化转换会破坏信息,而且往往出乎意料。
一个关键示例是基本的窄化。
double d = 7.9;
int i = d; // bad: narrowing: i becomes 7
i = (int) d; // bad: we're going to claim this is still not explicit enough
void f(int x, long y, double d)
{
char c1 = x; // bad: narrowing
char c2 = y; // bad: narrowing
char c3 = d; // bad: narrowing
}
指南支持库提供narrow_cast
操作以指定窄化是可接受的,并提供narrow
(“窄化如果”)在窄化会丢弃合法值时抛出异常。
i = gsl::narrow_cast<int>(d); // OK (you asked for it): narrowing: i becomes 7
i = gsl::narrow<int>(d); // OK: throws narrowing_error
我们还包括有损算术转换,例如从负浮点类型到无符号整数类型。
double d = -7.9;
unsigned u = 0;
u = d; // bad: narrowing
u = gsl::narrow_cast<unsigned>(d); // OK (you asked for it): u becomes 4294967289
u = gsl::narrow<unsigned>(d); // OK: throws narrowing_error
此规则不适用于上下文布尔转换。
if (ptr) do_something(*ptr); // OK: ptr is used as a condition
bool b = ptr; // bad: narrowing
一个好的分析器可以检测所有窄化转换。但是,标记所有窄化转换会导致许多误报。建议:
float
->char
和double
->int
。这里有危险!我们需要数据。)long
->char
。(我怀疑int
->char
非常常见。这里有危险!我们需要数据。)nullptr
而不是0
或NULL
可读性。最小化惊喜:nullptr
不会与int
混淆。nullptr
也有一个定义明确(非常严格)的类型,因此在类型推导可能对NULL
或0
做出错误处理的场景中,它能更好地工作。
考虑
void f(int);
void f(char*);
f(0); // call f(int)
f(nullptr); // call f(char*)
标记指针的0
和NULL
用法。可以通过简单的程序转换来辅助实现。
类型转换是错误的众所周知来源,并使某些优化不可靠。
double d = 2;
auto p = (long*)&d;
auto q = (long long*)&d;
cout << d << ' ' << *p << ' ' << *q << '\n';
您会认为这段片段会打印什么?结果最多是实现定义的。我得到了
2 0 4611686018427387904
添加
*q = 666;
cout << d << ' ' << *p << ' ' << *q << '\n';
我得到了
3.29048e-321 666 666
惊讶?实际上是未定义行为,因此也可能导致程序崩溃。
编写类型转换的程序员通常假设他们知道自己在做什么,或者编写类型转换使程序“更易于阅读”。事实上,它们通常会禁用使用值的通用规则。重载解析和模板实例化通常会在存在正确函数时选择正确的函数。如果不存在,也许应该有一个,而不是应用本地修复(类型转换)。
类型转换在系统编程语言中是必需的。例如,如何将设备寄存器的地址放入指针?但是,类型转换被严重滥用,也是错误的主要来源。
如果您觉得需要大量类型转换,可能存在根本的设计问题。
该类型配置文件禁止reinterpret_cast
和C风格的类型转换。
切勿将(void)
转换为以忽略[[nodiscard]]
返回值。如果您确实想丢弃这样的结果,请首先仔细考虑这是否真的是一个好主意(函数或返回类型作者使用[[nodiscard]]
通常都有充分的理由)。如果您仍然认为合适并且您的代码审查者也同意,请使用std::ignore =
来关闭警告,这很简单、可移植且易于搜索。
类型转换被广泛(错误地)使用。现代C++有规则和构造,在许多情况下消除了对类型转换的需求,例如:
std::variant
。std::ignore =
来忽略[[nodiscard]]
值。void
。Type(value)
。改用Type{value}
,它不是窄化的。(参见ES.64。)可读性。避免错误。命名类型转换比C风格或函数式类型转换更具体,允许编译器捕获一些错误。
命名的类型转换包括:
static_cast
const_cast
reinterpret_cast
dynamic_cast
std::move
// move(x)
是x
的右值引用std::forward
// forward<T>(x)
是右值引用或左值引用,取决于T
gsl::narrow_cast
// narrow_cast<T>(x)
是static_cast<T>(x)
gsl::narrow
// narrow<T>(x)
是static_cast<T>(x)
,如果static_cast<T>(x) == x
,或者它抛出narrowing_error
class B { /* ... */ };
class D { /* ... */ };
template<typename D> D* upcast(B* pb)
{
D* pd0 = pb; // error: no implicit conversion from B* to D*
D* pd1 = (D*)pb; // legal, but what is done?
D* pd2 = static_cast<D*>(pb); // error: D is not derived from B
D* pd3 = reinterpret_cast<D*>(pb); // OK: on your head be it!
D* pd4 = dynamic_cast<D*>(pb); // OK: return nullptr
// ...
}
示例是根据现实世界中的错误合成的,其中D
曾派生自B
,但有人重构了层次结构。C风格的类型转换很危险,因为它能进行任何类型的转换,使我们没有任何错误保护(现在或将来)。
在没有信息丢失的类型之间进行转换时(例如从float
到double
或从int32
到int64
),可以使用花括号初始化。
double d {some_float};
int64_t i {some_int32};
这使得类型转换是故意的,并且可以防止可能导致精度损失的类型之间的转换。(例如,尝试通过这种方式将float
初始化为double
是编译错误。)
reinterpret_cast
可能是必不可少的,但其基本用途(例如,将机器地址转换为指针)不是类型安全的。
auto p = reinterpret_cast<Device_register>(0x800); // inherently dangerous
void
。Type(value)
。改用Type{value}
,它不是窄化的。(参见ES.64。)reinterpret_cast
。static_cast
时发出警告。const
这使得const
成为谎言。如果变量实际上声明为const
,则修改它会导致未定义行为。
void f(const int& x)
{
const_cast<int&>(x) = 42; // BAD
}
static int i = 0;
static const int j = 0;
f(i); // silent side effect
f(j); // undefined behavior
有时,您可能会被诱惑诉诸const_cast
来避免代码重复,例如当两个仅在const
性上不同的访问器函数具有相似的实现时。例如:
class Bar;
class Foo {
public:
// BAD, duplicates logic
Bar& get_bar()
{
/* complex logic around getting a non-const reference to my_bar */
}
const Bar& get_bar() const
{
/* same complex logic around getting a const reference to my_bar */
}
private:
Bar my_bar;
};
相反,倾向于共享实现。通常,您可以让非const
函数调用const
函数。但是,当存在复杂逻辑时,这可能导致以下模式,该模式仍然依赖于const_cast
:
class Foo {
public:
// not great, non-const calls const version but resorts to const_cast
Bar& get_bar()
{
return const_cast<Bar&>(static_cast<const Foo&>(*this).get_bar());
}
const Bar& get_bar() const
{
/* the complex logic around getting a const reference to my_bar */
}
private:
Bar my_bar;
};
尽管此模式在正确应用时是安全的,因为调用者最初必须拥有一个非const
对象,但它并不理想,因为作为检查器规则,安全性很难自动强制执行。
相反,最好将公共代码放入一个公共辅助函数中——并使其成为模板,以便它推导const
。这根本不使用任何const_cast
。
class Foo {
public: // good
Bar& get_bar() { return get_bar_impl(*this); }
const Bar& get_bar() const { return get_bar_impl(*this); }
private:
Bar my_bar;
template<class T> // good, deduces whether T is const or non-const
static auto& get_bar_impl(T& t)
{ /* the complex logic around getting a possibly-const reference to my_bar */ }
};
注意:不要在模板中执行大量非依赖工作,这会导致代码膨胀。例如,一个进一步的改进是,如果get_bar_impl
的全部或部分可以是无依赖的,并提取到一个公共非模板函数中,以可能大大减小代码大小。
在调用const
不正确的函数时,您可能需要移除const
。最好将此类函数包装在内联的const
正确包装器中,以将类型转换封装在一个地方。
有时,“移除const
”是为了允许更新一个不可变对象的某些瞬时信息。示例包括缓存、记忆化和预计算。这些示例通常可以使用mutable
或间接引用来处理,而不是使用const_cast
。
考虑为昂贵的操作保留先前计算的结果。
int compute(int x); // compute a value for x; assume this to be costly
class Cache { // some type implementing a cache for an int->int operation
public:
pair<bool, int> find(int x) const; // is there a value for x?
void set(int x, int v); // make y the value for x
// ...
private:
// ...
};
class X {
public:
int get_val(int x)
{
auto p = cache.find(x);
if (p.first) return p.second;
int val = compute(x);
cache.set(x, val); // insert value for x
return val;
}
// ...
private:
Cache cache;
};
在此,get_val()
在逻辑上是const
的,因此我们希望将其设为const
成员。为此,我们仍然需要修改cache
,因此人们有时会诉诸const_cast
。
class X { // Suspicious solution based on casting
public:
int get_val(int x) const
{
auto p = cache.find(x);
if (p.first) return p.second;
int val = compute(x);
const_cast<Cache&>(cache).set(x, val); // ugly
return val;
}
// ...
private:
Cache cache;
};
幸运的是,有一个更好的解决方案:声明cache
即使对于const
对象也是可变的。
class X { // better solution
public:
int get_val(int x) const
{
auto p = cache.find(x);
if (p.first) return p.second;
int val = compute(x);
cache.set(x, val);
return val;
}
// ...
private:
mutable Cache cache;
};
另一种解决方案是存储指向cache
的指针。
class X { // OK, but slightly messier solution
public:
int get_val(int x) const
{
auto p = cache->find(x);
if (p.first) return p.second;
int val = compute(x);
cache->set(x, val);
return val;
}
// ...
private:
unique_ptr<Cache> cache;
};
该解决方案是最灵活的,但需要显式构造和销毁*cache
(最可能在X
的构造函数和析构函数中)。
在任何变体中,我们都必须防止多线程代码中cache
的数据竞争,可能使用std::mutex
。
const_cast
。不会溢出的结构不会溢出(并且通常运行更快)。
for (auto& x : v) // print all elements of v
cout << x << '\n';
auto p = find(v, x); // find x in v
查找显式边界检查并启发式地建议替代方案。
std::move()
我们移动而不是复制,以避免重复并提高性能。
移动通常会留下一个空对象(C.64),这可能会令人惊讶甚至危险,因此我们尽量避免从左值移动(它们可能稍后被访问)。
移动通常在源是右值时隐式发生(例如,return
语句中的值或函数结果),因此在这种情况下不要通过显式写入move
来不必要地使代码复杂化。相反,编写返回值的短函数,并且该函数的返回和调用者接受返回都可以自然地进行优化。
总的来说,遵循本文档中的指南(包括不使变量的作用域不必要地扩大,编写返回值的短函数,返回局部变量)有助于消除大多数显式std::move
的需要。
显式move
是显式地将对象移动到另一个作用域所必需的,特别是传递给“接收器”函数,以及移动构造函数、移动赋值运算符和交换操作的实现本身。
void sink(X&& x); // sink takes ownership of x
void user()
{
X x;
// error: cannot bind an lvalue to a rvalue reference
sink(x);
// OK: sink takes the contents of x, x must now be assumed to be empty
sink(std::move(x));
// ...
// probably a mistake
use(x);
}
通常,std::move()
用作&&
参数的参数。在您执行此操作之后,假设对象已被移动(请参见C.64),并且在您首次将其设置为新值之前不要再次读取其状态。
void f()
{
string s1 = "supercalifragilisticexpialidocious";
string s2 = s1; // ok, takes a copy
assert(s1 == "supercalifragilisticexpialidocious"); // ok
// bad, if you want to keep using s1's value
string s3 = move(s1);
// bad, assert will likely fail, s1 likely changed
assert(s1 == "supercalifragilisticexpialidocious");
}
void sink(unique_ptr<widget> p); // pass ownership of p to sink()
void f()
{
auto w = make_unique<widget>();
// ...
sink(std::move(w)); // ok, give to sink()
// ...
sink(w); // Error: unique_ptr is carefully designed so that you cannot copy it
}
std::move()
伪装成&&
的类型转换;它本身不移动任何东西,但会将命名对象标记为可以从中移动的候选对象。语言已经知道可以移动对象的常见情况,特别是在从函数返回值时,因此请勿通过冗余的std::move()
使代码复杂化。
切勿仅因听过“它更高效”而编写std::move()
。总的来说,不要相信“效率”的说法而没有数据(???)。总的来说,不要无缘无故地使代码复杂化(??)。切勿在const
对象上编写std::move()
,它会被静默转换为复制(参见Meyers15中的条目23)。
vector<int> make_vector()
{
vector<int> result;
// ... load result with data
return std::move(result); // bad; just write "return result;"
}
切勿编写return move(local_variable);
,因为语言已经知道该变量是移动候选对象。在这种代码中编写move
不会有帮助,甚至可能是有害的,因为它在某些编译器上会通过创建局部变量的额外引用别名来干扰RVO(返回值优化)。
vector<int> v = std::move(make_vector()); // bad; the std::move is entirely redundant
切勿对返回值使用 move
,例如 x = move(f());
,其中 f
按值返回。语言已经知道返回值是一个临时对象,可以从中移动。
void mover(X&& x)
{
call_something(std::move(x)); // ok
call_something(std::forward<X>(x)); // bad, don't std::forward an rvalue reference
call_something(x); // suspicious, why not std::move?
}
template<class T>
void forwarder(T&& t)
{
call_something(std::move(t)); // bad, don't std::move a forwarding reference
call_something(std::forward<T>(t)); // ok
call_something(t); // suspicious, why not std::forward?
}
std::move(x)
的用法,其中 x
是一个右值,或者语言已经将其视为右值,包括 return std::move(local_variable);
和按值返回的函数的 std::move(f())
。const S&
重载来处理左值,则标记接受 S&&
参数的函数。std::move
d 参数,除非参数类型是 X&&
右值引用,或者类型是仅移动类型且参数按值传递。std::move
应用于转发引用(T&&
,其中 T
是模板参数类型)时进行标记。请改用 std::forward
。std::move
应用于非 const 的右值引用以外的类型时进行标记。(前一条规则的更通用情况,用于涵盖非转发情况。)std::forward
应用于右值引用(X&&
,其中 X
是非模板参数类型)时进行标记。请改用 std::move
。std::forward
应用于非转发引用时进行标记。(前一条规则的更通用情况,用于涵盖非移动情况。)const
操作时进行标记;应首先进行一个非 const
操作,最好是赋值,以首先重置对象的值。new
和 delete
直接在应用程序代码中管理资源很容易出错且繁琐。
这也称为“禁止裸 new
!”规则
void f(int n)
{
auto p = new X[n]; // n default constructed Xs
// ...
delete[] p;
}
在 ...
部分的代码可能导致 delete
永远不会发生。
另请参见:R:资源管理
标记裸 new
和裸 delete
。
delete[]
删除数组,使用 delete
删除非数组这是语言的要求,不匹配可能导致资源释放错误和/或内存损坏。
void f(int n)
{
auto p = new X[n]; // n default constructed Xs
// ...
delete p; // error: just delete the object p, rather than delete the array p[]
}
此示例不仅违反了前一个示例中的“禁止裸 new
”规则,而且还有更多问题。
new
和 delete
在同一作用域中不匹配,则进行标记。new
和 delete
是构造函数/析构函数对,则进行标记。这样做会导致未定义行为。
void f()
{
int a1[7];
int a2[9];
if (&a1[5] < &a2[7]) {} // bad: undefined
if (0 < &a1[5] - &a2[7]) {} // bad: undefined
}
此示例还有更多问题。
???
切片——即通过赋值或初始化仅复制对象的一部分——大多数时候会导致错误,因为对象被视为一个整体。在切片是故意的情况下,代码可能会令人惊讶。
class Shape { /* ... */ };
class Circle : public Shape { /* ... */ Point c; int r; };
Circle c { {0, 0}, 42 };
Shape s {c}; // copy construct only the Shape part of Circle
s = c; // or copy assign only the Shape part of Circle
void assign(const Shape& src, Shape& dest)
{
dest = src;
}
Circle c2 { {1, 1}, 43 };
assign(c, c2); // oops, not the whole state is transferred
assert(c == c2); // if we supply copying, we should also provide comparison,
// but this will likely return false
结果将是无意义的,因为 center
和 radius
将不会从 c
复制到 s
。首先要做的防御是定义基类 Shape
不允许这样做。
如果您打算切片,请定义一个显式操作来执行此操作。这可以避免读者混淆。例如
class Smiley : public Circle {
public:
Circle copy_circle();
// ...
};
Smiley sm { /* ... */ };
Circle c1 {sm}; // ideally prevented by the definition of Circle
Circle c2 {sm.copy_circle()};
警告切片。
T{e}
表示法进行构造T{e}
构造语法明确表示需要构造。 T{e}
构造语法不允许缩小转换。T{e}
是从表达式 e
构造 T
类型的值的安全且通用的表达式。强制转换表示法 T(e)
和 (T)e
既不安全也不通用。
对于内置类型,构造表示法可以防止缩小转换和重新解释
void use(char ch, int i, double d, char* p, long long lng)
{
int x1 = int{ch}; // OK, but redundant
int x2 = int{d}; // error: double->int narrowing; use a cast if you need to
int x3 = int{p}; // error: pointer to->int; use a reinterpret_cast if you really need to
int x4 = int{lng}; // error: long long->int narrowing; use a cast if you need to
int y1 = int(ch); // OK, but redundant
int y2 = int(d); // bad: double->int narrowing; use a cast if you need to
int y3 = int(p); // bad: pointer to->int; use a reinterpret_cast if you really need to
int y4 = int(lng); // bad: long long->int narrowing; use a cast if you need to
int z1 = (int)ch; // OK, but redundant
int z2 = (int)d; // bad: double->int narrowing; use a cast if you need to
int z3 = (int)p; // bad: pointer to->int; use a reinterpret_cast if you really need to
int z4 = (int)lng; // bad: long long->int narrowing; use a cast if you need to
}
当使用 T(e)
或 (T)e
表示法时,整数到/从指针的转换是实现定义的,并且在具有不同整数和指针大小的平台之间不可移植。
避免强制转换(显式类型转换),如果必须进行,请优先使用命名强制转换。
在无歧义的情况下,可以从 T{e}
中省略 T
。
complex<double> f(complex<double>);
auto z = f({2*pi, 1});
构造表示法是最通用的初始化表示法。
std::vector
和其他容器是在我们有 {}
作为构造表示法之前定义的。考虑
vector<string> vs {10}; // ten empty strings
vector<int> vi1 {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // ten elements 1..10
vector<int> vi2 {10}; // one element with the value 10
如何获得一个包含 10 个默认初始化的 int
的 vector
?
vector<int> v3(10); // ten elements with value 0
使用 ()
而不是 {}
表示元素数量是惯用的(可以追溯到 20 世纪 80 年代初),难以更改,但仍然是一个设计错误:对于元素类型可能与元素数量混淆的容器,我们存在一个必须解决的歧义。惯用的解决方法是将 {10}
解释为包含一个元素的列表,并使用 (10)
来区分大小。
这个错误不必在新代码中重复。我们可以定义一个类型来表示元素数量
struct Count { int n; };
template<typename T>
class Vector {
public:
Vector(Count n); // n default-initialized elements
Vector(initializer_list<T> init); // init.size() elements
// ...
};
Vector<int> v1{10};
Vector<int> v2{Count{10}};
Vector<Count> v3{Count{10}}; // yes, there is still a very minor problem
剩下的主要问题是为 Count
找到一个合适的名称。
标记 C 风格的 (T)e
和函数式风格的 T(e)
强制转换。
解引用无效指针,例如 nullptr
,是未定义行为,通常会导致立即崩溃、错误结果或内存损坏。
此处所说的指针是指任何指向对象的间接引用,包括迭代器或视图。
此规则是一个明显且众所周知的语言规则,但可能难以遵循。需要良好的编码风格、库支持和静态分析才能在不产生重大开销的情况下消除违规行为。这是关于C++ 的类型和资源安全模型讨论的主要部分。
另请参阅:
nullptr
不是可能性的情况下,使用引用。nullptr
。void f()
{
int x = 0;
int* p = &x;
if (condition()) {
int y = 0;
p = &y;
} // invalidates p
*p = 42; // BAD, p might be invalid if the branch was taken
}
要解决此问题,请扩展指针打算引用的对象的生命周期,或缩短指针的生命周期(将解引用移到指向的对象生命周期结束之前)。
void f1()
{
int x = 0;
int* p = &x;
int y = 0;
if (condition()) {
p = &y;
}
*p = 42; // OK, p points to x or y and both are still in scope
}
不幸的是,大多数无效指针问题都更难发现和更难修复。
void f(int* p)
{
int x = *p; // BAD: how do we know that p is valid?
}
存在大量的此类代码。大多数代码都能工作——在经过大量测试后——但单独来看,无法确定 p
是否可能是 nullptr
。因此,这也是错误的主要来源。有许多方法可以处理这种潜在问题
void f1(int* p) // deal with nullptr
{
if (!p) {
// deal with nullptr (allocate, return, throw, make p point to something, whatever)
}
int x = *p;
}
测试 nullptr
时存在两个潜在问题
nullptr
,不总是清楚该怎么做void f2(int* p) // state that p is not supposed to be nullptr
{
assert(p);
int x = *p;
}
这只会增加启用断言检查时的成本,并且会为编译器/分析器提供有用的信息。如果/当 C++ 获得对契约的直接支持时,这将效果更好。
void f3(int* p) // state that p is not supposed to be nullptr
[[expects: p]]
{
int x = *p;
}
或者,我们可以使用 gsl::not_null
来确保 p
不是 nullptr
。
void f(not_null<int*> p)
{
int x = *p;
}
这些补救措施只处理 nullptr
。请记住,还有其他方式可以获得无效指针。
void f(int* p) // old code, doesn't use owner
{
delete p;
}
void g() // old code: uses naked new
{
auto q = new int{7};
f(q);
int x = *q; // BAD: dereferences invalid pointer
}
void f()
{
vector<int> v(10);
int* p = &v[5];
v.push_back(99); // could reallocate v's elements
int x = *p; // BAD: dereferences potentially invalid pointer
}
此规则属于生命周期安全配置文件。
nullptr
而失效的指针的解引用。delete
而失效的指针的解引用。语句控制控制流(函数调用和异常抛出除外,它们是表达式)。
switch
语句而不是 if
语句switch
与常量进行比较,通常比一系列 if
-then
-else
链中的测试进行更好的优化。switch
支持一些启发式一致性检查。例如,是否覆盖了 enum
的所有值?如果没有,是否有 default
?void use(int n)
{
switch (n) { // good
case 0:
// ...
break;
case 7:
// ...
break;
default:
// ...
break;
}
}
而不是
void use2(int n)
{
if (n == 0) // bad: if-then-else chain comparing against a set of constants
// ...
else if (n == 7)
// ...
}
标记 if
-then
-else
链,该链仅检查常量。
for
语句而不是 for
语句可读性。错误预防。效率。
for (gsl::index i = 0; i < v.size(); ++i) // bad
cout << v[i] << '\n';
for (auto p = v.begin(); p != v.end(); ++p) // bad
cout << *p << '\n';
for (auto& x : v) // OK
cout << x << '\n';
for (gsl::index i = 1; i < v.size(); ++i) // touches two elements: can't be a range-for
cout << v[i] + v[i - 1] << '\n';
for (gsl::index i = 0; i < v.size(); ++i) // possible side effect: can't be a range-for
cout << f(v, &v[i]) << '\n';
for (gsl::index i = 0; i < v.size(); ++i) { // body messes with loop variable: can't be a range-for
if (i % 2 != 0)
cout << v[i] << '\n'; // output odd elements
}
人类或优秀的静态分析器可以确定 f(v, &v[i])
中的 v
确实没有副作用,因此循环可以重写。
通常最好避免在循环体中“篡改循环变量”。
不要在范围 for
循环中使用循环变量的昂贵副本
for (string s : vs) // ...
这将把 vs
的每个元素复制到 s
。更好的做法是
for (string& s : vs) // ...
更好的是,如果循环变量未被修改或复制
for (const string& s : vs) // ...
查看循环,如果传统循环仅检查序列的每个元素,并且对元素的操作没有副作用,则将循环重写为范围 for
循环。
for
语句而不是 while
语句可读性:循环的完整逻辑“一次性”可见。循环变量的作用域可以受到限制。
for (gsl::index i = 0; i < vec.size(); i++) {
// do work
}
int i = 0;
while (i < vec.size()) {
// do work
i++;
}
???
while
语句而不是 for
语句可读性。
int events = 0;
for (; wait_for_event(); ++events) { // bad, confusing
// ...
}
“事件循环”具有误导性,因为 events
计数器与循环条件 (wait_for_event()
) 无关。更好的做法是
int events = 0;
while (wait_for_event()) { // better
++events;
// ...
}
标记 for
初始化器和 for
增量中的与 for
条件无关的操作。
for
语句的初始化部分声明循环变量参见ES.6。
do
语句可读性,避免错误。终止条件位于末尾(可能被忽略),并且第一次运行时不检查条件。
int x;
do {
cin >> x;
// ...
} while (x < 0);
是的,确实存在 do
语句是解决方案明确陈述的真实示例,但也有很多错误。
标记 do
语句。
goto
可读性,避免错误。人类有更好的控制结构;goto
是为机器生成的代码准备的。
跳出嵌套循环。在这种情况下,请始终向前跳转。
for (int i = 0; i < imax; ++i)
for (int j = 0; j < jmax; ++j) {
if (a[i][j] > elem_max) goto finished;
// ...
}
finished:
// ...
C 语言的 goto-exit 习惯用法使用得相当普遍。
void f()
{
// ...
goto exit;
// ...
goto exit;
// ...
exit:
// ... common cleanup code ...
}
这是析构函数的临时模拟。声明您的资源句柄,其中包含清理的析构函数。如果您出于某种原因无法通过析构函数处理使用的变量的所有清理,请考虑使用 gsl::finally()
,这比 goto exit
更清晰、更可靠。
goto
。最好标记所有不从嵌套循环跳转到嵌套循环之后语句的 goto
。break
和 continue
在非平凡的循环体中,很容易忽略 break
或 continue
。
break
在循环中的含义与 switch
语句中的 break
的含义截然不同(您可以在循环中包含 switch
语句,也可以在 switch
语句的 case
中包含循环)。
switch(x) {
case 1 :
while (/* some condition */) {
// ...
break;
} // Oops! break switch or break while intended?
case 2 :
// ...
break;
}
通常,需要 break
的循环是函数(算法)的良好候选者,在这种情况下,break
会变成 return
。
//Original code: break inside loop
void use1()
{
std::vector<T> vec = {/* initialized with some values */};
T value;
for (const T item : vec) {
if (/* some condition*/) {
value = item;
break;
}
}
/* then do something with value */
}
//BETTER: create a function and return inside loop
T search(const std::vector<T> &vec)
{
for (const T &item : vec) {
if (/* some condition*/) return item;
}
return T(); //default value
}
void use2()
{
std::vector<T> vec = {/* initialized with some values */};
T value = search(vec);
/* then do something with value */
}
通常,使用 continue
的循环可以等效地并且同样清晰地通过 if
语句来表达。
for (int item : vec) { // BAD
if (item%2 == 0) continue;
if (item == 5) continue;
if (item > 10) continue;
/* do something with item */
}
for (int item : vec) { // GOOD
if (item%2 != 0 && item != 5 && item <= 10) {
/* do something with item */
}
}
如果您确实需要跳出循环,break
通常比修改循环变量或 goto
等替代方案更好。
???
switch
语句中的隐式穿透始终用 break
结束非空 case
。意外遗漏 break
是一个相当常见的错误。故意的穿透可能成为维护难题,应该很少见且明确。
switch (eventType) {
case Information:
update_status_bar();
break;
case Warning:
write_event_log();
// Bad - implicit fallthrough
case Error:
display_error_window();
break;
}
单个语句的多个 case 标签是可以的。
switch (x) {
case 'a':
case 'b':
case 'f':
do_something(x);
break;
}
case 标签中的 return 语句也是可以的。
switch (x) {
case 'a':
return 1;
case 'b':
return 2;
case 'c':
return 3;
}
在极少数情况下,如果认为穿透是合适的,请明确使用 [[fallthrough]]
注释。
switch (eventType) {
case Information:
update_status_bar();
break;
case Warning:
write_event_log();
[[fallthrough]];
case Error:
display_error_window();
break;
}
标记非空 case
的所有隐式穿透。
default
来处理常见情况代码清晰度。提高了错误检测的机会。
enum E { a, b, c, d };
void f1(E x)
{
switch (x) {
case a:
do_something();
break;
case b:
do_something_else();
break;
default:
take_the_default_action();
break;
}
}
这里很清楚有一个默认操作,并且 a
和 b
这两种情况是特殊的。
但是,如果没有默认操作,并且您只想处理特定情况,该怎么办?在这种情况下,请留空 default
,否则就无法知道您是否打算处理所有情况。
void f2(E x)
{
switch (x) {
case a:
do_something();
break;
case b:
do_something_else();
break;
default:
// do nothing for the rest of the cases
break;
}
}
如果您省略 default
,维护者和/或编译器可能会合理地假设您打算处理所有情况。
void f2(E x)
{
switch (x) {
case a:
do_something();
break;
case b:
case c:
do_something_else();
break;
}
}
您是否忘记了 d
这个 case,还是故意省略了它?忘记 case 通常发生在枚举类型添加了 case,而执行此操作的人未将其添加到所有枚举器的 switch 中。
标记没有处理所有枚举值且没有 default
的枚举的 switch
语句。这可能会在某些代码库中产生过多的误报;如果这样,则仅标记处理了大部分但并非所有情况的 switch
(这是第一个 C++ 编译器的策略)。
没有这样的东西。对人类来说看起来是未命名的变量,对编译器来说是一个在定义后立即超出作用域的临时变量。
void f()
{
lock_guard<mutex>{mx}; // Bad
// ...
}
这会声明一个未命名的 lock_guard
对象,该对象在分号处立即超出作用域。这不是一个不常见的错误。特别是,这个具体的例子可能导致难以发现的竞态条件。
未命名的函数参数是可以的。
标记仅仅是临时对象的语句。
可读性。
for (i = 0; i < max; ++i); // BAD: the empty statement is easily overlooked
v[i] = f(v[i]);
for (auto x : v) { // better
// nothing
}
v[i] = f(v[i]);
标记非块状且不包含注释的空语句。
开头的循环控制应该能够正确地推理出循环内部正在发生的事情。在迭代表达式和循环体内同时修改循环计数器是产生意外和错误的一个持续来源。
for (int i = 0; i < 10; ++i) {
// no updates to i -- ok
}
for (int i = 0; i < 10; ++i) {
//
if (/* something */) ++i; // BAD
//
}
bool skip = false;
for (int i = 0; i < 10; ++i) {
if (skip) { skip = false; continue; }
//
if (/* something */) skip = true; // Better: using two variables for two concepts.
//
}
标记在循环控制迭代表达式和循环体内都可能被更新(有非 const
使用)的变量。
==
或 !=
这样做可以避免冗长,并消除一些出错的机会。有助于使风格一致和规范。
根据定义,if
语句、while
语句或 for
语句中的条件选择 true
和 false
。数值与 0
比较,指针值与 nullptr
比较。
// These all mean "if p is not nullptr"
if (p) { ... } // good
if (p != 0) { ... } // redundant !=0, bad: don't use 0 for pointers
if (p != nullptr) { ... } // redundant !=nullptr, not recommended
通常,if (p)
被读作“如果 p
有效”,这是程序员意图的直接表达,而 if (p != nullptr)
将是笨拙的变通方法。
此规则在条件用作声明时尤其有用。
if (auto pc = dynamic_cast<Circle*>(ps)) { ... } // execute if ps points to a kind of Circle, good
if (auto pc = dynamic_cast<Circle*>(ps); pc != nullptr) { ... } // not recommended
请注意,布尔值隐式转换适用于条件。例如
for (string s; cin >> s; ) v.push_back(s);
这会调用 istream
的 operator bool()
。
将整数显式比较 0
通常不是多余的。原因是(与指针和布尔值相反)整数通常有两个以上的合理值。此外,0
(零)通常用于表示成功。因此,最好是明确比较。
void f(int i)
{
if (i) // suspect
// ...
if (i == success) // possibly better
// ...
}
始终记住,整数可以有两个以上的值。
有人指出
if(strcmp(p1, p2)) { ... } // are the two C-style strings equal? (mistake!)
是一个常见的初学者错误。如果您使用 C 风格字符串,您必须很好地了解 <cstring>
函数。冗长地编写
if(strcmp(p1, p2) != 0) { ... } // are the two C-style strings equal? (mistake!)
本身并不能挽救您。
相反的条件最容易通过否定来表达。
// These all mean "if p is nullptr"
if (!p) { ... } // good
if (p == 0) { ... } // redundant == 0, bad: don't use 0 for pointers
if (p == nullptr) { ... } // redundant == nullptr, not recommended
很简单,只需检查条件中是否冗余使用 !=
和 ==
。
避免错误的结果。
int x = -3;
unsigned int y = 7;
cout << x - y << '\n'; // unsigned result, possibly 4294967286
cout << x + y << '\n'; // unsigned result: 4
cout << x * y << '\n'; // unsigned result, possibly 4294967275
在更真实的示例中更难发现问题。
不幸的是,C++ 对数组下标使用有符号整数,标准库对容器下标使用无符号整数。这使得不一致成为必然。请使用 gsl::index
作为下标;参见 ES.107。
sizeof
或对容器 .size()
的调用,而另一个参数是 ptrdiff_t
时,不要对混合有符号/无符号比较发出警告。无符号类型支持位操作,而不会因符号位而产生意外。
unsigned char x = 0b1010'1010;
unsigned char y = ~x; // y == 0b0101'0101;
无符号类型也可用于模运算。但是,如果您想要模运算,请根据需要添加注释,指出依赖于环绕行为,因为这种代码可能会让许多程序员感到惊讶。
因为大多数算术假定为有符号;当 y > x
时,x - y
会产生负数,除非在您确实想要模运算的罕见情况下。
如果您不期望无符号算术,它可能会产生令人惊讶的结果。对于混合有符号和无符号算术更是如此。
template<typename T, typename T2>
T subtract(T x, T2 y)
{
return x - y;
}
void test()
{
int s = 5;
unsigned int us = 5;
cout << subtract(s, 7) << '\n'; // -2
cout << subtract(us, 7u) << '\n'; // 4294967294
cout << subtract(s, 7u) << '\n'; // -2
cout << subtract(us, 7) << '\n'; // 4294967294
cout << subtract(s, us + 2) << '\n'; // -2
cout << subtract(us, s + 2) << '\n'; // 4294967294
}
这里我们非常明确地说明了正在发生的事情,但如果您看到 us - (s + 2)
或 s += 2; ...; us - s
,您会可靠地怀疑结果会打印为 4294967294
吗?
如果您确实想要模运算,请使用无符号类型 - 根据需要添加注释,指出依赖于溢出行为,因为这种代码会让许多程序员感到惊讶。
标准库使用无符号类型进行下标。内置数组使用有符号类型进行下标。这使得惊喜(和错误)不可避免。
int a[10];
for (int i = 0; i < 10; ++i) a[i] = i;
vector<int> v(10);
// compares signed to unsigned; some compilers warn, but we should not
for (gsl::index i = 0; i < v.size(); ++i) v[i] = i;
int a2[-2]; // error: negative size
// OK, but the number of ints (4294967294) is so large that we should get an exception
vector<int> v2(-2);
请使用 gsl::index
作为下标;参见 ES.107。
-2
)作为容器下标。sizeof
或对容器 .size()
的调用,而另一个参数是 ptrdiff_t
时,不要对混合有符号/无符号比较发出警告。溢出通常使您的数值算法毫无意义。将一个值增加到超出最大值可能导致内存损坏和未定义行为。
int a[10];
a[10] = 7; // bad, array bounds overflow
for (int n = 0; n <= 10; ++n)
a[n] = 9; // bad, array bounds overflow
int n = numeric_limits<int>::max();
int m = n + 1; // bad, numeric overflow
int area(int h, int w) { return h * w; }
auto a = area(10'000'000, 100'000'000); // bad, numeric overflow
如果您确实想要模运算,请使用无符号类型。
替代方案:对于可以承受一些开销的关键应用程序,请使用范围检查的整数和/或浮点类型。
???
将一个值递减到超出最小值可能导致内存损坏和未定义行为。
int a[10];
a[-2] = 7; // bad
int n = 101;
while (n--)
a[n - 1] = 9; // bad (twice)
如果您确实想要模运算,请使用无符号类型。
???
结果是未定义的,并且很可能导致崩溃。
这也适用于 %
。
int divide(int a, int b)
{
// BAD, should be checked (e.g., in a precondition)
return a / b;
}
int divide(int a, int b)
{
// good, address via precondition (and replace with contracts once C++ gets them)
Expects(b != 0);
return a / b;
}
double divide(double a, double b)
{
// good, address via using double instead
return a / b;
}
替代方案:对于可以承受一些开销的关键应用程序,请使用范围检查的整数和/或浮点类型。
unsigned
来避免负值选择 unsigned
意味着对整数的常规行为进行了许多更改,包括模运算,可以抑制与溢出相关的警告,并为有符号/无符号混合相关的错误打开大门。使用 unsigned
实际上并不能消除负值的可能性。
unsigned int u1 = -2; // Valid: the value of u1 is 4294967294
int i1 = -2;
unsigned int u2 = i1; // Valid: the value of u2 is 4294967294
int i2 = u2; // Valid: the value of i2 is -2
这些(完全合法的)构造的问题在实际代码中很难发现,并且是许多实际错误的原因。考虑
unsigned area(unsigned height, unsigned width) { return height*width; } // [see also](#Ri-expects)
// ...
int height;
cin >> height;
auto a = area(height, 2); // if the input is -2 a becomes 4294967292
请记住,当 -1
赋值给 unsigned int
时,它会变成最大的 unsigned int
。此外,由于无符号算术是模运算,乘法没有溢出,它发生了环绕。
unsigned max = 100000; // "accidental typo", I mean to say 10'000
unsigned short x = 100;
while (x < max) x += 100; // infinite loop
如果 x
是一个有符号 short
,我们可以警告关于溢出时的未定义行为。
x >= 0
。Assert(-1 < x)
例如:
struct Positive {
int val;
Positive(int x) :val{x} { Assert(0 < x); }
operator int() { return val; }
};
int f(Positive arg) { return arg; }
int r1 = f(2);
int r2 = f(-2); // throws
???
参见 ES.100 执行。
unsigned
作为下标,优先使用 gsl::index
避免有符号/无符号混淆。实现更好的优化。实现更好的错误检测。避免 auto
和 int
的陷阱。
vector<int> vec = /*...*/;
for (int i = 0; i < vec.size(); i += 2) // might not be big enough
cout << vec[i] << '\n';
for (unsigned i = 0; i < vec.size(); i += 2) // risk wraparound
cout << vec[i] << '\n';
for (auto i = 0; i < vec.size(); i += 2) // might not be big enough
cout << vec[i] << '\n';
for (vector<int>::size_type i = 0; i < vec.size(); i += 2) // verbose
cout << vec[i] << '\n';
for (auto i = vec.size()-1; i >= 0; i -= 2) // bug
cout << vec[i] << '\n';
for (int i = vec.size()-1; i >= 0; i -= 2) // might not be big enough
cout << vec[i] << '\n';
vector<int> vec = /*...*/;
for (gsl::index i = 0; i < vec.size(); i += 2) // ok
cout << vec[i] << '\n';
for (gsl::index i = vec.size()-1; i >= 0; i -= 2) // ok
cout << vec[i] << '\n';
内置数组允许有符号下标。标准库容器使用无符号下标。因此,不可能有一个完美且完全兼容的解决方案(除非且直到标准库容器将来某天更改为使用有符号下标)。考虑到无符号和有符号/无符号混合的已知问题,最好坚持使用足够大小的(有符号)整数,这由 gsl::index
保证。
template<typename T>
struct My_container {
public:
// ...
T& operator[](gsl::index i); // not unsigned
// ...
};
??? demonstrate improved code generation and potential for error detection ???
用户的替代方案
sizeof
或对容器 .size()
的调用,而另一个参数是 ptrdiff_t
时,不要对混合有符号/无符号比较发出警告。???这个部分应该在主指南中吗???
本节包含针对需要高性能或低延迟的人的规则。也就是说,这些规则与如何花费最少的时间和最少的资源来以可预测的短时间完成任务有关。本节中的规则比许多(大多数)应用程序所需的限制性更强,侵入性也更强。不要在通用代码中盲目尝试遵循它们:实现低延迟的目标需要额外的工作。
性能规则摘要
如果没有优化的必要,那么这项工作的主要结果将是更多的错误和更高的维护成本。
有些人会出于习惯或因为有趣而进行优化。
???
精心优化的代码通常比未优化的代码更大,更难修改。
???
优化程序中非性能关键部分对系统性能没有影响。
如果您的程序大部分时间都在等待网络或等待人工输入,那么内存中计算的优化可能毫无用处。
换句话说:如果您的程序将 4% 的处理时间用于计算 A,将 40% 的时间用于计算 B,那么 A 的 50% 改进的影响仅相当于 B 的 5% 改进。(如果您甚至不知道 A 或 B 花费了多少时间,请参阅 Per.1 和 Per.2。)
简单的代码可以非常快。优化器有时会对简单代码产生惊人的效果
// clear expression of intent, fast execution
vector<uint8_t> v(100000);
for (auto& c : v)
c = ~c;
// intended to be faster, but is often slower
vector<uint8_t> v(100000);
for (size_t i = 0; i < v.size(); i += sizeof(uint64_t)) {
uint64_t& quad_word = *reinterpret_cast<uint64_t*>(&v[i]);
quad_word = ~quad_word;
}
???
???
低级代码有时会阻碍优化。优化器有时会对高级代码产生惊人的效果。
???
???
性能领域充斥着神话和错误的民间传说。现代硬件和优化器会挑战朴素的假设;即使是专家也经常感到惊讶。
获得良好的性能测量可能很困难,需要专门的工具。
使用 Unix 的 time
或标准库的 <chrono>
进行的一些简单的微基准测试有助于消除最明显的迷思。如果您无法准确测量您的完整系统,至少尝试测量您的一些关键操作和算法。分析器可以帮助您确定系统中的哪些部分是性能关键的。通常,您会感到惊讶。
???
因为我们经常需要优化初始设计。因为忽视了后期改进可能性的设计很难修改。
来自 C(和 C++)标准
void qsort (void* base, size_t num, size_t size, int (*compar)(const void*, const void*));
你什么时候想过对内存进行排序?实际上,我们对元素序列进行排序,这些元素通常存储在容器中。调用 qsort
会丢失大量有用信息(例如,元素类型),迫使用户重复已知信息(例如,元素大小),并迫使用户编写额外的代码(例如,一个比较 double
s 的函数)。这会给程序员增加工作量,容易出错,并剥夺了编译器进行优化所需的信息。
double data[100];
// ... fill a ...
// 100 chunks of memory of sizeof(double) starting at
// address data using the order defined by compare_doubles
qsort(data, 100, sizeof(double), compare_doubles);
从接口设计的角度来看,qsort
丢失了有用信息。
我们可以做得更好(在 C++98 中)
template<typename Iter>
void sort(Iter b, Iter e); // sort [b:e)
sort(data, data + 100);
在这里,我们利用编译器关于数组大小、元素类型以及如何比较 double
s 的知识。
有了 C++20,我们可以做得更好
// sortable specifies that c must be a
// random-access sequence of elements comparable with <
void sort(sortable auto& c);
sort(c);
关键是传递足够的信息以选择一个好的实现。在这方面,此处显示的 sort
接口仍然存在弱点:它们隐含地依赖于元素类型定义了小于 (<
)。为了完成接口,我们需要一个接受比较标准的第二个版本
// compare elements of c using r
template<random_access_range R, class C> requires sortable<R, C>
void sort(R&& r, C c);
标准库的 sort
规范提供了这两个版本以及更多。
俗话说,过早优化是万恶之源,但这并不是鄙视性能的理由。考虑什么使得设计易于改进,并且性能提升是通常希望的改进,这永远不会为时过早。目标是养成一套习惯,使默认情况下生成高效、可维护和可优化的代码。特别是,当你编写一个不仅仅是实现细节的函数时,请考虑
std::vector
,并以系统化的方式访问。如果您认为需要链接结构,请尝试构建接口,使其结构不被用户看到。考虑
template<class ForwardIterator, class T>
bool binary_search(ForwardIterator first, ForwardIterator last, const T& val);
binary_search(begin(c), end(c), 7)
会告诉您 7
是否在 c
中,但它不会告诉您 7
在哪里,或者是否有多个 7
。
有时,仅返回最少的信息(此处为 true
或 false
)就足够了,但一个好的接口会向调用者返回所需的信息。因此,标准库也提供了
template<class ForwardIterator, class T>
ForwardIterator lower_bound(ForwardIterator first, ForwardIterator last, const T& val);
lower_bound
返回指向第一个匹配项的迭代器(如果存在),否则返回指向第一个大于 val
的元素的迭代器,或者如果不存在这样的元素则返回 last
。
然而,lower_bound
仍然没有返回足够的信息供所有用途使用,因此标准库也提供了
template<class ForwardIterator, class T>
pair<ForwardIterator, ForwardIterator>
equal_range(ForwardIterator first, ForwardIterator last, const T& val);
equal_range
返回一对迭代器,指定第一个匹配项和一个超出最后一个匹配项的位置。
auto r = equal_range(begin(c), end(c), 7);
for (auto p = r.first; p != r.second; ++p)
cout << *p << '\n';
显然,这三个接口是由相同的基本代码实现的。它们只是将基本的二分查找算法呈现给用户的三种方式,从最简单的(“让简单的事情更简单!”)到返回完整但并非总是需要的信息(“不要隐藏有用的信息”)。当然,构建这样一套接口需要经验和领域知识。
不要仅仅根据您想到的第一个实现和第一个用例来构建接口。一旦您的第一个初始实现完成,请对其进行审查;一旦您部署了它,错误将很难纠正。
对效率的需求并不意味着需要低级代码。高级代码不一定慢或臃肿。
事物都有成本。不要对成本过于偏执(现代计算机确实非常快),但要对您使用的东西的数量级成本有一个大致的了解。例如,对内存访问、函数调用、字符串比较、系统调用、磁盘访问以及网络消息的成本有一个大致的了解。
如果您只能想到一种实现,那么您可能没有可以设计稳定接口的东西。也许它只是一个实现细节——并非所有代码都需要一个稳定的接口——但请停下来考虑一下。一个可能很有用的问题是:“如果这个操作要使用多线程实现,或者要进行向量化,需要什么样的接口?”
这条规则并不与不要过早优化规则相矛盾。它与之互补,鼓励开发人员在需要时启用后期——适当且非过早的——优化。
棘手。也许查找 void*
函数参数可以找到一些会阻碍后期优化的接口示例。
类型违规、弱类型(例如 void*
)和低级代码(例如,将序列作为单个字节操作)使优化器的工作更加困难。简单代码的优化效果通常优于手工复杂的代码。
???
以减少代码大小和运行时间。通过使用常量来避免数据竞争。在编译时捕获错误(从而消除错误处理代码的需要)。
double square(double d) { return d*d; }
static double s2 = square(2); // old-style: dynamic initialization
constexpr double ntimes(double d, int n) // assume 0 <= n
{
double m = 1;
while (n--) m *= d;
return m;
}
constexpr double s3 {ntimes(2, 3)}; // modern-style: compile-time initialization
像 s2
的初始化这样的代码并不少见,特别是对于比 square()
稍微复杂一点的初始化。但是,与 s3
的初始化相比,存在两个问题
s2
在初始化发生之前可能被另一个线程访问。注意:常量不会发生数据竞争。
考虑一种流行技术,用于在句柄本身中存储小对象,在堆上存储大对象。
constexpr int on_stack_max = 20;
template<typename T>
struct Scoped { // store a T in Scoped
// ...
T obj;
};
template<typename T>
struct On_heap { // store a T on the free store
// ...
T* objp;
};
template<typename T>
using Handle = typename std::conditional<(sizeof(T) <= on_stack_max),
Scoped<T>, // first alternative
On_heap<T> // second alternative
>::type;
void f()
{
Handle<double> v1; // the double goes on the stack
Handle<std::array<double, 200>> v2; // the array goes on the free store
// ...
}
假设 Scoped
和 On_heap
提供兼容的用户界面。在这里,我们在编译时计算要使用的最佳类型。有类似的技术用于选择要调用的最佳函数。
理想情况是不尝试在编译时执行所有操作。显然,大多数计算取决于输入,因此无法将其移至编译时,但除了这个逻辑限制之外,复杂的编译时计算可能会严重增加编译时间并使调试复杂化。甚至可能通过编译时计算来减慢代码的速度。这不可否认很少见,但通过将通用计算分解为独立的最佳子计算,可能会降低指令缓存的有效性。
???
???
???
???
性能通常以内存访问时间为主。
???
???
性能通常以内存访问时间为主。
???
性能非常依赖缓存性能,缓存算法偏爱对相邻数据的简单(通常是线性)访问。
int matrix[rows][cols];
// bad
for (int c = 0; c < cols; ++c)
for (int r = 0; r < rows; ++r)
sum += matrix[r][c];
// good
for (int r = 0; r < rows; ++r)
for (int c = 0; c < cols; ++c)
sum += matrix[r][c];
???
我们经常希望我们的计算机能够同时执行许多任务(或者至少看起来是这样)。这样做的原因各不相同(例如,使用单个处理器等待多个事件,同时处理多个数据流,或利用多个硬件设施),表达并发和并行性的基本设施也是如此。在这里,我们阐述了使用 ISO 标准 C++ 的并发和并行性表达设施的原则和规则。
线程是并发和并行编程的机器级基础。线程允许独立运行程序的多个部分,同时共享相同的内存。并发编程很棘手,因为保护线程之间的共享数据说起来容易做起来难。使现有的单线程代码并发执行可能就像战略性地添加 std::async
或 std::thread
一样简单,或者根据原始代码是否以对线程友好的方式编写,可能需要完全重写。
本文档中的并发/并行规则的制定考虑了三个目标
同样重要的是要注意,C++ 中的并发是一个未完成的故事。C++11 引入了许多核心并发原语,C++14 和 C++17 对它们进行了改进,并且人们对使 C++ 中并发程序的编写更加容易有很大的兴趣。我们预计此处与库相关的某些指导将随时间发生显著变化。
此部分需要大量工作(显然)。请注意,我们从针对相对非专家的规则开始。真正的专家需要等待一段时间;欢迎投稿,但请考虑那些努力使并发程序正确且高效的程序员。
并发和并行规则摘要
volatile
进行同步另请参阅:
很难确定并发现在是否未被使用,或者将来是否不会被使用。代码会被重用。不使用线程的库可能会被程序中使用线程的其他部分调用。请注意,此规则对库代码最为紧急,对独立应用程序最不紧急。但是,随着时间的推移,代码片段可能会出现在意想不到的地方。
double cached_computation(int x)
{
// bad: these statics cause data races in multi-threaded usage
static int cached_x = 0.0;
static double cached_result = COMPUTATION_OF_ZERO;
if (cached_x != x) {
cached_x = x;
cached_result = computation(x);
}
return cached_result;
}
虽然 cached_computation
在单线程环境中工作得很好,但在多线程环境中,两个 static
变量会导致数据竞争,从而导致未定义行为。
struct ComputationCache {
int cached_x = 0;
double cached_result = COMPUTATION_OF_ZERO;
double compute(int x) {
if (cached_x != x) {
cached_x = x;
cached_result = computation(x);
}
return cached_result;
}
};
这里,缓存存储为 ComputationCache
对象的成员数据,而不是作为共享静态状态。此重构本质上是将关注点向上委托给调用者:单线程程序可能仍会选择拥有一个全局 ComputationCache
,而多线程程序可能拥有每个线程一个 ComputationCache
实例,或者每个“上下文”一个 ComputationCache
实例(“上下文”的任何定义)。重构后的函数不再尝试管理 cached_x
的分配。从这个意义上说,这是单一职责原则的应用。
在此特定示例中,为线程安全进行的重构也提高了在单线程程序中的可重用性。不难想象,单线程程序可能希望拥有两个 ComputationCache
实例,用于程序的不同部分,而不会使它们缓存的数据相互覆盖。
还有其他几种方法可以为为标准多线程环境(即,唯一并发形式是 std::thread
)编写的代码添加线程安全性
thread_local
而不是 static
。static std::mutex
来保护对两个 static
变量的访问。从不运行在多线程环境中的代码。
请注意:在许多代码“已知”从不运行在多线程程序中,但后来却作为多线程程序的一部分运行的示例中,要小心。通常,此类程序会导致痛苦的数据竞争移除工作。因此,从不打算在多线程环境中运行的代码应明确标记,并最好附带编译或运行时强制执行机制,以尽早捕获这些用法错误。
除非你这样做,否则没有什么能保证正常工作,并且会持续存在细微的错误。
简而言之,如果两个线程可以并发访问同一个对象(没有同步),并且至少有一个是写入者(执行非 const
操作),则存在数据竞争。有关如何很好地使用同步来消除数据竞争的更多信息,请参阅一本关于并发的好书(参见 仔细研究文献)。
存在许多数据竞争的示例,其中一些示例目前正在生产软件中运行。一个非常简单的例子
int get_id()
{
static int id = 1;
return id++;
}
这里的递增是数据竞争的一个例子。这可能以多种方式出错,包括
id
的值,操作系统将 A 切换出一段时间,在此期间其他线程创建了数百个 ID。然后,线程 A 再次开始运行,并将 id
写回该位置,作为 A 读取 id
加一。id
并同时递增它。它们都获得相同的 ID。局部静态变量是数据竞争的常见来源。
void f(fstream& fs, regex pattern)
{
array<double, max> buf;
int sz = read_vec(fs, buf, max); // read from fs into buf
gsl::span<double> s {buf};
// ...
auto h1 = async([&] { sort(std::execution::par, s); }); // spawn a task to sort
// ...
auto h2 = async([&] { return find_all(buf, sz, pattern); }); // spawn a task to find matches
// ...
}
这里,我们有一个(糟糕的)buf
元素上的数据竞争(sort
将读写)。所有数据竞争都很糟糕。在这里,我们设法在堆栈上的数据上产生了数据竞争。并非所有数据竞争都像这个一样容易发现。
// code not controlled by a lock
unsigned val;
if (val < 5) {
// ... other thread can change val here ...
switch (val) {
case 0: // ...
case 1: // ...
case 2: // ...
case 3: // ...
case 4: // ...
}
}
现在,一个不知道 val
会改变的编译器很可能会使用一个有五个条目的跳转表来实现该 switch
。然后,一个超出 [0..4]
范围的 val
将导致跳转到程序中任何可能的位置,并在那里继续执行。真的,“一切皆有可能”,如果你遇到数据竞争。实际上,情况可能更糟:通过查看生成的代码,您可能能够确定给定值的错误跳转将去往何处;这可能是一个安全风险。
有些是可能的,至少做一些事情。有商业和开源工具试图解决这个问题,但请注意,解决方案是有成本和盲点的。静态工具通常有许多误报,而运行时工具通常成本很高。我们希望有更好的工具。使用多种工具可以比单个工具发现更多问题。
还有其他方法可以减少数据竞争的机会
static
变量constexpr
和 const
)如果您不共享可写数据,您就不会发生数据竞争。您共享的越少,忘记同步访问(并导致数据竞争)的可能性就越小。您共享的越少,等待锁的可能性就越小(因此性能可以提高)。
bool validate(const vector<Reading>&);
Graph<Temp_node> temperature_gradients(const vector<Reading>&);
Image altitude_map(const vector<Reading>&);
// ...
void process_readings(const vector<Reading>& surface_readings)
{
auto h1 = async([&] { if (!validate(surface_readings)) throw Invalid_data{}; });
auto h2 = async([&] { return temperature_gradients(surface_readings); });
auto h3 = async([&] { return altitude_map(surface_readings); });
// ...
h1.get();
auto v2 = h2.get();
auto v3 = h3.get();
// ...
}
如果没有这些 const
,我们就必须审查每个异步调用的函数,以查找 surface_readings
的潜在数据竞争。将 surface_readings
设为 const
(相对于此函数)允许仅使用函数体进行推理。
不可变数据可以安全有效地共享。无需锁定:常量不会发生数据竞争。另请参阅 CP.mess: 消息传递 和 CP.31: 优先按值传递。
???
一个 thread
是一个实现概念,一种思考机器的方式。一个任务是一个应用程序概念,是你想要做的事情,最好是与其他任务并发地进行。应用程序概念更容易推理。
void some_fun(const std::string& msg)
{
std::thread publisher([=] { std::cout << msg; }); // bad: less expressive
// and more error-prone
auto pubtask = std::async([=] { std::cout << msg; }); // OK
// ...
publisher.join();
}
除了 async()
之外,标准库设施是低级的、面向机器的、线程和锁级别的。这是一个必要的基础,但我们必须努力提高抽象级别:为了生产力、可靠性和性能。这是使用更高级别的、更面向应用程序的库(如果可能,基于标准库设施构建)的一个有力论据。
???
volatile
进行同步在 C++ 中,与其他一些语言不同,volatile
不提供原子性,不提供线程间同步,也不防止指令重排序(无论是编译器还是硬件)。它根本与并发无关。
int free_slots = max_slots; // current source of memory for objects
Pool* use()
{
if (int n = free_slots--) return &pool[n];
}
这里我们遇到了一个问题:在单线程程序中,这是完全可以的代码,但是让两个线程执行它,free_slots
就会出现竞争条件,导致两个线程可能获得相同的值和 free_slots
。那是(显然)一个糟糕的数据竞争,所以习惯于其他语言的人可能会尝试像这样修复它
volatile int free_slots = max_slots; // current source of memory for objects
Pool* use()
{
if (int n = free_slots--) return &pool[n];
}
这对同步没有影响:数据竞争仍然存在!
C++ 中的机制是 atomic
类型
atomic<int> free_slots = max_slots; // current source of memory for objects
Pool* use()
{
if (int n = free_slots--) return &pool[n];
}
现在 --
操作是原子的,而不是一个读-增-写序列,其中另一个线程可能在单独的操作之间插入。
在您可能在其他语言中使用 volatile
的地方使用 atomic
类型。对于更复杂的示例,请使用 mutex
。
经验表明,并发代码极难正确编写,并且编译时检查、运行时检查和测试在查找并发错误方面的效果不如在查找顺序代码中的错误。细微的并发错误可能产生灾难性的不良影响,包括内存损坏、死锁和安全漏洞。
???
线程安全极具挑战性,常常让有经验的程序员都感到棘手:工具是缓解这些风险的重要策略。市面上有许多工具,包括商业和开源工具,以及研究和生产工具。不幸的是,人们的需求和限制差异如此之大,以至于我们无法提供具体的建议,但我们可以提及
静态强制工具:clang 和一些较早版本的 GCC 都支持对线程安全属性进行静态注解。一致使用此技术可以将许多类别的线程安全错误转换为编译时错误。注解通常是局部的(标记一个特定的数据成员由特定的互斥锁保护),并且通常易于学习。但是,与许多静态工具一样,它可能经常出现误报;应该被捕获但被允许的情况。
动态强制工具:Clang 的 Thread Sanitizer(也称为 TSAN)是动态工具的一个强大示例:它会更改程序的构建和执行,以添加内存访问的记账,从而完全确定给定二进制执行的数据竞争。其代价是内存(在大多数情况下为 5-10 倍)和 CPU 速度减慢(2-20 倍)。像这样的动态工具最适用于集成测试、金丝雀发布或操作多个线程的单元测试。工作负载很重要:当 TSAN 识别出问题时,它实际上总是一个实际的数据竞争,但它只能识别在给定执行中遇到的竞争。
应用程序构建者可以选择对特定应用程序有价值的支持工具。
本节重点介绍通过共享数据进行通信的多个线程的相对临时的用法。
并发规则摘要
lock()
/unlock()
std::lock()
或 std::scoped_lock
来获取多个 mutex
thread
视为作用域容器thread
视为全局容器gsl::joining_thread
而不是 std::thread
detach()
线程thread
之间共享所有权,请使用 shared_ptr
wait
lock_guard
和 unique_lock
命名mutex
以及它所保护的数据。尽可能使用 synchronized_value<T>
try_lock()
lock_guard
而不是 unique_lock
new thread
lock()
/unlock()
避免了因未释放锁而导致的糟糕错误。
mutex mtx;
void do_stuff()
{
mtx.lock();
// ... do stuff ...
mtx.unlock();
}
迟早会有人忘记 mtx.unlock()
,在 ... do stuff ...
中放置一个 return
,抛出一个异常,或者其他什么。
mutex mtx;
void do_stuff()
{
unique_lock<mutex> lck {mtx};
// ... do stuff ...
}
标记对成员 lock()
和 unlock()
的调用。???
std::lock()
或 std::scoped_lock
来获取多个 mutex
避免多个 mutex
上的死锁。
这是在请求死锁
// thread 1
lock_guard<mutex> lck1(m1);
lock_guard<mutex> lck2(m2);
// thread 2
lock_guard<mutex> lck2(m2);
lock_guard<mutex> lck1(m1);
而是使用 lock()
// thread 1
lock(m1, m2);
lock_guard<mutex> lck1(m1, adopt_lock);
lock_guard<mutex> lck2(m2, adopt_lock);
// thread 2
lock(m2, m1);
lock_guard<mutex> lck2(m2, adopt_lock);
lock_guard<mutex> lck1(m1, adopt_lock);
或者(更好,但仅限 C++17)
// thread 1
scoped_lock<mutex, mutex> lck1(m1, m2);
// thread 2
scoped_lock<mutex, mutex> lck2(m2, m1);
在这里,thread1
和 thread2
的作者仍然没有就 mutex
的顺序达成一致,但顺序不再重要。
在实际代码中,mutex
很少被命名以便方便地提醒程序员预期的关系和预期的获取顺序。在实际代码中,mutex
并不总是在连续的行上方便地获取。
在 C++17 中,可以编写纯粹的
lock_guard lck1(m1, adopt_lock);
并让 mutex
类型被推导出来。
检测多个 mutex
的获取。这通常是不可判定的,但捕获常见的简单示例(如上面的示例)很容易。
如果你不知道一段代码的作用,你就会面临死锁的风险。
void do_this(Foo* p)
{
lock_guard<mutex> lck {my_mutex};
// ... do something ...
p->act(my_data);
// ...
}
如果你不知道 Foo::act
是做什么的(也许它是一个调用派生类成员的虚函数,而这个派生类成员还没有被编写),它可能会(递归地)调用 do_this
并导致 my_mutex
死锁。也许它会锁定另一个互斥锁并且不会在合理的时间内返回,导致调用 do_this
的任何代码延迟。
“调用未知代码”问题的一个常见例子是调用一个试图获取同一对象的锁定访问的函数。这样的问题通常可以通过使用 recursive_mutex
来解决。例如
recursive_mutex my_mutex;
template<typename Action>
void do_something(Action f)
{
unique_lock<recursive_mutex> lck {my_mutex};
// ... do something ...
f(this); // f will do something to *this
// ...
}
如果 f()
很可能调用 *this
的操作,我们必须确保对象的不变式在调用之前成立。
mutex
时调用虚函数mutex
时调用回调thread
视为作用域容器为了维护指针安全并避免泄漏,我们需要考虑 thread
使用了哪些指针。如果 thread
被 join,我们可以安全地传递指向 thread
及其封闭作用域中的对象的指针。
void f(int* p)
{
// ...
*p = 99;
// ...
}
int glob = 33;
void some_fct(int* p)
{
int x = 77;
joining_thread t0(f, &x); // OK
joining_thread t1(f, p); // OK
joining_thread t2(f, &glob); // OK
auto q = make_unique<int>(99);
joining_thread t3(f, q.get()); // OK
// ...
}
一个 gsl::joining_thread
是一个 std::thread
,它有一个析构函数会 join 并且不能被 detached()
。所谓“OK”,我们是指对象的作用域(“存活”)将与 thread
使用指向它的指针一样长。线程并发运行的事实不会影响这里的生命周期或所有权问题;这些线程可以被视为只是从 some_fct
调用了一个函数对象。
确保 joining_thread
不会 detach()
。此后,通常的生命周期和所有权(对于局部对象)强制执行适用。
thread
视为全局容器为了维护指针安全并避免泄漏,我们需要考虑 thread
使用了哪些指针。如果 thread
被分离,我们可以安全地传递静态和自由存储对象的指针(仅限于此)。
void f(int* p)
{
// ...
*p = 99;
// ...
}
int glob = 33;
void some_fct(int* p)
{
int x = 77;
std::thread t0(f, &x); // bad
std::thread t1(f, p); // bad
std::thread t2(f, &glob); // OK
auto q = make_unique<int>(99);
std::thread t3(f, q.get()); // bad
// ...
t0.detach();
t1.detach();
t2.detach();
t3.detach();
// ...
}
“OK”的意思是对象将在其作用域内(“存活”),只要 thread
可以使用指向它的指针。 “bad”的意思是 thread
可能会在指向的对象被销毁后使用指针。 thread
s 并发运行这一事实不影响这里的生命周期或所有权问题;这些 thread
s 可以被看作是从 some_fct
调用过来的一个函数对象。
即使是静态存储持续时间的对象,如果从分离的线程使用,也可能存在问题:如果线程一直运行到程序结束,它可能与具有静态存储持续时间的对象销毁同时进行,因此对这些对象的访问可能会发生竞争。
如果您 不 detach()
并且 使用 gsl::joining_thread
,此规则是多余的。但是,将代码转换为遵循这些指南可能很困难,甚至对于第三方库来说是不可能的。在这种情况下,该规则对于生命周期安全和类型安全至关重要。
一般来说,无法确定是否对 thread
执行了 detach()
,但简单的常见情况很容易检测到。如果我们无法证明 thread
没有 detach()
,我们必须假设它确实 detach()
了,并且它的寿命超出了它被构造的作用域;此后,通常的生命周期和所有权(对于全局对象)强制执行适用。
标记将局部变量传递给可能 detach()
的线程的尝试。
gsl::joining_thread
而不是 std::thread
A joining_thread
是一个在其作用域结束时进行连接的线程。分离的线程难以监控。确保分离线程(及潜在的分离线程)中没有错误更加困难。
void f() { std::cout << "Hello "; }
struct F {
void operator()() const { std::cout << "parallel world "; }
};
int main()
{
std::thread t1{f}; // f() executes in separate thread
std::thread t2{F()}; // F()() executes in separate thread
} // spot the bugs
void f() { std::cout << "Hello "; }
struct F {
void operator()() const { std::cout << "parallel world "; }
};
int main()
{
std::thread t1{f}; // f() executes in separate thread
std::thread t2{F()}; // F()() executes in separate thread
t1.join();
t2.join();
} // one bad bug left
将“永生线程”设为全局变量,放入封闭的作用域,或放在自由存储区,而不是 detach()
。 不要 detach
。
由于旧代码和使用 std::thread
的第三方库,可能很难引入此规则。
标记 std::thread
的使用
gsl::joining_thread
或 C++20 的 std::jthread
。detach()
线程通常,线程的任务需要比其创建作用域更长的生命周期,但通过 detach
实现此想法会使监控和与分离线程通信更加困难。特别是,确保线程按预期完成或其寿命符合预期更加困难(尽管并非不可能)。
void heartbeat();
void use()
{
std::thread t(heartbeat); // don't join; heartbeat is meant to run forever
t.detach();
// ...
}
这是线程的一个合理用途,通常使用 detach()
。但存在问题。我们如何监控分离的线程以查看它是否仍然存在?心跳可能会出现问题,而丢失心跳对于需要它的系统来说可能非常严重。因此,我们需要与心跳线程通信(例如,通过消息流或使用 condition_variable
的通知事件)。
一个替代的、通常更优的解决方案是将其放置在其创建(或激活)点之外的作用域中来控制其生命周期。例如
void heartbeat();
gsl::joining_thread t(heartbeat); // heartbeat is meant to run "forever"
此心跳(除非发生错误、硬件问题等)将运行直到程序结束。
有时,我们需要将创建点与所有权点分开
void heartbeat();
unique_ptr<gsl::joining_thread> tick_tock {nullptr};
void use()
{
// heartbeat is meant to run as long as tick_tock lives
tick_tock = make_unique<gsl::joining_thread>(heartbeat);
// ...
}
标记 detach()
。
与使用某些锁定机制共享数据相比,复制少量数据在成本和访问上都更低。复制自然地提供了唯一所有权(简化代码)并消除了数据竞争的可能性。
精确定义“少量数据”是不可能的。
string modify1(string);
void modify2(string&);
void fct(string& s)
{
auto res = async(modify1, s);
async(modify2, s);
}
调用 modify1
涉及复制两个 string
值;调用 modify2
则不涉及。另一方面,modify1
的实现与我们为单线程代码编写的完全相同,而 modify2
的实现将需要某种形式的锁定来避免数据竞争。如果字符串很短(例如 10 个字符),modify1
的调用可能出奇地快;实际上所有的成本都在 thread
切换上。如果字符串很长(例如 1,000,000 个字符),复制两次可能不是一个好主意。
请注意,这个论点与 async
本身无关。它同样适用于关于是使用消息传递还是共享内存的考虑。
???
thread
之间的所有权,请使用 shared_ptr
如果线程不相关(即,不知道它们是否在同一作用域或一个在另一个的生命周期内)并且它们需要共享需要删除的自由存储内存,那么 shared_ptr
(或等效物)是确保正确删除的唯一安全方式。
???
???
上下文切换是昂贵的。
???
???
线程创建是昂贵的。
void worker(Message m)
{
// process
}
void dispatcher(istream& is)
{
for (Message m; is >> m; )
run_list.push_back(new thread(worker, m));
}
这为每个消息生成一个 thread
,并且 run_list
假定被管理以销毁这些任务一旦它们完成。
相反,我们可以有一组预先创建的工作线程来处理消息
Sync_queue<Message> work;
void dispatcher(istream& is)
{
for (Message m; is >> m; )
work.put(m);
}
void worker()
{
for (Message m; m = work.get(); ) {
// process
}
}
void workers() // set up worker threads (specifically 4 worker threads)
{
joining_thread w1 {worker};
joining_thread w2 {worker};
joining_thread w3 {worker};
joining_thread w4 {worker};
}
如果您的系统有一个好的线程池,请使用它。如果您的系统有一个好的消息队列,请使用它。
???
wait
没有条件的 wait
可能会错过唤醒,或者仅仅被唤醒却发现没有工作要做。
std::condition_variable cv;
std::mutex mx;
void thread1()
{
while (true) {
// do some work ...
std::unique_lock<std::mutex> lock(mx);
cv.notify_one(); // wake other thread
}
}
void thread2()
{
while (true) {
std::unique_lock<std::mutex> lock(mx);
cv.wait(lock); // might block forever
// do work ...
}
}
在这里,如果另一个 thread
消耗了 thread1
的通知,thread2
可能会永远等待。
template<typename T>
class Sync_queue {
public:
void put(const T& val);
void put(T&& val);
void get(T& val);
private:
mutex mtx;
condition_variable cond; // this controls access
list<T> q;
};
template<typename T>
void Sync_queue<T>::put(const T& val)
{
lock_guard<mutex> lck(mtx);
q.push_back(val);
cond.notify_one();
}
template<typename T>
void Sync_queue<T>::get(T& val)
{
unique_lock<mutex> lck(mtx);
cond.wait(lck, [this] { return !q.empty(); }); // prevent spurious wakeup
val = q.front();
q.pop_front();
}
现在,如果执行 get()
的线程在唤醒时队列为空(例如,因为另一个线程先于它执行了 get()
),它将立即再次休眠等待。
标记所有没有条件的 wait
。
持有 mutex
的时间越少,另一个 thread
等待的机会就越小,而 thread
的挂起和恢复是昂贵的。
void do_something() // bad
{
unique_lock<mutex> lck(my_lock);
do0(); // preparation: does not need lock
do1(); // transaction: needs locking
do2(); // cleanup: does not need locking
}
在这里,我们持有锁的时间超过了必要的时间:在需要之前不应该获取锁,并且应该在开始清理之前释放它。我们可以重写为
void do_something() // bad
{
do0(); // preparation: does not need lock
my_lock.lock();
do1(); // transaction: needs locking
my_lock.unlock();
do2(); // cleanup: does not need locking
}
但这会损害安全性并违反 使用 RAII 规则。相反,为临界区添加一个块
void do_something() // OK
{
do0(); // preparation: does not need lock
{
unique_lock<mutex> lck(my_lock);
do1(); // transaction: needs locking
}
do2(); // cleanup: does not need locking
}
一般情况下不可能。标记“裸露”的 lock()
和 unlock()
。
lock_guard
和 unique_lock
命名未命名的局部对象是临时对象,会立即超出作用域。
// global mutexes
mutex m1;
mutex m2;
void f()
{
unique_lock<mutex>(m1); // (A)
lock_guard<mutex> {m2}; // (B)
// do work in critical section ...
}
这看起来足够无辜,但事实并非如此。在 (A) 处,m1
是一个默认构造的局部 unique_lock
,它隐藏了全局的 ::m1
(并且没有锁定它)。在 (B) 处,一个未命名的临时 lock_guard
被构造并锁定 ::m2
,但立即超出作用域并再次解锁 ::m2
。对于函数 f()
的其余部分,两个互斥锁都没有被锁定。
标记所有未命名的 lock_guard
和 unique_lock
。
mutex
,以保护该数据。可能时使用 synchronized_value<T>
读者应该清楚数据需要被保护以及如何保护。这减少了锁定错误互斥锁或未锁定互斥锁的可能性。
使用 synchronized_value<T>
可确保数据具有互斥锁,并在访问数据时锁定正确的互斥锁。请参阅 WG21 提案,将 synchronized_value
添加到未来的 TS 或 C++ 标准修订版中。
struct Record {
std::mutex m; // take this mutex before accessing other members
// ...
};
class MyClass {
struct DataRecord {
// ...
};
synchronized_value<DataRecord> data; // Protect the data with a mutex
};
??? 可能吗?
本节重点介绍协程的用法。
协程规则摘要
对于协程 lambda,正确的普通 lambda 用法模式是危险的。捕获变量的明显模式将导致在第一个挂起点之后访问已释放内存,即使是对于引用计数的智能指针和可复制类型。
lambda 会产生一个闭包对象,该对象拥有存储空间,通常在栈上,它会在某个时候超出作用域。当闭包对象超出作用域时,捕获的变量也会超出作用域。普通 lambda 在此时已经执行完毕,因此这不是问题。协程 lambda 可能会在闭包对象已销毁后从挂起状态恢复,此时所有捕获都将成为已使用已释放内存的访问。
int value = get_value();
std::shared_ptr<Foo> sharedFoo = get_foo();
{
const auto lambda = [value, sharedFoo]() -> std::future<void>
{
co_await something();
// "sharedFoo" and "value" have already been destroyed
// the "shared" pointer didn't accomplish anything
};
lambda();
} // the lambda closure object has now gone out of scope
int value = get_value();
std::shared_ptr<Foo> sharedFoo = get_foo();
{
// take as by-value parameter instead of as a capture
const auto lambda = [](auto sharedFoo, auto value) -> std::future<void>
{
co_await something();
// sharedFoo and value are still valid at this point
};
lambda(sharedFoo, value);
} // the lambda closure object has now gone out of scope
使用函数作为协程。
std::future<void> Class::do_something(int value, std::shared_ptr<Foo> sharedFoo)
{
co_await something();
// sharedFoo and value are still valid at this point
}
void SomeOtherFunction()
{
int value = get_value();
std::shared_ptr<Foo> sharedFoo = get_foo();
do_something(value, sharedFoo);
}
标记一个捕获列表非空的协程 lambda。
此模式会造成严重的死锁风险。某些类型的等待允许当前线程执行额外的工作,直到异步操作完成。如果持有锁的线程执行的工作需要相同的锁,它将死锁,因为它试图获取它已经持有的锁。
如果协程在与获取锁的线程不同的线程上完成,那么这是未定义行为。即使显式返回到原始线程,也可能在协程恢复之前抛出异常,结果将是锁守护程序未被销毁。
std::mutex g_lock;
std::future<void> Class::do_something()
{
std::lock_guard<std::mutex> guard(g_lock);
co_await something(); // DANGER: coroutine has suspended execution while holding a lock
co_await somethingElse();
}
std::mutex g_lock;
std::future<void> Class::do_something()
{
{
std::lock_guard<std::mutex> guard(g_lock);
// modify data protected by lock
}
co_await something(); // OK: lock has been released before coroutine suspends
co_await somethingElse();
}
此模式对性能也不利。当达到挂起点(如 co_await)时,当前函数的执行停止,并开始运行其他代码。协程恢复可能需要很长时间。在此整个期间,锁将被持有,其他线程无法获取它来执行工作。
标记所有在协程挂起之前未被销毁的锁守护程序。
一旦协程达到第一个挂起点,例如 co_await,同步部分就会返回。此后,通过引用传递的任何参数都将是悬垂的。任何超出此范围的使用都是未定义行为,可能包括写入已释放的内存。
std::future<int> Class::do_something(const std::shared_ptr<int>& input)
{
co_await something();
// DANGER: the reference to input may no longer be valid and may be freed memory
co_return *input + 1;
}
std::future<int> Class::do_something(std::shared_ptr<int> input)
{
co_await something();
co_return *input + 1; // input is a copy that is still valid here
}
此问题不适用于仅在第一个挂起点之前访问的引用参数。对函数的后续更改可能会添加或移动挂起点,这会重新引入此类错误。某些类型的协程在协程中的第一行代码执行之前就具有挂起点,在这种情况下,引用参数始终不安全。通过值传递更安全,因为复制的参数将位于协程帧中,该帧在整个协程中都是安全可访问的。
同样的危险也适用于输出参数。 F.20:对于“out”输出值,首选返回值而不是输出参数 否定了输出参数。协程应完全避免它们。
标记协程的所有引用参数。
我们所说的“并行性”是指在一个任务上(或多或少)同时(“与…并行”)在许多数据项上执行。
并行性规则摘要
标准库设施非常底层,侧重于使用 thread
s、mutex
es、atomic
类型等的接近硬件的关键编程需求。大多数人不应该在这个级别工作:它容易出错且开发缓慢。如果可能,使用更高级别的设施:消息传递库、并行算法和向量化。本节介绍消息传递,以便程序员不必进行显式同步。
消息传递规则摘要
???? 是否应有“使用 X 而不是 std::async
”,其中 X 是使用更好指定的线程池的东西?
??? 考虑到未来(甚至现有,如库)的并行性设施,std::async
是否值得使用?如果有人想并行化,例如 std::accumulate
(附加了可交换性的先决条件)或归并排序,指南应该推荐什么?
future
从并发任务中返回值A future
为异步任务保留了通常的函数调用返回语义。没有显式的锁定,并且正确(值)返回和错误(异常)返回都得到了简单处理。
???
???
???
async()
启动并发任务类似于 R.12(该规则告诉您避免原始拥有指针),您也应尽可能避免原始线程和原始 promise。使用工厂函数,如 std::async
,它可以处理启动或重用线程,而无需将原始线程暴露给您自己的代码。
int read_value(const std::string& filename)
{
std::ifstream in(filename);
in.exceptions(std::ifstream::failbit);
int value;
in >> value;
return value;
}
void async_example()
{
try {
std::future<int> f1 = std::async(read_value, "v1.txt");
std::future<int> f2 = std::async(read_value, "v2.txt");
std::cout << f1.get() + f2.get() << '\n';
} catch (const std::ios_base::failure& fail) {
// handle exception here
}
}
不幸的是,std::async
并非完美。例如,它不使用线程池,这意味着它可能会由于资源耗尽而失败,而不是将您的任务排队等待以后执行。但是,即使您无法使用 std::async
,您也应该倾向于编写自己的 future
返回的工厂函数,而不是使用原始 promise。
此示例显示了使用 std::future
成功但避免原始 std::thread
管理失败的两种不同方式。
void async_example()
{
std::promise<int> p1;
std::future<int> f1 = p1.get_future();
std::thread t1([p1 = std::move(p1)]() mutable {
p1.set_value(read_value("v1.txt"));
});
t1.detach(); // evil
std::packaged_task<int()> pt2(read_value, "v2.txt");
std::future<int> f2 = pt2.get_future();
std::thread(std::move(pt2)).detach();
std::cout << f1.get() + f2.get() << '\n';
}
此示例展示了一种遵循 std::async
所设定的通用模式的方法,该方法适用于 std::async
本身在生产环境中不可接受的情况。
void async_example(WorkQueue& wq)
{
std::future<int> f1 = wq.enqueue([]() {
return read_value("v1.txt");
});
std::future<int> f2 = wq.enqueue([]() {
return read_value("v2.txt");
});
std::cout << f1.get() + f2.get() << '\n';
}
为执行 read_value
代码而生成的任何线程都隐藏在对 WorkQueue::enqueue
的调用后面。用户代码仅处理 future
对象,从不处理原始 thread
、promise
或 packaged_task
对象。
???
向量化是一种在不引入显式同步的情况下并发执行多个任务的技术。一个操作只是并行地应用于数据结构(向量、数组等)的元素。向量化有一个有趣的特性,即它通常不需要对程序进行非局部更改。然而,向量化最适用于简单的数据结构和专门为此目的设计的算法。
向量化规则摘要
使用 mutex
es 和 condition_variable
s 进行同步可能相对昂贵。此外,它可能导致死锁。为了性能和消除死锁的可能性,我们有时必须使用棘手的低级“无锁”设施,这些设施依赖于短暂地获得对内存的独占(“原子”)访问。无锁编程也用于实现更高级别的并发机制,例如 thread
s 和 mutex
es。
无锁编程规则摘要
它容易出错,并且需要语言特性、机器架构和数据结构的专家级知识。
extern atomic<Link*> head; // the shared head of a linked list
Link* nh = new Link(data, nullptr); // make a link ready for insertion
Link* h = head.load(); // read the shared head of the list
do {
if (h->data <= data) break; // if so, insert elsewhere
nh->next = h; // next element is the previous head
} while (!head.compare_exchange_weak(h, nh)); // write nh to head or to h
找出错误。通过测试找到它将非常困难。阅读有关 ABA 问题的内容。
只要您使用的是顺序一致的内存模型(memory_order_seq_cst)(这是默认设置),就可以简单安全地使用原子变量。
更高级别的并发机制,例如 thread
s 和 mutex
es 是使用无锁编程实现的。
替代方案:使用其他人作为某些库一部分实现的无锁数据结构。
无锁编程使用的低级硬件接口是最难正确实现的接口之一,也是最微妙的可移植性问题发生最多的领域之一。如果您为了性能而进行无锁编程,您需要检查回归。
指令重排序(静态和动态)使得我们在这种级别上有效思考变得困难(尤其如果您使用宽松的内存模型)。经验、(半)形式化模型和模型检查可能很有用。测试——通常是极端的——是必不可少的。“不要飞得离太阳太近。”
制定严格的就地重新测试规则,涵盖硬件、操作系统、编译器和库的任何更改。
除了原子变量和一些其他标准模式外,无锁编程确实是专家级话题。在发布供他人使用的无锁代码之前,请成为专家。
自 C++11 起,静态局部变量现在以线程安全的方式进行初始化。与 RAII 模式结合使用时,静态局部变量可以取代编写自己的双重检查锁定用于初始化的需求。std::call_once 也可以达到相同的目的。请使用 C++11 的静态局部变量或 std::call_once,而不是自己编写用于初始化的双重检查锁定。
带 std::call_once 的示例。
void f()
{
static std::once_flag my_once_flag;
std::call_once(my_once_flag, []()
{
// do this only once
});
// ...
}
带 C++11 线程安全静态局部变量的示例。
void f()
{
// Assuming the compiler is compliant with C++11
static My_class my_object; // Constructor called only once
// ...
}
class My_class
{
public:
My_class()
{
// do this only once
}
};
??? 是否可以检测这种习语?
双重检查锁定很容易出错。如果您确实需要自己编写双重检查锁定,尽管有 CP.110:不要自己编写用于初始化的双重检查锁定 和 CP.100:除非绝对必要,否则不要使用无锁编程 的规则,那么请以约定模式进行。
双重检查锁定模式的用法,这些用法不违反 CP.110:不要自己编写用于初始化的双重检查锁定,是因为非线程安全的动作既困难又罕见,并且存在一个快速的线程安全测试,该测试可用于保证该动作不是必需的,但不能用于保证反之。
volatile 的使用并不使第一次检查线程安全,另请参阅 CP.200:仅在与非 C++ 内存通信时使用 volatile
mutex action_mutex;
volatile bool action_needed;
if (action_needed) {
std::lock_guard<std::mutex> lock(action_mutex);
if (action_needed) {
take_action();
action_needed = false;
}
}
mutex action_mutex;
atomic<bool> action_needed;
if (action_needed) {
std::lock_guard<std::mutex> lock(action_mutex);
if (action_needed) {
take_action();
action_needed = false;
}
}
当获取加载比顺序一致加载更有效时,精细调整的内存顺序可能是有益的
mutex action_mutex;
atomic<bool> action_needed;
if (action_needed.load(memory_order_acquire)) {
lock_guard<std::mutex> lock(action_mutex);
if (action_needed.load(memory_order_relaxed)) {
take_action();
action_needed.store(false, memory_order_release);
}
}
??? 是否可以检测这种习语?
这些规则无法简单归类
volatile
volatile
用于引用与“非 C++”代码或不遵循 C++ 内存模型的硬件共享的对象。
const volatile long clock;
这描述了一个由时钟电路不断更新的寄存器。 clock
是 volatile
的,因为它的值会在不 C++ 程序使用它的情况下发生变化。例如,读取两次 clock
常常会得到两个不同的值,因此优化器最好不要在这种代码中优化掉第二次读取。
long t1 = clock;
// ... no use of clock here ...
long t2 = clock;
clock
是 const
的,因为程序不应尝试写入 clock
。
除非您正在编写操作硬件的最低级别代码,否则请将 volatile
视为一种晦涩的功能,最好避免使用。
通常 C++ 代码接收由其他地方(硬件或另一种语言)拥有的 volatile
内存。
int volatile* vi = get_hardware_memory_location();
// note: we get a pointer to someone else's memory here
// volatile says "treat this with extra respect"
有时 C++ 代码会分配 volatile
内存,并通过故意逃逸指针与“其他地方”(硬件或另一种语言)共享。
static volatile long vl;
please_use_this(&vl); // escape a reference to this to "elsewhere" (not C++)
局部 volatile
变量几乎总是不对的——它们如何与别的语言或硬件共享,如果它们是短暂的呢?对于数据成员来说,原因相同,几乎同样适用。
void f()
{
volatile int i = 0; // bad, volatile local variable
// etc.
}
class My_type {
volatile int i = 0; // suspicious, volatile data member
// etc.
};
在 C++ 中,与某些其他语言不同,volatile
与同步无关。
volatile T
;几乎可以肯定您打算改用 atomic<T>
。??? UNIX 信号处理???。可能值得提醒有多少东西不是异步信号安全的,以及如何与信号处理程序通信(最好是“不与任何东西”)。
错误处理涉及
并非所有错误都可以恢复。如果错误无法恢复,则重要的是以明确定义的方式“快速退出”。错误处理策略必须简单,否则它就会成为更糟糕错误的根源。未经测试且很少执行的错误处理代码本身就是许多错误的根源。
这些规则旨在帮助避免几种类型的错误
union
s 和强制转换)delete
后访问它)错误处理规则摘要
throw
导致函数退出是不可能或不可接受时,使用 noexcept
swap
和异常类型复制/移动构造函数绝不能失败try
/catch
的使用catch
子句一致且完整的错误处理和资源泄漏策略很难事后加到系统中。
使错误处理系统化、健壮且不重复。
struct Foo {
vector<Thing> v;
File_handle f;
string s;
};
void use()
{
Foo bar { {Thing{1}, Thing{2}, Thing{monkey} }, {"my_file", "r"}, "Here we go!"};
// ...
}
在此,vector
和 string
的构造函数可能无法为其元素分配足够的内存,vector
的构造函数可能无法在其初始化列表中复制 Thing
,而 File_handle
可能无法打开所需文件。在每种情况下,它们都会抛出异常供 use()
的调用者处理。如果 use()
可以处理 bar
构造失败的情况,它就可以使用 try
/catch
来控制。无论哪种情况,Foo
的构造函数都会在将控制权转交给任何尝试创建 Foo
的对象之前,正确地销毁已构造的成员。请注意,没有返回值可以包含错误代码。
File_handle
构造函数可以定义如下:
File_handle::File_handle(const string& name, const string& mode)
: f{fopen(name.c_str(), mode.c_str())}
{
if (!f)
throw runtime_error{"File_handle: could not open " + name + " as " + mode};
}
人们常说异常是用来信号异常事件和错误的。然而,这有点循环定义,因为“什么才是异常?” 举例来说:
v[v.size()] = 7
)相比之下,普通循环的终止并非异常。除非循环旨在无限运行,否则终止是正常且可预期的。
不要将 throw
用作函数返回值的简单替代方法。
某些系统,例如硬实时系统,要求在执行开始前已知(通常很短)的固定最大时间内采取行动。此类系统只有在有工具支持精确预测从 throw
中恢复的最大时间时,才能使用异常。
另请参见:RAII
另请参阅:讨论
在决定不能承受或不喜欢基于异常的错误处理之前,请查看替代方案;它们也有自身的复杂性和问题。此外,在可能的情况下,在做出关于效率的声明之前进行测量。
将错误处理与“普通代码”分开。C++ 实现倾向于基于异常罕见的假设进行优化。
// don't: exception not used for error handling
int find_index(vector<string>& vec, const string& x)
{
try {
for (gsl::index i = 0; i < vec.size(); ++i)
if (vec[i] == x) throw i; // found x
}
catch (int i) {
return i;
}
return -1; // not found
}
这更复杂,并且很可能比显而易见的替代方法慢得多。在 vector
中查找值并非异常。
需要启发式处理。寻找“泄漏”出 catch
子句的异常值。
要使用一个对象,它必须处于有效状态(由不变量正式或非正式地定义),并且要从错误中恢复,所有未销毁的对象都必须处于有效状态。
不变量是对象成员的一个逻辑条件,构造函数必须建立该条件,以便公共成员函数可以假定它。
???
使对象处于未建立不变量的状态是自寻烦恼。并非所有成员函数都可以调用。
class Vector { // very simplified vector of doubles
// if elem != nullptr then elem points to sz doubles
public:
Vector() : elem{nullptr}, sz{0}{}
Vector(int s) : elem{new double[s]}, sz{s} { /* initialize elements */ }
~Vector() { delete [] elem; }
double& operator[](int s) { return elem[s]; }
// ...
private:
owner<double*> elem;
int sz;
};
类不变量——此处用注释说明——由构造函数建立。new
在无法分配所需内存时抛出异常。运算符,特别是下标运算符,依赖于不变量。
另请参阅:如果构造函数无法构造有效对象,则抛出异常
将没有构造函数(公共、受保护或私有)的类标记为具有 private
状态。
泄漏通常是不可接受的。手动释放资源容易出错。RAII(“资源获取即初始化”)是防止泄漏最简单、最系统的方法。
void f1(int i) // Bad: possible leak
{
int* p = new int[12];
// ...
if (i < 17) throw Bad{"in f()", i};
// ...
}
我们可以在 throw
之前小心地释放资源
void f2(int i) // Clumsy and error-prone: explicit release
{
int* p = new int[12];
// ...
if (i < 17) {
delete[] p;
throw Bad{"in f()", i};
}
// ...
}
这很冗长。在具有多个可能 throw
的大型代码中,显式释放会变得重复且容易出错。
void f3(int i) // OK: resource management done by a handle (but see below)
{
auto p = make_unique<int[]>(12);
// ...
if (i < 17) throw Bad{"in f()", i};
// ...
}
请注意,即使 throw
是隐式的,因为它发生在被调用的函数中,这也同样有效。
void f4(int i) // OK: resource management done by a handle (but see below)
{
auto p = make_unique<int[]>(12);
// ...
helper(i); // might throw
// ...
}
除非你确实需要指针语义,否则请使用局部资源对象
void f5(int i) // OK: resource management done by local object
{
vector<int> v(12);
// ...
helper(i); // might throw
// ...
}
这样更简单、更安全,而且通常更高效。
如果没有明显的资源句柄,并且由于某种原因定义一个合适的 RAII 对象/句柄不可行,作为最后的手段,清理操作可以表示为一个 final_action
对象。
但是,如果我们编写一个不能使用异常的程序,我们该怎么办?首先挑战这个假设;有很多关于异常的神话。我们只知道少数几个充分的理由
其中只有第一个理由是根本性的,因此,只要有可能,就使用异常来实现 RAII,或设计你的 RAII 对象使其永远不会失败。当无法使用异常时,模拟 RAII。也就是说,系统地检查对象在构造后是否有效,并在析构函数中始终释放所有资源。一种策略是向每个资源句柄添加一个 valid()
操作。
void f()
{
vector<string> vs(100); // not std::vector: valid() added
if (!vs.valid()) {
// handle error or exit
}
ifstream fs("foo"); // not std::ifstream: valid() added
if (!fs.valid()) {
// handle error or exit
}
// ...
} // destructors clean up as usual
显然,这会增加代码量,不允许“异常”的隐式传播(valid()
检查),并且 valid()
检查可能会被遗忘。优先使用异常。
另请参阅:使用 noexcept
???
避免接口错误。
另请参阅:前置条件规则
避免接口错误。
另请参阅:后置条件规则
noexcept
,因为 throw
不可能或不可接受使错误处理系统化、健壮且高效。
double compute(double d) noexcept
{
return log(sqrt(d <= 0 ? 1 : d));
}
在这里,我们知道 compute
不会抛出,因为它是由不抛出异常的操作组成的。通过将 compute
声明为 noexcept
,我们为编译器和人类读者提供了信息,可以使他们更容易理解和操作 compute
。
许多标准库函数是 noexcept
的,包括所有从 C 标准库“继承”的标准库函数。
vector<double> munge(const vector<double>& v) noexcept
{
vector<double> v2(v.size());
// ... do something ...
}
这里的 noexcept
表明我不想或不能处理无法构造局部 vector
的情况。也就是说,我认为内存耗尽是一个严重的设计错误(与硬件故障相当),因此我愿意在发生这种情况时崩溃程序。
不要使用传统的异常规范。
讨论.
那将是泄漏。
void leak(int x) // don't: might leak
{
auto p = new int{7};
if (x < 0) throw Get_me_out_of_here{}; // might leak *p
// ...
delete p; // we might never get here
}
避免此类问题的一种方法是始终使用资源句柄
void no_leak(int x)
{
auto p = make_unique<int>(7);
if (x < 0) throw Get_me_out_of_here{}; // will delete *p if necessary
// ...
// no need for delete p
}
另一种解决方案(通常更好)是使用局部变量来消除显式使用指针
void no_leak_simplified(int x)
{
vector<int> v(7);
// ...
}
如果你有一个需要清理但没有由具有析构函数的对象表示的局部“事物”,那么这种清理也必须在 throw
之前完成。有时,finally()
可以使这种非系统化的清理稍微更容易管理。
用户定义的类型可以更好地将错误信息传递给处理程序。信息可以编码到类型本身,并且类型不太可能与其他人的异常发生冲突。
throw 7; // bad
throw "something bad"; // bad
throw std::exception{}; // bad - no info
从 std::exception
派生提供了灵活性,可以捕获特定的异常,或者通过 std::exception
进行通用处理。
class MyException : public std::runtime_error
{
public:
MyException(const string& msg) : std::runtime_error{msg} {}
// ...
};
// ...
throw MyException{"something bad"}; // good
异常不需要从 std::exception
派生。
class MyCustomError final {}; // not derived from std::exception
// ...
throw MyCustomError{}; // good - handlers must catch this type (or ...)
如果无法在检测点添加有用的信息,则可以像泛型异常一样使用派生自 std::exception
的库类型。
throw std::runtime_error("something bad"); // good
// ...
throw std::invalid_argument("i is not even"); // good
也允许使用 enum
类。
enum class alert {RED, YELLOW, GREEN};
throw alert::RED; // good
捕获内置类型和 std::exception
的 throw
。
按值(而不是指针)抛出,并按引用捕获,可以防止复制,特别是切片基类子对象。
void f()
{
try {
// ...
throw new widget{}; // don't: throw by value, not by raw pointer
// ...
}
catch (base_class e) { // don't: might slice
// ...
}
}
相反,使用引用
catch (base_class& e) { /* ... */ }
或者——通常更好——使用 const
引用
catch (const base_class& e) { /* ... */ }
大多数处理程序不会修改它们的异常,而且我们通常推荐使用 const
。
对于像 enum
值这样的小值类型,按值捕获是合适的。
要重新抛出捕获的异常,请使用 throw;
而不是 throw e;
。使用 throw e;
会抛出一个 e
的新副本(当异常被 catch (const std::exception& e)
捕获时,会切片到 std::exception
的静态类型),而不是重新抛出原始的 std::runtime_error
类型的异常。(但请牢记 不要试图在每个函数中捕获所有异常 和 最小化显式 try
/catch
的使用。)
swap
以及异常类型的复制/移动构造函数绝不能失败如果我们不知道如何编写可靠的程序,如果析构函数、swap、内存解分配或尝试复制/移动构造异常对象失败(即,如果它通过异常退出或根本不执行其所需的操作)。
class Connection {
// ...
public:
~Connection() // Don't: very bad destructor
{
if (cannot_disconnect()) throw I_give_up{information};
// ...
}
};
许多人试图编写违反此规则的可靠代码的示例,例如“拒绝关闭”的网络连接。据我们所知,没有人找到一种通用的方法来做到这一点。偶尔,对于非常具体的示例,您可以设置某些状态以供将来清理。例如,我们可以将一个不想关闭的套接字放在一个“坏套接字”列表中,供系统状态的定期扫描检查。我们见过的每个示例都是易错的、专门化的,并且通常有错误。
标准库假定析构函数、解分配函数(例如 operator delete
)和 swap
不会抛出异常。如果它们抛出,则基本的标准库不变量会被破坏。
operator delete
,必须是 noexcept
的。swap
函数必须是 noexcept
的。noexcept
的。noexcept
。noexcept
的。通常我们无法以机械方式强制执行此操作,因为我们不知道类型是否旨在用作异常类型。throw
一个其复制构造函数不是 noexcept
的类型。通常我们无法以机械方式强制执行此操作,因为即使 throw std::string(...)
也可能抛出,但实际上不会。swap
。noexcept
的此类操作。另请参阅:讨论
在无法采取有意义的恢复措施的函数中捕获异常会增加复杂性和浪费。让异常传播,直到它到达一个可以处理它的函数。让展开路径上的清理操作由 RAII 处理。
void f() // bad
{
try {
// ...
}
catch (...) {
// no action
throw; // propagate exception
}
}
try
/catch
的使用try
/catch
很冗长,非平凡的使用很容易出错。try
/catch
可能是不系统和/或低级别资源管理或错误处理的迹象。
void f(zstring s)
{
Gadget* p;
try {
p = new Gadget(s);
// ...
delete p;
}
catch (Gadget_construction_failure) {
delete p;
throw;
}
}
这段代码很乱。裸指针在 try
块中可能导致泄漏。并非所有异常都已处理。删除一个构造失败的对象几乎肯定是一个错误。更好的做法是
void f2(zstring s)
{
Gadget g {s};
}
???很难,需要启发式
final_action
对象来表达清理来自 GSL 的 finally
比 try
/catch
更简洁,更不易出错。
void f(int n)
{
void* p = malloc(n);
auto _ = gsl::finally([p] { free(p); });
// ...
}
finally
不如 try
/catch
混乱,但它仍然是临时的。优先选择 适当的资源管理对象。考虑将 finally
作为最后的手段。
使用 finally
是处理没有系统化资源管理的清理的旧式 goto exit;
技术 的一种系统化且相当干净的替代方法。
启发式:检测 goto exit;
即使没有异常,RAII 通常也是处理资源的最优和最系统的方法。
使用异常进行错误处理是 C++ 中处理非局部错误的唯一完整且系统化的方法。特别是,非侵入性地发出构造对象失败的信号需要异常。以一种不能被忽略的方式发出错误信号需要异常。如果你不能使用异常,就尽你所能模拟它们的使用。
很多对异常的恐惧是误导性的。当用于代码中的异常情况,而这些代码没有充斥着指针和复杂的控制结构时,异常处理几乎总是可以承受的(在时间和空间上),并且几乎总是能产生更好的代码。当然,这假定异常处理机制有一个好的实现,而这并非在所有系统上都可用。还有一些情况,上述问题不适用,但出于其他原因无法使用异常。一些硬实时系统就是一个例子:操作必须在固定的时间内完成,要么出错,要么给出正确答案。在缺乏适当的时间估算工具的情况下,这对于异常很难保证。此类系统(例如飞行控制软件)通常也禁止使用动态(堆)内存。
因此,错误处理的主要指南是“使用异常和 RAII。”本节处理的是那些你没有高效的异常实现,或者拥有一个充斥着遗留代码的烂摊子(例如,大量指针、未定义的所有权,以及大量基于错误码测试的不系统错误处理),以至于引入简单而系统的异常处理不可行的场景。
在批评异常或过多抱怨其成本之前,请考虑使用错误码的例子。考虑使用错误码的成本和复杂性。如果你担心性能,那就去测量。
假设你想写
void func(zstring arg)
{
Gadget g {arg};
// ...
}
如果 gadget
没有正确构造,func
将以异常退出。如果我们不能抛出异常,我们可以通过向 Gadget
添加一个 valid()
成员函数来模拟这种 RAII 式的资源处理。
error_indicator func(zstring arg)
{
Gadget g {arg};
if (!g.valid()) return gadget_construction_error;
// ...
return 0; // zero indicates "good"
}
问题当然是调用者现在必须记住测试返回值。为了鼓励这样做,可以考虑添加一个 [[nodiscard]]
。
另请参阅:讨论
可能(仅)适用于此想法的特定版本:例如,测试用于资源句柄构造后的系统化 valid()
测试。
如果你不能很好地进行恢复,至少可以尽快退出,以免造成更多附带损害。
另请参阅:模拟 RAII
如果你不能系统地处理错误,考虑将任何无法在本地处理的错误作为“崩溃”来响应。也就是说,如果你无法在检测到错误的函数上下文中恢复,请调用 abort()
、quick_exit()
或类似的函数来触发某种系统重启。
在有很多进程和/或很多计算机的系统中,你仍然需要预料并处理致命崩溃,例如来自硬件故障。在这种情况下,“崩溃”只是将错误处理留给系统的下一级。
void f(int n)
{
// ...
p = static_cast<X*>(malloc(n * sizeof(X)));
if (!p) abort(); // abort if memory is exhausted
// ...
}
大多数程序仍然无法优雅地处理内存耗尽。这大致相当于
void f(int n)
{
// ...
p = new X[n]; // throw if memory is exhausted (by default, terminate)
// ...
}
通常,最好在退出前记录“崩溃”的原因。
尴尬
任何错误处理策略的系统化使用都能最大限度地减少忘记处理错误的几率。
另请参阅:模拟 RAII
有几个问题需要解决
通常,返回错误指示器意味着返回两个值:结果和错误指示器。错误指示器可以是对象的一部分,例如对象可以有一个 valid()
指示器,或者可以返回一对值。
Gadget make_gadget(int n)
{
// ...
}
void user()
{
Gadget g = make_gadget(17);
if (!g.valid()) {
// error handling
}
// ...
}
这种方法与 模拟的 RAII 资源管理 相吻合。 valid()
函数可以返回一个 error_indicator
(例如,error_indicator
枚举的一个成员)。
如果我们不能或不想修改 Gadget
类型怎么办?在这种情况下,我们必须返回一对值。例如:
std::pair<Gadget, error_indicator> make_gadget(int n)
{
// ...
}
void user()
{
auto r = make_gadget(17);
if (!r.second) {
// error handling
}
Gadget& g = r.first;
// ...
}
如所示,std::pair
是一个可能的返回类型。有些人更喜欢特定类型。例如:
Gval make_gadget(int n)
{
// ...
}
void user()
{
auto r = make_gadget(17);
if (!r.err) {
// error handling
}
Gadget& g = r.val;
// ...
}
偏好特定返回类型的一个原因是为了给其成员命名,而不是稍微晦涩的 first
和 second
,以及避免与 std::pair
的其他用法混淆。
通常,你必须在错误退出之前进行清理。这可能会很混乱
std::pair<int, error_indicator> user()
{
Gadget g1 = make_gadget(17);
if (!g1.valid()) {
return {0, g1_error};
}
Gadget g2 = make_gadget(31);
if (!g2.valid()) {
cleanup(g1);
return {0, g2_error};
}
// ...
if (all_foobar(g1, g2)) {
cleanup(g2);
cleanup(g1);
return {0, foobar_error};
}
// ...
cleanup(g2);
cleanup(g1);
return {res, 0};
}
模拟 RAII 可能不是件容易的事,尤其是在具有多个资源和多个可能错误的函数中。一种不少见的技巧是将清理工作集中在函数末尾以避免重复(注意 g2
周围的额外作用域是不理想的,但对于 goto
版本可以编译是必需的)。
std::pair<int, error_indicator> user()
{
error_indicator err = 0;
int res = 0;
Gadget g1 = make_gadget(17);
if (!g1.valid()) {
err = g1_error;
goto g1_exit;
}
{
Gadget g2 = make_gadget(31);
if (!g2.valid()) {
err = g2_error;
goto g2_exit;
}
if (all_foobar(g1, g2)) {
err = foobar_error;
goto g2_exit;
}
// ...
g2_exit:
if (g2.valid()) cleanup(g2);
}
g1_exit:
if (g1.valid()) cleanup(g1);
return {res, err};
}
函数越大,这种技术就越有吸引力。finally
可以 稍微减轻痛苦。而且,程序变得越大,就越难系统地应用基于错误指示符的错误处理策略。
我们偏好基于异常的错误处理,并建议保持函数简短。
另请参阅:讨论
另请参阅:返回多个值
尴尬。
errno
)全局状态难以管理,而且很容易忘记检查它。你上次检查 printf()
的返回值是什么时候?
另请参阅:模拟 RAII
int last_err;
void f(int n)
{
// ...
p = static_cast<X*>(malloc(n * sizeof(X)));
if (!p) last_err = -1; // error if memory is exhausted
// ...
}
C 风格的错误处理基于全局变量 errno
,因此完全避免这种风格几乎是不可能的。
尴尬。
异常规范使错误处理变得脆弱,会带来运行时成本,并且已被从 C++ 标准中移除。
int use(int arg)
throw(X, Y)
{
// ...
auto x = f(arg);
// ...
}
如果 f()
抛出的异常与 X
和 Y
不同,则会调用意外处理程序,该处理程序默认会终止。这没关系,但假设我们已经检查过这不会发生,并且 f
已更改为抛出新的异常 Z
,那么我们就会面临崩溃,除非我们更改 use()
(并重新测试所有内容)。问题在于 f()
可能在一个我们无法控制的库中,并且新的异常是 use()
无法处理或根本不关心的任何东西。我们可以更改 use()
来传递 Z
,但现在 use()
的调用者可能需要修改。这很快就会变得难以管理。或者,我们可以添加一个 try
-catch
到 use()
中,将 Z
映射到一个可接受的异常。这同样很快就会变得难以管理。请注意,异常集合的变化通常发生在系统的最低级别(例如,由于网络库或某些中间件的变化),因此变化会“冒泡”通过长调用链。在一个大型代码库中,这可能意味着没有人能够更新到新版本的库,直到最后一个用户被修改。如果 use()
是一个库的一部分,可能无法更新它,因为更改可能会影响未知的客户端。
让异常传播直到它们到达一个可能可以处理它们的函数这一策略已经经过了多年的验证。
否。如果异常规范是静态强制执行的,情况也不会好多少。例如,请参阅 Stroustrup94。
如果不能抛出异常,请使用 noexcept
。
标记每一个异常规范。
catch
子句catch
子句按出现的顺序进行评估,一个子句可以隐藏另一个子句。
void f()
{
// ...
try {
// ...
}
catch (Base& b) { /* ... */ }
catch (Derived& d) { /* ... */ }
catch (...) { /* ... */ }
catch (std::exception& e) { /* ... */ }
}
如果 Derived
派生自 Base
,则 Derived
处理程序将永远不会被调用。“捕获所有”处理程序确保 std::exception
处理程序永远不会被调用。
标记所有“隐藏处理程序”。
你无法对常量进行竞态条件。当许多对象的值不能改变时,更容易推理程序。承诺“不改变”传递给参数的对象的接口极大地提高了可读性。
常量规则总结
const
const
s 的指针和引用const
定义构造后值不变的对象constexpr
定义可以计算的值不可变对象更容易推理,因此仅当有需要更改其值时,才使对象非 const
。防止意外或难以察觉的值更改。
for (const int i : c) cout << i << '\n'; // just reading: const
for (int i : c) cout << i << '\n'; // BAD: just reading
按值返回且比复制更便宜的局部变量不应声明为 const
,因为它可能会强制进行不必要的复制。
std::vector<int> f(int i)
{
std::vector<int> v{ i, i, i }; // const not needed
return v;
}
按值传递的函数参数很少被修改,但也很少被声明为 const
。为避免混淆和大量误报,请勿对函数参数强制执行此规则。
void g(const int i) { ... } // pedantic
请注意,函数参数是局部变量,因此对其的更改是局部的。
const
变量(除参数以避免大量误报和返回的局部变量外)。const
成员函数应标记为 const
,除非它更改了对象的可观察状态。这提供了更精确的设计意图陈述、更好的可读性、编译器捕获的更多错误以及有时更多的优化机会。
class Point {
int x, y;
public:
int getx() { return x; } // BAD, should be const as it doesn't modify the object's state
// ...
};
void f(const Point& pt)
{
int x = pt.getx(); // ERROR, doesn't compile because getx was not marked const
}
传递指向非 const
的指针或引用本身并非坏事,但仅应在被调用函数旨在修改对象时才这样做。代码的阅读者必须假定一个采用“纯粹” T*
或 T&
的函数将修改所引用的对象。如果现在不修改,则将来可能会在不强制重新编译的情况下进行修改。
有一些代码/库提供了函数,它们声明了 T*
,即使这些函数不修改该 T
。这对于正在现代化代码的人来说是一个问题。你可以
const
正确性;首选的长期解决方案const
”;最好避免示例
void f(int* p); // old code: f() does not modify `*p`
void f(const int* p) { f(const_cast<int*>(p)); } // wrapper
请注意,此包装器解决方案是一种修补程序,仅应在无法修改 f()
的声明时使用,例如因为它位于您无法修改的库中。
一个 const
成员函数可以修改 mutable
对象的值或通过指针成员访问的对象的值。一个常见的用途是维护一个缓存,而不是反复进行复杂的计算。例如,这里有一个 Date
对象,它缓存(记忆化)其字符串表示形式,以简化重复使用。
class Date {
public:
// ...
const string& string_ref() const
{
if (string_val == "") compute_string_rep();
return string_val;
}
// ...
private:
void compute_string_rep() const; // compute string representation and place it in string_val
mutable string string_val;
// ...
};
换句话说,const
性不是传递性的。一个 const
成员函数可能更改 mutable
成员的值以及通过非 const
指针访问的对象的值。类的工作是确保此类变异仅在符合其向用户提供的语义(不变量)时才发生。
另请参阅:Pimpl
const
但未对任何数据成员执行非 const
操作的成员函数。const
s 的指针和引用以避免被调用的函数意外更改值。当被调用的函数不修改状态时,更容易推理程序。
void f(char* p); // does f modify *p? (assume it does)
void g(const char* p); // g does not modify *p
传递指向非 const
的指针或引用本身并非坏事,但仅应在被调用函数旨在修改对象时才这样做。
const
的指针或引用传递的对象const
的指针或引用传递的对象const
定义构造后值不变的对象防止意外更改对象值带来的惊喜。
void f()
{
int x = 7;
const int y = 9;
for (;;) {
// ...
}
// ...
}
由于 x
不是 const
,我们必须假设它在循环中的某个地方被修改了。
const
变量。constexpr
定义可以计算的值更好的性能,更好的编译时检查,保证编译时求值,没有竞态条件的可能性。
double x = f(2); // possible run-time evaluation
const double y = f(2); // possible run-time evaluation
constexpr double z = f(2); // error unless f(2) can be evaluated at compile time
参见 F.4。
const
定义。泛型编程是使用由类型、值和算法参数化的类型和算法进行编程。在 C++ 中,泛型编程通过 template
语言机制得到支持。
泛型函数的参数由对涉及的参数类型和值的一组要求来表征。在 C++ 中,这些要求通过称为概念的编译时谓词来表示。
模板也可用于元编程;即,在编译时组合代码的程序。
通用编程的一个核心概念是“概念”;也就是说,将模板参数的约束以编译时谓词的形式呈现。“概念”在 C++20 中已标准化,尽管它们最早在 GCC 6.1 中以稍旧的语法提供。
模板使用规则摘要
概念使用规则摘要
概念定义规则摘要
!C<T>
)来表达细微差别C1<T> || C2<T>
)来表达替代方案模板接口规则摘要
using
而非 typedef
enable_if
模拟它们模板定义规则摘要
{}
而非 ()
以避免歧义模板与继承规则摘要
可变参数模板规则摘要
元编程规则摘要
constexpr
函数在编译时计算值其他模板规则摘要
static_assert
检查类是否符合概念通用编程是使用由类型、值和算法参数化的类型和算法进行编程。
通用性。复用性。效率。鼓励用户类型的统一定义。
概念上,以下要求是错误的,因为我们对 T
的要求不仅仅是“可递增”或“可相加”这些非常低级别的概念
template<typename T>
requires Incrementable<T>
T sum1(vector<T>& v, T s)
{
for (auto x : v) s += x;
return s;
}
template<typename T>
requires Simple_number<T>
T sum2(vector<T>& v, T s)
{
for (auto x : v) s = s + x;
return s;
}
假设 Incrementable
不支持 +
而 Simple_number
不支持 +=
,我们就会过度限制 sum1
和 sum2
的实现者。在这种情况下,我们还错失了泛化的机会。
template<typename T>
requires Arithmetic<T>
T sum(vector<T>& v, T s)
{
for (auto x : v) s += x;
return s;
}
假设 Arithmetic
需要 +
和 +=
,我们就限制了 sum
的用户提供一个完整的算术类型。这不是一个最小化的要求,但它为算法的实现者提供了急需的自由,并确保任何 Arithmetic
类型都可以用于各种各样的算法。
为了进一步的通用性和可重用性,我们也可以使用更通用的 Container
或 Range
概念,而不是只局限于一个容器 vector
。
如果我们定义一个模板,要求它只满足单个算法单个实现所需的全部操作(例如,只要求 +=
而不是 =
和 +
),并且只要求这些,那么我们就过度限制了维护者。我们的目标是最小化对模板参数的要求,但实现的绝对最小化要求很少是一个有意义的概念。
模板可以用于表达几乎所有内容(它们是图灵完备的),但通用编程(使用模板表达)的目的是在具有相似语义属性的类型集合上有效地泛化操作/算法。
通用性。最小化源代码。互操作性。复用性。
这是 STL 的基础。单个 find
算法可以轻松地与任何类型的输入范围一起工作
template<typename Iter, typename Val>
// requires Input_iterator<Iter>
// && Equality_comparable<Value_type<Iter>, Val>
Iter find(Iter b, Iter e, Val v)
{
// ...
}
除非您有实际需要超过一个模板参数类型,否则不要使用模板。不要过度抽象。
??? 很难,可能需要人工 ???
容器需要元素类型,将其表达为模板参数是通用的、可重用的且类型安全的。它还可以避免脆弱或低效的变通方法。惯例:STL 就是这样做的。
template<typename T>
// requires Regular<T>
class Vector {
// ...
T* elem; // points to sz Ts
int sz;
};
Vector<double> v(10);
v[7] = 9.9;
class Container {
// ...
void* elem; // points to size elements of some type
int sz;
};
Container c(10, sizeof(double));
((double*) c.elem)[7] = 9.9;
这不能直接表达程序员的意图,并且会隐藏程序的结构,不被类型系统和优化器所知。
通过宏隐藏 void*
只是掩盖了问题,并带来了新的困惑。
例外:如果您需要 ABI 稳定的接口,您可能必须提供一个基础实现,并在此基础上表达(类型安全的)模板。请参见 稳定基类。
void*
s 和转换???
???
异常: ???
通用和面向对象技术是互补的。
静态帮助动态:使用静态多态来实现动态多态接口。
class Command {
// pure virtual functions
};
// implementations
template</*...*/>
class ConcreteCommand : public Command {
// implement virtuals
};
动态帮助静态:提供一个通用的、舒适的、静态绑定的接口,但内部进行动态分派,从而提供统一的对象布局。例如,类型擦除,如 std::shared_ptr
的删除器(但 不要过度使用类型擦除)。
#include <memory>
class Object {
public:
template<typename T>
Object(T&& obj)
: concept_(std::make_shared<ConcreteCommand<T>>(std::forward<T>(obj))) {}
int get_id() const { return concept_->get_id(); }
private:
struct Command {
virtual ~Command() {}
virtual int get_id() const = 0;
};
template<typename T>
struct ConcreteCommand final : Command {
ConcreteCommand(T&& obj) noexcept : object_(std::forward<T>(obj)) {}
int get_id() const final { return object_.get_id(); }
private:
T object_;
};
std::shared_ptr<Command> concept_;
};
class Bar {
public:
int get_id() const { return 1; }
};
struct Foo {
public:
int get_id() const { return 2; }
};
Object o(Bar{});
Object o2(Foo{});
在类模板中,非虚函数仅在被使用时才会被实例化——但虚函数每次都会被实例化。这会膨胀代码大小,并且可能通过实例化不需要的功能而过度限制通用类型。避免这种情况,尽管标准库的 facet 犯了这个错误。
请参阅有关更具体规则的参考。
Concepts 是 C++20 中用于指定模板参数要求的工具。它们在思考通用编程方面至关重要,并且是未来 C++ 库(标准库和其他)许多工作的基础。
本节假定支持概念
概念使用规则摘要
概念定义规则摘要
正确性和可读性。模板参数的假定含义(语法和语义)是模板接口的基础。概念极大地改善了模板的文档和错误处理。为模板参数指定概念是一种强大的设计工具。
template<typename Iter, typename Val>
requires input_iterator<Iter>
&& equality_comparable_with<iter_value_t<Iter>, Val>
Iter find(Iter b, Iter e, Val v)
{
// ...
}
或者等效且更简洁地说
template<input_iterator Iter, typename Val>
requires equality_comparable_with<iter_value_t<Iter>, Val>
Iter find(Iter b, Iter e, Val v)
{
// ...
}
普通 typename
(或 auto
)是最弱的概念。它应该只在无法假定比“它是一个类型”更多信息时才使用。这通常只在(作为模板元编程代码的一部分)我们操作纯表达式树,推迟类型检查时才需要。
参考文献:TC++PL4
标记没有概念的模板类型参数
“标准”概念(由 GSL 和 ISO 标准本身提供)为我们节省了思考自己的概念的工作,比我们仓促完成的要考虑周全,并提高了互操作性。
除非您正在创建一个新的通用库,否则您需要的大部分概念已经由标准库定义。
template<typename T>
// don't define this: sortable is in <iterator>
concept Ordered_container = Sequence<T> && Random_access<Iterator<T>> && Ordered<Value_type<T>>;
void sort(Ordered_container auto& s);
这个 Ordered_container
是很有可能的,但它与标准库中的 sortable
概念非常相似。它更好吗?它正确吗?它准确地反映了标准对 sort
的要求吗?使用 sortable
更简单
void sort(sortable auto& s); // better
随着我们接近包含概念的 ISO 标准,一组“标准”概念正在不断发展。
设计一个有用的概念是具有挑战性的。
很难。
auto
auto
是最弱的概念。概念名称比 auto
传达了更多的含义。
vector<string> v{ "abc", "xyz" };
auto& x = v.front(); // bad
String auto& s = v.front(); // good (String is a GSL concept)
可读性。直接表达思想。
要说明“T
是 sortable
”
template<typename T> // Correct but verbose: "The parameter is
requires sortable<T> // of type T which is the name of a type
void sort(T&); // that is sortable"
template<sortable T> // Better: "The parameter is of type T
void sort(T&); // which is Sortable"
void sort(sortable auto&); // Best: "The parameter is Sortable"
较短的版本更符合我们的说话方式。请注意,许多模板不需要使用 template
关键字。
<typename T>
和 <class T
> 表示法转换。定义好的概念并非易事。概念旨在代表应用程序域中的基本概念(因此得名“概念”)。同样,将一组语法约束组合起来用于单个类或算法的参数并不是概念设计的初衷,也无法获得该机制的全部好处。
显然,定义概念对于可以使用实现的代码(例如 C++20 或更高版本)最有用,但定义概念本身就是一种有用的设计技术,有助于捕捉概念错误并清理实现的(sic!)概念。
概念旨在表达语义概念,例如“一个数字”、“一个元素范围”和“全序”。简单的约束,例如“有一个 +
运算符”和“有一个 >
运算符”不能孤立地有意义地指定,并且应该仅作为有意义概念的构建块使用,而不是在用户代码中使用。
template<typename T>
// bad; insufficient
concept Addable = requires(T a, T b) { a + b; };
template<Addable N>
auto algo(const N& a, const N& b) // use two numbers
{
// ...
return a + b;
}
int x = 7;
int y = 9;
auto z = algo(x, y); // z = 16
string xx = "7";
string yy = "9";
auto zz = algo(xx, yy); // zz = "79"
也许串联是预期的。更有可能的是,这是一个意外。同等地定义减法会产生截然不同的接受类型集。这个 Addable
违反了加法应该是可交换的数学规则:a+b == b+a
。
指定有意义语义的能力是真正概念与简单语法约束的区别特征。
template<typename T>
// The operators +, -, *, and / for a number are assumed to follow the usual mathematical rules
concept Number = requires(T a, T b) { a + b; a - b; a * b; a / b; };
template<Number N>
auto algo(const N& a, const N& b)
{
// ...
return a + b;
}
int x = 7;
int y = 9;
auto z = algo(x, y); // z = 16
string xx = "7";
string yy = "9";
auto zz = algo(xx, yy); // error: string is not a Number
包含多个操作的概念意外匹配类型的机会要比仅包含一个操作的概念少得多。
concepts
定义之外使用的单操作 concepts
。enable_if
来模拟单操作 concepts
。易于理解。提高互操作性。帮助实现者和维护者。
这是通用规则 概念必须有语义意义 的一个特定变体。
template<typename T> concept Subtractable = requires(T a, T b) { a - b; };
这没有语义意义。您至少需要 +
才能使 -
有意义且有用。
完整的操作集示例如下:
Arithmetic
:+
、-
、*
、/
、+=
、-=
、*=
、/=
Comparable
:<
、>
、<=
、>=
、==
、!=
此规则适用于我们是否使用直接语言支持概念。这是一个通用设计规则,甚至适用于非模板
class Minimal {
// ...
};
bool operator==(const Minimal&, const Minimal&);
bool operator<(const Minimal&, const Minimal&);
Minimal operator+(const Minimal&, const Minimal&);
// no other operators
void f(const Minimal& x, const Minimal& y)
{
if (!(x == y)) { /* ... */ } // OK
if (x != y) { /* ... */ } // surprise! error
while (!(x < y)) { /* ... */ } // OK
while (x >= y) { /* ... */ } // surprise! error
x = x + y; // OK
x += y; // surprise! error
}
这是最小化的,但对于用户来说是令人惊讶和受限的。它甚至可能效率更低。
该规则支持“概念应反映一套(数学上)连贯的操作”的观点。
class Convenient {
// ...
};
bool operator==(const Convenient&, const Convenient&);
bool operator<(const Convenient&, const Convenient&);
// ... and the other comparison operators ...
Convenient operator+(const Convenient&, const Convenient&);
// ... and the other arithmetic operators ...
void f(const Convenient& x, const Convenient& y)
{
if (!(x == y)) { /* ... */ } // OK
if (x != y) { /* ... */ } // OK
while (!(x < y)) { /* ... */ } // OK
while (x >= y) { /* ... */ } // OK
x = x + y; // OK
x += y; // OK
}
定义所有运算符可能有点麻烦,但并不困难。理想情况下,该规则应该通过默认提供比较运算符来得到语言支持。
==
但不是 !=
,或者 +
但不是 -
。是的,std::string
是“奇怪”的,但为时已晚,无法改变。有意义/有用的概念具有语义含义。以非正式、半正式或正式的方式表达这些语义使概念对读者来说更易于理解,并且表达它的努力可以捕捉概念错误。指定语义是一种强大的设计工具。
template<typename T>
// The operators +, -, *, and / for a number are assumed to follow the usual mathematical rules
// axiom(T a, T b) { a + b == b + a; a - a == 0; a * (b + c) == a * b + a * c; /*...*/ }
concept Number = requires(T a, T b) {
{ a + b } -> convertible_to<T>;
{ a - b } -> convertible_to<T>;
{ a * b } -> convertible_to<T>;
{ a / b } -> convertible_to<T>;
};
这是数学意义上的公理:无需证明即可假定的东西。通常,公理是不可证明的,即使可以证明,证明通常也超出了编译器的能力。公理可能不通用,但模板编写者可以假定它对所有实际使用的输入都成立(类似于前置条件)。
在此上下文中,公理是布尔表达式。有关示例,请参阅 Palo Alto TR。目前,C++ 不支持公理(甚至 ISO Concepts TS),因此我们很长一段时间以来都不得不依赖注释。一旦有了语言支持,公理前面的 //
就可以被删除
GSL 概念具有明确定义的语义;请参阅 Palo Alto TR 和 Ranges TS。
新“概念”的早期版本仍在开发中,通常只定义简单的约束集,而没有明确定义的语义。寻找好的语义可能需要时间和精力。一套不完整的约束仍然可能非常有用
// balancer for a generic binary tree
template<typename Node> concept Balancer = requires(Node* p) {
add_fixup(p);
touch(p);
detach(p);
};
因此,一个 Balancer
必须至少提供这些对树 Node
的操作,但我们尚未准备好指定详细的语义,因为一种新的平衡树可能需要更多的操作,并且所有节点的精确通用语义在设计的早期阶段很难确定。
一个不完整或没有明确定义的语义的“概念”仍然可能很有用。例如,它允许在初步实验期间进行一些检查。但是,不应假定它是稳定的。每个新的用例都可能需要改进这种不完整的概念。
否则编译器无法自动区分它们。
template<typename I>
// Note: input_iterator is defined in <iterator>
concept Input_iter = requires(I iter) { ++iter; };
template<typename I>
// Note: forward_iterator is defined in <iterator>
concept Fwd_iter = Input_iter<I> && requires(I iter) { iter++; };
编译器可以根据所需操作的集合(此处为后缀 ++
)来确定细化。这减轻了这些类型的实现者的负担,因为他们不需要任何特殊的声明来“挂入概念”。如果两个概念具有完全相同的要求,那么它们在逻辑上是等价的(没有细化)。
要求相同语法但具有不同语义的两个概念会导致歧义,除非程序员区分它们。
template<typename I> // iterator providing random access
// Note: random_access_iterator is defined in <iterator>
concept RA_iter = ...;
template<typename I> // iterator providing random access to contiguous data
// Note: contiguous_iterator is defined in <iterator>
concept Contiguous_iter =
RA_iter<I> && is_contiguous_v<I>; // using is_contiguous trait
程序员(在库中)必须适当地定义 is_contiguous
(一个特质)。
将标签类包装到概念中可以更简洁地表达这个想法
template<typename I> concept Contiguous = is_contiguous_v<I>;
template<typename I>
concept Contiguous_iter = RA_iter<I> && Contiguous<I>;
程序员(在库中)必须适当地定义 is_contiguous
(一个特质)。
特质可以是特质类或类型特质。这些可以是用户定义的,也可以是标准库的。优先使用标准库的。
清晰度。可维护性。使用否定表达互补要求的函数是脆弱的。
最初,人们会尝试定义具有互补要求的函数
template<typename T>
requires !C<T> // bad
void f();
template<typename T>
requires C<T>
void f();
这样更好
template<typename T> // general template
void f();
template<typename T> // specialization by concept
requires C<T>
void f();
仅当 C<T>
不满足时,编译器才会选择未约束的模板。如果您不想(或不能)定义未约束版本的 f()
,那么将其删除。
template<typename T>
void f() = delete;
编译器将选择重载,或发出适当的错误。
互补约束在 enable_if
代码中不幸常见
template<typename T>
enable_if<!C<T>, void> // bad
f();
template<typename T>
enable_if<C<T>, void>
f();
一个要求上的互补要求有时(错误地)被认为是可管理的。但是,对于两个或更多要求,所需的定义数量可能呈指数级增长(2,4,8,16,…)
C1<T> && C2<T>
!C1<T> && C2<T>
C1<T> && !C2<T>
!C1<T> && !C2<T>
现在错误的几率呈指数级增长。
C<T>
和 !C<T>
约束的函数对定义更具可读性,并且直接对应于用户必须编写的内容。会考虑转换。您不必记住所有类型特质的名称。
您可能会想这样定义一个 Equality
概念
template<typename T> concept Equality = has_equal<T> && has_not_equal<T>;
显然,使用标准的 equality_comparable
会更好、更容易,但-举个例子-如果您必须定义这样的概念,请优先使用
template<typename T> concept Equality = requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
// axiom { !(a == b) == (a != b) }
// axiom { a = b; => a == b } // => means "implies"
};
而不是定义两个无意义的概念 has_equal
和 has_not_equal
,仅仅作为 Equality
定义中的辅助。我们称之为“无意义”,是因为我们无法孤立地指定 has_equal
的语义。
???
多年来,使用模板进行编程一直存在模板接口与其实现之间区分薄弱的问题。在概念出现之前,这种区分没有直接的语言支持。然而,模板的接口是一个关键概念——用户和实现者之间的合同——并且应该经过仔细设计。
函数对象可以通过接口传递比“普通”函数指针更多的信息。通常,传递函数对象比传递函数指针具有更好的性能。
bool greater(double x, double y) { return x > y; }
sort(v, greater); // pointer to function: potentially slow
sort(v, [](double x, double y) { return x > y; }); // function object
sort(v, std::greater{}); // function object
bool greater_than_7(double x) { return x > 7; }
auto x = find_if(v, greater_than_7); // pointer to function: inflexible
auto y = find_if(v, [](double x) { return x > 7; }); // function object: carries the needed data
auto z = find_if(v, Greater_than<double>(7)); // function object: carries the needed data
当然,您可以使用 auto
或概念来泛化这些函数。例如
auto y1 = find_if(v, [](totally_ordered auto x) { return x > 7; }); // require an ordered type
auto z1 = find_if(v, [](auto x) { return x > 7; }); // hope that the type has a >
Lambda 函数会生成函数对象。
性能参数取决于编译器和优化器技术。
保持接口简单稳定。
考虑一下,一个 sort
仪器化了(过度简化的)简单的调试支持
void sort(sortable auto& s) // sort sequence s
{
if (debug) cerr << "enter sort( " << s << ")\n";
// ...
if (debug) cerr << "exit sort( " << s << ")\n";
}
这是否应该重写为
template<sortable S>
requires Streamable<S>
void sort(S& s) // sort sequence s
{
if (debug) cerr << "enter sort( " << s << ")\n";
// ...
if (debug) cerr << "exit sort( " << s << ")\n";
}
毕竟,sortable
中没有任何东西需要 iostream
支持。另一方面,排序的基本思想中也没有关于调试的任何内容。
如果我们要求使用的每个操作都列在要求中,那么接口将不稳定:每当我们更改调试设施、使用数据收集、测试支持、错误报告等时,模板的定义都需要更改,并且模板的每次使用都必须重新编译。这很麻烦,在某些环境中不可行。
相反,如果我们使用概念检查未保证的属性的模板参数,我们可能会在后期收到编译时错误。
通过不对模板参数中不被视为必需的属性进行概念检查,我们将检查推迟到实例化时间。我们认为这是一个值得的权衡。
请注意,使用非局部、非依赖名称(如 debug
和 cerr
)也会引入上下文依赖,这可能导致“神秘”错误。
决定类型的哪些属性是必需的,哪些不是,这可能很困难。
???
改进可读性。实现隐藏。请注意,模板别名取代了许多用于计算类型的 trait。它们也可以用于包装 trait。
template<typename T, size_t N>
class Matrix {
// ...
using Iterator = typename std::vector<T>::iterator;
// ...
};
这使 Matrix
的用户不必知道其元素存储在 vector
中,也使他们不必重复输入 typename std::vector<T>::
。
template<typename T>
void user(T& c)
{
// ...
typename container_traits<T>::value_type x; // bad, verbose
// ...
}
template<typename T>
using Value_type = typename container_traits<T>::value_type;
这使得 Value_type
的用户不必知道实现 value_type
s 的技术。
template<typename T>
void user2(T& c)
{
// ...
Value_type<T> x;
// ...
}
一个简单、常见的用法可以表示为:“包装 trait!”
typename
作为 using
声明之外的歧义符的使用。using
而非 typedef
改进可读性:使用 using
时,新名称在前而不是嵌入在声明的某个位置。通用性:using
可用于模板别名,而 typedef
很难成为模板。统一性:using
在语法上与 auto
相似。
typedef int (*PFI)(int); // OK, but convoluted
using PFI2 = int (*)(int); // OK, preferred
template<typename T>
typedef int (*PFT)(T); // error
template<typename T>
using PFT2 = int (*)(T); // OK
typedef
的使用。这将产生大量“命中”:-(显式编写模板参数类型可能很繁琐且不必要地冗长。
tuple<int, string, double> t1 = {1, "Hamlet", 3.14}; // explicit type
auto t2 = make_tuple(1, "Ophelia"s, 3.14); // better; deduced type
注意 s
后缀的使用,以确保字符串是 std::string
,而不是 C 风格字符串。
由于您可以轻松编写 make_T
函数,编译器也可以。因此,make_T
函数将来可能会变得多余。
有时没有好的方法来推导模板参数,有时您希望显式指定参数
vector<double> v = { 1, 2, 3, 7.9, 15.99 };
list<Record*> lst;
请注意,C++17 将通过允许直接从构造函数参数推导模板参数来使此规则变得多余:构造函数模板参数推导(Rev. 3)。例如
tuple t1 = {1, "Hamlet"s, 3.14}; // deduced: tuple<int, string, double>
标记其中显式特化类型完全匹配所用参数类型的用法。
可读性。防止意外和错误。大多数用法反正都支持这一点。
class X {
public:
explicit X(int);
X(const X&); // copy
X operator=(const X&);
X(X&&) noexcept; // move
X& operator=(X&&) noexcept;
~X();
// ... no more constructors ...
};
X x {1}; // fine
X y = x; // fine
std::vector<X> v(10); // error: no default constructor
半正则要求默认可构造。
一个未受约束的模板参数是任何事物的完美匹配,因此这样的模板可能比需要次要转换的更具体的类型更受青睐。这尤其恼人/危险,因为 ADL 被使用。常见名称使此问题更有可能发生。
namespace Bad {
struct S { int m; };
template<typename T1, typename T2>
bool operator==(T1, T2) { cout << "Bad\n"; return true; }
}
namespace T0 {
bool operator==(int, Bad::S) { cout << "T0\n"; return true; } // compare to int
void test()
{
Bad::S bad{ 1 };
vector<int> v(10);
bool b = 1 == bad;
bool b2 = v.size() == bad;
}
}
这会打印 T0
和 Bad
。
现在 Bad
中的 ==
被设计用来引起麻烦,但在实际代码中您会发现问题吗?问题在于 v.size()
返回一个 unsigned
整数,因此需要转换才能调用本地的 ==
;Bad
中的 ==
不需要任何转换。像标准库迭代器这样的实际类型可以表现出类似的“反社会”倾向。
如果一个未受约束的模板与一个类型定义在同一个命名空间中,那么该未受约束的模板可以通过 ADL 找到(正如示例中发生的那样)。也就是说,它是高度可见的。
这个规则应该是没有必要的,但委员会无法同意将未受约束的模板排除在 ADL 之外。
不幸的是,这将产生许多误报;标准库广泛违反了这一点,将许多未受约束的模板和类型放入唯一的 std
命名空间。
标记在也定义了具体类型的命名空间中定义的模板(可能在有概念之前不可行)。
enable_if
模拟它们因为这是我们在没有直接概念支持的情况下能做到的最好的。 enable_if
可用于有条件地定义函数以及在一组函数中进行选择。
template<typename T>
enable_if_t<is_integral_v<T>>
f(T v)
{
// ...
}
// Equivalent to:
template<Integral T>
void f(T v)
{
// ...
}
注意 互补约束。使用 enable_if
模拟概念重载有时迫使我们使用这种容易出错的设计技术。
???
类型擦除通过将类型信息隐藏在单独的编译边界后面,引入了额外的间接层。
???
例外:类型擦除有时是合适的,例如对于 std::function
。
???
模板定义(类或函数)可以包含任意代码,因此只有对 C++ 编程技术的全面审查才能涵盖此主题。但是,本节侧重于模板实现特有的内容。特别是,它侧重于模板定义对其上下文的依赖性。
易于理解。最小化意外依赖引起的错误。便于工具创建。
template<typename C>
void sort(C& c)
{
std::sort(begin(c), end(c)); // necessary and useful dependency
}
template<typename Iter>
Iter algo(Iter first, Iter last)
{
for (; first != last; ++first) {
auto x = sqrt(*first); // potentially surprising dependency: which sqrt()?
helper(first, x); // potentially surprising dependency:
// helper is chosen based on first and x
TT var = 7; // potentially surprising dependency: which TT?
}
}
模板通常出现在头文件中,因此它们的上下文依赖比 .cpp 文件中的函数更容易受到 #include 顺序依赖的影响。
让模板仅操作其参数是减少依赖项数量到最小的一种方法,但这通常是难以管理的。例如,算法通常使用其他算法并调用不完全操作参数的操作。更不用说宏了!
另请参见:T.69
??? 棘手
不依赖于模板参数的成员,除非指定特定的模板参数,否则无法使用。这限制了使用,通常会增加代码大小。
template<typename T, typename A = std::allocator<T>>
// requires Regular<T> && Allocator<A>
class List {
public:
struct Link { // does not depend on A
T elem;
Link* pre;
Link* suc;
};
using iterator = Link*;
iterator first() const { return head; }
// ...
private:
Link* head;
};
List<int> lst1;
List<int, My_allocator> lst2;
这看起来足够无害,但现在 Link 形式上依赖于分配器(即使它不使用分配器)。这会强制进行冗余实例化,在某些实际场景中可能成本惊人。通常,解决方案是将本应是嵌套类的类设为非局部类,并拥有其自己的最小模板参数集。
template<typename T>
struct Link {
T elem;
Link* pre;
Link* suc;
};
template<typename T, typename A = std::allocator<T>>
// requires Regular<T> && Allocator<A>
class List2 {
public:
using iterator = Link<T>*;
iterator first() const { return head; }
// ...
private:
Link<T>* head;
};
List2<int> lst1;
List2<int, My_allocator> lst2;
有些人发现 List 中 Link 不再隐藏起来的想法很吓人,所以我们将这种技术命名为SCARY。来自那篇学术论文:“SCARY的首字母缩写描述了赋值和初始化,它们看似错误(似乎受到冲突的通用参数的约束),但实际上具有正确的实现(由于最小化依赖性而未受冲突约束)。”
这也适用于不依赖于所有模板参数的 lambda。
允许在不指定模板参数且不进行模板实例化的情况下使用基类成员。
template<typename T>
class Foo {
public:
enum { v1, v2 };
// ...
};
???
struct Foo_base {
enum { v1, v2 };
// ...
};
template<typename T>
class Foo : public Foo_base {
public:
// ...
};
此规则的更通用版本是“如果类模板成员仅依赖于 M 个模板参数中的 N 个,则将其放入具有 N 个参数的基类中。” 对于 N == 1,我们可以选择周围作用域中的类作为基类,如T.61 所示。
??? 常量呢?类静态成员呢?
模板定义了一个通用接口。特化提供了一种强大的机制来提供该接口的替代实现。
??? string specialization (==)
??? representation specialization ?
???
???
这是 std::copy 的简化版本(忽略非连续序列的可能性)
struct trivially_copyable_tag {};
struct non_trivially_copyable_tag {};
// T is not trivially copyable
template<class T> struct copy_trait { using tag = non_trivially_copyable_tag; };
// int is trivially copyable
template<> struct copy_trait<int> { using tag = trivially_copyable_tag; };
template<class Iter>
Out copy_helper(Iter first, Iter last, Iter out, trivially_copyable_tag)
{
// use memmove
}
template<class Iter>
Out copy_helper(Iter first, Iter last, Iter out, non_trivially_copyable_tag)
{
// use loop calling copy constructors
}
template<class Iter>
Out copy(Iter first, Iter last, Iter out)
{
using tag_type = typename copy_trait<std::iter_value_t<Iter>>;
return copy_helper(first, last, out, tag_type{})
}
void use(vector<int>& vi, vector<int>& vi2, vector<string>& vs, vector<string>& vs2)
{
copy(vi.begin(), vi.end(), vi2.begin()); // uses memmove
copy(vs.begin(), vs.end(), vs2.begin()); // uses a loop calling copy constructors
}
这是编译时算法选择的一种通用且强大的技术。
使用 C++20 的 constraints,可以区分这些替代方案
template<class Iter>
requires std::is_trivially_copyable_v<std::iter_value_t<Iter>>
Out copy_helper(In, first, In last, Out out)
{
// use memmove
}
template<class Iter>
Out copy_helper(In, first, In last, Out out)
{
// use loop calling copy constructors
}
???
???
???
???
() 容易出现语法歧义。
template<typename T, typename U>
void f(T t, U u)
{
T v1(T(u)); // mistake: oops, v1 is a function, not a variable
T v2{u}; // clear: obviously a variable
auto x = T(u); // unclear: construction or cast?
}
f(1, "asdf"); // bad: cast from const char* to int
有三种主要方法可以让调用代码自定义模板。
template<class T>
// Call a member function
void test1(T t)
{
t.f(); // require T to provide f()
}
template<class T>
void test2(T t)
// Call a non-member function without qualification
{
f(t); // require f(/*T*/) be available in caller's scope or in T's namespace
}
template<class T>
void test3(T t)
// Invoke a "trait"
{
test_traits<T>::f(t); // require customizing test_traits<>
// to get non-default functions/types
}
通常,trait 是用于计算类型的类型别名、用于计算值的 constexpr 函数,或者传统的可被用户类型特化的 traits 模板。
如果你打算调用自己的 helper 函数 helper(t),其中 t 是依赖于模板类型参数的值,请将其放在 ::detail 命名空间中,并像 detail::helper(t); 一样限定调用。未限定的调用将成为一个自定义点,其中可以调用 t 的类型的命名空间中的任何函数 helper;这可能导致问题,例如意外调用未约束的函数模板。
模板是 C++ 支持泛型编程的基石,而类层次结构是其支持面向对象编程的基石。这两种语言机制可以有效地结合使用,但必须避免一些设计陷阱。
模板化具有许多函数,尤其是许多虚函数的类层次结构,可能导致代码膨胀。
template<typename T>
struct Container { // an interface
virtual T* get(int i);
virtual T* first();
virtual T* next();
virtual void sort();
};
template<typename T>
class Vector : public Container<T> {
public:
// ...
};
Vector<int> vi;
Vector<string> vs;
将 sort 定义为容器的成员函数可能是一个糟糕的主意,但并非闻所未闻,它是一个很好的反面教材。
鉴于此,编译器无法知道是否调用了 vector<int>::sort(),因此它必须生成其代码。vector<string>::sort() 也是如此。除非调用了这两个函数,否则那就是代码膨胀。想象一下,这对具有数十个成员函数和数十个派生类的类层次结构以及许多实例会产生什么影响。
在许多情况下,您可以通过不参数化基类来提供稳定的接口;请参见“稳定的基类”和OO 和 GP。
派生类数组可以隐式“衰减”为基类指针,可能导致灾难性后果。
假设 Apple 和 Pear 是两种 Fruit。
void maul(Fruit* p)
{
*p = Pear{}; // put a Pear into *p
p[1] = Pear{}; // put a Pear into p[1]
}
Apple aa [] = { an_apple, another_apple }; // aa contains Apples (obviously!)
maul(aa);
Apple& a0 = &aa[0]; // a Pear?
Apple& a1 = &aa[1]; // a Pear?
可能,aa[0] 将是一个 Pear(无需强制转换!)。如果 sizeof(Apple) != sizeof(Pear),则访问 aa[1] 将与数组中对象的正确起始位置不对齐。我们遇到了类型违规,并且可能(很可能)发生内存损坏。切勿编写此类代码。
请注意,maul() 违反了 T*
指向单个对象的规则。
替代方案:使用适当的(模板化)容器
void maul2(Fruit* p)
{
*p = Pear{}; // put a Pear into *p
}
vector<Apple> va = { an_apple, another_apple }; // va contains Apples (obviously!)
maul2(va); // error: cannot convert a vector<Apple> to a Fruit*
maul2(&va[0]); // you asked for it
Apple& a0 = &va[0]; // a Pear?
请注意,maul2() 中的赋值违反了切片规则。
???
???
???
C++ 不支持这一点。如果支持,vtbl 要到链接时才能生成。并且通常,实现必须处理动态链接。
class Shape {
// ...
template<class T>
virtual bool intersect(T* p); // error: template cannot be virtual
};
我们需要一个规则,因为人们一直在询问此事。
双重分派、访问者、计算调用哪个函数
编译器会处理这个问题。
提高代码稳定性。避免代码膨胀。
它可以是基类
struct Link_base { // stable
Link_base* suc;
Link_base* pre;
};
template<typename T> // templated wrapper to add type safety
struct Link : Link_base {
T val;
};
struct List_base {
Link_base* first; // first element (if any)
int sz; // number of elements
void add_front(Link_base* p);
// ...
};
template<typename T>
class List : List_base {
public:
void put_front(const T& e) { add_front(new Link<T>{e}); } // implicit cast to Link_base
T& front() { return static_cast<Link<T>*>(first)->val; } // explicit cast back to Link<T>
// ...
};
List<int> li;
List<string> ls;
现在,链接和解除链接 List 元素的这些操作只有一个副本。Link 和 List 类除了类型操作外,什么也不做。
除了使用单独的“基”类型外,另一种常见技术是为 void 或 void* 进行特化,并让 T 的通用模板只是安全地封装对 void 实现的转换和从 void 实现的转换。
替代方案:使用 Pimpl 实现。
???
???
可变模板是实现此目的最通用的机制,并且既高效又类型安全。不要使用 C 的 varargs。
??? printf
???
??? beware of move-only and reference arguments
???
???
??? forwarding, type checking, references
???
还有更精确的方法来指定同质序列,例如 initializer_list。
???
???
模板为编译时编程提供了通用机制。
元编程是一种编程,其中至少一个输入或一个输出是类型。模板在编译时提供图灵完备的(除内存容量外)鸭子类型。所需的语法和技术相当糟糕。
模板元编程很难正确编写,会减慢编译速度,而且通常非常难以维护。但是,有一些实际示例表明,模板元编程比专家级汇编代码以外的任何替代方案都提供更好的性能。此外,还有一些实际示例表明,模板元编程比运行时代码更好地表达了基本思想。例如,如果您确实需要在编译时进行 AST 操作(例如,用于可选的矩阵运算折叠),在 C++ 中可能没有其他方法。
???
enable_if
相反,请使用 concepts。但请参阅如何模拟没有语言支持的 concepts。
??? good
替代方案:如果结果是值而不是类型,请使用 constexpr 函数。
如果您觉得有必要将模板元编程隐藏在宏中,那您可能走得太远了。
在 C++20 不可用时,我们需要使用 TMP 来模拟它们。需要 concepts 的用例(例如基于 concepts 的重载)是最常见(且简单)的 TMP 用例之一。
template<typename Iter>
/*requires*/ enable_if<random_access_iterator<Iter>, void>
advance(Iter p, int n) { p += n; }
template<typename Iter>
/*requires*/ enable_if<forward_iterator<Iter>, void>
advance(Iter p, int n) { assert(n >= 0); while (n--) ++p;}
使用 concepts 编写此类代码要简单得多。
void advance(random_access_iterator auto p, int n) { p += n; }
void advance(forward_iterator auto p, int n) { assert(n >= 0); while (n--) ++p;}
???
模板元编程是唯一直接支持且半有原则的编译时类型生成方法。
“Traits”技术大部分被模板别名替换以计算类型,被 constexpr 函数替换以计算值。
??? big object / small object optimization
???
函数是表达值计算最明显和最传统的方式。通常,constexpr 函数比替代方案的编译时开销要小。
“Traits”技术大部分被模板别名替换以计算类型,被 constexpr 函数替换以计算值。
template<typename T>
// requires Number<T>
constexpr T pow(T v, int n) // power/exponential
{
T res = 1;
while (n--) res *= v;
return res;
}
constexpr auto f7 = pow(pi, 7);
标准中定义的设施,如 conditional、enable_if 和 tuple,是可移植的,并且可以被假定为已知。
???
???
获取高级 TMP 设施并不容易,使用库会让您成为(希望是支持性的)社区的一部分。只有在您确实需要时才编写自己的“高级 TMP 支持”。
???
???
参见F.10
参见F.11
提高可读性。
???
???
通用性。可重用性。不要随意绑定到细节;使用最通用的可用设施。
使用 != 而不是 < 比较迭代器;!= 对更多对象有效,因为它不依赖于排序。
for (auto i = first; i < last; ++i) { // less generic
// ...
}
for (auto i = first; i != last; ++i) { // good; more generic
// ...
}
当然,如果范围 for 能完成您想要的工作,那会更好。
使用具有您所需功能的最低派生类。
class Base {
public:
Bar f();
Bar g();
};
class Derived1 : public Base {
public:
Bar h();
};
class Derived2 : public Base {
public:
Bar j();
};
// bad, unless there is a specific reason for limiting to Derived1 objects only
void my_func(Derived1& param)
{
use(param.f());
use(param.g());
}
// good, uses only Base interface so only commit to that
void my_func(Base& param)
{
use(param.f());
use(param.g());
}
根据语言规则,您无法部分特化函数模板。您可以完全特化函数模板,但几乎肯定应该选择重载——因为函数模板特化不参与重载,它们不会像您期望的那样工作。很少情况下,您应该通过委托给可以正确特化的类模板来实际特化。如果确实有有效理由特化函数模板,请编写一个单独的函数模板,将其委托给一个类模板,然后特化该类模板(包括编写部分特化的能力)。
???
例外:如果您确实有充分理由特化函数模板,只需编写一个单独的函数模板,将其委托给一个类模板,然后特化该类模板(包括编写部分特化的能力)。
如果您希望一个类匹配 concept,尽早验证它将节省用户调试的痛苦。
class X {
public:
X() = delete;
X(const X&) = default;
X(X&&) = default;
X& operator=(const X&) = default;
// ...
};
在某个地方,可能是在一个实现文件中,让编译器检查 X 的期望属性。
static_assert(Default_constructible<X>); // error: X has no default constructor
static_assert(Copyable<X>); // error: we forgot to define X's move constructor
不可行。
C 和 C++ 是密切相关的语言。它们都源自 1978 年的“经典 C”,并且此后一直在 ISO 委员会中发展。已经进行了许多尝试以保持它们的兼容性,但两者都不是对方的子集。
C 规则摘要
C++ 提供了更好的类型检查和更多的符号支持。它为高级编程提供了更好的支持,并且通常生成更快的代码。
char ch = 7;
void* pv = &ch;
int* pi = pv; // not C++
*pi = 999; // overwrite sizeof(int) bytes near &ch
C 中与 void* 的隐式转换规则微妙且未强制执行。特别是,此示例违反了转换到具有更严格对齐的类型的规则。
使用 C++ 编译器。
该子集可以使用 C 和 C++ 编译器进行编译,并且当作为 C++ 编译时,比“纯 C”具有更好的类型检查。
int* p1 = malloc(10 * sizeof(int)); // not C++
int* p2 = static_cast<int*>(malloc(10 * sizeof(int))); // not C, C-style C++
int* p3 = new int[10]; // not C
int* p4 = (int*) malloc(10 * sizeof(int)); // both C and C++
如果使用将代码编译为 C 的构建模式,则发出警告。
C++ 比 C 更具表达力,并为许多类型的编程提供了更好的支持。
例如,要使用第三方 C 库或 C 系统接口,请在 C 和 C++ 的公共子集(为了更好的类型检查)中定义低级接口。尽可能将低级接口封装在遵循 C++ 指南(为了更好的抽象、内存安全和资源安全)的接口中,并在 C++ 代码中使用该 C++ 接口。
您可以从 C 调用 C++
// in C:
double sqrt(double);
// in C++:
extern "C" double sqrt(double);
sqrt(2);
您可以从 C 调用 C++
// in C:
X call_f(struct Y*, int);
// in C++:
extern "C" X call_f(Y* p, int i)
{
return p->f(i); // possibly a virtual function call
}
无需
区分声明(用作接口)和定义(用作实现)。使用头文件表示接口并强调逻辑结构。
源文件规则摘要
参见NL.27
包含受同一定义规则约束的实体会导致链接错误。
// file.h:
namespace Foo {
int x = 7;
int xx() { return x+x; }
}
// file1.cpp:
#include <file.h>
// ... more ...
// file2.cpp:
#include <file.h>
// ... more ...
链接 file1.cpp 和 file2.cpp 将导致两个链接器错误。
替代表述:头文件只能包含
检查上面的正面列表。
可维护性。可读性。
// bar.cpp:
void bar() { cout << "bar\n"; }
// foo.cpp:
extern void bar();
void foo() { bar(); }
bar 的维护者无法找到 bar 的所有声明,如果它的类型需要更改。bar 的用户无法知道使用的接口是否完整且正确。充其量,错误消息(延迟地)来自链接器。
最小化上下文依赖并提高可读性。
#include <vector>
#include <algorithm>
#include <string>
// ... my code here ...
#include <vector>
// ... my code here ...
#include <algorithm>
#include <string>
这适用于 .h 和 .cpp 文件。
有一种观点认为,通过在我们要保护的代码之后(如“坏”示例所示)包含头文件来隔离代码免受头文件中的声明和宏的影响。但是
另请参阅:
简单。
这使得编译器能够进行早期一致性检查。
// foo.h:
void foo(int);
int bar(long);
int foobar(int);
// foo.cpp:
void foo(int) { /* ... */ }
int bar(double) { /* ... */ }
double foobar(int);
调用 bar 或 foobar 的程序将在链接时捕获错误。对于 foobar 的返回类型错误,现在在编译 foo.cpp 时立即捕获。由于重载的可能性,bar 的参数类型错误直到链接时才能捕获,但系统地使用 .h 文件增加了程序员更早捕获它的可能性。
// foo.h:
void foo(int);
int bar(long);
int foobar(int);
// foo.cpp:
#include "foo.h"
void foo(int) { /* ... */ }
int bar(double) { /* ... */ }
double foobar(int); // error: wrong return type
foobar 的返回类型错误现在在编译 foo.cpp 时立即捕获。由于重载的可能性,bar 的参数类型错误直到链接时才能捕获,但系统地使用 .h 文件会增加程序员更早捕获它的可能性。
???
using namespace 可能导致名称冲突,因此应谨慎使用。但是,在用户代码中(例如,在过渡期间)并非总是可以限定命名空间中的每个名称,有时命名空间是如此基础且在代码库中普遍存在,以至于一致地限定会变得冗长和分散注意力。
#include <string>
#include <vector>
#include <iostream>
#include <memory>
#include <algorithm>
using namespace std;
// ...
这里(显然),标准库被广泛使用,并且显然没有使用其他库,因此要求到处使用 std::可能会分散注意力。
使用 using namespace std; 会使用户暴露于与标准库中的名称发生名称冲突的风险。
#include <cmath>
using namespace std;
int g(int x)
{
int sqrt = 7;
// ...
return sqrt(x); // error
}
但是,这不太可能导致一个非错误的解决方案,并且使用 using namespace std 的人应该知道 std 和此风险。
.cpp 文件是一种局部作用域。包含 using namespace X 的 N 行 .cpp 文件、包含 using namespace X 的 N 行函数以及每个包含 using namespace X 的 M 个函数(总共 N 行代码)在名称冲突的可能性方面几乎没有区别。
不要在头文件的全局作用域中使用 using namespace.
这样做会剥夺 #include 者有效消歧和使用替代方案的能力。当包含顺序不同时,它们可能具有不同的含义,这也会使 #include 的头文件依赖于顺序。
// bad.h
#include <iostream>
using namespace std; // bad
// user.cpp
#include "bad.h"
bool copy(/*... some parameters ...*/); // some function that happens to be named copy
int main()
{
copy(/*...*/); // now overloads local ::copy and std::copy, could be ambiguous
}
一个例外是 using namespace std::literals;。在头文件中使用字符串字面量是必需的,并且根据规则 - 用户被要求将自己的 UDL 命名为 operator""_x - 它们不会与标准库发生冲突。
标记头文件的全局作用域中的 using namespace。
避免文件被 #include 多次。
为了避免 include 保护冲突,不要仅根据文件名来命名保护。务必包含一个键和一个好的区分符,例如头文件所属的库或组件的名称。
// file foobar.h:
#ifndef LIBRARY_FOOBAR_H
#define LIBRARY_FOOBAR_H
// ... declarations ...
#endif // LIBRARY_FOOBAR_H
标记没有 #include 保护的 .h 文件。
一些实现提供了 #pragma once 等供应商扩展作为 include 保护的替代方案。它不是标准的,也不是可移植的。它将托管机器的文件系统语义注入您的程序,此外还将您锁定在供应商那里。我们的建议是使用 ISO C++ 编写:请参见规则 P.2。
周期会使理解复杂化并减慢编译速度。它们还会使转换为使用语言支持的模块(当它们可用时)复杂化。
消除循环;不要仅仅用 #include 保护来打破它们。
// file1.h:
#include "file2.h"
// file2.h:
#include "file3.h"
// file3.h:
#include "file1.h"
标记所有循环。
避免意外。如果包含的头文件发生更改,则避免不得不更改 #include。避免意外地依赖于头文件中包含的实现细节和逻辑上分离的实体。
#include <iostream>
using namespace std;
void use()
{
string s;
cin >> s; // fine
getline(cin, s); // error: getline() not defined
if (s == "surprise") { // error == not defined
// ...
}
}
<iostream> 暴露了 std::string 的定义(“为什么?”是一个有趣的琐事问题),但它不必这样做,因为它传递性地包含了整个 <string> 头文件,导致了常见的初学者问题“为什么 getline(cin,s); 不起作用?”或者甚至是偶尔的“string 不能与 == 比较”。
解决方案是显式 #include <string>
#include <iostream>
#include <string>
using namespace std;
void use()
{
string s;
cin >> s; // fine
getline(cin, s); // fine
if (s == "surprise") { // fine
// ...
}
}
一些头文件正是为了从各种头文件中收集一组一致的声明而存在的。例如
// basic_std_lib.h:
#include <string>
#include <map>
#include <iostream>
#include <random>
#include <vector>
用户现在可以通过一个 #include 来获取该声明集。
#include "basic_std_lib.h"
这条反对隐式包含的规则无意阻止这种故意的聚合。
执行此操作需要一些关于头文件中的内容是“导出”给用户,哪些是为了实现而存在的知识。在模块可用之前,不可能有真正好的解决方案。
可用性,头文件应易于使用,并在单独包含时工作。头文件应封装它们提供的功能。避免头文件的客户端需要管理该头文件的依赖项。
#include "helpers.h"
// helpers.h depends on std::string and includes <string>
未能遵循此规则会导致头文件的客户端难以诊断的错误。
头文件应包含其所有依赖项。使用相对路径时要小心,因为 C++ 实现对其含义的解释不同。
测试应验证头文件本身是否编译,或者仅包含该头文件的 cpp 文件是否编译。
标准提供了灵活性,允许编译器实现使用尖括号(<>)或引号("")语法选择的两种形式的 #include。供应商利用这一点,使用不同的搜索算法和方法来指定包含路径。
不过,指导方针是使用引号形式来包含与包含 `#include` 语句的文件的相对路径存在的文件(来自同一组件或项目),并在其他任何可能的地方使用尖括号形式。这有助于清晰地说明文件相对于包含它的文件的位置,或者需要不同的搜索算法的场景。它能让人一目了然地理解一个头文件是来自本地相对文件,还是标准库头文件,或是来自备用搜索路径的头文件(例如,来自另一个库或一组通用包含项的头文件)。
// foo.cpp:
#include <string> // From the standard library, requires the <> form
#include <some_library/common.h> // A file that is not locally relative, included from another library; use the <> form
#include "foo.h" // A file locally relative to foo.cpp in the same project, use the "" form
#include "util/util.h" // A file locally relative to foo.cpp in the same project, use the "" form
#include <component_b/bar.h> // A file in the same project located via a search path, use the <> form
未能遵循这一点会导致难以诊断的错误,因为在包含时错误地指定范围而选择了错误的文件。例如,在典型情况下,`#include ""` 的搜索算法会首先查找本地相对路径的文件,然后使用此形式引用一个非本地相对的文件,这意味着如果一个文件(例如,包含文件被移动到新位置)出现在本地相对路径中,它将比之前的包含文件更早被找到,并且包含项的集合会以意想不到的方式发生更改。
库的创建者应该将他们的头文件放在一个文件夹中,并让客户端使用相对路径 `#include <some_library/common.h>` 来包含这些文件。
测试应确定通过 `""` 引用的头文件是否可以用 `<>` 来引用。
`标准`并未指定编译器如何通过 `#include` 指令中的标识符唯一定位头文件,也未指定什么构成唯一性。例如,实现是否区分标识符的大小写,或者标识符是否为指向头文件的文件系统路径,如果是,那么层级文件系统路径的定界符是什么。
为了最大化 `#include` 指令在编译器之间的便携性,建议
// good examples
#include <vector>
#include <string>
#include "util/util.h"
// bad examples
#include <VECTOR> // bad: the standard library defines a header identified as <vector>, not <VECTOR>
#include <String> // bad: the standard library defines a header identified as <string>, not <String>
#include "Util/Util.H" // bad: the header file exists on the file system as "util/util.h"
#include "util\util.h" // bad: may not work if the implementation interprets `\u` as an escape sequence, or where '\' is not a valid path separator
只有在实现区分头文件标识符的大小写并且只支持 `/` 作为文件路径定界符的情况下,才有可能强制执行此规则。
???
???
???
在头文件中提及未命名命名空间几乎总是错误。
// file foo.h:
namespace
{
const double x = 1.234; // bad
double foo(double y) // bad
{
return y + x;
}
}
namespace Foo
{
const double x = 1.234; // good
inline double foo(double y) // good
{
return y + x;
}
}
外部任何内容都不能依赖于嵌套未命名命名空间中的实体。除非正在定义“外部/导出的”实体,否则应将实现源文件中的每个定义都放在未命名命名空间中。
static int f();
int g();
static bool h();
int k();
namespace {
int f();
bool h();
}
int g();
int k();
API 类及其成员不能存在于未命名命名空间中;但是,在实现源文件中定义的任何“辅助”类或函数都应该在未命名命名空间的作用域内。
???
仅使用基础语言,任何任务都将是繁琐的(无论使用何种语言)。使用合适的库,任何任务都可以相对简单。
标准库多年来一直在稳步发展。它在标准中的描述现在比语言特性本身还要大。因此,很可能指南中的这个库部分最终会增长到与所有其他部分相等或超过。
« ??? 我们需要另一个级别的规则编号??? »
C++ 标准库组件摘要
标准库规则摘要
节省时间。不要重复造轮子。不要复制他人的工作。在他人改进时受益。在您改进时帮助他人。
更多人了解标准库。它比您自己的代码或大多数其他库更可能稳定、维护良好且广泛可用。
向 `std` 添加内容可能会改变符合标准的代码的含义。向 `std` 添加内容可能会与标准的新版本发生冲突。
namespace std { // BAD: violates standard
class My_vector {
// . . .
};
}
namespace Foo { // GOOD: user namespace is allowed
class My_vector {
// . . .
};
}
可能,但混乱且可能导致平台问题。
因为,显然,违反此规则可能导致未定义行为、内存损坏以及各种其他糟糕的错误。
这是一个半哲学的元规则,需要许多支持性的具体规则。我们需要它作为更具体规则的总括。
更具体规则的摘要
???
容器规则摘要
C 数组不安全,并且与 `array` 和 `vector` 相比没有优势。对于固定长度数组,请使用 `std::array`,它在传递给函数时不会退化为指针,并且知道其大小。此外,与内置数组一样,栈分配的 `std::array` 将其元素保留在栈上。对于可变长度数组,请使用 `std::vector`,它还可以更改其大小并处理内存分配。
int v[SIZE]; // BAD
std::array<int, SIZE> w; // ok
int* v = new int[initial_size]; // BAD, owning raw pointer
delete[] v; // BAD, manual delete
std::vector<int> w(initial_size); // ok
使用 `gsl::span` 来引用容器中的非拥有数据。
将栈上分配的固定大小数组与堆上分配的 `vector` 进行性能比较是错误的。您也可以将栈上的 `std::array` 与通过指针访问的 `malloc()` 的结果进行比较。对于大多数代码,即使是栈分配和堆分配之间的差异也没有关系,但 `vector` 的便利性和安全性是有意义的。处理具有重要差异的代码的人员有能力在 `array` 和 `vector` 之间进行选择。
`vector` 和 `array` 是唯一提供以下优势的标准容器:
通常您需要向容器添加和删除元素,因此默认使用 `vector`;如果您不需要修改容器的大小,请使用 `array`。
即使其他容器看起来更适合,例如用于 O(log N) 查找性能的 `map` 或用于在中间高效插入的 `list`,对于大小 up to 几 KB 的容器,`vector` 通常仍然表现更好。
不应将 `string` 用作单个字符的容器。`string` 是文本字符串;如果您想要一个字符容器,请改用 `vector
如果您有充分的理由使用其他容器,请改用它。例如:
如果 `vector` 满足您的需求,但您不需要容器是可变大小的,请改用 `array`。
如果您需要一个字典式查找容器,它保证 O(K) 或 O(log N) 的查找时间,容器会更大(超过几 KB),并且您频繁执行插入操作,使得维护已排序 `vector` 的开销不可行,那么请继续使用 `unordered_map` 或 `map`。
要用一定数量的元素初始化 vector,请使用 `()` 初始化。要用元素列表初始化 vector,请使用 `{}` 初始化。
vector<int> v1(20); // v1 has 20 elements with the value 0 (vector<int>{})
vector<int> v2 {20}; // v2 has 1 element with the value 20
读取或写入超出分配的元素范围通常会导致严重的错误、错误的结果、崩溃和安全漏洞。
适用于元素范围的标准库函数都具有(或可以具有)边界安全的重载,这些重载接受 `span`。标准类型(如 `vector`)可以通过添加约定等方式进行修改,以在边界配置文件下执行边界检查,或者与 `at()` 一起使用。
理想情况下,边界保证应由静态强制执行。例如:
此类循环与任何未经检查/不安全的等效循环一样快。
通常,简单的预检查可以消除对单个索引的检查需求。例如:
此类循环可以比单独检查的元素访问快得多。
void f()
{
array<int, 10> a, b;
memset(a.data(), 0, 10); // BAD, and contains a length error (length = 10 * sizeof(int))
memcmp(a.data(), b.data(), 10); // BAD, and contains a length error (length = 10 * sizeof(int))
}
此外,`std::array<>::fill()` 或 `std::fill()` 甚至空初始化器都比 `memset()` 更好。
void f()
{
array<int, 10> a, b, c{}; // c is initialized to zero
a.fill(0);
fill(b.begin(), b.end(), 0); // std::fill()
fill(b, 0); // std::ranges::fill()
if ( a == b ) {
// ...
}
}
如果代码使用未经修改的标准库,那么仍然有解决方法可以使 `std::array` 和 `std::vector` 以边界安全的方式使用。代码可以调用每个类的 `.at()` 成员函数,这将导致抛出 `std::out_of_range` 异常。或者,代码可以调用 `at()` 自由函数,这将在边界违规时导致快速失败(或自定义操作)。
void f(std::vector<int>& v, std::array<int, 12> a, int i)
{
v[0] = a[0]; // BAD
v.at(0) = a[0]; // OK (alternative 1)
at(v, 0) = a[0]; // OK (alternative 2)
v.at(0) = a[i]; // BAD
v.at(0) = a.at(i); // OK (alternative 1)
v.at(0) = at(a, i); // OK (alternative 2)
}
此规则是 边界配置文件 的一部分。
这样做会破坏对象的语义(例如,通过覆盖 `vptr`)。
同样适用于 (w)memset、(w)memcpy、(w)memmove 和 (w)memcmp。
struct base {
virtual void update() = 0;
};
struct derived : public base {
void update() override {}
};
void f(derived& a, derived& b) // goodbye v-tables
{
memset(&a, 0, sizeof(derived));
memcpy(&a, &b, sizeof(derived));
memcmp(&a, &b, sizeof(derived));
}
相反,定义适当的默认初始化、复制和比较函数。
void g(derived& a, derived& b)
{
a = {}; // default initialize
b = a; // copy
if (a == b) do_something(a, b);
}
TODO 说明:
文本操作是一个巨大的主题。`std::string` 并不涵盖所有这些。本节主要试图阐明 `std::string` 与 `char*`、`zstring`、`string_view` 和 `gsl::span
另请参见:正则表达式
在这里,我们使用“字符序列”或“字符串”来指代打算被读取为文本(以某种方式,最终)的字符序列。我们不考虑 ???
字符串摘要
另请参阅:
`string` 正确处理了分配、拥有、复制、逐步扩展,并提供了各种有用的操作。
vector<string> read_until(const string& terminator)
{
vector<string> res;
for (string s; cin >> s && s != terminator; ) // read a word
res.push_back(s);
return res;
}
请注意 `string` 如何提供 `>>` 和 `!=`(作为有用操作的示例),并且没有显式的分配、去分配或范围检查(`string` 会处理这些)。
在 C++17 中,我们可能会在参数中使用 `string_view`,而不是 `const string&`,以允许调用者具有更大的灵活性。
vector<string> read_until(string_view terminator) // C++17
{
vector<string> res;
for (string s; cin >> s && s != terminator; ) // read a word
res.push_back(s);
return res;
}
不要对需要非平凡内存管理的 C 风格字符串操作使用 C 风格字符串。
char* cat(const char* s1, const char* s2) // beware!
// return s1 + '.' + s2
{
int l1 = strlen(s1);
int l2 = strlen(s2);
char* p = (char*) malloc(l1 + l2 + 2);
strcpy(p, s1, l1);
p[l1] = '.';
strcpy(p + l1 + 1, s2, l2);
p[l1 + l2 + 1] = 0;
return p;
}
我们是否做对了?调用者是否会记得 `free()` 返回的指针?此代码是否能通过安全审查?
不要假设 `string` 比低级技术慢,除非经过测量,并记住并非所有代码都对性能至关重要。不要过早优化。
???
`std::string_view` 或 `gsl::span
vector<string> read_until(string_view terminator);
void user(zstring p, const string& s, string_view ss)
{
auto v1 = read_until(p);
auto v2 = read_until(s);
auto v3 = read_until(ss);
// ...
}
`std::string_view` (C++17) 是只读的。
???
可读性。意图声明。普通的 `char*` 可以是指向单个字符的指针、指向字符数组的指针、指向 C 风格(以零结尾)字符串的指针,甚至是小整数。区分这些替代方案可以防止误解和错误。
void f1(const char* s); // s is probably a string
我们所知道的是,它应该为空指针或指向至少一个字符。
void f1(zstring s); // s is a C-style string or the nullptr
void f1(czstring s); // s is a C-style string constant or the nullptr
void f1(std::byte* s); // s is a pointer to a byte (C++17)
除非有理由,否则不要将 C 风格字符串转换为 `string`。
像任何其他“普通指针”一样,`zstring` 不应表示拥有权。
“现有的 C++ 代码”有数十亿行,其中大多数使用 `char*` 和 `const char*` 而不记录意图。它们以多种方式使用,包括表示拥有权以及作为通用内存指针(而不是 `void*`)。很难区分这些用法,因此此指南难以遵循。这是 C 和 C++ 程序中错误的主要来源之一,因此 wherever feasible 遵循此指南是值得的。
当前代码中 `char*` 的各种用法是错误的主要来源。
char arr[] = {'a', 'b', 'c'};
void print(const char* p)
{
cout << p << '\n';
}
void use()
{
print(arr); // run-time error; potentially very bad
}
数组 `arr` 不是 C 风格字符串,因为它不是以零结尾的。
请参阅 `zstring`、`string` 和 `string_view`。
使用 `char*` 来表示指向不一定为字符的对象的指针会引起混淆,并会禁用有价值的优化。
???
C++17
???
`std::string` 支持标准库 `locale` 设施。
???
???
???
`std::string_view` 是只读的。
???
???
编译器将标记尝试写入 `string_view` 的行为。
直接表达一个想法可以最大限度地减少错误。
auto pp1 = make_pair("Tokyo", 9.00); // {C-style string,double} intended?
pair<string, double> pp2 = {"Tokyo", 9.00}; // a bit verbose
auto pp3 = make_pair("Tokyo"s, 9.00); // {std::string,double} // C++14
pair pp4 = {"Tokyo"s, 9.00}; // {std::string,double} // C++17
???
`iostream` 是一个类型安全、可扩展、格式化和非格式化的流 I/O 库。它支持多种(用户可扩展的)缓冲策略和多个区域设置。它可以用于常规 I/O、读写内存(字符串流)以及用户定义的扩展,例如跨网络流式传输(asio:尚未标准化)。
Iostream 规则摘要
除非您确实只处理单个字符,否则使用字符级输入会导致用户代码执行可能出错且可能效率低下的令牌组合。
char c;
char buf[128];
int i = 0;
while (cin.get(c) && !isspace(c) && i < 128)
buf[i++] = c;
if (i == 128) {
// ... handle too long string ....
}
更好的(更简单,可能更快)
string s;
s.reserve(128);
cin >> s;
而且 `reserve(128)` 可能不值得。
???
错误通常应尽快处理。如果输入未得到验证,则每个函数都必须能够处理坏数据(这是不切实际的)。
???
???
`iostream`s 是安全、灵活且可扩展的。
// write a complex number:
complex<double> z{ 3, 4 };
cout << z << '\n';
`complex` 是用户定义的类型,其 I/O 是在不修改 `iostream` 库的情况下定义的。
// read a file of complex numbers:
for (complex<double> z; cin >> z; )
v.push_back(z);
??? 性能 ???
人们经常(且经常正确地)指出 `printf()` 系列相对于 `iostream`s 有两个优点:格式灵活性和性能。这需要与 `iostream`s 的优点进行权衡,即可扩展性以处理用户定义的类型、安全性违反的弹性、隐式内存管理以及 `locale` 处理。
如果您需要 I/O 性能,几乎总能比 `printf()` 做得更好。
`gets()`、使用 `%s` 的 `scanf()` 和使用 `%s` 的 `printf()` 是安全隐患(容易发生缓冲区溢出且通常容易出错)。C11 定义了一些“可选扩展”,它们对参数进行额外的检查。如果您的 C 库中存在,`gets_s()`、`scanf_s()` 和 `printf_s()` 可能是更安全的选择,但它们仍然不是类型安全的。
可以选择性地标记 `
将 `iostreams` 与 `printf` 风格的 I/O 同步可能会很昂贵。`cin` 和 `cout` 默认与 `printf` 同步。
int main()
{
ios_base::sync_with_stdio(false);
// ... use iostreams ...
}
???
`endl` 操纵符在大多数情况下等同于 `'\n'` 和 `"\n"`;最常见的用法是它通过执行冗余的 `flush()` 来减慢输出速度。与 `printf` 风格的输出相比,这种减速可能非常显著。
cout << "Hello, World!" << endl; // two output operations and a flush
cout << "Hello, World!\n"; // one output operation and no flush
对于 `cin`/`cout`(及类似)交互,没有理由刷新;这会自动完成。对于写入文件,很少需要 `flush`。
对于字符串流(特别是 `ostringstream`),插入 `endl` 完全等同于插入 `'\n'` 字符,但在这种情况下,`endl` 可能明显更慢。
`endl` **不**会负责生成特定于平台的行尾序列(如 Windows 上的 `"\r\n"`)。因此,对于字符串流,`s << endl` 只插入一个**单个**字符 `'\n'`。
除了(偶尔重要的)性能问题外,`'\n'` 和 `endl` 之间的选择几乎完全是美学上的。
`
`
???
C 标准库规则摘要
`longjmp` 会忽略析构函数,从而使所有依赖 RAII 的资源管理策略失效。
标记 `longjmp` 和 `setjmp` 的所有出现。
本节包含关于更高层架构思想和库的思路。
架构规则摘要
隔离不稳定的代码有助于其单元测试、接口改进、重构和最终弃用。
库是声明和定义的集合,这些声明和定义被一起维护、记录和分发。库可以是一组头文件(“仅头文件库”),也可以是一组头文件加上一组目标文件。您可以将库静态或动态链接到程序中,或者您可以 `#include` 一个仅头文件库。
库可以在其组件的定义中包含循环引用。例如:
???
但是,一个库不应该依赖于另一个依赖于它的库。
本节包含在某些地方很受欢迎,但我们故意不推荐的规则和指导方针。我们非常清楚,在某些时间和地点,这些规则是合理的,我们自己也曾使用过。然而,在我们推荐并支持的编程风格的指导方针中,这些“非规则”会造成损害。
即使在今天,也可能存在规则有意义的上下文。例如,缺乏合适的工具支持可能使例外情况不适用于硬实时系统,但请不要盲目相信“常识”(例如,关于“效率”的未经证实的陈述);这种“常识”可能基于数十年前的信息或来自与 C++ 属性非常不同的语言(例如 C 或 Java)的经验。
替代规则的积极论点列在作为“替代方案”提供的规则中。
非规则摘要
“所有声明放在顶部”的规则是旧编程语言的遗留问题,这些语言不允许在语句之后初始化变量和常量。这会导致程序更长,并因未初始化和错误初始化的变量而导致更多错误。
int use(int x)
{
int i;
char c;
double d;
// ... some stuff ...
if (x < i) {
// ...
i = f(x, d);
}
if (i < x) {
// ...
i = g(x, c);
}
return i;
}
未初始化变量与其使用之间的距离越大,出现 bug 的可能性就越大。幸运的是,编译器可以捕获许多“先使用后设置”的错误。不幸的是,编译器无法捕获所有这类错误,而且 bug 并不总是像这个小示例那样容易发现。
单一 return 规则可能导致代码冗余和额外状态变量的引入。特别是,单一 return 规则使得难以将错误检查集中在函数的顶部。
template<class T>
// requires Number<T>
string sign(T x)
{
if (x < 0)
return "negative";
if (x > 0)
return "positive";
return "zero";
}
为了只使用一个 return,我们不得不这样做:
template<class T>
// requires Number<T>
string sign(T x) // bad
{
string res;
if (x < 0)
res = "negative";
else if (x > 0)
res = "positive";
else
res = "zero";
return res;
}
这既冗长又可能效率低下。函数越大越复杂,变通方法就越痛苦。当然,许多简单的函数由于其固有的简单逻辑,自然会只有一个 return。
int index(const char* p)
{
if (!p) return -1; // error indicator: alternatively "throw nullptr_error{}"
// ... do a lookup to find the index for p
return i;
}
如果应用此规则,我们会得到类似这样的结果:
int index2(const char* p)
{
int i;
if (!p)
i = -1; // error indicator
else {
// ... do a lookup to find the index for p
}
return i;
}
请注意,我们(故意)违反了关于未初始化变量的规则,因为这种风格通常会导致这种情况。此外,这种风格会诱使使用 goto exit 的非规则。
不使用异常似乎有四个主要原因:
我们无法就此问题达成所有人的满意。毕竟,关于异常的讨论已经进行了 40 多年。有些语言不能在没有异常的情况下使用,而另一些语言则不支持它们。这导致了对异常使用和不使用的强烈传统,以及激烈的辩论。
然而,我们可以简要概述一下为什么我们认为异常是通用编程和本指南背景下的最佳选择。简单的赞成和反对论点通常没有结论。有些专用应用程序确实不适合使用异常(例如,没有支持异常处理成本可靠估计的硬实时系统)。
依次考虑对异常的主要反对意见:
许多(可能大部分)与异常相关的问题源于与混乱的旧代码交互的历史需求。
使用异常的基本论点是:
记住:
???
将每个类放在自己的文件中的结果文件数量难以管理,并且会减慢编译速度。单个类很少是维护和分发的良好逻辑单元。
???
将初始化分为两部分会导致更弱的不变量、更复杂的代码(不得不处理半构造的对象)以及错误(当我们未能一致地正确处理半构造的对象时)。
有时也称为两阶段构造。
// Old conventional style: many problems
class Picture
{
int mx;
int my;
int * data;
public:
// main problem: constructor does not fully construct
Picture(int x, int y)
{
mx = x; // also bad: assignment in constructor body
// rather than in member initializer
my = y;
data = nullptr; // also bad: constant initialization in constructor
// rather than in member initializer
}
~Picture()
{
Cleanup();
}
// ...
// bad: two-phase initialization
bool Init()
{
// invariant checks
if (mx <= 0 || my <= 0) {
return false;
}
if (data) {
return false;
}
data = (int*) malloc(mx*my*sizeof(int)); // also bad: owning raw * and malloc
return data != nullptr;
}
// also bad: no reason to make cleanup a separate function
void Cleanup()
{
if (data) free(data);
data = nullptr;
}
};
Picture picture(100, 0); // not ready-to-use picture here
// this will fail..
if (!picture.Init()) {
puts("Error, invalid picture");
}
// now have an invalid picture object instance.
class Picture
{
int mx;
int my;
vector<int> data;
static int check_size(int size)
{
// invariant check
Expects(size > 0);
return size;
}
public:
// even better would be a class for a 2D Size as one single parameter
Picture(int x, int y)
: mx(check_size(x))
, my(check_size(y))
// now we know x and y have a valid size
, data(mx * my) // will throw std::bad_alloc on error
{
// picture is ready-to-use
}
// compiler generated dtor does the job. (also see C.21)
// ...
};
Picture picture1(100, 100);
// picture1 is ready-to-use here...
// not a valid size for y,
// default contract violation behavior will call std::terminate then
Picture picture2(100, 0);
// not reach here...
goto 容易出错。此技术是 RAII 类资源和错误处理的预异常技术。
void do_something(int n)
{
if (n < 100) goto exit;
// ...
int* p = (int*) malloc(n);
// ...
if (some_error) goto_exit;
// ...
exit:
free(p);
}
并找到 bug。
protected 数据是错误的来源。protected 数据可以从任意数量的代码在各种地方进行操作。protected 数据是类层次结构中等同于全局数据的东西。
???
已为 C++ 编写了许多编码标准、规则和指南,尤其适用于 C++ 的专门用途。许多
糟糕的编码标准比没有编码标准更糟糕。然而,一套合适的指南远好于没有标准:“形式是解放的”。
为什么我们不能拥有一种允许我们想要的一切并禁止我们不想要的一切的语言(“完美的语言”)?根本原因在于,负担得起的语言(及其工具链)也服务于需求与您不同的人,并且比您今天拥有的需求服务于更多需求。此外,您的需求会随着时间而变化,需要一种通用语言来使您能够适应。对今天来说是理想的语言,明天就会过于受限。
编码指南根据特定需求调整语言的使用。因此,不可能有一个适合所有人的编码风格。我们期望不同的组织提供补充,通常具有更多的限制和更严格的风格规则。
参考部分
本节包含对演示核心指南及其背后思想有用的材料。
请注意,CppCon 演讲的幻灯片可用(与发布的视频链接)。
非常欢迎对此列表做出贡献。
感谢许多为此提供规则、建议、支持信息、参考文献等方面的人。
以及参见 GitHub 上的贡献者列表。
理想情况下,我们会遵循所有指南。这将产生最干净、最规则、最少出错,通常也是最快的代码。不幸的是,这通常是不可能的,因为我们必须将我们的代码融入大型代码库并使用现有的库。通常,此类代码已经编写了数十年,并且不遵循这些指南。我们必须致力于逐步采用。
无论我们采取何种渐进式采用策略,我们都需要能够应用相关指南集来首先解决某些问题集,然后推迟其他问题。当某些但不全部指南与代码库相关,或者当要为特定应用程序领域应用一组特定指南时,“相关指南”的类似概念变得重要。我们将这样一组相关指南称为“配置文件”。我们的目标是使这样一组指南保持一致,以便它们共同帮助我们达到特定目标,例如“避免范围错误”或“静态类型安全”。每个配置文件旨在消除一类错误。孤立地执行“随机”规则比交付明确的改进更可能破坏代码库。
“配置文件”是一组确定性且可移植执行的规则子集(即限制),旨在实现特定保证。“确定性”意味着它们仅需要本地分析,并且可以实现在编译器中(尽管它们不必如此)。“可移植执行”意味着它们类似于语言规则,因此程序员可以指望不同的执行工具对相同的代码给出相同的答案。
使用此类语言配置文件编写的无警告代码被认为符合该配置文件。符合配置文件代码被认为是“安全构造”,因为它符合该配置文件所针对的安全属性。符合配置文件代码不会是错误(对于该属性)的根本原因,尽管此类错误可能会通过其他代码、库或外部环境引入程序。配置文件还可能引入其他库类型,以简化合规性并鼓励正确代码。
配置文件摘要
将来,我们期望定义更多配置文件并向现有配置文件添加更多检查。候选包括:
启用配置文件是实现定义的;通常,它在使用的分析工具中设置。
要抑制对配置文件检查的执行,请在语言契约上放置一个 suppress 注释。例如:
[[suppress("bounds")]] char* raw_find(char* p, int n, char x) // find x in p[0]..p[n - 1]
{
// ...
}
现在 raw_find() 可以随意混乱内存。显然,抑制应该是非常罕见的。
此配置文件使构建使用类型正确且避免意外类型转换的代码更加容易。它通过专注于消除类型冲突的主要来源来实现,包括对 cast 和 union 的不安全使用。
就本节而言,类型安全被定义为变量的使用方式不违反其定义类型规则的属性。作为类型 T 访问的内存不应是实际包含非相关类型 U 对象的有效内存。请注意,当与边界安全和生命周期安全结合时,安全性才是完整的。
此配置文件的实现应识别源代码中的以下模式,并发出诊断。
类型安全配置文件摘要
通过类型安全配置文件,您可以确信每个操作都应用于有效的对象。可以抛出异常来指示在编译时无法静态检测到的错误。请注意,只有在我们同时拥有边界安全和生命周期安全的情况下,这种类型安全才能是完整的。没有这些保证,内存区域的访问可能与存储在其内的任何对象、对象或对象部分无关。
此配置文件有助于构建在分配内存块边界内运行的代码。它通过专注于消除边界冲突的主要来源:指针算术和数组索引来实现。此配置文件的一个核心特性是限制指针只能指向单个对象,而不是数组。
我们将边界安全定义为程序不使用对象访问其分配范围之外的内存的属性。边界安全仅在与类型安全和生命周期安全结合时才是完整的,它们涵盖了其他允许边界冲突的不安全操作。
边界安全配置文件摘要
边界安全意味着对对象的访问——尤其是数组——不会超出对象的内存分配。这消除了大量隐蔽且难以查找的错误,包括(臭名昭著的)“缓冲区溢出”错误。即使越界访问“仅仅是一次读取”,也可能导致不变量冲突(当访问的对象不是假定的类型时)和“神秘值”。
通过指向无效指针的指针进行访问是错误的常见来源,并且在许多传统的 C 或 C++ 编程风格中很难避免。例如,指针可能未初始化、为 nullptr、指向数组范围之外,或指向已删除的对象。
生命周期安全配置文件摘要
一旦通过风格规则、静态分析和库支持完全强制执行,此配置文件
GSL 是一个小型库,旨在支持本套指南。没有这些设施,指南在语言细节上将必须更加严格。
核心指南支持库在 gsl 命名空间中定义,并且这些名称可能是标准库或其他知名库名称的别名。通过 gsl 命名空间(编译时)的间接引用允许进行实验和本地支持设施变体。
GSL 是仅头文件的,可以在 GSL:指南支持库 中找到。支持库设施设计得非常轻量级(零开销),因此与使用传统替代方案相比没有开销。在适当时,它们可以“仪表化”其他功能(例如检查)以用于调试等任务。
本指南除了 GSL 类型外,还使用了标准(例如 C++17)中的类型。例如,我们假设有一个 variant 类型,但它目前不在 GSL 中。最终,请使用投票选入 C++17 的那个。
由于技术原因(例如当前 C++ 版本的功能限制),您使用的库可能不支持下面列出的一些 GSL 类型。因此,请查阅您的 GSL 文档以获取更多信息。
对于下面的每个 GSL 类型,我们都陈述了该类型的不变量。只要用户代码仅使用类型提供的成员/自由函数来更改 GSL 对象的状态(即用户代码不通过违反任何其他指南规则来绕过类型接口来更改对象的值/位),该不变量就成立。
GSL 组件摘要
我们计划为 GSL 提供“ISO C++ 标准风格”的半形式化规范。
我们依赖 ISO C++ 标准库,并希望 GSL 的部分内容能被吸收到标准库中。
这些类型允许用户区分拥有和非拥有指针,以及区分指向单个对象的指针和指向序列第一个元素的指针。
这些“视图”永远不是所有者。
引用永远不是所有者(参见 R.4)。注意:引用有很多机会比它们所引用的对象活得更长(通过引用返回局部变量、持有向量元素的引用并执行 push_back、绑定到 std::max(x, y + 1) 等)。生命周期安全配置文件旨在解决这些问题,但即使如此,owner<T&> 也是没有意义的,并且不被推荐。
名称大多遵循 ISO 标准库风格(小写和下划线)。
“原始指针”表示法(例如 int*)被假定为其最常见的含义;也就是说,指针指向一个对象,但不拥有它。所有者应转换为资源句柄(例如 unique_ptr 或 vector<T>)或标记为 owner<T*>。
owner 用于标记代码中的拥有指针,而这些代码无法升级到使用适当的资源句柄。原因包括:
owner<T> 与 T 的资源句柄不同,因为它仍然需要显式的 delete。
owner<T> 被假定指向自由存储(堆)上的对象。
如果某物不应该是 nullptr,请说明。
not_null<T> // T 通常是指针类型(例如 not_null<int*> 和 not_null<owner<Foo*>>),它不能是 nullptr。T 可以是任何对 nullptr 有意义的类型。
span<T> 指向零个或多个可变 T,除非 T 是 const 类型。所有对 span 元素的访问,特别是通过 operator[],默认都保证进行边界检查。
注意:GSL 的
span
(最初称为array_view
)被提议包含在 C++ 标准库中,并被采纳(名称和接口有所更改),只是std::span
不提供保证的边界检查。因此,GSL 更改了span
的名称和接口以跟踪std::span
,并且应该与std::span
完全相同,唯一的区别是 GSLspan
默认是完全边界安全的。如果边界安全会影响其接口,那么这些更改提案应通过 ISO C++ 委员会带回,以保持gsl::span
与std::span
的接口兼容性。如果std::span
的未来演进增加了边界检查,则可以移除gsl::span
。
“指针算术”最好在 span
中进行。指向多个 char
但不是 C 风格字符串的 char*
(例如,指向输入缓冲区的指针)应由 span
表示。
zstring
// 一个假定为 C 风格字符串的 char*
;即,一个以零结尾的 char
序列或 nullptr
czstring
// 一个假定为 C 风格字符串的 const char*
;即,一个以零结尾的 const
char
序列或 nullptr
从逻辑上讲,最后两个别名不是必需的,但我们并非总是合乎逻辑,它们使指向一个 char
的指针和指向 C 风格字符串的指针之间的区别变得明确。一个不假定以零结尾的字符序列应该是一个 span<char>
,或者如果由于 ABI 问题无法做到,则应是一个 char*
,而不是 zstring
。
对于不能为 nullptr
的 C 风格字符串,请使用 not_null<zstring>
。??? 我们需要一个 not_null<zstring>
的名字吗?或者它的丑陋是一种特性?
unique_ptr<T>
// 唯一所有权:std::unique_ptr<T>
shared_ptr<T>
// 共享所有权:std::shared_ptr<T>
(计数指针)stack_array<T>
// 栈分配的数组。元素数量在构造时确定,之后固定。除非 T
是 const
类型,否则元素是可变的。dyn_array<T>
// 一个容器,非增长的动态分配数组。元素数量在构造时确定,之后固定。除非 T
是 const
类型,否则元素是可变的。基本上是分配并拥有其元素的 span
。Expects
// 前置条件断言。目前放在函数体中。之后应移至声明。// Expects(p)
终止程序,除非 p == true
// Expects
受一些选项(执行、错误消息、终止替代项)的控制Ensures
// 后置条件断言。目前放在函数体中。之后应移至声明。这些断言目前是宏(糟糕!)并且必须出现在函数定义(仅)中,等待标准委员会关于契约和断言语法的决定。请参阅契约提案;例如,使用属性语法,Expects(p)
将变成 [[expects: p]]
。
finally
// finally(f)
创建一个 final_action{f}
,其析构函数调用 f
narrow_cast
// narrow_cast<T>(x)
是 static_cast<T>(x)
narrow
// narrow<T>(x)
是 static_cast<T>(x)
,如果 static_cast<T>(x) == x
且没有符号提升,否则会抛出 narrowing_error
(例如,narrow<unsigned>(-42)
会抛出)[[implicit]]
// 用于单参数构造函数的“标记”,以显式地使其非显式。move_owner
// p = move_owner(q)
意味着 p = q
但是 ???joining_thread
// std::thread
的 RAII 风格版本,会调用 join。index
// 用于所有容器和数组索引的类型(目前是 ptrdiff_t
的别名)这些概念(类型谓词)借鉴自 Andrew Sutton 的 Origin 库、Range 提案和 ISO WG21 Palo Alto TR。其中许多与 C++20 中成为 C++ 标准一部分的内容非常相似。
String
Number
Boolean
Range
// C++20 中为 std::ranges::range
Sortable
// C++20 中为 std::sortable
EqualityComparable
// C++20 中为 std::equality_comparable
Convertible
// C++20 中为 std::convertible_to
Common
// C++20 中为 std::common_with
Integral
// C++20 中为 std::integral
SignedIntegral
// C++20 中为 std::signed_integral
SemiRegular
// C++20 中为 std::semiregular
Regular
// C++20 中为 std::regular
TotallyOrdered
// C++20 中为 std::totally_ordered
Function
// C++20 中为 std::invocable
RegularFunction
// C++20 中为 std::regular_invocable
Predicate
// C++20 中为 std::predicate
Relation
// C++20 中为 std::relation
Pointer
// 具有 *
、->
、==
和默认构造函数的类型(默认构造函数假定设置为唯一的“null”值)Unique_pointer
// 匹配 Pointer
、可移动且不可复制的类型Shared_pointer
// 匹配 Pointer
且可复制的类型一致的命名和布局是有帮助的。即使没有其他原因,也可以减少“我的风格比你的风格好”的争论。然而,存在许多风格,人们对它们(支持和反对)充满热情。此外,大多数实际项目包含来自多个来源的代码,因此对所有代码进行单一风格的标准化通常是不可能的。在收到用户的大量指导请求后,我们提出了您可以使用的一套规则,如果您没有更好的想法,但真正的目标是保持一致性,而不是任何特定的规则集。IDE 和工具可以提供帮助(也可以带来阻碍)。
命名和布局规则
ALL_CAPS
用于宏名称underscore_style
名称void
用作参数类型const
表示法.cpp
后缀,对接口文件使用 .h
后缀这些规则大多是关于美学的,程序员对此持有强烈意见。IDE 也倾向于有默认设置和一系列替代方案。这些规则是建议遵循的默认设置,除非您有理由不这样做。
我们收到过关于命名和布局非常个人化和/或任意的评论,认为我们不应该试图“立法”它们。我们不是在“立法”(参见上一段)。然而,我们收到了许多关于在没有外部约束的情况下使用一套命名和布局约定的请求。
更具体和详细的规则更容易强制执行。
这些规则与《使用 C++ 进行编程:原理与实践》一书配套的《PPP 风格指南》中的建议非常相似。
编译器不读取注释。注释不如代码精确。注释不像代码那样一致地更新。
auto x = m * v1 + vv; // multiply m with v1 and add the result to vv
构建一个能解释日常英语文本的人工智能程序,看看它是否能更好地用 C++ 来表达。
代码说明了做了什么,而不是应该做什么。通常,意图的表达比实现更清晰、更简洁。
void stable_sort(Sortable& c)
// sort c in the order determined by <, keep equal elements (as defined by ==) in
// their original relative order
{
// ... quite a few lines of non-trivial code ...
}
如果注释和代码不一致,两者都可能出错。
冗长会减慢理解速度,并使代码更难阅读,因为它分散在源代码文件中。
使用易于理解的英语。我可能精通丹麦语,但大多数程序员不是;我的代码的维护者可能不是。避免使用短信俚语,注意您的语法、标点和大小写。目标是专业,而不是“酷”。
不可能。
可读性。避免“愚蠢的错误”。
int i;
for (i = 0; i < max; ++i); // bug waiting to happen
if (i == j)
return i;
通常,在 if (...)
、for (...)
和 while (...)
之后始终缩进语句是个好主意。
if (i < 0) error("negative argument");
if (i < 0)
error("negative argument");
使用工具。
如果名称反映的是类型而不是功能,那么更改提供该功能的类型就会变得困难。此外,如果变量的类型发生更改,使用它的代码也需要修改。尽量减少意外的转换。
void print_int(int i);
void print_string(const char*);
print_int(1); // repetitive, manual type matching
print_string("xyzzy"); // repetitive, manual type matching
void print(int i);
void print(string_view); // also works on any string-like sequence
print(1); // clear, automatic type matching
print("xyzzy"); // clear, automatic type matching
编码类型的名称要么冗长,要么晦涩难懂。
printS // print a std::string
prints // print a C-style string
printi // print an int
在未类型化的语言中使用像匈牙利命名法这样的技术来编码类型,但在像 C++ 这样强静态类型的语言中,这通常是不必要的,并且是有害的,因为注释会过时(疣就像注释一样,也会像注释一样腐烂),并且它们会干扰对语言的良好使用(使用相同的名称和重载解析)。
一些风格使用非常通用的(非类型特定的)前缀来表示变量的通用用途。
auto p = new User();
auto p = make_unique<User>();
// note: "p" is not being used to say "raw pointer to type User,"
// just generally to say "this is an indirection"
auto cntHits = calc_total_of_hits(/*...*/);
// note: "cnt" is not being used to encode a type,
// just generally to say "this is a count of something"
这没有害处,也不属于本指南,因为它不编码类型信息。
一些风格区分成员、局部变量和/或全局变量。
struct S {
int m_;
S(int m) : m_{abs(m)} { }
};
这没有害处,也不属于本指南,因为它不编码类型信息。
与 C++ 一样,一些风格区分类型和非类型。例如,通过大写类型名称,但不区分函数和变量的名称。
typename<typename T>
class HashTable { // maps string to T
// ...
};
HashTable<int> index;
这没有害处,也不属于本指南,因为它不编码类型信息。
理由:作用域越大,混淆和意外名称冲突的可能性就越大。
double sqrt(double x); // return the square root of x; x must be non-negative
int length(const char* p); // return the number of characters in a zero-terminated C-style string
int length_of_string(const char zero_terminated_array_of_char[]) // bad: verbose
int g; // bad: global variable with a cryptic name
int open; // bad: global variable with a short, popular name
在受限作用域中使用 p
表示指针,使用 x
表示浮点变量是惯例且不会引起混淆。
???
理由:命名和命名风格的一致性会提高可读性。
存在许多风格,当您使用多个库时,您无法遵循它们所有不同的约定。选择一种“内部风格”,但将“导入”的库保留其原始风格。
ISO 标准,仅使用小写字母和数字,用下划线分隔单词
int
vector
my_map
避免标识符名称包含双下划线 __
或以单下划线后跟大写字母(例如 _Throws
)开头。此类标识符保留给 C++ 实现。
Stroustrup:ISO 标准,但使用大写字母表示您自己的类型和概念
int
vector
My_map
CamelCase:在多词标识符中大写每个单词
int
vector
MyMap
myMap
一些约定大写第一个字母,有些则不。
尝试在您的缩写词和标识符长度使用上保持一致
int mtbf {12};
int mean_time_between_failures {12}; // make up your mind
可能可以实现,但存在使用具有不同约定的库的问题。
ALL_CAPS
用于宏名称以避免将宏与遵守作用域和类型规则的名称混淆。
void f()
{
const int SIZE{1000}; // Bad, use 'size' instead
int v[SIZE];
}
特别是,这可以避免将宏与非宏符号常量混淆(另请参阅 Enum.5:不要对枚举数使用 ALL_CAPS
)
enum bad { BAD, WORSE, HORRIBLE }; // BAD
ALL_CAPS
非宏名称underscore_style
名称使用下划线分隔名称的各个部分是原始的 C 和 C++ 风格,并在 C++ 标准库中使用。
此规则是仅在您有选择时使用的默认设置。通常,您没有选择,必须遵循已建立的风格以实现一致性。一致性的需求胜过个人品味。
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
Stroustrup:ISO 标准,但使用大写字母表示您自己的类型和概念
int
vector
My_map
不可能。
可读性。
使用数字分隔符避免长串数字
auto c = 299'792'458; // m/s2
auto q2 = 0b0000'1111'0000'0000;
auto ss_number = 123'456'7890;
在需要澄清的地方使用字面量后缀
auto hello = "Hello!"s; // a std::string
auto world = "world"; // a C-style string
auto interval = 100ms; // using <chrono>
字面量不应作为“魔术常数”随意散布在代码中,但在定义它们的地方仍然应该使它们可读。在一长串整数中很容易输入错误。
标记长数字序列。麻烦在于定义“长”;也许是 7。
过多的空格会使文本变大并分散注意力。
#include < map >
int main(int argc, char * argv [ ])
{
// ...
}
#include <map>
int main(int argc, char* argv[])
{
// ...
}
一些 IDE 有自己的看法,并添加分散注意力的空格。
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
我们认为恰当的空格对可读性大有帮助。只是不要过度。
成员的常规顺序可以提高可读性。
在声明类时,请使用以下顺序
using
)使用 public
在 protected
之前,protected
在 private
之前。
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
class X {
public:
// interface
protected:
// unchecked function for use by derived class implementations
private:
// implementation details
};
有时,成员的默认顺序与分离公共接口与实现细节的愿望相冲突。在这种情况下,私有类型和函数可以与私有数据放在一起。
class X {
public:
// interface
protected:
// unchecked function for use by derived class implementations
private:
// implementation details (types, functions, and data)
};
避免将一种访问(例如 public
)的声明块分散在具有不同访问(例如 private
)的声明块之间。
class X { // bad
public:
void f();
public:
int g();
// ...
};
用于声明成员组的宏通常会导致违反任何排序规则。然而,使用宏会掩盖实际表达的内容。
标记与建议顺序的偏离。会有很多不遵循此规则的旧代码。
这是原始的 C 和 C++ 布局。它能很好地保留垂直空间。它能很好地区分不同的语言构造(如函数和类)。
在 C++ 的背景下,这种风格通常被称为“Stroustrup”。
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
struct Cable {
int x;
// ...
};
double foo(int x)
{
if (0 < x) {
// ...
}
switch (x) {
case 0:
// ...
break;
case amazing:
// ...
break;
default:
// ...
break;
}
if (0 < x)
++x;
if (x < 0)
something();
else
something_else();
return some_value;
}
注意 if
和 (
之间的空格
为 if
的分支和 for
的主体使用单独的行。
用于 class
和 struct
的 {
不在新的一行上,但函数的 {
在新的一行上。
大写您的用户定义类型的名称,以将其与标准库类型区分开。
不要大写函数名称。
如果您需要强制执行,请使用 IDE 进行重新格式化。
C 风格的布局强调在表达式和语法中使用,而 C++ 风格强调类型。表达式中的使用参数对引用不成立。
T& operator[](size_t); // OK
T &operator[](size_t); // just strange
T & operator[](size_t); // undecided
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
面对历史是不可能的。
可读性。并非所有人都有屏幕和打印机能轻松区分所有字符。我们很容易混淆拼写相似或轻微拼写错误的单词。
int oO01lL = 6; // bad
int splunk = 7;
int splonk = 8; // bad: splunk and splonk are easily confused
???
可读性。当一行上有多个语句时,很容易忽略一个语句。
int x = 7; char* p = 29; // don't
int x = 7; f(x); ++x; // don't
简单。
可读性。最大程度地减少与声明符语法的混淆。
有关详细信息,请参阅ES.10。
void
用作参数类型它很冗长,并且仅在需要 C 兼容性时才需要。
void f(void); // bad
void g(); // better
即使 Dennis Ritchie 也认为 void f(void)
是个畸形。您可以在 C 中为这个畸形找理由,因为函数原型很少见,所以禁止
int f();
f(1, 2, "weird but valid C89"); // hope that f() is defined int f(a, b, c) char* c; { /* ... */ }
会导致重大问题,但在 21 世纪的 C++ 中则不然。
const
表示法常规表示法对更多程序员来说更熟悉。大型代码库的一致性。
const int x = 7; // OK
int const y = 9; // bad
const int *const p = nullptr; // OK, constant pointer to constant int
int const *const p = nullptr; // bad, constant pointer to constant int
我们清楚地知道,您可以声称“糟糕”的示例比被标记为“OK”的示例更符合逻辑,但它们也使更多人感到困惑,尤其是依赖使用更常见、更常规的 OK 风格的教学材料的新手。
一如既往,请记住这些命名和布局规则的目的是保持一致性,而美学则差异很大。
这是对在您没有约束或更好想法时的建议。此规则是在收到许多指导请求后添加的。
标记用作类型后缀的 const
。
.cpp
后缀,对接口文件使用 .h
后缀这是一个长期存在的约定。但一致性更重要,因此如果您的项目使用其他方式,请遵循该方式。
此约定反映了一种常见的用法模式:头文件通常与 C 共享,以 C++ 和 C 编译,后者通常使用 .h
,并且命名所有头文件为 .h
比仅为那些打算与 C 共享的头文件使用不同的扩展名更容易。另一方面,实现文件很少与 C 共享,因此通常应与 .c
文件区分开,因此通常最好将所有 C++ 实现文件命名为其他名称(例如 .cpp
)。
不需要特定的名称 .h
和 .cpp
(仅推荐为默认值),并且其他名称也在广泛使用。例如 .hh
、.C
和 .cxx
。同等使用这些名称。在本文档中,我们将 .h
和 .cpp
用作头文件和实现文件的简写,即使实际扩展名可能不同。
您的 IDE(如果您使用的话)可能对后缀有强烈意见。
// foo.h:
extern int a; // a declaration
extern void foo();
// foo.cpp:
int a; // a definition
void foo() { ++a; }
foo.h
提供了 foo.cpp
的接口。最好避免全局变量。
// foo.h:
int a; // a definition
void foo() { ++a; }
在程序中两次 #include <foo.h>
会导致链接器因两次单一定义规则冲突而报错。
.h
和 .cpp
(及等效文件)是否遵循以下规则。本节涵盖了对这些指南的常见问题的解答。
请参阅本页面顶部。这是一个开源项目,旨在维护编写 C++ 代码的现代权威指南,使用当前 C++ 标准。该指南旨在现代化,尽可能机器可强制执行,并对贡献和分叉开放,以便组织可以轻松地将其纳入其公司编码指南。
Bjarne Stroustrup 在其 CppCon 2015 开幕主题演讲《编写良好的 C++14》中公布了此内容。另请参阅随附的 isocpp.org 博客文章,以及关于类型和内存安全指南理由的 Herb Sutter 的后续 CppCon 2015 演讲《编写良好的 C++14……默认情况下》。
最初的主要作者和维护者是 Bjarne Stroustrup 和 Herb Sutter,迄今为止的指南是与来自 CERN、Microsoft、Morgan Stanley 和其他几个组织的专家的贡献共同开发的。在发布时,该指南处于“0.6”状态,并欢迎贡献。正如 Stroustrup 在其公告中所说:“我们需要帮助!”
请参阅CONTRIBUTING.md。我们感谢志愿者的帮助!
通过首先做出大量贡献,并使您的贡献具有一致的质量并得到认可。请参阅CONTRIBUTING.md。我们感谢志愿者的帮助!
否。这些指南超出了标准范围。它们旨在服务于标准,并作为关于如何有效使用现代标准 C++ 的当前指南进行维护。我们的目标是随着委员会的演变,使其与标准保持同步。
github.com/isocpp
下?因为 isocpp
是标准 C++ 基金会;委员会的存储库位于 github.com/cplusplus 下。必须有一个中立的组织拥有版权和许可,以明确这不是由任何一个人或供应商主导的。自然实体是基金会,它致力于促进现代标准 C++ 的使用和最新理解以及委员会的工作。这遵循了 isocpp.org 在 C++ FAQ 中的模式,该 FAQ 最初由 Bjarne Stroustrup、Marshall Cline 和 Herb Sutter 完成,并以相同的方式贡献给了开放项目。
否。这些指南是关于如何最好地使用现代标准 C++,并假设您拥有现代兼容的编译器来编写代码。
否。这些指南是关于如何最好地使用现代标准 C++,它们仅限于推荐这些特性。
这些编码标准是使用 CommonMark 和 <a>
HTML 锚点编写的。
我们正在考虑以下来自GitHub Flavored Markdown (GFM) 的扩展
避免其他 HTML 标签和其他扩展。
注意:我们尚未在此样式上保持一致。
GSL 是这些指南中指定的一小组类型和别名。截至此时,它们在此处的规范还很稀疏;我们计划添加一个 WG21 风格的接口规范,以确保不同实现之间的一致性,并作为贡献提案以供标准化,通常会根据委员会决定接受/改进/更改/拒绝的内容。
否。那只是 Microsoft 贡献的第一个实现。鼓励其他供应商的其他实现,以及对该实现的分叉和贡献。截至公开项目一周后的此时,至少已经存在一个 GPLv3 开源实现。我们计划提供一个 WG21 风格的接口规范,以确保不同实现之间的一致性。
我们不愿青睐某一个特定的实现,因为我们不想让人们认为只有一个实现,并无意中扼杀并行实现。而且,如果这些指南包含实际的实现,那么贡献者可能会被错误地视为过于有影响力。我们更愿意遵循委员会的长期方法,即指定接口,而不是实现。但同时我们也希望至少有一个可用的实现;我们希望有很多。
因为我们想立即使用它们,而且因为它们是临时的,我们希望一旦标准库中存在满足相同需求的类型,就将它们弃用。
否。GSL 的存在仅是为了提供一些当前不在标准库中的类型和别名。如果委员会决定采用标准版本(这些类型或其他满足相同需求的类型),那么它们就可以从 GSL 中移除。
span<char>
与库基础 1 技术规范和 C++17 工作草案中的 string_view
不同?为什么不直接使用委员会批准的 string_view
?C++ 标准库视图的分类共识是,“视图”意味着“只读”,“span”意味着“读/写”。如果您只需要一个不需要保证边界检查的只读字符视图,并且您有 C++17,请使用 C++17 的 std::string_view
。否则,如果您需要一个不需要保证边界检查的读/写视图,并且您有 C++20,请使用 C++20 的 std::span<char>
。否则,请使用 gsl::span<char>
。
owner
是否与提议的 observer_ptr
相同?否。owner
拥有所有权,是一个别名,可以应用于任何间接类型。 observer_ptr
的主要意图是表示一个*非*拥有指针。
stack_array
是否与标准 array
相同?否。stack_array
保证在栈上分配。尽管 std::array
将其存储直接包含在自身内部,但 array
对象可以放置在任何地方,包括堆。
dyn_array
是否与 vector
或提议的 dynarray
相同?否。dyn_array
是一个容器,类似于vector
,但它的大小是固定的;它的尺寸在运行时构建时就确定了。它是一种安全引用动态“堆”分配的固定大小数组的方法。与vector
不同,它的目的是取代数组new[]
。与委员会提出的dynarray
不同,它并不预设编译器/语言的魔法能在其作为栈分配对象的成员时,自动将其分配在栈上;它仅仅是引用一个“动态”的或基于堆的数组。
Expects
与 assert
相同吗?否。它是语言支持契约前置条件的占位符。
Ensures
与 assert
相同吗?否。它是语言支持契约后置条件的占位符。
本节列出了推荐的库,并明确推荐了其中一些。
??? 适合一般性指南吗?我认为不是 ???
理想情况下,我们遵循所有代码的所有规则。现实情况是,我们必须处理大量的旧代码
如果我们有数百万行新代码,那么“一次性全部更改”的想法通常是不现实的。因此,我们需要一种逐渐现代化代码库的方法。
将旧代码升级到现代风格可能是一项艰巨的任务。通常,旧代码既混乱(难以理解)又工作正常(符合当前的使用范围)。通常,原始程序员已不在,且测试用例不完整。代码混乱的事实大大增加了进行任何更改所需的努力和引入错误的风险。经常,混乱的旧代码运行速度会不必要地变慢,因为它需要过时的编译器并且无法利用现代硬件。在许多情况下,自动化“现代化工具”支持将是重大升级工作的必需品。
现代化代码的目的是简化添加新功能、方便维护、提高性能(吞吐量或延迟),并更好地利用现代硬件。使代码“看起来漂亮”或“遵循现代风格”本身并不是更改的原因。每一次更改都伴随着风险,而过时的代码库则意味着成本(包括机会成本)。成本的降低必须大于风险。
但是如何做?
没有一种方法可以现代化代码。最佳做法取决于代码、更新的压力、开发人员的背景以及可用的工具。这里有一些(非常通用的)想法
span
,都不能按模块进行。无论您选择哪种方式,请注意,最大的优势来自于最高程度地遵循指南。指南不是随机的、不相关的规则集,您不能随机选择和匹配期望成功。
我们非常希望听到有关经验和使用的工具。在分析工具甚至代码转换工具的支持下,现代化可以更快、更简单、更安全。
本节包含有关规则和规则集的后续材料。特别是,在这里我们展示了进一步的理由、更长的示例以及对替代方案的讨论。
数据成员的初始化顺序始终与其在类定义中的声明顺序一致,因此在构造函数的初始化列表中也按此顺序编写。以不同的顺序编写只会使代码混乱,因为它不会按照您看到的方式运行,并且这会使查看与顺序相关的错误变得困难。
class Employee {
string email, first, last;
public:
Employee(const char* firstName, const char* lastName);
// ...
};
Employee::Employee(const char* firstName, const char* lastName)
: first(firstName),
last(lastName),
// BAD: first and last not yet constructed
email(first + "." + last + "@acme.com")
{}
在此示例中,email
将在 first
和 last
之前构造,因为它是第一个声明的。这意味着它的构造函数将尝试过早地使用 first
和 last
— 不仅仅是在它们被设置为期望值之前,而是在它们被构造之前。
如果类定义和构造函数体位于不同的文件中,那么数据成员声明顺序对构造函数正确性的长距离影响将更难发现。
参考文献:
[Cline99] §22.03-11, [Dewhurst03] §52-53, [Koenig97] §4, [Lakos96] §10.3.5, [Meyers97] §13, [Murray93] §2.1.3, [Sutter00] §47
=
、{}
和 ()
作为初始化器???
如果您的设计希望在基类构造函数或析构函数中进行派生类虚拟分派以调用函数如f
和g
,您需要其他技术,例如构造后函数 — 调用者必须调用的单独成员函数来完成初始化,它可以通过成员函数安全地调用f
和g
,因为虚拟调用在此类函数中表现正常。这里展示了一些技术(参见参考文献)。以下是一个不详尽的选项列表
以下是最后一个选项的示例
class B {
public:
B()
{
/* ... */
f(); // BAD: C.82: Don't call virtual functions in constructors and destructors
/* ... */
}
virtual void f() = 0;
};
class B {
protected:
class Token {};
public:
// constructor needs to be public so that make_shared can access it.
// protected access level is gained by requiring a Token.
explicit B(Token) { /* ... */ } // create an imperfectly initialized object
virtual void f() = 0;
template<class T>
static shared_ptr<T> create() // interface for creating shared objects
{
auto p = make_shared<T>(typename T::Token{});
p->post_initialize();
return p;
}
protected:
virtual void post_initialize() // called right after construction
{ /* ... */ f(); /* ... */ } // GOOD: virtual dispatch is safe
}
};
class D : public B { // some derived class
protected:
class Token {};
public:
// constructor needs to be public so that make_shared can access it.
// protected access level is gained by requiring a Token.
explicit D(Token) : B{ B::Token{} } {}
void f() override { /* ... */ };
protected:
template<class T>
friend shared_ptr<T> B::create();
};
shared_ptr<D> p = D::create<D>(); // creating a D object
此设计需要以下约束
D
这样的派生类不能暴露公开调用的构造函数。否则,D
的用户可以创建不调用post_initialize
的D
对象。operator new
。B
可以重载new
(参见SuttAlex05中的项目45和46)。D
必须定义一个具有与B
选择的参数相同的构造函数。但是,定义create
的多个重载可以缓解这个问题;并且重载甚至可以基于参数类型进行模板化。如果满足上述要求,则设计保证post_initialize
已为任何完全构造的B
派生对象调用。 post_initialize
不需要是虚拟的;但是,它可以自由地调用虚拟函数。
总之,没有完美的构造后技术。最糟糕的技术是通过简单地要求调用者手动调用构造后函数来回避整个问题。即使是最好的技术也需要不同的构造对象语法(易于在编译时检查)和/或派生类作者的合作(无法在编译时检查)。
参考文献:[Alexandrescu01] §3, [Boost], [Dewhurst03] §75, [Meyers97] §46, [Stroustrup00] §15.4.3, [Taligent94]
销毁是否应该虚拟?也就是说,是否允许通过基类指针进行销毁?如果允许,那么base
的析构函数必须是公共的才能被调用,并且是虚拟的,否则调用它会导致未定义行为。否则,它应该被保护起来,以便只有派生类可以在自己的析构函数中调用它,并且是非虚拟的,因为它不需要虚拟行为。
基类的常见情况是它旨在具有公共派生类,因此调用代码几乎肯定会使用类似shared_ptr<base>
的东西。
class Base {
public:
~Base(); // BAD, not virtual
virtual ~Base(); // GOOD
// ...
};
class Derived : public Base { /* ... */ };
{
unique_ptr<Base> pb = make_unique<Derived>();
// ...
} // ~pb invokes correct destructor only when ~Base is virtual
在较少的情况下,例如策略类,该类被用作基类是为了方便,而不是为了多态行为。建议使这些析构函数成为受保护的非虚拟。
class My_policy {
public:
virtual ~My_policy(); // BAD, public and virtual
protected:
~My_policy(); // GOOD
// ...
};
template<class Policy>
class customizable : Policy { /* ... */ }; // note: private inheritance
这个简单的指南说明了一个微妙的问题,并反映了继承和面向对象设计原则的现代用法。
对于基类Base
,调用代码可能会尝试通过Base
的指针销毁派生对象,例如在使用unique_ptr<Base>
时。如果Base
的析构函数是公共的且非虚拟的(默认),它可能会在实际上指向派生对象的指针上被意外调用,在这种情况下,尝试删除的行为是未定义的。这种情况导致旧的代码标准强制要求所有基类析构函数都必须是虚拟的。这有点过度(即使它是常见情况);相反,规则应该是仅当基类析构函数是公共的时才使其虚拟。
编写基类就是定义一个抽象(参见项目35至37)。回想一下,对于参与该抽象的每个成员函数,您都需要决定
Base
指针的调用者公开,还是应该是隐藏的内部实现细节。如项目39所述,对于正常的成员函数,选择是在允许通过Base
指针非虚拟地调用它(但如果它调用虚拟函数,如在NVI或模板方法模式中,则可能具有虚拟行为),虚拟地调用它,还是根本不调用。
销毁可以看作是另一个操作,尽管它具有使非虚拟调用危险或错误的特殊语义。因此,对于基类析构函数,选择是在允许通过Base
指针虚拟地调用它,还是根本不调用;“非虚拟”不是一个选项。因此,基类析构函数是虚拟的,如果它可以被调用(即是公共的),否则是非虚拟的。
请注意,NVI模式不能应用于析构函数,因为构造函数和析构函数不能进行深度虚拟调用。(参见项目39和55。)
推论:编写基类时,请务必显式编写析构函数,因为隐式生成的析构函数是公共的且非虚拟的。如果默认体可以,您总是可以=default
实现,而您只是编写函数来赋予它正确的可见性和虚拟性。
一些组件架构(例如COM和CORBA)不使用标准的删除机制,并促进对象处置的不同协议。遵循本地模式和习惯用法,并酌情调整此指南。
也请考虑这个罕见的案例
B
既是基类也是具体类,可以独立实例化,因此析构函数必须是公共的,以便B
对象可以创建和销毁。B
也没有虚拟函数,也不打算以多态方式使用,所以即使析构函数是公共的,它也不需要是虚拟的。然后,即使析构函数必须是公共的,也可能存在不将其设为虚拟的巨大压力,因为作为第一个虚拟函数,它将带来所有运行时类型开销,而添加的功能可能永远不需要。
在这种罕见情况下,您可以使析构函数公共且非虚拟,但要清楚地记录进一步派生的对象不得以多态方式作为B
的对象使用。这正是std::unary_function
所做的。
总的来说,然而,要避免具体基类(参见项目35)。例如,unary_function
是一个仅包含typedef的集合,从未打算独立实例化。给它一个公共析构函数几乎没有意义;更好的设计是遵循此项目建议,并为其提供受保护的非虚拟析构函数。
参考文献:[SuttAlex05]项目50, [Cargill92] pp. 77-79, 207, [Cline99] §21.06, 21.12-13, [Henricson97] pp. 110-114, [Koenig97] Chapters 4, 11, [Meyers97] §14, [Stroustrup00] §12.4.2, [Sutter02] §27, [Sutter04] §18
???
绝不允许从析构函数、资源解分配函数(例如 operator delete
)或通过 throw
报告错误的 swap
函数。如果这些操作失败,几乎不可能编写有用的代码,即使确实发生了问题,重试也几乎没有意义。具体来说,析构函数可能抛出异常的类型被明确禁止与 C++ 标准库一起使用。大多数析构函数现在默认隐式地是 noexcept
。
class Nefarious {
public:
Nefarious() { /* code that could throw */ } // ok
~Nefarious() { /* code that could throw */ } // BAD, should not throw
// ...
};
Nefarious
对象即使作为局部变量也很难安全使用
void test(string& s)
{
Nefarious n; // trouble brewing
string copy = s; // copy the string
} // destroy copy and then n
在这里,复制 s
可能会抛出,如果它抛出,而 n
的析构函数也抛出,程序将通过 std::terminate
退出,因为无法同时传播两个异常。
带有 Nefarious
成员或基类的类也很难安全使用,因为它们的析构函数必须调用 Nefarious
的析构函数,并且同样因其不良行为而受到影响。
class Innocent_bystander {
Nefarious member; // oops, poisons the enclosing class's destructor
// ...
};
void test(string& s)
{
Innocent_bystander i; // more trouble brewing
string copy2 = s; // copy the string
} // destroy copy and then i
在这里,如果构造 copy2
抛出,我们会遇到同样的问题,因为 i
的析构函数现在也可以抛出,如果抛出,我们将调用 std::terminate
。
您也无法可靠地创建全局或静态 Nefarious
对象
static Nefarious n; // oops, any destructor exception can't be caught
您无法可靠地创建 Nefarious
的数组
void test()
{
std::array<Nefarious, 10> arr; // this line can std::terminate()
}
在析构函数抛出异常的情况下,数组的行为是未定义的,因为没有合理的回滚行为可以被设计出来。试想一下:编译器如何为构造 arr
生成代码,其中如果第四个对象的构造函数抛出,代码就必须放弃,并在其清理模式下尝试调用已构造对象的析构函数……而其中一个或多个析构函数也抛出?没有令人满意的答案。
您无法在标准容器中使用 Nefarious
对象
std::vector<Nefarious> vec(10); // this line can std::terminate()
标准库禁止所有用于它的析构函数抛出异常。您不能将 Nefarious
对象存储在标准容器中,也不能将它们与标准库的任何其他部分一起使用。
这些是必须不失败的关键函数,因为它们对于事务性编程中的两个关键操作至关重要:在处理过程中出现问题时回滚工作,以及在没有问题时提交工作。如果没有办法使用非失败操作安全地回滚,那么就无法实现非失败回滚。如果没有办法使用非失败操作(特别是但不限于 swap
)安全地提交状态更改,那么就无法实现非失败提交。
考虑 C++ 标准中发现的以下建议和要求
如果在堆栈展开期间调用的析构函数以异常退出,则会调用 terminate (15.5.1)。因此,析构函数通常应捕获异常,不要让它们传播出析构函数。—[C++03] §15.2(3)
C++ 标准库中定义的任何析构函数操作(包括用于实例化标准库模板的任何类型的析构函数)都不会抛出异常。—[C++03] §17.4.4.8(3)
解分配函数,包括专门重载的 operator delete
和 operator delete[]
,属于同一类别,因为它们也用于通用清理,特别是在异常处理期间,用于回滚需要撤销的部分工作。除了析构函数和解分配函数之外,常见的错误安全技术还依赖于 swap
操作从不失败 — 在这种情况下,不是因为它们用于实现保证的回滚,而是因为它们用于实现保证的提交。例如,以下是一个类型的 operator=
的惯用实现 T
,它执行复制构造然后调用无失败 swap
T& T::operator=(const T& other)
{
auto temp = other;
swap(temp);
return *this;
}
(另见项目56。???)
幸运的是,在释放资源时,失败的范围肯定更小。如果使用异常作为错误报告机制,请确保此类函数处理其内部处理可能生成的任何异常和其他错误。(对于异常,只需将您的析构函数执行的所有敏感操作包装在 try/catch(...)
块中。)这一点尤其重要,因为析构函数可能在危机情况下被调用,例如未能分配系统资源(例如,内存、文件、锁、端口、窗口或其他系统对象)。
当使用异常作为错误处理机制时,请始终通过声明这些函数 noexcept
来记录此行为。(参见项目75。)
参考文献:[SuttAlex05]项目51;[C++03] §15.2(3), §17.4.4.8(3), [Meyers96] §11, [Stroustrup00] §14.4.7, §E.2-4, [Sutter00] §8, §16, [Sutter02] §18-19
???
如果您定义了复制构造函数,则还必须定义复制赋值运算符。
如果您定义了移动构造函数,则还必须定义移动赋值运算符。
class X {
public:
X(const X&) { /* stuff */ }
// BAD: failed to also define a copy assignment operator
X(x&&) noexcept { /* stuff */ }
// BAD: failed to also define a move assignment operator
// ...
};
X x1;
X x2 = x1; // ok
x2 = x1; // pitfall: either fails to compile, or does something suspicious
如果您定义了析构函数,则不应使用编译器生成的复制或移动操作;您可能需要定义或禁止复制和/或移动。
class X {
HANDLE hnd;
// ...
public:
~X() { /* custom stuff, such as closing hnd */ }
// suspicious: no mention of copying or moving -- what happens to hnd?
};
X x1;
X x2 = x1; // pitfall: either fails to compile, or does something suspicious
x2 = x1; // pitfall: either fails to compile, or does something suspicious
如果您定义了复制,并且任何基类或成员的类型定义了移动操作,则您也应定义移动操作。
class X {
string s; // defines more efficient move operations
// ... other data members ...
public:
X(const X&) { /* stuff */ }
X& operator=(const X&) { /* stuff */ }
// BAD: failed to also define a move construction and move assignment
// (why wasn't the custom "stuff" repeated here?)
};
X test()
{
X local;
// ...
return local; // pitfall: will be inefficient and/or do the wrong thing
}
如果您定义了复制构造函数、复制赋值运算符或析构函数中的任何一个,您可能也应该定义其他。
如果您需要定义这五个函数中的任何一个,这意味着您需要它执行比默认行为更多的工作 — 这五个函数是不对称关联的。方法如下:
在许多情况下,使用 RAII“拥有”对象正确封装资源可以消除自己编写这些操作的需要。(参见项目13。)
优先使用编译器生成的(包括=default
)特殊成员;只有这些可以被归类为“平凡”的,并且至少有一家主要的标准库供应商对具有平凡特殊成员的类进行了大量优化。这很可能成为普遍的做法。
例外:当任何特殊函数仅被声明为非公共或虚拟,但没有特殊语义时,这并不意味着其他函数是必需的。在极少数情况下,具有奇怪类型成员(如引用成员)的类是一个例外,因为它们具有特殊的复制语义。在持有引用的类中,您可能需要编写复制构造函数和赋值运算符,但默认析构函数已经做了正确的事情。(请注意,使用引用成员几乎总是错误的。)
参考文献:[SuttAlex05]项目52;[Cline99] §30.01-14, [Koenig97] §4, [Stroustrup00] §5.5, §10.4, [SuttHysl04b]
资源管理规则摘要
防止泄漏。泄漏可能导致性能下降、难以理解的错误、系统崩溃和安全漏洞。
替代表述:让每项资源都表示为某个管理其生命周期的类的对象。
template<class T>
class Vector {
private:
T* elem; // sz elements on the free store, owned by the class object
int sz;
// ...
};
该类是资源句柄。它管理T
的生命周期。为此,Vector
必须定义或删除复制、移动和销毁操作。
??? "odd" non-memory resource ???
防止泄漏的基本技术是让每项资源都由具有合适析构函数的资源句柄拥有。检查器可以找到“裸露的new
”。给定 C 风格分配函数的列表(例如 fopen()
),检查器还可以找到未被资源句柄管理的用法。一般来说,“裸露指针”可能会引起怀疑、被标记和/或分析。不带人工输入(“资源”的定义必然过于宽泛)就无法生成资源的完整列表,但可以“参数化”一个工具,使其带有一个资源列表。
那将是泄漏。
void f(int i)
{
FILE* f = fopen("a file", "r");
ifstream is { "another file" };
// ...
if (i == 0) return;
// ...
fclose(f);
}
如果 i == 0
,则 a file
的文件句柄将被泄漏。另一方面,another file
的 ifstream
将在其销毁时正确关闭其文件。如果您必须使用显式指针,而不是具有特定语义的资源句柄,请使用 unique_ptr
或带自定义删除器的 shared_ptr
。
void f(int i)
{
unique_ptr<FILE, int(*)(FILE*)> f(fopen("a file", "r"), fclose);
// ...
if (i == 0) return;
// ...
}
更好的方式
void f(int i)
{
ifstream input {"a file"};
// ...
if (i == 0) return;
// ...
}
检查器必须认为所有“裸露指针”都可疑。检查器可能必须依赖人工提供的资源列表。首先,我们知道标准库容器、string
和智能指针。使用 span
和 string_view
应该会有很大帮助(它们不是资源句柄)。
以便能够区分所有者和视图。
这与您如何“拼写”指针无关:T*
, T&
, Ptr<T>
和 Range<T>
都不是所有者。
避免极其难以找到的错误。解引用这样的指针是未定义行为,可能导致类型系统冲突。
string* bad() // really bad
{
vector<string> v = { "This", "will", "cause", "trouble", "!" };
// leaking a pointer into a destroyed member of a destroyed object (v)
return &v[0];
}
void use()
{
string* p = bad();
vector<int> xx = {7, 8, 9};
// undefined behavior: x might not be the string "This"
string x = *p;
// undefined behavior: we don't know what (if anything) is allocated a location p
*p = "Evil!";
}
v
的 string
s 在 bad()
退出时被销毁,v
本身也是如此。返回的指针指向自由存储中的未分配内存。当执行 *p
时,这块内存(被 p
指向)可能已被重新分配。可能没有 string
可读,通过 p
的写入很容易损坏不相关类型的对象。
大多数编译器已经警告了简单的情况,并且拥有做更多事情所需的信息。请将函数返回的任何指针视为可疑。使用容器、资源句柄和视图(例如,已知不是资源句柄的 span
)来减少需要检查的案例数量。首先,将任何带有析构函数的类视为资源句柄。
以提供静态类型安全的元素操作。
template<typename T> class Vector {
// ...
T* elem; // point to sz elements of type T
int sz;
};
简化代码并消除显式内存管理的需求。将对象带入周围作用域,从而延长其生命周期。
vector<int> get_large_vector()
{
return ...;
}
auto v = get_large_vector(); // return by value is ok, most modern compilers will do copy elision
请参阅 F.20 中的例外情况。
检查从函数返回的指针和引用,并查看它们是否已分配给资源句柄(例如,分配给 unique_ptr
)。
以便对资源的生命周期进行完全控制。以便对资源提供一组一致的操作。
??? Messing with pointers
如果所有成员都是资源句柄,则尽可能依赖编译器生成的操作。
template<typename T> struct Named {
string name;
T value;
};
现在,假定 T
拥有默认构造函数、析构函数以及高效的复制和移动操作,Named
也拥有这些。
一般来说,工具无法知道一个类是否是资源句柄。然而,如果一个类拥有 某些默认操作,那么它就应该拥有所有这些操作;如果一个类拥有一个成员,而该成员是一个资源句柄,那么这个类就应该被视为资源句柄。
通常需要一组初始元素。
template<typename T> class Vector {
public:
Vector(std::initializer_list<T>);
// ...
};
Vector<string> vs { "Nygaard", "Ritchie" };
类何时是容器? ???
本节包含支持采纳 C++ 核心指南的工具列表。此列表并非旨在提供一份有助于编写良好 C++ 代码的工具的详尽列表。如果一个工具专门用于支持 C++ 核心指南并链接到 C++ 核心指南,则可以将其包含在内。
Clang-tidy 包含一组专门强制执行 C++ 核心指南的规则。这些规则的命名模式为 cppcoreguidelines-*
。
Microsoft 编译器的 C++ 代码分析包含一组专门用于强制执行 C++ 核心指南的规则。
指南中使用的术语的相对非正式定义(基于《C++ 编程原理与实践》的术语表)。
有关 C++ 的许多主题的更多信息,请访问 标准 C++ 基金会的网站。
[0:max)
。final
虚函数),并且该类型的对象打算仅通过间接方式(例如,通过指针)使用。[严格来说,“基类”可以定义为“我们从中派生的东西”,但我们在这里是根据类设计者的意图来指定的。]通常,基类有一个或多个虚函数。while
语句。[0:5)
表示值 0、1、2、3 和 4。std::regular
概念)。复制后,复制的对象与原始对象进行相等比较。正则类型表现得类似于内置类型,如 int
,并且可以使用 ==
进行比较。特别是,正则类型的对象可以被复制,并且复制的结果是一个独立的、与原始对象值相等的对象。另请参见半正则类型。std::semiregular
概念)。复制的结果是一个具有与原始对象相同的值的独立对象。半正则类型大致上类似于 int
这样的内置类型,但可能没有 ==
运算符。另请参见正则类型。这是我们的待办事项列表。最终,这些条目将成为规则或规则的一部分。或者,我们将决定不需要更改并删除该条目。
std::literals::*_literals
)?void*
的公共接口的人都应该把他们的脚趾烧掉。这几年来,这一个一直是我个人的最爱。:)const_iterators
auto
(size)
与 {initializers}
与 {Extent{size}}
std::function
) vs. CRTP/静态?是的,也许甚至与标签分派相比?std::bind
,Stephen T. Lavavej 非常批评它,以至于我开始怀疑它是否真的会在未来消失。应该推荐 lambda 吗?p = (s1 + s2).c_str();
迭代器/指针失效导致悬空指针
void bad()
{
int* p = new int[700];
int* q = &p[7];
delete p;
vector<int> v(700);
int* q2 = &v[7];
v.resize(900);
// ... use q and q2 ...
}
避免静态类成员变量(竞态条件,几乎全局的变量)
lock_guard
、unique_lock
、shared_lock
),切勿直接调用 mutex.lock
和 mutex.unlock
(RAII)std::terminate
如果未加入或分离……是否有分离线程的好理由?)—— ??? 能否提供 std::thread
的 RAII 包装器?std::lock
(或其他死锁避免算法?)condition_variable
时,始终用互斥锁保护条件(在互斥锁外部设置的原子布尔值是错误的!),并为条件变量本身使用相同的互斥锁。atomic_compare_exchange_strong
用于 std::atomic<user-defined-struct>
(填充差异很重要,而循环中的 compare_exchange_weak
会收敛到稳定的填充)shared_future
对象不是线程安全的:两个线程不能等待同一个 shared_future
对象(它们可以等待指向相同共享状态的 shared_future
的副本)单独的 shared_ptr
对象不是线程安全的:不同线程可以在指向相同共享对象的 *不同* shared_ptr
上调用非 const
成员函数,但一个线程不能在另一个线程访问同一 shared_ptr
对象的同时调用该 shared_ptr
对象的非 const
成员函数(如果需要,请考虑 atomic_shared_ptr
)