文章目录
1. 介绍
1.1 简介
C++ 是 Google 许多开源项目使用的主要开发语言之一。每个 C++ 程序员都知道,该语言具有许多强大的功能,但这种功能也带来了复杂性,这反过来又会使代码更容易出现错误,并且更难以阅读和维护。
本指南的目标是通过详细描述编写 C++ 代码的注意事项来管理这种复杂性。这些规则的存在是为了保持代码库的可管理性,同时仍然允许编码人员高效地使用 C++ 语言功能。
风格,也称为可读性,是我们所说的管理 C++ 代码的约定。术语"样式”有点用词不当,因为这些约定涵盖的不仅仅是源文件格式。
Google 开发的大多数开源项目都符合本指南中的要求。请注意,本指南不是 C++ 教程:我们假设读者熟悉该语言。
我们目前看到的样式指南的目标如下:
-
样式规则应发挥其作用:样式规则的好处必须足够大,以证明要求所有工程师记住它是合理的。
-
为读者优化,而不是为作者优化:我们的代码库(以及提交给它的大多数单个组件)预计将持续相当长一段时间。因此,阅读大部分代码所花的时间将比编写代码所花的时间更多。我们明确选择优化普通软件工程师阅读、维护和调试代码库中的代码的体验,而不是简化编写上述代码的过程。
-
与现有代码保持一致:在我们的代码库中一致地使用一种风格让我们可以专注于其他(更重要的)问题。一致性还允许自动化:格式化代码或调整 #includes 的工具只有在代码与工具的期望一致时才能正常工作。
一致性通常不应被用作以旧风格行事的理由,而不考虑新风格的好处,或代码库随着时间的推移趋向于新风格的趋势。
-
在适当的时候与更广泛的 C++ 社区保持一致:与其他组织使用 C++ 的方式保持一致,可以减少软件工程师的负担。如果 C++ 标准中的某个功能解决了问题,或者某个习语被广泛知晓和接受,那么这就是使用它的理由。
但是,有时标准功能和习语存在缺陷,或者在设计时没有考虑到我们代码库的需求。在这些情况下(如下所述),限制或禁止标准功能是适当的。
-
避免令人意外或危险的构造,C++ 具有比乍一看更令人意外或更危险的功能。一些风格指南限制旨在防止陷入这些陷阱,风格指南对此类限制的豁免门槛很高,因为放弃此类规则通常会直接危及程序的正确性。
-
避免使用普通 C++ 程序员会觉得棘手或难以维护的结构,C++ 具有一些可能并不适用的特性,因为它们会给代码带来复杂性。在广泛使用的代码中,使用更棘手的语言结构可能更容易被接受,因为更复杂的实现所带来的任何好处都会因使用而成倍增加,并且在使用代码库的新部分时不需要再次支付理解复杂性的成本。
-
注意我们的规模,代码库有 1 亿多行,有数千名工程师,一个工程师的一些错误和简化可能会让许多人付出高昂的代价。例如,避免污染全局命名空间尤为重要:如果每个人都将内容放入全局命名空间,那么数亿行代码库中的名称冲突将难以处理,也难以避免。
-
必要时进行优化,性能优化有时是必要且适当的,即使它们与本文档的其他原则相冲突。
本文档旨在提供最大限度的指导和合理的限制。与往常一样,常识和良好品味应该占上风。我们特指整个 Google C++ 社区的既定惯例,而不仅仅是您的个人偏好或团队偏好。对巧妙或不寻常的构造持怀疑态度,并不要使用:没有禁止并不等同于继续进行。请自行判断,如果您不确定,请随时询问您的项目负责人以获取更多意见。
1.2 C++版本
目前,代码应以 C++20 为目标,即不应使用 C++23 功能。本指南所针对的 C++ 版本将随着时间的推移而(积极)发展。请勿使用非标准扩展。
在项目中使用 C++17 和 C++20 的功能之前,请考虑可移植到其他环境。
(PS. 关于版本,理论上越新越好,但有些平台上受限于编译器支持,C++11也是一个不错的选择,关键是确定一个版本上限,并且坚持底线)
2. 头文件使用
一般来说,每个 .cc 文件都应该有一个关联的 .h 文件。但也有一些常见的例外,例如单元测试和仅包含 main() 函数的小型 .cc 文件。正确使用头文件可以对代码的可读性、大小和性能产生巨大影响。
2.1 独立头文件
头文件应是自包含的(自行编译)并以 .h 结尾。用于包含的非头文件应以 .inc 结尾并谨慎使用。
所有头文件都应是自包含的。用户和重构工具不必遵守特殊条件来包含头文件。具体而言,头文件应具有头文件保护并包含其所需的所有其他头文件。
当头文件的使用者将头文件中声明的内联函数或模板实例化时,内联函数和模板也必须在头文件中有定义,无论是直接定义还是包含在其包含的文件中。不要将这些定义移动到单独包含的头文件 (-inl.h
) 中;这种做法在过去很常见,但现在已不再允许。
(PS. 这里直白点说就是函数、类、模板等对象的声明(前向声明)和定义尽量不要分离在多个头文件里面,而是一个文件中就可见)
当模板的所有实例都发生在一个 .cc
文件中时,无论是因为它们是显式的还是因为该定义只能由 .cc
文件访问,模板定义都可以保留在该文件中。
在极少数情况下,设计为包含的文件不是自包含的。这些文件通常被包含在不寻常的位置,例如另一个文件的中间。它们可能不使用标头保护,也可能不包含其先决条件。使用 .inc
扩展名命名此类文件。请谨慎使用,并尽可能使用自包含的标头。
2.2 头文件保护
所有头文件都应具有 #define 保护以防止多次包含。符号名称的格式应为 <PROJECT>_<PATH>_<FILE>_H_
。
为了保证唯一性,它们应基于项目源树中的完整路径。例如,项目 foo 中的文件 foo/src/bar/baz.h
应具有以下保护:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
2.3 只包含必须头文件
如果源文件或头文件引用了在其他地方定义的符号,则该文件应直接包含一个旨在提供该符号的声明或定义的头文件。它不应因任何其他原因而包含头文件。
不要依赖传递包含。这允许人们从其头文件中删除不再需要的 #include 语句而不会破坏对应的源文件。
例如,foo.cc 使用来自 bar.h 的符号,即使 foo.h 包含 bar.h,它也应该包含 bar.h。
2.4 前向声明
尽可能避免使用前向声明,相反,请包含您需要的标头。“前向声明”是没有关联定义的实体声明。
// In a C++ source file:
class B;
void FuncInB();
extern int variable_in_b;
ABSL_DECLARE_FLAG(flag_in_b);
优点:
- 前向声明可以节省编译时间,因为
#includes
会强制编译器打开更多文件并处理更多输入。 - 前向声明可以节省不必要的重新编译。
#includes
可能会强制您的代码更频繁地重新编译,因为标头中存在不相关的更改。 - PS. 前向声明可以解决嵌套依赖的情况,这可以避免通过强制类型来访问对象,这也是最重要的使用方式。
缺点:
-
前向声明可以隐藏依赖项,允许用户代码在标头更改时跳过必要的重新编译。
-
与 #include 语句相反,前向声明使自动工具难以发现定义符号的模块。
-
前向声明可能会因对库的后续更改而被破坏。函数和模板的前向声明可以阻止标头所有者对其 API 进行其他兼容的更改,例如扩大参数类型、添加具有默认值的模板参数或迁移到新的命名空间。
-
在命名空间
std::
中前向声明符号会产生未定义的行为。 -
很难确定是否需要前向声明或完整的
#include
。用前向声明替换#include
可以悄悄改变代码的含义:// b.h: struct B {}; struct D : B {}; // good_user.cc: #include "b.h" void f(B*); void f(void*); void test(D* x) { f(x); } // Calls f(B*)
如果将 #include 替换为 B 和 D 的前向声明,则 test() 将调用 f(void*)。
-
从头文件中前向声明多个符号可能比简单地
#include
头文件更冗长。 -
构造代码中启用前向声明(例如,使用指针成员而不是对象成员)会使代码更慢、更复杂。
决定:
- 尽量避免前向声明在另一个项目中定义的实体。
2.5 内联函数
仅当函数很小(例如 10 行或更少)时才将其定义成内联函数。可以声明函数,使编译器能够将其扩展为内联函数,而不是通过通常的函数调用机制来调用它们。
优点:
- 只要内联函数很小,内联函数就可以生成更高效的目标代码。 可以随意内联访问器和变量器以及其他简短、性能关键的函数。
缺点:
-
过度使用内联实际上会使程序变慢。 根据函数的大小,内联它可能会导致代码大小增加或减少。 内联非常小的访问器函数通常会减少代码大小,而内联非常大的函数会显著增加代码大小。
在现代处理器上,较小的代码通常运行得更快,因为可以更好地利用指令缓存。
决定:
- 一个不错的经验法则是,如果函数长度超过 10 行,则不要内联它。 小心析构函数,由于隐式成员和基函数析构函数调用,它们通常比看起来的要长!
- 另一个有用的经验法则:使用循环或 switch 语句内联函数通常不具成本效益(除非在常见情况下循环或 switch 语句永远不会执行)。
- 重要的是要知道,即使函数被声明为内联函数,它们也并不总是内联的;例如,虚拟函数和递归函数通常不会内联。通常,递归函数不应内联。将虚拟函数设为内联的主要原因是将其定义放在类中,以方便使用或记录其行为,例如,用于访问器和变量器。
2.6 包含的名称和顺序
按以下顺序包含头文件:相关头文件、C 系统头文件、C++ 标准库头文件、其他库的头文件、项目的头文件。
项目的所有头文件都应列为项目源目录的后代,而不使用 UNIX 目录别名 .
(当前目录)或 ..
(父目录)。例如,google-awesome-project/src/base/logging.h
应按以下方式包含:
#include "base/logging.h"
仅当库要求您这样做时,才应使用尖括号路径包含头文件。特别是,以下头文件需要尖括号:
- C 和 C++ 标准库头文件(例如
<stdlib.h>
和<string>
)。 - POSIX、Linux 和 Windows 系统头文件(例如
<unistd.h>
和<windows.h>
)。 - 在极少数情况下,第三方库(例如
<Python.h>
)。
在 dir/foo.cc
或 dir/foo_test.cc
中,其主要目的是实现或测试 dir2/foo2.h
中的内容,请按以下顺序排列包含内容(用一个空行分隔每个非空组):
#include "dir2/foo2.h"
// - 一个空行
// C 系统头文件,以及带有 .h 扩展名的尖括号中的任何其他头文件,例如 <unistd.h>、<stdlib.h>、<Python.h>
#include <stdlib.h>
// 一个空行
// C++ 标准库头文件(无文件扩展名),例如 <algorithm>、<cstddef>
#include <algorithm>
// 一个空行
// 其他库的 .h 文件
// 一个空行
// 您的项目的 .h 文件
使用首选顺序,如果相关头文件 dir2/foo2.h
省略任何必要的包含内容,则 dir/foo.cc
或 dir/foo_test.cc
的构建将中断。因此,此规则可确保构建中断首先显示给处理这些文件的人员,而不是显示给其他软件包中的无辜人员。
dir/foo.cc
和 dir2/foo2.h
通常位于同一目录中(例如,base/basictypes_test.cc
和 base/basictypes.h
),但有时也可能位于不同的目录中。
请注意,C 头文件(例如 stddef.h)本质上可以与其 C++ 对应文件(cstddef)互换。两种风格都可以接受,但最好与现有代码保持一致。
(PS. 尽量使用C++对应头文件是一种不错的风格,本质上这是两种语言)。
在每个部分中,包含内容应按字母顺序排列。请注意,较旧的代码可能不符合此规则,应在方便时进行修复。
例如,google-awesome-project/src/foo/internal/fooserver.cc
中的包含内容可能如下所示:
#include "foo/server/fooserver.h"
#include <sys/types.h>
#include <unistd.h>
#include <string>
#include <vector>
#include "base/basictypes.h"
#include "foo/server/bar.h"
#include "third_party/absl/flags/flag.h"
有时,系统特定代码需要条件包含,此类代码可以将条件包含放在其他包含之后。当然,请保持系统特定代码简短且本地化,示例:
#include "foo/public/fooserver.h"
#include "base/port.h" // For LANG_CXX11.
#ifdef LANG_CXX11
#include <initializer_list>
#endif // LANG_CXX11
3. 范围作用域
3.1 命名空间
除少数例外,将代码放置在命名空间中。命名空间应该具有基于项目名称及其路径的唯一名称。不要使用 using 指令(例如 using 命名空间 foo)。不要使用内联命名空间,对于未命名的命名空间,请参阅内部链接。
命名空间将全局范围细分为不同的命名范围,因此对于防止全局范围中的名称冲突非常有用。命名空间提供了一种防止大型程序中名称冲突的方法,同时允许大多数代码使用相当短的名称。
例如,如果两个不同的项目在全局范围内有一个类 Foo,这些符号可能会在编译时或运行时发生冲突。如果每个项目将其代码放在命名空间中,则project1::Foo
和project2::Foo
现在是不冲突的不同符号,并且每个项目命名空间内的代码可以继续引用没有前缀的Foo。
内联命名空间会自动将其名称放置在封闭范围内。例如,考虑以下代码片段:
namespace outer {
inline namespace inner {
void foo();
} // namespace inner
} // namespace outer
表达式outer::inner::foo()
和outer::foo()
是可以互换的,内联命名空间主要用于跨版本的 ABI 兼容性。
命名空间可能会令人困惑,因为它们使确定名称所指定义的机制变得复杂。特别是内联命名空间可能会令人困惑,因为名称实际上并不限于声明它们的命名空间。它们仅作为某些较大版本控制策略的一部分有用。
在某些情况下,有必要通过符号的完全限定名称重复引用它们。对于深度嵌套的命名空间,这可能会增加很多混乱(最好控制在3层嵌套深度以内)。
命名空间应按如下方式使用:
-
遵循命名空间名称的规则。
-
使用注释终止多行名称空间,如给定示例所示。
-
命名空间在包含(
include
)、gflags
定义/声明以及来自**其他命名空间的类的前向声明(forward declarations)**之后包装整个源文件。// In the .h file namespace mynamespace { // All declarations are within the namespace scope. // Notice the lack of indentation. class MyClass { public: ... void Foo(); }; } // namespace mynamespace
// In the .cc file namespace mynamespace { // Definition of functions is within scope of the namespace. void MyClass::Foo() { ... } } // namespace mynamespace
更复杂的
.cc
文件可能包含其他详细信息,例如标志(flags
)或使用声明(using-declarations
)。#include "a.h" ABSL_FLAG(bool, someflag, false, "a flag"); namespace mynamespace { using ::foo::Bar; ...code for mynamespace... // Code goes against the left margin. } // namespace mynamespace
-
要将生成的协议消息代码放置在命名空间中,请使用
.proto
文件中的包说明符,这里指Google Protobuf协议。 -
不要在命名空间 std 中声明任何内容,包括标准库类的前向声明。在名称空间 std 中声明实体是未定义的行为,即不可移植。要声明标准库中的实体,请包含适当的头文件。
-
不能使用 using 指令来使命名空间中的所有名称可用。
// Forbidden -- This pollutes the namespace. using namespace foo;
-
不要在头文件中的命名空间范围内使用命名空间别名(
Namespace aliases
),除非在显式标记的仅限内部命名空间中,因为导入到头文件中的命名空间中的任何内容都会成为该文件导出的公共 API 的一部分。// Shorten access to some commonly used names in .cc files. namespace baz = ::foo::bar::baz;
// Shorten access to some commonly used names (in a .h file). namespace librarian { namespace internal { // Internal, not part of the API. namespace sidetable = ::pipeline_diagnostics::sidetable; } // namespace internal inline void my_inline_function() { // namespace alias local to a function (or method). namespace baz = ::foo::bar::baz; ... } } // namespace librarian
-
不要使用内联命名空间。
-
使用名称中带有“internal”的命名空间来记录 API 用户不应提及的 API 部分。
// We shouldn't use this internal name in non-absl code. using ::absl::container_internal::ImplementationDetail;
-
在新代码中首选单行嵌套命名空间声明,但不是必需的。
3.2 内部链接性
当 .cc 文件中的定义不需要在该文件外部引用时,可以通过将它们放置在未命名的命名空间中或将它们声明为静态来为它们提供内部链接。不要在 .h 文件中使用这些构造中的任何一个。
所有声明都可以通过将它们放置在未命名的命名空间中来给予内部链接。函数和变量也可以通过将它们声明为静态来给予内部链接。这意味着您声明的任何内容都无法从另一个文件访问。如果不同的文件声明具有相同名称的内容,则这两个实体是完全独立的。
对于不需要在其他地方引用的所有代码,鼓励使用 .cc 文件中的内部链接。不要在 .h 文件中使用内部链接。
像命名命名空间一样设置未命名命名空间的格式。在终止注释中,将命名空间名称留空:
namespace {
...
} // namespace
3.3 全局函数、静态成员函数和非成员函数
更喜欢将非成员函数放在命名空间中;很少使用完全全局的函数。不要仅仅使用类来对静态成员进行分组。类的静态方法通常应该与类的实例或类的静态数据密切相关。
非成员函数和静态成员函数在某些情况下可能很有用。将非成员函数放入命名空间可以避免污染全局命名空间。
非成员和静态成员函数作为新类的成员可能更有意义,特别是当它们访问外部资源或具有显着依赖性时。
有时定义一个不绑定到类实例的函数很有用。这样的函数可以是静态成员函数或非成员函数。非成员函数不应依赖于外部变量,并且几乎应始终存在于命名空间中。
不要创建类只是为了对静态成员进行分组;这与仅仅给名称提供一个共同的前缀没有什么不同,而且这样的分组通常是不必要的。
如果定义非成员函数并且仅在其 .cc
文件中需要它,请使用内部链接来限制其范围。
3.4 本地变量
将函数的变量放置在尽可能窄的范围内,并在声明中初始化变量。
C++ 允许在函数中的任何位置声明变量。我们鼓励在尽可能本地的范围内声明它们,并尽可能接近第一次使用。这使得读者更容易找到声明并查看变量的类型以及它被初始化为什么。特别是,应该使用初始化而不是声明和赋值,例如:
int i;
i = f(); // Bad -- initialization separate from declaration.
int i = f(); // Good -- declaration has initialization.
int jobs = NumJobs();
// More code...
f(jobs); // Bad -- declaration separate from use.
int jobs = NumJobs();
f(jobs); // Good -- declaration immediately (or closely) followed by use.
std::vector<int> v;
v.push_back(1); // Prefer initializing using brace initialization.
v.push_back(2);
std::vector<int> v = {1, 2}; // Good -- v starts initialized.
if、while 和 for 语句所需的变量通常应在这些语句内声明,以便这些变量被限制在这些范围内。例如:
while (const char* p = strchr(str, '/')) str = p + 1;
有一个警告:如果变量是一个对象,则每次进入作用域并创建时都会调用其构造函数,每次超出作用域时都会调用其析构函数。
// Inefficient implementation:
for (int i = 0; i < 1000000; ++i) {
Foo f; // My ctor and dtor get called 1000000 times each.
f.DoSomething(i);
}
在循环外部声明一个在循环中使用的变量可能会更有效:
Foo f; // My ctor and dtor get called once each.
for (int i = 0; i < 1000000; ++i) {
f.DoSomething(i);
}
3.5 静态和全局变量
禁止具有静态存储持续时间的对象,除非它们是可轻易破坏的。非正式地,这意味着析构函数不执行任何操作,即使考虑到成员和基析构函数。更正式地说,它意味着该类型没有用户定义的或虚拟的析构函数,并且所有基类和非静态成员都是可轻易破坏的。静态函数局部变量可以使用动态初始化。不鼓励对静态类成员变量或命名空间范围内的变量使用动态初始化,但在有限的情况下允许。
根据经验,如果全局变量的声明(单独考虑)可以是 constexpr,则它满足这些要求。
每个对象都有一个存储持续时间,这与其生命周期相关。具有静态存储持续时间的对象从其初始化点一直存活到程序结束。此类对象显示为名称空间范围内的变量(“全局变量”)、类的静态数据成员或使用 static 说明符声明的函数局部变量。
函数局部静态变量在控制权首次通过其声明时被初始化;所有其他具有静态存储持续时间的对象都作为程序启动的一部分进行初始化。所有具有静态存储持续时间的对象都会在程序退出时被销毁(这发生在未加入的线程终止之前)。
初始化可能是动态的,这意味着初始化期间会发生一些重要的事情。例如,考虑分配内存的构造函数,或使用当前进程 ID 初始化的变量,另一种初始化是静态初始化。不过,这两者并不完全相反:静态初始化总是发生在具有静态存储持续时间的对象上(将对象初始化为给定常量或由所有字节设置为零的表示形式),而动态初始化发生在这之后,如果必需的。
全局变量和静态变量对于大量应用程序非常有用:命名常量、某些翻译单元内部的辅助数据结构、命令行标志、日志记录、注册机制、后台基础设施等。
使用动态初始化或具有重要析构函数的全局和静态变量会产生复杂性,很容易导致难以发现的错误。动态初始化不是跨翻译单元排序的,销毁也不是(除了销毁以与初始化相反的顺序发生)。当一个初始化引用另一个具有静态存储持续时间的变量时,这可能会导致对象在其生命周期开始之前(或在其生命周期结束之后)被访问。此外,当一个程序启动线程但在退出时没有对它们进行 join 操作,这些线程可能会在对象生命周期结束后尝试访问这些对象,如果对象的析构函数已经运行过。
当析构函数很简单时,它们的执行根本不受顺序限制(它们实际上不是“运行”);否则,我们将面临在对象生命周期结束后访问对象的风险。因此,我们只允许具有静态存储持续时间的对象(如果它们是可轻易破坏的),基本类型(如指针和 int)是普通可破坏的,普通可破坏类型的数组也是如此。请注意,用 constexpr 标记的变量是可破坏的。
const int kNum = 10; // Allowed
struct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // Allowed
void foo() {
static const char* const kMessages[] = {"hello", "world"}; // Allowed
}
// Allowed: constexpr guarantees trivial destructor.
constexpr std::array<int, 3> kArray = {1, 2, 3};
// bad: non-trivial destructor
const std::string kFoo = "foo";
// Bad for the same reason, even though kBar is a reference (the
// rule also applies to lifetime-extended temporary objects).
const std::string& kBar = StrCat("a", "b", "c");
void bar() {
// Bad: non-trivial destructor.
static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}
请注意,引用不是对象,因此它们不受可破坏性的约束。不过,动态初始化的限制仍然适用。特别是,形式为 static T& t = *new T;
的函数局部静态引用是允许的。
初始化是一个更复杂的主题。这是因为我们不仅要考虑类构造函数是否执行,还必须考虑初始化器的评估:
int n = 5; // Fine
int m = f(); // ? (Depends on f)
Foo x; // ? (Depends on Foo::Foo)
Bar y = g(); // ? (Depends on g and on Bar::Bar)
除了第一条语句之外的所有语句都让我们面临不确定的初始化顺序。
我们正在寻找的概念在 C++ 标准的形式语言中称为常量初始化。这意味着初始化表达式是常量表达式,如果对象是通过构造函数调用初始化的,则构造函数也必须指定为 constexpr
:
struct Foo { constexpr Foo(int) {} };
int n = 5; // Fine, 5 is a constant expression.
Foo x(2); // Fine, 2 is a constant expression and the chosen constructor is constexpr.
Foo a[] = { Foo(1), Foo(2), Foo(3) }; // Fine
始终允许常量初始化。静态存储持续时间变量的常量初始化应使用 constexpr
或 constinit
进行标记。任何没有如此标记的非本地静态存储持续时间变量都应该被假定为具有动态初始化,并非常仔细地检查。
相比之下,以下初始化是有问题的:
// Some declarations used below.
time_t time(time_t*); // Not constexpr!
int f(); // Not constexpr!
struct Bar { Bar() {} };
// Problematic initializations.
time_t m = time(nullptr); // Initializing expression not a constant expression.
Foo y(f()); // Ditto
Bar b; // Chosen constructor Bar::Bar() not constexpr.
不鼓励动态初始化非局部变量,并且一般来说是禁止的。但是,如果程序的任何方面都不依赖于此初始化相对于所有其他初始化的顺序,我们确实允许这样做。在这些限制下,初始化的顺序不会产生明显的差异。例如:
int p = getpid(); // Allowed, as long as no other static variable
// uses p in its own initialization.
静态局部变量的动态初始化是允许的(并且很常见)。
常见的几种情况:
-
全局字符串:如果需要命名全局或静态字符串常量,请考虑使用 string_view、字符数组或字符指针的 constexpr 变量,指向字符串文字。字符串文字已经具有静态存储持续时间,并且通常就足够了。
-
映射、集合和其他动态容器:如果需要静态、固定的集合,例如要搜索的集合或查找表,则不能将标准库中的动态容器用作静态变量,因为它们具有非简易的析构函数。相反,考虑一个由简单类型组成的简单数组,例如,整数数组的数组(用于“从 int 到 int 的映射”)或对数组(例如,int 和 const char* 对)。
对于小型集合,线性搜索完全足够(并且由于内存局部性而有效),如有必要,请保持集合按排序顺序并使用二分搜索算法。如果确实更喜欢标准库中的动态容器,请考虑使用函数局部静态指针。
-
智能指针(std::unique_ptr、std::shared_ptr):智能指针在销毁期间执行清理,因此被禁止。考虑您的用例是否适合本节中描述的其他模式之一。一个简单的解决方案是使用指向动态分配对象的普通指针,并且永远不删除它(请参阅最后一项)。
-
自定义类型的静态变量:如果您需要需要自己定义的类型的静态常量数据,请为该类型提供一个简单的析构函数和一个 constexpr 构造函数。
-
如果所有其他方法都失败,可以使用函数本地静态指针或引用(例如
static const auto& impl = *new T(args...);
)动态创建一个对象,并且永远不会删除它(需要删除析构函数)。
3.6 线程本地变量
未在函数内部声明的 thread_local
变量必须使用真正的编译时常量进行初始化,并且必须使用 constinit
属性来强制执行。与其他定义线程本地数据的方法相比,更喜欢 thread_local
。
可以使用 thread_local 说明符声明变量:
thread_local Foo foo = ...;
这样的变量实际上是一个对象的集合,这样不同的线程访问它时,实际上访问的是不同的对象。 thread_local 变量在许多方面与静态存储持续时间变量非常相似。例如,它们可以在命名空间范围、函数内部或作为静态类成员声明,但不能作为普通类成员声明。
thread_local 变量实例的初始化与静态变量非常相似,只不过它们必须为每个线程单独初始化,而不是在程序启动时初始化一次。这意味着函数内声明的 thread_local 变量是安全的,但其他 thread_local 变量会遇到与静态变量相同的初始化顺序问题(以及更多问题)。
thread_local 变量有一个微妙的销毁顺序问题:在线程关闭期间,thread_local 变量将以与其初始化相反的顺序销毁(C++ 中通常如此)。如果由任何 thread_local 变量的析构函数触发的代码引用该线程上任何已销毁的 thread_local,我们将特别难以诊断释放后使用。
优点:
- 线程本地数据本质上是安全的,不会出现竞争(因为通常只有一个线程可以访问它),这使得 thread_local 对于并发编程非常有用。
- thread_local 是创建线程本地数据的唯一标准支持的方法。
缺点:
- 访问 thread_local 变量可能会在线程启动或首次在给定线程上使用期间触发不可预测且无法控制数量的其他代码的执行。
- thread_local 变量实际上是全局变量,除了线程安全性之外,具有全局变量的所有缺点。
- thread_local 变量消耗的内存随着正在运行的线程数量(在最坏的情况下)而变化,这在程序中可能非常大。
- 数据成员不能是 thread_local,除非它们也是静态的。
- 如果 thread_local 变量具有复杂的析构函数,我们可能会遇到释放后使用错误。特别是,任何此类变量的析构函数不得(传递地)调用任何引用任何可能被破坏的 thread_local 的代码。该属性很难执行。
- 在全局/静态上下文中避免使用后释放的方法不适用于 thread_locals。具体来说,跳过全局变量和静态变量的析构函数是允许的,因为它们的生命周期在程序关闭时结束。因此,任何“泄漏”都会立即由操作系统清理内存和其他资源来管理。相比之下,跳过 thread_local 变量的析构函数会导致资源泄漏,该泄漏与程序生命周期内终止的线程总数成正比。
类或命名空间范围内的 thread_local 变量必须使用真正的编译时常量进行初始化(即,它们必须没有动态初始化)。为了强制执行这一点,类或命名空间范围内的 thread_local 变量必须用 constinit
(或 constexpr
,但这应该很少见)来注释:
constinit thread_local Foo foo = ...;
函数内的 thread_local 变量没有初始化问题,但在线程退出期间仍然存在释放后使用的风险。请注意,可以使用函数范围 thread_local 通过定义公开它的函数或静态方法来模拟类或命名空间范围 thread_local:
Foo& MyThreadLocalFoo() {
thread_local Foo result = ComplicatedInitialization();
return result;
}
请注意,每当线程退出时,thread_local 变量都会被销毁。
如果任何此类变量的析构函数引用任何其他(可能被破坏的)thread_local,我们将遇到难以诊断释放后使用错误的问题。更喜欢简单类型,或者可以证明在销毁时不运行用户提供的代码的类型,以最大限度地减少访问任何其他 thread_local 的可能性。
thread_local 应该优先于定义线程本地数据的其他机制。
4. 类对象
4.1 构造函数执行的工作
避免在构造函数中调用虚拟方法,并避免在无法发出错误信号的情况下可能失败的初始化。
可以在构造函数主体中执行任意初始化。
优点:
- 无需担心类是否已初始化。
- 通过构造函数调用完全初始化的对象可以是 const,也可能更易于与标准容器或算法一起使用。
缺点:
- 如果工作调用虚拟函数,则这些调用将不会被分派到子类实现。即使您的类当前没有子类化,将来对类的修改也会悄悄地引入此问题,从而造成很多混乱。
- 构造函数没有简单的方法来发出错误信号,除非使程序崩溃(并不总是合适的)或使用异常(这是被禁止的)。
- 如果工作失败,我们现在有一个初始化代码失败的对象,因此它可能是一种不寻常的状态,需要 bool IsValid() 状态检查机制(或类似机制),很容易忘记调用。
- 不能获取构造函数的地址,因此在构造函数中完成的任何工作都不能轻易地交给例如另一个线程。
构造函数绝不应调用虚拟函数。如果合适的话,终止程序可能是一种合适的错误处理响应。否则,一般会通过Init()方法,即半构造函数来解决该问题。
4.2 隐式转换
不要定义隐式转换,对转换运算符和单参数构造函数使用显式关键字explicit
。
隐式转换允许在需要不同类型(称为目标类型)的地方使用一种类型(称为源类型)的对象,例如将 int
参数传递给采用 double
参数的函数时。
除了语言定义的隐式转换外,用户还可以通过在源或目标类型的类定义中添加适当的成员来定义自己的隐式转换。源类型中的隐式转换由以目标类型命名的类型转换运算符定义(例如,operator bool()
)。目标类型中的隐式转换由可以将源类型作为其唯一参数(或没有默认值的唯一参数)的构造函数定义。
显式关键字可以应用于构造函数或转换运算符,以确保它只能在使用时目标类型是显式的(例如,使用强制类型转换)时使用。这不仅适用于隐式转换,也适用于列表初始化语法:
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);
Func({42, 3.14}); // Error
从技术上讲,这种代码不是隐式转换,但就显式转换而言,语言将其视为隐式转换。
隐式转换可以消除在类型明显的情况下显式命名类型的需要,从而使类型更易于使用且更具表现力。隐式转换可以成为重载的更简单替代方法,例如,当使用带有 string_view
参数的单个函数代替 std::string
和 const char*
的单独重载时。
列表初始化语法是一种简洁而富有表现力的初始化对象的方式。
隐式转换可以隐藏类型不匹配错误,即目标类型与用户的期望不匹配,或者用户不知道会发生任何转换。隐式转换可以使代码更难阅读,尤其是在存在重载的情况下,因为它使实际调用的代码不那么明显。
采用单个参数的构造函数可能会意外地用作隐式类型转换,即使它们不是有意这样做的。当单参数构造函数未标记为显式时,没有可靠的方法来判断它是打算定义隐式转换,还是作者只是忘记标记它。隐式转换可能导致调用站点歧义,尤其是在存在双向隐式转换时。这可能是由于两种类型都提供隐式转换,或者一种类型同时具有隐式构造函数和隐式类型转换运算符而导致的。
如果目标类型是隐式的,列表初始化可能会遇到相同的问题,特别是当列表只有一个元素时。
类型转换运算符和可使用单个参数调用的构造函数必须在类定义中标记为显式。作为例外,复制和移动构造函数不应是显式的,因为它们不执行类型转换。
对于设计为可互换的类型,隐式转换有时是必要且适当的,例如当两种类型的对象只是相同基础值的不同表示时。
无法使用单个参数调用的构造函数可以省略显式。采用单个 std::initializer_list
参数的构造函数也应省略显式,以支持复制初始化:
MyType m = {1, 2};
4.3 可复制和可移动类型
类的公共 API 必须明确说明该类是可复制的、仅可移动的,还是既不可复制也不可移动的。如果这些操作对于您的类型来说明确且有意义,则支持复制和/或移动。
可移动类型是可以从临时变量初始化和分配的类型。可复制类型是可以从同一类型的任何其他对象初始化或分配的类型(因此根据定义也是可移动的),但前提是源的值不会改变。std::unique_ptr<int>
是可移动但不可复制类型的示例(因为在分配给目标时必须修改源 std::unique_ptr<int>
的值)。int 和 std::string 是可移动但也是可复制类型的示例。对于 int,移动和复制操作相同;对于 std::string,存在一个比复制更便宜的移动操作。
对于用户定义类型,复制行为由复制构造函数和复制赋值运算符定义。移动行为由移动构造函数和移动赋值运算符(如果存在)定义,否则由复制构造函数和复制赋值运算符定义。
在某些情况下,编译器可以隐式调用复制/移动构造函数,例如,按值传递对象时。
可复制和可移动类型的对象可以通过值传递和返回,这使得 API 更简单、更安全、更通用。与通过指针或引用传递对象不同,不存在所有权、生存期、可变性和类似问题混淆的风险,也无需在契约中指定它们。它还可以防止客户端和实现之间的非本地交互,这使得它们更容易被编译器理解、维护和优化。此外,此类对象可以与需要按值传递的通用 API(例如大多数容器)一起使用,并且它们允许在类型组合等方面提供额外的灵活性。
复制/移动构造函数和赋值运算符通常比 Clone()
、CopyFrom()
或 Swap()
等替代方案更容易正确定义,因为它们可以由编译器生成,无论是隐式生成还是使用=default
。它们简洁明了,并确保复制所有数据成员。复制和移动构造函数通常也更高效,因为它们不需要堆分配或单独的初始化和赋值步骤,并且它们有资格进行复制省略等优化。
移动操作允许隐式高效地将资源从右值对象中转移出去。在某些情况下,这允许更简单的编码风格。
某些类型不需要可复制,为此类类型提供复制操作可能会造成混淆、毫无意义或完全不正确。表示单例对象 (Registerer)、绑定到特定范围 (Cleanup) 或与对象标识紧密耦合 (Mutex) 的对象的类型无法进行有意义的复制。要以多态方式使用的基类类型的复制操作很危险,因为使用它们可能会导致对象切片。默认或粗心实施的复制操作可能不正确,并且由此产生的错误可能会造成混淆且难以诊断。
复制构造函数是隐式调用的,这使得调用很容易被忽略。这可能会让习惯于使用按引用传递是常规或强制的语言的程序员感到困惑。它还可能鼓励过度复制,从而导致性能问题。
每个类的公共接口必须明确说明该类支持哪些复制和移动操作。这通常应采取在声明的公共部分中明确声明和/或删除相应操作的形式。
具体而言,可复制类应明确声明复制操作,仅可移动类应明确声明移动操作,不可复制/可移动类应明确删除复制操作。可复制类还可以声明移动操作以支持高效移动。明确声明或删除所有四个复制/移动操作是允许的,但不是必需的。如果提供复制或移动赋值运算符,还必须提供相应的构造函数。
class Copyable {
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// The implicit move operations are suppressed by the declarations above.
// You may explicitly declare move operations to support efficient moves.
};
class MoveOnly {
public:
MoveOnly(MoveOnly&& other) = default;
MoveOnly& operator=(MoveOnly&& other) = default;
// The copy operations are implicitly deleted, but you can
// spell that out explicitly if you want:
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// Not copyable or movable
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&) = delete;
// The move operations are implicitly disabled, but you can
// spell that out explicitly if you want:
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
= delete;
};
只有在明显的情况下,才可以省略这些声明/删除:
-
如果类没有私有部分,如结构或仅接口的基类,则可复制性/可移动性可以由任何公共数据成员的可复制性/可移动性决定。
-
如果基类显然不可复制或可移动,则派生类自然也不会可复制或可移动。 仅接口的基类将这些操作保留为隐式,不足以使具体子类清晰。
-
请注意,如果明确声明或删除用于复制的构造函数或赋值操作,则其他复制操作不明显,必须声明或删除。 移动操作也是如此。
如果复制/移动的含义对普通用户来说不清楚,或者会产生意外成本,则类型不应可复制/可移动。 可复制类型的移动操作严格来说是一种性能优化,是错误和复杂性的潜在来源,因此请避免定义它们,除非它们比相应的复制操作效率高得多。 如果您的类型提供复制操作,建议您设计类,以便这些操作的默认实现是正确的。请记住,像检查其他代码一样,检查任何默认操作的正确性。
为了消除切片风险,最好使基类抽象化,方法是使其构造函数受保护,将其析构函数声明为受保护,或为其提供一个或多个纯虚拟成员函数。最好避免从具体类派生。
4.4 结构体和类
仅对携带数据的被动对象使用结构;其他一切都是类。
struct 和 class 关键字在 C++ 中的行为几乎相同。我们为每个关键字添加了自己的语义含义,因此应该使用适合您定义的数据类型的关键字。
struct 应用于携带数据的被动对象,并且可能具有关联的常量。所有字段都必须是公共的。结构不得具有暗示不同字段之间关系的不变量,因为用户直接访问这些字段可能会破坏这些不变量。可以存在构造函数、析构函数和辅助方法;但是,这些方法不得要求或强制任何不变量。
如果需要更多功能或不变量,或者结构具有广泛的可见性并有望发展,那么类更合适。如果有疑问,请将其设为类。
为了与 STL 保持一致,可以对无状态类型(例如特征、模板元函数和一些函子)使用 struct 而不是 class。
请注意,结构和类中的成员变量具有不同的命名规则。
4.5 结构体与元组
只要元素可以具有有意义的名称,就优先使用结构而不是序对或元组。
虽然使用对和元组可以避免定义自定义类型的需要,从而可能节省编写代码的工作量,但在阅读代码时,有意义的字段名称几乎总是比 .first
、.second
或 std::get<X>
更清晰。虽然 C++14 引入了 std::get<Type>
来按类型而不是索引访问元组元素(当类型唯一时)有时可以部分缓解这种情况,但字段名称通常比类型更清晰、更具信息性。
对和元组可能适用于通用代码,其中对或元组的元素没有特定含义。它们可能还需要用于与现有代码或 API 进行互操作。
4.6 继承
组合通常比继承更合适。使用继承时,请将其设为公共public
。
当子类从基类继承时,它包括基类定义的所有数据和操作的定义。“接口继承”是从纯抽象基类(没有状态或定义方法的基类)继承;所有其他继承都是“实现继承”。实现继承通过重用基类代码(因为它专门化了现有类型)来减少代码大小。由于继承是编译时声明,因此编译器可以理解操作并检测错误。接口继承可用于以编程方式强制类公开特定 API。同样,编译器可以检测错误,在这种情况下,当类未定义 API 的必要方法时。
对于实现继承,由于实现子类的代码分布在基类和子类之间,因此理解实现可能更加困难。子类无法覆盖非虚拟函数,因此子类无法更改实现。
多重继承尤其成问题,因为它通常会带来更高的性能开销(事实上,从单继承到多重继承的性能下降通常比从普通到虚拟分派的性能下降更大),并且它有导致“菱形”继承模式的风险,这种模式容易产生歧义、混淆和彻底的错误。
所有继承都应该是公共的。如果要进行私有继承,则应改为包含基类的实例作为成员。如果不希望支持将类用作基类,则可以在类上使用 final。
不要过度使用实现继承。组合通常更合适。尝试将继承的使用限制在is-a
的情况下:如果可以合理地说 Bar 是一种 Foo,则 Bar 子类化 Foo。
将 protected 的使用限制在可能需要从子类访问的成员函数上。请注意,数据成员应该是私有的。
使用 override 或(不太常见)final 说明符明确注释虚拟函数或虚拟析构函数的覆盖。声明覆盖时不要使用虚拟。
理由:标记为 override 或 final 的函数或析构函数如果不是基类虚拟函数的覆盖,则不会编译,这有助于捕获常见错误。
说明符用作文档;如果没有说明符,则读者必须检查相关类的所有祖先,以确定函数或析构函数是否为虚拟的。
允许多重继承,但强烈反对多重实现继承。
4.7 运算符重载
谨慎地重载运算符。不要使用用户定义的文字。
C++ 允许用户代码使用运算符关键字声明内置运算符的重载版本,只要其中一个参数是用户定义的类型。运算符关键字还允许用户代码使用operator""
定义新类型的文字,并定义类型转换函数,例如operator bool()
。
运算符重载可以使用户定义类型的行为与内置类型相同,从而使代码更简洁、更直观。重载运算符是某些操作的惯用名称(例如 ==、<、= 和 <<),遵守这些约定可以使用户定义类型更具可读性,并使它们能够与需要这些名称的库进行互操作。
用户定义的文字是创建用户定义类型对象的非常简洁的符号。
缺点:
- 提供一组正确、一致且不出意外的运算符重载需要小心谨慎,否则可能会导致混乱和错误。
- 过度使用运算符会导致代码混淆,特别是如果重载运算符的语义不符合惯例。
- 函数重载的危害与运算符重载一样,甚至更甚。
- 运算符重载会欺骗我们的直觉,让我们认为昂贵的操作是廉价的内置操作。
- 查找重载运算符的调用点可能需要一个了解 C++ 语法的搜索工具,而不是 grep。
- 如果重载运算符的参数类型错误,您可能会得到不同的重载,而不是编译器错误。例如,foo < bar 可能做一件事,而 &foo < &bar 做完全不同的事情。
- 某些运算符重载本身就很危险。重载一元 & 可能会导致相同的代码具有不同的含义,具体取决于重载声明是否可见。&&、|| 和 ,(逗号)的重载无法匹配内置运算符的求值顺序语义。
- 运算符通常在类之外定义,因此存在不同文件引入同一运算符的不同定义的风险。如果两个定义都链接到同一个二进制文件中,则会导致未定义的行为,这可能会表现为微妙的运行时错误。
- 用户定义文字 (UDL) 允许创建甚至经验丰富的 C++ 程序员都不熟悉的新语法形式,例如
“Hello World”sv
是std::string_view(“Hello World”)
的简写。现有的符号更清晰,但不够简洁。 - 由于它们不能是命名空间限定的,因此使用 UDL 还需要使用 using 指令(我们禁止使用)或 using 声明(我们在头文件中禁止使用,除非导入的名称是相关头文件公开的接口的一部分)。鉴于头文件必须避免使用 UDL 后缀,我们希望避免头文件和源文件之间的文字约定不同。
优点:
-
仅当重载运算符的含义明显、不足为奇且与相应的内置运算符一致时,才定义重载运算符。例如,将 | 用作按位或逻辑或,而不是用作 shell 样式的管道。
-
仅在您自己的类型上定义运算符。更准确地说,在与它们操作的类型相同的标头、.cc 文件和命名空间中定义它们。这样,无论类型在哪里,运算符都可以使用,从而最大限度地降低多重定义的风险。如果可能,避免将运算符定义为模板,因为它们必须满足任何可能的模板参数的此规则。如果您定义了一个运算符,还请定义任何有意义的相关运算符,并确保它们的定义一致。
-
最好将非修改二元运算符定义为非成员函数。如果二元运算符被定义为类成员,则隐式转换将应用于右侧参数,但不应用于左侧参数。如果 a + b 编译但 b + a 不编译,它会让您的用户感到困惑。
-
对于可以比较值是否相等的类型 T,定义非成员运算符 == 并记录何时将两个类型 T 的值视为相等。如果存在一个明显的概念,即类型 T 的值 t1 小于另一个这样的值 t2,那么您也可以定义运算符 <=>,它应该与运算符 == 一致。最好不要重载其他比较和排序运算符。
-
不要刻意避免定义运算符重载。例如,最好定义 ==、= 和 <<,而不是 Equals()、CopyFrom() 和 PrintTo()。相反,不要仅仅因为其他库需要运算符重载就定义它们。例如,如果您的类型没有自然排序,但您想将其存储在 std::set 中,请使用自定义比较器而不是重载 <。
-
不要重载 &&、||、,(逗号)或一元 &。不要重载运算符“”,即不要引入用户定义的文字。不要使用其他人(包括标准库)提供的任何此类文字。
类型转换运算符在隐式转换部分中介绍。= 运算符在复制构造函数部分中介绍。重载 << 以用于流在流部分中介绍。另请参阅函数重载规则,这些规则也适用于运算符重载。
4.8 访问控制
将类的数据成员设为私有,除非它们是常量。这简化了关于不变量的推理,但代价是必要时以访问器(通常是 const)的形式提供一些简单的样板。
出于技术原因,我们允许在使用 Google Test 时保护 .cc 文件中定义的测试装置类的数据成员。如果测试装置类是在使用它的 .cc 文件之外定义的,例如在 .h 文件中,则将数据成员设为私有。
4.9 声明顺序
将相似的声明组合在一起,将公共部分放在前面。
类定义通常应以 public:
部分开头,然后是 protected:
,然后是 private:
,省略空的部分。
在每个部分中,最好将相似的声明组合在一起,并最好遵循以下顺序:
-
类型和类型别名(typedef、using、enum、嵌套结构和类以及友元类型)
-
(可选,仅适用于结构)非静态数据成员
-
静态常量
-
工厂函数
-
构造函数和赋值运算符
-
析构函数
-
所有其他函数(静态和非静态成员函数以及友元函数)
-
所有其他数据成员(静态和非静态)
不要将大型方法定义内联到类定义中。通常,只有琐碎或性能关键且非常短的方法才可以内联定义。