PREPROCESSOR (Bộ tiền xử lý) – Preprocessor directives (Chỉ thị tiền xử lý)

1. Preprocessor – Bộ tiền xử lý
Preprocessor là bộ xử lý văn bản của source file (tệp nguồn). Preprocessor hoạt động trong giai đoạn tiền xử lý – giai đoạn đầu tiên trong 4 giai đoạn biên dịch một chương trình C. Preprocessor không phân tích cú pháp trong chương trình mà có chức năng chính là:
  • Xóa bỏ các chú thích và comment
  • Xử lý các lệnh gọi macro
Trình biên dịch thường gọi bộ tiền xử lý trong giai đoạn đầu của quá trình biên dịch, ngoài ra bộ tiền xử lý cũng có thể được gọi riêng để xử lý văn bản của tệp nguồn mà không cần các giai đoạn biên dịch sau đó. Compiler cung cấp /E command để bạn có thể sử dụng để thực hiện riêng giai đoạn tiền xử lý (ví dụ tại hình 1).
Hình 1. Command /E được sử dụng để triển khai giai đoạn tiền xử lý
Các thành phần được xử lý bởi Preprocessor bao gồm:
  • Preprocessor directives – Chỉ thị tiền xử lý: #define, #if, #endif, #include,
  • Preprocessor operators – Toán tử tiền xử lý: #, #@, ##
  • Predefined macros – Macro được định nghĩa trước
  • Pragmas: #pragma, __pragma, _Pragma
Trong bài viết này, chúng ta sẽ tìm hiểu chi tiết hơn về các Preprocessor directives – Chỉ thị tiền xử lý. 
2. Preprocessor directives – Chỉ thị tiền xử lý
Chỉ thị tiền xử lý là những chỉ thị được sử dụng mang mục đích để trình biên dịch xử lý những thông tin trước khi bắt đầu quá trình dịch. Tất cả các chỉ thị tiền xử lý đều bắt đầu với # và các chỉ thị tiền xử lý không phải là lệnh C/C++ vì vậy không có dấu ; khi kết thúc. Tiền xử lý được nhận dạng và xử lý trước khi mở rộng các macro (macro expansion). Nên khi macro được mở rộng thành lệnh chứa chỉ thị tiền xử lý thì lệnh đó sẽ không được phát hiện. Các chỉ thị tiền xử lý có thể xuất hiện ở bất kỳ đâu trong source file, nhưng chúng chỉ được áp dụng cho phần source kể từ dòng code mà chúng xuất hiện.
Trong ngôn ngữ C, chỉ thị tiền xử lý được chia ra làm các nhóm phổ biến sau:
  • Chỉ thị tạo/hủy định nghĩa macro (#define/#undef)
  • Chỉ thị biên dịch có điều kiện (#if, #elif, #else, #endif, #ifdef, #ifndef)
  • Chỉ thị bao hàm tệp (#include)
  • Chỉ thị thông báo lỗi (#error)
2.1. Chỉ thị tạo/hủy định nghĩa macro (#define/#undef)
2.1.1. Chỉ thị #define
Chỉ thị define là một trong những chỉ thị quan trọng và được sử dụng rất phổ biến để định nghĩa macro. Bao gồm 2 loại: Object-like macro và Function-like macro. Chúng ta cùng đi sâu hơn về cú pháp và ví dụ sử dụng 2 loại này.
a) Object-like macro:
Syntax: #define identifier replacement-token
Các identifier trong source file sẽ được compiler thay thế bằng các replacement-token ngay trong quá trình tiền xử lý. Xem ví dụ hình 2.
Hình 2. Ứng dụng chỉ thị #define Object-like macro
Indentifier không hợp lệ khi nó nằm trong thành phần chú thích, comment, trong một chuỗi hoặc là một phần của identifier khác. replacement-token có thể là một hằng số, chuỗi, ký tự.
b) Function-like macro:
Syntax: #define identifier(para1, para2, ..., paran) replacement-token
para1, para2, … trong cú pháp trên là các tham số và được đặt trong cặp dấu ngoặc đơn. Function-like macro được sử dụng để mô phỏng một hàm, một câu lệnh, một đoạn chương trình có thể có hoặc không có các tham số. Xem ví dụ hình 3.
Hình 3. Ứng dụng chỉ thị #define Function-like macro
Lưu ý: Theo tài liệu MISRA-C_2012, function nên được sử dụng thay vì Function-like macro.
2.1.2. Chỉ thị #undef: hủy bỏ định nghĩa và compiler sẽ không phát hiện, xử lý các identifier kể từ dòng lệnh chứa chỉ thị #undef trở về sau. Thường được sử dụng kèm với #define. Xem ví dụ hình 4.
Hình 4. Ứng dụng chỉ thị #undef Function-like macro
2.2. Chỉ thị biên dịch có điều kiện (#if, #elif, #else, #endif, #ifdef, #ifndef
Tương tự với lệnh if, else if, else mà chúng ta thường sử dụng, các chỉ thị điều kiện cũng có chức năng hoạt động khá tương đồng. Chỉ thị điều kiện được xử lý tại giai đoạn Tiền xử lý nhằm mục đích lựa chọn các khối văn bản phù hợp trong source file để biên dịch. Một số đặc điểm của các chỉ thị điều kiện:
  • Mỗi chỉ thị điều kiện #if trong tệp nguồn phải tương ứng với lệnh đóng điều kiện #endif. Bất kỳ số lượng chỉ thị #elif nào cũng có thể xuất hiện giữa các chỉ thị #if và #endif, nhưng tối đa một chỉ thị #else được cho phép trong khối lệnh đó. Chỉ thị #else (nếu có) phải là chỉ thị cuối cùng trước #endif.
  • Các chỉ thị #if, #elif, #else#endif có thể lồng bên trong trong các chỉ thị #if khác.
  • Bộ tiền xử lý đánh giá giá trị biểu thức trong #if và #elif, nếu mang giá trị khác 0 thì sẽ chọn khối lệnh bên trong #if hoặc #elif cho đến #elif hoặc #endif tương ứng để biên dịch. Xem ví dụ hình 5.
Hình 5. Ứng dụng chỉ thị biên dịch có điều kiện
Một số Preprocessor operator (Toán tử tiền xử lý) được sử dụng phổ biến nhằm kết hợp với các chỉ thị điều kiện là defined__has_include.
a) Toán tử defined:
Syntax: defined (identifier)
Được sử dụng kết hợp với chỉ thị #if và #elif để kiểm tra xem identifier có phải là macro hay không (hay đã được define chưa). defined trả về giá trị là 1 nếu identifier là macro (đã được define) tại thời điểm hiện tại trong chương trình và 0 trong trường hợp ngược lại. Ngoài ra, toán tử defined có thể kết hợp sử dụng với các toán tử khác. Xem ví dụ hình 6.
Hình 6. Ứng dụng toán tử defined
Lưu ý: có thể sử dụng chỉ thị #ifdef thay cho #if defined, #ifndef thay cho #if !defined, và đó cũng là chức năng của #ifdef và #ifndef. Về cách thức sử dụng thì #ifdef và #ifndef được sử dụng như #if.
Một cách sử dụng phổ biến toán tử defined là ngăn chặn sự lặp lại của cùng một header file khi khai báo nhiều lần tại nhiều file khác nhau trong một project. Các bạn xem tại ví dụ hình 7 nhé!
Hình 7. Ứng dụng toán tử defined để ngăn chặn trùng lặp khai báo file header
b) Toán tử __has_include:
Syntax: __has_include (<filesystem>) or __has_include ("fileuser")
Thường được sử dụng trong biểu thức ‘#if’ và ‘#elif’ để kiểm tra xem header file có thể được đưa vào bằng cách sử dụng lệnh ‘#include’ hay không. Cách thức hoạt động của __has_include giống với #include ở bước kiểm tra file có trong phạm vi tìm kiếm hay không. Nếu có, nghĩa là có thể khai báo filesystem / fileuser tại đây, toán tử sẽ trả về 1. Nếu không thì trả về 0. Xem ví dụ hình 8.
Hình 8. Ứng dụng toán tử __has_include
Lưu ý: Nếu kết quả toán tử trả về 1 không có nghĩa là tiêu đề hợp lệ và không chứa chỉ thị #error cảnh báo lỗi.
2.3. Chỉ thị bao hàm tệp (#include)
Trước khi bắt đầu tìm hiểu về chỉ thị #include, chúng ta cùng tìm hiểu về Header file trước nhé!
Header file là file chứa các khai báo (biến, hàm) và định nghĩa các macro nhằm mục đích chia sẻ việc sử dụng giữa các source file.
Header file bao gồm 2 nhóm với mục đích khác nhau:
  • File hệ thống (System header file)
  • File người dùng (Your owner header file)
Chúng ta có thể sử dụng header file trong chương trình bằng cách include nó với chỉ chị #include mà C/C++ cung cấp. Chúng ta sẽ thường gặp 2 cấu trúc khai báo file header tương ứng với 2 nhóm thư viện kể trên.
Syntax:
#include <path-spec>
#include "path-spec"
Bản chất cả hai cú pháp trên nhằm mục đích tìm kiếm header file và sao chép toàn bộ nội dung của header file vào ngay vị trí câu lệnh include. Xem ví dụ hình 9.
Hình 9. Ứng dụng chỉ thị #include
Điểm khác nhau của hai cú pháp là ở bước tìm kiếm file thư viện.
  • Syntax 1: được sử dụng include các header file hệ thống. Tìm kiếm file trong danh sách các folder hệ thống tiêu chuẩn; các folder được liên kết khi sử dụng với -I. Trong ví dụ hình 10 dưới đây, người dùng thực hiện include file GPIO.h, file này được đặt trong folder lib (lib/GPIO.h), không thuộc vị trí tìm kiếm folder hệ thống. Nếu bạn muốn sử dụng syntax 1 trong trường hợp này, bạn có thể kết hợp với compiler option -I.

Hình 10. Ứng dụng chỉ thị #include

  • Syntax 2: được sử dụng để include các header file trong chương trình của bạn. Tìm kiếm lần lượt trong cùng folder chứa file đang include header file; các folder được list khi sử dụng với -iqoute (tương tự như -I nhưng chỉ áp dụng cho syntax 2); đến các vị trí được tìm kiếm của Syntax 1.
  • Preprocessor sẽ ngừng tìm kiếm ngay khi tìm thấy header file.

2.4. Chỉ thị thông báo lỗi (#error)

Chỉ thị #error được sử dụng để thông báo lỗi trong chương trình. Trong quá trình biên dịch, nếu gặp chỉ thị #error, trình biên dịch sẽ ngưng lại và hiển thị thông báo lỗi tương ứng.

Syntax:
#error "error message"
Hình 11. Ứng dụng chỉ thị #error
Chỉ chị #error có thể nên được sử dụng trong trường hợp như phiên bản trình biên dịch không bao gồm đầy đủ các thư viện và chúng ta mong muốn sử dụng, làm cho chương trình hoạt động không bình thường quá trình biên dịch không thành công.
Bên cạnh việc sử dụng chỉ thị #error, trình biên dịch có cung cấp các compiler warning đối với những lỗi không nghiêm trọng và không làm thay đổi hoạt động chương trình. Trình biên dịch cũng cung cấp các compiler error khi gặp những lỗi nghiêm trọng.
Vậy nếu compiler đã cung cấp các warning và error để chúng ta có thể theo dõi rồi, vậy chúng ta sử dụng #error để làm gì?
  • Một số lỗi về runtime sẽ không được compiler phát hiện ra, hậu quả sẽ xuất hiện khi chương trình được khởi chạy.
  • Thông báo nội dung lỗi rõ ràng, đẩy nhanh thời gian phân tích vấn đề và giải quyết.

Để sử dụng được hiệu quả chỉ thị này, các bạn cần xem xét các trường hợp lỗi của mình và áp dụng phù hợp nhé!

Vậy là qua bài viết này, mình đã giới thiệu đến các bạn 4 nhóm chỉ thị tiền xử lý phổ biến và người lập trình thường sử dụng. Hy vọng qua bài viết này, các bạn sẽ hiểu rõ về cách thức sử dụng và hoạt động của chúng.

Chúc các bạn thành công!

N.T.Nhien

Tìm hiểu thêm:
Fanpage Cộng đồng Kỹ thuật TAPIT: TAPIT – AIoT Learning