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 tôi đo xem lưới ấy bắt được gì trong container — và phát hiện nó có một cái lỗ ngay đúng chỗ tôi tưởng nó chắc chắn 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. Có một chi tiết đáng biết: -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). Và 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ì.

Cái lỗ nằm 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 mẩm -Wall sẽ bắt nó rực rỡ. Thứ hai, tôi tin -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 vốn 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 đảm bảo 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 rồi: -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 rút ra làm tôi phải sửa hẳn cách nghĩ: coi "-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 thể đầy đủ (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ỉ dám nói "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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Và điều cuối, cũng là bài học mang theo: mỗi công cụ có phạm vi bắt lỗi riêng, tin nó ngoài phạm vi đó là tự ru ngủ. Gói lại bằng số: -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.

Cái ranh giới "tĩnh bắt tại chỗ, động mới thấy luồng dữ liệu" này lặp lại y hệt ở các ngôn ngữ khác, chỉ khác tên gọi. Java có javac -Xlint cùng các linter như SpotBugs/Error Prone đóng vai lưới tĩnh, còn NullPointerException lúc chạy chính là cái "biến chưa gán" của C hiện hình muộn — và các annotation @Nullable cùng checker-framework là nỗ lực kéo một phần vế động đó về phía tĩnh. Go đi xa nhất theo hướng "bật sẵn": go vet chạy cùng go build, và trình biên dịch từ chối biến khai mà không dùng — biến cảnh báo -Wunused-variable thành lỗi cứng, đúng tinh thần -Werror. Ngay cả cơ sở dữ liệu cũng có hai tầng: kiểu cột và ràng buộc NOT NULL là lưới tĩnh bắt lúc định nghĩa, còn một truy vấn chậm bất ngờ chỉ lộ khi EXPLAIN ANALYZE chạy trên dữ liệu thật. Mẫu số chung: một bản build sạch, một linter xanh, đều mới chỉ là qua lưới đầu; muốn biết mã đúng phải cho nó chạy dưới công cụ soi được hành vi thật.

Muốn tự thấy thì 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 sai kiểu vào đâu đó — printf("%d\n", 3.14) chẳng hạn — rồi 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ó.