Menu

try-catch trong C++: xử lý ngoại lệ đúng cách

Bọc đoạn code có rủi ro trong try, phản ứng trong catch. Tìm hiểu cách bắt ngoại lệ bằng tham chiếu const, sắp xếp nhiều khối catch, dùng catch (...) và ném lại mà không rò rỉ tài nguyên.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Từ ném đến xử lý

Ở trang trước, bạn đã học cách throw một ngoại lệ (exception) khi có gì đó sai. Ném chỉ là một nửa câu chuyện: một ngoại lệ không bao giờ được bắt sẽ gọi std::terminate và làm chương trình sập. Câu lệnh try/catch là cách bạn xử lý thứ đã được ném ra và tiếp tục chạy.

Cấu trúc rất đơn giản: đặt đoạn code có rủi ro trong khối try, theo sau là một hoặc nhiều khối catch phản ứng với từng kiểu lỗi cụ thể. Nếu khối try chạy suôn sẻ, mọi catch đều bị bỏ qua. Ngay khi có gì đó được ném ra, chương trình nhảy thẳng đến catch đầu tiên khớp.

Để ý rằng "after" không bao giờ được in ra. Ngay khi throw chạy, phần còn lại của khối try bị bỏ dở và chương trình tiếp tục bên trong catch khớp. Sau khi catch chạy xong, chương trình tiếp tục bình thường ở phía dưới.

Bắt bằng tham chiếu const

Thói quen quan trọng nhất khi xử lý lỗi trong C++: bắt ngoại lệ bằng tham chiếu const, không phải bằng giá trị.

Bắt bằng giá trị sẽ sao chép ngoại lệ, tệ hơn nữa là cắt (slice) nó. Các ngoại lệ chuẩn tạo thành một hệ phân cấp (runtime_error và logic_error đều kế thừa từ std::exception), nên bắt một ngoại lệ lớp con dưới dạng giá trị lớp cha sẽ cắt mất phần lớp con. Bắt bằng tham chiếu giữ đối tượng nguyên vẹn và đa hình:

Ở đây ta ném một out_of_range nhưng bắt nó dưới dạng const exception&. Vì out_of_range kế thừa từ exception, trình xử lý của lớp cha khớp, và nhờ dùng tham chiếu nên e.what() vẫn trả về đúng thông báo thật. Nếu bạn viết catch (exception e) (bằng giá trị), đối tượng sẽ bị cắt thành một exception thuần và bạn có thể mất thông báo cụ thể.

Nhiều khối catch

Một khối try có thể có nhiều khối catch theo sau, mỗi khối cho một kiểu ngoại lệ. C++ thử chúng từ trên xuống dưới và chạy khối đầu tiên khớp, vì vậy hãy sắp xếp từ cụ thể nhất đến tổng quát nhất.

Vì invalid_argument cụ thể hơn exception, nó phải đứng trước. Nếu bạn đảo thứ tự và đặt catch (const exception&) lên đầu, nó sẽ nuốt mọi ngoại lệ, và trình xử lý invalid_argument bên dưới trở thành code chết không bao giờ chạy. Nhiều trình biên dịch sẽ cảnh báo, nhưng ngôn ngữ không ngăn bạn.

catch (...) và ném lại

Đôi khi bạn muốn có một tấm lưới an toàn cho những gì bạn không lường trước. Trình xử lý bắt tất cả catch (...) khớp với mọi kiểu ngoại lệ, kể cả những kiểu không kế thừa từ std::exception (ai đó có thể throw 42; hoặc throw "oops";).

Điểm trừ là bạn không có đối tượng nào: không có e để kiểm tra. Vì vậy catch (...) tốt nhất nên dùng làm phương án cuối cùng: ghi log rằng có gì đó đã thất bại, hoặc dọn dẹp rồi ném lại.

Để ném lại (rethrow) ngoại lệ hiện tại, tức chuyển nó lên trình xử lý bên ngoài sau khi dọn dẹp hoặc ghi log tại chỗ, hãy dùng throw; không kèm toán hạng. Cách này giữ nguyên ngoại lệ gốc (đúng kiểu và thông báo), khác với throw e; sẽ ném lại một bản sao đã bị cắt:

Trình xử lý bên trong ghi log rồi ném lại; trình xử lý bên ngoài trong main sẽ xử lý tiếp. Hãy dùng throw; cho việc này, không bao giờ dùng throw e;.

Stack unwinding và RAII

Khi một ngoại lệ lan ra khỏi khối try, C++ thực hiện stack unwinding (tháo ngăn xếp): mọi đối tượng cục bộ nằm giữa throw và catch khớp đều được gọi hàm hủy, theo thứ tự ngược với lúc tạo. Đây chính là điều làm ngoại lệ trở nên an toàn: tài nguyên do các đối tượng trên ngăn xếp nắm giữ được giải phóng tự động.

Đó chính là lý do bạn nên giữ tài nguyên trong các kiểu RAII (như std::vector, std::string và smart pointer) thay vì dùng new/delete thủ công. Hãy xem chuyện gì xảy ra khi một ngoại lệ cắt ngang một lần cấp phát thủ công:

void leaky() {
    int* buffer = new int[1000];
    mightThrow();        // if this throws, the next line never runs...
    delete[] buffer;     // ...and the buffer leaks
}

Vì throw nhảy qua delete[], vùng nhớ bị mất. Smart pointer sửa chuyện này mà không tốn công: hàm hủy của nó chạy trong lúc tháo ngăn xếp:

void safe() {
    auto buffer = std::make_unique<int[]>(1000);
    mightThrow();   // if this throws, buffer's destructor still frees the memory
}                   // no manual delete, no leak, even on the exception path

Điều cần nhớ: đừng catch một ngoại lệ chỉ để delete thứ gì đó. Hãy để hàm hủy lo việc dọn dẹp, và dành catch cho các quyết định về cách phục hồi.

Lỗi thường gặp và cái bẫy

Có vài cái bẫy cứ lặp đi lặp lại:

Đừng dùng ngoại lệ cho luồng điều khiển bình thường. Ném và tháo ngăn xếp chậm hơn nhiều so với một câu lệnh if đơn giản. Hãy dành ngoại lệ cho những tình huống lỗi thật sự bất thường, không phải cho "người dùng nhập chuỗi rỗng".

Khối catch trống che giấu bug. Viết catch (...) {} để im lặng một lỗi nghĩa là các thất bại biến mất không dấu vết. Ít nhất hãy ghi log vấn đề; thường thì bạn nên ném lại hoặc xử lý cho đúng.

Hàm hủy ném ngoại lệ là nguy hiểm. Nếu một hàm hủy ném ngoại lệ trong lúc tháo ngăn xếp (khi đang có một ngoại lệ khác được xử lý), chương trình sẽ gọi std::terminate. Trong C++ hiện đại, hàm hủy mặc định là noexcept; đừng bao giờ để ngoại lệ thoát ra khỏi hàm hủy.

catch chỉ thấy những gì try bao bọc. Một ngoại lệ được ném ra trước khi vào try, hoặc trong một hàm khác không nằm trên luồng gọi bên trong nó, sẽ không bị bắt ở đây. catch chỉ bảo vệ code chạy bên trong chính khối try của nó (trực tiếp hoặc trong các hàm mà nó gọi).

Tiếp theo: undefined behavior

Ngoại lệ là cách được định nghĩa rõ ràng để C++ báo cho bạn biết có gì đó sai: bạn ném, bạn bắt, hành vi có thể dự đoán được. Nhưng C++ còn có một góc tối hơn, nơi ngôn ngữ không hứa hẹn gì cả: truy cập một con trỏ treo, đọc vượt quá cuối mảng, tràn số nguyên có dấu. Trang tiếp theo nói về undefined behavior (hành vi không xác định): điều gì gây ra nó, vì sao nó có vẻ "chạy được" cho đến khi đổ vỡ thảm hại, và làm sao giữ nó tránh xa code của bạn.

Câu hỏi thường gặp

try-catch hoạt động thế nào trong C++?

Bạn đặt đoạn code có thể ném ngoại lệ vào trong khối try { }. Nếu có ngoại lệ được ném ra, chương trình dừng chạy phần còn lại của khối try và nhảy đến khối catch đầu tiên khớp, nơi bạn xử lý lỗi. Nếu không có gì được ném ra, các khối catch bị bỏ qua hoàn toàn.

Vì sao nên bắt ngoại lệ bằng tham chiếu const trong C++?

Bắt bằng tham chiếu (catch (const std::exception& e)) tránh sao chép đối tượng ngoại lệ và, quan trọng hơn, giữ được tính đa hình: một ngoại lệ lớp con bị bắt dưới dạng lớp cha vẫn gọi đúng what(). Bắt bằng giá trị (catch (std::exception e)) sẽ cắt mất phần lớp con và có thể làm mất thông tin.

Làm sao bắt mọi ngoại lệ trong C++?

Dùng catch (...): dấu ba chấm bắt mọi ngoại lệ bất kể kiểu gì. Đây là trình xử lý dự phòng cuối cùng hữu ích, nhưng vì bạn không có đối tượng nào để kiểm tra, hãy đặt nó sau các khối catch cụ thể và chủ yếu dùng để ghi log hoặc ném lại.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu