OOP 朋辈辅学 Lec4 移动语义与异常处理¶
1 C++ 的类型系统¶
1. 1 类型¶
1. 1. 1 基础类型和复合类型¶
C++ 的基础类型基本继承自 C 语言,常见类型包括:
- 整型:
short、int、long、long long,可以配合signed/unsigned - 浮点型:
float、double - 字符型:
char - 布尔型:
bool - 空类型:
void,常用于函数返回值或指针类型
在基础类型之上,C++ 还提供了复合类型:
- 引用:对象的别名,如
int& - 指针:保存内存地址,如
int* - 数组:相同类型元素组成的序列,如
int arr[10] - 函数:拥有特定参数列表和返回类型的逻辑单元
用户自定义类型包括 struct、class、union、enum 等,其中 struct 和 class 是面向对象建模的核心。
1. 1. 2 联合体¶
联合体(union)允许多个成员共用同一块内存空间,但同一时刻通常只有一个成员处于活跃状态。
示例
union Data {
int i;
float f;
};
Data data;
data.i = 10;
cout << data.i << endl;
data.f = 220.5; // 此时 i 的值会被覆盖
cout << data.f << endl;
union 还可以匿名定义在结构体或类中,此时联合体成员可以直接通过外层对象访问。
匿名联合体
struct Widget {
int id;
union {
int count;
float weight;
};
};
Widget widget;
widget.count = 3; // 无需写成 widget.u.count
联合体的用途
- 节省内存:多个属性逻辑上互斥时,只保存其中一种表示
- 数据拆解:底层网络协议、硬件寄存器访问中常见
IP 地址的两种观察方式
union IPAddress {
uint32_t address;
unsigned char bytes[4];
};
// 可以通过 address 处理整个 IP,也可以通过 bytes 访问单个字节
注意
union的所有成员起始地址相同- 读取非活跃成员通常是未定义行为
union的大小至少等于最大成员的大小,并且会受到内存对齐影响
1. 1. 3 枚举¶
枚举(enum)用于为一组相关的整数常量分配可读名字,例如星期、颜色、状态码等。
传统枚举
enum Color {
RED, // 默认值为 0
GREEN, // 默认值为 1
BLUE // 默认值为 2
};
Color myColor = RED;
传统枚举来自 C 语言,枚举成员名会直接进入当前作用域,因此容易污染命名空间。
传统枚举的问题
enum Color {
RED,
GREEN,
BLUE
};
enum TrafficLight {
RED, // 错误:RED 已经在同一作用域定义过
YELLOW,
GREEN
};
- 传统枚举通常可以隐式转换为
int - 传统枚举的底层类型一般由编译器决定
- 同一作用域内不能定义同名枚举成员
C++11 引入了强类型枚举 enum class,这是现代 C++ 更推荐的写法。
强类型枚举
enum class Status {
OK = 200,
NOT_FOUND = 404,
INTERNAL_ERROR = 500
};
Status s = Status::OK;
enum class 的特点
- 成员名被限定在枚举类内部,使用时必须写
Status::OK - 不会隐式转换为
int,需要static_cast<int>(s) - 可以显式指定底层整数类型
指定底层类型
enum class Device : unsigned char {
CPU,
GPU
}; // 通常只占 1 字节
1. 2 修饰符¶
1. 2. 1 const¶
const 的核心含义是“承诺不修改”。最基础的用法是定义常量,初始化后不能再次赋值。
const int MAX_USERS = 100;
当 const 和指针组合时,需要看 const 位于 * 的哪一侧。
指向常量的指针
int val1 = 10;
const int* p = &val1;
// *p = 20; // 错误:不能通过 p 修改所指向的内容
int val2 = 20;
p = &val2; // 正确:p 本身可以改指向
const 在 * 之前,表示不能通过指针修改指向的对象。
常量指针
int val1 = 10;
int* const p = &val1;
*p = 20; // 正确:可以修改指向的内容
int val2 = 20;
// p = &val2; // 错误:p 本身不能改指向
const 在 * 之后,表示指针变量本身保存的地址不能改变。
指向常量的常量指针
int val = 10;
const int* const p = &val;
// *p = 20; // 错误
// p = nullptr; // 错误
const 也常用于函数参数,尤其是大对象的引用传递。
常量引用参数
void printMessage(const string& msg)
{
// msg = "new"; // 错误
cout << msg << endl;
}
为什么用 const T&
- 避免值传递带来的拷贝开销
- 防止函数内部意外修改实参
- 既可以绑定左值,也可以绑定右值
const 还可以修饰成员函数,表示这个成员函数承诺不修改对象状态。
const 成员函数
class User {
private:
string name;
public:
string getName() const
{
// name = "new"; // 错误
return name;
}
};
注意
const 对象只能调用 const 成员函数。如果一个对象被声明为 const,编译器会拒绝调用任何可能修改对象状态的非 const 成员函数。
1. 2. 2 static¶
static 在不同位置含义不同,但都与“生命周期、链接属性、归属关系”有关。
静态局部变量
被 static 修饰的局部变量生命周期贯穿整个程序运行期间,但作用域仍然局限在函数内部。
- 第一次执行到声明处时初始化
- 程序结束时销毁
- C++11 之后,静态局部变量的初始化是线程安全的
- 初始化之后的并发读写仍然需要程序员自己保证同步
静态全局变量
被 static 修饰的全局变量只在当前源文件中可见,这会限制它的链接属性。
C++ 程序启动后会立刻执行 main 吗
不会。
在 Linux 等系统中,程序真正入口通常是标准库和运行时提供的启动代码,例如 _start。启动代码会初始化进程环境、调用 C++ 运行时库,并在进入 main 之前完成一部分全局或静态对象初始化。
在类中使用 static 时,静态成员属于类本身,而不是某个具体对象。
静态成员变量
class Player {
public:
static int total;
Player()
{
total++;
}
};
int Player::total = 0; // 类外定义和初始化
静态成员变量的特点
- 所有对象共享同一份静态成员变量
- 静态成员变量实际存储在全局数据区
- 它不计入单个对象的
sizeof - 通常需要在类外进行定义和初始化
静态成员函数
class MathUtils {
public:
static int add(int a, int b)
{
return a + b;
}
};
int sum = MathUtils::add(5, 3);
静态成员函数的限制
- 可以通过类名直接调用,无需创建对象
- 没有
this指针 - 只能直接访问静态成员变量或静态成员函数
- 不能直接访问非静态成员,因为它不知道应该访问哪个对象
2 右值与移动语义¶
2. 1 左值与右值¶
左值(lvalue)是指向某个可识别内存位置的表达式,通常可以取地址。
常见左值
- 变量名
- 函数名
- 返回引用的函数调用
- 下标表达式,如
arr[i]
右值(rvalue)通常是临时值,表达式结束后就会被销毁。
常见右值
- 字面量,如
42、true - 返回值的函数调用
- 算术表达式,如
a + b
左值和右值
int x = 10;
x是左值,有名字、有地址10是右值,是一个临时值
2. 2 引用类型¶
之前常见的引用是左值引用 T&,它只能绑定到左值。
左值引用
int a = 10;
int& refA = a; // 正确
// int& refB = 10; // 错误:普通左值引用不能绑定右值
C++11 新增了右值引用 T&&,专门用于绑定右值。
右值引用
int a = 10;
// int&& refA = a; // 错误:a 是左值
int&& refB = 10; // 正确:10 是右值
补充:const T&
const T& 可以同时绑定左值和右值,因此常用于只读参数传递。但它不能表达“资源可以被转移”,移动语义真正依赖的是右值引用 T&&。
2. 3 移动语义¶
移动语义(Move Semantics)是 C++11 引入的重要机制。它的目标是通过转移资源所有权,减少不必要的深拷贝。
如果没有移动语义,把临时对象赋给另一个对象时往往要执行深拷贝:
- 为新对象分配资源
- 将临时对象的数据逐个复制过去
- 销毁临时对象
对于 std::vector、字符串、大缓冲区等资源较重的对象,这种做法开销很大。
移动构造函数
class Buffer {
public:
int* data;
int size;
Buffer(int s): data(new int[s]), size(s) {}
~Buffer()
{
delete[] data;
}
Buffer(Buffer&& other): data(nullptr), size(0)
{
this->data = other.data;
this->size = other.size;
other.data = nullptr;
other.size = 0;
}
};
移动构造的签名
ClassName(ClassName&& other);
移动构造并不重新分配和复制资源,而是直接接管 other 持有的资源。
移动后要让原对象处于安全状态
移动构造需要把原对象的指针置空,或改成其他可安全析构的状态。否则原对象析构时仍会释放已经转移出去的资源,导致重复释放。
移动赋值运算符和移动构造类似,但需要先处理当前对象已经拥有的旧资源。
移动赋值的签名
ClassName& operator=(ClassName&& other);
移动赋值中还需要判断 other 和 *this 是否是同一个对象,避免把自己持有的资源提前释放掉。
对象名本身是左值,即使它的类型是右值引用,也不能自动触发移动。此时需要使用 std::move。
std::move 的本质
std::move 并不会真正移动任何数据,它只是一个强制类型转换,把左值转换成右值引用,从而告诉编译器:这个对象的资源可以被移动。
触发移动
Buffer a(100);
Buffer b(std::move(a)); // 调用移动构造
std::move(a) 之后,a 仍然是一个存在的对象,但它的资源可能已经被转走,因此只能进行析构、重新赋值等安全操作,不应继续依赖原来的内容。
3 异常处理¶
3. 1 背景¶
早期程序常用返回值或全局错误码处理错误,例如返回 -1 或 NULL。这种方式在复杂工程中容易让错误处理和业务逻辑混在一起。
返回值层层传递错误
int D()
{
if (NETWORK_ERROR) return -1;
return 0;
}
int C()
{
int ret = D();
if (ret == -1) return -1;
// 正常逻辑...
return 0;
}
// B() 和 A() 也要做类似检查
返回值处理错误的缺点
- 每一层函数都要检查和继续返回错误码
- 真正的业务逻辑容易被大量错误判断淹没
- 如果忘记检查返回值,程序可能在错误状态下继续运行
异常处理机制允许错误从深层函数直接跳转到能处理它的上层代码。
3. 2 try-catch-throw¶
异常处理主要依赖三个关键字:
try:包围可能抛出异常的代码catch:匹配并捕获异常,然后进行处理throw:在检测到无法处理的问题时抛出异常
抛出异常
#include <stdexcept>
double divide(double a, double b)
{
if (b == 0) {
throw invalid_argument("Division by zero!");
}
return a / b;
}
捕获异常
double x = 10;
double y = 0;
try {
double result = divide(x, y);
cout << "Result: " << result << endl;
} catch (const invalid_argument& e) {
cerr << e.what() << endl;
} catch (...) {
cerr << "Unknown exception" << endl;
}
捕获顺序
- 先写具体类型的
catch - 再写更宽泛的
catch catch (...)可以捕获所有异常,通常放在最后
3. 3 关键特性¶
异常被抛出后,程序会沿函数调用栈向上寻找匹配的 catch 块。这个过程称为栈展开(stack unwinding)。
栈展开时会发生什么
- 抛出点之后,当前路径上需要销毁的局部对象会自动调用析构函数
- 如果找到匹配的
catch,程序跳转到对应处理逻辑 - 如果一直找不到匹配的
catch,程序会调用std::terminate()终止
<stdexcept> 定义了一系列常用标准异常类,它们都继承自 std::exception。
常见标准异常
std::invalid_argumentstd::out_of_rangestd::runtime_errorstd::logic_error
虽然 C++ 理论上允许 throw 任意类型对象,包括基本类型,但实际开发中通常建议抛出标准异常类型或其派生类。
3. 4 异常规格与 noexcept¶
早期 C++ 可以用动态异常规格指定函数可能抛出的异常类型。
已弃用的写法
void f() throw(runtime_error)
{
// ...
}
这种写法在 C++11 中已经被弃用,不应在新代码中使用。
现代 C++ 中,如果一个函数承诺不抛出异常,应使用 noexcept。
noexcept
void g() noexcept
{
// ...
}
3. 5 讨论¶
异常机制被 C++、Java、C#、Python 等语言广泛采用,它能把错误处理从普通返回路径中分离出来。
不过,Go 和 Rust 等现代语言更倾向于显式错误处理。它们认为错误本身就是程序控制流的一部分,开发者应当在代码中直接面对并处理错误。
两种思路的取舍
- 异常处理能减少错误码层层传递,让正常逻辑更清晰
- 显式错误处理的跳转路径更直观,运行时开销通常也更低
- 在 C++ 中,应遵守项目风格,不要混乱使用多套错误处理策略