google-styleguide-chinese-translation

C++

«  序言   ::   Contents

C++

C++ 是Google许多开源项目的主要开发编程语言。每个 C++ 程序员都知道,这种语言有许多强大的特性,但是这种强大也带来了程序的复杂性,这些复杂性会使程序更加容易出错,也更难去阅读和掌握。

这篇指南的目的是通过详细描述在 C++ 编码要怎样写而不要怎样写来避免这些复杂性。这些规则使程序仍然可以高效的使用 C++ 特性的同时也易于管理。

风格,也称为可读性,是我们用来管理 C++ 代码的约定。风格这个词有点用词不当,因为这些约定不止涵盖了源代码文件的格式。

一种我们保持程序代码易于管理的方法是保持一致性。程序员能够快速看懂其他人的代码是非常重要的。保持一种统一的风格并且遵循约定意味着我们可以轻松的用“模式匹配”来推断什么是变量什么是不变量。一般来说,遵循规范和模式使代码更容易理解。有时,可能会有一些好的建议来改变这些规则,但是我们仍然保持它们的原样来保证一致性。

这篇指南所讲的另一个方面是 C++ 的特性膨胀。 C++ 是一个有着许多高级特性的庞大编程语言,有时我们限制甚至禁止某些特性的使用。这样做是为了保持代码的简洁性,并且防止由于这些特性引起的不通错误和问题。这篇指南列出了这些特性并且解释了为什么要限制它们的使用。

头文件

一般来说,每一个 .cc 文件都应该有一个和它相对应的 .h 文件。当然也有一些例外的情况,比如单元测试代码和只包含 main() 函数的比较小的 .cc 文件。

正确的使用头文件能够使你代码的可读性、规模和性能有一个巨大的改观。

下面的规则将改正你在使用头文件上面的误区。

#define 保护

所有的头文件都要有 #define 保护以免被多次包含。符号名称的格式是 <PROJECT>_<PATH>_<FILE>_H_ .

为了保证唯一性, define 声明必须基于工程代码树的全路径来命名。比如, foo 工程中的 foo/src/bar/baz.h 文件应该有如下的保护声明:

#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_

...

#endif  // FOO_BAR_BAZ_H_

前向声明

可以通过前向声明普通类来避免不必要的 #include 包含。

定义 :

“前向声明”是对类,函数或者没有相关定义的模板的声明。 不管用户代码用了什么符号, #include 行经常能被前向声明替代。

优点 :

  • 不必要的 #include 行使编译器打开更多的文件并且执行更多的输入操作。
  • 当头文件里的内容改变后,不必要的 #include 也会使你的代码更经常的被重新编译

缺点 :

  • 在存在模板,定义类型,默认参数,使用声明特性程序中,有时决定一个前向声明的正确形式是很难的。
  • 有时在一段给定代码中决定使用前向声明还是 #include 包含是很难的,特别是当存在隐式转换操作的时候。在最极端的情况下, 用前向声明代替 #include 包含会悄悄的改变代码的作用。
  • 从头文件中前向声明多个符号要比直接 #include 这个头文件写的更详细。
  • 函数和模板的前向声明会妨碍他们所在的头文件为他们的API做兼容性改变。比如,扩大一个参数类型或者增加一个默认值的模板参数。
  • std:: 命名空间前向声明符号经常会由于未定义而失败。
  • 构建代码支持前向声明(比如,用指针成员代替对象成员)会使代码变得复杂而且运行缓慢。
  • 从前向声明获得的实际效率是未经证实的。

总结 :

  • 当要用到一个头文件中的函数声明的时候,就 #include 那个头文件。
  • 当要用到类模板的时候,最好 #include 那个头文件。
  • 当要用到一个普通的类时,依赖一个前向声明是可以的,但是要注意前向声明可能会没有效率甚至错误;当不确定的时候,就 #include 那个头文件。
  • 不要为了减掉一行 #include ,用指针去替换数据成员。

总是 #include 那些提供你需要的定义或者声明的头文件;不要使用不是通过头文件直接包含进来的符号。一个例外的情况是, myfile.cc 可能会依赖它的头文件 myfile.h 中的 #includes 的头文件和前向声明。

内联函数

只有当函数很小,比如说10行或者更少的时候,我们才会去定义内联函数。

定义 :

对于内联函数,编译器在编译阶段会直接展开代码,而不是像通常的函数调用机制去处理它们。

优点 :

只要一个函数足够小,将它声明为内联的,就能够生成更加高效的目标代码。可以随意将类成员的访问函数和设置函数和一些短而且对性能有很高要求的函数声明为内联函数。

缺点 :

过度的使用内联函数会使程序运行缓慢。一个内联函数的规模的大小会造成代码增加或者减少。内联一个很小的设置函数经常能够减少代码规模,而内联一个比较长的函数会急剧增加代码的规模。对于现代的处理器,小的代码段因为指令缓存的使用而运行的更快。

总结 :

经验告诉我们如果一个函数超过了10行,就不要内联它。要当心析构函数,由于隐式成员和基类的析构,它们往往比看起来的要长!

另外一个很有用的经验是 :如果把一个含有循环和 switch 语句的函数声明为内联的,那么往往会使性能下降(除非,一般情况下,循环和 switch 是不会被执行的)。

必须要知道的是就算有些函数像上面定义的内联函数那样也是不能被内联的。比如,虚函数和迭代函数一般都不能被内联。让一个虚函数内联的主要原因是把它定义在类里面,不管是为了方便还是为它的行为提供文档参考,比如类成员的访问函数和设置函数。

-inl 文件

必要的时候,我们可以用 -inl.h 后缀的文件来定义复杂的内联函数。

内联函数的定义必须在一个头文件中,这样编译器在调用它们的地方就能知道它的函数定义。然而实现代码应当在 .cc 文件中,我们也不喜欢有很多实现代码在 .h 文件中,除非这样做能够提高可读性和性能。

如果一个内联函数的定义特别短,基本没有逻辑语句在里面,那么你可以把实现代码写在 .h 文件中。比如,类成员的设置和访问函数的实现就写在类声明里面。为方便实现者和调用者,更复杂的内联函数也可以放在 .h 文件中,如果这样让 .h 文件太笨拙的话,可以把代码放在一个分离的 -inl.h 文件中。这样能把实现代码从类定义里面分离出来,当需要的时候仍然可以把这些 .inl.h 文件包含进来。

-inl.h 文件的另一个用途是函数模板的定义。这样可以让你的模板定义更容易阅读。

记住 -inl.h 文件和其它头文件一样也需要 #define 保护。

函数参数顺序

当定义一个函数的时候,参数的顺序是这样的 :输入参数,然后是输出参数。

C/C++ 函数的参数要么是只有输入,要么是只有输出,要么都有。输入参数一般都是常量值或者有 const 限制的,然而输出参数或者输入/输出参数不会有 const 限制。当我们对函数参数排序的时候,把所有只用作输入的参数放在所有输出参数之前。不要因为要新添参数就把它放在最后,应该还是按照规定输入输出顺序来放置。

当然,这也不是一个不可违逆的规则,一些既是输入又是输出的参数(经常是类或者结构体)会把这个规则搞乱。所以,保持这些函数的一致性有时需要你不一定完全遵守规则。

includes 文件的名字和顺序

要用标准的顺序来保证可读性并且避免隐含的依赖,标准的先后顺序是 : C 的头文件,C++ 的头文件, 其它第三方库的头文件和自己工程的头文件。

工程的所有头文件都应该安装源代码目录树的顺序来排列,而不要使用 UNIX 的简化目录 . (当前目录) 和 .. (上级目录)。比如, google-awesome-project/src/base/logging.h 应该这样被包含 :

#include "base/logging.h"

dir/foo.cc 或者 dir/foo_test.cc 文件的主要功能是实现并且测试 dir2/foo2.h 文件中的东西,头文件包含的顺序应该是 :

  1. dir2/foo2.h (优先位置,详情如下)
  2. C 的头文件
  3. C++ 的头文件
  4. 其它库的头文件
  5. 工程的头文件

对于优先的头文件,如果 dir2/foo2.h 遗漏了任何必需的 includes 行, dir/foo.cc 或者 dir/foo_test.cc 的编译都会有问题。因此,这条规则保证构建出错的时候第一个提示是对应的的头文件,而不是其它库的“无辜”的头文件。

dir/foo.ccdir2/foo2.h 一般都在统一个目录(比如 base/basictypes_test.ccbase/basictypes.h ),但是也可以在不同的目录。

在每一个分类中,头文件包含的顺序都要按照字母表排序,注意比较老的代码可能没有遵守这个规则,如果方便的话就更改一下。

比如, google-awesome-project/src/foo/internal/fooserver.cc 文件的 include 行可能看起来是这样的 :

#include "foo/public/fooserver.h"  // Preferred location.

#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"

范围

命名空间

应该鼓励在 .cc 文件中用未命名的命名空间。对于有名字的命名空间,要根据项目名,最好和它所在的目录一起来命名该命名空间。不要直接使用 using 语句来使用命名空间。

定义 :

命名空间把全局范围分成不同的区域,有效的防止了全局范围内的命名冲突。

优点 :

除了类提供命名层次外,命名空间提供了这个功能。

比如,如果两个不同的工程在全局都有一个叫做 Foo 的类,这种命名在编译或者运行时可能会有冲突。如果每一个工程把它们各自的代码放在自己的命名空间里,就可以避免这个问题。 project1::Fooproject2::Foo 就能把这种冲突给消除。

缺点 :

命名空间可能会让人混淆,因为除了类的层次外,命名空间又提供了一种额外的命名层次。

在头文件中使用未命名的命名空间很容易违反 C++ 的“只能定义一次”规则。

总结 :

用下面的方法来使用命名空间。用下面的例子终结所有对命名空间的讨论。

未命名的命名空间规则如下:

  • 允许甚至鼓励在 .cc 文件中使用未命名的命名空间,这样能够避免运行时的冲突:
namespace {                           // This is in a .cc file.

// The content of a namespace is not indented
enum { kUnused, kEOF, kError };       // Commonly used tokens.
bool AtEof() { return pos_ == kEOF; }  // Uses our namespace's EOF.

}  // namespace

然而对于和特性类相关联的文件范围内声明,我们一般把它们定义成类型,数据成员或者静态成员,而不是把它们定义在未命名的命名空间里。

  • 不要在 .h 文件中使用未命名的命名空间。

命名的命名空间规则如下:

  • 命名空间要把 includes 行,全局定义或声明,其它命名空间类的前向声明之后所有的内容都包含:
// 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 文件可能有更复杂的细节,比如对其它命名空间的类的引用等。

#include "a.h"

DEFINE_bool(someflag, false, "dummy flag");

class C;  // Forward declaration of class C in the global namespace.
namespace a { class A; }  // Forward declaration of a::A.

namespace b {

...code for b...         // Code goes against the left margin.

}  // namespace b
  • 不要声明命名空间 std 下的任何内容,包括标准库类的前向声明。声明命名空间 std 中的实体会导致未知的行为,比如不可移植性。为了声明标准库中的实体,可以包含相应的头文件。
  • 不应改直接用一个 using 语句使该命名空间中的所有名字都可用。
// Forbidden -- This pollutes the namespace.
using namespace foo;
  • 可以在任何位置用 using 声明,比如 .cc 文件,函数里,方法里或者 .h 文件中。
// OK in .cc files.
// Must be in a function, method or class in .h files.
using ::foo::bar;
  • 可以在 .cc 文件的任何位置使用命名空间别名,在包含整个 .h 文件的有名字的命名空间内,函数和方法内,也可以使用命名空间别名。
// Shorten access to some commonly used names in .cc files.
namespace fbz = ::foo::bar::baz;

// Shorten access to some commonly used names (in a .h file).
namespace librarian {
// The following alias is available to all files including
// this header (in namespace librarian):
// alias names should therefore be chosen consistently
// within a project.
namespace pd_s = ::pipeline_diagnostics::sidetable;

inline void my_inline_function() {
  // namespace alias local to a function (or method).
    namespace fbz = ::foo::bar::baz;
      ...
}
}  // namespace librarian

要知道 .h 文件中的别名在任何包含这个头文件的文件中都是有效的,所以那些公共的头文件(在工程外可用)和它们包含的一些头文件都应该避免使用别名,因为一般来说应该把公共 API 的范围控制到最小。

嵌套类

当一个嵌套类是接口的一部分时,可能就会用到,但是最好用命名空间把它们的定义从全局范围隔离开来。

定义

在一个类里面可以定义另一个类,也可以叫作成员类。

class Foo {

private:
    // Bar is a member class, nested within Foo.
    class Bar {
         ...
    };

};

优点

当嵌套类(或者成员类)只在类里面被使用的话是很有用的;把它作为一个成员放在类里面比声明在外面要好的多。嵌套类可以前向声明在包含它的类里面,并且在 .cc 文件中定义它,这样可以避免在类中有嵌套类的定义,这样嵌套类的定义就只与实现文件有关了。

缺点

嵌套类在被前向声明的时候只能连同包含它的类一起。因此,任何操作 Foo::Bar* 指针的头文件都必须包含 Foo 整个类的声明。

总结

除了当嵌套类是接口的一部分外,不要把嵌套类声明成公开的,比如一些方法使用了这个类的选项。

非成员函数,静态成员函数和全局函数

最好使用命名空间中的非成员函数或者静态成员函数,而不要使用全局函数。

优点

非成员函数和静态成员函数有时非常有用。把非成员函数放在命名空间中可以避免对全局作用域的污染。

缺点

把非成员函数喝静态成员函数作为一个新类的成员或许会更有意义,特别是当它们想访问外部资源或者有明显的依赖关系的时候。

总结

有时定义一个没有绑定到类的函数是很有用的,甚至是必须的。它们可以是非成员函数或者是静态成员函数。非成员函数不要对外部变量有依赖并且几乎要存在在一个命名空间里。相比于新创建一个类来把静态成员函数集合起来,使用命名空间可能更好一点。

作为生产类,定义在同一个编译单元内的函数在被其它编译单元调用的时候会引入不必要的耦合和链接时的依赖关系,静态成员函数对这一点尤为敏感。考虑新建一个类或者可能的话把这些函数放在一个命名空间中。

如果一定要定义一个非成员函数并且只有 .cc 文件用到它的话,用一个未名的命名空间或者 static 关键字(比如 static int Foo() )来限制它们的范围。

局部变量

尽可能将函数的局部变量限制在最小的作用范围内,最好在声明它的时候就初始化。

C++ 允许在函数的任何地方声明变量。我们提倡在尽可能小的作用域里声明它们,离第一次使用越近越好。这样阅读的人也更容易找到它的声明知道变量的类型和初始值。特别的,应该用初始化代替声明和定义,比如:

int i;
i = f();      // Bad -- initialization separate from declaration.
int j = g();  // Good -- declaration has initialization.

注意 gcc 编译器可以正确执行 for (int i = 0; i < 10; i++) (i 的作用域在循环里面),因此你可以在其它的 for 循环中再次使用变量 i 。在 ifwhile 语句中这样声明也是正确的,比如:

while (const char* p = strchr(str, '/')) str = p + 1;

警告一下:如果变量是一个对象,每当进入作用域都要调用其构造函数,每次离开作用域都要调用其析构函数。

// Inefficient implementation:
for (int i = 0; i < 1000000; ++i) {
    Foo f;  // My ctor and dtor get called 1000000 times each.
    f.DoSomething(i);
}

在循环外面声明这样的变量会更有效率一些:

Foo f;  // My ctor and dtor get called once each.
for (int i = 0; i < 1000000; ++i) {
    f.DoSomething(i);
}

静态变量和全局变量

类的静态变量或者全局变量是禁止的:由于构造和析构函数执行的顺序不确定性,很容易引起找不到定义的错误。然而 const 修饰的变量是可以这样声明的因为它们不会动态初始化或者析构。

静态存储期的对象,包括全局变量,静态变量,静态成员变量,静态函数变量,一定要是简单的数据类型:整型,字符,浮点,数组或者结构。

在 C++ 中,对类的静态变量的构造和初始化的顺序只是部分定义的,每次构建可能都不一样,这样就很可能出现很难查找的 bug。因此除了禁止类的全局变量,我们也不允许静态变量在函数中初始化,除非那些函数自身不依赖其它的全局变量。

同样的,析构函数被调用的顺序和构造函数是相反的,由于构造顺序是不确定的,所以析构的顺序也是不确定的。比如,在程序结束的时候一个静态变量可能已经被销毁了,带是代码仍然在运行,可能在其它的线程里,想要获取它的值但是会失败。或者是一个静态 string 类型的变量肯能在销毁之前被另一个变量引用了,那么析构之后也会出错。

总而言之,我们只允许普通数据类型的静态变量。所以完全不允许 vector (用 C 的数组代替) 或者 string (用 const char [] 代替) 的静态变量。

如果你需要一个静态或者全局的类类型的变量,考虑在 main() 函数或者 pthread_once() 函数中用指针去初始化()。注意一定要是一个原始的指针,因为聪明的指针析构存在我们正在避免的析构顺序问题。

类是 C++ 的基本单元。自然而然的,我们在项目中广泛的运用它。这一节列举了在写一个类的时候应该和不应改做的事情。

构造函数

避免在构造函数中做很复杂的初始化工作(特别是那些可能会失败或者需要虚方法调用的初始化工作)。

定义 :

可以在构造函数中执行初始化。

优点 :

排版方便;无需担心类是否被初始化了。

缺点 :

在构造函数中操作的问题如下:

  • 构造函数不容易报告错误,也禁止使用异常机制。
  • 如果操作失败了,就会有一个对象的初始化操作失败了,这是一种不确定的状态。
  • 如果构造函数中调用了虚函数,这些调用不会被派发到子类的实现中。即使你的类现在还有子类,但是未来对类很小的改变都会引起很大的错误。
  • 如果有人创建了这个类的一个全局变量(尽管违反了规则,但是他仍然这样做了),构造函数会在 main() 函数之前被调用,可能会打破一些构造函数中隐式的假设,比如,全局标志(gflag)还没有被初始化。

总结

构造函数不能调用虚函数或者可能引起非致命的错误。如果你的对象需要重要的初始化,考虑用一个工厂函数或者 Init() 方法。

默认构造函数

如果你的类定义了成员变量,没有其它的构造函数,你要定义一个默认的够砸奥函数。否则,编译器会自己做构造操作,效果很差。

定义 :

当我们 new 一个不带参数的类对象的时候,默认构造函数就会被调用。当调用 new[] (为数组)的时候,默认构造函数也会被调用。

优点 :

默认将结构初始化成“不可能”的值,使调试变得更加容易。

缺点 :

对于代码编写者来说,这是多余的工作。

总结 :

如果你的类定义了成员变量并且没有其它的构造函数,你要定义一个默认的构造函数(一个不带参数的构造函数)。它最好能够使对象初始化之后,内部的状态保持一致和正确。

这样做的原因是如果你没有其它的构造函数,也不定义一个默认构造函数的话,编译器会为你自动产生一个。但是产生的构造函数可能不会很明智的初始化你的对象。

如果你的类是从其它类继承的并且你没有增加新的成员变量,你不许要再去新建一个默认构造函数。

显式构造函数

用 C++ 的关键字 explicit 去修饰只含有一个参数的构造函数。

定义 :

一般来说,如果一个构造函数只有一个参数,可以把它用作一个转换。比如,如果你定义了 Foo::Foo(string name) ,然后当向一个需要 Foo 对象的函数传递一个 string 时,构造函数会被调用把这个字符床转换成一个 Foo 对象然后把这个对象传递给函数。这样有时很方便,但是当你不想这样做,麻烦也会随之而来。把构造函数声明成 explicit 的能够防止这样隐式转换的发生。

优点 :

避免不合适的转换。

缺点 :

总结 :

我们希望所有只有一个参数的构造函数都是 explicit 的,总是用 explicit 去修饰只有一个参数的构造函数 : explicit Foo(string name);

极少数情况下,当我们允许拷贝构造的时候,这就是是一个例外了,构造函数就不应改用 explicit 来修饰。透明包装其它类的类也是一个例外。这样例外的情况应该用注释说明清楚。

拷贝构造函数

只有需要的时候才提供拷贝构造函数和赋值操作。否则,用 DISALLOW_COPY_AND_ASSIGN 去禁止他们。

定义 :

拷贝构造函数和复制操作用来创建对象的拷贝。在一些情况下,拷贝构造函数会被编译器隐式的调用,比如直接把值传给对象。

优点 :

拷贝构造让拷贝对象更加容易。STL容器的所有类型都是可拷贝和被赋值的。拷贝构造比 CopyFrom() 类型的函数更有效率,因为它们在拷贝的时候结合了构造函数,编译器可能不把它们放在正文段中,这样可以避免堆的分配。

缺点 :

在 C++ 中,隐式的对象拷贝是错误的重要来源。不像引用操作,由于很难去跟踪被传递的对象,拷贝构造也降低了程序的可读性,哪里修改的对象也变得难以跟踪。

总结 :

基本没有类需要被拷贝。大多数都不需要拷贝构造和赋值操作。很多情况下,指针或者引用可以有拷贝一样的效果,且性能更好。比如,你可以向函数传递指针或者引用参数,而不是值,你也可以在 STL 容器中保存指针,而不是对象。

如果你的类需要被拷贝,最好提供一个拷贝方法,比如 CopyFrom() 或者 Clone() 而不是用拷贝构造,因为这样的方法是不会被隐式调用的。如果在你的情形下,拷贝方法是不够的(比如性能要求或者你的类需要以值的形式存在 STL 容器中),那么提供一个拷贝构造和赋值操作。

如果你的类不需要拷贝构造和赋值操作,必须显式的禁止它们。为了这样做,可以在类的 private 部分为拷贝构造和赋值操作声明一个假的声明 :但是不要提供任何相关的定义(这样任何想要调用它们的操作都会有一个链接错误)。

为了方便, DISALLOW_COPY_AND_ASSIGN 宏可以这样用:

// A macro to disallow the copy constructor and operator= functions
// This should be used in the private: declarations for a class
#define DISALLOW_COPY_AND_ASSIGN(TypeName) \
    TypeName(const TypeName&);               \
    void operator=(const TypeName&)

然后,在 Foo 类中:

class Foo {
    public:
        Foo(int f);
         ~Foo();

    private:
        DISALLOW_COPY_AND_ASSIGN(Foo);
};

结构和类的比较

当对象只包含数据的时候用结构,其余的情况都要用类。

C++ 中结构和类关键字的表现几乎是一样的。我们人为为它们的添加自己的语义,因此我们必须为我们定义的数据类型使用合适的关键字。

结构只能用在只包含数据的对象上,它可能也有一些关联的变量,但是没有存取之外的任何函数。存取操作是通过直接访问结构中的域来完成的,而不是通过方法调用。这里的方法指的是处理数据成员的方法,比如构造函数,析构函数, Initialize(), Reset(), Validate() 函数。

如果需要其它的函数功能,用类会更合适。如果你不确定的话,就用类。

当和 STL 结合使用的时候,可以为了仿函数和特征用结构代替类。

注意结构和类中的成员变量有不通的命名规则。

继承

用组合往往比用继承要合适。当我们用继承的时候,要用 public 继承。

定义 :

当一个子类从基类集成的时候,它就包含了基类定义的所有数据和操作。一般来说,C++ 的继承有两个主要的用途:实现继承,这种继承下子类继承了父类的代码;接口继承,只有方法名字被继承了。

优点 :

实现继承通过复用基类定义的代码减少了代码量。因为继承是编译时声明,你和编译器都可以理解这些操作并且检查错误。接口继承是用来代码实现一个类暴露的特定 API。同样的,如果一个继承类没有定义必需的方法,编译器可以检查错误。

缺点 :

对于实现继承,由于子类的代码包括基类和子类自己的,所以很难看懂代码。子类不能重写一个非虚函数,所以子类不能改变一些代码的实现。基类也会定义一些数据成员,所以还要区分基类的物理布局。

总结 :

所有的继承必须是 public 的。如果你想要用 private 继承,要声明一个父类句柄的成员。

不要过度使用实现继承。组合一般会更合适。尽量在只有 is-a 关系的时候才用继承:如果 BarFoo 的一种,那么可以让 Bar 继承 Foo

尽量让析构函数是虚函数。如果类里面有虚函数,那么析构函数应该也是虚函数。

限定仅在子类访问的成员为 protected 的。注意数据成员应该是 private 的。

当再次定义继承的虚函数的时候,在派生类中显式的声明它为 virtual 的。原因:如果没有 virtual 的话,读者需要检查它所有的祖先来确定它是否是虚函数。

多重继承

很少情况下,多重实现继承才是有用的。只有当最多一个基类有实现代码,其它基类都是接口类的时候,才允许多重继承。

定义 :

多重继承允许一个子类有多余一个的基类。要将纯接口的基类和有实现的基类分开。

优点 :

多重继承比单继承可以重用更多的代码。

缺点 :

极少情况下多重继承才有用。当觉得多重继承看上去是个解决方法的时候,你一般可以找到一个不同的更加明确清晰的解决方案。

总结 :

只有当所有的基类,最多只有一个不是纯接口,其它都是的情况下,才去用多重继承。为了保证这些类是纯接口,必须以 Interface 作为后缀。

Note

在 Windows系统中有一个例外

接口

接口是满足特定条件的类,往往以 Interface 结尾,但是不是必须的。

定义 :

如果一个类满足下面的条件,它就是纯接口 : * 它只有公共的纯虚函数和静态方法(下文提到的析构函数除外)。 * 不能有非静态数据成员。 * 不需要定义任何构造函数,如果提供的话,它必须没有参数并且是 protected 的。 * 如果它是子类,那么它也是从满足上面条件并且以 Interface 结尾的类继承而来。

由于声明了纯虚函数,接口不能被实例化。为了保证接口类的所有实现都能被正确的销毁,必须为它声明一个虚析构函数(上面第一条要求的例外,析构函数不能是纯虚的),详见 Stroustrup 的 The C++ Programming Language 第三版 12.4 章节。

优点 :

为一个类加上 Interface 后缀让别人知道不能为它增加实现的方法或者非静态数据成员。这一点在多重继承中尤为重要。除此之外,接口的概念对 Java 程序员来说已经很熟悉了。

缺点 :

Interface 后缀增加了类名的长度,使程序难以阅读和理解。同时,接口可能被认为是实现细节,不能暴露给用户。

总结 :

只有当一个类满足上面的要求才能加上 Interface 后缀。但是满足上面条件的类也不一定要加上 Interface 后缀。

操作符重载

在极少数情况下我们才需要重载操作符。

定义 :

类可以定义像 +/ 这样的操作符,让它们像内建类型一样使用。

优点 :

操作符重载可以让代码看起来更加的直观,因为类可以像内建类型(比如 int )一样。重载的操作符比 Equals() 或者 Add() 这样平淡乏味的函数名更好玩。为了让一些模板函数正确的工作,有时需要定义操作符。

缺点 :

尽管操作符重载能让代码更加直观,但是也有一些缺点:

  • 让我们直观的以为耗时的操作能够像内建类型操作一样。
  • 查找重载操作符的调用处比较困难。如果搜索 Equals() 就比 == 容易多了。
  • 一些操作符也会对指针进行操作,容易产生更多的错误。 Foo + 4 做的是一件事,而 &Foo + 4 做了另外意见完全不同的事。而编译器却不会对上面的两种情况报错,这样就很难去调试错误。

重载操作符也可能产生令人惊讶的副作用。比如,一个重载了 & 操作符的类不能被安全的前向声明。

总结 :

一般情况下,不要重载操作符。尤其是 = 操作符是很诡异,尽量避免。如果需要的话,你可以定义 Equals()CopyFrom() 这样的函数。同样的,如果一个类有可能被前向声明的话,无论如何不要重载 & 操作符。

然而,有极少情况下需要重载操作符和模板或是“标准” C++ 类衔接(比如日志类的 operator<<(ostream&, const T&) ).如果是合理的,那么就可以接受,但是尽量去避免重载操作符。尤其是不要重载 == 或者 < 操作符为了类能够在 STL 中用作键。去而代之,你应该在声明容器的时候创建相等和比较函数。

一些 STL 算法确实需要重载操作符 == ,这种情况下你可以这样做,但是在文档中表明为什么这样做。

参考 拷贝构造函数重载 小结。

访问控制

让数据成员是 private 的,需要的话提供存取函数(由于技术上的原因,当用到 Google Test的时候,我们允许将测试类中的数据成员声明为 protected )。一般一个叫做 foo_ 第变量的存取函数分别是 foo()set_foo() 。但是 static const 数据成员是例外(一般叫做 kFoo ),它不需要是 private 的。

存取函数一般内联在头文件中。

参考 继承函数名称 小结。

声明顺序

在类中使用这样特定的顺序 : public:private: 之前,函数成员在数据成员之前等等。

类的定义应该 public: 部分开始,然后是 protected: 部分,最后是 private: 部分。如果有部分是空的话,就删掉它。

在每一个部分里面,声明的顺序应该是如下的:

  • Typedefs 和枚举
  • 常量( static const 的数据成员)
  • 构造函数
  • 析构函数
  • 函数成员,包括静态方法
  • 数据成员(不包括 static const 的数据成员)

友员声明要在 private 部分, DISALLOW_COPY_AND_ASSIGN 宏定义应该在 private 部分的最后。它应该是类最后要做的事。参考 拷贝构造

.cc 中的方法定义也应该尽量是这个顺序。

不要在类定义中把大量代码短声明为内联的。通常,只有琐碎的,性能要求高的,很短的方法才被定义成内联的。详见 内联函数 小结。

编写短小函数

尽量编写短小精炼的函数。

我们知道有时长的函数是必须的,所有在函数长度上没有硬性要求。如果一个函数超过了40行,考虑一下能否在不毁坏程序结构的情况下将它分割。

就算你的长函数现在运行很好,几个月之后有人修改的话会增加新的行为。这有可能造成很难发现的错误。让你的函数保持短小和简单能让其他人很轻松的阅读和修改。

在处理一些代码的时候,你可能会遇到长的函数。修改它们的时候不要害怕:如果证实函数很复杂,很难发现错误,或者你只想用其中的一段,那么考虑把它分割成更小和更易于管理的代码段。

Google 特有魔法

Google 有很多使 C++ 代码更健壮的技巧的工具,这些方法和你在其它地方看到的不同。

智能指针

如果你真的需要指针,那么 scoped_ptr 很好。如果需要共享一个对象的所有权(比如在一个 STL 容器中),那么只能用 std::tr1::shared_ptr 和一个非常量引用。千万不要使用 auto_ptr

定义 :

智能指针是像指针一样的对象,但是自动管理底层内存。

优点 :

智能指针对于避免内存泄漏是很有用的,对于写异常安全代码也很有必要。同时它们格式化和文档化动态分配内存的所有权。

缺点 :

我们倾向与设计只有一个确定所有者的对象。可以把允许共享和转移所有权的智能指针作为所有全语义精心设计的诱人的选择,但是这种情况下当内存没有被删除的时候容易导致令人混淆的代码甚至是错误。智能指针(特别是 auto_ptr )的语义不明显。只能指针的异常安全优势也不是决定性的,因为我们不孕虚异常。

总结 :

scoped_ptr
简单无风险,合适就用。
auto_ptr
令人混淆,所有权转移容易出错。不要用。
shared_ptr
对于常量引用是安全的(比如 shared_ptr<const T> )。非常量的计数引用指针最好用 shared_ptr 。可能的话,试着改写成单一所有者。

cpplint

cpplint.py 去检测风格错误。

cpplint.py 是一个读取源程序并识别风格错误的工具。它不是完美的,会出现错误的判断,但是它仍然是一个宝贵的工具。在一行的后面加上 //NOLINT 能让工具忽略这一行。

一些工程有如何使用 cpplint 的方法。如果你的项目没有的话,可以下载。

其它 C++ 特性

引用参数

所有通过引用传递的参数一定要标为 const .

定义 :

在 C 程序里面,如果函数要修改一个变量,参数必须是一个指针,例如 int foo(int *pval) 。在 C++ 程序中,函数也可以把这个参数声明为引用 : int foo(int &val)

优点 :

将参数定义成引用能避免像 (*pval)++ 这样的代码。对于拷贝构造这样的程序很有必要。需要细讲的是,引用不像指针,存在 null 这样没有值的指针。

缺点 :

引用可能比较难懂,因为它们有值的语法但是却有指针的语义。

总结 :

函数参数列表的所有引用必须是 const 的:

void Foo(const string &in, string \*out);

事实上,Google 的代码有一个很硬性的规定,输入参数是值或者 const 引用,输出参数是指针。输入参数也可能是 const 指针,但是我们从不允许非常量引用参数,除了惯例需要,比如 swap() 函数。

然而,有时使用 const T* 比用 const T& 更好,比如:

  • 如果你想传递一个NULL指针。
  • 函数在输入变量保存了指针或者引用。

记住大部分情况下,输入参数都要是 const T& 。如果用 const T* 代替的话会告诉读者输入会被不同的对待。所以如果你选择 cosnt T* 而不是 const T& ,要因为一个具体的原因,否则会误导读者让他们去寻找一个不存在的原因。

函数重载

只有在一个寻找调用处的读者不需要指出哪一个重载函数被调用就可以明白什么会发生的情况下才使用重载函数(包括构造函数)。

定义:

你可以会写一个接受 const string& 参数的函数去重载另一个接受 const char* 参数的函数。

class MyClass {
    public :
        void Analyze(const string &text);
        void Analyze(const char \*text, size_t textlen);
};

优点 :

重载让代码看起来更直观,它允许相同名字的函数接受不同的参数。对于模板代码可能是必需的,它对访问者来说也很方便。

缺点 :

如果一个函数只是对参数类型进行了重载,那么读者必须理解 C++ 的复杂的匹配规则才能知道发生了什么。同样的,当继承类只重载了函数的几个变量,那么人们就会被这样的语义所迷惑。

总结 :

如果你想重载一个函数,考虑根据参数信息来命名,比如 AppendString() , AppendInt() ,而不是只使用 Append()

默认参数

我们不允许默认的函数参数,除非满足下面的要求。如果合适的话,用函数重载去模拟。

优点 :

你经常会碰到有默认值的函数,但是偶尔你想覆盖这些默认值。默认参数提供了一个简单的方法,而不是定义很多这样的函数。和重载函数相比,默认参数有更清晰的语法,更少的样本,“需要的”和“可选的”参数之间更清晰的区别。

缺点 :

默认参数的函数指针容易让人混淆,因为函数签名通常和调用签名不匹配。为一个已经存在的函数增加一个默认参数会改变它的类型,从而引起错误。而增加函数重载不会有这个问题。除此之外,默认参数会导致代码笨拙,因为它们在每次调用都重复了,不同的是,重载函数的“默认”参数在函数定义里面。

总结 :

虽然上面的缺点没有那么严重,但是它们仍然高估了默认参数对于函数重载的小小好处。所以除了下面提到了,我们需要所有的参数都显示的指定。

一个例外是当函数是 .cc 文件中的静态函数(或者在未名命名空间中),这种情况下,由于函数的使用太局部了所以缺点不适用。

另一个例外是默认参数用来模拟可变参数列表。

// Support up to 4 params by using a default empty AlphaNum.
string StrCat(const AlphaNum &a,
              const AlphaNum &b = gEmptyAlphaNum,
              const AlphaNum &c = gEmptyAlphaNum,
              const AlphaNum &d = gEmptyAlphaNum);

变长数组和 alloca()

我们不允许变长数组和 alloca()

优点 :

变长数组有自然的语法。变长数组和 alloca() 都是很高效的。

缺点 :

变长数组和 alloca 都不是标准 C++ 的一部分。更重要的是,它们动态分配一片依赖数据大小的堆栈空间,容易引起难以发现的内存覆盖错误:“在我机器上运行的很好,但是在生产环境下就神秘的死掉了”。

总结 :

用更安全的分配器,比如 scoped_ptr/scoped_array

友员

我们允许合理的使用友员类和友员函数。

友员一般都定义在同一个文件中,这样读者就不必去另外的文件找另一个类的私有成员。友员的一个基本用法是将 FooBuilder 声明成 Foo 类的友员,这样它就能正确的构造 Foo 的内部状态,而不用把它暴露出来。有时把一个单元测试类声明成该类的友员是很有好处的。

友员延伸但是没有打破类的边界。有时这比声明一个 public 的成员更好,因为你只想给另外的一个类访问自己的权利,然而,大多数类都是提供公共成员和其它类交互。

«  序言   ::   Contents