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

Con trỏ hàm

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

  • Khai báo và dùng con trỏ hàm đúng cú pháp
  • Xây bảng điều phối thay cho chuỗi if hoặc switch
  • Dùng typedef để làm con trỏ hàm dễ đọc
  • Biết chi phí của lời gọi gián tiếp

Tới giờ bạn truyền dữ liệu vào hàm. Con trỏ hàm cho phép bạn truyền cả hành vi. Đây là công cụ đứng sau qsort, sau mọi cơ chế xử lý sự kiện, và sau cách C mô phỏng lập trình hướng đối tượng.

#Hàm cũng có địa chỉ

Mã máy của một hàm nằm ở vùng .text trong bộ nhớ, như Bài 12.1 đã vẽ. Vậy nó có địa chỉ, và địa chỉ đó lưu vào biến được.

dia-chi-ham.c
#include <stdio.h>

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

int main(void)
{
    printf("dia chi cua cong : %p\n", (void *)cong);
    printf("dia chi cua main : %p\n", (void *)main);

    printf("cong == &cong : %d\n", cong == &cong);   /* 1 */

    return 0;
}
terminal
./dia-chi-ham
dia chi cua cong : 0x55f3a2401149
dia chi cua main : 0x55f3a2401169
cong == &cong : 1

#Cú pháp khai báo và gọi

int (*p)(int, int);      /* p là con trỏ tới hàm nhận hai int, trả về int */
PhầnNghĩa là
intKiểu trả về của hàm
(*p)p là con trỏ. Ngoặc bắt buộc
(int, int)Danh sách kiểu tham số
con-tro-ham.c
#include <stdio.h>

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

int main(void)
{
    int (*phep)(int, int);

    phep = cong;
    printf("%d\n", phep(7, 3));      /* 10 */

    phep = tru;
    printf("%d\n", phep(7, 3));      /* 4 */

    phep = nhan;
    printf("%d\n", (*phep)(7, 3));   /* 21, cách viết rõ ràng hơn */

    return 0;
}

Truyền hàm vào hàm khác

truyen-ham.c
/* Áp dụng một phép toán lên mọi phần tử của mảng. */
void ap_dung(int *a, size_t n, int (*phep)(int))
{
    if (a == NULL || phep == NULL) return;

    for (size_t i = 0; i < n; ++i)
        a[i] = phep(a[i]);
}

static int binh_phuong(int x) { return x * x; }
static int am(int x)          { return -x; }

int a[5] = { 1, 2, 3, 4, 5 };

ap_dung(a, 5, binh_phuong);      /* 1 4 9 16 25 */
ap_dung(a, 5, am);               /* -1 -4 -9 -16 -25 */

Cùng một vòng lặp, hành vi khác nhau tùy hàm truyền vào. Đây là điểm bắt đầu của Bài 13.6 về hàm gọi lại.

#Bảng điều phối

Bảng điều phối
Một mảng con trỏ hàm, dùng để chọn hàm cần gọi bằng chỉ số thay vì bằng chuỗi if hoặc switch.
Chuỗi if else if
int tinh(char op, int a, int b)
{
    if      (op == '+') return a + b;
    else if (op == '-') return a - b;
    else if (op == '*') return a * b;
    else if (op == '/') return b != 0 ? a / b : 0;
    else if (op == '%') return b != 0 ? a % b : 0;

    return 0;
}

/* Thêm một phép mới phải sửa hàm này. */
Bảng điều phối
typedef int (*Phep)(int, int);

static int cong(int a, int b) { return a + b; }
static int tru(int a, int b)  { return a - b; }
static int nhan(int a, int b) { return a * b; }
static int chia(int a, int b) { return b != 0 ? a / b : 0; }
static int du(int a, int b)   { return b != 0 ? a % b : 0; }

static const struct {
    char ky_hieu;
    Phep ham;
} BANG[] = {
    { '+', cong },
    { '-', tru  },
    { '*', nhan },
    { '/', chia },
    { '%', du   },
};

int tinh(char op, int a, int b, int *ra)
{
    for (size_t i = 0; i < sizeof BANG / sizeof BANG[0]; ++i)
        if (BANG[i].ky_hieu == op) {
            *ra = BANG[i].ham(a, b);
            return 0;
        }

    return -1;
}

/* Thêm một phép mới chỉ là thêm một hàm và một dòng vào BANG. */

Bảng tra trực tiếp bằng chỉ số

bang-chi-so.c
typedef void (*XuLy)(const char *);

static void xu_ly_them(const char *s)  { printf("them: %s\n", s); }
static void xu_ly_xoa(const char *s)   { printf("xoa: %s\n", s); }
static void xu_ly_sua(const char *s)   { printf("sua: %s\n", s); }

enum { LENH_THEM, LENH_XOA, LENH_SUA, SO_LENH };

static const XuLy BANG[SO_LENH] = {
    [LENH_THEM] = xu_ly_them,
    [LENH_XOA]  = xu_ly_xoa,
    [LENH_SUA]  = xu_ly_sua,
};

void chay(int lenh, const char *tham_so)
{
    if (lenh < 0 || lenh >= SO_LENH || BANG[lenh] == NULL) {
        fprintf(stderr, "Lenh khong hop le\n");
        return;
    }

    BANG[lenh](tham_so);
}
Chuỗi if else ifBảng điều phối
Thời gian tìmTuyến tính theo số nhánhHằng số nếu tra bằng chỉ số
Thêm nhánh mớiSửa hàmThêm một dòng dữ liệu
Đọc mãThấy ngay từng nhánh làm gìPhải nhảy tới hàm tương ứng
Trình biên dịch tối ưuCó thể nhúng từng nhánhKhông nhúng được lời gọi gián tiếp
Phù hợp vớiÍt nhánh, lô gic khác nhau nhiềuNhiều nhánh cùng dạng

#typedef làm mã dễ đọc

Viết trần
void ap_dung(int *a, size_t n, int (*phep)(int));

int (*chon(char c))(int, int);

int (*bang[10])(const char *, size_t);

void (*sig(int, void (*)(int)))(int);
Dùng typedef
typedef int (*PhepMotNgoi)(int);
typedef int (*PhepHaiNgoi)(int, int);
typedef int (*XuLyChuoi)(const char *, size_t);
typedef void (*XuLyTinHieu)(int);

void        ap_dung(int *a, size_t n, PhepMotNgoi phep);
PhepHaiNgoi chon(char c);
XuLyChuoi   bang[10];
XuLyTinHieu sig(int, XuLyTinHieu);

Cú pháp gọi qua typedef

typedef.c
typedef int (*PhepHaiNgoi)(int, int);

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

int main(void)
{
    PhepHaiNgoi p = cong;

    printf("%d\n", p(3, 4));         /* 7 */
    printf("%d\n", (*p)(3, 4));      /* 7, tương đương */

    PhepHaiNgoi bang[2] = { cong, NULL };

    if (bang[0] != NULL)
        printf("%d\n", bang[0](5, 6));   /* 11 */

    return 0;
}

#Chi phí của lời gọi gián tiếp

terminal
# Gọi một hàm cộng đơn giản một tỷ lần
./do-goi
goi truc tiep       : 0.281 s
goi qua con tro ham : 1.104 s
ti le               : 3.9 lan
Chi phí thêmGiải thích
Không nhúng đượcTrình biên dịch không biết hàm nào sẽ được gọi nên không chép thân hàm vào
Đọc thêm một lần bộ nhớPhải lấy địa chỉ từ biến con trỏ trước khi nhảy
Dự đoán rẽ nhánh kém hơnBộ xử lý khó đoán đích nhảy nếu con trỏ đổi liên tục
Mất tối ưu xuyên hàmTrình biên dịch không suy luận được gì về thân hàm được gọi

Khi nào con trỏ hàm là lựa chọn đúng

  • Cần chọn hành vi lúc chạy. Người dùng chọn thuật toán sắp xếp, hoặc chọn bộ lọc.
  • Viết hàm tổng quát. qsort không biết trước kiểu dữ liệu, nên nó phải hỏi bạn cách so sánh.
  • Đăng ký xử lý sự kiện. Thư viện gọi lại mã của bạn khi có chuyện xảy ra.
  • Nạp thư viện động. Hàm được tìm lúc chạy nên chỉ có địa chỉ.
  • Mô phỏng hướng đối tượng. Một struct chứa các con trỏ hàm chính là bảng phương thức ảo. Chương 30 sẽ nói kỹ.
huong-doi-tuong.c
/* Mô phỏng giao diện: struct chứa dữ liệu và các thao tác. */
typedef struct HinhVT {
    double (*dien_tich)(const void *);
    double (*chu_vi)(const void *);
} HinhVT;

typedef struct {
    const HinhVT *vt;
    double r;
} HinhTron;

static double tron_dien_tich(const void *p)
{
    const HinhTron *h = p;

    return 3.14159265358979 * h->r * h->r;
}

static double tron_chu_vi(const void *p)
{
    const HinhTron *h = p;

    return 2 * 3.14159265358979 * h->r;
}

static const HinhVT TRON_VT = { tron_dien_tich, tron_chu_vi };

HinhTron t = { &TRON_VT, 2.0 };

printf("%.2f\n", t.vt->dien_tich(&t));      /* 12.57 */

Đây chính xác là cách C++ cài đặt hàm ảo, chỉ khác là trình biên dịch làm hộ. Chương 15 sẽ dạy struct đầy đủ, và Chương 30 sẽ quay lại mẫu này.

Tự làm thử

  1. In địa chỉ của ba hàm khác nhau, xác nhận f&f bằng nhau.
  2. Khai báo con trỏ hàm, gán lần lượt ba hàm và gọi qua nó, in kết quả từng lần.
  3. Viết hàm ap_dung nhận con trỏ hàm một ngôi, dùng nó với ba phép toán khác nhau.
  4. Cài máy tính đơn giản bằng bảng điều phối, so sánh với bản dùng chuỗi if về số dòng khi thêm phép thứ sáu.
  5. Cài bảng tra bằng chỉ số với khởi tạo có chỉ định, cố tình bỏ trống một ô và kiểm tra cơ chế phát hiện NULL.
  6. Đo thời gian gọi trực tiếp và gọi qua con trỏ hàm một tỷ lần, thử với hàm rỗng và hàm làm việc thật.
  7. Cài mẫu bảng phương thức ảo cho hai loại hình học, gọi cùng một hàm trên cả hai.

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

  • Hàm có địa chỉ, và tên hàm tự phân rã thành con trỏ. f &f tương đương.
  • Khai báo là int (*p)(int, int). Thiếu ngoặc thì thành hàm trả về con trỏ, hoàn toàn khác.
  • Bảng điều phối tách dữ liệu khỏi lô gic, nên thêm hành vi mới chỉ là thêm một dòng dữ liệu.
  • Luôn dùng typedef cho con trỏ hàm, và luôn kiểm tra khác NULL trước khi gọi.
  • Lời gọi gián tiếp chậm hơn vì không nhúng được, nhưng chi phí đó không đáng kể với hàm làm việc thật.