C phân biệt rạch ròi hai việc mà nhiều ngôn ngữ khác gộp làm một: khai báo một cái tên (nói rằng nó tồn tại, kiểu gì) và định nghĩa nó (thật sự tạo ra nó). Sự tách bạch này chính là thứ cho phép mỗi file dịch độc lập như bài trước đã đo — nhưng nó cũng là nguồn của lỗi "undefined reference" quen thuộc và một cái bẫy extern tinh vi. Bài này đo ai cần cái gì, ở giai đoạn nào, và tôi vấp đúng cái bẫy extern đó.

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

Khai báo nói "tồn tại", định nghĩa tạo ra nó

Một khai báo giới thiệu một cái tên và kiểu của nó — "có một thứ tên như vầy, kiểu như vầy, ở đâu đó". Nguyên mẫu hàm int f(int); là khai báo; extern int x; là khai báo. Bạn được phép khai báo cùng một thứ nhiều lần, ở nhiều file.

Một định nghĩa thật sự tạo ra thứ đó — cấp phát bộ nhớ, hoặc sinh mã hàm. int f(int x){ return x; } là định nghĩa; int x; (ở phạm vi file) là định nghĩa; int x = 5; là định nghĩa. Quy tắc một định nghĩa (ODR): mỗi thứ phải được định nghĩa đúng một lần trong toàn chương trình.

Điểm cốt lõi cần đo: hai giai đoạn khác nhau cần hai thứ khác nhau. Trình biên dịch, khi dịch một file, chỉ cần khai báo — đủ để biết kiểu mà sinh mã đúng. Chỉ bước liên kết mới cần định nghĩa — để có mã/bộ nhớ thật mà nối vào. Hệ quả trực tiếp: thiếu định nghĩa không phải lỗi biên dịch.

Đo: thiếu định nghĩa chỉ hỏng lúc liên kết

Tôi khai báo một hàm int compute(int);, gọi nó trong main, nhưng không viết định nghĩa, rồi dịch riêng:

gcc -c caller.c  ->  DỊCH OK
nm caller.o      ->  U compute   (U = undefined, chờ liên kết)

Biên dịch thành công — khai báo là đủ để trình dịch sinh mã lời gọi, để lại một ký hiệu ngoài compute chưa phân giải. Chỉ khi liên kết mới nổ:

undefined reference to `compute'

Thêm file def.c chứa định nghĩa compute vào rồi liên kết lại: chạy trơn, trả về 25. Đây là lý do "undefined reference" là lỗi liên kết, không phải lỗi cú pháp — nó nghĩa là "có ai đó hứa cái này tồn tại, nhưng không ai định nghĩa nó". Với biến toàn cục qua extern cũng vậy: guse.c dùng extern int gcounter; để đọc biến định nghĩa ở gdef.c → chạy ra 7; bỏ gdef.c đi (mất định nghĩa) → "undefined reference to gcounter". Khai báo cho bạn quyền dùng một cái tên; định nghĩa mới đặt thực thể để liên kết tìm ra. Nắm chắc chuyện này rồi, tôi tự tin đặt một biến cấu hình dùng chung — và sập bẫy.

Một lần tôi đo hớ: extern kèm khởi tạo là định nghĩa

Tôi muốn một biến config mà nhiều file cùng đọc. "extern là khai báo", tôi nghĩ, "nên cứ đặt extern int config = 100; trong một header chung là an toàn — vừa khai báo cho mọi file, vừa cho luôn giá trị". Tôi include header đó vào hai file và dịch. Kết quả không như tôi tưởng:

warning: 'config' initialized and declared 'extern'
...
multiple definition of `config'

Lỗi multiple definition — đúng cái lỗi của một định nghĩa bị lặp, không phải của một khai báo. Chìa khóa nằm ở cái = 100: một extern kèm khởi tạo không còn là khai báo, nó trở thành định nghĩa. Chuẩn C quy định vậy, và gcc còn cảnh báo thẳng ("initialized and declared extern") — một cảnh báo mà nếu vội tôi đã lướt qua. Vì mỗi file include header đều nhận được một định nghĩa config, nên có hai định nghĩa cho cùng một tên toàn cục, và liên kết từ chối.

Bài học đo lường: "extern luôn là khai báo" là một quy tắc gần đúng, và cái gần đúng đó che một ngoại lệ quan trọngextern có khởi tạo là định nghĩa. Cách sửa đúng tách bạch hai vai: đặt extern int config; (khai báo thuần) trong header cho mọi file dùng, và đặt int config = 100; (định nghĩa) ở đúng một file .c. Làm vậy rồi, liên kết sạch và chương trình in "100 100". Và một lần nữa, cái cảnh báo của trình dịch chính là công cụ nói thật ngay từ đầu — kỷ luật của sê-ri: đọc cảnh báo, đừng lướt.

Có một chi tiết C thú vị liên quan: int t; int t; (không khởi tạo) trong cùng một file lại dịch được — C gọi đây là định nghĩa tạm (tentative definition) và gộp chúng làm một. Nhưng int t = 1; int t = 2; thì lỗi "redefinition". Quy tắc "một định nghĩa" có ngoại lệ tinh vi cho biến không khởi tạo — thêm một lý do để luôn khai báo extern rõ ràng thay vì dựa vào định nghĩa tạm.

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

Hệ quả đầu tiên là đọc đúng "undefined reference". Đây gần như luôn là lỗi liên kết nghĩa là một định nghĩa bị thiếu — bạn quên biên dịch/thêm file .c chứa định nghĩa, quên -l một thư viện, hoặc gõ sai tên hàm. Nó không phải lỗi cú pháp và không sửa được bằng cách sửa khai báo; phải cung cấp định nghĩa. Phân biệt "lỗi biên dịch" (sai trong một file) với "lỗi liên kết" (thiếu định nghĩa khi ghép) cắt một nửa thời gian mò.

Hệ quả thứ hai là mẫu chuẩn cho biến toàn cục dùng chung: extern int x; trong header (khai báo, mọi file include), int x = ...; trong đúng một .c (định nghĩa). Đừng bao giờ đặt một định nghĩa — kể cả extern có khởi tạo, kể cả int x; trần — trong header được include nhiều nơi, vì mỗi lần include sẽ nhân bản định nghĩa và gây multiple definition (hoặc, với -fcommon kiểu cũ, gộp im lặng thành một bug khó lần). Header chỉ nên chứa khai báo.

Hệ quả thứ ba nối lại toàn cảnh: sự tách bạch khai báo/định nghĩa chính là cái làm cho biên dịch tách rời khả thi. Vì một file chỉ cần khai báo để dịch, các file có thể được dịch độc lập và song song, rồi liên kết mới ghép các định nghĩa lại — đúng mô hình đơn vị dịch ở bài trước. Con số mang theo: khai báo giới thiệu tên và kiểu (đủ để một file dịch), định nghĩa tạo ra thực thể (đủ để liên kết chạy); khai báo được nhiều lần, định nghĩa đúng một lần; thiếu định nghĩa là lỗi liên kết chứ không phải biên dịch; và extern kèm khởi tạo lén trở thành định nghĩa. Hiểu ranh giới này là hiểu vì sao lỗi hiện ra ở giai đoạn nào — và sửa đúng chỗ.

Thử ba mươi giây

Viết một file main.c gọi một hàm int tinh(void); mà bạn khai báo nhưng cố tình không định nghĩa. Chạy gcc -c main.c — nó dịch xong, không lỗi. Rồi chạy gcc main.c -o app (có liên kết): giờ mới thấy "undefined reference to tinh". Chỉ một lần thử là bạn thấy tận mắt ranh giới: khai báo qua được trình dịch, nhưng thiếu định nghĩa thì liên kết chặn. Thử thêm: đặt extern int n = 5; và xem gcc cảnh báo "initialized and declared extern" — cái bẫy biến khai báo thành định nghĩa.