文章目录
- 背景介绍
- 风格指南的目标
- C++ 版本
- 头文件
- 自包含头文件
- #define 防护
- 包含所需内容
- 前置声明
- 在头文件中定义函数
- 头文件包含顺序与命名规范
- 作用域
- 命名空间
- 内部链接
- 非成员函数、静态成员函数与全局函数
- 局部变量
- 静态与全局变量
- 关于析构的决策
- 关于初始化的决策
- 常见模式
- thread_local 变量
- 类
- 构造函数中的工作处理
- 隐式转换
- 可复制与可移动类型
- 优势
- 实现要点
- 注意事项
- 规范要求
- 结构体与类的选择
- 结构体 vs. 对组与元组
- 继承
- 运算符重载
- 访问控制
- 声明顺序
- 函数
- 输入与输出
- 编写短小的函数
- 函数重载
- 默认参数
- 尾置返回类型语法
- Google 特有的魔法技巧
- 所有权与智能指针
- cpplint
- 其他 C++ 特性
- 右值引用
- 友元
- 异常处理规范
- 支持使用异常的理由
- 反对使用异常的理由
- 现状考量
- `noexcept`
- 运行时类型信息 (RTTI)
- 类型转换
- 流
- 前增量和前减量
- const的使用
- const 的位置选择
- constexpr、constinit 和 consteval 的使用
- 整数类型
- 关于无符号整数
- 浮点类型
- 架构可移植性
- 预处理器宏
- 0 与 nullptr/NULL 的区别
- sizeof
- 类型推导(包括auto)
- 函数模板参数推导
- 局部变量类型推导
- 返回类型推导
- 参数类型推导
- Lambda 初始化捕获
- 结构化绑定
- 类模板参数推导
- 指定初始化器
- Lambda 表达式
- 模板元编程
- 概念与约束的使用准则
- C++20 模块
- 协程
- Boost库使用规范
- 禁用标准库特性
- 非标准扩展
- 别名
- Switch 语句
- 包容性语言
- 命名规范
- 命名选择
- 文件名规范
- 类型命名
- 概念命名
- 变量命名
- 常见变量命名
- 类数据成员
- 结构体数据成员
- 常量命名
- 函数命名
- 命名空间名称
- 枚举器命名
- 模板参数命名规范
- 宏命名规范
- 别名
- 命名规则的例外情况
- 注释
- 注释风格
- 文件注释
- 法律声明与作者署名
- 结构体与类注释
- 类注释规范
- 函数注释
- 函数声明
- 函数定义
- 变量注释
- 类数据成员
- 全局变量
- 实现注释
- 解释性注释
- 函数参数注释
- 避免事项
- 标点、拼写与语法规范
- TODO 注释
- 代码格式规范
- 行宽限制
- 允许例外的情况
- 非ASCII字符
- 空格与制表符
- 函数声明与定义
- Lambda 表达式
- 浮点数字面量
- 函数调用
- 大括号初始化列表格式
- 循环与分支语句
- 指针与引用表达式及类型
- 布尔表达式
- 返回值
- 变量与数组初始化
- 预处理器指令
- 类格式
- 构造函数初始化列表
- 命名空间格式化
- 水平空白符的使用
- 概述
- 循环与条件语句
- 运算符
- 模板与类型转换
- 垂直留白
- 规则的例外情况
- 现有不符合规范的代码
- Windows 代码规范
背景介绍
C++是谷歌众多开源项目采用的主要开发语言之一。正如每位C++开发者所知,该语言具备许多强大特性,但伴随这种强大而来的是复杂性,这种复杂性可能导致代码更易出错且难以阅读维护。
本指南旨在通过详细阐述编写C++代码的最佳实践和禁忌来管控这种复杂性。这些规则的存在既是为了保持代码库的可管理性,同时也让开发者能高效利用C++语言特性。
所谓代码风格(也称为可读性),是指我们规范C++代码的约定集合。"风格"这个术语其实不够准确,因为这些约定涵盖的范围远不止源代码格式化。
谷歌开发的大多数开源项目都遵循本指南的要求。
请注意,本指南并非C++教程:我们默认读者已具备该语言的基础知识。
风格指南的目标
为什么我们需要这份文档?
我们认为本指南应服务于几个核心目标。这些根本性的"为什么"构成了所有具体规则的基础。通过将这些理念置于首位,我们希望为讨论奠定基础,并让更广泛的社区更清楚地理解规则制定的原因以及特定决策背后的考量。如果您能理解每条规则所服务的目标,那么当某条规则可能被豁免时(有些规则确实可以),以及需要怎样的论据或替代方案才能修改指南中的规则时,对所有人来说都会更加清晰。
当前我们认为风格指南的目标如下:
- 规则的价值必须与其成本相当
风格规则的收益必须足够大,才能证明要求所有工程师记住它是合理的。收益是相对于没有该规则时我们可能获得的代码库来衡量的,因此针对极其有害做法的规则即使收益较小也可能是合理的——如果人们不太可能这么做的话。这一原则主要解释了我们没有哪些规则,而非已有规则:例如goto
违反了许多后续原则,但由于其已极为罕见,因此风格指南并未讨论它。 - 为读者而非作者优化
我们的代码库(以及提交给它的绝大多数独立组件)预计会持续存在相当长时间。因此,阅读代码的时间将远超过编写代码的时间。我们明确选择为工程师在代码库中阅读、维护和调试代码的平均体验进行优化,而非追求编写代码时的便利性。"为读者留下痕迹"是这一原则下特别常见的子要点:当代码片段中出现意外或特殊情况时(例如指针所有权的转移),在使用处为读者留下文本提示非常有价值(如std::unique_ptr
在调用点明确展示了所有权转移)。 - 与现有代码保持一致
在整个代码库中保持统一的风格让我们能专注于其他(更重要的)问题。一致性还为自动化提供了可能:格式化代码或调整#include
的工具只有在代码符合工具预期时才能正常工作。许多情况下,“保持一致性"的规则可归结为"选定一种方式并停止纠结”——在这些问题上允许灵活性的潜在价值,远低于人们争论它们所付出的代价。但一致性也有其限度:当缺乏明确技术论据或长期方向时,它是很好的决策依据。一致性在局部(单个文件或紧密相关的接口集)应用得更严格。通常不应将一致性作为沿用旧风格的借口,而不考虑新风格的优势或代码库随时间推移向新风格靠拢的趋势。 - 在适当时与更广泛的C++社区保持一致
与其他组织使用C++的方式保持一致,其价值与保持代码库内部一致性同理。如果C++标准中的特性解决了问题,或者某些惯用法被广泛认知和接受,这就是使用它的理由。但有时标准特性或惯用法存在缺陷,或设计时未考虑我们代码库的需求。在这些情况下(如下文所述),限制或禁止标准特性是合理的。有时我们更倾向于使用自研或第三方库而非C++标准库,这可能是出于对优越性的判断,或认为迁移到标准接口的价值不足。 - 避免意外或危险的构造
C++中有些特性比乍看之下更令人意外或危险。部分风格限制正是为了防止落入这些陷阱。对此类限制的豁免门槛很高,因为豁免往往直接危及程序正确性。 - 避免让普通C++程序员感到复杂或难以维护的构造
C++的某些特性可能因引入的复杂性而不适合普遍使用。在广泛使用的代码中,采用更复杂的语言构造可能更可接受,因为复杂实现带来的收益会通过广泛使用被放大,而理解复杂性的成本在接触代码库新部分时无需重复支付。如有疑问,可向项目负责人申请豁免此类规则。这对我们的代码库尤为重要,因为代码所有权和团队成员会随时间变化:即使当前所有相关开发人员都理解某段代码,也不能保证几年后依然如此。 - 考虑我们的规模
在拥有上亿行代码和数千名工程师的环境中,个别工程师的错误或简化可能对许多人造成高昂代价。例如避免污染全局命名空间尤为重要:在数亿行代码库中,如果所有人都将内容放入全局命名空间,命名冲突将难以处理和避免。 - 必要时为优化让步
性能优化有时是必要且恰当的,即使它们与本文件的其他原则相冲突。
本文档旨在提供最大限度的指导,同时保持合理限制。一如既往,常识和良好的判断力应占上风。这里我们特指整个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
防护机制来防止重复包含。符号名称的格式应为 *<项目名>*_*<路径>*_*<文件名>*_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
,foo.cc
也应显式包含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
文件中,除非出于性能或技术原因必须置于头文件。
即使定义必须放在头文件中,也不足以成为将其置于公开部分的理由。相反,定义可以放在头文件的内部区域,例如类的private部分、包含internal
字样的命名空间内,或类似// 以下仅为实现细节
的注释下方。
一旦定义出现在头文件中,必须通过添加inline
说明符、或作为函数模板隐式内联、或在首次声明时定义于类体内等方式确保其ODR安全性。
template <typename T>
class Foo {public:int bar() { return bar_; }
void MethodWithHugeBody();private:int bar_;
};// Implementation details only below heretemplate <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"#ifdef _WIN32
#include <windows.h>
#endif // _WIN32
作用域
命名空间
除少数例外情况外,应将代码置于命名空间内。命名空间名称应基于项目名称(可能包含路径)保持唯一。禁止使用 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 filenamespace mynamespace {// All declarations are within the namespace scope.// Notice the lack of indentation.class MyClass {public:...void Foo();};} // namespace mynamespace
// In the .cc filenamespace 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
指令。详情参见Protocol Buffer Packages。 - 禁止在
std
命名空间中声明任何内容,包括标准库类的前向声明。在std
命名空间中声明实体属于未定义行为,即不具备可移植性。如需使用标准库中的实体,请包含对应的头文件。 - 禁止通过using-directive指令使命名空间下的所有名称全局可用。
// Forbidden -- This pollutes the namespace.using namespace foo;
- 不要在头文件的命名空间作用域中使用命名空间别名,除非是在明确标记为内部使用的命名空间中。因为任何被导入到头文件命名空间的内容都会成为该文件导出的公共API的一部分。当不满足上述条件时可以使用命名空间别名,但必须遵循适当的命名规范。
// In a .h file, an alias must not be a separate API, or must be hidden in an// implementation detail.namespace librarian {namespace internal { // Internal, not part of the API.namespace sidetable = ::pipeline_diagnostics::sidetable;} // namespace internalinline void my_inline_function() {// Local to a function.namespace baz = ::foo::bar::baz;...}} // namespace librarian
// Remove uninteresting parts of some commonly used names in .cc files.namespace sidetable = ::pipeline_diagnostics::sidetable;
- 不要使用内联命名空间。
- 对于 API 中不应被用户提及的部分,使用名称中包含 “internal” 的命名空间进行文档标注。
// 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初始化的变量),也可能是静态初始化。两者并非完全对立:静态初始化总是先于动态初始化发生(将对象初始化为给定常量或全零字节表示),动态初始化仅在需要时随后执行。
全局和静态变量在以下场景非常有用:
- 命名常量
- 翻译单元内部的辅助数据结构
- 命令行标志
- 日志系统
- 注册机制
- 后台基础设施等
但使用动态初始化或非平凡析构函数的全局/静态变量会引入复杂性,容易导致难以发现的缺陷。动态初始化在翻译单元间没有顺序保证,析构同样如此(仅保证析构顺序与初始化相反)。当某个初始化引用另一个静态存储期变量时,可能导致对象在其生命周期开始前(或结束后)被访问。此外,若程序启动的线程在退出时未被合并,这些线程可能在对象生命周期结束后尝试访问已被析构的对象。
关于析构的决策
当析构函数是平凡(trivial)时,它们的执行完全不受顺序约束(实际上不会"运行");否则我们将面临在对象生命周期结束后仍访问它们的风险。因此,我们只允许具有静态存储期且可平凡析构的对象存在。基础类型(如指针和int
)是可平凡析构的,由可平凡析构类型构成的数组也是如此。请注意,标记为constexpr
的变量也是可平凡析构的。
const int kNum = 10; // Allowedstruct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // Allowedvoid 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.
允许(并且常见)对静态局部变量进行动态初始化。
常见模式
- 全局字符串:如果需要命名的全局或静态字符串常量,考虑使用指向字符串字面量的
constexpr
变量(类型为string_view
、字符数组或字符指针)。字符串字面量本身具有静态存储期,通常已能满足需求。详见TotW #140。 - 映射表、集合和其他动态容器:如果需要静态的固定集合(例如用于检索的集合或查找表),不能将标准库中的动态容器作为静态变量使用,因为它们具有非平凡的析构函数。可考虑使用由平凡类型构成的简单数组,例如整型数组的数组(实现"整型到整型的映射"),或由键值对构成的数组(例如
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
变量,就会导致难以诊断的释放后使用(use-after-free)错误。建议优先使用简单类型,或能证明在析构时不执行用户提供代码的类型,以降低访问其他thread_local
变量的风险。
在定义线程局部数据时,应优先选择thread_local
而非其他机制。
类
类是 C++ 中最基础的代码单元,我们自然会大量使用它们。本节列出了编写类时应遵循的主要注意事项。
构造函数中的工作处理
应避免在构造函数中调用虚方法,若无法有效传递错误信号,则尽量避免可能失败的初始化操作。
在构造函数体内执行任意初始化是可行的:
- 无需担心类是否已完成初始化
- 通过构造函数调用完成完全初始化的对象可声明为
const
,且更易于配合标准容器或算法使用 - 若涉及虚函数调用,这些调用不会分派到子类实现。即使当前类未被继承,未来修改仍可能悄然引入此问题,导致难以排查的隐患
- 构造函数缺乏有效的错误通知机制,除了终止程序(并非总是适用)或使用异常(根据规范禁止)
- 若初始化失败,将获得一个初始化异常的对象,可能需要引入
bool IsValid()
等状态检查机制(此类检查常被遗漏调用) - 无法获取构造函数地址,因此构造函数中的工作难以移交(例如给其他线程)
构造函数绝不应调用虚函数。若符合代码场景,终止程序可能是合理的错误处理方式。否则建议采用如TotW #42所述的工厂函数或Init()
方法。对于不存在其他状态影响公共方法调用的对象(此类半构造对象尤其难以正确处理),应避免使用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 movableNotCopyableOrMovable(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
部分(例如结构体或纯接口基类),那么其可复制性/可移动性取决于公有数据成员的相应特性。 - 如果基类明显不可复制或移动,派生类自然也不具备这些特性。仅通过隐式操作定义的纯接口基类,不足以明确具体子类的这些行为。
- 注意:如果显式声明或删除了拷贝构造函数或拷贝赋值操作中的任意一个,另一个拷贝操作不会自动生效,必须显式声明或删除。移动操作同理。
当普通用户难以理解复制/移动的语义,或这些操作会带来意外开销时,类型不应支持复制/移动。对于可复制类型而言,移动操作仅是性能优化手段,可能引发错误和复杂性,因此除非移动操作效率显著高于拷贝操作,否则应避免定义。如果类型支持拷贝操作,建议将类的默认实现设计为正确行为。切记像检查其他代码一样审查默认操作的正确性。
为避免对象切割风险,建议通过以下方式将基类设为抽象类:将其构造函数设为protected、声明protected析构函数,或提供至少一个纯虚成员函数。尽量避免从具体类继承。
结构体与类的选择
仅当处理纯数据载体时使用struct
,其他情况一律使用class
。
在C++中,struct
和class
关键字的行为几乎完全一致。我们为这两个关键字赋予特定的语义含义,因此应根据定义的数据类型选择合适的关键字。
struct
应当用于纯数据载体,可以包含关联常量。所有字段必须公开。结构体不应存在隐含字段间关系的约束条件,因为用户直接访问字段可能破坏这些约束。允许存在构造函数、析构函数和辅助方法,但这些方法不得要求或强制任何约束条件。
若需要更复杂的功能或约束条件,或结构体具有广泛可见性且预期会演进,则更适合使用class
。如有疑问,优先选择class
。
为保持与STL的一致性,对于无状态的类型(如特性类、模板元函数和部分函数对象),可使用struct
替代class
。
注意:结构体与类的成员变量遵循不同的命名规则。
结构体 vs. 对组与元组
当元素可以拥有有意义的名称时,优先使用 struct
而非对组(pair)或元组(tuple)。
虽然使用对组和元组可以避免定义自定义类型,可能在编写代码时减少工作量,但在阅读代码时,一个有意义的字段名几乎总是比 .first
、.second
或 std::get<X>
清晰得多。尽管 C++14 引入了通过类型而非索引访问元组元素的 std::get<Type>
(当类型唯一时)有时能部分缓解这个问题,但字段名通常比类型名更清晰且信息量更丰富。
在泛型代码中,若对组或元组的元素没有特定含义时,使用它们可能是合适的。此外,为了与现有代码或 API 交互,也可能需要使用对组或元组。
继承
组合通常比继承更合适。当使用继承时,应将其设为public
。
当子类继承基类时,它会包含基类定义的所有数据和操作的定义。“接口继承"是指从纯抽象基类(无状态或已定义方法)继承;其他所有继承都属于"实现继承”。
实现继承通过复用基类代码来缩小代码规模,同时特化现有类型。由于继承是编译时声明,开发者和编译器都能理解操作并检测错误。接口继承可用于以编程方式强制类暴露特定API。同样,编译器可以检测错误,例如当类未定义API的必要方法时。
对于实现继承,由于子类的实现代码分布在基类和子类之间,可能更难理解具体实现。子类无法重写非虚函数,因此不能改变其实现。
多重继承尤其存在问题,因为它通常会带来更高的性能开销(实际上,从单继承到多重继承的性能下降往往比普通派发到虚派发的下降更显著),并且可能导致"菱形"继承模式,这种模式容易引发歧义、混淆甚至直接错误。
所有继承都应该是public
的。如果需要私有继承,应该改为将基类实例作为成员包含。当不希望类被用作基类时,可以使用final
修饰符。
不要过度使用实现继承。组合通常更合适。尽量将继承限制在"is-a"的情况下:如果可以说Bar
是Foo
的一种,那么Bar
才应该继承Foo
。
将protected
的使用限制在可能需要被子类访问的成员函数上。注意数据成员应为private
。
使用override
或(较少使用的)final
修饰符明确标注虚函数或虚析构函数的重写。声明重写时不要使用virtual
关键字。原理:标记为override
或final
的函数或析构函数如果不是基类虚函数的重写,将无法通过编译,这有助于捕获常见错误。这些修饰符也起到文档作用;如果没有修饰符,读者需要检查类的所有祖先才能确定函数或析构函数是否为虚函数。
允许使用多重继承,但强烈不建议使用多重实现继承。
运算符重载
应谨慎使用运算符重载。不要使用用户自定义字面量。
C++允许用户代码通过operator
关键字声明内置运算符的重载版本,只要其中一个参数是用户自定义类型。operator
关键字还允许用户代码使用operator""
定义新的字面量类型,以及定义类型转换函数如operator bool()
。
运算符重载能让用户自定义类型表现得像内置类型一样,使代码更简洁直观。重载运算符是某些操作的惯用名称(如==
、<
、=
和<<
),遵循这些约定可以使自定义类型更具可读性,并能与期望这些名称的库互操作。
用户自定义字面量是创建用户自定义类型对象的极简表示法。
- 提供正确、一致且符合预期的运算符重载集需要格外小心,否则可能导致混淆和错误。
- 滥用运算符会导致代码晦涩难懂,特别是当重载运算符的语义不符合惯例时。
- 函数重载的风险同样存在于运算符重载中,甚至更为严重。
- 运算符重载可能误导我们以为高开销操作是廉价的内置操作。
- 查找重载运算符的调用点可能需要支持C++语法的搜索工具,而非简单的grep。
- 如果重载运算符的参数类型错误,可能会调用不同的重载版本而非触发编译错误。例如
foo < bar
和&foo < &bar
可能执行完全不同的操作。 - 某些运算符重载本身具有风险。重载一元
&
会导致同一代码在不同上下文中含义不同。&&
、||
和逗号运算符的重载无法匹配内置运算符的求值顺序语义。 - 运算符通常在类外定义,因此存在不同文件引入相同运算符不同定义的风险。若两个定义链接到同一二进制文件,会导致未定义行为,表现为微妙的运行时错误。
- 用户自定义字面量(UDLs)会创建即使经验丰富的C++程序员也不熟悉的语法形式,如用
"Hello World"sv
表示std::string_view("Hello World")
。现有表示法虽然不够简洁,但更为清晰。 - 由于UDLs不能限定命名空间,使用时需要配合using指令(我们禁止使用)或using声明(头文件中禁止使用,除非导入的名称是该头文件接口的一部分)。鉴于头文件必须避免UDL后缀,我们更倾向于保持头文件与源文件字面量规则的一致性。
仅当运算符含义明确、符合预期且与对应内置运算符一致时才定义重载。例如,使用|
表示按位或逻辑或,而非shell风格的管道。
仅对自定义类型定义运算符。更准确地说,应在与操作类型相同的头文件、.cc
文件和命名空间中定义它们。这样运算符在类型可用的地方都可用,最小化多重定义风险。如有可能,避免将运算符定义为模板,因为它们必须对所有模板参数满足此规则。如果定义了一个运算符,也应定义所有相关的合理运算符,并确保定义一致。
优先将非修改性二元运算符定义为非成员函数。若二元运算符作为类成员定义,隐式转换适用于右参数但不适用于左参数。如果a + b
能编译而b + a
不能,会让用户感到困惑。
对于可比较相等性的类型T
,定义非成员operator==
并说明何时认为两个T
类型的值相等。如果存在明确的比较规则,可以额外定义与operator==
保持一致的operator<=>
。尽量避免重载其他比较和排序运算符。
不要刻意避免定义运算符重载。例如,优先定义==
、=
和<<
,而非Equals()
、CopyFrom()
和PrintTo()
。反之,不要仅因其他库需要就定义运算符重载。例如,若类型没有自然排序但需存入std::set
,应使用自定义比较器而非重载<
。
不要重载&&
、||
、逗号或一元&
。不要重载operator""
,即不要引入用户自定义字面量。不要使用他人提供的此类字面量(包括标准库)。
类型转换运算符在隐式转换章节说明。=
运算符在拷贝构造函数章节说明。流操作相关的<<
重载在流章节说明。另请参阅同样适用于运算符重载的函数重载规则。
访问控制
除非是常量,否则应将类的数据成员声明为private
。这种做法虽然需要编写一些简单的访问器(通常是const
类型)作为样板代码,但能显著简化对不变量的推理。
出于技术原因,我们允许在.cc
文件中定义的测试夹具类(使用[Google Test](https://github.com/google/googletest)时)将其数据成员声明为protected
。但如果测试夹具类是在使用它的.cc
文件之外定义的(例如在.h
文件中),则应将数据成员声明为private
。
声明顺序
将相似的声明分组放置,public
部分应放在前面。
类定义通常应以 public:
段开头,其次是 protected:
,最后是 private:
。如果某段为空,可以省略。
在每个段内部,建议将相似类型的声明分组,并遵循以下顺序:
- 类型和类型别名(
typedef
、using
、enum
、嵌套结构体和类,以及friend
类型) - (仅适用于结构体,可选)非
static
数据成员 - 静态常量
- 工厂函数
- 构造函数和赋值运算符
- 析构函数
- 所有其他函数(
static
和非static
成员函数,以及friend
函数) - 所有其他数据成员(静态和非静态)
不要在类定义中内联定义大型方法。通常,只有简单、性能关键且非常简短的方法可以内联定义。更多细节请参阅在头文件中定义函数。
函数
输入与输出
C++函数的输出通常通过返回值提供,有时也通过输出参数(或输入/输出参数)实现。
优先使用返回值而非输出参数:返回值可提升代码可读性,且通常能提供相同或更好的性能。详见 TotW #176。
返回值传递方式:优先按值返回,其次按引用返回。除非可能返回空值,否则避免返回原始指针。
参数分类:函数参数可分为输入参数、输出参数或兼具二者功能。非可选的输入参数通常应为值类型或const
引用,而非可选的输出参数和输入/输出参数通常应为引用(且不可为空)。通常使用std::optional
表示可选的值类型输入参数,当非可选形式本应使用引用时改用const
指针。使用非const
指针表示可选的输出参数和可选的输入/输出参数。
生命周期注意事项:避免定义要求引用参数在函数调用后继续存活的函数。某些情况下引用参数可能绑定到临时对象,导致生命周期错误。应通过消除生命周期要求(例如复制参数)或改用指针传递并明确文档化生命周期和非空要求来解决此问题。详见 TotW 116。
参数顺序规则:
- 所有纯输入参数应置于输出参数之前
- 不要仅因新增参数就将其置于函数末尾,新增的纯输入参数应放在输出参数前
- 此规则非绝对——兼具输入输出功能的参数可能打破此顺序
- 与相关函数保持一致性时可能需要调整规则
- 可变参数函数可能需要特殊参数排序
(注:保留所有代码术语如const
、std::optional
等原样,链接和文献引用格式完整保留)
编写短小的函数
推荐使用小巧而专注的函数。
我们理解长函数有时是合理的,因此并未对函数长度设置硬性限制。但如果一个函数超过约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);
};
通过允许同名函数接受不同参数,重载可以使代码更加直观。这对于模板化代码可能是必要的,对于访问者模式也很方便。
基于 const
或引用限定符的重载可以提高工具代码的可用性、效率,或两者兼具。更多信息请参阅 TotW #148。
如果函数仅通过参数类型重载,读者可能需要理解 C++ 复杂的匹配规则才能明白发生了什么。此外,如果派生类仅覆盖函数的某些变体,许多人会对继承的语义感到困惑。
当不同变体之间没有语义差异时,可以对函数进行重载。这些重载可能在类型、限定符或参数数量上有所不同。然而,调用处的读者不需要知道选择了重载集中的哪个成员,只需知道调用了集中的某个成员即可。
为了体现这种统一设计,建议使用一个全面的"总括"注释来记录整个重载集,并将其放在第一个声明之前。
如果读者可能难以将总括注释与特定重载联系起来,可以为特定重载添加注释。
默认参数
当默认值能确保始终相同时,非虚函数允许使用默认参数。需遵循与函数重载相同的限制条件——如果默认参数带来的可读性提升无法抵消下述缺点,则应优先使用重载函数。
常见场景是函数通常使用默认值,但偶尔需要覆盖默认值。默认参数提供了一种简便的实现方式,无需为少数例外情况定义多个函数。与函数重载相比,默认参数的语法更简洁,减少了样板代码,同时更清晰地区分了"必需"和"可选"参数。
默认参数是实现重载函数语义的另一种方式,因此所有反对函数重载的理由同样适用。
虚函数调用中的参数默认值由目标对象的静态类型决定,无法保证该函数的所有重写都声明相同的默认值。
默认参数会在每次调用时重新求值,可能导致生成代码膨胀。阅读者也可能期望默认值在声明时固定,而非每次调用时变化。
当存在默认参数时,函数指针会令人困惑,因为函数签名常与调用签名不匹配。通过添加函数重载可避免这些问题。
虚函数禁止使用默认参数(因其无法正常工作),在指定默认值可能因求值时机不同而产生不同结果时也应避免使用。(例如不要写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);
versus
template <typename T, typename U>decltype(declval<T&>() + declval<U&>()) add(T t, U u);
尾置返回类型语法相对较新,在C++类语言(如C和Java)中没有类似用法,因此部分读者可能会感到陌生。
现有代码库中存在大量函数声明不会改用新语法,因此实际选择只有两种:仅使用旧语法或混合使用两者。统一采用单一版本更有利于保持代码风格的一致性。
在大多数情况下,建议继续使用传统的函数声明风格(即返回类型位于函数名前)。仅在以下场景使用尾置返回类型:语法强制要求时(如lambda表达式),或者将返回类型放在参数列表后能显著提升可读性。后一种情况应当非常罕见,主要出现在相当复杂的模板代码中——而这类代码在大多数情况下是不鼓励使用的。
Google 特有的魔法技巧
我们采用多种技巧和工具来增强 C++ 代码的健壮性,这些方法可能与其他地方常见的 C++ 使用方式有所不同。
所有权与智能指针
优先为动态分配的对象设置单一固定所有者。建议使用智能指针进行所有权转移。
"所有权"是一种用于管理动态分配内存(及其他资源)的簿记技术。动态分配对象的所有者是一个对象或函数,负责确保在不再需要时删除该对象。所有权有时可以共享,此时通常由最后一个所有者负责删除。即使所有权不共享,也可以在不同代码段之间转移。
"智能"指针是行为类似指针的类(例如通过重载*
和->
运算符)。某些智能指针类型可自动完成所有权簿记,确保满足这些职责。std::unique_ptr
是一种表示独占所有权的智能指针类型,当std::unique_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::unique_ptr
),必须使用std::move
。 - 使用右值引用符号的转发引用可以编写通用函数包装器,将其参数转发给其他函数,无论参数是否为临时对象和/或常量。这称为“完美转发”。
- 右值引用尚未被广泛理解。引用折叠和转发引用的特殊推导规则等概念较为晦涩。
- 右值引用常被误用。在函数调用后参数预期保持有效指定状态或未执行移动操作的场景中,使用右值引用会违反直觉。
除非符合以下情况,否则不要使用右值引用(或在方法上应用 &&
限定符):
- 可用于定义移动构造函数和移动赋值运算符(如可复制和可移动类型中所述)。
- 可用于定义逻辑上“消耗”
*this
的&&
限定方法,使其处于不可用或空状态。注意这仅适用于方法限定符(位于函数签名右括号之后);若要“消耗”普通函数参数,建议按值传递。 - 可与
std::forward
结合使用转发引用,以支持完美转发。 - 可用于定义重载对,例如一个接受
Foo&&
,另一个接受const Foo&
。通常首选方案是按值传递,但重载函数对有时能提供更好性能(例如函数有时不消耗输入)。切记:若为性能编写更复杂代码,需确保其确实有效。
友元
我们允许在合理范围内使用friend
类和函数。
友元通常应定义在同一文件中,这样读者无需查看其他文件就能了解类私有成员的使用情况。friend
的常见用法是让FooBuilder
类成为Foo
的友元,这样它就能正确构建Foo
的内部状态,而无需将这些状态暴露给外部。某些情况下,将单元测试类设为被测试类的友元也很有用。
友元扩展了类的封装边界,但不会破坏它。当您只想让另一个类访问某个成员时,使用友元比将该成员设为public
更合适。不过,大多数类应仅通过其公共成员与其他类交互。
异常处理规范
我们禁止使用 C++ 异常机制,原因如下:
支持使用异常的理由
- 简化错误处理:异常机制允许应用程序高层决定如何处理深层嵌套函数中的"不可能发生"错误,避免了错误码带来的晦涩和易错问题
- 语言一致性:多数现代语言都采用异常机制,在 C++ 中使用可使代码风格与 Python、Java 等语言保持统一
- 第三方库兼容:部分第三方 C++ 库依赖异常机制,禁用异常会增加集成难度
- 构造函数失败处理:异常是构造函数报告失败的唯一途径。虽然可通过工厂函数或
Init()
方法模拟,但这分别需要堆内存分配或引入"无效"状态 - 测试框架优势:异常机制在测试框架中非常实用
反对使用异常的理由
- 调用链维护成本:当向现有函数添加
throw
语句时,必须检查所有调用链。调用者要么实现基本异常安全保证,要么接受程序终止的后果。例如f()
调用g()
调用h()
时,若h()
抛出被f()
捕获的异常,g()
必须谨慎处理否则可能无法正确清理资源 - 控制流混乱:异常会导致程序流程难以通过代码静态分析判断,函数可能在预期外的位置返回,增加维护和调试难度。虽然可以通过使用规范降低影响,但这增加了开发者的认知负担
- 编码实践要求:要实现异常安全需要结合 RAII 和特殊编码规范,需要大量辅助机制。为确保代码可读性,还必须将对持久状态的修改隔离到"提交"阶段,这会带来额外的设计成本
- 性能影响:启用异常会增加二进制文件体积,可能轻微影响编译速度并增加内存压力
- 滥用风险:异常机制可能诱使开发者在不当场景抛出异常(如用户输入校验),或在不安全时进行恢复。要防范此类问题需要制定更冗长的规范
现状考量
表面上看,异常机制的优势(特别是对新项目)大于代价。但对于既有代码库,引入异常会影响所有依赖代码。若允许异常传播到新项目外,将难以与现有无异常代码集成。由于 Google 大多数现有 C++ 代码未做异常处理准备,集成异常代码的难度更高。
鉴于 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
能带来显著的性能提升,请与项目负责人讨论。
如果完全禁用异常(例如大多数 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 T&
)或常量指针(const T*
) - 对于按值传递的函数参数,
const
对调用者没有影响,因此不建议在函数声明中使用。参见TotW #109 - 除非方法会改变对象的逻辑状态(或允许用户修改该状态,例如返回非常量引用,但这很罕见),或者不能安全地并发调用,否则应将方法声明为
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
。
除非有特殊需求(如表示位模式而非数值,或需要明确的2^N模溢出),否则不应使用uint32_t
等无符号整数类型。特别要注意,不要用无符号类型来表示"数值永不为负"的概念,应改用断言来实现这个目的。
如果代码是返回大小的容器,请确保使用能容纳所有可能情况的类型。如有疑问,优先选择更大的类型而非更小的类型。
转换整数类型时需谨慎。整数转换和提升可能导致未定义行为,引发安全漏洞等问题。
关于无符号整数
无符号整数非常适合表示位域和模运算。由于历史原因,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
)。若必须导出,必须确保宏具有全局唯一名称——采用项目命名空间的大写形式作为前缀(例如PROJECTNAME_MACRO
)。
0 与 nullptr/NULL 的区别
对于指针,使用 nullptr
;对于字符,使用 '\0'
(而不是字面量 0
)。
在处理指针(地址值)时,应使用 nullptr
,因为它能提供类型安全性。
空字符应使用 '\0'
。使用正确的类型能使代码更具可读性。
sizeof
优先使用 sizeof(varname)
而非 sizeof(type)
。
当获取特定变量的大小时,应使用 sizeof(varname)
。若后续有人修改变量类型,sizeof(varname)
会自动适应更新。只有在处理与具体变量无关的代码时(例如管理外部或内部数据格式,且使用合适的 C++ 类型变量不方便时),才考虑使用 sizeof(type)
。
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); // 调用f<int>(0)
auto
变量声明
变量声明可用auto
关键字替代类型。编译器根据初始化表达式推导类型,规则与函数模板参数推导相同(只要不使用花括号替代圆括号):
auto a = 42; // a是int类型 auto& b = a; // b是int&类型 auto c = b; // c是int类型 auto d{42}; // d是int类型,而非std::initializer_list<int>
auto
可搭配const
限定符,也可作为指针或引用类型的一部分,且(C++17起)可作为非类型模板参数。此语法的罕见变体使用decltype(auto)
替代auto
,此时推导类型是对初始化器应用decltype
的结果。- 函数返回类型推导
auto
(及decltype(auto)
)也可替代函数返回类型。编译器根据函数体内的return
语句推导返回类型,规则与变量声明相同:
auto f() { return 0; } // f的返回类型是int
Lambda表达式的返回类型可通过省略返回类型(而非显式使用auto
)触发推导。需注意,函数的尾置返回类型语法虽在返回类型位置使用auto
,但不依赖类型推导,仅是显式返回类型的替代语法。 - 泛型lambda
Lambda表达式可用auto
替代部分或全部参数类型。这会使lambda的调用运算符成为函数模板(而非普通函数),每个auto
参数对应独立的模板参数:
// 按降序排序vec std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });
- Lambda初始化捕获
Lambda捕获可含显式初始化器,用于声明全新变量(而非仅捕获现有变量):
[x = 42, y = "foo"] { ... } // x是int类型,y是const char*类型
此语法不允许指定类型,而是按auto
变量规则推导。 - 类模板参数推导
参见下文。 - 结构化绑定
用auto
声明元组、结构体或数组时,可为单个元素指定名称(而非整个对象)。这些名称称为"结构化绑定",整个声明称为"结构化绑定声明"。此语法无法指定外围对象或单个绑定的类型:
auto [iter, success] = my_map.insert({key, value}); if (!success) { iter->second = value; }
auto
可搭配const
、&
和&&
限定符,但注意这些限定符实际应用于匿名元组/结构体/数组,而非单个绑定。绑定类型的判定规则较复杂,结果通常符合直觉,但绑定类型通常不会是引用(即使声明了引用,其行为通常仍类似引用)。
(上述总结省略了许多细节和注意事项,详见各链接。)
- 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_iteratorit = 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
语句且其他代码极少时使用返回类型推导(适用于函数和lambda表达式),否则读者可能无法一眼看出返回类型。此外,仅当函数或lambda的作用域非常狭窄时才使用该特性,因为具有推导返回类型的函数不会定义抽象边界:其实现就是接口。特别注意,头文件中的公共函数几乎永远不应使用推导返回类型。
参数类型推导
使用 lambda 表达式的 auto
参数类型时应谨慎,因为实际类型由调用该 lambda 的代码决定,而非 lambda 自身的定义。因此,除非满足以下情况之一,否则显式声明类型通常会更清晰:
- lambda 在定义处附近被显式调用(读者能轻松查看两者上下文);
- lambda 被传递到一个接口,该接口的调用参数非常明确(例如前文提到的
std::sort
场景)。
Lambda 初始化捕获
初始化捕获遵循更具体的样式规则,该规则在很大程度上取代了类型推导的通用规则。
结构化绑定
与其他类型推导形式不同,结构化绑定实际上能为读者提供额外信息——通过为较大对象的元素赋予有意义的名称。这意味着在某些情况下,即使使用auto
无法提升可读性,结构化绑定声明相比显式类型声明仍能带来净可读性提升。当对象是pair或tuple时(如前文insert
示例所示),结构化绑定尤为有益,因为这些类型本身缺乏有意义的字段名。但请注意,除非像insert
这样的现有API强制要求,否则通常不应使用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
能向代码阅读者提供更多信息,但它同样没有给出明显的提示表明信息已被省略。
除非模板维护者通过提供至少一个显式推导指南明确支持CTAD的使用(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];
});
变量捕获也可以包含显式初始化器,这适用于通过值捕获仅移动(move-only)变量的情况,或处理普通引用捕获或值捕获无法覆盖的其他场景。
std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {...
}
这种捕获方式(通常称为"初始化捕获"或"广义lambda捕获")实际上不需要从外围作用域"捕获"任何内容,甚至可以使用与外围作用域无关的名称;该语法是定义lambda对象成员的完全通用方式。
[foo = std::vector<int>({1, 2, 3})] () {...
}
带有初始化器的捕获类型推导规则与 auto
相同。
- 相比其他定义函数对象传递给STL算法的方式,Lambda表达式更加简洁,可显著提升代码可读性。
- 合理使用默认捕获能消除冗余,并突出与默认情况不同的重要例外。
- Lambda表达式、
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 的生命周期明显短于任何潜在捕获对象时,才使用默认引用捕获 (
[&]
)。 - 仅当需要为简短 lambda 绑定少量变量时使用默认值捕获 (
[=]
),此时捕获的变量集一目了然,且不会隐式捕获this
。(这意味着出现在非静态类成员函数中并引用其体内非静态类成员的 lambda,必须显式捕获this
或通过[&]
捕获。)尽量避免对冗长或复杂的 lambda 使用默认值捕获。 - 捕获仅应用于实际从外围作用域捕获变量。不要使用带初始化器的捕获来引入新名称,或实质上改变现有名称的含义。相反,应以常规方式声明新变量再捕获它,或避免使用 lambda 简写而显式定义函数对象。
- 关于参数和返回类型的指定指引,请参阅类型推导章节。
模板元编程
避免使用复杂的模板编程技术。
模板元编程是指利用C++模板实例化机制具有图灵完备性这一特性,在类型领域执行任意编译期计算的一系列技术。
模板元编程能够实现类型安全且高性能的极致灵活接口。诸如GoogleTest、std::tuple
、std::function
和Boost.Spirit等设施都离不开这项技术。
但模板元编程技术往往只有语言专家才能理解。使用复杂模板方式的代码通常难以阅读,调试和维护也极为困难。
模板元编程经常导致极其糟糕的编译期错误信息:即便接口设计简单,当用户操作失误时,复杂的实现细节仍会暴露无遗。
模板元编程会加大重构工具的难度,从而阻碍大规模重构。首先,模板代码会在多个上下文中展开,很难验证转换在所有上下文中都合理;其次,部分重构工具基于模板展开后的AST结构工作,很难自动追溯到需要重写的原始源代码结构。
虽然模板元编程有时能实现更简洁易用的接口,但也容易诱使开发者过度炫技。最合理的应用场景是少量底层组件,通过大量复用分摊额外的维护成本。
在使用模板元编程或其他复杂模板技术前请三思:考虑当您转至其他项目后,团队普通成员是否能充分理解代码进行维护;非C++程序员或代码库浏览者能否理解错误信息或追踪目标函数的执行流程。如果您正在使用递归模板实例化、类型列表、元函数、表达式模板,或依赖SFINAE、sizeof
技巧检测函数重载决议,那么很可能已经过度设计了。
若必须使用模板元编程,您需要投入大量精力来最小化和隔离复杂性。应尽可能将元编程隐藏为实现细节,保证用户可见头文件的可读性,并对精巧代码进行详尽注释。需仔细记录代码使用方式,并说明"生成"代码的形态。要特别关注用户出错时编译器产生的错误信息——这些信息是用户界面的一部分,必要时应该调整代码,确保错误信息从用户角度易于理解和操作。
概念与约束的使用准则
应谨慎使用概念。通常,概念和约束仅应用于那些在C++20之前会使用模板的场景。避免在头文件中引入新概念,除非这些头文件被标记为库的内部实现。不要定义编译器无法强制实施的概念。优先选择约束而非模板元编程,并避免使用template<*概念* T>
语法,改用requires(*概念<T>*)
语法。
concept
关键字是一种定义模板参数需求(如类型特征或接口规范)的新机制。requires
关键字则提供了对模板施加匿名约束并在编译时验证约束是否满足的能力。概念与约束常结合使用,但也可独立应用。
- 优势
- 概念能让编译器在涉及模板时生成更清晰的错误信息,减少困惑并显著提升开发体验。
- 概念可减少定义和使用编译时约束所需的样板代码,提升代码可读性。
- 约束能实现一些模板和SFINAE技术难以达成的功能。
- 风险
- 与模板类似,概念可能大幅增加代码复杂度,降低可理解性。
- 概念语法易造成混淆,因其在使用处看起来类似类类型。
- 概念(尤其在API边界)会增加代码耦合度、僵化性和固化风险。
- 概念可能重复函数体内的逻辑,导致代码冗余和维护成本上升。
- 概念作为独立命名实体可在多处使用,但其底层契约的真实来源可能模糊,导致声明需求与实际需求随时间推移产生偏差。
- 概念与约束会以新颖且非显而易见的方式影响重载决议。
- 与SFINAE类似,约束会加大大规模代码重构的难度。
实施规范
- 标准库预定义概念应优先于类型特征(例如:若C++20之前会用
std::is_integral_v
,则C++20代码应改用std::integral
)。 - 优先采用现代约束语法(通过
requires(*条件*)
),避免遗留模板元编程结构(如std::enable_if<*条件*>
)及template<*概念* 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++ 生态系统。此外,当前的构建系统、编译器及其他工具链对模块的支持尚不完善,关于编写和使用模块的最佳实践也需要进一步探索。
协程
仅允许通过项目负责人批准的库来使用 C++20 协程。
C++20 引入了协程:这类函数可以暂停执行并在之后恢复。它们在异步编程中特别便利,能显著优于传统的基于回调的框架。
与大多数其他编程语言(如 Kotlin、Rust、TypeScript 等)不同,C++ 并未提供具体的协程实现。相反,它要求用户自行实现可等待类型(通过承诺类型),该类型决定了协程参数类型、协程执行方式,并允许在协程执行的不同阶段运行用户自定义代码。
- 协程可用于实现针对特定任务(如异步编程)的安全高效库。
- 协程在语法上几乎与非协程函数相同,这使得它们的可读性远高于替代方案。
- 高度可定制性使得相比替代方案,能在协程中插入更详细的调试信息。
- 目前没有标准的协程承诺类型,每个用户自定义实现在某些方面都可能具有独特性。
- 由于返回类型、承诺类型中的各种可定制钩子以及编译器生成代码之间存在关键性交互,仅通过阅读用户代码极难推断协程语义。
- 协程的众多可定制特性会引入大量陷阱,尤其是悬垂引用和竞态条件问题。
总之,设计高质量且可互操作的协程库需要大量复杂工作、周密思考和完善的文档。
仅使用项目负责人批准在全项目范围内使用的协程库。切勿自行实现承诺类型或可等待类型。
Boost库使用规范
仅允许使用Boost库集合中经过批准的库。
Boost库集合是一个广受欢迎的、经过同行评审的免费开源C++库集合。Boost代码通常具有极高的质量,具备广泛的移植性,并填补了C++标准库中的许多重要空白,例如类型特征和更优的绑定器。
部分Boost库提倡的编码实践可能会影响代码可读性,例如元编程和其他高级模板技术,以及过度"函数式"的编程风格。为了确保所有可能阅读和维护代码的贡献者都能保持高水平的可读性,我们仅允许使用Boost功能的一个批准子集。目前允许使用的库包括:
- Call Traits 来自
boost/call_traits.hpp
- Compressed Pair 来自
boost/compressed_pair.hpp
- Boost图库(BGL) 来自
boost/graph
,但不包括序列化(adj_list_serialize.hpp
)以及并行/分布式算法和数据结构(boost/graph/parallel/*
和boost/graph/distributed/*
) - Property Map 来自
boost/property_map
,但不包括并行/分布式属性映射(boost/property_map/parallel/*
) - Iterator 来自
boost/iterator
- Polygon中涉及Voronoi图构造且不依赖Polygon其他部分的内容:
boost/polygon/voronoi_builder.hpp
、boost/polygon/voronoi_diagram.hpp
和boost/polygon/voronoi_geometry_type.hpp
- Bimap 来自
boost/bimap
- 统计分布和函数 来自
boost/math/distributions
- 特殊函数 来自
boost/math/special_functions
- 求根与最小化函数 来自
boost/math/tools
- Multi-index 来自
boost/multi_index
- Heap 来自
boost/heap
- Container中的扁平容器:
boost/container/flat_map
和boost/container/flat_set
- Intrusive 来自
boost/intrusive
boost/sort
库- Preprocessor 来自
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()
,以及"Elvis运算符"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 标签时,必须使用 [[fallthrough]];
属性进行标注。[[fallthrough]];
应放置在执行流程实际发生贯穿到下一个 case 标签的位置。常见例外情况是连续的 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()
。经验法则是:如果该缩写被维基百科收录,则基本可用。注意某些通用缩写是可接受的,如用i
表示循环索引,T
表示模板参数。
高频出现的名称与普通名称不同:少量"词汇级"名称被广泛复用,始终自带上下文。这类名称往往简短甚至缩写,其完整含义来自显式的长篇文档而非定义处的注释或名称本身。例如absl::Status
在开发指南中有专属页面说明其正确用法。虽然不常需要定义新词汇级名称,但若需定义,应通过额外设计评审确保所选名称在广泛使用时仍能良好工作。
class MyClass {public:int CountFooErrors(const std::vector<Foo>& foos) {int n = 0; // Clear meaning given limited scope and contextfor (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 Namereturn 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 contextfor (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);...
}
函数命名
通常,函数遵循PascalCase命名规范:以大写字母开头,每个新单词首字母大写。
AddTableEntry()
DeleteUrl()
OpenFileOrDie()
同样的命名规则适用于作为API一部分公开且设计成类似函数形式的类和命名空间作用域常量,因为它们是对象而非函数这一事实属于无关紧要的实现细节。
访问器和修改器(get和set函数)可以采用snake_case
风格的变量命名方式。这些方法通常对应实际的成员变量,但并非强制要求。例如: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
文件中声明的函数也不例外。函数注释应以隐含的主语该函数开头,并使用动词短语,例如"Opens the file"而非"Open the file"。通常,这些注释不描述函数如何完成任务,具体实现细节应留给函数定义中的注释说明。
函数声明注释中需涵盖的内容类型:
- 输入和输出是什么。如果用
反引号
标注函数参数名,代码索引工具可能更好地呈现文档。 - 对于类成员函数:对象是否会在方法调用结束后仍保留引用或指针参数。这在构造函数指针/引用参数中很常见。
- 对于每个指针参数,是否允许为null以及为null时的处理方式。
- 对于每个输出或输入/输出参数,参数原有状态会发生什么变化(例如状态是被追加还是被覆盖)。
- 函数使用方式是否存在性能影响。
示例如下:
// 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);
versus:
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);
}
标点、拼写与语法规范
注重标点符号、拼写和语法的正确性——阅读书写规范的注释远比糟糕的表述更轻松高效。
注释应像叙述性文字一样具备可读性,注意规范的大小写和标点使用。多数情况下,完整的句子比零碎片段更易理解。行尾简短注释有时可以稍显随意,但需保持风格一致性。
尽管代码审查时被指出该用分号却误用逗号会令人沮丧,但保持源码的高度清晰与可读性至关重要。规范的标点、拼写和语法正是实现这一目标的基础。
TODO 注释
对于临时性代码、短期解决方案或勉强可用但不够完美的代码,请使用 TODO
注释。
所有 TODO
注释必须包含全大写的 TODO
字符串,后接对应的缺陷 ID、责任人姓名、邮箱地址或其他能明确关联问题上下文的标识信息(例如关联的问题追踪编号)。
// 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字符限制:
- 注释行:若拆分会影响可读性、复制粘贴或自动链接功能(如包含超长示例命令或URL)
- 字符串字面量:符合以下任一条件时:
- 包含URI等关键语义内容
- 内嵌特定语言结构
- 多行文本中换行符具有实际意义(如帮助信息)
注意:测试代码除外,这类字面量应置于文件顶部的命名空间作用域。若Clang-Format等工具无法识别不可拆分内容,可临时禁用格式化功能
- include语句
- 头文件保护宏
- using声明语句
(需权衡字面量的可用性/可搜索性与周边代码可读性之间的平衡)
非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交互的代码,因后者广泛使用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 indentType 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 表达式
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 indentargument3, 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)// CommentDoSomething();// 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 indentFoo(); // 4 space indentbreak;}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.
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
int *x; // Disallowed - & or * must be left of the space
char * c; // Bad - spaces on both sides of *
const std::string & str; // Bad - spaces on both sides of &
布尔表达式
当布尔表达式长度超过标准行宽时,需要保持换行方式的一致性。
在这个示例中,逻辑与运算符始终位于行末:
if (this_one_thing > this_other_thing &&a_third_thing == a_fourth_thing &&yet_another && last_one) {...
}
请注意,在此示例中代码换行时,两个 &&
逻辑与运算符都位于行尾。
这种情况在 Google 代码中更为常见,不过将所有运算符放在行首的换行方式也是允许的。
可以酌情添加额外的括号,因为合理使用时它们能显著提升代码可读性,但需注意避免过度使用。另外请注意,应当始终使用标点形式的运算符(如 &&
和 ~
),而非单词形式的运算符(如 and
和 compl
)。
返回值
不要毫无必要地用括号包裹 return
表达式。
仅在 x = expr;
中会使用括号的情况下,才在 return 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 lineif (lopsided_score) {
#if DISASTER_PENDING // Correct -- Starts at beginning of lineDropEverything();
# if NOTIFY // OK but not required -- Spaces after #NotifyClient();
# endif
#endifBackToNormal();}
// Bad - indented directivesif (lopsided_score) {#if DISASTER_PENDING // Wrong! The "#if" should be at beginning of lineDropEverything();#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 indentsome_other_var_(var + 1) { // lined upDoSomething();
}// 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;
垂直留白
应谨慎使用垂直留白;不必要的空行会干扰代码整体结构的辨识。仅在有助于读者理解结构时添加空行。
当缩进已能清晰划分代码块(如代码块开头或结尾处)时,无需额外添加空行。但可用空行将代码分隔为逻辑紧密的段落,类似于文本中的分段。在语句或声明内部,通常仅因以下情况换行:超出行长度限制,或需要为局部内容添加注释。
规则的例外情况
上述编码规范是强制性的。然而,正如所有优秀的规则一样,这些规范有时也存在例外情况,我们将在本节进行讨论。
现有不符合规范的代码
在处理不符合本风格指南的代码时,您可以偏离这些规则。
如果您正在修改的代码是根据不同于本指南提出的规范编写的,为了与该代码中的局部约定保持一致,您可能需要偏离这些规则。如果您不确定如何操作,请咨询原始作者或当前负责该代码的人员。请记住,一致性也包括局部一致性。
Windows 代码规范
Windows 程序员发展出了一套独特的编码惯例,主要源自 Windows 头文件和其他微软代码的约定。为了让所有人都能轻松理解您的代码,我们为所有平台的 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++ 附带的版本)广泛使用了异常。使用 ATL 时应定义
_ATL_NO_EXCEPTIONS
来禁用异常。建议检查能否在 STL 中禁用异常,若不能,则允许开启编译器异常选项(注意:这仅用于通过 STL 编译,您仍不应自行编写异常处理代码)。 - 预编译头文件:常规做法是在每个源文件顶部包含
StdAfx.h
或precompile.h
等头文件。为提高代码可移植性,请避免显式包含该文件(precompile.cc
除外),改用/FI
编译器选项自动包含。 - 资源头文件:通常命名为
resource.h
且仅包含宏定义的资源头文件,可不遵循本风格指南。
2025-08-16(六)