Google C++ 风格指南
背景
C++ 是许多 Google 开源项目使用的主要开发语言之一。正如每位 C++ 程序员所知,该语言具有许多强大的功能,但这种强大功能带来了复杂性,进而可能使代码更容易出错,更难以阅读和维护。
本指南的目标是通过详细描述编写 C++ 代码的规范和禁忌来管理这种复杂性。 这些规则的存在是为了保持代码库的可管理性,同时仍然允许编码人员高效地使用 C++ 语言功能。
风格,也称为可读性,是我们用来描述 C++ 代码的约定。 “风格”一词有点用词不当,因为这些约定涵盖的内容远不止源文件格式。
Google 开发的大多数开源项目都符合本指南的要求。
请注意,本指南不是 C++ 教程:我们假设读者熟悉该语言。
风格指南的目标
我们为什么要编写本文档?
我们认为本指南应该服务于几个核心目标。 这些是所有单个规则的基础原因。 通过将这些想法摆在首位,我们希望为讨论奠定基础,并使我们更广泛的社区更清楚地了解规则存在的原因以及做出特定决定的原因。 如果您了解每个规则所服务的目标,那么当规则可以免除(有些可以)时,以及需要什么样的论据或替代方案来更改指南中的规则时,对每个人来说都应该更清楚。
我们目前看到的风格指南的目标如下
- 风格规则应该发挥作用
- 风格规则的好处必须足够大,才能证明要求我们所有的工程师记住它。 该好处是相对于没有该规则的代码库来衡量的,因此,如果人们不太可能这样做,那么针对非常有害的做法的规则可能仍然只有很小的好处。 此原则主要解释了我们没有的规则,而不是我们有的规则:例如,
goto
违反了以下许多原则,但已经非常罕见,因此风格指南没有讨论它。 - 为读者优化,而不是为编写者优化
- 我们的代码库(以及提交给它的大多数单个组件)预计会持续相当长的时间。 因此,与编写代码相比,阅读我们的大多数代码将花费更多时间。 我们明确选择优化我们的普通软件工程师阅读、维护和调试代码库中的代码的体验,而不是编写代码时的轻松程度。 “为读者留下痕迹”是该原则的一个特别常见的子点:当一段代码中发生令人惊讶或不寻常的事情时(例如,指针所有权的转移),在使用点为读者留下文本提示是有价值的(
std::unique_ptr
在调用点明确地展示了所有权转移)。 - 与现有代码保持一致
- 在我们的代码库中始终如一地使用一种风格,可以让我们专注于其他(更重要的)问题。 一致性还允许自动化:当您的代码与工具的预期一致时,格式化您的代码或调整您的
#include
的工具才能正常工作。 在许多情况下,归因于“保持一致”的规则可以归结为“只需选择一个并停止担心它”; 允许在这些点上灵活性的潜在价值被人们争论它们的成本所抵消。 但是,一致性也是有限度的; 当没有明确的技术论据,也没有长期方向时,它是一个很好的决胜局。 它更多地应用于本地(每个文件,或用于一组紧密相关的接口)。 通常不应将一致性用作以旧风格做事而不考虑新风格的好处或代码库随着时间的推移趋向于新风格的理由。 - 在适当的时候与更广泛的 C++ 社区保持一致
- 与其他组织使用 C++ 的方式保持一致具有与我们的代码库中保持一致相同的原因的价值。 如果 C++ 标准中的一项功能解决了问题,或者如果某种习惯用法广为人知且被接受,那么这就是使用它的论据。 但是,有时标准功能和习惯用法存在缺陷,或者只是在设计时没有考虑到我们代码库的需求。 在这些情况下(如下所述),限制或禁止标准功能是合适的。 在某些情况下,我们更喜欢自制的或第三方的库,而不是 C++ 标准中定义的库,这要么是出于感觉到的优越性,要么是过渡代码库到标准接口的价值不足。
- 避免令人惊讶或危险的结构
- C++ 的功能比乍一看可能认为的更令人惊讶或危险。 一些风格指南限制是为了防止陷入这些陷阱。 对此类限制的风格指南豁免有一个很高的标准,因为豁免此类规则通常会直接冒着损害程序正确性的风险。
- 避免我们的普通 C++ 程序员会觉得棘手或难以维护的结构
- C++ 具有一些特性,由于它们给代码带来的复杂性,因此可能通常不适用。 在广泛使用的代码中,使用更复杂的语言结构可能更容易接受,因为更复杂实现的任何好处都会因使用而广泛增加,并且在处理代码库的新部分时,不需要再次付出理解复杂性的代价。 如有疑问,可以通过咨询您的项目负责人来寻求对此类规则的豁免。 这对我们的代码库尤为重要,因为代码所有权和团队成员会随着时间的推移而发生变化:即使当前与某些代码一起工作的每个人都理解它,也不能保证这种理解在几年后仍然成立。
- 注意我们的规模
- 对于一个拥有超过 1 亿行代码和数千名工程师的代码库来说,一位工程师的一些错误和简化可能会对许多人造成代价。 例如,避免污染全局命名空间尤为重要:如果每个人都将内容放入全局命名空间,则在数亿行代码的代码库中,名称冲突很难处理且难以避免。
- 必要时进行优化
- 有时性能优化可能是必要的和适当的,即使它们与本文档的其他原则相冲突。
本文档的目的是在合理的限制下提供最大的指导。 与往常一样,常识和良好的品味应该占上风。 在此,我们特别指的是整个 Google C++ 社区的既定惯例,而不仅仅是您的个人偏好或您团队的偏好。 对使用巧妙或不寻常的结构持怀疑和不情愿的态度:没有禁止并不意味着可以继续进行。 使用您的判断力,如果您不确定,请随时咨询您的项目负责人以获取更多意见。
C++ 版本
目前,代码应以 C++20 为目标,即不应使用 C++23 功能。 本指南所针对的 C++ 版本将随着时间的推移(积极地)推进。
请勿使用非标准扩展。
在使用项目中的 C++17 和 C++20 的功能之前,请考虑可移植到其他环境。
头文件
一般来说,每个 .cc
文件都应该有一个关联的 .h
文件。 有一些常见的例外,例如单元测试和仅包含 main()
函数的小型 .cc
文件。
正确使用头文件可以对代码的可读性、大小和性能产生巨大的影响。
以下规则将指导您完成使用头文件的各种陷阱。
自包含的头文件
头文件应该是自包含的(可以独立编译),并以 .h
结尾。 旨在包含的非头文件应以 .inc
结尾,并应谨慎使用。
所有头文件都应该是自包含的。 用户和重构工具不应遵守特殊条件来包含头文件。 具体来说,头文件应该有头文件保护符,并包含它需要的所有其他头文件。
当头文件声明客户端将实例化的内联函数或模板时,内联函数和模板也必须在头文件中包含定义,可以直接包含,也可以在它包含的文件中包含。 不要将这些定义移动到单独包含的头文件 (-inl.h
) 文件中; 这种做法在过去很常见,但现在不再允许。 当模板的所有实例化都发生在一个 .cc
文件中时,要么是因为它们是显式的,要么是因为定义只能被 .cc
文件访问,则可以将模板定义保留在该文件中。
在极少数情况下,设计为包含的文件不是自包含的。 这些文件通常旨在包含在不寻常的位置,例如另一个文件的中间。 它们可能不使用头文件保护符,并且可能不包含其先决条件。 使用 .inc
扩展名命名此类文件。 谨慎使用,并尽可能选择自包含的头文件。
#define 保护符
所有头文件都应该有 #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_
需要什么就包含什么
如果源文件或头文件引用了在其他地方定义的符号,则该文件应直接包含一个头文件,该头文件旨在正确地提供该符号的声明或定义。 它不应包含任何其他原因的头文件。
不要依赖传递包含。 这允许人们从他们的头文件中删除不再需要的 #include
语句,而不会破坏客户端。 这也适用于相关的头文件 - 如果 foo.cc
使用了 bar.h
中的符号,即使 foo.h
包含了 bar.h
,也应该包含 bar.h
。
前置声明
尽可能避免使用前置声明。 相反,包含你需要的所有头文件。
“前置声明”是没有关联定义的实体的声明。
// In a C++ source file: class B; void FuncInB(); extern int variable_in_b; ABSL_DECLARE_FLAG(flag_in_b);
- 前置声明可以节省编译时间,因为
#include
会强制编译器打开更多文件并处理更多输入。 - 前置声明可以节省不必要的重新编译。 由于头文件中不相关的更改,
#include
可能会强制您的代码更频繁地重新编译。
- 前置声明可以隐藏依赖关系,允许用户代码在头文件更改时跳过必要的重新编译。
- 与
#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
头文件相比,从头文件前置声明多个符号可能会更冗长。 - 构建代码以启用前置声明(例如,使用指针成员而不是对象成员)可能会使代码更慢和更复杂。
尽量避免前置声明在另一个项目中定义的实体。
在头文件中定义函数
仅当函数定义简短时,才将其定义放在头文件的声明处。 如果定义因其他原因必须位于头文件中,请将其放在文件的内部部分。 如果需要使定义符合ODR安全,请使用inline
说明符标记它。
在头文件中定义的函数有时被称为“内联函数”,这是一个有些重载的术语,指的是几个不同但重叠的情况
- 一个文本内联符号的定义在声明处向读者公开。
- 在头文件中定义的函数或变量是可扩展内联的,因为它的定义可用于编译器的内联展开,这可以提高目标代码的效率。
- ODR安全实体不违反“单一定义规则”,这通常需要对在头文件中定义的内容使用inline关键字。
虽然函数往往是造成混淆的更常见来源,但这些定义也适用于变量,此处的规则也同样适用。
- 内联地文本定义一个函数可以减少简单函数(如访问器和修改器)的样板代码。
- 如上所述,由于编译器内联展开,头文件中的函数定义可以提高小型目标代码的效率。
- 函数模板和
constexpr
函数通常需要在声明它们的头文件中定义(但不一定是公共部分)。
- 将函数定义嵌入公共API会使API更难浏览,并给该API的读者带来认知负担 - 函数越复杂,成本越高。
- 公共定义暴露了实现细节,这些细节充其量是无害的,而且常常是无关的。
仅当函数简短(例如,10行或更少)时,才在其公共声明处定义函数。 除非由于性能或技术原因必须位于头文件中,否则将较长的函数体放在.cc
文件中。
即使一个定义必须位于头文件中,这也不足以将其放在公共部分中。 相反,该定义可以位于头文件的内部部分,例如类的私有部分,包含单词internal
的命名空间内,或者在诸如// Implementation details only below here
之类的注释下。
一旦定义位于头文件中,它必须是ODR安全的,要么具有inline
说明符,要么通过作为函数模板或在首次声明时在类主体中定义而被隐式指定为内联。
template <typename T> class Foo { public: int bar() { return bar_; } void MethodWithHugeBody(); private: int bar_; }; // Implementation details only below here template <typename T> void Foo<T>::MethodWithHugeBody() { ... }
包含文件的名称和顺序
按以下顺序包含头文件:相关头文件、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
中的内容,请按以下顺序包含文件
dir2/foo2.h
.- 一个空行
- C系统头文件,以及带有
.h
扩展名的任何其他尖括号头文件,例如<unistd.h>
,<stdlib.h>
,<Python.h>
。 - 一个空行
- C++标准库头文件 (没有文件扩展名),例如
<algorithm>
,<cstddef>
。 - 一个空行
- 其他库的
.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
) 本质上是可互换的。 任何一种样式都是可以接受的,但最好与现有代码保持一致。
在每个部分中,应按字母顺序对包含文件进行排序。 请注意,较旧的代码可能不符合此规则,应在方便时进行修复。
例如,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
作用域
命名空间
在极少数例外情况下,将代码放在命名空间中。 命名空间应具有基于项目名称的唯一名称,并且可能包含其路径。 不要使用using指令 (例如,using namespace foo
)。 不要使用内联命名空间。 对于未命名的命名空间,请参见内部链接。
命名空间将全局作用域细分为不同的命名作用域,因此可用于防止全局作用域中的名称冲突。
命名空间提供了一种防止大型程序中名称冲突的方法,同时允许大多数代码使用相对较短的名称。
例如,如果两个不同的项目在全局作用域中都有一个类 Foo
,则这些符号可能会在编译时或运行时发生冲突。 如果每个项目将其代码放在一个命名空间中,则 project1::Foo
和 project2::Foo
现在是不同的符号,不会发生冲突,并且每个项目命名空间中的代码可以继续引用 Foo
而无需前缀。
内联命名空间会自动将其名称放在封闭作用域中。 例如,考虑以下代码段
namespace outer { inline namespace inner { void foo(); } // namespace inner } // namespace outer
表达式 outer::inner::foo()
和 outer::foo()
是可以互换的。 内联命名空间主要用于跨版本的ABI兼容性。
命名空间可能会造成混淆,因为它们使确定名称引用的定义的机制变得复杂。
特别是,内联命名空间可能会造成混淆,因为名称实际上并不局限于声明它们的命名空间。 它们仅作为某种更大的版本控制策略的一部分有用。
在某些情况下,必须重复使用它们的完全限定名称来引用符号。 对于深度嵌套的命名空间,这会增加很多混乱。
应按以下方式使用命名空间
- 遵循命名空间名称上的规则。
- 如给定的示例所示,使用注释终止多行命名空间。
-
在包含文件、gflags定义/声明以及来自其他命名空间的类的前向声明之后,命名空间将包装整个源文件。
// 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
文件可能包含其他详细信息,例如标志或using声明。#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
文件中的package
说明符。 有关详细信息,请参见协议缓冲区包。 - 不要在命名空间
std
中声明任何内容,包括标准库类的前向声明。 在命名空间std
中声明实体是未定义的行为,即不可移植。 要声明标准库中的实体,请包含相应的头文件。 你不得使用 using指令 使一个命名空间中的所有名称都可用。
// Forbidden -- This pollutes the namespace. using namespace foo;
除非在明确标记为仅限内部使用的命名空间中,否则不要在头文件中使用命名空间作用域中的命名空间别名,因为导入到头文件中命名空间中的任何内容都会成为该文件导出的公共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;
请注意,嵌套的
internal
命名空间中的库之间仍然存在冲突的风险,因此通过添加库的文件名,为命名空间中的每个库提供唯一的内部命名空间。 例如,gshoe/widget.h
将使用gshoe::internal_widget
而不仅仅是gshoe::internal
。在新代码中首选单行嵌套命名空间声明,但不是必需的。
内部链接
当.cc
文件中的定义不需要在该文件外部引用时,通过将它们放在未命名的命名空间中或声明它们为static
来为它们提供内部链接。 不要在.h
文件中使用这些构造。
通过将所有声明都放在未命名的命名空间中,可以为它们提供内部链接。 函数和变量也可以通过将它们声明为static
来提供内部链接。 这意味着你声明的任何内容都无法从另一个文件访问。 如果另一个文件声明了具有相同名称的某些内容,则这两个实体是完全独立的。
对于所有不需要在其他地方引用的代码,鼓励在.cc
文件中使用内部链接。 不要在.h
文件中使用内部链接。
像命名命名空间一样格式化未命名的命名空间。 在终止注释中,将命名空间名称留空
namespace { ... } // namespace
非成员、静态成员和全局函数
首选将非成员函数放在命名空间中; 很少使用完全全局的函数。 不要仅使用类来对静态成员进行分组。 类的静态方法通常应与类的实例或类的静态数据密切相关。
非成员和静态成员函数在某些情况下可能很有用。 将非成员函数放在命名空间中可以避免污染全局命名空间。
非成员和静态成员函数作为新类的成员可能更有意义,尤其是在它们访问外部资源或具有重要的依赖项时。
有时定义一个不绑定到类实例的函数是有用的。 这样的函数可以是静态成员或非成员函数。 非成员函数不应依赖于外部变量,并且几乎总是应该存在于命名空间中。 不要仅创建类来对静态成员进行分组; 这与仅给名称添加一个公共前缀没有什么不同,并且这种分组通常是不必要的。
如果你定义了一个非成员函数,并且它只需要在其.cc
文件中使用,则使用内部链接来限制其作用域。
局部变量
将函数变量放在尽可能窄的作用域中,并在声明中初始化变量。
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); }
静态变量和全局变量
除非具有静态存储期的对象是可平凡析构的,否则是被禁止的。非正式地说,这意味着析构函数不做任何事情,甚至包括成员和基类的析构函数。更正式地说,这意味着该类型没有用户定义的或虚析构函数,并且所有基类和非静态成员都是可平凡析构的。静态函数局部变量可以使用动态初始化。不鼓励对静态类成员变量或命名空间作用域中的变量使用动态初始化,但在有限的情况下允许使用;请参阅下面的详细信息。
作为经验法则:如果全局变量的声明,在孤立考虑时,可以变成 constexpr
,那么它就满足这些要求。
每个对象都有一个存储期,这与其生命周期相关联。具有静态存储期的对象从其初始化开始到程序结束一直存在。这种对象表现为命名空间作用域中的变量(“全局变量”)、类的静态数据成员,或者使用 static
说明符声明的函数局部变量。函数局部静态变量在控制首次通过其声明时初始化;所有其他具有静态存储期的对象作为程序启动的一部分进行初始化。所有具有静态存储期的对象在程序退出时被销毁(这发生在未连接线程终止之前)。
初始化可以是动态的,这意味着在初始化期间会发生一些非平凡的事情。(例如,考虑一个分配内存的构造函数,或一个用当前进程 ID 初始化的变量。)另一种初始化是静态的初始化。但这两种初始化并非完全相反:静态初始化总是发生在具有静态存储期的对象上(将对象初始化为给定的常量或初始化为所有字节都设置为零的表示),而动态初始化在此之后发生(如果需要)。
全局变量和静态变量对于大量应用程序非常有用:命名常量、某些翻译单元内部的辅助数据结构、命令行标志、日志记录、注册机制、后台基础设施等。
使用动态初始化或具有非平凡析构函数的全局变量和静态变量会产生复杂性,这些复杂性很容易导致难以发现的错误。动态初始化在不同的翻译单元之间没有顺序,销毁也没有顺序(除非销毁以初始化的相反顺序发生)。当一个初始化引用另一个具有静态存储期的变量时,可能会导致在对象的生命周期开始之前(或在其生命周期结束后)访问该对象。此外,当程序启动在退出时未连接的线程时,如果这些线程的析构函数已经运行,则它们可能会尝试访问在其生命周期结束后对象。
关于析构的决定
当析构函数是平凡的时,它们的执行根本不受顺序的约束(它们实际上并没有“运行”);否则,我们将面临在对象的生命周期结束后访问对象的风险。因此,我们只允许具有静态存储期的对象是可平凡析构的。基本类型(如指针和 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
变量,指向字符串文字。字符串文字已经具有静态存储期,并且通常就足够了。请参阅TotW #140。 - 映射、集合和其他动态容器:如果您需要一个静态的、固定的集合,例如用于搜索的集合或查找表,则不能使用标准库中的动态容器作为静态变量,因为它们具有非平凡的析构函数。相反,考虑使用简单的基本类型数组,例如,整数数组的数组(对于“从 int 到 int 的映射”),或对数组(例如,
int
和const char*
的对)。对于小型集合,线性搜索完全足够(并且由于内存局部性而有效);考虑使用来自absl/algorithm/container.h中的工具进行标准操作。如有必要,保持集合排序并使用二分搜索算法。如果您确实更喜欢标准库中的动态容器,请考虑使用如下所述的函数局部静态指针。 - 智能指针 (
std::unique_ptr
,std::shared_ptr
):智能指针在销毁期间执行清理,因此是被禁止的。考虑您的用例是否适合本节中描述的其他模式之一。一个简单的解决方案是使用指向动态分配对象的普通指针并且永远不删除它(参见最后一项)。 - 自定义类型的静态变量:如果您需要自定义类型的静态常量数据,请给该类型一个平凡的析构函数和一个
constexpr
构造函数。 - 如果所有其他方法都失败了,您可以通过使用函数局部静态指针或引用来动态创建对象并且永远不删除它(例如,
static const auto& impl = *new T(args...);
)。
thread_local 变量
未在函数内部声明的 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
,除非它们也是static
。 - 如果
thread_local
变量具有复杂的析构函数,我们可能会遭受释放后使用错误。特别是,任何此类变量的析构函数都不得调用(传递地)引用任何可能已销毁的thread_local
的任何代码。此属性难以强制执行。 - 用于避免全局/静态上下文中释放后使用的方法不适用于
thread_local
。具体来说,跳过全局变量和静态变量的析构函数是可以接受的,因为它们的生命周期在程序关闭时结束。因此,任何“泄漏”都由操作系统清理我们的内存和其他资源来立即管理。相比之下,跳过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
,而不是其他机制。
类
类是 C++ 中代码的基本单元。 自然地,我们广泛地使用它们。 本节列出了编写类时应遵循的主要事项。
在构造函数中执行工作
避免在构造函数中调用虚方法,如果无法发出错误信号,请避免可能失败的初始化。
可以在构造函数的主体中执行任意初始化。
- 无需担心类是否已初始化。
- 通过构造函数调用完全初始化的对象可以是
const
,并且也可以更容易地与标准容器或算法一起使用。
- 如果该工作调用虚函数,则这些调用不会被分派到子类实现。 即使您的类当前没有被子类化,将来对您的类的修改也可能会悄悄地引入此问题,从而导致很多混乱。
- 构造函数没有简单的方法来发出错误信号,除非崩溃程序(并非总是合适)或使用异常(禁止)。
- 如果该工作失败,我们现在有了一个初始化代码失败的对象,因此它可能处于一种不寻常的状态,需要一个
bool IsValid()
状态检查机制(或类似机制),这种机制很容易忘记调用。 - 您无法获取构造函数的地址,因此构造函数中完成的任何工作都无法轻松地传递给,例如,另一个线程。
构造函数永远不应调用虚函数。 如果适合您的代码,终止程序可能是适当的错误处理响应。 否则,请考虑使用工厂函数或 Init()
方法,如 TotW #42 中所述。 避免在没有其他影响可以调用的公共方法的对象上使用 Init()
方法(这种形式的半构造对象尤其难以正确使用)。
隐式转换
不要定义隐式转换。 对转换运算符和单参数构造函数使用 explicit
关键字。
隐式转换允许使用一种类型(称为源类型)的对象,该对象原本需要另一种类型(称为目标类型),例如将 int
参数传递给接受 double
参数的函数。
除了语言定义的隐式转换之外,用户还可以通过将适当的成员添加到源类型或目标类型的类定义中来定义自己的隐式转换。 源类型中的隐式转换由以目标类型命名的类型转换运算符定义(例如,operator bool()
)。 目标类型中的隐式转换由可以将源类型作为其唯一参数(或没有默认值的唯一参数)的构造函数定义。
explicit
关键字可以应用于构造函数或转换运算符,以确保只有在目标类型在使用点显式指定时(例如,使用强制转换),才能使用它。 这不仅适用于隐式转换,还适用于列表初始化语法。
class Foo { explicit Foo(int x, double y); ... }; void Func(Foo f);
Func({42, 3.14}); // Error
从技术上讲,这种代码不是隐式转换,但就 explicit
而言,该语言将其视为隐式转换。
- 隐式转换可以通过消除在显而易见的情况下显式命名类型的需要来使类型更易于使用和表达。
- 隐式转换可以是重载的更简单替代方案,例如,当具有
string_view
参数的单个函数代替了std::string
和const char*
的单独重载时。 - 列表初始化语法是一种简洁且富有表现力的初始化对象的方式。
- 隐式转换会隐藏类型不匹配的错误,其中目标类型与用户的期望不符,或者用户不知道会发生任何转换。
- 隐式转换会使代码更难阅读,尤其是在存在重载的情况下,因为它使实际调用的代码不太明显。
- 采用单个参数的构造函数可能会意外地用作隐式类型转换,即使它们并非旨在这样做。
- 当单参数构造函数未标记为
explicit
时,无法可靠地判断它是旨在定义隐式转换,还是作者只是忘记标记它。 - 隐式转换可能导致调用点模糊,尤其是在存在双向隐式转换的情况下。 这可能是由于有两种类型都提供隐式转换,或者由于单个类型同时具有隐式构造函数和隐式类型转换运算符而引起的。
- 如果目标类型是隐式的,列表初始化可能会遇到相同的问题,特别是如果列表只有一个元素。
类型转换运算符以及可以使用单个参数调用的构造函数必须在类定义中标记为 explicit
。 作为例外,复制和移动构造函数不应是 explicit
,因为它们不执行类型转换。
对于设计为可互换的类型,有时可能需要和适合使用隐式转换,例如,当两种类型的对象只是相同基础值的不同表示形式时。 在这种情况下,请联系您的项目负责人以请求放弃此规则。
无法使用单个参数调用的构造函数可以省略 explicit
。 采用单个 std::initializer_list
参数的构造函数也应省略 explicit
,以支持复制初始化(例如,MyType m = {1, 2};
)。
可复制和可移动类型
类的公共 API 必须明确表明该类是可复制的、仅可移动的还是既不可复制也不可移动的。 如果这些操作对于您的类型是清晰且有意义的,请支持复制和/或移动。
可移动类型是可以从临时对象初始化和赋值的类型。
可复制类型是可以从同一类型的任何其他对象初始化或赋值的类型(因此,根据定义也是可移动的),但条件是源的值不会更改。 std::unique_ptr<int>
是可移动但不可复制的类型的示例(因为必须在分配给目标期间修改源 std::unique_ptr<int>
的值)。 int
和 std::string
是可移动类型也是可复制类型的示例。 (对于 int
,移动和复制操作相同;对于 std::string
,存在比复制更便宜的移动操作。)
对于用户定义的类型,复制行为由复制构造函数和复制赋值运算符定义。 移动行为由移动构造函数和移动赋值运算符定义(如果存在),否则由复制构造函数和复制赋值运算符定义。
在某些情况下,例如,当按值传递对象时,编译器可以隐式调用复制/移动构造函数。
可复制和可移动类型的对象可以按值传递和返回,这使得 API 更简单、更安全、更通用。 与按指针或引用传递对象不同,不会存在所有权、生命周期、可变性以及类似问题的混淆风险,也不需要在合约中指定它们。 它还防止了客户端和实现之间的非本地交互,从而使编译器更容易理解、维护和优化它们。 此外,此类对象可以与需要按值传递的通用 API(例如,大多数容器)一起使用,并且它们允许在类型组合中具有更大的灵活性。
复制/移动构造函数和赋值运算符通常比 Clone()
、CopyFrom()
或 Swap()
等替代方法更容易正确定义,因为它们可以由编译器隐式生成或使用 = default
生成。 它们简洁明了,并确保复制所有数据成员。 复制和移动构造函数通常也更有效,因为它们不需要堆分配或单独的初始化和赋值步骤,并且它们有资格进行优化,例如 复制省略。
移动操作允许从右值对象中隐式且高效地转移资源。 在某些情况下,这允许更简单的编码风格。
某些类型不需要是可复制的,并且为此类类型提供复制操作可能会令人困惑、毫无意义或完全不正确。 表示单例对象 (Registerer
)、与特定范围相关的对象 (Cleanup
) 或与对象标识紧密耦合的对象 (Mutex
) 的类型无法有意义地复制。 用于以多态方式使用的基类类型的复制操作是危险的,因为使用它们可能会导致 对象切片。 默认或粗心实施的复制操作可能不正确,并且由此产生的错误可能会令人困惑且难以诊断。
复制构造函数是隐式调用的,这使得调用很容易被忽略。 这可能会使习惯于按引用传递是常规或强制性语言的程序员感到困惑。 它也可能鼓励过度复制,这可能会导致性能问题。
每个类的公共接口都必须明确说明该类支持哪些复制和移动操作。 这通常应采用显式声明和/或删除声明的 public
部分中的相应操作的形式。
具体来说,可复制类应显式声明复制操作,仅可移动类应显式声明移动操作,不可复制/移动类应显式删除复制操作。 可复制类还可以声明移动操作,以支持高效的移动。 允许显式声明或删除所有四个复制/移动操作,但不是必需的。 如果您提供复制或移动赋值运算符,则还必须提供相应的构造函数。
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; };
只有当这些声明/删除很明显时,才可以省略它们。
- 如果类没有
private
部分,就像 struct 或纯接口基类一样,那么其可复制性/可移动性可以由任何公共数据成员的可复制性/可移动性决定。 - 如果基类明显不可复制或不可移动,那么派生类自然也不会可复制或可移动。一个只包含接口的基类,如果隐式地保留这些操作,不足以使具体的子类变得清晰。
- 请注意,如果您显式声明或删除复制构造函数或赋值操作符中的任何一个,则另一个复制操作将不明显,并且必须声明或删除。移动操作也是如此。
如果复制/移动的含义对普通用户来说不清楚,或者会产生意外的成本,则该类型不应该是可复制/可移动的。可复制类型的移动操作严格来说是一种性能优化,并且是错误和复杂性的潜在来源,因此除非它们比相应的复制操作效率高得多,否则应避免定义它们。如果您的类型提供了复制操作,建议您设计您的类,以便这些操作的默认实现是正确的。请记住像对待任何其他代码一样审查任何默认操作的正确性。
为了消除切片的风险,最好使基类成为抽象类,方法是将其构造函数声明为受保护的,将其析构函数声明为受保护的,或者给它们一个或多个纯虚成员函数。最好避免从具体类派生。
Structs vs. Classes(结构体 vs. 类)
仅对携带数据的被动对象使用 struct
;其他一切都使用 class
。
struct
和 class
关键字在 C++ 中的行为几乎相同。我们为每个关键字添加了自己的语义含义,因此您应该为要定义的数据类型使用适当的关键字。
structs
应该用于携带数据的被动对象,并且可能具有相关的常量。所有字段必须是 public 的。struct 不能有暗示不同字段之间关系的约束条件,因为直接的用户访问这些字段可能会破坏这些约束。可以存在构造函数、析构函数和辅助方法;但是,这些方法不得要求或强制执行任何约束。
如果需要更多的功能或约束,或者 struct 具有广泛的可见性并且期望演变,那么 class
更合适。如果不确定,请使用 class
。
为了与 STL 保持一致,您可以将 struct
代替 class
用于无状态类型,例如 traits、模板元函数和一些 functors。
请注意,struct 和 class 中的成员变量具有不同的命名规则。
Structs vs. Pairs and Tuples(结构体 vs. 对和元组)
只要元素可以具有有意义的名称,就更喜欢使用 struct
而不是 pair 或 tuple。
虽然使用 pair 和 tuple 可以避免定义自定义类型的需要,从而可能节省编写代码的工作量,但有意义的字段名称在阅读代码时几乎总是比 .first
、.second
或 std::get<X>
更清晰。虽然 C++14 引入了 std::get<Type>
来按类型而不是索引访问元组元素(当类型唯一时)有时可以部分缓解这个问题,但字段名称通常比类型更清晰且更具信息性。
Pairs 和 tuples 可能适用于泛型代码,其中 pair 或 tuple 的元素没有特定的含义。为了与现有代码或 API 互操作,也可能需要使用它们。
Inheritance(继承)
组合通常比继承更合适。使用继承时,使其为 public
。
当子类从基类继承时,它包括基类定义的所有数据和操作。“接口继承”是从纯抽象基类(没有状态或定义的方法)继承;所有其他继承都是“实现继承”。
实现继承通过重用基类代码来专门化现有类型,从而减少代码大小。因为继承是编译时声明,所以您和编译器可以理解该操作并检测错误。接口继承可用于以编程方式强制类公开特定的 API。同样,编译器可以检测错误,在这种情况下,当类没有定义 API 的必要方法时。
对于实现继承,因为实现子类的代码分布在基类和子类之间,所以理解实现可能更加困难。子类无法覆盖非虚函数,因此子类无法更改实现。
多重继承尤其存在问题,因为它通常会带来更高的性能开销(事实上,从单继承到多重继承的性能下降通常大于从普通分派到虚拟分派的性能下降),并且因为它有导致“菱形”继承模式的风险,这种模式容易产生歧义、混淆和直接的错误。
所有继承都应该是 public
。如果您想进行私有继承,您应该包含基类的实例作为成员。如果您不打算支持将类用作基类,则可以在类上使用 final
。
不要过度使用实现继承。组合通常更合适。尽量将继承的使用限制在“is-a”情况:如果可以合理地说 Bar
“是一种”Foo
,则 Bar
是 Foo
的子类。
将 protected
的使用限制在可能需要从子类访问的成员函数。请注意,数据成员应为 private
。
使用 override
或(较少使用)final
说明符显式地注解对虚函数或虚析构函数的覆盖。在声明覆盖时不要使用 virtual
。理由:标记为 override
或 final
且不是基类虚函数的覆盖的函数或析构函数将无法编译,这有助于捕获常见错误。这些说明符充当文档;如果不存在说明符,则读者必须检查相关类的所有祖先以确定函数或析构函数是否为虚函数。
允许多重继承,但强烈建议不要使用多重实现继承。
Operator Overloading(运算符重载)
谨慎地重载运算符。不要使用用户定义的字面量。
C++ 允许用户代码使用 operator
关键字声明内置运算符的重载版本,只要其中一个参数是用户定义的类型。 operator
关键字还允许用户代码使用 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
,定义一个非成员 operator==
,并记录何时类型 T
的两个值被认为是相等的。如果对于类型 T
的值 t1
小于另一个此类值 t2
存在一个明显的概念,那么您也可以定义 operator<=>
,它应该与 operator==
一致。最好不要重载其他比较和排序运算符。
不要刻意避免定义运算符重载。例如,更喜欢定义 ==
、=
和 <<
,而不是 Equals()
、CopyFrom()
和 PrintTo()
。相反,不要仅仅因为其他库期望它们就定义运算符重载。例如,如果您的类型没有自然的顺序,但您想将其存储在 std::set
中,请使用自定义比较器而不是重载 <
。
不要重载 &&
、||
、,
(逗号) 或一元 &
。不要重载 operator""
,即不要引入用户定义的字面量。不要使用其他人(包括标准库)提供的任何此类字面量。
类型转换运算符在关于隐式转换的部分中讨论。 =
运算符在关于复制构造函数的部分中讨论。与流一起使用的 <<
重载在关于流的部分中讨论。另请参阅关于函数重载的规则,这些规则也适用于运算符重载。
Access Control(访问控制)
除非是常量,否则将类的数据成员设为 private
。这简化了对不变式的推理,但如果需要,会增加一些简单的访问器(通常是 const
)样板代码。
由于技术原因,当使用 Google Test 时,我们允许在 .cc
文件中定义的测试 fixture 类的数据成员为 protected
。如果测试 fixture 类是在它使用的 .cc
文件之外定义的,例如在 .h
文件中,则将数据成员设为 private
。
声明顺序
将相似的声明组合在一起,并将 public
部分放在前面。
一个类的定义通常应该以 public:
部分开始,然后是 protected:
,最后是 private:
。省略为空的部分。
在每个部分中,最好将相似类型的声明组合在一起,并首选以下顺序:
- 类型和类型别名(
typedef
、using
、enum
、嵌套结构和类以及friend
类型) - (可选,仅适用于结构体)非
static
数据成员 - 静态常量
- 工厂函数
- 构造函数和赋值运算符
- 析构函数
- 所有其他函数(
static
和非static
成员函数以及friend
函数) - 所有其他数据成员(静态和非静态)
不要在类定义中内联定义大型方法。通常,只有琐碎的或对性能至关重要的、非常短的方法才可以内联定义。有关更多详细信息,请参阅 内联函数。
函数
输入和输出
C++ 函数的输出自然通过返回值提供,有时通过输出参数(或输入/输出参数)提供。
优先使用返回值而不是输出参数:它们提高了可读性,并且通常提供相同或更好的性能。参见 TotW #176。
优先按值返回,否则,按引用返回。避免返回原始指针,除非它可以为空。
参数要么是函数的输入,要么是函数的输出,要么两者都是。非可选的输入参数通常应该是值或 const
引用,而非可选的输出和输入/输出参数通常应该是引用(不能为 null)。通常,使用 std::optional
表示可选的按值输入,并在非可选形式使用引用的情况下使用 const
指针。使用非 const
指针表示可选的输出和可选的输入/输出参数。
避免定义需要引用参数在调用后仍然存在的函数。在某些情况下,引用参数可以绑定到临时对象,从而导致生存期错误。相反,找到一种方法来消除生存期要求(例如,通过复制参数),或者通过指针传递保留的参数并记录生存期和非空要求。有关更多信息,请参见 TotW 116。
在对函数参数进行排序时,将所有仅输入参数放在任何输出参数之前。特别地,不要仅仅因为它们是新的就将新参数添加到函数的末尾;将新的仅输入参数放在输出参数之前。这不是一个硬性规定。既是输入又是输出的参数会使情况变得复杂,并且,与相关函数保持一致可能需要您弯曲规则。可变参数函数也可能需要不寻常的参数顺序。
编写短函数
优先使用小型且集中的函数。
我们认识到长函数有时是合适的,因此对函数长度没有硬性限制。如果一个函数超过大约 40 行,请考虑是否可以在不损害程序结构的情况下将其分解。
即使你的长函数现在运行良好,几个月后修改它的人可能会添加新的行为。这可能会导致难以发现的错误。保持函数的简短和简单,可以使其他人更容易阅读和修改你的代码。小函数也更容易测试。
在处理某些代码时,你可能会发现冗长而复杂的函数。不要被修改现有代码所吓倒:如果处理这样的函数被证明很困难,你发现错误很难调试,或者你想在几个不同的上下文中使用它的一部分,请考虑将该函数分解成更小更易于管理的部分。
函数重载
仅当读者在查看调用点时,可以很好地了解正在发生的事情,而无需首先弄清楚正在调用哪个重载时,才使用重载函数(包括构造函数)。
你可以编写一个接受 const std::string&
的函数,并使用另一个接受 const char*
的函数对其进行重载。但是,在这种情况下,请考虑使用 std::string_view
代替。
class MyClass { public: void Analyze(const std::string &text); void Analyze(const char *text, size_t textlen); };
重载可以通过允许具有相同名称的函数接受不同的参数来使代码更直观。它对于模板化代码可能是必要的,并且对于 Visitors 来说可能很方便。
基于 const
或 ref 限定的重载可以使实用程序代码更易于使用、更高效或两者兼而有之。请参阅 TotW #148 了解更多信息。
如果一个函数仅通过参数类型重载,则读者可能必须了解 C++ 复杂的匹配规则才能知道发生了什么。而且,如果派生类仅覆盖函数的一些变体,许多人会对继承的语义感到困惑。
当变体之间没有语义差异时,你可以重载函数。这些重载可能在类型、限定符或参数计数方面有所不同。但是,此类调用的读者不需要知道选择了重载集的哪个成员,只需知道正在调用该集合中的某些东西。如果你可以在 header 中使用单个注释记录重载集中的所有条目,那么这是一个设计良好的重载集的良好标志。
默认参数
当保证默认值始终具有相同的值时,允许在非虚函数上使用默认参数。遵循与 函数重载 相同的限制,并且如果使用默认参数获得的可读性不足以抵消下面的缺点,则首选重载函数。
通常,你有一个使用默认值的函数,但有时你想覆盖默认值。默认参数允许一种简单的方法来做到这一点,而不必为罕见的异常定义许多函数。与重载函数相比,默认参数具有更清晰的语法,更少的样板代码以及“必需”和“可选”参数之间更清晰的区别。
默认参数是实现重载函数语义的另一种方式,因此所有 不重载函数的原因都适用。
虚函数调用中参数的默认值由目标对象的静态类型决定,并且不能保证给定函数的所有覆盖都声明相同的默认值。
默认参数在每个调用点重新评估,这会使生成的代码膨胀。读者也可能期望默认值在声明时是固定的,而不是在每次调用时都变化。
函数指针在存在默认参数的情况下会令人困惑,因为函数签名通常与调用签名不匹配。添加函数重载可以避免这些问题。
默认参数在虚函数上是被禁止的,因为它们无法正常工作,并且在指定默认值可能无法根据评估时间评估为相同值的情况下也是如此。(例如,不要写 void f(int n = counter++);
。)
在其他一些情况下,默认参数可以提高函数声明的可读性,足以克服上述缺点,因此允许使用它们。如有疑问,请使用重载。
尾置返回类型语法
仅在使用普通语法(前置返回类型)不切实际或可读性差得多时才使用尾置返回类型。
C++ 允许两种不同的函数声明形式。在较旧的形式中,返回类型出现在函数名称之前。例如
int foo(int x);
较新的形式在函数名称之前使用 auto
关键字,并在参数列表之后使用尾置返回类型。例如,上面的声明可以等效地写成
auto foo(int x) -> int;
尾置返回类型位于函数的作用域中。这对于像 int
这样的简单情况没有区别,但对于更复杂的情况(例如在类作用域中声明的类型或根据函数参数编写的类型)很重要。
尾置返回类型是显式指定 lambda 表达式的返回类型的唯一方法。在某些情况下,编译器能够推断 lambda 的返回类型,但并非所有情况都如此。即使编译器可以自动推断它,有时显式指定它对于读者来说会更清楚。
有时,在函数参数列表已经出现之后指定返回类型更容易且更具可读性。当返回类型取决于模板参数时尤其如此。例如
template <typename T, typename U> auto add(T t, U u) -> decltype(t + u);与
template <typename T, typename U> decltype(declval<T&>() + declval<U&>()) add(T t, U u);
尾置返回类型语法相对较新,并且在 C 和 Java 等类 C++ 语言中没有类似的形式,因此一些读者可能会觉得它不熟悉。
现有的代码库中有大量的函数声明不会被更改为使用新语法,因此实际的选择是仅使用旧语法或混合使用这两种语法。使用单一版本对于样式的一致性更好。
在大多数情况下,继续使用旧样式的函数声明,其中返回类型位于函数名称之前。仅在需要使用新的尾置返回类型形式(例如 lambdas)或通过将类型放在函数参数列表之后,允许你以更易读的方式编写类型的情况下使用。后一种情况应该很少见;这主要是相当复杂的模板代码中的一个问题,在大多数情况下不鼓励使用模板代码。
Google 特有的技巧
我们使用各种技巧和实用程序来使 C++ 代码更加健壮,并且我们使用 C++ 的方式可能与你在其他地方看到的不同。
所有权和智能指针
首选为动态分配的对象设置单一的、固定的所有者。首选使用智能指针传递所有权。
“所有权”是一种用于管理动态分配的内存(和其他资源)的记账技术。动态分配对象的所有者是负责确保在不再需要时将其删除的对象或函数。所有权有时可以是共享的,在这种情况下,最后一个所有者通常负责删除它。即使所有权不是共享的,也可以从一段代码转移到另一段代码。
“智能”指针是像指针一样操作的类,例如,通过重载 *
和 ->
运算符。一些智能指针类型可用于自动执行所有权记账,以确保这些职责得到满足。 std::unique_ptr
是一种智能指针类型,它表示对动态分配对象的独占所有权;当 std::unique_ptr
超出范围时,该对象将被删除。它不能被复制,但可以移动以表示所有权转移。 std::shared_ptr
是一种智能指针类型,它表示对动态分配对象的共享所有权。 std::shared_ptr
可以被复制;对象的所有权在所有副本之间共享,并且当最后一个 std::shared_ptr
被销毁时,该对象将被删除。
- 如果没有某种所有权逻辑,几乎不可能管理动态分配的内存。
- 转移对象的所有权可能比复制它更便宜(如果甚至可以复制它)。
- 转移所有权可能比“借用”指针或引用更简单,因为它减少了协调两个用户之间对象生存期的需求。
- 智能指针可以通过使所有权逻辑显式、自记录和明确来提高可读性。
- 智能指针可以消除手动所有权记账,简化代码并消除大量错误。
- 对于
const
对象,共享所有权可以成为深度复制的简单而有效的替代方案。
- 所有权必须通过指针(智能指针或普通指针)来表示和传递。指针语义比值语义更复杂,尤其是在 API 中:您不仅要担心所有权,还要担心别名、生命周期和可变性等问题。
- 值语义的性能成本通常被高估,因此所有权转移的性能优势可能无法证明其在可读性和复杂性方面的成本是合理的。
- 转移所有权的 API 会将其客户端强制到一个单一的内存管理模型中。
- 使用智能指针的代码在资源释放发生的位置方面不太明确。
std::unique_ptr
使用移动语义来表达所有权转移,而移动语义相对较新,可能会让一些程序员感到困惑。- 共享所有权可能是一个诱人的选择,可以避免仔细的所有权设计,但它会模糊系统的设计。
- 共享所有权需要在运行时进行显式记账,这可能会带来很大的成本。
- 在某些情况下(例如,循环引用),具有共享所有权的对象可能永远不会被删除。
- 智能指针并非完美地替代普通指针。
如果必须进行动态分配,请优先将所有权保留在分配它的代码中。如果其他代码需要访问该对象,请考虑传递一个副本,或者传递一个指针或引用而不转移所有权。优先使用 std::unique_ptr
来显式地进行所有权转移。例如
std::unique_ptr<Foo> FooFactory(); void FooConsumer(std::unique_ptr<Foo> ptr);
如果没有充分的理由,请不要设计您的代码来使用共享所有权。一个这样的理由是避免昂贵的复制操作,但只有在性能优势显著且底层对象是不可变的(即 std::shared_ptr<const Foo>
)时才应该这样做。如果您确实使用了共享所有权,请优先使用 std::shared_ptr
。
永远不要使用 std::auto_ptr
。相反,使用 std::unique_ptr
。
cpplint
使用 cpplint.py
检测样式错误。
cpplint.py
是一个读取源文件并识别许多样式错误的工具。它并不完美,既有误报也有漏报,但仍然是一个有价值的工具。
某些项目有关于如何从其项目工具运行 cpplint.py
的说明。如果您要贡献的项目没有,您可以单独下载 cpplint.py
。
其他 C++ 特性
右值引用
仅在下面列出的某些特殊情况下使用右值引用。
右值引用是一种只能绑定到临时对象的引用类型。其语法类似于传统的引用语法。例如,void f(std::string&& s);
声明了一个函数,其参数是对 std::string
的右值引用。
当令牌 '&&' 应用于函数参数中未限定的模板参数时,将应用特殊的模板参数推导规则。 这样的引用被称为转发引用。
- 定义移动构造函数(采用对类类型的右值引用的构造函数)可以移动一个值而不是复制它。 例如,如果
v1
是一个std::vector<std::string>
,那么auto v2(std::move(v1))
可能会导致一些简单的指针操作,而不是复制大量数据。 在许多情况下,这可以带来重大的性能提升。 - 右值引用可以实现可移动但不可复制的类型,这对于没有合理的复制定义的类型很有用,但您可能仍然希望将它们作为函数参数传递,将它们放入容器中等等。
std::move
对于有效使用某些标准库类型(例如std::unique_ptr
)是必需的。- 转发引用 使用右值引用标记,可以编写一个通用的函数包装器,将它的参数转发给另一个函数,并且无论它的参数是临时对象还是 const 对象,它都能工作。 这被称为“完美转发”。
- 右值引用尚未被广泛理解。 引用折叠规则以及转发引用的特殊推导规则有些晦涩。
- 右值引用经常被误用。 在函数调用之后期望参数具有有效的指定状态,或者不执行任何移动操作的签名中使用右值引用是违反直觉的。
除非如下所述,否则不要使用右值引用(或将 &&
限定符应用于方法)。
- 您可以将它们用于定义移动构造函数和移动赋值运算符(如 可复制和可移动类型 中所述)。
- 您可以将它们用于定义逻辑上“消耗”
*this
的&&
限定的方法,使其处于不可用或空状态。 请注意,这仅适用于方法限定符(位于函数签名的右括号之后); 如果您想“消耗”一个普通函数参数,请优先按值传递它。 - 您可以结合使用转发引用和
std::forward
来支持完美转发。 - 您可以使用它们来定义重载对,例如一个采用
Foo&&
,另一个采用const Foo&
。 通常,首选的解决方案只是按值传递,但重载的函数对有时会产生更好的性能,例如,如果这些函数有时不消耗输入。 与往常一样:如果您为了提高性能而编写更复杂的代码,请确保您有证据表明它实际上有帮助。
友元
我们允许在合理范围内使用 friend
类和函数。
友元通常应该在同一个文件中定义,这样读者就不必在另一个文件中查找对类的私有成员的使用。 friend
的常见用途是让 FooBuilder
类成为 Foo
的友元,以便它可以正确地构造 Foo
的内部状态,而不会将此状态暴露给外界。 在某些情况下,使单元测试类成为其测试的类的友元可能很有用。
友元扩展但不会破坏类的封装边界。 在某些情况下,当您只想允许一个其他类访问成员时,这比将成员设置为 public
更好。 但是,大多数类应该仅通过其公共成员与其他类进行交互。
异常
我们不使用 C++ 异常。
- 异常允许应用程序的更高级别决定如何处理深层嵌套函数中“不可能发生”的故障,而无需模糊不清且容易出错的错误代码簿记。
- 大多数其他现代语言都使用异常。 在 C++ 中使用它们会使其与 Python、Java 和其他人熟悉的 C++ 更加一致。
- 一些第三方 C++ 库使用异常,在内部关闭它们会使与这些库集成变得更加困难。
- 异常是构造函数失败的唯一方法。 我们可以使用工厂函数或
Init()
方法来模拟这一点,但这分别需要堆分配或新的“无效”状态。 - 异常在测试框架中非常方便。
- 当您向现有函数添加
throw
语句时,您必须检查其所有传递调用者。 他们要么必须至少提供基本的异常安全保证,要么他们绝不能捕获异常并对程序因此终止感到满意。 例如,如果f()
调用g()
调用h()
,并且h
抛出一个f
捕获的异常,则g
必须小心,否则它可能无法正确清理。 - 更一般地说,异常使得通过查看代码难以评估程序的控制流:函数可能会在您意想不到的地方返回。 这会导致可维护性和调试困难。 您可以通过一些关于如何以及在何处使用异常的规则来最小化此成本,但这需要开发人员知道和理解更多内容。
- 异常安全需要 RAII 和不同的编码实践。 需要大量的支持机制来使编写正确的异常安全代码变得容易。 此外,为了避免要求读者理解整个调用图,异常安全的代码必须将写入持久状态的逻辑隔离到“提交”阶段。 这将带来好处和成本(可能在你被迫模糊代码以隔离提交的地方)。 允许异常会迫使我们始终支付这些成本,即使它们不值得。
- 启用异常会向生成的每个二进制文件中添加数据,从而增加编译时间(可能略微增加)并可能增加地址空间压力。
- 异常的可用性可能会鼓励开发人员在不适当的时候抛出异常,或者在不安全的时候从异常中恢复。 例如,无效的用户输入不应导致抛出异常。 我们需要使样式指南更长才能记录这些限制!
从表面上看,使用异常的好处大于成本,尤其是在新项目中。 然而,对于现有代码,引入异常会对所有依赖代码产生影响。 如果异常可以传播到新项目之外,那么将新项目集成到现有无异常代码中也会出现问题。 因为 Google 的大多数现有 C++ 代码都没有准备好处理异常,所以采用生成异常的新代码相对困难。
鉴于 Google 的现有代码不容忍异常,因此使用异常的成本略高于新项目中的成本。 转换过程将缓慢且容易出错。 我们不认为异常的可用替代方案(例如错误代码和断言)会带来重大负担。
我们反对使用异常的建议不是基于哲学或道德理由,而是基于实践理由。 因为我们希望在 Google 使用我们的开源项目,并且如果这些项目使用异常,则很难做到这一点,所以我们需要建议 Google 开源项目不要使用异常。 如果我们必须从头开始做所有事情,情况可能会有所不同。
此禁令也适用于与异常处理相关的功能,例如 std::exception_ptr
和 std::nested_exception
。
对于 Windows 代码,此规则(没有双关语)存在 例外。
noexcept
在有用且正确时指定 noexcept
。
noexcept
说明符用于指定函数是否会抛出异常。 如果异常从标记为 noexcept
的函数中逸出,则程序将通过 std::terminate
崩溃。
noexcept
运算符执行编译时检查,如果表达式声明为不抛出任何异常,则返回 true。
- 将移动构造函数指定为
noexcept
可以在某些情况下提高性能,例如,如果 T 的移动构造函数为noexcept
,则std::vector<T>::resize()
移动对象而不是复制对象。 - 在启用异常的环境中,在函数上指定
noexcept
可以触发编译器优化,例如,如果编译器知道由于noexcept
说明符而不会抛出任何异常,则编译器不必生成额外的堆栈展开代码。
- 在遵循本指南且禁用异常的项目中,很难确保
noexcept
说明符的正确性,并且很难定义正确性甚至意味着什么。 - 撤销
noexcept
很困难(如果不是不可能),因为它消除了调用者可能依赖的保证,其方式很难检测。
如果 noexcept
准确地反映了您函数的预期语义,即,如果以某种方式从函数体内部抛出异常,则表示致命错误,那么您可以在性能方面有用时使用 noexcept
。 您可以假设移动构造函数上的 noexcept
具有有意义的性能优势。 如果您认为在某些其他函数上指定 noexcept
会带来显着的性能优势,请与您的项目负责人讨论。
如果完全禁用了异常(即大多数 Google C++ 环境),请优先使用无条件的 noexcept
。否则,使用带有简单条件的有条件 noexcept
说明符,使其仅在函数可能抛出异常的极少数情况下评估为 false。这些测试可能包括类型特征检查,以确定所涉及的操作是否可能抛出异常(例如,用于移动构造对象的 std::is_nothrow_move_constructible
),或者分配是否可能抛出异常(例如,用于标准默认分配的 absl::default_allocator_is_nothrow
)。请注意,在许多情况下,异常的唯一可能原因是分配失败(我们认为移动构造函数不应抛出异常,除非由于分配失败),并且在许多应用程序中,将内存耗尽视为致命错误而不是程序应尝试从中恢复的异常情况是合适的。即使对于其他潜在的失败,您也应优先考虑接口的简洁性,而不是支持所有可能的异常抛出场景:例如,与其编写一个复杂的 noexcept
子句,该子句取决于哈希函数是否可以抛出异常,不如简单地记录您的组件不支持哈希函数抛出异常,并使其无条件地 noexcept
。
运行时类型信息 (RTTI)
避免使用运行时类型信息 (RTTI)。
RTTI 允许程序员在运行时查询对象的 C++ 类。这通过使用 typeid
或 dynamic_cast
来完成。
RTTI 的标准替代方案(如下所述)需要修改或重新设计有问题的类层次结构。有时,这种修改是不可行的或不希望的,尤其是在广泛使用或成熟的代码中。
RTTI 在某些单元测试中可能很有用。例如,它在工厂类的测试中很有用,在这些测试中,测试必须验证新创建的对象是否具有预期的动态类型。它在管理对象及其模拟之间的关系时也很有用。
在考虑多个抽象对象时,RTTI 很有用。考虑
bool Base::Equal(Base* other) = 0; bool Derived::Equal(Base* other) { Derived* that = dynamic_cast<Derived*>(other); if (that == nullptr) return false; ... }
在运行时查询对象的类型通常意味着设计问题。需要在运行时知道对象的类型通常表明您的类层次结构的设计存在缺陷。
不加约束地使用 RTTI 会使代码难以维护。它可能导致基于类型的决策树或 switch 语句散布在整个代码中,在进行进一步更改时必须检查所有这些内容。
RTTI 具有合法的用途,但容易被滥用,因此您在使用它时必须小心。您可以在单元测试中自由使用它,但在其他代码中尽可能避免使用它。特别是,在编写新代码之前三思而后行。如果您发现自己需要编写基于对象的类以不同方式执行的代码,请考虑以下查询类型的替代方法之一
- 虚拟方法是根据特定子类类型执行不同代码路径的首选方法。这会将工作放在对象本身内部。
- 如果工作属于对象外部,而是属于某些处理代码,请考虑双重分发解决方案,例如访问者设计模式。这允许对象外部的工具使用内置类型系统确定类的类型。
当程序的逻辑保证基类的给定实例实际上是特定派生类的实例时,可以自由地在对象上使用 dynamic_cast
。通常,在这种情况下可以使用 static_cast
作为替代方法。
基于类型的决策树强烈表明您的代码走错了方向。
if (typeid(*data) == typeid(D1)) { ... } else if (typeid(*data) == typeid(D2)) { ... } else if (typeid(*data) == typeid(D3)) { ...
当向类层次结构添加其他子类时,此类代码通常会中断。此外,当子类的属性更改时,很难找到并修改所有受影响的代码段。
不要手动实现类似 RTTI 的解决方法。反对 RTTI 的论点同样适用于带有类型标签的类层次结构等解决方法。此外,解决方法掩盖了您的真实意图。
类型转换
使用 C++ 风格的类型转换,例如 static_cast<float>(double_value)
,或者使用大括号初始化来转换算术类型,例如 int64_t y = int64_t{1} << 42
。除非强制转换为 void
,否则不要使用 (int)x
这样的类型转换格式。仅当 T
是类类型时,才可以使用 T(x)
这样的类型转换格式。
C++ 引入了与 C 不同的类型转换系统,该系统区分了类型转换操作的类型。
C 风格类型转换的问题是操作的歧义;有时您在进行转换(例如,(int)3.5
),有时您在进行强制类型转换(例如,(int)"hello"
)。大括号初始化和 C++ 类型转换通常可以帮助避免这种歧义。此外,C++ 类型转换在搜索时更明显。
C++ 风格的类型转换语法冗长而繁琐。
一般来说,不要使用 C 风格的类型转换。相反,当需要显式类型转换时,请使用这些 C++ 风格的类型转换。
- 使用大括号初始化来转换算术类型(例如,
int64_t{x}
)。这是最安全的方法,因为如果转换可能导致信息丢失,代码将无法编译。语法也很简洁。 - 当显式转换为类类型时,请使用函数式类型转换。例如,优先选择
std::string(some_cord)
而不是static_cast<std::string>(some_cord)
。 - 使用
absl::implicit_cast
安全地向上转换类型层次结构,例如,将Foo*
转换为SuperclassOfFoo*
或将Foo*
转换为const Foo*
。C++ 通常会自动执行此操作,但某些情况需要显式向上转换,例如使用?:
运算符。 - 当您需要显式地将指针从类向上转换为其超类,或者需要显式地将指针从超类转换为子类时,请使用
static_cast
作为 C 风格类型转换的等效形式,它可以进行值转换。在后一种情况下,您必须确保您的对象实际上是子类的实例。 - 使用
const_cast
删除const
限定符(参见 const)。 - 使用
reinterpret_cast
执行指针类型与整数和其他指针类型(包括void*
)之间的不安全转换。仅当您知道自己在做什么并且了解别名问题时才使用它。此外,请考虑取消引用指针(无需强制转换)并使用std::bit_cast
强制转换结果值。 - 使用
std::bit_cast
来使用相同大小的不同类型解释值的原始位(类型双关),例如将double
的位解释为int64_t
。
有关 dynamic_cast
的使用指南,请参见 RTTI 部分。
流
在适当的情况下使用流,并坚持使用“简单”用法。仅为表示值的类型重载 <<
以进行流式传输,并且仅写入用户可见的值,而不写入任何实现细节。
流是 C++ 中的标准 I/O 抽象,如标准头文件 <iostream>
所示。它们在 Google 代码中被广泛使用,主要用于调试日志记录和测试诊断。
<<
和 >>
流运算符提供了一个格式化的 I/O API,易于学习、可移植、可重用和可扩展。相比之下,printf
甚至不支持 std::string
,更不用说用户定义的类型了,并且很难移植。 printf
还迫使您在众多略有不同的该函数的版本中进行选择,并浏览数十个转换说明符。
流通过 std::cin
、std::cout
、std::cerr
和 std::clog
提供对控制台 I/O 的一流支持。 C API 也这样做,但受到需要手动缓冲输入的阻碍。
- 可以通过改变流的状态来配置流格式。 这种突变是持久的,因此您的代码的行为可能会受到流的整个先前历史的影响,除非您竭尽全力每次都将其恢复到已知状态,否则其他代码可能已经触摸过它。 用户代码不仅可以修改内置状态,还可以通过注册系统添加新的状态变量和行为。
- 由于上述问题、代码和数据混合在流代码中的方式以及运算符重载的使用(可能会选择与您期望不同的重载),因此很难精确地控制流输出。
- 通过
<<
运算符链构建输出的实践会干扰国际化,因为它将单词顺序嵌入到代码中,并且流对本地化的支持是 有缺陷的。 - 流 API 非常微妙和复杂,因此程序员必须通过经验才能有效地使用它。
- 对于编译器来说,解析
<<
的许多重载非常昂贵。 当在大型代码库中广泛使用时,它可能会消耗高达 20% 的解析和语义分析时间。
仅当流是完成工作的最佳工具时才使用流。 通常,当 I/O 是临时的、本地的、人类可读的并且面向其他开发人员而不是最终用户时,情况就是如此。 与您周围的代码以及整个代码库保持一致; 如果有一个已建立的工具来解决您的问题,请使用该工具代替。 特别是,对于诊断输出,日志记录库通常比 std::cerr
或 std::clog
更好,并且 absl/strings
或等效项中的库通常比 std::stringstream
更好。
避免使用流来处理面向外部用户或处理不受信任数据的 I/O。 相反,请查找并使用适当的模板库来处理诸如国际化、本地化和安全加固之类的问题。
如果您确实使用流,请避免使用流 API 的有状态部分(错误状态除外),例如 imbue()
、xalloc()
和 register_callback()
。 使用显式格式化函数(例如 absl::StreamFormat()
)而不是流操纵器或格式化标志来控制格式化细节,例如数字基数、精度或填充。
仅当您的类型表示值时,才将 <<
重载为类型的流运算符,并且 <<
写出该值的人类可读的字符串表示形式。 避免在 <<
的输出中公开实现细节; 如果您需要打印对象内部以进行调试,请改用命名的函数(名为 DebugString()
的方法是最常见的约定)。
前置递增和前置递减
除非您需要后置语义,否则请使用递增和递减运算符的前缀形式 (++i
)。
当变量递增 (++i
或 i++
) 或递减 (--i
或 i--
) 并且不使用表达式的值时,必须决定是前置递增(递减)还是后置递增(递减)。
后置递增/递减表达式的计算结果为修改之前的值。 这可能会导致代码更紧凑但更难以阅读。 前缀形式通常更易于阅读,永远不会效率较低,并且效率可能更高,因为它不需要复制操作之前的值。
在 C 中,已经形成了使用后置递增的传统,即使不使用表达式值,尤其是在 for
循环中。
使用前置递增/递减,除非代码明确需要后置递增/递减表达式的结果。
const 的使用
在 API 中,只要有意义就使用 const
。 对于 const 的某些用途,constexpr
是更好的选择。
可以将声明的变量和参数放在关键字 const
前面,以指示变量未更改(例如,const int foo
)。 类函数可以具有 const
限定符,以指示该函数不更改类成员变量的状态(例如,class Foo { int Bar(char c) const; };
)。
更容易理解变量的使用方式。允许编译器进行更好的类型检查,并且,可能生成更好的代码。帮助人们确信程序的正确性,因为他们知道他们调用的函数在修改你的变量方面是有限制的。帮助人们知道哪些函数在多线程程序中可以安全使用,而无需加锁。
const
具有传染性:如果你将一个 const
变量传递给一个函数,该函数必须在其原型中包含 const
(或者该变量需要一个 const_cast
)。当调用库函数时,这可能会成为一个特殊的问题。
我们强烈建议在 API 中(例如,在函数参数、方法和非局部变量上)有意义且准确地使用 const
。这提供了一致的、主要由编译器验证的文档,说明操作可以改变哪些对象。拥有一致且可靠的方式来区分读取和写入对于编写线程安全的代码至关重要,并且在许多其他上下文中也很有用。特别是
- 如果一个函数保证它不会修改通过引用或指针传递的参数,那么相应的函数参数应该是引用到 const(
const T&
)或指针到 const(const T*
)。 - 对于按值传递的函数参数,
const
对调用者没有影响,因此不建议在函数声明中使用。参见 TotW #109。 - 声明方法为
const
,除非它们改变了对象的逻辑状态(或者允许用户修改该状态,例如,通过返回一个非const
引用,但这很少见),或者它们不能安全地并发调用。
鼓励或不鼓励在局部变量上使用 const
。
一个类的所有 const
操作都应该是可以彼此并发安全调用的。如果这是不可行的,则必须将该类清楚地记录为“线程不安全”。
const 放在哪里
有些人倾向于 int const *foo
的形式,而不是 const int* foo
。他们认为这更具可读性,因为它更一致:它保持了 const
总是跟随它所描述的对象的规则。但是,这种一致性论点不适用于具有少量深度嵌套指针表达式的代码库,因为大多数 const
表达式只有一个 const
,并且它应用于底层值。在这种情况下,没有一致性需要维护。将 const
放在前面可以说是更具可读性的,因为它遵循英语,将“形容词”(const
)放在“名词”(int
)之前。
也就是说,虽然我们鼓励将 const
放在前面,但我们不强制要求。但要与你周围的代码保持一致!
constexpr、constinit 和 consteval 的使用
使用 constexpr
来定义真常量或确保常量初始化。使用 constinit
来确保非常量变量的常量初始化。
某些变量可以声明为 constexpr
,以指示这些变量是真常量,即在编译/链接时固定。某些函数和构造函数可以声明为 constexpr
,这使得它们可以用于定义 constexpr
变量。函数可以声明为 consteval
以限制它们在编译时使用。
使用 constexpr
可以定义带有浮点表达式的常量,而不仅仅是字面量;定义用户定义类型的常量;以及定义带有函数调用的常量。
过早地将某些东西标记为 constexpr
可能会在以后必须降级时导致迁移问题。当前对 constexpr
函数和构造函数中允许的内容的限制可能会导致这些定义中出现模糊的解决方法。
constexpr
定义可以更可靠地指定接口的常量部分。使用 constexpr
来指定真常量和支持其定义的函数。consteval
可用于必须不在运行时调用的代码。避免复杂化函数定义以使其与 constexpr
一起使用。不要使用 constexpr
或 consteval
来强制内联。
整数类型
在内置的 C++ 整数类型中,唯一使用的是 int
。如果程序需要不同大小的整数类型,请使用 <stdint.h>
中的精确宽度整数类型,例如 int16_t
。如果你有一个可能大于或等于 2^31 的值,请使用 64 位类型,例如 int64_t
。请记住,即使你的值永远不会太大而无法放入 int
中,它也可能在中间计算中使用,这可能需要更大的类型。如有疑问,请选择更大的类型。
C++ 没有为整数类型(如 int
)指定确切的大小。当代架构上的常见大小是 short
为 16 位,int
为 32 位,long
为 32 或 64 位,long long
为 64 位,但不同的平台做出不同的选择,特别是对于 long
。
声明的统一性。
C++ 中整数类型的大小可能因编译器和架构而异。
标准库头文件 <stdint.h>
定义了诸如 int16_t
、uint32_t
、int64_t
等类型。当你需要保证整数的大小时,你应该始终优先使用它们,而不是 short
、unsigned long long
等。对于这些类型,最好省略 std::
前缀,因为额外的 5 个字符不值得增加混乱。在内置整数类型中,只应使用 int
。在适当的情况下,欢迎你使用标准类型别名,如 size_t
和 ptrdiff_t
。
我们经常使用 int
,对于我们知道不会太大的整数,例如,循环计数器。对于此类事情,请使用普通的 int
。你应该假设一个 int
至少是 32 位,但不要假设它超过 32 位。如果你需要一个 64 位整数类型,请使用 int64_t
或 uint64_t
。
对于我们知道可能“很大”的整数,请使用 int64_t
。
你不应该使用无符号整数类型,例如 uint32_t
,除非有正当理由,例如表示位模式而不是数字,或者你需要定义的模 2^N 溢出。特别是,不要使用无符号类型来说一个数字永远不会是负数。相反,使用断言来实现这一点。
如果你的代码是一个返回大小的容器,请务必使用一种可以容纳你的容器的任何可能用法的类型。如有疑问,请使用较大的类型而不是较小的类型。
转换整数类型时要小心。整数转换和提升可能导致未定义的行为,从而导致安全漏洞和其他问题。
关于无符号整数
无符号整数非常适合表示位域和模运算。由于历史原因,C++ 标准还使用无符号整数来表示容器的大小 - 标准机构的许多成员认为这是一个错误,但在这一点上实际上不可能修复。无符号算术不模拟简单整数的行为,而是由标准定义为模拟模运算(在溢出/下溢时回绕)这一事实意味着编译器无法诊断一类重要的错误。在其他情况下,定义的行为会阻碍优化。
也就是说,整数类型的有符号性混合也会导致同样多的问题。我们能提供的最好的建议是:尽量使用迭代器和容器而不是指针和大小,尽量不要混合有符号性,尽量避免使用无符号类型(除了表示位域或模运算)。不要仅仅为了断言变量是非负数而使用无符号类型。
浮点类型
在内置的 C++ 浮点类型中,唯一使用的是 float
和 double
。你可以假设这些类型分别表示 IEEE-754 binary32 和 binary64。
不要使用 long double
,因为它会产生不可移植的结果。
架构可移植性
编写架构可移植的代码。不要依赖于特定于单个处理器的 CPU 功能。
- 打印值时,使用类型安全的数字格式化库,例如
absl::StrCat
、absl::Substitute
、absl::StrFormat
或std::ostream
,而不是printf
系列函数。 - 当将结构化数据移入或移出你的进程时,使用序列化库(如 Protocol Buffers)对其进行编码,而不是复制内存中的表示形式。
- 如果你需要将内存地址作为整数使用,请将它们存储在
uintptr_t
中,而不是uint32_t
或uint64_t
中。 - 根据需要使用带花括号的初始化来创建 64 位常量。例如
int64_t my_value{0x123456789}; uint64_t my_mask{uint64_t{3} << 48};
- 使用可移植的浮点类型;避免
long double
。 - 使用可移植的整数类型;避免
short
、long
和long long
。
预处理器宏
避免定义宏,尤其是在头文件中;优先使用内联函数、枚举和 const
变量。用项目特定的前缀命名宏。不要使用宏来定义 C++ API 的片段。
宏意味着你看到的代码与编译器看到的代码不同。这可能会引入意外的行为,尤其是在宏具有全局范围的情况下。
当使用宏来定义 C++ API 的片段时,宏引入的问题尤其严重,对于公共 API 更是如此。当开发人员不正确地使用该接口时,来自编译器的每个错误消息现在都必须解释宏如何形成该接口。重构和分析工具更难以更新接口。因此,我们特别禁止以这种方式使用宏。例如,避免像这样的模式
class WOMBAT_TYPE(Foo) { // ... public: EXPAND_PUBLIC_WOMBAT_API(Foo) EXPAND_WOMBAT_COMPARISONS(Foo, ==, <) };
幸运的是,在 C++ 中,宏不像在 C 中那样必不可少。与其使用宏来内联性能关键代码,不如使用内联函数。与其使用宏来存储常量,不如使用 const
变量。与其使用宏来“缩写”一个长的变量名,不如使用引用。与其使用宏来条件编译代码……好吧,根本不要这样做(当然,除了 #define
保护来防止头文件被重复包含之外)。这会使测试变得更加困难。
宏可以做一些其他技术无法完成的事情,并且你确实会在代码库中看到它们,尤其是在较低级别的库中。它们的一些特殊功能(如字符串化、连接等)在语言本身中是不可用的。但在使用宏之前,请仔细考虑是否有非宏的方法可以达到相同的结果。如果你需要使用宏来定义接口,请联系你的项目负责人,请求豁免此规则。
以下使用模式可以避免宏带来的许多问题;如果使用宏,请尽可能遵循它
- 不要在
.h
文件中定义宏。 - 在使用宏之前立即
#define
定义它,并在使用后立即#undef
取消定义。 - 不要仅仅在用你自己的宏替换现有宏之前
#undef
取消定义它;相反,选择一个不太可能重复的名称。 - 尽量不要使用扩展为不平衡的 C++ 结构的宏,或者至少要充分记录这种行为。
- 尽量不要使用
##
来生成函数/类/变量名。
强烈不鼓励从头文件中导出宏(即,在头文件中定义它们,但在头文件末尾之前不 #undef
取消定义它们)。如果你确实从头文件中导出一个宏,它必须具有全局唯一的名称。为了实现这一点,它必须以你的项目命名空间的名称(但大写)作为前缀。
0 和 nullptr/NULL
指针使用 nullptr
,字符使用 '\0'
(而不是 0
字面量)。
对于指针(地址值),使用 nullptr
,因为它提供了类型安全。
对于空字符,使用 '\0'
。使用正确的类型可以使代码更具可读性。
sizeof
优先使用 sizeof(varname)
而不是 sizeof(type)
。
当你获取特定变量的大小时,使用 sizeof(varname)
。如果有人现在或以后更改变量类型,sizeof(varname)
将会适当地更新。对于与任何特定变量无关的代码,你可以使用 sizeof(type)
,例如管理外部或内部数据格式的代码,其中适当的 C++ 类型的变量不方便使用。
MyStruct data; memset(&data, 0, sizeof(data));
memset(&data, 0, sizeof(MyStruct));
if (raw_size < sizeof(int)) { LOG(ERROR) << "compressed record not big enough for count: " << raw_size; return false; }
类型推导(包括 auto)
只有在类型推导使不熟悉该项目的读者更容易理解代码,或者使代码更安全时才使用类型推导。不要仅仅为了避免编写显式类型的麻烦而使用它。
在 C++ 中,有几种情况下允许(甚至要求)编译器推导类型,而不是在代码中显式拼写出来
- 函数模板参数推导
- 可以不使用显式模板参数来调用函数模板。编译器从函数参数的类型推导出这些参数
template <typename T> void f(T t); f(0); // Invokes f<int>(0)
auto
变量声明- 变量声明可以使用
auto
关键字代替类型。编译器从变量的初始化器推导类型,遵循与使用相同初始化器的函数模板参数推导相同的规则(只要你不使用花括号而不是括号)。auto a = 42; // a is an int auto& b = a; // b is an int& auto c = b; // c is an int auto d{42}; // d is an int, not a std::initializer_list<int>
auto
可以用const
修饰,并且可以用作指针或引用类型的一部分,以及(自 C++17 起)用作非类型模板参数。此语法的罕见变体使用decltype(auto)
代替auto
,在这种情况下,推导的类型是应用decltype
到初始化器的结果。 - 函数返回类型推导
auto
(和decltype(auto)
)也可以用来代替函数返回类型。编译器从函数体中的return
语句推导出返回类型,遵循与变量声明相同的规则auto f() { return 0; } // The return type of f is int
Lambda 表达式 返回类型可以以相同的方式推导,但这通过省略返回类型来触发,而不是通过显式的auto
来触发。令人困惑的是,函数的 尾置返回类型 语法也在返回类型位置使用auto
,但这不依赖于类型推导;它只是显式返回类型的另一种语法。- 泛型 lambdas
- lambda 表达式可以使用
auto
关键字代替其一个或多个参数类型。这会导致 lambda 的调用运算符成为函数模板而不是普通函数,每个auto
函数参数都有一个单独的模板参数// Sort `vec` in decreasing order std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });
- Lambda 初始化捕获
- Lambda 捕获可以具有显式初始化器,该初始化器可用于声明全新的变量,而不仅仅是捕获现有变量
[x = 42, y = "foo"] { ... } // x is an int, and y is a const char*
此语法不允许指定类型;相反,它使用auto
变量的规则进行推导。 - 类模板参数推导
- 参见 下文。
- 结构化绑定
- 使用
auto
声明元组、结构体或数组时,你可以为单个元素指定名称,而不是为整个对象指定名称;这些名称称为“结构化绑定”,整个声明称为“结构化绑定声明”。此语法无法指定封闭对象或单个名称的类型auto [iter, success] = my_map.insert({key, value}); if (!success) { iter->second = value; }
auto
也可以用const
、&
和&&
修饰,但请注意,这些修饰符在技术上适用于匿名元组/结构体/数组,而不是单个绑定。确定绑定类型的规则非常复杂;结果往往不会令人惊讶,除非绑定类型通常不会是引用,即使声明声明了引用(但它们通常会像引用一样工作)。
(这些摘要省略了许多细节和警告;有关更多信息,请参见链接。)
- C++ 类型名称可能很长且繁琐,尤其是在它们涉及模板或命名空间时。
- 当 C++ 类型名称在单个声明或小代码区域内重复时,重复可能无助于可读性。
- 让类型被推导有时更安全,因为这避免了意外复制或类型转换的可能性。
C++ 代码通常在类型显式时更清晰,尤其是在类型推导将依赖于来自代码远处的的信息时。在像这样的表达式中
auto foo = x.add_foo(); auto i = y.Find(key);
如果 y
的类型不是很清楚,或者如果 y
在很多行之前声明,那么结果类型可能并不明显。
程序员必须了解类型推导何时会或不会产生引用类型,否则他们会在不希望的情况下得到副本。
如果推导的类型用作接口的一部分,那么程序员可能会在只想更改其值时更改其类型,从而导致比预期更激烈的 API 更改。
基本规则是:仅在使代码更清晰或更安全时才使用类型推导,而不要仅仅为了避免编写显式类型的麻烦而使用它。在判断代码是否更清晰时,请记住你的读者不一定在你的团队中,或者熟悉你的项目,因此你和你的审阅者认为是不必要的混乱的类型,通常会为其他人提供有用的信息。例如,你可以假设 make_unique<Foo>()
的返回类型是显而易见的,但 MyWidgetFactory()
的返回类型可能不是。
这些原则适用于所有形式的类型推导,但细节有所不同,如下面的章节所述。
函数模板参数推导
函数模板参数推导几乎总是没问题的。类型推导是与函数模板交互的预期默认方式,因为它允许函数模板像无限组的普通函数重载一样工作。因此,函数模板几乎总是被设计成模板参数推导是清晰和安全的,或者无法编译。
局部变量类型推导
对于局部变量,你可以使用类型推导来使代码更清晰,方法是消除明显或不相关的类型信息,以便读者可以专注于代码的有意义的部分
std::unique_ptr<WidgetWithBellsAndWhistles> widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2); absl::flat_hash_map<std::string, std::unique_ptr<WidgetWithBellsAndWhistles>>::const_iterator it = my_map_.find(key); std::array<int, 6> numbers = {4, 8, 15, 16, 23, 42};
auto widget = std::make_unique<WidgetWithBellsAndWhistles>(arg1, arg2); auto it = my_map_.find(key); std::array numbers = {4, 8, 15, 16, 23, 42};
类型有时包含有用信息和样板代码的混合,例如上面示例中的 it
:很明显该类型是一个迭代器,并且在许多上下文中,容器类型甚至键类型都不相关,但值的类型可能很有用。在这种情况下,通常可以使用显式类型定义局部变量,以传达相关信息
if (auto it = my_map_.find(key); it != my_map_.end()) { WidgetWithBellsAndWhistles& widget = *it->second; // Do stuff with `widget` }
如果类型是模板实例,并且参数是样板代码,但模板本身具有信息量,则可以使用类模板参数推导来抑制样板代码。但是,实际提供有意义好处的情况非常罕见。请注意,类模板参数推导也受到 单独的样式规则 的约束。
如果可以使用更简单的选项,请不要使用 decltype(auto)
,因为它是一个相当晦涩的功能,因此在代码清晰度方面付出的代价很高。
返回类型推导
仅当函数体具有非常少量的 return
语句并且几乎没有其他代码时,才使用返回类型推导(对于函数和 lambdas),否则读者可能无法一目了然地知道返回类型是什么。此外,仅当函数或 lambda 的范围非常窄时才使用它,因为具有推导返回类型的函数不定义抽象边界:实现就是接口。特别是,头文件中的公共函数几乎不应该具有推导的返回类型。
参数类型推导
lambda 的 auto
参数类型应谨慎使用,因为实际类型由调用 lambda 的代码确定,而不是由 lambda 的定义确定。因此,除非 lambda 被显式调用非常接近定义的位置(以便读者可以轻松地看到两者),或者 lambda 被传递到一个众所周知的接口,以至于很明显最终将使用什么参数调用它(例如,上面的 std::sort
示例),否则显式类型几乎总是更清晰。
Lambda 初始化捕获
初始化捕获由 更具体的样式规则 涵盖,该规则在很大程度上取代了类型推导的一般规则。
结构化绑定
与其他形式的类型推导不同,结构化绑定实际上可以通过为较大对象的元素提供有意义的名称来为读者提供额外的信息。这意味着结构化绑定声明可能比显式类型提供净可读性改进,即使在 auto
不起作用的情况下也是如此。当对象是 pair 或 tuple 时(如上面的 insert
示例中所示),结构化绑定尤其有益,因为它们一开始就没有有意义的字段名称,但请注意,除非预先存在的 API(如 insert
)强制你这样做,否则通常 不应使用 pair 或 tuple。
如果被绑定的对象是一个结构体,有时提供更具体的名字可能很有帮助,但请记住,与字段名相比,这些名字可能不太容易被读者识别。 如果绑定名称与底层字段名称不匹配,我们建议使用注释来指示底层字段的名称,使用与函数参数注释相同的语法。
auto [/*field_name1=*/bound_name1, /*field_name2=*/bound_name2] = ...
与函数参数注释一样,这可以使工具检测到您是否弄错了字段的顺序。
类模板参数推导
仅对明确选择支持的模板使用类模板参数推导。
类模板参数推导 (通常缩写为 "CTAD") 发生在声明一个变量,其类型命名了一个模板,并且没有提供模板参数列表(甚至没有空的尖括号)时。
std::array a = {1, 2, 3}; // `a` is a std::array<int, 3>
编译器使用模板的“推导指南”从初始化器中推导出参数,这些指南可以是显式的,也可以是隐式的。
显式推导指南看起来像带有尾置返回类型的函数声明,只是没有前导 auto
,并且函数名是模板的名称。 例如,上面的示例依赖于 std::array
的此推导指南。
namespace std { template <class T, class... U> array(T, U...) -> std::array<T, 1 + sizeof...(U)>; }
主模板(与模板特化相对)中的构造函数也隐式地定义了推导指南。
当您声明一个依赖于 CTAD 的变量时,编译器使用构造函数重载解析规则选择一个推导指南,并且该指南的返回类型成为变量的类型。
CTAD 有时可以让你从代码中省略样板代码。
从构造函数生成的隐式推导指南可能具有不良行为,或者完全不正确。 对于在 C++17 中引入 CTAD 之前编写的构造函数来说,这尤其成问题,因为这些构造函数的作者无法知道(更不用说修复)他们的构造函数会给 CTAD 带来任何问题。 此外,添加显式推导指南来修复这些问题可能会破坏任何依赖隐式推导指南的现有代码。
CTAD 也存在与 auto
相同的许多缺点,因为它们都是从变量的初始化器中推导出变量的全部或部分类型的机制。 CTAD 比 auto
给读者提供了更多的信息,但它也没有给读者一个明显的提示,表明信息已被省略。
除非模板的维护者至少提供了一个显式推导指南(std
命名空间中的所有模板也被假定为已选择加入),否则不要对给定的模板使用 CTAD。 如果可用,应使用编译器警告强制执行此操作。
CTAD 的使用还必须遵循 类型推导 的一般规则。
指定初始化器
仅使用符合 C++20 标准的指定初始化器形式。
指定初始化器 是一种语法,允许通过显式命名其字段来初始化聚合(“普通的旧结构体”)
struct Point { float x = 0.0; float y = 0.0; float z = 0.0; }; Point p = { .x = 1.0, .y = 2.0, // z will be 0.0 };
显式列出的字段将按指定的方式初始化,其他字段将以与传统聚合初始化表达式(如 Point{1.0, 2.0}
)相同的方式初始化。
指定初始化器可以创建方便且高度可读的聚合表达式,特别是对于字段排序不如上面的 Point
示例那么简单的结构体。
虽然指定初始化器长期以来一直是 C 标准的一部分,并作为 C++ 编译器的扩展支持,但在 C++20 之前,它们不受 C++ 的支持。
C++ 标准中的规则比 C 和编译器扩展中的规则更严格,要求指定的初始化器以与字段在结构体定义中出现的相同顺序出现。 因此,在上面的示例中,根据 C++20 初始化 x
然后初始化 z
是合法的,但初始化 y
然后初始化 x
是不合法的。
仅使用与 C++20 标准兼容的形式的指定初始化器:初始化器与相应字段在结构体定义中出现的顺序相同。
Lambda 表达式
在适当的情况下使用 Lambda 表达式。 如果 lambda 可能会超出当前作用域,请优先选择显式捕获。
Lambda 表达式是创建匿名函数对象的一种简洁方法。 在将函数作为参数传递时,它们通常很有用。 例如
std::sort(v.begin(), v.end(), [](int x, int y) { return Weight(x) < Weight(y); });
它们还允许通过名称从封闭作用域显式捕获变量,或使用默认捕获隐式捕获变量。 显式捕获要求列出每个变量,作为值捕获或引用捕获。
int weight = 3; int sum = 0; // Captures `weight` by value and `sum` by reference. std::for_each(v.begin(), v.end(), [weight, &sum](int x) { sum += weight * x; });
默认捕获隐式捕获 lambda 主体中引用的任何变量,包括使用的任何成员的 this
。
const std::vector<int> lookup_table = ...; std::vector<int> indices = ...; // Captures `lookup_table` by reference, sorts `indices` by the value // of the associated element in `lookup_table`. std::sort(indices.begin(), indices.end(), [&](int a, int b) { return lookup_table[a] < lookup_table[b]; });
变量捕获也可以具有显式初始化器,该初始化器可用于按值捕获仅移动变量,或用于普通引用或值捕获无法处理的其他情况。
std::unique_ptr<Foo> foo = ...; [foo = std::move(foo)] () { ... }
此类捕获(通常称为“初始化捕获”或“广义 lambda 捕获”)实际上不必从封闭作用域“捕获”任何内容,甚至不必具有来自封闭作用域的名称; 此语法是定义 lambda 对象成员的完全通用的方法。
[foo = std::vector<int>({1, 2, 3})] () { ... }
使用与 auto
相同的规则推导具有初始化器的捕获的类型。
- 与定义要传递给 STL 算法的函数对象的其他方式相比,Lambdas 更加简洁,这可以提高可读性。
- 适当使用默认捕获可以消除冗余并突出显示与默认值的重要的例外情况。
- Lambdas、
std::function
和std::bind
可以组合使用作为通用的回调机制; 它们使编写将绑定函数作为参数的函数变得容易。
- lambda 中的变量捕获可能是悬垂指针错误的来源,特别是如果 lambda 超出当前作用域。
- 默认按值捕获可能会产生误导,因为它们不能防止悬垂指针错误。 按值捕获指针不会导致深度复制,因此它通常具有与按引用捕获相同的生存期问题。 当按值捕获
this
时,这尤其令人困惑,因为this
的使用通常是隐式的。 - 捕获实际上声明了新变量(无论捕获是否具有初始化器),但它们看起来不像 C++ 中的任何其他变量声明语法。 特别是,没有变量类型的位置,甚至没有
auto
占位符(尽管初始化捕获可以间接指示它,例如,使用强制转换)。 这甚至可能很难将它们识别为声明。 - 初始化捕获本身依赖于 类型推导,并且存在与
auto
相同的许多缺点,另外的问题是语法甚至没有提示读者正在进行推导。 - lambda 的使用可能会失控; 非常长的嵌套匿名函数会使代码更难理解。
- 在适当的情况下使用 lambda 表达式,并按照 以下 描述的格式进行格式化。
- 如果 lambda 可能会超出当前作用域,请优先选择显式捕获。 例如,而不是
{ Foo foo; ... executor->Schedule([&] { Frobnicate(foo); }) ... } // BAD! The fact that the lambda makes use of a reference to `foo` and // possibly `this` (if `Frobnicate` is a member function) may not be // apparent on a cursory inspection. If the lambda is invoked after // the function returns, that would be bad, because both `foo` // and the enclosing object could have been destroyed.
更喜欢写{ Foo foo; ... executor->Schedule([&foo] { Frobnicate(foo); }) ... } // BETTER - The compile will fail if `Frobnicate` is a member // function, and it's clearer that `foo` is dangerously captured by // reference.
- 仅当 lambda 的生存期明显短于任何潜在的捕获时,才使用默认按引用捕获 (
[&]
)。 - 仅当作为绑定少量变量的手段时,才使用默认按值捕获 (
[=]
),其中捕获的变量集一目了然,并且不会导致隐式捕获this
。 (这意味着出现在非静态类成员函数中并且在其主体中引用非静态类成员的 lambda 必须显式地或通过[&]
捕获this
。)最好不要编写具有默认按值捕获的长或复杂的 lambda。 - 仅使用捕获来实际捕获封闭作用域中的变量。 不要使用带有初始化器的捕获来引入新名称,或实质性地更改现有名称的含义。 而是以传统方式声明一个新变量,然后捕获它,或者避免 lambda 简写并显式定义一个函数对象。
- 有关指定参数和返回类型的指导,请参阅有关 类型推导 的部分。
模板元编程
避免复杂的模板编程。
模板元编程是指一系列利用 C++ 模板实例化机制是图灵完备的这一事实的技术,并且可用于在类型域中执行任意编译时计算。
模板元编程允许极其灵活的接口,这些接口是类型安全且高性能的。 如果没有它,像 GoogleTest、std::tuple
、std::function
和 Boost.Spirit 这样的工具是不可能实现的。
模板元编程中使用的技术对于除语言专家之外的任何人来说通常都是晦涩难懂的。 以复杂的方式使用模板的代码通常是不可读的,并且难以调试或维护。
模板元编程通常会导致极差的编译时错误消息:即使接口很简单,当用户做错事时,复杂的实现细节也会变得可见。
模板元编程会干扰大规模重构,因为这会使重构工具的工作更加困难。 首先,模板代码在多个上下文中展开,并且很难验证转换在所有上下文中是否有意义。 其次,一些重构工具使用仅表示模板展开后代码结构的 AST。 很难自动回溯到需要重写的原始源代码结构。
模板元编程有时允许比没有它更清晰和更易于使用的接口,但它也常常诱使人们过于聪明。 它最好用于少量底层组件中,在这些组件中,额外的维护负担分散到大量用途上。
在使用模板元编程或其他复杂模板技术之前请三思; 想一想你团队的普通成员是否能够充分理解你的代码,以便在你切换到另一个项目后维护它,或者非 C++ 程序员或随便浏览代码库的人是否能够理解错误消息或跟踪他们想要调用的函数的流程。 如果你使用递归模板实例化或类型列表或元函数或表达式模板,或者依赖 SFINAE 或 sizeof
技巧来检测函数重载解析,那么你很可能走得太远了。
如果你使用模板元编程,你应该准备投入大量精力来最小化和隔离复杂性。 尽可能地将元编程隐藏为实现细节,以便用户可见的头文件是可读的,并且你应该确保棘手的代码都有特别的注释。 你应该仔细记录代码的使用方式,并且你应该说明“生成”的代码是什么样的。 特别注意编译器在用户犯错时发出的错误消息。 错误消息是用户界面的一部分,你应该根据需要调整代码,以便从用户的角度来看,错误消息是可理解的和可操作的。
概念和约束
谨慎使用概念。一般来说,概念和约束应该只在 C++20 之前会使用模板的情况下使用。 避免在头文件中引入新的概念,除非这些头文件被标记为库的内部头文件。 不要定义编译器不强制执行的概念。 优先使用约束而不是模板元编程,并避免使用 template<Concept T>
语法; 相反,使用 requires(Concept<T>)
语法。
concept
关键字是一种新的机制,用于为模板参数定义需求(例如类型特征或接口规范)。 requires
关键字提供了一种机制,用于对模板设置匿名约束,并验证约束是否在编译时得到满足。 概念和约束通常一起使用,但也可以独立使用。
- 使用概念允许编译器在涉及模板时生成更好的错误消息,这可以减少混乱并显著改善开发体验。
- 概念可以减少定义和使用编译时约束所需的样板代码,通常可以提高生成代码的清晰度。
- 约束提供了一些使用模板和 SFINAE 技术难以实现的功能。
- 与模板一样,概念可能会使代码变得更加复杂且难以理解。
- 概念语法可能会让读者感到困惑,因为概念在使用时看起来类似于类类型。
- 概念,尤其是在 API 边界处,会增加代码耦合、刚性和固化。
- 概念和约束可以复制函数体中的逻辑,导致代码重复和维护成本增加。
- 概念模糊了其底层契约的真实来源,因为它们是可以在多个位置使用的独立命名实体,所有这些实体都彼此独立地发展。 这会导致声明的和隐含的需求随着时间的推移而产生差异。
- 概念和约束以新颖且不明显的方式影响重载解析。
- 与 SFINAE 一样,约束使得大规模重构代码变得更加困难。
如果存在等效的概念,则应优先使用标准库中预定义的概念而不是类型特征。(例如,如果在 C++20 之前会使用 std::is_integral_v
,那么在 C++20 代码中应该使用 std::integral
。) 类似地,优先使用现代约束语法(通过 requires(Condition)
)。 避免使用旧的模板元编程构造(例如 std::enable_if<Condition>
)以及 template<Concept T>
语法。
不要手动重新实现任何现有的概念或特征。 例如,使用 requires(std::default_initializable<T>)
而不是 requires(requires { T v; })
或类似的东西。
新的 concept
声明应该很少见,并且只能在库内部定义,这样它们就不会在 API 边界暴露。 更一般地说,如果在 C++17 中你不会使用它们的旧式模板等效项,则不要使用概念或约束。
不要定义复制函数体的概念,或强加从代码主体或生成的错误消息中微不足道或显而易见的要求。 例如,避免以下情况
template <typename T> // Bad - redundant with negligible benefit concept Addable = std::copyable<T> && requires(T a, T b) { a + b; }; template <Addable T> T Add(T x, T y, T z) { return x + y + z; }相反,除非你能证明概念在这种特定情况下带来了显着改进,例如在深度嵌套或不明显的要求的最终错误消息中,否则最好将代码保留为普通模板。
概念应该可以由编译器静态验证。 不要使用任何主要好处来自语义(或其他未强制执行)约束的概念。 未在编译时强制执行的需求应通过其他机制(如注释、断言或测试)来强制执行。
C++20 模块
不要使用 C++20 模块。
C++20 引入了“模块”,这是一种新的语言特性,旨在替代头文件的文本包含。 它引入了三个新的关键字来支持这一点:module
、export
和 import
。
模块是 C++ 的编写和编译方式的重大转变,我们仍在评估它们未来如何适应 Google 的 C++ 生态系统。 此外,我们的构建系统、编译器和其他工具目前对它们的支持还不够好,并且需要进一步探索编写和使用它们的最佳实践。
协程
不要使用协程(尚未)。
不要包含 <coroutine>
头文件,或使用 co_await
、co_yield
或 co_return
关键字。
注意:预计此禁令是暂时的,因为正在制定进一步的指南。
Boost
仅使用来自 Boost 库集合的批准库。
Boost 库集合是一个流行的、经过同行评审的、免费的、开源的 C++ 库集合。
Boost 代码通常质量很高,具有广泛的可移植性,并且填补了 C++ 标准库中的许多重要空白,例如类型特征和更好的绑定器。
一些 Boost 库鼓励可能妨碍可读性的编码实践,例如元编程和其他高级模板技术,以及过度“函数式”的编程风格。
为了保持所有可能阅读和维护代码的贡献者的高水平可读性,我们只允许批准的 Boost 功能子集。 目前,允许使用以下库
-
来自
boost/call_traits.hpp
的 调用特征 - 来自
boost/compressed_pair.hpp
的 压缩对 - 来自
boost/graph
的 Boost 图形库 (BGL),除了序列化 (adj_list_serialize.hpp
) 和并行/分布式算法和数据结构 (boost/graph/parallel/*
和boost/graph/distributed/*
)。 - 来自
boost/property_map
的 属性映射,除了并行/分布式属性映射 (boost/property_map/parallel/*
)。 - 来自
boost/iterator
的 迭代器 - 多边形 中处理 Voronoi 图构造的部分,不依赖于多边形的其余部分:
boost/polygon/voronoi_builder.hpp
、boost/polygon/voronoi_diagram.hpp
和boost/polygon/voronoi_geometry_type.hpp
- 来自
boost/bimap
的 Bimap - 来自
boost/math/distributions
的 统计分布和函数 - 来自
boost/math/special_functions
的 特殊函数 - 来自
boost/math/tools
的 寻根 & 最小化函数 - 来自
boost/multi_index
的 多重索引 - 来自
boost/heap
的 堆 - 来自 容器 的扁平容器:
boost/container/flat_map
和boost/container/flat_set
- 来自
boost/intrusive
的 Intrusive。 boost/sort
库.- 来自
boost/preprocessor
的 预处理器。
我们正在积极考虑将其他 Boost 功能添加到列表中,因此此列表将来可能会扩展。
不允许使用的标准库功能
与 Boost 一样,一些现代 C++ 库功能鼓励妨碍可读性的编码实践——例如,通过删除对读者有帮助的检查冗余(例如类型名称),或者通过鼓励模板元编程。 其他扩展复制了可通过现有机制获得的功能,这可能会导致混淆和转换成本。
不得使用以下 C++ 标准库功能
- 编译时有理数 (
<ratio>
),因为担心它与更重模板的接口风格相关联。 <cfenv>
和<fenv.h>
头文件,因为许多编译器不支持这些功能。<filesystem>
头文件,它没有足够的测试支持,并且存在固有的安全漏洞。
非标准扩展
除非另有说明,否则不得使用 C++ 的非标准扩展。
编译器支持各种不属于标准 C++ 的扩展。 此类扩展包括 GCC 的 __attribute__
、内部函数(如 __builtin_prefetch
或 SIMD)、#pragma
、内联汇编、__COUNTER__
、__PRETTY_FUNCTION__
、复合语句表达式(例如,foo = ({ int x; Bar(&x); x })
)、变长数组和 alloca()
,以及“猫王运算符” a?:b
。
- 非标准扩展可以提供标准 C++ 中不存在的有用功能。
- 只能使用扩展来指定编译器的重要性能指导。
- 非标准扩展并非在所有编译器中都有效。 使用非标准扩展会降低代码的可移植性。
- 即使它们在所有目标编译器中都受支持,这些扩展通常也没有得到很好的规范,并且编译器之间可能存在细微的行为差异。
- 非标准扩展添加到读者必须知道才能理解代码的语言特性中。
- 非标准扩展需要额外的工作才能跨架构移植。
不要使用非标准扩展。 您可以使用使用非标准扩展实现的移植包装器,只要这些包装器由指定的项目范围的移植头文件提供。
别名
公共别名是为了 API 用户的利益,应该清楚地记录下来。
有几种方法可以创建其他实体的别名。
using Bar = Foo; typedef Foo Bar; // But prefer `using` in C++ code. using ::other_namespace::Foo; using enum MyEnumType; // Creates aliases for all enumerators in MyEnumType.
在新代码中,`using` 优于 `typedef`,因为它提供了与 C++ 其他部分更一致的语法,并且可以与模板一起使用。
与其他声明一样,在头文件中声明的别名是该头文件的公共 API 的一部分,除非它们位于函数定义中、类的私有部分中或显式标记的内部命名空间中。这些区域或 `.cc` 文件中的别名是实现细节(因为客户端代码无法引用它们),因此不受此规则的约束。
- 别名可以通过简化冗长或复杂的名称来提高可读性。
- 别名可以通过在一个地方命名 API 中重复使用的类型来减少重复,这 *可能* 使以后更容易更改类型。
- 当放置在客户端代码可以引用的头文件中时,别名会增加该头文件的 API 中的实体数量,从而增加其复杂性。
- 客户端很容易依赖公共别名的意外细节,从而使更改变得困难。
- 创建仅用于实现的公共别名而不考虑其对 API 或可维护性的影响可能很诱人。
- 别名可能会造成名称冲突的风险。
- 别名可能会通过给熟悉的构造赋予不熟悉的名称来降低可读性。
- 类型别名可能会创建不清晰的 API 约定:不清楚别名是否保证与它别名的类型完全相同,是否具有相同的 API,或者是否只能以指定的狭隘方式使用。
不要仅仅为了节省实现中的输入而将别名放入公共 API 中;只有当您希望客户端使用它时才这样做。
在定义公共别名时,请记录新名称的意图,包括是否保证始终与当前别名的类型相同,或者是否需要更有限的兼容性。这让用户知道他们是否可以将这些类型视为可替代的,或者是否必须遵循更具体的规则,并且可以帮助实现保留一定程度的更改别名的自由。
不要将命名空间别名放入公共 API 中。(另请参见 命名空间)。
例如,这些别名记录了它们在客户端代码中的预期使用方式。
namespace mynamespace { // Used to store field measurements. DataPoint may change from Bar* to some internal type. // Client code should treat it as an opaque pointer. using DataPoint = ::foo::Bar*; // A set of measurements. Just an alias for user convenience. using TimeSeries = std::unordered_set<DataPoint, std::hash<DataPoint>, DataPointComparator>; } // namespace mynamespace
这些别名没有记录预期用途,其中一半并不打算供客户端使用。
namespace mynamespace { // Bad: none of these say how they should be used. using DataPoint = ::foo::Bar*; using ::std::unordered_set; // Bad: just for local convenience using ::std::hash; // Bad: just for local convenience typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries; } // namespace mynamespace
但是,局部便利别名在函数定义、类的 `private` 部分、显式标记的内部命名空间以及 `.cc` 文件中是允许的。
// In a .cc file using ::foo::Bar;
Switch 语句
如果不是以枚举值为条件,则 switch 语句应始终具有 `default` 分支(如果是枚举值,如果未处理任何值,编译器将发出警告)。如果永远不应执行 default 分支,请将其视为错误。例如
switch (var) { case 0: { ... break; } case 1: { ... break; } default: { LOG(FATAL) << "Invalid value in switch statement: " << var; } }
从一个 case 标签到另一个 case 标签的 Fall-through 必须使用 `[[fallthrough]];` 属性进行注释。 `[[fallthrough]];` 应放置在执行到下一个 case 标签的 fall-through 发生的位置。一个常见的例外是连续的 case 标签之间没有插入代码,在这种情况下不需要注释。
switch (x) { case 41: // No annotation needed here. case 43: if (dont_be_picky) { // Use this instead of or along with annotations in comments. [[fallthrough]]; } else { CloseButNoCigar(); break; } case 42: DoSomethingSpecial(); [[fallthrough]]; default: DoSomethingGeneric(); break; }
包容性语言
在所有代码(包括命名和注释)中,使用包容性语言,避免其他程序员可能认为不尊重或冒犯的术语(例如“master”和“slave”、“blacklist”和“whitelist”或“redline”),即使这些术语也具有表面上的中性含义。同样,使用性别中立的语言,除非你指的是特定的人(并使用他们的代词)。例如,对未指定性别的人使用“they”/“them”/“their”(即使是单数),对软件、计算机和其他非人类事物使用“it”/“its”。
命名
最重要的保持一致的规则是那些控制命名的规则。名称的样式立即告诉我们命名实体是什么类型的:类型、变量、函数、常量、宏等,而无需我们搜索该实体的声明。我们大脑中的模式匹配引擎在很大程度上依赖于这些命名规则。
关于命名的样式规则非常随意,但我们认为在这种情况下,一致性比个人偏好更重要,因此无论您是否觉得它们合理,这些规则都是规则。
为了以下命名规则的目的,“单词”是指您在英语中不带内部空格编写的任何内容。单词要么全部小写,单词之间用下划线分隔(“snake_case”),要么单词大小写混合,每个单词的首字母大写(“camelCase” 或 “PascalCase”)。
选择名称
为事物命名,使其目的或意图对于新的读者来说可以理解,即使是与所有者不在同一个团队的人。不要担心节省横向空间,因为让新的读者立即理解您的代码更为重要。
考虑使用名称的上下文。即使在远离使代码可供使用的代码中使用名称,该名称也应该是描述性的。但是,名称不应通过重复立即上下文中存在的信息来分散读者的注意力。通常,这意味着描述性应与名称的可见范围成正比。在标头中声明的自由函数可能应该提及标头的库,而局部变量可能不应该解释它所在的函数。
尽量减少使用您的项目之外的人可能不知道的缩写(尤其是首字母缩略词和词首字母缩写词)。不要通过删除单词中的字母来缩写。当使用缩写时,最好将其大写为单个“单词”,例如,`StartRpc()` 而不是 `StartRPC()`。根据经验,如果 Wikipedia 中列出了缩写,则可能可以。请注意,某些普遍已知的缩写是可以的,例如循环索引的 `i` 和模板参数的 `T`。
您最常看到的名称不像大多数名称;少量的“词汇”名称被广泛重用,以至于它们始终处于上下文中。这些名称往往很短甚至缩写,它们的完整含义来自明确的长篇文档,而不仅仅是关于它们的定义和名称中单词的注释。例如,`absl::Status` 在 devguide 中有一个专门的页面,记录了它的正确使用方法。您可能不会经常定义新的词汇名称,但如果这样做,请进行额外的设计审查,以确保所选名称在广泛使用时效果良好。
class MyClass { public: int CountFooErrors(const std::vector<Foo>& foos) { int n = 0; // Clear meaning given limited scope and context for (const auto& foo : foos) { ... ++n; } return n; } // Function comment doesn't need to explain that this returns non-OK on // failure as that is implied by the `absl::Status` return type, but it // might document behavior for some specific codes. absl::Status DoSomethingImportant() { std::string fqdn = ...; // Well-known abbreviation for Fully Qualified Domain Name return absl::OkStatus(); } private: const int kMaxAllowedConnections = ...; // Clear meaning within context };
class MyClass { public: int CountFooErrors(const std::vector<Foo>& foos) { int total_number_of_foo_errors = 0; // Overly verbose given limited scope and context for (int foo_index = 0; foo_index < foos.size(); ++foo_index) { // Use idiomatic `i` ... ++total_number_of_foo_errors; } return total_number_of_foo_errors; } // A return type with a generic name is unclear without widespread education. Result DoSomethingImportant() { int cstmr_id = ...; // Deletes internal letters } private: const int kNum = ...; // Unclear meaning within broad scope };
文件名
文件名应全部小写,并且可以包含下划线 (``_``) 或破折号 (``-``)。遵循您的项目使用的约定。如果没有一致的本地模式可遵循,请首选 “``_``”。
可接受的文件名示例
my_useful_class.cc
my-useful-class.cc
myusefulclass.cc
myusefulclass_test.cc // _unittest 和 _regtest 已弃用。
C++ 文件应具有 `.cc` 文件扩展名,并且头文件应具有 `.h` 扩展名。在特定点依赖于文本包含的文件应以 `.inc` 结尾(另请参见关于 自包含头文件的部分)。
不要使用 ` /usr/include` 中已存在的文件名,例如 `db.h`。
通常,使您的文件名非常具体。例如,使用 `http_server_logs.h` 而不是 `logs.h`。一个非常常见的情况是有一对名为 `foo_bar.h` 和 `foo_bar.cc` 的文件,它们定义了一个名为 `FooBar` 的类。
类型名称
类型名称以大写字母开头,并且每个新单词都有一个大写字母,没有下划线:`MyExcitingClass`、`MyExcitingEnum`。
所有类型的名称(类、结构体、类型别名、枚举和类型模板参数)都具有相同的命名约定。类型名称应以大写字母开头,并且每个新单词都有一个大写字母。没有下划线。例如
// classes and structs class UrlTable { ... class UrlTableTester { ... struct UrlTableProperties { ... // typedefs typedef hash_map<UrlTableProperties *, std::string> PropertiesMap; // using aliases using PropertiesMap = hash_map<UrlTableProperties *, std::string>; // enums enum class UrlTableError { ...
概念名称
概念名称遵循与 类型名称 相同的规则。变量名称
变量(包括函数参数)和数据成员的名称是 `snake_case`(全部小写,单词之间用下划线分隔)。类的(但不是结构体的)数据成员另外具有尾随下划线。例如:`a_local_variable`、`a_struct_data_member`、`a_class_data_member_`。
常见变量名称
例如
std::string table_name; // OK - snake_case.
std::string tableName; // Bad - mixed case.
类数据成员
类的静态和非静态数据成员的命名方式与普通的非成员变量一样,但带有尾随下划线。唯一的例外是静态常量类成员,它应遵循 命名常量的规则。
class TableInfo { public: ... static const int kTableVersion = 3; // OK - constant naming. ... private: std::string table_name_; // OK - underscore at end. static Pool<TableInfo>* pool_; // OK. };
结构体数据成员
结构体的静态和非静态数据成员的命名方式与普通的非成员变量一样。它们没有类中数据成员具有的尾随下划线。
struct UrlTableProperties { std::string name; int num_entries; static Pool<UrlTableProperties>* pool; };
有关何时使用结构体与类的讨论,请参见 结构体与类。
常量名称
声明为 `constexpr` 或 `const` 的变量,其值在程序持续时间内是固定的,以领先的 "k" 开头,后跟混合大小写。在极少数情况下,无法使用大写来分隔时,可以使用下划线作为分隔符。例如
const int kDaysInAWeek = 7; const int kAndroid8_0_0 = 24; // Android 8.0.0
所有具有静态存储持续时间的此类变量(即静态变量和全局变量,有关详细信息,请参见 存储持续时间)都应以这种方式命名,包括那些是静态常量类数据成员和模板中模板的不同实例化可能具有不同值的成员。对于其他存储类别的变量(例如,自动变量),此约定是可选的;否则,适用通常的变量命名规则。例如
void ComputeFoo(absl::string_view suffix) { // Either of these is acceptable. const absl::string_view kPrefix = "prefix"; const absl::string_view prefix = "prefix"; ... }
void ComputeFoo(absl::string_view suffix) { // Bad - different invocations of ComputeFoo give kCombined different values. const std::string kCombined = absl::StrCat(kPrefix, suffix); ... }
函数名称
常规函数具有混合大小写;访问器和修改器可以像变量一样命名。
通常,函数应以大写字母开头,并且每个新单词都有一个大写字母。
AddTableEntry() DeleteUrl() OpenFileOrDie()
(相同的命名规则适用于作为 API 的一部分公开并打算看起来像函数的类范围和命名空间范围常量,因为它们是对象而不是函数的事实是不重要的实现细节。)
访问器和修改器(get 和 set 函数)可以像变量一样命名。这些通常对应于实际的成员变量,但这不是必需的。例如,`int count()` 和 `void set_count(int count)`。
命名空间名称
命名空间名称是 `snake_case`(全部小写,单词之间用下划线分隔)。
在为命名空间选择名称时,请注意,在命名空间之外的头文件中使用名称时,名称必须是完全限定的,因为通常禁止使用非限定别名。
顶级命名空间必须是全局唯一的且可识别的,因此每个命名空间应由单个项目或团队拥有,名称基于该项目或团队的名称。通常,命名空间中的所有代码应位于一个或多个目录中,这些目录的名称与命名空间相同。
嵌套命名空间应避免使用众所周知的顶级命名空间名称,尤其是 `std` 和 `absl`,因为在 C++ 中,嵌套命名空间不能防止与其他命名空间中的名称发生冲突(请参见 TotW #130)。
枚举器名称
枚举器(对于作用域内和作用域外枚举)应像常量一样命名,而不是像宏一样命名。也就是说,使用 `kEnumName` 而不是 `ENUM_NAME`。
enum class UrlTableError { kOk = 0, kOutOfMemory, kMalformedInput, };
enum class AlternateUrlTableError { OK = 0, OUT_OF_MEMORY = 1, MALFORMED_INPUT = 2, };
在2009年1月之前,枚举值的命名风格类似于宏。这导致了枚举值和宏之间出现命名冲突的问题。因此,改为推荐常量风格的命名。新的代码应该使用常量风格的命名。
模板参数名称
模板参数应该遵循其所属类别的命名风格:类型模板参数应该遵循命名类型的规则,非类型模板参数应该遵循命名变量或常量的规则。
宏名称
你真的要定义宏吗?如果你一定要,它们应该像这样:MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE
。
请参阅宏的描述;通常情况下,应该避免使用宏。然而,如果它们是绝对必要的,那么它们应该用全大写字母和下划线命名,并带有项目特定的前缀。
#define MYPROJECT_ROUND(x) ...
别名
别名的名称遵循与其他任何新名称相同的原则,应用于定义别名的上下文,而不是原始名称出现的位置。
命名规则的例外情况
如果你要命名的东西类似于现有的C或C++实体,那么你可以遵循现有的命名约定方案。
bigopen()
- 函数名,遵循
open()
的形式 uint
typedef
bigpos
struct
或class
,遵循pos
的形式sparse_hash_map
- 类似于STL的实体;遵循STL命名约定
LONGLONG_MAX
- 一个常量,如
INT_MAX
注释
注释对于保持我们的代码可读性至关重要。以下规则描述了你应该注释什么以及在哪里注释。但请记住:虽然注释非常重要,但最好的代码是自我文档化的。给类型和变量赋予有意义的名称比使用模糊的名称,然后通过注释来解释它们要好得多。
在编写注释时,要为你的受众编写:下一个需要理解你的代码的贡献者。慷慨一些 —— 下一个可能就是你!
注释风格
可以使用//
或/* */
语法,只要你保持一致即可。
虽然两种语法都是可以接受的,但//
更为常见。保持注释方式的一致性,以及在何处使用哪种风格的一致性。
文件注释
在每个文件的开头添加许可协议样板。
如果源文件(例如.h
文件)声明了多个面向用户的抽象(常用函数、相关类等),请包含一个注释,描述这些抽象的集合。包含足够的细节,以便未来的作者知道什么不适合放在那里。但是,关于各个抽象的详细文档应该与这些抽象一起,而不是在文件级别。
例如,如果你为frobber.h
编写了一个文件注释,则无需在frobber.cc
或frobber_test.cc
中包含文件注释。另一方面,如果你在registered_objects.cc
中编写了一组没有关联头文件的类,则必须在registered_objects.cc
中包含文件注释。
法律声明和作者行
每个文件都应该包含许可协议样板。为项目使用的许可证选择适当的样板(例如,Apache 2.0、BSD、LGPL、GPL)。
如果你对带有作者行的文件进行了重大更改,请考虑删除作者行。新文件通常不应包含版权声明或作者行。
结构体和类注释
每个非显而易见的类或结构体声明都应该有一个伴随注释,描述它的用途以及应该如何使用它。
// Iterates over the contents of a GargantuanTable. // Example: // std::unique_ptr<GargantuanTableIterator> iter = table->NewIterator(); // for (iter->Seek("foo"); !iter->done(); iter->Next()) { // process(iter->key(), iter->value()); // } class GargantuanTableIterator { ... };
类注释
类注释应向读者提供足够的信息,以了解如何以及何时使用该类,以及正确使用该类所需的任何其他注意事项。记录类做出的同步假设(如果有)。如果该类的实例可以被多个线程访问,请格外小心地记录围绕多线程使用的规则和不变式。
类注释通常是展示该类的简单且集中的用法的小代码片段的好地方。
当充分分离时(例如,.h
和.cc
文件),描述类用法的注释应与其接口定义一起;关于类操作和实现的注释应伴随类方法的实现。
函数注释
声明注释描述函数的使用(当它不明显时);函数定义处的注释描述操作。
函数声明
几乎每个函数声明都应该在其前面添加注释,描述该函数的功能以及如何使用它。只有当函数简单且明显时(例如,类明显属性的简单访问器),才可以省略这些注释。.cc
文件中声明的私有方法和函数也不例外。函数注释应该以此函数为隐含主语编写,并以动词短语开头;例如,“打开文件”,而不是“打开文件”。一般来说,这些注释不描述函数如何执行其任务。相反,这应该留给函数定义中的注释。
在函数声明的注释中需要提及的事项类型
- 输入和输出是什么。如果在反引号中提供了函数参数名称,则代码索引工具可能能够更好地呈现文档。
- 对于类成员函数:对象是否记住方法调用持续时间之外的引用或指针参数。这对于构造函数的指针/引用参数非常常见。
- 对于每个指针参数,是否允许为空,如果为空会发生什么。
- 对于每个输出或输入/输出参数,该参数中的任何状态会发生什么。(例如,状态是追加还是覆盖?)。
- 如果函数的使用方式有任何性能影响。
这是一个例子
// Returns an iterator for this table, positioned at the first entry // lexically greater than or equal to `start_word`. If there is no // such entry, returns a null pointer. The client must not use the // iterator after the underlying GargantuanTable has been destroyed. // // This method is equivalent to: // std::unique_ptr<Iterator> iter = table->NewIterator(); // iter->Seek(start_word); // return iter; std::unique_ptr<Iterator> GetIterator(absl::string_view start_word) const;
但是,不要不必要地冗长或说明完全明显的事情。
在记录函数覆盖时,重点关注覆盖本身的细节,而不是重复被覆盖函数的注释。在许多情况下,覆盖不需要额外的文档,因此不需要注释。
在注释构造函数和析构函数时,请记住,阅读你代码的人知道构造函数和析构函数的用途,因此仅仅说“销毁此对象”之类的注释是无用的。记录构造函数如何处理其参数(例如,如果它们取得指针的所有权),以及析构函数做什么清理。如果这很简单,只需跳过注释。析构函数通常没有头部注释。
函数定义
如果函数执行其工作的方式有任何棘手的地方,函数定义应该有一个解释性的注释。例如,在定义注释中,你可以描述你使用的任何编码技巧,概述你经历的步骤,或者解释为什么你选择以你所做的方式实现该函数,而不是使用可行的替代方案。例如,你可能会提到为什么必须为函数的前半部分获取锁,但为什么后半部分不需要。
注意,你不应该仅仅重复在函数声明中给出的注释,无论是在.h
文件中还是在其他地方。简要概括函数的功能是可以的,但注释的重点应该放在它是如何完成的。
变量注释
一般来说,变量的实际名称应该具有足够的描述性,以清楚地了解变量的用途。在某些情况下,需要更多注释。
类数据成员
每个类数据成员(也称为实例变量或成员变量)的用途必须明确。如果存在任何类型和名称未明确表达的不变式(特殊值、成员之间的关系、生存期要求),则必须对其进行注释。但是,如果类型和名称足够(int num_events_;
),则不需要注释。
特别是,添加注释以描述前哨值的存在和含义,例如nullptr或-1,当它们不明显时。例如
private: // Used to bounds-check table accesses. -1 means // that we don't yet know how many entries the table has. int num_total_entries_;
全局变量
所有全局变量都应该有一个注释,描述它们是什么,它们用于什么,以及(如果不清楚)为什么它们需要是全局的。例如
// The total number of test cases that we run through in this regression test. const int kNumTestCases = 6;
实现注释
在你的实现中,你应该在代码的棘手、不明显、有趣或重要的部分添加注释。
解释性注释
棘手或复杂的代码块应该在其前面添加注释。
函数参数注释
当函数参数的含义不明显时,请考虑以下补救措施之一
- 如果参数是一个文字常量,并且在多个函数调用中以一种默认它们相同的方式使用相同的常量,那么你应该使用一个命名常量来明确该约束,并保证它成立。
- 考虑更改函数签名,用
enum
参数替换bool
参数。这将使参数值自我描述。 - 对于具有多个配置选项的函数,请考虑定义一个单独的类或结构体来保存所有选项,并传递该类的实例。这种方法有几个优点。选项在调用站点按名称引用,这阐明了它们的含义。它还减少了函数参数的数量,这使得函数调用更容易阅读和编写。作为额外的好处,当你添加另一个选项时,你不必更改调用站点。
- 用命名变量替换大型或复杂的嵌套表达式。
- 作为最后的手段,使用注释来阐明调用站点上的参数含义。
// What are these arguments? const DecimalNumber product = CalculateProduct(values, 7, false, nullptr);
与
ProductOptions options; options.set_precision_decimals(7); options.set_use_cache(ProductOptions::kDontUseCache); const DecimalNumber product = CalculateProduct(values, options, /*completion_callback=*/nullptr);
不要
不要陈述显而易见的事情。特别是,不要逐字描述代码做什么,除非对于一个精通C++的读者来说,该行为是不明显的。相反,提供描述代码为什么要做它所做事情的更高级别的注释,或者使代码自我描述。
比较这个// Find the element in the vector. <-- Bad: obvious! if (std::find(v.begin(), v.end(), element) != v.end()) { Process(element); }与这个
// Process "element" unless it was already processed. if (std::find(v.begin(), v.end(), element) != v.end()) { Process(element); }
自我描述的代码不需要注释。上面示例中的注释将是显而易见的
if (!IsAlreadyProcessed(element)) { Process(element); }
标点符号、拼写和语法
注意标点符号、拼写和语法;阅读写得好的注释比阅读写得不好的注释更容易。
注释应该像叙述性文本一样可读,具有正确的 capitalization 和标点符号。在许多情况下,完整的句子比句子片段更易读。较短的注释(例如,代码行末尾的注释)有时可能不太正式,但你应该与你的风格保持一致。
虽然让代码审查员指出你在应该使用分号时使用逗号可能会令人沮丧,但源代码保持高水平的清晰度和可读性非常重要。正确的标点符号、拼写和语法有助于实现这一目标。
TODO 注释
对于临时代码、短期解决方案或足够好但不完美的代码,请使用TODO
注释。
TODO
s 应该包含所有大写的字符串 TODO
,后跟错误 ID、姓名、电子邮件地址或具有有关 TODO
引用的问题的最佳上下文的个人或问题的其他标识符。
// TODO: bug 12345678 - Remove this after the 2047q4 compatibility window expires. // TODO: example.com/my-design-doc - Manually fix up this code the next time it's touched. // TODO(bug 12345678): Update this list after the Foo service is turned down. // TODO(John): Use a "\*" here for concatenation operator.
如果你的 TODO
采用“在未来某个日期做某事”的形式,请确保你包含一个非常具体的日期(“在 2005 年 11 月之前修复”)或一个非常具体的事件(“当所有客户端都可以处理 XML 响应时删除此代码。”)。
格式化
编码风格和格式相当随意,但是如果每个人都使用相同的风格,项目就更容易理解。个人可能不同意格式规则的每个方面,并且某些规则可能需要一些时间来适应,但是所有项目贡献者都遵循风格规则非常重要,以便他们都可以轻松地阅读和理解每个人的代码。
为了帮助您正确格式化代码,我们创建了一个emacs 的设置文件。
行长度
代码中的每一行文本应最多为 80 个字符。
我们认识到此规则存在争议,但是很多现有代码已经遵守了该规则,并且我们认为一致性很重要。
那些支持此规则的人认为,强迫他们调整窗口大小是不礼貌的,并且没有必要使用更长的行。有些人习惯于并排放置多个代码窗口,因此在任何情况下都没有空间来扩大窗口。人们在假设特定的最大窗口宽度的情况下设置其工作环境,而 80 列一直是传统的标准。为什么要改变它?
变更的支持者认为,更宽的行可以使代码更具可读性。80 列限制是对 1960 年代大型机的顽固倒退;现代设备具有宽屏幕,可以轻松显示更长的行。
最大长度为 80 个字符。
如果一行属于以下情况,则可以超过 80 个字符:
- 注释行,如果不损害可读性、易于剪切和粘贴或自动链接,则无法拆分 - 例如,如果一行包含示例命令或长度超过 80 个字符的文字 URL。
- 字符串字面量,无法轻松地在 80 列处换行。这可能是因为它包含 URI 或其他语义上关键的部分,或者因为该字面量包含嵌入式语言,或者是一个多行字面量,其换行符很重要,例如帮助消息。在这些情况下,分解字面量会降低可读性、可搜索性、单击链接的能力等。除了测试代码外,此类字面量应出现在文件顶部附近的命名空间范围内。如果像 Clang-Format 这样的工具无法识别不可分割的内容,请根据需要禁用该工具。
(我们必须在此类字面量的可用性/可搜索性与周围代码的可读性之间取得平衡。) - include 语句。
- 头文件保护符
- using-declaration
非 ASCII 字符
非 ASCII 字符应很少见,并且必须使用 UTF-8 格式。
您不应在源代码中硬编码面向用户的文本,即使是英语,因此非 ASCII 字符的使用应很少见。但是,在某些情况下,在代码中包含此类单词是合适的。例如,如果您的代码解析来自国外来源的数据文件,则可能适合硬编码这些数据文件中使用的非 ASCII 字符串作为分隔符。更常见的是,单元测试代码(不需要本地化)可能包含非 ASCII 字符串。在这种情况下,您应该使用 UTF-8,因为这是一种大多数能够处理不仅仅是 ASCII 的工具都能理解的编码。
十六进制编码也可以,并且在增强可读性时鼓励使用 - 例如,"\xEF\xBB\xBF"
,或者更简单地说,"\uFEFF"
,是 Unicode 零宽度不间断空格字符,如果作为直接 UTF-8 包含在源代码中,则该字符将不可见。
如果可能,请避免使用 u8
前缀。 从 C++20 开始,它具有显着不同的语义,与 C++17 相比,它产生 char8_t
而不是 char
的数组,并且在 C++23 中将再次更改。
您不应使用 char16_t
和 char32_t
字符类型,因为它们用于非 UTF-8 文本。 出于类似的原因,您也不应使用 wchar_t
(除非您正在编写与 Windows API 交互的代码,Windows API 广泛使用 wchar_t
)。
空格与制表符
仅使用空格,并且一次缩进 2 个空格。
我们使用空格进行缩进。 请勿在代码中使用制表符。 您应该将编辑器设置为在按下 Tab 键时发出空格。
函数声明和定义
返回值类型与函数名称在同一行,参数如果可以容纳则在同一行。 如果参数列表无法容纳在一行上,请像在函数调用中包装参数一样包装它。
函数看起来像这样
ReturnType ClassName::FunctionName(Type par_name1, Type par_name2) { DoSomething(); ... }
如果您有太多文本无法容纳在一行上
ReturnType ClassName::ReallyLongFunctionName(Type par_name1, Type par_name2, Type par_name3) { DoSomething(); ... }
或者如果您甚至无法容纳第一个参数
ReturnType LongClassName::ReallyReallyReallyLongFunctionName( Type par_name1, // 4 space indent Type par_name2, Type par_name3) { DoSomething(); // 2 space indent ... }
需要注意的几点
- 选择好的参数名称。
- 仅当函数的定义中未使用参数时,才可以省略参数名称。
- 如果返回值类型和函数名称无法容纳在一行上,请在它们之间换行。
- 如果在函数声明或定义的返回值类型之后换行,请勿缩进。
- 左括号始终与函数名称位于同一行。
- 函数名称和左括号之间永远没有空格。
- 括号和参数之间永远没有空格。
- 左大括号始终位于函数声明的最后一行的末尾,而不是下一行的开头。
- 右大括号要么位于最后一行的单独一行上,要么与左大括号位于同一行上。
- 右括号和左大括号之间应有一个空格。
- 如果可能,应将所有参数对齐。
- 默认缩进为 2 个空格。
- 包装的参数具有 4 个空格的缩进。
可以省略从上下文中显而易见的未使用参数
class Foo { public: Foo(const Foo&) = delete; Foo& operator=(const Foo&) = delete; };
可能不明显的未使用参数应该在函数定义中注释掉变量名
class Shape { public: virtual void Rotate(double radians) = 0; }; class Circle : public Shape { public: void Rotate(double radians) override; }; void Circle::Rotate(double /*radians*/) {}
// Bad - if someone wants to implement later, it's not clear what the // variable means. void Circle::Rotate(double) {}
属性以及扩展为属性的宏出现在函数声明或定义的开头,在返回类型之前
ABSL_ATTRIBUTE_NOINLINE void ExpensiveFunction(); [[nodiscard]] bool IsOk();
Lambda 表达式
像格式化任何其他函数一样格式化参数和主体,并像其他逗号分隔的列表一样捕获列表。
对于按引用捕获,不要在 & 和变量名之间留空格。
int x = 0; auto x_plus_n = [&x](int n) -> int { return x + n; }
短 lambda 可以作为函数参数内联编写。
absl::flat_hash_set<int> to_remove = {7, 8, 9}; std::vector<int> digits = {3, 9, 1, 8, 4, 7, 1}; digits.erase(std::remove_if(digits.begin(), digits.end(), [&to_remove](int i) { return to_remove.contains(i); }), digits.end());
浮点字面量
浮点字面量应始终具有基数点,两侧都有数字,即使它们使用指数表示法。 如果所有浮点字面量都采用这种熟悉的形式,则可读性会得到提高,因为这有助于确保它们不会被误认为是整数字面量,并且指数表示法的 E
/e
不会被误认为是十六进制数字。 使用整数字面量初始化浮点变量是可以的(假设变量类型可以准确地表示该整数),但请注意,指数表示法中的数字永远不是整数字面量。
float f = 1.f; long double ld = -.5L; double d = 1248e6;
float f = 1.0f; float f2 = 1.0; // Also OK float f3 = 1; // Also OK long double ld = -0.5L; double d = 1248.0e6;
函数调用
要么将调用全部写在一行上,要么在括号处包装参数,要么在新行上开始参数,并缩进四个空格,然后继续使用该 4 个空格的缩进。 在没有其他考虑因素的情况下,请使用最少的行数,包括在适当的地方将多个参数放在每行上。
函数调用具有以下格式
bool result = DoSomething(argument1, argument2, argument3);
如果参数不能全部容纳在一行上,则应将其分成多行,每后续行与第一个参数对齐。 不要在左括号后或右括号前添加空格
bool result = DoSomething(averyveryveryverylongargument1, argument2, argument3);
可以选择将所有参数放在后续行上,并使用四个空格进行缩进
if (...) { ... ... if (...) { bool result = DoSomething( argument1, argument2, // 4 space indent argument3, argument4); ... }
将多个参数放在一行上以减少调用函数所需的行数,除非存在特定的可读性问题。 有些人发现严格地将每个参数格式化在每行上更具可读性,并且简化了参数的编辑。 但是,我们优先考虑读者而不是编辑参数的便利性,并且大多数可读性问题最好通过以下技术来解决。
如果由于构成某些参数的表达式的复杂性或令人困惑的性质而导致在一行中放置多个参数降低了可读性,请尝试创建变量以描述性名称捕获这些参数
int my_heuristic = scores[x] * y + bases[x]; bool result = DoSomething(my_heuristic, x, y, z);
或者将令人困惑的参数放在其自己的行上,并带有解释性注释
bool result = DoSomething(scores[x] * y + bases[x], // Score heuristic. x, y, z);
如果仍然有一种情况下,一个参数在其自身的行上更具可读性,则将其放在其自身的行上。 该决定应特定于更具可读性的参数,而不是一般策略。
有时,参数会形成对可读性很重要的结构。 在这些情况下,可以随意根据该结构格式化参数
// Transform the widget by a 3x3 matrix. my_widget.Transform(x1, x2, x3, y1, y2, y3, z1, z2, z3);
大括号初始化列表格式
像格式化函数调用一样格式化大括号初始化列表。
如果大括号列表遵循名称(例如,类型或变量名称),则格式化方式就像 {}
是具有该名称的函数调用的括号一样。 如果没有名称,则假定为零长度名称。
// Examples of braced init list on a single line. return {foo, bar}; functioncall({foo, bar}); std::pair<int, int> p{foo, bar}; // When you have to wrap. SomeFunction( {"assume a zero-length name before {"}, some_other_function_parameter); SomeType variable{ some, other, values, {"assume a zero-length name before {"}, SomeOtherType{ "Very long string requiring the surrounding breaks.", some, other, values}, SomeOtherType{"Slightly shorter string", some, other, values}}; SomeType variable{ "This is too long to fit all in one line"}; MyType m = { // Here, you could also break before {. superlongvariablename1, superlongvariablename2, {short, interior, list}, {interiorwrappinglist, interiorwrappinglist2}};
循环和分支语句
从宏观上看,循环或分支语句由以下**组件**组成
- 一个或多个**语句关键字**(例如
if
、else
、switch
、while
、do
或for
)。 - 括号内的一个**条件或迭代说明符**。
- 一个或多个**受控语句**或受控语句块。
- 语句的组成部分应以单个空格(而不是换行符)分隔。
- 在条件或迭代说明符中,在每个分号和下一个令牌之间放置一个空格(或换行符),除非该令牌是右括号或另一个分号。
- 在条件或迭代说明符中,请勿在左括号后或右括号前放置空格。
- 将任何受控语句放在块中(即,使用大括号)。
- 在受控块内,在左大括号后立即放置一个换行符,并在右大括号前立即放置一个换行符。
if (condition) { // Good - no spaces inside parentheses, space before brace. DoOneThing(); // Good - two-space indent. DoAnotherThing(); } else if (int a = f(); a != 3) { // Good - closing brace on new line, else on same line. DoAThirdThing(a); } else { DoNothing(); } // Good - the same rules apply to loops. while (condition) { RepeatAThing(); } // Good - the same rules apply to loops. do { RepeatAThing(); } while (condition); // Good - the same rules apply to loops. for (int i = 0; i < 10; ++i) { RepeatAThing(); }
if(condition) {} // Bad - space missing after `if`. else if ( condition ) {} // Bad - space between the parentheses and the condition. else if (condition){} // Bad - space missing before `{`. else if(condition){} // Bad - multiple spaces missing. for (int a = f();a == 10) {} // Bad - space missing after the semicolon. // Bad - `if ... else` statement does not have braces everywhere. if (condition) foo; else { bar; } // Bad - `if` statement too long to omit braces. if (condition) // Comment DoSomething(); // Bad - `if` statement too long to omit braces. if (condition1 && condition2) DoSomething();
由于历史原因,我们允许上述规则的一个例外:如果因此整个语句出现在单行上(在这种情况下,右括号和受控语句之间有一个空格)或两行上(在这种情况下,右括号后有一个换行符,并且没有大括号),则可以省略受控语句的大括号或大括号内的换行符。
// OK - fits on one line. if (x == kFoo) { return new Foo(); } // OK - braces are optional in this case. if (x == kFoo) return new Foo(); // OK - condition fits on one line, body fits on another. if (x == kBar) Bar(arg1, arg2, arg3);
此例外不适用于多关键字语句,如 if ... else
或 do ... while
。
// Bad - `if ... else` statement is missing braces. if (x) DoThis(); else DoThat(); // Bad - `do ... while` statement is missing braces. do DoThis(); while (x);
只有在语句简短时才使用此样式,并且考虑到具有复杂条件或受控语句的循环和分支语句可能使用花括号更具可读性。有些项目始终需要花括号。
switch
语句中的 case
块可以有花括号,也可以没有,这取决于你的偏好。 如果你确实包含花括号,则应如下所示放置它们。
switch (var) { case 0: { // 2 space indent Foo(); // 4 space indent break; } default: { Bar(); } }
空的循环体应使用一对空的花括号或 continue
(不带花括号),而不是单个分号。
while (condition) {} // Good - `{}` indicates no logic. while (condition) { // Comments are okay, too } while (condition) continue; // Good - `continue` indicates no logic.
while (condition); // Bad - looks like part of `do-while` loop.
指针和引用表达式
句点或箭头周围没有空格。指针运算符没有尾随空格。
以下是格式正确的指针和引用表达式的示例
x = *p; p = &x; x = r.y; x = r->y;
请注意
- 访问成员时,句点或箭头周围没有空格。
- 指针运算符在
*
或&
之后没有空格。
在引用指针或引用(变量声明或定义、参数、返回类型、模板参数等)时,你可以将空格放置在星号/与号之前或之后。 在尾随空格样式中,在某些情况下(模板参数等)会省略空格。
// These are fine, space preceding. char *c; const std::string &str; int *GetPointer(); std::vector<char *> // These are fine, space following (or elided). char* c; const std::string& str; int* GetPointer(); std::vector<char*> // Note no space between '*' and '>'
你应该在单个文件中保持一致。 修改现有文件时,请使用该文件中的样式。
允许(如果不同寻常)在同一声明中声明多个变量,但如果其中任何一个变量具有指针或引用修饰,则是不允许的。 这样的声明很容易被误读。
// Fine if helpful for readability. int x, y;
int x, *y; // Disallowed - no & or * in multiple declaration int* x, *y; // Disallowed - no & or * in multiple declaration; inconsistent spacing char * c; // Bad - spaces on both sides of * const std::string & str; // Bad - spaces on both sides of &
布尔表达式
如果你的布尔表达式长于标准行长度,请保持行中断方式的一致性。
在此示例中,逻辑 AND 运算符始终位于行的末尾
if (this_one_thing > this_other_thing && a_third_thing == a_fourth_thing && yet_another && last_one) { ... }
请注意,当代码在此示例中换行时,两个 &&
逻辑 AND 运算符都位于行的末尾。 这在 Google 代码中更为常见,尽管也允许将所有运算符包装在行的开头。 可以随意插入额外的括号,因为适当地使用它们可以非常有助于提高可读性,但要注意过度使用。 另请注意,应始终使用标点符号运算符,例如 &&
和 ~
,而不是单词运算符,例如 and
和 compl
。
返回值
不要不必要地用括号括住 return
表达式。
仅在 return expr;
中使用括号,在你会在 x = expr;
中使用括号的地方使用它们。
return result; // No parentheses in the simple case. // Parentheses OK to make a complex expression more readable. return (some_long_condition && another_condition);
return (value); // You wouldn't write var = (value); return(result); // return is not a function!
变量和数组初始化
你可以在 =
、()
和 {}
之间进行选择; 以下都是正确的
int x = 3; int x(3); int x{3}; std::string name = "Some Name"; std::string name("Some Name"); std::string name{"Some Name"};
当在具有 std::initializer_list
构造函数的类型上使用带括号的初始化列表 {...}
时要小心。 非空的带括号的初始化列表会尽可能首选 std::initializer_list
构造函数。 请注意,空花括号 {}
是特殊的,如果可用,将调用默认构造函数。 要强制使用非 std::initializer_list
构造函数,请使用括号而不是花括号。
std::vector<int> v(100, 1); // A vector containing 100 items: All 1s. std::vector<int> v{100, 1}; // A vector containing 2 items: 100 and 1.
此外,花括号形式可防止整数类型缩小。 这可以防止某些类型的编程错误。
int pi(3.14); // OK -- pi == 3. int pi{3.14}; // Compile error: narrowing conversion.
预处理器指令
开始预处理器指令的井号应始终位于行的开头。
即使预处理器指令位于缩进代码的主体中,指令也应从行的开头开始。
// Good - directives at beginning of line if (lopsided_score) { #if DISASTER_PENDING // Correct -- Starts at beginning of line DropEverything(); # if NOTIFY // OK but not required -- Spaces after # NotifyClient(); # endif #endif BackToNormal(); }
// Bad - indented directives if (lopsided_score) { #if DISASTER_PENDING // Wrong! The "#if" should be at beginning of line DropEverything(); #endif // Wrong! Do not indent "#endif" BackToNormal(); }
类格式
public
、protected
和 private
部分按顺序排列,每个部分缩进一个空格。
类定义的基本格式(缺少注释,有关需要哪些注释的讨论,请参见类注释)是
class MyClass : public OtherClass { public: // Note the 1 space indent! MyClass(); // Regular 2 space indent. explicit MyClass(int var); ~MyClass() {} void SomeFunction(); void SomeFunctionThatDoesNothing() { } void set_some_var(int var) { some_var_ = var; } int some_var() const { return some_var_; } private: bool SomeInternalFunction(); int some_var_; int some_other_var_; };
注意事项
- 任何基类名称都应与子类名称位于同一行,但受 80 列限制。
public:
、protected:
和private:
关键字应缩进一个空格。- 除了第一个实例外,这些关键字前面应有一个空行。 此规则在小型类中是可选的。
- 不要在这些关键字后留下空行。
public
部分应排在第一位,其次是protected
部分,最后是private
部分。- 有关在每个部分中声明排序的规则,请参见声明顺序。
构造函数初始化列表
构造函数初始化列表可以全部放在一行上,也可以将后续行缩进四个空格。
初始化列表的可接受格式为
// When everything fits on one line: MyClass::MyClass(int var) : some_var_(var) { DoSomething(); } // If the signature and initializer list are not all on one line, // you must wrap before the colon and indent 4 spaces: MyClass::MyClass(int var) : some_var_(var), some_other_var_(var + 1) { DoSomething(); } // When the list spans multiple lines, put each member on its own line // and align them: MyClass::MyClass(int var) : some_var_(var), // 4 space indent some_other_var_(var + 1) { // lined up DoSomething(); } // As with any other code block, the close curly can be on the same // line as the open curly, if it fits. MyClass::MyClass(int var) : some_var_(var) {}
命名空间格式
命名空间的内容不会缩进。
命名空间不会添加额外的缩进级别。 例如,使用
namespace { void foo() { // Correct. No extra indentation within namespace. ... } } // namespace
不要在命名空间内缩进
namespace { // Wrong! Indented when it should not be. void foo() { ... } } // namespace
水平空格
水平空格的使用取决于位置。 切勿在行尾放置尾随空格。
一般
int i = 0; // Two spaces before end-of-line comments. void f(bool b) { // Open braces should always have a space before them. ... int i = 0; // Semicolons usually have no space before them. // Spaces inside braces for braced-init-list are optional. If you use them, // put them on both sides! int x[] = { 0 }; int x[] = {0}; // Spaces around the colon in inheritance and initializer lists. class Foo : public Bar { public: // For inline function implementations, put spaces between the braces // and the implementation itself. Foo(int b) : Bar(), baz_(b) {} // No spaces inside empty braces. void Reset() { baz_ = 0; } // Spaces separating braces from implementation. ...
添加尾随空格可能会给编辑同一文件的其他人带来额外的工作,当他们合并时,删除现有尾随空格也是如此。 所以:不要引入尾随空格。 如果你已经在更改该行,则删除它,或者在单独的清理操作中执行此操作(最好是在没有其他人处理该文件时)。
循环和条件
if (b) { // Space after the keyword in conditions and loops. } else { // Spaces around else. } while (test) {} // There is usually no space inside parentheses. switch (i) { for (int i = 0; i < 5; ++i) { // Loops and conditions may have spaces inside parentheses, but this // is rare. Be consistent. switch ( i ) { if ( test ) { for ( int i = 0; i < 5; ++i ) { // For loops always have a space after the semicolon. They may have a space // before the semicolon, but this is rare. for ( ; i < 5 ; ++i) { ... // Range-based for loops always have a space before and after the colon. for (auto x : counts) { ... } switch (i) { case 1: // No space before colon in a switch case. ... case 2: break; // Use a space after a colon if there's code after it.
运算符
// Assignment operators always have spaces around them. x = 0; // Other binary operators usually have spaces around them, but it's // OK to remove spaces around factors. Parentheses should have no // internal padding. v = w * x + y / z; v = w*x + y/z; v = w * (x + z); // No spaces separating unary operators and their arguments. x = -5; ++x; if (x && !y) ...
模板和强制转换
// No spaces inside the angle brackets (< and >), before // <, or between >( in a cast std::vector<std::string> x; y = static_cast<char*>(x); // Spaces between type and pointer are OK, but be consistent. std::vector<char *> x;
垂直空格
尽量减少垂直空格的使用。
这更多的是一项原则,而不是规则:在不需要时不要使用空行。 特别是,不要在函数之间放置超过一个或两个空行,尽量不要以空行开始函数,不要以空行结束函数,并且要谨慎使用空行。 代码块中的空行就像散文中的段落分隔符:在视觉上分隔两个想法。
基本原则是:屏幕上显示的程序代码越多,就越容易遵循和理解程序的控制流。 有目的地使用空格来分隔控制流。
一些经验法则,可以帮助你确定何时空行可能有用
- 函数开头或结尾处的空行无助于提高可读性。
- if-else 块链中的空行可能有助于提高可读性。
- 注释行之前的空行通常有助于提高可读性——引入新的注释表明新思路的开始,空行清楚地表明注释与后面的内容而不是前面的内容相关联。
- 紧靠命名空间或命名空间块的声明内的空行可以通过在视觉上将承载内容与(很大程度上非语义的)组织包装器分开来帮助提高可读性。 特别是当命名空间内的第一个声明前面有注释时,这会成为前一条规则的特例,帮助注释“附加”到后续声明。
规则的例外情况
以上描述的编码约定是强制性的。 但是,与所有好的规则一样,这些规则有时会有例外,我们在此处讨论。
现有的不符合规范的代码
处理不符合本样式指南的代码时,你可以偏离这些规则。
如果你发现自己在修改根据本指南提供的规范以外的规范编写的代码,则可能需要偏离这些规则,以便与该代码中的本地约定保持一致。 如果你对如何执行此操作有疑问,请咨询原始作者或当前负责该代码的人员。 请记住,一致性也包括本地一致性。
Windows 代码
Windows 程序员已经开发了自己的编码约定集,主要源自 Windows 标头和其他 Microsoft 代码中的约定。 我们希望让任何人都能轻松理解你的代码,因此我们为在任何平台上编写 C++ 的每个人都提供了一组统一的准则。
值得重申一些你可能忘记的指导原则,如果你习惯了流行的 Windows 样式
- 不要使用匈牙利命名法(例如,将整数命名为
iNum
)。 使用 Google 命名约定,包括源文件的.cc
扩展名。 - Windows 为原始类型定义了许多自己的同义词,例如
DWORD
、HANDLE
等。 当调用 Windows API 函数时,完全可以接受并且鼓励你使用这些类型。 即便如此,也请尽可能接近底层的 C++ 类型。 例如,使用const TCHAR *
而不是LPCTSTR
。 - 使用 Microsoft Visual C++ 进行编译时,将编译器设置为警告级别 3 或更高,并将所有警告视为错误。
- 不要使用
#pragma once
; 而是使用标准的 Google 包含保护。 包含保护中的路径应相对于项目树的顶部。 - 事实上,除非绝对必要,否则不要使用任何非标准扩展,例如
#pragma
和__declspec
。 允许使用__declspec(dllimport)
和__declspec(dllexport)
; 但是,你必须通过诸如DLLIMPORT
和DLLEXPORT
之类的宏来使用它们,以便有人可以在共享代码时轻松禁用这些扩展。
但是,在 Windows 上,我们偶尔需要打破一些规则
- 通常,我们强烈建议不要使用多重实现继承; 但是,在使用 COM 和某些 ATL/WTL 类时,这是必需的。 你可以使用多重实现继承来实现 COM 或 ATL/WTL 类和接口。
- 尽管你不应在自己的代码中使用异常,但它们在 ATL 和某些 STL 中被广泛使用,包括 Visual C++ 附带的 STL。 使用 ATL 时,应定义
_ATL_NO_EXCEPTIONS
以禁用异常。 你应该调查是否也可以在 STL 中禁用异常,但如果不能,则可以在编译器中启用异常。 (请注意,这仅用于使 STL 编译。 你仍然不应该自己编写异常处理代码。) - 使用预编译标头的常用方法是在每个源文件的顶部包含一个标头文件,通常名称类似于
StdAfx.h
或precompile.h
。 为了使你的代码更易于与其他项目共享,请避免显式包含此文件(除了在precompile.cc
中),并使用/FI
编译器选项自动包含该文件。 - 资源标头通常命名为
resource.h
,仅包含宏,不需要符合这些样式指南。