Mọi file C bạn từng biên dịch đều bắt đầu bằng một dòng như #include <stdio.h>, và dòng đó không phải là C. Nó là chỉ dẫn cho một chương trình riêng, bộ tiền xử lý (preprocessor), chạy trước tiên, viết lại văn bản mã nguồn của bạn và chuyển kết quả cho trình biên dịch.
Hiểu sự phân chia hai giai đoạn đó sẽ giải thích được nhiều hành vi của C: vì sao header được dán vào chứ không phải được import, vì sao một macro viết sai lại báo lỗi ở một dòng trông hoàn toàn ổn, và vì sao cùng một file mã nguồn có thể biên dịch thành những chương trình khác nhau trên những hệ thống khác nhau.
Điều gì thực sự xảy ra trước khi biên dịch
Biên dịch một file .c không phải là một bước. Đại khái, nó gồm bốn bước:
- Tiền xử lý: làm theo mọi dòng bắt đầu bằng
#, tạo ra một văn bản mã nguồn lớn đã được mở rộng (một translation unit). - Biên dịch: biến văn bản đó thành assembly, rồi thành object file.
- Hợp dịch: tạo ra mã máy.
- Liên kết: ghép các object file với thư viện thành một file thực thi.
Bộ tiền xử lý chỉ làm việc với văn bản. Nó không biết biến là gì, kiểu là gì, hay cặp ngoặc nhọn của bạn có cân đối không. Nó thấy các ký tự và token, thay một số thứ bằng thứ khác, rồi đi tiếp. Mọi điều kỳ lạ về macro đều bắt nguồn từ đúng một sự thật đó.
Khi trình biên dịch đọc chương trình này, không còn GREETING ở đâu cả. Bộ tiền xử lý đã thay nó bằng chuỗi "Hello from a macro", y như thể bạn tự gõ vào.
Họ chỉ thị tiền xử lý
Mọi chỉ thị tiền xử lý (directive) đều bắt đầu bằng #, là ký tự đầu tiên không phải khoảng trắng trên dòng của nó. Không có dấu chấm phẩy, và chỉ thị kết thúc ở cuối dòng, trừ khi bạn nối tiếp bằng dấu gạch chéo ngược.
| Chỉ thị | Công dụng |
|---|---|
#include | Dán nội dung của một file khác vào |
#define | Định nghĩa một macro (một phép thay thế văn bản) |
#undef | Xóa định nghĩa của một macro |
#ifdef, #ifndef | Chỉ giữ đoạn code phía sau nếu một macro được (hoặc không được) định nghĩa |
#if, #elif, #else, #endif | Giữ code dựa trên một biểu thức hằng |
#error | Dừng biên dịch kèm một thông báo |
#pragma | Chỉ dẫn riêng cho từng trình biên dịch, chẳng hạn #pragma once |
#line | Đổi số dòng được báo cáo (hiếm dùng) |
Ba trong số đó có bài riêng: bài macro nói sâu về #define, bài biên dịch có điều kiện nói về họ #if, và bài file header nói về cách dùng #include để tổ chức một chương trình nhiều file.
#include: ngoặc nhọn và ngoặc kép
#include chỉ làm đúng một việc: thay chính dòng của nó bằng toàn bộ nội dung của file được nêu tên. File được include đó cũng được tiền xử lý, nên các dòng #include bên trong nó cũng được mở rộng.
#include <stdio.h> /* search the system include directories */
#include "config.h" /* search this file's directory first */
Khác biệt nằm ở thứ tự tìm kiếm:
<angle brackets>tìm trong các thư mục include chuẩn của trình biên dịch:/usr/include, các thư mục riêng của bộ công cụ, cộng với mọi thư mục bạn thêm bằng-I. Cách này dành cho header của thư viện."quotes"tìm trước trong thư mục chứa file đang include, rồi mới quay về cùng danh sách như ngoặc nhọn. Cách này dành cho header bạn tự viết.
Trên hầu hết trình biên dịch, cả hai cách đều dùng được cho cả hai loại header, nhưng quy ước mang ý nghĩa: ngoặc nhọn nói "đây là header của người khác", ngoặc kép nói "đây là của tôi". Dùng lẫn lộn chính là cách một dự án lỡ include một header hệ thống đã cũ thay vì header của chính nó.
Vì include là dán văn bản, include cùng một header hai lần sẽ dán nó hai lần, và đó là lý do header cần include guard. Đây là điều đầu tiên bài file header giải quyết.
#define: thay thế văn bản, không hơn
#define NAME replacement nói với bộ tiền xử lý: từ đây trở đi, ở đâu có token NAME, hãy đặt replacement vào thay.
Hãy để ý những gì không xảy ra ở đây. MAX_USERS không có kiểu. Nó không được lưu ở đâu cả. Không thể xem nó trong trình gỡ lỗi. Nó là một quy tắc tìm và thay thế, và sau bước tiền xử lý, chương trình thực sự chứa printf("%s allows %d users\n", "Coddy", 100);.
Điều đó cũng có nghĩa là phép thay thế hoàn toàn mù quáng. Đoạn sau vẫn biên dịch được, và làm một điều bất ngờ:
#define SIZE 5 + 1
int arr[SIZE]; /* fine: int arr[5 + 1]; */
int total = SIZE * 2; /* 5 + 1 * 2 == 7, not 12 */
Cách sửa, đặt dấu ngoặc quanh mọi thứ, là quy tắc trung tâm của bài macro, cùng với các macro nhận đối số.
Một #define không có văn bản thay thế sẽ định nghĩa cái tên đó là "có mặt nhưng rỗng". Dùng để thay thế thì vô dụng, nhưng dùng làm cờ thì không thể thiếu:
#define DEBUG /* defined, expands to nothing */
Khi đó code có thể hỏi DEBUG có tồn tại không bằng #ifdef.
Xem kết quả của bộ tiền xử lý
Cách tốt nhất để hiểu trực giác là nhìn vào những gì bộ tiền xử lý thực sự tạo ra. gcc -E dừng sau bước tiền xử lý và in ra kết quả:
gcc -E hello.c
Với một file có include <stdio.h>, kết quả dài từ 700 đến 30.000 dòng tùy hệ thống, gần như toàn bộ là nội dung của chính header đó. Để chỉ xem phần của bạn, hãy lấy phần cuối:
gcc -E hello.c | tail -20
Thử với một file như sau:
#define SQUARE(x) ((x) * (x))
#define LIMIT 10
int main(void) {
int n = SQUARE(LIMIT);
return n;
}
Phần cuối của kết quả hiện ra:
int main(void) {
int n = ((10) * (10));
return n;
}
Mọi macro đều đã biến mất; chỉ còn lại văn bản đã được thay thế. Khi một macro chạy sai, lệnh này cho bạn biết lý do chỉ trong vài giây, tốt hơn nhiều so với đoán mò. Thêm hai lệnh hữu ích: gcc -dM -E - < /dev/null liệt kê mọi macro mà trình biên dịch định nghĩa sẵn, và gcc -E -P file.c bỏ đi các dòng đánh dấu gây nhiễu.
Vì sao lỗi chỉ sai dòng
Vì trình biên dịch nhìn thấy văn bản đã mở rộng, một lỗi bên trong macro được báo ở chỗ macro được dùng, không phải chỗ nó được viết:
#define HALF(x) (x / 2
int main(void) {
int y = HALF(8); /* error reported here */
return 0;
}
Dấu ngoặc bị thiếu nằm ở dòng #define, nhưng trình biên dịch lại phàn nàn về dòng chứa HALF(8), thường kèm một thông báo về token không mong đợi chẳng có nghĩa gì trong ngữ cảnh đó. Khi một lỗi trông như không thể xảy ra, hãy mở rộng file bằng gcc -E và đọc dòng thật.
Các trình biên dịch hiện đại có hỗ trợ: GCC và clang in thêm ghi chú "in expansion of macro" trỏ ngược về định nghĩa. Hãy biên dịch với -Wall -Wextra để thực sự thấy những ghi chú đó.
Macro định nghĩa sẵn
Bộ tiền xử lý tự cung cấp một số macro. Chúng thực sự hữu ích cho việc chẩn đoán lỗi:
__FILE__ và __LINE__ được mở rộng thành tên file và số dòng hiện tại, đó là cách các macro assert và ghi log báo ra chỗ có vấn đề. (__func__ hơi khác: nó là một định danh thật do trình biên dịch cung cấp, không phải macro tiền xử lý, nhưng được dùng theo cùng cách.)
Trình biên dịch cũng định nghĩa sẵn các macro nền tảng như __linux__, _WIN32 và __APPLE__. Code cần khác nhau trên từng hệ thống sẽ kiểm tra chúng bằng #ifdef, và đó là chủ đề của bài biên dịch có điều kiện.
Điều cần ghi nhớ
Bộ tiền xử lý nhỏ và ngây ngô, và cả hai điều đó đều có chủ đích. Nó cho bạn ba quyền năng: kéo một file vào, thay thế văn bản, bật tắt code, và tuyệt nhiên không đi kèm kiểm tra kiểu nào.
Sự đánh đổi đó là lý do lời khuyên tiêu chuẩn là ưu tiên dùng tính năng của ngôn ngữ trước: dùng const int hoặc enum thay cho #define để tạo hằng số khi có thể, và một hàm thật thay cho macro dạng hàm. Ở những chỗ bộ tiền xử lý thực sự là công cụ phù hợp, như header, công tắc cho tính di động hay cấu hình lúc biên dịch, nó là thứ không thể thay thế.
Tiếp theo, bài macro sẽ nói kỹ về #define: đối số, các quy tắc về dấu ngoặc, và những cái bẫy đi kèm việc thay văn bản vào code mà bạn không viết.
Câu hỏi thường gặp
Bộ tiền xử lý trong C là gì?
Là một bước xử lý văn bản chạy trước khi biên dịch. Nó làm theo các dòng bắt đầu bằng #: dán nội dung các file header vào chỗ #include, thay thế các macro được định nghĩa bằng #define, và xóa hoặc giữ code tùy theo #if/#ifdef. Trình biên dịch chỉ nhìn thấy kết quả, không bao giờ thấy file gốc của bạn.
#include <stdio.h> và #include "myfile.h" khác nhau thế nào?
Ngoặc nhọn tìm trong các thư mục include hệ thống của trình biên dịch, nơi chứa header của thư viện chuẩn. Ngoặc kép tìm trước trong thư mục của file hiện tại, rồi mới quay về các đường dẫn hệ thống. Dùng ngoặc nhọn cho header của thư viện và ngoặc kép cho header bạn tự viết.
Làm sao xem bộ tiền xử lý đã tạo ra gì?
Chạy gcc -E file.c để dừng sau bước tiền xử lý và in ra mã nguồn đã được mở rộng. Với file có include <stdio.h>, kết quả dài hàng nghìn dòng, nên hãy dùng pipe: gcc -E file.c | tail -30 chỉ hiện phần code của bạn với mọi macro đã được thay thế.
#include có phải là một câu lệnh C không?
Không. Các chỉ thị không thuộc về bản thân ngôn ngữ C: chúng có cú pháp riêng theo dòng, không cần dấu chấm phẩy, và đã biến mất khi trình biên dịch phân tích chương trình của bạn. Đó là lý do một lỗi trong macro lại hiện ra thành một thông báo khó hiểu ở một dòng trông hoàn toàn ổn.