谷歌 C++ 风格指南最新版(含C++17和C++20)
# 谷歌 C++ 风格指南 最新版(含C++17和C++20)
# 翻译说明
本文档翻译自最新《Google C++ Style Guide (opens new window)》,作者张小方,更新于2025年3月7日,内容涵盖C++17和C++20.
限于作者水平有限,翻译中有不妥的地方,可以加小方微信 cppxiaofang
反馈和交流。
本文首发于【CppGuide】公众号,未经授权,不得转载。
英文链接:https://google.github.io/styleguide/cppguide.html (opens new window)
小方微信公众号【CppGuide】:
# 目录
C++ 版本 | |
---|---|
头文件 | ・自包含头文件(Self - contained Headers) ・#define保护宏(The #define Guard) ・按需包含(Include What You Use) ・前置声明(Forward Declarations) ・内联函数(Inline Functions) ・包含头文件的名称和顺序(Names and Order of Includes) |
作用域 | ・命名空间(Namespaces) ・内部链接(Internal Linkage) ・非成员函数、静态成员函数和全局函数(Nonmember, Static Member, and Global Functions) ・局部变量(Local Variables) ・静态变量和全局变量(Static and Global Variables) ・thread_local变量(thread_local Variables) |
类 | ・在构造函数中执行操作(Doing Work in Constructors) ・隐式转换(Implicit Conversions) ・可复制与可移动类型(Copyable and Movable Types) ・结构体与类(Structs vs. Classes) ・结构体与对组和元组(Structs vs. Pairs and Tuples) ・继承(Inheritance) ・运算符重载(Operator Overloading) ・访问控制(Access Control) ・声明顺序(Declaration Order) |
函数 | ・输入和输出(Inputs and Outputs) ・编写简短函数(Write Short Functions) ・函数重载(Function Overloading) ・默认参数(Default Arguments) ・尾置返回类型语法(Trailing Return Type Syntax) |
google特定技巧 | ・所有权和智能指针(Ownership and Smart Pointers) ・cpplint |
其他 C++ 特性 | ・右值引用(Rvalue References) ・友元(Friends) ・异常(Exceptions) ・noexcept 说明符 ・运行时类型信息(Run - Time Type Information,RTTI) ・强制类型转换(Casting) ・流(Streams) ・前置递增和前置递减(Preincrement and Predecrement) ・const 的使用(Use of const) ・constexpr、constinit 和 consteval 的使用(Use of constexpr, constinit, and consteval) ・整数类型(Integer Types) ・浮点类型(Floating - Point Types) ・架构可移植性(Architecture Portability) ・预处理器宏(Preprocessor Macros) ・0 与 nullptr/NULL ・sizeof 运算符 ・类型推导(含 auto 用法)(Type Deduction (including auto)) ・类模板参数推导(Class Template Argument Deduction) ・指定初始化器(Designated Initializers) ・lambda 表达式(Lambda Expressions) ・模板元编程(Template Metaprogramming) ・概念与约束(Concepts and Constraints) ・C++20 模块(C++20 modules) ・协程(Coroutines) ・Boost 库 ・禁止使用的标准库特性(Disallowed standard library features) ・非标准扩展(Nonstandard Extensions) ・别名(Aliases) ・switch 语句(Switch Statements) |
包容性语言 | |
命名规范 | ・通用命名规则(General Naming Rules) ・文件命名(File Names) ・类型命名(Type Names) ・概念命名(Concept Names) ・变量命名(Variable Names) ・常量命名(Constant Names) ・函数命名(Function Names) ・命名空间命名(Namespace Names) ・枚举命名(Enumerator Names) ・宏命名(Macro Names) ・命名规则的例外情况(Exceptions to Naming Rules) |
注释规范 | ・注释风格(Comment Style) ・文件注释(File Comments) ・结构体和类注释(Struct and Class Comments) ・函数注释(Function Comments) ・变量注释(Variable Comments) ・实现注释(Implementation Comments) ・标点、拼写和语法(Punctuation, Spelling, and Grammar) ・TODO注释(TODO Comments) |
格式规范 | ・行宽限制(Line Length) ・非 ASCII 字符(Non - ASCII Characters) ・空格与制表符(Spaces vs. Tabs) ・函数声明和定义(Function Declarations and Definitions) ・lambda 表达式(Lambda Expressions) ・浮点型字面量(Floating - point Literals) ・函数调用(Function Calls) ・大括号初始化列表格式(Braced Initializer List Format) ・循环和分支语句(Looping and branching statements) ・指针和引用表达式(Pointer and Reference Expressions) ・布尔表达式(Boolean Expressions) ・返回值(Return Values) ・变量和数组初始化(Variable and Array Initialization) ・预处理器指令(Preprocessor Directives) ・类格式(Class Format) ・构造函数初始化列表(Constructor Initializer Lists) ・命名空间格式(Namespace Formatting) ・水平空白(Horizontal Whitespace) ・垂直空白(Vertical Whitespace) |
规则例外情况 | ・现有不符合规范的代码(Existing Non - conformant Code) ・Windows 代码(Windows Code) |
# 背景(Background)
C++是Google许多开源项目使用的主要开发语言。正如每个C++程序员所知,这门语言功能强大但复杂度高,这种复杂性可能导致代码更容易出错,也更难阅读和维护。
本指南的目标是通过详细描述C++代码的"该做"与"不该做"来管理这种复杂性。这些规则既保持代码库(codebase)的可管理性,又允许开发者高效使用C++语言特性。
风格(Style)(或称可读性(readability))是我们对C++代码约定的统称。用"风格"这个词其实不太准确,因为这些约定远不止源代码格式化(source file formatting)这么简单。
Google开发的大多数开源项目都遵循本指南的要求。
请注意,本指南不是C++教程:我们假设读者已经熟悉这门语言。
# 风格指南的目标(Goals of the Style Guide)
为什么要有这份文档?
我们认为本指南应服务于以下几个核心目标——这些根本性的"为什么"构成了所有具体规则的基础。通过明确这些理念,我们希望帮助开发者理解规则制定的原因。如果您能理解每条规则背后的目标,就能更清楚地判断何时可以破例(有些规则允许例外),以及需要怎样的论据才能修改规则。
当前我们理解的本指南目标如下:
风格规则必须物有所值
风格规则的收益必须足够大,才能让所有工程师记住它。收益是相对于没有该规则时的代码库而言的,因此即使某个规则针对的危害行为实际发生概率很低,只要危害足够大,该规则仍有价值。这个原则主要解释了我们"没有制定的规则",例如虽然goto违反许多原则,但由于已极其罕见,本指南不再讨论它。
为读者优化,而非作者
我们的代码库(以及提交到其中的大多数组件)预计会长期存在。因此,阅读代码的时间将远超过编写时间。我们明确选择优化代码阅读、维护和调试的体验,而非编写的便利性。"给读者留下线索"是这个原则的经典体现:当代码出现反直觉操作时(比如指针所有权(pointer ownership)转移),在调用点用文本提示(如std::unique_ptr)能明确展示所有权转移。
与现有代码保持一致性(Be Consistent)
全代码库使用统一风格让我们能专注于更重要的问题。一致性还能实现自动化:格式化代码或调整#include的工具只有在代码符合工具预期时才能正常工作。许多"保持一致性"的规则本质是"选一个方案然后停止争论"——灵活性的潜在价值抵不上争论的成本。但一致性也有边界:当没有明确技术优劣或长期方向时,它才是好的决策依据。一致性在局部(单个文件或紧密相关的接口集)更重要。注意不能以一致性为借口拒绝考虑新风格的优势,或忽视代码库随时间向新风格演进的自然趋势。
必要时与更广的C++社区保持一致
与其他组织保持C++使用一致性,其价值与代码库内部一致性同理。如果C++标准(C++ standard)已解决问题,或某个惯用法(idiom)已被广泛接受,就应优先使用。但标准特性有时存在缺陷,或不符合我们的需求,此时限制或禁用它们是合理的(详见后文)。有时我们也会优先使用自研或第三方库而非标准库,无论是出于优越性考量,还是迁移成本过高。
避免意外或危险结构
C++有些特性比表面看起来更"惊喜"或危险。部分风格限制就是为了避免这些陷阱。这类规则的破例门槛很高,因为破例往往会直接危及程序正确性(program correctness)。
避免让普通C++开发者困惑的构造
C++有些特性因其复杂性而不适合普遍使用。但在广泛使用的代码中,使用复杂语言结构可能更合理——因为复杂实现的好处会被广泛复用,而理解成本无需在新代码中重复支付。如有疑问,可向项目负责人申请规则豁免。这对我们的代码库尤为重要,因为代码所有权和团队成员会随时间变化:即使当前所有开发者都理解某段代码,也不能保证几年后依然如此。
注意代码规模
面对亿级代码量和数千工程师的规模,个别工程师的错误或简化可能让众人买单。例如避免污染全局命名空间(global namespace)至关重要:在十亿行代码中,全局命名冲突(name collisions)将难以处理。
必要时为优化让步
性能优化有时是必要且合理的,即使与其他原则冲突。
本文档旨在提供最大限度的指导而非过度限制。请始终运用常识和品味——这里特指整个Google C++社区的既定惯例,而非个人偏好。对聪明但冷僻的结构保持警惕:未被禁止≠被允许。请善用判断力,如有疑问,请随时咨询项目负责人。
# C++版本(C++ Version)
当前代码应以C++20为目标,即不应使用C++23特性。本指南的目标C++版本会(积极地)随时间推进。
不要使用非标准扩展(non-standard extensions)。
在使用C++17和C++20特性前,请考虑代码向其他环境的可移植性(portability)。
# 头文件(Header Files)
通常每个 .cc 文件都应该有一个对应的 .h 文件。常见的例外情况包括单元测试和仅包含 main() 函数的小型 .cc 文件。
正确使用头文件能显著提升代码可读性、减小规模并优化性能。以下规则将帮助你避开使用头文件时的各种"坑"。
# 自包含头文件(Self-contained Headers)
头文件应当自包含(能独立编译)并以 .h 结尾。非头文件但需要被包含的文件应以 .inc 结尾,并谨慎使用。
所有头文件必须自包含。使用者和重构工具不应为了包含头文件而遵守特殊条件。具体来说,头文件应当包含保护宏(header guards)并引入其依赖的所有其他头文件。
当头文件声明了内联函数或模板(客户端会实例化的内容)时,这些定义必须直接或通过包含其他文件的方式存在于头文件中。不要将这些定义移到单独引入的 -inl.h 文件中——这种做法过去常见,但现在已被禁止。若某个模板的所有实例化都发生在单个 .cc 文件中(无论是显式实例化还是定义仅对该 .cc 文件可见),模板定义可保留在该文件中。
极少数情况下,设计为被包含的文件可能不自包含。这类文件通常需要被包含在特殊位置(比如其他文件中间),可能不使用保护宏,也不包含依赖项。此类文件应使用 .inc 扩展名,并尽量优先使用自包含头文件。
# #define保护宏(The #define Guard)
所有头文件都应使用 #define 保护宏防止重复包含。保护宏命名格式应为:<项目名>_<路径>_<文件名>_H_。
为保证唯一性,应基于文件在项目源码树中的完整路径。例如,项目 foo 中 foo/src/bar/baz.h 文件的保护宏应为:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif// FOO_BAR_BAZ_H_
2
3
4
# 按需包含(Include What You Use)
若源文件或头文件引用了其他地方的符号,应直接引入提供该符号声明或定义的头文件。不要为了其他原因包含头文件。
不要依赖"搭便车式包含"(transitive inclusions)。这允许他人从自己的头文件中移除不再需要的 #include 语句而不会破坏客户端代码。该规则同样适用于相关头文件——若 foo.cc 使用了 bar.h 中的符号,即使 foo.h 已经包含 bar.h,foo.cc 仍需显式包含 bar.h。
# 前置声明(Forward Declarations)
尽量不用,直接引入所需头文件。
定义: "前置声明"(forward declaration)是不带完整定义的符号声明。
优点: · 节省编译时间(减少 #include 带来的文件处理) · 减少不必要的重新编译(避免因无关头文件改动触发重编译)
缺点: · 隐藏依赖关系,可能导致头文件变更时漏掉必要重编译 · 妨碍自动化工具发现符号定义的模块 · 可能被库的后续变更破坏(比如参数类型扩展、新增模板参数等) · 对 std:: 命名空间符号做前置声明会导致未定义行为 · 替换 #include 为前置声明可能悄咪咪改变代码语义(比如重载函数解析) · 对多个符号做前置声明可能比直接 #include 更啰嗦 · 为支持前置声明而调整代码结构(比如用指针成员代替对象成员)可能降低性能并增加复杂度
决策: 尽量避免对另一项目中定义的实体使用前置声明。
# 内联函数(Inline Functions)
只在函数短小(比如 10 行以内)时使用内联。
定义: 通过声明方式让编译器展开函数而非常规调用机制。
优点: · 对短小函数内联可生成更高效的目标代码(适合存取函数等性能关键短函数)
缺点: · 滥用内联反而会降低程序速度。根据函数大小,内联可能增减代码体积:短存取函数通常减小体积,大函数则会显著膨胀。现代处理器上,小代码通常因更好利用指令缓存而更快。
决策: · 超过 10 行的函数不要内联(注意析构函数常因隐式调用成员/基类析构而显得更长) · 含循环或 switch 的函数通常不值得内联(除非这些结构在常见情况下不执行) · 虚函数和递归函数通常不会被内联(虚函数内联主要用于在类内定义方便或文档化行为)
# 包含头文件的名称和顺序(Names and Order of Includes)
包含顺序: 相关头文件 > C 系统头文件 > C++标准库头文件 > 其他库头文件 > 本项目头文件。
项目头文件应使用基于源码目录的路径,不要用 UNIX 目录别名 .(当前目录)或 ..(上级目录)。例如:
#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 文件
每组非空内容间用空行分隔。这种顺序确保相关头文件缺失依赖时,构建错误会首先出现在该文件的维护者面前。
注意: · C 头文件(如 stddef.h)与 C++ 版本(cstddef)可互换,但建议与现有代码风格一致 · 每个分组内按字母序排列(旧代码可能不符合,方便时可调整)
示例: google-awesome-project/src/foo/internal/fooserver.cc
的包含可能如下:
#include "foo/public/fooserver.h"
#include <sys/types.h>
#include <unistd.h>
#include <hash_map>
#include <vector>
#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"
2
3
4
5
6
7
8
9
10
11
例外情况(Exception)
系统特定代码可能需要条件包含。这类代码可将条件包含放在其他包含之后,但要保持其精简和局部化。
示例:
#include "foo/public/fooserver.h"
#include "base/port.h"// For LANG_CXX11.
#ifdef LANG_CXX11
#include <initializer_list>
#endif// LANG_CXX11
2
3
4
5
6
# 作用域(Scoping)
# 命名空间(Namespaces)
除特殊情况外,所有代码都应置于命名空间中。命名空间名称应基于项目名及其路径(如project/path
)保持唯一。禁止使用 using 指令(如using namespace foo
),禁止使用内联命名空间(inline namespaces)。关于无名命名空间(unnamed namespaces),请参考"内部链接(Internal Linkage)"章节。
定义: 命名空间将全局作用域划分为独立的命名区域,能有效防止全局作用域中的命名冲突。
优点:
命名空间既能避免大型程序中的命名冲突,又允许代码使用简洁的短名称。比如两个全局作用域中的Foo
类会产生冲突,但放在project1
和project2
命名空间后,project1::Foo
和project2::Foo
就成了互不干扰的独立符号。
内联命名空间(inline namespaces)会将其内容"泄露"到外层作用域。例如:
namespace outer
{
inline namespace inner
{
void foo();
}// namespace inner
}// namespace outer
2
3
4
5
6
7
此时outer::inner::foo()
和outer::foo()
等价。这主要用于跨版本的ABI兼容(ABI compatibility)。
缺点: 命名空间会增加理解代码逻辑的难度——毕竟要搞清楚"这个变量到底是哪家的孩子"可不简单。内联命名空间尤其调皮,因为它们会让名字"越狱"到外层作用域。深层嵌套的命名空间还会让代码像俄罗斯套娃,写全称时简直像在念咒语。
决策: 使用命名空间时请注意: • 遵守命名规范(Namespace Names) • 多行命名空间结尾要用注释标注,示例:
// 在.h文件中
namespace mynamespace
{
// 所有声明都在命名空间作用域内
// 注意这里不需要缩进
class MyClass;
void Foo();
}// namespace mynamespace
2
3
4
5
6
7
8
复杂.cc
文件可能包含额外内容(比如flags或using声明)。
特别条款:
● 想为协议缓冲区生成代码添加命名空间?请在.proto
文件中使用package指定(详见Protocol Buffer Packages)。
● 禁止在std
命名空间声明任何内容(包括标准库类的前向声明),这在C++中属于"作死行为"——会导致未定义行为。想用标准库?请乖乖include头文件。
● 禁止在头文件使用命名空间别名(Namespace aliases),除非在明确标记的内部命名空间:
// 在.cc文件中可以这样偷懒
namespace baz = ::foo::bar::baz;
// 在.h文件中要这样"藏私房钱"
namespace librarian
{
namespace internal// 内部API,不对外开放
{
namespace sidetable = ::pipeline_diagnostics::sidetable;
}// namespace internal
}// namespace librarian
2
3
4
5
6
7
8
9
10
11
● 禁止使用内联命名空间(inline namespaces),就像不要随便把大象装冰箱。 ● 用含"internal"的命名空间标记API中的"禁区":
// 非abs1代码请勿调戏这个内部名称
using ::abs1::container_internal::ImplementationDetail;
2
● 新代码推荐单行嵌套命名空间声明(但不是强制要求):
namespace foo::bar::baz {...}
# 内部链接(Internal Linkage)
当.cc
文件中的定义无需被外部引用时,请通过无名命名空间或static
声明赋予其内部链接属性。头文件(.h)中禁止使用这两种方式。
定义:
无名命名空间内的声明具有内部链接,static
声明的函数/变量同理。这相当于给代码上了"仅限本地使用"的封印——其他文件即使声明同名实体,也会被视为完全独立的个体。
决策:
鼓励在.cc
文件中为不需要导出的代码使用内部链接。头文件中禁止使用。无名命名空间的格式要与普通命名空间一致,结尾注释留空:
namespace
{
...
}// namespace
2
3
4
# 非成员函数/静态成员函数/全局函数
优先将非成员函数放在命名空间,慎用全局函数。不要用类来强行"组团"静态成员——静态方法应与类实例或静态数据成员密切相关。
优点: 非成员函数和静态成员函数在某些场景很有用。把非成员函数放在命名空间可以避免污染全局空间。
缺点: 有时候这些函数更适合作为新类的成员,特别是当它们需要访问外部资源或存在复杂依赖时。
决策: 如果函数不需要绑定类实例,可以设为静态成员或非成员函数。非成员函数应当不依赖外部变量,并且必须住在命名空间这个"集体宿舍"里。不要创建只包含静态成员的类——这就像给名字加统一前缀的强迫症行为,通常没必要。
如果非成员函数只在.cc
文件内使用,请用内部链接(无名命名空间或static
)限制它的活动范围。
# 局部变量(Local Variables)
把变量关进最小的作用域"牢房",并在声明时直接初始化。
C++允许在函数任意位置声明变量,但我们建议:在离首次使用最近的位置声明,并尽可能缩小作用域。这样读者能快速找到声明,看清类型和初始值。特别要注意——声明和初始化要"领证结婚",不要"分居":
int i;
i = f();// 差评——声明和初始化分居两地
int i = f();// 好评——持证上岗
2
3
为if
/while
/for
服务的变量应该直接在这些语句中声明,就像给它们发"临时工作证":
while (const char* p = strchr(str, '/')) str = p + 1;
但有个例外:如果变量是对象,每次进入作用域都会触发构造/析构:
// 低效写法:构造函数在循环里反复蹦迪
for (int i = 0; i < 1000000; ++i)
{
Foo f;// 每次循环都呼叫构造函数和析构函数
f.DoSomething(i);
}
2
3
4
5
6
这时应该让对象在循环外"提前上班":
Foo f;// 构造函数和析构函数只蹦迪一次
for (int i = 0; i < 1000000; ++i)
{
f.DoSomething(i);
}
2
3
4
5
# 静态与全局变量
禁止使用具有静态存储期(static storage duration)的对象,除非它们是可平凡析构的(trivially destructible)。通俗来说,这意味着析构函数不需要执行任何操作(即使考虑了成员和基类的析构函数)。更正式地说,类型不能有用户定义或虚析构函数,且所有基类和非静态成员都必须是可平凡析构的。函数内的静态局部变量(static function-local variables)允许动态初始化(dynamic initialization)。不鼓励对静态类成员变量或命名空间作用域的变量使用动态初始化,但在有限情况下允许;详见下文。
经验法则:如果一个全局变量的声明(单独考虑时)可以写成 constexpr
,那么它大概率满足要求。
定义:
每个对象都有存储期(storage duration),与其生命周期相关。具有静态存储期的对象从初始化点开始存活到程序结束。这类对象包括:命名空间作用域的变量("全局变量")、类的静态数据成员,或用 static
声明的函数局部变量。函数内的静态局部变量在控制流首次经过其声明时初始化;其他静态存储期对象在程序启动时初始化。所有静态存储期对象在程序退出时销毁(此时未加入(unjoined)的线程可能尚未终止)。
初始化可以是动态的(dynamic),即初始化期间执行了非平凡操作(例如调用分配内存的构造函数,或用当前进程 ID 初始化的变量)。另一种是静态初始化(static initialization)。二者并非完全对立:静态初始化始终作用于静态存储期对象(将对象初始化为常量或全零字节表示),而动态初始化在其之后进行(如果需要)。
优点:
全局和静态变量在以下场景非常有用:命名常量、翻译单元内部的辅助数据结构、命令行标志、日志、注册机制、后台基础设施等。
缺点:
使用动态初始化或具有非平凡析构函数的全局/静态变量会引入复杂性,容易导致难以捉摸的 bug。动态初始化在翻译单元之间没有顺序保证,析构也是如此(除了析构顺序与初始化相反)。当一个初始化过程引用了另一个静态存储期变量时,可能导致在对象生命周期开始前(或结束后)访问它。此外,若程序启动的线程在退出时未被加入(joined),这些线程可能在对象析构后尝试访问它们。
决策:
关于析构的决策
若析构函数是平凡的(trivial),其执行完全不涉及顺序问题(它们实际上不会"运行");否则可能面临访问已结束生命周期对象的风险。因此,只允许可平凡析构的静态存储期对象。基础类型(如指针和 int
)及其数组是可平凡析构的。注意:标记为 constexpr
的变量也是可平凡析构的。
引用(references)不是对象,因此不受析构约束限制,但仍需遵守动态初始化的限制。例如,函数内的静态引用 static T& t = *new T;
是允许的。
关于初始化的决策
初始化更复杂,因为不仅要考虑类构造函数的执行,还要考虑初始化器的求值:
int n = 5;// 没问题
int m = f();// ?(取决于 f)
Foo x;// ?(取决于 Foo::Foo)
Bar y = g();// ?(取决于 g 和 Bar::Bar)
2
3
4
除第一条语句外,其他都可能面临不确定的初始化顺序。
我们需要的是 C++ 标准中称为常量初始化(constant initialization)的概念:初始化表达式必须是常量表达式(constant expression),若通过构造函数初始化,则构造函数必须标记为 constexpr
:
struct Foo { constexpr Foo(int) {} };
int n = 5;// 符合常量初始化
constexpr Foo x(3); // 符合常量初始化
2
3
常量初始化始终允许。静态存储期变量的常量初始化应标记为 constexpr
或 constinit
。未标记的非局部静态存储期变量应假定为动态初始化,并需严格审查。
以下初始化存在问题:
time_t m = time(nullptr);// 初始化表达式不是常量
Foo y(f());// 同上
Bar b; // 所选构造函数 Bar::Bar() 非 constexpr
2
3
不鼓励对非局部变量进行动态初始化,通常禁止。但如果程序不依赖该初始化与其他初始化的顺序关系,则允许。例如:
int p = getpid();// 允许,只要没有其他静态变量在初始化时使用 p
静态局部变量的动态初始化是允许的(且常见)。
常见模式
· 全局字符串:若需要命名的全局/静态字符串常量,可考虑使用 constexpr
的 string_view
、字符数组或指向字符串字面量的指针。字符串字面量本身已有静态存储期,通常足够。见 TotW#140。
· 动态容器(map、set 等):若需要静态的固定集合(如搜索用的集合或查找表),不能使用标准库的动态容器作为静态变量(因其有非平凡析构函数)。可考虑使用简单数组(例如 int
到 int
的映射可用 int
的二维数组,或 pair<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
变量按初始化逆序销毁。若任何 thread_local
变量的析构函数代码引用了同一线程上已销毁的 thread_local
变量,会导致极难诊断的释放后使用(use-after-free)问题。
优点:
· 线程局部数据天然避免竞态(race-free),适合并发编程。
· 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 = Initializer();
return result;
}
2
3
4
注意:线程退出时,thread_local
变量会被销毁。若任何此类变量的析构函数引用了其他(可能已销毁的)thread_local
变量,会导致极难诊断的释放后使用问题。优先使用平凡类型,或析构时不运行用户代码的类型,以最小化风险。
应优先选择 thread_local
而非其他线程局部数据机制。
# 类(Classes)
类是C++代码的基本单元。我们自然会广泛使用它们。本节列出了编写类时应遵循的主要规则。
# 在构造函数中执行操作(Doing Work in Constructors)
避免在构造函数中调用虚方法,并尽量避免可能失败的初始化操作(若无法有效报告错误)。
定义(Definition): 在构造函数体内执行任意初始化操作是允许的。
优点(Pros):
· 无需担心类是否已初始化。
· 通过构造函数完全初始化的对象可以是 const
类型,且更容易与标准容器或算法配合使用。
缺点(Cons):
• 构造函数中调用虚函数时,这些调用不会分派到子类实现。即使当前类未被继承,未来的修改也可能意外引入此问题。
• 构造函数难以优雅地报告错误,只能终止程序(并非总是合适)或使用异常(但异常被禁止)。
• 若初始化失败,对象将处于异常状态,可能需要通过 bool IsValid()
等状态检查机制(容易被遗忘)来处理。
• 无法获取构造函数的地址,因此构造函数中的工作无法轻松移交(例如给其他线程)。
决策(Decision):
构造函数绝不应调用虚函数。若适合代码场景,终止程序可能是合理的错误处理方式。否则,参考 TotW#42 中描述的工厂函数或 Init()
方法。对于没有其他状态影响公共方法调用的对象,应避免使用 Init()
方法(此类半构造对象难以正确操作)。
# 隐式转换(Implicit Conversions)
不要定义隐式转换。对转换运算符和单参数构造函数使用 explicit
关键字。
定义(Definition):
隐式转换允许源类型(source type)的对象用于需要目标类型(destination type)的场合(例如将 int
传递给接受 double
的函数)。
用户可通过在源类型或目标类型的类定义中添加成员来自定义隐式转换:
• 源类型的隐式转换通过以目标类型命名的转换运算符(如 operator bool()
)定义。
• 目标类型的隐式转换通过接受源类型作为唯一参数(或无默认值的唯一参数)的构造函数定义。
explicit
关键字可应用于构造函数或转换运算符,确保其仅在目标类型显式声明时使用(例如通过强制转换)。这也适用于列表初始化语法:
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);
2
3
4
5
此代码技术上不属于隐式转换,但语言在 explicit
的语义下仍视其为隐式转换。
优点(Pros):
· 隐式转换可简化代码(当类型显而易见时无需显式命名)。
· 隐式转换可替代重载(例如用 string_view
参数替代 std::string
和 const char*
的重载)。
· 列表初始化语法简洁且富有表达性。
缺点(Cons):
• 隐式转换可能掩盖类型不匹配的错误(用户未意识到转换发生)。
· 隐式转换可能降低代码可读性(尤其在重载存在时)。
• 单参数构造函数可能意外成为隐式转换途径。
· 若未标记 explicit
,无法判断单参数构造函数是否意图定义隐式转换。
· 隐式转换可能导致调用点歧义(例如双向隐式转换或同时存在隐式构造函数和转换运算符)。
• 列表初始化若目标类型隐式,可能引发相同问题(尤其是单元素列表)。
决策(Decision):
类型转换运算符和可被单参数调用的构造函数必须在类定义中标记为 explicit
。例外:拷贝和移动构造函数不应为 explicit
(因不执行类型转换)。
若类型设计为可互换(例如同一值的不同表示),可申请豁免此规则。
无法通过单参数调用的构造函数可省略 explicit
。接受 std::initializer_list
参数的构造函数也应省略 explicit
,以支持拷贝初始化(如 MyType m = {1, 2};
)。
# 可复制与可移动类型(Copyable and Movable Types)
类的公共API必须明确说明其是否可复制(copyable)、仅可移动(move-only),或不可复制/移动。若操作明确且有意义,则支持这些操作。
定义(Definition):
• 可移动类型(movable type):可从临时对象初始化和赋值。
• 可复制类型(copyable type):可从同类型对象初始化和赋值(因此也可移动),且源对象值不变。
• 示例:std::unique_ptr<int>
可移动但不可复制;int
和 std::string
可移动且可复制。
• 用户定义类型的复制行为由拷贝构造函数和拷贝赋值运算符定义;移动行为由移动构造函数和移动赋值运算符定义(若不存在则由拷贝操作定义)。
编译器可能在传值等场景隐式调用拷贝/移动构造函数。
优点(Pros):
· 值传递/返回使API更简单、安全、通用,无需担忧所有权或生命周期问题。
· 拷贝/移动操作可由编译器隐式生成(通过 = default
),确保所有成员被复制且更高效。
· 移动操作允许高效转移右值(rvalue)对象的资源。
缺点(Cons): · 某些类型无意义复制(例如单例对象、互斥锁)。 · 多态基类的拷贝操作可能导致对象切片(object slicing)。 · 默认拷贝操作可能隐含错误,且隐式调用易被忽略。 · 过度拷贝可能导致性能问题。
决策(Decision): 每个类的公共接口必须明确声明支持的拷贝/移动操作: • 可复制类显式声明拷贝操作。 • 仅可移动类显式声明移动操作。 • 不可复制/移动类显式删除拷贝操作。 可复制类也可声明移动操作以优化性能。 若声明/删除任一拷贝操作,必须显式处理其对应操作(例如声明拷贝构造函数则需处理拷贝赋值运算符)。
例外: · 若类无私有成员(如结构体或接口基类),其可复制/移动性可由公有成员决定。 · 若基类明显不可复制/移动,派生类自然也不可。
避免定义无明确意义或带来意外开销的拷贝/移动操作。移动操作应仅在显著优于拷贝时定义。 为防止对象切片(slicing),优先将基类设为抽象类(通过保护构造函数/析构函数或纯虚函数)。避免从具体类继承。
# 结构体与类(Structs vs. Classes)
仅当对象被动承载数据时使用结构体(struct);其他情况使用类(class)。
定义(Definition): struct
和 class
在C++中几乎等价,但附加语义含义:
• 结构体用于被动数据对象,所有字段必须公有,不维护字段间的不变量(invariants)。
• 可包含构造函数、析构函数和辅助方法,但这些方法不得依赖或强制不变量。
若需更多功能、不变量或预期演进,应使用类。
为与STL一致,无状态类型(如特性模板、元函数、仿函数)可使用结构体。
注意:结构体和类的成员变量命名规则不同。
# 结构体 vs 对组与元组(Structs vs. Pairs and Tuples)
当元素可命名时,优先使用结构体而非 pair
或 tuple
。
定义(Definition):
虽然 pair
和 tuple
可避免自定义类型,但命名字段(如 user.name
)比 .first
/.second
或 std::get<X>
更清晰。C++14 的 std::get<Type>
可缓解此问题,但字段名通常更明确。
pair
和 tuple
适用于泛型代码或无特定含义的元素,或与现有代码/API交互时。
# 继承(Inheritance)
优先使用组合而非继承。使用继承时,必须为公有继承。
定义(Definition): • 接口继承(interface inheritance):从纯抽象基类(无状态或已定义方法)继承。 • 实现继承(implementation inheritance):从非抽象基类继承。
优点(Pros): · 实现继承通过复用基类代码减少代码量。 · 接口继承可强制类实现特定API。
缺点(Cons): · 实现继承的代码分散在基类和子类中,难以理解。 · 子类无法重写非虚函数。 · 多重继承(multiple inheritance)易导致性能问题和"菱形继承"歧义。
决策(Decision):
所有继承应为公有。若需私有继承,应改为包含基类实例作为成员。可使用 final
禁止类被继承。
不要过度使用实现继承(implementation inheritance)。组合(composition)通常更为合适。尽量将继承的使用限制在"是一个(is-a)"的情况:如果Bar可以合理地称为Foo的一种类型("is a kind of" Foo),则让Bar子类化Foo。
将protected成员的使用限制在可能需要被子类访问的成员函数上。注意数据成员应始终为私有(private)。
显式使用override
或(较少使用的)final
说明符标注虚函数或虚析构函数的重写(override)。在声明重写时不要使用virtual
关键字。
理由:标记为override
或final
的函数或析构函数如果不是基类虚函数的重写,将无法通过编译,这有助于捕获常见错误。这些说明符本身具有文档作用;若未使用说明符,读者必须检查该类的所有祖先才能确定函数或析构函数是否为虚函数。
允许多重继承(multiple inheritance),但强烈反对多重实现继承(multiple implementation inheritance)。
# 运算符重载(Operator Overloading)
明智地重载运算符。不要使用用户定义字面量(user-defined literals)。
定义(Definition):
C++ 允许用户代码通过 operator
关键字声明内置运算符的重载版本 (opens new window),只要其中一个参数是用户定义类型。operator
关键字还允许用户代码通过 operator""
定义新类型的字面量,以及定义类型转换函数(如 operator bool()
)。
优点(Pros):
• 运算符重载(operator overloading)可以通过让用户定义类型的行为与内置类型一致,使代码更简洁直观。重载运算符是某些操作的惯用名称(例如 ==
、<
、=
和 <<
),遵循这些约定可以提高用户定义类型的可读性,并使其能与依赖这些名称的库交互。
• 用户定义字面量(user-defined literals)是创建用户定义类型对象的极简表示法。
缺点(Cons):
• 提供正确、一致且符合预期的运算符重载集需要谨慎处理,否则可能导致混淆和错误。
• 运算符的过度使用会导致代码晦涩,尤其是当重载运算符的语义不遵循约定时。
• 函数重载的风险同样适用于运算符重载,甚至更甚。
• 运算符重载可能误导开发者,将高开销操作误认为是廉价的底层操作。
• 查找重载运算符的调用点需要支持 C++ 语法的搜索工具,而非简单的 grep
。
• 如果重载运算符的参数类型错误,可能会匹配到不同的重载版本而非触发编译错误。例如,foo < bar
可能执行一个操作,而 &foo < &bar
可能执行完全不同的操作。
• 某些运算符重载本质上是危险的。重载一元 &
可能导致同一段代码因重载声明是否可见而产生不同含义。&&
、||
和 ,
(逗号)的重载无法匹配内置运算符的求值顺序语义。
• 运算符通常定义在类外部,因此存在不同文件引入同一运算符不同定义的风险。若两种定义被链接到同一二进制文件中,会导致未定义行为(undefined behavior),可能表现为隐蔽的运行时错误。
• 用户定义字面量(UDLs)允许创建即使对经验丰富的 C++ 程序员也陌生的语法形式,例如用 "Hello World"sv
作为 std::string_view("Hello World")
的简写。现有表示法虽不够简洁,但更清晰。
• 由于用户定义字面量无法通过命名空间限定,使用它们需要 using
指令(我们禁止此类用法 (opens new window))或 using
声明(头文件中禁止使用 (opens new window),除非导入的名称是头文件暴露接口的一部分)。鉴于头文件必须避免使用 UDL 后缀,我们倾向于避免头文件与源文件在字面量约定上存在差异。
决策(Decisions):
• 仅当重载运算符的含义明确、符合预期且与对应内置运算符一致时,才定义它们。例如,将 |
用作位运算或逻辑“或”,而非 shell 风格的管道。
• 仅对自定义类型定义运算符。更准确地说,应在与操作类型相同的头文件、.cc
文件和命名空间中定义运算符。这样,运算符在类型可用的地方均可访问,从而最小化多重定义的风险。尽可能避免将运算符定义为模板,因为它们必须对所有可能的模板参数满足此规则。若定义某个运算符,应同时定义所有相关的合理运算符,并确保其定义一致。
• 优先将非修改性二元运算符定义为非成员函数。若将二元运算符定义为类成员,隐式转换(implicit conversions)将适用于右侧参数而非左侧。如果 a + b
能编译而 b + a
不能,会令用户困惑。
• 对于可进行相等性比较的类型 T
,定义非成员函数 operator==
并记录类型 T
的两个值何时被视为相等。若类型 T
的值 t1
小于另一个值 t2
存在唯一明确的定义,则可定义 operator<=>
,且其应与 operator==
保持一致。尽量避免重载其他比较和排序运算符。
• 不要刻意避免定义运算符重载。例如,优先定义 ==
、=
和 <<
,而非 Equals()
、CopyFrom()
和 PrintTo()
。反之,不要仅因其他库需要而定义运算符重载。例如,若类型没有自然顺序,但需将其存入 std::set
,应使用自定义比较器而非重载 <
。
• 不要重载 &&
、||
、,
(逗号)或一元 &
。不要重载 operator""
,即不要引入用户定义字面量(user-defined literals)。不要使用他人(包括标准库)提供的此类字面量。
类型转换运算符(type conversion operators)的规范见隐式转换 (opens new window)章节。=
运算符的规范见拷贝构造函数 (opens new window)章节。为流(streams)重载 <<
的规范见流 (opens new window)章节。另请参阅适用于运算符重载的函数重载 (opens new window)规则。
# 访问控制(Access Control)
将类的数据成员设为私有(private),除非它们是常量。这简化了不变量的推理,代价是必要时需编写简单的访问器(通常为const形式)。
出于技术原因,我们允许测试夹具类(test fixture class)在.cc
文件中定义protected数据成员(当使用Google Test时)。若测试夹具类定义在其使用的.cc
文件外部(例如在.h
文件中),则数据成员应设为私有。
# 声明顺序(Declaration Order)
将相似的声明归类分组,并优先放置公开(public)部分。
类定义通常应以 public:
部分开头,其次是 protected:
,最后是 private:
。若某部分为空,则省略该部分。
在每个部分内部,建议将相似类型的声明归类分组,并遵循以下顺序:
- 类型及类型别名(
typedef
、using
、enum
、嵌套结构体/类,以及友元类型) - (仅对结构体可选)非静态数据成员(non-static data members)
- 静态常量(static constants)
- 工厂函数(factory functions)
- 构造函数与赋值运算符(constructors and assignment operators)
- 析构函数(destructor)
- 所有其他函数(静态/非静态成员函数,以及友元函数)
- 所有其他数据成员(静态/非静态)
禁止在类定义内联(inline)编写大型方法定义。 通常,仅允许简单、性能关键且极短的方法内联定义。详见内联函数(Inline Functions) (opens new window)章节。
# 函数
# 输入与输出
C++函数的输出通常通过返回值提供,有时也通过输出参数(或输入/输出参数)提供。
优先使用返回值而非输出参数:这种做法可提升可读性,通常也能提供相同或更好的性能表现。
优先按值返回,若不可行则按引用返回。除非指针可为空,否则应避免返回原始指针。
函数参数可分为输入参数、输出参数或兼具两者功能。非可选输入参数通常应为值类型或常量引用,非可选输出和输入/输出参数通常应为引用(不可为空)。通常使用std::optional
表示可选的值类型输入,当非可选形式本应使用引用时,使用常量指针。使用非常量指针表示可选输出和可选输入/输出参数。
避免定义要求引用参数在函数调用后继续存活的函数。某些情况下引用参数可能绑定到临时对象,导致生命周期错误。应设法消除生命周期要求(例如通过复制参数),或通过指针传递保留参数并明确记录生命周期和非空要求。
排列函数参数时,所有纯输入参数应置于输出参数之前。特别注意不要仅因新增参数而将其置于参数列表末尾,新增的纯输入参数应放在输出参数之前。此非绝对规则,兼具输入输出功能的参数会模糊界限,且与相关函数保持一致性时可能需要调整规则。可变参数函数可能需要特殊的参数顺序。
# 编写简短函数
优先编写简短且功能集中的函数。
我们理解长函数有时是合理的,因此不对函数长度设置硬性限制。若函数超过约40行,应考虑是否能在不破坏程序结构的前提下进行拆分。
即使长函数当前运行完美,数月后修改者可能新增功能,导致难以发现的错误。保持函数简短可使他人更易阅读和修改代码。短函数也更易于测试。
遇到冗长复杂函数时不必畏惧修改现有代码:若处理此类函数困难重重、调试困难或需复用部分功能,应考虑将其拆分为更小更易管理的片段。
# 函数重载
仅在调用处的读者无需精确判断具体调用哪个重载就能理解代码意图时,才使用重载函数(含构造函数)。
定义:
可编写接收const std::string&
的函数,并重载接收const char*
的版本。但此类情况建议改用std::string_view
。
优点:
通过允许同名函数接收不同参数,重载可提升代码直观性。对模板化代码是必要手段,对访问者模式也较便利。
基于常量或引用限定符的重载可提升工具代码的可用性和效率(参见TotW 148 (opens new window))。
缺点:
若函数仅通过参数类型重载,读者可能需要理解C++复杂的匹配规则才能明白逻辑。当派生类仅重载函数的部分变体时,继承语义易引发困惑。
决策:
当重载变体间无语义差异时可进行重载。这些重载可在类型、限定符或参数数量上存在差异。但调用处的读者无需知晓具体调用哪个重载,只需知道调用了集合中的某个成员。若能用头文件中的单个注释说明所有重载条目,则表明这是设计良好的重载集合。
# 默认参数
当默认值保证恒定时,允许在非虚函数中使用默认参数。遵循与函数重载相同的限制,若默认参数带来的可读性提升无法抵消下述缺点,则优先使用重载函数。
优点:
常见场景使用默认值,特殊场景需覆盖默认时,默认参数提供便捷方式,无需为罕见例外定义多个函数。相比重载,默认参数语法更简洁,减少样板代码,更清晰区分"必需"与"可选"参数。
缺点:
默认参数是实现重载语义的另一种方式,因此所有反对重载的理由均适用。
虚函数调用中的参数默认值由目标对象的静态类型决定,无法保证所有重写函数都声明相同默认值。
默认参数在每次调用处重新求值,可能膨胀生成代码。读者可能误认为默认值在声明处固定,而非每次调用变化。
存在默认参数时函数指针易引发困惑,因函数签名常与调用签名不匹配。添加函数重载可避免此类问题。
决策:
禁止在虚函数中使用默认参数(因其工作异常),以及默认值可能随求值时机变化的场景(例如勿写void f(int n = counter++);
)。
其他情况下,若默认参数显著提升函数声明可读性,则可允许。存疑时,使用重载。
# 尾置返回类型(Trailing Return Type)语法
仅在常规语法(前置返回类型)不切实际或显著降低可读性时使用尾置返回类型。
定义:
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);
2
缺点:
尾置返回语法相对新颖,在C++类语言(如C和Java)中无对应形式,部分读者可能陌生。
现有代码库包含大量不会改用新语法的函数声明,现实选择是仅用旧语法或混合使用。统一风格更佳。
决策:
多数情况下继续使用返回类型前置的传统函数声明语法。仅在必需场景(如lambda)或尾置形式显著提升可读性时使用新语法(后者应少见,主要出现在复杂模板代码中,而此类代码通常不鼓励使用)。
# Google特规技巧(Google-Specific Magic)
我们使用各种技巧和工具增强C++代码健壮性,部分实践可能异于常规。
# 所有权与智能指针
优先为动态分配对象设定单一固定所有者,通过智能指针转移所有权。
定义:
"所有权"是管理动态分配内存(及其他资源)的簿记(bookkeeping)技术。动态分配对象的所有者负责确保不再需要时删除对象。所有权可共享,通常由最后所有者负责删除。即使不共享,所有权也可在代码间转移。
"智能"指针是通过重载*
和->
运算符等行为模拟指针的类。部分智能指针类型可自动化所有权簿记以确保责任履行。std::unique_ptr
是表达动态分配对象独占所有权的智能指针类型,离开作用域时自动删除对象。不可复制但可移动转移所有权。std::shared_ptr
是表达共享所有权的智能指针类型,可复制,所有权由所有副本共享,最后一个std::shared_ptr
销毁时删除对象。
优点:
• 无所有权逻辑几乎无法管理动态分配内存 • 转移所有权可能比复制对象更高效(若复制可行) • 转移所有权比"借用"指针/引用更简单,因减少协调对象生命周期的需求 • 智能指针通过显式所有权逻辑提升可读性,实现自文档化 • 智能指针消除手动簿记,简化代码并杜绝大类错误 • 对常量对象,共享所有权可作为深拷贝的简单高效替代
缺点:
• 必须通过指针(智能或原始)表示和转移所有权。指针语义比值语义复杂,API中需考虑所有权、别名、生命周期和可变性等问题
• 值语义的性能代价常被高估,所有权转移的收益可能无法抵消可读性和复杂性代价
• 强制所有权转移的API使客户端限于单一内存模型
• 智能指针代码对资源释放时机的明确性较低
• std::unique_ptr
使用移动语义转移所有权,新特性可能使部分程序员困惑
• 共享所有权可能沦为粗糙设计的替代方案,模糊系统设计
• 共享所有权需显式运行时簿记,代价高昂
• 某些情况(如循环引用)下共享所有权对象可能永不删除
• 智能指针无法完美替代原始指针
决策:
若需动态分配,优先让分配者保留所有权。若其他代码需访问对象,考虑传递副本,或传递不转移所有权的指针/引用。优先使用std::unique_ptr
显式转移所有权,例如:
std::unique_ptr<Foo> FooFactory();
void FooConsumer(std::unique_ptr<Foo> ptr);
2
无充分理由勿设计共享所有权代码。合理理由包括避免昂贵复制操作,但仅应在性能收益显著且底层对象不可变时使用(如std::shared_ptr<const Foo>
)。若使用共享所有权,优先选std::shared_ptr
。
禁用std::auto_ptr
,改用std::unique_ptr
。
# cpplint
使用cpplint.py
检测风格错误。
cpplint.py
是读取源文件并识别多种风格错误的工具。虽不完美(存在误报漏报),但仍具价值。
部分项目提供通过项目工具运行cpplint.py
的说明。若贡献项目无此说明,可单独下载cpplint.py
(opens new window)。
# 其他 C++ 特性
# 右值引用(Rvalue References)
仅在以下特定情况下使用右值引用。
定义:
右值引用是一种只能绑定到临时对象的引用类型。其语法与传统引用语法类似。例如,void f(std::string&& s);
声明了一个参数为 std::string
右值引用的函数。
当 &&
符号应用于函数参数中未限定的模板参数时,会触发特殊的模板参数推导规则。这种引用称为转发引用(forwarding reference)。
优点:
· 定义移动构造函数(接受类类型右值引用的构造函数)可以实现值的移动而非复制。例如,若 v1
是 std::vector<std::string>
,则 auto v2(std::move(v1))
可能仅通过简单的指针操作完成,而非复制大量数据。这在许多情况下可显著提升性能。
· 右值引用使得实现可移动但不可复制的类型成为可能,这对于没有合理复制定义但仍需作为函数参数或放入容器等场景非常有用。
· std::move
是有效使用某些标准库类型(如 std::unique_ptr
)的必要工具。
· 使用右值引用符号的转发引用(forwarding references)使得编写通用函数包装器成为可能,该包装器能将参数转发给其他函数,并同时支持临时对象和常量参数。这称为"完美转发(perfect forwarding)"。
缺点:
· 右值引用尚未被广泛理解。引用折叠(reference collapsing)和转发引用的特殊推导规则等概念较为晦涩。
· 右值引用常被误用。在函数签名中,若参数在调用后需保持有效状态,或未执行移动操作时,使用右值引用会违反直觉。
决策:
除非符合以下情况,否则不要使用右值引用(或在方法上应用 &&
限定符):
• 可用于定义移动构造函数和移动赋值运算符(如"可复制和可移动类型"章节所述)。
• 可用于定义逻辑上"消耗" *this
的 &&
限定方法,使对象进入不可用或空状态。注意此规则仅适用于方法限定符(位于函数签名闭合括号之后);若需"消耗"普通函数参数,建议按值传递。
• 可与 std::forward
结合使用转发引用,以支持完美转发。
• 可用于定义成对的重载函数,例如一个接受 Foo&&
,另一个接受 const Foo&
。通常更优的解决方案是按值传递,但成对重载函数有时能提升性能,例如当函数偶尔不消耗输入时。
始终遵循:若为性能编写复杂代码,需确保有实际收益的证据。
# 友元(Friends)
在合理范围内允许使用友元类和友元函数。
友元通常应定义在同一个文件中,以便读者无需查看其他文件即可了解类私有成员的使用。友元的典型用例是让 FooBuilder
类成为 Foo
的友元,以便正确构建 Foo
的内部状态,同时不向外界暴露该状态。某些情况下,将单元测试类设为被测试类的友元可能有用。
友元扩展了类的封装边界,但并未破坏封装。当希望仅允许另一个类访问某个成员时,友元可能比公开该成员更合适。然而,大多数类应仅通过公有成员与其他类交互。
# 异常(Exceptions)
我们不使用 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
用于标准默认分配)。注意许多情况下唯一可能的抛出原因是内存分配失败(如持有 std::string
或 std::vector
的容器移动构造函数),而许多应用适合将内存耗尽视为致命错误而非可恢复的异常。对于其他潜在故障,应优先考虑接口简洁性而非支持所有异常场景:例如,与其编写依赖哈希函数是否抛出的复杂 noexcept
子句,不如直接声明组件不支持抛出哈希函数并设为无条件 noexcept
。
# 运行时类型信息(RTTI)
避免使用运行时类型信息(RTTI)。
定义:
RTTI 允许程序员在运行时查询 C++ 对象的类型。通过 typeid
或 dynamic_cast
实现。
优点:
RTTI 的替代方案(如下所述)通常需要修改或重新设计类层次结构。对于广泛使用或成熟的代码,此类修改可能不可行或不理想。
RTTI 在某些单元测试中有用。例如,工厂类测试中验证新创建对象是否具有预期动态类型,或管理对象与其模拟对象的关系时。
RTTI 在处理多个抽象对象时有用。
缺点:
在运行时频繁查询对象类型通常意味着设计问题。需要运行时类型信息常表明类层次结构设计存在缺陷。
滥用 RTTI 会使代码难以维护。可能导致基于类型的决策树或分散的 switch
语句,任何后续修改都需检查这些代码。
决策:
RTTI 有合法用途但易被滥用,使用时需谨慎。单元测试中可自由使用,其他代码应尽量避免。编写新代码时需三思。若发现需要根据对象类型执行不同代码,请考虑以下替代方案:
• 虚方法是根据子类类型执行不同代码路径的首选方式,将工作封装在对象内部。
• 若工作属于外部处理代码,可考虑双重分发方案(如访问者设计模式),利用内置类型系统让外部设施确定类类型。
当程序逻辑保证基类实例实际为特定派生类实例时,可自由使用 dynamic_cast
。通常可用 static_cast
替代。
基于类型的决策树强烈暗示代码设计有误。此类代码在类层次结构新增子类时易失效,且子类属性变更时难以定位修改点。
不要手动实现类似 RTTI 的变通方案。反对 RTTI 的论点同样适用于带类型标签的类层次结构等变通方案。此外,变通方案会掩盖真实意图。
# 强制类型转换(Casting)
使用 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}
)。这是最安全的方式,因为若转换导致信息丢失,代码将无法编译。语法也简洁。
· 用 absl::implicit_cast
安全地在类型层次中向上转换(如将 Foo*
转为 SuperclassOfFoo*
或 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 章节的指导。
# 流(Streams)
在适当场景使用流,并保持"简单"用法。仅为表示值的类型重载 <<
,且仅输出用户可见值,而非实现细节。
定义:
流是 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()
方法)。
# 前置递增与前置递减(Preincrement and Predecrement)
除非需要后缀语义(postfix semantics),否则应使用递增运算符 $(++ i)
和递减运算符的前缀形式(prefix form)。
定义:
当变量被递增(++i
或 i++
)或递减(--i
或 i--
)且表达式的值未被使用时,必须决定使用前置(preincrement/decrement)还是后置(postincrement/decrement)形式。
优点:
后置递增/递减表达式的值为变量修改前的值。这可能导致代码更紧凑但更难阅读。前缀形式通常更易读,效率绝不逊色,且可能更高效,因为它无需复制操作前的值副本。
缺点:
在 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)+名词(int)"结构,可读性更强。
我们鼓励将const前置,但不做强制要求。请与周边代码风格保持一致!
# constexpr、constinit 和 consteval 的使用
使用constexpr定义真正的常量或确保常量初始化。使用constinit确保非常量变量的常量初始化。
定义:
• 声明为constexpr的变量是真正的常量(在编译/链接期确定) • constexpr函数和构造函数可用于定义constexpr变量 • consteval函数限制其只能在编译期调用
优点:
• constexpr允许使用浮点表达式、用户自定义类型和函数调用定义常量
缺点:
• 过早标记constexpr可能导致后续降级困难 • constexpr函数和构造函数的当前限制可能导致晦涩的变通方案
决策:
constexpr定义能更稳健地指定接口的常量部分。应使用constexpr定义真常量及其支持函数。consteval可用于必须不在运行时调用的代码。避免为适配constexpr而复杂化函数定义。不要用constexpr或consteval强制内联。
# 整数类型
C++内置整数类型中仅使用int。若需不同大小的整数类型,使用<cstdint>
中的精确宽度类型(如int16_t)。若值可能超过$2^{31}$,使用int64_t等64位类型。注意即使值本身不会超出int范围,中间计算可能需要更大类型。不确定时选择更大类型。
定义:
C++未规定int等整数类型的精确大小。当代架构中常见尺寸:short(16位)、int(32位)、long(32或64位)、long long(64位),但不同平台(尤其是long)存在差异。
优点:
声明统一性
缺点:
整数类型大小依赖编译器和架构
决策:
优先使用<cstdint>
定义的int16_t、uint32_t、int64_t等类型,而非short、unsigned long long等。可省略std::前缀(5字符的冗长不值得)。内置类型中仅使用int:
• 常用int表示已知不会过大的整数(如循环计数器)。假设int至少32位,但不要假设超过32位。需要64位时使用int64_t或uint64_t • 可能较大的整数使用int64_t • 除非表示位模式或需要模$2^N$溢出,否则避免无符号类型(如uint32_t)。不要用无符号类型表示非负数,应使用断言 • 容器返回大小时应使用能容纳所有使用场景的类型 • 不确定时选择更大类型 • 注意整数转换和提升可能引发未定义行为
关于无符号整数
无符号整数适合表示位字段和模运算。由于历史原因,C++标准也用无符号整数表示容器大小(标准委员会多数认为这是错误,但已无法修正)。无符号算术不模拟普通整数行为(而是模运算),导致大量错误无法被编译器诊断,同时阻碍优化。
但混合符号类型同样引发大量问题。最佳建议:
• 优先使用迭代器和容器而非指针和大小 • 避免混合符号类型 • 除位字段或模运算外避免无符号类型 • 不要仅因变量非负就使用无符号类型
# 浮点类型
仅使用float和double,假设它们分别对应IEEE-754 binary32和binary64。不要使用long double(结果不可移植)。
# 架构可移植性
编写架构无关代码,不要依赖特定处理器特性:
• 打印值时使用类型安全的格式化库(如absl::StrCat
、absl::Substitute
、absl::StrFormat
或std::ostream
),而非printf系列函数
• 进程间传输结构化数据时使用协议缓冲区等序列化库,而非直接复制内存表示
• 将内存地址作为整数操作时使用uintptr_t而非uint32_t或uint64_t
• 使用大括号初始化创建64位常量(如int64_t my_value{0x123456789};
)
• 使用可移植浮点类型(避免long double)
• 使用可移植整数类型(避免short、long、long long)
# 预处理器宏
避免定义宏(尤其实在头文件中),优先使用内联函数、枚举和const变量。宏名称应包含项目特定前缀。不要用宏定义C++ API组成部分。
宏会导致所见代码与编译器处理的代码不一致,可能引发意外行为(特别是具有全局作用域的宏)。当宏用于定义C++ API(尤其是公共API)时问题更严重:
• 错误信息难以解释宏展开结果 • 重构和分析工具难以更新接口 • 因此明确禁止此类用法
替代方案:
• 性能关键代码:使用内联函数
• 常量:使用const变量
• 长变量名缩写:使用引用
• 条件编译:除头文件保护宏(#define
guards)外避免使用
若必须使用宏,遵循以下模式:
• 不在.h
文件中定义宏
• 使用前立即#define
,使用后立即#undef
• 不要覆盖现有宏,应选择唯一性高的名称
• 避免生成不平衡C++结构的宏,或充分文档化其行为
• 避免用##
生成函数/类/变量名
头文件中导出宏(定义后不#undef
)极其不鼓励。若必须导出,应使用全局唯一名称(包含项目命名空间的大写前缀)。
# 0 与 nullptr/NULL
指针使用nullptr,字符使用'\0'
(而非字面量0):
• 指针地址值使用nullptr(类型安全)
• 空字符使用'\0'
(正确类型提升可读性)
# sizeof 运算符
优先sizeof(varname)
而非sizeof(type)
:
• 对特定变量取大小时使用sizeof(varname)
(类型变更时自动更新)
• 与具体变量无关的代码(如处理外部数据格式时)可使用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;
}
2
3
4
5
6
7
8
# 类型推导(Type Deduction,含 auto 用法)
仅当类型推导能提高代码对不熟悉项目的读者的清晰度,或能增强代码安全性时方可使用。不可单纯为避免显式类型书写的不便而使用。
定义:
在以下若干上下文中,C++ 允许(甚至要求)编译器推导类型而非显式指定:
函数模板实参推导(Function template argument deduction)
调用函数模板时可不显式指定模板参数。编译器根据函数实参类型推导模板参数:
template<typename T>
void f(T t);
f(0); // 调用 f<int>(0)
2
3
4
auto 变量声明(auto variable declarations)
变量声明可用 auto
关键字替代类型声明。编译器根据初始化表达式推导类型,推导规则与函数模板实参推导相同(只要不使用花括号替代圆括号):
auto a = 42; // a 是 int 类型
auto
可搭配 const
限定符使用,可作为指针或引用类型的一部分,并(自 C++17 起)作为非类型模板参数。该语法的罕见变体使用 decltype(auto)
替代 auto
,此时推导类型为对初始化表达式应用 decltype
的结果。
函数返回类型推导(Function return type deduction)
auto
(及 decltype(auto)
)可用于替代函数返回类型声明。编译器根据函数体内的 return
语句推导返回类型,规则与变量声明相同:
auto f() { return 0; } // 函数 f 的返回类型是 int
Lambda 表达式返回类型可通过相同方式推导,但需通过省略返回类型而非显式使用 auto
来触发。需注意,函数的尾置返回类型语法虽在返回类型位置使用 auto
,但实际不依赖类型推导,仅是显式返回类型的替代语法。
泛型 lambda(Generic lambdas)
Lambda 表达式可用 auto
关键字替代部分或全部参数类型。这会使 lambda 的调用运算符成为函数模板而非普通函数,每个 auto
参数对应独立模板参数:
// 按降序排序 vec
std::sort(vec.begin(), vec.end(), [](auto lhs, auto rhs) { return lhs > rhs; });
2
Lambda 初始化捕获(Lambda init captures)
Lambda 捕获可含显式初始化器,用于声明全新变量而非仅捕获现有变量:
[x = 42, y = "foo"] { /*...*/ } // x 是 int 类型,y 是 const char*
此语法不允许指定类型,类型推导规则与 auto
变量相同。
类模板实参推导(Class template argument deduction)
详见下文。
结构化绑定(Structured bindings)
使用 auto
声明元组、结构体或数组时,可为单个元素指定名称而非整个对象命名,此类名称称为"结构化绑定",整个声明称为"结构化绑定声明"。此语法无法指定外层对象或单个绑定的类型:
auto [iter, success] = my_map.insert({key, value});
if (!success)
iter->second = value;
2
3
auto
可搭配 const
、&
和 &&
限定符使用,但需注意这些限定符实际作用于匿名元组/结构体/数组而非单个绑定。绑定类型的推导规则较为复杂,结果通常符合直觉,但绑定类型通常不会成为引用(即使声明中使用了引用限定符)。
(以上摘要省略了诸多细节和注意事项,详见相关链接。)
优点:
• C++ 类型名称可能冗长复杂,尤其是涉及模板或命名空间时。 • 当 C++ 类型名称在单个声明或小范围代码区域重复出现时,重复书写可能无益于可读性。 • 类型推导有时更安全,可避免意外拷贝或类型转换。
缺点:
• 显式类型通常使 C++ 代码更清晰,特别是当类型推导依赖代码远端信息时。例如:
auto foo = x.add_foo();
auto i = y.Find(key);
2
若 y
的类型不明确或声明位置较远,推导结果类型可能难以判断。
• 程序员需理解类型推导何时生成引用类型,否则可能导致非预期的拷贝。 • 若推导类型作为接口的一部分,修改类型时可能引发超出预期的 API 变更。
决策:
基本原则:仅当提升代码清晰度或安全性时使用类型推导,不可单纯为避免显式类型书写而使用。评估代码清晰度时,需考虑读者可能非项目成员或不熟悉项目,因此您认为冗余的类型信息可能对他人至关重要。例如,可假定 make_unique<Foo>()
的返回类型显而易见,但 MyWidgetFactory()
的返回类型未必如此。
以下细则适用于各类推导场景:
函数模板实参推导(Function template argument deduction)
函数模板实参推导基本均可接受。类型推导是与函数模板交互的默认方式,因其允许函数模板表现为无限重载函数集合。因此,函数模板通常设计为模板实参推导清晰安全,或直接无法编译。
局部变量类型推导(Local variable type deduction)
对局部变量可使用类型推导消除明显或无关的类型信息,使读者聚焦代码核心逻辑:
std::unordered_map<std::string, std::unique_ptr<Widget>>::iterator it = m.find(key);
// vs
auto it = m.find(key);
2
3
类型有时混杂有效信息与样板代码,如上例 it
明显是迭代器类型,容器类型甚至键类型常无关紧要,但值类型可能重要。此类情况下,可用显式类型声明局部变量传递关键信息:
for (const auto& [key, value] : m) { // 键值类型明确
// ...
}
2
3
若类型为模板实例且参数为样板代码,可使用类模板实参推导(CTAD)消除样板。但实际受益场景较少。注意 CTAD 需遵守独立风格规则。
若简单方案可行,则避免使用 decltype(auto)
,因其晦涩性会损害代码清晰度。
返回类型推导(Return type deduction)
仅在函数体含极少量 return
语句且代码量极少时使用返回类型推导(含函数与 lambda),否则读者难以快速判断返回类型。此外,仅限函数或 lambda 作用域极窄时使用,因推导返回类型的函数不定义抽象边界:实现即接口。特别注意头文件中的公共函数几乎不应使用推导返回类型。
参数类型推导(Parameter type deduction)
Lambda 的 auto
参数类型需谨慎使用,因实际类型由调用方代码决定而非 lambda 定义。因此,显式类型通常更清晰,除非 lambda 在定义处附近显式调用(便于读者查看两者),或传递给众所周知的接口(如前述 std::sort
示例)。
Lambda 初始化捕获(Lambda init captures)
初始化捕获受更具体的风格规则约束,基本覆盖类型推导的通用规则。
结构化绑定(Structured bindings)
与其他推导形式不同,结构化绑定通过为对象元素赋予有意义的名称,可为读者提供额外信息。这意味着即使 auto
不适用时,结构化绑定声明仍可能比显式类型更具可读性。当对象为 pair
或 tuple
时(如前文 insert
示例),结构化绑定尤其实用,因这些类型本身缺乏有意义的字段名。但注意若非受限于 insert
等现有 API,通常应避免使用 pair
或 tuple
。
若绑定对象为结构体,有时可指定更贴合使用场景的名称,但需注意此类名称可能不如原字段名易识别。建议使用函数参数注释的语法标注原字段名:
auto [/*field1=*/name, /*field2=*/address] = GetStruct();
与函数参数注释类似,此方式可帮助工具检测字段顺序错误。
# 类模板实参推导(Class template argument deduction)
仅当模板明确支持类模板实参推导(CTAD)时方可使用。
定义:
类模板实参推导(常缩写为 CTAD)发生于变量声明使用模板名作为类型且未提供模板实参列表(甚至空尖括号)时:
std::array arr = {1, 2, 3}; // 推导为 std::array<int, 3>
此功能依赖"推导指引"——指导编译器如何将构造函数参数映射到模板实参的规则。显式推导指引语法示例(std::array
的推导指引):
template <class T, class... U>
array(T, U...) -> array<T, 1 + sizeof...(U)>;
2
主模板(非特化模板)的构造函数会隐式定义推导指引。当声明依赖 CTAD 的变量时,编译器通过构造函数重载决议选择推导指引,其返回类型成为变量类型。
优点:
• CTAD 有时可消除样板代码。
缺点:
由构造函数生成的隐式推导指南(implicit deduction guides)可能产生不符合预期的行为,甚至完全错误。这对C++17引入CTAD前编写的构造函数尤其成问题,因为当时作者无法预见(更无法修复)其构造函数对CTAD可能造成的影响。此外,添加显式推导指南(explicit deduction guides)来修复这些问题可能会破坏依赖隐式推导指南的现有代码。
CTAD也存在与auto
相似的缺陷,因为它们都是通过初始化表达式推导变量全部或部分类型的机制。虽然CTAD比auto
能向读者传递更多信息,但同样无法直观提示哪些信息被隐式省略。
决策:
除非模板维护者通过提供至少一个显式推导指南明确支持CTAD的使用(同时假定std
命名空间中的所有模板都已支持),否则不得对任何模板使用CTAD。应尽可能通过编译器警告来强制执行此规则。
使用CTAD时必须同时遵守类型推导的通用规则。
# 指定初始化器(Designated Initializers)
仅使用符合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 将初始化为0.0
};
2
3
4
5
6
7
8
9
10
11
显式列出的字段按指定值初始化,其他字段的初始化方式与传统聚合初始化(如Point{1.0, 2.0}
)相同。
优点:
对于字段顺序不直观的结构体(如比上述Point
更复杂的案例),指定初始化器能提高代码可读性和便利性。
缺点:
虽然指定初始化器长期作为C标准的一部分并被C++编译器扩展支持,但C++20之前的标准并不支持。
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);
});
2
3
lambda可以通过显式命名或默认捕获从外围作用域捕获变量:
显式捕获要求逐个列出值捕获或引用捕获的变量:
int weight = 3;
int sum = 0;
std::for_each(v.begin(), v.end(), [&sum, weight](int x) {
sum += weight * x;
});
2
3
4
5
默认捕获会隐式捕获lambda体内引用的所有变量(若使用成员变量则包括this
):
std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)]() { // 显式初始化捕获(常称"初始化捕获"或"广义lambda捕获")
...
};
2
3
4
此类捕获无需实际从外围作用域"捕获"任何内容,甚至可以使用外围作用域不存在的名称。这种语法本质上是定义lambda对象成员的通用方式:
auto lambda = [value = 1] { return value; };
带初始化器的捕获类型使用与auto
相同的规则推导。
优点:
• 相比其他定义函数对象的方式,lambda极大提升了STL算法参数的可读性
• 合理使用默认捕获可消除冗余,突出重要例外
• lambda、std::function
和std::bind
组合形成通用回调机制,便于编写接收绑定函数参数的函数
缺点:
• lambda变量捕获可能引发悬垂指针问题(特别是lambda逃逸当前作用域时)
• 值默认捕获具有误导性,因其无法阻止悬垂指针(指针值捕获不进行深拷贝,生命周期问题与引用捕获类似。this
隐式捕获时尤其混乱)
• 捕获实际声明新变量(无论是否带初始化器),但其语法与C++其他变量声明差异巨大(无类型声明甚至auto
占位符)。这导致难以识别其为变量声明
• 初始化捕获依赖类型推导,除具有auto
的缺点外,语法本身也无法提示类型推导的存在
• 过度使用嵌套匿名函数会降低代码可读性
决策:
• 在适当场景使用lambda表达式,并按规范格式化 • 当lambda可能逃逸当前作用域时,优先使用显式捕获。例如:
// 不良实践:若Frobnicate是成员函数,快速浏览时可能无法察觉
// 若lambda在函数返回后执行,将导致foo和外围对象已被销毁的问题
Foo foo;
...
executor->Schedule([&] { Frobnicate(foo); });
// 良好实践:若Frobnicate是成员函数,编译器将报错
// 且明确提示foo被危险地引用捕获
Foo foo;
...
executor->Schedule([&foo] { Frobnicate(foo); });
2
3
4
5
6
7
8
9
10
11
• 仅当lambda生命周期明显短于被捕获变量时,才使用引用默认捕获([&]
)
• 仅当绑定少量变量至简短lambda,且捕获集合一目了然(不隐式捕获this
)时,才使用值默认捕获([=]
)。避免对复杂lambda使用值默认捕获
• 捕获仅用于实际获取外围变量。不要通过初始化捕获引入新名称或改变现有名称语义,应通过常规变量声明后捕获,或显式定义函数对象
• 关于参数和返回类型规范,参考类型推导章节
# 模板元编程(Template Metaprogramming)
避免复杂的模板元编程。
定义:
模板元编程指利用C++模板实例化机制的图灵完备性,在类型域进行编译期计算的系列技术。
优点:
模板元编程能实现类型安全、高性能的灵活接口。GoogleTest、std::tuple
、std::function
和Boost.Spirit等设施均依赖此技术。
缺点:
• 模板元编程技术通常只有语言专家能够理解 • 复杂模板代码难以调试和维护 • 模板错误信息冗长晦涩:即使用户代码简单,底层实现细节也会在出错时暴露 • 模板元编程阻碍大规模重构:模板代码在多处展开,难以验证重构正确性;重构工具基于模板展开后的AST工作,难以回溯原始代码
决策:
模板元编程可能带来更清晰的接口,但也容易导致过度设计。建议仅在底层组件中有限使用,将维护成本分摊至大量使用场景。
在使用模板元编程或其他复杂模板技术前应慎重考虑:确保团队成员能理解代码以进行维护;非C++程序员或代码浏览者能否理解错误信息或函数流程。若涉及递归模板实例化、类型列表、元函数、表达式模板,或依赖SFINAE、sizeof
技巧检测函数重载,则可能已过度使用。
若必须使用模板元编程,应着力最小化和隔离复杂度:将其隐藏为实现细节,确保公共头文件可读;对复杂代码添加详尽注释;完整记录用法并说明生成代码形态;特别注意编译器错误信息的用户友好性(错误信息是用户界面的一部分),必要时调整代码以产生可理解的错误提示。
# 概念与约束(Concepts and Constraints)
谨慎使用概念。通常,概念和约束应仅用于C++20之前需要模板的场景。避免在头文件中引入新概念(除非头文件标记为库内部使用)。不要定义编译器无法强制实施的概念。优先使用约束而非模板元编程,避免template<Concept T>
语法,改用requires(Concept<T>)
形式。
定义:
concept
关键字用于定义模板参数需求(如类型特征或接口规范)。requires
提供对模板的匿名约束并验证编译期满足性。二者常结合使用,也可独立运用。
优点:
• 概念能生成更友好的模板相关编译错误,改善开发体验 • 概念可减少编译期约束的样板代码,提升代码清晰度 • 约束提供模板和SFINAE难以实现的能力
缺点:
• 概念可能显著增加代码复杂度,降低可理解性 • 概念语法易与类类型混淆 • 概念(尤其在API边界)增加代码耦合度和僵化性 • 概念可能重复函数体内的逻辑,导致代码冗余和维护成本增加 • 概念模糊底层契约的真实来源(作为独立实体在多处使用,可能随时间推移产生需求偏差) • 概念和约束以新颖且非直观方式影响重载决议 • 与SFINAE类似,约束增加大规模重构难度
决策:
• 优先使用标准库预定义概念而非类型特征(如用std::integral
替代std::is_integral_v
)
• 优先使用现代约束语法(requires(Condition)
),避免传统模板元编程结构(如std::enable_if<Condition>
)和template<Concept T>
语法
• 不要手动重实现现有概念或特征(如用requires(std::default_initializable<T>)
而非requires(requires{ T v; })
)
• 新概念声明应极少,且仅在库内部使用(不暴露于API边界)。通常,在C++17不使用模板等效方案的场景,也不应使用概念或约束
• 不要定义与函数体重复或需求不显著的概念。例如避免:
template <typename T> concept Addable = requires(T a, T b) { a + b };
template <Addable T> T Add(T a, T b) { return a + b; }
2
除非能证明概念显著改善了特定场景(如深层嵌套或不明显需求的错误信息)
• 概念必须能被编译器静态验证。不要使用主要优势来自语义(或其他无法强制实施)约束的概念,这类需求应通过注释、断言或测试实现
# C++20 模块(C++20 modules)
不要使用C++20模块。
C++20引入了"模块"(modules),这是一种旨在替代头文件文本包含的新语言特性,引入了三个新关键字:module
、export
和import
。
模块显著改变了C++的编写和编译方式,我们仍在评估其如何融入Google的C++生态系统。此外,当前构建系统、编译器和相关工具链对其支持不足,需要进一步探索编写和使用模块的最佳实践。
# 协程(Coroutines)
暂时禁止使用协程。
不要包含 <coroutine>
头文件,或使用 co_await
、co_yield
或 co_return
关键字。
注:此项禁令预计为临时性,后续将制定更详细的指导方针。
# Boost
仅允许使用 Boost 库集合中经批准的库。
定义: Boost 库集合是一组经过同行评审、免费开源的流行 C++ 库。
优点: Boost 代码通常质量极高,具有广泛的移植性,并填补了 C++ 标准库的许多重要空白,例如类型特征(type traits)和更好的绑定器(binders)。
缺点: 某些 Boost 库提倡可能影响代码可读性的编程实践,例如元编程(metaprogramming)和其他高级模板技术,以及过度"函数式"的编程风格。
决策: 为保持代码对全体贡献者的可读性和可维护性,我们仅允许使用 Boost 功能的一个经批准子集。当前允许的库包括:
· 从 boost/call_traits.hpp
引入的调用特征(Call Traits)
· 从 boost/compressed_pair.hpp
引入的压缩对(Compressed Pair)
· Boost 图库(BGL)中的 boost/graph
部分,但排除序列化(adj_list_serialize.hpp
)和并行/分布式算法与数据结构(boost/graph/parallel/*
和 boost/graph/distributed/*
)
· boost/property_map
中的属性映射(Property Map),但排除并行/分布式属性映射(boost/property_map/parallel/*
)
· boost/iterator
中的迭代器(Iterator)
· Polygon 库中涉及 Voronoi 图构造且不依赖其他 Polygon 组件的部分:boost/polygon/voronoi_builder.hpp
、boost/polygon/voronoi_diagram.hpp
和 boost/polygon/voronoi_geometry_type.hpp
· boost/bimap
中的双向映射(Bimap)
· boost/math/distributions
中的统计分布与函数(Statistical Distributions and Functions
· boost/math/special_functions
中的特殊函数(Special Functions)
· boost/math/tools
中的求根与最优化函数(Root Finding & Minimization Functions)
· boost/multi_index
中的多索引(Multi-index)
· boost/heap
中的堆(Heap)
· 容器库中的扁平容器:boost/container/flat_map
和 boost/container/flat_set
· boost/intrusive
中的侵入式容器(Intrusive)
· boost/sort
排序库
· boost/preprocessor
中的预处理器(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++ 中不存在的有用功能。 · 某些关键性能优化指令只能通过扩展实现。
缺点:
· 非标准扩展无法在所有编译器中工作,会降低代码可移植性。
· 即使目标编译器均支持扩展,其规范往往不完善,不同编译器间可能存在细微行为差异。
· 非标准扩展增加了读者理解代码所需掌握的语言特性。
· 非标准扩展需额外工作才能跨架构移植。
决策: 禁止使用非标准扩展。若需使用基于非标准扩展实现的移植性包装,必须通过项目指定的全局移植性头文件提供。
# 别名(Aliases)
公共别名(public aliases)应为 API 使用者服务,并需明确文档化。
定义: 创建别名的方式包括:
using Bar = Foo;
typedef Foo Bar; // 但在 C++ 代码中优先使用 using
using ::other_namespace::Foo;
using enum MyEnumType; // 为 MyEnumType 的所有枚举项创建别名
2
3
4
在新代码中,优先使用 using
而非 typedef
,因其语法与 C++ 其他部分更一致且支持模板。
头文件中的别名属于 API 的公开部分,除非它们位于函数定义、类的私有部分或显式标记的内部命名空间中。这些区域或 .cc
文件中的别名属于实现细节(因客户端代码无法引用),不受此规则限制。
优点: · 别名可通过简化复杂名称提升可读性。 · 别名可减少重复命名,便于后续类型变更。
缺点: · 头文件中的公共别名会增加 API 复杂度。 · 客户端可能意外依赖公共别名的实现细节,增加修改难度。 · 可能误将实现专用的别名暴露为公共 API。 · 别名可能导致名称冲突。 · 别名可能通过赋予熟悉结构陌生名称而降低可读性。 · 类型别名可能模糊 API 契约:无法明确别名是否始终与原始类型相同,或仅在特定场景可用。
决策: 禁止仅为减少实现代码量而添加公共别名,仅当明确需要供客户端使用时方可创建。定义公共别名时,需文档说明其用途,包括是否保证始终与原始类型一致,或仅限特定兼容性。这有助于用户理解类型是否可互换,并为实现保留变更空间。
禁止在公共 API 中使用命名空间别名(另见命名空间章节)。例如以下别名明确说明了客户端使用方式:
// 以下别名是客户端 API 的一部分
namespace mynamespace {
// 用于存储测量数据的容器。
// 客户端代码可复制此别名。
using DataTable = ::std::unordered_set<::foo::Bar*, ::foo::BarHash>;
// 专用于本库的便利别名
using Props = ::std::unordered_map<::std::string, ::foo::Property>;
} // namespace mynamespace
2
3
4
5
6
7
8
而以下别名未说明用途,部分本不应供客户端使用:
namespace mynamespace // 不良:均未说明使用方式
{
using DataPoint = ::foo::Bar*;
using ::std::unordered_set; // 不良:仅为局部便利
using ::std::hash; // 不良:仅为局部便利
typedef unordered_set<DataPoint, hash<DataPoint>, DataPointComparator> TimeSeries;
} // namespace mynamespace
2
3
4
5
6
7
但函数定义、类的私有部分、显式标记的内部命名空间及 .cc
文件中的局部便利别名是允许的:
// 在 .cc 文件中
using ::foo::Bar;
2
# Switch 语句
若非基于枚举值的条件判断,switch 语句必须包含 default
分支(对于枚举值,编译器会警告未处理的情况)。若 default
分支不应执行,需按错误处理。例如:
switch (var) {
case 0: {
...
break;
}
case 1: {
...
break;
}
default: {
LOG(ERROR) << "Invalid value: " << var;
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
从一个 case 标签贯穿(fall-through)到另一个时,必须使用 [[fallthrough]];
属性标注。[[fallthrough]];
应置于发生贯穿的执行点。连续无代码的 case 标签无需标注:
switch (x) {
case 41:
case 43:
if (dont_be_picky) {
...
[[fallthrough]];
} else {
...
break;
}
case 42:
...
break;
default:
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 包容性语言(Inclusive Language)
所有代码(包括命名和注释)应使用包容性语言,避免其他开发者可能认为不尊重或冒犯的术语(如"master"和"slave"、"blacklist"和"whitelist"或"redline"),即使这些术语表面看似中立。类似地,除非特指某人(并使用其代词),否则需使用性别中立语言。例如:对未指定性别者使用"they/them/their"(即使单数),对软件、计算机等非人物体使用"it/its"。
# 命名规范
最重要的规范性原则是命名规则。名称的样式能立即告诉我们该实体是什么:类型(type)、变量(variable)、函数(function)、常量(constant)、宏(macro)等,无需查找其声明。我们大脑的模式识别机制极大地依赖这些命名规则。
命名规则虽具有一定随意性,但我们认为一致性比个人偏好更重要。因此无论您是否认同这些规则,都应当遵守。
# 通用命名规则
优先考虑可读性,使用即使其他团队人员也能清晰理解的名称。选择能描述对象用途或意图的名称。无需担心节省水平空间——让新读者快速理解代码更为重要。尽量减少使用项目外部人员可能不熟悉的缩写(特别是首字母缩略词)。不要通过删除单词中的字母来缩写。经验法则是:如果某个缩写能在维基百科上列出,则可能是可接受的。一般来说,名称的描述性应与可见范围成反比。例如,在五行函数内使用n
可能是合适的,但在类作用域中就显得过于模糊。
注意某些广为人知的缩写是可接受的,例如用i
表示迭代变量,T
表示模板参数。
在下述命名规则中,"单词"指任何无需内部空格的英文书写单位,包括缩写(如首字母缩略词)。对于混合大小写(又称"驼峰式命名"或"帕斯卡命名法")的名称,每个单词首字母大写,建议将缩写视为独立单词进行大写处理,例如StartRpc()
而非StartRPC()
。
模板参数应遵循所属类别的命名风格:类型模板参数使用类型命名规则,非类型模板参数使用变量命名规则。
# 文件命名
文件名应全小写,可包含下划线(_
)或连字符(-
)。遵循项目现有约定。若无统一模式,优先使用下划线。
可接受的文件名示例:
· 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
。
所有类型(类、结构体、类型别名、枚举、类型模板参数)遵循相同命名规则。类型名称应首字母大写,每个新单词首字母大写,无下划线。例如:
// 类和结构体
class UrlTable {...
class UrlTableTester ...
struct UrlTableProperties ...
// 类型别名
typedef hash_map<UrlTableProperties*, std::string> PropertiesMap;
using PropertiesMap = hash_map<UrlTableProperties*, std::string>;
// 枚举
enum class UrlTableError {...
2
3
4
5
6
7
8
9
10
11
# Concept 命名(Concept Names)
概念命名规则与类型命名相同。
# 变量命名
变量名称(包括函数参数)和数据成员采用蛇形命名法(全小写,单词间用下划线分隔)。类的数据成员(不包括结构体)在名称末尾添加下划线。例如:a_local_variable,a_struct_data_member,a_class_data_member_。
普通变量命名
示例:
std::string table_name; // 正确 - 蛇形命名。
std::string tableName; // 错误 - 混合大小写。
2
类成员命名
类的数据成员命名为普通非成员变量的形式,但在末尾添加下划线。例如:
class TableInfo {
...
private:
std::string table_name_; // 正确 - 末尾有下划线。
static Pool<TableInfo>* pool_; // 正确。
};
2
3
4
5
6
结构体成员命名
结构体的数据成员命名为普通非成员变量的形式。它们没有类的数据成员所带的末尾下划线。例如:
struct UrlTableProperties {
std::string name;
int num_entries;
static Pool<UrlTableProperties>* pool;
};
2
3
4
5
有关何时使用结构体而非类的讨论,请参见Structs vs. Classes (opens new window)。
# 常量命名
常量命名遵循驼峰命名法,以大写字母开头,每个单词首字母大写,大写字母。在极少数无法使用大写区分的情况下,可以使用下划线作为分隔符。例如:
const int kDaysInAWeek = 7;
const int kAndroid8_0_0 = 24; // Android 8.0.0
2
所有具有静态存储期(即静态和全局变量,详细信息请参见存储持续时间 (opens new window))的此类变量都应按照这种方式命名,包括模板中不同实例化可能具有不同值的变量。对于其他存储类别的变量(例如自动变量),此惯例是可选的;否则,通常的变量命名规则适用。例如:
void ComputeFoo(absl::string_view suffix) {
// 以下两种方式均可接受。
const absl::string_view kPrefix = "prefix";
const absl::string_view prefix = "prefix";
...
}
void ComputeFoo(absl::string_view suffix) {
// 错误 - ComputeFoo 的不同调用会赋予 kCombined 不同的值。
const std::string kCombined = absl::StrCat(kPrefix, suffix);
...
}
2
3
4
5
6
7
8
9
10
11
12
# 函数命名
函数名称遵循驼峰命名法;访问器和修改器可以像变量一样命名。
通常,函数名应以大写字母开头,每个新单词首字母大写:
AddTableEntry()
DeleteUrl()
OpenFileOrDie()
2
3
(此命名规则同样适用于作为 API 一部分公开的类和命名空间作用域常量,这些常量旨在看起来像函数,因为它们是对象而非函数的这一事实是一个不重要的实现细节。)
访问器和修改器(获取和设置函数)可以像变量一样命名。这些通常与实际成员变量相对应,但并非必须。例如,int count() 和 void set_count(int count)。
# 命名空间命名
命名空间名称均为小写,单词之间用下划线分隔。顶级命名空间名称基于项目名称。避免嵌套命名空间与知名顶级命名空间发生冲突。
顶级命名空间的名称通常应是包含该命名空间代码的项目或团队的名称。该命名空间中的代码通常应位于与命名空间名称匹配的基本名称的目录中(或其子目录中)。
请记住,避免缩写名称规则适用于命名空间,就像适用于变量名称一样。代码在命名空间内部很少需要提及命名空间名称,因此通常不需要缩写。
避免创建与知名顶级命名空间匹配的嵌套命名空间。由于名称查找规则,命名空间名称的冲突可能导致意外的构建中断。特别是,不要创建任何嵌套的 std 命名空间。优先使用唯一的项目标识符(如 websearch::index,websearch::index_util),而不是容易发生冲突的名称(如 websearch::util)。还应避免命名空间嵌套过深(TotW #130 (opens new window))。
对于内部命名空间,请注意不要将其他代码添加到相同的内部命名空间中,以免导致名称冲突(团队内部的辅助程序通常相关,可能会导致冲突)。在这种情况下,使用文件名来创建唯一的内部名称很有帮助(例如 websearch::index::frobber_internal 可用于 frobber.h)。
# 枚举命名
枚举量(含作用域与非作用域枚举)应采用常量式命名,而非宏式命名。即使用kEnumName
而非ENUM_NAME
:
enum class UrlTableError {
kOk = 0,
kOutOfMemory,
kMalformedInput,
};
enum class AlternateUrlTableError {
OK = 0,
OUT_OF_MEMORY = 1,
MALFORMED_INPUT = 2,
};
2
3
4
5
6
7
8
9
10
11
2009年1月前曾使用宏式命名(全大写加下划线),但因易导致名称冲突,现已改为常量式命名。新代码应使用常量式命名。
# 宏命名
(您真的需要定义宏吗?)若必须定义,格式应为全大写加下划线,并添加项目特有前缀:MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE
。
请参阅[宏使用说明]占位链接章节,原则上应避免使用宏。若必须使用,应遵循全大写加下划线的命名规则,并添加项目前缀。
# 命名规则的例外情况
当命名实体与现有C/C++实体存在类比关系时,可沿用原有命名约定:
bigopen() // 仿照open()的函数命名
uint // typedef命名
bigpos // 仿照pos的结构体/类命名
sparse_hash_map // STL风格实体命名
LONGLONG_MAX // 仿照INT_MAX的常量命名
2
3
4
5
# 注释规范
注释对保持代码可读性至关重要。以下规则描述了应注释的内容和位置。但请谨记:虽然注释非常重要,但最好的代码是自文档化的。为类型和变量赋予合理的名称,远比使用晦涩名称后通过注释解释要好得多。
编写注释时,要为读者考虑:即下一位需要理解你代码的贡献者。请慷慨注释——下一位读者可能就是你自己!
# 注释风格
使用//
或/* */
语法,只要保持风格一致。
可以任选//
或/* */
语法,但//
更为常见。请保持注释方式和风格的一致性。
# 文件注释
每个文件开头应包含许可证样板文本(license boilerplate)。
若源文件(如.h
文件)声明了多个面向用户的抽象(公共函数、相关类等),应添加描述这些抽象集合的注释。包含足够的细节让后续作者了解哪些内容不适合放置于此。但关于单个抽象的详细文档应归属于这些抽象本身,而非文件层级。
例如,若为frobber.h
编写了文件注释,则无需在frobber.cc
或frobber_test.cc
中添加文件注释。反之,若在registered_objects.cc
中编写了没有对应头文件的类集合,则必须在registered_objects.cc
中添加文件注释。
法律声明和作者行
每个文件都应包含许可证样板文本。根据项目使用的许可证(如Apache 2.0、BSD、LGPL、GPL)选择适当的样板文本。
若对有作者行的文件进行重大修改,应考虑删除作者行。新文件通常不应包含版权声明或作者行。
# 结构体和类注释
每个不明显的类或结构体声明都应有 accompanying comment 描述它的用途和使用方式。
// 遍历巨无霸表的内容。
// 示例:
// std::unique_ptr<GargantuanTableIterator> iter = table->NewIterator();
// for (iter->Seek("foo"); !iter->done(); iter->Next()) {
// process(iter->key(), iter->value());
// }
class GargantuanTableIterator {
...
};
2
3
4
5
6
7
8
9
类注释
类注释应为读者提供足够的信息,以便了解如何以及何时使用该类,以及正确使用该类所需的任何其他注意事项。如果类有同步假设,请记录下来。如果类的实例可以被多个线程访问,需特别注意记录多线程使用相关的规则和不变量。
类注释通常是放置小段示例代码的好地方,演示类的简单和专注的使用。
当类的声明和定义足够分离(例如,.h 和 .cc 文件),描述类使用的注释应与接口定义放在一起;关于类操作和实现的注释应与类方法的实现放在一起。
# 函数注释
声明注释描述函数的使用方式(当不明显时);函数定义处的注释描述函数的操作。
# 函数声明
几乎每个函数声明前都应该有注释,描述函数的功能和使用方式。如果函数简单且明显(例如,类的简单属性访问器),可以省略这些注释。私有方法和在.cc 文件中声明的函数也不例外。函数注释应以"This function"作为隐含主语,并以动词短语开头;例如,"Opens the file",而不是"Open the file"。一般来说,这些注释不应描述函数如何完成任务,而应留给函数定义中的注释。
在函数声明的注释中需要提及的内容类型:
- 输入和输出是什么。如果函数参数名称用反引号(
backticks
)括起来,那么代码索引工具可能会更好地呈现文档。 - 对于类成员函数:对象是否在方法调用期间记住引用或指针参数。这对于构造函数的指针/引用参数很常见。
- 对于每个指针参数:是否允许为 null,以及如果为 null 会发生什么。
- 对于每个输出或输入/输出参数:该参数的状态会发生什么变化。(例如,状态是被追加还是被覆盖?)
- 如果函数的使用方式有任何性能影响。
示例:
// 返回一个迭代器,用于此表,定位在第一个词条
// 词法顺序大于或等于`start_word`。如果没有这样的
// 词条,则返回空指针。客户端在底层巨无霸表被销毁后
// 不得再使用该迭代器。
//
// 此方法等价于:
// std::unique_ptr<Iterator> iter = table->NewIterator();
// iter->Seek(start_word);
// return iter;
std::unique_ptr<Iterator> GetIterator(absl::string_view start_word) const;
2
3
4
5
6
7
8
9
10
然而,不要过于冗长或陈述完全明显的内容。
在记录函数重载时,重点关注重载本身的具体内容,而不是重复被重载函数的注释。在许多情况下,重载不需要额外的文档,因此不需要注释。
在注释构造函数和析构函数时,请记住,读你代码的人知道构造函数和析构函数的用途,因此像"销毁此对象"这样的注释是没有用的。记录构造函数如何处理其参数(例如,如果它们获得指针的所有权),以及析构函数执行的清理操作。如果这些操作很简单,可以省略注释。析构函数通常没有头部注释是很常见的做法。
# 函数定义
如果函数完成任务的方式有任何巧妙之处,函数定义应有解释性注释。例如,在定义注释中,你可以描述你使用的任何编程技巧,概述你经历的步骤,或者解释你为什么选择以这种方式实现函数,而不是使用可行的替代方案。例如,你可能会提到为什么函数的前半部分必须获取锁,但后半部分不需要。
# 变量注释
通常变量名称本身应足够描述其用途。某些情况下需要更多注释。
# 类数据成员
每个类数据成员(实例变量或成员变量)的用途必须明确。若存在类型和名称无法清晰表达的不变量(特殊值、成员关系、生命周期要求),必须添加注释。若类型和名称已足够明确(如int num_events_;
),则无需注释。
特别注意描述哨兵值(sentinel values)的存在和含义,如nullptr
或-1
(当含义不明显时)。例如:
private:
// 用于边界检查表访问。-1表示
// 我们尚不知道表有多少条目
int num_total_entries_;
2
3
4
# 全局变量
所有全局变量应有注释说明其作用、用途,以及(若不明显)为何需要全局存在。例如:
// The total number of tests cases that we run through in this regression test.
const int kNumTestCases = 6;
2
# 实现注释
在实现中应对复杂、非显而易见、有趣或重要的代码部分添加注释。
解释性注释
复杂或晦涩的代码块前应添加注释。
函数参数注释
当函数参数含义不明显时,可考虑以下补救措施: · 若参数是字面常量,且相同常量在多处函数调用中隐式假定为同一值,应使用命名常量明确约束并保证一致性 · 考虑将布尔参数改为枚举参数,使参数值自描述 · 对于有多个配置选项的函数,可定义类/结构体保存所有选项并传递实例。这种方式优点包括:调用处通过名称引用选项(提高清晰度),减少参数数量(提高可读性),添加新选项时无需修改调用处 · 用命名变量替代大型或复杂嵌套表达式 · 作为最后手段,在调用处用注释阐明参数含义
比较示例:
// 这些参数是什么?
const DecimalNumber product = CalculateProduct(values, 7, false, nullptr);
2
与:
ProductOptions options;
options.set_precision_decimals(7);
options.set_use_cache(ProductOptions::kDontUseCache);
const DecimalNumber product =
CalculateProduct(values, options, /*completion_callback=*/nullptr);
2
3
4
5
禁止项
不要陈述显而易见的内容。特别地,不要逐字描述代码行为,除非该行为对熟悉C++的读者而言非显而易见。应提供说明代码动机的高层级注释,或使代码自描述。
比较:
// Find the element in the vector. <-- 差:明显陈述!
auto it = std::find(v.begin(), v.end(), element);
if (it != v.end()) {
Process(element);
}
2
3
4
5
与:
// Process "element" unless it was already processed.
auto it = std::find(v.begin(), v.end(), element);
if (it != v.end()) {
Process(element);
}
2
3
4
5
自描述代码不需要注释。上例中的注释应是显而易见的:
if (!IsAlreadyProcessed(element)) {
Process(element);
}
2
3
# 标点、拼写和语法
注意标点、拼写和语法规范,因为精心编写的注释更易阅读。
注释应像叙述文本一样可读,使用正确的大小写和标点。多数情况下完整句子比片段更易读。较短注释(如行尾注释)可稍随意,但需保持风格一致。
虽然代码审查者指出逗号/分号误用可能令人沮丧,但保持源码高度清晰和可读性至关重要。正确的标点、拼写和语法有助于实现该目标。
# TODO 注释
使用 TODO 注释来标记临时代码、短期解决方案或尚可接受但并不完美的代码。
TODO 应包含全大写的 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.
2
3
4
如果您的 TODO 是“将来某个日期执行某事”的形式,请确保包含一个非常具体的日期(例如“在 2005 年 11 月之前修复”)或一个非常具体的事件(“当所有客户端都能处理 XML 响应时删除此代码”)。
# 格式规范
代码风格和格式设置具有较强的主观性,但项目成员使用统一风格能显著提升代码可读性。个人可能不完全认同某些格式规则,部分规则可能需要适应过程,但所有贡献者遵循统一风格规范对保证代码可读性和可维护性至关重要。
为辅助正确格式化代码,我们提供了适用于emacs的配置文件。
# 行宽限制
代码中每行文本长度不得超过80个字符。
我们理解该规定存在争议,但考虑到已有大量代码遵循此规范,我们认为保持一致性更为重要。
优点:
支持者认为强制调整编辑器窗口宽度有失礼貌,且没有必要使用更长行宽。部分开发者习惯并排多个代码窗口,客观上无法扩展行宽。开发者通常基于特定行宽配置工作环境,而80列宽度是历史传承的标准,无需变更。
缺点:
变革倡导者认为更宽的行距可提升代码可读性。80列限制是上世纪60年代大型机时代的遗留产物,现代宽屏设备完全能够展示更长代码行。
决策:
严格遵循80字符最大行宽限制,但以下情况允许例外:
· 注释行若拆分会影响可读性、复制粘贴或自动链接功能——例如包含超长命令示例或URL的情形
· 字符串字面量因包含URI等关键元素,或内嵌语言格式,或多行文本换行符具有语义价值(如帮助信息)而无法拆分。此类情形下拆分将损害可读性、可搜索性和链接跳转等功能。除测试代码外,此类字面量应置于文件顶部的命名空间作用域。若Clang-Format等工具无法识别不可拆分内容,请临时禁用相关工具。
(需在字面量的可用性/可搜索性与周边代码可读性间取得平衡)
● 包含语句(include statement)
● 头文件守卫(header guard)
● using声明(using-declaration)
# 非ASCII字符
非ASCII字符应谨慎使用,且必须采用UTF-8编码格式。
原则上不应在源码中硬编码用户可见文本(包括英文),故非ASCII字符使用场景有限。但在以下情况允许例外:
· 代码解析外来数据文件时,可能需要硬编码数据文件中的非ASCII分隔符
· 单元测试代码(无需本地化)可能包含非ASCII字符串
此类情况应使用UTF-8编码,因该编码已被多数工具支持。十六进制编码亦可接受,特别是在提升可读性时推荐使用——例如"\xEF\xBB\xBF"
或更简洁的"\uFEFF"
表示零宽度不间断空格字符,若直接使用UTF-8编码该字符在源码中不可见。
应尽量避免使用u8
前缀。从C++20开始其语义发生重大变化(生成char8_t
数组而非char
数组),且C++23标准将再次调整。
不应使用char16_t
和char32_t
字符类型,因其专用于非UTF-8文本。同理也不推荐使用wchar_t
(除非编写与大量使用wchar_t
的Windows API交互的代码)。
# 空格与制表符
统一使用空格缩进,每次缩进2个空格。
代码缩进必须使用空格,禁止使用制表符。请配置编辑器在输入制表符时自动转换为空格。
# 函数声明与定义
返回类型应与函数名同行,参数列表若可容纳也保持同行。若需换行,参数列表换行方式与函数调用时的参数换行规则一致。
标准函数格式示例:
ReturnType ClassName::FunctionName(Type par_name1, Type par_name2) {
DoSomething();
}
2
3
超长函数名换行示例:
ReturnType ClassName::ReallyLongFunctionName(Type par_name1, Type par_name2,
Type par_name3) {
DoSomething();
}
2
3
4
首个参数无法容纳时的换行示例:
ReturnType LongClassName::ReallyReallyReallyLongFunctionName(
Type par_name1, // 4空格缩进
Type par_name2,
Type par_name3) {
DoSomething();
}
2
3
4
5
6
关键规范要点: ● 选择恰当的参数名称 ● 仅当参数未在函数体中使用时可省略参数名 ● 返回类型与函数名无法同行时,在二者之间换行 ● 函数声明/定义的返回类型后换行时不缩进 · 左圆括号始终与函数名同行 · 函数名与左圆括号间不留空格 · 参数列表与圆括号间不留空格 · 左花括号始终位于函数声明末行行尾,不单独起行 · 右花括号可单独成行或与左花括号同行 • 右圆括号与左花括号间保留空格 • 尽可能对齐所有参数 · 基础缩进为2个空格 • 换行参数使用4空格缩进
上下文明确的未使用参数可省略:
class MyClass {
public:
void FunctionWithUnusedParams(int used_param, int) {
// 未使用第二个参数
}
};
2
3
4
5
6
存在歧义的未使用参数应在函数定义中注释变量名:
void Callback(int /*unused_param*/) {}
属性和展开为属性的宏应置于函数声明/定义最前端,位于返回类型之前:
ABSL_ATTRIBUTE_NOINLINE void ExpensiveFunction();
# Lambda表达式
参数列表和函数体遵循常规函数格式,捕获列表按逗号分隔列表规则处理。
按引用捕获时,&
符号与变量名间不留空格:
int x = 0;
auto x_plus_n = [&x](int n) -> int { return x + n; };
2
简短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());
2
3
4
5
# 浮点型字面量
浮点数字面量必须始终包含小数点,且小数点前后均应有数字,即使采用科学计数法。
统一格式可提升可读性,避免与整型字面量混淆,同时防止科学计数法中的E
/e
被误认为十六进制数字。允许使用整型字面量初始化浮点变量(假设变量类型可精确表示该整数),但需注意科学计数法表示的数不属于整型字面量。
错误示例:
float f = 1.f;
long double ld = -.5L;
double d = 1248e6;
2
3
正确示例:
float f = 1.0f;
float f2 = 1.0; // Also OK
float f3 = 1; // Also OK
long double ld = -0.5L;
double d = 1248.0e6;
2
3
4
5
# 函数调用
函数调用可单行书写,也可在括号处换行,或在新行以4空格缩进参数并保持该缩进。若无特殊考虑,应使用最少行数,适当将多个参数置于同一行。
函数调用格式如下:
bool result = DoSomething(argument1, argument2, argument3);
若参数无法单行容纳,应分行书写,后续行与首个参数对齐。勿在左括号后或右括号前添加空格:
bool result = DoSomething(averyveryveryverylongargument1,
argument2, argument3);
2
也可选择将所有参数置于后续行并4空格缩进:
if (...) {
...
...
if (...) {
bool result = DoSomething(
argument1, argument2, // 4空格缩进
argument3, argument4);
...
}
2
3
4
5
6
7
8
9
除非存在特定可读性问题,应将多个参数置于单行以减少行数。部分人认为每行仅一个参数更易读且便于编辑,但我们优先考虑可读性而非编辑便利性,多数可读性问题可通过以下方法解决。
若单行多参数因表达式复杂导致可读性下降,可创建具描述性名称的变量存储参数:
int my_heuristic = scores[x] * y + bases[x];
bool result = DoSomething(my_heuristic, x, y, z);
2
或为复杂参数单独换行并添加注释:
bool result = DoSomething(scores[x] * y + bases[x], // 分数启发式计算
x, y, z);
2
若某参数单独分行显著提升可读性,可为之单独分行。此决定应基于具体参数而非通用规则。
某些情况下参数构成特定结构,此时可按结构格式化:
// 用3x3矩阵变换部件
my_widget.Transform(x1, x2, x3,
y1, y2, y3,
z1, z2, z3);
2
3
4
# 大括号初始化列表格式
大括号初始化列表格式应与函数调用完全一致。
若列表前有名称(如类型或变量名),格式等同于该名称的函数调用括号。若无名称,则视为零长度名称。
// 单行大括号初始化示例
return {foo, bar};
functioncall({foo, bar});
std::pair<int, int> p{foo, bar};
2
3
4
// 需要换行时
SomeFunction(
{"假设{前有零长度名称"},
some_other_function_parameter);
SomeType variable{
some, other, values,
{"假设{前有零长度名称"},
SomeOtherType{
"超长字符串需换行处理",
some, other, values},
SomeOtherType{"稍短字符串",
some, other, values}};
SomeType variable{
"过长无法单行容纳的字符串"};
MyType m = { // 也可在{前换行
superlongvariablename1,
superlongvariablename2,
{short, interior, list},
{interiorwrappinglist,
interiorwrappinglist2}};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 循环与分支语句
循环或分支语句由以下组件构成:
- 一个或多个语句关键字(如
if
、else
、switch
、while
、do
、for
)。 - 一个条件或迭代说明符,位于括号内。
- 一个或多个受控语句或语句块。
格式要求:
- 语句组件间用单个空格分隔(非换行)。
- 条件或迭代说明符内部,分号与下一标记间留空格(或换行),但右括号或分号本身除外。
- 条件或迭代说明符内部,左括号后及右括号前不留空格。
- 所有受控语句置于代码块(即使用花括号)。
- 代码块内部,左花括号后立即换行,右花括号前立即换行。
if (condition) { // 正确 - 括号内无空格,{前有空格
DoOneThing(); // 正确 - 2空格缩进
DoAnotherThing();
} else if (int a = f(); a != 3) { // 正确 - 右花括号换行,else同一行
DoAThirdThing(a);
} else {
DoNothing();
}
// 正确 - 循环适用相同规则
while (condition) {
RepeatAThing();
}
// 正确 - do-while适用相同规则
do {
RepeatAThing();
} while (condition);
// 正确 - for循环适用相同规则
for (int i = 0; i < 10; ++i) {
RepeatAThing();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
错误示例:
if(condition) {} // 错误 - if后缺少空格
else if ( condition ) {} // 错误 - 括号与条件间有空格
else if (condition){} // 错误 - {前缺少空格
else if(condition){} // 错误 - 多处空格缺失
for (int a = f();a == 10) {} // 错误 - 分号后缺少空格
// 错误 - if...else未全用花括号
if (condition)
foo;
else {
bar;
}
// 错误 - if语句过长未用花括号
if (condition)
// 注释
DoSomething();
// 错误 - if条件过长未用花括号
if (condition1 &&
condition2)
DoSomething();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
历史原因允许一个例外:若整个语句可单行显示(此时右括号与受控语句间有空格)或两行显示(此时右括号后换行且无花括号),则可省略花括号或换行。
// 正确 - 单行容纳
if (x == kFoo) { return new Foo(); }
// 正确 - 可省略花括号
if (x == kFoo) return new Foo();
// 正确 - 条件与主体各占一行
if (x == kBar)
Bar(arg1, arg2, arg3);
2
3
4
5
6
7
8
9
此例外不适用于if...else
或do...while
等复合语句。
// 错误 - if...else缺失花括号
if (x) DoThis();
else DoThat();
// 错误 - do...while缺失花括号
do DoThis();
while (x);
2
3
4
5
6
7
仅在语句简短时使用此风格,复杂条件或语句建议使用花括号。部分项目要求始终使用花括号。
switch
语句的case
块可选择是否使用花括号。若使用,格式如下:
switch (var) {
case 0: { // 2空格缩进
Foo(); // 4空格缩进
break;
}
default: {
Bar();
}
}
2
3
4
5
6
7
8
9
空循环体应使用空花括号或continue
,而非单个分号。
while (condition) {} // 正确 - {}表明无逻辑
while (condition) {
// 注释也可
}
while (condition) continue; // 正确 - continue表明无逻辑
while (condition); // 错误 - 易与do-while混淆
2
3
4
5
6
# 指针与引用表达式
成员访问运算符(.
或->
)周围无空格。指针运算符后无空格。
正确示例:
x = *p;
p = &x;
x = r.y;
x = r->y;
2
3
4
注意:
- 成员访问时运算符周围无空格。
- 指针运算符(
*
或&
)后无空格。
声明指针或引用时,星号/与号前后空格可任选。尾空格风格在某些情况(模板参数等)省略空格。
// 空格前置风格
char *c;
const std::string &str;
int *GetPointer();
std::vector<char *>
// 空格后置风格(或省略)
char* c;
const std::string& str;
int* GetPointer();
std::vector<char*> // 注意*与>间无空格
2
3
4
5
6
7
8
9
10
11
同一文件应保持一致风格。修改现有文件时应沿用原有风格。
允许(但不推荐)在同一声明中定义多个变量,但含指针或引用修饰符时禁止。此类声明易产生歧义。
// 若有助于可读性可接受
int x, y;
int x, *y; // 禁止 - 多变量声明含&/*
int* x, *y; // 禁止 - 多变量声明含&/*且空格不一致
char * c; // 错误 - *两侧均有空格
const std::string & str; // 错误 - &两侧均有空格
2
3
4
5
6
7
# 布尔表达式
布尔表达式超过标准行宽时,换行方式应保持一致性。建议将逻辑运算符置于行尾:
if (this_one_thing > this_other_thing &&
a_third_thing == a_fourth_thing &&
yet_another && last_one) {
...
}
2
3
4
5
可谨慎添加括号以增强可读性,但避免过度使用。始终使用&&
、~
等符号运算符,而非and
、compl
等单词运算符。
# 返回值
勿在return
表达式外不必要地添加括号。仅在需要明确优先级时使用括号:
return result; // 简单情况无需括号
// 括号用于复杂表达式可读性
return (some_long_condition &&
another_condition);
return (value); // 错误 - 无需多余括号
return(result); // 错误 - return非函数调用
2
3
4
5
6
7
8
# 变量与数组初始化
可选择=
、()
或{}
初始化:
int x = 3;
int x(3);
int x{3};
std::string name = "Some Name";
std::string name("Some Name");
std::string name{"Some Name"};
2
3
4
5
6
7
使用{...}
初始化含std::initializer_list
构造函数的类型时需谨慎。非空{}
优先调用std::initializer_list
构造函数,空{}
则调用默认构造函数。若需强制调用非std::initializer_list
构造函数,应使用括号。
std::vector<int> v(100, 1); // 含100个1的向量
std::vector<int> v{100, 1}; // 含2个元素的向量:100和1
2
花括号形式可防止整数类型的窄化转换:
int pi(3.14); // 正确 - pi == 3
int pi{3.14}; // 编译错误 - 窄化转换
2
# 预处理指令
预处理指令的#
号必须位于行首。
即使预处理器指令位于缩进代码块内,指令也应从行首开始。
// 好的 - 指令从行首开始
if (lopsided_score) {
#if DISASTER_PENDING // 正确 -- 从行首开始
DropEverything();
# if NOTIFY // 可以但不强制 -- # 后面有空格
NotifyClient();
# endif
#endif
BackToNormal();
}
2
3
4
5
6
7
8
9
10
// 不好的 - 缩进的指令
if (lopsided_score) {
#if DISASTER_PENDING // 错误!"#if" 应该从行首开始
DropEverything();
#endif // 错误!不要缩进 "#endif"
BackToNormal();
}
2
3
4
5
6
7
# 类格式
按 public、protected 和 private 的顺序排列各部分,每部分缩进一个空格。
类定义的基本格式如下(不包括注释,注释的相关讨论见类注释部分):
class MyClass : public OtherClass {
public: // 注意缩进一个空格!
MyClass(); // 正常缩进两个空格。
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_;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
注意事项: • 基类名应与子类名在同一行,受80列限制约束。 • public:、protected: 和 private: 关键字应缩进一个空格。 • 除了第一次出现外,这些关键字之前应有一个空行。此规则在小型类中是可选的。 • 这些关键字之后不要留空行。 • public 部分应排在第一,接着是 protected 部分,最后是 private 部分。 • 有关每个部分内声明顺序的规则,请参见声明顺序部分。
# 构造函数初始化列表格式
构造函数初始化列表可以全部放在一行,或者后续行缩进四个空格。
可接受的初始化列表格式如下:
// 当所有内容都放在一行时:
MyClass::MyClass(int var) : some_var_(var) {
DoSomething();
}
2
3
4
// 如果签名和初始化列表不能都放在一行,
// 你必须在冒号前换行并缩进4个空格:
MyClass::MyClass(int var)
: some_var_(var), some_other_var_(var + 1) {
DoSomething();
}
2
3
4
5
6
// 当列表跨越多行时,将每个成员放在单独的一行
// 并对齐它们:
MyClass::MyClass(int var)
: some_var_(var), // 4 空格缩进
some_other_var_(var + 1) { // 对齐
DoSomething();
}
2
3
4
5
6
7
// 与任何其他代码块一样,如果大括号可以放在同一行,则关闭大括号可以放在同一行:
MyClass::MyClass(int var)
: some_var_(var) {}
2
3
# 命名空间格式
命名空间的内容不缩进。
命名空间不会增加额外的缩进级别。例如,应使用:
namespace {
void foo() { // 正确。命名空间内没有额外的缩进。
...
}
} // namespace
2
3
4
5
6
7
不要在命名空间内缩进:
namespace {
// 错误!应该缩进的地方没有缩进。
void foo() {
...
}
} // namespace
2
3
4
5
6
7
8
# 水平空白
水平空白的使用取决于位置。行末不要添加多余的空白。
# 一般规则
int i = 0; // 行末注释前有两个空格。
void f(bool b) { // 开放括号前应始终有一个空格。
...
int i = 0; // 分号通常前面没有空格。
// 大括号内的初始化列表中添加空格是可选的。如果使用了空格,
// 请在两边都添加!
int x[] = { 0 };
int x[] = {0};
// 继承和初始化列表中的冒号周围添加空格。
class Foo : public Bar {
public:
// 对于内联函数实现,在大括号和实现本身之间添加空格。
Foo(int b) : Bar(), baz_(b) {} // 空的大括号内没有空格。
void Reset() { baz_ = 0; } // 大括号和实现之间用空格分隔。
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
添加行尾空格可能会在合并时给其他编辑同一文件的人带来额外的工作,删除现有的行尾空格也是如此。因此:
- 不要引入行尾空格。
- 如果已经在修改某一行,或者在单独的清理操作中(最好是在没有人其他人在文件上工作时),可以删除行尾空格。
# 循环和条件语句
if (b) { // 条件和循环中的关键字后添加一个空格。
} else { // else 周围添加空格。
}
while (test) {} // 括号内通常没有空格。
switch (i) {
for (int i = 0; i < 5; ++i) {
// 循环和条件中的括号内可以添加空格,但这很少见。保持一致。
switch ( i ) {
if ( test ) {
for ( int i = 0; i < 5; ++i ) {
// for 循环的分号后始终添加一个空格。分号前可以添加空格,但这很少见。
for ( ; i < 5 ; ++i) {
...
// 基于范围的 for 循环中,冒号前后始终添加空格。
for (auto x : counts) {
...
}
switch (i) {
case 1: // switch case 中的冒号前不要添加空格。
...
case 2: break; // 如果冒号后有代码,则添加一个空格。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 运算符
// 赋值运算符周围始终添加空格。
x = 0;
// 其他二元运算符周围通常添加空格,但删除因子周围的空格是可以的。括号内部不应该有填充。
v = w * x + y / z;
v = w*x + y/z;
v = w * (x + z);
// 单目运算符和其参数之间不要添加空格。
x = -5;
++x;
if (x && !y)
...
2
3
4
5
6
7
8
9
10
11
12
13
# 模板和强制转换
// 尖括号(< 和 >)内不要添加空格,强制转换前也不要添加空格。
std::vector<std::string> x;
y = static_cast<char*>(x);
// 类型和指针之间的空格是可以的,但要保持一致。
std::vector<char *> x;
2
3
4
5
6
# 垂直空白
尽量减少垂直空白的使用。
这更像是一个原则,而不是一个规则:在不需要的地方不要使用空行。特别是,函数之间不要添加超过一两行的空行,不要在函数开头添加空行,函数结尾也不要添加空行,并且在代码块内部使用空行时要有所节制。代码块内的空行就像散文中的段落分隔一样:视觉上分隔两个想法。
基本原则是:一个屏幕上能显示的代码越多,程序的控制流程就越容易理解和跟踪。有目的地使用空白来分隔流程。
以下是一些关于何时可能有用空行的规则:
- 在函数开头或结尾添加空行无助于可读性。
- 在一系列 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,且仅包含宏,不需要符合这些风格指南。
# 翻译说明
本文档翻译自最新《Google C++ Style Guide (opens new window)》,作者张小方,更新于2025年3月7日,内容涵盖C++17和C++20。
限于作者水平有限,翻译中有不妥的地方,可以加小方微信 cppxiaofang
反馈和交流。
本文首发于【CppGuide】公众号,未经授权,不得转载。
英文链接:https://google.github.io/styleguide/cppguide.html (opens new window)
小方微信公众号【CppGuide】: