Google-C++编程代码风格(转载)
Google C++ Code Style
零、序
C++ 是 Google 大部分开源项目的主要编程语言。正如每个 C++ 程序员都知道的,C++ 有很多强大的特性,但这种强大不可避免的导致它走向复杂,使代码更容易产生 bug, 难以阅读和维护。
本指南的目的是通过详细阐述 C++ 注意事项来驾驭其复杂性。这些规则在保证代码易于管理的同时,也能高效使用 C++ 的语言特性。
风格亦被称作可读性,也就是指导 C++ 编程的约定。使用术语 “风格” 有些用词不当,因为这些习惯远不止源代码文件格式化这么简单。
使代码易于管理的方法之一是加强代码一致性。让任何程序员都可以快速读懂你的代码这点非常重要。保持统一编程风格并遵守约定意味着可以很容易根据 “模式匹配” 规则来推断各种标识符的含义。创建通用、必需的习惯用语和模式可以使代码更容易理解。在一些情况下可能有充分的理由改变某些编程风格,但我们还是应该遵循一致性原则,尽量不这么做。
一、预编译
1.1、头文件
一个 .cc/.cpp 文件都应该对应一个 .h 文件。也有些常见例外,例如单元测试代码和只包含 main() 入口函数的源文件。
1.1.1、Self-contained 头文件
头文件应该能够自给自足(self-contained,也就是可以作为第一个头文件被引入),简单来说就是头文件中依赖的其他声明要在头文件中定义清楚,而不能依赖在 .cc 文件中调整引入顺序解决依赖。
如果 .h 文件声明了一个模板或内联函数,同时也在该文件加以定义。凡是有用到这些的 .cc 文件,就得统统包含该头文件,否则程序可能会在构建中链接失败。
有个例外:如果某函数模板为所有相关模板参数显式实例化,或本身就是某类的一个私有成员,那么它就只能定义在实例化该模板的 .cc 文件里。
1.1.2、#define 保护
所有头文件都应该使用 #define 来防止头文件被多重包含,命名格式当是:< PROJECT > _ < PATH > _ < FILE > _ H _。
为保证唯一性,头文件的命名应该基于所在项目源代码树的全路径。例如项目 foo 中的头文件 foo/src/bar/baz.h 可按如下方式保护:
1 |
|
#define 与 #pragma once 区别
#pragma once
是编译相关,就是说这个编译系统上能用,但在其他编译系统不一定可以,也就是说移植性差。所以尽量使用 #ifndef 来避免头文件重复引用。
1.1.3、前置声明
尽可能地避免使用前置声明。使用 #include 包含需要的头文件即可。
所谓「前置声明」(forward declaration)是类、函数和模板的纯粹声明,没有伴随着定义。
- 优点
- 前置声明能够节省编译时间,多余的 #include 会迫使编译器展开更多的文件,处理更多的输入。
- 前置声明能够节省不必要的重新编译的时间。#include 使代码因为头文件中无关的改动而被重新编译多次。
- 缺点
前置声明隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。
前置声明可能会被库的后续更改所破坏。前置声明函数或模板有时会妨碍头文件开发者变动其 API。例如扩大形参类型,加个自带默认参数的模板形参等等。
前置声明来自命名空间 std:: 的 symbol 时,其行为未定义。
很难判断什么时候该用前置声明,什么时候该用 #include。极端情况下,用前置声明代替 #include 甚至都会暗暗地改变代码的含义:
1
2
3
4
5
6
7
8
9// b.h:
struct B {};
struct D : B {}
// good_user.cc:
void f(B*);
void f(void*);
void test(D* x) { f(x); } // calls f(B*)如果 #include 被 B 和 D 的前置声明替代,test() 就会调用 f(void*)。
前置声明不少来自头文件的 symbol 时,就会比单单一行的 include 冗长。
仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂。
- 结论
- 尽量避免前置声明那些定义在其他项目中的实体。
- 函数总是使用 #include。
- 类模板优先使用 #include。
1.1.4、内联函数
只有当函数只有 10 行甚至更少时才将其定义为内联函数。
定义
当函数被声明为内联函数之后,编译器会将其内联展开,而不是按通常的函数调用机制进行调用。优点
只要内联的函数体较小,内联该函数可以令目标代码更加高效。对于存取函数以及其它函数体比较短,性能关键的函数,鼓励使用内联。缺点
滥用内联将导致程序变得更慢。内联可能使目标代码量或增或减,这取决于内联函数的大小。内联非常短小的存取函数通常会减少代码大小,但内联一个相当大的函数将增加代码大小。现代处理器由于更好的利用了指令缓存,小巧的代码往往执行更快。结论
一个较为合理的经验准则是,不要内联超过 10 行的函数。谨慎对待析构函数,析构函数往往比其表面看起来要更长,因为有隐含的成员和基类析构函数被调用。
有些函数即使声明为内联的也不一定会被编译器内联,这点很重要;比如虚函数和递归函数就不会被正常内联。通常递归函数不应该声明成内联函数,递归调用堆栈的展开并不像循环那么简单,比如递归层数在编译时可能是未知的,大多数编译器都不支持内联递归函数。虚函数内联的主要原因则是想把它的函数体放在类定义内,为了图个方便,抑或是当作文档描述其行为,比如精短的存取函数。
1.1.5、#include 的路径及顺序
使用标准的头文件包含顺序可增强可读性,避免隐藏依赖相关头文件、C 库、C++ 库、其他库的 .h,本项目内的 .h。
项目内头文件应按照项目源代码目录树结构排列,避免使用 UNIX 特殊的快捷目录 .(当前目录)
或 ..(上级目录)
。例如, google-awesome-project/src/base/logging.h 应该按如下方式包含:
1 |
又如,dir/foo.cc 或 dir/foo_test.cc 的主要作用是实现或测试 dir2/foo2.h 的功能,foo.cc 中包含头文件的次序如下:
- dir2/foo2.h(优先位置,详情如下)
- C 系统文件
- C++ 系统文件
- 其他库的 .h 文件
- 本项目内 .h 文件
这种优先的顺序排序保证当 dir2/foo2.h 遗漏某些必要的库时,dir/foo.cc 或 dir/foo_test.cc 的构建会立刻中止。因此这一条规则保证维护这些文件的人们首先看到构建中止的消息而不是维护其他包的人们。
您所依赖的符号(symbols)被哪些头文件所定义,您就应该包含(include)哪些头文件,前置声明 (forward declarations) 情况除外。比如您要用到 bar.h 中的某个符号,哪怕您所包含的 foo.h 已经包含了 bar.h,也照样得包含bar.h,除非 foo.h 有明确说明它会自动向您提供bar.h 中的 symbol。不过,凡是 cc 文件所对应的「相关头文件」已经包含的,就不用再重复包含进其 cc 文件里面了,就像 foo.cc 只包含foo.h 就够了,不用再管后者所包含的其它内容。
举例来说,google-awesome-project/src/foo/internal/fooserver.cc 的包含次序如下:
1 |
1.1.6、小结
- 避免多重包含。
- 头文件尽量避免使用前置声明,直接 #include。
- 内联函数最好少于 10 行。类内部的函数一般会自动内联。所以某函数一旦不需要内联,其定义就不要再放在头文件里,而是放到对应的 .cc 文件里。
- 包含文件的次序除了美观之外,最重要的是可以减少隐藏依赖,使每个头文件在 “最需要编译” 的地方编译。
1.2、作用域
1.2.1、命名空间
鼓励在 .cc 文件内使用匿名命名空间或 static 声明。使用具名的命名空间时,其名称可基于项目名或相对路径。禁止使用 using 指示 (using-directive e.g. using namespace foo;)。禁止使用内联命名空间 (inline namespace)。
- 定义
- 命名空间将全局作用域细分为独立的,具名的作用域,可有效防止全局作用域的命名冲突。
- 优点
- 类已经提将命名分割在不同类的作用域内,命名空间在这基础上又封装了一层。
- 举例来说,两个不同项目的全局作用域都有一个类 Foo,这样在编译或运行时造成冲突。如果每个项目将代码置于不同命名空间中, project1::Foo 和 project2::Foo 作为不同符号自然不会冲突。
- 内联命名空间会自动把内部的标识符放到外层作用域,比如:X::Y::foo() 与 X::foo() 彼此可代替。内联命名空间主要用来保持跨版本的 ABI 兼容性。
1
2
3
4
5namespace X {
inline namespace Y {
void foo();
} // namespace Y
} // namespace X
- 缺点
- 命名空间具有迷惑性,因为它们使得区分两个相同命名所指代的定义更加困难。
- 内联命名空间很容易令人迷惑,毕竟其内部的成员不再受其声明所在命名空间的限制。内联命名空间只在大型版本控制里有用。
- 结论
- 根据下文将要提到的策略合理使用命名空间。
- 遵守命名空间命名中的规则。
- 像之前的几个例子中一样,在命名空间的最后注释出命名空间的名字。
- 用命名空间把文件包含,以及类的前置声明以外的整个源文件封装起来,以区别于其它命名空间:
1
2
3
4
5
6
7
8
9
10
11
12// .h 文件
namespace mynamespace {
// 所有声明都置于命名空间中
// 注意不要使用缩进
class MyClass {
public:
...
void Foo();
};
} // namespace mynamespace - 不要在命名空间 std 内声明任何东西,包括标准库的类前置声明。在 std 命名空间声明实体是未定义的行为,会导致如不可移植。声明标准库下的实体,需要包含对应的头文件。
- 不应该使用 using 指示引入整个命名空间的标识符号。
1
2// 禁止 —— 污染命名空间
using namespace foo; - 不要在头文件中使用命名空间别名,除非显式标记内部命名空间使用。因为任何在头文件中引入的命名空间都会成为公开 API 的一部分。
- 禁止用内联命名空间
1.2.2、匿名命名空间和静态变量
在 .cc 文件中定义一个不需要被外部引用的变量时,可以将它们放在匿名命名空间或声明为 static。但是不要在 .h 文件中这么做。
定义
所有置于匿名命名空间的声明都具有内部链接性,函数和变量可以经由声明为 static 拥有内部链接性,这意味着你在这个文件中声明的这些标识符都不能在另一个文件中被访问。即使两个文件声明了完全一样名字的标识符,它们所指向的实体实际上是完全不同的。结论
推荐、鼓励在 .cc 中对于不需要在其他地方引用的标识符使用内部链接性声明,但是不要在 .h 中使用。
匿名命名空间的声明和具名的格式相同,在最后注释上 namespace:
1 | namespace { |
1.2.3、非成员函数、静态成员函数和全局函数
使用静态成员函数或命名空间内的非成员函数,尽量不要用裸的全局函数。将一系列函数直接置于命名空间中,不要用类的静态方法模拟出命名空间的效果,类的静态方法应当和类的实例或静态数据紧密相关。
优点
某些情况下,非成员函数和静态成员函数是非常有用的,将非成员函数放在命名空间内可避免污染全局作用域。缺点
将非成员函数和静态成员函数作为新类的成员或许更有意义,当它们需要访问外部资源或具有重要的依赖关系时更是如此。结论
有时,把函数的定义同类的实例脱钩是有益的,甚至是必要的。这样的函数可以被定义成静态成员,或是非成员函数。非成员函数不应依赖于外部变量,应尽量置于某个命名空间内。相比单纯为了封装若干不共享任何静态数据的静态成员函数而创建类,不如使用命名空间。举例而言,对于头文件 myproject/foo_bar.h,应当使用
1 | namespace myproject { |
而非
1 | namespace myproject { |
定义在同一编译单元的函数,被其他编译单元直接调用可能会引入不必要的耦合和链接时依赖;静态成员函数对此尤其敏感。可以考虑提取到新类中,或者将函数置于独立库的命名空间内。
如果你必须定义非成员函数,又只是在 .cc 文件中使用它,可使用匿名命名空间或 static 链接关键字 (如 static int Foo() {…}) 限定其作用域。
1.2.4、局部变量
将函数变量尽可能置于最小作用域内,并在变量声明时进行初始化。
C++ 允许在函数的任何位置声明变量。我们提倡在尽可能小的作用域中声明变量,离第一次使用越近越好。这使得代码浏览者更容易定位变量声明的位置,了解变量的类型和初始值。特别是应使用初始化的方式替代声明再赋值, 比如:
1 | int i; |
属于 if、while 和 for 语句的变量应当在这些语句中正常地声明,这样子这些变量的作用域就被限制在这些语句中了,举例而言:
1 | while (const char* p = strchr(str, '/')) str = p + 1; |
有一个例外,如果变量是一个对象,每次进入作用域都要调用其构造函数,每次退出作用域都要调用其析构函数。这会导致效率降低。
1 | // 低效的实现 |
1.2.5、静态和全局变量
禁止定义静态储存周期非 POD 变量,禁止使用含有副作用的函数初始化 POD 全局变量,因为多编译单元中的静态变量执行时的构造和析构顺序是未明确的,这将导致代码的不可移植。
静态生存周期的对象,即包括了全局变量,静态变量,静态类成员变量和函数静态变量,都必须是原生数据类型(POD : Plain Old Data): 即 int、char 和 float,以及 POD 类型的指针、数组和结构体。
静态变量的构造函数、析构函数和初始化的顺序在 C++ 中是只有部分明确的,甚至随着构建变化而变化,导致难以发现的 bug。所以除了禁用类类型的全局变量,我们也不允许用函数返回值来初始化 POD 变量,除非该函数(比如 getenv() 或 getpid())不涉及任何全局变量。函数作用域里的静态变量除外,毕竟它的初始化顺序是有明确定义的,而且只会在指令执行到它的声明那里才会发生。
综上所述,我们只允许 POD 类型的静态变量,即完全禁用 vector(使用 C 数组替代)和 string(使用 const char [])。
1.2.6、小结
- .cc 中的匿名命名空间可避免命名冲突、限定作用域,避免直接使用 using 关键字污染命名空间
- 尽量不用全局函数和全局变量,考虑作用域和命名空间限制,尽量单独形成编译单元
- 多线程中的全局变量 (含静态成员变量) 不要使用 class 类型 (含 STL 容器),避免不明确行为导致的 bug
- 局部变量在声明的同时进行显式值初始化,比起隐式初始化再赋值的两步过程要高效
二、类
类是 C++ 中代码的基本单元。显然,它们被广泛使用。本节列举了在写一个类时的主要注意事项。
2.1、构造函数的职责
- 总述
- 不要在构造函数中调用虚函数,也不要在无法报出错误时进行可能失败的初始化。
- 定义
- 在构造函数中可以进行各种初始化操作。
- 优点
- 无需考虑类是否被初始化
- 经过构造函数完全初始化后的对象可以为 const 类型,也能更方便地被标准容器或算法使用
- 缺点
- 如果在构造函数内调用了自身的虚函数,这类调用是不会重定向到子类的虚函数实现。即使当前没有子类化实现,将来仍是隐患。
- 如果执行失败,会得到一个初始化失败的对象,这个对象有可能进入不正常的状态,必须使用 bool isValid() 或类似这样的机制才能检查出来,然而这是一个十分容易被疏忽的方法。
- 构造函数的地址是无法被取得的,因此,举例来说,由构造函数完成的工作是无法以简单的方式交给其他线程的。
- 结论
- 构造函数不允许调用虚函数。如果代码允许,直接终止程序是一个合适的处理错误的方式。否则,考虑用 Init() 方法或工厂函数。
2.2、隐式类型转换
- 总述
- 不要定义隐式类型转换。对于转换运算符和单参数构造函数,请使用 explicit 关键字。
- 定义
隐式类型转换允许一个某种类型 (称作源类型) 的对象被用于需要另一种类型 (称作目的类型) 的位置,例如将一个 int 类型的参数传递给需要double 类型的函数。
explicit 关键字可以用于构造函数或(在 C++11 引入)类型转换运算符,以保证只有当目的类型在调用点被显式写明时才能进行类型转换,例如使用 cast。这不仅作用于隐式类型转换,还能作用于 C++11 的列表初始化语法:
1
2
3
4
5
6class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);此时下面的代码是不允许的:
1
Func({42, 3.14}); // Error
这一代码从技术上说并非隐式类型转换,但是语言标准认为这是 explicit 应当限制的行为。
- 优点
- 有时目的类型名是一目了然的,通过避免显式地写出类型名,隐式类型转换可以让一个类型的可用性和表达性更强。
- 隐式类型转换可以简单地取代函数重载。
- 在初始化对象时,列表初始化语法是一种简洁明了的写法。
- 缺点
- 隐式类型转换会隐藏类型不匹配的错误。有时目的类型并不符合用户的期望,甚至用户根本没有意识到发生了类型转换。
- 隐式类型转换会让代码难以阅读,尤其是在有函数重载的时候,因为这时很难判断到底是哪个函数被调用。
- 单参数构造函数有可能会被无意地用作隐式类型转换。
- 如果单参数构造函数没有加上 explicit 关键字,读者无法判断这一函数究竟是要作为隐式类型转换,还是作者忘了加上 explicit 标记。
- 并没有明确的方法用来判断哪个类应该提供类型转换,这会使得代码变得含糊不清。
- 如果目的类型是隐式指定的,那么列表初始化会出现和隐式类型转换一样的问题,尤其是在列表中只有一个元素的时候。
- 结论
- 在类型定义中,类型转换运算符和单参数构造函数都应当用 explicit 进行标记. 一个例外是拷贝和移动构造函数不应当被标记为 explicit,,因为它们并不执行类型转换。
- 不能以一个参数进行调用的构造函数不应当加上 explicit。接受一个 std::initializer_list 作为参数的构造函数也应当省略 explicit,以便支持拷贝初始化(例如 MyType m = {1, 2};)。
2.3、可拷贝类型和可移动类型
- 总述
- 如果你的类型需要,就让它们支持拷贝 / 移动。否则,就把隐式产生的拷贝和移动函数禁用。
- 定义
- 可拷贝类型允许对象在初始化时得到来自相同类型的另一对象的值,或在赋值时被赋予相同类型的另一对象的值,同时不改变源对象的值。对于用户定义的类型,拷贝操作一般通过拷贝构造函数与拷贝赋值操作符定义。string 类型就是一个可拷贝类型的例子。
- 可移动类型允许对象在初始化时得到来自相同类型的临时对象的值,或在赋值时被赋予相同类型的临时对象的值(因此所有可拷贝对象也是可移动的)。std::unique_ptr< int > 就是一个可移动但不可复制的对象的例子。对于用户定义的类型,移动操作一般是通过移动构造函数和移动赋值操作符实现的。
- 拷贝 / 移动构造函数在某些情况下会被编译器隐式调用。例如,通过传值的方式传递对象。
- 缺点
- 许多类型都不需要拷贝,为它们提供拷贝操作会让人迷惑,也显得荒谬而不合理。单件类型(Registerer),与特定的作用域相关的类型 (Cleanup),与其他对象实体紧耦合的类型(Mutex)从逻辑上来说都不应该提供拷贝操作。为基类提供拷贝 / 赋值操作是有害的,因为在使用它们时会造成对象切割。默认的或者随意的拷贝操作实现可能是不正确的,这往往导致令人困惑并且难以诊断出的错误。
- 拷贝构造函数是隐式调用的,也就是说,这些调用很容易被忽略。这会让人迷惑,尤其是对那些所用的语言约定或强制要求传引用的程序员来说更是如此。同时,这从一定程度上说会鼓励过度拷贝,从而导致性能上的问题。
- 结论
- 如果需要就让你的类型可拷贝 / 可移动。作为一个经验法则,如果对于你的用户来说这个拷贝操作不是一眼就能看出来的,那就不要把类型设置为可拷贝。如果让类型可拷贝,一定要同时给出拷贝构造函数和赋值操作的定义,反之亦然。如果让类型可拷贝,同时移动操作的效率高于拷贝操作,那么就把移动的两个操作(移动构造函数和赋值操作)也给出定义。如果类型不可拷贝,但是移动操作的正确性对用户显然可见,那么把这个类型设置为只可移动并定义移动的两个操作。
- 如果定义了拷贝 / 移动操作,则要保证这些操作的默认实现是正确的。记得时刻检查默认操作的正确性,并且在文档中说明类是可拷贝的且 / 或可移动的。由于存在对象切割的风险,不要为任何有可能有派生类的对象提供赋值操作或者拷贝 / 移动构造函数(当然也不要继承有这样的成员函数的类)。如果你的基类需要可复制属性,请提供一个 public virtual Clone() 和一个 protected 的拷贝构造函数以供派生类实现。
1
2
3
4
5
6
7
8class Foo {
public:
Foo(Foo&& other) : field_(other.field) {}
// 差, 只定义了移动构造函数, 而没有定义对应的赋值运算符.
private:
Field field_;
}; - 如果你的类不需要拷贝 / 移动操作,请显式地通过在 public 域中使用
= delete
或其他手段禁用之。1
2
3// MyClass is neither copyable nor movable.
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;
参考原文:
Google C++ 编程风格
- 本文标题:Google-C++编程代码风格(转载)
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/d5d5709a.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!