异常与成员函数
截至目前,教程中所见异常皆用于非成员函数。然而,异常在成员函数中同样适用,尤其在重载运算符中更为便捷。以下示例为一个简单整型数组类中的重载下标运算符:
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
原因如下:
- 派生类可被基类处理器匹配(Derived is-a Base)。
- 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(定义于头文件
多数情况下,我们并不关心具体是 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 拷贝构造函数不可用,编译停止。
异常对象不应持有指向栈上对象的指针或引用。若异常导致栈回卷,这些指针或引用将悬空。