使用現代 C++ 風格的 Status code

對待 C++ exceptions 的態度

我不建議在 C++ 工程裡使用 exceptions。確實在某些例子裡,C++ exceptions 看起來很方便,但實際工程中鋪開 exceptions 不僅會讓代碼極難維護,更會由於需要照顧到內存狀態安全性而使代碼複雜度陡增(畢竟 C++ 不是 Java,沒有 GC 幫我們照顧拋出異常後的內存狀態安全)。

不使用 exceptions 的另一個原因是因為要為實時系統編寫代碼,拋 exceptions 無法保證硬實時性。把目光放寬的話,C++ 作為一門近裸機語言,能支持它 exceptions 特性的硬環境,其實並不多。

統一管理 error code

既然不使用 exceptions 來彙報錯誤,那麼唯一的選擇只有錯誤碼。

樣例代碼

namespace demo {

class Status {
private:
  enum class Code {
    kOK = 0,
    kFileNotFound,
    kFileNotOpened,
    kInvalidArg,
    kTargetLoss,
  };

public:
  virtual ~Status() = default;

  // explicit copyable by calling `Copy()`
  Status(const Status& rhs) = delete;
  Status& operator=(const Status& rhs) = delete;
  Status Copy() const { return Status(status_code_); }
  // moveable
  Status(Status&& rhs) = default;
  Status& operator=(Status&& rhs) = default;

  static Status OK() { return Status(Code::kOK); }
  static Status FileNotFound() { return Status(Code::kFileNotFound); }
  static Status FileNotOpened() { return Status(Code::kFileNotOpened); }
  static Status InvalidArg() { return Status(Code::kInvalidArg); }
  static Status TargetLoss() { return Status(Code::kTargetLoss); }

  bool IsOK() const { return status_code_ == Code::kOK; }
  bool IsFileNotFound() const { return status_code_ == Code::kFileNotFound; }
  bool IsFileNotOpened() const { return status_code_ == Code::kFileNotOpened; }
  bool IsInvalidArg() const { return status_code_ == Code::kInvalidArg; }
  bool IsTargetLoss() const { return status_code_ == Code::kTargetLoss; }

private:
  explicit Status(const Code& status_code) : status_code_(status_code) {}

  Code status_code_;
};

}  // namespace demo

假如我們的項目名為 demo,現在我們需要為項目 demo 創立一個公有類demo::Status,在 Status 內部用 enum class 來統一管理預定義錯誤碼。

注意我們將 Status 類的 enum class Code 和其構造函數均設置為私有,然後提供一系列靜態方法諸如demo::Status::OK()等來生成特定種類的 Status。

不要直接把 enum class Code 暴露到公開域來表示錯誤碼。

我們可能會認為,直接用一個公共的enum Class StatusCode更加簡潔,或者應該把 Status 的構造函數設為公有並且去除 explicit 限制,但這樣在後續不僅會使我們寫出更多的代碼,而且由於公有 enum class 本身的不可控會帶來一系列維護難題。

不直接使用 enum Class 的另一個理由是:儘管在上述 Status 類內部只有一個類型的原子性錯誤碼 enum class Code,而實際工程中,Status 類完全可以依賴多個內部 enum class 來組合不同類型的錯誤碼,然後用公有函數接口表示各內部錯誤碼的組合。由此我們既避免了以下兩種寫法:

  1. enum Class StatusCode 內部定義一堆相互依賴的複雜錯誤碼(使用本文的方法,可以用 class Status 內的 enum class CodeDiskenum class CodeNetwork 的類似寫法來拆分錯誤碼依賴)。
  2. 為了避免各錯誤碼內部邏輯依賴,而在同個項目內,不得不聲明許多類型的錯誤碼:如 enum Class StatusCodeDiskenum Class StatusCodeNetwork,但卻沒有為各錯誤碼提供統一組合接口。

只需要在include/demo/status.hh裡多囉嗦幾句,而其它部分的代碼都會變得易於維護。

另一方面,Standard Library 的預定義錯誤碼std::error_code太過原始,並不會比我們手動實現的Status類好用。

使用 status 進行 error handling

假如我們的 demo 項目中需要實現一個 DBClient,它能夠連接到某個數據庫並寫入數據。

由於本文的核心是 error handling,所以在樣例代碼裡,我們簡化 DBClient 連接到某個已存在的本地磁盤文件,並且向該已存在的文件寫入數據。

要求:

  • 該文件在磁盤上不存在時,報錯。
  • 該文件存在,但無法打開時,報錯。
  • CLI 的參數有誤時,報錯。

Demo 本身沒有什麼應用意義,但是它的處理流程和大部分實際應用是相同的:比如我們的 client 類要能連接到某個遠程 tcp 端口,然後這個 client 要能向連接後的端口發動消息。

namespace demo {

class DBClient {
public:
  static std::optional<DBClient> Connect(const char* file_path) {
    if (!std::filesystem::exists(file_path)) {
      return std::nullopt;
    }
    return DBClient(file_path);
  }

  virtual ~DBClient() = default;
  // not copyable
  DBClient(const DBClient& rhs) = delete;
  DBClient& operator=(const DBClient& rhs) = delete;
  // moveable
  DBClient(DBClient&& rhs) = default;
  DBClient& operator=(DBClient&& rhs) = default;

  Status WriteMsg(const char* msg) {
    if (!std::filesystem::exists(file_path_)) {
      return Status::FileNotFound();
    }
    FILE* of = fopen(file_path_, "a");
    if (!of) {
      return Status::FileNotOpened();
    }
    auto clock_now = std::chrono::system_clock::to_time_t(
        std::chrono::system_clock::now());
    fmt::print(of, "data: {}\ttime: {}", msg, ctime(&clock_now));
    if (of) {
      fclose(of);
    }
    return Status::OK();
  }

private:
  explicit DBClient(const char* file_path) : file_path_(file_path){};

  const char* file_path_;
};

}  // namespace demo

Status 類的習慣用法可以類比上述代碼。

由於 exceptions 是構造函數唯一的報錯途徑,所以在禁用異常的前提下,為了寫出足夠安全的代碼,C++類的構造函數必須成功

但是實際初始化過程中,正確彙報出錯往往是不可避免的需求,比如 tcp client class,一般是要使用一個地址來初始化該類,但目標地址不一定可達,這種情況下出錯與否不是類的設計者可以掌控的,由於構造函數必須成功,所以我們將構造函數聲明為 private,然後提供靜態工廠函數,然後在工廠函數內部間接調用構造函數。

工廠函數,根據情況,可以返回std::optional,這樣調用者在返回值為std::nullopt的情況下知道其初始化失敗。也可以返回std::unique_ptr,這樣調用者在返回值等同nullptr的情況下知道其初始化失敗。就實際應用而言,從安全的層面考慮,工廠函數只需要告訴調用者創建成功還是失敗(並做好內部資源管理工作,失敗的時候該清理的一定要清理乾淨),不需要彙報過多的信息。

工廠函數,比如上面代碼中的demo::DBClient::Connect(...),它先把可能發生錯誤的步驟全部在呼叫構造函數之前完成,如果有錯,直接 return(前提是正確釋放了資源,建議儘可能依賴 RAII),這樣就能確保在調用構造函數時,構造函數進行的只是一些例行化的簡單初始化,即確保構造函數一經調用必定正確。

使用示例

namespace demo {

Status VaildArg(int argc, char* argv[]) {
  if (argc != 3) {
    return Status::InvalidArg();
  }
  if (atoi(argv[2]) <= 0) {
    return Status::InvalidArg();
  }
  return Status::OK();
}

}  // namespace demo

int main(int argc, char* argv[]) {
  if (!demo::VaildArg(argc, argv).IsOK()) {
    fmt::print(stderr, "usage: demo_db [db_file_path] [write_times > 0]\n");
    return EXIT_FAILURE;
  }
  auto client = demo::DBClient::Connect(argv[1]);
  if (!client.has_value()) {
    fmt::print(stderr, "failed to connect db\n");
    return EXIT_FAILURE;
  }
  auto s = demo::Status::OK();
  for (int i = 0; i < atoi(argv[2]); ++i) {
    auto msg = fmt::format("message {}", i);
    s = client->WriteMsg(msg.c_str());
    if (!s.IsOK()) {
      if (s.IsFileNotFound()) {
        fmt::print(stderr, "failed to find file\n");
        return EXIT_FAILURE;
      }
      if (s.IsFileNotOpened()) {
        fmt::print(stderr, "failed to open file\n");
        return EXIT_FAILURE;
      }
      fmt::print(stderr, "some other error...\n");
      return EXIT_FAILURE;
    }
    fmt::print(stdout, "write: {}\n", msg);
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
  }
  return EXIT_SUCCESS;
}

合理使用現代 C++ 風格的 Status Code 並充分利用 RAII,代碼可以更易於維護。


CC BY-SA 4.0

本文使用 CC BY-SA 4.0 授權

標籤:

分類:

更新時間: