C++的异常处理机制中,throw关键字承担了抛出异常和重新抛出异常两个角色。很多人在catch块里捕获异常后,想继续向上层传递,但往往会纠结到底应该写throw还是throw e。这个选择并不只是语法上的差异,它直接决定了原始异常类型能否被完整保留。比如一个继承体系下的自定义异常,如果通过throw e重新抛出,很可能触发对象切片,上层catch匹配到的就不再是原本的派生类异常。因此搞清楚throw的用法,尤其是异常重新抛出的细节,对编写健壮的C++程序非常重要。

throw关键字的基本用法
在C++中,throw用于主动抛出一个异常对象。语法上可以抛出任意可拷贝的类型,包括内建类型、标准库异常以及自定义类。抛出的表达式会被用来构造一个临时对象,这个临时对象由异常处理机制管理。例如throw std::runtime_error("something wrong");会先构造一个std::runtime_error临时对象,然后将其交给运行时异常处理流程。catch块根据异常对象的静态类型进行匹配,如果异常类型与catch参数类型兼容,则进入对应的处理分支。
需要注意,throw表达式中的对象不一定是左值。如果抛出的是一个指针,那么catch到的也是指针,但这会带来内存管理上的隐患。一般建议抛出对象而非指针,除非有特殊理由。标准库中的异常类都继承自std::exception,通过引用捕获可以避免不必要的拷贝。例如catch (const std::exception& e)比catch (std::exception e)更高效,也更安全,因为按值捕获同样可能引发切片。下面是一段简单的抛出和捕获代码:
#include <iostream>
#include <stdexcept>
void checkValue(int value) {
if (value < 0) {
throw std::out_of_range("value must not be negative");
}
}
int main() {
try {
checkValue(-1);
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << std::endl;
}
return 0;
}上面的代码中,throw抛出了一个std::out_of_range对象,catch通过基类引用捕获到它。由于使用引用,即便异常对象实际是派生类,也能保留虚函数的多态行为。如果把catch参数写成std::exception e,就会发生拷贝构造,派生类部分被切除,e.what()调用的仍然是std::exception的版本,这在实际项目中常常是bug的来源。
throw与throw e的核心区别
在catch块内部,throw后面可以不带任何表达式,写成单独的throw;。这种形式称为异常重新抛出,它会把当前正在处理的异常原样重新抛出去。这里的“原样”意味着异常对象的类型、内容、以及所有附加信息都不会改变。例如进入catch之前抛出的本来就是std::runtime_error的派生类,那么throw;重新抛出后,上层catch看到的仍然是那个派生类对象。这是重新抛出异常最安全的方式。
相比之下,throw e;并不是真正意义上的重新抛出,而是把当前捕获到的异常对象e作为新的异常再次抛出。问题在于,e的类型取决于catch子句的声明类型。如果catch是按基类引用声明的,那么e的静态类型就是基类,尽管它可能引用着一个派生类对象。执行throw e;时,编译器会以e的静态类型为准来构造新的异常对象,派生类部分的信息就会丢失。这个现象叫对象切片。下面通过代码对比两种写法的影响:
#include <iostream>
#include <exception>
#include <typeinfo>
class BaseException : public std::exception {
public:
const char* what() const noexcept override {
return "BaseException";
}
};
class DerivedException : public BaseException {
public:
const char* what() const noexcept override {
return "DerivedException";
}
};
void throwDerived() {
throw DerivedException();
}
void rethrowWithThrow() {
try {
throwDerived();
} catch (const BaseException& e) {
std::cout << "caught as: " << e.what() << std::endl;
throw;
}
}
void rethrowWithThrowE() {
try {
throwDerived();
} catch (const BaseException& e) {
std::cout << "caught as: " << e.what() << std::endl;
throw e;
}
}
int main() {
try {
rethrowWithThrow();
} catch (const BaseException& e) {
std::cout << "outer caught: " << e.what() << std::endl;
}
try {
rethrowWithThrowE();
} catch (const BaseException& e) {
std::cout << "outer caught: " << e.what() << std::endl;
}
return 0;
}运行这段代码可以看到,rethrowWithThrow的外层catch输出的是DerivedException,说明派生类信息保留了下来。而rethrowWithThrowE的外层catch输出的是BaseException,说明切片已经发生。因此,只要目标是继续传递当前异常,就应当使用不带操作数的throw;,而不是throw e;。只有当你确实需要抛出一个新的、不同类型的异常对象时,才使用throw new_exception;的形式。
还有一个容易忽视的点:throw;只能在catch块或catch块调用的函数中使用。如果在没有活跃异常的位置写throw;,程序会调用std::terminate。这是因为单独的throw;依赖当前线程正在处理异常这一上下文。在析构函数中尤其要小心,如果析构函数内部需要重新抛出异常,必须保证当前确实存在活跃异常,否则会终止程序。
异常重新抛出的典型应用场景
异常重新抛出最常见的场景是在捕获异常后执行一些清理或日志操作,再将异常继续向上传递。例如一个函数负责打开文件并读取数据,如果读取过程中抛出异常,函数可以在catch块中关闭文件句柄、释放缓冲区,然后使用throw;把原始异常重新抛给调用者。这样既保证了资源不被泄漏,又不破坏异常的原始信息。代码结构大致如下:
#include <iostream>
#include <fstream>
#include <stdexcept>
void processFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw std::runtime_error("failed to open file");
}
try {
std::string line;
while (std::getline(file, line)) {
if (line.empty()) {
throw std::runtime_error("empty line encountered");
}
// 处理每一行
}
} catch (...) {
std::cout << "cleaning up file resources" << std::endl;
file.close();
throw;
}
}
int main() {
try {
processFile("test.txt");
} catch (const std::exception& e) {
std::cout << "main caught: " << e.what() << std::endl;
}
return 0;
}另一个常见场景是异常转换。有时底层模块抛出的异常类型对上层来说过于细化,希望将其包装成更高层次的异常。这时可以在catch块中捕获底层异常,记录必要信息,然后抛出一个新的异常对象。这种情况下throw new_exception;是合理的,但要注意保留原始异常信息。C++11提供了std::throw_with_nested,它可以在抛出新的异常时把当前异常作为嵌套异常附加进去,上层可以通过std::rethrow_if_nested来访问。下面是一个简单示例:
#include <iostream>
#include <stdexcept>
#include <exception>
#include <string>
class DatabaseException : public std::runtime_error {
public:
DatabaseException(const std::string& msg)
: std::runtime_error(msg) {}
};
void lowLevelQuery() {
throw std::runtime_error("connection lost");
}
void highLevelService() {
try {
lowLevelQuery();
} catch (const std::exception& e) {
std::throw_with_nested(DatabaseException("query failed"));
}
}
int main() {
try {
highLevelService();
} catch (const DatabaseException& e) {
std::cout << "service error: " << e.what() << std::endl;
try {
std::rethrow_if_nested(e);
} catch (const std::exception& nested) {
std::cout << "nested error: " << nested.what() << std::endl;
}
}
return 0;
}这段代码中,底层异常connection lost被包装进DatabaseException,但原始异常并未丢失,而是以嵌套异常的形式存储在DatabaseException对象中。上层捕获后可以通过rethrow_if_nested把嵌套异常重新抛出并处理。这种方式适合构建分层的错误处理体系,同时保留完整的错误上下文。不过使用嵌套异常会让代码复杂度上升,适合对错误信息要求较高的系统级模块。
异常重新抛出时的资源安全与注意事项
在C++中构造和析构对象时,异常安全是一个需要重点考虑的问题。如果在catch块中执行throw;,当前异常会继续传播,此时所有局部对象的析构函数会按照栈展开的顺序被执行。这就要求这些析构函数本身不能抛出异常。C++标准规定,如果栈展开过程中析构函数抛出了异常而当前已经有一个异常在传播,程序会调用std::terminate直接终止。因此,在可能被异常穿透的类中,析构函数应该声明为noexcept,并内部吞掉所有可能的异常。
另一个需要留意的是catch块中的重新抛出与参数传递。catch参数如果声明为按值捕获,那么重新抛出throw;时,重新抛出的仍然是原始异常,而不是catch参数的那个副本。这点有时会让人误解。比如catch (std::runtime_error e) {}内部写throw;,重新抛出的还是最初进入catch之前的那个异常对象,而不是e。这保证了即使catch参数按值捕获,单独throw;也能保留原始异常的类型信息,避免切片。因此即便catch写成了按值传递,throw;也比throw e;更可靠。
在并发环境中,异常重新抛出还涉及线程边界的处理。一个线程内的异常不能被另一个线程直接捕获,如果需要在线程间传递异常,可以使用std::exception_ptr。std::current_exception可以捕获当前异常并返回一个exception_ptr,然后在另一个线程中通过std::rethrow_exception重新抛出。这个过程与catch中的throw;不同,但本质上也是为了在捕获之后延迟抛出异常,适合实现线程池中的任务错误汇总。
总结来说,C++中throw有两种用法,一是抛出新的异常对象,二是在catch块中重新抛出当前异常。如果你希望异常继续向上层传播且不丢失类型,务必使用不带操作数的throw;。只有当你需要抛出一个新的异常替代当前异常时,才使用throw new_exception;。明确这两者的区别,能帮助你在处理资源清理、日志记录、异常转换等场景时写出更加安全和可维护的代码。