C++代码风格指南
当前页面的内容正在依照C++ style guide的内容进行翻译。如果您熟知页面内容并擅长翻译,欢迎协助改善或校对此页面。
Contents
背景
C++是KlayGE使用的主要编程语言。每个C++程序员都知道,这个语言有着很多强大的特性,但强大的同时也带来了各种复杂性。结果就是更容易出bug,并且难以阅读和维护。
本指南将详细描述各种在写C++代码的过程中该做和不该做的事情,以达到控制复杂性的目的。这些规则一方面让代码库便于管理,另一方面允许开发者高效地使用C++的语言特性。
风格,也称为可读性,是掌控我们C++代码的规则。风格这个术语也许有些不当,因为这些规则远远超过了源代码格式的范畴。
我们管理代码库的一种方式是强制保持一致性。让任何开发者可以很快地看懂和明白其他人的代码非常重要。维护一个统一的风格并遵循规则,意味着我们可以更容易地使用“模式匹配”来推断各种符号是什么意思,以及有哪些不变性。建立一个通用的、必需的范式和模式会让代码容易理解得多。在某些情况下,可能会有很好的理由需要改变一些风格的规则,但为了保证一致性,我们仍然需要保持规则不变。
本指南要解决的另一个问题是应对C++的特性膨胀。C++是一个巨大的语言,有着许多先进特性。有些时候我们约束、甚至禁止使用一些特性。我们这么做可以保持代码简单易懂,并避免那些特性可能带来的多种常见错误和问题。本指南列出了这些特性,并解释为什么要限制使用他们。
注意,本指南不是个C++教程。我们假设读者熟悉这门语言。
头文件
一般来说,每个.cpp文件都应该有一个对应的.hpp文件。也有一些常见的例外,比如单元测试和只包含一个main()函数的小.cpp文件。
正确使用头文件会对你代码的可读性、大小和性能产生巨大的改善。
下面的规则将会指引你通过多种使用头文件的陷阱。
#define防护
所有的头文件都应该同时有“#define防护”和“#pragma once”,以防止多次包含,并能加速编译。符号名的格式是_<FILE>_HPP。比如,文件foo.hpp应该有这样的防护:
#ifndef _FOO_HPP #define _FOO_HPP #pragma once ... #endif // _FOO_HPP
头文件依赖
如果前置声明就足够的话,就不要用#include。
当你包含了一个头文件,你就引入了一个依赖。每次那个头文件改变的时候你的代码就需要重新编译。如果你的头文件包含了其他头文件,那么对那些文件的任一次修改都会需要重新编译所有包含该头文件的代码。因此,包含越少越好,特别是在一个头文件里包含其它头文件。
通过使用前置声明,你可以显著地减少在你的头文件中需要包含的其他头文件数量。比如,如果你的头文件使用了File类,但不需要访问到File类的定义,你的头文件只需要前置声明一个class File,而不用#include "base/file.hpp"。在KlayGE中,所有的类和结构都在"KlayGE/PreDeclare.hpp"中进行了前置声明。你可以在你的.hpp开头#include它。
如何在头文件中使用Foo类而不访问它的定义?
- 可以把数据成员的类型定义成Foo*或Foo&。
- 在函数的声明(但不是定义)中,可以使用Foo类型的参数和/或返回类型。(例外之一是,如果一个参数Foo或Foo const &有非显式的、单参数构造函数,你就需要完整的定义才能支持自动类型转换。)
- 可以声明Foo类型的静态数据成员。这是因为静态数据成员是在类定义之外进行定义的。
另一方面,如果你的类是Foo的子类或有个Foo类型的数据成员,你就必须包含Foo的头文件。
有时候,用指针(用scoped_ptr就更好了)来代替对象成员更有道理。但是,这让代码的可读性变复杂了,并增加了一个性能惩罚。所以如果只是为了减少包含头文件,可以不用做这个转换。
当然,.cpp文件一般需要他们所用的类的定义,所以需要包含一些头文件。
注意:如果在你的源文件中使用Foo符号,你应该自己引入Foo的定义,而不是通过#include或前置声明。不该依赖于不是通过直接包含的头文件带来的符号。一个例外是,如果myfile.cpp中用到了Foo,则可以在myfile.hpp里#include(或前置声明)Foo,而不是在myfile.cpp中。
内联函数
只有当函数很小,比如10行以下的时候才定义成内联函数。
定义:
如果把一个函数声明为内联,就说明允许编译器在调用的时候内联地展开它,而不是像一般的函数调用机制那样调用。
优点:
只好内联函数比较小,那么内联可以产生更有效率的二进制代码。访问和修改函数,以及其他短的、对性能很关键的函数应该内联。
缺点:
过度使用内联实际上会使程序变慢。根据函数的大小,内联会造成代码长度的增加或减少。内联一个很小的访问函数一般会减少代码长度,而内联很大的函数会显著地增加代码长度。在现代处理器上,短代码一般比较快,因为可以更好地利用指令cache。
决定:
一个相当好的经验法则是如果一个函数的代码超过10行,就不要内联。注意析构函数,他们经常比看起来的要长,因为隐含了成员和基类的构造函数调用!
另一个有用的经验法则:如果函数里有循环或switch语句,内联一般是不合算的(除非,在大部分时候循环或switch没执行过)。
有个很重要的事情是即便一个函数声明成内联了,它也不总是内联的。比如,虚函数和递归函数通常是不会内联的。递归函数也不该内联。让虚函数内联的主要原因是把他们的声明放在类里,要么为了方便,要么为了写清楚它的行为,比如访问和修改函数。
函数参数顺序
当定义一个函数的时候,参数的顺序是,输入、然后输出。
C/C++函数的参数要么是函数的输入,要么是函数的输出,或二者兼备。输入参数通常是值或常量引用,输出和输入/输出参数是非常量指针。当排列函数参数顺序时,把纯输入的参数放在所有输出参数之前。特别是,不要仅仅因为是新参数就把它加在函数后面。新的纯输入参数也要放在输出参数之前。
这不是个死规定。同时有输入和输出的参数(经常是类/结构体)会来搅混水,和平常一样,与相关函数保持一致就可能需要你调整这个规则。
名字和包含的顺序
为了可读性和避免隐藏的依赖,使用标准顺序:KlayGE的.hpp,C库,C++库,其他库的.hpp,你项目的.hpp。
所有KlayGE的公共头文件都在"Include/KlayGE"目录,并且在包含路径中。不要通过UNIX的短路径.(当前目录)或..(上级目录)。比如,KlayGE/foo.hpp应该这样包含:
#include <KlayGE/foo.hpp>
对于dir/foo.cpp或dir/foo_test.cpp这样的文件,主要目的是实现或测试dir2/foo2.hpp里的东西,包含顺序应该这样排列:
- KlayGE/KlayGE.hpp
- dir2/foo2.hpp (推荐的位置——细节参见下面)。
- C系统文件。
- C++系统文件
- 其他库的.hpp文件。
- 你项目的.hpp文件。
对于推荐的位置,如果dir/foo2.hpp漏掉了任何需要的头文件,那么编译dir/foo.cpp或dir/foo_test.cpp就会失败。因此,这条规则保证了先看到编译失败的是在这些文件上工作的人,而不是在其他包上的无辜者。
dir/foo.cpp和dir2/foo2.hpp经常在同一个目录(比如base/basictypes_test.cpp和base/basictypes.hpp),但也可能在不同的目录。
比如,KlayGE/src/foo.cpp里的包含顺序看起来像这样:
#include <KlayGE/KlayGE.hpp> #include <KlayGE/foo.hpp> #include <vector> #include <boost/shared_ptr.hpp> #include <KlayGE/Math.hpp> #include <KlayGE/ResLoader.hpp>
范围
命名空间
鼓励在.cpp文件中使用匿名命名空间。对于具名命名空间,要根据项目来命名,可能的话再加上路径。不要用using指示符。
定义:
命名空间把全局域划分成独立的、有名字的范围,可以有效地解决全局域中的名字冲突。
优点:
命名空间提供一个(分层的)命名坐标轴,之前class已经提供了另一个(也是分层的)命名坐标轴。
例如,如果两个不同的项目都在全局域中有个类Foo,这些符号将会在编译期或运行期出现冲突。如果每个项目把代码放在自己的命名空间内,project1::Foo和project2::Foo现在是独立的符号了,不会发生冲突。
缺点:
命名空间可能会制造混乱,因为除了class提供的(分层的)命名坐标轴之外,命名空间又提供了一个(分层的)命名坐标轴。
在头文件中使用匿名命名空间经常会违反C++的单次定义法则(ODR).
决定:
根据下面的规则使用命名空间。
匿名命名空间
- 在.cpp文件中使用匿名命名空间是允许的,甚至鼓励的,可以避免运行期名字冲突:
namespace { // 在.cpp文件里 // 命名空间里的内容需要缩进 enum { Unused, EOF, Error }; // 常用的常量 bool AtEof() { return EOF == pos_; } // 使用命名空间里的EOF }
但是,文件域的声明中与一个特定类相关的部分可以在类中声明成类型、静态数据成员或静态成员函数,而不是匿名命名空间的成员。
- 不要在.hpp文件中使用匿名命名空间
具名命名空间
具名命名空间应该像这样使用:
- 命名空间可以围绕整个源文件,在include、定义/声明、以及来自其他命名空间的类的前置声明之后:
// 在.hpp文件中 namespace mynamespace { // 所有的声明都在命名空间的范围内 // 注意缩进 class MyClass { public: ... void Foo(); }; } // 在.cpp文件中 namespace mynamespace { // 在命名空间范围内的函数定义 void MyClass::Foo() { ... } }
典型的.cpp文件可能有更复杂的细节,包括需要引用其他命名空间中的类。
#include "a.hpp" #define someflag "dummy flag" class C; // 在全局命名空间中类C的前置声明 namespace a { class A; // a::A的前置声明 } namespace b { ...code for b... }
- 不要在std命名空间内声明任何东西,即使是标准库类的前置声明。在std命名空间中声明一个东西会导致未定义的行为,也就是,不可移植。要声明标准库中的东西,就包含适当的头文件。
- 不能使用using指示符从一个命名空间中开放出所有的名字。
// 禁止 -- 污染了命名空间。 using namespace foo;
- 可以在.cpp文件的任何地方使用using声明,在.hpp文件的函数、方法和类中使用using声明。
// 可以在.cpp文件中 // 在.hpp中的话,就必须在函数、方法或类中 using ::foo::bar;
- 可以在.cpp文件的任何地方使用命名空间别名,在围绕整个.hpp文件的名字空间内的任何地方、以及函数和方法内使用命名空间别名。
// 在.cpp文件中把常用的名字缩短 namespace fbz = ::foo::bar::baz; // 在.hpp文件中把常用的名字缩短 namespace librarian { // 下面的别名可以用于任何包含着个头文件的地方 // (在librarian命名空间中): // 别名应该在项目内保持一致 namespace pd_s = ::pipeline_diagnostics::sidetable; inline void my_inline_function() { // 命名空间别名在函数(或方法)内 namespace fbz = ::foo::bar::baz; ... } }
注意,在.hpp文件中的别名在所有包含那个文件的地方都是可见的。所以公共头文件(在项目外也可用的)以及它们包含的头文件应该避免定义别名,保持公共API越小越好是通用准则的一部分。
内嵌类
虽然当内嵌类也是接口的一部分时,你可以使用一个公开的内嵌类,但更好的方法是用一个命名空间来保证声明不在全局范围。
定义:
一个类可以在内部定义另一个类,也称为成员类。
class Foo { private: // Bar是一个成员类,内嵌在Foo中 class Bar { ... }; };
优点:
当内嵌类(或成员类)仅仅在包含它的类中使用的时候挺有用,这可以使它在包含类的范围内,而不会污染到外部域。内嵌类可以前置声明在包含类内,然后在.cpp文件内定义。这样可以避免在包含类的声明中定义内嵌类,因为内嵌类的定义通常只和实现有关。
缺点:
内嵌类可以仅仅是前置声明在包含类的声明中。因此,任何含有Foo::Bar*指针的头文件都必须包含整个Foo类的声明。
决定:
不要公开内嵌类,除非它们真的是接口的一部分,比如,一个类对于某个方法有多种选择。
非成员、静态成员和全局函数
优先选择命名空间中的非成员函数或静态成员函数,而不是全局函数。几乎不用完全的全局函数。
优点:
非成员和静态成员函数在一些情况下很有用。把非成员函数放在命名空间中,以防止污染全局命名空间。
缺点:
把那些非成员和静态成员函数作为一个新类的成员可能更有道理,特别是如果它们访问外部资源或者有明显的依赖性。
决定:
有时候很有用,甚至必须把一个函数定义成不绑定于类的某个实例上。这样的函数可以是静态成员或非成员函数。非成员函数不能依赖于外部变量,而且应该总是存在于某个命名空间中。如果建立一个类只是为了把静态成员函数归在一起,而不共享静态数据,那么就应该改用命名空间。
如果一个函数和类定义在了同一个编译单元内,当它被其他编译单元直接调用的时候,可能引入不必要的耦合性和连接期依赖。静态成员函数特别容易受这个影响。适当考虑提取出一个新类,或把这样的函数放在一个独立库的命名空间中。
如果你必须定义一个非成员函数,而它只会在它的.cpp文件中用到,就用一个匿名命名空间来限定它的范围。
局部变量
把函数的变量放在尽量小的范围内,并在声明的时候初始化。
C++允许你在函数的任何地方声明变量。我们鼓励把变量声明在尽可能小的范围内,并且尽量靠近第一次使用的地方。这会让读者更容易找到声明、看到变量的类型、以及它被初始化成什么值。特别是,应该使用初始化而不是声明后赋值。比如:
int i; i = f(); // 错误 -- 初始化和声明分开了
int j = g(); // 正确 -- 声明就初始化
注意,MSVC和gcc正确地实现了for (int i = 0; i < 10; ++ i)(i的生命期只在for循环的范围内),所以你在同范围的其他循环仍可以重用i。在if和while语句内,声明范围也是这样的规则,比如:
while (const char* p = strchr(str, '/')) { str = p + 1; }
有一个需要留心的地方:如果变量是个对象,那么在每一次进入范围和建立的时候都会调用它的构造函数,每一次出范围的时候都会调用它的析构函数。
// 低效的实现: for (int i = 0; i < 1000000; ++ i) { Foo f; // 构造和析构各会调用1000000次 f.DoSomething(i); }
把这样的变量声明在循环外会更高效:
Foo f; // 构造和析构只调用一次 for (int i = 0; i < 1000000; ++ i) { f.DoSomething(i); }
静态和全局变量
class类型的静态和全局变量是禁止的:由于构造和析构的不确定顺序产生的bug往往难以寻找。在一些平台上(比如Android),带有构造函数的全局变量会直接造成崩溃。
含有静态存储周期的对象,包括全局变量、静态变量、静态类成员变量、以及函数静态变量,必须是纯数据的类型(POD): int、char、floats、指针、或POD的数组或结构体。
静态变量的类构造函数和初始化调用顺序在C++中只是部分定义而已,甚至在不同次构建之间都会改变。这回产生很难跟踪的bug。因此除了禁止class类型的全局变量,我们也禁止静态的POD变量用函数的返回值来初始化,除非那个函数(比如getenv()或getpid())本身不依赖于其他全局变量。
同样,析构函数的调用顺序被定义成了和构造函数调用相反的顺序。因为构造的顺序是不确定的,析构顺序也是不确定的。比如,在程序端一个静态变量可能已经被销毁了,但代码还在执行——可能在另一个线程——试图访问它,就会失败。或者一个静态string变量可能先于引用它的另一个变量之前析构。
结果我们只允许静态变量包含POD数据。这个规则完全禁止了vector(使用C数组来代替),或字符串(使用char const []来代替)。
如果你需要一个class类型的静态或全局变量,可以初始化一个指针(并且从来不释放)——要么从你的main()函数,要么从pthread_once()。注意那必须是个原始的指针,不是“智能”指针,因为智能指针的析构函数也会出现析构顺序问题,而那正是我们想避免的。