001 《Effective C++》读书笔记
《Effective C++》第三版的所有 Items:
第一章:Accustoming Yourself to C++ (让自己习惯 C++)
→ Item 1: View C++ as a federation of languages (视 C++ 为一个语言联邦).
→ Item 2: Prefer const
s, enums
, and inline
s to #define
s (尽量以 const
、enum
、inline
替换 #define
).
→ Item 3: Use const
whenever possible (尽可能使用 const
).
→ Item 4: Make sure that objects are initialized before they're used (确定对象在使用前已先被初始化).
第二章:Constructors, Destructors, and Assignment Operators (构造/析构/赋值运算)
→ Item 5: Know what functions C++ silently writes and calls (了解 C++ 默默编写并调用哪些函数).
→ Item 6: Explicitly disallow the use of compiler-generated functions you do not want (若不想使用编译器自动生成的函数,就该明确拒绝).
→ Item 7: Declare destructors virtual in polymorphic base classes (为多态基类声明虚析构函数).
→ Item 8: Prevent exceptions from leaving destructors (别让异常逃离析构函数).
→ Item 9: Never call virtual functions during construction or destruction (绝不在构造和析构过程中调用虚函数).
→ Item 10: Have assignment operators return a reference to *this
(令 operator=
返回一个指向 *this
的引用).
→ Item 11: Handle assignment to self in operator=
(在 operator=
中处理“自我赋值”).
→ Item 12: Copy all parts of an object (复制对象时勿忘其每一个成分).
第三章:Resource Management (资源管理)
→ Item 13: Use objects to manage resources (以对象管理资源).
→ Item 14: Think carefully about copying behavior in resource-managing classes (在资源管理类中小心 copying 行为).
→ Item 15: Provide access to raw resources in resource-managing classes (在资源管理类中提供对原始资源的访问).
→ Item 16: Use the same form in corresponding uses of new
and delete
(成对使用 new
和 delete
时要采取相同形式).
→ Item 17: Store new
ed objects in smart pointers in standalone statements (以独立语句将 new
ed 对象置入智能指针).
第四章:Designs and Declarations (设计与声明)
→ Item 18: Make interfaces easy to use correctly and hard to use incorrectly (让接口容易被正确使用,不易被误用).
→ Item 19: Treat class design as type design (设计 class 犹如设计 type).
→ Item 20: Prefer pass-by-reference-to-const
to pass-by-value (宁以 pass-by-reference-to-const
替换 pass-by-value).
→ Item 21: Don't try to return a reference when you must return an object (必须返回对象时,别妄想返回其 reference).
→ Item 22: Declare data members private
(将成员变量声明为 private
).
→ Item 23: Prefer non-member non-friend functions to member functions (宁以 non-member non-friend 函数替换 member 函数).
→ Item 24: Declare non-member functions when type conversions should apply to all parameters (若所有参数皆需类型转换,请为此采用 non-member 函数).
→ Item 25: Consider support for a non-throwing swap
(考虑写出一个不抛异常的 swap
函数).
第五章:Implementations (实现)
→ Item 26: Postpone variable definitions as long as possible (尽可能延后变量定义式的出现时间).
→ Item 27: Minimize casting (尽量少做转型动作).
→ Item 28: Avoid returning "handles" to object internals (避免返回 handles 指向对象内部成分).
→ Item 29: Strive for exception-safe code (为“异常安全”而努力是值得的).
→ Item 30: Understand the ins and outs of inlining (透彻了解 inlining 的里里外外).
→ Item 31: Minimize compilation dependencies between files (将文件间的编译依存关系降至最低).
第六章:Inheritance and Object-Oriented Design (继承与面向对象设计)
→ Item 32: Make sure public inheritance models "is-a" (确定你的 public 继承塑模出 is-a 关系).
→ Item 33: Avoid hiding inherited names (避免遮掩继承而来的名称).
→ Item 34: Differentiate between inheritance of interface and inheritance of implementation (区分接口继承和实现继承).
→ Item 35: Consider alternatives to virtual functions (考虑虚函数以外的其他选择).
→ Item 36: Never redefine an inherited non-virtual function (绝不重新定义继承而来的 non-virtual 函数).
→ Item 37: Never redefine a function's inherited default parameter value (绝不重新定义继承而来的缺省参数值).
→ Item 38: Model "has-a" or "is-implemented-in-terms-of" through composition (通过复合塑模 has-a 或“根据某物实现出”).
→ Item 39: Use private inheritance judiciously (明智而审慎地使用 private 继承).
→ Item 40: Use multiple inheritance judiciously (明智而审慎地使用多重继承).
第七章:Templates and Generic Programming (模板与泛型编程)
→ Item 41: Understand implicit interfaces and compile-time polymorphism (了解隐式接口和编译期多态).
→ Item 42: Understand the two meanings of typename
(了解 typename
的双重意义).
→ Item 43: Know how to access names in templatized base classes (学习处理模板化基类内的名称).
→ Item 44: Factor parameter-independent code out of templates (将模板代码中与参数无关的代码抽离).
→ Item 45: Use member function templates to accept "all compatible types" (运用成员函数模板接受所有兼容类型).
→ Item 46: Define non-member functions inside templates when type conversions are desired (需要类型转换时请为模板定义非成员函数).
→ Item 47: Use traits classes for information about types (使用 traits classes 表现类型信息).
→ Item 48: Be aware of template metaprogramming (认识模板元编程).
第八章:Customizing new
and delete
(定制 new
和 delete
)
→ Item 49: Understand the behavior of the new-handler
(了解 new-handler
的行为).
→ Item 50: Understand when it makes sense to replace new
and delete
(了解何时适合替换 new
和 delete
).
→ Item 51: Adhere to convention when writing new
and delete
(编写 new
和 delete
时需固守常规).
→ Item 52: Write placement delete
if you write placement new
(如果写了 placement new
,也要写 placement delete
).
第九章:Miscellany (杂项讨论)
→ Item 53: Pay attention to compiler warnings (不要轻忽编译器的警告).
→ Item 54: Familiarize yourself with the standard library, including TR1 (让自己熟悉包括 TR1 在内的标准库).
→ Item 55: Familiarize yourself with Boost (让自己熟悉 Boost).
好的,现在我将按照您约定的输出格式,对您提供的每个 Item 进行非常非常详细的解释。
1. Item 1: View C++ as a federation of languages (视 C++ 为一个语言联邦)
1.1 理解“语言联邦”的概念
C++ 并非单一的编程语言,而更像是由多种编程范式和风格组成的联邦。理解这一点对于高效地使用 C++ 至关重要。这个“联邦”主要包含以下几个“成员”:
1.1.1 C 语言子集 (The C Sublanguage)
① C++ 兼容大部分 C 语言的特性。这意味着你可以在 C++ 中使用许多 C 的语法和库。
①.① 例如,你可以使用 printf
和 scanf
进行输入输出,使用 malloc
和 free
进行动态内存管理(虽然在 C++ 中通常有更好的替代方案)。
①.② 这种兼容性使得从 C 语言迁移到 C++ 相对容易,并且允许在 C++ 项目中重用现有的 C 代码。
1.1.2 面向对象编程 (Object-Oriented C++)
② C++ 的核心特性之一是面向对象编程 (OOP)。这包括:
②.① 类 (Classes):允许你定义自己的数据类型,将数据和操作数据的函数封装在一起。
②.② 继承 (Inheritance):允许你从现有的类派生出新的类,从而实现代码的重用和扩展。
②.③ 多态 (Polymorphism):允许你通过基类的指针或引用来操作派生类的对象,实现更灵活的设计。
②.④ 封装 (Encapsulation):通过访问控制(例如 public
、private
、protected
)来隐藏对象的内部实现细节,提高代码的可维护性和安全性。
1.1.3 模板编程 (Template C++)
③ C++ 的模板 (Templates) 是一种强大的泛型编程工具,它允许你编写可以用于多种数据类型的代码,而无需为每种类型都编写不同的版本。
③.① 函数模板 (Function Templates):可以创建通用的函数,这些函数可以操作不同类型的参数。
③.② 类模板 (Class Templates):可以创建通用的类,这些类可以存储和操作不同类型的数据。例如,std::vector
和 std::map
就是类模板。
③.③ 模板编程在提高代码的灵活性和重用性方面非常有效,并且是 C++ 标准库 (STL) 的基石。
1.1.4 标准模板库 (STL)
④ 标准模板库 (Standard Template Library, STL) 是 C++ 标准库的重要组成部分,它提供了一组高效且通用的容器(例如 vector
、list
、map
)、迭代器、算法和函数对象。
④.① STL 极大地提高了 C++ 的开发效率,因为它提供了许多常用的数据结构和算法的实现,开发者可以直接使用而无需重复造轮子。
1.1.5 底层编程 (Low-Level C++)
⑤ 尽管 C++ 提供了高级的抽象,但它仍然允许你进行底层的内存管理和硬件操作。
⑤.① 你可以使用指针直接访问内存地址。
⑤.② 你可以进行位操作。
⑤.③ 你可以与操作系统进行更底层的交互。
⑤.④ 这种能力使得 C++ 非常适合开发性能关键型应用、系统级软件和嵌入式系统。
1.1.6 C++11 及更高版本的新特性 (Modern C++)
⑥ 自 C++11 标准以来,C++ 语言不断发展,引入了许多现代化的特性,例如:
⑥.① Lambda 表达式 (Lambda Expressions):方便创建匿名函数对象。
⑥.② 智能指针 (Smart Pointers):自动管理动态分配的内存,避免内存泄漏。
⑥.③ 移动语义 (Move Semantics):提高对象拷贝和赋值的效率。
⑥.④ 并发编程支持 (Concurrency Support):例如 std::thread
、std::mutex
等。
⑥.⑤ 范围 for 循环 (Range-Based For Loops):简化容器的遍历。
⑥.⑥ 类型推导 (Type Inference):使用 auto
关键字自动推导变量类型。
1.2 理解“语言联邦”的重要性
认识到 C++ 是一个“语言联邦”有助于你:
→ 选择合适的编程范式:根据项目的需求和性质,选择最合适的编程范式或它们的组合。例如,对于图形界面应用,面向对象编程可能更合适;对于需要高性能的算法实现,模板编程和底层编程技巧可能更重要。
→ 更有效地学习和使用 C++:将 C++ 分解为不同的组成部分进行学习,可以让你更系统地掌握这门语言。
→ 更好地理解现有的 C++ 代码:不同的代码库和项目可能采用不同的 C++ 特性和风格。理解“语言联邦”的概念可以帮助你更好地理解这些代码。
→ 做出更明智的设计决策:在设计 C++ 应用程序时,你需要考虑如何有效地利用 C++ 的各种特性,以实现最佳的性能、可维护性和可扩展性。
总而言之,将 C++ 视为一个由多种语言风格组成的联邦,能够帮助开发者更全面、更深入地理解这门强大而复杂的编程语言,并能根据实际需求灵活地运用其不同的特性。
2. Item 2: Prefer const
s, enums
, and inline
s to #define
s (尽量以 const
、enum
、inline
替换 #define
)
2.1 #define
的问题
在 C 语言以及早期的 C++ 中,#define
预处理器指令被广泛用于定义常量和简单的宏函数。然而,#define
存在一些固有的问题:
2.1.1 缺乏类型安全性 (Lack of Type Safety)
① #define
仅仅是简单的文本替换,预处理器在编译之前进行替换,不会进行类型检查。
①.① 例如,#define PI 3.14159
只是将代码中的所有 PI
替换为 3.14159
,编译器并不知道 PI
应该是一个浮点数。
①.② 这可能导致一些难以发现的类型错误,因为这些错误直到编译的后期阶段才可能暴露出来,甚至可能在运行时才出现。
2.1.2 作用域问题 (Scope Issues)
② #define
定义的宏是全局的,它们的作用域从定义处开始,一直到文件结束或者遇到 #undef
指令。
②.① 这可能导致命名冲突,尤其是在大型项目中,不同的文件可能定义了相同名称的宏,但其含义不同。
②.② 调试时也比较困难,因为你无法确定一个宏定义究竟是在哪里定义的。
2.1.3 调试困难 (Debugging Difficulty)
③ 当程序出错时,调试器通常会显示源代码中的变量名。然而,对于 #define
定义的常量,调试器可能无法识别它们,而是直接显示替换后的值。
③.① 这使得在调试过程中很难追踪宏定义的值和使用情况。
2.1.4 可能产生意外的副作用 (Potential for Unexpected Side Effects)
④ 对于用 #define
定义的宏函数,由于它们是简单的文本替换,可能会导致一些意想不到的副作用,尤其是在宏的参数包含自增或自减等操作符时。
④.① 例如,考虑以下宏定义:#define MAX(a, b) ((a) > (b) ? (a) : (b))
。如果调用 MAX(i++, j++)
,那么 i
和 j
可能会被递增两次,这可能不是你期望的结果。
2.2 使用 const
替换 #define
定义常量
对于定义常量,应该优先使用 const
关键字:
2.2.1 提供类型安全性
① const
定义的常量具有明确的类型,编译器会进行类型检查,从而避免类型错误。
①.① 例如,const double PI = 3.14159;
明确地将 PI
定义为一个双精度浮点数。
2.2.2 拥有作用域
② const
定义的常量遵循 C++ 的作用域规则,可以是全局的、局部的或者类的成员。
②.① 这有助于避免命名冲突,并使得代码更易于管理。
2.2.3 可被调试器识别
③ 调试器可以识别 const
定义的常量,并在调试过程中显示其名称和值,方便调试。
2.2.4 可以用于更复杂的类型
④ const
可以用于定义各种类型的常量,包括基本类型、指针、对象等。
2.2.5 示例
1
// 不推荐的做法
2
#define ARRAY_SIZE 100
3
4
// 推荐的做法
5
const int arraySize = 100;
2.3 使用 enum
替换 #define
定义枚举常量
对于一组相关的整型常量,可以使用 enum
类型:
2.3.1 提供类型安全性
① enum
类型定义了一组具名的整数常量,编译器会进行类型检查。
2.3.2 拥有作用域
② enum
类型也遵循 C++ 的作用域规则。
2.3.3 提高代码可读性
③ 使用 enum
可以使代码更易于理解和维护,因为它明确地表示了一组相关的常量。
2.3.4 示例
1
// 不推荐的做法
2
#define RED 0
3
#define GREEN 1
4
#define BLUE 2
5
6
// 推荐的做法
7
enum Color { RED, GREEN, BLUE };
2.4 使用 inline
函数替换 #define
定义宏函数
对于简单的函数,应该优先使用 inline
函数而不是宏函数:
2.4.1 提供类型安全性
① inline
函数是真正的函数,编译器会对其参数和返回值进行类型检查。
2.4.2 避免宏的副作用
② inline
函数的参数只会被求值一次,避免了宏可能导致的意外副作用。
2.4.3 可以进行更复杂的逻辑
③ inline
函数可以包含更复杂的逻辑,而宏通常只适用于简单的表达式。
2.4.4 可被调试器识别
④ 调试器可以像调试普通函数一样调试 inline
函数。
2.4.5 编译器可以进行优化
⑤ 编译器可以根据具体情况决定是否将 inline
函数展开,从而在提高性能的同时保持代码的可读性和可维护性。
2.4.6 示例
1
// 不推荐的做法
2
#define SQUARE(x) ((x) * (x))
3
4
// 推荐的做法
5
inline int square(int x) { return x * x; }
2.5 总结
总而言之,尽量避免使用 #define
来定义常量和简单的宏函数,而是应该优先使用 const
、enum
和 inline
。这些 C++ 的特性提供了更好的类型安全性、作用域管理、调试支持,并且可以避免宏的一些潜在问题,从而提高代码的质量和可维护性。在现代 C++ 中,#define
主要用于条件编译等场景。
3. Item 3: Use const
whenever possible (尽可能使用 const
)
3.1 const
的重要性
const
是 C++ 中一个非常重要的关键字,它用于表示“不变性”。尽可能地使用 const
可以带来许多好处,包括提高代码的安全性、可读性和可维护性。
3.2 const
的应用场景
const
可以应用于多种场景:
3.2.1 修饰变量
① const
修饰基本类型变量:表示变量的值在初始化后不能被修改。
①.① 例如:const int MAX_VALUE = 100;
①.② 这样做可以防止意外地修改不应该改变的变量,提高代码的健壮性。
② const
修饰指针:有以下几种情况:
②.① 指向 const
对象的指针:指针本身可以修改(指向不同的对象),但不能通过该指针修改所指向的对象的值。
②.①.① 例如:const int* ptr = &value;
②.①.② 这表示 ptr
指向一个 const int
,你不能通过 *ptr
来修改 value
的值。
②.② const
指针:指针本身不能被修改(不能指向其他对象),但可以通过该指针修改所指向的对象的值(如果对象本身不是 const
的)。
②.②.① 例如:int* const ptr = &value;
②.②.② 这表示 ptr
是一个指向 int
的常量指针,ptr
始终指向 value
,但你可以通过 *ptr
修改 value
的值。
②.③ 指向 const
对象的 const
指针:指针本身不能被修改,也不能通过该指针修改所指向的对象的值。
②.③.① 例如:const int* const ptr = &value;
②.③.② 这表示 ptr
是一个指向 const int
的常量指针,ptr
始终指向 value
,并且你不能通过 *ptr
修改 value
的值。
③ const
修饰引用:引用一旦绑定到一个对象,就不能再绑定到其他对象。const
引用表示不能通过该引用修改所引用的对象的值。
③.① 例如:const int& ref = value;
③.② 这表示 ref
是一个对 value
的常量引用,你不能通过 ref
来修改 value
的值。通常,使用常量引用作为函数参数可以避免不必要的拷贝,并且可以接受常量对象作为参数。
3.2.2 修饰函数参数
① 值传递:对于基本类型或小型对象的值传递,使用 const
通常没有实际意义,因为函数接收的是参数的副本,修改副本不会影响原始对象。
② 指针传递或引用传递:使用 const
修饰指针或引用参数,表示函数不会修改通过该指针或引用传递的对象。
②.① 例如:void printValue(const int& value);
②.② 这样做可以明确地告诉函数的调用者,该函数不会改变传递给它的对象,有助于提高代码的可读性和安全性。
3.2.3 修饰函数返回值
① 返回 const
值:对于返回基本类型或小型对象的函数,返回 const
通常没有实际意义。
② 返回指向 const
对象的指针或引用:表示函数的返回值是一个指向常量对象的指针或引用,调用者不能通过该返回值修改对象的值。
②.① 例如:const std::string& getName() const;
(假设 getName
是一个成员函数)
②.② 这样做可以防止调用者意外地修改函数返回的对象。
3.2.4 修饰成员函数
① const
成员函数:在成员函数的声明和定义末尾加上 const
关键字,表示该成员函数不会修改对象的任何非静态成员变量。
①.① 例如:int getValue() const;
①.② const
成员函数只能访问对象的 const
成员变量,或者调用其他 const
成员函数。
①.③ 将不修改对象状态的成员函数声明为 const
是一个非常好的习惯,它允许你对 const
对象调用这些函数,提高了代码的灵活性。
3.3 使用 const
的好处
→ 提高代码的安全性:const
可以防止意外地修改不应该被修改的变量或对象,从而减少程序中的错误。
→ 提高代码的可读性:const
明确地表明了哪些变量或对象是只读的,有助于理解代码的意图。
→ 提高代码的可维护性:通过使用 const
,可以更容易地理解代码的依赖关系,因为你知道哪些部分不会改变哪些数据。
→ 允许编译器进行优化:编译器可以利用 const
信息进行一些优化,例如将 const
变量存储在只读内存中,或者在某些情况下进行常量折叠。
→ 支持函数重载:可以基于成员函数是否为 const
来进行函数重载。
1
class MyClass {
2
public:
3
int getValue() const { return value_; } // const 版本,用于 const 对象
4
int& getValue() { return value_; } // 非 const 版本,用于非 const 对象
5
6
private:
7
int value_;
8
};
3.4 何时使用 const
应该尽可能地使用 const
,除非你需要修改变量或对象的值。以下是一些通用的原则:
→ 将不应该被修改的变量声明为 const
。
→ 将不会修改通过指针或引用传递的参数的函数参数声明为 const
。
→ 如果一个成员函数不会修改对象的状态,则将其声明为 const
。
→ 如果函数返回的对象不应该被修改,则考虑将其声明为 const
。
3.5 总结
总之,尽可能地使用 const
是编写高质量 C++ 代码的重要原则之一。它可以提高代码的安全性、可读性和可维护性,并允许编译器进行优化。养成使用 const
的习惯,会让你的代码更加健壮和可靠。
4. Item 4: Make sure that objects are initialized before they're used (确定对象在使用前已先被初始化)
4.1 未初始化对象的问题
在使用对象之前确保它们已经被初始化是非常重要的。使用未初始化的对象可能导致不可预测的行为,包括:
→ 未定义行为 (Undefined Behavior):访问未初始化的变量或对象是 C++ 中未定义行为的一种常见形式。这意味着程序的行为是不可预测的,可能在不同的编译器、不同的操作系统或不同的运行环境下表现出不同的结果,包括崩溃、产生垃圾值或者看似正常地运行但结果错误。
→ 难以调试的错误:由于未定义行为的不可预测性,由未初始化对象引起的问题通常很难追踪和调试。
→ 安全隐患:在某些情况下,使用未初始化的数据可能会导致安全漏洞。
4.2 初始化方式
C++ 提供了多种初始化对象的方式:
4.2.1 默认初始化 (Default Initialization)
① 当对象在没有显式提供初始值的情况下被创建时,会进行默认初始化。默认初始化的行为取决于对象的类型和存储位置:
①.① 内置类型 (Built-in Types)(例如 int
, float
, bool
等):在函数或块作用域内定义的内置类型对象不会被初始化,它们的值是未定义的。在全局作用域或静态存储区定义的内置类型对象会被初始化为零或 false
。
①.② 类类型 (Class Types):如果类有默认构造函数(即没有参数的构造函数),则会调用该默认构造函数来初始化对象。如果类没有默认构造函数,则在没有提供初始值的情况下创建该类的对象会导致编译错误。
①.③ 数组 (Arrays):如果数组是内置类型的,并且在函数或块作用域内定义,则其元素不会被初始化。如果数组是类类型的,则会为每个元素调用默认构造函数(如果存在)。
4.2.2 值初始化 (Value Initialization)
② 值初始化发生在以下情况:
②.① 当使用空的花括号 {}
初始化对象时,例如 int x{};
或 std::string s{};
。
②.② 当创建未提供初始值的数组时,例如 int arr[10] = {};
。
②.③ 当使用圆括号 ()
调用没有参数的构造函数时,例如 std::string s();
(注意这与声明一个返回 std::string
的函数不同,正确的默认构造函数调用是 std::string s;
或 std::string s{};
)。
②.④ 对于内置类型,值初始化会将其设置为零或 false
。对于类类型,如果存在默认构造函数,则调用它;否则,如果类有用户提供的默认构造函数,则调用它;如果类没有用户提供的默认构造函数,但其所有非静态数据成员都被值初始化,则对象也会被值初始化。
4.2.3 直接初始化 (Direct Initialization)
③ 当使用圆括号 ()
提供初始值时,会进行直接初始化。
③.① 例如:int x(10);
或 std::string s("hello");
。
③.② 对于类类型,会调用与提供的参数匹配的构造函数。
4.2.4 拷贝初始化 (Copy Initialization)
④ 当使用等号 =
提供初始值时,会进行拷贝初始化。
④.① 例如:int x = 10;
或 std::string s = "hello";
。
④.② 对于类类型,会调用与提供的参数匹配的构造函数,或者如果存在拷贝构造函数,则会调用拷贝构造函数。在某些情况下,编译器可能会优化掉拷贝操作。
4.2.5 列表初始化 (List Initialization)
⑤ 使用花括号 {}
提供初始值的方式称为列表初始化(也称为统一初始化)。这是 C++11 引入的一种通用的初始化语法,可以用于各种类型的初始化。
⑤.① 例如:int x{10};
,std::string s{"hello"};
,std::vector<int> v{1, 2, 3};
。
⑤.② 列表初始化具有一些特殊的行为,例如它可以防止窄化转换(例如从 double
到 int
的隐式转换,如果会丢失精度则会报错)。
4.3 确保对象在使用前被初始化的最佳实践
→ 总是显式地初始化对象:不要依赖默认初始化,除非你清楚地知道其行为并且它是你所期望的。在声明变量或创建对象时,总是提供一个初始值。
1
int counter = 0;
2
std::string name = "";
3
std::vector<int> data; // 默认构造函数会被调用,data 被初始化为空 vector
→ 使用构造函数进行类对象的初始化:对于类类型的对象,确保在构造函数中初始化所有的成员变量。最好使用成员初始化列表来完成这个任务。
1
class MyClass {
2
public:
3
MyClass(int value) : memberVar_(value) {} // 使用成员初始化列表
4
private:
5
int memberVar_;
6
};
→ 注意内置类型在不同作用域的初始化行为:记住局部作用域内的内置类型不会被默认初始化,因此务必显式初始化它们。
1
void myFunction() {
2
int localValue; // localValue 未被初始化,使用它会导致未定义行为
3
int initializedValue = 0; // 显式初始化
4
// ...
5
}
→ 对于 const
对象,必须在定义时进行初始化:因为 const
对象的值在初始化后不能被修改。
1
const int SIZE = 10; // 必须在定义时初始化
→ 避免使用未初始化的指针:在使用指针之前,确保它指向一个有效的内存地址,或者将其初始化为 nullptr
(在 C++11 及更高版本中)或 NULL
。
1
int* ptr = nullptr; // 初始化为 null
2
int value = 5;
3
ptr = &value; // 指向有效的内存
→ 使用智能指针管理动态分配的内存:智能指针(例如 std::unique_ptr
和 std::shared_ptr
)会在创建时进行初始化,并自动管理所指向的内存,减少了因忘记释放内存或使用悬挂指针而导致的问题。
4.4 总结
确保对象在使用前被初始化是编写可靠 C++ 代码的基础。通过理解不同的初始化方式以及遵循最佳实践,可以避免许多潜在的错误和未定义行为,提高代码的质量和稳定性。记住,显式地初始化你的对象,特别是局部变量和类的成员变量。
5. Item 5: Know what functions C++ silently writes and calls (了解 C++ 默默编写并调用哪些函数)
5.1 编译器自动生成的函数
在 C++ 中,如果程序员没有显式地定义某些特殊的成员函数,编译器在必要时会自动生成它们。这些函数通常被称为“特殊成员函数”。了解这些函数以及它们何时被生成和调用至关重要,因为它们的默认行为可能不是你所期望的。
编译器会自动生成的特殊成员函数包括:
5.1.1 默认构造函数 (Default Constructor)
① 何时生成:当一个类没有定义任何构造函数时,编译器会生成一个默认构造函数。
② 默认行为:
②.① 对于类中的内置类型(例如 int
, float
, bool
等)的成员变量,默认构造函数不会进行任何初始化,它们的值是未定义的。
②.② 对于类中的类类型成员变量,会调用它们的默认构造函数(如果存在)。
②.③ 如果类中包含 const
成员或引用成员,并且没有在构造函数的初始化列表中显式初始化它们,则会导致编译错误,因为这些成员在创建后就不能再被赋值。
③ 注意事项:
③.① 如果你为类定义了任何构造函数(即使是有参数的),编译器就不会再生成默认构造函数。如果你仍然需要一个无参数的构造函数,你必须显式地定义它。
③.② 对于没有定义任何构造函数的类(通常是只包含数据成员的简单结构体或类),编译器生成的默认构造函数可能是你想要的。但对于更复杂的类,你通常需要自定义构造函数来确保对象被正确地初始化。
5.1.2 拷贝构造函数 (Copy Constructor)
① 何时生成:当一个类没有定义拷贝构造函数时,并且在以下情况发生时需要创建一个对象的副本,编译器会生成一个默认拷贝构造函数:
①.① 当使用一个已存在的对象初始化一个新的对象时(例如 ClassName newObject = existingObject;
或 ClassName newObject(existingObject);
)。
①.② 当对象作为参数按值传递给函数时。
①.③ 当函数按值返回对象时。
② 默认行为:默认拷贝构造函数执行的是浅拷贝 (Shallow Copy),即它会逐个成员地将原始对象的非静态成员变量的值拷贝到新对象中。
②.① 对于内置类型的成员变量,会直接拷贝其值。
②.② 对于类类型的成员变量,会调用它们的拷贝构造函数(如果存在)。
②.③ 对于指针类型的成员变量,只会拷贝指针的值(即所指向的内存地址),而不会拷贝指针所指向的内存内容。这可能导致两个对象中的指针指向同一块内存,当其中一个对象销毁时释放了这块内存,另一个对象的指针就会变成悬挂指针,从而引发问题。
③ 注意事项:
③.① 如果你的类中包含需要深拷贝的资源(例如动态分配的内存),那么默认拷贝构造函数的浅拷贝行为通常是不够的,你需要自定义拷贝构造函数来执行深拷贝。
③.② 如果你不想让你的类可以被拷贝,你可以将拷贝构造函数声明为私有的并且不提供定义(在 C++11 及更高版本中,可以使用 = delete
来禁用)。
5.1.3 拷贝赋值运算符 (Copy Assignment Operator)
① 何时生成:当一个类没有定义拷贝赋值运算符时,并且在一个已存在的对象被赋值给另一个已存在的对象时(例如 object1 = object2;
),编译器会生成一个默认拷贝赋值运算符。
② 默认行为:默认拷贝赋值运算符也执行的是浅拷贝 (Shallow Copy),即它会逐个成员地将右侧对象的非静态成员变量的值赋给左侧对象的对应成员变量。
②.① 对于内置类型的成员变量,会直接赋值其值。
②.② 对于类类型的成员变量,会调用它们的拷贝赋值运算符(如果存在)。
②.③ 对于指针类型的成员变量,只会拷贝指针的值,同样可能导致浅拷贝问题。
③ 注意事项:
③.① 与拷贝构造函数类似,如果你的类中包含需要深拷贝的资源,那么默认拷贝赋值运算符的浅拷贝行为通常是不够的,你需要自定义拷贝赋值运算符来执行深拷贝,并且需要注意自赋值的情况(例如 object = object;
)。
③.② 如果你不想让你的类可以被赋值,你可以将拷贝赋值运算符声明为私有的并且不提供定义(在 C++11 及更高版本中,可以使用 = delete
来禁用)。
5.1.4 移动构造函数 (Move Constructor)
① 何时生成:在 C++11 中引入了移动语义,如果一个类没有定义移动构造函数,并且在需要将资源从一个对象“移动”到另一个对象时(例如,当源对象是一个临时对象或即将被销毁的对象时),编译器可能会生成一个默认移动构造函数。
② 默认行为:默认移动构造函数执行的是浅拷贝 (Shallow Copy),但其目的是为了高效地转移资源的所有权,而不是像拷贝构造函数那样创建一个完全独立的副本。
②.① 对于内置类型的成员变量,会直接拷贝其值。
②.② 对于类类型的成员变量,会调用它们的移动构造函数(如果存在)。
②.③ 对于指针类型的成员变量,会拷贝指针的值,并且通常会将源对象中的指针设置为 nullptr
,以防止源对象在销毁时释放已被移动的资源。
③ 注意事项:
③.① 如果你的类管理着动态分配的资源,并且自定义了拷贝构造函数,那么通常也需要自定义移动构造函数来实现高效的资源转移。
③.② 编译器只有在满足特定条件时才会生成默认移动构造函数,例如类中没有用户声明的拷贝构造函数、拷贝赋值运算符、移动赋值运算符或析构函数。
5.1.5 移动赋值运算符 (Move Assignment Operator)
① 何时生成:类似于移动构造函数,如果一个类没有定义移动赋值运算符,并且在一个对象被赋值为另一个即将被销毁的对象时,编译器可能会生成一个默认移动赋值运算符。
② 默认行为:默认移动赋值运算符也执行的是浅拷贝,并负责将资源的所有权从右侧对象转移到左侧对象。
③ 注意事项:
③.① 与移动构造函数类似,如果需要自定义移动语义,通常需要同时定义移动构造函数和移动赋值运算符。
③.② 编译器生成默认移动赋值运算符的条件与生成默认移动构造函数类似。
5.1.6 析构函数 (Destructor)
① 何时生成:如果一个类没有定义析构函数,编译器会生成一个默认析构函数。
② 默认行为:默认析构函数不做任何事情。它只是在对象生命周期结束时被调用。
③ 注意事项:
③.① 如果你的类中分配了需要手动释放的资源(例如使用 new
分配的内存),那么你需要自定义析构函数来释放这些资源,以避免内存泄漏。
③.② 如果一个类是多态基类,并且你希望通过基类指针删除派生类对象时能够正确地调用派生类的析构函数,那么你需要将基类的析构函数声明为 virtual
。
5.2 总结
了解编译器在背后默默生成和调用的这些特殊成员函数对于编写正确和高效的 C++ 代码至关重要。你需要根据类的具体需求来决定是否需要自定义这些函数。通常,如果你的类管理着资源(例如动态内存、文件句柄、网络连接等),那么你很可能需要自定义拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符以及析构函数,以确保资源的正确管理和对象的正确行为。这条规则通常被称为“五法则 (Rule of Five)”,在 C++11 之后,由于移动语义的引入,有时也称为“五法则”或“零法则 (Rule of Zero)”(如果类不负责任何资源的分配和释放,那么它不需要定义任何特殊成员函数,可以使用编译器生成的默认版本)。
6. Item 6: Explicitly disallow the use of compiler-generated functions you do not want (若不想使用编译器自动生成的函数,就该明确拒绝)
6.1 为什么需要禁用编译器自动生成的函数
在某些情况下,编译器自动生成的特殊成员函数(如拷贝构造函数和拷贝赋值运算符)的默认行为可能不适合你的类的需求。例如:
→ 单例模式 (Singleton Pattern):你可能希望确保一个类只有一个实例,因此需要禁用拷贝构造函数和拷贝赋值运算符,以防止创建额外的实例。
→ 管理独占资源的类:如果你的类拥有独占的资源(例如通过 new
分配的内存,并且没有使用智能指针),默认的浅拷贝行为会导致多个对象指向同一块内存,当其中一个对象销毁时释放了该内存,其他对象就会出现问题。在这种情况下,你需要自定义深拷贝或者完全禁用拷贝操作。
→ 只允许移动的类型:在某些情况下,你可能希望你的类只能被移动而不能被拷贝,例如管理 std::unique_ptr
的类。
6.2 禁用编译器自动生成函数的方法
在 C++ 中,有几种方法可以显式地禁用编译器自动生成的函数:
6.2.1 C++03 及更早版本:声明为私有且不提供定义
① 在 C++03 及更早的版本中,禁用拷贝构造函数和拷贝赋值运算符的常用方法是将它们的声明放在类的 private
部分,并且不提供定义。
1
class Uncopyable {
2
private:
3
Uncopyable(const Uncopyable&); // 声明为私有,但不提供定义
4
Uncopyable& operator=(const Uncopyable&); // 声明为私有,但不提供定义
5
6
public:
7
Uncopyable() {}
8
// ... 其他成员
9
};
② 工作原理:
②.① 将这些函数声明为 private
意味着类的外部(以及友元函数)无法访问它们。
②.② 如果类的成员函数或友元函数试图调用这些未定义的函数,链接器会报错。
③ 缺点:
③.① 这种方法只适用于拷贝构造函数和拷贝赋值运算符。
③.② 错误信息通常在链接阶段才出现,可能不够直观。
6.2.2 C++11 及更高版本:使用 = delete
① C++11 引入了一个更简洁和明确的方法来禁用编译器自动生成的函数,即使用 = delete
。
1
class Uncopyable {
2
public:
3
Uncopyable() = default; // 显式要求编译器生成默认构造函数
4
Uncopyable(const Uncopyable&) = delete;
5
Uncopyable& operator=(const Uncopyable&) = delete;
6
Uncopyable(Uncopyable&&) = delete; // 可选:禁用移动构造函数
7
Uncopyable& operator=(Uncopyable&&) = delete; // 可选:禁用移动赋值运算符
8
virtual ~Uncopyable() = default; // 显式要求编译器生成默认析构函数
9
10
// ... 其他成员
11
};
② 工作原理:
②.① 在函数的声明后加上 = delete
,明确地告诉编译器不要生成该函数的默认版本,并且任何试图使用该函数的代码都会导致编译错误。
②.② = delete
可以用于任何函数,包括默认构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符以及析构函数。
③ 优点:
③.① 语法简洁明了,意图清晰。
③.② 错误信息在编译阶段就会出现,更容易定位问题。
③.③ 可以用于禁用任何特殊成员函数。
6.2.3 禁用其他编译器自动生成的函数
① 默认构造函数:如果你想强制用户在创建对象时提供参数,可以不声明任何构造函数,或者声明一个或多个带参数的构造函数,但不声明无参数的构造函数(或者显式地 = delete
默认构造函数)。
1
class MyClass {
2
public:
3
MyClass(int value) : value_(value) {}
4
private:
5
int value_;
6
};
7
8
// MyClass obj; // 错误:没有默认构造函数
9
MyClass obj(10); // 正确
② 析构函数:通常情况下,我们希望编译器生成默认析构函数。但如果你希望阻止类的对象被销毁(这通常是不好的设计),你可以将析构函数声明为 private
并且不提供定义(或者使用 = delete
)。但需要注意这样做会带来严重的生命周期管理问题。
③ 移动操作:如果你希望禁用移动构造函数和移动赋值运算符,可以使用 = delete
。这在某些需要严格控制对象生命周期或资源管理的场景下可能有用。
6.3 何时应该禁用编译器自动生成的函数
→ 当你需要控制对象的拷贝行为时:例如,对于管理独占资源的类,应该禁用拷贝操作,或者提供深拷贝的实现。
→ 当你需要控制对象的赋值行为时:类似于拷贝控制。
→ 当你需要控制对象的创建方式时:例如,在单例模式中,需要禁用拷贝构造函数以防止创建多个实例。
→ 当你希望类只能被移动而不能被拷贝时:例如,对于拥有 std::unique_ptr
成员的类。
6.4 总结
显式地禁用你不想使用的编译器自动生成的函数是一个良好的编程习惯。它可以帮助你更好地控制类的行为,避免潜在的错误,并使代码的意图更加清晰。在现代 C++ 中,使用 = delete
是推荐的做法,因为它更简洁、更明确,并且可以用于禁用任何特殊成员函数。在 C++03 及更早版本中,将拷贝构造函数和拷贝赋值运算符声明为私有且不提供定义是常用的禁用方法。
7. Item 7: Declare destructors virtual in polymorphic base classes (为多态基类声明虚析构函数)
7.1 多态基类与析构函数的问题
当你在 C++ 中使用继承和多态时,可能会遇到通过基类指针或引用来操作派生类对象的情况。如果基类拥有派生类需要覆盖的虚函数,那么通过基类指针调用这些虚函数会正确地执行派生类的实现(这就是多态的体现)。然而,对于析构函数,情况有所不同。
考虑以下场景:你有一个基类 Base
和一个派生类 Derived
,Derived
在其析构函数中执行一些清理操作(例如释放派生类特有的资源)。如果你通过一个指向 Derived
对象的 Base
类指针来删除该对象,并且 Base
类的析构函数不是虚函数,那么只会调用 Base
类的析构函数,而不会调用 Derived
类的析构函数。这会导致 Derived
类中分配的资源没有被正确释放,从而可能导致内存泄漏或其他问题。
7.2 虚析构函数的作用
将基类的析构函数声明为 virtual
可以解决这个问题。当通过基类指针删除一个派生类对象时,如果基类的析构函数是虚函数,那么 C++ 运行时系统会根据对象的实际类型(即派生类类型)来调用相应的析构函数链,从派生类的析构函数开始,然后依次调用其基类的析构函数,直到最顶层的基类。这样就确保了派生类对象的所有清理工作都能被正确地执行。
7.3 示例
1
#include <iostream>
2
3
class Base {
4
public:
5
Base() { std::cout << "Base constructor" << std::endl; }
6
virtual ~Base() { std::cout << "Base destructor" << std::endl; } // 声明为虚函数
7
};
8
9
class Derived : public Base {
10
public:
11
Derived() { std::cout << "Derived constructor" << std::endl; }
12
~Derived() override { std::cout << "Derived destructor" << std::endl; }
13
};
14
15
int main() {
16
Base* ptr = new Derived();
17
delete ptr; // 通过基类指针删除派生类对象
18
19
return 0;
20
}
输出 (如果 Base
的析构函数是虚函数):
1
Base constructor
2
Derived constructor
3
Derived destructor
4
Base destructor
输出 (如果 Base
的析构函数不是虚函数):
1
Base constructor
2
Derived constructor
3
Base destructor
可以看到,当 Base
的析构函数是虚函数时,Derived
的析构函数也会被调用,从而保证了资源的正确清理。
7.4 何时应该声明虚析构函数
→ 如果一个类作为基类,并且可能被多态地使用(即你可能会通过基类指针或引用来操作派生类对象),那么它的析构函数应该声明为 virtual
。
→ 如果一个类没有打算作为基类使用,或者不会被多态地使用,那么不需要(也不应该)声明虚析构函数。 声明虚函数会增加一些额外的开销(例如虚函数表),虽然通常很小,但在某些对性能非常敏感的场景下可能需要考虑。
→ 如果一个类拥有任何虚函数,那么它很可能需要一个虚析构函数。 这是因为如果一个类已经有了虚函数,那么它很可能被用作多态基类。
7.5 纯虚析构函数
一个基类可以拥有纯虚函数,也可以拥有纯虚析构函数。纯虚析构函数的声明方式如下:
1
class Base {
2
public:
3
virtual ~Base() = 0; // 纯虚析构函数
4
};
如果一个类声明了纯虚析构函数,那么它就变成了一个抽象类,不能被直接实例化。但是,你仍然需要为纯虚析构函数提供定义(即使是空的),因为派生类的析构函数在执行完毕后会调用其基类的析构函数。
1
Base::~Base() {
2
// 纯虚析构函数的定义
3
}
7.6 总结
为多态基类声明虚析构函数是 C++ 中一个非常重要的设计原则。它确保了当通过基类指针删除派生类对象时,派生类的析构函数能够被正确地调用,从而避免资源泄漏和其他问题。如果你设计的类打算作为其他类的基类,并且你可能会通过基类指针操作派生类对象,那么请务必将基类的析构函数声明为 virtual
。
8. Item 8: Prevent exceptions from leaving destructors (别让异常逃离析构函数)
8.1 析构函数与异常
在 C++ 中,析构函数的主要职责是清理对象所占用的资源。理想情况下,析构函数应该总是成功完成,而不应该抛出异常。如果异常从析构函数中逃逸(即没有被捕获),可能会导致严重的后果。
8.2 异常从析构函数逃逸的后果
① 程序终止 (Program Termination):在 C++98/03 标准中,如果异常从析构函数中逃逸,并且该析构函数是在异常处理过程中被调用的(例如,在栈展开期间),程序会调用 std::terminate
函数来立即终止。
② 资源泄漏 (Resource Leaks):即使在 C++11 及更高版本中,异常从析构函数逃逸的行为有所改变(不再会导致 std::terminate
,除非析构函数被声明为 noexcept(true)
或隐式地为 noexcept(true)
),但仍然可能导致资源泄漏。例如,如果在析构函数中需要释放多个资源,而释放第一个资源时抛出了异常,那么后续资源的释放可能不会被执行。
③ 破坏对象状态 (Corrupted Object State):如果析构函数在执行清理操作的过程中抛出异常,可能会导致对象的状态不一致,从而影响程序的后续行为。
8.3 如何防止异常从析构函数逃逸
防止异常从析构函数逃逸的主要方法是在析构函数内部捕获任何可能抛出的异常。
8.3.1 使用 try-catch
块
在析构函数中使用 try-catch
块来包围可能抛出异常的代码。在 catch
块中,你应该处理该异常,例如记录错误信息,清理已部分释放的资源,或者执行其他适当的操作。关键是不要让异常继续传播到析构函数外部。
1
class MyClass {
2
public:
3
~MyClass() {
4
try {
5
// 可能抛出异常的操作,例如释放资源
6
releaseResource1();
7
releaseResource2();
8
} catch (...) {
9
// 捕获所有异常
10
// 处理异常,例如记录错误日志
11
logError("Exception caught in destructor");
12
// 注意:不要重新抛出异常
13
}
14
}
15
16
private:
17
void releaseResource1() {
18
// ...
19
if (/* error condition */) {
20
throw std::runtime_error("Error releasing resource 1");
21
}
22
// ...
23
}
24
25
void releaseResource2() {
26
// ...
27
if (/* error condition */) {
28
throw std::runtime_error("Error releasing resource 2");
29
}
30
// ...
31
}
32
33
void logError(const std::string& message) {
34
// ... 实现日志记录功能
35
}
36
};
8.3.2 确保析构函数执行的操作不会抛出异常
尽量设计你的类,使得其析构函数需要执行的操作不会抛出异常。例如,如果析构函数需要释放资源,确保释放操作本身是可靠的,或者在资源管理类(例如智能指针)的析构函数中已经处理了可能的异常。
8.3.3 考虑使用 noexcept
说明符 (C++11 及更高版本)
C++11 引入了 noexcept
说明符,用于表明一个函数是否会抛出异常。你可以将析构函数声明为 noexcept(true)
(或简写为 noexcept
)来保证它不会抛出异常。如果一个声明为 noexcept
的函数内部抛出了异常,程序会调用 std::terminate
。
1
class MyClass {
2
public:
3
~MyClass() noexcept {
4
// 假设这里的操作不会抛出异常
5
releaseResource();
6
}
7
8
private:
9
void releaseResource() {
10
// ...
11
}
12
};
如果你的析构函数确实有可能抛出异常,并且你选择不捕获它,那么你不应该将其声明为 noexcept
。
8.4 特殊情况:资源管理类
对于负责管理资源的类(例如封装了文件句柄、网络连接或互斥锁的类),通常在它们的析构函数中执行资源的释放操作。这些操作有时可能会失败并抛出异常。在这种情况下,一种常见的做法是在资源管理类的析构函数中捕获异常并记录错误,但不让异常逃逸。资源管理类的目的是确保资源总是被正确地释放,即使在发生错误的情况下也是如此。
例如,一个简单的文件句柄封装类:
1
#include <fstream>
2
#include <iostream>
3
4
class FileHandle {
5
public:
6
FileHandle(const std::string& filename) : file_(filename) {
7
if (!file_.is_open()) {
8
throw std::runtime_error("Could not open file");
9
}
10
}
11
12
~FileHandle() {
13
try {
14
file_.close();
15
} catch (...) {
16
std::cerr << "Exception caught while closing file" << std::endl;
17
// 记录错误,但不重新抛出
18
}
19
}
20
21
private:
22
std::fstream file_;
23
};
8.5 总结
异常从析构函数中逃逸可能会导致严重的问题,包括程序终止和资源泄漏。为了避免这些问题,你应该遵循以下原则:
→ 尽量设计你的类,使得析构函数的操作不会抛出异常。
→ 如果析构函数中包含可能抛出异常的代码,务必使用 try-catch
块捕获并处理这些异常,不要让它们传播到析构函数外部。
→ 考虑使用 noexcept
说明符来表明析构函数不会抛出异常,但只有在你确信不会抛出异常时才这样做。
→ 对于资源管理类,在析构函数中捕获并处理可能在资源释放过程中发生的异常,以确保资源的最终释放。
9. Item 9: Never call virtual functions during construction or destruction (绝不在构造和析构过程中调用虚函数)
9.1 构造和析构过程中的特殊性
在 C++ 中,对象的构造和析构是一个特殊的过程。当创建一个派生类对象时,会首先调用基类的构造函数,然后是派生类的构造函数。当销毁一个派生类对象时,会首先调用派生类的析构函数,然后是基类的析构函数。这个顺序保证了对象能够被正确地创建和销毁,遵循从最基础的部分开始,到最具体的部分结束的原则。
9.2 构造过程中调用虚函数的问题
当在构造函数中调用虚函数时,不会发生你期望的多态行为。这是因为在基类构造函数执行时,派生类的部分尚未构造完成,对象的类型仍然是基类类型。因此,即使你通过基类的指针或引用调用虚函数,实际执行的也是基类版本的函数,而不是派生类版本的函数。
考虑以下示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
Base() {
6
std::cout << "Base constructor" << std::endl;
7
print(); // 调用虚函数
8
}
9
virtual void print() {
10
std::cout << "Base print" << std::endl;
11
}
12
};
13
14
class Derived : public Base {
15
public:
16
Derived() {
17
std::cout << "Derived constructor" << std::endl;
18
}
19
void print() override {
20
std::cout << "Derived print, value = " << value_ << std::endl;
21
}
22
private:
23
int value_ = 10;
24
};
25
26
int main() {
27
Derived d;
28
return 0;
29
}
输出:
1
Base constructor
2
Base print
3
Derived constructor
可以看到,在 Base
的构造函数中调用 print()
时,执行的是 Base
版本的 print()
,而不是 Derived
版本的,即使我们创建的是一个 Derived
对象。这是因为在 Base
的构造函数执行时,Derived
对象的 value_
成员尚未初始化。
9.3 析构过程中调用虚函数的问题
在析构函数中调用虚函数也存在类似的问题。当派生类的析构函数开始执行时,派生类特有的成员变量和资源已经被销毁。当基类的析构函数被调用时,对象的类型被视为基类类型。因此,如果在基类的析构函数中调用虚函数,只会执行基类版本的函数,而不会执行派生类版本的函数。
考虑以下示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual ~Base() {
6
std::cout << "Base destructor" << std::endl;
7
cleanup(); // 调用虚函数
8
}
9
virtual void cleanup() {
10
std::cout << "Base cleanup" << std::endl;
11
}
12
};
13
14
class Derived : public Base {
15
public:
16
~Derived() override {
17
std::cout << "Derived destructor" << std::endl;
18
// 派生类特有的清理操作
19
}
20
void cleanup() override {
21
std::cout << "Derived cleanup, releasing derived resource" << std::endl;
22
}
23
};
24
25
int main() {
26
Base* ptr = new Derived();
27
delete ptr;
28
return 0;
29
}
输出:
1
Base destructor
2
Base cleanup
3
Derived destructor
可以看到,在 Base
的析构函数中调用 cleanup()
时,执行的是 Base
版本的 cleanup()
,而不是 Derived
版本的。这是因为在 Base
的析构函数执行时,Derived
对象的析构函数已经执行完毕,派生类特有的资源已经被释放。
9.4 解决方法
如果你需要在构造或析构过程中执行一些与类型相关的操作,可以考虑以下方法:
→ 在构造函数中使用非虚函数来调用派生类提供的辅助函数:你可以在基类的构造函数中调用一个非虚的私有或保护成员函数,该函数在派生类中可以被重载(但不是通过 virtual
机制)。
1
class Base {
2
public:
3
Base() {
4
std::cout << "Base constructor" << std::endl;
5
init();
6
}
7
protected:
8
virtual void init() {} // 派生类可以重写这个函数
9
};
10
11
class Derived : public Base {
12
protected:
13
void init() override {
14
std::cout << "Derived init" << std::endl;
15
value_ = 10;
16
}
17
private:
18
int value_;
19
};
→ 使用模板方法模式 (Template Method Pattern):在基类中定义一个非虚的模板方法,该方法包含构造或析构的基本流程,并在适当的时候调用可以被派生类重写的虚函数。
→ 在构造函数中使用初始化列表来初始化成员变量:尽量在构造函数的初始化列表中完成成员变量的初始化,而不是在构造函数体中依赖虚函数的行为。
→ 对于需要在析构时执行的类型相关清理操作,确保在派生类的析构函数中完成:基类的析构函数应该只负责清理基类自己的资源。
9.5 总结
在构造函数和析构函数中调用虚函数不会触发动态绑定,而是会调用当前正在执行的构造函数或析构函数所属类的版本。这是因为在构造过程中,对象尚未完全形成;在析构过程中,对象的派生类部分已经被销毁。因此,为了避免意外的行为和潜在的错误,应该避免在构造和析构过程中直接调用虚函数。如果需要在这些阶段执行类型相关的操作,应该采用其他设计模式和技巧。
10. Item 10: Have assignment operators return a reference to *this
(令 operator=
返回一个指向 *this
的引用)
10.1 赋值运算符的约定
在 C++ 中,赋值运算符(通常指拷贝赋值运算符 operator=
和移动赋值运算符 operator=
)有一个约定俗成的返回类型:对当前对象(*this
)的引用。这个约定使得可以进行连续赋值(assignment chaining),例如 a = b = c;
。
10.2 为什么返回引用
返回引用的主要原因是为了支持连续赋值的语法。当执行 a = b = c;
时,这个表达式会从右向左求值。首先执行 b = c;
,如果这个操作返回的是 b
的一个引用,那么接下来 a = (b 的引用);
就可以顺利执行,将 c
的值赋给 b
,然后将 b
的值赋给 a
。
如果赋值运算符返回的是值而不是引用,那么 b = c;
会创建一个 b
的副本,然后 a
会被赋值为这个副本,而不是原始的 b
。这可能会导致不必要的拷贝操作,并且在某些情况下可能会产生意想不到的行为,尤其是在涉及到对象状态修改时。
10.3 实现拷贝赋值运算符的示例
以下是一个自定义类的拷贝赋值运算符的典型实现,它返回一个指向 *this
的引用:
1
#include <iostream>
2
#include <string>
3
4
class MyString {
5
public:
6
MyString(const std::string& str = "") : data_(new char[str.length() + 1]) {
7
strcpy(data_, str.c_str());
8
length_ = str.length();
9
}
10
11
// 拷贝构造函数(为了完整性)
12
MyString(const MyString& other) : data_(new char[other.length_ + 1]) {
13
strcpy(data_, other.data_);
14
length_ = other.length_;
15
}
16
17
// 析构函数(为了完整性)
18
~MyString() {
19
delete[] data_;
20
}
21
22
// 拷贝赋值运算符
23
MyString& operator=(const MyString& other) {
24
// 1. 处理自赋值情况
25
if (this == &other) {
26
return *this;
27
}
28
29
// 2. 释放当前对象的资源
30
delete[] data_;
31
data_ = nullptr;
32
length_ = 0;
33
34
// 3. 分配新的内存并拷贝数据
35
data_ = new char[other.length_ + 1];
36
strcpy(data_, other.data_);
37
length_ = other.length_;
38
39
// 4. 返回指向 *this 的引用
40
return *this;
41
}
42
43
void print() const {
44
std::cout << data_ << std::endl;
45
}
46
47
private:
48
char* data_;
49
size_t length_;
50
};
51
52
int main() {
53
MyString str1("hello");
54
MyString str2("world");
55
MyString str3;
56
57
str3 = str2 = str1; // 连续赋值
58
59
str1.print(); // 输出: hello
60
str2.print(); // 输出: hello
61
str3.print(); // 输出: hello
62
63
return 0;
64
}
在上面的例子中,operator=
函数返回 MyString&
,即对当前 MyString
对象的引用。
10.4 实现移动赋值运算符的示例 (C++11 及更高版本)
对于支持移动语义的类,移动赋值运算符也应该返回一个指向 *this
的引用:
1
#include <iostream>
2
#include <string>
3
#include <utility> // std::move
4
5
class MyString {
6
public:
7
MyString(const std::string& str = "") : data_(new char[str.length() + 1]) {
8
strcpy(data_, str.c_str());
9
length_ = str.length();
10
}
11
12
// 移动构造函数
13
MyString(MyString&& other) noexcept : data_(other.data_), length_(other.length_) {
14
other.data_ = nullptr;
15
other.length_ = 0;
16
}
17
18
// 移动赋值运算符
19
MyString& operator=(MyString&& other) noexcept {
20
// 1. 处理自赋值情况
21
if (this == &other) {
22
return *this;
23
}
24
25
// 2. 释放当前对象的资源
26
delete[] data_;
27
data_ = nullptr;
28
length_ = 0;
29
30
// 3. 接管其他对象的资源
31
data_ = other.data_;
32
length_ = other.length_;
33
34
// 4. 使其他对象处于有效但未定义的状态
35
other.data_ = nullptr;
36
other.length_ = 0;
37
38
// 5. 返回指向 *this 的引用
39
return *this;
40
}
41
42
// ... (其他成员如拷贝构造函数、拷贝赋值运算符、析构函数等)
43
44
void print() const {
45
std::cout << data_ << std::endl;
46
}
47
48
private:
49
char* data_;
50
size_t length_;
51
};
52
53
int main() {
54
MyString str1("hello");
55
MyString str2("world");
56
MyString str3;
57
58
str3 = std::move(str2) = std::move(str1); // 连续移动赋值
59
60
// 注意:str1 和 str2 在移动后处于有效但未定义的状态,不应再使用
61
str3.print(); // 输出: hello
62
63
return 0;
64
}
10.5 总结
让赋值运算符返回一个指向 *this
的引用是 C++ 中关于赋值操作的一个重要约定。它使得可以进行连续赋值,并且符合用户对于内置类型赋值操作的直觉。当你重载赋值运算符时(无论是拷贝赋值还是移动赋值),务必遵循这个约定,返回对 *this
的引用。这有助于确保你的自定义类能够像内置类型一样自然地使用。
好的,现在我将继续按照您约定的输出格式,对您提供的 Item 11 和 Item 12 进行非常非常详细的解释。
11. Item 11: Handle assignment to self in operator=
(在 operator=
中处理“自我赋值”)
11.1 什么是自我赋值
自我赋值(assignment to self)是指将一个对象赋值给它自身的操作,例如:
1
MyClass obj;
2
obj = obj;
虽然看起来这种操作似乎没有任何意义,但在某些情况下,它可能会发生,例如当通过指针或引用操作对象时,或者在复杂的表达式中。因此,在实现类的赋值运算符(特别是拷贝赋值运算符)时,务必考虑并正确处理自我赋值的情况。
11.2 为什么需要处理自我赋值
如果不显式地处理自我赋值,可能会导致以下问题:
11.2.1 资源释放后又被使用
① 考虑一个管理动态分配内存的类,其拷贝赋值运算符的典型实现可能包括以下步骤:
①.① 释放当前对象已有的内存。
①.② 分配新的内存。
①.③ 将右侧对象的数据拷贝到新分配的内存中。
①.④ 返回指向当前对象的引用。
② 如果发生自我赋值,那么步骤 ① 会释放当前对象所拥有的内存,而步骤 ③ 试图从同一个已经释放的内存中拷贝数据,这会导致未定义行为。
11.2.2 效率问题
③ 即使没有导致未定义行为,不必要的资源释放和重新分配也会降低程序的效率。对于自我赋值,实际上不需要做任何拷贝操作。
11.3 如何处理自我赋值
处理自我赋值最常见且最安全有效的方法是在赋值运算符的开始处添加一个检查,判断左侧操作数(*this
)和右侧操作数是否是同一个对象。
11.3.1 通过比较地址
可以使用比较运算符 ==
来比较两个对象的地址。如果它们的地址相同,则说明是自我赋值,此时可以直接返回 *this
而不做任何其他操作。
1
MyClass& operator=(const MyClass& other) {
2
// 检查是否是自我赋值
3
if (this == &other) {
4
return *this; // 如果是,直接返回当前对象的引用
5
}
6
7
// ... 执行正常的拷贝赋值操作(释放旧资源,分配新资源,拷贝数据)
8
return *this;
9
}
对于移动赋值运算符,也可以采用类似的检查:
1
MyClass& operator=(MyClass&& other) noexcept {
2
if (this == &other) {
3
return *this;
4
}
5
6
// ... 执行正常的移动赋值操作(转移资源所有权)
7
return *this;
8
}
11.3.2 注意点
① 使用引用比较:在比较时,应该比较对象的指针(this
和 &other
),而不是对象本身的值。比较对象的值可能会触发重载的 operator==
,这可能会导致无限递归或者不期望的行为。
② 在资源释放之前检查:自我赋值的检查应该在任何可能修改对象状态的操作(例如释放资源)之前进行。
11.4 示例:处理自我赋值的拷贝赋值运算符
以下是一个完整的拷贝赋值运算符的示例,其中包含了对自我赋值的处理:
1
#include <iostream>
2
#include <string>
3
#include <cstring>
4
5
class MyString {
6
public:
7
MyString(const std::string& str = "") : data_(new char[str.length() + 1]) {
8
strcpy(data_, str.c_str());
9
length_ = str.length();
10
}
11
12
MyString(const MyString& other) : data_(new char[other.length_ + 1]) {
13
strcpy(data_, other.data_);
14
length_ = other.length_;
15
}
16
17
~MyString() {
18
delete[] data_;
19
}
20
21
MyString& operator=(const MyString& other) {
22
// 处理自我赋值
23
if (this == &other) {
24
std::cout << "Self-assignment detected, no operation needed." << std::endl;
25
return *this;
26
}
27
28
// 释放当前对象的资源
29
delete[] data_;
30
data_ = nullptr;
31
length_ = 0;
32
33
// 分配新的内存并拷贝数据
34
data_ = new char[other.length_ + 1];
35
strcpy(data_, other.data_);
36
length_ = other.length_;
37
38
return *this;
39
}
40
41
void print() const {
42
std::cout << data_ << std::endl;
43
}
44
45
private:
46
char* data_;
47
size_t length_;
48
};
49
50
int main() {
51
MyString str("example");
52
str = str; // 自我赋值
53
54
MyString str1("hello");
55
MyString str2("world");
56
str2 = str1; // 正常赋值
57
str2 = str2; // 再次自我赋值
58
59
return 0;
60
}
可能的输出:
1
Self-assignment detected, no operation needed.
2
Self-assignment detected, no operation needed.
11.5 异常安全与自我赋值
在实现赋值运算符时,还需要考虑异常安全性。一个好的赋值运算符应该能够保证即使在拷贝过程中发生异常,对象也应该处于一个有效的状态。处理自我赋值是实现强异常安全保证的一个重要方面。如果在拷贝过程中抛出异常,而我们没有首先检查自我赋值,可能会导致对象的状态被破坏。
一种实现异常安全拷贝赋值运算符的常见技术是“拷贝并交换 (copy and swap)”。这种技术通常可以自动处理自我赋值,并且提供良好的异常安全性。
1
#include <iostream>
2
#include <string>
3
#include <cstring>
4
#include <algorithm> // std::swap
5
6
class MyString {
7
public:
8
MyString(const std::string& str = "") : data_(new char[str.length() + 1]) {
9
strcpy(data_, str.c_str());
10
length_ = str.length();
11
}
12
13
MyString(const MyString& other) : data_(new char[other.length_ + 1]) {
14
strcpy(data_, other.data_);
15
length_ = other.length_;
16
}
17
18
~MyString() {
19
delete[] data_;
20
}
21
22
MyString& operator=(const MyString& other) {
23
// 1. 创建 other 的一个副本
24
MyString temp(other);
25
// 2. 交换当前对象和副本的资源
26
std::swap(data_, temp.data_);
27
std::swap(length_, temp.length_);
28
// 3. 当 temp 离开作用域时,会销毁原始的资源(如果存在)
29
return *this;
30
}
31
32
void print() const {
33
std::cout << data_ << std::endl;
34
}
35
36
private:
37
char* data_;
38
size_t length_;
39
};
在这个“拷贝并交换”的实现中,首先创建了 other
的一个副本 temp
。这个拷贝操作会分配新的内存并拷贝数据,如果这个过程抛出异常,当前对象的状态不会被改变。然后,使用 std::swap
函数交换了当前对象和 temp
的内部资源。当 temp
在函数结束时被销毁时,它会释放当前对象之前拥有的资源。这种方法优雅地处理了自我赋值,因为如果 this
和 &other
是同一个对象,拷贝构造函数仍然会创建一个副本,然后交换操作会使对象恢复到原始状态。
11.6 总结
在实现拷贝赋值运算符时,务必检查并处理自我赋值的情况,以避免资源被错误地释放和潜在的未定义行为。最简单的方法是在运算符的开始处比较对象的地址,如果相同则直接返回。此外,考虑使用“拷贝并交换”等技术来实现既能正确处理自我赋值又能保证异常安全的赋值运算符。
12. Item 12: Copy all parts of an object (复制对象时勿忘其每一个成分)
12.1 对象拷贝的重要性
对象的拷贝发生在多种场景,包括使用拷贝构造函数创建新对象、通过拷贝赋值运算符将一个对象的值赋给另一个对象,以及按值传递或返回对象等。在这些情况下,确保对象的所有组成部分都被正确地复制至关重要。
12.2 需要复制的“每一个成分”
一个对象的“每一个成分”可能包括:
→ 直接拥有的数据成员 (Directly Owned Data Members):这是最直接的部分,包括对象中声明的各种类型的成员变量。
→ 基类部分 (Base Class Parts):如果一个类继承自其他类,那么在拷贝该类的对象时,也必须正确地拷贝其基类的部分。
→ 间接拥有的资源 (Indirectly Owned Resources):例如,如果对象通过指针持有对动态分配内存或其他资源的引用,那么拷贝可能需要创建这些资源的独立副本(深拷贝)。
12.3 默认拷贝行为的问题
编译器自动生成的拷贝构造函数和拷贝赋值运算符默认执行的是浅拷贝 (Shallow Copy),即它们只是简单地复制对象中每个非静态成员变量的值。对于内置类型和直接包含的对象成员,这通常是足够的。但是,对于指针成员,浅拷贝只会复制指针的值(即内存地址),而不会复制指针所指向的内容。这会导致多个对象共享同一个动态分配的资源,从而可能引发问题,例如:
→ 悬挂指针 (Dangling Pointers):当一个对象销毁并释放了其拥有的资源后,其他仍然指向该资源的对象的指针就会变成悬挂指针,访问这些指针会导致未定义行为。
→ 双重释放 (Double Free):如果多个对象共享同一个动态分配的资源,并且它们都在销毁时尝试释放该资源,就会导致双重释放错误,通常会导致程序崩溃。
12.4 如何确保复制所有成分
为了确保对象的所有部分都被正确地复制,你需要根据类的具体情况来实现拷贝构造函数和拷贝赋值运算符。
12.4.1 拷贝构造函数
① 如果你的类需要深拷贝或者有特殊的拷贝逻辑,你需要自定义拷贝构造函数。
② 在自定义的拷贝构造函数中,你需要:
②.① 显式地调用基类的拷贝构造函数,以确保基类部分被正确地拷贝。
②.② 复制所有直接拥有的数据成员。
②.③ 对于任何间接拥有的资源(例如通过指针管理的动态内存),需要分配新的内存,并将原始资源的内容拷贝到新的内存中。
1
class Base {
2
public:
3
Base(int b) : base_value_(b) {}
4
Base(const Base& other) : base_value_(other.base_value_) {
5
std::cout << "Base copy constructor called" << std::endl;
6
}
7
private:
8
int base_value_;
9
};
10
11
class Derived : public Base {
12
public:
13
Derived(int b, const std::string& s) : Base(b), str_(new char[s.length() + 1]) {
14
strcpy(str_, s.c_str());
15
length_ = s.length();
16
}
17
18
Derived(const Derived& other) : Base(other), // 调用基类的拷贝构造函数
19
str_(new char[other.length_ + 1]),
20
length_(other.length_) {
21
strcpy(str_, other.str_);
22
std::cout << "Derived copy constructor called" << std::endl;
23
}
24
25
~Derived() {
26
delete[] str_;
27
}
28
29
private:
30
char* str_;
31
size_t length_;
32
};
在上面的 Derived
类的拷贝构造函数中,首先通过 Base(other)
调用了基类 Base
的拷贝构造函数,然后分配了新的内存来存储字符串,并将 other
对象的字符串内容拷贝到新的内存中。
12.4.2 拷贝赋值运算符
① 类似于拷贝构造函数,如果需要深拷贝或特殊的赋值逻辑,你需要自定义拷贝赋值运算符。
② 在自定义的拷贝赋值运算符中,你需要:
②.① 处理自我赋值的情况(如 Item 11 所述)。
②.② 释放当前对象已有的资源(如果有的话)。
②.③ 显式地调用基类的拷贝赋值运算符,以确保基类部分被正确地赋值。
②.④ 分配新的内存(如果需要),并将右侧对象的数据拷贝到当前对象中。
②.⑤ 返回指向当前对象的引用 (return *this;
)。
1
class Base {
2
public:
3
Base& operator=(const Base& other) {
4
if (this != &other) {
5
base_value_ = other.base_value_;
6
std::cout << "Base copy assignment operator called" << std::endl;
7
}
8
return *this;
9
}
10
private:
11
int base_value_;
12
};
13
14
class Derived : public Base {
15
public:
16
Derived& operator=(const Derived& other) {
17
if (this == &other) {
18
return *this;
19
}
20
21
Base::operator=(other); // 调用基类的拷贝赋值运算符
22
23
delete[] str_;
24
str_ = new char[other.length_ + 1];
25
strcpy(str_, other.str_);
26
length_ = other.length_;
27
28
std::cout << "Derived copy assignment operator called" << std::endl;
29
return *this;
30
}
31
32
// ... (其他成员如构造函数、析构函数等)
33
34
private:
35
char* str_;
36
size_t length_;
37
};
在上面的 Derived
类的拷贝赋值运算符中,首先调用了基类 Base
的拷贝赋值运算符,然后释放了当前对象可能拥有的字符串内存,分配了新的内存,并将 other
对象的字符串内容拷贝到新的内存中。
12.4.3 使用成员初始化列表
在拷贝构造函数中,应该使用成员初始化列表来调用基类的拷贝构造函数和初始化成员变量,这通常比在构造函数体内部赋值更有效。
12.4.4 避免代码重复
在拷贝构造函数和拷贝赋值运算符中,经常会有相似的代码用于拷贝数据。可以考虑将这部分逻辑提取到私有的辅助函数中,以避免代码重复。
12.4.5 考虑使用智能指针
对于管理动态分配的内存,使用智能指针(如 std::unique_ptr
和 std::shared_ptr
)可以大大简化拷贝控制的实现。智能指针在拷贝或移动时会自动处理所指向的内存,从而减少了手动管理内存的需求,并降低了出错的风险。如果你的类使用 std::unique_ptr
作为成员,那么默认的拷贝操作会被禁用,你需要显式地定义移动操作。如果使用 std::shared_ptr
,则默认的拷贝操作会增加引用计数,从而实现共享所有权。
12.5 总结
在拷贝对象时,务必确保对象的所有组成部分都被正确地复制,包括直接拥有的数据成员、基类部分以及间接拥有的资源。对于需要深拷贝的类,你需要自定义拷贝构造函数和拷贝赋值运算符,并在其中显式地处理资源的复制。同时,不要忘记在拷贝构造函数中使用成员初始化列表来调用基类的拷贝构造函数,并在拷贝赋值运算符中显式地调用基类的拷贝赋值运算符。考虑使用智能指针来简化资源管理和拷贝控制。忽略对象的任何成分都可能导致程序出现逻辑错误、内存泄漏或其他难以调试的问题。
好的,现在我将继续按照您约定的输出格式,对您提供的 Item 13 到 Item 17 进行非常非常详细的解释。
13. Item 13: Use objects to manage resources (以对象管理资源)
13.1 资源管理的重要性
在编程中,我们经常需要使用各种资源,例如内存、文件句柄、网络连接、互斥锁等。正确地管理这些资源至关重要,否则可能导致资源泄漏、死锁等问题,最终影响程序的稳定性甚至安全性。
13.2 传统资源管理方式的弊端
在没有自动内存管理的语言(如 C++)中,程序员需要手动分配和释放资源。这种方式容易出错,常见的问题包括:
→ 忘记释放资源 (Memory Leaks, Resource Leaks):如果在不再需要资源时忘记显式地释放它,会导致资源被永久占用,最终耗尽系统资源。
→ 过早释放资源 (Premature Release):如果在资源还在被使用时就释放了它,会导致程序访问无效的资源,产生未定义行为。
→ 重复释放资源 (Double Free):如果一个资源被释放了多次,可能会导致程序崩溃或产生安全漏洞。
→ 异常安全问题 (Exception Safety):如果在资源分配后和释放前发生了异常,可能会导致释放资源的代码没有被执行,从而造成资源泄漏。
13.3 RAII (Resource Acquisition Is Initialization) 原则
C++ 采用了一种称为 资源获取即初始化 (Resource Acquisition Is Initialization, RAII) 的编程范式来解决资源管理的问题。RAII 的核心思想是:
将资源的生命周期与对象的生命周期绑定在一起。
具体来说,当对象被创建时(即在初始化阶段),它会获取所需的资源。当对象的生命周期结束时(即对象被销毁时),会自动释放其所拥有的资源。
13.4 实现 RAII 的关键:析构函数
在 C++ 中,实现 RAII 的关键在于使用类的析构函数。析构函数在对象的生命周期结束时会被自动调用。因此,资源管理类通常会在其构造函数中获取资源,并在其析构函数中释放资源。
13.5 RAII 的优势
→ 自动资源管理 (Automatic Resource Management):一旦资源被封装在 RAII 对象中,资源的释放就会由对象的析构函数自动完成,无需程序员显式地调用释放函数。这大大降低了忘记释放资源的风险。
→ 异常安全 (Exception Safety):即使在获取资源后和释放资源前发生了异常,导致程序提前退出作用域,RAII 对象的析构函数仍然会被调用(通过栈展开机制),从而保证资源得到释放。
→ 代码简洁且易于维护 (Concise and Maintainable Code):RAII 可以将资源管理的逻辑集中在类的构造函数和析构函数中,使得代码更加清晰和易于维护。
13.6 常见的 RAII 示例
13.6.1 智能指针 (Smart Pointers)
① C++ 标准库提供了多种智能指针,例如 std::unique_ptr
和 std::shared_ptr
,它们是 RAII 的典型应用。
② 智能指针在构造时获取对动态分配内存的所有权(通过 new
),并在析构时自动释放这些内存(通过 delete
或 delete[]
)。
1
#include <memory>
2
3
void example() {
4
// 使用 std::unique_ptr 管理动态分配的 int 数组
5
std::unique_ptr<int[]> ptr(new int[10]);
6
// ... 使用 ptr
7
// 当 ptr 超出作用域时,会自动释放 int 数组的内存,无需手动 delete[]
8
}
9
10
void anotherExample() {
11
// 使用 std::shared_ptr 管理动态分配的 int
12
std::shared_ptr<int> ptr1(new int(42));
13
std::shared_ptr<int> ptr2 = ptr1; // 多个 shared_ptr 可以共享同一个对象的所有权
14
// ... 使用 ptr1 和 ptr2
15
// 只有当最后一个指向该 int 的 shared_ptr 超出作用域时,内存才会被释放
16
}
13.6.2 文件流 (File Streams)
① std::fstream
等文件流类也遵循 RAII 原则。
② 当文件流对象被创建时,它会尝试打开指定的文件。当文件流对象超出作用域时,其析构函数会自动关闭文件。
1
#include <fstream>
2
#include <iostream>
3
4
void fileOperation() {
5
std::ofstream outfile("example.txt");
6
if (outfile.is_open()) {
7
outfile << "This is a line of text.\n";
8
// ... 写入更多内容
9
// 当 outfile 超出作用域时,文件会自动关闭
10
} else {
11
std::cerr << "Unable to open file." << std::endl;
12
}
13
}
13.6.3 互斥锁 (Mutex Locks)
① std::lock_guard
和 std::unique_lock
等锁管理类也实现了 RAII。
② 当锁管理对象被创建时,它会尝试获取互斥锁。当对象超出作用域时,锁会自动被释放。这可以防止忘记解锁导致的死锁问题。
1
#include <mutex>
2
#include <iostream>
3
4
std::mutex myMutex;
5
6
void criticalSection() {
7
std::lock_guard<std::mutex> lock(myMutex); // 获取锁
8
// ... 在临界区执行操作,myMutex 在 lock 超出作用域时自动释放
9
std::cout << "Inside critical section." << std::endl;
10
}
13.7 如何设计资源管理类
如果你需要管理自定义的资源,你可以创建一个类来封装该资源,并在其构造函数中获取资源,在析构函数中释放资源。通常还需要考虑拷贝控制(拷贝构造函数和拷贝赋值运算符),这将在 Item 14 中详细讨论。
13.8 总结
使用对象来管理资源(即遵循 RAII 原则)是编写健壮、安全且易于维护的 C++ 代码的关键。通过将资源的生命周期与对象的生命周期绑定,可以自动地管理资源的分配和释放,避免了许多常见的资源管理错误,并提高了代码的异常安全性。在现代 C++ 中,应该尽可能地使用 RAII 来管理各种类型的资源。
14. Item 14: Think carefully about copying behavior in resource-managing classes (在资源管理类中小心 copying 行为)
14.1 资源管理类与拷贝
当你的类负责管理资源(例如通过 RAII 原则实现)时,你需要仔细考虑当该类的对象被拷贝时会发生什么。默认的浅拷贝行为可能不适合资源管理类,因为它可能导致多个对象管理同一个资源,从而引发问题(如 Item 12 中所述)。
14.2 处理拷贝行为的策略
对于资源管理类,有几种常见的策略来处理拷贝行为:
14.2.1 禁用拷贝 (Disallow Copying)
① 这是最简单且通常最安全的选择,特别是当资源是独占拥有且不能被共享时。
② 可以通过将拷贝构造函数和拷贝赋值运算符声明为私有且不提供定义(在 C++03 中),或者使用 = delete
(在 C++11 及更高版本中)来禁用拷贝。
1
class ExclusiveResource {
2
public:
3
ExclusiveResource() : resource_(acquireResource()) {}
4
~ExclusiveResource() { releaseResource(resource_); }
5
6
// 禁用拷贝构造函数和拷贝赋值运算符 (C++11 及更高版本)
7
ExclusiveResource(const ExclusiveResource&) = delete;
8
ExclusiveResource& operator=(const ExclusiveResource&) = delete;
9
10
private:
11
ResourceType* resource_;
12
ResourceType* acquireResource();
13
void releaseResource(ResourceType*);
14
};
③ 这种策略适用于例如管理互斥锁、独占文件句柄或 std::unique_ptr
的类。
14.2.2 执行深拷贝 (Perform Deep Copy)
① 如果资源可以被安全地复制,并且你希望拷贝后的对象拥有独立的资源副本,那么你需要实现深拷贝。
② 这意味着在拷贝构造函数和拷贝赋值运算符中,你需要分配新的资源,并将原始资源的内容完整地复制到新的资源中。
③ Item 12 中的 MyString
类的示例就是一个深拷贝的例子。
④ 深拷贝适用于例如需要独立副本的数据缓冲区或字符串等情况。
⑤ 需要注意的是,深拷贝可能会带来性能开销,特别是当资源很大时。
14.2.3 使用引用计数 (Use Reference Counting)
① 如果多个对象可以共享同一个资源,并且只有当最后一个引用该资源的对象被销毁时才需要释放资源,那么可以使用引用计数。
② 这通常通过一个额外的计数器来实现,该计数器记录了有多少个对象共享该资源。拷贝操作会增加计数器,而析构函数会减少计数器。当计数器变为零时,才真正释放资源。
③ std::shared_ptr
就是使用引用计数来实现共享所有权的智能指针。
④ 你也可以自己实现一个使用引用计数的资源管理类,但这需要小心处理线程安全问题(如果多个线程可能同时访问和修改引用计数)。
14.2.4 转移所有权 (Transfer Ownership)
① 对于某些资源,拷贝操作可能意味着将资源的所有权从一个对象转移到另一个对象,而原始对象不再拥有该资源。
② 这通常通过移动语义(移动构造函数和移动赋值运算符)来实现。
③ std::unique_ptr
就是一个只允许移动、不允许拷贝的智能指针,它通过移动操作来转移对动态分配内存的所有权。
1
#include <memory>
2
#include <iostream>
3
4
void example() {
5
std::unique_ptr<int> ptr1(new int(42));
6
std::unique_ptr<int> ptr2 = std::move(ptr1); // 所有权从 ptr1 转移到 ptr2
7
if (ptr1 == nullptr) {
8
std::cout << "ptr1 is now null." << std::endl;
9
}
10
if (ptr2 != nullptr) {
11
std::cout << "ptr2 now owns the integer with value: " << *ptr2 << std::endl;
12
}
13
}
14.2.5 其他策略
① 有时,拷贝行为可能需要根据具体的应用场景来定制。例如,对于某些资源,拷贝可能意味着创建一个指向原始资源的共享句柄,而不是复制资源本身。
14.3 如何选择合适的策略
选择哪种策略取决于资源的性质以及你希望如何管理它的生命周期和共享方式:
→ 独占且不可共享的资源:通常应该禁用拷贝。
→ 需要独立副本的资源:应该实现深拷贝。
→ 可以安全共享的资源:可以考虑使用引用计数。
→ 所有权需要转移的资源:应该使用移动语义。
14.4 总结
在设计管理资源的类时,务必仔细考虑对象的拷贝行为。默认的浅拷贝通常是不够的,可能会导致严重的问题。你需要根据资源的特性和你的设计目标选择合适的策略:禁用拷贝、执行深拷贝、使用引用计数或转移所有权(使用移动语义)。明确地定义或禁用拷贝行为是编写安全可靠的资源管理类的关键步骤。
15. Item 15: Provide access to raw resources in resource-managing classes (在资源管理类中提供对原始资源的访问)
15.1 为什么需要访问原始资源
尽管资源管理类通过 RAII 原则自动管理资源的生命周期,但在某些情况下,你可能需要直接访问被管理的原始资源。这可能是因为:
→ 需要与接受原始资源的旧有 API 或库进行交互:某些 C API 或遗留的 C++ 代码可能期望接收指向原始资源的指针或句柄。
→ 需要执行某些高级操作,而资源管理类没有提供相应的接口:资源管理类可能只封装了最常用的操作,对于一些更底层的或特定的操作,可能需要直接访问原始资源。
→ 在某些特定的算法或逻辑中,直接操作原始资源可能更高效。
15.2 提供访问原始资源的方法
资源管理类通常会提供一些方法来允许用户安全地访问其管理的原始资源。常见的方法包括:
15.2.1 get()
方法
① 许多资源管理类(例如智能指针 std::unique_ptr
和 std::shared_ptr
)都提供一个 get()
方法,该方法返回指向原始资源的指针。
② 使用 get()
方法可以临时获取原始指针,以便与需要原始指针的 API 交互。
1
#include <memory>
2
#include <iostream>
3
4
void processRawPointer(int* ptr) {
5
if (ptr) {
6
std::cout << "Processing value: " << *ptr << std::endl;
7
} else {
8
std::cout << "Pointer is null." << std::endl;
9
}
10
}
11
12
void example() {
13
std::unique_ptr<int> up(new int(10));
14
processRawPointer(up.get()); // 使用 get() 获取原始指针
15
16
std::shared_ptr<int> sp(new int(20));
17
processRawPointer(sp.get()); // 同样可以使用 get()
18
}
③ 需要注意的是,通过 get()
方法获取的原始指针的生命周期仍然由智能指针管理。你不应该对这个原始指针执行 delete
操作,否则会导致双重释放。
15.2.2 重载解引用运算符 (operator*
和 operator->
)
① 智能指针还重载了解引用运算符 operator*
和成员访问运算符 operator->
,使得你可以像使用原始指针一样使用智能指针来访问所指向的对象。
1
#include <memory>
2
#include <iostream>
3
4
struct MyStruct {
5
int value;
6
void print() { std::cout << "Value: " << value << std::endl; }
7
};
8
9
void example() {
10
std::unique_ptr<MyStruct> up(new MyStruct{42});
11
std::cout << up->value << std::endl; // 使用 operator-> 访问成员
12
up->print();
13
14
std::shared_ptr<MyStruct> sp(new MyStruct{99});
15
(*sp).value = 100; // 使用 operator* 访问对象
16
sp->print();
17
}
② 这些运算符提供了更方便的访问方式,同时也强调了智能指针仍然是对资源的封装。
15.2.3 显式转换方法 (例如 .release()
)
① 某些资源管理类可能提供方法来显式地释放对原始资源的所有权,并返回该原始资源。例如,std::unique_ptr
的 release()
方法会释放对所管理对象的控制权,并返回原始指针。调用 release()
后,智能指针会变为空,并且不再负责释放资源。
1
#include <memory>
2
#include <iostream>
3
4
void example() {
5
std::unique_ptr<int> up(new int(55));
6
int* rawPtr = up.release(); // 释放所有权,up 变为空
7
if (up == nullptr) {
8
std::cout << "up is now null." << std::endl;
9
}
10
if (rawPtr != nullptr) {
11
std::cout << "Raw pointer value: " << *rawPtr << std::endl;
12
delete rawPtr; // 现在需要手动释放 rawPtr
13
}
14
}
② 使用 release()
方法需要非常小心,因为一旦释放了所有权,资源的释放责任就转移到了调用者身上。如果忘记释放,就会导致内存泄漏。
15.2.4 对于自定义的资源管理类
① 如果你正在创建自己的资源管理类,你应该考虑提供一个 get()
方法来返回原始资源。
② 你也可以根据需要重载解引用运算符,或者提供其他适合你所管理资源的访问方法。
15.3 注意事项
→ 封装性 (Encapsulation):虽然提供对原始资源的访问是必要的,但也应该注意保持封装性。不应该让用户能够随意地修改资源管理类的内部状态,或者直接管理资源,从而破坏 RAII 的保证。
→ 生命周期管理责任 (Responsibility for Lifetime Management):当通过 get()
方法获取原始指针或句柄时,用户必须清楚资源的所有权仍然归资源管理对象所有,不应该对原始资源进行释放操作。如果使用 release()
等方法转移了所有权,那么资源的释放责任就转移到了用户身上。
→ const
正确性 (Const Correctness):如果资源管理对象是 const
的,那么通过其提供的访问方法获取的原始资源也应该是 const
的(如果适用)。
15.4 总结
虽然 RAII 的核心思想是让对象自动管理资源的生命周期,但在实际应用中,有时需要提供对原始资源的访问。资源管理类通常通过 get()
方法、重载解引用运算符或显式的释放所有权方法来实现这一点。在提供这些访问方法时,需要仔细考虑封装性、生命周期管理责任以及 const
正确性,以确保用户能够安全地使用原始资源,而不会破坏 RAII 的保证。
16. Item 16: Use the same form in corresponding uses of new
and delete
(成对使用 new
和 delete
时要采取相同形式)
16.1 new
和 delete
的两种形式
在 C++ 中,new
运算符用于动态分配内存,它有两种主要形式:
→ 标量形式 (Scalar Form):用于分配单个对象。语法是 new Type
或 new Type(arguments)
。对应的释放内存的运算符是 delete
。
→ 数组形式 (Array Form):用于分配一个对象数组。语法是 new Type[size]
。对应的释放内存的运算符是 delete[]
。
16.2 匹配 new
和 delete
的重要性
必须使用与分配内存时所用 new
形式相匹配的 delete
形式来释放内存。如果 new
和 delete
的形式不匹配,会导致严重的未定义行为。
16.2.1 使用 delete
释放通过 new[]
分配的内存
① 当你使用 new Type[size]
分配了一个对象数组后,必须使用 delete[] pointer
来释放这块内存。
② 如果你错误地使用 delete pointer
来释放通过 new[]
分配的内存,其行为是未定义的。通常情况下,这会导致:
②.① 只调用了数组中第一个元素的析构函数(如果该类型有自定义析构函数)。数组中其余元素的析构函数不会被调用,这可能导致资源泄漏。
②.② 只释放了数组中第一个元素所占用的内存。由于内存管理器可能记录了分配的内存块的大小(通常会为数组分配额外的空间来存储数组的大小),使用 delete
可能会导致内存管理器内部数据结构的损坏,最终可能导致程序崩溃。
16.2.2 使用 delete[]
释放通过 new
分配的单个对象的内存
① 当你使用 new Type
分配了一个单个对象后,必须使用 delete pointer
来释放这块内存。
② 如果你错误地使用 delete[] pointer
来释放通过 new
分配的单个对象的内存,其行为也是未定义的。这通常会导致程序崩溃,因为 delete[]
会期望找到数组的大小信息,而这对于单个对象的分配是不存在的。
16.3 示例
1
#include <iostream>
2
3
class MyClass {
4
public:
5
MyClass() { std::cout << "MyClass constructor" << std::endl; }
6
~MyClass() { std::cout << "MyClass destructor" << std::endl; }
7
};
8
9
void scalarNewDelete() {
10
MyClass* objPtr = new MyClass(); // 使用标量 new
11
delete objPtr; // 使用标量 delete,正确
12
}
13
14
void arrayNewDelete() {
15
MyClass* objArrayPtr = new MyClass[3]; // 使用数组 new
16
delete[] objArrayPtr; // 使用数组 delete[],正确
17
}
18
19
void incorrectDeleteScalar() {
20
MyClass* objPtr = new MyClass(); // 使用标量 new
21
// delete[] objPtr; // 错误:使用了数组 delete[]
22
}
23
24
void incorrectDeleteArray() {
25
MyClass* objArrayPtr = new MyClass[3]; // 使用数组 new
26
// delete objArrayPtr; // 错误:使用了标量 delete
27
}
28
29
int main() {
30
std::cout << "Scalar new and delete:" << std::endl;
31
scalarNewDelete();
32
std::cout << "\nArray new and delete[]:" << std::endl;
33
arrayNewDelete();
34
std::cout << "\nIncorrect delete[] for scalar new (undefined behavior):" << std::endl;
35
// incorrectDeleteScalar(); // 取消注释可能会导致程序崩溃或未定义行为
36
std::cout << "\nIncorrect delete for array new[] (undefined behavior):" << std::endl;
37
// incorrectDeleteArray(); // 取消注释可能会导致程序崩溃或资源泄漏
38
return 0;
39
}
在上面的示例中,scalarNewDelete
和 arrayNewDelete
展示了正确匹配 new
和 delete
的形式。incorrectDeleteScalar
和 incorrectDeleteArray
展示了不匹配的形式,取消注释这些调用可能会导致程序出现问题。
16.4 如何避免 new
和 delete
的不匹配
→ 始终记住分配内存时使用的形式:如果你使用了 new Type
,就必须使用 delete pointer
来释放;如果你使用了 new Type[size]
,就必须使用 delete[] pointer
来释放。
→ 尽量使用智能指针:智能指针(如 std::unique_ptr
和 std::shared_ptr
)在创建时就与分配内存的形式绑定在一起(例如,std::unique_ptr<int[]>
用于数组),并在析构时自动使用正确的 delete
形式来释放内存,从而避免了手动管理 new
和 delete
可能导致的不匹配问题。
1
#include <memory>
2
3
void usingUniquePtrForScalar() {
4
std::unique_ptr<int> ptr(new int(42)); // 使用标量 new
5
// ptr 在超出作用域时会自动使用 delete 释放内存
6
}
7
8
void usingUniquePtrForArray() {
9
std::unique_ptr<int[]> ptr(new int[10]); // 使用数组 new
10
// ptr 在超出作用域时会自动使用 delete[] 释放内存
11
}
→ 避免直接使用原始指针进行内存管理:在现代 C++ 中,应该尽可能地使用智能指针来管理动态分配的内存,以提高代码的安全性和可维护性。只有在非常特殊的情况下,才应该直接使用 new
和 delete
,并且在这种情况下要格外小心地确保它们的匹配使用。
16.5 总结
正确地匹配 new
和 delete
的形式是 C++ 内存管理的基本要求。使用 delete
释放通过 new[]
分配的内存,或者使用 delete[]
释放通过 new
分配的内存,都会导致未定义行为,可能包括程序崩溃、资源泄漏或数据损坏。为了避免这些问题,应该始终记住分配内存时使用的形式,并使用相应的 delete
形式来释放。更推荐的做法是使用智能指针来自动管理动态分配的内存,从而避免手动管理 new
和 delete
带来的风险。
17. Item 17: Store newed
objects in smart pointers in standalone statements (以独立语句将 newed
对象置入智能指针)
17.1 潜在的异常安全问题
在 C++ 中,当使用 new
运算符动态分配内存时,如果后续的操作(例如智能指针的构造)抛出异常,那么刚刚分配的内存可能会因为没有被智能指针接管而发生泄漏。
考虑以下看似无害的代码:
1
#include <memory>
2
3
void processWidget(std::shared_ptr<Widget> pw, int priority) {
4
// ...
5
}
6
7
int computePriority() {
8
// ... 计算优先级
9
return 5;
10
}
11
12
class Widget {};
13
14
int main() {
15
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
16
return 0;
17
}
在这个例子中,我们试图创建一个 Widget
对象,并将其放入一个 std::shared_ptr
中,然后将这个智能指针和一个计算得到的优先级传递给 processWidget
函数。
然而,C++ 标准并没有规定函数参数的求值顺序。编译器可能会按照以下顺序执行:
- 调用
new Widget()
分配Widget
对象的内存。 - 调用
computePriority()
计算优先级。 - 调用
std::shared_ptr<Widget>
的构造函数,将new
返回的指针传递给它。
如果在步骤 2(调用 computePriority()
)时抛出了异常,那么在步骤 1 中分配的 Widget
对象的内存将永远不会被 std::shared_ptr
管理,从而导致内存泄漏。
17.2 使用独立语句的解决方案
为了避免这个问题,最佳实践是将 new
操作和智能指针的创建放在独立的语句中:
1
#include <memory>
2
3
void processWidget(std::shared_ptr<Widget> pw, int priority) {
4
// ...
5
}
6
7
int computePriority() {
8
// ... 计算优先级
9
return 5;
10
}
11
12
class Widget {};
13
14
int main() {
15
std::shared_ptr<Widget> pw(new Widget); // 独立语句创建智能指针
16
processWidget(pw, computePriority());
17
return 0;
18
}
现在,如果 new Widget
成功执行,分配了内存,那么这块内存会立即被 std::shared_ptr
pw
接管。即使后续的 computePriority()
抛出异常,pw
的析构函数也会在栈展开时被调用,从而释放之前分配的 Widget
对象的内存,避免了内存泄漏。
17.3 适用于所有智能指针
这个原则不仅适用于 std::shared_ptr
,也适用于其他智能指针,例如 std::unique_ptr
:
1
#include <memory>
2
3
std::unique_ptr<int> getResult() {
4
// return std::unique_ptr<int>(new int(42)); // 不推荐
5
std::unique_ptr<int> result(new int(42)); // 推荐
6
return result;
7
}
即使在函数返回值的情况下,先在一个独立的语句中创建智能指针,再返回它,也是一种更安全的做法,尽管在这种简单的情况下风险较低。
17.4 避免隐式转换
有时候,可能会通过隐式转换将原始指针传递给接受智能指针的函数,但这通常不是一个好主意,因为它可能隐藏了所有权转移或共享的语义。显式地创建智能指针可以使代码的意图更加清晰。
17.5 总结
为了确保在使用 new
动态分配的对象能够被智能指针正确地管理,并避免潜在的内存泄漏问题,应该总是使用独立的语句来创建智能指针,并将 new
返回的指针立即传递给智能指针的构造函数。这可以保证即使在后续操作抛出异常的情况下,分配的内存也能被正确地释放,从而提高程序的异常安全性。
好的,现在我将继续按照您约定的输出格式,对您提供的 Item 18 到 Item 25 进行非常非常详细的解释。
18. Item 18: Make interfaces easy to use correctly and hard to use incorrectly (让接口容易被正确使用,不易被误用)
18.1 优秀接口的目标
一个优秀的接口应该能够引导用户以正确的方式使用它,同时尽可能地防止用户犯错。这涉及到接口的设计、文档、以及类型系统的运用。
18.2 使接口容易被正确使用的方法
18.2.1 选择直观且一致的命名
① 函数、类、变量等的命名应该清晰地表达其意图和功能,并且在整个代码库中保持一致的命名风格。
② 例如,使用 getName()
而不是 GetTheName()
或 queryName()
。
18.2.2 提供完整的文档和示例
① 接口应该有清晰、准确的文档,说明其用途、参数、返回值、前置条件、后置条件以及可能抛出的异常。
② 提供简单的使用示例可以帮助用户快速理解如何正确地使用接口。
18.2.3 使用合适的参数类型和返回值类型
① 限制参数的取值范围:例如,使用枚举类型来限制参数只能取预定义的值,而不是使用可能包含非法值的整数或字符串。
② 使用强类型:避免使用过于通用的类型(例如 void*
),尽可能使用具体的类型,以便编译器进行类型检查。
③ 返回值应该清晰地指示操作的结果:例如,返回布尔值表示成功或失败,或者返回包含错误信息的结构体。
18.2.4 提供合理的默认行为
① 如果某些参数在大多数情况下都有一个合理的默认值,可以在函数签名中提供默认参数,以简化用户的调用。
② 但要注意不要过度使用默认参数,以免接口变得难以理解。
18.2.5 支持常见的操作模式
① 考虑用户最常使用接口的方式,并提供相应的便捷方法。
② 例如,如果一个类经常需要被迭代,可以提供迭代器接口。
18.3 使接口难以被误用的方法
18.3.1 阻止不合理的操作
① 限制对象的创建方式:例如,使用工厂函数而不是公开的构造函数来控制对象的创建过程。
② 禁用拷贝和赋值:对于不应该被拷贝或赋值的类(例如单例类),应该显式地禁用拷贝构造函数和拷贝赋值运算符。
③ 删除不安全的或过时的接口:及时移除不再推荐使用或存在安全隐患的接口。
18.3.2 利用类型系统来强制约束
① 使用 const
来表明不应该被修改的对象或参数。这可以防止用户意外地修改数据。
② 使用引用而不是指针:引用不能为空,并且不需要显式地解引用,可以减少空指针和悬挂指针的错误。
③ 使用 RAII 来管理资源:如 Item 13 所述,通过对象来管理资源可以自动处理资源的释放,避免忘记释放资源的问题。
18.3.3 在编译期发现错误
① 尽可能地使用编译期检查:通过选择合适的类型、使用模板、以及利用 static_assert
等机制,可以在编译阶段发现潜在的错误,而不是等到运行时。
② 避免隐式类型转换:过多的隐式类型转换可能会导致用户传递了错误类型的参数而没有被编译器发现。可以使用 explicit
关键字来控制构造函数的隐式转换。
18.3.4 考虑异常安全
① 接口应该明确地说明可能抛出的异常及其条件。
② 实现接口时要考虑异常安全,确保在发生异常时不会留下不一致的状态或资源泄漏。
18.3.5 提供清晰的错误报告
① 当接口被误用时,应该提供清晰、有意义的错误信息,帮助用户快速定位和解决问题。
② 例如,抛出带有详细错误描述的异常,或者返回包含错误码和错误信息的结构体。
18.4 示例
考虑一个表示日期的类:
容易被误用的接口 (使用整数表示月份):
1
class Date {
2
public:
3
Date(int day, int month, int year); // month 可以是 1 到 12 之外的值
4
// ...
5
};
更安全的接口 (使用枚举表示月份):
1
enum class Month {
2
January = 1, February, March, April, May, June,
3
July, August, September, October, November, December
4
};
5
6
class Date {
7
public:
8
Date(int day, Month month, int year); // month 的取值范围被限制在有效的月份
9
// ...
10
};
在这个例子中,使用枚举 Month
可以防止用户传递无效的月份值,从而使接口更难以被误用。
18.5 总结
设计易于正确使用且难以误用的接口是一个重要的软件设计原则。通过仔细考虑命名、文档、类型系统、错误处理和默认行为等方面,可以创建出更健壮、更易于理解和维护的接口,从而提高软件的质量。
19. Item 19: Treat class design as type design (设计 class 犹如设计 type)
19.1 类即用户自定义类型
在 C++ 中,class
关键字不仅用于创建对象,更重要的是,它允许程序员定义新的类型。当你设计一个类时,你实际上是在设计一种新的类型,这种类型拥有自己的属性(数据成员)和行为(成员函数)。
19.2 设计类型的考量
将类设计视为类型设计意味着你需要像设计语言内置类型(例如 int
、double
、std::string
)一样仔细地考虑你的类的各个方面:
19.2.1 类型的生命周期
① 如何创建对象 (Construction):你需要考虑应该提供哪些构造函数来创建你的类型的对象。是否需要默认构造函数?是否需要从其他类型转换而来的构造函数?是否需要拷贝构造函数和移动构造函数?
② 如何销毁对象 (Destruction):你需要考虑在对象生命周期结束时需要执行哪些清理操作。是否需要自定义析构函数来释放资源?是否需要虚析构函数(如果该类可能作为基类)?
③ 如何拷贝和赋值对象 (Copying and Assignment):你需要决定你的类型是否应该支持拷贝和赋值操作。如果支持,应该如何实现拷贝构造函数和拷贝赋值运算符(深拷贝、浅拷贝、禁用拷贝等)?是否需要移动语义(移动构造函数和移动赋值运算符)?
19.2.2 类型支持的操作
① 对象应该支持哪些操作 (Member Functions):你需要定义一组能够操作你的类型对象的成员函数。这些函数应该清晰地表达了类型的行为。
② 操作应该具有哪些性质:这些操作是否应该修改对象的状态(非 const
成员函数)?是否应该只读取对象的状态(const
成员函数)?
③ 操作符重载 (Operator Overloading):你的类型是否应该支持某些操作符(例如 +
、-
、==
、<
等)?重载操作符可以使你的类型更易于使用,并且与内置类型具有更一致的语法。
19.2.3 类型与其他类型的关系
① 继承 (Inheritance):你的类型是否应该作为基类被其他类型继承?或者它是否应该继承自其他类型?如果涉及到继承,需要仔细考虑虚函数、虚析构函数以及接口的设计。
② 组合 (Composition):你的类型是否包含其他类型的对象作为其成员?如果是,需要考虑这些成员对象的生命周期管理以及如何与它们交互。
③ 类型转换 (Type Conversion):你的类型是否应该能够与其他类型进行隐式或显式地转换?你需要定义相应的转换构造函数或转换运算符。
19.2.4 类型的可见性
① 哪些部分应该公开 (Public):公开接口定义了用户如何与你的类型交互。这部分应该设计得清晰、简洁且易于使用。
② 哪些部分应该私有 (Private):私有成员变量和函数是类型的内部实现细节,应该对用户隐藏起来,以保护对象的状态并允许在不影响用户代码的情况下修改实现。
③ 哪些部分应该受保护 (Protected):受保护成员可以被派生类访问,这在继承体系中很有用。
19.2.5 类型的效率
① 对象的创建和销毁是否高效?
② 类型支持的操作是否具有良好的性能?
③ 是否需要考虑内存使用?
19.2.6 类型的异常安全性
① 类型的操作在发生异常时是否能保证对象处于一致的状态?
② 析构函数是否会抛出异常?(如 Item 8 所述,析构函数不应该抛出异常)
19.3 设计内置类型的启示
思考内置类型(例如 int
)的设计可以帮助我们更好地理解类型设计的概念:
→ int
有其特定的生命周期(创建时分配内存,超出作用域时释放内存)。
→ int
支持一系列操作(例如加减乘除、比较等)。
→ int
可以与其他数值类型进行转换。
→ int
的内部表示对用户是隐藏的。
19.4 总结
将类设计视为类型设计是一种更深入、更全面的思考方式。它要求我们不仅要考虑类的功能,还要考虑其行为、生命周期、与其他类型的关系以及各种设计原则。通过像设计内置类型一样认真地对待类设计,我们可以创建出更健壮、更易于使用、更易于维护的自定义类型,从而提高软件的质量。
20. Item 20: Prefer pass-by-reference-to-const to pass-by-value (宁以 pass-by-reference-to-const 替换 pass-by-value)
20.1 函数参数传递的三种方式
在 C++ 中,函数参数可以通过以下三种方式传递:
→ 传值 (Pass-by-value):将实参的值拷贝一份传递给形参。形参是实参的副本,函数内部对形参的修改不会影响实参。
→ 传引用 (Pass-by-reference):形参是实参的别名,函数内部对形参的修改会直接影响实参。
→ 传指针 (Pass-by-pointer):形参是一个指向实参的指针,函数内部可以通过解引用指针来访问和修改实参所指向的对象。
20.2 pass-by-value
的开销
当通过值传递参数时,会发生以下操作:
- 创建形参对象,需要调用形参类型的拷贝构造函数(如果存在)。
- 将实参的值拷贝给形参。
- 在函数返回时,形参对象会被销毁,需要调用形参类型的析构函数(如果存在)。
对于大型对象或者拷贝开销很大的对象,传值会导致显著的性能开销,包括时间和空间上的开销。
20.3 pass-by-reference-to-const
的优势
使用常量引用传递参数 (const T& parameter
) 可以带来以下优势:
20.3.1 避免拷贝开销
① 传引用不会创建实参的副本,而是直接使用实参本身(作为别名)。因此,对于大型对象,可以显著提高性能,因为它避免了拷贝构造函数和析构函数的调用以及内存的分配和释放。
20.3.2 可以传递常量对象
② 通过值传递的形参是实参的副本,因此可以接受常量对象作为实参。但是,如果函数内部试图修改形参,会导致编译错误(除非形参本身不是 const
的)。
③ 通过非常量引用传递参数 (T& parameter
) 不能接受常量对象作为实参,因为这会允许函数修改一个被声明为常量的对象。
④ 通过常量引用传递参数 (const T& parameter
) 可以接受常量对象作为实参,并且保证函数不会修改该对象。这使得接口更加灵活。
20.3.3 避免对象切片 (Object Slicing)
⑤ 当通过值传递一个派生类对象给一个接受基类对象的函数时,会发生对象切片。只有派生类对象中基类的部分会被拷贝到形参对象中,派生类特有的成员会被丢失。
⑥ 通过引用传递参数可以避免对象切片,因为形参是实参的别名,它仍然是完整的派生类对象(如果实参是派生类对象)。
20.4 何时应该使用 pass-by-value
尽管 pass-by-reference-to-const
通常是更好的选择,但在以下情况下,pass-by-value
可能更合适:
→ 小型内置类型 (Small Built-in Types):例如 int
、float
、bool
等。对于这些类型,拷贝的开销通常很小,甚至可能比传递引用的开销更小。此外,按值传递可以明确表示函数不会修改原始值。
→ 需要在函数内部修改参数的副本:如果函数需要在内部修改参数,但不希望影响原始对象,那么按值传递是一个合适的选择。
→ 拷贝成本低于引用传递成本的特殊情况:在某些非常特殊的情况下,例如移动成本很高的对象,如果拷贝成本很低,按值传递然后移动可能比按引用传递然后拷贝性能更好(但这通常是高级主题,且不常见)。
20.5 总结
对于不需要在函数内部修改的参数,并且该参数不是小型内置类型时,应该优先使用 pass-by-reference-to-const
。这可以避免不必要的拷贝开销,提高性能,并且可以接受常量对象作为实参。只有当参数是小型内置类型,或者需要在函数内部修改参数的副本时,才应该考虑使用 pass-by-value
。
21. Item 21: Don't try to return a reference when you must return an object (必须返回对象时,别妄想返回其 reference)
21.1 返回引用的前提
返回引用通常用于以下情况:
→ 返回一个在函数调用之前就已经存在的对象:例如,返回一个类的成员变量(如果该成员变量不是局部变量)。
→ 实现操作符重载以支持链式调用:例如,operator=
通常返回对 *this
的引用。
在这些情况下,返回引用可以避免不必要的拷贝,提高效率。
21.2 不能返回局部对象的引用
局部对象是在函数内部创建的,当函数执行结束时,局部对象会被销毁,其所占用的内存也会被释放。如果函数返回对一个局部对象的引用,那么在函数调用结束后,引用将指向一个不再存在的对象,这会导致悬挂引用 (dangling reference),使用这个引用会产生未定义行为。
1
class MyClass {
2
public:
3
int value;
4
};
5
6
const MyClass& badFunction() {
7
MyClass localObj; // localObj 是局部对象
8
localObj.value = 42;
9
return localObj; // 返回对局部对象的引用(错误的做法)
10
}
11
12
int main() {
13
const MyClass& ref = badFunction();
14
// ref 现在是一个悬挂引用,访问 ref.value 会导致未定义行为
15
std::cout << ref.value << std::endl;
16
return 0;
17
}
在上面的例子中,localObj
在 badFunction
返回后就被销毁了,所以 ref
指向的是无效的内存。
21.3 不能返回临时对象的引用
临时对象通常是在表达式求值过程中创建的,它们具有短暂的生命周期,通常在创建它们的完整表达式结束后就会被销毁。如果函数返回对一个临时对象的引用,那么在函数调用结束后,引用也会变成悬挂引用。
1
class MyClass {
2
public:
3
MyClass(int val) : value(val) {}
4
int value;
5
};
6
7
const MyClass& anotherBadFunction(int x) {
8
return MyClass(x); // 返回对临时对象的引用(错误的做法)
9
}
10
11
int main() {
12
const MyClass& ref = anotherBadFunction(99);
13
// ref 现在是一个悬挂引用
14
std::cout << ref.value << std::endl;
15
return 0;
16
}
在这里,MyClass(x)
创建了一个临时对象,这个临时对象在 anotherBadFunction
返回后就会被销毁。
21.4 何时必须返回对象
在以下情况下,函数应该返回一个对象(通过值返回):
→ 当函数需要返回一个在函数内部创建的新对象时。
→ 当函数需要返回一个临时计算结果时。
→ 当需要返回一个不属于任何现有对象的“值”时。
在这些情况下,返回对象会导致拷贝操作(或者在 C++11 及更高版本中可能是移动操作),但这是保证程序正确性的必要代价。
1
class MyClass {
2
public:
3
MyClass(int val) : value(val) {}
4
int value;
5
};
6
7
MyClass goodFunction(int x) {
8
MyClass result(x * 2); // result 是局部对象
9
return result; // 返回对象(正确的做法)
10
}
11
12
int main() {
13
MyClass obj = goodFunction(50);
14
std::cout << obj.value << std::endl; // 输出 100
15
return 0;
16
}
在这个例子中,goodFunction
返回的是局部对象 result
的一个副本(在 C++11 中可能是移动),这是安全的。
21.5 返回值优化 (Return Value Optimization, RVO) 和命名返回值优化 (Named Return Value Optimization, NRVO)
C++ 编译器通常会进行优化,以减少函数返回值时的拷贝开销。返回值优化(RVO)发生在返回未命名的临时对象时,而命名返回值优化(NRVO)发生在返回局部具名对象时。通过这些优化,编译器可以直接在调用者的内存中构造返回值,从而避免了额外的拷贝操作。因此,即使在必须返回对象的情况下,性能开销也可能比想象的要小。
21.6 总结
当函数需要返回一个在函数内部创建的对象或者一个临时计算结果时,应该通过值返回对象,而不是尝试返回对局部对象或临时对象的引用。返回悬挂引用会导致未定义行为。虽然返回对象可能会涉及拷贝开销,但编译器通常会进行优化来减少这种开销。在设计函数接口时,要仔细考虑返回类型,确保其既能满足功能需求,又能保证程序的正确性。
22. Item 22: Declare data members private
(将成员变量声明为 private
)
22.1 封装 (Encapsulation) 的概念
封装是面向对象编程的核心原则之一。它指的是将对象的内部状态(数据成员)和实现细节(成员函数)隐藏起来,只通过公共接口与外界交互。
22.2 将数据成员声明为 private
的好处
将类的成员变量声明为 private
可以带来以下诸多好处:
22.2.1 提供对数据成员的受控访问
① private
成员只能在类的内部(以及友元函数)被访问。这意味着类的外部代码不能直接读取或修改这些数据成员。
② 类可以通过提供 public
的成员函数(例如 getter 和 setter)来控制对数据成员的访问。这允许类在访问或修改数据时执行额外的逻辑,例如:
②.① 读取时进行格式化或计算。
②.② 写入时进行验证,确保数据的有效性。
②.③ 维护类的内部不变量 (invariants):不变量是类的数据成员必须满足的某些条件。通过控制对数据成员的修改,类可以确保在任何时候其不变量都得到满足。
22.2.2 隐藏实现细节
③ 将数据成员声明为 private
可以隐藏类的内部实现细节。类的外部代码只需要知道公共接口如何使用,而不需要了解内部数据的具体存储方式。
④ 这种信息隐藏使得在不影响类的用户代码的情况下,可以修改类的内部实现(例如更改数据成员的类型或名称)。如果数据成员是 public
的,那么任何直接访问这些成员的代码都会依赖于具体的实现细节,修改这些细节会破坏用户代码。
22.2.3 提高代码的可维护性
⑤ 通过封装,类的不同部分之间的依赖性降低,使得代码更容易修改、测试和维护。
⑥ 如果需要修改数据成员的实现,只需要修改类的内部代码以及可能相关的访问函数,而不需要修改所有直接访问该数据成员的外部代码。
22.2.4 促进代码重用
⑦ 良好的封装可以使类更容易被重用。类的用户只需要关注其公共接口,而不需要关心其内部实现,这降低了使用该类的复杂性。
22.2.5 允许延迟决策
⑧ 通过将数据成员声明为 private
,类的设计者可以推迟关于数据如何存储和表示的决策。只要公共接口保持不变,内部实现就可以在以后进行修改。
22.3 何时不应该将数据成员声明为 private
① struct
的默认访问级别是 public
:在 C++ 中,struct
和 class
的主要区别在于默认的访问级别。struct
的默认访问级别是 public
,而 class
的默认访问级别是 private
。通常,如果一个类型只是一个简单的数据结构,没有太多的行为,可以使用 struct
并允许公共访问其成员。然而,如果需要封装和控制访问,应该使用 class
并将数据成员声明为 private
。
② 特殊情况下的友元 (Friends):友元函数或友元类可以访问类的 private
和 protected
成员。在某些特定的设计模式或需要紧密合作的类之间,可以使用友元关系来允许受控的直接访问。但应该谨慎使用友元,因为它会破坏封装性。
22.4 示例
1
class Counter {
2
public:
3
Counter() : count_(0) {}
4
5
void increment() {
6
if (count_ < max_count_) {
7
++count_;
8
}
9
}
10
11
int getCount() const {
12
return count_;
13
}
14
15
private:
16
int count_;
17
static const int max_count_ = 100;
18
};
19
20
int main() {
21
Counter c;
22
c.increment();
23
// c.count_ = 200; // 错误:count_ 是 private 的
24
std::cout << "Count: " << c.getCount() << std::endl;
25
return 0;
26
}
在这个例子中,count_
被声明为 private
,外部代码不能直接修改它。increment()
方法控制了 count_
的增长,并确保它不会超过 max_count_
。getCount()
方法提供了只读访问。
22.5 总结
将类的成员变量声明为 private
是实现封装的关键。它提供了对数据成员的受控访问,隐藏了实现细节,提高了代码的可维护性、可重用性,并允许类维护其内部不变量。虽然在某些特殊情况下(例如简单的 struct
)可以允许公共访问数据成员,但在大多数情况下,为了遵循良好的面向对象设计原则,应该优先将数据成员声明为 private
,并通过公共成员函数提供受控的访问。
23. Item 23: Prefer non-member non-friend functions to member functions (宁以 non-member non-friend 函数替换 member 函数)
23.1 成员函数与非成员非友元函数的区别
→ 成员函数 (Member Functions):是定义在类内部的函数,可以访问类的所有成员(包括 private
、protected
和 public
)。它们通过特定的对象来调用(除了 static
成员函数)。
→ 非成员函数 (Non-member Functions):是定义在类外部的函数,不能直接访问类的 private
和 protected
成员,除非它们被声明为该类的友元函数。
→ 友元函数 (Friend Functions):是在类内部声明但定义在类外部的函数,它可以访问类的所有成员,就像成员函数一样。友元关系需要显式声明。
23.2 为什么优先选择非成员非友元函数
相比于成员函数,非成员非友元函数通常具有以下优势:
23.2.1 更好的封装性 (Increased Encapsulation)
① 非成员非友元函数只能通过类的公共接口来访问类的对象。这意味着它们不会破坏类的封装性,因为它们无法直接触及类的内部实现细节(private
和 protected
成员)。
② 成员函数天然地拥有对类的内部状态的访问权,这可能会使得成员函数更容易依赖于类的具体实现,从而降低了封装性。
23.2.2 增加了类的灵活性 (Increased Class Flexibility)
③ 如果一个功能可以通过类的公共接口来实现,那么将其实现为非成员非友元函数可以使得这个功能不那么紧密地耦合到类本身。
④ 这样,如果类的内部实现发生变化,只要公共接口保持不变,这个非成员非友元函数通常不需要修改。
⑤ 此外,将功能放在非成员函数中,可以更容易地将这些功能移动到不同的类中,或者在不同的类之间重用。
23.2.3 减少了类的接口尺寸 (Reduced Class Interface Size)
⑥ 类的接口(即 public
成员)定义了用户如何与类进行交互。过多的成员函数会使得类的接口变得庞大且难以理解和维护。
⑦ 将一些可以通过公共接口实现的功能放在非成员函数中,可以保持类的接口更小更精简。
23.2.4 促进命名空间的使用 (Promotes Use of Namespaces)
⑧ 非成员非友元函数通常属于某个命名空间。使用命名空间可以有效地组织相关的函数和类,避免命名冲突。
⑨ 成员函数总是与特定的类相关联,不直接参与命名空间的组织。
23.3 何时应该使用成员函数
尽管非成员非友元函数有很多优点,但在以下情况下,使用成员函数通常是更合适的选择:
→ 操作需要访问类的非公共成员:如果一个操作需要直接访问类的 private
或 protected
成员,并且不能通过公共接口高效地实现,那么它应该是一个成员函数(或者可能是友元函数,但应该优先考虑成员函数)。
→ 操作在概念上是类的一部分:有些操作在概念上与类的对象紧密相关,例如获取对象的状态、修改对象的状态等。将这些操作实现为成员函数可以更清晰地表达这种关系。
→ 实现类的基本行为:例如,构造函数、析构函数、拷贝控制函数等必须是成员函数。
23.4 何时应该使用友元函数
友元函数可以访问类的非公共成员,但它们不是类的成员函数。应该谨慎使用友元函数,因为它们会破坏类的封装性。通常只有在以下情况下才考虑使用友元函数:
→ 需要访问多个类的非公共成员:如果一个函数需要访问多个不同类的 private
成员,并且这些类之间存在某种逻辑上的关联,可以考虑将该函数声明为这些类的友元。
→ 某些操作符重载:例如,重载输入/输出流操作符 operator<<
和 operator>>
时,通常需要访问类的私有成员,这时友元函数是一个常见的选择。
1
#include <iostream>
2
3
class Point {
4
public:
5
Point(int x, int y) : x_(x), y_(y) {}
6
7
// 非成员非友元函数,通过公共接口访问
8
void print(std::ostream& os) const {
9
os << "(" << getX() << ", " << getY() << ")";
10
}
11
12
int getX() const { return x_; }
13
int getY() const { return y_; }
14
15
private:
16
int x_;
17
int y_;
18
};
19
20
// 非成员非友元函数
21
void printPoint(const Point& p, std::ostream& os) {
22
p.print(os); // 使用公共成员函数
23
}
24
25
int main() {
26
Point p(1, 2);
27
printPoint(p, std::cout); // 输出: (1, 2)
28
return 0;
29
}
在这个例子中,printPoint
是一个非成员非友元函数,它通过调用 Point
类的公共成员函数 print
和 getX
/getY
来实现其功能,从而保持了 Point
类的封装性。
23.5 总结
在设计类的接口时,应该优先考虑将功能实现为非成员非友元函数,只要这些功能可以通过类的公共接口来完成。这样做可以提高封装性、增加类的灵活性、减小类的接口尺寸,并促进命名空间的使用。只有当操作需要直接访问类的非公共成员,或者在概念上是类的一部分时,才应该使用成员函数。应该谨慎使用友元函数,只在必要时才考虑使用。
24. Item 24: Declare non-member functions when type conversions should apply to all parameters (若所有参数皆需类型转换,请为此采用 non-member 函数)
24.1 隐式类型转换的局限性
在 C++ 中,编译器可以对函数参数进行隐式类型转换,以便将实参的类型转换为形参的类型(如果存在可行的转换)。然而,对于类的成员函数,隐式类型转换只适用于除调用对象(this
指针指向的对象)之外的参数。调用对象本身必须是类的精确类型。
24.2 成员函数中的隐式转换
考虑以下示例:
1
class Rational {
2
public:
3
Rational(int numerator = 0, int denominator = 1);
4
int numerator() const;
5
int denominator() const;
6
const Rational operator*(const Rational& rhs) const;
7
private:
8
int num_;
9
int den_;
10
};
11
12
const Rational operator*(const Rational& lhs, const Rational& rhs); // 非成员函数
如果我们有一个 Rational
对象,我们可以将一个 int
隐式转换为 Rational
来与它相乘(假设存在从 int
到 Rational
的非 explicit
构造函数):
1
Rational oneHalf(1, 2);
2
Rational result = oneHalf * 2; // OK: 2 被隐式转换为 Rational(2, 1)
3
result = 2 * oneHalf; // Error!
在第二个乘法运算中,2
是 int
类型,它位于 *
运算符的左侧。由于这是一个成员函数调用(oneHalf.operator*(2)
),编译器不会将 2
隐式转换为 Rational
,因为它需要对调用 operator*
的对象(oneHalf
)进行类型转换。
24.3 非成员函数允许所有参数进行转换
如果我们将 operator*
实现为一个非成员函数:
1
const Rational operator*(const Rational& lhs, const Rational& rhs) const;
那么,对于表达式 2 * oneHalf
,编译器会尝试将 2
转换为 Rational(2, 1)
,因为非成员函数 operator*
的两个参数都是 const Rational&
类型。同样地,对于 oneHalf * 2
,2
也会被隐式转换为 Rational(2, 1)
。
1
Rational oneHalf(1, 2);
2
Rational result = oneHalf * 2; // OK: 2 被隐式转换为 Rational(2, 1)
3
result = 2 * oneHalf; // OK: 2 被隐式转换为 Rational(2, 1)
因此,当希望某个操作符(或其他函数)的所有参数都支持隐式类型转换时,应该将其声明为非成员函数。
24.4 非成员函数与封装
你可能会担心非成员函数无法访问类的私有成员。如果操作确实需要访问私有成员,并且不能通过公共接口高效地实现,那么可以将该非成员函数声明为类的友元函数。
1
class Rational {
2
public:
3
Rational(int numerator = 0, int denominator = 1);
4
int numerator() const;
5
int denominator() const;
6
// ...
7
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
8
private:
9
int num_;
10
int den_;
11
};
12
13
const Rational operator*(const Rational& lhs, const Rational& rhs) {
14
return Rational(lhs.num_ * rhs.num_, lhs.den_ * rhs.den_); // 可以访问私有成员
15
}
然而,如 Item 23 所述,应该尽量避免使用友元函数。更好的做法是提供足够的公共接口,使得非成员非友元函数可以通过这些接口来实现其功能。
1
class Rational {
2
public:
3
Rational(int numerator = 0, int denominator = 1);
4
int numerator() const;
5
int denominator() const;
6
// ...
7
private:
8
int num_;
9
int den_;
10
};
11
12
// 非成员非友元函数,使用公共接口
13
const Rational operator*(const Rational& lhs, const Rational& rhs) {
14
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
15
}
24.5 总结
当希望函数的所有参数(包括调用操作符的对象本身)都能参与隐式类型转换时,应该将该函数声明为非成员函数。这在实现二元操作符(例如乘法、加法等)时尤其重要。如果非成员函数需要访问类的私有成员,可以将其声明为友元函数,但应尽量通过公共接口来实现功能,以维护更好的封装性。
25. Item 25: Consider support for a non-throwing swap
(考虑写出一个不抛异常的 swap
函数)
25.1 std::swap
的默认行为
std::swap
是 C++ 标准库中用于交换两个对象值的函数。其默认实现通常涉及一次拷贝构造和两次赋值操作(或者使用移动语义时是移动构造和移动赋值)。对于包含大量资源的类,这些操作可能会比较昂贵,并且可能抛出异常。
25.2 为自定义类提供 swap
的好处
为你的自定义类提供一个专门的 swap
函数(通常是非成员友元函数)可以带来以下好处:
25.2.1 提高效率
① 如果你的类管理着大量资源(例如动态分配的内存),交换两个对象的内部指针通常比深拷贝整个对象要高效得多。
② 你可以实现一个只交换内部指针和其他少量成员的 swap
函数,从而显著提高交换操作的性能。
25.2.2 提供异常安全保证
③ 默认的 std::swap
实现可能在拷贝或赋值过程中抛出异常。如果你的自定义 swap
函数只涉及基本类型的交换(例如指针或整数),你可以确保它不会抛出异常。
④ 提供一个不抛异常的 swap
函数对于实现强异常安全保证非常重要,尤其是在使用“拷贝并交换 (copy and swap)”惯用法时(如 Item 11 所述)。
25.3 实现不抛异常的 swap
要为你自己的类实现一个不抛异常的 swap
函数,通常需要以下步骤:
25.3.1 在类内部提供一个 swap
成员函数
① 这个成员函数应该交换两个相同类型对象的内部状态。为了保证不抛出异常,这个函数应该只交换内置类型成员或不会抛出异常的其他成员。
② 将这个成员函数声明为 noexcept
(在 C++11 及更高版本中)。
1
class MyClass {
2
public:
3
// ... 其他成员
4
5
void swap(MyClass& other) noexcept {
6
using std::swap; // 允许对成员使用标准库的 swap
7
8
swap(ptr_, other.ptr_); // 假设 ptr_ 是一个指针
9
swap(size_, other.size_); // 假设 size_ 是一个基本类型
10
// ... 交换其他需要交换的成员
11
}
12
13
private:
14
ResourceType* ptr_;
15
size_t size_;
16
// ...
17
};
25.3.2 在类的命名空间中提供一个非成员友元 swap
函数
① 这个非成员函数会调用类内部的 swap
成员函数。将其声明为友元可以允许它访问类的非公共成员(如果需要)。
② 同样,将这个函数声明为 noexcept
。
1
namespace MyNamespace {
2
class MyClass {
3
// ... (如上)
4
5
friend void swap(MyClass& a, MyClass& b) noexcept {
6
a.swap(b);
7
}
8
};
9
10
void swap(MyClass& a, MyClass& b) noexcept {
11
a.swap(b);
12
}
13
}
在 C++11 及更高版本中,通常在类的命名空间中提供一个非成员的 swap
函数,它通过调用类的成员 swap
函数来实现交换。是否需要声明为友元取决于 swap
操作是否需要访问非公共成员。
25.3.3 特殊化 std::swap
(可选,但推荐)
① 为了使你的类的 swap
函数能够被泛型代码(例如标准库算法)所使用,你应该在 std
命名空间中为你自己的类提供一个特化版本的 std::swap
。
② 这需要在包含你的类定义的头文件中进行。
1
namespace std {
2
template <>
3
void swap(MyNamespace::MyClass& a, MyNamespace::MyClass& b) noexcept {
4
a.swap(b); // 调用我们自定义的 swap 函数
5
}
6
}
注意:只能在 std
命名空间中特化模板,而不能添加新的重载。
25.4 使用自定义的 swap
一旦你为你的类提供了 swap
函数,你应该在需要交换两个 MyClass
对象的值时使用它,而不是依赖于默认的 std::swap
。
1
#include <algorithm> // std::swap
2
3
void example() {
4
MyNamespace::MyClass obj1;
5
MyNamespace::MyClass obj2;
6
7
// 使用自定义的 swap
8
using std::swap; // 引入 std::swap 以便可以找到特化版本
9
swap(obj1, obj2); // 这会调用 std::swap 的特化版本,进而调用 MyClass 的成员 swap
10
}
25.5 总结
为你的资源管理类或其他需要高效且可能不抛异常交换操作的类提供一个自定义的 swap
函数是一个好主意。这通常涉及到在类内部提供一个 noexcept
的 swap
成员函数,然后在类的命名空间中提供一个调用该成员函数的非成员 noexcept
函数,并且在 std
命名空间中特化 std::swap
以便泛型代码可以使用。这样做可以提高性能,并为异常安全的代码编写提供更好的支持。
好的,现在我将继续按照您约定的输出格式,对您提供的 Item 26 到 Item 31 进行非常非常详细的解释。
26. Item 26: Postpone variable definitions as long as possible (尽可能延后变量定义式的出现时间)
26.1 变量定义式与使用
在 C++ 中,变量的定义式(definition)会为变量分配内存并可能进行初始化。这条规则建议我们不要在函数或块作用域的开始就定义所有需要的变量,而应该尽可能地将变量的定义推迟到它即将被使用的地方。
26.2 延后变量定义式的好处
26.2.1 提高程序的可读性和可维护性
① 将变量的定义放在靠近其首次使用的地方,可以使代码更容易理解。读者在阅读代码时,可以更容易地看到变量的类型和初始值,以及它是如何被使用的,而不需要在作用域的开头查找变量的定义。
② 这也使得代码更容易维护,因为当需要修改或删除某个变量时,相关的定义和使用代码会更靠近,减少了不必要的修改范围。
26.2.2 避免不必要的构造和析构开销
③ 如果一个变量在某个条件分支中才会被使用,而在其他分支中不会用到,那么在作用域的开始就定义它可能会导致不必要的构造和析构开销。
④ 考虑以下代码:
1
void someFunction(bool condition) {
2
MyClass expensiveObject; // 在作用域开始就定义
3
4
if (condition) {
5
// 使用 expensiveObject
6
expensiveObject.doSomething();
7
} else {
8
// 不使用 expensiveObject
9
}
10
}
在这个例子中,如果 condition
为 false
,expensiveObject
的构造和析构操作就是不必要的。
⑤ 将变量的定义移动到 if
语句块中可以避免这种开销:
1
void someFunction(bool condition) {
2
if (condition) {
3
MyClass expensiveObject; // 只在需要时定义
4
expensiveObject.doSomething();
5
} else {
6
// 不使用 expensiveObject
7
}
8
}
现在,expensiveObject
只在 condition
为 true
时才会被构造和析构。
26.2.3 避免使用未初始化的变量
⑥ 如果在作用域的开始就定义了变量,很容易忘记在使用之前对其进行初始化。这可能导致程序出现难以调试的错误。
⑦ 将变量的定义推迟到即将使用时,通常会强制程序员在定义时就对其进行初始化,或者至少在使用之前进行初始化。
1
void anotherFunction() {
2
int x; // 定义但可能忘记初始化
3
// ... 很多代码 ...
4
// std::cout << x; // 如果 x 没有被初始化,行为是未定义的
5
6
// 更好的做法:
7
// ... 很多代码 ...
8
int y = someValue(); // 在使用之前定义并初始化
9
std::cout << y;
10
}
26.3 循环中的变量定义
对于循环中的变量,特别是循环控制变量,通常在循环语句中定义它们是最好的做法:
1
for (int i = 0; i < 10; ++i) {
2
// 使用 i
3
}
4
5
// i 的作用域仅限于 for 循环内部
6
// std::cout << i; // 错误:i 在这里不可见
这样做可以限制变量的作用域,提高代码的清晰度,并避免在循环结束后意外地使用这些变量。
26.4 注意事项
① 性能关键的代码:在对性能要求非常高的代码中,过多的变量定义和初始化可能会带来轻微的开销。然而,通常情况下,可读性和正确性比这种微小的性能差异更重要。并且,现代编译器通常会对这种局部变量的创建和销毁进行优化。
② 异常安全:如果变量的构造函数可能会抛出异常,并且你需要确保在异常发生时某些清理操作能够进行,那么可能需要在作用域的开始就定义某些管理资源的 RAII 对象。
26.5 总结
尽可能延后变量定义式的出现时间是一个良好的编程习惯。它可以提高代码的可读性和可维护性,避免不必要的构造和析构开销,并减少使用未初始化变量的风险。通常情况下,应该在变量即将被使用的地方才进行定义,并且最好在定义时就进行初始化。
27. Item 27: Minimize casting (尽量少做转型动作)
27.1 什么是类型转换 (Casting)
类型转换(或称转型)是指将一个表达式的类型转换为另一种类型的操作。在 C++ 中,存在多种类型的转换操作。
27.2 为什么要尽量少做转型
过度使用类型转换可能带来以下问题:
27.2.1 破坏类型系统
① C++ 是一种强类型语言,类型系统有助于在编译时发现类型错误。类型转换可以绕过这种类型检查,使得一些错误被延迟到运行时才暴露出来,增加了调试的难度。
27.2.2 引入运行时错误
② 某些类型的转换在运行时可能是不安全的,例如将一个指向基类的指针强制转换为指向派生类的指针,如果该指针实际指向的并非派生类对象,则会导致未定义行为。
27.2.3 降低代码的可读性和可维护性
③ 过多的类型转换会使得代码难以理解,因为它们模糊了变量的真实类型和意图。
④ 当需要修改代码时,如果存在大量的类型转换,可能会难以判断哪些转换是必要的,哪些是多余的或不安全的,增加了维护的风险。
27.2.4 可能隐藏设计缺陷
⑤ 频繁地使用类型转换可能表明代码的设计存在问题。例如,如果需要在不同的类型之间进行大量的转换,可能意味着类之间的接口设计不够合理,或者使用了不合适的继承结构。
27.3 C++ 中的不同类型转换
C++ 提供了几种不同的类型转换操作符,应该根据具体的情况选择合适的类型:
27.3.1 static_cast
① 用于执行可以在编译时确定的类型转换,例如基本类型之间的转换(int
到 float
),具有继承关系的指针或引用之间的转换(向上转型总是安全的,向下转型需要程序员保证类型是正确的)。
② static_cast
不提供运行时的类型检查。
1
int i = 10;
2
float f = static_cast<float>(i); // int to float
3
4
class Base {};
5
class Derived : public Base {};
6
Derived* d = new Derived();
7
Base* b = static_cast<Base*>(d); // 向上转型 (安全)
8
// Derived* d2 = static_cast<Derived*>(b); // 向下转型 (可能不安全,需要程序员保证)
27.3.2 dynamic_cast
① 主要用于在继承关系中进行安全的向下转型。它会在运行时检查指针或引用所指向的对象的实际类型是否与目标类型兼容。
② 如果类型不兼容,对指针的 dynamic_cast
会返回 nullptr
,对引用的 dynamic_cast
会抛出 std::bad_cast
异常。
③ dynamic_cast
只能用于具有虚函数的类层次结构中(因为运行时类型信息 (RTTI) 需要虚函数表)。
1
class Base { virtual void f() {} };
2
class Derived : public Base {};
3
Base* b = new Derived();
4
Derived* d = dynamic_cast<Derived*>(b); // 安全的向下转型,如果 b 实际指向 Derived 对象
5
6
Base* b2 = new Base();
7
Derived* d2 = dynamic_cast<Derived*>(b2); // d2 将为 nullptr,因为 b2 实际指向 Base 对象
27.3.3 const_cast
① 用于添加或移除变量的 const
或 volatile
限定符。
② 应该谨慎使用 const_cast
,因为它可能会导致修改原本声明为 const
的对象,从而破坏程序的常量性。
③ 通常只在需要调用一个不接受 const
参数的遗留 C API 时才可能使用 const_cast
。
1
const int c = 10;
2
// int* nonConstPtr = &c; // 错误:不能将 const int* 转换为 int*
3
int* nonConstPtr = const_cast<int*>(&c);
4
*nonConstPtr = 20; // 行为是未定义的,如果 c 本身是 const 定义的
5
6
void legacyFunction(int* ptr);
7
const int readOnlyVar = 30;
8
legacyFunction(const_cast<int*>(&readOnlyVar)); // 可能需要这样做以调用遗留代码
27.3.4 reinterpret_cast
① 最危险的类型转换操作符。它执行低级的位模式转换,本质上是将一个对象的二进制表示解释为另一种类型。
② reinterpret_cast
的结果几乎总是与平台相关,并且通常会导致未定义行为。
③ 只有在非常底层的代码中(例如与硬件或操作系统交互)才可能需要使用 reinterpret_cast
,并且需要非常小心地确保转换的正确性。
1
int* ip = new int(65);
2
char* cp = reinterpret_cast<char*>(ip); // 将 int* 解释为 char*
3
std::cout << *cp << std::endl; // 可能输出 'A' (ASCII 65)
27.3.5 C 风格的类型转换
① C++ 仍然支持 C 风格的类型转换,例如 (type)expression
或 type(expression)
。
② 这种语法形式不明确,无法区分是哪种类型的转换,因此应该尽量避免使用。应该使用 C++ 提供的四种命名的类型转换操作符,因为它们更安全、更明确,并且更容易在代码中搜索和识别。
27.4 减少类型转换的方法
27.4.1 避免强制转换的设计
① 良好的面向对象设计应该尽量避免强制类型转换。如果发现代码中存在大量的向下转型,可能需要重新考虑类的继承结构或接口设计。可以考虑使用虚函数来实现多态行为,而不是通过类型转换来调用派生类的特定方法.
27.4.2 使用模板
② 模板可以编写出类型无关的代码,从而减少对特定类型转换的需求。
27.4.3 利用类型推导 (auto
)
③ 在 C++11 及更高版本中,可以使用 auto
关键字让编译器自动推导变量的类型,这有时可以避免显式的类型转换。
27.4.4 使用正确的接口
④ 确保使用的库或 API 提供了类型安全的接口,避免需要将对象转换为不兼容的类型。
27.5 总结
虽然类型转换在某些情况下是必要的,但应该尽量减少其使用,因为它们可能会破坏类型安全、引入运行时错误并降低代码的可读性和可维护性。当需要进行类型转换时,应该选择最合适的 C++ 类型转换操作符 (static_cast
, dynamic_cast
, const_cast
, reinterpret_cast
),并理解它们的语义和潜在风险。应该优先考虑通过更好的设计、使用模板或利用类型推导来避免类型转换。
28. Item 28: Avoid returning "handles" to object internals (避免返回 handles 指向对象内部成分)
28.1 什么是 "handles"
在这里,“handles” 指的是指针(pointers)、引用(references)或迭代器(iterators),它们允许类的用户间接访问对象的内部数据成员。
28.2 为什么避免返回内部 handles
返回指向对象内部成分的 handles 会破坏封装性,并可能导致以下问题:
28.2.1 破坏封装性 (Breaking Encapsulation)
① 如果一个函数返回指向对象内部数据成员的指针或引用,那么类的用户就可以直接访问甚至修改这些内部数据,而无需通过类的公共接口。这暴露了类的内部实现细节,使得类的用户代码直接依赖于这些细节。
② 如果类的内部实现需要改变(例如,数据成员的类型或存储方式),那么所有直接访问这些内部 handles 的用户代码都可能需要修改,即使类的公共接口没有改变。这降低了代码的可维护性和灵活性。
28.2.2 可能导致悬挂 handles (Dangling Handles)
③ 如果返回的 handle 指向一个在函数返回后就会失效的对象(例如局部变量),那么这个 handle 就会变成悬挂 handle,使用它会导致未定义行为。
④ 即使指向的是对象的成员,如果对象本身的生命周期结束,那么返回的 handle 也会失效。
28.2.3 难以维护类的约束条件 (Difficult to Maintain Class Invariants)
⑤ 类通常会定义一些约束条件(不变量),这些条件在对象的整个生命周期中都应该保持为真。如果用户可以直接通过返回的 handles 修改对象的内部数据,那么类就很难保证这些约束条件不被破坏。
28.2.4 增加了类的复杂性
⑥ 返回内部 handles 可能会使得类的接口更加复杂,用户需要了解哪些 handles 是有效的,以及如何正确地使用它们,这增加了类的使用难度。
28.3 应该返回什么
当需要提供对对象信息的访问时,应该优先考虑以下方式:
28.3.1 返回对象的拷贝 (Returning Copies of Objects)
① 如果需要返回对象内部状态的一个副本,可以通过值返回一个拷贝。这样做可以保证原始对象的封装性不被破坏,并且返回的对象有其独立的生命周期。
② 当然,如果拷贝的代价很高,需要考虑其他方法。
28.3.2 返回指向 const
对象的引用或指针 (Returning References or Pointers to const
Objects)
③ 如果只需要提供对内部数据的只读访问,可以返回指向 const
对象的引用或指针。这可以避免拷贝的开销,同时防止用户修改内部数据。
1
class MyClass {
2
public:
3
const std::string& getName() const { return name_; } // 返回 const 引用
4
const int* getData() const { return &data_[0]; } // 返回指向 const 的指针
5
private:
6
std::string name_;
7
std::vector<int> data_;
8
};
28.3.3 提供迭代器或代理对象 (Providing Iterators or Proxy Objects)
④ 如果需要访问容器类型的内部数据,可以提供迭代器接口,让用户能够遍历容器中的元素,而无需直接访问底层的存储结构。
⑤ 对于更复杂的情况,可以考虑使用代理对象来控制对内部数据的访问。代理对象可以封装访问逻辑,并在访问时执行额外的操作(例如检查、转换等)。
28.3.4 提供返回值的函数 (Providing Functions that Return Values)
⑥ 可以提供返回特定值的成员函数,而不是直接暴露内部数据结构。
1
class Circle {
2
public:
3
double getRadius() const { return radius_; }
4
private:
5
double radius_;
6
};
28.4 示例:避免返回指向内部数组的指针
1
class Rectangle {
2
public:
3
Rectangle(int width, int height) : width_(width), height_(height) {}
4
5
// 不好的做法:返回指向内部数组的指针
6
// int* getDimensions() { return dimensions_; }
7
8
// 好的做法:提供访问函数
9
int getWidth() const { return width_; }
10
int getHeight() const { return height_; }
11
12
private:
13
int width_;
14
int height_;
15
// int dimensions_[2]; // 内部存储方式可能会改变
16
};
17
18
int main() {
19
Rectangle rect(10, 20);
20
// int* dims = rect.getDimensions();
21
// dims[0] = 30; // 直接修改了 Rectangle 对象的内部状态
22
std::cout << "Width: " << rect.getWidth() << ", Height: " << rect.getHeight() << std::endl;
23
return 0;
24
}
在这个例子中,如果 getDimensions()
返回一个指向内部数组的指针,用户就可以直接修改矩形的宽度和高度,而 Rectangle
类本身可能无法感知到这种修改,也无法进行任何验证或维护其内部一致性。
28.5 特殊情况
在某些性能关键的代码中,为了避免拷贝的开销,可能需要在仔细权衡后返回内部 handles。在这种情况下,应该提供清晰的文档,说明返回的 handles 的生命周期和使用限制,并尽量返回指向 const
对象的 handles。
28.6 总结
避免返回指向对象内部成分的指针、引用或迭代器是保持封装性和代码健壮性的重要原则。应该优先考虑返回对象的拷贝、指向 const
对象的 handles、迭代器、代理对象或直接返回值的函数来提供对对象信息的访问。只有在非常特殊的情况下,并且经过仔细考虑后,才应该返回非 const
的内部 handles,并需要充分了解其潜在的风险。
29. Item 29: Strive for exception-safe code (为“异常安全”而努力是值得的)
29.1 什么是异常安全
异常安全是指当代码抛出异常时,程序能够保持其状态的一致性,不会发生资源泄漏,并且不会留下无效的数据。换句话说,异常安全的程序能够从异常中恢复,或者至少以一种可预测和可控的方式终止。
29.2 异常安全级别
通常将异常安全分为以下几个级别:
29.2.1 无保证 (No Guarantee)
① 这是最弱的级别。如果代码抛出异常,程序的任何状态都可能被破坏,可能会发生资源泄漏,数据可能处于不一致的状态。
② 很多早期的 C++ 代码或者没有认真考虑异常安全的代码都属于这个级别。
29.2.2 基本保证 (Basic Guarantee)
③ 如果代码提供基本保证,那么当异常抛出时,程序的状态仍然是有效的。这意味着对象不会处于完全损坏的状态,不会发生资源泄漏(例如内存泄漏)。
④ 然而,程序的状态可能与操作开始之前的状态不同。
29.2.3 强异常安全保证 (Strong Exception Safety)
⑤ 如果代码提供强异常安全保证,那么当异常抛出时,程序的状态要么保持不变(操作完全成功),要么恢复到操作开始之前的状态。也就是说,操作要么完全成功,要么完全没有副作用。
⑥ 这通常通过“提交或回滚 (commit or rollback)”的语义来实现。
29.2.4 不抛异常保证 (No-Throw Guarantee)
⑦ 这是最高的级别。如果一个函数或代码块提供不抛异常保证,那么它承诺在任何情况下都不会抛出异常。这通常通过在函数声明中使用 noexcept
关键字(在 C++11 及更高版本中)来表明。
⑧ 具有不抛异常保证的代码对于编写异常安全的代码非常重要,因为它可以作为异常处理的基础。例如,析构函数通常应该提供不抛异常保证。
29.3 实现异常安全的代码
实现异常安全的代码需要注意以下几个方面:
29.3.1 使用 RAII (Resource Acquisition Is Initialization)
① 如 Item 13 所述,RAII 是实现异常安全的关键技术。通过将资源的生命周期与对象的生命周期绑定,可以确保在异常发生时资源能够被自动释放。
② 例如,使用智能指针管理动态内存,使用锁管理对象管理互斥锁。
29.3.2 拷贝并交换 (Copy and Swap) 惯用法
③ 如 Item 11 所述,拷贝并交换是一种实现强异常安全保证的常用技术,尤其是在实现赋值运算符时。
④ 其基本思想是先创建要修改数据的副本,然后在副本上执行修改,最后通过一个不抛异常的交换操作将修改后的副本与原始对象交换。如果在拷贝过程中抛出异常,原始对象的状态不会被改变。
29.3.3 小心使用裸指针和原始资源
⑤ 避免在可能抛出异常的代码中使用裸指针来管理动态内存或其他资源,因为如果在 new
和 delete
之间抛出异常,会导致资源泄漏。应该使用 RAII 包装器来管理这些资源。
29.3.4 保证析构函数不抛出异常
⑥ 析构函数在对象生命周期结束时会被自动调用,包括在栈展开期间(当异常抛出时)。如果析构函数本身抛出异常,可能会导致程序终止或其他不可预测的行为。因此,析构函数应该总是提供不抛异常保证。通常通过在析构函数内部捕获所有可能抛出的异常并进行处理(例如记录错误)来实现这一点。
29.3.5 考虑函数可能抛出的异常
⑦ 在设计函数时,应该考虑它可能抛出的异常,并确保在异常发生时能够满足所需的异常安全级别。
⑧ 可以使用异常规范(虽然在 C++11 中已弃用,但在理解异常行为方面仍然有用)或 noexcept
关键字来声明函数是否可能抛出异常。
29.3.6 编写异常中立的代码
⑨ 异常中立的代码是指不捕获自己无法处理的异常,而是将它们传递给调用者。这样可以让调用者来决定如何处理异常。
29.3.7 使用事务性操作
⑩ 对于需要修改多个相关状态的操作,可以考虑使用事务性操作。这意味着要么所有修改都成功完成,要么都不完成(回滚到初始状态),以保证强异常安全。
29.4 示例:使用 RAII 保证基本异常安全
1
#include <iostream>
2
#include <fstream>
3
#include <string>
4
#include <stdexcept>
5
6
class FileWriter {
7
public:
8
FileWriter(const std::string& filename) : file_(filename) {
9
if (!file_.is_open()) {
10
throw std::runtime_error("Unable to open file: " + filename);
11
}
12
}
13
14
void writeLine(const std::string& line) {
15
file_ << line << std::endl;
16
if (file_.fail()) {
17
throw std::runtime_error("Error writing to file.");
18
}
19
}
20
21
~FileWriter() {
22
file_.close(); // 文件流的析构函数保证文件会被关闭,即使在异常发生后
23
}
24
25
private:
26
std::ofstream file_;
27
};
28
29
void processFile(const std::string& filename, const std::vector<std::string>& lines) {
30
FileWriter writer(filename);
31
for (const auto& line : lines) {
32
writer.writeLine(line);
33
}
34
// 如果在循环中抛出异常,writer 的析构函数仍然会被调用,文件会被关闭
35
}
36
37
int main() {
38
try {
39
processFile("output.txt", {"Line 1", "Line 2", "Line 3"});
40
std::cout << "File written successfully." << std::endl;
41
} catch (const std::exception& e) {
42
std::cerr << "Exception caught: " << e.what() << std::endl;
43
}
44
return 0;
45
}
在这个例子中,FileWriter
类使用 std::ofstream
来管理文件资源。std::ofstream
的析构函数会自动关闭文件,即使在构造函数或 writeLine
函数中抛出异常,也能保证文件资源不会泄漏。这提供了基本异常安全保证。
29.5 总结
努力编写异常安全的代码是 C++ 编程中非常重要的一个方面。通过使用 RAII、拷贝并交换等技术,并仔细考虑代码在异常发生时的行为,可以编写出更健壮、更可靠的程序。根据应用的需求,可以选择不同的异常安全级别。通常来说,至少应该保证基本异常安全,而对于关键的代码,应该努力实现强异常安全甚至不抛异常保证。
30. Item 30: Understand the ins and outs of inlining (透彻了解 inlining 的里里外外)
30.1 什么是内联 (Inlining)
内联是一种编译器优化技术,它尝试将对一个函数的调用替换为该函数实际的代码。这样做可以减少函数调用的开销,例如参数传递、栈帧的创建和销毁、以及指令指针的跳转等,从而可能提高程序的执行效率。
30.2 内联的优点
→ 减少函数调用开销:这是内联的主要优点。通过将函数体直接插入到调用点,可以避免函数调用相关的 overhead。
→ 允许更进一步的优化:当函数被内联后,编译器可以对周围的代码和被内联的函数体进行更深入的分析和优化,例如常量传播、死代码消除等。
30.3 内联的缺点
→ 增加代码大小:如果一个函数在多个地方被内联,那么最终生成的可执行文件的大小可能会增加。对于非常小的程序,这可能不是问题,但对于大型程序或嵌入式系统,代码大小可能是一个重要的考虑因素。
→ 可能导致指令缓存未命中 (Instruction Cache Misses):如果内联导致代码体积显著增加,可能会降低指令缓存的命中率,从而抵消甚至超过内联带来的性能提升。
→ 增加编译时间:内联可能会增加编译时间,因为编译器需要为每个内联点生成代码。此外,如果内联的函数定义发生变化,所有调用该函数的文件都需要重新编译。
→ 调试复杂性:内联可能会使调试更加困难,因为在调试器中单步执行内联函数时,行为可能与非内联函数不同。
30.4 编译器如何决定是否内联
是否内联一个函数通常是由编译器自动决定的。编译器会考虑多个因素,例如:
→ 函数的大小:通常,编译器更倾向于内联小型、简单的函数。对于大型、复杂的函数,内联可能会导致代码膨胀,反而降低性能。
→ 函数的调用频率:如果一个函数被频繁地调用,内联可能会带来更大的性能提升。
→ 是否使用了 inline
关键字:程序员可以使用 inline
关键字向编译器提出内联的建议。然而,编译器并不保证一定会采纳这个建议。编译器可能会选择内联没有标记为 inline
的函数,也可能拒绝内联标记为 inline
的函数。
→ 优化级别:编译器的优化级别越高,它可能越积极地进行内联。
→ 链接时优化 (Link-Time Optimization, LTO):LTO 允许编译器在链接阶段进行更全局的分析和优化,包括跨文件边界的内联。
30.5 程序员的指导
虽然最终是否内联由编译器决定,但程序员可以通过一些方式来指导编译器:
→ 将小型、频繁调用的函数声明为 inline
:这通常是定义在头文件中的函数。例如,简单的 getter 和 setter 函数。
1
class MyClass {
2
public:
3
inline int getValue() const { return value_; }
4
inline void setValue(int v) { value_ = v; }
5
private:
6
int value_;
7
};
→ 将函数定义放在头文件中:为了进行内联,编译器需要在编译时看到函数的定义。因此,通常会将小型函数的定义放在头文件中,以便在调用点进行内联。
→ 避免在内联函数中编写过于复杂的代码:复杂的函数不太可能被内联。
→ 使用链接时优化:开启编译器的 LTO 选项可以允许更多的跨文件内联。
30.6 虚函数和内联
虚函数通常不能被内联,因为编译器在编译时不知道会调用哪个派生类的实现(这需要在运行时通过虚函数表来确定)。然而,如果编译器能够确定虚函数的具体调用目标(例如,通过静态类型分析),那么它仍然有可能进行内联。
30.7 模板和内联
模板函数通常定义在头文件中,这使得它们更容易被内联。实际上,模板的实例化过程本身就有点类似于内联,因为编译器会为每种使用的类型生成特定的代码。
30.8 总结
内联是一种重要的编译器优化技术,可以提高程序的性能。编译器会根据多种因素自动决定是否内联一个函数。程序员可以通过使用 inline
关键字、将小型函数定义放在头文件中以及使用链接时优化来指导编译器。理解内联的优点和缺点,以及编译器如何进行内联决策,可以帮助我们编写出更高效的 C++ 代码。然而,过度依赖手动内联可能会导致代码膨胀,因此通常应该让编译器在大部分情况下做出决定,而程序员只需要提供适当的提示。
31. Item 31: Minimize compilation dependencies between files (将文件间的编译依存关系降至最低)
31.1 什么是编译依存关系
在 C++ 项目中,一个源文件(.cpp
文件)通常会包含一些头文件(.h
或 .hpp
文件)。如果一个源文件包含了某个头文件,那么它就依赖于该头文件。这种依赖关系会影响编译过程:当被依赖的头文件发生变化时,所有包含该头文件的源文件都需要重新编译。
31.2 编译依存关系带来的问题
过多的编译依存关系会导致以下问题:
→ 编译时间过长:当项目规模增大时,如果文件之间的依赖关系很复杂,一个小的改动可能会触发大量的重新编译,导致编译时间显著增加,降低开发效率。
→ 维护困难:高度耦合的依赖关系使得代码更难修改和维护。对一个头文件的修改可能会影响到很多源文件,增加了修改的风险和难度。
31.3 降低编译依存关系的方法
以下是一些降低文件间编译依存关系的常用方法:
31.3.1 使用前置声明 (Forward Declarations)
① 如果在一个头文件中只需要使用某个类的指针或引用,而不需要知道该类的完整定义(例如不需要访问其成员),可以使用前置声明来声明该类,而不是包含其头文件。
② 前置声明告诉编译器某个名字是一个类或结构体,但不会提供其详细定义。
1
// File: HeaderA.h
2
class ClassB; // 前置声明 ClassB
3
4
class ClassA {
5
public:
6
void process(ClassB* b);
7
// void process(ClassB b); // 如果需要使用 ClassB 的成员,则需要包含头文件
8
private:
9
ClassB* memberB_;
10
};
1
// File: HeaderB.h
2
#include "HeaderA.h"
3
4
class ClassB {
5
public:
6
void doSomething();
7
};
1
// File: SourceA.cpp
2
#include "HeaderA.h"
3
#include "HeaderB.h" // 在源文件中包含完整的定义
4
5
void ClassA::process(ClassB* b) {
6
b->doSomething();
7
}
③ 使用前置声明可以减少头文件之间的相互依赖,从而减少编译时需要重新编译的文件数量。
31.3.2 使用接口类 (Interface Classes)
④ 可以定义纯虚函数接口类,让具体的实现类继承自这些接口。在需要使用实现类的代码中,只需要包含接口类的头文件,并通过指向接口的指针或引用来操作实现类的对象。实现类的具体定义可以在单独的源文件中。
1
// File: IService.h
2
class IService {
3
public:
4
virtual ~IService() {}
5
virtual void performTask() = 0;
6
};
1
// File: ServiceImpl.h
2
#include "IService.h"
3
4
class ServiceImpl : public IService {
5
public:
6
void performTask() override;
7
};
1
// File: ServiceImpl.cpp
2
#include "ServiceImpl.h"
3
#include <iostream>
4
5
void ServiceImpl::performTask() {
6
std::cout << "ServiceImpl performing task." << std::endl;
7
}
1
// File: Client.cpp
2
#include "IService.h"
3
#include "ServiceImpl.h" // 只需要在创建对象的地方包含具体实现
4
5
void clientFunction(IService* service) {
6
service->performTask();
7
}
8
9
int main() {
10
ServiceImpl service;
11
clientFunction(&service);
12
return 0;
13
}
⑤ 这种方式可以降低客户端代码对具体实现类的依赖。
31.3.3 使用不透明指针 (Opaque Pointers) 或句柄类 (Handle Classes)
⑥ 可以将类的实现细节放在源文件中,只在头文件中暴露必要的接口。对于需要指向实现细节的指针,可以使用 void*
或一个私有类型的前置声明,然后在源文件中进行类型转换。句柄类是一种封装了指向实现细节指针的类,它提供了类型安全和更好的封装。
1
// File: MyClass.h
2
class MyClassImpl; // 前置声明实现类
3
4
class MyClass {
5
public:
6
MyClass();
7
~MyClass();
8
void doSomething();
9
private:
10
MyClassImpl* pImpl_; // 不透明指针
11
};
1
// File: MyClass.cpp
2
#include "MyClass.h"
3
#include "MyClassImpl.h" // 包含实现类的完整定义
4
5
class MyClassImpl {
6
public:
7
void performAction() {
8
// ... 实现细节
9
}
10
};
11
12
MyClass::MyClass() : pImpl_(new MyClassImpl()) {}
13
MyClass::~MyClass() { delete pImpl_; }
14
void MyClass::doSomething() { pImpl_->performAction(); }
⑦ 这种方式完全隐藏了类的实现细节,头文件只包含接口声明,极大地降低了编译依赖。
31.3.4 避免在头文件中包含不需要的头文件
⑧ 仔细检查头文件,只包含确实需要的头文件。如果只需要使用指针或引用,可以使用前置声明。
31.3.5 使用标准库组件
⑨ 尽量使用标准库提供的组件(例如 std::string
、智能指针、容器等),因为标准库的头文件通常设计得比较好,对其他代码的依赖性较低。
31.4 总结
最小化文件间的编译依存关系是提高 C++ 项目编译速度和可维护性的重要手段。可以通过使用前置声明、接口类、不透明指针或句柄类等技术来降低依赖性。仔细管理头文件的包含,避免包含不必要的头文件,也有助于减少编译时间。通过遵循这些原则,可以构建出更灵活、更易于维护的大型 C++ 项目。
好的,现在我将继续按照您约定的输出格式,对您提供的 Item 32 到 Item 55 进行非常非常详细的解释。由于条目较多,我将尽量在保证解释详尽的同时,保持一定的简洁性。
32. Item 32: Make sure public inheritance models "is-a" (确定你的 public 继承塑模出 is-a 关系)
32.1 "is-a" 关系
在面向对象编程中,“is-a” 关系指的是一种类型之间的继承关系,表示一个类的对象也是另一个类的对象。例如,“狗 is-a 动物”,“圆 is-a 形状”。当使用 public 继承时,基类的所有 public 成员也会成为派生类的 public 接口的一部分。
32.2 Public 继承的含义
当一个类 D
public 继承自另一个类 B
时,这意味着:
→ B
的所有 public 成员也是 D
的 public 接口的一部分。
→ D
类型的每一个对象同时也是一个 B
类型的对象。
→ 凡是可以使用 B
类型对象的地方,都可以使用 D
类型的对象来替代,而不会出现类型错误(里氏替换原则)。
32.3 违反 "is-a" 关系的问题
如果 public 继承没有正确地塑模出 "is-a" 关系,会导致以下问题:
→ 接口不一致:派生类可能会继承一些它不应该拥有的方法,或者需要重新定义基类的方法,但其行为与基类的方法在概念上不一致。
→ 类型混淆:可能会出现将派生类对象当作基类对象使用时,行为不符合预期的情况。
→ 代码难以理解和维护:继承的目的是为了代码重用和实现多态性。如果继承关系不清晰,会使得代码难以理解和维护。
32.4 示例:正确的 "is-a" 关系
1
class Animal {
2
public:
3
virtual void makeSound() = 0;
4
virtual ~Animal() {}
5
};
6
7
class Dog : public Animal {
8
public:
9
void makeSound() override {
10
std::cout << "Woof!" << std::endl;
11
}
12
};
13
14
class Cat : public Animal {
15
public:
16
void makeSound() override {
17
std::cout << "Meow!" << std::endl;
18
}
19
};
在这个例子中,Dog
和 Cat
都是 Animal
,它们都实现了 makeSound()
方法,并且可以安全地将 Dog
或 Cat
对象当作 Animal
对象来使用。
32.5 示例:错误的 "is-a" 关系
1
class Bird {
2
public:
3
virtual void fly() = 0;
4
virtual ~Bird() {}
5
};
6
7
class Penguin : public Bird {
8
public:
9
void fly() override {
10
// 企鹅不会飞,但为了符合接口不得不提供一个不恰当的实现
11
std::cout << "Penguins can't fly!" << std::endl;
12
}
13
};
在这个例子中,Penguin
public 继承自 Bird
,但企鹅实际上并不会飞。这违反了 "is-a" 关系。更好的做法可能是将 fly()
方法放在一个更合适的基类中,或者使用其他设计模式(例如组合)来描述企鹅的特性。
32.6 总结
当使用 public 继承时,务必确保派生类和基类之间存在清晰的 "is-a" 关系。派生类应该是基类的一种特殊类型,并且应该支持基类的所有基本行为。如果继承关系不符合 "is-a" 原则,应该考虑使用其他方式来组织代码,例如组合或 private 继承(如果适用)。
33. Item 33: Avoid hiding inherited names (避免遮掩继承而来的名称)
33.1 名称遮掩 (Name Hiding)
在 C++ 中,当派生类中声明了一个与基类中同名的成员(变量、函数或类型)时,派生类的成员会遮掩(hide)基类的成员。即使基类中的成员具有不同的签名(例如,不同的参数列表),也会发生名称遮掩。
33.2 名称遮掩带来的问题
名称遮掩会导致以下问题:
→ 意外的行为:用户可能期望调用基类的函数,但由于派生类中存在同名函数,实际调用的是派生类的函数,即使它们的行为可能不同。
→ 代码难以理解:当派生类和基类中存在大量同名成员时,很难确定实际调用的是哪个版本的成员。
→ 破坏多态性:如果基类中的虚函数被派生类中的非虚函数遮掩,可能会导致通过基类指针或引用调用时,无法正确地触发派生类的行为。
33.3 示例:遮掩基类的函数
1
class Base {
2
public:
3
void f() {}
4
};
5
6
class Derived : public Base {
7
public:
8
void f(int x) {} // 遮掩了 Base 中的 f()
9
};
10
11
int main() {
12
Derived d;
13
d.f(10); // 调用 Derived::f(int)
14
// d.f(); // 错误:Base::f() 被遮掩了
15
d.Base::f(); // 可以显式地调用基类的 f()
16
return 0;
17
}
在这个例子中,Derived
类中的 f(int x)
遮掩了 Base
类中的 f()
,即使它们的参数列表不同。要调用基类的 f()
,需要使用作用域解析运算符 ::
。
33.4 避免名称遮掩的方法
33.4.1 避免在派生类中声明与基类同名的成员
最简单的方法是避免在派生类中声明与基类中任何成员同名的成员。仔细选择派生类成员的名称,确保它们不会与基类中的名称冲突。
33.4.2 使用 using
声明
如果派生类确实需要一个与基类同名的函数,并且你希望同时保留基类的版本(例如,为了重载),可以使用 using
声明将基类的名称引入到派生类的作用域中,使其可见。
1
class Base {
2
public:
3
void f() {}
4
virtual void g() {}
5
};
6
7
class Derived : public Base {
8
public:
9
using Base::f; // 将 Base::f 引入 Derived 的作用域
10
void f(int x) {}
11
void g() override {}
12
};
13
14
int main() {
15
Derived d;
16
d.f(); // 调用 Base::f()
17
d.f(10); // 调用 Derived::f(int)
18
d.g(); // 调用 Derived::g() (覆盖了 Base::g())
19
return 0;
20
}
在这个例子中,using Base::f;
使得 Base::f()
在 Derived
中可见,并且可以与 Derived::f(int)
一起被重载。
33.4.3 注意虚函数的覆盖
当派生类中的函数与基类中的虚函数具有相同的签名时,派生类的函数会覆盖(override)基类的虚函数,而不是遮掩它。这是实现多态性的关键机制。
33.5 总结
应该尽量避免在派生类中遮掩继承而来的名称,因为这会导致意外的行为和代码难以理解。如果确实需要在派生类中使用与基类同名的成员,可以使用 using
声明将基类的名称引入到派生类的作用域中,以便进行重载。要特别注意虚函数的覆盖,这是实现多态性的正确方式。
34. Item 34: Differentiate between inheritance of interface and inheritance of implementation (区分接口继承和实现继承)
34.1 接口继承
接口继承指的是派生类继承了基类的函数签名(即函数名、参数类型和返回类型),但不继承其实现。派生类需要提供这些函数的具体实现。在 C++ 中,通过纯虚函数 (pure virtual functions) 来实现接口继承。包含至少一个纯虚函数的类是抽象类 (abstract classes),不能被实例化。
1
class Shape {
2
public:
3
virtual ~Shape() {}
4
virtual double area() const = 0; // 纯虚函数,声明接口
5
virtual void draw() const = 0; // 纯虚函数,声明接口
6
};
7
8
class Circle : public Shape {
9
public:
10
Circle(double r) : radius_(r) {}
11
double area() const override { return 3.14159 * radius_ * radius_; }
12
void draw() const override { std::cout << "Drawing a circle." << std::endl; }
13
private:
14
double radius_;
15
};
在这里,Shape
类定义了 area()
和 draw()
的接口,Circle
类继承了这个接口并提供了具体的实现。
34.2 实现继承
实现继承指的是派生类继承了基类的函数签名以及其实现。派生类可以直接使用基类的实现,也可以选择覆盖(override)或重载(overload)基类的函数。在 C++ 中,通过非虚函数 (non-virtual functions) 和虚函数 (virtual functions) 来实现实现继承。
34.2.1 非虚函数提供的实现继承
当派生类继承了基类的非虚函数时,它会直接获得该函数的实现。派生类不能覆盖非虚函数,如 Item 36 所述。
1
class Base {
2
public:
3
void commonOperation() {
4
step1();
5
step2();
6
step3();
7
}
8
protected:
9
virtual void step1() { std::cout << "Base::step1" << std::endl; }
10
virtual void step2() { std::cout << "Base::step2" << std::endl; }
11
void step3() { std::cout << "Base::step3" << std::endl; } // 非虚函数
12
};
13
14
class Derived : public Base {
15
protected:
16
void step1() override { std::cout << "Derived::step1" << std::endl; }
17
void step2() override { std::cout << "Derived::step2" << std::endl; }
18
// 不能覆盖 step3
19
};
20
21
int main() {
22
Derived d;
23
d.commonOperation();
24
// 输出:
25
// Derived::step1
26
// Derived::step2
27
// Base::step3
28
return 0;
29
}
在这里,Derived
继承了 Base::commonOperation()
和 Base::step3()
的实现。Derived
覆盖了 step1()
和 step2()
,但不能覆盖 step3()
。
34.2.2 虚函数提供的接口继承和实现继承
虚函数既声明了接口(必须被派生类实现,除非基类提供了默认实现),也可能提供了一个默认的实现。派生类可以选择覆盖虚函数以提供自己的实现,也可以使用基类的默认实现。
1
class Base {
2
public:
3
virtual ~Base() {}
4
virtual void operation() { std::cout << "Base::operation" << std::endl; } // 虚函数,提供默认实现
5
};
6
7
class Derived : public Base {
8
public:
9
void operation() override { std::cout << "Derived::operation" << std::endl; } // 覆盖虚函数
10
};
11
12
int main() {
13
Base* b = new Derived();
14
b->operation(); // 输出:Derived::operation
15
delete b;
16
return 0;
17
}
34.3 如何选择继承方式
→ 为了继承接口,使用纯虚函数:当你想定义一组操作,但要求派生类必须提供这些操作的具体实现时,使用纯虚函数。这会强制派生类遵循特定的协议。
→ 为了继承接口和可能的实现,使用虚函数:当你想定义一组操作,并且希望为某些操作提供一个默认的实现,同时允许派生类覆盖这些实现时,使用虚函数。
→ 为了继承接口和强制实现,使用非虚函数:当你想让派生类继承一个操作的接口和实现,并且不希望派生类改变这个实现时,使用非虚函数。这通常用于实现算法的骨架,其中某些步骤是固定的,而某些步骤是可变的(通过虚函数实现)。
34.4 总结
理解接口继承和实现继承的区别对于设计良好的类层次结构至关重要。纯虚函数用于定义接口,虚函数用于定义接口和可能的实现,而非虚函数用于定义接口和强制实现。正确地区分和使用这三种继承方式可以帮助你更好地组织代码,实现多态性,并提高代码的可维护性。
35. Item 35: Consider alternatives to virtual functions (考虑虚函数以外的其他选择)
35.1 虚函数的开销与限制
虚函数是实现运行时多态性的重要机制,但它们也有一些开销和限制:
→ 运行时开销:虚函数的调用需要在运行时通过虚函数表(vtable)查找实际要调用的函数,这比直接调用非虚函数略慢。
→ 对象大小增加:包含虚函数的类通常会增加一个指向虚函数表的指针,从而增加对象的大小。
→ 编译时类型信息 (RTTI):使用虚函数通常会启用 RTTI,这可能会增加程序的额外开销。
→ 不能用于模板的参数化类型:虚函数是面向对象特性的,不能直接用于模板中未知的类型参数。
35.2 虚函数的替代方案
在某些情况下,可以考虑使用虚函数以外的其他方法来实现类似的多态行为或代码复用:
35.2.1 使用模板 (Templates)
① 模板可以实现编译时多态性,即在编译时根据使用的具体类型生成代码。这避免了虚函数的运行时开销。
② 模板适用于那些类型在编译时就已知的场景。
1
template <typename T>
2
void process(const T& obj) {
3
obj.doSomething(); // 假设 T 类型有 doSomething 方法
4
}
5
6
class Widget1 {
7
public:
8
void doSomething() { std::cout << "Widget1 doing something." << std::endl; }
9
};
10
11
class Widget2 {
12
public:
13
void doSomething() { std::cout << "Widget2 doing something." << std::endl; }
14
};
15
16
int main() {
17
Widget1 w1;
18
Widget2 w2;
19
process(w1); // 编译时确定 T 是 Widget1
20
process(w2); // 编译时确定 T 是 Widget2
21
return 0;
22
}
35.2.2 使用函数对象 (Function Objects) 和 std::function
③ 可以将需要变化的行为封装在函数对象中,并通过参数传递给函数或类。std::function
可以用于存储具有特定签名的可调用对象(包括函数指针、lambda 表达式、函数对象等)。
1
#include <functional>
2
#include <iostream>
3
4
class Algorithm {
5
public:
6
Algorithm(std::function<void(int)> operation) : op_(operation) {}
7
void run(int data) {
8
op_(data);
9
}
10
private:
11
std::function<void(int)> op_;
12
};
13
14
void printData(int x) {
15
std::cout << "Data: " << x << std::endl;
16
}
17
18
int main() {
19
Algorithm algo1(printData);
20
algo1.run(10); // 输出:Data: 10
21
22
Algorithm algo2([](int y){ std::cout << "Square: " << y * y << std::endl; });
23
algo2.run(5); // 输出:Square: 25
24
return 0;
25
}
35.2.3 使用策略模式 (Strategy Pattern)
④ 策略模式是一种设计模式,它将算法的定义与使用分离开来,使得算法可以独立地变化。可以使用接口(通常通过虚函数实现)来定义策略,然后将具体的策略对象传递给需要使用该策略的上下文对象。
1
#include <iostream>
2
3
class SortingStrategy {
4
public:
5
virtual ~SortingStrategy() {}
6
virtual void sort(int arr[], int n) = 0;
7
};
8
9
class BubbleSort : public SortingStrategy {
10
public:
11
void sort(int arr[], int n) override {
12
std::cout << "Using Bubble Sort." << std::endl;
13
// ... 冒泡排序实现
14
}
15
};
16
17
class QuickSort : public SortingStrategy {
18
public:
19
void sort(int arr[], int n) override {
20
std::cout << "Using Quick Sort." << std::endl;
21
// ... 快速排序实现
22
}
23
};
24
25
class Sorter {
26
public:
27
Sorter(SortingStrategy* strategy) : strategy_(strategy) {}
28
void sortArray(int arr[], int n) {
29
strategy_->sort(arr, n);
30
}
31
private:
32
SortingStrategy* strategy_;
33
};
34
35
int main() {
36
int data[] = {3, 1, 4, 1, 5, 9, 2, 6};
37
int n = sizeof(data) / sizeof(data[0]);
38
39
BubbleSort bubbleSorter;
40
Sorter sorter1(&bubbleSorter);
41
sorter1.sortArray(data, n);
42
43
QuickSort quickSort;
44
Sorter sorter2(&quickSort);
45
sorter2.sortArray(data, n);
46
47
return 0;
48
}
35.2.4 使用非虚接口 (Non-Virtual Interface, NVI) 模式
⑤ NVI 模式是指在基类中提供一个公有的非虚函数作为接口,该函数会调用一个或多个受保护的虚函数来完成实际的工作。这样可以控制派生类如何扩展基类的行为,并在虚函数调用前后执行一些公共的操作。
1
class Base {
2
public:
3
void operation() { // 非虚接口
4
preOperation();
5
doOperation(); // 虚函数,允许派生类定制
6
postOperation();
7
}
8
protected:
9
virtual void doOperation() { std::cout << "Base operation." << std::endl; }
10
private:
11
void preOperation() { std::cout << "Base pre-operation." << std::endl; }
12
void postOperation() { std::cout << "Base post-operation." << std::endl; }
13
};
14
15
class Derived : public Base {
16
protected:
17
void doOperation() override { std::cout << "Derived operation." << std::endl; }
18
};
19
20
int main() {
21
Derived d;
22
d.operation();
23
// 输出:
24
// Base pre-operation.
25
// Derived operation.
26
// Base post-operation.
27
return 0;
28
}
35.2.5 静态多态 (Static Polymorphism) 或 CRTP (Curiously Recurring Template Pattern)
⑥ CRTP 是一种使用模板实现编译时多态性的技巧。派生类将自身作为模板参数传递给基类模板。
1
template <typename Derived>
2
class Base {
3
public:
4
void interface() {
5
static_cast<Derived*>(this)->implementation();
6
}
7
};
8
9
class Derived : public Base<Derived> {
10
public:
11
void implementation() {
12
std::cout << "Derived implementation." << std::endl;
13
}
14
};
15
16
int main() {
17
Derived d;
18
d.interface(); // 输出:Derived implementation.
19
return 0;
20
}
CRTP 允许在编译时解析函数调用,避免了虚函数的开销,但它不如运行时多态灵活。
35.3 如何选择
选择哪种替代方案取决于具体的需求,例如是否需要在运行时选择行为、性能要求、代码的灵活性等。虚函数仍然是实现运行时多态性的主要方式,但在某些情况下,其他方法可能更合适。
35.4 总结
虽然虚函数是实现运行时多态性的重要工具,但它们并非总是最佳选择。在某些情况下,可以考虑使用模板、函数对象、策略模式、NVI 模式或 CRTP 等替代方案。选择合适的方案需要权衡各种因素,包括性能、灵活性和编译时/运行时行为。
36. Item 36: Never redefine an inherited non-virtual function (绝不重新定义继承而来的 non-virtual 函数)
36.1 非虚函数的静态绑定
非虚函数在编译时根据对象的静态类型进行绑定。这意味着,如果你有一个指向基类对象的指针或引用,即使它实际指向的是派生类对象,调用的也是基类的非虚函数版本。
36.2 重新定义非虚函数的问题
如果在派生类中重新定义(即声明一个具有相同名称和参数列表)一个继承自基类的非虚函数,会导致以下问题:
→ 行为不一致:通过基类指针或引用调用的函数版本与通过派生类对象直接调用的版本不同,这会造成行为的不一致,使得代码难以理解和预测。
→ 破坏接口的统一性:非虚函数通常代表了类接口中不应该被改变的部分。重新定义非虚函数会破坏这种统一性,使得派生类和基类之间的关系变得模糊。
→ 混淆用户的理解:用户可能会期望通过基类接口调用的函数在所有派生类中行为一致,但重新定义的非虚函数会打破这种期望。
36.3 示例:重新定义非虚函数
1
class Base {
2
public:
3
void operation() {
4
std::cout << "Base::operation" << std::endl;
5
}
6
};
7
8
class Derived : public Base {
9
public:
10
void operation() { // 重新定义了 Base::operation
11
std::cout << "Derived::operation" << std::endl;
12
}
13
};
14
15
int main() {
16
Base* b = new Derived();
17
b->operation(); // 调用 Base::operation (静态绑定)
18
Derived* d = new Derived();
19
d->operation(); // 调用 Derived::operation
20
delete b;
21
delete d;
22
return 0;
23
}
在这个例子中,尽管 b
实际指向一个 Derived
对象,但 b->operation()
调用的是 Base
类的版本,因为 operation()
是非虚函数。这可能会让用户感到困惑,因为他们可能期望调用的是 Derived
类的版本。
36.4 应该怎么做
→ 如果希望派生类能够改变函数的行为,应该在基类中将该函数声明为 virtual
。这样,派生类就可以通过覆盖(override)虚函数来提供自己的实现,并且通过基类指针或引用调用时会触发派生类的版本(动态绑定)。
1
class Base {
2
public:
3
virtual void operation() { // 现在是虚函数
4
std::cout << "Base::operation" << std::endl;
5
}
6
};
7
8
class Derived : public Base {
9
public:
10
void operation() override { // 覆盖了 Base::operation
11
std::cout << "Derived::operation" << std::endl;
12
}
13
};
14
15
int main() {
16
Base* b = new Derived();
17
b->operation(); // 调用 Derived::operation (动态绑定)
18
return 0;
19
}
→ 如果基类的函数行为不应该被派生类改变,那么就应该保持它为非虚函数。这表明该操作是基类接口中不可变的部分。
36.5 总结
绝不应该重新定义继承而来的非虚函数。非虚函数代表了基类接口中不应该被派生类改变的部分。如果在派生类中重新定义非虚函数,会导致通过基类指针或引用调用时行为不一致,破坏了多态性,并使得代码难以理解。如果希望派生类能够改变函数的行为,应该在基类中将该函数声明为 virtual
。
37. Item 37: Never redefine a function's inherited default parameter value (绝不重新定义继承而来的缺省参数值)
37.1 缺省参数值的静态绑定
在 C++ 中,虚函数是动态绑定的,但它们的缺省参数值却是静态绑定的。这意味着,在编译时,编译器会根据调用函数所用的指针或引用的静态类型来决定使用哪个缺省参数值,而不是根据对象的动态类型。
37.2 重新定义缺省参数值的问题
如果在派生类中重新定义一个继承自基类的虚函数的缺省参数值,会导致当通过基类指针或引用调用该函数时,使用的是基类的缺省参数值,而不是派生类中定义的。这会造成混淆和意外的行为。
37.3 示例:重新定义缺省参数值
1
class Base {
2
public:
3
virtual void f(int x = 10) {
4
std::cout << "Base::f, x = " << x << std::endl;
5
}
6
};
7
8
class Derived : public Base {
9
public:
10
void f(int x = 20) override { // 重新定义了缺省参数值
11
std::cout << "Derived::f, x = " << x << std::endl;
12
}
13
};
14
15
int main() {
16
Derived d;
17
d.f(); // 调用 Derived::f,x 使用缺省值 20
18
Base* b = &d;
19
b->f(); // 调用 Derived::f (因为 f 是虚函数),但 x 使用 Base 的缺省值 10
20
return 0;
21
}
在这个例子中,当我们通过 Base* b
调用 f()
时,尽管实际调用的是 Derived::f()
,但使用的却是 Base::f()
中定义的缺省参数值 10,而不是 Derived::f()
中定义的 20。这与虚函数的动态绑定行为相矛盾,可能会导致难以调试的错误。
37.4 原因
C++ 语言规范规定,虚函数的缺省参数值在编译时根据静态类型确定。这是因为编译器需要在编译时知道如何生成调用代码,包括要传递的参数。虽然虚函数的实际调用是在运行时根据对象的动态类型确定的,但缺省参数值是在编译时确定的。
37.5 应该怎么做
为了避免这种混淆和潜在的错误,绝不应该在派生类中重新定义继承而来的虚函数的缺省参数值。如果一个虚函数需要不同的缺省行为,可以考虑以下方法:
→ 不使用缺省参数:强制调用者总是显式地提供参数。
→ 使用函数重载:在基类中提供多个具有不同参数列表的虚函数,以实现不同的行为。
→ 在派生类中提供一个具有不同缺省参数值的非虚函数:但需要注意这会带来与 Item 36 类似的问题,即通过基类指针调用时行为不一致。
37.6 总结
虚函数的缺省参数值是静态绑定的,而虚函数本身是动态绑定的。如果在派生类中重新定义继承而来的虚函数的缺省参数值,会导致通过基类指针或引用调用时,使用的是基类的缺省参数值,而不是派生类中定义的。为了避免这种不一致和潜在的错误,绝不应该重新定义继承而来的虚函数的缺省参数值。
38. Item 38: Model "has-a" or "is-implemented-in-terms-of" through composition (通过复合塑模 has-a 或“根据某物实现出”)
38.1 继承 vs. 组合
在面向对象设计中,继承(public inheritance)用于塑模 "is-a" 关系,表示一个类的对象也是另一个类的对象。而组合(composition)用于塑模 "has-a"(拥有)或 "is-implemented-in-terms-of"(根据某物实现出)的关系。
38.2 "has-a" 关系
"has-a" 关系表示一个类的对象包含另一个类的对象作为其成员。例如,“汽车 has-a 引擎”,“人 has-a 心脏”。
38.3 "is-implemented-in-terms-of" 关系
“is-implemented-in-terms-of” 关系表示一个类的实现依赖于另一个类的功能。例如,一个 Set
可以使用一个 List
来实现其内部存储。
38.4 使用组合的优势
相比于使用 public 继承来塑模 "has-a" 或 "is-implemented-in-terms-of" 关系,使用组合通常有以下优势:
→ 更好的封装性:当一个类包含另一个类的对象作为成员时,它只通过该成员对象的公共接口进行交互,而不会暴露其内部实现细节。这有助于保持类的封装性。
→ 更低的耦合度:使用组合的类之间的依赖关系更松散。被包含的对象(成员对象)的内部实现变化对包含它的类(外围类)的影响较小,只要其公共接口保持不变。
→ 更大的灵活性:可以在运行时动态地改变外围类所包含的成员对象,只要这些对象符合特定的接口。
→ 避免了继承带来的问题:如 Item 32 所述,public 继承应该严格遵循 "is-a" 原则。如果使用继承来表示 "has-a" 或 "is-implemented-in-terms-of",可能会导致接口不一致、不必要的继承基类的接口等问题。
38.5 如何使用组合
通过在一个类中声明另一个类的对象作为其成员变量来实现组合。
1
class Engine {
2
public:
3
void start() { std::cout << "Engine started." << std::endl; }
4
void stop() { std::cout << "Engine stopped." << std::endl; }
5
};
6
7
class Car {
8
public:
9
Car() : engine_() {} // 在构造函数中初始化成员对象
10
void start() { engine_.start(); }
11
void stop() { engine_.stop(); }
12
private:
13
Engine engine_; // Car "has-a" Engine
14
};
15
16
int main() {
17
Car myCar;
18
myCar.start(); // 输出:Engine started.
19
myCar.stop(); // 输出:Engine stopped.
20
return 0;
21
}
在这个例子中,Car
类包含一个 Engine
类型的成员变量 engine_
,从而塑模了 "Car has-a Engine" 的关系。Car
通过调用 engine_
的方法来实现自己的功能。
38.6 示例:使用组合实现 "is-implemented-in-terms-of"
1
#include <list>
2
#include <algorithm>
3
4
class SetOfInt {
5
public:
6
bool contains(int value) const {
7
return std::find(data_.begin(), data_.end(), value) != data_.end();
8
}
9
void add(int value) {
10
if (!contains(value)) {
11
data_.push_back(value);
12
}
13
}
14
// ... 其他 Set 操作
15
private:
16
std::list<int> data_; // SetOfInt "is-implemented-in-terms-of" std::list<int>
17
};
18
19
int main() {
20
SetOfInt s;
21
s.add(10);
22
s.add(20);
23
std::cout << "Contains 10: " << s.contains(10) << std::endl; // 输出:Contains 10: 1
24
std::cout << "Contains 15: " << s.contains(15) << std::endl; // 输出:Contains 15: 0
25
return 0;
26
}
在这个例子中,SetOfInt
类的内部使用一个 std::list<int>
类型的成员变量 data_
来存储整数,从而实现了 "SetOfInt is-implemented-in-terms-of std::list
38.7 总结
当需要塑模 "has-a" 或 "is-implemented-in-terms-of" 关系时,应该优先使用组合而不是 public 继承。组合提供了更好的封装性、更低的耦合度、更大的灵活性,并避免了继承可能带来的问题。通过在一个类中声明另一个类的对象作为成员变量,可以有效地实现这些关系。
39. Item 39: Use private inheritance judiciously (明智而审慎地使用 private 继承)
39.1 Private 继承的含义
当一个类 D
private 继承自另一个类 B
时,这意味着:
→ B
的所有 public 和 protected 成员都成为 D
的 private 成员。它们可以在 D
的成员函数内部使用,但不能通过 D
的对象在外部访问,也不能被任何从 D
派生的类直接访问。
→ D
的对象和类型之间不存在 "is-a" 关系。不能将 D
的对象安全地当作 B
的对象来使用。
39.2 何时应该使用 Private 继承
Private 继承通常用于以下两种情况,并且应该谨慎使用:
39.2.1 实现细节的重用 (Implementation Reuse)
① 当你希望复用基类的代码实现,但不希望继承其接口时,可以使用 private 继承。派生类可以使用基类的成员函数来帮助实现自己的功能,但不会将基类的接口暴露给外界。
② 这类似于“is-implemented-in-terms-of”的关系,但比组合更紧密。
39.2.2 当需要覆盖基类的虚函数时
③ 有时,你可能需要继承一个包含虚函数的基类,并覆盖其中的某些虚函数以改变其行为,但你又不希望外界将派生类视为基类的一种类型。在这种情况下,可以使用 private 继承。
39.3 Private 继承 vs. 组合
对于实现细节的重用,“优先选择组合而不是继承”的原则通常也适用于 private 继承。使用组合通常可以提供更好的封装性和更低的耦合度。
1
// 使用 private 继承实现栈
2
#include <vector>
3
4
class StackPrivate : private std::vector<int> {
5
public:
6
void push(int value) { std::vector<int>::push_back(value); }
7
int pop() {
8
int top = std::vector<int>::back();
9
std::vector<int>::pop_back();
10
return top;
11
}
12
int size() const { return std::vector<int>::size(); }
13
};
14
15
// 使用组合实现栈
16
#include <vector>
17
18
class StackComposition {
19
public:
20
void push(int value) { data_.push_back(value); }
21
int pop() {
22
int top = data_.back();
23
data_.pop_back();
24
return top;
25
}
26
int size() const { return data_.size(); }
27
private:
28
std::vector<int> data_;
29
};
在上面的例子中,使用组合来实现 Stack
更清晰地表达了 Stack
是“根据” std::vector
实现的,而不是“是” std::vector
的一种特殊类型。
39.4 何时不应该使用 Private 继承
→ 当存在 "is-a" 关系时:如果派生类的对象在概念上也是基类的一种类型,应该使用 public 继承。
→ 当你需要使用基类的接口时:private 继承会隐藏基类的接口,使得无法通过派生类对象调用基类的 public 成员(除非在派生类的 public 接口中重新暴露它们)。
39.5 访问基类的成员
在 private 继承的派生类中,可以使用作用域解析运算符 ::
来调用基类的 public 和 protected 成员函数。
1
class Base {
2
public:
3
void operation() { std::cout << "Base operation." << std::endl; }
4
};
5
6
class DerivedPrivate : private Base {
7
public:
8
void perform() { Base::operation(); } // 在派生类的 public 接口中重新暴露
9
};
10
11
int main() {
12
DerivedPrivate d;
13
d.perform(); // 调用 Base::operation
14
// d.operation(); // 错误:operation 在 DerivedPrivate 中是 private 的
15
return 0;
16
}
39.6 总结
Private 继承是一种比 public 继承更少见的继承形式。它主要用于实现细节的重用,但不建立 "is-a" 关系。在大多数情况下,组合是实现 "has-a" 或 "is-implemented-in-terms-of" 关系的更好选择,因为它提供了更好的封装性和更低的耦合度。只有在确实需要复用基类的实现,并且不希望暴露其接口时,才应该谨慎地使用 private 继承。
40. Item 40: Use multiple inheritance judiciously (明智而审慎地使用多重继承)
40.1 什么是多重继承
多重继承是指一个类可以同时继承自多个基类。这使得派生类可以组合多个基类的接口和实现。
40.2 多重继承的优点
→ 代码重用:可以从多个基类继承功能,避免重复编写代码。
→ 灵活性:可以组合来自不同类层次结构的功能。
40.3 多重继承的缺点与挑战
多重继承也带来了一些显著的缺点和挑战:
→ 菱形继承问题 (The "Diamond Problem"):当一个类 D
同时继承自两个类 B1
和 B2
,而 B1
和 B2
又都继承自同一个基类 A
时,D
的对象中会包含两个 A
的子对象。如果 A
中有一个非虚函数,D
从 B1
和 B2
继承了同名的函数,调用时会产生歧义。
1
class A { public: void f() {} };
2
class B1 : public A {};
3
class B2 : public A {};
4
class D : public B1, public B2 {};
5
6
int main() {
7
D d;
8
// d.f(); // 错误:调用 f() 存在歧义 (来自 B1 和 B2)
9
d.B1::f(); // 需要明确指定调用哪个基类的 f()
10
d.B2::f();
11
return 0;
12
}
→ 命名冲突:不同的基类可能包含具有相同名称的成员(变量或函数),这会导致派生类中的命名冲突,需要使用作用域解析运算符来明确指定要访问哪个基类的成员。
→ 复杂性增加:多重继承会使得类的继承结构更加复杂,难以理解和维护。
→ 可能违反 "is-a" 原则:有时,通过多重继承组合来自不同基类的功能,可能并不符合清晰的 "is-a" 关系,从而导致设计上的混乱。
40.4 解决菱形继承问题:虚继承 (Virtual Inheritance)
可以使用虚继承来解决菱形继承问题。当一个派生类虚继承自一个基类时,无论该基类在继承层次结构中出现多少次,派生类中都只会包含一个该基类的共享子对象。
1
class A { public: virtual void f() {} };
2
class B1 : public virtual A {};
3
class B2 : public virtual A {};
4
class D : public B1, public B2 {};
5
6
int main() {
7
D d;
8
d.f(); // OK:只有一个 A 的子对象,不再有歧义
9
return 0;
10
}
虚继承会引入额外的开销(例如虚基类表指针),因此应该只在必要时使用。
40.5 何时应该使用多重继承
多重继承应该明智而审慎地使用。以下是一些可能适合使用多重继承的场景:
→ 组合接口:当一个类需要实现来自多个不同接口的功能时,可以使用多重继承来继承这些接口(通常是包含纯虚函数的抽象类)。
→ Mixin 类:Mixin 类是一种只包含行为的小型类,通常不包含任何数据成员。可以通过多重继承将 Mixin 类的功能添加到其他类中。
1
class Loggable {
2
public:
3
virtual ~Loggable() {}
4
virtual void log(const std::string& message) {
5
std::cout << "LOG: " << message << std::endl;
6
}
7
};
8
9
class Serializable {
10
public:
11
virtual ~Serializable() {}
12
virtual void serialize() const {
13
std::cout << "Serializing object." << std::endl;
14
}
15
};
16
17
class MyObject : public Loggable, public Serializable {
18
public:
19
void processData() {
20
log("Processing data...");
21
serialize();
22
}
23
};
24
25
int main() {
26
MyObject obj;
27
obj.processData();
28
return 0;
29
}
40.6 优先选择组合
在许多情况下,使用组合可以替代多重继承,并且通常可以避免多重继承带来的复杂性和潜在问题。如果一个类需要使用另一个类的功能,通常优先考虑在该类中包含另一个类的对象作为成员,而不是使用继承。
40.7 总结
多重继承是一种强大的语言特性,但也带来了显著的复杂性。应该明智而审慎地使用多重继承,只在确实需要组合来自多个不同来源的接口或行为时才考虑使用。在大多数情况下,优先选择使用组合来建立类之间的关系,因为组合通常可以提供更好的封装性和更低的耦合度。当使用多重继承时,需要特别注意菱形继承问题和命名冲突,并考虑使用虚继承来解决菱形继承问题。
41. Item 41: Understand implicit interfaces and compile-time polymorphism (了解隐式接口和编译期多态)
41.1 显式接口与隐式接口
在面向对象编程中,接口通常是通过显式的类定义(例如包含纯虚函数的抽象类)来指定的。派生类必须实现这些接口才能被认为是该接口类型的子类型。这被称为显式接口 (explicit interfaces) 和运行时多态 (runtime polymorphism),通常通过继承和虚函数来实现。
然而,在 C++ 中,还存在另一种形式的接口,即隐式接口 (implicit interfaces),它与编译期多态 (compile-time polymorphism) 相关联,通常通过模板来实现。
41.2 模板与隐式接口
当编写一个模板函数或模板类时,你并没有指定模板参数必须是某个特定的类或继承自某个特定的基类。相反,你通过模板代码中对模板参数类型 T
的操作来定义 T
必须支持的接口。这个接口是“隐式”的,因为它不是通过显式的类定义来指定的。
1
template <typename T>
2
void printName(const T& obj) {
3
std::cout << obj.getName() << std::endl;
4
}
5
6
class Person {
7
public:
8
std::string getName() const { return "Alice"; }
9
};
10
11
class Car {
12
public:
13
std::string getName() const { return "MyCar"; }
14
};
15
16
int main() {
17
Person person;
18
Car car;
19
printName(person); // T 被推导为 Person,Person 需要有 getName() 方法
20
printName(car); // T 被推导为 Car,Car 需要有 getName() 方法
21
return 0;
22
}
在上面的例子中,printName
函数模板对类型 T
的唯一要求是它必须有一个名为 getName
的成员函数,该函数不接受任何参数并返回一个可以被 std::cout
输出的类型(例如 std::string
)。Person
和 Car
类都满足这个隐式接口。
41.3 编译期多态
当使用模板时,多态性发生在编译期。编译器会根据实际使用的类型参数生成特定的函数或类代码。在上面的例子中,当使用 Person
调用 printName
时,编译器会生成一个 printName<Person>
的版本;当使用 Car
调用时,会生成一个 printName<Car>
的版本。这种在编译时根据类型生成不同代码的行为被称为编译期多态。
41.4 隐式接口的特点
→ 通过代码中的操作来定义:接口不是通过显式的类型声明来指定的,而是通过模板代码中对类型参数的操作来隐式定义的。
→ 灵活性:只要一个类型支持模板代码中使用的操作,它就可以作为模板参数使用,而不需要继承自特定的基类或实现特定的接口。
→ 编译时检查:如果一个类型不满足模板代码所要求的隐式接口(例如,缺少某个必需的成员函数),编译器会在编译时报错。
41.5 对比显式接口和隐式接口
特性 | 显式接口(运行时多态) | 隐式接口(编译期多态) |
---|---|---|
如何定义接口 | 通过类定义(例如抽象类) | 通过模板代码中的操作 |
多态发生时间 | 运行时 | 编译时 |
类型关系 | 基于继承 | 基于类型支持的操作 |
灵活性 | 较低,需要继承层次结构 | 较高,只要满足操作即可 |
性能 | 有虚函数调用开销 | 通常没有运行时开销 |
41.6 何时使用隐式接口
→ 当需要编写可以适用于多种不同类型的通用代码时,而这些类型之间没有必然的继承关系,但支持相同的操作。例如,实现一个可以处理任何提供 begin()
和 end()
方法的容器的算法。
→ 在需要利用编译时类型信息进行优化或代码生成时。
41.7 总结
隐式接口和编译期多态是 C++ 中通过模板实现泛型编程的关键概念。与通过继承和虚函数实现的显式接口和运行时多态不同,隐式接口是通过模板代码中对类型参数的操作来定义的,而多态性发生在编译时,编译器会根据实际使用的类型生成特定的代码。理解这两种多态形式及其适用场景对于编写高效、灵活的 C++ 代码至关重要。
42. Item 42: Understand the two meanings of typename
(了解 typename
的双重意义)
42.1 typename
作为类型声明符
typename
的第一个也是最常见的用途是作为类型声明符,用于声明变量、函数参数、返回值等。在这种情况下,它与 class
关键字通常可以互换使用(除了在声明模板参数时,class
可以用于非类类型,而 typename
更精确地表示这是一个类型名)。
1
typename std::vector<int>::iterator iter; // 声明一个迭代器
2
3
template <typename T>
4
void process(typename T::value_type value) { // value_type 是 T 的一个类型成员
5
// ...
6
}
42.2 typename
用于指示模板中的嵌套依赖类型
typename
的第二个用途是在模板定义中,用于指示一个名称是一个类型名,特别是当该名称是一个嵌套依赖名称 (nested dependent name) 时。
42.2.1 什么是嵌套依赖名称
一个名称被称为依赖名称 (dependent name),如果它依赖于某个模板参数。例如,在上面的 process
函数模板中,T::value_type
就是一个依赖名称,因为它依赖于模板参数 T
。
一个依赖名称被称为嵌套名称 (nested name),如果它位于作用域解析运算符 ::
的右边,并且其左边指定了一个类或类模板。例如,T::value_type
中的 value_type
就是一个嵌套名称,因为它位于 ::
的右边,并且其左边是 T
。
因此,嵌套依赖名称就是既依赖于模板参数,又是一个嵌套名称的名称,例如 T::value_type
。
42.2.2 typename
的必要性
编译器在编译模板时,通常无法确定模板参数 T
会是什么类型。因此,当遇到一个嵌套依赖名称时,编译器不知道它指的是一个类型还是一个静态成员变量或其他东西。为了消除这种歧义,如果嵌套依赖名称指的是一个类型,必须在其前面加上 typename
关键字。
考虑以下代码:
1
template <typename T>
2
void printSizeType(const T& container) {
3
// typename T::size_type size = container.size(); // 如果 size_type 是类型,需要 typename
4
std::cout << "Size type is ..." << std::endl;
5
}
6
7
template <typename T>
8
void printIteratorType(const T& container) {
9
typename T::iterator it = container.begin(); // iterator 很可能是一个类型,需要 typename
10
// ...
11
}
在 printSizeType
中,如果 T::size_type
是一个类型(例如 std::vector<int>::size_type
),那么就需要使用 typename
来声明变量 size
的类型。同样,在 printIteratorType
中,T::iterator
很可能是一个类型,所以需要 typename
。
42.2.3 何时不需要 typename
在某些情况下,即使是嵌套依赖名称,也不需要使用 typename
:
→ 作为基类列表中的名称:
1
template <typename T>
2
class Derived : public T::Base { // Base 是 T 的基类,不需要 typename
3
public:
4
// ...
5
};
→ 作为构造函数初始化列表中的名称:
1
template <typename T>
2
class Container {
3
public:
4
Container() : value_(T::defaultValue) {} // defaultValue 是 T 的静态成员,不需要 typename
5
private:
6
typename T::ValueType value_; // ValueType 是 T 的类型成员,需要 typename
7
};
→ 作为 template
关键字之后的名称:当引用一个成员模板时,需要使用 template
关键字,此时不需要 typename
。
1
template <typename T>
2
void process(const T& obj) {
3
obj.template memberFunc<int>(); // memberFunc 是 T 的成员模板,不需要 typename
4
}
42.3 总结
typename
关键字在 C++ 中有两个主要的用途。首先,它可以用作类型声明符,与 class
关键字类似。其次,在模板定义中,它用于指示一个嵌套依赖名称是一个类型名。当编译器遇到一个嵌套依赖名称时,为了消除它可能是一个静态成员变量或其他非类型名称的歧义,需要在其前面加上 typename
。理解 typename
的这两种用法对于编写正确的 C++ 模板代码至关重要。
43. Item 43: Know how to access names in templatized base classes (学习处理模板化基类内的名称)
43.1 问题背景
当一个派生类继承自一个依赖于模板参数的基类时,派生类可能需要访问基类中的成员(例如变量、函数或类型)。然而,由于基类的具体类型在模板实例化之前是未知的,编译器在编译派生类模板时可能会遇到一些问题,特别是当派生类中的名称与基类中的名称相同时。
43.2 依赖名称的查找规则
在模板中,编译器对依赖名称(即依赖于模板参数的名称)的查找规则与非模板代码有所不同。对于一个嵌套依赖名称 T::x
,编译器在实例化模板之前不会去查找 T
的定义,因此它不知道 x
是一个类型、一个变量还是一个函数。
43.3 访问模板化基类中的名称的方法
有几种方法可以访问模板化基类中的名称:
43.3.1 使用 this->
① 如果要访问基类的成员函数或成员变量,可以使用 this->
前缀。这会明确告诉编译器该名称是当前对象的成员,包括从模板化基类继承而来的成员。
1
template <typename T>
2
class Base {
3
public:
4
void baseFunction() { std::cout << "Base function." << std::endl; }
5
};
6
7
template <typename T>
8
class Derived : public Base<T> {
9
public:
10
void callBaseFunction() {
11
this->baseFunction(); // 使用 this-> 访问基类的成员函数
12
}
13
};
14
15
int main() {
16
Derived<int> d;
17
d.callBaseFunction(); // 输出:Base function.
18
return 0;
19
}
43.3.2 使用作用域解析运算符 Base<T>::
② 可以使用基类的完整类型名加上作用域解析运算符 ::
来显式地访问基类的成员。这种方法在访问静态成员或类型别名时特别有用。
1
template <typename T>
2
class Base {
3
public:
4
static int staticVar;
5
typedef int BaseType;
6
};
7
8
template <typename T>
9
int Base<T>::staticVar = 42;
10
11
template <typename T>
12
class Derived : public Base<T> {
13
public:
14
void accessBaseStatic() {
15
std::cout << "Static var: " << Base<T>::staticVar << std::endl;
16
}
17
typename Base<T>::BaseType getBaseType() {
18
return 10;
19
}
20
};
21
22
int main() {
23
Derived<double> d;
24
d.accessBaseStatic(); // 输出:Static var: 42
25
std::cout << "Base type value: " << d.getBaseType() << std::endl; // 输出:Base type value: 10
26
return 0;
27
}
43.3.3 使用 using
声明
③ 可以使用 using
声明将基类中的名称引入到派生类的作用域中,使得可以直接使用该名称而无需加前缀。
1
template <typename T>
2
class Base {
3
public:
4
void baseFunction() { std::cout << "Base function (using)." << std::endl; }
5
};
6
7
template <typename T>
8
class Derived : public Base<T> {
9
public:
10
using Base<T>::baseFunction; // 使用 using 声明引入名称
11
void callBaseFunction() {
12
baseFunction(); // 现在可以直接使用 baseFunction
13
}
14
};
15
16
int main() {
17
Derived<float> d;
18
d.callBaseFunction(); // 输出:Base function (using).
19
return 0;
20
}
43.4 类型名称的特殊处理:typename
如 Item 42 所述,如果模板化基类中一个依赖于模板参数的名称是一个类型,并且需要在派生类模板中引用它,则需要在其前面加上 typename
关键字。
1
template <typename T>
2
class Base {
3
public:
4
typedef T ValueType;
5
};
6
7
template <typename T>
8
class Derived : public Base<T> {
9
public:
10
void processValue(typename Base<T>::ValueType value) {
11
std::cout << "Processing value: " << value << std::endl;
12
}
13
};
14
15
int main() {
16
Derived<int> d;
17
d.processValue(100); // 输出:Processing value: 100
18
return 0;
19
}
在这里,Base<T>::ValueType
是一个依赖于模板参数 T
的类型名称,所以在 Derived
类中引用它时需要使用 typename
。
43.5 总结
当派生类模板需要访问其模板化基类中的名称时,需要特别注意名称查找规则。可以使用 this->
来访问成员函数和成员变量,使用 Base<T>::
来显式指定基类的作用域,或者使用 using
声明将名称引入到派生类的作用域中。如果基类中的名称是一个依赖于模板参数的类型,并且需要在派生类模板中引用它,则需要在其前面加上 typename
关键字。正确地处理模板化基类中的名称是编写复杂的模板代码的关键。
44. Item 44: Factor parameter-independent code out of templates (将模板代码中与参数无关的代码抽离)
44.1 模板代码膨胀 (Code Bloat)
模板的优势在于可以编写出适用于多种类型的通用代码。然而,一个潜在的缺点是代码膨胀 (code bloat)。当模板被实例化为不同的类型时,编译器会为每种类型生成一份代码。如果模板中包含大量的代码,并且被很多不同的类型实例化,最终生成的可执行文件可能会变得非常庞大。
44.2 识别参数无关的代码
模板代码中可能包含一些部分,其行为并不依赖于模板参数的具体类型。例如,某些控制逻辑、辅助计算或数据结构的管理可能与模板参数的类型无关。
44.3 抽离参数无关代码的方法
将模板代码中与参数无关的部分抽离出来,可以减少代码膨胀。常见的方法包括:
44.3.1 将参数无关的代码移到非模板基类中
① 如果一个模板类的大部分实现与模板参数无关,可以将这部分代码移到一个非模板的基类中,然后让模板类继承自这个基类。
1
// 非模板基类,包含与参数无关的代码
2
class DataStructureBase {
3
protected:
4
void initializeCommonData() {
5
// ... 初始化与类型无关的数据
6
std::cout << "Initializing common data." << std::endl;
7
}
8
public:
9
virtual ~DataStructureBase() {}
10
// ... 其他与类型无关的公共接口
11
};
12
13
template <typename T>
14
class DataStructure : public DataStructureBase {
15
public:
16
DataStructure() {
17
initializeCommonData();
18
// ... 初始化与类型 T 相关的数据
19
std::cout << "Initializing data specific to type T." << std::endl;
20
}
21
void process(const T& item) {
22
// ... 处理类型为 T 的 item
23
}
24
// ... 其他与类型 T 相关的操作
25
};
26
27
int main() {
28
DataStructure<int> intStructure;
29
DataStructure<double> doubleStructure;
30
return 0;
31
}
在这个例子中,initializeCommonData
方法与模板参数 T
无关,所以被移到了非模板基类 DataStructureBase
中。DataStructure
模板类继承自它,从而避免了在 DataStructure<int>
和 DataStructure<double>
中重复生成 initializeCommonData
的代码。
44.3.2 将参数无关的代码移到非模板的辅助函数中
② 如果模板中的某个函数包含与参数无关的部分,可以将这部分代码移到一个非模板的自由函数或静态成员函数中,然后在模板函数中调用它。
1
template <typename T>
2
class Processor {
3
private:
4
static void logMessage(const std::string& message) {
5
std::cout << "Log: " << message << std::endl;
6
}
7
public:
8
void process(const T& data) {
9
logMessage("Starting processing.");
10
// ... 处理与类型 T 相关的数据
11
logMessage("Processing finished.");
12
}
13
};
14
15
int main() {
16
Processor<int> intProcessor;
17
intProcessor.process(10);
18
Processor<std::string> stringProcessor;
19
stringProcessor.process("hello");
20
return 0;
21
}
这里的 logMessage
函数与模板参数 T
无关,所以被声明为静态成员函数,避免了为每种 Processor
的实例化都生成一份 logMessage
的代码。
44.3.3 使用函数对象或策略模式
③ 如 Item 35 所述,可以使用函数对象或策略模式将与特定类型相关的行为封装起来,然后在模板中使用这些策略。模板本身的代码可以保持与类型无关。
44.4 权衡
在决定是否将代码从模板中抽离时,需要权衡代码膨胀的风险和代码结构的复杂性。如果抽离后的代码使得程序更难理解和维护,那么可能并不值得。只有当代码膨胀是一个实际问题时,才应该考虑这些优化。
44.5 总结
为了减少模板带来的代码膨胀问题,应该识别模板代码中与参数无关的部分,并将这部分代码抽离到非模板的基类、辅助函数或使用函数对象/策略模式中。这样做可以减小最终可执行文件的大小,并可能提高程序的性能。在进行这种优化时,需要权衡代码膨胀的风险和代码结构的可读性和可维护性。
45. Item 45: Use member function templates to accept "all compatible types" (运用成员函数模板接受所有兼容类型)
45.1 问题背景
有时,我们希望一个类的成员函数能够接受多种不同类型的参数,只要这些类型在某种程度上是“兼容”的。例如,我们可能希望一个容器的 insert
函数能够接受与容器元素类型相同或可以隐式转换为容器元素类型的参数。
45.2 传统的解决方案及其局限性
一种常见的做法是提供多个重载的成员函数来处理不同的参数类型。然而,这种方法有以下局限性:
→ 需要预先知道所有可能的兼容类型:如果兼容类型的数量很多或者将来可能会扩展,就需要添加更多的重载函数,使得代码冗余且难以维护。
→ 无法处理用户自定义的类型转换:如果用户定义了新的类型,并且该类型可以隐式转换为容器的元素类型,重载函数无法自动处理这种情况,需要显式地添加新的重载。
45.3 成员函数模板的优势
使用成员函数模板可以优雅地解决这些问题。成员函数模板是定义在类内部的模板函数。它可以接受任意数量的类型参数,并且编译器会根据实际传入的参数类型进行实例化。
1
class MyContainer {
2
public:
3
template <typename T>
4
void insert(const T& value) {
5
// 假设 element_type 是容器存储的元素类型
6
element_type convertedValue = value; // 依赖于 T 到 element_type 的隐式转换
7
data_.push_back(convertedValue);
8
}
9
10
private:
11
std::vector<element_type> data_;
12
};
13
14
class SpecialInt {
15
public:
16
SpecialInt(int val) : value_(val) {}
17
operator int() const { return value_; } // 允许隐式转换为 int
18
private:
19
int value_;
20
};
21
22
int main() {
23
MyContainer intContainer;
24
intContainer.insert(10); // T 被推导为 int
25
intContainer.insert(SpecialInt(20)); // T 被推导为 SpecialInt,通过 operator int() 转换为 int
26
return 0;
27
}
在这个例子中,MyContainer
类有一个成员函数模板 insert
。这个模板可以接受任何类型的参数 T
。在函数内部,它尝试将 T
类型的 value
隐式转换为容器的元素类型 element_type
(假设是 int
)。只要存在从 T
到 element_type
的隐式转换,insert
函数就能工作,而无需为每种可能的 T
都提供一个重载版本。
45.4 更复杂的示例:智能指针的构造函数
考虑 std::shared_ptr
的一个简化版本。它可以使用一个指向某种类型 U
的原始指针来构造一个 std::shared_ptr
对象,只要 U*
可以隐式转换为 T*
(例如,当 U
是 T
或 T
的派生类时)。
1
template <typename T>
2
class SharedPtr {
3
public:
4
template <typename U>
5
SharedPtr(U* ptr) : ptr_(ptr) {} // 成员函数模板
6
7
T* get() const { return ptr_; }
8
9
private:
10
T* ptr_;
11
};
12
13
class Base {};
14
class Derived : public Base {};
15
16
int main() {
17
SharedPtr<Base> basePtr(new Base());
18
SharedPtr<Base> derivedPtr(new Derived()); // U 是 Derived,可以隐式转换为 Base*
19
// SharedPtr<Derived> basePtr2(new Base()); // 错误:Base* 不能隐式转换为 Derived*
20
21
return 0;
22
}
在这个例子中,SharedPtr
类有一个接受类型 U*
的成员函数模板构造函数。这使得我们可以使用指向 Base
或 Derived
对象的原始指针来创建 SharedPtr<Base>
对象,因为 Derived*
可以隐式转换为 Base*
。
45.5 注意事项
→ 类型转换的有效性:成员函数模板依赖于编译器能够找到从模板参数类型到目标类型的有效隐式转换。如果不存在这样的转换,编译器会报错。
→ 模板实例化:编译器会为每种实际使用的参数类型实例化一个成员函数模板。如果模板函数体很复杂并且被很多不同的类型实例化,可能会导致代码膨胀。
→ 约束模板参数:可以使用 SFINAE(Substitution Failure Is Not An Error)或 C++20 的 Concepts 来对成员函数模板的参数类型进行更精确的约束。
45.6 总结
成员函数模板是一种强大的工具,可以使类的接口更加灵活,能够接受所有兼容类型的参数,而无需显式地为每种类型都提供重载函数。它们特别适用于那些需要利用隐式类型转换的场景,例如容器的插入操作或智能指针的构造。通过使用成员函数模板,可以编写出更通用、更易于扩展的代码。
46. Item 46: Define non-member functions inside templates when type conversions are desired (需要类型转换时请为模板定义非成员函数)
46.1 回顾 Item 24
Item 24 讨论了当希望类型转换应用于函数的所有参数时,应该使用非成员函数。对于类的成员函数,只有调用该函数的对象(通过 this
指针)之外的参数才能够进行隐式类型转换。
46.2 模板中的类似问题
当涉及到模板时,这个原则同样适用。如果在模板类内部定义一个操作符重载函数(例如 operator*
),那么只有右操作数可以进行隐式类型转换。左操作数(即调用该操作符的对象)的类型必须与模板类的实例化类型完全匹配。
46.3 示例:模板类中的成员操作符
考虑一个模板化的 Rational
类:
1
template <typename T>
2
class Rational {
3
public:
4
Rational(T num = 0, T den = 1) : num_(num), den_(den) {}
5
6
template <typename U>
7
Rational<typename std::common_type<T, U>::type>
8
operator*(const Rational<U>& rhs) const {
9
using CommonType = typename std::common_type<T, U>::type;
10
return Rational<CommonType>(static_cast<CommonType>(num_) * rhs.num_,
11
static_cast<CommonType>(den_) * rhs.den_);
12
}
13
14
private:
15
T num_;
16
T den_;
17
};
这个例子中,operator*
是一个成员函数模板,它可以接受另一个 Rational
对象作为右操作数,其模板参数 U
可以与左操作数的模板参数 T
不同。然而,如果我们尝试将一个可以隐式转换为 Rational
的类型与 Rational
对象相乘,可能会遇到问题:
1
Rational<int> oneHalf(1, 2);
2
Rational<double> result = oneHalf * Rational<double>(2, 1); // OK
3
4
// result = 2 * oneHalf; // 错误:int 不能隐式转换为 Rational<int> 作为左操作数
46.4 解决方法:在模板外部定义非成员函数
解决这个问题的方法是在模板类的外部定义一个非成员函数(通常也是一个模板函数)来实现所需的操作符重载。
1
template <typename T, typename U>
2
auto operator*(const Rational<T>& lhs, const Rational<U>& rhs) {
3
using CommonType = typename std::common_type<T, U>::type;
4
return Rational<CommonType>(static_cast<CommonType>(lhs.num_) * rhs.num_,
5
static_cast<CommonType>(lhs.den_) * rhs.den_);
6
}
7
8
Rational<int> oneHalf(1, 2);
9
Rational<double> result1 = oneHalf * Rational<double>(2, 1); // OK
10
Rational<double> result2 = 2 * oneHalf; // OK:2 可以隐式转换为 Rational<int>(2, 1)
11
Rational<double> result3 = oneHalf * 2; // OK:2 可以隐式转换为 Rational<double>(2, 1)
在这个修改后的例子中,operator*
是一个定义在 Rational
模板外部的模板函数。现在,它的两个参数 lhs
和 rhs
都可以进行隐式类型转换。例如,当计算 2 * oneHalf
时,编译器可以尝试将 2
转换为 Rational<int>(2, 1)
,然后调用 operator*
。
46.5 需要访问私有成员的情况
如果非成员模板函数需要访问模板类的私有成员,可以将其声明为模板类的友元函数。
1
template <typename T>
2
class Rational {
3
public:
4
Rational(T num = 0, T den = 1) : num_(num), den_(den) {}
5
6
template <typename U>
7
friend auto operator*(const Rational<T>& lhs, const Rational<U>& rhs);
8
9
private:
10
T num_;
11
T den_;
12
};
13
14
template <typename T, typename U>
15
auto operator*(const Rational<T>& lhs, const Rational<U>& rhs) {
16
using CommonType = typename std::common_type<T, U>::type;
17
return Rational<CommonType>(static_cast<CommonType>(lhs.num_) * rhs.num_,
18
static_cast<CommonType>(lhs.den_) * rhs.den_);
19
}
46.6 总结
当需要在模板化的类上进行操作,并且希望所有操作数都能参与隐式类型转换时,应该在模板类的外部定义非成员函数(通常也是模板函数)来实现这些操作。这与 Item 24 中对于非模板类的建议是一致的。如果这些非成员模板函数需要访问模板类的私有成员,可以将它们声明为友元函数。这种做法可以使得模板化的代码更加灵活和易于使用。
47. Item 47: Use traits classes for information about types (使用 traits classes 表现类型信息)
47.1 问题背景
在编写泛型代码(特别是模板代码)时,有时需要根据类型的一些特性来执行不同的操作。例如,对于内置类型和用户自定义类型,拷贝和移动的成本可能不同;对于某些类型,可能需要执行特定的初始化或清理操作。
47.2 什么是 Traits Classes
Traits classes 是一种在编译时提供关于类型信息的机制。一个 traits class 通常是一个模板类,它接受一个类型参数,并定义了一些静态成员常量或类型别名,用于描述该类型的特定属性或行为。
47.3 标准库中的 Traits
C++ 标准库提供了许多有用的 traits classes,例如:
→ std::is_pointer<T>
: 判断 T
是否为指针类型。
→ std::is_integral<T>
: 判断 T
是否为整数类型。
→ std::is_floating_point<T>
: 判断 T
是否为浮点类型。
→ std::is_class<T>
: 判断 T
是否为类类型。
→ std::has_trivial_constructor<T>
: 判断 T
是否具有平凡的构造函数。
→ std::has_trivial_destructor<T>
: 判断 T
是否具有平凡的析构函数。
→ std::is_copy_constructible<T>
: 判断 T
是否可拷贝构造。
→ std::is_move_constructible<T>
: 判断 T
是否可移动构造。
→ std::remove_reference<T>
: 移除 T
的引用。
→ std::remove_const<T>
: 移除 T
的 const
限定符。
→ std::common_type<T, U, ...>
: 确定一组类型的公共类型。
这些 traits classes 通常定义在 <type_traits>
头文件中。
47.4 如何使用 Traits
可以使用这些 traits classes 的静态成员 value
来获取类型的信息。
1
#include <iostream>
2
#include <type_traits>
3
4
template <typename T>
5
void process(const T& value) {
6
if (std::is_integral<T>::value) {
7
std::cout << "Processing an integer: " << value << std::endl;
8
} else if (std::is_floating_point<T>::value) {
9
std::cout << "Processing a float: " << value << std::endl;
10
} else {
11
std::cout << "Processing something else." << std::endl;
12
}
13
}
14
15
int main() {
16
process(10); // 输出:Processing an integer: 10
17
process(3.14f); // 输出:Processing a float: 3.14
18
process("hello"); // 输出:Processing something else.
19
return 0;
20
}
47.5 自定义 Traits Classes
如果标准库提供的 traits 不够用,可以创建自定义的 traits classes 来描述特定于你的类型的属性。
1
#include <iostream>
2
#include <type_traits>
3
4
struct SupportsLoggingTag {};
5
6
template <typename T>
7
struct SupportsLogging : std::false_type {};
8
9
template <>
10
struct SupportsLogging<int> : std::true_type {};
11
12
template <>
13
struct SupportsLogging<std::string> : std::true_type {};
14
15
template <typename T>
16
void logValue(const T& value) {
17
if (SupportsLogging<T>::value) {
18
std::cout << "Logging value: " << value << std::endl;
19
} else {
20
std::cout << "Type does not support logging." << std::endl;
21
}
22
}
23
24
int main() {
25
logValue(123); // 输出:Logging value: 123
26
logValue(3.14); // 输出:Type does not support logging.
27
logValue("hello"); // 输出:Logging value: hello
28
return 0;
29
}
在这个例子中,我们定义了一个自定义的 traits class SupportsLogging
,它通过模板特化来为 int
和 std::string
类型返回 true
,为其他类型返回 false
。然后,logValue
函数根据这个 traits 的值来决定是否进行日志记录。
47.6 使用 Traits 进行函数重载
Traits 还可以用于在编译时选择不同的函数实现,这通常通过 SFINAE(Substitution Failure Is Not An Error)或 C++20 的 Concepts 来实现。
1
#include <iostream>
2
#include <type_traits>
3
4
template <typename T>
5
typename std::enable_if<std::is_integral<T>::value>::type
6
processValue(const T& value) {
7
std::cout << "Processing integral value: " << value << std::endl;
8
}
9
10
template <typename T>
11
typename std::enable_if<std::is_floating_point<T>::value>::type
12
processValue(const T& value) {
13
std::cout << "Processing floating-point value: " << value << std::endl;
14
}
15
16
int main() {
17
processValue(10); // 输出:Processing integral value: 10
18
processValue(3.14f); // 输出:Processing floating-point value: 3.14
19
// processValue("hello"); // 编译错误:没有匹配的函数
20
return 0;
21
}
47.7 总结
Traits classes 是一种强大的工具,用于在编译时获取和使用类型信息。它们可以帮助我们编写更灵活、更高效的泛型代码,这些代码可以根据不同类型的特性采取不同的行为。C++ 标准库提供了许多常用的 traits,也可以根据需要创建自定义的 traits。通过使用 traits,可以实现更精细的类型控制和优化。
48. Item 48: Be aware of template metaprogramming (认识模板元编程)
48.1 什么是模板元编程 (Template Metaprogramming, TMP)
模板元编程是一种使用 C++ 模板在编译时执行计算的技术。通常,模板被用来生成在运行时执行的代码。然而,通过巧妙地使用模板的实例化过程,可以在编译期间进行类型操作、数值计算、代码生成等。
48.2 TMP 的特点
→ 编译时执行:TMP 代码在程序编译时运行,而不是在程序运行时。
→ 基于模板实例化:TMP 的“程序”是通过模板的定义和实例化来表达的。
→ 使用类型作为“数据”:TMP 主要操作的是类型,而不是运行时的值。数值计算通常通过将数值编码为类型来实现。
→ 函数式编程风格:TMP 代码通常采用递归和模式匹配等函数式编程的风格,而不是使用循环和变量赋值。
48.3 TMP 的应用场景
→ 编译时计算常量:可以计算一些在编译时就能确定的常量值,例如阶乘、幂等。
→ 类型检查和类型转换:可以基于类型特性执行条件编译,或者在编译时进行类型转换。
→ 代码生成:可以根据类型或其他编译时信息生成特定的代码结构。
→ 优化:可以根据类型信息在编译时选择最优的算法或实现。
48.4 示例:编译时计算阶乘
1
template <int N>
2
struct Factorial {
3
static const int value = N * Factorial<N - 1>::value;
4
};
5
6
template <>
7
struct Factorial<0> {
8
static const int value = 1;
9
};
10
11
int main() {
12
constexpr int fact5 = Factorial<5>::value; // fact5 在编译时被计算为 120
13
std::cout << "5! = " << fact5 << std::endl;
14
return 0;
15
}
在这个例子中,Factorial
是一个模板类,它通过递归地实例化自身来计算阶乘。当 N
为 0 时,特化版本提供递归的终止条件。Factorial<5>::value
将在编译时被计算出来。
48.5 TMP 的缺点
→ 代码难以阅读和理解:TMP 代码通常非常抽象,使用了大量的模板语法,使得代码难以阅读和理解。
→ 编译错误信息复杂:当 TMP 代码出现错误时,编译器产生的错误信息通常很长且难以理解,因为它们涉及到模板的实例化链。
→ 编译时间可能增加:复杂的 TMP 代码可能会导致编译时间显著增加。
48.6 C++11/14/17 的改进
现代 C++ 标准引入了一些新特性,使得模板元编程更加方便和易于使用:
→ constexpr
函数:允许在编译时执行的函数,可以替代一些基于模板的数值计算。
→ 类型别名模板 (using
):使得类型操作更加简洁。
→ 可变参数模板:简化了处理不定数量模板参数的语法。
→ std::integral_constant
:用于表示编译时常量的类型安全的别名。
→ if constexpr
:允许在编译时根据常量表达式的值选择执行不同的代码分支。
→ Concepts (C++20):提供了一种更清晰、更强大的方式来约束模板参数。
48.7 示例:使用 constexpr
计算阶乘
1
constexpr int factorial(int n) {
2
return (n <= 1) ? 1 : n * factorial(n - 1);
3
}
4
5
int main() {
6
constexpr int fact5 = factorial(5); // 仍然在编译时计算
7
std::cout << "5! = " << fact5 << std::endl;
8
return 0;
9
}
使用 constexpr
函数通常比基于模板的 TMP 更易于阅读和理解。
48.8 总结
模板元编程是一种强大的技术,可以在编译时执行复杂的计算和类型操作,从而实现高度优化的代码。然而,TMP 代码通常难以阅读和维护。现代 C++ 标准提供了一些更易于使用的替代方案,例如 constexpr
函数。在决定使用 TMP 时,需要权衡其带来的性能优势和增加的代码复杂性。了解 TMP 的基本概念和应用场景,可以帮助我们更好地利用 C++ 的强大功能。
49. Item 49: Understand the behavior of the new-handler
(了解 new-handler
的行为)
49.1 new
运算符的失败处理
当 new
运算符无法分配请求的内存时,默认行为是抛出 std::bad_alloc
异常。然而,在抛出异常之前,new
还会尝试调用一个new-handler
函数。
49.2 new-handler
的作用
new-handler
是一个由程序员设置的函数,当 new
运算符内存分配失败时,new
会调用这个函数。new-handler
的目的是尝试做一些事情来使得内存分配有可能成功,例如:
→ 释放一些已经分配的内存。
→ 向操作系统请求更多的交换空间。
→ 终止程序(如果无法恢复)。
如果 new-handler
能够使内存分配在下一次尝试时成功,那么 new
将会继续尝试分配内存。如果 new-handler
无法使内存分配成功,或者它选择返回(而不是抛出异常或终止程序),那么 new
将会继续尝试调用 new-handler
,直到分配成功或 new-handler
抛出 std::bad_alloc
异常。
49.3 如何设置 new-handler
可以使用标准库函数 std::set_new_handler
来设置全局的 new-handler
。这个函数接受一个指向 new-handler
函数的指针作为参数,并返回先前设置的 new-handler
(如果存在)。new-handler
函数的类型必须是 void (*)()
,即一个不接受任何参数且不返回任何值的函数。
1
#include <iostream>
2
#include <new>
3
4
void myNewHandler() {
5
std::cerr << "Unable to allocate memory!" << std::endl;
6
std::abort(); // 终止程序
7
}
8
9
int main() {
10
std::set_new_handler(myNewHandler);
11
try {
12
// 尝试分配大量内存,可能会失败
13
int* p = new int[100000000000];
14
delete[] p;
15
} catch (const std::bad_alloc& e) {
16
std::cerr << "Caught bad_alloc: " << e.what() << std::endl;
17
}
18
return 0;
19
}
在这个例子中,我们设置了一个简单的 new-handler
,它只是输出一个错误消息并终止程序。当 new int[...]
无法分配足够的内存时,myNewHandler
会被调用。
49.4 类特定的 new-handler
除了全局的 new-handler
,还可以为特定的类设置 new-handler
。这通常通过在类中定义一个静态成员函数作为 new-handler
,并重载类的 operator new
来实现。
1
#include <iostream>
2
#include <new>
3
4
class MyClass {
5
public:
6
static std::new_handler set_new_handler(std::new_handler p) noexcept {
7
std::new_handler oldHandler = currentHandler_;
8
currentHandler_ = p;
9
return oldHandler;
10
}
11
12
static void newHandler() {
13
std::cerr << "MyClass: Unable to allocate memory!" << std::endl;
14
throw std::bad_alloc();
15
}
16
17
static void* operator new(std::size_t size) {
18
std::cout << "MyClass::operator new called for size: " << size << std::endl;
19
std::new_handler globalHandler = std::set_new_handler(currentHandler_);
20
void* mem;
21
try {
22
mem = ::operator new(size); // 调用全局的 new
23
} catch (const std::bad_alloc& e) {
24
std::set_new_handler(globalHandler); // 恢复全局 handler
25
throw;
26
}
27
std::set_new_handler(globalHandler); // 恢复全局 handler
28
return mem;
29
}
30
31
static void operator delete(void* ptr) noexcept {
32
::operator delete(ptr);
33
}
34
35
private:
36
static std::new_handler currentHandler_;
37
};
38
39
std::new_handler MyClass::currentHandler_ = nullptr;
40
41
int main() {
42
MyClass::set_new_handler(MyClass::newHandler);
43
try {
44
MyClass* obj = new MyClass();
45
delete obj;
46
} catch (const std::bad_alloc& e) {
47
std::cerr << "Caught bad_alloc in main." << std::endl;
48
}
49
return 0;
50
}
在这个例子中,我们为 MyClass
定义了一个静态的 newHandler
和一个静态的 operator new
。当尝试分配 MyClass
对象时,会使用 MyClass::operator new
,它会设置类特定的 newHandler
。
49.5 new-handler
的行为准则
一个好的 new-handler
应该尝试以下操作之一:
→ 释放更多内存:例如,删除不再需要的对象。
→ 返回:如果它能使下一次内存分配尝试更有可能成功。
→ 抛出 std::bad_alloc
异常:表明内存分配无法完成。
→ 终止程序:例如,调用 std::abort()
。
一个不好的 new-handler
是永远循环而不做任何进展。
49.6 总结
new-handler
是一种机制,允许程序在 new
运算符内存分配失败时执行自定义的处理逻辑。可以设置全局的 new-handler
,也可以为特定的类设置类特定的 new-handler
。new-handler
的目的是尝试使内存分配在后续尝试中成功,或者在无法成功时采取适当的行动(例如抛出异常或终止程序)。理解 new-handler
的行为对于编写健壮的内存管理代码非常重要。
50. Item 50: Understand when it makes sense to replace new
and delete
(了解何时适合替换 new
和 delete
)
50.1 默认的 new
和 delete
C++ 的默认 new
和 delete
运算符用于在自由存储区(通常是堆)上分配和释放内存。对于大多数应用程序来说,这些默认实现是足够用的。
50.2 何时需要替换 new
和 delete
在某些特定的情况下,可能需要替换默认的 new
和 delete
运算符:
50.2.1 改善性能
① 定制内存分配策略:默认的 new
和 delete
可能不是在所有情况下都最高效的。例如,对于频繁分配和释放小块内存的应用程序,可以使用内存池 (memory pooling) 或定制的内存管理器来减少开销和提高性能。
② 对齐要求:某些特定的数据类型或硬件可能需要特殊的内存对齐。可以通过自定义 new
和 delete
来确保满足这些对齐要求。
50.2.2 调试和诊断
③ 内存泄漏检测:可以替换 new
和 delete
来记录内存的分配和释放情况,从而帮助检测内存泄漏。
④ 越界访问检测:在分配的内存块周围添加“哨兵”字节,并在释放时检查它们是否被修改,可以帮助检测越界写入等错误。
50.2.3 嵌入式系统
⑤ 内存受限的环境:在内存资源非常有限的嵌入式系统中,可能需要使用定制的内存管理方案,例如从预先分配的固定大小的内存池中分配。
50.2.4 垃圾回收 (Garbage Collection)
⑥ 虽然 C++ 本身不提供内置的垃圾回收机制,但可以通过替换 new
和 delete
来实现自定义的垃圾回收策略(尽管这通常是一个非常复杂的任务)。
50.2.5 特定类的内存管理
⑦ 可以为特定的类重载 operator new
和 operator delete
,以便为该类的对象使用定制的内存管理方案。这通常用于优化特定对象的分配和释放,或者实现某些设计模式(例如单例模式)。
50.3 如何替换 new
和 delete
可以替换全局的 new
和 delete
运算符,也可以为特定的类重载它们。
50.3.1 替换全局的 new
和 delete
要替换全局的 new
和 delete
,需要在全局作用域中提供以下函数的定义:
→ void* operator new(std::size_t size)
→ void operator delete(void* ptr) noexcept
→ void* operator new[](std::size_t size)
→ void operator delete[](void* ptr) noexcept
这些函数会取代默认的全局分配和释放行为。需要注意的是,替换全局的 new
和 delete
会影响整个程序,因此应该谨慎进行。
50.3.2 为类重载 new
和 delete
要为特定的类重载 new
和 delete
,需要在类的定义中声明它们为静态成员函数:
→ static void* operator new(std::size_t size)
→ static void operator delete(void* ptr) noexcept
→ static void* operator new[](std::size_t size)
→ static void operator delete[](void* ptr) noexcept
当使用 new
或 delete
操作符来分配或释放该类的对象或数组时,会调用这些重载的版本。
50.4 示例:简单的内存池
1
#include <iostream>
2
#include <memory>
3
#include <vector>
4
5
class PoolAllocator {
6
public:
7
PoolAllocator(size_t chunkSize, size_t numChunks) : chunkSize_(chunkSize), numChunks_(numChunks) {
8
pool_.resize(numChunks * chunkSize);
9
freeBlocks_.reserve(numChunks);
10
for (size_t i = 0; i < numChunks; ++i) {
11
freeBlocks_.push_back(pool_.data() + i * chunkSize);
12
}
13
}
14
15
void* allocate(size_t size) {
16
if (size > chunkSize_ || freeBlocks_.empty()) {
17
return nullptr; // 或者抛出异常
18
}
19
void* block = freeBlocks_.back();
20
freeBlocks_.pop_back();
21
return block;
22
}
23
24
void deallocate(void* ptr, size_t size) {
25
if (size == chunkSize_ && ptr >= pool_.data() && ptr < pool_.data() + pool_.size()) {
26
freeBlocks_.push_back(static_cast<char*>(ptr));
27
}
28
}
29
30
private:
31
size_t chunkSize_;
32
size_t numChunks_;
33
std::vector<char> pool_;
34
std::vector<char*> freeBlocks_;
35
};
36
37
PoolAllocator allocator(sizeof(int), 10);
38
39
void* operator new(std::size_t size) {
40
if (void* ptr = allocator.allocate(size)) {
41
return ptr;
42
}
43
return std::malloc(size); // 如果内存池用完,使用默认分配器
44
}
45
46
void operator delete(void* ptr) noexcept {
47
allocator.deallocate(ptr, sizeof(int));
48
}
49
50
int main() {
51
int* p1 = new int(1);
52
int* p2 = new int(2);
53
delete p1;
54
delete p2;
55
int* p3 = new int[2];
56
delete[] p3; // 注意:这个例子没有重载 operator new[] 和 delete[]
57
return 0;
58
}
这个例子展示了一个简单的内存池分配器,并重载了全局的 operator new
和 operator delete
来使用这个内存池(仅针对 sizeof(int)
大小的分配)。
50.5 总结
替换默认的 new
和 delete
运算符通常是为了改善性能、进行调试、满足嵌入式系统的需求或实现特定的内存管理策略。可以替换全局的运算符,也可以为特定的类重载它们。在进行替换时,务必遵循 Item 51 中的约定,以确保行为正确。全局替换应该谨慎使用,因为它会影响整个程序。
51. Item 51: Adhere to convention when writing new
and delete
(编写 new
和 delete
时需固守常规)
51.1 operator new
的约定
当你重载或替换 operator new
时,需要遵循以下约定:
→ 返回正确的指针:operator new
应该返回一个指向至少 size
字节的内存块的指针。
→ 处理 0 字节请求:如果请求分配 0 字节,operator new
应该返回一个合法的指针(不一定是空指针,但不能解引用)。许多实现返回一个指向 1 字节的指针。
→ 处理内存不足:如果在分配内存时发生错误,operator new
应该遵循 Item 49 中描述的 new-handler
机制。它应该反复调用 new-handler
,直到分配成功或 new-handler
抛出 std::bad_alloc
异常。如果 new-handler
返回 nullptr
,operator new
应该抛出 std::bad_alloc
。
→ 避免抛出异常(除非是 std::bad_alloc
):标准的 operator new
在分配失败时抛出 std::bad_alloc
。你的替换版本也应该遵循这个约定。
51.2 operator delete
的约定
当你重载或替换 operator delete
时,需要遵循以下约定:
→ 接受空指针:operator delete
应该能够安全地处理空指针(即,调用 delete
一个空指针应该不做任何事情)。
→ 避免抛出异常:operator delete
不应该抛出异常。如果在内存释放过程中发生错误,应该以其他方式处理(例如记录错误)。
51.3 operator new[]
和 operator delete[]
的约定
对于数组形式的 new
和 delete
(operator new[]
和 operator delete[]
),需要遵循类似的约定:
→ operator new[]
:除了 operator new
的约定外,当分配一个包含 n
个对象的数组时,operator new[]
通常会分配额外的空间来存储数组的大小 n
(以便在调用 delete[]
时知道需要调用多少次析构函数)。
→ operator delete[]
:应该能够处理通过 operator new[]
分配的内存,并确保为数组中的每个对象调用析构函数。它也应该能够安全地处理空指针,并且不应该抛出异常。
51.4 Placement new
和 delete
的约定
Placement new
允许在预先分配好的内存上构造对象。它的形式是 new (args) Type
。Placement new
的重载版本需要返回传递给它的指针。
Placement delete
是与 placement new
配对使用的。如果 placement new
构造对象时抛出异常,会调用相应的 placement delete
来清理可能已经分配但尚未完全构造的对象。Placement delete
不会被正常的 delete
调用。Item 52 详细讨论了 placement delete
。
51.5 总结
在编写自定义的 new
和 delete
运算符时,必须遵循标准库定义的约定,以确保程序的行为与预期一致,并且能够与现有的 C++ 代码和库正确地交互。这包括正确处理内存分配失败、空指针、0 字节请求,以及避免在 delete
中抛出异常等。对于数组形式的 new
和 delete
,还需要处理存储数组大小的问题。遵循这些约定是编写可靠的自定义内存管理代码的基础。
52. Item 52: Write placement delete
if you write placement new
(如果写了 placement new
,也要写 placement delete
)
52.1 什么是 Placement new
Placement new
是一种特殊的 new
运算符形式,它允许你在一个已经分配好的内存地址上构造对象,而不是在堆上分配新的内存。其语法形式是 new (address) Type(arguments)
。Placement new
不会分配内存;它只是调用对象的构造函数在指定的内存位置初始化对象。
52.2 何时使用 Placement new
Placement new
通常在以下情况下使用:
→ 在预先分配的缓冲区上创建对象:例如,在嵌入式系统中,内存可能是在编译时静态分配的。
→ 实现定制的内存管理方案:例如,在内存池中分配的对象可能需要使用 placement new
在池中的某个位置构造。
→ 异常处理中的对象重建:在某些异常处理场景中,可能需要在相同的内存位置重建一个对象。
52.3 Placement new
的问题
当使用 placement new
构造对象时,如果在构造函数中抛出了异常,会发生什么?由于 placement new
本身不分配内存,所以它不会负责释放内存。但是,如果对象构造失败,可能需要执行一些清理操作(例如,释放对象可能已经获取的资源)。
52.4 Placement delete
的作用
Placement delete
是一种与 placement new
配对使用的 delete
运算符。它的目的是在 placement new
构造对象时抛出异常的情况下,执行必要的清理工作。Placement delete
的签名必须与对应的 placement new
相同,只是返回类型是 void
。它不会被正常的 delete
表达式调用。
52.5 示例:Placement new
和 delete
1
#include <iostream>
2
#include <new>
3
#include <stdexcept>
4
5
class MyClass {
6
public:
7
MyClass() {
8
std::cout << "MyClass constructor called." << std::endl;
9
// 模拟构造函数可能抛出异常
10
// throw std::runtime_error("Constructor failed");
11
}
12
~MyClass() {
13
std::cout << "MyClass destructor called." << std::endl;
14
}
15
};
16
17
void* buffer = operator new(sizeof(MyClass)); // 分配原始内存
18
19
int main() {
20
MyClass* ptr = nullptr;
21
try {
22
ptr = new (buffer) MyClass(); // 使用 placement new 在 buffer 上构造对象
23
// ... 使用 ptr
24
ptr->~MyClass(); // 显式调用析构函数,因为内存是手动分配的
25
operator delete(buffer); // 释放原始内存
26
} catch (const std::exception& e) {
27
std::cerr << "Exception caught: " << e.what() << std::endl;
28
if (ptr != nullptr) {
29
// 如果构造函数抛出异常,不会执行到这里
30
ptr->~MyClass();
31
operator delete(buffer);
32
} else {
33
operator delete(buffer); // 即使构造失败,也要释放已分配的 buffer
34
}
35
}
36
return 0;
37
}
38
39
// 如果 MyClass 的构造函数可能抛出异常,我们需要提供 placement delete:
40
void operator delete(void* ptr, std::size_t size, void* placementAddress) noexcept {
41
std::cout << "Placement delete called." << std::endl;
42
if (ptr == placementAddress) {
43
// 在这里可以执行一些清理操作,但通常不需要释放 ptr,因为它不是通过普通 new 分配的
44
}
45
}
46
47
// 注意:C++11 引入了更简洁的 placement delete 语法
48
void operator delete(void* ptr, void* placementAddress) noexcept {
49
std::cout << "Placement delete (C++11) called." << std::endl;
50
if (ptr == placementAddress) {
51
// ...
52
}
53
}
在这个例子中,我们首先使用普通的 operator new
分配了一块内存 buffer
。然后,我们使用 placement new
在这块内存上构造了一个 MyClass
对象。如果 MyClass
的构造函数抛出异常,那么会调用我们提供的 placement delete
。注意,placement delete
的参数列表必须与对应的 placement new
相同(除了第一个参数是 void*
,指向 placement new
返回的地址)。
在正常的执行流程中(没有异常),placement delete
不会被调用。我们需要显式地调用析构函数,然后使用普通的 operator delete
来释放我们最初分配的内存。
52.6 总结
如果你定义了 placement new
运算符(特别是那些参数列表中包含 std::size_t
以外的额外参数的版本),那么你也应该提供相应的 placement delete
运算符。当使用 placement new
构造对象时抛出异常时,编译器会自动查找并调用匹配的 placement delete
来执行清理工作。这有助于确保在异常发生时不会发生资源泄漏或其他问题。
53. Item 53: Pay attention to compiler warnings (不要轻忽编译器的警告)
53.1 编译器警告的含义
编译器在编译代码时,除了报告语法错误外,还会发出警告信息。警告通常表明代码可能存在潜在的问题、不符合最佳实践,或者可能导致未定义的行为。虽然警告不会阻止程序编译通过,但它们往往是代码中潜在错误的信号。
53.2 为什么应该重视编译器警告
→ 预示潜在的错误:许多编译器警告都指向了代码中可能存在的逻辑错误、类型不匹配、未使用的变量或不安全的编程实践。忽视这些警告可能会导致程序在运行时出现意想不到的行为甚至崩溃。
→ 提高代码质量:修复编译器警告可以帮助你编写出更清晰、更健壮、更符合规范的代码,从而提高整体的代码质量。
→ 减少调试时间:尽早发现和修复潜在的问题可以大大减少后续的调试时间。一个看似无害的警告可能隐藏着一个难以追踪的 bug。
→ 跨平台兼容性:不同的编译器可能会对相同的代码发出不同的警告。关注并解决警告可以提高代码在不同编译器和平台上的兼容性。
53.3 如何处理编译器警告
→ 理解警告信息:仔细阅读编译器发出的警告信息,理解它所指出的问题是什么。通常,警告信息会包含文件名、行号以及问题的描述。
→ 查看文档:如果不清楚某个警告的含义,可以查阅编译器的文档或在线资源,了解更多关于该警告的信息以及可能的解决方案。
→ 修改代码以消除警告:根据警告信息,修改代码以解决潜在的问题。这可能涉及到更改变量的类型、显式地进行类型转换、初始化变量、移除未使用的代码等。
→ 调整编译选项:大多数编译器都提供了控制警告级别的选项。可以使用更高的警告级别来发现更多潜在的问题。例如,GCC 和 Clang 的 -Wall
和 -Wextra
选项会启用额外的警告。也可以使用 -Werror
选项将所有警告视为错误,强制在修复所有警告后才能编译通过。
→ 使用静态分析工具:除了编译器警告,还可以使用专门的静态分析工具来检查代码中潜在的缺陷和不符合规范的地方。这些工具通常可以发现比编译器更复杂的潜在问题。
53.4 常见的应该重视的警告
→ 未使用的变量 (Unused variables):可能表明代码逻辑存在错误,或者存在冗余代码。
→ 隐式类型转换 (Implicit type conversions):可能导致精度丢失或意外的行为。
→ 有符号/无符号比较 (Signed/unsigned comparison):可能导致逻辑错误。
→ 控制流到达非 void 函数的末尾 (Control reaches end of non-void function):表明函数在某些情况下可能没有返回值。
→ 可能未初始化的变量 (Possibly uninitialized variables):使用未初始化的变量会导致未定义行为。
→ 过时的或不推荐使用的特性 (Deprecated features):表明代码使用了将来可能会被移除或不再支持的语言特性或库函数。
→ 资源泄漏 (Resource leaks):静态分析工具可能会检测到潜在的内存泄漏或其他资源泄漏。
53.5 总结
编译器警告是帮助你发现代码中潜在问题的宝贵信息来源。应该养成重视编译器警告的习惯,仔细理解警告信息,并采取适当的措施来修复代码,消除警告。通过积极地处理编译器警告,可以显著提高代码的质量、健壮性和可维护性,并减少调试时间。
54. Item 54: Familiarize yourself with the standard library, including TR1 (让自己熟悉包括 TR1 在内的标准库)
54.1 C++ 标准库的重要性
C++ 标准库(通常称为 STL,虽然 STL 最初是标准库的一部分,但现在标准库包含更多内容)是 C++ 语言的核心组成部分,提供了丰富的功能,包括:
→ 容器 (Containers):例如 std::vector
、std::list
、std::map
、std::set
等,用于存储和管理数据集合。
→ 算法 (Algorithms):例如 std::sort
、std::find
、std::transform
等,用于对容器中的数据进行各种操作。
→ 迭代器 (Iterators):用于遍历容器中的元素。
→ 函数对象 (Function Objects):例如 std::plus
、std::less
等,以及 lambda 表达式,用于表示可调用的实体。
→ 输入/输出 (I/O):例如 std::iostream
、std::fstream
等,用于进行输入和输出操作。
→ 字符串 (Strings):std::string
类用于处理字符串。
→ 异常处理 (Exception Handling):标准异常类,例如 std::exception
、std::bad_alloc
等。
→ 时间和日期 (Time and Date):例如 <chrono>
库。
→ 数值操作 (Numerics):例如 <cmath>
、<numeric>
等。
→ 并发 (Concurrency):例如 <thread>
、<mutex>
、<future>
等(C++11 及更高版本)。
→ 智能指针 (Smart Pointers):例如 std::unique_ptr
、std::shared_ptr
等(C++11 及更高版本)。
→ 类型支持 (Type Support):例如 <type_traits>
库,用于查询和操作类型信息。
54.2 熟悉标准库的好处
→ 代码重用:标准库提供了经过充分测试和优化的组件,可以直接使用,避免了重复编写代码。
→ 提高效率:标准库的实现通常非常高效,使用标准库可以获得更好的性能。
→ 可移植性:标准库是跨平台的,使用标准库可以提高代码的可移植性。
→ 代码可读性和可维护性:使用标准库可以让代码更易于理解和维护,因为其他 C++ 程序员通常也熟悉标准库。
→ 遵循最佳实践:标准库的设计通常遵循良好的编程原则和最佳实践。
54.3 什么是 TR1
Technical Report 1 (TR1) 是一个由 C++ 标准委员会发布的技术报告,其中包含了一些在 C++03 标准发布后提出的新的库组件的规范。这些组件在 C++11 标准中被大部分采纳。熟悉 TR1 可以帮助你了解 C++11 标准库中一些重要特性的起源和设计思路。
TR1 中包含的一些重要的组件包括(通常位于 std::tr1
命名空间中):
→ 智能指针:例如 std::tr1::shared_ptr
。
→ 函数对象:例如 std::tr1::function
和绑定器。
→ 元组 (Tuples):std::tr1::tuple
。
→ 正则表达式 (Regular Expressions):std::tr1::regex
。
→ 随机数生成器 (Random Number Generation):std::tr1::random
。
→ 类型特征 (Type Traits):std::tr1::type_traits
。
→ 哈希表 (Hash Tables):例如 std::tr1::unordered_map
和 std::tr1::unordered_set
。
54.4 如何熟悉标准库和 TR1
→ 阅读文档:查阅 C++ 标准库和 TR1 的官方文档或相关书籍。
→ 查看示例代码:学习如何使用标准库中的各种组件。
→ 在实际项目中使用:尝试在自己的项目中尽可能地使用标准库提供的功能。
→ 关注 C++ 标准的发展:了解最新的 C++ 标准(例如 C++11、C++14、C++17、C++20 等)引入的新库组件。
54.5 总结
熟悉 C++ 标准库(包括 TR1 中引入并在后续标准中采纳的组件)对于成为一名高效的 C++ 程序员至关重要。标准库提供了丰富的功能,可以帮助你编写出更高效、更可靠、更易于维护和移植的代码。花时间学习和掌握标准库是提高 C++ 编程技能的关键步骤。
55. Item 55: Familiarize yourself with Boost (让自己熟悉 Boost)
55.1 什么是 Boost
Boost C++ Libraries 是一个由 C++ 社区开发和维护的广泛的、开源的 C++ 库集合。Boost 包含了很多高质量的库,涵盖了各种领域,例如:
→ 智能指针
→ 容器和数据结构
→ 算法
→ 函数对象和高阶编程
→ 模板元编程
→ 并发和多线程
→ 网络编程
→ 日期和时间
→ 正则表达式
→ 文件系统
→ 数学和数值计算
→ 图像处理
→ 序列化
→ 单元测试
许多 Boost 库后来被吸纳到 C++ 标准库中(例如 std::shared_ptr
、std::unique_ptr
、std::tuple
、std::regex
、std::unordered_map
等)。
55.2 熟悉 Boost 的好处
→ 扩展标准库的功能:Boost 提供了很多标准库中没有的功能,可以帮助你解决更复杂的问题。
→ 高质量和经过充分测试的代码:Boost 库由经验丰富的 C++ 开发者编写和维护,并且经过了广泛的测试。
→ 学习先进的 C++ 技术:Boost 库使用了许多先进的 C++ 技术,例如模板元编程、泛型编程等,学习 Boost 可以帮助你提高自己的 C++ 水平。
→ 社区支持:Boost 拥有一个活跃的开发者和用户社区,可以提供帮助和支持。
→ 许多 Boost 库已成为或正在成为标准库的一部分:熟悉 Boost 可以让你更好地理解 C++ 标准库的演进方向。
55.3 如何熟悉 Boost
→ 浏览 Boost 官方网站:了解 Boost 提供的各种库。
→ 阅读 Boost 的文档:学习如何使用特定的 Boost 库。
→ 查看示例代码:Boost 库通常包含丰富的示例代码,可以帮助你快速上手。
→ 在实际项目中使用:尝试在自己的项目中使用一些 Boost 库。可以从一些常用的库开始,例如 Boost.Asio(网络)、Boost.Filesystem(文件系统)、Boost.Date_Time(日期时间)、Boost.Smart_Ptr(智能指针,虽然现在标准库也有了,但 Boost 的版本可能更早被广泛使用)、Boost.Test(单元测试)等。
→ 关注 Boost 的更新:Boost 定期发布新版本,包含新的库和对现有库的改进。
55.4 总结
Boost C++ Libraries 是一个非常强大和有用的 C++ 库集合,它可以极大地扩展标准库的功能,并提供很多高质量的工具和组件。熟悉 Boost 可以帮助你编写出更高效、更强大的 C++ 程序,并提高你的 C++ 编程技能。虽然许多 Boost 库已经成为标准库的一部分,但 Boost 仍然在不断发展,并提供了许多前沿的技术和解决方案。因此,花时间熟悉 Boost 是值得每一位 C++ 程序员投入的。