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()。注意那必须是个原始的指针,不是“智能”指针,因为智能指针的析构函数也会出现析构顺序问题,而那正是我们想避免的。
类
类是C++代码的基本单元。我们很自然会频繁地使用它。本节列出了一些在写一个类时应该和不应该做的事情。
在构造函数里做事情
一般来说,构造函数应该只是把成员变量设置成它们的初始值。其他复杂的初始化应该放到一个显式的Init()函数中。
定义:
在构造函数内进行初始化。
优点:
输入方便。不需要考虑这个类是否已经被初始化过了。
缺点:
在构造函数里做事情的问题在于:
- 让构造函数发出错误信号很不容易,没法使用异常。
- 如果失败了,就意味着你有了个初始化失败的对象,也就是处于一个不确定的状态。
- 如果调用了虚函数,那么将不会调用到子类实现。即使你的类现在还没有子类,一旦以后做了修改,仍将会默默地引入一些问题,造成很多混乱。
- 如果有人建立了这个类型的一个全局变量(这是违反规则的),构造函数的代码会在main()之前就被调用,可能会破坏构造函数代码中的一些隐含假设。
决定
如果你的对象需要非平凡的初始化,考虑用一个显式的Init()函数。特别是,构造函数不能调用虚函数、不能抛出错误、访问潜在未初始化的全局变量,等等。
默认构造函数
如果你的类定义了成员变量,但没有别的构造函数,就必须定义一个默认构造函数。否则编译器会把这件事情搞得很遭。
定义:
当不带参数新建一个类对象的时候,默认构造函数就会被调用。当调用new[]申请数组的时候,默认构造函数总是会被调到。
优点:
把结构体默认初始化成“不可能”的值,可以简化调试。
缺点:
写代码需要额外的工作。
决定
如果你的类定义了成员变量,但没有别的构造函数,就必须定义一个默认构造函数(没有参数的)。最好能把对象初始化成内部状态是一致而且有效的。
这么做的原因是如果你没有其他构造函数而且也没有默认构造函数,编译器会产生一个。编译器产生的构造函数可能无法聪明地把你的对象初始化得很好。
如果你的类继承了另一个类,但你增加了新的成员变量,不是一定要加一个默认构造函数。
显式构造函数
对于只有一个参数的构造函数,要使用C++关键字explicit.
定义:
通常,如果一个构造函数只有一个参数,它可能用作转换。比如,如果你定义了Foo::Foo(string name),然后把字符串传给一个希望得到Foo的函数,那么字符串会通过那个构造函数转换成Foo,然后把Foo传给那个函数。这看起来很方便,但同时也成为麻烦之源,你不希望出现的新对象被构造和转换。把构造函数定义成explicit就能阻止它以转换的形式被隐式调用。
优点:
避免不希望的转换。
缺点:
无。
决定
我们要求所有单参数构造函数都定义成explicit。在类定义里,总是把explicit放在单参数构造函数的前面:explicit Foo(string name);
例外情况是拷贝构造函数,除非极个别的情况,它不该是explicit。故意要透明地转换成其他类的类也是例外。这些例外需要清楚地在注释里标明。
拷贝构造函数
只在必要的时候后提供拷贝构造函数和赋值操作符。其他情况下,通过私有的拷贝构造函数和赋值操作符来禁用它们。
定义:
拷贝构造函数和赋值操作符用来建立对象的拷贝。在一些情况下,拷贝构造函数会被编译器隐式调用,比如,按值传递对象。
优点:
拷贝构造函数简化了对象拷贝。STL容器要求所有内容都必须是可以拷贝和赋值的。拷贝构造函数比CopyFrom()风格的方法更高效,因为它们合并了构造和拷贝,编译器在一些时候会去掉它们,以减少堆分配。
缺点:
C++中对象的隐式拷贝很可能成为bug和性能问题的源头。同时这也降低了可读性,因为很难追踪那个对象按值传递而不是按引用,因此也就不知道对象在哪里被修改了。
决定
很少有类需要拷贝,大部分应该没有构造函数和赋值操作符。在很多时候,指针或引用和拷贝值一样能用,并且性能更好。比如,你可以通过引用或指针来传递函数参数,而不用值,你也可以把指针而不是对象放入STL容器。
如果你的对象需要拷贝,最好提供一个拷贝方法,比如CopyFrom()或Clone(),而不是拷贝构造函数。因为这些方法不会被隐式调用。如果拷贝方法不足以对付你的情况(比如,因为性能原因,或因为你的类需要按值存入STL容器中),那么需要同时提供拷贝构造函数和赋值操作符。
如果你的类不需要拷贝构造函数和赋值操作符,你必须显式禁止它们。方法是,在类的private:里加一个拷贝构造函数和赋值操作符的空声明,但不要提供任何相应的定义(所以试图使用它们一定会导致链接错误)。比如,在class Foo:
class Foo { public: Foo(int f0, int f1); ~Foo(); private: Foo(Foo const &); void operator=(Foo const &); };
结构体和类
结构体仅用于只有数据的被动对象,其他都应该是类。
在C++中,struct和class关键字的行为几乎完全相同。我们给每个关键字增加了语义上的含义,所以你应该对定义的数据类型使用适当的关键字。
结构体应该用于被动对象,只包含数据,以及相关的常量,但没有任何除了访问/设置数据成员之外的功能。通过直接访问成员,而不是通过调用函数来访问/设置一个成员。函数只应该用来建立数据成员,比如,构造函数、析构函数、Initialize()、Reset()、Validate()。
如果需要更多的功能,定义成类更合适。如果还犹豫不决,就定义成类。
为了和STL一致,定义functor和trait可以使用结构体而不是类。
继承
组合经常比继承更合适。当使用继承的时候,用public。
定义:
当子类从基类继承而来的时候,它包含了基类定义的所有数据和操作。实际上,继承在C++里主要用作两种方式:实现继承——实际代码被子类继承,以及接口继承——只有方法名被继承。
优点:
在实现继承对一个现有类型进行特化的时候,它可以通过重用基类的代码来减少代码长度。因为继承是编译期声明的,你和编译器都可以明白这个操作,以及检测错误。接口继承可以用来在程序上强制一个类暴露出特定的API。在这种情况下,当一个类并没有定义API需要的方法时,编译器可以检测出错误。
缺点:
对于实现继承,因为子类的代码实现分散在了基类和子类之间,所以会更难理解。子类不能覆盖不是virtual的函数,所以子类不能改变实现。基类可能也得定义一些数据成员,所以基类的物理布局也就确定了。
决定
所有的继承都应该是public的。如果你要private继承,你应该改成包含一个基类对象作为成员。
不要过分使用实现继承,组合经常更合适。尽量把继承的使用限制在“是一个”的情况:如果Bar从Foo继承而来,是因为说Bar“是一种”Foo是合理的。
必要的话把析构函数声明为虚的。如果你的类有虚函数,构造函数也必须是虚的。
只有可能会被子类访问到的成员函数才用protect。注意数据成员应该是private。
当重定义一个继承来的虚函数时,在派生类的声明里把它显式声明成virtual。基本原理:如果virtual被省略了,读者就必须检查它的所有祖先类,才能确定这个函数是否是虚函数。
多重继承
多重继承只在一种稀有的情况下真能发挥作用。我们只有在最多一个基类有实现、其他基类都是纯接口类的时候才允许多重继承。
定义:
多重继承允许子类有多于一个基类。我们需要区分纯接口的基类和有实现的基类。
优点:
多重实现继承可以让你比单继承重用更多的代码(参见继承)。
缺点:
多重实现继承只在一种稀有的情况下真能发挥作用。当多重实现继承看似一个解决方案时,你通常可以找到另一个更明显、更干净的解决方案。
决定
只有当所有父类,除了第一个可以例外,其他都是纯接口的时候才允许多重继承。为了确保它们确实是纯接口,类名必须有Interface的后缀。
接口
满足一定条件的类可以,但不是必须,以Interface后缀为结尾。
定义:
一个类如果符合下列要求,就是一个纯接口:
- 只含有public纯虚("= 0")方法和静态方法(析构函数看下面)。
- 不能有非静态数据成员。
- 不需要定义任何构造函数。如果提供了构造函数,它不能有参数而且必须是protected。
- 如果是个子类,它只能从符合这些条件而且有Interface后缀的类继承而来。
接口类不能被直接实例化,因为它声明了纯虚方法。为了确保这个接口的所有实现都正确地销毁了,这个接口也必须声明虚析构函数(作为第一条规则的例外,析构不能是纯虚的)。细节参见Stroustrup的《The C++ Programming Language》第三版,第12.4节。
优点:
给一个类加上Interface的后缀可以让其他人知道他们不能增加函数实现或非静态数据成员。这一点在多重继承的时候尤为重要。另外,Java程序员已经非常了解接口的概念了。
缺点:
Interface后缀加长了类名,使得类较难阅读和明白。同时,接口属性算作实现细节,不该暴露给用户。
决定
只有符合上面要求的类可以以Interface结尾。但是,我们不要求反过来也成立:符合上面要求德雷不是必须以Interface结尾。
操作符重载
不要重载操作符,除了在很稀少、很特殊的情况下。
定义:
一个类可以定义操作符,比如+和/,就好象它们是内建类型一样。
优点:
可以让代码更直观,因为一个类会表现得像内建类型(比如int)。比起函数那样平淡无奇的名字,例如Equals()或Add(),重载操作符的名字则更有趣。为了让一些模板函数正确执行,你可能必须定义操作符。
缺点:
虽然操作符重载会让代码更直观,但它也有几个缺点:
- 它会愚弄你的直觉,让你以为昂贵的操作是廉价的内建操作。
- 重载操作符让调用的位置更加难以寻找。搜索Equals()比搜索==的相关调用容易得多。
- 有些操作符也能接受指针,更容易引入bug。Foo + 4做的是一件事情,而&Foo + 4则会做完全不同的事情。编译器不会报告其中任何一种情况,使得debug更困难。
- 重载也会有意外的副作用。例如,如果一个类重载了一元操作符&,它就不能安全地进行前置声明。
决定
总的来说,不要重载操作符。特别是赋值操作符(operator=)很危险,应该避免定义。如果需要的话,可以定义类似Equals()和 CopyFrom()的函数。同样,无论如何应该避免定义危险的一元操作符&,如果这个类有任何会被前置声明的可能性的话。
但是,在非常罕见的情况下,你需要重载操作符来与模板和“标准”C++类交互(比如用operator<<(ostream&, T const &)来记录日志)。如果条件完全满足的话也是可以接受的,但你应该试图尽量避免这些。特别是,不要仅仅为了让你的类可以在STL容器中用作key就重载operator==或operator<;取而代之的是,你在声明容器的时候应该建立相等和比较的仿函数类型。
有些STL算法确实需要你重载operator==,在这些情况下你也得那么做,同时在文档中标明原因。
访问控制
数据成员都应该是private的,并通过需要的访问函数来访问它们。一般来说一个变量应该命名为foo_,访问函数是Foo()。你可能也需要一个修改函数Foo()。例外:静态常量数据成员(一般称为FOO)不需要私有。
访问函数的定义一般inline在头文件里。
声明顺序
在类里面使用特定的声明顺序:public:在private:之前,函数在数据成员(变量)之前,等。
类定义应该从public:区域开始,接着是protected:区域,然后是private:区域。如果任何一个区域是空的,就跳过去。
在每一区域内,声明顺序一般如下:
- Typedef和枚举
- 常量(静态常量数据成员)
- 构造函数
- 析构函数
- 函数,包括静态函数
- 数据成员(除了静态常量数据成员)
友元声明应该总是在私有区域,用来禁止拷贝的拷贝构造和赋值操作符应该在private:区域的结尾。它应该是类的最后一个东西。参见拷贝构造函数。
在相应.cpp文件中定义的函数应该和声明顺序一致,至少尽可能一致。
不要在类定义中放入大函数定义。通常,只有琐碎的或者性能相关、而且非常短的函数可能定义成内联。细节参见内联函数。