Classes 类
类(Class)是 C++ 代码的基本组成单元。因此,我们会大量使用它们。本节列出了在编写类时应当遵循的主要准则和禁忌。
在构造函数中执行操作
避免在构造函数中调用虚方法(函数),并且如果无法报错,就避免可能会失败的初始化操作。
定义:
在构造函数体中执行任意的初始化操作是可能的。
优点:
无需担心类是否已完成初始化。
通过构造函数完全初始化的对象可以被声明为 const,并且在配合标准容器或算法使用时也会更加便捷。
缺点:
如果初始化代码中调用了虚函数,这些调用将不会分发到子类的实现中。 即使您的类当前没有被继承,未来对类的修改也可能悄悄引入这一问题,从而导致极大的困惑。
构造函数几乎没有简单的方法来报告错误, 除非让程序崩溃(这并不总是合适的)或者使用异常(而这通常是被禁止的)。
如果初始化工作失败,我们就会得到一个初始化代码执行失败的对象。 这意味着对象可能处于一种异常状态,需要引入类似 bool IsValid() 的状态检查机制,而这种检查很容易被使用者忘记调用。
您无法获取构造函数的地址, 因此在构造函数中完成的任何工作都无法轻易地移交(例如传递给另一个线程)。
结论:
构造函数绝不应该调用虚函数。如果适合您的代码,终止程序可能是一种适当的错误处理方式。否则,请考虑使用 TotW #42 中描述的工厂函数或 Init() 方法。对于那些没有其他状态来决定哪些公共方法可以被调用的对象,应避免使用 Init() 方法(这种形式的半成品对象特别难以正确处理)。
隐式转换
不要定义隐式转换。对于转换运算符和单参数构造函数,请使用 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* 的独立重载函数时。
列表初始化语法是初始化对象的一种简洁且富有表现力的方式。
缺点:
隐式转换可能会掩盖类型不匹配的 Bug。在这种情况下,目标类型可能与使用者的预期不符,或者使用者根本意识不到发生了类型转换。
隐式转换会让代码更难阅读,特别是在存在函数重载的情况下,它会让使用者很难一眼看出实际调用的是哪个函数。
只有一个参数的构造函数可能会意外地充当隐式类型转换,即使类的设计者本意并非如此。
当一个单参数构造函数没有标记为 explicit 时,无法可靠地判断 这究竟是设计者有意定义的隐式转换,还是设计者只是单纯地忘记添加该关键字了。
隐式转换可能导致调用点产生歧义,特别是当存在双向隐式转换时。这种情况可能由两个类型都提供了隐式转换引起,也可能由单个类型同时拥有隐式构造函数和隐式类型转换运算符引起。
如果目标类型是隐式的,列表初始化也可能出现同样的问题,特别是当列表中只有一个元素时。
结论:
类型转换运算符,以及可以通过单个参数调用的构造函数,必须在类定义中显式标记为 explicit。例外情况是,拷贝构造函数和移动构造函数不应标记为 explicit,因为它们并不执行类型转换。
有时,对于那些设计为可互换的类型(例如,当两个类型的对象仅仅是同一底层值的不同表示形式时),隐式转换可能是必要且合理的。在这种情况下,请联系您的项目负责人申请对该规则的豁免。
无法通过单个参数调用的构造函数可以省略 explicit。此外,接受单个 std::initializer_list 参数的构造函数也应省略 explicit,以支持拷贝初始化(例如:MyType m = {1, 2};)。
一个使用explicit关键字的例子
// 没有使用 explicit(危险的情况) #include <iostream> class File { public: // 普通构造函数(只有一个参数) File(int mode) { std::cout << "文件已打开,模式代码: " << mode << std::std::endl; } void read() { std::cout << "正在读取文件..." << std::endl; } }; // 一个处理字符串的函数 void processString(const std::string& str) { std::cout << "处理字符串: " << str << std::endl; } int main() { // === 情况 A:正常的对象创建 === File myFile(1); // 正常:显式构造,没问题 // === 情况 B:隐式转换(编译器自动把 int 变成了 File 对象)=== // 假设我们想调用 processString,但误传了一个 int // processString(10); // 如果 std::string 有接受 int 的构造函数(实际上没有,只是为了演示逻辑), // 编译器就会尝试 File(int) 来转换,这通常不是我们想要的。 // === 情况 C:最隐蔽的错误 === // 假设我们有一个函数,期望一个 File 对象 void openFile(const File& f); // 我们错误地直接传了一个整数 openFile(5); // 编译器会默默地执行:openFile(File(5)); // 程序员本意可能是传一个文件名或路径,结果却传了一个模式代码。 // 这种“自动修正”非常危险且难以调试。 return 0; }在上面的代码中,File(int mode) 允许编译器进行隐式转换。这意味着你可以在任何需要 File 对象的地方直接写一个数字,编译器会自动帮你创建一个 File 对象。这很容易导致逻辑错误(比如把“打开模式”误当成“文件句柄”)。
// 使用 explicit(安全的情况) #include <iostream> class File { public: // 使用 explicit 修饰构造函数 explicit File(int mode) { std::cout << "文件已打开,模式代码: " << mode << std::endl; } void read() { std::cout << "正在读取文件..." << std::endl; } }; int main() { // === 情况 A:显式构造(唯一允许的方式)=== File myFile(1); // 正确 File myFile2{2}; // 正确 (C++11 列表初始化) // === 情况 B:隐式转换被禁止 === // File myFile3 = 3; // 编译错误!explicit 禁止了这种语法 // === 情况 C:函数调用必须显式 === // openFile(5); // 编译错误!不再允许隐式转换 openFile(File(5)); // 正确:必须显式写出构造过程 openFile({6}); // 正确:显式列表初始化 return 0; }
可复制和可移动的类型
一个类的公共 API 必须明确表明该类是可复制的、仅可移动的,还是既不可复制也不可移动的。如果对于您的类型而言,复制和/或移动操作是清晰且有意义的,那么就应当支持这些操作。
定义:
可移动类型是指能够通过临时对象(右值)进行初始化和赋值的类型。
可复制类型是指能够通过同类型的任何其他对象进行初始化或赋值的类型(因此,根据定义,它也一定是可移动的),其规定是源对象的值不会发生改变。std::unique_ptr<int> 就是一个可移动但不可复制的类型示例(因为在赋值给目标对象的过程中,源 std::unique_ptr<int> 的值必须被修改)。int 和 std::string 则是既可移动也可复制的类型示例。(对于 int,移动和复制的操作是相同的;而对于 std::string,则存在一种比复制更节省开销的移动操作。)
对于用户自定义的类型,其复制行为由拷贝构造函数和拷贝赋值运算符定义。而移动行为则由移动构造函数和移动赋值运算符定义(如果它们存在的话);否则,移动行为将回退(fall back)到由拷贝构造函数和拷贝赋值运算符定义。
在某些情况下(例如按值传递对象时),编译器可能会隐式地调用拷贝/移动构造函数。
优点:
可复制和可移动类型的对象可以通过值进行传递和返回,这使得 API 更加简洁、安全且通用。与通过指针或引用传递对象不同,值传递不存在关于所有权、生命周期、可变性以及类似问题的混淆风险,也无需在契约中对这些进行说明。它还避免了客户端与实现之间的非局部交互,从而让代码更易于理解与维护,并且更利于编译器进行优化。此外,这类对象可以用于要求值传递的通用 API(例如大多数容器),并且在类型组合等方面提供了额外的灵活性。
复制/移动构造函数和赋值运算符通常比 Clone()、CopyFrom() 或 Swap() 等替代方案更容易正确定义,因为它们可以由编译器生成(无论是隐式生成还是使用 = default)。这使得代码非常简洁,并能确保所有数据成员都被复制。复制和移动构造函数通常也更加高效,因为它们不需要堆内存分配,也不需要分开的初始化和赋值步骤,并且有资格接受诸如“复制消除”之类的优化。
移动操作允许从右值对象中隐式且高效地转移资源。这在某些情况下允许采用更朴素、直观的编码风格。
缺点:
有些类型根本不需要支持复制,为这些类型提供复制操作可能会引起混淆、毫无意义,甚至是完全错误的。代表单例对象(如 Registerer)、与特定作用域绑定的对象(如 Cleanup),或与对象身份紧密耦合的对象(如 Mutex)都无法进行有意义的复制。对于那些需要进行多态使用的基类类型,其复制操作是危险的,因为使用这些操作会导致对象切片。此外,编译器默认生成的或草率实现的复制操作可能是不正确的,而由此产生的 Bug 往往令人困惑且难以诊断。
拷贝构造函数是隐式调用的,这使得调用行为很容易被忽视。对于习惯于使用“引用传递”作为惯例或强制要求的语言的程序员来说,这可能会引起混淆。它也可能鼓励过度复制,从而导致性能问题。
结论:
每个类的公共接口必须明确表明该类支持哪些拷贝和移动操作。这通常通过在类声明的 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;
};只有在这些操作是显而易见的情况下,才可以省略这些声明/删除操作:
如果类没有私有部分(例如结构体或仅包含接口的基类),那么其可复制性/可移动性将取决于其任何公有数据成员的可复制性/可移动性。
如果基类明确不可复制或不可移动,派生类自然也不会是可复制或可移动的。 但是,一个仅包含接口且对这些操作保持隐式的基类,不足以让具体的派生类变得清晰明确。
注意: 如果你显式声明或删除了拷贝操作中的构造函数或赋值运算符(二者之一),那么另一个拷贝操作就不再是显而易见的,因此必须显式声明或删除它。移动操作同理。
如果对于普通用户来说,复制/移动的含义不清晰,或者会带来意想不到的开销,那么该类型就不应该支持复制/移动。对于可复制的类型来说,移动操作严格来说只是一种性能优化。同时,移动操作也是潜在的 Bug 和复杂性的来源,因此除非移动操作比对应的复制操作效率显著更高,否则应避免定义它们。如果你的类型提供了复制操作,建议你设计类的方式使得这些操作的默认实现是正确的。请记住,你应该像对待任何其他代码一样,仔细审查任何“默认生成”的操作是否正确。
为了消除对象切片的风险,建议通过以下方式将基类设为抽象类:将其构造函数设为 protected,将其析构函数设为 protected,或者为其定义一个或多个纯虚函数。建议尽量避免从具体的(非抽象的)类进行派生。
结构体(struct)VS 类(class)
仅将 struct 用于携带数据的被动对象;除此之外的一切都应使用 class。
在 C++ 中,struct 和 class 关键字的行为几乎完全相同。我们为这两个关键字赋予了特定的语义含义,因此你应该为你正在定义的数据类型使用恰当的关键字。
struct 应该仅用于那些携带数据的被动对象,并且可以包含相关的常量。所有字段必须是公有的。struct 类型本身不应该拥有暗示字段间关系的不变式,因为用户对这些字段的直接访问可能会破坏这些不变式;不过,struct 的使用者可能对它的特定用法有特定的要求和保证。struct 可以包含构造函数、析构函数和辅助方法;但是,这些方法不得要求或强制执行任何不变式。
如果需要更多的功能或不变式,或者 struct 具有较广泛的可见性且预期会不断演化,那么使用 class 更为合适。如果拿不准,就把它做成 class。
为了与 STL(标准模板库)保持一致,你可以对无状态的类型(例如类型特征、模板元函数以及某些函数对象)使用 struct 而不是 class。
注意: 结构体和类中的成员变量有不同的命名规则。
结构体(structs)VS Pairs 和 Tuples
只要元素能够拥有有意义的名称,就应优先使用 struct 而不是 pair 或 tuple。
虽然使用 pair 和 tuple 可以避免定义自定义类型,从而在编写代码时可能节省工作量,但在阅读代码时,有意义的字段名通常比 .first、.second 或 std::get<X> 清晰得多。尽管 C++14 引入了通过类型来访问 tuple 元素(std::get<Type>)的功能(当类型唯一时),在某种程度上缓解了这一问题,但一个字段名通常比类型本身更具可读性和信息量。
适用场景: pair 和 tuple 可能适用于那些元素本身没有特定含义的泛型代码中。此外,为了与现有的代码或 API 进行互操作时,也可能需要使用它们
| 特性 | struct | std::pair | std::tuple |
| 成员数量 | 任意多个 | 固定为 2 个 | 任意多个 |
| 成员访问 | 通过自定义名字 .name | 通过 .first, .second | 通过索引 get<0>(t) |
| 语义清晰度 | 高(代码自解释) | 中等 | 低(需要记住索引含义) |
| 适用场景 | 正式的数据模型、业务对象 | 临时组合两个值 | 临时组合、函数多返回值 |
继承
组合通常比继承更合适。如果使用继承,请务必将其设为公有的。
定义:
当一个子类继承一个基类时,它包含了基类定义的所有数据和操作的定义。“接口继承”是指从纯抽象基类(即没有任何状态或已实现方法的类)进行继承;而所有其他形式的继承则被称为“实现继承”。
优点:
实现继承通过复用基类的代码来缩小代码体积,从而对现有类型进行特化。因为继承是一种编译期声明,你和编译器都能够理解其操作并检测错误。
接口继承可用于从程序上强制规定一个类暴露特定的 API。同样,编译器能够检测错误——在该情况下,即当一个类未定义 API 中必要的方法时。
缺点:
对于实现继承,由于子类的实现代码分散在基类和子类之间,理解其实现逻辑可能会更加困难。此外,子类无法重写非虚函数,这意味着子类无法改变这些函数的实现。
多重继承的问题尤其严重,原因有二:首先,它往往伴随着更高的性能开销(事实上,从单继承转变为多继承所带来的性能下降,往往比从普通函数调用转变为虚函数调用的性能下降还要大);其次,它容易导致“菱形”继承模式,从而引发歧义、混乱以及各种严重的 Bug。
结论:
所有继承都应该是公有的。 如果你想进行私有继承,你应该改为将基类的一个实例作为成员变量包含进来。当你不打算将某个类用作基类时,可以将其标记为 final。
不要过度使用实现继承。 组合通常更为合适。请尽量将继承的使用限制在“是一个(is-a)”的场景:只有当 Bar 确实是 Foo 的一种时,Bar 类才应该继承 Foo 类。
限制 protected 的使用范围,仅将其用于那些可能需要在子类中被访问的成员函数。请注意,数据成员应该是私有的。
明确地使用 override 或 final 说明符(通常只使用其中一个)来标注虚函数或虚析构函数的重写。在声明重写时,不要使用 virtual 关键字。理由: 如果一个标记了 override 或 final 的函数实际上并没有重写基类的虚函数,编译将无法通过,这有助于捕获常见错误。这些说明符起到了文档注释的作用;如果没有这些说明符,读者就必须去检查该类的所有祖先类,才能确定该函数是否为虚函数。
允许多重继承,但强烈不建议多重实现继承。
运算符重载
审慎地重载运算符。不要使用用户定义字面量。
定义:
C++ 允许用户代码使用 operator 关键字,为内置运算符声明重载版本,前提是(重载函数的)至少有一个参数是用户定义的类型。operator 关键字还允许用户代码使用 operator"" 定义新型的字面量,并定义类型转换函数(例如 operator bool())。
优点:
运算符重载可以通过让用户定义的类型像内置类型一样工作,从而使代码更加简洁和直观。重载的运算符是某些操作的习惯性命名方式(例如 ==、<、= 和 <<),遵循这些约定可以使用户定义的类型更具可读性,并使其能够与那些依赖这些名称的库进行互操作。
用户定义字面量是创建用户定义类型对象的一种非常简洁的表示法。
缺点:
提供一套正确、一致且符合直觉的运算符重载需要格外小心,如果未能做到这一点,可能会导致混淆和 Bug。
运算符的过度使用会导致代码晦涩难懂,特别是当重载运算符的语义不符合惯例时。
函数重载所带来的隐患,对于运算符重载来说有过之而无不及。
运算符重载可能会误导我们的直觉,让我们误以为昂贵的操作其实是廉价的内置操作。
查找重载运算符的调用点,可能需要使用能够理解 C++ 语法的搜索工具,而不能简单地使用 grep 等文本搜索工具。
如果你搞错了重载运算符的参数类型,你可能会无意中调用了一个不同的重载版本,而不是触发编译器报错。例如,foo < bar 可能是一种行为,而 &foo < &bar 却可能是完全不同的另一种行为。
某些运算符重载在本质上就是危险的。重载一元运算符 & 可能会导致同一段代码在重载声明是否可见的情况下具有不同的含义。重载 &&、|| 和 ,(逗号)运算符无法匹配内置运算符的求值顺序语义。
运算符通常在类外部定义,因此存在不同的文件引入了同一个运算符不同定义的风险。如果这两个定义被链接到同一个二进制文件中,将导致未定义行为,这可能会表现为难以捉摸的运行时 Bug。
用户定义字面量(UDLs)允许创建新的语法形式,这对于即使是经验丰富的 C++ 程序员来说也是陌生的,例如用 "Hello World"sv 作为 std::string_view("Hello World") 的简写。相比之下,现有的显式写法虽然不够简短,但更加清晰。
由于用户定义字面量无法使用命名空间进行限定(不能写成 std::string_view_literals::operator""sv 这种形式调用),使用 UDL 时必须配合 using 指令(我们在全局范围内禁止使用)或者 using 声明(我们在头文件中禁止使用,除非导入的名称是该头文件对外暴露接口的一部分)。考虑到头文件必须避免使用 UDL 后缀,我们倾向于避免让字面量的使用惯例在头文件和源文件之间有所不同。
结论:
仅在运算符的含义显而易见、符合直觉且与相应的内置运算符保持一致时,才定义重载运算符。 例如,将 | 用作按位或/逻辑或,而不是用作 shell 风格的管道。
仅在你自己的类型上定义运算符。 更准确地说,应在与操作对象类型相同的头文件、.cc 文件和命名空间中定义它们。这样,运算符将在类型可用的任何地方都可用,从而最大限度地降低多重定义的风险。如果可能,避免将运算符定义为模板,因为对于任何可能的模板参数,它们都必须满足这一规则。如果定义了一个运算符,也应定义任何相关的、有意义的运算符,并确保它们的一致性。
倾向于将不修改左操作数的二元运算符定义为非成员函数。 如果将二元运算符定义为类成员,隐式转换将仅适用于右操作数,而不适用于左操作数。如果 a + b 能编译通过但 b + a 却不能,这会让你的用户感到困惑。
对于其值可进行相等性比较的类型 T,应定义一个非成员的 operator==,并记录在什么情况下两个 T 类型的值被视为相等。 如果在判断一个 T 类型的值 t1 是否小于另一个 T 类型的值 t2 时存在一种单一且明显的逻辑,那么你也可以定义 operator<=>(三路比较运算符),但它应与 operator== 保持一致。尽量避免重载其他的比较和排序运算符(如单独的 >, <= 等)。
不要刻意避免定义运算符重载。 例如,倾向于定义 ==、= 和 <<,而不是使用 Equals()、CopyFrom() 和 PrintTo() 这样的函数。相反,也不要仅仅因为其他库需要就去重载运算符。例如,如果你的类型没有自然的排序规则,但你想将其存储在 std::set 中,应使用自定义比较器,而不是重载 < 运算符。
不要重载 &&、||、,(逗号)或一元 &。不要重载 operator"",即不要引入用户定义字面量。也不要使用其他人(包括标准库)提供的此类字面量。
类型转换运算符的内容包含在关于“隐式转换”的章节中。赋值运算符 = 的内容包含在关于“拷贝构造函数”的章节中。关于重载 << 用于流操作的内容包含在关于“流”的章节中。有关函数重载的规则同样适用于运算符重载,请参阅相关章节。
访问控制
将类的数据成员设为私有(private),除非它们是常量(constants)。 这种做法简化了对类不变式(invariants)的推理和维护,代价是在必要时需要编写一些简单的样板代码(boilerplate),即访问器(accessors,通常为 const 类型)。
出于技术原因,当使用 Google Test 时,我们允许在 .cc 文件中定义的测试夹具(test fixture)类的数据成员为受保护(protected)状态。 如果测试夹具类是在 .cc 文件之外定义的(例如在 .h 头文件中),则应将数据成员设为私有。
声明顺序
将相似的声明放在一起,并将公有(public)部分置于前面。
类定义通常应该以 public: 部分开始,接着是 protected:,最后是 private:。如果某个部分为空,则省略该部分。
在每个访问控制段内,建议将相似类型的声明归组在一起,并建议遵循以下顺序:
类型与类型别名
包含:typedef、using、enum、嵌套的 struct 和 class,以及友元类型声明。
说明:这些通常定义了类所需的类型环境,放在最前面便于阅读。
(可选,仅针对结构体)非静态数据成员
说明:如果是纯数据的 struct,有时会将数据成员放在类型声明之后、函数之前。但在普通类中,数据成员通常放在最后。
静态常量
说明:如 static const int kMaxSize; 或 inline constexpr 静态常量。
工厂函数
说明:用于创建类实例的静态方法,如 Create() 或 Instance()。
构造函数与赋值运算符
说明:包括默认构造、带参构造、拷贝/移动构造,以及拷贝/移动赋值运算符。
析构函数
说明:通常显式写出来,即使使用默认行为(为了虚析构或明确语义)。
其他所有函数
说明:包含静态成员函数、非静态成员函数,以及友元函数。
其他所有数据成员
说明:包含静态数据成员和非静态数据成员。通常建议将数据成员集中放在类的最后,以突出接口。
不要在类定义中内联(inline)放置大型方法的定义。通常,只有微不足道的、对性能至关重要的,且非常短小的方法,才可以被定义为内联函数。更多细节请参阅“在头文件中定义函数”一节。

晋公网安备14030302000174号 |