Toán tử && và || trong C có một tính chất ai cũng biết: dừng sớm (short-circuit). a && b không tính b nếu a đã sai; a || b không tính b nếu a đã đúng. Từ đó ra một mẹo tối ưu quen thuộc: đặt điều kiện rẻ và hay-sai trước, để phần lớn thời gian né được điều kiện đắt bên phải. Tôi vào đo mẹo đó, tin nó luôn nhanh hơn — và đo được hai điều bất ngờ: hiệu năng đi ngược, còn giá trị thật của && nằm ở chỗ khác hẳn.
Dừng sớm để né phép đắt — trên lý thuyết
Ý tưởng rõ ràng: nếu bạn viết if(cheap(x) && expensive(x)) và cheap(x) thường sai, thì expensive(x) phần lớn không phải chạy — dừng sớm cắt nó đi. Đảo lại thành if(expensive(x) && cheap(x)) thì expensive chạy mọi lần. Nghe như đặt điều kiện loại-bỏ-nhiều lên trước luôn thắng. Tôi đo trong container gcc:13 (ARM AArch64): cheap(x) đúng khoảng 1/32 lần (rẻ), expensive(x) là một chuỗi 20 phép nhân (đắt hơn), luôn trả về đúng.
cheap && expensive (rẻ trước, expensive chạy ~1/32) : 1,046 ns/phần tử
expensive && cheap (đắt trước, expensive chạy mọi lần): 0,910 ns/phần tử
cheap & expensive (bitwise, LUÔN tính cả hai) : 0,696 ns/phần tử
Kết quả lật ngược trực giác. Bản "rẻ trước" — chạy expensive ít nhất — lại chậm nhất. Và bản dùng & bitwise (không dừng sớm, tính cả hai mọi lần) nhanh nhất. Vì sao?
Lý do là cái nhánh. Dừng sớm thêm một rẽ nhánh phụ thuộc dữ liệu: "cheap có đúng không, có cần gọi expensive không?". Với cheap đúng ngẫu nhiên 1/32, cái nhánh này đoán sai một phần đáng kể (như dự đoán nhánh đã đo), và mỗi lần đoán sai phạt nhiều chu kỳ. Trong khi đó, expensive chỉ là 20 phép nhân — không đủ đắt để cái tiết kiệm (bỏ nó 31/32 lần) bù lại chi phí nhánh. Bản & bitwise không có nhánh nào: tính cả hai, AND lại — như chuyển nhánh thành không nhánh đã cho thấy, khi hai vế đủ rẻ, làm cả hai không nhánh thắng việc rẽ để bỏ bớt.
Đây là đo hớ của tôi: tôi tin "đặt điều kiện rẻ trước để short-circuit né phép đắt luôn nhanh hơn". Đo ra dừng sớm chỉ thắng tốc độ khi vế phải thật sự đắt — một lời gọi I/O, một cấp phát bộ nhớ, một vòng lặp lớn — đủ để phần tiết kiệm vượt chi phí nhánh. Với phép rẻ-tới-vừa, nhánh của short-circuit tốn hơn cả cái nó tránh.
Giá trị thật của && là ngữ nghĩa, không phải tốc độ
Nếu & nhanh hơn, sao không luôn dùng & thay &&? Vì && không chỉ là tối ưu — nó là bảo đảm ngữ nghĩa của ngôn ngữ. Ca kinh điển là bảo vệ null:
int safe(struct Node* p){ return p && p->x > 0; }
objdump cho thấy điều này compile thành:
safe:
cbz x0, .L3 (nếu p == 0, nhảy tới .L3)
ldr w0, [x0] (chỉ đọc p->x KHI p != 0)
...
.L3:
mov w0, 0 (p null -> trả 0, KHÔNG đọc)
Dừng sớm compile thành một nhánh nhảy qua việc đọc p->x khi p là null. Chạy safe(NULL) trả về 0, không crash. Nhưng nếu bạn đảo thành p->x > 0 && p (đọc p->x trước), hay dùng p & ... (bitwise, luôn tính vế phải), thì với p == NULL bạn deref con trỏ null → crash. Ở đây thứ tự và toán tử không đổi hiệu năng — chúng đổi đúng/sai, và cái "tối ưu" & phá vỡ sự an toàn.
Vậy đo hớ có hai lớp. Lớp một: dừng sớm không phải luôn nhanh hơn (bitwise không-nhánh thắng khi vế phải rẻ). Lớp hai, quan trọng hơn: && tồn tại chủ yếu để đúng, không phải để nhanh — và thay nó bằng & để "branchless" là đánh đổi mất bảo vệ, một cái bug chờ nổ.
|| là mặt đối, cùng một bảo đảm
Mọi điều trên áp cho || theo chiều ngược. a || b bỏ b khi a đã đúng, nên nó dùng để thoát sớm khỏi đường lỗi: if(ptr == NULL || ptr->hỏng) return LỖI; — nếu ptr null thì không đọc ptr->hỏng. Và mẫu gán-rồi-kiểm quen thuộc if((p = tìm(k)) && p->ok) dựa vào chính bảo đảm thứ tự: gán trước, chỉ đọc p->ok khi p khác null. Những thành ngữ này chỉ đúng vì dừng sớm được ngôn ngữ bảo đảm — không phải một tối ưu có thể bật/tắt, mà là ngữ nghĩa cố định bạn xây logic lên trên.
Ngược lại, đúng ra phải nhớ: dừng sớm khiến vế phải có thể không chạy. Nếu bạn đặt một tác dụng phụ cần thiết ở vế phải — if(kiểm() && ghi_log()) — thì ghi_log() bị bỏ khi kiểm() sai. Đó là lỗi ẩn phổ biến: một biểu thức tưởng luôn chạy nhưng bị dừng sớm cắt đi. Tác dụng phụ nên đứng riêng, không nấp sau &&/||.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng thứ tự điều kiện cho đúng đắn trước, cho tốc độ sau — và đo trước khi tin. Đặt điều kiện bảo vệ (null check, kiểm biên) lên trước là bắt buộc về ngữ nghĩa: p && p->x, i < n && a[i]. Việc này không thương lượng. Chuyện đặt điều kiện rẻ trước để nhanh chỉ đáng khi vế phải thật sự đắt; với phép rẻ, nó có thể còn chậm hơn vì cái nhánh — đo mới biết.
Hệ quả thứ hai: đừng thay &&/|| bằng &/| để "tránh nhánh" nếu vế phải có tác dụng phụ hoặc có thể lỗi. & và | (bitwise) luôn tính cả hai vế — mất hoàn toàn tính bảo vệ của dừng sớm. p & (p->x > 0) deref null; f() | g() gọi cả f lẫn g kể cả khi không cần. Chỉ dùng &/| khi cả hai vế thuần (không tác dụng phụ), luôn an toàn để tính, và bạn đã đo thấy nó nhanh hơn.
Hệ quả thứ ba là bài học đo lường: dừng sớm là ngữ nghĩa ngôn ngữ (bảo đảm), khác dự đoán nhánh (phần cứng) và branchless (mã). Đừng lẫn "dừng sớm bỏ được phép đắt" (đúng về mặt công việc) với "dừng sớm luôn nhanh hơn" (sai khi cái nhánh đắt hơn cái được bỏ). Con số mang theo: && và || dừng sớm là bảo đảm NGÔN NGỮ (tính trái trước, bỏ phải khi thừa) — giá trị chính là NGỮ NGHĨA (p && p->x compile thành cbz nhảy qua deref, an toàn với null; đảo thứ tự hay thay & là crash), không phải tốc độ: đo ra bitwise & không nhánh nhanh nhất (0,696 ns) rồi đắt-trước (0,910) rồi rẻ-trước (1,046) vì phép 20 nhân chưa đủ đắt để bù chi phí nhánh, short-circuit chỉ thắng tốc độ khi vế phải THẬT SỰ đắt. Chọn && vì đúng; chỉ đổi vì tốc độ sau khi đo và khi vế phải an toàn để tính.
Thử ba mươi giây
Viết int f(int*p){ return p && *p > 0; } và gcc -O2 -S -o - t.c. Tìm f: bạn sẽ thấy một cbz/cbnz (nhảy khi con trỏ 0) trước lệnh đọc *p — đó là dừng sớm compile thành nhánh bảo vệ. Gọi f(NULL) — trả 0, không crash. Giờ đổi thành return *p > 0 && p; (đảo thứ tự) và gọi f(NULL) — lần này nó đọc *p trước khi kiểm null, và crash. Ba mươi giây đó cho bạn thấy && không phải chỉ là "và logic", mà là một bảo đảm về thứ tự và bỏ qua — thứ giữ cho p && p->x an toàn, và thứ bạn không được vứt đi vì một con số tốc độ.