3. 类

类是 C++ 中代码的基本单元. 显然, 它们被广泛使用. 本节列举了在写一个类时的主要注意事项.

3.1. 构造函数的职责

总述

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

定义

在构造函数中可以进行各种初始化操作.

优点

  • 无需考虑类是否被初始化.

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

缺点

  • 如果在构造函数内调用了自身的虚函数, 这类调用是不会重定向到子类的虚函数实现. 即使当前没有子类化实现, 将来仍是隐患.

  • 在没有使程序崩溃 (因为并不是一个始终合适的方法) 或者使用异常 (因为已经被 禁用 了) 等方法的条件下, 构造函数很难上报错误

  • 如果执行失败, 会得到一个初始化失败的对象, 这个对象有可能进入不正常的状态, 必须使用 bool IsValid() 或类似这样的机制才能检查出来, 然而这是一个十分容易被疏忽的方法.

  • 构造函数的地址是无法被取得的, 因此, 举例来说, 由构造函数完成的工作是无法以简单的方式交给其他线程的.

结论

构造函数不允许调用虚函数. 如果代码允许, 直接终止程序是一个合适的处理错误的方式. 否则, 考虑用 Init() 方法或工厂函数, 参考 TotW #42.

构造函数不得调用虚函数, 或尝试报告一个非致命错误. 如果对象需要进行有意义的 (non-trivial) 初始化, 考虑使用明确的 Init() 方法或使用工厂模式. 避免在没有其他状态或标志位表征某个公有的函数是否可以被调用的类中使用 ``Init()``方法(此类形式的半构造对象有时无法正确工作).

3.2. 隐式类型转换

总述

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

定义

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

除了语言所定义的隐式类型转换, 用户还可以通过在类定义中添加合适的成员定义自己需要的转换. 在源类型中定义隐式类型转换, 可以通过目的类型名的类型转换运算符实现 (例如 operator bool()). 在目的类型中定义隐式类型转换, 则通过以源类型作为其唯一参数 (或唯一无默认值的参数) 的构造函数实现.

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

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

void Func(Foo f);

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

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

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

优点

  • 有时目的类型名是一目了然的, 通过避免显式地写出类型名, 隐式类型转换可以让一个类型的可用性和表达性更强.

  • 隐式类型转换可以简单地取代函数重载, 例如使用带有一个 string_view 参数的函数来取代对 std::stringconst char* 的重载函数.

  • 在初始化对象时, 列表初始化语法是一种简洁明了的写法.

缺点

  • 隐式类型转换会隐藏类型不匹配的错误. 有时, 目的类型并不符合用户的期望, 甚至用户根本没有意识到发生了类型转换.

  • 隐式类型转换会让代码难以阅读, 尤其是在有函数重载的时候, 因为这时很难判断到底是哪个函数被调用.

  • 单参数构造函数有可能会被无意地用作隐式类型转换.

  • 如果单参数构造函数没有加上 explicit 关键字, 读者无法判断这一函数究竟是要作为隐式类型转换, 还是作者忘了加上 explicit 标记.

  • 隐式类型转换可能会导致调用点歧义, 尤其是存在双向隐式转换时. 这可能是因为两种类型都提供隐式转换, 或者一种类型同时存在隐式构造函数和隐式转换运算符.

  • 如果目的类型是隐式指定的, 那么列表初始化会出现和隐式类型转换一样的问题, 尤其是在列表中只有一个元素的时候.

结论

在类型定义中, 类型转换运算符和单参数构造函数都应当用 explicit 进行标记. 一个例外是, 拷贝和移动构造函数不应当被标记为 explicit, 因为它们并不执行类型转换.

对于设计目的就是需要互换的类型来说, 隐式类型转换有时是必要且合适的. 例如, 两种类型的对象只是对同一个值的不同表示. 在这种情况下, 请联系项目负责人说明特殊情况.

单参数构造函数不应当省略 explicit. 接受一个 std::initializer_list 作为参数的构造函数应当省略 explicit, 以便支持拷贝初始化 (例如 MyType m = {1, 2};).

3.3. 可拷贝类型和可移动类型

总述

一个类的公有接口必须明确这个类是可复制构造/可移动构造/不可复制和移动构造的. 若复制构造和移动构造对于某一类型是清晰且有意义的, 则支持这些操作.

定义

可移动类型允许对象从临时变量进行初始化.

可拷贝类型允许对象从另一个相同类型的对象进行初始化 (因此从定义上来说也是可移动的), 同时不改变源对象的值. std::unique_ptr<int> 就是一个可移动但不可复制的对象的例子 (因为智能指针对应的值必须在拥有所有权的对象中修改). intstring 为既可移动也可复制的例子 (对于 int 类型, 移动和复制操作时相同的, 对于 std::string 类型, 移动操作比复制操作开销更低).

对于用户自定义的类型, 复制行为由复制构造函数和复制构造赋值运算符来定义. 移动行为由移动构造函数和移动构造赋值运算符来定义, 否则由复制构造函数和复制构造赋值运算符来定义.

在某些情况下, 编译器可能会隐式地调用复制/移动构造函数. 例如, 通过传递值的方法传递对象时.

优点

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

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

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

缺点

某些类型并不需要拷贝, 为它们提供拷贝操作会让人迷惑, 也显得荒谬而不合理. 单例对象 (Registerer), 或与其他对象实体紧耦合的类型 (Mutex) 从逻辑上来说都不应该提供拷贝操作. 为基类提供拷贝 / 赋值操作是危险的, 因为在使用它们时会造成 对象切割 . 默认的或者随意的拷贝操作实现可能是不正确的, 这往往导致令人困惑并且难以诊断出的错误.

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

结论

每个类的公有接口都应该明确这个类是否支持复制构造/移动构造. 通常应在 public 字段中显示的声明/删除对应的函数或操作符.

通常来说, 一个可复制的类应该显示声明复制操作, 一个只能移动的类应显示声明移动操作, 一个既不能复制也不能移动的类应该显示删除复制操作. 可复制类也应声明移动操作以支持高效的移动. 显示的声明或删除所有4个复制/移动操作是允许的, 但不是必须的. 如果您提供复制或移动赋值运算符, 则必须提供对应的构造函数.

class Copyable {
 public:
  Copyable(const Copyable& other) = default;
  Copyable& operator=(const Copyable& other) = default;
  // 隐式移动操作会被上面的声明抑制.
  // 您可以声明移动操作来支持更高效的移动
};

class MoveOnly {
 public:
  MoveOnly(MoveOnly&& other) = default;
  MoveOnly& operator=(MoveOnly&& other) = default;

  // 复制操作被隐式地删除, 但您可以显式地声明:
  MoveOnly(const MoveOnly&) = delete;
  MoveOnly& operator=(const MoveOnly&) = delete;
};

class NotCopyableOrMovable {
 public:
  // 既不可复制也不可移动
  NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
  NotCopyableOrMovable& operator=(NotCopyableOrMovable&) = delete;

  // 移动操作被隐式地删除, 但您可以显式地声明:
  NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
  NotCopyableOrMovable& operator=(NotCopyableOrMovable&&) = delete;
};

当且仅当上述声明/删除为显而易见时, 您可以省略它们:

  • 如果一个类没有 private 部分, 例如结构体或仅包含接口的基类, 则可复制性/移动性可以由任意公有属性的数据成员的可复制性/可移动性来确定.

  • 如果一个基类是不可移动/复制的, 那么其派生类也不行. 一个隐式保留移动/复制操作的仅包含接口的基类无法说明具体子类的可复制性/可移动性.

  • 注意到如果您显式声明/删除复制构造函数或复制操作赋值运算符的其中一个, 另一个也必须显式地声明/删除. 移动操作亦是如此.

如果用户不清楚一个类的复制/移动的含义, 或者不清楚复制/移动操作会产生意料之外的开销, 那么这个类应该是不可复制/移动的. 可复制类型的移动操作严格来说只是一种性能优化, 并且是错误和复杂性的潜在来源, 因此请避免定义它们, 除非它们的实现比复制操作更高效. 如果您的类是可复制的, 建议您在设计中保证默认的构造和赋值运算符是正确的. 请记得像检查您自己的代码一样地检查默认操作的正确性.

为了消除对象切片的风险, 最好将基类抽象化, 通常可以将构造和析构函数声明成protected属性, 或者提供一个或多个纯虚的成员函数. 尽量避免继承一个具体的类.

3.4. 结构体 VS. 类

总述

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

说明

在 C++ 中 structclass 关键字的含义几乎是一样的. 我们为这两个关键字添加我们自己的语义理解, 以便为定义的数据类型选择合适的关键字.

struct 用来定义包含数据的被动式对象, 也可以包含相关的常量, 并且它们的属性必须是公共的. 由于用户能直接访问 struct 中的所有字段, 因此 struct 中不能包含任何表示不同字段关系的不变量. struct 中可能存在构造函数、析构函数和辅助的函数方法, 这些方法不能要求任何不变量的值.

如果需要更多的函数功能或不变量, 亦或者对象具有抽象性且可能会被继承, 那么 class 会更合适. 如果拿不准, 就用 class.

为了和 STL 保持一致, 对于traits技术、 模板元编程 、仿函数等特性可以不用 class 而是使用 struct.

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

3.5. 结构体 VS. pair 和 tuple

总述

当变量的命名有意义时, 倾向于使用 struct 而不是 pair 或 tuple.

说明

使用 pair 或 tuple 可以省略为变量命名, 潜在地减少了写代码的开销, 但一个清晰的变量命名会让你的代码更加清晰, 相比于 .first, .second, std::get<X> 来说. 虽然 C++14 通过引入 std::get<Type> 来获取 tuple 中的变量 (而不是下标索引) 会让代码更清晰, 但一个带名字的变量总是会比上述方法更加显而易见.

pair 或 tuple 可能适用于通用代码, 其中的变量没有特定含义. 为了与现有的代码和 API 互操作时, 可能也需要使用它们.

3.6. 继承

总述

使用组合 (YuleFox 注: 这一点也是 GoF 在 <<Design Patterns>> 里反复强调的) 常常比使用继承更合理. 如果使用继承的话, 定义为 public 继承.

定义

当子类继承基类时, 子类包含了父基类所有数据及操作的定义. “接口继承”指从纯抽象基类 (不包含状态或定义的方法) 继承; 所有其他继承都是“实现继承”.

优点

实现继承通过复用基类代码来减少代码大小. 由于继承是在编译时声明, 程序员和编译器都可以理解相应操作并发现错误. 接口继承是用来强制规定必须实现的 API. 在继承类中没有实现某个必须的 API 时, 编译器可以检测到错误.

缺点

对于实现继承, 由于子类的实现代码散布在父类和子类间之间, 要理解其实现变得更加困难. 子类不能重写父类的非虚函数, 因此无法修改其实现.

多重继承可能引入很多问题, 因为它通常会带来更高的性能开销 (事实上, 从单继承到多重继承导致的性能下降会高于从普通方法到虚方法的性能下降), 并且它有导致“菱形继承”的风险, 容易造成歧义、混乱和彻底的错误.

结论

所有继承必须是 public 的. 如果您想使用私有继承, 您应该把该对象的实例保存在成员变量中. 当你不希望你的类被继承时, 应该使用 final 关键字.

不要过度使用实现继承. 组合常常更合适一些. 尽量做到只在 “是一个” (“is-a”, YuleFox 注: 其他 “has-a” 情况下请使用组合) 的情况下使用继承: 如果 Bar 的确 “是一种” Foo, Bar 才能继承 Foo.

protected 的变量和函数限制为那些可能需要从子类访问的成员函数. 注意到 数据成员应该是私有的.

使用 overridefinal (较少使用) 关键字来标识重载的虚函数或者虚的析构函数. 重载时不要使用 virtual . 基本原理: 如果一个函数带有 overridefinal 关键字, 但却不是基类虚函数的重载, 会出现编译报错, 有助于定位问题. 这些声明符也是一种标识, 如果不存在这些说明符, 代码阅读者需要查看基类来确定这些函数或者析构函数是否为虚的.

多重继承是允许的, 但多重实现继承是强烈不推荐的.

3.7. 运算符重载

总述

明智地使用运算符重载. 不要使用用户自定义的字面值.

定义

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

优点

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

对于创建用户定义的类型的对象来说, 用户定义字面值是一种非常简洁的标记.

缺点

  • 要提供正确, 一致, 不出现异常行为的操作符运算需要花费不少精力, 而且如果达不到这些要求的话, 会导致令人迷惑的 Bug.

  • 过度使用运算符会带来难以理解的代码, 尤其是在重载的操作符的语义与通常的约定不符合时.

  • 函数重载有多少弊端, 运算符重载就至少有多少.

  • 运算符重载会混淆视听, 让你误以为一些耗时的操作和操作内建类型一样轻巧.

  • 对重载运算符的调用点的查找需要的可就不仅仅是像 grep 那样的程序了, 这时需要能够理解 C++ 语法的搜索工具.

  • 如果重载运算符的参数写错, 此时得到的可能是一个完全不同的重载而非编译错误. 例如: foo < bar 执行的是一个行为, 而 &foo < &bar 执行的就是完全不同的另一个行为了.

  • 重载某些运算符本身就是有害的. 例如, 重载一元运算符 & 会导致同样的代码有完全不同的含义, 这取决于重载的声明对某段代码而言是否是可见的. 重载诸如 &&, ||, 会导致运算顺序和内建运算的顺序不一致.

  • 运算符从通常定义在类的外部, 所以对于同一运算, 可能出现不同的文件引入了不同的定义的风险. 如果两种定义都链接到同一二进制文件, 就会导致未定义的行为, 有可能表现为难以发现的运行时错误.

  • 用户定义字面量 (UDLs) 所创建的语义形式对于某些有经验的 C++ 程序员来说都是很陌生的, 例如将 "Hello World"sv 作为 std::string_view("Hello World") 的简写. 现有的符号更清晰, 但不够简洁.

  • 用户定义字面量 (UDLs) 不能被命名空间限定, 所以对于它们的使用需要我们 约定禁止 使用或者在头文件中禁止使用 (除非字面量必须出现在某个有问题的头文件的接口里). 鉴于头文件必须避免字面量后缀, 我们更希望能避免头文件的字面量和源文件的字面量不同的情况.

结论

只有在意义明显, 不会出现奇怪的行为并且与对应的内建运算符的行为一致时才定义重载运算符. 例如, | 要作为位或/逻辑或来使用, 而不是作为 shell 中的管道.

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

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

对于可以比较其值是否相等的类型 T, 请定义非成员运算符 operator== 并且提供文档说明何时将类型为 T 的两个值视为相等. 如果一个类型为 T 的值 t1 小于 t2 是显而易见的, 那么你还应该定义 operator<==>, 它的定义应该与 operator== 保持一致. 最好不要重载其他比较和排序运算符.

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

不要重载 &&, ||, , 或一元运算符 &. 不要重载 operator"", 也就是说, 不要引入用户定义字面量. 不要使用任何其他人提供的字面量 (包括标准库).

类型转换运算符在 隐式类型转换 一节有提及. = 运算符在 可拷贝类型和可移动类型 一节有提及. 运算符 << 一节有提及. 同时请参见 函数重载 一节, 其中提到的的规则对运算符重载同样适用.

3.8. 存取控制

总述

所有 数据成员声明为 private, 除非是常量 (遵循 常量命名规则). 这会简化关于常量的获取, 但需要形成一些带有 const 值获取的访问器.

出于技术上的原因, 在使用 Google Test 时我们允许测试夹具类中的数据成员在 .cc 文件中定义为 protected. 如果测试夹具类是在使用它的 .cc 文件之外定义的, 例如在 .h 文件中, 将数据成员变量声明为私有的.

3.9. 声明顺序

总述

将相似的声明放在一起, 将 public 部分放在最前.

说明

类定义一般应以 public: 开始, 后跟 protected:, 最后是 private:. 省略空部分.

在各个部分中, 建议将类似的声明放在一起, 并且建议以如下的顺序:

    1. 类型及其别名 (包括 typedef, using, enum, 嵌套的结构体与类以及友元类型)

    1. (可选, 仅用于 struct) 非静态数据成员

    1. 静态常量

    1. 工厂函数

    1. 构造函数和赋值运算符

    1. 析构函数

    1. 所有的剩余函数 (静态与非静态成员函数, 以及友元函数)

    1. 所有的剩余数据成员 (静态的和非静态的)

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

译者 (YuleFox) 笔记

  1. 不在构造函数中做太多逻辑相关的初始化;

  2. 编译器提供的默认构造函数不会对变量进行初始化, 如果定义了其他构造函数, 编译器不再提供, 需要编码者自行提供默认构造函数;

  3. 为避免隐式转换, 需将单参数构造函数声明为 explicit;

  4. 为避免拷贝构造函数, 赋值操作的滥用和编译器自动生成, 可将其声明为 private 且无需实现;

  5. 仅在作为数据集合时使用 struct;

  6. 组合 > 实现继承 > 接口继承 > 私有继承, 子类重载的虚函数也要声明 virtual 关键字, 虽然编译器允许不这样做;

  7. 避免使用多重继承, 使用时, 除一个基类含有实现外, 其他基类均为纯接口;

  8. 接口类类名以 Interface 为后缀, 除提供带实现的虚析构函数, 静态成员函数外, 其他均为纯虚函数, 不定义非静态数据成员, 不提供构造函数, 提供的话, 声明为 protected;

  9. 为降低复杂性, 尽量不重载操作符, 模板, 标准类中使用时提供文档说明;

  10. 存取函数一般内联在头文件中;

  11. 声明次序: public -> protected -> private;

  12. 函数体尽量短小, 紧凑, 功能单一;