Khi bạn viết a() + b(), hàm nào chạy trước? Trực giác nói a() (trái sang phải). Nhưng chuẩn C không quy định thứ tự đánh giá các biểu thức con của hầu hết toán tử — trình biên dịch tự chọn. Nối tiếp bài hành vi không xác định, bài này đo thứ tự đánh giá các biểu thức có tác dụng phụ, và tôi vấp một cái bẫy tinh vi hơn UB thông thường: sự nhất quán trên một máy làm tôi tưởng mình có một hành vi được định nghĩa.

Điểm dãy và tác dụng phụ

Điểm dãy: mốc mà thứ tự được bảo đảm

C chỉ bảo đảm thứ tự thực hiện tác dụng phụ tại các điểm dãy (sequence point): sau mỗi câu lệnh (;), giữa hai vế của &&, ||, toán tử phẩy ,, và trước nhánh của ?:. Toán tử phẩy , cũng là một điểm dãy — khác hẳn dấu phẩy ngăn cách đối số hàm, vốn KHÔNG phải điểm dãy; đây là một nhầm lẫn phổ biến khiến người ta tưởng đối số hàm được đánh giá theo thứ tự. Mọi tác dụng phụ trước một điểm dãy phải hoàn tất trước khi vượt qua nó. Nhưng giữa hai điểm dãy — ví dụ giữa hai toán hạng của +, hay giữa các đối số của một lời gọi hàm — thứ tự đánh giá là không xác định, và trình dịch được tự do chọn.

Có hai mức độ rắc rối cần phân biệt. Không xác định (unspecified): next() + next() — hai lời gọi có thể chạy theo thứ tự nào cũng được, kết quả phụ thuộc thứ tự nhưng chương trình vẫn hợp lệ. Không định nghĩa (UB): sửa cùng một biến nhiều lần giữa hai điểm dãy, như i = i++ + ++i — đây là hành vi không xác định thực sự, kết quả có thể là bất cứ gì. Tôi đo cả hai.

Đo: gcc chọn trái sang phải, và một biểu thức UB

Tôi viết next() với tác dụng phụ (tăng một biến đếm c, trả về giá trị mới), rồi đo thứ tự:

next() + next()*10        = 21   (trái trước: 1 + 2*10; phải trước sẽ là 2 + 1*10 = 12)
printf("%d, %d", next(), next())  = 1, 2   (đối số trái đánh giá trước)

Trên gcc/aarch64, cả hai đều đánh giá trái sang phải: phép cộng cho 21, các đối số printf cho 1, 2. Và quan trọng: chạy lại bao nhiêu lần, ở -O0 hay -O2, kết quả luôn giống nhau. Rồi đến biểu thức UB i = i++ + ++i (i bắt đầu là 1):

-O0 = 4     -O2 = 4
gcc cảnh báo: warning: operation on 'i' may be undefined [-Wsequence-point]

Kết quả 4 ở cả hai mức, kèm cảnh báo -Wsequence-point. Nhìn qua thì mọi thứ "ổn định" — và đó chính là lúc tôi đo hớ.

Một lần tôi đo hớ: nhất quán trên máy này không phải "được định nghĩa"

Tôi vào bài với ý định chứng minh thứ tự đánh giá thay đổi — ai cũng nói "đối số hàm được đánh giá phải sang trái", nên tôi tưởng sẽ thấy printf("%d, %d", next(), next()) in ra 2, 1. Đo ra 1, 2: gcc/aarch64 đánh giá trái sang phải, và nhất quán như vậy mọi lần chạy, mọi mức tối ưu. Tôi không chứng minh được sự thay đổi tôi kỳ vọng.

Nhưng chính điều đó mới là bài học đáng nhớ, và nó tinh vi hơn UB thông thường: một hành vi "không xác định" vẫn có thể hoàn toàn nhất quán trên một trình biên dịch cụ thể — và sự nhất quán đó là cái bẫy. Vì next() + next() cho 21 mỗi lần, code của tôi trông như có thứ tự xác định, chạy đúng mãi trên máy tôi. Rồi một ngày ai đó biên dịch nó bằng trình dịch khác, hay trên kiến trúc x86 (nơi đối số hàm thường được đẩy phải-sang-trái, và một số compiler đánh giá theo thứ tự đó), và kết quả lật thành 12 hay 2, 1 — một bug "không thể tái hiện trên máy mình" nhưng có thật ở nơi khác. Tái hiện được (reproducible) không có nghĩa là được định nghĩa (defined). Tôi đã lấy "chạy ra cùng kết quả mỗi lần" làm bằng chứng của "hành vi xác định", trong khi hai thứ đó khác nhau: cái đầu là một lựa chọn của trình dịch hiện tại, cái sau là một bảo đảm của chuẩn.

Và trường hợp i = i++ + ++i còn nặng hơn: nó không chỉ không xác định thứ tự mà là UB thật sự (sửa i nhiều lần giữa hai điểm dãy), nên kết quả 4 chỉ là một trong nhiều khả năng — trình dịch khác cho 5, 6, hay gì cũng được. May là gcc cảnh báo -Wsequence-point; công cụ lại nói thật, nếu ta chịu bật -Wall và đọc.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đừng viết biểu thức mà kết quả phụ thuộc thứ tự đánh giá các phần có tác dụng phụ. a[i] = i++;, f(i++, i), x = x++ + 1, printf("%d %d", i++, i++) — tất cả đều là bẫy, từ "không xác định thứ tự" tới "UB". Cách chữa đơn giản: tách ra từng câu lệnh, vì mỗi dấu ; là một điểm dãy. Viết int a = next(); int b = next(); thì thứ tự rõ ràng và xác định. Ở đây dấu chấm phẩy giữa hai dòng là một điểm dãy: tác dụng phụ của dòng đầu (tăng c lần thứ nhất) chắc chắn hoàn tất trước khi dòng sau bắt đầu, nên a luôn là 1 và b luôn là 2 trên mọi trình dịch. Sự rõ ràng đáng giá hơn việc gộp mọi thứ vào một dòng.

Hệ quả thứ hai là bật và đọc cảnh báo, đừng tin vào kết quả chạy thử. -Wsequence-point, -Wall, và UBSan (-fsanitize=undefined) bắt được những biểu thức nguy hiểm này. Đặc biệt, đừng bao giờ kết luận "code đúng" chỉ vì nó cho cùng kết quả mỗi lần chạy trên máy bạn — với hành vi không xác định, sự nhất quán đó là ngẫu nhiên của một trình dịch một phiên bản, không phải bảo đảm.

Hệ quả thứ ba là bài học đo lường xuyên sê-ri, mở thêm một chiều: tái hiện được trên một cấu hình không chứng minh tính đúng đắn — cấu hình (trình dịch, phiên bản, kiến trúc, mức -O) là một biến ẩn. Con số mang theo: thứ tự đánh giá các biểu thức con có tác dụng phụ là KHÔNG xác định (trình dịch tự chọn; gcc/aarch64 chọn trái-phải nhưng x86/compiler khác có thể ngược); sửa cùng một biến nhiều lần giữa hai điểm dãy là UB; và nhất quán trên một máy KHÔNG phải "được chuẩn định nghĩa" — hãy tách câu lệnh (mỗi ; là một điểm dãy) và tin cảnh báo hơn tin kết quả chạy thử. Khi một biểu thức "chạy đúng mãi" nhưng đọc thấy nó phụ thuộc thứ tự, đừng yên tâm — hỏi xem chuẩn có bảo đảm không, chứ không phải máy bạn có nhất quán không.

Thử ba mươi giây

Viết một hàm int c=0; int nx(){ return ++c; } và in printf("%d %d\n", nx(), nx());. Trên gcc bạn có thể thấy 1 2; thử biên dịch cùng file bằng clang (nếu có) hoặc trên một kiến trúc khác — thứ tự có thể lật thành 2 1. Rồi viết int i=1; i = i++ + ++i; và dịch với gcc -Wall: bạn sẽ thấy cảnh báo -Wsequence-point. Chỉ vài lần thử là bạn thấy vì sao "chạy ra cùng kết quả" không đủ để kết luận đúng — và vì sao tách mỗi tác dụng phụ ra một câu lệnh riêng luôn là lựa chọn an toàn.