4 类

类是 C++中代码的基本单元,本章列举了在写一个类时的主要注意事项。

4.1 构造函数的职责

规则:不要在构造函数中调用虚函数,也不要在无法报出错误时进行可能失败的初始化。

定义:在构造函数中可以进行各种初始化操作。

优点:

  • 无需考虑类是否被初始化。
  • 经过构造函数完全初始化后的对象可以为 const 类型,也能更方便地被标准容器或算法使用。

缺点:

  • 如果在构造函数内调用了自身的虚函数,这种调用不会被重定向到子类的虚函数实现。即使当前没有子类化实现,将来仍是隐患。
  • 除了程序崩溃(因为并不是一个始终合适的方法)或者使用异常(因为已经被 禁用了),构造函数很难上报错误。
  • 如果执行失败,会得到一个初始化失败的对象,这个对象有可能进入不正常的状态,必须使用 bool IsValid() 或类似这样的机制才能检查出来,然而这是一个十分容易被疏忽的方法。
  • 构造函数的地址无法被获取,因此,由构造函数完成的工作无法以简单的方式交给其它线程。

结论

不允许在构造函数中调用虚函数。如果代码允许,直接终止程序是一个合适的处理错误的方式。否则,可使用 Init()方法或工厂函数。如果一个对象没有其他的能影响调用公共方法的状态,则应避免对其使用 Init()方法(此类形式的半构造对象有时无法正确工作)。

4.2 隐式类型转换

规则:不要定义隐式类型转换。对于转换运算符和单参数构造函数,请使用 explicit关键字修饰。

定义:隐式类型转换允许一个某种类型(源类型)的对象被用于需要另一种类型(目的类型)的位置,例如,将一个 int 类型参数传递给需要 double 类型参数的函数。

除了语言所定义的隐式类型转换,还可通过在类定义中添加合适的成员来定义所需的类型转换。

explicit 关键字可以用于构造函数或(在 C++11 引入)类型转换运算符,以保证只有当目的类型在调用点被显式给出时才会进行类型转换,例如使用 cast。这不仅作用于隐式类型转换,还能作用于 C++11 的列表初始化语法:

class Foo {
    explicit Foo(int x, double y);
    ...
};
void Func(Foo f);

此时下面的代码是不允许的:

Func({42, 3.14}); // Error

这一代码从技术上说并非隐式类型转换,但是语言标准认为这是 explicit 应当限制的行为。

优点:

  • 当目的类型非常明确时,可避免显式地写出类型名,隐式类型转换可以让一个类型的可用性和表达性更强。
  • 隐式类型转换可以简单地取代函数重载。
  • 在初始化对象时,列表初始化语法是一种简洁明了的写法。

缺点:

  • 隐式类型转换会隐藏类型不匹配的错误。有时,目的类型并不符合期望,甚至根本没有意识到发生了类型转换。
  • 隐式类型转换会让代码难以阅读,尤其是在有函数重载的时候,因为这时很难判断到底是哪个函数被调用。
  • 单参数构造函数有可能会被无意地用作隐式类型转换。
  • 如果单参数构造函数没有加上 explicit 关键字,使用时无法判断这一函数究竟是要作为隐式类型转换,还是当初忘了加上 explicit 标记。
  • 没有明确的方法可用来判断哪个类应该提供类型转换,这会使得代码变得含糊不清。
  • 如果目的类型是隐式指定的,那么列表初始化会出现和隐式类型转换一样的问题,尤其是在列表中只有一个元素的时候。

结论:

在类型定义中,类型转换运算符和单参数构造函数都应当用 explicit 进行标记。例外情况是,拷贝和移动构造函数不应当被标记为 explicit,因为它们并不执行类型转换。对于设计目的就是用于对其他类型进行透明包装的类来说,隐式类型转换有时是必要且合适的。

不能以一个参数进行调用的构造函数不应当加上 explicit。含有std::initializer_list 作为参数的构造函数也应当省略 explicit,以便支持拷贝初始化 (例如 MyType m = {1,2};)。

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

规则:仅当明确需要时,才使用拷贝/移动。否则,隐式产生的拷贝和移动函数应禁用。

定义:

可拷贝类型允许对象在初始化时得到来自相同类型的另一对象的值,或在赋值时被赋予相同类型的另一对象,同时不改变源对象的值。对于用户定义的类型,拷贝操作一般通过拷贝构造函数与拷贝赋值操作符定义。string 类型就是一个可拷贝类型的例子。

可移动类型允许对象在初始化时得到来自相同类型的临时对象的值,或在赋值时被赋予相同类型的临时对象的值(因此所有可拷贝对象也是可移动的)。std::unique_ptr<int>就是一个可移动但不可复制的对象的例子。对于用户定义的类型,移动操作一般是通过移动构造函数和移动赋值操作符实现的。

拷贝/移动构造函数在某些情况下会被编译器隐式调用。例如,通过传值的方式传递对象。

优点:

可移动及可拷贝类型的对象可以通过传值的方式进行传递或者返回,这使得 API 更简单,更安全也更通用。与传指针和引用不同,这样的传递不会造成所有权、生命周期、可变性等方面的混乱,也就没必要在协议中予以明确。同时也可防止客户端与实现在非局部交互,使得它们更容易被理解,维护以及被编译器优化。这样的对象可以和需要传值操作的通用 API 一起使用,例如大多数容器。并且它们允许在类型组合中具有更多的灵活性。

拷贝/移动构造函数与赋值操作一般来说要比它们的各种替代方案,比如 Clone(),CopyFrom()或者 Swap(),更容易定义,因为它们能通过编译器产生,无论是隐式的还是通过 = default。这种方式很简洁,也保证所有数据成员都会被复制。拷贝与移动构造函数一般也更高效,因为它们不需要堆的分配或者是单独的初始化和赋值步骤,同时,对于类似省略不必要的拷贝这样的优化它们也更加合适。

移动操作允许隐式、高效地将源数据转移出右值对象。这能让代码风格更加清晰。

缺点:

许多类型都不需要拷贝,为它们提供拷贝操作会让人迷惑,也显得荒谬而不合理。singleton 类型 (Registerer)、与特定的作用域相关的类型 (Cleanup)、或与其它对象实体紧耦合的类型 (Mutex) 从逻辑上来说都不应该提供拷贝操作。为支持多态的基类提供拷贝/赋值操作是有害的,因为在使用它们时会造成对象切割。默认的或者随意的拷贝操作实现可能是不正确的,这往往导致令人困惑并且难以诊断出的错误。

拷贝构造函数是隐式调用的,也就是说,这些调用很容易被忽略。这会让人迷惑,尤其是对那些所用语言约定或强制要求传递引用的程序员来说更是如此。同时,这从一定程度上会鼓励过度拷贝,从而导致性能上的问题。

结论:

如果对于用户来说这个拷贝移动操作不是一眼就能看出来的,并且也没有不好的影响那就可以提供。如果定义了拷贝或者移动构造函数,那就要定义相应的赋值操作,反之亦然。如果类型可拷贝,那么就不要定义移动操作除非它们的效率远高于拷贝操作。如果类型不可拷贝,但是移动操作的正确性对用户显然可见,那么把这个类型设置为只可移动并定义移动的两个操作。

如果定义了拷贝/移动操作,则要保证这些操作的默认实现是正确的。记得时刻检查默认操作的正确性,并且在文档中说明类是可拷贝的且/或可移动的。

class Foo {
public:
    Foo(Foo&& other): field_(other.field) {}
    // 差, 只定义了移动构造函数, 而没有定义对应的赋值运算符.
    private:
    Field field_;
};

由于存在对象切割的风险,不要为任何有可能有派生类的类提供赋值操作或者拷贝/移动构造函数 (当然也不要继承有这样的成员函数的类)。如果你的基类需要可复制属性, 请提供一个 public virtual Clone() 方法 和一个 protected 的拷贝构造函数以供派生类实现。

如果你的类不需要拷贝 / 移动操作,请显式地通过在 public:域中使用=delete 或其他手段禁用之。

// MyClass is neither copyable nor movable.
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;

4.4 结构体 VS 类

规则:仅当只有数据成员时使用 struct,其它一概使用 class。

在 C++ 中 struct 和 class 关键字几乎含义一样。

规则:struct 用来定义包含数据的被动式对象,也可以包含相关的常量,但除了存取数据成员之外,没有别的函数功能。并且存取功能是通过直接访问位域,而非函数调用。除了构造函数、析构函数、Initialize() 、 Reset() 、 Validate()等类似的用于设定数据成员的函数外,不能提供其它功能的函数。

规则:如果需要更多的函数功能,class 更适合。如果拿不准,就用 class。为了和STL 保持一致,对于仿函数等特性可以不用 class 而是使用 struct。

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

4.5 继承

规则:使用 Composition 常常比用继承更合理。如果使用继承的话,定义为 public 继承。

定义:

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

优点:

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

缺点:

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

结论:

所有继承必须是 public 的。如果你想使用私有继承,应该把基类的实例作为成员对象。不要过度使用继承。omposition 常常更合适一些。尽量做到只在"is-a"的情况下使用继承: 如果 Bar 的确 “is-a”Foo,Bar 才能继承 Foo。

必要的话,析构函数应声明为 virtual。如果你的类有虚函数,则析构函数也应该为虚函数。对于可能被子类访问的成员函数,不要过度使用 protected 关键字。注意,数据成员都必须是私有的。

对于重载的虚函数或虚析构函数,使用 override,或 (较不常用的)final 关键字显式地进行标记。较早 (早于 C++11) 的代码可能会使用 virtual 关键字作为不得已的选项。因此,在声明重载时,请使用 override,final 或 virtual 的其中之一进行标记。标记为 override或 final 的析构函数如果不是对基类虚函数的重载的话,编译会报错,这有助于捕获常见的错误。这些标记起到了文档的作用,因为如果省略这些关键字,代码阅读者不得不检查所有父类,以判断该函数是否是虚函数。

4.6 多重继承

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

定义:

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

优点:

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

缺点:

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

结论:

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

4.7 接口

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

定义:

当一个类满足以下要求时,称之为纯接口:

  • 只包含纯虚函数和静态函数。
  • 没有非静态数据成员。
  • 没有定义任何构造函数。如果有,也不能带有参数,并且必须为 protected。
  • 如果它是一个子类,也只能从满足上述条件并以 Interface 为后缀的类继承。

接口类不能被直接实例化,因为它声明了纯虚函数。为确保接口类的所有实现可被正确销毁,必须为其声明虚析构函数 (作为上述第 1 条规则的特例,析构函数不能是纯虚函数)。具体细节可参考 Stroustrup 的 The C++ Programming Language,3rd edition 第 12.4节。

优点:

以 Interface 为后缀可以提醒其他人不要为该接口类增加函数实现或非静态数据成员。这一点对于多重继承尤其重要。

缺点:

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

结论:

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

4.8 运算符重载

规则:除少数特定环境外,不要重载运算符。也不要创建用户定义字面量。

定义:

C++允许使用 operator 关键字对内建运算符进行重载定义,只要其中一个参数是自定义的类型。operator 关键字还允许使用 operator""定义新的字面运算符,并且定义类型转换函数,例如 operator bool()。

优点:

重载运算符可以让代码更简洁易懂,也使得自定义类型和内建类型拥有相似的行为。重载运算符对于某些运算来说是符合习惯的(例如 ==,<,=,<=),遵循这些约定可以让自定义类型更易读,也能更好地和需要这些重载运算符的函数库进行交互操作。

对于创建自定义类型的对象来说,自定义字面量是一种非常简洁的标记。

缺点:

  • 要提供正确,一致,不出现异常行为的运算符重载需要花费不少精力,一旦达不到这些要求的话,会导致令人迷惑的 Bug。
  • 过度使用运算符重载可能会使代码难以理解,尤其是在重载的运算符的语义与通常的约定不符合时。
  • 函数重载有多少弊端,运算符重载就至少有多少。
  • 运算符重载会混淆视听,让你误以为一些耗时的操作和操作内建类型一样轻巧。
  • 查找重载运算符的调用点需要的可不仅仅是像 grep 那样的程序,这需要能够理解 C++语法的搜索工具。
  • 如果重载运算符的参数写错,得到的可能是一个完全不同的重载而非编译错误。例如:foo < bar 执行的是一个行为,而 &foo < &bar 执行的就是完全不同的另一个行为了。
  • 重载某些运算符本身就是有害的。例如,重载一元运算符&会导致同样的代码有完全不同的含义,这取决于重载的声明对某段代码而言是否可见。重载诸如&&,||和逗号运算符会导致运算顺序和内建运算的顺序不一致。
  • 运算符通常定义在类的外部,所以对于同一运算,可能出现不同的文件引入了不同的定义的风险。如果两种定义都链接到同一二进制文件,就会导致未定义的行为,有可能发生难以发现的运行时错误。
  • 用户定义字面量所创建的语义形式对于某些有经验的 C++程序员来说都是很陌生的。

结论:

只有在意义明显,不会出现奇怪的行为并且与对应的内建运算符的行为一致时才定义重载运算符。

只对自定义类型重载运算符。更准确地说,将它们和它们所操作的类型定义在同一个头文件中,.cc 中和命名空间中。这样做无论类型在哪里都能够使用定义的运算符,并且最大程度上避免了多重定义的风险。如果可能的话,避免将运算符定义为模板,因为此时它们必须对任何模板参数都能够作用。如果定义了一个运算符,请将其相关且有意义的运算符都进行定义,并且保证这些定义的语义是一致的。例如,如果重载了<,那么请将所有的比较运算符都进行重载,并且保证对于同一组参数,<和>不会同时返回 true。

建议将不修改内容的二元运算符定义为非成员函数。如果一个二元运算符被定义为类成员,这时隐式转换会作用于右侧的参数而不会作用于左侧。这时会出现 a < b 能够通过编译而 b < a 却不能的情况,这是很让人迷惑的。

不要为了避免重载操作符而走极端。比如说,应当定义==、=、和<<而不是Equals(),CopyFrom()和 PrintTo()。反过来,不要只是为了满足函数库需要而去定义运算符重载。比如说,如果类型没有自然顺序,而需要将它们存入 std::set 中,最好还是定义一个自定义的比较运算符而不是重载 <。

不要重载&&,||,逗号运算符或一元运算符&。不要重载 operator"",也就是说,不要引入用户定义字面量。

4.9 存取控制

规则:所有数据成员应声明为 private,除非是 static const 类型成员。

4.10 声明顺序

规则:相似的声明须放在一起,public 部分放在最前面。

说明:

类定义一般应以 public:开始,接下来是 protected:,最后是 private:。

在各个部分中,建议将类似的声明放在一起,并且建议采用如下顺序: 类型 (包括typedef,using 和嵌套的结构体与类),常量,工厂函数,构造函数,赋值运算符,析构函数,其它函数,数据成员。

不要将大段的函数定义内联在类定义中。只有那些普通的,或非性能关键且短小的函数可以内联在函数定义中。

results matching ""

    No results matching ""