Chiến lược xử lý lỗi: Chọn phương pháp tiếp cận phù hợp
So sánh các ngoại lệ, mã lỗi và std::expected — tìm hiểu thời điểm mỗi cách tiếp cận phù hợp và cách xử lý lỗi trong hàm tạo, hàm hủy và mã đa luồng.
Không có giải pháp nào phù hợp cho tất cả
C++ cung cấp nhiều cơ chế xử lý lỗi, mỗi cơ chế có sự cân bằng khác nhau. Việc chọn đúng tùy thuộc vào tên miền của bạn:
Ngoại lệ là tốt nhất cho các lỗi hiếm và không thể xử lý cục bộ. Chúng không tốn chi phí trên con đường thành công nhưng lại đắt khi được sử dụng ($1000 nano giây). Chúng tự động lan truyền trên các khung cuộc gọi.
Mã lỗi (giá trị trả về,errno,std::error_code) là phù hợp nhất cho các lỗi dự kiến và thường xuyên. Họ có một khoản chi phí nhỏ, không đổi cho mỗi cuộc gọi (người gọi phải kiểm tra giá trị trả về). Chúng là tiêu chuẩn trong API C và mã cấp độ kernel.
std::expected (C++23) kết hợp ưu điểm tốt nhất của cả hai: nó buộc người gọi phải thừa nhận lỗi (như mã lỗi) nhưng mang nhiều loại lỗi (như ngoại lệ) và hỗ trợ chuỗi đơn âm. Nó nhanh chóng trở thành phương pháp được ưa thích trong mã C++ mới.
Thông tin chi tiết quan trọng: đây không phải là những triết lý cạnh tranh — chúng là công cụ cho các tình huống khác nhau và mã sản xuất thường sử dụng cả ba.
So sánh: Ngoại lệ so với Mã lỗi so với std::expected
| Criterion | Exceptions | Error Codes |std::expected|
|---|---|---|---|
| Cost on success | Zero (table-based) | Branch + check | Minimal (stores value) |
| Cost on error | High (unwinding) | Same as success | Same as success |
| Can be ignored? | No (program terminates) | Yes (silent bugs) | Harder (must access.value()) |
| Constructors | Only option | Cannot return a value | Cannot return a value |
| Destructors | Must not throw | Fine | Fine |
| Composability | Limited (try/catch) | Manual if-chains | Monadic (and_then, transform) |
| Binary size | Larger (unwind tables) | Minimal | Minimal |
| Real-time safe? | No (unbounded time) | Yes | Yes |
Xử lý lỗi trong hàm tạo
Constructors cannot return error codes — they have no return value. Exceptions are the only clean mechanism for signaling construction failure. A thrown exception means the object was never fully constructed, so its destructor is never called (but destructors of already-constructed sub-objects and bases are called).
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>
class ConfigFile {
public:
explicit ConfigFile(const std::string& path)
: path_(path), stream_(path) {
if (!stream_.is_open()) {
throw std::runtime_error(
"Cannot open config file: " + path);
}
// Parse the file — if this throws, stream_ is
// already constructed and its destructor will
// close the file automatically (RAII)
parse();
}
std::string get(const std::string& key) const {
auto it = data_.find(key);
if (it == data_.end()) {
throw std::out_of_range("Key not found: " + key);
}
return it->second;
}
private:
void parse() {
std::string line;
while (std::getline(stream_, line)) {
auto pos = line.find('=');
if (pos != std::string::npos) {
data_[line.substr(0, pos)] = line.substr(pos + 1);
}
}
}
std::string path_;
std::ifstream stream_; // RAII: closed by destructor
std::map<std::string, std::string> data_;
};
// Usage: the object is fully valid or does not exist
int main() {
try {
ConfigFile cfg("/etc/myapp.conf");
std::cout << cfg.get("port") << '\n';
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
}std::error_code for System-Level Errors
std::error_codeprovides a lightweight, non-throwing mechanism for system-level errors. Thelibrary is a great example — most functions have an overload that takes anstd::error_code¶meter instead of throwing.
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
fs::copy_file(src, dst); // throws fs::filesystem_error on failure
}
// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
std::error_code ec;
fs::copy_file(src, dst, ec); // sets ec instead of throwing
if (ec) {
std::cerr << "Copy failed: " << ec.message()
<< " (" << ec.category().name()
<< ':' << ec.value() << ")\n";
return false;
}
return true;
}
int main() {
// The error_code version is preferable in loops where
// failure is expected (e.g., batch file processing)
for (const auto& entry : fs::directory_iterator(".")) {
std::error_code ec;
auto sz = fs::file_size(entry.path(), ec);
if (!ec) {
std::cout << entry.path().filename()
<< ": " << sz << " bytes\n";
}
}
}Destructors in C++11 and later are implicitlynoexcept. If a destructor throws,std::terminate()is called. Even if you explicitly mark a destructornoexcept(false), throwing during stack unwinding (when another exception is already active) callsstd::terminate().
- Log and suppress: If a cleanup operation fails, log the error and continue
- Provide a separate
close()method: Let callers explicitly handle cleanup errors before destruction - Design resources so cleanup cannot fail: For example, closing a file descriptor always succeeds at the OS level
- Never call a throwing function from a destructor without wrapping it in a try/catch
Xử lý lỗi trong mã đa luồng
Các ngoại lệ không vượt qua ranh giới luồng. Nếu một luồng được ném ra và ngoại lệ không bị bắt trong luồng đó thìstd::terminate()sẽ được gọi. Để truyền bá ngoại lệ giữa các chủ đề:
1. Sử dụng std::future và std::async: Các ngoại lệ được đưa vào tác vụ không đồng bộ sẽ được ghi lại và gửi lại khi bạn gọi.get()trong tương lai.
2. Sử dụng std::exception_ptr vớistd::current_exception()vàstd::rethrow_exception()để thu thập và vận chuyển các ngoại lệ theo cách thủ công.
3. Sử dụng std::promise::set_exception() để gửi ngoại lệ từ luồng sản xuất tới người tiêu dùng.
Đối với mã đồng thời có thông lượng cao, mã lỗi hoặcstd::expectedthường được ưu tiên hơn vì chúng tránh được chi phí cho cơ sở hạ tầng ngoại lệ trong mỗi luồng và dễ dàng xử lý hơn trên các ranh giới của luồng.
Hãy kết hợp cơ chế xử lý lỗi với tình huống.
- Sử dụng ngoại lệ cho các lỗi hiếm gặp, không thể xử lý cục bộ và sẽ lan truyền lên ngăn xếp (ví dụ: không tìm thấy tệp, cơ sở dữ liệu bị hỏng)
- Sử dụng mã lỗi cho các hoạt động tần suất cao trong đó lỗi là bình thường và có thể xảy ra (ví dụ: lỗi tra cứu bộ đệm, chặn I/O không chặn)
- Sử dụng
std::expectedcho mã mới mà bạn muốn xử lý lỗi rõ ràng với nhiều loại lỗi và thành phần đơn âm - Sử dụng ngoại lệ trong hàm tạo — chúng là cách duy nhất để báo hiệu lỗi trước khi đối tượng tồn tại
- Không bao giờ đưa vào hàm hủy — ghi lại lỗi và loại bỏ nó
- Các ngoại lệ, mã lỗi và
std::expectedlà các công cụ bổ sung - không phải triết lý cạnh tranh - Người xây dựng phải sử dụng ngoại lệ để báo hiệu lỗi; mã lỗi không phải là một lựa chọn
- Hàm hủy không bao giờ được ném - chúng ngầm là
noexceptkể từ C++11 - Các ngoại lệ không vượt qua ranh giới luồng - sử dụng
std::futurehoặcstd::exception_ptrđể chuyển chúng std::error_codelý tưởng cho các lỗi cấp hệ thống mà bạn muốn có API không ném
Câu đố — Kiểm tra kiến thức
(15 XP)1. Tại sao các ngoại lệ lại là cơ chế xử lý lỗi ưa thích trong hàm tạo?
2. Điều gì sẽ xảy ra nếu một ngoại lệ được ném vào một hàm hủy trong khi giải phóng ngăn xếp khỏi một ngoại lệ khác?
3. Làm thế nào các ngoại lệ có thể được chuyển giữa các chủ đề?