Menu

Biên dịch có điều kiện trong C: #ifdef, #ifndef, #if và -D

Biên dịch code khác nhau cho từng bản build bằng #ifdef, #ifndef, #if, #elif và #else: công tắc debug, nhánh theo nền tảng, định nghĩa macro từ dòng lệnh bằng -D và dùng #if 0 để vô hiệu hóa code.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Biên dịch có điều kiện (conditional compilation) cho phép một tệp nguồn trở thành nhiều chương trình khác nhau. Trước khi trình biên dịch chạy, bộ tiền xử lý (preprocessor) quyết định khối văn bản nào được giữ lại. Vì vậy code trong một nhánh không thỏa mãn không chỉ bị bỏ qua lúc chạy, mà bị xóa hẳn. Code đó không bao giờ được phân tích cú pháp, nên không đóng góp gì vào tệp thực thi và thậm chí có thể chứa những cấu trúc mà trình biên dịch hiện tại sẽ từ chối.

Đó là điểm khác với một câu lệnh if thông thường. Cả hai đều chọn giữa các hướng đi; chỉ một trong hai chọn trước khi chương trình tồn tại.

#ifdef và #ifndef

Câu hỏi đơn giản nhất là một macro đã được định nghĩa hay chưa:

Xóa dòng #define DEBUG là mọi lệnh in debug biến mất khỏi bản build: không phải bị vô hiệu hóa, mà là không còn nữa. Chạy một lần như đang viết, sau đó xóa dòng đó và chạy lại.

#ifndef là phủ định: chỉ giữ khối lệnh nếu macro chưa được định nghĩa. Cách dùng phổ biến nhất là include guard, được trình bày ở bài tệp header, và đây cũng là cách cung cấp một giá trị mặc định mà nơi gọi có thể ghi đè:

Mỗi #ifdef và #ifndef phải được đóng bằng #endif. Trong các tệp dài, ghi chú bên cạnh dòng đóng, ví dụ #endif /* DEBUG */, sẽ giúp bạn tiết kiệm khá nhiều thời gian về sau.

#if, #elif, #else

#if nhận một biểu thức số nguyên hằng và giữ khối lệnh khi kết quả khác 0. Nhờ vậy có thể so sánh phiên bản và cấp độ:

Đổi LOG_LEVEL thành 0 hoặc 3 rồi chạy lại để thấy chính chương trình thay đổi hình dạng.

Biểu thức được bộ tiền xử lý tính, nghĩa là chỉ dùng được hằng số nguyên, toán tử số học và so sánh, cùng các macro khai triển thành những thứ đó. Bộ tiền xử lý không nhìn thấy sizeof, giá trị enum, biến hay bất cứ thứ gì cần đến trình biên dịch. Một macro chưa định nghĩa bên trong #if được tính là 0 chứ không gây lỗi, điều này tiện lợi nhưng đôi khi gây bất ngờ:

#if FEATURE_X        /* FEATURE_X is never defined anywhere -> 0 -> block dropped */

defined(NAME) biến câu hỏi của #ifdef thành thứ dùng được bên trong #if, nên bạn có thể kết hợp nhiều phép kiểm tra:

#if defined(DEBUG) && !defined(NDEBUG)
    /* debug build, and assertions are on */
#endif

#if defined(LINUX) || defined(BSD)
    /* either unix-like system */
#endif

#if defined(X) và #ifdef X có cùng ý nghĩa; hãy chọn cách đầu khi cần &&, || hoặc !.

Định nghĩa macro từ dòng lệnh

Sức mạnh thật sự của các công tắc này là mã nguồn không cần thay đổi gì cả. gcc -D định nghĩa một macro cho toàn bộ quá trình biên dịch:

gcc -DDEBUG program.c -o program        # DEBUG defined as 1
gcc -DLOG_LEVEL=3 program.c -o program  # specific value
gcc -DDEBUG -DBUFFER_SIZE=512 a.c b.c -o app

Chỉ -DNAME tương đương với #define NAME 1. -U NAME bỏ định nghĩa một macro, điều này quan trọng khi một header định nghĩa thứ bạn muốn tắt.

Vì thế cách sắp xếp thường gặp là: mã nguồn chứa các giá trị mặc định được bảo vệ bằng #ifndef, còn lệnh build chọn cấu hình.

/* config.h */
#ifndef LOG_LEVEL
#define LOG_LEVEL 1        /* quiet by default */
#endif

#ifndef MAX_CONNECTIONS
#define MAX_CONNECTIONS 64
#endif
# development build
gcc -DDEBUG -DLOG_LEVEL=3 -Wall -Wextra -g src/*.c -o app-dev

# release build
gcc -DNDEBUG -O2 src/*.c -o app

NDEBUG đã được chuẩn hóa: định nghĩa nó sẽ tắt mọi assert() trong chương trình, vì bản thân <assert.h> được viết bằng biên dịch có điều kiện. Đó là cả mô hình thu nhỏ: một header được biên dịch thành những thứ khác nhau tùy vào những gì bản build đã định nghĩa.

Công tắc theo nền tảng

Trình biên dịch định nghĩa sẵn các macro nhận diện hệ thống đích, nên một tệp nguồn có thể gọi đúng API trên từng hệ thống:

#if defined(_WIN32)
    #include <windows.h>
    #define CLEAR_SCREEN "cls"
#elif defined(__APPLE__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#elif defined(__linux__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#else
    #error "Unsupported platform"
#endif

Những macro phổ biến: _WIN32 (được định nghĩa trên cả Windows 32 bit và 64 bit), _WIN64, __linux__, __APPLE__, __unix__, __ANDROID__. Trình biên dịch cũng có bộ macro nhận diện riêng, như __GNUC__, __clang__, _MSC_VER, và kiến trúc cũng vậy, như __x86_64__, __aarch64__.

Một phiên bản chạy được, cho biết nó được build cho hệ thống nào:

#error đáng để biết riêng: nó dừng quá trình biên dịch kèm thông báo của bạn. Kết thúc chuỗi nền tảng bằng #else / #error "Unsupported platform" sẽ biến một bản build sai âm thầm thành một lỗi rõ ràng ngay đầu log build.

Để liệt kê mọi macro mà trình biên dịch định nghĩa sẵn:

gcc -dM -E - < /dev/null

Kết quả đó là câu trả lời chính xác nhất cho câu hỏi "trên máy này mình có thể kiểm tra những gì".

Dùng #if 0 để tắt code

Biến một khối code thành chú thích bằng /* ... */ sẽ hỏng ngay khi khối đó có chú thích riêng, vì chú thích trong C không lồng nhau được: dấu */ đầu tiên bên trong kết thúc chú thích bên ngoài và mọi thứ phía sau trở thành code thừa. #if 0 không gặp vấn đề đó:

#if 0
    /* This whole region is removed, comments and all. */
    legacy_init();
    int n = old_calculation(42);  /* even this comment is fine */
    report(n);
#endif

Đổi thành #if 1 để đưa code trở lại. Trình soạn thảo vẫn tô sáng nó như code C, và bạn có thể lồng một #if 0 bên trong một #if khác.

Đây là công cụ gỡ lỗi, không phải nơi lưu trữ. Code nằm trong #if 0 không bao giờ được biên dịch, nên nó âm thầm mục ruỗng: đến lúc có người bật công tắc thì nó đã không build được nữa. Hãy dùng nó khi đang khoanh vùng một lỗi, sau đó xóa khối đó và để hệ thống quản lý phiên bản ghi nhớ thay bạn.

Khi biên dịch có điều kiện gây rắc rối

Ba kiểu lỗi chiếm phần lớn những phiền toái.

Code không bao giờ được biên dịch thì không bao giờ được kiểm tra. Một lỗi gõ nhầm trong nhánh #ifdef không hoạt động sẽ vô hình cho đến khi có người build cấu hình đó, có thể là nhiều tháng sau, có thể là trong CI trên một nền tảng bạn không có. Nếu dự án có các nhánh nền tảng quan trọng, hãy build tất cả chúng thường xuyên.

Đan xen #ifdef với luồng điều khiển nhanh chóng làm code khó đọc. Kiểu code này rất khó suy luận:

if (ready) {
#ifdef FAST_PATH
    fast_send(buf);
} else {
#endif
    slow_send(buf);
}

Dấu ngoặc nhọn mở ở nhánh này và đóng ở nhánh kia là hợp lệ, nhưng rất tệ. Hãy ưu tiên điều kiện bao trọn cả hàm, rồi chọn giữa các hàm đó:

#ifdef FAST_PATH
static void send_data(const char *buf) { fast_send(buf); }
#else
static void send_data(const char *buf) { slow_send(buf); }
#endif

Một câu lệnh if lúc chạy thường tốt hơn. Nếu cả hai nhánh đều biên dịch được ở mọi nơi, một if (debug_enabled) bình thường giữ cho cả hai hướng đi đều được kiểm tra kiểu, kiểm thử được và chuyển đổi được mà không cần build lại. Hãy dành bộ tiền xử lý cho việc chỉ nó làm được: code thực sự không thể biên dịch trên nền tảng khác.

Về mô hình kết hợp tất cả những điều này, gồm một header chứa giá trị mặc định, -D cho từng bản build và các nhánh nền tảng được bọc trong include guard, xem bài tệp header; về chính #define, xem bài macro.

Câu hỏi thường gặp

Biên dịch có điều kiện trong C là gì?

Là dùng các chỉ thị tiền xử lý (preprocessor directive) để giữ lại hoặc loại bỏ từng khối mã nguồn trước khi trình biên dịch chạy. Code nằm trong một #ifdef không thỏa mãn bị xóa hẳn khỏi văn bản: không bao giờ được phân tích cú pháp, không bao giờ được biên dịch và không bao giờ xuất hiện trong tệp thực thi. Nhờ vậy một tệp nguồn có thể hỗ trợ nhiều nền tảng hoặc nhiều kiểu build.

#ifdef và #if khác nhau thế nào?

#ifdef NAME chỉ hỏi macro đó có tồn tại hay không, bất kể giá trị của nó. #if expression tính một biểu thức số nguyên hằng, nên có thể so sánh giá trị: #if VERSION >= 3. Dùng #ifdef cho các cờ chỉ cần có hoặc không, dùng #if khi giá trị quan trọng. #if defined(NAME) kết hợp cả hai và có thể nối bằng && và ||.

Làm sao định nghĩa macro từ dòng lệnh với gcc?

Dùng -D: gcc -DDEBUG program.c định nghĩa DEBUG như thể tệp bắt đầu bằng #define DEBUG 1, còn -DMAX=50 gán cho nó một giá trị cụ thể. Đây là cách các hệ thống build bật tính năng mà không cần sửa mã nguồn, và -U NAME để bỏ định nghĩa một macro.

Vì sao dùng #if 0 thay vì biến code thành chú thích?

Vì chú thích /* ... */ không lồng nhau được: dấu */ đầu tiên bên trong vùng đó kết thúc chú thích quá sớm và phần còn lại trở thành code lỗi. #if 0 ... #endif loại bỏ bao nhiêu code cũng được, dù chứa bao nhiêu chú thích, vẫn giữ tô sáng cú pháp và chỉ cần đổi thành #if 1 là khôi phục.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu