Google C++编程风格指南(一):头文件 【本文】
Google C++编程风格指南(二):作用域
Google
C++编程风格指南(三):C++ 类
Google C++编程风格指南(四):智能指针和其他C++特性
Google
C++编程风格指南(五):命名约定
Google C++编程风格指南(六):代码注释
Google
C++编程风格指南(七):格式
Google C++编程风格指南(八):规则之例外
背景
Google的开源项目大多使用C++开发。每一个C++程序员也都知道,C++具有很多强大的语言特性,但这种强大不可避免的导致它的复杂,这种复杂会使得代码更易于出现bug、难于阅读和维护。
本指南的目的是通过详细阐述在C++编码时要怎样写、不要怎样写来规避其复杂性。这些规则可在允许代码有效使用C++语言特性的同时使其易于管理。
风格,也被视为可读性,主要指称管理C++代码的习惯。使用术语风格有点用词不当,因为这些习惯远不止源代码文件格式这么简单。
使代码易于管理的方法之一是增强代码一致性,让别人可以读懂你的代码是很重要的,保持统一编程风格意味着可以轻松根据“模式匹配”规则推断各种符号的含义。创建通用的、必需的习惯用语和模式可以使代码更加容易理解,在某些情况下改变一些编程风格可能会是好的选择,但我们还是应该遵循一致性原则,尽量不这样去做。
本指南的另一个观点是C++特性的臃肿。C++是一门包含大量高级特性的巨型语言,某些情况下,我们会限制甚至禁止使用某些特性使代码简化,避免可能导致的各种问题,指南中列举了这类特性,并解释说为什么这些特性是被限制使用的。
由Google开发的开源项目将遵照本指南约定。
注意:本指南并非C++教程,我们假定读者已经对C++非常熟悉。
头文件
通常,每一个.cc文件(C++的源文件)都有一个对应的.h文件(头文件),也有一些例外,如单元测试代码和只包含main()的.cc文件。
正确使用头文件可令代码在可读性、文件大小和性能上大为改观。
下面的规则将引导你规避使用头文件时的各种麻烦。
1.
#define的保护
所有头文件都应该使用#define防止头文件被多重包含(multiple
inclusion),命名格式当是:<PROJECT>_<PATH>_<FILE>_H_
为保证唯一性,头文件的命名应基于其所在项目源代码树的全路径。例如,项目foo中的头文件foo/src/bar/baz.h按如下方式保护:
#ifndef
FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif //
FOO_BAR_BAZ_H_
2. 头文件依赖
使用前置声明(forward
declarations)尽量减少.h文件中#include的数量。
当一个头文件被包含的同时也引入了一项新的依赖(dependency),只要该头文件被修改,代码就要重新编译。如果你的头文件包含了其他头文件,这些头文件的任何改变也将导致那些包含了你的头文件的代码重新编译。因此,我们宁可尽量少包含头文件,尤其是那些包含在其他头文件中的。
使用前置声明可以显著减少需要包含的头文件数量。举例说明:头文件中用到类File,但不需要访问File的声明,则头文件中只需前置声明class
File;无需#include "file/base/file.h"。
在头文件如何做到使用类Foo而无需访问类的定义?
1)
将数据成员类型声明为Foo *或Foo &;
2) 参数、返回值类型为Foo的函数只是声明(但不定义实现);
3)
静态数据成员的类型可以被声明为Foo,因为静态数据成员的定义在类定义之外。
另一方面,如果你的类是Foo的子类,或者含有类型为Foo的非静态数据成员,则必须为之包含头文件。
有时,使用指针成员(pointer
members,如果是scoped_ptr更好)替代对象成员(object
members)的确更有意义。然而,这样的做法会降低代码可读性及执行效率。如果仅仅为了少包含头文件,还是不要这样替代的好。
当然,.cc文件无论如何都需要所使用类的定义部分,自然也就会包含若干头文件。
译者注:能依赖声明的就不要依赖定义。
3. 内联函数
只有当函数只有10行甚至更少时才会将其定义为内联函数(inline
function)。
定义(Definition):当函数被声明为内联函数之后,编译器可能会将其内联展开,无需按通常的函数调用机制调用内联函数。
优点:当函数体比较小的时候,内联该函数可以令目标代码更加高效。对于存取函数(accessor、mutator)以及其他一些比较短的关键执行函数。
缺点:滥用内联将导致程序变慢,内联有可能是目标代码量或增或减,这取决于被内联的函数的大小。内联较短小的存取函数通常会减少代码量,但内联一个很大的函数(译者注:如果编译器允许的话)将戏剧性的增加代码量。在现代处理器上,由于更好的利用指令缓存(instruction
cache),小巧的代码往往执行更快。
结论:一个比较得当的处理规则是,不要内联超过10行的函数。对于析构函数应慎重对待,析构函数往往比其表面看起来要长,因为有一些隐式成员和基类析构函数(如果有的话)被调用!
另一有用的处理规则:内联那些包含循环或switch语句的函数是得不偿失的,除非在大多数情况下,这些循环或switch语句从不执行。
重要的是,虚函数和递归函数即使被声明为内联的也不一定就是内联函数。通常,递归函数不应该被声明为内联的(译者注:递归调用堆栈的展开并不像循环那么简单,比如递归层数在编译时可能是未知的,大多数编译器都不支持内联递归函数)。析构函数内联的主要原因是其定义在类的定义中,为了方便抑或是对其行为给出文档。
4.
-inl.h文件
复杂的内联函数的定义,应放在后缀名为-inl.h的头文件中。
在头文件中给出内联函数的定义,可令编译器将其在调用处内联展开。然而,实现代码应完全放到.cc文件中,我们不希望.h文件中出现太多实现代码,除非这样做在可读性和效率上有明显优势。
如果内联函数的定义比较短小、逻辑比较简单,其实现代码可以放在.h文件中。例如,存取函数的实现理所当然都放在类定义中。出于实现和调用的方便,较复杂的内联函数也可以放到.h文件中,如果你觉得这样会使头文件显得笨重,还可以将其分离到单独的-inl.h中。这样即把实现和类定义分离开来,当需要时包含实现所在的-inl.h即可。
-inl.h文件还可用于函数模板的定义,从而使得模板定义可读性增强。
要提醒的一点是,-inl.h和其他头文件一样,也需要#define保护。
5.
函数参数顺序(Function Parameter
Ordering)
定义函数时,参数顺序为:输入参数在前,输出参数在后。
C/C++函数参数分为输入参数和输出参数两种,有时输入参数也会输出(译者注:值被修改时)。输入参数一般传值或常数引用(const
references),输出参数或输入/输出参数为非常数指针(non-const
pointers)。对参数排序时,将所有输入参数置于输出参数之前。不要仅仅因为是新添加的参数,就将其置于最后,而应该依然置于输出参数之前。
这一点并不是必须遵循的规则,输入/输出两用参数(通常是类/结构体变量)混在其中,会使得规则难以遵循。
6. 包含文件的名称及次序
将包含次序标准化可增强可读性、避免隐藏依赖(hidden
dependencies,译者注:隐藏依赖主要是指包含的文件中编译时),次序如下:C库、C++库、其他库的.h、项目内的.h。
项目内头文件应按照项目源代码目录树结构排列,并且避免使用UNIX文件路径.(当前目录)和..(父目录)。例如,google-awesome-project/src/base/logging.h应像这样被包含:
#include
"base/logging.h"
dir/foo.cc的主要作用是执行或测试dir2/foo2.h的功能,foo.cc中包含头文件的次序如下:
dir2/foo2.h(优先位置,详情如下)
C系统文件
C++系统文件
其他库头文件
本项目内头文件
这种排序方式可有效减少隐藏依赖,我们希望每一个头文件独立编译。最简单的实现方式是将其作为第一个.h文件包含在对应的.cc中。
dir/foo.cc和dir2/foo2.h通常位于相同目录下(像base/basictypes_unittest.cc和base/basictypes.h),但也可在不同目录下。
相同目录下头文件按字母序是不错的选择。
举例来说,google-awesome-project/src/foo/internal/fooserver.cc的包含次序如下:
#include
"foo/public/fooserver.h" // 优先位置
#include
<sys/types.h>
#include <unistd.h>
#include
<hash_map>
#include <vector>
#include
"base/basictypes.h"
#include "base/commandlineflags.h"
#include
"foo/public/bar.h"
______________________________________
译者:英语不太好,翻译的也就不太好。这一篇主要提到的是头文件的一些规则,总结一下:
1. 避免多重包含是学编程时最基本的要求;
2. 前置声明是为了降低编译依赖,防止修改一个头文件引发多米诺效应;
3. 内联函数的合理使用可提高代码执行效率;
4. -inl.h可提高代码可读性(一般用不到吧
);
5.
标准化函数参数顺序可以提高可读性和易维护性(对函数参数的堆栈空间有轻微影响,我以前大多是相同类型放在一起);
6.
包含文件的名称使用.和..虽然方便却易混乱,使用比较完整的项目路径看上去很清晰、很条理,包含文件的次序除了美观之外,最重要的是可以减少隐藏依赖,使每个头文件在“最需要编译”(对应源文件处
)的地方编译,有人提出库文件放在最后,这样出错先是项目内的文件,头文件都放在对应源文件的最前面,这一点足以保证内部错误的及时发现了。
Google C++编程风格指南(二):作用域
1. 命名空间(Namespaces)
在.cc文件中,提倡使用不具名的命名空间(unnamed namespaces,译者注:不具名的命名空间就像不具名的类一样,似乎被介绍的很少:-()。使用具名命名空间时,其名称可基于项目或路径名称,不要使用using指示符。
定义:命名空间将全局作用域细分为不同的、具名的作用域,可有效防止全局作用域的命名冲突。
优点:命名空间提供了(可嵌套)命名轴线(name axis,译者注:将命名分割在不同命名空间内),当然,类也提供了(可嵌套)的命名轴线(译者注:将命名分割在不同类的作用域内)。
举例来说,两个不同项目的全局作用域都有一个类Foo,这样在编译或运行时造成冲突。如果每个项目将代码置于不同命名空间中,project1::Foo和project2::Foo作为不同符号自然不会冲突。
缺点:命名空间具有迷惑性,因为它们和类一样提供了额外的(可嵌套的)命名轴线。在头文件中使用不具名的空间容易违背C++的唯一定义原则(One Definition Rule (ODR))。
结论:根据下文将要提到的策略合理使用命名空间。
1) 不具名命名空间(Unnamed Namespaces)
在.cc文件中,允许甚至提倡使用不具名命名空间,以避免运行时的命名冲突:
namespace { // .cc 文件中
// 命名空间的内容无需缩进
enum { UNUSED, EOF, ERROR }; // 经常使用的符号
bool AtEof() { return pos_ == EOF; } // 使用本命名空间内的符号EOF
} // namespace
然而,与特定类关联的文件作用域声明在该类中被声明为类型、静态数据成员或静态成员函数,而不是不具名命名空间的成员。像上文展示的那样,不具名命名空间结束时用注释// namespace标识。
不能在.h文件中使用不具名命名空间。
2) 具名命名空间(Named Namespaces)
具名命名空间使用方式如下:
命名空间将除文件包含、全局标识的声明/定义以及类的前置声明外的整个源文件封装起来,以同其他命名空间相区分。
// .h文件
namespace mynamespace {
// 所有声明都置于命名空间中
// 注意不要使用缩进
class MyClass {
public:
...
void Foo();
};
} // namespace mynamespace
// .cc文件
namespace mynamespace {
// 函数定义都置于命名空间中
void MyClass::Foo() {
...
}
} // namespace mynamespace
通常的.cc文件会包含更多、更复杂的细节,包括对其他命名空间中类的引用等。
#include "a.h"
DEFINE_bool(someflag, false, "dummy flag");
class C; // 全局命名空间中类C的前置声明
namespace a { class A; } // 命名空间a中的类a::A的前置声明
namespace b {
...code for b... // b中的代码
} // namespace b
不要声明命名空间std下的任何内容,包括标准库类的前置声明。声明std下的实体会导致不明确的行为,如,不可移植。声明标准库下的实体,需要包含对应的头文件。
最好不要使用using指示符,以保证命名空间下的所有名称都可以正常使用。
// 禁止——污染命名空间
using namespace foo;
在.cc文件、.h文件的函数、方法或类中,可以使用using。
// 允许:.cc文件中
// .h文件中,必须在函数、方法或类的内部使用
using ::foo::bar;
在.cc文件、.h文件的函数、方法或类中,还可以使用命名空间别名。
// 允许:.cc文件中
// .h文件中,必须在函数、方法或类的内部使用
namespace fbz = ::foo::bar::baz;
2. 嵌套类(Nested Class)
当公开嵌套类作为接口的一部分时,虽然可以直接将他们保持在全局作用域中,但将嵌套类的声明置于命名空间中是更好的选择。
定义:可以在一个类中定义另一个类,嵌套类也称成员类(member class)。
class Foo {
private:
// Bar是嵌套在Foo中的成员类
class Bar {
...
};
};
优点:当嵌套(成员)类只在被嵌套类(enclosing class)中使用时很有用,将其置于被嵌套类作用域作为被嵌套类的成员不会污染其他作用域同名类。可在被嵌套类中前置声明嵌套类,在.cc文件中定义嵌套类,避免在被嵌套类中包含嵌套类的定义,因为嵌套类的定义通常只与实现相关。
缺点:只能在被嵌套类的定义中才能前置声明嵌套类。因此,任何使用Foo::Bar*指针的头文件必须包含整个Foo的声明。
结论:不要将嵌套类定义为public,除非它们是接口的一部分,比如,某个方法使用了这个类的一系列选项。
3. 非成员函数(Nonmember)、静态成员函数(Static Member)和全局函数(Global Functions)
使用命名空间中的非成员函数或静态成员函数,尽量不要使用全局函数。
优点:某些情况下,非成员函数和静态成员函数是非常有用的,将非成员函数置于命名空间中可避免对全局作用域的污染。
缺点:将非成员函数和静态成员函数作为新类的成员或许更有意义,当它们需要访问外部资源或具有重要依赖时更是如此。
结论:
有时,不把函数限定在类的实体中是有益的,甚至需要这么做,要么作为静态成员,要么作为非成员函数。非成员函数不应依赖于外部变量,并尽量置于某个命名空间中。相比单纯为了封装若干不共享任何静态数据的静态成员函数而创建类,不如使用命名空间。
定义于同一编译单元的函数,被其他编译单元直接调用可能会引入不必要的耦合和连接依赖;静态成员函数对此尤其敏感。可以考虑提取到新类中,或者将函数置于独立库的命名空间中。
如果你确实需要定义非成员函数,又只是在.cc文件中使用它,可使用不具名命名空间或static关联(如static int Foo() {...})限定其作用域。
4. 局部变量(Local Variables)
将函数变量尽可能置于最小作用域内,在声明变量时将其初始化。
C++允许在函数的任何位置声明变量。我们提倡在尽可能小的作用域中声明变量,离第一次使用越近越好。这使得代码易于阅读,易于定位变量的声明位置、变量类型和初始值。特别是,应使用初始化代替声明+赋值的方式。
int i;
i = f(); // 坏——初始化和声明分离
int j = g(); // 好——初始化时声明
注意:gcc可正确执行for (int i = 0; i < 10; ++i)(i的作用域仅限for循环),因此其他for循环中可重用i。if和while等语句中,作用域声明(scope declaration)同样是正确的。
while (const char* p = strchr(str, '/')) str = p + 1;
注意:如果变量是一个对象,每次进入作用域都要调用其构造函数,每次退出作用域都要调用其析构函数。
// 低效的实现
for (int i = 0; i < 1000000; ++i) {
Foo f; // 构造函数和析构函数分别调用1000000次!
f.DoSomething(i);
}
类似变量放到循环作用域外面声明要高效的多:
Foo f; // 构造函数和析构函数只调用1次
for (int i = 0; i < 1000000; ++i) {
f.DoSomething(i);
}
5. 全局变量(Global Variables)
class类型的全局变量是被禁止的,内建类型的全局变量是允许的,当然多线程代码中非常数全局变量也是被禁止的。永远不要使用函数返回值初始化全局变量。
不幸的是,全局变量的构造函数、析构函数以及初始化操作的调用顺序只是被部分规定,每次生成有可能会有变化,从而导致难以发现的bugs。
因此,禁止使用class类型的全局变量(包括STL的string, vector等等),因为它们的初始化顺序有可能导致构造出现问题。内建类型和由内建类型构成的没有构造函数的结构体可以使用,如果你一定要使用class类型的全局变量,请使用单件模式(singleton pattern)。
对于全局的字符串常量,使用C风格的字符串,而不要使用STL的字符串:
const char kFrogSays[] = "ribbet";
虽然允许在全局作用域中使用全局变量,使用时务必三思。大多数全局变量应该是类的静态数据成员,或者当其只在.cc文件中使用时,将其定义到不具名命名空间中,或者使用静态关联以限制变量的作用域。
记住,静态成员变量视作全局变量,所以,也不能是class类型!
______________________________________
译者:这一篇主要提到的是作用域的一些规则,总结一下:
1. .cc中的不具名命名空间可避免命名冲突、限定作用域,避免直接使用using提示符污染命名空间;
2. 嵌套类符合局部使用原则,只是不能在其他头文件中前置声明,尽量不要public;
3. 尽量不用全局函数和全局变量,考虑作用域和命名空间限制,尽量单独形成编译单元;
4. 多线程中的全局变量(含静态成员变量)不要使用class类型(含STL容器),避免不明确行为导致的bugs。
作用域的使用,除了考虑名称污染、可读性之外,主要是为降低耦合度,提高编译、执行效率。
Google C++编程风格指南(三):C++ 类
类是C++中基本的代码单元,自然被广泛使用。本节列举了在写一个类时要做什么、不要做什么。
1. 构造函数(Constructor)的职责
构造函数中只进行那些没有实际意义的(trivial,译者注:简单初始化对于程序执行没有实际的逻辑意义,因为成员变量的“有意义”的值大多不在构造函数中确定)初始化,可能的话,使用Init()方法集中初始化为有意义的(non-trivial)数据。
定义:在构造函数中执行初始化操作。
优点:排版方便,无需担心类是否初始化。
缺点:在构造函数中执行操作引起的问题有:
1) 构造函数中不易报告错误,不能使用异常。
2) 操作失败会造成对象初始化失败,引起不确定状态。
3) 构造函数内调用虚函数,调用不会派发到子类实现中,即使当前没有子类化实现,将来仍是隐患。
4) 如果有人创建该类型的全局变量(虽然违背了上节提到的规则),构造函数将在main()之前被调用,有可能破坏构造函数中暗含的假设条件。例如,gflags尚未初始化。
结论:如果对象需要有意义的(non-trivial)初始化,考虑使用另外的Init()方法并(或)增加一个成员标记用于指示对象是否已经初始化成功。
2. 默认构造函数(Default Constructors)
如果一个类定义了若干成员变量又没有其他构造函数,需要定义一个默认构造函数,否则编译器将自动生产默认构造函数。
定义:新建一个没有参数的对象时,默认构造函数被调用,当调用new[](为数组)时,默认构造函数总是被调用。
优点:默认将结构体初始化为“不可能的”值,使调试更加容易。
缺点:对代码编写者来说,这是多余的工作。
结论:
如果类中定义了成员变量,没有提供其他构造函数,你需要定义一个默认构造函数(没有参数)。默认构造函数更适合于初始化对象,使对象内部状态(internal state)一致、有效。
提供默认构造函数的原因是:如果你没有提供其他构造函数,又没有定义默认构造函数,编译器将为你自动生成一个,编译器生成的构造函数并不会对对象进行初始化。
如果你定义的类继承现有类,而你又没有增加新的成员变量,则不需要为新类定义默认构造函数。
3. 明确的构造函数(Explicit Constructors)
对单参数构造函数使用C++关键字explicit。
定义:通常,只有一个参数的构造函数可被用于转换(conversion,译者注:主要指隐式转换,下文可见),例如,定义了Foo::Foo(string name),当向需要传入一个Foo对象的函数传入一个字符串时,构造函数Foo::Foo(string name)被调用并将该字符串转换为一个Foo临时对象传给调用函数。看上去很方便,但如果你并不希望如此通过转换生成一个新对象的话,麻烦也随之而来。为避免构造函数被调用造成隐式转换,可以将其声明为explicit。
优点:避免不合时宜的变换。
缺点:无。
结论:
所有单参数构造函数必须是明确的。在类定义中,将关键字explicit加到单参数构造函数前:explicit Foo(string name);
例外:在少数情况下,拷贝构造函数可以不声明为explicit;特意作为其他类的透明包装器的类。类似例外情况应在注释中明确说明。
4. 拷贝构造函数(Copy Constructors)
仅在代码中需要拷贝一个类对象的时候使用拷贝构造函数;不需要拷贝时应使用DISALLOW_COPY_AND_ASSIGN。
定义:通过拷贝新建对象时可使用拷贝构造函数(特别是对象的传值时)。
优点:拷贝构造函数使得拷贝对象更加容易,STL容器要求所有内容可拷贝、可赋值。
缺点:C++中对象的隐式拷贝是导致很多性能问题和bugs的根源。拷贝构造函数降低了代码可读性,相比按引用传递,跟踪按值传递的对象更加困难,对象修改的地方变得难以捉摸。
结论:
大量的类并不需要可拷贝,也不需要一个拷贝构造函数或赋值操作(assignment operator)。不幸的是,如果你不主动声明它们,编译器会为你自动生成,而且是public的。
可以考虑在类的private中添加空的(dummy)拷贝构造函数和赋值操作,只有声明,没有定义。由于这些空程序声明为private,当其他代码试图使用它们的时候,编译器将报错。为了方便,可以使用宏DISALLOW_COPY_AND_ASSIGN:
// 禁止使用拷贝构造函数和赋值操作的宏
// 应在类的private:中使用
#define DISALLOW_COPY_AND_ASSIGN(TypeName) /
TypeName(const TypeName&); /
void operator=(const TypeName&)
class Foo {
public:
Foo(int f);
~Foo();
private:
DISALLOW_COPY_AND_ASSIGN(Foo);
};
如上所述,绝大多数情况下都应使用DISALLOW_COPY_AND_ASSIGN,如果类确实需要可拷贝,应在该类的头文件中说明原由,并适当定义拷贝构造函数和赋值操作,注意在operator=中检测自赋值(self-assignment)情况。
在将类作为STL容器值得时候,你可能有使类可拷贝的冲动。类似情况下,真正该做的是使用指针指向STL容器中的对象,可以考虑使用std::tr1::shared_ptr。
5. 结构体和类(Structs vs. Classes)
仅当只有数据时使用struct,其它一概使用class。
在C++中,关键字struct和class几乎含义等同,我们为其人为添加语义,以便为定义的数据类型合理选择使用哪个关键字。
struct被用在仅包含数据的消极对象(passive objects)上,可能包括有关联的常量,但没有存取数据成员之外的函数功能,而存取功能通过直接访问实现而无需方法调用,这儿提到的方法是指只用于处理数据成员的,如构造函数、析构函数、Initialize()、Reset()、Validate()。
如果需要更多的函数功能,class更适合,如果不确定的话,直接使用class。
如果与STL结合,对于仿函数(functors)和特性(traits)可以不用class而是使用struct。
注意:类和结构体的成员变量使用不同的命名规则。
6. 继承(Inheritance)
使用组合(composition,译者注,这一点也是GoF在《Design Patterns》里反复强调的)通常比使用继承更适宜,如果使用继承的话,只使用公共继承。
定义:当子类继承基类时,子类包含了父基类所有数据及操作的定义。C++实践中,继承主要用于两种场合:实现继承(implementation inheritance),子类继承父类的实现代码;接口继承(interface inheritance),子类仅继承父类的方法名称。
优点:实现继承通过原封不动的重用基类代码减少了代码量。由于继承是编译时声明(compile-time declaration),编码者和编译器都可以理解相应操作并发现错误。接口继承可用于程序上增强类的特定API的功能,在类没有定义API的必要实现时,编译器同样可以侦错。
缺点:对于实现继承,由于实现子类的代码在父类和子类间延展,要理解其实现变得更加困难。子类不能重写父类的非虚函数,当然也就不能修改其实现。基类也可能定义了一些数据成员,还要区分基类的物理轮廓(physical layout)。
结论:
所有继承必须是public的,如果想私有继承的话,应该采取包含基类实例作为成员的方式作为替代。
不要过多使用实现继承,组合通常更合适一些。努力做到只在“是一个”("is-a",译者注,其他"has-a"情况下请使用组合)的情况下使用继承:如果Bar的确“是一种”Foo,才令Bar是Foo的子类。
必要的话,令析构函数为virtual,必要是指,如果该类具有虚函数,其析构函数应该为虚函数。
译者注:至于子类没有额外数据成员,甚至父类也没有任何数据成员的特殊情况下,析构函数的调用是否必要是语义争论,从编程设计规范的角度看,在含有虚函数的父类中,定义虚析构函数绝对必要。
限定仅在子类访问的成员函数为protected,需要注意的是数据成员应始终为私有。
当重定义派生的虚函数时,在派生类中明确声明其为virtual。根本原因:如果遗漏virtual,阅读者需要检索类的所有祖先以确定该函数是否为虚函数(译者注,虽然不影响其为虚函数的本质)。
7. 多重继承(Multiple Inheritance)
真正需要用到多重实现继承(multiple implementation inheritance)的时候非常少,只有当最多一个基类中含有实现,其他基类都是以Interface为后缀的纯接口类时才会使用多重继承。
定义:多重继承允许子类拥有多个基类,要将作为纯接口的基类和具有实现的基类区别开来。
优点:相比单继承,多重实现继承可令你重用更多代码。
缺点:真正需要用到多重实现继承的时候非常少,多重实现继承看上去是不错的解决方案,通常可以找到更加明确、清晰的、不同的解决方案。
结论:只有当所有超类(superclass)除第一个外都是纯接口时才能使用多重继承。为确保它们是纯接口,这些类必须以Interface为后缀。
注意:关于此规则,Windows下有种例外情况(译者注,将在本译文最后一篇的规则例外中阐述)。
8. 接口(Interface)
接口是指满足特定条件的类,这些类以Interface为后缀(非必需)。
定义:当一个类满足以下要求时,称之为纯接口:
1) 只有纯虚函数("=0")和静态函数(下文提到的析构函数除外);
2) 没有非静态数据成员;
3) 没有定义任何构造函数。如果有,也不含参数,并且为protected;
4) 如果是子类,也只能继承满足上述条件并以Interface为后缀的类。
接口类不能被直接实例化,因为它声明了纯虚函数。为确保接口类的所有实现可被正确销毁,必须为之声明虚析构函数(作为第1条规则的例外,析构函数不能是纯虚函数)。具体细节可参考Stroustrup的《The C++ Programming Language, 3rd edition》第12.4节。
优点:以Interface为后缀可令他人知道不能为该接口类增加实现函数或非静态数据成员,这一点对于多重继承尤其重要。另外,对于Java程序员来说,接口的概念已经深入人心。
缺点:Interface后缀增加了类名长度,为阅读和理解带来不便,同时,接口特性作为实现细节不应暴露给客户。
结论:。只有在满足上述需要时,类才以Interface结尾,但反过来,满足上述需要的类未必一定以Interface结尾。
9. 操作符重载(Operator Overloading)
除少数特定环境外,不要重载操作符。
定义:一个类可以定义诸如+、/等操作符,使其可以像内建类型一样直接使用。
优点:使代码看上去更加直观,就像内建类型(如int)那样,重载操作符使那些Equals()、Add()等黯淡无光的函数名好玩多了。为了使一些模板函数正确工作,你可能需要定义操作符。
缺点:虽然操作符重载令代码更加直观,但也有一些不足
1) 混淆直觉,让你误以为一些耗时的操作像内建操作那样轻巧;
2) 查找重载操作符的调用处更加困难,查找Equals()显然比同等调用==容易的多;
3) 有的操作符可以对指针进行操作,容易导致bugs,Foo + 4做的是一件事,而&Foo + 4可能做的是完全不同的另一件事,对于二者,编译器都不会报错,使其很难调试;
4) 重载还有令你吃惊的副作用,比如,重载操作符&的类不能被前置声明。
结论:
一般不要重载操作符,尤其是赋值操作(operator=)比较阴险,应避免重载。如果需要的话,可以定义类似Equals()、CopyFrom()等函数。
然而,极少数情况下需要重载操作符以便与模板或“标准”C++类衔接(如operator<<(ostream&, const T&)),如果被证明是正当的尚可接受,但你要尽可能避免这样做。尤其是不要仅仅为了在STL容器中作为key使用就重载operator==或operator<,取而代之,你应该在声明容器的时候,创建相等判断和大小比较的仿函数类型。
有些STL算法确实需要重载operator==时可以这么做,不要忘了提供文档说明原因。
参考拷贝构造函数和函数重载。
10. 存取控制(Access Control)
将数据成员私有化,并提供相关存取函数,如定义变量foo_及取值函数foo()、赋值函数set_foo()。
存取函数的定义一般内联在头文件中。
参考继承和函数命名。
11. 声明次序(Declaration Order)
在类中使用特定的声明次序:public:在private:之前,成员函数在数据成员(变量)前。
定义次序如下:public:、protected:、private:,如果那一块没有,直接忽略即可。
每一块中,声明次序一般如下:
1) typedefs和enums;
2) 常量;
3) 构造函数;
4) 析构函数;
5) 成员函数,含静态成员函数;
6) 数据成员,含静态数据成员。
宏DISALLOW_COPY_AND_ASSIGN置于private:块之后,作为类的最后部分。参考拷贝构造函数。
.cc文件中函数的定义应尽可能和声明次序一致。
不要将大型函数内联到类的定义中,通常,只有那些没有特别意义的或者性能要求高的,并且是比较短小的函数才被定义为内联函数。更多细节参考译文第一篇的内联函数。
12. 编写短小函数(Write Short Functions)
倾向于选择短小、凝练的函数。
长函数有时是恰当的,因此对于函数长度并没有严格限制。如果函数超过40行,可以考虑在不影响程序结构的情况下将其分割一下。
即使一个长函数现在工作的非常好,一旦有人对其修改,有可能出现新的问题,甚至导致难以发现的bugs。使函数尽量短小、简单,便于他人阅读和修改代码。
在处理代码时,你可能会发现复杂的长函数,不要害怕修改现有代码:如果证实这些代码使用、调试困难,或者你需要使用其中的一小块,考虑将其分割为更加短小、易于管理的若干函数。
______________________________________
译者:关于类的注意事项,总结一下:
1. 不在构造函数中做太多逻辑相关的初始化;
2. 编译器提供的默认构造函数不会对变量进行初始化,如果定义了其他构造函数,编译器不再提供,需要编码者自行提供默认构造函数;
3. 为避免隐式转换,需将单参数构造函数声明为explicit;
4. 为避免拷贝构造函数、赋值操作的滥用和编译器自动生成,可目前声明其为private且无需实现;
5. 仅在作为数据集合时使用struct;
6. 组合>实现继承>接口继承>私有继承,子类重载的虚函数也要声明virtual关键字,虽然编译器允许不这样做;
7. 避免使用多重继承,使用时,除一个基类含有实现外,其他基类均为纯接口;
8. 接口类类名以Interface为后缀,除提供带实现的虚析构函数、静态成员函数外,其他均为纯虚函数,不定义非静态数据成员,不提供构造函数,提供的话,声明为protected;
9. 为降低复杂性,尽量不重载操作符,模板、标准类中使用时提供文档说明;
10. 存取函数一般内联在头文件中;
11. 声明次序:public->protected->private;
12. 函数体尽量短小、紧凑,功能单一。