类(Classes)
类是 C++ 中代码的基本单元。自然地,我们广泛使用它们。本节列出了编写类时应遵循的主要注意事项。
在构造函数中执行工作
避免在构造函数中调用虚方法,并且如果你无法发出错误信号,则避免可能失败的初始化。
可以在构造函数的函数体中执行任意初始化。
- 不需要担心类是否已经被初始化。
- 通过构造函数调用完全初始化的对象可以是
const的,也可能更容易与标准容器或算法一起使用。
- 如果工作调用虚函数,这些调用不会被分派到子类实现。即使你的类当前没有子类,将来对类的修改也可能悄悄地引入这个问题,造成很多困惑。
- 构造函数没有简单的方式来发出错误信号,除了使程序崩溃(并不总是合适的)或使用异常(这是被禁止的)。
- 如果工作失败,我们现在有一个初始化代码失败的对象,因此它可能处于不寻常的状态,需要一个
bool IsValid()状态检查机制(或类似的),而这很容易忘记调用。 - 你不能获取构造函数的地址,因此在构造函数中完成的工作不容易交给例如另一个线程。
构造函数绝不应调用虚函数。如果适合你的代码,终止程序可能是一种适当的错误处理响应。否则,考虑使用工厂函数或
Init() 方法,如 TotW #42
所述。避免在没有其他状态影响可调用哪些公共方法的对象上使用 Init()
方法(这种形式的半构造对象特别难以正确使用)。
隐式转换(Implicit Conversions)
不要定义隐式转换。对转换运算符和单参数构造函数使用 explicit 关键字。
隐式转换允许一种类型(称为源类型)的对象被用在期望另一种类型(称为目标类型)的地方,例如将
int 参数传递给接受 double 参数的函数。
除了语言定义的隐式转换外,用户还可以定义自己的隐式转换,通过向源类型或目标类型的类定义添加适当的成员。源类型中的隐式转换由以目标类型命名的类型转换运算符定义(例如,operator bool())。目标类型中的隐式转换由可以将源类型作为唯一参数(或唯一没有默认值的参数)的构造函数定义。
explicit
关键字可以应用于构造函数或转换运算符,以确保只有在使用点目标类型是显式的时才能使用,例如通过类型转换。这不仅适用于隐式转换,也适用于列表初始化语法:
class Foo {
explicit Foo(int x, double y);
...
};
void Func(Foo f);Func({42, 3.14}); // Error这种代码严格来说不是隐式转换,但就 explicit 而言,语言将其视为隐式转换。
- 当类型显而易见时,隐式转换可以通过消除显式命名类型的需要使类型更可用和更具表达力。
- 隐式转换可以是重载的一种更简单的替代方案,例如当一个带
string_view参数的函数取代了std::string和const char*的单独重载时。 - 列表初始化语法是一种简洁且富有表达力的对象初始化方式。
- 隐式转换可以隐藏类型不匹配的错误,其中目标类型不符合用户的预期,或者用户不知道会发生任何转换。
- 隐式转换可以使代码更难阅读,尤其是在存在重载的情况下,因为实际调用的代码不那么明显。
- 接受单个参数的构造函数可能会意外地可用作隐式类型转换,即使它们并非如此设计。
- 当单参数构造函数未标记为
explicit时,没有可靠的方法来判断它是否旨在定义隐式转换,还是作者只是忘记了标记。 - 隐式转换可能导致调用点歧义,尤其是存在双向隐式转换时。这可能是由两个类型都提供隐式转换引起的,也可能是由一个同时具有隐式构造函数和隐式类型转换运算符的类型引起的。
- 如果目标类型是隐式的,列表初始化也可能遭受同样的问题,特别是当列表只有一个元素时。
类型转换运算符和可用单个参数调用的构造函数必须在类定义中标记为
explicit。作为例外,拷贝和移动构造函数不应该是
explicit 的,因为它们不执行类型转换。
对于设计为可互换的类型,隐式转换有时是必要且合适的,例如当两种类型的对象只是同一底层值的不同表示时。在这种情况下,请联系你的项目负责人请求豁免此规则。
不能用单个参数调用的构造函数可以省略 explicit。接受单个
std::initializer_list 参数的构造函数也应该省略
explicit,以支持拷贝初始化(例如,MyType m = {1, 2};)。
可拷贝和可移动类型
类的公共 API 必须明确该类是可拷贝的、仅可移动的,还是既不可拷贝也不可移动。如果这些操作对你的类型来说是清晰且有意义的,则支持拷贝和/或移动。
可移动类型是可以从临时对象初始化和赋值的类型。
可拷贝类型是可以从同类型的任何其他对象初始化或赋值的类型(因此根据定义也是可移动的),条件是源的值不会改变。std::unique_ptr<int>
是一个可移动但不可拷贝类型的例子(因为源 std::unique_ptr<int>
的值在赋值给目标时必须被修改)。int 和 std::string
是既可移动又可拷贝的类型的例子。(对于 int,移动和拷贝操作是相同的;对于
std::string,存在比拷贝成本更低的移动操作。)
对于用户定义类型,拷贝行为由拷贝构造函数和拷贝赋值运算符定义。移动行为由移动构造函数和移动赋值运算符定义(如果存在),否则由拷贝构造函数和拷贝赋值运算符定义。
拷贝/移动构造函数在某些情况下可以被编译器隐式调用,例如,按值传递对象时。
可拷贝和可移动类型的对象可以按值传递和返回,这使 API 更简单、更安全和更通用。与通过指针或引用传递对象不同,不会有关于所有权、生命周期、可变性等问题的混淆风险,也不需要在契约中指定它们。它还防止了客户端和实现之间的非局部交互,使它们更容易被理解、维护和被编译器优化。此外,这样的对象可以与要求按值传递的通用 API(如大多数容器)一起使用,并允许在例如类型组合方面有额外的灵活性。
拷贝/移动构造函数和赋值运算符通常比 Clone()、CopyFrom() 或 Swap()
等替代方案更容易正确定义,因为它们可以由编译器隐式生成或通过 = default
生成。它们简洁,并确保所有数据成员都被拷贝。拷贝和移动构造函数通常也更高效,因为它们不需要堆分配或单独的初始化和赋值步骤,并且有资格进行拷贝省略 等优化。
移动操作允许从右值对象中隐式且高效地转移资源。这在某些情况下允许更朴素的编码风格。
某些类型不需要是可拷贝的,为这些类型提供拷贝操作可能令人困惑、无意义或完全错误。表示单例对象的类型(Registerer)、与特定作用域绑定的对象(Cleanup)或与对象标识紧密耦合的对象(Mutex)不能被有意义地拷贝。为将被多态使用的基类类型提供拷贝操作是危险的,因为使用它们可能导致对象切片 。默认的或草率实现的拷贝操作可能是不正确的,由此产生的错误可能令人困惑且难以诊断。
拷贝构造函数是隐式调用的,这使得调用容易被忽视。这可能会让习惯于按引用传递是惯例或强制性的语言的程序员感到困惑。它也可能鼓励过度拷贝,从而导致性能问题。
每个类的公共接口必须明确类支持哪些拷贝和移动操作。这通常应该采用在声明的
public 部分显式声明和/或删除适当操作的形式。
具体来说,可拷贝类应显式声明拷贝操作,仅可移动类应显式声明移动操作,不可拷贝/不可移动类应显式删除拷贝操作。可拷贝类也可以声明移动操作以支持高效移动。显式声明或删除所有四个拷贝/移动操作是允许的,但不是必需的。如果你提供了拷贝或移动赋值运算符,你还必须提供相应的构造函数。
class Copyable {
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// The implicit move operations are suppressed by the declarations above.
// You may explicitly declare move operations to support efficient moves.
};
class MoveOnly {
public:
MoveOnly(MoveOnly&& other) = default;
MoveOnly& operator=(MoveOnly&& other) = default;
// The copy operations are implicitly deleted, but you can
// spell that out explicitly if you want:
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// Not copyable or movable
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)
= delete;
// The move operations are implicitly disabled, but you can
// spell that out explicitly if you want:
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
= delete;
};这些声明/删除只有在显而易见时才可以省略:
- 如果类没有
private部分,如结构体或纯接口基类,那么可拷贝性/可移动性可以由任何公共数据成员的可拷贝性/可移动性确定。 - 如果基类明显不可拷贝或不可移动,派生类自然也不会。纯接口基类如果将这些操作留为隐式的,不足以使具体子类变得清晰。
- 请注意,如果你显式声明或删除了拷贝的构造函数或赋值操作中的任何一个,另一个拷贝操作就不是显而易见的,必须被声明或删除。移动操作也是如此。
如果拷贝/移动的含义对普通用户不清楚,或者会产生意外的成本,类型就不应该是可拷贝/可移动的。可拷贝类型的移动操作严格来说是性能优化,也是错误和复杂性的潜在来源,因此除非它们比相应的拷贝操作显著高效,否则避免定义它们。如果你的类型提供了拷贝操作,建议你设计类使这些操作的默认实现是正确的。记住像审查任何其他代码一样审查任何默认操作的正确性。
为了消除切片(Slicing)的风险,优先使基类成为抽象类,通过使其构造函数为
protected、声明其析构函数为 protected
或给它们一个或多个纯虚成员函数来实现。优先避免从具体类派生。
结构体(Struct) vs. 类(Class)
仅对携带数据的被动对象使用 struct;其他所有情况使用 class。
struct 和 class 关键字在 C++
中的行为几乎相同。我们为每个关键字添加了自己的语义含义,因此你应该为你定义的数据类型使用适当的关键字。
struct
应用于携带数据的被动对象,可以有关联的常量。所有字段必须是公共的。结构体类型本身不能有暗示不同字段之间关系的不变量,因为用户直接访问这些字段可能会破坏这些不变量,但结构体的用户可以对其特定用法有要求和保证。构造函数、析构函数和辅助方法可以存在;但是,这些方法不能要求或强制执行任何不变量。
如果需要更多功能或不变量,或者结构体具有广泛的可见性并预计会演变,那么
class 更合适。如有疑问,使其成为 class。
为了与 STL 保持一致,你可以对无状态类型使用 struct 而不是
class,例如 traits、模板元函数和一些仿函数。
请注意,结构体和类中的成员变量具有不同的命名规则。
结构体 vs. 键值对(Pair)和元组(Tuple)
当元素可以有有意义的名称时,优先使用 struct
而不是键值对(Pair)或元组(Tuple)。
虽然使用键值对和元组可以避免定义自定义类型的需要,在编写代码时可能节省工作量,但在阅读代码时,有意义的字段名几乎总是比
.first、.second 或 std::get<X> 更清晰。虽然 C++14 引入的
std::get<Type>
可以通过类型而不是索引来访问元组元素(当类型唯一时),有时可以部分缓解这个问题,但字段名通常比类型名更清晰、更有信息量。
在泛型代码中,当键值对或元组的元素没有特定含义时,使用它们可能是合适的。为了与现有代码或 API 互操作,也可能需要使用它们。
继承(Inheritance)
组合(Composition)通常比继承更合适。使用继承时,使其为 public。
当子类从基类继承时,它包含基类定义的所有数据和操作的定义。“接口继承”是从纯抽象基类(没有状态或定义的方法的基类)继承;所有其他继承都是”实现继承”。
实现继承通过在特化现有类型时重用基类代码来减少代码大小。因为继承是编译时声明,你和编译器都能理解操作并检测错误。接口继承可以用于以编程方式强制类暴露特定的 API。同样,编译器可以检测错误,在这种情况下,是当类没有定义 API 的必要方法时。
对于实现继承,由于实现子类的代码分布在基类和子类之间,因此理解实现可能更困难。子类不能覆盖非虚函数,因此子类无法更改实现。
多重继承尤其容易出问题,因为它通常会带来更高的性能开销(事实上,从单继承到多重继承的性能下降往往比从普通调度到虚调度的性能下降更大),而且它有导致”菱形”继承模式的风险,这种模式容易产生歧义、混淆和严重的错误。
所有继承应该是 public 的。如果你想使用 private
继承,你应该改为将基类的实例作为成员包含。当你不打算支持将类用作基类时,可以在类上使用
final。
不要过度使用实现继承。组合通常更合适。尝试将继承的使用限制在”is-a”的情况:如果可以合理地说
Bar “是一种” Foo,则 Bar 继承 Foo。
将 protected
的使用限制在可能需要从子类访问的成员函数上。请注意,数据成员应该是
private 的。
使用且仅使用一个 override 或(较少见的)final
说明符来显式标注虚函数或虚析构函数的覆盖。在声明覆盖时不要使用
virtual。理由是:标记为 override 或 final
的函数或析构函数如果不是基类虚函数的覆盖,将无法编译,这有助于捕获常见错误。这些说明符用作文档;如果没有说明符,读者必须检查相关类的所有祖先来确定函数或析构函数是否是虚的。
允许多重继承,但强烈不鼓励多重实现继承。
运算符重载(Operator Overloading)
审慎地重载运算符。不要使用用户定义字面量。
C++ 允许用户代码使用 operator
关键字声明内置运算符的重载版本 ,只要其中一个参数是用户定义类型。operator
关键字还允许用户代码使用 operator""
定义新种类的字面量,以及定义类型转换函数(如 operator bool())。
运算符重载可以使代码更简洁和直观,通过使用户定义类型的行为与内置类型相同。重载运算符是某些操作(例如
==、<、= 和
<<)的惯用名称,遵循这些约定可以使用户定义类型更具可读性,并使它们能够与期望这些名称的库互操作。
用户定义字面量是创建用户定义类型对象的非常简洁的表示法。
- 提供一组正确、一致且不令人惊讶的运算符重载需要一些小心,如果做不好可能导致混淆和错误。
- 过度使用运算符可能导致代码晦涩,尤其当重载运算符的语义不遵循约定时。
- 函数重载的危害同样适用于运算符重载,甚至更严重。
- 运算符重载可能欺骗我们的直觉,使我们误认为昂贵的操作是廉价的内置操作。
- 查找重载运算符的调用点可能需要能识别 C++ 语法的搜索工具,而不是例如 grep。
- 如果你搞错了重载运算符的参数类型,你可能会得到不同的重载而不是编译器错误。例如,
foo < bar可能做一件事,而&foo < &bar做的事情完全不同。 - 某些运算符重载本身就是危险的。重载一元
&可能导致相同的代码根据重载声明是否可见而具有不同的含义。&&、||和,(逗号)的重载无法匹配内置运算符的求值顺序语义。 - 运算符通常在类外定义,因此存在不同文件引入同一运算符不同定义的风险。如果两个定义都链接到同一个二进制文件中,将导致未定义行为,可能表现为微妙的运行时错误。
- 用户定义字面量(UDL)允许创建即使对有经验的 C++
程序员来说也不熟悉的新语法形式,如用
"Hello World"sv作为std::string_view("Hello World")的简写。现有的表示法更清晰,尽管不那么简洁。 - 因为 UDL 不能使用命名空间限定,使用 UDL 还需要使用 using 指令(我们禁止)或 using 声明(我们在头文件中禁止,除非导入的名称是相关头文件暴露的接口的一部分)。鉴于头文件必须避免 UDL 后缀,我们更倾向于避免头文件和源文件之间字面量约定的差异。
只有当重载运算符的含义明显、不令人惊讶且与相应的内置运算符一致时,才定义它们。例如,使用
| 作为按位或或逻辑或,而不是作为 shell 风格的管道。
只在你自己的类型上定义运算符。更精确地说,在与它们操作的类型相同的头文件、.cc
文件和命名空间中定义它们。这样,运算符在类型可用的任何地方都可用,最大限度地减少了多重定义的风险。如果可能,避免将运算符定义为模板,因为它们必须对任何可能的模板参数满足此规则。如果你定义了一个运算符,也要定义任何有意义的相关运算符,并确保它们的定义一致。
优先将非修改性二元运算符定义为非成员函数。如果二元运算符被定义为类成员,隐式转换将应用于右侧参数,但不应用于左侧参数。如果
a + b 能编译但 b + a 不能,这会让你的用户感到困惑。
对于值可以比较相等性的类型 T,定义非成员 operator== 并记录何时类型
T 的两个值被视为相等。如果存在一个明显的概念来定义类型 T 的值 t1
何时小于另一个这样的值 t2,你也可以定义 operator<=>,它应该与
operator== 一致。优先不要重载其他比较和排序运算符。
不要刻意避免定义运算符重载。例如,优先定义 ==、= 和 <<,而不是
Equals()、CopyFrom() 和
PrintTo()。反过来,不要仅仅因为其他库期望就定义运算符重载。例如,如果你的类型没有自然的排序,但你想将它存储在
std::set 中,使用自定义比较器而不是重载 <。
不要重载 &&、||、,(逗号)或一元 &。不要重载
operator"",即不要引入用户定义字面量。也不要使用他人(包括标准库)提供的此类字面量。
类型转换运算符在隐式转换一节中讨论。=
运算符在拷贝构造函数一节中讨论。用于流的 <<
重载在流一节中讨论。另请参阅函数重载的规则,它们同样适用于运算符重载。
访问控制(Access Control)
将类的数据成员设为 private,除非它们是常量。这简化了对不变量的推理,代价是如果需要的话,需要一些简单的样板代码形式的访问器(通常是
const)。
出于技术原因,当使用 Google
Test 时,我们允许在 .cc
文件中定义的测试固件类的数据成员是 protected 的。如果测试固件类是在其使用的
.cc 文件之外定义的,例如在 .h 文件中,则数据成员应为 private。
声明顺序(Declaration Order)
将类似的声明分组在一起,将 public 部分放在前面。
类定义通常应以 public: 部分开头,然后是 protected:,最后是
private:。省略空的部分。
在每个部分中,优先将类似种类的声明分组在一起,并优先采用以下顺序:
- 类型和类型别名(
typedef、using、enum、嵌套结构体和类、以及friend类型) - (可选,仅适用于结构体)非
static数据成员 - 静态常量
- 工厂函数
- 构造函数和赋值运算符
- 析构函数
- 所有其他函数(
static和非static成员函数,以及friend函数) - 所有其他数据成员(静态和非静态)
不要在类定义中内联放置大型方法定义。通常,只有平凡的或性能关键的、非常短的方法才可以内联定义。详见在头文件中定义函数。