Ngoại lệ: ném, thử, bắt
Hiểu cơ chế ngoại lệ C++ — giải phóng ngăn xếp, phân cấp ngoại lệ tiêu chuẩn, ngoại lệ tùy chỉnh và thời điểm ưu tiên ngoại lệ hơn mã lỗi.
Tại sao ngoại lệ tồn tại
Trước khi có ngoại lệ, mã C báo hiệu lỗi bằng cách trả về các giá trị đặc biệt (như-1hoặcNULL). Điều này có một lỗ hổng nghiêm trọng: người gọi có thể âm thầm bỏ qua lỗi. Các ngoại lệ giải quyết vấn đề này bằng cách buộc chương trình xử lý lỗi hoặc chấm dứt. Họ cũng tách biệt trang phát hiện lỗi (sâu trong mã thư viện) khỏi trang xử lý lỗi (trong mã ứng dụng), trang này có thể cách nhau nhiều khung cuộc gọi.
Các trường hợp ngoại lệ C++ tuân theo mô hình "chi phí bằng 0 trên con đường thành công" trên hầu hết các trình biên dịch hiện đại (sử dụng tính năng tháo gỡ dựa trên bảng). Khi không có ngoại lệ nào được đưa ra, sẽ không có chi phí thời gian chạy — không có kiểm traifẩn, không có dự đoán nhánh nào bị bỏ sót. Chi phí chỉ được thanh toán khi một ngoại lệ thực sự được đưa ra, điều này làm cho các ngoại lệ trở nên lý tưởng cho các điều kiện lỗi đặc biệt (hiếm).
ném, thử và bắt
Cơ chế cốt lõi rất đơn giản: ném một đối tượng để báo hiệu lỗi, bọc mã rủi ro trong khối thử và bắt các loại ngoại lệ phù hợp.
#include <iostream>
#include <stdexcept>
#include <string>
double divide(double numerator, double denominator) {
if (denominator == 0.0) {
throw std::invalid_argument("denominator cannot be zero");
}
return numerator / denominator;
}
int main() {
try {
double result = divide(10.0, 0.0);
std::cout << "Result: " << result << '\n';
} catch (const std::invalid_argument& e) {
std::cerr << "Invalid argument: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
} catch (...) {
std::cerr << "Unknown error occurred\n";
}
}Ngăn xếp giải nén chi tiết
Khi một ngoại lệ được ném ra, bộ thực thi sẽ thực hiện giải phóng ngăn xếp: nó quay trở lại ngăn xếp lệnh gọi, hủy tất cả các biến cục bộ trong mỗi khung bằng cách gọi hàm hủy của chúng. Đây là lý do tại sao RAII (Khởi tạo thu thập tài nguyên) lại rất quan trọng — nếu bạn giữ tài nguyên trong trình bao bọc RAII, chúng sẽ tự động được giải phóng trong quá trình giải phóng.
Quá trình tháo gỡ diễn ra như sau:
1. Biểu thứcthrowtạo đối tượng ngoại lệ (thường trên một vùng bộ nhớ đặc biệt, không phải trên ngăn xếp).
2. Thời gian chạy tìm kiếm hàm hiện tại để tìm trình xử lýcatchphù hợp.
3. Nếu không tìm thấy, nó sẽ thoát khỏi chức năng hiện tại, hủy tất cả các đối tượng cục bộ theo thứ tự xây dựng ngược lại.
4. Nó lặp lại các bước 2-3 trong ngăn xếp cuộc gọi.
5. Nếu không tìm thấy trình xử lý nào trong bất kỳ khung nào,std::terminate()sẽ được gọi.
Quy tắc chính: ném theo giá trị, bắt theo tham chiếuconst. Ném theo giá trị sẽ tránh được các con trỏ lơ lửng. Việc bắt bằng tham chiếuconstsẽ tránh bị cắt (trong đó ngoại lệ dẫn xuất được sao chép vào đối tượng lớp cơ sở, làm mất dữ liệu dẫn xuất) và tránh các bản sao không cần thiết.
Hệ thống phân cấp ngoại lệ tiêu chuẩn
Thư viện chuẩn cung cấp hệ thống phân cấp bắt nguồn từstd::exception. Hiểu nó giúp bạn ném đúng kiểu và bắt đúng cấp độ.
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error (programmer mistakes - precondition violations)
// │ ├── std::invalid_argument
// │ ├── std::domain_error
// │ ├── std::length_error
// │ └── std::out_of_range
// ├── std::runtime_error (errors detectable only at runtime)
// │ ├── std::range_error
// │ ├── std::overflow_error
// │ ├── std::underflow_error
// │ └── std::system_error (OS-level errors with error_code)
// └── std::bad_alloc (memory allocation failure)
// std::bad_cast (failed dynamic_cast)
// std::bad_typeid (typeid on null pointer)
#include <stdexcept>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3};
try {
// .at() throws std::out_of_range, unlike operator[] which is UB
int val = v.at(10);
} catch (const std::out_of_range& e) {
std::cerr << "Out of range: " << e.what() << '\n';
}
}Các lớp ngoại lệ tùy chỉnh
Bắt nguồn từstd::exceptionhoặc một trong các lớp con của nó để tạo các ngoại lệ dành riêng cho miền. Luôn ghi đèwhat()để cung cấp thông điệp có ý nghĩa.
#include <stdexcept>
#include <string>
#include <iostream>
class FileParseError : public std::runtime_error {
public:
FileParseError(const std::string& filename, int line, const std::string& msg)
: std::runtime_error(
"Parse error in '" + filename + "' at line " +
std::to_string(line) + ": " + msg),
filename_(filename),
line_(line) {}
const std::string& filename() const noexcept { return filename_; }
int line() const noexcept { return line_; }
private:
std::string filename_;
int line_;
};
void parse_config(const std::string& filename) {
// Simulate a parse failure at line 42
throw FileParseError(filename, 42, "unexpected token '@@'");
}
int main() {
try {
parse_config("app.conf");
} catch (const FileParseError& e) {
std::cerr << e.what() << '\n';
std::cerr << " File: " << e.filename()
<< ", Line: " << e.line() << '\n';
}
}Thông số kỹ thuật không ngoại lệ và ngoại lệ
Trình xác định noexcept cho trình biên dịch (và trình đọc của con người) biết rằng một hàm sẽ không bao giờ đưa ra ngoại lệ. Nếu hàmnoexceptthực hiện ném,std::terminate()sẽ được gọi ngay lập tức - không có thao tác giải phóng ngăn xếp.
Tại saonoexceptlại quan trọng?
- Hiệu suất: Trình biên dịch có thể bỏ qua các bảng tháo gỡ cho các hàmnoexcept, tạo ra mã chặt chẽ hơn.
- Tối ưu hóa STL: Các vùng chứa nhưstd::vectorsẽ di chuyển các phần tử trong quá trình tái phân bổ chỉ khi hàm tạo di chuyển lànoexcept. Nếu không, họ sẽ quay lại sao chép để duy trì sự đảm bảo ngoại lệ mạnh mẽ. Đây có thể là một sự khác biệt đáng kể về hiệu suất.
- Tài liệu: Đây là hợp đồng với người gọi rằng chức năng không thể bị lỗi.
Sử dụngnoexcepttrên: hàm tạo di chuyển, toán tử gán di chuyển, hàm hoán đổi, hàm hủy (ngầm lànoexcept), hàm getter đơn giản và toán tử so sánh.
Thông sốthrow()cũ (vàthrow(type-list)) không được dùng nữa trong C++11 và bị xóa trong C++20. Thay vào đó, hãy luôn sử dụngnoexcept.
Không bao giờ ném ngoại lệ vào hàm hủy. Trong khi giải phóng ngăn xếp khỏi một ngoại lệ khác, việc ném ngoại lệ thứ hai gọistd::terminate(). Nếu hàm hủy phải thực hiện một thao tác có thể thất bại, hãy bắt ngoại lệ bên trong hàm hủy và xử lý nó ở đó (ví dụ: ghi nhật ký và loại bỏ).
- Bắt theo giá trị lát các ngoại lệ xuất phát — luôn được
const&bắt - Ném con trỏ (ví dụ:
throw new std::runtime_error(...)) dẫn đến rò rỉ bộ nhớ — ném theo giá trị - Bắt
...trước ẩn tất cả các trình xử lý cụ thể bên dưới nó - đặt nó ở cuối - Ngoại lệ trong hàm hủy trong quá trình giải phóng ngăn xếp gây ra
std::terminate() - Sử dụng ngoại lệ cho luồng điều khiển (đường dẫn không có lỗi) sẽ phá hủy mô hình chi phí bằng 0 và gây tổn hại đến hiệu suất
- Ném theo giá trị, bắt theo tham chiếu
constđể tránh bị cắt và rò rỉ - Việc tháo ngăn xếp sẽ phá hủy các đối tượng cục bộ theo thứ tự xây dựng ngược lại — RAII đảm bảo việc dọn dẹp
- Các trường hợp ngoại lệ C++ không tốn chi phí trên đường dẫn thành công (giải phóng dựa trên bảng), chỉ tốn kém khi bị loại bỏ
noexceptcho phép tối ưu hóa trình biên dịch và rất quan trọng đối với các hoạt động di chuyển được STL sử dụng- Lấy các ngoại lệ tùy chỉnh từ
std::runtime_errorhoặcstd::logic_errorvà ghi đèwhat()
Câu đố — Kiểm tra kiến thức
(15 XP)1. Tại sao bạn nên nắm bắt ngoại lệ theo`const&`thay vì theo giá trị?
2. Điều gì xảy ra nếu một hàm được đánh dấu`noexcept`ném ra một ngoại lệ?
3. Trong quá trình giải phóng ngăn xếp, điều gì xảy ra với các biến cục bộ trong mỗi khung ngăn xếp?