bài trước ta thấy một #include <stdio.h> kéo theo 37 file, và gộp ba header lại chỉ nạp 80 file thay vì 123 nhờ include guard chống nạp trùng. Bài này soi kỹ chính cái cơ chế đó: vì sao cần nó, nó hoạt động ra sao, và một cái bẫy khiến "có guard rồi mà vẫn hỏng". Đo tận mắt bằng gcc -E, tôi vấp đúng cái bẫy đó — và nó dạy một điều tinh tế: include guard không khóa theo file, nó khóa theo tên.

Chống include lặp

Vì sao cần chống include lặp

Vấn đề nảy sinh từ cấu trúc kim cương: main.c include a.hb.h, mà cả a.h lẫn b.h đều include c.h (chứa định nghĩa một struct). Vì #include chỉ là dán văn bản, c.h bị dán hai lần vào đơn vị dịch — và định nghĩa một struct hai lần là lỗi. Tôi dựng đúng tình huống này và dịch:

c.h:1:8: error: redefinition of 'struct Config'

Đếm trong file đã tiền xử lý (gcc -E), thân struct Config xuất hiện 2 lần — đúng thủ phạm. Định nghĩa lặp (struct, union, enum, typedef, hàm inline) là lỗi biên dịch cứng; khai báo lặp (nguyên mẫu hàm, extern) thì thường không sao. Nhưng vì header hay chứa định nghĩa kiểu, cứ để lặp là hỏng — nên cần một cách bảo đảm mỗi header chỉ được dán một lần dù được include bao nhiêu lối.

Đo: include guard dán thân đúng một lần

Include guard là mẫu quen thuộc bọc quanh toàn bộ header:

#ifndef CONFIG_H
#define CONFIG_H
struct Config { int x; };
#endif

Lần đầu bộ tiền xử lý gặp header, CONFIG_H chưa được định nghĩa nên nó vào trong, định nghĩa CONFIG_H rồi dán thân. Lần thứ hai, CONFIG_H đã được định nghĩa, nên #ifndef sai và toàn bộ thân bị bỏ qua. Thêm guard vào c.h rồi dịch lại: chương trình dịch sạch, và đếm trong gcc -E cho thấy thân struct Config giờ chỉ còn 1 lần, dù c.h vẫn được include qua cả a.hb.h. Cơ chế đơn giản và hiệu quả. Nhưng chú ý một chi tiết: guard hoạt động bằng cách kiểm tra một tên macro — và đó là chỗ tôi sắp vấp.

Một lần tôi đo hớ: hai header trùng tên guard, một cái biến mất

Tôi định chốt gọn: "có include guard là an toàn khỏi include lặp". Để kiểm, tôi dựng hai header hoàn toàn khác nhausensor.h khai báo doc_cam_bien(), motor.h khai báo chay_motor() — nhưng lỡ (như vẫn hay xảy ra khi copy-paste) cho cả hai cùng một tên guard COMMON_H. Rồi app.c include cả hai và gọi cả hai hàm. Tôi đoán mọi thứ vẫn chạy. Đo ra:

app.c: warning: implicit declaration of function 'chay_motor'

Đọc gcc -E thì rõ: khai báo doc_cam_bien (từ sensor.h) có mặt, nhưng khai báo chay_motor (từ motor.h) biến mất hoàn toàn. Lý do: sensor.h được nạp trước, nó định nghĩa COMMON_H. Khi tới motor.h, bộ tiền xử lý thấy COMMON_H đã có, #ifndef sai, nên bỏ qua toàn bộ thân motor.h — dù đây là một file khác hẳn. Guard không hề biết đây là hai file khác nhau; nó chỉ thấy một cái tên đã được đánh dấu.

Bài học đo lường: include guard khóa theo TÊN MACRO bạn tự đặt, không theo danh tính file. Trùng tên guard giữa hai header khác nhau khiến cái nạp sau bị nuốt im lặng — và triệu chứng chỉ là một cảnh báo "implicit declaration" dễ trôi qua trong C (trong C++ thì thành lỗi khó hiểu ở nơi khác). Tôi đã đem giả định "guard bảo vệ theo file" áp lên một cơ chế thực ra chỉ đếm tên. Và một lần nữa, cách bắt được sự thật là đọc gcc -E — thấy thân motor.h đã bốc hơi — thay vì tin vào giả định. Sửa thì đơn giản: đổi hai guard thành tên riêng (SENSOR_H, MOTOR_H), lập tức cả hai khai báo trở lại và dịch sạch.

#pragma once: khóa theo danh tính file

Có một cách thay thế né hẳn bẫy trùng tên: #pragma once. Đặt đúng một dòng đó ở đầu file, và trình biên dịch tự ghi nhớ danh tính vật lý của file (thường là inode) đã nạp, không cần bạn đặt tên gì. Tôi thay hai guard bằng #pragma once và đo: cả doc_cam_bien lẫn chay_motor đều còn nguyên, dịch sạch — vì không có cái tên nào để mà đụng nhau.

Đổi lại, #pragma once không thuộc chuẩn C (dù gcc, clang, MSVC đều hỗ trợ), và vì nó nhận diện file theo danh tính vật lý, một file cùng nội dung mà tới được qua hai đường dẫn khác nhau (symlink, bản sao, mount trùng) có thể bị coi là hai file và nạp hai lần. Hai cách khóa theo hai thứ khác nhau: guard theo tên bạn đặt, pragma once theo file trên đĩa — mỗi cái có một kiểu hỏng riêng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đặt tên guard cho thật độc nhất. Đừng dùng UTIL_H hay COMMON_H chung chung — trong một dự án lớn, xác suất đụng tên là có thật, và hậu quả (một header biến mất im lặng) rất khó lần vì lỗi hiện ra ở nơi dùng, không phải nơi guard. Quy ước an toàn là gắn đường dẫn hoặc tên dự án vào guard: PROJECT_MODULE_SENSOR_H. Nhiều nơi chọn #pragma once chính để khỏi phải nghĩ ra tên độc nhất.

Hệ quả thứ hai là biết triệu chứng để chẩn đoán nhanh. Khi một hàm hay kiểu "rõ ràng đã khai báo trong header" mà trình dịch báo không tìm thấy (implicit declaration, unknown type), một nghi phạm hàng đầu là guard bị trùng tên làm header đó bị bỏ qua. Mở gcc -E xem thân header có thật sự xuất hiện không là cách kiểm tra dứt điểm — đúng thói quen "đọc thứ tiền xử lý dán ra" mà sê-ri này lặp đi lặp lại.

Hệ quả thứ ba là một góc nhìn về chính bản chất #include: nó không phải một hệ thống module, nó chỉ là dán văn bản, và mọi cơ chế chống lặp đều là mẹo vá lên trên cái dán đó. Guard, pragma once — đều là cách nói với bộ tiền xử lý "đừng dán lại". Đây là lý do C++ hiện đại thêm hẳn modules để thay thế, và vì sao thời gian biên dịch của các dự án C/C++ lớn bị chi phối bởi việc nạp và phân tích lại header. Con số mang theo: include guard chống include lặp bằng cách khóa theo tên macro — hiệu quả nhưng dễ dính bẫy trùng tên khiến một header biến mất không dấu vết; #pragma once khóa theo danh tính file nên né được bẫy đó nhưng không thuộc chuẩn — và cách chắc chắn nhất để biết một header có được nạp hay không luôn là đọc gcc -E. Cơ chế nào cũng có mặt trái; hiểu nó khóa theo cái gì là chìa khóa để gỡ khi nó hỏng.

Thử ba mươi giây

Tạo hai file x.hy.h, cho cả hai cùng mở đầu bằng #ifndef HDR / #define HDR (cố tình trùng tên), mỗi file khai báo một hàm khác nhau. Viết một main.c include cả hai rồi chạy gcc -E main.c và tìm hai khai báo: bạn sẽ thấy chỉ khai báo của file nạp trước xuất hiện, file sau biến mất. Đổi HDR của một file thành tên khác, chạy lại gcc -E: cả hai khai báo cùng hiện ra. Chỉ một lần xem gcc -E là bạn thấy tận mắt vì sao "có guard" chưa đủ — guard phải có tên độc nhất mới an toàn.