C++异常处理深度解析:从原理到实践,构建健壮代码的基石
1. 项目概述为什么C异常处理值得你花时间深究在C开发这条路上无论是刚入门的新手还是摸爬滚打多年的老手都绕不开一个既基础又复杂的主题异常处理。你可能已经习惯了用if (ptr nullptr)来检查空指针用函数返回值来标识错误但当你面对一个大型项目函数调用层级深、资源管理复杂时传统的错误处理方式很快就会变得像一团乱麻代码里充斥着if-else和错误码检查核心业务逻辑反而被淹没了。这就是C异常处理机制存在的核心价值——它提供了一种将错误处理逻辑与正常业务逻辑分离的标准化方式。简单来说异常处理就是一套“消防预案”。你的程序正常运行时是“安全状态”一旦发生预料之外的“火灾”比如内存不足、文件打开失败、除零错误异常机制能让你不必在每个房间函数都准备灭火器错误检查而是建立一个统一的“消防通道”异常抛出和“消防站”异常捕获让程序能够有序地报告错误、清理现场并尝试恢复或优雅退出。最近社区里关于visual c redistributable、vscode配置c/c环境以及各种c面试题、c八股文的讨论热度很高而异常处理恰恰是面试中高频出现的深度考点也是写出健壮、可维护的C代码的基石。无论是开发c小游戏处理用户输入还是用opencv c做图像处理时应对摄像头断开亦或是实现c多线程时处理线程同步失败一套清晰的异常处理策略都至关重要。这篇文章我将从一个写过无数try-catch块、也踩过无数相关坑的开发者角度带你彻底拆解C的异常处理机制。我们不只讲try、throw、catch这三个关键字怎么用更要深入背后异常抛出时栈是如何展开Stack Unwinding的构造函数和析构函数中的异常有什么“坑”noexcept关键字到底在向编译器和运行时承诺什么为什么说异常安全Exception Safety有不同等级我会结合具体的代码示例比如在c结构体链表操作中或是模拟c回调函数调用失败时来展示异常处理的最佳实践和常见陷阱。目标是让你读完不仅能应付面试更能真正在项目中自信、正确地使用异常写出更健壮的C代码。2. 异常处理的核心机制与工作原理解析要玩转异常必须先理解它的运行机制。这不像学一个库函数调用那么简单它涉及到程序执行流的根本性改变并且与编译器和运行时库深度绑定。2.1 基本语法与执行流程try,throw,catch这三个关键字构成了异常处理的基本骨架它们的协作流程是理解一切的基础。#include iostream #include stdexcept double divide(int a, int b) { if (b 0) { throw std::runtime_error(Division by zero!); } return static_castdouble(a) / b; } int main() { int x 10, y 0; try { double result divide(x, y); std::cout Result: result std::endl; } catch (const std::runtime_error e) { std::cerr Caught an exception: e.what() std::endl; } catch (...) { // 捕获所有其他类型的异常 std::cerr Caught an unknown exception! std::endl; } std::cout Program continues after exception handling. std::endl; return 0; }执行流程拆解try块定义监控范围程序进入try块正常执行。try块定义了异常的“监控区”这里面的代码如果抛出异常就会被后续的catch块处理。throw抛出异常对象当divide函数中b 0条件成立时throw语句被执行。这里的关键是throw并不是简单地跳转而是构造一个异常对象这里是std::runtime_error的临时对象。这个对象可以是我们自定义类的实例但更常见的是使用标准库中派生自std::exception的异常类型。栈展开Stack Unwinding这是异常机制最核心也最微妙的部分。当throw发生后程序会立即停止当前的正常执行流开始从抛出点向外层逐层退出函数调用栈即“栈展开”。在退出每一层栈帧即每一个函数作用域时一个重要的事情发生了该作用域内所有已构造的局部对象的析构函数会被自动调用。这是确保资源不泄露如内存、文件句柄、锁的生命线。例如如果try块里或divide函数里定义了std::vector或std::fstream对象在栈展开经过它们时它们的析构函数会被正确调用以释放资源。catch块匹配并处理栈展开会一直进行直到找到一个能处理该类型异常的catch块。catch块通过其参数类型来匹配异常对象的类型。匹配规则类似于函数参数匹配允许通过基类引用捕获派生类异常这正是为什么推荐自定义异常继承自std::exception。一旦匹配成功程序就进入这个catch块执行处理代码。catch (...)是“全捕获”子句能匹配任何类型的异常通常用于最后的兜底处理。处理后的执行流catch块执行完毕后程序会继续执行该catch块之后的所有代码如上例中输出 “Program continues…”。异常被成功捕获和处理程序得以继续运行。注意如果抛出的异常始终没有被任何catch块捕获即栈展开到了main函数之外std::terminate会被调用默认行为是终止程序。这在调试时往往表现为程序崩溃。2.2 栈展开Stack Unwinding的深度剖析栈展开是异常安全性的基石但也是最容易让人困惑的地方。我们通过一个更复杂的例子来看。#include iostream #include memory class Resource { public: Resource(int id) : id_(id) { std::cout Resource id_ acquired.\n; } ~Resource() { std::cout Resource id_ released.\n; } private: int id_; }; void functionC() { Resource res3(3); throw std::runtime_error(Error from functionC); // res3 的析构函数会在 throw 之后的栈展开中被调用 } void functionB() { Resource res2(2); functionC(); // 如果 functionC 抛出异常res2 的析构函数会在栈展开经过 functionB 时被调用 } void functionA() { Resource res1(1); try { functionB(); } catch (const std::exception e) { std::cerr Caught in functionA: e.what() std::endl; } } int main() { functionA(); return 0; }运行这个程序输出将会是Resource 1 acquired. Resource 2 acquired. Resource 3 acquired. Resource 3 released. Resource 2 released. Caught in functionA: Error from functionC Resource 1 released.关键点解析析构顺序输出清晰地展示了栈展开的“反向”顺序res3-res2。这正是函数调用栈后进先出的体现。functionC中的res3最先构造、最后析构在functionC栈帧退出时functionB中的res2随后析构。资源管理即使functionC因异常而提前退出res3和res2的资源在这个例子里是模拟的都得到了正确释放。这就是RAIIResource Acquisition Is Initialization理念与异常机制完美结合的魅力将资源管理封装在对象生命周期中依赖析构函数自动释放从而在异常发生时也能保证资源安全。functionA中的res1注意res1的析构发生在catch块执行之后。因为res1是在functionA的局部作用域内而catch块也在同一个作用域内。只有当functionA这个函数完全退出时catch块执行完毕继续执行到函数末尾res1的生命周期才结束其析构函数被调用。栈展开的潜在陷阱如果析构函数本身也抛出异常会怎样这会导致程序立即调用std::terminate终止。因为C运行时无法同时处理两个活跃的异常。因此析构函数必须绝不抛出异常这是一个铁律。通常需要在析构函数内部用try-catch(...)吞掉所有可能的异常。2.3 异常对象与异常类型体系throw抛出的可以是一切可以被复制的对象但最佳实践是使用标准库或自定义的异常类。标准异常体系stdexcept头文件定义了一系列常用的标准异常它们都继承自std::exception。std::logic_error程序逻辑错误理论上可以在编码阶段避免。std::invalid_argument无效参数。std::out_of_range访问越界比如vector::at。std::length_error试图创建超出最大长度的对象。std::runtime_error运行时错误通常由外部因素引起难以在编码时预知。std::overflow_error/std::underflow_error算术溢出/下溢。std::system_error系统调用错误C11引入包含错误码。使用标准异常的好处是它们都提供了what()成员函数返回错误描述并且有清晰的类型层次便于捕获。自定义异常类当标准异常不足以描述你的错误时可以自定义。class MyNetworkException : public std::runtime_error { public: MyNetworkException(const std::string msg, int errorCode) : std::runtime_error(msg), errorCode_(errorCode) {} int getErrorCode() const { return errorCode_; } private: int errorCode_; }; // 使用 void connectToServer() { if (/* 连接失败 */) { throw MyNetworkException(Connection failed, 1001); } } // 捕获时可以精确匹配 try { connectToServer(); } catch (const MyNetworkException e) { std::cerr Network error e.getErrorCode() : e.what() std::endl; } catch (const std::exception e) { // 兜底捕获其他标准异常 }实操心得自定义异常时务必以public方式继承自std::exception或其派生类如std::runtime_error。这样既可以利用多态通过基类引用捕获又能通过what()获取基本信息还能添加你自己的额外信息如错误码、错误位置等。避免直接throw一个基本类型如throw “error”;这会让错误处理变得难以维护。3. 异常安全Exception Safety的等级与实现策略异常处理不仅仅是try-catch更重要的是保证当异常发生时你的程序状态尤其是数据不会崩溃或处于不可预测的状态。这就是“异常安全”。它通常被分为几个等级理解这些等级是编写健壮C代码的关键。3.1 异常安全保证的三个级别基本保证Basic Guarantee如果异常被抛出程序仍处于有效状态。没有资源泄漏所有对象仍可被安全地析构但程序的具体状态可能是未知的比如一个容器的部分元素可能已被修改。这是最低要求通常通过RAII来保证资源不泄露。强保证Strong Guarantee如果操作因异常而失败程序状态完全回滚到操作调用之前就像这个操作从未执行过一样。这通常意味着操作具有“事务性”Transactional。实现强保证往往需要额外的开销比如先在一个临时副本上操作成功后再用noexcept的swap来替换原对象。不抛异常保证Nothrow Guarantee承诺该操作绝不会抛出任何异常。这通常适用于简单的getter、析构函数、移动操作等。在C11后可以用noexcept关键字来显式声明。3.2 实现异常安全的关键技术RAII与copy-and-swapRAII资源获取即初始化是实现基本保证的基石。其核心思想是将资源的生命周期与一个对象的生命周期绑定。在构造函数中获取资源在析构函数中释放资源。这样无论函数是正常返回还是因异常退出只要对象离开其作用域析构函数就会被调用资源就能确保被释放。智能指针std::unique_ptr,std::shared_ptr、容器、文件流等都是RAII的典范。// 不安全的做法 void unsafeFunction() { int* ptr new int[100]; someOperationThatMayThrow(); // 如果这里抛出异常内存泄漏 delete[] ptr; } // 安全的RAII做法 void safeFunction() { std::vectorint vec(100); // 使用vector管理内存 someOperationThatMayThrow(); // 即使这里抛出异常vec的析构函数也会自动释放内存 }copy-and-swap惯用法是实现强保证的经典技术。其核心是任何可能失败的操作都先在一个局部副本上进行待所有操作都成功后再用一个不会失败的操作如swap来替换原对象的状态。class StringArray { public: // ... 其他成员函数 void append(const std::string str) { // 1. 创建当前状态的副本 StringArray temp(*this); // 2. 在副本上执行可能失败的操作 temp.data_.push_back(str); // push_back 可能因内存不足而抛出 std::bad_alloc // 3. 如果上一步成功用 noexcept 的 swap 交换状态 swap(temp); // 假设 swap 被实现为 noexcept } void swap(StringArray other) noexcept { using std::swap; swap(data_, other.data_); } private: std::vectorstd::string data_; };在这个例子中append操作提供了强保证如果push_back失败抛出std::bad_alloc异常会传播出去但*this对象的状态完全没有被改变因为所有修改都发生在临时对象temp上。只有所有操作都成功才会通过swap原子性地更新状态。注意事项copy-and-swap虽然强大但涉及一次完整的拷贝可能有性能开销。需要根据实际情况在性能和异常安全之间权衡。对于许多标准库容器操作如std::vector::push_back它们自身在可能重新分配内存时内部已经实现了类似强保证或至少基本保证的机制。3.3 构造函数与析构函数中的异常处理这是异常安全中的两个特殊且重要的场景。构造函数中的异常如果一个对象的构造函数在执行过程中抛出异常那么这个对象的构造被视为失败其析构函数不会被调用因为对象并未完全构造成功。但是对于该对象的所有已成功构造的成员子对象和基类子对象它们的析构函数会被依次调用顺序与构造顺序相反。这就是为什么成员变量最好也使用RAII对象如智能指针、标准容器这样即使构造函数中途失败已分配的资源也能被自动清理。class Widget { public: Widget() : ptr1(new int(42)), ptr2(new int(100)) { // 假设 new int(100) 成功但接下来抛异常 throw std::runtime_error(Construction failed!); // 此时ptr1 指向的内存会泄漏吗不会 // 因为ptr1是内置指针不是RAII对象。这里会内存泄漏 } private: int* ptr1; // 危险裸指针 int* ptr2; };为了避免上述内存泄漏必须使用RAIIclass SafeWidget { public: SafeWidget() : uptr1(std::make_uniqueint(42)), uptr2(std::make_uniqueint(100)) { throw std::runtime_error(Construction failed!); // 异常抛出时uptr1和uptr2的析构函数会被调用自动释放内存。 } private: std::unique_ptrint uptr1; // 安全 std::unique_ptrint uptr2; };析构函数中的异常如前所述析构函数绝不能抛出异常。如果析构函数中执行的操作可能失败比如关闭文件、网络连接必须在其内部进行捕获和处理防止异常传播到析构函数之外。class FileHandler { public: ~FileHandler() noexcept { // 最好声明为 noexcept try { if (file_.is_open()) { file_.close(); // close() 可能失败 } } catch (...) { // 记录日志但绝不能重新抛出 std::cerr Failed to close file in destructor, ignoring.\n; // 通常在这里会记录到日志系统而不是cout/cerr } } private: std::fstream file_; };4. C11/17/20 中异常处理的现代特性现代C标准引入了一些重要特性来增强和优化异常处理。4.1noexcept关键字性能优化与契约声明noexcept有两个主要作用异常规范声明一个函数不会抛出任何异常。这不同于旧的throw()声明。noexcept是函数接口的一部分调用者可以依赖这个承诺。void mySwap(int a, int b) noexcept { int temp a; a b; b temp; }运算符作为一个运算符noexcept(expression)可以在编译期判断一个表达式是否被声明为不抛异常。这在模板元编程和条件noexcept声明中非常有用。template typename T void swap(T a, T b) noexcept(noexcept(a.swap(b))) { a.swap(b); } // 这个swap函数是否noexcept取决于 T::swap 是否noexcept。为什么noexcept重要性能优化编译器知道一个函数是noexcept后可以生成更高效的代码因为它不需要为栈展开准备复杂的异常处理表exception table。移动语义标准库中的许多操作如std::vector::resize、std::vector::push_back在元素类型具有noexcept移动构造函数时会优先使用移动而非拷贝因为这能提供更强的异常安全保证移动操作失败时源对象状态可能改变但至少不会泄漏。契约与文档它向函数的用户明确声明了行为是接口设计的一部分。实操心得不要滥用noexcept。只在你绝对确定函数不会抛出任何异常并且未来也不会修改为可能抛出异常时才使用它。将一个可能抛出的函数错误地声明为noexcept当异常真的发生时程序会直接调用std::terminate终止而不是进行正常的栈展开和捕获这通常是灾难性的。对于析构函数、移动操作、交换操作应尽量使其成为noexcept。4.2 异常指针std::exception_ptr与跨线程异常传递在多线程编程中一个线程中抛出的异常无法被另一个线程直接捕获。C11引入了std::exception_ptr来解决这个问题。它是一个共享的、类型擦除的智能指针可以捕获任何异常通过std::current_exception()并稍后在另一个线程中重新抛出通过std::rethrow_exception。#include iostream #include thread #include future #include exception void mayThrow() { throw std::runtime_error(Exception from thread); } int main() { std::exception_ptr eptr; std::thread t([eptr] { try { mayThrow(); } catch (...) { // 捕获所有异常并存储到 exception_ptr 中 eptr std::current_exception(); } }); t.join(); // 在主线程中处理子线程的异常 if (eptr) { try { std::rethrow_exception(eptr); } catch (const std::exception e) { std::cerr Caught exception from thread: e.what() std::endl; } } return 0; }std::future也利用了这种机制当你调用future::get()时如果异步操作中抛出了异常它会被重新抛出到调用get()的线程中。4.3 嵌套异常std::nested_exception在处理异常时有时你想捕获一个异常进行一些处理比如记录日志、转换错误类型然后抛出一个新的异常但同时又不丢失原始的异常信息。std::nested_exception和std::throw_with_nested提供了这种能力。#include iostream #include exception #include stdexcept void lowLevelFunction() { throw std::runtime_error(Low-level I/O error); } void highLevelFunction() { try { lowLevelFunction(); } catch (...) { // 捕获低级异常包装成更高级的异常再抛出同时保留原始异常 std::throw_with_nested(std::logic_error(High-level operation failed)); } } int main() { try { highLevelFunction(); } catch (const std::exception e) { std::cerr Caught: e.what() std::endl; // 尝试解包嵌套的异常 try { std::rethrow_if_nested(e); } catch (const std::exception nested) { std::cerr Nested: nested.what() std::endl; } } return 0; }这对于构建清晰的错误传播链非常有用尤其是在分层架构的软件中。5. 异常处理的最佳实践、常见陷阱与性能考量掌握了机制和特性最终要落地到如何用好。这里总结一些血泪教训换来的最佳实践。5.1 何时该用异常何时不该用应该使用异常的情况无法在本地处理的错误当函数检测到一个错误但它自身缺乏足够的上下文来恢复或做出有意义的处理时应该抛出异常让上层调用者决定如何应对。例如一个解析配置文件的函数遇到格式错误时它自己无法决定是退出程序、使用默认值还是提示用户应该抛出异常。构造函数失败构造函数没有返回值报告失败的唯一标准方式就是抛出异常。操作符重载失败像operator[]、operator/这类操作符很难通过返回值来有效表示错误使用异常更合适。跨多层函数调用的错误传播当错误需要穿透很多层中间函数才能到达处理逻辑时使用异常可以避免每一层都进行错误码检查和传递保持代码清晰。不应该使用异常的情况可预见的、频繁发生的控制流例如在遍历中查找一个元素没找到是正常情况应该用返回值如end()迭代器或std::optional来表示而不是抛出异常。异常机制有开销不适合用于常规控制流。析构函数中如前所述析构函数必须处理掉所有异常绝不能抛出。内存分配失败new在现代操作系统中new在内存不足时默认抛出std::bad_alloc。但对于一些实时或嵌入式系统可能更倾向于使用nothrow版本的new并检查空指针。与C语言或没有异常机制的代码交互跨越语言边界时如C回调给C函数异常必须被捕获并转换为错误码否则会导致未定义行为。5.2 常见陷阱与避坑指南切片问题Slicing通过值捕获异常对象会导致对象切片丢失派生类的信息。务必通过引用捕获。// 错误做法切片 catch (std::exception e) { ... } // 正确做法通过 const 引用捕获 catch (const std::exception e) { ... }捕获顺序问题catch子句的匹配是按书写顺序进行的。因此应该先捕获更特化派生类的异常再捕获更泛化基类的异常。try { ... } catch (const MyNetworkException e) { ... } // 先捕获具体的 catch (const std::runtime_error e) { ... } // 再捕获基类 catch (const std::exception e) { ... } // 最后捕获最通用的 catch (...) { ... } // 兜底在析构函数中抛出异常这是致命错误会导致std::terminate。务必在析构函数内部try-catch(...)。异常安全问题编写可能抛出异常的函数时时刻思考其异常安全等级。优先使用RAII管理资源对于关键操作考虑使用copy-and-swap实现强保证。不要吞掉所有异常而不做记录catch (...) { }是一个危险的空操作。如果你确实需要捕获所有异常并继续运行比如在一个长期运行的服务的主循环中至少应该记录日志。try { processRequest(); } catch (...) { logError(Unknown exception in processRequest); // 可能还需要进行一些必要的状态恢复 }5.3 异常处理的性能开销分析很多人对异常的性能有误解认为“有异常慢无异常快”。实际情况更复杂“零开销”原则在没有异常抛出的正常执行路径上现代C编译器的异常机制开销极低甚至接近于零。编译器使用“表驱动”的方法将异常处理信息放在单独的数据段不影响正常代码的性能。抛出异常的开销大抛出和捕获异常是一个相对昂贵的操作。它涉及查找异常处理表、栈展开、异常对象的拷贝/移动等。因此异常只应用于真正的“异常”情况而不是常规控制流。noexcept的优化声明函数为noexcept不仅是一种承诺也允许编译器在调用该函数时进行更多优化例如简化栈帧布局。同时它影响标准库容器对元素类型的操作选择如移动 vs 拷贝。性能建议不要因为害怕性能而拒绝使用异常。对于真正的错误情况异常提供的清晰错误传播路径和自动资源清理的价值远大于其开销。避免在性能关键的紧密循环hot loop内部使用可能频繁抛异常的代码。积极使用noexcept来标记那些确实不会失败的函数帮助编译器和标准库进行优化。6. 实战在典型C场景中应用异常处理理论最终要服务于实践。我们看几个结合热词的典型场景。6.1 在资源管理类中实现强异常安全保证假设我们要实现一个简单的DatabaseConnectionRAII类。#include memory #include stdexcept class DatabaseConnection { struct Impl; // 前向声明Pimpl惯用法 std::unique_ptrImpl pImpl_; public: // 构造函数可能因连接失败而抛出异常 DatabaseConnection(const std::string connectionString); // 析构函数必须noexcept确保连接被安全关闭 ~DatabaseConnection() noexcept; // 移动操作应设为noexcept使该类能在标准容器中高效移动 DatabaseConnection(DatabaseConnection) noexcept default; DatabaseConnection operator(DatabaseConnection) noexcept default; // 拷贝操作可能被禁用或深拷贝可能抛出 DatabaseConnection(const DatabaseConnection) delete; DatabaseConnection operator(const DatabaseConnection) delete; void executeQuery(const std::string sql); // 可能抛出 std::runtime_error // ... 其他方法 };在这个设计中构造函数和executeQuery可能抛出异常报告错误。析构函数和移动操作被标记为noexcept确保了在容器重组或异常发生时资源管理的可靠性。使用std::unique_ptr管理实现细节即使DatabaseConnection构造中途失败已分配的资源也能自动释放。6.2 处理标准库容器操作中的异常标准库容器本身提供了基本的异常安全保证。以std::vector::push_back为例如果元素类型的拷贝/移动构造函数是noexcept的push_back通常提供强保证。如果拷贝/移动构造函数可能抛出push_back在因容量不足需要重新分配时只提供基本保证元素本身可能被拷贝/移动如果中途失败已操作的元素状态是有效的但容器的内容可能部分改变。了解这一点很重要。如果你需要向容器中插入一个可能抛出拷贝异常的对象并且需要强保证可以考虑先reserve足够空间或者使用emplace_back配合移动语义如果移动是noexcept的。std::vectorMyType vec; vec.reserve(100); // 预先分配避免插入时的重分配 for (int i 0; i 100; i) { MyType obj createMyType(i); // 可能失败 vec.push_back(std::move(obj)); // 如果MyType的移动构造是noexcept这里就是强保证 }6.3 在多线程与异步编程中传递异常结合c多线程和std::async/std::future。#include future #include iostream #include numeric #include vector int computeSum(const std::vectorint data) { if (data.empty()) { throw std::invalid_argument(Input vector is empty); } return std::accumulate(data.begin(), data.end(), 0); } int main() { std::vectorint bigData {1, 2, 3, 4, 5}; // 使用 std::async 异步执行计算 std::futureint fut std::async(std::launch::async, computeSum, std::cref(bigData)); try { int result fut.get(); // 如果异步任务中抛出了异常会在这里被重新抛出 std::cout Sum is: result std::endl; } catch (const std::invalid_argument e) { std::cerr Invalid argument: e.what() std::endl; } catch (const std::exception e) { std::cerr Standard exception: e.what() std::endl; } return 0; }future::get()是获取异步操作结果和异常的统一接口这使得多线程中的错误处理可以和单线程一样清晰。6.4 自定义异常类与错误码的混合使用在一些需要与C接口交互或性能极其敏感的模块错误码可能更合适。我们可以设计一个系统在底层使用错误码在接口边界转换为异常提供统一的错误处理体验。enum class DatabaseErrorCode { Success 0, ConnectionFailed, QueryFailed, Timeout }; class DatabaseException : public std::runtime_error { public: DatabaseException(DatabaseErrorCode code, const std::string msg) : std::runtime_error(msg), errorCode_(code) {} DatabaseErrorCode getErrorCode() const { return errorCode_; } static void throwIfError(DatabaseErrorCode code, const std::string context) { if (code ! DatabaseErrorCode::Success) { throw DatabaseException(code, Operation failed: context); } } private: DatabaseErrorCode errorCode_; }; // 底层C风格函数 extern C DatabaseErrorCode db_query_raw(void* conn, const char* sql); // C包装层 void executeQueryWrapper(void* connection, const std::string sql) { DatabaseErrorCode err db_query_raw(connection, sql.c_str()); DatabaseException::throwIfError(err, Executing SQL: sql); }这套机制既保留了底层使用轻量级错误码的灵活性又为上层C用户提供了方便的基于异常的接口。在实际项目中异常和错误码并非对立而是可以根据场景混合使用的工具。

相关新闻

最新新闻

日新闻

周新闻

月新闻