C++编程风格笔记

定义函数时,参数顺序为:输入参数在前,输出参数在后。

C/C++函数参数分为输入参数和输出参数两种,有时输入参数也会输出(值被修改时),输入参数一般传值或常数引用,输出参数或输入/输出常数为非常数指针(non-const pointers)。对参数排序时,将所有输入参数置于输出参数之前。不要仅仅因为是新添加的参数,就将其置于最后,而应该依然置于输出参数之前。


包含文件的名称及次序

将包含次序标准化可增强可读性丶避免隐藏依赖(hidden dependencies, 隐藏依赖主要指包含的文件中编译时),次序如下:C库丶C++库丶其他库的.h丶项目内的.h。

项目内头文件应按照项目源代码目录树结构排序,并且避免使用UNIX文件路径。//.(当前路径) ..(父路径)。//例如,google-awesome-project/src/base/logging.h应像这样被包含:

1
#include "base/logging.h"

dir/foo.cc的主要作用是执行或测试dir2/foo2.h的功能,foo.cc中包含头文件的次序如下:

1
2
3
4
5
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”的包含次序如下:

1
2
3
4
5
6
7
8
#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. 包含文件的名称使用.和..虽然方便却易混乱,使用比较完整的项目路径看上去很清晰丶很条理,包含文件的次序除了美观之外,最重要的是可以减少隐藏依赖,使每个头文件在“最需要编译”的地方编译(对应源文件处),有人提出库文件放在最后,这样出错先是项目内的文件,头文件都放在对应源文件的最前面,这一点足以保证内部错误的即时发现了。

局部变量


将函数尽可能置于最小作用域内,在声明变量时将其初始化。

C++允许在函数的任何位置声明变量。提倡在尽可能小的作用域内声明变量,离第一次使用越近越好。这使得代码易于阅读,易于定位变量的声明位置丶变量类型和初始值。特别是,应使用初始化代替声明+赋值的方式。

1
2
3
int i;
i = f(); //坏 ---- 初始化和声明分离
int j = g(); //好------初始化时声明

注意:gcc可正确执行for(int i =0; i < 10 ;++i)(i的作用域仅限for循环),因此其他for循环可重用i。if和 while 等语句中,作用域声明同样是正确的。

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

注意:如果变量是一个对象,每次进入作用域都要调用其构造函数,每次退出都要调用其析构函数。

//低效的实现

1
2
3
4
for(int i = 0; i < 1000000; ++i) {
Foo f;
f.DoSomething();
}

类似变量要放到循环作用域外面声明要高效的多:

1
2
3
4
Foo f;
for (int i = 0; i < 1000000; ++i) {
f.DoSomething();
}

全局变量


class类型的全局变量是被禁止的,内建类型的全局变量是允许的,当然多线程代码中非常数全局变量也是被禁止的。永远不要使用函数返回值初始化全局变量。如果一定要使用class类型的全局变量,请使用单例模式(singleton pattern)。

对于全局的字符串常量,使用C风格的字符串,而不要使用STL的字符串:

1
const char kFrogSays[] = "ribbet";

虽然允许在全局作用域使用全局变量,但请三思。大多数全局变量应该是类的静态数据成员,或者当其只在.cc文件中使用时,将其定义到不具名命名空间中,或者使用静态关联以限制变量的作用域。

记住,静态成员变量视作全局变量,所以也不能是class类型!!

总结:

  1. .cc文件中的不具名空间可避免命名冲突丶限定作用域,避免直接使用using提示符污染命名空间。
  2. 嵌套类符合局部使用原则,只是不能在其他头文件中前置声明,尽量不要public
  3. 尽量不用全局函数和全局变量,考虑作用域和命名空间限制,尽量单独形成编译单元。
  4. 多线程中的全局变量(含静态成员变量)不要使用class类型(含STL容器),避免不明确行为导致的bugs
  5. 作用域的使用,除了考虑名称污染可读性之外,主要是为降低耦合度,提高编译丶执行效率。


介绍类该做声明,不该做什么。

explicit: 明确的

implicit: 隐含的

trivial: 没有意义的

non-trivial: 有意义的


#1.构造函数的职责

构造函数中只进行那些没有实际意义的初始化(trivial,简单初始化对于程序执行没有实际的逻辑意义,因为成员变量的“有意义”的值大多不在构造函数中确定),可能的话,使用Init()方法集中初始化为有意义的数据(non-trivial).

定义:在构造函数中执行初始化操作。

优点:排版方便,无需担心类是否初始化。

缺点:在构造函数中执行操作引起的问题有:

  1. 构造函数不易报告错误,不能使用异常
  2. 操作失败会造成对象初始化失败,引起不确定状态
  3. 构造函数内调用虚函数,调用不会派发到子类实现中,即时当前没有子类化实现,将来仍是隐患
  4. 如果有人创建该类型的全局变量,构造函数将在main()之前被调用,有可能破坏构造函数中暗含的假设条件

结论:如果对象需要有意义的(non-trivial)初始化,考虑使用另外的Init()方法并增加一个成员标志用于指示对象是否初始化成功。

###2.默认构造函数

如果一个类定义了若干成员变量又没有其他构造函数,需要定义一个默认构造函数,否则编译器将自动生产默认构造函数。

定义:新建一个没有参数的对象时,默认构造函数被调用,当调用new[](为数组)时,默认构造函数总是被调用。

优点:默认将结构体初始化为“不可能的值。”,使调试更加容易。

缺点:对代码编写者来说是多余的工作。

结论:

如果类中定义了成员变量,没有提供其他构造函数,你需要定义一个默认构造函数(无参数)。默认构造函数更适合初始化对象,使对象内部状态(internal state)一致,有效。

提供默认构造函数的原因是:如果倪没有提供其他构造函数,又没有定义默认构造函数。编译器会自动生成一个,但编译器生成的构造函数不会对对象进行初始化。

如果你定义的类继承现有类,而你又没有增加新的成员变量,则不需要为新类定义默认构造函数。

###3.明确的构造函数(Explicit Constructors)

对单参数构造函数使用C++关键字explicit。

定义:只有一个参数的构造函数可被用于转换(只要指隐式转换)。例如,定义了Foo::Foo(string name),当向需要传入一个Foo对象的函数传入一个字符串时,构造函数Foo::Foo(string name)被调用并将该字符串转换为一个Foo临时对象传给调用函数。看上去方便,但是如果你并不希望通过转换生成一个新对象的话,麻烦也随着而来,为避免构造函数被调用造成隐式转换,可以将其声明为explicit。

优点:避免不合时宜的转换

缺点:

结论

所有单参数构造函数必须是明确的(explicit)。在类定义中,将关键字explicit加到单参数构造函数前:

explicit Foo(string name)

例外:在少数情况下,拷贝构造函数可以不声明为explicit;特意作为其他类的透明包装器的类。

4.拷贝构造函数

仅在代码中需要拷贝一个类对象的时候使用拷贝构造函数;不需要拷贝时应使用DISALLOW_COPY_AND_ASSIGN。

定义:通过拷贝新建对象时可使用拷贝构造函数(特别是对象的传值时).

优点:拷贝构造函数使得拷贝对象更加容易,STL容器要求所有内容可拷贝丶可赋值。

缺点:C++中对象的隐式拷贝是导致很多性能问题和bugs的根源。拷贝构造函数降低了代码可读性,相比于按引用传递,跟踪按值传递的对象更加困难,对象修改的地方变得难以琢磨。

结论:

大量的类并不需要可拷贝,也不需要一个拷贝构造函数或赋值操作。但不幸的是,你不主动声明他们,编译器会为你自动生成,而且是public。

可以考虑在类的private中添加空的(dummy)拷贝构造函数和赋值操作,只有声明,没有定义。由于这些空程序声明为private,当其他代码视图使用他们时,编译器将报错,为了方便,可以使用宏定义DISALLOW_COPY_AND_ASSIGN。

1
2
3
4
5
6
7
8
9
10
11
12
//禁止使用拷贝构造函数和赋值操作的宏
//应在类的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.结构体和类(struct vs Classes)

仅当只有数据时使用struct,其他一律使用class。

在C++中,关键字structclass几乎含义等同,我们为其认为添加语义,以便为定义的数据类型合理选择使用哪个关键字。

struct被用在仅包含数据的消极对象(passive objetcts)上,可能包括有关联的常量,但没有存取数据成员之外的函数功能,而存取功能通过直接访问实现而无需方法调用,这提到的方法是只用于处理数据成员的,如构造函数丶析构函数丶Initialize()丶Reset()丶Validate().

如果需要更多的函数功能,class更适合,如果不确定,直接使用class。

如果与STL结合,对于仿函数(functors)和特性(traits)可以不用class而是使用struct。

6.继承(inheritance)

使用组合(composition)通常比继承更适宜,如果使用继承的话,只是用公共继承。

定义:当子类继承基类时,子类包含了父基类所有数据及操作的定义。C++中,继承主要用于两个场合:1.实现继承,子类继承父类的实现代码。2.接口继承,子类仅继承父类的方法名称

优点:实现继承通过原封不动的重用基类代码减少了代码量。由于继承是编译时声明,编码者和编译器都可以理解相应操作并发现错误。接口继承可用于程序上增强类的特定API的功能,在类没有定义API的必要实现时,编译器同样可以甄错。

缺点:对于实现继承,由于实现子类的代码在父类和子类间延展,要理解其实现变得更加困难。子类不能重写父类的非虚函数,当然也就不能修改其实现。基类也可能定义一些数据成员,还要区分基类的物理轮廓(physical layout)。

结论:

所有继承必须是public的,如果想私有继承的话,应该采取包含基类实例作为成员的方式作为替代。

不要过多实现继承,组合通常更合适一些。努力做到只在“是一个”的情况下使用继承;如果Bar的确是一种Foo,才令Bar是Foo的子类。

必要的话,析构函数必须是virtual,必要是指,如果该类具有虚函数,其析构函数应该为虚函数。

至于子类没有额外数据成员,父类也没有任何数据成员的特殊情况下,析构函数的调用是否必要是语义争论,从编程设计规范的角度看,在含有虚函数的父类中,定义虚函数绝对必要。

限定仅在子类访问的成员函数为protected,需要注意数据成员应该始终为私有。

当重定义派生的虚函数时,在派生类中明确声明其为virtual。根本原因:如果遗漏virtual,阅读者需要检索其所有祖先以确定该函数是否为虚函数。

7.多重继承

真正需要多重实现继承的时候非常少,只有当最多一个基类中含有实现,其他基类都是以Interface为后缀的纯接口类时才会使用多继承。

定义:多重继承允许子类拥有多个基类,要将作为纯接口的基类和具有实现的基类区别开来。

优点:相比于单继承,多重实现继承可令你重用更多代码

缺点:真正需要用到多重实现继承的时候非常少,多重实现继承看上去是不错的解决方案,通常可以找到更加明确丶清晰丶不同的解决方案

结论:

只有当所有超类除第一个外都是纯接口时才能使用多重继承。为确保它们是纯接口,这些类必须以Interface为后缀。

8.接口

接口是指满足指定特定条件的类,这些类以interface为后缀(非必须)。

当一个类满足以下条件时,称为纯接口:

  1. 只有纯虚函数(“=0”)和静态函数(下文提到的析构函数除外)。
  2. 没有非静态数据成员。
  3. 没有定义任何构造函数。如果有也不含参数,并且为protected。
  4. 如果是子类,也只能继承满足以上条件并以interface为后缀的类。

接口类不能被直接实例化,因为它声明了纯虚函数。为确保接口类的所有实现可被正确销毁,必须为之声明虚析构函数(作为第一条的例外,析构函数不可能为纯虚函数)。

优点:以interface为后缀可令他人知道不能为该接口类增加实现函数或非静态数据成员,这一点对多重继承尤其重要

缺点:interface后缀增加了类名长度,为阅读和理解带来不变,接口特性作为实现细节不应暴露给客户。

结论:

只有在满足上述需要时,类才以interface结尾,但反过来满足上述需要的类未必一定以interface结尾。

9.操作符重载

除少数特定环境外,不要重载操作符。

定义:一个类可以定义诸如 + 丶/等操作符,使其可以像内建类型一样直接使用。

优点:是代码看上去更加直观,就像内建类型(如int)那样,重载操作符使那些equal丶add等暗淡无关的函数名好玩多了。为了使一些模板函数正确工作,可能需要定义操作符。

缺点:虽然操作符重载令代码更加直观,但也有一些不足。

  1. 混淆直觉,让你误以为一些耗时操作像内建操作那样轻巧
  2. 查找重载操作符的调用处更加困难,查找equal显然比同等调用==容易的多。
  3. 有的操作符可以对指针进行操作,容易导致bugs丶Foo+4 做的是一件事,而&Foo+4 可能做的是完全不同的另一件事,对于二者,编译器都不会报错,使其很难调试。
  4. 重载还有令你吃惊的副作用,比如重载操作符**&**的类不能被前置声明。

结论:

一般不要重载操作符,尤其是赋值操作(operator==)比较阴险,应避免重载,需要的话可以定义类似equal丶copyFrom等函数。

然而,极少数情况下需要重载操作符以便与模板或“标准”C++类衔接(operator<<(ostream&,const T&)),如果被证明是正当的尚可接受,但你要尽可能避免这样做尤其是不要仅仅为了在STL容器中作为key使用就重载operator==或operator<,取而代之,你应该在声明容器的时候,创建相等判断和大小比较的仿函数类型。

有些STL算法确实需要重载operator==时可以这么做,不要忘了提供文档说明原因。

参考拷贝构造函数和函数重载。

10.存取控制

将数据成员私有化,并提供相关存取函数,如定义变量foo_及取值函数foo()丶赋值函数set_foo().

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

参考继承和函数命名。

11.声明次序

在类中使用特定的声明次序:public:在private:之前,成员函数在数据成员(变量)前。

定义次序如下:public:丶protected:丶private:.

每一块中,声明次序一般如下:

  1. typedefs和enums;
  2. 常量
  3. 构造函数
  4. 析构函数
  5. 成员函数,含静态函数
  6. 数据成员,含静态数据成员

宏DISALLOW_COPY_AND_ASSIGN置于private:块之后,作为类的最后部分。参考拷贝构造函数。

.cc文件中函数的定义应尽可能和声明次序一致。

不要将大型函数内联到类的定义中,只有那些没有特定意义的或者性能要求高的,并且是比较短小的函数才能被定义为内联函数。

关于类的结论

  • 不在构造函数内做太多关于逻辑的初始化
  • 编译器提供的默认构造函数不会对变量进行初始化,如果定义了其他构造函数,编译器不再提供,需要编码者自行提供默认构造函数
  • 为避免隐式转换,须将单参数构造函数声明为explicit
  • 为避免拷贝构造函数丶赋值操作的滥用和编译器自动生成,可目前声明其为private且无需实现
  • 仅在作为数据集合时使用struct
  • 组合 > 实现继承 > 接口继承 >私有继承,子类重载的虚函数也要声明virtual关键字,虽然编译器允许不这样做
  • 避免使用多重继承,使用时,除一个基类含有实现外,其他基类均为纯接口
  • 接口类类名以interface为后缀,除提供带实现的虚析构函数丶静态成员函数外,其他均为纯虚函数,不定义非静态数据成员,不提供构造函数,提供的话,声明为protected
  • 为降低复杂性,尽量不重载操作符,模板丶标准类中使用时提供文档说明
  • 存取函数一般内联在头文件中
  • 声明次序:public->protected->private
  • 函数体尽量短小丶紧凑,功能单一