3. 类¶
类 (class) 是 C++ 中最基本的代码单元. 因此,类在 C++ 中被广泛使用. 本节将列出编写类时需要注意的事项.
3.1. 构造函数的内部操作¶
Tip
构造函数 (constructor) 中不得调用虚函数 (virtual method). 不要在没有错误处理机制的情况下进行可能失败的初始化.
定义
构造函数可以执行任意初始化操作.
优点
无需担心类是否已经初始化.
由构造函数完全初始化的对象可以是
const
类型, 也方便在标准容器或算法中使用.
缺点
在构造函数内调用虚函数时, 不会分派 (dispatch) 到子类的虚函数实现 (implementation). 即使当前还没有子类, 将来也是隐患, 会引发混乱.
构造函数难以报告错误, 只能让程序崩溃 (有时不合适) 或者用 异常 (我们禁止使用) 来表示错误.
如果初始化失败,对象将处于异常状态. 为了检查对象有效性,需要额外添加
IsValid()
等状态检查机制, 但用户容易忘记调用这些检查.由于无法获取构造函数的地址, 因此难以将构造函数的工作转交给其他线程执行.
结论
构造函数不允许调用虚函数. 合适时, 终止程序也是一种处理错误的方式. 否则, 可以像 第 42 号每周提示 一样定义
Init()
方法或工厂函数 (factory function). 若要添加Init()
方法, 应该确保可以从对象的状态中得知哪些公用方法是可用的. 若在对象未构造完成时调用其方法, 很容易导致错误.
3.2. 隐式类型转换¶
Tip
不要定义隐式类型转换. 定义类型转换运算符和单个参数的构造函数时, 请使用 explicit
关键字 (keyword).
定义
可以通过隐式转换, 在需要目标类型的地方使用源类型的对象. 例如向接受
double
参数的函数传入int
类型的参数.除了语言内置的隐式类型转换, 用户还可以在类中定义特定的成员来添加自定义转换函数. 若要在源类型中定义隐式类型转换, 可以定义一个以目标类型命名的类型转换运算符 (例如
operator bool()
). 若要在目标类型中定义隐式类型转换, 则可以定义一个以源类型为唯一参数 (或唯一无默认值的参数) 的构造函数.可以在构造函数或类型转换运算符上添加
explicit
关键字, 确保使用者必须明确指定目标类型, 例如使用转换 (cast) 运算符. 该关键字也适用于列表初始化:class Foo { explicit Foo(int x, double y); ... }; void Func(Foo f);Func({42, 3.14}); // 报错此类代码并非真正的隐式类型转换, 但语法标准将其视为类似的转换, 因此也适用于
explicit
关键字.
优点
有时目标类型显而易见. 隐式类型转换无需明确指定类名, 使类型更易用、更简洁.
隐式类型转换可以简化函数重载 (overload), 例如只需定义一个接受
string_view
参数的函数, 即可取代std::string
和const char*
的重载函数.初始化对象时, 列表初始化语法简洁明了.
缺点
隐式转换可能掩盖类型不匹配错误. 有时目标类型与用户预期不符, 甚至用户不知道会出现类型转换.
隐式转换降低了代码可读性. 尤其是存在函数重载的情况下, 难以判断实际调用的函数.
单参数构造函数可能被意外当作隐式类型转换, 这可能不是作者的本意.
如果单参数构造函数没有
explicit
标记, 读者无法判断这是隐式类型转换还是遗漏了explicit
标记.隐式类型转换可能导致调用歧义 (call-site ambiguity), 尤其是存在双向隐式转换的情况下. 这种情况可能是因为两种类型都定义了隐式转换, 或者一种类型同时定义了隐式构造函数和隐式转换运算符.
如果隐式指定列表初始化时的目标类型, 那么同样存在上述问题, 尤其是在列表只有一个元素的情况下.
结论
在类的定义中, 类型转换运算符和单参数构造函数都应该标记为
explicit
. 拷贝和移动构造函数除外, 因为它们不执行类型转换.对于某些可互换的类型, 隐式类型转换是必要且恰当的, 例如两种类型的对象只是同一底层值的不同表示形式. 此时请联系项目负责人申请豁免.
接受多个参数的构造函数可以省略
explicit
标记. 接受单个std::initializer_list
参数的构造函数也应该省略explicit
标记, 以支持拷贝初始化 (copy-initialization, 例如MyType m = {1, 2};
).
3.3. 可拷贝类型和可移动类型¶
Tip
类的公有接口必须明确指明该类是可拷贝的、仅可移动的、还是既不可拷贝也不可移动的. 如果该类型的复制和移动操作有明确的语义并且有用,则应该支持这些操作.
定义
可移动类型 (movable type) 可以用临时变量初始化或赋值.
可拷贝类型 (copyable type) 可以用另一个相同类型的对象初始化 (因此从定义上来说也是可移动的), 同时源对象的状态保持不变.
std::unique_ptr<int>
是可移动但不可拷贝类型的例子 (因为在赋值过程中必须修改提供初始值的std::unique_ptr<int>
对象).int
和std::string
是既可移动也可拷贝的例子. (int
类型的移动和拷贝操作等效.std::string
类型的移动操作比拷贝操作的开销更低.)对于用户自定义的类型, 拷贝操作由拷贝构造函数 (copy constructor) 和拷贝赋值运算符 (copy-assignment operator) 定义. 移动操作由移动构造函数 (move constructor) 和移动赋值运算符 (move-assignment operator) 定义, 不存在时由拷贝构造函数和拷贝赋值运算符代替.
编译器会在某些情况下隐式调用拷贝或移动构造函数, 例如使用值传递 (pass by value) 传递参数时.
优点
可移动和可拷贝类型的对象可以通过值传递来传递参数或返回值, 使 API 更简单、安全、通用. 与指针或引用传递 (pass by reference) 不同, 值传递不会造成所有权、生命周期和可变性 (mutability) 等方面的混乱, 也无需在调用约定中进行规定, 阻止了用户和定义者之间的跨作用域交互, 使代码更可读、更好维护, 并且更容易被编译器优化. 您可以在必须使用值传递的通用 API (例如大多数容器) 中使用这些对象, 同时它们在类型组合 (type composition) 等场景下具有更好的灵活性.
拷贝、移动构造函数和赋值运算符通常比其他替代方案 (比如
Clone()
,CopyFrom()
或Swap()
) 更容易确保正确性, 因为可以隐式或显式用= default
指示编译器自动生成代码. 这种写法简洁明了, 可以保证复制所有数据成员. 拷贝和移动构造函数通常更高效, 因为不需要分配堆内存, 不需要分离初始化和赋值的过程, 同时还适用于 拷贝消除 等优化.用户可以通过移动运算符隐式而高效地从右值对象 (rvalue) 中转移资源. 这有时可以使代码更加清晰.
缺点
某些类型不应该支持拷贝. 为这些类型提供拷贝操作可能会产生误导性的、无意义甚至错误的结果. 对于单例对象的类型 (例如注册表)、用于特定作用域的类型 (例如
Cleanup
) 或与其他对象紧密耦合的类型 (例如互斥锁), 它们的拷贝操作没有意义. 为多态类型的基类提供拷贝运算符很危险, 因为使用此运算符时会导致 对象切割 (object slicing) . 默认或者随意编写的拷贝操作可能存在错误, 往往会引发令人困惑且难以诊断的错误.拷贝构造函数是隐式调用的, 因此很容易忽视这些调用. 一些其他编程语言经常或者强制使用引用传递. 习惯了这些语言的程序员可能会感到困惑. 同时, 这也容易导致过度拷贝, 引发性能问题.
结论
每个类的公有接口都应该明确指明该类是否支持拷贝和移动操作. 通常应在声明的
public
部分显式声明或删除对应的操作.具体来说, 可拷贝的类应该显式声明拷贝运算符, 仅能移动的类应该显式声明移动运算符, 既不能拷贝也不能移动的类应该显式删除复制运算符. 可拷贝的类也可以声明移动运算符, 以支持更高效的移动. 您可以显式声明或删除所有四个拷贝和移动运算符, 但这不是必需的. 如果您提供拷贝或移动赋值运算符, 则必须提供同类的构造函数.
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
部分 (例如结构体或纯接口基类), 则该类型的可拷贝性和可移动性可以由其公有数据成员的可拷贝性/可移动性来确定.如果一个基类明显是不可移动或不可拷贝的, 那么其派生类自然也是不可移动或不可拷贝的. 如果纯接口基类只是隐式声明这些操作, 则不足以明确说明子类的可复制性或可移动性.
注意, 如果您显式声明或删除拷贝构造函数或拷贝赋值运算符之一, 另一个也必须显式声明或删除. 移动操作亦如此.
如果普通用户容易误解某个类的拷贝或移动操作的含义, 或者此操作会产生意想不到的开销, 那么这个类应该设计为不可拷贝或不可移动的. 可拷贝类型的移动操作只是一种性能优化, 容易增加复杂性并引发错误. 除非移动操作明显比拷贝操作更高效, 不要定义移动操作. 如果您的类可拷贝, 那么最好确保自动生成的默认 (default) 实现是正确的. 请像检查您自己的代码一样检查默认实现的正确性.
为了避免对象切割的风险, 基类最好是抽象 (abstract) 类. 若要声明抽象类, 可以将构造函数或析构函数声明为 protected
, 或者声明纯虚 (pure virtual) 成员函数. 尽量避免继承一个具体类 (concrete class).
3.4. 结构体还是类¶
Tip
只能用 struct
定义那些用于储存数据的被动对象. 其他情况应该使用 class
.
C++ 中 struct
和 class
关键字的含义几乎一样. 我们自己为这两个关键字赋予了不同的语义, 所以您要选择合适的关键字.
应该用结构体定义用于储存数据的被动对象, 其中可能包含常量成员. 所有成员都必须是公共的. 结构体的成员之间不能存在不变式 (invariant) 关系, 因为用户直接访问这些成员时可能破坏不变式. 结构体可以有构造函数、析构函数和辅助方法, 但是这些函数不能要求或实现不变式.
如果需要实现更多功能或不变式约束, 或者该结构体用途广泛并且会在未来不断更新, 那么类更合适. 在不确定的时候, 应该选择类.
为了与 STL 保持一致, 特征 (trait)、 模板元函数 (template metafunction) 、仿函数 (functor) 等无状态的类型可以使用结构体而不用类.
注意, 类和结构体的成员变量具有不同的 命名规则.
3.5. 结构体、数对还是元组¶
Tip
如果可以给成员起一个有意义的名字, 应该用结构体而不是数对 (pair) 或元组 (tuple).
虽然使用数对和元组可以免于定义自定义类型, 从而节省编写代码的时间, 但是有意义的成员名称通常比 .first
, .second
和 std::get<X>
更可读. C++14 引入了 std::get<Type>
, 只要某类型的元素唯一, 就可以根据类型而非下标来访问元组元素. 这在一定程度上缓解了问题, 但成员名称通常比类型名称更清晰、更有信息量.
数对和元组适合通用代码, 因为其中的元素没有特定含义. 与现有代码或 API 交互时也可能需要它们.
3.6. 继承¶
Tip
通常情况下, 组合 (composition) 比继承 (inheritance) 更合适. 请使用 public
继承.
定义
当子类继承基类时, 子类会包含基类定义的所有数据及操作. 接口继承 (interface inheritance) 指从纯抽象基类 (pure abstract base class, 不包含状态或方法定义) 继承; 所有其他继承都是 实现继承 (implementation inheritance).
优点
实现继承复用了基类代码, 因此可以减少代码量. 继承是在编译时声明的, 因此您和编译器都可以理解并检查错误. 接口继承可以强制一个类公开特定 API. 当类没有定义 API 中的方法时, 编译器可以检测到错误.
缺点
对于实现继承, 子类的实现代码散布在父类和子类之间, 因此更难理解. 子类不能重写 (override) 父类的非虚函数, 因此无法修改其实现.
多重继承的问题更严重, 因为这样通常会产生更大的性能开销 (事实上, 多重继承相比单继承的性能损失大于虚方法相比普通方法的性能损失). 此外, 这容易产生 菱形继承 (diamond inheritance) 的模式, 造成歧义、混乱和严重错误.
结论
所有继承都应该使用 public
的访问权限. 如果要实现私有继承, 可以将基类对象作为成员变量保存. 当您不希望您的类被继承时, 可以使用 final
关键字.
不要过度使用实现继承. 组合 (composition) 通常更合适. 尽量只在 “什么是什么” (“is-a”, YuleFox 注: 其他 “has-a” 情况下请使用组合) 关系的情况下使用继承: 如果 Bar
是一种 Foo
, 那么 Bar
才能继承 Foo
.
只将子类可能需要访问的成员函数设为 protected
. 注意, 数据成员应该是私有的.
明确使用 override
或 final
(较少使用) 关键字限定重写的虚函数或者虚析构函数. 重写时不要使用 virtual
关键字. 原因: 如果函数带有 override
或 final
关键字, 却没有正确重写基类的虚函数, 会导致编译错误, 有助于发现常见笔误. 这些限定符相当于文档. 如果不用限定符, 读者必须检查所有祖先类才能确定函数是否是虚函数.
允许多重继承, 但强烈建议避免多重实现继承.
3.7. 运算符重载¶
Tip
谨慎使用运算符重载 (overload). 禁止自定义字面量 (user-defined literal).
定义
用户可以使用 operator
关键字来 重载内置运算符 , 前提是其中一个参数是用户自定义类型. 用户还可以使用 operator""
定义一类新的字面量, 或者定义类型转换函数 (例如 operator bool()
).
优点
重载运算符可以让用户定义的类型拥有与内置类型相似的行为, 使得代码更简洁直观. 重载运算符相当于特定操作的惯用名称 (例如 ==
, <
, =
, <<
). 若用户定义的类型符合这些习惯, 则代码更易读, 并便于与使用这些名称的库进行互操作.
自定义字面量提供了一种创建自定义类型对象的简洁写法。
缺点
需要花费精力才能实现正确、一致且符合预期的一组重载运算符. 稍有不慎就会引起困惑和错误.
过度使用运算符会让代码难以理解, 特别是重载运算符的语义不合常理时.
函数重载的弊端也同样适用于运算符重载,甚至更加严重.
重载运算符可能会混淆视听, 让您把一些耗时的操作误以为是快速的内置运算.
要列出重载运算符的调用者, 需要能理解 C++ 语法的搜索工具, 无法使用
grep
等通用工具.如果重载运算符的参数类型错误, 您可能会调用一个完全不同的重载函数, 而不是得到编译错误. 例如:
foo < bar
和&foo < &bar
会执行完全不同的代码.某些运算符的重载是危险的. 例如, 重载一元运算符
&
时, 取决于重载声明是否在某段代码中可见, 同样的代码可能具有完全不同的含义. 重载的&&
,||
和,
与内置运算符的运算顺序不一致. (译者注: 例如, 内置的&&
运算符会短路求值, 左侧为假时会跳过右侧的运算, 而重载的运算符不会短路.)通常我们在类的定义以外定义运算符, 所以不同的文件可能对同一个运算有不同的定义. 如果同一二进制文件中链接 (link) 了两种定义, 结果就是未定义行为 (undefined behavior), 可能出现难以发现的运行时错误.
自定义字面量会创造新的语法, 例如将
std::string_view("Hello World")
简写为"Hello World"sv
. 经验丰富的 C++ 程序员都对此感到陌生. 现有的语法更清晰, 尽管不够简洁.自定义字面量不能限制在命名空间中, 因此使用自定义字面量时必须同时使用 using 指令 (using-directive, 我们:ref:禁止使用 <namespaces>) 或 using 声明 (using-declaration, 我们禁止在头文件里使用, 除非导入的名称是需要暴露的接口). 因为头文件不能使用自定义字面量, 所以源文件的字面量格式也不应该与头文件不同.
结论
重载运算符应该意义明确, 符合常理, 并且与对应的内置运算符行为一致. 例如, 应该用 |
表示位或/逻辑或, 而非类似 shell 的管道.
只为您自己定义的类型定义重载运算符. 具体来说, 重载运算符和对应的类型应该在同一个头文件, .cc
文件和命名空间中. 这样, 任何使用该类型的代码都可以使用这些运算符, 避免了多重定义的风险. 尽量避免用模板定义运算符, 否则每个可能的模板类型都必须符合以上要求. 定义一个运算符时, 请同时定义相关且有意义的运算符, 并且保证语义一致.
建议将不修改数据的二元运算符定义为非成员函数. 如果二元运算符是成员函数, 那么右侧的参数可以隐式类型转换, 左侧却不能. 此时可能 a + b
能编译而 b + a
编译失败, 令人困惑.
对可以判断相等性的类型 T
, 请定义非成员运算符 operator==
, 并用文档说明什么条件下认为 T
的两个值相等. 如果类型 T
的“小于”的概念是显而易见的, 那么您也可以定义 operator<=>
, 并且保持与 operator==
的逻辑一致. 不建议重载其他的比较、排序运算符.
不要为了避免重载操作符而走极端. 比如, 应当定义 ==
, =
和 <<
而不是 Equals()
, CopyFrom()
和 PrintTo()
. 另一方面, 不要仅仅因为其他库需要运算符重载而定义运算符. 比如, 如果您的类型没有自然顺序, 但您要在 std::set
中存储这样的对象, 最好使用自定义比较器 (comparator) 而不是重载 <
.
不要重载 &&
, ||
, ,
或一元的 (unary) &
运算符. 不要重载 operator""
, 即不要引入自定义字面量. 不要使用其他人提供的任何自定义字面量 (包括标准库).
类型转换运算符的内容参见 隐式类型转换. =
运算符的内容参见 拷贝构造函数. 关于重载用于流 (stream) 操作的 <<
运算符, 参见 流. 另请参阅 函数重载 的规则, 这些规则也适用于运算符重载.
3.8. 访问控制¶
Tip
类的 所有 数据成员应该声明为私有 (private), 除非是常量. 这样做可以简化类的不变式 (invariant) 逻辑, 代价是需要增加一些冗余的访问器 (accessor) 代码 (通常是 const 方法).
由于技术原因, 在使用 Google Test 时, 我们允许在 .cc
文件中将测试夹具类 (test fixture class) 的数据成员声明为受保护的 (protected). 如果测试夹具类的声明位于使用该夹具的 .cc
文件之外 (例如在 .h
文件中), 则应该将数据成员设为私有.
3.9. 声明次序¶
Tip
将相似的声明放在一起. 公有 (public) 部分放在最前面.
类的定义通常以 public:
开头, 其次是 protected:
, 最后以 private:
结尾. 空的部分可以省略.
在各个部分中, 应该将相似的声明分组, 并建议使用以下顺序:
类型和类型别名 (
typedef
,using
,enum
, 嵌套结构体和类, 友元类型)(可选, 仅适用于结构体) 非静态数据成员
静态常量
工厂函数 (factory function)
构造函数和赋值运算符
析构函数
所有其他函数 (包括静态与非静态成员函数, 还有友元函数)
所有其他数据成员 (包括静态和非静态的)
不要在类定义中放置大段的函数定义. 通常, 只有简单、对性能至关重要且非常简短的方法可以声明为内联函数. 参见 内联函数 一节.
译者 (YuleFox) 笔记¶
不在构造函数中做太多逻辑相关的初始化;
编译器提供的默认构造函数不会对变量进行初始化, 如果定义了其他构造函数, 编译器不再提供, 需要编码者自行提供默认构造函数;
为避免隐式转换, 需将单参数构造函数声明为
explicit
;为避免拷贝构造函数, 赋值操作的滥用和编译器自动生成, 可将其声明为
private
且无需实现;仅在作为数据集合时使用
struct
;组合 > 实现继承 > 接口继承 > 私有继承, 子类重载的虚函数也要声明
virtual
关键字, 虽然编译器允许不这样做;避免使用多重继承, 使用时, 除一个基类含有实现外, 其他基类均为纯接口;
接口类类名以
Interface
为后缀, 除提供带实现的虚析构函数, 静态成员函数外, 其他均为纯虚函数, 不定义非静态数据成员, 不提供构造函数, 提供的话, 声明为protected
;为降低复杂性, 尽量不重载操作符, 模板, 标准类中使用时提供文档说明;
存取函数一般内联在头文件中;
声明次序:
public
->protected
->private
;函数体尽量短小, 紧凑, 功能单一;