此为

https://www.bilibili.com/video/BV1BwHCz4EC6?spm_id_from=333.788.player.switch&vd_source=480e7aa1ddae874c0194f31a76e66a73&p=15

这个系列的笔记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <stdexcept> // 包含标准异常类

int divide(int a, int b) {
if (b == 0) {
// 当除数为0时,抛出运行时错误异常
throw std::runtime_error("除数不能为0!");
}
return a / b;
}

int main() {
int x = 10, y = 0;

try {
std::cout << "尝试计算 " << x << " / " << y << std::endl;
int result = divide(x, y);
std::cout << "结果是: " << result << std::endl;
}
catch (std::runtime_error &ex) { // 捕获异常
std::cout << "捕获到异常: " << ex.what() << std::endl;
}

std::cout << "程序继续执行..." << std::endl;

return 0;
}

exception类

抛出派生类对象类型的异常,并在接收基类异常的处理程序中捕获它是完全合法的。

std::exception是所有标准异常类的基类。它提供了一个虚函数what(),用于返回描述错误的字符串。这使得我们可以利用C++的多态特性,用基类引用来捕获派生类异常。

抛出派生类对象类型的异常,并在接收基类异常的处理程序中捕获它是完全合法的。这是因为派生类对象”是一种”基类对象,通过基类引用捕获可以保留异常对象的原始类型信息,同时避免对象切片问题。例如,我们可以抛出std::runtime_error(派生自std::exception),然后用catch(std::exception& ex)来捕获它。

catch(…)
这是C++中的”万能捕获”,能够捕获任何类型的异常,无论它是标准异常、自定义异常还是其他类型(如整数或字符串常量)。

最好是最后一个catch块。因为catch(…)能捕获所有未被前面catch块处理的异常类型,如果把它放在前面,后面的具体catch块就永远没有机会执行了。它通常作为异常处理的最后防线,用于执行统一的清理工作或记录未知错误。

栈展开:析构函数也会被调用?但在堆上不能被销毁,还是要用RAII

异常处理中手动内存处理
当异常抛出后,程序会沿着函数调用链向上查找匹配的catch块。在离开每个函数的作用域时,该函数内定义的所有局部对象都会被自动销毁,它们的析构函数会被调用。这个过程就是栈展开。但在堆上不能被销毁,还是要用RAII。因为栈展开只会自动销毁栈上的局部对象,而通过new在堆上分配的内存不会自动释放。所以必须用RAII对象(如智能指针)来管理堆资源,这样当RAII对象本身作为局部对象被销毁时,它的析构函数会自动释放堆内存。

如何理解栈展开:

生活类比:多层建筑的火灾报警

想象一栋5层楼的建筑:

  • 1楼:函数A
  • 2楼:函数B
  • 3楼:函数C
  • 4楼:函数D
  • 5楼:函数E(发生异常的地方)

过程

  1. 5楼发生火灾(抛出异常)
  2. 5楼自己处理不了,开始疏散(函数E无法处理异常)
  3. 4楼也处理不了,继续疏散(函数D无法处理)
  4. 3楼能处理,停止疏散,开始灭火(函数C捕获并处理异常)
  5. 2楼和1楼就不用疏散了(函数B和A正常执行)

有一个问题就是:异常对象保存在哪?
异常对象并不存储在函数的栈上。当执行throw时,编译器会在一个特殊的内存区域(称为异常存储区)中拷贝构造一个异常对象的副本。这个存储区独立于任何函数的栈帧,因此即使栈展开销毁了所有局部对象,异常对象仍然存在。它的生命周期从throw开始,一直持续到最后一个处理它的catch块执行完毕。

嵌套异常处理

for循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <stdexcept>
#include <vector>

void processNumbers(const std::vector<int>& numbers) {
try {
for (size_t i = 0; i < numbers.size(); i++) {
std::cout << "处理第 " << i << " 个数: " << numbers[i];

if (numbers[i] < 0) {
throw std::runtime_error("负数不能处理: " + std::to_string(numbers[i]));
}

if (numbers[i] > 100) {
throw std::out_of_range("数字太大: " + std::to_string(numbers[i]));
}

std::cout << " -> 成功处理" << std::endl;
}
}
catch (const std::runtime_error& e) {
std::cout << "\n[运行时错误] " << e.what() << std::endl;
}
catch (const std::out_of_range& e) {
std::cout << "\n[范围错误] " << e.what() << std::endl;
}
}

int main() {
std::vector<int> data = {10, 20, -5, 30, 200, 40};

std::cout << "开始处理数据..." << std::endl;
processNumbers(data);
std::cout << "处理完成" << std::endl;

return 0;
}

可能修改原始的异常对象?
这取决于捕获方式。如果使用引用捕获(如catch(std::exception& e)),那么e就是异常存储区中原始对象的别名,对它的任何修改都会反映到原始对象上。如果在这个catch块中重新抛出(throw;),后续的catch块将看到修改后的状态。但如果使用值捕获(catch(std::exception e)),则e是原始对象的副本,修改它不会影响原始对象,重新抛出时抛出的也是原始对象。

目前的问题总结:

C++异常处理核心概念总结

在你学习C++异常处理的过程中,我们逐步探讨了几个关键概念。首先,你理解了异常对象是当程序检测到错误时使用throw语句创建的特殊对象,它不像普通局部变量那样存储在栈上,而是存放在专门的异常存储区中,这使得它能够在栈展开过程中一直存活,直到被合适的catch块捕获处理。当异常被抛出后,程序会触发栈展开机制,沿着函数调用链向上查找匹配的catch块,在这个过程中,所有局部对象都会被自动销毁(利用RAII机制确保资源正确释放),直到找到能够处理该异常的catch块为止。

在嵌套异常处理中,for循环扮演着”工作流水线”的角色,它遍历所有数据,对每个元素应用相同的处理逻辑,即使某个元素处理失败也不会中断循环,这种模式特别适合批处理任务。关于异常对象的修改权限,通过引用捕获catch (std::exception& e))可以修改原始的异常对象,这些修改会影响到后续的catch块(如果重新抛出),而通过值捕获则只能修改副本,不会影响原始对象。

至于为什么需要重新抛出异常,主要是因为当前层可能无法完全处理这个错误,需要添加上下文信息后让上层处理,或者只做了部分处理(如记录日志)但决策权在上层,或者是当前层发现错误但无力恢复,必须将问题”升级上报”。最后,在日常开发中,我们通常遵循这样的最佳实践:用const引用捕获只读异常,按从特殊到一般的顺序排列catch块,用throw;重新抛出同一异常,利用RAII管理资源避免手动清理,只在真正异常的情况下使用异常机制,以及在能够处理时就地处理、处理不了就往上抛的原则。

构造函数和析构函数中的异常处理:

构造函数尝试获取资源但失败,抛出异常

对象不会继续进行初始化,但是析构函数不会被调用?

运行时自动创建的具体对象,在构造函数抛出异常时都会自动销毁

but 通过new的不行

还是得用RAII,交给智能指针
构造函数尝试获取资源但失败,抛出异常。此时构造过程被中断,对象被认为没有成功创建。

对象不会继续进行初始化,但是析构函数不会被调用?正确。因为对象从未完整构造完成,所以它的析构函数不会被调用。这带来了资源泄漏的风险:如果在构造函数中已经成功获取了部分资源(如打开的文件、分配的内存),然后抛出异常,这些资源就可能泄漏。

运行时自动创建的具体对象,在构造函数抛出异常时都会自动销毁。这里需要澄清:如果对象是直接定义的局部对象,它的析构函数不会被调用。但如果是作为其他对象的成员,且该成员已经在异常抛出前成功构造,那么这个成员对象会被自动销毁。换句话说,C++保证在构造函数抛出异常时,已经完成构造的成员对象和基类子对象会被自动销毁。

but 通过new的不行。通过new创建的对象,如果在构造函数中抛出异常,operator new分配的内存会被自动释放,但构造函数中已获取的其他资源不会自动释放。

还是得用RAII,交给智能指针。这是唯一可靠的解决方案。将所有资源获取都放在RAII包装类中,这样即使构造函数抛出异常,这些RAII成员也会自动释放它们管理的资源。

析构函数不应该抛出异常

析构函数可能因为栈展开被调用,而这个栈展开可能是由另一个异常引起的

即使必须抛出异常,该异常也不应逃逸出析构函数

首先,析构函数通常在两种情况下被调用:一种是对象正常离开作用域时,另一种是异常发生导致栈展开时。当程序已经因为某个异常进入栈展开过程,如果此时正在被销毁的对象的析构函数又抛出另一个异常,C++运行时系统就会面临两个同时存在的异常,这种情况被称为”双重异常”。由于程序无法同时处理两个异常,唯一的出路就是立即调用std::terminate终止整个程序。这意味着,一个原本可以被捕获和处理的上层异常,会因为析构函数抛出的新异常而导致程序崩溃。

其次,析构函数的异常会破坏资源清理的完整性。当容器(如vector)在销毁过程中,需要依次销毁其包含的所有元素。如果第一个元素的析构函数抛出了异常,后续元素的析构函数将根本没有机会执行,这直接导致这些元素所管理的资源(如内存、文件句柄、数据库连接等)永久泄漏。这种情况违背了RAII的核心承诺——资源一定会被正确释放。

此外,标准库的所有组件都假定析构函数不会抛出异常。例如,智能指针、容器、算法等在设计时都基于这个前提。如果一个对象的析构函数可能抛出异常,将其放入标准容器中就会导致未定义行为。

析构函数的首要职责是确保资源释放,而不是报告错误。资源释放必须成功,而错误报告应该通过其他机制在更合适的时机进行。

从C++11开始,所有析构函数默认隐式地带有noexcept说明符,这意味着如果析构函数试图抛出异常,程序会直接调用std::terminate终止。这进一步强化了”析构函数绝不抛出异常”的规则。

noexcept关键字:

告知编译器该函数不会抛出异常,编译器就可以优化了?不生成栈展开函数?
noexcept告诉编译器该函数承诺不抛出异常,编译器可以进行优化,减少运行时开销,因为不需要为可能的栈展开生成额外的代码路径。

调用库函数的时候最好不要,因为不知道会不会抛出异常

noexpect(true)

noexpect(noexpect(Test(x)))

析构也应该noexpect,C++11中,所有析构函数都隐式标记了noexpect的说明符

实现移动操作时应使用noexpect标记

第一,标准容器在重新分配内存时会优先选择移动操作。当vectordeque等容器需要扩容时,必须将现有元素转移到新的内存空间。如果元素的移动构造函数被标记为noexcept,容器会毫不犹豫地使用移动操作,高效地将资源”偷”过来。但如果移动构造函数可能抛出异常,容器出于安全考虑只能退而使用拷贝操作。拷贝通常涉及深复制,性能开销远大于移动,尤其是在处理大型对象时。

第二,noexcept移动操作保证了容器操作的强异常安全保证。以vectorpush_back为例:当需要扩容时,容器首先在新内存中构造元素。如果移动操作可能抛出异常,容器无法保证在构造过程中发生异常时能够将原元素恢复到稳定状态。因此,标准库规定:只有当移动操作是noexcept时,容器才会在重新分配时使用移动语义;否则,为了保证强异常安全(即操作要么完全成功,要么回滚到原始状态),容器必须使用拷贝操作。

下面是C++的文件相关的内容

原始字符串字面量,忽略任何

{R“(C\t\n)”}

无需嵌入转义

分隔符,MSG嵌入?

C++11的还是C++17的?文件系统库?

原始字符串字面量

原始字符串字面量是C++11引入的特性,它允许我们以更直观的方式编写字符串,特别适合处理包含大量转义字符的文本(如文件路径、正则表达式等)。

基本语法

原始字符串字面量的基本形式是:R"delimiter(字符串内容)delimiter"

  • R 前缀表示这是一个原始字符串
  • 括号 () 内的内容会原样保留,不需要转义
  • 两边的 delimiter 是可选的分隔符,通常省略

核心特点:无需嵌入转义

在普通字符串中,我们不得不使用大量转义字符:

cpp

1
2
3
4
// 普通字符串:需要转义
"c:\\temp\\new\\file.txt" // 路径中的反斜杠需要双写
"Line 1\nLine 2\nLine 3" // 换行符需要转义
"\\d+\\.\\d+" // 正则表达式中的反斜杠和点号都需要转义

在原始字符串中,所有这些字符都按原样处理:

cpp

1
2
3
4
5
6
// 原始字符串:直接写,无需转义
R"(c:\temp\new\file.txt)" // 反斜杠就是反斜杠
R"(Line 1
Line 2
Line 3)" // 可以直接换行
R"(\d+\.\d+)" // 正则表达式直接写

分隔符的作用

当字符串内容中可能包含 ) 时,分隔符就变得很有用了。分隔符可以是任何不超过16个字符的短字符串:

cpp

1
2
3
4
5
// 不使用分隔符的情况:如果字符串中包含 )",会导致提前结束
R"(这会导致问题 )" 这里有引号)" // 编译器会困惑

// 使用分隔符:指定一个自定义的分隔符,比如 MSG
R"MSG(这里可以放心地写 )" 或者任何内容 )MSG" // 完全没问题

应用场景

原始字符串字面量特别适合以下场景:

  1. 文件系统路径:Windows路径中的反斜杠不再需要转义
  2. 正则表达式:避免”反斜杠海啸”
  3. 多行文本:可以直接在代码中嵌入大段文本
  4. HTML/XML/JSON:嵌入这些格式时非常清晰

版本说明

原始字符串字面量是 C++11 标准引入的特性。至于文件系统库,它是 C++17 标准引入的,不要将这两个特性混淆。文件系统库需要包含 <filesystem> 头文件,而原始字符串字面量是语言核心特性,不需要任何头文件。

这个特性极大提高了代码的可读性和可维护性,特别是在处理那些需要大量转义的字符串时。

C++17文件系统库

std::experimental::filesystem

path p(“R()”)文件系统路径 不检查路径有效性

for循环打印p

directory_iterator beg{p};

然后遍历

1. 版本变迁

文件系统库最初在C++14时作为实验性特性引入,位于 std::experimental::filesystem 命名空间。到了C++17,文件系统库被正式纳入C++标准,命名空间调整为 std::filesystem。使用时需要包含 <filesystem> 头文件。

2. path 类——文件系统路径的核心

path 类是文件系统库中最基础的组件,专门用于表示文件或目录的路径。

路径创建时不会检查有效性:创建 path 对象时,系统并不会去验证这个路径是否真实存在。它仅仅是对路径字符串的封装,用于后续的路径操作。路径是否有效、文件是否存在,需要通过其他函数来检查确认。

路径的组成部分:一个完整路径可以被分解为多个部分,包括根名称(如Windows的盘符)、根目录、父路径、文件名(含扩展名)、文件名主体(不含扩展名)和扩展名等。path 类提供了相应的成员函数来获取这些组成部分。

路径拼接:可以使用 / 运算符来拼接路径的不同部分,这种方式会自动处理不同操作系统的路径分隔符差异,是推荐的做法。

遍历路径组件:可以对 path 对象使用范围for循环,依次访问路径的每一级组成部分,例如从根目录开始逐级向下遍历各级目录名直到文件名。

3. 目录遍历

directory_iterator 的作用:这是用于遍历目录内容的迭代器,类似于在命令行中执行 dirls 命令的效果。

基本遍历方式:创建一个以目标路径为参数的 directory_iterator 作为起始迭代器,配合默认构造的结束迭代器,就可以遍历指定目录下的所有条目。由于它支持范围for循环,代码可以写得很简洁。

directory_entry 的信息:遍历时每个迭代器指向的是一个 directory_entry 对象,它封装了文件系统条目的详细信息。通过这个对象可以获取条目的完整路径,还可以判断条目的类型(是普通文件、目录还是符号链接),获取文件大小、最后修改时间等属性。

递归遍历:如果需要遍历目录及其所有子目录中的所有内容,可以使用 recursive_directory_iterator。它能够深入每一级子目录,并且可以控制递归深度或跳过某些目录。

4. 常用文件系统操作

存在性检查:可以检查指定的路径是否存在,如果是文件还可以获取文件大小。

类型判断:能够判断一个路径指向的是普通文件、目录还是符号链接。

创建操作:可以创建单级目录,也可以递归创建多级目录结构。

复制与移动:支持文件的复制操作,以及重命名或移动文件的功能。

删除操作:可以删除单个文件,也可以递归删除整个目录及其所有内容,后者是危险操作需要谨慎使用。

路径信息获取:能够获取当前工作目录的路径、系统临时目录的路径。

路径规范化:可以将包含 ... 的相对路径解析为规范化的绝对路径,也可以将相对路径转换为绝对路径。

磁盘空间查询:可以查询指定分区的总容量、可用空间和空闲空间信息。

5. 异常处理

大多数文件系统操作函数在出错时会抛出 std::filesystem::filesystem_error 异常,这个异常对象包含了错误描述以及涉及到的路径信息。

另外,也提供了接受 std::error_code 参数的重载版本,这些版本在出错时不会抛出异常,而是通过错误码来返回错误信息,适合在不想使用异常处理的场景中使用。

6. 最佳实践要点

处理Windows路径时推荐使用原始字符串字面量,避免反斜杠转义的困扰。

进行路径拼接时始终使用 / 运算符,而不是手动拼接字符串,以确保跨平台兼容性。

执行任何操作前最好先检查路径是否存在以及文件类型,不要做任何假设。

注意权限问题,特别是在执行删除和写入操作时。

根据场景选择合适的错误处理方式——要么使用try-catch捕获异常,要么使用带 error_code 参数的重载版本。

处理包含中文等字符的路径名时,需要使用合适的编码方式避免乱码问题。

7. 实用技巧

可以为 std::filesystem 定义命名空间别名(如 namespace fs = std::filesystem;)来简化代码书写。

文件系统库中的时间信息通常以系统时钟的时间点形式返回,需要时可以转换为更容易阅读的格式。

File i/O

ofstream

ifstream

fstream

include

image-20260214190818186

image-20260214190928599

文件的输入输出暂时我用得不多,先学习模板相关:

模板和泛型编程

1
2
3
4
5
template <typename T>
T max(T a, T b)
{

}

T是占位符

typename/class?

函数模板未被调用的话,编译器就不会生成该函数

实例化

编译器推导

no类型转换

编译器在模板参数推导之后生成代码,叫模板实例化

发生在编译时

什么时候触发实例化?

头文件中定义实现

显式特化

不能在头文件定义?

显式实例化?怎么区分?

非类型模板参数

模板参数通常是我们熟悉的类型参数(如 typename Tclass U),但模板也可以接受具体的值作为参数,这些值就是非类型模板参数

  • 类型参数:template <typename T> —— T 代表某种类型(如 int、double、string)
  • 非类型参数:template <int N> —— N 代表一个具体的整数值(如 5、100、数组长度)

必须常量表达式

begin,end里用了?

完美转发

类内多种构造函数

1
2
3
4
5
6
7
8
9
10
11
// 为左值准备的版本(当 T 是左值引用时)
template <typename T>
T&& forward(typename std::remove_reference<T>::type& param) {
return static_cast<T&&>(param); // 转回左值引用
}

// 为右值准备的版本(当 T 不是引用时)
template <typename T>
T&& forward(typename std::remove_reference<T>::type&& param) {
return static_cast<T&&>(param); // 转回右值引用
}
原始类型 折叠后
T& & T&
T& && T&
T&& & T&
T&& && T&&

简单记忆:只要有一个左值引用,结果就是左值引用;两个都是右值引用,结果才是右值引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x = 10;
wrapper(x); // 传入左值

// 1. 模板参数推导:T = int&(因为传入左值)
// 2. 函数签名变为:wrapper(int& && param) → 折叠后:wrapper(int& param)
// 3. param 的类型是 int&(左值引用)

// 4. 调用 std::forward<T>(param):
// T = int&
// std::forward<int&>(param)
// → 返回类型:int& && → 折叠为 int&
// → 返回左值引用

// 结果:保持了左值属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
wrapper(10);  // 传入右值

// 1. 模板参数推导:T = int(因为传入右值)
// 2. 函数签名变为:wrapper(int&& param)
// 3. param 的类型是 int&&(右值引用),但 param 本身是左值

// 4. 调用 std::forward<T>(param):
// T = int
// std::forward<int>(param)
// → 返回类型:int&&
// → 返回右值引用

// 5. static_cast<int&&>(param) 把左值 param 转回右值
// 结果:恢复了右值属性
  • std::move:无条件转换为右值
  • std::forward:有条件转换(只有原始是右值时才转成右值)

可变参数模板:

第一种,类型相同时初始化列表形式

但是类型不同呢?

接收任意类型和任意数量的参数

模板参数包

void Print()

{

}

template<typename T,typename…Params>

void Print(T a,Params… args)

{

​ //如何访问各个参数,必须依赖递归

​ //如何终止?

​ Print(args…);

}

基础情况函数

确定参数包的参数数量

sizeof…(args)

还要使用完美转发

类模板

这里想到了enum class

特性 enum(传统) enum class(C++11)
作用域 枚举值泄漏到外层作用域 枚举值限定在类型内(必须用类型名::访问)
隐式转换 可以隐式转换为整数 不能隐式转换,需要显式 static_cast
底层类型 由编译器决定,不可指定 可以显式指定(如 : char、: int)
向前声明 不支持(因为不知道底层类型) 支持(可指定或不指定底层类型)
名称冲突 容易冲突 避免冲突
语法 enum Color {RED, GREEN}; enum class Color {RED, GREEN};