[C++] 编程实践之1: Google的C++代码风格3:类

##
  类是C++中代码的基本单元。显然它们被广泛使用。本节列举了在写一个类时的主要注意事项。
###构造函数的职责

不要在构造函数中进行复杂的初始化(尤其是那些有可能失败或者需要调用虚函数的初始化)。

定义:

  • 在构造函数体中进行初始化操作。

优点:

  • 排版方便,无需担心类是否已经初始化。

缺点:

  • 构造函数中很难上报错误,不能使用异常。
  • 操作失败会造成对象初始化失败,进入不确定状态。
  • 如果在构造函数内调用了自身的虚函数,这类调用是不会重新定向到子类的虚函数实现,即使当前没哟子类化实现,将来仍然是隐患。
  • 如果有人创建该类的全局变量(虽然违背了上节提到的规则),构造函数将先main()一步被调用,有可能破坏构造函数中暗含的假设条件。例如gflags尚未初始化。

结论:

  • 构造函数不得调用虚函数,或尝试报告一个非致命错误。如果对象需要进行有意义的(non-trivial)初始化,考虑使用明确的Init()方法或者使用工厂模式。

###初始化

如果类中定义了成员变量,则必须在类中为每个成员变量提供初始化函数或定义一个构造函数。若未声明构造函数,则编译器会生成一个默认的构造函数,这有可能导致某些成员变量未被初始化或被初始化为不恰当的值。

定义:

  • new一个不带参数的类对象时,会调用这个类的默认构造函数。用new[]创建数组时,默认构造函数则总是被调用。在类成员里面进行初始化是指声明一个成员变量的时候使用一个结构例如int _count = 17或者string _name{“abc”}来替代int _count或者string _name这样的形式。

优点:

  • 用户定义的默认构造函数将在没有提供初始化操作时将对象初始化。这样就保证了对象在被构造之时就处于一个有效且可用的状态,同时保证了对象在被创建时就处于一个显然“不可能”的状态,以此帮助调试。

缺点:

  • 对代码编写者来说,这是多余的工作。如果一个成员变量在声明时初始化又在构造函数里用相同的方式初始化,有可能造成混乱,因为构造函数中的值会覆盖掉声明中的值。

结论:

  • 简单的初始化用类成员初始化完成,尤其是当一个成员变量要在多个构造函数里用相同的方式初始化的时候。
  • 如果你的类中有成员变量没有在类里面进行初始化,而且没有提供其它构造函数,你必须定义一个(不带参数)默认构造函数,把对象的内部状态初始化成为一致/有效的值。这无疑是一个更合理的方式。这么做的原因是:
    • 如果你没有提供其它构造函数,又没有定义默认构造函数,编译器将为你自动生成一个,编译器生成的构造函数并不会对对象进行合理的初始化。
    • 如果你定义的类继承现有类,而你又没有增加新的成员变量,则不需要为新类定义默认构造函数。

###显式构造函数

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

定义:

  • 通常,如果构造函数只有一个参数,可看成是一种隐式转换。打个比方,如果你定义了Foo:Foo(string name),接着把一个字符串传给一个以Foo对象为参数的函数,构造函数Foo:Foo(string name)将被调用,并将该字符串转换为一个Foo的临时对象传给调用函数。看上去很方便,但如果你并不希望通过转换生成一个新对象的话,麻烦也随之而来。为避免构造函数被调用造成隐式转换,可以将其声明为explicit。
  • 除单参数构造函数之外,这一规则也适用于除第一个参数以外的其它参数都具有默认参数的构造函数,例如Foo:Foo(string name, int id = 42)。

优点:

  • 避免不合时宜的变换。

缺点:

  • 无。

结论:

  • 所有单参数的构造函数都必须是显式的,在类定义中,将关键字explicit加到单参数构造函数之前:explicit Foo(string name)。
  • 例外:在极少数情况下,拷贝构造函数可以不声明成explicit,作为其它类的透明包装的类也是特例之一,类似的例外情况应在注释中明确说明。
  • 最后,只有std::initializer_list的构造函数可以是非explicit,以允许你的类型可以使用列表初始化的方式进行赋值,例如:
MyType m = {1, 2};
MyType MakeMyType() { return {1, 2}; }
TakeMyType({1, 2});

###可拷贝类型和可移动类型

如果你的类型需要,就让它们支持拷贝/移动。否则,就把隐式产生的拷贝和移动禁用。

定义:

  • 可拷贝类型通常允许对象在初始化时得到来自相同类型的另一对象的值,或在赋值时被赋予相同类型的另一对象的值,同时不改变源对象的值。对于用户定义的类型,拷贝操作一般通过拷贝构造函数与拷贝赋值操作符(=)定义。string类型就是一个可拷贝类型的例子。
  • 可移动类型允许对象在初始化时得到来自相同类型的临时对象的值,或在赋值时被赋予相同类型的临时对象的值(因此所有可拷贝对象也是可移动的)。std:unique_ptr就是一个可移动但不可复制的对象的例子。对于用户定义的类型,移动操作一般是通过移动构造函数和移动赋值操作符实现的。
  • 拷贝/移动构造函数在某些情况下会被编译器隐式调用。例如通过传值的方式传递对象。

优点:

  • 可移动及可拷贝类型的对象可以通过传值的方式进行传递或者返回,这使得API更简单、更安全也更通用。与传指针和引用不同,这样的传递不会造成所有权、生命周期、可变性等方面的混乱,也就没必要再协议中予以明确。这同时也防止了客户端与实现在非作用域内的交互,使得它们更容易被理解与维护。这样的对象可以和需要传值操作的通用API一起使用,例如大多数容器。
  • 拷贝/移动构造函数与赋值操作一般来说要比它们的各种替代方案,比如Clone(), CopyFrom()或者Swap()更容易定义,因为它们能通过编译器产生,无论是隐式的还是通过“=”默认。这种方式很简洁,也保证所有数据成员都会被复制。拷贝与移动构造函数一般也更高效,因为它们不需要堆的分配或者是单独的初始化和赋值步骤。同时,对于省略不必要的拷贝这样的优化它们也更加合适。
  • 移动操作允许隐式且高效地将源数据转移出右值对象,这有时能让代码风格更加清晰。

缺点:

  • 许多类型都不需要拷贝,为它们提供拷贝操作会让人迷惑,也显得荒谬而不合理。为基类提供拷贝/赋值操作时有害的,因为在使用它们时会造成对象的切割。默认的或者随意的拷贝操作实现可能使不正确的,这往往导致令人困惑并且难以诊断的错误。
  • 拷贝构造函数是隐式调用的,也就是说,这些调用很容易被忽略。这回让人迷惑,尤其是对那些所用语言约定或强制要求传引用的程序员来说更是如此。同时,这从一定程度上说会鼓励过度拷贝,从而导致性能上的问题。

结论:

  • 如果需要,就让你的类型可拷贝/可移动。作为一个经验法则,如果对于你的用户来说这个拷贝操作不是一眼就能看出来的,那就不要把类型设置为可拷贝。如果让类型可拷贝,一定要同时给出拷贝构造函数和赋值操作的定义。如果让类型可拷贝,同时移动操作的效率高于拷贝操作,那么就把移动的两个操作(移动构造函数和赋值操作)也给出定义。如果类型不可拷贝,但是移动操作的正确性对用户显然可见,那么把这个类型设置为只可移动并定义移动的两个操作。
  • 建议通过=default定义拷贝和移动操作。定义非默认的移动操作目前需要异常。时刻记得检测默认操作的正确性。由于存在对象切割的风险,不要为任何有可能有派生类的对象提供赋值操作或者拷贝/移动构造函数(当然也不要继承有这样的成员函数的类)。如果你的基类需要可复制属性,请提供一个public virtual Clone()和一个protected的拷贝构造函数以供派生类实现。
  • 如果你的类不需要拷贝/移动操作,请显示地通过=delete或其它手段禁用之。

###委派和继承构造函数

在能够减少重复代码的情况下使用委派和继承构造函数。

定义:

  • 委派和继承构造函数是由C++11引进为了减少构造函数重复代码而开发的两种不同的特性。通过特殊的初始化列表语法,委派构造函数允许类的一个构造函数调用其它的构造函数,例如:
X::X(const string& name) : name_(name) {
	...
}

X:X():X("") {}
  • 继承构造函数允许派生类直接调用基类的构造函数,一如继承基类的其它成员函数,而无需重新声明当基类拥有多个构造函数时这一功能尤其有用。例如下面的代码片段。如果派生类的构造函数只是调用基类的构造函数而没有其它行为时,这一功能特别有用。
class Base {
public:
	Base();
	Base(int n);
	Base(const string& s);
	...
}

class Derived : public Base {
public:
	using Base::Base;  // Base's constructors are redeclared here
}

优点:

  • 委派和继承构造函数可以减少荣誉代码,提高可读性。委派构造函数对Java程序员来说并不陌生。

缺点:

  • 使用辅助函数可以预估出委派构造函数的行为。如果派生类和基类相比引入了新的成员变量,继承构造函数就会让人迷惑,因为基类并不知道这些新的成员变量的存在。

结论:

  • 只在能够减少冗余代码,提高可读性的前提下使用委派和继承构造函数。如果派生类有新的成员变量,那么使用继承构造函数时要小心。如果在派生类中对成员变量使用了类内部初始化(指在定义的时候就初始化)的话,继承构造函数还是适用的。

###结构体 vs. 类

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

说明:

  • 在C++中struct和类关键字几乎含义一样。我们为这两个关键字添加我们自己的语义理解,以便未定义的数据类型选择合适的关键字。
  • struct用来定义包含数据的被动式方式,也可以包含相关的常量,但除了存取数据成员之外,没有别的函数功能。并且存取功能是通过直接访问位域,而非函数的调用。除了构造函数,析构函数,Initialize(),Reset(),Validate()等类似的函数外,不能提供其它功能的函数。
  • 如果需要更多的函数功能,class更适合。如果拿不准,就用class。
  • 为了和STL保持一致,对于仿函数和trait特性可以不用class,而是使用struct。
  • 注意:类和结构体的成员变量使用不同的命名规则。

###继承

使用组合(composition)常常比使用继承更合理。如果使用继承的话,定义为public继承。

定义:

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

优点:

  • 实现继承通过原封不动的服用基类代码,减少了代码量。由于继承实在编译时声明,程序员和编译器都可以理解相应操作并发现错误。从编程角度而言,接口继承时用来强制类输出特定的API。在类没有实现API中某个必须的方法时,编译器同样会发现并报告错误。

缺点:

  • 对于实现继承,由于子类的实现代码散步在父类和子类之间,要理解其实现变得更加困难。子类不能重写父类的非虚函数,当然也就不能修改器实现。基类也可能定义了一些数据成员,还要区分基类的实际布局。

结论:

  • 所有继承必须是public的,如果你想使用私有继承,你应该替换成把基类的实例作为成员对象的方式。
  • 不要过度使用实现继承。组合常常更合适一些。尽量做到只在“is a”情况下使用继承:如果Bar的确“是一种”Foo,Bar才能继承Foo。
  • 必要的话,析构函数声明为virtual。如果你的类有虚函数,则析构函数也应该为虚函数。注意数据成员在任何情况下都必须是私有的。
  • 当重载一个虚函数,在衍生类中把它明确的声明为virtual。理论依据:如果省略virtual关键字,代码阅读者不得不检查所有父类,以判断该函数是否是虚函数。

###多重继承

真正需要用到多重实现继承的情况少之又少。只有在以下情况下我们才允许多重继承:最多只有一个基类是非抽象类;其它类都是以Interface为后缀的纯接口类。

定义:

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

优点:

  • 相比单继承,多重实现继承可以复用更多的代码。

缺点:

  • 真正需要用到多重实现继承的情况少之又少。多重实现继承看上去是不错的解决方案。但你通常也可以找到一个更明确,更清晰的不同解决方案。

结论:

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

关于该规则,Window下有个特例,请参见:Google的C++代码风格9:规则特例。

###接口

接口是指满足特定条件的类,这些类以Interface为后缀(不强制)。

定义:

  • 当一个类满足以下要求时,称之为纯接口:
    • 只有纯虚函数("=0")和静态函数(除了下文提到的析构函数)。
    • 没有非静态数据成员。
    • 没有定义任何构造函数。如果有,也不能带有参数,并且必须为protected。
    • 如果它是一个子类,也只能从满足上述条件兵役Interface为后缀的类继承。
  • 接口类不能为直接实例化,因为它声明了纯虚函数。为确保接口类的所有实现可悲正确销毁,必须为之声明虚析构函数(作为上述第一条规则的特例,析构函数不能是纯虚函数)。

优点:

  • 以Interface为后缀可以提醒其他人不要为该接口类增加函数实现或者非静态数据成员。这一点对于多重继承尤其重要。另外,对于Java程序员而言,接口的概念已经深入人心。

缺点:

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

结论:

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

###运算符重载

除少数特定环境外,不要重载运算符。

定义:

  • 一个类可以定义诸如+和/等运算符,使其可以像內建类型一样直接操作。

优点:

  • 使代码看上去更加直观,类表现的和內建类型(如int)行为一致。重载运算符使Equals(),Add()等函数名黯然失色。为了使一些模板函数正确工作,你可能必须定义操作符。

缺点:

  • 虽然操作符重载令代码更加直观,但也有一些不足之处:
    • 混淆视听,让你误以为一些耗时的操作和操作內建类型一样轻巧。
    • 更难定位重载运算符的调用点,查找Equals()显然比对应的==调用点要容易的多。
    • 有的运算符可以对指针进行操作,容易导致bug。Foo + 4做的是一件事情,而&Foo + 4可能做的是完全不同的另外一件事情。对于二者,编译器都不会报错,使其很难调试。
  • 重载还有令你吃惊的副作用。比如,重载了operator&的类不能被前置声明。

结论:

  • 一般不要重载运算符。尤其是赋值操作(operator=)比较诡异,应避免重载。如果需要的话,可以定义类似Equals(),CopyFrom()等函数。
  • 然而,极少数情况年可能需要重载运算符以便与模板或者“标准”C++类互操作(如operator<<(ostream&, const T&))。只有被证明是完全合理的才能重载,但你还是要尽可能避免这样做。尤其是不要仅仅为了在STL容器中用作键值就重载operator==或者operator<;相反,你应该在声明容器的时候,创建相等判断和大小比较的仿函数类型。
  • 有些STL算法确实需要重载operator==时,你可以这么做,记得别忘了在文档中说明原因。

###存取控制

将所有数据成员声明为private,并根据需要提供相应的存取函数。例如,某个名为foo_的变量,其取值函数时foo()。还可能需要一个赋值函数set_foo()。
特例是,静态常量数据成员(一般写作kFoo)不需要是私有成员。
一般在头文件中把存取函数定义成内联函数。
###声明顺序
在类中使用特定的声明顺序:public:在private:之前,成员函数在数据成员(变量)前。

类的访问控制区段的声明顺序依次为:pubic:,protected:,private:。如果某区段没有内容,可以不声明。

每个区段内的声明通常按以下顺序:

  • typedefs和枚举
  • 常量
  • 构造函数
  • 析构函数
  • 成员函数,含静态成员函数
  • 数据成员,含静态数据成员

友元声明应该放在private区段。如果用宏DISALLOW_COPY_AND_ASSIGN禁用拷贝和赋值,应当将其置于private区段的末尾,也即整个类声明的末尾。参见可拷贝类型和可移动类型。

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

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

###编写简短函数

倾向于编写简短、凝练的函数。

我们承认长函数有时是合理的,因此并不硬性限制函数的长度。如果函数超过40行,可以思索一下能不能在不影响程序结构的前提下对其进行分割。
  即使一个长函数现在工作非常好,一旦有人对其修改,有可能出现新的问题,甚至导致难以发现的bug。使函数尽量简短,便于他人阅读和修改代码。
  在处理代码时,你可能会发现复杂的长函数。不要害怕修改现有代码:如果证实这些代码使用/调试困难,或者你需要使用其中的一小段代码,考虑将其分割为更加简短并且易于管理的若干函数。

###总结

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值