C++ 异常、类与继承

异常与成员函数

截至目前,教程中所见异常皆用于非成员函数。然而,异常在成员函数中同样适用,尤其在重载运算符中更为便捷。以下示例为一个简单整型数组类中的重载下标运算符:

int& IntArray::operator[](const int index)
{
    return m_data[index];
}

只要下标有效,该函数运行良好;但显然缺乏必要的错误检查。可添加断言来确保下标合法:

int& IntArray::operator[](const int index)
{
    assert(index >= 0 && index < getLength());
    return m_data[index];
}

现在,若用户传入非法下标,程序将触发断言失败。然而,重载运算符对其形参与返回值的数量和类型有严格规定,无法通过返回码或布尔值向调用者报告错误。异常不会改变函数签名,因此在此场景下尤为适用:

int& IntArray::operator[](const int index)
{
    if (index < 0 || index >= getLength())
        throw index;

    return m_data[index];
}

若用户传入非法下标,operator[] 将抛出 int 类型的异常。

构造函数失败时的处理

构造函数是另一类可充分利用异常的场景。若构造函数因某种原因必须失败(例如用户输入无效),只需抛出异常以表明对象创建失败。此时,对象构造被中止,所有已构造并初始化完毕的成员变量会按常规流程析构;但类本身的析构函数不会被调用(因为对象未完成构造)。因此,不能把已分配资源的清理工作完全依赖于该析构函数。

这引出一个问题:若在构造函数中已分配资源,而异常在构造完毕前发生,如何确保这些资源被正确清理?一种做法是将可能失败的代码置于 try 块内,用对应 catch 块捕获异常并执行必要清理,随后重新抛出异常(将在 《重新抛出异常》讨论)。然而,这会使代码冗长且易出错,尤其在类需分配多种资源时。

幸运的是,有更佳方案:利用“即使构造函数失败,成员变量仍会被析构”这一事实,把资源分配放在类成员内部(而非直接在构造函数中)。当成员析构时,它们可自行完成清理。

示例:

#include <iostream>

class Member
{
public:
    Member()
    {
        std::cerr << "Member allocated some resources\n";
    }

    ~Member()
    {
        std::cerr << "Member cleaned up\n";
    }
};

class A
{
private:
    int m_x{};
    Member m_member;

public:
    A(int x) : m_x{x}
    {
        if (x <= 0)
            throw 1;
    }

    ~A()
    {
        std::cerr << "~A\n"; // 不会被调用
    }
};

int main()
{
    try
    {
        A a{0};
    }
    catch (int)
    {
        std::cerr << "Oops\n";
    }

    return 0;
}

输出:

Member allocated some resources
Member cleaned up
Oops

类 A 抛出异常时,其成员全部析构,m_member 的析构函数被调用,从而完成资源清理。

这也是为何极力倡导 RAII(见 《栈展开》)的原因之一:即便在异常情形下,遵循 RAII 的类仍能自我清理。

然而,为管理资源而编写专用类(如 Member)并不高效。C++ 标准库已提供遵循 RAII 的资源管理类,如文件类 std::fstream(见 28.6《基本文件 I/O》)以及动态内存类 std::unique_ptr 等智能指针(见 22.1《智能指针与移动语义简介》)。

例如,将:

class Foo
{
private:
    int* ptr; // Foo 负责分配/释放
};

改写为:

class Foo
{
private:
    std::unique_ptr<int> ptr; // std::unique_ptr 负责分配/释放
};

在前者中,若构造函数在分配动态内存后失败,Foo 必须手动完成清理;而后者中,若构造函数失败,ptr 的析构函数会自动释放内存,Foo 无需任何显式清理。

异常类

使用基本数据类型(如 int)作为异常类型的一大弊端在于语义模糊。更严重的是,当 try 块内存在多个语句或函数调用时,无法区分异常来源:

// 使用上述 IntArray 重载 operator[]

try
{
    int* value{ new int{ array[index1] + array[index2] } };
}
catch (int value)
{
    // 我们捕获的到底是什么?
}

此例中,若捕获 int 异常,无法得知究竟是数组下标越界、整数加法溢出,还是 new 分配内存失败。即便抛出 const char* 异常可描述错误,也无法对不同来源的异常进行差异化处理。

解决之道是使用异常类。异常类即专为抛出而设计的普通类。以下示例为 IntArray 设计了一个简单异常类:

#include <string>
#include <string_view>

class ArrayException
{
private:
    std::string m_error;

public:
    ArrayException(std::string_view error)
        : m_error{ error }
    {
    }

    const std::string& getError() const { return m_error; }
};

完整示例:

#include <iostream>
#include <string>
#include <string_view>

class ArrayException
{
private:
    std::string m_error;

public:
    ArrayException(std::string_view error)
        : m_error{ error }
    {
    }

    const std::string& getError() const { return m_error; }
};

class IntArray
{
private:
    int m_data[3]{}; // 为简化,假设数组长度为 3

public:
    IntArray() {}

    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException{ "Invalid index" };

        return m_data[index];
    }
};

int main()
{
    IntArray array;

    try
    {
        int value{ array[5] }; // 越界访问
    }
    catch (const ArrayException& exception)
    {
        std::cerr << "数组异常发生(" << exception.getError() << ")\n";
    }
}

借助异常类,异常对象可携带错误描述,为定位问题提供上下文。由于 ArrayException 是独立类型,可专门捕获数组类抛出的异常,并与其他异常区别对待。

注意:异常处理器应以引用捕获类异常对象,而非值捕获。这样可避免编译器在捕获点生成异常副本(类对象复制代价高),并可防止派生异常类出现对象切片(稍后讨论)。除非有特殊理由,应避免以指针捕获异常。

最佳实践

  • 基本类型异常可按值捕获(复制开销小)。
  • 类类型异常应按(const)引用捕获,以避免昂贵复制及对象切片。

异常与继承

由于类可作为异常被抛出,且类可继承,因此需考虑使用继承类作为异常时的行为。事实上,异常处理器不仅能匹配指定类型,还能匹配其所有派生类型:

#include <iostream>

class Base
{
public:
    Base() {}
};

class Derived : public Base
{
public:
    Derived() {}
};

int main()
{
    try
    {
        throw Derived();
    }
    catch (const Base& base)
    {
        std::cerr << "caught Base";
    }
    catch (const Derived& derived)
    {
        std::cerr << "caught Derived";
    }

    return 0;
}

程序输出:

caught Base

原因如下:

  1. 派生类可被基类处理器匹配(Derived is-a Base)。
  2. C++ 按顺序查找处理器,先检查 Base 处理器,发现匹配即执行,Derived 处理器不再被测试。

要使程序按预期工作,应调整 catch 顺序:

#include <iostream>

class Base
{
public:
    Base() {}
};

class Derived : public Base
{
public:
    Derived() {}
};

int main()
{
    try
    {
        throw Derived();
    }
    catch (const Derived& derived)
    {
        std::cerr << "caught Derived";
    }
    catch (const Base& base)
    {
        std::cerr << "caught Base";
    }

    return 0;
}

规则
派生异常类的处理器应排在基类处理器之前。

利用基类处理器捕获派生异常的能力极为有用。

std::exception

标准库中的许多类和运算符在失败时会抛出异常类。例如,operator new 在无法分配足够内存时抛出 std::bad_alloc;失败的 dynamic_cast 抛出 std::bad_cast 等等。截至 C++20,共有 28 种异常类可被抛出,且每个新标准都会增加更多。

好消息是,所有这些异常类均派生自单一基类 std::exception(定义于头文件 )。std::exception 是一个小型接口类,专作所有标准库抛出异常的基类。

多数情况下,我们并不关心具体是 bad_alloc、bad_cast 还是其他异常,只需知道发生灾难性错误即可。借助 std::exception,可设置一个处理器捕获 std::exception,从而一网打尽所有派生异常:

#include <cstddef>      // std::size_t
#include <exception>    // std::exception
#include <iostream>
#include <limits>
#include <string>

int main()
{
    try
    {
        // 使用标准库的代码
        // 为演示故意触发异常
        std::string s;
        s.resize(std::numeric_limits<std::size_t>::max()); // 触发 std::length_error 或内存分配异常
    }
    catch (const std::exception& exception)
    {
        std::cerr << "标准异常: " << exception.what() << '\n';
    }
    return 0;
}

作者机器输出:

标准异常: string too long

std::exception 拥有虚成员函数 what(),返回描述异常的 C 风格字符串。多数派生类重写该函数以提供特定信息。注意,该字符串仅作描述,请勿用于比较,因不同编译器实现可能不同。

有时需针对特定异常类型做不同处理,可添加该类型的专用处理器,其余异常落入基类处理器:

try
{
    // 使用标准库的代码
}
catch (const std::length_error& exception)
{
    std::cerr << "内存耗尽!\n";
}
catch (const std::exception& exception)
{
    std::cerr << "标准异常: " << exception.what() << '\n';
}

此例中,std::length_error 及其派生异常被第一个处理器捕获,其余 std::exception 派生异常落入第二个处理器。

这种继承体系使我们既能用特化处理器捕获具体派生类,也能用基类处理器捕获整个异常层次,实现精准控制且减少代码量。

直接使用标准异常

没有任何代码会抛出 std::exception 本身,您也不应这么做。若标准异常类已满足需求,可直接抛出。可在 cppreference 查阅全部标准异常列表。

std::runtime_error(位于 )是常用选择,因其名称通用,构造函数可接受自定义信息:

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

int main()
{
    try
    {
        throw std::runtime_error("发生糟糕的事");
    }
    catch (const std::exception& exception)
    {
        std::cerr << "标准异常: " << exception.what() << '\n';
    }
    return 0;
}

输出:

标准异常: 发生糟糕的事

从 std::exception 或 std::runtime_error 派生自定义类

您可以从 std::exception 派生自定义异常类,并重写虚函数 what() const。以下示例将 ArrayException 改为派生自 std::exception:

#include <exception>
#include <iostream>
#include <string>
#include <string_view>

class ArrayException : public std::exception
{
private:
    std::string m_error{};

public:
    ArrayException(std::string_view error)
        : m_error{error}
    {
    }

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

class IntArray
{
private:
    int m_data[3]{};

public:
    IntArray() {}
    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException("Invalid index");
        return m_data[index];
    }
};

int main()
{
    IntArray array;
    try
    {
        int value{ array[5] };
    }
    catch (const ArrayException& exception) // 派生类处理器在前
    {
        std::cerr << "数组异常 (" << exception.what() << ")\n";
    }
    catch (const std::exception& exception)
    {
        std::cerr << "其他 std::exception (" << exception.what() << ")\n";
    }
}

注意虚函数 what() 带有 noexcept 说明符,承诺自身不抛异常,因此重写版本亦需 noexcept。

由于 std::runtime_error 已具备字符串管理能力,亦常被用作基类。其构造函数接受 C 风格字符串或 const std::string&。下面示例改用 std::runtime_error 作为基类:

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

class ArrayException : public std::runtime_error
{
public:
    // 沿用 std::runtime_error 的接口,接受 const std::string&
    ArrayException(const std::string& error)
        : std::runtime_error{error}
    {
    }
    // 无需重写 what(),可直接使用 std::runtime_error::what()
};

class IntArray
{
private:
    int m_data[3]{};

public:
    IntArray() {}
    int getLength() const { return 3; }

    int& operator[](const int index)
    {
        if (index < 0 || index >= getLength())
            throw ArrayException("Invalid index");
        return m_data[index];
    }
};

int main()
{
    IntArray array;
    try
    {
        int value{ array[5] };
    }
    catch (const ArrayException& exception)
    {
        std::cerr << "数组异常 (" << exception.what() << ")\n";
    }
    catch (const std::exception& exception)
    {
        std::cerr << "其他 std::exception (" << exception.what() << ")\n";
    }
}

您可自行决定是编写独立异常类、直接使用标准异常,还是从 std::exception 或 std::runtime_error 派生——三者皆视需求而定,皆为合理做法。

异常对象的生命周期

被抛出的异常对象通常是栈上的临时变量或局部变量。然而,异常处理过程可能导致栈回卷,从而销毁函数内的局部变量。那么异常对象如何在栈回卷后仍然存活?

当异常被抛出时,编译器会在异常处理专用内存(位于调用栈外)中复制一份异常对象。因此,无论栈如何回卷,异常对象始终存在,直至异常处理完毕。

这意味着被抛对象必须可复制(即使实际未回卷栈)。智能编译器可在特定情境下使用移动或省略复制。

提示
异常对象需可复制。

以下示例尝试抛出不可复制的 Derived 对象:

#include <iostream>

class Base
{
public:
    Base() {}
};

class Derived : public Base
{
public:
    Derived() {}
    Derived(const Derived&) = delete; // 不可复制
};

int main()
{
    Derived d{};

    try
    {
        throw d; // 编译错误:Derived 拷贝构造函数被删除
    }
    catch (const Derived& derived)
    {
        std::cerr << "捕获 Derived";
    }
    catch (const Base& base)
    {
        std::cerr << "捕获 Base";
    }

    return 0;
}

编译器将报错:Derived 拷贝构造函数不可用,编译停止。

异常对象不应持有指向栈上对象的指针或引用。若异常导致栈回卷,这些指针或引用将悬空。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"