一、异常的基本概念

1.1 什么是异常

异常是程序运行时发生的、可预料但不可避免的错误,会导致程序正常执行流程中断。例如:

  • 内存申请失败(new 抛出 bad_alloc
  • 打开不存在的文件
  • 数组越界访问
  • 除零错误
  • 网络连接断开

核心区别:编译错误是语法问题,编译阶段就能发现;异常是运行时错误,只有程序执行到特定逻辑才会触发。


1.2 C 语言的错误处理方式(面试对比考点)

C 语言没有原生异常机制,主要通过以下方式处理错误,这些方式存在明显缺陷,也是 C++ 引入异常的原因:

(1)函数返回错误码

通过函数返回值表示执行结果,通常用0表示成功,非0表示错误。

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd = open("nonexist.txt", O_RDONLY);
    if (fd == -1) { // 必须手动检查返回值,否则错误会被忽略
        perror("open failed"); // 打印错误信息
        return 1;
    }
    close(fd);
    return 0;
}
(2)全局错误号 errno

系统调用失败时会设置全局变量errno,配合strerror(errno)perror()解析错误信息。

#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>

int main() {
    int* p = (int*)malloc(1024 * 1024 * 1024 * 1024); // 申请过大内存
    if (p == NULL) {
        printf("malloc failed: %s\n", strerror(errno));
        return 1;
    }
    free(p);
    return 0;
}
(3)断言 assert

用于调试阶段检查逻辑错误,表达式为假时直接终止程序。发布版本会被禁用,不能用于处理运行时错误。

#include <assert.h>

int divide(int a, int b) {
    assert(b != 0); // 调试阶段有效,发布版失效
    return a / b;
}
C 语言错误处理的核心缺陷(面试必问)
  1. 错误码易被忽略:程序员忘记检查返回值时,错误会静默传播,导致后续更严重的问题。
  2. 返回值冲突:函数正常返回值和错误码可能重叠(比如返回-1既可能是正常结果也可能是错误)。
  3. 调用链传递麻烦:深层函数发生错误时,需要逐层向上传递错误码,代码冗余。
  4. 无法自动清理资源:错误发生时,需要手动释放之前申请的内存、文件句柄等,容易遗漏导致资源泄露。

二、C++ 异常处理机制

C++ 提供了throw(抛出异常)、try(监控异常)、catch(捕获异常)三个关键字,实现了错误与正常逻辑的分离。

2.1 异常抛出 throw

语法:throw 异常对象;

  • 可以抛出任意类型的对象:基本类型(int、double)、字符串、自定义类对象、标准异常类对象。
  • 异常抛出后,当前函数立即终止执行,开始向上层调用者传播异常。
#include <iostream>
using namespace std;

int divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero"; // 抛出const char*类型异常
    }
    return a / b;
}

int main() {
    try {
        cout << divide(5, 2) << endl; // 正常执行
        cout << divide(8, 0) << endl; // 抛出异常
        cout << divide(7, 1) << endl; // 不会执行
    } catch (const char* e) { // 捕获const char*类型异常
        cout << "Error: " << e << endl;
    }
    return 0;
}

运行结果:

2
Error: Division by zero

2.2 异常捕获 try-catch

语法结构
try {
    // 可能抛出异常的代码
} catch (异常类型1 变量名) {
    // 处理类型1的异常
} catch (异常类型2 变量名) {
    // 处理类型2的异常
} catch (...) {
    // 处理所有未被上面捕获的异常(必须放在最后)
}
异常匹配规则(面试必问)
  1. 精确匹配优先:优先匹配类型完全相同的 catch 块。
  2. 基类引用可以接收派生类对象:如果用基类引用捕获,必须放在派生类 catch 块的后面,否则派生类异常会被基类块提前拦截。
  3. catch(...) 捕获所有异常:作为兜底,必须放在所有 catch 块的最后。
错误示例(基类在前导致派生类异常无法捕获)
#include <stdexcept>
#include <iostream>
using namespace std;

int main() {
    try {
        throw invalid_argument("Invalid argument"); // 派生自logic_error
    } catch (const exception& e) { // 基类在前,会拦截所有派生类异常
        cout << "Base exception: " << e.what() << endl;
    } catch (const invalid_argument& e) { // 永远不会执行
        cout << "Invalid argument: " << e.what() << endl;
    }
    return 0;
}

2.3 异常的传播与栈展开(面试核心)

异常传播过程
  1. 异常抛出后,首先检查当前函数是否在try块内,且有匹配的catch块。
  2. 如果当前函数无法处理,立即终止当前函数执行,销毁所有局部对象(栈展开),回到上层调用函数。
  3. 重复上述过程,直到找到匹配的catch块;如果到main函数仍未处理,调用terminate()终止程序。
栈展开(Unwinding)

异常抛出后,从进入try块到异常抛出点之间,所有在栈上构造的对象会自动按构造的逆序调用析构函数,释放资源。

这是 C++ 异常最核心的优势之一:自动清理局部资源,避免 C 语言手动清理的遗漏问题。

教材示例 12-2:栈展开演示

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

class Demo {
public:
    Demo() { cout << "Constructor of Demo" << endl; }
    ~Demo() { cout << "Destructor of Demo" << endl; }
};

void func() {
    Demo d; // 栈上对象,异常抛出时会自动析构
    cout << "Throw exception in func()" << endl;
    throw "Exception from func";
}

int main() {
    try {
        func();
    } catch (const char* e) {
        cout << "Caught: " << e << endl;
    }
    return 0;
}

运行结果:

Constructor of Demo
Throw exception in func()
Destructor of Demo
Caught: Exception from func

三、自定义异常类

C++ 允许自定义异常类,推荐继承标准库的std::exception,这样可以和标准异常统一处理。

3.1 标准异常类体系(面试必背)

标准异常定义在<stdexcept>头文件中,基类是std::exception,核心派生类分为两大类:

类别 含义 常见子类
logic_error 逻辑错误(可提前避免) invalid_argument(无效参数)out_of_range(越界)length_error(长度超限)
runtime_error 运行时错误(不可预料) bad_alloc(内存申请失败)range_error(计算结果越界)overflow_error(上溢)

基类exception提供了虚函数what(),返回异常描述字符串:

virtual const char* what() const noexcept;

3.2 自定义异常类示例

方式 1:直接继承std::exception
#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;

// 自定义账户异常类,继承runtime_error
class AccountException : public runtime_error {
private:
    string accountId; // 异常关联的账户ID
public:
    AccountException(const string& id, const string& msg)
        : runtime_error(msg), accountId(id) {}
    
    // 新增方法获取账户ID
    const string& getAccountId() const { return accountId; }
};

class Account {
private:
    string id;
    double balance;
public:
    Account(const string& id, double balance) : id(id), balance(balance) {}
    
    void withdraw(double amount) {
        if (amount > balance) {
            // 抛出自定义异常
            throw AccountException(id, "Insufficient balance");
        }
        balance -= amount;
    }
};

int main() {
    try {
        Account acc("123456", 1000);
        acc.withdraw(2000);
    } catch (const AccountException& e) {
        cout << "Account " << e.getAccountId() << " error: " << e.what() << endl;
    } catch (const exception& e) {
        cout << "Other error: " << e.what() << endl;
    }
    return 0;
}

运行结果:

plaintext

Account 123456 error: Insufficient balance
方式 2:完全自定义异常类(不推荐)

虽然语法允许,但无法和标准异常统一捕获,不符合 C++ 规范:

class MyException {
private:
    string msg;
public:
    MyException(const string& msg) : msg(msg) {}
    const string& what() const { return msg; }
};

四、noexcept 关键字(C++11 面试高频)

4.1 基本语法与作用

noexcept用于标识函数不会抛出异常,编译器可以据此进行优化(例如省略栈展开的额外代码,提高运行效率)。

两种形式
  1. 无参数形式:承诺函数绝对不会抛出异常
    void func() noexcept; // func不会抛出任何异常
    
  2. 带表达式形式:表达式为true时承诺不抛异常
    // func的异常说明和swap一致:swap不抛则func不抛
    void func() noexcept(noexcept(swap(a, b))); 
    

4.2 核心注意事项(面试必问)

  1. noexcept只是承诺,不是强制:如果noexcept函数内部真的抛出了异常,会直接调用terminate()终止程序,不会进行栈展开,局部对象不会被析构,可能导致资源泄露。

    void bad_func() noexcept {
        throw "Exception in noexcept function"; // 编译通过,运行时直接terminate
    }
    
  2. 推荐使用noexcept的场景

    • 所有析构函数(C++11 后析构函数默认noexcept
    • 构造函数、拷贝构造函数、移动构造函数、赋值运算符(如果确定不会抛异常)
    • 简单的只读函数(如getter方法)
    • swap函数(STL 所有容器的swap都是noexcept
  3. noexcept运算符:编译期判断一个表达式是否承诺不抛异常,返回bool

    #include <iostream>
    #include <vector>
    using namespace std;
    
    void func1() noexcept {}
    void func2() {}
    
    int main() {
        cout << boolalpha;
        cout << noexcept(func1()) << endl; // true
        cout << noexcept(func2()) << endl; // false
        cout << noexcept(vector<int>().swap(vector<int>())) << endl; // true
        return 0;
    }
    

五、异常安全性(面试高级考点)

异常安全性是指:异常发生时,程序不会发生资源泄露,且对象状态保持合法。分为三个级别:

1. 基本保证(Basic Guarantee)

异常发生后,程序处于合法状态,没有资源泄露,但对象状态可能发生改变(例如部分操作完成)。

例如:修改后的栈push函数,赋值失败时栈的状态不变。

2. 强保证(Strong Guarantee)

异常发生后,程序状态完全回滚到操作前,就像操作从未发生过。

实现技巧:先创建临时对象完成所有可能抛异常的操作,再用noexceptswap交换临时对象和原对象。

#include <vector>
#include <algorithm>
using namespace std;

// 强保证的逆序函数
void reverse(vector<int>& v) {
    vector<int> tmp(v.rbegin(), v.rend()); // 所有可能抛异常的操作在临时对象上完成
    v.swap(tmp); // swap是noexcept,不会抛异常
}

3. 不抛保证(No-throw Guarantee)

操作绝对不会抛出异常,是最高级别的异常安全。

例如:基本类型的赋值、指针操作、STL 容器的swap函数。


六、面试高频问题汇总

  1. C++ 异常和 C 语言错误处理的区别?

    • C++ 异常将错误处理和正常逻辑分离,代码更清晰;C 语言错误码和业务逻辑混杂。
    • C++ 异常会自动进行栈展开,清理局部资源;C 语言需要手动释放资源。
    • C++ 异常支持类型安全的错误传递,且可以传递复杂的错误信息;C 语言错误码只能传递整数。
    • C++ 异常无法被忽略;C 语言错误码容易被程序员遗漏。
  2. 什么是栈展开?异常抛出后,从try块入口到异常抛出点之间,所有栈上的局部对象会按构造逆序自动调用析构函数,释放资源,直到找到匹配的catch块。

  3. 为什么析构函数不要抛异常?异常传播的栈展开过程中,会调用局部对象的析构函数。如果析构函数再抛出异常,会导致两个异常同时存在,C++ 无法处理,会直接调用terminate()终止程序。

  4. throwthrow;throw ex;的区别?

    • throw ex;:抛出一个新的异常对象ex
    • throw;:重新抛出当前正在处理的异常(只能在catch块内使用),不会创建新对象。
    • throw用于将异常向上层传递,保留原始异常的类型和信息。
  5. noexcept函数抛异常会怎么样?会直接调用terminate()终止程序,不会进行栈展开,局部对象不会被析构,可能导致资源泄露。

  6. 标准异常类中logic_errorruntime_error的区别?

    • logic_error:逻辑错误,是程序员编写代码时可以避免的错误(例如传入无效参数、数组越界)。
    • runtime_error:运行时错误,是程序运行时由环境因素导致的不可预料的错误(例如内存不足、文件损坏)。

更多推荐