Trong bài này chúng ta sẽ học xử lý ngoại lệ trong C++: cách báo lỗi bằng throw, bắt lỗi bằng try và catch, các lớp ngoại lệ có sẵn trong thư viện chuẩn, tự định nghĩa ngoại lệ, chuyện gì xảy ra với các đối tượng khi có ngoại lệ, và từ khóa noexcept. Xử lý ngoại lệ giúp chương trình gặp lỗi như nhập sai, không tìm thấy dữ liệu vẫn chạy tiếp được, thay vì crash.
Bài 26 trong Serie Lập trình C++ từ A tới Z
Ngoại lệ là gì?
Ngoại lệ (exception) là một tình huống bất thường xảy ra lúc chương trình chạy mà đoạn code hiện tại không tự xử lý được: chia cho 0, người dùng gõ chữ vào ô nhập số, tìm sách không có trong kho, hết bộ nhớ.
Trong C, hàm thường báo lỗi bằng giá trị trả về đặc biệt như -1 hay NULL. Cách này có 2 nhược điểm: người gọi rất hay quên kiểm tra, và nhiều hàm không có giá trị nào “dư” để làm mã lỗi. Ví dụ hàm tính trung bình thì mọi số double đều có thể là kết quả hợp lệ.
Ngoại lệ giống như chuông báo cháy. Ai phát hiện cháy thì bấm chuông (throw), còn người phụ trách xử lý (catch) có thể ở tầng khác hẳn. Người bấm chuông không cần biết ai sẽ dập lửa, và chuông đã kêu thì không thể bị lờ đi.
Xử lý ngoại lệ trong C++ với try, catch và throw
#include <iostream>
#include <stdexcept>
double tinhTrungBinh(int tong, int soLuong)
{
if (soLuong == 0)
throw std::invalid_argument("so luong bang 0, khong chia duoc");
return static_cast<double>(tong) / soLuong;
}
int main()
{
try
{
std::cout << "Trung binh 1: " << tinhTrungBinh(30, 4) << '\n';
std::cout << "Trung binh 2: " << tinhTrungBinh(30, 0) << '\n';
std::cout << "Dong nay khong chay\n";
}
catch (const std::invalid_argument& e)
{
std::cout << "Loi: " << e.what() << '\n';
}
std::cout << "Chuong trinh van chay tiep\n";
return 0;
}
throw std::invalid_argument("..."): ném ra một ngoại lệ kèm thông báo. Hàm dừng ngay tại đó, không chạy tớireturn.try { ... }: khối code có thể ném ngoại lệ.catch (const std::invalid_argument& e): bắt ngoại lệ đúng kiểu đó.e.what()trả về thông báo lỗi.
Khi ngoại lệ được ném, chương trình nhảy thẳng tới khối catch phù hợp, bỏ qua phần còn lại của try. Vì vậy dòng “Dong nay khong chay” không được in. Còn chữ “Trung binh 2: ” vẫn in ra vì từ C++17, các vế << được tính lần lượt từ trái qua phải, nên chữ đó được in trước khi hàm tinhTrungBinh được gọi.
Không bắt ngoại lệ thì sao?
Ngoại lệ không được catch ở đâu cả thì chương trình bị dừng ngay:
#include <iostream>
#include <vector>
int main()
{
std::vector<int> maSach = {1001, 1002, 1005};
std::cout << "Phan tu thu 10: " << maSach.at(10) << '\n';
return 0;
}
at(10) vượt ngoài vector nên ném std::out_of_range. Không có catch nào, C++ gọi std::terminate() và chương trình kết thúc kèm thông báo terminate called after throwing.... Chúng ta đã gặp đúng tình huống này với std::array::at() ở bài mảng và con trỏ trong C++.
Các lớp ngoại lệ chuẩn
Thư viện chuẩn có sẵn một họ các lớp ngoại lệ trong <stdexcept>, tất cả đều kế thừa từ std::exception:
Nhờ tính kế thừa và đa hình, catch (const std::exception& e) bắt được mọi loại ngoại lệ chuẩn, và e.what() trả về đúng thông báo của loại cụ thể. Một số hàm hay ném ngoại lệ:
vector::at(),string::at(),array::at():std::out_of_rangekhi vượt chỉ số.std::stoi,std::stod(đổi chuỗi sang số):std::invalid_argumentkhi chuỗi không phải số,std::out_of_rangekhi số quá lớn.new:std::bad_allockhi hết bộ nhớ.
Bắt lỗi khi chuyển chuỗi sang số
Đọc dữ liệu người dùng nhập là chỗ dùng ngoại lệ nhiều nhất. Nhiều catch liên tiếp để xử lý từng loại lỗi:
#include <iostream>
#include <stdexcept>
#include <string>
int main()
{
std::string dauVao;
std::cout << "Nhap ma sach: ";
std::getline(std::cin, dauVao);
try
{
int maSach = std::stoi(dauVao);
std::cout << "Ma sach: " << maSach << '\n';
}
catch (const std::invalid_argument&)
{
std::cout << "'" << dauVao << "' khong phai so\n";
}
catch (const std::out_of_range&)
{
std::cout << "'" << dauVao << "' qua lon\n";
}
return 0;
}
catch không dùng tới biến lỗi thì bỏ luôn tên biến như trên, viết catch (const std::invalid_argument&) là đủ.
Thứ tự các khối catch
Các khối catch được kiểm tra từ trên xuống, khối đầu tiên khớp kiểu sẽ bắt. Vì vậy phải đặt lớp con lên trước lớp cha:
#include <iostream>
#include <stdexcept>
#include <vector>
int main()
{
std::vector<int> v = {1, 2, 3};
try
{
std::cout << v.at(5) << '\n';
}
catch (const std::exception& e)
{
std::cout << "Loi chung: " << e.what() << '\n';
}
catch (const std::out_of_range& e)
{
std::cout << "Vuot mang: " << e.what() << '\n';
}
return 0;
}
std::out_of_range là con của std::exception, nên khối catch chung ở trên đã bắt mất, khối out_of_range bên dưới không bao giờ chạy tới. g++ cảnh báo will be caught by earlier handler. Đảo 2 khối lại là đúng: cụ thể trước, chung sau.
Tự định nghĩa ngoại lệ
Lỗi riêng của chương trình thì nên có lớp ngoại lệ riêng, kế thừa từ lớp chuẩn để vẫn bắt được bằng std::exception:
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
class KhongTimThaySach : public std::runtime_error
{
public:
KhongTimThaySach(int ma)
: std::runtime_error("khong co sach ma " + std::to_string(ma)), ma_(ma) {}
int ma() const { return ma_; }
private:
int ma_;
};
struct Sach
{
int ma;
std::string ten;
};
const Sach& timSach(const std::vector<Sach>& kho, int ma)
{
for (const Sach& s : kho)
if (s.ma == ma)
return s;
throw KhongTimThaySach(ma);
}
int main()
{
std::vector<Sach> kho = {{1001, "Lap trinh C++"}, {1002, "Clean Code"}};
for (int ma : {1002, 9999})
{
try
{
std::cout << "Tim " << ma << ": " << timSach(kho, ma).ten << '\n';
}
catch (const KhongTimThaySach& e)
{
std::cout << "Loi: " << e.what() << " (ma = " << e.ma() << ")\n";
}
}
return 0;
}
Lớp KhongTimThaySach truyền thông báo lên lớp cha std::runtime_error, và mang thêm thông tin riêng là mã sách. Người bắt lỗi có thể đọc e.ma() để xử lý, ví dụ gợi ý những mã gần giống.
Để ý hàm timSach trả về tham chiếu tới cuốn sách. Nếu không có ngoại lệ, hàm này buộc phải trả về một thứ gì đó khi không tìm thấy, mà tham chiếu thì không thể rỗng như con trỏ. Ngoại lệ giải quyết gọn chuyện này.
Ngoại lệ và RAII: dọn dẹp tự động
Khi ngoại lệ được ném, chương trình thoát khỏi các hàm đang chạy dở. Trên đường thoát ra, hàm hủy của mọi đối tượng cục bộ đều được gọi. Quá trình này gọi là stack unwinding:
#include <iostream>
#include <stdexcept>
class KhoaSo
{
public:
KhoaSo() { std::cout << " Mo so muon sach\n"; }
~KhoaSo() { std::cout << " Dong so muon sach\n"; }
};
void ghiPhieuMuon(int soCuon)
{
KhoaSo so;
std::cout << " Dang ghi phieu " << soCuon << " cuon\n";
if (soCuon > 5)
throw std::runtime_error("moi lan chi duoc muon toi da 5 cuon");
std::cout << " Ghi xong\n";
}
int main()
{
try
{
ghiPhieuMuon(7);
}
catch (const std::exception& e)
{
std::cout << "Loi: " << e.what() << '\n';
}
return 0;
}
Dù hàm bị cắt ngang giữa chừng, “Dong so muon sach” vẫn được in, trước cả khi catch chạy. Đây là nền tảng của kỹ thuật RAII: gắn tài nguyên (file, bộ nhớ, khóa) với một đối tượng, mở trong hàm khởi tạo, đóng trong hàm hủy. Có ngoại lệ hay không thì tài nguyên cũng luôn được trả lại. std::unique_ptr, std::vector, std::fstream đều làm việc theo cách này.
Các lỗi hay gặp khi bắt ngoại lệ
Bắt ngoại lệ theo giá trị
#include <iostream>
#include <stdexcept>
int main()
{
try
{
throw std::runtime_error("het sach trong kho");
}
catch (std::exception e) // bat theo gia tri: bi cat lat
{
std::cout << "Bat theo gia tri: " << e.what() << '\n';
}
try
{
throw std::runtime_error("het sach trong kho");
}
catch (const std::exception& e) // bat theo tham chieu
{
std::cout << "Bat theo tham chieu: " << e.what() << '\n';
}
return 0;
}
Bắt theo giá trị catch (std::exception e) sao chép ngoại lệ thành một std::exception thuần, phần runtime_error bị cắt mất. Đây chính là lỗi cắt lát đối tượng ở bài đa hình, và kết quả là thông báo “het sach trong kho” thành “std::exception” vô nghĩa.
Lưu ý: Luôn bắt ngoại lệ bằng tham chiếu hằng catch (const LoaiLoi& e). g++ có cảnh báo -Wcatch-value khi bật -Wall. Và ném ra thì ném đối tượng throw LoaiLoi(...), không ném con trỏ throw new LoaiLoi(...), vì người bắt sẽ không biết có phải delete hay không.
Dùng ngoại lệ cho luồng xử lý bình thường
Ngoại lệ chỉ dành cho tình huống bất thường. Người dùng chọn “0. Thoát” trong menu là chuyện bình thường, dùng if hay break, đừng throw. Ném và bắt ngoại lệ chậm hơn nhiều so với if, và lạm dụng sẽ khiến luồng chương trình khó theo dõi.
noexcept
Hàm chắc chắn không ném ngoại lệ thì đánh dấu noexcept:
int soLuong() const noexcept { return soLuong_; }
noexcept là lời hứa với trình biên dịch và người đọc code. Trình biên dịch dựa vào đó để tối ưu, ví dụ std::vector sẽ di chuyển phần tử thay vì sao chép khi mở rộng nếu hàm di chuyển là noexcept. Nếu hàm noexcept mà vẫn ném ngoại lệ thì chương trình gọi std::terminate() ngay.
Hàm hủy mặc định đã là noexcept. Quy tắc quan trọng: hàm hủy không bao giờ được ném ngoại lệ, vì nó có thể đang chạy trong lúc stack unwinding của một ngoại lệ khác.
Trên vi điều khiển, nhiều project tắt hẳn ngoại lệ bằng cờ -fno-exceptions để tiết kiệm bộ nhớ chương trình. Khi đó code báo lỗi bằng giá trị trả về như C. Phần này mình sẽ nói kỹ ở bài C++ trên vi điều khiển.
Bài tập
- Viết hàm
double chia(double a, double b)némstd::invalid_argumentkhibbằng 0. Trongmain, cho người dùng nhập 2 số và bắt lỗi. - Viết chương trình đọc tuổi từ bàn phím bằng
std::getlinevàstd::stoi. Nhập sai thì bắt lỗi và yêu cầu nhập lại, cho tới khi đúng. - Tạo lớp ngoại lệ
VuotHanMuckế thừastd::runtime_error, ném ra khi độc giả mượn quá 5 cuốn, kèm số cuốn đang mượn. - Đảo thứ tự 2 khối
catchtrong ví dụ thứ tự catch ở trên, biên dịch lại và so sánh kết quả.
Lời giải sẽ có trong bài tập tổng hợp của chương nâng cao nhé.
Kết
Xử lý ngoại lệ trong C++ tách việc phát hiện lỗi (throw) khỏi việc xử lý lỗi (catch), giúp chương trình gặp lỗi vẫn chạy tiếp và không bỏ sót lỗi như cách trả về mã lỗi. Nhớ 4 quy tắc: ném đối tượng, bắt bằng const&, đặt catch cụ thể trước chung sau, và dùng RAII để tài nguyên luôn được giải phóng. Đọc thêm về cơ chế này ở trang exceptions trên cppreference.
Bài trước trong chương OOP là tính trừu tượng trong C++. Bài tiếp theo chúng ta sẽ học đọc ghi file với fstream, nơi ngoại lệ và RAII được dùng rất nhiều. Có thắc mắc gì, các bạn để lại bình luận bên dưới nhé.









