Menu

Macro trong C: #define, macro dạng hàm và quy tắc dấu ngoặc

Macro dạng đối tượng và macro dạng hàm với #define: vì sao mọi tham số và cả phần thân đều cần ngoặc, cái bẫy tính toán nhiều lần, macro nhiều dòng với do-while(0), và khi nào hàm hoặc const là công cụ tốt hơn.

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.

Macro là một cái tên mà bộ tiền xử lý thay bằng một đoạn văn bản trước khi trình biên dịch chạy. Toàn bộ cơ chế chỉ có vậy, và mọi quy tắc trên trang này đều là hệ quả của nó: macro không có kiểu, không tôn trọng phạm vi, không tính đối số, và không biết lời gọi hàm là gì. Chúng chỉ sao chép văn bản.

Dùng cẩn thận thì macro là không thể thiếu: công tắc cho tính khả chuyển, macro assert ghi lại số dòng, hằng số lúc biên dịch dùng được làm kích thước mảng. Dùng ẩu thì chúng sinh ra những bug trông như không thể xảy ra, cho tới khi bạn mở rộng tệp ra xem.

Macro dạng đối tượng

Dạng đơn giản nhất định nghĩa một cái tên đại diện cho một giá trị:

Theo quy ước, các tên này viết kiểu UPPER_SNAKE_CASE để người đọc nhìn qua là biết một ký hiệu là macro chứ không phải biến. Quy ước này ở đây quan trọng hơn bình thường, vì macro bỏ qua phạm vi: một #define bên trong hàm vẫn áp dụng cho phần còn lại của tệp, và sẵn sàng viết lại một biến cùng tên trong một hàm khác.

#undef NAME xóa một định nghĩa, để về sau có thể định nghĩa lại tên đó mà không bị cảnh báo định nghĩa lại.

Macro dạng hàm

Đặt danh sách tham số ngay sau tên, không có khoảng trắng trước ngoặc mở, là macro sẽ nhận đối số:

Quy tắc về khoảng trắng là có thật: #define SQUARE (x) ((x)*(x)) định nghĩa một macro dạng đối tượng tên SQUARE có phần thay thế bắt đầu bằng (x). Lỗi biên dịch xuất hiện sau đó sẽ chẳng nhắc gì tới khoảng trắng.

Quy tắc dấu ngoặc

Có hai quy tắc, và cả hai tồn tại vì phần thân được dán vào một biểu thức mà bạn không nhìn thấy lúc viết macro.

Quy tắc 1: bọc ngoặc mọi tham số. Thiếu nó, một đối số vốn là biểu thức sẽ bị độ ưu tiên toán tử xé lẻ:

#define SQUARE_BAD(x) x * x

SQUARE_BAD(2 + 3)   /* becomes 2 + 3 * 2 + 3  ==  11, not 25 */

Quy tắc 2: bọc ngoặc toàn bộ phần thân. Thiếu nó, biểu thức xung quanh sẽ xé lẻ kết quả:

#define DOUBLE_BAD(x) (x) + (x)

10 / DOUBLE_BAD(5)  /* becomes 10 / (5) + (5)  ==  7, not 1 */

Chạy cả hai lỗi cạnh các phiên bản đã sửa:

Cả hai phiên bản sai đều biên dịch mà không có lấy một cảnh báo và cho ra số sai. Thói quen cần tập là thao tác máy móc: bọc từng tham số, rồi bọc kết quả. ((a) > (b) ? (a) : (b)) trông rối mắt, và chính sự rối mắt đó là mục đích.

Cái bẫy tính toán nhiều lần

Dấu ngoặc không cứu được bạn khỏi mối nguy thứ hai. Macro dán văn bản đối số vào mọi chỗ tham số xuất hiện, nên một đối số có tác dụng phụ sẽ xảy ra nhiều hơn một lần:

Macro mở rộng thành ((i++) > (j) ? (i++) : (j)). Phép so sánh tăng i lên 6 và so sánh 5 với 3; nhánh đúng lại tăng i lên 7 và cho ra 6. Vậy m là 6 và i là 7, cả hai đều không phải thứ mà "số lớn hơn giữa i và j" nên cho ra. Một hàm max(i++, j) sẽ đúng, vì đối số được tính một lần trước khi gọi.

Cái bẫy này bắt mọi thứ có tác dụng phụ: SQUARE(rand()) gọi rand() hai lần và nhân hai số khác nhau. CHECK(read_byte()) đọc mất hai byte.

Hai cách phòng, theo thứ tự ưu tiên:

  1. Dùng một hàm thật. Nếu cần cho nhiều kiểu, viết mỗi kiểu một hàm, hoặc dùng hàm static inline trong header.
  2. Nếu buộc phải là macro, hãy ghi chú thật rõ rằng đối số bị tính nhiều lần, và giữ các chỗ gọi không có tác dụng phụ.

Macro nhiều dòng và do-while(0)

Phần thân macro có thể trải ra nhiều dòng nếu mọi dòng trừ dòng cuối đều kết thúc bằng dấu gạch chéo ngược. Phiên bản ngây thơ trông có vẻ ổn:

#define LOG_TWICE(msg) \
    printf("%s\n", msg); \
    printf("%s\n", msg)

Rồi nó hỏng một cách lặng lẽ, đúng ở chỗ quan trọng:

if (verbose)
    LOG_TWICE("hello");
else
    printf("quiet\n");

Sau khi thay thế, if chỉ sở hữu printf đầu tiên, printf thứ hai luôn chạy, và else giờ không còn if nào để ghép: một lỗi biên dịch mà thông báo chẳng chỉ tới đâu hữu ích. Bọc phần thân bằng ngoặc nhọn trần cũng không khá hơn: dấu ; sau LOG_TWICE("hello") thành câu lệnh rỗng kết thúc if, và else lại hỏng.

Cách viết chạy được là do { ... } while (0):

do { ... } while (0) là một câu lệnh duy nhất, chạy phần thân đúng một lần, và bắt buộc có dấu chấm phẩy phía sau, nên chỗ gọi đọc như một lời gọi hàm bình thường và hoạt động như vậy trong mọi ngữ cảnh điều khiển luồng. Cẩn thận với dấu gạch chéo ngược: một khoảng trắng lạc sau dấu gạch chéo ngược sẽ ngắt phần nối dòng và gây ra một lỗi khó hiểu.

Macro định nghĩa sẵn và các mẫu assert

Bộ tiền xử lý tự định nghĩa một số macro, và chúng là lý do có những thứ đơn giản là không thể viết thành hàm:

__FILE__ và __LINE__ mở rộng tại chỗ chúng được viết, nên đặt chúng trong một macro sẽ ghi lại vị trí của bên gọi. Hàm thì không làm được: bên trong hàm, chúng luôn báo tệp và dòng của chính hàm ghi log. Đây chính xác là cách macro assert chuẩn báo vị trí của biểu thức bị sai.

Các tên định nghĩa sẵn hữu ích khác: __DATE__, __TIME__ và __STDC_VERSION__ (ví dụ 201710L cho C17).

Hai toán tử nên biết

Bên trong phần thân macro, # biến một tham số thành chuỗi ký tự ("stringizing") và ## dán các token lại với nhau ("token pasting").

SHOW(width * height) trở thành printf("width * height" " = %d\n", (width * height));: các chuỗi ký tự đứng cạnh nhau được trình biên dịch nối lại, nên một lời gọi in ra cả văn bản của biểu thức lẫn giá trị của nó. Đây là mẹo gỡ lỗi đáng nhớ.

## hiếm gặp hơn và chủ yếu xuất hiện trong các macro sinh code: #define MAKE_VAR(n) int var_##n biến MAKE_VAR(3) thành int var_3. Hãy dùng tiết chế; các định danh do bộ tiền xử lý tạo ra không thể tìm kiếm theo tên, khiến code khó lần theo.

Macro, hàm hay const

Hãy dùng công cụ yếu nhất mà vẫn làm được việc:

  • Biến const: const double PI = 3.14159; có kiểu, tuân theo phạm vi, hiện trong trình gỡ lỗi, và không thể bị một header chẳng liên quan định nghĩa lại. Ưu tiên nó cho các giá trị dùng lúc chạy.
  • enum: enum { MAX_USERS = 100 }; cho một hằng số nguyên có tên, dùng được ở những chỗ C đòi hằng số lúc biên dịch, có kiểu và phạm vi. Phù hợp cho các giới hạn số nguyên.
  • Hàm static inline: được kiểm tra kiểu, tính mỗi đối số đúng một lần, và trình biên dịch hiện đại inline nó y như một macro. Đây là thứ thay thế đúng cho gần như mọi macro dạng hàm.
  • Macro: khi bạn cần __LINE__, khi thứ đó phải chạy được trước khi có kiểu (kích thước mảng trong code cũ), khi bạn bật tắt code bằng biên dịch có điều kiện, hoặc khi bạn sinh code lặp đi lặp lại.

Một chỗ mà macro vẫn chưa có đối thủ là tính kích thước mảng, vì kết quả phải là hằng số lúc biên dịch:

Macro này cũng có một lưu ý nổi tiếng: nó chỉ đúng với mảng thật. Truyền vào một con trỏ, thứ mà mảng biến thành khi được đưa vào hàm, thì sizeof đo con trỏ và cho ra một đáp án sai một cách tự tin và im lặng.

Gỡ lỗi một macro

Khi macro chạy sai, đừng nhìn chằm chằm vào nó. Hãy mở rộng nó ra:

gcc -E program.c | tail -30

Văn bản sau khi thay thế cho bạn biết ngay vấn đề là thiếu dấu ngoặc, bị tính hai lần, hay một cái tên trùng với thứ khác. Cũng nên biên dịch với -Wall -Wextra: GCC và clang thêm các ghi chú "in expansion of macro" nối dòng báo lỗi về với định nghĩa.

Tiếp theo: tệp header, nơi #define và #include kết hợp để một dự án trải ra nhiều tệp nguồn mà không phải dán cùng các khai báo hai lần.

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

Macro trong C là gì?

Là một đoạn văn bản có tên mà bộ tiền xử lý thay vào mã nguồn trước khi biên dịch. #define MAX 100 biến mọi MAX phía sau thành 100; #define SQUARE(x) ((x) * (x)) nhận đối số và thay chúng vào một khuôn mẫu. Macro không có kiểu và không tuân theo quy tắc phạm vi nào: chúng chỉ là phép thay thế văn bản.

Vì sao macro trong C cần nhiều dấu ngoặc đến vậy?

Vì phần thân được dán vào code xung quanh rồi mới được phân tích như một khối. #define SQUARE(x) x * x biến SQUARE(2 + 3) thành 2 + 3 * 2 + 3, tức 11 chứ không phải 25. Bọc mọi tham số và toàn bộ phần thân, ((x) * (x)), giúp văn bản sau khi thay được nhóm đúng như bạn muốn bất kể xung quanh là gì.

Macro và hàm trong C khác nhau thế nào?

Hàm được biên dịch một lần, được kiểm tra kiểu, và tính mỗi đối số đúng một lần. Macro được dán vào mọi chỗ dùng, không kiểm tra gì, và có thể tính một đối số nhiều lần, nên MAX(i++, j) có thể tăng i hai lần. Hãy ưu tiên hàm (và const/enum cho hằng số); dùng macro cho những việc hàm không làm được, như lấy __LINE__ hay sinh code.

Vì sao macro nhiều dòng được bọc trong do { ... } while (0)?

Để nó hoạt động như một câu lệnh duy nhất. Phần thân chỉ có { ... } sẽ hỏng khi theo sau là dấu chấm phẩy bên trong if/else (dấu chấm phẩy kết thúc if quá sớm), còn một dãy câu lệnh trần sẽ hỏng trong if không có ngoặc nhọn. do { ... } while (0) là một câu lệnh nhận dấu chấm phẩy phía sau và chạy phần thân đúng một lần.

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