Menu

Nguyên mẫu hàm trong C: khai báo và định nghĩa

Vì sao C cần thấy hình dạng của hàm trước khi bạn gọi nó: cách viết nguyên mẫu hàm, sửa lỗi implicit declaration, đặt nguyên mẫu trong tệp header, và khác biệt thật sự giữa f(void) và f().

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.

Trình biên dịch đọc từ trên xuống dưới

Trình biên dịch C xử lý một tệp nguồn trong một lượt, từ dòng đầu đến dòng cuối. Khi gặp một lời gọi hàm, nó cần biết sẵn ba điều: hàm trả về gì, nhận bao nhiêu đối số và kiểu của chúng là gì. Thiếu những điều đó, nó không thể sinh code đúng hay kiểm tra lời gọi.

Vì vậy đoạn code này có vấn đề:

#include <stdio.h>

int main(void) {
    printf("%d\n", add(2, 3));   /* add has not been seen yet */
    return 0;
}

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

Cách sửa là một nguyên mẫu hàm (prototype): chữ ký của hàm, viết trước lời gọi, với dấu chấm phẩy ở chỗ lẽ ra là thân hàm.

Giờ main có thể đứng đầu, và phần lớn tệp C được tổ chức như vậy: các prototype ở trên cùng cho biết tệp cung cấp những gì, main đọc như một dàn ý, còn chi tiết nằm phía dưới.

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

Hai từ dễ bị nhập nhằng và nên tách bạch:

  • Khai báo (declaration) cho biết một hàm tồn tại và kiểu của nó. Nó kết thúc bằng dấu chấm phẩy và không có thân hàm. Bạn viết bao nhiêu lần cũng được.
  • Định nghĩa (definition) cung cấp thân hàm. Nó phải xuất hiện đúng một lần trong cả chương trình: hai định nghĩa cho cùng một hàm là lỗi của trình liên kết ("multiple definition of").
int add(int a, int b);                        /* declaration (prototype) */
int add(int a, int b) { return a + b; }       /* definition - also a declaration */

Tên tham số là tùy chọn trong prototype; trình biên dịch chỉ quan tâm tới kiểu:

int add(int, int);              /* legal, and equivalent */
int add(int a, int b);          /* better: the names document the order */

Hãy cứ ghi tên. void drawRect(int, int, int, int); không nói gì với người đọc, còn void drawRect(int x, int y, int width, int height); nói lên tất cả.

Prototype mang lại gì cho bạn

Không chỉ là được sắp xếp lại các định nghĩa: prototype chính là thứ khiến trình biên dịch kiểm tra các lời gọi của bạn.

Khi có prototype trong phạm vi, 3 và 2 được chuyển sang double trước lời gọi. Không có nó, chúng sẽ được truyền dưới dạng int và scale lại đọc chúng như double: kết quả là giá trị rác, và các phương ngữ C cũ không đưa ra cảnh báo nào.

Prototype cũng bắt được lỗi sai số lượng đối số và kiểu không tương thích ngay lúc biên dịch:

scale(3.0);              /* error: too few arguments */
scale(3.0, "two");       /* error: passing char * where double is expected */

Đó là những lỗi bạn mong muốn: rõ ràng, xuất hiện lúc biên dịch, chỉ thẳng vào dòng có vấn đề.

Lỗi implicit declaration

Gọi một hàm mà trình biên dịch chưa từng nghe tới, bạn sẽ nhận một trong những thông báo phổ biến nhất của C:

warning: implicit declaration of function 'add' [-Wimplicit-function-declaration]

Ở C89, trình biên dịch sẽ đoán: giả định hàm trả về int và chấp nhận bất kỳ đối số nào bạn truyền vào. Lần đoán đó sai nhiều hơn đúng, và khi hàm thật trả về double hay một con trỏ thì kết quả là vô nghĩa. C99 đã loại bỏ khai báo ngầm khỏi ngôn ngữ, nên từ C99 trở đi đây là một lỗi: GCC và clang ở các phiên bản gần đây từ chối nó theo mặc định.

Hai nguyên nhân, hai cách sửa:

Hàm của bạn chưa được khai báo. Thêm prototype phía trên lời gọi, hoặc chuyển định nghĩa lên trước.

Hàm thư viện mà bạn quên include header. Prototype của thư viện nằm trong header của nó, nên bạn cần #include:

printf, scanf, fopen        ->  #include <stdio.h>
malloc, free, exit, atoi    ->  #include <stdlib.h>
strlen, strcpy, strcmp      ->  #include <string.h>
sqrt, pow, sin, fabs        ->  #include <math.h>
isdigit, toupper            ->  #include <ctype.h>
bool, true, false           ->  #include <stdbool.h>

Một thông báo liên quan, conflicting types for 'add', nghĩa là prototype và định nghĩa không khớp nhau: khác kiểu một tham số, hoặc khác kiểu trả về. Sửa bên nào sai; hai bên phải khớp hoàn toàn.

Đệ quy tương hỗ cần prototype

Có lúc sắp xếp lại không giải quyết được: hai hàm gọi lẫn nhau thì không thể cả hai cùng đứng trước.

Một prototype là đủ phá vòng luẩn quẩn đó. Đây cũng là cơ chế mà một hàm đệ quy thông thường dựa vào: tên của hàm đã nằm trong phạm vi ngay bên trong thân hàm, nên đệ quy trực tiếp không cần prototype, còn đệ quy tương hỗ thì cần.

Prototype trong tệp header

Khi chương trình trải ra nhiều tệp .c, prototype được chuyển vào một header để mọi tệp đều thấy cùng các khai báo từ một chỗ.

mathutils.h, phần giao diện:

#ifndef MATHUTILS_H
#define MATHUTILS_H

int add(int a, int b);
int multiply(int a, int b);
double average(const int values[], int count);

#endif

mathutils.c, phần cài đặt:

#include "mathutils.h"

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

int multiply(int a, int b) {
    return a * b;
}

double average(const int values[], int count) {
    if (count == 0) return 0.0;
    int total = 0;
    for (int i = 0; i < count; i++) total += values[i];
    return (double) total / count;
}

main.c, nơi sử dụng:

#include <stdio.h>
#include "mathutils.h"

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

Sau đó:

gcc main.c mathutils.c -o program

Có hai thói quen nên áp dụng ở đây. Lớp bọc #ifndef / #define / #endif là include guard: nó ngăn các khai báo bị xử lý hai lần nếu header được include theo hai đường khác nhau. Và mathutils.c include chính header của nó: trông có vẻ thừa nhưng không phải, vì nhờ vậy trình biên dịch đối chiếu từng định nghĩa với khai báo mà các tệp khác sẽ dùng. Nếu hai bên lệch nhau, bạn biết ngay chứ không phải đợi tới lúc liên kết. Trang về tệp header trình bày phần còn lại.

Dấu ngoặc nhọn (<stdio.h>) tìm trong các đường dẫn include của hệ thống; dấu nháy kép ("mathutils.h") tìm trong thư mục dự án trước. Dùng dấu nháy kép cho header của bạn.

f(void) và f()

Điểm này thật sự gây bất ngờ, và đó là lý do mọi ví dụ trong tài liệu này đều viết int main(void).

void ping(void);      /* takes NO arguments - calls with arguments are rejected */
void pong();          /* says NOTHING about the parameters */

void ping(void); là một prototype: nó khai báo rằng hàm không nhận tham số nào, nên ping(1, 2, 3) là lỗi biên dịch.

void pong(); là cách khai báo kiểu cũ thừa hưởng từ C trước khi có chuẩn. Nó khai báo kiểu trả về và hoàn toàn không nói gì về tham số, nên trình biên dịch không thể kiểm tra lời gọi: pong(1, 2, 3) biên dịch êm ru và gây ra hành vi không xác định.

int main(void) {      /* right: main takes no arguments */
int main() {          /* legal, but argument checking is off */

Luôn viết (void) cho hàm không có tham số. C23 đổi () thành cùng nghĩa với (void), cuối cùng cũng gỡ được cái bẫy này, nhưng rất nhiều code và trình biên dịch chưa theo kịp, còn (void) thì đúng ở mọi chuẩn C.

Sự phân biệt này cũng áp dụng cho định nghĩa. void ping(void) { } là định nghĩa kiểu prototype; void ping() { } thì không, và không bật kiểm tra cho các lời gọi đứng trước nó.

Lỗi thường gặp

  • Đặt dấu chấm phẩy ở định nghĩa. int add(int a, int b); { return a + b; } khai báo add rồi để lại một khối lệnh lạc lõng. Thông báo lỗi thì khó hiểu, còn nguyên nhân chỉ là một ký tự.
  • Thiếu dấu chấm phẩy ở prototype. Trình biên dịch đọc lấn sang phần phía sau và báo một lỗi khó hiểu ở vài dòng bên dưới.
  • Prototype và định nghĩa không khớp. conflicting types for .... Hãy làm cho chúng giống hệt nhau, hoặc tốt hơn, include header trong tệp cài đặt để việc kiểm tra diễn ra tự động.
  • Khai báo hàm bên trong một hàm khác. Hợp lệ (int add(int, int); bên trong main), nhưng khi đó khai báo chỉ có phạm vi trong hàm đó. Hãy đặt prototype ở phạm vi tệp.
  • Định nghĩa hàm trong header. Include header đó từ hai tệp .c là trình liên kết báo định nghĩa trùng lặp. Header chứa khai báo; định nghĩa nằm trong tệp .c.
  • Trông cậy vào () để kiểm tra. Nó không kiểm tra gì cả. Hãy viết (void).

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

Nguyên mẫu hàm trong C là gì?

Là phần khai báo chữ ký của hàm, gồm kiểu trả về, tên và kiểu các tham số, kết thúc bằng dấu chấm phẩy thay vì thân hàm: int add(int a, int b);. Nó cho trình biên dịch biết hàm được gọi như thế nào để kiểm tra các lời gọi, mà không nói hàm làm gì.

Khai báo và định nghĩa trong C khác nhau thế nào?

Khai báo giới thiệu tên và kiểu (int add(int, int);) và có thể xuất hiện nhiều lần. Định nghĩa cung cấp thân hàm (int add(int a, int b) { return a + b; }) và phải xuất hiện đúng một lần trong cả chương trình. Mọi định nghĩa cũng đồng thời là một khai báo.

Sửa lỗi "implicit declaration of function" trong C như thế nào?

Thêm prototype trước lời gọi. Với hàm của bạn, đặt returnType name(paramTypes); ở gần đầu tệp hoặc trong một header; với hàm thư viện, include đúng header: #include <stdio.h> cho printf, <stdlib.h> cho malloc, <string.h> cho strlen, <math.h> cho sqrt.

f(void) và f() trong C khác nhau thế nào?

void f(void); khai báo một hàm không nhận đối số nào, và trình biên dịch từ chối mọi lời gọi có truyền đối số. void f(); là cách khai báo kiểu cũ, không nói gì về tham số, nên việc kiểm tra đối số bị tắt. Luôn viết (void); C23 cuối cùng đã cho hai cách viết cùng nghĩa, nhưng code và trình biên dịch cũ vẫn xử lý khác nhau.

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