"Undefined behavior" thực sự nghĩa là gì
Trang trước đã cho thấy cách try/catch xử lý những lỗi mà chương trình của bạn định nghĩa và cố ý ném ra. Hành vi không xác định (undefined behavior) thì ngược lại: đó là tập hợp các thao tác mà chuẩn C++ từ chối gán cho bất kỳ ý nghĩa nào. Không có ngoại lệ để bắt, không có mã lỗi, cũng không đảm bảo chương trình sẽ sập. Trình biên dịch được tự do giả định UB không bao giờ xảy ra và làm bất cứ điều gì nó muốn khi UB xảy ra.
Chính sự tự do đó khiến UB nguy hiểm đến vậy. Cùng một dòng code lỗi có thể in ra đáp án "đúng" trên laptop của bạn, trả về giá trị rác trên máy chủ, và bị trình tối ưu xóa sạch ở mức -O2. UB không phải "hành vi chúng tôi không ghi lại trong tài liệu", mà là "hành vi ngôn ngữ không hứa hẹn gì cả". Việc của bạn là ngay từ đầu đừng bao giờ viết ra nó.
int arr[3] = {1, 2, 3};
int x = arr[5]; // undefined behavior: reading past the end of the array
Ở đây không có lỗi biên dịch nào, và ở nhiều lần chạy, chương trình sẽ lặng lẽ đưa cho bạn một số nguyên lạc lõng. Thành công bề ngoài đó chính là cái bẫy.
Đọc hoặc ghi ngoài phạm vi
Dạng UB phổ biến nhất là đụng vào vùng nhớ không thuộc về bạn. Mảng có sẵn và std::vector::operator[] không kiểm tra phạm vi: một chỉ số vượt quá cuối (hoặc âm) là UB ngay lập tức, dù bạn đọc hay ghi.
Bug cần để ý là <= ở chỗ bạn muốn viết <: khi i == v.size(), bạn truy cập vượt phần tử cuối cùng một vị trí, và đó là UB. Hãy ưu tiên vòng lặp for dạng range-based (đã học ở phần trước) khi không cần chỉ số, vì nó không thể chạy quá cuối. Khi bạn thật sự dùng chỉ số thủ công và muốn có lưới an toàn, v.at(i) sẽ ném std::out_of_range thay vì âm thầm làm hỏng bộ nhớ:
Dùng at() khi bạn đang truy tìm bug; quay lại [] trong các vòng lặp cần hiệu năng sau khi đã chứng minh các chỉ số hợp lệ.
Con trỏ treo và dùng vùng nhớ sau khi giải phóng
Một con trỏ hoặc tham chiếu sống lâu hơn đối tượng mà nó trỏ đến được gọi là con trỏ treo (dangling). Dùng nó là UB: vùng nhớ có thể đã được tái sử dụng, giải phóng, hoặc chưa từng tồn tại. Đây là cái bẫy mà smart pointer (ở chương trước) giúp bạn tránh, nhưng con trỏ thô vẫn để bạn rơi vào.
Phiên bản nguy hiểm nhất là trả về địa chỉ của một biến cục bộ. Biến cục bộ biến mất khi hàm trả về, nên nơi gọi chỉ còn giữ một con trỏ trỏ vào hư không:
int* makeNumber() {
int n = 42;
return &n; // returns address of a local - it's gone after return
}
// Dereferencing the result is undefined behavior.
Chuyện tương tự xảy ra sau delete, hoặc khi một vector cấp phát lại và làm vô hiệu các iterator hay con trỏ trỏ vào nó:
int* p = new int(5);
delete p;
cout << *p; // use-after-free: undefined behavior
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // may reallocate - 'first' is now dangling
cout << *first; // undefined behavior
Cách phòng thủ là những gì bạn đã biết: giữ đối tượng sống chừng nào còn con trỏ cần đến nó, ưu tiên tham chiếu và smart pointer thay cho con trỏ thô sở hữu bộ nhớ, và lấy lại con trỏ hoặc iterator sau mọi thao tác có thể thay đổi kích thước container.
Biến chưa khởi tạo và tràn số có dấu
Đọc một biến trước khi gán giá trị cho nó là UB với các kiểu có sẵn: không có giá trị mặc định 0. Biến chứa bất kỳ bit nào đã nằm sẵn trong vùng nhớ đó, và trình tối ưu có thể giả định bạn không bao giờ đọc nó khi chưa khởi tạo.
Nếu sum được khai báo trống là int sum;, mỗi lần sum += i sẽ đọc một giá trị không xác định trước tiên. Đó là UB, và là một bug nổi tiếng khó tìm vì nó thường trông như chạy đúng. Hãy tập thói quen khởi tạo: int x = 0; hoặc int x{};.
Một thủ phạm âm thầm khác là tràn số nguyên có dấu. Đẩy một int có dấu vượt quá giá trị lớn nhất là UB (kiểu không dấu quay vòng một cách có thể đoán trước; kiểu có dấu thì không):
int big = 2147483647; // INT_MAX on a 32-bit int
int oops = big + 1; // signed overflow: undefined behavior
Đừng trông chờ nó "quay vòng thành số âm": trình biên dịch được phép giả định tràn số không thể xảy ra và có thể tối ưu dựa trên giả định đó. Nếu bạn cần quay vòng có định nghĩa rõ, hãy dùng kiểu không dấu hoặc kiểm tra giới hạn trước khi cộng.
Bắt UB bằng sanitizer và cảnh báo
Bạn không thể chỉ dựa vào việc chạy thử để tự tin về UB, vì một lần chạy thành công không đảm bảo điều gì. Điều thực sự hiệu quả là khiến UB lên tiếng lúc chạy nhờ các sanitizer của trình biên dịch (có trong GCC và Clang).
// AddressSanitizer: out-of-bounds, use-after-free, leaks
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer: signed overflow, null deref, bad casts
g++ -fsanitize=undefined -g main.cpp -o app && ./app
Chạy các test hiện có với những cờ này, và lần đọc ngoài phạm vi, lần dùng vùng nhớ sau khi giải phóng hay lần tràn số có dấu từng "chạy ổn" sẽ biến thành một báo cáo chính xác, chỉ rõ tệp và dòng. Kết hợp chúng với -Wall -Wextra để trình biên dịch cũng đánh dấu code đáng ngờ (như một lần đọc có khả năng chưa khởi tạo) trước cả khi bạn chạy.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
Hãy coi mọi báo cáo của sanitizer là bug bắt buộc phải sửa, không phải cảnh báo có thể bỏ qua: nó đang nói với bạn rằng chuẩn không hứa hẹn gì về dòng code đó.
Tổng kết
Undefined behavior là phần của C++ nơi các hàng rào an toàn bị gỡ bỏ: truy cập ngoài phạm vi, con trỏ treo, dùng vùng nhớ sau khi giải phóng, đọc biến chưa khởi tạo và tràn số có dấu đều tạo ra code không có ý nghĩa xác định, và "chạy được" không bao giờ là bằng chứng code đúng. Cách để an toàn là viết code phòng thủ (khởi tạo mọi biến, tôn trọng giới hạn của container, để smart pointer sở hữu bộ nhớ heap), rồi kiểm chứng bằng -fsanitize=address, -fsanitize=undefined và -Wall -Wextra để UB âm thầm trở thành một báo cáo rõ ràng, sửa được.
Đến đây là hết chương Lỗi và gỡ lỗi. Với ngoại lệ, try/catch và một nỗi sợ lành mạnh với UB, giờ bạn đã có đủ công cụ để viết C++ thất bại một cách rõ ràng và có chủ đích, thay vì âm thầm và ngẫu nhiên.
Câu hỏi thường gặp
Undefined behavior trong C++ là gì?
Hành vi không xác định (UB) là bất kỳ thao tác nào mà chuẩn C++ cố ý để không có kết quả xác định, ví dụ đọc vượt quá cuối mảng hoặc truy cập một con trỏ treo. Trình biên dịch được phép làm bất cứ điều gì: sập chương trình, trả về giá trị rác, tối ưu bỏ luôn đoạn code, hoặc trông như chạy đúng hôm nay rồi hỏng sau một lần biên dịch lại. Đó là bug trong chương trình của bạn, không phải tính năng của ngôn ngữ.
Vì sao chương trình C++ của mình vẫn chạy dù có undefined behavior?
"Chạy được" chẳng chứng minh gì về UB. Chuẩn không đảm bảo điều gì theo cả hai hướng, nên một bug UB có thể cho đúng kết quả bạn mong đợi trên máy bạn với trình biên dịch hôm nay, rồi sập khi đổi mức tối ưu, nền tảng hoặc phiên bản trình biên dịch. Đừng bao giờ coi một lần chạy thành công là bằng chứng UB vô hại; hãy dùng sanitizer để thực sự bắt được nó.
Làm sao phát hiện undefined behavior trong C++?
Biên dịch với sanitizer: -fsanitize=address (AddressSanitizer) phát hiện đọc hoặc ghi ngoài phạm vi và dùng vùng nhớ sau khi giải phóng, còn -fsanitize=undefined (UndefinedBehaviorSanitizer) báo tràn số có dấu, truy cập con trỏ null và ép kiểu sai. Bật cảnh báo (-Wall -Wextra) và chạy test với các cờ này: chúng biến UB âm thầm thành một báo cáo rõ ràng lúc chạy.