Modern Cpp使用正确姿势返回错误码


概述

根据错误的等级可以分为两类,一类是可恢复错误,一类是不可恢复错误,后者来说处理起来比较简单,因为发生错误且不可逆转,这时候往往只需要把程序关闭即可,所以后面讨论的默认是前者。

返回错误码

最简单和传统的方法:

优点:

  1. 简单直观:返回错误码是一种简单且广泛理解的方法,易于实现和使用。
  2. 控制流明确:调用者可以通过检查返回值来决定如何进行错误处理,控制流清晰。
  3. 性能:返回错误码通常具有很高的性能,因为它只需要返回一个整数,没有额外的异常处理开销。
  4. 兼容性:由于C语言中广泛使用错误码,许多现有的库和系统调用都返回错误码,这使得它具有良好的兼容性。
  5. 丰富的错误信息:可以通过定义不同的错误码来传达多种错误情况,提供丰富的错误信息。
  6. 避免异常的开销:与异常处理相比,返回错误码避免了异常抛出和捕获的运行时开销。
  7. 适用于资源受限的环境:在内存或处理能力受限的系统中,返回错误码是一种节省资源的错误处理方式。

缺点:

  1. 错误处理代码分散:错误检查和处理通常分散在调用栈的多个位置,可能导致代码难以阅读和维护。
  2. 容易遗漏错误处理:开发者可能会忘记检查错误码,导致未处理的错误被忽略。
  3. 错误码的全局性:错误码通常是全局定义的,可能导致不同模块之间的错误码冲突。
  4. 减少函数的返回能力:函数如果使用整数作为错误码,就无法同时返回有意义的结果和错误指示,限制了函数的返回类型。
  5. 错误处理不一致:不同的开发者或团队可能会使用不同的错误码约定,导致项目中的错误处理不一致。(遇到过)
  6. 降低了代码的可读性:错误码需要开发者去查阅文档以了解每种错误码的含义,降低了代码的直观性。
  7. 异常安全性:与RAII(资源获取即初始化)和异常处理相比,返回错误码不保证异常安全,因为资源可能在发生错误时没有被适当释放。
  8. 不利于函数的重构:如果函数的返回类型是错误码,当需要修改函数以返回额外信息时,可能会更加困难。
  9. 调试困难:当错误码未被适当处理时,程序可能会继续执行,导致难以追踪的错误和状态。
  10. 污染接口或者返回值:错误码有时候会跟随函数签名,不直观。

std::optional

std::optional是一个在C++17中引入的模板类,它可以用来包装一个可能存在或不存在的值。虽然std::optional主要用于表示可选值,但它也可以用于错误处理,使用std::nullopt来表示返回值出错,优点和缺点都很明显。

优点:

  1. 表达可能的“无值”状态std::optional可以自然地表达一个函数可能不返回有效结果的情况,这与传统的错误码返回(总是有值)形成对比。
  2. 避免魔术值:使用std::optional可以避免使用特定的返回值作为错误指示(例如,-1或nullptr),这样可以减少对“魔术值”的依赖。
  3. 类型安全std::optional提供了类型安全的错误处理方式,编译器可以帮助确保正确处理了可选值的存在与否。
  4. 现代C++特性:作为现代C++的一部分,std::optional与语言的其他特性(如自动类型推断auto)协同工作良好。
  5. 简洁性:使用std::optional可以使代码更加简洁,特别是在简单的错误处理场景中。
  6. 与标准库的一致性:使用std::optional可以使代码与C++标准库的其他部分保持一致性。
  7. 使用方便: 直接使用opt.value(),内部会帮助判断是否为std::nullopt,是则抛出异常。

缺点:

  1. 语义不明确std::optional主要用于表达“值可能不存在”,而不是“发生了错误”,这可能导致语义上的混淆。
  2. 丢失错误信息:与传统的错误码相比,std::optional不直接提供错误的详细信息,可能需要额外的机制来传递错误信息(非常致命)。
  3. 性能开销:与返回简单状态码相比,std::optional可能会有轻微的性能开销,因为它需要额外的存储空间和构造函数调用。
  4. 使用复杂性:在复杂的错误处理场景中,std::optional可能不如专门的错误处理机制(如std::expected或异常)直观和方便。
  5. 需要额外的错误处理逻辑:使用std::optional可能需要编写额外的代码来检查值是否存在,并相应地处理错误情况。
  6. 不是所有错误都适用:对于一些需要返回详细错误信息或状态的错误处理场景,std::optional可能不是最佳选择。

多态错误码思想

利用enum对状态码进行定义,注意使用强类型避免能从int转换,至于如何转换先前的博文已经有介绍过了,Cpp 中如何优雅进行 enum 到 string 的转换

enum class LogicErrc: std::uint8_t{
    success=0,
    not_valid,
    not_login
}

这时候注意到小标题,这跟多态错误码有什么关系?因为有些时候函数除了自定义的错误意外,还会出现诸如文件符操作失败,网络连接失败等原因,这时候我们需要兼容这类情况,该怎么做?

包装一个自己的类,加上对应的成员函数,相信应该知道怎么操作了,当然缺点也很明显,需要设计和实现一套完整的错误码和异常类体系,还需要处理传入参数的不同。

std::error_code

std::error_code 是 C++ 标准库中定义的一个类,它在 <system_error> 头文件中,用于表示错误的状态码。这个类是 C++11 引入的,目的是提供一种标准的方式来报告和处理错误,它与 POSIX 风格的 errno 系统相兼容,并且可以扩展以支持其他错误报告机制。以下是 std::error_code 的一些关键特性:

特性

  1. 兼容性std::error_code 可以存储来自操作系统的错误码,并且与 errno 兼容。
  2. 错误分类:它与 std::error_category 一起使用,后者定义了错误码的分类,例如系统错误、通用错误等。
  3. 消息获取:可以通过 std::error_code 获取错误码对应的消息描述。
  4. 比较操作std::error_code 支持比较操作,允许你检查两个错误码是否相等或不等。
  5. 清空:可以清空 std::error_code 对象,使其表示“没有错误”。
  6. 转换:可以将 std::error_code 转换为 int 类型,以便于与 POSIX 风格的 API 交互。

与多态错误码结合

class ErrorCategory : public std::error_category{
    virtual string message(int ev)=0;
}

class StdErrorCategory : public ErrorCategory{
public:
    const char* name() const noexcept{
        return "stdError";
    }
    std::string message(int ev) const override {
        // 根据错误码ev返回错误消息
        return std::stderror(ev);
    }
};

class MyErrorCategory : public ErrorCategory {
public:
    const char* name() const noexcept {
        return "MyError";
    }
    std::string message(int ev) const override {
        // 根据错误码ev返回错误消息
        return "Error description";
    }
};

const MyErrorCategory myErrorCategory;
std::error_code error(1, myErrorCategory);//注意这里

完全可以把message设为虚函数,这时候就能比较方便的实现与标准库结合的错误码输出处理。

优点:

  • 类型安全:使用 std::error_code 可以避免使用魔术数字作为错误码。
  • 表达性:它提供了一种更表达性的方式来处理错误。
  • 扩展性:可以定义新的 error_category 来扩展错误码的种类。

缺点:

  • 复杂性:对于简单的错误处理,使用 std::error_code 可能显得有些复杂。
  • 性能:与简单的整数错误码相比,对象可能涉及额外的性能开销。
  • 使用习惯:需要开发者适应这种新的错误处理方式。

std::variant与std::expected

std::variantstd::expected 都是 C++ 中处理值多样性的工具,但它们在错误处理方面的使用各有特点和适用场景。

使用 std::variant 处理错误

std::variant 可以用来表示一个值,这个值可以是多种类型中的一个。在错误处理的上下文中,std::variant 可以持有一个成功值或者一个错误值。

优点

  • 可以存储不同类型的值,包括错误信息。
  • 利用 std::holds_alternative 可以检查当前存储的类型。

缺点

  • 需要手动管理错误信息的类型。
  • 错误处理逻辑可能分散在代码各处。

示例

std::variant<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return "Error: Division by zero";
    }
    return a / b;
}

void handle_division(int a, int b) {
    auto result = divide(a, b);
    if (std::holds_alternative<std::string>(result)) {
        std::cout << std::get<std::string>(result) << std::endl;
    } else {
        std::cout << "Result: " << std::get<int>(result) << std::endl;
    }
}

使用 std::expected 处理错误

std::expected<T, E> 是为错误处理设计的类型,其中 T 是期望的成功值类型,而 E 是错误值的类型。

优点

  • 明确区分了成功值和错误值,语义清晰。
  • 提供了 has_valuehas_error 成员函数来检查状态。
  • 错误处理逻辑集中,易于理解和使用。

缺点

  • 需要包含错误类型的定义,可能增加复杂性。
  • 目前std::expected 还未成为正式的 C++ 标准。

示例(假设 std::expected 已可用):

std::expected<int, std::error_code> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected(std::make_error_code(std::errc::invalid_argument));
    }
    return a / b;
}

void handle_division(int a, int b) {
    auto result = divide(a, b);
    if (result.has_value()) {
        std::cout << "Result: " << result.value() << std::endl;
    } else {
        std::cout << "Error: " << result.error().message() << std::endl;
    }
}

总结

  • std::variant 提供了一种通用的方法来处理多种类型的值,包括成功值和错误值,但它需要手动检查和处理错误。
  • std::expected 是专门为表示操作结果和错误设计的,提供了更明确和方便的错误处理机制,但可能需要额外的错误类型定义。

在实际应用中,如果错误处理是主要关注点,std::expected 是更合适的选择。如果需要处理多种类型的值,并且错误只是其中一种可能性,std::variant 可能更灵活。

throw std::system_error

在应用程序中使用系统错误时通常会与 std::error_code 一起使用,std::error_code 封装了来自操作系统的错误代码。使用 std::system_error 的典型场景是将从系统调用返回的错误代码转换为异常,然后抛出。

以下是如何抛出 std::system_error 的示例:

#include <iostream>
#include <system_error>
#include <cstdio> // 用于示例的文件操作

void some_system_call() {
    // 假设我们调用一个系统函数,如 fopen,它可能会失败
    FILE* file = std::fopen("somefile.txt", "r");
    if (!file) {
        // 获取 errno 来获取错误代码
        int err_code = errno;
        // 抛出 std::system_error,传递错误代码和相关信息
        throw std::system_error(err_code, std::system_category(), "Failed to open file");
    }
    // 如果 fopen 成功,不要忘记在适当的时候 fclose(file);
}

int main() {
    try {
        some_system_call();
    } catch (const std::system_error& e) {
        std::cerr << "System error: " << e.what() << std::endl;
        // 处理错误,例如清理资源、退出程序等
    }
    return 0;
}

如果 fopen 调用失败,函数 some_system_call 会抛出一个 std::system_error 异常。异常构造函数接收三个参数:

  1. 错误代码:errno 表示的错误代码。
  2. 错误类别:std::system_category() 提供了系统错误的类别。
  3. 描述信息:一个描述错误的字符串。

main 函数中,我们使用 try-catch 块来捕获并处理 std::system_error 异常。

抛出 std::system_error 的优点:

  • 明确地报告了系统级错误,使得错误处理更加清晰。
  • 异常信息中包含了错误代码和描述,便于调试和日志记录。
  • 与标准库中的异常处理机制兼容。

抛出 std::system_error 的缺点:

  • 抛出异常可能会带来性能开销,尤其是在异常频繁发生的情况下。
  • 需要确保异常被适当地捕获和处理,否则可能导致程序异常终止。
  • 在一些需要严格错误处理策略的系统中,使用异常可能不被推荐。

文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录