文章目录
《C++基础与深度解析》传送门:
- C++初探 | Hello World | 系统I/O | 控制流 | 结构体与自定义数据类型
- 什么是C++ | C++开发环境与相关工具 | C++编译/链接模型
- 对象与基本类型 | 初始化/赋值语句 | 类型 | 复合类型 | 常量类型与常量表达式 | 类型别名与类型的自动推导 | 作用域与对象的生命周期
- 数组 | vector | string
- 表达式 | 操作符
- 语句 | 分支语句 | 循环语句 | 达夫设备
- 函数的声明与定义 | 函数调用 | 函数详解 | 函数重载 | 重载解析 | 递归函数 | 内联函数 | 函数指针
- 输入与输出 | 文件与内存操作 | 流的状态、定位与同步
- 动态内存管理 | 智能指针
- 序列容器 | 关联容器 | 适配器与生成器
- 泛型算法 | bind | Lambda表达式
- 类与面向对象编程 | 数据成员 | 成员函数 | 访问限定符与友元 | 构造、析构成员函数 | 字面值类、成员指针与bind交互
- 类进阶 | 运算符重载 | 类的继承 | 虚函数
- 模板 | 函数模板 | 类模板与成员函数模板 | concepts | 完美转发 | 模板的其他内容
- 元编程 | 元编程的编写方式 | 减少实例化技巧
- 异常处理 | 枚举与联合 | 嵌套类与局部类 | 嵌套名字空间与匿名名字空间 | 位域与volatile关键字
一、异常处理
异常处理用于处理程序在调用过程中的非正常行为。
-
传统的处理方法:传返回值表示函数调用是否正常结束。
例如,返回
0
表示成功,非0
表示失败。这种方法的缺点是函数的返回值被错误处理逻辑占用,不能用于其他目的。这种方法有局限性
-
C++ 中的处理方法:通过关键字 try/catch/throw 引入异常处理机制
通过
try/catch/throw
引入了结构化的错误处理机制,使得错误处理逻辑与正常逻辑分离,提高了代码的可读性和可维护性。C++异常处理的关键组件:
-
throw关键字
throw
关键字用于抛出一个异常。它后面可以跟任意类型的表达式,该表达式的结果将被用作异常对象 -
try块
try
块包含了可能会抛出异常的代码。如果try
块中的代码抛出了异常,那么与之匹配的catch
块将被执行。 -
catch块
catch
块用于捕获并处理异常。可以有多个catch
块来捕获不同类型的异常。 -
异常类
C++标准库中定义了一些基本的异常类,如
std::exception
、std::runtime_error
、std::logic_error
等
-
异常触发时的系统行为—栈展开:
-
抛出异常后续的代码不会被执行
一旦抛出异常,
throw
语句之后的代码将不会被执行。控制流会立即转移到异常处理机制。 -
局部对象会按照构造相反的顺序自动销毁
在栈展开过程中,局部对象(包括由
new
分配的对象)会按照它们构造的相反顺序自动销毁。这是为了保证资源的正确释放,防止内存泄漏。 -
系统尝试匹配相应的 catch 代码段
-
如果找到匹配的
catch
块:- 执行
catch
块中的异常处理逻辑。 - 异常被“捕获”后,
catch
块之后的代码会继续执行。
- 执行
-
如果没有找到匹配的
catch
块:-
栈展开会继续进行,直到找到匹配的
catch
块或者退出当前函数。 -
如果当前函数中没有找到匹配的
块,栈展开会继续,直到:
-
找到一个匹配的
catch
块。 -
达到
main
函数。如果在
main
函数之前的所有函数中都没有找到匹配的catch
块,程序将退出main
函数。如果程序在退出main
函数之前没有捕获到异常,std::terminate
函数将被调用。这通常会导致程序立即终止。
-
-
-
异常对象:
- 系统会使用抛出的异常拷贝初始化一个临时对象,称为异常对象
- 异常对象会在栈展开过程中被保留,并最终传递给匹配的 catch 语句
try / catch语句块:
-
一个 try 语句块后面可以跟一到多个 catch 语句块(至少跟一个)
-
每个 catch 语句块用于匹配一种类型的异常对象
可以有多个
catch
块,每个用于处理不同类型的异常。 -
catch 语句块的匹配按照从上到下进行
catch
块按照它们在代码中出现的顺序(从上到下)进行匹配。一旦找到匹配的异常类型,就执行相应的catch
块,忽略后面的catch
块。 -
使用 catch(…) 匹配任意异常
catch(...)
是一个通用的异常捕获器,它可以捕获任何类型的异常,包括未被前面的catch
块捕获的异常。 -
在 catch 中调用 throw 继续抛出相同的异常
在
catch
块中,可以使用throw;
(不带参数)来重新抛出当前捕获的异常,这将导致继续搜索外层catch
块。
示例:演示
#include <iostream>
struct Str{};
struct Base{};
struct Derive : Base{};
void f1()
{
int x;
Str obj;
//throw Derive{}; //打印Derive exception is called in f2
throw Str{}; //打印exception is called in f2
}
void f2()
{
int x2;
Str obj2;
try
{
f1();
}
catch(Derive& e)
{
std::cout << "Derive exception is called in f2 " << "\n";
}
catch(Base& e)
{
std::cout << "Base exception is called in f2 " << "\n";
}
catch(...)
{
std::cout << "exception is called in f2" << "\n";
throw; //重新抛出当前捕获的异常
}
std::cout << "other logic in f2.\n";
}
void f3()
{
try
{
f2();
}
catch(Str& e)
{
std::cout << "exception is called in f2" << "\n";
}
}
int main()
{
f3();
}
在一个异常未处理完成时抛出新的异常会导致程序崩溃:
- 不要在析构函数或 operator delete 函数重载版本中抛出异常
- 通常来说, catch 所接收的异常类型为引用类型
异常与构造、析构函数:
-
使用 function-try-block保护初始化逻辑
在C++中,function-try-block允许你在函数的初始化列表和函数体中使用
try
和catch
。这在构造函数中特别有用,因为它可以保护对象的初始化代码。示例:
#include <iostream> struct Str { Str() { throw 100; } } class Resource { public: Resource() try : m_str() { } catch(int) { std::cout << "Exception is catched at Resource::Resource" << std::endl; throw; } private: Str m_str; }; int main() { try { Resource obj; } catch(int) { std::cout << "Exception is catched at main" << std::endl; } }
运行结果:
Exception is catched at Resource::Resource Exception is catched at main
-
在构造函数中抛出异常:
-
已经构造的成员对象会被销毁
如果在构造函数中抛出异常,已经构造的成员对象将按照它们构造的相反顺序自动销毁。
-
类本身的析构函数不会被调用
如果异常是在对象的构造过程中抛出的,并且没有被捕获,那么类的析构函数不会被调用。这是因为对象被视为未完全构造,因此析构函数不适用。
-
局部对象的销毁
如果对象是局部的(即在栈上),异常抛出时,局部对象会自动销毁,但不会调用其析构函数。
-
动态分配对象的销毁
如果对象是动态分配的(即使用
new
),在构造函数中抛出异常且未被捕获时,需要手动释放分配的内存,因为析构函数不会被调用。
-
描述函数是否会抛出异常:
-
如果函数不会抛出异常,则应表明,从而为系统提供更多的优化空间
- C++ 98 的方式:
- throw() :表明不会抛出异常
- throw(int, char):表明可能抛出异常,显式给定了要抛出异常的类型
- C++11 后的改进:
- noexcept :表明不会抛出异常
- noexcept(false):表明可能抛出异常,不需要显式给定会抛出哪种类型的异常
- C++ 98 的方式:
-
noexcept
- 限定符:接收 false / true 表示是否会抛出异常
- 操作符:接收一个表达式,根据表达式是否可能抛出异常返回 false/true
- 在声明了 noexcept 的函数中抛出异常会导致 terminate 被调用,程序终止
- 不作为函数重载依据,但函数指针、虚拟函数重写时要保持形式兼容
示例:
#include <iostream>
void fun2()
{
}
void fun() noexcept(noexcept(fun2()))
{
fun2();
}
int main()
{
std::cout << noexcept(fun()) << std::endl;
}
标准异常:
C++标准库的异常类(可用于实例化异常对象)及之间的继承关系。
包括:运行期错误、逻辑错误、内存错误。并且这些异常类可以记录异常信息,可以使用e.what()
来获取异常信息。希望使用标准异常以及标准异常派生出的自定义异常。
正确对待异常处理:
-
不要滥用:异常的执行成本非常高
-
不要不用:对于真正的异常场景,异常处理是相对高效、简洁的处理方式
-
编写异常安全的代码
异常安全的代码即在抛出异常时资源能被正常释放,通常使用构造函数中申请资源析构函数中释放资源的类,内容比较多,具体不展开讨论。
二、枚举与联合
1.枚举
C++枚举(enum) :一种取值受限的特殊类型。
枚举的分类:分为无作用域枚举与有作用域枚举( C++11 起)两种
-
无作用域枚举(Unscoped enum):
- 在C++11之前,枚举默认为无作用域枚举。枚举项在枚举类型定义的外部也是可见的。
- 无作用域枚举项隐式地转换为整数值。
示例:
enum Color { RED, GREEN, BLUE }; int main() { Color c = RED; // RED是一个全局标识符 return 0; }
在这个例子中,
RED
、GREEN
和BLUE
是全局可见的,可以在程序的任何地方使用。 -
有作用域枚举(Scoped enum, C++11起):与无作用域枚举相比,防止名称污染
- 有作用域枚举使用
enum class
或enum struct
声明。 - 枚举项在枚举类型的外部是不可见的,需要使用作用域运算符
::
来访问。 - 枚举值的作用域被限制在枚举类型内部,因此它们不会与全局作用域中的其他名称冲突。
- 强枚举:枚举项不能隐式转换为整数值,需要显式转换
示例:
enum class Color { RED, GREEN, BLUE }; int main() { Color color = Color::RED; // 使用作用域运算符 int value = static_cast<int>(color); // 显式转换为整数值 return 0; }
在这个例子中,
RED
、GREEN
和BLUE
是Color
枚举类的一部分,它们的作用域被限制在Color
内部。要访问这些值,需要使用作用域运算符::
。 - 有作用域枚举使用
枚举项:
-
默认初始化:枚举项缺省使用 0 初始化,依次递增1。
enum Color { RED, // 默认值为0 GREEN, // 默认值为1 BLUE // 默认值为2 };
-
可以使用常量表达式来修改枚举项的默认值。
enum Color { RED = 1, GREEN = 5, BLUE // 默认值为11 };
-
可以为枚举指定底层类型,表明了枚举项的尺寸和可能的值范围。
enum Color : uint8_t { RED, GREEN, BLUE };
在这个例子中,
Color
枚举的底层类型是uint8_t
,这意味着枚举项的值将被存储为8位无符号整数。 -
无作用域枚举项可隐式转换为整数值;
这意味着,如果枚举项没有被明确地限定作用域,它们可以被当作整数值使用。
enum Color { RED, GREEN, BLUE }; int main() { int value = RED; // 隐式转换为整数值 return 0; }
-
也可用 static_cast 在枚举项与整数值间显式转换
enum Color { RED, GREEN, BLUE }; int main() { int value = static_cast<int>(RED); // 显式转换为整数值 return 0; }
枚举的声明与定义:
在实际编程中,枚举的定义通常放在头文件中,而枚举的声明可以在需要引用枚举类型但不需要知道其具体值的源文件或头文件中使用。使用前向声明可以减少编译依赖并提高编译效率。
-
枚举的声明
无作用域枚举的声明需要指定枚举的类型,而有作用域枚举的声明的枚举类型默认是int
// 无作用域枚举的声明 enum Color : int; // 声明Color枚举类型,但不定义它的值 //有作用域枚举的声明 enum class Color;
枚举的前向声明:
在C++中,枚举类型可以被前向声明,这意味着你可以在不知道枚举值的情况下声明枚举类型。
// 前向声明枚举类型 enum class Color; // 定义函数,使用前向声明的枚举类型 void useColor(Color c) { // 函数实现 }
-
枚举的定义
//无作用域枚举的定义 enum Color { RED, GREEN, BLUE }; //有作用域枚举的定义 enum class Color { RED, GREEN, BLUE };
2.联合
在C++中,联合(union)是一种特殊的数据类型,允许在相同的内存位置存储不同的数据类型,但一次只能使用其中一个。联合的主要目的是节省空间,因为联合的大小等于它能够存储的最大的成员的大小。
联合的基本特性:
- 内存共享:联合的所有成员都共享相同的内存位置。当为联合赋新值时,之前的值会被覆盖,因此在使用联合时需要小心。
- 类型多样性:联合可以包含不同类型的数据成员。
- 大小限制:联合的大小等于它的最大成员的大小,而不是所有成员大小的总和。
- 访问限制:在任何给定时间,只能访问联合的一个成员。
联合与枚举一起使用:
联合经常与枚举一起使用,尤其是在需要根据不同的情况访问不同类型的数据时。枚举用于标识联合中当前存储的数据类型。
示例:
#include <iostream>
enum class Type {
INT,
CHAR,
DOUBLE
};
union Data {
int intValue;
char charValue;
double doubleValue;
};
void processData(Type type, Data& data) {
switch (type) {
case Type::INT:
std::cout << "Integer: " << data.intValue << std::endl;
break;
case Type::CHAR:
std::cout << "Character: " << data.charValue << std::endl;
break;
case Type::DOUBLE:
std::cout << "Double: " << data.doubleValue << std::endl;
break;
}
}
int main()
{
Type type = Type::INT;
Data data;
data.intValue = 1;
processData(type, data);
}
匿名联合:
C++11允许在结构体或类中使用匿名联合,即不命名的联合。这使得可以直接访问联合的成员,而不需要通过联合变量。
示例:
struct Data {
union {
int i;
char c;
double d;
};
};
int main() {
Data data;
data.i = 10; // 直接访问匿名联合的成员
return 0;
}
在联合中包含非内建类型( C++11 起):
从C++11开始,联合可以包含用户定义的类型(UDT),例如类或结构体或string
或vector
。但是,这些类型必须没有非静态成员数据或位字段,因为它们的大小和对齐要求可能与联合的内存布局冲突。
示例:
#include <iostream>
// 一个简单的类,没有成员数据,没有构造函数和析构函数
class EmptyClass {
public:
void doSomething() const {
std::cout << "Doing something in EmptyClass" << std::endl;
}
};
// 联合可以包含EmptyClass类型
union DataUnion {
int i;
double d;
EmptyClass e;
};
int main() {
DataUnion data;
// 初始时,联合默认使用第一个成员,这里是int类型
std::cout << "Initial value of i: " << data.i << std::endl;
// 访问联合中的EmptyClass成员,并调用其成员函数
data.e.doSomething();
// 改变联合的当前活动成员为double
data.d = 3.14;
std::cout << "Value of d: " << data.d << std::endl;
// 再次访问EmptyClass成员,注意:此时EmptyClass的doSomething可能不会执行预期的操作
data.e.doSomething();
return 0;
}
三、嵌套类与局部类
1.嵌套类
在C++中,嵌套类(nested class)是在另一个类内部定义的类。嵌套类提供了一种组织代码的方式,使得相关的类可以紧密地组合在一起,同时保持了封装性和逻辑上的联系。
特性:
- 作用域:嵌套类具有自己的域,与外围类的域形成嵌套关系。
- 访问权限:嵌套类可以访问外围类的所有成员,包括私有(private)和保护(protected)成员。但需要确保这些成员是通过外围类的对象来访问的,而不是直接访问。
- 名称查找:如果在嵌套类中查找名称失败,编译器会在外围类的作用域中继续查找。
- 独立性:尽管嵌套类与外围类有紧密的联系,但它们各自拥有独立的成员。
示例:
class OuterClass {
private:
int outerPrivate;
protected:
int outerProtected;
public:
OuterClass() : outerPrivate(0), outerProtected(0) {}
void setOuterPrivate(int value) { outerPrivate = value; }
class InnerClass {
private:
int innerPrivate;
public:
void setInnerPrivate(int value) { innerPrivate = value; }
// 嵌套类中的函数,通过外围类对象的引用访问外围类的成员
void accessOuterMembers(OuterClass& outer) {
outer.outerPrivate = 10; // 通过外围类的引用访问私有成员
outer.outerProtected = 20; // 访问外围类的保护成员
outer.setOuterPrivate(30); // 调用外围类的公有成员函数
}
};
};
int main() {
OuterClass outer; // 创建外围类的对象
OuterClass::InnerClass inner; // 创建嵌套类的对象
inner.setInnerPrivate(5); // 访问嵌套类的公有成员函数
inner.accessOuterMembers(outer); // 将外围类对象的引用传递给嵌套类的成员函数
return 0;
}
2.局部类
局部类(local class)是在函数内部定义的类。
特性:
- 访问权限:可以访问外围函数中定义的类型声明、静态对象与枚举
- 成员函数定义:局部类的成员函数必须在类定义的内部进行定义。这是因为局部类的实例在函数外部是不可见的,因此无法在函数外部定义其成员函数。
- 静态数据成员:局部类不能定义静态数据成员,因为静态成员需要在函数外部具有存储位置,而局部类的实例在函数外部是不可访问的。
- 作用域:局部类的作用域限定在定义它的函数内部。
示例:
void outerFunction() {
static int staticVar = 42; // 静态局部变量
enum Color { RED, GREEN, BLUE }; // 枚举类型
// 局部类定义
class LocalClass {
public:
int localMember;
// 成员函数定义必须在类定义内部
void accessOuterScope() {
localMember = staticVar; // 访问静态局部变量
// 这里不能直接使用 RED,因为它不是静态存储期的
}
};
LocalClass localObject;
localObject.accessOuterScope();
}
int main() {
outerFunction();
return 0;
}
四、嵌套名字空间与匿名名字空间
1.嵌套名字空间
在C++中,命名空间(namespace)是一种用于组织代码的方式,可以减少不同代码模块之间的名称冲突。C++允许定义嵌套命名空间,这可以进一步帮助组织大型项目中的代码。
特性:
- 嵌套域:命名空间可以嵌套在其他命名空间内部,形成嵌套域。
- 名称查找:在嵌套命名空间中声明的名称首先在其直接父命名空间中查找,如果找不到则继续向上查找。
- 多处定义:相同的命名空间可以在程序的多个地方定义,这是允许的,并且可以用来向同一个命名空间中增加声明或定义。这有助于在不同的编译单元中扩展命名空间。
-
C++17 简化定义:从C++17开始,可以使用
inline
关键字来简化嵌套命名空间的定义,使得在头文件中定义的命名空间可以在包含该头文件的任何地方使用,而不需要重复命名空间的声明。
示例:传统嵌套名字空间
namespace Outer {
namespace Inner {
void function() {
// ...
}
}
}
int main() {
Outer::Inner::function(); // 调用嵌套命名空间中的函数
return 0;
}
示例:C++17嵌套名字空间
namespace Outer {
inline namespace Inner {
void function() {
// ...
}
}
}
// 在包含这个头文件的任何地方,都可以直接使用Outer::function()
int main() {
Outer::function(); // 直接调用,不需要显式地使用Inner
return 0;
}
2.匿名名字空间
C++中的匿名命名空间是一种特殊的命名空间,它提供了一种机制来确保在同一个翻译单元(通常是一个源文件)内定义的实体具有唯一性,而不与其他翻译单元中的同名实体冲突。
特性:
- 翻译单元唯一性:在匿名命名空间中定义的任何实体(如类、函数、变量等)仅在当前的翻译单元(源文件)内可见,不具有外部链接。这意味着在其他翻译单元中定义的同名实体不会与匿名命名空间中的实体冲突。
-
可用 static 代替:匿名命名空间可以作为
static
关键字的替代品,用于控制变量和函数的可见性。与static
不同,匿名命名空间不要求在每个函数和变量前都使用static
关键字,而是在命名空间级别上提供唯一性。 - 可作为嵌套命名空间:匿名命名空间可以作为其他命名空间的嵌套成员。这可以进一步帮助组织代码和控制命名空间的可见性。
-
没有名称:匿名命名空间没有名称,它通过在
namespace
关键字后面不跟任何标识符来定义。
示例:
// 匿名命名空间的定义
namespace {
int globalVar = 42; // 仅在当前翻译单元内可见
void globalFunction() {
// ...
}
}
int main() {
// 直接使用匿名命名空间中的实体
globalVar = 100;
globalFunction();
return 0;
}
在这个示例中,globalVar
和globalFunction
是在匿名命名空间中定义的,它们仅在包含这个定义的翻译单元内可见。
注意事项:
- 匿名命名空间不能被其他翻译单元访问,即使是嵌套在另一个命名空间内部。
- 匿名命名空间在每个包含它的头文件或源文件中都是唯一的,即使它们包含相同的代码。
- 匿名命名空间可以用于避免命名冲突,特别是在大型项目或库的开发中。
五、位域与volatile关键字
1.位域
详细内容可参考:https://zh.cppreference.com/w/cpp/language/bit_field
C++中的位域(bit-fields)用于表明对象尺寸(所占位数)。它允许程序员访问和操作内存中的特定位数。
-
在结构体 / 类中使用
位域通过在结构体或类中声明成员时指定它们所占的位数来定义。
-
多个位域对象可能会被打包存取
编译器可能会将多个位域成员打包存储在内存中,以优化存储空间的使用。
-
声明为位域的对象不能被取地址,因此不能使用指针或非常量引用来访问它们
-
尺寸通常会小于对象类型所对应的尺寸,否则取值受类型限制
位域的大小通常小于或等于其底层类型的大小。如果位域的尺寸大于其类型的大小,编译器将应用类型的大小限制。
-
对齐要求
位域的布局可能受到编译器和平台的内存对齐要求的影响。
-
不同的编译器和平台可能以不同的方式实现位域,因此使用位域的代码可能不具备高度的可移植性。
示例:
#include <iostream>
struct BitField {
unsigned int is_enabled : 1; // 1位用于表示是否启用
unsigned int has_data : 2; // 2位用于表示数据状态
unsigned int priority : 3; // 3位用于表示优先级
};
int main() {
BitField bf;
bf.is_enabled = 1;
bf.has_data = 3;
bf.priority = 7;
std::cout << "is_enabled: " << bf.is_enabled << std::endl;
std::cout << "has_data: " << bf.has_data << std::endl;
std::cout << "priority: " << bf.priority << std::endl;
// 尝试取位域对象的地址将导致编译错误
// int* ptr = &bf.is_enabled; // 错误:不能为位域对象取地址
return 0;
}
运行结果:
is_enabled: 1
has_data: 3
priority: 7
在这个示例中,BitField
结构体定义了三个位域成员,分别占用1位、2位和3位。位域成员通过指定它们所占的位数来定义。尝试为位域成员取地址将导致编译错误。
2.volatile关键字
在C++中,volatile
关键字是一种类型限定符,用于告诉编译器一个变量可能会被程序的控制流之外的因素所改变。这通常用于嵌入式编程或硬件接口编程,其中变量可能由硬件或其他并发执行的线程所修改。
特性:
-
外部修改:
volatile
表明变量的值可能会在程序不知道的情况下改变,因此编译器不会优化涉及该变量的代码。 -
禁止优化:由于编译器认为
volatile
变量的值可能随时改变,因此编译器不会缓存这些变量的值,每次访问都会从原始的内存位置读取。 -
性能影响:频繁地读写
volatile
变量可能会增加程序的执行负担,因为每次访问都需要直接与内存交互。 -
慎用:
volatile
应该慎重使用,因为它会影响程序的性能。只有在确实需要时才使用volatile
。 -
atomic
替代:在某些情况下,如果变量的修改是由多个线程以原子操作进行的,可以使用std::atomic
来代替volatile
。std::atomic
提供了一组原子操作,可以保证在多线程环境中对变量的安全访问。
示例:
#include <iostream>
volatile int flag = 0;
void interrupt_service_routine() {
// 模拟中断服务程序,可能会改变flag的值
flag = 1;
}
int main() {
// 假设下面的循环在等待中断
while (flag == 0) {
// 编译器不会优化这个循环,因为它认为flag的值可能会改变
}
// 当中断发生时,flag的值被改变
interrupt_service_routine();
std::cout << "Flag is now: " << flag << std::endl;
return 0;
}