Hình dung một trạm kiểm tra an ninh với hai bước: liếc thẻ (rẻ, nhanh) rồi quét toàn thân (đắt hơn). Nghe hợp lý là "ai thẻ hợp lệ thì bỏ qua bước quét" — chỉ quét số ít người thẻ đáng ngờ, tiết kiệm bao công. Nhưng có một chi phí ẩn: cái quyết định "người này có cần quét không" bản thân nó cũng tốn thời gian, tốn sự do dự. Nếu bước quét thật ra khá nhanh, thì cứ quét thẳng tất cả mọi người có khi lại trôi hơn là dừng lại cân nhắc từng người. Toán tử && với mẹo "đặt điều kiện rẻ trước" của nó đúng là cái trạm này, và tôi đã tin chắc nó nhanh — cho tới lúc đo.
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 đo mẹo đó trong container gcc:13 (ARM), 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: 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. Đúng cái trạm an ninh: cứ quét thẳng nhanh hơn là dừng lại cân nhắc.
Đây là chỗ tôi nhầm: 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 nó 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 cú vấp 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ề đến code hằng ngày
Điều đầ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. Còn 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.
Điều 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.
Cái bảo đảm dừng sớm này may thay là một trong số ít thứ giống hệt nhau qua gần như mọi ngôn ngữ, nên thói quen mang theo được. Java, Go, JavaScript, Python đều có &&/|| (Python là and/or) dừng sớm y hệt, và chính vì thế if (obj != null && obj.field > 0) là thành ngữ null-guard phổ biến nhất trong Java. Python còn khai thác sâu hơn: x = a or default trả về a nếu nó "thật", ngược lại default — dùng giá trị vế được chọn chứ không phải true/false. Go và Rust cũng vậy. Nhưng chỗ nhiều người vấp đúng là chỗ đối lập với C bài này: trong ngôn ngữ có toán tử bitwise riêng (&, |), Java và C# vẫn cho bạn viết a & b trên boolean — nó biên dịch được, luôn tính cả hai vế, và âm thầm mất short-circuit; đó là một bug null-guard kinh điển. Còn ở tầng CSDL thì WHERE a AND b không bảo đảm thứ tự — bộ tối ưu tự do đảo điều kiện nào rẻ lên trước, nên bạn không được dựa vào WHERE p IS NOT NULL AND p.x > 0 để tránh lỗi; phải dùng CASE WHEN. Mẫu số chung: short-circuit là hợp đồng ngữ nghĩa ở ngôn ngữ mệnh lệnh — quý vì tính đúng, không vì tốc độ — nhưng đừng mang giả định đó sang SQL nơi thứ tự điều kiện là của bộ tối ưu.
Muốn tự thấy thì 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 độ.