Code đã biên dịch nằm trong bộ nhớ giống như dữ liệu, nên hàm cũng có địa chỉ. Con trỏ hàm (function pointer) là một biến lưu địa chỉ đó, và khi đã lưu được một hàm, bạn có thể truyền nó làm đối số, giữ cả một bảng các hàm, hoặc chọn hàm nào để chạy lúc chương trình đang thực thi.
Đó là khả năng đứng sau việc sắp xếp theo thứ tự tùy chỉnh, trình xử lý sự kiện, giao diện plugin và máy trạng thái. Đây cũng là phần cú pháp C dễ khiến người đọc phải dừng lại nheo mắt nhất, nên ta bắt đầu từ đó.
Cú pháp khai báo
Lấy một hàm bình thường:
int add(int a, int b);
Để khai báo một con trỏ chứa được địa chỉ của nó, thay tên hàm bằng (*name):
int (*op)(int, int);
Đọc từ trong ra: op là một con trỏ, tới một hàm, nhận (int, int), trả về int.
Cặp ngoặc không phải để trang trí. Thiếu chúng, * sẽ gắn vào kiểu trả về:
int (*f)(int); // pointer to a function taking int, returning int
int *g(int); // FUNCTION taking int, returning int* - completely different
Đây là ví dụ chạy trọn vẹn:
Cả hai cách gọi đều chạy được vì tên hàm tự động chuyển thành con trỏ. Hãy viết op(10, 3); cách viết (*op)(10, 3) là thói quen còn sót lại.
Một lưu ý về lệnh printf cuối: %p cần một con trỏ đối tượng, còn con trỏ hàm về mặt hình thức thuộc một nhóm riêng, nên in nó ra không hoàn toàn khả chuyển. Phép ép kiểu giúp trình biên dịch im lặng trên những nền tảng mà việc này chạy được; hiếm khi bạn cần in một con trỏ hàm.
typedef giúp code dễ đọc
Cú pháp khai báo nhanh chóng trở nên rối mắt, và typedef là cách chữa tiêu chuẩn:
So sánh int apply(BinaryOp op, int x, int y) với dạng gốc int apply(int (*op)(int, int), int x, int y). Nghĩa như nhau, nhưng chỉ một trong hai đọc được ngay. Xem trang typedef để biết các quy tắc chung.
Callback: để bên gọi tự quyết định hành vi
Callback là một hàm bạn đưa cho hàm khác để nó gọi lại bạn vào đúng thời điểm. Nhờ đó một đoạn code lo phần khung của công việc, còn bên gọi cung cấp phần quyết định.
Không có con trỏ hàm, bạn sẽ phải viết count_even, count_positive và count_big, ba bản sao của cùng một vòng lặp. Có con trỏ hàm, vòng lặp chỉ viết một lần và phép kiểm tra trở thành tham số.
Lợi ích thật sự: qsort
Hàm qsort của thư viện chuẩn là ví dụ con trỏ hàm mà bạn sẽ thật sự dùng. Nó sắp xếp mảng thuộc bất kỳ kiểu nào, và làm được vậy là nhờ không cần biết gì về các phần tử ngoài kích thước của chúng và một hàm so sánh do bạn cung cấp.
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
Hàm so sánh nhận hai const void *, tức địa chỉ của hai phần tử, và trả về số âm nếu phần tử đầu đứng trước phần tử sau, 0 nếu bằng nhau, số dương nếu đứng sau.
Có hai thói quen nên rút ra từ hàm so sánh này. Đừng bao giờ viết return x - y;: trông thì gọn nhưng sẽ tràn số với giá trị lớn hoặc âm, cho ra dấu sai và mảng bị sắp xếp sai. Ép kiểu trước khi dereference, không phải sau: *(const int *)a, vì *a trên một void * không có gì để đọc.
Cùng cơ chế đó sắp xếp struct theo bất kỳ trường nào bạn muốn:
Thành ngữ (q->score > p->score) - (q->score < p->score) trả về đúng -1, 0 hoặc 1 mà không có phép trừ nào và không tràn số. Còn strcmp vốn đã có quy ước trả về phù hợp, nên hàm so sánh chuỗi chỉ cần một dòng.
Bảng con trỏ hàm
Một mảng con trỏ hàm biến chuỗi if/else thành một phép tra cứu. Đây là cách các trình thông dịch điều phối opcode và cách các hệ thống menu chạy lệnh.
Giờ thêm một phép toán chỉ là thêm một hàng, không phải sửa switch. Kết hợp với struct là bạn có dạng plugin chuẩn của C: một struct chứa các con trỏ hàm chính là "interface" trong ngôn ngữ này.
Những chỗ dễ vấp
Chữ ký phải khớp chính xác. Kiểu của con trỏ hàm bao gồm kiểu các tham số và kiểu trả về. Gán một hàm không khớp là vi phạm ràng buộc, và gọi hàm qua một con trỏ sai kiểu là undefined behavior kể cả khi nó "chạy được". Đừng ép kiểu chữ ký của hàm so sánh để tắt cảnh báo: hãy sửa hàm so sánh.
Con trỏ hàm null vẫn là con trỏ null. Khởi tạo bằng NULL và kiểm tra trước khi gọi, y như với con trỏ dữ liệu:
if (handler != NULL) {
handler(event);
}
Xem con trỏ null để hiểu vì sao phép kiểm tra đó đáng giá.
Con trỏ void làm mất an toàn kiểu, và bạn chính là hệ thống kiểu. Bên trong hàm so sánh của qsort, không gì ngăn bạn ép sang sai kiểu. Nếu bạn sắp xếp một mảng double bằng compare_ints, code vẫn biên dịch trơn tru và cho ra kết quả vô nghĩa.
Cú pháp khai báo có thể lồng nhau. Một hàm trả về con trỏ hàm được viết là int (*get_op(char c))(int, int);. Khi thấy mình phải viết như vậy, hãy dùng typedef:
typedef int (*BinaryOp)(int, int);
BinaryOp get_op(char c); // the same thing, readable
Câu hỏi thường gặp
Khai báo con trỏ hàm trong C như thế nào?
Viết chữ ký của hàm, đặt (*name) vào chỗ tên hàm: int (*op)(int, int); khai báo op là con trỏ tới một hàm nhận hai int và trả về int. Cặp ngoặc quanh *op là bắt buộc: thiếu chúng, bạn lại khai báo một hàm trả về con trỏ.
Gọi hàm thông qua con trỏ như thế nào?
Dùng op(3, 4) hoặc (*op)(3, 4): cả hai đều hợp lệ và làm cùng một việc, vì tên hàm tự động chuyển thành con trỏ. Code C hiện đại dùng dạng gọn op(3, 4).
Callback trong C là gì?
Là một hàm bạn đưa cho hàm khác để nó gọi lại bạn. qsort là ví dụ kinh điển: bạn truyền vào một hàm so sánh, và qsort gọi hàm đó mỗi khi cần biết phần tử nào đứng trước. Nhờ vậy một hàm sắp xếp duy nhất dùng được cho mọi kiểu dữ liệu.
Có cần dấu & khi lấy địa chỉ của hàm không?
Không. Tên hàm vốn đã chuyển thành con trỏ tới hàm đó, nên op = add; và op = &add; là tương đương. Phần lớn code C bỏ dấu &.