C++异常:从概念到实践的一篇博客

一、异常的概念

异常处理机制允许程序中独立开发的部分在运行时对出现的问题进行通信并做出相应处理。它最大的意义是:把"问题的检测"和"问题的解决"分开。程序的一部分负责检测问题,然后抛出一个异常对象;另一部分负责捕获这个对象并处理。检测环节不需要知道处理模块的所有细节。

在C语言中,我们主要通过错误码处理错误。错误码本质是对错误信息分类编号,拿到错误码后还要去查询错误信息,比较麻烦。而C++异常抛出的是一个对象,这个对象可以携带更全面的信息,比如错误描述、错误编号、SQL语句、请求类型等。

二、异常的抛出和捕获

程序出现问题时,通过 throw 一个对象来引发异常。这个对象的类型以及当前调用链决定了应该由哪个 catch 处理代码来处理。被选中的处理代码,是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。根据抛出对象的类型和内容,抛出异常的部分告知处理部分到底发生了什么错误。

throw 执行时,throw 后面的语句不再执行。程序执行从 throw 位置跳到与之匹配的 catch 模块。catch 可能是同一函数中的局部 catch,也可能是调用链中另一个函数中的 catch。控制权从 throw 位置转移到 catch 位置。这有两个重要含义:第一,沿着调用链的函数可能提早退出;第二,一旦程序开始执行异常处理程序,沿着调用链创建的对象都将销毁。

抛出异常对象后,会生成一个异常对象的拷贝。因为抛出的异常对象可能是一个局部对象,所以会生成一个拷贝对象,这个拷贝对象会在 catch 子句后销毁。这里的处理类似于函数的传值返回。

cpp 复制代码
double Divide(int a, int b)
{
    try
    {
        // 当b==0时抛出异常
        if (b == 0)
        {
            string s("Divide by zero condition!");
            throw s;
        }
        else
        {
            return ((double)a / (double)b);
        }
    }
    catch (int errid)
    {
        cout << errid << endl;
    }

    return 0;
}

void Func()
{
    int len, time;
    cin >> len >> time;

    try
    {
        cout << Divide(len, time) << endl;
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }

    cout << __FUNCTION_ <<": " << __LINE_ << "行执行" << endl;
}

int main()
{
    while (1)
    {
        try
        {
            Func();
        }
        catch (const string& errmsg)
        {
            cout << errmsg << endl;
        }
    }

    return 0;
}

这段代码非常适合理解"类型匹配"和"栈展开"。Divide 中如果 b==0,会构造一个 string 对象 s,然后 throw s;。注意,抛出的是 string 类型。Divide 内部的 catch (int errid) 只能捕获 int,不能捕获 string,所以这个 catch 不会命中。异常会退出 Divide,进入 FuncFunc 中有 catch (const char* errmsg),它只能捕获 const char*,也不能捕获 string,所以也不会命中。异常继续退出 Func,进入 mainmain 中有 catch (const string& errmsg),这个可以捕获 string,于是 main 中的 catch 命中,打印错误信息。

如果异常在 Func 中没有被捕获,那么 Funccout << __FUNCTION_... 这行不会执行,因为异常已经让 Func 提前退出了。这就是 throw 后语句不再执行、控制权转移的体现。

三、栈展开

抛出异常后,程序暂停当前函数的执行,开始寻找与之匹配的 catch 子句。首先检查 throw 本身是否在 try 块内部。如果在,则查找匹配的 catch 语句;如果有匹配的,就跳到 catch 的地方进行处理。如果当前函数中没有 try/catch 子句,或者有 try/catch 子句但是类型不匹配,则退出当前函数,继续在外层调用函数链中查找。这个查找 catch 的过程被称为栈展开。

如果到达 main 函数,依旧没有找到匹配的 catch 子句,程序会调用标准库的 terminate 函数终止程序。如果找到匹配的 catch 子句,处理后,catch 子句代码会继续执行。

比如给三个函数 func1()func2()func3()main() 的例子:在 func2() 中调用 func1()func3() 中调用 func2()main() 中调用 func3(),并在 func1() 抛出一个异常,在 main()catch 语句捕获。

栈展开过程如下:

首先检查 throw 本身是否在 try 块内部,如果是再查找匹配的 catch 语句。如果有匹配的,则处理。没有则退出当前函数栈,继续在调用函数的栈中进行查找,不断重复上述过程。若到达 main 函数的栈,依旧没有匹配的,则终止程序。

可以用文字描述这个调用链:

cpp 复制代码
func1() 中:
{
    throw e;
}

func2() 中:
{
    func1();
}

func3() 中:
{
    func2();
}

main() 中:
{
    func3();
}

func1 抛出 e 后,func1 中没有匹配的 catch,于是 func1 退出,局部对象销毁;然后到 func2 中查找,func2 中没有匹配,退出;再到 func3 中查找,退出;最后到 main 中查找,如果 main 有匹配的 catch,就执行 main 中的 catch。如果 main 也没有,就调用 terminate 终止程序。

四、查找匹配的处理代码

一般情况下,抛出对象和 catch 是类型完全匹配的。如果有多个类型匹配的,就选择离它位置更近的那个。但也有一些例外,允许以下转换:从非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组元素类型的指针;允许函数被转换成指向函数的指针;允许从派生类向基类类型的转换。这个点非常实用,实际中继承体系基本都是用这个方式设计的。

如果到 main 函数,异常仍旧没有被匹配,就会终止程序。不是发生严重错误的情况下,我们是不期望程序终止的,所以一般 main 函数中最后都会使用 catch(...),它可以捕获任意类型的异常,但是不知道异常错误是什么。

我们模拟设计了一个服务的几个模块。每个模块的继承都是 Exception 的派生类,每个模块可以添加自己的数据。最后捕获时,我们捕获基类就可以。代码如下:

cpp 复制代码
class Exception
{
public:
    Exception(const string& errmsg, int id)
        :_errmsg(errmsg)
        ,_id(id)
    {}

    virtual string what() const
    {
        return _errmsg;
    }

    int getid() const
    {
        return _id;
    }

protected:
    string _errmsg;
    int _id;
};

class SqlException : public Exception
{
public:
    SqlException(const string& errmsg, int id, const string& sql)
        :Exception(errmsg, id)
        ,_sql(sql)
    {}

    virtual string what() const
    {
        string str = "SqlException:";
        str += _errmsg;
        str += "->";
        str += _sql;
        return str;
    }

private:
    const string _sql;
};

class CacheException : public Exception
{
public:
    CacheException(const string& errmsg, int id)
        :Exception(errmsg, id)
    {}

    virtual string what() const
    {
        string str = "CacheException:";
        str += _errmsg;
        return str;
    }
};

class HttpException : public Exception
{
public:
    HttpException(const string& errmsg, int id, const string& type)
        :Exception(errmsg, id)
        , _type(type)
    {}

    virtual string what() const
    {
        string str = "HttpException:";
        str += _type;
        str += ":";
        str += _errmsg;
        return str;
    }

private:
    const string _type;
};

void SQLMgr()
{
    if (rand() % 7 == 0)
    {
        throw SqlException("权限不足", 100, "select * from name = '张三'");
    }
    else
    {
        cout << "SQLMgr 调用成功" << endl;
    }
}

void CacheMgr()
{
    if (rand() % 5 == 0)
    {
        throw CacheException("权限不足", 100);
    }
    else if (rand() % 6 == 0)
    {
        throw CacheException("数据不存在", 101);
    }
    else
    {
        cout << "CacheMgr 调用成功" << endl;
    }

    SQLMgr();
}

void HttpServer()
{
    if (rand() % 3 == 0)
    {
        throw HttpException("请求资源不存在", 100, "get");
    }
    else if (rand() % 4 == 0)
    {
        throw HttpException("权限不足", 101, "post");
    }
    else
    {
        cout << "HttpServer调用成功" << endl;
    }

    CacheMgr();
}

int main()
{
    srand(time(0));

    while (1)
    {
        this_thread::sleep_for(chrono::seconds(1));

        try
        {
            HttpServer();
        }
        catch (const Exception& e) // 这里捕获基类,基类对象和派生类对象都可以被捕获
        {
            cout << e.what() << endl;
        }
        catch (...)
        {
            cout << "Unknown Exception" << endl;
        }
    }

    return 0;
}

这段代码重点展示了"派生类向基类转换"的异常匹配。SqlExceptionCacheExceptionHttpException 都继承自 Exception。在 main 中,catch (const Exception& e) 可以捕获所有派生类异常。因为捕获的是基类引用,所以不会发生对象切片,并且 e.what() 会通过虚函数多态调用实际派生类的 what()。这就是实际项目中常见的异常体系设计:定义基类 Exception,各模块定义自己的派生异常,最后在主函数捕获基类即可。catch(...) 用来兜底未知异常。

五、异常重新抛出

有时 catch 到一个异常对象后,需要对错误进行分类。其中某种异常错误需要进行特殊处理,其他错误则重新抛出异常给外层调用链处理。捕获异常后需要重新抛出,直接 throw; 就可以把捕获的对象直接抛出。

我们模拟了聊天时发送消息,发送失败捕获异常。但可能在电梯、地下室等场景手机信号不好,则需要多次尝试。如果多次尝试都发送不出去,则就需要捕获异常再重新抛出。其次,如果不是网络差导致的错误,捕获后也要重新抛出。代码如下:

cpp 复制代码
void _SeedMsg(const string& s)
{
    if (rand() % 2 == 0)
    {
        throw HttpException("网络不稳定,发送失败", 102, "put");
    }
    else if (rand() % 7 == 0)
    {
        throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
    }
    else
    {
        cout << "发送成功" << endl;
    }
}

void SendMsg(const string& s)
{
    // 发送消息失败,则再重试3次
    for (size_t i = 0; i < 4; i++)
    {
        try
        {
            _SeedMsg(s);
            break;
        }
        catch (const Exception& e)
        {
            // 捕获异常,if中是102号错误,网络不稳定,则重新发送
            // 捕获异常,else中不是102号错误,则将异常重新抛出
            if (e.getid() == 102)
            {
                // 重试三次以后否失败了,则说明网络太差了,重新抛出异常
                if (i == 3)
                    throw;

                cout << "开始第" << i + 1 << "重试" << endl;
            }
            else
            {
                throw;
            }
        }
    }
}

int main()
{
    srand(time(0));

    string str;
    while (cin >> str)
    {
        try
        {
            SendMsg(str);
        }
        catch (const Exception& e)
        {
            cout << e.what() << endl << endl;
        }
        catch (...)
        {
            cout << "Unkown Exception" << endl;
        }
    }

    return 0;
}

这段代码展示了 throw; 的用法。SendMsg 中循环 4 次,i 从 0 到 3。如果捕获到 id 为 102 的网络不稳定异常,并且 i == 3,说明重试多次仍失败,就 throw; 重新抛出当前异常,交给 main 处理。如果捕获到的不是 102,比如 103 好友关系异常,则直接 throw; 重新抛出,因为这种错误重试没有意义。throw; 只能用在 catch 块中,表示重新抛出当前正在处理的异常对象。

六、异常安全问题

异常抛出后,后面的代码就不再执行。如果前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常,就会导致资源没有释放。这里由于异常就引发了资源泄漏,产生安全性的问题。中间我们需要捕获异常,释放资源后面再重新抛出。

其次,析构函数中如果抛出异常也要谨慎处理。比如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,也资源泄漏了。《Effective C++》第8个条款也专门讲了这个问题,别让异常逃离析构函数。

异常安全示例:

cpp 复制代码
double Divide(int a, int b)
{
    // 当 b == 0 时抛出异常
    if (b == 0)
    {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}

void Func()
{
    // 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
    // 所以这里捕获异常后并不处理异常,异常还是交给外层处理,这里捕获了再重新抛出去。
    int* array = new int[10];

    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...)
    {
        // 捕获异常释放内存
        cout << "delete []" << array << endl;
        delete[] array;
        throw; // 异常重新抛出,捕获到什么抛出什么
    }

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

int main()
{
    try
    {
        Func();
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (const exception& e)
    {
        cout << e.what() << endl;
    }
    catch (...)
    {
        cout << "Unknown Exception" << endl;
    }

    return 0;
}

这段代码说明:Func 中先 new int[10],如果后面的 Divide 抛出异常,那么正常路径下的 delete[] array 不会执行。所以在 catch(...) 中先释放 array,再 throw; 把异常继续抛给外层。这样既保证了资源释放,又不吃掉异常。main 中分别捕获 const char*const std::exception&...。注意 throw "Division by zero condition!"; 抛出的是字符串字面量,类型可以理解为 const char*,所以 maincatch (const char* errmsg) 可以捕获。

异常安全的核心思想是:异常可能在任何时候打断正常执行流,因此资源的获取和释放必须成对考虑。但更好的做法是使用 RAII(接下来会有专门的章节讲解),比如智能指针,让对象的析构函数自动释放资源。这样即使发生异常,栈展开时局部对象也会被销毁,从而释放资源。

七、异常规范

对于用户和编译器而言,预先知道某个程序会不会抛出异常大有裨益。知道某个函数是否会抛出异常,有助于简化调用函数的代码。

C++98中,函数参数列表的后面接 throw(),表示函数不抛异常;函数参数列表的后面接 throw(类型1, 类型2...),表示可能会抛出多种类型的异常,可能会抛出的类型用逗号分割。

C++98的方式过于复杂,实践中并不好用。C++11中进行了简化,函数参数列表后面加 noexcept 表示不会抛出异常,啥都不加表示可能会抛出异常。

编译器并不会在编译时检查 noexcept。也就是说,如果一个函数用 noexcept 修饰了,但是同时又包含了 throw 语句,或者调用的函数可能会抛出异常,编译器还是会顺利编译通过(有些编译器可能会报个警告)。但是一个声明了 noexcept 的函数抛出了异常,程序会调用 terminate 终止程序。

noexcept(expression) 还可以作为一个运算符去检测一个表达式是否会抛出异常,可能会则返回 false,不会就返回 true。

cpp 复制代码
// C++98
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();

// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;

double Divide(int a, int b) noexcept
{
    // 当b == 0时抛出异常
    if (b == 0)
    {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}

int main()
{
    try
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (...)
    {
        cout << "Unknown Exception" << endl;
    }

    int i = 0;
    cout << noexcept(Divide(1,2)) << endl;
    cout << noexcept(Divide(1,0)) << endl;
    cout << noexcept(++i) << endl;

    return 0;
}

这段代码中,Divide 被声明为 noexcept,但它内部却可能 throw。这其实是危险的:如果 b==0Divide 抛出异常,由于它声明了 noexcept,程序会调用 terminate 终止,而不会正常传播异常。noexcept(Divide(1,2))noexcept(Divide(1,0)) 都会输出 true,因为 noexcept 运算符只看函数声明是否 noexcept,不会实际执行函数体。noexcept(++i) 通常为 true,因为 ++i 这种内置类型操作不会抛异常。输出时 true 显示为 1,false 显示为 0。

八、标准库的异常

C++标准库也定义了一套自己的异常继承体系库,基类是 std::exception。所以我们日常写程序,需要在主函数捕获 exception 即可。要获取异常信息,调用 what 函数,what 是一个虚函数,派生类可以重写。

标准库异常体系图,可以整理为:

基类:std::exception

直接或间接派生:

  • std::bad_alloc

  • std::bad_cast

  • std::bad_typeid

  • std::bad_exception

  • std::logic_error

    • std::domain_error

    • std::invalid_argument

    • std::length_error

    • std::out_of_range

  • std::runtime_error

    • std::overflow_error

    • std::range_error

    • std::underflow_error

std::exceptionwhat() 是虚函数,返回 const char*。派生类可以重写它来提供更具体的错误信息。我们日常写代码时,可以在 main 中这样捕获:

cpp 复制代码
try
{
    // ...
}
catch (const std::exception& e)
{
    cout << e.what() << endl;
}
catch (...)
{
    cout << "Unknown Exception" << endl;
}

九、总结

异常处理的核心可以概括为以下几点:

  1. 异常把错误的检测和处理分离,抛出的对象可以携带丰富信息。

  2. throw 后,后面的语句不再执行,控制权转移到匹配的 catch

  3. 查找 catch 的过程叫栈展开,会沿着调用链向外找,找不到就 terminate

  4. 匹配时优先完全匹配,也允许常量转换、数组转指针、函数转指针、派生类转基类。

  5. catch(...) 可以捕获任意异常,常用于 main 中兜底。

  6. throw; 可以重新抛出当前异常,用于分类处理和重试。

  7. 异常安全要注意资源释放,推荐 RAII,析构函数不要抛异常。

  8. C++11 用 noexcept 表示不抛异常,noexcept(expression) 可以检测表达式是否声明为不抛异常。

  9. 标准库异常基类是 std::exception,日常捕获 const std::exception& 即可。

相关推荐
m0_734571761 小时前
深入理解C++ 析构函数<二>析构顺序
开发语言·c++
小孩玩什么2 小时前
深入理解字符串匹配算法:BF算法,KMP算法
java·c语言·开发语言·数据结构·c++·算法
无忧.芙桃2 小时前
C++内存管理
c语言·开发语言·c++·青少年编程
郝学胜_神的一滴2 小时前
C++11 工程级应用 11:编译期黑魔法,告别重复烂代码
c++·visual studio
zyeyeye3 小时前
C++入门:从HelloWorld到命名空间揭秘
c语言·开发语言·c++·算法
励志不掉头发的内向程序员3 小时前
【LibreCAD 2D架构】从 addHistory 到 RS_UndoCycle:LibreCAD 里的两套 History 与撤销机制
开发语言·c++·qt·学习·系统架构
elseif1233 小时前
【双指针/二分】P1102 A-B 数对
c++·算法·二分·双指针
雪落漂泊3 小时前
C++智能指针
开发语言·jvm·c++
bnmoel4 小时前
C++ 基础入门篇(二):引用
开发语言·c++·引用·底层