C++ 异常 奇牛(Gemini润色)

C++ 异常 —— 奇牛

Note: 现代 C++ 对异常的态度是“标准库离不开,工业界不敢用”。官方标准从未彻底抛弃异常,但实际工程(尤其是高性能领域和Google等大厂规范)因性能开销常将其禁用,转而使用 std::expected 等现代替代方案。学习异常,是为了看懂标准库和理解 C++ 底层机制,以及维护老旧项目

1. 异常的核心概念

异常(Exception)是 C++ 引入的一种错误处理和程序控制机制,它与传统的函数返回值机制互补。

  • 传统函数机制:基于栈结构展开,依赖上下层函数通过返回值(如返回 -1NULL)逐层传递错误状态。如果调用链很深,逐层判断返回值会使代码变得极为臃肿。
  • 异常机制:一种带“穿透力”的控制结构。当底层函数出现无法处理的“意外”时,可以直接中断当前执行流,并利用 throw 将异常对象抛出。程序会自动跨越(跳过)中间隔代的调用者,直到在调用栈中找到能够匹配该异常类型的 catch 块。

2. 异常处理基本语法与执行流程

异常处理由三个关键字组成:throw(抛出)、try(尝试捕获)、catch(处理异常)。

2.1 基本语法

// 1. 在异常发生的第一现场,抛出异常
void func()
{
    // ... 发现错误
    throw 表达式; // 抛出一个任意类型的对象或内置数据类型
}

// 2. 在需要关注并处理异常的地方,尝试捕获
void caller()
{
    try
    {
        // 保护段:可能抛出异常的代码
        func();
    }
    catch (异常类型1 [形参])
    {
        // 匹配异常类型1时的处理代码
    }
    catch (异常类型2 [形参])
    {
        // 匹配异常类型2时的处理代码
    }
    catch (...) 
    {
        // 兜底:捕获所有其他未明确声明的异常类型
    }
}

2.2 执行流程与注意事项

  1. 正常执行:程序顺序执行到达 try 语句,进入 try 块(保护段)。如果执行期间没有发生异常,所有的 catch 子句将被忽略,程序继续执行最后一个 catch 块之后的代码。

  2. 异常触发:一旦 throw 被执行,try 块内后续的代码将立即中止执行。

  3. 栈展开(Stack Unwinding):非常重要!在寻找匹配的 catch 的过程中,系统会从抛出异常的函数开始,逐层向上退出调用栈。在退栈时,局部对象的析构函数会被自动调用,确保资源被正确释放。

  4. 严格的类型匹配catch 子句按其在 try 块后出现的顺序被检查。一旦找到类型严格匹配的 catch 块,就会执行其中的代码。

  5. 未处理的异常:如果异常抛出后,在整个程序的调用栈中都没有找到匹配的 catch,C++ 的缺省行为是调用 std::terminate()(内部调用 abort())强行终止程序。

  6. 异常的重新抛出:如果当前的 catch 块只能记录日志,或者无法完全处理该异常,可以使用无操作数的 throw;同一个异常继续向外层调用者抛出。

2.3 示例代码:文件拷贝

这里提供一个完整的上下文基础框架,后续的演示都将基于此框架的变体。

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <Windows.h>

#define BUFSIZE 1024

// 1. 底层函数:抛出异常的第一现场
int copyfile2(const char* dest, const char* src)
{
    FILE* fp1 = nullptr;
    FILE* fp2 = nullptr;

    // 使用标准的 std::fopen,保证跨平台 (GCC/MSVC均可)
    fp1 = std::fopen(src, "rb");
    if (fp1 == nullptr)
    {
        // 演示:抛出指针(极其危险的写法,仅为展示语法匹配)
        // 更好的做法应该是:throw std::runtime_error("文件不存在");
        throw new std::string("源文件不存在或无法打开");
    }

    fp2 = std::fopen(dest, "wb");
    if (fp2 == nullptr)
    {
        std::fclose(fp1); // 发生错误前需手动清理资源,推荐使用 RAII 自动管理
        throw - 2;         // 抛出 int 型异常
    }

    // ... 文件读写逻辑略 ...
    std::fclose(fp1);
    std::fclose(fp2);
    return 0;
}

// 2. 中间层函数:拦截部分异常,重新抛出
int copyfile1(const char* dest, const char* src)
{
    try
    {
        copyfile2(dest, src);
    }
    catch (float e)
    {
        std::cout << "copyfile1 捕获到 float: " << e << ",准备向上层重新抛出...\n";
        throw e; // 抛出具体的变量 e
    }
    catch (...)
    {
        std::cout << "copyfile1 捕获到未知异常,准备原封不动向上层重新抛出...\n";
        // 核心语法:无参 throw,将异常原封不动继续向上抛给 main 函数
        throw;
    }


    return 0;
}

// 3. 顶层调用者:最终捕获兜底
int main()
{
    try // 保护段
    {
        // 故意传入不存在的路径触发异常
        copyfile1("C:/test/dest.txt", "C:/test/NonExistent.txt");

        std::cout << "执行 copyfile1 完毕,未发生异常\n";
    }
    // 按照从上到下的顺序匹配 catch
catch (int error)
    {
        std::cout << "main 捕获到 int 异常!错误码: " << error << "\n";
    }
    catch (std::string* error)
    {
        std::cout << "main 捕获到 std::string* 异常: " << *error << "\n";
        delete error; // 捕获指针必须手动 delete 释放内存!
    }
    catch (...)
    {
        std::cout << "main 触发未知类型异常的兜底捕获 (catch ...)\n";
    }
    system("pause");
}

//输出
//copyfile1 捕获到未知异常,准备原封不动向上层重新抛出...
//main 捕获到 std::string* 异常: 源文件不存在或无法打开

3. 异常接口声明(动态异常规范)

在早期的 C++ 标准中,可以在函数声明后列出该函数可能抛出的异常类型。

注意:此特性在 C++11 中被弃用,并在 C++17 中被正式移除,现代 C++ 中使用 noexcept 来标记不抛出异常的函数。

// 声明此函数只会抛出 float, std::string*, int 这三种类型的异常
// 如果抛出了其他类型,程序会调用 std::unexpected() 终止
int copyfile2(const char* dest, const char* src) throw (float, std::string*, int)
{
    // ... 
}

4. 异常类型和生命周期

异常的 throw 可以抛出任何类型的数据。但在 C++ 的底层机制中,异常对象不在普通的函数调用栈上,也不在常规的堆(Heap)上。

  • 异常对象在特殊的异常内存区中:

    当执行 throw 时,当前的函数调用栈即将被“栈展开(Unwinding)”并销毁。为了保证异常对象在退栈后依然存活,并能传递给外层的 catch 块,编译器会调用底层 API(如 GCC 的 __cxa_allocate_exception),在编译器专门维护的、独立于调用栈的“线程局部存储区(TLS)”或专用的异常堆空间中,开辟一块内存来单独存放这个异常对象。

4.1 抛出普通类型

抛出普通数据类型(如 int, float, char 等)时,其语法表现和函数传值一致,但底层生命周期同样依赖这块专门的异常内存区。

#include <iostream>

void testBasicType()
{
    int errorCode = 404; // 1. 在当前函数栈上生成局部变量 errorCode
    
    throw errorCode;     // 2. 核心:系统将栈上的 404 拷贝到“专用的异常内存区”中。
                         // 3. 栈展开开始,局部变量 errorCode 被销毁。
}

int main()
{
    try 
    {
        testBasicType();
    }
    catch (int e)        // 4. 按值捕获:系统将“异常内存区”中的 404 再次拷贝给局部变量 e。
                         //    (注:对于内置类型,按值捕获开销极小,通常不要求写成引用)
    {
        std::cout << "捕获到 int 型异常,错误码: " << e << "\n";
    }                    // 5. catch 块结束,局部变量 e 销毁。
                         // 6. 系统回收并清理“专用异常内存区”里的那个 404 原始数据。
    
    return 0;
}

4.2 抛出 C 风格字符串常量

当抛出字符串字面量时,系统会将其视为指针类型。异常匹配是极其严格的,没有隐式类型转换机制(比如不会自动转为 std::string),因此必须严格匹配 const char*

#include <iostream>
#include <string>

void testFunc()
{
    // 抛出字符串字面量,其类型严格为 const char*
    throw "源文件有问题!"; 
}

// 在 main 中:
int main()
{
    try 
    {
        testFunc();
    }
    catch (std::string error) // ❌ 无法匹配!不会隐式转换为 std::string
    {
        std::cout << "匹配失败\n";
    }
    catch (char* error)       // ❌ 无法匹配!少了 const 修饰符
    {
        std::cout << "匹配失败\n";
    }
    catch (const char* error) // ✅ 严格匹配成功!
    {
        std::cout << "main 捕获到 const char* 异常: " << error << "\n";
    }
    return 0;
}

4.3 抛出自定义对象类型(深究拷贝与多态)

当我们抛出自定义对象时,如果不注意写法,会经历多次拷贝构造。这不仅会带来不必要的性能开销,还可能在多态使用中引发对象切片(Object Slicing)问题。

测试类定义:

class ErrorException 
{
public:
    int _id;
    ErrorException() : _id(0) 
    { 
        std::cout << "ErrorException 无参构造\n"; 
    }
    ~ErrorException() 
    { 
        std::cout << "_id: " << _id << " 析构\n"; 
    }
    ErrorException(const ErrorException& other) : _id(1) 
    { 
        std::cout << "ErrorException 拷贝构造\n"; 
    }
};

场景 A:按值抛出,按值捕获(不推荐)

void copyfile4()
{
    ErrorException ee; // 1. 在当前函数栈上调用无参构造,生成局部对象 ee (id=0)
    
    throw ee;          // 2. 将栈上的 ee 拷贝构造到“专用的异常内存区” (id=1)。
                       // 3. 栈展开开始,局部变量 ee 被销毁,调用栈上的析构函数 (id=0)
}

// 在 main 中:
catch (ErrorException error) // 4. 按值捕获。系统又将“专用异常内存区”里的对象,
                             //    拷贝构造给 catch 块内的局部变量 error (id=1 拷贝变 id=1)。
                             //    (注:部分现代编译器会优化掉这一次拷贝,但在理论语义上它是存在的)
{
    error._id = 2;
    std::cout << "main 捕捉到 ErrorException 类型\n";
} // 5. catch 块结束,销毁 catch 块内的局部对象 error (id=2)
  // 6. 异常处理彻底完成,系统回收并销毁“专用异常内存区”里的那个原始异常对象 (id=1)

场景 B:按值抛出匿名对象,按引用捕获(推荐)

为了避免上述场景中多余的拷贝构造与内存折腾,我们通常采用统一的规范:throw by value, catch by reference(按值抛出,按引用捕获)

void copyfile4()
{
    // 1 & 2. 直接抛出匿名对象。
    // 编译器会直接在“专用的异常内存区”中调用构造函数生成该对象,省去了一次从局部栈拷贝的开销。
    throw ErrorException(); 
}

// 在 main 中:
catch (const ErrorException& error) // 3. 使用 const 引用捕获!
{
    // 直接引用“专用异常内存区”里的那个对象,不再发生任何多余的拷贝构造。
    std::cout << "main 通过引用捕捉异常,没有发生额外的拷贝\n";
} // 4. catch 块结束,引用失效。
  // 5. 系统清理“专用异常内存区”的对象。

优点:

  1. 性能最优:没有额外的拷贝开销,直接引用异常专属内存区的数据。
  2. 多态安全:如果抛出的是派生类,捕获的是基类引用,可以完美支持多态,避免了按值捕获导致的子类对象被“切片”(Slicing)退化为不完整的基类对象。
  3. 内存安全:不需要像抛出堆指针(throw new ...)那样要求捕获者去手动 delete 内存,从根本上防止了内存泄漏。

5. 异常和继承

在 C++ 中,异常本身也是类。我们可以像设计普通类一样设计异常类,在其中使用虚函数、继承、引用传递,并添加数据成员(例如记录出错的具体数值)。

5.1 为什么要结合“继承”与“异常”

  • 统一捕获,利用多态: 将多种错误归类到一个“基类”下。在 try-catch 时,只需要 catch 基类的引用,就能自动处理所有派生类的异常,省去写一大堆 catch 分支的麻烦。
  • 必须使用“引用”捕获 (catch(Exception& e)):
    • 防切片: 按值捕获会导致派生类被“裁剪”成基类,丢失子类特有的数据和虚函数特性。
    • 提性能: 避免异常对象拷贝带来的额外开销。

注意事项:

  1. 虚析构: 基类只要有虚函数,就务必加上虚析构函数(virtual ~类名() = default;),防内存泄漏。

  2. override 子类重写虚函数时加上此关键字,让编译器帮忙查错。

  3. const 像打印错误(logError)这种不修改数据的操作,函数尾部应加上 const

5.2 示例代码

设计数组容器 MyArr,重载 [] 操作符。

  1. idx < 0,抛出 ErrNegativeException
  2. idx > 1000,抛出 ErrTooBigException
  3. 父类 ErrSizeException 实现有参构造,并定义 virtual void logError() const

MyArr.hpp:

#ifndef MYARR_HPP
#define MYARR_HPP

#include <iostream>

// ==========================================
// 1. 异常类设计 (基类与派生类)
// ==========================================

class ErrSizeException 
{
public:
    explicit ErrSizeException(int size) : _size(size) {}
    
    virtual ~ErrSizeException() = default; // 规范:添加虚析构

    virtual void logError() const {        // 规范:添加 const
        std::cout << "size or idx: " << _size << ", ErrSizeException\n"; 
    }
protected:
    int _size;
};

class ErrNegativeException : public ErrSizeException 
{
public:
    explicit ErrNegativeException(int size) : ErrSizeException(size) {}

    void logError() const override {       // 规范:明确 override
        std::cout << "size or idx: " << _size << ", ErrNegativeException\n"; 
    }
};

class ErrTooBigException : public ErrSizeException 
{
public:
    explicit ErrTooBigException(int size) : ErrSizeException(size) {}

    void logError() const override { 
        std::cout << "size or idx: " << _size << ", ErrTooBigException\n"; 
    }
};

// ==========================================
// 2. MyArr 容器类实现
// ==========================================

class MyArr
{
public:
    MyArr(int size = 128);
    MyArr(const MyArr& other);
    ~MyArr();

    MyArr& operator=(const MyArr& other);
    int& operator[](int idx);
    
    int getSize() const { return _size; }
private:
    int _size;
    int* _arr;
};

MyArr::MyArr(int size) : _size(size)
{
    if (size < 0) throw ErrNegativeException{_size};
    if (size > 1000) throw ErrTooBigException{_size};

    _arr = new int[_size];
}

MyArr::MyArr(const MyArr& other) : _size(other._size)
{
    if (_size < 0) throw ErrNegativeException{_size};
    if (_size > 1000) throw ErrTooBigException{_size};

    _arr = new int[_size];
    for (int i = 0; i != _size; i++) {
        _arr[i] = other._arr[i];
    }
}

MyArr::~MyArr()
{
    _size = 0;
    delete[] _arr;
    _arr = nullptr;
}

MyArr& MyArr::operator=(const MyArr& other)
{
    if (this == &other) return *this;

    // 强异常安全保证:先 new 再 delete
    int* new_arr = new int[other._size];
    for (int i = 0; i != other._size; i++) {
        new_arr[i] = other._arr[i];
    }

    delete[] _arr;
    _size = other._size;
    _arr = new_arr;

    return *this;
}

int& MyArr::operator[](int idx)
{
    if (idx < 0) throw ErrNegativeException{ idx };
    if (idx >= _size) throw ErrTooBigException{ idx };

    return _arr[idx];
}

#endif // MYARR_HPP

test.cpp:

#include <iostream>
#include "MyArr.hpp"

int main()
{
    try
    {
        MyArr marr{ 1 };
        
        // 触发异常:数组大小为 1,索引 1 越界
        marr[1] = 0; 

        std::cout << "running normally without any error" << std::endl;
    }
    // 重点:只需使用基类引用捕获,即可触发多态
    catch(const ErrSizeException& error)
    {
        error.logError(); // 实际调用的是 ErrTooBigException::logError()
    }
    catch (...)
    {
        std::cout << "Unknown error" << std::endl;
    }
    
    return 0;
}
posted @ 2026-05-07 21:48  学习笔记草稿存放账号  阅读(17)  评论(0)    收藏  举报