Google C++ Style Guide 中文版 原始文档:https://google.github.io/styleguide/cppguide.html 基于版本:3.277(Aug 15, 2018 最后提交)
C++ 是 Google 许多开源项目所使用的主要编程语言。正如每个 C++ 程序员所知的那样,这门语言有许多强大的特性,但是随之而来的是其复杂性的增加,这反过来又使代码更容易引入 Bug,难以阅读和维护。
这份指南的目标是通过详细描述在写 C++ 代码时什么可以做什么不可以做来管理这种复杂性。这些规则可以保持代码基本的可控性,同时又允许程序员高效地使用 C++ 的语言特性。
风格,或可读性,就是我们管理 C++ 代码的约定。“风格”这个术语有点不恰当,因为这些约定所覆盖的范围远远不只源码文件的格式。
Google 开发的大多数开源项目都符合本指南的要求。
注意本指南不是 C++ 教程:我们假设读者都熟悉 C++。
我们为什么要写这份文档?
我们认为本指南应该服务于几个核心目标。这些目标是每一条规则为什么存在的根本原因。通过把这些想法列在前面,我们希望广大社区能展开讨论,并清楚地了解为什么会有这条规则以及为什么会做出这样的决定。如果我们了解每条规则背后的目标,每个人就会清楚什么时候可以放弃一条规则(有时候这是可以的),以及修改一条规则时需要什么论证或替代方案。
本风格指南当前的目标如下:
-
规则要“有用” 规则必须有足够的好处才能使所有的工程师记住它。“好处”是和没有这条规则下的代码量相关的,因此一条针对非常有害的但是人们通常不会用的实践的规则,其实“好处”就非常有限。这条原则主要解释了那些我们没有写的规则,而不是那样我们写下的:例如,
goto
违反了下面的许多原则,但是它已经非常罕见了,所以本指南中就没有讨论它。 -
为读者优化,而不是为作者
我们的代码库(以及提交的独立组件)是要持续相当一段时间的。因此阅读代码的时间要比写代码的时间长。显然我们要优化的是我们的普通水平工程师阅读、维护和调试代码的体验,而不是只为了让写代码更轻松。“给读者留下线索”就是这条原则的子项:当代码片中出现不常见的事情时(如转移指针所有权),就在这个点上给读者留下文本提示(
std:unique_ptr
在调用处就能清楚展示所有权的转移),这是非常有价值的。 -
与现有代码保持一致
在代码库中使用一致的风格让我们可以关注其它(更重要)的问题。一致性还允许使用自动化:只有你的代码与工具预期一致时,它才能格式化你的代码或自动调整头文件引用。很多情况下,“一致性”规划都可以归结为“选一个别纠结”,人们在一些点上的争论会抵消允许灵活性所带来的潜在价值。(译者:有时候只是规定好这么做就行了,没有必要去讨论和纠结以满足不同人审美。)
-
在适当的时候与C++大社区保持一致
与其它使用 C++ 的组织保持一致,与在代码库中保持一致的价值是一样的。如果 C++ 标准中的一个特性解决了一个问题,或一种惯用法被广泛接受,那就应该使用它们。但是有时标准特性或惯用法有缺陷,或设计时没有考虑我们的代码库的需求。这时(下面会描述),就应该限制或禁止标准特性的使用。有时候我们倾向于使用自己或第三方库而不是使用标准库,是因为这样有特别的优势,或是因为将代码库转换到标准接口的价值不够。
-
避免令人意外或危险的构造
C++ 有一些特别,比乍看上去更令人惊讶或危险。一些风格指南限制就是为了防止落入这些陷阱。指南中对这类限制很坚持,因为放弃这些规则会直接危及程序的正确性。
-
避免普通 C++ 程序员难以驾驭的构造
C++ 有一些特性会在代码中引入复杂性,从而没有普适性。在广泛使用的代码中,使用复杂的语言构造更容易被接受,因为更复杂实现带来的好处被多次使用,而理解这些复杂性的代价在代码库其它地方不需要再次支付。如果有疑问,想放弃这类规则的人可以去咨询项目领导。这对我们代码库特别重要,因为代码所有权和团队成员是处于变动中的:即使现在每个使用者都能理解,也不能保证几年后还能这样。
-
要考虑到我们的规模
作为一个规模超过 1 亿行的代码库,一个工程师的一些错误或简化代价可能会很高。比如,避免污染全局命名空间特别重要:如果每个人都往全局命名空间里放东西,那么上亿行代码中的命名冲突很难避免。
-
在必要时对优化做出让步
即使和本文档中其它原则冲突,有时候性能优化也是必要且合理的。
本文档旨在使用合理的限制提供最大限度的指导。一如既往,常识和良好的品味应该占上风。关于这一点,我们指的是整个 Google C++ 社区的既定传统,而不是您个人或团队的偏好。要怀疑并且不去使用那些聪明或不寻常的构造 :没有禁止不等同于许可。运用你的判断,如果不确定,就赶紧要求你的项目负责人提供额外的信息。
当前代码应该符合 C++17,即不应该使用 C++2x 的特性。本指南所针对的 C++ 版本会随时间(积极地)进步。
不要使用 非标准扩展 。
在工程中使用 C++14 和 C++17 特性之前,要考虑其它环境的可移植性。
通常每个.cc
文件都要有个对应的.h
文件。也有一些常见的例外,如单元测试以及只包含一个main()
函数的小.cc
文件。
正确使用头文件可以使用你代码的可读性、大小和性能都有很大的改观。
下面的规则会引导你规避使用头文件的各种陷阱。
头文件应该是自包含的(独立编译),并且以.h
为后缀。用以包含的非头文件应以inc
为后缀,并要谨慎使用。
所有头文件都应该是自包含的。在包含该头文件时,对用户和重构工具不应该有特殊要求。特别是,一个头文件应该有头文件保护,并包含它自己所需求的全部其它头文件。
要把模版和内联函数的定义和声明放在同一个文件中。这些结构的定义必须被包含在使用它们的.cc
文件中,否则在某些配置下程序链接会失败。如果声明和定义在不同的文件中,对前者的包含也应该间接包含后者。不要把这些定义移动到单独的头文件中(-inl.h
),这种实践以前很常用,但是不再被允许了。(译者:在上一版3.237中,这还是被提倡的。)
作为例外,如果为一个模版的所有参数集合显式实例化,或是一个类的私有化实现细节时(译者:类的私有化成员),那么允许将其定义在那个唯一的实例化这个模版的cc
文件中。
有极少数的情况,一个文件被设计成非自包含的。这通常中为了在一个不寻常的地方被包含,如其它文件中间。他们可能不使用头文件保护,也可能不包含它们的依赖。使用.inc
作为这种文件的后缀。尽量少使用,尽可能使用自包含的头文件。
所有的头文件都应该使用#define
保护起来以阻止多次包含。符号名规则应该是<项目>_<路径>_<文件>_H_
。
为了保证唯一性,它们应该基于在工程源代码树中的全路径。如,工程foo
中的文件foo/src/bar/baz.h
应该有以下的保护:
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
之前的版本这一节叫“头文件依赖”,对前置声明的好处和使用讲得很多。这一版指南调整了规则:明确禁止前置声明函数,不再要求使用前置声明,不鼓励前置声明模板。基本上开发中不用刻意考虑前置声明了。
update[2019.8]: 更不推荐使用前置声明了。
尽可以避免使用前置声明,#include
你需要的头文件就行了。
“前置声明”是类、函数或模版的声明,但不带相关定义。
- 前置声明可以节省编译时间,而
#include
会强制编译器打开更多文件并处理更多输入。 - 前置声明可以节省不必要的重复编译,而
#include
会因为头文件的改变更频繁地强制你的代码重新编译。
- 前置声明会隐藏信赖,使用户的代码跳过在头文件改变必须的重新编译。
- 对库的后续修改可能会破坏前置声明。函数和模版的前置声明会阻止头文件的拥有者对 API 作一些兼容性修改,如扩展形参类型,给模版参数增加默认值,或者迁移到新的命名空间等。(译者:如果用
#include
,这些修改都是允许的) - 前置声明来自命名空间
std::
的符号时,其行为未定义。 - 可能难以确定在给定的代码片中使用前置声明还是完整包含头文件。使用前置声明代替
#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*)
。(译者:如果使用前置声明,不知道D是B的子类) - 相比简单包含头文件,前置声明多个符号更冗长。
- 为了前置声明构造的代码(如使用指针成员代替对象成员)可能更慢更复杂。
- 避免前置声明定义在其它工程中的实体。
- 当使用一个声明在头文件中的函数时,只要
#include
这个头件就行了。 - 当使用一个类模板时,优先包含其头文件。
关于什么时候包含一个头文件,请参考名称以及包含顺序。
只内联小于 10 行代码的小函数。
你可以以一种方式来声明函数,以允许编译器将它们就地展开,而不是通过普通函数调用机制来调用。
只要函数足够小,将其内联可以产生更高效的目标代码。对于存取函数和一些性能关键的小函数,可以放心使用内联。
过度使用内联会导致程序变慢。内联可能使代码变大或变小,这取决于函数大小。内联一个很小的存取函数通常会减小代码尺寸,但是内联一个很大的函数则可能使代码体积暴增。利用指令缓存,在现代处理器上小代码通常跑得更快。
一个合理的经验准则是:不要内联超过 10 行的函数。要警惕析构函数,由于隐含成员和基类析构函数的调用,它们往往比表面上看起来要长。
另一个有用的经验法则:内联那些含有循环或 switch 语句的函数通常是不划算的(除非,这循环或 switch 语句在正常情况下不会执行到)。
还有一点很重要,即使函数被声明为内联的,它们也不一定总是会被内联;比如,虚函数和递归函数就不能正常内联。通常递归函数不应该被内联。内联一个虚函数的主要原因是要将其定义放在类中,这是为了方便或是文档化其自身的行为,比如存取函数。
使用标准的头文件包含顺序增强可读性,并避免隐藏依赖:相关头文件,C 库,C++ 库,其它库头文件,本项目库头文件。
所有本工程的头文件应该按源代码目录树结构排列,避免使用 UNIX 的特殊缩写 .(
当前目录)或 ..
(父目录)。比如,google-awesome-project/src/base/logging.h
应该这样被包含:
#include "base/logging.h"
在dir/foo.cc
或dir/foo_test.cc
中,其主要目的是实现或测试dir2/foo2.h
中的声明的东西, 你的包含次序应该是这样的:
dir2/foo2.h
- 空行
- C 系统文件
- C++ 系统文件
- 空行
- 其它库的
.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
)是特价的,用什么都可以,便是要优先和已有代码保持一致。
在上面的每一块中,应该按字母顺序排列。注意旧代码可以不符合这条规则,但应该在方便的时候修改它。
你应该包含你所依赖的所有符号的头文件,除非是极端的有前置声明的情况。如果你依赖bar.h
中的符号,无论是否已经包含了包含bar.h
的foo.h
,都应该再直接包含bar.h
,除非foo.h
显式说明了它会为你提供bar.h
的符号。不过,任何在"相关头文件"中的头文件都不需要在对应的cc
文件中再包含一次(如foo.cc
可以依赖foo.h
中包含的头文件)。
举个例子,google-awesome-project/src/foo/internal/fooserver.cc
中的包含次序应看起来像这样:
#include "foo/server/fooserver.h"
#include <sys/types.h>
#include <unistd.h>
#include <vector>
#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/server/bar.h"
有时,系统相关的代码需要条件包含。这样的代码可以把条件包含放到其它的包含之后。当然,要保持系统相关代码短小并局部化。如:
#include "foo/public/fooserver.h"
#include "base/port.h" // For LANG_CXX11.
#ifdef LANG_CXX11
#include <initializer_list>
#endif // LANG_CXX11
除了极少数的例外,代码都应该放在命名空间中。命名空间应该使用基于工程名的唯一名字,尽可能使用工程的路径。不要使用using
指令(如using namespace foo
)。不要使用内联命名空。对于匿名空间,参见匿名空间和静态变量
命名空间把全局作用域划分为独立的具名作用域,以此可以有效防止全局作用域中的命名冲突。
命名空间提供了一种可以在大型程序中避免命名冲突的方案,同时还允许大部分代码都能使用简短的命名。
比如,如果两个工程的全局作用域中都有一个名为Foo
的类,这在编译或运行时就可能会造成冲突。如果每个工程都把它们的代码放在一个命名空间中,这样project1::Foo
和project2:Foo
就是两个不同的符号,自然没有冲突,而两个工程内的代码又可以不加前缀直接引用Foo
。
内联命名空间自动将其名称放置到封闭作用域中。举个例子,考虑下面的代码片:
namespace outer {
inline namespace inner {
void foo();
} // namespace inner
} // namespace outer
表达式outer::inner::foo()
和outer::foo()
是可替换的。内联命名空间主要是为了跨版本的 ABI 兼容性。
命名空间使确定一个名称的确切指向机制变得复杂,因些会带来混乱。
特别是内联命名空间,更可能混淆,因为名称并没有被强制限制在其声明的命名空间内。它们只有在作为更大版本策略的一部分时才有用。
有时,需要使测完全形式的名称重复引用一些符号,当命名空间嵌套很深时,就会变得很乱。
要根据以下策略使用命名空间:
-
要遵循命名空间名称中的规则。
-
要像示例中那样,在命名空间结束处添加注释。
-
命名空间要包含头文件包含、
gflags
定义/声明以及其它命名空间中类的前置声明之外的整个源文件。// In the .h file namespace mynamespace { // All declarations are within the namespace scope. // Notice the lack of indentation. class MyClass { public: ... void Foo(); }; } // namespace mynamespace
// In the .cc file namespace mynamespace { // Definition of functions is within scope of the namespace. void MyClass::Foo() { ... } } // namespace mynamespace
更复杂的
.cc
文件可能还有其它细节,如标志或using
声明。#include "a.h" DEFINE_FLAG(bool, someflag, false, "dummy 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
指令来暴露一个命名空间中的所有名称。// Forbidden -- This pollutes the namespace. using namespace foo;
-
不要在头文件的命名空间中使测命名空间别名,除非是在显式标记为内部的命名空间中,这是因为在头文件的命名空间中引入的任何东西都会成为 API 的一部分。
// Shorten access to some commonly used names in .cc files. namespace baz = ::foo::bar::baz;
// Shorten access to some commonly used names (in a .h file). namespace librarian { namespace impl { // Internal, not part of the API. namespace sidetable = ::pipeline_diagnostics::sidetable; } // namespace impl inline void my_inline_function() { // namespace alias local to a function (or method). namespace baz = ::foo::bar::baz; ... } } // namespace librarian
-
不要使用内联命名空间。(译者:关于内联命名空间,这文章讲得很明白https://blog.csdn.net/craftsman1970/article/details/82872497)
.cc
文件中那些不被外部引用的定义,应当放在匿名空间中或将它们声明成static
的。但是不要在头文件中使用这两种结构。
所有放入匿名空间中的声明都有内部链接性。静态函数或变量也具有内部链接性。这也就意味着在其它文件都不能它们。如果其它文件中声明了同名的东西,这两个实体也是完全独立的。
对于那些不需要在别处引用的代码,鼓励在.cc
文件中使用内部链接性,但是不要是.h
文件中使用。
匿名空间和具名空间格式一样,在结尾的注释中省略空间名就行:
namespace {
...
} // namespace
优先把非成员函数放到命名空间中,尽量少使用全局函数。不要简单地用一个类去包装静态函数。类的静态方法通常应该与类的实例与类的静态数据密切相关。
非成员函数和静态成员函数在某些情况很有用。把非成员函数放入一个命名空间可以避免污染全局命名空间。
非成员函数和静态成员函数作为一个新类的成员时意义更多,尤其是在它们访问外部资源或有重大依赖关系时。
有时候定义一个与类实例不挂钩的函数是有用的。这样一个函数可以是静态成员也可以是一个非成员函数。非成员函数不应该依赖外部变量,并且应该总是被放在一个命名空间中。不要仅仅为了封装静态成员函数而去创建一个类,这和给这些函数一个公共前缀没有区别,并且这种封装通常是不必要的。
如果你定义一个非成员函数,而这个函数只在本.cc
文件使用,那么使用内部链接性来限制其作用域。
尽量将函数变量放在一个较小的作用域内,并在声明时进行初始化。
C++ 允许你在函数的任何位置声明变量。我们鼓励你在尽可能小的作用域内声明,离第一次使用越近越好。这使得读者更容易看到变量的声明、定义以及初始值。特别提醒,应该初始化而非声明再赋值,如:
int i;
i = f(); // 不好 -- 初始化和声明分开。
int j = g(); // 好 -- 声明时初始化。
vector<int> v;
v.push_back(1); // 应优先使用括号初始化。
v.push_back(2);
vector<int> v = {1, 2}; // 好 -- 变量 v 声明时初始化。
if
,while
和for
语句中所需的变量也应该在这此语句内部声明,这样变量的被限制在它们的作用域中了。如:
while (const char* p = strchr(str, '/')) str = p + 1;
有一点需要注意:如果变量是一个对象,它的构造函数会在每一次进入作用域创建对象时被调用,它的析构函数也会在每次离开作用域时被调用。
// 无效率的实现:
for (int i = 0; i < 1000000; ++i) {
Foo f; // Foo 的构造函数和析构函数分别被调用了1000000次。
f.DoSomething(i);
}
这时在循环之外定义循环中使用的变量要高效的多:
Foo f; // Foo 的构造函数和析构函数只分别被调用了1次。
for (int i = 0; i < 1000000; ++i) {
f.DoSomething(i);
}
禁止使用有静态存储周期(static storage duration)的对象,除非它们是可平凡析构(trivially destructible)的。非正式地说,这意味着析构函数什么也不做,甚至成员和基类析构都不做。正式点说,这意味着这个类型没有用户定义的析构函数或虚析构函数,并且所有的基类和非静态成员都是可平凡析构的。静态的函数局部变量可能使用了动态动态初始化。静态成员变量或命名空间中变量的动态初始化的不鼓励使用的,但是在有限的情况下允许使用,详见下文。
经验法则:单独考虑声明,如果是constexpr
的,那么一个全局变量就能满足这些要求。
每一个对象都有一个存储周期,这与其生命周期是相关的。具有静态存储周期的对象从它们初始化一直存在到程序结束。命名空间中的变量(“全局变量”)、类的静态数据成员、函数的静态局部变量都是这样的对象。函数静态局部变量在首次声明时变初始化,其它的在程序启动时初始化。具有静态存储周期的对象都在程序退出时销毁(在未joined
线程结束之前)。
初始化可能是动态的,这意味着初始化期间会发生一些非平凡的事。(如,一个会分配内存的构造函数,或一个用当前进程 ID 初始化的变量。)另一种类型的初始化是静态初始化。不过这两者并不完全对立:静态初始化总是发生在有静态存储周期的对象上(将对象初始化为给定的常量,或初始化为所有字节全零的表示形式),而动态初始化发生在这之后,如果需要的化。
全局变量和静态变量在很多应用中都很有用:具名常量、某些翻译单元的内部辅助数据结构、命令行参数、注册机制和后台基础设施等。
使用动态初始化或有非平凡析构函数的全局或静态变量会带来复杂性,产生难找的 Bug。动态初始化不跨翻译单元顺序,析构函数也一样(除非析构以初始化相反的顺序发生)。当一个初始化引用了其它有静态存储周期的变量时,这可能会引发在对象生产周期开始前(或在生命周期结束后)访问它的问题。此外,如果一个程序启动了一些线程,但是在退出时没有joined
,如果析构函数已经运行,这些线程就可能访问生命周期已经结束的对象。
当析构函数是平凡的时,它们的执行完全不受顺序约束(实际上它们并不“运行”);否则我们就会面临在对象生命周期结束后访问它的问题。因此我们只允许平凡析构的对象具有静态存储周期。基本类型(像指针和整型)是平凡析构的,平凡析构类型的数组也是。注意,标记了constexpr
的变量也是平凡析构的。
const int kNum = 10; // allowed
struct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // allowed
void foo() {
static const char* const kMessages[] = {"hello", "world"}; // allowed
}
// allowed: constexpr guarantees trivial destructor
constexpr std::array<int, 3> kArray = {{1, 2, 3}};
// bad: non-trivial destructor
const std::string kFoo = "foo";
// bad for the same reason, even though kBar is a reference (the
// rule also applies to lifetime-extended temporary objects)
const std::string& kBar = StrCat("a", "b", "c");
void bar() {
// bad: non-trivial destructor
static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}
注意,引用本身不是对象,所以它们也没有析构方面的约束。不过动态初始化的约束依然存在。像static T& t = *new T;
这样的函数静态局部引用,是允许的。
初始化更复杂。因为我们不只要考虑类构造函数的执行,还要考虑初始化器的求值:
int n = 5; // fine
int m = f(); // ? (depends on f)
Foo x; // ? (depends on Foo::Foo)
Bar y = g(); // ? (depends on g and on Bar::Bar)
除了第一条,所有的语句都会碰到不确定的初始化顺序的问题。
我们追求的概念在 C++ 标准中被称为常量初始化。就是说初始化表达式是一个常量表达式,如果一个对象由调用构造函数初始化,那么构造函数也要用constexpr
限定。
struct Foo { constexpr Foo(int) {} };
int n = 5; // fine, 5 is a constant expression
Foo x(2); // fine, 2 is a constant expression and the chosen constructor is constexpr
Foo a[] = { Foo(1), Foo(2), Foo(3) }; // fine
常量初始化总是允许的。静态存储周期变量的常量初始化应该用constexpr
标记,或,如果可能的话,ABSL_CONST_INIT
属性。任何没有此标记的非局部静态存储周期变量都应该被假定为有动态初始化,要仔细审查。
相对的,下面的初始化是有问题的:
// 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
静态局部变量的动态初始化是允许的(并且很常用)。
- 全局字符串:如果你需要一个全局或静态的字符串常量,考虑使用简单的字符数组,或指向字符串字面量的字符指针。字符串字面量已经有静态存储周期,并且通常很高效。
map
、set
以及其它动态容器:如果你需要一个静态的固定大小集合,如一个用于搜索或查询表的set
,你不能使用标准库中的动态容器作为一个静态变量,因为它们有非平凡的析构函数。作为替代,可以考虑使用一个平凡类型的简单数组,如一个int
数组的数组(int
到int
的map
),或一个序对的数组(如int
和const char*
的序对)。对于小集合,线性搜索完全够用(由于内存局部性原理,也是高效的);对于标准操作,考虑absl/algorithm/container.h
中的工具。如果需要,保持集合处于排序状态并使用二分法查找。如果你就是想要标准库中的动态容器,考虑使用函数局部静态指针,后面会讨论。- 智能指针(
unique_ptr
,shared_ptr
):智能指针在析构时会执行清理工作,因此是被禁止的。考虑一下你的用例是否符合本节表述的模式。一个简单的解决方案是使用一个指向动态分配的内存的普通指针,并且永远不要delete
它(见最后一条)。 - 自定义类型的静态变量:如果需要一个自定义类型的静态常量,那么给这个类型一个平凡析构函数和
consexpr
构造函数。 - 如果其它的方法都不行,你可以使用函数局部静态指针或引用创建一个动态对象并且永远不
delete
它(如static const auto& impl = *new Y(args...);
)。
不是定义在函数内部的thread_local
变量必须使用一个真正的编译期常量进行初始化,这必须使用ABSL_CONST_INIT
属性强制限定。相对于其它定义线程本地变量的方法,优先使用thread_local
。
从 C++11 开始,变量可以用thread_local
声明:
thread_local Foo foo = ...;
这样的变量其实是一个对象的集合,这样不同的线程访问它时,其它是访问的不同对象。thread_local
变量和静态存储周期变量在很多方面都很像。比如,它们都可以被声明在命名空间中、函数内部或静态成员函数,不能声明成普通类成员。
thread_local
变量实例的初始化和静态变量一样,除了它们需要在不同的线程中分别声明,而不是只在程序启动时声明一次。这意味着在函数内部声明的thread_local
变量是安全的,但是其它的thread_local
变量面临和静态变量(以及其它变量)一样的初始化顺序问题。
thread_local
变量实例在线程结束时销毁,所有他们没有静态变量那样的析构顺序问题。
- 线程本地变量天生是安全的(因为只有一个线程能访问),这使
thread_local
在并发编程中非常有用。 thread_local
是唯一一种标准支持的创建线程本地变量的方法。
- 访问
thread_local
变量可能触发不可预测的、不可控的其它代码的执行。 thread_local
变量实际上是全局变量,有除线程安全外全局变量所有的缺点。thread_local
变量消耗的内部和线程数成正比(在最坏的情况下),在一个程序中这可能会很大。- 一个普通的类成员工能是
thread_local
的。 thread_local
可能没有编译器内部函数那么高效。
函数内部的thread_local
变量没有安全问题,可以无限制使用。注意,通过定义一个函数或静态方法,你可以使用一个函数作用域的thread_local
变量来模拟类或命名空间作用域的thread_local
变量:
Foo& MyThreadLocalFoo() {
thread_local Foo result = ComplicatedInitialization();
return result;
}
类或命名空间中的thread_local
变量必须使用真正编译期的常量初始化(即它们必须没有动态初始化)。为了强制这一点,类或命名空间中thread_local
变量必须用ABSL_CONST_INIT
(或constexpr
,但不常用)限定:
ABSL_CONST_INIT thread_local Foo foo = ...;
相对于其它定义线程本地数据的方法,应该优先使用thread_local
。
类是C++代码的基本单元,我们自然会广泛使用。这一节列出了我们在写一个类时应该遵循的主要规则。
在构造函数中避免虚函数调用,如果不能抛出错误,还要避免那些可能会失败的初始化。
在构造函数中可以进行任意初始化操作。
- 不需要纠结类是否已经初始化。
- 使用构造函数完全初始化的对象可以是
const
的,也更容易和标准容器或算法一起使用。
- 如果调用了虚函数,这些调用不会被分派给子类的实现。即使你的类现在没有子类,未来的改动也可能会默默引入这个问题,这会引起很大的混乱。
- 构造函数无法报告错误,简单地使程序崩溃(并不总是合适)或使用异常(这是被禁止的)。
- 一旦构造函数失败,我们就持有了一个初始化不完全的对象,它可能是处于不确定的状态,需要用
bool IsValid()
或类似的机制进行检查,而这些检查通常会被忘记调用。 - 你无法得到构造函数的地址,所以构造函数中的工作不能轻易传给其它线程。
构造函数永远不要调用虚函数。如果合适,也可以以结束来处理错误,否则,就使用 TotW #42 中所表述的Init()
方法。对于那些公共函数调用时没有其它有效状态的的对象避免使用Init()
(这类构造不完全的对象通常无法正常工作)。
不要定义隐式类型转换。对转换运算符和单参数构造函数使用explicit
关键字。
隐式类型转换允许一个类型(称为源类型)的对象可以被用在一个需要其它类型(称为目的类型)的地方,就象给一个需要double
参数传入一个int
一样。
除了语言自身定义的隐式转换,还可以通过给源类型或目的类型的类添加合适的成员来自定义隐式类型转换。一个源类型的隐式转换由一个以目的类型命名的转换运算符来定义(如operator bool()
)。一个目的类型的隐式转换通过一个以源类型为唯一参数的构造函数来定义(或者是唯一无默认值参数的构造函数)。
explicit
关键字可以被用在构造函数(从 C++11 开始)或转换运算符上,以保证只有在目的类型被显式指定时才能使用,例如使用cast
。这不仅用在隐式类型转换上,在 C++11 的列表初始化语法上也适用:
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);
Func({42, 3.14}); // Error
从技术上说这段代码并不是隐式类型转换,但是标准认为也应该受explicit
限定。
- 在一目了然的情况下,隐式类型转换不需要显式的类型名,因而使类型的可用性和表现力更强。
- 隐式类型转换比重载更简洁,如一个以
string_view
为参数的函数就能代替对std::string
和const char*
的重载。 - 列表初始化语法是一种简洁明了的对象初始化方法。
- 隐式类型转换会隐藏类型不匹配的 Bug,有时候目的类型和用户预期不一致,有时候用户没有注意到会发生了什么转换。
- 隐式类型转换会让代码更难读,特别是有重载的时候,很难确定调用了哪个函数。
- 单参数的构造函数有时会被误用于隐式类型转换,即使它们并没有被设计成这样。
- 当一个单参数构造函数没有被
explicit
标识时,没有方法能证明它是被故意定义成隐式类型转换,还是作者忘了加了。 - 应该被转换何种类型并不总是明确的,如果都可以,那代码就会有歧义。
- 如果目的类型是隐式的,列表初始化也有类似的问题,特别是列表只有一个单独元素时。
类型转换运算符和能以单参数被调用的构造函数,必须使用explicit
。copy
和move
构造函数例外,因为它们不执行类型转换。在定义一些包装其它类型的类型时,隐式转换有时是必要的也是合适的。这时,请联系你的项目经理申请免除这一条限制。
不能以一个参数调用的构造函数可以不加explicit
。只接收一个std::initializer_list
为参数的构造函数也应该不加explicit
,以支持拷贝初始化(如 MyType m = {1, 2};
)。
类的公开 API 必须能清晰表现这个类是否是可拷贝的、仅可移动还是既不能拷贝也不能移动。当拷贝和/或移动操作对你的类型是清晰并且有意义时才可以支持。
一个类型可移动是指它可以从临时变量初始化或赋值。
一个类型可拷贝是批它可以从其它同类型的对象初始化或赋值(根据定义它也一定是可移动的),同时源对象的值不改变。std::unique_ptr
是一个可移动但不能拷贝的类型(因为一个std::unique_ptr
类型在赋值给目标之前必须发生改变)。int
和std::string
是可以移动又可拷贝的类型。(对于int
来说,移动和拷贝操作是一样的;对std::string
来说,移动操作比拷贝开销更小。)
用户自定义的类型,拷贝的行为取决于拷贝构造函数和拷贝赋值运算符。如果存在移动构造函数和移动赋值运算符,移动的行为就取决于它们,否则,也取决于拷贝构造函数和拷贝赋值运算符。
在某些情况下,编译器会隐式调用拷贝/移动构造函数,如对象传值的时候。
可拷贝可移动类型的对象可以传值以及直接返回值,这样 API 更简单、更安全也更通用。不像传对象指针或引用,传值没有所有权、生命周期、可变性混淆类似的风险,不需要在协议中特别约定。同时也能阻止客户端和其实现之间的非本地交互,这使得它们更易于理解和维护,也利于编译器优化。更进一步,这样的对象可以用于要求传值的泛型 API,如大多数容器,并且他们还在类型组合等方面带来额外的灵活性。
拷贝/移动构造函数和赋值运算符通常对应的Clone()
、CopyFrom()
和Swap()
更容易正确定义,因为它们可以由编译器生成,经由隐式构造或= default
。它们很简洁,并能保护所有的数据成员都被拷贝。拷贝和移动构造函数通常也更为高效,因为不需要分配堆内存或单独的初始化和赋值步骤,还可以进行诸如复制省略之类的优化。
移动运算符允许隐式、高效地将资源传出右值对象,这在某些情况下会使用代码更简明。
有些类型不需要可拷贝,为这些类型提供拷贝运算符可能会引起混淆、无意义或根本就是错误的。单例对象的类型(Registerer
)、绑定到特定作用域的类型(Cleanup
)或与其它对象紧密耦合的类型(Mutex
)都不能被拷贝。在可以用于多态的基类上的拷贝操作是危险的,因为它们可能引起对象切片。默认或不小实现的拷贝运算符可能不正确,引发不易诊断的 bug。
拷贝构造函数的调用是隐式的,这使得它很容易被忽视。对于使用将传引用作为约定或强制要求的编程语言的程序员来说,会引起混乱。它也会示鼓励过渡拷贝,从而引发性能问题。
每一个类的公共接口必须清楚地表明它支持哪些拷贝和移动操作。通常的形式是在公共部分显式声明和删除适当的操作。
具体来说,一个可拷贝的类应该显式声明一个拷贝操作,一个只能移动的类应该显式声明一个移动操作,一个不能拷贝或移动的类,应该显式删除拷贝操作。显式声明或删除所有的四个拷贝/移动操作是允许的,但是不是必需的。如果提供了拷贝或移动赋值操作符,还必须提供相应的构造函数。
class Copyable {
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// The implicit move operations are suppressed by the declarations above.
};
class MoveOnly {
public:
MoveOnly(MoveOnly&& other);
MoveOnly& operator=(MoveOnly&& other);
// The copy operations are implicitly deleted, but you can
// spell that out explicitly if you want:
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// Not copyable or movable
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)
= delete;
// The move operations are implicitly disabled, but you can
// spell that out explicitly if you want:
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
= delete;
};
这些声明/删除只有在非常明显的情况下才能省略“
- 如果类没有私有部分,像[结构体](#结构体 VS 类)或只作为接口的基类,则可拷贝性和可移动性能通过公有数据成员的可拷贝性和可移动性来确定。
- 如果基类显然是不可拷贝或移动的,那么派生类自然也不能。只作为接口的基类使隐式使用这些操作并不能使具体的子类更清晰。
- 请注意,如果显式声明或删除了拷贝相关的构造函数或赋值操作符,其它不显然的拷贝操作也需要被声明或删除。移动操作亦然。
如果一个普通用户不能清楚拷贝/移动的含义,或带来非预期的开销,一个类型就不应该可拷贝/移动。严格意义上说对可拷贝类型的移动操作是一种性能优化,会带来潜在的 bug 和复杂性,所以除非它们相比拷贝有巨大的性能优势,应该避免使用。如果提供了拷贝操作,要设计好类,以使得这些操作的默认实现正确。记住,要像检查其它代码一样检查任何默认操作的正确性。
因为有切片的风险,最好避免为打算被派生的类提供公有赋值操作符或拷贝/移动构造函数(也要尽量避免继承拥有这些成员的类)。如果基类需要可拷贝,就提供一个公有的Clone()
虚函数和一个protected
拷贝构造函数,派生类可用以去实现它。
只持有数据的被动对象才能作为结构体;其它的都应该用类。
C++ 中struct
关键字和class
关键字的行为几乎一样。我们给每个关键字添加了自己的语义,所以你应该为你定义的数据类型使用合适的关键字。
结构体只能用在只持有数据的被动对象上,并且可以有相关的常量,但是除了存取数据成员外没有任何其它功能。所有的字段都必须是公有的,对数据成员的存取都是通过直接访问而不是调用方法。结构体不能包含依赖不同字段关系的不变量,因为直接用户存取可能会破坏这些不变量。除了设置数据成员,方法不能有其它行为,如,构造函数,析构函数,Initialize()
,Reset()
。
如果需要更多功能,类更合适。如果不确定,就用类。
为了与 STL 保持一致,对于无状态类型可以使用结构体代替类, 如 Traits、模版元函数和一些仿函数(functors)。
注意结构体和类的成员变量有不同的命名规则。
当元素能有意义命名时,最好使用结构体而不是序对或元组。
虽然使用序对和元组可以避免自定义类型,可以节省编码工作,但是一个有意义的初段名几乎总比.first
、.second
或std::get<X>
这类代码清晰得多。虽然 C++14 引入了std::get<Type>
可以通过类型代替索引访问元组元素(当类型是unique
的),部分缓解了这种情况,但是字段名通常比类型更清晰、信息更丰富。
在元素没有特别含义的泛型代码中,序对和元组可能是合适的。为了和现有代码或 API 交互,也可能用到它们。
组合通常都比继承更合适。当使用继承时,只能用公有继承。
当一个子类继承一个基类时,他就包含了父类中定义的所有数据和操作。“接口继承”是指继承自纯抽象基类(没有状态也没有定义方法);其它的继承都是“实现继承”。
通过原样复用基类代码,实现继承减少了代码量。由于继承是编译期声明的,程序员和编译器都可以理解操作并检查错误。接口继承可用以强制暴露特定的 API。在这种情况下,当一个类没有定义必要的 API 方法时,编译器也可以检查到。
对于实现继承,实现子类的代码在基类和子类间传播,这使得理解一个实现更为困难。子类不能重写非虚函数,所以子类也不能改变其实现。
多重继承的问题尤为严重,因为承前它会带来更大的性能开销(事实上,从单继承到多重继承的性能下降通常比从普通方法到虚方法的下降要大得多),还因为有导致“菱形继承”的风险,更容易模糊、混乱甚至完全错误。
所有的继承都应该是公有的。如果你想要一个私有继承,那么你应该把基类的实例作为成员包含进来。
不要过度使用实现继承。组合往往更合适。试着把继承的使用限制在“is-a”的情况:只有Bar
确实是一种Foo
时,Bar
才能作为Foo
的子类。
尽可能使用虚析构函数。如果类中有虚方法,那么析构函数就一定要是虚方法。
仅对于那些可能在子类中访问的成员函数才使用protected
。注意数据成员都应该是私有的。
使用override
或final
(不常用) 来显式地标注虚函数或虚析构函数的重载。声明重载时不要使用virtual
。
重定义一个继承的虚函数,要在声明中显式使用virtual关键字。原因是:如果省略virtaul,为了搞清楚一个函数是不是虚函数,读者就需要检查所有的父类。原理:如果一个被标记为override
或fina
函数或析构函数不是对基类虚函数的重载,是不能通过编译的,这有助于发现常规的错误。这些标记起到了文档的作用,如果没有标记,读者就要检查所有的基类以确定一个函数或析构函数是否是虚的。
明智地重载运算符。不要创建用户自定义的字面量。
C++允许用代码使用operator
关键字声明内建运算符的重载版本,只要其中一个参数是用户自定义的类型。operator
关键字还允许用户代码使用operator""
来定义新的字面量,定义像operator bool()
这种类型转换函数。
运算符重载可以使一个类的行为和内建类型一致,从而使代码看上去更简洁更直观。 对一些运算来说重载成惯用的名称(如==
,<
,=
和 <<
),符合这些习惯可以使用用户代码更易读,并且可以和需要这些名称的库交互。
用户定义的字面量是创建户自定义类型对象的一种非常简洁的方法。
- 提供一组正确的、一致的符合直觉的重载运算符需要特别小心,一不小心就可以导致混淆和错误;
- 过度使用运算符会导致代码混乱,特别是当重载的运算符不遵循约定时;
- 函数重载的危险同样适用于运算符重载,甚至更多;
- 会混淆视听,让我们误以为一些耗时的操作和内建操作一样轻巧;
- 查找一个重载运算符的调用点需要一个能理解 C++ 语法的查找工作,仅仅
grep
不够用了; - 如果重载运算符参数错误,可以会执行一个不同的重载版本而不是编译错误。如,
foo < bar
执行一个操作,而&foo < &bar
行为可能完全不同; - 重载某些操作符本身就是危险的,重载一元运算符
&
会使相同代码有不同的含意,这取决于重载声明是否可见。重载&&
、||
和,
这类操作符并不能匹配相应内建运算符的优先级; - 运算符通常在类的外部定义,这就有在不同文件中引入同一个运算符不同实现的风险。如果这两个实现链接进了同一个库,会导致未定义的行为,这会表现为一些难于查觉的运行时错误;
- 用户定义字面量(UDL)允许创建新的语法形式,即使有经验的 C++ 程序员来说也不熟悉,比如使用
"Hello World"sv
作为std::string_view("Hello World")
的简写。即使不够简洁,内建标注也更清楚; - 因为无法限定命名空间,所以使用 UDL 还要求我们使用
using
指令(这个我们禁止了)或using
声明(除非导入的名称是头文件导出接口的一部分,这我们也禁止了)。 考虑到头文件中我们要避免 UDL 后缀,我们更希望避免头文件和源文件的字面量约定不一致。
只有在意义明确、符合直觉并且和相关的内建运算符一致时才重载运算符。比如,使用|
作为位或或逻辑或,而不要作为 shell 管道。
只对自己定义的类型重载运算符。更准确地说, 在所操作的类型定义的同一个头文件、 .cc
和命名空间中定义它们。这样,无论类型在哪里,都能够使用自定义的运算符,最大程度上避免了多重定义的风险。如果可能的话,避免将运算符定义为模板,因为这样它们就必须能用于任何可能的模板参数。如果你定义了一个运算符,请将其相关含义的运算符都进行定义,并且保证语义一致。例如, 如果你重载了 <
, 那么请将所有的比较运算符都进行重载,并且保证对于同一组参数,<
和 >
不会同时返回 true
。
不要将不修改的二元运算符定义为成员函数。如果一个二元运算符被定义为类成员,这时隐式转换会作用于右侧参数却不会作用于左侧。出现 a < b
能够通过编译而 b < a
不能的情况,会很让人迷惑的。
通常都不要重载运算符。特别是赋值运算符(operator=
)更容易出错,更应该避免重载。如果需要你可以定义Equals()
和CopyFrom()
函数。同时,如果一个类有一丁点可能被前置声明,那么在任何情况下都要避免重载危险的一元操作符operator&
。
不需要刻意避免运算符重载。如,要定义==
,=
和<<
,而不是Equals()
,CopyFrom()
和PrintTo()
。反过来,也不要仅仅因为库需要就去重载运算符。比如,你的类型本身没有顺序,但是你想将其存储在std::set
中,这种不要去重载<
,而要使用自定义的比较运算符。
不要重载&&
、||
,,
或一元&
,也不要重载operator""
,即不要引入用户自定义字面量。不要使用其它人提供的此类字面量(包括标准库中的)。
类型转换运算符在隐式类型转换一节中有提及。=
运算符在拷贝构造函数一节中有提及。重载<<
以配合流使用在流一节提及。同时也注意函数重载中的规则,它们都同样适用于运算符重载。
除非是常量,类数据成员都定义成私有的。这简化了不变量的推理,代价是在需要时提供一些简单存取函数(通常是const
的)。
因为技术原因,当使用Google Test时,允许夹具类的数据成员是pretected
的。
类似的声明放到一起,public
的尽可能往前放。
类定义通常应该是public:
部分开头,后面是protected:
部分,最后是private:
部分。空的部分省略。
每一区中,把类似的声明放在一起,并且一般使用这样的顺序:
- 类型定义(
typedef
、using
、嵌入结构体和类) - 常量
- 工厂函数
- 构造函数
- 赋值操作符
- 析构函数
- 其它所有方法和数据成员
大的方法定义不要内联在类定义中。通常,那些没有特别意义或对性能要求高,还非常短小的方法才可以内联。参见内联函数。
##输出参数
一个 C++ 函数通常经由返回值输出,有时也通过输出参数。
相比输出参数,应该优先使用返回值:它们更可读,而且通常性能差不多甚至更好。如果使用了只作输出用的参数,它们应该发现在输入参数后面。
一参数或者向函数输出值,或者输出,或两者兼有。输出参数通常是值或const
引用,而输出或输入/输出参数需要是非常数指针。
在排序函数参数时,将所有的只输出参数放在输出参数前面。特别是不要仅仅因为是最新的,就将一个参数加到最后面,新的只输出参数要放到输出参数之前。
这条规则不是一成不变的。同时输入和输出的参数(通常是类或结构体)会使问题变复杂,而且,为了和相关函数保持一致性,也可以会要求违反本规则。
尽量编写短小精炼的函数。
我们意识到有时候长函数更合适,所以在函数长短方面没有硬性的规定。如果一个函数超过 40 行,就考虑一下在不破坏程序结构的情况下能否将其拆分。
即使你的长函数现在可以完美工作,几个月后可能会有人为其添加新的行为,这可能引入难以查找的bug。保持函数短小、简单可以使他人更容易阅读和修改你的代码。小函数也更容易测试。
你也许会在一些代码中发现一些又长又复杂的函数。不要被修改已有代码吓倒:如果和复杂的函数打交道,你发现错误难以调试,或是你只想使用一部分代码,你可以考虑把这个函数拆成更可控的小函数。
所有传左值引用的参数都要用const
修饰。
在 C 语言中,如果函数要修改一个变量,就必须使用指针作用参数,如int foo(int *pval)
。而在 C++ 中,还可以使用引用参数:int foo(int &val)
。
把参数定义成引用可以避免(*pval)++
这类的丑陋代码。对像拷贝构造函数这些应用也是必须的。不像指针,没有空引用的问题。
引用可能引起混淆,因为它有值的语法但是指针的语义。
在函数的形参列表中,所有的引用都必须是const
的:
void Foo(const std::string &in, std::string *out);
实际上,Google 代码是有一个很强的传统就是输入参数是值或const
引用,而输出参数是指针。输入参数可以是const
指针,但是我们不允许非const
的引用参数,除非有约定需要,如swap()
。
不过,还是有一些场合const T*
参数比const T&
参数更合适,如:
- 你希望传入空指针;
- 需要把指针或引用赋给输入参数。
记住,绝大多数时候输入参数都应该是const T&
。用const T*
来告诉读者这个输入有时候会区别对待。所以如果你使用了const T*
而非const T&
,要有具体的原因,否则就会迷惑读者去寻找不存在的解释。
使用重载函数(包括构造函数)时,一定要确保看代码的人一看到调用就能知道发生了什么,而不用先去查找哪一个重载函数被调用了。
你可以写一个以const string&
为参数的函数,然后再写一个以const char *
为指针的。不过在这里应该考虑使用std::string_view
。
class MyClass {
public:
void Analyze(const string &text);
void Analyze(const char *text, size_t textlen);
};
通过参数不同的同名函数,重载可以使代码更直观。模板化代码也可能要求重载,这也方便了代码使用者。
基于const
或ref
限定的重载可能使代码更易用、更高效。(详见TotW 148)
如果一个函数只是以参数类型不同重载,读者可能需要了解 C++ 复杂的匹配规则来确定将要发生什么。同样,如果继承时一个派生类只重载了函数的一部分变体,会使许多人找不着北的。
重载函数时要确保各变体之间没有语义差别。这些重载可能是类型不同、修饰符不同或参数数量不同。不过,读者一定不需要关心哪个重载被调用的问题,只要知道重载函数中某个被调用了即可。如果在头文件中你对所有的重载使用一个注释,说明这是一个好的重载设计。
只有能保证每次的缺省值不一样时,才可以在非虚函数中使用缺省参数。要遵循和函数重载同样的约束,并且在可读性收益不能弥补下面的损失时优先使用重载。
函数经常会用到缺省值,但你偶尔会想要重载这些缺省值。使用缺省参数可以轻松搞定,又不必为了这种少数例外定义多个函数。相对于重载函数,缺省参数语法更干净,样板代码更少,可以更清楚地区分‘必须的’和‘可选的’参数。
缺省参数是实现函数重载语义的另一种方法,所以不能使用重载函数的理由都适用。
一个虚函数调用的缺省实参由目标对象的静态类型决定,不能保证所有的重载都声明了相同的缺省值。
缺省参数在每次调用时都会重新求值,这会导致代码膨胀。读者会期待缺省值在声明时就固定了,而不是在每次调用时都会变化。
有缺省参数的函数指针容易引起混乱,因为函数签名经常和调用签名不匹配。这时应该使用重载来避免次类问题。
虚函数上禁止使用缺省参数,它们无法正常工作。在有些情况下,缺省值在不同时间的求值可能会不同。(如,不要这样写:void f(int n = counter++);
。)
其它情况下,只要缺少参数对函数可读性的提高收益大于上面的不利,就可以使用。如果不确实,那么就使用重载。
只有在普通语法(前置返回值类型)不能实现或可读性不好时才使用后置返回值类型。
C++ 允许两种形式的函数声明。老形式中,返回值类型在函数名之前。如:
int foo(int x);
在 C++11 引入的新形式中,在函数名之前使用auto
关键字并在参数列表后面后置一个返回类型。如:
auto foo(int x) -> int;
后置返回类型在函数作用域内。对于像int
这种简单情况来说这没有什么差别。但是在复杂情况下,如类型在类作用域内声明或类型是函数参数的形式,就不一样了。
后置返回类型是显式指明 [lambda 表达式](#lambda 表达式)返回值的唯一方法。有时候编译器可以推导出 lambda 表达式的返回值,但有时候不能。而且即使编译器能自动推导,有时候显式指明也更清晰。
有时在函数形参列表后面指定返回类型更容易也更可读。特别是当返回类型依赖模板参数时更是如此。如:
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u);
对比:
template <typename T, typename U>
decltype(declval<T&>() + declval<U&>()) add(T t, U u);
后置返回类型语法相对较新,在 C 和 Java 这些和 C++ 相似的语言里没有对应物,所以一些读者可以对它们不熟悉。
已经存在的代码中有大量的函数声明,它们不会修改到新语法上,所以现实的选择是只使用老语法或混合使用。一个版本比风格不一致要好。
大多数情况下,继续使用老风格的函数声明就行了。只在必须时(如 lambda 表达式)或返回类型写到后面增加代码的可读性时再使用新的后置返回类型语法。后一种情况不多见,因为只有在复杂的模板代码中才有,而复杂模板是不被鼓励的。
我们使用了大量技巧和工具使 C++ 代码更健壮,我们使用 C++ 的方式可能与你在别处见到的有所不同。
对动态分配的对象优先使用单独的、固定的拥有者。优先使用智能指针转移所有权。
“所有权”是一种用以管理动态分配的内存(还有其它资源)的记账技术。动态对象的拥有者是一个对象或函数,它们要负责在对象不再需要时删除它们。所有权有时可以共享,这时通常是最后一个拥有者负责删除。即使所有权不共享,它也可以从一段代码转移到另一段。
智能指针是像指针一样工作的类,如,通过重载*
和->
运算符。一些智能指针类型可用以自动化所有权的记账,以确保完成其职责。std::unique_ptr
是一个 C++11 引入的智能指针,表示动态对象的独占所有权;当std::unique_ptr
离开其作用域时,对象被删除。它不能被拷贝,但可以move
以体现所有权转移。shared_ptr
是一个表示动态对象共享所有权的智能指针。shared_ptr
可以拷贝,所有的拷贝共享所有权,当最后一个shared_ptr
销毁时对象被删除。
- 不使用一些所有权逻辑,管理动态分配的内存几乎不可能。
- 转移一个对象的所有权比拷贝它开销更小(如果可以拷贝的话)。
- 转移一个对象的所有权比“借”一个指针或引用要简单,因为它减少了两个用户之间关于此对象生命周期的交互。
- 智能指针通过显式的所有权逻辑、自文档且无歧义提高了可读性。
- 智能指针可以消除手动所有权记录,简化代码并且排除一大类错误。
- 对于常量对象,共享所有权比深度拷贝更简单也更高效。
- 所有权必须通过指针(不管是智能指针还是普通指针)来表达和转移。指针的语义比值要复杂得多,特别是在 API 中:你要关心的不只是所有权,还有别名、生命周期、可变性以及其它事项。
- 值语义的性能开销经常被高估,所以所有权转移的性能优势可能不足以弥补可读性和复杂性的损失。
- 所有权转移 API 把用户绑定在了单一内存管理模型上。
- 使用智能指针的代码在资源是否已释放这事上更不明确。
std::unique_ptr
使用了 C++11 的move
语义来表达所有权转移,这相对较新,可能会迷惑一些程序员。- 共享所有权比小心地所有权设计更有诱惑力,会使系统设计更模糊。
- 共享所有权需要在运行时显式记账,可能会有开销。
- 有些情况下(如,循环引用),有共享引用的对象可能永不会删除。
- 智能指针不是纯指针的完美替代。
如果动态分配是必须的,最好把所有权控制在执行分配的代码中。如果其它代码需要访问这个对象,考虑传递一个拷贝、一个指针或引用给它,而不是使用所有权转移。优先使用std::unique_ptr
来进行所有权转移。如:
std::unique_ptr<Foo> FooFactory();
void FooConsumer(std::unique_ptr<Foo> ptr);
没有很好的理由不要把代码设计成使用共享所有权。其中一个理由是避免昂贵的拷贝操作,但只有在性能很关键且相关对象是不可变的(即,shared_ptr<const Foo>
)时你才应该使用。如果非要使用共享所有权,优先使用shared_ptr
。
永远不要使用std::auto_ptr
,要使用std::unique_ptr
代替。
使用 cpplint.py 检查风格错误。
cpplint.py 是一个读取源代码并能指出许多风格错误的工具。它并不完美,有时会漏报或误报,但是依然是个有价值的工具。误报可以行尾加// NOLINT
注释或前一行加// NOLINTNEXTLINE
注释来忽略。
一些项目会指导你使用该项目的工具运行 cpplint.py。如果你的项目没有,可以单独下载。
使用右值引用来:
- 定义移动构造函数和移动赋值运算符。
- 如果你能证明可以比传值提供更好的性能,或你在写低开销的需要支持任意参数的泛型代码,可以用
const&
和&&
来定义重载函数集。注意组合的重载集,很少重载超过一个参数。 - 在泛型代码中支持“完美转发”(perfect forwarding)。
右值引用是一种只能绑定到临时对象的引用。语法和传统引用类似。如,void f(string&& s);
声明了一个函数,其参数为一个到std::string
的右值引用。
当标记&&
作用于函数参数中的非限定模板参数时,将应用特殊的模板参数推导规则。这样的引用被称为转发引用。
- 定义一个移动构造函数(一个接收类的右值引用的构造函数)使得可以使用移动代替拷贝。例如,如果
v1
是一个vector<string>
,那么auto v2(std::move(v1))
多半仅仅就是简单的指针维护而不是拷贝大量数据。在很多情况下这可以大幅提高性能。 - 使用右值引用才能实现可移动但不能拷贝的类型,这对一些没有合理的拷贝定义的类型很有用,这些类型没有合理的拷贝定义,但你可能想把它们作为参数传递给一个函数,或把它们放在容器中,等等。
- 要高效使用某些标准库类型,如
std::unique_ptr
,std::move
是必须的。 - 用使用了右值引用符的转发引用,可以写出能将其参数传给其它函数的泛型函数包装器,无论参数是不是临时对象或/和
const
对象都可以工作。这叫“完美转发”。
- 右值引用还没有被广泛理解。像引用折叠和转发引用的特殊推导规则都很晦涩。
- 右值引用经常被误用。在签名中使用右值引用是违反直觉的,在函数调用之后,参数应该有一个特定的状态或没有没有发生移动操作。
你可以使用右值引用来定义移动构造函数和移动赋值操作符(见可拷贝类型和可移动类型)。在《C++ Primer》中有更多关于移动语义和std::move
信息。
你可以使用右值引用定义一对重载,一个接受Foo&&
,别一个接受const Foo&
。通常首选方案是仅传值,但是重载这样一对函数有时候可以带来更好的性能,还有在需要支持大量类型的泛型代码中有时也需要。一如既往:如果你在为了性能写更复杂的代码,一定要确保这真是值得。
你也可以与std::froward
一起使用转发引用,以支持完美转发。
我们允许合理使用友元类和函数。
友元通常应该定义在同一个文件中,这样读者就不用为了查看这个私有成员的用法而查找另一个文件。友元的一个常用用法是:将FooBuilder
类定义成Foo
类的友元,这样它就能正确构造Foo
类的内部状态,又不用将内部状态暴露给外面。有时将单元测试类定义成被测试的类的友元很有用。
友元扩展而非破坏了类封装的边界。在一些情况下,如果你只是想在另一个类中访问一个类,友元比使用公有成员更好。当然,大多数类应该只通过其公有成员来与其它类交互。
我们不使用 C++ 异常。
- 异常允许在应用程序的更上层决定如何处理那些深度嵌套的函数中“不可能发生的”错误,不会像记账式的错误处理代码那样含糊又容易出错。
- 许多现代语言都使用异常。在 C++ 中使用异常会和 Python,Java 以及其它人熟悉的 C++ 保持一致。
- 一些第三方 C++ 库使用异常,在内部关闭异常将难以与这些库集成。
- 异常是构造函数报告失败的唯一方法。我们可以使用工厂函数或
Init()
方法来模拟,但是它们又分别需要堆内存分配或一个新的“无效”状态。 - 异常在测试框架中相当方便。
- 当给一个已存在的函数添加一个
throw
语句时,你必须测试调用路径上的所有调用者。它们或者能至少保证基本的异常安全,或者不捕获异常并乐于接受程序退出的结果。举个例子,如果f()
调用了g()
,后者又调用了h()
,f
捕获了h
抛出一个异常,这时g
要特别小心,否则就容易清理不妥当。 - 更普遍的情况是异常使靠看代码弄明白程序的控制流非常困难:函数可能会在你想不到的地方返回。这引发维护和调试困难。你可能通过一些如何使用异常的规则来最小化这种代价,但代价是使开发者要理解更多的东西。
- 异常安全需要 RAII 和不同的编码实践。需要大量的支持机制才能轻易写出异常安全的代码。为了避免要求阅读者理解整个调用关系图,异常安全的代码必须把写入持久状态的逻辑隔离到“提交”阶段。这有好处也有代价(可能为了隔离提交你不得不弄乱代码)。允许异常使我们无论是否值得都要付出这些代价。
- 使用异常会向生成的二进制文件中添加数据,增加了编译时间(或许微不足道),并增加了地址空间的压力。
- 可以使用异常会使开发者在不恰当的时候抛出,又在不安全的时候恢复某些异常。如,无效的用户输入不应该引发异常。要写下这些限制,这份指南会长很多。
表面上使用异常利大于弊,特别是对新项目。但对于已有代码,引入异常会影响所有依赖的代码。如果异常可以向新项目以外传播,在跟之前未使用异常的代码集成时也会碰到麻烦。因为 Google 中大部分 C++ 代码都没有准备处理异常,引入新的产生异常的代码更是困难。
由于 Google 的已有代码不使用异常,使用异常的代价比新项目要高。迁移的过程会很慢并容易出错。我们不相信异常的有效替代,如错误处理代码和断言,会引入严重的负担。
我们反对使用异常的建议不是出于哲学或道德判断,纯粹是出自于实用目的。因为我们希望在 Google 使用我们的开源项目,但项目中使用异常会很麻烦,因此我们也建议在 Google 的开源项目中不使用异常。如果一切都从头开始,可能会有所不同。
这条禁令也适用于 C++11 中加入的异常相关的特性,如std::exception_ptr
,和std::nested_exception
。
对 Windows 代码这条规则有一个例外(没有双关的意思)。
我说:看来这条规则主要是出于和Google的遗留代码兼容。实际中是不是要用再判断吧。
如果有用且正确,可以使用noexcept
。
noexcept
指示符用以指明函数是否抛出异常。如果一个被标记为noexcept
的函数抛出一个异常,程序会由std::terminate
终止。
noexcept
运算符执行一个编译期检查,如果一个表达式被声明成不抛出异常,则返回真。
- 为移动构造函数指定
noexcept
有时会提升性能,如,std::vector<T>::resise()
,当T
的构造函数时noexcept
的时,只会移动对象,而不会去拷贝。 - 在函数上指定
noexcep
在启动了异常的环境中会触发编译器优化,如,编译器如果知道不会抛出异常,就不必再为栈展开生成额外的代码。
- 在遵循本指南的工程中,异常是被禁止的,判断
noexcept
是否正确很困难,甚至都没法定义什么是“正确”。 - 即使不是不可能,也很难取消
noexcept
,因为这会消除调用者可以已经依赖的保证,这总是很难检查的。
如果它能精确反映出函数的预期语义,即如果一个函数抛出异常就代表发了一个致命错误,那么使用noexcept
会对性能有利。你可以假设在移动构造函数上使用noexcept
是有性能收益的。如果你认为在其它函数上使用noexcept
会带来显著的性能收益,请与你的项目负责人讨论。
如果异常被完全禁止(如大多数的 Google C++ 环境),最好使用无条件的noexcept
。否则,使用带有简单条件的条件noexcept
,仅在函数可能抛出异常的少数情况下求值为假。测试可能包含类型特征(Type Traits)所涉及的操作是否抛出异常(如,拷贝构造对象的absl::default_constructible
),或内存分配是否抛出异常(如,默认分配的sbsl::default_allocator_is_throw
)。注意在许多情况下只有一种引发异常的情况就是内存分配失败(我们相信移动构造函数不应该抛出异常,除非因为内存分配失败),而对多数应用而言,应该将内存耗尽作为一个致命错误,而不是一个要试图从中恢复的异常。 即使对于其它的潜在失败,你也应该优先使用简单的方法,而不是去支持所有可能抛出异常的场景:例如,与其针对一个所依赖哈希函数抛出异常的情况写一个复杂的noexcept
子句,还不如在文档中说明你的组件不支持哈希函数抛出异常,使其无条件noexcept
。
避免使用运行时类型信息(RTTI)。
RTTI 允许程序员在运行时查询一个对象的类型。这是由typeid
和dynamic_cast
实现的。
RTTI 的标准替代品(下面会描述)需要修改或重新设计类的继承结构。有时修改是不可行或不能接受的,特别是那些已经被广泛使用或很成熟的代码。
RTTI 在一些单元测试中会很有用。例如,测试工厂类时需要校验新生成的对象是否是预期的类型,这时 RTTI 就很有用。在管理对象和其 mock 对象的关系时也很有用。
当有多个抽象对象时,RTTI 很有用。考虑下面代码:
bool Base::Equal(Base* other) = 0;
bool Derived::Equal(Base* other) {
Derived* that = dynamic_cast<Derived*>(other);
if (that == NULL)
return false;
...
}
运行时查询一个对象的类型通常意味着设计有问题。需要在运行时知道一个对象的类型往往说明你的类继承层次设计有缺陷。
无原则地使用 RTTI 会使代码难以调试。会导致代码中充斥着基于类型的决策树或switch
语句,以后要修改时,这些都是必须要检查的。
RTTI 有合理的应用,但是容易滥用,所以使用时你必须要小心。在单元测试时你可以放心使用 RTTI,但是其它代码中应尽可能避免。当你发现你写的代码需要根据对象类型采取不同的动作时,考虑下面的方案来代 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<>()
,或使用大括号初始化来转换算术类型,如int64 y = int64{1} << 42
。不要使用其它的类型转换形式,如int y = (int)x;
或int y = int(x);
。(但当调用一个类的构造函数时,后者是可以的)
C++ 引入了与 C 不同的类型转换系统,会区分转换操作的类型。
C 语言类型转换的问题是模棱两可,有时是值转换(conversion),( 如(int)3.5
),有时是强制类型转换(cast,如,(int)"hello"
),而大括号初始化和 C++ 的类型转换避免了这种情况。另外 C++ 的类型转换很容易查找。
语法又长又臭。
要使用 C++ 风格的类型转换,而不是使用 C 风格的。
- 使用大括号初始化来转换算术类型(如
int64{x}
)。这是最安全的方法,因为如果有信息损失就通不过编译。语法也很简洁。 static_cast
可以作为 C 风格值转换的替代,用来显式地将指针提升为其父类的指针,或需要显式地将一个指向父类的指针指向子类。后一种情况下,你必须确保实例是一个子类对象。- 使用
const_cast
来去掉const
修饰符(见const)。 - 使用
reinterpret_cast
来执指针类型和整数或其它类型指针之间接非安全转换。使用时一定要清楚你在干什么,并对明白 aliasing 问题(见下)。同时,也可以考虑使用absl::bit_cast
替代。 - 使用
absl::bit_cast
来用内存大小相同的类型来解决一块内存,如将一个double
类型的内存解释为int64
。
对dynamic_cast
的使用指南参见 RTTI 一节。
aliasing 在C/C++中指“不同类型的指针指向同一地址”。
TODO HERE
只在记日志时才使用流。
流是printf()
和scanf()
的替代。
使用流,你就不必知道要打印的对象类型。不会有格式化字符串和参数列表不匹配的问题。(使用gcc时,printf
也没有这个问题。)流的构造函数和析构函数会自动打开和关闭相关文件。
流难以实现类似pread()
的功能。不用printf类似的手段,用流难以高效实现一些格式化操作(特别是常用的格式化字符串%.*s
)。流不支持重排操作(%1s
指令),而这对于国际化很有用。
除非在日志接口中,否则不要使用流,而应该使用printf
风格的函数。
使用流有好处也有坏处,但是又一次,一致性胜过一切。不要在代码中使用流。
关于这个问题有一些争论,所以在此进一步解释一下。回想一下 **唯一性(Only One Way)**原则:我们希望确保使用同一类型的I/O的代码看起来都是一样的。因此,我们不想让用户来决定使用流还是printf
+read/write
等。相反,我们应该明确唯一一种方式。之所以日志例外,因为它是很特别的应用,并有一些历史原因。
流的支持者们认为流是不二之选,但是理由实际上都不怎么清楚。流的每一个优点都有相应的劣势。最大的优势莫过于你无需关心要打印的对象类型,这的确是优势。但是也有不利的一面:你容易用错类型,并且编译器不会警告你。使用流,你很容易犯下面的错误而不自知:
cout << this; // 打印地址
cout << *this; // 打印内容
因为<<
已被重载,所以编译器不会报错。就是因为这原因,我们才不鼓励重载。
有人说printf
风格丑陋难以阅读,便流也好不到哪里去。考虑下面两段代码,以两种风格实现相同功能。哪一个更清晰?
cerr << "Error connecting to '" << foo->bar()->hostname.first
<< ":" << foo->bar()->hostname.second << ": " << strerror(errno);
fprintf(stderr, "Error connecting to '%s:%u: %s",
foo->bar()->hostname.first, foo->bar()->hostname.second,
strerror(errno));
这样的例子可以举出很多。(你可能会争论说“合理封装一下会更好”,但是这里可以,其它地方呢?还有,记住,我们的目标是使语言更小,而不是添加更多需要人去学习的新设施。)
两种方式都有各自的优点和缺点,并且没有一个超级解决方案。简单原则让我们必须选择其一,多数的决定是printf
+read/write
。
迭代器和其它模板对象的自增和自减要使用前缀形式(++i
)。
当一个对一个变量进行自增(++i
或i++
)或自减(--i
或i--
)后,表达式的值又没有被用到,就必须确定是用前置操作还是后置操作。
当返回值被忽略时,前置形式(++i
)至少不会比后置形式(i++
)效率低,通常会高得多。因为后置操作需要一个i
的拷贝作为表达式的返回值。当i
是迭代器或其它非数值类型时,拷贝i
可能开销很大。既然当返回值被忽略时这两种形式作用一样,那为什么不总是使用前置操作?
传统的C语言开发中,当表达式值没有被用到时通常使用后置操作,特别是在for
循环中。有人觉得后置操作更易读,因为“主语”(i
)执行了“动作”(++
)这种结构更像英语。
对于简单的值类型(不是对象),用什么都无所谓。对于迭代器和其它模板类型,必须使用前置操作。
合理使用const
。C++11中,对有些常量的使用constexpr
是更好的选择。
声明变量和参数时可以使用const
修饰以表明此变量不会被修改(如const int foo
)。对类方法使用const
标识符表明此方法不会修改类的成员变量(如,class Foo { int Bar(char c) const; };
)。
使人很容易就知道变量是如何使用的。使编译器可以更好地做类型检查,自然也会生成更好的代码。帮助写出正确的代码,因为人们知道他们调用的函数在是否能修改他们的变量方面是如何受限的。还可以帮助人们知道在多线程程序中哪些函数是可以无锁安全调用的。
const
是有传染性的:如果你传递一个const
变量给一个函数,这个函数的原型必须也要接收const
变量(否则这个变量就需要去const
强制类型转换)。这个问题在调用库函数时尤为麻烦。
const
变量,数据成员,方法和参数添加了一层编译期类型检查,可以尽快查错。因此我们强烈建议你在所有合适的情况下使用const
:
- 如果函数不会修改一个引用或指针参数指向的值,这个参数应该是
const
的。 - 尽可能把方法声明成
const
的。访问函数总应该是const
的。如果不修改类的数据成员,没有调用非const
方法,并且没有返回非const
值或数据成员的非const
引用,方法就应该是const
的。 - 构造完对象之后就不会再修改的数据成员也考虑声明成
const
的。
允许使用mutable
关键字,但多线程时就不安全了,所以应该首先认真考虑线程安全问题。
相对于const int* foo
,有人喜欢更int const *foo
。认为后者更可读,因为更一致:它遵循了const
总是紧跟着它修饰的对象的原则。但是这种一致性在没有深度嵌套的指针表达式时并不适用,因为多数的const
表达式中都只有一个const
,作用于基本值。这时,就没有所谓的一致性要维护了。也可以说将const
放在最前面更易读,因为更符合英语习惯:将“形容词”(const
)放在“名词”(int
)前。
简而言之,我们鼓励将const
放在最前面,但不强制要求。只要你自己的代码保持一致就行。
C++11中,使用constexpr
来定义真正的常量或保证常量初始化。
一些变量可以声明成常量以表明这些变量是真正的常量,即,在编译期和链接期都是固定的。
constexpr
的使用使得可以使用浮点数表达式定义常量而不仅仅是字面量;可以定义用户自定义类型的常量;还有和函数调用一起的常量定义。
过早地把一些东西使用constexpr
标记在稍后想要降级的时候可能会引起迁移问题。目前constexpr
函数和构造函数中的限制可能会在这些定义中引入晦涩的解决方案。
constexpr
定义使接口的常量部分有了一个更稳健的规范。使用constexpr
来指定真正的常量和支持它们定义的函数。为了支持constexpr
,要避免复杂的函数定义。不要使用constexpr
来强制内联。
在C++内建的整数类型中,只使用int
。如果程序需要不同大小的变量,就使用<stdint.h>
中有明确宽度的整数类型,如int16_t
。如果你的变量表示的值可能大于或等于2^31 (2GiB),就使用64位类型如int64_t
。记住即使你的值不会比int
大,它还可能用于计算的中间结果,而中间结果有可能需要更大的类型。当不确定时,就选择一个更大的类型。
C++没有指定其整数类型的大小。通常都假设short
是16位,int
是32位,long
是32位,long long
是64位的。
声明一致。
C++中整数类型的大小可能随编译器和体系结构不同而有所不同。
<stdint.h>
中定义了int16_t
,int32_t
,int64_t
等类型。当你需要保证一个整数的大小时,你应该使用这些类型,而非short
,unsigned long long
类似的类型。C语言中的整数类型,只能使用int
。在合适的情况下,推荐使用像size_t
和ptrdiff_t
这样的标准类型。
对于已知不会太大的整数,我们最经常使用的就是int
,如循环计数器等。类似的情况就用原生的int
。你可以假设int
最少是32位的,但不要假设它会比32位更大。如果你需要64位整数,应该用int64_t
或uint64_t
。
对于那些可能会“很大”的整数,用int64_t
。
除非你有合理的理由,如要表示一个位图而非一个数,或需要定义溢出模2^N ,否则不要使用无符号数。尤其不要因为一个数不可能为负而使用无符号类型,这时应该使用断言。
如果你的代码是一个可以返回大小的容器,要确保使用可以适应所有可能情况的类型。不确定时,就使用选大的那个类型。
整数类型互相转换时要小心。整数转换和提升可以会引发难以捉摸的行为。
有些人,包括一些教科书作者,都推荐使用无符号类型来表示永远不可能为负的数,以图达到代码的自文档化。然而在C语言中,这种好处被可能引入的真实bug掩盖。考虑下面的代码:
for (unsigned int i = foo.Length()-1; i >= 0; --i) ...
这段代码永远都不会结束!有时gcc会发现并提醒你,便是通常都不会。同样的bug,在比较有符号和无符号数时也会产生。基本上,是C语言的类型提升机制使无符号类型的行为不同于预期。
所有使用断言来文档化一个变量不能为负,不要使用无符号类型。
代码应该是64位和32位都支持的。要时刻考虑到打印,比较和结构体对齐等问题。
printf()
的有些指示符不能在32位和64位之间很好的移植。C99定义了一些可移植的指示符。 不幸的是MSVC7.1支持得不全,且标准本身也有所遗漏,所以我们有时不得不定义自己的丑陋版本(按标准头文件inttypes.h的风格)。
// size_t的printf的宏,按inttypes.h风格
#ifdef _LP64
#define __PRIS_PREFIX "z"
#else
#define __PRIS_PREFIX
#endif
// 在printf中格式化字符串的%后使用这些宏,在32/64位下都可以获得正确行为,像这样:
// size_t size = records.size();
// printf("%"PRIuS"\n", size);
#define PRIdS __PRIS_PREFIX "d"
#define PRIxS __PRIS_PREFIX "x"
#define PRIuS __PRIS_PREFIX "u"
#define PRIXS __PRIS_PREFIX "X"
#define PRIoS __PRIS_PREFIX "o"
类型 | 不要使用 | 使用 | 备注 |
---|---|---|---|
void * (或任何指针类型) |
%1x | %p | |
int64_t | %qd,%lld | %"PRId64" | |
uint64_t | %qu,%llu,%llx | %"PRIu64", %"PRIx64" | |
size_t | %u | %"PRIuS", %"PRIxS" | C99中是%zu |
ptrdiff_t | %d | %"PRIdS" | C99中是%td |
注意PRI*
这些宏展成后会由编译器拼接成独立字符串。因此如果你使用非常量的格式化字符串,应该插入值而非宏名。仍然可以包含长度指示符,如使用PRI*
宏时在%
后面。综上所述,举个例子,printf("x=%30"PRIuS"\n",x)
在32位Linux上会展开成printf(x=%30" "u" "\n", x)
,编译器看来就是printf("x = %30u\n",x)
。
- 记住
sizeof(void *)
不等于sizeof(int)
。如果需要和指针一样大小的整数,需要使用intptr_t
。 - 你要小心结构体对齐,特别是对那些需要保存在磁盘上的结构体。在64位系统上,任何含有
int64_t
或uint64_t
数据成员的类或结构体都会以8字节对齐。如果你需要32位代码和64位代码在磁盘上共享它,就必须要确保在两种架构上以一致的方式打包。大部分编译器都提供了改变结构体对齐方式的方法。gcc可以使用__attribute__((packed))
,MSVC则提供了#pragma pack()
和__declspec(align())
。 - 创建64位常量时要使用
LL
或ULL
后缀。如:
int64_t my_value = 0x123456789LL;
uint64_t my_mask = 3ULL << 48;
- 如果确实需要在32位和64位使用不同的代码,使用
#ifdef _LP64
来区分。(但是应尽量避免这样使用,应保持代码修改的局部化。)
使用宏要特别小心。优选函数、枚举和const
变量。
宏意味着你看到的代码和编译器看到的代码是不一样的。这可能引入非预期的行为,特别是当宏拥有全局作用域时。
庆幸的是,宏在C++中不像在C中那样必须。当宏用以内联性能关键的代码时,可以使用内联函数;当宏用以保存常量时,可以使用const
变量;当宏用以“缩写”一长变量名时,可以使用引用;当使用宏来条件编译代码时。。。好吧,避免那样做(当然有例外,就是头文件使用#define
保护以避免重复包含)。宏让测试变得异常困难。
宏可以做一些其它技术做不到的事,你可以在一些代码库,尤其中底层库中看到。其中有些特性(像字符串化(#
),和连接##
等)都不能通过语言本身实现。但决定使用宏之前,一定要认真考虑是否有其它替代方案。
以下使用模式可以避免宏的许多问题,如果你使用宏,请尽量遵循以下几条:
- 不要在.h文件中定义宏。
- 紧挨着使用之前定义宏,用完马上
#undef
掉。 - 在替换成你自己的宏之前不要只是简单
#undef
掉已有的宏,你应该选择一个看起来不会冲突的名字。 - 不要使用展开后会破坏C++代码结构的宏,至少也应将行为用文档详细描述。
- 不要使用
##
来生成函数/类/变量名.
整数用0
,实数用0.0
,指针用nullptr
或NULL
,字符(串)用'\0'
。
整数用0
,实数用0.0
。这两点没有争议。
对于指针(地址值),可以在0
、NULL
和nullptr
中选择。对于允许使用C++11标准的工程,使用nullptr
。对于C++03的工程,我们使用NULL
,因为它看起来更像个指针。实际上,一些编译器提供了特殊定义的NULL
,以给出有用的警告信息,尤其中sizeof(NULL)
和sizeof(0)
不相等的情况。
对字符(串)使用\0
。这是正确的类型同时使代码更易读。
相对于sizeof(类型)
,优选sizeof(变量名)
。
当你想要一个特定变量的大小时,使用sizeof(变量名)
。sizeof(变量名)
会随变量类型的变化更新。在与特定变量无关时你才可以使用sizeof(类型)
,如在管理外部或内部数据格式的代码中,就不方便使用相应C++类型的变量。
Struct data;
memset(&data, 0, sizeof(data)); // 好!
memset(&data, 0, sizeof(Struct)); // 不好!
if (raw_size < sizeof(int)) { // 可以
LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
return false;
}
只对于那些凌乱的类型名才使用auto
避免。只要有利于可读性就继续使用显式的类型声明,永远不要在局部变量之外使用auto
。
在C++11中,一个auto类型的变量的实际类型会和初始化它的表达式匹配。你可以在以拷贝方式初始化变量时,或绑定引用时使用auto
。
vector<string> v;
...
auto s1 = v[0]; // 生成一个v[0]的拷贝
const auto& s2 = v[0]; // s2是v[0]的引用
C++的类型名有时非常长且笨重,特别是涉及模板和命名空间时更是如此。在下面这样的语句中:
sparse_hash_map<string, int>::iterator iter = m.find(val);
返回类型难以阅读,并掩盖了代码的主要目的。改成下面这样就好读多了:
auto iter = m.find(val);
没有auto
,在有些表达式中我们要写两次类型名,而这对于代码阅读者没有任何价值,如
diagnostics::ErrorStatus* status = new diagnostics::ErrorStatus("xyz");
auto
使得合理使用中间变量变得更容易,减少了显示写出它们类型的麻烦。
有时使用显式的类型使代码更清晰,特别是当一个变量初始化时要依赖远处声明的东西时。像在下面的表达式中:
auto i = x.Lookup(key);
如果x
是在几百行代码之前声明的,i
的类型就不明显了。
程序员需要理解auto
和const auto&
的不同,否则就会在不必要的地方发生拷贝。
auto
和C++11中的大括号初始化一起用可能会引起困惑。下面的声明是不同的:
auto x(3); // Note: parentheses.
auto y{3}; // Note: curly braces.
x
是一个int
,而y
是一个initializer_list
。通常不可见的代理类型也有同样的问题。
如果auto
变量是接口的一部分,如,是头文件中的常量,程序员就可能在改变其值时无意中改变了其类型,这会导致非预期的API剧烈变化。
auto
只对局部变量是允许的。对文件作用域或命名空间作用域的变量,以及类的成员变量都不要使用auto
。不要把大括号初始化列表赋值给一个auto
变量。
auto
关键字还被用在一个无关的C++特性中:它是以“trailing return type”(延时判断函数返回值类型)方式声明函数语法的一部分。以“trailing return type”方式声明函数是不允许的。
你可以使用大括号初始化。
在C++03中,聚合类型(没有构造函数的数组和结构体)可以使用大括号初始化。
struct Point { int x; int y; };
Point p = {1, 2};
C++11把这种语法扩展到了所有数据类型,这种形式被称为brace-init-list。下面是一些使用示例。
// 包含几个元素的Vector
vector<string> v{"foo", "bar"};
// 和上面一样,这种形式要求initializer_list的构造函数不能是explicit的。
// 否则你应该选择其它形式。
vector<string> v = {"foo", "bar"};
// 包含pair列表的Map。嵌套的braced-init-lists也能工作。
map<int, st5rring> m = {{1, "one"}, {2, "2"}};
// braced-init-list可以隐式转换成返回值类型。
vector<int> test_function() {
return {1, 2, 3};
}
// 遍历braced-init-list.
for (int i : {-1, -2, -3}) {}
// 用braced-init-list调用函数
void test_function2(vector<int> v) {}
test_function2({1, 2, 3});
自定义数据类型也可以定义使用initializer_list
的构造函数,它会自动从braced-init-list创建:
class MyType {
public:
// initializer_list是底层初始化列表的引用,所以可以传值
MyType(initializer_list<int> init_list) {
for (int element : init_list) {}
}
};
MyType m{2, 3, 5, 7};
最后,大括号初始化也可以在没有initializer_list
构造函数时调用普通构造函数。
double d{1.23};
// 只要MyOtherTypeinitializer_list没有构造函数,就调用普通构造函数。
class MyOtherType {
public:
explicit MyOtherType(string);
MyOtherType(int, string);
};
MyOtherType m = {1, "b"};
// 如果相应的构造函数是explicit的,你就不能使用"= {}"的形式。
MyOtherType m{"b"};
不要把braced-init-list赋值给一个auto
局部变量。在列表中只有一个值的情况下,会引起误解。
auto d = {1.23}; // d的类型是 initializer_list<double>。
auto d = double{1.23}; // 好 -- d的类型是double,而不是 initializer_list。
不要使用lambda表达式,也不要使用相关的std::function
或std::bind
等工具。
Lambda表达式是创建匿名对象的一种简洁方法。他们常用在传递函数作为参数时。如std::sort(v.begin(), v.end(), [](string x, string y) { return x[1] < y[1]; });。Lambda在C++11中引入,还有一组工具,用以和函数对象配合,如多态封装器
std::funtction`。
- Lambda比其它定义函数对象以传入STL算法的方法都要简单,可以提高可读性。
- Lambda,
std::function
和std::bind
可以一起作为通用目的的回调机制使用;使得写以绑定的函数为参数的函数更简单。
- Lambda中的变量捕获很有技巧性,可能会成为新的悬空指针BUG滋生地。
- 使用lambda可能失控;很长嵌套的匿名函数很难慬。
不要使用lambda表达式、std::function
或std::bind
。
只使用Boost中被认可的库。
Boost库集是一组受欢迎的经过同行评审的,免费开源的C++库。
Boost库普遍质量很高,可移植性好,并且填补了C++标准库中一些重要的空白,如类型特性(type traits),更好的绑定器,以及更好的智能指针。它也实现了标准库的TR1扩展。
一些Boost库提倡的编程实践可读性不好,如元编程和其它高级模板技巧 ,以及过度追求函数式编程。
为了对代码维护者和阅读者维持高度的可读性,我们只允许Boost库的一个经验证的子集。目前允许下述Boost库:
- Call Traits,来自boost/call_traits.hpp
- Compressed Pair,来自boost/compressed_pair.hpp
- The Boost Graph Library(BGL),来自boost/graph,除了序列化(adj_list_serialize.hpp)、并行/分布算法和数据结构(boost/graph/parallel/和boost/graph/distributed/)。
- Property Map,来自boost/property_map,除了并行/分布属性map(boost/property_map/parallel/*)。
- Iterator中处理迭代器定义的部分:boost/iterator/iterator_adaptor.hpp,boost/iterator/iterator_facade.hpp, and boost/function_output_iterator.hpp
- Polygon中处理构造沃罗诺伊图(Voronoi Diagram)的部分,这部分不依赖Polygon其它部分:boost/polygon/voronoi_builder.hpp, boost/polygon/voronoi_diagram.hpp, and boost/polygon/voronoi_geometry_type.hpp
- Bitmap,来自boost/bitmap
- Statistical Distributions and Functions,来自boost/math/distributions
我们正在积极考虑添加其它Boost特性,所以这个列表以后会不断扩展。
下面的库也允许使用,但是不被鼓励,因为它们已经被C++11标准库取代了:
- Array,来自boost/array.hpp:用
std::array
代替。 - Pointer Container,来自boost/ptr_container:使用std::unique_ptr的容器代替。
在恰当的时候才使用C++11(即先前的C++0x)中的库和语言扩展。在你的工程中使用C++11特性之前先考虑一下对其它环境的可移植性问题。
C++11是ISO的C++标准的最新版本。对语言和库都作了重要改动。
C++11已经成为官方标准,终将会被越来越多的C++编译器支持。它标准化了一些我们已经在使用的通用C++扩展,允许对一些操作速记,并且有一些性能和安全性提升。
C++11实际上比之前的标准要复杂得多(1300页对800页),并且许多开发者都对其不太熟悉。一些特性对代码可读性和可维护性的长期影响尚不得而知。我们不知道相关工具什么时候才能一致地支持C++11那么多特性,特别是在强制使用旧版本工具的工程中。
和Boost库一样,一些C++11扩展所鼓励的编程实践也会降低可读性,如去掉了对阅读代码有帮助冗余检查(如类型名),还有鼓励模板元编译。其它复制了现有系统已有的功能的扩展,这可能导致混乱和转换成本。
除非特别指明,C++11中特性可以使用。除了本指南其它部分中描述的,下面的C++11特性不可以使用:
- 结尾返回类型的函数,如,使用
auto foo() -> int;
代替nt foo();
,因为要和许多已存在的函数声明保持一致。 - 编译期有理数(
<ratio>
),因为它被绑定到一个更加重量级的模板风格上。 <cfenv>
和<fenv.h>
头文件,因为许多编译器都不能可靠地支持那些特性。- Lambda表达式,或相关的
std::function
或std::bind
工具。
最重要的一致性原则就是管理命名。命名风格可以让我们无需查看声明就能立刻知道它的含义:是类型,变量,函数,常量还是宏等等。我们大脑的模式匹配引擎在很大程度上依赖这些命名规则。
命名规则有一定程度的随意性,但是我们认为在命名这件事上一致性比各自为政重要的多,所以不管你怎么想,规矩就是规矩。
函数名,变量名和文件名应该是描述性的,并避免缩写。
尽量给一个名字合理的描述性。不要操心省一点行空间的事,代码可以让新的阅读者快速理解重要的多得多。不要用缩写,它会使你项目外的读者迷惑或不熟悉,也不要通过删除单词中的几个字母来缩写。
// 符合规定的命名
int price_count_reader; // 没有缩写
int num_errors; // "num"是一个广为人知的用法
int num_dns_connections; // 大多数人知道"DNS"是什么意思
// 不符合规定的命名
int n; // 没有意义
int nerr; // 不清楚的缩写
int n_comp_conns; // 不清楚的缩写
int wgc_connections; // 只有你的团队知道是什么意思
int pc_reader; // 许多东西都可以缩写成"pc"
int cstmr_id; // 删除了中间的字母
文件名应该都是小写字母。可以包含下划线()和破折号(-),用什么要遵循你项目的约定,如果没有标准,就用“”。
下面是可接受的文件名:
my_useful_class.cc
my-useful-class.cc
myusefulclass.cc
myusefulclass_test.cc // _unittest和_regtest已被弃用
C++源文件后缀应是.cc,头文件后缀应为.h。
不要使用/usr/include中已经存在的文件名,如db.h。
通常应该让你的文件名更明确,如http_server_logs.h就比logs.h好。一个非常常规的用法就是用一对文件,比如名为foo_bar.h和foo_bar.cc,来定义一个名为FooBar的类。
内联函数必须要.h文件中。如果你的内联函数很短,就应该直接写在.h文件中;如果很长,就应该写在另一个以-inl.h为后缀的文件中。对于有大量内联代码的类,可能有三个与之对应的文件:
url_table.h // 类声明
url_table.cc // 类定义
url_table-inl.h // 大的内联函数
见-inl.h文件一节。
类型名应该以大写字母开头,并且中间的每个单词都以大写字母开头,不使用下划线:MyExcitingClass
,MyExcitingEnum
。
所有的类型名(类,结构体,typedef
,枚举等)都要使用相同的约定。类型名应该以大写字母开头,并且中间的每个单词都以大写字母开头。不需要使用下划线。如:
// 类和结构体
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...
// typedef
typedef hash_map<UrlTableProperties *, string> PropertiesMap;
// 枚举
enum UrlTableErrors { ...
变量名都是小写字母,单词使用下划线隔开。类成员以下划线结尾。如,my_exciting_local_variable
,my_exciting_member_variable_
如:
string table_name; // 好 - 使用下划线
string tablename; // 好 - 所有的字母都是小写
string tableName; // 不好 - 大小写混合
类数据成员(也叫实例变量或成员变量)和普通变量一样,是小写字母加可选的下划线,但是最后要以一个下划线结尾。
string table_name_; // 好 - 下划线结尾
string tablename_; // 好
结构体的数据成员应该和普通变量一样命名,结尾不需要和类成员一样的下划线。
struct UrlTableProperties {
string name;
int num_entries;
}
什么时候用结构体而不是用类请见结构体 VS 类一节。
对全局变量命名没有特殊要求,它本来也极少使用,但如果你非要用一个全局变量,考虑使用g_
或其它的前缀来使其很容易和局部变量区分开。
用k
跟着大小写混合的形式命名常量:kDaysInAWeek
。
对所有编译期的常量,不管是局部的,全局的还是一个类中的,都遵循和其它变量稍微不同的命名约定。使用k
跟着首字母大写的单词来命名。
const int kDaysInAWeek = 7;
普通函数使用大小写混合模式命名;存取函数要和变量名匹配:MyExcitingFunctions()
,MyExcitingMethod()
,my_exciting_member_variable()
,set_my_exciting_member_variable()
。
函数名应该以大写字母开头,中间的每一个单词都首字母大写。不需要下划线。
如果函数在碰到某些错误时会崩溃,你应该在函数名后面加上OrDie
。这些函数都是生产环境中使用的代码,且在常规操作时有可能会失败。
AddTableEntry()
DeleteUrl()
OpenFileOrDie()
存取函数(get/set函数)应该和它们操作的变量名匹配。下面摘录了一个类,这个类有一个名为num_entries_
的成员变量。
class MyClass {
public:
...
int num_entries() const { return num_entries_; }
void set_num_entries(int num_entries) { num_entries_ = num_entries; }
private:
int num_entries_;
};
非常短小的内联函数也可以使用小写字母。如,那些特别轻量的函数,轻的你在循环中调用都不会缓存其返回值,这时使用小写字母是可以接受的。
命名空间名字都是小写的,基于工程名和目录结构:google_awsome_project
。
关于命名空间的讨论详见命名空间。
枚举应该和常量或宏一样命名:或kEnumName
或ENUM_NAME
。
每个枚举值应该优先以常量的形式命名。不过按宏方式命名也是可以接受的。枚举名,UrlTableErrors
(以及AlternateUrlTableErrors
),是一个类型,因此使用大小写混合模式。
enum UrlTableErrors {
kOK = 0,
kErrorOutOfMemory,
kErrorMalformedInput,
};
enum AlternateUrlTableErrors {
OK = 0,
OUT_OF_MEMORY = 1,
MALFORMED_INPUT = 2,
};
2009年1月之前,本指南还建议将枚举按宏一样命名。这会引起枚举值和宏之间的命名冲突,所以改为以常量风格命名。新代码应该尽量使用常量风格,但没有必要修改旧代码,除非它们产生了编译问题。
通常都不需要定义宏,不是吗?如果你定义了宏,看起来应该是这样的:MY_MACRO_THAT_SCARES_SMALL_CHILDREN
。
请查看对宏的描述:通常都不应该使用宏。但是如果确实需要宏,应该以全大写字母加下划线来命名。
#define ROUND(x) ...
#define PI_ROUNDED 3.0
如果你要命名的东西和已有的C/C++代码中的类似,你应该遵循已有的命名约定。
bigopen()
: 函数名,以open()
方式uint
:typedef
bigpos
: 结构体或类, 参照了pos
的形式sparse_hash_map
: STL相似,遵循STL命名约定LONGLONG_MAX
: 常量,如同INT_MAX
尽管注释写起来很痛苦,但对代码可读性至关重要。下面规则描述了你应该在什么地方使用什么样的注释。但是要记住:尽管注释很重要,最好的代码却一定是可以自解释的。给类型和变量起一个合理的名字,要远远胜过用一个含糊的名字再用注释去解释。
注释是写给读者的:下一个需要理解你代码的贡献者。慷慨些吧,下一个人很可能就是你自己。
用//
和/* */
都行,只要你自己保持一致。*
用//
和/* */
都行,但//
更常用;你自己如何使用注释和注释风格要统一。
文件开头应为版权许可证后面跟着本文件内容描述。
每一个文件都应该包含版权许可证。为你的项目选择一个合适的许可证(如,Apache 2.0,BSD,LGPL,GPL)。
如果你对一个有作者信息的文件做了重大的改动,考虑删除作者信息行。
Why?
每一个文件头部都要有一段描述其内容的注释。
通常.h文件应该描述它声明的类,以及类作用和使用方法的简要说明。.cc文件应该包含实现细节或算法技巧描述等更多信息。如果你认为实现细节或算法讨论会对.h读者有用,就把它们放在.h文件中,只是别忘了在.cc文件中要提醒一下它们在.h文件。
不要在.h和.cc文件之间复制注释。复制注释就失去注释的真正意义了。
每一个类定义都要附带一个注释来描述其功能和用法。
// GargantuanTable内容的迭代器。使用示例:
// GargantuanTableIterator* iter = table->NewIterator();
// for (iter->Seek("foo"); !iter->done(); iter->Next()) {
// process(iter->key(), iter->value());
// }
// delete iter;
class GargantuanTableIterator {
...
};
如果文件头部已经有了类的描述,直接来一句“完整描述见文件头”也没问题,但一定要确保有此类注释。
如果有同步相关的假设,就要写下来。一个类对象是否可以被多线程访问,多线程下规则和常量的使用在文档化时要格外注意。
函数需要声明注释;函数定义处的注释描述操作。
每个函数的前面都要有注释来说明其功能和用法。这些注释应该是描述性的("Opens the file")而不是命令式的("Open the file");注释是描述函数用的,而不是告诉函数干什么的。通常,这些注释都不会描述函数如何执行任务,这应该是函数定义处的注释要做的事。
函数声明注释中要提及的内容:
- 输入输出。
- 对于类成员函数:对象是否会在调用它之外记住引用参数,它是否会释放这些引用参数。
- 如果函数分配了内存,调用者必须负责释放
- 参数可否为空指针
- 使用时有没有隐含的性能开销
- 是否可以重入。其同步假设是什么?
下面是一个例子:
// 返回这个表的一个迭代器。使用完后需要用户来删除这个迭代器。
// 迭代器指向的GargantuanTable对象被删除后就不能再使用这个迭代器了。
//
// 此迭代器初始指向表头
//
// 这个方法等价于:
// Iterator* iter = table->NewIterator();
// iter->Seek("");
// return iter;
// 如果你拿到这个迭代器会立刻查找另一个位置,用NewIterator()会更快,
// 并可以避免额外的查找操作。
Iterator* GetIterator() const;
然而,没有必要罗里罗嗦地去做些显而易见的说明。注意下面的例子就没有必要说“否则返回false
”,因为这个已经隐含了。
// 如果表满了就返回true
bool IsTableFull();
当注释构造函数和析构函数时,要清楚读者是明白构造函数和析构函数的,所以类似“销毁此对象”这种注释是没有用的。需要说明是构造函数用参数来干什么(如,是否会持有指针),以及析构函数做了什么样的清理工作。析构函数通常都不需要头文件注释。
函数中使用的任何技巧都应该在注释中说明。如,在一个函数定义注释中,你可能会描述你使用的编码技巧,给出大体的实现步骤,以及你为什么要这样而不是那样实现此函数。你也会提及为什么前半段代码需要锁,而后半段不需要。
注意不要只是简单重复头文件或其它什么地方的声明注释。简要概括一下函数是可以的,但着重是要注释如何实现。
通常变量名应该足以说明变量的用途。在一些特定情况下,才需要多一点注释。
每一个类数据成员(又称实例变量或成员变量)都要注释其用途。变量是否可以持有特定含义的哨兵值,如空指针或-1
,也要说明。举个例子:
private:
// 跟踪表的项数。用来保证不越界。
// -1 表示我们还不知道表的项数。
int num_total_entries_;
和数据成员一样,全局变量也要用注释来说明其含义和用法。如:
// 本次回归测试中总共跑的测试用例数
const int kNumTestCases = 6;
在你的实现中,应该注释技巧、不明显的、有趣的或重要的部分。
技巧和复杂代码块前需要注释。如:
// 将结果除2,注意x保存进位
for (int i = 0; i < result->size(); i++) {
x = (x << 8) + (*result)[i];
(*result)[i] = x >> 1;
x &= 1;
}
不明确的行尾部也要添加注释。这些注释和代码之间要有2个空格。如:
// If we have enough memory, mmap the data portion too.
mmap_budget = max<int64>(0, mmap_budget - index_->length());
if (mmap_budget >= data_size_ && !MmapData(mmap_chunk_bytes, mlock))
return; // Error already logged.
注意上面既有表述代码作用的注释,也有注释提醒函数返回时已经记录了日志。
如果在连续的行上都有注释,将它们对齐更可读:
DoSomething(); // Comment here so the comments line up.
DoSomethingElseThatIsLonger(); // Comment here so there are two spaces between
// the code and the comment.
{ // One space before comment when opening a new scope is allowed,
// thus the comment lines up with the following comments and code.
DoSomethingElse(); // Two spaces before line comments normally.
}
DoSomething(); /* For trailing block comments, one space is fine. */
当传给函数一个空指针,布尔值,或一个字面数值时,应该考虑注释一下它们是什么意思,或用常量使你的代码可以自说明。如,对比下面两段代码:
bool success = CalculateSomething(interesting_value,
10,
false,
NULL); // What are these arguments??
和
bool success = CalculateSomething(interesting_value,
10, // Default base value.
false, // Not the first time we're calling this.
NULL); // No callback.
抑或使用可以自解释的常量也行:
const int kDefaultBaseValue = 10;
const bool kFirstTimeCalling = false;
Callback *null_callback = NULL;
bool success = CalculateSomething(interesting_value,
kDefaultBaseValue,
kFirstTimeCalling,
null_callback);
注意永远都不要对代码进行解释。假设读代码的人即便不知道你要做什么,他的C++水平也要比你高:
// Now go through the b array and make sure that if i occurs,
// the next element is i+1.
... // Geez. What a useless comment.
要在标点、拼写和语法上心思;写得好的注释更易读。
注释应该和叙事文本一样易读,要有正确的大小写和标点符号。通常完整的句子要比片段更易读。短注释,如行尾注释,有时可以不那么正规,但你自己也要保持一致的风格。
虽然让代码评审者指出你应该用分号时使用了逗号很让人不爽,但让源代码保持一个高层次的清晰性和可读性是非常重要的。合理的标点、拼写和语法对此会有所帮助。
对临时代码、短期解决方案以及可用但不够完美的代码使用TODO
注释。
TODO
应该包含全大写的字符串TODO
,后面跟着可以提供这个问题来龙去脉的人的大名、邮箱或其它标识。后面再一个可选的冒号。这么做的主要目的是为了有一个一致的TODO
格式,可以查找能为该请求提供更多细节的人。TODO
不是用来注释某人以后会修正这个问题的。所以,当添加一条TODO
时,总是应该写上你自己的名字。
// TODO([email protected]): Use a "*" here for concatenation operator.
// TODO(Zeke) change this to use relations.
当你的TODO
是类似“未来会如何如何”这种形式时,要确保包含确切的日期(“2005年11月修正”)或特定事件(“当所有客户都能处理XML文件时,就移除所有这些代码”)。
对弃用的接口使用DEPRECATED
注释进行说明。
你可以用包含全大写的单词DEPRECATED
的注释来标记一个已经弃用的接口。这个注释或者放在接口的声明之前或者放在同一行。
在单词DEPRECATED
后面的括号里写上你的大名,邮箱,或其它身份标识。
一个弃用注释必须包含简单清楚的用法说明,来帮助人们修改他们的调用点。C++中,你可以把弃用函数声明成一个内联函数,并在其中调用新的接口。
把一个接口标记成DEPRECATED
并不能自动修改调用点。如果你真的需要调用者停止使用弃用的设施,你应该自己去修改调用点或纠集一帮人来帮你干这个事。
新代码不应该调用弃用的接口,而要使用新接口。如果你看不懂用法说明,就找创建这个弃用的人,让他们教你使用新接口。
编码风格和格式可以很随意,但是在一个工程中遵循相同的风格要容易得多。个体可以不同意格式规定中的所有项,一些规则可能需要时间适应,但是所有贡献者使用相同的代码风格很重要,这使他们自己也可以更容易地阅读和理解每个人的代码。
为了帮助你以正确格式编码,我们已经创建了一个emacs配置文件。
你代码中的每一行文本长度应最多不能超过80个字符。
我们知道这条规则有争议,但是许多已有代码都在遵循它,我们觉得保持这种一致性很重要。
本条规则的拥护者认为强迫他们放大窗口是一种冒犯,同时也没有必要。一些人同时并排开了几个编程窗口,因此根本没有多余空间来拉伸他们的窗口。人们设置他们的工作环境时会假设一个窗口的最大宽度,80列已经成为传统的标准,为什么要改变?
支持改变的人认为更宽的行可以使代码更可读。80列限制是顽固地倒退回上世纪60年代大型机的时代。现代的显示器拥有更宽的屏幕,可以轻松显示更长的行。
80个字符是最大值。
- 例外:如果一个注释行包含了示例命令或一个超过80字符的URL,那么这一行可以超过80个字符以方便拷贝粘贴。
- 例外:当
#include
语句后面的路径长度超过80列宽时。要尽量避免这种情况。 - 例外:不需要关心头文件保护超过长度限制。
非ASCII字符极少需要,用也要是UTF-8编码格式。
你不应该把用户界面文本硬编码到代码中,即使是英语也不行,所以需要使用非ASCII字符的情况非常之少。但有些特殊情况适合包含此类单词。例如,如果你的代码要解析外文数据文件,硬编码文件中一些非ASCII字符串作为分隔符是合理的。更常见的,单元测试代码(不需要本地化)可能包含非ASCII字符串。在这些情况下你应该使用UTF-8编码,因为除了ASCII编码外,大多数工具都能理解UTF-8编码。
Hex编码也可以,在有助于可读性的情况下尤为鼓励,如,"\xFE\xBB\xBF"
,或更简单的,u8"\uFEFF"
是一个零宽度、无间断的UNICODE空白字符,如果在源代码中直接使用UTF-8就是不可见的。
使用u8
来确保一个包含\uXXXX
转义序列的字符串字面值会以UTF-8编码。不要在包含UTF-8编码的非ASCII字符的字符串中使用它,因为如果编译器不把源文件解释成UTF-8,这会产生错误的输出。
不太明白
不要使用C++11中的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个空格缩进
Type par_name2,
Type par_name3) {
DoSomething(); // 2个空格缩进
...
}
需要指明几点:
- 如果不能把函数返回类型和函数名放在一行上,可以在中间加换行。
- 如果在函数的返回类型之后换行,就不要缩进了。
- 左圆括号也总是和函数名在同一行上。
- 左括号和函数名之间没有空格。
- 括号和参数之间没有空格。
- 左大括号总是和最后一个参数在同一行上。
- 右大括号或者单独一行,或者(如果不违背其它规则)和左大括号在同一行上。
- 右小括号和左大括号之间要有一个空格。
- 所有参数都要具名,声明和实现时都要有标识名。
- 所有参数都应该尽量对齐。
- 默认缩进是2个空格。
- 换行的参数使用4字节缩进。
如果有参数没有使用,在函数定义时把这个变量名注释出来:
// 接口中总是使用具名参数
class Shape {
public:
virtual void Rotate(double radians) = 0;
}
// 声明中总是使用具名参数
class Circle : public Shape {
public:
virtual void Rotate(double radians);
}
// 定义时注释未使用的参数名
void Circle::Rotate(double /*radians*/) {}
// 不好 - 如果有人以后想实现这个函数,这个变量的含义就不清楚
void Circle::Rotate(double) {}
尽量放在同一行,否则换行圆括号中的实参。
函数调用形式如下:
bool retval = DoSomething(argument1, argument2, argument3);
如果参数不能放在同一行上,就应该断到多行,后面的每一行都和第一个参数对齐。不要在左括号后和右括号前不要有空格。
bool retval = DoSomething(averyveryveryverylongargument1,
argument2, argument3);
如果函数有太多参数,考虑每行一个来使代码更易读:
bool retval = DoSomething(argument1,
argument2,
argument3,
argument4);
参数也可以都放在函数名下面的行上,一行一个:
if (...) {
...
...
if (...) {
DoSomething(
argument1, // 4 space indent
argument2,
argument3,
argument4);
}
特别是当函数签名太长放在同一行时会超过一行的最大长度,更要如此。
和函数调用一样格式化大括号初始化列表。
如果列表跟在一个名字后(如一个类型或一个变量名),就把{}
看作函数调用中的小括号一样格式化。如果没有名字,假想一个0长度的名字。
尽量把所有的东西都放到一行上。如果不能放到一行上,左大括号应该是其所在行的最后一个字符,且右大括号应该是其所在行的第一个字符。
// 单行大括号列表示例。
return {foo, bar};
functioncall({foo, bar});
pair<int, int> p{foo, bar};
// 需要换行时。
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}};
尽量括号中不要有空格。else
关键字另起一行。
一个基本的条件语句有两种可接受的形式。一种在小括号和条件之间有空格,另一种没有。
最常用的形式是没有空格的。另一种也可以,但是要保持一致。如果你在修改一个文件,使用已有的格式。对于新代码,使用同一目录或同一工程中的形式。如果不确定并且没有个人倾向,就不要用空格。
if (condition) { // 小括号里没有空格
... // 2个空格缩进
} else if (...) { // 和右大括号在同一行的else语句。
...
} else {
...
}
如果你选择在小括号中添加空格:
if ( condition ) { // 小括号中有空格 - 少用
... // 2个空格缩进
} else { // 和右大括号在同一行的else语句。
...
}
注意无论何种形式,你都必须在if
和左小括号之间加空格。如果有大括号,右小括号和左大括号之间也要有空格。
if(condition) // 不好 - if后面缺了空格。
if (condition){ // 不好 - {后面缺了空格。
if(condition){ // 更不好
if (condition) { // 好 - if后和{前都有合适的空格。
如果可以加强可读性,短的条件语句可以放在一行。只有在代码行非常精简并且没有使用else
语句时才能这样。
if (x == kFoo) return new Foo();
if (x == kBar) return new Bar();
当if
语句有else
子句时,这是不允许的:
// 不允许 - IF语句在有ELSE时还放在同一行上
if (x) DoThis();
else DoThat();
通常,单行语句不需要花括号,但是如果你喜欢也可以使用;有复杂条件或语句的条件或循环语句使用花括号更易读。一些工程要求if
必必须要有对应的大括号。
if (condition)
DoSomething(); // 2个空格缩进。
if (condition) {
DoSomething(); // 2个空格缩进。
}
然而,如果if-else
语句的某一部分使用了花括号,其它的部分也必须要使用:
// 不允许 - IF使用了花括号而ELSE没有
if (condition) {
foo;
} else
bar;
// 不允许 - ELSE使用了花括号而IF没有
if (condition)
foo;
else {
bar;
}
// 因为一部分使用了花括号,所以IF和ELSE都需要使用
if (condition) {
foo;
} else {
bar;
}
switch
语句可以使用大括号分块。要注释case
之间重要的失败。空循环体应该用{}
或continue
。
switch
语句中的case
块可以使用也可以不使用花括号,这取决于你的喜好。如果要使用花括号,需要像下面的示例那样放置。
如果不是以枚举值为条件,switch
语句应该有一个default
匹配(如果使用枚举值,对没有处理的值编译器会有警告)。如果default
匹配永远都不应该发生,简单使用一个assert
即可:
switch (var) {
case 0: { // 2个空格缩进
... // 4个空格缩进
break;
}
case 1: {
...
break;
}
default: {
assert(false);
}
}
空循环体应该用{}
或continue
,而不能只一个单独的分号。
while (condition) {
// 重复测试直到返回fasle。
}
for (int i = 0; i < kSomeNumber; ++i) {} // 好 - 空循环体。
while (condition) continue; // 好 - continue表明无逻辑。
while (condition); // 不好 - 看起来像do/while循环的一部分。
点和箭头周围都不要有空格。指针操作符后面也不要有空格。
下面都是正确格式的指针和引用示例:
x = *p;
p = &x;
x = r.y;
x = r->y;
注意:
- 访问成员时的点号和箭头周围都没有空格。
- 指针操作符
*
或&
后面没有空格。
当声明指针变量或参数时,你让星号靠近类型或靠近变量名都可以:
// 下面这样可以,星号前面加空格。
char *c;
const string &str;
// 下面这样也可,星号后面加空格。
char* c; // 但是要记住多个变量的时候:"char* c, *d, *e, ...;"!
const string& str;
char * c; // 不好 - * 前后都有空格
const string & str; // 不好 - &前后都有空格
在一个文件中使用的形式要一致,所以修改文件时,要使用文件中已用的风格。
当你的布尔表达式超过标准行长度时,如何换行要保持一致。
下面的例子中,逻辑与操作符总是在行尾:
if (this_one_thing > this_other_thing &&
a_third_thing == a_fourth_thing &&
yet_another && last_one) {
...
}
注意在这个例子中,两个逻辑与操作符&&
都在行尾。这在Google的代码是很常用,尽管把操作符放在行首的断行方式也是允许的。放心大胆地合理使用小括号,因为如果使用得当它们会极大增加可读性。同时也要注意,总是使用符号操作符,如&&
和~
,而不要使用文字操作符,如and
和cmpl
。
不要徒劳地用小括号包围起return
表达式。
只有当你在x = expr
中也要使用括号时,才需要在return expr
是使用小括号。
return result; // 简单情况不用括号。
return (some_long_condition && // 括号使用正确,增加了复杂表达式的可读性
another_condition);
return (value); // 不好! 不是var = (value)的形式;
return(result); // 不好! return不是函数!
用=
,()
或{}
均可。
你可以在=
,()
和{}
之间选择,下面都是正确的:
int x = 3;
int x(3);
int x{3};
string name = "Some Name";
string name("Some Name");
string name{"Some Name"};
在有接收initializer_list
的构造函数的类型上使用{}
要小必,{}
语法会尽可能优先选择initializer_list
构造函数。要使用其它构造函数,应使用()
。
vector<int> v(100, 1); // A vector of 100 1s.
vector<int> v{100, 1}; // A vector of 100, 1.
大括号形式也可以阻止整数类型窄化转型(narrowing conversion),这可以防止一些编程错误。
int pi(3.14); // OK -- pi == 3.
int pi{3.14}; // 编译器错误: 窄化转型
井号开头的预处理指令应该在行首。
即使预处理指令在缩进的代码段中,也要从行首开始。
// 好 - 指令在行首
if (lopsided_score) {
#if DISASTER_PENDING // 正确 -- 从行首开始
DropEverything();
# if NOTIFY // 可以但不要求#后有空格
NotifyClient();
# endif
#endif
BackToNormal();
}
// 不好 - 缩进指令
if (lopsided_score) {
#if DISASTER_PENDING // 错误! “#if”应该在行首
DropEverything();
#endif // 错误!不要缩进“#endif”
BackToNormal();
}
代码段顺序为public
,protected
和private
,各缩进一个空格。
类声明的基本形式(不包含注释,参见类注释相关讨论)是:
class MyClass : public OtherClass {
public: // 注意一个空格缩进!
MyClass(); // 普通的2空格缩进。
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_;
DISALLOW_COPY_AND_ASSIGN(MyClass);
};
注意事项:
- 任何基类名都应该和子类在同一行上,但受限于80列限制。
public:
、protected:
和private:
关键字应缩进一个空格。- 上面的关键字,除了第一个出现的,都应该前面加一空行。这条规则在较小的类中是可选的。
- 上面的关键字后面不要留空行。
- 先是
public
段,接着是protected
段,最后是private
段。 - 每一段内部的声明顺序参见声明顺序一节。
构造函数初始化列表可以在同一行上,也可以分行,后面的行都缩进4个空格。
初始化列表有两种可接受的形式:
// 可以放在同一行上:
MyClass::MyClass(int var) : some_var_(var), some_other_var_(var + 1) {}
或
// 需要多行,缩进4个空格,算上第一行的冒号
MyClass::MyClass(int var)
: some_var_(var), // 4空格缩进
some_other_var_(var + 1) { // 和上一行对齐
...
DoSomething();
...
}
命名空间内容不缩进。
命名空间不增加缩进层次。如:
namespace {
void foo() { // 正确。命名空间里不需要额外的缩进。
...
}
} // namespace
命名空间不需要缩进:
namespace {
// 错误。不应该有缩进。
void foo() {
...
}
} // namespace
当声明了嵌套命名空间时,每个一行:
namespace foo {
namespace bar {
水平空白取决于位置。不要在行尾添加空白。
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; } // 用空格分隔大括号和实现代码。
...
行尾添加空格和给其它编辑代码的人造成额外的负担,当他们合并时,会删除已存在的空白。所以,不要引入行尾空白。编辑时删除行尾的空白,或通过单独的清理操作删除(当然要在没有其它人正在使用此文件时)。
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 ( ; i < 5 ; ++i) { // for循环中,分号之后一定要有空格,前面也可以有
...
for (auto x : counts) { // 基于范围的for循环冒号前后总需要空格
...
}
switch (i) {
case 1: // switch中case子句前的冒号不需要空格。
...
case 2: break; // 如果冒号后有代码,就需要加空格。
x = 0; // 赋值操作符周围需要空格。
x = -5; // 一元操作符和其参数之间不需要空格。
++x;
if (x && !y)
...
v = w * x + y / z; // 二元操作符周围通常都有空格,
v = w*x + y/z; // 但删除因子周围的空格也可以。
v = w * (x + z); // 小括号内不需要空格。
vector<string> x; // 尖括号内部(<和>),<之前或>(之间不需要空格。
y = static_cast<char*>(x);
vector<char *> x; // 类型和指针符号间可以有空格,但要保持一致。
set<list<string>> x; // C++11代码中允许。
set<list<string> > x; // C++03要求> >之间有一空格。
set< list<string> > x; // 你也可以在< <间使用空格来保护对称性。
尽可能少用垂直空白。
这更像是原则面不是规则:不要随便使用空行。特别是在函数之间不要添加起过一两个空行,函数开始不要有空行,也不要以空行结束,并且在函数体中使用空行也要节制。
基本原则是:一屏能显示的代码越多越好,这就越容易跟踪和理解程序的控制流。当然,过于密集和过于松散的代码可读性同样不好,这你要自己判断。但是,通常都是垂直空白越少越好。
一些经验法则可以帮助确定何时空行是有用的:
- 函数内头尾的空行对可读性几乎没有帮助。
if-else
代码链中的空行有助于可读性。
上面的编码约定都是强制性的。但是和所有好的规则一样,有时候也会有例外,我们会在这里讨论这样的情况。
处理不符合本指南规则的代码时,你可以变通一下。
如果你发现你正在修改的代码使用的规则不来自本指南,那么可以有一些变通以和原代码中的风格保持一致。如果你不知道怎么做,就去问原作者或现在对代码负责的人。记住,一致性也包含本地一致性。
Windows程序员已经有了一组编码约定,主要演变自Windows头文件和其它微软代码。我们希望任何人都能轻松看懂我们的代码,所以我们对在任何平台上写C++的人有单独的一组规则。
有必要重申几点,如果你习惯Windows风格,可能忘记的规则:
- 不要使用匈牙利命名法(如把一个整数命名为
iNum
)。使用Google的命名约定,包括源代码文件应使用.cc扩展名。 - Windows定义了许多原生类型的同义词,如
DWORD
,HANDLE
等。在调用Windows API函数时使用这些类型是可接受的,并且是被鼓励的。即使如此,也要尽可以接近原生的C++类型。如,使用const TCHAR *
来代替``LPCTSTR`。 - 使用微软的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。为了使你的代码更容易和其它工程分享,避免显式包含这个文件(除了在precopile.cc中), 应该使用/FI编译选项来自动包含它。
- 资源头文件,通常名为resource.h并且只包含一些宏,不需要遵守本指南的风格。
运用常识,并 保持一致性。
如果你正在写代码,停几分钟,看看你周围的代码并确定其风格。如果它们在if
语句周围使用空格,你也要这么做。如果它们的注释用星号围成的盒子包围,你也要如此。
使用风格指南的核心是使用通用的词汇来让人们可以准确知道你在说什么,而不是聚焦于如何表达上。我们列出公共的风格规则就是要让人知道这些词汇。但本地风格也很重要。如果你向一个文件中添加的代码和周围的代码很不搭,这种中断会打乱读者的节奏,要尽量避免。
好了,关于代码风格已经写得够多了;代码本身要有趣得多。尽情享受吧!