021 《C++ 类全面深度解析》
🌟🌟🌟本文由Gemini 2.5 Flash Preview 04-17生成,用来辅助学习。🌟🌟🌟
书籍大纲
▮▮ 1. C++ 类基础入门 (C++ Class Fundamentals)
▮▮▮▮ 1.1 面向对象编程简介 (Introduction to Object-Oriented Programming)
▮▮▮▮▮▮ 1.1.1 什么是 OOP? (What is OOP?)
▮▮▮▮▮▮ 1.1.2 C++ 与 OOP (C++ and OOP)
▮▮▮▮ 1.2 类与对象 (Class and Object)
▮▮▮▮▮▮ 1.2.1 类的声明与定义 (Class Declaration and Definition)
▮▮▮▮▮▮ 1.2.2 对象的创建与使用 (Object Creation and Usage)
▮▮▮▮ 1.3 成员变量与成员函数 (Member Variables and Member Functions)
▮▮▮▮▮▮ 1.3.1 数据成员 (Data Members)
▮▮▮▮▮▮ 1.3.2 成员函数 (Member Functions)
▮▮▮▮ 1.4 访问控制 (Access Control): public, protected, private
▮▮▮▮▮▮ 1.4.1 public
访问权限 (public
Access)
▮▮▮▮▮▮ 1.4.2 private
访问权限 (private
Access)
▮▮▮▮▮▮ 1.4.3 protected
访问权限 (protected
Access)
▮▮ 2. 对象的生命周期管理 (Managing Object Lifetime)
▮▮▮▮ 2.1 构造函数 (Constructors)
▮▮▮▮▮▮ 2.1.1 默认构造函数 (Default Constructor)
▮▮▮▮▮▮ 2.1.2 带参数的构造函数 (Parameterized Constructors)
▮▮▮▮▮▮ 2.1.3 重载构造函数 (Overloading Constructors)
▮▮▮▮▮▮ 2.1.4 委托构造函数 (Delegating Constructors) (C++11+)
▮▮▮▮ 2.2 析构函数 (Destructors)
▮▮▮▮▮▮ 2.2.1 析构函数的作用与定义 (Role and Definition of Destructors)
▮▮▮▮▮▮ 2.2.2 析构函数调用时机 (Timing of Destructor Calls)
▮▮▮▮ 2.3 对象的存储期 (Object Storage Duration)
▮▮▮▮▮▮ 2.3.1 栈对象 (Stack Objects)
▮▮▮▮▮▮ 2.3.2 堆对象 (Heap Objects)
▮▮▮▮▮▮ 2.3.3 静态存储期对象 (Static Storage Duration Objects)
▮▮ 3. 深入理解成员函数特性 (Understanding Member Function Characteristics)
▮▮▮▮ 3.1 this
指针 (this
Pointer)
▮▮▮▮ 3.2 常量成员函数 (Const Member Functions)
▮▮▮▮▮▮ 3.2.1 const
的位置与含义 (const
Placement and Meaning)
▮▮▮▮▮▮ 3.2.2 常量对象与非常量对象调用 (Calls from Const and Non-Const Objects)
▮▮▮▮ 3.3 静态成员变量与静态成员函数 (Static Member Variables and Static Member Functions)
▮▮▮▮▮▮ 3.3.1 静态成员变量 (Static Member Variables)
▮▮▮▮▮▮ 3.3.2 静态成员函数 (Static Member Functions)
▮▮▮▮ 3.4 内联成员函数 (Inline Member Functions)
▮▮ 4. 拷贝、移动与资源管理 (Copy, Move, and Resource Management)
▮▮▮▮ 4.1 成员初始化列表 (Member Initializer Lists)
▮▮▮▮ 4.2 拷贝构造函数 (Copy Constructors)
▮▮▮▮▮▮ 4.2.1 默认拷贝构造函数 (Default Copy Constructor)
▮▮▮▮▮▮ 4.2.2 自定义拷贝构造函数 (Custom Copy Constructor)
▮▮▮▮▮▮ 4.2.3 何时调用拷贝构造函数 (When Copy Constructor is Called)
▮▮▮▮ 4.3 拷贝赋值运算符 (Copy Assignment Operators)
▮▮▮▮▮▮ 4.3.1 默认拷贝赋值运算符 (Default Copy Assignment Operator)
▮▮▮▮▮▮ 4.3.2 自定义拷贝赋值运算符 (Custom Copy Assignment Operator)
▮▮▮▮▮▮ 4.3.3 自我赋值的防护 (Self-Assignment Protection)
▮▮▮▮ 4.4 移动语义 (Move Semantics) (C++11+)
▮▮▮▮▮▮ 4.4.1 右值引用 (Rvalue References)
▮▮▮▮▮▮ 4.4.2 移动构造函数 (Move Constructors)
▮▮▮▮▮▮ 4.4.3 移动赋值运算符 (Move Assignment Operators)
▮▮▮▮▮▮ 4.4.4 std::move
和 std::forward
▮▮▮▮ 4.5 资源获取即初始化 (RAII: Resource Acquisition Is Initialization)
▮▮▮▮▮▮ 4.5.1 RAII 原则 (RAII Principle)
▮▮▮▮▮▮ 4.5.2 智能指针 (Smart Pointers) (std::unique_ptr, std::shared_ptr)
▮▮ 5. 运算符重载 (Operator Overloading)
▮▮▮▮ 5.1 运算符重载基础 (Operator Overloading Fundamentals)
▮▮▮▮ 5.2 作为成员函数重载 (Overloading as Member Functions)
▮▮▮▮ 5.3 作为非成员函数重载 (Overloading as Non-Member Functions)
▮▮▮▮ 5.4 友元函数与友元类 (Friend Functions and Friend Classes)
▮▮▮▮ 5.5 常用运算符的重载实践 (Practical Operator Overloading)
▮▮ 6. 继承与多态 (Inheritance and Polymorphism)
▮▮▮▮ 6.1 继承的概念与目的 (Concept and Purpose of Inheritance)
▮▮▮▮ 6.2 继承方式 (Inheritance Types): public, protected, private
▮▮▮▮ 6.3 派生类的构造与析构 (Derived Class Constructors and Destructors)
▮▮▮▮ 6.4 多重继承 (Multiple Inheritance)
▮▮▮▮ 6.5 菱形继承与虚继承 (Diamond Problem and Virtual Inheritance)
▮▮▮▮ 6.6 向上转型与向下转型 (Upcasting and Downcasting)
▮▮ 7. 多态的实现与应用 (Implementing and Applying Polymorphism)
▮▮▮▮ 7.1 虚函数 (Virtual Functions)
▮▮▮▮▮▮ 7.1.1 虚函数的工作原理 (How Virtual Functions Work)
▮▮▮▮▮▮ 7.1.2 override
和 final
说明符 (C++11+) (override
and final
Specifiers)
▮▮▮▮ 7.2 纯虚函数与抽象类 (Pure Virtual Functions and Abstract Classes)
▮▮▮▮ 7.3 虚析构函数 (Virtual Destructors)
▮▮▮▮ 7.4 运行时类型信息 (RTTI: Run-Time Type Information)
▮▮▮▮ 7.5 协变返回类型 (Covariant Return Types)
▮▮ 8. 类模板 (Class Templates)
▮▮▮▮ 8.1 类模板的定义与实例化 (Defining and Instantiating Class Templates)
▮▮▮▮ 8.2 类模板的成员函数 (Member Functions of Class Templates)
▮▮▮▮ 8.3 类模板的特化 (Class Template Specialization)
▮▮▮▮▮▮ 8.3.1 完全特化 (Full Specialization)
▮▮▮▮▮▮ 8.3.2 部分特化 (Partial Specialization)
▮▮▮▮ 8.4 类模板与友元 (Class Templates and Friends)
▮▮▮▮ 8.5 类模板与继承 (Class Templates and Inheritance)
▮▮ 9. 异常处理与类 (Exception Handling and Classes)
▮▮▮▮ 9.1 异常安全保证 (Exception Safety Guarantees)
▮▮▮▮ 9.2 利用 RAII 实现异常安全资源管理 (Exception-Safe Resource Management with RAII)
▮▮▮▮ 9.3 构造函数中抛出异常 (Throwing Exceptions in Constructors)
▮▮▮▮ 9.4 析构函数中抛出异常 (Throwing Exceptions in Destructors)
▮▮ 10. 高级类特性 (Advanced Class Features)
▮▮▮▮ 10.1 嵌套类 (Nested Classes)
▮▮▮▮ 10.2 局部类 (Local Classes)
▮▮▮▮ 10.3 Pimpl Idiom (Pointer to Implementation)
▮▮▮▮ 10.4 匿名联合与匿名结构体 (Anonymous Unions and Structures)
▮▮ 11. 类设计原则与最佳实践 (Class Design Principles and Best Practices)
▮▮▮▮ 11.1 SOLID 原则 (SOLID Principles)
▮▮▮▮ 11.2 接口与实现分离 (Separation of Interface and Implementation)
▮▮▮▮ 11.3 避免耦合 (Avoiding Coupling)
▮▮▮▮ 11.4 效率与性能考量 (Efficiency and Performance Considerations)
▮▮▮▮ 11.5 可维护性、可读性与可测试性 (Maintainability, Readability, and Testability)
▮▮▮▮ 11.6 常见类设计模式 (Common Class Design Patterns)
▮▮ 12. 现代 C++ 中的类 (Classes in Modern C++)
▮▮▮▮ 12.1 默认成员函数控制: = default
, = delete
(C++11)
▮▮▮▮ 12.2 委派构造函数 (Delegating Constructors) (C++11)
▮▮▮▮ 12.3 类内成员初始化 (In-class Member Initializers) (C++11)
▮▮▮▮ 12.4 继承构造函数 (Inheriting Constructors) (C++11)
▮▮▮▮ 12.5 结构化绑定 (Structured Bindings) (C++17)
▮▮▮▮ 12.6 Concepts (概念) (C++20)
▮▮▮▮ 12.7 Modules (模块) (C++20)
▮▮ 附录A: C++ 标准对类的定义摘要 (Summary of Class Definition in C++ Standard)
▮▮ 附录B: 常见类相关的错误与陷阱 (Common Class-Related Errors and Pitfalls)
▮▮ 附录C: 推荐阅读与参考文献 (Recommended Reading and References)
▮▮ 附录D: 类相关的工具与调试技巧 (Tools and Debugging Techniques for Classes)
1. C++ 类基础入门 (C++ Class Fundamentals)
欢迎来到本书的第一章!🎉 在本章中,我们将一同踏上 C++ 面向对象编程 (Object-Oriented Programming, OOP) 的旅程。我们将深入学习 C++ 中最为核心和强大的特性之一——类(class)。类是构建现代 C++ 应用程序的基石,理解并掌握类是成为一名优秀的 C++ 程序员的必备能力。
本章旨在为您构建一个坚实的基础,无论您是刚接触 C++ 的编程新手,还是希望系统回顾和深化对类理解的开发者,都能从中受益。我们将从面向对象的基本思想讲起,逐步深入到 C++ 类的基本语法、成员构成以及访问控制等关键概念。通过本章的学习,您将能够理解类与对象的区别与联系,学会如何声明和定义自己的类,以及如何创建和使用类的对象。让我们开始这段精彩的学习之旅吧!🚀
1.1 面向对象编程简介 (Introduction to Object-Oriented Programming)
在深入学习 C++ 的类之前,我们首先需要理解类所服务的编程范式——面向对象编程(OOP)。OOP 是一种广泛采用的软件开发方法论,它将程序组织成相互协作的对象集合,这些对象是类的实例。这种方法有助于提高代码的组织性、可读性、可维护性和可重用性。
1.1.1 什么是 OOP? (What is OOP?)
面向对象编程(OOP)的核心思想是将现实世界中的事物抽象为程序中的“对象”。每个对象都包含了数据(描述事物的属性)和行为(描述事物可以执行的操作或对数据进行的处理)。OOP 的主要特征通常被概括为以下几个方面:
① 封装 (Encapsulation):
▮▮▮▮封装是将数据(成员变量)和操作数据的方法(成员函数)捆绑在一起,形成一个独立的单元——类。
▮▮▮▮它隐藏了对象的内部实现细节,只对外提供有限的接口进行交互。
▮▮▮▮这种隐藏机制被称为信息隐藏 (Information Hiding),是封装的重要目的之一。
▮▮▮▮封装的好处在于:
▮▮▮▮▮▮▮▮❶ 提高安全性:防止外部代码随意修改对象的内部状态。
▮▮▮▮▮▮▮▮❷ 简化使用:用户只需关心对象提供的公共接口,无需了解复杂的内部工作原理。
▮▮▮▮▮▮▮▮❸ 增强可维护性:修改类的内部实现不会影响到使用其公共接口的外部代码。
② 继承 (Inheritance):
▮▮▮▮继承允许一个类(派生类 (Derived Class) 或 子类 (Subclass))继承另一个类(基类 (Base Class) 或 父类 (Superclass))的属性和行为。
▮▮▮▮这建立了一种“is-a”(是一个)的关系,例如,“猫”是“动物”的一种。
▮▮▮▮继承的主要目的是实现代码的重用 (Code Reusability)。派生类可以复用基类的代码,并在此基础上添加新的特性或修改现有行为。
③ 多态 (Polymorphism):
▮▮▮▮多态意味着“多种形态”。在 OOP 中,多态允许使用统一的接口来处理不同类型的对象。
▮▮▮▮最常见的形式是运行时多态(或称动态多态),通过基类指针或引用调用虚函数时,实际执行哪个函数取决于运行时对象的实际类型。
▮▮▮▮多态的好处在于:
▮▮▮▮▮▮▮▮❶ 增加灵活性:可以编写通用的代码来处理一个类族的对象。
▮▮▮▮▮▮▮▮❷ 降低耦合度:代码依赖于接口而不是具体的实现。
④ 抽象 (Abstraction):
▮▮▮▮抽象是忽略不相关的细节,只关注与当前问题相关的关键特性和行为的过程。
▮▮▮▮类本身就是对现实世界实体的一种抽象。我们创建一个 Car
类,不需要包含汽车所有的物理细节,只需要抽象出与其在特定程序中的用途相关的属性(如颜色、品牌、速度)和行为(如启动、加速、刹车)。
▮▮▮▮抽象帮助我们管理复杂性。
1.1.2 C++ 与 OOP (C++ and OOP)
C++ 是一种强大的多范式编程语言,它对面向对象编程提供了全面的支持。在 C++ 中,类 (class) 是实现封装、继承和多态等 OOP 特性的核心工具。
C++ 如何支持 OOP:
⚝ 类 (Class):C++ 中的 class
关键字允许我们定义自己的抽象数据类型,这是实现封装的基础。类可以将数据(成员变量)和函数(成员函数)封装在一起。
⚝ 对象 (Object):类是蓝图,对象是类的具体实例。我们可以创建多个同一类的对象,每个对象拥有自己的数据副本。
⚝ 继承 (Inheritance):C++ 通过 :
语法支持单继承和多重继承,允许类之间建立父子关系,实现代码重用。
⚝ 多态 (Polymorphism):C++ 通过虚函数 (Virtual Functions) 和纯虚函数 (Pure Virtual Functions) 实现运行时多态。指针或引用指向基类对象时,可以根据实际对象的类型调用正确的函数版本。
⚝ 访问控制 (Access Control):C++ 提供了 public
、protected
、private
三种访问修饰符,用于控制类成员的可见性,这是实现封装和信息隐藏的关键机制。
理解 C++ 中的类,就是理解 C++ 如何以一种强大而灵活的方式支持这些面向对象的核心概念。接下来的章节将详细阐述 C++ 类的各个方面。
1.2 类与对象 (Class and Object)
类和对象是面向对象编程中紧密相关的两个概念,理解它们的区别与联系至关重要。
1.2.1 类的声明与定义 (Class Declaration and Definition)
类是用户自定义的数据类型,它是创建对象的蓝图或模板。类本身不占用内存空间(除了某些特定的元数据,这通常由编译器管理),它只定义了对象将拥有的属性(数据)和行为(函数)。
类的声明通常在头文件 (.h 或 .hpp) 中进行,它告诉编译器类的名称、它包含哪些成员(成员变量和成员函数)以及这些成员的访问权限。类的定义包含了成员函数的具体实现,通常在源文件 (.cpp) 中进行。
类的声明语法:
1
class ClassName {
2
public:
3
// 公共成员 (对外提供接口)
4
// 成员变量声明
5
// 成员函数声明
6
7
protected:
8
// 保护成员 (对类内部和派生类可见)
9
// 成员变量声明
10
// 成员函数声明
11
12
private:
13
// 私有成员 (只能在类内部访问)
14
// 成员变量声明
15
// 成员函数声明
16
}; // 注意:类声明结束有分号
⚝ class
:关键字,用于声明一个类。
⚝ ClassName
:您为类指定的名称(通常遵循 PascalCase 命名约定)。
⚝ {}
:花括号包含类的成员列表。
⚝ public:
, protected:
, private:
:访问修饰符 (Access Specifiers),用于控制其后面声明的成员的访问权限。这些修饰符可以出现多次,成员的访问权限由其前面最近的访问修饰符决定。如果没有指定修饰符,类成员的默认访问权限是 private
。
⚝ 成员变量:声明数据成员,就像声明普通变量一样。
⚝ 成员函数:声明成员函数,就像声明普通函数一样,但它们是类的组成部分。
示例声明:
1
// in Person.h
2
#ifndef PERSON_H
3
#define PERSON_H
4
5
#include <string>
6
7
class Person {
8
public:
9
// 构造函数声明 (特殊成员函数,用于初始化对象)
10
Person(std::string name, int age);
11
12
// 成员函数声明
13
void displayInfo();
14
void setAge(int newAge);
15
int getAge() const; // const 成员函数,承诺不修改对象状态
16
17
private:
18
// 数据成员声明
19
std::string _name; // 通常以下划线或 m_ 开头表示成员变量
20
int _age;
21
};
22
23
#endif // PERSON_H
类的定义(成员函数的实现)语法:
成员函数的定义通常在 .cpp
文件中,需要使用作用域解析运算符 (Scope Resolution Operator) ::
来指明该函数属于哪个类。
1
// in Person.cpp
2
#include "Person.h"
3
#include <iostream>
4
5
// 构造函数的定义
6
Person::Person(std::string name, int age) {
7
_name = name;
8
_age = age;
9
std::cout << "Person object created: " << _name << std::endl;
10
}
11
12
// displayInfo 成员函数的定义
13
void Person::displayInfo() {
14
std::cout << "Name: " << _name << ", Age: " << _age << std::endl;
15
}
16
17
// setAge 成员函数的定义
18
void Person::setAge(int newAge) {
19
if (newAge > 0) {
20
_age = newAge;
21
}
22
}
23
24
// getAge 成员函数的定义
25
int Person::getAge() const {
26
return _age;
27
}
28
29
// 析构函数的定义 (特殊成员函数,用于对象销毁前的清理)
30
// Person::~Person() {
31
// std::cout << "Person object destroyed: " << _name << std::endl;
32
// }
📢 注意:上面示例中的构造函数 Person(std::string name, int age)
和析构函数 ~Person()
是特殊成员函数,我们将在下一章详细讨论。这里的目的是展示成员函数的定义方式。
1.2.2 对象的创建与使用 (Object Creation and Usage)
对象是类的具体实例(instance)。当我们创建一个类的对象时,实际上是根据类的蓝图在内存中分配了一块空间,用于存储该对象的数据成员,并可以通过对象调用类中定义的成员函数。
对象的创建方式主要有两种:
① 栈上创建对象 (Stack Allocation):
▮▮▮▮这是最常见的方式,直接声明类类型的变量。对象在栈上分配内存,其生命周期由其作用域决定。
▮▮▮▮语法:ClassName objectName;
或 ClassName objectName(arguments);
▮▮▮▮优点:创建和销毁快速,内存由编译器自动管理。
▮▮▮▮缺点:生命周期受限于作用域,不适合需要动态管理生命周期的场景。
1
// 创建 Person 类的对象 person1 在栈上
2
Person person1("Alice", 30);
② 堆上创建对象 (Heap Allocation):
▮▮▮▮使用 new
运算符在自由存储区(heap)分配内存创建对象。new
运算符返回一个指向新创建对象的指针。
▮▮▮▮语法:ClassName* pointerName = new ClassName;
或 ClassName* pointerName = new ClassName(arguments);
▮▮▮▮优点:生命周期不受限于作用域,可以在程序运行时动态决定创建和销毁。适合创建大型对象或需要长期存在的对象。
▮▮▮▮缺点:需要手动使用 delete
运算符释放内存,否则会导致内存泄漏 (Memory Leak)。
1
// 创建 Person 类的对象在堆上,并使用指针 personPtr 指向它
2
Person* personPtr = new Person("Bob", 25);
3
4
// 使用完毕后,需要手动释放内存
5
// delete personPtr;
⚠️ 重要提示:手动管理堆内存(new
和 delete
)容易出错。在现代 C++ 中,强烈推荐使用智能指针 (Smart Pointers)(如 std::unique_ptr
, std::shared_ptr
)来管理堆上的对象,它们利用 RAII (Resource Acquisition Is Initialization) 原则自动进行内存释放,从而避免内存泄漏。我们将在第 4 章详细介绍 RAII 和智能指针。
对象的成员访问:
创建对象后,我们可以通过成员访问运算符 (Member Access Operator) 来访问对象的成员变量和成员函数。
⚝ 对于栈上的对象(或对象的引用),使用点运算符 (.
)。
⚝ 对于堆上的对象(通过指针访问),使用箭头运算符 (->
)。
1
Person person1("Alice", 30); // 栈对象
2
Person* personPtr = new Person("Bob", 25); // 堆对象
3
4
// 访问栈对象的成员
5
person1.displayInfo(); // 调用成员函数
6
person1.setAge(31); // 调用成员函数
7
int age1 = person1.getAge(); // 调用成员函数并获取返回值
8
// 不能直接访问私有成员,例如: person1._age = 32; // 错误!
9
10
// 访问堆对象的成员 (通过指针)
11
personPtr->displayInfo(); // 调用成员函数
12
personPtr->setAge(26); // 调用成员函数
13
int age2 = personPtr->getAge(); // 调用成员函数
14
// 不能直接访问私有成员,例如: personPtr->_age = 27; // 错误!
15
16
// 释放堆内存 (如果使用了 new)
17
// delete personPtr;
1.3 成员变量与成员函数 (Member Variables and Member Functions)
类是数据(成员变量)和行为(成员函数)的集合。
1.3.1 数据成员 (Data Members)
数据成员 (Data Members) 也称为属性 (Attributes) 或特性 (Properties),它们是定义在类内部的变量,用于存储对象的状态或属性。每个对象都有其自己的一套数据成员副本(静态成员除外,静态成员是类级别的)。
数据成员可以是任何有效的 C++ 数据类型,包括基本类型(如 int
, double
, bool
)和其他自定义类型(如其他类的对象)。
示例:
1
class Car {
2
private:
3
std::string _brand; // 品牌
4
std::string _model; // 型号
5
int _year; // 年份
6
double _speed; // 当前速度
7
8
public:
9
// ... 成员函数声明 ...
10
};
在这个 Car
类中,_brand
, _model
, _year
, _speed
都是数据成员,它们描述了汽车的属性。这些成员通常声明为 private
,以实现封装,防止外部代码直接访问和修改,只能通过提供的公共成员函数(如 getter/setter 函数)来访问和修改。
1.3.2 成员函数 (Member Functions)
成员函数 (Member Functions) 也称为方法 (Methods) 或行为 (Behaviors),它们是定义在类内部的函数,用于执行操作、访问或修改数据成员,或与对象进行交互。成员函数定义了类对象的行为能力。
成员函数可以访问同一类中所有其他成员(包括私有和保护成员),而不受访问控制的限制。
示例:
1
class Car {
2
private:
3
std::string _brand;
4
std::string _model;
5
int _year;
6
double _speed;
7
8
public:
9
// 构造函数
10
Car(std::string brand, std::string model, int year) {
11
_brand = brand;
12
_model = model;
13
_year = year;
14
_speed = 0.0; // 初始化速度为0
15
}
16
17
// 成员函数:加速
18
void accelerate(double amount) {
19
_speed += amount;
20
}
21
22
// 成员函数:刹车
23
void brake(double amount) {
24
_speed -= amount;
25
if (_speed < 0) _speed = 0;
26
}
27
28
// 成员函数:获取当前速度
29
double getCurrentSpeed() const {
30
return _speed;
31
}
32
33
// 成员函数:显示汽车信息
34
void displayInfo() {
35
std::cout << "Brand: " << _brand << ", Model: " << _model
36
<< ", Year: " << _year << ", Speed: " << _speed << " km/h" << std::endl;
37
}
38
};
在这个 Car
类中,Car()
, accelerate()
, brake()
, getCurrentSpeed()
, displayInfo()
都是成员函数,它们定义了 Car
对象的行为,比如如何创建一辆车,如何让它加速或刹车,如何获取当前速度,以及如何显示信息。
成员函数与普通函数的区别:
⚝ 成员函数是类的组成部分,它们的操作通常与特定对象的状态相关。
⚝ 成员函数可以直接访问其所属对象的成员变量,无需通过参数传递(除了需要访问其他对象的成员)。
⚝ 成员函数可以有访问修饰符(public
, protected
, private
),控制其在类外部的可访问性。
1.4 访问控制 (Access Control): public, protected, private
访问控制是 C++ 实现封装和信息隐藏的关键机制。通过访问修饰符,我们可以控制类成员(包括成员变量和成员函数)在类外部的可访问性。C++ 提供了三种访问修饰符:public
、private
和 protected
。
1.4.1 public
访问权限 (public
Access)
public
成员是类的公共接口。任何在程序中能够访问到该类对象的地方,都可以直接访问其 public
成员。
特性:
⚝ 对类的内部和外部都完全可见和可访问。
⚝ 通常用于暴露类提供的服务和功能(成员函数)以及少量对外可见的数据。
示例:
1
class Example {
2
public:
3
int publicData; // 公共数据成员
4
5
void publicMethod() { // 公共成员函数
6
std::cout << "This is a public method." << std::endl;
7
}
8
};
9
10
int main() {
11
Example obj;
12
obj.publicData = 10; // 可以直接访问公共数据成员
13
obj.publicMethod(); // 可以直接调用公共成员函数
14
return 0;
15
}
1.4.2 private
访问权限 (private
Access)
private
成员是类的内部实现细节,只能在类的内部(即该类的成员函数中)访问。在类的外部,包括 main 函数或其他类的成员函数,都不能直接访问 private
成员。
特性:
⚝ 只能在类内部访问。
⚝ 这是实现封装和信息隐藏的主要手段,用于保护类的内部状态不被随意修改。
⚝ 通常用于声明数据成员和仅供类内部使用的辅助成员函数。
示例:
1
class Example {
2
private:
3
int privateData; // 私有数据成员
4
5
void privateMethod() { // 私有成员函数
6
std::cout << "This is a private method." << std::endl;
7
}
8
9
public:
10
// 公共成员函数可以访问私有成员
11
void setPrivateData(int value) {
12
privateData = value; // 在类内部访问私有成员
13
privateMethod(); // 在类内部调用私有成员函数
14
}
15
16
int getPrivateData() const {
17
return privateData; // 在类内部访问私有成员
18
}
19
};
20
21
int main() {
22
Example obj;
23
// obj.privateData = 10; // 错误! 不能直接访问私有数据成员
24
// obj.privateMethod(); // 错误! 不能直接调用私有成员函数
25
26
obj.setPrivateData(10); // 通过公共接口间接访问私有成员
27
std::cout << obj.getPrivateData() << std::endl; // 通过公共接口间接访问私有成员
28
return 0;
29
}
1.4.3 protected
访问权限 (protected
Access)
protected
成员的访问权限介于 public
和 private
之间。它们可以在类的内部访问,也可以在该类的派生类(子类)内部访问,但在类的外部是不可直接访问的。
特性:
⚝ 可以在类内部访问。
⚝ 可以在该类的派生类内部访问。
⚝ 在类的外部不可直接访问。
⚝ 主要用于继承场景,允许子类访问父类的一些成员,但仍对外隐藏。
示例:
1
class Base {
2
protected:
3
int protectedData; // 保护数据成员
4
5
void protectedMethod() { // 保护成员函数
6
std::cout << "This is a protected method in Base." << std::endl;
7
}
8
9
public:
10
// 公共成员函数可以访问保护成员
11
void accessProtectedMembers() {
12
protectedData = 100;
13
protectedMethod();
14
}
15
};
16
17
// Derived 类继承自 Base 类
18
class Derived : public Base {
19
public:
20
void accessBaseProtectedMembers() {
21
protectedData = 200; // 在派生类内部访问基类的保护成员 (OK)
22
protectedMethod(); // 在派生类内部调用基类的保护成员函数 (OK)
23
std::cout << "Accessed protectedData from Derived: " << protectedData << std::endl;
24
}
25
};
26
27
int main() {
28
Base baseObj;
29
// baseObj.protectedData = 10; // 错误! 在类外部不能直接访问保护成员
30
// baseObj.protectedMethod(); // 错误! 在类外部不能直接调用保护成员函数
31
baseObj.accessProtectedMembers(); // 通过公共接口访问 (OK)
32
33
Derived derivedObj;
34
// derivedObj.protectedData = 300; // 错误! 在类外部不能直接访问保护成员
35
derivedObj.accessBaseProtectedMembers(); // 在派生类中通过其成员函数访问基类的保护成员 (OK)
36
37
return 0;
38
}
在上面的示例中,Derived
类作为 Base
类的派生类,可以访问 Base
类中的 protectedData
和 protectedMethod()
。但无论是 Base
类对象 baseObj
还是 Derived
类对象 derivedObj
,在 main
函数中都不能直接访问它们的保护成员。
访问权限总结:
访问修饰符 (Access Specifier ) | 在类内部 (Inside the class ) | 在派生类内部 (Inside a derived class ) | 在类外部 (Outside the class ) |
---|---|---|---|
public | ✅ 可访问 | ✅ 可访问 | ✅ 可访问 |
protected | ✅ 可访问 | ✅ 可访问 | ❌ 不可访问 |
private | ✅ 可访问 | ❌ 不可访问 | ❌ 不可访问 |
这一章我们学习了面向对象编程的基本概念以及 C++ 中类和对象的基础知识,包括类的声明与定义、对象的创建与使用,以及成员变量、成员函数和访问控制。这些是构建 C++ 面向对象程序的基础。在下一章中,我们将深入探讨对象的生命周期管理,特别是构造函数和析构函数的作用。
2. 对象的生命周期管理 (Managing Object Lifetime)
欢迎来到本书的第二章!👋 在上一章中,我们学习了 C++ 中类(class)和对象(object)的基本概念,理解了类如何作为用户自定义类型来封装数据和行为。本章将深入探讨 C++ 中一个至关重要的概念:对象的生命周期(object lifetime)。
理解对象的生命周期,意味着你要清楚一个对象何时被创建(诞生),何时被初始化,何时被使用,以及何时被销毁(死亡)。这个过程远不止简单的内存分配和释放。对象的创建涉及到初始化成员变量的复杂逻辑,而对象的销毁则可能需要执行资源清理操作。掌握对象的生命周期管理,是编写安全、高效且没有资源泄露的 C++ 代码的关键🔑。
本章我们将聚焦于以下几个核心话题:对象的初始化机制——构造函数(Constructor),对象的清理机制——析构函数(Destructor),以及不同类型的对象在内存中的存在方式和它们的生命周期特性——对象的存储期(Storage Duration)。无论你是初学者还是有经验的开发者,深入理解这些概念都将极大地提升你对 C++ 面向对象编程的掌控力。
2.1 构造函数 (Constructors)
每一个 C++ 对象在被创建时都需要进行初始化。想象一下,如果你创建了一个表示“银行账户”的对象,你不希望它里面存储的金额是一个随机的、不可预测的值。你可能希望它在创建时默认为零,或者由创建者指定一个初始金额。构造函数(Constructor)就是 C++ 中用于执行对象初始化工作的特殊成员函数(member function)。
构造函数与普通成员函数有几个显著的区别:
⚝ 构造函数与类同名。
⚝ 构造函数没有返回类型,甚至连 void
也不写。
⚝ 构造函数在对象创建时自动被调用。
⚝ 构造函数不能被显式调用(除了在极少数特定的初始化场景,比如 Placement New)。
⚝ 构造函数不能被继承,但派生类(derived class)的构造函数会负责调用基类(base class)的构造函数。
⚝ 构造函数可以是内联(inline)的。
构造函数的主要任务是建立对象的有效状态(valid state),即确保对象的成员变量在对象开始其生命周期时就具有有意义的值,并执行任何必要的设置操作,比如分配资源。
2.1.1 默认构造函数 (Default Constructor)
默认构造函数(Default Constructor)是指那些可以在不提供任何参数的情况下被调用的构造函数。它主要用于创建不带特定初始值的对象。
如果一个类(class)没有声明任何构造函数,并且它没有基类也没有虚函数,那么编译器(compiler)会为你自动生成一个公有(public)、内联(inline)的默认构造函数。这个编译器生成的默认构造函数会调用其成员变量的默认构造函数(如果是类类型),或者对内置类型(built-in types)不做任何初始化(这意味着它们的值是不确定的,即“垃圾值”)。对于非静态成员变量(non-static data members),如果在声明时提供了类内初始化(in-class initializer, C++11+),编译器生成的默认构造函数会使用这个初始化值。
然而,一旦你为类声明了任何一个构造函数(无论是带参数的还是无参数的),编译器就不再自动生成默认构造函数了。如果你仍然需要一个无参数的构造函数,你就必须自己显式地声明和定义它。
示例:
1
#include <iostream>
2
#include <string>
3
4
class MyClass {
5
public:
6
int id;
7
std::string name;
8
9
// 用户自定义的默认构造函数
10
MyClass() {
11
std::cout << "MyClass() constructor called." << std::endl;
12
id = 0; // 显式初始化内置类型成员
13
name = "Default"; // 显式初始化类类型成员
14
}
15
16
// 用户自定义的带参数构造函数(稍后会详细介绍)
17
MyClass(int i, const std::string& n) {
18
std::cout << "MyClass(int, string) constructor called." << std::endl;
19
id = i;
20
name = n;
21
}
22
23
void display() const {
24
std::cout << "ID: " << id << ", Name: " << name << std::endl;
25
}
26
};
27
28
int main() {
29
// 调用默认构造函数创建对象
30
MyClass obj1; // 注意:不要写成 MyClass obj1(); 除非是函数声明
31
32
obj1.display();
33
34
// 如果没有上面自定义的MyClass(),并且你定义了 MyClass(int, string)
35
// MyClass obj2; // 这会编译错误,因为编译器不再提供默认构造函数
36
37
return 0;
38
}
在这个例子中,我们显式地定义了一个 MyClass()
构造函数,它不接受参数,这就是一个默认构造函数。它确保了对象 obj1
的 id
初始化为 0,name
初始化为 "Default"。
如果没有自定义构造函数,或者显式地使用 = default
(C++11+) 请求编译器生成默认构造函数:
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
5
class AnotherClass {
6
public:
7
int value;
8
std::vector<int> data; // vector 是类类型,有自己的默认构造函数
9
10
// 没有自定义任何构造函数,编译器会自动生成默认构造函数
11
// 或者使用 = default 明确要求编译器生成
12
// AnotherClass() = default;
13
14
// 在这里,value 是内置类型,编译器生成的默认构造函数不会初始化它
15
// data 是 std::vector,其默认构造函数会被调用,data 是一个空的 vector
16
};
17
18
class YetAnotherClass {
19
public:
20
int value = 100; // C++11+ 类内成员初始化
21
std::string name = "Initialized"; // C++11+ 类内成员初始化
22
23
YetAnotherClass() = default; // 显式要求编译器生成默认构造函数,它会使用类内初始化
24
25
void display() const {
26
std::cout << "Value: " << value << ", Name: " << name << std::endl;
27
}
28
};
29
30
31
int main() {
32
AnotherClass objA;
33
// std::cout << "objA.value: " << objA.value << std::endl; // value 的值是不确定的!
34
std::cout << "objA.data size: " << objA.data.size() << std::endl; // size 是 0
35
36
YetAnotherClass objB; // 调用编译器生成的默认构造函数,它使用类内初始化
37
objB.display(); // 输出 Value: 100, Name: Initialized
38
39
return 0;
40
}
重要提示:在使用编译器生成的默认构造函数时要小心,尤其是当类包含内置类型成员时,因为它们可能不会被初始化。使用成员初始化列表(member initializer list,将在 2.4.1 节详细介绍)或在类内进行初始化(C++11+)是更好的做法。
2.1.2 带参数的构造函数 (Parameterized Constructors)
带参数的构造函数(Parameterized Constructors)允许你在创建对象时为其提供初始值。这使得对象的创建更加灵活,可以根据不同的需求创建具有不同初始状态的对象。
定义带参数的构造函数与定义普通函数类似,只是没有返回类型,且函数名与类名相同。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Point {
5
public:
6
double x;
7
double y;
8
9
// 带参数的构造函数
10
Point(double initialX, double initialY) {
11
std::cout << "Point(double, double) constructor called." << std::endl;
12
x = initialX; // 初始化成员变量
13
y = initialY;
14
}
15
16
// 默认构造函数(如果需要无参数创建对象)
17
Point() {
18
std::cout << "Point() constructor called." << std::endl;
19
x = 0.0;
20
y = 0.0;
21
}
22
23
void display() const {
24
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
25
}
26
};
27
28
int main() {
29
// 使用带参数的构造函数创建对象
30
Point p1(1.0, 2.5); // 直接初始化 syntax
31
p1.display();
32
33
Point p2 = Point(3.0, 4.0); // 拷贝初始化 syntax (通常会被优化为直接初始化)
34
p2.display();
35
36
Point p3; // 使用默认构造函数
37
p3.display();
38
39
return 0;
40
}
在这个例子中,Point(double initialX, double initialY)
是一个带参数的构造函数,允许我们在创建 Point
对象时指定其 x
和 y
坐标的初始值。
成员初始化列表:上面的构造函数体中使用了赋值操作符 =
来初始化成员变量。虽然这在很多情况下是可行的,但更推荐的做法是使用成员初始化列表(member initializer list)。成员初始化列表在构造函数的参数列表后,函数体之前,用冒号 :
引导。
修改后的 Point 类构造函数:
1
class Point {
2
public:
3
double x;
4
double y;
5
6
// 使用成员初始化列表的带参数构造函数
7
Point(double initialX, double initialY) : x(initialX), y(initialY) {
8
std::cout << "Point(double, double) constructor called with initializer list." << std::endl;
9
// 构造函数体在这里执行,通常用于更复杂的设置逻辑,
10
// 而成员变量的初始化已经在初始化列表中完成了
11
}
12
13
// 使用成员初始化列表的默认构造函数
14
Point() : x(0.0), y(0.0) {
15
std::cout << "Point() constructor called with initializer list." << std::endl;
16
}
17
18
void display() const {
19
std::cout << "Point(" << x << ", " << y << ")" << std::endl;
20
}
21
};
使用成员初始化列表有几个优点:
⚝ 对于常量成员(const member
)和引用成员(reference member
),必须使用成员初始化列表进行初始化,因为它们在对象构造后就不能被赋值。
⚝ 对于类类型的成员,使用初始化列表直接调用其构造函数进行初始化比在构造函数体内先默认构造再赋值效率更高。
⚝ 初始化列表按照成员变量在类中声明的顺序执行初始化,这有助于避免一些潜在的错误。
我们将在 2.4.1 节对成员初始化列表进行更深入的讨论。
2.1.3 重载构造函数 (Overloading Constructors)
一个类可以有多个构造函数,只要它们的参数列表不同,这就是构造函数重载(Overloading Constructors)。就像普通函数可以重载一样,构造函数也可以根据不同的参数个数或参数类型提供多种初始化的方式。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Rectangle {
5
public:
6
double width;
7
double height;
8
std::string color;
9
10
// 默认构造函数:创建一个单位正方形
11
Rectangle() : width(1.0), height(1.0), color("transparent") {
12
std::cout << "Rectangle() called." << std::endl;
13
}
14
15
// 带参数构造函数:指定宽度和高度
16
Rectangle(double w, double h) : width(w), height(h), color("transparent") {
17
std::cout << "Rectangle(double, double) called." << std::endl;
18
}
19
20
// 带参数构造函数:指定宽度、高度和颜色
21
Rectangle(double w, double h, const std::string& c) : width(w), height(h), color(c) {
22
std::cout << "Rectangle(double, double, string) called." << std::endl;
23
}
24
25
// 带参数构造函数:创建一个正方形,指定边长
26
Rectangle(double side) : width(side), height(side), color("transparent") {
27
std::cout << "Rectangle(double) called (for square)." << std::endl;
28
}
29
30
31
void display() const {
32
std::cout << "Rectangle: Width=" << width << ", Height=" << height << ", Color=" << color << std::endl;
33
}
34
};
35
36
int main() {
37
Rectangle r1; // 调用 Rectangle()
38
r1.display();
39
40
Rectangle r2(5.0, 10.0); // 调用 Rectangle(double, double)
41
r2.display();
42
43
Rectangle r3(2.0, 3.0, "red"); // 调用 Rectangle(double, double, string)
44
r3.display();
45
46
Rectangle r4(7.0); // 调用 Rectangle(double)
47
r4.display();
48
49
return 0;
50
}
通过重载构造函数,我们可以根据不同的初始化场景选择最合适的构造函数,使对象的创建更加灵活和直观。
2.1.4 委托构造函数 (Delegating Constructors) (C++11+)
在 C++11 及以后的版本中,引入了委托构造函数(Delegating Constructors)的特性。这个特性允许一个构造函数调用(或委托给)同一个类中的另一个构造函数来完成部分的初始化工作。
这在类的构造函数逻辑有大量重复部分时非常有用。通过委托,你可以将共同的初始化代码放在一个构造函数中,然后其他构造函数只负责处理它们特有的初始化部分,最后委托给那个通用的构造函数。这有助于减少代码重复,提高可维护性。
委托构造函数的语法是在构造函数的成员初始化列表位置调用另一个构造函数。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Product {
5
public:
6
int id;
7
std::string name;
8
double price;
9
int quantity;
10
11
// 通用初始化构造函数
12
Product(int i, const std::string& n, double p, int q) : id(i), name(n), price(p), quantity(q) {
13
std::cout << "Product(int, string, double, int) called. Full initialization." << std::endl;
14
}
15
16
// 委托构造函数 1: 只提供 id 和 name,委托给通用构造函数,设定默认价格和数量
17
Product(int i, const std::string& n) : Product(i, n, 0.0, 0) {
18
std::cout << "Product(int, string) called. Delegating..." << std::endl;
19
// 可以在这里添加 Product(int, string) 特有的逻辑,但通常只做委托
20
}
21
22
// 委托构造函数 2: 只提供 id,委托给通用构造函数,设定默认名称、价格和数量
23
Product(int i) : Product(i, "Unnamed Product", 0.0, 0) {
24
std::cout << "Product(int) called. Delegating..." << std::endl;
25
}
26
27
// 默认构造函数: 委托给通用构造函数,设定所有默认值
28
Product() : Product(0, "Unnamed Product", 0.0, 0) {
29
std::cout << "Product() called. Delegating..." << std::endl;
30
}
31
32
void display() const {
33
std::cout << "Product: ID=" << id << ", Name='" << name << "', Price=" << price << ", Quantity=" << quantity << std::endl;
34
}
35
};
36
37
int main() {
38
Product p1(101, "Laptop", 999.99, 5); // 调用通用构造函数
39
p1.display();
40
41
Product p2(102, "Mouse"); // 调用委托构造函数 1
42
p2.display();
43
44
Product p3(103); // 调用委托构造函数 2
45
p3.display();
46
47
Product p4; // 调用委托构造函数 (默认构造函数)
48
p4.display();
49
50
return 0;
51
}
在上面的例子中,Product(int, string)
, Product(int)
和 Product()
构造函数都委托给了 Product(int, string, double, int)
构造函数来完成实际的成员初始化。委托构造函数中不能同时使用成员初始化列表来初始化成员变量,因为初始化工作已经委托给另一个构造函数去完成了。
委托构造函数极大地提高了构造函数的可读性和可维护性,尤其是在类有多个构造函数且它们之间存在共同的初始化逻辑时。
2.2 析构函数 (Destructors)
与构造函数相反,析构函数(Destructor)是对象生命周期结束时自动被调用的特殊成员函数。它的主要作用是执行清理工作,释放对象在构造期间或者生命周期内获取的资源。
比如,如果一个对象在创建时分配了内存(使用 new
),那么它需要在销毁时释放这块内存(使用 delete
),以避免内存泄露(memory leak)。如果一个对象打开了一个文件句柄或网络连接,那么它需要在销毁时关闭这些句柄或连接。
析构函数与构造函数类似,也有一些特殊规则:
⚝ 析构函数与类同名,但在名字前加上波浪号 ~
。例如,类 MyClass
的析构函数是 ~MyClass()
。
⚝ 析构函数没有返回类型,甚至连 void
也不写。
⚝ 析构函数不接受任何参数。一个类只能有一个析构函数。
⚝ 析构函数在对象销毁时自动被调用。
⚝ 析构函数不能被显式调用(除了在极少数特定的清理场景,比如 Placement Delete)。
⚝ 析构函数可以是虚函数(virtual function),这对于多态(polymorphism)非常重要(将在 2.6.3 节详细介绍虚析构函数)。
⚝ 析构函数不能是静态成员(static member)。
2.2.1 析构函数的作用与定义 (Role and Definition of Destructors)
析构函数的定义非常简单:
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
5
class ResourceWrapper {
6
private:
7
int* data;
8
size_t size;
9
std::string name;
10
11
public:
12
// 构造函数:分配资源
13
ResourceWrapper(size_t s, const std::string& n) : size(s), name(n) {
14
std::cout << "ResourceWrapper(" << size << ", '" << name << "') constructor called. Allocating memory." << std::endl;
15
data = new int[size]; // 分配堆内存
16
// 初始化内存...
17
for(size_t i = 0; i < size; ++i) {
18
data[i] = i * 10;
19
}
20
}
21
22
// 析构函数:释放资源
23
~ResourceWrapper() {
24
std::cout << "ResourceWrapper destructor called for '" << name << "'. Freeing memory." << std::endl;
25
delete[] data; // 释放分配的堆内存
26
data = nullptr; // 避免悬空指针(dangling pointer)
27
}
28
29
// 为了防止浅拷贝(shallow copy)导致 double free,需要自定义拷贝/移动语义
30
// 这部分将在 2.4 节详细讲解
31
// 简单起见,这里暂时忽略自定义拷贝/移动构造函数和赋值运算符
32
33
void display_data() const {
34
std::cout << "Data for '" << name << "': ";
35
if (data && size > 0) {
36
for(size_t i = 0; i < size; ++i) {
37
std::cout << data[i] << (i == size - 1 ? "" : ", ");
38
}
39
std::cout << std::endl;
40
} else {
41
std::cout << "Empty or invalid data." << std::endl;
42
}
43
}
44
};
45
46
int main() {
47
std::cout << "Entering main scope." << std::endl;
48
{ // 新的作用域
49
std::cout << "Entering inner scope." << std::endl;
50
ResourceWrapper obj1(5, "Obj1"); // 创建对象,调用构造函数
51
obj1.display_data();
52
53
ResourceWrapper obj2(3, "Obj2");
54
obj2.display_data();
55
56
std::cout << "Exiting inner scope." << std::endl;
57
} // obj1 和 obj2 在这里超出作用域,它们的析构函数会被自动调用
58
std::cout << "Exiting main scope." << std::endl;
59
60
// 如果没有自定义析构函数,而类成员包含指针指向堆内存,
61
// 编译器生成的默认析构函数不会释放这块内存,导致内存泄露。
62
63
return 0;
64
}
输出示例:
1
Entering main scope.
2
Entering inner scope.
3
ResourceWrapper(5, 'Obj1') constructor called. Allocating memory.
4
Data for 'Obj1': 0, 10, 20, 30, 40
5
ResourceWrapper(3, 'Obj2') constructor called. Allocating memory.
6
Data for 'Obj2': 0, 10, 20
7
Exiting inner scope.
8
ResourceWrapper destructor called for 'Obj2'. Freeing memory.
9
ResourceWrapper destructor called for 'Obj1'. Freeing memory.
10
Exiting main scope.
可以看到,当对象 obj1
和 obj2
超出它们所在的作用域 {}
时,它们的析构函数被自动调用,执行了内存释放操作。
如果一个类没有定义析构函数,编译器也会为它自动生成一个公有(public)、内联(inline)的默认析构函数。编译器生成的析构函数会调用其成员变量的析构函数(如果是类类型),并对基类执行必要的清理(如果存在)。对于内置类型成员,编译器生成的析构函数不做任何操作。当类持有需要手动释放的资源(如堆内存、文件句柄等)时,必须自定义析构函数来执行清理工作。
2.2.2 析构函数调用时机 (Timing of Destructor Calls)
理解析构函数何时被调用对于正确管理资源至关重要。析构函数被自动调用的常见时机包括:
① 自动存储期(automatic storage duration)的对象:
▮▮▮▮当对象超出其作用域(scope)时,例如函数执行完毕、局部块 {}
结束时。
② 动态存储期(dynamic storage duration)的对象:
▮▮▮▮当通过指向该对象的指针使用 delete
运算符释放内存时。例如 delete ptr;
或 delete[] array_ptr;
。重要:如果通过 new
创建的对象没有对应的 delete
调用,就会发生内存泄露。
③ 静态存储期(static storage duration)的对象:
▮▮▮▮程序的 main
函数执行完毕,或者调用了 exit()
函数时。这些对象的析构函数通常按照其构造顺序的逆序被调用。全局对象、静态局部对象都属于此类。
④ 线程存储期(thread storage duration)的对象:
▮▮▮▮当创建该对象的线程结束时。
⑤ 临时对象(temporary objects):
▮▮▮▮临时对象通常在创建它们的完整表达式(full expression)结束时被销毁。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Demo {
5
public:
6
std::string name;
7
Demo(const std::string& n) : name(n) {
8
std::cout << "Demo('" << name << "') constructed." << std::endl;
9
}
10
~Demo() {
11
std::cout << "Demo('" << name << "') destructed." << std::endl;
12
}
13
};
14
15
// 全局对象 (静态存储期)
16
Demo global_obj("Global");
17
18
void func() {
19
// 静态局部对象 (静态存储期)
20
static Demo static_local_obj("Static Local");
21
22
// 栈对象 (自动存储期)
23
Demo stack_obj("Stack in func");
24
25
std::cout << "Inside func()." << std::endl;
26
27
// 临时对象 (通常在语句结束时销毁)
28
// 注意:有些情况下临时对象的生命周期会被延长,比如绑定到 const 引用
29
Demo("Temporary"); // 创建一个临时对象,通常在此行语句结束时销毁
30
std::cout << "Statement with temporary finished." << std::endl;
31
32
} // stack_obj 在这里超出作用域,析构函数被调用
33
34
int main() {
35
std::cout << "Entering main()." << std::endl;
36
37
// 堆对象 (动态存储期)
38
Demo* heap_obj_ptr = new Demo("Heap"); // 在堆上创建对象
39
40
func(); // 调用函数
41
42
std::cout << "Back in main()." << std::endl;
43
44
delete heap_obj_ptr; // 显式释放堆对象,调用析构函数
45
46
std::cout << "Exiting main()." << std::endl;
47
48
// global_obj 和 static_local_obj (如果 func 被调用过) 在程序结束时销毁
49
50
return 0;
51
}
可能的输出顺序(可能会因编译器和环境略有差异,尤其是静态对象的初始化和销毁顺序):
1
Demo('Global') constructed. // 全局对象在 main 之前初始化
2
Entering main().
3
Demo('Heap') constructed. // 堆对象通过 new 创建
4
Inside func().
5
Demo('Static Local') constructed. // 静态局部对象在第一次进入作用域时初始化
6
Demo('Temporary') constructed. // 临时对象创建
7
Demo('Temporary') destructed. // 临时对象销毁 (通常在语句结束)
8
Statement with temporary finished.
9
Demo('Stack in func') destructed. // 栈对象超出 func 作用域销毁
10
Back in main().
11
Demo('Heap') destructed. // 堆对象通过 delete 释放
12
Exiting main().
13
Demo('Static Local') destructed. // 静态局部对象在程序结束时销毁
14
Demo('Global') destructed. // 全局对象在程序结束时销毁
从输出可以看出,不同存储期的对象有不同的构造和析构时机。理解这一点对于正确管理资源,特别是处理动态分配的内存,避免内存泄露或使用已释放内存(use-after-free)等错误至关重要。虚析构函数(将在 2.7.3 节讨论)在通过基类指针删除派生类对象时保证正确的多态销毁行为。
2.3 对象的存储期 (Object Storage Duration)
对象的存储期(Object Storage Duration)决定了对象在内存中存在的时间以及它在程序执行过程中的生命周期。C++ 标准定义了几种主要的存储期:自动存储期、静态存储期、动态存储期和线程存储期(自 C++11 起)。理解对象的存储期有助于预测对象的生命周期,从而正确地管理资源。
2.3.1 栈对象 (Stack Objects)
具有自动存储期(Automatic Storage Duration)的对象通常被称为栈对象(Stack Objects),因为它们通常存储在程序的调用栈(call stack)上。
⚝ 生命周期: 它们的生命周期与其声明所在的作用域(scope)严格绑定。当程序执行进入对象声明所在的作用域时,对象被构造;当程序执行离开该作用域时(无论是正常结束、通过 return
语句返回,还是由于抛出异常),对象的析构函数会自动被调用。
⚝ 内存管理: 内存由编译器自动管理。无需手动分配或释放,这使得栈对象的使用非常安全和方便,且构造和销毁的开销通常很小。
⚝ 声明位置: 通常在函数内部、代码块内部声明的非静态局部变量。
示例:
1
#include <iostream>
2
#include <string>
3
4
class StackDemo {
5
public:
6
std::string name;
7
StackDemo(const std::string& n) : name(n) {
8
std::cout << "StackDemo('" << name << "') constructed." << std::endl;
9
}
10
~StackDemo() {
11
std::cout << "StackDemo('" << name << "') destructed." << std::endl;
12
}
13
};
14
15
void test_stack_objects() {
16
std::cout << "Entering test_stack_objects()." << std::endl;
17
StackDemo obj1("Inner 1"); // obj1 具有自动存储期
18
{ // 新的嵌套作用域
19
std::cout << "Entering nested scope." << std::endl;
20
StackDemo obj2("Inner 2"); // obj2 具有自动存储期
21
std::cout << "Exiting nested scope." << std::endl;
22
} // obj2 在这里超出作用域并被销毁
23
std::cout << "Exiting test_stack_objects()." << std::endl;
24
} // obj1 在这里超出作用域并被销毁
25
26
int main() {
27
std::cout << "Entering main()." << std::endl;
28
StackDemo obj_main("Main"); // obj_main 具有自动存储期
29
30
test_stack_objects(); // 调用函数
31
32
std::cout << "Exiting main()." << std::endl;
33
// obj_main 在这里超出 main() 作用域并被销毁
34
35
return 0;
36
}
输出:
1
Entering main().
2
StackDemo('Main') constructed.
3
Entering test_stack_objects().
4
StackDemo('Inner 1') constructed.
5
Entering nested scope.
6
StackDemo('Inner 2') constructed.
7
Exiting nested scope.
8
StackDemo('Inner 2') destructed.
9
Exiting test_stack_objects().
10
StackDemo('Inner 1') destructed.
11
Exiting main().
12
StackDemo('Main') destructed.
从输出中可以看出,栈对象的构造和析构顺序严格遵循作用域的进入和退出顺序(后构造的先析构,像堆栈一样 LIFO)。
2.3.2 堆对象 (Heap Objects)
具有动态存储期(Dynamic Storage Duration)的对象通常被称为堆对象(Heap Objects),因为它们存储在程序的堆(heap)内存区域。
⚝ 生命周期: 它们的生命周期不与作用域绑定,而是由程序员通过 new
运算符显式创建,并通过 delete
运算符显式销毁。对象从 new
调用成功时开始,直到对应的 delete
调用完成时结束。
⚝ 内存管理: 内存需要手动管理。使用 new
分配内存并创建对象,使用 delete
释放内存并销毁对象。使用 new[]
分配数组,使用 delete[]
释放数组。
⚝ 优点: 可以在运行时动态确定对象的数量或大小,并且对象的生命周期可以跨越创建它的函数或作用域。
⚝ 缺点: 手动内存管理容易出错,如忘记释放内存(导致内存泄露)、多次释放同一块内存(导致程序崩溃)、使用已释放的内存(use-after-free)等。推荐使用智能指针(smart pointers,将在 2.4.5 节详细介绍)来自动化堆对象的内存管理,以实现 RAII (Resource Acquisition Is Initialization) 原则。
示例:
1
#include <iostream>
2
#include <string>
3
4
class HeapDemo {
5
public:
6
std::string data;
7
HeapDemo(const std::string& d) : data(d) {
8
std::cout << "HeapDemo('" << data << "') constructed." << std::endl;
9
}
10
~HeapDemo() {
11
std::cout << "HeapDemo('" << data << "') destructed." << std::endl;
12
}
13
};
14
15
void create_and_destroy_heap_object() {
16
std::cout << "Entering create_and_destroy_heap_object()." << std::endl;
17
18
// 创建堆对象
19
HeapDemo* obj_ptr = new HeapDemo("Heap object"); // 在堆上创建对象,返回指向它的指针
20
21
std::cout << "Heap object created." << std::endl;
22
23
// 使用堆对象 (通过指针访问成员)
24
std::cout << "Object data: " << obj_ptr->data << std::endl;
25
26
// 销毁堆对象
27
delete obj_ptr; // 释放堆内存,调用析构函数
28
29
std::cout << "Heap object destroyed." << std::endl;
30
31
// obj_ptr 现在是一个悬空指针,不要再使用它!
32
obj_ptr = nullptr; // 好的编程习惯,将指针设为空
33
34
std::cout << "Exiting create_and_destroy_heap_object()." << std::endl;
35
}
36
37
int main() {
38
std::cout << "Entering main()." << std::endl;
39
40
create_and_destroy_heap_object(); // 调用函数
41
42
// 示例:内存泄露(如果忘记 delete)
43
// HeapDemo* leaky_obj = new HeapDemo("Leaky object");
44
// std::cout << "Leaky object created (will not be deleted)." << std::endl;
45
// 函数返回,但 leaky_obj 指向的内存没有释放
46
47
std::cout << "Exiting main()." << std::endl;
48
49
return 0;
50
}
输出:
1
Entering main().
2
Entering create_and_destroy_heap_object().
3
HeapDemo('Heap object') constructed.
4
Heap object created.
5
Object data: Heap object
6
HeapDemo('Heap object') destructed.
7
Heap object destroyed.
8
Exiting create_and_destroy_heap_object().
9
Exiting main().
可以看到,堆对象的生命周期完全由 new
和 delete
控制,与函数或作用域的边界无关。这提供了更大的灵活性,但也带来了手动管理内存的风险。
2.3.3 静态存储期对象 (Static Storage Duration Objects)
具有静态存储期(Static Storage Duration)的对象在程序开始运行时被创建,在程序结束时被销毁。它们的内存分配在程序的静态存储区。
⚝ 生命周期: 贯穿程序的整个执行期间。它们在 main
函数开始执行之前被构造(全局对象),或者在第一次执行到其声明语句时被构造(静态局部对象)。它们的析构函数在 main
函数结束或程序通过 exit()
终止时被调用,通常按照构造顺序的逆序进行。
⚝ 内存管理: 内存由编译器自动管理。
⚝ 声明位置: 全局作用域中声明的对象、命名空间作用域中声明的对象、以及函数或代码块内部使用 static
关键字声明的局部对象。
示例:
1
#include <iostream>
2
#include <string>
3
4
class StaticDemo {
5
public:
6
std::string name;
7
StaticDemo(const std::string& n) : name(n) {
8
std::cout << "StaticDemo('" << name << "') constructed." << std::endl;
9
}
10
~StaticDemo() {
11
std::cout << "StaticDemo('" << name << "') destructed." << std::endl;
12
}
13
};
14
15
// 全局对象 (静态存储期)
16
StaticDemo global_static_obj("Global");
17
18
void test_static_local() {
19
std::cout << "Entering test_static_local()." << std::endl;
20
// 静态局部对象 (静态存储期)
21
static StaticDemo static_local_obj("Static Local in func"); // 第一次调用此函数时构造
22
std::cout << "Exiting test_static_local()." << std::endl;
23
}
24
25
int main() {
26
std::cout << "Entering main()." << std::endl;
27
28
test_static_local(); // 第一次调用,构造 static_local_obj
29
test_static_local(); // 第二次调用,不构造,因为已存在
30
31
std::cout << "Exiting main()." << std::endl;
32
// global_static_obj 和 static_local_obj (如果 test_static_local 被调用过)
33
// 在这里程序结束时销毁
34
return 0;
35
}
输出:
1
StaticDemo('Global') constructed. // 全局对象在 main 之前构造
2
Entering main().
3
Entering test_static_local().
4
StaticDemo('Static Local in func') constructed. // 静态局部对象在第一次进入函数时构造
5
Exiting test_static_local().
6
Entering test_static_local(). // 第二次进入函数,静态局部对象已存在,不构造
7
Exiting test_static_local().
8
Exiting main().
9
StaticDemo('Static Local in func') destructed. // 静态局部对象在程序结束时销毁
10
StaticDemo('Global') destructed. // 全局对象在程序结束时销毁
注意全局对象和静态局部对象的构造时机。全局对象在进入 main
函数之前就已构造完成。静态局部对象只在其声明语句被第一次执行到时构造。它们的析构则都在程序正常结束时发生,且顺序与构造顺序相反。静态对象的生命周期长,适合存储程序运行期间需要一直存在的数据或资源,但过多使用可能影响程序的启动和关闭时间,且它们的状态会持续存在,需要注意线程安全问题。
线程存储期对象(Thread Storage Duration)使用 thread_local
关键字声明,它们的生命周期与创建它们的线程绑定,在线程启动时构造,线程结束时销毁。这在并发编程中非常有用。
总结来说,C++ 中的对象生命周期管理是面向对象编程的核心之一。构造函数负责对象的诞生和初始化,析构函数负责对象的死亡和清理,而对象的存储期则决定了它们在整个程序执行期间的存在范围。掌握这些概念是编写健壮、高效 C++ 代码的基础🧱。在接下来的章节中,我们将继续深入探讨与对象生命周期管理密切相关的其他主题,如拷贝、移动和资源管理。
3. 深入理解成员函数特性 (Understanding Member Function Characteristics)
本章深入探讨成员函数的更高级用法,如 this
指针、const
、static
等。
3.1 this
指针 (this
Pointer)
在 C++ 的非静态成员函数(non-static member function)中,存在一个特殊的隐含指针,称为 this
指针。this
指针指向调用该成员函数的对象本身。通过 this
指针,成员函数可以访问调用该函数的对象的成员变量和成员函数。
本质上,当你通过一个对象 obj
调用其成员函数 obj.memberFunction()
时,编译器会将这个调用转换为类似 memberFunction(&obj)
的形式(这是一种概念上的理解,不是实际的函数签名)。在 memberFunction
的内部,可以通过这个隐含传递进来的对象地址来访问对象的成员。这个隐含传递进来的对象地址就是 this
指针。
this
指针的类型是指向类类型的常量指针(constant pointer)。例如,对于一个类 MyClass
,在其成员函数内部,this
指针的类型通常是 MyClass* const
。这意味着 this
指针本身的值(即它指向的对象地址)是不能被修改的,但它指向的对象(如果不是 const
对象)的内容是可以被修改的。
this
指针的作用:
① 区分成员变量与同名局部变量。
② 在成员函数内部返回对当前对象的引用(例如,用于链式调用)。
③ 将当前对象的地址传递给其他函数。
示例:
1
#include <iostream>
2
3
class Box {
4
private:
5
double length;
6
double breadth;
7
double height;
8
9
public:
10
// 构造函数
11
Box(double l, double b, double h) {
12
std::cout << "Constructor called." << std::endl;
13
// 使用 this-> 区分成员变量和参数
14
this->length = l;
15
this->breadth = b;
16
this->height = h;
17
}
18
19
double getVolume() const {
20
return length * breadth * height;
21
}
22
23
// 返回当前对象的引用,用于链式调用
24
Box& setLength(double l) {
25
this->length = l;
26
return *this; // *this 就是当前对象本身
27
}
28
29
Box& setBreadth(double b) {
30
this->breadth = b;
31
return *this;
32
}
33
34
Box& setHeight(double h) {
35
this->height = h;
36
return *this;
37
}
38
39
// 打印当前对象的地址
40
void printAddress() const {
41
std::cout << "Object address: " << this << std::endl;
42
}
43
};
44
45
int main() {
46
Box box1(10.0, 5.0, 2.0);
47
box1.printAddress(); // 打印 box1 的地址
48
std::cout << "Volume: " << box1.getVolume() << std::endl;
49
50
// 使用链式调用修改属性
51
box1.setLength(15.0).setBreadth(7.0).setHeight(3.0);
52
std::cout << "New Volume: " << box1.getVolume() << std::endl;
53
54
return 0;
55
}
在上面的示例中,this->length = l;
明确指定了是为当前对象的 length
成员赋值,即使参数名与成员变量名相同。setLength
, setBreadth
, setHeight
函数通过返回 *this
实现了链式调用。printAddress
函数打印了 this
指针的值,即当前对象的内存地址。
注意:
⚝ 静态成员函数(static member function)没有 this
指针,因为它们不与任何特定的对象实例关联。
⚝ this
指针是一个右值(rvalue)。在 C++11 之后,可以通过引用限定符(reference qualifier)来区分左值(lvalue)和右值对象调用的成员函数。
3.2 常量成员函数 (Const Member Functions)
常量成员函数是在其声明末尾带有 const
关键字的成员函数。这个 const
关键字表明该成员函数承诺不会修改调用它的对象的任何非静态(non-static)成员变量的状态。
const
成员函数的重要性:
① 确保对象的常量正确性(const correctness)。如果你有一个指向常量对象的指针或引用(例如 const MyClass* ptr
或 const MyClass& ref
),你只能通过这个指针或引用调用该对象的常量成员函数。这强制了程序员遵守对象的常量性。
② 允许常量对象调用某些函数。如果没有常量成员函数,常量对象将无法调用任何成员函数(除了静态成员函数,因为静态成员函数不依赖于特定的对象状态)。
③ 提高代码的可读性和可维护性,明确表示函数的副作用。
3.2.1 const
的位置与含义 (const
Placement and Meaning)
在成员函数的声明中,const
关键字位于参数列表之后、函数体或分号之前。
1
class MyClass {
2
int data;
3
public:
4
// 一个常量成员函数
5
int getData() const { // const 放在这里
6
// 在这里不能修改 data,也不能调用非 const 成员函数
7
return data;
8
}
9
10
// 一个非常量成员函数
11
void setData(int val) { // 没有 const
12
data = val; // 在这里可以修改 data
13
}
14
};
这里的 const
修饰的是 this
指针指向的对象,即隐式地将 this
指针的类型从 MyClass* const
变为 const MyClass* const
。因此,在 const
成员函数内部,所有通过 this
访问的成员变量都被视为 const
的,不能被赋值修改。同时,在 const
成员函数内部,你也不能调用同类的非常量成员函数,因为非常量成员函数可能修改对象的状态。
3.2.2 常量对象与非常量对象调用 (Calls from Const and Non-Const Objects)
调用成员函数时,对象的常量性会影响哪些函数可以被调用。
① 非常量对象(Non-const objects):可以调用该类的所有成员函数,包括常量成员函数和非常量成员函数。
② 常量对象(Const objects)或指向常量对象的指针/引用:只能调用该类的常量成员函数和静态成员函数。不能调用非常量成员函数,因为非常量成员函数可能修改对象状态,这与常量对象的属性相悖。
示例:
1
#include <iostream>
2
3
class Counter {
4
private:
5
int count;
6
public:
7
Counter(int c = 0) : count(c) {}
8
9
// 非常量成员函数:可以修改 count
10
void increment() {
11
count++;
12
}
13
14
// 常量成员函数:不能修改 count
15
int getCount() const {
16
return count;
17
}
18
19
// 常量成员函数:返回 count 的引用,但返回类型也是 const 引用
20
const int& getCountRef() const {
21
return count;
22
}
23
24
// 非常量成员函数:返回 count 的引用,返回类型是非常量引用
25
int& getCountRefNonConst() {
26
return count;
27
}
28
};
29
30
int main() {
31
Counter c1(10); // 非常量对象
32
const Counter c2(20); // 常量对象
33
34
c1.increment(); // OK,非常量对象可以调用非常量成员函数
35
std::cout << "c1 count: " << c1.getCount() << std::endl; // OK,非常量对象可以调用常量成员函数
36
37
// c2.increment(); // 错误:常量对象不能调用非常量成员函数
38
39
std::cout << "c2 count: " << c2.getCount() << std::endl; // OK,常量对象可以调用常量成员函数
40
41
// int& ref1 = c2.getCountRefNonConst(); // 错误:常量对象不能返回非常量引用(通过非常量函数)
42
const int& ref2 = c2.getCountRef(); // OK:常量对象可以返回常量引用(通过常量函数)
43
44
int& ref3 = c1.getCountRefNonConst(); // OK:非常量对象可以返回非常量引用
45
const int& ref4 = c1.getCountRef(); // OK:非常量对象可以返回常量引用
46
47
ref3 = 100; // 通过非常量引用修改对象状态
48
// ref2 = 200; // 错误:通过常量引用不能修改对象状态
49
50
std::cout << "c1 count after ref3 modify: " << c1.getCount() << std::endl;
51
52
return 0;
53
}
通过合理使用 const
成员函数,可以更好地设计类的接口,区分哪些操作会改变对象状态,哪些不会,从而提高代码的安全性和清晰度。在设计类时,除非一个成员函数确实需要修改对象状态,否则都应该将其声明为常量成员函数。
3.3 静态成员变量与静态成员函数 (Static Member Variables and Static Member Functions)
除了普通的非静态(non-static)成员,C++ 类还可以拥有静态(static)成员。静态成员与类的对象实例无关,它们属于类本身。所有该类的对象共享同一份静态成员。
3.3.1 静态成员变量 (Static Member Variables)
静态成员变量是属于类的,而不是属于任何特定对象的。这意味着无论创建多少个类的对象,静态成员变量都只有一份存储空间。
特点:
⚝ 静态成员变量在程序的整个生命周期中只被初始化一次。
⚝ 它们通常在类定义内部声明,但在类定义外部进行定义和初始化。
⚝ 访问控制 (public
, protected
, private
) 同样适用于静态成员变量。
⚝ 静态成员变量可以在没有创建任何对象的情况下访问(如果其访问权限允许)。
定义与初始化:
在类定义内部声明静态成员变量:
1
class MyClass {
2
public:
3
static int static_member; // 声明静态成员变量
4
// ... 其他成员 ...
5
};
6
7
// 在类定义外部定义和初始化静态成员变量
8
// 注意:这里不需要 static 关键字,但需要类名和作用域解析符 ::
9
int MyClass::static_member = 0;
注意: 静态成员变量的定义和初始化通常放在一个 .cpp
源文件中,以避免多重定义的问题(One Definition Rule - ODR)。常量静态成员变量(const static
)如果是整型或枚举类型,可以在类定义内部直接初始化。
访问:
可以通过类名和作用域解析符 ::
来访问静态成员变量,也可以通过类的对象或对象指针/引用来访问(但不推荐通过对象访问,因为它可能会给读者造成错觉,以为是对象特有的)。
1
// 访问静态成员变量
2
std::cout << MyClass::static_member << std::endl; // 通过类名访问
3
4
MyClass obj;
5
std::cout << obj.static_member << std::endl; // 通过对象访问 (不推荐)
用途:
⚝ 统计某个类的对象数量。
⚝ 存储所有对象共享的常量或配置信息。
⚝ 实现单例模式(Singleton Pattern)的基础。
示例:
1
#include <iostream>
2
3
class ObjectCounter {
4
private:
5
static int object_count; // 声明静态成员变量
6
7
public:
8
ObjectCounter() {
9
object_count++; // 构造新对象时增加计数
10
std::cout << "Object created. Total objects: " << object_count << std::endl;
11
}
12
13
~ObjectCounter() {
14
object_count--; // 对象销毁时减少计数
15
std::cout << "Object destroyed. Total objects: " << object_count << std::endl;
16
}
17
18
static int getObjectCount() { // 静态成员函数访问静态成员变量
19
return object_count;
20
}
21
};
22
23
// 在类外部定义和初始化静态成员变量
24
int ObjectCounter::object_count = 0;
25
26
int main() {
27
std::cout << "Initial object count: " << ObjectCounter::getObjectCount() << std::endl;
28
29
ObjectCounter obj1;
30
ObjectCounter obj2;
31
32
std::cout << "Current object count: " << ObjectCounter::getObjectCount() << std::endl;
33
34
{ // 局部作用域
35
ObjectCounter obj3;
36
std::cout << "Object count inside block: " << ObjectCounter::getObjectCount() << std::endl;
37
} // obj3 在这里销毁
38
39
std::cout << "Final object count: " << ObjectCounter::getObjectCount() << std::endl;
40
41
return 0;
42
}
3.3.2 静态成员函数 (Static Member Functions)
静态成员函数是属于类的函数,不与任何特定的对象实例关联。它们可以直接通过类名调用,无需创建对象。
特点:
⚝ 静态成员函数没有 this
指针,因为它们不作用于特定的对象。
⚝ 因此,静态成员函数只能直接访问类的静态成员变量、静态成员函数以及类外部的全局函数或变量。它们不能直接访问类的非静态成员变量或调用非静态成员函数,因为这些都需要一个具体的对象实例。
⚝ 访问控制 (public
, protected
, private
) 同样适用于静态成员函数。
声明与定义:
在类定义内部声明静态成员函数:
1
class MyClass {
2
private:
3
static int static_data;
4
int non_static_data;
5
6
public:
7
static void static_function() { // 声明并定义静态成员函数
8
std::cout << "Inside static_function." << std::endl;
9
std::cout << "Static data: " << static_data << std::endl; // OK: 访问静态成员
10
11
// std::cout << "Non-static data: " << non_static_data << std::endl; // 错误: 不能访问非静态成员
12
// non_static_function(); // 错误: 不能调用非静态成员函数
13
}
14
15
void non_static_function() { // 非静态成员函数
16
std::cout << "Inside non_static_function." << std::endl;
17
std::cout << "Static data: " << static_data << std::endl; // OK: 非静态成员函数可以访问静态成员
18
std::cout << "Non-static data: " << non_static_data << std::endl; // OK: 访问非静态成员
19
}
20
};
21
22
int MyClass::static_data = 10; // 定义和初始化静态成员变量
静态成员函数也可以在类定义内部声明,然后在类定义外部定义(就像普通函数一样,但要加上 static
关键字和类名作用域)。```cpp
class MyClass {
public:
static void static_function(); // 声明静态成员函数
// ...
};
// 在类外部定义静态成员函数
void MyClass::static_function() { // 定义时不需要 static 关键字
// ... 实现 ...
}
1
**访问:**
2
3
可以通过类名和作用域解析符 `::` 来调用静态成员函数,也可以通过类的对象或对象指针/引用来调用(同样不推荐通过对象调用)。
4
5
```cpp
6
7
MyClass::static_function(); // 通过类名调用 (推荐)
8
9
MyClass obj;
10
obj.static_function(); // 通过对象调用 (不推荐)
用途:
⚝ 访问或操作静态成员变量。
⚝ 提供与类相关但不需要特定对象状态的功能(例如,工厂方法,虽然有时也用非静态)。
⚝ 作为回调函数,因为它们没有 this
指针,签名更简单。
3.4 内联成员函数 (Inline Member Functions)
内联函数(inline function)是一种编译器优化建议。当函数被声明为 inline
时,编译器可以选择将函数体直接插入到调用它的地方,而不是生成一个独立的函数调用指令。这可以消除函数调用的开销(如参数传递、栈帧创建和销毁等),从而提高程序的执行速度。然而,内联并不总是会发生,它只是一种建议,最终是否内联由编译器根据函数的大小、复杂度、优化设置等因素决定。
成员函数与内联:
成员函数也可以被声明为内联函数。在类定义内部定义的成员函数默认就是内联函数。在类定义外部定义的成员函数,可以通过在定义前加上 inline
关键字来建议编译器进行内联。
类内定义内联成员函数 (Implicit Inline):
1
class Rectangle {
2
private:
3
double width, height;
4
public:
5
Rectangle(double w, double h) : width(w), height(h) {}
6
7
// 在类定义内部定义的成员函数,默认是内联的
8
double getArea() const {
9
return width * height;
10
}
11
12
void setWidth(double w) {
13
width = w;
14
}
15
};
getArea()
和 setWidth()
函数因为在类定义内部定义,所以它们被隐式地视为 inline
建议。
类外定义内联成员函数 (Explicit Inline):
1
class Circle {
2
private:
3
double radius;
4
public:
5
Circle(double r) : radius(r) {}
6
7
// 在类定义内部声明
8
double getCircumference() const;
9
void setRadius(double r);
10
};
11
12
// 在类定义外部定义,使用 inline 关键字
13
inline double Circle::getCircumference() const {
14
return 2 * 3.14159 * radius;
15
}
16
17
inline void Circle::setRadius(double r) {
18
radius = r;
19
}
4. 拷贝、移动与资源管理 (Copy, Move, and Resource Management)
欢迎来到本书的第四章。在本章中,我们将深入探讨 C++ 中对象生命周期管理的核心议题,特别是如何处理对象的复制、移动行为,以及一种至关重要的资源管理范式——资源获取即初始化(RAII)。理解这些概念对于编写高效、健壮且异常安全的 C++ 代码至关重要,特别是当你处理动态分配的内存或其他系统资源时。我们将从成员初始化列表这一初始化成员变量的最佳实践开始,逐步深入到拷贝构造函数、拷贝赋值运算符、现代 C++(C++11 及更高版本)引入的移动语义,最后讨论如何利用 RAII 原则有效地管理资源。
4.1 成员初始化列表 (Member Initializer Lists)
在 C++ 中,初始化类的成员变量有多种方式,但成员初始化列表(Member Initializer Lists)通常是最佳方式。它允许我们在构造函数的函数体执行之前,对类的成员变量进行初始化。
通过成员初始化列表初始化成员变量的主要语法是在构造函数参数列表后,使用冒号 :
引出初始化列表,列表中的每个成员初始化项由成员变量名后跟一对括号 ()
,括号内是用于初始化的值或表达式,不同成员之间用逗号 ,
分隔。
1
#include <string>
2
#include <vector>
3
4
class Example {
5
private:
6
int int_member;
7
double double_member;
8
std::string string_member;
9
std::vector<int> vector_member;
10
11
public:
12
// 使用成员初始化列表的构造函数
13
Example(int i, double d, const std::string& s)
14
: int_member(i), // 初始化 int_member
15
double_member(d), // 初始化 double_member
16
string_member(s), // 初始化 string_member
17
vector_member({1, 2, 3}) // 初始化 vector_member (C++11 列表初始化)
18
{
19
// 构造函数体可以为空,或者执行其他逻辑
20
// 成员变量在此之前已经完成初始化
21
// std::cout << "Example 对象构造完成" << std::endl; // 举例,需要包含 <iostream>
22
}
23
24
// 也可以结合函数体中的赋值,但通常不推荐用于非基本类型
25
// Example(int i, double d, const std::string& s)
26
// {
27
// int_member = i; // 这是赋值,不是初始化
28
// double_member = d; // 这是赋值,不是初始化
29
// string_member = s; // 这是赋值,不是初始化 (对于 std::string 来说,会先默认构造再赋值)
30
// vector_member = {1, 2, 3}; // 这是赋值
31
// }
32
};
33
34
int main() {
35
Example ex(10, 3.14, "hello");
36
// 使用对象 ex
37
return 0;
38
}
使用成员初始化列表的优势包括:
⚝ 效率更高: 特别是对于类类型的成员变量,使用初始化列表是直接调用其构造函数进行初始化。如果在构造函数体内使用赋值,则会先调用成员的默认构造函数进行默认初始化(或不初始化,取决于类型),然后再调用赋值运算符。对于一些类型(如 std::string
或 std::vector
),这可能涉及额外的构造和赋值开销。对于没有默认构造函数的类类型成员,则必须使用初始化列表。
⚝ 初始化 const
和引用成员: const
成员和引用(Reference)成员在声明时就必须初始化,它们不能在构造函数体内进行赋值。因此,初始化列表是初始化这些成员的唯一方式。
⚝ 避免未定义行为: 对于某些基本类型成员,如果在构造函数体内赋值而未在初始化列表中初始化,它们可能处于未定义的状态,直到赋值发生。使用初始化列表可以确保所有成员在构造函数体执行前都已被明确初始化。
⚝ 强制要求初始化顺序: 成员变量的初始化顺序与它们在类定义中的声明顺序一致,而不是与初始化列表中的顺序一致。了解这一点可以避免一些潜在的错误。
总而言之,对于所有成员变量,特别是类类型、const
和引用成员,都应该优先使用成员初始化列表进行初始化。
4.2 拷贝构造函数 (Copy Constructors)
拷贝构造函数(Copy Constructor)是一种特殊的构造函数,用于通过一个已存在的同类型对象来创建一个新的对象。它定义了当对象被复制时应该发生什么。
拷贝构造函数的典型形式是 ClassName(const ClassName& other);
。参数必须是一个常量引用(const ClassName&
),以避免无限递归调用拷贝构造函数,并保证源对象不被修改。
4.2.1 默认拷贝构造函数 (Default Copy Constructor)
如果你没有为类显式定义拷贝构造函数,C++ 编译器会在需要时自动生成一个默认拷贝构造函数。
默认拷贝构造函数执行的是浅拷贝(Shallow Copy)。它会逐个成员地复制源对象的值到新对象。对于基本类型(如 int
, double
, 指针等),这是简单地复制值。对于类类型的成员变量,它会调用该成员变量的拷贝构造函数。
1
#include <iostream>
2
3
class Simple {
4
public:
5
int value;
6
7
Simple(int v) : value(v) {
8
std::cout << "Simple(int) 构造函数被调用,value = " << value << std::endl;
9
}
10
11
// 没有显式定义拷贝构造函数
12
};
13
14
int main() {
15
Simple s1(10);
16
Simple s2 = s1; // 调用默认拷贝构造函数
17
Simple s3(s1); // 调用默认拷贝构造函数
18
19
std::cout << "s1.value = " << s1.value << std::endl;
20
std::cout << "s2.value = " << s2.value << std::endl;
21
std::cout << "s3.value = " << s3.value << std::endl;
22
23
return 0;
24
}
在这个例子中,s2 = s1;
和 Simple s3(s1);
都调用了默认拷贝构造函数,简单地复制了 s1
的 value
成员到 s2
和 s3
。
然而,默认拷贝构造函数的浅拷贝对于包含指针或管理动态资源的类来说是危险的。因为浅拷贝只会复制指针的值,而不是指针指向的内容。结果是多个对象会共享同一个资源,当其中一个对象销毁时释放资源,其他对象持有的指针就变成了悬空指针(Dangling Pointer),访问这些指针会导致未定义行为,通常是程序崩溃或数据损坏。
4.2.2 自定义拷贝构造函数 (Custom Copy Constructor)
当类拥有指向动态分配资源的指针或句柄时,你需要自定义拷贝构造函数来实现深拷贝(Deep Copy)。深拷贝不仅复制对象本身的值,还会复制对象指向的资源,确保每个对象都拥有独立的资源副本。
在自定义拷贝构造函数中,你需要:
① 分配新的资源(例如,使用 new
分配内存)。
② 将源对象资源中的内容复制到新分配的资源中。
③ 初始化新对象的成员变量,使其指向新分配和复制的资源。
1
#include <iostream>
2
#include <cstring> // For std::strcpy, std::strlen
3
4
class MyString {
5
private:
6
char* data;
7
size_t length;
8
9
public:
10
// 构造函数
11
MyString(const char* str = nullptr) {
12
if (str) {
13
length = std::strlen(str);
14
data = new char[length + 1]; // +1 for null terminator
15
std::strcpy(data, str);
16
} else {
17
length = 0;
18
data = nullptr;
19
}
20
std::cout << "Constructor called for: " << (data ? data : "nullptr") << std::endl;
21
}
22
23
// 析构函数:释放资源
24
~MyString() {
25
std::cout << "Destructor called for: " << (data ? data : "nullptr") << std::endl;
26
delete[] data;
27
}
28
29
// 自定义拷贝构造函数 (深拷贝)
30
MyString(const MyString& other) : length(other.length) {
31
if (other.data) {
32
data = new char[length + 1];
33
std::strcpy(data, other.data);
34
} else {
35
data = nullptr;
36
}
37
std::cout << "Copy constructor called for: " << (data ? data : "nullptr") << " from " << (other.data ? other.data : "nullptr") << std::endl;
38
}
39
40
// 获取数据 (示例用)
41
const char* getData() const {
42
return data;
43
}
44
};
45
46
int main() {
47
MyString s1("Hello"); // 调用构造函数
48
MyString s2 = s1; // 调用拷贝构造函数
49
MyString s3(s1); // 调用拷贝构造函数
50
51
std::cout << "s1: " << s1.getData() << std::endl;
52
std::cout << "s2: " << s2.getData() << std::endl;
53
std::cout << "s3: " << s3.getData() << std::endl;
54
55
// s1, s2, s3 销毁时,各自的析构函数会被调用,释放各自的内存副本,避免双重释放
56
57
return 0;
58
}
在这个例子中,MyString
类管理一个动态分配的字符数组。默认拷贝构造函数会简单复制 data
指针,导致两个 MyString
对象指向同一块内存。自定义拷贝构造函数则分配了新的内存,并将字符串内容复制过去,实现了深拷贝。
拷贝构造函数是 C++ “三/五/零法则”(Rule of Three/Five/Zero)中的核心部分。简单来说,如果你的类需要自定义析构函数来释放资源,那么它很可能也需要自定义拷贝构造函数和拷贝赋值运算符。在现代 C++ (C++11+) 中,由于移动语义的引入,这通常扩展为“五法则”,并推荐使用“零法则”——尽量通过智能指针等 RAII 对象来管理资源,从而让编译器生成默认的特殊成员函数。
4.2.3 何时调用拷贝构造函数 (When Copy Constructor is Called)
拷贝构造函数在以下几种情况下会被调用:
① 当通过一个同类型的现有对象初始化一个新对象时:
▮▮▮▮ⓑ ClassName obj2 = obj1;
▮▮▮▮ⓒ ClassName obj2(obj1);
▮▮▮▮ⓓ ClassName obj2{obj1};
(C++11 统一初始化)
② 当对象作为函数参数以值(by value)传递时,会调用拷贝构造函数创建参数的副本。
1
void func_by_value(MyString str) {
2
// str 是传入对象的副本,由拷贝构造函数创建
3
std::cout << "Inside func_by_value: " << str.getData() << std::endl;
4
} // str 销毁时,析构函数被调用
5
6
int main() {
7
MyString s1("World");
8
func_by_value(s1); // 调用 MyString 的拷贝构造函数
9
return 0;
10
}
③ 当函数返回一个对象时,如果该对象是通过值返回的(或者返回一个局部对象的副本),可能会调用拷贝构造函数。但是,现代 C++ 编译器通常会执行返回值优化(Return Value Optimization, RVO)或具名返回值优化(Named Return Value Optimization, NRVO),在很多情况下可以避免这次拷贝。
1
MyString createMyString() {
2
MyString temp("Temporary"); // 局部对象
3
return temp; // 可能发生拷贝构造或移动构造,但也可能被 NRVO 优化
4
}
5
6
int main() {
7
MyString s = createMyString(); // 可能调用拷贝/移动构造,或被 RVO 优化
8
return 0;
9
}
④ 在某些标准库容器的操作中,例如将对象插入到 std::vector
中可能导致容器扩容,扩容时会将旧元素拷贝(或移动)到新位置。
理解拷贝构造函数的调用时机对于性能优化(尤其是避免不必要的拷贝)和资源管理至关重要。
4.3 拷贝赋值运算符 (Copy Assignment Operators)
拷贝赋值运算符(Copy Assignment Operator)是一种特殊的成员函数,用于将一个已存在的同类型对象的值赋给另一个已存在的对象。它定义了当对象通过赋值操作符 =
被赋值时应该发生什么。
拷贝赋值运算符的典型形式是 ClassName& operator=(const ClassName& other);
。通常返回一个对当前对象的引用(ClassName&
),以便支持链式赋值,例如 a = b = c;
。参数同样是一个常量引用(const ClassName&
)。
4.3.1 默认拷贝赋值运算符 (Default Copy Assignment Operator)
与拷贝构造函数类似,如果你没有显式定义拷贝赋值运算符,C++ 编译器会在需要时自动生成一个默认拷贝赋值运算符。
默认拷贝赋值运算符也执行浅拷贝。它会逐个成员地将源对象的值赋给目标对象。对于基本类型,是简单赋值。对于类类型的成员,会调用该成员变量的拷贝赋值运算符。
1
#include <iostream>
2
3
class SimpleAssign {
4
public:
5
int value;
6
7
SimpleAssign(int v) : value(v) {
8
std::cout << "SimpleAssign(int) 构造函数被调用,value = " << value << std::endl;
9
}
10
// 没有显式定义拷贝赋值运算符
11
};
12
13
int main() {
14
SimpleAssign sa1(10);
15
SimpleAssign sa2(20);
16
17
std::cout << "Before assignment: sa1.value = " << sa1.value << ", sa2.value = " << sa2.value << std::endl;
18
sa2 = sa1; // 调用默认拷贝赋值运算符
19
std::cout << "After assignment: sa1.value = " << sa1.value << ", sa2.value = " << sa2.value << std::endl;
20
21
return 0;
22
}
默认拷贝赋值运算符的浅拷贝同样对包含指针或管理动态资源的类带来问题,可能导致资源双重释放或内存泄漏。
4.3.2 自定义拷贝赋值运算符 (Custom Copy Assignment Operator)
当类管理动态资源时,需要自定义拷贝赋值运算符来实现深拷贝。实现拷贝赋值运算符通常比实现拷贝构造函数更复杂,因为它涉及到处理目标对象可能已经持有的资源以及自我赋值的情况。
一个健壮的拷贝赋值运算符实现通常遵循以下步骤:
① 处理自我赋值:检查源对象和目标对象是否是同一个对象(例如,通过比较 this
指针和源对象的地址)。如果是,则直接返回 *this
,不做任何操作。
② 释放目标对象原有的资源:在分配新资源之前,必须释放目标对象当前持有的资源,以避免内存泄漏。
③ 分配新资源:为新数据分配所需的资源(例如,使用 new
)。
④ 复制数据:将源对象的数据复制到新分配的资源中。
⑤ 更新目标对象的成员变量:更新目标对象的指针或句柄,使其指向新分配和复制的资源。
⑥ 返回引用:返回 *this
的引用。
为了提高异常安全性,一种常见的模式是拷贝并交换(Copy-and-Swap)惯用法。这种方法利用拷贝构造函数和 swap
函数来简化赋值运算符的实现,并提供强大的异常安全保证。
1
#include <iostream>
2
#include <cstring>
3
#include <algorithm> // For std::swap
4
5
class MyStringAssignment {
6
private:
7
char* data;
8
size_t length;
9
10
public:
11
// 构造函数
12
MyStringAssignment(const char* str = nullptr) {
13
if (str) {
14
length = std::strlen(str);
15
data = new char[length + 1];
16
std::strcpy(data, str);
17
} else {
18
length = 0;
19
data = nullptr;
20
}
21
std::cout << "Constructor called for: " << (data ? data : "nullptr") << std::endl;
22
}
23
24
// 析构函数
25
~MyStringAssignment() {
26
std::cout << "Destructor called for: " << (data ? data : "nullptr") << std::endl;
27
delete[] data;
28
}
29
30
// 拷贝构造函数 (与 MyString 类的相同)
31
MyStringAssignment(const MyStringAssignment& other) : length(other.length) {
32
if (other.data) {
33
data = new char[length + 1];
34
std::strcpy(data, other.data);
35
} else {
36
data = nullptr;
37
}
38
std::cout << "Copy constructor called for: " << (data ? data : "nullptr") << " from " << (other.data ? other.data : "nullptr") << std::endl;
39
}
40
41
// 自定义拷贝赋值运算符 (使用拷贝并交换惯用法)
42
// 参数 by value 会调用拷贝构造函数创建一个副本
43
MyStringAssignment& operator=(MyStringAssignment other) { // 注意:这里参数是 MyStringAssignment other (按值传递)
44
std::cout << "Copy assignment operator called." << std::endl;
45
// 使用 std::swap 交换当前对象的成员与传入副本的成员
46
swap(*this, other);
47
// 函数返回时,other (现在包含了原对象的数据) 的析构函数会被调用,释放资源
48
return *this;
49
}
50
51
// swap 函数 (通常声明为 friend 或 public)
52
friend void swap(MyStringAssignment& first, MyStringAssignment& second) noexcept {
53
using std::swap; // enable ADL
54
swap(first.data, second.data);
55
swap(first.length, second.length);
56
}
57
58
// 获取数据 (示例用)
59
const char* getData() const {
60
return data;
61
}
62
};
63
64
int main() {
65
MyStringAssignment msa1("Hello");
66
MyStringAssignment msa2("World is big");
67
68
std::cout << "Before assignment: msa1=" << msa1.getData() << ", msa2=" << msa2.getData() << std::endl;
69
msa2 = msa1; // 调用拷贝赋值运算符 (实际是拷贝构造 + 交换)
70
std::cout << "After assignment: msa1=" << msa1.getData() << ", msa2=" << msa2.getData() << std::endl;
71
72
MyStringAssignment msa3("Self test");
73
msa3 = msa3; // 自我赋值测试 (使用拷贝并交换,自我赋值是安全的)
74
std::cout << "After self-assignment: msa3=" << msa3.getData() << std::endl;
75
76
77
return 0;
78
} // msa1, msa2, msa3 销毁
使用拷贝并交换惯用法,拷贝赋值运算符的实现变得简洁且异常安全。如果拷贝构造函数抛出异常,它发生在交换之前,不会改变目标对象的状态。如果 swap
抛出异常(虽然对于基本类型和指针通常不会),情况会复杂一些,但对于资源管理类,通常会提供 noexcept
的 swap
函数。
4.3.3 自我赋值的防护 (Self-Assignment Protection)
在传统的(非拷贝并交换)自定义拷贝赋值运算符实现中,处理自我赋值(如 obj = obj;
)至关重要。如果不加防护,可能导致以下问题:
① 在释放旧资源后,试图复制的数据已经不存在(因为它就是刚刚被释放的资源)。
防护自我赋值的常见方法是在操作开始时检查源对象和目标对象的地址:
1
// 传统的自定义拷贝赋值运算符实现示例 (非拷贝并交换)
2
MyStringAssignment& operator=(const MyStringAssignment& other) {
3
std::cout << "Traditional copy assignment operator called." << std::endl;
4
// 1. 自我赋值检查
5
if (this == &other) {
6
std::cout << "Self-assignment detected." << std::endl;
7
return *this;
8
}
9
10
// 2. 释放旧资源
11
delete[] data;
12
13
// 3. 分配新资源并复制数据
14
length = other.length;
15
if (other.data) {
16
data = new char[length + 1];
17
std::strcpy(data, other.data);
18
} else {
19
data = nullptr;
20
}
21
22
// 4. 返回引用
23
return *this;
24
}
这种传统实现需要仔细处理资源释放和新资源分配的顺序,以确保异常安全。例如,如果在 new char[length + 1]
处抛出异常,旧的 data
指向的内存已经被释放,而 data
指针没有更新,导致悬空。拷贝并交换惯用法通过先完成所有可能抛出异常的操作(拷贝)到一个临时对象,然后原子性地交换指针,从而提供了更好的异常安全保证。
4.4 移动语义 (Move Semantics) (C++11+)
在 C++11 之前,处理大对象或包含大量资源的对象时,拷贝操作可能会非常昂贵。例如,复制一个 std::vector
涉及分配新的内存并将所有元素复制过去。当源对象在拷贝后即将被销毁(如从函数返回临时对象),这种昂贵的拷贝是低效的。
移动语义(Move Semantics)允许资源(如动态分配的内存)从一个对象移动到另一个对象,而不是进行深拷贝。这通过“窃取”源对象的资源来实现,通常涉及将源对象的内部指针复制到目标对象,然后将源对象的指针置空,这样源对象销毁时就不会释放资源。移动操作比深拷贝快得多,因为它通常只涉及指针操作和少量的 bookkeeping 开销。
移动语义的核心在于右值引用(Rvalue References)和一对新的特殊成员函数:移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment Operator)。
4.4.1 右值引用 (Rvalue References)
右值引用是一种新的引用类型,用 &&
表示。左值引用(普通的 &
)绑定到左值(可以取地址,有持久身份的对象),而右值引用主要绑定到右值(临时的、将要被销毁的值,不能取地址,或其地址不重要)。
右值示例:
⚝ 字面量(除字符串字面量,它是左值): 10
, 3.14
, true
⚝ 临时对象:函数返回值
⚝ 表达式的结果,如果不是左值引用:x + y
, some_function()
右值引用的主要作用是区分左值和右值,并允许我们绑定到右值,从而可以在右值对象上执行资源转移而不是拷贝。
1
int x = 10;
2
int& lvalue_ref = x; // 左值引用绑定到左值
3
4
// int& rvalue_ref = 10; // 错误:左值引用不能绑定到右值
5
6
int&& rvalue_ref = 10; // 右值引用绑定到右值
7
// int&& rvalue_ref2 = x; // 错误:右值引用不能直接绑定到左值
8
9
// std::string s1 = "hello";
10
// std::string&& rvalue_ref3 = s1; // 错误:s1 是左值
11
// std::string&& rvalue_ref4 = std::string("world"); // 右值引用绑定到临时对象 (右值)
尽管右值引用不能直接绑定到左值,但我们可以使用 std::move
将左值显式转换为右值引用。
4.4.2 移动构造函数 (Move Constructors)
移动构造函数用于通过一个右值引用的同类型对象来构造新对象。它通常“窃取”源对象的资源,然后将源对象的资源指针置空。
移动构造函数的典型形式是 ClassName(ClassName&& other);
。
1
#include <iostream>
2
#include <cstring>
3
#include <algorithm> // For std::swap
4
#include <vector> // For std::vector example
5
6
class MyStringMove {
7
private:
8
char* data;
9
size_t length;
10
11
public:
12
// 构造函数
13
MyStringMove(const char* str = nullptr) {
14
if (str) {
15
length = std::strlen(str);
16
data = new char[length + 1];
17
std::strcpy(data, str);
18
} else {
19
length = 0;
20
data = nullptr;
21
}
22
std::cout << "Constructor called for: " << (data ? data : "nullptr") << std::endl;
23
}
24
25
// 析构函数
26
~MyStringMove() {
27
std::cout << "Destructor called for: " << (data ? data : "nullptr") << std::endl;
28
delete[] data;
29
}
30
31
// 拷贝构造函数 (深拷贝)
32
MyStringMove(const MyStringMove& other) : length(other.length) {
33
if (other.data) {
34
data = new char[length + 1];
35
std::strcpy(data, other.data);
36
} else {
37
data = nullptr;
38
}
39
std::cout << "Copy constructor called for: " << (data ? data : "nullptr") << " from " << (other.data ? other.data : "nullptr") << std::endl;
40
}
41
42
// *** 移动构造函数 ***
43
MyStringMove(MyStringMove&& other) noexcept // 注意参数是右值引用 &&
44
: data(other.data), length(other.length) // 初始化列表直接接管资源
45
{
46
other.data = nullptr; // 将源对象的指针置空,避免双重释放
47
other.length = 0; // 可选,将源对象长度置零
48
std::cout << "Move constructor called for: " << (data ? data : "nullptr") << " from " << (other.data ? other.data : "nullptr") << std::endl;
49
}
50
51
// 拷贝赋值运算符 (使用拷贝并交换惯用法)
52
MyStringMove& operator=(MyStringMove other) {
53
std::cout << "Copy/Move assignment operator called." << std::endl;
54
swap(*this, other);
55
return *this;
56
}
57
58
// swap 函数
59
friend void swap(MyStringMove& first, MyStringMove& second) noexcept {
60
using std::swap;
61
swap(first.data, second.data);
62
swap(first.length, second.length);
63
}
64
65
// 获取数据 (示例用)
66
const char* getData() const {
67
return data;
68
}
69
};
70
71
// 返回一个临时对象
72
MyStringMove createMyStringMove(const char* str) {
73
MyStringMove temp(str);
74
return temp; // 返回 temp (一个局部左值,但 C++11 允许将其视为右值进行移动)
75
}
76
77
int main() {
78
std::vector<MyStringMove> vec;
79
std::cout << "--- Push back temporary ---" << std::endl;
80
vec.push_back(MyStringMove("VectorElement1")); // 这里的临时对象会调用移动构造函数
81
std::cout << "--- Push back named object ---" << std::endl;
82
MyStringMove s1("NamedObject");
83
vec.push_back(s1); // s1 是左值,调用拷贝构造函数
84
std::cout << "--- Assign temporary ---" << std::endl;
85
MyStringMove s2; // Default constructor
86
s2 = MyStringMove("Temporary for Assignment"); // 临时对象调用移动赋值 (通过拷贝并交换)
87
std::cout << "--- Assign named object ---" << std::endl;
88
MyStringMove s3("Original s3");
89
s3 = s1; // s1 是左值,调用拷贝赋值 (通过拷贝并交换)
90
std::cout << "--- Return from function ---" << std::endl;
91
MyStringMove s4 = createMyStringMove("Returned String"); // createMyStringMove 返回的临时对象调用移动构造 (或被 NRVO 优化)
92
93
94
std::cout << "--- End of main ---" << std::endl;
95
return 0;
96
} // 对象 s1, s2, s3, s4 和 vec 中的元素销毁
在这个例子中,当使用临时对象(右值)进行构造或赋值时,会优先匹配并调用移动构造函数或移动赋值运算符,从而避免了昂贵的深拷贝。
4.4.3 移动赋值运算符 (Move Assignment Operators)
移动赋值运算符用于通过一个右值引用的同类型对象的值赋给另一个已存在的对象。它通常通过交换指针来实现资源的快速转移。
移动赋值运算符的典型形式是 ClassName& operator=(ClassName&& other) noexcept;
。通常也标记为 noexcept
,因为如果移动操作可能抛出异常,使用它可能导致不期望的行为(例如,当容器进行移动时)。
1
// 见 MyStringMove 类的 operator= 实现 (使用拷贝并交换)
2
// MyStringMove& operator=(MyStringMove other) { ... }
3
// 这里的参数 other 是按值传递的。如果传入的是左值,会调用拷贝构造函数创建 other。
4
// 如果传入的是右值,会调用移动构造函数创建 other。
5
// 这种方式可以同时处理拷贝赋值和移动赋值,编译器会根据传入参数的类型自动选择合适的构造函数来创建 other。
如果选择不使用拷贝并交换惯用法,可以单独实现移动赋值运算符,其逻辑类似于移动构造函数,但需要先处理目标对象已有的资源。
1
// MyStringMove 类中独立的移动赋值运算符实现 (非拷贝并交换)
2
MyStringMove& operator=(MyStringMove&& other) noexcept { // 注意参数是右值引用 &&
3
std::cout << "Separate Move assignment operator called." << std::endl;
4
// 1. 自我赋值检查 (可选,但推荐)
5
if (this == &other) {
6
return *this;
7
}
8
9
// 2. 释放旧资源
10
delete[] data;
11
12
// 3. 窃取资源
13
data = other.data;
14
length = other.length;
15
16
// 4. 将源对象资源置空
17
other.data = nullptr;
18
other.length = 0;
19
20
// 5. 返回引用
21
return *this;
22
}
独立的移动赋值运算符实现通常比拷贝并交换版本更快,因为它避免了一次额外的析构(拷贝并交换中临时对象 other
的析构)。但在异常安全方面,拷贝并交换通常更容易实现强保证。
4.4.4 std::move
和 std::forward
⚝ std::move
: 是一个函数模板,定义在 <utility>
头文件中。它实际上不做任何移动,而是将一个参数无条件地转换为其对应的右值引用。它的主要作用是告诉编译器:“我不再关心这个对象的原始值了,可以随意地从它那里窃取资源。” 使用 std::move
转换后的左值,如果该类型定义了移动构造函数或移动赋值运算符,则后续的操作会优先匹配移动操作。
1
#include <iostream>
2
#include <vector>
3
#include <string>
4
#include <utility> // For std::move
5
6
int main() {
7
std::string str1 = "Hello";
8
std::vector<std::string> vec;
9
10
// vec.push_back(str1); // 调用 string 的拷贝构造函数,str1 不变
11
// std::cout << "str1 after copy: " << str1 << std::endl;
12
13
vec.push_back(std::move(str1)); // 使用 std::move 将 str1 转换为右值引用
14
// 调用 string 的移动构造函数,str1 的状态变得有效但未指定(通常为空)
15
std::cout << "str1 after move: '" << str1 << "'" << std::endl;
16
std::cout << "vec[0]: " << vec[0] << std::endl;
17
18
std::string str2 = "World";
19
std::string str3 = "!";
20
str2 = std::move(str3); // 使用 std::move 将 str3 转换为右值引用
21
// 调用 string 的移动赋值运算符,str3 的状态变得有效但未指定
22
std::cout << "str2 after move assignment: " << str2 << std::endl;
23
std::cout << "str3 after move assignment: '" << str3 << "'" << std::endl;
24
25
return 0;
26
}
重要提示: 使用 std::move
意味着你承诺不再依赖源对象在移动后的具体值(虽然它必须处于有效但未指定的状态)。在对一个对象调用 std::move
后,不应该再依赖其内容,除非文档明确说明了移动后的状态。
⚝ std::forward
: 也是一个函数模板,定义在 <utility>
头文件中。它主要用于完美转发(Perfect Forwarding),通常与函数模板中的万能引用(Universal References)(或称转发引用 Forwarding References,形如 T&&
但当 T
是模板参数时有特殊行为)一起使用。std::forward<T>(arg)
的作用是:如果 arg
是一个左值,它就转发为一个左值引用;如果 arg
是一个右值,它就转发为一个右值引用。它的目的是在模板函数中,将参数的原始值类别(左值或右值)透明地转发给内部调用的其他函数。
1
#include <iostream>
2
#include <utility> // For std::forward, std::move
3
4
void process(int& lvalue) {
5
std::cout << "Processing lvalue: " << lvalue << std::endl;
6
}
7
8
void process(int&& rvalue) {
9
std::cout << "Processing rvalue: " << rvalue << std::endl;
10
}
11
12
template<typename T>
13
void forward_example(T&& arg) { // T&& here is a universal reference
14
std::cout << "forward_example received: ";
15
process(std::forward<T>(arg)); // Use std::forward to maintain original value category
16
}
17
18
int main() {
19
int x = 10;
20
forward_example(x); // x is lvalue, T is int&, std::forward<int&>(x) yields int&, calls process(int&)
21
forward_example(20); // 20 is rvalue, T is int, std::forward<int>(20) yields int&&, calls process(int&&)
22
forward_example(std::move(x)); // std::move(x) is rvalue, T is int, std::forward<int>(std::move(x)) yields int&&, calls process(int&&)
23
24
return 0;
25
}
std::forward
对于编写既能接受左值又能接受右值,并将其以原始值类别转发给内部函数的通用模板代码非常有用,尤其是在实现移动构造函数和移动赋值运算符时,有时需要在初始化列表中或函数体内将成员转发给其自身的构造函数或赋值运算符。
4.5 资源获取即初始化 (RAII: Resource Acquisition Is Initialization)
资源获取即初始化(RAII: Resource Acquisition Is Initialization)是一种重要的 C++ 编程范式,用于优雅且安全地管理资源(Resource),如动态分配的内存、文件句柄、互斥锁、网络连接等。
4.5.1 RAII 原则 (RAII Principle)
RAII 的核心思想是将资源的生命周期绑定到对象的生命周期。
⚝ 资源获取(Acquisition):在对象的构造函数中获取资源。如果资源获取失败,构造函数应该抛出异常,表示对象未能成功创建,避免创建出无效的对象。
⚝ 即初始化(Is Initialization):资源是在构造函数中获取的,对象的创建与其所需资源的准备是同步进行的。
⚝ 资源释放(Release):在对象的析构函数中释放资源。由于析构函数无论对象是正常离开作用域还是因异常而销毁都会被调用,这保证了资源能够被及时且确定地释放,从而避免资源泄漏。
简而言之,RAII 对象在其构造函数中“拥有”资源,并在其析构函数中“释放”资源。通过将资源管理逻辑封装在类的构造函数和析构函数中,可以极大地简化代码并提高资源管理的安全性,尤其是在存在异常的情况下。
使用 RAII 的主要优势:
⚝ 自动资源管理:程序员无需手动编写资源释放的代码(如 delete
或 close()
),资源的释放由对象的生命周期自动触发。
⚝ 异常安全:即使在构造函数获取资源后、析构函数有机会执行前发生了异常,只要对象已被成功构造(或部分构造但能正确清理已获取资源),其析构函数仍然会被调用,确保资源得到释放。这有助于实现异常安全的代码。
⚝ 代码简洁:资源管理逻辑集中在类的定义中,使用资源的客户端代码变得更简洁,无需充斥着资源释放的 try-catch-finally
块(C++ 中没有 finally
,通常用 RAII 替代)。
4.5.2 智能指针 (Smart Pointers) (std::unique_ptr, std::shared_ptr)
智能指针(Smart Pointers)是标准库提供的 RAII 类的典型示例,用于自动管理动态分配的内存。它们将原始指针(Raw Pointer)封装在对象中,并在智能指针对象销毁时自动释放其指向的内存。
⚝ std::unique_ptr
: 表示对动态分配对象独占所有权(Exclusive Ownership)的智能指针。在任何时候,只有一个 unique_ptr
可以指向特定的内存。当 unique_ptr
对象销毁时,它所管理的内存会被自动释放。unique_ptr
不能被拷贝,但可以被移动(Move),这使得所有权可以在不同 unique_ptr
之间转移。
1
#include <iostream>
2
#include <memory> // For std::unique_ptr, std::make_unique
3
4
class Resource {
5
public:
6
Resource() { std::cout << "Resource acquired" << std::endl; }
7
~Resource() { std::cout << "Resource released" << std::endl; }
8
void use() { std::cout << "Using resource" << std::endl; }
9
};
10
11
int main() {
12
// 创建一个 unique_ptr,获取资源
13
std::unique_ptr<Resource> res_ptr1 = std::make_unique<Resource>(); // C++14 推荐用法
14
// std::unique_ptr<Resource> res_ptr1(new Resource()); // C++11 写法
15
16
if (res_ptr1) {
17
res_ptr1->use();
18
}
19
20
// std::unique_ptr<Resource> res_ptr2 = res_ptr1; // 错误!unique_ptr 不能被拷贝
21
22
// 移动 unique_ptr,转移所有权
23
std::unique_ptr<Resource> res_ptr2 = std::move(res_ptr1); // 所有权从 res_ptr1 转移到 res_ptr2
24
25
if (res_ptr1) { // res_ptr1 现在是空的
26
res_ptr1->use(); // 不会执行
27
} else {
28
std::cout << "res_ptr1 is null" << std::endl;
29
}
30
31
if (res_ptr2) { // res_ptr2 持有资源
32
res_ptr2->use();
33
}
34
35
// main 函数结束,res_ptr2 销毁,自动释放 Resource 对象
36
37
return 0;
38
}
unique_ptr
适用于那些资源所有权需要明确且不能共享的场景。
⚝ std::shared_ptr
: 表示对动态分配对象共享所有权(Shared Ownership)的智能指针。多个 shared_ptr
可以指向同一个对象。shared_ptr
使用引用计数(Reference Count)来跟踪有多少个 shared_ptr
正在指向同一个对象。当最后一个 shared_ptr
对象销毁时(即引用计数变为零),它所管理的内存才会被释放。shared_ptr
可以被拷贝和移动。
1
#include <iostream>
2
#include <memory> // For std::shared_ptr, std::make_shared
3
4
class SharedResource {
5
public:
6
SharedResource() { std::cout << "Shared Resource acquired" << std::endl; }
7
~SharedResource() { std::cout << "Shared Resource released" << std::endl; }
8
void use() { std::cout << "Using shared resource" << std::endl; }
9
};
10
11
int main() {
12
std::shared_ptr<SharedResource> shared_ptr1 = std::make_shared<SharedResource>(); // C++11 推荐用法
13
// std::shared_ptr<SharedResource> shared_ptr1(new SharedResource()); // 另一种写法
14
15
std::cout << "shared_ptr1 use count: " << shared_ptr1.use_count() << std::endl; // 引用计数为 1
16
17
{
18
std::shared_ptr<SharedResource> shared_ptr2 = shared_ptr1; // 拷贝 shared_ptr,共享所有权
19
std::cout << "shared_ptr1 use count: " << shared_ptr1.use_count() << std::endl; // 引用计数为 2
20
std::cout << "shared_ptr2 use count: " << shared_ptr2.use_count() << std::endl; // 引用计数为 2
21
22
if (shared_ptr1 && shared_ptr2 && (shared_ptr1.get() == shared_ptr2.get())) {
23
shared_ptr1->use();
24
shared_ptr2->use();
25
}
26
} // shared_ptr2 离开作用域并销毁,引用计数减 1
27
28
std::cout << "shared_ptr1 use count after shared_ptr2 scope: " << shared_ptr1.use_count() << std::endl; // 引用计数为 1
29
30
// main 函数结束,shared_ptr1 销毁,引用计数减 1 变为 0,SharedResource 对象被释放
31
32
return 0;
33
}
shared_ptr
适用于资源所有权需要共享的场景。需要注意的是,shared_ptr
可能导致循环引用(Cyclic Reference)问题,当两个或多个对象相互持有对方的 shared_ptr
时,它们的引用计数永远不会降到零,导致资源泄漏。解决循环引用通常使用 std::weak_ptr
。
除了 unique_ptr
和 shared_ptr
,标准库还提供了 std::weak_ptr
(与 shared_ptr
配合使用,解决循环引用)和 std::auto_ptr
(已废弃,不推荐使用)。此外,还可以自定义 RAII 类来管理其他类型的资源(如文件句柄、锁等),只需在构造函数中获取资源并在析构函数中释放即可。RAII 是现代 C++ 中进行资源管理的基础和核心。
5. 运算符重载 (Operator Overloading)
欢迎来到本书关于 C++ 类的第五章:运算符重载(Operator Overloading)。在前几章中,我们学习了如何定义类、创建对象、管理对象的生命周期以及控制成员的访问权限。现在,我们将探索 C++ 的一个强大特性,它允许我们为自定义类型赋予内置类型(Built-in Types)运算符的行为,从而使我们的代码更加直观、富有表现力,并且更接近自然语言或数学表达式。
运算符重载(Operator Overloading)使得我们可以用诸如 +
、-
、*
、<<
等熟悉的符号来操作类的对象。例如,我们可以重载一个表示二维向量的类的 +
运算符,使得 Vector v3 = v1 + v2;
这样的代码能够直接实现向量的加法运算,就像操作基本数据类型一样。这极大地提高了代码的可读性和易用性。
然而,运算符重载并非没有限制,不恰当地使用也可能导致代码难以理解和维护。本章将系统地介绍运算符重载的基础知识、语法、实现方式(成员函数或非成员函数),探讨与之密切相关的友元机制,并通过丰富的示例展示如何重载常用的运算符,帮助您掌握这一强大的工具并学会在实践中明智地应用它。
5.1 运算符重载基础 (Operator Overloading Fundamentals)
5.1.1 什么是运算符重载? (What is Operator Overloading?)
运算符重载(Operator Overloading)是 C++ 的一个特性,它允许我们为用户自定义类型(User-Defined Types),即类(Class)或结构体(Struct),重新定义标准运算符(Standard Operators)的行为。本质上,运算符重载是函数重载(Function Overloading)的一种特殊形式,我们是在重载一个名为 operator
加上特定运算符符号的函数。
例如,如果有一个 Complex
类表示复数,我们希望实现复数的加法,可以写一个成员函数 add(const Complex& other)
或一个全局函数 Complex add(const Complex& c1, const Complex& c2)
。但是,通过运算符重载,我们可以定义一个 operator+
函数,然后就可以使用 c3 = c1 + c2;
这样的语法来执行加法,这无疑比函数调用更直观。
运营商重载的目的是:
① 增强代码的可读性(Readability)。
② 使自定义类型的操作更符合其数学或逻辑意义。
③ 让自定义类型能够像内置类型一样方便地参与表达式。
5.1.2 运算符重载的语法 (Operator Overloading Syntax)
运算符重载是通过定义一个特殊的函数来完成的,这个函数的名称由关键字 operator
跟着要重载的运算符符号组成。
函数的定义形式通常是:
1
ReturnType operator OperatorSymbol (Parameters) {
2
// 实现运算符的行为
3
}
⚝ ReturnType
: 运算符操作结果的类型。
⚝ operator
: C++ 关键字,表示接下来是一个运算符重载函数。
⚝ OperatorSymbol
: 需要重载的运算符,例如 +
, -
, *
, /
, ==
, !=
, <
, >
, <=
, >=
, =
, ++
, --
, <<
, >>
, []
, ()
等。
⚝ Parameters
: 运算符操作数(Operands)的类型和数量。参数的数量取决于运算符是一元(Unary)还是二元(Binary),以及它是作为成员函数还是非成员函数重载(这将在后续章节详细讨论)。
例如,重载一个 Vector
类的加法运算符 +
:
1
Vector operator+(const Vector& lhs, const Vector& rhs); // 作为非成员函数
或者
1
Vector operator+(const Vector& rhs) const; // 作为成员函数
5.1.3 运算符重载的限制 (Limitations of Operator Overloading)
尽管运算符重载提供了很大的灵活性,但 C++ 对其有一些重要的限制:
① 不能创建新的运算符(Cannot Create New Operators):只能重载 C++ 中已有的运算符集合。
② 不能改变运算符的优先级(Precedence)和结合性(Associativity):重载后的运算符优先级和结合性与原运算符保持一致。
③ 不能改变运算符的操作数个数(Arity):例如,+
永远是二元运算符(接受两个操作数),!
永远是一元运算符(接受一个操作数)。
④ 某些运算符不能被重载(Certain Operators Cannot Be Overloaded):这些运算符通常是语言结构或实现细节的关键部分。包括:
▮▮▮▮⚝ .
:成员访问运算符(Member Access Operator)
▮▮▮▮⚝ .*
:成员指针访问运算符(Pointer to Member Operator)
▮▮▮▮⚝ ::
:作用域解析运算符(Scope Resolution Operator)
▮▮▮▮⚝ ?:
:条件运算符(Conditional Operator / Ternary Operator)
▮▮▮▮⚝ sizeof
:大小运算符(Sizeof Operator)
▮▮▮▮⚝ typeid
:类型信息运算符(Type Information Operator) (C++11 之前有时列出,但实际上不能重载)
▮▮▮▮⚝ static_cast
, dynamic_cast
, reinterpret_cast
, const_cast
:类型转换运算符(Type Cast Operators) (虽然 operator Type()
可以重载用户自定义类型转换,但这些内置 cast 运算符不能被重载)。
⑤ 至少一个操作数必须是用户自定义类型:为了防止用户重载基本类型的运算符行为(例如改变 int + int
的含义),C++ 要求重载运算符时,至少有一个操作数必须是类类型(Class Type)或枚举类型(Enumeration Type)或它们的引用。
理解这些限制对于正确、安全地使用运算符重载至关重要。滥用或违反直觉的重载可能会使代码变得难以理解和调试,从而适得其反。
5.2 作为成员函数重载 (Overloading as Member Functions)
运算符重载函数可以作为类的非静态成员函数来实现。当运算符作为成员函数重载时,运算符的左操作数(Left Operand)隐式地成为调用该成员函数的对象(即 *this
指针指向的对象)。
5.2.1 成员函数重载的机制 (Mechanism of Member Function Overloading)
如果一个二元运算符 @
(例如 +
, -
, *
, /
, ==
等)作为成员函数重载,其语法通常是:
1
ReturnType operator@(const OtherType& rhs) const; // 对于不修改 *this 的情况
2
ReturnType operator@(const OtherType& rhs); // 对于修改 *this 的情况 (如 +=)
或者对于一元运算符 $
(例如 -
, !
, ++
, --
等):
1
ReturnType operator$() const; // 对于不修改 *this 的情况
2
ReturnType operator$(); // 对于修改 *this 的情况 (如 ++, --)
在成员函数版本的重载中:
① 左操作数是隐式的 *this
对象。
② 对于二元运算符,右操作数(Right Operand)作为函数的参数传入。
③ 对于一元运算符,没有显式的参数(除非是后置 ++
或 --
,它有一个哑元 int
参数)。
表达式 lhs @ rhs
当 @
作为 LhsClass
的成员函数重载时,会被编译器解释为 lhs.operator@(rhs)
。
表达式 $ rhs
当 $
作为 RhsClass
的成员函数重载时(这通常只对前置一元运算符有意义,且 this
指向 rhs
),会被解释为 rhs.operator$()
。
5.2.2 成员函数重载的示例 (Examples of Member Function Overloading)
考虑一个简单的 Point
类,我们想重载它的 +
运算符来实现点的向量加法,并重载 +=
运算符来实现原地加法赋值。
1
#include <iostream>
2
3
class Point {
4
private:
5
int x;
6
int y;
7
8
public:
9
// 构造函数
10
Point(int px = 0, int py = 0) : x(px), y(py) {}
11
12
// 获取成员的函数 (const 版本)
13
int getX() const { return x; }
14
int getY() const { return y; }
15
16
// 重载二元运算符 + (作为成员函数)
17
// 返回一个新的 Point 对象,表示两个点的和
18
// 因为这个操作不修改当前对象(*this),所以函数声明为 const
19
Point operator+(const Point& other) const {
20
std::cout << "调用成员函数 operator+" << std::endl;
21
return Point(this->x + other.x, this->y + other.y);
22
}
23
24
// 重载复合赋值运算符 += (作为成员函数)
25
// 修改当前对象(*this) 并返回 *this 的引用,以便链式调用
26
Point& operator+=(const Point& other) {
27
std::cout << "调用成员函数 operator+=" << std::endl;
28
this->x += other.x;
29
this->y += other.y;
30
return *this; // 返回当前对象的引用
31
}
32
33
// 重载前置 ++ 运算符 (作为成员函数)
34
// 增加当前对象并返回增加后的对象引用
35
Point& operator++() { // 前置版本没有参数
36
std::cout << "调用成员函数 operator++ (前置)" << std::endl;
37
++this->x;
38
++this->y;
39
return *this; // 返回修改后的 *this 的引用
40
}
41
42
// 重载后置 ++ 运算符 (作为成员函数)
43
// 增加当前对象,但返回增加前的对象副本
44
// 哑元 int 参数用于区分前置和后置版本
45
Point operator++(int) { // 后置版本有一个 int 参数
46
std::cout << "调用成员函数 operator++ (后置)" << std::endl;
47
Point temp = *this; // 保存当前对象的状态
48
++(*this); // 调用前置 ++ 来增加当前对象
49
return temp; // 返回之前保存的旧状态副本
50
}
51
};
52
53
// 为了展示,这里暂时不重载 <<,我们用 getX, getY 打印
54
// 后面会在 5.3 和 5.4 中介绍如何重载 <<
55
56
int main() {
57
Point p1(1, 2);
58
Point p2(3, 4);
59
60
// 使用重载的 + 运算符
61
Point p3 = p1 + p2; // 实际调用: p1.operator+(p2)
62
std::cout << "p1 + p2 = (" << p3.getX() << ", " << p3.getY() << ")" << std::endl; // 输出: (4, 6)
63
64
// 使用重载的 += 运算符
65
Point p4(10, 20);
66
Point p5(1, 1);
67
p4 += p5; // 实际调用: p4.operator+=(p5)
68
std::cout << "p4 after += p5 = (" << p4.getX() << ", " << p4.getY() << ")" << std::endl; // 输出: (11, 21)
69
70
// 使用重载的前置 ++ 运算符
71
Point p6(5, 5);
72
Point p7 = ++p6; // 实际调用: p6.operator++()
73
std::cout << "p6 after ++p6 = (" << p6.getX() << ", " << p6.getY() << ")" << std::endl; // 输出: (6, 6)
74
std::cout << "p7 = (" << p7.getX() << ", " << p7.getY() << ")" << std::endl; // 输出: (6, 6)
75
76
// 使用重载的后置 ++ 运算符
77
Point p8(7, 7);
78
Point p9 = p8++; // 实际调用: p8.operator++(0) -- 哑元参数的值不重要
79
std::cout << "p8 after p8++ = (" << p8.getX() << ", " << p8.getY() << ")" << std::endl; // 输出: (8, 8)
80
std::cout << "p9 = (" << p9.getX() << ", " << p9.getY() << ")" << std::endl; // 输出: (7, 7)
81
82
return 0;
83
}
1
// 运行示例代码的输出可能如下:
2
// 调用成员函数 operator+
3
// p1 + p2 = (4, 6)
4
// 调用成员函数 operator+=
5
// p4 after += p5 = (11, 21)
6
// 调用成员函数 operator++ (前置)
7
// p6 after ++p6 = (6, 6)
8
// p7 = (6, 6)
9
// 调用成员函数 operator++ (后置)
10
// 调用成员函数 operator++ (前置) // 注意:后置 ++ 内部通常调用前置 ++
11
// p8 after p8++ = (8, 8)
12
// p9 = (7, 7)
5.2.3 何时作为成员函数重载 (When to Overload as Member Functions)
通常建议在以下情况将运算符重载为类的成员函数:
① 赋值运算符(Assignment Operators):=
, +=
, -=
, *=
, /=
, etc. 这些运算符通常会修改左操作数(即对象自身),因此作为成员函数是自然的选择。并且,赋值运算符 =
必须是类的非静态成员函数。
② 下标运算符(Subscript Operator):[]
. 这个运算符也是操作对象自身(通过索引访问其内部元素),必须是类的非静态成员函数。
③ 函数调用运算符(Function Call Operator):()
. 用于创建函数对象(Functor),必须是类的非静态成员函数。
④ 指针成员访问运算符(Pointer-to-member Access Operator):->
和 ->*
. 用于模拟指针行为,必须是类的非静态成员函数。
⑤ 一元运算符(Unary Operators):例如 -
(取负), !
(逻辑非), &
(取地址). 如果该运算符主要作用于对象本身并可能修改对象(比如 ++
, --
),则通常重载为成员函数。
对于像 +
, -
, *
, /
, ==
, !=
, <
, >
等二元运算符,如果操作是非对称的(Asymmetric),即左操作数是类类型,而右操作数可能是其他类型(例如 MyClass + int
),并且操作主要依赖于类对象的内部状态,可以考虑作为成员函数。但如果操作是对称的(Symmetric)(例如 MyClass + MyClass
或 int + MyClass
都应该支持),则通常作为非成员函数重载更灵活(详见下一节)。
5.3 作为非成员函数重载 (Overloading as Non-Member Functions)
除了作为成员函数,运算符重载也可以作为普通(非成员)函数来实现。当运算符作为非成员函数重载时,运算符的所有操作数都作为函数的参数显式传递。
5.3.1 非成员函数重载的机制 (Mechanism of Non-Member Function Overloading)
如果一个二元运算符 @
作为非成员函数重载,其语法通常是:
1
ReturnType operator@(const Type1& lhs, const Type2& rhs);
其中 lhs
是左操作数,rhs
是右操作数。Type1
和 Type2
可以是不同的类型。
对于一元运算符 $
:
1
ReturnType operator$(const Type& operand);
其中 operand
是操作数。
在非成员函数版本的重载中:
① 所有操作数都作为函数的参数显式地列出。
② 函数不属于任何类,因此没有 this
指针。③ 至少一个参数必须是用户自定义类型(User-Defined Type),这是 C++ 的强制要求,以避免重载基本类型的运算符。
表达式 lhs @ rhs
当 @
作为非成员函数重载时,会被编译器解释为 operator@(lhs, rhs)
。
表达式 $ operand
当 $
作为非成员函数重载时,会被解释为 operator$(operand)
。
5.3.2 非成员函数重载的示例 (Examples of Non-Member Function Overloading)
继续使用 Point
类。我们希望重载二元 +
运算符作为非成员函数,以及流插入运算符 <<
。
1
#include <iostream>
2
3
class Point {
4
private:
5
int x;
6
int y;
7
8
public:
9
// 构造函数
10
Point(int px = 0, int py = 0) : x(px), y(py) {}
11
12
// 获取成员的函数 (const 版本)
13
int getX() const { return x; }
14
int getY() const { return y; }
15
16
// ... (其他成员函数,如 +=, ++ 等,可以继续保留或删除) ...
17
18
// 声明非成员的 operator<< 为友元函数,以便访问 private 成员
19
friend std::ostream& operator<<(std::ostream& os, const Point& p);
20
21
// 声明非成员的 operator+ 为友元函数 (可选,如果 operator+ 需要访问 private 成员)
22
// 或者提供 public 的 getX/getY 供 operator+ 调用
23
// friend Point operator+(const Point& lhs, const Point& rhs); // 如果需要访问 private 成员
24
};
25
26
// 重载二元运算符 + (作为非成员函数)
27
// 不需要是友元,因为 Point 提供了 public 的 getX/getY
28
Point operator+(const Point& lhs, const Point& rhs) {
29
std::cout << "调用非成员函数 operator+" << std::endl;
30
return Point(lhs.getX() + rhs.getX(), lhs.getY() + rhs.getY());
31
}
32
33
// 重载流插入运算符 << (作为非成员函数)
34
// 必须是非成员函数,因为左操作数是 std::ostream 类型
35
// 通常需要是类的友元函数才能方便地访问私有成员
36
std::ostream& operator<<(std::ostream& os, const Point& p) {
37
os << "(" << p.x << ", " << p.y << ")"; // 访问 private 成员 x 和 y
38
return os; // 返回流的引用,以便链式输出
39
}
40
41
int main() {
42
Point p1(1, 2);
43
Point p2(3, 4);
44
45
// 使用重载的非成员 operator+
46
Point p3 = p1 + p2; // 实际调用: operator+(p1, p2)
47
std::cout << "p1 + p2 = " << p3 << std::endl; // 使用重载的 operator<<
48
49
// 尝试 int + Point?
50
// Point p4 = 5 + p1; // 如果没有 operator+(int, Point) 重载,这里会出错
51
// 要支持 int + Point,需要重载 operator+(int, const Point&),这必须是非成员函数
52
// 让我们添加一个这样的重载
53
Point p4 = operator+(5, p1); // 显式调用 (不常用)
54
std::cout << "5 + p1 = " << p4 << std::endl; // 使用重载的 operator<<
55
56
return 0;
57
}
58
59
// 添加支持 int + Point 的非成员重载
60
Point operator+(int lhs, const Point& rhs) {
61
std::cout << "调用非成员函数 operator+(int, Point)" << std::endl;
62
return Point(lhs + rhs.getX(), rhs.getY());
63
}
1
// 运行示例代码的输出可能如下:
2
// 调用非成员函数 operator+
3
// p1 + p2 = (4, 6)
4
// 调用非成员函数 operator+(int, Point)
5
// 5 + p1 = (6, 2)
在这个例子中:
⚝ 非成员 operator+
接受两个 Point
对象作为参数,这使得 p1 + p2
的语法自然。由于 Point
提供了公共的 getX()
和 getY()
方法,这个函数不需要是 Point
的友元。
⚝ 非成员 operator<<
接受一个 std::ostream&
和一个 const Point&
作为参数。这使得 std::cout << p3
的语法成为可能,因为 std::cout
是 std::ostream
的一个对象。为了方便地访问 Point
的私有成员 x
和 y
进行输出,我们将 operator<<
函数声明为 Point
类的友元。
⚝ 我们还添加了一个 operator+(int lhs, const Point& rhs)
的非成员重载,这使得 5 + p1
这样的语法成为可能,展示了非成员函数重载在处理混合类型操作数时的优势。
5.3.3 何时作为非成员函数重载 (When to Overload as Non-Member Functions)
通常建议在以下情况将运算符重载为非成员函数:
① 对称操作(Symmetric Operations):当运算符的操作数具有对称性,例如 obj + obj
或 TypeA + TypeB
与 TypeB + TypeA
都需要支持时。非成员函数重载可以将两个操作数都作为参数,实现对称性。如果作为成员函数,TypeA + TypeB
会被解释为 TypeA.operator+(TypeB)
,但 TypeB + TypeA
则需要 TypeB
类有相应的成员函数重载,这通常不方便或不可能。
② 混合类型操作(Mixed-Type Operations):当运算符的操作数涉及不同类型,特别是其中一个操作数是内置类型或与当前类无关的类型时(例如 int + MyClass
或 ostream << MyClass
)。非成员函数重载可以显式地接受不同类型的参数,提供更大的灵活性。例如,要支持 int + Point
和 Point + int
,作为成员函数只能实现 Point.operator+(int)
(对应 Point + int
),而 int + Point
则无法实现。作为非成员函数,可以分别重载 operator+(Point, Point)
, operator+(int, Point)
, operator+(Point, int)
。
③ 流插入和流提取运算符(Stream Insertion and Extraction Operators):<<
和 >>
. 这些运算符的左操作数分别是 std::ostream
和 std::istream
对象,它们不是您正在定义的类。因此,这些运算符必须作为非成员函数重载。它们通常需要访问类的私有成员,因此常常被声明为类的友元(Friend)。
总结来说,如果一个运算符需要访问类的私有或保护成员,并且它最好作为非成员函数重载(例如因为需要对称性或处理混合类型),那么它应该被声明为类的友元函数。
5.4 友元函数与友元类 (Friend Functions and Friend Classes)
在前面的章节中,我们已经提到了友元(Friend)的概念,特别是在重载流运算符 <<
时。本节将详细探讨友元函数和友元类,以及它们在运算符重载中的作用。
5.4.1 友元函数 (Friend Functions)
概念:友元函数是定义在类外部的普通函数,但它被授予访问该类私有(private)和保护(protected)成员的权限。
声明:在类定义内部使用 friend
关键字声明友元函数。声明可以放在类的任何访问控制区域(public
, protected
, private
)——尽管通常放在 public
或类的开始部分以表明意图,但实际上位置和访问控制区域不影响其友元关系。
1
class MyClass {
2
private:
3
int data;
4
5
public:
6
MyClass(int d) : data(d) {}
7
8
// 声明一个非成员函数 friendFunction 为 MyClass 的友元
9
friend void friendFunction(const MyClass& obj);
10
11
// 声明一个非成员运算符重载函数 operator<< 为 MyClass 的友元
12
friend std::ostream& operator<<(std::ostream& os, const MyClass& obj);
13
};
14
15
// 友元函数 friendFunction 的定义
16
void friendFunction(const MyClass& obj) {
17
// 友元函数可以直接访问 MyClass 的 private 成员 data
18
std::cout << "Friend function access: obj.data = " << obj.data << std::endl;
19
}
20
21
// 友元运算符重载函数 operator<< 的定义
22
std::ostream& operator<<(std::ostream& os, const MyClass& obj) {
23
// 友元函数可以直接访问 MyClass 的 private 成员 data
24
os << "MyClass(" << obj.data << ")";
25
return os;
26
}
27
28
int main() {
29
MyClass obj(123);
30
friendFunction(obj); // 调用友元函数
31
std::cout << obj << std::endl; // 使用友元运算符重载
32
33
// std::cout << obj.data << std::endl; // 错误:不能直接访问 private 成员
34
return 0;
35
}
1
// 运行示例代码的输出可能如下:
2
// Friend function access: obj.data = 123
3
// MyClass(123)
作用:友元函数主要用于需要在类外部实现,但又需要访问类内部私有/保护成员的情况。在运算符重载中,它经常用于:
① 非成员运算符重载函数,如 <<
或 >>
,因为它们的左操作数不是类类型。
② 需要对称访问两个不同类型对象的运算符,如果其中一个对象需要访问另一个对象的私有/保护成员。
③ 为对称的二元运算符(如 +
, ==
)提供非成员重载,同时允许它们直接访问类成员,而无需通过公共的 getter/setter 方法(这可能更高效或在某些设计下更合理,尽管牺牲了一定的封装性)。
注意事项:
⚝ 友元关系是单向的(Unilateral):A
是 B
的友元,不意味着 B
也是 A
的友元。
⚝ 友元关系是非传递的(Non-transitive):如果 A
是 B
的友元,B
是 C
的友元,不意味着 A
是 C
的友元,也不意味着 C
是 B
的友元。
⚝ 友元关系是非继承的(Non-inherited):基类的友元不自动成为派生类的友元,派生类的友元也不自动成为基类的友元。
⚝ 友元函数虽然在类中声明,但它不是类的成员函数,因此不能使用 this
指针。
5.4.2 友元类 (Friend Classes)
概念:一个类可以将另一个类声明为它的友元类。友元类的所有成员函数都被授予访问该友元声明所在类的私有和保护成员的权限。
声明:在类定义内部使用 friend class
关键字声明友元类。
1
class Subject {
2
private:
3
int secret_data;
4
5
public:
6
Subject(int data) : secret_data(data) {}
7
8
// 声明 Reporter 类是 Subject 的友元类
9
friend class Reporter;
10
};
11
12
class Reporter {
13
public:
14
void report(const Subject& obj) {
15
// 作为 Subject 的友元类,Reporter 的成员函数可以访问 Subject 的 private 成员
16
std::cout << "Reporter access: obj.secret_data = " << obj.secret_data << std::endl;
17
}
18
};
19
20
int main() {
21
Subject sub(456);
22
Reporter rep;
23
rep.report(sub); // Reporter 的成员函数可以访问 Subject 的 private 成员
24
25
// std::cout << sub.secret_data << std::endl; // 错误:不能直接访问 private 成员
26
return 0;
27
}
1
// 运行示例代码的输出可能如下:
2
// Reporter access: obj.secret_data = 456
作用:友元类通常用于关系非常紧密的两个类,它们在实现上紧密协作,需要相互访问私有成员。例如,一个容器类和它的迭代器类。
注意事项:
⚝ 友元类的成员函数具有访问权限,但友元类本身不是原类的派生类,它们之间没有继承关系。
⚝ 友元类的所有成员函数都是友元函数,无论它们是否实际需要访问私有成员。
5.4.3 友元在运算符重载中的应用 (Application of Friends in Operator Overloading)
友元机制在运算符重载中扮演着重要的角色,尤其是在需要将运算符重载为非成员函数但又需要访问类私有数据时。
最典型的例子就是流插入 <<
和流提取 >>
运算符。它们的左操作数是 std::ostream&
和 std::istream&
,因此必须是非成员函数。为了让这些函数能够访问类对象内部的数据进行输出或输入,需要将它们声明为类的友元函数。
1
// 回顾 Point 类的 operator<< 例子
2
class Point {
3
private:
4
int x;
5
int y;
6
7
public:
8
Point(int px = 0, int py = 0) : x(px), y(py) {}
9
10
// 声明非成员函数 operator<< 为 Point 的友元
11
friend std::ostream& operator<<(std::ostream& os, const Point& p);
12
};
13
14
// operator<< 的定义可以访问 p.x 和 p.y
15
std::ostream& operator<<(std::ostream& os, const Point& p) {
16
os << "(" << p.x << ", " << p.y << ")";
17
return os;
18
}
对于其他二元运算符,例如 +
。如果希望支持混合类型操作(如 int + Point
),或者希望实现对称操作(如 Point + Point
)作为非成员函数,并且需要访问私有成员,那么也可以将 operator+
声明为友元。
1
class Point {
2
private:
3
int x;
4
int y;
5
6
public:
7
Point(int px = 0, int py = 0) : x(px), y(py) {}
8
9
// ... 其他成员 ...
10
11
// 如果 operator+ 需要访问 private 成员,可以声明为友元
12
friend Point operator+(const Point& lhs, const Point& rhs);
13
friend Point operator+(int lhs, const Point& rhs);
14
friend Point operator+(const Point& lhs, int rhs);
15
16
// ... 其他友元 ...
17
};
18
19
// 友元 operator+ 定义,可以直接访问 x, y
20
Point operator+(const Point& lhs, const Point& rhs) {
21
return Point(lhs.x + rhs.x, lhs.y + rhs.y);
22
}
23
24
Point operator+(int lhs, const Point& rhs) {
25
return Point(lhs + rhs.x, rhs.y);
26
}
27
28
Point operator+(const Point& lhs, int rhs) {
29
return Point(lhs.x + rhs, lhs.y);
30
}
使用友元函数进行运算符重载是一种常见的模式,尤其对于需要非成员实现的运算符。然而,需要注意的是,滥用友元会削弱类的封装性。因此,应该仅在必要且能带来清晰、直观的代码时使用友元。
5.5 常用运算符的重载实践 (Practical Operator Overloading)
本节将通过一些常见运算符的重载示例,展示如何在实践中应用成员函数和非成员函数重载技术,以及友元机制。我们将使用一个简单的 Fraction
类来表示分数 \( \frac{numerator}{denominator} \)。
1
#include <iostream>
2
#include <numeric> // for std::gcd in C++17, or implement gcd manually
3
4
// 手动实现最大公约数 (GCD)
5
int gcd(int a, int b) {
6
return b == 0 ? a : gcd(b, a % b);
7
}
8
9
class Fraction {
10
private:
11
int numerator; // 分子
12
int denominator; // 分母
13
14
// 简化分数到最简形式
15
void simplify() {
16
if (denominator == 0) {
17
// 处理错误情况,例如抛出异常或设置为无效状态
18
// 这里简单起见,不处理除零
19
return;
20
}
21
if (denominator < 0) {
22
numerator = -numerator;
23
denominator = -denominator;
24
}
25
int common = gcd(std::abs(numerator), denominator);
26
numerator /= common;
27
denominator /= common;
28
}
29
30
public:
31
// 构造函数
32
Fraction(int num = 0, int den = 1) : numerator(num), denominator(den) {
33
// 确保分母不为零,并简化分数
34
if (denominator == 0) {
35
std::cerr << "错误:分母不能为零!" << std::endl;
36
// 可以选择抛出异常 std::runtime_error("分母为零");
37
// 或者设置为一个无效状态,这里简单处理
38
numerator = 0;
39
denominator = 1;
40
}
41
simplify();
42
}
43
44
// 获取分子和分母 (const 版本)
45
int getNumerator() const { return numerator; }
46
int getDenominator() const { return denominator; }
47
48
// --- 作为成员函数重载 ---
49
50
// 重载 +=
51
Fraction& operator+=(const Fraction& other) {
52
numerator = numerator * other.denominator + other.numerator * denominator;
53
denominator = denominator * other.denominator;
54
simplify();
55
return *this;
56
}
57
58
// 重载 ==
59
bool operator==(const Fraction& other) const {
60
// 因为构造函数和 += 都进行了简化,直接比较分子分母即可
61
return numerator == other.numerator && denominator == other.denominator;
62
}
63
64
// 重载 <
65
bool operator<(const Fraction& other) const {
66
// 为了避免浮点精度问题,通过交叉相乘比较
67
// lhs.num / lhs.den < rhs.num / rhs.den
68
// 等价于 lhs.num * rhs.den < rhs.num * lhs.den (假设分母都为正)
69
// 我们的 simplify 确保分母为正
70
return (long long)numerator * other.denominator < (long long)other.numerator * denominator;
71
}
72
73
// --- 声明友元用于非成员重载 ---
74
75
// 流插入运算符 <<
76
friend std::ostream& operator<<(std::ostream& os, const Fraction& f);
77
78
// 重载 + (作为非成员函数)
79
// 如果需要访问 private 成员,就声明为友元
80
// 这里我们让它访问 private 成员以简化代码,避免调用 getter
81
friend Fraction operator+(Fraction lhs, const Fraction& rhs); // 注意:lhs 传值,为了方便 += 实现
82
friend Fraction operator+(Fraction lhs, int rhs);
83
friend Fraction operator+(int lhs, const Fraction& rhs);
84
85
// 重载 !=, >, <=, >= (基于 == 和 < 实现)
86
friend bool operator!=(const Fraction& lhs, const Fraction& rhs);
87
friend bool operator>(const Fraction& lhs, const Fraction& rhs);
88
friend bool operator<=(const Fraction& lhs, const Fraction& rhs);
89
friend bool operator>=(const Fraction& lhs, const Fraction& rhs);
90
};
91
92
// --- 作为非成员函数重载 ---
93
94
// 重载流插入运算符 <<
95
std::ostream& operator<<(std::ostream& os, const Fraction& f) {
96
os << f.numerator;
97
if (f.denominator != 1) {
98
os << "/" << f.denominator;
99
}
100
return os;
101
}
102
103
// 重载 + (利用 += 实现)
104
// lhs 传值是为了创建 lhs 的一个副本,然后在这个副本上执行 +=
105
Fraction operator+(Fraction lhs, const Fraction& rhs) {
106
lhs += rhs; // 调用成员函数 +=
107
return lhs; // 返回修改后的副本
108
}
109
110
Fraction operator+(Fraction lhs, int rhs) {
111
lhs += Fraction(rhs); // 将 int 转换为 Fraction,然后调用 +=
112
return lhs;
113
}
114
115
Fraction operator+(int lhs, const Fraction& rhs) {
116
return Fraction(lhs) + rhs; // 将 int 转换为 Fraction,然后调用上面的 operator+(Fraction, Fraction)
117
}
118
119
120
// 重载比较运算符 (基于已有的 == 和 < 实现)
121
bool operator!=(const Fraction& lhs, const Fraction& rhs) {
122
return !(lhs == rhs);
123
}
124
125
bool operator>(const Fraction& lhs, const Fraction& rhs) {
126
return rhs < lhs;
127
}
128
129
bool operator<=(const Fraction& lhs, const Fraction& rhs) {
130
return !(lhs > rhs);
131
}
132
133
bool operator>=(const Fraction& lhs, const Fraction& rhs) {
134
return !(lhs < rhs);
135
}
136
137
138
int main() {
139
Fraction f1(1, 2); // 1/2
140
Fraction f2(1, 3); // 1/3
141
Fraction f3(2, 4); // 2/4 -> 1/2
142
143
std::cout << "f1: " << f1 << std::endl;
144
std::cout << "f2: " << f2 << std::endl;
145
std::cout << "f3: " << f3 << std::endl;
146
147
// 使用重载的 +
148
Fraction f4 = f1 + f2;
149
std::cout << f1 << " + " << f2 << " = " << f4 << std::endl; // 1/2 + 1/3 = 5/6
150
151
// 使用重载的 +=
152
Fraction f5(1, 4); // 1/4
153
f5 += f1;
154
std::cout << "f5 after += f1: " << f5 << std::endl; // 1/4 + 1/2 = 3/4
155
156
// 使用重载的 == 和 !=
157
std::cout << f1 << " == " << f3 << " ? " << (f1 == f3 ? "true" : "false") << std::endl; // true
158
std::cout << f1 << " != " << f2 << " ? " << (f1 != f2 ? "true" : "false") << std::endl; // true
159
160
// 使用重载的 <, >, <=, >=
161
std::cout << f1 << " < " << f2 << " ? " << (f1 < f2 ? "true" : "false") << std::endl; // false (1/2 > 1/3)
162
std::cout << f1 << " > " << f2 << " ? " << (f1 > f2 ? "true" : "false") << std::endl; // true
163
std::cout << f1 << " <= " << f3 << " ? " << (f1 <= f3 ? "true" : "false") << std::endl; // true
164
std::cout << f1 << " >= " << f2 << " ? " << (f1 >= f2 ? "true" : "false") << std::endl; // true
165
166
// 使用混合类型操作数
167
Fraction f6 = f1 + 1;
168
std::cout << f1 << " + 1 = " << f6 << std::endl; // 1/2 + 1 = 3/2
169
170
Fraction f7 = 1 + f2;
171
std::cout << "1 + " << f2 << " = " << f7 << std::endl; // 1 + 1/3 = 4/3
172
173
return 0;
174
}
1
// 运行示例代码的输出可能如下:
2
// f1: 1/2
3
// f2: 1/3
4
// f3: 1/2
5
// 1/2 + 1/3 = 5/6
6
// f5 after += f1: 3/4
7
// 1/2 == 1/2 ? true
8
// 1/2 != 1/3 ? true
9
// 1/2 < 1/3 ? false
10
// 1/2 > 1/3 ? true
11
// 1/2 <= 1/2 ? true
12
// 1/2 >= 1/3 ? true
13
// 1/2 + 1 = 3/2
14
// 1 + 1/3 = 4/3
在这个例子中,我们展示了:
① 算术运算符 (+
, +=
): +=
作为成员函数实现,因为它修改左操作数。非成员 +
利用 +=
来实现,通过传值的方式获取左操作数的副本并在其上操作,然后返回副本,这是一种常见的实现模式。还展示了如何通过非成员重载支持混合类型操作数(Fraction + int
和 int + Fraction
)。
② 比较运算符 (==
, <
, !=
, >
, <=
, >=
): ==
和 <
作为成员函数实现。其他比较运算符 (!=
, >
, <=
, >=
) 则作为非成员函数实现,并且利用 ==
和 <
的结果。这减少了代码重复,并确保了一致性(例如,如果 ==
实现有误,依赖它的 !=
也会有误,但逻辑关系正确)。在 C++20 中,可以使用三向比较运算符(Three-way Comparison Operator) <=>
(Spaceship Operator) 极大地简化比较运算符的实现。
③ 流插入运算符 (<<
): 必须作为非成员函数实现,并声明为类的友元,以便访问私有成员进行输出。
常用运算符重载的建议实践:
⚝ 赋值运算符 =
: 必须是成员函数。实现时要注意自我赋值检查和资源管理(深拷贝/移动)。
⚝ 复合赋值运算符 +=
, -=
, etc.:通常作为成员函数实现,因为它们修改对象自身。返回 *this
的引用 (ClassName& operator...=
) 以支持链式调用。
⚝ 二元算术运算符 +
, -
, etc.:通常作为非成员函数实现,以支持对称操作和混合类型操作数。可以利用相应的复合赋值运算符来实现 (如 Fraction operator+(Fraction lhs, const Fraction& rhs) { lhs += rhs; return lhs; }
)。
⚝ 比较运算符 ==
, !=
, <
, >
, <=
, >=
: 可以作为成员或非成员。通常将 ==
和 <
作为成员函数实现,然后将其他比较运算符作为非成员函数,通过调用 ==
和 <
来实现。C++20 引入 operator<=>
可以简化所有比较的实现。
⚝ 下标运算符 []
: 必须是成员函数。通常提供 const
和非 const
版本,非 const
版本返回引用以便修改,const
版本返回 const 引用以防止修改。
⚝ 函数调用运算符 ()
: 必须是成员函数。用于创建可调用对象。
⚝ 流插入/提取运算符 <<
, >>
: 必须是非成员函数,通常需要声明为友元。返回流的引用 (std::ostream&
或 std::istream&
) 以支持链式操作。
⚝ 递增/递减运算符 ++
, --
: 可以作为成员函数实现。需要区分前置 (ClassName& operator++()
) 和后置 (ClassName operator++(int)
) 版本。前置版本返回引用并修改对象,后置版本返回修改前的对象副本并修改对象。
选择成员函数还是非成员函数重载取决于运算符的语义和操作数的类型。理解何时使用哪种方式,以及友元的作用,是有效使用运算符重载的关键。合理的运算符重载能够显著提升代码的质量和可用性。
6. 继承与多态 (Inheritance and Polymorphism)
欢迎来到本书关于 C++ 类的深入解析之旅!在前面的章节中,我们已经学习了如何定义一个类,如何管理对象的生命周期,以及类的基本成员特性。这些构建了面向对象编程(OOP)的基石。而本章,我们将探索 OOP 中最强大和核心的特性之一:继承(Inheritance)和多态(Polymorphism)。
继承允许我们定义一个类作为另一个类的扩展,从而实现代码的重用和建立类型之间的层次关系。多态则允许我们以统一的方式处理不同类型的对象,增强了代码的灵活性和可扩展性。理解并熟练运用继承与多态是成为一名优秀的 C++ 程序员的关键步骤。
在本章中,我们将从继承的基本概念出发,探讨不同的继承方式及其对访问权限的影响,深入理解派生类(Derived Class)的构造和析构过程。随后,我们将介绍多重继承(Multiple Inheritance)可能带来的挑战,并学习如何使用虚继承(Virtual Inheritance)来解决这些问题。最后,我们将讨论在继承体系中的类型转换:向上转型(Upcasting)和向下转型(Downcasting)。准备好了吗?让我们一起踏上这段激动人心的学习旅程!🚀
6.1 继承的概念与目的 (Concept and Purpose of Inheritance)
继承(Inheritance)是面向对象编程(OOP)的三大基本特性(封装、继承、多态)之一。它是一种“is-a”关系的模型,表示一个新类(派生类或子类)可以基于一个已有的类(基类或父类)创建,并继承基类的属性(成员变量)和行为(成员函数)。
例如,我们可以有一个通用的 Shape
(形状)类,然后派生出 Circle
(圆形)、Square
(方形)等类。一个 Circle
是一个 Shape
,一个 Square
也是一个 Shape
。这就是典型的继承关系。
继承的目的主要有:
① 代码重用(Code Reusability):派生类可以重用基类中已经实现的成员变量和成员函数,而无需重复编写相同的代码。这大大减少了代码量,提高了开发效率。
② 建立层次结构(Establishing Hierarchies):继承允许我们对复杂的现实世界或系统模型进行分层建模,从一般概念(基类)到具体概念(派生类),使代码结构更清晰、更易于理解和管理。
③ 实现多态(Enabling Polymorphism):继承是实现运行时多态(Runtime Polymorphism)的基础。通过基类指针或引用操作派生类对象时,可以调用派生类中特有的或重写的成员函数,实现“同一个接口,不同的实现”。
考虑一个简单的例子:
1
#include <iostream>
2
#include <string>
3
4
// 基类: 动物 (Animal)
5
class Animal {
6
public:
7
std::string name; // 名字
8
9
Animal(const std::string& n) : name(n) {
10
std::cout << "Animal " << name << " created." << std::endl;
11
}
12
13
~Animal() {
14
std::cout << "Animal " << name << " destroyed." << std::endl;
15
}
16
17
void eat() const {
18
std::cout << name << " is eating." << std::endl;
19
}
20
21
void sleep() const {
22
std::cout << name << " is sleeping." << std::endl;
23
}
24
};
25
26
// 派生类: 狗 (Dog),继承自 Animal
27
class Dog : public Animal { // public 继承
28
public:
29
std::string breed; // 品种
30
31
Dog(const std::string& n, const std::string& b) : Animal(n), breed(b) { // 调用基类构造函数
32
std::cout << "Dog " << name << " (breed: " << breed << ") created." << std::endl;
33
}
34
35
~Dog() {
36
std::cout << "Dog " << name << " (breed: " << breed << ") destroyed." << std::endl;
37
}
38
39
void bark() const { // Dog 特有的行为
40
std::cout << name << " is barking." << std::endl;
41
}
42
};
43
44
int main() {
45
Dog myDog("Buddy", "Golden Retriever"); // 创建 Dog 对象
46
myDog.eat(); // 调用继承来的 Animal 成员函数
47
myDog.sleep(); // 调用继承来的 Animal 成员函数
48
myDog.bark(); // 调用 Dog 特有的成员函数
49
50
// Animal generic_animal("Leo");
51
// generic_animal.bark(); // 错误:Animal 没有 bark() 方法
52
53
return 0; // myDog 会被销毁,先调用 Dog 的析构函数,再调用 Animal 的析构函数
54
}
上面的例子中,Dog
类通过 public Animal
继承了 Animal
类的所有非私有成员(name
, eat()
, sleep()
)。Dog
类还拥有自己特有的成员 breed
和 bark()
。创建 Dog
对象时,它既具有 Animal
的特性,也具有 Dog
自己的特性。这就是继承带来的好处:基于现有功能进行扩展。
6.2 继承方式 (Inheritance Types): public, protected, private
在 C++ 中,继承时可以使用关键字 public
、protected
或 private
来指定继承方式。不同的继承方式会影响基类成员在派生类中的访问权限。
假设基类 Base
中有 public
、protected
和 private
成员。派生类 Derived
通过某种方式继承自 Base
。
1
class Base {
2
public:
3
int pub_member;
4
protected:
5
int prot_member;
6
private:
7
int priv_member; // private 成员在类外和派生类中都不可直接访问
8
};
现在,让我们看看三种继承方式的影响:
① public
继承 (public
Inheritance):
▮▮▮▮ 基类成员在派生类中的访问权限保持不变。
▮▮▮▮ public
成员在派生类中仍然是 public
。
▮▮▮▮ protected
成员在派生类中仍然是 protected
。
▮▮▮▮ private
成员在派生类中仍然是 private
(不可直接访问)。
▮▮▮▮ 用途:最常用的继承方式,通常用于实现“is-a”关系。表示派生类是基类的一个子类型,派生类的公共接口也是其自身公共接口的一部分。
1
class Derived_Public : public Base {
2
// Inside Derived_Public:
3
void func() {
4
pub_member = 1; // OK (public)
5
prot_member = 2; // OK (protected)
6
// priv_member = 3; // Error (private in Base, not accessible)
7
}
8
};
9
10
// Outside Derived_Public:
11
void other_func_public(Derived_Public dp) {
12
dp.pub_member = 10; // OK (public)
13
// dp.prot_member = 20; // Error (protected in Derived_Public, not accessible outside)
14
// dp.priv_member = 30; // Error (private in Base, not accessible)
15
}
② protected
继承 (protected
Inheritance):
▮▮▮▮ 基类的 public
和 protected
成员在派生类中变为 protected
。
▮▮▮▮ 基类的 private
成员在派生类中仍然是 private
(不可直接访问)。
▮▮▮▮ 用途:较少使用,通常用于当派生类不希望将基类的公共接口暴露给外部,但又希望进一步的派生类能够访问这些成员时。
1
class Derived_Protected : protected Base {
2
// Inside Derived_Protected:
3
void func() {
4
pub_member = 1; // OK (becomes protected)
5
prot_member = 2; // OK (protected)
6
// priv_member = 3; // Error
7
}
8
};
9
10
// Outside Derived_Protected:
11
void other_func_protected(Derived_Protected dp) {
12
// dp.pub_member = 10; // Error (becomes protected in Derived_Protected, not accessible outside)
13
// dp.prot_member = 20; // Error
14
// dp.priv_member = 30; // Error
15
}
③ private
继承 (private
Inheritance):
▮▮▮▮ 基类的 public
和 protected
成员在派生类中变为 private
。
▮▮▮▮ 基类的 private
成员在派生类中仍然是 private
(不可直接访问)。
▮▮▮▮ 用途:通常用于实现“has-a”或者“implemented in terms of”关系,而非“is-a”关系。派生类利用基类的实现,但不暴露基类的接口给外部。它更像是一种组合(Composition)的替代方案,但可以访问基类的 protected
成员。
1
class Derived_Private : private Base {
2
// Inside Derived_Private:
3
void func() {
4
pub_member = 1; // OK (becomes private)
5
prot_member = 2; // OK (becomes private)
6
// priv_member = 3; // Error
7
}
8
};
9
10
// Outside Derived_Private:
11
void other_func_private(Derived_Private dp) {
12
// dp.pub_member = 10; // Error (becomes private in Derived_Private, not accessible outside)
13
// dp.prot_member = 20; // Error
14
// dp.priv_member = 30; // Error
15
}
下表总结了不同继承方式对基类成员访问权限的影响:
基类成员访问权限 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
public | public | protected | private |
protected | protected | protected | private |
private | 不可访问 | 不可访问 | 不可访问 |
重要的点:
⚝ 无论何种继承方式,派生类都无法直接访问基类的 private
成员。如果需要访问,通常需要通过基类提供的 public
或 protected
成员函数。
⚝ 继承方式只影响基类成员在派生类内部以及通过派生类对象在外部的访问权限,不影响基类成员在基类内部的访问权限。
在实际开发中,public
继承是最常见和推荐的方式,它清晰地表达了类型之间的“is-a”关系。private
继承有时用于实现细节,而 protected
继承则相对较少用到。
6.3 派生类的构造与析构 (Derived Class Constructors and Destructors)
当创建一个派生类对象时,它的构建过程不仅仅是初始化派生类自身的成员,还需要构建其基类部分。类似地,销毁派生类对象时,需要先销毁派生类部分,再销毁基类部分。这个过程涉及到基类和派生类构造函数与析构函数的调用顺序。
构造函数调用顺序:
在创建一个派生类对象时,构造函数的调用顺序遵循从基类到派生类的原则。如果存在多层继承,顺序则为:
① 虚拟基类的构造函数(如果存在)
② 非虚拟基类的构造函数(按照它们在派生类声明中的出现顺序)
③ 派生类自身的构造函数
考虑一个简单的两层继承结构:
1
#include <iostream>
2
3
class Base {
4
public:
5
Base() {
6
std::cout << "Base Constructor called." << std::endl;
7
}
8
~Base() {
9
std::cout << "Base Destructor called." << std::endl;
10
}
11
};
12
13
class Derived : public Base {
14
public:
15
Derived() {
16
std::cout << "Derived Constructor called." << std::endl;
17
}
18
~Derived() {
19
std::cout << "Derived Destructor called." << std::endl;
20
}
21
};
22
23
int main() {
24
Derived d; // 创建一个 Derived 对象
25
// 输出:
26
// Base Constructor called.
27
// Derived Constructor called.
28
29
return 0; // d 销毁时
30
// 输出:
31
// Derived Destructor called.
32
// Base Destructor called.
33
}
上面的例子清晰地展示了构造时先基类后派生类,析构时先派生类后基类的顺序。
派生类构造函数中初始化基类部分:
派生类的构造函数负责初始化整个对象,包括其继承的基类部分和派生类自身的成员。初始化基类部分通常通过成员初始化列表(Member Initializer List)在派生类构造函数中显式调用基类的构造函数来完成。
1
#include <iostream>
2
#include <string>
3
4
class Animal {
5
public:
6
std::string name;
7
Animal(const std::string& n) : name(n) {
8
std::cout << "Animal Constructor with name: " << name << std::endl;
9
}
10
Animal() { // Default constructor
11
std::cout << "Animal Default Constructor." << std::endl;
12
}
13
};
14
15
class Dog : public Animal {
16
public:
17
std::string breed;
18
Dog(const std::string& n, const std::string& b) : Animal(n), breed(b) { // 初始化列表调用基类构造函数
19
std::cout << "Dog Constructor with breed: " << breed << std::endl;
20
}
21
Dog(const std::string& b) : breed(b) { // 没有显式调用基类构造函数
22
std::cout << "Dog Constructor (no base name): " << breed << std::endl;
23
}
24
};
25
26
int main() {
27
Dog d1("Buddy", "Golden"); // 调用 Dog(const std::string&, const std::string&)
28
// 输出:
29
// Animal Constructor with name: Buddy
30
// Dog Constructor with breed: Golden
31
32
std::cout << "---" << std::endl;
33
34
Dog d2("Poodle"); // 调用 Dog(const std::string&)
35
// 输出:
36
// Animal Default Constructor. <--- 注意这里调用了基类的默认构造函数
37
// Dog Constructor (no base name): Poodle
38
39
return 0;
40
}
在 Dog::Dog(const std::string& n, const std::string& b) AlBeRt63EiNsTeIn Animal(n), breed(b)
是成员初始化列表。Animal(n)
显式调用了基类 Animal
的带参数构造函数来初始化基类部分。breed(b)
初始化派生类自身的成员 breed
。
如果在派生类构造函数的初始化列表中没有显式调用基类的构造函数,编译器会尝试调用基类的默认构造函数(Default Constructor)。如果基类没有默认构造函数(例如,只定义了带参数的构造函数但没有无参数构造函数),并且派生类构造函数没有显式调用基类的其他构造函数,则会导致编译错误。因此,通常建议基类要么提供一个默认构造函数,要么派生类在初始化列表中显式调用基类的某个构造函数。
析构函数调用顺序:
析构函数(Destructor)的调用顺序与构造函数相反,遵循从派生类到基类的原则:
① 派生类自身的析构函数
② 非虚拟基类的析构函数(按照它们在派生类声明中的出现顺序的逆序)
③ 虚拟基类的析构函数(如果存在,只会调用一次)
这一点在上面的简单例子中已经展示过了。需要注意的是,如果通过基类指针删除派生类对象,为了确保派生类的析构函数也能被正确调用,基类的析构函数必须是虚函数(Virtual Function)。这将在下一章多态的部分详细讨论(参见 7.3 虚析构函数)。
6.4 多重继承 (Multiple Inheritance)
多重继承(Multiple Inheritance)是指一个类可以从多个基类继承特性。C++ 支持多重继承,其语法是在派生类声明的基类列表中列出所有基类,用逗号分隔。
1
#include <iostream>
2
#include <string>
3
4
// 基类 A
5
class BaseA {
6
public:
7
BaseA() { std::cout << "BaseA Constructor" << std::endl; }
8
~BaseA() { std::cout << "BaseA Destructor" << std::endl; }
9
void methodA() const { std::cout << "BaseA::methodA" << std::endl; }
10
};
11
12
// 基类 B
13
class BaseB {
14
public:
15
BaseB() { std::cout << "BaseB Constructor" << std::endl; }
16
~BaseB() { std::cout << "BaseB Destructor" << std::endl; }
17
void methodB() const { std::cout << "BaseB::methodB" << std::endl; }
18
};
19
20
// 派生类 Derived 继承自 BaseA 和 BaseB
21
class Derived : public BaseA, public BaseB {
22
public:
23
Derived() { std::cout << "Derived Constructor" << std::endl; }
24
~Derived() { std::cout << "Derived Destructor" << std::endl; }
25
// Derived 可以在内部访问 BaseA 和 BaseB 的非私有成员
26
};
27
28
int main() {
29
Derived d; // 创建 Derived 对象
30
// 构造函数调用顺序 (取决于 BaseA 和 BaseB 在 Derived 声明中的顺序):
31
// BaseA Constructor
32
// BaseB Constructor
33
// Derived Constructor
34
35
d.methodA(); // 调用继承自 BaseA 的方法
36
d.methodB(); // 调用继承自 BaseB 的方法
37
38
return 0; // d 销毁时
39
// 析构函数调用顺序 (与构造顺序相反的基类部分,然后派生类):
40
// Derived Destructor
41
// BaseB Destructor
42
// BaseA Destructor
43
}
在上面的例子中,Derived
类同时继承了 BaseA
和 BaseB
的功能,可以调用 methodA()
和 methodB()
。构造和析构的顺序遵循“从基类到派生类”和“从派生类到基类”的原则,且基类部分的顺序取决于它们在派生类声明中出现的顺序。
多重继承的潜在问题:
虽然多重继承提供了强大的组合能力,但也可能引入一些复杂性,其中最著名的是菱形继承问题(Diamond Problem)。我们将在下一节详细讨论这个问题及其解决方案。
另一个潜在的问题是命名冲突(Name Clashes)。如果不同的基类拥有同名的成员(变量或函数),而派生类试图直接访问这个名称,编译器将无法确定应该使用哪个基类的成员,从而产生歧义错误。
1
#include <iostream>
2
3
class Base1 {
4
public:
5
int value = 1;
6
void print() const { std::cout << "Base1 value: " << value << std::endl; }
7
};
8
9
class Base2 {
10
public:
11
int value = 2;
12
void print() const { std::cout << "Base2 value: " << value << std::endl; }
13
};
14
15
class Derived : public Base1, public Base2 {
16
public:
17
void display() {
18
// std::cout << value << std::endl; // Error: reference to 'value' is ambiguous
19
// print(); // Error: reference to 'print' is ambiguous
20
21
// 需要使用作用域解析运算符显式指定
22
std::cout << "Derived accessing Base1::value: " << Base1::value << std::endl;
23
std::cout << "Derived accessing Base2::value: " << Base2::value << std::endl;
24
Base1::print();
25
Base2::print();
26
}
27
};
28
29
int main() {
30
Derived d;
31
d.display();
32
return 0;
33
}
当基类成员发生命名冲突时,需要在派生类或外部访问时使用作用域解析运算符(::
)来明确指定要访问哪个基类的成员,例如 Base1::value
或 Base2::print()
。
尽管存在这些问题,多重继承在某些情况下仍然是有用的,尤其是在需要组合不相关类的接口或功能时。然而,它也常常被组合(Composition)或接口继承(通过抽象类和虚函数)等技术替代,以降低复杂性。
6.5 菱形继承与虚继承 (Diamond Problem and Virtual Inheritance)
菱形继承问题(Diamond Problem)是多重继承中一个典型的、比较复杂的场景。它发生在当一个类(例如 Derived
)同时继承自两个类(例如 IntermediateA
和 IntermediateB
),而这两个中间类又都继承自同一个基类(例如 Base
)时。其继承关系图呈现为一个菱形。
1
Base
2
/ / IntermediateA IntermediateB
3
\ /
4
\ /
5
Derived
在没有特殊处理的情况下,Derived
类会包含 Base
类的两份独立的副本(一份来自 IntermediateA
,一份来自 IntermediateB
)。这会导致以下问题:
① 数据冗余(Data Redundancy):Derived
对象中会有两套 Base
类的成员变量,浪费内存。
② 访问歧义(Access Ambiguity):如果 Derived
试图直接访问 Base
类的成员(例如,调用 Base
的某个函数或访问某个变量),编译器会因为不知道应该通过 IntermediateA
的路径还是 IntermediateB
的路径来访问 Base
的成员而产生歧义错误。
例如:
1
#include <iostream>
2
3
class Base {
4
public:
5
int value;
6
Base(int v) : value(v) { std::cout << "Base Constructor: " << value << std::endl; }
7
~Base() { std::cout << "Base Destructor: " << value << std::endl; }
8
};
9
10
class IntermediateA : public Base {
11
public:
12
IntermediateA(int v) : Base(v) { std::cout << "IntermediateA Constructor" << std::endl; }
13
~IntermediateA() { std::cout << "IntermediateA Destructor" << std::endl; }
14
};
15
16
class IntermediateB : public Base {
17
public:
18
IntermediateB(int v) : Base(v) { std::cout << "IntermediateB Constructor" << std::endl; }
19
~IntermediateB() { std::cout << "IntermediateB Destructor" << std::endl; }
20
};
21
22
class Derived : public IntermediateA, public IntermediateB {
23
public:
24
Derived(int v_a, int v_b) : IntermediateA(v_a), IntermediateB(v_b) {
25
std::cout << "Derived Constructor" << std::endl;
26
// std::cout << value << std::endl; // Error: 'value' is ambiguous
27
// std::cout << Base::value << std::endl; // Error: non-static member 'value' may not be qualified with the name of its class
28
// 需要通过 Intermediate 类来访问
29
std::cout << "Value via A: " << IntermediateA::value << std::endl;
30
std::cout << "Value via B: " << IntermediateB::value << std::endl;
31
}
32
~Derived() { std::cout << "Derived Destructor" << std::endl; }
33
};
34
35
int main() {
36
Derived d(10, 20);
37
// 构造顺序(非虚拟继承):
38
// Base Constructor: 10 (IntermediateA 的 Base 部分)
39
// IntermediateA Constructor
40
// Base Constructor: 20 (IntermediateB 的 Base 部分)
41
// IntermediateB Constructor
42
// Derived Constructor
43
44
return 0;
45
// 析构顺序:
46
// Derived Destructor
47
// IntermediateB Destructor
48
// Base Destructor: 20
49
// IntermediateA Destructor
50
// Base Destructor: 10
51
}
注意 Derived
对象中存在 Base
的两个独立实例,它们的 value
成员可以有不同的值。直接访问 value
会产生歧义。
虚继承(Virtual Inheritance)是 C++ 为了解决菱形继承问题而引入的机制。通过在继承声明中使用 virtual
关键字,可以使得在多重继承体系中,某个基类(被称为虚拟基类)的实例在派生类对象中只存在一份。
1
#include <iostream>
2
3
class Base {
4
public:
5
int value;
6
Base(int v) : value(v) { std::cout << "Base Constructor (Virtual): " << value << std::endl; }
7
~Base() { std::cout << "Base Destructor (Virtual): " << value << std::endl; }
8
};
9
10
// IntermediateA 虚继承自 Base
11
class IntermediateA : virtual public Base {
12
public:
13
IntermediateA(int v) : Base(v) { // 虚继承时,虚拟基类的构造函数由最派生类负责调用
14
std::cout << "IntermediateA Constructor" << std::endl;
15
}
16
~IntermediateA() { std::cout << "IntermediateA Destructor" << std::endl; }
17
};
18
19
// IntermediateB 也虚继承自 Base
20
class IntermediateB : virtual public Base {
21
public:
22
IntermediateB(int v) : Base(v) { // 虚继承时,虚拟基类的构造函数由最派生类负责调用
23
std::cout << "IntermediateB Constructor" << std::endl;
24
}
25
~IntermediateB() { std::cout << "IntermediateB Destructor" << std::endl; }
26
};
27
28
// Derived 多重继承 IntermediateA 和 IntermediateB
29
// Derived 是最派生类 (Most Derived Class)
30
class Derived : public IntermediateA, public IntermediateB {
31
public:
32
// 作为最派生类,Derived 需要负责调用虚拟基类 Base 的构造函数
33
Derived(int v_base, int v_a, int v_b)
34
: Base(v_base), // <-- 这里的 Base(v_base) 调用 Base 的构造函数,用于初始化共享的 Base 部分
35
IntermediateA(v_a), IntermediateB(v_b) { // IntermediateA 和 IntermediateB 中的 Base(v) 调用会被忽略
36
std::cout << "Derived Constructor" << std::endl;
37
// 现在可以直接访问 value,因为它只有一份
38
std::cout << "Value via Derived: " << value << std::endl;
39
}
40
~Derived() { std::cout << "Derived Destructor" << std::endl; }
41
};
42
43
int main() {
44
Derived d(30, 10, 20); // 提供给 Base 的值是 30
45
// 构造顺序 (虚继承):
46
// Base Constructor (Virtual): 30 <-- 只有一份 Base,由 Derived 负责初始化
47
// IntermediateA Constructor
48
// IntermediateB Constructor
49
// Derived Constructor
50
51
std::cout << "Final value: " << d.value << std::endl; // OK, value 是共享的
52
53
return 0;
54
// 析构顺序:
55
// Derived Destructor
56
// IntermediateB Destructor
57
// IntermediateA Destructor
58
// Base Destructor (Virtual): 30 <-- 最后销毁 Base
59
}
在虚继承体系中,虚拟基类的构造函数由最派生类(Most Derived Class)负责调用和初始化。路径上的中间类(如 IntermediateA
和 IntermediateB
)如果在初始化列表中调用了虚拟基类的构造函数,这些调用将被忽略,只有最派生类的调用生效。这确保了虚拟基类只被构建一次。
虚继承解决了数据冗余和访问歧义问题,但它引入了一些额外的开销(通常需要额外的指针或偏移量来定位虚拟基类部分)和构造函数初始化的复杂性(最派生类需要知道并调用所有虚拟基类的构造函数)。因此,虚继承不应滥用,只在真正需要解决菱形问题时使用。
6.6 向上转型与向下转型 (Upcasting and Downcasting)
在 C++ 的继承体系中,对象之间的类型转换是一种常见的操作,特别是与多态结合使用时。主要有两种类型的转型:向上转型和向下转型。
① 向上转型(Upcasting):
▮▮▮▮ 将派生类对象的指针或引用转换为其基类类型的指针或引用。
▮▮▮▮ 安全性:这是安全的、隐式的(automatic)类型转换。因为派生类对象“is-a”基类对象,它包含了基类部分所需的所有成员和信息。
▮▮▮▮ 用法:可以将派生类对象的地址或引用直接赋值给基类类型的指针或引用变量。
1
#include <iostream>
2
3
class Base {
4
public:
5
void baseMethod() const { std::cout << "Base method" << std::endl; }
6
};
7
8
class Derived : public Base {
9
public:
10
void derivedMethod() const { std::cout << "Derived method" << std::endl; }
11
void baseMethod() const { std::cout << "Derived overriding base method" << std::endl; } // 重写基类方法
12
};
13
14
int main() {
15
Derived d; // 创建派生类对象
16
Base* pb = &d; // 向上转型:将 Derived* 转换为 Base*
17
Base& rb = d; // 向上转型:将 Derived& 转换为 Base&
18
19
pb->baseMethod(); // 调用的是 Derived::baseMethod(),如果 Base::baseMethod 是虚函数 (多态)
20
// 否则 (如果 Base::baseMethod 不是虚函数),调用的是 Base::baseMethod()
21
rb.baseMethod(); // 同上
22
23
// pb->derivedMethod(); // Error: Base 指针不知道 Derived 特有的方法
24
25
return 0;
26
}
向上转型是多态得以实现的基础。通过基类指针或引用,我们可以统一地操作不同类型的派生类对象,并(如果基类成员是虚函数)在运行时调用到实际对象的成员函数。
② 向下转型(Downcasting):
▮▮▮▮ 将基类对象的指针或引用转换为其派生类类型的指针或引用。
▮▮▮▮ 安全性:这是不安全的、显式的(explicit)类型转换,需要程序员自己负责确保转换的合法性。一个基类指针或引用可能指向一个基类对象本身,也可能指向一个派生类对象。如果基类指针/引用实际上指向一个与目标派生类不相关的对象,向下转型就会失败或导致未定义行为。
▮▮▮▮ 用法:需要使用类型转换运算符。C++ 提供了几种用于向下转型的转换运算符:static_cast
和 dynamic_cast
。
▮▮▮▮⚝ static_cast
:
▮▮▮▮▮▮▮▮⚝ 用法:Derived* pd = static_cast<Derived*>(pb);
▮▮▮▮▮▮▮▮⚝ 特点:在编译时进行检查,不进行运行时类型检查。如果转换不合法(例如 pb
实际上没有指向一个 Derived
对象),结果是未定义的行为,可能会导致程序崩溃或其他错误。速度较快。
▮▮▮▮▮▮▮▮⚝ 适用场景:当你确定基类指针/引用实际指向的是目标派生类对象时。
▮▮▮▮⚝ dynamic_cast
:
▮▮▮▮▮▮▮▮⚝ 用法:
▮▮▮▮▮▮▮▮▮▮▮▮⚝ 指针:Derived* pd = dynamic_cast<Derived*>(pb);
如果转换成功,返回一个有效的 Derived*
指针;如果转换失败(pb
没有指向 Derived
对象),返回 nullptr
。
▮▮▮▮▮▮▮▮▮▮▮▮⚝ 引用:Derived& rd = dynamic_cast<Derived&>(rb);
如果转换成功,返回一个有效的 Derived&
引用;如果转换失败,抛出 std::bad_cast
异常。
▮▮▮▮▮▮▮▮⚝ 特点:在运行时进行类型检查。要求基类必须至少包含一个虚函数(这样对象才会有虚表 VTable,用于运行时类型信息 RTTI)。速度相对较慢。
▮▮▮▮▮▮▮▮⚝ 适用场景:当你不确定基类指针/引用实际指向的是什么类型,需要在运行时进行判断时。这是进行安全的向下转型的推荐方式。
1
#include <iostream>
2
#include <typeinfo> // for std::bad_cast
3
4
class Base {
5
public:
6
virtual ~Base() {} // 需要虚函数才能使用 dynamic_cast
7
void baseMethod() const { std::cout << "Base method" << std::endl; }
8
};
9
10
class Derived1 : public Base {
11
public:
12
void derived1Method() const { std::cout << "Derived1 method" << std::endl; }
13
};
14
15
class Derived2 : public Base {
16
public:
17
void derived2Method() const { std::cout << "Derived2 method" << std::endl; }
18
};
19
20
int main() {
21
Derived1 d1;
22
Base* pb = &d1; // 向上转型
23
24
// 使用 dynamic_cast 进行安全的向下转型
25
Derived1* pd1 = dynamic_cast<Derived1*>(pb);
26
if (pd1) {
27
std::cout << "Dynamic cast to Derived1 successful." << std::endl;
28
pd1->derived1Method(); // OK
29
} else {
30
std::cout << "Dynamic cast to Derived1 failed." << std::endl;
31
}
32
33
Derived2* pd2 = dynamic_cast<Derived2*>(pb); // pb 实际指向 Derived1 对象
34
if (pd2) {
35
std::cout << "Dynamic cast to Derived2 successful." << std::endl;
36
pd2->derived2Method();
37
} else {
38
std::cout << "Dynamic cast to Derived2 failed (as expected)." << std::endl; // 会执行这里
39
}
40
41
std::cout << "---" << std::endl;
42
43
// 使用 static_cast (不安全,如果类型不匹配)
44
Derived1* ps1 = static_cast<Derived1*>(pb); // 知道 pb 指向 Derived1,是安全的
45
ps1->derived1Method(); // OK
46
47
Base* pb_base = new Base();
48
// Derived1* ps_fail = static_cast<Derived1*>(pb_base); // 错误:pb_base 指向 Base,不是 Derived1
49
// 虽然编译通过,但调用 ps_fail->derived1Method() 会导致未定义行为!
50
delete pb_base; // Remember to delete heap objects
51
52
return 0;
53
}
总结来说,向上转型是安全的,用于利用多态特性。向下转型是不安全的,需要显式转换。dynamic_cast
是进行安全向下转型的首选方式,它在运行时检查类型。static_cast
更快,但只应用于你确定类型正确的情况。
本章我们详细探讨了 C++ 继承的各个方面,包括基本概念、不同继承方式、构造/析构顺序、多重继承以及菱形继承问题和虚继承的解决方案,最后讨论了类型转型。这些是理解和使用 C++ 面向对象特性的关键。在下一章,我们将聚焦于多态的实现机制和更高级的应用,进一步发挥 OOP 的强大能力。
7. 多态的实现与应用 (Implementing and Applying Polymorphism)
欢迎来到本书关于 C++ 类的深度解析之旅!在前一章,我们了解了继承如何构建类之间的层级关系。本章将在此基础上,深入探讨面向对象编程(Object-Oriented Programming, OOP)的另一个核心支柱:多态(Polymorphism)。多态允许我们以统一的方式处理不同类型的对象,极大地提高了代码的灵活性、可扩展性和可维护性。我们将详细讲解 C++ 中实现运行时多态的关键机制:虚函数(Virtual Functions)、纯虚函数(Pure Virtual Functions)和抽象类(Abstract Classes),并探讨虚析构函数(Virtual Destructors)、运行时类型信息(Run-Time Type Information, RTTI)以及协变返回类型(Covariant Return Types)等相关高级话题。无论您是希望掌握多态的基础用法,还是想理解其底层机制和高级应用,本章都将为您提供全面而深入的指导。
7.1 虚函数 (Virtual Functions)
虚函数是 C++ 实现运行时多态(Run-Time Polymorphism)或动态多态(Dynamic Polymorphism)的核心机制。通过在基类(Base Class)中声明一个函数为虚函数,我们可以确保在通过基类指针(Pointer)或引用(Reference)调用该函数时,实际执行的是指针或引用所指向的派生类(Derived Class)中对应的函数版本,而不是基类中的版本。这种在运行时根据对象的实际类型来确定调用哪个函数的能力,就是多态性的体现。
7.1.1 虚函数的工作原理 (How Virtual Functions Work)
理解虚函数的工作原理有助于我们更好地使用它,尤其是在涉及性能和内存开销的场景。这是 C++ 标准允许的一种实现方式,但不是强制要求,大多数现代编译器(如 GCC, Clang, MSVC)都采用这种基于虚表(vtable)和虚指针(vptr)的机制。
① 虚表(vtable):
▮▮▮▮每个带有虚函数的类(或继承了虚函数的类)都有一个与之关联的虚表。
▮▮▮▮虚表是一个函数指针(Function Pointer)的数组。
▮▮▮▮数组中的每个条目指向该类中一个虚函数的实现地址。如果派生类重写(Override)了基类的虚函数,则虚表中对应条目指向派生类中的实现;否则,指向基类中的实现。
▮▮▮▮虚表通常由编译器在编译时生成。
② 虚指针(vptr):
▮▮▮▮对于一个包含虚函数的类的对象,编译器会为其添加一个隐藏的成员:虚指针。
▮▮▮▮这个虚指针通常是对象内存布局中的第一个成员。
▮▮▮▮虚指针指向该对象所属类(或其最底层的派生类)的虚表。
▮▮▮▮当通过基类指针或引用调用虚函数时,程序会通过该对象的虚指针找到对应的虚表,然后根据虚函数在虚表中的偏移量(由编译器确定)找到正确的函数地址,最后调用该函数。
③ 示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
// 声明为虚函数
6
virtual void greet() {
7
std::cout << "Hello from Base!" << std::endl;
8
}
9
10
// 非虚函数
11
void non_virtual_func() {
12
std::cout << "This is a non-virtual function in Base." << std::endl;
13
}
14
};
15
16
class Derived : public Base {
17
public:
18
// 重写基类的虚函数
19
void greet() override { // C++11 以后可以使用 override 关键字,下一节会介绍
20
std::cout << "Hello from Derived!" << std::endl;
21
}
22
23
// 派生类自己的函数
24
void derived_func() {
25
std::cout << "This is a function in Derived." << std::endl;
26
}
27
};
28
29
int main() {
30
Base* base_ptr;
31
Derived derived_obj;
32
33
base_ptr = &derived_obj; // 向上转型 (Upcasting)
34
35
// 通过基类指针调用虚函数 - 调用的是派生类的实现 (多态)
36
base_ptr->greet();
37
38
// 通过基类指针调用非虚函数 - 调用的是基类的实现 (非多态)
39
base_ptr->non_virtual_func();
40
41
// 直接通过派生类对象调用函数
42
derived_obj.greet();
43
derived_obj.non_virtual_func();
44
derived_obj.derived_func();
45
46
return 0;
47
}
1
// 预期输出:
2
// Hello from Derived!
3
// This is a non-virtual function in Base.
4
// Hello from Derived!
5
// This is a non-virtual function in Base.
6
// This is a function in Derived.
④ 注意事项:
▮▮▮▮构造函数(Constructor)不能是虚函数。在对象构造时,虚表尚未完全构建或稳定,此时进行虚函数调用可能导致未定义行为。
▮▮▮▮静态成员函数(Static Member Functions)不能是虚函数,因为它们不与类的任何特定对象关联。
▮▮▮▮友元函数(Friend Functions)不能是虚函数,因为友元函数不是类的成员。
▮▮▮▮内联函数(Inline Functions)可以声明为虚函数,但如果通过基类指针/引用调用(触发多态),编译器通常会取消内联。
▮▮▮▮虚函数会带来一定的运行时开销(通过虚表查找函数地址)和内存开销(每个对象多一个虚指针,每个类多一个虚表),但在实现多态时通常是必要的。
7.1.2 override
和 final
说明符 (C++11+) (override
and final
Specifiers)
从 C++11 开始,引入了两个上下文关键字(Contextual Keywords)override
和 final
,用于增强虚函数的类型安全和控制。
① override
说明符:
▮▮▮▮用于显式地标记一个成员函数旨在重写基类中的虚函数。
▮▮▮▮如果在派生类中标记了 override
,但该函数实际上没有重写基类的虚函数(例如,基类中没有同名同参数列表的虚函数,或者基类函数不是虚函数),编译器会报错。
▮▮▮▮这有助于在编译时捕获因拼写错误、参数列表不匹配或基类函数签名改变等原因导致的重写失败,提高代码的健壮性。
② final
说明符:
▮▮▮▮可以用于虚函数或类。
▮▮▮▮用于虚函数时,表示该虚函数不能在任何进一步的派生类中被重写。
▮▮▮▮用于类时,表示该类不能被其他类继承。试图继承一个标记为 final
的类会引起编译错误。
③ 示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual void func1() { std::cout << "Base::func1" << std::endl; }
6
virtual void func2(int x) { std::cout << "Base::func2(" << x << ")" << std::endl; }
7
virtual void func3() { std::cout << "Base::func3" << std::endl; }
8
};
9
10
class Derived : public Base {
11
public:
12
// 正确重写 func1,使用 override 明确意图
13
void func1() override { std::cout << "Derived::func1" << std::endl; }
14
15
// 错误示例:尝试重写 func2,但参数列表不匹配 (func2 没有 override)
16
// void func2(double x) override { std::cout << "Derived::func2(" << x << ")" << std::endl; } // 编译错误
17
18
// 正确重写 func2,使用 override 明确意图
19
void func2(int x) override { std::cout << "Derived::func2(" << x << ")" << std::endl; }
20
21
// 重写 func3,并标记为 final,阻止进一步派生类重写它
22
void func3() override final { std::cout << "Derived::func3 (final)" << std::endl; }
23
};
24
25
// 错误示例:尝试继承 Derived (因为它标记为 final)
26
// class FurtherDerived : public Derived {}; // 编译错误
27
28
int main() {
29
Base* ptr = new Derived();
30
ptr->func1();
31
ptr->func2(10);
32
ptr->func3(); // 调用 Derived::func3
33
34
delete ptr;
35
36
return 0;
37
}
1
// 预期输出:
2
// Derived::func1
3
// Derived::func2(10)
4
// Derived::func3 (final)
使用 override
和 final
可以提高代码的清晰度,减少潜在的错误,并在设计继承体系时提供更强的控制。
7.2 纯虚函数与抽象类 (Pure Virtual Functions and Abstract Classes)
在某些情况下,我们可能希望基类定义一个接口(Interface),而不提供某个虚函数的具体实现。我们期望所有继承该基类的派生类都必须提供这个函数的实现。这时,我们就可以使用纯虚函数(Pure Virtual Functions)和抽象类(Abstract Classes)。
① 纯虚函数:
▮▮▮▮一个纯虚函数是在虚函数声明后面加上 = 0
。例如:virtual void func() = 0;
。
▮▮▮▮纯虚函数没有函数体(即没有实现)。
▮▮▮▮它的存在是为了在基类中声明一个接口,强制派生类必须提供实现。
② 抽象类:
▮▮▮▮任何包含至少一个纯虚函数的类都是抽象类。
▮▮▮▮抽象类不能被直接实例化(不能创建抽象类的对象)。
▮▮▮▮抽象类的主要作用是作为基类,定义一个接口或一个骨架,供派生类继承和实现。
▮▮▮▮派生类如果想要实例化,就必须实现基类中的所有纯虚函数。如果派生类没有实现基类中的所有纯虚函数,那么该派生类本身也是抽象类。
③ 示例:
1
#include <iostream>
2
3
// 抽象基类 Shape
4
class Shape {
5
public:
6
// 纯虚函数 - 强制派生类实现计算面积的方法
7
virtual double area() const = 0;
8
9
// 纯虚函数 - 强制派生类实现绘制方法
10
virtual void draw() const = 0;
11
12
// 可以有非虚函数
13
void print_info() const {
14
std::cout << "This is a Shape." << std::endl;
15
}
16
17
// 可以有虚函数,但有默认实现 (非纯虚)
18
virtual void display_type() const {
19
std::cout << "Type: Unknown Shape" << std::endl;
20
}
21
22
// 抽象类可以有构造函数和析构函数 (析构函数通常需要是虚函数)
23
Shape() { std::cout << "Shape constructor called." << std::endl; }
24
virtual ~Shape() { std::cout << "Shape destructor called." << std::endl; } // 虚析构函数很重要,下一节会讲
25
};
26
27
// 派生类 Circle
28
class Circle : public Shape {
29
private:
30
double radius;
31
public:
32
Circle(double r) : radius(r) { std::cout << "Circle constructor called." << std::endl; }
33
34
// 实现纯虚函数 area
35
double area() const override {
36
return 3.14159 * radius * radius;
37
}
38
39
// 实现纯虚函数 draw
40
void draw() const override {
41
std::cout << "Drawing a Circle with radius " << radius << std::endl;
42
}
43
44
// 重写虚函数 display_type
45
void display_type() const override {
46
std::cout << "Type: Circle" << std::endl;
47
}
48
49
~Circle() override { std::cout << "Circle destructor called." << std::endl; } // 虚析构函数的重写
50
};
51
52
// 派生类 Rectangle
53
class Rectangle : public Shape {
54
private:
55
double width;
56
double height;
57
public:
58
Rectangle(double w, double h) : width(w), height(h) { std::cout << "Rectangle constructor called." << std::endl; }
59
60
// 实现纯虚函数 area
61
double area() const override {
62
return width * height;
63
}
64
65
// 实现纯虚函数 draw
66
void draw() const override {
67
std::cout << "Drawing a Rectangle with width " << width << " and height " << height << std::endl;
68
}
69
70
~Rectangle() override { std::cout << "Rectangle destructor called." << std::endl; } // 虚析构函数的重写
71
};
72
73
74
int main() {
75
// Shape s; // 错误:不能实例化抽象类 Shape
76
77
Shape* shapes[2]; // 可以创建指向抽象类的指针或引用
78
79
shapes[0] = new Circle(5.0);
80
shapes[1] = new Rectangle(4.0, 6.0);
81
82
// 通过基类指针调用虚函数和纯虚函数 (多态行为)
83
for (int i = 0; i < 2; ++i) {
84
shapes[i]->draw();
85
std::cout << "Area: " << shapes[i]->area() << std::endl;
86
shapes[i]->display_type();
87
shapes[i]->print_info();
88
std::cout << "---" << std::endl;
89
}
90
91
// 清理内存 (需要虚析构函数)
92
for (int i = 0; i < 2; ++i) {
93
delete shapes[i];
94
}
95
96
return 0;
97
}
1
// 预期输出 (顺序可能因内存分配略有不同,但构造/析构对应关系不变):
2
// Shape constructor called.
3
// Circle constructor called.
4
// Shape constructor called.
5
// Rectangle constructor called.
6
// Drawing a Circle with radius 5
7
// Area: 78.53975
8
// Type: Circle
9
// This is a Shape.
10
// ---
11
// Drawing a Rectangle with width 4 and height 6
12
// Area: 24
13
// Type: Rectangle
14
// This is a Shape.
15
// ---
16
// Circle destructor called.
17
// Shape destructor called.
18
// Rectangle destructor called.
19
// Shape destructor called.
④ 纯虚函数与接口:
▮▮▮▮一个类如果只包含纯虚函数而没有数据成员和非虚函数(除了构造函数和虚析构函数),则它非常类似于 Java 或 C# 中的接口(Interface)。C++ 标准库中的许多概念,如迭代器(Iterator)和分配器(Allocator)等,也是基于这种接口思想设计的。
7.3 虚析构函数 (Virtual Destructors)
虚析构函数是 C++ 多态中一个非常关键但常常被新手忽略的细节。它的作用是确保在通过基类指针(或引用)删除一个派生类对象时,能够正确地调用派生类以及所有中间基类的析构函数,从而避免资源泄露(Resource Leak)。
① 问题:
▮▮▮▮考虑一个类继承体系,基类 Base
和派生类 Derived
。如果 Derived
类中分配了堆内存或其他资源,需要在析构函数中释放。
▮▮▮▮如果在不将基类析构函数声明为虚函数的情况下,通过 Base* ptr = new Derived();
创建对象,并通过 delete ptr;
删除对象。
▮▮▮▮此时,delete
语句会根据指针的静态类型(即 Base*
)调用 Base
类的析构函数。Derived
类的析构函数将不会被调用,导致 Derived
中分配的资源无法释放。
② 解决方案:
▮▮▮▮将基类的析构函数声明为虚函数:virtual ~Base();
。
▮▮▮▮当基类的析构函数是虚函数时,通过基类指针删除派生类对象时,运行时机制会查找对象的实际类型(Derived
),并从最派生的类(Derived
)开始,沿着继承链依次调用所有类的析构函数,直到基类(Base
)。
③ 规则与建议:
▮▮▮▮如果一个类将被用作基类,并且可能通过基类指针删除其派生类对象,那么基类的析构函数就应该声明为虚函数。这是保证资源安全的基本原则。
▮▮▮▮如果一个类没有虚函数,也不打算被继承(或者即使被继承,也不打算通过基类指针删除派生类对象),那么它的析构函数可以不是虚函数。
▮▮▮▮虚析构函数会引入虚表和虚指针的开销,但对于可能被用作基类的类来说,这个开销通常是值得的,因为它保证了内存安全。
▮▮▮▮一个类只要包含任何虚函数,就几乎总是应该将其析构函数声明为虚函数。这是一个常用的最佳实践。
④ 示例:
1
#include <iostream>
2
#include <vector> // 引入 vector 方便演示资源管理
3
4
class Base {
5
public:
6
// 如果没有 virtual,delete ptr 会只调用 Base 的析构函数
7
// 如果有 virtual,delete ptr 会调用 Derived 的析构函数,再调用 Base 的析构函数
8
virtual ~Base() {
9
std::cout << "Base destructor called." << std::endl;
10
}
11
12
// 为了让这个类有可能被继承,我们至少需要一个虚函数或者虚析构函数
13
// 如果类中有其他虚函数,通常析构函数也应该为虚函数
14
virtual void dummy_virtual_func() {}
15
};
16
17
class Derived : public Base {
18
private:
19
std::vector<int>* data; // 模拟派生类中分配的资源
20
public:
21
Derived() : data(new std::vector<int>(100)) {
22
std::cout << "Derived constructor called. Allocating data." << std::endl;
23
}
24
25
~Derived() override { // 派生类的析构函数,通常使用 override 明确重写意图
26
std::cout << "Derived destructor called. Deallocating data." << std::endl;
27
delete data; // 释放资源
28
}
29
};
30
31
int main() {
32
Base* ptr = new Derived(); // 使用基类指针指向派生类对象
33
std::cout << "Object created." << std::endl;
34
35
// 通过基类指针删除对象
36
// 如果 Base::~Base 是非虚函数,这里只会调用 Base::~Base()
37
// 如果 Base::~Base 是虚函数,这里会先调用 Derived::~Derived(),再调用 Base::~Base()
38
delete ptr;
39
std::cout << "Object deleted." << std::endl;
40
41
return 0;
42
}
1
// 预期输出 (Base 析构函数是虚函数时):
2
// Derived constructor called. Allocating data.
3
// Object created.
4
// Derived destructor called. Deallocating data.
5
// Base destructor called.
6
// Object deleted.
7
8
// 如果 Base 析构函数不是虚函数:
9
// Derived constructor called. Allocating data.
10
// Object created.
11
// Base destructor called.
12
// Object deleted.
13
// (注意:Derived 析构函数没有被调用,data 没有被释放,可能导致内存泄露)
这个例子清楚地展示了虚析构函数在通过基类指针删除派生类对象时的重要性。这是编写安全可靠的 C++ 面向对象代码的关键。
7.4 运行时类型信息 (RTTI: Run-Time Type Information)
运行时类型信息(Run-Time Type Information, RTTI)是 C++ 提供的一种机制,允许程序在运行时查询对象的实际类型。RTTI 主要通过两个操作符实现:dynamic_cast
和 typeid
。
① dynamic_cast
:
▮▮▮▮用于在类层次结构中进行安全的向下转型(Downcasting)或侧向转型(Crosscasting)。
▮▮▮▮向下转型是将基类指针/引用转换为派生类指针/引用。侧向转型是将一个基类的指针/引用转换为另一个(非直接相关,但可能通过多重继承关联的)基类的指针/引用。
▮▮▮▮dynamic_cast
在运行时检查类型转换的合法性。
▮▮▮▮如果转换是合法的(即指针或引用确实指向目标类型或其派生类型的对象),则 dynamic_cast
返回有效的指针或引用。
▮▮▮▮如果转换不合法:
▮▮▮▮▮▮▮▮对指针类型,dynamic_cast
返回 nullptr
。
▮▮▮▮▮▮▮▮对引用类型,dynamic_cast
抛出 std::bad_cast
异常。
▮▮▮▮dynamic_cast
只能用于包含虚函数的类层次结构(多态类)。这是因为 dynamic_cast
的实现依赖于虚表信息来确定对象的实际类型。
▮▮▮▮语法:
▮▮▮▮▮▮▮▮dynamic_cast<TargetType*>(pointer_to_Base)
▮▮▮▮▮▮▮▮dynamic_cast<TargetType&>(reference_to_Base)
② typeid
:
▮▮▮▮用于获取表达式的类型信息。
▮▮▮▮对于对象或指针解引用,typeid
返回一个 std::type_info
对象的引用,该对象描述了表达式的动态类型(如果表达式是多态类型的左值表达式)。
▮▮▮▮对于其他表达式(非多态类型的左值或右值),typeid
返回的是表达式的静态类型。
▮▮▮▮typeid
操作符的实现也可能依赖于虚表信息来获取动态类型。
▮▮▮▮头文件:<typeinfo>
。
▮▮▮▮语法:typeid(expression)
。
▮▮▮▮std::type_info
类提供了 name()
成员函数(返回类型的字符串表示,格式依赖于实现)和 before(const type_info&)
成员函数(比较类型顺序)。
③ RTTI 的开销:
▮▮▮▮启用 RTTI 会增加可执行文件的大小(需要存储类型信息和虚表)。
▮▮▮▮dynamic_cast
在运行时进行类型检查,这会带来一定的性能开销,通常比静态类型转换 (static_cast
, reinterpret_cast
) 慢。
④ 何时使用 RTTI:
▮▮▮▮通常情况下,面向对象设计鼓励使用虚函数和多态来避免显式的类型检查和向下转型。这遵循“多态优于条件判断”(Polymorphism over conditional logic)的原则。
▮▮▮▮但在某些特定场景下,RTTI 是有用甚至必需的:
▮▮▮▮▮▮▮▮你需要获取对象的实际类型信息,例如用于日志记录或调试。
▮▮▮▮▮▮▮▮你需要根据对象的实际类型执行特定的操作,而这些操作无法通过虚函数机制表达(例如,你正在处理一个来自外部库的对象,无法修改其类定义添加虚函数)。
▮▮▮▮▮▮▮▮实现某些设计模式,如访问者模式(Visitor Pattern)。
⑤ 示例:
1
#include <iostream>
2
#include <typeinfo> // for typeid
3
#include <vector> // for dynamic_cast with references
4
5
class Base {
6
public:
7
virtual ~Base() {} // 需要虚函数才能使用 dynamic_cast 和 typeid 获取动态类型
8
virtual void func_base() { std::cout << "Base::func_base" << std::endl; }
9
};
10
11
class DerivedA : public Base {
12
public:
13
void func_a() { std::cout << "DerivedA::func_a" << std::endl; }
14
void func_base() override { std::cout << "DerivedA::func_base" << std::endl; }
15
};
16
17
class DerivedB : public Base {
18
public:
19
void func_b() { std::cout << "DerivedB::func_b" << std::endl; }
20
void func_base() override { std::cout << "DerivedB::func_base" << std::endl; }
21
};
22
23
int main() {
24
Base* ptr1 = new DerivedA();
25
Base* ptr2 = new DerivedB();
26
Base* ptr3 = new Base();
27
Base* ptr4 = nullptr;
28
29
std::cout << "--- Using dynamic_cast ---" << std::endl;
30
31
// 向下转型 (指针)
32
DerivedA* ptrA = dynamic_cast<DerivedA*>(ptr1);
33
if (ptrA) {
34
std::cout << "ptr1 successfully cast to DerivedA*" << std::endl;
35
ptrA->func_a();
36
} else {
37
std::cout << "ptr1 could not be cast to DerivedA*" << std::endl;
38
}
39
40
DerivedB* ptrB = dynamic_cast<DerivedB*>(ptr1);
41
if (ptrB) {
42
std::cout << "ptr1 successfully cast to DerivedB*" << std::endl;
43
ptrB->func_b();
44
} else {
45
std::cout << "ptr1 could not be cast to DerivedB*" << std::endl;
46
}
47
48
DerivedA* ptrA_from_base = dynamic_cast<DerivedA*>(ptr3);
49
if (ptrA_from_base) {
50
std::cout << "ptr3 successfully cast to DerivedA*" << std::endl;
51
ptrA_from_base->func_a();
52
} else {
53
std::cout << "ptr3 could not be cast to DerivedA*" << std::endl; // 预期:失败
54
}
55
56
DerivedA* ptrA_from_null = dynamic_cast<DerivedA*>(ptr4);
57
if (ptrA_from_null) {
58
std::cout << "ptr4 successfully cast to DerivedA*" << std::endl;
59
} else {
60
std::cout << "ptr4 could not be cast to DerivedA* (nullptr)" << std::endl; // 预期:失败,返回 nullptr
61
}
62
63
std::cout << "\n--- Using dynamic_cast with references ---" << std::endl;
64
DerivedA objA;
65
Base& ref1 = objA;
66
67
try {
68
DerivedA& refA = dynamic_cast<DerivedA&>(ref1);
69
std::cout << "ref1 successfully cast to DerivedA&" << std::endl;
70
refA.func_a();
71
} catch (const std::bad_cast& e) {
72
std::cout << "ref1 could not be cast to DerivedA&: " << e.what() << std::endl;
73
}
74
75
DerivedB objB;
76
Base& ref_b_to_a = objB;
77
try {
78
DerivedA& refA_from_b = dynamic_cast<DerivedA&>(ref_b_to_a);
79
std::cout << "ref_b_to_a successfully cast to DerivedA&" << std::endl;
80
refA_from_b.func_a();
81
} catch (const std::bad_cast& e) {
82
std::cout << "ref_b_to_a could not be cast to DerivedA&: " << e.what() << std::endl; // 预期:抛异常
83
}
84
85
86
std::cout << "\n--- Using typeid ---" << std::endl;```cpp
87
#include <iostream>
88
#include <typeinfo> // for typeid
89
#include <vector> // for dynamic_cast with references
90
91
class Base {
92
public:
93
virtual ~Base() {} // 需要虚函数才能使用 dynamic_cast 和 typeid 获取动态类型
94
virtual void func_base() { std::cout << "Base::func_base" << std::endl; }
95
};
96
97
class DerivedA : public Base {
98
public:
99
void func_a() { std::cout << "DerivedA::func_a" << std::endl; }
100
void func_base() override { std::cout << "DerivedA::func_base" << std::endl; }
101
};
102
103
class DerivedB : public Base {
104
public:
105
void func_b() { std::cout << "DerivedB::func_b" << std::endl; }
106
void func_base() override { std::cout << "DerivedB::func_base" << std::endl; }
107
};
108
109
int main() {
110
Base* ptr1 = new DerivedA();
111
Base* ptr2 = new DerivedB();
112
Base* ptr3 = new Base();
113
Base* ptr4 = nullptr;
114
115
std::cout << "--- Using dynamic_cast ---" << std::endl;
116
117
// 向下转型 (指针)
118
DerivedA* ptrA = dynamic_cast<DerivedA*>(ptr1);
119
if (ptrA) {
120
std::cout << "ptr1 successfully cast to DerivedA*" << std::endl;
121
ptrA->func_a();
122
} else {
123
std::cout << "ptr1 could not be cast to DerivedA*" << std::endl;
124
}
125
126
DerivedB* ptrB = dynamic_cast<DerivedB*>(ptr1);
127
if (ptrB) {
128
std::cout << "ptr1 successfully cast to DerivedB*" << std::endl;
129
ptrB->func_b();
130
} else {
131
std::cout << "ptr1 could not be cast to DerivedB*" << std::endl; // 预期:失败
132
}
133
134
DerivedA* ptrA_from_base = dynamic_cast<DerivedA*>(ptr3);
135
if (ptrA_from_base) {
136
std::cout << "ptr3 successfully cast to DerivedA*" << std::endl;
137
ptrA_from_base->func_a();
138
} else {
139
std::cout << "ptr3 could not be cast to DerivedA*" << std::endl; // 预期:失败
140
}
141
142
DerivedA* ptrA_from_null = dynamic_cast<DerivedA*>(ptr4);
143
if (ptrA_from_null) {
144
std::cout << "ptr4 successfully cast to DerivedA*" << std::endl;
145
} else {
146
std::cout << "ptr4 could not be cast to DerivedA* (nullptr)" << std::endl; // 预期:失败,返回 nullptr
147
}
148
149
std::cout << "\n--- Using dynamic_cast with references ---" << std::endl;
150
DerivedA objA;
151
Base& ref1 = objA;
152
153
try {
154
DerivedA& refA = dynamic_cast<DerivedA&>(ref1);
155
std::cout << "ref1 successfully cast to DerivedA&" << std::endl;
156
refA.func_a();
157
} catch (const std::bad_cast& e) {
158
std::cout << "ref1 could not be cast to DerivedA&: " << e.what() << std::endl;
159
}
160
161
DerivedB objB;
162
Base& ref_b_to_a = objB;
163
try {
164
DerivedA& refA_from_b = dynamic_cast<DerivedA&>(ref_b_to_a);
165
std::cout << "ref_b_to_a successfully cast to DerivedA&" << std::endl;
166
refA_from_b.func_a();
167
} catch (const std::bad_cast& e) {
168
std::cout << "ref_b_to_a could not be cast to DerivedA&: " << e.what() << std::endl; // 预期:抛异常
169
}
170
171
172
std::cout << "\n--- Using typeid ---" << std::endl;
173
std::cout << "Type of *ptr1: " << typeid(*ptr1).name() << std::endl; // 动态类型 DerivedA
174
std::cout << "Type of *ptr2: " << typeid(*ptr2).name() << std::endl; // 动态类型 DerivedB
175
std::cout << "Type of *ptr3: " << typeid(*ptr3).name() << std::endl; // 动态类型 Base
176
std::cout << "Type of ptr1: " << typeid(ptr1).name() << std::endl; // 静态类型 Base*
177
std::cout << "Type of objA: " << typeid(objA).name() << std::endl; // 静态类型 DerivedA
178
std::cout << "Type of ref1: " << typeid(ref1).name() << std::endl; // 动态类型 DerivedA
179
180
// 注意:typeid(*ptr4) 当 ptr4 是 nullptr 时,行为是未定义的!
181
//std::cout << "Type of *ptr4: " << typeid(*ptr4).name() << std::endl; // 避免这样做
182
183
delete ptr1;
184
delete ptr2;
185
delete ptr3;
186
// ptr4 是 nullptr,无需 delete
187
188
return 0;
189
}
1
// 预期输出 (typeid().name() 的具体字符串取决于编译器实现):
2
// --- Using dynamic_cast ---
3
// ptr1 successfully cast to DerivedA*
4
// DerivedA::func_a
5
// ptr1 could not be cast to DerivedB*
6
// ptr3 could not be cast to DerivedA*
7
// ptr4 could not be cast to DerivedA* (nullptr)
8
9
// --- Using dynamic_cast with references ---
10
// ref1 successfully cast to DerivedA&
11
// DerivedA::func_a
12
// ref_b_to_a could not be cast to DerivedA&: std::bad_cast
13
14
// --- Using typeid ---
15
// Type of *ptr1:
16
// Type of *ptr2:
17
// Type of *ptr3:
18
// Type of ptr1:
19
// Type of objA:
20
// Type of ref1:
7.5 协变返回类型 (Covariant Return Types)
协变返回类型(Covariant Return Types)是 C++ 中允许虚函数重写时,派生类函数返回类型可以更具体的一种规则。
① 规则:
▮▮▮▮如果基类虚函数返回一个指向类 B
的指针或引用(即 B*
或 B&
),并且派生类重写了该虚函数。
▮▮▮▮那么派生类中对应的重写函数可以返回指向派生类 D
的指针或引用(即 D*
或 D&
),其中 D
是从 B
公有派生而来。
▮▮▮▮简而言之,如果 Derived::f
重写了 Base::f
,并且 Base::f
返回 B*
,则 Derived::f
可以返回 D*
,只要 D
公有继承自 B
。对于引用类型也是类似的。
▮▮▮▮这个规则仅适用于指针或引用类型的返回类型,不适用于值类型。
② 作用:
▮▮▮▮协变返回类型提高了虚函数重写的灵活性,使得在返回对象时能够返回更精确的类型,避免了在调用方进行额外的向下转型。
③ 示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual ~Base() {}
6
virtual Base* clone() const { // 基类虚函数返回 Base*
7
std::cout << "Base::clone" << std::endl;
8
return new Base(*this);
9
}
10
};
11
12
class Derived : public Base {
13
public:
14
virtual ~Derived() {}
15
// 派生类重写 clone,返回类型是 Derived* (协变返回类型,因为 Derived 公有继承自 Base)
16
Derived* clone() const override {
17
std::cout << "Derived::clone" << std::endl;
18
return new Derived(*this);
19
}
20
21
void specific_derived_func() {
22
std::cout << "This is a specific function in Derived." << std::endl;
23
}
24
};
25
26
int main() {
27
Base* base_ptr = new Derived();
28
29
// 调用虚函数 clone,因为返回类型是协变的,这里返回的是 Derived*
30
// 但通过 Base* 接收,仍然是 Base* 类型
31
Base* base_cloned_ptr = base_ptr->clone();
32
std::cout << "Cloned object type via Base*: " << typeid(*base_cloned_ptr).name() << std::endl; // 动态类型是 Derived
33
34
// 如果想使用 Derived 的特定功能,需要向下转型
35
Derived* derived_cloned_ptr = dynamic_cast<Derived*>(base_cloned_ptr);
36
if (derived_cloned_ptr) {
37
derived_cloned_ptr->specific_derived_func();
38
}
39
40
41
std::cout << "\n--- Direct Derived object ---" << std::endl;
42
Derived original_derived;
43
Derived* derived_cloned_ptr_direct = original_derived.clone(); // 直接通过 Derived 对象调用 clone,返回 Derived*
44
std::cout << "Cloned object type via Derived*: " << typeid(*derived_cloned_ptr_direct).name() << std::endl; // 动态类型是 Derived
45
derived_cloned_ptr_direct->specific_derived_func();
46
47
48
delete base_ptr;
49
delete base_cloned_ptr;
50
delete derived_cloned_ptr_direct;
51
52
return 0;
53
}
1
// 预期输出 (typeid().name() 的具体字符串取决于编译器实现):
2
// Derived::clone
3
// Cloned object type via Base*:
4
// This is a specific function in Derived.
5
6
// --- Direct Derived object ---
7
// Derived::clone
8
// Cloned object type via Derived*:
9
// This is a specific function in Derived.
10
11
// Derived destructor called.
12
// Base destructor called.
13
// Derived destructor called.
14
// Base destructor called.
15
// Derived destructor called.
16
// Base destructor called.
在这个例子中,Derived::clone
返回 Derived*
而不是 Base*
,这就是协变返回类型。当我们通过 Base*
调用 clone()
并用 Base*
接收时,实际返回的是 Derived
对象的地址,只是指针类型是 Base*
。当我们直接通过 Derived
对象调用 clone()
并用 Derived*
接收时,我们可以直接获得 Derived*
类型,无需向下转型即可访问 Derived
特有的成员。
通过本章的学习,您现在应该对 C++ 中多态的实现机制有了全面而深入的理解,包括虚函数、纯虚函数、抽象类、虚析构函数、RTTI 和协变返回类型。这些是构建灵活、可扩展和健壮的面向对象软件的关键工具。在下一章,我们将转向 C++ 模板,探索泛型编程的力量。
8. 类模板 (Class Templates) 📚
本章概要
欢迎来到 C++ 类模板的世界!在前面的章节中,我们学习了如何定义类来创建用户自定义类型,以及如何利用面向对象特性(如封装、继承、多态)来构建灵活的代码。然而,有时我们需要处理多种不同数据类型的相同结构或算法,例如一个可以存储任何类型元素的列表或者一个可以比较任何类型对象的函数。传统 C++ 在面对这种情况时,可能需要为每种类型重复编写几乎完全相同的代码,这会导致大量的代码冗余和维护困难。
类模板 (Class Templates) 正是 C++ 用来解决这一问题的强大工具。它们允许我们定义一种泛型类,其行为或数据结构可以独立于所使用的具体数据类型。通过使用模板参数,我们可以编写一次类定义,然后编译器可以根据我们提供的具体类型(在编译时)自动生成适用于该类型的类。这极大地提高了代码的复用性 (reusability),并使得我们可以构建出类型安全 (type-safe) 的通用库,如 C++ 标准库 (Standard Library) 中的容器(如 std::vector
、std::list
、std::map
)和算法。
本章将系统地介绍 C++ 类模板的核心概念和高级用法,帮助你掌握如何创建和使用泛型类,以及如何处理特定类型下的特殊需求。无论你是初学者希望了解泛型的力量,还是希望深入理解模板特化和模板元编程 (template metaprogramming) 的专家,本章都将为你提供扎实的基础和深入的洞察。
8.1 类模板的定义与实例化 (Defining and Instantiating Class Templates)
本节概要
本节将介绍 C++ 类模板的基本语法:如何声明和定义一个类模板,以及如何根据这个模板创建具体的类类型(实例化)和对象。
什么是类模板? (What is a Class Template?)
类模板 (Class template) 是一种参数化的类定义。它不是一个实际的类,而是一个生成类的蓝图或配方。模板参数 (template parameters) 可以代表类型、非类型值(如整数常量)或甚至是另一个模板。通过提供不同的模板参数,我们可以从同一个类模板生成不同的具体类类型。
📘 注意: 类模板的机制是在编译时 (compile time) 完成的。编译器会根据你使用的具体类型,从模板定义中生成对应的具体类代码,这个过程称为模板的实例化 (instantiation)。
类的声明与定义 (Class Template Declaration and Definition)
类模板的声明以关键字 template
开头,后跟一对尖括号 <>
,其中包含模板参数列表。然后是普通的类定义。
1
template <typename T> // 声明一个类型参数 T
2
class Box {
3
public:
4
Box(T value) : data(value) {} // 构造函数也使用类型参数
5
6
T getData() const { // 成员函数也使用类型参数
7
return data;
8
}
9
10
void setData(T value) {
11
data = value;
12
}
13
14
private:
15
T data; // 成员变量使用类型参数
16
};
17
18
// 可以有多个模板参数,例如:
19
template <typename T1, typename T2>
20
class Pair {
21
public:
22
Pair(T1 first, T2 second) : first_(first), second_(second) {}
23
24
T1 getFirst() const { return first_; }
25
T2 getSecond() const { return second_; }
26
27
private:
28
T1 first_;
29
T2 second_;
30
};
⚝ template <typename T>
或 template <class T>
:这里的 typename
和 class
在模板参数列表中是等价的,都表示后面的 T
是一个类型参数。你可以使用任何合法的标识符作为参数名,通常使用单个大写字母如 T
, U
, V
或 T1
, T2
。
⚝ 模板参数可以是多种类型:
▮▮▮▮⚝ 类型参数 (Type parameters):用 typename
或 class
声明,代表一个类型(如 T
)。
▮▮▮▮⚝ 非类型参数 (Non-type parameters):用内置类型名或枚举类型名声明,代表一个常量值(如 int Size
)。例如 template <typename T, int Size> class Array { ... };
。
▮▮▮▮⚝ 模板参数 (Template parameters):代表另一个模板(较少见,更高级)。
对象的创建与使用 (Object Creation and Usage - Instantiation)
要使用一个类模板,你需要为模板参数提供具体的类型或值。这个过程叫做模板的实例化 (instantiation)。实例化会生成一个具体的类类型,然后你就可以像使用普通类一样创建这个类型的对象了。
1
// 实例化 Box 类模板,生成 Box<int> 类型
2
Box<int> intBox(10);
3
std::cout << "Integer box data: " << intBox.getData() << std::endl; // 输出 10
4
5
// 实例化 Box 类模板,生成 Box<std::string> 类型
6
Box<std::string> stringBox("Hello Templates");
7
std::cout << "String box data: " << stringBox.getData() << std::endl; // 输出 Hello Templates
8
9
// 实例化 Pair 类模板,生成 Pair<int, double> 类型
10
Pair<int, double> p1(5, 3.14);
11
std::cout << "Pair data: " << p1.getFirst() << ", " << p1.getSecond() << std::endl; // 输出 5, 3.14
12
13
// 实例化 Pair 类模板,生成 Pair<std::string, std::vector<int>> 类型
14
Pair<std::string, std::vector<int>> p2("List", {1, 2, 3});
15
// ... 使用 p2 对象
⚝ Box<int>
:这就是 Box
模板的一个实例化。它是一个具体的类型,就像 int
或 std::string
一样。
⚝ 编译器会根据 Box<int>
这个用法,查找 Box
模板定义,并将其中所有的 T
替换为 int
,从而生成 Box<int>
这个具体的类定义。
⚝ C++17 引入了类模板参数推导 (Class Template Argument Deduction, CTAD) 🌱,在某些情况下,你创建对象时可以省略模板参数列表。
1
// C++17 之前,必须写 Box<double>
2
Box<double> doubleBox(9.99);
3
4
// C++17 开始,可以省略模板参数列表,编译器可以从构造函数参数推导出类型
5
Box anotherDoubleBox(8.88); // 编译器推导出类型是 double,实例化 Box<double>
6
Box yetAnotherBox("deduced string"); // 编译器推导出类型是 const char* 或 std::string (取决于构造函数签名及推导规则)
7
8
Pair yetAnotherPair(1, 2.5); // 编译器推导出类型是 Pair<int, double>
类模板参数推导 (CTAD) 让模板类的使用更加方便,看起来更像普通类。但这只是语法上的简化,本质上仍然是模板实例化。
8.2 类模板的成员函数 (Member Functions of Class Templates)
本节概要
本节将详细讲解如何定义和使用类模板的成员函数,包括在类内和类外定义的方式。
在类模板定义内定义成员函数
在类模板定义内定义的成员函数与普通类的成员函数类似,可以直接使用模板参数。这些函数被视为模板成员函数 (template member functions),但它们不是独立的函数模板,而是属于类模板的成员。它们的实例化通常与类模板的实例化同时发生,或者是在调用时按需实例化。
1
template <typename T>
2
class MyContainer {
3
public:
4
// 构造函数:在类内定义,直接使用 T
5
MyContainer(T val) : value_(val) {}
6
7
// 成员函数:在类内定义,直接使用 T
8
T getValue() const {
9
return value_;
10
}
11
12
// 设置值
13
void setValue(T val) {
14
value_ = val;
15
}
16
17
private:
18
T value_;
19
};
这种方式简洁明了,特别是对于短小的函数。
在类模板定义外定义成员函数
对于较长的成员函数,通常推荐在类模板定义之外进行定义,以保持类定义的整洁。在类模板定义外定义成员函数时,需要重复模板声明,并在函数名前加上完整的类模板名。
1
template <typename T>
2
class MyContainer {
3
public:
4
MyContainer(T val) : value_(val) {}
5
6
T getValue() const; // 声明成员函数
7
8
void setValue(T val); // 声明成员函数
9
10
private:
11
T value_;
12
};
13
14
// 在类定义外部定义 getValue 成员函数
15
template <typename T> // 重复模板声明
16
T MyContainer<T>::getValue() const { // 函数名前加上完整的类模板名 MyContainer<T>
17
return value_;
18
}
19
20
// 在类定义外部定义 setValue 成员函数
21
template <typename T> // 重复模板声明
22
void MyContainer<T>::setValue(T val) { // 函数名前加上完整的类模板名 MyContainer<T>
23
value_ = val;
24
}
🚀 要点:
⚝ 外部定义成员函数时,template <...>
头是必需的。
⚝ 成员函数名前必须加上 ClassName<TemplateParameters>::
前缀。
⚝ 在函数体内部,你可以直接使用模板参数(如 T
)。
成员函数的实例化时机 (Instantiation Timing of Member Functions)
对于类模板,类本身的定义 (MyContainer<int>
) 会在需要时被实例化。而它的成员函数通常是按需实例化的 (instantiated on demand)。这意味着,如果一个特定的成员函数在你的程序中从未被调用过,那么编译器可能不会为其生成代码,即使这个类模板已经被实例化了。
这有助于减小最终可执行文件的大小,但也意味着如果在未被调用的成员函数中存在语法错误或使用了不适用于特定模板参数的操作,这些错误可能直到该函数被调用时才会被发现。
1
template <typename T>
2
class ComplexContainer {
3
public:
4
void print() const {
5
// 假设 T 是一个可打印的类型
6
std::cout << "Value: " << value_ << std::endl;
7
}
8
9
void onlyForPointers() {
10
// 假设 T 是一个指针类型
11
std::cout << "Dereferenced value: " << *value_ << std::endl;
12
}
13
14
private:
15
T value_;
16
};
17
18
int main() {
19
ComplexContainer<int> intContainer(100);
20
intContainer.print(); // print() 会被实例化
21
22
// intContainer.onlyForPointers(); // 如果调用这行,而 T 是 int,这里会产生编译错误,因为 *100 是无效的
23
24
ComplexContainer<int*> ptrContainer(new int(200));
25
ptrContainer.print(); // print() 会被实例化 (如果 int* 可打印,例如流操作符有为其定义)
26
ptrContainer.onlyForPointers(); // onlyForPointers() 会被实例化
27
28
delete ptrContainer.value_; // 清理内存
29
return 0;
30
}
在上面的例子中,ComplexContainer<int>::onlyForPointers()
成员函数不会被实例化,因为它从未被调用。即使该函数内部的代码 (*value_
) 对于 int
类型是无效的,只要不调用它,就不会产生编译错误。但是,ComplexContainer<int*>::onlyForPointers()
会被实例化并正确执行。
8.3 类模板的特化 (Class Template Specialization)
本节概要
虽然类模板提供了极大的通用性,但在某些情况下,默认的模板实现对于特定类型可能不够高效、不适用甚至无法编译。模板特化 (Template Specialization) 允许我们为类模板的特定模板参数组合提供一个完全不同或部分不同的实现。
特化是编译时多态 (compile-time polymorphism) 的一种形式,编译器会根据实例化时提供的具体类型选择最匹配的模板定义或特化版本。
8.3.1 完全特化 (Full Specialization)
本小节概要
完全特化为类模板的所有模板参数指定了具体的类型或值,从而为这一特定的参数组合提供一个完全定制的类定义。
完全特化的语法
完全特化的语法以 template <>
开头,后面是被特化的类模板名,以及在尖括号中指定了具体类型的模板参数列表。接着是该特化版本的完整类定义。
1
// 原始的通用类模板定义
2
template <typename T1, typename T2>
3
class Pair {
4
public:
5
Pair(T1 first, T2 second) : first_(first), second_(second) {
6
std::cout << "通用 Pair<T1, T2> 构造函数" << std::endl;
7
}
8
9
// ... 其他成员
10
11
private:
12
T1 first_;
13
T2 second_;
14
};
15
16
// 对 Pair<int, double> 的完全特化
17
template <> // template <> 表示完全特化
18
class Pair<int, double> { // 指定所有模板参数为具体类型
19
public:
20
// 提供针对 int 和 double 的定制实现
21
Pair(int first, double second) : integer_data(first), double_data(second) {
22
std::cout << "Pair<int, double> 完全特化构造函数" << std::endl;
23
}
24
25
void print() const {
26
std::cout << "特化版本: int=" << integer_data << ", double=" << double_data << std::endl;
27
}
28
29
private:
30
int integer_data;
31
double double_data;
32
};
使用完全特化
当你实例化一个完全匹配特化参数的类模板时,编译器会优先选择特化版本。
1
// 使用通用模板
2
Pair<float, float> floatPair(1.1f, 2.2f); // 调用通用 Pair<T1, T2> 构造函数
3
// floatPair.print(); // 错误:通用版本没有 print() 函数
4
5
// 使用完全特化版本
6
Pair<int, double> specificPair(10, 3.14); // 调用 Pair<int, double> 完全特化构造函数
7
specificPair.print(); // 调用特化版本的 print() 函数,输出: 特化版本: int=10, double=3.14
🥇 完全特化的用途:
① 为特定类型提供更高效的实现(例如,为 bool
特化 std::vector
)。
② 为特定类型提供原始模板无法处理或不应该处理的行为。
③ 在某些情况下,简化特定类型的代码或引入特定类型的特性。
完全特化就像是为模板的某个特定“组合”编写了一个全新的、独立的类。这个特化版本与原始模板在语法上看起来相似,但它们是两个独立的实体。
8.3.2 部分特化 (Partial Specialization)
本小节概要
部分特化为类模板的部分模板参数指定了具体的类型,或者对部分模板参数的形式进行了约束。它仍然是一个模板,但其模板参数列表比原始模板少或者形式更具体。
部分特化不能应用于函数模板,只能应用于类模板。
部分特化的语法
部分特化的语法也以 template <...>
开头,但尖括号中包含的是剩余的模板参数。接着是被特化的类模板名,其后的尖括号中是被特化或约束过的参数列表。然后是该部分特化版本的类定义。
1
// 原始的通用类模板定义 (同上例)
2
template <typename T1, typename T2>
3
class Pair {
4
public:
5
Pair(T1 first, T2 second) : first_(first), second_(second) {
6
std::cout << "通用 Pair<T1, T2> 构造函数" << std::endl;
7
}
8
// ... 其他成员
9
private:
10
T1 first_;
11
T2 second_;
12
};
13
14
// 对 Pair<T, T> 的部分特化 (两个类型参数相同)
15
template <typename T> // 剩余一个类型参数 T
16
class Pair<T, T> { // 特化形式:两个参数都是 T
17
public:
18
Pair(T value1, T value2) : data1(value1), data2(value2) {
19
std::cout << "Pair<T, T> 部分特化构造函数" << std::endl;
20
}
21
22
T sum() const { // 为相同类型提供特有功能
23
return data1 + data2;
24
}
25
26
private:
27
T data1;
28
T data2;
29
};
30
31
// 对 Pair<T*, U> 的部分特化 (第一个参数是指针类型)
32
template <typename T, typename U> // 剩余两个类型参数 T 和 U
33
class Pair<T*, U> { // 特化形式:第一个参数是指针 T*
34
public:
35
Pair(T* p, U val) : ptr(p), data(val) {
36
std::cout << "Pair<T*, U> 部分特化构造函数" << std::endl;
37
}
38
39
T& dereference_first() const { // 为指针类型提供特有功能
40
return *ptr;
41
}
42
43
private:
44
T* ptr;
45
U data;
46
};
使用部分特化
当实例化类模板时,编译器会尝试匹配:
① 首先尝试完全特化版本。
② 如果没有完全特化匹配,则尝试所有部分特化版本,并选择最具体 (most specialized) 的一个。
③ 如果有多个同样具体的特化版本匹配(会产生歧义),则编译失败。
④ 如果没有完全特化或部分特化匹配,则使用原始的通用模板。
1
Pair<int, double> p1(10, 3.14); // 匹配 Pair<int, double> 完全特化
2
p1.print(); // 调用完全特化的 print()
3
4
Pair<int, int> p2(5, 7); // 匹配 Pair<T, T> 部分特化 (T=int)
5
std::cout << "Sum: " << p2.sum() << std::endl; // 调用部分特化的 sum()
6
7
Pair<float, float> p3(1.1f, 2.2f); // 匹配 Pair<T, T> 部分特化 (T=float)
8
std::cout << "Sum: " << p3.sum() << std::endl; // 调用部分特化的 sum()
9
10
Pair<long*, std::string> p4(new long(123), "pointer data"); // 匹配 Pair<T*, U> 部分特化 (T=long, U=std::string)
11
std::cout << "Dereferenced: " << p4.dereference_first() << std::endl; // 调用部分特化的 dereference_first()
12
delete p4.dereference_first(); // 清理内存
13
14
Pair<char, bool> p5('a', true); // 没有匹配的完全或部分特化,使用通用模板
15
// p5.sum(); // 错误:通用版本没有 sum() 函数
🎯 如何判断哪个特化更具体? 这是一个复杂的规则,通常可以直观理解为:如果一个特化版本的参数列表可以作为另一个特化版本参数列表的一个子集(经过适当的映射),并且前者有更多的具体类型或更强的约束,那么前者更具体。例如,Pair<int, T>
比 Pair<T1, T2>
更具体;Pair<T, T>
也比 Pair<T1, T2>
更具体;Pair<int*, int>
比 Pair<T*, U>
更具体。编译器会有一套精确的匹配和偏序规则来确定最佳匹配。
部分特化使得我们可以为模板提供一系列越来越具体的实现,以满足不同类型组合的需求,同时避免为每种可能的类型组合都写一个完全特化版本。
8.4 类模板与友元 (Class Templates and Friends)
本节概要
在 C++ 中,友元 (friend) 机制允许一个函数或一个类访问另一个类的私有 (private) 或保护 (protected) 成员。当涉及到类模板时,友元声明变得更加灵活和复杂,我们可以声明非模板函数、函数模板的特定实例化、完整的函数模板、非模板类、类模板的特定实例化或完整的类模板作为友元。
友元声明的分类
在类模板 MyTemplate<T>
中声明友元时,友元可以是:
① 非模板函数或类: 这是一个普通函数或类,被声明为特定实例化模板类(如 MyTemplate<int>
)的友元。
② 函数模板的特定实例化: 一个函数模板 MyFunc<U>
的特定实例化(如 MyFunc<double>
)被声明为模板类 MyTemplate<T>
的友元。
③ 完整的函数模板: 整个函数模板 MyFunc<U>
被声明为模板类 MyTemplate<T>
的友元。这意味着该函数模板的 所有 实例化都是 MyTemplate<T>
的友元。
④ 非模板类: 一个普通类 MyClass
被声明为模板类 MyTemplate<T>
的友元。这意味着 MyClass
的 所有 对象都是 MyTemplate<T>
的 所有 实例化(如 MyTemplate<int>
, MyTemplate<double>
等)的友元。
⑤ 类模板的特定实例化: 一个类模板 MyOtherTemplate<U>
的特定实例化(如 MyOtherTemplate<std::string>
) 被声明为模板类 MyTemplate<T>
的友元。
⑥ 完整的类模板: 整个类模板 MyOtherTemplate<U>
被声明为模板类 MyTemplate<T>
的友元。这意味着 MyOtherTemplate<U>
的 所有 实例化都是 MyTemplate<T>
的 所有 实例化(如 MyTemplate<int>
, MyTemplate<double>
等)的友元。
这里主要关注函数友元和类友元在模板中的常见用法。
函数友元与类模板
最常见的场景是为类模板重载流插入/提取运算符(<<
, >>
)。这些通常作为非成员函数来实现,需要访问模板类的私有数据。
1
#include <iostream>
2
3
template <typename T>
4
class Data {
5
private:
6
T value;
7
public:
8
Data(T val) : value(val) {}
9
10
// 将 operator<< 声明为友元
11
// 这里使用了 friend declaration for template function (友元模板函数声明)
12
// <U> 是函数模板自己的参数,而 <U> operator<< 的 <U> 与 Data<T> 的 <T> 不同
13
// 通常我们希望 operator<< 的参数类型与 Data 的模板参数一致
14
// 可以这样声明:
15
template <typename U>
16
friend std::ostream& operator<<(std::ostream& os, const Data<U>& data);
17
18
// 或者,如果我们想让 operator<< 成为 Data<T> 的“配对”友元,即
19
// Data<int> 的友元是 operator<<(ostream&, const Data<int>&)
20
// Data<double> 的友元是 operator<<(ostream&, const Data<double>&)
21
// 可以使用这种语法:
22
// friend std::ostream& operator<< <T>(std::ostream& os, const Data<T>& data);
23
// 注意这里的 <T>,它指定了友元是 operator<< 函数模板的一个特定实例化,其模板参数与 Data 的模板参数 T 相同。
24
// 这种方式通常更常用,因为它确保了友元函数与类实例化类型匹配。
25
26
// 为了简洁和清晰,我们采用第一种,完整的函数模板作为所有 Data<T> 实例化的友元 (尽管更精确的匹配可能需要第二种)
27
// 让我们采用第二种更精确的匹配方式,需要提前声明函数模板
28
template <typename U>
29
friend std::ostream& operator<<(std::ostream& os, const Data<U>& data); // 这是前向声明友元函数模板
30
31
// 在类定义外部定义这个友元函数模板
32
// 需要完整重复模板声明
33
template <typename U>
34
std::ostream& operator<<(std::ostream& os, const Data<U>& data) {
35
os << data.value; // 访问私有成员 value
36
return os;
37
}
38
39
};
40
41
// 尽管函数模板在类内部被声明为友元,但其定义通常在类定义之后提供。
42
// 这里是上面 Data 类内部友元声明对应的函数模板定义
43
// C++ 标准规定,在类模板内部声明的友元函数模板可以直接在类模板定义之后定义,无需再次使用 template<...>。
44
// 但是为了清晰和兼容性,重复 template<...> 通常更安全。这里我们按照更标准的写法,在外部定义函数模板。
45
/*
46
// Data 类定义中的友元声明:
47
template <typename U>
48
friend std::ostream& operator<<(std::ostream& os, const Data<U>& data);
49
*/
50
51
// 友元函数模板的定义 (必须在类模板定义之后)
52
// template <typename U>
53
// std::ostream& operator<<(std::ostream& os, const Data<U>& data) {
54
// os << data.value; // 访问私有成员 value
55
// return os;
56
// }
57
// 👆 上面的定义放在 Data 类定义**之后**即可工作。
58
59
// 让我们用一个完整的例子来演示
60
#include <iostream>
61
#include <string>
62
63
// 前向声明类模板和友元函数模板
64
template <typename T> class Container;
65
66
template <typename T>
67
std::ostream& operator<<(std::ostream& os, const Container<T>& cont);
68
69
70
template <typename T>
71
class Container {
72
private:
73
T item;
74
public:
75
Container(T val) : item(val) {}
76
77
// 声明友元函数模板的特定实例化
78
// 语法: friend 函数返回类型 operator<< <模板参数列表> (函数参数列表);
79
friend std::ostream& operator<< <T>(std::ostream& os, const Container<T>& cont);
80
81
// 如果你希望 operator<< 的所有实例化都是 Container<T> 所有实例化的友元,可以这样声明:
82
// template <typename U>
83
// friend std::ostream& operator<<(std::ostream& os, const Container<U>& cont);
84
// 注意,上面这种声明意味着 operator<<(..., const Container<int>&) 是 Container<double> 的友元。
85
// 通常这不是我们想要的。我们想要的是 Container<int> 的友元是 operator<<(..., const Container<int>&)。
86
// 所以 friend ... <T>(...) 语法更常用。
87
};
88
89
// 友元函数模板定义
90
template <typename T> // 定义时需要重复模板声明
91
std::ostream& operator<<(std::ostream& os, const Container<T>& cont) {
92
os << "Container item: " << cont.item; // 访问私有成员 item
93
return os;
94
}
95
96
int main() {
97
Container<int> c1(123);
98
Container<std::string> c2("hello");
99
100
std::cout << c1 << std::endl; // 输出: Container item: 123
101
std::cout << c2 << std::endl; // 输出: Container item: hello
102
103
return 0;
104
}
在上面的例子中,friend std::ostream& operator<< <T>(std::ostream& os, const Container<T>& cont);
声明了一个特定实例化版本的 operator<<
函数模板作为 Container<T>
的友元。当 Container<int>
被实例化时,它的友元就是 operator<< <int>(...)
。当 Container<std::string>
被实例化时,它的友元就是 operator<< <std::string>(...)
。
类友元与类模板
类似地,你可以将一个普通类或类模板声明为另一个类模板的友元。
1
// 前向声明类模板
2
template <typename T> class DataHolder;
3
4
// 前向声明友元类(可以是普通类或类模板)
5
class NonTemplateHelper;
6
template <typename U> class TemplateHelper;
7
8
template <typename T>
9
class DataHolder {
10
private:
11
T secret_data;
12
public:
13
DataHolder(T val) : secret_data(val) {}
14
15
// 声明一个非模板类为友元
16
friend class NonTemplateHelper;
17
18
// 声明一个类模板的特定实例化为友元 (例如 TemplateHelper<int>)
19
friend class TemplateHelper<int>;
20
21
// 声明完整的类模板为友元 (TemplateHelper<U> 的所有实例化都是 DataHolder<T> 所有实例化的友元)
22
template <typename U>
23
friend class TemplateHelper;
24
25
// 注意:上面两种 Friend class TemplateHelper... 的声明不能同时存在,会有重定义的问题。
26
// 通常我们选择其中一种。完整的类模板友元声明更强大,但也更宽松。
27
// 如果只想让 TemplateHelper<T> 是 DataHolder<T> 的友元(类型匹配),语法会复杂一些,
28
// 通常需要友元类模板前向声明自己,并使用更精细的模板参数。
29
30
};
31
32
// 非模板友元类定义
33
class NonTemplateHelper {
34
public:
35
template <typename T> // 这个Helper可以操作任何 DataHolder<T>
36
void accessData(DataHolder<T>& dh) {
37
std::cout << "从 NonTemplateHelper 访问 DataHolder<T> 私有数据: " << dh.secret_data << std::endl; // 访问私有成员
38
}
39
};
40
41
// 类模板友元定义
42
template <typename U>
43
class TemplateHelper {
44
public:
45
void accessData(DataHolder<U>& dh) { // 这个 Helper 的模板参数 U 需要与 DataHolder 的参数匹配
46
std::cout << "从 TemplateHelper<" << typeid(U).name() << "> 访问 DataHolder<" << typeid(U).name() << "> 私有数据: " << dh.secret_data << std::endl; // 访问私有成员
47
}
48
49
// 演示访问 DataHolder<int> (因为 TemplateHelper<int> 是 DataHolder<T> 的友元)
50
void accessIntDataHolder(DataHolder<int>& dh_int) {
51
std::cout << "从 TemplateHelper<" << typeid(U).name() << "> 访问 DataHolder<int> 私有数据: " << dh_int.secret_data << std::endl;
52
}
53
};
54
55
int main() {
56
DataHolder<int> d_int(100);
57
DataHolder<double> d_double(3.14);
58
59
NonTemplateHelper h1;
60
h1.accessData(d_int);
61
h1.accessData(d_double);
62
63
TemplateHelper<int> h2_int;
64
h2_int.accessData(d_int); // OK, TemplateHelper<int> 是 DataHolder<int> 的友元
65
h2_int.accessIntDataHolder(d_int); // OK, TemplateHelper 是 DataHolder 的友元
66
67
TemplateHelper<double> h2_double;
68
h2_double.accessData(d_double); // OK, TemplateHelper 是 DataHolder 的友元
69
// h2_double.accessData(d_int); // 错误:TemplateHelper<double> 是 DataHolder<double> 的友元,不是 DataHolder<int> 的友元
70
// h2_double.accessIntDataHolder(d_int); // OK, TemplateHelper 是 DataHolder 的友元
71
72
return 0;
73
}
通过友元声明,我们可以赋予外部函数或类访问类模板私有成员的能力,这在实现某些特定的功能(如运算符重载、构建辅助类)时非常有用。然而,友元破坏了封装性,应谨慎使用。
8.5 类模板与继承 (Class Templates and Inheritance)
本节概要
类模板可以参与继承关系,既可以作为基类,也可以作为派生类。模板与继承的结合提供了更多的代码复用和组织方式,但也引入了一些特有的复杂性,尤其是在涉及模板参数时。
继承的几种形式
① 非模板类继承类模板的特定实例化 (Non-template class inherits a specific instantiation of a class template):
派生类是一个普通类,基类是类模板的一个具体实例化。
1
template <typename T>
2
class BaseTemplate {
3
protected:
4
T base_data;
5
public:
6
BaseTemplate(T val) : base_data(val) {
7
std::cout << "BaseTemplate(" << val << ") constructor" << std::endl;
8
}
9
void displayBase() const {
10
std::cout << "Base data: " << base_data << std::endl;
11
}
12
};
13
14
// Derived 是一个普通类,继承 BaseTemplate<int>
15
class DerivedInt : public BaseTemplate<int> {
16
public:
17
// 派生类构造函数需要调用基类构造函数,并提供基类模板参数对应的类型值
18
DerivedInt(int base_val, int derived_val) : BaseTemplate<int>(base_val), derived_data(derived_val) {
19
std::cout << "DerivedInt(" << base_val << ", " << derived_val << ") constructor" << std::endl;
20
}
21
22
void displayDerived() const {
23
displayBase(); // 调用基类的 public/protected 成员
24
std::cout << "Derived data: " << derived_data << std::endl;
25
// std::cout << "Base data (direct access): " << base_data << std::endl; // 访问 protected 成员
26
}
27
private:
28
int derived_data;
29
};
30
31
// 使用 DerivedInt
32
DerivedInt obj(10, 20);
33
obj.displayDerived();
34
// Output:
35
// BaseTemplate(10) constructor
36
// DerivedInt(10, 20) constructor
37
// Base data: 10
38
// Derived data: 20
这种情况下,BaseTemplate<int>
是一个完全具体的类,DerivedInt
继承它就像继承任何普通类一样。
② 类模板继承非模板类 (Class template inherits a non-template class):
派生类是一个类模板,基类是一个普通类。派生类的模板参数可以被用于派生类自己的成员,也可以用于基类成员的某些操作(如果基类提供了这样的接口)。
1
class Base {
2
protected:
3
int base_value;
4
public:
5
Base(int val) : base_value(val) {
6
std::cout << "Base(" << val << ") constructor" << std::endl;
7
}
8
void displayBase() const {
9
std::cout << "Base value: " << base_value << std::endl;
10
}
11
};
12
13
// DerivedTemplate 是一个类模板,继承 Base
14
template <typename T>
15
class DerivedTemplate : public Base {
16
public:
17
// 派生类模板构造函数需要调用基类构造函数
18
DerivedTemplate(int base_val, T derived_val) : Base(base_val), derived_data(derived_val) {
19
std::cout << "DerivedTemplate(" << base_val << ", " << derived_val << ") constructor" << std::endl;
20
}
21
22
void displayDerived() const {
23
displayBase();
24
std::cout << "Derived data: " << derived_data << std::endl;
25
// std::cout << "Base value (direct access): " << base_value << std::endl; // 访问 protected 成员
26
}
27
private:
28
T derived_data;
29
};
30
31
// 使用 DerivedTemplate
32
DerivedTemplate<double> obj2(100, 5.5);
33
obj2.displayDerived();
34
// Output:
35
// Base(100) constructor
36
// DerivedTemplate(100, 5.5) constructor
37
// Base value: 100
38
// Derived data: 5.5
这种情况下,DerivedTemplate<T>
的每个实例化都会继承 Base
类。
③ 类模板继承类模板的特定实例化 (Class template inherits a specific instantiation of a class template):
派生类是类模板,基类是另一个类模板的一个具体实例化。
1
template <typename T>
2
class BaseAnotherTemplate {
3
protected:
4
T data;
5
public:
6
BaseAnotherTemplate(T val) : data(val) {
7
std::cout << "BaseAnotherTemplate(" << val << ") constructor" << std::endl;
8
}
9
void display() const { std::cout << "Base data: " << data << std::endl; }
10
};
11
12
// DerivedTemplateSpecificBase 是一个类模板,继承 BaseAnotherTemplate<int>
13
template <typename T_Derived>
14
class DerivedTemplateSpecificBase : public BaseAnotherTemplate<int> {
15
public:
16
// 派生类构造函数需要调用基类构造函数
17
DerivedTemplateSpecificBase(int base_val, T_Derived derived_val)
18
: BaseAnotherTemplate<int>(base_val), derived_data(derived_val) {
19
std::cout << "DerivedTemplateSpecificBase(" << base_val << ", " << derived_val << ") constructor" << std::endl;
20
}
21
22
void displayFull() const {
23
display(); // 调用基类成员
24
std::cout << "Derived data: " << derived_data << std::endl;
25
}
26
private:
27
T_Derived derived_data;
28
};
29
30
// 使用 DerivedTemplateSpecificBase
31
DerivedTemplateSpecificBase<std::string> obj3(200, "templated derived");
32
obj3.displayFull();
33
// Output:
34
// BaseAnotherTemplate(200) constructor
35
// DerivedTemplateSpecificBase(200, templated derived) constructor
36
// Base data: 200
37
// Derived data: templated derived
这里 BaseAnotherTemplate<int>
是固定的基类类型,不受 DerivedTemplateSpecificBase
的模板参数影响。
④ 类模板继承类模板 (Class template inherits a class template - using its own parameters):
派生类是类模板,基类也是类模板,且基类的模板参数依赖于派生类的模板参数。这是最常见的模板继承形式,用于构建通用的层次结构。
1
template <typename T>
2
class GenericBase {
3
protected:
4
T generic_base_data;
5
public:
6
GenericBase(T val) : generic_base_data(val) {
7
std::cout << "GenericBase(" << val << ") constructor" << std::endl;
8
}
9
void displayGenericBase() const { std::cout << "Generic base data: " << generic_base_data << std::endl; }
10
};
11
12
// GenericDerived 是一个类模板,继承 GenericBase<T>
13
template <typename T>
14
class GenericDerived : public GenericBase<T> { // 基类的模板参数使用了派生类的模板参数
15
public:
16
// 派生类构造函数需要调用基类构造函数
17
GenericDerived(T base_val, T derived_val)
18
: GenericBase<T>(base_val), generic_derived_data(derived_val) { // 调用 GenericBase<T> 的构造函数
19
std::cout << "GenericDerived(" << base_val << ", " << derived_val << ") constructor" << std::endl;
20
}
21
22
void displayGenericDerived() const {
23
this->displayGenericBase(); // 调用基类成员 (需要 this-> 或 using declaration)
24
std::cout << "Generic derived data: " << generic_derived_data << std::endl;
25
// 访问基类受保护成员的常见问题:
26
// 模板基类的受保护成员在派生类中默认是**不可见**的,除非使用 this-> 限定或 using声明。
27
// 例如:std::cout << "Generic base data (direct access): " << this->generic_base_data << std::endl; // OK
28
// 或者在 public/protected 段添加 using GenericBase<T>::generic_base_data;
29
}
30
private:
31
T generic_derived_data;
32
};
33
34
// 使用 GenericDerived
35
GenericDerived<int> obj4(300, 400);
36
obj4.displayGenericDerived();
37
// Output:
38
// GenericBase(300) constructor
39
// GenericDerived(300, 400) constructor
40
// Generic base data: 300
41
// Generic derived data: 400
在形式④中,派生类模板参数 T
被用来确定基类模板 GenericBase
的实例化类型。这种模式非常强大,允许构建灵活的类型层次。需要注意的是,访问模板基类的成员时,由于编译器在实例化派生类模板时还不知道 T
是什么具体类型,它无法确定基类中是否存在某个成员。因此,为了访问模板基类中的成员(包括受保护成员),通常需要使用 this->
指针或 using
声明来明确告诉编译器去基类查找。
⑤ CRTP (Curiously Recurring Template Pattern):
这是一种特殊的模板继承形式,一个类模板 Base<Derived>
以其派生类作为模板参数。这通常用于在基类中提供通用的功能,而这些功能可以访问或利用派生类自身的成员,从而实现静态多态 (static polymorphism)。
1
// CRTP 基类模板
2
template <typename Derived>
3
class Counter {
4
public:
5
Counter() : count_(0) {}
6
7
void increment() {
8
count_++;
9
}
10
11
void display() const {
12
// 通过 static_cast 将 *this 转换为派生类引用,然后调用派生类特有的方法
13
// 这种方式可以在编译时确定调用哪个派生类的方法
14
static_cast<const Derived*>(this)->displayDerivedCount(count_);
15
}
16
17
private:
18
int count_;
19
};
20
21
// CRTP 派生类
22
class MyCounter : public Counter<MyCounter> { // 派生类 MyCounter 将自己作为模板参数传递给基类 Counter
23
public:
24
void displayDerivedCount(int c) const {
25
std::cout << "MyCounter count: " << c << std::endl;
26
}
27
};
28
29
// 使用 MyCounter
30
MyCounter mc;
31
mc.increment();
32
mc.increment();
33
mc.display(); // 调用基类模板的 display(),但内部会调用派生类 MyCounter 的 displayDerivedCount()
34
// Output: MyCounter count: 2
CRTP 是一种高级模板技巧,常用于实现静态多态、mixin、策略模式等。它避免了虚函数的开销,但要求在编译时知道具体的派生类类型。
模板与继承的结合非常灵活,但也可能增加代码的理解难度。合理地使用这些模式可以构建高度可复用和高效的代码库。
9. 异常处理与类 (Exception Handling and Classes)
本章深入探讨在面向对象设计中如何有效地处理异常并保证资源安全。在 C++ 中,类不仅封装了数据和行为,还经常管理着各种资源(如内存、文件句柄、锁等)。当程序执行过程中发生异常时,如果资源没有得到妥善管理,就会导致资源泄露(resource leak)或程序状态不一致,这在复杂的面向对象系统中尤其危险。本章将讨论 C++ 的异常处理机制如何与类的生命周期交互,特别是如何利用 RAII(资源获取即初始化)原则来编写异常安全(exception-safe)的代码。我们将学习不同级别的异常安全保证,了解在构造函数和析构函数中处理异常的注意事项,并探讨如何使用智能指针等现代 C++ 特性来简化资源管理。📚🛡️
9.1 异常安全保证 (Exception Safety Guarantees)
理解异常安全保证有助于我们设计健壮(robust)的类和函数。一个操作的异常安全性是指当它抛出异常时,程序状态所能达到的程度。通常,我们将异常安全分为几个不同的等级:基本保证、强保证和不抛异常保证。并非所有操作都能提供最高级别的异常安全,但理解这些级别可以帮助我们为特定场景选择合适的设计。🎯
9.1.1 基本保证 (Basic Guarantee)
👍 基本保证(Basic Guarantee)是最低级别的异常安全承诺。如果一个操作提供基本保证,那么当它抛出异常时:
① 所有不变式(invariants)都被维持。
② 没有资源泄露(resource leak)。
③ 程序状态保持有效,但具体处于哪个有效状态可能是不确定的或不可预测的。
这意味着,即使操作失败并抛出异常,程序仍然可以继续运行,而不会出现内存泄露、文件句柄未关闭、锁未释放等问题。但用户可能无法预测对象或系统的最终状态。这是编写异常安全代码应力求达到的最低目标。
9.1.2 强保证 (Strong Guarantee)
💪 强保证(Strong Guarantee)是一种更强的保证级别。如果一个操作提供强保证,那么当它抛出异常时:
① 程序状态恢复到操作开始之前的状态。
② 操作要么完全成功,要么没有任何效果(原子性,或者说事务性)。
这提供了“全部或全无”(all-or-nothing)的语义。如果操作失败,就像从未发生过一样,这使得错误处理和状态恢复变得更容易。实现强保证通常需要额外的努力,例如使用事务性技术或“复制并交换”(copy-and-swap)习惯用法。然而,对于某些操作(如修改多个相互关联的数据结构),实现强保证可能非常复杂或开销巨大。
9.1.3 不抛异常保证 (No-Throw Guarantee)
🔒 不抛异常保证(No-Throw Guarantee),也称为失败不可发生(failure-oblivious)或非抛出(non-throwing)保证,是最高级别的异常安全承诺。如果一个操作提供不抛异常保证,那么它保证:
① 永远不会抛出异常。
② 总是会成功完成其任务。
这类函数或操作是异常安全的基石,因为它们不会传播异常。很多基础操作,比如释放资源(例如,智能指针的析构函数、std::vector
的析构函数),通常应该提供不抛异常保证,以避免在异常处理过程中再抛异常导致程序终止。
总结:
编写异常安全代码时,我们应该根据操作的性质和对用户的影响来选择合适的异常安全级别。对于资源管理类,至少要提供基本保证。对于修改重要状态的操作,如果可能且代价合理,应争取提供强保证。析构函数和资源释放操作理想情况下应提供不抛异常保证。✨
9.2 利用 RAII 实现异常安全资源管理 (Exception-Safe Resource Management with RAII)
RAII(Resource Acquisition Is Initialization),即“资源获取即初始化”,是 C++ 中用于实现异常安全资源管理的核心思想。它将资源的生命周期与对象的生命周期绑定在一起。当创建对象时(通常在构造函数中),资源被获取;当对象被销毁时(通常在析构函数中),资源被自动释放。由于 C++ 保证在发生异常进行栈展开(stack unwinding)时,已经构造完成的栈对象的析构函数会被调用,因此 RAII 机制可以有效地防止资源泄露。🔑
9.2.1 RAII 原则 (RAII Principle)
资源的获取(Acquisition)发生在对象的构造期间(Initialization)。这确保了只要对象成功构造,它所管理的资源就已准备就绪。当对象超出作用域、被显式删除、或在异常发生导致栈展开时,对象的析构函数会被自动调用。资源的释放逻辑被放在析构函数中,从而保证无论正常执行结束还是异常发生,资源都能得到释放。
一个没有 RAII 的潜在问题例子:
1
void process_file(const char* filename) {
2
FILE* file = fopen(filename, "r");
3
if (!file) {
4
// 处理错误,例如打印错误信息或抛出异常
5
// 如果在这里抛出异常,file 是 NULL,没问题
6
throw std::runtime_error("无法打开文件");
7
}
8
9
// ... 使用 file 进行文件操作 ...
10
// 如果文件操作过程中抛出异常,这里的 fclose(file) 将不会被执行!
11
// 这将导致文件句柄泄露!
12
13
fclose(file); // 正常退出时释放资源
14
} // 函数结束
在这个例子中,如果在 ...使用 file 进行文件操作...
期间抛出异常,fclose(file)
语句将永远不会被执行,导致文件句柄泄露。
使用 RAII 解决资源泄露:
我们可以设计一个简单的文件句柄 RAII 类:
1
#include <cstdio> // For FILE, fopen, fclose
2
#include <stdexcept> // For std::runtime_error
3
#include <string> // For std::string
4
5
class FileHandle {
6
public:
7
// 构造函数:获取资源
8
FileHandle(const char* filename, const char* mode) : file_(nullptr) {
9
file_ = fopen(filename, mode);
10
if (!file_) {
11
// 构造函数中抛出异常:资源获取失败
12
throw std::runtime_error("无法打开文件: " + std::string(filename));
13
}
14
// 如果构造函数成功完成,file_ 指向有效的文件句柄
15
}
16
17
// 析构函数:释放资源
18
~FileHandle() {
19
if (file_) {
20
// 在析构函数中释放资源,无论如何都会被调用
21
// 注意:fclose 可能失败,但在析构函数中抛出异常通常不好
22
// 更好的做法是在析构函数中处理错误(如记录日志),而不是抛出
23
if (fclose(file_) != 0) {
24
// 实际应用中,这里可能会记录日志或设置一个全局错误标志
25
// std::cerr << "Warning: Failed to close file." << std::endl;
26
}
27
file_ = nullptr; // Good practice to nullify after closing
28
}
29
}
30
31
// 禁用拷贝构造和拷贝赋值,因为文件句柄通常不应该被复制
32
// 或者实现深拷贝语义(这里禁用更简单安全)
33
FileHandle(const FileHandle&) = delete;
34
FileHandle& operator=(const FileHandle&) = delete;
35
36
// C++11 以后,可以考虑添加移动语义,但对于这种简单包装可能不必要
37
// FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
38
// other.file_ = nullptr;
39
// }
40
// FileHandle& operator=(FileHandle&& other) noexcept {
41
// if (this != &other) {
42
// if (file_) fclose(file_);
43
// file_ = other.file_;
44
// other.file_ = nullptr;
45
// }
46
// return *this;
47
// }
48
49
50
// 提供访问底层资源的接口 (可选)
51
FILE* get() const {
52
return file_;
53
}
54
55
// 提供显式关闭资源的接口 (可选,允许在对象生命周期结束前释放)
56
void close() {
57
if (file_) {
58
if (fclose(file_) != 0) {
59
throw std::runtime_error("无法关闭文件"); // 显式关闭可以抛异常
60
}
61
file_ = nullptr;
62
}
63
}
64
65
private:
66
FILE* file_; // 管理的资源
67
};
68
69
// 使用 RAII 类的例子
70
void process_file_raii(const char* filename) {
71
// FileHandle 对象创建时获取资源
72
FileHandle file(filename, "r");
73
74
// ... 使用 file.get() 进行文件操作 ...
75
// 例如:char buffer[100]; fgets(buffer, 100, file.get());
76
77
// 无论这里是否发生异常,file 对象在超出作用域时,
78
// 其析构函数都会被调用,从而自动关闭文件句柄。
79
} // file 对象在这里超出作用域,~FileHandle() 被调用
通过使用 FileHandle
类,文件句柄资源被绑定到了对象的生命周期。无论 process_file_raii
函数正常返回还是因为异常而退出,file
对象的析构函数都会被调用,确保 fclose
被执行,从而防止文件句柄泄露。
9.2.2 智能指针 (Smart Pointers) (std::unique_ptr, std::shared_ptr)
智能指针(Smart Pointers)是 C++ 标准库提供的 RAII 类的典型例子,专门用于管理动态分配的内存。它们包装了原始指针(raw pointer),并在智能指针对象超出作用域时,自动调用被管理对象的析构函数并释放内存。🏗️
⚝ std::unique_ptr
(唯一指针):管理一个独占所有权的动态分配对象。当 unique_ptr
被销毁时,它所指向的对象也被销毁。unique_ptr
不能被复制,但可以被移动。它是实现独占资源管理的标准方式,开销与原始指针相当。
1
#include <memory> // For std::unique_ptr
2
#include <vector>
3
4
void f() {
5
// 在堆上创建一个 vector<int>
6
std::unique_ptr<std::vector<int>> vec_ptr(new std::vector<int>{1, 2, 3});
7
8
// ... 使用 vec_ptr ...
9
10
// 如果这里发生异常,vec_ptr 在栈展开时被销毁
11
// vec_ptr 的析构函数会自动 delete 指向的 vector
12
// 内存得到释放,没有泄露。
13
} // vec_ptr 在这里超出作用域并被销毁
⚝ std::shared_ptr
(共享指针):管理一个共享所有权的动态分配对象。它通过引用计数(reference count)来追踪有多少个 shared_ptr
指向同一个对象。当最后一个 shared_ptr
被销毁时,对象才会被删除。shared_ptr
可以被复制。适用于需要共享对象所有权的场景。
1
#include <memory> // For std::shared_ptr
2
#include <string>
3
4
void process(std::shared_ptr<std::string> str_ptr) {
5
// str_ptr 是 shared_ptr 的一个副本,引用计数增加
6
// ... 使用 str_ptr ...
7
} // str_ptr 在这里超出作用域,引用计数减少
8
9
void g() {
10
std::shared_ptr<std::string> shared_str = std::make_shared<std::string>("Hello"); // 引用计数 = 1
11
12
// ... 使用 shared_str ...
13
// 传递给其他函数或存储在其他地方
14
15
process(shared_str); // 传递时复制,引用计数增加到 2,函数返回后减少到 1
16
17
// 如果这里发生异常,shared_str 在栈展开时被销毁
18
// 引用计数减少。如果引用计数变为 0,对象被删除。
19
// 内存得到释放,没有泄露。
20
} // shared_str 在这里超出作用域,引用计数减少。如果为 0,对象被删除。
使用智能指针管理堆内存是现代 C++ 中编写异常安全代码的关键实践。它们极大地简化了动态内存的管理,避免了手动 delete
带来的忘记释放、重复释放、在异常路径上忘记释放等问题。对于其他类型的资源,如果标准库没有提供现成的 RAII 类,我们可以像上面的 FileHandle
例子一样,自定义 RAII 类来封装资源的获取和释放逻辑。
9.3 构造函数中抛出异常 (Throwing Exceptions in Constructors)
构造函数(Constructor)的职责是初始化对象,使其达到一个有效的状态。如果构造过程中发生错误,导致对象无法被正确初始化,构造函数应该报告这个错误。在 C++ 中,构造函数报告错误的主要方式就是抛出异常。🔨
当构造函数抛出异常时,这个对象的生命周期实际上并未开始。C++ 语言规定,如果一个对象的构造函数抛出异常,那么该对象的内存会被自动回收(对于栈对象和全局/静态对象,内存会自动管理;对于堆对象,如果使用了 new
表达式,内存会被自动释放)。更重要的是,如果构造函数未能完成(即抛出了异常),该对象的析构函数将不会被调用。
这对编写构造函数提出了一个重要的要求:在构造函数中分配的任何资源,如果分配在构造函数完成之前因为异常而中断,必须能够得到妥善清理。这正是 RAII 原则在构造函数内部的应用:
① 成员对象的构造: 如果类的成员变量本身是具有 RAII 语义的对象(如 std::string
, std::vector
, 智能指针 std::unique_ptr
, std::shared_ptr
等),并且它们的构造函数在包含类的构造函数体执行 之前(通过成员初始化列表)或 期间 完成并抛出异常,那么这些已成功构造的成员对象的析构函数会在栈展开时被自动调用,从而释放它们管理的资源。这是最安全和推荐的方式。
② 原始指针管理的资源: 如果在构造函数内部使用原始指针 new
分配了内存或其他资源,并且在 delete
或释放资源之前抛出了异常,那么这部分资源将得不到释放,导致泄露。
例子:构造函数中的异常与资源管理
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
#include <memory>
5
#include <stdexcept>
6
7
class Resource {
8
public:
9
Resource(int id) : id_(id) {
10
std::cout << "Resource " << id_ << " acquired." << std::endl;
11
// 模拟资源获取可能失败
12
if (id_ == 99) {
13
throw std::runtime_error("Failed to acquire resource 99");
14
}
15
}
16
~Resource() {
17
std::cout << "Resource " << id_ << " released." << std::endl;
18
}
19
private:
20
int id_;
21
};
22
23
class MyClass {
24
public:
25
// 构造函数
26
MyClass(int val, int res1_id, int res2_id) :
27
value_(val), // 基本类型成员,不会抛异常
28
res1_(res1_id), // RAII 对象成员
29
raw_ptr_(nullptr) // 原始指针先初始化为 null
30
{
31
std::cout << "MyClass constructor starting." << std::endl;
32
// 在构造函数体内分配资源 (使用原始指针)
33
raw_ptr_ = new int[10];
34
std::cout << "Raw memory acquired." << std::endl;
35
36
// 模拟在 RAII 成员构造和 raw pointer 分配之间可能抛出的异常
37
// 如果 res1_id 是 99,res1_ 的构造函数会抛异常
38
// 如果抛异常,raw_ptr_ = new int[10]; 不会被执行
39
40
// 模拟在 raw pointer 分配之后可能抛出的异常
41
if (value_ == -1) {
42
// 这里抛出异常,raw_ptr_ 已经分配了内存
43
// 但 MyClass 的析构函数不会被调用
44
// raw_ptr_ 指向的内存将泄露!
45
throw std::runtime_error("Invalid value -1");
46
}
47
48
// 在构造函数体内分配 RAII 资源 (使用智能指针)
49
// smart_ptr_ = std::make_unique<Resource>(res2_id); // 如果 res2_id=99, 这里的构造会抛异常
50
51
std::cout << "MyClass constructor finished." << std::endl;
52
}
53
54
// 析构函数
55
~MyClass() {
56
std::cout << "MyClass destructor starting." << std::endl;
57
// 释放原始指针管理的资源
58
delete[] raw_ptr_;
59
std::cout << "Raw memory released." << std::endl;
60
// RAII 成员 (res1_, smart_ptr_) 的析构函数会自动调用
61
std::cout << "MyClass destructor finished." << std::endl;
62
}
63
64
// 更好的做法是使用智能指针管理动态分配的成员
65
// private:
66
// int value_;
67
// Resource res1_;
68
// std::unique_ptr<int[]> smart_raw_ptr_; // 使用 unique_ptr 管理动态数组
69
// std::unique_ptr<Resource> smart_res2_; // 使用 unique_ptr 管理 Resource 对象
70
// public:
71
// MyClass(int val, int res1_id, int res2_id) :
72
// value_(val),
73
// res1_(res1_id), // Resource 的构造函数可能抛异常
74
// smart_raw_ptr_(nullptr),
75
// smart_res2_(nullptr)
76
// {
77
// std::cout << "MyClass constructor starting (improved)." << std::endl;
78
// try {
79
// smart_raw_ptr_ = std::make_unique<int[]>(10); // 分配内存
80
// std::cout << "Smart raw memory acquired." << std::endl;
81
//
82
// // 模拟其他可能抛异常的操作
83
// if (value_ == -1) {
84
// throw std::runtime_error("Invalid value -1 (improved)");
85
// }
86
//
87
// smart_res2_ = std::make_unique<Resource>(res2_id); // 分配 Resource 对象
88
// std::cout << "Smart Resource acquired." << std::endl;
89
//
90
// } catch (...) {
91
// // 异常发生时,已成功构造的成员(res1_, smart_raw_ptr_)
92
// // 在栈展开时会自动调用析构函数,释放资源。
93
// // smart_res2_ 如果未成功构造,不会有问题。
94
// std::cout << "Exception caught in constructor, rethrowing." << std::endl;
95
// throw; // 重新抛出原始异常
96
// }
97
// std::cout << "MyClass constructor finished (improved)." << std::endl;
98
// }
99
// // 析构函数现在只需要关注那些不是 RAII 对象管理的资源(如果还有的话)
100
// // ~MyClass() { std::cout << "MyClass destructor (improved)." << std::endl; } // 智能指针成员会自动清理
101
// private:
102
// int value_;
103
// Resource res1_;
104
// std::unique_ptr<int[]> smart_raw_ptr_;
105
// std::unique_ptr<Resource> smart_res2_;
106
107
private:
108
int value_;
109
Resource res1_; // RAII 对象成员
110
int* raw_ptr_; // 原始指针成员 (容易泄露)
111
// std::unique_ptr<Resource> smart_ptr_; // 智能指针成员 (RAII 友好)
112
};
113
114
int main() {
115
std::cout << "Attempting to create MyClass(10, 1, 2)..." << std::endl;
116
try {
117
MyClass obj1(10, 1, 2);
118
// obj1 正常构造完成
119
} catch (const std::exception& e) {
120
std::cerr << "Caught exception: " << e.what() << std::endl;
121
}
122
std::cout << std::endl;
123
124
std::cout << "Attempting to create MyClass(10, 99, 2)..." << std::endl;
125
try {
126
// res1_ 的构造函数会抛异常
127
MyClass obj2(10, 99, 2);
128
} catch (const std::exception& e) {
129
std::cerr << "Caught exception: " << e.what() << std::endl;
130
// obj2 的 MyClass 构造函数未完成,~MyClass() 不会被调用
131
// raw_ptr_ = new int[10]; 未执行,没有泄露
132
// res1_ 的 Resource(99) 构造函数失败,其析构函数也不会被调用,但 Resource 的构造函数本身处理了失败情况。
133
// smart_ptr_ = ...; 也未执行
134
}
135
std::cout << std::endl;
136
137
std::cout << "Attempting to create MyClass(-1, 1, 2)..." << std::endl;
138
try {
139
// MyClass 构造函数体内会抛异常
140
MyClass obj3(-1, 1, 2);
141
} catch (const std::exception& e) {
142
std::cerr << "Caught exception: " << e.what() << std::endl;
143
// obj3 的 MyClass 构造函数在 raw_ptr_ = new int[10]; 之后抛异常
144
// res1_ (Resource 1) 已经成功构造,其析构函数会在栈展开时被调用。
145
// 但 MyClass 的析构函数 ~MyClass() 不会被调用!
146
// 导致 raw_ptr_ 指向的内存泄露!
147
}
148
std::cout << std::endl;
149
150
// 如果使用改进后的 MyClass (使用智能指针)
151
// std::cout << "Attempting to create MyClass(10, 1, 99) (improved)..." << std::endl;
152
// try {
153
// // smart_res2_ 的 Resource(99) 构造函数会抛异常
154
// MyClass improved_obj(10, 1, 99);
155
// } catch (const std::exception& e) {
156
// std::cerr << "Caught exception: " << e.what() << std::endl;
157
// // improved_obj 的 MyClass 构造函数抛异常
158
// // res1_ (Resource 1) 和 smart_raw_ptr_ 已经成功构造。
159
// // 它们在栈展开时会正确销毁,释放资源。没有泄露。
160
// }
161
162
163
return 0;
164
}
运行上述原始 MyClass
代码的输出可能会是:
1
Attempting to create MyClass(10, 1, 2)...
2
Resource 1 acquired.
3
MyClass constructor starting.
4
Raw memory acquired.
5
MyClass constructor finished.
6
MyClass destructor starting.
7
Raw memory released.
8
Resource 1 released.
9
MyClass destructor finished.
10
11
Attempting to create MyClass(10, 99, 2)...
12
Resource 99 acquired.
13
Caught exception: Failed to acquire resource 99
14
15
Attempting to create MyClass(-1, 1, 2)...
16
Resource 1 acquired.
17
MyClass constructor starting.
18
Raw memory acquired.
19
Caught exception: Invalid value -1
20
Resource 1 released. // res1_ 的析构函数被调用了,因为它在 MyClass 构造函数体执行前就构造成功了
可以看到,当 MyClass(-1, 1, 2)
构造函数抛异常时,raw_ptr_
指向的内存在 MyClass
析构函数中释放的逻辑没有被执行,发生了内存泄露。而成员对象 res1_
因为是 RAII 对象,并且在 MyClass
构造函数体抛异常之前已经构造完成(通过初始化列表),所以它的析构函数被正确调用了。
关键 takeaway: 在构造函数中,确保所有在可能抛出异常的代码 之前 获得的资源都由 RAII 对象管理。如果必须在构造函数体内部使用原始指针分配资源,并且其后可能抛出异常,考虑使用 try-catch
块在构造函数内部捕获异常,清理已分配的资源,然后重新抛出异常,或者(更好的方式)重构代码,使用智能指针或自定义 RAII 类来管理这些资源。
9.4 析构函数中抛出异常 (Throwing Exceptions in Destructors)
一个非常重要的 C++ 编程原则是:不应该在析构函数(Destructors)中抛出异常。🗑️💥
析构函数的主要职责是释放资源和清理对象状态。它们在对象的生命周期结束时被调用,无论是因为正常程序流程(对象超出作用域、delete
被调用)还是因为异常处理过程中的栈展开(stack unwinding)。如果在析构函数执行期间又抛出了一个异常,而此时程序正处于异常处理状态(即有另一个异常正在传播),C++ 标准规定会调用 std::terminate()
,导致程序立即终止。这通常不是我们期望的错误处理行为,因为这剥夺了异常处理机制正常工作的机会,并可能导致其他已经获得的资源无法得到释放。
为什么析构函数不应该抛异常?
① 双重异常(Double Exception): 如上所述,如果在栈展开过程中调用析构函数,而该析构函数又抛出了异常,程序会调用 std::terminate()
。这使得异常处理变得不可预测且难以调试。
② 资源清理顺序混乱: 如果析构函数抛异常,可能会中断资源清理的链条。例如,一个对象管理着多个资源,析构函数按特定顺序释放它们。如果在释放某个资源时抛异常,后续资源的释放可能就不会执行,导致更多泄露或其他未清理状态。
③ 不可恢复的状态: 析构函数通常用于清理,而不是处理复杂的错误或进入需要恢复的状态。抛出异常意味着一种需要外部干预的错误,这与析构函数的被动清理角色冲突。
④ 标准库容器和算法问题: 标准库容器(如 std::vector
, std::list
)和算法在销毁其元素时依赖于元素析构函数不抛异常的保证。如果元素析构函数抛异常,容器的状态可能会变得不确定,甚至导致程序终止。
考虑一个有问题的析构函数例子:
1
#include <iostream>
2
#include <cstdio> // For FILE, fclose
3
4
class FileHandleBadDestructor {
5
public:
6
FileHandleBadDestructor(const char* filename) {
7
file_ = fopen(filename, "w");
8
if (!file_) {
9
throw std::runtime_error("Failed to open file for writing.");
10
}
11
}
12
13
// 有问题的析构函数
14
~FileHandleBadDestructor() {
15
if (file_) {
16
std::cout << "Closing file..." << std::endl;
17
// 模拟 fclose 失败可能抛出异常(实际 fclose 返回错误码)
18
// 为了演示概念,假设它会抛异常
19
bool close_failed_simulated = true; // 假设关闭操作失败
20
if (close_failed_simulated) {
21
std::cerr << "Error: Simulated failure during file closing." << std::endl;
22
// !!! 在析构函数中抛出异常 !!!
23
throw std::runtime_error("Error closing file!");
24
}
25
fclose(file_);
26
file_ = nullptr;
27
std::cout << "File closed." << std::endl;
28
}
29
}
30
private:
31
FILE* file_;
32
};
33
34
void func_with_exception() {
35
// 假设这里有些操作会抛异常
36
std::cout << "Inside func_with_exception, about to throw." << std::endl;
37
throw std::runtime_error("Exception from func_with_exception");
38
}
39
40
int main() {
41
try {
42
FileHandleBadDestructor file("test.txt");
43
std::cout << "File object created." << std::endl;
44
45
// 调用可能抛异常的函数
46
func_with_exception(); // 抛出第一个异常
47
48
std::cout << "This line will not be reached." << std::endl;
49
50
} catch (const std::exception& e) {
51
// 第一个异常在这里被捕获
52
std::cerr << "Caught first exception: " << e.what() << std::endl;
53
// 在异常处理过程中,file 对象的析构函数被调用
54
// 如果 ~FileHandleBadDestructor() 抛出异常,将导致 std::terminate()
55
}
56
// 如果析构函数没有抛异常,程序将继续执行到这里
57
58
std::cout << "Program finished." << std::endl;
59
return 0;
60
}
运行上述代码(如果模拟的 fclose
失败抛异常)可能会导致程序终止,并输出类似信息:
1
File object created.
2
Inside func_with_exception, about to throw.
3
Closing file...
4
Error: Simulated failure during file closing.
5
terminate called after throwing an instance of 'std::runtime_error'
6
what(): Error closing file!
7
Aborted (core dumped)
这表明在处理第一个异常时,析构函数抛出了第二个异常,导致了 std::terminate
。
如何在析构函数中处理失败?
如果析构函数中进行的资源释放操作确实可能失败(例如,写入缓冲到磁盘失败),不应该通过抛出异常来报告。可行的替代方案包括:
① 记录错误: 将错误信息记录到日志文件或标准错误输出。
② 设置错误标志: 在对象或某个相关的全局/线程局部状态中设置一个错误标志,供后续检查。但这通常难以管理。
③ 提供显式清理方法: 提供一个公共的成员函数(如 close()
, flush()
, release()
),该函数执行资源释放逻辑,并且 可以 抛出异常。用户需要在对象销毁 之前 调用此方法,并自行处理可能抛出的异常。如果用户忘记调用,析构函数仍然会执行资源释放,但不抛异常,从而保证基本异常安全(无资源泄露)。
1
// 改进后的 FileHandle 析构函数
2
class FileHandleGoodDestructor {
3
public:
4
FileHandleGoodDestructor(const char* filename) { /* ... 同上 ... */ file_ = fopen(filename, "w"); if (!file_) throw std::runtime_error("Failed to open file."); }
5
6
// 显式关闭方法,可以抛异常
7
void close() {
8
if (file_) {
9
std::cout << "Explicitly closing file..." << std::endl;
10
// 模拟 fclose 失败可能抛出异常
11
bool close_failed_simulated = true; // 假设关闭操作失败
12
if (close_failed_simulated) {
13
std::cerr << "Error: Simulated failure during explicit closing." << std::endl;
14
throw std::runtime_error("Error closing file explicitly!");
15
}
16
fclose(file_);
17
file_ = nullptr;
18
std::cout << "File explicitly closed." << std::endl;
19
}
20
}
21
22
// 良好的析构函数:不抛异常
23
~FileHandleGoodDestructor() {
24
if (file_) {
25
std::cout << "Implicitly closing file in destructor..." << std::endl;
26
// 资源释放逻辑,不抛异常
27
if (fclose(file_) != 0) {
28
// 处理关闭失败,但不抛异常
29
// 例如:记录日志 std::cerr << "Warning: Failed to close file in destructor." << std::endl;
30
std::cerr << "Warning: Failed to close file in destructor." << std::endl;
31
}
32
file_ = nullptr;
33
std::cout << "File implicitly closed." << std::endl;
34
}
35
}
36
private:
37
FILE* file_;
38
};
39
40
int main() {
41
std::cout << "--- Test with explicit close ---" << std::endl;
42
try {
43
FileHandleGoodDestructor file1("test1.txt");
44
std::cout << "File object1 created." << std::endl;
45
// 在对象销毁前显式关闭
46
file1.close();
47
std::cout << "Explicit close called." << std::endl;
48
49
} catch (const std::exception& e) {
50
std::cerr << "Caught exception during explicit close: " << e.what() << std::endl;
51
}
52
std::cout << std::endl;
53
54
55
std::cout << "--- Test with exception and implicit close ---" << std::endl;
56
try {
57
FileHandleGoodDestructor file2("test2.txt");
58
std::cout << "File object2 created." << std::endl;
59
60
// 调用可能抛异常的函数
61
func_with_exception(); // 抛出第一个异常
62
63
std::cout << "This line will not be reached." << std::endl;
64
65
} catch (const std::exception& e) {
66
std::cerr << "Caught exception: " << e.what() << std::endl;
67
// 在异常处理过程中,file2 对象的析构函数被调用
68
// ~FileHandleGoodDestructor() 不会抛异常,程序可以继续执行
69
}
70
std::cout << "Program finished cleanly after handling exception." << std::endl;
71
return 0;
72
}
运行改进后的代码,即使模拟关闭失败,也不会导致程序终止:
1
--- Test with explicit close ---
2
File object1 created.
3
Explicitly closing file...
4
Error: Simulated failure during explicit closing.
5
Caught exception during explicit close: Error closing file explicitly!
6
7
--- Test with exception and implicit close ---
8
File object2 created.
9
Inside func_with_exception, about to throw.
10
Implicitly closing file in destructor...
11
Warning: Failed to close file in destructor.
12
File implicitly closed.
13
Caught exception: Exception from func_with_exception
14
Program finished cleanly after handling exception.
这个例子说明了在析构函数中处理错误(如记录警告)而不是抛出异常的重要性,尤其是在异常处理上下文中的清理操作。🛡️✅
10. 高级类特性 (Advanced Class Features)
欢迎来到本书关于 C++ 类深入探讨的第十章。在本章中,我们将超越基础和一些常用特性,探索 C++ 类中一些更为高级和特殊的用法。这些特性虽然可能不如前几章介绍的那么频繁出现,但在特定场景下却能提供强大的工具,帮助我们实现更精巧、更高效或更能满足特定设计需求的解决方案。我们将深入了解嵌套类、局部类、Pimpl Idiom (指向实现的指针惯用法)以及匿名联合和匿名结构体。
10.1 嵌套类 (Nested Classes)
总结: 在另一个类内部定义的类。
嵌套类(Nested Class)是一种在一个类的定义范围内声明的类。从概念上讲,它与普通的类相似,但其名称的作用域(scope)限定在外部类之内。嵌套类可以看作是外部类的一个成员类型(member type)。
10.1.1 什么是嵌套类? (What is a Nested Class?)
嵌套类是在另一个类的主体内部声明的类。例如:
1
class Outer {
2
public:
3
class Nested { // Nested class declaration
4
// ... members of Nested ...
5
};
6
// ... members of Outer ...
7
};
在这个例子中,Nested
就是 Outer
类的一个嵌套类。Nested
这个名称只能在 Outer
类的作用域内直接访问,或者通过 Outer::Nested
的形式在外部访问。
10.1.2 嵌套类的定义与声明 (Nested Class Declaration and Definition)
嵌套类可以在外部类内部声明,然后在外部类内部或外部定义其成员函数。类本身的定义(成员变量和成员函数的声明)通常都在外部类内部完成。
1
// inside Outer class definition
2
class Outer {
3
public:
4
class Nested {
5
public:
6
void nestedMethod(); // Declaration inside Outer
7
int nestedData;
8
};
9
// ...
10
};
11
12
// Definition of nestedMethod outside Outer class
13
void Outer::Nested::nestedMethod() {
14
// implementation
15
nestedData = 10; // Can access Nested's members
16
}
注意,定义嵌套类成员函数时,需要使用 Outer::Nested::
的限定符。
10.1.3 访问规则 (Access Rules)
嵌套类本身是一个独立于外部类的类型。这意味着:
① 嵌套类对象的创建与外部类对象无关:
▮▮▮▮ⓑ 你可以直接创建嵌套类的对象,无需先创建外部类的对象:
1
Outer::Nested obj; // Valid
▮▮▮▮ⓑ 嵌套类不是外部类的子对象(subobject)。
▮▮▮▮ⓒ 外部类对象不包含嵌套类对象作为其成员,除非你显式地定义一个嵌套类类型的成员变量。
② 访问外部类成员:
▮▮▮▮ⓑ 嵌套类的成员函数可以访问外部类的 static
成员(变量和函数),因为 static
成员不依赖于任何特定的外部类对象。
1
class Outer {
2
public:
3
static int outerStaticData;
4
class Nested {
5
public:
6
void accessOuterStatic() {
7
outerStaticData = 20; // OK
8
}
9
};
10
};
11
int Outer::outerStaticData = 0;
▮▮▮▮ⓑ 嵌套类的成员函数 不能 直接访问外部类的非 static
成员(变量和函数),除非通过指向外部类对象的指针或引用。这是因为非 static
成员依赖于一个具体的外部类对象,而嵌套类对象本身并不“属于”任何特定的外部类对象。
1
class Outer {
2
int outerData;
3
public:
4
class Nested {
5
public:
6
void accessOuterNonStatic(Outer& outerObj) {
7
outerObj.outerData = 30; // OK, through reference
8
// outerData = 40; // Error! Cannot access directly
9
}
10
};
11
};
③ 访问嵌套类成员:
▮▮▮▮ⓑ 外部类的成员函数 可以 访问嵌套类的 public
和 protected
成员。
▮▮▮▮ⓒ 外部类的成员函数 不能 直接访问嵌套类的 private
成员,除非外部类被声明为嵌套类的友元(friend)。这与普通的访问控制规则一致。
1
class Outer {
2
public:
3
class Nested {
4
int nestedPrivateData;
5
public:
6
int nestedPublicData;
7
// friend class Outer; // If uncommented, Outer can access nestedPrivateData
8
};
9
10
void accessNestedMembers(Nested& nestedObj) {
11
nestedObj.nestedPublicData = 50; // OK
12
// nestedObj.nestedPrivateData = 60; // Error, unless Outer is friend
13
}
14
};
总结来说,嵌套类拥有其自己的访问控制权限,并且独立于外部类。外部类和嵌套类之间并非父子关系,更像是包含关系,但这种包含仅限于命名空间和有限的访问(static
成员)。
10.1.4 使用场景 (Use Cases)
嵌套类的一些常见使用场景包括:
⚝ 辅助类 (Helper Classes): 当一个类(外部类)需要一个小的、内部使用的辅助类来完成其功能时,可以将这个辅助类定义为嵌套类,以避免污染全局命名空间或外部类的命名空间之外的其他作用域。例如,一个链表类可能需要一个表示节点的嵌套类。
1
class LinkedList {
2
private:
3
struct Node { // Nested struct (struct is a class with default public members)
4
int data;
5
Node* next;
6
Node(int d) : data(d), next(nullptr) {}
7
};
8
Node* head;
9
public:
10
// ... LinkedList methods using Node ...
11
};
⚝ 迭代器类 (Iterator Classes): 容器类(如 std::vector
, std::list
)通常需要一个迭代器类来遍历其元素。迭代器类与容器类紧密相关,并且通常需要访问容器类的内部数据结构。将其定义为嵌套类是一个常见的模式。
1
class MyVector {
2
int* data;
3
size_t size;
4
public:
5
class Iterator {
6
int* current;
7
public:
8
Iterator(int* p) : current(p) {}
9
int& operator*() const { return *current; }
10
Iterator& operator++() { ++current; return *this; } // Pre-increment
11
// ... other iterator operators ...
12
bool operator!=(const Iterator& other) const { return current != other.current; }
13
};
14
15
Iterator begin() { return Iterator(data); }
16
Iterator end() { return Iterator(data + size); }
17
// ... MyVector methods ...
18
};
⚝ 封装实现细节 (Encapsulating Implementation Details): 虽然不如 Pimpl Idiom 强大,但嵌套类也可以用于组织和封装与外部类实现相关的类型定义。
10.2 局部类 (Local Classes)
总结: 在函数内部定义的类。
局部类(Local Class)是在一个函数的定义范围之内声明的类。与嵌套类类似,其名称的作用域限定在它所在的函数之内。
10.2.1 什么是局部类? (What is a Local Class?)
局部类是在函数体内部定义的类。例如:
1
void myFunction() {
2
class LocalHelper { // Local class definition
3
public:
4
void greet() {
5
std::cout << "Hello from local helper!" << std::endl;
6
}
7
};
8
9
LocalHelper helper; // Creating an object of the local class
10
helper.greet();
11
}
局部类的名称 LocalHelper
只在 myFunction
函数内部可见和使用。
10.2.2 局部类的限制 (Limitations of Local Classes)
局部类有一些重要的限制:
⚝ 不能有静态数据成员 (Cannot have Static Data Members): 局部类不能包含 static
成员变量,因为 static
成员需要在类外部进行定义,而局部类的名称只在函数内部可见,无法在外部定义其 static
成员。
1
void myFunction() {
2
class LocalWithStatic {
3
public:
4
// static int count; // Error! Local classes cannot have static data members
5
};
6
// ...
7
}
8
// int LocalWithStatic::count = 0; // Error! LocalWithStatic not visible here
⚝ 不能有静态成员函数 (Cannot have Static Member Functions): 与静态数据成员类似,局部类也不能包含静态成员函数。这是标准规定的限制。
⚝ 不能是模板 (Cannot be Templates): 局部类不能定义为模板类。
⚝ 不能访问外部函数的非静态局部变量 (Cannot Directly Access Non-Static Local Variables of Enclosing Function): 这是局部类最重要的一个限制。局部类的成员函数不能直接访问其所在外部函数的非静态局部变量。
1
void myFunction() {
2
int localVar = 10;
3
class LocalCannotAccess {
4
public:
5
void accessVar() {
6
// localVar = 20; // Error! Cannot access localVar directly
7
}
8
void accessVarThroughPointer(int* ptr) {
9
*ptr = 30; // OK, if passed via pointer/reference
10
}
11
};
12
// ...
13
}
要访问外部函数的局部变量,必须通过指针、引用或将其作为参数传递给局部类的构造函数或成员函数。这个限制背后的原因是,局部类对象可能在函数返回后仍然存在(例如,如果它被返回或存储在堆上),而外部函数的局部变量在函数返回时就会销毁。
⚝ 不能在外部函数作用域之外定义成员函数 (Cannot Define Member Functions Outside the Enclosing Function): 局部类的所有成员函数必须在类体内定义(即默认为 inline
),不能在函数作用域之外定义。
10.2.3 使用场景 (Use Cases)
由于上述限制,局部类的使用场景相对较少。它们主要用于定义一些非常简单的、仅在特定函数内部用作辅助结构体或类的类型,并且该类不需要复杂的行为或与外部函数的状态有紧密的直接交互(除了通过传递引用/指针的方式)。例如,一个简单的比较器或临时的状态持有者。
1
#include <vector>
2
#include <algorithm>
3
4
void sortAndPrint(std::vector<int>& vec) {
5
class CompareAbsolute {
6
public:
7
bool operator()(int a, int b) const {
8
return std::abs(a) < std::abs(b);
9
}
10
};
11
12
std::sort(vec.begin(), vec.end(), CompareAbsolute());
13
14
// ... print vec ...
15
}
在这个例子中,CompareAbsolute
是一个非常简单的、仅用于 sortAndPrint
函数内部的比较器,将其定义为局部类可以避免污染其他作用域。
10.3 Pimpl Idiom (Pointer to Implementation)
总结: 一种隐藏实现细节、减少编译依赖的设计模式。
Pimpl Idiom(Pointer to Implementation,指向实现的指针)是一种 C++ 编程惯用法,旨在通过将一个类的所有私有(private)和保护(protected)成员移动到一个独立的类中,并通过一个指针来访问这个内部类对象,从而减少类使用者对类实现细节的编译时依赖。
10.3.1 解决的问题 (Problems Solved)
不使用 Pimpl Idiom 的类通常将所有成员变量和成员函数(包括私有和保护的)的声明都放在头文件 (.h 或 .hpp) 中。这导致:
① 编译依赖 (Compilation Dependencies): 任何包含该头文件的源文件都需要在编译时知道类的大小、成员变量的类型等所有实现细节。如果类的私有实现发生变化(例如,添加、移除或修改私有成员变量),所有包含该头文件的源文件都需要重新编译,即使这些变化对使用者是透明的。这在大型项目中可能导致漫长的编译时间。
② 暴露实现细节 (Exposing Implementation Details): 类的私有实现细节暴露在头文件中,这可能违反封装原则,让使用者了解到他们不需要或不应该知道的信息。
10.3.2 工作原理 (How it Works)
Pimpl Idiom 的核心思想是:
① 在类的头文件中,只保留公共(public)接口,并将所有私有/保护成员的声明移到一个单独的内部实现类中。
② 在头文件中,声明一个指向这个内部实现类的指针(通常是智能指针,如 std::unique_ptr
)。这个内部实现类通常只需要一个前向声明(forward declaration)。
③ 在类的源文件 (.cpp) 中,定义这个内部实现类及其所有成员。
④ 类的公共成员函数通过头文件中的指针来访问和操作内部实现类的成员。
10.3.3 实现细节 (Implementation Details)
假设我们有一个类 MyClass
。
MyClass.h
文件:
1
#include <memory> // For std::unique_ptr
2
3
// 前向声明内部实现类
4
class MyClassImpl; // Forward declaration
5
6
class MyClass {
7
public:
8
MyClass(); // 构造函数
9
~MyClass(); // 析构函数
10
11
// 拷贝构造函数和拷贝赋值运算符
12
// 需要特殊处理,因为默认的浅拷贝会复制指针而不是底层对象
13
MyClass(const MyClass& other);
14
MyClass& operator=(const MyClass& other);
15
16
// 移动构造函数和移动赋值运算符 (C++11+)
17
MyClass(MyClass&& other) noexcept;
18
MyClass& operator=(MyClass&& other) noexcept;
19
20
void doSomething(); // 公共成员函数
21
22
private:
23
// 指向内部实现类的指针
24
std::unique_ptr<MyClassImpl> pimpl_;
25
};
MyClass.cpp
文件:
1
#include "MyClass.h"
2
#include <iostream>
3
#include <vector> // Now we can include implementation-specific headers here!
4
5
// 定义内部实现类
6
class MyClassImpl {
7
public:
8
// 内部实现类的成员变量
9
int internalData_ = 0;
10
std::vector<int> complexData_; // Complex member
11
12
// 内部实现类的构造函数
13
MyClassImpl(int data) : internalData_(data) {
14
std::cout << "MyClassImpl constructed with data: " << internalData_ << std::endl;
15
}
16
17
// 内部实现类的析构函数
18
~MyClassImpl() {
19
std::cout << "MyClassImpl destructed." << std::endl;
20
}
21
22
// 内部实现类的成员函数
23
void doSomethingImpl() {
24
std::cout << "Doing something with internal data: " << internalData_ << std::endl;
25
complexData_.push_back(internalData_);
26
}
27
};
28
29
// MyClass 的构造函数
30
MyClass::MyClass() : pimpl_(std::make_unique<MyClassImpl>(42)) {
31
// MyClass 构造函数在这里创建 MyClassImpl 对象
32
std::cout << "MyClass constructed." << std::endl;
33
}
34
35
// MyClass 的析构函数
36
MyClass::~MyClass() {
37
// std::unique_ptr 会自动删除 MyClassImpl 对象
38
std::cout << "MyClass destructed." << std::endl;
39
}
40
41
// 拷贝构造函数
42
MyClass::MyClass(const MyClass& other)
43
: pimpl_(std::make_unique<MyClassImpl>(*(other.pimpl_))) // 深拷贝内部对象
44
{
45
std::cout << "MyClass copy constructed." << std::endl;
46
}
47
48
// 拷贝赋值运算符
49
MyClass& MyClass::operator=(const MyClass& other) {
50
std::cout << "MyClass copy assigned." << std::endl;
51
if (this != &other) {
52
*pimpl_ = *(other.pimpl_); // 深拷贝内部对象
53
}
54
return *this;
55
}
56
57
// 移动构造函数
58
MyClass::MyClass(MyClass&& other) noexcept
59
: pimpl_(std::move(other.pimpl_)) // 移动指针
60
{
61
std::cout << "MyClass move constructed." << std::endl;
62
}
63
64
// 移动赋值运算符
65
MyClass& MyClass::operator=(MyClass&& other) noexcept {
66
std::cout << "MyClass move assigned." << std::endl;
67
if (this != &other) {
68
pimpl_ = std::move(other.pimpl_); // 移动指针
69
}
70
return *this;
71
}
72
73
74
// MyClass 的公共成员函数调用 MyClassImpl 的函数
75
void MyClass::doSomething() {
76
pimpl_->doSomethingImpl();
77
}
注意,由于 std::unique_ptr
的析构函数在 MyClass 的析构函数中被调用,它需要知道 MyClassImpl
的完整定义才能正确地调用其析构函数。因此,MyClass.cpp
文件 必须 包含 MyClassImpl
的完整定义(即 #include "MyClass.h"
之后)。如果 MyClass.h
中只前向声明 MyClassImpl
,而 MyClass.cpp
中没有其完整定义,那么 MyClass
的默认析构函数就无法正确销毁 std::unique_ptr
指向的对象,导致编译错误或运行时问题。因此,Pimpl 的析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符通常需要在 .cpp
文件中定义。
10.3.4 优缺点 (Benefits and Drawbacks)
优点 (Benefits):
⚝ 减少编译依赖 (Reduced Compilation Dependencies): 对私有实现的修改只需要重新编译 .cpp
文件,无需重新编译所有包含 .h
文件的代码,显著提高大型项目的编译速度。
⚝ 隐藏实现细节 (Information Hiding): 将类的内部实现完全隐藏在 .cpp
文件中,增强了封装性。
⚝ 提高接口稳定性 (Improved Interface Stability): 类的公共接口保持不变,内部实现可以更自由地修改。
缺点 (Drawbacks):
⚝ 增加了代码量和复杂性 (Increased Code Volume and Complexity): 需要额外定义一个内部实现类,并手动实现(或 = default
/= delete
)所有 Big Five/Six (构造、析构、拷贝、移动、赋值) 成员,增加了代码量。
⚝ 运行时开销 (Runtime Overhead): 访问成员需要通过指针解引用,引入了轻微的运行时开销。
⚝ 内存开销 (Memory Overhead): 每个对象需要额外的指针存储空间,并在堆上分配内部实现对象。
总的来说,Pimpl Idiom 对于那些实现复杂、私有成员经常变动、或者被大量其他文件包含的类来说,是一个非常有价值的设计模式,可以显著改善编译时间和模块化。
10.4 匿名联合与匿名结构体 (Anonymous Unions and Structures)
总结: 不带名称的联合体或结构体成员,其成员被提升到包含它们的父作用域中。
10.4.1 匿名联合 (Anonymous Unions)
匿名联合体(Anonymous Union)是一种不带名称的联合体。它的成员可以直接在其所在的父作用域中访问,就像是父作用域的成员一样。
规则与语法:
① 匿名联合必须声明为 public
或 private
,取决于其所在的上下文(例如,在类中)。在全局或命名空间作用域中声明的匿名联合默认为 public
。
② 匿名联合不能包含成员函数(包括构造函数、析构函数等)、静态数据成员或引用成员。
③ 在类中定义的匿名联合不会创建实际的对象实例;它只是指定了一块内存区域,这块区域可以按不同成员类型进行解释。
示例:
1
#include <iostream>
2
3
struct Value {
4
int type; // Indicates which member of the union is active
5
union { // Anonymous union
6
int i;
7
float f;
8
char c;
9
}; // No variable name after union {}
10
}; // struct Value
11
12
int main() {
13
Value v;
14
v.type = 0; // Indicate using 'i'
15
v.i = 10;
16
std::cout << "Integer value: " << v.i << std::endl; // Access 'i' directly
17
18
v.type = 1; // Indicate using 'f'
19
v.f = 3.14f;
20
std::cout << "Float value: " << v.f << std::endl; // Access 'f' directly
21
22
v.type = 2; // Indicate using 'c'
23
v.c = 'A';
24
std::cout << "Char value: " << v.c << std::endl; // Access 'c' directly
25
26
// You cannot reliably access other members after writing to one,
27
// unless you handle the type externally (e.g., using 'type' member).
28
// std::cout << "Integer value (after setting char): " << v.i << std::endl; // Undefined behavior if 'i' is not the active member
29
return 0;
30
}
在这个例子中,匿名联合体 union { int i; float f; char c; };
没有变量名。它的成员 i
, f
, c
直接成为 struct Value
的成员,可以通过 v.i
, v.f
, v.c
直接访问。
使用场景:
⚝ 节省内存 (Memory Efficiency): 当一个对象只需要在不同时间存储不同类型的值之一时,使用匿名联合可以节省内存,因为所有成员共享同一块内存区域,大小取决于最大成员的大小。
⚝ 实现变体类型 (Implementing Variant Types): 如上面的 Value
示例,通常与一个类型标识符一起使用,以创建类似变体(variant)的数据结构,其中用户负责跟踪当前存储的实际类型。
10.4.2 匿名结构体 (Anonymous Structures)
匿名结构体(Anonymous Structure)是不带名称的结构体。与匿名联合类似,其成员也可以被提升到包含它们的父作用域。然而,在标准 C++ 中,匿名结构体主要用作匿名联合的成员,而不是独立存在于类或结构体中。
规则与语法 (作为联合的成员):
在 C++ 标准中,你可以在一个联合体(包括匿名联合体)中包含一个匿名结构体作为成员。
1
#include <iostream>
2
3
union Data {
4
int i;
5
float f;
6
struct { // Anonymous struct inside a union
7
int x;
8
int y;
9
}; // No variable name after struct {}
10
};
11
12
int main() {
13
Data d;
14
d.i = 10;
15
std::cout << "Data.i: " << d.i << std::endl;
16
17
d.x = 20; // Access members of the anonymous struct directly
18
d.y = 30;
19
std::cout << "Data.x: " << d.x << ", Data.y: " << d.y << std::endl;
20
21
// Note: Writing to d.x or d.y makes d.i and d.f indeterminate (unless x/y exactly overlap i/f)
22
// Writing to d.i or d.f makes d.x and d.y indeterminate.
23
// This is the nature of unions.
24
return 0;
25
}
在这个例子中,联合体 Data
包含一个匿名的结构体 struct { int x; int y; };
。这个匿名结构体的成员 x
和 y
直接成为 Data
联合体的成员,可以通过 d.x
和 d.y
访问。
在类或结构体中独立的匿名结构体 (Non-Union Anonymous Structures in Classes/Structs):
一些编译器(特别是 C 语言中)允许在结构体或联合体中直接包含匿名的结构体或联合体作为成员,即使它不是整个结构体/联合体本身是匿名的。这通常被称为“匿名结构体/联合体成员”(anonymous struct/union members)。C++ 标准在 C++11 之后对此提供了有限的支持,主要是在联合体中作为非静态数据成员。将一个匿名的结构体作为非联合体的类或结构体的成员是 C++20 引入的特性,通常称为嵌套匿名结构体/联合体。
1
// C++20 example
2
struct Point3D {
3
struct { // Anonymous struct member
4
int x;
5
int y;
6
}; // No variable name
7
int z;
8
};
9
10
int main() {
11
Point3D p;
12
p.x = 1; // Access members of the anonymous struct directly
13
p.y = 2;
14
p.z = 3;
15
std::cout << "Point: (" << p.x << ", " << p.y << ", " << p.z << ")" << std::endl;
16
return 0;
17
}
这种 C++20 特性允许将相关的成员变量组合在一起,同时避免引入额外的嵌套结构体名称。
使用场景:
⚝ 组织相关数据 (Organizing Related Data): 在使用匿名联合作为变体类型时,匿名结构体可以用来组合相关的字段,例如二维坐标 x, y
。
⚝ 简化访问 (Simplifying Access): 避免了通过额外的结构体名来访问成员(如 point.coords.x
变成 point.x
)。
⚝ 与 C 兼容性 (C Compatibility): 在 C 语言中,匿名结构体/联合体成员是标准的一部分(C11)。
理解这些高级特性有助于我们更好地阅读和编写复杂的 C++ 代码,并在特定场景下做出更优的设计选择。它们并非日常必需,但在需要极致控制或解决特定设计问题时,会是很有用的工具。
11. 类设计原则与最佳实践 (Class Design Principles and Best Practices)
本章将带领读者超越 C++ 语言本身的语法和特性,从软件工程和设计的角度来审视如何构建健壮(robust)、灵活(flexible)、可维护(maintainable)和可扩展(extensible)的 C++ 类。优秀的代码不仅仅是语法正确,更在于其优雅的设计,能够应对需求变化,减少错误,并促进团队协作。本章将介绍一些经过实践检验的设计原则和模式,帮助您写出更高质量的 C++ 代码。
11.1 SOLID 原则 (SOLID Principles)
SOLID 原则是一组由 Robert C. Martin(通常称为 Uncle Bob)推广的面向对象设计原则的首字母缩写。它们旨在帮助开发者创建易于理解、维护和扩展的软件系统。理解并应用这些原则对于设计高质量的 C++ 类至关重要。
11.1.1 单一职责原则 (Single Responsibility Principle - SRP)
单一职责原则规定,一个类(或其他代码单元)应该只有一个理由去改变。换句话说,一个类应该只负责一项特定的功能或职责。
⚝ 核心思想: 将相关的行为组织在一起,将不相关的行为分开。一个类应该只为一个actor(用户、角色、系统)负责。
⚝ 优点: 降低类的复杂性;提高类的内聚性(cohesion);减少类之间的耦合(coupling);提高代码的可读性和可维护性。
⚝ 反例: 一个 Report
类既负责生成报表数据,又负责将报表格式化为 HTML 或 PDF,还负责将报表发送到邮件。当报表数据生成逻辑变化、或格式化需求变化、或发送方式变化时,都需要修改同一个类,违反了 SRP。
⚝ 正例: 将功能分解为 ReportDataGenerator
类、ReportFormatter
接口及其具体实现类 (HtmlReportFormatter
, PdfReportFormatter
)、以及 ReportSender
类。每个类只负责其中一项职责。
在 C++ 中,应用 SRP 意味着你的类不应该承担过多的功能。如果一个类有多个公共成员函数或一组数据成员在逻辑上属于不同的功能领域,那么考虑将其拆分成多个更小的类。
11.1.2 开放封闭原则 (Open/Closed Principle - OCP)
开放封闭原则规定,软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着在不修改现有代码的基础上,可以增加新的功能。
⚝ 核心思想: 通过抽象(abstractions)来应对变化。当需求变化时,通过添加新的派生类或实现类来扩展系统功能,而不是修改已有的稳定代码。
⚝ 优点: 提高了系统的可扩展性和灵活性;降低了修改现有代码引入错误的风险。
⚝ 实现方式: 在 C++ 中,OCP 通常通过接口(抽象基类)和多态(virtual functions)来实现。定义一个稳定的抽象接口,具体的实现则由派生类提供。当需要增加新的功能时,只需创建新的派生类实现该接口即可。
⚝ 反例: 一个处理不同形状(圆形、方形)面积计算的函数,使用 if/else
或 switch
来判断形状类型,并分别计算面积。如果需要增加新的形状(三角形),就需要修改这个函数,违反了 OCP。
⚝ 正例: 定义一个抽象基类 Shape
,包含一个纯虚函数 getArea()
。然后创建 Circle
、Square
、Triangle
等派生类,分别实现 getArea()
函数。计算面积的函数接收 Shape*
或 Shape&
参数,通过多态调用 getArea()
,无需知道具体形状类型,对新增形状封闭修改,对扩展开放。
1
// 反例(违反OCP)
2
enum ShapeType { Circle, Square };
3
struct CircleData { double radius; };
4
struct SquareData { double side; };
5
6
struct Shape {
7
ShapeType type;
8
union {
9
CircleData circle;
10
SquareData square;
11
};
12
};
13
14
double calculateArea(const Shape& shape) {
15
double area = 0.0;
16
switch (shape.type) {
17
case Circle:
18
area = 3.14159 * shape.circle.radius * shape.circle.radius;
19
break;
20
case Square:
21
area = shape.square.side * shape.square.side;
22
break;
23
}
24
return area;
25
}
26
27
// 正例(遵循OCP)
28
class Shape { // 抽象基类
29
public:
30
virtual double getArea() const = 0; // 纯虚函数
31
virtual ~Shape() = default;
32
};
33
34
class Circle : public Shape {
35
private:
36
double radius_;
37
public:
38
Circle(double radius) : radius_(radius) {}
39
double getArea() const override {
40
return 3.14159 * radius_ * radius_;
41
}
42
};
43
44
class Square : public Shape {
45
private:
46
double side_;
47
public:
48
Square(double side) : side_(side) {}
49
double getArea() const override {
50
return side_ * side_;
51
}
52
};
53
54
// 计算面积的函数,对新增形状封闭修改
55
double calculateTotalArea(const std::vector<Shape*>& shapes) {
56
double totalArea = 0.0;
57
for (const auto& shape : shapes) {
58
totalArea += shape->getArea(); // 通过多态调用
59
}
60
return totalArea;
61
}
11.1.3 里氏替换原则 (Liskov Substitution Principle - LSP)
里氏替换原则规定,子类型(派生类)必须能够替换掉它们的基类型(基类)而不影响程序的正确性。简而言之,使用基类指针或引用调用成员函数时,如果实际指向的是派生类对象,程序的行为应该是预期中的,符合基类的契约(contract)。
⚝ 核心思想: 派生类必须增强或保持基类的行为,而不是改变或破坏它。基类定义的行为约束(如前置条件、后置条件、不变式)必须在派生类中得到遵守。
⚝ 优点: 保证了继承体系的正确性和健壮性;使得通过基类进行抽象编程成为可能。
⚝ 反例: 有一个 Rectangle
基类和一个 Square
派生类。Rectangle
有 setWidth()
和 setHeight()
方法。如果 Square
类重写这些方法,使得设置宽度时高度也随之改变(反之亦然),那么 Square
对象就不能在所有使用 Rectangle
对象的地方进行替换,因为它的行为改变了基类的预期(例如,设置宽度后再设置高度,最终的宽度可能不是第一次设置的值)。
⚝ 正例: 确保派生类重写的虚函数(如果重写)遵守基类对该函数的行为约束。例如,一个 Animal
基类有 makeSound()
方法,派生类 Dog
和 Cat
实现 makeSound()
。无论通过 Animal*
指针指向的是 Dog
还是 Cat
,调用 makeSound()
都能得到预期的动物叫声,不会出现意外行为。
LSP 强制我们仔细思考继承关系是否真的代表了一种“is-a”(是一个)的关系,并且子类是否真的能够完全履行父类的“契约”。
11.1.4 接口隔离原则 (Interface Segregation Principle - ISP)
接口隔离原则规定,客户端不应该被迫依赖于它们不使用的接口。换句话说,定义更小、更具体的接口,而不是一个庞大、通用的接口。
⚝ 核心思想: 将大接口拆分成多个小接口,每个接口服务于特定的客户端或功能模块。
⚝ 优点: 减少类对不相关方法的依赖;降低类之间的耦合;提高系统的灵活性和可维护性。
⚝ 反例: 定义一个巨大的 Worker
接口,包含 work()
、eat()
、sleep()
、manage()
等所有可能的行为。一个简单的 RobotWorker
类只需要实现 work()
,但却被迫实现 eat()
、sleep()
、manage()
等对其无意义的方法。
⚝ 正例: 将大接口拆分为 Workable
(包含 work()
)、Eatable
(包含 eat()
)、Sleepable
(包含 sleep()
)、Manageable
(包含 manage()
) 等多个小接口。RobotWorker
只需要继承 Workable
接口。
在 C++ 中,ISP 应用于抽象基类。与其定义一个包含所有可能虚函数的单一抽象基类,不如定义多个小的抽象基类,每个代表一个独立的能力或角色。类可以多重继承这些小的抽象基类,只实现它实际需要的能力。
11.1.5 依赖倒置原则 (Dependency Inversion Principle - DIP)
依赖倒置原则规定:
① 高层模块不应该依赖于低层模块,它们都应该依赖于抽象。
② 抽象不应该依赖于细节,细节应该依赖于抽象。
⚝ 核心思想: 软件设计中,应该优先依赖稳定的抽象(接口或抽象基类),而不是依赖易变的具体实现类。
⚝ 优点: 降低高层模块与低层模块之间的耦合;提高了系统的灵活性和可测试性;促进了模块的独立开发。
⚝ 反例: 一个 Application
类直接创建并使用一个具体的 Database
类实例来存储数据。Application
(高层模块)直接依赖于 Database
(低层模块的细节)。如果需要更换数据库类型,就需要修改 Application
类。
⚝ 正例: 定义一个抽象接口 DataStorage
,包含 save()
等方法。Database
类实现 DataStorage
接口。Application
类通过 DataStorage*
或 DataStorage&
来与数据存储交互。这样,Application
依赖于 DataStorage
这个抽象,而不是具体的 Database
实现。更换数据库时,只需提供 DataStorage
的另一个实现,而 Application
代码无需修改。
1
// 反例(违反DIP)
2
class Database { // 低层模块细节
3
public:
4
void save(const std::string& data) {
5
// 具体数据库保存逻辑
6
std::cout << "Saving data to database: " << data << std::endl;
7
}
8
};
9
10
class Application { // 高层模块
11
private:
12
Database db_; // 直接依赖具体实现
13
public:
14
void processData(const std::string& data) {
15
// ... 处理数据 ...
16
db_.save(data); // 调用具体实现
17
}
18
};
19
20
// 正例(遵循DIP)
21
class DataStorage { // 抽象
22
public:
23
virtual void save(const std::string& data) = 0;
24
virtual ~DataStorage() = default;
25
};
26
27
class Database : public DataStorage { // 低层模块细节,依赖抽象
28
public:
29
void save(const std::string& data) override {
30
// 具体数据库保存逻辑
31
std::cout << "Saving data to actual database: " << data << std::endl;
32
}
33
};
34
35
class MockStorage : public DataStorage { // 另一个低层模块细节,依赖抽象 (用于测试等)
36
public:
37
void save(const std::string& data) override {
38
std::cout << "Saving data to mock storage: " << data << std::endl;
39
}
40
};
41
42
class Application { // 高层模块,依赖抽象
43
private:
44
DataStorage& storage_; // 依赖抽象接口
45
public:
46
Application(DataStorage& storage) : storage_(storage) {} // 通过依赖注入
47
void processData(const std::string& data) {
48
// ... 处理数据 ...
49
storage_.save(data); // 调用抽象接口
50
}
51
};
在正例中,Application
不再直接创建 Database
对象,而是通过构造函数接收一个 DataStorage
的引用。这被称为依赖注入(Dependency Injection),是实现 DIP 的一种常见方式。
理解并应用 SOLID 原则需要时间和实践,但它们是构建高质量面向对象软件的基石。
11.2 接口与实现分离 (Separation of Interface and Implementation)
将类的公共接口(interface)与其内部实现(implementation)分离开是 C++ 中一项重要的设计原则。接口定义了类能做什么(函数签名),而实现则说明了类是如何做到的(函数体和私有成员)。
⚝ 核心思想: 类的使用者只应该关心它的公共接口,而不应该知道或依赖它的内部细节。
⚝ 实现方式:
① 在头文件(.h
或 .hpp
)中只声明类、成员变量(通常是私有的)、成员函数(特别是公共接口函数)以及虚函数的声明。
② 在源文件(.cpp
)中定义成员函数的具体实现。
③ 对于只需要在类内部使用的辅助函数或数据结构,应声明为 private
或 protected
,并优先在 .cpp
文件中实现或定义。
④ 尽量减少头文件中包含其他头文件,特别是那些只在实现文件中用到的类型。可以使用前置声明(forward declaration)来代替 #include
,前提是只需要类型名称即可(例如,作为指针或引用)。
⚝ 优点:
① 减少编译依赖: 修改类的实现细节通常只需要重新编译对应的 .cpp
文件,而不需要重新编译所有包含了该类头文件的其他 .cpp
文件。这可以显著加快大型项目的编译速度(特别是当类的实现频繁变动时)。
② 隐藏实现细节: 类的内部工作原理对外部是不可见的,提高了类的封装性(encapsulation)。这使得修改类的内部实现变得更容易,而不会影响到使用该类的客户端代码。
③ 提高代码的可读性: 头文件提供了类的“契约”和“蓝图”,读者可以快速了解类的功能,而无需被具体的实现细节分散注意力。
④ 支持并行开发: 不同的开发者可以同时开发使用某个类的客户端代码和该类的具体实现,只要双方都遵循头文件中定义的接口。
1
// Foo.h (接口声明)
2
#ifndef FOO_H
3
#define FOO_H
4
5
#include <string> // 需要string类型的声明
6
7
class Foo {
8
public:
9
Foo(); // 构造函数声明
10
~Foo(); // 析构函数声明
11
12
void setValue(int val); // 公共接口函数声明
13
int getValue() const;
14
15
// 前置声明 Bar 类,避免在这里include "Bar.h"
16
// class Bar; // 如果只是使用Bar的指针或引用,则可以这样前置声明
17
// Bar* getBar(); // 这种情况下,返回值或参数如果是指针或引用,可以使用前置声明
18
19
private:
20
int value_; // 私有成员变量声明
21
// Bar* bar_ptr_; // 如果使用了前置声明的Bar
22
};
23
24
#endif // FOO_H
25
26
// Foo.cpp (实现定义)
27
#include "Foo.h"
28
// #include "Bar.h" // 如果在实现中需要完整类型定义,则在这里include
29
30
Foo::Foo() : value_(0) {
31
// 构造函数实现
32
// bar_ptr_ = new Bar(); // 如果有Bar成员
33
}
34
35
Foo::~Foo() {
36
// 析构函数实现
37
// delete bar_ptr_; // 清理资源
38
}
39
40
void Foo::setValue(int val) {
41
value_ = val;
42
// 可能在这里调用 Bar 的成员函数,需要 Bar 的完整定义
43
// bar_ptr_->doSomething();
44
}
45
46
int Foo::getValue() const {
47
return value_;
48
}
Pimpl (Pointer to implementation) Idiom 是一种更彻底的接口与实现分离技术(详见 10.3 节),它通过在公共头文件中只包含一个指向私有实现结构的指针,将所有的私有数据成员和非内联成员函数的实现细节完全隐藏在 .cpp
文件中,从而最大程度地减少编译依赖。
11.3 避免耦合 (Avoiding Coupling)
耦合(coupling)是指两个或多个模块(类、函数等)之间的相互依赖程度。高耦合意味着一个模块的改变很可能影响到其他模块,使得系统难以理解、修改和测试。低耦合是良好软件设计的重要标志。
⚝ 核心思想: 设计类时,尽量减少它与其他类的直接依赖。一个类应该尽可能独立地完成其职责。
⚝ 优点:
① 提高模块化: 各个模块可以独立开发、测试和维护。
② 增强灵活性: 更容易替换或修改某个模块而不影响其他部分。
③ 促进代码重用: 低耦合的类更容易在不同的项目或上下文中使用。
④ 简化测试: 可以更容易地对单个类进行单元测试,而无需搭建复杂的依赖环境(可以使用 Mock 对象)。
⚝ 避免高耦合的策略:
① 遵循 SOLID 原则: 特别是 SRP 和 DIP,它们是降低耦合的有力工具。
② 依赖于抽象,而不是具体实现: 如 DIP 所述,通过接口或抽象基类进行交互。
③ 通过参数传递依赖: 而不是在类内部直接创建依赖的对象。这通常通过构造函数或 Setter 方法实现(依赖注入)。
④ 减少类的公共接口暴露的内部细节: 避免在公共接口中返回指向内部数据结构的指针或引用,这可能让外部代码直接修改类的内部状态,形成隐式耦合。如果需要提供访问,考虑返回副本或使用不可修改的引用/指针。
⑤ 使用事件或消息机制: 当一个对象需要通知其他对象某个事件发生时,与其直接调用依赖对象的方法,不如通过一个中介(如事件总线或信号槽机制)发布事件,对事件感兴趣的对象订阅这些事件。发布者和订阅者之间通过中介解耦。
⑥ 数据驱动设计: 将一些行为或配置从代码中提取到数据文件(如 JSON, XML, 配置文件)中,减少代码中的硬编码依赖。
⑦ 限制友元(friend)的使用: 友元破坏了封装性,增加了类之间的耦合。只有在特殊且必要的情况下才考虑使用友元,并且要谨慎。
考虑一个例子:一个 OrderProcessor
类需要计算订单的总价,其中包含商品价格和运费。
1
// 高耦合示例
2
class ShippingCalculator {
3
public:
4
double calculateShipping(double weight, const std::string& country) {
5
// 硬编码的运费计算逻辑,可能依赖外部服务或配置
6
if (country == "USA") return weight * 5.0;
7
if (country == "Canada") return weight * 7.0;
8
return weight * 10.0;
9
}
10
};
11
12
class OrderProcessor {
13
private:
14
ShippingCalculator calculator_; // 直接创建依赖对象
15
public:
16
double processOrder(const Order& order) {
17
double itemPrice = calculateItemPrice(order.items); // 假设有这个函数
18
double shippingCost = calculator_.calculateShipping(order.totalWeight, order.shippingCountry); // 直接调用依赖对象方法
19
return itemPrice + shippingCost;
20
}
21
// ... calculateItemPrice 等其他方法 ...
22
};
这里的 OrderProcessor
直接依赖于 ShippingCalculator
的具体实现。如果 ShippingCalculator
的构造函数需要参数,或者需要使用不同的运费计算方式,OrderProcessor
的代码就需要修改。
1
// 低耦合示例 (遵循DIP和依赖注入)
2
class IShippingCalculator { // 抽象接口
3
public:
4
virtual double calculateShipping(double weight, const std::string& country) const = 0;
5
virtual ~IShippingCalculator() = default;
6
};
7
8
class ConcreteShippingCalculator : public IShippingCalculator { // 具体实现
9
public:
10
double calculateShipping(double weight, const std::string& country) const override {
11
// 具体运费计算逻辑
12
if (country == "USA") return weight * 5.0;
13
if (country == "Canada") return weight * 7.0;
14
return weight * 10.0;
15
}
16
};
17
18
class OrderProcessor {
19
private:
20
const IShippingCalculator& calculator_; // 依赖抽象接口
21
public:
22
// 通过构造函数注入依赖
23
OrderProcessor(const IShippingCalculator& calculator) : calculator_(calculator) {}
24
25
double processOrder(const Order& order) {
26
double itemPrice = calculateItemPrice(order.items); // 假设有这个函数
27
double shippingCost = calculator_.calculateShipping(order.totalWeight, order.shippingCountry); // 通过抽象接口调用
28
return itemPrice + shippingCost;
29
}
30
// ... calculateItemPrice 等其他方法 ...
31
private:
32
double calculateItemPrice(const std::vector<Item>& items) const { /*...*/ return 0.0; } // 假设实现
33
};
34
35
// 在使用时,创建具体实现并注入
36
// ConcreteShippingCalculator realCalculator;
37
// OrderProcessor processor(realCalculator);
38
// processor.processOrder(someOrder);
39
40
// 也可以注入 Mock 对象进行测试
41
// MockShippingCalculator mockCalculator;
42
// OrderProcessor testProcessor(mockCalculator);
43
// testProcessor.processOrder(someOrder);
在这个低耦合的例子中,OrderProcessor
不再关心具体的运费计算是如何实现的,它只依赖于 IShippingCalculator
这个抽象接口。不同的运费计算逻辑可以通过实现 IShippingCalculator
接口来提供,而无需修改 OrderProcessor
类。
11.4 效率与性能考量 (Efficiency and Performance Considerations)
在 C++ 中,性能通常是一个重要的设计目标。在设计类时,需要在清晰度、可维护性和性能之间做出权衡。虽然不应该在设计初期就过度优化(premature optimization),但在关键部分或对性能有严格要求的场景下,必须仔细考虑类的性能影响。
⚝ 核心思想: 了解 C++ 语言特性对性能的影响,并合理地使用它们来优化关键路径的代码。
⚝ 考量因素:
① 对象的创建与销毁:
▮▮▮▮⚝ 栈对象通常比堆对象创建和销毁更快。
▮▮▮▮⚝ 避免在循环中频繁创建和销毁大对象。
▮▮▮▮⚝ 虚函数调用(virtual function calls)会引入少量开销(通过虚表查找),但在多态性带来的设计灵活性面前通常是可接受的。了解这一开销有助于在性能敏感的代码中决定是否使用多态。
▮▮▮▮⚝ 构造函数和析构函数中的复杂操作(如动态内存分配、文件I/O)会增加开销。
② 拷贝与移动:
▮▮▮▮⚝ 深拷贝(deep copy)涉及内存分配和数据复制,开销较大。
▮▮▮▮⚝ 移动语义(move semantics, C++11+)通过转移资源所有权而不是复制来提高效率,特别是在传递临时对象或从函数返回大对象时。确保你的类正确实现了移动构造函数和移动赋值运算符。
③ 内存分配:
▮▮▮▮⚝ 堆内存分配 (new
/delete
) 比栈分配慢。频繁的小块内存分配可能导致内存碎片和性能下降。
▮▮▮▮⚝ 使用智能指针(smart pointers)可以帮助管理动态内存,但智能指针本身也有少量开销。选择合适的智能指针 (unique_ptr
通常比 shared_ptr
开销小)。
④ 数据结构的选择:
▮▮▮▮⚝ 类内部使用的数据结构(如 std::vector
, std::list
, std::map
)对其性能有显著影响。根据访问模式(随机访问、插入/删除频率等)选择合适的数据结构。
⑤ 内联成员函数(Inline Member Functions):
▮▮▮▮⚝ 小型、频繁调用的成员函数可以考虑声明为内联,减少函数调用开销。但这只是对编译器的建议,编译器可能忽略。过度使用内联可能导致代码膨胀。
⑥ 常量正确性(Const Correctness):
▮▮▮▮⚝ 正确使用 const
不仅提高代码清晰度,也能让编译器进行更多优化。例如,常量成员函数可以被常量对象调用,并且编译器知道这些函数不会修改对象状态。
⑦ 缓存友好性:
▮▮▮▮⚝ 数据成员的布局会影响缓存性能。将经常一起访问的数据放在一起可以提高缓存命中率。
⑧ 避免不必要的计算:
▮▮▮▮⚝ 在 getter 函数中避免执行复杂的计算,除非是必须的。如果某个值可以提前计算并存储,则这样做。
⑨ 考虑使用值语义或引用语义:
▮▮▮▮⚝ 在某些情况下,使用值语义(直接存储对象副本)可能比引用语义(存储指针或引用)更简单或更高效(例如,对于小型对象,拷贝开销很小,且局部性更好)。但在处理大型对象或需要多态时,引用语义是必须的。
⑩ 编译时计算与运行时计算:
▮▮▮▮⚝ 利用 C++ 的模板和 constexpr
可以在编译时完成计算,将运行时开销转移到编译阶段。
注意: 性能优化应该基于测量(measurement),而不是猜测。使用性能分析工具(profiler)来识别真正的性能瓶颈。在确保代码正确和可维护的前提下,再针对性地进行优化。
11.5 可维护性、可读性与可测试性 (Maintainability, Readability, and Testability)
一个设计良好的类不仅功能正确,还应该易于他人(或未来的自己)理解、修改和测试。这直接关系到软件的生命周期成本和开发效率。
⚝ 核心思想: 编写清晰、简洁、模块化的代码,使其行为易于预测和验证。
⚝ 提高可读性(Readability):
① 命名: 使用清晰、有意义的类名、成员变量名和成员函数名。遵循一致的命名约定(例如,驼峰命名法、下划线命名法)。
② 代码风格: 遵循一致的代码风格(缩进、括号位置、空格等)。可以使用工具(如 clang-format
)来自动化格式化。
③ 注释: 添加必要的注释来解释类的目的、复杂的算法、非显而易见的逻辑或潜在的陷阱。但避免为显而易见的单行代码添加注释。
④ 代码结构: 保持成员函数简短,专注于单一任务。将大的函数分解为小的辅助函数(即使是私有的)。
⑤ 避免魔法数字和字符串: 使用具名常量或枚举来代替硬编码的值。
⑥ 合理使用 const
: 标记不会修改对象状态的成员函数为 const
,标记常量变量为 const
或 constexpr
,提高代码的清晰度和安全性。
⚝ 提高可维护性(Maintainability):
① 遵循设计原则: 应用 SOLID 原则、接口与实现分离、避免耦合等原则可以降低修改代码时的风险和工作量。
② 模块化: 将大系统分解为小的、独立的模块(类)。
③ 最小化依赖: 减少类与其他类的依赖,特别是对易变类的依赖。
④ 清晰的接口: 类的公共接口应该简单、直观且稳定。
⑤ 错误处理: 清晰地定义和处理错误条件,使用异常、错误码或其他合适的机制。
⑥ 版本控制: 配合良好的版本控制系统(如 Git)和分支策略,方便代码修改和回溯。
⚝ 提高可测试性(Testability):
① 低耦合: 依赖性少的类更容易被隔离进行测试。通过依赖注入等方式,可以在测试时轻松替换真实的依赖为 Mock 或 Stub 对象。
② 单一职责: 职责单一的类更容易编写针对性的单元测试。
③ 清晰的接口: 类的公共接口是测试的入口点。定义良好的接口使得测试用例更容易设计。
④ 避免全局状态和静态变量: 全局状态和静态变量会引入隐式依赖和测试隔离的困难。
⑤ 将业务逻辑与输入/输出分离: 将核心业务逻辑放在独立的类或函数中,使其不依赖于特定的输入(如用户界面、文件I/O、网络)。这样可以更容易地测试业务逻辑本身。
⑥ 提供必要的访问点: 有时为了测试私有或保护成员,可能需要调整设计或使用友元(但要谨慎)。更好的做法是重新审视设计,看是否可以将需要测试的逻辑暴露在公共接口之下,或者将其移动到可以公开访问的辅助类中。
编写单元测试(Unit Tests): 为你设计的类编写单元测试是确保其正确性和可维护性的关键步骤。单元测试可以帮助你在修改代码时快速发现引入的回归错误,并且它们本身也是对类如何使用的文档。使用像 Google Test 或 Catch2 这样的 C++ 单元测试框架。
11.6 常见类设计模式 (Common Class Design Patterns)
设计模式(Design Patterns)是软件设计中常见问题的经典解决方案。它们是经验的总结,使用设计模式可以加速开发过程,提高代码质量和可理解性。这里简要介绍一些与类设计密切相关的常见模式。
⚝ 核心思想: 识别并应用成熟的设计模式来解决特定类型的设计问题。
⚝ 常见模式举例:
① 单例模式 (Singleton Pattern): 保证一个类只有一个实例,并提供一个全局访问点。
▮▮▮▮⚝ 应用场景: 配置管理器、日志记录器、线程池等全局唯一对象。
▮▮▮▮⚝ C++ 实现注意事项: 需要考虑线程安全、懒汉式/饿汉式实现、避免拷贝和赋值。
② 工厂模式 (Factory Pattern):
▮▮▮▮⚝ 简单工厂 (Simple Factory): 一个工厂类负责创建不同类型的对象。
▮▮▮▮▮▮▮▮⚝ 应用场景: 根据输入参数创建不同产品对象。
▮▮▮▮⚝ 工厂方法 (Factory Method): 定义一个创建对象的接口,让子类决定实例化哪个类。工厂方法使得类的实例化延迟到子类。
▮▮▮▮▮▮▮▮⚝ 应用场景: 当一个类不知道它需要创建哪个具体的类,或者一个类希望其子类指定它所创建的对象时。
▮▮▮▮⚝ 抽象工厂 (Abstract Factory): 提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
▮▮▮▮▮▮▮▮⚝ 应用场景: 创建一组相关的产品家族(例如,创建跨平台的 GUI 控件)。
③ 观察者模式 (Observer Pattern): 定义对象之间的一对多依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
▮▮▮▮⚝ 应用场景: GUI 事件处理、股票行情通知、消息队列。
④ 策略模式 (Strategy Pattern): 定义一系列算法,将每个算法封装起来,并使它们可以相互替换。策略模式使得算法可以在运行时独立于使用它的客户端而变化。
▮▮▮▮⚝ 应用场景: 不同排序算法的选择、不同支付方式的处理。
⑤ 模板方法模式 (Template Method Pattern): 在一个抽象类中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下重新定义算法的特定步骤。
▮▮▮▮⚝ 应用场景: 构建框架、算法中的不变部分和可变部分。
⑥ 装饰者模式 (Decorator Pattern): 动态地给一个对象添加额外的职责,相比于继承,装饰者模式更灵活。
▮▮▮▮⚝ 应用场景: 给对象添加日志、权限检查等功能。
⑦ 适配器模式 (Adapter Pattern): 将一个类的接口转换成客户希望的另一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作。
▮▮▮▮⚝ 应用场景: 集成第三方库、兼容旧代码。
设计模式并不是银弹,不应该为了使用模式而使用模式。应该在理解了问题和不同模式的适用场景后,选择最合适的模式来解决设计问题。
本章介绍的设计原则和模式是构建高质量 C++ 类的基石。在实际开发中,不断实践和反思这些原则,将有助于您成为更优秀的 C++ 开发者。
12. 现代 C++ 中的类 (Classes in Modern C++)
欢迎来到本书的最后一章!📚 在前面的章节中,我们系统地学习了 C++ 类从基础概念到高级特性的方方面面。然而,C++ 语言标准在不断演进,尤其是从 C++11 开始,引入了大量旨在提升效率、安全性和表达力的新特性。本章将聚焦于这些现代 C++ 标准(C++11, C++14, C++17, C++20 等)中与类相关的特性和改进,帮助读者了解如何利用这些新工具写出更现代、更健壮的 C++ 代码。
12.1 默认成员函数控制: = default
, = delete
(C++11)
在 C++ 中,编译器会为类自动生成一些特殊的成员函数,包括:
⚝ 默认构造函数 (Default Constructor)
⚝ 析构函数 (Destructor)
⚝ 拷贝构造函数 (Copy Constructor)
⚝ 拷贝赋值运算符 (Copy Assignment Operator)
⚝ 移动构造函数 (Move Constructor) (C++11)
⚝ 移动赋值运算符 (Move Assignment Operator) (C++11)
这些函数在特定条件下会被自动生成。例如,如果一个类没有声明任何构造函数,编译器会自动生成一个默认构造函数。如果类没有声明拷贝构造函数和拷贝赋值运算符,编译器会自动生成它们,执行成员的逐个拷贝(浅拷贝)。
但在某些情况下,我们可能需要更精细地控制这些默认函数的行为:
① 强制编译器生成默认版本,即使有其他情况阻止了自动生成。
② 明确禁止编译器生成或使用某个默认函数。
C++11 引入了 = default
和 = delete
语法来解决这些问题。
12.1.1 = default
:显式请求默认行为 (= default
: Explicitly Requesting Default Behavior)
当你在类中声明了某个特殊成员函数,编译器通常不会再自动生成其他一些默认函数。例如,声明一个带参数的构造函数后,编译器就不会自动生成默认构造函数了。如果你仍然需要默认构造函数,在 C++11 之前,你必须自己提供一个空的实现。使用 = default
可以显式地要求编译器生成默认版本的函数,这通常比手动实现更高效,也更清晰地表达了意图。
你可以将其应用于默认构造函数、拷贝/移动构造函数、拷贝/移动赋值运算符和析构函数。
示例:
1
class MyClass {
2
public:
3
int value;
4
5
// 显式请求默认构造函数
6
MyClass() = default;
7
8
// 自定义带参数构造函数
9
MyClass(int val) : value(val) {}
10
11
// 显式请求默认拷贝构造函数
12
MyClass(const MyClass& other) = default;
13
14
// 显式请求默认拷贝赋值运算符
15
MyClass& operator=(const MyClass& other) = default;
16
17
// C++11: 显式请求默认移动构造函数
18
MyClass(MyClass&& other) = default;
19
20
// C++11: 显式请求默认移动赋值运算符
21
MyClass& operator=(MyClass&& other) = default;
22
23
// 显式请求默认析构函数
24
~MyClass() = default;
25
};
26
27
// 使用默认构造函数
28
MyClass obj1; // OK, 使用了 = default 的默认构造函数
29
30
// 使用自定义构造函数
31
MyClass obj2(10);
32
33
// 使用拷贝构造函数
34
MyClass obj3 = obj2; // OK, 使用了 = default 的拷贝构造函数
35
36
// 使用拷贝赋值运算符
37
MyClass obj4;
38
obj4 = obj2; // OK, 使用了 = default 的拷贝赋值运算符
使用 = default
的好处包括:
⚝ 代码更简洁明了,意图清晰。
⚝ 编译器生成的默认函数通常是最高效的。
⚝ 可以在类定义外将成员函数声明为 = default
(除了默认构造函数和析构函数),这有助于接口和实现的分离。
示例(类外定义):
1
class AnotherClass {
2
public:
3
int data;
4
AnotherClass(int d);
5
AnotherClass& operator=(const AnotherClass& other); // 声明拷贝赋值运算符
6
};
7
8
// 在类外显式请求默认拷贝赋值运算符
9
AnotherClass& AnotherClass::operator=(const AnotherClass& other) = default;
10
11
// 构造函数实现
12
AnotherClass::AnotherClass(int d) : data(d) {}
12.1.2 = delete
:显式禁止功能 (= delete
: Explicitly Deleting Functions)
有时候,你可能希望禁止用户调用某个成员函数,比如禁止对象的拷贝。在 C++11 之前,常用的方法是将拷贝构造函数和拷贝赋值运算符声明为 private
,但这种方法不够直接,而且友元(friend)和成员函数仍然可以调用它们。
= delete
允许你显式地删除一个函数的定义。任何尝试调用被删除的函数都会导致编译错误。这提供了一种干净、安全的方式来禁用特定的操作。
= delete
可以应用于任何函数,而不仅仅是特殊成员函数。这对于禁用特定参数类型的函数重载(function overload)也非常有用。
示例:禁用拷贝和默认构造函数
1
class NonCopyable {
2
public:
3
int id;
4
5
// 禁止默认构造函数
6
NonCopyable() = delete;
7
8
// 允许带参数构造
9
NonCopyable(int i) : id(i) {}
10
11
// 禁止拷贝构造函数
12
NonCopyable(const NonCopyable& other) = delete;
13
14
// 禁止拷贝赋值运算符
15
NonCopyable& operator=(const NonCopyable& other) = delete;
16
17
// 允许移动构造函数和移动赋值运算符(如果编译器生成)
18
// NonCopyable(NonCopyable&& other) = default;
19
// NonCopyable& operator=(NonCopyable&& other) = default;
20
};
21
22
int main() {
23
// NonCopyable obj1; // 编译错误:默认构造函数已被删除
24
NonCopyable obj2(10); // OK
25
26
// NonCopyable obj3 = obj2; // 编译错误:拷贝构造函数已被删除
27
// NonCopyable obj4;
28
// obj4 = obj2; // 编译错误:拷贝赋值运算符已被删除
29
30
NonCopyable obj5 = std::move(obj2); // OK (如果移动操作被允许)
31
32
return 0;
33
}
通过 = delete
,我们可以清晰地表达类的设计意图,例如表示一个资源类不能被复制(例如 std::unique_ptr
)。
总结来说,= default
和 = delete
提供了对特殊成员函数生成的精细控制,使得类设计的意图更加明确,代码更加安全和高效。
12.2 委派构造函数 (Delegating Constructors) (C++11)
在 C++11 之前,一个类中如果有多个构造函数,并且它们之间有共享的初始化逻辑,程序员不得不复制代码。这违反了 DRY (Don't Repeat Yourself) 原则,增加了维护的难度。
例如,一个 Person
类可能有不同的构造函数来初始化姓名、年龄和地址:
1
// Pre-C++11 示例
2
class Person_Old {
3
std::string name;
4
int age;
5
std::string address;
6
7
public:
8
// 构造函数 1: 初始化所有成员
9
Person_Old(const std::string& n, int a, const std::string& addr) :
10
name(n), age(a), address(addr)
11
{
12
// 可能会有一些额外的初始化逻辑,例如日志记录
13
std::cout << "Person_Old constructed: " << name << std::endl;
14
}
15
16
// 构造函数 2: 只初始化姓名和年龄,地址使用默认值
17
Person_Old(const std::string& n, int a) :
18
name(n), age(a), address("Unknown") // 重复了地址的初始化
19
{
20
// 可能会有一些额外的初始化逻辑,与构造函数 1 相同或类似
21
std::cout << "Person_Old constructed: " << name << std::endl; // 重复了日志记录
22
}
23
24
// 构造函数 3: 只初始化姓名,年龄和地址使用默认值
25
Person_Old(const std::string& n) :
26
name(n), age(0), address("Unknown") // 重复了年龄和地址的初始化
27
{
28
// 可能会有一些额外的初始化逻辑
29
std::cout << "Person_Old constructed: " << name << std::endl; // 重复了日志记录
30
}
31
};
可以看到,成员初始化和构造函数体内的额外逻辑都存在重复。
C++11 引入的委派构造函数(Delegating Constructors)允许一个构造函数调用(或“委派给”)同一个类中的另一个构造函数来完成部分或全部初始化工作。这样可以消除构造函数之间的代码重复。
使用委派构造函数的语法是在成员初始化列表的位置,调用同类的另一个构造函数。
示例:使用委派构造函数
1
class Person {
2
std::string name;
3
int age;
4
std::string address;
5
6
public:
7
// 委派目标构造函数:最全面的初始化逻辑
8
Person(const std::string& n, int a, const std::string& addr) :
9
name(n), age(a), address(addr)
10
{
11
// 额外的初始化逻辑,只写一次
12
std::cout << "Person fully constructed: " << name << std::endl;
13
}
14
15
// 委派构造函数 1:委派给上面的构造函数,使用默认地址
16
Person(const std::string& n, int a) :
17
Person(n, a, "Unknown") // 委派调用 Person(string, int, string)
18
{
19
// 这里可以有额外的逻辑,但通常委派构造函数体为空
20
// 因为主要的初始化逻辑已经在被委派的构造函数体中完成了
21
}
22
23
// 委派构造函数 2:委派给上面的构造函数,使用默认年龄和地址
24
Person(const std::string& n) :
25
Person(n, 0) // 委派调用 Person(string, int)
26
// 注意:这里是链式委派,Person(string) 委派给 Person(string, int),
27
// 而 Person(string, int) 又委派给 Person(string, int, string)
28
{
29
// 通常为空
30
}
31
32
// 如果需要默认构造函数,可以委派给一个使用默认值的其他构造函数
33
Person() : Person("Anonymous") {} // 委派给 Person(string)
34
};
35
36
int main() {
37
Person p1("Alice", 30, "Beijing"); // 直接调用全面构造函数
38
Person p2("Bob", 25); // 调用委派构造函数 Person(string, int)
39
Person p3("Charlie"); // 调用委派构造函数 Person(string)
40
Person p4; // 调用委派构造函数 Person()
41
}
委派构造函数的注意事项:
⚝ 委派调用必须位于成员初始化列表的位置,且是唯一项。你不能在同一个初始化列表中既委派给另一个构造函数又初始化成员变量。
⚝ 委派构造函数体的执行是在被委派构造函数体执行 之后。然而,通常委派构造函数体是空的,因为所有初始化工作都应该在被委派的构造函数中完成。
⚝ 委派链不能形成循环。
委派构造函数是 C++11 中一个非常实用的特性,它极大地提高了构造函数的可读性和可维护性。
12.3 类内成员初始化 (In-class Member Initializers) (C++11)
在 C++11 之前,类的非静态数据成员(non-static data members)只能在构造函数中通过成员初始化列表或赋值语句进行初始化。这导致一个问题:如果多个构造函数都将同一个成员初始化为相同的默认值,就必须在每个构造函数中重复这段初始化代码。
示例:
1
// Pre-C++11 示例
2
class Config_Old {
3
int timeout;
4
std::string url;
5
bool verbose;
6
7
public:
8
Config_Old() { // 在构造函数体中赋值
9
timeout = 30;
10
url = "localhost";
11
verbose = false;
12
}
13
14
Config_Old(const std::string& u) { // 成员初始化列表 + 赋值
15
timeout = 30; // 重复
16
url = u;
17
verbose = false; // 重复
18
}
19
20
Config_Old(int t, const std::string& u, bool v) : // 成员初始化列表
21
timeout(t), url(u), verbose(v)
22
{}
23
};
可以看到,timeout
和 verbose
的默认初始化值在多个地方重复出现。
C++11 允许在类定义中直接为非静态数据成员提供一个默认的初始化表达式。这个表达式会在构造函数执行 之前 用于初始化成员,除非该成员在构造函数的成员初始化列表中被显式初始化。
示例:使用类内成员初始化
1
class Config {
2
int timeout = 30; // 类内成员初始化
3
std::string url = "localhost"; // 类内成员初始化
4
bool verbose = false; // 类内成员初始化
5
6
public:
7
// 默认构造函数,使用类内默认值
8
Config() = default; // 或者 { }
9
10
// 构造函数,只初始化 url
11
Config(const std::string& u) : url(u) {
12
// timeout 和 verbose 会使用类内默认值进行初始化
13
}
14
15
// 构造函数,初始化所有成员
16
Config(int t, const std::string& u, bool v) :
17
timeout(t), url(u), verbose(v)
18
{
19
// 这里的初始化会覆盖类内默认值
20
}
21
};
22
23
int main() {
24
Config c1; // timeout=30, url="localhost", verbose=false
25
Config c2("my_server"); // timeout=30, url="my_server", verbose=false
26
Config c3(60, "another_server", true); // timeout=60, url="another_server", verbose=true
27
}
使用类内成员初始化,代码更加简洁,并且更容易维护成员的默认值。如果一个成员的默认值发生变化,只需要修改类定义中的初始化表达式即可,而无需修改所有相关的构造函数。
注意:静态数据成员(static data members)仍然需要在类定义外部进行初始化(如果它们是常整型或枚举型,可以在类内常量初始化)。
12.4 继承构造函数 (Inheriting Constructors) (C++11)
在 C++11 之前,如果派生类需要使用基类(base class)的构造函数来初始化基类部分,即使派生类本身没有任何额外的成员需要初始化,也必须在派生类中显式地编写转发(forwarding)构造函数。这再次导致了大量的样板代码(boilerplate code),特别是在基类有许多构造函数时。
示例:
1
// Pre-C++11 示例
2
class Base {
3
int b_val;
4
public:
5
Base(int v) : b_val(v) {}
6
Base(int v1, int v2) : b_val(v1 + v2) {}
7
};
8
9
class Derived_Old : public Base {
10
int d_val;
11
public:
12
// 必须手动编写构造函数来转发基类的构造参数
13
Derived_Old(int v) : Base(v), d_val(0) {}
14
Derived_Old(int v1, int v2) : Base(v1, v2), d_val(0) {}
15
Derived_Old(int v, int dv) : Base(v), d_val(dv) {}
16
};
C++11 引入了继承构造函数(Inheriting Constructors)的语法,允许派生类通过 using
声明直接继承基类的构造函数。
语法:在派生类定义中使用 using BaseClass::BaseClass;
。
示例:使用继承构造函数
1
class Base {
2
int b_val;
3
public:
4
Base(int v) : b_val(v) { std::cout << "Base(int) called with " << b_val << std::endl; }
5
Base(int v1, int v2) : b_val(v1 + v2) { std::cout << "Base(int, int) called with " << b_val << std::endl; }
6
};
7
8
class Derived : public Base {
9
int d_val;
10
public:
11
// 继承基类的构造函数
12
using Base::Base;
13
14
// 派生类自己的构造函数 (如果需要)
15
Derived(int v, int dv) : Base(v), d_val(dv) { std::cout << "Derived(int, int) called with d_val = " << d_val << std::endl; }
16
17
// 注意:如果基类有默认构造函数,这里不会被继承,除非显式地写 Derived() = default;
18
// 如果基类有拷贝/移动构造函数,这里也不会被继承,派生类会生成自己的默认拷贝/移动构造函数。
19
};
20
21
int main() {
22
// 使用继承的构造函数 Base(int)
23
Derived d1(10); // Base(int) called with 10
24
25
// 使用继承的构造函数 Base(int, int)
26
Derived d2(10, 20); // Base(int, int) called with 30
27
28
// 使用派生类自己的构造函数
29
Derived d3(10, 100); // Base(int) called with 10, Derived(int, int) called with d_val = 100
30
}
当派生类继承基类的构造函数时:
⚝ 基类的所有构造函数(除了默认构造函数、拷贝构造函数、移动构造函数)都可能被继承。
⚝ 继承的构造函数会按其在基类中的参数列表在派生类中生成对应的构造函数。这些生成的构造函数会调用对应的基类构造函数来初始化基类部分,派生类自己的成员会使用类内初始化或默认初始化。
⚝ 如果基类和派生类都有相同签名的构造函数,派生类自己的构造函数会“隐藏”或覆盖(override)继承的版本。
⚝ 继承构造函数不会改变基类成员的访问权限。
继承构造函数是简化多层继承或需要简单转发基类构造函数的场景下非常有用的特性。
12.5 结构化绑定 (Structured Bindings) (C++17)
结构化绑定(Structured Bindings)是 C++17 中引入的一个语法特性,它允许你使用类似元组(tuple)解包(unpacking)的方式,将一个复合类型对象(如数组、结构体或具有特定协议的类)的成员或元素绑定到一组独立的变量名上。
这个特性使得从对象中提取多个值变得更加简洁。虽然它不直接改变类的定义方式,但它提供了一种更方便的方式来使用类的实例,特别是当类主要用作数据容器(类似 C 风格的结构体)时。
结构化绑定可以用于:
① 数组(Arrays)
② std::pair
和 std::tuple
③ 具有非静态公共数据成员的结构体或类(Structs or Classes with Non-static Public Data Members)
④ 实现了结构化绑定协议的类(例如,通过提供 std::tuple_size
, std::tuple_element
, 和 get
函数)
对于类和结构体,最常见的用法是针对具有非静态公共数据成员的情况。
示例:
1
struct Point {
2
double x;
3
double y;
4
};
5
6
class Circle {
7
public: // 注意:必须是公共成员才能直接使用结构化绑定
8
Point center;
9
double radius;
10
};
11
12
int main() {
13
Point p = {1.0, 2.0};
14
// 使用结构化绑定解包 Point 对象
15
auto [px, py] = p;
16
std::cout << "Point coordinates: (" << px << ", " << py << ")" << std::endl; // 输出: (1, 2)
17
18
Circle c = {{3.0, 4.0}, 5.0};
19
// 使用结构化绑定解包 Circle 对象
20
// 嵌套的结构化绑定 (C++20 增强) 或手动解包
21
auto [c_center, c_radius] = c;
22
auto [cx, cy] = c_center; // 解包嵌套的 Point
23
24
std::cout << "Circle center: (" << cx << ", " << cy << "), radius: " << c_radius << std::endl; // 输出: (3, 4), radius: 5
25
26
// 结构化绑定也可以使用引用
27
auto& [ref_px, ref_py] = p;
28
ref_px = 10.0; // 修改 ref_px 会修改 p.x
29
std::cout << "Modified point x: " << p.x << std::endl; // 输出: 10
30
31
// 对于只有公共非静态成员的类,成员的顺序决定了绑定变量的顺序。
32
// 变量的声明类型可以是 auto,也可以是具体的类型,但必须匹配。
33
// 例如:Point p2 = {5.0, 6.0}; auto [x_coord, y_coord] = p2; x_coord 绑定到 p2.x, y_coord 绑定到 p2.y。
34
// 如果类有其他成员(如成员函数),不影响对公共非静态数据成员的结构化绑定。
35
}
结构化绑定提供了一种语法糖(syntactic sugar),它使得从简单的数据类对象中获取数据变得更加便捷,特别是在处理返回值是 pair 或 tuple,或者在使用范围 for 循环遍历容器时。
12.6 Concepts (概念) (C++20)
在 C++20 之前,模板(Templates)是实现泛型编程(Generic Programming)的强大工具,但也带来了挑战。模板参数的约束是隐式的,由模板实例化过程中代码的有效性决定(SFINAE - Substitution Failure Is Not An Error)。当模板实例化失败时,编译器错误信息通常非常冗长、难以理解,并且指向模板的内部实现而不是用户期望的接口。
Concepts (概念) 是 C++20 引入的一项重大特性,旨在为模板参数提供显式的、语义化的约束。它允许程序员清晰地表达模板参数需要满足的条件(例如,是否可比较、是否可哈希、是否是某个基类的派生类等)。
Concepts 可以应用于函数模板和类模板。对于类模板,Concepts 极大地改善了模板接口的可读性、可用性,并提供了更好的编译期诊断。
示例:使用 Concept 约束类模板
1
#include <concepts> // 包含 C++20 标准概念
2
3
// 定义一个 Concept,要求类型 T 必须是可默认构造的、可拷贝的、并且支持 < 运算符(即 可排序 Comparable)
4
template<typename T>
5
concept Sortable = std::default_initializable<T> &&
6
std::copyable<T> &&
7
std::totally_ordered<T>; // std::totally_ordered 需要 operator<, <=, >, >=, ==, !=
8
9
// 定义一个类模板,使用 Concept 约束其类型参数
10
template<Sortable T>
11
class SortingContainer {
12
std::vector<T> data;
13
14
public:
15
SortingContainer() = default;
16
void add(const T& value) {
17
data.push_back(value);
18
}
19
20
void sort() {
21
std::sort(data.begin(), data.end());
22
}
23
24
void print() const {
25
for (const auto& item : data) {
26
std::cout << item << " ";
27
}
28
std::cout << std::endl;
29
}
30
};
31
32
// 一个满足 Sortable Concept 的类型
33
struct MyInt {
34
int value;
35
// 满足 default_initializable
36
MyInt() : value(0) {}
37
// 满足 copyable (默认生成)
38
// 满足 totally_ordered
39
bool operator<(const MyInt& other) const { return value < other.value; }
40
bool operator==(const MyInt& other) const { return value == other.value; }
41
// 其他比较运算符可以通过 < 和 == 自动推导
42
};
43
44
// 为了使用 std::cout << MyInt,需要提供 operator<<
45
std::ostream& operator<<(std::ostream& os, const MyInt& obj) {
46
os << obj.value;
47
return os;
48
}
49
50
// 一个不满足 Sortable Concept 的类型 (例如,不可比较)
51
struct NonSortable {
52
int value;
53
};
54
55
int main() {
56
// 可以使用 MyInt 实例化 SortingContainer
57
SortingContainer<MyInt> my_container;
58
my_container.add({5});
59
my_container.add({2});
60
my_container.add({8});
61
my_container.sort();
62
my_container.print(); // 输出: 2 5 8
63
64
// 尝试使用 NonSortable 实例化会产生清晰的编译错误
65
// SortingContainer<NonSortable> non_sortable_container; // 编译错误:NonSortable 不满足 Sortable Concept
66
}
在上面的例子中,template<Sortable T>
清晰地表明了 SortingContainer
只能用于满足 Sortable
概念的类型。如果尝试用不满足该概念的类型实例化,编译器会给出明确的错误信息,说明哪个概念未能满足,而不是一堆深奥的模板实例化失败报告。
Concepts 的优点:
⚝ 提高可读性: 模板接口的约束条件清晰明了。
⚝ 改进错误信息: 编译器能够给出更有针对性的错误提示。
⚝ 增强可用性: 用户可以更容易地理解模板的要求。
⚝ 更好的重载解析: Concepts 可以用于函数模板的约束,影响重载决议。
对于编写泛型类(如容器、智能指针等)的开发者来说,Concepts 是一个极其重要的工具,它将泛型编程的门槛大大降低。
12.7 Modules (模块) (C++20)
在 C++20 之前,C++ 代码组织的基本单位是文件,通过 #include
指令包含头文件。这种基于文本替换的包含模型存在一些固有的问题:
⚝ 编译时间长: 头文件会被同一个编译单元中的多个源文件重复解析。
⚝ 宏污染(Macro Pollution): 头文件中定义的宏会影响到包含它的源文件,可能导致意外行为。
⚝ 缺乏信息隐藏: #include
包含了头文件中的所有内容,无法只暴露接口而隐藏实现细节(除了通过 Pimpl idiom 等模式)。
⚝ 顺序依赖: 头文件的包含顺序有时很重要,可能导致难以发现的错误。
Modules (模块) 是 C++20 引入的一个新的代码组织和编译模型,旨在解决 #include
模型的问题。模块将代码组织成逻辑单元,显式地声明它们提供(导出 export
)哪些接口供其他模块使用,以及它们需要(导入 import
)哪些其他模块的功能。
模块对类有重要的影响:
⚝ 更好的封装: 模块可以导出类,但只导出类的声明;类的私有成员和内部辅助类/函数等都可以完全隐藏在模块内部,不会暴露给模块的用户。
⚝ 更快的编译: 模块通常只编译一次,然后可以被高效地导入到多个地方,避免了头文件的重复解析。
⚝ 消除宏污染: 导入模块不会引入该模块内部定义的宏(除非显式导出)。
⚝ 清晰的依赖关系: 通过 import
语句可以清晰地看到一个模块依赖于哪些其他模块。
示例:一个简单的模块和类
假设我们有一个模块 geometry.ixx
(模块接口文件通常使用 .ixx
或 .cppm
扩展名) 定义一个 Point
类。
1
// geometry.ixx (Module Interface)
2
module; // 模块全局模块片段 (Global module fragment) - 允许包含传统的头文件
3
#include <iostream>
4
#include <string>
5
6
export module geometry; // 导出名为 geometry 的模块接口
7
8
export class Point { // 导出 Point 类
9
double x_; // 私有成员,不导出
10
double y_; // 私有成员,不导出
11
12
public:
13
// 导出的构造函数和成员函数
14
Point(double x, double y) : x_(x), y_(y) {}
15
16
double get_x() const { return x_; }
17
double get_y() const { return y_; }
18
19
void print() const; // 导出成员函数声明,实现可以在模块内部的其他文件或这里提供
20
};
21
22
// 在模块内部定义导出成员函数 (可以在接口文件或单独的实现文件 *.cpp 中)
23
// 如果在 .ixx 中实现,不需要重复 export
24
void Point::print() const {
25
std::cout << "Point(" << x_ << ", " << y_ << ")" << std::endl;
26
}
27
28
// 模块内部不导出的类或函数
29
class InternalHelper {}; // 这个类不会被模块外部访问
30
void internal_function() {} // 这个函数不会被模块外部访问
使用模块:
1
// main.cpp
2
import geometry; // 导入 geometry 模块
3
4
int main() {
5
Point p(10.0, 20.0); // 可以直接使用 Point 类,因为它被导出了
6
p.print(); // 调用导出的成员函数
7
8
// InternalHelper h; // 编译错误:InternalHelper 没有被导出
9
// internal_function(); // 编译错误:internal_function 没有被导出
10
11
// std::cout << p.x_; // 编译错误:x_ 是私有成员
12
// std::cout << p.get_x() << std::endl; // OK,get_x() 被导出了
13
}
通过 Modules,C++ 拥有了现代化的代码组织和编译方式。对于大型项目,Modules 可以显著提高编译速度,改善封装性,并简化依赖管理。在设计类时,现在需要考虑哪些类或哪些类的成员应该被导出为模块接口的一部分。
总结,现代 C++ (C++11 及以后版本) 为类带来了许多重要的增强,从更灵活的默认成员函数控制 (= default
, = delete
),到简化构造函数编写 (Delegating Constructors
, Inheriting Constructors
, In-class Member Initializers
),再到提升模板可用性 (Concepts
) 和改进代码组织方式 (Modules
)。掌握这些特性对于编写高效、健壮且易于维护的 C++ 类至关重要。鼓励读者在实际编程中积极尝试和应用这些现代 C++ 的新工具。🚀
Appendix A: C++ 标准对类的定义摘要 (Summary of Class Definition in C++ Standard)
Appendix A1: 本附录的目的 (Purpose of This Appendix)
作为一名严谨的 C++ 开发者,理解语言的规范是至关重要的。C++ 标准(C++ Standard)是定义 C++ 语言行为的唯一权威文档。本书前面章节对 C++ 类(class)进行了全面深入的讲解,涵盖了其概念、语法、特性及应用。然而,所有这些知识都源自于并必须符合 C++ 标准的定义。
本附录的目的并非要复制 C++ 标准中关于类的全部内容(这既不现实也超出了本书的范畴),而是为了:
① 提供一个 C++ 标准中与类定义相关的关键概念和术语的摘要。
② 引导读者了解在 C++ 标准中,关于类的重要方面是如何被形式化定义和规范的。
③ 强调查阅 C++ 标准(或其权威解读)的重要性,以解决在实际开发中遇到的歧义或深入理解特定行为的需求。
理解 C++ 标准对类的定义,有助于开发者:
⚝ 编写出更符合标准、更具可移植性(portability)的代码。
⚝ 深入理解 C++ 语言的底层机制和保证。
⚝ 更好地诊断和解决复杂的问题(issue)和错误(bug)。
⚝ 掌握现代 C++ 版本(如 C++11, C++14, C++17, C++20 及后续版本)引入的新特性和改进。
请注意,C++ 标准文档是技术性很强、篇幅巨大且不断演进的。本摘要基于对 C++ 标准的普遍理解,旨在提供一个概念性的路线图。对于任何精确的语言行为、特殊情况或最新特性,请务必查阅最新版本的 C++ 标准文档。
Appendix A2: 标准中类的核心概念 (Core Concepts of Classes in the Standard)
C++ 标准在多个章节定义了类(class)及其相关特性。其中,关于类的核心定义和规则主要集中在描述用户自定义类型(user-defined types)的部分。以下是标准中关于类的一些关键概念的摘要:
Appendix A2.1: 类是什么? (What is a Class?)
① 定义(Definition): 标准将类定义为一种用户自定义的类型(user-defined type),它封装了数据成员(data members)和成员函数(member functions)。类是创建对象(object)的蓝图或模板。
② 种类(Kinds): 在 C++ 中,类类型包括使用关键字 class
、struct
或 union
定义的类型。
▮▮▮▮ⓒ class
:默认成员访问权限为 private
,默认继承方式为 private
。
▮▮▮▮ⓓ struct
:默认成员访问权限为 public
,默认继承方式为 public
。
▮▮▮▮ⓔ union
:一种特殊的类类型,其所有非静态数据成员共享同一块内存空间。一次只能存储其中一个成员的值。
Appendix A2.2: 成员 (Members)
类的主体包含其成员。标准详细定义了不同类型的成员:
① 数据成员 (Data Members):
▮▮▮▮ⓑ 存储与类或对象相关的数据。
▮▮▮▮ⓒ 可以是各种类型,包括其他类类型、内置类型、指针、引用等。
▮▮▮▮ⓓ 静态数据成员(static data members)不与类的任何特定对象关联,而是属于类本身,所有对象共享同一个静态数据成员的副本。
② 成员函数 (Member Functions):
▮▮▮▮ⓑ 定义类或对象的行为。
⑧ 可以访问类的所有成员(包括私有 private
和保护 protected
成员)。
⑨ 静态成员函数(static member functions)不与任何特定对象关联,不能直接访问类的非静态成员,因为它们没有 this
指针。
③ 其他成员 (Other Members):
⑧ 嵌套类(nested classes)。
⑨ 枚举成员(enumerator members)。
⑩ 类型别名(type aliases)/ using
声明。
Appendix A2.3: 访问控制 (Access Control)
标准定义了 public
, protected
, private
访问说明符(access specifiers),控制类成员的可访问性:
① public
: 成员可以在类的任何地方以及类外部被访问。
② protected
: 成员可以在类的内部以及其派生类(derived classes)的内部被访问。
③ private
: 成员只能在类本身的内部被访问。
Appendix A2.4: 特殊成员函数 (Special Member Functions)
标准对一些由编译器隐式生成(implicitly generated)或由用户显式定义(explicitly defined)的特殊成员函数有严格的规定,它们管理对象的生命周期和值语义:
① 构造函数 (Constructors):
⑧ 用于初始化对象。它们与类同名,没有返回类型。
⑨ 标准定义了默认构造函数(default constructor)、拷贝构造函数(copy constructor)、移动构造函数(move constructor) (C++11+)。编译器在特定条件下会隐式声明或定义这些构造函数。
⑩ 用户可以定义自己的构造函数,包括带参数的构造函数、委托构造函数 (C++11+)。
② 析构函数 (Destructors):
⑦ 用于在对象生命周期结束时执行清理工作。它们与类同名,前缀为 ~
,没有参数和返回类型。
⑧ 编译器会隐式声明或定义析构函数。
⑨ 虚析构函数(virtual destructor)在继承体系中尤为重要,标准规定了何时需要虚析构函数以避免资源泄露(resource leak)。
③ 拷贝赋值运算符 (Copy Assignment Operator):
⑥ 用于将一个对象的值赋给另一个已存在的对象。通常形式为 ClassName& operator=(const ClassName&)
。
⑦ 编译器会隐式声明或定义拷贝赋值运算符。
⑧ 移动赋值运算符 (Move Assignment Operator) (C++11+):
⑥ 用于将一个对象(通常是右值,rvalue)的资源“移动”给另一个已存在的对象。通常形式为 ClassName& operator=(ClassName&&)
。
⑦ 编译器会隐式声明或定义移动赋值运算符。
标准还定义了如何控制这些特殊成员函数的生成,例如使用 = default
和 = delete
(C++11+)。
Appendix A2.5: this
指针 (this
Pointer)
标准规定,对于类的非静态成员函数,存在一个隐式的(implicit)、常量(constant)的 this
指针。在成员函数内部,this
指针指向调用该函数的对象实例。this
的类型通常是 ClassName* const
,对于 const
成员函数,类型通常是 const ClassName* const
。
Appendix A2.6: const
成员函数 (Const Member Functions)
标准允许在成员函数的参数列表后使用 const
关键字,表示该成员函数不会修改对象的可观测状态(observable state)。标准对 const
正确性(const correctness)有详细规定,包括常量对象只能调用常量成员函数。
Appendix A2.7: 友元 (Friends)
标准允许类声明友元函数(friend functions)或友元类(friend classes)。友元不是类的成员,但被授予访问该类 private
和 protected
成员的权限。友元机制提供了控制访问的一种手段,但应谨慎使用,因为它破坏了封装性(encapsulation)。
Appendix A2.8: 继承 (Inheritance)
继承是面向对象编程(OOP)的关键机制之一,标准详细描述了:
① 基类(Base Class)与派生类(Derived Class)的关系。
② 继承方式(Inheritance Access Specifiers) (public
, protected
, private
) 如何影响基类成员在派生类中的访问权限。
③ 多重继承(Multiple Inheritance):一个类可以从多个基类继承。
④ 虚继承(Virtual Inheritance):解决多重继承中的“菱形继承(diamond inheritance)”问题,确保共享的基类子对象只有一份。
Appendix A2.9: 多态 (Polymorphism)
标准通过虚函数(virtual functions)支持运行时多态(runtime polymorphism)。
① 虚函数:在基类中声明为 virtual
的成员函数。通过基类指针或引用调用虚函数时,实际执行的是运行时对象的类型所对应的函数版本。
② 纯虚函数(pure virtual functions)与抽象类(abstract classes):含有纯虚函数的类是抽象类,不能被实例化。它们定义了接口(interface),强制派生类提供具体实现。
③ 虚析构函数:如前所述,确保通过基类指针删除派生类对象时的正确行为。
④ override
和 final
说明符 (C++11+): 标准引入这两个说明符以提高代码清晰度和安全性。override
显式表明函数旨在重写基类虚函数;final
阻止函数被进一步重写或类被继承。
Appendix A2.10: 类模板 (Class Templates)
标准定义了类模板(class templates),允许创建泛型类,即类的定义可以参数化(parameterized by type, non-type, or template parameters)。
① 模板定义与实例化(Template Definition and Instantiation): 模板本身不是一个类型,而是类型的蓝图。通过提供模板参数,可以从模板实例化(instantiate)具体的类类型。
② 模板特化(Template Specialization): 标准允许为模板的特定参数组合提供定制的实现,包括完全特化(full specialization)和部分特化(partial specialization)。
③ 模板参数可以是类型(如 typename T
或 class T
)、非类型(如 int N
)、或其他模板。
Appendix A2.11: 异常处理与类 (Exception Handling and Classes)
标准关于异常处理(exception handling)的部分规定了异常的抛出(throwing)、捕获(catching)和传播(propagation)。与类相关的重要规则包括:
① 资源获取即初始化(RAII: Resource Acquisition Is Initialization)是一种重要的编程范式,标准库中的智能指针(smart pointers)是其典型应用,它们利用对象的构造和析构来管理资源。
② 标准对在构造函数或析构函数中抛出异常的行为有规定。通常不建议在析构函数中抛出异常,因为这可能导致程序终止或未定义行为(undefined behavior)。构造函数中抛出异常是允许的,但需要确保已获取的资源能够正确清理。
Appendix A2.12: 现代 C++ 中的类相关特性 (Class-Related Features in Modern C++)
C++11 及后续标准引入了许多改进,进一步丰富和完善了类特性:
① 移动语义(Move Semantics) (C++11+): 基于右值引用(rvalue references)实现,允许资源在对象之间高效转移,而非总是复制。
② = default
和 = delete
(C++11+): 精确控制编译器对特殊成员函数的隐式生成。
③ 类内成员初始化(In-class Member Initializers) (C++11+): 允许在类定义中直接为非静态数据成员提供默认初始值。
④ 委派构造函数(Delegating Constructors) (C++11+): 允许一个构造函数在其成员初始化列表中调用同类的另一个构造函数。
⑤ 继承构造函数(Inheriting Constructors) (C++11+): 允许派生类继承基类的构造函数。
⑥ 结构化绑定(Structured Bindings) (C++17+): 为解包(unpacking)类或结构体成员提供方便的语法。
⑦ Concepts(概念) (C++20+): 允许在模板声明中对模板参数进行更精确的约束,提高模板的可用性和错误提示。
⑧ Modules(模块) (C++20+): 改变了传统的头文件机制,影响类的可见性和接口导入/导出方式。
Appendix A3: 如何查阅 C++ 标准 (How to Consult the C++ Standard)
C++ 标准由国际标准化组织(ISO)发布。正式的标准文档是需要购买的。然而,有一些资源可以帮助开发者理解标准的内容:
⚝ 草案(Drafts): 在正式标准发布前会有多个草案版本。虽然草案不是最终标准,但它们通常与最终版本非常接近,并且可以在网上找到(例如,N4659 是 C++17 草案,N4860 是 C++20 草案等)。这些草案是学习标准细节的重要资源。
⚝ 权威书籍和教程: 许多优秀的 C++ 书籍(例如 Scott Meyers 的 Effective C++ 系列、Stanley B. Lippman 的 C++ Primer 等)都深度解读了标准中的概念和规则。本书本身也旨在成为这样一个资源。
⚝ 在线资源和社区: C++ 相关的网站(如 cppreference.com)提供了对标准库和语言特性的详细参考,通常会注明相关的标准版本和规则。C++ 社区论坛和邮件列表也是讨论标准细节的好地方。
查阅标准时,需要注意以下几点:
⚝ 版本: C++ 标准不断更新(C++98, C++03, C++11, C++14, C++17, C++20 等)。不同版本之间可能存在重要差异和新特性。务必参考与你使用的编译器和目标平台相符的标准版本。
⚝ 语言 vs. 库: 标准文档分为核心语言(Core Language)和标准库(Standard Library)两部分。类本身的定义属于核心语言范畴,而标准库中使用的类(如 std::vector
, std::string
, 智能指针等)则在标准库部分定义。
⚝ “行为(Behavior)”与“实现(Implementation)”: 标准主要规定了语言的可观察行为(observable behavior),而非底层的实现细节(例如虚函数的 vtable 实现、对象内存布局等)。虽然了解实现有助于理解行为,但标准的权威性在于其对行为的规范。
本附录是对 C++ 标准中关于类定义的极简摘要。它提供了一个基础框架,帮助你定位和理解更详细的标准文档。鼓励读者在学习过程中,针对特定的疑问或需要深入理解的概念,主动查阅更权威、更详细的标准资料。
Appendix B: 常见类相关的错误与陷阱 (Common Class-Related Errors and Pitfalls)
在使用 C++ 类进行编程时,开发者,尤其是初学者,常常会遇到一些常见的错误和陷阱。这些问题可能源于对 C++ 特性理解不够深入,或者是在复杂场景下遗漏了一些关键细节。本附录旨在列举并深入分析这些常见的类相关错误,帮助读者识别问题、理解原因,并掌握规避或解决这些陷阱的有效方法。无论您是初学 C++ 还是有一定经验,了解这些常见陷阱都能帮助您编写更健壮、更安全、更高效的代码。
B.1 浅拷贝与深拷贝错误 (Shallow vs. Deep Copy Errors)
これは最も常见的错误之一,尤其是当类中包含指针或管理动态分配的资源时。默认的拷贝构造函数(copy constructor)和拷贝赋值运算符(copy assignment operator)执行的是浅拷贝(shallow copy),即按成员逐位复制(bitwise copy)。对于基本类型成员,这通常没有问题。但对于指针成员,浅拷贝只会复制指针本身,而不是指针指向的数据。结果是多个对象拥有指向同一块内存区域的指针。
B.1.1 浅拷贝陷阱的现象 (Symptoms of Shallow Copy Pitfall)
① 内存泄漏(memory leak):当一个对象被销毁时,其析构函数可能释放了指针指向的内存。如果其他对象持有指向同一内存的指针,当这些对象被销毁时,它们会尝试再次释放这块已经释放过的内存,导致重复释放(double free)错误,通常会导致程序崩溃。
② 野指针(dangling pointer):当一个对象释放了共享的内存后,其他对象的指针就变成了野指针,访问这些指针会导致未定义行为(undefined behavior)。
③ 数据不一致:一个对象通过其指针修改了共享内存中的数据,会意外地影响到其他对象。
B.1.2 代码示例 (Code Example)
考虑一个简单的 String
类,它使用字符指针管理动态分配的字符串:
1
#include <cstring>
2
#include <iostream>
3
4
class String {
5
public:
6
char* data;
7
int length;
8
9
String(const char* str = "") {
10
length = strlen(str);
11
data = new char[length + 1];
12
strcpy(data, str);
13
std::cout << "Constructor called for: " << data << std::endl;
14
}
15
16
~String() {
17
std::cout << "Destructor called for: " << data << std::endl;
18
delete[] data; // 释放动态分配的内存
19
}
20
21
// 默认的拷贝构造函数和拷贝赋值运算符会导致浅拷贝!
22
};
23
24
int main() {
25
String s1("Hello");
26
String s2 = s1; // 调用默认拷贝构造函数 (浅拷贝)
27
28
// s1 和 s2 的 data 指针指向同一块内存!
29
30
return 0;
31
} // s2 析构,释放内存;s1 析构,尝试重复释放同一块内存,导致崩溃
在上面的例子中,s1
和 s2
的 data
指针指向同一块内存。当 main
函数结束时,s2
的析构函数被调用,delete[] data
释放了内存。接着 s1
的析构函数被调用,它再次尝试释放 data
指针指向的同一块内存,触发运行时错误。
B.1.3 解决方法:深拷贝 (Solution: Deep Copy)
为了避免浅拷贝带来的问题,当类管理动态资源时,通常需要实现深拷贝。这意味着在拷贝构造函数和拷贝赋值运算符中,不仅复制指针,还要复制指针指向的数据,为新对象分配独立的内存。
⚝ 实现拷贝构造函数:
1
#include <cstring>
2
#include <iostream>
3
4
class String {
5
public:
6
char* data;
7
int length;
8
9
String(const char* str = "") {
10
length = strlen(str);
11
data = new char[length + 1];
12
strcpy(data, str);
13
std::cout << "Constructor called for: " << data << std::endl;
14
}
15
16
~String() {
17
std::cout << "Destructor called for: " << (data ? data : "nullptr") << std::endl;
18
delete[] data;
19
data = nullptr; // 避免悬空指针
20
}
21
22
// 深拷贝构造函数 (Deep Copy Constructor)
23
String(const String& other) {
24
length = other.length;
25
data = new char[length + 1]; // 为新对象分配新内存
26
strcpy(data, other.data); // 复制数据内容
27
std::cout << "Copy Constructor called for: " << data << " from " << other.data << std::endl;
28
}
29
30
// 深拷贝赋值运算符 (Deep Copy Assignment Operator)
31
String& operator=(const String& other) {
32
std::cout << "Copy Assignment called for: " << (data ? data : "nullptr") << " from " << other.data << std::endl;
33
if (this == &other) { // 处理自我赋值 (self-assignment)
34
return *this;
35
}
36
37
delete[] data; // 释放当前对象原有的内存
38
39
length = other.length;
40
data = new char[length + 1]; // 为当前对象分配新内存
41
strcpy(data, other.data); // 复制数据内容
42
43
return *this;
44
}
45
46
// C++11 后的移动构造函数和移动赋值运算符也可以帮助优化资源管理
47
// 本节仅关注拷贝问题,移动语义将在后续章节讨论
48
};
49
50
int main() {
51
String s1("Hello");
52
String s2 = s1; // 调用深拷贝构造函数
53
54
String s3("World");
55
s3 = s1; // 调用深拷贝赋值运算符
56
57
// 现在 s1, s2, s3 都拥有独立的 data 内存副本
58
59
return 0;
60
}
实现深拷贝构造函数和赋值运算符,确保每个对象都拥有独立的资源副本,从而避免了内存共享问题。这是 C++ 中实现正确资源管理(resource management)的关键一步,尤其是在引入 RAII(Resource Acquisition Is Initialization)模式之前。
B.2 资源管理遗漏 (Resource Management Omissions)
与浅拷贝问题紧密相关的是资源管理不当。如果在类的构造函数中获取了资源(如动态内存、文件句柄、网络连接等),但没有在析构函数中正确释放,就会导致资源泄漏(resource leak)。反之,如果多次释放或使用已释放的资源,则会导致崩溃或未定义行为。
B.2.1 常见遗漏 (Common Omissions)
① 忘记释放内存: 在构造函数或成员函数中使用 new
分配了内存,但在析构函数中没有对应的 delete
或 delete[]
。
② 重复释放资源: 如同浅拷贝示例所示,多个指针指向同一资源,在析构时被多次释放。
③ 异常安全问题: 如果构造函数或某个成员函数在资源获取和释放之间抛出异常,可能导致资源未被释放。
B.2.2 代码示例 (Code Example)
1
#include <iostream>
2
#include <cstdio> // for FILE*
3
4
class FileManager {
5
FILE* file_handle;
6
char* buffer;
7
8
public:
9
FileManager(const char* filename) : file_handle(nullptr), buffer(nullptr) {
10
file_handle = fopen(filename, "r");
11
if (!file_handle) {
12
std::cerr << "Error opening file: " << filename << std::endl;
13
// 没有抛出异常,也没有释放已分配的资源(这里buffer还未分配)
14
// 如果在打开文件后分配了buffer,这里需要清理
15
}
16
// 假设这里会分配buffer
17
buffer = new char[1024];
18
std::cout << "FileManager constructed." << std::endl;
19
}
20
21
// ❌ 遗漏了析构函数来释放资源!
22
// ~FileManager() {
23
// if (file_handle) {
24
// fclose(file_handle);
25
// std::cout << "File closed." << std::endl;
26
// }
27
// delete[] buffer;
28
// std::cout << "Buffer deleted." << std::endl;
29
// }
30
};
31
32
int main() {
33
// 这个对象创建后,其管理的 FILE* 和 buffer 都不会在对象生命周期结束时被自动清理
34
FileManager fm("test.txt");
35
36
// 如果在构造函数中抛出异常
37
// FileManager fm_error("non_existent_file.txt");
38
// 如果构造函数分配了部分资源后抛出异常,未释放的资源就会泄漏
39
return 0;
40
}
在上面的例子中,FileManager
类获取了文件句柄和动态内存,但缺少析构函数来释放它们,这会导致资源泄漏。
B.2.3 解决方法:RAII 原则 (Solution: RAII Principle)
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 中管理资源的最佳实践。核心思想是将资源的生命周期绑定到对象的生命周期。在对象的构造函数中获取资源,并在析构函数中释放资源。当对象超出作用域或被销毁时,其析构函数会自动调用,无论是因为正常执行、函数返回还是异常发生,都能保证资源被正确清理。
⚝ 为 FileManager
添加析构函数:
1
#include <iostream>
2
#include <cstdio>
3
4
class FileManager {
5
FILE* file_handle;
6
char* buffer;
7
8
public:
9
FileManager(const char* filename) : file_handle(nullptr), buffer(nullptr) {
10
std::cout << "Constructing FileManager..." << std::endl;
11
file_handle = fopen(filename, "r");
12
if (!file_handle) {
13
// 在构造函数中获取资源失败时,最好抛出异常
14
delete[] buffer; // 清理已分配的资源 (如果buffer分配在fopen之后)
15
buffer = nullptr;
16
throw std::runtime_error("Failed to open file.");
17
}
18
buffer = new char[1024];
19
// 检查 new 是否成功,虽然现代C++中new失败默认抛出异常std::bad_alloc
20
std::cout << "FileManager constructed successfully." << std::endl;
21
}
22
23
// RAII: 在析构函数中释放资源
24
~FileManager() {
25
std::cout << "Destructing FileManager..." << std::endl;
26
if (file_handle) {
27
fclose(file_handle);
28
file_handle = nullptr;
29
std::cout << "File closed." << std::endl;
30
}
31
delete[] buffer;
32
buffer = nullptr; // 良好习惯,将指针置空
33
std::cout << "Buffer deleted." << std::endl;
34
}
35
36
// 为了完整的RAII,还需要实现拷贝/移动构造和赋值,遵循“Rule of Three/Five”
37
// 参见 B.1 和 B.6
38
};
39
40
int main() {
41
try {
42
FileManager fm("test.txt"); // 假设test.txt存在
43
// 使用fm对象...
44
} catch (const std::exception& e) {
45
std::cerr << "Caught exception: " << e.what() << std::endl;
46
} // fm 超出作用域,析构函数自动调用,释放资源
47
48
std::cout << "--- Trying to open non-existent file ---" << std::endl;
49
try {
50
FileManager fm_error("non_existent_file.txt");
51
} catch (const std::exception& e) {
52
std::cerr << "Caught exception: " << e.what() << std::endl;
53
} // 即使构造函数抛出异常,如果buffer已分配,析构函数不会被调用 (构造未完成)
54
// 这强调了在构造函数中出现错误时需要清理已分配部分资源的必要性,
55
// 或者更好地,使用智能指针/RAII wrapper来管理资源分配。
56
57
return 0;
58
}
对于动态内存管理,标准库提供的智能指针(smart pointers),如 std::unique_ptr
和 std::shared_ptr
,是实现 RAII 的典型例子和首选工具。它们在指针对象被销毁时自动释放所指向的内存。
B.3 继承体系中的陷阱 (Pitfalls in Inheritance Hierarchies)
继承是实现代码重用和多态的重要机制,但也引入了一些特有的陷阱。
B.3.1 对象切片 (Object Slicing)
当派生类对象被赋值或初始化给基类类型的对象时,派生类特有的部分会被“切掉”,只留下基类部分。这会丢失派生类的行为和数据。
⚝ 现象: 通过基类对象调用虚函数时,实际执行的是基类的版本,而不是期望的派生类版本。派生类特有的数据成员也会丢失。
⚝ 原因: 编译器在处理基类对象时,只分配基类对象所需的大小,无法容纳完整的派生类对象。
⚝ 代码示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
int base_data;
6
Base(int d) : base_data(d) {}
7
8
virtual void print() const { // 虚函数
9
std::cout << "Base data: " << base_data << std::endl;
10
}
11
};
12
13
class Derived : public Base {
14
public:
15
int derived_data;
16
Derived(int b, int d) : Base(b), derived_data(d) {}
17
18
void print() const override { // 重写虚函数
19
std::cout << "Derived data: " << derived_data << ", Base data: " << base_data << std::endl;
20
}
21
};
22
23
void process_object(Base obj) { // 参数是 Base 类型对象,会发生切片
24
obj.print(); // 调用 Base::print()
25
}
26
27
int main() {
28
Derived d(10, 20);
29
process_object(d); // 将 Derived 对象传递给 Base 对象参数,发生切片
30
31
Base b = d; // 拷贝赋值,发生切片
32
b.print(); // 调用 Base::print()
33
34
// 正确使用多态通常通过指针或引用
35
Base* bp = &d;
36
bp->print(); // 调用 Derived::print() (通过虚函数表)
37
38
Base& br = d;
39
br.print(); // 调用 Derived::print() (通过虚函数表)
40
41
return 0;
42
}
⚝ 解决方法: 避免将派生类对象直接赋值或初始化给基类类型的对象。如果需要利用多态性,应使用基类指针或基类引用来处理派生类对象。如果需要传递对象的副本,并且希望保留派生类的特性,可以考虑使用智能指针和工厂模式,或者实现虚拷贝(virtual clone)方法。
B.3.2 缺少虚析构函数 (Missing Virtual Destructor)
这是继承体系中另一个非常常见且危险的错误。如果在基类中使用 new
创建了派生类对象,然后通过基类指针 delete
这个对象,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这将导致派生类中分配的资源(如内存、文件句柄等)无法被释放,造成资源泄漏。
⚝ 现象: 通过基类指针删除派生类对象时,派生类的析构函数未被调用。
⚝ 原因: 当使用 delete
操作符删除指向对象的指针时,C++ 标准规定,如果指针类型是基类指针,并且基类析构函数不是虚函数,那么行为是未定义的(Undefined Behavior),但通常的结果是只调用静态绑定(static binding)确定的基类析构函数。
⚝ 代码示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
Base() { std::cout << "Base Constructor" << std::endl; }
6
// ❌ 非虚析构函数
7
~Base() { std::cout << "Base Destructor" << std::endl; }
8
};
9
10
class Derived : public Base {
11
public:
12
int* resource;
13
Derived() : resource(new int(10)) { std::cout << "Derived Constructor" << std::endl; }
14
~Derived() { // 派生类析构函数释放资源
15
std::cout << "Derived Destructor" << std::endl;
16
delete resource;
17
resource = nullptr;
18
}
19
};
20
21
int main() {
22
Base* bp = new Derived(); // 通过基类指针指向派生类对象
23
24
// ❌ 释放内存时只会调用 Base::~Base(), Derived::~Derived() 不会被调用
25
delete bp;
26
27
// 这里的 new int(10) 造成的内存泄漏!
28
29
return 0;
30
}
输出通常会是:
1
Base Constructor
2
Derived Constructor
3
Base Destructor
可以看到 Derived Destructor
没有被调用,new int(10)
分配的内存没有被释放。
⚝ 解决方法: 如果类可能被用作基类,并且通过基类指针删除派生类对象是可能的(通常在使用多态时就是如此),那么基类的析构函数必须声明为虚函数(virtual)。
1
#include <iostream>
2
3
class Base {
4
public:
5
Base() { std::cout << "Base Constructor" << std::endl; }
6
// ✅ 虚析构函数
7
virtual ~Base() { std::cout << "Base Destructor" << std::endl; }
8
};
9
10
class Derived : public Base {
11
public:
12
int* resource;
13
Derived() : resource(new int(10)) { std::cout << "Derived Constructor" << std::endl; }
14
~Derived() override { // 派生类析构函数 (使用 override 明确意图)
15
std::cout << "Derived Destructor" << std::endl;
16
delete resource;
17
resource = nullptr;
18
}
19
};
20
21
int main() {
22
Base* bp = new Derived();
23
24
// ✅ 现在会正确调用 Derived::~Derived(),然后再调用 Base::~Base()
25
delete bp;
26
27
return 0;
28
}
输出会是:
1
Base Constructor
2
Derived Constructor
3
Derived Destructor
4
Base Destructor
这样就保证了派生类资源的正确释放。
B.4 const
正确性问题 (const
Correctness Issues)
const
关键字在 C++ 中用于表示不变性,使用得当可以提高代码的清晰度、安全性和效率。然而,对 const
的误解或错误使用会导致编译错误或逻辑错误。```cpp
include
class MyClass {
private:
int value;
mutable int mutable_cache; // 使用 mutable 修饰符
public:
MyClass(int v) : value(v), mutable_cache(0) {}
1
// ✅ 常量成员函数,承诺不修改对象的状态 (value)
2
int getValue() const {
3
// value = 10; // ❌ 编译错误,不能修改非 mutable 成员
4
mutable_cache = 1; // ✅ 可以修改 mutable 成员
5
return value;
6
}
7
8
// 非常量成员函数
9
void setValue(int v) {
10
value = v; // ✅ 可以修改成员
11
}
};
int main() {
const MyClass const_obj(100);
// const_obj.setValue(200); // ❌ 编译错误,常量对象不能调用非常量成员函数
std::cout << "Value from const object: " << const_obj.getValue() << std::endl; // ✅ 可以调用常量成员函数
1
MyClass non_const_obj(300);
2
non_const_obj.setValue(400); // ✅ 非常量对象可以调用非常量成员函数
3
std::cout << "Value from non-const object: " << non_const_obj.getValue() << std::endl; // ✅ 非常量对象可以调用常量成员函数
4
5
return 0;
}
1
* **解决方法:**
2
* 任何不修改对象状态的成员函数都应该声明为常量成员函数(在函数签名末尾加上 `const`)。
3
* 理解 `const` 关键字的不同位置对指针、引用和成员函数的含义。
4
* 如果需要在常量成员函数中修改少数不影响对象逻辑状态(logical state)的成员(如缓存、互斥锁等),可以使用 `mutable` 关键字修饰这些成员。
5
* 常量对象只能调用常量成员函数。非常量对象既可以调用常量成员函数,也可以调用非常量成员函数。
6
7
### B.5 指针成员与生命周期 (Pointer Members and Lifetime)
8
9
当类的成员是指针时,管理指针指向的对象的生命周期 becomes crucial. 未能正确管理这些资源的生命周期会导致多种问题,包括内存泄漏、悬空指针和双重释放。
10
11
#### B.5.1 悬空指针 (Dangling Pointers)
12
13
当一个指针指向的内存已经被释放,但指针本身仍然存在,并且后续被使用时,它就变成了悬空指针。访问悬空指针会导致未定义行为。
14
15
* **现象:** 程序崩溃、数据损坏、难以预测的行为。
16
* **原因:**
17
* 指针指向的对象被删除或超出作用域。
18
* 浅拷贝导致多个指针指向同一块内存,其中一个对象释放了内存,其他指针成为悬空指针。
19
* **代码示例:**
20
21
```cpp
22
23
#include <iostream>
24
25
class PointerHolder {
26
public:
27
int* ptr;
28
29
PointerHolder(int val) : ptr(new int(val)) {}
30
31
~PointerHolder() {
32
std::cout << "Deleting ptr: " << (ptr ? *ptr : -1) << std::endl;
33
delete ptr;
34
// ❌ 没有将 ptr 置为 nullptr
35
}
36
37
// ❌ 默认的拷贝构造函数和赋值运算符 (浅拷贝)
38
};
39
40
int main() {
41
PointerHolder* ph1 = new PointerHolder(10);
42
PointerHolder* ph2 = ph1; // 浅拷贝指针值 (假设类没有自定义拷贝行为)
43
44
delete ph1; // 释放 ph1 指向的内存,ph2 成为悬空指针
45
46
// ❌ 现在使用 ph2 是危险的,会访问已释放的内存
47
// std::cout << *ph2->ptr << std::endl; // 未定义行为
48
49
// delete ph2; // ❌ 尝试双重释放!
50
51
return 0;
52
}
B.5.2 双重释放 (Double Free)
多次尝试释放同一块内存会导致双重释放错误,通常是致命的运行时错误。
- 现象: 程序崩溃,通常伴随特定的运行时错误信息。
- 原因:
- 浅拷贝导致多个对象析构时释放同一块内存(如 B.1 所述)。
- 手动管理指针时,不小心对同一个指针变量多次调用
delete
。 - 使用了已经释放的悬空指针。
- 代码示例: (同 B.1 和 B.5.1 的浅拷贝例子,以及手动多次 delete 的例子)
1
#include <iostream>
2
3
int main() {
4
int* p = new int(100);
5
delete p;
6
// delete p; // ❌ 双重释放!
7
p = nullptr; // 置空指针可以帮助避免双重释放 (如果delete nullptr是安全的)
8
// delete p; // ✅ delete nullptr 是安全的,不会崩溃
9
return 0;
10
}
B.5.3 解决方法:RAII 和智能指针 (Solution: RAII and Smart Pointers)
- 遵循 RAII 原则: 将资源的生命周期绑定到管理该资源的对象上。
- 实现深拷贝和移动语义: 如果类需要管理动态资源,必须实现深拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符,遵循“Rule of Three/Five”。
- 使用智能指针: 这是管理动态内存的最佳实践。
std::unique_ptr
提供独占所有权语义,保证同一块内存只有一个unique_ptr
管理,避免双重释放和许多悬空指针问题。std::shared_ptr
提供共享所有权语义,通过引用计数(reference count)管理资源的生命周期,只有当最后一个shared_ptr
销毁时才释放资源。 - 将指针置空: 在
delete
后将指针设置为nullptr
是一个良好的习惯,因为delete nullptr
是安全的空操作。但这并不能解决所有悬空指针问题,例如多个原始指针(raw pointer)指向同一块内存,其中一个被 delete。智能指针是更根本的解决方案。
1
#include <iostream>
2
#include <memory> // For smart pointers
3
4
class PointerHolder {
5
public:
6
std::unique_ptr<int> ptr; // 使用智能指针
7
8
PointerHolder(int val) : ptr(std::make_unique<int>(val)) {} // 使用 make_unique 创建智能指针管理的资源
9
10
// 不需要自定义析构函数,unique_ptr 会自动释放内存
11
// 不需要自定义拷贝构造函数和赋值运算符 (unique_ptr 默认是不可拷贝的)
12
// 可以实现移动构造函数和移动赋值运算符 (unique_ptr 支持移动)
13
14
// 如果需要拷贝语义,考虑使用 std::shared_ptr
15
// class SharedPointerHolder {
16
// public:
17
// std::shared_ptr<int> ptr;
18
// SharedPointerHolder(int val) : ptr(std::make_shared<int>(val)) {}
19
// // shared_ptr 支持拷贝和赋值,会自动管理引用计数
20
// };
21
};
22
23
int main() {
24
// 使用 unique_ptr
25
PointerHolder ph1(10);
26
// PointerHolder ph2 = ph1; // ❌ unique_ptr 默认不可拷贝
27
28
PointerHolder ph3(std::move(ph1)); // ✅ 通过移动语义转移所有权
29
// 现在 ph1.ptr 为 nullptr, ph3.ptr 管理原来的资源
30
std::cout << "ph3 value: " << *ph3.ptr << std::endl;
31
32
// ph3 超出作用域,ph3.ptr 释放内存
33
// ph1.ptr 是 nullptr,析构时安全
34
35
// 使用 shared_ptr
36
// SharedPointerHolder sph1(20);
37
// SharedPointerHolder sph2 = sph1; // ✅ shared_ptr 可以拷贝
38
// std::cout << "sph1 value: " << *sph1.ptr << ", sph2 value: " << *sph2.ptr << std::endl;
39
// sph1 和 sph2 都指向同一块内存,引用计数为 2
40
// sph2 超出作用域,引用计数减到 1
41
// sph1 超出作用域,引用计数减到 0,释放内存
42
43
return 0;
44
}
B.6 默认成员函数陷阱 (Default Member Function Pitfalls)
C++ 编译器会自动为类生成一些默认的成员函数,包括默认构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数(C++11 起)、移动赋值运算符(C++11 起)和析构函数。这些默认行为在许多简单情况下工作正常,但对于管理资源的类来说,默认行为通常是错误的浅拷贝或简单的成员逐位操作,可能导致 B.1 和 B.5 中描述的问题。
- 现象: 当类包含指针成员或管理其他资源时,使用默认生成的拷贝/赋值函数会导致浅拷贝。
- 原因: 编译器不知道你的类管理了特定的资源,它只知道如何按位复制内存。
- 代码示例: (同 B.1 的浅拷贝示例)
1
#include <cstring>
2
#include <iostream>
3
4
class BadString {
5
public:
6
char* data;
7
int length;
8
9
BadString(const char* str = "") {
10
length = strlen(str);
11
data = new char[length + 1];
12
strcpy(data, str);
13
}
14
15
~BadString() {
16
delete[] data;
17
}
18
19
// ❌ 没有自定义拷贝构造函数和拷贝赋值运算符,使用了默认的浅拷贝
20
21
// C++11 之后,因为自定义了析构函数,编译器不会再自动生成移动构造/赋值
22
// 如果不希望拷贝,可以显式禁用
23
// BadString(const BadString&) = delete;
24
// BadString& operator=(const BadString&) = delete;
25
};
26
27
int main() {
28
BadString s1("Hello");
29
BadString s2 = s1; // 调用默认拷贝构造函数 -> 浅拷贝
30
// 程序退出时会发生双重释放
31
32
// BadString s3("World");
33
// s3 = s1; // 调用默认拷贝赋值运算符 -> 浅拷贝,也可能导致资源泄露和双重释放
34
return 0;
35
}
- 解决方法:Rule of Three/Five/Zero (三/五/零法则)
- Rule of Three: 如果你为一个类自定义了析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么你可能需要自定义全部三个,以确保正确的资源管理和拷贝语义(深拷贝)。
- Rule of Five (C++11+): 如果你需要 Rule of Three,那么为了支持移动语义(提高效率),你通常还需要自定义移动构造函数和移动赋值运算符。
Rule of Zero: 最佳实践是,如果你的类不直接管理原始资源(raw resources),而是使用标准库提供的 RAII 类型(如智能指针
std::unique_ptr
,std::shared_ptr
,容器std::vector
,std::string
等),那么通常不需要自定义析构函数、拷贝构造/赋值、移动构造/赋值。编译器生成的默认版本就能正确地处理这些 RAII 成员(它们的默认行为是正确的)。这被称为 Rule of Zero,它鼓励使用标准库提供的工具来简化资源管理。使用
= default
和= delete
(C++11+):- 可以使用
= default
强制编译器生成默认的成员函数版本,即使自定义了其他特殊成员函数。 - 可以使用
= delete
显式禁用某个默认生成的成员函数,例如禁止拷贝一个独占资源的类。
1
#include <cstring>
2
#include <iostream>
3
#include <memory> // For unique_ptr
4
5
// 遵循 Rule of Zero
6
class GoodString {
7
public:
8
std::unique_ptr<char[]> data; // 使用智能指针管理内存
9
int length;
10
11
GoodString(const char* str = "") : length(strlen(str)), data(std::make_unique<char[]>(length + 1)) {
12
strcpy(data.get(), str);
13
std::cout << "Constructor called: " << data.get() << std::endl;
14
}
15
16
// unique_ptr 默认不可拷贝,但支持移动。
17
// 因为使用了 unique_ptr (RAII), 编译器生成的默认移动构造函数和移动赋值运算符是正确的。
18
// 编译器生成的默认析构函数会调用 unique_ptr 的析构函数,从而正确释放内存。
19
// 如果需要拷贝,可以显式禁用默认拷贝,或者使用 shared_ptr
20
21
// 显式禁用拷贝,强调独占性 (可选)
22
GoodString(const GoodString&) = delete;
23
GoodString& operator=(const GoodString&) = delete;
24
25
// 编译器会自动生成移动构造和移动赋值 (因为没有显式声明拷贝构造/赋值/析构)
26
// 但为了清晰起见,也可以显式 = default
27
// GoodString(GoodString&& other) = default;
28
// GoodString& operator=(GoodString&& other) = default;
29
30
31
~GoodString() {
32
std::cout << "Destructor called for: " << (data ? data.get() : "nullptr") << std::endl;
33
// unique_ptr 会自动释放内存,此处不需要 delete[] data;
34
}
35
};
36
37
int main() {
38
GoodString s1("Hello");
39
// GoodString s2 = s1; // ❌ 拷贝被禁用,编译错误
40
41
GoodString s3(std::move(s1)); // ✅ 调用默认生成的移动构造函数
42
// s1.data 现在是 nullptr,s3.data 管理原来的内存
43
std::cout << "s3 value: " << s3.data.get() << std::endl;
44
45
// s3 超出作用域,其 unique_ptr 释放内存
46
// s1 超出作用域,其 unique_ptr (nullptr) 安全析构
47
48
return 0;
49
}
遵循 Rule of Zero 并利用标准库提供的 RAII 类型,是避免资源管理相关陷阱的强大策略。
B.7 运算符重载陷阱 (Operator Overloading Pitfalls)
运算符重载使得我们可以为自定义类型定义运算符的行为, enhancing 代码的可读性和直观性。但是,不恰当或误导性的运算符重载可能导致混乱、错误或性能问题。
B.7.1 违反直觉的重载 (Counter-Intuitive Overloading)
- 现象: 重载的运算符行为与用户对该运算符的通常认知不符。
- 原因: 滥用运算符重载,没有遵循运算符的习惯用法(例如,重载
+
运算符使其执行减法)。 - 代码示例:
1
#include <iostream>
2
3
class Number {
4
int val;
5
public:
6
Number(int v) : val(v) {}
7
8
// ❌ 违反直觉的重载:+ 运算符执行减法
9
Number operator+(const Number& other) const {
10
std::cout << "Warning: Using overloaded + for subtraction!" << std::endl;
11
return Number(val - other.val);
12
}
13
14
int getValue() const { return val; }
15
};
16
17
int main() {
18
Number a(10), b(5);
19
Number c = a + b; // 用户期望 15,实际得到 5
20
std::cout << "Result: " << c.getValue() << std::endl;
21
return 0;
22
}
23
24
25
26
**解决方法:** 只在运算符的含义对你的类来说是清晰、直观且符合其在内置类型上的习惯用法时,才进行重载。如果行为差异很大,考虑使用命名函数代替。
27
28
B.7.2 返回值错误 (Incorrect Return Types)
29
30
符(如赋值运算符 `operator=`)需要返回一个引用 (`T&`) 以支持链式赋值 (`a = b = c;`)。其他运算符(如算术运算符 `operator+`)通常返回新创建的对象(by value)。返回类型错误会破坏运算符的正常使用方式。
31
32
**现象:** 无法进行链式赋值,或结果不符合预期。
33
**原因:** 对运算符重载的约定不熟悉。
34
**代码示例:**
35
36
```cpp
37
38
#include <iostream>
39
40
class MyValue {
41
int data;
42
public:
43
MyValue(int d) : data(d) {}
44
45
// ❌ 拷贝赋值运算符返回类型错误 (返回 void)
46
// void operator=(const MyValue& other) {
47
// data = other.data;
48
// }
49
50
// ✅ 正确的拷贝赋值运算符返回类型 (返回引用)
51
MyValue& operator=(const MyValue& other) {
52
data = other.data;
53
return *this; // 返回当前对象的引用
54
}
55
56
int getData() const { return data; }
57
};
58
59
int main() {
60
MyValue a(1), b(2), c(3);
61
// a = b = c; // 如果赋值运算符返回 void, 这行代码会编译错误
62
a = b; // 如果返回 void, 只有这部分能工作
63
64
std::cout << "a.getData(): " << a.getData() << std::endl;
65
return 0;
66
}
解决方法: 查阅 C++ 标准或可靠的 C++ 教程,了解各种运算符重载的约定,尤其是返回值类型。赋值运算符通常返回 T&
,算术运算符通常返回 T
,比较运算符通常返回 bool
,流运算符 (<<
, >>
) 通常返回流对象的引用 (ostream&
, istream&
)。
B.7.3 作为成员函数还是非成员函数重载 (Member vs. Non-Member Overloading)
符可以作为成员函数或非成员函数(通常是友元函数)重载。选择哪种方式取决于运算符的特性和是否需要访问类的私有成员。
现象: 无法实现某些运算符(如二元算术运算符,当左操作数不是类类型时),或需要不必要的友元声明。
原因: 不理解成员函数和非成员函数重载的区别。
示例: 对于二元运算符 a + b
,如果 a
是你的类类型,可以重载为成员函数 a.operator+(b)
。但如果需要支持 b + a
(其中 b
不是你的类类型),或者需要支持 ostream << obj
,就必须作为非成员函数重载。
代码示例:
1
#include <iostream>
2
3
class Point {
4
int x, y;
5
public:
6
Point(int x_=0, int y_=0) : x(x_), y(y_) {}
7
8
// 作为成员函数重载 +: 支持 Point + Point
9
Point operator+(const Point& other) const {
10
return Point(x + other.x, y + other.y);
11
}
12
13
// ❌ 无法作为成员函数重载 << 运算符,因为左操作数 (ostream) 不是 Point 类型
14
// std::ostream& operator<<(std::ostream& os) const { ... }
15
16
// 需要作为非成员函数重载 <<,通常声明为友元以访问私有成员
17
friend std::ostream& operator<<(std::ostream& os, const Point& p);
18
};
19
20
// 作为非成员函数重载 <<
21
std::ostream& operator<<(std::ostream& os, const Point& p) {
22
os << "(" << p.x << ", " << p.y << ")";
23
return os;
24
}
25
26
int main() {
27
Point p1(1, 2), p2(3, 4);
28
Point p3 = p1 + p2; // 使用成员函数重载的 +
29
std::cout << "p3 = " << p3 << std::endl; // 使用非成员函数重载的 <<
30
return 0;
31
}
解决方法: * 赋值运算符 (=
)、下标运算符 ([]
)、函数调用运算符 (()
) 和成员访问运算符 (->
) 必须作为成员函数重载。 * 复合赋值运算符 (+=
, -=
, etc.) 通常作为成员函数重载,以修改左操作数。 * 二元算术运算符 (+
, -
, *
, /
, etc.) 通常作为非成员函数重载,以便支持对称操作(左操作数不是类类型的情况),并且可以利用已有的复合赋值运算符来实现(a + b
可以实现为 temp = a; temp += b; return temp;
)。 * 流插入/提取运算符 (<<
, >>
) 必须作为非成员函数重载。
B.8 访问控制与友元滥用 (Misuse of Access Control and Friends)
lic,
protected,
private` 访问控制关键字是封装(encapsulation)的基础,用于限制类成员的可见性。友元(friend)机制允许非成员函数或类访问类的私有和保护成员,是封装的例外。滥用访问控制或友元会破坏封装性,降低代码的可维护性和灵活性。
现象: * 将过多成员声明为 public
,暴露了内部实现细节。 * 过度使用 friend
函数或 friend
类,导致类的内部结构被外部大量知晓和依赖。
原因: * 为了方便访问私有数据而牺牲封装。 * 没有找到更好的设计方式来处理类之间的协作。
代码示例:
1
#include <iostream>
2
3
class SensitiveData {
4
private:
5
int secret_value;
6
7
public:
8
SensitiveData(int v) : secret_value(v) {}
9
10
// ✅ 提供有限的 public 接口来间接访问/修改 private 数据
11
int getValue() const {
12
// 可以进行验证、日志等
13
return secret_value;
14
}
15
16
void setValue(int v) {
17
// 可以进行验证、通知等
18
secret_value = v;
19
}
20
21
// ❌ 声明一个与类强相关的外部函数为友元,打破封装
22
// friend void printSecret(const SensitiveData& data);
23
};
24
25
// ❌ 依赖友元来访问私有成员,与类紧密耦合
26
// void printSecret(const SensitiveData& data) {
27
// std::cout << "Secret value (via friend): " << data.secret_value << std::endl; // 直接访问私有成员
28
// }
29
30
int main() {
31
SensitiveData data(42);
32
std::cout << "Secret value (via public getter): " << data.getValue() << std::endl; // 通过 public 接口访问
33
// printSecret(data); // 如果 printSecret 是友元,这里可以调用
34
return 0;
35
}
解决方法: * 始终优先将成员声明为 private
。 * 只将那些构成类公共接口(public interface)的成员函数和数据成员声明为 public
。公共接口应该稳定,不随内部实现改变而频繁变动。 * protected
主要用于继承体系中,允许派生类访问基类的某些成员,但仍然对外部隐藏。 * 谨慎使用 friend
。友元关系破坏了封装,增加了类之间的耦合度。只有在非成员函数或另一个类与当前类有非常紧密的协作关系,并且通过公共接口实现起来非常麻烦或效率低下时,才考虑使用友元。流运算符重载通常是一个合理的友元使用场景。
B.9 临时对象与效率问题 (Temporary Objects and Efficiency Issues)
- 中,某些操作可能会创建临时对象(temporary objects)。虽然编译器有很多优化手段(如返回值优化 RVO/NRVO, copy elision)可以减少或消除临时对象的开销,但在某些情况下,临时对象的创建、拷贝/移动和销毁仍然会带来性能损失,尤其是对于大型或资源密集型对象。
现象: 代码看似简单,但在循环或频繁调用中可能导致意外的性能瓶颈。
原因: * 函数返回大型对象值。 * 不必要的拷贝(尤其是在没有移动语义或无法应用拷贝消除时)。 * 表达式求值过程中产生的临时对象。
代码示例:
1
#include <vector>
2
#include <iostream>
3
4
std::vector<int> createVector(int size) {
5
std::vector<int> v(size);
6
// 填充 v...
7
return v; // 返回大型对象值,可能产生临时对象 (但现代C++通常会优化)
8
}
9
10
void processVector(std::vector<int> v) { // 参数按值传递,会创建 v 的拷贝 (可能产生临时对象)
11
// 处理 v...
12
}
13
14
int main() {
15
// std::vector<int> myVec = createVector(100000); // 通常会发生返回值优化 (RVO/NRVO)
16
17
std::vector<int> sourceVec(100000);
18
// processVector(sourceVec); // ❌ 将 sourceVec 拷贝给 processVector 的参数 v,开销大
19
// 如果函数不需要修改传入的 vector,应按 const 引用传递
20
// 如果函数需要修改传入的 vector,且调用者不再需要原始 vector,应按值传递以启用移动语义 (C++11+)
21
22
return 0;
23
}
B.9.1 解决方法:引用、指针、移动语义和常量正确性 (Solution: References, Pointers, Move Semantics, and const Correctness)
按引用或指针传递对象: 对于大型对象,优先按引用或指针传递,以避免不必要的拷贝。 * 如果函数不修改对象,使用常量引用 (const T&
):void processVector(const std::vector<int>& v)
。 * 如果函数需要修改对象且这种修改应该反映给调用者,使用非常量引用 (T&
):void modifyVector(std::vector<int>& v)
。 * 使用指针通常用于可选参数、在堆上创建对象或与 C API 交互。
利用移动语义 (C++11+): 当一个对象的数据可以安全地从一个临时对象或即将销毁的对象“窃取”时,移动语义可以避免深拷贝。 * 函数返回大型对象时,编译器通常会执行返回值优化(RVO/NRVO),直接在目标位置构造对象,这比移动更高效。 * 按值传递参数时,如果传入的是右值(如临时对象或 std::move()
结果),会触发移动构造函数。
避免不必要的临时对象: 仔细编写表达式,有时可以避免创建临时对象。
pp
include
include
include // For std::move
// 优化后的 createVector,通常会触发返回值优化
std::vector
std::vector
std::cout << "Creating vector inside function, size: " << size << std::endl;
return v; // RVO/NRVO 可能在这里发生
}
// ✅ 按常量引用传递,避免拷贝
void processVectorByRef(const std::vector
std::cout << "Processing vector by const reference. Size: " << v.size() << std::endl;
// 不能修改 v
}
// ✅ 按值传递,但调用者可以决定是拷贝还是移动
void processVectorByValue(std::vector
std::cout << "Processing vector by value (possibly moved). Size: " << v.size() << std::endl;
// 可以修改 v (修改的是拷贝或移动后的副本)
}
int main() {
std::cout << "--- Testing createVector ---" << std::endl;
std::vector
std::cout << "--- Testing processVectorByRef ---" << std::endl;
processVectorByRef(myVec); // 按常量引用,无拷贝
std::cout << "--- Testing processVectorByValue ---" << std::endl;
// processVectorByValue(myVec); // ❌ 显式拷贝,开销大 (如果不想拷贝,使用move)
processVectorByValue(std::move(myVec)); // ✅ 显式移动,开销小
// 注意:移动后 myVec 进入有效但不确定状态,不应再依赖其内容
// processVectorByValue(createVector(50000)); // 传入临时对象,触发移动构造
return 0;
}
1
### B.10 析构函数中抛出异常 (Throwing Exceptions from Destructors)
2
3
C++ 标准明确规定,如果在析构函数执行过程中抛出异常,并且这个析构函数是在另一个异常处理过程中被调用的(例如,在栈展开时调用析构函数),那么程序会立即终止 (`std::terminate`)。这是因为 C++ 不支持同时处理两个未捕获的异常。
4
5
* **现象:** 在异常发生时的栈展开(stack unwinding)过程中,如果某个对象的析构函数抛出异常,程序会异常终止。
6
* **原因:** C++ 的异常处理机制无法安全地处理嵌套的异常抛出。析构函数在清理资源时被调用,而清理本身可能是因为另一个异常引起的。
7
* **代码示例:**
8
```cpp
9
10
#include <iostream>
11
#include <stdexcept>
12
13
class RiskyResource {
14
public:
15
RiskyResource() { std::cout << "Resource acquired." << std::endl; }
16
~RiskyResource() noexcept(false) { // 显式声明析构函数可能抛出异常 (C++11+)
17
std::cout << "Releasing resource..." << std::endl;
18
bool failure = true; // 模拟清理失败
19
if (failure) {
20
// ❌ 在析构函数中抛出异常是危险的!
21
// throw std::runtime_error("Cleanup failed!");
22
}
23
std::cout << "Resource released." << std::endl;
24
}
25
};
26
27
void risky_function() {
28
RiskyResource res;
29
// 模拟一个异常
30
// throw std::runtime_error("Something went wrong in risky_function!");
31
// 如果这里抛出异常,栈展开会调用 res 的析构函数
32
// 如果 res 的析构函数又抛出异常,程序 terminate
33
}
34
35
int main() {
36
try {
37
risky_function();
38
} catch (const std::exception& e) {
39
std::cerr << "Caught exception: " << e.what() << std::endl;
40
}
41
return 0;
42
}
解决方法: * 绝不在析构函数中抛出异常。 析构函数应该是非抛出(noexcept)的。 * 如果资源清理操作可能失败并需要报告错误,应该提供一个独立的函数(例如 close()
, release()
等)来执行清理,并允许这个函数抛出异常。用户需要在对象销毁前显式调用这个清理函数,并在 try-catch
块中处理可能的异常。 * 在析构函数中执行清理操作时,如果失败,应该记录错误(logging)而不是抛出异常,或者在内部默默处理失败。
pp
include
include
include // For fclose, FILE*
class SafeResource {
FILE* file_handle; // 模拟一个可能清理失败的资源
public:
SafeResource(const char* filename) : file_handle(nullptr) {
std::cout << "Resource acquired." << std::endl;
file_handle = fopen(filename, "w"); // 模拟资源获取
if (!file_handle) {
throw std::runtime_error("Failed to acquire resource.");
}
}
1
// ✅ 提供一个显式清理函数,允许抛出异常
2
void close() {
3
if (file_handle) {
4
std::cout << "Releasing resource (via close)..." << std::endl;
5
int result = fclose(file_handle); // 模拟清理操作,可能返回非零表示失败
6
file_handle = nullptr; // 即使失败,也要将句柄置空,防止二次清理
7
if (result != 0) {
8
throw std::runtime_error("Resource release failed!"); // 可以在这里抛出异常
9
}
10
std::cout << "Resource released (via close)." << std::endl;
11
}
12
}
13
14
// ✅ 析构函数不抛出异常
15
~SafeResource() noexcept {
16
std::cout << "Releasing resource (via destructor)..." << std::endl;
17
// 在析构函数中,即使清理失败也不要抛出异常,通常是记录日志或默默处理
18
if (file_handle) {
19
std::cerr << "Warning: Resource not explicitly closed, releasing in destructor." << std::endl;
20
int result = fclose(file_handle);
21
file_handle = nullptr;
22
if (result != 0) {
23
std::cerr << "Error: Resource release failed in destructor!" << std::endl;
24
// 这里不抛异常
25
}
26
}
27
std::cout << "Resource destructor finished." << std::endl;
28
}
29
30
// 为了完整的 RAII 和异常安全,需要 Rule of 5/0 (省略)
};
void use_resource(const char* filename) {
SafeResource res(filename); // 资源获取即初始化
// ... 使用 res ...
1
// ✅ 在对象生命周期结束前显式调用 close 来处理可能的清理异常
2
try {
3
res.close();
4
} catch (const std::exception& e) {
5
std::cerr << "Caught cleanup exception: " << e.what() << std::endl;
6
// 处理清理失败的情况
7
}
8
// 如果这里没有调用 close,析构函数会在退出作用域时被调用
} // res 超出作用域,析构函数被调用
int main() {
try {
use_resource("test_file.txt"); // 假设文件操作成功
} catch (const std::exception& e) {
std::cerr << "Caught acquisition exception: " << e.what() << std::endl;
}
1
std::cout << "\n--- Testing destructor release ---" << std::endl;
2
try {
3
// 创建一个对象,但不显式调用 close
4
SafeResource res2("test_file_2.txt");
5
// res2 超出作用域,析构函数执行清理
6
} catch (const std::exception& e) {
7
std::cerr << "Caught acquisition exception: " << e.what() << std::endl;
8
}
9
10
return 0;
}
1
### B.11 多重继承的复杂性 (Multiple Inheritance Complexity)
2
3
多重继承(Multiple Inheritance)允许一个类从多个基类继承特性。虽然它提供了一种强大的组合能力,但也引入了复杂的继承图和潜在的命名冲突。
4
5
* **现象:**
6
* 命名冲突:不同的基类拥有同名成员,派生类访问时产生歧义。
7
* 菱形继承问题(Diamond Problem):当一个类通过不同的路径多次继承同一个基类时,派生类中会有该基类成员的多个副本,访问时产生歧义。
8
* **原因:** 多重继承的复杂性增加了继承路径和成员来源的不确定性。
9
* **代码示例 (菱形继承):**
10
11
```cpp
12
13
#include <iostream>
14
15
class Base {
16
public:
17
int value;
18
Base(int v) : value(v) {}
19
// ❌ 可能会产生歧义的函数
20
void show() { std::cout << "Base value: " << value << std::endl; }
21
};
22
23
class Derived1 : public Base {
24
public:
25
Derived1(int v) : Base(v) {}
26
};
27
28
class Derived2 : public Base {
29
public:
30
Derived2(int v) : Base(v) {}
31
};
32
33
// Class MostDerived inherits from Derived1 and Derived2
34
// Each path brings a Base subobject
35
class MostDerived : public Derived1, public Derived2 {
36
public:
37
MostDerived(int v1, int v2) : Derived1(v1), Derived2(v2) {}
38
39
// ❌ 访问 value 会产生歧义:是 Derived1::value 还是 Derived2::value?
40
// int getValue() const { return value; } // 编译错误
41
42
// ❌ 访问 show() 会产生歧义:是 Derived1::show() 还是 Derived2::show()?
43
// void printValue() { show(); } // 编译错误
44
45
// 必须通过作用域解析运算符 :: 来消除歧义
46
int getDerived1Value() const { return Derived1::value; }
47
int getDerived2Value() const { return Derived2::value; }
48
void printDerived1Value() { Derived1::show(); }
49
void printDerived2Value() { Derived2::show(); }
50
};
51
52
int main() {
53
MostDerived md(10, 20);
54
// std::cout << md.value << std::endl; // 编译错误
55
// md.show(); // 编译错误
56
57
std::cout << "Derived1's Base value: " << md.getDerived1Value() << std::endl;
58
std::cout << "Derived2's Base value: " << md.getDerived2Value() << std::endl;
59
md.printDerived1Value();
60
md.printDerived2Value();
61
62
return 0;
63
}
64
65
66
67
**解决方法:虚继承 (Virtual Inheritance)**
68
* 使用虚继承(virtual inheritance)可以解决菱形继承问题,确保在多重继承路径中,共享的基类只有一个副本。
69
70
pp
71
72
#include <iostream>
73
74
class Base {
75
public:
76
int value;
77
Base(int v) : value(v) {}
78
void show() { std::cout << "Base value: " << value << std::endl; }
79
};
80
81
// Use virtual inheritance
82
class Derived1 : virtual public Base {
83
public:
84
// When using virtual inheritance, the most derived class is responsible for initializing the virtual base class
85
Derived1(int v) : Base(v) {} // This initialization is ignored by MostDerived if it also virtually inherits Base
86
};
87
88
// Use virtual inheritance
89
class Derived2 : virtual public Base {
90
public:
91
// This initialization is ignored by MostDerived if it also virtually inherits Base
92
Derived2(int v) : Base(v) {}
93
};
94
95
// MostDerived is now responsible for initializing the virtual Base
96
class MostDerived : public Derived1, public Derived2 {
97
public:
98
// Initialize the virtual Base here
99
MostDerived(int base_v, int d1_v, int d2_v)
100
: Base(base_v), // Initialize the virtual base
101
Derived1(d1_v), // Initialize non-virtual parts of Derived1 (if any)
102
Derived2(d2_v) // Initialize non-virtual parts of Derived2 (if any)
103
{
104
// Note: Derived1(d1_v) and Derived2(d2_v) constructors will still be called,
105
// but their initialization of Base(v) will be skipped because Base is virtual.
106
}
107
108
// Now accessing value and show() is unambiguous, as there's only one Base subobject
109
int getValue() const { return value; } // Access the single Base::value
110
void printValue() { show(); } // Access the single Base::show()
111
};
112
113
int main() {
114
MostDerived md(30, 10, 20); // Initializing the virtual Base with 30
115
116
// Now value and show() are unambiguous
117
std::cout << "MostDerived value: " << md.getValue() << std::endl; // Access the single Base::value (30)
118
md.printValue(); // Access the single Base::show()
119
120
// Note: The values 10 and 20 passed to Derived1(10) and Derived2(20) constructors are effectively ignored
121
// for initializing the virtual Base part when MostDerived initializes it.
122
// This highlights a common point of confusion with virtual inheritance initialization.
123
// Derived1 and Derived2 constructors are still called for any non-virtual parts.
124
125
return 0;
126
}
虚继承解决了菱形继承问题,但也增加了构造函数初始化的复杂性。在设计继承体系时,应仔细权衡多重继承带来的复杂性。有时,接口继承(通过纯虚函数和抽象类)结合成员对象(composition)是比多重继承更好的选择。
B.12 其他常见小陷阱 (Other Common Minor Pitfalls)
- 忘记默认构造函数: 如果你定义了任何带参数的构造函数,编译器就不会再为你生成默认构造函数。如果你需要一个无参数构造函数,需要显式定义它。
```cpp
class MyClass {
int data;
public:
MyClass(int d) : data(d) {} // 定义了带参构造函数
// ❌ 编译器不再生成 MyClass()
// MyClass obj; // 编译错误
// ✅ 显式定义默认构造函数
// MyClass() : data(0) {}
};
1
**在构造函数和析构函数中调用虚函数:** 在构造函数或析构函数中调用虚函数不会有多态行为,只会调用当前类版本的函数。这是因为在构造过程中,对象的类型还不完整;在析构过程中,派生类部分已经被销毁。
2
```cpp
3
4
class Base {
5
public:
6
Base() { print(); } // 在构造函数中调用虚函数
7
virtual ~Base() { print(); } // 在析构函数中调用虚函数
8
virtual void print() const { std::cout << "Base::print()" << std::endl; }
9
};
10
11
class Derived : public Base {
12
public:
13
Derived() : Base() {}
14
~Derived() override {}
15
void print() const override { std::cout << "Derived::print()" << std::endl; }
16
};
17
18
int main() {
19
Derived d; // 构造时输出 Base::print()
20
// d 销毁时,先调用 Derived 析构函数 (空),再调用 Base 析构函数,输出 Base::print()
21
return 0;
22
}
过度使用 new
和 delete
: 尽量使用栈对象、标准库容器和智能指针来避免手动内存管理。手动 new
和 delete
是资源管理错误的常见来源。
忽略编译警告: 编译器警告常常是潜在错误的信号,例如未使用的变量、隐式类型转换、可能的性能问题等。养成关注并解决警告的好习惯。
不理解隐式类型转换和构造函数: 单参数构造函数可能被用于隐式类型转换。如果这是意外的,可以使用 explicit
关键字禁止隐式转换。
1
class Number {
2
int value;
3
public:
4
// ❌ 允许从 int 到 Number 的隐式转换
5
// Number(int v) : value(v) {}
6
// ✅ 禁止隐式转换
7
explicit Number(int v) : value(v) {}
8
9
int getValue() const { return value; }
10
};
11
12
int main() {
13
Number n1 = 10; // ❌ 如果构造函数是 explicit,这里编译错误
14
Number n2(10); // ✅ 总是允许的直接初始化
15
Number n3 = Number(20); // ✅ 显式转换,允许
16
return 0;
17
}
Appendix C: 推荐阅读与参考文献 (Recommended Reading and References)
Appendix C1: 引言 (Introduction)
亲爱的读者,恭喜您深入学习了 C++ 类的方方面面。掌握 C++ 类及其面向对象特性是精通这门强大语言的关键一步。然而,编程世界的知识浩瀚无垠,标准持续演进,最佳实践也在不断更新。本书旨在提供一个全面而深入的知识框架,但更广阔的学习和实践仍然等待着您。
为了帮助您在 C++ 学习之路上走得更远、更扎实,本附录为您精心挑选了一些推荐的阅读材料和参考文献。这些资源涵盖了从 C++ 标准本身到经典著作、现代实践指南以及在线社区,它们将是您持续学习和提升的重要助力。无论您是初学者希望巩固基础,还是希望深入理解更高级的概念和设计模式,亦或是追踪最新的 C++ 标准动态,这些资源都能提供宝贵的洞见和知识。
记住,持续学习是成为一名优秀程序员的不二法门。利用好这些资源,不断挑战自己,通过实践来深化理解。📚✨
Appendix C2: C++ 标准文档与官方资源 (C++ Standard Documents and Official Resources)
理解 C++ 的权威来源莫过于其官方标准文档。虽然直接阅读标准文档可能枯燥且技术性强,但它们是解决歧义和深入理解语言规则的最终依据。此外,一些辅助性的官方资源也极具价值。
① C++ 标准文档 (ISO C++ Standard)
▮▮▮▮ⓑ 最新 C++ 标准草案 (Latest C++ Standard Draft): ISO C++ 标准委员会(ISO/IEC JTC1/SC22/WG21)会发布标准的草案。虽然不是最终发布版本,但它们反映了标准最新的进展和特性。可以在委员会的官方网站或相关的在线仓库找到这些草案。这是了解 C++ 最新特性的最直接途径。
▮▮▮▮ⓒ 已发布 C++ 标准 (Published C++ Standards): 历次发布的 C++ 标准版本,如 C++98, C++03, C++11, C++14, C++17, C++20, C++23 等。这些是语言的正式规范。通常需要购买才能获取最终发布的版本,但理解它们的不同版本是理解语言发展和特性的基础。
② cppreference.com
▮▮▮▮ⓑ 这是最常用的 C++ 语言和标准库的在线参考。它提供了详尽的语言特性解释、标准库组件(包括容器、算法、智能指针等)的文档,并通常包含示例代码。对于查阅特定语法、函数或类的信息,这是首选资源。
▮▮▮▮ⓒ 网址:https://cppreference.com/ (推荐使用英文版,内容更新更及时,中文版翻译可能滞后或不完整)。
③ C++ Core Guidelines
▮▮▮▮ⓑ 由 Bjarne Stroustrup(C++ 创建者)和 Herb Sutter(ISO C++ 标准委员会主席)等人发起和维护的一套现代 C++ 编码规范和最佳实践。它旨在帮助开发者编写更安全、更可靠、更易维护的代码。
▮▮▮▮ⓒ 网址:https://github.com/isocpp/CppCoreGuidelines
Appendix C3: 经典与基础读物 (Classic and Fundamental Reading)
这些书籍是 C++ 领域的奠基之作或被广泛认为是学习 C++ 的经典教材。它们提供了坚实的基础知识,对于理解 C++ 的设计哲学和核心概念至关重要。
① 《C++ 程序设计语言》(The C++ Programming Language) by Bjarne Stroustrup
▮▮▮▮ⓑ 这是由 C++ 创建者本人撰写的权威著作。最新的版本(第4版)涵盖了 C++11/14 的内容。它系统地介绍了 C++ 语言的各个方面,包括其历史、设计哲学和所有核心特性。
▮▮▮▮ⓒ 适合读者:所有级别的 C++ 开发者,特别是希望深入理解语言设计原理和全面掌握语言特性的读者。内容非常详尽,可以作为一本参考手册。
② 《C++ Primer》 by Stanley B. Lippman, Josée Lajoie, Barbara E. Moo
▮▮▮▮ⓑ 一本被广泛推荐的 C++ 入门和进阶教材。它以清晰易懂的方式讲解 C++ 语言,包含大量的示例代码和练习。最新的版本(第5版)涵盖了 C++11 标准。
▮▮▮▮ⓒ 适合读者:C++ 初学者和希望系统性地学习 C++ 基础和重要特性的开发者。结构清晰,循序渐进。
③ 《Effective C++》系列 (Effective C++ and More Effective C++) by Scott Meyers
▮▮▮▮ⓑ Scott Meyers 的 Effective 系列是 C++ 进阶学习的必读经典。它们以条款(Item)的形式,深入探讨了 C++ 编程中的各种陷阱、注意事项、最佳实践和优化技巧。
▮▮▮▮ⓒ 《Effective C++》:侧重于 C++ 的核心语言特性和 STL 的有效使用。
▮▮▮▮ⓓ 《More Effective C++》:补充了《Effective C++》中未涵盖的一些主题,如构造函数、析构函数、运算符重载、模板等的高级用法。
▮▮▮▮ⓔ 适合读者:已经掌握 C++ 基本语法,希望提升编程技能、写出更健壮、高效、可维护代码的中级和高级 C++ 开发者。
④ 《Effective Modern C++》 by Scott Meyers
▮▮▮▮ⓑ 这本书是 Scott Meyers Effective 系列的新成员,专注于 C++11 和 C++14 的新特性和最佳实践。它详细讲解了 auto、移动语义、右值引用、Lambda 表达式、并发等现代 C++ 的重要主题。
▮▮▮▮ⓒ 适合读者:希望学习和掌握现代 C++ 特性的开发者,特别是从 C++98/03 过渡到 C++11/14 的开发者。
Appendix C4: 面向对象设计与模式读物 (Object-Oriented Design and Patterns)
理解类的核心在于理解面向对象的设计思想。这些书籍侧重于如何设计良好的类和类之间的关系,以及常用的设计模式。
① 《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software) by Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (Gang of Four, GoF)
▮▮▮▮ⓑ 这是关于软件设计模式的开创性著作。它介绍了23个经典的面向对象设计模式,并给出了它们在 Smalltalk 和 C++ 中的实现示例。理解这些模式对于设计灵活、可扩展的类体系至关重要。
▮▮▮▮ⓒ 适合读者:希望学习面向对象设计模式、提升软件架构能力的中高级开发者。
② 《敏捷软件开发:原则、模式与实践》(Agile Software Development, Principles, Patterns, and Practices) by Robert C. Martin (Uncle Bob)
▮▮▮▮ⓑ 本书深入探讨了敏捷开发方法以及支持敏捷开发的面向对象设计原则(如 SOLID 原则)和设计模式。它提供了大量 C++ 和 Java 的示例代码来解释设计原则和模式的应用。
▮▮▮▮ⓒ 适合读者:希望学习如何将面向对象设计原则应用于实际软件开发,提高代码质量和可维护性的开发者。
③ 《面向对象分析与设计》(Object-Oriented Analysis and Design) by Grady Booch
▮▮▮▮ⓑ Grady Booch 是面向对象领域的先驱之一。本书系统地介绍了面向对象分析和设计的过程、方法和概念。虽然理论性较强,但对于理解面向对象建模和设计大型软件系统非常有帮助。
▮▮▮▮ⓒ 适合读者:希望深入学习面向对象方法论和软件系统设计的开发者和架构师。
Appendix C5: 特定主题与现代 C++ 读物 (Specific Topics and Modern C++ Reading)
随着 C++ 标准的不断发展,许多书籍专注于特定的现代特性或更高级的主题。
① 《C++ Template: The Complete Guide》 by David Vandevoorde, Nicolai M. Josuttis, Chris Scott
▮▮▮▮ⓑ 如果您需要深入理解 C++ 模板,这本书是权威指南。它全面且深入地讲解了模板的各个方面,包括模板元编程。
▮▮▮▮ⓒ 适合读者:需要大量使用或深入理解 C++ 模板的进阶和专家级开发者。
② 《C++ Concurrency in Action》 by Anthony Williams
▮▮▮▮ⓑ 现代 C++ 在并发和多线程方面取得了巨大进步。本书详细介绍了 C++ 标准库中用于多线程编程的工具和技术,以及相关的设计模式和注意事项。
▮▮▮▮ⓒ 适合读者:需要在 C++ 中进行并发和多线程编程的开发者。
③ 关于 C++ 新标准的书籍 (Books on New C++ Standards)
▮▮▮▮ⓑ 随着 C++17, C++20, C++23 的发布,市面上出现了许多专注于这些新标准的书籍,例如:
▮▮▮▮▮▮▮▮❸ 《C++ Standard Library》 by Nicolai M. Josuttis (涵盖最新标准库特性)
▮▮▮▮▮▮▮▮❹ 专注于 C++17/20/23 新特性的书籍,通常会在书名中包含标准版本号。
▮▮▮▮ⓔ 适合读者:希望学习和利用 C++ 最新标准的开发者。
Appendix C6: 在线资源与社区 (Online Resources and Communities)
互联网提供了丰富的 C++ 学习资源和交流平台。
① Stack Overflow (https://stackoverflow.com/)
▮▮▮▮ⓑ 这是一个问答社区,您可以在这里提问和搜索 C++ 相关的问题。许多有经验的 C++ 开发者会在这里分享知识和解决问题。在搜索问题时,通常能找到您遇到的问题的解答。
▮▮▮▮ⓒ 适合读者:所有级别的开发者,是解决具体编程问题的强大工具。
② C++ Subreddit (https://www.reddit.com/r/cpp)
▮▮▮▮ⓑ Reddit 上的 C++ 社区,这里有关于 C++ 新闻、特性讨论、问题求助、学习资源分享等内容。是一个活跃的交流平台。
▮▮▮▮ⓒ 适合读者:希望了解 C++ 社区动态、参与讨论的开发者。
③ C++ Blogs and Websites
▮▮▮▮ⓑ 有许多高质量的 C++ 博客和网站,例如:
▮▮▮▮▮▮▮▮❸ Sutter's Mill (Herb Sutter 的博客): https://herbsutter.com/
▮▮▮▮▮▮▮▮❹ Various authors' blogs (如 Bartłomiej Filipek, Raymond Chen 等)。
▮▮▮▮▮▮▮▮❺ C++ Super-FAQ: https://isocpp.org/wiki/faq (C++ 常见问题解答集)
▮▮▮▮ⓕ 适合读者:希望了解 C++ 专家的观点、学习特定主题的深入分析、追踪最新 C++ 发展的开发者。
④ 在线课程平台 (Online Course Platforms)
▮▮▮▮ⓑ Coursera, Udacity, Udemy, edX 等平台提供了许多 C++ 相关的在线课程,有些课程由知名大学或专家提供。
▮▮▮▮ⓒ 适合读者:喜欢通过视频课程学习的读者。
Appendix C7: 结语 (Conclusion)
以上推荐的资源只是 C++ 学习海洋中的一小部分浪花。最重要的是找到适合您的学习方式和感兴趣的主题,保持好奇心和实践热情。
记住,阅读是获取知识,而实践是将知识转化为技能。结合书籍的学习与实际项目的练习,不断编写代码、调试、重构,您将在 C++ 类的世界中游刃有余,并最终成为一名优秀的 C++ 开发者。
祝您学习顺利!🚀
Appendix D: 类相关的工具与调试技巧 (Tools and Debugging Techniques for Classes)
掌握 C++ 类的设计和实现是构建健壮、高效软件的基础。然而,在复杂的面向对象系统中,错误(bug)是难以避免的。本附录旨在介绍一系列用于分析、调试和测试 C++ 类的实用工具和技术,帮助开发者更有效地定位问题、优化性能并提升代码质量。无论是初学者在理解对象生命周期时遇到的困惑,还是专家在调试复杂的继承或多态行为时的挑战,正确的工具和技术都能极大地提高效率。我们将探讨常见的调试器使用、内存错误检测、性能分析、静态代码分析以及单元测试框架等。
Appendix D1: 使用调试器 (Using Debuggers)
调试器 (debugger) 是查找运行时错误最强大的工具之一。它们允许你暂停程序的执行,检查变量的值,并跟踪代码的执行路径。对于 C++ 类而言,调试器能帮助你深入了解对象的创建、状态变化、方法调用以及销毁过程。
Appendix D1.1: 基本调试流程 (Basic Debugging Workflow)
典型的调试流程包括设置断点、运行程序、单步执行和检查状态。
⚝ 设置断点 (Setting Breakpoints):
▮▮▮▮断点是在程序执行到某一点时强制暂停的位置。对于类,你通常会在以下地方设置断点:
▮▮▮▮⚝ 构造函数 (Constructors) 或析构函数 (Destructors) 的入口,以观察对象的创建或销毁时机和状态。
▮▮▮▮⚝ 关键成员函数 (Member Functions) 的开始处,以查看传入的参数或对象在调用前的状态。
▮▮▮▮⚝ 可能引发错误的特定代码行,例如访问成员变量后或某个条件判断处。
⚝ 运行程序 (Running the Program):
▮▮▮▮在调试模式下启动程序,程序会正常运行直到遇到第一个断点。
⚝ 单步执行 (Stepping Through Code):
▮▮▮▮程序暂停后,你可以使用单步执行命令逐行或逐语句地执行代码:
▮▮▮▮⚝ 单步进入 (Step Into): 进入当前行调用的函数内部。这对于理解成员函数的具体执行过程至关重要。
▮▮▮▮⚝ 单步跳过 (Step Over): 执行当前行,但不进入当前行调用的函数内部(如果该函数有调试信息)。用于跳过不关心的函数调用,如库函数。
▮▮▮▮⚝ 单步跳出 (Step Out): 执行完当前函数剩余的代码,然后暂停在调用该函数的下一行。用于快速退出当前嵌套的函数调用。
⚝ 检查状态 (Inspecting State):
▮▮▮▮程序暂停时,你可以检查各种程序状态信息:
▮▮▮▮⚝ 变量观察 (Watch Variables): 查看局部变量 (Local Variables)、全局变量 (Global Variables) 以及对象的成员变量 (Member Variables) 的当前值。对于类对象,调试器通常会显示其所有成员的名称和值。
▮▮▮▮⚝ 调用堆栈 (Call Stack): 查看当前函数是如何被调用的,即调用路径。这有助于理解程序的执行流程和函数之间的关系,特别是在处理继承和多态时。
▮▮▮▮⚝ 内存查看 (Memory Inspection): 在某些高级场景下,你可能需要直接查看对象在内存中的原始表示。
Appendix D1.2: 针对类的调试技巧 (Debugging Techniques Specific to Classes)
当调试 C++ 类时,可以利用调试器提供的更深入的功能。
⚝ 观察对象状态 (Observing Object State):
▮▮▮▮在调试器中,你可以展开类对象的变量,查看其所有 public
、protected
和 private
成员的值(取决于调试器的能力和编译选项)。这对于验证对象是否被正确初始化或在程序执行过程中保持期望的状态非常有用。
▮▮▮▮例如,如果你有一个 Person
类:
1
class Person {
2
private:
3
std::string name;
4
int age;
5
public:
6
Person(const std::string& n, int a) : name(n), age(a) {}
7
void celebrateBirthday() { age++; }
8
};
9
10
int main() {
11
Person p("Alice", 30);
12
// Set breakpoint here
13
p.celebrateBirthday();
14
return 0;
15
}
▮▮▮▮在 p.celebrateBirthday();
行设置断点,运行到此处暂停时,你可以观察 p
对象,看到 name
是 "Alice",age
是 30。单步进入 celebrateBirthday
函数,执行 age++;
后,再次观察 p
,会看到 age
变成了 31。
⚝ 条件断点 (Conditional Breakpoints):
▮▮▮▮设置只有在特定条件满足时才触发的断点。这在调试包含大量对象或循环处理对象集合的代码时非常有用。例如,你可以在某个对象的成员变量达到特定值时设置断点,或者当处理某个特定 ID 的对象时暂停。
▮▮▮▮例如,在一个对象数组中查找异常:
1
class Item {
2
public:
3
int id;
4
double value;
5
// ...
6
};
7
8
std::vector<Item> items;
9
// ... populate items ...
10
for (const auto& item : items) {
11
// Set conditional breakpoint: item.id == 101
12
if (item.value < 0) {
13
// Handle error
14
}
15
}
▮▮▮▮在 if (item.value < 0)
行设置条件断点,条件为 item.id == 101
,这样只有当循环处理 ID 为 101 的 Item 对象时才会暂停。
⚝ 查看调用堆栈 (Examining the Call Stack):
▮▮▮▮当程序崩溃或进入非预期状态时,调用堆栈显示了导致当前执行点的函数调用序列。在面向对象程序中,这能帮助你追踪方法调用的层次结构,理解是如何从一个对象的方法调用到另一个对象的方法,或者在继承体系中,了解实际调用的是哪个派生类的方法(特别是多态场景)。
⚝ 调试多态行为 (Debugging Polymorphic Behavior):
▮▮▮▮当使用基类指针或引用指向派生类对象并通过虚函数 (Virtual Functions) 调用方法时,调试器可以显示对象的实际运行时类型。这有助于确认多态是否按预期工作,以及调用的是基类还是派生类的函数实现。
⚝ 数据断点 (Data Breakpoints):
▮▮▮▮某些高级调试器支持数据断点,它会在某个特定内存地址的内容发生变化时触发。对于类对象,你可以对某个成员变量的内存地址设置数据断点,以了解是哪部分代码修改了它的值。
Appendix D1.3: 常用调试器 (Common Debuggers)
不同的开发环境和操作系统提供不同的调试器。
⚝ GDB (GNU Debugger): 强大的命令行调试器,广泛用于 Linux 和 macOS 环境。通过命令行指令进行操作,功能全面,支持远程调试。
⚝ LLDB: Clang/LLVM 项目的调试器,通常集成在 Xcode (macOS) 和其他 LLVM 工具链中,也支持命令行使用。性能和功能与 GDB 类似,在某些方面对 C++ 特性支持更好。
⚝ Visual Studio Debugger: 集成在 Microsoft Visual Studio IDE 中的图形化调试器,功能强大且易于使用,是 Windows 开发的主流选择。
⚝ 集成开发环境 (IDE) 内置调试器: 许多跨平台或特定平台的 IDE (如 CLion, VS Code 配合插件, Eclipse CDT) 都集成了 GDB 或 LLDB,提供了友好的图形界面进行调试。
Appendix D2: 内存错误检测工具 (Memory Error Detection Tools)
C++ 中手动管理内存(尽管现代 C++ 倾向于智能指针和 RAII)是常见的错误源,如内存泄漏 (Memory Leak)、使用已释放内存 (Use After Free) 或重复释放 (Double Free)。这些错误往往难以通过传统调试器发现,可能导致程序不稳定或崩溃。内存错误检测工具通过运行时插桩或模拟来检测这些问题。
Appendix D2.1: Valgrind
Valgrind 是一个强大的框架,用于构建动态分析工具,其中最常用的是 Memcheck,用于检测内存管理错误。它在运行时模拟 CPU,并在执行代码时进行检查,因此会显著降低程序执行速度,但能发现许多棘手的内存问题。
⚝ Memcheck 检测的常见类相关错误:
▮▮▮▮⚝ 内存泄漏 (Memory Leaks): 检测程序结束时仍然未释放的动态分配内存。对于类对象,这意味着用 new
创建的对象没有对应的 delete
调用,或者智能指针没有正确管理内存。
▮▮▮▮⚝ 使用未初始化内存 (Use of Uninitialised Memory): 使用没有赋值的变量的值。这可能发生在类对象的成员变量没有在构造函数或初始化列表中初始化时。
▮▮▮▮⚝ 使用无效指针 (Use of Invalid Pointers): 包括使用空指针、野指针、已释放的指针或超出数组边界的指针来访问对象成员或调用成员函数。
▮▮▮▮⚝ 越界读写 (Reading/Writing Off the End of Malloc'd Blocks): 访问动态分配内存块(如 new
创建的对象或数组)的边界之外。
⚝ 使用示例 (Usage Example):
▮▮▮▮在 Linux/macOS 命令行下:
1
valgrind --leak-check=full --show-leak-kinds=all ./your_program
▮▮▮▮运行后,Valgrind 会输出详细的内存错误报告,包括错误类型、发生的代码位置以及相关的堆栈信息,这有助于追踪是哪个类的哪个操作导致了问题。
Appendix D2.2: AddressSanitizer (ASan) 和其他 Sanitizers
AddressSanitizer (ASan) 是一种更现代、更快的内存错误检测工具,通常集成在 GCC 和 Clang 编译器中。它通过在编译时插入检查代码来工作,对程序性能的影响比 Valgrind 小得多,因此更适合在日常开发或测试中使用。
⚝ ASan 检测的常见类相关错误:
▮▮▮▮⚝ 越界访问 (Heap/Stack/Global Buffer Overflow/Underflow): 访问对象内存块之前或之后的位置。
▮▮▮▮⚝ 使用已释放内存 (Use After Free): 在对象被 delete
后仍然通过指针或引用访问其成员。
▮▮▮▮⚝ 重复释放 (Double Free): 对同一块内存进行两次 delete
操作。
▮▮▮▮⚝ 使用返回栈地址的指针 (Use After Return): 函数返回后,使用指向函数栈上对象的指针或引用(当对象已被销毁)。
⚝ 使用示例 (Usage Example):
▮▮▮▮使用 GCC 或 Clang 编译时添加 -fsanitize=address
选项:
1
g++ your_program.cpp -fsanitize=address -g -o your_program
2
./your_program
▮▮▮▮当程序发生内存错误时,ASan 会立即终止程序并输出详细的错误报告,指明错误类型、发生地址以及相关的调用堆栈。
⚝ 其他 Sanitizers: 除了 ASan,GCC/Clang 还提供了其他 Sanitizers,如 ThreadSanitizer (TSan) 用于检测数据竞争 (Data Races),MemorySanitizer (MSan) 用于检测使用未初始化内存(比 ASan 更严格),UndefinedBehaviorSanitizer (UBSan) 用于检测未定义行为 (Undefined Behavior)。这些工具在调试涉及多线程类、复杂内存操作或依赖未定义行为的代码时也非常有用。
Appendix D3: 性能分析工具 (Profiling Tools)
性能问题可能隐藏在频繁创建/销毁的对象、低效的成员函数实现或过度的拷贝操作中。性能分析器 (Profiler) 能够帮助你识别程序中占用 CPU 时间或内存最多的部分。
⚝ 常见性能问题与类:
▮▮▮▮⚝ 频繁的对象创建与销毁: 在循环中频繁地创建和销毁大对象可能导致性能瓶颈。
▮▮▮▮⚝ 低效的成员函数: 某个成员函数的实现算法效率低下。
▮▮▮▮⚝ 不必要的拷贝: 对象拷贝(特别是深拷贝)如果频繁发生,会消耗大量时间和内存。移动语义 (Move Semantics) 正是为了解决这个问题。
▮▮▮▮⚝ 虚函数调用开销: 虽然通常很小,但在极端性能敏感的紧密循环中,大量的虚函数调用可能成为瓶颈(但这通常不是首要关注点)。
⚝ 常用性能分析工具:
▮▮▮▮⚝ gprof: GCC 自带的简单性能分析工具,可以提供函数调用次数和各函数耗时。需要在编译和链接时添加 -pg
选项。
▮▮▮▮⚝ perf (Linux): Linux 原生的性能分析工具,功能强大,可以进行 CPU 采样、事件计数等。
▮▮▮▮⚝ VTune Profiler (Intel): Intel 提供的商业性能分析工具,功能非常强大,支持多种分析类型,包括 CPU、内存、线程等。
▮▮▮▮⚝ Visual Studio Profiling Tools: Visual Studio IDE 中集成的性能分析工具,提供了图形界面进行 CPU 使用、内存使用、函数耗时等分析。
⚝ 使用性能分析器:
▮▮▮▮性能分析器通常以两种方式工作:
▮▮▮▮⚝ 采样 (Sampling): 定期中断程序,记录程序计数器,从而了解程序大部分时间花费在哪些函数中。
▮▮▮▮⚝ 插桩 (Instrumentation): 在函数入口和出口插入代码,精确测量函数的执行时间。
▮▮▮▮使用性能分析器后,你会得到一个报告,显示各个函数(包括类成员函数)的耗时比例、调用次数等信息,从而定位性能热点 (hotspots)。
Appendix D4: 静态代码分析工具 (Static Code Analysis Tools)
静态代码分析 (Static Code Analysis) 是指在不执行程序的情况下检查源代码,以发现潜在的错误、不规范的代码风格或违反编程原则的地方。这些工具基于规则集进行检查,能发现一些编译器难以检测但可能导致运行时问题的模式。对于 C++ 类,静态分析工具可以帮助发现许多常见的设计和实现错误。
⚝ 静态分析工具能检测的常见类相关问题:
▮▮▮▮⚝ Rule of Three/Five/Zero 违反: 如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符,它通常也需要自定义其他几个(Rule of Three/Five)。现代 C++ 中,如果不需要自定义资源管理,则应该遵守 Rule of Zero,不定义任何析构函数/拷贝/移动操作,依赖编译器生成或使用智能指针。静态分析工具可以检查这些规则的潜在违反。
▮▮▮▮⚝ const 正确性问题: 在不应该修改对象状态的成员函数中遗漏 const
关键字,或者试图在 const
成员函数中修改非 mutable
成员变量。
▮▮▮▮⚝ 未初始化的成员变量: 类的数据成员在构造函数中没有被完全初始化。
▮▮▮▮⚝ 错误的继承或访问控制: 例如,通过公共继承暴露不应该暴露的内部细节。
▮▮▮▮⚝ 潜在的空指针解引用: 在访问对象指针的成员之前没有检查指针是否为空。
▮▮▮▮⚝ 虚函数使用不当: 例如,基类析构函数不是虚函数,可能导致通过基类指针删除派生类对象时出现问题。
⚝ 常用静态代码分析工具:
▮▮▮▮⚝ Clang-Tidy: 基于 Clang 的静态分析工具,提供了大量的检查项,可以与 Clang 编译器紧密集成。
⚝ Cppcheck: 另一个开源的静态分析工具,可以检测多种类型的错误。
⚝ PVS-Studio: 一款商业静态分析工具,功能强大,支持多种语言,对 C++ 的分析深度较高。
⚝ SonarQube: 一个代码质量管理平台,可以集成多种静态分析工具,提供代码质量报告和趋势分析。
⚝ 使用静态分析工具:
▮▮▮▮这些工具通常在编译过程之外运行,对源代码进行分析。它们可以集成到构建系统 (Build System) 或持续集成 (Continuous Integration, CI) 流程中,在代码提交前或构建时自动运行检查。使用静态分析工具可以早期发现问题,降低后期调试成本。
Appendix D5: 单元测试框架 (Unit Testing Frameworks)
单元测试 (Unit Testing) 是验证软件中最小可测试单元(通常是函数或类方法)行为的软件开发过程。通过为类的各个成员函数编写测试用例,你可以确保它们在各种输入和状态下都能按预期工作。单元测试是保证类正确性和可靠性的重要手段。
⚝ 单元测试框架的作用:
▮▮▮▮单元测试框架提供了一套机制来定义、组织和运行测试用例,并报告测试结果。它们通常提供断言 (Assertions) 宏,用于检查某个条件是否为真(例如,函数返回值是否等于期望值,或者对象成员变量是否具有特定值)。
⚝ 针对类的单元测试:
▮▮▮▮为类编写单元测试通常包括:
▮▮▮▮⚝ 测试构造函数: 确保对象在创建时被正确初始化,成员变量具有预期的初始值。
▮▮▮▮⚝ 测试成员函数: 为类的每个 public
成员函数编写测试用例,覆盖正常输入、边界条件和异常情况。
▮▮▮▮⚝ 测试对象状态: 在调用成员函数前后,检查对象的成员变量是否发生了预期的变化,或者是否保持了特定的不变式 (Invariants)。
▮▮▮▮⚝ 测试特殊成员函数: 对拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符等进行测试,确保它们正确处理资源。
▮▮▮▮⚝ 测试异常处理: 如果类的方法可能抛出异常,测试在抛出异常时类的状态是否保持一致,以及资源是否被正确清理(例如,使用 RAII)。
⚝ 常用 C++ 单元测试框架:
▮▮▮▮⚝ Google Test (GTest): 由 Google 开发,功能丰富,支持各种断言、测试 fixture(用于设置和清理测试环境,非常适合测试类)等。
▮▮▮▮⚝ Catch2: 一款轻量级、易于使用的测试框架,只需包含一个头文件即可使用,语法简洁。
▮▮▮▮⚝ Boost.Test: Boost 库中的单元测试模块,功能全面,支持多种测试组织方式。
⚝ 使用示例 (Usage Example - Google Test):
1
#include "gtest/gtest.h"
2
#include "YourClass.h" // 假设你的类定义在 YourClass.h 中
3
4
// 定义一个测试 fixture,用于为一组测试提供共享的测试对象和环境
5
class YourClassTest : public ::testing::Test {
6
protected:
7
// 在每个测试用例运行前被调用
8
void SetUp() override {
9
obj = new YourClass(some_initial_value);
10
}
11
12
// 在每个测试用例运行后被调用
13
void TearDown() override {
14
delete obj;
15
}
16
17
YourClass* obj;
18
};
19
20
// 使用 TEST_F 定义一个属于 YourClassTest fixture 的测试用例
21
TEST_F(YourClassTest, ConstructorInitializesCorrectly) {
22
// 检查对象是否被正确初始化
23
ASSERT_EQ(obj->getValue(), some_initial_value);
24
}
25
26
TEST_F(YourClassTest, MemberFunctionChangesState) {
27
obj->modifyValue(10);
28
// 检查成员函数是否按预期改变了对象状态
29
ASSERT_EQ(obj->getValue(), some_initial_value + 10);
30
}
31
32
// 你也可以定义独立的测试用例,不使用 fixture
33
TEST(AnotherClassTest, SomeFunctionWorks) {
34
AnotherClass another_obj;
35
// ... test another_obj methods ...
36
}