Effective C++
条款 05:了解C++编译器会自动生成哪些函数
在C++中,编译器会为一个空类(即没有成员变量和成员函数的类)自动生成以下四种特殊成员函数:
- 默认构造函数:如果没有声明任何构造函数,编译器会提供一个默认构造函数。
- 拷贝构造函数:用于将一个对象的状态复制到另一个对象中。
- 拷贝赋值运算符:用于将一个对象赋值给另一个对象。
- 析构函数:用于在对象销毁时执行清理工作。
需要注意的是:
- 当类中包含
const
或reference
成员时,编译器不会自动生成拷贝赋值运算符。这是因为const
成员不能被重新赋值,reference
成员的指向也不能被更改。在这种情况下,需要程序员自己编写拷贝赋值运算符。
条款 06:禁止类对象的拷贝操作
为了禁止类对象的拷贝操作(即禁止拷贝构造和拷贝赋值),我们需要显式地声明这两个函数并将它们设为私有(private)。这样可以防止类的外部代码调用这些函数。
解决方案1:将拷贝构造函数和拷贝赋值运算符声明为私有
1 | class A { |
问题:虽然在类的外部不能调用这两个私有函数,但是类的成员函数和友元函数仍然可以调用这些私有函数。
解决方案2:使用基类阻止拷贝
定义一个基类来阻止拷贝操作,通过将拷贝构造函数和拷贝赋值运算符声明为私有且不定义,使得派生类无法调用这些函数。
1 | class Uncopyable { |
在这种情况下,即使是类A
的成员函数和友元函数,在尝试拷贝A
的对象时,编译器会尝试生成拷贝构造函数和拷贝赋值运算符,但由于基类Uncopyable
的对应函数是私有的,子类无法调用,从而实现了拷贝操作的禁止。
条款 07:为多态基类声明virtual析构函数
当一个基类指针指向一个子类对象时,如果基类的析构函数不是virtual
的,在删除基类指针时,只会调用基类的析构函数,而不会调用子类的析构函数,这会导致子类对象的资源无法正确释放,造成内存泄漏。
任何带有virtual
函数的类都应该有一个virtual
析构函数,以确保当指向派生类对象的基类指针被删除时,会正确调用派生类的析构函数,释放派生类对象的资源。
虚函数的实现原理
- 虚函数表(vtbl):每个包含
virtual
函数的类都有一个虚函数表,表中存储的是指向各个虚函数的函数指针。 - 虚指针(vptr):每个对象内存空间内有一个虚指针,这个虚指针指向类的虚函数表。
- 虚函数调用:当对象调用某个
virtual
函数时,编译器通过对象的虚指针找到类的虚函数表,在表中寻找适当的函数指针进行调用。
注意事项
- 不作为基类使用的类不要声明析构函数为
virtual
,因为虚表和虚指针会占用额外的内存。 std::string
和STL容器的析构函数都是非virtual
的,不要将这些类作为基类使用。
通过理解和遵循这些规则,可以有效地控制C++类的行为,避免潜在的错误和内存泄漏。
条款 08:别让异常逃离析构函数
C++并不禁止析构函数抛出异常,但这是不建议的。原因是如果有两个异常同时存在(例如一个异常在析构函数中抛出,另一个异常在异常处理过程中产生),程序要么会终止,要么会导致不明确的行为。因此,必须避免析构函数抛出异常。如果确实无法避免,可以采用以下两种方法:
方法1:在析构函数抛出异常时调用 abort 结束程序
1 | A::~A() { |
方法2:吞下异常,当做什么也没发生过
1 | A::~A() { |
条款 09:绝不在构造和析构过程中调用 virtual 函数
在子类对象调用子类构造函数之前,会先调用父类构造函数。如果子类重写了父类中的一个虚函数func()
,而父类的构造函数中调用了这个虚函数,那么在父类构造函数执行到调用func()
时,实际调用的是父类版本的func()
。这是因为在父类的构造函数执行时,子类的成员变量尚未初始化,如果此时调用虚函数并下降到子类层次,会存在使用未初始化成员的风险。因此,C++禁止在构造和析构过程中调用虚函数。
更根本的原因
在调用父类构造函数期间,对象类型是基类而不是子类。不仅虚函数会使用父类版本,此时使用 dynamic_cast
和 typeid
也会把对象视为基类类型。同样的原因适用于析构函数。
条款 10:令 operator= 返回一个绑定到 *this
的引用
这是为了实现连锁赋值,如下例所示:
1 | class A { |
连锁赋值的例子:
1 | int x, y, z; |
条款 11:在 operator= 中处理自我赋值
自我赋值的意思是等号右边和左边的对象是同一个对象(地址相同),例如 a[i] = a[j];
当 i == j
时。这种情况下,需要在赋值前检查自我赋值,以避免不必要的操作和潜在的问题。
一般的做法是在赋值前做一个检查
1 | class A { |
条款 12:赋值对象时勿忘其每一个成分
在实现复制函数(拷贝构造函数和拷贝赋值运算符)时,需要确保所有成员变量都被正确复制。如果自定义了复制函数,编译器将不会生成默认的复制函数。因此,必须小心处理每个成员变量。
1. 修改复制函数以处理新成员变量
每次为类添加一个新成员变量时,必须同时更新复制函数以处理该成员变量。
2. 子类复制函数中的基类成员复制
为子类自定义复制函数时,需要复制基类的成员变量。由于基类成员通常是私有的,子类无法直接访问,因此子类的复制函数应该调用相应的基类复制函数。
不建议的做法
不能为了简化代码而在拷贝构造函数中调用拷贝赋值运算符,或者在拷贝赋值运算符中调用拷贝构造函数。原因是构造函数用于初始化新对象,而赋值运算符只能用于已初始化的对象。
建议的做法
可以建立一个新的成员函数给复制函数调用,这个函数通常是私有的且命名为 init
。例如:
1 | class A { |
条款 16:成对使用 new 和 delete
使用规则
- 使用
new
创建对象时,必须使用delete
删除对象。 - 使用
new[]
创建数组时,必须使用delete[]
删除数组。
条款 30:了解 inline 的里里外外
inline
函数
inline
函数在每个调用处都用函数本体替代,以减少函数调用的开销。然而,这会增加目标代码的大小,可能导致额外的分页行为,降低缓存命中率,最终可能导致性能损失。
inline
虚函数
对虚函数进行 inline
是无意义的,因为虚函数在运行时确定,而 inline
在编译期替换。编译器通常不会对通过函数指针进行调用提供 inline
支持,是否 inline
取决于调用的方式。
条款 34:区分接口继承和实现继承
public
继承
public
继承包括函数接口继承和函数实现继承。
纯虚函数
纯虚函数有两个特性:
- 必须被任何继承它们的具体类重新声明。
- 在抽象类中通常没有定义。
声明纯虚函数的目的是让派生类只继承函数接口,提供接口后,派生类根据自身需求去实现。
非纯虚函数
声明非纯虚函数的目的是让派生类继承函数的接口和默认实现。如果派生类不想重新实现(override)函数,可以使用基类提供的默认版本。
非虚函数
声明非虚函数的目的是令派生类继承函数接口和一份强制性实现。非虚函数代表不变性,不应该在派生类中重新定义。这意味着派生类不应尝试修改此类函数。
通过这些规则,可以确保类的复制行为正确,同时保证接口和实现的明确区分,提高代码的可维护性和可靠性。
条款 35:考虑 virtual 函数以外的其他选择
在设计类时,除了使用 virtual
函数外,还可以考虑以下替代方案:
1. Non-virtual Interface(NVI)
使用 public
的非虚成员函数来调用访问性较低的(private
或 protected
)虚函数。这种模式通过一个公共接口间接调用虚函数,从而增加了灵活性和控制力。
1 | class Base { |
2. 用函数指针替换 virtual 函数
在某些情况下,可以用函数指针成员变量替代 virtual
函数,从而实现类似的多态行为。
1 | class Base { |
3. 用 std::function 替换 virtual 函数
使用 std::function
成员变量代替 virtual
函数,可以增加灵活性,同时避免一些继承带来的复杂性。
1 |
|
条款 36:绝不重新定义继承而来的非虚函数
public
继承意味着每个派生类对象都是一个基类对象,非虚函数(静态绑定)会继承基类的接口和实现。重新定义会导致设计矛盾和行为的不一致。如果派生类需要不同的行为,不应该使用 public
继承。
条款 37:绝不重新定义继承而来的缺省参数值
缺省参数(Default Parameter Value)值是静态绑定的,而虚函数是动态绑定的。重新定义继承的缺省参数值会导致行为不一致,因为调用函数时的参数值取决于静态类型,而函数调用本身是动态绑定的。
条款 39:明智而审慎地使用 private 继承
private
继承在以下情况下是适当的:
- 派生类需要访问基类的受保护成员。
- 派生类需要重新定义继承来的虚函数。
private
继承表示“实现继承”而不是“接口继承”,即基类的接口不会暴露在派生类的接口中。
1 |
|
在上述示例中,Derived
类以 private
方式继承自 Base
类。它能够访问基类的受保护成员 protectedData
,并且重新定义了虚函数 virtualMethod
。但由于是 private
继承,在外部不能通过 Derived
类的对象直接访问 Base
类的公共成员。
条款 40:明智而审慎地使用多重继承
多重继承允许一个类同时继承多个基类。需要注意以下几点:
- 使用虚拟继承以确保派生类中只有一份基类的数据。
- 虚拟继承会增加大小、速度和初始化的成本。
- 最好避免在虚基类中放置数据,或者尽量减少数据的放置。
1 | class A { |
条款 44:将与参数无关的代码抽离模板
模板会生成多个类和多个函数,因此任何模板代码都不应与特定的模板参数产生依赖关系,以避免代码膨胀。
因非类型模板参数导致的代码膨胀
可以通过函数参数或类成员变量替换模板参数来消除膨胀。
1 | template<int N> |
因类型模板参数导致的代码膨胀
可以通过让具有相同二进制表示的实例共享实现代码来减少膨胀。
1 | template<typename T> |
通过这些技术,可以提高代码的可维护性,减少代码膨胀,并提高运行时的效率。
条款 45:运用成员函数模板接受所有兼容类型
当你声明成员函数模板来实现“泛化的拷贝构造”或“泛化的赋值操作”时,仍然需要声明正常的拷贝构造函数和拷贝赋值运算符。原因是编译器在某些情况下需要使用这些正常版本,例如,当需要合成某些特殊的成员函数时。
1 | class A { |
条款 46:需要类型转换时请为模板定义非成员函数
当编写一个类模板,并且希望提供与此模板相关的函数支持所有参数的隐式类型转换时,请将这些函数定义为类模板内部的 friend
函数。
1 | template<typename T> |
条款 47:请使用 traits classes 表现类型信息
Traits classes 使得类型相关信息在编译期可用。它们通过模板和模板特化来实现,并可以在编译期执行类似 if...else
的测试。
1 | template<typename T> |
条款 48:认识模板元编程
模板元编程(Template Metaprogramming,TMP)可以将工作由运行期移到编译期,从而实现早期错误检测和更高的执行效率。TMP 可以用于生成基于策略选择组合的客户定制代码,也可以避免生成对某些特殊类型不适合的代码。
1 | // 判断是否为指针类型的元编程 |
通过这些技术,可以在编译期执行复杂的类型检查和优化,从而提高代码的健壮性和执行效率。