Trước khi trình biên dịch nhìn thấy một dòng code nào, một chương trình khác đã chạy qua file của bạn và viết lại nó: bộ tiền xử lý. Nó chỉ làm đúng một việc — thay thế văn bản — nhưng chính sự đơn giản đó là nguồn của những con bọ khó chịu nhất, vì #define trông như một hàm mà lại không phải hàm. Bài này đo tận mắt cái mà macro và #include bung ra bằng gcc -E, và vấp đúng cái bẫy kinh điển của macro — một cái bẫy mà đọc văn bản đã dán ra là thấy ngay.
Bộ tiền xử lý chỉ dán văn bản
Bộ tiền xử lý xử lý mọi dòng bắt đầu bằng # và làm ba việc chính: thay #define (macro), bung #include (dán nguyên nội dung file), và xóa chú thích. Điểm cốt lõi cần nắm — vì mọi cạm bẫy đều từ đây mà ra — là nó thao tác trên văn bản thuần, trước và độc lập hoàn toàn với ngữ nghĩa của C. Nó không biết gì về kiểu, về phạm vi, về hàm; nó chỉ cắt và dán chuỗi.
Có hai loại macro. Loại giống biến (#define PI 3.14) dán một chuỗi thay cho một tên. Loại giống hàm (#define SQ(x) ((x)*(x))) trông y như một lời gọi hàm SQ(a), nhưng nó không gọi gì cả — nó dán nguyên văn bản của đối số vào chỗ x. Sự khác biệt "gọi hàm" và "dán văn bản" nghe nhỏ nhặt, cho tới khi bạn đo nó.
Đo: macro dán ra văn bản gì
Tôi viết ba macro và dùng gcc -E để xem chúng bung thành văn bản gì trước khi biên dịch:
SQ_BAD(1+2) -> 1+2*1+2
SQ(1+2) -> ((1+2)*(1+2))
Với #define SQ_BAD(x) x*x (thiếu ngoặc), SQ_BAD(1+2) dán thẳng 1+2 vào chỗ x thành 1+2*1+2. Theo thứ tự phép toán, đây là 1 + (2*1) + 2 = 5, không phải 9. Bản có ngoặc #define SQ(x) ((x)*(x)) bung thành ((1+2)*(1+2)) = 9 đúng. Chạy thật xác nhận: SQ_BAD(1+2) in ra 5, SQ(1+2) in ra 9. Cùng một ý định "bình phương tổng", chỉ khác vài dấu ngoặc trong định nghĩa macro, mà một cái sai một cái đúng — vì macro không hiểu "tổng", nó chỉ dán chuỗi rồi để trình biên dịch đọc lại theo thứ tự phép toán thông thường.
Còn về #include, tôi đếm số dòng thật (bỏ dòng trống và chỉ thị) mà mỗi header bung ra, cùng số file được nạp (gcc -H):
| Include | Dòng thật | Số header nạp |
|---|---|---|
| (rỗng) | 1 | 0 |
stdio.h |
271 | 37 |
stdlib.h |
522 | 63 |
string.h |
120 | 23 |
| cả ba | 847 | 80 |
Một dòng #include <stdio.h> kéo theo 37 file lồng nhau (stdio.h → libc-header-start.h → features.h → ...). Và một con số đáng chú ý: gộp cả ba header lẽ ra là 37+63+23 = 123 file, nhưng thực tế chỉ nạp 80 — vì ba header dùng chung nhiều file con (features.h, cdefs.h, wordsize.h...), và include guard (#ifndef/#pragma once) chặn không cho nạp lại cái đã có. Nhưng con bọ thật sự nằm ở macro, và nó đợi tôi ở phép đo tiếp theo.
Một lần tôi đo hớ: SQ(i++) làm i tăng hai lần
Tôi định minh họa rằng macro SQ(x) "cũng như một hàm bình phương". Để chắc, tôi truyền vào một đối số có tác dụng phụ — SQ(i++) với i = 3 — và đoán rằng nó sẽ như gọi hàm: dùng giá trị 3, rồi i tăng một lần thành 4, kết quả 9. Đo ra thì khác hẳn:
macro: SQ(i++) với i=3 -> kết quả 12, i sau = 5 (i tăng HAI lần!)
hàm : sqf(j++) với j=3 -> kết quả 9, j sau = 4 (j tăng một lần)
i nhảy từ 3 lên 5 — tăng hai lần. Đọc gcc -E là hiểu ngay tại sao: SQ(i++) dán nguyên i++ vào cả hai chỗ x, thành ((i++)*(i++)). Có hai i++ trong văn bản, nên i tăng hai lần, và kết quả là tích của hai giá trị khác nhau (một mớ hỗn độn). Trong khi hàm thật sqf(j++) nhận đối số, tính j++ một lần đúng theo ngữ nghĩa gọi hàm, nên j tăng một lần và kết quả là 9 sạch sẽ.
Bài học đo lường: macro không gọi hàm — nó dán văn bản, nên mọi tác dụng phụ trong đối số bị lặp lại đúng bằng số lần đối số đó xuất hiện trong thân macro. Tôi đã đem trực giác về hàm áp lên một thứ không phải hàm. Và cách bắt được sự thật lại chính là kỷ luật của cả sê-ri: đừng đoán, đọc văn bản mà tiền xử lý dán ra (gcc -E) — nó phơi bày ((i++)*(i++)) trắng đen, và cái i++ nhân đôi đó giải thích mọi thứ. Nếu chỉ nhìn SQ(i++) trong mã nguồn mà suy, tôi đã tin chắc nó tăng một lần.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là hai quy tắc vàng khi viết macro giống hàm: (1) bọc mọi đối số và cả thân macro trong ngoặc — #define SQ(x) ((x)*(x)), không bao giờ x*x — để thứ tự phép toán không vỡ khi đối số là một biểu thức; (2) đừng bao giờ truyền biểu thức có tác dụng phụ (i++, một lời gọi hàm có phụ tác, getchar()) vào macro, vì nó sẽ chạy nhiều lần một cách âm thầm. Hai lỗi này không gây lỗi biên dịch — mã vẫn dịch trơn tru — mà chỉ ra kết quả sai lúc chạy, nên rất khó lần.
Hệ quả thứ hai là khi nào chọn hàm inline thay cho macro. Trong C hiện đại, một hàm static inline gần như luôn tốt hơn một macro giống hàm: nó có kiểu (trình dịch bắt lỗi truyền sai kiểu), có phạm vi (không rò tên ra ngoài), tính mỗi đối số đúng một lần, và với -O2 thường được nội tuyến nên chẳng tốn lời gọi nào — được cái tiện của macro mà không dính bẫy của nó. Macro chỉ thật sự cần khi bạn phải thao tác trên chính văn bản (nối token, tạo tên, biên dịch có điều kiện) — những việc hàm không làm được.
Hệ quả thứ ba nối vào bài trước về chi phí biên dịch: #include là dao hai lưỡi. Nó tiện đến mức ta quên rằng mỗi cái kéo theo cả một cây file, và trình biên dịch phân tích lại toàn bộ cây đó cho mỗi đơn vị dịch. Con số 80 file cho ba header nhắc rằng include guard đang âm thầm cứu bạn khỏi việc nạp lại hàng chục lần — và cũng nhắc vì sao nên include ít nhất có thể, đặt include trong .c thay vì .h khi được, để cây header không phình ra. Con số mang theo: macro là dán văn bản chứ không phải gọi hàm — thiếu ngoặc thì vỡ thứ tự phép, đối số có phụ tác thì chạy lặp — và cách duy nhất để chắc chắn nó bung ra gì là đọc gcc -E, đúng như cách duy nhất để biết CPU chạy gì là đọc assembly. Tiền xử lý là tầng đầu tiên viết lại mã của bạn; nó cũng là tầng dễ hiểu lầm nhất, vì nó giả trang thành ngôn ngữ mà thực ra chỉ là cắt dán chuỗi.
Thử ba mươi giây
Viết #define SQ(x) x*x rồi một dòng int r = SQ(2+3);, chạy gcc -E file.c và tìm dòng đó trong kết quả: bạn sẽ thấy int r = 2+3*2+3; — tận mắt cái văn bản đã dán, và hiểu ngay vì sao r ra 11 chứ không phải 25. Rồi thêm ngoặc #define SQ(x) ((x)*(x)), dịch lại -E, thấy nó thành ((2+3)*(2+3)). Chỉ hai lần xem gcc -E là bạn thấy trọn cái bẫy macro — và học được thói quen quý nhất: khi macro cư xử lạ, đừng đoán, hãy xem nó bung ra văn bản gì.