This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Xử lý lỗi (error handling) là một trong những thách thức lớn nhất khi phát triển phần mềm đáng tin cậy. C++ cấp 1990 chỉ có cơ chế trả về mã lỗi (error codes), nhưng điều này dẫn đến code rườm rà, khó bảo trì, và dễ gây rò rỉ tài nguyên (resource leak). C++ hiện đại giới thiệu hệ thống Exception Handling kết hợp với RAII (Resource Acquisition Is Initialization) để đảm bảo an toàn tài nguyên ngay cả khi lỗi xảy ra. Bài học này sẽ mổ xẻ sâu các mức độ an toàn ngoại lệ, cơ chế stack unwinding, và các best practices để viết mã an toàn.
1. Tại Sao Cần Exception? — Hạn Chế Của Mã Lỗi Truyền Thống
Trước khi Exception xuất hiện, cách duy nhất xử lý lỗi là trả về một mã lỗi (error code) hoặc giá trị đặc biệt từ hàm:
// Cách cũ: Trả về mã lỗi
bool readFile(const std::string& filename, std::vector<char>& buffer) {
std::ifstream file(filename);
if (!file.is_open()) {
return false; // Mã lỗi: 0 = thất bại
}
if (!file.read(buffer.data(), buffer.size())) {
return false; // Một mã lỗi cho hai sự kiện khác nhau!
}
return true; // 1 = thành công
}
// Lời gọi hàm: phải kiểm tra return value
std::vector<char> data(1024);
if (!readFile("config.txt", data)) {
// Nó thất bại vì lý do gì? Không biết!
// File không tồn tại? Không đủ quyền? Lỗi đọc?
std::cerr << "Lỗi chung chung nào đó!" << std::endl;
}
Những vấn đề chính của cách tiếp cận error codes:
- Mất thông tin: Mã lỗi (thường là 0/1 hoặc số nhỏ) không thể mô tả chi tiết nguyên nhân sai sót.
- Code xử lý rườm rà: Cứ mỗi lời gọi hàm đều phải kiểm tra return value, làm cho code chính bị che khuất.
- Rò rỉ tài nguyên: Dễ quên giải phóng tài nguyên (memory, file handles, database connections) khi lỗi xảy ra giữa chừng.
-
Không thể phân biệt giữa lỗi thực sự và kết quả hợp lệ: Nếu hàm trả về
-1, nó có thể là lỗi hoặc là giá trị dữ liệu hợp lệ.
Exception là cơ chế tách riêng xử lý lỗi ra khỏi control flow bình thường, cho phép lập trình viên mô tả chi tiết lý do sai sót và tự động giải phóng tài nguyên thông qua RAII.
2. Try-Catch-Throw: Cơ Sở Của Exception Handling
Exception handling trong C++ có ba từ khóa chính: throw (ném ngoại lệ),
try (khối bảo vệ), và catch (bắt ngoại lệ).
#include <iostream>
#include <stdexcept>
#include <fstream>
class FileNotFoundError : public std::exception {};
std::string readFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
// Ném exception kèm thông tin chi tiết
throw std::ios_base::failure(
"Cannot open file: " + filename
);
}
std::string content, line;
while (std::getline(file, line)) {
content += line + "\n";
}
return content;
}
int main() {
try {
// Khối mã có thể ném exception
std::string data = readFile("missing.txt");
std::cout << "File content:\n" << data << std::endl;
}
catch (const std::ios_base::failure& ex) {
// Bắt exception cụ thể (specific type)
std::cerr << "File I/O Error: " << ex.what() << std::endl;
}
catch (const std::exception& ex) {
// Bắt lớp cơ sở (base class) - catch-all cho std exceptions
std::cerr << "Standard Exception: " << ex.what() << std::endl;
}
catch (...) {
// Catch-all cho bất kỳ ngoại lệ nào (không an toàn)
std::cerr << "Unknown exception!" << std::endl;
}
return 0;
}
Quy tắc Catch: Specific First, General Last
Compiler sẽ kiểm tra các catch blocks từ trên xuống dưới và chọn block đầu tiên có kiểu match. Vì vậy, luôn để các exception types cụ thể ở trên, các generic/base classes ở dưới.
// ĐÚNG: Specific first, then generic
try {
// some code
}
catch (const std::invalid_argument& ex) { // Most specific
std::cerr << "Invalid argument: " << ex.what() << std::endl;
}
catch (const std::logic_error& ex) { // More generic
std::cerr << "Logic error: " << ex.what() << std::endl;
}
catch (const std::exception& ex) { // Even more generic
std::cerr << "Standard exception: " << ex.what() << std::endl;
}
catch (...) { // Catch-all (last resort)
std::cerr << "Unknown exception!" << std::endl;
}
// SAI: Generic first sẽ "ăn" cả specific exceptions!
try {
// some code
}
catch (const std::exception& ex) { // Cái này sẽ catch mọi std exceptions!
// ...
}
catch (const std::invalid_argument& ex) { // Không bao giờ chạy đến đây
// ...
}
3. Exception Hierarchies — Thiết Kế Hệ Thống Exception Tốt
Ngôn ngữ C++ cung cấp một hệ thống exception chuẩn dựa trên lớp std::exception và các lớp
con của nó. Hiểu rõ cấu trúc này rất quan trọng để thiết kế exception handling linh hoạt.
std::exception
├── std::logic_error (Lỗi logic - bug trong chương trình)
│ ├── std::invalid_argument (Đối số không hợp lệ)
│ ├── std::out_of_range (Chỉ số vượt phạm vi)
│ ├── std::length_error (Độ dài quá lớn)
│ └── std::domain_error (Giá trị ngoài domain toán học)
│
└── std::runtime_error (Lỗi thời gian chạy - không lỗi logic)
├── std::range_error (Lỗi khoảng toán học)
├── std::overflow_error (Tràn số)
└── std::underflow_error (Dưới số)
Quy tắc chọn lựa:
-
std::logic_error: Khi lỗi là do bug trong code logic (VD: hàm được gọi với đối số không hợp lệ). -
std::runtime_error: Khi lỗi là do các điều kiện thời gian chạy mà chương trình không thể kiểm soát (VD: file không tồn tại, network down).
Tạo Custom Exception Classes
Đối với các lỗi cụ thể của ứng dụng, bạn nên tạo các lớp exception tùy chỉnh được thừa kế từ
std::exception hoặc các lớp con của nó:
#include <iostream>
#include <stdexcept>
#include <string>
// Custom exception cho JSON parsing errors
class JSONParseError : public std::runtime_error {
private:
int lineNumber;
std::string expectedToken;
public:
JSONParseError(const std::string& message, int line,
const std::string& expected)
: std::runtime_error(message),
lineNumber(line),
expectedToken(expected) {}
int getLineNumber() const { return lineNumber; }
const std::string& getExpected() const { return expectedToken; }
};
class DatabaseError : public std::runtime_error {
private:
int errorCode;
public:
DatabaseError(const std::string& msg, int code)
: std::runtime_error(msg), errorCode(code) {}
int getErrorCode() const { return errorCode; }
};
// Sử dụng custom exceptions
void parseJSON(const std::string& json) {
if (json.empty()) {
throw JSONParseError(
"Empty JSON string",
1,
"Opening brace '{'"
);
}
if (json[0] != '{') {
throw JSONParseError(
"Invalid JSON: expected object",
1,
"{");
}
}
int main() {
try {
parseJSON("");
}
catch (const JSONParseError& ex) {
std::cerr << "JSON Error at line " << ex.getLineNumber()
<< ": " << ex.what() << std::endl;
std::cerr << "Expected: " << ex.getExpected() << std::endl;
}
catch (const std::exception& ex) {
std::cerr << "Error: " << ex.what() << std::endl;
}
return 0;
}
4. Exception Safety Guarantees — Mức Độ An Toàn Khi Ngoại Lệ Xảy Ra
Khi thiết kế các hàm C++, việc xác định Exception Safety Guarantee (mức độ an toàn ngoại lệ) là rất quan trọng. Có bốn mức độ an toàn được định nghĩa rõ:
| Mức Độ | Định Nghĩa | Ví Dụ |
|---|---|---|
| No-Throw | Không bao giờ ném exception, luôn thành công | Destructor, swap() |
| Strong | Commit-or-Rollback: nếu lỗi xảy ra, state trở về như cũ | Database transaction |
| Basic | Bất biến được duy trì nhưng state có thể thay đổi | Vector::push_back nếu allocation thất bại |
| Weak | Không đảm bảo gì cả - có thể xảy ra undefined behavior | Raw pointers, malloc/free |
No-Throw Guarantee (Không Bao Giờ Ném)
Hàm này đảm bảo luôn thành công, không bao giờ ném exception. Destructor phải được đặc
biệt khai báo là noexcept (implicit):
class NoThrowExample {
private:
int value = 0;
public:
// Destructor: implicitly noexcept, không bao giờ ném
~NoThrowExample() {
// Chỉ thực hiện các tác vụ an toàn (ghi log, reset)
}
// Ghi rõ noexcept - hàm này không ném exception
void reset() noexcept {
value = 0;
}
// swap() thường được implement với no-throw guarantee
void swap(NoThrowExample& other) noexcept {
std::swap(this->value, other.value);
}
int getValue() const noexcept {
return value;
}
};
Strong Exception Safety (Commit-or-Rollback)
Nếu hàm thất bại do exception, state của object trở về hoàn toàn như cũ (All-or-Nothing):
#include <iostream>
#include <vector>
class StrongSafetyExample {
private:
std::vector<int> data;
std::string metadata;
public:
// Phương pháp: Copy-and-swap
// 1. Tạo bản sao
// 2. Thay đổi bản sao
// 3. Nếu thành công, swap với original
// 4. Nếu thất bại, original không bị ảnh hưởng
void updateData(const std::vector<int>& newData) {
// Tạo bản sao
std::vector<int> temp = data;
// Thay đổi bản sao (có thể ném exception)
if (newData.size() > 1000) {
throw std::invalid_argument(
"Data size exceeds maximum"
);
}
temp = newData;
metadata = "Updated at " + std::to_string(time(nullptr));
// Nếu đến đây mà không ném exception, thì swap
// Swap là no-throw operation
swap(temp); // temp được hủy bỏ bình thường
}
private:
void swap(std::vector<int>& other) noexcept {
data.swap(other);
}
};
Basic Exception Safety (Bất Biến Được Duy Trì)
Mọi invariant của object được duy trì, nhưng state có thể đã thay đổi một phần:
class BasicSafetyExample {
private:
std::vector<int> data;
size_t count = 0;
public:
// push_back() chỉ đảm bảo basic safety
// Nếu allocation thất bại, object vẫn trong trạng thái hợp lệ
// (không hỏng bất biến), nhưng dữ liệu mới không được thêm vào
void push_back(int value) {
// Nếu memory allocation thất bại ở đây,
// vector vẫn hợp lệ, nhưng value không được thêm
try {
data.push_back(value);
count++;
}
catch (const std::bad_alloc&) {
// Object vẫn ở trạng thái hợp lệ (Basic Safety)
// nhưng count không tăng
std::cerr << "Memory allocation failed" << std::endl;
// Rethrow hoặc xử lý
throw;
}
}
size_t getCount() const noexcept {
return count;
}
};
5. RAII & Exception Safety — Tối Ưu Hóa Giải Phóng Tài Nguyên
RAII (Resource Acquisition Is Initialization) là mẫu thiết kế C++ mạnh mẽ nhất để đảm bảo tài nguyên luôn được giải phóng đúng cách, ngay cả khi exception xảy ra. Ý tưởng chính: tài nguyên được cấp phát trong constructor và được giải phóng trong destructor.
Khi exception xảy ra, C++ runtime thực hiện stack unwinding: nó lần lượt gọi destructor của tất cả các objects trên stack theo thứ tự ngược lại (LIFO - Last In First Out).
[STACK UNWINDING DIAGRAM]
Function call stack:
┌─────────────────────┐
│ main() │ ← Execution stops here
│ ├─ obj1 (Resource) │
│ ├─ obj2 (Resource) │
│ └─ obj3 (Resource) │ ← EXCEPTION THROWN!
└─────────────────────┘
Exception caught:
~obj3() called ← Resource freed
~obj2() called ← Resource freed
~obj1() called ← Resource freed
(catch block executes safely)
Ví Dụ: Database Transaction Với RAII
Một ứng dụng thực tế của RAII: database transaction rollback khi lỗi xảy ra:
#include <iostream>
#include <stdexcept>
class DatabaseConnection {
private:
bool isConnected = false;
bool inTransaction = false;
public:
void connect() {
std::cout << "[DB] Connected" << std::endl;
isConnected = true;
}
void disconnect() {
if (isConnected) {
std::cout << "[DB] Disconnected" << std::endl;
isConnected = false;
}
}
void beginTransaction() {
if (!isConnected) throw std::runtime_error("Not connected");
std::cout << "[DB] BEGIN TRANSACTION" << std::endl;
inTransaction = true;
}
void commit() {
if (!inTransaction) throw std::runtime_error("No active transaction");
std::cout << "[DB] COMMIT" << std::endl;
inTransaction = false;
}
void rollback() {
if (!inTransaction) throw std::runtime_error("No active transaction");
std::cout << "[DB] ROLLBACK" << std::endl;
inTransaction = false;
}
};
// RAII wrapper cho transaction
class Transaction {
private:
DatabaseConnection& db;
bool committed = false;
public:
Transaction(DatabaseConnection& connection) : db(connection) {
db.beginTransaction();
}
~Transaction() {
// Destructor: rollback nếu chưa commit
if (!committed) {
std::cout << "[RAII] Destructor: rolling back uncommitted transaction" << std::endl;
db.rollback();
}
}
void commit() {
db.commit();
committed = true;
}
};
// Scope guard cho connection
class ConnectionGuard {
private:
DatabaseConnection& db;
public:
ConnectionGuard(DatabaseConnection& connection) : db(connection) {
db.connect();
}
~ConnectionGuard() {
db.disconnect();
}
};
int main() {
DatabaseConnection db;
try {
ConnectionGuard connGuard(db); // Tự động connect/disconnect
{
Transaction txn(db); // BEGIN TRANSACTION
// Thực hiện các truy vấn
std::cout << "[App] Updating user data..." << std::endl;
// Giả sử xảy ra lỗi
throw std::runtime_error("Database constraint violation!");
// Không bao giờ đến đây
txn.commit(); // Nếu đến đây, transaction được commit
} // RAII: ~Transaction() gọi rollback() tự động!
}
catch (const std::exception& ex) {
std::cerr << "[Error] " << ex.what() << std::endl;
}
// RAII: ~ConnectionGuard() gọi disconnect() tự động!
std::cout << "[App] Program ended safely" << std::endl;
return 0;
}
/* OUTPUT:
[DB] Connected
[DB] BEGIN TRANSACTION
[App] Updating user data...
[RAII] Destructor: rolling back uncommitted transaction
[DB] ROLLBACK
[Error] Database constraint violation!
[DB] Disconnected
[App] Program ended safely
*/
6. Exception Handling Patterns & Alternatives
Exception không phải là cách duy nhất xử lý lỗi. Dưới đây là các mẫu khác nhau và khi nên sử dụng chúng:
Exceptions vs std::optional (C++17)
std::optional<T> được dùng khi một giá trị có thể có hoặc không có, không phải do
lỗi nghiêm trọng:
#include <optional>
#include <string>
// Sử dụng exceptions: Lỗi nghiêm trọng
double divideWithException(double a, double b) {
if (b == 0.0) {
throw std::invalid_argument("Division by zero");
}
return a / b;
}
// Sử dụng optional: Giá trị có thể có hoặc không
std::optional<double> divideWithOptional(double a, double b) {
if (b == 0.0) {
return std::nullopt; // Không có kết quả
}
return a / b; // Có kết quả
}
// Sử dụng optional cho tìm kiếm
std::optional<std::string> findUserEmail(int userId) {
// Giả sử tìm kiếm user trong database
if (userId < 0) {
return std::nullopt; // User không tồn tại
}
return "[email protected]";
}
int main() {
// Cách sử dụng optional
if (auto result = divideWithOptional(10.0, 2.0)) {
std::cout << "Result: " << result.value() << std::endl;
} else {
std::cout << "Division by zero" << std::endl;
}
// Cách sử dụng exception
try {
double result = divideWithException(10.0, 2.0);
std::cout << "Result: " << result << std::endl;
}
catch (const std::invalid_argument& ex) {
std::cerr << "Error: " << ex.what() << std::endl;
}
return 0;
}
RAII-Based Error Handling
Một mẫu mạnh mẽ khác: giữ lỗi của một thao tác trong object, rồi kiểm tra khi cần:
#include <iostream>
#include <optional>
#include <string>
class FileResult {
private:
std::optional<std::string> content;
std::string errorMessage;
bool hasError = false;
public:
void readFile(const std::string& filename) {
// Thay vì ném exception, lưu error
std::ifstream file(filename);
if (!file.is_open()) {
hasError = true;
errorMessage = "Cannot open file: " + filename;
return;
}
std::string data((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
content = data;
}
bool isOk() const { return !hasError; }
const std::string& getError() const { return errorMessage; }
const std::string& getValue() const { return content.value(); }
};
int main() {
FileResult result;
result.readFile("config.txt");
if (result.isOk()) {
std::cout << "File content: " << result.getValue() << std::endl;
} else {
std::cerr << "Error: " << result.getError() << std::endl;
}
return 0;
}
7. Performance & Best Practices
Hiệu năng của Exception Handling: Exceptions có overhead tính toán khi được ném, nhưng chi phí khi không có exception xảy ra là rất thấp (zero-cost model). Điều này có nghĩa:
- Đặt exception handling ở nơi mà lỗi thực sự có thể xảy ra (VD: I/O, network, validation).
- Không sử dụng exceptions cho control flow bình thường (VD: dùng exception để kiểm tra điều kiện loop).
- Trong code hiệu năng cực cao (inner loops, game engines), ưu tiên error codes hoặc optional.
Best Practices Cho Exception Handling
1. Throw by value, catch by const reference:
// ĐÚNG
try {
throw std::invalid_argument("Invalid input");
}
catch (const std::invalid_argument& ex) { // catch by const ref
// ...
}
// SAI
try {
throw std::invalid_argument("Invalid input");
}
catch (std::invalid_argument ex) { // Sao chép không cần thiết!
// ...
}
2. Đặc biệt cẩn thận với destructor:
class Resource {
public:
~Resource() noexcept { // Destructor PHẢI là noexcept!
// Không được ném exception
cleanup();
}
private:
void cleanup() noexcept {
// Tất cả cleanup code phải là no-throw
}
};
3. Re-throw exceptions để bảo vệ information:
try {
riskyOperation();
}
catch (const std::exception& ex) {
// Xử lý lỗi nếu cần, rồi re-throw
std::cerr << "Error occurred: " << ex.what() << std::endl;
throw; // Re-throw exception gốc
}
4. Sử dụng exception specifications (hiện đại):
// C++17+: noexcept specification
void swap(Vector& other) noexcept { // Guarantee: never throws
// ...
}
void mayThrow() { // Có thể ném exception
// ...
}
Câu hỏi ôn tập kiến thức
Khi một exception được ném trong hàm A gọi hàm B gọi hàm C (C ném exception), thứ tự các destructor nào sẽ được gọi?
Tải mã nguồn mẫu bài học
Bạn có thể tải file mã nguồn C++ mẫu hoàn chỉnh của bài học này để tiến hành thực hành trực tiếp trên máy tính cá nhân.
Tải exception_handling.cpp
Comments
Bình luận