Trình biên dịch dịch được mã của bạn thành công không có nghĩa mã đúng. Rất nhiều lỗi hợp lệ về cú pháp nhưng sai về ý — = thay vì ==, in một long bằng %d, dùng một biến chưa gán giá trị — biên dịch trơn tru, exit 0, rồi chạy sai. Cờ cảnh báo -Wall -Wextra là lưới đầu tiên bắt những lỗi đó. Bài này đo lưới ấy bắt được gì trong container — và phát hiện nó có một cái lỗ ngay chỗ tôi tưởng nó chắc nhất.
Cảnh báo bắt lỗi mà mã vẫn dịch
Tôi viết một file warn.c với năm lỗi kinh điển, tất cả đều biên dịch thành công (không một lỗi nào):
if(x = 0){ ... } // '=' thay vì '==' — gán rồi kiểm, luôn sai
int y; if(cond) y = 5; return y+1; // dùng y khi cond=0 chưa gán
long v = 4000000000L; printf("%d\n", v); // %d cho một long
for(int i=0; i<n; i++){} // i (signed) so với n (unsigned)
int khong_dung = 42; // biến khai rồi không dùng
Biên dịch không cờ cảnh báo nào: 0 cảnh báo — trình biên dịch im lặng hoàn toàn, mã vẫn ra. Đây là điểm nguy hiểm: dịch sạch không phải bằng chứng đúng.
Bật -Wall -Wextra, trình biên dịch lên tiếng — 4 cảnh báo:
-Wparentheses : gợi ý '=' trong điều kiện (có thể bạn muốn '==')
-Wformat : %d nhận 'long' nhưng chờ 'int'
-Wunused-variable: 'khong_dung' khai rồi không dùng
-Wsign-compare : so sánh 'int' với 'unsigned' (-Wextra)
Bốn lỗi thật, mỗi lỗi là một bug tiềm tàng, tất cả bị bắt trước khi chạy. Đáng chú ý là -Wall không phải một cờ đơn, mà là một tập hợp hàng chục cảnh báo con mà cộng đồng gcc chọn ra vì chúng "gần như luôn là lỗi thật"; -Wextra thêm một tập nữa nghiêm hơn (như -Wsign-compare). Có những cảnh báo hữu ích không nằm trong cả hai — -Wshadow (biến che biến ngoài), -Wconversion (thu hẹp kiểu ngầm) — mà bạn phải bật riêng nếu muốn. Đây là lý do -Wall -Wextra nên bật mặc định trong mọi dự án: nó biến những cái bẫy im lặng thành thông báo rõ ràng, gần như không tốn gì.
Một lần tôi đo hớ: lỗ ngay chỗ tôi tin nhất
Tôi vào bài với hai giả định. Thứ nhất, tôi chọn lỗi dùng biến chưa khởi tạo (bug_uninit) làm ví dụ ngôi sao — nó là lỗi nguy hiểm nhất trong năm, gây hành vi không xác định và giá trị rác, và tôi chắc -Wall sẽ bắt nó rực rỡ. Thứ hai, tôi tin rằng -O2 sẽ bắt nhiều cảnh báo hơn -O0, vì phân tích luồng dữ liệu để phát hiện biến chưa khởi tạo là một phần của tối ưu.
Phép đo bác bỏ cả hai. Về giả định thứ hai: -O0 và -O2 bắt y hệt bốn cảnh báo — không có cái nào thêm ở -O2. Về giả định thứ nhất, và đây mới là cú sốc: lỗi bug_uninit bị bắt bởi không cái nào — 0 cảnh báo ở cả -O0 lẫn -O2, và kể cả khi tôi bật tường minh -Wmaybe-uninitialized, nó vẫn im. Chạy hàm đó: f(0)+1 trả về 6 — một giá trị rác đọc từ ô nhớ chưa gán, tình cờ ra 6 lần này, không ổn định lần sau.
Vì sao lỗi nghiêm trọng nhất lại lọt lưới? Vì cái tên cảnh báo đã nói thật: -Wmaybe-uninitialized — chữ "maybe" là nghĩa đen. Phân tích tĩnh của gcc để phát hiện biến chưa khởi tạo là bảo thủ: nó chỉ báo khi khá chắc chắn, và cố tình im khi không chắc, để tránh làm phiền bạn bằng hàng loạt báo giả (false positive) trên mã thực ra đúng. Cái giá của việc ít báo giả là bỏ lọt nhiều báo thật (false negative) — và bug_uninit của tôi rơi đúng vào vùng "không chắc" đó.
Bài học đo lường: -Wall -Wextra = bắt hết lỗi là một ước lượng nguy hiểm. Cảnh báo bắt đáng tin các lỗi cú pháp và cục bộ — thứ nhìn thấy ngay trong một dòng hay một biểu thức (= nhầm, %d sai kiểu, biến thừa, so sánh lệch dấu). Nhưng các lỗi cần phân tích luồng dữ liệu sâu — biến có được gán trên mọi đường đi tới chỗ dùng không — thì cảnh báo tĩnh bỏ lọt, vì phân tích đó vốn không đầy đủ được (bài toán không quyết định được trong trường hợp tổng quát). Cho những lỗi ấy, công cụ đúng là lúc chạy: sanitizer bộ nhớ, valgrind — chúng theo dõi từng lần đọc thật và bắt được cái mà phân tích tĩnh chỉ "maybe". Tôi suýt viết một bài ca ngợi -Wall bắt mọi lỗi; phép đo dạy tôi rằng nó là lưới đầu tiên có lỗ, không phải bảo chứng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn bật -Wall -Wextra, và cân nhắc -Werror. Đây là công cụ rẻ nhất để bắt cả một lớp lỗi trước khi chạy — không tốn thời gian chạy, không đổi hành vi, chỉ cần thêm hai cờ. -Werror biến cảnh báo thành lỗi biên dịch, buộc bạn xử lý thay vì cuộn qua; trên một codebase kỷ luật, không cảnh báo nào được phép lọt vào nhánh chính. Bỏ qua cảnh báo là bỏ qua những bug mà máy đã chỉ tận nơi cho bạn.
Hệ quả thứ hai: đừng nhầm "không cảnh báo" với "không lỗi". Một bản build sạch -Wall -Wextra là điều kiện cần, không phải điều kiện đủ. Những lỗi độc nhất và nguy hiểm — dùng biến chưa khởi tạo, đọc ngoài mảng, dùng bộ nhớ đã giải phóng, rò rỉ, đua tranh luồng — phần lớn không hiện ra trong cảnh báo tĩnh. Chúng cần một tầng lưới thứ hai chạy lúc thực thi: -fsanitize=address, -fsanitize=undefined, -fsanitize=thread, valgrind. Một quy trình nghiêm túc dùng cả hai: cảnh báo tĩnh chặn lỗi cú pháp/cục bộ ở khâu biên dịch, sanitizer chặn lỗi luồng/bộ nhớ ở khâu test.
Hệ quả thứ ba là bài học mang theo: mỗi công cụ có phạm vi bắt lỗi riêng, và tin nó ngoài phạm vi đó là tự ru ngủ. Con số mang theo: -Wall -Wextra bắt đáng tin bốn lỗi cú pháp/cục bộ mà mã vẫn dịch exit 0 (= nhầm, %d cho long, biến thừa, lệch dấu), nhưng bỏ lọt lỗi dùng biến chưa khởi tạo ở cả -O0 lẫn -O2, kể cả -Wmaybe-uninitialized — vì phân tích tĩnh bảo thủ; lỗi đó cần sanitizer lúc chạy. Bật cảnh báo là bắt buộc, nhưng "biên dịch sạch, không cảnh báo" chỉ nói mã của bạn qua được lưới đầu tiên — không nói nó đúng.
Thử ba mươi giây
Viết một hàm với một biến chưa khởi tạo trên một nhánh — int f(int c){ int y; if(c) y = 5; return y; } — và biên dịch gcc -Wall -Wextra -O2 -c. Rất có thể bạn không thấy cảnh báo nào, dù đây là một lỗi rõ ràng: gcc bảo thủ, chỉ báo khi chắc. Giờ thêm một dòng cú pháp sai kiểu vào đâu đó — printf("%d\n", 3.14) chẳng hạn — và biên dịch lại: cảnh báo -Wformat hiện ra ngay, vì đó là lỗi cục bộ nhìn thấy trong một dòng. Đó là ranh giới: cảnh báo tĩnh giỏi cái nhìn thấy tại chỗ, kém cái cần lần theo luồng dữ liệu — và cho vế thứ hai, bạn phải chạy mã dưới một sanitizer chứ không chỉ biên dịch nó.