Menu

Tệp header trong C: .h và .c, include guard, chương trình nhiều tệp

Cách chia một chương trình C ra nhiều tệp: thứ gì thuộc về tệp .h, thứ gì thuộc về tệp .c, include guard để chặn include hai lần, biên dịch nhiều tệp cùng lúc và chia sẻ biến toàn cục bằng extern.

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.

Một tệp .c duy nhất vẫn ổn cho tới khi nó dài 2.000 dòng. Chia chương trình ra nhiều tệp giúp từng phần được biên dịch riêng, tái sử dụng trong chương trình khác và đọc độc lập, nhưng C không có hệ thống import. Thứ C có là bộ tiền xử lý dán văn bản, cộng với trình liên kết ghép các mảnh lại ở bước cuối.

Tệp header (header file, .h) là bản giao kèo chung giữa các mảnh đó: nó cho mọi tệp nguồn biết những gì tồn tại ở nơi khác, mà không chứa phần cài đặt.

Khai báo và định nghĩa

Toàn bộ thiết kế dựa trên một sự phân biệt.

Khai báo nói rằng thứ này tồn tại ở đâu đó và đây là hình dạng của nó. Nó không sinh ra code và có thể xuất hiện bao nhiêu lần cũng được:

int add(int a, int b);        /* function declaration (prototype) */
extern int error_count;       /* variable declaration */
struct Point { int x, y; };   /* type definition - safe to repeat per file */

Định nghĩa tạo ra thứ đó. Nó phải xuất hiện đúng một lần trong cả chương trình:

int add(int a, int b) { return a + b; }   /* function definition */
int error_count = 0;                      /* variable definition */

Header chứa khai báo. Tệp nguồn chứa định nghĩa. Làm ngược lại là trình liên kết báo "multiple definition of ...", thông báo lỗi gần như luôn có nghĩa là một định nghĩa đã lạc vào header.

Chương trình hai tệp

Đây là cách chia nhỏ nhất mà vẫn hữu ích. Một header khai báo hai hàm:

/* math_utils.h */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b);
int max_of(int a, int b);

#endif

Tệp nguồn cài đặt chúng, để ý là nó include chính header của mình:

/* math_utils.c */
#include "math_utils.h"

int add(int a, int b) {
    return a + b;
}

int max_of(int a, int b) {
    return (a > b) ? a : b;
}

Và chương trình sử dụng chúng:

/* main.c */
#include <stdio.h>
#include "math_utils.h"

int main(void) {
    printf("add(3, 4)    = %d\n", add(3, 4));
    printf("max_of(3, 4) = %d\n", max_of(3, 4));
    return 0;
}

Biên dịch cả hai tệp nguồn cùng lúc:

gcc main.c math_utils.c -o app
./app

Có hai chi tiết đáng chú ý. Thứ nhất, math_utils.c include chính header của nó, và điều đó không thừa. Nhờ vậy trình biên dịch kiểm tra mỗi định nghĩa có khớp với khai báo không, nên nếu bạn sửa prototype trong header mà quên tệp .c, bạn nhận lỗi ngay thay vì một chỗ lệch lúc liên kết.

Thứ hai, math_utils.h không có mặt trên dòng lệnh gcc. Header không bao giờ được biên dịch; chúng được #include dán vào các tệp .c. Đưa một tệp .h cho trình biên dịch chỉ tạo ra một tệp precompiled header thừa và không có code nào để liên kết.

Cũng chương trình đó gói gọn vào một tệp để bạn chạy ngay tại đây:

Các khai báo đứng trước main chính là thứ header cung cấp trong phiên bản thật, và đó là lý do nguyên mẫu hàm và header là cùng một ý tưởng ở hai quy mô.

Include guard

#include dán văn bản, và văn bản dán hai lần là văn bản bị lặp. Điều đó vô hại với prototype của hàm nhưng chết người với một struct:

/* shapes.h WITHOUT a guard */
struct Point { int x, y; };

Nếu main.c include cả shapes.h lẫn canvas.h, mà canvas.h cũng include shapes.h, trình biên dịch sẽ thấy struct Point được định nghĩa hai lần trong cùng một đơn vị biên dịch và dừng lại với lỗi "redefinition of 'struct Point'". Trong dự án thật, các chuỗi include này sâu tới mức bạn không thể theo dõi bằng tay.

Cách sửa là một include guard: một macro ghi nhận "header này đã được dán rồi".

/* shapes.h */
#ifndef SHAPES_H
#define SHAPES_H

struct Point { int x, y; };
struct Point origin_point(void);

#endif /* SHAPES_H */

Lần include đầu tiên thấy SHAPES_H chưa được định nghĩa, nên giữ phần thân, và định nghĩa luôn SHAPES_H trên đường đi qua. Mọi lần include sau trong cùng tệp đều thấy nó đã được định nghĩa và nhảy thẳng tới #endif. Tên macro phải là duy nhất trong cả dự án; quy ước thường dùng là FILENAME_H lấy từ đường dẫn.

Cách viết một dòng thay thế được mọi trình biên dịch phổ biến hỗ trợ:

/* shapes.h */
#pragma once

struct Point { int x, y; };

#pragma once không thể bị trùng tên và không thể hỏng vì gõ sai ở #endif. Nhược điểm duy nhất là nó không nằm trong chuẩn C, nên dự án phải build trên các trình biên dịch lạ nên chọn dạng #ifndef. Dù chọn cách nào, header nào cũng phải có một, không ngoại lệ, kể cả những header bạn nghĩ sẽ không có tệp nào khác include.

Thứ gì nên nằm trong header

Đặt vào tệp .h:

  • Prototype của hàm
  • Định nghĩa struct, union và enum
  • Khai báo typedef
  • Macro dùng chung
  • Khai báo extern cho biến toàn cục dùng chung
  • Các #include mà chính header đó cần để tự đứng vững

Giữ ngoài tệp .h:

  • Thân hàm (trừ khi cố ý dùng static inline)
  • Định nghĩa biến: int counter; trong header sẽ định nghĩa một biến riêng trong mỗi tệp include nó, hoặc gây lỗi liên kết, tùy trình biên dịch
  • #include những header mà chính header đó không cần: làm vậy là đẩy phụ thuộc đó sang mọi tệp phía sau

"Tự đứng vững" đáng được biến thành quy tắc: một header phải biên dịch được khi được include đầu tiên, trước mọi thứ khác. Nếu shapes.h dùng size_t, nó tự include <stddef.h> thay vì trông chờ tệp include nó đã làm việc đó.

Một header hoàn chỉnh, đúng chuẩn:

/* inventory.h */
#ifndef INVENTORY_H
#define INVENTORY_H

#include <stddef.h>   /* for size_t, used below */

#define MAX_NAME 64

typedef struct {
    char   name[MAX_NAME];
    int    quantity;
    double price;
} Item;

/* shared across the program, defined once in inventory.c */
extern int item_count;

void   inventory_add(const Item *item);
double inventory_total(void);
size_t inventory_size(void);

#endif /* INVENTORY_H */

Chia sẻ biến toàn cục bằng extern

Một biến toàn cục phải được định nghĩa trong đúng một tệp .c và được khai báo ở mọi nơi khác. extern là thứ tạo ra khai báo:

/* inventory.h  - declaration, no storage */
extern int item_count;
/* inventory.c  - the one definition */
#include "inventory.h"
int item_count = 0;
/* main.c - uses it, via the header */
#include <stdio.h>
#include "inventory.h"

int main(void) {
    printf("%d items\n", item_count);
    return 0;
}

Bỏ extern khỏi header là mọi tệp include nó sẽ định nghĩa item_count của riêng mình, tốt nhất thì gặp lỗi liên kết "multiple definition", tệ nhất thì có hai bộ đếm độc lập.

Nhu cầu ngược lại cũng phổ biến không kém: một biến hay hàm phụ trợ chỉ nên dùng riêng trong một tệp .c. static ở phạm vi tệp làm được điều đó: nó cho tên liên kết nội bộ (internal linkage), vô hình với trình liên kết và do đó với mọi tệp khác:

/* inventory.c */
static Item storage[256];          /* private to this file */
static int  find_slot(const char *name);   /* private helper */

Hai tệp có thể mỗi tệp một static int counter; mà không xung đột. Đây là phiên bản thành viên private của C, và là lựa chọn mặc định bạn nên dùng: chỉ những gì tệp khác thật sự cần mới đưa vào header.

Biên dịch chương trình lớn hơn

Liệt kê mọi tệp thì chạy được nhưng chậm, vì lần nào cũng biên dịch lại tất cả:

gcc main.c inventory.c report.c -o app

Cách mở rộng được là biên dịch mỗi tệp nguồn thành một tệp đối tượng (object file) rồi liên kết chúng:

gcc -c main.c        # produces main.o
gcc -c inventory.c   # produces inventory.o
gcc -c report.c      # produces report.o
gcc main.o inventory.o report.o -o app

Giờ sửa report.c chỉ cần gcc -c report.c và liên kết lại. Đó chính là phần sổ sách mà make tự động hóa:

app: main.o inventory.o report.o
	gcc main.o inventory.o report.o -o app

%.o: %.c
	gcc -Wall -Wextra -c $< -o $@

Nếu header nằm trong một thư mục con, -Iinclude thêm thư mục đó vào đường dẫn tìm kiếm cho dấu ngoặc nhọn.

Các lỗi và ý nghĩa của chúng

Hai kiểu thất bại rất dễ phân biệt khi bạn biết giai đoạn nào sinh ra chúng.

"undefined reference to 'add'": lỗi của trình liên kết. Tìm thấy khai báo nhưng không tìm thấy định nghĩa. Có thể bạn quên liệt kê tệp .c trên dòng lệnh, hoặc hàm là static, hoặc tên bị viết sai ở một trong hai chỗ.

"multiple definition of 'item_count'": cũng là lỗi trình liên kết, trường hợp ngược lại: một định nghĩa lọt vào header, hoặc nằm trong hai tệp nguồn. Chuyển nó vào một tệp .c và để lại khai báo extern trong header.

"redefinition of 'struct Item'": lỗi của trình biên dịch, nghĩa là một header bị dán hai lần vào cùng một tệp. Hãy thêm include guard.

"implicit declaration of function 'add'": một cảnh báo của trình biên dịch (là lỗi ở chế độ C99 trở đi), nghĩa là prototype chưa bao giờ được nhìn thấy. Bạn quên #include, hoặc header không khai báo hàm đó.

Khi chương trình đã trải ra nhiều tệp, điều tiếp theo bạn thường muốn là thay đổi những gì được biên dịch theo từng nền tảng hay từng kiểu build, và đó là biên dịch có điều kiện.

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

Thứ gì nằm trong tệp .h và thứ gì nằm trong tệp .c?

Header chứa khai báo: prototype của hàm, định nghĩa struct và typedef, các enum, macro, và khai báo extern cho biến toàn cục dùng chung. Tệp .c chứa định nghĩa: thân hàm và các biến thật sự. Nguyên tắc dễ nhớ: header nói cái gì tồn tại, tệp nguồn nói nó làm gì.

Include guard là gì và vì sao cần nó?

#include dán văn bản, nên include một header hai lần sẽ dán nội dung của nó hai lần, định nghĩa lại mọi struct và typedef bên trong và làm biên dịch thất bại. Include guard bọc header trong #ifndef MYHEADER_H / #define MYHEADER_H / #endif, để lần include thứ hai thấy macro đã được định nghĩa và bỏ qua phần thân.

Biên dịch chương trình C có nhiều tệp như thế nào?

Liệt kê mọi tệp .c trên dòng lệnh: gcc main.c math_utils.c -o app. Đừng bao giờ đưa tệp .h vào đó: header được #include dán vào, không được biên dịch riêng. Với dự án lớn hơn, hãy biên dịch ra tệp đối tượng (gcc -c main.c) rồi liên kết chúng lại, đó chính là việc Makefile tự động hóa.

Nên dùng #pragma once hay include guard #ifndef?

Cả hai đều được. #pragma once chỉ một dòng, không thể trùng tên, và mọi trình biên dịch phổ biến đều hỗ trợ, nhưng nó không nằm trong chuẩn C. Dạng #ifndef/#define/#endif là chuẩn và chạy ở mọi nơi. Hãy chọn một và dùng thống nhất trong dự án; nếu cần khả chuyển tối đa thì chọn dạng #ifndef.

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