Menu

Ngoại lệ trong C++: throw, what() và xử lý lỗi

Ngoại lệ báo những lỗi mà một hàm không tự xử lý được tại chỗ. Tìm hiểu cách throw, các kiểu ngoại lệ chuẩn, thông báo what(), và vì sao ngoại lệ tốt hơn mã lỗi trả về với những lỗi quan trọng.

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.

Vì sao có ngoại lệ

Ở trang trước bạn đã dùng enum class để đặt tên có ý nghĩa cho các trạng thái lỗi. Cách đó rất hợp với những kết quả mà hàm lường trước và nơi gọi được kỳ vọng sẽ kiểm tra. Nhưng có những lỗi khác hẳn: một hàm nằm sâu trong chuỗi gọi hàm phát hiện tệp không mở được, hoặc một đối số vô nghĩa, và nó không biết chương trình nên làm gì với chuyện đó. Trả về mã lỗi chỉ hiệu quả khi mọi nơi gọi trong chuỗi đều nhớ kiểm tra và chuyển nó lên trên. Thiếu một lần kiểm tra là chương trình cứ thế chạy tiếp với dữ liệu rác.

Ngoại lệ (exception) giải quyết chuyện này. Khi có gì đó sai, bạn throw một đối tượng. Việc thực thi dừng ngay lập tức, stack được tháo dần (mọi đối tượng cục bộ nằm giữa chỗ ném và chỗ xử lý đều được chạy hàm hủy), và luồng điều khiển nhảy tới khối catch phù hợp gần nhất. Một ngoại lệ không được xử lý thì không thể bị lờ đi âm thầm: nếu không ai bắt nó, chương trình gọi std::terminate và dừng hẳn.

Trang này tập trung vào phía ném ngoại lệ, tức chính các đối tượng lỗi. Trang tiếp theo đi sâu vào cơ chế try/catch.

Ném ngoại lệ và thông báo what()

Về mặt kỹ thuật bạn có thể throw bất kỳ giá trị nào, throw 42; hay throw "oops"; đều hợp lệ, nhưng đừng làm vậy. Quy ước mà ai cũng theo là ném một đối tượng kế thừa từ std::exception. Lớp cơ sở này khai báo một phương thức ảo, what(), trả về một const char* mô tả vấn đề. Theo đúng quy ước nghĩa là chỉ một catch (const std::exception& e) là xử lý được mọi thứ.

Header <stdexcept> cung cấp sẵn các kiểu có hàm khởi tạo nhận thông báo:

Để ý what() trả về đúng chuỗi bạn đã dùng để tạo ngoại lệ. Cũng để ý rằng ta bắt bằng const exception& dù đã ném runtime_error: điều đó chạy được vì runtime_error là một std::exception (quan hệ bạn sẽ nhận ra từ trang kế thừa).

Cây ngoại lệ chuẩn

Trước khi tự viết kiểu ngoại lệ, hãy xem thư viện chuẩn đã có kiểu nào phù hợp chưa. Chúng đều kế thừa từ std::exception và chia thành hai nhóm trong <stdexcept>:

  • logic_error: một bug trong logic chương trình mà về nguyên tắc có thể phát hiện trước khi chạy. Các kiểu con gồm invalid_argument, out_of_range, domain_error và length_error.
  • runtime_error: một lỗi chỉ xuất hiện lúc chạy và bản thân nó không phải lỗi lập trình. Các kiểu con gồm range_error, overflow_error và underflow_error.

Nhiều hàm thư viện ném các ngoại lệ này giúp bạn. Ví dụ, at() của container vector kiểm tra giới hạn và ném out_of_range thay vì để bạn đọc vượt quá cuối mảng:

at() là phiên bản an toàn của v[9]. operator[] thông thường không kiểm tra giới hạn: đọc v[9] ở đây là undefined behavior, không phải ngoại lệ. Chọn at() là cách bạn biến một lỗi hỏng dữ liệu âm thầm thành một lỗi bắt được.

Hãy chọn kiểu mô tả đúng lỗi: invalid_argument khi nơi gọi truyền vào thứ vô nghĩa, out_of_range cho vấn đề về chỉ số hay khóa, runtime_error cho trường hợp "thế giới bên ngoài làm hỏng việc của tôi".

Tự viết kiểu ngoại lệ

Khi không kiểu chuẩn nào phù hợp, chẳng hạn bạn muốn gắn thêm dữ liệu, hoặc muốn catch riêng lỗi của mình mà không bắt gì khác, hãy định nghĩa một lớp kế thừa từ std::exception (hoặc một kiểu con của nó) và ghi đè what(). Kế thừa từ std::runtime_error là cách dễ nhất vì nó đã lưu sẵn thông báo và cài đặt what() giúp bạn:

Vì NetworkError mang theo mã trạng thái, phần xử lý có thể phản ứng theo nó: thử lại khi gặp 5xx, bỏ cuộc khi gặp 4xx. Một chuỗi lỗi trần không làm được vậy. Kiểu tự viết còn cho phép catch (const NetworkError&) chỉ bắt lỗi mạng và để mọi thứ khác cho phần xử lý tổng quát hơn bên dưới.

Nếu bạn kế thừa thẳng từ std::exception (không phải runtime_error), nhớ tự ghi đè what() và đánh dấu nó noexcept cho khớp với chữ ký của lớp cơ sở:

class ParseError : public std::exception {
public:
    const char* what() const noexcept override {
        return "failed to parse input";
    }
};

Ném theo giá trị, bắt theo tham chiếu

Đây là quy tắc quan trọng nhất của ngoại lệ trong C++, và cũng là quy tắc người mới hay làm sai. Ném đối tượng theo giá trị và bắt chúng bằng tham chiếu const.

throw runtime_error("oops");            // by value - correct
catch (const runtime_error& e) { ... }  // by const reference - correct

Nếu bắt theo giá trị, catch (std::exception e), ngoại lệ sẽ bị sao chép vào một đối tượng kiểu lớp cơ sở và phần dẫn xuất bị cắt mất (slicing). Sau khi bị cắt, e.what() gọi phiên bản của lớp cơ sở chứ không phải phiên bản bạn đã ghi đè, nên thông báo bạn viết cẩn thận biến mất:

try {
    throw NetworkError(503, "service unavailable");
} catch (std::exception e) {       // by value - object slicing!
    std::cout << e.what();         // generic message, status() is gone
}

Tham chiếu (&) giữ nguyên kiểu động thật sự, nên what() ảo được gọi đúng và bạn vẫn truy cập được các thành viên của lớp dẫn xuất. Thêm const vì bạn chỉ đọc ngoại lệ chứ không sửa nó. Đừng bao giờ ném một con trỏ (throw new runtime_error(...)): nơi bắt sẽ phải delete nó, nhưng trên nhánh code nào? Đó chính là kiểu rò rỉ mà ngoại lệ sinh ra để ngăn chặn.

Tiếp theo: try-catch

Giờ bạn đã tạo và throw được những ngoại lệ đúng chuẩn và chọn được kiểu chuẩn phù hợp cho từng lỗi. Nửa còn lại của câu chuyện là phía bắt ngoại lệ. Trang tiếp theo trình bày đầy đủ try/catch: sắp xếp nhiều khối catch từ cụ thể nhất đến tổng quát nhất, khối bắt tất cả catch (...), ném lại bằng throw; trần, và cách RAII (hãy nhớ lại smart pointer) bảo đảm tài nguyên được giải phóng khi stack được tháo dần.

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

Ngoại lệ trong C++ là gì?

Ngoại lệ (exception) là một đối tượng báo hiệu một lỗi mà hàm hiện tại không tự xử lý được. Bạn throw nó, stack được tháo dần (hủy các đối tượng cục bộ trên đường đi), và một khối catch phù hợp ở cấp cao hơn tiếp quản. Cơ chế này tách phần code phát hiện vấn đề khỏi phần code quyết định phải làm gì với nó.

Dùng throw và return để báo lỗi khác nhau thế nào?

Giá trị return phải được nơi gọi kiểm tra, và rất dễ quên: chương trình cứ thế chạy tiếp với dữ liệu sai. Một ngoại lệ đã ném ra thì không thể bị lờ đi: nếu không ai bắt nó, chương trình sẽ kết thúc. Ngoại lệ dành cho những lỗi thật sự (tệp không mở được, dữ liệu nhập không hợp lệ); giá trị trả về vẫn phù hợp cho kết quả thông thường, kể cả các trường hợp "không tìm thấy" đã lường trước.

Phương thức what() trong ngoại lệ C++ dùng để làm gì?

Mọi lớp kế thừa từ std::exception đều có phương thức ảo what() trả về một const char* mô tả lỗi. Khi bắt được ngoại lệ, gọi e.what() cho bạn thông báo dễ đọc để ghi log hoặc in ra. Các kiểu ngoại lệ chuẩn đặt thông báo này từ chuỗi bạn truyền vào hàm khởi tạo của chúng.

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