Skip to content

OOP 朋辈辅学 Lec4 移动语义与异常处理

1 C++ 的类型系统

1. 1 类型

1. 1. 1 基础类型和复合类型

C++ 的基础类型基本继承自 C 语言,常见类型包括:

  • 整型shortintlonglong long,可以配合 signed / unsigned
  • 浮点型floatdouble
  • 字符型char
  • 布尔型bool
  • 空类型void,常用于函数返回值或指针类型

在基础类型之上,C++ 还提供了复合类型:

  • 引用:对象的别名,如 int&
  • 指针:保存内存地址,如 int*
  • 数组:相同类型元素组成的序列,如 int arr[10]
  • 函数:拥有特定参数列表和返回类型的逻辑单元

用户自定义类型包括 structclassunionenum 等,其中 structclass 是面向对象建模的核心。

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)通常是临时值,表达式结束后就会被销毁。

常见右值

  • 字面量,如 42true
  • 返回值的函数调用
  • 算术表达式,如 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 引入的重要机制。它的目标是通过转移资源所有权,减少不必要的深拷贝。

如果没有移动语义,把临时对象赋给另一个对象时往往要执行深拷贝:

  1. 为新对象分配资源
  2. 将临时对象的数据逐个复制过去
  3. 销毁临时对象

对于 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 背景

早期程序常用返回值或全局错误码处理错误,例如返回 -1NULL。这种方式在复杂工程中容易让错误处理和业务逻辑混在一起。

返回值层层传递错误
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_argument
  • std::out_of_range
  • std::runtime_error
  • std::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++ 中,应遵守项目风格,不要混乱使用多套错误处理策略