Google 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(constTypeName&);              \
    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.析构函数

析构函数(destructor) 与构造函数作用相反,当对象结束其生命周期时(例如对象所在的函数已调用完毕),程序自动执行析构函数,释放对象占用的内存资源。

使用析构函数需要注意以下几点: (1)如果基类还有虚函数,那么析构函数要申明为virtual。 这么做的原因是析构类对象的时候能够动态调用析构函数,防止内存泄露。

(2)一般情况下,应该避免在构造函数和析构函数中调用虚函数,如果一定要这样做,程序猿必须清楚,这是对虚函数的调用其实是实调用。可参考博客:C++不要在构造函数和析构函数中调用虚函数

(3)析构函数中是可以抛出异常的,但尽量不要这要做,因为很危险。 析构函数中万不得以抛出异常时尽量不要让异常逃离函数。其原因主要有一下两点:

(a)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。

(b)通常异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding),因发生异常而逐步退出复合语句和函数定义的过程,被称为栈展开。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。

6. 结构体和类(Structs vs Classes)

仅当只有数据时使用struct,其它一概使用class。

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

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

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

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

注意:类和结构体的成员变量使用不同的命名规则。

7. 继承(Inheritance)

使用组合(composition,译者注,这一点也是GoF在《Design Patterns》里反复强调的)通常比使用继承更适宜,如果使用继承的话,只使用公共继承。

定义:当子类继承基类时,子类包含了父基类所有数据及操作的定义。C++实践中,继承主要用于两种场合:实现继承(implementation inheritance),子类继承父类的实现代码;接口继承(interface inheritance),子类仅继承父类的方法名称。

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

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

使用继承的相关规范: (1)所有继承尽量使用public的,如果想私有继承的话,应该采取包含基类实例作为成员的方式作为替代。

(2)不要过多使用实现继承,组合通常更合适一些。努力做到只在“是一个”(”is-a”,译者注,其他”has-a”情况下请使用组合)的情况下使用继承:如果Bar的确“是一种”Foo,才令Bar是Foo的子类。

(3)基类如果有虚函数函数,那么令析构函数为virtual。原因是保证通过基类指针能够动态的调用子类析构函数,避免内存泄露。

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

(5)当重定义派生的虚函数时,在派生类中明确声明其为virtual。这样做的原因属于代码性注释,直观明了的指明该函数是虚函数。

8. 多重继承(Multiple Inheritance)

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

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

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

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

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

9. 接口(Interface)

接口是指满足特定条件的类,这些类以Interface为后缀(非必需),C++中的接口就是指纯抽象类。

定义:当一个类满足以下要求时,称之为纯接口: 1) 只有纯虚函数(”=0”)和静态函数(下文提到的析构函数除外);

2) 没有非静态数据成员;

3) 没有定义任何构造函数。如果有,也不含参数,并且为protected;

4) 如果是子类,也只能继承满足上述条件并以Interface为后缀的类。

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

优点:以Interface为后缀可令他人知道不能为该接口类增加实现函数或非静态数据成员,这一点对于多重继承尤其重要。另外,对于Java程序员来说,接口的概念已经深入人心。

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

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

10. 操作符重载(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==时可以这么做,不要忘了提供文档说明原因。

11. 存取控制(Access Control)

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

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

12. 声明次序(Declaration Order)

在类中使用特定的声明次序,定义次序如下:public:、protected:、private:,如果那一块没有,直接忽略即可。成员函数在数据成员(变量)前,也就是“以行为为中心”进行类设计。

主张以“行为为中心”的人将关注的重点放在了类的服务和接口上,习惯将public类型的函数写在前面,而将private类型的数据写在后面[4]^{[4]},所以很多大公司如Google推荐的类成员声明次序如下: 1) typedefs和enums; 2) 常量; 3) 构造函数; 4) 析构函数; 5) 成员函数,含静态成员函数; 6) 数据成员,含静态数据成员。

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

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

不要将大型函数内联到类的定义中,通常,只有那些没有特别意义的或者性能要求高的,并且是比较短小的函数才被定义为内联函数。更多细节参考译文第一篇的内联函数。

13. 编写短小函数(Write Short Functions)

倾向于选择短小、凝练的函数。

长函数有时是恰当的,因此对于函数长度并没有严格限制。如果函数超过40行,可以考虑在不影响程序结构的情况下将其分割一下。

即使一个长函数现在工作的非常好,一旦有人对其修改,有可能出现新的问题,甚至导致难以发现的bugs。使函数尽量短小、简单,便于他人阅读和修改代码。

在处理代码时,你可能会发现复杂的长函数,不要害怕修改现有代码:如果证实这些代码使用、调试困难,或者你需要使用其中的一小块,考虑将其分割为更加短小、易于管理的若干函数。

14.以指针代替嵌入对象或引用[4]^{[4]}

设计类遇到自定义类型的数据成员时,可以有三种方式: a.嵌入对象(组合) b.使用对象引用 c.使用对象指针 这三种方式孰优孰劣?举例,每辆汽车(CCar)都会有一个引擎(CEngine)。

class CEngine{...};

//嵌入对象
class CCar
{
public:
    void Start();
    void Move();
    void Stop();
private:
    CEngine m_engine;
};

如果使用嵌入对象,我们必须通过CEngine构造函数创建m_engine。它的生存周期受到了CCar对象的影响,m_engine将在CCar对象的生存周期中一直存在。如果CEngine发生了变化,那么CCar也必须重新编译。如果用户创建了一个CCar对象,但是不使用m_engine,那么创建m_engine就成了无用功。

//对象引用
class CCar
{
public:
    ...
private:
    CEngine& m_engine;
};

在这种方式中,使用的是CEngine的引用。相较于上面的“嵌入对象”方式,优点是类CCar不再依赖于CEngine的大小。同时,即使CEngine的实现发生了变化,CCar也不需要重新编译。但是由于m_engine必须绑定到一个现存的CEngine对象上,也就是说,在实际应用中,构造CCar之前,我们必须保证合法的CEngine对象的存在,不管后来是否用到它。

//对象指针
class CCar
{
public:
    ...
private:
    CEngine* m_pEngine;
};

使用对象指针,上面的烦恼就都没了。首先,在构造时,可以将m_pEngine置为NULL。在需要使用m_pEngine的时候,判断其是否为NULL。如果是,则创建一个新的CEngine对象。这就满足了我们按需创建的要求(惰性原则)。其次,在CCar对象的生命周期内,同一个CCar对象可以使用不同的CEngine对象,灵活性更强。最后,使用指针还有一大好处,那就是可以支持数据成员的多态行为。比如有三种汽车引擎:

class CEngine{...};
class CFerrariEngine:public CEngine{...};
class CAgrimotorEngine:public CEngine{...};

如果我们创建的CCar是一辆法拉利跑车,那么将m_pEngine指向CFerrariEngine对象即可;如果创建的是农用拖拉机,那么将m_pEngine指向CAgrimotorEngine对象即可,而对于类CCar的设计,我们无需做任何改变。当然,这一优点引用也同样拥有。

综上所述,在类数据成员中使用到自定义类型时,使用指针是一个较为明智地选择,有如下几个优点: (1)成员对象类的变化不会引起包含类的重编译; (2)支持惰性计算,不创建不使用的对象,效率更高; (3)支持数据成员的多态行为。

15.小结

关于类的注意事项和使用规范,总结一下:

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

参考文献

[1] C++构造函数和析构函数中抛出异常的注意事项 [2]C++不要在构造函数和析构函数中调用虚函数 [3]百度文库.Google C++编码规范中文版 [4]李健.编写高质量代码:改善C++程序的150个建议.第一版.北京:机械工业出版社,2012.1:287

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏编程心路

你不知道的 equals 和 ==

i1 == i2 和 i1.equals(i2) 这两个都是 true,大多数人应该可以答对。后面的 i3 == i4 和 i3.equals(i4) 估计就...

9320
来自专栏流媒体

STL(二)map/multimapmapmultimap

Map是STL的一个关联容器,它提供一对一(其中第一个可以称为关键字,每个关键字只能在map中出现一次,第二个可能称为该关键字的值)的数据 处理能力。由于这个特...

11930
来自专栏编程

机器学习之Python基础(二)

标题 类 面向对象 装饰器 1 类 首先举一个创建类的例子 class是声明类的关键字,human是类名,括号里的object是继承的父类(在Python2中如...

203100
来自专栏ImportSource

厕读:每日一题,面试无忧

4. 下列说法正确的有() A. class中的constructor不可省略 B. constructor必须与class同名,但方法不能与class同名 C...

29360
来自专栏企鹅号快讯

Python类与面向对象

面向对象程序 程序 = 指令 + 数据 代码可以选择以指令为核心或以数据为核心进行编程 两种范例 1.以指令为核心:围绕"正在发生什么"编写 面向过程编程:程序...

27380
来自专栏Java帮帮-微信公众号-技术文章全总结

Java基础-day10-基础题-继承;抽象类

Java基础-day10-基础题-继承&抽象类 什么是继承?继承有什么好处? 继承是面向对象最显著的一个特性。继承是从已有的类中派生出新的类,新的类能吸收已有类...

38160
来自专栏C/C++基础

C++ explicit禁止单参数构造函数隐式调用

C++中单参数构造函数是可以被隐式调用的,主要有两种情形会隐式调用单参数构造函数: (1)同类型对象的拷贝构造;即用相同类型的其它对象来初始化当前对象。 (...

23250
来自专栏walterlv - 吕毅的博客

Roslyn 语法树中的各种语法节点及每个节点的含义

2018-07-18 12:24

25810
来自专栏个人随笔

房上的猫:楼主错题:解析

本题考查的是对java中数组的相关知识, 数组一旦定义就不能改变大小了;数组中存放的都是同一类型的数据;数组的下标是从0开始的,也就是说下标为0的位置存放的是第...

411110
来自专栏工科狗和生物喵

【计算机本科补全计划】Java学习笔记(四) 修饰符

正文之前 今天总算是把那个党员谈话给弄完了,三个学弟轮番跟我来聊天,讲自己的入党动机啥的,看到他们就仿佛看到了大一的自己,原来当时面对学长,面对这类事情,会紧张...

34390

扫码关注云+社区

领取腾讯云代金券