002《More Effective C++》读书笔记
《More Effective C++》这本书的所有 Items
第一章:Basics (基础)
→ Item 1: Distinguish between pointers and references (区分指针与引用)
→ Item 2: Prefer C++-style casts (优先使用C++风格的类型转换)
→ Item 3: Never treat arrays polymorphically (不要用多态方式处理数组)
→ Item 4: Avoid gratuitous default constructors (避免不必要的默认构造函数)
第二章:Operators (操作符)
→ Item 5: Be wary of user-defined conversion functions (谨慎定义类型转换函数)
→ Item 6: Distinguish between prefix and postfix forms of increment and decrement operators (区分前置和后置自增/自减操作符)
→ Item 7: Never overload &&
, ||
, or ,
(不要重载&&
、||
或逗号操作符)
→ Item 8: Understand the different meanings of new
and delete
(理解new
和delete
的不同含义)
第三章:Exceptions (异常)
→ Item 9: Use destructors to prevent resource leaks (用析构函数防止资源泄漏)
→ Item 10: Prevent resource leaks in constructors (在构造函数中防止资源泄漏)
→ Item 11: Prevent exceptions from leaving destructors (阻止异常从析构函数中逃离)
→ Item 12: Understand how throwing an exception differs from passing a parameter or calling a virtual function (理解抛出异常与传递参数或调用虚函数的不同)
→ Item 13: Catch exceptions by reference (通过引用捕获异常)
→ Item 14: Use exception specifications judiciously (谨慎使用异常规范)
→ Item 15: Understand the costs of exception handling (了解异常处理的成本)
第四章:Efficiency (效率)
→ Item 16: Remember the 80-20 rule (牢记80-20法则)
→ Item 17: Consider using lazy evaluation (考虑使用延迟计算)
→ Item 18: Amortize the cost of expected computations (分摊预期计算的成本)
→ Item 19: Understand the origin of temporary objects (理解临时对象的来源)
→ Item 20: Facilitate the return value optimization (协助返回值优化)
→ Item 21: Overload to avoid implicit type conversions (通过重载避免隐式类型转换)
→ Item 22: Consider using op=
instead of stand-alone op
(考虑用op=
替代单独的操作符)
→ Item 23: Consider alternative libraries (考虑其他库的实现)
→ Item 24: Understand the costs of virtual functions, multiple inheritance, virtual base classes, and RTTI (了解虚函数、多重继承、虚基类和RTTI的成本)
第五章:Techniques (技术)
→ Item 25: Virtualizing constructors and non-member functions (将构造函数和非成员函数虚化)
→ Item 26: Limiting the number of objects of a class (限制类的对象数量)
→ Item 27: Requiring or prohibiting heap-based objects (要求或禁止基于堆的对象)
→ Item 28: Smart pointers (智能指针)
→ Item 29: Reference counting (引用计数)
→ Item 30: Proxy classes (代理类)
→ Item 31: Making functions virtual with respect to more than one object (针对多个对象的虚函数)
第六章:Miscellany (杂项)
→ Item 32: Program in the future tense (以未来时态编程)
→ Item 33: Make non-leaf classes abstract (将非叶类声明为抽象类)
→ Item 34: Understand how to combine C++ and C in the same program (理解如何在同一个程序中混合使用C++和C)
→ Item 35: Familiarize yourself with the language standard (熟悉语言标准)
1. Distinguish between pointers and references (区分指针与引用)
1.1 基本概念
1.1.1 指针(Pointer)
1.1.1.1 定义
① 指针是一个变量,其存储的是另一个变量的内存地址。
② 可以将指针想象成一个指示牌,上面写着某个东西(另一个变量)的具体位置。 📍
1.1.1.2 声明语法
1
int number = 10;
2
int *ptr = &number; // ptr 是一个指向整数的指针,存储的是 number 的地址
1.1.2 引用(Reference)
1.1.2.1 定义
① 引用是已存在变量的别名。一旦引用被初始化为某个变量,它就永远与该变量绑定在一起,不能再引用其他变量。
② 可以将引用想象成一个外号,它代表着同一个事物(变量)。🏷️
1.1.2.2 声明语法
1
int number = 10;
2
int &ref = number; // ref 是 number 的引用
1.2 关键区别点
1.2.1 初始化
① 指针可以不初始化。未初始化的指针包含一个随机的内存地址,使用它可能会导致未定义行为。
1
int *ptr; // 指针 ptr 没有被初始化
② 引用在声明时必须立即初始化,并且一旦初始化就不能再引用其他变量。引用必须始终指向一个有效的对象。
1
int number = 10;
2
int &ref = number; // 引用 ref 在声明时被初始化为 number
3
4
// 错误示例:
5
// int &ref; // 编译错误:引用必须被初始化
1.2.2 空值(Nullability)
① 指针可以为空(NULL 或 nullptr)。空指针不指向任何有效的内存地址。
1
int *ptr = nullptr; // ptr 是一个空指针
② 引用不能为空。由于引用在声明时必须初始化,并且必须绑定到一个有效的对象,因此不存在空引用。
1.2.3 重新赋值(Reassignment)
① 指针可以被重新赋值,使其指向另一个不同的变量。
1
int number1 = 10;
2
int number2 = 20;
3
int *ptr = &number1; // ptr 指向 number1
4
ptr = &number2; // ptr 现在指向 number2
② 引用一旦初始化后就不能再引用其他变量。它始终是初始绑定变量的别名。
1
int number1 = 10;
2
int number2 = 20;
3
int &ref = number1; // ref 是 number1 的引用
4
// ref = number2; // 这不是将 ref 重新绑定到 number2,而是将 number2 的值赋给 ref 所引用的 number1
1.2.4 指针运算(Pointer Arithmetic)
① 指针支持指针运算,例如递增、递减等。这在数组操作中非常有用。
1
int arr[] = {1, 2, 3};
2
int *ptr = arr; // ptr 指向数组的第一个元素
3
4
*ptr; // 输出 1
5
ptr++; // ptr 指向数组的第二个元素
6
*ptr; // 输出 2
② 引用不支持指针运算。对引用的任何操作都直接作用于其所引用的变量本身。
1.2.5 内存大小
① 指针本身占用一定的内存空间,用于存储目标变量的地址。指针的大小通常与计算机的体系结构有关(例如,32位系统上通常是4字节,64位系统上通常是8字节)。
② 引用本身不占用额外的内存空间。它只是被引用变量的另一个名称。在底层实现中,编译器可能会使用类似指针的方式来实现引用,但这对于程序员来说是透明的。
1.2.6 间接访问
① 使用指针访问其指向的变量需要使用解引用操作符 *
。
1
int number = 10;
2
int *ptr = &number;
3
int value = *ptr; // 通过解引用 ptr 获取 number 的值 (value = 10)
② 使用引用访问其引用的变量可以直接使用引用的名称,就像使用变量本身一样。
1
int number = 10;
2
int &ref = number;
3
int value = ref; // 直接使用 ref 获取 number 的值 (value = 10)
1.2.7 作为函数参数
① 将指针作为函数参数传递时,函数内部可以通过解引用修改原始变量的值(传地址调用)。
1
void increment(int *numPtr) {
2
(*numPtr)++;
3
}
4
5
int main() {
6
int count = 5;
7
increment(&count); // 传递 count 的地址
8
// count 现在是 6
9
return 0;
10
}
② 将引用作为函数参数传递时,函数内部可以直接修改原始变量的值(传引用调用)。语法上更简洁。
1
void increment(int &numRef) {
2
numRef++;
3
}
4
5
int main() {
6
int count = 5;
7
increment(count); // 直接传递 count
8
// count 现在是 6
9
return 0;
10
}
1.2.8 返回值
① 函数可以返回指针。但需要注意返回的指针所指向的内存是否仍然有效(例如,不要返回指向局部变量的指针)。
1
int* createInt(int value) {
2
int *ptr = new int(value);
3
return ptr;
4
}
5
6
int main() {
7
int *myInt = createInt(42);
8
// ... 使用 myInt ...
9
delete myInt; // 记得释放内存
10
return 0;
11
}
② 函数可以返回引用。返回引用通常用于操作符重载等场景,可以使代码更简洁。返回引用时同样需要确保引用的对象在函数返回后仍然有效。
1
int& getElement(int arr[], int index) {
2
return arr[index];
3
}
4
5
int main() {
6
int numbers[] = {10, 20, 30};
7
getElement(numbers, 1) = 25; // 修改数组元素
8
// numbers 现在是 {10, 25, 30}
9
return 0;
10
}
1.3 使用场景
1.3.1 指针的常见使用场景
① 动态内存分配:使用 new
和 delete
操作符管理堆上的内存。
② 数据结构:例如链表、树等需要使用指针来连接节点。
③ 数组操作:遍历数组和进行指针运算。
④ 函数参数传递(传地址):当需要在函数内部修改实参的值时。
⑤ 指向函数的指针:用于实现回调函数等。
1.3.2 引用的常见使用场景
① 函数参数传递(传引用):避免拷贝,提高效率,并且可以在函数内部修改实参的值。
② 函数返回值:允许将函数调用放在赋值语句的左边(例如,操作符重载)。
③ 作为变量的别名:使代码更易读。
1.4 总结
特性 | 指针(Pointer) | 引用(Reference) |
---|---|---|
定义 | 存储内存地址的变量 | 已存在变量的别名 |
初始化 | 可以不初始化 | 声明时必须立即初始化 |
空值 | 可以为空(NULL 或 nullptr) | 不能为 null |
重新赋值 | 可以指向不同的变量 | 一旦初始化,始终指向同一个变量 |
指针运算 | 支持指针运算 | 不支持指针运算 |
内存大小 | 占用额外的内存空间 | 通常不占用额外的内存空间(底层实现可能类似指针) |
间接访问 | 使用解引用操作符 * | 直接使用引用名称 |
函数参数 | 传地址调用,可以通过解引用修改实参 | 传引用调用,可以直接修改实参 |
函数返回值 | 可以返回指针,需要注意内存管理 | 可以返回引用,需要确保引用对象的有效性 |
2. Prefer C++-style casts (优先使用C++风格的类型转换)
2.1 C风格类型转换的缺点
① C 风格的类型转换(例如 (type)expression
或 type(expression)
)过于宽泛,缺乏类型安全性检查。
② 难以在代码中搜索和区分不同类型的转换,不利于代码维护和调试。
2.2 C++风格类型转换的优点
① C++ 提供了四种命名的类型转换操作符,它们各自有明确的用途,提高了类型安全性。
② 更容易在代码中识别和理解不同类型的转换。
2.3 四种C++风格类型转换操作符
2.3.1 static_cast
2.3.1.1 用途
① 用于执行静态的、编译时的类型转换。
② 可以用于基本类型之间的转换(例如 int
到 float
)。
③ 可以用于具有继承关系的类型之间的转换:
* 向上转型 (Upcasting):将派生类指针或引用转换为基类指针或引用,这是安全的。
* 向下转型 (Downcasting):将基类指针或引用转换为派生类指针或引用,不进行运行时类型检查,因此可能是不安全的。程序员需要确保转换的安全性。
2.3.1.2 示例
1
int i = 10;
2
float f = static_cast<float>(i); // int 转 float
3
4
class Base {};
5
class Derived : public Base {};
6
7
Derived d;
8
Base *bPtr = static_cast<Base*>(&d); // 向上转型 (安全)
9
// Derived *dPtr = static_cast<Derived*>(bPtr); // 向下转型 (可能不安全,需要程序员保证)
2.3.2 const_cast
2.3.2.1 用途
① 用于添加或移除变量的 const
或 volatile
限定符。
② 只能用于修改类型的常量性,不能改变变量的实际类型。
③ 滥用 const_cast
可能会导致未定义行为,应谨慎使用。
2.3.2.2 示例
1
const int constantValue = 10;
2
// int *nonConstPtr = &constantValue; // 编译错误
3
int *nonConstPtr = const_cast<int*>(&constantValue);
4
*nonConstPtr = 20; // 修改了原本声明为 const 的变量 (可能导致未定义行为)
2.3.3 dynamic_cast
2.3.3.1 用途
① 主要用于执行安全的向下转型 (Downcasting),即在继承层次结构中将基类指针或引用转换为派生类指针或引用。
② 进行运行时类型检查。如果转换是不安全的(即基类指针或引用实际指向的不是目标派生类对象),则:
* 如果转换目标是指针类型,则返回 nullptr
。
* 如果转换目标是引用类型,则抛出 std::bad_cast
异常。
③ 只能用于多态类型(即基类中至少包含一个虚函数)。
2.3.3.2 示例
1
class Base { public: virtual ~Base() {} }; // 基类需要有虚函数才能使用 dynamic_cast
2
class Derived AlBeRt63EiNsTeIn void derivedMethod() {} };
3
4
Base *bPtr = new Derived();
5
Derived *dPtr = dynamic_cast<Derived*>(bPtr); // 向下转型 (安全,因为 bPtr 实际指向 Derived 对象)
6
if (dPtr) {
7
dPtr->derivedMethod();
8
}
9
10
Base *bPtr2 = new Base();
11
Derived *dPtr2 = dynamic_cast<Derived*>(bPtr2); // 向下转型 (不安全,返回 nullptr)
12
if (!dPtr2) {
13
// 处理转换失败的情况
14
}
15
16
Base &bRef = static_cast<Base&>(*new Derived());
17
try {
18
Derived &dRef = dynamic_cast<Derived&>(bRef); // 向下转型 (安全)
19
dRef.derivedMethod();
20
} catch (const std::bad_cast& e) {
21
// 不会抛出异常,因为 bRef 实际引用 Derived 对象
22
}
23
24
Base &bRef2 = static_cast<Base&>(*new Base());
25
try {
26
Derived &dRef2 = dynamic_cast<Derived&>(bRef2); // 向下转型 (不安全,抛出 std::bad_cast 异常)
27
} catch (const std::bad_cast& e) {
28
// 处理转换失败的情况
29
}
30
delete bPtr;
31
delete bPtr2;
2.3.4 reinterpret_cast
2.3.4.1 用途
① 用于执行低级的、不安全的类型转换。
② 允许将一个指针转换为另一种类型的指针,即使它们之间没有任何逻辑关系。
③ 通常用于与硬件或底层系统交互的场景。
④ 非常危险,容易导致程序崩溃或产生未定义行为,应尽可能避免使用。
2.3.4.2 示例
1
int *intPtr = new int(10);
2
// char *charPtr = intPtr; // 编译错误
3
char *charPtr = reinterpret_cast<char*>(intPtr); // 将 int* 强制转换为 char*
4
5
// 对 charPtr 的操作需要非常小心,因为其指向的内存可能并不适合作为 char 类型访问
6
delete intPtr;
2.4 总结
优先使用 C++ 风格的类型转换,因为它们更安全、更清晰,并且能够更好地表达类型转换的意图。只有在确实需要进行底层操作时才考虑使用 reinterpret_cast
,并且需要非常谨慎。对于涉及继承关系的类型转换,dynamic_cast
是进行安全向下转型的首选。
3. Never treat arrays polymorphically (不要用多态方式处理数组)
3.1 多态与数组的矛盾
① 多态性依赖于通过基类指针或引用调用虚函数时,能够根据对象的实际类型执行相应的派生类版本。
② 当通过基类指针操作派生类对象数组时,指针运算的步长是按照基类的大小计算的,而不是派生类的大小。
3.2 问题示例
1
class Base {
2
public:
3
virtual void print() {
4
std::cout << "Base" << std::endl;
5
}
6
};
7
8
class Derived : public Base {
9
public:
10
void print() override {
11
std::cout << "Derived" << std::endl;
12
}
13
int extraData; // 派生类比基类可能占用更多内存
14
};
15
16
int main() {
17
Derived derivedArray[3];
18
Base *basePtr = derivedArray; // 隐式向上转型
19
20
// 期望输出 "Derived" 三次,但实际可能不是
21
for (int i = 0; i < 3; ++i) {
22
basePtr[i].print(); // 指针运算的步长是 sizeof(Base)
23
}
24
25
// 错误的尝试:delete[] basePtr; // 可能会导致问题,因为析构函数调用可能不正确
26
27
return 0;
28
}
3.3 问题分析
① 在上面的例子中,derivedArray
是一个 Derived
类型的数组,每个元素的大小是 sizeof(Derived)
。
② 当将 derivedArray
赋值给 Base* basePtr
时,发生了隐式的向上转型。
③ 在循环中,basePtr[i]
实际上是通过 basePtr + i * sizeof(Base)
来计算数组元素的地址。
④ 如果 sizeof(Derived)
大于 sizeof(Base)
,那么 basePtr[1]
实际上指向的是第一个 Derived
对象的中间部分,而不是第二个 Derived
对象的起始位置。
⑤ 这会导致 print()
函数的调用不正确,可能会访问到错误的内存,甚至导致程序崩溃。
⑥ 同样,如果基类有虚析构函数,通过 delete[] basePtr;
删除数组时,也可能因为步长计算错误而导致析构函数调用不正确,造成资源泄漏或未定义行为。
3.4 解决方案
① 不要将派生类对象的数组赋值给基类指针。
② 如果需要使用多态性,考虑使用指针数组或智能指针数组,例如 std::vector<std::unique_ptr<Base>>
。这样,每个元素都是一个指向 Base
或其派生类的指针,指针本身的大小是固定的,可以正确地进行指针运算。
3.5 正确的多态数组处理方式示例
1
#include <vector>
2
#include <memory>
3
#include <iostream>
4
5
class Base {
6
public:
7
virtual ~Base() {}
8
virtual void print() {
9
std::cout << "Base" << std::endl;
10
}
11
};
12
13
class Derived : public Base {
14
public:
15
void print() override {
16
std::cout << "Derived" << std::endl;
17
}
18
int extraData;
19
};
20
21
int main() {
22
std::vector<std::unique_ptr<Base>> polymorphicArray;
23
polymorphicArray.push_back(std::make_unique<Derived>());
24
polymorphicArray.push_back(std::make_unique<Derived>());
25
polymorphicArray.push_back(std::make_unique<Derived>());
26
27
for (const auto& ptr : polymorphicArray) {
28
ptr->print(); // 正确调用派生类的 print 函数
29
}
30
31
return 0;
32
}
3.6 总结
当处理数组时,要特别注意类型大小和指针运算。多态性通常不适用于直接的数组类型,应该使用指针或智能指针的集合来实现多态行为。
4. Avoid gratuitous default constructors (避免不必要的默认构造函数)
4.1 什么是默认构造函数
① 默认构造函数是指一个没有参数(或者所有参数都有默认值)的构造函数。
② 如果一个类没有显式定义任何构造函数,编译器会隐式生成一个默认构造函数。
③ 如果一个类显式定义了任何构造函数,编译器不会再隐式生成默认构造函数。
4.2 默认构造函数的必要性
4.2.1 创建对象数组
① 如果要创建类的对象数组,该类必须提供一个默认构造函数(无论是显式定义的还是隐式生成的)。
1
class MyClass {
2
public:
3
MyClass(int value) : m_value(value) {}
4
private:
5
int m_value;
6
};
7
8
// MyClass array[10]; // 错误:MyClass 没有默认构造函数
9
10
class MyClassWithDefault {
11
public:
12
MyClassWithDefault() : m_value(0) {}
13
MyClassWithDefault(int value) : m_value(value) {}
14
private:
15
int m_value;
16
};
17
18
MyClassWithDefault array[10]; // 正确:MyClassWithDefault 有默认构造函数
4.2.2 被其他需要默认构造函数的代码使用
① 某些库或框架可能要求使用的类具有默认构造函数。
② 如果一个类作为另一个类的成员变量,并且该成员变量需要在构造父对象时默认构造,那么该类需要提供默认构造函数。
4.2.3 作为基类
① 如果一个类作为基类,并且派生类需要默认构造,那么基类通常需要提供一个默认构造函数,以便派生类的默认构造函数可以隐式调用基类的默认构造函数。
4.3 避免不必要的默认构造函数的原因
4.3.1 可能导致对象处于无效状态
① 如果一个类的对象在逻辑上必须通过某些参数进行初始化才能处于有效状态,那么提供一个不执行这些初始化的默认构造函数可能会导致创建出无效的对象。
② 这会增加在使用对象之前进行额外检查的负担,并可能导致程序逻辑错误。
4.3.2 隐藏了必要的初始化
① 如果类提供了默认构造函数,用户可能会忘记或忽略使用带参数的构造函数进行必要的初始化。
4.3.3 降低了代码的清晰性
① 如果一个类的所有成员变量都应该在对象创建时被初始化,那么一个不执行任何操作的默认构造函数可能会让人感到困惑。
4.4 何时应该避免默认构造函数
① 当类的对象在创建时必须进行特定的初始化才能处于有效状态时。
② 当默认构造函数的存在会模糊类的设计意图时。
4.5 如何避免不必要的默认构造函数
① 只定义带参数的构造函数。如果这样做,编译器就不会生成默认构造函数。
② 如果需要默认构造的行为,但希望进行特定的默认初始化,可以显式定义一个默认构造函数。
4.6 总结
只有在真正需要默认构造函数的情况下才提供它。如果一个类的对象需要特定的初始化才能有效,那么避免提供不执行这些初始化的默认构造函数,可以提高代码的健壮性和可读性。
5. Be wary of user-defined conversion functions (谨慎定义类型转换函数)
5.1 什么是用户自定义的类型转换函数
① 用户自定义的类型转换函数(也称为转换操作符)是类的成员函数,用于将类的对象转换为其他类型。
② 它们有两种形式:
* 转换构造函数 (Converting Constructors):接受一个参数,该参数的类型不是该类本身,并且没有使用 explicit
关键字修饰。这种构造函数允许将参数类型隐式转换为该类类型。
* 转换操作符 (Conversion Operators):使用 operator target-type()
的语法定义,用于将该类类型的对象隐式转换为 target-type
。
5.2 隐式类型转换的潜在问题
5.2.1 可能导致意外的行为
① 隐式类型转换可能会在程序员不期望的情况下发生,导致代码行为与预期不符。
② 这会使代码难以理解和调试。
5.2.2 降低代码的可读性
① 当类型转换是隐式发生时,代码的意图可能不明确,降低了可读性。
5.2.3 可能导致二义性
① 如果存在多个可能的隐式类型转换路径,编译器可能无法确定应该选择哪一个,从而导致编译错误。
5.3 转换构造函数 (Converting Constructors)
5.3.1 示例
1
class String {
2
public:
3
String(const char* str) { // 转换构造函数:允许 char* 隐式转换为 String
4
// ... 字符串拷贝逻辑 ...
5
}
6
// ... 其他成员 ...
7
};
8
9
void printString(const String& s) {
10
// ... 打印字符串 ...
11
}
12
13
int main() {
14
printString("Hello"); // "Hello" (const char*) 被隐式转换为 String 类型
15
return 0;
16
}
5.3.2 使用 explicit
关键字
① 可以使用 explicit
关键字修饰转换构造函数,阻止隐式类型转换的发生。
② 这样,只能通过显式的方式进行类型转换。
1
class String {
2
public:
3
explicit String(const char* str) { // 使用 explicit 阻止隐式转换
4
// ... 字符串拷贝逻辑 ...
5
}
6
// ... 其他成员 ...
7
};
8
9
void printString(const String& s) {
10
// ... 打印字符串 ...
11
}
12
13
int main() {
14
// printString("Hello"); // 错误:不能进行隐式转换
15
printString(String("Hello")); // 正确:显式创建 String 对象
16
return 0;
17
}
5.4 转换操作符 (Conversion Operators)
5.4.1 示例
1
class Rational {
2
public:
3
Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {}
4
operator double() const { // 转换操作符:允许 Rational 隐式转换为 double
5
return static_cast<double>(numerator) / denominator;
6
}
7
private:
8
int numerator;
9
int denominator;
10
};
11
12
void printDouble(double d) {
13
std::cout << d << std::endl;
14
}
15
16
int main() {
17
Rational r(1, 2);
18
printDouble(r); // Rational 对象 r 被隐式转换为 double 类型 (输出 0.5)
19
double d = r; // 隐式转换
20
return 0;
21
}
5.4.2 潜在的问题
① 像上面的例子一样,隐式转换为基本类型可能会导致精度损失或不期望的行为。
② 如果一个类提供了多个转换操作符,可能会导致二义性。
5.5 何时应该谨慎使用类型转换函数
① 当隐式类型转换可能会导致意外的行为或降低代码可读性时。
② 当存在多种可能的转换路径时。
5.6 建议
① 优先使用 explicit
关键字修饰单参数的构造函数,除非你确实需要隐式转换。
② 谨慎定义转换操作符。考虑是否真的需要将你的类隐式转换为其他类型。
③ 提供显式的转换函数(例如 toDouble()
, toString()
)通常比隐式转换操作符更安全和更易于理解。
5.7 总结
用户自定义的类型转换函数可以很方便,但也可能引入难以察觉的错误。谨慎使用它们,并考虑使用显式转换函数来提高代码的清晰度和安全性。
6. Distinguish between prefix and postfix forms of increment and decrement operators (区分前置和后置自增/自减操作符)
6.1 内建类型的行为
① 前置自增/自减 (++i
, --i
):先将操作数的值加/减 1,然后返回修改后的值。
② 后置自增/自减 (i++
, i--
):先返回操作数当前的值(在修改之前),然后再将操作数的值加/减 1。
6.2 重载自定义类型的自增/自减操作符
6.2.1 前置自增/自减操作符的重载
① 前置版本通常声明为返回对象的引用 (Type&
),因为它们返回的是修改后的对象本身。
② 它们不接受任何额外的参数。
1
class Counter {
2
public:
3
Counter(int value = 0) : count(value) {}
4
5
Counter& operator++() { // 前置自增
6
++count;
7
return *this;
8
}
9
10
Counter& operator--() { // 前置自减
11
--count;
12
return *this;
13
}
14
15
int get() const { return count; }
16
17
private:
18
int count;
19
};
20
21
int main() {
22
Counter c1(5);
23
++c1;
24
std::cout << "Prefix increment: " << c1.get() << std::endl; // 输出 6
25
--c1;
26
std::cout << "Prefix decrement: " << c1.get() << std::endl; // 输出 5
27
return 0;
28
}
6.2.2 后置自增/自减操作符的重载
① 后置版本通常声明为返回对象的值 (Type
),因为它们需要返回修改之前的对象状态。
② 为了区分后置版本和前置版本,后置版本通常接受一个哑元 int
参数(编译器在调用后置版本时会自动传入 0,我们不需要显式传递)。
1
class Counter {
2
public:
3
Counter(int value = 0) : count(value) {}
4
5
Counter& operator++() { // 前置自增
6
++count;
7
return *this;
8
}
9
10
Counter operator++(int) { // 后置自增 (哑元 int 参数)
11
Counter temp = *this; // 保存当前值
12
++count; // 先自增
13
return temp; // 返回保存的原始值
14
}
15
16
Counter& operator--() { // 前置自减
17
--count;
18
return *this;
19
}
20
21
Counter operator--(int) { // 后置自减 (哑元 int 参数)
22
Counter temp = *this; // 保存当前值
23
--count; // 先自减
24
return temp; // 返回保存的原始值
25
}
26
27
int get() const { return count; }
28
29
private:
30
int count;
31
};
32
33
int main() {
34
Counter c2(5);
35
Counter resultPostIncrement = c2++;
36
std::cout << "Postfix increment result: " << resultPostIncrement.get() << std::endl; // 输出 5 (原始值)
37
std::cout << "Postfix incremented c2: " << c2.get() << std::endl; // 输出 6 (已自增)
38
39
Counter c3(5);
40
Counter resultPostDecrement = c3--;
41
std::cout << "Postfix decrement result: " << resultPostDecrement.get() << std::endl; // 输出 5 (原始值)
42
std::cout << "Postfix decremented c3: " << c3.get() << std::endl; // 输出 4 (已自减)
43
44
return 0;
45
}
6.3 效率考量
① 对于内建类型,前置和后置版本的效率差别很小。
② 对于自定义类型,后置版本通常比前置版本效率稍低,因为后置版本需要创建一个临时对象来保存原始值并返回。
③ 因此,在不需要使用自增/自减之前的原始值时,优先使用前置版本。
6.4 总结
理解前置和后置自增/自减操作符的行为差异对于正确地重载它们至关重要。记住前置版本返回修改后的对象引用,而后置版本返回修改前的对象值(通过创建一个临时对象实现)。在实际使用中,优先考虑使用前置版本以获得潜在的性能优势。
7. Never overload &&
, ||
, or ,
(不要重载&&
、||
或逗号操作符)
7.1 &&
和 ||
操作符的短路求值特性
① 内建的逻辑与 (&&
) 和逻辑或 (||
) 操作符具有短路求值 (short-circuiting) 的特性。
② 对于 a && b
,如果 a
的值为 false
,则 b
不会被求值。
③ 对于 a || b
,如果 a
的值为 true
,则 b
不会被求值。
7.2 重载 &&
和 ||
会破坏短路求值
① 当重载 &&
或 ||
操作符时,它们会变成普通的函数调用。
② 函数的所有参数在函数调用之前都会被求值,因此短路求值特性会丢失。
③ 这可能导致意想不到的行为,特别是当操作符的右侧操作数具有副作用或者求值成本很高时。
7.3 示例
1
class MyClass {
2
public:
3
bool operator&&(const MyClass& other) const {
4
std::cout << "MyClass::operator&& called" << std::endl;
5
return /* ... 逻辑与的实现 ... */;
6
}
7
// ...
8
};
9
10
bool someExpensiveOperation() {
11
std::cout << "someExpensiveOperation called" << std::endl;
12
return false;
13
}
14
15
int main() {
16
MyClass obj1, obj2;
17
if (false && someExpensiveOperation()) {
18
// 期望 someExpensiveOperation 不会被调用,因为左侧为 false
19
std::cout << "Inside if block" << std::endl;
20
}
21
22
if (obj1 && someExpensiveOperation()) {
23
// 如果 operator&& 被重载,someExpensiveOperation 仍然会被调用
24
std::cout << "Inside if block (overloaded &&)" << std::endl;
25
}
26
27
return 0;
28
}
7.4 逗号操作符的求值顺序和结果
① 内建的逗号操作符 (a, b
) 会从左到右依次求值操作数,并返回最右侧操作数的值。
7.5 重载逗号操作符会改变其行为
① 重载逗号操作符会使其变成普通的二元操作符函数。
② 求值顺序不再保证是从左到右,并且返回值可以是任意类型,不再是右侧操作数的值。
③ 这会使代码难以理解,并可能破坏依赖于逗号操作符特定行为的代码。
7.6 示例
1
class MyClass {
2
public:
3
MyClass& operator,(const MyClass& other) {
4
std::cout << "MyClass::operator, called" << std::endl;
5
return *this;
6
}
7
int value;
8
};
9
10
int main() {
11
MyClass obj1, obj2, obj3;
12
int a = 1, b = 2, c = 3;
13
int result = (a, b, c); // result 的值为 3
14
15
MyClass resultObj = (obj1, obj2, obj3); // 调用重载的 operator,,行为可能与预期不同
16
17
return 0;
18
}
7.7 替代方案
① 如果需要自定义的逻辑组合行为,可以考虑使用普通的函数来实现,这样可以清晰地控制求值顺序和短路行为(如果需要)。
② 对于逗号操作符,其特定的行为通常用于特定的场景(例如在 for
循环的更新部分),重载它通常没有必要且容易引起混淆。
7.8 总结
由于重载 &&
、||
和逗号操作符会改变它们内建的关键行为(短路求值和求值顺序/结果),这会导致代码难以理解和维护,并可能引入难以发现的错误。因此,强烈建议不要重载这些操作符。如果需要类似的功能,考虑使用普通的函数或其他设计模式。
8. Understand the different meanings of new
and delete
(理解new
和delete
的不同含义)
8.1 new
操作符 (new operator)
① 我们通常所说的 new
是一个操作符,它执行两个关键步骤:
* 分配内存 (Allocation):它调用名为 operator new
的函数(可以是全局的,也可以是类的成员)来分配一块足够大的、原始的(未初始化的)内存,以容纳指定类型的对象。
* 构造对象 (Construction):在分配到的内存上调用对象的构造函数来初始化对象。
② 例如,new MyClass(arg)
这个表达式会先调用 operator new(sizeof(MyClass))
来分配内存,然后在分配到的内存上调用 MyClass
的构造函数,并将 arg
作为参数传递给它。最后,new
操作符返回指向新创建对象的指针。
8.2 delete
操作符 (delete operator)
① delete
也是一个操作符,它执行与 new
相反的两个步骤:
* 析构对象 (Destruction):调用指针所指向对象的析构函数来执行清理操作。
* 释放内存 (Deallocation):调用名为 operator delete
的函数(可以是全局的,也可以是类的成员)来释放之前由 operator new
分配的内存。
② 例如,delete ptr
这个表达式会先调用 ptr
所指向对象的析构函数,然后调用 operator delete(ptr)
来释放 ptr
指向的内存。
8.3 operator new
函数
① operator new
是一个函数,负责分配原始内存。
② 它可以被重载:
* 全局重载:可以提供自定义的全局内存分配行为。
* 类内重载:可以为特定的类提供自定义的内存分配行为。
③ 通常的形式是 void* operator new(std::size_t size)
,它接受要分配的内存大小作为参数,并返回一个指向已分配内存的指针(如果分配失败则抛出 std::bad_alloc
异常)。
8.4 operator delete
函数
① operator delete
也是一个函数,负责释放原始内存。
② 同样可以被重载(全局或类内)。
③ 通常的形式是 void operator delete(void* ptr)
,它接受一个指向要释放的内存的指针作为参数。
8.5 数组形式的 new
和 delete
① 对于数组,我们使用 new[]
和 delete[]
操作符。
② new[]
会分配足够的内存来存储指定数量的对象数组,并对数组中的每个对象调用其默认构造函数(或者使用初始化列表)。它返回指向数组第一个元素的指针。
③ delete[]
应该用于释放通过 new[]
分配的内存。它会对数组中的每个对象调用其析构函数,然后释放整个内存块。如果使用 delete
来释放通过 new[]
分配的内存,会导致未定义行为(通常只会调用数组中第一个对象的析构函数,并且内存可能无法正确释放)。
8.6 示例
1
#include <iostream>
2
#include <new> // 需要包含以使用 std::bad_alloc
3
4
class MyClass {
5
public:
6
MyClass() { std::cout << "MyClass constructor called" << std::endl; }
7
~MyClass() { std::cout << "MyClass destructor called" << std::endl; }
8
9
// 重载类内的 operator new
10
static void* operator new(std::size_t size) {
11
std::cout << "MyClass::operator new called, size = " << size << std::endl;
12
return ::operator new(size); // 调用全局的 operator new
13
}
14
15
// 重载类内的 operator delete
16
static void operator delete(void* ptr) {
17
std::cout << "MyClass::operator delete called" << std::endl;
18
::operator delete(ptr); // 调用全局的 operator delete
19
}
20
};
21
22
int main() {
23
MyClass* objPtr = new MyClass(); // 调用 MyClass::operator new 和 MyClass 构造函数
24
delete objPtr; // 调用 MyClass 析构函数和 MyClass::operator delete
25
26
MyClass* objArrayPtr = new MyClass[2]; // 调用全局的 operator new (或重载的 operator new[]) 和两次 MyClass 默认构造函数
27
delete[] objArrayPtr; // 调用两次 MyClass 析构函数和全局的 operator delete (或重载的 operator delete[])
28
29
return 0;
30
}
8.7 总结
理解 new
和 delete
操作符与 operator new
和 operator delete
函数之间的区别至关重要。new
和 delete
是我们通常用来创建和销毁对象的语言特性,而 operator new
和 operator delete
是负责实际内存分配和释放的函数,我们可以对其进行自定义。正确地使用 new
, delete
, new[]
, 和 delete[]
对于避免内存泄漏和确保程序行为正确至关重要。
9. Use destructors to prevent resource leaks (用析构函数防止资源泄漏)
9.1 什么是资源泄漏
① 资源泄漏是指程序在使用完系统资源(例如内存、文件句柄、网络连接、互斥锁等)后,没有正确地将其释放或归还给系统,导致这些资源无法被其他部分或程序使用。
② 最常见的资源泄漏是内存泄漏,即通过 new
分配的内存在使用后没有通过 delete
释放。
9.2 析构函数的作用
① 析构函数 (Destructor) 是一个特殊的成员函数,当对象的生命周期结束时(例如对象离开其作用域,或者通过 delete
删除动态分配的对象),由系统自动调用。
② 析构函数的主要目的是执行对象销毁前的清理工作,例如释放对象所拥有的资源。
9.3 使用析构函数管理资源
9.3.1 内存管理
① 如果一个对象在其生命周期内分配了动态内存(例如使用 new
),那么应该在它的析构函数中使用 delete
(或 delete[]
如果是数组)来释放这些内存。
② 这种模式被称为 Resource Acquisition Is Initialization (RAII),即在对象创建时获取资源,并在对象销毁时释放资源。
1
class MyClass {
2
public:
3
MyClass(int size) : m_size(size), m_data(new int[size]) {
4
std::cout << "MyClass constructor allocating memory" << std::endl;
5
}
6
7
~MyClass() {
8
std::cout << "MyClass destructor releasing memory" << std::endl;
9
delete[] m_data;
10
}
11
12
private:
13
int m_size;
14
int* m_data;
15
};
16
17
void someFunction() {
18
MyClass obj(10); // obj 在栈上创建,离开作用域时析构函数会自动调用,释放内存
19
}
20
21
int main() {
22
someFunction();
23
24
MyClass* ptr = new MyClass(5); // ptr 指向的对象在堆上创建
25
delete ptr; // 需要显式调用 delete 来触发析构函数,释放内存
26
27
return 0;
28
}
9.3.2 文件句柄管理
① 如果一个对象打开了一个文件,那么应该在它的析构函数中关闭该文件。
1
#include <fstream>
2
3
class FileWriter {
4
public:
5
FileWriter(const std::string& filename) : m_file(filename) {
6
if (!m_file.is_open()) {
7
throw std::runtime_error("Could not open file");
8
}
9
std::cout << "FileWriter constructor opening file" << std::endl;
10
}
11
12
~FileWriter() {
13
std::cout << "FileWriter destructor closing file" << std::endl;
14
if (m_file.is_open()) {
15
m_file.close();
16
}
17
}
18
19
void write(const std::string& data) {
20
m_file << data << std::endl;
21
}
22
23
private:
24
std::ofstream m_file;
25
};
26
27
void writeFile() {
28
FileWriter writer("output.txt"); // writer 在栈上创建,离开作用域时文件会自动关闭
29
writer.write("This is some data.");
30
}
31
32
int main() {
33
writeFile();
34
return 0;
35
}
9.3.3 其他资源管理
① 类似的,析构函数可以用于释放网络连接、解锁互斥锁、释放系统句柄等各种需要在对象生命周期结束时清理的资源。
9.4 智能指针
① 对于动态分配的内存,使用智能指针(例如 std::unique_ptr
和 std::shared_ptr
)通常是更好的选择,因为它们可以自动管理内存的释放,无需显式编写 delete
语句,从而大大降低了内存泄漏的风险。智能指针在其析构函数中自动释放所管理的内存。
9.5 总结
析构函数是防止资源泄漏的关键机制。通过在析构函数中释放对象所拥有的资源,可以确保当对象不再需要时,这些资源能够被及时回收。结合 RAII 原则和智能指针的使用,可以更有效地管理资源,编写出更健壮和可靠的程序。
10. Prevent resource leaks in constructors (在构造函数中防止资源泄漏)
10.1 构造函数中的资源分配
① 构造函数通常负责对象的初始化,包括获取对象所需的资源(例如分配内存、打开文件、获取锁等)。
10.2 构造函数中发生异常时的资源泄漏问题
① 如果在构造函数执行过程中抛出异常,那么该对象将不会被完全构造出来。
② 对于部分构造的对象,其析构函数不会被调用。
③ 这意味着如果在构造函数中已经获取了一些资源,但在抛出异常之前没有释放它们,就会发生资源泄漏。
10.3 防止构造函数中资源泄漏的方法
10.3.1 使用 RAII (Resource Acquisition Is Initialization)
① RAII 是防止构造函数中资源泄漏的最有效方法。
② 原则:将资源的获取和管理与对象的生命周期绑定。在构造函数中获取资源,并将资源的管理责任交给拥有该资源的成员对象。这些成员对象通常是智能指针或其他实现了 RAII 的类。
③ 当构造函数抛出异常时,已经完全构造的成员对象的析构函数会被调用,从而释放它们所拥有的资源。
1
#include <memory>
2
#include <fstream>
3
#include <iostream>
4
5
class FileHandler { // RAII 类,管理文件句柄
6
public:
7
FileHandler(const std::string& filename) : file_(filename) {
8
if (!file_.is_open()) {
9
throw std::runtime_error("Could not open file: " + filename);
10
}
11
std::cout << "FileHandler opened file: " << filename << std::endl;
12
}
13
~FileHandler() {
14
std::cout << "FileHandler closing file" << std::endl;
15
if (file_.is_open()) {
16
file_.close();
17
}
18
}
19
std::ofstream& getFile() { return file_; }
20
private:
21
std::ofstream file_;
22
};
23
24
class MyClass {
25
public:
26
MyClass(const std::string& filename, int size) : file_(filename), data_(new int[size]) {
27
std::cout << "MyClass constructor allocating memory" << std::endl;
28
// 可能会有其他初始化操作,如果这些操作抛出异常,file_ 的析构函数仍然会被调用
29
if (size < 0) {
30
throw std::invalid_argument("Size cannot be negative");
31
}
32
}
33
~MyClass() {
34
std::cout << "MyClass destructor releasing memory" << std::endl;
35
delete[] data_;
36
}
37
private:
38
FileHandler file_; // 使用 RAII 管理文件
39
std::unique_ptr<int[]> data_; // 使用智能指针管理动态内存 (更推荐)
40
};
41
42
int main() {
43
try {
44
MyClass obj("example.txt", 10);
45
// ... 使用 obj ...
46
} catch (const std::exception& e) {
47
std::cerr << "Exception caught: " << e.what() << std::endl;
48
}
49
50
try {
51
MyClass obj2("error.txt", -5); // 构造函数会抛出异常
52
} catch (const std::exception& e) {
53
std::cerr << "Exception caught: " << e.what() << std::endl;
54
}
55
56
return 0;
57
}
在上面的例子中,FileHandler
类使用 RAII 来管理文件句柄。MyClass
的构造函数在初始化成员 file_
时尝试打开文件。如果打开失败抛出异常,MyClass
的构造函数会提前退出,但 file_
对象的析构函数仍然会被调用,从而保证文件被关闭(即使 MyClass
对象没有完全构造成功)。对于动态内存,使用 std::unique_ptr
也能达到类似的效果。
10.3.2 在构造函数中使用 try-catch 块(不推荐作为主要方法)
① 可以使用 try-catch
块捕获构造函数中可能抛出的异常,并在 catch
块中释放已经获取的资源,然后重新抛出异常,以防止资源泄漏。
② 这种方法比较繁琐,容易出错,并且与 RAII 的思想不符,通常不推荐作为主要方法。RAII 更简洁、更安全。
1
class MyClassWithTryCatch {
2
public:
3
MyClassWithTryCatch(int size) : m_data(nullptr) {
4
try {
5
m_data = new int[size];
6
std::cout << "MyClassWithTryCatch constructor allocating memory" << std::endl;
7
if (size < 0) {
8
throw std::invalid_argument("Size cannot be negative");
9
}
10
} catch (const std::exception& e) {
11
std::cerr << "Exception in constructor: " << e.what() << std::endl;
12
delete[] m_data; // 手动释放资源
13
m_data = nullptr;
14
throw; // 重新抛出异常
15
}
16
}
17
~MyClassWithTryCatch() {
18
std::cout << "MyClassWithTryCatch destructor releasing memory" << std::endl;
19
delete[] m_data;
20
}
21
private:
22
int* m_data;
23
};
10.4 总结
防止构造函数中的资源泄漏的关键在于使用 RAII 原则。通过将资源的获取和管理交给实现了 RAII 的成员对象(例如智能指针、自定义的资源管理类),可以确保即使构造函数抛出异常,已经获取的资源也能被正确地释放,从而避免资源泄漏。尽量避免在构造函数中手动管理资源并使用 try-catch 块,除非在非常特殊的情况下。
11. Prevent exceptions from leaving destructors (阻止异常从析构函数中逃离)
11.1 析构函数不应该抛出异常的原因
① 可能导致程序终止 (std::terminate):如果在异常处理过程中(例如在 catch
块中)调用了一个会抛出异常的析构函数,并且这个异常没有被捕获,C++ 运行时会调用 std::terminate
函数,通常会导致程序立即终止。
② 可能导致资源泄漏或状态不一致:当异常从析构函数逃逸时,可能会中断其他对象的析构过程。这可能导致某些对象的析构函数没有被执行,从而造成资源泄漏或对象处于未完成析构的状态。
11.2 如何阻止异常从析构函数逃逸
11.2.1 在析构函数内部捕获并处理异常
① 最常见的做法是在析构函数内部使用 try-catch
块来捕获任何可能抛出的异常。
② 在 catch
块中,应该对异常进行适当的处理,例如记录错误信息、清理部分资源等。
③ 不要让异常重新抛出析构函数。
1
#include <iostream>
2
#include <fstream>
3
4
class ResourceHolder {
5
std::ofstream file_;
6
public:
7
ResourceHolder(const std::string& filename) : file_(filename) {
8
file_.exceptions(std::ofstream::failbit | std::ofstream::badbit); // 设置抛出异常的条件
9
try {
10
file_ << "ResourceHolder created" << std::endl;
11
} catch (const std::ofstream::failure& e) {
12
std::cerr << "Exception in ResourceHolder constructor: " << e.what() << std::endl;
13
// 构造函数中抛出异常通常意味着对象无法正常创建,可能需要重新考虑设计
14
throw;
15
}
16
}
17
18
~ResourceHolder() {
19
std::cout << "ResourceHolder destructor called" << std::endl;
20
try {
21
file_ << "ResourceHolder being destroyed" << std::endl;
22
} catch (const std::ofstream::failure& e) {
23
std::cerr << "Exception in ResourceHolder destructor: " << e.what() << std::endl;
24
// 在析构函数中捕获异常,防止其逃逸
25
}
26
if (file_.is_open()) {
27
file_.close();
28
}
29
}
30
};
31
32
int main() {
33
try {
34
ResourceHolder rh("example.txt");
35
} catch (const std::exception& e) {
36
std::cerr << "Exception caught in main: " << e.what() << std::endl;
37
}
38
return 0;
39
}
在上面的例子中,ResourceHolder
的析构函数尝试写入文件。如果写入过程中发生异常(例如磁盘空间不足),catch
块会捕获这个异常并记录错误信息,但不会让异常逃逸析构函数。
11.2.2 使用 noexcept
说明符(C++11 及更高版本)
① 可以使用 noexcept
说明符来声明析构函数不会抛出异常。
② 如果一个声明为 noexcept
的函数在运行时抛出了异常,C++ 运行时会调用 std::terminate
。
③ 如果你知道你的析构函数不会抛出异常,或者你已经在内部处理了所有可能的异常,那么使用 noexcept
是一个好的实践。
1
#include <iostream>
2
#include <fstream>
3
4
class ResourceHolderNoexcept {
5
std::ofstream file_;
6
public:
7
ResourceHolderNoexcept(const std::string& filename) try : file_(filename) {
8
file_.exceptions(std::ofstream::failbit | std::ofstream::badbit);
9
file_ << "ResourceHolderNoexcept created" << std::endl;
10
} catch (const std::ofstream::failure& e) {
11
std::cerr << "Exception in ResourceHolderNoexcept constructor: " << e.what() << std::endl;
12
throw;
13
}
14
15
~ResourceHolderNoexcept() noexcept {
16
std::cout << "ResourceHolderNoexcept destructor called" << std::endl;
17
try {
18
if (file_.is_open()) {
19
file_ << "ResourceHolderNoexcept being destroyed" << std::endl;
20
file_.close();
21
}
22
} catch (...) {
23
// 记录错误,但不能抛出异常
24
std::cerr << "Exception during ResourceHolderNoexcept destruction" << std::endl;
25
}
26
}
27
};
28
29
int main() {
30
try {
31
ResourceHolderNoexcept rh("example_noexcept.txt");
32
} catch (const std::exception& e) {
33
std::cerr << "Exception caught in main: " << e.what() << std::endl;
34
}
35
return 0;
36
}
注意在 noexcept
的析构函数中,即使捕获了异常,通常也只能记录错误信息,而不能重新抛出。
11.3 特殊情况:虚析构函数
① 如果一个类是基类并且有虚函数,那么它的析构函数通常也应该是虚函数。
② 即使派生类的析构函数抛出了异常,如果基类的析构函数是虚函数,并且异常在基类的析构函数中被捕获,那么也能防止异常逃逸。
11.4 总结
析构函数应该尽力完成清理工作而不抛出异常。如果在析构函数中可能会发生异常,应该在析构函数内部捕获并处理它们,例如记录错误信息。使用 noexcept
说明符可以明确表明析构函数不应抛出异常。遵循这些原则可以避免程序因析构函数中的异常而终止,并有助于保证资源被正确释放和对象状态的一致性。
12. Understand how throwing an exception differs from passing a parameter or calling a virtual function (理解抛出异常与传递参数或调用虚函数的不同)
12.1 传递参数 (Passing a Parameter)
① 控制流:参数传递是函数调用的正常控制流的一部分。当一个函数被调用时,参数的值(或引用、指针)从调用者传递给被调用者。函数执行完毕后,控制权返回给调用者。
② 类型匹配:参数的类型在编译时确定,被调用函数的参数类型必须与传递的参数类型兼容(可以进行隐式转换)。
③ 作用域:参数在被调用函数的作用域内有效。
④ 生命周期:参数的生命周期与被调用函数的执行周期相关。
12.2 调用虚函数 (Calling a Virtual Function)
① 多态行为:虚函数调用允许在运行时根据对象的实际类型来执行相应的函数版本。这需要在运行时进行类型查找(通常通过虚函数表)。
② 控制流:虚函数调用也是函数调用的正常控制流的一部分。
③ 类型匹配:虚函数的签名(参数类型、返回类型、const 限定符)在基类和派生类中必须匹配(或满足协变返回类型)。
④ 作用域和生命周期:与普通的成员函数调用类似。
12.3 抛出异常 (Throwing an Exception)
① 异常控制流:抛出异常是一种非局部控制流。当异常被抛出时,程序的正常执行流程被打断。
② 栈展开 (Stack Unwinding):运行时系统会沿着调用栈向上查找能够捕获该类型异常的 catch
块。在这个过程中,从异常抛出点到 catch
块之间的所有栈帧上的局部对象都会被销毁(它们的析构函数会被调用)。
③ 类型匹配:异常对象具有类型,catch
块通过异常的类型来捕获异常。可以捕获特定类型的异常,也可以使用 catch (...)
捕获任何类型的异常。
④ 作用域和生命周期:异常对象通常是一个临时对象,其生命周期会持续到被相应的 catch
块处理完毕。
⑤ 动态类型:异常对象在抛出时会被复制(或移动),catch
块捕获的是这个副本。如果抛出的是一个基类类型的异常,而 catch
块捕获的是派生类类型,可能会发生对象切片 (object slicing)。
12.4 主要区别总结
特性 | 传递参数 | 调用虚函数 | 抛出异常 |
---|---|---|---|
控制流 | 正常函数调用 | 正常函数调用,但具有多态性 | 非局部控制流,导致栈展开 |
类型匹配 | 编译时确定,需要类型兼容 | 编译时确定,签名必须匹配(或满足协变返回类型) | 运行时匹配,通过异常类型 |
作用域 | 参数在被调用函数作用域内有效 | 与普通成员函数调用类似 | 异常对象的生命周期持续到被捕获处理完毕 |
生命周期 | 与被调用函数执行周期相关 | 与普通成员函数调用类似 | 临时对象,生命周期到捕获结束 |
多态性 | 不涉及 | 涉及,根据对象实际类型执行 | 可以抛出任何类型的对象,catch 可以捕获基类或派生类 |
栈展开 | 无 | 无 | 有,从抛出点到 catch 块之间的局部对象会被销毁 |
目的 | 向函数传递数据 | 根据对象类型执行特定操作 | 通知发生了异常情况,需要特殊处理 |
12.5 示例代码片段
1
#include <iostream>
2
#include <stdexcept>
3
4
class Base {
5
public:
6
virtual void process() {
7
std::cout << "Base::process()" << std::endl;
8
}
9
};
10
11
class Derived : public Base {
12
public:
13
void process() override {
14
std::cout << "Derived::process()" << std::endl;
15
}
16
};
17
18
void myFunction(int value) { // 传递参数
19
std::cout << "myFunction received: " << value << std::endl;
20
if (value < 0) {
21
throw std::invalid_argument("Value cannot be negative"); // 抛出异常
22
}
23
}
24
25
int main() {
26
int data = 10;
27
myFunction(data); // 正常函数调用,传递参数
28
29
Base* obj = new Derived();
30
obj->process(); // 调用虚函数,执行 Derived::process()
31
32
try {
33
myFunction(-5); // 抛出异常
34
} catch (const std::invalid_argument& e) {
35
std::cerr << "Caught exception: " << e.what() << std::endl; // 捕获并处理异常
36
}
37
38
delete obj;
39
return 0;
40
}
12.6 总结
理解抛出异常与传递参数或调用虚函数的不同对于编写健壮和可维护的 C++ 代码至关重要。异常处理是一种用于处理程序运行时出现的意外或错误情况的机制,它具有独特的控制流和类型匹配方式,并涉及栈展开。与正常的函数调用机制有着本质的区别。
13. Catch exceptions by reference (通过引用捕获异常)
13.1 捕获异常的三种方式
① 按值捕获 (Catch by Value):catch (ExceptionType e)
② 按引用捕获 (Catch by Reference):catch (ExceptionType& e)
或 catch (const ExceptionType& e)
③ 按指针捕获 (Catch by Pointer):catch (ExceptionType* e)
13.2 按值捕获的问题
13.2.1 对象切片 (Object Slicing)
① 当抛出一个派生类对象,并按基类类型的值捕获时,会发生对象切片。
② 只有基类部分的成员变量会被复制到捕获的异常对象中,派生类特有的成员变量和信息会丢失。
③ 这会导致 catch
块无法访问到异常的完整信息,尤其是在使用多态异常类型时。
1
#include <iostream>
2
#include <stdexcept>
3
4
class BaseException : public std::runtime_error {
5
public:
6
BaseException(const std::string& msg) : std::runtime_error(msg) {}
7
virtual std::string getDetails() const { return what(); }
8
};
9
10
class DerivedException : public BaseException {
11
public:
12
DerivedException(const std::string& msg, int code) : BaseException(msg), error_code_(code) {}
13
std::string getDetails() const override {
14
return what() + " (Error Code: " + std::to_string(error_code_) + ")";
15
}
16
private:
17
int error_code_;
18
};
19
20
void throwException() {
21
throw DerivedException("Something went wrong", 42);
22
}
23
24
int main() {
25
try {
26
throwException();
27
} catch (BaseException e) { // 按值捕获
28
std::cerr << "Caught BaseException by value: " << e.what() << std::endl;
29
std::cerr << "Details: " << e.getDetails() << std::endl; // 调用的是 BaseException::getDetails()
30
}
31
return 0;
32
}
在上面的例子中,抛出的是 DerivedException
,但按 BaseException
的值捕获时,DerivedException
特有的 error_code_
信息丢失了。
13.2.2 性能开销
① 按值捕获会创建一个异常对象的副本,这可能会有额外的性能开销,特别是当异常对象很大时。
13.3 按指针捕获的问题
13.3.1 所有权和生命周期管理
① 如果按指针捕获异常,catch
块会接收到一个指向异常对象的指针。
② catch
块需要知道谁负责释放这个异常对象所占用的内存。
③ 如果异常是在 throw
语句中创建的临时对象,那么在 catch
块结束后,这个临时对象的生命周期如何管理就成了问题。
④ 通常不建议按指针捕获异常,除非你知道异常对象是在堆上分配的,并且 catch
块负责释放它(这通常不是异常处理的惯用方式)。
13.4 按引用捕获的优点
13.4.1 避免对象切片
① 按引用捕获异常时,catch
块接收到的是原始异常对象的引用,而不是副本。
② 这保留了异常对象的实际类型,从而可以正确地调用派生类特有的成员函数(包括虚函数),避免了对象切片的问题。
1
#include <iostream>
2
#include <stdexcept>
3
4
class BaseException : public std::runtime_error {
5
public:
6
BaseException(const std::string& msg) : std::runtime_error(msg) {}
7
virtual std::string getDetails() const { return what(); }
8
};
9
10
class DerivedException : public BaseException {
11
public:
12
DerivedException(const std::string& msg, int code) : BaseException(msg), error_code_(code) {}
13
std::string getDetails() const override {
14
return what() + " (Error Code: " + std::to_string(error_code_) + ")";
15
}
16
private:
17
int error_code_;
18
};
19
20
void throwException() {
21
throw DerivedException("Something went wrong", 42);
22
}
23
24
int main() {
25
try {
26
throwException();
27
} catch (const BaseException& e) { // 按常量引用捕获
28
std::cerr << "Caught BaseException by reference: " << e.what() << std::endl;
29
std::cerr << "Details: " << e.getDetails() << std::endl; // 调用的是 DerivedException::getDetails()
30
}
31
return 0;
32
}
在这个修改后的例子中,按 const BaseException&
捕获异常时,getDetails()
调用的是 DerivedException
的版本,因为 e
是原始 DerivedException
对象的引用。
13.4.2 避免不必要的拷贝
① 按引用捕获避免了创建异常对象的副本,从而提高了性能,特别是对于大型异常对象。
13.4.3 允许修改异常对象(如果不需要 const
引用)
① 虽然通常不建议在 catch
块中修改异常对象,但按非常量引用捕获是允许的。
13.5 总结
强烈建议通过引用(通常是常量引用 const &
)来捕获异常。这样做可以避免对象切片,提高性能,并且不需要处理异常对象的内存管理问题。按值捕获会导致对象切片和额外的拷贝开销,而按指针捕获则涉及到复杂的所有权管理。因此,按引用捕获是处理异常的最佳实践。
14. Use exception specifications judiciously (谨慎使用异常规范)
14.1 什么是异常规范 (Exception Specifications)
① 异常规范是函数声明的一部分,用于声明函数可能抛出的异常类型。
② 在 C++11 之前,异常规范有两种形式:
* throw()
:表示函数不抛出任何异常。
* throw(Type1, Type2, ...)
:表示函数可能抛出 Type1
, Type2
或其派生类型的异常。如果函数抛出了不在规范列表中的异常,会调用 std::unexpected
函数,默认情况下会调用 std::terminate
终止程序。
14.2 C++11 引入的 noexcept
① C++11 引入了 noexcept
关键字作为异常规范的替代品。
② noexcept
说明符有两种形式:
* noexcept
或 noexcept(true)
:表示函数不抛出任何异常。
* noexcept(false)
:表示函数可能抛出异常(与没有 noexcept
说明符效果相同)。
③ 与旧的 throw()
相比,noexcept
提供了更好的编译器优化机会,并且在违反规范时行为更明确(调用 std::terminate
)。
14.3 谨慎使用异常规范的原因
14.3.1 旧的异常规范的问题
① 运行时检查:旧的异常规范是在运行时强制执行的。如果函数抛出了不在规范中的异常,会导致 std::unexpected
被调用,这可能会导致程序终止。
② 与模板和泛型代码的兼容性问题:很难为模板函数或泛型代码编写准确的异常规范,因为它们可能与不同类型的参数一起使用,而这些参数可能抛出不同的异常。
③ 维护困难:当函数内部实现发生变化,可能抛出新的异常时,需要更新异常规范。如果忘记更新,可能会导致运行时错误。
④ 限制了函数的实现:为了满足异常规范,函数可能需要捕获并转换其内部调用的函数可能抛出的异常,这增加了复杂性。
14.3.2 noexcept
的使用场景
① 明确保证不抛出异常的函数:例如移动构造函数、移动赋值操作符、析构函数(通常应该如此)。使用 noexcept
可以让编译器进行更好的优化。
② 底层库函数:某些底层库函数可能需要保证不抛出异常。
14.3.3 何时应该避免使用异常规范(特别是旧的 throw(...)
)
① 大多数情况下:由于旧的异常规范存在上述问题,现代 C++ 编程风格倾向于避免使用 throw(...)
形式的异常规范。
② 不确定函数是否会抛出异常时。
③ 在编写模板或泛型代码时。
14.4 建议
14.4.1 优先使用 noexcept
① 对于确实不会抛出异常的函数,使用 noexcept
或 noexcept(true)
进行标记。这有助于编译器进行优化,并且在违反规范时行为是明确的(程序终止)。
14.4.2 避免使用 throw(...)
形式的异常规范
① 除非有非常特殊的原因和充分的理解,否则应该避免使用旧的异常规范。
14.4.3 考虑文档说明异常
① 对于可能抛出异常的函数,最好通过文档(例如函数注释)来说明可能抛出的异常类型和条件,而不是依赖于异常规范。
14.4.4 注意虚函数的异常规范
① 派生类中重写的虚函数的异常规范不能比基类中对应虚函数的异常规范更严格。如果基类虚函数声明为可能抛出某种异常,那么派生类的重写版本也必须允许抛出至少相同类型的异常(或者更多)。如果基类虚函数声明为 noexcept
,那么派生类的重写版本也必须是 noexcept
的。
14.5 总结
异常规范(特别是旧的 throw(...)
形式)在实践中存在一些问题,可能导致代码更复杂且难以维护。C++11 引入的 noexcept
是一个更好的选择,用于明确声明函数不抛出异常,从而允许编译器进行优化。在大多数情况下,对于可能抛出异常的函数,最好通过文档来说明,而不是使用异常规范。谨慎地使用异常规范,并优先考虑 noexcept
。
15. Understand the costs of exception handling (了解异常处理的成本)
15.1 正常情况下的开销
① 栈展开 (Stack Unwinding) 信息的维护:即使没有异常抛出,编译器也需要在生成的目标代码中维护一些额外的信息,以便在发生异常时能够正确地展开调用栈,并调用栈上局部对象的析构函数。这可能会带来一定的性能开销,尽管在现代编译器中这种开销通常很小。
② 代码大小增加:为了支持异常处理,生成的目标代码可能会更大一些。
15.2 抛出和捕获异常时的开销
15.2.1 异常对象的创建和销毁
① 当异常被抛出时,通常会创建一个异常对象的副本(或移动)。这个过程涉及到内存分配和拷贝(或移动)操作,这会产生一定的开销。
② 当异常被 catch
块处理完毕后,异常对象会被销毁,这也会有开销。
15.2.2 栈展开过程
① 栈展开是一个比较昂贵的操作。当异常抛出后,运行时系统需要遍历调用栈中的每一层,找到合适的 catch
块。
② 在展开过程中,每个栈帧上的局部对象的析构函数都需要被调用。如果栈很深,或者析构函数本身很复杂,这个过程可能会消耗大量的时间。
15.2.3 查找 catch
块
① 运行时系统需要根据异常的类型来找到合适的 catch
块。这可能涉及到类型匹配和比较,也会有一定的开销。
15.3 与其他错误处理机制的比较
① 返回错误码:返回错误码是一种更轻量级的错误处理方式。它不会涉及栈展开和异常对象的创建,但需要在每个可能出错的地方检查错误码,并且错误信息可能不够丰富。
② 断言 (Assertions):断言通常用于在开发阶段检查程序的内部状态是否正确。如果断言失败,程序会立即终止。断言的开销通常很小(在发布版本中可能会被禁用)。断言不适合处理运行时可能发生的预期错误。
15.4 何时应该使用异常处理
① 处理真正异常的情况 (Exceptional Conditions):异常处理最适合用于处理那些不应该在正常情况下发生的错误,例如内存分配失败、文件无法打开、网络连接中断等。
② 将错误处理代码与正常代码分离:异常处理可以将错误处理的逻辑集中在 catch
块中,使正常代码更清晰。
③ 跨越函数调用栈传递错误信息:异常可以自动地沿着调用栈向上查找合适的处理程序,而无需在每个函数中显式地传递错误码。
15.5 何时不应该使用异常处理
① 用于正常的控制流:异常处理的开销比正常的控制流(例如 if
语句、循环)要大得多,不应该用作正常的程序逻辑。
② 在性能至关重要的代码中:如果一段代码的性能要求非常高,并且错误发生的概率很低,可以考虑使用其他更轻量级的错误处理方式。
15.6 最佳实践
① 只在真正需要时才抛出异常。
② 避免在频繁调用的代码路径中抛出异常。
③ 捕获你能够处理的异常。不要捕获所有异常 (catch (...)
) 而不进行任何处理。
④ 抛出异常时,提供足够的信息,以便能够诊断和解决问题。
⑤ 考虑使用自定义的异常类型,以便更精确地捕获和处理特定类型的错误。
15.7 总结
异常处理是一种强大而灵活的错误处理机制,但在性能上也有一定的成本。理解这些成本有助于我们合理地使用异常处理,将其应用于真正异常的情况,避免滥用,从而编写出更高效和健壮的程序。在性能敏感的场景下,需要仔细权衡使用异常处理与其他错误处理机制的利弊。
16. Remember the 80-20 rule (牢记80-20法则)
16.1 什么是 80-20 法则 (帕累托原则)
① 80-20 法则,也称为帕累托原则,是一种普遍存在的现象,它指出大约 80% 的结果来源于 20% 的原因。
② 这个原则最初由意大利经济学家维尔弗雷多·帕累托在 19 世纪末提出,他观察到意大利大约 80% 的财富掌握在 20% 的人口手中。
16.2 80-20 法则在软件开发中的应用
16.2.1 性能优化
① 80% 的性能问题通常是由 20% 的代码引起的。这意味着在优化软件性能时,应该将主要的精力放在识别和优化这 20% 的关键代码上,而不是试图优化每一行代码。
② 可以使用性能分析工具(profilers)来帮助找到这 20% 的热点代码。
16.2.2 缺陷管理
① 80% 的软件缺陷可能集中在 20% 的代码模块中。这意味着测试和代码审查工作应该更侧重于这些容易出错的模块。
16.2.3 功能使用
① 用户通常只使用 20% 的软件功能来完成他们 80% 的任务。这对于产品设计和用户界面优化非常重要,可以帮助团队专注于最常用的功能。
16.2.4 项目管理
① 80% 的项目延迟可能由 20% 的任务引起。识别这些关键任务并合理安排资源可以帮助更好地管理项目进度。
16.2.5 代码维护
① 80% 的代码维护工作可能集中在 20% 的代码上。这可能是因为这部分代码更复杂、更频繁地被修改,或者包含更多的缺陷。
16.3 如何应用 80-20 法则
16.3.1 性能优化
① 进行性能分析:使用 profiler 找出程序中耗时最多的部分。
② 集中优化:将优化工作集中在这些热点代码上。
③ 不要过早优化:在没有实际性能问题之前不要进行优化。
16.3.2 缺陷管理
① 重点测试高风险模块:根据历史数据或代码复杂性分析,识别可能包含更多缺陷的模块,并进行更彻底的测试。
② 加强代码审查:对关键模块的代码进行更细致的审查。
16.3.3 功能开发
① 识别核心功能:了解用户最常用的功能,并在开发过程中优先考虑和完善这些功能。
16.4 注意事项
① 80-20 法则只是一个经验法则,实际比例可能因具体情况而异。
② 不要盲目地认为任何情况下都是 80% 和 20%。关键在于识别出少数的关键因素,它们对整体结果产生了不成比例的影响。
16.5 总结
牢记 80-20 法则可以帮助我们在软件开发过程中更有效地分配时间和资源。通过识别出少数的关键部分(例如性能瓶颈、高风险模块、核心功能),我们可以将主要的精力投入到这些地方,从而在有限的资源下获得最大的效益。
17. Consider using lazy evaluation (考虑使用延迟计算)
17.1 什么是延迟计算 (Lazy Evaluation)
① 延迟计算,也称为惰性求值,是一种求值策略,它将表达式的求值延迟到真正需要其结果时才进行。
② 与之相对的是及早求值 (eager evaluation),即在表达式被定义或赋值时立即进行求值。
17.2 延迟计算的优点
17.2.1 提高性能
① 如果表达式的结果在某些情况下不需要被使用,那么延迟计算可以避免不必要的计算,从而提高程序的性能。
17.2.2 处理潜在的无限数据结构
① 延迟计算使得定义和操作潜在的无限数据结构成为可能。只有当需要访问数据结构中的元素时,才会计算该元素的值。
17.2.3 简化控制流
① 在某些情况下,延迟计算可以简化代码的控制流,例如在条件判断中,只有当第一个条件不满足时才需要计算第二个条件。
17.2.4 节省内存
① 对于大型数据结构,延迟计算可以只在需要时生成和存储数据,从而节省内存空间。
17.3 C++ 中实现延迟计算的方式
17.3.1 使用代理对象 (Proxy Objects)
① 可以创建一个代理对象来表示一个尚未计算的值。当需要访问该值时,代理对象会执行计算并返回结果。
1
#include <iostream>
2
#include <string>
3
4
class LazyString {
5
private:
6
std::function<std::string()> generator;
7
std::string* cachedValue;
8
bool evaluated;
9
10
public:
11
LazyString(std::function<std::string()> gen) : generator(gen), cachedValue(nullptr), evaluated(false) {}
12
13
~LazyString() {
14
delete cachedValue;
15
}
16
17
const std::string& get() const {
18
if (!evaluated) {
19
cachedValue = new std::string(generator());
20
evaluated = true;
21
}
22
return *cachedValue;
23
}
24
};
25
26
std::string expensiveOperation() {
27
std::cout << "Expensive operation called" << std::endl;
28
return "Result of expensive operation";
29
}
30
31
int main() {
32
LazyString lazyResult(expensiveOperation); // expensiveOperation 尚未被调用
33
std::cout << "LazyString created" << std::endl;
34
35
if (true) {
36
std::cout << "Result: " << lazyResult.get() << std::endl; // 第一次调用 get() 时执行 expensiveOperation
37
}
38
39
if (false) {
40
std::cout << "This will not be printed" << std::endl;
41
// lazyResult.get(); // 如果条件为 false,expensiveOperation 不会被调用
42
}
43
44
std::cout << "Second access: " << lazyResult.get() << std::endl; // 直接返回缓存的结果
45
return 0;
46
}
17.3.2 使用迭代器 (Iterators) 和生成器 (Generators)
① 迭代器可以用于按需生成序列中的元素。生成器函数(在其他语言中常见,C++ 中可以通过协程实现,或者手动构建状态机)可以延迟产生序列的值。
17.3.3 表达式模板 (Expression Templates)
① 表达式模板是一种高级技术,用于延迟计算复杂数值表达式,直到需要结果时才进行。这在科学计算库中很常见。
17.3.4 Lambda 表达式和 std::function
① 可以使用 lambda 表达式和 std::function
来封装需要延迟执行的代码。
1
#include <iostream>
2
#include <functional>
3
4
std::function<int(int)> createMultiplier(int factor) {
5
return [factor](int x) {
6
std::cout << "Multiplying " << x << " by " << factor << std::endl;
7
return x * factor;
8
};
9
}
10
11
int main() {
12
auto multiplyByTwo = createMultiplier(2); // 乘法操作尚未执行
13
std::cout << "Multiplier created" << std::endl;
14
15
int result1 = multiplyByTwo(5); // 第一次调用时执行乘法
16
std::cout << "Result 1: " << result1 << std::endl;
17
18
int result2 = multiplyByTwo(10); // 第二次调用时执行乘法
19
std::cout << "Result 2: " << result2 << std::endl;
20
21
return 0;
22
}
17.4 何时考虑使用延迟计算
① 当存在昂贵的计算,但其结果并非总是需要时。
② 当需要处理潜在的无限数据流时。
③ 当需要构建复杂的计算流程,但希望在需要最终结果时才执行整个流程时。
17.5 延迟计算的缺点
① 延迟计算可能会增加代码的复杂性,使得程序的行为更难预测。
② 可能会引入额外的间接层,导致轻微的性能损失(例如访问代理对象)。
17.6 总结
延迟计算是一种强大的优化技术,可以在某些情况下显著提高性能和灵活性。在 C++ 中,可以通过代理对象、迭代器、表达式模板和 lambda 表达式等方式来实现延迟计算。需要根据具体的应用场景权衡其优点和缺点,决定是否使用延迟计算。
18. Amortize the cost of expected computations (分摊预期计算的成本)
18.1 什么是成本分摊 (Amortization)
① 在计算机科学中,成本分摊是指在执行一系列操作时,将一个或多个开销较大的操作的成本分散到其他操作中,使得一系列操作的平均成本较低。
② 目标是避免在单个操作中出现极高的开销,从而提高整体的性能和响应性。
18.2 成本分摊的常见策略
18.2.1 预先计算 (Pre-computation)
① 如果某些计算的结果会被频繁使用,可以在程序启动或对象创建时预先计算好这些结果,并将它们存储起来。
② 这样,在后续使用时就可以直接获取结果,避免了重复计算的开销。
1
#include <iostream>
2
#include <vector>
3
#include <cmath>
4
5
class PrimeChecker {
6
private:
7
std::vector<bool> primes;
8
int maxNumber;
9
10
public:
11
PrimeChecker(int max) : maxNumber(max), primes(max + 1, true) {
12
if (max >= 0) primes[0] = primes[1] = false;
13
for (int p = 2; p * p <= max; p++) {
14
if (primes[p]) {
15
for (int i = p * p; i <= max; i += p)
16
primes[i] = false;
17
}
18
}
19
std::cout << "Prime numbers up to " << max << " pre-computed." << std::endl;
20
}
21
22
bool isPrime(int n) const {
23
if (n < 0 || n > maxNumber) {
24
return false; // Or throw an exception
25
}
26
return primes[n];
27
}
28
};
29
30
int main() {
31
PrimeChecker checker(1000); // 预先计算 1000 以内的素数
32
33
std::cout << "Is 17 prime? " << checker.isPrime(17) << std::endl; // 直接查询预先计算的结果
34
std::cout << "Is 997 prime? " << checker.isPrime(997) << std::endl;
35
std::cout << "Is 1001 prime? " << checker.isPrime(1001) << std::endl;
36
37
return 0;
38
}
18.2.2 延迟初始化 (Lazy Initialization)
① 有些对象或资源可能只有在被第一次使用时才需要初始化。延迟初始化可以将初始化的成本推迟到第一次使用时。
1
#include <iostream>
2
#include <string>
3
#include <memory>
4
5
class Configuration {
6
private:
7
std::unique_ptr<std::string> settings;
8
9
public:
10
const std::string& getSetting(const std::string& key) {
11
if (!settings) {
12
loadSettings(); // 只有在第一次需要时才加载配置
13
}
14
// ... 从 settings 中查找 key 对应的配置 ...
15
return *settings; // 假设 settings 存储了所有配置
16
}
17
18
private:
19
void loadSettings() {
20
std::cout << "Loading configuration..." << std::endl;
21
settings = std::make_unique<std::string>("Some configuration data");
22
}
23
};
24
25
int main() {
26
Configuration config;
27
// 在第一次调用 getSetting 之前,配置不会被加载
28
std::cout << "Configuration object created." << std::endl;
29
std::cout << "Setting: " << config.getSetting("someKey") << std::endl; // 触发配置加载
30
std::cout << "Setting again: " << config.getSetting("anotherKey") << std::endl; // 直接使用已加载的配置
31
return 0;
32
}
18.2.3 增量计算 (Incremental Computation)
① 如果需要进行一系列相似的计算,可以尝试利用前一次计算的结果来加速下一次计算,而不是每次都从头开始。
18.2.4 缓存 (Caching)
① 将昂贵计算的结果存储起来,以便后续可以直接使用,而无需重新计算。
18.3 动态数组的扩容
① std::vector
等动态数组在容量不足时通常会重新分配一块更大的内存,并将现有元素复制过去。为了分摊这个扩容的成本,它们通常会以指数级别(例如每次扩容为当前容量的 1.5 倍或 2 倍)增加容量。这样,虽然单次扩容的成本可能较高,但平均到每次 push_back
操作上,成本就相对较低。
18.4 何时考虑成本分摊
① 当程序中存在一些开销较大的操作,但这些操作的结果会被频繁使用,或者这些操作可以被分解为一系列增量步骤时。
② 当希望提高程序的响应性,避免出现长时间的卡顿时。
18.5 注意事项
① 成本分摊通常会增加程序的复杂性。
② 需要仔细权衡预先计算、延迟初始化或缓存所带来的好处和额外的内存开销。
18.6 总结
成本分摊是一种重要的优化策略,可以通过预先计算、延迟初始化、增量计算和缓存等方式,将高昂的计算成本分散到一系列操作中,从而提高程序的整体性能和用户体验。在设计程序时,应该考虑哪些计算是预期会发生的,并思考如何分摊这些计算的成本。
19. Understand the origin of temporary objects (理解临时对象的来源)
19.1 什么是临时对象
① 临时对象是没有名字的、生命周期有限的对象。它们通常由编译器在幕后创建,用于存储中间计算结果或满足函数调用的需求。
② 临时对象在创建它们的那条完整表达式结束时(或者在某些特殊情况下,如返回值优化时)会被销毁。
19.2 临时对象的常见来源
19.2.1 函数返回非引用或非指针类型的对象
① 当一个函数返回一个按值传递的对象时,编译器通常会创建一个临时对象来存储函数的返回值。这个临时对象会被用于初始化接收返回值的变量,或者在表达式中使用。
1
#include <iostream>
2
3
class MyClass {
4
public:
5
MyClass() { std::cout << "MyClass default constructor" << std::endl; }
6
MyClass(int value) : data(value) { std::cout << "MyClass constructor with value " << value << std::endl; }
7
MyClass(const MyClass& other) : data(other.data) { std::cout << "MyClass copy constructor" << std::endl; }
8
~MyClass() { std::cout << "MyClass destructor" << std::endl; }
9
int data;
10
};
11
12
MyClass createObject() {
13
return MyClass(42); // 返回一个临时对象
14
}
15
16
int main() {
17
std::cout << "Start main" << std::endl;
18
MyClass obj = createObject(); // 临时对象被用于拷贝构造 obj
19
std::cout << "End main" << std::endl;
20
return 0;
21
}
在这个例子中,createObject
函数返回的 MyClass(42)
就是一个临时对象。它首先被创建,然后用于拷贝构造 obj
,最后在 createObject
函数返回后被销毁。
19.2.2 隐式类型转换
① 当一个操作符或函数期望一个特定类型的参数,但接收到了一个可以隐式转换为该类型的参数时,编译器会创建一个临时对象来执行这个转换。
1
#include <iostream>
2
3
class Rational {
4
public:
5
Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {
6
std::cout << "Rational constructor (" << num << ", " << den << ")" << std::endl;
7
}
8
Rational(const Rational& other) : numerator(other.numerator), denominator(other.denominator) {
9
std::cout << "Rational copy constructor" << std::endl;
10
}
11
~Rational() {
12
std::cout << "Rational destructor (" << numerator << ", " << denominator << ")" << std::endl;
13
}
14
Rational operator+(const Rational& other) const {
15
return Rational(numerator * other.denominator + other.numerator * denominator, denominator * other.denominator);
16
}
17
void print() const { std::cout << numerator << "/" << denominator << std::endl; }
18
private:
19
int numerator;
20
int denominator;
21
};
22
23
int main() {
24
Rational r1(1, 2);
25
Rational r2 = r1 + 3; // 整数 3 被隐式转换为 Rational(3, 1) 临时对象
26
r2.print();
27
return 0;
28
}
在这里,整数 3
被隐式转换为 Rational(3, 1)
临时对象,然后与 r1
相加。这个临时对象在表达式结束后被销毁。
19.2.3 函数参数按值传递
① 当一个函数参数是按值传递时,如果传递的实参不是期望的类型,可能会先创建一个临时对象进行类型转换,然后将该临时对象传递给函数。
19.2.4 操作符重载的返回值
① 当重载二元操作符(例如 +
, -
, *
等)时,通常会返回一个新的对象作为结果,这个返回的对象通常是一个临时对象。
19.2.5 类型转换
① 显式地进行类型转换也可能创建临时对象。例如,将一个 int
转换为 double
时,可能会创建一个临时的 double
对象。
19.3 临时对象的生命周期扩展 (Lifetime Extension of Temporaries)
① 在某些特定情况下,临时对象的生命周期会被延长,以避免立即销毁。最常见的情况是当一个临时对象被绑定到一个 const
左值引用时。
1
#include <iostream>
2
#include <string>
3
4
std::string getString() {
5
return "Hello"; // 返回一个临时的 std::string 对象
6
}
7
8
int main() {
9
const std::string& message = getString(); // 临时对象的生命周期被延长到 message 的生命周期结束
10
std::cout << message << std::endl;
11
return 0;
12
}
在这个例子中,getString()
返回的临时 std::string
对象的生命周期被延长,因为它被绑定到了 const std::string& message
上。
19.4 临时对象的开销
① 创建和销毁临时对象会有一定的性能开销,特别是对于构造和析构成本较高的对象。
② 过多的临时对象创建可能会导致程序运行效率降低。
19.5 如何减少临时对象的创建
① 使用引用返回:如果可能,函数可以返回对象的引用而不是值。
② 使用 const
引用作为函数参数:避免不必要的拷贝。
③ 利用返回值优化 (RVO) 和具名返回值优化 (NRVO):编写代码以帮助编译器进行这些优化。
④ 考虑使用移动语义 (Move Semantics):在 C++11 及更高版本中,移动语义可以避免不必要的拷贝操作。
19.6 总结
理解临时对象的来源和生命周期对于编写高效的 C++ 代码非常重要。通过了解哪些操作会导致临时对象的创建,我们可以采取一些措施来减少不必要的临时对象的产生,从而提高程序的性能。
20. Facilitate the return value optimization (协助返回值优化)
20.1 什么是返回值优化 (Return Value Optimization, RVO)
① 返回值优化 (RVO) 是一种编译器优化技术,它允许编译器省略掉函数返回的临时对象的创建。
② 当函数返回一个按值传递的对象时,通常会创建一个临时对象来存储返回值。RVO 允许编译器直接在接收返回值的目标变量的内存位置上构造对象,从而避免了临时对象的创建和拷贝或移动操作。
20.2 具名返回值优化 (Named Return Value Optimization, NRVO)
① 具名返回值优化 (NRVO) 是 RVO 的一种特殊形式,发生在函数返回一个局部具名对象时。
② 编译器可以直接在调用者提供的内存位置上构造这个局部对象,而不是先在函数内部构造一个临时对象,然后再拷贝或移动到调用者的位置。
20.3 如何协助编译器进行 RVO 和 NRVO
20.3.1 直接返回构造的对象
① 当函数需要返回一个新创建的对象时,直接在 return
语句中构造该对象,而不是先创建一个局部对象再返回。
1
#include <iostream>
2
3
class MyClass {
4
public:
5
MyClass() { std::cout << "MyClass default constructor" << std::endl; }
6
MyClass(int value) : data(value) { std::cout << "MyClass constructor with value " << value << std::endl; }
7
MyClass(const MyClass& other) : data(other.data) { std::cout << "MyClass copy constructor" << std::endl; }
8
MyClass(MyClass&& other) : data(other.data) { std::cout << "MyClass move constructor" << std::endl; }
9
~MyClass() { std::cout << "MyClass destructor" << std::endl; }
10
int data;
11
};
12
13
MyClass createObjectRVO() {
14
return MyClass(42); // 协助 RVO
15
}
16
17
int main() {
18
std::cout << "Start main (RVO)" << std::endl;
19
MyClass objRVO = createObjectRVO();
20
std::cout << "End main (RVO)" << std::endl;
21
return 0;
22
}
在上面的例子中,createObjectRVO
直接返回 MyClass(42)
,这更有可能触发 RVO。
20.3.2 返回局部具名对象
① 当函数需要返回一个局部创建的对象时,直接返回该局部对象。
1
MyClass createObjectNRVO() {
2
MyClass localObj(42); // 局部具名对象
3
return localObj; // 协助 NRVO
4
}
5
6
int main() {
7
std::cout << "Start main (NRVO)" << std::endl;
8
MyClass objNRVO = createObjectNRVO();
9
std::cout << "End main (NRVO)" << std::endl;
10
return 0;
11
}
这里,createObjectNRVO
返回局部对象 localObj
,这更有可能触发 NRVO。
20.3.3 避免在返回语句中使用临时变量
① 尽量避免先将要返回的对象赋值给一个临时变量,然后再返回该临时变量。
1
// 不利于 NRVO
2
MyClass createObjectNoNRVO() {
3
MyClass localObj(42);
4
MyClass temp = localObj;
5
return temp;
6
}
20.3.4 确保只有一个返回路径返回具名对象
① 如果函数有多个返回路径,并且不是所有路径都返回同一个具名对象,那么 NRVO 可能不会发生。
20.3.5 编译器优化选项
① 编译器需要启用优化选项(例如 -O2
, -O3
在 GCC 和 Clang 中,/Ox
在 MSVC 中)才能进行 RVO 和 NRVO。在调试模式下,这些优化通常是关闭的。
20.4 RVO 和 NRVO 的好处
① 减少拷贝或移动操作:避免了临时对象的创建和拷贝或移动,提高了性能。
② 减少构造和析构函数的调用:减少了不必要的对象生命周期管理。
20.5 注意事项
① RVO 和 NRVO 是编译器优化,不能保证一定会发生。程序员应该编写出能够触发这些优化的代码,但最终是否优化取决于编译器及其设置。
② 在 C++11 及更高版本中引入的移动语义在某些情况下可以替代 RVO/NRVO 的作用,即使没有 RVO/NRVO,也可以通过移动构造函数来减少拷贝的开销。
20.6 总结
为了提高程序的性能,特别是当函数返回大型对象时,应该尽量编写出能够协助编译器进行返回值优化(RVO 和 NRVO)的代码。这通常意味着直接返回构造的对象或者局部具名对象,并避免不必要的临时变量和复杂的返回路径。同时,要确保编译器的优化选项是启用的。理解这些优化有助于我们写出更高效的 C++ 代码。
21. Overload to avoid implicit type conversions (通过重载避免隐式类型转换)
21.1 隐式类型转换的潜在问题
① 隐式类型转换是指编译器在没有显式请求的情况下自动进行的类型转换。虽然有时很方便,但它们也可能导致意外的行为和性能开销。
② 性能开销:隐式类型转换可能会导致创建临时对象,而临时对象的创建和销毁会带来额外的开销。
③ 意外行为:编译器可能会选择一个不期望的隐式转换路径,导致程序逻辑错误。
④ 二义性:如果存在多个可能的隐式转换路径,编译器可能无法确定选择哪一个,从而导致编译错误。
21.2 通过重载避免隐式类型转换
21.2.1 提供精确匹配的重载版本
① 当一个函数或操作符可能被不同类型的参数调用时,可以提供针对这些特定类型的重载版本,而不是依赖于隐式类型转换。
1
#include <iostream>
2
#include <string>
3
4
void process(int value) {
5
std::cout << "Processing integer: " << value << std::endl;
6
}
7
8
void process(double value) {
9
std::cout << "Processing double: " << value << std::endl;
10
}
11
12
int main() {
13
int i = 10;
14
double d = 3.14;
15
float f = 2.718f;
16
17
process(i); // 调用 process(int)
18
process(d); // 调用 process(double)
19
// process(f); // 如果没有 process(float),float 会隐式转换为 double
20
21
// 为了避免 float 到 double 的隐式转换,可以添加一个重载版本
22
process(static_cast<double>(f)); // 或者添加 void process(float value) { ... }
23
24
return 0;
25
}
21.2.2 操作符重载的例子
1
#include <iostream>
2
3
class MyClass {
4
public:
5
MyClass(int val) : value(val) {}
6
int value;
7
};
8
9
MyClass operator+(const MyClass& lhs, const MyClass& rhs) {
10
std::cout << "operator+(MyClass, MyClass) called" << std::endl;
11
return MyClass(lhs.value + rhs.value);
12
}
13
14
// 如果没有这个重载,那么当与 int 相加时,会尝试将 int 隐式转换为 MyClass (如果存在合适的构造函数)
15
MyClass operator+(const MyClass& lhs, int rhs) {
16
std::cout << "operator+(MyClass, int) called" << std::endl;
17
return MyClass(lhs.value + rhs);
18
}
19
20
MyClass operator+(int lhs, const MyClass& rhs) {
21
std::cout << "operator+(int, MyClass) called" << std::endl;
22
return MyClass(lhs + rhs.value);
23
}
24
25
int main() {
26
MyClass obj(5);
27
MyClass result1 = obj + MyClass(3); // 调用 operator+(MyClass, MyClass)
28
MyClass result2 = obj + 7; // 调用 operator+(MyClass, int)
29
MyClass result3 = 2 + obj; // 调用 operator+(int, MyClass)
30
31
return 0;
32
}
在这个例子中,通过提供针对 int
类型的重载版本,我们避免了依赖于可能存在的从 int
到 MyClass
的隐式转换(例如通过单参数构造函数)。
21.3 使用 explicit
关键字
① 对于单参数的构造函数,可以使用 explicit
关键字来禁止隐式类型转换。如果使用了 explicit
,那么只能通过显式的方式进行类型转换。
1
#include <iostream>
2
3
class MyClass {
4
public:
5
explicit MyClass(int val) : value(val) {} // 使用 explicit 阻止 int 隐式转换为 MyClass
6
int value;
7
};
8
9
void processObject(const MyClass& obj) {
10
std::cout << "Processing MyClass with value: " << obj.value << std::endl;
11
}
12
13
int main() {
14
// processObject(5); // 错误:不能将 int 隐式转换为 MyClass
15
processObject(MyClass(5)); // 正确:显式创建 MyClass 对象
16
return 0;
17
}
21.4 总结
通过提供精确匹配的函数或操作符重载版本,可以避免编译器进行隐式类型转换。这有助于提高代码的清晰度,避免潜在的意外行为和性能开销。对于类的单参数构造函数,使用 explicit
关键字可以更严格地控制类型转换,只允许显式转换。在设计接口时,应该考虑哪些类型是可能被使用的,并提供相应的重载版本,以减少对隐式类型转换的依赖。
22. Consider using op=
instead of stand-alone op
(考虑用op=
替代单独的操作符)
22.1 什么是 op=
和 stand-alone op
① op=
指的是复合赋值操作符,例如 +=
, -=
, *=
, /=
, %=
, &=
, |=
, ^=
, <<=
, >>=
。这些操作符将操作数与右侧的值进行运算,并将结果赋值给左侧的操作数。
② stand-alone op
指的是独立的二元操作符,例如 +
, -
, *
, /
, %
, &
, |
, ^
, <<
, >>
。这些操作符接受两个操作数,并返回一个新的结果,而不修改原始的操作数。
22.2 优先使用 op=
的原因
22.2.1 潜在的性能优势
① 复合赋值操作符通常可以直接在现有对象上进行修改,避免了创建和销毁临时对象的开销。
② 例如,对于 a += b;
,如果 a
是一个复杂的对象,可以直接在其内部修改,而对于 a = a + b;
,可能需要先创建一个临时对象来存储 a + b
的结果,然后再将该临时对象赋值给 a
。
22.2.2 代码的清晰性和一致性
① 先实现复合赋值操作符,然后基于它来实现独立的二元操作符,可以使代码更清晰和一致。
② 独立的二元操作符通常可以定义为调用对应的复合赋值操作符,然后返回修改后的对象的副本。
22.3 实现模式
22.3.1 实现 op=
① 复合赋值操作符通常作为成员函数来实现,它们修改 *this
对象并返回 *this
的引用。
1
class MyClass {
2
public:
3
MyClass(int val = 0) : value(val) {}
4
5
MyClass& operator+=(const MyClass& other) {
6
value += other.value;
7
return *this;
8
}
9
10
MyClass& operator-=(const MyClass& other) {
11
value -= other.value;
12
return *this;
13
}
14
15
int getValue() const { return value; }
16
17
private:
18
int value;
19
};
22.3.2 基于 op=
实现 stand-alone op
① 独立的二元操作符通常作为非成员函数来实现,它们接受两个操作数,创建一个左操作数的副本,然后使用复合赋值操作符修改该副本,并返回修改后的副本。
1
// 基于 += 实现 +
2
MyClass operator+(MyClass lhs, const MyClass& rhs) {
3
lhs += rhs; // 调用 MyClass::operator+=
4
return lhs; // 返回修改后的副本 (触发拷贝构造或移动构造)
5
}
6
7
// 基于 -= 实现 -
8
MyClass operator-(MyClass lhs, const MyClass& rhs) {
9
lhs -= rhs; // 调用 MyClass::operator-=
10
return lhs; // 返回修改后的副本
11
}
12
13
#include <iostream>
14
15
int main() {
16
MyClass a(5);
17
MyClass b(3);
18
MyClass c = a + b; // 调用 operator+(MyClass, MyClass)
19
std::cout << "a + b = " << c.getValue() << std::endl; // 输出 8
20
21
MyClass d(10);
22
MyClass e = d - b; // 调用 operator-(MyClass, MyClass)
23
std::cout << "d - b = " << e.getValue() << std::endl; // 输出 7
24
25
return 0;
26
}
22.4 为什么这种模式更好
22.4.1 减少代码重复
① 只需要实现核心的运算逻辑一次(在 op=
中),然后就可以在 stand-alone op
中复用。
22.4.2 提高效率
① 复合赋值操作符可以直接修改对象,避免了创建临时对象的开销。
② stand-alone 操作符通过拷贝构造(或移动构造)和调用复合赋值操作符来实现,通常比直接在 stand-alone 操作符中实现所有逻辑更高效。
22.4.3 易于维护
① 如果需要修改运算的逻辑,只需要修改 op=
的实现,对应的 stand-alone op
会自动更新。
22.5 注意事项
① 并非所有的操作符都有对应的复合赋值版本(例如前置和后置的自增/自减操作符)。
② 对于某些基本类型,例如 int
和 double
,a = a + b
和 a += b
的效率差别可能很小,但对于用户自定义的复杂类型,这种差异可能会更显著。
22.6 总结
在为自定义类重载二元操作符时,考虑优先实现对应的复合赋值操作符 (op=
)。然后,可以基于复合赋值操作符来实现独立的二元操作符 (op
),通常通过创建一个左操作数的副本,使用 op=
修改该副本,并返回修改后的副本。这种模式可以提高效率,减少代码重复,并使代码更易于维护。
23. Consider alternative libraries (考虑其他库的实现)
23.1 标准库的局限性
① C++ 标准库(STL)提供了许多常用的数据结构和算法,但在某些特定领域或有特殊性能要求的场景下,标准库的实现可能不是最优的选择。
② 标准库的设计目标是通用性和广泛适用性,因此在某些特定情况下,可能无法提供最定制化或最高效的解决方案。
23.2 考虑其他库的原因
23.2.1 性能优化
① 某些第三方库可能针对特定的硬件或应用场景进行了高度优化,能够提供比标准库更好的性能。
② 例如,在数值计算领域,像 Eigen 或 Armadillo 这样的库提供了优化的矩阵和向量运算。
23.2.2 扩展功能
① 标准库可能没有包含所有需要的功能。第三方库可以提供更丰富的功能集,例如:
* 网络编程:Boost.Asio, cpp-netlib
* 图形界面:Qt, wxWidgets
* 数据库访问:各种数据库的 C++ 客户端库
* 并发编程:Boost.Thread, Intel TBB
* JSON 处理:nlohmann/json, rapidjson
* 日志记录:spdlog, glog
23.2.3 更好的易用性
① 有些库可能提供了更简洁、更易于使用的 API,可以提高开发效率。
23.2.4 特定领域的需求
① 某些领域(例如游戏开发、嵌入式系统、高性能计算)可能需要特定的库来实现特定的功能或满足特定的约束。
23.3 选择替代库时需要考虑的因素
23.3.1 库的成熟度和稳定性
① 选择被广泛使用、社区活跃、有良好文档和测试的库。
② 避免使用过于新颖或不稳定的库,除非你愿意承担额外的风险。
23.3.2 库的许可证
① 确保库的许可证与你的项目兼容。常见的开源许可证包括 MIT, Apache, GPL, LGPL 等。
23.3.3 库的依赖
① 考虑库的依赖关系。引入一个库可能会增加项目的构建复杂性。
23.3.4 学习曲线
① 学习和使用一个新的库需要时间和精力。评估团队的学习成本。
23.3.5 库的大小和性能
① 考虑库的大小对最终可执行文件大小的影响,以及库本身的性能特点。
23.3.6 与现有代码的集成
① 评估该库与你现有代码的集成难度。
23.4 一些常见的替代库示例
23.4.1 Boost
① Boost 是一个非常流行的 C++ 库集合,提供了各种各样的功能,涵盖了字符串处理、容器、算法、并发、网络、数学等多个领域。许多 Boost 库后来被吸收到 C++ 标准库中。
23.4.2 Eigen
① Eigen 是一个用于线性代数、矩阵和向量运算的 C++ 模板库,具有高性能和良好的灵活性。
23.4.3 Armadillo
① Armadillo 也是一个用于线性代数和科学计算的 C++ 库,语法风格与 MATLAB 类似。
23.4.4 Qt
① Qt 是一个跨平台的应用程序开发框架,提供了用于创建图形用户界面以及处理网络、数据库、多媒体等功能的工具。
23.4.5 wxWidgets
① wxWidgets 是另一个流行的跨平台 GUI 工具包。
23.4.6 nlohmann/json 和 rapidjson
① 这两个库都是用于在 C++ 中解析和生成 JSON 数据的优秀选择,它们在性能和易用性方面各有特点。
23.4.7 spdlog 和 glog
① 这两个库都是用于 C++ 的高性能日志记录库。
23.5 何时应该考虑替代库
① 当标准库无法满足你的性能需求时。
② 当需要标准库没有提供的特定功能时。
③ 当第三方库提供了更易于使用或更适合特定场景的解决方案时。
23.6 总结
虽然 C++ 标准库非常强大和有用,但在某些情况下,考虑使用其他第三方库可以带来性能、功能或易用性方面的优势。选择替代库时需要仔细评估其成熟度、许可证、依赖、学习曲线以及与现有代码的集成性。了解可用的替代库可以帮助我们更高效地解决各种编程问题。
24. Understand the costs of virtual functions, multiple inheritance, virtual base classes, and RTTI (了解虚函数、多重继承、虚基类和RTTI的成本)
24.1 虚函数 (Virtual Functions) 的成本
24.1.1 虚函数表 (vtable) 和虚函数指针 (vptr)
① 对于包含虚函数的类,编译器会为该类创建一个虚函数表 (vtable),其中存储了该类及其基类中所有虚函数的地址。
② 每个包含虚函数的对象都会有一个额外的虚函数指针 (vptr),指向其类的 vtable。这增加了对象的大小。
24.1.2 间接调用
① 调用虚函数时,实际上是通过 vptr 找到 vtable,然后通过 vtable 找到要调用的虚函数的地址,最后再进行调用。这比直接调用非虚函数多了一层间接性,可能会带来轻微的性能开销。
24.1.3 禁止内联
① 大多数情况下,虚函数的调用无法在编译时确定,因此编译器通常不会对虚函数进行内联优化。
24.2 多重继承 (Multiple Inheritance) 的成本
24.2.1 更复杂的对象布局
① 多重继承会导致对象的内存布局更加复杂,因为一个对象可能包含多个基类的子对象。
24.2.2 名称冲突
① 如果不同的基类中包含相同名称的成员(包括成员变量和成员函数),可能会导致名称冲突,需要使用作用域解析符来明确指定要访问的成员。
24.2.3 虚继承的需求 (见 24.3)
① 当多个基类继承自同一个更高级别的基类时,如果不使用虚继承,可能会导致最终派生类中包含该最高级别基类的多个副本,这通常不是期望的行为。
24.3 虚基类 (Virtual Base Classes) 的成本
24.3.1 额外的间接层
① 虚基类通过额外的间接层来实现共享。通常,派生类会包含一个指向虚基类子对象的指针,而不是直接包含虚基类的子对象。这会增加访问虚基类成员的成本。
24.3.2 更复杂的构造和析构
① 虚基类的构造和析构顺序比普通继承更复杂,需要由最终派生类负责初始化虚基类。
24.3.3 增加对象大小
① 由于需要额外的指针来指向虚基类子对象,使用虚基类会增加对象的大小。
24.4 运行时类型信息 (Run-Time Type Information, RTTI) 的成本
24.4.1 类型信息的存储
① 为了支持 RTTI,编译器需要在程序中存储关于类类型的信息(例如类型名称、继承关系等)。这会增加程序的大小。
24.4.2 dynamic_cast
的开销
① dynamic_cast
是一种使用 RTTI 在运行时进行类型检查的类型转换操作符。如果转换失败(例如将基类指针转换为不正确的派生类指针),dynamic_cast
会返回 nullptr
(对于指针)或抛出 std::bad_cast
异常(对于引用)。
② dynamic_cast
的运行时类型检查会带来一定的性能开销,因为它需要在继承层次结构中进行查找。
24.4.3 typeid
操作符的开销
① typeid
操作符也使用 RTTI 来获取对象的类型信息。
24.5 何时应该使用这些特性
24.5.1 虚函数
① 当需要在运行时根据对象的实际类型来执行不同的行为时(多态性)。这是面向对象编程的关键特性。
24.5.2 多重继承
① 当一个类需要组合来自多个不同基类的接口和实现时。需要谨慎使用,因为它会增加代码的复杂性。
24.5.3 虚基类
① 当在多重继承中,多个基类继承自同一个更高级别的基类,并且希望最终派生类只包含该最高级别基类的一个共享实例时。
24.5.4 RTTI
① 当需要在运行时确定对象的实际类型时,例如在实现某些设计模式(如 Visitor)或进行安全的向下转型时。应该谨慎使用 dynamic_cast
,因为它可能表明设计上存在问题(例如,过度依赖于对象的具体类型)。
24.6 避免不必要的成本
24.6.1 避免不必要的虚函数
① 如果一个成员函数不需要在派生类中被重写,则不要将其声明为虚函数。
24.6.2 谨慎使用多重继承
① 优先考虑使用单继承和组合来实现代码的重用和扩展。只有在确实需要组合来自多个独立基类的特性时才使用多重继承。
24.6.3 避免过度使用虚基类
① 只有当确实需要在多重继承层次结构中共享同一个基类的实例时才使用虚基类。
24.6.4 尽量避免使用 dynamic_cast
① 依赖于 dynamic_cast
可能意味着你的设计不够清晰。可以考虑使用虚函数来实现多态行为,而不是在运行时检查类型。
24.7 总结
虚函数、多重继承、虚基类和 RTTI 是 C++ 中强大的特性,但也伴随着一定的运行时和空间成本。理解这些成本有助于我们根据实际需求和性能考量来合理地使用它们。在设计类层次结构时,应该权衡这些特性的优点和缺点,避免不必要的开销,并选择最适合的解决方案。
25. Virtualizing constructors and non-member functions (将构造函数和非成员函数虚化)
25.1 虚构造函数 (Virtual Constructors)
① 在 C++ 中,构造函数不能声明为虚函数。
② 原因:当创建一个对象时,我们需要知道对象的确切类型,以便调用正确的构造函数来初始化对象。虚函数机制是在对象创建之后,通过对象的虚函数指针来确定调用哪个版本的虚函数。在构造函数执行时,对象的类型尚未完全确定,因此无法使用虚函数机制来选择构造函数。
25.2 模拟虚构造函数的方法
25.2.1 使用工厂方法 (Factory Method Pattern)
① 工厂方法模式通过定义一个工厂接口来创建对象,但将实际创建哪个类的对象的决定延迟到子类中。
② 可以定义一个静态的虚函数(通常返回指向基类的指针或智能指针)作为工厂方法。派生类可以重写这个方法来创建自己的对象。
1
#include <iostream>
2
#include <memory>
3
4
class Shape {
5
public:
6
virtual ~Shape() {}
7
virtual void draw() = 0;
8
static std::unique_ptr<Shape> create(int type); // 工厂方法
9
};
10
11
class Circle : public Shape {
12
public:
13
void draw() override { std::cout << "Drawing a circle" << std::endl; }
14
};
15
16
class Square : public Shape {
17
public:
18
void draw() override { std::cout << "Drawing a square" << std::endl; }
19
};
20
21
std::unique_ptr<Shape> Shape::create(int type) {
22
if (type == 1) {
23
return std::make_unique<Circle>();
24
} else if (type == 2) {
25
return std::make_unique<Square>();
26
} else {
27
return nullptr;
28
}
29
}
30
31
int main() {
32
std::unique_ptr<Shape> shape1 = Shape::create(1);
33
if (shape1) shape1->draw(); // 输出 "Drawing a circle"
34
35
std::unique_ptr<Shape> shape2 = Shape::create(2);
36
if (shape2) shape2->draw(); // 输出 "Drawing a square"
37
38
return 0;
39
}
25.2.2 使用原型模式 (Prototype Pattern)
① 原型模式通过复制现有对象(原型)来创建新对象。
② 可以定义一个虚函数 clone()
,用于创建当前对象的副本。派生类可以重写这个方法来返回自己的副本。
1
#include <iostream>
2
#include <memory>
3
4
class Shape {
5
public:
6
virtual ~Shape() {}
7
virtual void draw() = 0;
8
virtual std::unique_ptr<Shape> clone() const = 0; // 原型方法
9
};
10
11
class Circle : public Shape {
12
public:
13
void draw() override { std::cout << "Drawing a circle" << std::endl; }
14
std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); }
15
};
16
17
class Square : public Shape {
18
public:
19
void draw() override { std::cout << "Drawing a square" << std::endl; }
20
std::unique_ptr<Shape> clone() const override { return std::make_unique<Square>(*this); }
21
};
22
23
int main() {
24
std::unique_ptr<Shape> circlePrototype = std::make_unique<Circle>();
25
std::unique_ptr<Shape> squarePrototype = std::make_unique<Square>();
26
27
std::unique_ptr<Shape> shape1 = circlePrototype->clone();
28
shape1->draw(); // 输出 "Drawing a circle"
29
30
std::unique_ptr<Shape> shape2 = squarePrototype->clone();
31
shape2->draw(); // 输出 "Drawing a square"
32
33
return 0;
34
}
25.3 虚非成员函数 (Virtual Non-Member Functions)
① 非成员函数不能声明为虚函数。
② 原因:虚函数是属于类的成员函数,它们的调用依赖于对象的类型(通过 this
指针)。非成员函数不属于任何类,也没有 this
指针,因此无法实现虚函数的动态绑定行为。
25.4 模拟虚非成员函数的方法
25.4.1 使用静态成员函数和虚函数
① 可以将非成员函数的功能封装在一个静态成员函数中,该静态成员函数接受一个指向对象的指针或引用作为参数。
② 在静态成员函数内部,可以调用该对象的虚函数来实现多态行为。
1
#include <iostream>
2
3
class Shape {
4
public:
5
virtual ~Shape() {}
6
virtual void drawImpl() const { std::cout << "Drawing a generic shape" << std::endl; }
7
static void draw(const Shape& s) { // 静态成员函数
8
s.drawImpl(); // 调用虚函数
9
}
10
};
11
12
class Circle : public Shape {
13
public:
14
void drawImpl() const override { std::cout << "Drawing a circle" << std::endl; }
15
};
16
17
class Square : public Shape {
18
public:
19
void drawImpl() const override { std::cout << "Drawing a square" << std::endl; }
20
};
21
22
int main() {
23
Circle c;
24
Square s;
25
Shape::draw(c); // 输出 "Drawing a circle"
26
Shape::draw(s); // 输出 "Drawing a square"
27
28
return 0;
29
}
25.4.2 使用访问者模式 (Visitor Pattern)
① 访问者模式允许在不修改对象结构的前提下定义对对象结构中元素的新操作。
② 可以使用访问者模式来模拟对不同类型对象执行的“虚”操作。
25.4.3 使用函数对象 (Function Objects) 或策略模式 (Strategy Pattern)
① 可以将不同的行为封装在函数对象或策略类中,然后通过参数传递给非成员函数。
1
#include <iostream>
2
#include <functional>
3
4
class Shape {
5
public:
6
virtual ~Shape() {}
7
};
8
9
class Circle : public Shape {};
10
class Square : public Shape {};
11
12
void draw(const Shape& s, const std::function<void(const Shape&)>& drawer) {
13
drawer(s);
14
}
15
16
void drawCircle(const Shape& s) {
17
std::cout << "Drawing a circle" << std::endl;
18
}
19
20
void drawSquare(const Shape& s) {
21
std::cout << "Drawing a square" << std::endl;
22
}
23
24
int main() {
25
Circle c;
26
Square s;
27
draw(c, drawCircle); // 输出 "Drawing a circle"
28
draw(s, drawSquare); // 输出 "Drawing a square"
29
30
return 0;
31
}
25.5 总结
虽然 C++ 不允许构造函数和非成员函数直接声明为虚函数,但可以通过使用工厂方法、原型模式、静态成员函数、访问者模式、函数对象或策略模式等设计模式来模拟虚构造函数和虚非成员函数的多态行为。选择哪种方法取决于具体的需求和设计考虑。
26. Limiting the number of objects of a class (限制类的对象数量)
26.1 使用静态成员变量计数
① 可以使用一个静态成员变量来跟踪类的已创建对象的数量。
② 在构造函数中递增计数器,在析构函数中递减计数器。
③ 在构造函数中检查计数器的值,如果超过了预设的限制,可以抛出异常或阻止对象的创建。
1
#include <iostream>
2
#include <stdexcept>
3
4
class LimitedClass {
5
private:
6
static const int maxInstances = 3;
7
static int instanceCount;
8
9
public:
10
LimitedClass() {
11
if (instanceCount >= maxInstances) {
12
throw std::runtime_error("Cannot create more than " + std::to_string(maxInstances) + " instances of LimitedClass.");
13
}
14
instanceCount++;
15
std::cout << "LimitedClass instance created (" << instanceCount << ")" << std::endl;
16
}
17
18
~LimitedClass() {
19
instanceCount--;
20
std::cout << "LimitedClass instance destroyed (" << instanceCount << ")" << std::endl;
21
}
22
23
static int getCount() { return instanceCount; }
24
};
25
26
int LimitedClass::instanceCount = 0; // 初始化静态成员变量
27
28
int main() {
29
try {
30
LimitedClass obj1;
31
LimitedClass obj2;
32
LimitedClass obj3;
33
LimitedClass obj4; // 应该会抛出异常
34
} catch (const std::exception& e) {
35
std::cerr << "Exception caught: " << e.what() << std::endl;
36
}
37
38
std::cout << "Current instance count: " << LimitedClass::getCount() << std::endl;
39
40
return 0;
41
}
26.2 使用单例模式 (Singleton Pattern)
① 单例模式是一种设计模式,它确保一个类只有一个实例存在,并提供一个全局访问点来获取该实例。
② 这是一种限制对象数量为 1 的特殊情况。
1
#include <iostream>
2
#include <memory>
3
4
class Singleton {
5
private:
6
Singleton() { std::cout << "Singleton created" << std::endl; }
7
~Singleton() { std::cout << "Singleton destroyed" << std::endl; }
8
static std::unique_ptr<Singleton> instance;
9
10
public:
11
static Singleton& getInstance() {
12
if (!instance) {
13
instance.reset(new Singleton());
14
}
15
return *instance;
16
}
17
18
// 防止拷贝和赋值
19
Singleton(const Singleton&) = delete;
20
Singleton& operator=(const Singleton&) = delete;
21
22
void doSomething() {
23
std::cout << "Singleton is doing something" << std::endl;
24
}
25
};
26
27
std::unique_ptr<Singleton> Singleton::instance; // 初始化静态成员指针
28
29
int main() {
30
Singleton::getInstance().doSomething();
31
Singleton::getInstance().doSomething(); // 两次调用返回同一个实例
32
return 0;
33
}
26.3 使用工厂函数控制创建
① 可以不提供公开的构造函数,而是提供一个或多个静态的工厂函数来创建对象。在工厂函数中可以控制对象的创建数量。
1
#include <iostream>
2
#include <vector>
3
#include <stdexcept>
4
5
class FactoryClass {
6
private:
7
static const int maxInstances = 2;
8
static std::vector<FactoryClass*> instances;
9
FactoryClass() { std::cout << "FactoryClass private constructor called" << std::endl; }
10
~FactoryClass() { std::cout << "FactoryClass destroyed" << std::endl; }
11
12
public:
13
static FactoryClass* createInstance() {
14
if (instances.size() >= maxInstances) {
15
throw std::runtime_error("Cannot create more than " + std::to_string(maxInstances) + " instances of FactoryClass.");
16
}
17
FactoryClass* newInstance = new FactoryClass();
18
instances.push_back(newInstance);
19
return newInstance;
20
}
21
22
static void destroyInstance(FactoryClass* instance) {
23
for (auto it = instances.begin(); it != instances.end(); ++it) {
24
if (*it == instance) {
25
delete *it;
26
instances.erase(it);
27
std::cout << "FactoryClass instance destroyed via factory" << std::endl;
28
return;
29
}
30
}
31
std::cerr << "Warning: Attempted to destroy an instance not managed by the factory." << std::endl;
32
}
33
34
void doSomething() {
35
std::cout << "FactoryClass instance is doing something" << std::endl;
36
}
37
};
38
39
std::vector<FactoryClass*> FactoryClass::instances; // 初始化静态成员向量
40
41
int main() {
42
try {
43
FactoryClass* obj1 = FactoryClass::createInstance();
44
FactoryClass* obj2 = FactoryClass::createInstance();
45
FactoryClass* obj3 = FactoryClass::createInstance(); // 应该会抛出异常
46
if (obj1) obj1->doSomething();
47
if (obj2) obj2->doSomething();
48
FactoryClass::destroyInstance(obj1);
49
FactoryClass::destroyInstance(obj2);
50
} catch (const std::exception& e) {
51
std::cerr << "Exception caught: " << e.what() << std::endl;
52
}
53
54
return 0;
55
}
26.4 总结
限制类的对象数量可以通过多种方式实现,包括使用静态成员变量计数、应用单例模式或使用工厂函数来控制对象的创建。选择哪种方法取决于对对象数量的具体限制以及类的设计需求。
27. Requiring or prohibiting heap-based objects (要求或禁止基于堆的对象)
27.1 要求对象必须基于堆 (Heap-Only Objects)
27.1.1 将析构函数声明为 private
或 protected
① 如果一个类的析构函数是私有的或受保护的,那么该类的对象就不能在栈上自动创建或销毁,因为当栈上的对象离开作用域时,会尝试调用其析构函数,而外部无法访问私有或受保护的析构函数。
② 要创建和销毁这种类的对象,必须使用 new
在堆上分配内存,并通过指向该对象的指针来操作,并在不再需要时使用 delete
显式地销毁。
③ 为了能够使用 delete
,通常会提供一个公共的静态成员函数用于对象的销毁。
1
#include <iostream>
2
3
class HeapOnly {
4
private:
5
HeapOnly() { std::cout << "HeapOnly constructor" << std::endl; }
6
~HeapOnly() { std::cout << "HeapOnly destructor" << std::endl; } // 私有析构函数
7
8
public:
9
static HeapOnly* create() {
10
return new HeapOnly();
11
}
12
13
void doSomething() {
14
std::cout << "HeapOnly object is doing something" << std::endl;
15
}
16
17
static void destroy(HeapOnly* obj) {
18
delete obj;
19
}
20
};
21
22
int main() {
23
HeapOnly* obj = HeapOnly::create();
24
if (obj) {
25
obj->doSomething();
26
HeapOnly::destroy(obj);
27
}
28
// HeapOnly stackObj; // 错误:无法访问私有析构函数
29
return 0;
30
}
27.1.2 使用 delete
操作符的技巧
① 可以通过重载类的 operator new
和 operator delete
为私有,并提供静态的创建和销毁函数,来更严格地控制对象的创建和销毁。
27.2 禁止对象基于堆 (Stack-Only Objects)
27.2.1 重载 operator new
和 operator new[]
为 private
① 如果将类的 operator new
和 operator new[]
重载为私有的,那么就无法在该类的作用域外使用 new
来创建该类的对象。
② 尝试在堆上创建对象会导致编译错误。
③ 这种类的对象只能在栈上或作为其他对象的成员变量创建。
1
#include <iostream>
2
3
class StackOnly {
4
private:
5
StackOnly() { std::cout << "StackOnly constructor" << std::endl; }
6
~StackOnly() { std::cout << "StackOnly destructor" << std::endl; }
7
8
// 禁止在堆上分配
9
void* operator new(std::size_t) = delete;
10
void operator delete(void*) = delete;
11
void* operator new[](std::size_t) = delete;
12
void operator delete[](void*) = delete;
13
14
public:
15
// 允许在栈上创建
16
StackOnly(int value) : data(value) {}
17
void printData() const { std::cout << "Data: " << data << std::endl; }
18
19
private:
20
int data;
21
};
22
23
int main() {
24
StackOnly stackObj(42); // 正确:在栈上创建
25
stackObj.printData();
26
// StackOnly* heapObj = new StackOnly(10); // 错误:operator new 被删除
27
return 0;
28
}
27.3 使用场景
27.3.1 要求堆对象
① 当对象的生命周期需要比创建它的作用域更长时。
② 当需要使用多态性,并通过基类指针来管理派生类对象时。
③ 当对象很大,在栈上创建可能会导致栈溢出时。
27.3.2 禁止堆对象
① 当希望确保对象的生命周期与某个作用域绑定时。
② 当希望避免显式的内存管理(new
和 delete
)时。
③ 对于一些轻量级的、生命周期短暂的对象。
27.4 注意事项
① 要求对象必须基于堆可能会增加内存管理的复杂性,需要确保使用 delete
来销毁对象,避免内存泄漏。使用智能指针可以简化这一过程。
② 禁止对象基于堆可能会限制类的使用场景,例如不能通过基类指针指向派生类对象(如果派生类也是栈对象)。
27.5 总结
通过控制类的构造函数、析构函数以及 operator new
和 operator delete
的可访问性,可以实现要求或禁止类的对象基于堆创建。这是一种高级的类设计技巧,可以用于控制对象的生命周期和内存管理方式,以满足特定的设计需求。
28. Smart pointers (智能指针)
28.1 什么是智能指针
① 智能指针是 C++ 中用于自动管理动态分配内存的对象。它们模仿普通指针的行为,但提供了自动的内存管理功能,以防止内存泄漏。
② 当智能指针对象离开其作用域时,它们会自动释放所管理的内存(即调用 delete
或 delete[]
)。
28.2 C++ 标准库中的智能指针
28.2.1 std::unique_ptr
① std::unique_ptr
提供独占所有权语义。一个 std::unique_ptr
对象在任何时候都只能指向一个对象,并且当 unique_ptr
对象被销毁时,它所指向的对象也会被销毁。
② std::unique_ptr
不支持普通的拷贝操作(拷贝构造函数和拷贝赋值操作符被删除),但支持移动操作(移动构造函数和移动赋值操作符)。这表示所有权可以从一个 unique_ptr
转移到另一个 unique_ptr
。
③ std::unique_ptr
是管理不需要共享所有权的动态分配对象的首选智能指针。
1
#include <iostream>
2
#include <memory>
3
4
class MyClass {
5
public:
6
MyClass() { std::cout << "MyClass created" << std::endl; }
7
~MyClass() { std::cout << "MyClass destroyed" << std::endl; }
8
void doSomething() { std::cout << "MyClass is doing something" << std::endl; }
9
};
10
11
int main() {
12
std::unique_ptr<MyClass> ptr1(new MyClass()); // 使用原始指针创建 unique_ptr
13
ptr1->doSomething();
14
15
// std::unique_ptr<MyClass> ptr2 = ptr1; // 错误:unique_ptr 不支持拷贝
16
17
std::unique_ptr<MyClass> ptr3 = std::move(ptr1); // 正确:使用移动操作转移所有权
18
if (ptr3) {
19
ptr3->doSomething();
20
}
21
if (!ptr1) {
22
std::cout << "ptr1 is now null" << std::endl;
23
}
24
25
std::unique_ptr<MyClass> ptr4 = std::make_unique<MyClass>(); // 推荐使用 make_unique (C++14)
26
ptr4->doSomething();
27
28
return 0; // ptr3 和 ptr4 在离开作用域时会自动销毁它们所指向的 MyClass 对象
29
}
28.2.2 std::shared_ptr
① std::shared_ptr
提供共享所有权语义。多个 shared_ptr
对象可以指向同一个对象。
② shared_ptr
使用引用计数来跟踪有多少个 shared_ptr
指向同一个对象。当最后一个指向该对象的 shared_ptr
被销毁时,才会销毁该对象。
③ shared_ptr
支持拷贝和赋值操作,这些操作会增加引用计数。
④ shared_ptr
适用于多个部分代码需要共享同一个动态分配的对象,并且对象的生命周期应该由所有拥有者共同决定。
1
#include <iostream>
2
#include <memory>
3
4
class MyClass {
5
public:
6
MyClass() { std::cout << "MyClass created" << std::endl; }
7
~MyClass() { std::cout << "MyClass destroyed" << std::endl; }
8
void doSomething() { std::cout << "MyClass is doing something" << std::endl; }
9
};
10
11
int main() {
12
std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>(); // 推荐使用 make_shared (C++11)
13
ptr1->doSomething();
14
std::cout << "Reference count: " << ptr1.use_count() << std::endl; // 输出 1
15
16
std::shared_ptr<MyClass> ptr2 = ptr1; // 拷贝 shared_ptr,引用计数增加
17
ptr2->doSomething();
18
std::cout << "Reference count: " << ptr1.use_count() << std::endl; // 输出 2
19
std::cout << "Reference count: " << ptr2.use_count() << std::endl; // 输出 2
20
21
{
22
std::shared_ptr<MyClass> ptr3 = ptr1; // 拷贝 shared_ptr,引用计数增加
23
ptr3->doSomething();
24
std::cout << "Reference count: " << ptr1.use_count() << std::endl; // 输出 3
25
} // ptr3 离开作用域,引用计数减少
26
27
std::cout << "Reference count: " << ptr1.use_count() << std::endl; // 输出 2
28
29
return 0; // ptr1 和 ptr2 在离开作用域时会减少引用计数,当计数变为 0 时,MyClass 对象会被销毁
30
}
28.2.3 std::weak_ptr
① std::weak_ptr
是一种不拥有对象所有权的智能指针。它指向一个由 std::shared_ptr
管理的对象,但不会增加对象的引用计数。
② weak_ptr
可以用来解决 shared_ptr
导致的循环引用问题。
③ 要访问 weak_ptr
指向的对象,需要先将其转换为 shared_ptr
(可以使用 weak_ptr::lock()
方法)。如果此时对象已经被销毁,lock()
方法会返回一个空的 shared_ptr
。
1
#include <iostream>
2
#include <memory>
3
4
class B;
5
6
class A {
7
public:
8
std::shared_ptr<B> b_ptr;
9
~A() { std::cout << "A destroyed" << std::endl; }
10
};
11
12
class B {
13
public:
14
std::weak_ptr<A> a_ptr; // 使用 weak_ptr 避免循环引用
15
~B() { std::cout << "B destroyed" << std::endl; }
16
};
17
18
int main() {
19
std::shared_ptr<A> a = std::make_shared<A>();
20
std::shared_ptr<B> b = std::make_shared<B>();
21
a->b_ptr = b;
22
b->a_ptr = a;
23
24
// 如果 b_ptr 在 A 中也是 weak_ptr,那么 A 和 B 都不会被销毁,因为存在循环引用
25
// 但由于 b_ptr 是 shared_ptr,而 a_ptr 是 weak_ptr,所以可以正确销毁
26
27
return 0;
28
}
28.3 使用智能指针的优点
① 自动内存管理:无需显式调用 delete
,降低了内存泄漏的风险。
② 异常安全:即使在构造对象后和释放内存前抛出异常,智能指针也会确保内存被正确释放。
③ 所有权语义清晰:unique_ptr
和 shared_ptr
明确了对象的所有权模型。
28.4 总结
智能指针是现代 C++ 中进行动态内存管理的首选方式。std::unique_ptr
适用于独占所有权的情况,而 std::shared_ptr
适用于共享所有权的情况。std::weak_ptr
用于处理 shared_ptr
可能导致的循环引用问题。使用智能指针可以显著提高程序的安全性并简化内存管理。
29. Reference counting (引用计数)
29.1 什么是引用计数
① 引用计数是一种内存管理技术,用于跟踪有多少个指针(或引用)指向一个特定的资源(通常是动态分配的对象)。
② 每个资源都关联着一个计数器,记录着当前指向该资源的引用数量。
③ 当创建一个新的引用指向该资源时,计数器会递增。当一个引用不再指向该资源(例如引用被销毁或指向其他地方)时,计数器会递减。
④ 当引用计数器的值变为零时,表示该资源不再被任何引用所使用,可以安全地释放该资源。
29.2 std::shared_ptr
的实现
① std::shared_ptr
是 C++ 标准库中实现了引用计数功能的智能指针。
② 每个 std::shared_ptr
对象都包含两个指针:一个指向实际管理的对象,另一个指向一个共享的控制块 (control block)。
③ 这个控制块包含了对象的引用计数、弱引用计数(用于 std::weak_ptr
)、以及自定义的删除器(deleter,用于在引用计数为零时释放对象)。
④ 当创建一个新的 std::shared_ptr
指向同一个对象时,它们会共享同一个控制块,并且引用计数会递增。当一个 std::shared_ptr
被销毁时,其对应的引用计数会递减。当引用计数变为零时,控制块会负责调用删除器来释放对象。
29.3 引用计数的优点
29.3.1 自动内存管理
① 引用计数可以自动管理资源的生命周期,无需程序员显式地释放内存,从而减少了内存泄漏的风险。
29.3.2 简化共享资源的管理
① 当多个对象需要共享同一个资源时,引用计数可以方便地管理资源的释放,只有当所有拥有者都不再需要该资源时才释放它。
29.4 引用计数的缺点
29.4.1 循环引用问题
① 如果两个或多个对象通过 std::shared_ptr
互相引用,它们的引用计数永远不会变为零,从而导致内存泄漏。
② 例如,对象 A 拥有指向对象 B 的 shared_ptr
,而对象 B 拥有指向对象 A 的 shared_ptr
。当 A 和 B 都离开作用域时,它们的引用计数都为 1,永远不会被释放。
③ 可以使用 std::weak_ptr
来打破这种循环引用。std::weak_ptr
指向由 std::shared_ptr
管理的对象,但不增加引用计数。
29.4.2 额外的开销
① 引用计数需要维护一个计数器,并且在每次创建、销毁或赋值 std::shared_ptr
时都需要进行原子操作来更新计数器。这会带来一定的性能开销,尤其是在多线程环境下。
② std::shared_ptr
通常比原始指针或 std::unique_ptr
占用更多的内存,因为它需要存储指向控制块的指针以及控制块本身的数据。
29.5 自定义引用计数
① 除了使用 std::shared_ptr
,也可以为自定义的类实现引用计数。
② 这通常需要在类中添加一个引用计数器(例如 std::atomic<int>
),并在拷贝构造函数、赋值操作符和析构函数中适当地更新计数器。
③ 需要特别小心地处理线程安全性和异常安全。
29.6 何时使用引用计数
① 当多个对象需要共享同一个资源,并且资源的生命周期应该由所有拥有者共同决定时。
② 当希望自动管理共享资源的生命周期,避免手动释放内存。
29.7 何时避免使用引用计数
① 当不需要共享资源,或者所有权是明确的独占关系时,std::unique_ptr
通常是更好的选择,因为它开销更小。
② 当可能出现循环引用时,需要仔细考虑使用 std::weak_ptr
来打破循环。
③ 在对性能要求非常高的场景下,可能需要评估引用计数的额外开销。
29.8 总结
引用计数是一种有效的内存管理技术,通过跟踪指向资源的引用数量来自动管理资源的生命周期。std::shared_ptr
是 C++ 标准库中实现引用计数的智能指针,方便了共享资源的自动管理,但也需要注意循环引用和性能开销等问题。了解引用计数的工作原理和适用场景,可以帮助我们更好地选择合适的内存管理策略。
30. Proxy classes (代理类)
30.1 什么是代理类
① 代理类是一种设计模式,它提供一个占位符 (placeholder) 对象来代表另一个对象(称为主体 (subject))。
② 代理类通常与主体类具有相同的接口,客户端代码可以像使用主体对象一样使用代理对象,而无需知道代理的存在。
③ 代理类可以在客户端和主体对象之间插入额外的逻辑,例如延迟加载、访问控制、日志记录等。
30.2 代理类的常见类型和用途
30.2.1 虚拟代理 (Virtual Proxy)
① 虚拟代理用于延迟创建开销较大的主体对象,直到真正需要使用它时才创建。
② 代理对象在主体对象被创建之前充当其替身。
1
#include <iostream>
2
#include <memory>
3
4
class Image {
5
public:
6
virtual ~Image() {}
7
virtual void display() = 0;
8
};
9
10
class RealImage : public Image {
11
private:
12
std::string filename;
13
void loadFromDisk() {
14
std::cout << "Loading image from disk: " << filename << std::endl;
15
}
16
public:
17
RealImage(const std::string& filename) : filename(filename) {
18
loadFromDisk();
19
}
20
void display() override {
21
std::cout << "Displaying real image: " << filename << std::endl;
22
}
23
};
24
25
class ProxyImage : public Image {
26
private:
27
std::string filename;
28
std::unique_ptr<RealImage> realImage;
29
public:
30
ProxyImage(const std::string& filename) : filename(filename), realImage(nullptr) {}
31
void display() override {
32
if (!realImage) {
33
realImage = std::make_unique<RealImage>(filename);
34
}
35
realImage->display();
36
}
37
};
38
39
int main() {
40
ProxyImage proxy("image.jpg"); // RealImage 尚未加载
41
std::cout << "Proxy image created" << std::endl;
42
proxy.display(); // 第一次调用 display 时加载 RealImage
43
proxy.display(); // 第二次调用直接使用已加载的 RealImage
44
return 0;
45
}
30.2.2 远程代理 (Remote Proxy)
① 远程代理用于代表位于不同地址空间(例如不同的机器)的主体对象。
② 代理对象负责处理与远程主体的通信细节。
30.2.3 保护代理 (Protection Proxy)
① 保护代理用于控制对主体对象的访问,例如根据用户的权限来决定是否允许访问主体对象的某些方法。
30.2.4 智能引用 (Smart Reference)
① 智能引用是一种代理,它在访问主体对象时执行额外的操作,例如引用计数、延迟加载或检查前置条件。智能指针可以看作是一种智能引用。
30.2.5 缓存代理 (Cache Proxy)
① 缓存代理用于缓存主体对象的操作结果,以便后续重复请求可以直接从缓存中获取,而无需再次访问主体对象。
30.2.6 日志代理 (Logging Proxy)
① 日志代理在调用主体对象的方法前后记录日志信息。
30.3 代理类的优点
30.3.1 控制对主体对象的访问
① 代理可以限制或增强对主体对象的访问。
30.3.2 延迟加载
① 虚拟代理可以延迟创建昂贵的主体对象。
30.3.3 增加额外的功能
① 代理可以在不修改主体对象的情况下添加额外的行为(例如日志记录、缓存)。
30.3.4 隐藏主体对象的复杂性
① 远程代理可以隐藏与远程通信相关的复杂性。
30.4 代理类的缺点
30.4.1 增加代码复杂性
① 引入代理类会增加类的数量,可能会使代码更复杂。
30.4.2 可能导致轻微的性能开销
① 每次通过代理访问主体对象时,可能会有额外的间接层。
30.5 何时使用代理类
① 当需要控制对对象的访问时。
② 当对象的创建成本很高,希望延迟加载时。
③ 当需要在不修改主体对象的情况下添加额外的行为时。
④ 当需要处理远程对象或复杂对象时。
30.6 总结
代理类是一种有用的设计模式,可以在客户端和主体对象之间提供一个间接层,从而实现各种目的,例如延迟加载、访问控制和增加额外的功能。理解不同类型的代理及其适用场景,可以帮助我们更好地设计和组织代码。
31. Making functions virtual with respect to more than one object (针对多个对象的虚函数)
31.1 标准虚函数的局限性
① C++ 的标准虚函数是单分派 (single dispatch) 的,这意味着虚函数的调用是基于一个对象的运行时类型来确定的,即调用该虚函数的对象(通过 this
指针)。
② 如果需要基于多个对象的运行时类型来决定执行哪个函数,标准虚函数就无法直接实现。
31.2 双分派 (Double Dispatch)
① 双分派是一种技术,允许在运行时基于两个对象的类型来选择执行哪个函数。
② 这通常通过使用访问者模式 (Visitor Pattern) 或双重虚函数调用来实现。
31.3 使用访问者模式实现双分派
31.3.1 结构
① 定义一个表示要执行的操作的访问者接口。
② 为每种具体的元素类型实现一个具体的访问者。
③ 在元素类中添加一个 accept
方法,该方法接受一个访问者对象作为参数,并调用访问者对象中对应于自身类型的 visit
方法。
1
#include <iostream>
2
#include <vector>
3
4
// 前向声明
5
class ConcreteElementA;
6
class ConcreteElementB;
7
8
// 访问者接口
9
class Visitor {
10
public:
11
virtual void visit(ConcreteElementA& element) = 0;
12
virtual void visit(ConcreteElementB& element) = 0;
13
virtual ~Visitor() {}
14
};
15
16
// 元素接口
17
class Element {
18
public:
19
virtual void accept(Visitor& visitor) = 0;
20
virtual ~Element() {}
21
};
22
23
// 具体元素 A
24
class ConcreteElementA : public Element {
25
public:
26
void accept(Visitor& visitor) override {
27
visitor.visit(*this);
28
}
29
std::string operationA() const { return "Operation A on ConcreteElementA"; }
30
};
31
32
// 具体元素 B
33
class ConcreteElementB : public Element {
34
public:
35
void accept(Visitor& visitor) override {
36
visitor.visit(*this);
37
}
38
std::string operationB() const { return "Operation B on ConcreteElementB"; }
39
};
40
41
// 具体访问者:执行操作
42
class ConcreteVisitor : public Visitor {
43
public:
44
void visit(ConcreteElementA& element) override {
45
std::cout << "ConcreteVisitor visited ConcreteElementA: " << element.operationA() << std::endl;
46
}
47
void visit(ConcreteElementB& element) override {
48
std::cout << "ConcreteVisitor visited ConcreteElementB: " << element.operationB() << std::endl;
49
}
50
};
51
52
int main() {
53
std::vector<Element*> elements;
54
elements.push_back(new ConcreteElementA());
55
elements.push_back(new ConcreteElementB());
56
57
ConcreteVisitor visitor;
58
for (Element* element : elements) {
59
element->accept(visitor);
60
}
61
62
for (Element* element : elements) {
63
delete element;
64
}
65
66
return 0;
67
}
31.4 使用双重虚函数调用实现双分派
31.4.1 结构
① 在第一个类的基类中定义一个虚函数,该函数接受第二个类的基类的引用或指针作为参数。
② 在第一个类的每个具体派生类中重写该虚函数。
③ 在第一个类的重写版本中,使用 dynamic_cast
将第二个类的基类指针或引用转换为具体的派生类类型。
④ 如果转换成功,则调用一个虚函数(在第二个类的基类中定义),并将第一个类的 this
指针作为参数传递。
⑤ 在第二个类的每个具体派生类中重写这个虚函数,实现基于两个对象类型的特定行为。
1
#include <iostream>
2
3
// 前向声明
4
class ConcreteTypeA;
5
class ConcreteTypeB;
6
7
class TypeB {
8
public:
9
virtual void collideWith(const TypeB& other) const {
10
std::cout << "TypeB collided with TypeB" << std::endl;
11
}
12
virtual void collideWith(const ConcreteTypeA& other) const;
13
virtual ~TypeB() {}
14
};
15
16
class ConcreteTypeB : public TypeB {
17
public:
18
void collideWith(const TypeB& other) const override {
19
std::cout << "ConcreteTypeB collided with TypeB" << std::endl;
20
}
21
void collideWith(const ConcreteTypeA& other) const override {
22
std::cout << "ConcreteTypeB collided with ConcreteTypeA" << std::endl;
23
}
24
};
25
26
class TypeA {
27
public:
28
virtual void collideWith(const TypeA& other) const {
29
std::cout << "TypeA collided with TypeA" << std::endl;
30
}
31
virtual void collideWith(const ConcreteTypeB& other) const {
32
other.collideWith(*this); // 第二次虚函数调用
33
}
34
virtual ~TypeA() {}
35
};
36
37
class ConcreteTypeA : public TypeA {
38
public:
39
void collideWith(const TypeA& other) const override {
40
std::cout << "ConcreteTypeA collided with TypeA" << std::endl;
41
}
42
void collideWith(const ConcreteTypeB& other) const override {
43
other.collideWith(*this); // 第二次虚函数调用
44
}
45
};
46
47
void TypeB::collideWith(const ConcreteTypeA& other) const {
48
std::cout << "TypeB collided with ConcreteTypeA" << std::endl;
49
}
50
51
int main() {
52
ConcreteTypeA a;
53
ConcreteTypeB b;
54
TypeA& refA = a;
55
TypeB& refB = b;
56
57
refA.collideWith(refA); // ConcreteTypeA collided with TypeA
58
refA.collideWith(refB); // ConcreteTypeA collided with ConcreteTypeB (通过双重虚函数调用)
59
refB.collideWith(refA); // ConcreteTypeB collided with ConcreteTypeA
60
refB.collideWith(refB); // ConcreteTypeB collided with TypeB
61
62
return 0;
63
}
31.5 多分派 (Multiple Dispatch)
① 双分派是多分派的一个特例,它基于两个对象的类型进行分派。
② 可以将这些技术推广到基于更多对象的类型进行分派,但这会变得更加复杂。
31.6 总结
虽然 C++ 的标准虚函数是单分派的,但可以使用访问者模式或双重虚函数调用等技术来实现基于多个对象的运行时类型来选择执行哪个函数的多分派行为。这些技术在需要处理多个不同类型对象之间的交互时非常有用。
32. Program in the future tense (以未来时态编程)
32.1 什么是“以未来时态编程”
① “以未来时态编程” 是一种编程理念,指的是在编写代码时考虑到未来的变化、扩展和维护需求。
② 这意味着要编写具有灵活性、可维护性和可扩展性的代码,以便能够适应未来的需求变化,而无需进行大规模的重写。
32.2 实践“以未来时态编程”的方法
32.2.1 使用清晰和一致的命名约定
① 为变量、函数、类等选择具有描述性且一致的名称,使代码易于理解。
32.2.2 编写模块化和可重用的代码
① 将代码分解为小的、独立的模块,每个模块负责一个明确的任务。
② 编写可重用的函数和类,避免代码重复。
32.2.3 遵循设计原则和模式
① 应用面向对象的设计原则(例如单一职责原则、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则)。
② 使用常见的设计模式(例如工厂模式、策略模式、观察者模式),这些模式提供了经过验证的解决常见设计问题的方法,并能提高代码的灵活性和可扩展性。
32.2.4 编写充分的文档和注释
① 为代码添加清晰的文档和注释,说明代码的目的、功能、使用方法和任何重要的设计决策。这对于未来的维护者(包括自己)来说至关重要。
32.2.5 考虑代码的可测试性
① 编写易于测试的代码。这通常意味着代码应该是模块化的,并且依赖关系清晰。编写单元测试和集成测试可以帮助在早期发现和修复缺陷,并确保代码在未来修改后仍然能够正常工作。
32.2.6 预留扩展点
① 在设计系统时,考虑到未来可能需要添加新的功能或修改现有功能。通过使用接口、抽象类、虚函数等机制,为未来的扩展预留空间。
32.2.7 避免硬编码
① 尽量使用配置文件、环境变量或常量来管理程序的配置信息,而不是将这些信息硬编码在代码中。这样可以更容易地修改程序的行为,而无需修改源代码。
32.2.8 关注代码的清晰性和可读性
① 编写简洁、易懂的代码。避免使用过于复杂的逻辑或晦涩的语法。清晰的代码更容易理解、维护和修改。
32.2.9 使用版本控制系统
① 使用 Git 等版本控制系统来管理代码的变更历史。这使得可以轻松地回溯到之前的版本,合并不同的修改,并协作开发。
32.2.10 持续学习和适应
① 软件开发领域不断发展,新的技术和最佳实践不断涌现。保持学习的态度,了解最新的语言特性、库和设计模式,并将其应用到你的代码中。
32.3 “以未来时态编程”的好处
32.3.1 提高代码的可维护性
① 易于理解、修改和修复缺陷。
32.3.2 增强代码的可扩展性
① 更容易添加新的功能,而不会破坏现有的代码。
32.3.3 降低未来的开发成本
① 避免了大规模重写的需求,减少了未来的开发工作量。
32.3.4 提高代码的健壮性
① 良好的设计和测试可以减少潜在的错误。
32.4 注意事项
① “以未来时态编程” 并不意味着要过度设计或预测所有可能的未来需求。关键在于在编写当前代码时,保持一定的灵活性和前瞻性。
② 需要在当前的需求和未来的可能性之间找到平衡。
32.5 总结
“以未来时态编程” 是一种重要的编程理念,它强调在编写代码时要考虑到未来的需求和变化。通过采用模块化设计、遵循设计原则、编写清晰的文档和测试等方法,我们可以创建出更易于维护、扩展和适应未来变化的软件。
33. Make non-leaf classes abstract (将非叶类声明为抽象类)
33.1 什么是叶类和非叶类
① 叶类 (Leaf Class):在继承层次结构中,不再有子类的类通常被称为叶类。它们通常实现了特定的功能,并且不会被进一步继承。
② 非叶类 (Non-Leaf Class):在继承层次结构中,有子类的类通常被称为非叶类。它们通常定义了一些通用的接口或行为,供其子类继承和实现。
33.2 什么是抽象类
① 抽象类是包含至少一个纯虚函数 (pure virtual function) 的类。
② 纯虚函数是在基类中声明的虚函数,但没有提供默认的实现,而是要求派生类必须提供自己的实现。
③ 抽象类不能被实例化,只能作为其他类的基类使用。
33.3 为什么应该将非叶类声明为抽象类
33.3.1 强制派生类实现接口
① 如果一个非叶类定义了一些重要的接口或行为,但这些接口或行为的具体实现应该由其不同的子类来完成,那么将这些接口声明为纯虚函数,并将该非叶类声明为抽象类,可以强制所有派生类都必须提供这些接口的具体实现。这有助于确保继承层次结构的一致性和完整性。
33.3.2 防止创建不完整的基类对象
① 非叶类通常代表一个更通用的概念,其自身可能没有一个完全具体的实例。例如,在图形库中,“形状 (Shape)” 可能是一个非叶类,而具体的形状(如“圆形 (Circle)”、“矩形 (Rectangle)”) 才是叶类。直接创建一个“形状”对象可能没有意义,甚至可能导致程序错误。将“形状”声明为抽象类可以防止直接实例化它。
33.3.3 明确类的设计意图
① 将非叶类声明为抽象类可以更清晰地表达类的设计意图:这个类是作为其他类的基类来使用的,而不是用于创建独立的对象。
33.4 示例
1
#include <iostream>
2
#include <vector>
3
4
// 抽象基类 Shape (非叶类)
5
class Shape {
6
public:
7
virtual ~Shape() {}
8
virtual double area() const = 0; // 纯虚函数
9
virtual void draw() const = 0; // 纯虚函数
10
};
11
12
// 具体派生类 Circle (叶类)
13
class Circle : public Shape {
14
private:
15
double radius;
16
public:
17
Circle(double r) : radius(r) {}
18
double area() const override { return 3.14159 * radius * radius; }
19
void draw() const override { std::cout << "Drawing a circle with radius " << radius << std::endl; }
20
};
21
22
// 具体派生类 Rectangle (叶类)
23
class Rectangle : public Shape {
24
private:
25
double width;
26
double height;
27
public:
28
Rectangle(double w, double h) : width(w), height(h) {}
29
double area() const override { return width * height; }
30
void draw() const override { std::cout << "Drawing a rectangle with width " << width << " and height " << height << std::endl; }
31
};
32
33
int main() {
34
// Shape s; // 错误:不能实例化抽象类 Shape
35
Circle c(5.0);
36
Rectangle r(4.0, 6.0);
37
38
std::vector<Shape*> shapes;
39
shapes.push_back(&c);
40
shapes.push_back(&r);
41
42
for (const Shape* shape : shapes) {
43
std::cout << "Area: " << shape->area() << std::endl;
44
shape->draw();
45
}
46
47
return 0;
48
}
在上面的例子中,Shape
是一个非叶类,它定义了 area()
和 draw()
两个纯虚函数,因此 Shape
本身是抽象类,不能被实例化。Circle
和 Rectangle
是 Shape
的叶类,它们必须实现 area()
和 draw()
这两个纯虚函数。
33.5 何时可以不将非叶类声明为抽象类
① 如果一个非叶类本身就有一个合理的、通用的实现,并且可以被直接实例化,那么可以不将其声明为抽象类。然而,如果该类的主要目的是作为基类使用,并且希望强制派生类实现某些接口,那么声明为抽象类通常是更好的选择。
33.6 总结
将非叶类声明为抽象类是一种良好的面向对象设计实践。它可以强制派生类实现必要的接口,防止创建不完整的基类对象,并更清晰地表达类的设计意图。在设计继承层次结构时,应该考虑哪些类是作为基类使用的,并且是否应该将它们声明为抽象类。
34. Understand how to combine C++ and C in the same program (理解如何在同一个程序中混合使用C++和C)
34.1 C++ 和 C 的主要区别
① 名称修饰 (Name Mangling):C++ 编译器会对函数名进行名称修饰,以便支持函数重载和类型安全链接。C 编译器通常不进行名称修饰。这会导致 C++ 代码编译出的函数名与 C 代码期望的函数名不同。
② 链接约定 (Linkage Conventions):C++ 和 C 使用不同的链接约定。C++ 的默认链接是 C++ 链接,而 C 的默认链接是 C 链接。
③ 类型安全 (Type Safety):C++ 比 C 提供更强的类型安全检查。
④ 面向对象特性:C++ 支持类、继承、多态等面向对象的特性,而 C 是面向过程的语言。
⑤ 标准库:C++ 有更丰富的标准库(例如 STL),而 C 的标准库相对较小。
⑥ 其他特性:C++ 支持异常处理、模板、命名空间等 C 中没有的特性。
34.2 混合使用 C++ 和 C 的方法
34.2.1 从 C++ 调用 C 代码
① 要在 C++ 代码中调用 C 函数,需要使用 extern "C"
块来告诉 C++ 编译器使用 C 的链接约定,并禁用名称修饰。
1
// C 头文件 (例如 my_c_header.h)
2
#ifndef MY_C_HEADER_H
3
#define MY_C_HEADER_H
4
5
#ifdef __cplusplus
6
extern "C" {
7
#endif
8
9
int c_function(int x, int y);
10
11
#ifdef __cplusplus
12
}
13
#endif
14
15
#endif // MY_C_HEADER_H
16
17
// C 源文件 (例如 my_c_source.c)
18
#include "my_c_header.h"
19
20
int c_function(int x, int y) {
21
return x + y;
22
}
23
24
// C++ 源文件 (例如 main.cpp)
25
#include <iostream>
26
#include "my_c_header.h"
27
28
int main() {
29
int result = c_function(5, 3);
30
std::cout << "Result from C function: " << result << std::endl; // 输出 8
31
return 0;
32
}
在上面的例子中,extern "C" {}
块告诉 C++ 编译器 c_function
是一个使用 C 链接约定的函数。
34.2.2 从 C 调用 C++ 代码
① 从 C 代码调用 C++ 函数稍微复杂一些,因为 C++ 函数名会被修饰。通常需要提供一个 C 兼容的接口(一个 C 函数),该 C 函数在内部调用 C++ 的实现。
② 还需要确保 C++ 代码中需要被 C 调用的函数也使用了 extern "C"
声明。
1
// C++ 头文件 (例如 my_cpp_header.h)
2
#ifndef MY_CPP_HEADER_H
3
#define MY_CPP_HEADER_H
4
5
#ifdef __cplusplus
6
extern "C" {
7
#endif
8
9
int cpp_function(int x, int y);
10
11
#ifdef __cplusplus
12
}
13
#endif
14
15
#endif // MY_CPP_HEADER_H
16
17
// C++ 源文件 (例如 my_cpp_source.cpp)
18
#include "my_cpp_header.h"
19
20
int cpp_function(int x, int y) {
21
return x * y;
22
}
23
24
// C 源文件 (例如 main.c)
25
#include <stdio.h>
26
#include "my_cpp_header.h"
27
28
int main() {
29
int result = cpp_function(5, 3);
30
printf("Result from C++ function: %d\n", result); // 输出 15
31
return 0;
32
}
同样,extern "C"
用于确保 cpp_function
使用 C 链接。
34.3 混合使用 C++ 和 C 时需要注意的问题
34.3.1 异常处理
① C++ 的异常处理机制在 C 代码中不起作用。从 C++ 代码抛出的异常不能直接在 C 代码中捕获。需要在 C++ 和 C 的边界处进行处理。
34.3.2 面向对象特性
① C 代码无法直接使用 C++ 的类、对象、继承、多态等特性。需要通过 C 兼容的接口来间接访问 C++ 对象。
34.3.3 内存管理
① 最好由分配内存的代码来释放内存。如果在 C++ 中使用 new
分配了内存,应该在 C++ 中使用 delete
来释放,反之亦然。避免跨语言边界进行内存分配和释放。
34.3.4 全局变量和静态变量的初始化顺序
① C++ 中全局对象和静态对象的初始化顺序可能很复杂,并且可能依赖于编译单元的链接顺序。当与 C 代码混合使用时,可能会出现初始化顺序的问题。
34.3.5 标准库的使用
① 尽量避免在 C 和 C++ 代码之间直接传递包含 C++ 标准库类型的对象(例如 std::string
, std::vector
)。可以使用 C 兼容的数据类型(例如字符数组、结构体)作为接口。
34.3.6 编译和链接
① 编译和链接混合语言的项目需要使用能够处理 C 和 C++ 代码的构建工具(例如 GCC 可以同时编译 .c
和 .cpp
文件)。链接时需要确保链接器能够正确地处理不同语言的目标文件。
34.4 总结
在同一个程序中混合使用 C++ 和 C 是可行的,但需要注意它们之间的差异,特别是名称修饰和链接约定。使用 extern "C"
可以实现 C++ 和 C 代码之间的函数调用。在跨语言边界交互时,需要谨慎处理异常、面向对象特性、内存管理和标准库的使用,以确保程序的正确性和稳定性。通常,最好定义清晰的 C 接口来作为 C++ 代码的桥梁,供 C 代码调用。
35. Familiarize yourself with the language standard (熟悉语言标准)
35.1 什么是 C++ 语言标准
① C++ 语言标准是由国际标准化组织 (ISO) 和国际电工委员会 (IEC) 联合制定的,用于规范 C++ 编程语言的语法、语义和标准库。
② 不同的 C++ 标准版本代表了语言的演进和发展。重要的标准版本包括:
* C++98 (ISO/IEC 14882:1998)
* C++03 (ISO/IEC 14882:2003)
* C++11 (ISO/IEC 14882:2011)
* C++14 (ISO/IEC 14882:2014)
* C++17 (ISO/IEC 14882:2017)
* C++20 (ISO/IEC 14882:2020)
* C++23 (ISO/IEC 14882:2023)
35.2 熟悉语言标准的重要性
35.2.1 编写可移植的代码
① 遵循语言标准可以确保你的代码在不同的编译器和平台上具有更好的可移植性。
35.2.2 理解语言特性和行为
① 语言标准详细定义了 C++ 的各种特性(例如模板、异常、内存模型)的行为。熟悉标准可以帮助你更深入地理解语言的工作原理,避免一些常见的陷阱。
35.2.3 使用最新的语言特性
① 每个新的 C++ 标准版本都会引入新的语言特性和标准库组件,这些新特性通常可以提高开发效率、代码性能和可读性。熟悉最新的标准可以让你利用这些优势。
35.2.4 遵循最佳实践
① 语言标准通常会引导开发者使用更安全、更高效的编程实践。
35.2.5 更好地理解其他人的代码
① 当阅读和维护别人的代码时,熟悉代码所遵循的 C++ 标准版本可以帮助你更好地理解代码的意图和实现方式。
35.2.6 面试和职业发展
① 熟悉 C++ 语言标准是成为一名优秀的 C++ 程序员的基础。在面试和工作中,对标准的理解往往是衡量专业水平的重要指标。
35.3 如何熟悉语言标准
35.3.1 阅读官方标准文档
① 可以从 ISO 官方网站购买 C++ 标准文档。虽然这些文档非常详细和权威,但通常也比较晦涩难懂,更适合作为参考手册。
35.3.2 阅读高质量的 C++ 书籍
① 有许多优秀的 C++ 书籍(例如 Scott Meyers 的 Effective C++ 系列,Bjarne Stroustrup 的 The C++ Programming Language)会深入探讨 C++ 标准的各个方面。
35.3.3 学习和使用最新的 C++ 特性
① 关注 C++ 社区的动态,学习和尝试使用 C++11 及更高版本引入的新特性(例如 lambda 表达式、智能指针、移动语义、并发库等)。
35.3.4 查阅在线资源
① 有许多优秀的在线资源(例如 cppreference.com, cplusplus.com)提供了 C++ 标准库和语言特性的详细文档和示例。
35.3.5 参与 C++ 社区
① 参与 C++ 论坛、邮件列表和会议,与其他 C++ 开发者交流,可以帮助你更好地理解和应用语言标准。
35.3.6 关注编译器和库的更新
① 不同的编译器对 C++ 标准的支持程度可能有所不同。关注你使用的编译器和标准库的更新日志,了解它们对新标准的支持情况。
35.4 总结
熟悉 C++ 语言标准对于编写高质量、可移植和可维护的 C++ 代码至关重要。通过阅读文档、书籍、学习新特性和参与社区等方式,不断加深对语言标准的理解,可以帮助你成为一名更优秀的 C++ 程序员。记住,C++ 是一门不断发展的语言,持续学习是非常重要的。