C++异常

文章目录

C++ 异常

我们在上一章了解map是说到过map::at()函数在访问不存在的key时会抛异常,官方也推荐我们使用map::at()函数,那么什么是异常?异常又有什么用?那么我们就一起来了解一下C++的异常吧。

C语言传统处理错误的方式

为什么C++要推出异常这个处理错误的方式,我们首先要了解一下C语言处理错误的方式是怎么样的:

传统的错误处理机制:

  1. 终止程序,如assert,缺陷: 用户难以接受。如发生内存错误,除0错误时就会终止程序。
  2. 返回错误码,缺陷: 需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误。

实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

// 传统错误码方式:层层返回,代码臃肿
int ConnectDB() { return -1; /* 模拟连接失败 */ }
int InitServer() { 
    int ret = ConnectDB(); 
    if (ret < 0) return ret; // 被迫传递错误
    return 0; 
}

void TestErrorCode() {
    if (InitServer() < 0) {
        cout << "Server init failed via error code!" << endl;
    }
}

// 异常方式:直接穿透调用链,代码清爽
void ConnectDB_Exc() { throw runtime_error("DB connection failed"); }
void InitServer_Exc() { ConnectDB_Exc(); /* 无需检查返回值 */ }

void TestException() {
    try {
        InitServer_Exc();
    } catch (const exception& e) {
        cout << "Server init failed via exception: " << e.what() << endl;
    }
}

虽然C语言处理错误的方式比较臃肿,但是C语言真正需要处理的错误普遍还是比较少的。因为需要返回错误的地方通常是:开辟内存空间、打开文件等一些往往是手动操作的。那么为什么C++还要多此一举呢?毕竟在设计中加法未必是能到达很好的设计。

C++异常概念

俗话说:知己知彼才能百战百胜。我们要知道为什么创造C++的大佬给C++设计了异常就要清楚异常到底是为做了哪些事而设计出来的。

异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。

C++ 异常处理由三个关键字组成:

  • throw:当问题发生时,抛出一个异常对象。
  • try:包裹可能抛出异常的代码(保护代码),其后必须跟随一个或多个 catch 块。
  • catch:捕获并处理特定类型的异常。

如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛

出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:

cpp 复制代码
try
{
	// 保护的标识代码
}catch( ExceptionName e1 )
{
	// catch 块
}catch( ExceptionName e2 )
{
	// catch 块
}catch( ExceptionName eN )
{
	// catch 块
}

异常的使用

异常的抛出和捕获

异常的抛出和匹配原则

  1. 匹配原则 :异常对象的类型决定了激活哪个 catch。匹配是严格类型匹配的,但允许派生类对象被基类引用/指针捕获(多态)。
  2. 栈展开(Stack Unwinding) :抛出异常后,程序会沿着函数调用链向上查找匹配的 catch。在查找过程中,会自动调用当前栈帧中已构造的局部对象的析构函数来释放资源。
  3. 兜底捕获 :catch(...) 可以捕获任意类型的未知异常。

在函数调用链中异常栈展开匹配原则

  1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则
    调到catch的地方进行处理。
  2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
  3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开 。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
  4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。

注意:捕获基类和派生类时,基类要在前,派生类要在后。如果是先派生类那么就永远到不了基类。

cpp 复制代码
catch (const DerivedException& e) { ... }
catch (const BaseException& e) { ... }

栈展开示例:

cpp 复制代码
void func1() {
    throw std::runtime_error("func1 error");
}

void func2() {
    func1();
}

void func3() {
    func2();
}

int main() {
    try {
        func3();
    } catch (const std::exception& e) {
        cout << "caught: " << e.what() << endl;
    }
    return 0;
}

注意:推荐抛异常时使用catch(const ExceptionName& name) 这种写法,第一这种写法使用了引用不会触发额外的拷贝构造,第二参考下面代码对基类和派生类的抛异常处理。

cpp 复制代码
class BaseException {
public:
    virtual ~BaseException() = default;
    virtual string what() const { return "BaseException"; }
};

class DerivedException : public BaseException {
public:
    string what() const override { return "DerivedException: Specific Error"; }
};

void ThrowDerived() { throw DerivedException(); }

void TestStackUnwindingAndSlicing() {
    try {
        ThrowDerived();
    } 
    // 错误示范:按值捕获,发生对象切片,多态失效,只输出 "BaseException"
    // catch (BaseException e) { cout << e.what() << endl; } 
    
    // 正确示范:按常量引用捕获,保留多态特性,输出 "DerivedException: Specific Error"
    catch (const BaseException& e) { 
        cout << e.what() << endl; 
    }
    catch (...) {
        cout << "Unknown Exception Caught!" << endl;
    }
}

异常的重新抛出

有时候单个 catch 不能完全处理异常,只做部分清理,然后希望交给外层继续处理。

用法:

cpp 复制代码
catch (...) {
    // 清理
    throw; // 重新抛出当前异常
}

注意:

  • throw; 不带表达式,表示重新抛出当前异常。
  • throw e; 会重新拷贝一份异常对象,可能切片。
  • throw; 只能在 catch 中,或从 catch 调用的函数中,表示重新抛出当前异常。
cpp 复制代码
double Division(int a, int b) {
    if (b == 0) {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}

void Func() {
    int* array = new int[10];
    try {
        int len, time;
        std::cin >> len >> time;
        std::cout << Division(len, time) << std::endl;
    } catch (...) {
    	// 如果不清理就会导致内存泄漏
        std::cout << "delete [] " << array << std::endl;
        delete[] array;
        throw; // 重新抛出,交给外层处理
    }

    std::cout << "delete [] " << array << std::endl;
    delete[] array;
}

int main() {
    try {
        Func();
    } catch (const char* errmsg) {
        cout << errmsg << endl;
    } catch (...) {
        cout << "unknown exception" << endl;
    }
    return 0;
}

异常安全

异常安全的核心问题:抛出异常后,资源是否正确释放?对象是否处于有效状态?

  • 构造函数完成对象构造和初始化,最好不要在构造函数中抛异常,否则可能导致对象不完整或没有完全初始化。
  • 析构函数主要完成资源清理,最好不要在析构函数内抛异常,否则可能导致资源泄漏。
  • C++ 没有垃圾回收,异常容易导致内存泄漏、死锁。
  • 常用 RAII 解决资源管理问题。

那么为什么析构函数绝对不能抛出异常?

因为当程序正在处理一个异常(进行栈展开)时,会自动调用局部对象的析构函数。如果此时析构函数又抛出了一个新的异常,C++运行时机制无法同时处理两个异常,会直接调用 std::terminate() 导致程序强制崩溃。

如果构造函数中可能发生异常,解决方法很简单使用一个智能指针就可以解决,因为智能指针是RAII的,智能指针我们后面会重点了解。

异常规范

C++98 异常规范 :在函数声明后使用 throw(type1, type2) 声明可能抛出的异常类型;throw() 表示不抛异常。但这在C++11中被废弃 ,因为编译器难以在编译期严格检查,且运行时检查开销大。

C++11 noexcept :用于明确承诺函数不会抛出异常 。如果 noexcept 函数内部抛出了异常,程序会直接调用 std::terminate 终止。

cpp 复制代码
// 旧式异常规范:
void fun() throw(A, B, C, D); // 可能抛 A/B/C/D
void* operator new(std::size_t size) throw(std::bad_alloc);
void* operator delete(std::size_t size, void* ptr) throw(); // 不抛

// C++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;
cpp 复制代码
void noThrowFunc() noexcept {
    // 不能抛异常;如果抛,会 std::terminate
}

void mayThrowFunc() {
    throw std::runtime_error("error");
}

int main() {
    try {
        noThrowFunc();
        mayThrowFunc();
    } catch (const std::exception& e) {
        cout << e.what() << endl;
    }
    return 0;

自定义异常体系

实际项目中,如果每个人随意抛异常,外层调用者无法统一处理。所以公司通常会定义一套继承体系:

  • 定义一个基类 Exception。
  • 所有异常继承自它。
  • 外层只捕获基类引用,利用多态调用 what()。
  • 派生类可以携带更多信息,比如 SQL 语句、HTTP 类型等。
cpp 复制代码
// 1. 自定义异常基类
class BaseException : public exception {
public:
    BaseException(const string& errmsg, int id) 
        : _errmsg(errmsg), _id(id) {}

    // 使用 virtual 保证多态,noexcept 保证不抛异常
    virtual const char* what() const noexcept override {
        return _errmsg.c_str();
    }
    
    int GetID() const { return _id; }

protected:
    string _errmsg;
    int _id;
};

// 2. 派生类:数据库异常
class SqlException : public BaseException {
public:
    SqlException(const string& errmsg, int id, const string& sql) 
        : BaseException(errmsg, id), _sql(sql) {}

    virtual const char* what() const noexcept override {
        // 这里为了演示简单拼接,实际应避免在 what 中频繁分配内存
        static string fullMsg;
        fullMsg = "SqlException: " + _errmsg + " | SQL: " + _sql;
        return fullMsg.c_str();
    }

private:
    string _sql;
};

void MockDbQuery() {
    // 模拟数据库查询失败
    throw SqlException("权限不足", 1001, "SELECT * FROM users");
}

void TestCustomException() {
    try {
        MockDbQuery();
    } 
    // 重点:必须使用引用捕获,防止对象切片,保证多态生效
    catch (const BaseException& e) {
        cout << "捕获业务异常: " << e.what() << " (ErrorID: " << e.GetID() << ")" << endl;
    } 
    catch (const exception& e) {
        cout << "捕获标准库异常: " << e.what() << endl;
    } 
    catch (...) {
        cout << "捕获未知异常" << endl;
    }
}

C++标准库的异常体系

C++ 标准库定义了一系列异常,以父子类层次组织。

核心基类:

cpp 复制代码
exception

常见标准异常:

异常 说明 异常 说明
std::exception 所有标准 C++ 异常的父类 std::bad_alloc new 失败抛出
std::bad_cast dynamic_cast 引用失败抛出 std::bad_exception 处理无法预期异常时有用
std::bad_typeid typeid 抛出 std::logic_error 理论上可通过读代码检测到的异常
std::domain_error 无效数学域 std::invalid_argument 无效参数
std::length_error 创建太长的 std::string 等 std::out_of_range vector::at、bitset::operator[] 等越界
std::runtime_error 理论上不能通过读代码检测到的异常 std::overflow_error 数学上溢
std::range_error 存储超出范围 std::underflow_error 数学下溢

头文件:

  • <exception>:std::exception
  • <stdexcept>:logic_error、runtime_error 等
  • <new>:bad_alloc
  • <typeinfo>:bad_cast、bad_typeid
cpp 复制代码
int main() {
    try {
        std::vector<int> v(10, 5);
        v.reserve(1000000000); // 可能抛 bad_alloc 或 length_error
        v.at(10) = 100;        // 抛 out_of_range
    } catch (const std::exception& e) {
        cout << e.what() << endl;
    } catch (...) {
        cout << "Unknown Exception" << endl;
    }
    return 0;
}

异常的优缺点

优点:

  • 异常对象可以清晰准确地展示错误信息,甚至包含堆栈信息。
  • 相比错误码,不需要层层返回错误,异常会直接跳到能处理的 catch。
  • 很多第三方库使用异常,比如 Boost、gtest、gmock。
  • 构造函数没有返回值,不方便用错误码,异常更合适。
  • 运算符重载、operator[]等不方便返回错误码,异常更自然。

缺点:

  • 异常会导致执行流乱跳,调试和分析困难。
  • 异常有性能开销,但现代硬件下不抛时开销很小,抛出时开销大。
  • C++ 没有垃圾回收,异常容易导致内存泄漏、死锁。
  • C++ 标准库异常体系设计不够好用,很多公司自定义异常体系。
  • 异常规范很重要,随意抛异常会让外层捕获者痛苦。

我们再回到最初的问题:为什么异常又有什么用?创造C++的大佬又为什么要设计异常?

经过上述对异常的了解,这两个问题我们就好回答了,

  • 异常有什么用呢?它让我们能快速定位到错误,不需要像C语言那样层层返回错误;
  • 创造C++大佬为什么要设计异常呢?Bjarne 设计异常是为了让 C++ 在大型系统和库开发中能优雅地处理错误。

完

相关推荐
米糕闯编程1 小时前
鱼香ros2(十七)C++小海龟巡逻
开发语言·c++·机器人·ros2·服务·小海龟
郝学胜-神的一滴1 小时前
NumPy深度解析|Python数值计算的基石,告别低效for循环
开发语言·人工智能·python·程序人生·numpy·量化投资
\光辉岁月/1 小时前
15.JavaSE-网络
java·开发语言·网络
兰亭妙微UI设计公司1 小时前
兰亭妙微软件界面设计公司的解锁按钮设计10大密码
开发语言·前端·javascript
人邮异步社区2 小时前
C语言好学,还是Python好学?
c语言·开发语言·python
栈帧之外2 小时前
【C++】红黑树实现
开发语言·c++
程序员老陆2 小时前
C++中同一个回调函数被多个设备并发调用:并发问题不在“函数”,而在“状态”
开发语言·c++·程序设计
辛苦才能2 小时前
C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选
c++·哈希算法·散列表
wangchunyu1142 小时前
JDK8新特性实战(Lambda、Stream、Optional)
java·开发语言