Effective C++

Channing Hsu

条款 05:了解C++编译器会自动生成哪些函数

在C++中,编译器会为一个空类(即没有成员变量和成员函数的类)自动生成以下四种特殊成员函数:

  1. 默认构造函数:如果没有声明任何构造函数,编译器会提供一个默认构造函数。
  2. 拷贝构造函数:用于将一个对象的状态复制到另一个对象中。
  3. 拷贝赋值运算符:用于将一个对象赋值给另一个对象。
  4. 析构函数:用于在对象销毁时执行清理工作。

需要注意的是:

  • 当类中包含constreference成员时,编译器不会自动生成拷贝赋值运算符。这是因为const成员不能被重新赋值,reference成员的指向也不能被更改。在这种情况下,需要程序员自己编写拷贝赋值运算符。

条款 06:禁止类对象的拷贝操作

为了禁止类对象的拷贝操作(即禁止拷贝构造和拷贝赋值),我们需要显式地声明这两个函数并将它们设为私有(private)。这样可以防止类的外部代码调用这些函数。

解决方案1:将拷贝构造函数和拷贝赋值运算符声明为私有

1
2
3
4
5
6
7
class A {
public:
A() = default;
private:
A(const A&);
A& operator=(const A&);
};

问题:虽然在类的外部不能调用这两个私有函数,但是类的成员函数和友元函数仍然可以调用这些私有函数。

解决方案2:使用基类阻止拷贝

定义一个基类来阻止拷贝操作,通过将拷贝构造函数和拷贝赋值运算符声明为私有且不定义,使得派生类无法调用这些函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};

class A : private Uncopyable {
public:
A() = default;
};

在这种情况下,即使是类A的成员函数和友元函数,在尝试拷贝A的对象时,编译器会尝试生成拷贝构造函数和拷贝赋值运算符,但由于基类Uncopyable的对应函数是私有的,子类无法调用,从而实现了拷贝操作的禁止。

条款 07:为多态基类声明virtual析构函数

当一个基类指针指向一个子类对象时,如果基类的析构函数不是virtual的,在删除基类指针时,只会调用基类的析构函数,而不会调用子类的析构函数,这会导致子类对象的资源无法正确释放,造成内存泄漏。

任何带有virtual函数的类都应该有一个virtual析构函数,以确保当指向派生类对象的基类指针被删除时,会正确调用派生类的析构函数,释放派生类对象的资源。

虚函数的实现原理

  1. 虚函数表(vtbl):每个包含virtual函数的类都有一个虚函数表,表中存储的是指向各个虚函数的函数指针。
  2. 虚指针(vptr):每个对象内存空间内有一个虚指针,这个虚指针指向类的虚函数表。
  3. 虚函数调用:当对象调用某个virtual函数时,编译器通过对象的虚指针找到类的虚函数表,在表中寻找适当的函数指针进行调用。

注意事项

  • 不作为基类使用的类不要声明析构函数为virtual,因为虚表和虚指针会占用额外的内存。
  • std::string和STL容器的析构函数都是非virtual的,不要将这些类作为基类使用。

通过理解和遵循这些规则,可以有效地控制C++类的行为,避免潜在的错误和内存泄漏。

条款 08:别让异常逃离析构函数

C++并不禁止析构函数抛出异常,但这是不建议的。原因是如果有两个异常同时存在(例如一个异常在析构函数中抛出,另一个异常在异常处理过程中产生),程序要么会终止,要么会导致不明确的行为。因此,必须避免析构函数抛出异常。如果确实无法避免,可以采用以下两种方法:

方法1:在析构函数抛出异常时调用 abort 结束程序

1
2
3
4
5
6
7
8
A::~A() {
try {
a.func();
} catch (...) {
// 记录调用 a.func() 过程中出现的异常
abort(); // 结束程序
}
}

方法2:吞下异常,当做什么也没发生过

1
2
3
4
5
6
7
A::~A() {
try {
a.func();
} catch (...) {
// 记录调用 a.func() 过程中出现的异常
}
}

条款 09:绝不在构造和析构过程中调用 virtual 函数

在子类对象调用子类构造函数之前,会先调用父类构造函数。如果子类重写了父类中的一个虚函数func(),而父类的构造函数中调用了这个虚函数,那么在父类构造函数执行到调用func()时,实际调用的是父类版本的func()。这是因为在父类的构造函数执行时,子类的成员变量尚未初始化,如果此时调用虚函数并下降到子类层次,会存在使用未初始化成员的风险。因此,C++禁止在构造和析构过程中调用虚函数。

更根本的原因

在调用父类构造函数期间,对象类型是基类而不是子类。不仅虚函数会使用父类版本,此时使用 dynamic_casttypeid 也会把对象视为基类类型。同样的原因适用于析构函数。

条款 10:令 operator= 返回一个绑定到 *this 的引用

这是为了实现连锁赋值,如下例所示:

1
2
3
4
5
6
7
8
class A {
public:
...
A& operator=(const A& other) {
...
return *this;
}
};

连锁赋值的例子:

1
2
int x, y, z;
x = y = z = 100; // 运行原理:x = (y = (z = 100))

条款 11:在 operator= 中处理自我赋值

自我赋值的意思是等号右边和左边的对象是同一个对象(地址相同),例如 a[i] = a[j];i == j 时。这种情况下,需要在赋值前检查自我赋值,以避免不必要的操作和潜在的问题。

一般的做法是在赋值前做一个检查

1
2
3
4
5
6
7
8
9
10
11
12
class A {
public:
...
A& operator=(const A& other) {
if (this == &other) {
return *this; // 如果是自我赋值,直接返回
}
// 处理赋值操作
...
return *this;
}
};

条款 12:赋值对象时勿忘其每一个成分

在实现复制函数(拷贝构造函数和拷贝赋值运算符)时,需要确保所有成员变量都被正确复制。如果自定义了复制函数,编译器将不会生成默认的复制函数。因此,必须小心处理每个成员变量。

1. 修改复制函数以处理新成员变量

每次为类添加一个新成员变量时,必须同时更新复制函数以处理该成员变量。

2. 子类复制函数中的基类成员复制

为子类自定义复制函数时,需要复制基类的成员变量。由于基类成员通常是私有的,子类无法直接访问,因此子类的复制函数应该调用相应的基类复制函数。

不建议的做法

不能为了简化代码而在拷贝构造函数中调用拷贝赋值运算符,或者在拷贝赋值运算符中调用拷贝构造函数。原因是构造函数用于初始化新对象,而赋值运算符只能用于已初始化的对象。

建议的做法

可以建立一个新的成员函数给复制函数调用,这个函数通常是私有的且命名为 init。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A {
public:
A(const A& other) {
init(other);
}

A& operator=(const A& other) {
if (this != &other) {
// 释放现有资源
init(other);
}
return *this;
}

private:
void init(const A& other) {
// 复制成员变量
}
};

条款 16:成对使用 new 和 delete

使用规则

  • 使用 new 创建对象时,必须使用 delete 删除对象。
  • 使用 new[] 创建数组时,必须使用 delete[] 删除数组。

条款 30:了解 inline 的里里外外

inline 函数

inline 函数在每个调用处都用函数本体替代,以减少函数调用的开销。然而,这会增加目标代码的大小,可能导致额外的分页行为,降低缓存命中率,最终可能导致性能损失。

inline 虚函数

对虚函数进行 inline 是无意义的,因为虚函数在运行时确定,而 inline 在编译期替换。编译器通常不会对通过函数指针进行调用提供 inline 支持,是否 inline 取决于调用的方式。

条款 34:区分接口继承和实现继承

public 继承

public 继承包括函数接口继承和函数实现继承。

纯虚函数

纯虚函数有两个特性:

  1. 必须被任何继承它们的具体类重新声明。
  2. 在抽象类中通常没有定义。

声明纯虚函数的目的是让派生类只继承函数接口,提供接口后,派生类根据自身需求去实现。

非纯虚函数

声明非纯虚函数的目的是让派生类继承函数的接口和默认实现。如果派生类不想重新实现(override)函数,可以使用基类提供的默认版本。

非虚函数

声明非虚函数的目的是令派生类继承函数接口和一份强制性实现。非虚函数代表不变性,不应该在派生类中重新定义。这意味着派生类不应尝试修改此类函数。

通过这些规则,可以确保类的复制行为正确,同时保证接口和实现的明确区分,提高代码的可维护性和可靠性。

条款 35:考虑 virtual 函数以外的其他选择

在设计类时,除了使用 virtual 函数外,还可以考虑以下替代方案:

1. Non-virtual Interface(NVI)

使用 public 的非虚成员函数来调用访问性较低的(privateprotected)虚函数。这种模式通过一个公共接口间接调用虚函数,从而增加了灵活性和控制力。

1
2
3
4
5
6
7
8
class Base {
public:
void interface() {
implementation();
}
protected:
virtual void implementation() = 0;
};

2. 用函数指针替换 virtual 函数

在某些情况下,可以用函数指针成员变量替代 virtual 函数,从而实现类似的多态行为。

1
2
3
4
5
6
7
8
9
10
class Base {
public:
typedef void (*FuncPtr)();
Base(FuncPtr ptr) : func(ptr) {}
void call() {
func();
}
private:
FuncPtr func;
};

3. 用 std::function 替换 virtual 函数

使用 std::function 成员变量代替 virtual 函数,可以增加灵活性,同时避免一些继承带来的复杂性。

1
2
3
4
5
6
7
8
9
10
11
#include <functional>

class Base {
public:
Base(std::function<void()> func) : func_(func) {}
void call() {
func_();
}
private:
std::function<void()> func_;
};

条款 36:绝不重新定义继承而来的非虚函数

public 继承意味着每个派生类对象都是一个基类对象,非虚函数(静态绑定)会继承基类的接口和实现。重新定义会导致设计矛盾和行为的不一致。如果派生类需要不同的行为,不应该使用 public 继承。

条款 37:绝不重新定义继承而来的缺省参数值

缺省参数(Default Parameter Value)值是静态绑定的,而虚函数是动态绑定的。重新定义继承的缺省参数值会导致行为不一致,因为调用函数时的参数值取决于静态类型,而函数调用本身是动态绑定的。

条款 39:明智而审慎地使用 private 继承

private 继承在以下情况下是适当的:

  1. 派生类需要访问基类的受保护成员。
  2. 派生类需要重新定义继承来的虚函数。

private 继承表示“实现继承”而不是“接口继承”,即基类的接口不会暴露在派生类的接口中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>

// 基类 Base
class Base {
protected:
int protectedData;

public:
virtual void virtualMethod() {
std::cout << "Base::virtualMethod()" << std::endl;
}
};

// 派生类 Derived 使用 private 继承自 Base
class Derived : private Base {
public:
// 派生类需要访问基类的受保护成员
void accessProtectedData() {
protectedData = 10;
std::cout << "Protected data in Derived: " << protectedData << std::endl;
}

// 派生类重新定义继承来的虚函数
void virtualMethod() override {
std::cout << "Derived::virtualMethod()" << std::endl;
}
};

int main() {
Derived d;
// 由于是 private 继承,不能像 public 继承那样直接通过派生类对象访问基类的公共成员
// d.virtualMethod(); // 错误

d.accessProtectedData();
return 0;
}

在上述示例中,Derived 类以 private 方式继承自 Base 类。它能够访问基类的受保护成员 protectedData ,并且重新定义了虚函数 virtualMethod 。但由于是 private 继承,在外部不能通过 Derived 类的对象直接访问 Base 类的公共成员。

条款 40:明智而审慎地使用多重继承

多重继承允许一个类同时继承多个基类。需要注意以下几点:

  1. 使用虚拟继承以确保派生类中只有一份基类的数据。
  2. 虚拟继承会增加大小、速度和初始化的成本。
  3. 最好避免在虚基类中放置数据,或者尽量减少数据的放置。
1
2
3
4
5
6
7
8
9
10
11
class A {
// ...
};

class B {
// ...
};

class C : public virtual A, public virtual B {
// ...
};

条款 44:将与参数无关的代码抽离模板

模板会生成多个类和多个函数,因此任何模板代码都不应与特定的模板参数产生依赖关系,以避免代码膨胀。

因非类型模板参数导致的代码膨胀

可以通过函数参数或类成员变量替换模板参数来消除膨胀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<int N>
class A {
public:
void func() {
// ...
}
};

// 改成
class A {
public:
void func(int N) {
// ...
}
};

因类型模板参数导致的代码膨胀

可以通过让具有相同二进制表示的实例共享实现代码来减少膨胀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class A {
public:
void func() {
// ...
}
};

// 改成
class A_Base {
protected:
void func() {
// ...
}
};

template<typename T>
class A : public A_Base {
// ...
};

通过这些技术,可以提高代码的可维护性,减少代码膨胀,并提高运行时的效率。

条款 45:运用成员函数模板接受所有兼容类型

当你声明成员函数模板来实现“泛化的拷贝构造”或“泛化的赋值操作”时,仍然需要声明正常的拷贝构造函数和拷贝赋值运算符。原因是编译器在某些情况下需要使用这些正常版本,例如,当需要合成某些特殊的成员函数时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class A {
public:
// 正常的拷贝构造函数
A(const A& other) {
// 拷贝构造逻辑
}

// 正常的拷贝赋值运算符
A& operator=(const A& other) {
if (this != &other) {
// 赋值逻辑
}
return *this;
}

// 成员函数模板用于泛化拷贝构造
template<typename T>
A(const T& other) {
// 泛化拷贝构造逻辑
}

// 成员函数模板用于泛化赋值操作
template<typename T>
A& operator=(const T& other) {
if (this != &other) {
// 泛化赋值逻辑
}
return *this;
}
};

条款 46:需要类型转换时请为模板定义非成员函数

当编写一个类模板,并且希望提供与此模板相关的函数支持所有参数的隐式类型转换时,请将这些函数定义为类模板内部的 friend 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
class MyClass {
public:
MyClass(const T& value) : value_(value) {}

// 定义为friend函数以支持隐式类型转换
template<typename U>
friend bool operator==(const MyClass<T>& lhs, const MyClass<U>& rhs) {
return lhs.value_ == rhs.value_;
}

private:
T value_;
};

条款 47:请使用 traits classes 表现类型信息

Traits classes 使得类型相关信息在编译期可用。它们通过模板和模板特化来实现,并可以在编译期执行类似 if...else 的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
struct TypeTraits {
static const bool isPointer = false;
};

// 特化版本
template<typename T>
struct TypeTraits<T*> {
static const bool isPointer = true;
};

// 使用 TypeTraits
template<typename T>
void process(const T& value) {
if (TypeTraits<T>::isPointer) {
std::cout << "Pointer type\n";
} else {
std::cout << "Non-pointer type\n";
}
}

条款 48:认识模板元编程

模板元编程(Template Metaprogramming,TMP)可以将工作由运行期移到编译期,从而实现早期错误检测和更高的执行效率。TMP 可以用于生成基于策略选择组合的客户定制代码,也可以避免生成对某些特殊类型不适合的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 判断是否为指针类型的元编程
template<typename T>
struct IsPointer {
static const bool value = false;
};

template<typename T>
struct IsPointer<T*> {
static const bool value = true;
};

// 使用元编程
template<typename T>
void process(const T& value) {
if (IsPointer<T>::value) {
std::cout << "Pointer type\n";
} else {
std::cout << "Non-pointer type\n";
}
}

通过这些技术,可以在编译期执行复杂的类型检查和优化,从而提高代码的健壮性和执行效率。

评论
目录
Effective C++