022 《C++ 面向对象技术深度解析与实践》
🌟🌟🌟本文由Gemini 2.5 Flash Preview 04-17生成,用来辅助学习。🌟🌟🌟
书籍大纲
▮▮ 1. 面向对象编程与 C++ 简介
▮▮▮▮ 1.1 编程范式演进:从面向过程到面向对象
▮▮▮▮ 1.2 面向对象编程 (OOP) 的核心概念
▮▮▮▮ 1.3 C++ 语言与 OOP
▮▮▮▮ 1.4 开发环境与基础知识回顾
▮▮ 2. 类和对象:OOP 的基石
▮▮▮▮ 2.1 类的定义与声明
▮▮▮▮ 2.2 对象的创建与生命周期
▮▮▮▮ 2.3 成员访问控制:public, private, protected
▮▮▮▮ 2.4 this 指针
▮▮▮▮ 2.5 const 成员函数和 const 对象
▮▮ 3. 构造函数与析构函数:对象的初始化与清理
▮▮▮▮ 3.1 构造函数的种类与作用
▮▮▮▮ 3.2 构造函数的初始化列表
▮▮▮▮ 3.3 析构函数的作用与调用时机
▮▮▮▮ 3.4 默认成员函数:何时生成与如何使用
▮▮ 4. 封装与信息隐藏:构建健壮的类
▮▮▮▮ 4.1 封装的原则与益处
▮▮▮▮ 4.2 通过访问修饰符实现封装
▮▮▮▮ 4.3 getter 和 setter 方法
▮▮▮▮ 4.4 接口与实现的分离
▮▮ 5. 继承:代码的复用与扩展
▮▮▮▮ 5.1 继承的基本概念
▮▮▮▮ 5.2 继承中的访问控制
▮▮▮▮ 5.3 构造函数与析构函数在继承中的行为
▮▮▮▮ 5.4 单一继承与多重继承
▮▮▮▮ 5.5 菱形继承与虚继承
▮▮ 6. 多态:实现灵活性的关键
▮▮▮▮ 6.1 多态的定义与分类
▮▮▮▮ 6.2 动态多态的实现:虚函数
▮▮▮▮ 6.3 纯虚函数与抽象类
▮▮▮▮ 6.4 override 和 final 关键字
▮▮▮▮ 6.5 多态与运行时类型信息 (RTTI)
▮▮ 7. 运算符重载与友元
▮▮▮▮ 7.1 运算符重载的概念与规则
▮▮▮▮ 7.2 成员函数形式的运算符重载
▮▮▮▮ 7.3 非成员函数形式的运算符重载
▮▮▮▮ 7.4 友元函数与友元类
▮▮ 8. 类模板:构建泛型类
▮▮▮▮ 8.1 类模板的定义与实例化
▮▮▮▮ 8.2 类模板的成员函数
▮▮▮▮ 8.3 类模板的特化
▮▮▮▮ 8.4 模板与继承、友元的关系
▮▮ 9. 资源管理与 RAII
▮▮▮▮ 9.1 传统资源管理的问题
▮▮▮▮ 9.2 RAII 原则:利用对象生命周期管理资源
▮▮▮▮ 9.3 智能指针:unique_ptr, shared_ptr, weak_ptr
▮▮▮▮ 9.4 自定义 RAII 类
▮▮ 10. 异常处理:构建健壮的程序
▮▮▮▮ 10.1 异常处理的基本机制
▮▮▮▮ 10.2 异常的抛出与捕获
▮▮▮▮ 10.3 异常与构造函数/析构函数
▮▮▮▮ 10.4 异常安全与 RAII
▮▮ 11. 深入探索 C++ OOP 的高级话题
▮▮▮▮ 11.1 RTTI (Runtime Type Information) 与动态类型转换
▮▮▮▮ 11.2 Casting 操作符:static_cast, const_cast, reinterpret_cast
▮▮▮▮ 11.3 C++ 标准库中的 OOP 应用示例
▮▮▮▮ 11.4 面向对象设计原则回顾 (SOLID 原则)
▮▮ 12. C++ OOP 设计模式与实践
▮▮▮▮ 12.1 设计模式概述
▮▮▮▮ 12.2 创建型模式 (Creational Patterns) 在 C++ 中的应用
▮▮▮▮ 12.3 结构型模式 (Structural Patterns) 在 C++ 中的应用
▮▮▮▮ 12.4 行为型模式 (Behavioral Patterns) 在 C++ 中的应用
▮▮▮▮ 12.5 结合案例分析设计模式的应用
▮▮ 13. 实战案例分析与高级实践
▮▮▮▮ 13.1 构建一个基于多态的图形绘制系统
▮▮▮▮ 13.2 利用 RAII 管理网络连接或文件句柄
▮▮▮▮ 13.3 使用智能指针处理复杂的对象生命周期
▮▮▮▮ 13.4 利用模板和 OOP 设计一个简单的容器
▮▮▮▮ 13.5 性能优化与 OOP
▮▮ 附录A: C++ 标准版本与 OOP 特性演进
▮▮ 附录B: 常见 C++ OOP 面试题解析
▮▮ 附录C: 术语对照表
▮▮ 附录D: 参考文献
1. 面向对象编程与 C++ 简介
欢迎来到 C++ 面向对象编程 (Object-Oriented Programming, OOP) 的世界!作为一门强大且广泛应用的编程语言,C++ 不仅继承了 C 语言的高效和底层控制能力,更通过引入面向对象的特性,为构建复杂、可维护的软件系统提供了有力的支持。本章将带领大家回顾编程思想的演进历程,理解面向对象编程的核心理念,初步认识 C++ 语言与 OOP 的关系,并简要回顾必要的 C++ 基础知识,为后续深入学习打下坚实的基础。🎯
1.1 编程范式演进:从面向过程到面向对象
程序的本质是数据和处理数据的逻辑。随着计算机技术的发展和软件规模的不断扩大,人们一直在探索更高效、更易于管理和维护的编程方法。编程范式 (Programming Paradigm) 正是指导我们如何组织代码、解决问题的一套思想和方法论。
① 早期编程范式
▮▮▮▮⚝ 机器码 (Machine Code) 与汇编语言 (Assembly Language):直接操作硬件,效率极高,但可读性差、开发效率低下,难以应对复杂任务。
▮▮▮▮⚝ 高级语言 (High-Level Language):如 Fortran, COBOL, BASIC 等,使用更接近自然语言的语法,提高了开发效率和可移植性。
② 面向过程编程 (Procedural Programming)
面向过程是一种较早且影响深远的编程范式。它的核心思想是:
▮▮▮▮ⓐ 将问题分解成一系列的步骤,然后用函数 (Function) 或过程 (Procedure) 来实现这些步骤。
▮▮▮▮ⓑ 数据与处理数据的函数是分离的。程序的主体是函数的顺序执行。
📜 特点:
▮▮▮▮⚝ 以函数为程序的基本单元。
▮▮▮▮⚝ 注重步骤的顺序执行和逻辑流程。
▮▮▮▮⚝ 数据在函数之间传递。
🤔 类比: 就像写一本食谱 (recipe)。食谱就是一系列按部就班的步骤(函数),数据就是食材。你需要一步一步地按照说明来操作食材,最终得到菜肴。
💔 局限性:
▮▮▮▮⚝ 代码重用性差: 函数往往是针对特定数据结构设计的,难以直接应用于不同的数据。
▮▮▮▮⚝ 可维护性差: 数据结构的变化可能影响到大量使用这些数据的函数,修改起来工作量大且容易出错。
▮▮▮▮⚝ 难以应对复杂系统: 随着程序规模增大,函数数量急剧增加,函数之间的调用关系变得错综复杂,难以理解和管理。
▮▮▮▮⚝ 数据安全性问题: 数据与函数分离,数据暴露在全局范围内,容易被非法访问和修改。
💡 为了克服面向过程编程在构建大型复杂系统时的诸多挑战,面向对象编程应运而生。
1.2 面向对象编程 (OOP) 的核心概念
面向对象编程 (Object-Oriented Programming, OOP) 是一种强大的编程范式,它试图以更贴近人类思维的方式来建模现实世界,从而解决软件开发的复杂性问题。OOP 的核心是将数据与处理数据的方法(行为)封装在一起,形成“对象”,并通过对象之间的协作来构建程序。
OOP 的核心理念可以概括为“三大支柱”:封装 (Encapsulation)、继承 (Inheritance) 和多态 (Polymorphism)。
① 封装 (Encapsulation)
封装是将数据(属性或成员变量)和操作这些数据的方法(行为或成员函数)捆绑在一起的机制。它创建一个“胶囊”,将数据和代码都包含在内。
📜 关键思想:
▮▮▮▮ⓐ 数据与行为的绑定: 一个对象包含了它自己的数据以及对这些数据进行操作的函数。
▮▮▮▮ⓑ 信息隐藏 (Information Hiding): 隐藏对象的内部实现细节,只对外提供有限的、清晰的接口 (Interface)。外部只能通过这些接口与对象交互,而无法直接访问或修改对象的内部状态。
🤔 类比: 想象一辆汽车 🚗。汽车是一个对象。它的数据包括颜色、速度、油量等;它的行为包括启动、加速、刹车、转弯等。作为驾驶员,你通过方向盘、刹车踏板、油门等接口与汽车交互,而不需要知道引擎内部是如何工作的。汽车的内部构造被“封装”和“隐藏”了。
💪 益处:
▮▮▮▮⚝ 提高安全性: 防止外部代码随意访问和修改内部数据,保护数据完整性。
▮▮▮▮⚝ 降低复杂度: 使用者只需要关心对象提供的接口,无需了解内部实现。
▮▮▮▮⚝ 增强可维护性: 修改对象的内部实现不会影响到外部使用其接口的代码,只要接口不变。
▮▮▮▮⚝ 提高模块化: 每个对象都是一个相对独立的模块,易于理解和测试。
在 C++ 中,类 (Class) 是实现封装的基本单位。
② 继承 (Inheritance)
继承是一种允许我们基于现有类(称为 基类 (Base Class) 或父类)创建新类(称为 派生类 (Derived Class) 或子类)的机制。派生类继承了基类的属性和行为,并可以添加新的属性和行为,或者修改继承来的行为。
📜 关键思想:
▮▮▮▮ⓐ 代码复用: 派生类无需重新编写基类已经实现的功能。
▮▮▮▮ⓑ 建立层次关系: 继承体现了对象之间的“is-a”关系。例如,“猫是一种动物”,“轿车是一种汽车”。
🤔 类比: 想象生物界的继承。孩子继承了父母的某些特征。或者,一个“电动汽车”类可以继承自“汽车”类,它拥有汽车的基本特征(有轮子、能行驶),同时也有自己特有的属性(电池容量)和行为(充电)。
💪 益处:
▮▮▮▮⚝ 提高代码复用率: 避免重复编写相似的代码。
▮▮▮▮⚝ 易于扩展: 可以通过创建派生类来增加新的功能,而无需修改现有基类代码(符合“开放封闭原则”的一部分思想)。
▮▮▮▮⚝ 支持多态: 继承是实现动态多态的基础之一。
③ 多态 (Polymorphism)
多态意味着“多种形态”。在 OOP 中,多态允许我们使用统一的接口来处理不同类型的对象。具体来说,基类指针或引用可以指向派生类的对象,并且通过这个指针或引用调用虚函数 (Virtual Function) 时,实际执行的是派生类中重写的函数版本。
📜 关键思想:
▮▮▮▮ⓐ 同名不同行为: 不同类的对象对同一个方法调用可以有不同的响应。
▮▮▮▮ⓑ 灵活性与通用性: 可以在不知道具体对象类型的情况下,通过基类接口进行操作。
📦 分类:
▮▮▮▮ⓐ 静态多态 (Static Polymorphism) 或称编译时多态:在程序编译阶段确定调用哪个函数。例如:
▮▮▮▮▮▮▮▮❷ 函数重载 (Function Overloading):同一个作用域内,函数名相同但参数列表不同。
▮▮▮▮▮▮▮▮❸ 运算符重载 (Operator Overloading):赋予运算符新的含义以操作自定义类型。
▮▮▮▮▮▮▮▮❹ 模板 (Template):实现泛型编程,可以在编译时根据类型生成特定的代码。
▮▮▮▮ⓔ 动态多态 (Dynamic Polymorphism) 或称运行时多态:在程序运行阶段确定调用哪个函数。主要通过虚函数和基类指针/引用实现。
🤔 类比: 想象一个“发出声音”的指令。对猫发出“发出声音”的指令,它会“喵喵”叫;对狗发出同样的指令,它会“汪汪”叫。指令(接口)是相同的,但不同对象(猫、狗)的响应行为是不同的。在程序中,你可能有一个动物指针,它可以指向猫或狗,当你调用 animal->makeSound()
时,实际执行的是猫或狗各自的 makeSound()
方法。
💪 益处:
▮▮▮▮⚝ 提高代码的通用性: 可以编写处理基类对象的代码,而这些代码能自动适用于所有派生类对象。
▮▮▮▮⚝ 易于扩展: 增加新的派生类时,无需修改处理基类对象的现有代码。
▮▮▮▮⚝ 实现接口与实现的分离: 通过基类定义接口,派生类提供不同的实现。
封装、继承、多态是 OOP 的三大基石,它们相互配合,共同构建了强大的面向对象编程体系。
1.3 C++ 语言与 OOP
C++ 语言由 Bjarne Stroustrup 在贝尔实验室开发,最初被称为 "C with Classes",意在为 C 语言增加面向对象的特性。随后,它不断发展,加入了更多强大的功能,成为一门支持多种编程范式(包括面向过程、面向对象、泛型编程 (Generic Programming))的通用编程语言。
① C++ 如何支持 OOP
C++ 通过以下关键特性直接支持面向对象编程:
▮▮▮▮⚝ 类 (Class) 和对象 (Object):提供了 class
关键字来定义类,通过类来创建对象。
▮▮▮▮⚝ 成员访问控制 (Member Access Control):使用 public
, private
, protected
关键字来实现封装和信息隐藏。
▮▮▮▮⚝ 构造函数 (Constructor) 和析构函数 (Destructor):用于对象的初始化和资源清理,支持 RAII (Resource Acquisition Is Initialization) 惯用法。
▮▮▮▮⚝ 继承 (Inheritance):提供继承语法(:
符号)来定义派生类。支持单一继承和多重继承 (Multiple Inheritance)。
▮▮▮▮⚝ 虚函数 (Virtual Function):通过 virtual
关键字实现动态多态。
▮▮▮▮⚝ 运算符重载 (Operator Overloading):允许类类型的对象使用标准的运算符。
▮▮▮▮⚝ 友元 (Friend):允许非成员函数或类访问类的私有或保护成员(一种打破封装的机制,需谨慎使用)。
▮▮▮▮⚝ 模板 (Template):虽然主要用于泛型编程,但类模板允许创建通用的类,与 OOP 结合紧密。
② C++ 的发展历程与 OOP 特性
C++ 语言随着时间的推移不断演进,标准化过程极大地推动了其发展和普及。重要的标准版本包括:
▮▮▮▮⚝ C++98/C++03:确立了语言的基础框架,包含了面向对象的核心特性。标准模板库 (Standard Template Library, STL) 在此阶段得到广泛应用,STL 本身也是泛型编程与 OOP 结合的典范。
▮▮▮▮⚝ C++11:一个重要的里程碑版本,引入了大量新特性,如自动类型推导 (auto
)、基于范围的 for 循环、Lambda 表达式、右值引用 (Rvalue Reference) 和移动语义 (Move Semantics)、智能指针 (Smart Pointer) 等,这些都极大地提升了 C++ 在现代软件开发中的效率和安全性,特别是智能指针和移动语义对资源管理和性能优化有着重要影响,与 OOP 中的对象生命周期管理紧密相关。
▮▮▮▮⚝ C++14, C++17, C++20:后续版本继续在语言特性、标准库等方面进行改进和增强,许多特性进一步完善了 C++ 对现代编程范式的支持。
理解 C++ 的发展历程有助于我们更好地理解一些旧有的编程习惯(如手动内存管理)为何逐渐被更现代、更安全的做法(如 RAII 和智能指针)取代。
C++ 强大的 OOP 支持使其成为开发操作系统、游戏引擎、高性能计算、嵌入式系统以及各种大型复杂软件的首选语言之一。
1.4 开发环境与基础知识回顾
在深入学习 C++ 的 OOP 特性之前,确保你已经具备了必要的 C++ 基础知识,并搭建好了开发环境。本节将进行简要回顾。
① 开发环境
你需要一套 C++ 开发工具链,通常包括:
▮▮▮▮⚝ 编译器 (Compiler):将 C++ 源代码转换成机器码。常见的编译器有:
▮▮▮▮▮▮▮▮❶ GCC (GNU Compiler Collection):跨平台,开源。
▮▮▮▮▮▮▮▮❷ Clang (LLVM):跨平台,开源,以编译速度快和诊断信息友好著称。
▮▮▮▮▮▮▮▮❸ MSVC (Microsoft Visual C++):Windows 平台,集成在 Visual Studio 中。
▮▮▮▮⚝ 构建系统 (Build System):管理项目的编译和链接过程。常见的有 Make, CMake, MSBuild 等。
▮▮▮▮⚝ 编辑器或集成开发环境 (Editor/Integrated Development Environment, IDE):用于编写、调试和管理代码。例如 VS Code (Visual Studio Code), Visual Studio, CLion, Eclipse CDT 等。
确保你的环境中安装了 C++ 编译器,并且配置了相应的路径,可以在命令行或者通过 IDE 调用编译器。
② C++ 程序的编译与链接过程
一个 C++ 源代码文件 (.cpp) 变成可执行程序大致要经过以下几个阶段:
▮▮▮▮ⓐ 预处理 (Preprocessing):处理以 #
开头的指令,如 #include
(包含头文件)、#define
(宏定义) 等。生成.i
文件。
▮▮▮▮ⓑ 编译 (Compilation):将预处理后的 .i
文件转换成汇编代码。生成 .s
文件。
▮▮▮▮ⓒ 汇编 (Assembly):将汇编代码转换成机器码,生成目标文件 (Object File)。在 Windows 上通常是 .obj
文件,在 Linux 上通常是 .o
文件。目标文件包含机器码以及符号表 (Symbol Table)。
▮▮▮▮ⓓ 链接 (Linking):将一个或多个目标文件以及所需的库文件 (Library File) 链接起来,生成最终的可执行文件 (Executable File)。链接器解析符号引用,将各个模块的代码和数据组合在一起。
理解这个过程有助于解决编译和链接阶段出现的错误。
③ 基础 C++ 语法回顾
在学习 OOP 之前,建议读者熟悉以下 C++ 基础知识:
▮▮▮▮⚝ 基本数据类型 (Basic Data Types):整型 (int
, short
, long
, long long
)、浮点型 (float
, double
)、字符型 (char
)、布尔型 (bool
) 等。
▮▮▮▮⚝ 变量 (Variable) 与常量 (Constant):变量的声明、定义和初始化,常量的定义 (const
)。
▮▮▮▮⚝ 运算符 (Operator):算术运算符 (+
, -
, *
, /
, %
)、关系运算符 (==
, !=
, <
, >
, <=
, >=
)、逻辑运算符 (&&
, ||
, !
)、赋值运算符 (=
, +=
, -=
等)、位运算符等。
▮▮▮▮⚝ 控制流程 (Control Flow):
▮▮▮▮▮▮▮▮❶ 条件语句 (Conditional Statements):if
, else if
, else
, switch
。
▮▮▮▮▮▮▮▮❷ 循环语句 (Loop Statements):for
, while
, do...while
。
▮▮▮▮⚝ 函数 (Function):函数的定义、声明、调用,参数传递(按值传递、按引用传递、按指针传递),返回值。
▮▮▮▮⚝ 数组 (Array) 和字符串 (String):数组的声明、初始化、访问,C 风格字符串 (char[]
) 和 C++ 风格字符串 (std::string
)。
▮▮▮▮⚝ 指针 (Pointer) 和引用 (Reference):指针的声明、解引用 (*
)、地址运算符 (&
),引用 (Reference) 的概念和使用。指针和引用在 C++ 的 OOP 中,尤其在多态和资源管理方面扮演着重要角色。
▮▮▮▮⚝ 结构体 (Struct):与类类似,但在 C++ 中默认成员是公有的。
▮▮▮▮⚝ 命名空间 (Namespace):用于组织代码,避免命名冲突。
如果对上述基础知识感到生疏,建议先回顾相关的 C++ 入门教程或书籍。本书后续章节将假定读者已经掌握这些基础。
本章作为本书的开篇,为大家描绘了面向对象编程的蓝图,并将其与 C++ 语言联系起来。从下一章开始,我们将正式进入 C++ 面向对象的具体技术细节的学习,从基石——类和对象——开始,逐步深入探索封装、继承、多态及其相关的高级特性。请准备好你的开发环境,让我们一同踏上这段精彩的学习旅程吧!🚀
2. 类和对象:OOP 的基石
欢迎来到本书的第二章。在上一章,我们回顾了编程范式的演进,并初步了解了面向对象编程 (OOP) 的核心思想以及 C++ 语言作为多范式语言对 OOP 的支持。本章将深入探索 C++ 中实现 OOP 最基本、最核心的构建块:类 (Class) 和 对象 (Object)。我们将学习如何定义自己的数据类型——类,如何基于类创建具体的实例——对象,以及如何控制类成员的访问权限,理解对象自身的引用(this
指针)以及如何使用 const
关键字来保证对象状态的不可变性。掌握这些概念是深入学习 C++ OOP 技术的基石。
2.1 类的定义与声明
编程语言中的 类型 (Type) 定义了数据的存储方式、表示范围以及可以在其上执行的操作。在面向过程编程中,我们主要使用内置类型 (Built-in Type) 或结构体 (Struct) 来组织数据。面向对象编程则引入了“类”的概念,允许我们创建抽象数据类型 (Abstract Data Type, ADT),将数据(成员变量)和操作数据的方法(成员函数)捆绑在一起。类是对象的蓝图或模板。
2.1.1 类的基本结构
在 C++ 中,使用 class
关键字来定义一个类。类的定义通常包含成员变量 (Member Variable) 和成员函数 (Member Function)。
1
// 这是一个简单的类的定义
2
class MyClass {
3
public: // 访问修饰符 (Access Specifier)
4
// 成员变量 (Member Variable) - 通常用于存储对象的状态
5
int data;
6
7
// 成员函数 (Member Function) - 通常用于操作对象的状态或提供功能
8
void setData(int value) {
9
data = value;
10
}
11
12
int getData() const { // const 成员函数 (const Member Function)
13
return data;
14
}
15
}; // 注意类定义后的分号
解释:
⚝ class MyClass { ... };
:这是类定义的语法。MyClass
是类的名称。
⚝ 花括号 {}
内是类的体 (Class Body),包含了类的成员。
⚝ public:
是一个访问修饰符 (Access Specifier),它指定了其后的成员可以被类外部的代码访问。还有 private
和 protected
两种访问修饰符,我们将在后续章节详细讨论它们的作用。
⚝ int data;
:这是一个成员变量的声明。它定义了类的一个数据成员,每个该类的对象都会拥有一个自己的 data
副本。
⚝ void setData(int value) { ... }
:这是一个成员函数的定义。它定义了类的行为或操作。setData
函数用于设置 data
成员的值。
⚝ int getData() const { ... }
:这是另一个成员函数的定义。getData
函数用于获取 data
成员的值。这里的 const
关键字表示这是一个 const
成员函数,它承诺不会修改对象的状态(即不会修改任何非 mutable
的成员变量)。
2.1.2 成员变量 (Member Variable)
成员变量也称为数据成员 (Data Member),它们定义了类对象的属性或状态。在类定义中,成员变量的声明类似于普通变量的声明。
1
class Circle {
2
private:
3
// 成员变量,私有的,外部无法直接访问
4
double radius; // 半径
5
const double PI = 3.14159; // 常量成员变量
6
public:
7
// ... 成员函数来访问或修改 radius
8
};
通常,出于封装 (Encapsulation) 的考虑(这在后续章节详细讨论),成员变量会被声明为 private
或 protected
,并通过公有的 (public) 成员函数(如 getter/setter)来访问或修改。
2.1.3 成员函数 (Member Function)
成员函数也称为方法 (Method),它们定义了类对象的行为或操作。成员函数可以直接访问同类中的所有成员变量和其他成员函数,无论它们的访问权限是 public
, private
, 还是 protected
。
成员函数可以在类体内定义,也可以在类体外定义。如果在类体内定义,函数通常会被编译器视为内联函数 (Inline Function) 的候选。
在类体内定义成员函数:
1
class Calculator {
2
public:
3
int add(int a, int b) { // 在类体内定义
4
return a + b;
5
}
6
};
在类体外定义成员函数:
1
class Calculator {
2
public:
3
int add(int a, int b); // 在类体内声明
4
};
5
6
// 在类体外定义
7
int Calculator::add(int a, int b) { // 使用作用域解析运算符 ::
8
return a + b;
9
}
在类体外定义成员函数时,需要使用 作用域解析运算符 (Scope Resolution Operator) ::
来指明该函数属于哪个类。这种方式将类的接口(声明在头文件中)和实现(定义在源文件中)分离开,是大型项目中常用的做法。
2.2 对象的创建与生命周期
类是蓝图,而对象 (Object) 是类的一个具体的实例 (Instance)。通过类创建对象的过程称为实例化 (Instantiation)。对象拥有类所定义的成员变量的独立副本,并可以调用类所定义的成员函数。
在 C++ 中,创建对象主要有两种方式:在栈 (Stack) 上创建和在堆 (Heap) 上创建。
2.2.1 栈对象 (Stack Object)
在函数内部或块作用域内,可以直接通过类名声明变量来创建对象,这样的对象存储在栈上。它们的生命周期由其作用域决定,在作用域结束时自动销毁。
1
#include <iostream>
2
3
class Dog {
4
public:
5
void bark() {
6
std::cout << "Woof!" << std::endl;
7
}
8
};
9
10
int main() {
11
// 在栈上创建 Dog 对象
12
Dog myDog; // myDog 是一个栈对象
13
14
// 调用成员函数
15
myDog.bark();
16
17
// myDog 在 main 函数作用域结束时自动销毁
18
return 0;
19
}
特点:
⚝ 创建速度快。
⚝ 内存由编译器自动管理(分配和释放)。
⚝ 生命周期绑定在作用域内。
2.2.2 堆对象 (Heap Object)
使用 new
运算符可以在堆上动态地创建对象。new
运算符返回一个指向新创建对象的指针。堆对象需要手动使用 delete
运算符来释放内存。
1
#include <iostream>
2
3
class Cat {
4
public:
5
void meow() {
6
std::cout << "Meow!" << std::endl;
7
}
8
};
9
10
int main() {
11
// 在堆上创建 Cat 对象
12
Cat* myCat = new Cat(); // myCat 是一个指向堆对象的指针
13
14
// 调用成员函数(通过指针)
15
myCat->meow(); // 使用 -> 运算符访问成员
16
17
// 手动释放内存
18
delete myCat;
19
myCat = nullptr; // 良好的习惯,避免悬垂指针 (Dangling Pointer)
20
21
// 注意:如果忘记 delete,会导致内存泄漏 (Memory Leak)
22
return 0;
23
}
特点:
⚝ 创建的对象生命周期可以超出其创建时的作用域。
⚝ 内存需要手动管理(使用 new
和 delete
)。
⚝ 如果忘记 delete
,会导致内存泄漏。
⚝ 如果重复 delete
或 delete
无效指针,会导致未定义行为 (Undefined Behavior)。
通常情况下,C++ 提倡优先使用栈对象或利用智能指针 (Smart Pointer) 来管理堆对象,以避免手动内存管理的复杂性和潜在错误。智能指针将在第 9 章详细介绍。
2.2.3 对象的初始化与销毁(初步了解)
对象的创建涉及到初始化过程,对象的销毁涉及到清理过程。这些过程由类的构造函数 (Constructor) 和析构函数 (Destructor) 负责。我们将在第 3 章深入讲解这两个特殊的成员函数。
初步认识:
⚝ 构造函数 (Constructor):在对象被创建时自动调用的特殊成员函数,用于初始化对象的成员变量和执行其他必要的设置。
⚝ 析构函数 (Destructor):在对象生命周期结束(栈对象超出作用域,堆对象被 delete
)时自动调用的特殊成员函数,用于清理对象占用的资源,例如释放动态分配的内存、关闭文件句柄等。
2.3 成员访问控制:public, private, protected
C++ 提供了三种访问修饰符 (Access Specifier) 来控制类成员(包括成员变量和成员函数)的可访问性,这是实现封装 (Encapsulation) 和信息隐藏 (Information Hiding) 的重要手段。
① public
(公有的):
▮▮▮▮被声明为 public
的成员可以在类内部以及类外部的任何地方被访问。
▮▮▮▮它们构成了类的接口 (Interface),是外部代码与类对象交互的方式。
② private
(私有的):
▮▮▮▮被声明为 private
的成员只能在类内部被访问(由类的其他成员函数访问)。
▮▮▮▮它们是类的实现细节 (Implementation Detail),对外部隐藏。这是实现信息隐藏的主要方式。
③ protected
(受保护的):
▮▮▮▮被声明为 protected
的成员可以在类内部被访问,也可以在其派生类 (Derived Class) 内部被访问。
▮▮▮▮它们对外部是不可见的,但在继承体系中,派生类可以访问基类的 protected
成员。这主要用于支持继承时的特殊访问需求,我们将在第 5 章继承部分详细探讨。
默认访问权限:
⚝ 使用 class
关键字定义的类,成员的默认访问权限是 private
。
⚝ 使用 struct
关键字定义的类(C++ 中 struct
也可以包含成员函数和访问修饰符),成员的默认访问权限是 public
。除了默认访问权限不同,class
和 struct
在定义类时没有本质区别。
示例:
1
class BankAccount {
2
private:
3
double balance; // 私有成员变量,隐藏账户余额
4
5
public:
6
// 公有成员函数,提供访问和修改余额的接口
7
void deposit(double amount) {
8
if (amount > 0) {
9
balance += amount;
10
std::cout << "Deposit successful. New balance: " << balance << std::endl;
11
}
12
}
13
14
bool withdraw(double amount) {
15
if (amount > 0 && amount <= balance) {
16
balance -= amount;
17
std::cout << "Withdrawal successful. New balance: " << balance << std::endl;
18
return true;
19
} else {
20
std::cout << "Withdrawal failed. Insufficient funds or invalid amount." << std::endl;
21
return false;
22
}
23
}
24
25
double getBalance() const { // 提供获取余额的接口
26
return balance;
27
}
28
29
protected:
30
// 受保护成员,假设后续有 SavingAccount 派生类需要访问
31
// double someProtectedData;
32
};
33
34
int main() {
35
BankAccount myAccount;
36
// myAccount.balance = 1000; // 错误:balance 是 private 的,外部无法直接访问
37
38
myAccount.deposit(500); // 正确:通过公有成员函数访问
39
myAccount.withdraw(200); // 正确:通过公有成员函数访问
40
std::cout << "Current balance: " << myAccount.getBalance() << std::endl; // 正确:通过公有成员函数访问
41
42
return 0;
43
}
在这个例子中,balance
是 private
的,外部代码无法直接读取或修改它,只能通过 public
的 deposit
, withdraw
, 和 getBalance
函数来间接操作。这样,我们可以控制对账户余额的所有操作,例如在存款前检查金额是否为正,在取款前检查余额是否充足,从而保护数据的完整性和有效性。这就是信息隐藏的强大之处。
2.4 this 指针
在类的非静态成员函数 (Non-static Member Function) 中,常常需要引用当前正在操作的对象自身。C++ 为此提供了一个特殊的指针:this
指针。
① this
指针的含义:
▮▮▮▮this
是一个指向当前对象自身的常量指针。
▮▮▮▮它存储了调用该成员函数的对象的内存地址。
② this
指针的用途:
▮▮▮▮在成员函数内部,this
指针隐式地存在,并且指向调用该函数的对象。
▮▮▮▮访问成员变量:当成员函数的参数名与成员变量名相同时,可以使用 this->
来明确指明是成员变量。
▮▮▮▮返回对象自身的引用:链式调用 (Chaining Method Calls) 时常用。
▮▮▮▮将当前对象的地址或引用传递给其他函数。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Person {
5
private:
6
std::string name;
7
int age;
8
9
public:
10
// 构造函数,参数名与成员变量名相同
11
Person(std::string name, int age) {
12
// 使用 this-> 区分成员变量和函数参数
13
this->name = name;
14
this->age = age;
15
std::cout << "Person " << this->name << " created." << std::endl;
16
}
17
18
// 设置年龄的成员函数,返回当前对象的引用以支持链式调用
19
Person& setAge(int age) {
20
this->age = age; // 这里 this-> 是可选的,但明确表示操作的是成员变量
21
return *this; // 返回当前对象的引用
22
}
23
24
// 获取信息的成员函数
25
void displayInfo() const {
26
// 在成员函数内部,直接访问成员变量即可,编译器会隐式使用 this->
27
std::cout << "Name: " << name << ", Age: " << age << std::endl;
28
}
29
30
// 获取当前对象的指针
31
Person* getPointer() {
32
return this;
33
}
34
35
// 获取当前对象的引用
36
Person& getReference() {
37
return *this;
38
}
39
};
40
41
int main() {
42
Person p1("Alice", 30);
43
p1.displayInfo();
44
45
// 链式调用 setAge
46
p1.setAge(31).displayInfo();
47
48
// 获取对象指针和引用
49
Person* ptr_p1 = p1.getPointer();
50
Person& ref_p1 = p1.getReference();
51
52
std::cout << "Address of p1: " << &p1 << std::endl;
53
std::cout << "Address returned by getPointer(): " << ptr_p1 << std::endl;
54
std::cout << "Address of object referenced by getReference(): " << &ref_p1 << std::endl;
55
56
return 0;
57
}
重要提示:
⚝ this
是一个常量指针,你不能改变 this
指向的对象(即 this = other_object;
是非法的)。
⚝ 在 const
成员函数中,this
指针的类型是 const ClassName*
,这意味着你不能通过 this
指针修改成员变量(除非该成员变量被声明为 mutable
)。
2.5 const 成员函数和 const 对象
const
关键字在 C++ 中非常重要,它可以用来修饰变量、指针、引用、函数参数、函数返回值,以及类的成员函数和对象。在这里,我们重点关注 const
在类和对象中的应用。
2.5.1 const 对象的限制
通过 const
关键字创建的对象是常量对象 (Const Object)。常量对象的状态在其生命周期内不能被修改。
1
class Point {
2
private:
3
int x, y;
4
public:
5
Point(int x_val, int y_val) : x(x_val), y(y_val) {}
6
7
// 设置 x 的函数
8
void setX(int x_val) {
9
x = x_val;
10
}
11
12
// 获取 x 的函数 (非 const)
13
int getX_non_const() {
14
return x;
15
}
16
17
// 获取 x 的函数 (const)
18
int getX_const() const { // 注意这里的 const 关键字
19
return x;
20
}
21
};
22
23
int main() {
24
const Point p(10, 20); // 创建一个 const 对象
25
26
// p.setX(5); // 错误:不能调用非 const 成员函数来修改 const 对象
27
28
// int val1 = p.getX_non_const(); // 错误:不能在 const 对象上调用非 const 成员函数
29
30
int val2 = p.getX_const(); // 正确:可以在 const 对象上调用 const 成员函数
31
std::cout << "x = " << val2 << std::endl;
32
33
return 0;
34
}
结论: 常量对象只能调用其 const
成员函数,不能调用非 const
成员函数。这是因为非 const
成员函数可能修改对象的状态,而 const
对象的状态是不可变的。
2.5.2 const 成员函数 (const Member Function)
如前所示,const
成员函数在其声明和定义的花括号前带有 const
关键字。
① const
成员函数的含义:
▮▮▮▮一个 const
成员函数承诺不会修改对象的状态。
▮▮▮▮具体来说,在 const
成员函数内部,不能修改类的非 mutable
成员变量。
② 为何需要 const
成员函数?
▮▮▮▮安全性: 确保读取对象状态的函数不会意外地修改对象。
▮▮▮▮灵活性: 只有 const
成员函数才能在 const
对象上调用。如果一个函数只读取对象状态而不修改,应将其声明为 const
,这样它就可以被 const
对象和非 const
对象同时调用。
▮▮▮▮设计意图: 清晰地表达函数的行为,告诉使用者这个函数是“只读”的。
示例:
1
class DataContainer {
2
private:
3
int value;
4
// int cached_result; // 假设这是一个缓存,理论上 getSum 应该不修改对象状态
5
6
public:
7
DataContainer(int v) : value(v) {}
8
9
// 修改 value 的函数 (非 const)
10
void setValue(int v) {
11
value = v;
12
}
13
14
// 获取 value 的函数 (const)
15
int getValue() const { // 这是一个 const 成员函数
16
return value;
17
}
18
19
// 假设有一个复杂的计算函数,只读取 value
20
// int getSum() const {
21
// // 这里可以访问 value,但不能修改 value
22
// // value = 0; // 错误:在 const 成员函数中修改非 mutable 成员
23
// // 如果有 mutable 成员,可以在 const 函数中修改它
24
// // cached_result = value + 10; // 如果 cached_result 是 mutable
25
// return value + 100;
26
// }
27
};
28
29
int main() {
30
DataContainer dc1(10); // 非 const 对象
31
const DataContainer dc2(20); // const 对象
32
33
dc1.setValue(15); // 正确:非 const 对象可以调用非 const 函数
34
std::cout << "dc1 value: " << dc1.getValue() << std::endl; // 正确:非 const 对象可以调用 const 函数
35
36
// dc2.setValue(25); // 错误:const 对象不能调用非 const 函数
37
std::cout << "dc2 value: " << dc2.getValue() << std::endl; // 正确:const 对象可以调用 const 函数
38
39
return 0;
40
}
2.5.3 mutable 关键字
默认情况下,const
成员函数不能修改任何成员变量。但有时可能存在这样的情况:某个成员变量是对象状态的一部分,但它的修改并不影响对象的逻辑状态,而只是用于优化(如缓存计算结果)或维护(如使用计数)。这时可以使用 mutable
关键字来修饰这个成员变量。被 mutable
修饰的成员变量可以在 const
成员函数中被修改。
1
class BigObject {
2
private:
3
int data;
4
mutable int cache; // 使用 mutable 修饰,可以在 const 函数中修改
5
mutable bool cache_valid; // 标记缓存是否有效
6
7
public:
8
BigObject(int d) : data(d), cache(0), cache_valid(false) {}
9
10
int getData() const {
11
// 在 const 函数中读取 data 是允许的
12
return data;
13
}
14
15
int getComputedValue() const {
16
if (!cache_valid) {
17
// 在 const 函数中修改 mutable 成员是允许的
18
cache = data * 2; // 模拟一个计算并缓存
19
cache_valid = true;
20
std::cout << "Calculating and caching..." << std::endl;
21
} else {
22
std::cout << "Using cached value..." << std::endl;
23
}
24
return cache;
25
}
26
27
// 非 const 函数,可以修改所有成员
28
void setData(int d) {
29
data = d;
30
cache_valid = false; // 数据改变,缓存失效
31
}
32
};
33
34
int main() {
35
const BigObject obj(10); // const 对象
36
37
std::cout << obj.getComputedValue() << std::endl; // 第一次调用,计算并缓存
38
std::cout << obj.getComputedValue() << std::endl; // 第二次调用,使用缓存
39
40
BigObject obj2(20); // 非 const 对象
41
std::cout << obj2.getComputedValue() << std::endl; // 第一次计算
42
obj2.setData(25); // 修改数据,缓存失效
43
std::cout << obj2.getComputedValue() << std::endl; // 重新计算
44
45
return 0;
46
}
mutable
关键字的使用应该谨慎,只应用于那些其修改不影响对象可观察逻辑状态的成员变量。
本章我们学习了 C++ 中类和对象的基本概念,包括类的定义、成员变量和成员函数、对象的创建(栈和堆)、访问修饰符、this
指针以及 const
在类和对象中的应用。这些是构建 C++ OOP 程序的基石。在下一章,我们将深入探讨对象的初始化和清理过程,也就是构造函数和析构函数。
3. 构造函数与析构函数:对象的初始化与清理 ✨
对象是面向对象编程的核心,而对象的生命周期——从诞生(创建)到消亡(销毁)——则由两个特殊的成员函数来管理:构造函数 (Constructor) 和析构函数 (Destructor)。理解它们的作用、类型和调用时机,对于编写健壮、高效的 C++ 代码至关重要。本章将深入探讨这两个关键概念,以及它们在对象生命周期管理中的核心作用。
3.1 构造函数的种类与作用 🛠️
当一个对象被创建时,它需要被正确地初始化,以确保其处于一个合法的、可用的状态。构造函数正是为此目的而生。它是一个特殊的成员函数,其名称与类名完全相同,没有返回类型(甚至连 void
都没有),并且在对象创建时被自动调用。
构造函数的主要作用包括:
⚝ 为对象的成员变量赋初值。
⚝ 分配对象所需的资源(例如,动态内存、文件句柄、网络连接等)。
⚝ 执行任何必要的初始化逻辑。
C++ 中存在几种类型的构造函数,它们在不同的场景下负责对象的初始化:
① 默认构造函数 (Default Constructor)
▮▮▮▮默认构造函数是不接受任何参数的构造函数。
▮▮▮▮它的形式通常是 ClassName();
。
▮▮▮▮如果一个类没有声明任何构造函数,编译器会为其隐式地生成一个公有的默认构造函数。这个编译器生成的默认构造函数会调用其成员对象(如果成员是类类型)的默认构造函数来初始化它们,对于内置类型(如 int
, float
, 指针等)成员,它不做任何初始化,它们的值将是不确定的。
▮▮▮▮如果一个类声明了任何构造函数(无论是带参数的还是拷贝构造函数等),编译器就不会再隐式生成默认构造函数。除非你使用 = default
显式请求编译器生成(C++11 标准引入)。
▮▮▮▮何时需要自定义默认构造函数? 当你希望对象在创建时能够执行特定的、无参数的初始化逻辑时,或者当编译器生成的默认构造函数无法满足需求(例如,需要初始化内置类型成员到一个特定值)时。
1
#include <iostream>
2
3
class MyClass {
4
public:
5
int value;
6
// 默认构造函数
7
MyClass() {
8
std::cout << "Default Constructor called" << std::endl;
9
value = 0; // 初始化内置类型成员
10
}
11
};
12
13
class AnotherClass {
14
int data;
15
// 未声明任何构造函数,编译器会隐式生成默认构造函数
16
// 但不会初始化data
17
};
18
19
int main() {
20
MyClass obj1; // 调用自定义默认构造函数
21
std::cout << "obj1.value: " << obj1.value << std::endl; // 输出 0
22
23
AnotherClass obj2; // 调用编译器生成的默认构造函数
24
// std::cout << "obj2.data: " << obj2.data << std::endl; // data的值是不确定的!避免这样做
25
return 0;
26
}
② 带参数构造函数 (Parameterized Constructor)
▮▮▮▮带参数构造函数允许你在创建对象时传递参数,用这些参数来初始化对象的成员。
▮▮▮▮通过提供不同的参数列表,一个类可以拥有多个带参数构造函数,这就是构造函数重载 (Constructor Overloading)。
▮▮▮▮它们提供了更灵活的对象初始化方式。
1
#include <iostream>
2
#include <string>
3
4
class Person {
5
public:
6
std::string name;
7
int age;
8
9
// 带参数构造函数
10
Person(std::string n, int a) {
11
std::cout << "Parameterized Constructor called" << std::endl;
12
name = n;
13
age = a;
14
}
15
16
// 另一个带参数构造函数 (构造函数重载)
17
Person(std::string n) {
18
std::cout << "Parameterized Constructor (string only) called" << std::endl;
19
name = n;
20
age = 0; // 提供默认年龄
21
}
22
};
23
24
int main() {
25
Person p1("Alice", 30); // 调用 Person(string, int)
26
std::cout << "p1: " << p1.name << ", " << p1.age << std::endl;
27
28
Person p2("Bob"); // 调用 Person(string)
29
std::cout << "p2: " << p2.name << ", " << p2.age << std::endl;
30
31
// Person p3; // ERROR: 没有默认构造函数 (因为已经声明了带参数的构造函数)
32
33
return 0;
34
}
③ 拷贝构造函数 (Copy Constructor)
▮▮▮▮拷贝构造函数用于创建一个新对象,它是现有对象的副本。
▮▮▮▮它的形式通常是 ClassName(const ClassName& other);
,接受一个同类型对象的常引用作为参数。
▮▮▮▮拷贝构造函数在以下情况会被调用:
▮▮▮▮▮▮▮▮❶ 使用一个对象显式或隐式地初始化另一个同类型的对象:
▮▮▮▮▮▮▮▮▮▮ClassName obj2 = obj1;
(初始化,不是赋值)
▮▮▮▮▮▮▮▮▮▮ClassName obj3(obj1);
▮▮▮▮▮▮▮▮❷ 对象作为参数按值传递给函数。
▮▮▮▮▮▮▮▮❸ 函数返回对象按值返回。
▮▮▮▮▮▮▮▮❹ 在某些编译器优化(如返回值优化 RVO/NRVO)未发生的情况下。
▮▮▮▮如果一个类没有声明拷贝构造函数,编译器会为其隐式地生成一个公有的拷贝构造函数。这个编译器生成的拷贝构造函数执行的是浅拷贝 (Shallow Copy),即按成员逐个复制。对于内置类型成员,直接复制值;对于指针成员,复制的是指针本身(即两个对象中的指针将指向同一块内存)。
▮▮▮▮何时需要自定义拷贝构造函数? 当类中包含指向动态分配内存或其他资源的指针或句柄时,通常需要自定义拷贝构造函数来实现深拷贝 (Deep Copy),确保新对象拥有独立的资源副本,而不是与原对象共享资源,避免“双重释放”等问题。
1
#include <iostream>
2
#include <cstring> // For strcpy, strlen
3
4
class MyString {
5
public:
6
char* data;
7
size_t length;
8
9
// 带参数构造函数
10
MyString(const char* s = "") {
11
length = std::strlen(s);
12
data = new char[length + 1];
13
std::strcpy(data, s);
14
std::cout << "Parameterized Constructor called for \"" << s << "\"" << std::endl;
15
}
16
17
// 析构函数 (用于释放资源)
18
~MyString() {
19
std::cout << "Destructor called for \"" << data << "\"" << std::endl;
20
delete[] data; // 释放动态分配的内存
21
data = nullptr; // 防止悬空指针
22
}
23
24
// 拷贝构造函数 (深拷贝)
25
MyString(const MyString& other) {
26
length = other.length;
27
data = new char[length + 1]; // 分配新的内存
28
std::strcpy(data, other.data); // 复制内容
29
std::cout << "Copy Constructor called for \"" << data << "\"" << std::endl;
30
}
31
32
// 拷贝赋值运算符 (深拷贝, 稍后在运算符重载章节详细介绍)
33
MyString& operator=(const MyString& other) {
34
std::cout << "Copy Assignment called for \"" << other.data << "\"" << std::endl;
35
if (this != &other) { // 防止自我赋值
36
delete[] data; // 释放原有资源
37
length = other.length;
38
data = new char[length + 1]; // 分配新的内存
39
std::strcpy(data, other.data); // 复制内容
40
}
41
return *this;
42
}
43
};
44
45
void processString(MyString s) { // 参数按值传递,会调用拷贝构造函数
46
std::cout << "Inside processString: " << s.data << std::endl;
47
} // s 在这里销毁,调用析构函数
48
49
int main() {
50
MyString s1("Hello"); // 参数构造函数
51
MyString s2 = s1; // 拷贝构造函数
52
MyString s3(s1); // 拷贝构造函数
53
54
std::cout << "s1: " << s1.data << ", s2: " << s2.data << ", s3: " << s3.data << std::endl;
55
56
processString(s1); // 传递 s1 的副本,调用拷贝构造函数。函数返回后副本销毁。
57
58
// s1, s2, s3 在 main 函数结束时销毁,调用析构函数。
59
return 0;
60
} // s1, s2, s3 析构函数依次被调用
▮▮▮▮在上面的 MyString
例子中,如果没有自定义拷贝构造函数,编译器生成的浅拷贝会导致 s1.data
和 s2.data
指向同一块内存。当 s2
或 s1
其中一个析构时,这块内存会被释放。当另一个对象尝试释放同一块内存时,就会发生双重释放 (Double Free) 错误,导致程序崩溃。自定义深拷贝构造函数则确保了每个对象都有独立的资源。
④ 移动构造函数 (Move Constructor)
▮▮▮▮移动构造函数是 C++11 引入的重要特性,用于实现移动语义 (Move Semantics)。
▮▮▮▮它的形式通常是 ClassName(ClassName&& other);
,接受一个同类型对象的右值引用 (Rvalue Reference) 作为参数。
▮▮▮▮它在源对象是一个右值(即将亡的临时对象或通过 std::move
显式转换为右值的对象)时被调用。
▮▮▮▮移动构造函数通常不进行资源的深拷贝,而是转移源对象对资源的所有权。通过“窃取”源对象的资源(例如,复制指针,然后将源对象的指针置空),从而避免昂贵的拷贝操作,提高效率。
▮▮▮▮如果一个类没有声明移动构造函数,但声明了拷贝构造函数、拷贝赋值运算符或析构函数,则不会隐式生成移动构造函数。
▮▮▮▮如果一个类没有声明拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符或析构函数,编译器会隐式生成移动构造函数和移动赋值运算符(如果它们的成员类型支持移动)。
▮▮▮▮何时需要自定义移动构造函数? 当类管理着堆内存或其他资源,并且希望在源对象是右值时避免深拷贝,而是高效地转移资源所有权时。
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
#include <utility> // For std::move
5
6
class Resource {
7
public:
8
int* data;
9
size_t size;
10
11
// Constructor
12
Resource(size_t s) : size(s) {
13
data = new int[size];
14
std::cout << "Resource created, size: " << size << std::endl;
15
}
16
17
// Destructor
18
~Resource() {
19
if (data) {
20
std::cout << "Resource destroyed, size: " << size << std::endl;
21
delete[] data;
22
data = nullptr;
23
} else {
24
std::cout << "Resource destroyed (nullptr data)" << std::endl;
25
}
26
}
27
28
// Copy Constructor (深拷贝) - Still needed for lvalues
29
Resource(const Resource& other) : size(other.size) {
30
data = new int[size];
31
std::copy(other.data, other.data + size, data);
32
std::cout << "Resource copied (deep copy), size: " << size << std::endl;
33
}
34
35
// Move Constructor (移动语义)
36
Resource(Resource&& other) noexcept : data(other.data), size(other.size) {
37
std::cout << "Resource moved, size: " << size << std::endl;
38
other.data = nullptr; // 窃取资源后,将源对象指针置空
39
other.size = 0;
40
}
41
42
// Copy Assignment Operator (深拷贝, for demonstration)
43
Resource& operator=(const Resource& other) {
44
std::cout << "Resource copy assigned, size: " << other.size << std::endl;
45
if (this != &other) {
46
delete[] data; // 释放原有资源
47
size = other.size;
48
data = new int[size];
49
std::copy(other.data, other.data + size, data);
50
}
51
return *this;
52
}
53
54
// Move Assignment Operator (移动语义, for demonstration)
55
Resource& operator=(Resource&& other) noexcept {
56
std::cout << "Resource move assigned, size: " << other.size << std::endl;
57
if (this != &other) {
58
delete[] data; // 释放原有资源
59
data = other.data; // 转移资源
60
size = other.size;
61
other.data = nullptr; // 源对象指针置空
62
other.size = 0;
63
}
64
return *this;
65
}
66
};
67
68
int main() {
69
std::vector<Resource> resources;
70
resources.reserve(3); // 预留空间,避免不必要的重新分配和移动
71
72
std::cout << "--- Adding Resource 1 (lvalue) ---" << std::endl;
73
Resource r1(10); // Constructor
74
resources.push_back(r1); // push_back(const T&): 调拷贝构造函数
75
76
std::cout << "--- Adding Resource 2 (rvalue) ---" << std::endl;
77
resources.push_back(Resource(20)); // push_back(T&&): 调移动构造函数 (临时对象是右值)
78
79
std::cout << "--- Adding Resource 3 (moved lvalue) ---" << std::endl;
80
Resource r3(30); // Constructor
81
resources.push_back(std::move(r3)); // push_back(T&&): 调移动构造函数 (std::move 将左值转为右值引用)
82
83
std::cout << "--- Resources in vector ---" << std::endl;
84
// vector resources 销毁时,其内部的 Resource 对象依次销毁
85
86
return 0;
87
} // r1, r3 在这里销毁 (r3.data 已经是 nullptr),vector 销毁其元素
▮▮▮▮运行上述代码会清楚地看到拷贝构造函数和移动构造函数在不同场景下的调用,以及移动构造函数如何避免深拷贝,提高向 std::vector
添加元素的效率(特别是当 vector
容量不足需要重新分配时)。
总之,构造函数是确保对象从诞生起就拥有良好状态的关键。选择和实现正确的构造函数(包括默认、带参、拷贝、移动)取决于类的需求,特别是其成员变量的类型以及类是否管理着外部资源。
3.2 构造函数的初始化列表 📝
在构造函数中初始化成员变量有两种常见方式:在构造函数体内部使用赋值语句,或使用初始化列表 (Initialization List)。初始化列表在构造函数的参数列表后,函数体 {}
之前,用 :
引导。
语法:
1
ClassName::ClassName(parameter list) : member1(arg1), member2(arg2), ... {
2
// Constructor body (optional, for other logic)
3
}
示例:
1
#include <iostream>
2
#include <string>
3
4
class Gadget {
5
private:
6
std::string name;
7
int id;
8
const double version; // const 成员
9
int& ref_count; // 引用成员
10
int count;
11
12
public:
13
// 使用初始化列表
14
Gadget(std::string n, int i, double v, int& rc)
15
: name(n), id(i), version(v), ref_count(rc), count(0) // 初始化列表
16
{
17
std::cout << "Gadget initialized with name=" << name << ", id=" << id << ", version=" << version << std::endl;
18
// 构造函数体可以用于其他逻辑,但成员变量的初始化应在初始化列表完成
19
// count = 0; // 也可以在这里赋值,但不如在列表里初始化好
20
}
21
22
// 如果在构造函数体内赋值会怎样?
23
/*
24
Gadget(std::string n, int i, double v, int& rc) {
25
name = n; // 先默认构造name,再赋值
26
id = i; // 直接赋值
27
// version = v; // ERROR: const 成员不能在构造函数体中赋值
28
// ref_count = rc; // ERROR: 引用成员不能在构造函数体中赋值
29
count = 0;
30
std::cout << "Gadget initialized (using assignment) with name=" << name << ", id=" << id << std::endl;
31
}
32
*/
33
};
34
35
int main() {
36
int global_count = 10;
37
Gadget g1("Gizmo", 101, 1.0, global_count);
38
39
// Gadget g2("Widget", 102, 2.0); // 如果上面注释掉的赋值版本构造函数可用,这个可以编译
40
// 但 const/reference 成员仍然是大问题
41
return 0;
42
}
初始化列表的重要性与优势:
① 必须使用初始化列表的情况:
▮▮▮▮ⓑ 初始化 const 成员变量。const 成员必须在对象构造时立即初始化,之后不能再被赋值。
▮▮▮▮ⓒ 初始化引用 (reference) 成员变量。引用必须在声明时绑定到一个对象,不能先声明后赋值。
▮▮▮▮ⓓ 初始化没有默认构造函数的对象类型成员。如果一个类成员是另一个类或结构体类型,且该类型只有带参数的构造函数而没有默认构造函数,则必须在初始化列表中调用其相应的构造函数进行初始化。
▮▮▮▮ⓔ 初始化基类成员。派生类的构造函数必须在初始化列表中调用其基类的构造函数来初始化基类部分。
② 推荐使用初始化列表的情况:
▮▮▮▮ⓑ 效率更高: 对于类类型的成员变量,如果在构造函数体内部使用赋值,实际上是先调用了该成员的默认构造函数,然后再调用了该成员的赋值运算符。而使用初始化列表则是直接调用了该成员的相应构造函数进行初始化。避免了不必要的默认构造和赋值操作,特别是在处理复杂对象时,可以显著提升性能。
▮▮▮▮ⓒ 代码意图更清晰: 初始化列表清晰地表达了成员变量是如何被初始化的,将成员的初始化与构造函数体的其他逻辑(如资源分配、状态设置等)分离开来。
▮▮▮▮ⓓ 避免未定义行为: 对于内置类型,如果在初始化列表中不初始化,然后在构造函数体中赋值,这本身是合法的。但如果在赋值之前使用了该成员的值,则会导致未定义行为(因为默认构造函数不对内置类型进行初始化)。使用初始化列表可以避免这种潜在问题。
成员初始化顺序:
一个容易混淆的点是,初始化列表中成员的初始化顺序不取决于它们在初始化列表中的出现顺序,而是取决于它们在类定义中的声明顺序。这是一个常见的错误来源,需要特别注意。
1
class OrderExample {
2
int y;
3
int x; // x 在 y 后面声明
4
5
public:
6
// 初始化列表中 y 在 x 前面
7
OrderExample(int val) : y(val), x(y) { // ❌ 这里 x 实际上是用未初始化的 y 来初始化的
8
std::cout << "x = " << x << ", y = " << y << std::endl;
9
}
10
// 如果在类定义中 x 在 y 前面声明,这里就是正常的初始化顺序
11
/*
12
class OrderExample {
13
int x; // x 在 y 前面声明
14
int y;
15
public:
16
OrderExample(int val) : y(val), x(y) { // √ x 用 val 初始化,y 用 x 的值初始化
17
std::cout << "x = " << x << ", y = " << y << std::endl;
18
}
19
};
20
*/
21
};
22
23
int main() {
24
OrderExample oe(10); // 输出可能不是 x = 10, y = 10,而是 x 是个随机值,y = 10
25
return 0;
26
}
为了避免这种问题,最佳实践是始终按照成员在类中声明的顺序在初始化列表中初始化它们。
总结:始终优先使用初始化列表来初始化成员变量。对于 const 成员、引用成员和没有默认构造函数的类类型成员,初始化列表是必需的。
3.3 析构函数的作用与调用时机 🧹
对象的生命周期有始有终。当对象不再需要时,它所占用的资源应该被释放,以避免资源泄露 (Resource Leak)。析构函数 (Destructor) 正是负责执行对象销毁前清理工作的特殊成员函数。
作用:
⚝ 释放对象在生命周期内获得的资源,例如:
▮▮▮▮ⓐ 释放由构造函数或成员函数动态分配的内存 (delete
或 delete[]
)。
▮▮▮▮ⓑ 关闭打开的文件句柄。
▮▮▮▮ⓒ 释放获取的网络连接或锁。
⚝ 执行任何必要的清理逻辑,确保对象在销毁时不会导致资源泄露或其他副作用。
特性:
⚝ 析构函数名称与类名相同,但在前面加一个波浪号 ~
,例如 ~ClassName();
。
⚝ 它没有返回类型(甚至连 void
都没有)。
⚝ 它不接受任何参数,因此一个类只能有一个析构函数,不能重载。
⚝ 如果一个类没有声明析构函数,编译器会为其隐式地生成一个公有的析构函数。这个编译器生成的析构函数会调用其成员对象(如果成员是类类型)的析构函数来清理它们,对于内置类型成员,它不做任何事情。
⚝ 何时需要自定义析构函数? 当类中包含指向动态分配内存或其他资源的指针或句柄,并且这些资源由该对象拥有时,需要自定义析构函数来释放这些资源。这是实现 RAII (Resource Acquisition Is Initialization) 原则的关键部分。
调用时机:
析构函数在对象的生命周期结束时被自动调用。具体的调用时机取决于对象的存储类型:
① 栈对象 (Stack Object):
▮▮▮▮当定义栈对象的函数返回、作用域结束或抛出异常且在当前作用域内未被捕获时,栈对象会被销毁,其析构函数被调用。对象销毁顺序与其创建顺序相反(后创建的先销毁)。
1
#include <iostream>
2
3
class ScopeGuard {
4
std::string name;
5
public:
6
ScopeGuard(std::string n) : name(n) {
7
std::cout << "ScopeGuard '" << name << "' created" << std::endl;
8
}
9
~ScopeGuard() {
10
std::cout << "ScopeGuard '" << name << "' destroyed" << std::endl;
11
}
12
};
13
14
void func() {
15
ScopeGuard guard1("A"); // 先创建
16
ScopeGuard guard2("B"); // 后创建
17
std::cout << "Inside func" << std::endl;
18
} // guard2 先销毁,guard1 后销毁
19
20
int main() {
21
std::cout << "Entering main" << std::endl;
22
func();
23
std::cout << "Exiting main" << std::endl;
24
return 0;
25
}
输出会显示 "ScopeGuard 'B' destroyed" 在 "ScopeGuard 'A' destroyed" 之前。
② 堆对象 (Heap Object):
▮▮▮▮通过 new
动态分配创建的对象存储在堆上。它们的析构函数不会在作用域结束时自动调用。必须通过 delete
运算符显式释放堆对象时,才会调用其析构函数。使用 delete[]
释放动态分配的数组时,会按创建顺序的逆序依次调用数组中每个元素的析构函数。
▮▮▮▮重要: 如果忘记对堆对象使用 delete
,就会发生内存泄露 (Memory Leak)。使用智能指针 (Smart Pointer) 是管理堆对象的推荐方式,它们基于 RAII 原则,可以确保在智能指针对象生命周期结束时自动调用 delete
,从而避免泄露。
1
#include <iostream>
2
3
class HeapResource {
4
public:
5
HeapResource() { std::cout << "HeapResource created" << std::endl; }
6
~HeapResource() { std::cout << "HeapResource destroyed" << std::endl; }
7
};
8
9
int main() {
10
HeapResource* ptr1 = new HeapResource(); // 创建堆对象
11
// ptr1 在 main 结束时不会自动调用析构函数!发生内存泄露。
12
// delete ptr1; // 需要手动释放
13
14
HeapResource* ptr2 = new HeapResource(); // 创建另一个堆对象
15
delete ptr2; // 显式释放,调用析构函数
16
17
std::cout << "Main function ending" << std::endl;
18
return 0;
19
}
③ 静态/全局对象 (Static/Global Object):
▮▮▮▮静态对象(包括全局对象、命名空间作用域的静态对象、函数内部的静态对象)在程序启动时创建(或在首次访问时创建,对于函数内部的静态对象),并在程序退出时被销毁。它们的析构函数在 main()
函数结束后,以其构造顺序的逆序被调用。
1
#include <iostream>
2
3
class StaticObject {
4
public:
5
StaticObject() { std::cout << "StaticObject created" << std::endl; }
6
~StaticObject() { std::cout << "StaticObject destroyed" << std::endl; }
7
};
8
9
StaticObject global_obj; // 全局静态对象
10
11
int main() {
12
std::cout << "Entering main" << std::endl;
13
static StaticObject static_func_obj; // 函数内部静态对象
14
std::cout << "Exiting main" << std::endl;
15
return 0;
16
} // static_func_obj 先于 global_obj 销毁 (取决于它们的构造顺序)
④ 成员对象:
▮▮▮▮当一个包含成员对象的类对象被销毁时,该类自己的析构函数首先被执行(用户编写的清理代码),然后其成员对象的析构函数会按照它们在类中声明的逆序被自动调用。
⑤ 继承体系中的对象:
▮▮▮▮对于派生类对象,析构函数的调用顺序是:先调用派生类自己的析构函数,然后自动调用基类的析构函数(如果有多重继承,则按继承声明的逆序调用)。
析构函数中不应抛出异常:
在一个对象的析构函数中抛出异常是非常危险的,可能会导致程序终止或未定义行为。考虑以下情况:当一个对象的析构函数因为某个原因被调用(例如,栈展开以响应另一个异常),如果在析构函数内部又抛出了一个新异常,这将导致两个异常同时存在,C++ 标准规定此时程序必须终止 (std::terminate
)。因此,析构函数中的代码应该尽量简单,只专注于资源的释放,避免可能抛出异常的操作。如果某个操作可能失败,应该在析构函数之外处理。
3.4 默认成员函数:何时生成与如何使用 🔄
C++ 标准规定了六种特殊的成员函数,它们控制着对象的创建、复制、移动和销毁过程。如果用户没有显式声明这些函数,在某些条件下编译器会隐式地生成它们。这些函数被称为默认成员函数 (Default Member Functions) 或特殊成员函数 (Special Member Functions)。
它们是:
- 默认构造函数 (Default Constructor)
- 析构函数 (Destructor)
- 拷贝构造函数 (Copy Constructor)
- 拷贝赋值运算符 (Copy Assignment Operator)
- 移动构造函数 (Move Constructor) (C++11)
- 移动赋值运算符 (Move Assignment Operator) (C++11)
前三者(默认构造函数、析构函数、拷贝构造函数)是我们本章重点讨论的,拷贝赋值和移动赋值将在运算符重载章节详细介绍。了解它们的默认生成规则对于理解对象的行为至关重要。
默认生成规则(简化版,仅关注本章相关的):
⚝ 默认构造函数:
▮▮▮▮⚝ 如果类中未声明任何构造函数,编译器会生成一个公有的、平凡的 (Trivial)(如果类及其成员满足特定条件)或非平凡的默认构造函数。
▮▮▮▮⚝ 如果类中声明了任何构造函数(无论是带参数的、拷贝的还是移动的),编译器不会再隐式生成默认构造函数。
⚝ 析构函数:
▮▮▮▮⚝ 如果类中未声明析构函数,编译器会生成一个公有的、平凡的或非平凡的析构函数。
▮▮▮▮⚝ 编译器生成的析构函数会调用基类和成员的析构函数。
▮▮▮▮⚝ 只有当基类的析构函数是虚函数时,编译器生成的析构函数才会是虚函数(通常建议将基类的析构函数声明为虚函数,以支持多态时的正确清理)。
⚝ 拷贝构造函数:
▮▮▮▮⚝ 如果类中未声明拷贝构造函数、移动构造函数、移动赋值运算符,且未声明析构函数,编译器会生成一个公有的拷贝构造函数。
▮▮▮▮⚝ 如果类中声明了移动构造函数或移动赋值运算符,编译器不会隐式生成拷贝构造函数。
▮▮▮▮⚝ 如果类中声明了析构函数,编译器不会隐式生成拷贝构造函数。
▮▮▮▮⚝ 编译器生成的拷贝构造函数执行成员的逐个拷贝(浅拷贝)。
规则之三/五/零 (Rule of Three/Five/Zero):
这是一个重要的编程指导原则,帮助你决定何时需要自定义特殊成员函数:
⚝ 规则之三 (Rule of Three): 如果你需要为类自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么你很可能需要自定义所有这三个。这通常是因为你的类管理着资源(如动态内存),而编译器默认生成的浅拷贝/析构行为不足以正确地处理资源。
⚝ 规则之五 (Rule of Five): 随着 C++11 引入移动语义,这个规则扩展为五。如果你需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么你很可能需要同时考虑自定义移动构造函数和移动赋值运算符,以便充分利用移动语义,提高效率。
⚝ 规则之零 (Rule of Zero): 这是现代 C++ 的最佳实践。如果可能,不要手动管理资源。而是使用 RAII (Resource Acquisition Is Initialization) 机制,将资源管理委托给标准库提供的类(如智能指针 std::unique_ptr
, std::shared_ptr
,容器 std::vector
, std::string
,文件流 std::fstream
等)。这些标准库类已经正确实现了析构函数、拷贝/移动构造函数和赋值运算符。如果你的类成员只包含这些 RAII 对象,那么你的类将不需要自定义任何特殊成员函数,编译器生成的默认版本将是正确且高效的。遵循规则之零可以大大简化代码,减少错误。
使用 = default
和 = delete
(C++11):
⚝ = default
: 你可以显式地请求编译器生成某个默认成员函数的版本。这在你声明了其他构造函数,但仍然希望编译器生成默认构造函数时非常有用,或者希望编译器生成特定版本的拷贝/移动操作时。
⚝ = delete
: 你可以显式地禁止编译器生成某个默认成员函数的版本。这在你希望阻止对象的拷贝(但允许移动)或完全禁止某个操作时非常有用。
1
#include <iostream>
2
3
class ExampleDefaultDelete {
4
int value;
5
6
public:
7
// 用户声明了带参数构造函数
8
ExampleDefaultDelete(int v) : value(v) {
9
std::cout << "Parameterized Constructor: " << value << std::endl;
10
}
11
12
// 显式请求编译器生成默认构造函数
13
ExampleDefaultDelete() = default;
14
15
// 显式禁止拷贝构造函数 (例如,对象不可拷贝)
16
ExampleDefaultDelete(const ExampleDefaultDelete&) = delete;
17
18
// 显式禁止拷贝赋值运算符
19
ExampleDefaultDelete& operator=(const ExampleDefaultDelete&) = delete;
20
21
// 允许编译器生成默认的析构函数 (对于内置类型成员是平凡的)
22
~ExampleDefaultDelete() = default;
23
24
// 允许编译器生成默认的移动构造函数和移动赋值运算符 (如果适用)
25
// ExampleDefaultDelete(ExampleDefaultDelete&&) = default;
26
// ExampleDefaultDelete& operator=(ExampleDefaultDelete&&) = default;
27
};
28
29
int main() {
30
ExampleDefaultDelete obj1(10); // 调用带参数构造函数
31
ExampleDefaultDelete obj2; // 调用显式 default 的默认构造函数
32
33
// ExampleDefaultDelete obj3 = obj1; // ERROR: 拷贝构造函数被 delete
34
// ExampleDefaultDelete obj4(obj1); // ERROR: 拷贝构造函数被 delete
35
36
ExampleDefaultDelete obj5(20);
37
// obj2 = obj5; // ERROR: 拷贝赋值运算符被 delete
38
39
// std::vector<ExampleDefaultDelete> vec;
40
// vec.push_back(obj1); // ERROR: 需要拷贝构造函数
41
// vec.push_back(ExampleDefaultDelete(30)); // OK (如果允许移动或临时对象直接构造)
42
43
std::cout << "Main function ending" << std::endl;
44
return 0;
45
} // obj1, obj2, obj5 析构函数被调用 (默认生成的)
使用 = delete
是向使用者表明某种操作不被支持的清晰方式,也让编译器来强制执行这一限制。
掌握默认成员函数的生成规则以及规则之三/五/零和 = default
/= delete
的用法,对于编写正确且高效的 C++ 类至关重要,特别是当你需要手动管理资源时。遵循规则之零,尽可能利用标准库提供的 RAII 类型,是现代 C++ 开发的推荐方向。
4. 封装与信息隐藏:构建健壮的类
欢迎来到本书关于 C++ 面向对象技术的第四章!在前几章中,我们了解了 OOP 的基本概念、C++ 对 OOP 的支持以及类和对象的基础知识,包括它们的生命周期、构造函数与析构函数等。本章我们将深入探讨 OOP 的核心支柱之一:封装(Encapsulation)。
封装不仅仅是将数据和函数包裹在一个类中那么简单,它更是一种强大的设计原则,与信息隐藏(Information Hiding)紧密相关,是构建健壮、可维护、易于扩展的软件系统的关键。我们将学习封装的原则、在 C++ 中如何通过访问修饰符实现封装,以及一些相关的实践技巧,比如使用 getter 和 setter 方法,以及接口与实现的分离。
4.1 封装的原则与益处
面向对象编程的核心思想之一是将数据(状态)与其操作数据的方法(行为)紧密地结合在一起,形成一个独立的单元,即“对象”。这个将数据和方法打包(bundling)的过程,就是封装(Encapsulation)。
更进一步,封装的核心目的在于实现信息隐藏(Information Hiding)。这意味着类的内部实现细节,如数据结构、算法、状态管理方式等,应该对类的外部用户隐藏起来。外部用户只能通过类提供的公共接口(public interface)与对象进行交互,而无需了解其内部工作原理。
这就像操作一台电视机📺:你只需要使用遥控器上的按钮(公共接口)来控制音量、频道等,而不需要知道电视内部的电路如何工作(内部实现细节)。
封装的原则可以概括为:
① 将数据和行为捆绑:类将相关的数据(成员变量)和操作数据的方法(成员函数)组织在一起。
② 隐藏实现细节:类的内部状态和实现不向外部直接暴露。
③ 通过公共接口交互:外部世界只能通过类提供的公共方法(接口)与对象进行通信和操作。
遵循封装原则会带来诸多益处:
⚝ 降低复杂度(Reduced Complexity):外部用户只需关注类提供了什么功能(接口),而无需关心这些功能是如何实现的。这简化了类的使用,降低了系统的整体认知复杂度。
⚝ 提高可维护性(Improved Maintainability):当需要修改类的内部实现时,只要不改变其公共接口,就不会影响到依赖该类的外部代码。这使得代码的修改和维护变得更加容易和安全。
⚝ 增强灵活性(Enhanced Flexibility):隐藏内部实现细节意味着将来可以在不修改公共接口的情况下,自由地改进或替换内部实现,以优化性能、修复 bug 或添加新功能。
⚝ 更易调试(Easier Debugging):当出现问题时,由于状态的修改受到方法的控制,更容易定位问题发生在哪个方法中,而不是随意地在外部修改了对象的状态。
⚝ 促进复用(Promoting Reusability):封装良好的类具有清晰的职责和接口,更容易在不同的项目或系统的不同部分中被重复使用。
总而言之,封装是构建可靠、稳定软件系统的基石。它帮助我们创建职责明确、边界清晰的软件模块,使得代码更容易理解、管理和演进。
4.2 通过访问修饰符实现封装
在 C++ 中,封装主要通过访问修饰符(Access Specifier)来实现。访问修饰符用于控制类成员(包括成员变量和成员函数)的可见性(visibility)和可访问性(accessibility)。C++ 提供了三种访问修饰符:
⚝ public
(公有的): 被声明为 public
的成员可以被类内、类外以及派生类(Derived Class)的任何代码访问。它们构成了类的公共接口。
⚝ private
(私有的): 被声明为 private
的成员只能被该类的成员函数或其友元(Friend,见第七章)访问。派生类和其他外部代码都无法直接访问 private
成员。private
成员用于隐藏类的内部实现细节。
⚝ protected
(受保护的): 被声明为 protected
的成员可以被类内成员函数、友元以及该类的派生类的成员函数访问。外部代码(非友元,非派生类成员函数)无法直接访问 protected
成员。protected
通常用于那些需要在继承体系中共享,但不希望完全暴露给外部的成员。
在一个类定义中,访问修饰符后面跟着冒号 :
,然后列出其作用范围内的成员。访问修饰符可以出现多次,每个修饰符的作用范围持续到下一个访问修饰符出现或者类定义结束。
默认情况下,在 class
关键字定义的类中,所有成员都是 private
的,直到遇到第一个访问修饰符。而在 struct
关键字定义的结构体中,所有成员默认是 public
的。
让我们通过一个简单的例子来理解访问修饰符的作用:
1
#include <iostream>
2
#include <string>
3
4
class Person {
5
public: // 公有成员,外部可访问
6
std::string getName() const {
7
return name;
8
}
9
10
void setAge(int a) {
11
if (a >= 0) { // 可以在设置时进行验证
12
age = a;
13
} else {
14
std::cerr << "Error: Age cannot be negative!" << std::endl;
15
}
16
}
17
18
int getAge() const {
19
return age;
20
}
21
22
private: // 私有成员,外部不可直接访问
23
std::string name; // 名字
24
int age; // 年龄
25
26
void internalProcessing() { // 私有方法,只供类内部使用
27
std::cout << "Performing some internal processing..." << std::endl;
28
}
29
30
protected: // 受保护成员,派生类可访问
31
std::string address; // 地址 (假设派生类可能需要访问)
32
};
33
34
class Student : public Person {
35
public:
36
void displayAddress() const {
37
// 派生类可以访问基类的 protected 成员
38
std::cout << "Student address: " << address << std::endl;
39
// 但不能访问基类的 private 成员,例如 name 或 age
40
// std::cout << "Student name: " << name << std::endl; // 错误!
41
}
42
};
43
44
int main() {
45
Person p;
46
// p.name = "Alice"; // 错误!name 是 private 的
47
// p.age = -5; // 错误!age 是 private 的
48
// p.internalProcessing(); // 错误!internalProcessing 是 private 的
49
50
p.setAge(30); // 正确!setAge 是 public 的
51
std::cout << "Age: " << p.getAge() << std::endl; // 正确!getAge 是 public 的
52
53
// p.address = "Some Address"; // 错误!address 是 protected 的,外部不可访问
54
55
Student s;
56
s.setAge(20); // 正确!从 Person 继承的 public 方法
57
s.displayAddress(); // 正确!访问了继承来的 protected 成员
58
59
return 0;
60
}
代码分析 🧐:
⚝ 在 Person
类中,name
和 age
被声明为 private
,外部的 main
函数无法直接访问或修改它们。internalProcessing
方法也是 private
的,只能在 Person
类的其他成员函数内部调用。
⚝ getName
, setAge
, getAge
被声明为 public
,它们是类的公共接口,外部代码可以通过这些方法安全地访问和修改 private
成员(在 setAge
中还进行了简单的有效性检查)。
⚝ address
被声明为 protected
。在 main
函数中,作为 Person
的外部用户,我们无法直接访问 p.address
。然而,Student
类作为 Person
的派生类,它的成员函数 displayAddress
却可以访问继承自 Person
的 protected
成员 address
。
通过合理使用 private
, protected
, public
,我们可以将类的内部实现隐藏起来,只暴露必要的操作接口,从而实现封装和信息隐藏。这是构建健壮 C++ 类的重要步骤。
4.3 getter 和 setter 方法
在实践中,为了保护类的私有成员变量不被随意访问和修改,我们通常会将它们声明为 private
。然后,如果需要允许外部访问或修改这些变量的值,我们会提供一对或多对公共(public
)的成员函数,习惯上称为:
⚝ 获取器(Getter):用于获取私有成员变量的值。通常命名为 getVarName()
或 varName()
。
⚝ 设置器(Setter):用于设置私有成员变量的值。通常命名为 setVarName(newValue)
。
这种模式是面向对象设计中实现封装的一种常见且推荐的方式。
为什么不直接将成员变量声明为 public
呢?理由有很多:
① 控制访问:Setter 方法可以在设置值之前进行有效性检查(Validation),确保数据的合法性。Getter 方法可以根据需要对数据进行处理或格式化后再返回,或者提供数据的只读访问。
② 隐藏内部表示:将来如果改变了内部存储数据的方式(比如从一个 int
变成一个 std::string
),只需要修改 getter 和 setter 方法的内部实现,而不需要改变它们的函数签名(Signature),依赖这些方法的外部代码就不需要修改。如果成员变量是 public
的,任何修改都会影响外部代码。
③ 实现逻辑:Getter 或 setter 不一定只是简单地读写一个变量。例如,一个 setter
可能触发一个内部状态的更新或通知其他部分,一个 getter
可能是一个计算属性,它根据多个私有成员计算出一个结果返回,而不是直接返回某个存储的变量。
继续使用上一节的 Person
类作为例子:
1
#include <iostream>
2
#include <string>
3
4
class Person {
5
public:
6
// Getter for name (read-only access)
7
std::string getName() const {
8
return name;
9
}
10
11
// Setter for age with validation
12
void setAge(int a) {
13
if (a >= 0 && a <= 150) { // 增加年龄范围检查
14
age = a;
15
std::cout << "Age set to: " << age << std::endl;
16
} else {
17
std::cerr << "Error: Invalid age value: " << a << std::endl;
18
}
19
}
20
21
// Getter for age
22
int getAge() const {
23
return age;
24
}
25
26
// Constructor to initialize name and age
27
Person(std::string n, int a) : name(n) {
28
setAge(a); // 使用 setter 初始化,确保年龄的有效性
29
}
30
31
private:
32
std::string name;
33
int age;
34
};
35
36
int main() {
37
Person p("Bob", 25); // 使用构造函数创建对象
38
39
std::cout << "Initial Name: " << p.getName() << std::endl;
40
std::cout << "Initial Age: " << p.getAge() << std::endl;
41
42
p.setAge(30); // 有效设置年龄
43
p.setAge(-10); // 无效设置年龄,setter 会报错
44
p.setAge(200); // 无效设置年龄
45
46
std::cout << "Current Age: " << p.getAge() << std::endl;
47
48
// p.name = "Charlie"; // 错误!name 是 private 的
49
// p.age = 50; // 错误!age 是 private 的
50
51
return 0;
52
}
代码分析 🧐:
⚝ name
和 age
成员变量被声明为 private
。
⚝ 我们提供了 getName()
作为名字的获取器,它被声明为 const
成员函数,表示该方法不会修改对象的状态。由于我们没有提供 setName()
方法,这意味着 name
对外部来说是只读的。
⚝ 我们提供了 getAge()
和 setAge(int)
作为年龄的获取器和设置器。在 setAge
中,我们添加了年龄必须在 0 到 150 之间的检查。这展示了 setter 方法如何提供受控的修改。
⚝ 构造函数 Person(std::string n, int a)
中,我们通过构造函数的初始化列表初始化 name
,并通过调用 setAge(a)
来初始化 age
,而不是直接赋值。这样做的好处是即使在初始化时也能确保 age
的有效性。
⚝ 在 main
函数中,我们看到可以直接调用 public
的 getter 和 setter 方法,而尝试直接访问或修改 private
成员则会导致编译错误。
使用 getter 和 setter 方法是实现封装的黄金法则之一,尤其是在需要对成员变量的访问或修改进行控制、验证或在未来可能改变内部实现时。当然,对于一些非常简单的、只包含数据的结构体(虽然技术上也可以用 class),或者确定未来不会改变的数据,有时也会允许 public 成员变量,但这需要谨慎权衡。
4.4 接口与实现的分离
封装的另一个重要方面是接口(Interface)与实现(Implementation)的分离。在 C++ 中,这通常通过将类的声明(声明哪些成员变量和成员函数)放在头文件(Header File,通常以 .h
或 .hpp
为后缀)中,而将成员函数的定义(实现具体功能)放在源文件(Source File,通常以 .cpp
为后缀)中来实现。
这种分离带来了显著的优势:
① 隐藏实现细节(Hiding Implementation Details):头文件只暴露类的公共接口(public
成员的声明)以及必要的私有成员的声明(虽然私有成员也是实现细节,但它们的声明必须在头文件中以便编译器了解类的内存布局)。具体的实现逻辑(函数体)被隐藏在 .cpp
文件中。这增强了信息隐藏。
② 加快编译速度(Faster Compilation):当修改 .cpp
文件中的函数实现时,由于头文件(接口)没有改变,依赖该类的其他 .cpp
文件无需重新编译,只需重新链接。这极大地提高了大型项目的编译效率。
③ 减少模块间的依赖(Reducing Dependencies):其他模块只需要 #include
类的头文件,它们只依赖于类的接口,而不依赖于具体的实现细节。这降低了模块间的耦合度。
④ 代码组织清晰(Clear Code Organization):将声明和定义分开使得代码结构更加清晰,易于阅读和管理。头文件提供了类的“契约”或“蓝图”,而源文件则提供了具体的“工作方式”。
让我们看一个简单的例子,如何将 Person
类进行接口与实现的分离:
Person.h 文件:
1
#ifndef PERSON_H
2
#define PERSON_H
3
4
#include <string>
5
#include <iostream> // 包含需要的标准库头文件
6
7
class Person {
8
public:
9
// 构造函数的声明
10
Person(std::string n, int a);
11
12
// getter 方法的声明
13
std::string getName() const;
14
int getAge() const;
15
16
// setter 方法的声明
17
void setAge(int a);
18
19
private:
20
// 私有成员变量的声明
21
std::string name;
22
int age;
23
24
// 私有成员函数的声明
25
void internalProcessing();
26
};
27
28
#endif // PERSON_H
Person.cpp 文件:
1
#include "Person.h" // 包含对应的头文件
2
3
// 构造函数的定义
4
Person::Person(std::string n, int a) : name(n) {
5
// 在定义中实现构造函数的逻辑
6
setAge(a); // 调用 setter 来设置年龄
7
internalProcessing(); // 调用私有方法
8
}
9
10
// getName 方法的定义
11
std::string Person::getName() const {
12
return name;
13
}
14
15
// getAge 方法的定义
16
int Person::getAge() const {
17
return age;
18
}
19
20
// setAge 方法的定义
21
void Person::setAge(int a) {
22
if (a >= 0 && a <= 150) {
23
age = a;
24
std::cout << "Age set to: " << age << std::endl;
25
} else {
26
std::cerr << "Error: Invalid age value: " << a << std::endl;
27
}
28
}
29
30
// internalProcessing 方法的定义
31
void Person::internalProcessing() {
32
std::cout << "Performing some internal processing for " << name << "..." << std::endl;
33
}
main.cpp 文件:
1
#include "Person.h" // 只包含类的头文件
2
3
int main() {
4
Person p("Alice", 28);
5
6
std::cout << "Name: " << p.getName() << std::endl;
7
std::cout << "Age: " << p.getAge() << std::endl;
8
9
p.setAge(30);
10
p.setAge(-5);
11
12
std::cout << "Current Age: " << p.getAge() << std::endl;
13
14
return 0;
15
}
编译过程 💻:
在典型的 C++ 开发流程中,Person.cpp
和 main.cpp
会被独立编译成目标文件(Object File,如 Person.o
和 main.o
)。这个编译过程只需要读取对应的 .cpp
文件以及其 #include
的头文件。最后,链接器(Linker)会将这些目标文件以及标准库的目标文件链接起来,生成最终的可执行程序。
总结 ✨:
通过将类的声明放在头文件中,将成员函数的定义放在源文件中,我们有效地分离了类的接口和实现。头文件定义了类的“做什么”(What it does),是外部用户与类交互的“契约”。源文件则定义了类的“如何做”(How it does it),是内部实现细节。这种分离是大型 C++ 项目中常用的代码组织方式,是良好封装和信息隐藏的重要实践。
至此,我们对 C++ 中的封装和信息隐藏有了深入的理解。我们学习了如何使用访问修饰符 (public
, private
, protected
) 控制成员的可见性,如何通过 getter 和 setter 方法提供对私有数据的受控访问,以及如何通过将接口与实现分离来组织代码并增强信息隐藏。掌握这些技术是编写健壮、可维护、灵活的 C++ 代码的基础。
5. 继承:代码的复用与扩展
本章将深入探讨面向对象编程 (Object-Oriented Programming, OOP) 的核心机制之一:继承 (Inheritance)。继承允许我们基于现有的类创建新类,从而实现代码的复用和扩展。理解继承是掌握多态 (Polymorphism) 和构建复杂类层次结构的关键。通过本章的学习,读者将能够理解继承的基本概念、C++ 中不同的继承方式及其对访问权限的影响、继承中构造函数和析构函数的行为,以及更高级的多重继承 (Multiple Inheritance) 和虚继承 (Virtual Inheritance) 等概念。掌握继承,将极大地提升代码的可维护性、可扩展性和复用性。
5.1 继承的基本概念
编程的本质之一在于管理复杂性并提高效率。随着程序规模的扩大,我们总是希望能够复用已有的代码,而不是从头开始编写一切。继承正是面向对象编程提供的一种强大的代码复用和扩展机制。
① 什么是继承?
继承是一种建立类之间关系的方式,它允许一个新类(称为派生类 (Derived Class) 或子类 (Subclass))继承另一个现有类(称为基类 (Base Class) 或父类 (Superclass))的属性(数据成员 (Data Member))和行为(成员函数 (Member Function))。简单来说,派生类“是”基类的一种特殊类型,它拥有基类的所有成员,并且可以在此基础上添加新的成员或修改(重写 (Override))继承来的行为。
② 继承的目的是什么?
继承的主要目的包括:
▮▮▮▮ⓐ 代码复用 (Code Reusability):派生类无需重新定义基类中已有的成员,可以直接使用基类的实现。这减少了代码量,提高了开发效率。
▮▮▮▮ⓑ 接口统一:通过继承,一组相关的类可以共享一个共同的基类接口,这对于实现多态至关重要。
▮▮▮▮ⓒ 建模现实世界:继承关系常常用于模拟现实世界中的分类关系,例如“狗是动物”,“汽车是交通工具”。
▮▮▮▮ⓓ 软件的易扩展性 (Extensibility):当需要添加新的功能或特殊类型的对象时,可以通过派生新类来实现,而无需修改基类或其他现有类。
③ 基类与派生类
在 C++ 中,我们使用特定的语法来定义继承关系。
⚝ 基类 (Base Class): 提供共同属性和行为的类。
⚝ 派生类 (Derived Class): 从基类继承属性和行为的类。派生类通常比基类更具体。
例如,我们可以有一个 Animal
(动物)基类,然后派生出 Dog
(狗)和 Cat
(猫)等类。Dog
和 Cat
都拥有动物的基本属性(如年龄、名字)和行为(如吃、睡觉),但它们各自有特有的属性(如狗的品种、猫的毛色)和行为(如狗叫、猫叫)。
1
// 基类 Animal 的定义
2
class Animal {
3
public:
4
void eat() {
5
// 吃东西的行为
6
}
7
void sleep() {
8
// 睡觉的行为
9
}
10
private:
11
int age;
12
protected:
13
std::string name;
14
};
15
16
// 派生类 Dog 的定义,公有继承自 Animal
17
class Dog : public Animal {
18
public:
19
void bark() {
20
// 狗叫的行为
21
}
22
// Dog 特有的成员
23
private:
24
std::string breed;
25
};
26
27
// 派生类 Cat 的定义,公有继承自 Animal
28
class Cat : public Animal {
29
public:
30
void meow() {
31
// 猫叫的行为
32
}
33
// Cat 特有的成员
34
private:
35
std::string color;
36
};
上面的例子展示了 Dog
和 Cat
如何通过 :
符号并指定继承方式 (public
) 从 Animal
类继承。这意味着 Dog
和 Cat
的对象将拥有 eat()
和 sleep()
方法。
④ "is-a" 关系
继承最常用来表达 "is-a"(是一个)关系。例如:
⚝ "A Dog is an Animal."(狗是一种动物)
⚝ "A Car is a Vehicle."(汽车是一种交通工具)
⚝ "A Square is a Rectangle."(正方形是一种矩形)
当类 A 继承自类 B 时,通常意味着类 A 是类 B 的一个更具体的类型。这种关系对于理解何时应该使用继承至关重要。如果两个类之间不是 "is-a" 关系,而更像是 "has-a"(有一个)或 "uses-a"(使用一个)关系,那么组合 (Composition) 或关联 (Association) 可能是更合适的设计选择,而不是继承。例如,一个 Car
"has a" Engine
(汽车有一个引擎),这里就应该使用组合,而不是让 Car
继承 Engine
。
总结本节:继承是 OOP 中实现代码复用和扩展的基础机制,它通过建立基类与派生类之间的 "is-a" 关系来实现。
5.2 继承中的访问控制
在基类中,成员变量和成员函数可以有 public
, protected
, 或 private
三种访问修饰符 (Access Specifier)。这些修饰符决定了类成员的可访问范围。当派生类从基类继承时,基类成员在派生类中的可访问性会受到继承方式 (public
, protected
, private
) 和基类成员自身访问修饰符的双重影响。理解这一点对于正确设计类层次结构和维护封装性 (Encapsulation) 至关重要。
① 基类成员自身的访问级别回顾
⚝ public
: 可以在类的内部、派生类中以及类的外部(通过对象)访问。
⚝ protected
: 可以在类的内部和派生类中访问,但不能在类的外部访问。
⚝ private
: 只能在类的内部访问,派生类和类的外部都无法直接访问。
② 继承方式 (public
, protected
, private
)
派生类在继承时可以指定继承方式,这会影响基类成员在派生类中的最低访问权限。
⚝ 公有继承 (Public Inheritance): 这是最常用的继承方式,表达典型的 "is-a" 关系。
▮▮▮▮⚝ 基类的 public
成员在派生类中仍然是 public
。
▮▮▮▮⚝ 基类的 protected
成员在派生类中仍然是 protected
。
▮▮▮▮⚝ 基类的 private
成员在派生类中仍然是 private
,对派生类不可直接访问。
▮▮▮▮⚝ 简而言之,公有继承保持了基类成员原有的访问级别(除了 private
成员依然不可访问)。
▮▮▮▮⚝ 示例:class Derived : public Base { ... };
⚝ 保护继承 (Protected Inheritance):
▮▮▮▮⚝ 基类的 public
成员在派生类中变为 protected
。
▮▮▮▮⚝ 基类的 protected
成员在派生类中仍然是 protected
。
▮▮▮▮⚝ 基类的 private
成员在派生类中仍然是 private
,对派生类不可直接访问。
▮▮▮▮⚝ 使用保护继承时,基类的公有接口在派生类及其后续派生类中可见,但对派生类的外部是隐藏的。这种方式不如公有继承常用。
▮▮▮▮⚝ 示例:class Derived : protected Base { ... };
⚝ 私有继承 (Private Inheritance):
▮▮▮▮⚝ 基类的 public
成员在派生类中变为 private
。
▮▮▮▮⚝ 基类的 protected
成员在派生类中变为 private
。
▮▮▮▮⚝ 基类的 private
成员在派生类中仍然是 private
,对派生类不可直接访问。
▮▮▮▮⚝ 私有继承通常用来表示 "implemented-in-terms-of"(基于...实现)的关系,或者说 "has-a" 关系的一种特殊实现方式,而不是 "is-a" 关系。基类的公有接口在派生类中完全隐藏,不能通过派生类对象访问基类成员。
▮▮▮▮⚝ 示例:class Derived : private Base { ... };
③ 总结继承中的访问权限规则
这是一个关于基类成员在不同继承方式下,在派生类内部的可访问性总结表:
基类成员访问级别 | 公有继承 (public) | 保护继承 (protected) | 私有继承 (private) |
---|---|---|---|
public | public | protected | private |
protected | protected | protected | private |
private | 不可访问 (不可见) | 不可访问 (不可见) | 不可访问 (不可见) |
注意: 上表描述的是基类成员在派生类内部的访问权限。对于派生类对象在类的外部访问基类成员,规则取决于派生类自身的访问修饰符以及继承方式。例如,如果 Derived
是 public
继承自 Base
,并且 Base
的某个成员是 public
的,那么可以通过 Derived
对象从外部访问该成员。但如果 Derived
是 protected
或 private
继承自 Base
,即使基类成员是 public
的,也不能通过 Derived
对象从外部直接访问该成员。
④ 访问权限的实际应用
⚝ public
继承:用于实现 "is-a" 关系,保留基类的接口特性,是实现多态的基础。
⚝ protected
成员:通常用于基类中那些既希望在派生类内部可见,又不希望在外部被随意访问的成员。
⚝ private
继承:较少直接使用,有时用于实现一个类基于另一个类功能的需求,但不希望暴露基类的公共接口。通常组合是更好的选择。
正确理解和运用继承中的访问控制是编写安全、健壮且符合设计意图的 C++ 代码的关键。
1
class Base {
2
public:
3
int public_mem;
4
protected:
5
int protected_mem;
6
private:
7
int private_mem;
8
9
public:
10
void public_func() {
11
// 可以访问所有成员
12
public_mem = 1;
13
protected_mem = 2;
14
private_mem = 3;
15
}
16
protected:
17
void protected_func() {
18
// 可以访问所有成员
19
}
20
private:
21
void private_func() {
22
// 可以访问所有成员
23
}
24
};
25
26
class DerivedPublic : public Base {
27
public:
28
void derived_public_func() {
29
// 可以访问 public_mem 和 protected_mem
30
public_mem = 10;
31
protected_mem = 20;
32
// 不能访问 private_mem
33
// private_mem = 30; // 错误!
34
35
// 可以调用 public_func() 和 protected_func()
36
public_func();
37
protected_func();
38
// 不能调用 private_func()
39
// private_func(); // 错误!
40
}
41
};
42
43
class DerivedProtected : protected Base {
44
public:
45
void derived_protected_func() {
46
// 可以访问 public_mem (现在是 protected) 和 protected_mem
47
public_mem = 100;
48
protected_mem = 200;
49
// 不能访问 private_mem
50
// private_mem = 300; // 错误!
51
52
// 可以调用 public_func() (现在是 protected) 和 protected_func()
53
public_func();
54
protected_func();
55
// 不能调用 private_func()
56
// private_func(); // 错误!
57
}
58
};
59
60
class DerivedPrivate : private Base {
61
public:
62
void derived_private_func() {
63
// 可以访问 public_mem (现在是 private) 和 protected_mem (现在是 private)
64
public_mem = 1000;
65
protected_mem = 2000;
66
// 不能访问 private_mem
67
// private_mem = 3000; // 错误!
68
69
// 可以调用 public_func() (现在是 private) 和 protected_func() (现在是 private)
70
public_func();
71
protected_func();
72
// 不能调用 private_func()
73
// private_func(); // 错误!
74
}
75
};
76
77
int main() {
78
DerivedPublic dp;
79
dp.public_mem = 11; // OK
80
dp.public_func(); // OK
81
// dp.protected_mem = 22; // 错误!protected 成员不能通过对象从外部访问
82
// dp.protected_func(); // 错误!protected 成员不能通过对象从外部访问
83
// dp.private_mem = 33; // 错误!private 成员不能通过对象从外部访问
84
// dp.private_func(); // 错误!private 成员不能通过对象从外部访问
85
86
DerivedProtected dprot;
87
// dprot.public_mem = 111; // 错误!基类的 public 成员在保护继承下在派生类外部不可见
88
// dprot.public_func(); // 错误!
89
90
DerivedPrivate dpri;
91
// dpri.public_mem = 1111; // 错误!基类的 public 成员在私有继承下在派生类外部不可见
92
// dpri.public_func(); // 错误!
93
94
return 0;
95
}
上面的例子清晰地展示了不同继承方式对基类成员在派生类内部和外部访问权限的影响。
5.3 构造函数与析构函数在继承中的行为
继承关系建立后,当创建或销毁派生类对象时,基类和派生类的构造函数 (Constructor) 和析构函数 (Destructor) 会按照特定的顺序被调用。理解这个顺序对于正确初始化和清理对象资源至关重要,尤其是在资源管理复杂的场景下。
① 派生类对象的构造过程
当创建一个派生类对象时,构造函数的调用顺序遵循“先基类,后派生类”的原则。如果存在多层继承,则遵循从最顶层基类开始,依次向下调用各级基类的构造函数,最后调用派生类自身的构造函数。
具体步骤如下:
① 调用直接基类的构造函数。如果有多个直接基类(多重继承),调用顺序取决于在派生类定义时基类出现的顺序。
② 按照声明顺序,初始化派生类自身的成员变量。这通常通过构造函数的初始化列表 (Initialization List) 完成。
③ 执行派生类构造函数体内的代码。
示例:
1
#include <iostream>
2
#include <string>
3
4
class Base {
5
public:
6
Base(const std::string& msg) {
7
std::cout << "Base Constructor: " << msg << std::endl;
8
}
9
~Base() {
10
std::cout << "Base Destructor" << std::endl;
11
}
12
};
13
14
class Derived : public Base {
15
public:
16
Derived(const std::string& base_msg, const std::string& derived_msg)
17
: Base(base_msg) { // 通过初始化列表调用基类构造函数
18
std::cout << "Derived Constructor: " << derived_msg << std::endl;
19
}
20
~Derived() {
21
std::cout << "Derived Destructor" << std::endl;
22
}
23
};
24
25
int main() {
26
Derived d("Hello from Base", "Hello from Derived");
27
// 对象 d 在此处生命周期结束时会被销毁
28
return 0;
29
}
输出预测:
1
Base Constructor: Hello from Base
2
Derived Constructor: Hello from Derived
3
Derived Destructor
4
Base Destructor
解释: 在 main
函数中创建 Derived
对象 d
时,首先调用 Derived
的构造函数。Derived
的构造函数通过初始化列表 : Base(base_msg)
调用了 Base
的构造函数。Base
构造函数执行完毕后,Derived
构造函数体内的代码才开始执行。
重要提示: 派生类构造函数必须负责调用其基类的构造函数来初始化基类部分的成员。如果在派生类构造函数的初始化列表中没有显式调用基类构造函数,编译器会尝试调用基类的默认构造函数 (Default Constructor)。如果基类没有默认构造函数(例如,只定义了带参数的构造函数而没有定义无参构造函数),并且派生类构造函数也没有显式调用基类的其他构造函数,则会导致编译错误。
② 派生类对象的销毁过程
当派生类对象被销毁时(例如,对象超出作用域或使用 delete
删除堆对象),析构函数 (Destructor) 的调用顺序与构造函数相反,遵循“先派生类,后基类”的原则。
具体步骤如下:
① 调用派生类自身的析构函数。
② 按照与构造时相反的顺序,调用直接基类的析构函数。
示例 (使用上面的类定义):
1
#include <iostream>
2
#include <string>
3
4
class Base {
5
public:
6
Base(const std::string& msg) {
7
std::cout << "Base Constructor: " << msg << std::endl;
8
}
9
~Base() {
10
std::cout << "Base Destructor" << std::endl;
11
}
12
};
13
14
class Derived : public Base {
15
public:
16
Derived(const std::string& base_msg, const std::string& derived_msg)
17
: Base(base_msg) {
18
std::cout << "Derived Constructor: " << derived_msg << std::endl;
19
}
20
~Derived() {
21
std::cout << "Derived Destructor" << std::endl;
22
}
23
};
24
25
int main() {
26
Derived d("Hello from Base", "Hello from Derived");
27
// 对象 d 在此处生命周期结束时会被销毁
28
return 0;
29
}
输出预测:
1
Base Constructor: Hello from Base
2
Derived Constructor: Hello from Derived
3
Derived Destructor <-- 先调用派生类析构函数
4
Base Destructor <-- 再调用基类析构函数
解释: 对象 d
生命周期结束时,首先调用 Derived
的析构函数,执行完 Derived
析构函数体内的代码后,会自动调用 Base
的析构函数。
重要提示: 如果基类中拥有需要释放的资源(如动态分配的内存),基类的析构函数通常应该声明为 virtual
(虚函数)。这是实现多态环境下正确清理资源的关键。如果在通过基类指针删除派生类对象时,基类析构函数不是虚函数,那么只有基类的析构函数会被调用,派生类特有的清理工作将不会执行,可能导致资源泄露 (Resource Leak)。虚析构函数 (Virtual Destructor) 将在下一章(多态)中详细讲解。
③ 派生类构造函数如何调用基类构造函数
派生类构造函数通过其初始化列表来调用基类的构造函数。语法是在派生类构造函数的参数列表后面加上 :
,然后列出需要调用的基类构造函数(以及派生类自身成员的初始化)。
1
Derived::Derived(const std::string& base_msg, const std::string& derived_msg)
2
: Base(base_msg), // 调用基类 Base 的带参构造函数
3
// member1(initial_value1), // 初始化派生类成员 member1 (如果存在)
4
// member2(initial_value2) // 初始化派生类成员 member2 (如果存在)
5
{
6
// 派生类构造函数体
7
}
如果基类有默认构造函数,且派生类构造函数没有显式调用基类构造函数,编译器会自动调用基类的默认构造函数:
1
class BaseWithDefault {
2
public:
3
BaseWithDefault() {
4
std::cout << "BaseWithDefault Default Constructor" << std::}
5
};
6
7
class DerivedSimple : public BaseWithDefault {
8
public:
9
DerivedSimple() { // 没有显式调用基类构造函数
10
std::cout << "DerivedSimple Constructor" << std::}
11
};
12
13
int main() {
14
DerivedSimple ds;
15
return 0;
16
}
输出预测:
1
BaseWithDefault Default Constructor
2
DerivedSimple Constructor
3
BaseWithDefault Destructor // 假设 BaseWithDefault 有析构函数
4
DerivedSimple Destructor // 假设 DerivedSimple 有析构函数
这再次强调了在基类没有默认构造函数时,派生类必须显式调用基类的其他构造函数。
总结本节:派生类对象的构造顺序是先基类后派生类,析构顺序是先派生类后基类。派生类通过初始化列表调用基类构造函数。理解这个顺序对于正确管理对象生命周期和资源至关重要。
5.4 单一继承与多重继承
继承可以分为单一继承 (Single Inheritance) 和多重继承 (Multiple Inheritance)。单一继承是最常见和推荐的方式,而多重继承虽然提供了更大的灵活性,但也可能带来复杂性和问题。
① 单一继承
单一继承是指一个派生类只从一个直接基类继承。这是最简单、最直观的继承形式,也是大部分面向对象语言支持的主要继承机制(例如 Java、C# 只支持单一继承)。
特点:
⚝ 类之间的关系形成一个树状结构或链状结构,清晰易懂。
⚝ 对象模型相对简单。
⚝ 通常不会引入复杂的命名冲突或二义性问题。
示例:
1
class A { /* ... */ };
2
class B : public A { /* B 继承自 A */ };
3
class C : public B { /* C 继承自 B */ }; // 多层单一继承
这里,B
单一继承 A
,C
单一继承 B
。这是一个典型的继承链。
② 多重继承
多重继承是指一个派生类从多个直接基类继承。
特点:
⚝ 允许一个类同时继承多个类的属性和行为,提供了更强的代码复用能力。
⚝ 类之间的关系可以形成一个有向无环图 (Directed Acyclic Graph, DAG)。
⚝ 可能引入复杂性,最典型的是菱形继承问题 (Diamond Problem)。
⚝ 可能导致命名冲突:如果多个基类拥有同名成员,派生类在访问时可能产生二义性。
语法: 在派生类定义时,在冒号 :
后列出所有直接基类,用逗号 ,
分隔。
1
class Base1 {
2
public:
3
void func1() { /* ... */ }
4
};
5
6
class Base2 {
7
public:
8
void func2() { /* ... */ }
9
// 假设 Base2 中也有一个名为 func1 的函数
10
void func1() { /* ... */ }
11
};
12
13
class Derived : public Base1, public Base2 {
14
public:
15
void func3() {
16
// 可以调用 Base1 的 func1 和 Base2 的 func2
17
Base1::func1(); // 显式指定调用 Base1 的 func1,避免二义性
18
Base2::func2(); // OK
19
// Base2::func1(); // 显式指定调用 Base2 的 func1
20
}
21
// 如果不使用 Base1:: 或 Base2:: 显式指定,直接调用 func1() 会产生编译错误 (ambiguity)
22
};
多重继承的潜在问题:
⚝ 命名冲突 (Name Clashing): 如果多个基类有同名的成员(函数或数据),在派生类中直接访问该成员会导致编译错误,因为编译器不知道你想访问哪个基类的成员。解决办法是使用作用域解析符 ::
显式指定基类。
⚝ 菱形继承问题 (Diamond Problem): 这是多重继承中最复杂的问题,将在下一节详细讨论。它可能导致基类子对象在派生类中重复出现和访问二义性。
何时使用多重继承?
多重继承在 C++ 中是合法的,但在实际开发中需要谨慎使用。它常常用于实现接口继承或混合类 (Mixin Class)。例如,一个类可能既需要某个接口的功能,又需要从另一个类继承一些实现细节。
尽管多重继承提供了灵活性,但由于其可能带来的复杂性(尤其是菱形继承),在许多情况下,使用单一继承结合组合、接口类(抽象类 (Abstract Class) 只有纯虚函数 (Pure Virtual Function) 的类)等方式可以达到类似的设计目标,且通常更容易理解和维护。软件设计大师们普遍推荐优先考虑单一继承和组合。
总结本节:单一继承简单明了,是主流继承方式。多重继承允许从多个基类继承,提供了更强的组合能力,但也可能引入命名冲突和菱形继承等问题,需要谨慎使用。
5.5 菱形继承与虚继承
多重继承中最著名也最棘手的问题是菱形继承问题 (Diamond Problem),而 C++ 提供了虚继承 (Virtual Inheritance) 机制来解决它。
① 菱形继承问题 (Diamond Problem)
菱形继承是指存在一个类 Derived
,它同时继承自两个类 Base1
和 Base2
,而 Base1
和 Base2
又都继承自同一个基类 GrandBase
。其继承关系图呈现菱形:
1
GrandBase
2
/ / Base1 Base2
3
\ /
4
\ /
5
Derived
问题所在:
⚝ 数据冗余 (Data Redundancy): 如果不采取特殊措施,Derived
类会拥有两个 GrandBase
子对象,一个来自 Base1
,一个来自 Base2
。这意味着 GrandBase
中的数据成员会在 Derived
对象中存储两份,造成内存浪费。
⚝ 访问二义性 (Ambiguity): 如果 GrandBase
中有一个成员(例如一个函数 greet()
或一个数据 value
),而 Derived
对象尝试直接访问这个成员(例如 derived_obj.greet()
或 derived_obj.value
),编译器将不知道应该通过 Base1
路径访问 GrandBase
的子对象,还是通过 Base2
路径访问 GrandBase
的子对象,从而产生二义性错误。
示例:
1
#include <iostream>
2
3
class GrandBase {
4
public:
5
int value;
6
GrandBase(int v) : value(v) {
7
std::cout << "GrandBase Constructor, value = " << value << std::endl;
8
}
9
};
10
11
class Base1 : public GrandBase {
12
public:
13
Base1(int v) : GrandBase(v) {
14
std::cout << "Base1 Constructor" << std::endl;
15
}
16
};
17
18
class Base2 : public GrandBase {
19
public:
20
Base2(int v) : GrandBase(v) {
21
std::cout << "Base2 Constructor" << std::endl;
22
}
23
};
24
25
class Derived : public Base1, public Base2 {
26
public:
27
// 派生类构造函数需要初始化所有直接基类
28
Derived(int v1, int v2) : Base1(v1), Base2(v2) {
29
std::cout << "Derived Constructor" << std::endl;
30
}
31
32
void show_values() {
33
// 问题:如何访问 GrandBase 的 value?
34
// std::cout << "Value: " << value << std::endl; // 二义性错误!
35
// 需要显式指定路径:
36
std::cout << "Value via Base1: " << Base1::value << std::endl;
37
std::cout << "Value via Base2: " << Base2::value << std::endl;
38
}
39
};
40
41
int main() {
42
Derived d(10, 20); // 创建 Derived 对象
43
d.show_values();
44
return 0;
45
}
输出预测:
1
GrandBase Constructor, value = 10 <-- 通过 Base1 调用 GrandBase 构造函数
2
Base1 Constructor
3
GrandBase Constructor, value = 20 <-- 通过 Base2 调用 GrandBase 构造函数
4
Base2 Constructor
5
Derived Constructor
6
Value via Base1: 10
7
Value via Base2: 20
8
// 析构函数略 (顺序相反)
注意 GrandBase
的构造函数被调用了两次,说明有两个 GrandBase
的子对象。尝试直接访问 value
会产生编译错误 request for member 'value' is ambiguous
。
② 虚继承 (Virtual Inheritance)
为了解决菱形继承带来的数据冗余和访问二义性问题,C++ 引入了虚继承。通过在继承时使用 virtual
关键字,可以确保在多重继承形成的类层次结构中,某个基类(通常是顶层基类)的子对象只会被创建和共享一份,而不是在派生类中重复出现。
语法: 在声明派生类时,在继承列表中,基类名前加上 virtual
关键字。
1
class GrandBase {
2
public:
3
int value;
4
GrandBase(int v) : value(v) {
5
std::cout << "GrandBase Constructor (virtual): " << value << std::endl;
6
}
7
};
8
9
class Base1 : virtual public GrandBase { // 虚继承
10
public:
11
Base1(int v) : GrandBase(v) { // 仍然需要调用 GrandBase 构造函数
12
std::cout << "Base1 Constructor (virtual)" << std::endl;
13
}
14
};
15
16
class Base2 : virtual public GrandBase { // 虚继承
17
public:
18
Base2(int v) : GrandBase(v) { // 仍然需要调用 GrandBase 构造函数
19
std::cout << "Base2 Constructor (virtual)" << std::endl;
20
}
21
};
22
23
class Derived : public Base1, public Base2 {
24
public:
25
// 在虚继承中,虚基类的构造函数由 最底层派生类 负责调用
26
// 而中间的派生类 (Base1, Base2) 对虚基类构造函数的调用会被忽略
27
Derived(int v_grand, int v1, int v2)
28
: GrandBase(v_grand), // 由 Derived 直接调用 GrandBase 构造函数
29
Base1(v1), // 调用 Base1 构造函数
30
Base2(v2) // 调用 Base2 构造函数
31
{
32
std::cout << "Derived Constructor (virtual)" << std::endl;
33
}
34
35
void show_value() {
36
// 现在直接访问 value 不会有二义性,因为它只有一个 GrandBase 子对象
37
std::cout << "Shared Value: " << value << std::endl;
38
// 也可以通过基类路径访问,它们都指向同一个子对象
39
std::cout << "Value via Base1: " << Base1::value << std::endl; // 仍然可以通过作用域解析符访问
40
std::cout << "Value via Base2: " << Base2::value << std::endl;
41
}
42
};
43
44
int main() {
45
Derived d(100, 10, 20); // 创建 Derived 对象
46
d.show_value();
47
return 0;
48
}
输出预测:
1
GrandBase Constructor (virtual): 100 <-- GrandBase 构造函数只被调用一次
2
Base1 Constructor (virtual)
3
Base2 Constructor (virtual)
4
Derived Constructor (virtual)
5
Shared Value: 100
6
Value via Base1: 100
7
Value via Base2: 100
8
// 析构函数略 (顺序相反,虚基类的析构函数最后调用一次)
虚继承的原理与构造函数调用规则:
⚝ 当使用虚继承时,编译器会特殊处理被虚继承的基类(虚基类)。在整个继承体系中,无论虚基类出现在继承链的哪个位置,最底层的派生类 (Most Derived Class) 会负责构造和销毁虚基类的那唯一一个共享子对象。
⚝ 因此,最底层派生类的构造函数必须在其初始化列表中显式调用虚基类的构造函数来初始化该共享子对象。即使中间的派生类(如 Base1
和 Base2
)也显式调用了虚基类的构造函数,这些调用在创建最底层派生类对象时会被忽略,真正生效的是最底层派生类的调用。
⚝ 如果最底层派生类没有显式调用虚基类的构造函数,编译器会尝试调用虚基类的默认构造函数。
⚝ 虚基类的构造顺序在所有非虚基类构造完成后、派生类自身成员构造前进行。
虚继承的开销:
虚继承虽然解决了菱形继承问题,但它引入了一些运行时开销:
⚝ 内存开销: 为了实现共享子对象的访问,通常需要一个额外的虚指针 (Virtual Pointer) 或类似机制来定位共享的虚基类子对象。这会增加对象的大小。
⚝ 访问开销: 访问虚基类成员可能比访问普通继承的成员稍微慢一些,因为可能需要通过额外的指针或表进行间接访问。
⚝ 构造开销: 虚基类的构造机制比普通继承复杂。
何时使用虚继承?
虚继承主要用于解决菱形继承问题,或者当设计意图确实是希望在多重继承体系中存在一个共享的基类子对象时。例如,某些库设计中,可能存在一个共同的顶级抽象接口类,希望所有具体实现类共享这个接口类的信息,这时可能会使用虚继承。然而,由于其复杂性和开销,应谨慎使用,并在确认需要解决菱形继承或实现特定共享基类语义时才考虑。
总结本节:菱形继承是多重继承中的一个常见问题,导致数据冗余和访问二义性。虚继承通过 virtual
关键字解决这个问题,确保共享基类子对象只存在一份,并由最底层派生类负责构造。使用虚继承需要权衡其带来的开销和复杂性。
6. 多态:实现灵活性的关键
6.1 多态的定义与分类
多态 (Polymorphism) 是面向对象编程 (Object-Oriented Programming, OOP) 的三大核心特征之一,与封装 (Encapsulation) 和继承 (Inheritance) 并列。多态一词源于希腊语,意为“多种形态”。在编程中,多态指的是允许使用一个接口 (Interface) 表示多种不同类型对象的能力。这意味着可以使用一个基类 (Base Class) 的指针 (Pointer) 或引用 (Reference) 来操作派生类 (Derived Class) 的对象,并且调用的方法会根据实际指向或引用的对象类型在运行时 (Runtime) 或编译时 (Compile Time) 确定,从而表现出不同的行为。多态极大地增强了代码的灵活性、可扩展性和可维护性。想象一下,如果我们要处理不同形状(如圆形、矩形、三角形)的图形,没有多态,我们需要写大量的 if-else 或 switch 语句来判断图形类型并调用相应的绘制函数;而有了多态,我们可以将这些图形视为同一种“形状”对象,通过一个统一的接口调用它们的绘制方法,具体的绘制逻辑由每种图形自己实现。
多态在 C++ 中主要可以分为两大类:
① 静态多态 (Static Polymorphism) 或称编译时多态 (Compile-Time Polymorphism)
▮▮▮▮⚝ 静态多态在程序编译阶段就已经确定了调用哪个函数或使用哪个类型。它的主要实现方式包括:
▮▮▮▮⚝ 函数重载 (Function Overloading):在同一作用域内,函数名相同但参数列表 (Parameter List) 不同的函数。编译器根据传递的参数类型和数量来决定调用哪个重载函数。
▮▮▮▮⚝ 运算符重载 (Operator Overloading):允许自定义运算符对于特定类型的行为。编译器根据运算符的操作数类型来决定调用哪个重载运算符函数。
▮▮▮▮⚝ 模板 (Template,包括函数模板和类模板):允许编写能够处理多种类型的通用代码。编译器在编译时根据模板参数实例化出具体的函数或类。
▮▮▮▮⚝ 静态多态的优点是效率高,因为所有的决定都在编译时做出,没有运行时的开销。缺点是缺乏动态性,一旦编译完成,行为就固定了。
1
#include <iostream>
2
3
// 函数重载示例
4
void print(int i) {
5
std::cout << "Printing int: " << i << std::endl;
6
}
7
8
void print(double d) {
9
std::cout << "Printing double: " << d << std::endl;
10
}
11
12
void print(const std::string& s) {
13
std::cout << "Printing string: " << s << std::endl;
14
}
15
16
int main() {
17
print(10); // 调用 print(int)
18
print(3.14); // 调用 print(double)
19
print("Hello"); // 调用 print(const std::string&)
20
return 0;
21
}
② 动态多态 (Dynamic Polymorphism) 或称运行时多态 (Runtime Polymorphism)
▮▮▮▮⚝ 动态多态在程序运行时根据对象的实际类型来确定调用的具体方法。这是 OOP 中实现“同一接口,不同行为”的关键。
▮▮▮▮⚝ 动态多态主要通过以下机制实现:
▮▮▮▮⚝ 继承 (Inheritance):必须存在一个基类和派生类之间的继承关系。
▮▮▮▮⚝ 虚函数 (Virtual Function):在基类中使用 virtual
关键字声明的函数,派生类可以重写 (Override) 这些函数。
▮▮▮▮⚝ 基类指针或引用:必须通过基类的指针或引用来调用虚函数。
▮▮▮▮⚝ 动态多态的优点是极大地增强了代码的灵活性和可扩展性,允许在不修改现有代码的情况下引入新的类型。缺点是存在一定的运行时开销(通常通过虚函数表实现)。
1
#include <iostream>
2
#include <string>
3
4
// 基类
5
class Animal {
6
public:
7
// 虚函数
8
virtual void speak() const {
9
std::cout << "Animal speaks." << std::endl;
10
}
11
12
// 非虚函数
13
void eat() const {
14
std::cout << "Animal eats." << std::endl;
15
}
16
17
virtual ~Animal() = default; // 虚析构函数,保证派生类对象能正确销毁
18
};
19
20
// 派生类 Cat
21
class Cat : public Animal {
22
public:
23
// 重写虚函数
24
void speak() const override {
25
std::cout << "Cat says Meow!" << std::endl;
26
}
27
28
// Cat 特有的函数
29
void climb() const {
30
std::cout << "Cat climbs." << std::endl;
31
}
32
};
33
34
// 派生类 Dog
35
class Dog : public Animal {
36
public:
37
// 重写虚函数
38
void speak() const override {
39
std::cout << "Dog says Woof!" << std::endl;
40
}
41
42
// Dog 特有的函数
43
void fetch() const {
44
std::cout << "Dog fetches." << std::endl;
45
}
46
};
47
48
int main() {
49
Animal* myAnimal; // 基类指针
50
51
// 指向 Cat 对象
52
Cat myCat;
53
myAnimal = &myCat;
54
myAnimal->speak(); // 调用 Cat 的 speak(),动态多态的核心体现
55
myAnimal->eat(); // 调用 Animal 的 eat(),非虚函数,行为取决于指针类型
56
57
std::cout << std::endl;
58
59
// 指向 Dog 对象
60
Dog myDog;
61
myAnimal = &myDog;
62
myAnimal->speak(); // 调用 Dog 的 speak()
63
myAnimal->eat(); // 调用 Animal 的 eat()
64
65
// 注意:通过基类指针无法直接调用派生类特有的函数
66
// myAnimal->climb(); // 错误
67
68
return 0;
69
}
上述示例清晰地展示了动态多态。尽管 myAnimal
是一个 Animal*
类型的指针,但在调用虚函数 speak()
时,程序的行为却取决于它实际指向的对象类型(是 Cat
还是 Dog
),这就是运行时多态。而非虚函数 eat()
的行为则只取决于指针的静态类型 (Animal*
)。
总结来说,静态多态主要通过函数名或运算符的重载以及模板来实现编译时绑定,适用于处理不同类型但逻辑相似的操作;动态多态则通过虚函数和继承机制实现运行时绑定,适用于处理具有共同基类但在行为上有差异的对象集合。在 C++ 的面向对象编程中,动态多态通常是实现灵活、可扩展设计的核心手段。
6.2 动态多态的实现:虚函数
动态多态在 C++ 中主要依赖于虚函数 (Virtual Function) 机制。当通过基类的指针或引用调用一个虚函数时,编译器会确保调用的是对象实际类型所对应的那个函数版本,而不是指针或引用声明类型所对应的版本。这个机制通常是通过虚函数表 (Virtual Table / vtable) 和虚指针 (Virtual Pointer / vptr) 来实现的。
① 虚函数 (Virtual Function)
▮▮▮▮⚝ 在基类中,使用 virtual
关键字声明的成员函数就是虚函数。
▮▮▮▮⚝ 派生类可以重写 (Override) 基类的虚函数,提供自己的实现。
▮▮▮▮⚝ 如果派生类没有重写虚函数,则继承基类的虚函数版本。
▮▮▮▮⚝ 虚函数必须是类的成员函数,不能是全局函数。
▮▮▮▮⚝ 构造函数 (Constructor) 不能是虚函数,因为在构造对象时,类型是已知的,无需多态;且虚函数机制依赖于虚函数表,而虚函数表在构造函数执行期间才建立。
▮▮▮▮⚝ 析构函数 (Destructor) 通常应该声明为虚函数,以确保通过基类指针删除派生类对象时,能够正确调用派生类的析构函数进行清理,避免资源泄露 (Memory Leak)。这是基类设计中的一个重要原则。
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual void show() const { // 虚函数
6
std::cout << "Base::show()" << std::endl;
7
}
8
9
void print() const { // 非虚函数
10
std::cout << "Base::print()" << std::endl;
11
}
12
13
virtual ~Base() { // 虚析构函数
14
std::cout << "Base::~Base()" << std::endl;
15
}
16
};
17
18
class Derived : public Base {
19
public:
20
void show() const override { // 重写虚函数
21
std::cout << "Derived::show()" << std::endl;
22
}
23
24
void print() const { // 隐藏 (Hiding) 非虚函数
25
std::cout << "Derived::print()" << std::endl;
26
}
27
28
~Derived() override { // 重写虚析构函数
29
std::cout << "Derived::~Derived()" << std::endl;
30
}
31
};
32
33
int main() {
34
Base* b_ptr;
35
Derived d_obj;
36
37
b_ptr = &d_obj; // 基类指针指向派生类对象
38
39
b_ptr->show(); // 调用的是 Derived::show() - 虚函数,动态绑定
40
b_ptr->print(); // 调用的是 Base::print() - 非虚函数,静态绑定 (取决于指针类型)
41
42
std::cout << std::endl;
43
44
Base base_obj;
45
base_obj.show(); // 调用的是 Base::show() - 虚函数,但对象本身就是 Base 类型
46
base_obj.print(); // 调用的是 Base::print() - 非虚函数
47
48
std::cout << std::endl;
49
50
// 使用基类指针删除派生类对象,需要虚析构函数
51
Base* d_ptr = new Derived();
52
delete d_ptr; // 如果 Base 的析构函数不是虚的,只会调用 Base::~Base()
53
// 因为它是虚的,会先调用 Derived::~Derived() 再调用 Base::~Base()
54
55
return 0;
56
}
② 虚函数表 (Virtual Table / vtable) 和 虚指针 (Virtual Pointer / vptr)
▮▮▮▮⚝ 虚函数机制的底层实现依赖于编译器。一个常见的实现方式是使用虚函数表 (vtable)。
▮▮▮▮⚝ 对于任何包含虚函数的类,编译器会为该类创建一个虚函数表。这是一个静态的数组,其中存储了该类中声明或继承的所有虚函数的地址。
▮▮▮▮⚝ 表中的每个条目 (Entry) 对应一个虚函数的指针。如果派生类重写了基类的虚函数,那么在派生类的 vtable 中,对应位置存放的是派生类重写后的函数地址;如果派生类没有重写,则存放的是基类的函数地址。
▮▮▮▮⚝ 类的每个对象,如果该类或其基类有虚函数,那么该对象会包含一个隐藏的成员变量——虚指针 (vptr)。vptr 在对象构造时被初始化,指向该对象所属类的虚函数表。
▮▮▮▮⚝ 当通过基类指针或引用调用一个虚函数时,编译器生成的代码实际上是通过对象的 vptr 找到其所属类的 vtable,然后根据虚函数在 vtable 中的索引找到对应的函数地址,最后调用该函数。这就是运行时绑定的过程。
下图(概念图,并非内存真实布局的精确表示):
1
graph TD
2
A[Animal Object] --> B(vptr);
3
B --> C(Animal vtable);
4
C --> D1(Address of Animal::speak);
5
C --> D2(Address of Animal::eat); % non-virtual, might not be in vtable
6
C --> D3(Address of Animal::~Animal);
7
8
E[Cat Object] --> F(vptr);
9
F --> G(Cat vtable);
10
G --> H1(Address of Cat::speak); % Overridden
11
G --> H2(Address of Animal::eat); % Inherited non-virtual
12
G --> H3(Address of Cat::~Cat); % Overridden
13
14
I[Dog Object] --> J(vptr);
15
J --> K(Dog vtable);
16
K --> L1(Address of Dog::speak); % Overridden
17
K --> L2(Address of Animal::eat); % Inherited non-virtual
18
K --> L3(Address of Dog::~Dog); % Overridden
(这是一个简化的概念图,eat
是非虚函数,其地址通常不在 vtable 中,调用非虚函数是静态绑定。)
虚函数的调用过程:
① 通过基类指针 ptr
调用虚函数 func()
。
② 编译器通过 ptr
访问对象内部的 vptr。
③ vptr 指向该对象实际类型的 vtable。
④ 编译器知道 func
函数在 vtable 中的固定索引位置。
⑤ 通过 vptr 找到 vtable,再通过索引找到 func
函数在 vtable 中的地址。
⑥ 调用该地址处的函数,这个函数就是对象实际类型对应的 func
版本。
这种机制使得在运行时能够根据对象的实际类型选择正确的函数执行,从而实现多态。当然,这种间接调用(通过 vptr 和 vtable)会带来微小的性能开销,但这通常是动态多态所必须付出的代价,并且在大多数应用中是可以接受的。
6.3 纯虚函数与抽象类
在面向对象设计中,我们有时需要定义一个基类,它只代表一个抽象的概念或接口,不应该被实例化(创建对象),而只作为派生类的共同基础。例如,“形状” (Shape) 这个概念本身是抽象的,我们通常只关心具体的形状,如圆形 (Circle) 或矩形 (Rectangle)。这时,我们就可以使用纯虚函数 (Pure Virtual Function) 和抽象类 (Abstract Class)。
① 纯虚函数 (Pure Virtual Function)
▮▮▮▮⚝ 纯虚函数是在基类中声明的虚函数,但没有具体的实现,其声明形式是在函数体位置写 = 0
。
▮▮▮▮⚝ 例如: virtual void draw() const = 0;
▮▮▮▮⚝ = 0
并不是说函数返回 0,而是表示这是一个纯虚函数,必须由派生类去实现 (Implement)。
▮▮▮▮⚝ 任何包含一个或多个纯虚函数的类都被称为抽象类。
② 抽象类 (Abstract Class)
▮▮▮▮⚝ 如果一个类至少包含一个纯虚函数,那么它就是一个抽象类。
▮▮▮▮⚝ 抽象类不能被实例化,也就是说,不能直接创建抽象类的对象。
▮▮▮▮⚝ 抽象类的主要目的是作为基类,定义派生类必须实现的接口。
▮▮▮▮⚝ 派生类如果想成为具体类 (Concrete Class)(即可实例化的类),就必须实现(重写)其基类所有的纯虚函数。
▮▮▮▮⚝ 如果派生类没有实现基类中的所有纯虚函数,那么该派生类本身也仍然是抽象类。
1
#include <iostream>
2
#include <cmath>
3
4
// 抽象基类 Shape
5
class Shape {
6
public:
7
// 纯虚函数:计算面积
8
virtual double area() const = 0;
9
10
// 纯虚函数:绘制
11
virtual void draw() const = 0;
12
13
// 普通虚函数(非纯):所有形状都可以有颜色,但具体实现可能不同或有默认
14
virtual void setColor(const std::string& color) {
15
std::cout << "Setting color to " << color << " (default implementation)." << std::endl;
16
// 实际中可能存储颜色成员变量
17
}
18
19
// 非虚函数:所有形状都可以获取ID
20
int getId() const { return id; }
21
22
// 虚析构函数是良好实践
23
virtual ~Shape() {
24
std::cout << "Shape destructor." << std::endl;
25
}
26
27
protected: // 保护成员,派生类可访问
28
int id;
29
// ... 其他通用属性
30
};
31
32
// 具体派生类 Circle
33
class Circle : public Shape {
34
private:
35
double radius;
36
public:
37
Circle(double r, int shape_id) : radius(r) {
38
id = shape_id; // 初始化继承来的成员
39
std::cout << "Circle constructor." << std::endl;
40
}
41
42
// 实现纯虚函数 area()
43
double area() const override {
44
return M_PI * radius * radius;
45
}
46
47
// 实现纯虚函数 draw()
48
void draw() const override {
49
std::cout << "Drawing Circle with radius " << radius << std::endl;
50
}
51
52
// 可选:重写 setColor
53
void setColor(const std::string& color) override {
54
std::cout << "Setting Circle color to " << color << std::endl;
55
}
56
57
~Circle() override {
58
std::cout << "Circle destructor." << std::endl;
59
}
60
};
61
62
// 具体派生类 Rectangle
63
class Rectangle : public Shape {
64
private:
65
double width;
66
double height;
67
public:
68
Rectangle(double w, double h, int shape_id) : width(w), height(h) {
69
id = shape_id; // 初始化继承来的成员
70
std::cout << "Rectangle constructor." << std::endl;
71
}
72
73
// 实现纯虚函数 area()
74
double area() const override {
75
return width * height;
76
}
77
78
// 实现纯虚函数 draw()
79
void draw() const override {
80
std::cout << "Drawing Rectangle with width " << width << " and height " << height << std::endl;
81
}
82
83
~Rectangle() override {
84
std::cout << "Rectangle destructor." << std::endl;
85
}
86
};
87
88
int main() {
89
// Shape myShape; // 错误:抽象类不能被实例化
90
91
// 使用基类指针指向派生类对象
92
Shape* shape1 = new Circle(5.0, 1);
93
Shape* shape2 = new Rectangle(4.0, 6.0, 2);
94
95
// 通过基类指针调用虚函数,实现多态
96
std::cout << "Shape 1 (ID " << shape1->getId() << ") area: " << shape1->area() << std::endl;
97
shape1->draw();
98
shape1->setColor("Red"); // 调用 Circle 的 setColor
99
100
std::cout << std::endl;
101
102
std::cout << "Shape 2 (ID " << shape2->getId() << ") area: " << shape2->area() << std::endl;
103
shape2->draw();
104
shape2->setColor("Blue"); // 调用 Rectangle 继承的默认 setColor (如果Rectangle没有重写)
105
106
// 清理资源
107
delete shape1; // 调用虚析构函数,先 Circle::~Circle() 再 Shape::~Shape()
108
delete shape2; // 调用虚析构函数,先 Rectangle::~Rectangle() 再 Shape::~Shape()
109
110
return 0;
111
}
在这个例子中,Shape
类是抽象类,它定义了所有形状都应该具备的接口(area()
和 draw()
),但没有提供具体的实现,因为抽象的“形状”无法计算面积或绘制。Circle
和 Rectangle
是具体类,它们继承自 Shape
并提供了 area()
和 draw()
的具体实现。我们不能创建 Shape
对象,但可以使用 Shape
指针或引用来统一管理 Circle
和 Rectangle
对象,并通过虚函数调用实现多态行为。纯虚函数是强制派生类实现特定行为的一种方式,它是定义接口的有力工具。### 6.4 override 和 final 关键字
C++11 标准引入了两个新的上下文关键字 (Contextual Keywords):override
和 final
,用于增强虚函数的使用安全性、清晰性和控制能力。
① override 关键字
▮▮▮▮⚝ override
用于明确表示派生类中的成员函数是旨在重写基类中的同名虚函数。
▮▮▮▮⚝ 如果派生类中的函数声明带有 override
关键字,但实际上没有重写基类中的虚函数(例如,函数名写错了、参数列表不匹配、const 限定符不匹配、基类函数不是虚函数等),编译器会报错。
▮▮▮▮⚝ 使用 override
的好处在于可以避免因为粗心造成的函数签名 (Function Signature) 不匹配,导致本想重写虚函数结果却创建了一个新的非虚函数(称为函数隐藏或遮蔽,Name Hiding)。这种隐藏会破坏多态性,且不容易发现。
▮▮▮▮⚝ 建议: 在派生类中重写基类虚函数时,始终使用 override
关键字。
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual void foo() { std::cout << "Base::foo()" << std::endl; }
6
virtual void bar(int i) const { std::cout << "Base::bar(" << i << ") const" << std::endl; }
7
virtual ~Base() = default;
8
};
9
10
class Derived : public Base {
11
public:
12
void foo() override { // 正确:重写 Base::foo()
13
std::cout << "Derived::foo()" << std::endl;
14
}
15
16
// void bar(int i) { std::cout << "Derived::bar(" << i << ")" << std::endl; }
17
// 上面这行如果写成这样,没有 const 且没有 override,它不会重写 Base::bar
18
// 且如果加了 override 会报错,因为签名不完全匹配
19
void bar(int i) const override { // 正确:重写 Base::bar(int) const
20
std::cout << "Derived::bar(" << i << ") const" << std::endl;
21
}
22
23
// virtual void baz() override { std::cout << "Derived::baz()" << std::endl; }
24
// 错误:Base 中没有名为 baz 的虚函数可供重写
25
26
// virtual void foo(int i) override { std::cout << "Derived::foo(" << i << ")" << std::endl; }
27
// 错误:试图重写 Base::foo() 但参数列表不匹配
28
};
29
30
int main() {
31
Base* ptr = new Derived();
32
ptr->foo(); // 调用 Derived::foo()
33
ptr->bar(10); // 调用 Derived::bar(10) const
34
delete ptr;
35
return 0;
36
}
② final 关键字
▮▮▮▮⚝ final
用于阻止类的继承或者阻止虚函数的进一步重写。
▮▮▮▮⚝ 用在类名后面:表示该类不能被进一步派生。
▮▮▮▮⚝ 用在虚函数声明后面:表示该虚函数不能在派生类中被重写。
1
#include <iostream>
2
3
class Base {
4
public:
5
virtual void foo() { std::cout << "Base::foo()" << std::endl; }
6
virtual void bar() final { std::cout << "Base::bar()" << std::endl; } // 该函数不能再被重写
7
virtual ~Base() = default;
8
};
9
10
class Derived : public Base {
11
public:
12
void foo() override { // 正确:重写 Base::foo()
13
std::cout << "Derived::foo()" << std::endl;
14
}
15
16
// void bar() override { std::cout << "Derived::bar()" << std::endl; }
17
// 错误:试图重写被标记为 final 的虚函数 Base::bar()
18
19
virtual ~Derived() override = default;
20
};
21
22
// class FinalDerived final : public Derived {
23
// public:
24
// // ...
25
// };
26
// 错误:Derived 本身没有标记 final,但如果我们标记 FinalDerived final
27
// 则 FinalDerived 不能被 further derived
28
29
// class CannotBeDerived final {
30
// public:
31
// // ...
32
// };
33
// class AnotherDerived : public CannotBeDerived { // 错误:CannotBeDerived 不能被继承
34
// // ...
35
// };
36
37
38
int main() {
39
Base* ptr = new Derived();
40
ptr->foo(); // 调用 Derived::foo()
41
ptr->bar(); // 调用 Base::bar() (Derived 中无法重写)
42
delete ptr;
43
return 0;
44
}
使用 final
关键字可以明确设计者的意图,防止不希望发生的继承或函数重写,这有助于代码的安全性和清晰度。
6.5 多态与运行时类型信息 (RTTI)
运行时类型信息 (Runtime Type Information, RTTI) 是 C++ 中允许程序在运行时查询对象类型的一种机制。它与动态多态密切相关,尤其是在需要确定通过基类指针或引用实际指向的对象类型,并可能需要将其转换为派生类指针或引用的场景。
C++ 中的 RTTI 主要通过两个操作符提供:
① typeid
操作符:用于获取表达式的类型信息。它返回一个 std::type_info
对象的引用。
② dynamic_cast
操作符:用于在类层次结构中进行安全的向下转型 (Downcasting)。
1
#include <iostream>
2
#include <typeinfo> // 包含 typeid 和 type_info
3
#include <string>
4
5
class Base {
6
public:
7
virtual void print_type() const { // 需要虚函数才能启用多态和 RTTI
8
std::cout << "Type is Base" << std::endl;
9
}
10
virtual ~Base() = default;
11
};
12
13
class Derived1 : public Base {
14
public:
15
void print_type() const override {
16
std::cout << "Type is Derived1" << std::endl;
17
}
18
void derived1_only() const {
19
std::cout << "This is Derived1 specific function." << std::endl;
20
}
21
};
22
23
class Derived2 : public Base {
24
public:
25
void print_type() const override {
26
std::cout << "Type is Derived2" << std::endl;
27
}
28
void derived2_only() const {
29
std::cout << "This is Derived2 specific function." << std::endl;
30
}
31
};
32
33
int main() {
34
Base* b1 = new Derived1();
35
Base* b2 = new Derived2();
36
Base* b3 = new Base();
37
Base* b4 = nullptr;
38
39
std::cout << "--- Using typeid ---" << std::endl;
40
// typeid 用于获取表达式的类型信息
41
// 对于多态类型(含有虚函数的类),通过指针/引用获取的是实际对象的类型
42
std::cout << "b1 points to: " << typeid(*b1).name() << std::endl; // 输出 Derived1 的类型名
43
std::cout << "b2 points to: " << typeid(*b2).name() << std::endl; // 输出 Derived2 的类型名
44
std::cout << "b3 points to: " << typeid(*b3).name() << std::endl; // 输出 Base 的类型名
45
std::cout << "Type of Base*: " << typeid(Base*).name() << std::endl; // 输出 Base* 的类型名 (静态类型)
46
std::cout << "Type of Derived1*: " << typeid(Derived1*).name() << std::endl; // 输出 Derived1* 的类型名 (静态类型)
47
48
// typeid 也可以用于非多态类型
49
int i = 0;
50
std::cout << "Type of i: " << typeid(i).name() << std::endl;
51
52
std::cout << "\n--- Using dynamic_cast ---" << std::endl;
53
// dynamic_cast 用于安全地将基类指针/引用转换为派生类指针/引用
54
// 如果转换成功,返回派生类指针/引用;如果转换失败(对象实际类型不是目标派生类或其派生类),
55
// 对于指针,返回 nullptr;对于引用,抛出 std::bad_cast 异常。
56
// dynamic_cast 只能用于多态类型。
57
58
Derived1* d1_ptr = dynamic_cast<Derived1*>(b1);
59
if (d1_ptr) {
60
std::cout << "b1 successfully cast to Derived1*" << std::endl;
61
d1_ptr->derived1_only(); // 可以安全调用派生类特有函数
62
} else {
63
std::cout << "b1 cast to Derived1* failed" << std::endl;
64
}
65
66
Derived1* d1_ptr_fail = dynamic_cast<Derived1*>(b2); // b2 指向 Derived2
67
if (d1_ptr_fail) {
68
std::cout << "b2 successfully cast to Derived1*" << std::endl;
69
} else {
70
std::cout << "b2 cast to Derived1* failed" << std::endl; // 转换失败,返回 nullptr
71
}
72
73
Derived2* d2_ptr = dynamic_cast<Derived2*>(b2);
74
if (d2_ptr) {
75
std::cout << "b2 successfully cast to Derived2*" << std::endl;
76
d2_ptr->derived2_only(); // 可以安全调用派生类特有函数
77
} else {
78
std::cout << "b2 cast to Derived2* failed" << std::endl;
79
}
80
81
// dynamic_cast 用于引用
82
try {
83
Derived1& d1_ref = dynamic_cast<Derived1&>(*b1);
84
std::cout << "b1 successfully cast to Derived1&" << std::endl;
85
d1_ref.derived1_only();
86
} catch (const std::bad_cast& e) {
87
std::cout << "b1 cast to Derived1& failed: " << e.what() << std::endl;
88
}
89
90
try {
91
Derived1& d1_ref_fail = dynamic_cast<Derived1&>(*b2); // b2 指向 Derived2
92
std::cout << "b2 successfully cast to Derived1&" << std::endl;
93
} catch (const std::bad_cast& e) {
94
std::cout << "b2 cast to Derived1& failed: " << e.what() << std::endl; // 转换失败,抛出异常
95
}
96
97
// 处理 nullptr
98
Derived1* d1_ptr_nullptr = dynamic_cast<Derived1*>(b4);
99
if (d1_ptr_nullptr) {
100
std::cout << "b4 successfully cast to Derived1*" << std::endl;
101
} else {
102
std::cout << "b4 cast to Derived1* failed (nullptr input)" << std::endl; // 转换失败,返回 nullptr
103
}
104
105
std::cout << "\n--- Cleaning up ---" << std::endl;
106
delete b1;
107
delete b2;
108
delete b3;
109
// b4 was nullptr, no need to delete
110
return 0;
111
}
使用 RTTI 需要注意以下几点:
① RTTI 默认是开启的(除非编译器选项明确关闭,例如 -fno-rtti
)。
② typeid
和 dynamic_cast
只能用于多态类(即至少包含一个虚函数的类)。对于非多态类,typeid
返回对象的静态类型信息,dynamic_cast
无法使用。
③ RTTI 会带来一定的运行时开销和二进制文件大小的增加。在对性能或代码大小有极其严格要求的场景下可能会被禁用。
④ 在设计上,过度依赖 dynamic_cast
进行类型判断然后调用特定派生类方法的模式,有时可能表明设计上没有充分利用虚函数的多态性。更面向对象的设计通常是利用虚函数将不同类型的行为差异封装在类内部,通过基类接口统一调用,而非在外部判断类型再分派。但是,dynamic_cast
在某些特定场景(如实现对象工厂、序列化/反序列化、插件系统或处理遗留代码)中是必要且有用的。
理解 RTTI 对于深入掌握 C++ 多态的机制以及在特定场景下进行类型操作至关重要。
7. 运算符重载与友元
欢迎来到 C++ 面向对象技术深度解析与实践的第七章:运算符重载与友元。在本章中,我们将深入探讨 C++ 中两个强大的特性:运算符重载(Operator Overloading)和友元(Friend)。运算符重载允许我们为自定义类型(例如类或结构体)赋予内置运算符的操作能力,使得使用这些自定义类型的代码更加直观和自然。而友元机制则提供了一种特殊的方式来访问类的私有(private)和保护(protected)成员,尽管它在一定程度上打破了封装原则,但在某些特定场景下却是必需的。
本章将从运算符重载的基本概念和规则讲起,详细讲解如何以成员函数或非成员函数的形式实现运算符重载,并通过丰富的示例展示其应用。接着,我们将深入探讨友元函数和友元类的作用、语法以及潜在风险,理解何时以及如何安全地使用友元机制。掌握这些技术,将帮助你编写出更具表达力、更符合直觉的 C++ 代码,并更好地理解标准库中一些核心组件(如输入输出流)的工作原理。
7.1 运算符重载的概念与规则
编程语言中的运算符,例如 +
、-
、*
、/
等,通常是为基本数据类型(如整型、浮点型)设计的。它们定义了这些类型之间的特定操作。然而,当我们定义了自己的复杂数据类型,如表示复数的 Complex
类,或者表示二维向量的 Vector2D
类时,我们可能希望也能使用这些直观的运算符来进行操作。例如,对于两个 Complex
对象 c1
和 c2
,我们可能希望能够直接写 c1 + c2
来实现复数相加,而不是调用一个像 c1.add(c2)
这样的成员函数。
运算符重载(Operator Overloading)正是 C++ 提供的机制,允许我们为自定义的数据类型重新定义(或“重载”)现有的运算符的行为。这使得用户定义类型的操作能够像内置类型一样方便和自然,提高了代码的可读性和易用性。
7.1.1 运算符重载的意义
① 增强代码的可读性与自然性(Readability and Naturalness)
▮▮▮▮ 使用运算符进行操作通常比函数调用更直观。例如,point1 + point2
比 point1.add(point2)
更容易理解其意图。
② 模仿内置类型的行为
▮▮▮▮ 允许用户定义类型在语法上与内置类型保持一致,降低学习和使用的门槛。
③ 构建更富有表达力的抽象
▮▮▮▮ 通过重载运算符,可以将复杂的类型操作封装起来,对外提供简洁的接口。
7.1.2 运算符重载的语法
运算符重载是通过定义一个特殊的函数来实现的,这个函数的名字由 operator
关键字后跟要重载的运算符符号组成。
语法形式通常是:
return_type operator op (parameters)
其中:
⚝ return_type
是操作结果的类型。
⚝ operator
是关键字。
⚝ op
是要重载的运算符符号(如 +
、-
、*
、<<
等)。
⚝ parameters
是操作数的参数列表,其数量取决于运算符是一元(Unary)还是二元(Binary),以及重载形式(成员函数或非成员函数)。
7.1.3 可以被重载的运算符
绝大多数 C++ 运算符都可以被重载。这包括:
① 算术运算符(Arithmetic Operators)
▮▮▮▮ +
, -
, *
, /
, %
▮▮▮▮ ++
, --
(前置和后置)
② 关系运算符(Relational Operators)
▮▮▮▮ ==
, !=
, <
, >
, <=
, >=
③ 逻辑运算符(Logical Operators)
▮▮▮▮ !
, &&
, ||
(注意,&&
和 ||
通常不建议重载,因为它们有短路求值特性,重载后会失去这个特性)
④ 位运算符(Bitwise Operators)
▮▮▮▮ &
, |
, ^
, ~
, <<
, >>
⑤ 赋值运算符(Assignment Operators)
▮▮▮▮ =
, +=
, -=
, *=
, /=
, %=
, &=
, |=
, ^=
, <<=
, >>=
⑥ 其他特殊运算符
▮▮▮▮ ()
, []
, ->
, *
(解引用), &
(取地址,但成员形式的取地址运算符 &
通常不重载,因为有内置的用途), new
, delete
, new[]
, delete[]
7.1.4 不能被重载的运算符
有少数几个运算符是不能被重载的,它们通常有特殊的、底层的功能,重载它们可能会导致语法混乱或破坏语言核心特性:
① .
(成员访问运算符)
② .*
(成员指针访问运算符)
③ ::
(作用域解析运算符)
④ ?:
(条件运算符)
⑤ sizeof
(长度运算符)
⑥ typeid
(类型信息运算符)
⑦ static_cast
, dynamic_cast
, reinterpret_cast
, const_cast
(类型转换运算符)
⑧ #
和 ##
(预处理运算符,不是真正的运行时运算符)
7.1.5 运算符重载的规则与限制
在重载运算符时,必须遵循以下规则:
① 不能改变运算符的优先级(Precedence)和结合性(Associativity)
▮▮▮▮ 例如,无论如何重载 *
和 +
,a * b + c
仍然会先计算 a * b
。
② 不能改变运算符的操作数数量(Arity)
▮▮▮▮ 一元运算符重载后仍是一元,二元运算符仍是二元。例如,你不能将二元运算符 +
重载成一个接受三个操作数的函数。
③ 至少有一个操作数是用户定义类型
▮▮▮▮ 不能为基本数据类型重载运算符。例如,你不能改变 int + int
的行为。
④ 不能创建新的运算符
▮▮▮▮ 只能重载 C++ 中已有的运算符。
⑤ 对于某些运算符,重载形式是固定的
▮▮▮▮ 例如,赋值运算符 =
、下标运算符 []
、函数调用运算符 ()
、成员访问运算符 ->
只能以成员函数的形式重载。其他大多数运算符既可以作为成员函数重载,也可以作为非成员函数(通常是友元函数)重载。
理解这些基本概念和规则是正确使用运算符重载的前提。在接下来的章节中,我们将看到如何具体地以成员函数和非成员函数的形式来实现运算符重载。
7.2 成员函数形式的运算符重载
将运算符重载实现为类的成员函数是 C++ 中一种常见的方式。这种形式特别适用于那些操作会修改对象自身状态的运算符(如赋值运算符 +=
)或一元运算符(如前置 ++
)。
当一个二元运算符 op
被重载为类 ClassA
的成员函数时,它的调用形式通常是 obj1 op obj2
。此时,obj1
是 ClassA
类型的对象,它将作为调用这个成员函数的对象,而 obj2
则作为函数的参数传递进去。也就是说,obj1 op obj2
实际上等同于 obj1.operator op(obj2)
。
如果是一元运算符 op
(如 -
或前置 ++
) 被重载为成员函数,调用形式是 op obj
(或 obj op
对于后置形式)。此时,obj
是 ClassA
类型的对象,它将作为调用成员函数的对象,而 operator op()
函数不再需要额外的参数。
7.2.1 语法与特点
成员函数形式的运算符重载语法:
1
class MyClass {
2
public:
3
// 二元运算符作为成员函数
4
// 假设重载 + 运算符: MyClass operator+(const MyClass& other);
5
MyClass operator+(const MyClass& other) const; // const 表示不修改当前对象
6
7
// 一元运算符作为成员函数 (前置)
8
// 假设重载前置 - 运算符: MyClass operator-();
9
MyClass operator-() const; // const 表示不修改当前对象
10
11
// 赋值运算符作为成员函数
12
// 假设重载 += 运算符: MyClass& operator+=(const MyClass& other);
13
MyClass& operator+=(const MyClass& other); // 返回引用,通常用于链式操作
14
15
// 一元运算符作为成员函数 (后置)
16
// 假设重载后置 ++ 运算符: MyClass operator++(int);
17
// 参数 int 是一个哑元参数,仅用于区分前置和后置形式
18
MyClass operator++(int); // 返回旧值,不加 const
19
};
特点:
⚝ 对于二元运算符,左操作数是调用成员函数的对象(this
指针指向的对象),右操作数作为参数传递。
⚝ 对于一元运算符,操作数是调用成员函数的对象(this
指针指向的对象),没有额外参数。
⚝ 成员函数形式的运算符重载可以天然访问类的私有(private)和保护(protected)成员。
7.2.2 示例:复数类的运算符重载
我们以一个简单的复数类(Complex Class)为例,演示如何重载加法 +
、复合赋值 +=
和取负 -
运算符。
1
#include <iostream>
2
3
class Complex {
4
private:
5
double real; // 实部 (Real Part)
6
double imag; // 虚部 (Imaginary Part)
7
8
public:
9
// 构造函数 (Constructor)
10
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
11
12
// 获取实部
13
double getReal() const { return real; }
14
15
// 获取虚部
16
double getImag() const { return imag; }
17
18
// 成员函数形式重载 二元运算符 +
19
// Complex operator+(const Complex& other) const; // 声明
20
Complex operator+(const Complex& other) const {
21
// 返回一个新的 Complex 对象,表示相加的结果
22
return Complex(real + other.real, imag + other.imag);
23
}
24
25
// 成员函数形式重载 复合赋值运算符 +=
26
// Complex& operator+=(const Complex& other); // 声明
27
Complex& operator+=(const Complex& other) {
28
// 直接修改当前对象
29
real += other.real;
30
imag += other.imag;
31
return *this; // 返回当前对象的引用,支持链式赋值
32
}
33
34
// 成员函数形式重载 一元运算符 - (取负)
35
// Complex operator-() const; // 声明
36
Complex operator-() const {
37
// 返回一个新的 Complex 对象,表示取负的结果
38
return Complex(-real, -imag);
39
}
40
41
// 为了方便输出,我们稍后会使用友元或非成员函数形式重载 <<
42
// 这里先定义一个简单的打印方法
43
void print() const {
44
std::cout << "(" << real << " + " << imag << "i)";
45
}
46
};
47
48
// 在 main 函数中使用重载的运算符
49
int main() {
50
Complex c1(1.0, 2.0);
51
Complex c2(3.0, 4.0);
52
53
// 使用重载的 + 运算符
54
Complex c3 = c1 + c2; // 等价于 c1.operator+(c2);
55
std::cout << "c1 + c2 = ";
56
c3.print(); // 输出 (4 + 6i)
57
std::cout << std::endl;
58
59
// 使用重载的 += 运算符
60
Complex c4(5.0, 6.0);
61
c4 += c1; // 等价于 c4.operator+=(c1);
62
std::cout << "c4 += c1 : c4 = ";
63
c4.print(); // 输出 (6 + 8i)
64
std::cout << std::endl;
65
66
// 使用重载的 一元 - 运算符
67
Complex c5 = -c1; // 等价于 c1.operator-();
68
std::cout << "-c1 = ";
69
c5.print(); // 输出 (-1 + -2i)
70
std::endl;
71
72
return 0;
73
}
在上面的例子中:
⚝ operator+
作为成员函数,接收一个 Complex
类型的常量引用 other
作为参数。左操作数是调用该函数的对象 c1
。它返回一个新的 Complex
对象。注意加上 const
,因为加法不应该修改原对象。
⚝ operator+=
也作为成员函数,接收一个 Complex
类型的常量引用 other
。它直接修改当前对象 *this
,因此不加 const
。返回当前对象的引用 *this
是一种常见的模式,允许链式赋值(如 c1 += c2 += c3
)。
⚝ operator-()
作为成员函数,没有参数,表示一元负号。它返回一个新的 Complex
对象,表示当前复数的负数。加上 const
,因为取负不修改原对象。
7.2.3 成员函数重载的适用场景
① 改变对象自身状态的运算符
▮▮▮▮ 例如,+=
, -=
, *=
, /=
, ++
, --
(前置和后置)。这些运算符通常会修改左操作数的状态,因此作为成员函数实现非常自然,可以直接访问和修改成员变量。
② 一元运算符
▮▮▮▮ 例如,-
(取负), !
(逻辑非), ~
(按位取反)。这些运算符只作用于一个操作数,将其作为成员函数的调用对象很直观。
③ 赋值运算符 =
▮▮▮▮ 赋值运算符只能作为成员函数重载,并且需要特别处理自赋值(Self-Assignment)和资源管理(如深拷贝)。这是因为 =
运算符的功能是复制一个对象的状态到另一个对象,它紧密地与类的内部状态相关。
④ 下标运算符 []
▮▮▮▮ 通常用于访问容器或数组中的元素,需要访问对象的内部数据结构,因此必须作为成员函数重载。
⑤ 函数调用运算符 ()
▮▮▮▮ 允许对象像函数一样被调用(Functor / 函数对象)。必须作为成员函数重载。
⑥ 成员访问运算符 ->
▮▮▮▮ 用于模拟指针的行为。必须作为成员函数重载。
总结来说,成员函数形式的运算符重载是处理与对象自身紧密相关的操作的首选方式。然而,对于一些需要对称性或左操作数不是类对象的运算符(最常见的是输入输出流运算符 <<
和 >>
),非成员函数形式(通常结合友元)则更为合适。
7.3 非成员函数形式的运算符重载
除了成员函数形式,许多运算符也可以被重载为非成员函数(Non-member Function)。当运算符重载为非成员函数时,它通常需要类的支持,以便访问类的私有或保护成员。这时,友元(Friend)机制就显得尤为重要。
当一个二元运算符 op
被重载为非成员函数时,它的调用形式依然是 obj1 op obj2
。但这次,obj1 op obj2
实际上等同于调用 operator op(obj1, obj2)
函数,其中 obj1
和 obj2
作为参数传递给该函数。
如果是一元运算符 op
(如 -
或前置 ++
) 被重载为非成员函数,调用形式是 op obj
(或 obj op
对于后置形式)。此时,op obj
实际上等同于调用 operator op(obj)
函数,其中 obj
作为参数传递。
7.3.1 语法与特点
非成员函数形式的运算符重载语法:
1
// 在类外部定义
2
// 对于二元运算符 op (如 +):
3
// return_type operator op (ParameterType1 param1, ParameterType2 param2);
4
5
// 对于一元运算符 op (如 -):
6
// return_type operator op (ParameterType param);
特点:
⚝ 对于二元运算符,两个操作数都作为参数传递给函数。
⚝ 对于一元运算符,操作数作为参数传递给函数。
⚝ 非成员函数默认不能访问类的私有(private)和保护(protected)成员。如果需要访问,通常需要将该函数声明为类的友元。
7.3.2 示例:复数类的运算符重载 (非成员函数形式)
我们仍然使用复数类(Complex Class),演示如何以非成员函数形式重载加法 +
运算符和输出流 <<
运算符。
1
#include <iostream>
2
3
class Complex {
4
private:
5
double real; // 实部 (Real Part)
6
double imag; // 虚部 (Imaginary Part)
7
8
public:
9
// 构造函数 (Constructor)
10
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
11
12
// 获取实部 (为非友元函数提供访问途径)
13
double getReal() const { return real; }
14
15
// 获取虚部 (为非友元函数提供访问途径)
16
double getImag() const { return imag; }
17
18
// 将非成员函数 operator+ 声明为友元 (如果需要访问私有成员)
19
// friend Complex operator+(const Complex& c1, const Complex& c2);
20
21
// 将非成员函数 operator<< 声明为友元 (因为它需要访问私有成员 real 和 imag)
22
friend std::ostream& operator<<(std::ostream& os, const Complex& c);
23
};
24
25
// 非成员函数形式重载 二元运算符 +
26
// 注意:这里不再是类的成员函数
27
// 如果 Complex 类提供了公共的 getter 方法,operator+ 可以不作为友元
28
// Complex operator+(const Complex& c1, const Complex& c2) {
29
// return Complex(c1.getReal() + c2.getReal(), c1.getImag() + c2.getImag());
30
// }
31
32
// 如果 operator+ 需要直接访问私有成员,则必须是友元
33
// friend Complex operator+(const Complex& c1, const Complex& c2); // 声明在类内
34
Complex operator+(const Complex& c1, const Complex& c2) {
35
// 直接访问私有成员 (因为被声明为友元)
36
return Complex(c1.real + c2.real, c1.imag + c2.imag);
37
}
38
39
40
// 非成员函数形式重载 输出流运算符 <<
41
// std::ostream& operator<<(std::ostream& os, const Complex& c); // 声明在类内
42
// 注意:第一个参数是 ostream 的引用,第二个参数是要输出的对象
43
std::ostream& operator<<(std::ostream& os, const Complex& c) {
44
// 直接访问私有成员 (因为被声明为友元)
45
os << "(" << c.real << " + " << c.imag << "i)";
46
return os; // 返回 ostream 引用,支持链式输出 (如 std::cout << c1 << c2;)
47
}
48
49
50
// 在 main 函数中使用重载的运算符
51
int main() {
52
Complex c1(1.0, 2.0);
53
Complex c2(3.0, 4.0);
54
55
// 使用重载的 + 运算符 (非成员函数形式)
56
Complex c3 = c1 + c2; // 等价于 operator+(c1, c2);
57
std::cout << "c1 + c2 = " << c3 << std::endl; // 使用重载的 << 运算符
58
59
Complex c4(5.0, 6.0);
60
// 注意:+= 通常作为成员函数更合适,因为它修改左操作数
61
// 如果要实现非成员函数形式的 +=,通常是这样:
62
// Complex& operator+=(Complex& c1, const Complex& c2) { c1.real += c2.real; c1.imag += c2.imag; return c1; }
63
// 但这不符合 += 的常见用法模式,且需要将左操作数声明为非 const 引用,不如成员函数形式自然。
64
// 所以通常 += 等复合赋值运算符用成员函数实现。
65
66
// 使用重载的 << 运算符
67
std::cout << "c1 = " << c1 << ", c2 = " << c2 << std::endl;
68
69
return 0;
70
}
在上面的例子中:
⚝ operator+
被实现为非成员函数,接受两个 Complex
对象作为参数。它可以直接访问 Complex
的私有成员 real
和 imag
,这是因为它在 Complex
类定义内部被声明为了友元(friend Complex operator+(const Complex& c1, const Complex& c2);
)。当然,如果 Complex
类提供了公共的 getReal()
和 getImag()
方法,operator+
也可以不声明为友元,直接通过这些公共方法访问数据。但为了演示友元,这里使用了友元方式。
⚝ operator<<
被实现为非成员函数,接受一个 std::ostream
引用和一个 Complex
对象常量引用作为参数。它必须能够访问 Complex
对象的私有成员来输出其内部状态,因此必须在 Complex
类定义内部被声明为友元(friend std::ostream& operator<<(std::ostream& os, const Complex& c);
)。返回 std::ostream&
允许像 std::cout << c1 << c2;
这样的链式输出。
7.3.3 非成员函数重载的适用场景
① 需要对称性的二元运算符
▮▮▮▮ 例如,+
、-
、*
、/
等算术运算符。当两个操作数类型可能不同,或者希望左操作数不是类对象时(例如 int + Complex
),非成员函数形式更具灵活性。虽然 Complex + int
可以通过成员函数重载(参数为 int
),但 int + Complex
无法作为 int
的成员函数实现。将 +
重载为非成员函数 operator+(const Complex& c, int i)
和 operator+(int i, const Complex& c)
可以解决这个问题。或者,一个更通用的非成员函数 operator+(const Complex& c1, const Complex& c2)
结合适当的构造函数或类型转换也可以处理混合类型运算。
② 输入/输出流运算符 <<
和 >>
▮▮▮▮ 这是非成员函数重载最经典的例子。左操作数是 std::ostream
或 std::istream
类型的对象,而不是自定义类型的对象。因此,它们必须作为非成员函数重载。为了访问类的私有成员,这些函数通常被声明为类的友元。
通常来说,如果一个二元运算符既可以作为成员函数重载,也可以作为非成员函数重载,那么:
⚝ 如果操作会修改左操作数的状态,或者是一元运算符,通常选择成员函数形式(如 +=
, ++
)。
⚝ 如果操作不修改操作数状态,且需要对称性,或者左操作数不是类对象,通常选择非成员函数形式(如 +
, *
, <<
, >>
)。
7.4 友元函数与友元类
在 C++ 中,封装(Encapsulation)是一个核心原则,它通过访问修饰符(public, protected, private)来控制类成员的可见性。通常情况下,类的私有(private)和保护(protected)成员只能由类的自身成员函数或其派生类的成员函数访问。然而,在某些特定场景下,我们可能需要允许外部的函数或类访问一个类的私有或保护成员。友元(Friend)机制就是为此目的而设计的。
通过将一个函数或类声明为另一个类的友元,可以赋予这个友元访问该类的私有和保护成员的权限。
7.4.1 友元函数(Friend Function)
友元函数是一个定义在类外部的普通函数,但它在类定义内部被声明为友元。一旦被声明为友元,该函数就可以访问类的所有成员,包括私有和保护成员。
语法:在类的定义内部,使用 friend
关键字声明一个函数:
1
class MyClass {
2
private:
3
int data;
4
5
public:
6
// ... 其他成员 ...
7
8
// 将一个普通函数 declareFunction 声明为 MyClass 的友元函数
9
friend void declareFunction(MyClass& obj);
10
11
// 将一个类 AnotherClass 的成员函数 memberFunction 声明为 MyClass 的友元函数
12
friend void AnotherClass::memberFunction(MyClass& obj);
13
14
// 将一个函数模板 templateFunction 声明为 MyClass 的友元函数
15
template <typename T>
16
friend void templateFunction(MyClass& obj, const T& value);
17
18
// 将一个特定的模板实例化 instanceFunction<int> 声明为 MyClass 的友元函数
19
template <>
20
friend void instanceFunction<int>(MyClass& obj, const int& value);
21
};
22
23
// 友元函数的实现通常在类定义外部
24
void declareFunction(MyClass& obj) {
25
// 作为 MyClass 的友元,declareFunction 可以访问 obj 的私有成员 data
26
obj.data = 100;
27
std::cout << "Accessed private member data: " << obj.data << std::endl;
28
}
29
30
// AnotherClass 定义 (如果在 MyClass 之前定义,需要前置声明 MyClass)
31
class AnotherClass {
32
public:
33
void memberFunction(MyClass& obj) {
34
// 作为 MyClass 的友元 AnotherClass::memberFunction,可以访问 obj 的私有成员 data
35
obj.data = 200;
36
std::cout << "Accessed private member data from AnotherClass::memberFunction: " << obj.data << std::endl;
37
}
38
};
39
40
// 函数模板友元函数的实现
41
template <typename T>
42
void templateFunction(MyClass& obj, const T& value) {
43
obj.data = static_cast<int>(value);
44
std::cout << "Accessed private member data from templateFunction: " << obj.data << std::endl;
45
}
46
47
// 特定的模板实例化友元函数的实现
48
template <>
49
void instanceFunction<int>(MyClass& obj, const int& value) {
50
obj.data = value + 1000;
51
std::cout << "Accessed private member data from instanceFunction<int>: " << obj.data << std::endl;
52
}
友元函数的特点:
⚝ 友元关系是单向的,不可传递。如果函数 A 是类 C 的友元,不代表类 C 是函数 A 的友元,也不代表函数 A 的友元也是类 C 的友元。
⚝ 友元函数不是类的成员函数,因此不能通过对象名加点运算符(.
)或指针加箭头运算符(->
)来调用。它就像一个普通的函数一样被调用。
⚝ 友元函数可以访问类的私有和保护成员,这在一定程度上打破了封装,应谨慎使用。
7.4.2 友元类(Friend Class)
友元类是一个类,它的所有成员函数都被声明为另一个类的友元函数。这意味着友元类的所有成员函数都可以访问被声明友元关系的那个类的所有成员,包括私有和保护成员。
语法:在类的定义内部,使用 friend
关键字声明另一个类为友元类:
1
// 需要前置声明 FriendClass,因为在 MyClass 中引用了它
2
class FriendClass;
3
4
class MyClass {
5
private:
6
int secret_data;
7
8
public:
9
MyClass(int data) : secret_data(data) {}
10
11
// 将 FriendClass 声明为 MyClass 的友元类
12
friend class FriendClass;
13
14
// 可以只将 FriendClass 中的特定成员函数声明为友元,而不是整个类 (见友元函数部分示例)
15
// friend void FriendClass::accessPrivate(MyClass& obj);
16
17
};
18
19
class FriendClass {
20
public:
21
void accessPrivate(MyClass& obj) {
22
// 作为 MyClass 的友元类,FriendClass 的成员函数可以访问 MyClass 的私有成员
23
std::cout << "FriendClass::accessPrivate - Accessing MyClass secret_data: " << obj.secret_data << std::endl;
24
obj.secret_data = 999; // 也可以修改私有成员
25
std::cout << "FriendClass::accessPrivate - Modified MyClass secret_data: " << obj.secret_data << std::endl;
26
}
27
28
void anotherMemberFunction(MyClass& obj) {
29
// FriendClass 的其他成员函数也可以访问 MyClass 的私有成员
30
std::cout << "FriendClass::anotherMemberFunction - Accessing MyClass secret_data: " << obj.secret_data << std::endl;
31
}
32
};
33
34
int main() {
35
MyClass m(123);
36
FriendClass f;
37
38
f.accessPrivate(m);
39
f.anotherMemberFunction(m);
40
41
// 尝试直接访问私有成员会出错
42
// std::cout << m.secret_data << std::endl; // Error!
43
44
return 0;
45
}
友元类的特点:
⚝ 友元关系是单向的,不自动相互。如果类 A 是类 B 的友元,不代表类 B 也是类 A 的友元。
⚝ 友元类的所有成员函数都可以访问被其声明为友元的那个类的所有成员。
7.4.3 友元的优缺点与使用场景
优点:
① 实现运算符重载(尤其输入/输出流)
▮▮▮▮ 如前面所述,对于需要左右操作数不对称,或者左操作数不是自定义类型的运算符(如 <<
和 >>
),非成员函数形式的重载是必须的。为了让这些函数能够访问类的内部私有数据,将它们声明为友元是常见且必要的做法。
② 在紧密耦合的类之间共享数据
▮▮▮▮ 当两个类之间存在非常紧密的协作关系,且其中一个类需要频繁、深入地访问另一个类的私有状态时,使用友元可以简化代码,避免编写大量的公共(public)getter/setter 方法来暴露内部实现细节。例如,一个容器类与其内部迭代器类之间可能存在友元关系,以便迭代器能够高效地访问容器的底层数据结构。
③ 方便实现某些设计模式
▮▮▮▮ 在某些设计模式中,可能需要特定的类或函数对另一个类有特殊的访问权限。
缺点:
① 破坏封装性
▮▮▮▮ 这是友元最主要的缺点。友元机制允许外部实体访问类的内部私有实现,降低了类的独立性和可维护性。一旦内部实现细节改变,友元函数或友元类可能也需要修改。
② 增加耦合度
▮▮▮▮ 友元关系增加了类之间的耦合。一个类的友元越多,它就越难以独立地修改或重用。
使用场景建议:
⚝ 谨慎使用:友元应被视为打破封装的“例外”机制,而非常规手段。只有在有充分理由、并且通过其他方式实现会导致代码更复杂或效率更低时才考虑使用。
⚝ 输入/输出流运算符:这是最常见的、合理使用友元的场景之一。
⚝ 操作符重载对称性:对于需要对称性的二元运算符(如 +
),如果非成员函数形式更合适,且需要访问私有成员,可以考虑使用友元。
⚝ 紧密相关的工具类或辅助类:例如,容器类和迭代器类。
总的来说,友元是一个强大的工具,但也容易被滥用。优秀的代码设计应该尽量减少对友元的依赖,维护良好的封装性。但在某些标准库惯用法或特定高性能场景下,友元能够提供简洁有效的解决方案。
8. 类模板:构建泛型类
章总结: 本章深入讲解 C++ 的类模板 (Class Template),学习如何创建可以处理多种数据类型的通用类。模板是 C++ 实现泛型编程 (Generic Programming) 的主要工具之一,它允许我们编写独立于具体类型 (Type) 的代码。类模板使得我们可以定义一个通用的类结构,该结构可以针对不同的数据类型生成具体的类。本章将从类模板的基本定义和实例化讲起,深入探讨其成员函数的实现、特化机制,以及与面向对象其他特性(如继承和友元)的结合使用。通过学习类模板,读者将掌握如何编写更加灵活、可重用和类型安全的代码。
8.1 类模板的定义与实例化
编写软件时,我们经常会遇到处理不同数据类型但逻辑结构相似的情况。例如,一个用于存储元素的堆栈 (Stack) 或队列 (Queue),其基本操作(入栈/队、出栈/队)与存储元素的具体类型无关。如果为每种可能的类型都重写一个类,这将导致大量的重复代码且难以维护。
泛型编程 (Generic Programming) 的目标是编写独立于特定类型工作的代码。C++ 的模板 (Template) 机制是实现泛型编程的核心工具。模板分为函数模板 (Function Template) 和类模板 (Class Template)。本章重点关注类模板。
类模板允许我们定义一个通用的类结构,其中包含一个或多个类型参数 (Type Parameter)。在实际使用时,通过为这些类型参数提供具体类型,编译器会生成一个特定类型的类,这个过程称为模板实例化 (Template Instantiation)。
8.1.1 类模板的基本定义
定义一个类模板,需要在类定义前使用 template
关键字,后跟一个模板参数列表 (Template Parameter List)。模板参数列表由尖括号 <>
包围,其中包含一个或多个模板参数的声明。每个模板参数前需要加上 typename
或 class
关键字。尽管 typename
更能准确地表示这是一个类型,但在模板参数列表中,class
与 typename
具有相同的含义。
1
template <typename T>
2
class MyPair {
3
public:
4
T first;
5
T second;
6
7
MyPair(T a, T b) : first(a), second(b) {}
8
9
T getFirst() const {
10
return first;
11
}
12
13
T getSecond() const {
14
return second;
15
}
16
};
17
18
template <typename T, typename U>
19
class AnotherPair {
20
public:
21
T first;
22
U second;
23
24
AnotherPair(T a, U b) : first(a), second(b) {}
25
26
T getFirst() const {
27
return first;
28
}
29
30
U getSecond() const {
31
return second;
32
}
33
};
在上面的例子中,MyPair
是一个类模板,带有一个类型参数 T
。AnotherPair
是另一个类模板,带有两个类型参数 T
和 U
。在类模板的定义中,类型参数 T
和 U
可以像内置类型 (Built-in Type) 或用户自定义类型 (User-defined Type) 一样使用,用来声明成员变量、成员函数的参数类型、返回类型等。
除了类型参数,模板参数列表还可以包含非类型参数 (Non-type Parameter) 或模板参数 (Template Template Parameter)。
① 非类型参数:允许模板根据一个值而不是类型进行参数化。
▮▮▮▮⚝ 例如:template <typename T, int Size> class Array { T arr[Size]; };
② 模板参数:允许模板根据另一个模板进行参数化。
▮▮▮▮⚝ 例如:template <typename T, template <typename> class Container> class Wrapper { Container<T> c; };
在本章,我们将主要关注类型参数。
8.1.2 类模板的实例化
类模板本身不是一个具体的类型,它是一个蓝图。要使用类模板,必须对其进行实例化 (Instantiation),即为模板参数提供具体类型,从而生成一个真实的类类型。
实例化的方式有两种:显式实例化 (Explicit Instantiation) 和隐式实例化 (Implicit Instantiation)。
① 隐式实例化:
▮▮▮▮这是最常见的方式。当你创建一个类模板的对象时,编译器会根据你提供的具体类型自动生成对应的类代码。
1
#include <string>
2
3
// 使用 MyPair 类模板创建对象
4
MyPair<int> intPair(10, 20); // 编译器会生成 MyPair<int> 类
5
MyPair<double> doublePair(3.14, 6.28); // 编译器会生成 MyPair<double> 类
6
MyPair<std::string> stringPair("hello", "world"); // 编译器会生成 MyPair<std::string> 类
7
8
// 使用 AnotherPair 类模板创建对象
9
AnotherPair<int, double> mixedPair(1, 2.5); // 编译器会生成 AnotherPair<int, double> 类
在上面的代码中,通过 MyPair<int>
、MyPair<double>
等语法,我们为模板参数 T
提供了具体的类型(int
、double
、std::string
),编译器根据这些类型生成了 MyPair
类的不同版本。这些版本的类被称为模板类 (Template Class) 或实例化类 (Instantiated Class)。
② 显式实例化:
▮▮▮▮你可以通过 template class ClassName<Type>;
语法显式地告诉编译器提前生成某个特定类型的模板类代码。这通常用于分离编译 (Separate Compilation) 的场景,或者当你希望确保某个特定实例化版本存在时。
1
// 显式实例化 MyPair<int>
2
template class MyPair<int>;
3
4
// 显式实例化 AnotherPair<double, char>;
5
template class AnotherPair<double, char>;
显式实例化会强制编译器在当前编译单元 (Compilation Unit) 中生成指定模板类的所有成员函数的代码,即使这些成员函数并没有被实际调用。这与隐式实例化不同,隐式实例化下,模板成员函数只在使用到时才会被实例化。
8.1.3 使用模板类对象
实例化后的模板类对象使用方式与普通类的对象相同。
1
MyPair<int> intPair(10, 20);
2
std::cout << "First int: " << intPair.getFirst() << std::endl; // 调用 MyPair<int>::getFirst()
3
std::cout << "Second int: " << intPair.second << std::endl; // 直接访问成员变量
4
5
AnotherPair<std::string, int> dataPair("Age", 30);
6
std::cout << dataPair.first << ": " << dataPair.second << std::endl; // 访问不同类型的成员
✨ 注意事项:
⚝ 类模板在使用前必须先定义。
⚝ 模板参数必须在实例化时确定具体的类型(或值)。
⚝ 编译器在实例化模板时,会对提供的具体类型进行检查,确保该类型支持模板类中使用的所有操作(例如,如果模板类中有 T obj1 + obj2
的操作,那么 T
必须支持 +
运算符)。这就是所谓的"鸭子类型" (Duck Typing) 在模板中的体现——"如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子"。
8.2 类模板的成员函数
类模板的成员函数与普通类的成员函数类似,它们可以是公有 (public)、私有 (private) 或保护 (protected) 的。成员函数的定义可以在类模板内部,也可以在类模板外部。
8.2.1 成员函数在类模板内部定义
当成员函数在类模板的定义内部直接给出实现时,它们也是模板的一部分。这种情况下,定义方式与普通类的成员函数类似。
1
template <typename T>
2
class Container {
3
private:
4
T value;
5
public:
6
Container(T v) : value(v) {} // 构造函数在类内部定义
7
8
T getValue() const { // 成员函数在类内部定义
9
return value;
10
}
11
12
void setValue(T v) { // 成员函数在类内部定义
13
value = v;
14
}
15
};
这种方式简单直观,适合成员函数实现较短的情况。
8.2.2 成员函数在类模板外部定义
对于实现较长的成员函数,通常习惯将其定义放在类模板的外部,以保持类定义本身的简洁。当在类模板外部定义成员函数时,必须在函数定义前重复 template <typename ...>
模板参数列表,并且在函数名或返回类型前加上类模板名和模板参数 <T, U, ...>
的限定。
1
template <typename T>
2
class Stack {
3
private:
4
// ... 堆栈数据结构
5
public:
6
Stack(); // 构造函数声明
7
~Stack(); // 析构函数声明
8
void push(const T& item); // 入栈声明
9
T pop(); // 出栈声明
10
bool isEmpty() const; // 判断是否为空声明
11
};
12
13
// 构造函数定义
14
template <typename T> // 重复模板参数列表
15
Stack<T>::Stack() { // 使用 Stack<T>:: 限定
16
// ... 构造函数实现
17
std::cout << "Stack created for type " << typeid(T).name() << std::endl;
18
}
19
20
// 析构函数定义
21
template <typename T>
22
Stack<T>::~Stack() {
23
// ... 析构函数实现
24
std::cout << "Stack destroyed for type " << typeid(T).name() << std::endl;
25
}
26
27
// push 成员函数定义
28
template <typename T>
29
void Stack<T>::push(const T& item) {
30
// ... push 实现
31
std::cout << "Pushed item of type " << typeid(T).name() << std::endl;
32
}
33
34
// pop 成员函数定义
35
template <typename T>
36
T Stack<T>::pop() {
37
// ... pop 实现
38
std::cout << "Popped item of type " << typeid(T).name() << std::endl;
39
// 假设返回默认构造的 T
40
return T();
41
}
42
43
// isEmpty 成员函数定义
44
template <typename T>
45
bool Stack<T>::isEmpty() const {
46
// ... isEmpty 实现
47
return true; // 示例,始终返回 true
48
}
✨ 编译与链接注意事项:
⚝ 当成员函数在类模板外部定义时,通常会将类模板的声明和成员函数的定义都放在同一个头文件 (.h
或 .hpp
) 中。这是因为模板代码在编译时需要根据具体类型进行实例化,而实例化需要在看到完整的模板定义(包括成员函数实现)时才能进行。如果将定义放在单独的 .cpp
文件中,编译器在看到使用模板的实例化代码时,可能无法找到成员函数的定义,导致链接错误 (Linker Error)。
⚝ 另一种解决分离编译的方法是使用显式实例化,但对于所有可能的类型都进行显式实例化是不切实际的。将模板定义放在头文件中是更常见和方便的做法。
8.2.3 成员函数的实例化
类模板的成员函数遵循 "按需实例化" (Instantiated On Demand) 的原则。这意味着,只有当你实际调用了某个模板类的特定成员函数时,编译器才会为该成员函数生成对应类型的代码。这有助于减少编译时间和最终可执行文件的大小。
1
// Stack 类模板的定义和成员函数实现在头文件 Stack.hpp 中
2
#include "Stack.hpp"
3
4
int main() {
5
Stack<int> intStack; // Stack<int>::Stack() 构造函数被实例化并调用
6
intStack.push(10); // Stack<int>::push(const int&) 被实例化并调用
7
// intStack.pop(); // 如果不调用,Stack<int>::pop() 就不会被实例化
8
9
Stack<std::string> stringStack; // Stack<std::string>::Stack() 构造函数被实例化并调用
10
stringStack.push("hello"); // Stack<std::string>::push(const std::string&) 被实例化并调用
11
stringStack.isEmpty(); // Stack<std::string>::isEmpty() 被实例化并调用
12
13
// ...
14
return 0;
15
}
在这个例子中,对于 Stack<int>
,构造函数和 push
函数会被实例化。如果 pop
函数从未被调用,那么 Stack<int>::pop()
的代码就不会被编译器生成。对于 Stack<std::string>
,构造函数、push
和 isEmpty
函数会被实例化。
8.3 类模板的特化
在某些情况下,一个通用的类模板实现可能不适用于某个特定类型,或者对于某个特定类型有更高效或不同的实现方式。这时,可以使用模板特化 (Template Specialization) 为特定的类型提供一个专门的实现。模板特化允许我们为特定的模板参数组合提供一个完全不同的类定义。
模板特化分为全特化 (Full Specialization) 和偏特化 (Partial Specialization)。
8.3.1 全特化 (Full Specialization)
全特化是指为类模板的所有模板参数都指定具体的类型(或值),从而为一个特定的实例化版本提供一个完全独立的实现。
语法:template<> class ClassName<SpecificType1, SpecificType2, ...> { ... };
template<>
表示这是一个全特化版本,后面的 <SpecificType1, ...>
指定了特化针对的具体类型。
例如,我们有一个通用的 Storage
类模板,用于存储一个值。但对于 bool
类型,我们可能希望使用位字段 (Bit Field) 或其他内存优化的方式来存储,而不是简单的 bool
变量。
1
// 通用类模板
2
template <typename T>
3
class Storage {
4
public:
5
T value;
6
Storage(T v) : value(v) {
7
std::cout << "General Storage constructor for type " << typeid(T).name() << std::endl;
8
}
9
void print() const {
10
std::cout << "Value: " << value << std::endl;
11
}
12
};
13
14
// Storage<bool> 的全特化版本
15
template<> // 表示全特化
16
class Storage<bool> { // 指定特化针对的类型是 bool
17
public:
18
bool value; // 假设这里为了简单还是用 bool,实际可以优化
19
Storage(bool v) : value(v) {
20
std::cout << "Specialized Storage<bool> constructor" << std::endl;
21
}
22
void print() const {
23
std::cout << "Boolean Value: " << (value ? "true" : "false") << std::endl;
24
}
25
};
使用示例:
1
Storage<int> intStorage(123); // 调用通用模板
2
intStorage.print();
3
4
Storage<bool> boolStorage(true); // 调用 Storage<bool> 的全特化版本
5
boolStorage.print();
当编译器看到 Storage<int>
的实例化请求时,它会使用通用的 Storage
模板。当看到 Storage<bool>
的实例化请求时,它会优先匹配到 Storage<bool>
的全特化版本,并使用这个特化版本进行实例化。
全特化提供了一个针对特定类型的“override”版本。特化版本的成员函数不需要与通用版本完全一致,可以有不同的成员、不同的函数签名,甚至不同的访问权限,因为它是一个全新的类定义。
8.3.2 偏特化 (Partial Specialization)
偏特化是指为类模板的部分模板参数指定具体类型(或值),或者为模板参数的某种特性(如指针、引用、数组)指定特化。偏特化本质上是为一个模板提供一个更具体的通用版本。
语法:template<typename ...> class ClassName<SpecificTemplateParams> { ... };
这里的 SpecificTemplateParams
是原模板参数的一个“模式”,它可以指定部分参数,或者对参数的类型进行限制(如 T*
, const T&
, T[]
等)。
例如,我们有一个 Pair
类模板 template <typename T, typename U>
。我们可以为其提供针对 Pair<T, T>
(两个类型相同)或 Pair<T, T*>
(第二个类型是指向第一个类型的指针)的偏特化版本。
1
// 通用 Pair 类模板
2
template <typename T, typename U>
3
class Pair {
4
public:
5
T first;
6
U second;
7
Pair(T f, U s) : first(f), second(s) {
8
std::cout << "General Pair<" << typeid(T).name() << ", " << typeid(U).name() << "> constructor" << std::endl;
9
}
10
void print() const {
11
std::cout << "Pair: (" << first << ", " << second << ")" << std::endl;
12
}
13
};
14
15
// Pair 的偏特化版本:两个类型相同 Pair<T, T>
16
template <typename T> // 这里只剩下一个未指定的类型参数 T
17
class Pair<T, T> { // 指定了两个类型参数模式都是 T
18
public:
19
T value1;
20
T value2;
21
Pair(T v1, T v2) : value1(v1), value2(v2) {
22
std::cout << "Partial Specialized Pair<T, T> constructor for type " << typeid(T).name() << std::endl;
23
}
24
void print() const {
25
std::cout << "Same Type Pair: (" << value1 << ", " << value2 << ")" << std::endl;
26
}
27
};
28
29
// Pair 的偏特化版本:第二个类型是指针 Pair<T, U*>
30
template <typename T, typename U> // 这里有两个未指定的类型参数 T 和 U
31
class Pair<T, U*> { // 指定了第二个类型模式是指向 U 的指针
32
public:
33
T first;
34
U* second;
35
Pair(T f, U* s) : first(f), second(s) {
36
std::cout << "Partial Specialized Pair<T, U*> constructor for T=" << typeid(T).name() << ", U=" << typeid(U).name() << std::endl;
37
}
38
void print() const {
39
std::cout << "Pointer Pair: (" << first << ", " << second << ")" << std::endl;
40
}
41
};
使用示例:
1
Pair<int, double> p1(1, 2.5); // 调用通用模板
2
p1.print();
3
4
Pair<std::string, std::string> p2("hello", "world"); // 调用 Pair<T, T> 偏特化版本,T=std::string
5
p2.print();
6
7
int i = 10;
8
Pair<double, int*> p3(3.14, &i); // 调用 Pair<T, U*> 偏特化版本,T=double, U=int
9
p3.print();
编译器在实例化模板时,会首先寻找最匹配的特化版本。如果找到全特化版本,则使用全特化版本。如果找到多个偏特化版本,则选择最具体的那个偏特化版本。如果找不到特化版本,则使用通用的主模板 (Primary Template)。
✨ 偏特化的几种形式:
⚝ 参数数量上的偏特化:如 template <typename T, typename U> class Pair;
-> template <typename T> class Pair<T, int>;
(固定第二个参数)。
⚝ 参数特性上的偏特化:如 template <typename T> class Array;
-> template <typename T> class Array<T*>;
(特化指针类型)。
⚝ 值参数的偏特化:如 template <typename T, int Size> class FixedArray;
-> template <typename T> class FixedArray<T, 0>;
(特化 Size 为 0)。
💡 函数模板 vs. 类模板特化:
⚝ 函数模板可以有全特化和偏特化。
⚝ 类模板可以有全特化和偏特化。
⚝ 然而,函数模板的偏特化在语法上是通过函数重载 (Function Overloading) 实现的,而不是像类模板那样在 template<>
后指定模式。例如,一个函数模板 template<typename T> void func(T obj)
的偏特化针对指针类型会写作 template<typename T> void func(T* ptr)
,这实际上是函数重载的一个特殊情况,编译器会选择最匹配的函数模板。而类模板的偏特化则是在 template<>
后的类名尖括号中指定模式。这是两者一个重要的语法区别。本节标题概要提到了函数模板偏特化可能是编写时的笔误,本节内容聚焦于类模板特化。
8.4 模板与继承、友元的关系
类模板可以与其他 C++ OOP 特性(如继承、友元)结合使用,这使得模板的应用更加灵活和强大。
8.4.1 模板与继承 (Inheritance)
类模板可以参与继承关系,既可以作为基类 (Base Class),也可以作为派生类 (Derived Class)。
① 非模板类继承自模板类:
▮▮▮▮一个普通的非模板类可以继承自一个模板类的具体实例化版本。
1
template <typename T>
2
class BaseTemplate {
3
public:
4
T value;
5
BaseTemplate(T v) : value(v) {}
6
void printValue() const { std::cout << "Value: " << value << std::endl; }
7
};
8
9
class DerivedFromInt : public BaseTemplate<int> { // 继承自 BaseTemplate<int>
10
public:
11
DerivedFromInt(int v) : BaseTemplate<int>(v) {}
12
void printPlusOne() const { std::cout << "Value + 1: " << value + 1 << std::endl; }
13
};
14
15
// 使用
16
DerivedFromInt obj(100);
17
obj.printValue(); // 调用基类方法
18
obj.printPlusOne(); // 调用派生类方法
这种情况下,DerivedFromInt
继承的是已经完全确定的 BaseTemplate<int>
类,没有额外的模板参数。
② 模板类继承自非模板类:
▮▮▮▮一个类模板可以继承自一个普通的非模板类。
1
class Base {
2
public:
3
int id;
4
Base(int i) : id(i) {}
5
void printId() const { std::cout << "ID: " << id << std::endl; }
6
};
7
8
template <typename T>
9
class DerivedTemplate : public Base { // 继承自 Base
10
public:
11
T data;
12
DerivedTemplate(int i, T d) : Base(i), data(d) {} // 调用基类构造函数
13
void printData() const { std::cout << "Data: " << data << std::endl; }
14
};
15
16
// 使用
17
DerivedTemplate<std::string> obj(10, "template data");
18
obj.printId(); // 调用基类方法
19
obj.printData(); // 调用派生类方法
这种情况下,基类部分是固定的,而派生类部分则根据模板参数实例化。
③ 模板类继承自依赖于模板参数的模板类:
▮▮▮▮一个更复杂但常见的情况是,一个类模板继承自另一个类模板,并且基类模板的参数依赖于派生类模板的参数。
1
template <typename T>
2
class AnotherBaseTemplate {
3
public:
4
T baseValue;
5
AnotherBaseTemplate(T v) : baseValue(v) {}
6
};
7
8
template <typename T>
9
class DerivedTemplateFromTemplate : public AnotherBaseTemplate<T> { // 继承自 AnotherBaseTemplate<T>
10
public:
11
T derivedValue;
12
// 注意:调用基类构造函数需要 BaseTemplate<T>:: 语法
13
DerivedTemplateFromTemplate(T bv, T dv) : AnotherBaseTemplate<T>(bv), derivedValue(dv) {}
14
15
void printBoth() const {
16
// 问题:如何访问基类的成员?
17
// 在依赖基类中,编译器在第一阶段查找名称时,不会查找依赖基类中的成员。
18
// 需要使用 this-> 或 BaseTemplate<T>:: 来提示编译器这是一个依赖名称。
19
std::cout << "Base Value: " << this->baseValue << ", Derived Value: " << derivedValue << std::endl;
20
// 或
21
std::cout << "Base Value: " << AnotherBaseTemplate<T>::baseValue << ", Derived Value: " << derivedValue << std::endl;
22
// 或者,如果成员是函数,可以在函数名前加 this->
23
// this->baseMethod(); // 如果基类有 baseMethod()
24
}
25
};
26
27
// 使用
28
DerivedTemplateFromTemplate<int> obj(10, 20);
29
obj.printBoth();
在 DerivedTemplateFromTemplate<T>
内部访问 AnotherBaseTemplate<T>
的成员(如 baseValue
)时,可能会遇到依赖名称 (Dependent Name) 的问题。因为直到 DerivedTemplateFromTemplate<T>
被实例化时,编译器才知道 AnotherBaseTemplate<T>
是一个什么具体的类型,也就不知道它有哪些成员。为了告诉编译器 baseValue
是基类中的一个成员,需要使用 this->baseValue
或 AnotherBaseTemplate<T>::baseValue
这样的语法进行限定。对于依赖的基类中的类型名,也需要使用 typename AnotherBaseTemplate<T>::SomeType
这样的语法。这被称为两阶段查找 (Two-Phase Name Lookup) 规则。
8.4.2 模板与友元 (Friend)
友元关系 (Friendship) 允许非成员函数或另一个类访问当前类的私有 (private) 和保护 (protected) 成员。在模板中,友元关系的应用变得更加灵活,可以建立通用模板与特定实例化版本之间的友元关系。
友元声明可以在类模板内部,可以是函数、函数模板、类或类模板。
① 非模板函数作为模板类的友元:
▮▮▮▮一个普通函数可以被声明为一个类模板某个特定实例化版本的友元。
1
template <typename T>
2
class MyData {
3
private:
4
T value;
5
public:
6
MyData(T v) : value(v) {}
7
// 将 printIntData 声明为 MyData<int> 的友元
8
friend void printIntData(const MyData<int>& obj);
9
};
10
11
// 非模板函数,只能访问 MyData<int> 的私有成员
12
void printIntData(const MyData<int>& obj) {
13
std::cout << "Printing MyData<int> value: " << obj.value << std::endl; // 访问私有成员
14
}
15
16
// 使用
17
MyData<int> intObj(10);
18
printIntData(intObj); // OK
19
20
// MyData<double> doubleObj(3.14);
21
// printIntData(doubleObj); // 错误:printIntData 不是 MyData<double> 的友元
注意,这里 printIntData
函数本身不是模板,它只能是 MyData<int>
的友元,不能是 MyData<double>
或其他实例化的友元。
② 模板函数作为模板类的友元:
▮▮▮▮一个函数模板可以被声明为一个类模板所有实例化版本的友元,或者某个特定实例化版本的友元。这对于为模板类重载非成员运算符(如 <<
用于输出流)非常有用。
▮▮▮▮ⓐ 所有实例化版本的友元 (Most Common):
1
template <typename T>
2
class MyData {
3
private:
4
T value;
5
public:
6
MyData(T v) : value(v) {}
7
// 将一个函数模板声明为所有实例化版本的友元
8
// 注意:这里直接使用函数模板的定义作为友元声明
9
template <typename U> // 这是友元函数模板的模板参数列表
10
friend void printData(const MyData<U>& obj);
11
};
12
13
// 在类外部实现友元函数模板
14
template <typename U>
15
void printData(const MyData<U>& obj) {
16
std::cout << "Printing MyData<" << typeid(U).name() << "> value: " << obj.value << std::endl; // 访问私有成员
17
}
18
19
// 使用
20
MyData<int> intObj(10);
21
printData(intObj); // 调用 printData<int>(intObj),可以访问 MyData<int> 的私有成员
22
23
MyData<double> doubleObj(3.14);
24
printData(doubleObj); // 调用 printData<double>(doubleObj),可以访问 MyData<double> 的私有成员
这种方式下,printData
函数模板的任何实例化版本 (printData<int>
, printData<double>
等) 都是对应 MyData
实例化版本 (MyData<int>
, MyData<double>
等) 的友元。这是为模板类重载 operator<<
等运算符的标准做法。
▮▮▮▮ⓑ 某个特定实例化版本的友元:
1
template <typename T>
2
class MyData {
3
private:
4
T value;
5
public:
6
MyData(T v) : value(v) {}
7
// 将函数模板 printSpecificData 的特定实例化版本 printSpecificData<int> 声明为友元
8
friend void printSpecificData<int>(const MyData<int>& obj); // 注意这里的 <int>
9
};
10
11
// 在类外部实现友元函数模板
12
template <typename U>
13
void printSpecificData(const MyData<U>& obj) {
14
std::cout << "Printing MyData<" << typeid(U).name() << "> value: " << obj.value << std::endl;
15
}
16
17
// 使用
18
MyData<int> intObj(10);
19
printSpecificData(intObj); // OK: 调用 printSpecificData<int>(intObj),是友元
20
21
// MyData<double> doubleObj(3.14);
22
// printSpecificData(doubleObj); // OK: 调用 printSpecificData<double>(doubleObj),但不是 MyData<double> 的友元
23
// 尝试访问 doubleObj.value 会导致编译错误
这种方式相对少见,它将友元关系限制在函数模板的一个特定实例化版本与类模板的一个特定实例化版本之间。
③ 非模板类作为模板类的友元:
▮▮▮▮一个普通类可以被声明为一个类模板所有实例化版本的友元,或者某个特定实例化版本的友元。
1
class Helper {
2
public:
3
// ...
4
};
5
6
template <typename T>
7
class MyData {
8
private:
9
T value;
10
public:
11
MyData(T v) : value(v) {}
12
// 将 Helper 类声明为所有 MyData 实例化版本的友元
13
friend class Helper; // 这里的 Helper 指的是非模板类 Helper
14
// 或者,只将 Helper 声明为 MyData<int> 的友元
15
// friend class Helper; // 如果 friend Helper; 放在 MyData<int> 的全特化里
16
};
17
18
// 在 Helper 类成员函数中访问 MyData 实例化版本的私有成员 (如果 friend class Helper; 在通用模板中)
19
// Helper 的成员函数可以访问 MyData<int>::value, MyData<double>::value 等
当 friend class Helper;
声明出现在通用类模板 template <typename T> class MyData { ... friend class Helper; ... };
中时,Helper
类的所有成员函数都可以访问 MyData<T>
的私有成员,对于任何被实例化的 T
。
④ 模板类作为模板类的友元:
▮▮▮▮一个类模板可以被声明为另一个类模板的友元。
▮▮▮▮ⓐ 所有实例化版本的友元:
1
template <typename T> class OtherData; // 前向声明
2
3
template <typename T>
4
class MyData {
5
private:
6
T value;
7
public:
8
MyData(T v) : value(v) {}
9
// 将 OtherData 模板的所有实例化版本声明为 MyData 的所有实例化版本的友元
10
template <typename U>
11
friend class OtherData;
12
};
13
14
template <typename T>
15
class OtherData {
16
public:
17
void accessMyData(MyData<T>& obj) {
18
std::cout << "Accessing MyData<" << typeid(T).name() << "> value from OtherData: " << obj.value << std::endl; // 访问私有成员
19
}
20
};
21
22
// 使用
23
MyData<int> intData(10);
24
OtherData<int> intHelper;
25
intHelper.accessMyData(intData); // OK
这种情况下,OtherData<T>
可以访问 MyData<T>
的私有成员,OtherData<U>
可以访问 MyData<U>
的私有成员。例如,OtherData<int>
是 MyData<int>
的友元,OtherData<double>
是 MyData<double>
的友元。
▮▮▮▮ⓑ 特定实例化版本的友元:
1
template <typename T> class OtherData; // 前向声明
2
3
template <typename T>
4
class MyData {
5
private:
6
T value;
7
public:
8
MyData(T v) : value(v) {}
9
// 将 OtherData<int> 声明为 MyData<int> 的友元
10
friend class OtherData<int>; // 注意这里的 <int>
11
};
12
13
template <typename T>
14
class OtherData {
15
public:
16
void accessMyData(MyData<T>& obj) {
17
// 只能访问 MyData<int> 的私有成员
18
// std::cout << "Accessing MyData<" << typeid(T).name() << "> value from OtherData: " << obj.value << std::endl; // 错误,除非 T 是 int
19
if constexpr (std::is_same_v<T, int>) { // C++17 折叠表达式,编译时分支
20
std::cout << "Accessing MyData<int> value from OtherData<int>: " << obj.value << std::endl; // OK
21
} else {
22
std::cout << "Cannot access MyData<" << typeid(T).name() << "> private members from OtherData<" << typeid(T).name() << ">" << std::endl;
23
}
24
}
25
};
26
27
// 使用
28
MyData<int> intData(10);
29
OtherData<int> intHelper;
30
intHelper.accessMyData(intData); // OK
31
32
MyData<double> doubleData(3.14);
33
OtherData<double> doubleHelper;
34
doubleHelper.accessMyData(doubleData); // doubleHelper 不是 MyData<double> 的友元,不能直接访问 doubleData.value
这种方式将友元关系限定在特定的实例化版本之间。
✨ 友元与封装 (Encapsulation):
⚝ 友元机制破坏了封装性,因为它允许非成员或外部类访问类的内部实现细节。应谨慎使用友元,只有在确实需要打破封装以实现某种功能(如运算符重载)或提高效率时才考虑使用。
⚝ 在模板中,友元通常用于实现与模板类紧密相关的非成员函数(如输入输出运算符)。
至此,我们已经全面探讨了 C++ 类模板的基本概念、定义、实例化、成员函数、特化以及与继承和友元的关系。掌握类模板是进行 C++ 泛型编程的关键,它极大地提高了代码的可重用性和灵活性,是现代 C++ 开发中不可或缺的技术。在后续章节中,我们将看到模板与其他 C++ 特性结合产生的强大能力。
9. 资源管理与 RAII
本章将深入探讨 C++ 中的资源管理。在复杂的软件系统中,除了内存,我们还会频繁地使用到文件句柄(File Handle)、网络连接(Network Connection)、锁(Lock)、数据库连接(Database Connection)等各种系统资源。正确有效地管理这些资源是编写健壮、可靠程序的基础。本章将重点介绍 C++ 中一种强大的资源管理范式——RAII(Resource Acquisition Is Initialization),并详细讲解标准库提供的智能指针(Smart Pointer),最后指导读者如何为特定资源设计自定义的 RAII 类。理解和应用 RAII 是掌握现代 C++ 和编写高质量代码的关键一步。
9.1 传统资源管理的问题
在 C++ 中,资源的管理通常涉及到资源的获取(Acquisition)和释放(Release)。例如,使用 new
获取内存,使用 delete
释放内存;使用 fopen
获取文件句柄,使用 fclose
释放;使用 lock()
获取锁,使用 unlock()
释放。当程序流程简单时,手动管理资源相对容易,但随着程序复杂度的增加,手动管理会带来诸多问题。
9.1.1 手动资源管理的挑战
① 资源泄露(Resource Leak)
资源泄露是指程序获取了资源,但在不再需要或程序结束前未能正确释放资源,导致资源持续被占用,最终可能耗尽系统资源。
▮▮▮▮⚝ 内存泄露(Memory Leak): 这是最常见的资源泄露类型。例如,使用 new
分配内存后,忘记使用 delete
释放,或者在释放前失去了指向这块内存的指针。
1
void process_data(int size) {
2
int* data = new int[size];
3
// ... 使用 data ...
4
// 如果这里发生异常或者提前 return,delete data 就不会被执行
5
delete[] data; // 可能会被跳过
6
}
▮▮▮▮⚝ 其他资源泄露: 文件句柄、网络连接、锁等资源如果未能正确关闭或释放,也会造成泄露。
1
FILE* file = fopen("config.txt", "r");
2
if (file) {
3
// ... 读取文件 ...
4
// 如果中间流程出现问题或异常,fclose 就不会被执行
5
fclose(file); // 可能会被跳过
6
}
7
// 如果 fopen 失败,file 是 nullptr,但后续代码可能没有检查
② 重复释放(Double Free)/无效释放
重复释放是指对同一资源释放多次,这通常会导致程序崩溃或不可预测的行为。无效释放是指释放一个已经被释放或从未分配过的资源。
1
int* ptr = new int;
2
delete ptr;
3
delete ptr; // 错误:重复释放
4
5
int* other_ptr = nullptr;
6
delete other_ptr; // 安全,delete nullptr 没有副作用
③ 异常安全性(Exception Safety)问题
在 C++ 中,异常处理(Exception Handling)是重要的控制流机制。如果在资源获取和释放之间发生了异常,而资源释放代码没有被 try-catch
块或特定的结构保护,那么释放代码就可能永远不会被执行,从而导致资源泄露。
1
void risky_operation() {
2
int* buffer = new int[100]; // 获取资源
3
// ... 可能抛出异常的代码 ...
4
delete[] buffer; // 释放资源,如果上面抛异常,这里执行不到
5
} // buffer 泄露
④ 分支和循环复杂性
在包含多个分支(if/else
)或循环的复杂函数中,确保在所有可能的退出路径(包括正常返回、提前返回、continue、break 或抛出异常)上都能正确释放所有已获取的资源,是一项极具挑战性的任务,极易出错。
传统的 C 风格资源管理(如 malloc/free
, fopen/fclose
)以及 C++ 中不加注意的裸指针(Raw Pointer)和手动 delete
都容易遭受上述问题的困扰。我们需要一种更系统、更健壮的方法来管理程序资源。
9.2 RAII 原则:利用对象生命周期管理资源
RAII(Resource Acquisition Is Initialization),即资源获取即初始化,是 C++ 中一种核心的资源管理策略。它的核心思想是将资源的生命周期与一个对象的生命周期绑定在一起。
9.2.1 RAII 的核心思想
① 资源获取发生在对象的构造函数中
当创建一个对象时,其构造函数(Constructor)被调用。在 RAII 模式下,资源的获取(例如,动态分配内存、打开文件、获取锁)应该在对象的构造函数中完成。如果资源获取失败,构造函数应该抛出异常,表示对象未能成功创建。
② 资源释放发生在对象的析构函数中
当对象生命周期结束时(例如,对象超出作用域、动态对象被 delete
、容器被销毁),其析构函数(Destructor)会被自动调用。在 RAII 模式下,资源的释放(例如,释放内存、关闭文件、释放锁)应该在对象的析构函数中完成。
③ 利用栈对象的自动销毁特性
C++ 保证,无论对象是正常退出作用域还是因异常而被栈展开(Stack Unwinding)导致退出作用域,栈上的对象都会自动调用其析构函数。通过将资源封装在栈对象的析构函数中,我们可以确保资源总是能够被正确释放,从而有效地防止资源泄露。
9.2.2 RAII 的优势
⚝ 自动化的资源管理: 大部分资源的生命周期管理都由 C++ 的类型系统和作用域规则自动完成,大大减少了手动管理的负担和出错的可能性。
⚝ 异常安全: 即使在发生异常时,栈对象的析构函数也会被调用,保证了资源的及时清理。这是 RAII 相对于传统手动资源管理最突出的优势之一。
⚝ 代码简洁与清晰: 资源管理逻辑被封装在类的内部,与业务逻辑分离,使得代码更加清晰、易于理解和维护。
⚝ 模块化与复用: 资源管理类可以独立设计和测试,并在不同的地方复用。
9.2.3 简单示例:文件句柄的 RAII 封装
为了说明 RAII 的思想,我们可以设计一个简单的类来管理文件句柄:
1
#include <cstdio>
2
#include <stdexcept> // For std::runtime_error
3
4
class FileHandle {
5
private:
6
FILE* file_ptr; // 文件句柄
7
8
public:
9
// 构造函数:获取资源 (打开文件)
10
FileHandle(const char* filename, const char* mode) : file_ptr(nullptr) {
11
file_ptr = fopen(filename, mode);
12
if (!file_ptr) {
13
// 如果资源获取失败,抛出异常
14
throw std::runtime_error("无法打开文件");
15
}
16
// 资源获取成功,file_ptr 不为 nullptr
17
printf("文件 '%s' 已打开。\n", filename);
18
}
19
20
// 析构函数:释放资源 (关闭文件)
21
~FileHandle() {
22
if (file_ptr) {
23
fclose(file_ptr);
24
printf("文件已关闭。\n");
25
file_ptr = nullptr; // 置空,避免重复释放
26
}
27
}
28
29
// 禁止拷贝和赋值,因为文件句柄通常不可拷贝
30
// C++11 之后,可以声明 deleted 函数
31
FileHandle(const FileHandle&) = delete;
32
FileHandle& operator=(const FileHandle&) = delete;
33
34
// 提供访问底层资源的成员函数(可选)
35
FILE* get() const {
36
return file_ptr;
37
}
38
39
// 移动构造函数和移动赋值运算符(可选,取决于资源语义)
40
// 这里为了简单示例,先忽略移动语义,默认编译器会生成,但对于资源类,通常需要自定义
41
// FileHandle(FileHandle&& other) noexcept : file_ptr(other.file_ptr) { other.file_ptr = nullptr; }
42
// FileHandle& operator=(FileHandle&& other) noexcept {
43
// if (this != &other) {
44
// if (file_ptr) fclose(file_ptr);
45
// file_ptr = other.file_ptr;
46
// other.file_ptr = nullptr;
47
// }
48
// return *this;
49
// }
50
};
51
52
// 使用 RAII 类管理文件
53
void process_file(const char* filename) {
54
try {
55
FileHandle my_file(filename, "r"); // 资源获取 (打开文件)
56
// ... 使用 my_file 进行文件操作 ...
57
printf("正在处理文件...\n");
58
// 模拟在处理过程中可能抛出异常
59
// if (rand() % 2 == 0) {
60
// throw std::runtime_error("处理文件时发生错误");
61
// }
62
printf("文件处理完成。\n");
63
} catch (const std::exception& e) {
64
fprintf(stderr, "发生错误: %s\n", e.what());
65
}
66
// my_file 在作用域结束时自动销毁,析构函数被调用,文件被关闭
67
printf("函数 process_file 结束。\n");
68
}
69
70
int main() {
71
// 创建一个测试文件
72
FILE* test_file = fopen("test.txt", "w");
73
if (test_file) {
74
fprintf(test_file, "This is a test file.\n");
75
fclose(test_file);
76
}
77
78
process_file("test.txt");
79
// process_file("nonexistent_file.txt"); // 测试异常情况
80
81
return 0;
82
}
在这个例子中,FileHandle
对象在栈上创建。无论 try
块中的代码是正常执行完毕,还是因为 std::runtime_error
异常而终止,my_file
对象都会在其作用域结束时被销毁,其析构函数会自动调用 fclose(file_ptr)
,从而保证文件句柄得到释放。这就是 RAII 的强大之处。
9.3 智能指针:unique_ptr, shared_ptr, weak_ptr
内存是 C++ 中最常见且最重要的资源之一。手动管理动态分配的内存(使用 new
和 delete
)是内存泄露和悬垂指针(Dangling Pointer)等问题的主要原因。为了解决这些问题,C++ 引入了智能指针(Smart Pointer)。智能指针是 RAII 原则在内存管理上的具体应用,它们是包装了裸指针的类模板,通过对象的生命周期来自动管理所指向的内存。
C++ 标准库(从 C++11 开始)提供了三种主要的智能指针:std::unique_ptr
、std::shared_ptr
和 std::weak_ptr
。
9.3.1 std::unique_ptr
:独占所有权
① 概念
std::unique_ptr
是一个独占所有权的智能指针。这意味着在任何时间点,只有一个 unique_ptr
可以指向特定的资源(通常是堆上的对象)。当 unique_ptr
对象被销毁时,它所拥有的资源也会被自动释放(通过调用 delete
或 delete[]
)。
② 特性
▮▮▮▮⚝ 独占性: 不允许普通的拷贝操作。一个 unique_ptr
不能像普通指针或 shared_ptr
那样简单地复制给另一个 unique_ptr
。
▮▮▮▮⚝ 移动语义(Move Semantics): unique_ptr
支持移动。你可以将一个 unique_ptr
的所有权转移给另一个 unique_ptr
,转移后原 unique_ptr
变为空(指向 nullptr
)。这是通过移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment Operator)实现的。
▮▮▮▮⚝ 轻量级: unique_ptr
通常与其内部的裸指针拥有相同的内存开销,因为它不需要维护引用计数等额外信息。
▮▮▮▮⚝ 支持数组: unique_ptr<T[]>
可以用来管理动态分配的数组,并在销毁时调用 delete[]
。
③ 使用场景
unique_ptr
是管理动态分配内存的首选智能指针,除非你需要共享对象的所有权。它适用于表示资源的独占性,例如:
▮▮▮▮⚝ 函数返回一个新创建的堆对象的所有权。
▮▮▮▮⚝ 类成员,独占地拥有另一个对象。
▮▮▮▮⚝ 在容器中存储指向堆对象的指针,而容器独占这些对象。
④ 示例
1
#include <iostream>
2
#include <memory> // 包含 unique_ptr
3
4
class MyClass {
5
public:
6
MyClass() { std::cout << "MyClass 构造函数\n"; }
7
~MyClass() { std::cout << "MyClass 析构函数\n"; }
8
void greet() { std::cout << "Hello from MyClass\n"; }
9
};
10
11
int main() {
12
// 创建一个 unique_ptr
13
std::unique_ptr<MyClass> ptr1(new MyClass()); // C++11 风格
14
// 推荐使用 std::make_unique (C++14)
15
// std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
16
17
ptr1->greet();
18
19
// 尝试拷贝 (编译错误)
20
// std::unique_ptr<MyClass> ptr2 = ptr1;
21
22
// 移动所有权
23
std::unique_ptr<MyClass> ptr3 = std::move(ptr1);
24
// 现在 ptr1 为空,ptr3 拥有 MyClass 对象
25
26
if (!ptr1) {
27
std::cout << "ptr1 现在为空\n";
28
}
29
30
ptr3->greet();
31
32
// 释放所有权但不删除对象 (需要手动 delete)
33
// MyClass* raw_ptr = ptr3.release();
34
// // raw_ptr 现在拥有对象,ptr3 为空
35
// delete raw_ptr; // 需要手动释放
36
37
// 重置 unique_ptr,释放当前拥有的对象
38
ptr3.reset(); // MyClass 对象被删除
39
40
if (!ptr3) {
41
std::cout << "ptr3 现在为空\n";
42
}
43
44
// 离开作用域时,ptr1 和 ptr3 (如果还有效) 会自动销毁,并释放资源
45
return 0;
46
} // ptr1 (空) 和 ptr3 (空) 的析构函数被调用
⑤ make_unique
(C++14)
推荐使用 std::make_unique<T>(args...)
而不是直接 new T()
。它的优点包括:
▮▮▮▮⚝ 异常安全:如果构造函数抛出异常,make_unique
可以确保不会发生资源泄露(尤其是在参数是表达式调用时)。
▮▮▮▮⚝ 代码简洁。
▮▮▮▮⚝ 性能略优(可能)。
9.3.2 std::shared_ptr
:共享所有权
① 概念
std::shared_ptr
是一个共享所有权的智能指针。多个 shared_ptr
可以指向同一个资源。shared_ptr
内部维护一个引用计数(Reference Count),记录有多少个 shared_ptr
共同拥有这个资源。当最后一个拥有资源的 shared_ptr
被销毁或不再指向该资源时,资源才会被释放。
② 特性
▮▮▮▮⚝ 共享性: 允许拷贝,每次拷贝都会增加引用计数。
▮▮▮▮⚝ 引用计数: 自动跟踪共享资源的 shared_ptr
数量。
▮▮▮▮⚝ 资源释放: 仅在引用计数归零时释放资源。
▮▮▮▮⚝ 开销: 相较于 unique_ptr
,shared_ptr
需要维护引用计数,通常占用更多的内存(可能是一个控制块),并且在拷贝和赋值时有引用计数操作的开销。
③ 使用场景
当多个部分的代码需要共享访问同一个动态分配的对象,并且这些部分的生命周期相互独立时,shared_ptr
是一个合适的选择。例如:
▮▮▮▮⚝ 多个对象需要引用同一个配置文件对象。
▮▮▮▮⚝ 在图结构中,节点之间相互引用。
▮▮▮▮⚝ 事件系统中,多个监听器需要引用同一个事件源。
④ 示例
1
#include <iostream>
2
#include <memory> // 包含 shared_ptr
3
4
class AnotherClass {
5
public:
6
AnotherClass() { std::cout << "AnotherClass 构造函数\n"; }
7
~AnotherClass() { std::cout << "AnotherClass 析构函数\n"; }
8
};
9
10
void process_shared(std::shared_ptr<AnotherClass> obj) {
11
std::cout << "在 process_shared 中,引用计数: " << obj.use_count() << "\n";
12
// obj 在函数结束时销毁,引用计数减少
13
}
14
15
int main() {
16
std::shared_ptr<AnotherClass> shared_ptr1;
17
18
{
19
// 推荐使用 std::make_shared (C++11)
20
std::shared_ptr<AnotherClass> shared_ptr2 = std::make_shared<AnotherClass>();
21
// std::shared_ptr<AnotherClass> shared_ptr2(new AnotherClass()); // C++11 风格
22
23
std::cout << "shared_ptr2 引用计数: " << shared_ptr2.use_count() << "\n"; // 输出 1
24
25
shared_ptr1 = shared_ptr2; // 拷贝,引用计数增加
26
std::cout << "shared_ptr2 引用计数: " << shared_ptr2.use_count() << "\n"; // 输出 2
27
std::cout << "shared_ptr1 引用计数: " << shared_ptr1.use_count() << "\n"; // 输出 2
28
29
process_shared(shared_ptr1); // 按值传递 shared_ptr,引用计数在函数内部临时增加,结束后减少
30
31
std::cout << "离开 process_shared 后,shared_ptr1 引用计数: " << shared_ptr1.use_count() << "\n"; // 输出 2
32
33
} // shared_ptr2 在这里离开作用域,销毁,引用计数减1
34
35
std::cout << "shared_ptr1 引用计数: " << shared_ptr1.use_count() << "\n"; // 输出 1
36
37
// shared_ptr1 在这里离开作用域,销毁,引用计数减1,归零,AnotherClass 对象被删除
38
return 0;
39
}
⑤ make_shared
(C++11)
推荐使用 std::make_shared<T>(args...)
而不是直接 new T()
。它的优点包括:
▮▮▮▮⚝ 性能:make_shared
只进行一次内存分配,同时分配对象本身和引用计数相关的控制块,而 shared_ptr(new T())
会进行两次分配。这可以减少内存碎片和开销。
▮▮▮▮⚝ 异常安全:与 make_unique
类似,提供异常安全保证。
▮▮▮▮⚝ 代码简洁。
⑥ 循环引用(Cyclic Reference)问题
shared_ptr
的引用计数模型存在一个潜在问题:循环引用。如果两个或多个对象互相持有对方的 shared_ptr
,就会形成一个环。即使外部不再有 shared_ptr
指向这些对象,它们的引用计数也不会降为零,导致资源永远不会被释放,形成内存泄露。
1
#include <iostream>
2
#include <memory>
3
4
class B; // 前置声明
5
6
class A {
7
public:
8
std::shared_ptr<B> b_ptr;
9
A() { std::cout << "A 构造函数\n"; }
10
~A() { std::cout << "A 析构函数\n"; }
11
};
12
13
class B {
14
public:
15
std::shared_ptr<A> a_ptr; // 注意这里使用了 shared_ptr
16
B() { std::cout << "B 构造函数\n"; }
17
~B() { std::cout << "B 析构函数\n"; }
18
};
19
20
int main() {
21
std::shared_ptr<A> pa = std::make_shared<A>(); // pa 引用计数 1
22
std::shared_ptr<B> pb = std::make_shared<B>(); // pb 引用计数 1
23
24
std::cout << "pa 引用计数: " << pa.use_count() << ", pb 引用计数: " << pb.use_count() << "\n";
25
26
pa->b_ptr = pb; // pb 的引用计数增加到 2
27
pb->a_ptr = pa; // pa 的引用计数增加到 2
28
29
std::cout << "pa 引用计数: " << pa.use_count() << ", pb 引用计数: " << pb.use_count() << "\n";
30
31
// pa 和 pb 在这里离开作用域,销毁
32
// pa 销毁,pa 指向的 A 对象的引用计数减1 (变为1)
33
// pb 销毁,pb 指向的 B 对象的引用计数减1 (变为1)
34
// A 和 B 对象的引用计数都未归零 (都为1),它们的析构函数不会被调用,发生内存泄露!
35
36
return 0;
37
}
为了解决循环引用问题,引入了 std::weak_ptr
。
9.3.3 std::weak_ptr
:观察者指针
① 概念
std::weak_ptr
是一种不控制对象生命周期的智能指针。它指向一个由 shared_ptr
管理的对象,但它本身不增加对象的引用计数。weak_ptr
像是一个“观察者”,它可以检查对象是否存在,并在对象存在时安全地访问它,但在对象不存在时也不会导致程序崩溃。
② 特性
▮▮▮▮⚝ 不拥有所有权: weak_ptr
指向 shared_ptr
管理的对象,但不影响对象的生命周期。
▮▮▮▮⚝ 检查有效性: 可以检查其指向的对象是否仍然存在(即,是否还有 shared_ptr
指向它)。
▮▮▮▮⚝ 不能直接访问资源: 不能像 shared_ptr
或裸指针那样直接通过 ->
或 *
访问对象。必须先通过 lock()
方法获取一个 shared_ptr
,如果对象仍然存在,lock()
返回一个有效的 shared_ptr
;否则,返回一个空的 shared_ptr
。
▮▮▮▮⚝ 解决循环引用: 在可能出现循环引用的地方,让其中一个指针使用 weak_ptr
而不是 shared_ptr
,可以打破引用循环。
③ 使用场景
▮▮▮▮⚝ 打破 shared_ptr
的循环引用。
▮▮▮▮⚝ 缓存机制:缓存中存储 weak_ptr
,可以引用对象但又不阻止对象在内存不足时被清理。
▮▮▮▮⚝ 观察者模式:观察者持有主题的 weak_ptr
,避免观察者持续拥有主题,导致主题无法被销毁。
④ 示例 (解决循环引用)
1
#include <iostream>
2
#include <memory>
3
4
class B_fixed; // 前置声明
5
6
class A_fixed {
7
public:
8
std::shared_ptr<B_fixed> b_ptr;
9
A_fixed() { std::cout << "A_fixed 构造函数\n"; }
10
~A_fixed() { std::cout << "A_fixed 析构函数\n"; }
11
};
12
13
class B_fixed {
14
public:
15
std::weak_ptr<A_fixed> a_ptr; // ****** 关键:这里使用 weak_ptr ******
16
B_fixed() { std::cout << "B_fixed 构造函数\n"; }
17
~B_fixed() { std::cout << "B_fixed 析构函数\n"; }
18
};
19
20
int main() {
21
std::shared_ptr<A_fixed> pa = std::make_shared<A_fixed>(); // pa 引用计数 1
22
std::shared_ptr<B_fixed> pb = std::make_shared<B_fixed>(); // pb 引用计数 1
23
24
std::cout << "pa 引用计数: " << pa.use_count() << ", pb 引用计数: " << pb.use_count() << "\n";
25
26
pa->b_ptr = pb; // pb 的引用计数增加到 2
27
pb->a_ptr = pa; // pa 的引用计数不变 (weak_ptr 不增加引用计数)
28
29
std::cout << "pa 引用计数: " << pa.use_count() << ", pb 引用计数: " << pb.use_count() << "\n";
30
31
// pa 和 pb 在这里离开作用域,销毁
32
// pa 销毁,pa 指向的 A_fixed 对象的引用计数减1 (变为1)
33
// pb 销毁,pb 指向的 B_fixed 对象的引用计数减1 (变为1)
34
// A_fixed 对象的引用计数变为 1 (因为 B_fixed 持有的是 weak_ptr)
35
// B_fixed 对象的引用计数变为 1 (因为 A_fixed 持有的是 shared_ptr)
36
37
// 外部的 shared_ptr pa 和 pb 销毁后,A_fixed 和 B_fixed 对象的引用计数都变为 1。
38
// 咦?为什么析构函数还是没调用?🤔
39
// 原因在于,当 pa 和 pb 超出作用域时,它们所管理的对象的引用计数减1。
40
// A_fixed 对象的引用计数从 2 (pa + pb->a_ptr) 减为 1 (只剩下 pb->a_ptr 指向,哦,不对,pb->a_ptr 是 weak_ptr 不算)。
41
// A_fixed 对象的引用计数从 2 (pa + pb->a_ptr) -> pa 销毁 -> 1 (pb->a_ptr 是 weak_ptr,不计入 shared 引用计数)
42
// B_fixed 对象的引用计数从 2 (pb + pa->b_ptr) -> pb 销毁 -> 1 (pa->b_ptr 仍然是 shared_ptr)
43
// 看来上面的解释还是有点问题。
44
45
// 让我们重新分析引用计数:
46
// 初始:pa 指向 A (count=1), pb 指向 B (count=1)
47
// pa->b_ptr = pb; -> pb 的引用计数 += 1。 现在:pa 指向 A (count=1), pb 指向 B (count=2, pb, pa->b_ptr)
48
// pb->a_ptr = pa; -> pa 的引用计数 += 0 (因为是 weak_ptr)。 现在:pa 指向 A (count=1), pb 指向 B (count=2)
49
50
// main 函数结束,pa 销毁。 pa 指向的 A 对象的引用计数 -= 1。 A 对象引用计数变为 0。
51
// A 对象的引用计数归零,A 对象被销毁,A 的析构函数被调用。
52
// 在 A 的析构函数中,pa->b_ptr (即 pb) 会被销毁。pb 指向的 B 对象的引用计数 -= 1。 B 对象引用计数从 2 变为 1。
53
// A 的析构函数执行完毕。
54
55
// main 函数继续,pb 销毁。 pb 指向的 B 对象的引用计数 -= 1。 B 对象引用计数从 1 变为 0。
56
// B 对象的引用计数归零,B 对象被销毁,B 的析构函数被调用。
57
// 在 B 的析构函数中,pb->a_ptr (即 pa) 会被销毁。pb->a_ptr 是一个 weak_ptr,销毁 weak_ptr 不会影响 shared_ptr 的引用计数。
58
// B 的析构函数执行完毕。
59
60
// 结论:通过使用 weak_ptr,循环引用被打破,A 和 B 对象都能被正确销毁。
61
62
std::cout << "pa 销毁后的引用计数 (pa): " << (pa ? pa.use_count() : 0) << "\n"; // 0
63
std::cout << "pb 销毁后的引用计数 (pb): " << (pb ? pb.use_count() : 0) << "\n"; // 0
64
65
// 示例:如何使用 weak_ptr 访问对象
66
std::shared_ptr<A_fixed> shared_from_weak = pb->a_ptr.lock();
67
if (shared_from_weak) {
68
std::cout << "通过 weak_ptr 成功获取 shared_ptr,对象仍然存在。\n";
69
std::cout << "获取到的 shared_ptr 引用计数: " << shared_from_weak.use_count() << "\n"; // 应该大于 1 (因为 pa 已经销毁,但这里 lock() 又创建了一个临时的 shared_ptr)
70
} else {
71
std::cout << "通过 weak_ptr 获取 shared_ptr 失败,对象已被销毁。\n";
72
}
73
74
75
return 0;
76
}
在这个例子中,我们将 B_fixed
持有的指向 A_fixed
的指针改为 std::weak_ptr<A_fixed> a_ptr;
。这样,B_fixed
的存在就不会增加 A_fixed
的引用计数。当外部的 shared_ptr pa
销毁时,A_fixed
对象的引用计数会降为 0,从而触发 A_fixed
的析构函数,进而清理其持有的 shared_ptr<B_fixed> b_ptr
,导致 B_fixed
对象的引用计数也降为 0 并被销毁。循环引用被成功打破。
9.3.4 智能指针的选择建议
① 优先使用 unique_ptr
:如果一个资源是某个对象独占的,毫无疑问应该使用 unique_ptr
。它提供了独占性保证,性能开销最小,并支持移动语义。
② 需要共享所有权时使用 shared_ptr
:只有当你确实需要多个指针共享同一个资源的所有权时,才使用 shared_ptr
。注意其维护引用计数的开销以及潜在的循环引用问题。
③ 使用 weak_ptr
打破 shared_ptr
循环引用:当两个对象需要相互引用,并且都使用 shared_ptr
可能导致循环引用时,应该让其中一个引用使用 weak_ptr
。这通常适用于父子关系(子持有父的 weak_ptr
)或同伴关系。
9.4 自定义 RAII 类
智能指针是 RAII 在动态内存管理上的成功应用。但是 RAII 原则并不仅限于内存。我们可以为任何需要“获取-使用-释放”模式管理的资源设计自定义的 RAII 类。这包括文件句柄、互斥锁、网络套接字、数据库连接、图形资源(如纹理、缓冲区)、系统句柄等。
9.4.1 设计自定义 RAII 类的步骤
① 确定需要管理的资源:识别程序中需要特殊获取和释放操作的资源。
② 创建一个类:这个类将用于封装资源。
③ 在构造函数中获取资源:在类的构造函数中执行资源的获取操作。如果获取失败,应该抛出异常,以符合 RAII 原则的“资源获取即初始化”含义。
④ 在析构函数中释放资源:在类的析构函数中执行资源的释放操作。确保析构函数不会抛出异常(noexcept
)。
⑤ 处理拷贝和赋值:考虑资源是否可以被拷贝或赋值。
▮▮▮▮⚝ 如果资源是独占的(如文件句柄、锁),通常应该禁止拷贝和赋值(使用 = delete
)。可以考虑提供移动语义(移动构造函数和移动赋值运算符),以便安全地转移资源所有权。
▮▮▮▮⚝ 如果资源可以共享(较少见),可以考虑实现类似 shared_ptr
的引用计数机制(但更常见的是直接使用 shared_ptr
包装自定义的资源对象)。
⑥ 提供访问资源的接口:根据需要,提供成员函数来访问或操作底层资源。可以提供一个 get()
方法返回底层资源的裸句柄,但要注意使用裸句柄的安全性。
⑦ 考虑边界情况:例如,构造函数获取资源失败、析构函数中资源已经无效等。
9.4.2 示例:互斥锁的 RAII 封装 (Lock Guard)
管理互斥锁(Mutex)是一个典型的 RAII 应用场景。在多线程编程中,获取锁(lock()
)后,必须确保在所有可能的退出路径上(包括正常返回和异常)都能释放锁(unlock()
),否则会导致死锁(Deadlock)。std::lock_guard
和 std::unique_lock
就是 C++ 标准库提供的 RAII 风格的锁管理器。
我们可以模仿 std::lock_guard
实现一个简单的锁管理器:
1
#include <iostream>
2
#include <mutex> // 使用标准库的 mutex
3
4
// 假设我们有一个全局的 mutex
5
std::mutex global_mutex;
6
7
class MyLockGuard {
8
private:
9
std::mutex& mtx; // 引用需要管理的 mutex
10
11
public:
12
// 构造函数:获取资源 (锁定 mutex)
13
explicit MyLockGuard(std::mutex& mutex) : mtx(mutex) {
14
std::cout << "尝试锁定 mutex...\n";
15
mtx.lock(); // 锁定 mutex
16
std::cout << "mutex 已锁定。\n";
17
}
18
19
// 析构函数:释放资源 (解锁 mutex)
20
~MyLockGuard() noexcept { // 析构函数应为 noexcept
21
std::cout << "解锁 mutex...\n";
22
mtx.unlock(); // 解锁 mutex
23
std::cout << "mutex 已解锁。\n";
24
}
25
26
// 禁止拷贝和赋值,因为锁是独占的
27
MyLockGuard(const MyLockGuard&) = delete;
28
MyLockGuard& operator=(const MyLockGuard&) = delete;
29
30
// MyLockGuard 不支持移动,因为它引用了一个外部的 mutex
31
// MyLockGuard(MyLockGuard&&) = delete;
32
// MyLockGuard& operator=(MyLockGuard&&) = delete;
33
// 注意:std::unique_lock 支持移动
34
};
35
36
void safe_function() {
37
// 创建 MyLockGuard 对象,在构造函数中锁定 global_mutex
38
MyLockGuard lock(global_mutex); // 资源获取
39
40
// ... 受保护的代码 ...
41
std::cout << "在安全区域内...\n";
42
43
// 无论这里是否抛出异常,lock 对象在离开作用域时都会销毁
44
// 析构函数会被调用,自动解锁 global_mutex
45
46
// 示例:可能抛出异常的操作
47
// if (rand() % 2 == 0) {
48
// throw std::runtime_error("Oops, something went wrong!");
49
// }
50
51
std::cout << "安全区域结束。\n";
52
} // lock 对象在这里超出作用域,析构函数被调用,global_mutex 被解锁
53
54
int main() {
55
try {
56
safe_function();
57
} catch (const std::exception& e) {
58
std::cerr << "捕获到异常: " << e.what() << std::endl;
59
}
60
61
// global_mutex 在 safe_function 返回后保证已被解锁
62
std::cout << "main 函数结束。\n";
63
64
return 0;
65
}
在这个例子中,MyLockGuard
对象 lock
在 safe_function
的开头被创建。它的构造函数锁定 global_mutex
。当 safe_function
正常返回或因异常而退出时,lock
对象都会被自动销毁,其析构函数会调用 global_mutex.unlock()
,确保锁总是被正确释放。这就是 RAII 在同步原语(Synchronization Primitive)管理中的典型应用,极大地简化了并发编程中的锁管理。
9.4.3 总结自定义 RAII 类
设计自定义 RAII 类是 C++ 程序员必备的技能之一。它使得我们可以将各种复杂的资源管理逻辑封装到类中,利用 C++ 的面向对象特性和语言机制(构造函数、析构函数、作用域、异常处理)来实现自动化、异常安全和模块化的资源管理。这不仅提高了代码的健壮性和可维护性,也体现了现代 C++ 强调的“让语言自身帮助我们管理资源”的理念。
10. 异常处理:构建健壮的程序
本章将带你深入了解 C++ 中的异常处理 (Exception Handling) 机制。在复杂的软件系统中,程序运行时难免会遇到各种预期之外的情况,例如文件无法打开、内存分配失败、网络连接中断等。传统的错误处理方式(如返回错误码)往往使得代码流程复杂、错误信息传递困难,特别是在跨越多层函数调用的场景下。C++ 的异常处理提供了一种更结构化、更优雅的方式来分离正常逻辑与错误处理逻辑,从而帮助我们构建更加健壮 (Robust)、更易于维护的程序。我们将学习如何使用 try
、catch
和 throw
关键字来检测、抛出和捕获异常,理解异常处理机制的工作原理,以及它与 C++ 其他重要特性(如构造函数、析构函数、资源管理以及 RAII 原则)之间紧密的联系。
10.1 异常处理的基本机制
传统的 C/C++ 错误处理通常依赖于函数返回值(错误码)或全局变量。这种方式在简单的程序中尚可接受,但在大型复杂系统中存在诸多弊端:
⚝ 调用者必须时刻检查返回值,容易遗漏。
⚝ 错误信息通常是一个简单的数字,难以携带详细的上下文信息。
⚝ 在多层函数调用中,错误码需要在各层之间层层传递,使得函数签名和逻辑变得复杂。
⚝ 对于构造函数等没有返回值的特殊成员函数,难以通过返回值报告错误。
异常处理 (Exception Handling) 机制应运而生,它提供了一种非本地 (Non-local) 的错误转移机制。当一个函数遇到了无法处理的错误时,它可以“抛出”一个异常,程序的控制权会沿着函数调用栈向上回溯 (Stack Unwinding),直到找到一个能够“捕获”该异常的处理器。
C++ 异常处理的核心包含三个关键字:
① throw
:用于抛出一个异常对象。这会中断当前的执行流程。
② try
:用于标识一个可能抛出异常的代码块。
③ catch
:用于标识一个处理特定类型异常的代码块。它紧跟在 try
块之后,用于捕获 try
块或从 try
块内部调用的函数中抛出的异常。
基本的语法结构如下:
1
try {
2
// 可能抛出异常的代码块
3
// 如果这里或调用内部的代码抛出了异常...
4
// ...当前的 try 块会被中断
5
} catch (ExceptionType1 ex1) {
6
// 捕获并处理 ExceptionType1 类型的异常
7
} catch (ExceptionType2 ex2) {
8
// 捕获并处理 ExceptionType2 类型的异常
9
} catch (...) {
10
// 捕获任何其他类型的异常 (万能捕获)
11
}
示例:一个简单的除法函数可能遇到的错误(除以零)。
1
#include <iostream>
2
#include <stdexcept> // 标准异常类头文件
3
4
double divide(double numerator, double denominator) {
5
if (denominator == 0) {
6
// 抛出一个标准的 runtime_error 异常对象
7
throw std::runtime_error("Division by zero error!");
8
}
9
return numerator / denominator;
10
}
11
12
int main() {
13
try {
14
double result = divide(10.0, 2.0);
15
std::cout << "Result: " << result << std::endl; // 正常执行
16
17
result = divide(5.0, 0.0);
18
std::cout << "This line will not be reached." << std::endl; // 抛出异常后,此行不会执行
19
20
} catch (const std::runtime_error& e) {
21
// 捕获 std::runtime_error 类型的异常
22
std::cerr << "Caught exception: " << e.what() << std::endl;
23
} catch (...) {
24
// 捕获其他类型的异常
25
std::cerr << "Caught an unknown exception." << std::endl;
26
}
27
28
std::cout << "Program continues after exception handling." << std::endl;
29
30
return 0;
31
}
运行输出:
1
Result: 5
2
Caught exception: Division by zero error!
3
Program continues after exception handling.
在这个例子中:
⚝ divide
函数在检测到除数为零时,使用 throw
抛出一个 std::runtime_error
类型的异常对象。
⚝ main
函数中的第一个 try
块包含了对 divide
的两次调用。
⚝ 第一次调用正常完成。
⚝ 第二次调用抛出了异常。此时,try
块的剩余代码(std::cout << "This line will not be reached."
)被跳过。
⚝ 程序控制权转移到紧跟的 catch
块。
⚝ catch (const std::runtime_error& e)
匹配到了抛出的异常类型,因此该 catch
块被执行,打印出错误信息。
⚝ 异常处理完毕后,程序流程恢复,继续执行 try-catch
块之后的语句。
这种机制将错误检测 (在 divide
函数中) 与错误处理 (在 main
函数的 catch
块中) 分离开来,使得代码更加清晰。
10.2 异常的抛出与捕获
10.2.1 异常的抛出 (Throwing)
throw
表达式用于抛出一个异常。它可以抛出任何类型的对象,包括基本类型、类对象或指针。通常,建议抛出类对象,特别是继承自 std::exception
标准库异常体系的类,以便能够携带更丰富的错误信息。
抛出不同类型的异常:
1
// 抛出基本类型
2
throw 101; // 抛出 int 类型
3
throw "Error!"; // 抛出 const char* 类型
4
5
// 抛出标准库异常对象
6
throw std::runtime_error("Something went wrong."); // 抛出 std::runtime_error 对象
7
throw std::bad_alloc(); // 抛出内存分配失败异常
8
9
// 抛出自定义异常对象
10
class MyError {
11
public:
12
std::string message;
13
MyError(const std::string& msg) : message(msg) {}
14
};
15
// ...
16
throw MyError("Custom error occurred.");
当 throw
表达式执行时,被抛出的对象会被复制 (或移动,取决于 C++ 版本和对象类型) 到一个由运行时环境管理的特殊区域。这个对象就是 catch
块可以访问到的异常对象。
10.2.2 异常的捕获 (Catching)
catch
块指定了要捕获的异常类型。如果抛出的异常类型与 catch
块指定的类型匹配(或者可以隐式转换为该类型,或者派生类异常被捕获基类类型),则执行相应的 catch
块。
捕获类型:
⚝ 按值捕获 (catch (ExceptionType ex)
): 异常对象会被拷贝到 catch
块的参数 ex
中。如果异常对象较大或拷贝成本高昂,这可能效率较低。不推荐使用。
⚝ 按引用捕获 (catch (ExceptionType& ex)
): 异常对象会以引用方式传递给 catch
块。这避免了拷贝,效率更高,并且允许修改异常对象(尽管通常不推荐这样做)。
⚝ 按 const 引用捕获 (catch (const ExceptionType& ex)
): 最常用的方式。异常对象以常量引用方式传递,避免了拷贝,效率高,且保证在 catch
块中不会意外修改异常对象。对于继承体系中的异常,这允许通过基类引用捕获派生类对象,实现多态捕获。
⚝ 捕获所有类型 (catch (...)
): 这是一个特殊的 catch
块,可以捕获任何类型的异常。通常放在最后一个 catch
块,用于处理未知或未预期类型的异常。
多个 catch 块:
一个 try
块后面可以跟多个 catch
块。当抛出异常时,系统会按照 catch
块出现的顺序,从上到下依次尝试匹配异常类型。一旦找到第一个匹配的 catch
块,就会执行它,而后续的 catch
块则会被忽略。因此,更具体的异常类型应放在前面,更通用的异常类型(如基类或 catch(...)
)应放在后面。
示例: 捕获不同类型的异常
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
#include <stdexcept>
5
6
class NetworkError : public std::runtime_error {
7
public:
8
NetworkError(const std::string& msg) : std::runtime_error(msg) {}
9
};
10
11
class FileError : public std::runtime_error {
12
public:
13
FileError(const std::string& msg) : std::runtime_error(msg) {}
14
};
15
16
void process(int choice) {
17
if (choice == 1) {
18
throw NetworkError("Connection timed out.");
19
} else if (choice == 2) {
20
throw FileError("File not found.");
21
} else if (choice == 3) {
22
throw std::out_of_range("Index out of bounds.");
23
} else if (choice == 4) {
24
throw "Just a string literal."; // 抛出 const char*
25
} else {
26
std::cout << "Processing successful." << std::endl;
27
}
28
}
29
30
int main() {
31
for (int i = 1; i <= 5; ++i) {
32
try {
33
process(i);
34
} catch (const NetworkError& e) {
35
std::cerr << "Caught Network Error: " << e.what() << std::endl;
36
} catch (const FileError& e) {
37
std::cerr << "Caught File Error: " << e.what() << std::endl;
38
} catch (const std::runtime_error& e) { // 捕获基类,会捕获到 std::out_of_range
39
std::cerr << "Caught Runtime Error (or derived): " << e.what() << std::endl;
40
} catch (const char* msg) { // 捕获 const char*
41
std::cerr << "Caught C-style string error: " << msg << std::endl;
42
} catch (...) { // 捕获所有其他类型
43
std::cerr << "Caught an unexpected type of exception." << std::endl;
44
}
45
}
46
return 0;
47
}
运行输出:
1
Caught Network Error: Connection timed out.
2
Caught File Error: File not found.
3
Caught Runtime Error (or derived): Index out of bounds.
4
Caught C-style string error: Just a string literal.
5
Processing successful.
注意 std::out_of_range
继承自 std::runtime_error
,所以它被 catch (const std::runtime_error& e)
块捕获。
10.2.3 异常传播与栈回溯 (Stack Unwinding)
当一个异常被抛出但当前函数没有匹配的 catch
块来处理它时,异常会沿着函数调用栈向上搜索。这个过程称为栈回溯 (Stack Unwinding)。
在栈回溯过程中,位于抛出异常点与 catch
块之间的所有局部对象(包括函数参数、局部变量)的析构函数 (Destructor) 都会被依次调用,以确保资源的释放。这是 C++ 异常处理的一项重要特性,它使得 RAII (Resource Acquisition Is Initialization) 原则能够有效地工作(详见 10.4 节)。
如果栈回溯到 main
函数,仍然没有找到能够处理该异常的 catch
块,程序将会终止 (terminate),通常会调用 std::terminate()
函数。默认情况下,std::terminate()
会调用 std::abort()
终止程序,可能还会执行一些清理操作。
10.2.4 异常规格 (Exception Specification) 与 noexcept
在 C++11 之前,C++ 引入了异常规格 (Exception Specification),允许函数声明其可能抛出的异常类型,例如 void func() throw(A, B);
表示 func
可能抛出 A
或 B
类型的异常。throw()
或 noexcept(true)
表示不抛出任何异常。
然而,异常规格的使用存在一些问题,例如运行时检查开销、继承关系复杂性以及与模板的交互困难,因此在 C++11 中被弃用 (deprecated),在 C++17 中被移除(除了 throw()
作为 noexcept(true)
的同义词)。
取而代之的是 noexcept
关键字,它在 C++11 中引入,并在后续版本中得到加强。noexcept
主要用于向编译器提供一个承诺:函数不会抛出异常。
⚝ void func() noexcept;
:表示 func
函数承诺不会抛出任何异常。
⚝ void func() noexcept(constant_expression);
:如果 constant_expression
为 true
,则函数承诺不抛出异常;如果为 false
,则函数可能抛出异常(与不写 noexcept
类似,但提供了运行时检查的可能性)。
如果一个声明了 noexcept(true)
的函数在运行时抛出了异常,程序会直接调用 std::terminate()
终止,而不会进行栈回溯。这通常比未捕获的异常导致的栈回溯终止更快,并且可以在编译时进行更多的优化。
使用 noexcept 的场景:
⚝ 移动构造函数 (Move Constructor) 和移动赋值运算符 (Move Assignment Operator):如果它们是 noexcept
的,标准库容器 (如 std::vector
) 在执行某些操作(如扩容)时,会优先选择移动语义而非复制语义,从而提高性能。
⚝ 析构函数 (Destructor):析构函数通常应该设计成 noexcept
的,因为在栈回溯过程中,如果析构函数抛出异常且已有其他异常处于活动状态,将导致程序终止。```cpp
// 示例:移动构造函数和移动赋值运算符使用 noexcept
include
include
include // for std::move
class MyData {
int* data;
size_t size;
public:
MyData(size_t s) : size(s), data(new int[s]) {
std::cout << "Constructor(" << size << ")" << std::endl;
}
~MyData() {
std::cout << "Destructor(" << size << ")" << std::endl;
delete[] data;
}
// 拷贝构造函数 (Copy Constructor)
MyData(const MyData& other) : size(other.size), data(new int[other.size]) {
std::cout << "Copy Constructor(" << size << ")" << std::endl;
std::copy(other.data, other.data + size, data);
}
// 拷贝赋值运算符 (Copy Assignment Operator)
MyData& operator=(const MyData& other) {
std::cout << "Copy Assignment(" << size << " <- " << other.size << ")" << std::endl;
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}
// 移动构造函数 (Move Constructor) - 注意 noexcept
MyData(MyData&& other) noexcept : size(other.size), data(other.data) {
std::cout << "Move Constructor(" << size << ")" << std::endl;
other.data = nullptr; // 防止 other 的析构函数释放资源
other.size = 0;
}
// 移动赋值运算符 (Move Assignment Operator) - 注意 noexcept
MyData& operator=(MyData&& other) noexcept {
std::cout << "Move Assignment(" << size << " <- " << other.size << ")" << std::endl;
if (this != &other) {
delete[] data; // 释放当前资源
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
// 为了演示 noexcept 抛异常的行为 (慎用!)
void potentially_throw() noexcept(false) {
std::cout << "potentially_throw()" << std::endl;
//throw std::runtime_error("Thrown from noexcept(false) function"); // 正常抛出
}
void guaranteed_no_throw() noexcept {
std::cout << "guaranteed_no_throw()" << std::endl;
// throw std::runtime_error("Thrown from noexcept(true) function"); // 会导致 terminate()
}
};
int main() {
// 演示移动语义对 vector 扩容的影响
std::vector
v.reserve(2); // 预留容量 2
std::cout << "\n--- Adding elements ---" << std::endl;
v.push_back(MyData(10)); // 调用构造函数
v.push_back(MyData(20)); // 调用构造函数
std::cout << "\n--- Triggering realloc ---" << std::endl;
// 当 vector 需要扩容时,如果 MyData 的移动构造函数是 noexcept,
// vector 会优先调用移动构造函数来迁移元素,而不是拷贝构造函数。
// 如果不是 noexcept (或者没有提供移动构造函数/赋值运算符),会调用拷贝构造函数。
v.push_back(MyData(30)); // 容量不足,vector 扩容,旧元素会移动或拷贝到新空间
std::cout << "\n--- End of scope ---" << std::endl; // 元素析构函数会在 vector 销毁时调用
std::cout << "\n--- noexcept behavior demo ---" << std::endl;
MyData d(1);
d.potentially_throw(); // 可以正常调用并抛异常(如果 uncomment throw 语句)
try {
d.guaranteed_no_throw();
// 如果 uncomment d.guaranteed_no_throw() 里面的 throw,
// 则会直接调用 terminate(),不会走到下面的 catch
} catch (const std::exception& e) {
std::cerr << "Caught exception from guaranteed_no_throw: " << e.what() << std::endl;
// 通常不会执行到这里
}
return 0;
} // d 的析构函数在此处调用
1
在这个例子中,因为 `MyData` 的移动构造函数和移动赋值运算符被标记为 `noexcept`,当 `std::vector` 扩容时,它能够安全地使用移动操作来迁移已有的 `MyData` 对象,避免了潜在的拷贝开销和异常风险(如果拷贝操作可能抛异常)。
2
3
### 10.3 异常与构造函数/析构函数
4
5
构造函数 (Constructor) 和析构函数 (Destructor) 在异常处理中扮演着特殊且重要的角色。
6
7
#### 10.3.1 异常与构造函数
8
9
构造函数负责对象的初始化,可能涉及资源的获取(如分配内存、打开文件、建立网络连接等)。这些资源获取操作本身就可能失败并抛出异常(例如 `new` 抛出 `std::bad_alloc`)。
10
11
如果在对象的构造过程中抛出了异常,会发生什么?
12
13
* **部分构造**: 如果构造函数在为所有成员变量完全初始化之前抛出了异常,那么该对象被认为是**未成功构造**的。
14
* **已成功构造的成员**: 在构造函数内部,**已经成功初始化**的成员变量(包括基类和成员对象)的析构函数会按照它们构造顺序的逆序被调用。
15
* **未成功构造的成员**: 尚未初始化成功的成员变量或尚未开始初始化的成员变量不会被调用析构函数。
16
* **对象本身**: 由于整个对象没有成功构造,**该对象本身的析构函数不会被调用**。
17
18
这意味着如果在构造函数中通过原始指针 `new` 了内存或其他资源,并且在 `delete` 它之前抛出了异常,这些资源将发生泄漏!
19
20
**示例**: 构造函数中的资源泄漏风险
21
22
```cpp
23
24
#include <iostream>
25
#include <stdexcept>
26
27
class RiskyResource {
28
int* data;
29
public:
30
RiskyResource(size_t size) : data(nullptr) { // 先初始化为 nullptr
31
std::cout << "RiskyResource Constructor(" << size << ")" << std::endl;
32
data = new int[size]; // 步骤 1: 分配内存 (可能抛 bad_alloc)
33
34
// 模拟另一个可能抛异常的操作
35
if (size > 1000) {
36
//throw std::runtime_error("Size too large!"); // 步骤 2: 模拟抛异常
37
}
38
39
// 如果在步骤 2 抛异常,步骤 1 分配的 data 内存将丢失,因为析构函数不会被调用!
40
std::cout << "RiskyResource Constructed successfully." << std::endl;
41
}
42
43
~RiskyResource() {
44
std::cout << "RiskyResource Destructor" << std::endl;
45
delete[] data; // 释放内存
46
}
47
};
48
49
int main() {
50
try {
51
//RiskyResource r1(500); // 正常构造
52
RiskyResource r2(2000); // 构造函数可能抛异常 (如果 uncomment throw)
53
} catch (const std::exception& e) {
54
std::cerr << "Caught exception: " << e.what() << std::endl;
55
}
56
// r1 或 r2 的析构函数会在离开 try 块或 main 函数作用域时调用,
57
// 但如果构造失败,对象的析构函数根本不会被调用。
58
return 0;
59
}
解决构造函数中资源泄漏问题的方法是使用 RAII (Resource Acquisition Is Initialization) 原则,例如使用智能指针 (Smart Pointer) 或其他 RAII 类来管理资源(详见 10.4 节)。智能指针在其自身的构造函数中获取资源(如分配内存),并在其析构函数中释放资源。即使包含智能指针的对象的构造函数抛出异常,智能指针成员的析构函数仍然会被调用,从而安全地释放其管理的资源。
10.3.2 异常与析构函数
与构造函数不同,析构函数通常不应该抛出异常。这是 C++ 异常处理中的一个重要规则。
原因如下:
- 栈回溯中的析构函数: 异常处理的一个关键部分是栈回溯。在栈回溯过程中,会调用处于栈上的局部对象的析构函数。
- 双重异常 (Double Exception): 假设在栈回溯过程中,某个析构函数被调用,而这个析构函数自身又抛出了另一个异常。此时,系统中存在两个同时处于活动状态的异常。C++ 标准规定,在这种情况下,程序必须调用
std::terminate()
立即终止。这会导致程序非正常退出,且栈回溯被打断,可能导致更多资源泄漏。
什么时候可以从析构函数抛出异常?
理论上,如果析构函数抛出的异常在同一个析构函数内部就被 catch
块完全捕获并处理掉,那么它是“安全的”,因为它没有将异常传播出去。
然而,将可能抛异常的代码放在析构函数中,并在内部捕获处理,通常不是好的设计。更好的做法是:
- 在析构函数之前,提供一个显式的
close()
或release()
方法,让用户在对象生命周期结束前手动释放资源。如果这个方法可能失败并抛异常,用户可以在调用它时使用try-catch
。 - 如果资源释放操作失败是致命的,且没有合理的恢复策略,那么在析构函数中打印错误日志并调用
std::abort()
可能比抛出异常更合适。 - 最佳实践: 遵循 RAII 原则,使用不会抛异常的析构函数来管理资源。资源的“释放失败”通常表示系统处于非常糟糕的状态,此时抛异常可能无济于事。
C++11 及以后版本中的 noexcept 与析构函数:
在 C++11 及更高版本中,编译器会默认将析构函数声明为 noexcept(true)
,除非基类或成员的析构函数是 noexcept(false)
。这进一步强化了析构函数不应抛出异常的约定。如果一个默认 noexcept(true)
的析构函数内部实际抛出了异常,会导致程序直接调用 std::terminate()
。
总结: 永远不要让异常从析构函数中逃逸 (escape)。将析构函数设计为 noexcept
是保证异常安全的重要一步。
10.4 异常安全与 RAII
异常安全 (Exception Safety) 是指程序在发生异常时,能够保持在一个有效的、可预测的状态,并且不会发生资源泄漏。异常安全是构建健壮 C++ 程序的关键要素之一。
根据异常发生时程序状态的保证程度,通常将异常安全分为几个级别:
① 无异常安全 (No Exception Safety): 程序在发生异常后可能进入不确定状态,可能导致资源泄漏或数据损坏。
② 基本异常安全 (Basic Exception Safety): 如果操作抛出异常,程序的所有不变量 (invariants) 都保持不变,不会发生资源泄漏。但程序的状态可能不是抛出异常前的原始状态(例如,容器操作失败后,容器可能被清空)。
③ 强异常安全 (Strong Exception Safety): 如果操作抛出异常,程序的状态回滚到操作开始之前的状态,就像这个操作从未发生过一样(事务语义)。如果没有抛出异常,操作则保证完成。
④ 无抛出保证 (No-Throw Guarantee) / 永不抛出 (Nothrow Safety): 函数承诺永远不会抛出异常。如果实际抛出,程序会立即调用 std::terminate()
。这是最强的保证。
构建异常安全的 C++ 代码,特别是实现基本和强异常安全,很大程度上依赖于 RAII (Resource Acquisition Is Initialization) 原则。
10.4.1 RAII 原则与异常安全
RAII (Resource Acquisition Is Initialization) 原则的核心思想是将资源的生命周期与对象的生命周期绑定。资源在对象的构造函数中获得 (Acquisition),并在对象的析构函数中释放 (Release)。
\[ \text{资源获取 (Acquisition)} \rightarrow \text{构造函数 (Initialization)} \]
\[ \text{资源释放 (Release)} \leftarrow \text{析构函数 (Destructor)} \]
由于 C++ 保证无论函数是正常返回还是通过异常退出,栈上对象的析构函数都会被调用(栈回溯机制),因此,只要将资源包装在一个拥有自动管理生命周期的对象中,就可以确保资源在任何情况下都能被正确释放,从而防止资源泄漏。
RAII 如何实现异常安全:
假设一个函数执行一系列操作,其中某些操作可能抛出异常。如果在这些操作之间获取了资源(如分配了堆内存、获取了文件句柄、锁定了互斥量),并且使用了 RAII 对象来管理这些资源,那么即使函数在中间步骤抛出了异常,栈回溯机制会保证 RAII 对象的析构函数被调用,从而安全地释放资源。
示例: 使用 RAII 管理文件句柄 (C-style FILE*)
1
#include <iostream>
2
#include <cstdio>
3
#include <stdexcept>
4
#include <string>
5
6
// 自定义 FILE* 的 RAII 包装类
7
class FileHandle {
8
FILE* file_ptr;
9
public:
10
FileHandle(const char* filename, const char* mode) : file_ptr(nullptr) {
11
std::cout << "Attempting to open file: " << filename << std::endl;
12
file_ptr = std::fopen(filename, mode);
13
if (!file_ptr) {
14
throw std::runtime_error(std::string("Failed to open file: ") + filename);
15
}
16
std::cout << "File opened successfully." << std::endl;
17
}
18
19
// 析构函数保证文件关闭,即使发生异常
20
~FileHandle() {
21
if (file_ptr) {
22
std::cout << "Closing file handle." << std::endl;
23
std::fclose(file_ptr);
24
file_ptr = nullptr; // Good practice
25
}
26
}
27
28
// 禁止拷贝和赋值,因为 FILE* 是独占资源
29
FileHandle(const FileHandle&) = delete;
30
FileHandle& operator=(const FileHandle&) = delete;
31
32
// 提供移动语义
33
FileHandle(FileHandle&& other) noexcept : file_ptr(other.file_ptr) {
34
other.file_ptr = nullptr;
35
}
36
FileHandle& operator=(FileHandle&& other) noexcept {
37
if (this != &other) {
38
if (file_ptr) { // 释放当前资源
39
std::fclose(file_ptr);
40
}
41
file_ptr = other.file_ptr;
42
other.file_ptr = nullptr;
43
}
44
return *this;
45
}
46
47
48
// 获取 FILE* 指针 (可选,谨慎使用)
49
FILE* get() const { return file_ptr; }
50
51
// 模拟一个可能抛异常的操作
52
void write_data(const char* data) {
53
if (!file_ptr) {
54
throw std::runtime_error("Attempted to write to invalid file handle.");
55
}
56
std::cout << "Writing data..." << std::endl;
57
// 模拟写操作,可能因磁盘满等原因失败,但这里仅为演示
58
if (std::fputs(data, file_ptr) == EOF) {
59
//throw std::runtime_error("Failed to write data to file.");
60
}
61
// 模拟另一个可能抛异常的操作
62
// throw std::runtime_error("Simulated error after writing!");
63
}
64
};
65
66
void process_file(const char* filename) {
67
FileHandle file(filename, "w"); // 文件打开,资源获取 (RAII)
68
file.write_data("Hello, Exception Safety!");
69
// 如果 write_data 抛异常,file 对象的析构函数仍会被调用,文件会被关闭。
70
// 如果没有异常,函数正常返回,file 对象的析构函数在函数退出时调用,文件关闭。
71
std::cout << "File processing finished successfully." << std::endl;
72
}
73
74
int main() {
75
// 创建一个不存在的目录,以便文件打开失败
76
// system("mkdir non_existent_dir"); // This might not work on all systems
77
// const char* test_file = "non_existent_dir/output.txt"; // This will likely fail
78
79
const char* test_file = "output.txt"; // Should succeed
80
81
try {
82
// 情况 1: 文件打开失败
83
// process_file(test_file); // 改成一个打不开的文件名测试
84
85
// 情况 2: 文件打开成功,写入过程中抛异常
86
process_file(test_file); // 在 write_data 中 uncomment 模拟抛异常的代码测试
87
88
} catch (const std::exception& e) {
89
std::cerr << "Caught exception in main: " << e.what() << std::endl;
90
}
91
92
// 无论是否发生异常,FileHandle 的析构函数都会被调用,文件资源得到释放。
93
std::cout << "Program finished." << std::endl;
94
95
return 0;
96
}
在这个例子中,FileHandle
类是一个简单的 RAII 包装。它在构造函数中打开文件(资源获取),在析构函数中关闭文件(资源释放)。如果在 process_file
函数中,无论是 FileHandle
的构造函数抛出了异常(文件打不开),还是后续的 write_data
方法抛出了异常(如果在其中模拟了异常),栈回溯会启动。在栈回溯过程中,process_file
函数中创建的 file
局部对象会被销毁,其析构函数会被调用,从而保证文件句柄被正确关闭,避免了资源泄漏。
10.4.2 智能指针与 RAII
C++ 标准库提供的智能指针 (如 std::unique_ptr
、std::shared_ptr
、std::weak_ptr
) 是 RAII 原则的典型应用,用于管理动态分配的内存。它们确保在指针失效或对象生命周期结束时,所指向的内存能够被自动释放。
std::unique_ptr
: 实现独占所有权。当unique_ptr
对象被销毁时,它所管理的内存会被释放。这提供了强异常安全,因为内存的管理与对象生命周期精确绑定。std::shared_ptr
: 实现共享所有权,通过引用计数 (Reference Counting) 机制管理内存。当最后一个shared_ptr
对象被销毁时,内存才会被释放。虽然引入了引用计数的开销,但同样提供了基本的异常安全保证。std::weak_ptr
: 配合shared_ptr
使用,解决循环引用问题,不参与引用计数。
示例: 使用智能指针防止内存泄漏
1
#include <iostream>
2
#include <memory> // for smart pointers
3
#include <vector>
4
5
class MyObject {
6
int id;
7
public:
8
MyObject(int i) : id(i) { std::cout << "MyObject(" << id << ") constructed." << std::endl; }
9
~MyObject() { std::cout << "MyObject(" << id << ") destructed." << std::endl; }
10
void do_something() const {
11
std::cout << "MyObject(" << id << ") doing something." << std::endl;
12
// 模拟一个可能抛异常的操作
13
//if (id == 5) {
14
// throw std::runtime_error("Error from MyObject 5!");
15
//}
16
}
17
};
18
19
void process_objects() {
20
// 使用 unique_ptr 管理动态分配的对象
21
auto obj1 = std::make_unique<MyObject>(1); // 资源获取
22
23
std::vector<std::unique_ptr<MyObject>> objects;
24
objects.push_back(std::make_unique<MyObject>(2));
25
objects.push_back(std::make_unique<MyObject>(3));
26
27
// 模拟循环,其中一个操作可能抛异常
28
for (int i = 0; i < 10; ++i) {
29
auto current_obj = std::make_unique<MyObject>(i + 4); // 循环内创建对象 (资源获取)
30
current_obj->do_something(); // 可能抛异常
31
// 如果 do_something 抛异常,current_obj 会在循环迭代结束时 (或异常发生时) 销毁,
32
// 其析构函数会被调用,管理的内存被释放。
33
// 如果没有异常,current_obj 在循环迭代结束时销毁,内存同样被释放。
34
// 不会将 current_obj 添加到 vector,因为它在循环内被销毁。
35
// 如果要添加到 vector,需要 push_back(std::move(current_obj));
36
}
37
38
obj1->do_something(); // 可能抛异常
39
40
// 如果上面的任何操作抛异常,obj1 和 objects 向量中已经存在的 MyObject 对象
41
// (如果添加到向量的话) 都会在 process_objects 函数栈回溯时被销毁,
42
// 它们管理的内存会被释放。不会发生内存泄漏。
43
44
std::cout << "Processing objects finished successfully." << std::endl;
45
}
46
47
int main() {
48
try {
49
process_objects();
50
} catch (const std::exception& e) {
51
std::cerr << "Caught exception in main: " << e.what() << std::endl;
52
}
53
std::cout << "Program finished." << std::endl;
54
return 0;
55
}
11. 深入探索 C++ OOP 的高级话题
本章将带领大家进一步深入 C++ 的面向对象世界,探讨一些相对高级且复杂的概念,包括运行时类型信息(RTTI)、不同的类型转换操作符、标准库中 OOP 思想的应用,以及面向对象设计的基本原则。通过本章的学习,读者将能更全面、深入地理解 C++ 的强大之处,并能更好地设计和实现复杂的软件系统。
11.1 RTTI (Runtime Type Information) 与动态类型转换
在 C++ 的多态(Polymorphism)机制中,我们通常通过基类(Base Class)指针或引用来操作派生类(Derived Class)对象。然而,在某些特定场景下,我们可能需要在程序运行时确定对象的实际类型,或者安全地将基类指针/引用转换为派生类指针/引用。这时,运行时类型信息(Runtime Type Information, RTTI)就派上了用场。
RTTI 是 C++ 提供的一种机制,允许程序在运行时获取对象的信息。它主要通过两个操作符实现:typeid
和 dynamic_cast
。需要注意的是,RTTI 是可选特性,编译器可以通过编译选项关闭它(例如 -fno-rtti
在 GCC/Clang 中),关闭 RTTI 会禁用 typeid
(对多态类型)和 dynamic_cast
的功能。
11.1.1 typeid
运算符
typeid
运算符用于获取表达式的类型信息。它返回一个 std::type_info
对象的引用,这个对象包含有关类型的信息,例如类型的名称。
① 基本用法:
typeid(expression)
或 typeid(type-name)
② 返回类型:
返回 const std::type_info&
。
③ std::type_info
对象:
这个类(定义在 <typeinfo>
头文件)提供了一些方法:
▮▮▮▮ⓐ name()
:返回一个表示类型名称的 C 风格字符串(其具体内容和格式取决于编译器实现)。
▮▮▮▮ⓑ before(const type_info& rhs)
:判断当前类型是否在某种排序下位于 rhs
之前。
▮▮▮▮ⓒ operator==
和 operator!=
:比较两个 type_info
对象是否代表同一类型。
④ 对多态类型(Polymorphic Type)的应用:
当 typeid
的操作数是一个基类指针或引用,并且该基类是多态的(即包含至少一个虚函数(Virtual Function)),typeid
将返回对象的实际派生类型的 type_info
。如果操作数是非多态类型,typeid
将返回操作数的静态类型的 type_info
。
▮▮▮▮ⓐ 示例:
1
#include <iostream>
2
#include <typeinfo>
3
4
class Base {
5
public:
6
virtual ~Base() {} // 使 Base 成为多态类型
7
};
8
9
class Derived : public Base {};
10
11
class AnotherClass {};
12
13
int main() {
14
Base* b_ptr = new Derived();
15
Derived* d_ptr = new Derived();
16
Base* base_ptr = new Base();
17
AnotherClass obj;
18
19
std::cout << "b_ptr points to: " << typeid(*b_ptr).name() << std::endl; // 实际类型 Derived
20
std::cout << "d_ptr points to: " << typeid(*d_ptr).name() << std::endl; // 实际类型 Derived
21
std::cout << "base_ptr points to: " << typeid(*base_ptr).name() << std::endl; // 实际类型 Base
22
std::cout << "Variable obj is: " << typeid(obj).name() << std::endl; // 静态类型 AnotherClass
23
std::cout << "Type of Base class: " << typeid(Base).name() << std::endl; // 类型 Base
24
25
delete b_ptr;
26
delete d_ptr;
27
delete base_ptr;
28
29
return 0;
30
}
▮▮▮▮ⓑ 注意事项:
▮▮▮▮▮▮▮▮❷ 对空指针使用 typeid(*ptr)
会抛出 std::bad_typeid
异常。使用 typeid(ptr)
则返回指针本身的类型信息,不会抛出异常。
▮▮▮▮▮▮▮▮❸ typeid
操作符有一定的运行时开销。
11.1.2 dynamic_cast
运算符
dynamic_cast
是 C++ 中进行类型转换(Type Casting)最安全的手段之一,主要用于在具有多态性的类层次结构中,安全地进行向下转换(Downcasting,从基类指针/引用转换为派生类指针/引用)或交叉转换(Crosscasting)。
① 基本用法:
dynamic_cast<TargetType>(expression)
② 要求:
dynamic_cast
的操作数(expression
)必须是多态类型的指针或引用。TargetType
必须是指针或引用类型。
③ 转换规则与结果:
▮▮▮▮ⓑ 指针转换:
▮▮▮▮▮▮▮▮❸ 如果 expression
是一个基类指针,并且实际指向的对象类型是 TargetType
或从 TargetType
公有继承的类型,则转换成功,返回一个指向实际对象的 TargetType
指针。
▮▮▮▮▮▮▮▮❹ 如果转换不可能(例如,expression
指向的对象实际类型与 TargetType
无关,或者不是 TargetType
或其派生类),则转换失败,返回 nullptr
。
▮▮▮▮ⓔ 引用转换:
▮▮▮▮▮▮▮▮❻ 如果 expression
是一个基类引用,并且实际指向的对象类型是 TargetType
或从 TargetType
公有继承的类型,则转换成功,返回一个 TargetType
引用。
▮▮▮▮▮▮▮▮❼ 如果转换失败(与指针转换类似),则抛出 std::bad_cast
异常(定义在 <typeinfo>
头文件)。
④ 示例:
1
#include <iostream>
2
#include <typeinfo> // For std::bad_cast
3
4
class Base {
5
public:
6
virtual ~Base() {}
7
void baseMethod() { std::cout << "Base method" << std::endl; }
8
};
9
10
class Derived : public Base {
11
public:
12
void derivedMethod() { std::cout << "Derived method" << std::endl; }
13
};
14
15
class AnotherDerived : public Base {
16
public:
17
void anotherDerivedMethod() { std::cout << "Another Derived method" << std::endl; }
18
};
19
20
int main() {
21
Base* b1 = new Derived();
22
Base* b2 = new Base();
23
Base* b3 = new AnotherDerived();
24
25
// 向下转换 (Downcasting) - 指针
26
Derived* d_ptr1 = dynamic_cast<Derived*>(b1); // 成功
27
if (d_ptr1) {
28
std::cout << "b1 successfully cast to Derived*" << std::endl;
29
d_ptr1->derivedMethod();
30
}
31
32
Derived* d_ptr2 = dynamic_cast<Derived*>(b2); // 失败
33
if (!d_ptr2) {
34
std::cout << "b2 failed to cast to Derived* (as expected)" << std::endl;
35
}
36
37
Derived* d_ptr3 = dynamic_cast<Derived*>(b3); // 失败
38
if (!d_ptr3) {
39
std::cout << "b3 failed to cast to Derived* (as expected)" << std::endl;
40
}
41
42
// 向下转换 (Downcasting) - 引用
43
try {
44
Derived& d_ref1 = dynamic_cast<Derived&>(*b1); // 成功
45
std::cout << "*b1 successfully cast to Derived&" << std::endl;
46
d_ref1.derivedMethod();
47
} catch (const std::bad_cast& e) {
48
std::cerr << "Cast failed: " << e.what() << std::endl;
49
}
50
51
try {
52
// 这会抛出 std::bad_cast 异常
53
Derived& d_ref2 = dynamic_cast<Derived&>(*b2);
54
std::cout << "*b2 successfully cast to Derived&" << std::endl;
55
d_ref2.derivedMethod();
56
} catch (const std::bad_cast& e) {
57
std::cerr << "*b2 failed to cast to Derived& (as expected): " << e.what() << std::endl;
58
}
59
60
delete b1;
61
delete b2;
62
delete b3;
63
64
return 0;
65
}
⑤ 使用场景与建议:
⚝ dynamic_cast
主要用于你确实需要在运行时根据对象的实际类型执行特定操作的场景。
⚝ 频繁使用 dynamic_cast
进行向下转换通常被认为是设计上的“代码异味”(Code Smell),可能表明你的设计没有充分利用多态性。考虑是否可以通过在基类中定义虚函数,并在派生类中重写来实现相同的行为,从而避免运行时类型检查。
⚝ 相比于 C 风格类型转换或 static_cast
进行不安全的向下转换,dynamic_cast
提供了安全性检查,是更推荐的方式(如果必须进行运行时向下转换的话)。
⚝ dynamic_cast
也有运行时开销,因为它需要查询对象的 RTTI。
11.2 Casting 操作符:static_cast, const_cast, reinterpret_cast
除了 dynamic_cast
,C++ 还提供了其他三种类型转换操作符:static_cast
、const_cast
和 reinterpret_cast
。它们各自有特定的用途和限制,使用它们可以使类型转换的意图更加明确,也比 C 风格的强制类型转换(C-style Cast)更安全(除了 reinterpret_cast
)。理解它们的区别对于编写清晰、安全、可维护的 C++ 代码至关重要。
11.2.1 static_cast
static_cast
用于执行各种隐式转换(Implicit Conversion),以及一些不涉及运行时类型信息检查的显式转换。它的名字“static”来源于其转换是在编译时(Compile Time)确定的。
① 常见用途:
▮▮▮▮ⓑ 数值类型之间的转换(如 int
到 double
,float
到 int
)。
▮▮▮▮ⓒ 指针或引用在相关类层次结构中的上下转换(Upcasting/Downcasting),但不进行运行时检查。向上转换(派生类到基类)总是安全的,向下转换(基类到派生类)不检查实际类型,可能是不安全的,除非你确定对象的实际类型。
▮▮▮▮ⓓ void*
到任意类型指针的转换(反之亦然)。
▮▮▮▮ⓔ 枚举类型(Enumeration Type)到整型(Integer Type)的转换,以及整型到枚举类型的转换。
▮▮▮▮ⓕ 任何类型到 void
的转换。
▮▮▮▮⚝ 示例:
1
#include <iostream>
2
3
class Base {
4
public:
5
void baseMethod() { std::cout << "Base method" << std::endl; }
6
};
7
8
class Derived : public Base {
9
public:
10
void derivedMethod() { std::cout << "Derived method" << std::endl; }
11
};
12
13
int main() {
14
// 数值转换
15
double pi = 3.14159;
16
int approximate_pi = static_cast<int>(pi); // double -> int
17
std::cout << "Approximation of pi: " << approximate_pi << std::endl;
18
19
// 向上转换 (安全)
20
Derived d;
21
Base* b_ptr = static_cast<Base*>(&d); // Derived* -> Base*
22
b_ptr->baseMethod();
23
24
// 向下转换 (不安全 - 无运行时检查)
25
Base* b2 = new Derived(); // 实际是 Derived
26
Derived* d_ptr1 = static_cast<Derived*>(b2); // Base* -> Derived*
27
if (d_ptr1) { // 注意:这里不会检查是否成功,d_ptr1 不会是 nullptr
28
std::cout << "Successfully cast b2 to Derived* (statically)" << std::endl;
29
d_ptr1->derivedMethod(); // 正确调用
30
}
31
32
Base* b3 = new Base(); // 实际是 Base
33
Derived* d_ptr2 = static_cast<Derived*>(b3); // Base* -> Derived*
34
if (d_ptr2) {
35
std::cout << "Successfully cast b3 to Derived* (statically)" << std::endl;
36
// WARNING: 调用 d_ptr2->derivedMethod() 将导致未定义行为 (Undefined Behavior),因为 b3 实际不是 Derived 对象
37
// d_ptr2->derivedMethod(); // 不要这样做!
38
}
39
40
// void* 转换
41
void* v_ptr = &d;
42
Derived* d_ptr3 = static_cast<Derived*>(v_ptr); // void* -> Derived*
43
d_ptr3->derivedMethod();
44
45
delete b2;
46
delete b3; // 注意:虽然 static_cast 了,但仍需删除原始指针 b3
47
return 0;
48
}
② 与 C 风格转换的对比:
static_cast
相比 (Type)expression
更安全,因为它只允许编译器认为“合理”的转换。例如,你不能使用 static_cast
将一个不相关的类指针转换为另一个不相关的类指针,但 C 风格转换可能允许。
11.2.2 const_cast
const_cast
的唯一用途是改变表达式的 const
或 volatile
属性。
① 常见用途:
▮▮▮▮ⓑ 移除 const
修饰符(解除常量性)。
▮▮▮▮ⓒ 添加 const
或 volatile
修饰符。
▮▮▮▮⚝ 示例:
1
#include <iostream>
2
3
void print_string(const char* s) {
4
std::cout << s << std::endl;
5
}
6
7
void modify_string(char* s) {
8
if (s) {
9
s[0] = 'X'; // 修改字符串
10
}
11
}
12
13
int main() {
14
const char* constant_string = "Hello";
15
// print_string(constant_string); // OK
16
17
// modify_string(constant_string); // 错误:不能将 const char* 隐式转换为 char*
18
19
// 使用 const_cast 移除 const
20
char* modifiable_string = const_cast<char*>(constant_string);
21
// WARNING: 如果 constant_string 指向的是一个实际的常量(如字符串字面量),
22
// 通过 modifiable_string 修改它会导致未定义行为!
23
// 只有当原始对象不是 const,但通过 const 指针/引用访问时,const_cast 并修改才是安全的。
24
25
char mutable_array[] = "World";
26
const char* const_ptr = mutable_array; // const 指向可变对象
27
28
char* safe_to_modify = const_cast<char*>(const_ptr);
29
modify_string(safe_to_modify); // OK,因为 mutable_array 是可变的
30
std::cout << "Modified array: " << mutable_array << std::endl; // 输出 Xorld
31
32
return 0;
33
}
② 注意事项:
⚝ const_cast
是唯一能够移除 const
或 volatile
属性的 C++ 类型转换操作符。
⚝ 通过 const_cast
获取非 const
指针或引用,然后修改一个最初就被声明为 const
的对象,会导致未定义行为。只有当对象本身是可变的(mutable),但你通过 const
引用或指针访问它时,使用 const_cast
移除 const
并进行修改才是合法的。
⚝ 谨慎使用 const_cast
,它经常是违反常量正确性(Const Correctness)的信号。一个常见的合法用例是调用一个接受非 const
指针/引用的旧式 C API,而你知道该 API 实际上不会修改传递的对象。
11.2.3 reinterpret_cast
reinterpret_cast
执行最低级的类型转换,它仅仅是对表达式的位模式(Bit Pattern)进行重新解释,将其看作是另一种类型的对象。它不进行任何安全检查或类型调整。
① 常见用途:
▮▮▮▮ⓑ 将一个指针类型转换为另一个不相关的指针类型。
▮▮▮▮ⓒ 将一个整型转换为指针类型,或将一个指针类型转换为整型。
▮▮▮▮ⓓ 将一个函数指针转换为另一个函数指针类型。
▮▮▮▮⚝ 示例:
1
#include <iostream>
2
3
int main() {
4
int a = 100;
5
int* ptr_a = &a;
6
7
// int* 转换为 char*
8
char* ptr_char = reinterpret_cast<char*>(ptr_a);
9
// 现在 ptr_char 指向 int 的第一个字节
10
11
std::cout << "Value of a: " << a << std::endl;
12
std::cout << "Value pointed by ptr_a: " << *ptr_a << std::endl;
13
std::cout << "First byte of a (via ptr_char): " << static_cast<int>(*ptr_char) << std::endl; // 将 char 转换为 int 打印
14
15
// 指针转换为整型 ( uintptr_t 是一个足够大的无符号整型,可以容纳任何对象指针 )
16
uintptr_t int_from_ptr = reinterpret_cast<uintptr_t>(ptr_a);
17
std::cout << "Address of a (as integer): " << int_from_ptr << std::endl;
18
19
// 整型转换回指针
20
int* ptr_back = reinterpret_cast<int*>(int_from_ptr);
21
std::cout << "Value pointed by ptr_back: " << *ptr_back << std::endl; // 应该和 *ptr_a 一样
22
23
return 0;
24
}
② 注意事项:
⚝ reinterpret_cast
是最危险的类型转换。它不提供任何移植性保证,结果很大程度上取决于具体的硬件和编译器实现。
⚝ 除了将指针转换为 uintptr_t
或 intptr_t
然后再转回原类型指针(这通常是安全的往返转换),其他很多 reinterpret_cast
的使用都可能导致未定义行为。
⚝ 它主要用于低级编程(如硬件交互、某些特定的系统编程任务)或需要绕过 C++ 类型系统的极端情况。
⚝ 尽量避免使用 reinterpret_cast
,除非你完全理解其风险并且没有其他替代方案。
11.2.4 C 风格类型转换 (Type)expression
C 风格的类型转换 (Type)expression
尝试依次使用 const_cast
、static_cast
和 reinterpret_cast
来进行转换。它的缺点在于意图不明确,且可能隐藏危险的转换(如执行一个 reinterpret_cast
但使用者以为是 static_cast
)。
① 转换尝试顺序:
▮▮▮▮ⓑ 如果目标类型可以通过 static_cast
从源类型获得,则尝试 static_cast
。
▮▮▮▮ⓒ 否则,如果目标类型是基类,源类型是派生类的引用,则尝试向上转换。
▮▮▮▮ⓓ 否则,如果目标类型或源类型是指针或引用,则尝试 const_cast
后再尝试 static_cast
。
▮▮▮▮ⓔ 否则,尝试 reinterpret_cast
。
② 建议:
在 C++ 代码中,强烈建议优先使用 C++ 风格的类型转换操作符(static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
),而不是 C 风格转换。这能让代码意图更清晰,并且类型系统能提供更多帮助进行检查。
11.3 C++ 标准库中的 OOP 应用示例
C++ 标准库(Standard Library),特别是标准模板库(Standard Template Library, STL),虽然广泛使用了模板(Template)和泛型编程(Generic Programming),但也包含了许多面向对象编程(OOP)思想的应用。理解这些应用有助于我们更好地使用标准库,并学习如何将 OOP 和泛型编程结合起来。
11.3.1 输入/输出流库 (iostream)
输入/输出流库是 C++ 标准库中最经典的 OOP 应用之一。它构建了一个清晰的类层次结构,利用继承和多态来实现不同类型流(如文件流、字符串流、标准流)的统一操作接口。
① 类层次结构:
▮▮▮▮⚝ 基类:std::ios_base
(管理格式化信息),std::basic_ios
(管理缓冲区和状态)。
▮▮▮▮⚝ 派生类:
▮▮▮▮▮▮▮▮❶ std::basic_istream
(输入流) 和 std::basic_ostream
(输出流) 从 basic_ios
派生,提供输入/输出操作符 >>
和 <<
的重载(虽然这些是成员函数重载,但在使用时常作为全局友元函数实现)。
▮▮▮▮▮▮▮▮❷ std::basic_iostream
同时继承自 basic_istream
和 basic_ostream
。
▮▮▮▮▮▮▮▮❸ 针对特定媒介的流类,如 std::basic_fstream
(文件流)、std::basic_stringstream
(字符串流),它们从 basic_istream
, basic_ostream
, 或 basic_iostream
派生。
▮▮▮▮⚝ 多态的应用:
▮▮▮▮▮▮▮▮❶ 虽然流操作符 <<
和 >>
通常是全局函数,但它们内部调用流对象的虚函数(Virtual Function)来执行实际的读写操作,例如与缓冲区对象(std::basic_streambuf
及其派生类)交互。这意味着你可以通过一个 std::ostream&
引用来操作 std::cout
、一个 std::ofstream
对象或一个 std::stringstream
对象,实现多态行为。
▮▮▮▮▮▮▮▮❷ 通过重载 operator<<
和 operator>>
,我们可以为自定义的类对象定义输入/输出方式,这体现了运算符重载在 OOP 中的应用,使得自定义类型能够与标准流无缝集成。
11.3.2 函数对象 (Function Objects / Functors)
函数对象是重载了函数调用运算符 operator()
的类或结构体对象。它们封装了数据和行为,可以像函数一样被调用,并且可以持有状态。在 STL 算法(Algorithm)中,函数对象被广泛用作谓词(Predicate)、比较器(Comparator)或其他操作。
① OOP 视角:
函数对象可以看作是行为(函数调用)与状态(成员变量)的封装。它们提供了一种在运行时传递行为的方式,这在某些方面可以替代或补充基于继承和多态的设计。
② 示例:
考虑一个需要在算法中使用的比较器,它需要一个运行时确定的阈值。
1
#include <vector>
2
#include <algorithm>
3
#include <iostream>
4
5
// 函数对象类
6
class GreaterThan {
7
private:
8
int threshold;
9
public:
10
GreaterThan(int val) : threshold(val) {}
11
12
// 重载函数调用运算符
13
bool operator()(int x) const {
14
return x > threshold;
15
}
16
};
17
18
int main() {
19
std::vector<int> nums = {10, 5, 20, 15, 25};
20
int count = 0;
21
int limit = 18;
22
23
// 使用函数对象作为 std::count_if 的谓词
24
// GreaterThan(limit) 创建了一个函数对象实例,封装了阈值 limit
25
count = std::count_if(nums.begin(), nums.end(), GreaterThan(limit));
26
27
std::cout << "Number of elements greater than " << limit << ": " << count << std::endl; // 输出 2
28
29
return 0;
30
}
在这个例子中,GreaterThan
类封装了比较逻辑和阈值状态,它的实例 GreaterThan(limit)
被传递给 std::count_if
算法。算法会调用这个函数对象的 operator()
来处理集合中的每个元素。
11.3.3 迭代器 (Iterators)
迭代器是 STL 中抽象访问容器元素的核心概念。它们提供了一种统一的方式来遍历各种不同的容器(如 std::vector
, std::list
, std::map
等),而无需了解容器的内部实现细节。
① OOP 视角:
迭代器可以被视为指向容器元素的“智能指针”或“游标”对象。不同的容器类型会提供不同类型的迭代器类(这些类通常是容器的嵌套类型),这些类封装了遍历容器所需的状态和逻辑。
② 接口:
虽然 C++ 标准库中迭代器通常是通过概念(Concepts,C++20)或特性(Traits)来描述其接口和行为,而不是严格的继承层次结构(以避免虚函数开销),但从概念上讲,不同类型的迭代器(输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器)可以被看作是实现不同“迭代器接口”的对象。它们都支持解引用 (*
)、递增 (++
)、比较 (==
, !=
) 等操作,但支持的全部操作集合不同,体现了一种基于“行为契约”而非类继承的类似多态思想。
11.3.4 容器 (Containers)
STL 容器类(如 std::vector
, std::list
, std::map
)是对数据结构的封装(Encapsulation)。它们将底层的数据存储(如动态数组、链表、红黑树)和对数据进行操作的方法(如 push_back
, insert
, erase
, size
)绑定在一起。
① 封装的应用:
用户只需要与容器提供的公有接口交互,而无需关心数据是如何存储和管理的。这隐藏了复杂性,降低了用户代码与具体数据结构实现的耦合度。例如,std::vector::push_back()
封装了内存分配和元素复制/移动的复杂逻辑。
11.3.5 分配器 (Allocators)
分配器是 C++ 标准库中用于管理容器内存分配和释放的组件。它们通常是模板参数,允许用户自定义内存管理策略。
① OOP 视角:
分配器类封装了内存管理的行为 (allocate
, deallocate
, construct
, destroy
)。虽然不同的分配器可能不通过继承共享同一个基类,但它们都提供符合特定接口的成员函数,这是一种基于接口(Interface)而非实现继承(Implementation Inheritance)的抽象方式。
总而言之,C++ 标准库在设计中大量使用了 OOP 思想,特别是封装和接口抽象,并结合泛型编程提供了强大且灵活的数据结构和算法。
11.4 面向对象设计原则回顾 (SOLID 原则)
面向对象设计原则是一组旨在帮助开发者构建易于理解、灵活、可维护和可重用的软件系统的指导方针。SOLID 是其中一套流行的原则,由 Robert C. Martin(Uncle Bob)推广。理解并应用这些原则对于用 C++ 进行良好的 OOP 设计至关重要。
SOLID 是五个原则的首字母缩写:
⚝ S - Single Responsibility Principle (单一职责原则)
⚝ O - Open/Closed Principle (开放封闭原则)
⚝ L - Liskov Substitution Principle (里氏替换原则)
⚝ I - Interface Segregation Principle (接口隔离原则)
⚝ D - Dependency Inversion Principle (依赖倒置原则)
11.4.1 单一职责原则 (Single Responsibility Principle, SRP)
① 原则内容:
一个类(Class)或模块应该只有一个理由(Reason)去改变。换句话说,一个类应该只负责一项职责(Responsibility)。
② 解释与应用:
这里的“职责”可以理解为“改变的原因”。如果一个类承担了多于一项的职责,那么由不同原因引起的变更都可能导致这个类发生变化,增加了类的耦合度和复杂性。
在 C++ 中,违反 SRP 的类通常是“大杂烩”类(God Class),它做了太多不相关的事情。遵循 SRP 意味着将不同的职责分配给不同的类。
③ 示例:
考虑一个管理用户数据的类,它既负责存储用户数据,又负责将用户数据保存到数据库。
▮▮▮▮⚝ 违反 SRP 的设计:
1
class User {
2
std::string name;
3
int id;
4
// ... 其他用户数据
5
6
public:
7
// 用户数据操作方法
8
void setName(const std::string& n) { name = n; }
9
// ...
10
11
// 数据库操作方法
12
void saveToDatabase() {
13
// 连接数据库,执行 INSERT/UPDATE 语句等...
14
std::cout << "Saving user " << name << " to database..." << std::endl;
15
}
16
17
void loadFromDatabase(int userId) {
18
// 连接数据库,执行 SELECT 语句等...
19
std::cout << "Loading user with ID " << userId << " from database..." << std::endl;
20
}
21
};
这个 User
类有两个职责:管理用户数据本身的业务逻辑,以及处理数据库持久化。如果数据库访问方式改变(例如从 SQL 数据库换成 NoSQL 数据库),或者用户数据的业务逻辑改变,都需要修改 User
类。
▮▮▮▮⚝ 遵循 SRP 的设计:
1
class User {
2
std::string name;
3
int id;
4
// ... 其他用户数据
5
6
public:
7
// 用户数据操作方法
8
void setName(const std::string& n) { name = n; }
9
std::string getName() const { return name; }
10
// ...
11
};
12
13
class UserRepository {
14
public:
15
void save(const User& user) {
16
// 连接数据库,执行 INSERT/UPDATE 语句等...
17
std::cout << "Saving user " << user.getName() << " to database..." << std::endl;
18
}
19
20
User load(int userId) {
21
// 连接数据库,执行 SELECT 语句等...
22
std::cout << "Loading user with ID " << userId << " from database..." << std::endl;
23
// 返回加载的用户对象
24
return User(); // 简化示例
25
}
26
};
现在 User
类只负责用户数据的管理,而 UserRepository
类只负责用户数据的持久化。数据库访问方式的改变只会影响 UserRepository
类,而用户数据的业务逻辑改变只会影响 User
类,职责清晰,耦合度降低。
④ 益处:
遵循 SRP 可以提高类的内聚性(Cohesion),降低耦合性(Coupling),使得类更容易理解、测试和维护。
11.4.2 开放封闭原则 (Open/Closed Principle, OCP)
① 原则内容:
软件实体(类、模块、函数等)应该对扩展(Extension)开放,对修改(Modification)封闭。
② 解释与应用:
这意味着在软件系统中,当需求发生变化时,我们应该通过添加新的代码(扩展)来满足新需求,而不是修改现有经过测试的代码(修改)。这有助于保持现有代码的稳定性和可靠性。
在 C++ 中,OCP 通常通过使用多态(Polymorphism)来实现。我们可以定义一个基类(Base Class)或接口(Interface),其行为可以通过虚函数(Virtual Function)或纯虚函数(Pure Virtual Function)来定义。新的功能可以通过创建派生类(Derived Class)来添加,派生类重写基类的虚函数,而无需修改基类或使用基类的客户端代码。
③ 示例:
考虑一个图形绘制程序,需要绘制不同形状(圆形、方形等)。
▮▮▮▮⚝ 违反 OCP 的设计:
1
enum ShapeType { Circle, Square };
2
3
struct Circle {
4
double radius;
5
};
6
7
struct Square {
8
double side;
9
};
10
11
struct Shape {
12
ShapeType type;
13
union {
14
Circle circle;
15
Square square;
16
};
17
};
18
19
void drawShape(const Shape& shape) {
20
switch (shape.type) {
21
case Circle:
22
std::cout << "Drawing Circle with radius " << shape.circle.radius << std::endl;
23
break;
24
case Square:
25
std::cout << "Drawing Square with side " << shape.square.side << std::endl;
26
break;
27
// 如果要添加新的形状(如三角形),需要修改这个函数!
28
}
29
}
这种设计中,drawShape
函数需要知道所有可能的形状类型。如果添加一个新的形状(如 Triangle
),就需要修改 Shape
结构体、ShapeType
枚举,以及 drawShape
函数。这是对修改封闭原则的违反。
▮▮▮▮⚝ 遵循 OCP 的设计:
1
// 定义抽象基类/接口
2
class Shape {
3
public:
4
virtual ~Shape() = default; // 需要虚析构函数
5
virtual void draw() const = 0; // 纯虚函数,定义接口
6
};
7
8
// 派生类实现接口
9
class Circle : public Shape {
10
private:
11
double radius;
12
public:
13
Circle(double r) : radius(r) {}
14
void draw() const override { // 重写虚函数
15
std::cout << "Drawing Circle with radius " << radius << std::endl;
16
}
17
};
18
19
class Square : public Shape {
20
private:
21
double side;
22
public:
23
Square(double s) : side(s) {}
24
void draw() const override { // 重写虚函数
25
std::cout << "Drawing Square with side " << side << std::endl;
26
}
27
};
28
29
// 现在,绘制函数只需要接受 Shape* 或 Shape&
30
void drawShape(const Shape& shape) {
31
shape.draw(); // 利用多态调用实际对象的 draw 方法
32
}
33
34
// 如果要添加新的形状 (如 Triangle),只需创建新的派生类并实现 draw 方法,
35
// 无需修改 Shape 基类或 drawShape 函数。这是对扩展开放、对修改封闭。
36
class Triangle : public Shape {
37
double base, height;
38
public:
39
Triangle(double b, double h) : base(b), height(h) {}
40
void draw() const override {
41
std::cout << "Drawing Triangle with base " << base << " and height " << height << std::endl;
42
}
43
};
44
45
int main() {
46
Circle c(10.0);
47
Square s(5.0);
48
Triangle t(6.0, 8.0);
49
50
drawShape(c); // Drawing Circle ...
51
drawShape(s); // Drawing Square ...
52
drawShape(t); // Drawing Triangle ... (无需修改 drawShape 函数)
53
54
return 0;
55
}
这种设计中,我们定义了一个 Shape
抽象基类,并使用纯虚函数 draw()
定义了绘制的接口。每种具体的形状都通过派生类来实现这个接口。drawShape
函数只依赖于 Shape
接口,通过多态调用具体派生类的 draw
方法。当需要添加新的形状时,只需创建新的派生类,实现 draw()
方法,而无需修改现有的 Shape
基类或 drawShape
函数。
④ 益处:
遵循 OCP 可以提高代码的可扩展性和可维护性,降低引入新功能时破坏现有功能的风险。
11.4.3 里氏替换原则 (Liskov Substitution Principle, LSP)
① 原则内容:
所有使用基类(Base Class)的地方,都可以透明地使用其派生类(Derived Class)的对象替换,而不会影响程序的正确性。
② 解释与应用:
这是对继承关系正确性的一个重要约束。简单地说,派生类必须能够替换其基类并表现出相同的行为(至少是客户端所期望的行为)。如果一个派生类的行为与基类有根本性的差异,以至于使用派生类对象替换基类对象会导致程序逻辑错误,那么就违反了 LSP。
在 C++ 中,LSP 强调派生类在重写(Overriding)基类的虚函数时,不能改变方法的契约(Contract)。这个契约包括:
▮▮▮▮ⓐ 前置条件(Preconditions): 派生类方法的入口条件不能比基类方法的入口条件更强(更严格)。
▮▮▮▮ⓑ 后置条件(Postconditions): 派生类方法的出口条件不能比基类方法的出口条件更弱(更宽松)。
▮▮▮▮ⓒ 不变式(Invariants): 类的不变式在方法执行前后必须保持。
▮▮▮▮ⓓ 历史约束(History Constraint): 对象状态的变化只能通过基类定义的方法进行。
③ 示例:
经典的违反 LSP 的例子是正方形(Square)和矩形(Rectangle)的关系。从数学上讲,正方形是一种特殊的矩形("is-a" 关系)。但在编程中,如果 Rectangle
有 setWidth
和 setHeight
方法,并且改变其中一个会影响另一个(为了保持正方形的特性),那么 Square
类就不能替换 Rectangle
基类。
▮▮▮▮⚝ 违反 LSP 的设计:
1
class Rectangle {
2
protected:
3
int width = 0;
4
int height = 0;
5
public:
6
virtual ~Rectangle() = default;
7
virtual void setWidth(int w) { width = w; }
8
virtual void setHeight(int h) { height = h; }
9
int getWidth() const { return width; }
10
int getHeight() const { return height; }
11
int getArea() const { return width * height; }
12
};
13
14
class Square : public Rectangle {
15
public:
16
void setWidth(int w) override { width = height = w; } // 同时设置高
17
void setHeight(int h) override { width = height = h; } // 同时设置宽
18
};
19
20
void processRectangle(Rectangle& r) {
21
r.setWidth(5);
22
r.setHeight(10);
23
// 期望面积是 5 * 10 = 50
24
std::cout << "Expected area: 50, Actual area: " << r.getArea() << std::endl;
25
}
26
27
int main() {
28
Rectangle rect;
29
processRectangle(rect); // Expected: 50, Actual: 50 (OK)
30
31
Square sq;
32
processRectangle(sq); // Expected: 50, Actual: 10 * 10 = 100 (!!! LSP violation)
33
34
return 0;
35
}
在 processRectangle
函数中,我们期望设置宽度为 5,高度为 10 后,面积是 50。对于 Rectangle
对象是正确的。但是当我们传入一个 Square
对象时,setWidth(5)
会将高度也设为 5,然后 setHeight(10)
又会将宽度也设为 10。最终宽度和高度都变成了 10,面积是 100。Square
对象不能在期望 Rectangle
对象的上下文(processRectangle
函数)中被正确替换使用,因此违反了 LSP。
解决办法通常是重新审视类之间的关系,可能 Square
不应该直接继承 Rectangle
,或者通过其他方式实现共享功能,而不是通过可能违反契约的继承。
④ 益处:
遵循 LSP 确保了继承关系的有效性,使得基于基类接口的代码能够健壮地处理派生类对象,提高了代码的可替换性和可重用性。它是实现开放封闭原则的基础。
11.4.4 接口隔离原则 (Interface Segregation Principle, ISP)
① 原则内容:
客户端(Client)不应该被迫依赖于它们不使用的接口(Interface)。或者说,一个类不应该强制它的客户去实现它们不需要的方法。
② 解释与应用:
这个原则主要针对“胖接口”(Fat Interface),即一个接口包含了许多方法。如果一个类实现了这个胖接口,但它只需要其中一部分方法,那么实现其他不相关方法就是不必要的负担。对于使用这个类的客户端来说,它们也必须知道这个胖接口中所有的方法,即使它们只调用其中的少数方法。
在 C++ 中,接口通常通过只包含纯虚函数(Pure Virtual Function)的抽象类(Abstract Class)来定义。ISP 建议将一个大接口拆分成几个更小、更具体的接口,每个客户端只需要依赖它实际需要的那个小接口。
③ 示例:
考虑一个“多功能”打印机,既能打印,又能扫描,还能传真。
▮▮▮▮⚝ 违反 ISP 的设计:
1
class IMultiFunctionDevice { // 胖接口
2
public:
3
virtual void print(const std::string& document) = 0;
4
virtual void scan(const std::string& document) = 0;
5
virtual void fax(const std::string& document) = 0;
6
// 可能还有其他方法...
7
};
8
9
// 如果有一个只有打印功能的简单打印机类,它被迫实现 scan() 和 fax() 方法
10
class SimplePrinter : public IMultiFunctionDevice {
11
public:
12
void print(const std::string& document) override {
13
std::cout << "Printing: " << document << std::endl;
14
}
15
16
void scan(const std::string& document) override {
17
// 这个打印机不能扫描,怎么办?抛异常?什么也不做?
18
std::cout << "Simple printer cannot scan!" << std::endl;
19
}
20
21
void fax(const std::string& document) override {
22
// 这个打印机不能传真
23
std::cout << "Simple printer cannot fax!" << std::endl;
24
}
25
};
26
27
// 客户端代码,如果只需要打印功能,仍然需要依赖 IMultiFunctionDevice 接口
28
void printDocument(IMultiFunctionDevice& device, const std::string& doc) {
29
device.print(doc);
30
}
这个 IMultiFunctionDevice
是一个胖接口。SimplePrinter
强制实现了它不需要的方法,这既增加了 SimplePrinter
的复杂性,也让客户端(printDocument
函数)依赖于它不需要的 scan
和 fax
方法的存在。
▮▮▮▮⚝ 遵循 ISP 的设计:
1
class IPrinter { // 打印接口
2
public:
3
virtual ~IPrinter() = default;
4
virtual void print(const std::string& document) = 0;
5
};
6
7
class IScanner { // 扫描接口
8
public:
9
virtual ~IScanner() = default;
10
virtual void scan(const std::string& document) = 0;
11
};
12
13
class IFax { // 传真接口
14
public:
15
virtual ~IFax() = default;
16
virtual void fax(const std::string& document) = 0;
17
};
18
19
// 简单打印机只实现 IPrinter 接口
20
class SimplePrinter : public IPrinter {
21
public:
22
void print(const std::string& document) override {
23
std::cout << "Printing: " << document << std::endl;
24
}
25
};
26
27
// 多功能打印机实现所有接口
28
class MultiFunctionPrinter : public IPrinter, public IScanner, public IFax {
29
public:
30
void print(const std::string& document) override {
31
std::cout << "MFP Printing: " << document << std::endl;
32
}
33
void scan(const std::string& document) override {
34
std::cout << "MFP Scanning: " << document << std::endl;
35
}
36
void fax(const std::string& document) override {
37
std::cout << "MFP Faxing: " << document << std::endl;
38
}
39
};
40
41
// 客户端代码根据需要依赖特定的接口
42
void printDocument(IPrinter& device, const std::string& doc) {
43
device.print(doc);
44
}
45
46
void scanDocument(IScanner& device, const std::string& doc) {
47
device.scan(doc);
48
}
49
50
int main() {
51
SimplePrinter printer;
52
MultiFunctionPrinter mfp;
53
54
printDocument(printer, "Report.pdf"); // OK
55
printDocument(mfp, "Presentation.ppt"); // OK
56
57
// scanDocument(printer, "Photo.jpg"); // 错误:SimplePrinter 不实现 IScanner 接口
58
scanDocument(mfp, "Photo.jpg"); // OK
59
60
return 0;
61
}
通过将大接口拆分为小的、专注于单一职责的接口,SimplePrinter
不再被迫实现它不支持的功能。客户端代码现在只需要依赖它实际需要的接口(例如,printDocument
只依赖 IPrinter
),降低了耦合度,提高了灵活性。
④ 益处:
遵循 ISP 可以避免“胖接口”问题,使得类更容易实现和维护,客户端代码只需要依赖于它实际使用的接口,从而降低了类之间的耦合度。
11.4.5 依赖倒置原则 (Dependency Inversion Principle, DIP)
① 原则内容:
高层模块(High-level Module)不应该依赖于低层模块(Low-level Module)。两者都应该依赖于抽象(Abstraction)。
抽象不应该依赖于细节(Details)。细节应该依赖于抽象。
② 解释与应用:
“高层模块”实现复杂的业务逻辑,“低层模块”实现具体的操作(如数据库访问、文件读写、网络通信)。传统上,高层模块会直接调用低层模块的具体实现。DIP 建议引入一个抽象层(通常是接口或抽象类),高层模块和低层模块都依赖于这个抽象层,而不是高层模块直接依赖于低层模块的具体实现。
在 C++ 中,这通常通过依赖注入(Dependency Injection)来实现。高层类不直接创建低层类的对象,而是依赖于一个基类指针或引用(抽象),具体的低层类对象在外部创建并注入(通过构造函数、setter 方法或方法参数)。
③ 示例:
考虑一个报告生成器(高层模块),它需要从某个地方获取数据(低层模块)。
▮▮▮▮⚝ 违反 DIP 的设计:
1
class Database { // 低层模块的具体实现
2
public:
3
std::string getData() {
4
std::cout << "Getting data from Database..." << std::endl;
5
return "Data from DB";
6
}
7
};
8
9
class ReportGenerator { // 高层模块
10
private:
11
Database db; // 直接依赖于低层模块的具体实现
12
public:
13
void generateReport() {
14
std::string data = db.getData(); // 调用低层模块的具体方法
15
std::cout << "Generating report with: " << data << std::endl;
16
}
17
};
18
19
int main() {
20
ReportGenerator generator;
21
generator.generateReport();
22
return 0;
23
}
ReportGenerator
类直接创建并使用了 Database
类对象。这意味着 ReportGenerator
高度依赖于 Database
的具体实现。如果数据源改为文件或网络服务,就需要修改 ReportGenerator
类。这违反了 DIP。
▮▮▮▮⚝ 遵循 DIP 的设计:
1
class IDataSource { // 抽象层 (接口)
2
public:
3
virtual ~IDataSource() = default;
4
virtual std::string getData() = 0;
5
};
6
7
class Database : public IDataSource { // 低层模块实现抽象
8
public:
9
std::string getData() override {
10
std::cout << "Getting data from Database..." << std::endl;
11
return "Data from DB";
12
}
13
};
14
15
class FileSource : public IDataSource { // 另一个低层模块实现抽象
16
public:
17
std::string getData() override {
18
std::cout << "Getting data from File..." << std::endl;
19
return "Data from File";
20
}
21
};
22
23
class ReportGenerator { // 高层模块依赖于抽象
24
private:
25
IDataSource* dataSource; // 依赖于抽象接口的指针
26
public:
27
// 通过构造函数注入依赖
28
ReportGenerator(IDataSource* source) : dataSource(source) {}
29
30
void generateReport() {
31
std::string data = dataSource->getData(); // 调用抽象接口的方法 (多态)
32
std::cout << "Generating report with: " << data << std::endl;
33
}
34
};
35
36
int main() {
37
Database db_source;
38
FileSource file_source;
39
40
// 在 main 或更高层模块中决定使用哪个具体的数据源对象,并注入到 ReportGenerator
41
ReportGenerator generator_db(&db_source);
42
generator_db.generateReport(); // 使用数据库数据源
43
44
ReportGenerator generator_file(&file_source);
45
generator_file.generateReport(); // 使用文件数据源
46
47
return 0;
48
}
现在 ReportGenerator
类依赖于 IDataSource
抽象接口的指针,而不是具体的 Database
或 FileSource
类。具体的数据源对象是在外部创建并传递给 ReportGenerator
的构造函数(依赖注入)。如果需要更换数据源,只需创建不同的 IDataSource
派生类对象并注入即可,无需修改 ReportGenerator
类本身。这样,高层模块和低层模块都依赖于抽象,实现了依赖的倒置。
④ 益处:
遵循 DIP 可以降低高层模块与低层模块具体实现之间的耦合度,提高了系统的灵活性、可测试性和可维护性。它使得替换底层实现变得容易,是实现可插拔架构的基础。
回顾 SOLID 原则,它们是互相补充的。例如,LSP 是实现 OCP 的前提;ISP 可以帮助我们设计更细粒度的接口,从而更好地实现 DIP;而所有这些原则都围绕着如何设计出职责单一、内聚性高、耦合性低的类和模块,这与 SRP 的目标一致。在进行 C++ OOP 设计时,时刻思考如何应用这些原则,将有助于我们构建出更健壮、更灵活、更易于维护的软件系统。
12. C++ 面向对象技术深度解析与实践
本章将介绍一些在 C++ 面向对象编程 (Object-Oriented Programming, OOP) 开发中常用的设计模式 (Design Pattern),并通过具体案例展示它们的应用。掌握设计模式能够帮助我们编写出更灵活、可维护和可扩展的代码,提升软件设计的质量。
12.1 设计模式概述
12.1.1 编程中的“模式”与“惯用法”
在软件开发领域,我们经常会遇到一些反复出现的问题。经过多年的实践和总结,前辈们发现针对这些问题,存在一些经过验证的、优秀的解决方案。这些解决方案并非可以直接复制粘贴的代码,而是一种抽象的、描述性的指导,它们被称为“设计模式”。
设计模式 (Design Pattern) 是一种在特定情境下,针对软件设计中常见问题的可重用解决方案。它们是面向对象设计经验的总结,描述了对象和类如何交互以解决特定设计问题。它们是语言无关的,但在不同的语言中可能有不同的实现方式和侧重点。
与设计模式相关的概念还有“惯用法”或“习语” (Idiom)。惯用法通常是特定编程语言中常用的、解决某个特定小问题的代码模式或技巧。例如,C++ 中的资源获取即初始化 (Resource Acquisition Is Initialization, RAII) 就是一个典型的惯用法。它比设计模式更低层次,更专注于语言特性。
12.1.2 GoF 设计模式分类
最著名的设计模式集合来自于 Gamma、Helm、Johnson 和 Vlissides 四位作者(通常称为 GoF,即 Gang of Four)的著作《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software)。他们将设计模式分为三大类:
① 创建型模式 (Creational Patterns):
▮▮▮▮⚝ 关注对象的创建机制,将对象的创建与使用分离,从而使得系统在创建对象时更灵活。
▮▮▮▮⚝ 常见的有:工厂模式 (Factory Pattern)、抽象工厂模式 (Abstract Factory Pattern)、单例模式 (Singleton Pattern)、建造者模式 (Builder Pattern)、原型模式 (Prototype Pattern)。
② 结构型模式 (Structural Patterns):
▮▮▮▮⚝ 关注如何将类和对象组合成更大的结构,通常涉及类之间的继承关系或对象的组合关系。
▮▮▮▮⚝ 常见的有:适配器模式 (Adapter Pattern)、桥接模式 (Bridge Pattern)、组合模式 (Composite Pattern)、装饰器模式 (Decorator Pattern)、外观模式 (Facade Pattern)、享元模式 (Flyweight Pattern)、代理模式 (Proxy Pattern)。
③ 行为型模式 (Behavioral Patterns):
▮▮▮▮⚝ 关注对象之间的职责分配和交互方式,描述对象和类如何相互协作来完成某个任务。
▮▮▮▮⚝ 常见的有:责任链模式 (Chain of Responsibility Pattern)、命令模式 (Command Pattern)、解释器模式 (Interpreter Pattern)、迭代器模式 (Iterator Pattern)、中介者模式 (Mediator Pattern)、备忘录模式 (Memento Pattern)、观察者模式 (Observer Pattern)、状态模式 (State Pattern)、策略模式 (Strategy Pattern)、模板方法模式 (Template Method Pattern)、访问者模式 (Visitor Pattern)。
12.1.3 设计模式的价值
为什么要学习和应用设计模式?
① 提供通用语言 (Common Vocabulary):模式提供了一种标准的方式来讨论设计问题和解决方案,提高了团队成员之间的沟通效率。当你提到“工厂模式”,其他有经验的开发者就能快速理解你正在尝试解决什么问题以及大致的设计方向。
② 提供成熟解决方案 (Proven Solutions):模式是经验的结晶,它们代表了在特定情境下被证明是有效的解决方案。使用模式可以避免重复发明轮子,并减少引入设计错误的风险。
③ 提高代码质量 (Improve Code Quality):应用模式通常能使代码更加符合面向对象的设计原则 (如 SOLID 原则,参阅第 11 章),从而提高代码的内聚性 (Cohesion)、降低耦合度 (Coupling),使其更易于理解、修改、测试和维护。
④ 促进可重用性 (Promote Reusability):模式本身是可重用的设计思想,而且遵循模式实现的代码结构往往更容易被复用或扩展。
⑤ 指导设计过程 (Guide Design Process):在面对一个复杂的设计问题时,模式可以作为思考的起点和方向,帮助开发者系统地组织类和对象的关系。
学习设计模式并非为了在任何地方都硬套模式,而是为了理解模式背后的思想,在合适的场景选择合适的模式,或者从模式中汲取灵感来解决自己的问题。重要的是理解其意图、结构、参与者以及它们如何协作。
12.2 创建型模式 (Creational Patterns) 在 C++ 中的应用
创建型模式旨在将对象的创建过程抽象化,以达到更加灵活地控制对象生成的目标。
12.2.1 工厂模式 (Factory Pattern)
工厂模式是一种常用的创建型模式,它提供了一种创建对象的最佳方式。在工厂模式中,创建对象时无需指定具体的类,而是通过一个工厂方法或函数来创建。
① 意图 (Intent):
▮▮▮▮⚝ 定义一个用于创建对象的接口,让子类决定实例化哪个类。工厂方法模式使一个类的实例化延迟到其子类。
② 结构 (Structure):
▮▮▮▮⚝ 通常涉及一个抽象的 Creator
类声明工厂方法,具体创建者 ConcreteCreator
类实现工厂方法返回具体的产品 ConcreteProduct
对象。产品本身通常也继承自一个抽象的 Product
基类。
③ C++ 实现示例 - 简单工厂 (Simple Factory):
简单工厂严格来说不是 GoF 模式,但它是一种常见的实现方式,尤其适用于产品族不复杂的情况。它由一个工厂类负责创建所有产品。
1
#include <iostream>
2
#include <string>
3
4
// 抽象产品 (Abstract Product)
5
class Shape {
6
public:
7
virtual void draw() const = 0;
8
virtual ~Shape() = default; // 虚析构函数很重要!
9
};
10
11
// 具体产品 (Concrete Products)
12
class Circle : public Shape {
13
public:
14
void draw() const override {
15
std::cout << "绘制圆形 (Drawing Circle)" << std::endl;
16
}
17
};
18
19
class Square : public Shape {
20
public:
21
void draw() const override {
22
std::cout << "绘制正方形 (Drawing Square)" << std::endl;
23
}
24
};
25
26
// 简单工厂 (Simple Factory)
27
class ShapeFactory {
28
public:
29
static Shape* createShape(const std::string& type) {
30
if (type == "circle") {
31
return new Circle();
32
} else if (type == "square") {
33
return new Square();
34
} else {
35
return nullptr; // 或者抛出异常 (throw exception)
36
}
37
}
38
};
39
40
// 客户端代码 (Client Code)
41
int main() {
42
// 通过工厂创建对象,客户端无需知道具体的 Circle 或 Square 类
43
Shape* circle = ShapeFactory::createShape("circle");
44
if (circle) {
45
circle->draw();
46
delete circle; // Remember to delete heap objects!
47
}
48
49
Shape* square = ShapeFactory::createShape("square");
50
if (square) {
51
square->draw();
52
delete square;
53
}
54
55
Shape* unknown = ShapeFactory::createShape("triangle");
56
if (!unknown) {
57
std::cout << "未知图形类型 (Unknown shape type)" << std::endl;
58
}
59
60
return 0;
61
}
▮▮▮▮⚝ 简单工厂的优缺点:
▮▮▮▮▮▮▮▮⚝ 优点:客户端代码与具体产品类解耦,只需知道产品类型字符串即可创建对象。
▮▮▮▮▮▮▮▮⚝ 缺点:工厂类职责过重,当产品种类增加时,工厂类的 createShape
方法需要修改,违反了开放封闭原则 (Open/Closed Principle)。
④ C++ 实现示例 - 工厂方法 (Factory Method):
工厂方法模式是 GoF 推荐的模式。它定义一个创建对象的接口,但将实际实例化延迟到子类。
1
#include <iostream>
2
#include <string>
3
#include <vector> // For demonstration in main
4
5
// 抽象产品 (Abstract Product) - 同上
6
class Product {
7
public:
8
virtual void use() const = 0;
9
virtual ~Product() = default;
10
};
11
12
// 具体产品 A (Concrete Product A)
13
class ConcreteProductA : public Product {
14
public:
15
void use() const override {
16
std::cout << "使用具体产品 A (Using Concrete Product A)" << std::endl;
17
}
18
};
19
20
// 具体产品 B (Concrete Product B)
21
class ConcreteProductB : public Product {
22
public:
23
void use() const override {
24
std::cout << "使用具体产品 B (Using Concrete Product B)" << std::endl;
25
}
26
};
27
28
// 抽象创建者 (Abstract Creator)
29
class Creator {
30
public:
31
// 工厂方法 (Factory Method)
32
virtual Product* factoryMethod() const = 0;
33
34
// 业务逻辑方法,使用工厂方法创建的产品
35
void doSomething() const {
36
Product* product = factoryMethod(); // 通过工厂方法获取产品
37
product->use();
38
delete product; // 清理资源 (Clean up resource)
39
}
40
41
virtual ~Creator() = default;
42
};
43
44
// 具体创建者 A (Concrete Creator A)
45
class ConcreteCreatorA : public Creator {
46
public:
47
// 实现工厂方法,创建具体产品 A
48
Product* factoryMethod() const override {
49
return new ConcreteProductA();
50
}
51
};
52
53
// 具体创建者 B (Concrete Creator B)
54
class ConcreteCreatorB : public Creator {
55
public:
56
// 实现工厂方法,创建具体产品 B
57
Product* factoryMethod() const override {
58
return new ConcreteProductB();
59
}
60
};
61
62
// 客户端代码 (Client Code)
63
int main() {
64
Creator* creatorA = new ConcreteCreatorA();
65
creatorA->doSomething(); // ConcreteCreatorA 创建 ConcreteProductA
66
67
Creator* creatorB = new ConcreteCreatorB();
68
creatorB->doSomething(); // ConcreteCreatorB 创建 ConcreteProductB
69
70
delete creatorA;
71
delete creatorB;
72
73
return 0;
74
}
▮▮▮▮⚝ 工厂方法的优缺点:
▮▮▮▮▮▮▮▮⚝ 优点:符合开放封闭原则,增加新产品时,只需增加新的具体产品类和新的具体创建者类,无需修改现有代码。客户端代码依赖于抽象创建者和抽象产品,具有良好的灵活性。
▮▮▮▮▮▮▮▮⚝ 缺点:每增加一个产品,就需要增加一个具体创建者,类的数量会增加,系统结构可能变得复杂。
抽象工厂模式 (Abstract Factory Pattern) 是工厂方法的进一步抽象,用于创建一系列相关或相互依赖对象的家族,而无需指定其具体类。这在需要支持多种主题或风格的UI库中很常见(例如创建 Windows 风格的按钮和文本框,或者 Mac 风格的按钮和文本框)。
12.2.2 单例模式 (Singleton Pattern)
单例模式是一种创建型模式,旨在确保一个类只有一个实例,并提供一个全局访问点。
① 意图 (Intent):
▮▮▮▮⚝ 确保一个类只有一个实例,并提供一个访问它的全局访问点。
② 结构 (Structure):
▮▮▮▮⚝ 单例类本身负责创建自己的唯一实例,并提供一个公共静态方法供外部获取该实例。通常,单例类的构造函数是私有的,防止外部直接实例化。
③ C++ 实现示例 (使用 C++11 Magic Static):
在 C++11 及更高版本中,实现线程安全的单例最简单且推荐的方式是使用静态局部变量 (Static Local Variable),也称为 Magic Static。局部静态变量的初始化在首次访问时进行,并且是线程安全的。
1
#include <iostream>
2
#include <mutex> // Included for understanding, though not strictly needed for magic static
3
4
// 单例类 (Singleton Class)
5
class Singleton {
6
private:
7
// 私有构造函数,防止外部直接实例化
8
Singleton() {
9
std::cout << "单例实例已创建 (Singleton instance created)" << std::endl;
10
}
11
12
// 私有析构函数 (可选,根据需求)
13
~Singleton() {
14
std::cout << "单例实例已销毁 (Singleton instance destroyed)" << std::endl;
15
}
16
17
// 删除拷贝构造函数和赋值运算符,确保唯一性
18
Singleton(const Singleton&) = delete;
19
Singleton& operator=(const Singleton&) = delete;
20
Singleton(Singleton&&) = delete; // C++11 移动构造函数
21
Singleton& operator=(Singleton&&) = delete; // C++11 移动赋值运算符
22
23
public:
24
// 获取单例实例的公共静态方法
25
static Singleton& getInstance() {
26
// Magic static: C++11 guarantees thread-safe initialization
27
static Singleton instance;
28
return instance;
29
}
30
31
// 单例类的业务方法
32
void doSomething() const {
33
std::cout << "单例实例正在执行业务逻辑 (Singleton instance is doing something)" << std::endl;
34
}
35
};
36
37
// 客户端代码 (Client Code)
38
int main() {
39
// 获取单例实例
40
Singleton& s1 = Singleton::getInstance();
41
Singleton& s2 = Singleton::getInstance();
42
43
// 验证 s1 和 s2 是同一个实例
44
if (&s1 == &s2) {
45
std::cout << "s1 和 s2 是同一个实例 (s1 and s2 are the same instance)" << std::endl;
46
s1.doSomething();
47
s2.doSomething();
48
}
49
50
// 不能直接实例化:
51
// Singleton s3; // 编译错误 (Compile error)
52
53
return 0;
54
} // 程序结束时,局部静态变量 instance 会被销毁,析构函数会被调用
④ 单例模式的优缺点:
▮▮▮▮⚝ 优点:
▮▮▮▮▮▮▮▮⚝ 保证类只有一个实例。
▮▮▮▮▮▮▮▮⚝ 提供全局访问点。
▮▮▮▮▮▮▮▮⚝ 实例在首次使用时才被创建,延迟加载 (Lazy Loading)。
▮▮▮▮⚝ 缺点:
▮▮▮▮▮▮▮▮⚝ 可能引入全局状态,使系统耦合度增加,难以测试。
▮▮▮▮▮▮▮▮⚝ 滥用单例可能导致“上帝对象” (God Object),持有过多功能和状态。
应用场景:配置管理器、日志记录器、线程池、数据库连接池等需要全局唯一访问或需要集中管理的资源。
12.3 结构型模式 (Structural Patterns) 在 C++ 中的应用
结构型模式关注类和对象的组合,通过继承或组合来构建更大、更灵活的结构。
12.3.1 适配器模式 (Adapter Pattern)
适配器模式允许接口不兼容的对象之间进行协作。
① 意图 (Intent):
▮▮▮▮⚝ 将一个类的接口转换成客户希望的另一个接口。适配器模式使得原来由于接口不兼容而不能一起工作的那些类可以一起工作。
② 结构 (Structure):
▮▮▮▮⚝ 通常涉及一个目标接口 (Target Interface),一个需要被适配的类 (Adaptee),以及一个适配器类 (Adapter) 实现目标接口并包含或继承被适配者。
③ C++ 实现示例 - 对象适配器 (Object Adapter):
对象适配器通过在适配器类中包含一个被适配者对象的引用或指针来实现。这是 C++ 中更常用和灵活的方式。
1
#include <iostream>
2
3
// 目标接口 (Target Interface) - 客户期望使用的接口
4
class Target {
5
public:
6
virtual void request() const = 0;
7
virtual ~Target() = default;
8
};
9
10
// 被适配者 (Adaptee) - 需要被适配的类,接口不兼容
11
class Adaptee {
12
public:
13
void specificRequest() const {
14
std::cout << "被适配者的特定请求 (Adaptee's specific request)" << std::endl;
15
}
16
};
17
18
// 适配器 (Adapter) - 实现目标接口,并使用被适配者
19
class Adapter : public Target {
20
private:
21
Adaptee* adaptee_;
22
23
public:
24
Adapter(Adaptee* adaptee) : adaptee_(adaptee) {}
25
26
// 实现目标接口的请求方法,内部调用被适配者的特定方法
27
void request() const override {
28
std::cout << "适配器调用 -> ";
29
adaptee_->specificRequest();
30
}
31
32
~Adapter() {
33
// 注意:这里适配器负责 Adaptee 的生命周期,或者由外部管理
34
// 在这个简单例子中,假设适配器拥有 Adaptee
35
delete adaptee_;
36
}
37
};
38
39
// 客户端代码 (Client Code)
40
int main() {
41
// 假设我们有一个 Adaptee 对象
42
Adaptee* existingAdaptee = new Adaptee();
43
44
// 我们需要一个 Target 对象,但只有 Adaptee
45
// 使用适配器将被适配者转换为目标接口
46
Target* target = new Adapter(existingAdaptee);
47
48
// 现在客户端可以使用目标接口与适配器交互
49
target->request();
50
51
delete target; // 删除适配器,其析构函数会删除被适配者
52
53
return 0;
54
}
④ C++ 实现示例 - 类适配器 (Class Adapter):
类适配器通过多重继承实现,适配器类同时继承目标接口和被适配者。这在 C++ 中相对不常用,因为它需要多重继承,且更严格地绑定了适配器和被适配者。
1
// C++ Class Adapter (需要多重继承)
2
// #include <iostream>
3
4
// // 目标接口 (Target Interface) - 同上
5
// class Target {
6
// public:
7
// virtual void request() const = 0;
8
// virtual ~Target() = default;
9
// };
10
11
// // 被适配者 (Adaptee) - 同上
12
// class Adaptee {
13
// public:
14
// void specificRequest() const {
15
// std::cout << "被适配者的特定请求 (Adaptee's specific request)" << std::endl;
16
// }
17
// };
18
19
// // 适配器 (Adapter) - 多重继承 Target 和 Adaptee
20
// class ClassAdapter : public Target, private Adaptee {
21
// public:
22
// // 实现目标接口的请求方法,直接调用继承来的被适配者方法
23
// void request() const override {
24
// std::cout << "类适配器调用 -> ";
25
// this->specificRequest(); // 直接访问 Adaptee 的方法
26
// }
27
// };
28
29
// // 客户端代码 (Client Code)
30
// int main() {
31
// Target* target = new ClassAdapter();
32
// target->request();
33
// delete target;
34
// return 0;
35
// }
▮▮▮▮⚝ 适配器模式的优缺点:
▮▮▮▮▮▮▮▮⚝ 优点:使得原本不兼容的类能够一起工作,提高了类的复用性。客户端代码与被适配者解耦。
▮▮▮▮▮▮▮▮⚝ 缺点:引入了新的类(适配器),增加了系统的复杂度。类适配器受限于多重继承,且不能适配被适配者的子类。对象适配器更灵活,可以适配被适配者及其子类。
应用场景:
▮▮▮▮⚝ 需要使用一个已有的类,但其接口与当前系统的其他部分不兼容。
▮▮▮▮⚝ 需要创建一个可复用的类,该类可以与一些老的或不相关的类协同工作。
▮▮▮▮⚝ 例如,将一个遗留库的接口适配到新的框架中,或者适配不同数据格式的接口。
12.3.2 装饰器模式 (Decorator Pattern)
装饰器模式允许在运行时动态地给对象添加新的行为。
① 意图 (Intent):
▮▮▮▮⚝ 动态地给一个对象添加一些额外的职责 (Responsibility)。就增加功能来说,装饰器模式相比生成子类更为灵活。
② 结构 (Structure):
▮▮▮▮⚝ 涉及一个抽象组件 (Component) 接口,具体组件 (Concrete Component),抽象装饰器 (Decorator) 类(继承自 Component,并包含一个 Component 对象的引用),以及具体装饰器 (Concrete Decorator) 类(继承自 Decorator,实现具体的新功能)。
③ C++ 实现示例:
考虑一个图形用户界面 (GUI) 中的文本视图组件。我们可以用装饰器模式为其添加滚动条或边框等功能。
1
#include <iostream>
2
#include <string>
3
4
// 抽象组件 (Abstract Component)
5
// 定义基本功能接口
6
class VisualComponent {
7
public:
8
virtual void display() const = 0;
9
virtual ~VisualComponent() = default;
10
};
11
12
// 具体组件 (Concrete Component)
13
// 基本的文本视图
14
class TextView : public VisualComponent {
15
public:
16
void display() const override {
17
std::cout << "显示基本文本视图 (Displaying basic text view)" << std::endl;
18
}
19
};
20
21
// 抽象装饰器 (Abstract Decorator)
22
// 继承自 Component,并包含一个 Component 指针
23
class Decorator : public VisualComponent {
24
protected:
25
VisualComponent* component_; // 被装饰的组件
26
27
public:
28
Decorator(VisualComponent* component) : component_(component) {}
29
30
// 转发 display 调用给被装饰的对象
31
void display() const override {
32
component_->display();
33
}
34
35
virtual ~Decorator() {
36
// 注意:这里装饰器负责其持有的 component_ 的生命周期
37
delete component_;
38
}
39
};
40
41
// 具体装饰器 A (Concrete Decorator A)
42
// 添加边框功能
43
class BorderDecorator : public Decorator {
44
public:
45
BorderDecorator(VisualComponent* component) : Decorator(component) {}
46
47
void display() const override {
48
std::cout << "--- 添加边框功能 (Adding border functionality) ---" << std::endl;
49
Decorator::display(); // 调用基类(转发)方法
50
std::cout << "--- 边框结束 (Border ends) ---" << std::endl;
51
}
52
};
53
54
// 具体装饰器 B (Concrete Decorator B)
55
// 添加滚动条功能
56
class ScrollDecorator : public Decorator {
57
public:
58
ScrollDecorator(VisualComponent* component) : Decorator(component) {}
59
60
void display() const override {
61
std::cout << "--- 添加滚动条功能 (Adding scrollbar functionality) ---" << std::endl;
62
Decorator::display(); // 调用基类(转发)方法
63
std::cout << "--- 滚动条结束 (Scrollbar ends) ---" << std::endl;
64
}
65
};
66
67
// 客户端代码 (Client Code)
68
int main() {
69
// 创建一个基本文本视图
70
VisualComponent* textView = new TextView();
71
std::cout << "--- 基本视图 ---" << std::endl;
72
textView->display();
73
delete textView; // 清理基本视图 (Clean up basic view)
74
std::cout << std::endl;
75
76
// 创建一个带边框的文本视图
77
VisualComponent* textViewWithBorder = new BorderDecorator(new TextView());
78
std::cout << "--- 带边框视图 ---" << std::endl;
79
textViewWithBorder->display();
80
delete textViewWithBorder; // 清理带边框视图 (Clean up bordered view)
81
std::cout << std::endl;
82
83
// 创建一个带滚动条的文本视图
84
VisualComponent* textViewWithScroll = new ScrollDecorator(new TextView());
85
std::cout << "--- 带滚动条视图 ---" << std::endl;
86
textViewWithScroll->display();
87
delete textViewWithScroll; // 清理带滚动条视图 (Clean up scrolled view)
88
std::endl;
89
90
// 创建一个既带边框又带滚动条的文本视图
91
// 注意装饰器的嵌套使用
92
VisualComponent* decoratedTextView = new BorderDecorator(new ScrollDecorator(new TextView()));
93
std::cout << "--- 既带边框又带滚动条视图 ---" << std::endl;
94
decoratedTextView->display();
95
delete decoratedTextView; // 清理嵌套装饰器 (Clean up nested decorators)
96
97
return 0;
98
}
④ 装饰器模式的优缺点:
▮▮▮▮⚝ 优点:
▮▮▮▮▮▮▮▮⚝ 比继承更灵活,可以在运行时动态添加功能。
▮▮▮▮▮▮▮▮⚝ 可以添加多个装饰器,以任意顺序组合功能。
▮▮▮▮▮▮▮▮⚝ 避免了继承导致的类爆炸问题。
▮▮▮▮⚝ 缺点:
▮▮▮▮▮▮▮▮⚝ 增加了系统的复杂性,类的数量增加。
▮▮▮▮▮▮▮▮⚝ 装饰器和其组件的对象标识 (Object Identity) 不同。
应用场景:
▮▮▮▮⚝ 需要在运行时动态透明地给对象添加功能,而又不影响其他对象。
▮▮▮▮⚝ 不适合用继承来扩展类的功能,比如继承会导致子类太多。```cpp
include
include
include
include // For std::remove_if
include // An alternative container
// 前向声明 Observer,避免循环依赖
class Observer;
// 抽象主体 (Abstract Subject)
// 定义管理和通知观察者的方法
class Subject {
private:
// 观察者列表,使用智能指针管理生命周期或确保外部管理
// 这里为简化示例,使用原始指针,实际应用推荐 std::weak_ptr
std::list
public:
// 添加观察者
void attach(Observer* observer) {
observers_.push_back(observer);
std::cout << "添加观察者 (Observer attached)" << std::endl;
}
1
// 移除观察者
2
void detach(Observer* observer) {
3
// C++11/14 使用 lambda 和 erase + remove_if
4
observers_.erase(std::remove_if(observers_.begin(), observers_.end(),
5
[&](Observer* o){ return o == observer; }),
6
observers_.end());
7
std::cout << "移除观察者 (Observer detached)" << std::endl;
8
}
9
10
// 通知所有观察者状态已改变
11
void notify() {
12
std::cout << "通知观察者 (Notifying observers)..." << std::endl;
13
for (Observer* observer : observers_) {
14
observer->update();
15
}
16
}
17
18
virtual ~Subject() = default; // 确保派生类析构正确
};
// 抽象观察者 (Abstract Observer)
// 定义接收更新通知的接口
class Observer {
public:
virtual void update() = 0;
virtual ~Observer() = default;
};
// 具体主体 (Concrete Subject)
// 维护自身状态,并在状态变化时通知观察者
class ConcreteSubject : public Subject {
private:
std::string state_;
public:
std::string getState() const {
return state_;
}
1
void setState(const std::string& state) {
2
std::cout << "具体主体状态改变: " << state << " (Concrete subject state changed: " << state << ")" << std::endl;
3
state_ = state;
4
notify(); // 状态改变时通知观察者
5
}
};
// 具体观察者 (Concrete Observer)
// 维护对具体主体的引用,存储自身状态,并实现 Observer 的 update 方法
class ConcreteObserver : public Observer {
private:
ConcreteSubject* subject_; // 观察的主体
std::string observerState_; // 观察者自身状态
public:
ConcreteObserver(ConcreteSubject* subject) : subject_(subject) {}
1
// 实现 update 方法,从主体获取状态并更新自身
2
void update() override {
3
observerState_ = subject_->getState();
4
std::cout << "具体观察者收到更新,状态变为: " << observerState_ << " (Concrete observer received update, state is: " << observerState_ << ")" << std::endl;
5
}
};
// 客户端代码 (Client Code)
int main() {
ConcreteSubject* subject = new ConcreteSubject();
1
ConcreteObserver* observer1 = new ConcreteObserver(subject);
2
ConcreteObserver* observer2 = new ConcreteObserver(subject);
3
4
// 观察者订阅主体
5
subject->attach(observer1);
6
subject->attach(observer2);
7
8
std::cout << "\n--- 改变主体状态第一次 (Change subject state first time) ---" << std::endl;
9
subject->setState("State A");
10
11
std::cout << "\n--- 移除一个观察者 (Detach one observer) ---" << std::endl;
12
subject->detach(observer2);
13
14
std::cout << "\n--- 改变主体状态第二次 (Change subject state second time) ---" << std::endl;
15
subject->setState("State B");
16
17
// 清理资源
18
delete subject; // Subject 析构,但它不拥有 Observer 的生命周期
19
// 实际应用中,Observer 的生命周期管理很重要,这里为简化由 main 函数负责
20
delete observer1;
21
delete observer2; // 虽然 observer2 已被 detach,但其对象仍需手动删除
22
23
return 0;
}
1
在这个例子中,当 `ConcreteSubject` 的状态通过 `setState` 改变时,它会调用 `notify()` 方法,遍历其观察者列表,并调用每个 `Observer` 的 `update()` 方法。`ConcreteObserver` 在接收到 `update` 通知后,会主动去 `ConcreteSubject` 获取最新的状态。
2
3
④ **观察者模式的优缺点**:
4
▮▮▮▮⚝ 优点:
5
▮▮▮▮▮▮▮▮⚝ 主体和观察者之间的耦合度低,主体只知道它有一个观察者列表,不知道具体的观察者类型。
6
▮▮▮▮▮▮▮▮⚝ 支持广播通信,状态改变时可以同时通知多个观察者。
7
▮▮▮▮▮▮▮▮⚝ 观察者可以独立地被添加或移除。
8
▮▮▮▮⚝ 缺点:
9
▮▮▮▮▮▮▮▮⚝ 如果观察者数量过多,通知可能需要较长时间。
10
▮▮▮▮▮▮▮▮⚝ 如果观察者和主体之间存在循环依赖,可能导致问题。
11
▮▮▮▮▮▮▮▮⚝ 在多线程环境下,需要考虑线程安全问题。
12
▮▮▮▮▮▮▮▮⚝ C++ 中观察者和主体之间的生命周期管理比较复杂,容易出现“悬空指针” (Dangling Pointer) 或内存泄漏 (Memory Leak),推荐使用 `std::weak_ptr` 来管理观察者引用。
13
14
**应用场景**:
15
▮▮▮▮⚝ 当一个对象的改变需要同时改变其他对象,而不知道具体有多少对象需要改变时。
16
▮▮▮▮⚝ 当一个抽象模型有两个方面,其中一个方面依赖于另一个方面时(例如 MVC 模式中的 Model 和 View)。
17
▮▮▮▮⚝ 事件处理系统,例如 GUI 事件、系统通知等。
18
19
#### 12.4.2 策略模式 (Strategy Pattern)
20
策略模式定义了一系列算法,将每个算法封装起来,并使它们可以相互替换。策略模式使得算法可以独立于使用它的客户端而变化。
21
22
① **意图 (Intent)**:
23
▮▮▮▮⚝ 定义一系列算法,把它们一个个封装起来,并且使它们可相互替换。策略模式使得算法的变化可独立于使用算法的客户。
24
② **结构 (Structure)**:
25
▮▮▮▮⚝ 涉及一个环境 (Context) 类,它持有一个对策略 (Strategy) 接口的引用;一个抽象策略 (Abstract Strategy) 接口,定义所有具体策略需要实现的算法接口;以及多个具体策略 (Concrete Strategy) 类,实现抽象策略接口中的具体算法。
26
③ **C++ 实现示例**:
27
假设我们需要实现不同的排序算法,但希望在运行时根据需要切换使用哪种算法。
28
29
```cpp
30
31
#include <iostream>
32
#include <vector>
33
#include <algorithm> // For std::sort
34
#include <numeric> // For std::iota
35
36
// 抽象策略 (Abstract Strategy)
37
// 定义算法接口
38
class SortStrategy {
39
public:
40
virtual void sort(std::vector<int>& data) const = 0;
41
virtual ~SortStrategy() = default;
42
};
43
44
// 具体策略 A (Concrete Strategy A) - 冒泡排序 (Bubble Sort)
45
class BubbleSort : public SortStrategy {
46
public:
47
void sort(std::vector<int>& data) const override {
48
std::cout << "使用冒泡排序 (Using Bubble Sort)" << std::endl;
49
size_t n = data.size();
50
for (size_t i = 0; i < n - 1; ++i) {
51
for (size_t j = 0; j < n - i - 1; ++j) {
52
if (data[j] > data[j + 1]) {
53
std::swap(data[j], data[j + 1]);
54
}
55
}
56
}
57
}
58
};
59
60
// 具体策略 B (Concrete Strategy B) - 标准库排序 (Std Library Sort)
61
class StdSort : public SortStrategy {
62
public:
63
void sort(std::vector<int>& data) const override {
64
std::cout << "使用标准库排序 (Using Std Library Sort)" << std::endl;
65
std::sort(data.begin(), data.end());
66
}
67
};
68
69
// 环境 (Context)
70
// 持有一个策略对象,并调用其算法方法
71
class SortContext {
72
private:
73
// 使用智能指针管理策略对象的生命周期,这里为简化用原始指针
74
// 实际推荐 std::unique_ptr<SortStrategy> 或 std::shared_ptr<SortStrategy>
75
SortStrategy* strategy_;
76
77
public:
78
// 构造函数接收具体策略对象
79
SortContext(SortStrategy* strategy) : strategy_(strategy) {}
80
81
// 设置或改变策略
82
void setStrategy(SortStrategy* strategy) {
83
// 注意:如果策略对象由 Context 管理,旧策略需要在这里销毁
84
// delete strategy_; // 如果 Context 拥有旧策略的话
85
strategy_ = strategy;
86
}
87
88
// 执行算法,委托给当前策略对象
89
void executeSort(std::vector<int>& data) const {
90
strategy_->sort(data);
91
}
92
93
~SortContext() {
94
// 注意:这里 Context 负责 strategy_ 的生命周期
95
delete strategy_;
96
}
97
};
98
99
// 客户端代码 (Client Code)
100
int main() {
101
std::vector<int> data = {5, 2, 8, 1, 9, 4};
102
103
// 使用冒泡排序策略
104
SortContext* context1 = new SortContext(new BubbleSort());
105
std::vector<int> data1 = data; // 复制数据以进行不同排序
106
context1->executeSort(data1);
107
std::cout << "排序结果 (Sorted result): ";
108
for (int x : data1) {
109
std::cout << x << " ";
110
}
111
std::cout << std::endl;
112
delete context1;
113
114
std::cout << "\n---\n";
115
116
// 使用标准库排序策略
117
SortContext* context2 = new SortContext(new StdSort());
118
std::vector<int> data2 = data; // 复制数据以进行不同排序
119
context2->executeSort(data2);
120
std::cout << "排序结果 (Sorted result): ";
121
for (int x : data2) {
122
std::cout << x << " ";
123
}
124
std::cout << std::endl;
125
delete context2;
126
127
return 0;
128
}
在这个例子中,SortContext
是环境,它并不直接实现排序算法,而是持有一个 SortStrategy
类型的指针。具体的排序算法(如 BubbleSort
和 StdSort
)是具体策略,它们都实现了 SortStrategy
接口。客户端通过向 SortContext
提供不同的具体策略对象来改变其行为。
④ 策略模式的优缺点:
▮▮▮▮⚝ 优点:
▮▮▮▮▮▮▮▮⚝ 提供了管理相关算法族的办法。
▮▮▮▮▮▮▮▮⚝ 避免了使用多重条件判断 (if-else 或 switch-case) 来选择算法,使得代码结构更清晰。
▮▮▮▮▮▮▮▮⚝ 使得算法的变化独立于使用它的客户端。
▮▮▮▮▮▮▮▮⚝ 易于扩展,增加新的算法只需添加新的具体策略类。
▮▮▮▮⚝ 缺点:
▮▮▮▮▮▮▮▮⚝ 客户端必须知道所有具体的策略类才能选择合适的策略。
▮▮▮▮▮▮▮▮⚝ 增加了系统的对象数量(每个具体策略都是一个类)。
▮▮▮▮▮▮▮▮⚝ 如果策略对象没有实例变量,所有客户端都可以共享同一个策略对象,可以考虑使用单例或享元模式优化。
应用场景:
▮▮▮▮⚝ 当一个对象有多种行为,并且这些行为可以在运行时根据条件进行切换时。
▮▮▮▮⚝ 当需要避免客户端使用复杂的条件语句来选择不同的行为时。
▮▮▮▮⚝ 需要提供一个算法族,并且客户端需要自由选择使用哪种算法时。
12.5 结合案例分析设计模式的应用
设计模式很少单独使用,它们常常组合起来解决更复杂的问题。理解模式的关键在于理解其意图和背后的设计原则。本节将讨论如何在实际案例中思考和应用设计模式,并与其他 C++ 特性结合。
12.5.1 模式的组合使用
在实际项目中,一个复杂的功能可能需要多种模式协同工作。例如:
▮▮▮▮⚝ 工厂方法 + 策略模式:可以使用工厂方法来创建具体的策略对象,从而将策略对象的创建过程与环境类解耦。例如,一个日志系统可以根据配置使用不同的输出策略(文件、控制台、数据库),通过一个工厂来创建这些策略实例。
▮▮▮▮⚝ 组合模式 + 装饰器模式:组合模式构建对象树,装饰器模式在运行时为树中的节点添加功能。例如,GUI 控件可以用组合模式构成树形结构,然后使用装饰器为特定的控件添加滚动条、边框等。
▮▮▮▮⚝ 观察者模式 + 单例模式:一个全局唯一的事件总线可以用单例模式实现,其他对象作为观察者订阅事件,事件源作为主体发布事件。
理解如何组合模式需要对每个模式的功能有深入理解,并分析它们如何弥补彼此的不足或增强彼此的优势。
12.5.2 如何在 C++ 中应用模式
C++ 作为一门多范式语言,其面向对象特性为实现设计模式提供了强大的支持,但也带来了一些需要注意的细节:
① 继承与多态的应用:大多数模式都依赖于继承来实现接口或抽象,依赖于多态来实现运行时行为的多样性(例如,工厂方法返回基类指针,实际是派生类对象;策略模式中环境类调用基类指针的虚函数)。虚析构函数 (Virtual Destructor) 在涉及多态和堆内存管理时至关重要,以避免内存泄漏。
② 资源管理与 RAII (Resource Acquisition Is Initialization):在设计模式中创建和管理对象(尤其是堆对象)时,结合 RAII 原则和智能指针 (std::unique_ptr
, std::shared_ptr
, std::weak_ptr
) 是 C++ 的最佳实践。例如,在工厂模式中返回智能指针可以简化客户端的内存管理;在观察者模式中,主体持有观察者的 weak_ptr
可以避免循环引用和解决生命周期问题。
③ 模板与泛型编程 (Generic Programming):C++ 的模板可以用来实现泛型设计模式,例如泛型工厂、泛型单例。模板在策略模式中也非常有用,可以将算法作为模板参数传递,实现静态多态 (Static Polymorphism),可能带来更好的性能(零开销抽象)。但这牺牲了运行时的灵活性。
④ Lambda 表达式与函数对象 (Function Object):对于行为型模式,特别是策略模式和命令模式,C++ 的 Lambda 表达式和函数对象可以作为轻量级的策略或命令实现,避免为简单的行为创建独立的类。这在 C++11 及以后版本中非常方便。
⑤ const 正确性 (Const Correctness):在设计模式的实现中,正确使用 const
关键字非常重要,可以提高代码的健壮性和可读性,明确哪些方法不改变对象状态。
12.5.3 案例分析思路与实践
在面对一个实际问题时,如何判断哪些地方可以使用设计模式?
① 识别共性问题:观察你的代码是否存在重复的结构、相似的逻辑或者难以维护、难以扩展的部分。例如:
▮▮▮▮⚝ 创建对象的方式很复杂,或者客户端需要了解太多创建细节?考虑创建型模式(工厂、建造者)。
▮▮▮▮⚝ 对象之间的接口不匹配,需要进行转换?考虑适配器模式。
▮▮▮▮⚝ 需要在运行时灵活地为对象添加功能?考虑装饰器模式。
▮▮▮▮⚝ 存在复杂的条件判断,根据不同的条件执行相似的操作但具体算法不同?考虑策略模式。
▮▮▮▮⚝ 一个对象的改变需要通知其他多个对象?考虑观察者模式。
② 分析上下文:考虑问题的具体约束和需求。性能要求高吗?需要高度的运行时灵活性吗?系统的复杂度如何?团队成员的经验水平如何?有时简单的解决方案比引入复杂模式更合适。
③ 从小处着手,逐步重构:不必一开始就设计得天衣无缝。可以先实现基本功能,然后随着对问题的理解加深和需求的演变,逐步将代码重构为更符合设计模式的结构。设计模式是演进的工具,而不是一开始就必须完美实现的蓝图。
④ 参考已有实现:C++ 标准库 (STL) 和许多流行的 C++ 库(如 Boost、Qt)中广泛使用了设计模式。学习这些库的源代码是学习模式在 C++ 中实际应用的极好方式。例如,STL 的迭代器 (Iterator
) 是迭代器模式的应用;std::function
和 std::bind
可以用于实现命令模式或策略模式;std::shared_ptr
的内部实现了引用计数,与某些版本的享元模式或代理模式相关。
⑤ 结合 SOLID 原则:设计模式往往是实现 SOLID 原则的手段。例如,策略模式和观察者模式体现了开放封闭原则和依赖倒置原则;单一职责原则指导我们将不同职责分离到不同的类中,这常常是应用模式(如工厂、策略)的前提。
12.5.4 案例示例:图形绘制系统回顾中的模式应用
回顾第 13 章可能提到的图形绘制系统(参阅章节 13.1)。在一个基于多态实现基本图形(如圆形、矩形)绘制的系统中,我们可以进一步应用设计模式:
▮▮▮▮⚝ 工厂模式:可以使用一个图形工厂来根据输入的字符串或枚举类型创建不同的图形对象,避免客户端代码直接 new Circle()
或 new Square()
。
▮▮▮▮⚝ 装饰器模式:可以为图形对象添加边框、填充颜色、阴影等装饰,例如 BorderedShape(Circle)
,FilledShape(Square)
,甚至 BorderedShape(FilledShape(Circle))
。
▮▮▮▮⚝ 组合模式:可以将多个图形组合成一个复合图形 CompositeShape
,使其能够被当做单个图形对待,例如移动或缩放整个组。
▮▮▮▮⚝ 策略模式:不同的图形可能有不同的绘制方式(例如,使用不同的图形库或不同的抗锯齿策略)。可以将绘制算法封装到策略对象中,由图形对象引用相应的策略。
▮▮▮▮⚝ 命令模式:图形编辑器的操作(如移动、旋转、改变颜色)可以封装成命令对象,支持撤销/重做功能。
通过这些模式的应用,图形绘制系统可以变得更加灵活、易于扩展,并且各部分职责更加清晰。
总之,设计模式是 C++ OOP 中重要的工具和思想财富。它们不是僵化的教条,而是经过实践检验的、灵活的解决方案。深入理解其原理、权衡其优缺点,并结合 C++ 的语言特性和现代编程实践(如 RAII、智能指针、模板),才能真正发挥模式的威力,写出高质量的 C++ 代码。不断学习和在实践中反思是掌握设计模式的关键。
1
## 13. 实战案例分析与高级实践
2
3
本章作为全书的最后一章,旨在将前面章节介绍的 C++ 面向对象编程 (OOP) 技术融会贯通,通过几个综合性的实战案例,展示如何在实际项目中应用这些知识。我们将深入分析如何运用继承 (Inheritance) 和多态 (Polymorphism) 构建灵活的系统,如何利用 RAII (Resource Acquisition Is Initialization) 和智能指针 (Smart Pointer) 安全高效地管理资源,以及如何结合模板 (Template) 实现泛型 (Generic) 的面向对象设计。此外,我们还将探讨在高性能场景下,如何权衡和优化 OOP 特性可能带来的性能开销。希望通过本章的学习,读者能够将理论知识转化为实践能力,更好地应对复杂的软件开发挑战。
4
5
### 13.1 构建一个基于多态的图形绘制系统
6
7
图形绘制是一个经典的面向对象应用的例子。不同的图形(如圆形、矩形、三角形)有不同的绘制方式,但它们都可以被视为“图形”的一种。使用多态 (Polymorphism),我们可以统一处理不同类型的图形对象,而无需知道它们的具体类型,从而实现系统的灵活性和可扩展性。
8
9
我们将构建一个简单的图形绘制系统,它能够存储多种图形,并以统一的方式调用它们的绘制方法。
10
11
① 问题定义:
12
▮▮▮▮需要一个系统来管理和绘制不同形状的图形,例如圆形、矩形、三角形。
13
▮▮▮▮系统应该能够轻松添加新的图形类型,而无需修改现有代码。
14
15
② OOP 解决方案:
16
▮▮▮▮定义一个抽象的基类 (Base Class) `Shape`,代表所有图形的共同概念。
17
▮▮▮▮在 `Shape` 类中声明一个纯虚函数 (Pure Virtual Function),例如 `draw()`,表示所有图形都“知道”如何绘制自己。
18
▮▮▮▮为每种具体的图形(如 `Circle`, `Rectangle`, `Triangle`)创建一个派生类 (Derived Class),继承自 `Shape`。
19
▮▮▮▮在每个派生类中实现 (Override) 基类中的 `draw()` 函数,提供具体的绘制逻辑。
20
▮▮▮▮使用基类指针或引用来管理图形对象集合,通过虚函数调用实现多态绘制。
21
22
③ 代码实现示例:
23
24
首先,定义基类 `Shape`:
25
26
```cpp
27
28
#include <iostream>
29
#include <vector>
30
#include <memory> // For std::unique_ptr
31
32
// 基类 Shape (Abstract Base Class - 抽象基类)
33
class Shape {
34
public:
35
// 构造函数和析构函数
36
Shape() { std::cout << "Shape Constructor" << std::endl; }
37
virtual ~Shape() { std::cout << "Shape Destructor" << std::endl; } // 虚析构函数 (Virtual Destructor) 是关键!
38
39
// 纯虚函数 draw() (Pure Virtual Function),表示这是一个抽象类
40
virtual void draw() const = 0;
41
42
// 非虚函数,所有派生类共享
43
void printInfo() const {
44
std::cout << "This is a generic shape." << std::endl;
45
}
46
};
接下来,定义派生类 Circle
和 Rectangle
:
1
// 派生类 Circle (Derived Class)
2
class Circle : public Shape {
3
private:
4
double radius; // 半径
5
public:
6
// 构造函数
7
Circle(double r) : radius(r) {
8
std::cout << "Circle Constructor with radius " << radius << std::endl;
9
}
10
// 析构函数
11
~Circle() override { // 使用 override 关键字 (C++11) 明确表示重写基类虚函数
12
std::cout << "Circle Destructor" << std::endl;
13
}
14
15
// 实现基类的纯虚函数 draw()
16
void draw() const override {
17
std::cout << "Drawing Circle with radius " << radius << std::endl;
18
}
19
20
// Circle 特有的方法
21
double getRadius() const { return radius; }
22
};
23
24
// 派生类 Rectangle (Derived Class)
25
class Rectangle : public Shape {
26
private:
27
double width; // 宽度
28
double height; // 高度
29
public:
30
// 构造函数
31
Rectangle(double w, double h) : width(w), height(h) {
32
std::cout << "Rectangle Constructor with width " << width << " and height " << height << std::endl;
33
}
34
// 析构函数
35
~Rectangle() override {
36
std::cout << "Rectangle Destructor" << std::endl;
37
}
38
39
// 实现基类的纯虚函数 draw()
40
void draw() const override {
41
std::cout << "Drawing Rectangle with width " << width << " and height " << height << std::endl;
42
}
43
44
// Rectangle 特有的方法
45
double getArea() const { return width * height; }
46
};
最后,在一个函数中使用多态来绘制不同图形:
1
// 使用多态绘制图形集合
2
void drawShapes(const std::vector<std::unique_ptr<Shape>>& shapes) {
3
std::cout << "\n--- Drawing Shapes ---" << std::endl;
4
for (const auto& shape_ptr : shapes) {
5
// 通过基类指针调用虚函数,实现动态多态
6
shape_ptr->draw();
7
// 也可以调用非虚函数
8
shape_ptr->printInfo();
9
}
10
std::cout << "--- Drawing Finished ---\n" << std::endl;
11
}
12
13
int main() {
14
// 创建一个 Shape 指针的容器
15
std::vector<std::unique_ptr<Shape>> drawing_board;
16
17
// 使用智能指针 (std::unique_ptr) 管理堆上的对象
18
// 将派生类对象存储到基类指针容器中 (向上转型 - Upcasting)
19
drawing_board.push_back(std::make_unique<Circle>(5.0));
20
drawing_board.push_back(std::make_unique<Rectangle>(4.0, 6.0));
21
drawing_board.push_back(std::make_unique<Circle>(2.5));
22
23
// 调用统一的绘制函数
24
drawShapes(drawing_board);
25
26
// unique_ptr 在超出作用域时会自动释放堆内存,调用对象的析构函数
27
// 由于基类析构函数是虚函数,会正确调用到派生类的析构函数 (虚析构函数的作用)
28
29
return 0;
30
}
④ 案例分析与要点:
⚝ 抽象基类与接口定义:Shape
类作为抽象基类,通过纯虚函数 draw()
定义了所有图形类必须遵循的“绘制”接口。它不能被实例化,只能作为其他类的基类。
⚝ 派生类与具体实现:Circle
和 Rectangle
是具体的派生类,它们实现了 draw()
接口,提供了各自独特的绘制逻辑。
⚝ 向上转型 (Upcasting):我们将 Circle
和 Rectangle
对象(通过 std::make_unique
创建在堆上并由 unique_ptr
管理)存储到 std::vector<std::unique_ptr<Shape>>
中,这是隐式的向上转型,将派生类指针转换为基类指针。
⚝ 动态多态 (Dynamic Polymorphism):在 drawShapes
函数中,尽管我们通过 Shape*
或 std::unique_ptr<Shape>
指针调用 draw()
方法,但实际执行的代码取决于指针实际指向的对象的类型(运行时确定)。这就是动态多态,通过虚函数表 (vtable) 实现。
⚝ 虚析构函数 (Virtual Destructor):在面向对象设计中,如果基类有虚函数,或者类可能被继承并在通过基类指针删除派生类对象,那么基类的析构函数必须声明为虚函数。否则,通过基类指针删除派生类对象时,只会调用基类的析构函数,导致派生类特有的资源泄露。在我们的例子中,~Shape()
是虚函数,确保 unique_ptr
销毁时能够正确调用到 Circle
或 Rectangle
的析构函数。
⚝ 可扩展性:如果需要添加新的图形类型(如 Triangle
),只需创建一个新的类继承 Shape
并实现 draw()
方法,无需修改 drawShapes
函数或其他现有代码,体现了开放封闭原则 (Open/Closed Principle) 的思想。
⚝ 资源管理:使用 std::unique_ptr
管理堆上的图形对象,确保了对象生命周期结束时资源的自动释放,避免了手动 delete
可能导致的错误或资源泄露,这是 RAII 原则的一种体现。
这个案例清晰地展示了如何利用继承和多态构建一个灵活、可扩展的系统,并结合现代 C++ 的智能指针进行安全的资源管理。
13.2 利用 RAII 管理网络连接或文件句柄
RAII (Resource Acquisition Is Initialization) 是 C++ 中一种重要的资源管理范式,其核心思想是将资源的生命周期与对象的生命周期绑定。当对象被创建时,资源被获取(Initialization);当对象被销毁时(通常通过析构函数),资源被自动释放(Acquisition)。这极大地简化了资源管理,并提供了强大的异常安全性保证。
本节将通过一个管理文件句柄 (File Handle) 的案例,深入探讨 RAII 的应用。传统 C 风格的文件操作需要手动打开和关闭文件,这容易在忘记关闭文件或发生异常时导致资源泄露。
① 问题定义:
▮▮▮▮在 C++ 中进行文件操作时,需要确保文件在不再需要时总是被关闭,即使在函数执行过程中发生错误或异常。
② 传统 C 风格的问题:
▮▮▮▮使用 FILE* file = fopen("...", "...")
打开文件。
▮▮▮▮进行文件读写操作。
▮▮▮▮必须手动调用 fclose(file)
关闭文件。
▮▮▮▮如果在 fclose
之前函数提前返回或者抛出异常,fclose
可能永远不会被调用,导致文件句柄泄露。
1
// 传统 C 风格文件操作的潜在问题
2
void processFile_c(const char* filename) {
3
FILE* file = fopen(filename, "r");
4
if (!file) {
5
// 处理错误
6
return;
7
}
8
9
// ... 执行文件操作 ...
10
// 如果这里发生异常或提前返回,fcloses 会被跳过!
11
12
fclose(file); // 容易被忘记或跳过
13
}
③ RAII 解决方案:
▮▮▮▮创建一个 C++ 类,其构造函数负责打开文件并获取文件句柄。
▮▮▮▮该类的析构函数负责关闭文件并释放句柄。
▮▮▮▮类的其他成员函数提供文件读写等操作。
▮▮▮▮用户只需创建该类的对象,文件的打开和关闭将由对象的生命周期自动管理。
④ RAII 类实现示例:
1
#include <cstdio> // For FILE*, fopen, fclose
2
#include <string>
3
#include <stdexcept> // For std::runtime_error
4
#include <iostream>
5
6
// RAII 风格的文件句柄管理器
7
class FileHandler {
8
private:
9
FILE* file_ptr; // 文件句柄 (File Handle)
10
std::string filename;
11
12
public:
13
// 构造函数:获取资源 (打开文件)
14
FileHandler(const std::string& fname, const char* mode) : filename(fname), file_ptr(nullptr) {
15
std::cout << "Attempting to open file: " << filename << std::endl;
16
file_ptr = fopen(filename.c_str(), mode);
17
if (!file_ptr) {
18
// 如果资源获取失败,在构造函数中抛出异常
19
throw std::runtime_error("Failed to open file: " + filename);
20
}
21
std::cout << "Successfully opened file: " << filename << std::endl;
22
}
23
24
// 析构函数:释放资源 (关闭文件)
25
~FileHandler() {
26
std::cout << "FileHandler Destructor called for file: " << filename << std::endl;
27
if (file_ptr) {
28
fclose(file_ptr);
29
std::cout << "Closed file: " << filename << std::endl;
30
}
31
}
32
33
// 禁用拷贝构造和拷贝赋值,防止资源被多次释放 (C++11+)
34
// 或者实现深拷贝/引用计数,但对于文件句柄通常更倾向于独占或共享管理
35
FileHandler(const FileHandler&) = delete; // 禁用拷贝构造
36
FileHandler& operator=(const FileHandler&) = delete; // 禁用拷贝赋值
37
38
// C++11 移动语义 (Move Semantics) 支持,允许资源所有权的转移
39
FileHandler(FileHandler&& other) noexcept
40
: file_ptr(other.file_ptr), filename(std::move(other.filename)) {
41
std::cout << "FileHandler Move Constructor called from: " << other.filename << std::endl;
42
other.file_ptr = nullptr; // 将源对象的文件句柄置空,防止源对象析构时关闭文件
43
}
44
45
FileHandler& operator=(FileHandler&& other) noexcept {
46
std::cout << "FileHandler Move Assignment called." << std::endl;
47
if (this != &other) {
48
// 先释放当前对象持有的资源
49
if (file_ptr) {
50
fclose(file_ptr);
51
std::cout << "Closed file in move assignment." << std::endl;
52
}
53
// 转移资源所有权
54
file_ptr = other.file_ptr;
55
filename = std::move(other.filename);
56
other.file_ptr = nullptr; // 置空源对象句柄
57
}
58
return *this;
59
}
60
61
62
// 提供访问底层资源的方法 (可选,取决于需求)
63
FILE* get() const {
64
return file_ptr;
65
}
66
67
// 文件操作示例方法
68
void writeLine(const std::string& line) {
69
if (file_ptr) {
70
fputs(line.c_str(), file_ptr);
71
fputc('\n', file_ptr);
72
} else {
73
throw std::runtime_error("Attempted to write to a closed or invalid file.");
74
}
75
}
76
77
// 检查文件是否有效
78
bool isValid() const { return file_ptr != nullptr; }
79
};
⑤ 使用 RAII 类进行文件操作:
1
void processFile_raii(const std::string& filename) {
2
try {
3
// FileHandler 对象创建,文件在构造函数中打开
4
FileHandler file(filename, "w");
5
6
// 使用文件对象进行操作
7
file.writeLine("Hello, RAII!");
8
file.writeLine("This is managed automatically.");
9
10
// 在函数结束时,无论正常返回还是抛出异常,file 对象的析构函数都会被调用
11
// 确保文件被关闭
12
std::cout << "File processing finished." << std::endl;
13
14
} catch (const std::runtime_error& e) {
15
// 捕获构造函数或成员函数可能抛出的异常
16
std::cerr << "Error: " << e.what() << std::endl;
17
// 即使发生异常,栈上的 file 对象也会被销毁,析构函数会关闭文件
18
}
19
// file 对象在此处超出作用域并销毁
20
}
21
22
int main() {
23
// 创建一个测试文件
24
processFile_raii("raii_example.txt");
25
26
std::cout << "\nBack in main." << std::endl;
27
28
// 示例:移动语义的使用
29
FileHandler file1("temp.txt", "w");
30
file1.writeLine("Original content.");
31
32
// 使用移动赋值,file2 接管了 file1 的文件句柄
33
FileHandler file2("another_temp.txt", "w"); // file2 打开另一个文件
34
file2 = std::move(file1); // file2 关闭 original_temp.txt,然后接管 file1 的句柄
35
36
// 此时 file1.file_ptr 是 nullptr
37
// file2 持有 raii_example.txt 的句柄
38
39
// file1 和 file2 在 main 结束时销毁
40
41
return 0;
42
}
⑥ 案例分析与要点:
⚝ 资源绑定:FileHandler
类将文件句柄(资源)绑定到 file_ptr
成员变量,其生命周期与 FileHandler
对象的生命周期一致。
⚝ 构造函数获取:在构造函数中执行资源获取操作 (fopen
)。如果在构造函数中获取资源失败(如文件不存在或无权限),通过抛出异常通知调用者,此时对象尚未完全构建,不会执行析构函数(C++ 保证已完成的子对象会正确销毁,但主对象本身视为构建失败)。
⚝ 析构函数释放:在析构函数中执行资源释放操作 (fclose
)。无论对象是如何销毁的(正常退出作用域、栈展开 (Stack Unwinding) 导致的异常),析构函数都会被调用,从而保证资源得到释放。
⚝ 异常安全性 (Exception Safety):这是 RAII 最重要的优势之一。即使在资源使用过程中发生异常,栈上的 RAII 对象会被自动销毁,其析构函数中的清理逻辑会被执行,避免资源泄露。
⚝ 禁止拷贝/支持移动:对于像文件句柄这样的独占性资源,简单的拷贝构造或赋值会导致两个对象持有同一个资源,当它们各自销毁时,资源会被释放两次,导致程序崩溃或未定义行为。通常通过 delete
拷贝构造函数和拷贝赋值运算符来禁用拷贝。为了支持对象的转移(如在函数间返回 RAII 对象或放入容器),C++11 引入了移动语义 (Move Semantics),通过移动构造函数和移动赋值运算符实现资源的有效转移,旧对象不再持有资源。
⚝ 通用性:RAII 不仅适用于文件句柄,还广泛应用于管理锁 (Lock)、网络套接字 (Socket)、数据库连接 (Database Connection)、动态分配的内存(智能指针就是 RAII 的典型应用)等任何需要在获取后保证释放的资源。
这个案例生动地展示了 RAII 如何利用 C++ 对象的生命周期机制,提供了一种健壮且易于使用的资源管理方式,是现代 C++ 开发中不可或缺的编程习惯。
13.3 使用智能指针处理复杂的对象生命周期
动态内存管理是 C++ 中一个容易出错的环节。传统的裸指针 (Raw Pointer) 需要手动 new
和 delete
,这可能导致内存泄露 (Memory Leak)(忘记 delete
)或重复释放 (Double Free)(多次 delete
同一个内存块)。智能指针 (Smart Pointer) 是 RAII 原则在内存管理上的应用,它们是封装了裸指针的类模板 (Class Template),在对象(智能指针本身)销毁时自动释放其管理的内存。
C++ 标准库提供了几种智能指针:
⚝ std::unique_ptr
:独占式所有权 (Exclusive Ownership)。同一时间只有一个 unique_ptr
可以指向某个对象。当 unique_ptr
被销毁时,它所指向的对象也会被销毁。它不能被拷贝,但可以被移动 (Move)。适用于需要明确表示独占资源的场景。
⚝ std::shared_ptr
:共享式所有权 (Shared Ownership)。多个 shared_ptr
可以指向同一个对象。shared_ptr
使用引用计数 (Reference Count) 机制,记录有多少个 shared_ptr
指向该对象。当最后一个指向该对象的 shared_ptr
被销毁时,对象才会被销毁。适用于对象需要被多个部分共享,且生命周期由这些共享者共同决定的场景。
⚝ std::weak_ptr
:弱引用 (Weak Reference)。weak_ptr
是一种不拥有对象所有权的智能指针。它指向由 shared_ptr
管理的对象,但不增加引用计数。主要用于解决 shared_ptr
可能导致的循环引用 (Circular Reference) 问题。
本节将重点探讨如何使用 shared_ptr
处理共享对象,并展示 weak_ptr
如何解决循环引用问题。
① 问题定义:
▮▮▮▮在某些复杂的对象关系中,多个对象可能需要共享访问另一个对象的生命周期,当所有共享者都不再需要该对象时,它才应该被销毁。
▮▮▮▮更进一步,如果对象之间存在相互引用(如父节点和子节点都持有对方的引用),使用 shared_ptr
可能导致循环引用,使得对象永远无法被销毁,造成内存泄露。
② 使用 shared_ptr
处理共享所有权:
考虑一个场景,一个文件系统中的文件可能被多个打开的句柄共享。
1
#include <iostream>
2
#include <string>
3
#include <vector>
4
#include <memory> // For std::shared_ptr, std::weak_ptr
5
6
// 模拟文件内容类
7
class FileContent {
8
private:
9
std::string content;
10
std::string name;
11
public:
12
FileContent(const std::string& n) : name(n) {
13
std::cout << "FileContent (" << name << ") created." << std::endl;
14
}
15
~FileContent() {
16
std::cout << "FileContent (" << name << ") destroyed." << std::endl;
17
}
18
void write(const std::string& data) { content += data; }
19
std::string read() const { return content; }
20
const std::string& getName() const { return name; }
21
};
22
23
// 模拟文件句柄类,每个句柄共享文件内容
24
class FileHandle {
25
private:
26
// 使用 shared_ptr 共享文件内容的生命周期
27
std::shared_ptr<FileContent> file_content;
28
public:
29
FileHandle(std::shared_ptr<FileContent> content) : file_content(content) {
30
std::cout << "FileHandle created for file: " << file_content->getName() << std::endl;
31
}
32
~FileHandle() {
33
std::cout << "FileHandle destroyed for file: " << file_content->getName() << std::endl;
34
}
35
void writeToFile(const std::string& data) {
36
if (file_content) { // 检查智能指针是否有效
37
file_content->write(data);
38
}
39
}
40
std::string readFromFile() const {
41
if (file_content) {
42
return file_content->read();
43
}
44
return "";
45
}
46
const std::string& getFileName() const { return file_content ? file_content->getName() : "Invalid Handle"; }
47
};
48
49
int main_shared_ptr() {
50
std::cout << "--- shared_ptr example ---" << std::endl;
51
{
52
// 创建文件内容对象,由第一个 shared_ptr 管理
53
std::shared_ptr<FileContent> shared_file = std::make_shared<FileContent>("my_document.txt");
54
// 引用计数为 1
55
56
// 创建两个文件句柄,它们都持有 shared_file 的 shared_ptr 副本
57
FileHandle handle1(shared_file); // 引用计数增加到 2
58
FileHandle handle2(shared_file); // 引用计数增加到 3
59
60
handle1.writeToFile("First line.\n");
61
handle2.writeToFile("Second line.\n");
62
63
std::cout << "File content after writes: " << handle1.readFromFile() << std::endl;
64
65
// shared_file, handle1, handle2 在这里超出作用域
66
} // 当最后一个 shared_ptr (shared_file) 超出作用域时,FileContent 对象才会被销毁
67
68
std::cout << "--- shared_ptr example finished ---\n" << std::endl;
69
return 0;
70
}
分析:在 main_shared_ptr
函数的作用域内,shared_file
, handle1.file_content
, handle2.file_content
都指向同一个 FileContent
对象。每当一个新的 shared_ptr
副本被创建(通过拷贝构造或赋值),引用计数加一;每当一个 shared_ptr
被销毁,引用计数减一。只有当引用计数归零时,被管理的 FileContent
对象才会被删除。这实现了多个“句柄”共享同一个文件内容的生命周期管理。
③ 使用 weak_ptr
解决循环引用:
考虑一个双向链表 (Doubly Linked List) 或树形结构中的父子关系,如果父节点持有指向子节点的 shared_ptr
,子节点也持有指向父节点的 shared_ptr
,就会形成循环引用。
1
#include <iostream>
2
#include <memory> // For std::shared_ptr, std::weak_ptr
3
#include <vector>
4
5
// 模拟节点类
6
class Node {
7
public:
8
std::string name;
9
std::shared_ptr<Node> child; // 父节点拥有子节点的强引用 (shared_ptr)
10
// std::shared_ptr<Node> parent; // 如果用 shared_ptr,会形成循环引用!
11
std::weak_ptr<Node> parent; // 使用 weak_ptr 指向父节点,不增加引用计数
12
13
Node(const std::string& n) : name(n) {
14
std::cout << "Node (" << name << ") created." << std::endl;
15
}
16
~Node() {
17
std::cout << "Node (" << name << ") destroyed." << std::endl;
18
}
19
20
// 获取父节点,返回 shared_ptr。需要先lock weak_ptr
21
std::shared_ptr<Node> getParent() const {
22
return parent.lock(); // lock() 方法尝试获取一个 shared_ptr
23
}
24
};
25
26
int main_circular_ref() {
27
std::cout << "--- Circular Reference Example ---" << std::endl;
28
{
29
std::shared_ptr<Node> nodeA = std::make_shared<Node>("Node A"); // RefCount(A) = 1
30
std::shared_ptr<Node> nodeB = std::make_shared<Node>("Node B"); // RefCount(B) = 1
31
32
// nodeA 持有 nodeB
33
nodeA->child = nodeB; // RefCount(B) = 2 (由 nodeA->child 和 nodeB 各持有一个)
34
35
// nodeB 持有 nodeA 的弱引用
36
nodeB->parent = nodeA; // RefCount(A) = 1 (weak_ptr 不增加引用计数)
37
38
std::cout << "Node A ref count: " << nodeA.use_count() << std::endl;
39
std::cout << "Node B ref count: " << nodeB.use_count() << std::endl;
40
41
// 尝试通过 nodeB 获取父节点 (nodeA)
42
if (auto parent_ptr = nodeB->getParent()) { // lock() 成功
43
std::cout << "Node B's parent is: " << parent_ptr->name << std::endl;
44
}
45
46
// nodeA 和 nodeB shared_ptr 在这里超出作用域
47
} // 当 nodeA 和 nodeB shared_ptr 超出作用域时:
48
// RefCount(A) 减 1 -> 0。 nodeA 被销毁。
49
// RefCount(B) 减 1 -> 1 (因为 nodeA->child 之前持有它)。
50
// 如果 nodeB->parent 是 shared_ptr,Refcount(A) 会是 2,导致 nodeA 不会被销毁。
51
// 现在因为 nodeB->parent 是 weak_ptr,RefCount(A) 归零,nodeA 销毁。
52
// nodeA 销毁时,其成员 child (持有 nodeB) 被销毁,导致 RefCount(B) 减 1 -> 0。
53
// RefCount(B) 归零,nodeB 被销毁。
54
// 两个对象都被正确销毁,没有内存泄露。
55
56
std::cout << "--- Circular Reference Example Finished ---\n" << std::endl;
57
return 0;
58
}
59
60
int main() {
61
main_shared_ptr();
62
main_circular_ref();
63
return 0;
64
}
分析:在 main_circular_ref
例子中,nodeA
通过 shared_ptr<Node> child
成员强引用 nodeB
,这使得只要 nodeA
存在,nodeB
的引用计数就不会降到 0。如果 nodeB
也通过一个 shared_ptr<Node> parent
强引用 nodeA
,那么 nodeA
和 nodeB
的引用计数将永远不会降到 0(因为它们各自的引用计数都至少是 1,由对方持有),即使外部所有指向它们的 shared_ptr
都已销毁。这就会导致它们永远不会被释放,形成内存泄露。
通过将 Node
类的 parent
成员改为 std::weak_ptr<Node>
,nodeB
指向 nodeA
的引用不再是强引用,不增加 nodeA
的引用计数。这样,当外部指向 nodeA
的最后一个 shared_ptr
(main
函数中的 nodeA
) 销毁时,nodeA
的引用计数会降到 0,nodeA
被销毁。nodeA
销毁时,其成员 child
(shared_ptr
指向 nodeB
) 也被销毁,导致 nodeB
的引用计数减一。如果此时 nodeB
的引用计数也变为 0,则 nodeB
也将被销毁。这样就打破了循环,避免了内存泄露。
在使用 weak_ptr
访问对象时,需要先调用其 lock()
方法,该方法返回一个 shared_ptr
。如果 weak_ptr
指向的对象仍然存在(即至少有一个 shared_ptr
仍然指向它),lock()
会返回一个有效的 shared_ptr
,此时该对象的引用计数会临时增加;如果对象已经不存在,lock()
返回一个空的 shared_ptr
。这种方式允许安全地访问可能已被销毁的对象。
智能指针,特别是 shared_ptr
和 weak_ptr
的组合,是管理复杂对象关系和生命周期、避免内存泄露的强大工具,是现代 C++ 中不可或缺的实践。
13.4 利用模板和 OOP 设计一个简单的容器
C++ 模板 (Template) 实现了泛型编程 (Generic Programming),允许我们编写不依赖于特定数据类型的代码。面向对象编程 (OOP) 则通过类和对象封装数据和行为,并利用继承和多态构建层次结构。将模板与 OOP 结合,可以创建出既通用又结构良好的代码,例如 C++ 标准库 (STL) 中的容器 (Container)(如 std::vector
, std::list
, std::map
)就是模板类,它们可以存储任何类型的元素,并提供面向对象风格的接口。
本节我们将设计一个简单的动态数组类模板 MyVector<T>
,展示如何结合模板和 OOP 概念。
① 问题定义:
▮▮▮▮需要一个动态数组,能够存储任意类型的数据,并且具有自动扩容、添加元素等基本功能。
② 解决方案:
▮▮▮▮定义一个类模板 MyVector<T>
,其中 T
是一个类型参数 (Type Parameter),代表存储元素的类型。
▮▮▮▮类的成员变量包括指向动态分配数组的指针、当前元素数量和数组容量。
▮▮▮▮构造函数负责初始化数组(可能分配初始容量)。
▮▮▮▮析构函数负责释放动态分配的内存(遵循 RAII 原则)。
▮▮▮▮提供 push_back
方法向数组末尾添加元素,并在容量不足时自动扩容。
▮▮▮▮提供 size
和 capacity
方法查询当前元素数量和容量。
▮▮▮▮提供运算符重载 (Operator Overloading),如 operator[]
,用于访问元素。
▮▮▮▮实现拷贝构造函数和拷贝赋值运算符,或者禁用它们并提供移动语义支持,以正确处理资源所有权。
③ 类模板实现示例:
1
#include <iostream>
2
#include <algorithm> // For std::copy, std::move
3
4
// 一个简单的动态数组类模板 (Class Template)
5
template <typename T> // T 是类型参数
6
class MyVector {
7
private:
8
T* data; // 指向动态分配内存的指针
9
size_t current_size; // 当前元素数量 (Current Size)
10
size_t current_capacity; // 当前数组容量 (Current Capacity)
11
12
// 扩容私有函数
13
void resize(size_t new_capacity) {
14
if (new_capacity <= current_capacity) return; // 新容量必须大于当前容量
15
16
std::cout << "Resizing from " << current_capacity << " to " << new_capacity << std::endl;
17
T* new_data = new T[new_capacity]; // 分配新内存
18
19
// 拷贝或移动旧数据到新内存
20
// std::copy(data, data + current_size, new_data); // 拷贝
21
// 对于支持移动语义的类型,优先使用移动
22
for (size_t i = 0; i < current_size; ++i) {
23
new_data[i] = std::move(data[i]); // 移动
24
}
25
26
27
delete[] data; // 释放旧内存
28
29
data = new_data;
30
current_capacity = new_capacity;
31
}
32
33
public:
34
// 构造函数
35
explicit MyVector(size_t initial_capacity = 10)
36
: data(nullptr), current_size(0), current_capacity(0) {
37
if (initial_capacity > 0) {
38
resize(initial_capacity);
39
}
40
std::cout << "MyVector Constructor. Capacity: " << current_capacity << std::endl;
41
}
42
43
// 析构函数:释放资源
44
~MyVector() {
45
std::cout << "MyVector Destructor. Capacity: " << current_capacity << std::endl;
46
delete[] data; // 释放动态分配的内存
47
}
48
49
// 拷贝构造函数 (Copy Constructor)
50
MyVector(const MyVector& other)
51
: data(nullptr), current_size(other.current_size), current_capacity(other.current_capacity) {
52
if (current_capacity > 0) {
53
data = new T[current_capacity];
54
// 拷贝所有元素
55
std::copy(other.data, other.data + current_size, data);
56
}
57
std::cout << "MyVector Copy Constructor. Capacity: " << current_capacity << std::endl;
58
}
59
60
// 拷贝赋值运算符 (Copy Assignment Operator)
61
MyVector& operator=(const MyVector& other) {
62
std::cout << "MyVector Copy Assignment Operator." << std::endl;
63
if (this != &other) { // 防止自赋值
64
// 释放当前资源
65
delete[] data;
66
67
// 拷贝其他对象的状态
68
current_size = other.current_size;
69
current_capacity = other.current_capacity;
70
71
// 分配新内存并拷贝数据
72
if (current_capacity > 0) {
73
data = new T[current_capacity];
74
std::copy(other.data, other.data + current_size, data);
75
} else {
76
data = nullptr;
77
}
78
}
79
return *this;
80
}
81
82
// 移动构造函数 (Move Constructor) (C++11+)
83
MyVector(MyVector&& other) noexcept
84
: data(other.data), current_size(other.current_size), current_capacity(other.current_capacity) {
85
// 将源对象的状态“偷”过来,并让源对象处于可析构的安全状态
86
other.data = nullptr;
87
other.current_size = 0;
88
other.current_capacity = 0;
89
std::cout << "MyVector Move Constructor. Capacity: " << current_capacity << std::endl;
90
}
91
92
// 移动赋值运算符 (Move Assignment Operator) (C++11+)
93
MyVector& operator=(MyVector&& other) noexcept {
94
std::cout << "MyVector Move Assignment Operator." << std::endl;
95
if (this != &other) { // 防止自赋值
96
// 释放当前资源
97
delete[] data;
98
99
// 转移资源所有权
100
data = other.data;
101
current_size = other.current_size;
102
current_capacity = other.current_capacity;
103
104
// 将源对象置空
105
other.data = nullptr;
106
other.current_size = 0;
107
other.current_capacity = 0;
108
}
109
return *this;
110
}
111
112
113
// 添加元素到末尾
114
void push_back(const T& value) {
115
if (current_size == current_capacity) {
116
// 如果容量不足,扩容
117
resize(current_capacity == 0 ? 1 : current_capacity * 2); // 容量翻倍或设为1
118
}
119
data[current_size++] = value; // 在当前末尾位置添加元素
120
std::cout << "Added element. Size: " << current_size << ", Capacity: " << current_capacity << std::endl;
121
}
122
123
// 使用右值引用支持移动插入 (C++11+)
124
void push_back(T&& value) {
125
if (current_size == current_capacity) {
126
resize(current_capacity == 0 ? 1 : current_capacity * 2);
127
}
128
data[current_size++] = std::move(value); // 使用移动语义
129
std::cout << "Added element (move). Size: " << current_size << ", Capacity: " << current_capacity << std::endl;
130
}
131
132
133
// 访问元素的运算符[]
134
T& operator[](size_t index) {
135
if (index >= current_size) {
136
throw std::out_of_range("Index out of bounds");
137
}
138
return data[index];
139
}
140
141
const T& operator[](size_t index) const {
142
if (index >= current_size) {
143
throw std::out_of_range("Index out of bounds");
144
}
145
return data[index];
146
}
147
148
// 获取当前元素数量
149
size_t size() const {
150
return current_size;
151
}
152
153
// 获取当前容量
154
size_t capacity() const {
155
return current_capacity;
156
}
157
158
// 迭代器支持 (简化版,只提供指针)
159
T* begin() { return data; }
160
const T* begin() const { return data; }
161
T* end() { return data + current_size; }
162
const T* end() const { return data + current_size; }
163
};
164
165
int main() {
166
std::cout << "--- MyVector Template Example ---" << std::endl;
167
168
// 实例化一个存储 int 的 MyVector
169
MyVector<int> int_vec;
170
int_vec.push_back(10);
171
int_vec.push_back(20);
172
int_vec.push_back(30);
173
174
std::cout << "int_vec size: " << int_vec.size() << std::endl;
175
std::cout << "int_vec[0]: " << int_vec[0] << std::endl;
176
177
// 实例化一个存储 std::string 的 MyVector
178
MyVector<std::string> str_vec;
179
str_vec.push_back("Hello");
180
str_vec.push_back("World");
181
str_vec.push_back(std::string("C++")); // 使用右值引用版本的 push_back
182
183
std::cout << "str_vec size: " << str_vec.size() << std::endl;
184
std::cout << "str_vec[1]: " << str_vec[1] << std::endl;
185
186
// 拷贝构造示例
187
MyVector<int> int_vec_copy = int_vec; // 调用拷贝构造函数
188
std::cout << "int_vec_copy size: " << int_vec_copy.size() << std::endl;
189
190
// 移动赋值示例
191
MyVector<std::string> str_vec_move;
192
str_vec_move = std::move(str_vec); // 调用移动赋值运算符
193
194
std::cout << "str_vec size after move: " << str_vec.size() << " (usually 0 or valid but unspecified state)" << std::endl;
195
std::cout << "str_vec_move size: " << str_vec_move.size() << std::endl;
196
197
198
// 遍历 MyVector
199
std::cout << "Elements in int_vec_copy:";
200
for (int val : int_vec_copy) { // 使用范围-based for 循环 (Range-based for loop) (C++11)
201
std::cout << " " << val;
202
}
203
std::cout << std::endl;
204
205
206
std::cout << "--- MyVector Template Example Finished ---\n" << std::endl;
207
return 0;
208
}
④ 案例分析与要点:
⚝ 类模板定义:template <typename T> class MyVector { ... };
定义了一个类模板,T
是一个占位符,在使用时会被具体的类型替换(如 int
, std::string
)。
⚝ 实例化 (Instantiation):当代码中使用 MyVector<int>
或 MyVector<std::string>
时,编译器会根据模板定义生成针对 int
或 std::string
的具体类代码,这个过程称为实例化。
⚝ OOP 特性:
▮▮▮▮封装 (Encapsulation):数据成员 (data
, current_size
, current_capacity
) 被声明为 private
,外部只能通过公有的成员函数(如 push_back
, size
, operator[]
)访问和修改它们。
▮▮▮▮RAII:构造函数 (MyVector()
) 负责分配内存(获取资源),析构函数 (~MyVector()
) 负责释放内存。这确保了 MyVector
对象生命周期结束时资源的自动清理。
▮▮▮▮运算符重载 (Operator Overloading):重载 operator[]
使得可以使用 int_vec[0]
这样的语法访问元素,提高了代码的可读性和便利性。
▮▮▮▮特殊成员函数:正确实现了拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符(或禁用拷贝并提供移动),这对于管理动态内存的类至关重要,遵循了“大三原则”或“大五原则”。这些函数确保在对象拷贝或移动时,底层资源(动态分配的数组)也能得到正确的处理,避免了浅拷贝 (Shallow Copy) 问题。
⚝ 泛型编程 (Generic Programming):通过模板,同一个 MyVector
代码可以用于存储不同类型的元素,实现了代码的复用性,减少了重复编写不同类型容器代码的工作。
⚝ 与 STL 对比:这个简单的 MyVector
远不如 std::vector
完善,标准库的 std::vector
提供了更全面的功能、更精细的内存管理、迭代器、异常安全保证、以及对各种类型(包括不完整类型和带有复杂构造/析构的类型)的良好支持。但通过实现这个简化版,可以帮助理解模板类的工作原理以及如何将其与 OOP 结合。
这个案例展示了模板作为 C++ 泛型编程的核心特性,如何与面向对象设计相结合,创建出能够处理多种数据类型的通用且结构化的类,是理解和使用 STL 等高级库的基础。
13.5 性能优化与 OOP
C++ 以其高性能而闻名,这使得它成为系统编程、游戏开发、高性能计算等领域的首选语言。然而,一些面向对象特性(如虚函数)在提供设计灵活性的同时,可能会引入一定的运行时开销。本节将探讨 C++ OOP 特性可能对性能产生的影响,以及在追求高性能的应用中如何进行权衡和优化。
① 潜在的性能开销:
⚝ 虚函数调用 (Virtual Function Call):
▮▮▮▮当通过基类指针或引用调用虚函数时,编译器无法在编译时确定具体要执行哪个函数,需要在运行时通过对象的虚函数表 (vtable) 查找实际的函数地址。
▮▮▮▮这个查找过程引入了一个小的间接调用开销,相比非虚函数或静态调用的直接跳转,会稍微慢一些。
▮▮▮▮在大量、频繁的虚函数调用场景下,这个开销可能会变得可观。
▮▮▮▮虚函数的存在还会导致对象存储额外的虚指针 (vptr)(通常占用 8 个字节或更多),增加了对象的内存开销,可能影响缓存效率。
⚝ 动态内存分配 (Dynamic Memory Allocation):
▮▮▮▮在堆 (Heap) 上使用 new
创建对象(例如通过智能指针管理的对象)比在栈 (Stack) 上创建对象开销更大。堆分配涉及到查找合适的内存块、更新内存管理数据结构等操作,速度慢于简单的栈指针移动。
▮▮▮▮频繁的堆分配和释放还可能导致内存碎片 (Memory Fragmentation)。
⚝ 对象切片 (Object Slicing):
▮▮▮▮当派生类对象按值传递给期望基类对象的函数参数时,派生类对象会被“切片”,只拷贝基类部分的成员。这丢失了派生类特有的数据,而且涉及一次对象拷贝,可能产生不必要的开销。
▮▮▮▮例如:
1
class Base { int b; };
2
class Derived : public Base { int d; };
3
void func(Base obj) { /* obj 只有 b,d 被切掉了 */ }
4
Derived d_obj;
5
func(d_obj); // 对象切片和拷贝发生
⚝ 复杂继承层次和多重继承:
▮▮▮▮过于复杂的继承层次会增加代码的理解和维护难度,可能间接影响性能优化(难以分析调用路径)。
▮▮▮▮多重继承 (Multiple Inheritance) 可能导致更复杂的 vtable 结构和对象布局,增加访问成员的开销(尽管现代编译器优化得很好,开销通常很小)。
② 优化策略与实践:
⚝ 明智地使用虚函数:
▮▮▮▮并非所有成员函数都需要是虚函数。只有那些需要在运行时表现出多态行为的函数才应该声明为虚函数。
▮▮▮▮考虑使用非虚接口 (Non-Virtual Interface, NVI) 设计模式:公有非虚函数调用私有或保护的虚函数。这可以在非虚函数中实现一些通用逻辑(如前置/后置处理、加锁/解锁),再委托给虚函数执行具体类型相关的操作。
▮▮▮▮对于只需要静态多态 (Static Polymorphism) 的场景(编译时确定类型),可以考虑使用函数重载 (Function Overloading) 或模板 (Template)(如 CRTP - Curiously Recurring Template Pattern),这些方式通常没有运行时开销。
⚝ 优化内存管理:
▮▮▮▮优先在栈上创建对象(如果对象不大且生命周期局限在当前作用域)。
▮▮▮▮对于小而生命周期短的对象,频繁的 new/delete
可以考虑使用对象池 (Object Pool) 或竞技场分配器 (Arena Allocator) 来减少系统调用和内存管理开销。
▮▮▮▮对于需要动态分配的对象,优先使用智能指针(unique_ptr
, shared_ptr
)而非裸指针,虽然智能指针本身有少量开销(如引用计数),但它们提供了异常安全性和自动管理,长期来看更安全高效。注意 shared_ptr
的引用计数更新是原子操作(在多线程环境下),可能带来同步开销。如果确定是单线程独占,可以使用非原子的 shared_ptr
实现(某些库提供,或 C++20 的 std::atomic<std::shared_ptr>
)。
⚝ 避免对象切片:
▮▮▮▮向函数传递多态对象时,总是通过基类指针或引用传递(通常是 const&
或 *
),而不是通过值传递。
⚝ 使用移动语义 (Move Semantics):
▮▮▮▮利用 C++11 引入的右值引用 (Rvalue Reference) 和移动语义,可以避免不必要的深拷贝 (Deep Copy),尤其是在对象包含大量资源(如动态数组、文件句柄)时。当临时对象或明确表示不再需要的对象被传递时,可以通过“移动”资源所有权而不是复制资源来提高效率。
⚝ 考虑内联 (Inlining):
▮▮▮▮对于小型的成员函数,可以考虑声明为 inline
函数。这建议编译器将函数体直接插入到调用点,消除函数调用的开销。但过度内联可能导致代码膨胀,反而降低缓存效率。现代编译器通常能做出最优判断,inline
更多是给编译器的建议。
⚝ 数据导向设计 (Data-Oriented Design):
▮▮▮▮在某些极端性能敏感的场景,传统的 OOP 设计(将数据和行为紧密绑定在对象中,对象分散在内存各处)可能不如将相关数据集中存储(例如,使用结构体数组或面向结构的数组),并对数据块进行批量处理的“数据导向设计”高效。这是因为集中存储有利于提高缓存命中率和向量化处理。这并非否定 OOP,而是在特定性能瓶颈处的一种权衡或补充。
⚝ 性能分析 (Profiling):
▮▮▮▮不要过早优化 (Premature Optimization Is the Root of All Evil)。在进行任何性能优化之前,首先使用性能分析工具 (Profiler) 找出程序的真正瓶颈所在。很多时候,性能问题并不在于虚函数或堆分配等微观开销,而在于不恰当的算法、频繁的 I/O 操作或锁竞争等宏观设计问题。
将性能优化融入 OOP 设计中,需要在设计灵活性、代码可维护性与运行时效率之间找到平衡。理解不同 OOP 特性背后的实现机制及其潜在开销,结合具体的应用场景和性能分析结果,才能做出明智的决策。
总结来说,C++ 的面向对象技术为构建复杂、可维护和可扩展的软件提供了强大的工具。通过本章的案例分析和高级实践探讨,我们深入了解了如何在实际项目中有效应用这些技术,包括多态的设计模式、安全的资源管理、泛型容器的构建,以及在追求高性能时需要考虑的因素和优化手段。掌握这些知识,将有助于您写出更健壮、高效且易于维护的 C++ 代码。
Appendix A: C++ 标准版本与 OOP 特性演进
在软件开发的漫长历史中,编程语言持续演进以适应更复杂的挑战和提高开发效率。C++ 作为一门强大的系统级编程语言,其标准化进程从未停止,每个新的标准版本都引入了许多重要的特性。这些特性不仅丰富了语言本身,也深刻地影响了 C++ 中面向对象编程 (OOP) 的实践、效率和安全性。本附录旨在简要回顾 C++ 标准化历程中与 OOP 密切相关的关键特性,帮助读者理解现代 C++ 如何更好地支持和提升面向对象的设计与实现。
通过了解这些演进,读者可以更好地理解为何某些旧有的编程习惯被废弃,以及为何引入了像智能指针 (Smart Pointer)、移动语义 (Move Semantics) 这样的机制。这对于编写现代、高效且安全的 C++ 面向对象代码至关重要。
Appendix A1: C++11 标准 (ISO/IEC 14882:2011)
C++11 是一个里程碑式的标准版本,引入了大量新特性,极大地提升了语言的现代性和 expressiveness。许多特性直接或间接影响了 OOP 的实践。
⚝ 移动语义 (Move Semantics) 和右值引用 (Rvalue Reference)
▮▮▮▮⚝ 核心概念:移动语义允许资源(如动态分配的内存、文件句柄)从一个对象“移动”到另一个对象,而不是进行昂贵的拷贝。这对于包含大量资源的类的拷贝操作尤其重要。右值引用 &&
是支持移动语义的语言机制,它绑定到一个临时对象(右值)或通过 std::move
转换的对象。
▮▮▮▮⚝ OOP 影响:需要在类中实现移动构造函数 (Move Constructor) 和移动赋值运算符 (Move Assignment Operator)。这通常遵循“三/五/零法则” (Rule of Three/Five/Zero),要求类如果需要自定义析构函数、拷贝构造函数或拷贝赋值运算符,通常也需要自定义移动构造函数和移动赋值运算符,或者显式地禁用它们。这使得包含动态资源的类更加高效且易于实现“浅拷贝” (shallow copy) 后进行资源转移。
⚝ 智能指针 (Smart Pointer)
▮▮▮▮⚝ 核心概念:C++11 引入了 std::unique_ptr
、std::shared_ptr
和 std::weak_ptr
三种智能指针,用于自动化内存管理,遵循 RAII (Resource Acquisition Is Initialization) 原则。
▮▮▮▮⚝ OOP 影响:
▮▮▮▮▮▮▮▮⚝ std::unique_ptr
:表示独占所有权。非常适合在类成员中管理动态分配的对象,确保对象生命周期与智能指针绑定,避免内存泄露。
▮▮▮▮▮▮▮▮⚝ std::shared_ptr
:表示共享所有权,使用引用计数 (Reference Counting)。适用于多个对象需要共享管理同一个动态对象的场景。
▮▮▮▮▮▮▮▮⚝ std::weak_ptr
:配合 std::shared_ptr
使用,解决循环引用 (Cyclic Reference) 问题,避免内存泄露。
▮▮▮▮⚝ 智能指针是实现 RAII 的重要工具,极大地简化了 C++ 中对象的资源管理,提高了代码的健壮性和安全性,尤其是在涉及多态和动态对象创建时。
⚝ nullptr
▮▮▮▮⚝ 核心概念:引入 nullptr
关键字作为空指针常量,代替了 C 风格的 NULL
或 0
。
▮▮▮▮⚝ OOP 影响:解决了函数重载时 NULL
可能被误解为整数 0
的问题,使得空指针的表达更加清晰和类型安全,减少了与指针相关的错误。
⚝ override
和 final
关键字
▮▮▮▮⚝ 核心概念:
▮▮▮▮▮▮▮▮⚝ override
:用于标记派生类中意图覆盖 (override) 基类虚函数 (virtual function) 的成员函数。如果标记了 override
的函数实际上没有覆盖任何基类虚函数(例如,函数签名不匹配),编译器会报错。
▮▮▮▮▮▮▮▮⚝ final
:用于标记一个虚函数不能在派生类中进一步覆盖,或者一个类不能被继承。
▮▮▮▮⚝ OOP 影响:极大地增强了继承和多态使用的安全性。override
帮助捕获因签名错误导致的虚函数覆盖失败,而 final
允许设计者明确控制继承层次结构的终止,提高了代码的可读性和可维护性。
⚝ 委托构造函数 (Delegating Constructor)
▮▮▮▮⚝ 核心概念:允许一个构造函数调用同一个类的另一个构造函数来完成初始化任务。
▮▮▮▮⚝ OOP 影响:减少了构造函数之间的代码重复,提高了代码的可读性和维护性,特别是在类有多个构造函数时。
⚝ 类内成员初始化 (In-class Member Initializer)
▮▮▮▮⚝ 核心概念:允许在类定义中直接为非静态成员变量提供默认的初始值。
▮▮▮▮⚝ OOP 影响:简化了构造函数的实现,特别是在多个构造函数需要以相同默认值初始化某些成员时。减少了冗余代码。
⚝ 显式转换运算符 (Explicit Conversion Operator)
▮▮▮▮⚝ 核心概念:允许使用 explicit
关键字修饰转换运算符,禁止隐式类型转换,只允许显式类型转换。
▮▮▮▮⚝ OOP 影响:增强了类型转换的控制力,避免了潜在的意外或危险的隐式类型转换,提高了代码的安全性。
⚝ 范围基于的 for 循环 (Range-based For Loop)
▮▮▮▮⚝ 核心概念:一种更简洁的循环语法,用于遍历容器或其他序列的元素。
▮▮▮▮⚝ OOP 影响:虽然不是直接的 OOP 特性,但它使得遍历对象集合的代码更加简洁易读,提升了使用容器或自定义集合类的体验。
Appendix A2: C++14 标准 (ISO/IEC 14882:2014)
C++14 是 C++11 的一个补充版本,主要是一些小改进和 bug 修复,但也包含了一些方便 OOP 开发的特性。
⚝ 泛型 Lambda (Generic Lambda)
▮▮▮▮⚝ 核心概念:允许 Lambda 表达式的参数使用 auto
关键字,使其成为一个函数对象 (Function Object) 模板。
▮▮▮▮⚝ OOP 影响:使得 Lambda 表达式在需要处理多种类型时更加灵活,常与标准库算法配合使用,处理不同类型的对象集合。
⚝ 成员初始化中的推导返回类型 (Deduced Return Type for Member Functions)
▮▮▮▮⚝ 核心概念:允许成员函数(包括 Lambda)的返回类型由 return
语句推导。
▮▮▮▮⚝ OOP 影响:简化了成员函数的定义,特别是在返回复杂类型或模板类型时。
⚝ 变量模板 (Variable Template)
▮▮▮▮⚝ 核心概念:允许定义模板变量。
▮▮▮▮⚝ OOP 影响:可以在模板化类或函数中使用模板变量,提高泛型代码的灵活性。
Appendix A3: C++17 标准 (ISO/IEC 14882:2017)
C++17 带来了许多实用的新特性,其中一些对 OOP 编程模式产生了影响。
⚝ 结构化绑定 (Structured Binding)
▮▮▮▮⚝ 核心概念:允许将一个对象(如结构体 (struct)、类 (class) 或数组 (array))的成员或元素解包 (unpack) 到一组独立的变量中。
▮▮▮▮⚝ OOP 影响:对于返回多个值(例如,通过返回结构体或 std::tuple
)的成员函数,结构化绑定使得接收这些返回值的代码更简洁。对于具有公共成员的简单数据类,结构化绑定提供了方便的访问方式。
1
// 示例:结构化绑定用于解包类对象 (需要类有公共成员或使用 std::tuple_size/get 特化)
2
struct Point { int x, y; };
3
4
// 假设有一个函数返回 Point 对象
5
Point getOrigin() { return {0, 0}; }
6
7
// 使用结构化绑定接收返回值
8
auto [origin_x, origin_y] = getOrigin();
⚝ if constexpr
▮▮▮▮⚝ 核心概念:在编译时根据条件进行分支判断,只有满足条件的代码块才会被编译。主要用于模板编程。
▮▮▮▮⚝ OOP 影响:在处理涉及类型特征 (type traits) 或 SFINAE (Substitution Failure Is Not An Error) 的复杂模板化类或函数时,if constexpr
提供了更清晰、更易读的编译时条件逻辑,可以根据对象或类型的特性在编译时选择不同的实现路径。
1
template<typename T>
2
void processObject(T& obj) {
3
if constexpr (std::is_pointer_v<T>) {
4
// 编译时检查 T 是否是指针
5
if (obj) obj->doSomething();
6
} else {
7
// 如果 T 不是指针,调用成员函数
8
obj.doSomethingElse();
9
}
10
}
⚝ 类模板参数推导 (Class Template Argument Deduction, CTAD)
▮▮▮▮⚝ 核心概念:允许在创建类模板对象时省略模板参数,编译器可以根据构造函数的参数自动推导出模板参数。
▮▮▮▮⚝ OOP 影响:简化了类模板的使用语法,使得实例化像 std::vector
、std::pair
这样的容器或工具类模板时更加简洁,例如 std::pair p(1, 2.0);
代替 std::pair<int, double> p(1, 2.0);
。
⚝ std::optional
, std::variant
, std::any
▮▮▮▮⚝ 核心概念:这些类模板提供了现代 C++ 中处理可选值、变体类型和任意类型的方式。
▮▮▮▮⚝ OOP 影响:
▮▮▮▮▮▮▮▮⚝ std::optional<T>
:表示一个可能包含 T
类型值或为空的对象。是处理函数可能“失败”返回一个值(例如,查找操作未找到)的优雅方式,避免使用特殊的返回值(如空指针或魔术数字)。
▮▮▮▮▮▮▮▮⚝ std::variant<Types...>
:表示一个对象可以持有指定的类型列表中的 任意一个 类型的值(类似联合体 union,但更安全,支持非平凡类型)。它提供了一种安全的、类型检查的方式来处理异构数据,是多态的一种替代或补充。
▮▮▮▮▮▮▮▮⚝ std::any
:表示一个对象可以持有 任何 类型的单个值。提供了更大的灵活性,但也牺牲了部分类型安全性(需要运行时检查)。
▮▮▮▮⚝ 这些工具类使得在 OOP 设计中处理不确定性、异构性和运行时类型变化更加方便和安全,减少了对原始指针、void* 或繁琐类型判断的需求。
⚝ 强制拷贝省略 (Guaranteed Copy Elision)
▮▮▮▮⚝ 核心概念:在某些特定情况下(主要是涉及返回临时对象和按值返回时),编译器 必须 省略拷贝或移动构造函数,直接在目标位置构造对象。
▮▮▮▮⚝ OOP 影响:虽然拷贝/移动省略一直是 C++ 的优化手段,但 C++17 保证了在特定场景下一定会发生。这影响了对对象生命周期和构造函数调用次数的某些假设,有时可能需要调整代码以适应强制省略的行为(尽管通常是朝着更好的性能方向)。
Appendix A4: C++20 标准 (ISO/IEC 14882:2020)
C++20 引入了更多重要的新特性,其中一些对 C++ 的泛型编程和大型项目结构产生了深远影响,进而也影响了 OOP 的实践。
⚝ 概念 (Concepts)
▮▮▮▮⚝ 核心概念:概念为模板参数定义了约束 (Constraint),使得模板接口更加清晰,编译器错误信息更友好。你可以定义一个概念,比如 Printable
或 Sortable
,然后约束模板参数必须满足这个概念的要求。
▮▮▮▮⚝ OOP 影响:概念虽然主要用于泛型编程,但它与多态有着理念上的联系——它们都关注接口。概念定义的是 编译时 接口或要求,而多态通过虚函数定义 运行时 接口。在设计模板化的类(如容器、算法)时,概念允许你明确地表达对类参数的“接口”期望(即它需要支持哪些操作),提高了泛型代码的可理解性和可用性,这对于构建通用的、与具体类型解耦的面向对象组件非常有用。
1
// 示例:定义一个概念 RequiresArithmetic,要求类型是算术类型
2
template<typename T>
3
concept RequiresArithmetic = std::is_arithmetic_v<T>;
4
5
template<RequiresArithmetic T> // 使用概念约束模板参数 T
6
class ValueWrapper {
7
T value;
8
public:
9
ValueWrapper(T v) : value(v) {}
10
// ... 其他成员函数
11
};
12
13
// ValueWrapper<int> 是有效的,但 ValueWrapper<std::string> 会导致编译错误
⚝ 模块 (Modules)
▮▮▮▮⚝ 核心概念:模块旨在取代传统的头文件 (.h
/.hpp) 和源文件 (
.cpp`) 的编译模式。模块提供了更清晰的接口导出和导入机制,可以显著提高编译速度,并解决宏、循环依赖等头文件机制带来的问题。
▮▮▮▮⚝ OOP 影响:模块改变了我们组织和使用类、函数等代码的方式。类的接口可以更清晰地通过模块导出,内部实现可以完全隐藏。这强化了封装性,并改善了大型面向对象项目的编译管理。
⚝ 三路比较运算符 (Three-way Comparison Operator) 或飞船运算符 (<=>
)
▮▮▮▮⚝ 核心概念:引入 operator<=>
,可以一次性定义类型的顺序比较规则。编译器可以根据 operator<=>
自动生成 ==
, !=
, <
, >
, <=
, >=
等比较运算符。
▮▮▮▮⚝ OOP 影响:极大地简化了需要实现全套比较运算符的类的实现,减少了冗余代码和潜在错误,提高了代码的可维护性。
1
struct Point {
2
int x, y;
3
// 使用 default 关键字让编译器生成 operator<=>
4
auto operator<=>(const Point&) const = default;
5
// 编译器也会自动生成 operator==
6
};
⚝ 基于范围的 for 循环初始化 (Initializer in Range-based For Loop)
▮▮▮▮⚝ 核心概念:允许在范围基于的 for 循环中包含一个初始化语句。
▮▮▮▮⚝ OOP 影响:可以在循环开始前创建并初始化一个对象,该对象的生命周期限于循环范围内,常用于在循环前获取一个资源(如锁)并在循环结束后自动释放(结合 RAII)。
1
for (auto&& obj = createObject(); const auto& element : obj.getElements()) {
2
// 使用 element 和 obj
3
} // obj 在这里销毁,释放资源
Appendix A5: 其他相关的 C++ 标准特性
除了上述主要版本中的特性,C++ 标准还引入了许多其他支持或影响 OOP 的机制:
⚝ Lambda 表达式 (Lambda Expression) (C++11)
▮▮▮▮⚝ 可以捕获作用域内的变量和对象,创建临时的函数对象。常用于算法和事件处理,与包含数据和行为的对象概念相契合。
⚝ 右值引用和完美转发 (Rvalue Reference and Perfect Forwarding) (C++11)
▮▮▮▮⚝ 除了移动语义,右值引用和 std::forward
也支持完美转发,使得编写既能接受左值也能接受右值并将其正确转发给其他函数的泛型代码(如构造函数、工厂函数)成为可能。这对于编写通用的类工厂或包装器类很有价值。
⚝ 用户定义字面量 (User-Defined Literals) (C++11)
▮▮▮▮⚝ 允许创建自定义的字面量后缀,用于创建特定类型的对象,例如 500ms
创建一个表示 500 毫秒的 std::chrono::duration
对象。这提供了一种更直观的方式来构造类的对象。
⚝ 属性 (Attributes) (C++11 引入基本概念,C++17/20 增加更多)
▮▮▮▮⚝ 提供了一种标准化的方式来向编译器、链接器或其他工具提供额外的信息,例如 [[noreturn]]
, [[deprecated]]
, [[nodiscard]]
。这些可以用于标记类、成员函数等的特性,辅助代码分析和警告。
Appendix A6: 总结
C++ 标准的持续演进为 C++ 的面向对象编程注入了新的活力。从 C++11 开始引入的移动语义、智能指针、override
/final
关键字等特性,极大地提升了资源管理的效率和安全性,增强了继承体系的健壮性。C++17 的结构化绑定、if constexpr
、可选值/变体类型等,提供了更灵活和类型安全的数据处理方式。C++20 的概念、模块、三路比较运算符等,则进一步提升了泛型编程的能力,改善了大型项目的组织结构,并简化了常见的类实现。
理解这些新特性,并将其融入到日常的 C++ OOP 开发中,是编写现代、高效、健壮且易于维护的 C++ 代码的关键。它们不仅是语言的增强,更是软件设计理念在语言层面的体现。作为 C++ 开发者,持续学习和掌握这些新标准带来的变化是必不可少的。
Appendix B: 常见 C++ OOP 面试题解析
欢迎来到本书的附录 B。在学习了 C++ 面向对象编程 (Object-Oriented Programming, OOP) 的各个核心概念和高级特性后,巩固知识、融会贯通的最佳方式之一就是面对实际问题。本附录精选了一些常见的 C++ OOP 面试题目,这些题目旨在考察面试者对 C++ OOP 基础、原理、实现机制以及最佳实践的理解深度。
对于不同经验水平的读者,这些题目和解析都能提供帮助:初学者可以借此检验对基本概念的掌握程度;中级开发者可以深入理解概念背后的原理;专家级开发者则可以通过这些题目回顾细节,并思考更深层次的设计和性能问题。
以下将按主题对常见面试题进行分类解析。
Appendix B1: 基础概念与类/对象
Appendix B1.1: OOP 基本概念
① 问题: 请解释面向对象编程 (OOP) 的三大基本特征:封装 (Encapsulation)、继承 (Inheritance) 和多态 (Polymorphism)。
解析:
⚝ 封装 (Encapsulation): 指将数据 (成员变量/属性) 和操作数据的方法 (成员函数/行为) 绑定在一起,形成一个独立的单元——类 (Class)。通过访问修饰符 (Access Specifier) (如 public
, private
, protected
) 控制外部对成员的访问权限,实现信息隐藏 (Information Hiding)。核心思想是“高内聚、低耦合”,将类的内部实现细节隐藏起来,只通过公有的接口 (Interface) 与外部交互。
⚝ 继承 (Inheritance): 允许一个类 (派生类/子类) 继承另一个类 (基类/父类) 的属性和方法。继承建立了“is-a”的关系,促进了代码的重用和扩展。派生类可以访问基类的 public
和 protected
成员,并可以添加新的成员或修改继承来的行为。
⚝ 多态 (Polymorphism): 意为“多种形态”。在 OOP 中,多态允许使用一个基类指针或引用来操作不同派生类的对象,并在运行时 (Runtime) 根据实际指向或引用的对象类型调用相应的成员函数。这是通过虚函数 (Virtual Function) 和动态绑定 (Dynamic Binding) 实现的。多态提高了代码的灵活性、可扩展性和可维护性。
Appendix B1.2: 类与对象
① 问题: 类 (Class) 和对象 (Object) 有什么区别?
解析:
⚝ 类 (Class): 是一个抽象的概念,它是创建对象的蓝图或模板,定义了对象的属性 (数据成员) 和行为 (成员函数)。类本身不占用内存,除非创建了对象。
⚝ 对象 (Object): 是类的一个具体实例 (Instance)。对象拥有类定义的属性,并可以通过调用类的成员函数来执行相应的行为。每个对象都独立地拥有其成员变量的一份拷贝(静态成员除外),并占用内存。可以把类比作图纸,对象比作根据图纸建造的房屋。
② 问题: 在 C++ 中,struct
和 class
有什么区别?
解析:
⚝ 在 C++ 中,struct
和 class
的主要区别在于默认的成员访问权限 (Access Specifier) 和默认的继承方式。
▮▮▮▮⚝ 对于 struct
,默认的成员访问权限是 public
,默认的继承方式是 public
。
▮▮▮▮⚝ 对于 class
,默认的成员访问权限是 private
,默认的继承方式是 private
。
⚝ 除此之外,它们的功能是相同的,都用于定义用户自定义类型,可以有成员变量、成员函数、构造函数、析构函数、继承、多态等。在实践中,通常使用 struct
来定义主要用于聚合数据的简单数据结构 (Plain Old Data, POD),而使用 class
来定义具有更复杂行为和严格封装的对象。
③ 问题: this
指针是什么?它有什么作用?
解析:
⚝ this
指针是一个指向当前对象的常量指针 (Constant Pointer)。它隐式地作为非静态成员函数的一个参数传递。
⚝ 作用:
▮▮▮▮⚝ 在成员函数内部,用于引用调用该函数的对象。
▮▮▮▮⚝ 当成员变量名与函数参数名相同时,可以使用 this->
来明确区分成员变量。
▮▮▮▮⚝ 允许成员函数返回当前对象的引用 (*this
) 或指针 (this
),常用于实现链式调用 (Method Chaining) 或在拷贝赋值运算符中返回 *this
。
⚝ 静态成员函数没有 this
指针,因为它们不与特定的对象关联,而是属于类本身。
④ 问题: const
成员函数有什么特点和用途?
解析:
⚝ 特点:
▮▮▮▮⚝ const
成员函数承诺不会修改对象的状态,即不会修改类的非静态成员变量。
▮▮▮▮⚝ 在 const
成员函数中,this
指针的类型是 const ClassType*
,因此不能通过 this
指针修改成员变量(除非成员变量被声明为 mutable
)。
▮▮▮▮⚝ const
对象只能调用其 const
成员函数。非 const
对象可以调用 const
和非 const
成员函数。
⚝ 用途:
▮▮▮▮⚝ 保证函数的行为不会改变对象的状态,提高代码的可读性和安全性。
▮▮▮▮⚝ 允许通过 const
引用或指针访问对象时调用相应的函数。
▮▮▮▮⚝ 区分对对象状态只读 (read-only) 和读写 (read-write) 的操作。
1
class MyClass {
2
int value;
3
public:
4
MyClass(int v) : value(v) {}
5
6
// 非 const 成员函数,可以修改 value
7
void setValue(int v) { value = v; }
8
9
// const 成员函数,不能修改 value
10
int getValue() const { return value; }
11
12
// 尝试在 const 函数中修改成员变量会导致编译错误
13
// void modifyValueInConst() const { value = 100; } // Error!
14
};
15
16
int main() {
17
const MyClass obj1(10);
18
// obj1.setValue(20); // Error! const object cannot call non-const member function
19
int v = obj1.getValue(); // OK. const object can call const member function
20
21
MyClass obj2(30);
22
obj2.setValue(40); // OK. non-const object can call non-const member function
23
int v2 = obj2.getValue(); // OK. non-const object can call const member function
24
25
return 0;
26
}
Appendix B2: 构造函数与析构函数
Appendix B2.1: 构造函数
① 问题: 什么是构造函数 (Constructor)?它有哪些种类?
解析:
⚝ 构造函数: 是一种特殊的成员函数,用于在创建对象时初始化对象的状态。它的名称与类名相同,没有返回类型 (甚至连 void
也没有)。对象创建时会自动调用合适的构造函数。
⚝ 种类:
▮▮▮▮⚝ 默认构造函数 (Default Constructor): 不需要任何参数的构造函数。如果用户没有定义任何构造函数,编译器 (Compiler) 会在需要时自动生成一个默认构造函数(如果类中没有虚函数或虚基类,且所有成员变量都有默认初始化方式或本身是 POD 类型)。如果用户定义了其他构造函数,编译器就不会再自动生成默认构造函数。
▮▮▮▮⚝ 带参数构造函数 (Parameterized Constructor): 接受一个或多个参数,用于使用外部提供的值初始化对象。
▮▮▮▮⚝ 拷贝构造函数 (Copy Constructor): 接受同类型的另一个对象的 const
引用作为参数 (ClassName(const ClassName& other)
),用于使用另一个已存在的对象来创建新的对象(例如,对象作为函数参数按值传递、函数返回对象按值返回、使用 =
进行初始化等)。如果用户没有定义,编译器会生成一个默认的拷贝构造函数,执行浅拷贝 (Shallow Copy)。当类中包含指向堆内存的指针等资源时,通常需要自定义拷贝构造函数实现深拷贝 (Deep Copy)。
▮▮▮▮⚝ 移动构造函数 (Move Constructor, C++11): 接受同类型的另一个对象的右值引用 (Rvalue Reference) 作为参数 (ClassName(ClassName&& other)
)。用于从一个临时对象或即将销毁的对象“窃取”资源,而不是复制资源。这在资源管理类中非常有用,可以提高性能。如果用户没有定义,且没有定义析构函数、拷贝构造函数或拷贝赋值运算符,编译器可能会生成一个默认的移动构造函数。
② 问题: 什么是初始化列表 (Initialization List)?为什么推荐使用它来初始化成员变量?
解析:
⚝ 初始化列表: 是构造函数定义的一部分,位于参数列表之后、函数体 {}
之前,用冒号 :
引出。它由一系列成员变量名和其初始值组成,用逗号 ,
分隔。
1
ClassName::ClassName(param1, param2) : member1(param1), member2(param2) {
2
// 构造函数体
3
}
⚝ 推荐原因:
▮▮▮▮⚝ 效率更高: 对于非 POD 类型 (Non-POD Type) 的成员变量(如类对象),初始化列表是调用成员变量的构造函数进行初始化。而在构造函数体内部赋值,则是先调用成员变量的默认构造函数,然后再调用赋值运算符 (Assignment Operator)。使用初始化列表可以避免一次默认构造函数的调用和一次赋值操作,从而更高效。对于 POD 类型,两者差异不大。
▮▮▮▮⚝ 必要性: 对于以下情况,必须使用初始化列表:
▮▮▮▮▮▮▮▮❶ 初始化 const
成员变量,因为 const
变量必须在定义时初始化,不能先声明再赋值。
▮▮▮▮▮▮▮▮❷ 初始化引用 (Reference) 成员变量,原因同上。
▮▮▮▮▮▮▮▮❸ 初始化没有默认构造函数的成员对象。
▮▮▮▮▮▮▮▮❹ 初始化基类的成员,基类构造函数的调用必须在派生类的初始化列表中完成。
▮▮▮▮⚝ 成员变量的初始化顺序: 成员变量的初始化顺序只取决于它们在类中声明的顺序,与初始化列表中的顺序无关。推荐初始化列表中的顺序与成员变量的声明顺序一致,以避免潜在的混淆和错误。
Appendix B2.2: 析构函数
① 问题: 什么是析构函数 (Destructor)?它的作用是什么?
解析:
⚝ 析构函数: 是一种特殊的成员函数,用于在对象生命周期结束时执行清理工作,释放对象可能占用的资源(如动态分配的内存、文件句柄、网络连接等)。它的名称是类名前加上波浪号 ~
,没有返回类型,也没有参数。
⚝ 作用: 确保对象所占用的资源被正确释放,避免资源泄露 (Resource Leak)。
⚝ 调用时机:
▮▮▮▮⚝ 对象生命周期结束时,例如局部对象离开作用域。
▮▮▮▮⚝ 使用 delete
操作符删除堆对象时。
▮▮▮▮⚝ 临时对象生命周期结束时。
⚝ 如果用户没有定义析构函数,编译器会自动生成一个默认的析构函数。默认析构函数会依次调用成员变量的析构函数,如果是派生类,还会调用基类的析构函数。当类中包含需要手动释放的资源时(如动态分配的内存),必须自定义析构函数来执行清理工作。
② 问题: 构造函数和析构函数的调用顺序是怎样的(特别是涉及继承和对象组合时)?
解析:
⚝ 单个对象:
▮▮▮▮⚝ 构造函数:按照成员变量在类中声明的顺序,依次调用成员变量的构造函数,然后执行构造函数体。
▮▮▮▮⚝ 析构函数:先执行析构函数体,然后按照成员变量在类中声明的顺序的逆序,依次调用成员变量的析构函数。
⚝ 继承:
▮▮▮▮⚝ 构造函数:先调用基类的构造函数,然后按照派生类成员变量声明的顺序调用派生类成员变量的构造函数,最后执行派生类的构造函数体。
▮▮▮▮⚝ 析构函数:先执行派生类的析构函数体,然后按照派生类成员变量声明的顺序的逆序调用派生类成员变量的析构函数,最后调用基类的析构函数。
⚝ 对象组合 (Composition/Aggregation):
▮▮▮▮⚝ 构造函数:先按照成员对象在类中声明的顺序调用成员对象的构造函数,然后执行类自身的构造函数体。
▮▮▮▮⚝ 析构函数:先执行类自身的析构函数体,然后按照成员对象在类中声明的顺序的逆序调用成员对象的析构函数。
⚝ 继承与组合混合: 调用顺序遵循上述规则的组合。先调用基类构造函数,然后按声明顺序调用成员对象的构造函数,最后执行派生类/包含类的构造函数体。析构顺序相反。
Appendix B3: 继承
Appendix B3.1: 继承的基本概念与访问控制
① 问题: 什么是继承 (Inheritance)?它有什么作用?请解释 public
, protected
, private
三种继承方式的区别。
解析:
⚝ 继承: 是一种机制,允许一个类 (派生类/子类) 基于另一个已存在的类 (基类/父类) 创建。派生类继承了基类的成员变量和成员函数。继承表达的是一种“is-a”的关系,例如“猫 (Cat) 是一种 动物 (Animal)”。
⚝ 作用:
▮▮▮▮⚝ 代码重用 (Code Reusability):派生类可以直接使用基类已有的代码,无需重复编写。
▮▮▮▮⚝ 扩展性 (Extensibility):可以在基类的基础上添加新的功能或修改现有功能。
▮▮▮▮⚝ 建立层次结构:更好地组织和管理代码,反映现实世界或问题领域的层级关系。
⚝ 三种继承方式: 继承方式 (public
, protected
, private
) 决定了基类成员在派生类中的最小访问权限。
▮▮▮▮⚝ public
继承: 基类的 public
成员在派生类中仍然是 public
;基类的 protected
成员在派生类中仍然是 protected
;基类的 private
成员在派生类中仍然是 private
(不可直接访问)。这是最常用的继承方式,保持了基类的接口特性。
▮▮▮▮⚝ protected
继承: 基类的 public
成员在派生类中变为 protected
;基类的 protected
成员在派生类中仍然是 protected
;基类的 private
成员仍然不可直接访问。派生类及其子类可以访问这些继承来的成员,但类的外部无法通过派生类对象访问基类的公共成员。
▮▮▮▮⚝ private
继承: 基类的 public
和 protected
成员在派生类中都变为 private
;基类的 private
成员仍然不可直接访问。这使得基类的所有成员在派生类的外部都不可访问,派生类可以访问基类的 public
和 protected
成员,但这种访问权限不会传递给派生类的子类。private
继承表达的是一种“has-a”或“is-implemented-in-terms-of”的关系,更倾向于实现细节的复用。
Appendix B3.2: 多重继承与虚继承```cpp
include
class Base {
public:
Base() { std::cout << "Base Constructor" << std::endl; }
~Base() { std::cout << "Base Destructor" << std::endl; }
};
class Derived : public Base {
public:
Derived() { std::cout << "Derived Constructor" << std::endl; }
~Derived() { std::cout << "Derived Destructor" << std::endl; }
};
class Composed {
Base obj; // Composition
public:
Composed() { std::cout << "Composed Constructor" << std::endl; }
~Composed() { std::cout << "Composed Destructor" << std::endl; }
};
int main() {
std::cout << "Creating Derived object:" << std::endl;
Derived d; // Calls Base(), then Derived()
std::cout << "\nCreating Composed object:" << std::endl;
Composed c; // Calls Base(), then Composed()
std::cout << "\nDestroying objects:" << std::endl;
// Objects d and c go out of scope, destructors are called
// For d: calls Derived(), then Base()
// For c: calls Composed(), then Base()
return 0;
}
1
⚝ **输出示例 (顺序可能略有差异,但基类/成员对象的构造/析构顺序是固定的):**
Creating Derived object:
Base Constructor
Derived Constructor
Creating Composed object:
Base Constructor
Composed Constructor
Destroying objects:
Derived Destructor
Base Destructor
Composed Destructor
Base Destructor
1
② **问题:** 什么是菱形继承 (Diamond Problem)?C++ 如何解决它?
2
3
**解析:**
4
⚝ **菱形继承:** 是一种特殊的多重继承场景,当两个派生类 (如 `Derived1` 和 `Derived2`) 都继承自同一个基类 (如 `Base`),而另一个类 (如 `MostDerived`) 又同时继承自这两个派生类 (`Derived1` 和 `Derived2`) 时,就会形成菱形结构。
1
Base
/ rived1 Derived2
\ /
MostDerived
⚝ 问题: 在这种情况下,MostDerived
类中会有两份 Base
类的成员 (通过 Derived1
继承一份,通过 Derived2
继承一份),这会导致访问歧义 (Ambiguity) 和资源冗余 (Resource Redundancy)。例如,访问 Base
类中的成员变量 data
时,编译器不知道应该使用哪一份 data
。
⚝ C++ 的解决方案: 使用虚继承 (Virtual Inheritance)。通过在继承声明中加上 virtual
关键字,可以将基类声明为虚基类 (Virtual Base Class)。
1
class Base { /* ... */ };
2
class Derived1 : virtual public Base { /* ... */ };
3
class Derived2 : virtual public Base { /* ... */ };
4
class MostDerived : public Derived1, public Derived2 { /* ... */ };
⚝ 虚继承的原理: 使用虚继承后,MostDerived
类不再包含两份 Base
子对象,而是只有一份共享的 Base
子对象。这份共享的 Base
子对象由最底层的派生类 (MostDerived
) 负责构造和销毁。编译器通过虚基类指针 (Virtual Base Pointer) 或偏移量来找到这份唯一的 Base
子对象,从而解决了访问歧义问题。虚继承会增加一些额外的开销(如存储虚基类指针/偏移量,查找虚基类成员)。
Appendix B4: 多态
Appendix B4.1: 静态多态与动态多态
问题:* 请解释 C++ 中的静态多态 (Static Polymorphism) 和动态多态 (Dynamic Polymorphism)?它们是如何实现的?
解析:
⚝ 静态多态 (Static Polymorphism): 也称为编译时多态 (Compile-time Polymorphism)。在程序编译期间,编译器就能够确定调用哪个函数。
▮▮▮▮⚝ 实现方式:
▮▮▮▮▮▮▮▮❶ 函数重载 (Function Overloading): 在同一个作用域内,函数名相同但参数列表 (参数个数、类型、顺序) 不同的函数。编译器根据调用时提供的参数类型和数量来确定调用哪个重载函数。
▮▮▮▮▮▮▮▮❷ 运算符重载 (Operator Overloading): 允许用户自定义类型的运算符具有特殊含义。编译器根据操作数的类型来确定调用哪个重载的运算符函数。
▮▮▮▮▮▮▮▮❸ 模板 (Template): 包括函数模板和类模板。编译器在编译时根据模板参数实例化出具体的函数或类。这是一种泛型编程 (Generic Programming) 的体现,也是静态多态的一种形式。
⚝ 动态多态 (Dynamic Polymorphism): 也称为运行时多态 (Runtime Polymorphism)。在程序运行时,才能确定调用哪个函数。这是通过基类指针或引用调用虚函数 (Virtual Function) 实现的。
▮▮▮▮⚝ 实现方式:
▮▮▮▮▮▮▮▮❶ 虚函数 (Virtual Function): 在基类中使用 virtual
关键字声明的成员函数。派生类可以重写 (Override) 这个虚函数。
▮▮▮▮▮▮▮▮❷ 虚函数表 (Virtual Table / vtable) 和虚指针 (Virtual Pointer / vptr): 编译器为包含虚函数的类生成一个虚函数表,其中存储了该类所有虚函数的地址。每个含有虚函数的对象都会有一个虚指针,指向该对象所属类的虚函数表。当通过基类指针或引用调用虚函数时,程序会查找对象中的虚指针,找到对应的虚函数表,然后在表中查找并调用正确版本的虚函数(即实际对象的类中重写的函数)。
问题:* 什么是虚函数 (Virtual Function)?它的工作原理是什么?
解析:
⚝ 虚函数: 是在基类中使用 virtual
关键字声明的成员函数。其目的是在派生类中被重写 (Override),并通过基类指针或引用实现运行时多态。
⚝ 工作原理 (通常实现):
▮▮▮▮⚝ 虚函数表 (vtable): 编译器为每个包含虚函数或继承虚函数的类创建一个虚函数表。这个表是一个函数指针数组,存放了该类中所有虚函数的地址。如果派生类重写了某个虚函数,则虚函数表中对应项会存放派生类中该函数的地址;如果派生类没有重写,则存放基类中该函数的地址。
▮▮▮▮⚝ 虚指针 (vptr): 每个含有虚函数的类的对象都会在其实例内存布局中包含一个虚指针。这个虚指针在对象创建时被初始化,指向该对象实际所属类的虚函数表。
▮▮▮▮⚝ 动态绑定 (Dynamic Binding): 当通过基类指针或引用调用虚函数时,编译器生成代码,不是直接调用特定地址的函数,而是:
▮▮▮▮▮▮▮▮❶ 通过基类指针/引用找到实际对象的地址。
▮▮▮▮▮▮▮▮❷ 从对象内存布局中找到虚指针 vptr
。
▮▮▮▮▮▮▮▮❸ 通过 vptr
访问对象所属类的虚函数表 vtable
。
▮▮▮▮▮▮▮▮❹ 在 vtable
中找到对应虚函数条目的地址(通常通过虚函数在表中固定的索引)。
▮▮▮▮▮▮▮▮❺ 调用该地址处的函数。
⚝ 这个过程在运行时发生,因此称为运行时多态或动态绑定。
问题:* 什么是纯虚函数 (Pure Virtual Function)?什么是抽象类 (Abstract Class)?
解析:
⚝ 纯虚函数: 是在基类中声明的虚函数,但没有具体的实现。在函数声明的末尾加上 = 0
来表示这是一个纯虚函数。
1
class Base {
2
public:
3
virtual void pureVirtualFunc() = 0; // 纯虚函数
4
virtual ~Base() {} // 抽象类可以有析构函数,且通常声明为虚函数
5
};
⚝ 纯虚函数的作用: 用于在基类中强制派生类必须提供该函数的具体实现,从而定义了一个接口。包含纯虚函数的类不能被实例化(不能创建对象)。
⚝ 抽象类 (Abstract Class): 任何包含至少一个纯虚函数的类都被认为是抽象类。
⚝ 抽象类的特点:
▮▮▮▮⚝ 不能直接创建抽象类的对象,只能创建其派生类的对象。
▮▮▮▮⚝ 抽象类通常用作接口或基类,用于规范派生类的行为。
▮▮▮▮⚝ 派生类如果想被实例化,必须实现 (Override) 基类中的所有纯虚函数。如果派生类没有实现所有的纯虚函数,那么它本身也是一个抽象类。
⚝ 用途: 抽象类是定义统一接口的强大工具,例如,一个图形类 Shape
可以是一个抽象类,包含纯虚函数 draw()
,然后 Circle
, Square
等派生类必须实现 draw()
函数,这样就可以通过 Shape*
指针调用不同形状的 draw()
函数实现多态绘图。
Appendix B4.2: override
和 final
关键字 (C++11)
问题:* C++11 引入的 override
和 final
关键字有什么作用?
解析:
⚝ override
: 用于显式地标记派生类中的成员函数是 intended to (旨在) 重写基类中的虚函数。
▮▮▮▮⚝ 作用:
▮▮▮▮▮▮▮▮❶ 提高代码可读性,清晰表明函数的目的是重写虚函数。
▮▮▮▮▮▮▮▮❷ 防止错误: 如果标记了 override
的函数并没有实际重写基类中的虚函数(例如,函数名拼写错误、参数列表不匹配、基类函数不是虚函数等),编译器会报错。这避免了因为签名不匹配而导致的多态失效(创建了一个新的函数而不是重写)。
1
class Base {
2
public:
3
virtual void foo(int) {}
4
virtual void bar() const {}
5
};
6
7
class Derived : public Base {
8
public:
9
// void foo(float) override; // 错误:参数类型不匹配,没有重写基类虚函数
10
// void bar() override; // 错误:缺少 const,签名不匹配
11
void foo(int) override {} // 正确
12
void bar() const override {} // 正确
13
};
⚝ final
: 可以用于类或虚函数。
▮▮▮▮⚝ 作用于虚函数: 标记某个虚函数不能在其派生类中被进一步重写。
1
class Base {
2
public:
3
virtual void importantFunc() final; // 不能在派生类中重写
4
virtual void anotherFunc() {}
5
};
6
7
class Derived : public Base {
8
// void importantFunc() override; // 错误:Base::importantFunc 被标记为 final
9
void anotherFunc() override {} // 正确
10
};
▮▮▮▮⚝ 作用于类: 标记某个类不能被继承。
1
class NonInheritable final {
2
// ...
3
};
4
5
// class CannotDerive : public NonInheritable {}; // 错误:NonInheritable 被标记为 final
⚝ 用途: final
用于限制继承或重写,提供更强的设计控制。
Appendix B4.3: RTTI (运行时类型信息)
问题:* 什么是 RTTI (Runtime Type Information)?它有什么作用?dynamic_cast
是如何工作的?
解析:
⚝ RTTI (Runtime Type Information): 运行时类型信息允许程序在运行时查询对象的实际类型。C++ 中主要通过 typeid
运算符和 dynamic_cast
运算符提供 RTTI 功能。RTTI 只有在启用了多态的类层次结构中(即类中含有虚函数)才真正有用,并且通常需要在编译选项中开启。
⚝ 作用:
▮▮▮▮⚝ typeid
运算符: 返回一个 std::type_info
对象的引用,该对象描述了表达式的类型。常用于比较两个对象的类型是否相同。对于多态类型,typeid
会返回对象的实际动态类型。
▮▮▮▮⚝ dynamic_cast
运算符: 用于在类层次结构中进行安全的动态类型转换 (Dynamic Type Casting)。它主要用于将基类指针或引用向下转换为派生类指针或引用 (Downcasting),在转换失败时,对于指针返回 nullptr
,对于引用抛出 std::bad_cast
异常。
⚝ dynamic_cast
工作原理 (通常实现):
▮▮▮▮⚝ dynamic_cast
依赖于 RTTI 信息,特别是对象的虚函数表 vtable
中存储的类型信息。
▮▮▮▮⚝ 当执行 dynamic_cast<Target*>(ptr)
或 dynamic_cast<Target&>(ref)
时,运行时会检查 ptr
或 ref
实际指向的对象类型是否是 Target
类型或 Target
类型的派生类。
▮▮▮▮⚝ 这个检查过程通常涉及查找对象的 vptr
,访问 vtable
中的类型信息,并与目标类型的信息进行比较,或者遍历类继承关系图。
▮▮▮▮⚝ 如果转换是有效且安全的(即对象实际类型是 Target
或其派生类,并且可以通过继承路径到达 Target
),则返回转换后的指针或引用;否则,对于指针返回 nullptr
,对于引用抛出异常。
⚝ 注意: dynamic_cast
只能用于具有虚函数的类层次结构中,因为它依赖于虚函数表中的类型信息。相比静态类型转换 (static_cast
),dynamic_cast
有运行时开销,但提供了类型安全的向下转换。
Appendix B5: 运算符重载与友元
Appendix B5.1: 运算符重载
问题:* 什么是运算符重载 (Operator Overloading)?请举例说明如何重载加号运算符 +
。
解析:
⚝ 运算符重载: 是一种 C++ 特性,允许为用户自定义的类类型重新定义 C++ 内置运算符的行为。这使得可以使用熟悉的运算符符号来操作类对象,提高代码的可读性和自然性。例如,可以重载 +
运算符,使得两个复数对象可以直接相加。
⚝ 规则: 并非所有运算符都可以重载(例如 .
.*
::
?:
sizeof
等)。重载后的运算符优先级、结合性、操作数个数不能改变。重载运算符可以是成员函数或非成员函数 (通常是友元函数)。
⚝ 重载加号运算符 +
示例:
⚝ 假设有一个表示二维向量的 Vector2D
类,我们想重载 +
运算符来实现向量相加。向量相加的结果是一个新的向量,不修改原向量,因此适合将其重载为非成员函数。
1
class Vector2D {
2
public:
3
double x, y;
4
5
Vector2D(double vx = 0.0, double vy = 0.0) : x(vx), y(vy) {}
6
7
// 重载 += 运算符作为成员函数(通常修改自身的操作适合作为成员函数)
8
Vector2D& operator+=(const Vector2D& other) {
9
x += other.x;
10
y += other.y;
11
return *this;
12
}
13
};
14
15
// 重载 + 运算符作为非成员函数(通常依赖于 += 实现,保持一致性)
16
// 需要访问 Vector2D 的私有成员时,可以声明为友元
17
// friend Vector2D operator+(const Vector2D& lhs, const Vector2D& rhs);
18
Vector2D operator+(const Vector2D& lhs, const Vector2D& rhs) {
19
Vector2D result = lhs; // 使用拷贝构造函数
20
result += rhs; // 调用 += 成员函数
21
return result; // 使用返回值优化 (RVO) 或移动语义
22
}
23
24
int main() {
25
Vector2D v1(1.0, 2.0);
26
Vector2D v2(3.0, 4.0);
27
Vector2D v3 = v1 + v2; // 调用 operator+(v1, v2)
28
29
std::cout << "v3 = (" << v3.x << ", " << v3.y << ")" << std::endl; // Output: v3 = (4, 6)
30
31
return 0;
32
}
⚝ 在这个例子中,我们将 +
重载为非成员函数,它接受两个 Vector2D
对象作为参数,返回一个新的 Vector2D
对象。通常,二元运算符(如 +
, -
, *
, /
)如果需要支持类型提升(例如 int + Vector2D
),或者操作符的左侧不是类类型,则必须重载为非成员函数。而那些会修改对象自身状态的运算符(如 +=
, -=
, ++
, --
)通常重载为成员函数。
Appendix B5.2: 友元
问题:* 什么是友元 (Friend) 函数和友元类 (Friend Class)?它们的作用和使用场景是什么?
解析:
⚝ 友元机制: 允许一个类授权其他函数或类访问其 private
和 protected
成员,打破了严格的封装性。友元关系是单向的,不可传递,不可继承。
⚝ 友元函数 (Friend Function): 在类定义中使用 friend
关键字声明的非成员函数。这个函数可以访问该类的所有成员,包括 private
和 protected
成员。
▮▮▮▮⚝ 声明语法: 在类定义内部声明:friend return_type function_name(parameter_list);
1
class MyClass {
2
private:
3
int data;
4
public:
5
MyClass(int d) : data(d) {}
6
friend void showData(const MyClass& obj); // 声明友元函数
7
};
8
9
// 友元函数的定义(不需要 friend 关键字)
10
void showData(const MyClass& obj) {
11
std::cout << "Data is: " << obj.data << std::endl; // 可以访问私有成员 data
12
}
⚝ 友元类 (Friend Class): 在一个类的定义中使用 friend
关键字声明另一个类。被声明为友元的类可以访问授权类的所有成员,包括 private
和 protected
成员。
▮▮▮▮⚝ 声明语法: 在类定义内部声明:friend class FriendClassName;
1
class Subject; // 前向声明
2
3
class Observer {
4
public:
5
void observe(const Subject& s); // 将在 Subject 定义后实现
6
};
7
8
class Subject {
9
private:
10
int state;
11
friend class Observer; // 声明 Observer 是 Subject 的友元类
12
public:
13
Subject(int s) : state(s) {}
14
};
15
16
// Observer 成员函数的实现,现在可以访问 Subject 的私有成员 state
17
void Observer::observe(const Subject& s) {
18
std::cout << "Subject state is: " << s.state << std::endl;
19
}
20
21
int main() {
22
Subject s(10);
23
Observer o;
24
o.observe(s); // Output: Subject state is: 10
25
return 0;
26
}
⚝ 使用场景:
▮▮▮▮⚝ 运算符重载: 当需要重载一个二元运算符,且操作符的左侧不是类类型,或者需要访问类的私有成员时,常将该运算符重载为类的友元非成员函数(如输入/输出流运算符 <<
和 >>
)。
▮▮▮▮⚝ 类之间紧密协作: 当两个类需要频繁且深入地互相访问对方的私有成员以完成特定功能时,可以考虑使用友元类。例如,迭代器类访问容器类的内部数据结构。
⚝ 潜在风险: 友元机制破坏了封装性,过度使用可能导致代码耦合度升高,降低可维护性。应谨慎使用,只在确实需要访问私有成员且没有其他优雅方式时才考虑。
Appendix B6: 模板
Appendix B6.1: 类模板
问题:* 什么是类模板 (Class Template)?如何定义和使用类模板?
解析:
⚝ 类模板: 是一种用于创建泛型类 (Generic Class) 的蓝图。它允许定义一个类的通用结构和行为,而将类中使用的数据类型作为参数。这样可以编写只与类型参数相关的代码,提高代码的重用性。常见的类模板有 C++ 标准库中的容器,如 std::vector
, std::list
, std::map
等。
⚝ 定义: 使用 template <typename T>
或 template <class T>
(两者在此处等价) 后面跟着类定义。T
是一个类型参数,可以在类定义内部像普通类型一样使用。可以有多个类型参数,甚至非类型参数 (Non-type Parameter),如整数常量。
1
template <typename T, int MaxSize> // 类型参数 T 和 非类型参数 MaxSize
2
class MyArray {
3
private:
4
T arr[MaxSize]; // 使用类型参数和非类型参数
5
int size;
6
public:
7
MyArray() : size(0) {}
8
9
void add(const T& value) {
10
if (size < MaxSize) {
11
arr[size++] = value;
12
} else {
13
std::cerr << "Array is full!" << std::endl;
14
}
15
}
16
17
T get(int index) const {
18
if (index >= 0 && index < size) {
19
return arr[index];
20
} else {
21
// Handle error, e.g., throw exception
22
throw std::out_of_range("Index out of bounds");
23
}
24
}
25
26
int getSize() const { return size; }
27
};
⚝ 使用 (实例化): 在使用类模板时,需要显式地指定模板参数的实际类型(对于非类型参数提供常量值),编译器会根据提供的参数实例化出具体的类。
1
int main() {
2
// 实例化一个存储 int 的数组,最大容量为 10
3
MyArray<int, 10> intArray;
4
intArray.add(10);
5
intArray.add(20);
6
std::cout << "Int array size: " << intArray.getSize() << std::endl; // Output: 2
7
8
// 实例化一个存储 double 的数组,最大容量为 5
9
MyArray<double, 5> doubleArray;
10
doubleArray.add(1.1);
11
std::cout << "Double array element at index 0: " << doubleArray.get(0) << std::endl; // Output: 1.1
12
13
return 0;
14
}
⚝ 成员函数定义: 类模板的成员函数可以在类定义内部定义,也可以在类定义外部定义。在外部定义时,函数前面需要再次加上 template <typename ...>
,并且函数名需要加上类模板名和模板参数列表 (ClassName<T, ...>::functionName(...)
).
Appendix B6.2: 模板特化
问题:* 什么是模板特化 (Template Specialization)?它有哪些类型?有什么作用?
解析:
⚝ 模板特化: 指为某个模板(函数模板或类模板)的特定类型参数提供一个定制的实现。当编译器遇到使用特定类型参数的模板实例化请求时,如果存在该类型参数的特化版本,则优先使用特化版本,而不是通用的模板定义。
⚝ 类型:
▮▮▮▮⚝ 全特化 (Full Specialization): 为模板的所有模板参数都提供具体的类型或值。全特化版本在使用时无需再提供模板参数,编译器会根据函数参数类型或直接使用特化名称来匹配。
▮▮▮▮▮▮▮▮❶ 函数模板全特化:
1
template <typename T> // 通用模板
2
void print(T value) {
3
std::cout << "Generic print: " << value << std::endl;
4
}
5
6
template <> // 全特化标志
7
void print<int>(int value) { // 为 int 类型全特化
8
std::cout << "Specialized print for int: " << value << std::endl;
9
}
▮▮▮▮▮▮▮▮❷ 类模板全特化:
1
template <typename T, int MaxSize> // 通用模板
2
class MyContainer { /* ... */ };
3
4
template <> // 全特化标志
5
class MyContainer<int, 10> { // 为 int 类型和 MaxSize=10 全特化
6
// 提供 int, 10 特有的实现,可能与通用模板完全不同
7
// ...
8
};
▮▮▮▮⚝ 偏特化 (Partial Specialization): 仅适用于类模板,为模板参数的一部分提供具体类型或值,或者对模板参数的类型进行限制(如指针、引用、数组等)。偏特化版本在使用时仍然需要提供剩余的模板参数。
▮▮▮▮▮▮▮▮❶ 部分模板参数特化:
1
template <typename T, typename U> // 通用模板
2
class MyPair { T first; U second; /* ... */ };
3
4
template <typename T> // 偏特化:固定第二个参数为 int
5
class MyPair<T, int> { T first; int second; /* ... */ };
6
7
template <typename U> // 偏特化:固定第一个参数为 double
8
class MyPair<double, U> { double first; U second; /* ... */ };
▮▮▮▮▮▮▮▮❷ 模板参数限制特化 (如指针、引用):
1
template <typename T> // 通用模板
2
class SmartPointer { T* ptr; /* ... */ };
3
4
template <typename T> // 偏特化:针对指针类型 T*
5
class SmartPointer<T*> { T** ptr; /* 针对指针的指针进行特殊管理 */ /* ... */ };
⚝ 作用:
▮▮▮▮⚝ 为特定类型优化或提供特殊行为: 有时通用模板实现对某些类型可能效率低下或不适用,可以通过特化为这些类型提供更高效或特定的实现。
▮▮▮▮⚝ 针对特定场景提供必要实现: 例如,对于指针类型的容器,可能需要特殊的内存管理逻辑,可以通过偏特化实现。
⚝ 注意: 函数模板只有全特化,没有偏特化。可以通过函数重载达到类似函数模板偏特化的效果。
Appendix B7: 资源管理与 RAII
Appendix B7.1: RAII 原则
问题:* 什么是 RAII (Resource Acquisition Is Initialization) 原则?它在 C++ 中有什么重要作用?
解析:
⚝ RAII (Resource Acquisition Is Initialization): “资源获取即初始化”原则。这是一种 C++ 编程习惯,旨在利用对象的生命周期来自动管理资源。其核心思想是将资源的获取(如内存分配、文件打开、锁获取、网络连接等)与对象的初始化绑定在一起,并将资源的释放与对象的析构绑定在一起。
⚝ 重要作用:
▮▮▮▮⚝ 自动资源管理: 确保资源在不再需要时(对象离开作用域、程序终止、异常发生等)能够被自动、可靠地释放,避免资源泄露 (Resource Leak)。
▮▮▮▮⚝ 异常安全 (Exception Safety): 即使在函数执行过程中发生异常,对象的析构函数也会被调用(栈展开过程),从而保证已获取的资源能够得到释放。这是 C++ 实现强大异常安全性的关键。
▮▮▮▮⚝ 简化资源管理代码: 将资源管理逻辑集中在类的构造函数和析构函数中,使用者无需手动释放资源,降低了出错的可能性。
⚝ 实现方式: 通过定义一个类来封装资源,在类的构造函数中获取资源,在类的析构函数中释放资源。当这个类的对象被创建(资源被获取)并在其生命周期结束时,析构函数会自动调用(资源被释放)。
⚝ 典型示例: C++ 标准库中的智能指针 (std::unique_ptr
, std::shared_ptr
), std::lock_guard
, std::fstream
等都是遵循 RAII 原则的类。
Appendix B7.2: 智能指针
问题:* C++11 引入了哪些智能指针 (Smart Pointer)?它们各自的用途和区别是什么?
解析:
⚝ C++11 引入了三种主要的智能指针,都定义在 <memory>
头文件中,用于更安全地管理动态分配的对象,遵循 RAII 原则。
▮▮▮▮⚝ std::unique_ptr
:
▮▮▮▮▮▮▮▮❶ 用途: 管理独占所有权的动态对象。同一时间只有一个 unique_ptr
可以指向某个对象。
▮▮▮▮▮▮▮▮❷ 特点: 轻量级,没有额外的开销,与裸指针 (Raw Pointer) 大小相同。不能被拷贝,但可以通过 std::move
转移所有权。当 unique_ptr
对象被销毁时,它会自动删除所指向的对象。
▮▮▮▮▮▮▮▮❸ 适用场景: 对象的独占所有权,函数返回动态创建的对象。
1
std::unique_ptr<int> ptr1(new int(10));
2
// std::unique_ptr<int> ptr2 = ptr1; // 编译错误
3
std::unique_ptr<int> ptr3 = std::move(ptr1); // 所有权转移
4
// ptr1 现在是空的
5
// ptr3 拥有 int(10)
▮▮▮▮⚝ std::shared_ptr
:
▮▮▮▮▮▮▮▮❶ 用途: 管理共享所有权的动态对象。多个 shared_ptr
可以指向同一个对象。
▮▮▮▮▮▮▮▮❷ 特点: 内部使用引用计数 (Reference Counting) 来追踪有多少个 shared_ptr
指向同一个对象。每当一个新的 shared_ptr
指向该对象时,引用计数加一;每当一个 shared_ptr
离开作用域或被重置时,引用计数减一。当引用计数变为零时,对象被自动删除。shared_ptr
通常比 unique_ptr
大,并且有引用计数带来的开销。
▮▮▮▮▮▮▮▮❸ 适用场景: 对象需要被多个所有者共享,生命周期由所有者共同决定。
1
std::shared_ptr<int> sptr1(new int(10));
2
std::shared_ptr<int> sptr2 = sptr1; // 共享所有权,引用计数为 2
3
{
4
std::shared_ptr<int> sptr3 = sptr1; // 共享所有权,引用计数为 3
5
} // sptr3 离开作用域,引用计数减为 2
6
// 当 sptr1 和 sptr2 都离开作用域后,引用计数减为 0,对象被删除
▮▮▮▮⚝ std::weak_ptr
:
▮▮▮▮▮▮▮▮❶ 用途: 协助 shared_ptr
,解决循环引用 (Circular Reference) 问题。weak_ptr
不拥有对象,也不增加引用计数。
▮▮▮▮▮▮▮▮❷ 特点: 不能直接通过 weak_ptr
访问对象,需要先调用 lock()
方法获取一个 shared_ptr
。如果对象已被删除,lock()
返回空的 shared_ptr
。
▮▮▮▮▮▮▮▮❸ 适用场景: 观察者模式 (Observer Pattern)、缓存、父子关系中的子节点持有父节点的引用但不影响父节点的生命周期等场景,用来打破 shared_ptr
形成的循环引用。
1
std::shared_ptr<int> sptr = std::make_shared<int>(10);
2
std::weak_ptr<int> wptr = sptr; // wptr 观察 sptr 指向的对象
3
4
if (auto locked_sptr = wptr.lock()) { // 尝试获取 shared_ptr
5
std::cout << *locked_sptr << std::endl; // 可以安全访问对象
6
} else {
7
std::cout << "Object has been deleted." << std::endl;
8
}
Appendix B8: 异常处理
Appendix B8.1: 异常处理机制
问题:* C++ 的异常处理 (Exception Handling) 机制是如何工作的?请解释 try
, catch
, throw
的作用。
解析:
⚝ 异常处理机制: C++ 提供了一种结构化的方式来处理程序运行时发生的非预期错误或异常情况。当一个异常发生时,程序中断正常的执行流程,转而查找能够处理该异常的代码。
⚝ try
块: 用于标识可能会抛出异常的代码段。将可能出错的代码放入 try
块中。
1
try {
2
// 可能抛出异常的代码
3
}
4
// ... 后面的 catch 块
⚝ throw
表达式: 用于抛出 (Throw) 一个异常。抛出异常会中断当前的执行流程,并将控制权转移给能够捕获该异常的 catch
块。可以抛出任何类型的对象作为异常。
1
if (/* 发生错误条件 */) {
2
throw SomeException("Error occurred!"); // 抛出异常对象
3
}
⚝ catch
块: 用于捕获 (Catch) 由 throw
表达式抛出的异常。一个 try
块后面可以跟着一个或多个 catch
块,每个 catch
块指定要捕获的异常类型。当抛出的异常类型与某个 catch
块声明的类型匹配时,该 catch
块的代码就会被执行。
1
try {
2
// ...
3
throw std::runtime_error("Something went wrong");
4
} catch (const std::runtime_error& e) { // 捕获 runtime_error 或其派生类异常
5
std::cerr << "Caught exception: " << e.what() << std::endl;
6
// 处理异常
7
} catch (...) { // 捕获任何其他类型的异常 (通用捕获)
8
std::cerr << "Caught unknown exception" << std::endl;
9
// 处理未知异常
10
}
⚝ 工作流程:
▮▮▮▮⚝ 当 try
块中的代码执行时,如果在其中或其中调用的函数中抛出了异常,正常的流程就会中断。
▮▮▮▮⚝ 栈展开 (Stack Unwinding) 过程开始:程序沿着函数调用栈向上回溯,销毁途中遇到的局部对象(调用它们的析构函数),直到找到一个能够捕获该异常类型的 catch
块。
▮▮▮▮⚝ 如果找到了匹配的 catch
块,程序流程会跳转到该 catch
块中执行异常处理代码。
▮▮▮▮⚝ 如果在当前函数和其调用者中都没有找到匹配的 catch
块,栈展开会继续向上进行,直到到达 main
函数。如果在 main
函数之外仍然没有捕获异常,程序通常会终止 (调用 std::terminate
)。
⚝ 异常安全 (Exception Safety): 异常处理与 RAII 结合是实现异常安全的关键。在栈展开过程中,RAII 对象的析构函数会被自动调用,确保资源得到释放。
Appendix B8.2: 异常与构造/析构函数
问题:* 在 C++ 中,为什么通常不建议在析构函数 (Destructor) 中抛出异常?如果在构造函数 (Constructor) 中抛出异常会发生什么?
解析:
⚝ 析构函数中抛出异常:
▮▮▮▮⚝ 原因: 强烈不建议在析构函数中抛出异常。如果析构函数抛出异常,可能会导致以下问题:
▮▮▮▮▮▮▮▮❶ 未定义行为 (Undefined Behavior): 当一个异常正在传播时(即正在进行栈展开),如果某个析构函数在清理局部对象时又抛出了另一个异常,C++ 标准规定程序会立即终止 (调用 std::terminate
),导致未定义行为。这是最危险的情况,可能导致程序崩溃或其他不可预测的结果。
▮▮▮▮▮▮▮▮❷ 资源泄露: 如果析构函数因为抛出异常而提前终止,它可能无法完成清理其负责的所有资源,导致资源泄露。
▮▮▮▮⚝ 替代方案: 如果在析构过程中确实遇到了需要报告的错误(例如,写文件时发生磁盘错误),应该捕获该错误并在析构函数内部处理(例如,记录日志、设置错误标志),而不是抛出异常。如果错误非常严重,可以考虑调用 std::abort()
来立即终止程序,但这通常是最后的手段。
⚝ 构造函数中抛出异常:
▮▮▮▮⚝ 发生情况: 在构造对象时,如果构造函数内部出现错误(例如,内存分配失败、无法打开文件等),可以在构造函数中抛出异常。
▮▮▮▮⚝ 结果:
▮▮▮▮▮▮▮▮❶ 如果在构造函数体内抛出异常,已成功构造的成员变量和基类子对象会按照其构造顺序的逆序被正确地析构。但对象本身并没有完全构造成功,因此该对象的析构函数不会被调用。
▮▮▮▮▮▮▮▮❷ 抛出的异常会被向上传播,直到被匹配的 catch
块捕获。
▮▮▮▮▮▮▮▮❸ 如果异常未被捕获,栈展开会继续。
⚝ 优点: 在构造函数中抛出异常是报告对象创建失败的标准方式。这确保了只有完全构造成功的对象才会被程序使用。RAII 原则在构造函数抛出异常时仍然有效,已部分构造的成员变量和基类子对象能够得到清理。
Appendix B9: 高级话题与实践
Appendix B9.1: Casting 操作符
问题:* C++ 中的四种主要类型转换操作符 static_cast
, dynamic_cast
, const_cast
, reinterpret_cast
有什么区别和用途?
解析:
⚝ C++ 提供了四种命名类型转换操作符,用于更安全、更明确地执行类型转换,以取代 C 风格的强制类型转换 (type)expression
。
▮▮▮▮⚝ static_cast
:
▮▮▮▮▮▮▮▮❶ 用途: 用于执行各种隐式转换 (Implicit Conversion) 的显式版本,以及一些逻辑上相关的、但需要显式指定的转换。例如,数值类型之间的转换(如 int
到 double
),基类指针/引用到派生类指针/引用 (Upcasting),void*
到其他类型指针,以及自定义类类型之间的转换(如果定义了相应的构造函数或转换运算符)。
▮▮▮▮▮▮▮▮❷ 特点: 在编译时进行类型检查。不涉及运行时开销(除了必要的构造/析构调用)。不能用于去除 const
或 volatile
限定符。向下转换 (Downcasting) 是不安全的,编译器不会检查转换后的指针是否真的指向派生类对象。
1
double d = 3.14;
2
int i = static_cast<int>(d); // double 到 int
3
4
class Base {};
5
class Derived : public Base {};
6
Derived* pd = new Derived;
7
Base* pb = static_cast<Base*>(pd); // Upcasting,安全
8
9
Base* pb2 = new Base;
10
// Derived* pd2 = static_cast<Derived*>(pb2); // Downcasting,不安全,pb2 实际指向 Base
▮▮▮▮⚝ dynamic_cast
:
▮▮▮▮▮▮▮▮❶ 用途: 主要用于在具有虚函数的类层次结构中,进行安全的向下转换 (Downcasting) 和交叉转换 (Crosscasting)。
▮▮▮▮▮▮▮▮❷ 特点: 在运行时进行类型检查,依赖 RTTI。如果转换失败,对于指针返回 nullptr
,对于引用抛出 std::bad_cast
异常。有运行时开销。只能用于具有虚函数的类。
1
class Base { virtual ~Base() {} }; // 需要虚函数支持 dynamic_cast
2
class Derived : public Base {};
3
class Another {};
4
5
Base* pb = new Derived;
6
if (Derived* pd = dynamic_cast<Derived*>(pb)) { // Downcasting,pb 实际指向 Derived
7
std::cout << "dynamic_cast successful" << std::endl;
8
}
9
10
Base* pb2 = new Base;
11
if (Derived* pd2 = dynamic_cast<Derived*>(pb2)) { // Downcasting,pb2 实际指向 Base
12
// 不会进入这里,pd2 为 nullptr
13
}
14
15
Base* pb3 = new Another;
16
// Another* pa = dynamic_cast<Another*>(pb3); // 交叉转换,需要多重继承和虚基类等复杂场景
▮▮▮▮⚝ const_cast
:
▮▮▮▮▮▮▮▮❶ 用途: 唯一能用于添加或移除对象 const
或 volatile
限定符的类型转换操作符。
▮▮▮▮▮▮▮▮❷ 特点: 只能改变对象的常量性或易变性。滥用 const_cast
修改原本是 const
的对象的行为是未定义行为。通常用于调用需要非 const
参数但已知不会修改实际对象的函数。
1
const int const_val = 10;
2
// int* p = &const_val; // 错误
3
int* p = const_cast<int*>(&const_val); // 移除 const 限定符
4
5
// *p = 20; // 未定义行为,因为 const_val 实际存储在只读内存或优化掉了
6
std::cout << const_val << ", " << *p << std::endl; // 输出可能不同
7
8
void print_str(char* s) { std::cout << s << std::endl; }
9
const char* cs = "hello";
10
// print_str(cs); // 错误
11
print_str(const_cast<char*>(cs)); // 移除 const 限定符以匹配函数签名 (如果函数确实不修改字符串)
▮▮▮▮⚝ reinterpret_cast
:
▮▮▮▮▮▮▮▮❶ 用途: 用于执行低级别的、位模式的转换。例如,将一个指针转换为一个整数,或将一个函数指针转换为另一个不同类型的函数指针,或将一个指针转换为另一个完全不相关的类型的指针。
▮▮▮▮▮▮▮▮❷ 特点: 这种转换的风险最高,几乎没有任何类型安全检查。转换结果高度依赖于具体的平台和编译器。通常用于与底层硬件交互、类型双关 (Type Punning)、或在极少数需要绕过类型系统的场景。
1
int i = 10;
2
int* p = &i;
3
long address = reinterpret_cast<long>(p); // 指针转换为整数
4
5
struct MyData { int x; double y; };
6
char* buffer = new char[sizeof(MyData)];
7
MyData* data = reinterpret_cast<MyData*>(buffer); // 将 char* 转换为 MyData* (小心对齐问题)
8
9
// reinterpret_cast 经常与 static_cast 结合使用
⚝ 总结: 应优先使用 static_cast
进行类型转换,它比 C 风格转换更安全、更清晰。在需要安全向下转换多态类型时使用 dynamic_cast
。只有在需要移除常量性时使用 const_cast
(且需谨慎)。只有在迫不得已进行底层位模式转换时才使用 reinterpret_cast
。
Appendix B9.2: 设计模式与 OOP (SOLID 原则)
问题:* 请简要介绍 SOLID 设计原则在 C++ OOP 设计中的应用。
解析:
⚝ SOLID 是面向对象设计中的五项基本原则的首字母缩写,旨在帮助开发者创建更易于理解、更灵活、更易于维护和扩展的软件系统。这些原则与 C++ 的 OOP 特性紧密相关。
▮▮▮▮⚝ S - Single Responsibility Principle (单一职责原则):
▮▮▮▮▮▮▮▮❶ 原则: 一个类或模块应该只有一个被修改的原因。也就是说,一个类只负责一项功能或职责。
▮▮▮▮▮▮▮▮❷ C++ OOP 应用: 设计类时,避免让一个类承担过多的功能。例如,一个类不应该既处理数据存储又处理用户界面显示。这使得类更小、更易于理解和测试,也降低了修改某个功能对其他不相关功能的影响。
▮▮▮▮⚝ O - Open/Closed Principle (开放封闭原则):
▮▮▮▮▮▮▮▮❶ 原则: 软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。
▮▮▮▮▮▮▮▮❷ C++ OOP 应用: 通过继承和多态来实现。定义一个稳定的基类或接口,并在派生类中实现新的功能或修改行为,而无需修改基类代码。虚函数和抽象类是实现这一原则的关键工具。
▮▮▮▮⚝ L - Liskov Substitution Principle (里氏替换原则):
▮▮▮▮▮▮▮▮❶ 原则: 所有使用基类的地方,都可以透明地替换为它的派生类,而不会影响程序的正确性。
▮▮▮▮▮▮▮▮❷ C++ OOP 应用: 派生类在重写基类虚函数时,不能改变原有的行为契约(如前置条件、后置条件、不变式等)。这确保了通过基类指针或引用调用虚函数时,无论实际对象是基类还是派生类,程序的行为都是符合预期的。违反该原则可能导致难以预料的错误。
▮▮▮▮⚝ I - Interface Segregation Principle (接口隔离原则):
▮▮▮▮▮▮▮▮❶ 原则: 客户端不应该被迫依赖于它们不使用的接口。与其提供一个大而全的接口,不如提供多个小而专的接口。
▮▮▮▮▮▮▮▮❷ C++ OOP 应用: 避免设计“胖”接口 (Fat Interface)。将功能分组,定义多个细粒度的抽象基类(接口),而不是一个包含所有方法的基类。类可以实现多个接口,客户端只需要依赖于它们实际需要的接口。这降低了类之间的耦合度。
▮▮▮▮⚝ D - Dependency Inversion Principle (依赖倒置原则):
▮▮▮▮▮▮▮▮❶ 原则: 高层模块不应该依赖于底层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
▮▮▮▮▮▮▮▮❷ C++ OOP 应用: 高层组件和底层组件都依赖于抽象(如接口或抽象类)。通过基类指针或引用与对象交互,而不是直接依赖于具体的派生类实现。这使得系统更加灵活,易于替换底层实现,也方便进行单元测试。依赖注入 (Dependency Injection) 是实现这一原则的常见方式。
Appendix B10: 其他常见问题
Appendix B10.1: 内存管理
问题:* C++ 中 new
和 delete
操作符与 malloc
和 free
函数有什么区别?在使用 new
时,何时应该使用 delete
,何时应该使用 delete[]
?
解析:
⚝ new
/ delete
vs malloc
/ free
:
▮▮▮▮⚝ 来源: new
和 delete
是 C++ 语言的关键字和操作符,是 C++ 内存管理的方式。malloc
和 free
是 C 标准库函数,包含在 <cstdlib>
(或 <stdlib.h>
) 头文件中。
▮▮▮▮⚝ 功能:
▮▮▮▮▮▮▮▮❶ malloc
分配一块原始的、未初始化的内存,返回 void*
指针,需要显式转换类型。free
释放 malloc
分配的内存。它们只负责内存的分配和释放,不涉及对象的构造和析构。
▮▮▮▮▮▮▮▮❷ new
分配内存 并 调用对象的构造函数进行初始化。delete
调用对象的析构函数 并 释放内存。它们负责对象的完整生命周期管理(构造+内存管理)。
▮▮▮▮⚝ 类型安全: new
返回的是指定类型的指针,无需强制类型转换,更具类型安全性。malloc
返回 void*
,需要手动转换。
▮▮▮▮⚝ 重载: new
和 delete
可以被用户为特定类或全局地重载,以定制内存分配和释放行为。malloc
和 free
不能被重载。
⚝ 何时使用 delete
vs delete[]
:
▮▮▮▮⚝ delete
: 用于释放使用 new
单个对象 分配的内存。它调用该对象的析构函数。
1
MyClass* obj = new MyClass;
2
// ...
3
delete obj; // 释放单个对象
▮▮▮▮⚝ delete[]
: 用于释放使用 new
数组 分配的内存。它会为数组中的每个元素依次调用析构函数,然后释放整块内存。
1
MyClass* arr = new MyClass[10];
2
// ...
3
delete[] arr; // 释放对象数组
▮▮▮▮⚝ 重要性: 必须使用与分配时匹配的释放方式。使用 delete
释放 new[]
分配的数组会导致除了第一个元素外其他元素的析构函数不会被调用,从而引起资源泄露和未定义行为。使用 delete[]
释放 new
分配的单个对象通常不会立即崩溃(特别是对于 POD 类型),但仍然是未定义行为,不应依赖。对于 POD 类型数组,delete
和 delete[]
可能都能释放内存,但仍然强烈建议使用 delete[]
来保持代码的一致性和正确性,因为未来类可能不再是 POD 类型。
Appendix B10.2: 其他概念
问题:* 什么是浅拷贝 (Shallow Copy) 和深拷贝 (Deep Copy)?何时需要实现深拷贝?
解析:
⚝ 浅拷贝 (Shallow Copy): 默认的成员逐个拷贝 (Memberwise Copy)。当发生浅拷贝时,只复制成员变量的值。如果类中包含指针成员,浅拷贝只会复制指针本身的值(即指针指向的地址),而不会复制指针指向的内容。结果是多个对象中的指针成员指向同一块内存。
⚝ 深拷贝 (Deep Copy): 手动实现的拷贝行为。当发生深拷贝时,不仅复制成员变量的值,如果成员变量是指针,还会分配新的内存,并复制指针指向的内容。每个对象都有自己独立的资源副本。
⚝ 何时需要深拷贝: 当类中包含指向动态分配资源的指针 (如堆内存、文件句柄等) 时,通常需要实现深拷贝。
▮▮▮▮⚝ 如果使用默认的浅拷贝,当原始对象或拷贝对象中的任何一个被销毁时,其析构函数会释放共享的资源。当另一个对象被销毁时,它的析构函数会试图再次释放同一块已经被释放的内存,导致二次释放 (Double Free) 错误,从而引发程序崩溃或未定义行为。
▮▮▮▮⚝ 实现深拷贝需要自定义拷贝构造函数 (Copy Constructor) 和拷贝赋值运算符 (Copy Assignment Operator),在其中手动分配新资源并复制内容。
1
class MyData {
2
private:
3
int* data;
4
int size;
5
public:
6
MyData(int s) : size(s), data(new int[s]) {} // 构造函数,分配内存
7
8
// 拷贝构造函数 (深拷贝)
9
MyData(const MyData& other) : size(other.size), data(new int[other.size]) {
10
std::copy(other.data, other.data + size, data); // 复制内容
11
}
12
13
// 拷贝赋值运算符 (深拷贝)
14
MyData& operator=(const MyData& other) {
15
if (this != &other) { // 防止自赋值
16
delete[] data; // 释放原有资源
17
size = other.size;
18
data = new int[size]; // 分配新资源
19
std::copy(other.data, other.data + size, data); // 复制内容
20
}
21
return *this;
22
}
23
24
// 析构函数 (释放资源)
25
~MyData() {
26
delete[] data;
27
data = nullptr; // 避免悬空指针
28
}
29
30
// 需要时还需要实现移动构造函数和移动赋值运算符 (C++11) 来优化性能
31
};
Appendix C: 术语对照表
本书旨在深入解析 C++ 的面向对象技术。在学习过程中,掌握准确的术语是理解概念的基础。本附录整理了本书中出现的主要面向对象和 C++ 相关术语,提供中英文对照,以帮助读者查阅和巩固知识。
⚝ 抽象类 (Abstract Class): 包含至少一个纯虚函数(Pure Virtual Function)的类,不能被直接实例化,主要用于定义接口或作为其他类的基类。
⚝ 适配器模式 (Adapter Pattern): 一种结构型设计模式 (Structural Pattern),将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以协同工作。
⚝ 算法 (Algorithm): 执行特定任务的步骤序列。在 C++ 标准库 (Standard Template Library, STL) 中,算法是独立于容器 (Container) 的函数模板 (Function Template)。
⚝ 异常安全 (Exception Safety): 程序在发生异常 (Exception) 时,状态仍然处于有效或可预测的状态。
⚝ 异常处理 (Exception Handling): C++ 中用于处理运行时错误 (Runtime Error) 的一种机制,通过 try, catch, throw 关键字实现。
⚝ 异常规格 (Exception Specification): C++98 中用于声明函数可能抛出的异常类型,在 C++11 中已废弃,推荐使用 noexcept 关键字。
⚝ 依赖倒置原则 (Dependency Inversion Principle): 面向对象设计原则 (Object-Oriented Design Principles) 之一,高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
⚝ 单一继承 (Single Inheritance): 一个派生类 (Derived Class) 只从一个基类 (Base Class) 继承。
⚝ 单一职责原则 (Single Responsibility Principle): 面向对象设计原则 (Object-Oriented Design Principles) 之一,一个类或模块只应该有一个改变的原因。
⚝ 动态多态 (Dynamic Polymorphism): 在程序运行时,通过基类指针 (Base Class Pointer) 或引用 (Reference) 调用虚函数 (Virtual Function),实际执行的是派生类中的版本。依赖于虚函数和继承 (Inheritance)。
⚝ 构造函数 (Constructor): 类 (Class) 的一个特殊成员函数 (Member Function),用于创建对象 (Object) 时初始化对象的状态。
⚝ 纯虚函数 (Pure Virtual Function): 在类声明 (Class Declaration) 中带有 = 0
的虚函数,它没有函数体 (Function Body),强制派生类必须提供实现 (Implementation)。
⚝ const 成员函数 (const Member Function): 声明为 const 的成员函数,承诺不修改对象的成员变量 (Member Variable)。
⚝ const 对象 (const Object): 声明为 const 的对象,只能调用其 const 成员函数。
⚝ 拷贝构造函数 (Copy Constructor): 一个特殊的构造函数,用于创建一个新对象作为现有对象的副本 (Copy)。
⚝ 创建型模式 (Creational Patterns): 设计模式 (Design Pattern) 的一种分类,关注对象的创建机制。
⚝ 成员访问控制 (Member Access Control): 用于控制类成员(变量和函数)在类外部、派生类或友元 (Friend) 中的可见性和可访问性,通过 public, private, protected 访问修饰符 (Access Specifier) 实现。
⚝ 成员变量 (Member Variable): 类中用于存储对象状态的数据成员。
⚝ 成员函数 (Member Function): 类中用于定义对象行为的函数成员。
⚝ 多态 (Polymorphism): 允许使用一个接口 (Interface) 表示多种不同类型的能力。在 C++ 中体现为静态多态 (Static Polymorphism) 和动态多态。
⚝ 多重继承 (Multiple Inheritance): 一个派生类从多个基类继承。
⚝ 默认构造函数 (Default Constructor): 没有参数的构造函数。如果没有定义任何构造函数,编译器有时会默认生成一个。
⚝ 默认成员函数 (Default Member Functions): 编译器可能自动生成的成员函数,如默认构造函数、拷贝构造函数、拷贝赋值运算符 (Copy Assignment Operator)、移动构造函数 (Move Constructor)、移动赋值运算符 (Move Assignment Operator) 和析构函数 (Destructor)。
⚝ 动态类型转换 (Dynamic Cast): C++ 中用于在类层次结构中进行运行时类型安全转换的操作符 dynamic_cast。
⚝ 泛型编程 (Generic Programming): 编程范式 (Programming Paradigm),通过使用类型参数(如模板)来编写可以适用于多种数据类型的代码。
⚝ 赋值运算符 (Assignment Operator): 用于将一个对象的值赋给另一个同类型对象的运算符 (=)。
⚝ 访问修饰符 (Access Specifier): public, private, protected 关键字,用于指定类成员的访问权限。
⚝ 工厂模式 (Factory Pattern): 一种创建型设计模式,用于创建对象而无需指定创建对象的具体类。
⚝ 封装 (Encapsulation): 面向对象 (Object-Oriented) 的三大支柱之一,将数据和操作数据的方法绑定在一起,隐藏内部实现细节 (Implementation Details),对外提供统一的接口。
⚝ 接口隔离原则 (Interface Segregation Principle): 面向对象设计原则 (Object-Oriented Design Principles) 之一,不应该强迫客户端 (Client) 依赖于它们不使用的接口。
⚝ getter 方法 (Getter Method): 公有 (public) 成员函数,用于获取类中私有 (private) 成员变量的值。
⚝ 公有 (public): 类成员访问修饰符,该成员在任何地方都可访问。
⚝ 构造函数的初始化列表 (Initialization List): 构造函数定义后面紧跟着冒号和成员变量列表,用于在对象创建时初始化成员变量。
⚝ 观察者模式 (Observer Pattern): 一种行为型设计模式 (Behavioral Pattern),定义对象间的一种一对多依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
⚝ 面向对象设计原则 (Object-Oriented Design Principles): 指导如何设计高质量、可维护、可扩展的面向对象软件的原则集合,如 SOLID 原则。
⚝ 菱形继承问题 (Diamond Problem): 在多重继承中,当一个类从两个或多个共享同一个祖先的类继承时,可能导致祖先类的成员在最终派生类中有多份拷贝,造成访问上的歧义。
⚝ 行为型模式 (Behavioral Patterns): 设计模式的一种分类,关注对象之间的交互和职责分配。
⚝ 堆对象 (Heap Object): 在程序运行时使用 new 关键字在堆内存 (Heap Memory) 中动态分配创建的对象。
⚝ this 指针 (this Pointer): 类成员函数中一个隐含的指针 (Pointer),指向调用该成员函数的对象本身。
⚝ 继承 (Inheritance): 面向对象 (Object-Oriented) 的三大支柱之一,允许一个类(派生类)继承另一个类(基类)的属性和行为,实现代码复用 (Code Reusability) 和扩展 (Extension)。
⚝ 接口 (Interface): 对外提供功能的约定或契约,在 C++ 中通常通过包含纯虚函数的抽象类或 C++20 的 Concepts 来体现。
⚝ 迭代器 (Iterator): 一种对象,用于遍历容器 (Container) 中的元素,提供访问容器中每个元素的方法。
⚝ 局部变量 (Local Variable): 在函数 (Function) 或代码块 (Code Block) 内部声明的变量,其生命周期 (Lifetime) 局限于该函数或代码块。
⚝ 类 (Class): 面向对象编程 (OOP) 的基本构建块,是创建对象 (Object) 的模板或蓝图 (Blueprint),定义了对象的属性(成员变量 (Member Variable))和行为(成员函数 (Member Function))。
⚝ 类模板 (Class Template): 用于创建可以处理多种数据类型的通用类的模板 (Template)。
⚝ 链接 (Linking): 将编译 (Compilation) 生成的多个目标文件 (Object File) 和所需的库文件 (Library File) 组合成一个可执行文件 (Executable File) 或库的过程。
⚝ 里氏替换原则 (Liskov Substitution Principle): 面向对象设计原则 (Object-Oriented Design Principles) 之一,子类 (Subclass) 应该可以替换掉它们的基类 (Base Class) 而不影响程序的正确性。
⚝ 类型转换操作符 (Casting Operator): C++ 中用于进行类型转换的运算符,如 static_cast, const_cast, reinterpret_cast, dynamic_cast。
⚝ 面向对象编程 (Object-Oriented Programming / OOP): 一种编程范式 (Programming Paradigm),将程序设计看作是对象之间的交互,强调封装 (Encapsulation)、继承 (Inheritance) 和多态 (Polymorphism) 等概念。
⚝ 面向过程编程 (Procedural Programming): 一种编程范式 (Programming Paradigm),将程序组织为一系列函数 (Function) 或过程的集合,强调算法 (Algorithm) 和数据结构 (Data Structure) 的分离。
⚝ override 关键字 (override keyword): C++11 引入的关键字,用于明确表示派生类中的函数是重写 (Override) 基类中的虚函数 (Virtual Function),有助于编译器检查错误。
⚝ 偏特化 (Partial Specialization): 对类模板 (Class Template) 或函数模板 (Function Template) 进行特化 (Specialization),但不是针对所有模板参数,而是部分特化。
⚝ 派生类 (Derived Class): 通过继承 (Inheritance) 从另一个类(基类 (Base Class))派生出的新类。
⚝ shared_ptr (shared_ptr): C++ 标准库 (Standard Library) 中的一种智能指针 (Smart Pointer),实现了共享所有权 (Shared Ownership) 语义,通过引用计数 (Reference Count) 管理对象的生命周期 (Lifetime)。
⚝ setter 方法 (Setter Method): 公有 (public) 成员函数,用于设置类中私有 (private) 成员变量的值。
⚝ 单例模式 (Singleton Pattern): 一种创建型设计模式 (Creational Pattern),确保一个类只有一个实例,并提供一个全局访问点。
⚝ 静态多态 (Static Polymorphism): 在程序编译时 (Compile Time) 确定的多态性,通过函数重载 (Function Overloading) 和运算符重载 (Operator Overloading) 等实现。
⚝ 声明 (Declaration): 告诉编译器 (Compiler) 一个名称(变量、函数、类等)及其类型,但不一定提供完整的定义 (Definition)。
⚝ 结构型模式 (Structural Patterns): 设计模式 (Design Pattern) 的一种分类,关注类和对象的组合。
⚝ 策略模式 (Strategy Pattern): 一种行为型设计模式 (Behavioral Pattern),定义一系列算法 (Algorithm),并将每个算法封装起来,使它们可以相互替换。
⚝ 生存期 / 生命周期 (Lifetime): 对象 (Object) 从被创建到被销毁的整个过程。
⚝ STL (Standard Template Library): C++ 标准库的一部分,提供了容器 (Container)、迭代器 (Iterator)、算法 (Algorithm) 和函数对象 (Function Object) 等组件。
⚝ 私有 (private): 类成员访问修饰符,该成员只能在类的内部访问。
⚝ 特化 (Specialization): 为特定的模板参数类型提供模板的独立实现 (Implementation)。
⚝ 模板 (Template): C++ 中实现泛型编程 (Generic Programming) 的工具,可以用于创建函数模板 (Function Template) 和类模板 (Class Template)。
⚝ throw 关键字 (throw keyword): 在 C++ 异常处理 (Exception Handling) 中用于抛出异常 (Exception)。
⚝ protected (protected): 类成员访问修饰符,该成员可以在类的内部和其派生类 (Derived Class) 中访问。
⚝ typeid 运算符 (typeid operator): C++ 中用于获取表达式的运行时类型信息 (Runtime Type Information, RTTI) 的运算符,返回一个 std::type_info 对象的引用 (Reference)。
⚝ unique_ptr (unique_ptr): C++ 标准库 (Standard Library) 中的一种智能指针 (Smart Pointer),实现了独占所有权 (Exclusive Ownership) 语义,确保资源 (Resource) 在其所有者销毁时被释放。
⚝ 虚函数 (Virtual Function): 在基类 (Base Class) 中声明的、期望在派生类 (Derived Class) 中被重写 (Override) 的成员函数 (Member Function),使用 virtual 关键字标识,是实现动态多态 (Dynamic Polymorphism) 的基础。
⚝ 虚函数表 (Virtual Table / vtable): 编译器为包含虚函数的类 (Class) 生成的一张表,存储类中虚函数的地址。
⚝ 虚继承 (Virtual Inheritance): 用于解决多重继承 (Multiple Inheritance) 中的菱形继承问题 (Diamond Problem),确保共享的基类子对象在派生类中只有一份拷贝。
⚝ 虚指针 (Virtual Pointer / vptr): 每个包含虚函数或继承自包含虚函数的类的对象 (Object) 中隐含的一个指针 (Pointer),指向该类的虚函数表 (Virtual Table)。
⚝ 析构函数 (Destructor): 类的一个特殊成员函数 (Member Function),用于对象被销毁时执行清理工作,如释放动态分配的内存 (Memory)。
⚝ 实例化 (Instantiation): 根据模板 (Template) 创建特定类型的类 (Class) 或函数 (Function) 的过程。
⚝ 系统 (System): 泛指由相互关联的组件构成的整体,在软件开发中通常指代待开发的软件或模块。
⚝ 异常 (Exception): 在程序执行期间发生的、阻止正常程序流程继续的事件。
⚝ 移动构造函数 (Move Constructor): C++11 引入的特殊构造函数 (Constructor),用于通过“窃取”临时对象 (Temporary Object) 的资源 (Resource) 来初始化新对象,实现高效的资源转移 (Resource Transfer)。
⚝ 移动语义 (Move Semantics): C++11 引入的特性,允许资源(如内存、文件句柄)从一个对象高效地转移到另一个对象,而不是进行深拷贝 (Deep Copy)。
⚝ 引用 (Reference): C++ 中一个已存在对象的别名 (Alias)。
⚝ 引用计数 (Reference Count): 智能指针 (Smart Pointer) shared_ptr 使用的一种技术,记录有多少个智能指针指向同一个对象。
⚝ 运行时类型信息 (Runtime Type Information / RTTI): C++ 语言在程序运行时 (Runtime) 提供的查询对象类型 (Object Type) 的能力。
⚝ RAII (Resource Acquisition Is Initialization): 一种 C++ 编程惯用法,将资源(如内存、文件句柄、锁)的生命周期 (Lifetime) 绑定到对象的生命周期,利用对象的构造函数 (Constructor) 获取资源并在析构函数 (Destructor) 中释放资源,从而确保资源被正确管理。
⚝ weak_ptr (weak_ptr): C++ 标准库 (Standard Library) 中的一种智能指针 (Smart Pointer),不拥有对象,是对 shared_ptr 管理的对象的非拥有性引用,用于解决 shared_ptr 可能导致的循环引用 (Circular Reference) 问题。
⚝ 全特化 (Full Specialization): 对类模板 (Class Template) 或函数模板 (Function Template) 针对所有模板参数提供具体的类型或值进行特化 (Specialization)。
⚝ 野指针 (Dangling Pointer): 指向已经被释放的内存区域的指针 (Pointer)。
⚝ 重载 (Overloading): 在同一作用域 (Scope) 内,允许函数 (Function) 或运算符 (Operator) 拥有相同名称但参数列表 (Parameter List) 不同。
⚝ 作用域 (Scope): 标识符(变量名、函数名等)在程序中可见和有效的范围。
⚝ 资源 (Resource): 程序需要获取和释放的任何东西,如内存 (Memory)、文件句柄 (File Handle)、网络连接 (Network Connection)、锁 (Lock) 等。
⚝ 自定义 RAII 类 (Custom RAII Class): 用户为特定类型的资源(非标准库提供的智能指针能管理的资源)设计的遵循 RAII 原则的类。
⚝ 左值 (Lvalue): 指向内存位置的表达式,可以出现在赋值号的左边。
⚝ 右值 (Rvalue): 没有持久存储位置的临时值或表达式结果,通常出现在赋值号的右边。
⚝ 智能指针 (Smart Pointer): 封装裸指针 (Raw Pointer) 的对象 (Object),通过面向对象技术自动管理内存和其他资源 (Resource),遵循 RAII 原则。
好的,作为一名经验丰富的讲师,我将严格按照您提供的书籍大纲和格式要求,为您撰写《C++ 面向对象技术深度解析与实践》一书的附录 D:参考文献部分。
Appendix D: 参考文献
本附录列出了撰写本书过程中参考的重要书籍、官方文档和在线资源。这些资源为本书提供了扎实的理论基础、丰富的实践案例以及最新的 C++ 标准信息。读者可以通过查阅这些文献,进一步深入学习和拓展知识视野。
📚 本书的撰写秉持了严谨治学的态度,力求概念清晰、解析深入、示例准确。列出的参考文献仅是冰山一角,但它们构成了 C++ 学习路径中不可或缺的重要里程碑。
Appendix D1: 经典 C++ 书籍
以下是 C++ 领域一些公认的经典著作,它们对本书的面向对象部分有着深刻影响,也强烈推荐给希望深入学习 C++ 的读者。
① 《C++ 程序设计原理与实践》(Programming: Principles and Practice Using C++)
▮▮▮▮ⓑ 作者 (Author): Bjarne Stroustrup
▮▮▮▮ⓒ 描述 (Description): C++ 语言创建者所著,从基础概念出发,循序渐进地讲解 C++ 的编程原理和实践,是学习 C++ 的绝佳入门和进阶书籍。
▮▮▮▮ⓓ 相关性 (Relevance): 提供了 C++ 语言设计哲学和面向对象思想的权威解释。
② 《C++ Primer (5th Edition)》
▮▮▮▮ⓑ 作者 (Author): Stanley B. Lippman, Josée Lajoie, Barbara E. Moo
▮▮▮▮ⓒ 描述 (Description): 一本广受欢迎的 C++ 教程,内容详尽,覆盖了 C++11 标准的诸多特性,适合系统学习。
▮▮▮▮ⓓ 相关性 (Relevance): 对 C++ 核心语言特性和标准库进行了全面介绍,包括 OOP 的各个方面。
③ 《Effective C++》系列(包括 Effective C++, More Effective C++, Effective Modern C++)
▮▮▮▮ⓑ 作者 (Author): Scott Meyers
▮▮▮▮ⓒ 描述 (Description): 专注于 C++ 编程实践中的经验法则和最佳实践,以条款 (Item) 的形式组织,深入浅出。
▮▮▮▮ⓓ 相关性 (Relevance): 提供了大量关于类设计、资源管理(RAII、智能指针)、继承、多态、模板等 OOP 相关主题的实用建议和注意事项。
④ 《The C++ Programming Language (4th Edition)》
▮▮▮▮ⓑ 作者 (Author): Bjarne Stroustrup
▮▮▮▮ⓒ 描述 (Description): C++ 语言的“圣经”,内容最全面、最权威,覆盖了 C++11 标准,适合作为参考手册使用。
▮▮▮▮ⓓ 相关性 (Relevance): C++ 语言本身的定义和详细规范,是理解 OOP 机制底层原理的重要来源。
⑤ 《Design Patterns: Elements of Reusable Object-Oriented Software》
▮▮▮▮ⓑ 作者 (Author): Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (Gang of Four, GoF)
▮▮▮▮ⓒ 描述 (Description): 设计模式领域的开创性著作,定义了常用的软件设计模式。
▮▮▮▮ⓓ 相关性 (Relevance): 虽然不专注于 C++,但书中描述的模式可以直接应用于 C++ 的面向对象设计中,对本书第 12 章有直接参考价值。
⑥ 《Modern C++ Design: Generic Programming and Design Patterns Applied》
▮▮▮▮ⓑ 作者 (Author): Andrei Alexandrescu
▮▮▮▮ⓒ 描述 (Description): 探讨了如何利用 C++ 的模板 (Template) 和泛型编程 (Generic Programming) 特性实现高级的设计模式。
▮▮▮▮ⓓ 相关性 (Relevance): 将泛型编程与 OOP 结合,提供了更高级的设计和实现技巧,对理解模板在 OOP 中的作用有启发。
Appendix D2: 官方文档与在线资源
官方文档和权威的在线资源是学习 C++ 标准特性、查询具体语法和库函数最直接、最准确的途径。
⚝ C++ Reference (cppreference.com)
▮▮▮▮⚝ 描述 (Description): 提供 C++ 标准库 (Standard Library)、C++ 语言特性、C 标准库等的详细参考文档,信息非常全面和准确。
▮▮▮▮⚝ 相关性 (Relevance): 查询 C++ 语言特性(如关键字、类型系统、成员函数)、标准库组件(如智能指针、容器)的权威来源。
⚝ Cplusplus.com
▮▮▮▮⚝ 描述 (Description): 另一个流行的 C++ 参考网站,提供语言教程、标准库参考和论坛等。
▮▮▮▮⚝ 相关性 (Relevance): 提供补充性的语言和库函数参考,以及一些基础教程。
⚝ Bjarne Stroustrup's Homepage
▮▮▮▮⚝ 描述 (Description): C++ 语言创建者 Bjarne Stroustrup 的个人网站,包含了他对 C++ 特性、设计哲学、历史演变等方面的文章和演讲资料。
▮▮▮▮⚝ 相关性 (Relevance): 获取 C++ 设计背后思想和原则的直接渠道。
⚝ C++ Standards Papers (isocpp.org/std/status)
▮▮▮▮⚝ 描述 (Description): C++ 标准化委员会 (ISO C++ Committee) 发布的工作草案和标准文档。
▮▮▮▮⚝ 相关性 (Relevance): C++ 语言最官方、最原始的定义,适合追求极致准确性的专家级读者查阅,了解最新标准特性。
⚝ Stack Overflow (stackoverflow.com)
▮▮▮▮⚝ 描述 (Description): 程序员问答社区,包含大量关于 C++ 编程问题的讨论和解决方案。
▮▮▮▮⚝ 相关性 (Relevance): 在实践中遇到具体问题时,查询解决方案、理解常见陷阱的宝贵资源。需要具备辨别信息正确性的能力。
⚝ 各大编译器的官方文档 (如 GCC, Clang, MSVC)
▮▮▮▮⚝ 描述 (Description): 编译器提供的关于其特性、警告、错误信息以及特定扩展的文档。
▮▮▮▮⚝ 相关性 (Relevance): 了解 C++ 特性在不同编译器上的实现细节、排查编译和链接错误。
Appendix D3: 其他参考资料
除了上述核心资源,撰写本书时也参考了大量的技术博客、学术论文、会议演讲(如 CppCon)等,这些资源提供了更前沿的讨论、深入的分析和新的编程范式。由于数量庞大且时效性较强,在此不一一列举具体链接,但它们共同构成了本书知识体系的广度和深度。
🔍 阅读这些丰富的资源,是持续提升 C++ 技能和理解面向对象精髓的关键。