C++ 学习
编译过程:
C++ 语言的编译过程与 C 语言类似,但由于 C++ 语言的特性(如类、模板、名字空间等),编译过程可能会更加复杂。C++ 编译过程也可以分为几个主要阶段,下面是每个阶段的详细说明:
1. 预处理(Preprocessing)
预处理阶段与 C 语言的预处理类似,主要处理以 #
开头的指令。预处理的任务包括:
- 宏替换:将宏常量和宏函数替换为实际的值或代码。
- 文件包含:处理
#include
指令,展开所有包含的头文件。 - 条件编译:处理
#if
、#ifdef
、#endif
等条件编译指令,决定哪些代码会被编译。 - 删除注释:删除源代码中的注释部分。
预处理之后的输出是一个扩展了所有宏和包含文件的代码文件,通常是 .i
扩展名(中间文件)。
命令示例:
g++ -E source.cpp -o source.i
2. 编译(Compilation)
编译阶段将预处理后的 C++ 代码(.i
文件)转换为汇编语言(.s
文件)。与 C 语言一样,编译器在此阶段执行以下任务:
- 语法分析:检查代码是否符合 C++ 语言的语法规则,分析类、模板、函数等复杂的 C++ 特性。
- 语义分析:检查代码中的语义错误,特别是 C++ 特有的特性,如类的成员函数、虚函数、模板实例化等。
- 生成汇编代码:将 C++ 代码翻译为目标平台的汇编语言。此阶段会处理 C++ 特性,生成对应的汇编代码。
编译的输出是汇编语言文件,通常是 .s
文件。
命令示例:
g++ -S source.i -o source.s
3. 汇编(Assembly)
在汇编阶段,汇编语言文件(.s
文件)会被汇编器转换为机器代码(目标代码)。汇编器将汇编语言翻译为目标平台的二进制格式,并输出目标文件(通常为 .o
文件),其中包含了机器指令,但还未连接成一个可执行程序。
命令示例:
g++ -c source.s -o source.o
4. 链接(Linking)
链接阶段将一个或多个目标文件(.o
文件)和库文件链接成最终的可执行文件。链接的主要任务与 C 语言相同,但由于 C++ 的一些特性(如类、虚函数、模板等),链接过程更复杂。主要工作包括:
- 符号解析:解析目标文件中的符号(如函数、变量、类的成员)并进行匹配,确保所有符号都有正确定义和引用。
- 模板实例化:C++ 中的模板是编译时生成代码的,因此链接器需要处理模板的实例化。链接器需要确保每个模板实例化只有一个定义。
- 虚函数表:C++ 中的虚函数会生成虚函数表(vtable),链接器需要将这些虚函数表正确地链接到类中。
- 地址分配:为程序中的数据和代码分配内存地址。
- 重定位:调整目标文件中的地址,确保它们在最终的可执行文件中指向正确的位置。
如果程序依赖于外部库,链接器还会将这些库文件包含进来。最终的输出是一个可执行文件。
命令示例:
g++ source.o -o output
5. 执行(Execution)
当所有编译和链接工作完成后,生成的可执行文件可以被操作系统执行。操作系统负责加载可执行文件并启动程序执行。
一、基本概念
1.1 面向对象编程
面向对象编程(Object-Oriented Programming,简称 OOP)是一种程序设计范式,它将现实世界中的事物视为“对象”,并通过定义这些对象的属性(数据)和行为(方法)来构建程序。面向对象编程的核心思想是通过对对象的封装、继承和多态等特性来实现代码的重用性、可维护性和可扩展性。
面向对象编程(Object-Oriented Programming,简称 OOP)是一种程序设计范式,它将现实世界中的事物视为“对象”,并通过定义这些对象的属性(数据)和行为(方法)来构建程序。面向对象编程的核心思想是通过对对象的封装、继承和多态等特性来实现代码的重用性、可维护性和可扩展性。
面向对象编程的四大基本特性包括
封装(Encapsulation):
封装是指将对象的状态(属性)和行为(方法)捆绑在一起,保护对象的内部数据,避免外部直接访问。外部只能通过对象提供的公共方法(接口)来与对象交互,确保了数据的安全性和隐藏了实现细节。
继承(Inheritance):
继承是指一个类可以从另一个类继承属性和方法,从而实现代码的复用和扩展。通过继承,可以创建一个新类,它可以获得原有类的功能,还可以添加或修改一些特性。
多态(Polymorphism):
多态是指同一方法或操作可以作用于不同的对象,并产生不同的行为。多态通过方法重载(同名方法不同参数)和方法重写(子类覆盖父类的方法)来实现。
抽象(Abstraction):
抽象是指将对象的共同特性提取出来,通过类来定义。抽象类是没有具体实现的类,它只提供一个接口,由子类来实现具体的功能。抽象使得复杂的系统能够简化为可管理的模块。
面向对象编程的基本组成部分:
- 类(Class):是创建对象的蓝图或模板,定义了对象的属性和方法。
- 对象(Object):是类的实例,是类的实际表现,拥有类中定义的属性和方法。
- 方法(Method):是定义在类中的函数,用来描述对象的行为。
- 属性(Attribute):是定义在类中的变量,用来描述对象的状态。
面向对象的优势:
- 代码重用:通过继承和多态,可以减少代码重复,提高代码的复用性。
- 模块化:每个类可以独立开发,具有较强的内聚性,且类之间的耦合性较低,有利于程序的维护和扩展。
- 更接近现实世界:面向对象的模型能更自然地映射到现实世界的实体,便于理解和建模复杂问题。
1.2 泛型编程
泛型编程(generic programming)是 C++ 支持的另一种编程模式。它与面向对象编程(OOP)的目标相同,即使重用代码和抽象通用概念的技术更简单。不过,面向对象编程强调的是编程的数据方面,而泛型编程强调的是独立于特定数据类型。它们的侧重点不同。面向对象编程是一个管理大型项目的工具,而泛型编程提供了执行常见任务(如对数据排序或合并链表)的工具。术语 “泛型”(generic)指的是创建独立于类型的代码。C++ 的数据表示有多种类型 —— 整数、小数、字符、字符串、用户定义的、由多种类型组成的复合结构。例如,要对不同类型的数据进行排序,通常必须为每种类型创建一个排序函数。泛型编程需要对语言进行扩展,以便可以只编写一个泛型(即不是特定类型的)函数,并将其用于各种实际类型。C++ 模板提供了完成这种任务的机制。
二、基本语法
2.1 预处理器
C++ 预处理器是编译过程中的重要环节,负责在编译前对源代码进行文本替换、条件编译和文件包含等处理。预处理器指令提供了强大的功能,可以通过宏定义、条件编译等方式增强代码的可移植性和灵活性。但同时也需要小心使用,避免宏展开带来的潜在问题。
2.2 命名空间
命名空间(Namespace)是 C++ 中用于组织和隔离代码的一个机制。它通过为标识符(如变量、函数、类等)分配一个独立的作用域,避免命名冲突,特别是当多个库或模块中有相同名称的标识符时,命名空间提供了一种方式来区分它们。
- 命名空间的定义
命名空间通过 namespace
关键字定义,可以在其中声明和定义变量、函数、类、类型别名等。
namespace NamespaceName {
// 变量、函数、类等声明和定义
int variable;
void func() {
// Function body
}
class MyClass {
// Class body
};
}
- 命名空间的作用和用途
命名空间最主要的作用是避免不同库或模块之间的命名冲突。在大型项目或跨平台的开发中,可能会使用多个库,这些库中可能会有相同的函数名、类名等。命名空间可以有效避免这种冲突。
例如,如果两个库都定义了一个 print
函数,我们可以使用命名空间将它们区分开来:
namespace LibraryA {
void print() {
std::cout << "LibraryA print function\n";
}
}
namespace LibraryB {
void print() {
std::cout << "LibraryB print function\n";
}
}
int main() {
LibraryA::print(); // 调用 LibraryA 的 print 函数
LibraryB::print(); // 调用 LibraryB 的 print 函数
return 0;
}
在这个例子中,LibraryA::print
和 LibraryB::print
是两个不同的函数,尽管它们的名称相同,但由于被放置在不同的命名空间中,它们不会发生冲突。
- 组织代码结构
命名空间有助于将相关功能或组件组织在一起,使代码结构更加清晰。例如,可以将不同的模块、功能区域放入不同的命名空间,增强代码的可读性和可维护性。
namespace Math {
double add(double a, double b) {
return a + b;
}
double subtract(double a, double b) {
return a - b;
}
}
namespace StringOperations {
std::string concatenate(const std::string &a, const std::string &b) {
return a + b;
}
int findSubstr(const std::string &s, const std::string &substr) {
return s.find(substr);
}
}
在这个例子中,Math
命名空间包含了数学相关的操作,而 StringOperations
命名空间包含了字符串相关的操作。这样做可以有效地组织代码,方便后续维护和扩展。
- 分隔库或模块的接口和实现
通过使用命名空间,库开发者可以将接口和实现区分开来。例如,std
命名空间是 C++ 标准库的命名空间,它将所有的标准库组件(如 std::cout
, std::vector
等)封装在其中。这样,开发者可以轻松地分辨标准库和用户自定义的函数、类。
2.3 数据类型
基本数据类型 (Primitive Data Types)
-
整型 (Integer Types):
int
:常用的整数类型,通常占 4 字节。short
:较小的整数类型,通常占 2 字节。long
:较大的整数类型,通常占 4 字节。long long
:更大的整数类型,通常占 8 字节。unsigned int
:无符号整数类型,表示非负整数。unsigned short
、unsigned long
、unsigned long long
:对应的无符号整数类型。
-
字符型 (Character Types):
char
:字符类型,通常占 1 字节,表示一个字符。unsigned char
和signed char
:字符类型的无符号和有符号版本。
-
浮点型 (Floating-Point Types):
float
:单精度浮点数类型,通常占 4 字节。double
:双精度浮点数类型,通常占 8 字节。long double
:扩展精度浮点数类型,通常占 8 字节或 16 字节(平台依赖)。
-
布尔型 (Boolean Type):
bool
:布尔类型,表示true
或false
。
-
空类型 (Void Type):
void
:无类型,通常用来表示函数没有返回值,或者指针类型的通用指针。
派生数据类型 (Derived Data Types)
-
数组 (Array):一组相同类型的数据元素,可以是基本数据类型或其他数据类型的数组。
-
指针 (Pointer):指向某个数据类型的内存地址。
-
引用 (Reference):另一种形式的指针,表示对一个变量的别名。
int a = 10; int &ref = a; // 引用
-
函数 (Function):函数可以返回不同类型的值,且函数本身也可以作为参数传递。
枚举类型 (Enumeration Types)
enum
:定义一组命名的整数常量。enum Color { RED, GREEN, BLUE };
类和结构体 (Class and Structure)
-
结构体 (struct):用户定义的类型,可以包含多个不同类型的成员。
struct Person { string name; int age; };
-
类 (class):与结构体类似,但具有更多的特性,如访问控制、继承、多态等。
class Car { public: string brand; int year; };
联合体 (Union)
union
:多个成员共享同一块内存空间,只有一个成员可以被同时存储。union Data { int i; float f; char str[20]; };
类型别名 (Type Alias)
-
typedef
:为现有数据类型创建别名。typedef unsigned int uint;
-
using
:C++11 新增的方式,功能与typedef
相同。using uint = unsigned int;
标准库类型
-
字符串 (String):C++ 标准库提供了
std::string
类型,处理字符串。std::string name = "C++";
-
容器类型:如
std::vector
、std::list
、std::map
、std::set
等常用容器。
智能指针 (Smart Pointers)
std::unique_ptr
:独占所有权的智能指针。std::shared_ptr
:共享所有权的智能指针。std::weak_ptr
:弱引用的智能指针。
2.4 数据类型转换
数据类型转换
2.5 const 用法
const
是 C 和 C++ 中的一个关键字,用来修饰变量、指针、类成员等,表示这些对象的值不可改变,修饰后的变量为只读变量,本质上还是变量,可以通过const_cast去除const修饰。
1. 常量修饰符:用于修饰基本类型
当 const
用于基本类型时,它表示该变量是常量,在初始化之后其值不能被修改。
const int a = 10; // a 是一个常量,值为 10
a = 20; // 错误,a 的值不能被修改。
在上面的例子中,const int a = 10;
定义了一个常量 a
,并且其值在程序的任何地方都不能改变。如果尝试修改 a
的值,编译器会报错。
2. 常量指针:指针所指向的值不可修改
const
也可以与指针一起使用,表示指针所指向的对象是常量,不能通过该指针修改对象的值。指针本身是否可以修改取决于 const
的位置。
-
指针指向的对象为常量:
int a = 10; const int* ptr = &a; // ptr 是一个指向常量的指针 *ptr = 20; // 错误,不能通过 ptr 修改 a 的值 a = 20; // 正确,a 本身的值可以修改
-
指针本身是常量:
int a = 10; int* const ptr = &a; // ptr 是一个常量指针 ptr = &b; // 错误,不能改变 ptr 指向的地址 *ptr = 20; // 正确,指针可以修改指向的值
-
指针本身和指向的对象都为常量:
int a = 10; const int* const ptr = &a; // ptr 是一个常量指针,指向的值也是常量 *ptr = 20; // 错误,不能修改 ptr 指向的值 ptr = &b; // 错误,不能改变 ptr 指向的地址
2.6 结构体和共用体
1. 内存分配的方式
结构体(struct
)
在结构体中,每个成员都有独立的内存空间。结构体的总大小是各成员大小之和,可能会因为对齐要求而有额外的填充空间。
- 内存布局: 每个成员都有自己的内存空间。
- 内存使用: 所有成员的内存会同时存在,结构体的总大小是所有成员大小之和(考虑对齐)。
struct MyStruct {
int a; // 4 字节
char b; // 1 字节
float c; // 4 字节
};
MyStruct obj;
在上述代码中,MyStruct
包含 3 个成员:a
、b
和 c
,它们的内存是独立的,整个结构体的内存大小通常是 4 + 1 + 4 = 9
字节(实际大小可能会因为对齐规则而有所变化)。
共用体(union
)
在共用体中,所有成员共享相同的内存空间。也就是说,union
只为其中的最大成员分配内存,所有成员会重叠在同一块内存区域中。
- 内存布局: 所有成员共享同一块内存,
union
的总大小是所有成员中最大成员的大小。 - 内存使用: 只能存储一个成员的值,其他成员的值会被覆盖。
union MyUnion {
int a; // 4 字节
char b; // 1 字节
float c; // 4 字节
};
MyUnion obj;
在这个例子中,MyUnion
的内存大小是 4 字节(因为 a
和 c
是 4
字节大小)。尽管 MyUnion
包含三个成员,只有一个成员会被存储在内存中,a
、b
和 c
会共享这 4 字节的内存空间。
2. 存储方式
结构体
结构体中的每个成员都有各自独立的内存位置。你可以在同一时间存储和访问结构体中的所有成员。
共用体
共用体中的所有成员共享同一块内存空间。因此,只有一个成员的值可以在任何时刻被使用,存储某个成员的值会覆盖其他成员的值。换句话说,当你给共用体中的一个成员赋值时,其他成员的数据会丢失。
例如:
union MyUnion {
int a;
char b;
float c;
};
MyUnion obj;
obj.a = 10; // 现在 a 存储 10,b 和 c 的值会被覆盖
obj.b = 'X'; // 现在 b 存储 'X',a 和 c 的值会被覆盖
union MyUnion { int a; char b; float c; }; MyUnion obj; obj.a = 10; // 现在 a 存储 10,b 和 c 的值会被覆盖 obj.b = 'X'; // 现在 b 存储 'X',a 和 c 的值会被覆盖
在这个例子中,obj.a
和 obj.b
是互相覆盖的,因为它们共享同一块内存。
3. 用途
-
结构体:适用于需要同时存储多个不同数据并且每个数据都需要独立存在的情况。例如,表示一个人的信息,包括姓名、年龄、性别等。
-
共用体:适用于需要在不同时间存储不同类型的数据,但不需要同时存储这些数据的情况。例如,处理不同类型的数据或者实现多种类型的变量复用,但不需要同时保存它们的值。
共用体的一个典型用途是节省内存。例如,如果你知道某个变量在不同的时间点只能是几种不同类型中的一种,可以使用共用体来共享内存。
4. 大小差异
- 结构体的大小是所有成员大小之和,并且考虑对齐规则。
- 共用体的大小是所有成员中最大成员的大小。
5. 内存对齐
- 结构体的内存对齐:通常会根据成员的类型和平台的要求来决定对齐方式。例如,
int
可能要求 4 字节对齐,而char
可能只需要 1 字节对齐。 - 共用体的内存对齐:共用体的对齐方式与最大成员的对齐方式一致,因为所有成员共享同一块内存。
6. 成员访问
- 在 结构体 中,所有成员都可以在任何时刻被访问。
- 在 共用体 中,访问某个成员时会覆盖其他成员的数据。为了避免错误访问,通常需要确保只访问当前被使用的成员。
2.7 枚举类型
C++11 引入了 枚举类(enum class
),它是一个更强大的枚举类型。枚举类具有以下优势:
- 枚举成员的作用域被限制在枚举类内,避免了命名冲突。
- 枚举类的类型不再是普通的整数类型,而是一个新的类型。
定义和使用枚举类
#include <iostream>
using namespace std;
enum class Color {
Red,
Green,
Blue
};
int main() {
Color c = Color::Green; // 使用枚举类时需要使用作用域解析符
cout << "Green value is: " << static_cast<int>(c) << endl; // 输出 1
return 0;
}
在枚举类中,枚举值需要使用 Color::Red
、Color::Green
等格式访问。由于枚举类的作用域是封闭的,因此避免了传统枚举中可能出现的命名冲突。
枚举类与底层类型
和传统枚举一样,枚举类也可以指定底层类型。如果不指定,默认的底层类型是 int
。
enum class Color : unsigned int {
Red = 1,
Green = 2,
Blue = 3
};
枚举类与类型转换
由于枚举类的值被视为独立类型,因此不能直接进行类型转换。如果需要将枚举类的值转换为整数,可以使用 static_cast
。
Color c = Color::Green;
int value = static_cast<int>(c); // 需要显式转换
2.8 指针
在 C++ 中,指针 是一种用于存储内存地址的变量。指针不仅可以存储基本类型的地址,还可以指向对象、数组、函数等。指针是 C++ 编程中非常重要的概念,理解指针的用法及其背后的原理对于高效编程、内存管理和与低级系统交互至关重要。
基本理解:
最详细的讲解C++中指针的使用方法(通俗易懂)_c++指针-CSDN博客
指针运算:
指针加法与减法:
指针可以进行加法和减法运算,但这些运算是基于指针指向的类型的大小。比如,如果一个指针指向 int
类型数据(假设 int
占 4 字节),则 ptr + 1
会使指针向后移动 4 字节。
指针比较:
指针之间可以进行比较,比较的是它们在内存中的位置。
int arr[] = {10, 20, 30};
int* ptr1 = &arr[0];
int* ptr2 = &arr[1];
if (ptr1 < ptr2) {
cout << "ptr1 指向的元素在 ptr2 之前" << endl;
}
动态内存管理:
C++ 提供了动态内存分配和释放功能,允许在运行时分配内存。动态分配内存时,使用 new
和 delete
。
指向函数的指针:
指针不仅可以指向数据,还可以指向函数。通过函数指针,你可以实现回调函数、动态调用等功能。
#include <iostream>
using namespace std;
void greet() {
cout << "Hello, World!" << endl;
}
int add(int a, int b) {
return a + b;
}
int main() {
void (*funcPtr)() = &greet; // 定义一个指向函数的指针
funcPtr(); // 调用 greet 函数
int (*addPtr)(int, int) = &add; // 定义一个指向带参数的函数的指针
cout << addPtr(3, 4) << endl; // 输出 7
}
指针的空指针(nullptr
)
C++11 引入了 nullptr
,它是一个类型安全的空指针常量,替代了 NULL
或 0
。使用 nullptr
可以避免指针类型的不匹配问题。
int* ptr = nullptr; // ptr 初始化为空指针
if (ptr == nullptr) {
cout << "ptr is null" << endl;
}
指针的智能指针(std::unique_ptr
, std::shared_ptr
, std::weak_ptr
)
C++11 引入了智能指针,自动管理内存。智能指针避免了内存泄漏和悬挂指针的问题。
std::unique_ptr
:独占所有权,自动释放内存。std::shared_ptr
:共享所有权,引用计数为 0 时自动释放内存。std::weak_ptr
:不增加引用计数,只作为观察者存在。
1. std::unique_ptr
std::unique_ptr
是一种 独占所有权 的智能指针,意味着它是指向资源的唯一拥有者。当 std::unique_ptr
超出作用域时,它会自动释放资源。不能将 unique_ptr
复制给另一个 unique_ptr
,但可以通过 std::move()
转移其所有权。
特点:
- 只能有一个
unique_ptr
拥有资源。 - 当
unique_ptr
被销毁时,自动释放资源。 - 不能复制,只能转移所有权(
std::move()
)。
#include <iostream>
#include <memory>
using namespace std;
class MyClass {
public:
MyClass() { cout << "MyClass constructed" << endl; }
~MyClass() { cout << "MyClass destructed" << endl; }
void greet() { cout << "Hello from MyClass!" << endl; }
};
int main() {
// 创建 unique_ptr
unique_ptr<MyClass> ptr1 = make_unique<MyClass>();
ptr1->greet(); // 调用 MyClass 方法
// 不允许复制 unique_ptr
// unique_ptr<MyClass> ptr2 = ptr1; // 错误:不能复制 unique_ptr
// 转移所有权
unique_ptr<MyClass> ptr2 = move(ptr1); // 使用 move 转移所有权
ptr2->greet();
// ptr1 现在是空指针,不再拥有 MyClass 对象
if (!ptr1) {
cout << "ptr1 is null" << endl;
}
// ptr2 会在作用域结束时自动释放资源
}
MyClass constructed
Hello from MyClass!
MyClass destructed
ptr1 is null
MyClass destructed
std::move()
的使用:
std::move()
并不移动对象,而是将对象标记为“可以转移所有权”,使得目标unique_ptr
接管其资源。
2. std::shared_ptr
std::shared_ptr
是一种 共享所有权 的智能指针。多个 shared_ptr
可以指向同一个资源,引用计数机制会跟踪指向资源的 shared_ptr
数量,当最后一个 shared_ptr
被销毁时,资源会被释放。
特点:
- 允许多个
shared_ptr
共享对同一资源的所有权。 - 内部维护引用计数,确保资源在没有任何
shared_ptr
持有时才会被释放。 - 可以通过
std::make_shared
创建shared_ptr
,这是推荐的做法,因为它比使用new
创建对象效率更高。
#include <iostream>
#include <memory>
class MyClass {
public:
void greet() { std::cout << "Hello from MyClass!" << std::endl; }
};
int main() {
// 创建一个 shared_ptr,指向 MyClass 类型的对象
std::shared_ptr<MyClass> p1 = std::make_shared<MyClass>();
// 使用 p1 调用 MyClass 的成员函数
p1->greet();
// 创建另一个 shared_ptr,指向同一对象
std::shared_ptr<MyClass> p2 = p1;
// 引用计数增加,p1 和 p2 都指向相同的对象
std::cout << "Use count: " << p1.use_count() << std::endl;
// 当 p1 和 p2 超出作用域时,指向的对象会自动销毁
return 0;
}
注意事项
循环引用:shared_ptr
的引用计数机制可能导致循环引用的问题,即两个对象互相持有对方的 shared_ptr
,从而导致内存泄漏。解决方法是使用 std::weak_ptr
来打破循环引用。
示例:
#include <iostream>
#include <memory>
class A;
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyed" << std::endl; }
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a; // 循环引用
// 没有输出 "A destroyed" 和 "B destroyed",造成内存泄漏
}
在这种情况下,使用 std::weak_ptr
来避免循环引用:
class A {
public:
std::weak_ptr<B> b_ptr; // 使用 weak_ptr 打破循环引用
};
-
性能:
shared_ptr
会维护一个引用计数,因此每次复制、赋值或销毁shared_ptr
时都会涉及对引用计数的操作。如果不需要共享所有权,可以使用std::unique_ptr
,它会有更高的性能,因为没有引用计数的开销。 -
线程安全:
shared_ptr
对引用计数操作是线程安全的,但它并不保证对同一对象的并发访问是线程安全的。如果多个线程同时访问和修改同一个对象,需要自行确保同步。
3.std::weak_ptr
std::weak_ptr
是 C++11 引入的智能指针,用于解决 std::shared_ptr
中的循环引用问题。weak_ptr
本身不增加引用计数,因此它不会影响对象的生命周期。它是一个观察者类型的智能指针,用于访问由 shared_ptr
管理的对象,但不会保持对象的所有权。
主要特点:
- 不会增加引用计数:
weak_ptr
不会影响引用计数,因此它不会阻止对象的销毁。 - 解决循环引用问题:通过使用
weak_ptr
,你可以避免shared_ptr
中的循环引用(即两个对象互相持有对方的shared_ptr
)。 - 过期检测:
weak_ptr
可以通过lock()
方法来检测对象是否已经被销毁。如果对象已经被销毁,lock()
返回一个空的shared_ptr
。
#include <iostream>
#include <memory>
class MyClass {
public:
void greet() {
std::cout << "Hello from MyClass!" << std::endl;
}
};
int main() {
std::shared_ptr<MyClass> sharedPtr1 = std::make_shared<MyClass>();
std::weak_ptr<MyClass> weakPtr = sharedPtr1; // weak_ptr 不增加引用计数
// 通过 weak_ptr 获取 shared_ptr
if (auto lockedPtr = weakPtr.lock()) { // lock() 返回一个 shared_ptr
lockedPtr->greet(); // 使用 shared_ptr 调用成员函数
} else {
std::cout << "Object has been destroyed." << std::endl;
}
sharedPtr1.reset(); // shared_ptr 被重置,销毁对象
// 再次尝试从 weak_ptr 获取 shared_ptr
if (auto lockedPtr = weakPtr.lock()) {
lockedPtr->greet();
} else {
std::cout << "Object has been destroyed." << std::endl;
}
return 0;
}
weak_ptr
和 shared_ptr
的互操作性
weak_ptr
本质上是一个指向对象的“弱”引用,它不能直接解引用。但可以通过 lock()
将它转化为 shared_ptr
,然后解引用。
#include <iostream>
#include <memory>
class MyClass {
public:
void greet() { std::cout << "Hello from MyClass!" << std::endl; }
};
int main() {
std::shared_ptr<MyClass> sharedPtr = std::make_shared<MyClass>();
std::weak_ptr<MyClass> weakPtr = sharedPtr;
if (auto lockedPtr = weakPtr.lock()) { // 尝试从 weak_ptr 获取 shared_ptr
lockedPtr->greet(); // 调用成员函数
}
sharedPtr.reset(); // shared_ptr 被重置
if (auto lockedPtr = weakPtr.lock()) {
lockedPtr->greet();
} else {
std::cout << "Object has been destroyed." << std::endl;
}
return 0;
}
2.9 vector 用法
std::vector
是 C++ 标准库中一个非常常用的容器,它提供了动态大小数组的功能,其本质上是一个包装了动态数组的容器类。我们从 内存管理、数据存储、增删改查操作 等多个角度分析 std::vector
的内部实现原理。
1. 内存管理和数据存储
std::vector
的内部实现依赖于 动态数组,即其存储的数据在堆上分配,并能够根据需要动态增长或收缩。以下是 std::vector
内部内存管理的关键点:
动态数组
std::vector
内部通过一个动态数组来存储数据,初始时会分配一块固定大小的内存来存放元素。随着元素数量的增加,std::vector
会自动扩展数组的大小。- 每次扩展时,
vector
会分配一个 更大的 内存块,通常是当前内存块大小的两倍。这个策略保证了扩展操作不会太频繁,从而优化了性能。
重新分配和拷贝
- 当
vector
的容量不足以容纳新元素时,它会分配一个更大的内存块,并将原有元素逐一拷贝到新的内存区域。这个过程会导致一定的性能开销,因为每次重新分配内存时,所有元素都需要重新拷贝。 - 新的内存块的容量通常是当前容量的两倍。这个增长策略是为了平衡扩展操作的频率和内存利用效率。它允许
vector
在元素增加时无需每次都重新分配。
容量和大小
- 容量(Capacity):是
vector
当前分配的内存空间的大小,表示vector
当前能容纳的最大元素数目。 - 大小(Size):是
vector
当前存储的实际元素数量。 - 容量和大小的关系:容量是
vector
分配的内存空间大小,而大小是vector
当前存储的元素数量。容量通常大于或等于大小,vector
在达到当前容量时会自动重新分配内存以扩展容量。
内存布局
std::vector
在内存中是一个 连续 的数组。这意味着可以直接通过指针进行随机访问,可以通过&vec[i]
或vec.data()
获取数据的地址。由于内存是连续的,vector
支持非常高效的随机访问。
2. std::vector
的操作本质
插入元素 (push_back
)
push_back
是 std::vector
添加元素的最常用方式。它会将新元素插入到 vector
的末尾。
- 当容量足够时,
push_back
直接将元素添加到末尾,不需要重新分配内存。 - 当容量不足时,
push_back
会导致重新分配内存。vector
通常会将容量扩展为当前容量的两倍,并将原有元素拷贝到新的内存位置。
在重新分配内存时,vector
需要执行以下几个步骤:
- 分配一个更大的内存块。
- 将原有元素从旧内存块拷贝到新内存块。
- 插入新元素。
- 释放旧的内存块。
虽然 push_back
在最坏情况下需要 O(n)
时间来重新分配和拷贝元素,但由于 vector
使用的是“扩容倍增”策略,所以平均每个 push_back
操作的时间复杂度是 常数时间,即 O(1)
。
删除元素 (pop_back
和 erase
)
pop_back()
是删除vector
最末尾的元素。删除操作只是更新容器的大小,并不会涉及到内存移动,因此它的时间复杂度为O(1)
。erase()
用于删除指定位置的元素。这个操作不仅需要删除元素,还需要将后面的元素往前移动,以填补被删除元素的位置。因此,erase()
的时间复杂度是 O(n),其中n
是从删除位置到vector
末尾的元素数量。
随机访问和迭代器
由于 std::vector
是一个连续的内存块,随机访问 是非常高效的。通过 []
或 at()
操作符可以以常数时间访问任何位置的元素。可以通过 vector[i]
或 vector.at(i)
访问元素,at(i)
提供了边界检查,而 []
不做边界检查。
std::vector
支持常见的 STL 迭代器操作,使用迭代器可以在不直接操作索引的情况下遍历 vector
。
容量管理
std::vector
提供了 reserve()
和 shrink_to_fit()
等方法来手动管理容量:
reserve(n)
:预留n
个元素的空间,确保vector
在当前容量足够容纳n
个元素时,不会重新分配内存。可以提高效率,避免频繁的内存重分配。shrink_to_fit()
:请求将vector
的容量缩小到当前的大小,释放多余的内存。
3. 扩展操作和内存效率
为什么使用倍增策略?
std::vector
通过将容量每次扩展为当前容量的两倍来避免频繁的内存重新分配。这个策略的好处是:
- 减少频繁扩展的开销:每次扩展时,都会导致
O(n)
的开销(拷贝所有元素)。但通过使用倍增策略,扩展次数较少,因此总的时间开销会趋向于线性,即O(n)
。 - 保持相对较低的内存碎片:虽然每次扩展都会浪费一部分内存(即超出实际需要的容量),但这种浪费通常是非常小的,相比频繁的内存重新分配,扩展两倍的策略会保持更好的性能。
内存释放的时机
std::vector
需要显式调用 clear()
或析构时才会释放内存。即使 vector
的大小为 0,容量也可能不变,直到重新分配或者调用 shrink_to_fit()
。因此,在某些场景下,手动调整容量可以减少内存浪费。
4.特殊操作:emplace_back
和 insert
emplace_back()
:与push_back()
不同,emplace_back()
直接在容器的末尾构造一个元素,避免了不必要的拷贝或移动操作。它通过完美转发参数来调用元素类型的构造函数,适用于复杂类型。insert()
:insert()
用于在指定位置插入元素。如果是在vector
的中间插入元素,vector
会将插入位置后面的元素向后移动,插入操作的时间复杂度是 O(n)。
2.10 array 类型
在 C++ 中,std::array
是一个封装固定大小数组的容器类,它是 C++11 引入的标准库容器之一。与传统的 C 风格数组(例如 int arr[10];
)相比,std::array
提供了更多的功能、类型安全、以及与 STL 容器一致的接口,适用于存储已知大小的固定元素。
1. 内存布局与存储
std::array
的核心就是 C 风格数组,它通过模板封装了一个固定大小的数组。由于 std::array
是一个模板类,它在编译时就会确定数组的大小(即 std::array<T, N>
中的 N
),并且在栈上分配内存。
内存分配
- 栈上分配:
std::array
和 C 风格数组一样,通常在栈上分配内存,特别是当其大小在编译时已知且不会变化时。std::array
对于固定大小的数据结构来说具有非常低的开销。 - 内存布局:
std::array
内部使用一个 连续的内存块 来存储其元素,就像普通的 C 风格数组一样。每个元素的内存布局是线性的,直接按照内存顺序存储,确保元素的存取速度是恒定的(O(1)
)。
std::array<int, 5> arr = {1, 2, 3, 4, 5};
std::array
将会在栈上分配一块连续的内存,足够存储 5 个 int
元素。
内存对齐
std::array
会根据元素类型的对齐要求进行内存对齐。例如,对于类型 T
,内存会按照类型 T
的对齐规则进行对齐,这样可以确保数组元素的访问效率。
2. 类型安全与访问控制
std::array
相比传统的 C 风格数组提供了类型安全的访问机制。C 风格数组不具备边界检查,而 std::array
的成员函数,如 at()
,可以进行 越界检查,从而避免了访问不合法内存的风险。
at()
和 operator[]
区别
at()
:通过 at()
访问元素时,std::array
会检查索引是否越界。如果索引超出范围,at()
会抛出 std::out_of_range
异常。
arr.at(10); // 如果 10 超出了数组的大小,会抛出异常
operator[]
:operator[]
是没有越界检查的,只要索引合法,它会直接返回相应的元素。这种方式类似于 C 风格数组的访问方式。虽然性能更高,但如果索引超出范围,会导致未定义行为。
arr[10]; // 如果索引超出范围,行为未定义
3. 成员函数与容器接口
std::array
实现了与其他 STL 容器相似的接口,提供了丰富的成员函数和方法,使得它不仅仅是一个数组,而是具备了一些容器的特性。
2.11 内联函数
在 C++ 中,内联函数(inline function)是一种提示编译器将函数的代码直接插入到调用该函数的地方,而不是通过常规的函数调用机制进行跳转。这样做可以减少函数调用的开销,特别是对于那些非常简单的小函数,可以提高性能。
内联函数的工作原理
当编译器遇到一个内联函数的调用时,它会直接将该函数的代码插入到调用位置,而不进行传统的函数调用过程(即不执行函数的栈操作、跳转等)。这种做法避免了函数调用时的开销,从而提高执行效率。
内联函数的优缺点
优点
- 减少函数调用的开销:尤其是对于短小、频繁调用的函数,内联可以显著减少函数调用的开销。
- 提高性能:避免了函数调用时的堆栈操作,减少了执行时间。
缺点
- 代码膨胀:内联函数会将函数体插入每个调用点,可能导致生成的机器代码体积变大,增加程序的大小。
- 不适用于复杂函数:内联函数通常适用于非常简单的函数,对于复杂函数,内联可能反而降低性能。
示例
#include <iostream>
inline int multiply(int x, int y) {
return x * y;
}
int main() {
int result = multiply(5, 3); // 此处编译器会将 multiply 函数体插入
std::cout << "Result: " << result << std::endl;
return 0;
}
在此示例中,multiply
是一个简单的内联函数,编译器可能会直接将 x * y
替换为调用位置,避免函数调用的开销。
2.12 函数重载
在 C++ 中,函数重载(Function Overloading)是指在同一个作用域内,可以定义多个同名但参数不同的函数。函数重载通过函数参数的数量、类型、顺序等来区分不同的函数。当调用函数时,编译器根据传递的参数类型和数量来决定调用哪个版本的函数。从底层来看,函数重载实际上是由 编译器在编译时根据调用时的参数类型和数量来选择合适的函数版本。
void func(int x) {
cout << "int: " << x << endl;
}
void func(double x) {
cout << "double: " << x << endl;
}
int main() {
func(10); // 调用 func(int)
func(3.14); // 调用 func(double)
func(2.5f); // 调用 func(double)(从 float 到 double 的隐式转换)
return 0;
}
2.13 函数模板
C++函数模板是一种通过引入“类型参数”来编写通用函数的机制。函数模板允许你编写只定义一次,但可以应用于多种数据类型的函数。与常规函数相比,函数模板的最大优势在于无需为每种数据类型编写重复的代码,从而提高了代码的复用性。
1. 函数模板的基本语法
函数模板的基本语法格式如下:
template <typename T>
ReturnType functionName(T arg1, T arg2) {
// 函数体
}
template <typename T>
:声明一个模板,T
是一个占位符,可以在调用时被任何具体的类型替代。ReturnType
:函数返回值的类型,可以是任何合法的类型。functionName
:函数的名称。T arg1, T arg2
:函数的参数列表,T
是一个模板参数类型,可以用来接受任何类型的数据。
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
int x = 5, y = 10;
double m = 3.14, n = 2.71;
// 调用 add 函数,模板会自动推导出 T 的类型
std::cout << add(x, y) << std::endl; // 输出: 15
std::cout << add(m, n) << std::endl; // 输出: 5.85
return 0;
}
在这个例子中,add
函数模板能够处理不同类型的数据,T
会根据传入的参数类型自动推导。
2. 模板参数
模板的参数可以是以下几种形式:
- 类型模板参数:如上述例子中的
T
,用来代表任意数据类型。 - 非类型模板参数:除了类型,还可以传入常量值、指针等作为模板参数。
非类型模板参数示例:
template <typename T, int size>
T* createArray() {
return new T[size]; // 创建一个大小为 size 的 T 类型数组
}
int main() {
auto arr = createArray<int, 10>(); // 创建一个大小为 10 的整型数组
delete[] arr;
return 0;
}
3. 模板的重载
函数模板支持重载,意味着你可以为不同的参数类型或数量定义多个模板版本,编译器会根据调用时传递的参数类型来选择合适的版本。
示例:
template <typename T>
void print(T arg) {
std::cout << arg << std::endl;
}
template <typename T, typename U>
void print(T arg1, U arg2) {
std::cout << arg1 << ", " << arg2 << std::endl;
}
int main() {
print(10); // 调用第一个模板版本
print(10, 3.14); // 调用第二个模板版本
return 0;
}
4. 模板特化
有时你可能希望为某种特定类型提供一个定制化的实现,这时就可以使用模板特化。模板特化可以分为 完全特化 和 偏特化。
完全特化
完全特化是指为某一个特定的类型提供一个完全不同的实现。语法如下:
template <typename T>
T add(T a, T b) {
return a + b;
}
// 完全特化版本,用于处理 string 类型
template <>
std::string add<std::string>(std::string a, std::string b) {
return a + " " + b;
}
int main() {
std::cout << add(5, 10) << std::endl; // 输出: 15
std::cout << add(std::string("Hello"), std::string("World")) << std::endl; // 输出: Hello World
return 0;
}
偏特化
偏特化允许你为模板参数的一部分提供定制的实现。
template <typename T>
class MyClass {
// 通用实现
};
template <typename T>
class MyClass<T*> {
// 针对指针类型的偏特化
};
模板的 SFINAE(Substitution Failure Is Not An Error)
#include <iostream>
#include <type_traits>
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
add(T a, T b) {
return a + b;
}
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(10, 20) << std::endl; // 调用整型版本
std::cout << add(3.14, 2.71) << std::endl; // 调用浮点型版本
return 0;
}
在这个例子中,std::enable_if
用来区分整型和浮点型的模板函数。
三、对象和类
1. 类的成员函数
普通成员函数
普通成员函数是最常见的成员函数类型,它们可以访问类的所有成员(包括私有成员)。
class MyClass {
public:
void setValue(int val) {
value = val;
}
private:
int value;
};
构造函数和析构函数
- 构造函数:在创建对象时自动调用,用来初始化成员变量。
- 析构函数:在对象销毁时自动调用,用来释放资源。
class MyClass {
public:
// 构造函数
MyClass(int val) : value(val) {}
// 析构函数
~MyClass() {
std::cout << "Object destroyed!" << std::endl;
}
private:
int value;
};
常成员函数(const 成员函数)
常成员函数是指函数不会修改类的任何成员变量,包括私有成员。它通过在函数声明中添加 const
关键字来实现。
class MyClass {
public:
int getValue() const {
return value; // 常成员函数不能修改成员变量
}
private:
int value;
};
静态成员函数
静态成员函数是属于类的,而不是某个特定对象。静态成员函数只能访问静态成员变量和其他静态成员函数。
class MyClass {
public:
static int count; // 静态成员变量
static void incrementCount() {
count++;
}
};
// 静态成员变量初始化
int MyClass::count = 0;
int main() {
MyClass::incrementCount();
std::cout << "Count: " << MyClass::count << std::endl;
return 0;
}
内联成员函数
内联成员函数是定义在类内部的函数,并且编译器会尽可能地将其展开为内联代码,而不是调用函数。这对于简短的成员函数非常有效,可以提高效率。
class MyClass {
public:
int add(int a, int b) {
return a + b; // 简短的函数,编译器可能会将其内联
}
};
成员函数的访问控制
成员函数的访问控制与成员变量相同,可以是 public
、private
或 protected
。
- public:允许类的外部代码访问。
- private:只允许类的内部代码访问。
- protected:允许类的内部和继承类访问。
虚函数
虚函数用于实现多态性。它们可以在基类中声明,并在派生类中重写。虚函数在通过基类指针或引用调用时,能够调用正确的派生类实现。
class Base {
public:
virtual void display() {
std::cout << "Base class display" << std::endl;
}
};
class Derived : public Base {
public:
void display() override {
std::cout << "Derived class display" << std::endl;
}
};
int main() {
Base *basePtr;
Derived derivedObj;
basePtr = &derivedObj;
// 动态绑定,调用的是Derived类的display
basePtr->display();
return 0;
}
2. 类的构造和析构
在C++中,构造函数和析构函数是类的重要组成部分,它们负责对象的初始化和销毁。它们分别在对象创建和销毁时自动调用。接下来,我们将详细分析构造函数和析构函数的特点、用法以及它们的区别。
构造函数是一个特殊的成员函数,它在创建对象时被自动调用,用来初始化对象的状态。构造函数没有返回值,也不能返回任何类型的值。
构造函数的特点
- 没有返回类型,即使是
void
也不可以。 - 构造函数的名称必须与类名相同。
- 构造函数可以有参数,可以通过不同的参数列表来重载构造函数(即构造函数重载)。
- 每个类可以有多个构造函数(构造函数重载),这允许不同的方式来初始化对象。
构造函数的类型
- 默认构造函数:不带任何参数的构造函数。如果用户没有定义构造函数,编译器会自动提供一个默认构造函数。
- 带参数构造函数:带有一个或多个参数的构造函数,可以用来初始化对象。
- 拷贝构造函数:用于创建一个新对象,该对象是通过现有对象的副本初始化的。
#include <iostream>
using namespace std;
class MyClass {
public:
// 默认构造函数
MyClass() {
cout << "Default constructor called" << endl;
value = 0;
}
// 带参数构造函数
MyClass(int val) {
cout << "Parameterized constructor called" << endl;
value = val;
}
// 拷贝构造函数
MyClass(const MyClass& other) {
cout << "Copy constructor called" << endl;
value = other.value;
}
// 成员函数
void display() {
cout << "Value: " << value << endl;
}
private:
int value;
};
int main() {
MyClass obj1; // 调用默认构造函数
obj1.display();
MyClass obj2(10); // 调用带参数构造函数
obj2.display();
MyClass obj3 = obj2; // 调用拷贝构造函数
obj3.display();
return 0;
}
构造函数的详细分析
- 默认构造函数:在
MyClass obj1;
中调用。它不接受任何参数,成员变量value
被初始化为0
。 - 带参数构造函数:在
MyClass obj2(10);
中调用,value
被初始化为10
。 - 拷贝构造函数:在
MyClass obj3 = obj2;
中调用,obj3
的成员变量value
被初始化为obj2.value
。
析构函数(Destructor)
析构函数(Destructor)
析构函数是另一个特殊的成员函数,它在对象生命周期结束时自动调用,用来释放对象占用的资源。析构函数的作用是清理对象创建时分配的内存或其他资源(例如文件句柄、网络连接等)。
析构函数的特点
- 没有返回类型,即使是
void
也不可以。 - 析构函数的名称与类名相同,但在名称前加上波浪符
~
。 - 每个类只能有一个析构函数。析构函数不能被重载。
- 析构函数不能接受参数,也不能返回任何值。
- 析构函数的调用时机是自动的,在对象销毁时,由编译器自动调用。
#include <iostream>
using namespace std;
class MyClass {
public:
// 构造函数
MyClass(int val) {
cout << "Constructor called" << endl;
value = val;
}
// 析构函数
~MyClass() {
cout << "Destructor called" << endl;
}
// 成员函数
void display() {
cout << "Value: " << value << endl;
}
private:
int value;
};
int main() {
MyClass obj(10); // 构造函数被调用
obj.display();
// 离开作用域时,析构函数自动调用
return 0;
}
C++ 类 this 指针
在C++中,this
指针是一个隐式指针,指向当前对象的地址。它是每个非静态成员函数的一个隐式参数,允许成员函数访问当前对象的成员。this
指针的使用是为了在类的成员函数中引用当前对象,尤其是在对象的成员变量或其他成员函数之间进行区分时。
this
指针的特点
- 指向当前对象:
this
是一个指向调用成员函数的对象的指针。 - 隐式存在:它在成员函数中隐式存在,你不需要显式传递它。
- 只能在非静态成员函数中使用:
this
指针只能在类的非静态成员函数中使用,因为静态成员函数不属于任何具体的对象,所以没有this
指针。 this
的类型:this
是一个指向当前对象的指针,其类型为T*
,其中T
是类的类型。如果成员函数是常量成员函数(const
成员函数),那么this
的类型是const T*
。
3. 运算符重载
在C++中,**运算符重载(Operator Overloading)**允许程序员为自定义数据类型(如类)定义或修改运算符的行为。这意味着你可以定义如何使用标准运算符(如 +
, -
, *
, []
, ()
, ==
等)来操作自定义类型的对象,从而让类的对象表现得像内建数据类型一样,进行直接的运算操作。
#include <iostream>
using namespace std;
class Complex {
private:
double real;
double imag;
public:
// 构造函数
Complex(double r, double i) : real(r), imag(i) {}
// 重载加法运算符
Complex operator+(const Complex& other) {
return Complex(real + other.real, imag + other.imag);
}
// 显示复数
void display() const {
cout << real << " + " << imag << "i" << endl;
}
};
int main() {
Complex c1(1.0, 2.0);
Complex c2(3.0, 4.0);
Complex c3 = c1 + c2; // 使用重载的 + 运算符
c3.display(); // 输出:4.0 + 6.0i
return 0;
}
4. 多态
C++ 中的 多态(Polymorphism) 是面向对象编程中的一个核心概念,它允许同一操作作用于不同类型的对象。通过多态,程序员可以编写更通用的代码,提高代码的可扩展性和维护性。多态通常通过 继承 和 虚函数(Virtual Functions) 实现,能够使得在基类指针或引用指向派生类对象时,调用派生类的成员函数。
多态的概念
多态有两种主要类型:
- 静态多态(静态绑定 / 编译时多态):在编译时就决定了调用哪个函数,常见的例子是函数重载和运算符重载。
- 动态多态(动态绑定 / 运行时多态):在程序运行时根据对象的实际类型来决定调用哪个函数,通常是通过 虚函数 和 继承 来实现的。
虚函数与动态多态
在 C++ 中,虚函数是实现动态多态的关键。通过将基类中的成员函数声明为虚函数,可以让基类指针或引用指向派生类对象时,调用派生类的实现。
虚函数的基本概念
- 虚函数 是在基类中声明为
virtual
的成员函数,它告诉编译器需要在运行时进行动态绑定。 - 如果基类中的函数被声明为虚函数,并且派生类中重写了该函数,那么通过基类指针或引用调用该函数时,会根据对象的实际类型调用派生类的版本,而不是基类的版本。
实现动态多态的步骤
- 在基类中声明虚函数。
- 在派生类中重写虚函数。
- 使用基类指针或引用指向派生类对象,调用重写的函数。
示例分析:动态多态
假设我们有一个 Shape
类,表示形状,派生类 Circle
和 Rectangle
分别表示圆形和矩形。我们希望通过基类指针调用不同派生类的 draw()
函数。
#include <iostream>
using namespace std;
// 基类
class Shape {
public:
// 虚函数
virtual void draw() const {
cout << "Drawing Shape" << endl;
}
// 虚析构函数,确保派生类的对象能正确销毁
virtual ~Shape() {
cout << "Shape destroyed" << endl;
}
};
// 派生类 Circle
class Circle : public Shape {
public:
void draw() const override {
cout << "Drawing Circle" << endl;
}
};
// 派生类 Rectangle
class Rectangle : public Shape {
public:
void draw() const override {
cout << "Drawing Rectangle" << endl;
}
};
int main() {
Shape* shape1 = new Circle(); // 基类指针指向派生类对象
Shape* shape2 = new Rectangle();
shape1->draw(); // 调用 Circle 的 draw()
shape2->draw(); // 调用 Rectangle 的 draw()
delete shape1; // 删除对象,调用析构函数
delete shape2;
return 0;
}
Drawing Circle
Drawing Rectangle
Shape destroyed
Shape destroyed
虚函数的工作原理
当一个类包含虚函数时,编译器会为该类创建一个 虚函数表(Vtable)。Vtable 是一个指向虚函数的指针数组。每个类的对象会包含一个指向 Vtable 的指针,该指针在运行时指向正确的函数版本。
- 当调用虚函数时,程序会通过该指针查找并调用实际的函数。
- 这就是 动态绑定 的核心,它允许在程序运行时根据对象的实际类型来决定调用哪个函数。
纯虚函数与抽象类
如果一个虚函数在基类中没有实现,而只是声明为纯虚函数,基类就变成了一个 抽象类。抽象类不能实例化,通常用于定义接口。
纯虚函数的声明
纯虚函数的声明形式为:virtual return_type function_name() = 0;
。例如:
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数
virtual ~Shape() {}
};
派生类中的实现
派生类必须实现纯虚函数,否则派生类也将变为抽象类,不能实例化。
class Circle : public Shape {
public:
void draw() const override {
cout << "Drawing Circle" << endl;
}
};
虚函数与构造函数
构造函数不能是虚函数,因为对象的构造是在调用基类构造函数时发生的,而虚函数是在对象创建后才发生动态绑定的。
但是,如果在构造函数中调用虚函数,它将调用基类的版本,而不是派生类的版本。
class Shape {
public:
Shape() {
// 这里调用的是基类的虚函数版本
draw();
}
virtual void draw() const {
cout << "Drawing Shape" << endl;
}
};
5.友元
C++中的**友元(friend)**是一种机制,它允许类的成员函数或非成员函数访问其他类的私有成员或保护成员。通过友元,可以打破类的封装性,但又不会影响类外的对象和函数的正常使用。友元通常用于那些需要访问类的内部状态但不适合直接成为该类成员的情况。
友元函数(Friend Function)
- 友元函数是指一个普通的非成员函数,可以访问某个类的私有成员和保护成员。友元函数虽然不是该类的成员函数,但它可以被授权访问类的私有和保护成员。
- 友元函数的声明放在类定义的内部,前面加上
friend
关键字。
class Box {
private:
double width;
public:
Box() : width(0) {}
// 声明friend函数
friend void printWidth(Box &b);
};
void printWidth(Box &b) {
// 友元函数可以访问类的私有成员
std::cout << "Width of Box: " << b.width << std::endl;
}
int main() {
Box b;
printWidth(b); // 可以调用友元函数访问私有成员
return 0;
}
友元类(Friend Class)
- 友元类是指某个类可以访问另一个类的私有成员和保护成员。类声明中的
friend
关键字可以指向另一个类,这样该类中的所有成员函数都可以访问当前类的私有成员。 - 友元类的声明也放在类定义的内部,前面加上
friend
关键字。
class Box {
private:
double width;
public:
Box() : width(10) {}
friend class Printer; // 声明Printer类为友元类
};
class Printer {
public:
void printWidth(Box &b) {
std::cout << "Width of Box: " << b.width << std::endl;
}
};
int main() {
Box b;
Printer p;
p.printWidth(b); // 友元类可以访问Box类的私有成员
return 0;
}
在这个例子中,Printer
类是Box
类的友元类,Printer
类中的成员函数可以访问Box
类的私有成员width
。
元成员函数(Friend Member Function)
除了普通的友元函数,某个类的成员函数也可以是另一个类的友元函数,允许其访问该类的私有成员。
class Box {
private:
double width;
public:
Box() : width(10) {}
// 声明友元成员函数
friend void BoxPrinter::printWidth(Box &b);
};
class BoxPrinter {
public:
void printWidth(Box &b) {
std::cout << "Width of Box: " << b.width << std::endl;
}
};
int main() {
Box b;
BoxPrinter p;
p.printWidth(b); // 友元成员函数可以访问Box类的私有成员
return 0;
}
四、异常处理
C++中的异常处理是一种用于处理运行时错误的机制。它通过try
、throw
和catch
语句来捕获和处理异常,允许程序在发生错误时不崩溃,并能优雅地处理错误情况。
异常处理的基本结构
C++异常处理由以下三个基本部分组成:
-
throw(抛出异常)
throw
用于抛出一个异常对象,可以是任意类型(包括内置类型、类类型等)。- 一旦
throw
语句被执行,控制权会转移到相应的catch
语句中。
-
try(异常捕获块)
try
块用于包装可能抛出异常的代码。如果在try
块中的某个操作抛出异常,异常会被传递到后续的catch
块。
-
catch(异常捕获)
catch
语句用于捕获并处理throw
抛出的异常。每个catch
语句会尝试匹配一个异常类型,并执行相应的处理逻辑。
异常处理的语法
try {
// 可能抛出异常的代码
throw exception; // 抛出异常
}
catch (const ExceptionType &e) {
// 处理异常的代码
}
catch (...) {
// 捕获所有类型的异常(通配符)
}
基本的异常处理
#include <iostream>
#include <stdexcept>
void divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Division by zero is not allowed");
}
std::cout << "Result: " << a / b << std::endl;
}
int main() {
try {
divide(10, 0);
} catch (const std::exception &e) {
std::cout << "Error: " << e.what() << std::endl;
}
return 0;
}
在上面的例子中:
throw std::invalid_argument("Division by zero is not allowed");
抛出了一个异常对象。catch (const std::exception &e)
捕获并处理了异常,通过e.what()
输出异常信息。
异常类型
异常对象可以是任何类型,但通常使用标准库定义的异常类型或自定义异常类型。
-
标准库异常类型
- C++标准库提供了一些常见的异常类型,例如:
std::exception
:所有异常的基类。std::runtime_error
:运行时错误的基类,通常用于程序错误。std::invalid_argument
:表示无效参数的异常。std::out_of_range
:表示超出范围的异常。std::logic_error
:表示程序逻辑错误的异常。std::bad_alloc
:表示内存分配失败的异常。
- C++标准库提供了一些常见的异常类型,例如:
-
自定义异常类型
用户可以通过继承std::exception
类,或者直接定义自己的异常类。
class MyException : public std::exception {
public:
const char* what() const noexcept override {
return "My custom exception";
}
};
try {
throw MyException();
} catch (const MyException &e) {
std::cout << e.what() << std::endl;
}
异常的传播
当异常被抛出后,程序会沿着调用栈进行搜索,查找与抛出的异常类型匹配的catch
块。这种查找机制叫做异常传播。
- 如果
try
块中抛出了异常,但没有与该异常类型匹配的catch
块,异常将继续向上层函数传播。 - 如果没有匹配的
catch
块,程序将调用std::terminate()
函数,导致程序终止。
捕获多个异常
一个try
块可以有多个catch
块来捕获不同类型的异常。程序会按照顺序检查catch
块,直到找到匹配的异常类型为止。
try {
throw std::out_of_range("Out of range error");
}
catch (const std::invalid_argument &e) {
std::cout << "Invalid argument: " << e.what() << std::endl;
}
catch (const std::out_of_range &e) {
std::cout << "Out of range: " << e.what() << std::endl;
}
catch (const std::exception &e) {
std::cout << "Generic error: " << e.what() << std::endl;
}
五、容器
C++ STL(标准模板库)提供了多种常用容器(container)来处理不同类型的数据。每种容器都有不同的底层实现和适用场景。下面我将详细分析 C++ 常用容器的使用方法以及其底层本质。
1. Vector (std::vector
)
用法
std::vector
是一个动态数组容器,提供了快速的随机访问和在末尾插入或删除元素的操作。它的大小可以根据需要动态调整。
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
vec.push_back(1); // 在末尾插入元素
vec.push_back(2);
vec.push_back(3);
// 访问元素
for (int i : vec) {
std::cout << i << " ";
}
std::cout << std::endl;
// 随机访问
std::cout << "Element at index 1: " << vec[1] << std::endl;
// 删除最后一个元素
vec.pop_back();
std::cout << "Size after pop_back: " << vec.size() << std::endl;
return 0;
}
底层本质
- 底层实现:
std::vector
底层使用动态数组来存储元素。每次扩容时,它会分配更大的内存空间,并将现有元素拷贝到新数组中。通常,它会按倍数扩展(例如,每次扩展两倍)。 - 随机访问: 由于是数组实现,
std::vector
提供常数时间复杂度的随机访问操作(O(1)
)。 - 插入和删除: 在末尾插入或删除元素的时间复杂度为
O(1)
,但在中间插入或删除元素的时间复杂度为O(n)
,因为需要移动元素。
2.Deque (std::deque
)
std::deque
是一个双端队列,允许在队列两端快速插入和删除元素。
#include <iostream>
#include <deque>
int main() {
std::deque<int> deq;
deq.push_back(1); // 在尾部插入
deq.push_front(2); // 在头部插入
deq.push_back(3);
// 访问元素
for (int i : deq) {
std::cout << i << " ";
}
std::cout << std::endl;
// 删除元素
deq.pop_back(); // 删除尾部
deq.pop_front(); // 删除头部
std::cout << "Size after pop: " << deq.size() << std::endl;
return 0;
}
- 底层实现:
std::deque
底层使用多个固定大小的数组块(通常称为"缓冲区")来存储元素。这使得它能够在头部和尾部进行高效的插入和删除。 - 插入和删除: 在两端插入或删除元素的时间复杂度为
O(1)
。但随机访问的时间复杂度较高,通常是O(n)
。 - 内存管理: 由于使用多个块存储元素,
std::deque
的内存管理比std::vector
更复杂。
3. List (std::list
)
std::list
是一个双向链表,每个元素包含指向前一个和下一个元素的指针。
#include <iostream>
#include <list>
int main() {
std::list<int> lst;
lst.push_back(1); // 插入到尾部
lst.push_front(2); // 插入到头部
// 访问元素
for (int i : lst) {
std::cout << i << " ";
}
std::cout << std::endl;
// 删除元素
lst.pop_back(); // 删除尾部元素
lst.pop_front(); // 删除头部元素
std::cout << "Size after pop: " << lst.size() << std::endl;
return 0;
}
- 底层实现:
std::list
底层使用双向链表,每个元素都有前后指针指向相邻的元素。 - 插入和删除: 在链表的任意位置插入或删除元素的时间复杂度为
O(1)
,但需要通过遍历找到该位置,因此随机访问的时间复杂度为O(n)
。 - 内存管理: 每个元素都有自己的内存块,因此比数组需要更多的内存。
4. Set (std::set
) 和 MultiSet (std::multiset
)
std::set
是一个有序的集合,保证元素不重复且按排序顺序存储。std::multiset
允许元素重复。
#include <iostream>
#include <set>
int main() {
std::set<int> s;
s.insert(3); // 插入元素
s.insert(1);
s.insert(2);
// 访问元素
for (int i : s) {
std::cout << i << " ";
}
std::cout << std::endl;
// 查找元素
if (s.find(2) != s.end()) {
std::cout << "Element 2 found" << std::endl;
}
return 0;
}
- 底层实现:
std::set
和std::multiset
底层通常使用红黑树或其他平衡二叉搜索树(如AVL树)。红黑树是一种自平衡的二叉查找树,能够保证插入、删除和查找操作的时间复杂度为O(log n)
。 - 元素顺序: 元素按排序顺序存储(默认使用
<
运算符,可以自定义排序规则)。 - 查找操作: 查找元素的时间复杂度为
O(log n)
,与树的高度相关。
5. Map (std::map
) 和 MultiMap (std::multimap
)
std::map
是一个有序的键值对容器,键是唯一的。std::multimap
允许相同的键出现多次。
#include <iostream>
#include <map>
int main() {
std::map<int, std::string> m;
m[1] = "One";
m[2] = "Two";
m[3] = "Three";
// 访问元素
for (const auto& pair : m) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 查找元素
if (m.find(2) != m.end()) {
std::cout << "Found key 2: " << m[2] << std::endl;
}
return 0;
}
- 底层实现:
std::map
和std::multimap
底层通常使用红黑树。每个元素由一个键值对组成,元素按键排序。 - 查找、插入和删除: 查找、插入和删除操作的时间复杂度为
O(log n)
。 - 键的唯一性: 在
std::map
中,键是唯一的,而在std::multimap
中,键可以重复。