C++异常详解:从 throw、try/catch 到栈展开、RAII 与 noexcept

C++异常详解:从 throw、try/catch 到栈展开、RAII 与 noexcept

头像

🔥 星恒随风: 个人主页
❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》 《Linux的学习之旅》
✨ 数据即知识,压缩即智能

前言:程序出错以后,我们到底应该怎么办?

1. 错误处理是程序设计绕不开的问题

程序在运行过程中可能遇到各种问题:

除数为 0
文件打开失败
内存申请失败
数组访问越界
数据库连接失败
网络请求失败
用户参数非法

最简单的处理方式可能是:

if (error)
{
    return -1;
}

然后调用者继续判断:

int ret = Func();

if (ret == -1)
{
    // 处理错误
}

这种方式就是:

错误码
Error Code

C 语言中大量接口都采用这种设计。

但是随着程序调用层次越来越深:

main
 ↓
HttpServer
 ↓
CacheManager
 ↓
SqlManager
 ↓
Database

如果最底层发生错误,每一层都要:

检查返回值
↓
判断错误
↓
继续返回错误码

大量正常业务代码会被错误处理代码打断。

C++ 因此提供了另一套错误传播机制:

异常
Exception

一、异常到底解决了什么问题?

1. 将“发现问题”和“处理问题”分开

假设:

数据库模块

发现:

SQL 执行失败

数据库模块通常最清楚:

发生了什么错误

却不一定知道:

应该怎么处理错误

例如是否:

重新请求
记录日志
通知用户
断开连接
终止服务

可能由更高层决定。

所以异常机制可以形成:

底层
↓
负责发现问题
↓
throw

中间层
↓
可以不处理

上层
↓
catch
↓
统一处理

这就是异常机制非常重要的设计思想:

错误检测与错误处理解耦。


二、C++异常的三个核心关键字

1. throw:抛出异常

例如除法:

double Divide(int a, int b)
{
    if (b == 0)
    {
        throw "division by zero";
    }

    return static_cast<double>(a) / b;
}

当:

b == 0

时执行:

throw "division by zero";

意味着:

产生异常
↓
当前正常执行流程停止
↓
开始寻找能够处理该异常的 catch

2. try:监控可能发生异常的代码

try
{
    cout << Divide(10, 0)
         << endl;
}

try 自己并不处理异常。

它只是告诉程序:

这一块代码可能抛出异常

3. catch:捕获异常

try
{
    cout << Divide(10, 0)
         << endl;
}
catch (const char* msg)
{
    cout << msg << endl;
}

完整结构:

try
{
    // 可能抛异常
}
catch (Type1 e)
{
    // Type1 异常
}
catch (Type2 e)
{
    // Type2 异常
}

可以把它理解成:

try
 ↓
执行代码
 ↓
发生异常?
 ├── 否 → 正常继续
 │
 └── 是
      ↓
   查找匹配 catch
      ↓
    执行处理

三、throw 执行以后发生了什么?

1. throw 后面的正常代码不会继续执行

例如:

void Func()
{
    cout << "before" << endl;

    throw 10;

    cout << "after" << endl;
}

执行:

Func();

如果异常被外层捕获:

before

会输出。

但是:

after

不会执行。

因为:

throw 10;

已经改变了正常控制流。


2. throw 的不是“错误编号”,而是一个对象

C++ 可以:

throw 10;

也可以:

throw string("error");

甚至:

throw MyException(...);

所以异常对象能够携带:

错误类型
错误编号
错误描述
模块信息
SQL语句
HTTP请求类型
文件路径

等更多信息。

这比:

return -1
return -2
return -3

通常具有更强的表达能力。


四、异常对象的生命周期

1. 为什么不能简单理解成“throw 局部变量的引用”?

例如:

void Func()
{
    string msg = "error";

    throw msg;
}

函数退出以后:

msg

这个局部对象会销毁。

但异常还必须继续向上传递。

所以运行时会建立一个:

异常对象
Exception Object

用于异常传播。

可以从语义上理解为:

局部对象 msg
      ↓
构造异常对象
      ↓
原来的局部变量可以销毁
      ↓
异常对象继续沿调用链传播

因此正常写法是:

按值抛出

例如:

throw MyException(...);

五、catch 到底怎么匹配异常?

1. 通常按照异常类型进行匹配

例如:

try
{
    throw 10;
}
catch (int e)
{
    cout << "int exception"
         << endl;
}
catch (double e)
{
    cout << "double exception"
         << endl;
}

因为:

throw 10;

抛出的类型是:

int

所以匹配:

catch (int e)

2. 多个 catch 时按顺序检查

例如:

try
{
    ...
}
catch (const SqlException& e)
{
}
catch (const Exception& e)
{
}

如果:

SqlException

既能够匹配:

SqlException&

又因为继承关系能够匹配:

Exception&

那么前面的:

catch (const SqlException& e)

会先被选择。

所以:

派生类异常通常放前面,基类异常放后面。


六、为什么异常体系特别适合使用继承?

1. 假设大型项目有多个模块

例如:

HTTP 模块
Cache 模块
SQL 模块

它们都可能产生异常。

可以建立:

              Exception
              /    |    \
             /     |     \
            ↓      ↓      ↓
   HttpException CacheException SqlException

于是不同模块:

能够携带不同的信息

而最上层又可以:

统一捕获 Exception

七、自定义异常类的基本设计

更贴近标准库的方式是继承:

std::exception

例如:

#include <exception>
#include <string>

class AppException
    : public std::exception
{
public:
    AppException(
        std::string message,
        int id)
        : _message(
            std::move(message))
        , _id(id)
    {}

    const char* what()
        const noexcept override
    {
        return _message.c_str();
    }

    int id() const noexcept
    {
        return _id;
    }

private:
    std::string _message;
    int _id;
};

其中:

what()

负责返回:

异常描述信息

八、派生不同模块的异常

1. SQL异常

class SqlException
    : public AppException
{
public:
    SqlException(
        const std::string& message,
        int id,
        const std::string& sql)
        : AppException(
            message,
            id)
        , _sql(sql)
        , _detail(
            "SqlException: "
            + message
            + " -> "
            + sql)
    {}

    const char* what()
        const noexcept override
    {
        return _detail.c_str();
    }

private:
    std::string _sql;
    std::string _detail;
};

2. HTTP异常

class HttpException
    : public AppException
{
public:
    HttpException(
        const std::string& message,
        int id,
        const std::string& method)
        : AppException(
            message,
            id)
        , _method(method)
        , _detail(
            "HttpException["
            + method
            + "]: "
            + message)
    {}

    const char* what()
        const noexcept override
    {
        return _detail.c_str();
    }

private:
    std::string _method;
    std::string _detail;
};

九、为什么推荐 catch(const Exception& e)?

这是异常处理中一个非常重要的习惯。

推荐:

catch (const AppException& e)
{
    cout << e.what() << endl;
}

而不是:

catch (AppException e)
{
}

原因主要有两个。

1. 避免额外复制

引用:

不需要再次复制异常对象

2. 避免对象切片

假设抛出:

throw SqlException(...);

如果:

catch (AppException e)

按值接收基类对象:

SqlException
      ↓
复制成 AppException
      ↓
派生类部分丢失

这就是:

对象切片
Object Slicing

而:

catch (const AppException& e)

保留实际动态类型。

于是:

e.what()

仍然可以通过虚函数调用:

SqlException::what()

十、完整的异常继承体系案例

#include <iostream>
#include <exception>
#include <string>
using namespace std;

class AppException
    : public exception
{
public:
    AppException(
        string message,
        int id)
        : _message(
            std::move(message))
        , _id(id)
    {}

    const char* what()
        const noexcept override
    {
        return _message.c_str();
    }

    int id() const noexcept
    {
        return _id;
    }

protected:
    string _message;
    int _id;
};

class SqlException
    : public AppException
{
public:
    SqlException(
        const string& message,
        int id,
        const string& sql)
        : AppException(message, id)
        , _detail(
            "SQL: "
            + message
            + " -> "
            + sql)
    {}

    const char* what()
        const noexcept override
    {
        return _detail.c_str();
    }

private:
    string _detail;
};

class HttpException
    : public AppException
{
public:
    HttpException(
        const string& message,
        int id,
        const string& method)
        : AppException(message, id)
        , _detail(
            "HTTP["
            + method
            + "]: "
            + message)
    {}

    const char* what()
        const noexcept override
    {
        return _detail.c_str();
    }

private:
    string _detail;
};

业务代码:

void SQLManager()
{
    throw SqlException(
        "permission denied",
        1001,
        "select * from user"
    );
}

void CacheManager()
{
    SQLManager();
}

void HttpServer()
{
    CacheManager();
}

最外层:

int main()
{
    try
    {
        HttpServer();
    }
    catch (
        const AppException& e)
    {
        cout
            << "id: "
            << e.id()
            << endl;

        cout
            << e.what()
            << endl;
    }

    return 0;
}

虽然实际抛出:

SqlException

但是:

catch (
    const AppException& e)

可以统一捕获整个异常继承体系。


十一、什么是栈展开?

这是 C++ 异常机制最核心的原理之一。

假设调用关系:

main()
 ↓
Func1()
 ↓
Func2()
 ↓
Func3()

然后:

Func3()
{
    throw Exception();
}

异常开始沿调用链向上寻找处理器:

Func3
 ↓
没有匹配 catch
 ↓
退出 Func3

Func2
 ↓
没有匹配 catch
 ↓
退出 Func2

Func1
 ↓
找到匹配 catch
 ↓
处理异常

这个过程称为:

栈展开
Stack Unwinding

十二、栈展开不仅是在“找 catch”

更加重要的是:

离开函数作用域时,已经构造完成的自动对象会执行析构函数。

例如:

class Test
{
public:
    Test(const char* name)
        : _name(name)
    {
        cout
            << "construct "
            << _name
            << endl;
    }

    ~Test()
    {
        cout
            << "destroy "
            << _name
            << endl;
    }

private:
    string _name;
};

void Func()
{
    Test t1("t1");
    Test t2("t2");

    throw runtime_error(
        "error");
}

即使:

Func 没有正常执行到函数末尾

栈展开时:

t2 析构
↓
t1 析构

仍然会发生。

这正是:

RAII

能够与异常完美配合的基础。


十三、如果一直找不到 catch 呢?

假设:

int main()
{
    throw runtime_error(
        "error");
}

没有任何地方捕获。

最终异常无法被处理:

没有匹配 handler
↓
std::terminate()
↓
程序终止

因此对于应用程序顶层,有时会使用:

try
{
    RunApplication();
}
catch (
    const std::exception& e)
{
    // 日志
}
catch (...)
{
    // 最后的未知异常边界
}

但是:

catch(...) 应作为必要时的最后保护层,而不是把所有函数都套上一层 catch(...)


十四、catch(…) 是什么?

catch (...)
{
}

表示:

可以匹配任意异常类型

例如:

try
{
    throw 10;
}
catch (...)
{
    cout
        << "unknown exception"
        << endl;
}

它的缺点也很明显:

不知道异常的具体类型
不知道异常对象本身的信息

所以通常把它放在:

所有具体 catch 后面

例如:

catch (
    const AppException& e)
{
}
catch (
    const std::exception& e)
{
}
catch (...)
{
}

十五、异常重新抛出

有时候某一层:

可以处理异常的一部分

但不能完全解决问题。

这时可以:

throw;

重新抛出当前异常。


1. 典型场景:重试

例如网络请求失败:

void SendMessage()
{
    try
    {
        Send();
    }
    catch (
        const HttpException& e)
    {
        if (e.id() == 1001)
        {
            // 可以重试
        }
        else
        {
            throw;
        }
    }
}

这里:

throw;

表示:

把当前正在处理的异常继续向外传播

十六、throw; 和 throw e; 有什么区别?

这是非常值得补充的一点。

假设:

catch (
    const AppException& e)
{
    throw;
}

throw; 会:

重新抛出原来的异常对象

保留:

真实异常类型

1. 不推荐这样写

catch (
    const AppException& e)
{
    throw e;
}

这里重新执行:

throw 一个 AppException 表达式

可能导致:

派生异常信息丢失
对象切片

因此重新抛出当前异常时:

throw;

才是最自然的写法。


十七、异常最大的风险之一:资源泄漏

看下面代码:

void Func()
{
    int* ptr =
        new int[100];

    DoSomething();

    delete[] ptr;
}

如果:

DoSomething();

抛出异常:

new[]
 ↓
DoSomething
 ↓
throw
 ↓
直接离开 Func

于是:

delete[] ptr;

根本没有执行。

最终:

内存泄漏

十八、传统解决方案:catch 后清理再重新抛出

可以写:

void Func()
{
    int* ptr =
        new int[100];

    try
    {
        DoSomething();
    }
    catch (...)
    {
        delete[] ptr;

        throw;
    }

    delete[] ptr;
}

确实能够解决。

但问题是:

代码复杂
容易漏掉
资源越多越难维护

因此现代 C++ 有更好的方案:

RAII

十九、RAII 为什么能解决异常安全?

RAII:

Resource Acquisition
Is Initialization

核心思想:

资源生命周期
绑定到对象生命周期

例如不要:

int* ptr =
    new int[100];

而使用:

vector<int> data(100);

或者:

auto ptr =
    make_unique<int[]>(100);

即使:

DoSomething();

抛异常:

异常产生
 ↓
栈展开
 ↓
vector / unique_ptr 析构
 ↓
自动释放资源

我们完全不需要手动:

try
catch
delete
throw

这就是现代 C++ 中异常安全的核心原则之一:

资源管理优先交给 RAII 对象,而不是手工 new/delete + try/catch。


二十、RAII 不只管理内存

除了:

动态内存

RAII 还可以管理:

文件
锁
socket
数据库连接
线程资源
系统句柄

例如:

lock_guard<mutex> lock(mtx);

如果后面抛异常:

栈展开
↓
lock 析构
↓
自动解锁

否则手工:

mtx.lock();

Func(); // throw

mtx.unlock();

就可能导致:

锁永远没有释放

二十一、异常安全到底是什么意思?

异常安全并不是:

程序不能抛异常

而是:

即使操作过程中发生异常,程序仍然保持我们承诺的状态。

通常可以分为几个等级。


1. 无异常保证

No-throw Guarantee

操作保证不会抛出异常。

例如很多:

析构函数
swap
移动操作

会尽量提供这种保证。


2. 强异常安全保证

Strong Guarantee

如果操作失败:

对象状态恢复到操作之前

可以理解成:

成功
↓
提交新状态

失败
↓
什么都没发生

类似事务:

commit / rollback

3. 基本异常安全保证

Basic Guarantee

异常发生以后:

不会资源泄漏

对象仍然有效

类不变量仍然成立

但对象的具体值:

可能发生改变

4. 无异常安全保证

如果异常发生:

资源可能泄漏
对象状态可能损坏
程序状态不可预测

显然是我们应该避免的。


二十二、Copy-and-Swap 为什么异常安全?

假设自己实现:

class String
{
public:
    String&
    operator=(
        const String& other)
    {
        String tmp(other);

        swap(tmp);

        return *this;
    }
};

这里先:

构造 tmp

如果深拷贝失败:

异常发生
↓
原来的 this 完全没有被修改

只有复制成功以后:

swap

才提交新状态。

因此这种设计天然具有很好的:

强异常安全

思想。


二十三、为什么析构函数不要让异常逃出去?

这是异常机制中另一个重要规则。

假设:

异常 A 正在栈展开

此时某个对象开始析构。

如果析构函数又抛出:

异常 B

系统同时面对:

两个无法继续正常传播的异常

可能直接:

std::terminate()

因此通常:

class Resource
{
public:
    ~Resource() noexcept
    {
        try
        {
            Close();
        }
        catch (...)
        {
            // 内部记录/处理
            // 不让异常继续逃逸
        }
    }
};

核心原则:

析构函数应尽量不抛出异常。


二十四、什么是 noexcept?

C++11 引入:

noexcept

例如:

void Func() noexcept
{
}

表示程序员声明:

Func 不允许异常逃出

注意:

noexcept

并不是:

编译器保证函数内部绝对没有 throw

例如:

void Func() noexcept
{
    throw runtime_error(
        "error");
}

有些编译器仍然可以编译。

但是异常真的逃出:

noexcept 函数

时会调用:

std::terminate()

二十五、noexcept 不等于 try/catch

void Func() noexcept

并不是:

自动帮你捕获异常

而是做出一个契约:

异常不能从这个函数传播出去

如果内部调用可能抛异常的函数:

void Func() noexcept
{
    RiskyFunction();
}

也完全可以编译。

如果:

RiskyFunction 真抛异常

并且没有在 Func() 内处理:

terminate

二十六、noexcept(expression)

noexcept 还可以作为一个运算符:

noexcept(expression)

例如:

cout
    << noexcept(1 + 2)
    << endl;

如果表达式在语言层面被认为不会抛出:

true

否则:

false

注意:

它不会真正执行这个表达式。

例如:

int i = 0;

bool ret =
    noexcept(++i);

之后:

i

仍然是:

0

二十七、为什么移动构造经常写 noexcept?

例如:

String(
    String&& other
) noexcept;

这是一个非常重要的现代 C++ 习惯。

假设:

vector<T>

扩容:

旧空间
↓
把已有元素搬到新空间

如果移动构造可能抛异常:

移动到一半失败

而已经被移动的旧对象状态又发生变化:

vector 很难维持强异常安全保证

因此标准容器在一些场景中会更愿意:

使用不会抛异常的移动构造

所以资源管理类的移动操作如果确实不会抛异常,通常应该:

T(T&&) noexcept;

二十八、C++98 的动态异常规范

以前可能看到:

void Func()
    throw(int, double);

表示:

声称可能抛出 int 和 double

还有:

void Func()
    throw();

表示:

不抛异常

这种动态异常规范后来逐渐被废弃。

现代 C++ 更主要使用:

noexcept

表示非抛出保证。


二十九、标准库异常体系

C++ 标准库定义了一套异常类型。

共同基类:

std::exception

基本结构可以简化为:

                     std::exception
                           |
            ┌──────────────┴──────────────┐
            ↓                             ↓
     std::logic_error             std::runtime_error
            |                             |
     ┌──────┼──────┐               ┌─────┼─────┐
     ↓      ↓      ↓               ↓     ↓     ↓
invalid  length  out_of_range   range  overflow underflow
argument error                 error   error     error

除此之外还有:

std::bad_alloc
std::bad_cast
std::bad_typeid
std::bad_function_call
std::system_error
...

三十、logic_error 与 runtime_error 怎么理解?

1. logic_error

更接近:

程序逻辑、参数、前置条件存在问题

例如:

invalid_argument
length_error
out_of_range

2. runtime_error

更偏向:

运行过程中才可能发现的问题

例如:

range_error
overflow_error
underflow_error

实际项目中也经常直接:

throw runtime_error(
    "database connection failed");

三十一、std::out_of_range 示例

例如:

vector<int> v
{
    1, 2, 3
};

cout << v.at(10);

at() 会进行范围检查。

可以:

try
{
    cout << v.at(10);
}
catch (
    const out_of_range& e)
{
    cout
        << e.what()
        << endl;
}

而:

v[10];

并不会提供同样的越界异常检查保证。


三十二、std::bad_alloc

例如:

try
{
    int* ptr =
        new int[
            static_cast<size_t>(
                -1)
        ];
}
catch (
    const bad_alloc& e)
{
    cout
        << e.what()
        << endl;
}

普通:

new

分配失败时通常会抛出:

std::bad_alloc

三十三、为什么顶层经常捕获 std::exception?

因为大量标准异常:

最终都继承 std::exception

因此:

try
{
    Run();
}
catch (
    const std::exception& e)
{
    cerr
        << e.what()
        << endl;
}

可以覆盖很多标准异常。

对于大型应用,可以进一步采用:

try
{
    Run();
}
catch (
    const AppException& e)
{
    // 项目自己的异常
}
catch (
    const std::exception& e)
{
    // 标准异常
}
catch (...)
{
    // 未知异常
}

三十四、为什么不推荐 throw int / string / const char*?

语法层面当然可以:

throw 10;

throw "error";

throw string("error");

教学阶段用来理解:

异常按照类型匹配

非常方便。

但是工程中最好定义:

有语义的异常类型

例如:

throw invalid_argument(
    "age cannot be negative");

或者:

throw SqlException(
    ...);

这样:

异常类型本身

就在表达:

发生了什么错误

三十五、异常不应该用来代替正常控制流

不要写:

try
{
    throw i;
}
catch (int index)
{
    return index;
}

来表达:

正常查找成功

异常适合:

异常情况 / 错误情况

而普通逻辑应该继续:

if
return
循环
状态值

否则会:

降低可读性
让错误处理和正常逻辑混乱
产生不必要的异常开销

三十六、错误码和异常谁更好?

没有绝对答案。

1. 错误码适合

例如:

失败是高频、正常且可预期的结果

例如:

查找不存在
尝试解析失败
非异常状态分支

2. 异常适合

例如函数:

已经无法完成自己的职责

需要跨越多层调用链,把错误交给真正有能力处理的位置。

尤其是:

构造函数失败

无法简单:

return -1;

异常往往更加自然。


三十七、一个推荐的异常使用策略

可以总结成:

底层发现错误
        ↓
抛出具有语义的异常对象
        ↓
中间层如果无法处理
        ↓
不要乱 catch
        ↓
让异常继续传播
        ↓
能恢复的边界处理
        ↓
最顶层记录未处理异常

而不是:

每一个函数
↓
try
↓
catch(...)
↓
什么都不做

后者反而会让真正的错误消失。


三十八、异常处理的几个工程习惯

1. 按值抛出

throw MyException(...);

2. 按 const 引用捕获

catch (
    const MyException& e)

3. 重新抛出使用 throw;

catch (...)
{
    // 局部处理

    throw;
}

4. 使用 RAII 管理资源

优先:

vector<int>
unique_ptr<T>
lock_guard<mutex>
fstream

而不是:

手工申请
+
try/catch
+
手工释放

5. 不要随意让析构函数抛异常

尤其不要:

在栈展开过程中再让异常逃出析构函数

6. 对真正不会抛出的函数使用 noexcept

例如:

MyClass(
    MyClass&&
) noexcept;

但不要:

不分析代码就给所有函数加 noexcept

因为一旦违反:

直接 terminate

三十九、异常执行流程总图

整个异常机制可以压缩成:

正常执行
   |
   v
发生错误
   |
   v
 throw Exception
   |
   v
构造异常对象
   |
   v
检查当前 try
   |
   ├── 找到匹配 catch
   |       |
   |       v
   |    执行处理
   |
   └── 没找到
           |
           v
        栈展开
           |
      析构局部对象
           |
           v
      回到上一层函数
           |
           v
      继续查找 catch
           |
       ┌───┴────┐
       ↓        ↓
     找到      没找到
       |        |
       ↓        ↓
     处理   std::terminate

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值