3.1. 构造函数的内部操作
1. 构造函数操作:构造函数中不允许调用虚函数。
2. 可能失败的初始化:在构造函数中不应进行可能失败的初始化,且应有错误处理机制。
3. 定义:构造函数用于执行对象的初始化操作。
4. 优点:
- 对象完全初始化后可以是 `const` 类型。
- 便于在标准容器或算法中使用。
5. 缺点:
- 构造函数内调用虚函数不会分派到子类实现,存在潜在风险。
- 构造函数难以报告错误,通常只能导致程序崩溃或使用异常(尽管异常使用被禁止)。
- 失败的初始化可能导致对象处于异常状态,需要额外的状态检查机制。
- 构造函数的工作难以转交给其他线程执行。
6. 结论:
- 构造函数设计应避免调用虚函数。
- 错误处理可以通过终止程序或定义 `Init()` 方法/工厂函数来实现。
- 如果添加 `Init()` 方法,应确保对象状态能反映哪些公共方法是可用的。
- 避免在对象未完全构造时调用其方法,以防止错误。
3.2. 隐式类型转换
1. 避免隐式类型转换:不要定义隐式类型转换,对于类型转换运算符和单参数构造函数,使用 `explicit` 关键字。
explicit是C++中的一个关键字,它用来修饰只有一个参数的类构造函数,以表明该构造函数是显式的,而非隐式的。当使用explicit修饰构造函数时,它将禁止类对象之间的隐式转换,以及禁止隐式调用拷贝构造函数。
【C++】explicit关键字详解(explicit关键字是什么? 为什么需要explicit关键字? 如何使用explicit 关键字)-CSDN博客
2. 定义:
- 隐式类型转换允许在需要目标类型的地方使用源类型的对象。
- 通过定义特定成员或构造函数,可以在类中添加自定义转换。
3. explicit 关键字:确保使用者必须明确指定目标类型,适用于构造函数、类型转换运算符和列表初始化。
4. 优点:
- 隐式类型转换可以使代码更简洁,简化函数重载。
- 列表初始化语法简洁。
5. 缺点:
- 可能掩盖类型不匹配错误,降低代码可读性。
- 单参数构造函数可能被意外当作隐式类型转换。
- 可能导致调用歧义。
6. 结论:
- 类型转换运算符和单参数构造函数应标记为 `explicit`,除非是拷贝或移动构造函数。
- 对于可互换的类型,隐式类型转换可能是恰当的,但需要申请豁免。
- 接受多个参数或单个 `std::initializer_list` 参数的构造函数可以省略 `explicit` 标记。
7. 注意事项:
- 隐式类型转换需要谨慎使用,以避免潜在问题。
- 显式标记 `explicit` 可以提高代码的可读性和安全性。
3.3. 可拷贝类型和可移动类型
1. 类的接口明确性:类的公有接口必须明确指明是否支持拷贝、移动或都不支持。
2. 定义:
- 可移动类型可以用临时变量进行初始化或赋值。
- 可拷贝类型可以用另一个相同类型的对象初始化,源对象状态不变。
- 可拷贝类型指的是可以通过复制构造函数或拷贝赋值运算符来生成对象的副本的类类型。
- 可移动类型是指可以通过移动构造函数或移动赋值运算符来转移对象所拥有资源的类类型,而不是复制资源。通过指针或引用来完成的,不需要复制整个对象的内容,因此可以提高性能。
3. 优点:
- 值传递使API更简单、安全、通用。
- 避免了所有权、生命周期和可变性的混乱。
- 值传递的对象可以在通用API和类型组合中使用,提高代码可读性和易于维护。
4. 缺点:
- 某些类型不应支持拷贝,如单例对象、特定作用域类型或与其他对象耦合的类型。
- 拷贝构造函数的隐式调用可能导致忽视和过度拷贝,引发性能问题。
5. 结论:
- 类的公有接口应明确支持的拷贝和移动操作。
- 应在public部分显式声明或删除对应的操作。
- 可拷贝类应显式声明拷贝运算符,仅能移动的类应显式声明移动运算符,都不支持的类应显式删除复制运算符。
1. 公有接口的省略条件:如果一个类没有私有部分(如结构体或纯接口基类),其可拷贝性和可移动性可以由公有数据成员的属性决定。
2. 基类的可移动性和可拷贝性:如果基类明显是不可移动或不可拷贝的,派生类也继承这一特性。纯接口基类的隐式声明不足以明确子类的可复制性或可移动性。
3. 显式声明或删除操作:如果显式声明或删除了拷贝构造函数或拷贝赋值运算符之一,也必须对另一个做同样的处理。同样的规则适用于移动操作。
4. 设计为不可拷贝或不可移动的情况:如果类的拷贝或移动操作可能被用户误解,或可能带来意外开销,应设计为不可拷贝或不可移动。
5. 移动操作的性能优化:移动操作是作为性能优化手段,可能会增加复杂性和错误。除非明显更高效,否则不应定义移动操作。
6. 可拷贝类型的默认实现:如果类是可拷贝的,应确保自动生成的默认实现是正确的,并像检查自己编写的代码一样检查其正确性。
7. 避免对象切割:为了避免对象切割的风险,基类最好是抽象的。声明抽象类可以通过将构造函数或析构函数声明为 protected
,或声明纯虚函数。
8. 继承的推荐:应避免继承具体类,以保持类的抽象性和灵活性。
3.4. 结构体还是类
1. 关键字含义:C++中`struct`和`class`关键字在技术上几乎相同,但语义上有所区别。
2. 使用场景:
- 结构体(struct):用于定义被动的数据存储对象,可能包含常量成员。所有成员默认为公共访问(public)。结构体的成员之间不维护不变式,因为它们可以被直接访问和修改。
- 类(class:当需要实现功能、维护不变式约束,或者对象用途广泛且可能不断更新时使用。
3. 功能和方法:
- 结构体可以拥有构造函数、析构函数和辅助方法,但这些方法不负责维护不变式。
- 类更适合实现复杂功能和维护对象状态的完整性。
4. 选择关键字的指导:
- 在不确定时,应选择使用`class`。
- 对于无状态的类型,如特性(trait)、模板元函数、仿函数等,为了与STL(标准模板库)保持一致,应使用`struct`而不是`class`。
5. 命名规则:类和结构体的成员变量应遵循不同的命名规则,尽管文中没有具体说明这些规则是什么。
3.5. 结构体、数对还是元组
1. 有意义的成员名称:如果能够给成员赋予有意义的名称,应优先使用结构体而不是`std::pair`或`std::tuple`。
2. 代码可读性:使用有意义的成员名称比使用`.first`、`.second`或`std::get<X>`更可读,有助于提高代码的清晰度和易维护性。
3. C++14特性:C++14引入了基于类型的访问方法`std::get<Type>`,允许通过元素类型而非下标来访问`std::tuple`中的元素,这改善了元组的使用体验。
4. 数对和元组的适用场景:
- 数对和元组适合用于通用代码场景,尤其是当元素没有特定含义时。
- 在与现有代码或API交互时,可能需要使用数对和元组。
5. 自定义类型的优势:尽管定义自定义类型可能需要更多的编码工作,但有意义的成员名称可以提供更好的语义清晰度,从而使得代码更易于理解和使用。
3.6. 继承
1. 组合与继承:通常情况下,组合(composition)比继承(inheritance)更合适,推荐使用公有继承(public inheritance)。
2. 定义:
- 接口继承:从纯抽象基类(不含状态或方法定义)继承。
- 实现继承:除接口继承外的所有继承。
3. 优点:
- 实现继承可以复用基类代码,减少代码量。
- 继承在编译时声明,有助于编译器检查错误。
- 接口继承可以强制类实现特定API。
4. 缺点:
- 对于实现继承使子类代码分散,难以理解。且子类不能重写父类的非虚函数。
- 多重继承可能导致性能开销、菱形继承问题和歧义。
5. 结论:
- 继承应使用公有访问权限,私有继承可以通过组合实现。
- 不希望类被继承时,使用`final`关键字。
- 避免过度使用实现继承,组合通常更合适。
- 只在"是什么"关系下使用继承,例如Bar是Foo的一种时。
6. 成员访问控制:
- 仅将子类可能需要的成员函数设为受保护(protected)。
- 数据成员应保持私有。
7. 虚函数和重写:
- 使用`override`或`final`关键字明确限定重写的虚函数或虚析构函数。
- 避免在重写时使用`virtual`关键字。
- 这些关键字有助于编译时检查错误,相当于文档。
8. 多重继承:
- 允许多重继承,但强烈建议避免多重实现继承,以减少复杂性和潜在问题。
3.7. 运算符重载
1. 运算符重载的定义与前提:
- 使用`operator`关键字重载内置运算符,其中一个参数必须是用户自定义类型。
- 使用`operator""`定义新的字面量或类型转换函数。
2. 运算符重载的优点:
- 提高代码的简洁性和直观性。
- 使自定义类型易于与内置类型和库进行互操作。
3. 运算符重载的缺点:
- 需要正确、一致地实现,否则易引起混淆和错误。
- 过度使用可能导致代码难以理解。
- 与函数重载一样,存在潜在的弊端。
- 可能被误认为是快速的内置运算。
- 难以使用通用工具进行搜索和列出调用者。
- 参数类型错误可能导致调用错误的重载函数。
- 某些运算符重载可能导致运行时错误或未定义行为。
-
要列出重载运算符的调用者, 需要能理解 C++ 语法的搜索工具, 无法使用
grep
等通用工具. -
如果重载运算符的参数类型错误, 您可能会调用一个完全不同的重载函数, 而不会得到编译错误. 例如:
foo < bar
和&foo < &bar
会执行完全不同的代码.
4. 自定义字面量的定义:
- 提供创建自定义类型对象的简洁写法。
5. 自定义字面量的缺点:
- 创造新的语法,可能让经验丰富的程序员感到陌生。
- 不能限制在命名空间中,使用时需要`using`指令或声明。
- 头文件中不宜使用,以避免与源文件的格式不一致。
6. 最佳实践:
- 通常在类定义之外定义运算符,以避免链接时的未定义行为。
7. 结论:
1). 运算符重载的基本原则:
- 运算符重载应意义明确、符合常理,并与内置运算符行为一致。
2). 运算符重载的范围:
- 只为自己定义的类型定义运算符重载。
- 运算符和对应的类型应在同一头文件、`.cc`文件和命名空间中定义。
3). 避免多重定义:
- 通过将运算符与类型定义在一起,避免多重定义的风险。
4). 模板与运算符重载:
- 尽量避免用模板定义运算符,以免每个模板类型都必须满足定义要求。
5). 相关运算符的一致性:
- 定义运算符时,同时定义相关且有意义的运算符,并保证语义一致。
6). 二元运算符的定义方式:
- 建议将不修改数据的二元运算符定义为非成员函数。
- 如果二元运算符是成员函数,右侧参数可以隐式类型转换,而左侧不能。
7). 比较运算符的定义:
- 对于可以判断相等性的类型T,定义`operator==`,并说明相等的条件。
- 如果类型有“小于”的概念,定义`operator<=>`,并与`operator==`逻辑一致。
8). 比较、排序运算符的使用:
- 不建议重载其他比较、排序运算符。
9). 避免过度或不当的运算符重载:
- 定义自然且必要的运算符,如`==`, `=`, `<<`,而不是 Equals()
, CopyFrom()
和 PrintTo()
。
- 不要仅仅因为其他库需要而定义运算符。
10). 禁止重载的运算符:
- 不要重载`&&`, `||`, `,`或一元的`&`运算符,不要重载 operator""
。
- 不要引入或使用自定义字面量。
11). 其他相关规则:
- 类型转换运算符、赋值运算符、流操作的`<<`运算符的定义应遵循特定规则。
- 函数重载的规则同样适用于运算符重载。
3.8. 访问控制
1. 数据成员的默认可见性:
- 类的所有数据成员应该声明为私有(`private`),除非它们是常量。
2. 私有成员的好处:
- 简化类的不变式(invariant)逻辑。
3. 私有成员的代价:
- 需要增加访问器(accessor)代码,通常是`const`方法。
4. 特殊情况下的例外:
- 在使用Google Test时,允许在`.cc`文件中将测试夹具类(test fixture class)的数据成员声明为受保护的(`protected`)。
5. 测试夹具类数据成员的可见性规则:
- 如果测试夹具类的声明位于使用该夹具的`.cc`文件之外(例如在`.h`文件中),则数据成员应设为私有。
3.9. 声明次序
将相似的声明放在一起. 公有 (public) 部分放在最前面.
类的定义通常以 public:
开头, 其次是 protected:
, 最后以 private:
结尾. 空的部分可以省略.
在各个部分中, 应该将相似的声明分组, 并建议使用以下顺序:
-
类型和类型别名 (
typedef
,using
,enum
, 嵌套结构体和类, 友元类型) -
(可选, 仅适用于结构体) 非静态数据成员
-
静态常量
-
工厂函数 (factory function)
-
构造函数和赋值运算符
-
析构函数
-
所有其他函数 (包括静态与非静态成员函数, 还有友元函数)
-
所有其他数据成员 (包括静态和非静态的)
不要在类定义中放置大段的函数定义. 通常, 只有简单、对性能至关重要且非常简短的方法可以声明为内联函数. 参见 内联函数 一节.