Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 8.214 phút đọc

Khai báo hàm

Sau bài này bạn sẽ làm được

  • Viết nguyên mẫu hàm đúng cú pháp
  • Giải thích vì sao thiếu nguyên mẫu là lỗi từ C99 trở đi
  • Phân biệt khai báo với định nghĩa
  • Hiểu vì sao int f() khác int f(void) trước C23

Trình biên dịch C đọc file đúng một lượt, từ trên xuống dưới, và không bao giờ quay lại. Nguyên mẫu hàm tồn tại chỉ vì lý do đó. Hiểu được câu này thì mọi quy tắc còn lại trong bài đều tự suy ra được.

#Cú pháp nguyên mẫu

Nguyên mẫu hàm
Một dòng khai báo cho biết tên hàm, kiểu trả về và danh sách kiểu của từng tham số, kết thúc bằng dấu chấm phẩy. Nó không có thân hàm.
int  cong(int a, int b);        /* dạng đầy đủ, có tên tham số */
int  cong(int, int);            /* tên tham số được phép lược bỏ */
void chao(void);                /* không nhận gì, không trả về gì */
double dien_tich(double r);

Hai dòng đầu tương đương hoàn toàn với trình biên dịch. Tên tham số trong nguyên mẫu chỉ có tác dụng với người đọc, nhưng đó là một tác dụng đáng giá: void ve(int, int, int, int) không nói được gì, còn void ve(int x, int y, int rong, int cao) thì nói đủ.

Thành phầnVí dụBắt buộc
Kiểu trả vềintCó. Bỏ trống là lỗi từ C99
Tên hàmcong
Danh sách kiểu tham số(int, int)Có. Không nhận gì thì ghi (void)
Tên tham sốa, bKhông, nhưng nên có
Dấu chấm phẩy;Có. Thiếu là biến khai báo thành định nghĩa

#Vì sao trình biên dịch cần nó

Xem chương trình sau. Nó trông hoàn toàn hợp lý với mắt người, vì add có được định nghĩa ở phía dưới.

thieu-prototype.c
#include <stdio.h>

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

int add(int a, int b)
{
    return a + b;
}
terminal
gcc -std=c17 -Wall thieu-prototype.c -o t
thieu-prototype.c:5:20: error: implicit declaration of function 'add' [-Wimplicit-function-declaration]

Tới dòng 5, trình biên dịch chưa từng nhìn thấy chữ add. Nó không có cách nào biết add nhận mấy tham số, kiểu gì, trả về gì. Mà nó cần biết cả ba thứ đó ngay lập tức để làm ba việc:

  • Kiểm tra số lượng và kiểu đối số. Gọi add(3) hay add(1, 2, 3) phải bị bắt ngay tại chỗ.
  • Sinh mã ép kiểu cần thiết. Nếu bạn gọi add(3.7, 4.2) thì phải sinh lệnh chuyển số thực thành số nguyên trước khi truyền.
  • Biết đọc kết quả trả về ở đâu và rộng bao nhiêu. Hàm trả về int và hàm trả về double đặt kết quả ở hai thanh ghi khác nhau hoàn toàn.

Khai báo khác định nghĩa ở chỗ nào

Khai báoĐịnh nghĩa
Có thân hàmKhông
Kết thúc bằngDấu chấm phẩyDấu ngoặc nhọn đóng
Được lặp lạiBao nhiêu lần cũng đượcĐúng một lần trong cả chương trình
Sinh ra mã máyKhông
Đặt ở đâuThường trong file .hTrong file .c

#Ba cách đặt nguyên mẫu

Cách 1: nguyên mẫu ở đầu file, chuẩn mực nhất

cach1.c
#include <stdio.h>

/* Toàn bộ giao diện của file nằm gọn ở đây, đọc một cái là nắm hết. */
int  cong(int a, int b);
int  tru(int a, int b);
void in_ket_qua(const char *nhan, int gia_tri);

int main(void)
{
    in_ket_qua("tong", cong(7, 3));
    in_ket_qua("hieu", tru(7, 3));

    return 0;
}

int cong(int a, int b) { return a + b; }
int tru(int a, int b)  { return a - b; }

void in_ket_qua(const char *nhan, int gia_tri)
{
    printf("%s = %d\n", nhan, gia_tri);
}

Ưu điểm lớn nhất là thứ tự định nghĩa bên dưới không còn quan trọng. Hàm nào gọi hàm nào cũng được, kể cả gọi vòng: a gọi b b gọi a. Không có nguyên mẫu thì trường hợp gọi vòng là không viết được.

Cách 2: định nghĩa hàm trước khi dùng

#include <stdio.h>

int cong(int a, int b)      /* định nghĩa trước, không cần nguyên mẫu riêng */
{
    return a + b;
}

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

Cách này hợp với file rất ngắn. Nhược điểm là main bị đẩy xuống cuối, người đọc phải cuộn qua hết mọi chi tiết trước khi thấy được ý đồ tổng thể, và gọi vòng thì bó tay.

Cách 3: nguyên mẫu trong file tiêu đề

mathlib.h
#ifndef MATHLIB_H
#define MATHLIB_H

int  la_nguyen_to(long n);
long uoc_chung_lon_nhat(long a, long b);
long boi_chung_nho_nhat(long a, long b);

#endif
mathlib.c
#include "mathlib.h"

int la_nguyen_to(long n)
{
    if (n < 2) return 0;

    for (long i = 2; i * i <= n; ++i)
        if (n % i == 0) return 0;

    return 1;
}

/* ... các hàm còn lại ... */
main.c
#include <stdio.h>
#include "mathlib.h"

int main(void)
{
    printf("%d\n", la_nguyen_to(97));
    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra main.c mathlib.c -o app
./app
1

Đây là cách của mọi dự án thật. File .h là bản hợp đồng, file .c là phần thực hiện hợp đồng đó. Cả hai bên cùng #include file tiêu đề nên nếu bạn sửa nguyên mẫu mà quên sửa định nghĩa, trình biên dịch báo lỗi ngay chứ không đợi tới lúc chạy. Chương 20 nói kỹ về mô hình nhiều file.

#int f() khác int f(void)

Đây là một trong những cái bẫy lâu đời nhất của C và vẫn còn bẫy người học tới tận hôm nay.

Ngoặc rỗng: chưa nói gì về tham số
int f();       /* nghĩa là: tôi chưa cho bạn biết f nhận gì */

int main(void)
{
    f(1, 2, 3);    /* trình biên dịch không có căn cứ để báo lỗi */
    f("xin chao"); /* cũng qua luôn */
    return 0;
}
void: khẳng định không có tham số
int f(void);   /* nghĩa là: f không nhận tham số nào cả */

int main(void)
{
    f(1, 2, 3);    /* error: too many arguments to function 'f' */
    return 0;
}

Vì lý do này, hãy tập thói quen viết int main(void) chứ không phải int main(). Bạn sẽ thấy suốt giáo trình này dùng dạng có void.

#Bốn cờ cảnh báo nên bật

CờBắt lỗi gì
-Wmissing-prototypesHàm không phải static mà không có nguyên mẫu khai báo trước
-Wstrict-prototypesKhai báo kiểu cũ dạng ngoặc rỗng như int f()
-Wmissing-declarationsHàm được định nghĩa nhưng chưa từng khai báo ở file tiêu đề nào
-Wold-style-definitionĐịnh nghĩa hàm theo cú pháp K&R từ thập niên 1970
terminal
# Dòng biên dịch nên dùng từ giờ trở đi
gcc -std=c17 -Wall -Wextra -Wmissing-prototypes -Wstrict-prototypes bai.c -o bai

Tự làm thử

  1. Viết chương trình gọi một hàm trước khi khai báo nó, đọc thông báo lỗi thật của trình biên dịch trên máy bạn và chép lại nguyên văn.
  2. Khai báo int f(); rồi định nghĩa int f(int a). Thử gọi f(), f(1)f(1, 2). Cái nào biên dịch được, cái nào chạy sai.
  3. Tách một chương trình sẵn có thành ba file: tienich.h, tienich.cmain.c. Biên dịch bằng một dòng lệnh duy nhất.
  4. Cố tình gõ sai tên hàm ở phần định nghĩa để tạo ra lỗi undefined reference, rồi tự tìm ra nó chỉ bằng cách đọc thông báo.
  5. Viết hai hàm gọi lẫn nhau: la_chan(n) gọi la_le(n - 1) và ngược lại. Giải thích vì sao bắt buộc phải có nguyên mẫu.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Trình biên dịch đọc file một lượt từ trên xuống, nên phải biết hàm trước khi thấy lời gọi.
  • Nguyên mẫu cho biết tên, kiểu trả về và kiểu từng tham số, kết thúc bằng dấu chấm phẩy và không có thân.
  • Ba cách đặt: đầu file, định nghĩa trước khi dùng, hoặc trong file tiêu đề. Dự án thật luôn dùng cách thứ ba.
  • int f() không có nghĩa là không tham số, mà là chưa nói gì về tham số. Luôn viết (void).
  • Bật -Wmissing-prototypes-Wstrict-prototypes để trình biên dịch bắt lỗi thay bạn.