使用現代 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
來組合不同類型的錯誤碼,然後用公有函數接口表示各內部錯誤碼的組合。由此我們既避免了以下兩種寫法:
- 在
enum Class StatusCode
內部定義一堆相互依賴的複雜錯誤碼(使用本文的方法,可以用class Status
內的enum class CodeDisk
、enum class CodeNetwork
的類似寫法來拆分錯誤碼依賴)。 - 為了避免各錯誤碼內部邏輯依賴,而在同個項目內,不得不聲明許多類型的錯誤碼:如
enum Class StatusCodeDisk
、enum 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,代碼可以更易於維護。