Bộ tiền xử lý – Preprocessor trong C/C++

Preprocessor (bộ tiền xử lý) là một khái niệm đặc trưng trong C/C++, đó là một công cụ được thực thi trước khi quá trình biên dịch thực sự được thực hiện.

Bộ tiền xử lý có nhiệm vụ xử lý các chỉ thị tiền xử lý, như #include, #define, #if, #ifdef, #ifndef, #endif,… Nó làm việc trên một file source C++ tại một thời điểm bằng cách thay thế các chỉ thị #include bằng nội dung của các file tương ứng (thường chỉ chứa khai báo), thay thế các macro #define và chọn các phần khác nhau trong source code để biên dịch tùy thuộc vào các chỉ thị #if, #ifdef, #ifndef.

Trong bài này mình sẽ tổng hợp 1 số  best practice về Preprocessor

Include Guards

Một file source có thể include một hoặc nhiều file header. Và một file header có thể include một hoặc nhiều file header khác. Do đó một source file include nhiều file header thì có thể gián tiếp include một file header nào đó nhiều lần. Nếu một file header được include nhiều lần mà trong file đó có chứa định nghĩa struct, class, … thì khi biên dịch sẽ bị lỗi.

“include guards” thường được dùng để tránh lỗi này. Để áp dụng “include guards” thì chúng ta sẽ sử dụng các chỉ thị #define, #ifndef, #endif. Ví dụ →

“include guards” có thể work ok với tất cả các standard compiler và preprocessor. Vấn đề mà các dev cần chú ý ở đây là làm sao đảm bảo tính duy nhất của macro dùng để guard. Ví dụ trong trường hợp này, nếu có nhiều hơn 1 header file cùng sử dụng FOO_H làm macro để guard thì có thể dẫn đến lỗi do include thiếu header. Và nếu trong project của chúng ta có sử dụng thư viện của bên thứ 3 nữa thì khả năng bị trùng sẽ càng cao.

Ngoài ra với cách này cũng cần phải đảm bảo macro sử dụng trong “include guards” cũng phải khác với tất cả các macro được define trong tất cả header của khác.

Để tránh rắc rối khi sử dụng “include guards” bằng macro thì hầu hết các trình biên dịch C++ hiện nay đều support chỉ thị #pragma_once để đảm bảo một header chỉ được include một lần vào 1 file source. Ví dụ →

Tuy nhiên ae phải nhớ rằng cái này ko thuộc ISO C++ standard nên không đảm bảo tất cả các trình biên dịch đều hỗ trợ nhé, và các compiler ko hỗ trợ sẽ âm thầm lặng lẽ bỏ qua chỉ thị này.

Bản thân mình thì vẫn quen dùng “include guards” bằng macro mặc dù đôi khi cũng bị trùng với macro trong các 3rd libraries.


Conditional pre-processing logic

“Conditional pre-processing logic” – “tiền xử lý điều kiện logic” là việc làm cho các phần code nào đó trở nên available hoặc unavailable trong quá trình biên dịch bằng cách sử dụng các chỉ thị tiền xử lý điều kiện.

Một số use-cases điển hình →
  • Cùng một app nhưng có các build mode khác nhau (debug, release, testing), mỗi mode lại có các phần code log thêm khác nhau hoặc log level cũng khác nhau
  • Cùng một source code nhưng sử dụng để build binary để run trên các platform khác nhau
  • Cùng một source code nhưng sử dụng để build binary cho các variant khác nhau của phần mềm. Ví dụ : dùng chung source code cho các bản Basic, Plus, và Premium của cùng 1 phần mềm chỉ khác nhau một chút về tính năng.
* Ví dụ 1: Sử dụng Conditional pre-processing trong cross-plaform source code (source code hỗ trợ nhiều platform)

Các macro như _WIN32, _WIN64 hay __unix__ được compiler tự động define dựa vào cấu hình build

* Ví dụ 2: Mở code in log riêng với chế độ build Testing * Ví dụ 3: Implement 1 tính năng mà chỉ có ở bản Premium Lưu ý:

Nếu một macro không được define và giá trị của nó được dùng để so sánh và kiểm tra bởi preprocessor thì preprocessor sẽ mặc định giá trị của macro đó bằng 0.


Macro

Macro là có thể coi một kỹ thuật dùng để copy paste code tự động tại thời điểm biên dịch, ở đó các đoạn code được lặp lại ở nhiều chỗ sẽ được đóng thành macro và sẽ được preprocessor đưa vào nơi cần thiết trước khi biên dịch. macro có thể chia thành 2 loại chính: object-likefunction-like.

Ví dụ → Macro thường được viết hoa toàn bộ để dễ nhận biết khi đọc code. * Khi preprocessor gặp một object-like macro thì hành động của nó đơn giản chỉ là copy-paste, macro name sẽ được thay thế bởi định nghĩa của nó. Còn khi gặp một function-like macro, macro name sẽ được thay thế bởi định nghĩa của nó đồng thời các tham số cũng được thay bởi tên tham số thật. Ví dụ →

* Các ae code C/C++ luôn luôn có thói quen kết thúc 1 dòng lệnh bằng dấu ; và đôi khi điều đó lại gây lỗi rất vớ vẩn khi dùng macro. Ví dụ →

Trong ví dụ này việc có 2 dấu ; ở cuối line 3 sẽ làm cho compiler không nhận ra else ở line số 4 là cùng block với lệnh if ở line số 2 dẫn đến compiler lỗi Do đó ae cần hết sức để ý khi dùng dấu ; trong định nghĩa macro * Trong trường hợp định nghĩa macro quá dài thì hoặc muốn tách ra nhiều line cho dễ nhìn thì có thể sử dụng ký tự backslash ở cuối dòng để nối xuống dòng tiếp theo → * Variadic macros: Macro này đặc biệt ở chỗ có thể lấy một số lượng tham số (không xác định trước) thay vào __VA_ARGS__ trong phần định nghĩa macro. Ví dụ → sau khi preprocessor xử lý thì compiler sẽ thấy như thế này

Predefined macros

Predefined macros là các macro được define bởi trình biên dịch. Ae chớ có nghịch ngu mà define lại (re-define) hoặc undefine các predefined macro nhé.

Theo C++ standard thì những macro sau được sẽ được predefined bởi compiler →

  • __LINE__ : line number của dòng code chứa macro này
  • __FILE__: tên của file chứa macro này
  • __DATE__: ngày mà file code chứa macro này được biên dịch, có định dạng “Mmm dd yyyy”
  • __TIME__: timemà file code chứa macro này được biên dịch, có định dạng “hh:mm:ss”
  • __cplusplus: được define bởi C++ compiler khi đang biên dịch file c++

Ngoài ra còn 1 số predefined macro khác nữa nhưng ko phổ biến và ít gặp trong code ứng dụng thông thường nên mình ko đề cập vào đây.

Ngoài các standard predefined macro thì các compiler khác nhau cũng có các bộ predefined macro riêng của chúng nữa. Những cái này phải đọc documents của compiler mới biết được. Biết vậy thôi chứ bình thường đọc làm gì cho đau đầu, khi nào cần thì research thôi.

Một số ví dụ sử dụng predefined macro →

Preprocessor Operators (Toán tử tiền xử lý)

* Toán tử # hay còn gọi là “stringizing operator” được sử dụng để chuyển 1 tham số của macro thành string. chỉ có thể sử dụng với macro có tham số. Ví dụ →

↓ sẽ được preprocessor convert thành * Toán tử ## hay còn gọi là “Token pasting operator” được sử dụng để nối 2 tham số của macro. Ví dụ → ↓ sẽ được preprocessor convert thành output sẽ là

Preprocessor error messages

Lỗi compile có thể được định nghĩa sử dụng preprocessor. Nó khá hữu dụng trong trường hợp cần thông báo lỗi compile liên quan đến platform hoặc compiler version.

Ví dụ: khi compile source code có đoạn code sau thì sẽ gặp lỗi báo lỗi compile “This code requires gcc > 3.0.0”  nếu gcc version <= 3.0.0

* Ví dụ: khi compile source code có đoạn code sau thì sẽ gặp lỗi báo lỗi compile “Apple products are not supported in this release”  nếu compile code để chạy trên Apple device  

— Phạm Minh Tuấn (Shun) —