Pointer và Function

Con trỏ (pointer) trang bị một khả năng quan trọng cho hàm (function). Nó cho phép dữ liệu (data) được truyền (pass) vào và sửa đổi (modify) bằng hàm. Dữ liệu phức tạp cũng có thể được truyền và trả về từ hàm dưới dạng một con trỏ tới cấu trúc (a pointer to a structure). Khả nẳng chứa địa của của hàm, con trỏ hàm, tạo ra tính linh động để điều khiển luồng thực thi của chương trình. Trong bài viết này, chúng ta sẽ khám phá sức mạnh của con trỏ khi sử dụng với hàm và học cách sử nó để giải quyết nhiều vấn đề thực tế.

Muốn hiểu sâu về hàm và sử dụng chúng với con trỏ, chúng ta cần biết thêm về program stack. Program stack được sử dụng bởi hầu hết ngôn ngữ cấu trúc hiện đại (modern block-structured language), như C, để hỗ trợ việc thực thi (execution) của hàm. Khi một hàm được gọi, stack frame của hàm được tạo và được đẩy (push) lên program stack. Khi hàm trả về (return), stack frame của hàm bị bốc (pop) ra khỏi program stack.

Khi làm việc với hàm, có hai công dụng mà con trỏ trở nên hữu ích. Công dụng thứ nhất là khi ta truyền một con trỏ vào hàm, nó cho phép hàm modify data được tham chiếu bằng con trỏ và giúp truyền từng khối thông tin (blocks of information) hiệu quả hơn. Công dụng thứ hai là việc khai báo con trỏ hàm (a pointer to a function). Về bản chất, ký hiệu hàm là ký hiệu con trỏ. Tên của hàm tính toán ra địa chỉ của hàm và các tham số (parameter) của hàm được truyền cho hàm. Như chúng ta sẽ thấy, con trỏ hàm (function pointer) cung cấp khả năng bổ sung để kiểm soát luồng thực thi của chương trình.

Trong bài viết này, chúng ta sẽ xây dựng nền tảng để hiểu và làm việc với hàm (function) và con trỏ (pointer). Do tính phổ biến của hàm và con trỏ, nền tảng này sẽ có ích cho bạn.

Program Stack and Heap

Program stack và heap là hai thành phần quan trọng của C khi chạy hệ thống đang chạy. Ở phần này chúng ta sẽ tìm hiểu về cấu trúc và cách sử dụng của program stack và heap. Chúng ta cũng sẽ tìm hiểu về cấu trúc của stack frame, nơi lưu giữ các biến cục bộ (local variable).

Biến cục bộ (local variable) cũng được gọi là biến tự động (automatic variable). Chúng luôn luôn được cấp phát vào stack frame.

Program stack nắm giữ những stack frames, đôi khi được gọi là activation records hoặc activation frames. Stack frame nắm giữ các parameters và local variables của một hàm. Phần heap quản lý bộ nhớ động (dynamic memory)

Hãy xem Figure 3-1 mô tả cách tổ chức stack và heap về mặt khái niệm. Mô tả này dựa trên đoạn code dưới đây:

void function2() {
    Object *var1 = ...;
    int var2;
    printf("Program Stack Example");
}

void function1() {
    Object *var3 = ...;
    function2();
}

int main() {
    int var4;
    function1();
}

Theo những hàm được gọi, stack frame của chúng được đẩy (push) vào stack và stack sẽ phát triển “lên” (upward). Khi một hàm kết thúc (terminate), stack frame của nó được bốc (pop) ra khỏi program stack. Bộ nhớ sử dụng cho stack frame này không được xóa và có thể bị ghi đè ngay sau đó bởi những stack frame khác được push vào program stack.

Figure 3-1. Stack and heap

Khi bộ nhớ được cấp phát động, nó sẽ nằm ở vùng heap và có xu hướng phát triển “xuống” (downward). Heap sẽ bị phân mảng (fragment) do bộ nhớ được cấp phát (allocate) và giải phóng (deallocate) nhiều lần. Mặc dù heap thường phát triển xuống (đây là chiều phổ biến), nhưng bộ nhớ vẫn có thể cấp phát ở bất cứ đâu trong heap.

Organization of a Stack Frame

Một stack frame bao gồm những phần tử sau:

  • Địa chỉ trả về (return address): là địa chỉ trong chương trình, vị trí của hàm được trả về sau khi hoàn thành.
  • Nơi lưu trữ dữ liệu cục bộ (storage for local data): bộ nhớ được cấp phát cho các biến cục bộ.
  • Nơi lưu trữ tham số (storage for parameters): bộ nhớ được cấp phát cho các tham số của hàm.
  • Con trỏ stack và base (stack and base pointers): những con trỏ được dùng khi chạy hệ thống (runtime system) để quản lý stack.

Lập trình viên C thường không quan tâm về con trỏ stack và base. Hai con trỏ này dùng để quản lý stack frame. Tuy nhiên, việc hiểu được chúng là gì và cách sử dụng sẽ giúp hiểu sâu hơn về program stack.

Con trỏ stack thường trỏ vào phần top của stack. Con trỏ stack base (con trỏ frame) thường tượng trưng và trỏ vào một địa chỉ trong stack frame, như là địa chỉ trả về (return address). Con trỏ này hỗ trợ truy cập các phần tử của stack frame. Hai con trỏ này đều không phải là con trỏ C. Chúng được dùng bởi runtime system để quản lý program stack. Nếu runtime system được viết bằng C, thì có thể những con trỏ này sẽ là con trỏ C thực sự.

Hãy xem xét quá trình tạo một stack frame cho hàm sau. Hàm này truyền vào một mảng số nguyên (array of integers), và một số nguyên là kích thước của mảng. Ba lệnh gọi hàm printf dùng để hiển thị địa chỉ các tham số và địa chỉ biến:

float average(int *arr, int size) {
    int sum;
    printf("arr: %p\n",&arr);
    printf("size: %p\n",&size);
    printf("sum: %p\n",&sum);

    for(int i=0; i<size; i++) {
        sum += arr[i];
    }

    return (sum * 1.0f) / size;
}

Khi thực thi, bạn nhận được kết quả tương tự như sau:

arr: 0x500
size: 0x504
sum: 0x480

Khoảng cách giữa parameter (arr, size) và local variable (sum) là do những phần tử khác của stack frame. Những phần tử này được dùng bởi runtime system để quản lý stack.

Khi stack frame được tạo, parameter được đẩy (push) vào theo thứ tự ngược lại với thứ tự khai báo của chúng, và nằm tiếp theo local variable. Điều này được mô tả ở Figure 3-2. Ở trường hợp này, size được đẩy vào sau arr. Thông thường, địa chỉ trả về (return address) cho hàm gọi (function call) được đẩy vào tiếp theo, và theo sau là các local variable.

Về mặt khái niệm, stack trong ví dụ này tăng “lên” (grow “up”). Tuy nhiên, những parameter, local variable và những stack frame mới khác được thêm vào tại địa chỉ thấp của bộ nhớ (lower memory address). Trong thực tế, hướng tăng của stack là tùy theo cách implement.

Stack frame example
Figure 3-2. Stack frame example

Biến i được dùng cho lệnh for không nằm trong stack frame này. Ngôn ngữ C xem nó như một hàm “mini” và sẽ đẩy (push) và kéo (pop) nó theo cách phù hợp. Ở trường hợp này khối lệnh được đẩy vào program stack bên trên stack frame average khi xử lý tới nó và sẽ được kéo ra (pop off) khi nó kết thúc.

Mặc dù địa chỉ đúng có thể thay đổi khác nhau, nhưng thứ tự thường không. Đây là điều quan trọng nên hiểu, vì nó giúp ta giải thích được cách bộ nhớ cấp phát và thiết lập thứ tự liên quan của những parameter và variable. Điều này cũng hữu dụng khi debug lỗi liên quan tới con trỏ. Nếu ta không hiểu được stack frame được cấp phát như thế nào, thì sẽ không hiểu được ý nghĩa của phép gán địa chỉ.

Vì stack frame được đẩy vào program stack, hệ thống có thể bị hết bộ nhớ (run out of memory). Điều kiện này gọi là stack overflow và thường gây ra hậu quả là chương trình bị dừng bất thường. Hãy nhớ rằng mỗi luồng (thread) thường cấp phát program stack của riêng nó. Điều này dẫn đến nguy cơ xung đột nếu một hoặc nhiều thread truy cập vào cùng đối tượng (object) trong bộ nhớ.

Passing and Returning by Pointer

Ở phần này, chúng ta sẽ tìm hiểu tác động của việc truyền con trỏ vào hàm và trả về con trỏ từ hàm. Truyền con trỏ (passing pointers) cho phép đối tượng tham chiếu (referenced object) có thể được truy cập bởi nhiều hàm mà không cần làm nó trở thành đối tượng toàn cục (global object). Có nghĩa là chỉ những hàm cần truy cập tới đối tượng sẽ có quyền truy cập và do đó không cần phải duplicate đối tượng này.

Nếu ta muốn dữ liệu (data) bị thay đổi trong một hàm, nó cần được truyền bởi con trỏ. Chúng ta có thể truyền dữ liệu bởi con trỏ và cấm thay đổi nó bằng cách truyền một con trỏ tới hằng số (a pointer to a constant), sẽ được nói đến ở phần “Passing a Pointer to a Constant”. Nếu dữ liệu cần thay đổi là một con trỏ, thì ta phải truyền một con trỏ tới con trỏ (a pointer to a pointer), sẽ được nói đến ở phần “Passing a Pointer to a Pointer”.

Parameter được truyền bằng giá trị, bao gồm cả con trỏ. Nghĩa là một bản copy của argument sẽ được truyền vào hàm. Truyền một con trỏ tới một argument sẽ có ích khi làm việc với cấu trúc dữ liệu lớn. Ví dụ, với một cấu trúc lớn tượng trưng cho nhân viên (employee). Nếu chúng ta truyền toàn bộ cấu trúc cho hàm, thì mỗi byte của cấu trúc sẽ cần được copy, dẫn đến làm chương trình chậm hơn và tốn nhiều bộ nhớ dùng cho stack frame. Truyền một con trỏ tới một object nghĩa là object không cần bị copy, và chúng ta có thể truy cập tới nó thông qua con trỏ.

Passing Data Using a Pointer

Một trong nhưng lý do cơ bản để truyền dữ liệu (passing data) bằng con trỏ là để cho phép hàm có khả năng chỉnh sửa (modify) data. Đoạn code sau implement hàm hoán đổi (swap function), là hàm hoán đổi giá trị của 2 parameter với nhau. Đây là một hàm thông dụng được dùng cho các thuật toán sắp xếp. Ở đây, chúng ta sử dụng con trỏ integer và tham chiếu chúng để ảnh hưởng phép toán swap:

void swapWithPointers(int* pnum1, int* pnum2) {
    int tmp;
    tmp = *pnum1;
    *pnum1 = *pnum2;
    *pnum2 = tmp;
}

Đoạn code sau sử dụng hàm trên:

int main() {
    int n1 = 5;
    int n2 = 10;
    swapWithPointers(&n1, &n2);
    return 0;
}

Con trỏ pnum1 pnum2 được tham chiếu trong quá trình swap. Kết quả là giá trị của n1n2 được chuyển đổi. Figure 3-3 mô tả cách tổ chức bộ nhớ. Ảnh trước (Before) là program stack lúc bắt đầu gọi hàm swap. Ảnh sau (After) là ngay trước lúc hàm return

Swapping with pointers
Figure 3-3. Swapping with pointers

Passing Data by Value

Nếu chúng ta không truyền bằng con trỏ, thì phép toán swap sẽ không thực hiện được. Trong hàm sau, hai số nguyên (integer) sẽ được truyền bằng giá trị (pass by value). Tiếp theo, trong hàm main, hai số nguyên sẽ được truyền cho hàm:

void swap(int num1, int num2) {
    int tmp;
    tmp = num1;
    num1 = num2;
    num2 = tmp;
}

int main() {
    int n1 = 5;
    int n2 = 10;
    swap(n1, n2);
    return 0;
}

Tuy nhiên, cách này sẽ vô dụng bởi vì số nguyên được truyền vào là giá trị, không phải con trỏ. Chỉ là bản copy của các argument lưu vào num1 num2. Nếu ta thay đổi num1, thì argument n1 vẫn không đổi. Khi ta thay đổi giá trị của các parameter, chúng ta không thay đổi được giá trị của các argument gốc. Figure 3-4 mô tả cách bộ nhớ được cấp phát cho parameter.

Pass by value
Figure 3-4. Pass by value

Passing a Pointer to a Constant

Truyền con trỏ tới một hằng số (constant) là một kĩ thuật thông dụng trong C. Cách làm này rất hiệu quả vì chúng ta chỉ truyền địa chỉ của data và có thể tránh việc copy toàn bộ vùng nhớ, trong một số trường hợp data khá lớn. Tuy nhiên, với một con trỏ thông thường, data có thể bị sửa đổi và chúng ta không muốn thế. Khi xét đến vấn đề này thì việc truyền một con trỏ tới một constant chính là giải pháp.

Trong ví dụ này, chúng ta truyền một con trỏ tới một hằng số nguyên (a pointer to a constant integer) và một con trỏ tới một số nguyên (a pointer to an integer), chúng ta không thể thay đổi giá trị được truyền vào bởi con trỏ tới một constant:

void passingAddressOfConstants(const int* num1, int* num2) {
    *num2 = *num1;
}

int main() {
    const int limit = 100;
    int result = 5;
    passingAddressOfConstants(&limit, &result);
    return 0;
}

Sẽ không có lỗi cú pháp nào xuất hiện, và hàm sẽ gán 100 cho biến result.

Ở phiên bản sau của hàm này, chúng ta thử thay đổi giá trị của cả hai số nguyên được tham chiếu như sau:

void passingAddressOfConstants(const int* num1, int* num2) {
    *num1 = 100;
    *num2 = 200;
}

Cách dùng dưới đây sẽ gây ra lỗi nếu chúng ta truyền biến hằng số limit cho hàm hai lần:

const int limit = 100;
passingAddressOfConstants(&limit, &limit);

Khi biên dịch nó sẽ báo lỗi cú pháp (syntax error) do kiểu biến không phù hợp (type mismatch) giữa parameter thứ hai và argument của nó. Ngoài ra, nó còn báo rằng chúng ta đang cố ý thay đổi một hằng số được tham chiếu bởi parameter thứ nhất.

Hàm kỳ vọng một con trỏ tới số nguyên (a pointer to an integer), nhưng thay vào đó là một con trỏ tới hằng số nguyên (a pointer to an integer constant) đã được truyền vào. Chúng ta không thể truyền địa chỉ của hằng số nguyên (address of an integer constant) cho một con trỏ tới một hằng số. Vì nó sẽ cho phép thay đổi giá trị hằng số.

Việc truyền trực tiếp địa chỉ của hằng số nguyên như sau cũng sẽ gây ra lỗi cú pháp:

passingAddressOfConstants(&23, &23); 

Lỗi chỉ ra rằng yêu cầu một lvalue làm toán tử (operand) cho phép toán địa chỉ (address-of operator).

Returning a Pointer

Muốn trả về con trỏ, ta chỉ việc khai báo kiểu trả về (return type) là một con trỏ tới kiểu dữ liệu tương ứng. Nếu bạn muốn hàm trả về một đối tượng (object), thì có 2 kỹ thuật thường dùng như sau:

  • Cấp phát bộ nhớ bên trong hàm bằng hàm malloc và trả về địa chỉ của nó. Người gọi có trách nhiệm giải phóng bộ nhớ được trả về.
  • Truyền một object cho hàm để modify. Người gọi có trách nhiệm cấp phát và giải phóng bộ nhớ của object.

Đầu tiên, hãy xem trường hợp sử dụng hàm malloc để cấp bộ nhớ trả về. Ví dụ dưới đây mô phỏng việc trả về một con trỏ tới một local object. Cách thứ hai không được khuyến khích sử dụng, nó sẽ được thảo luận ở phần “Passing Null Pointers”.

Trong ví dụ, chúng ta định nghĩa một hàm có parameter truyền vào là kích thước của mảng số nguyên và một giá trị để khởi tạo cho mỗi phần tử. Hàm này có nhiệm vụ cấp phát bộ nhớ cho một mảng số nguyên, khởi tạo mảng với giá trị được truyền, sau đó trả về địa chỉ của mảng:

int* allocateArray(int size, int value) {
    int* arr = (int*)malloc(size * sizeof(int));
    for(int i=0; i<size; i++) {
        arr[i] = value;
    }
    return arr;
}

Sử dụng hàm đó như sau:

int* vector = allocateArray(5,45);
for(int i=0; i<5; i++) {
    printf("%d\n", vector[i]);
}

Figure 3-5 vẽ cách bộ nhớ được cấp phát cho hàm này. Ảnh trước (Before) cho thấy trạng thái của chương trình ngay trước khi return. Ảnh sau (After) là trạng thái của chương trình sau khi hàm return. Lúc này, biến vector chứa địa chỉ của bộ nhớ được cấp phát (500) trong hàm allocateArray. Khi hàm kết thúc, biến arr bị xóa, bộ nhớ ở địa chỉ 500 được tham chiếu bởi con trỏ không bị mất. Người dùng phải nhớ free bộ nhớ này sau khi dùng xong.

Returning a pointer
Figure 3-5. Returning a pointer

Mặc dù ví dụ này hoạt động đúng, tuy nhiên nó tiềm ẩn vài vấn đề có thể xảy ra khi một hàm return con trỏ, bao gồm:

  • Trả về một con trỏ chưa khởi tạo (uninitialized pointer)
  • Trả về một con trỏ tới địa chỉ không hợp lệ (pointer to an invalid address)
  • Trả về một con trỏ tới một biến cục bộ (pointer to a local variable)
  • Trả về một con trỏ nhưng bị lỗi khi giải phóng (free) nó

Vấn đề cuối thường xảy ra ở hàm allocateArray. Trả về bộ nhớ cấp phát động từ hàm nghĩa là người gọi hàm chịu trách nhiệm trong việc giải phóng bộ nhớ của nó. Như cách làm dưới đây:

int* vector = allocateArray(5,45);
...
free(vector);

Chúng ta phải free nó sau khi sử dụng xong. Nếu không thì chúng ta sẽ bị lỗi rò rỉ bộ nhớ (memory leak).

Pointers to Local Data

Trả về con trỏ tới một dữ liệu cục bộ (local data) là một sai lầm dễ mắc phải nếu bạn không hiểu program stack hoạt động như thế nào. Trong ví dụ tiếp theo, chúng ta sẽ lấy và sửa lại hàm allocateArray ở phần “Returning a Pointer”. Thay vì cấp phát bộ nhớ động (dynamically allocating memory) cho mảng, chúng ta sẽ dùng một mảng cục bộ (local array):

int* allocateArray(int size, int value) {
    int arr[size];
    for(int i=0; i<size; i++) {
        arr[i] = value;
    }
    return arr;
}

Tuy nhiên, địa chỉ của mảng được trả về sẽ không còn hợp lệ một khi hàm return. Bởi vì stack frame của hàm bị lấy ra (pop off) khỏi stack. Mặc dù mỗi phần tử của mảng vẫn lưu giá trị 45, tuy nhiên giá trị này cũng có khả năng bị ghi đè (overwrite) nếu hàm khác được gọi. Điều này được thể hiện ở ví dụ sau. Trong này, hàm printf được gọi liên tục, khiến cho mảng bị lỗi corruption:

int* vector = allocateArray(5,45);
for(int i=0; i<5; i++) {
    printf("%d\n", vector[i]);
}

Figure 3-6 mô tả cách bộ nhớ được cấp phát như thế nào khi lỗi xảy ra. Ô nét chấm cho thấy chỗ từng là stack frame của hàm allocateArray có thể trở thành stack frame của những hàm khác, như là hàm printf, có thể bị đẩy (push) vào program stack, gây corrupt bộ nhớ đang dùng của mảng. Nội dung thực sự của stack frame đó phụ thuộc theo cách làm (implementation-dependent).

Returning a pointer to local data
Figure 3-6. Returning a pointer to local data

Một cách tiếp cận khác là khai báo biến arr làm static. Nó sẽ nâng scope của biến ra khỏi phạm vi hàm và allocate bên ngoài stack frame, loại trừ khả năng bị những hàm khác ghi đè (overwrite) giá trị lên biến này:

int* allocateArray(int size, int value) {
    static int arr[5];
    ...
}

Tuy nhiên, cách này không phải lúc nào cũng nên dùng. Mỗi lần hàm allocateArray được gọi, nó sẽ dùng lại chính mảng này. Điều này vô hiệu hóa mọi lệnh gọi tới hàm trước đó. Ngoài ra, vì mảng static phải được khai báo với kích thước cố định, do đó sẽ hạn chế năng lực của hàm khi xử lý các kích thước khác nhau.

Nếu chỉ cần hàm trả về vài giá trị cần thiết và không gây tổn hại gì khi chia sẻ chúng. Ta có thể duy trì một danh sách giá trị này và trả về cái tương ứng. Sẽ hữu ích nếu chúng ta cần trả về tin nhắn kiểu trạng thái (a status type message), như một error number, là một giá trị không cần thay đổi.

Passing Null Pointers

Trong phiên bản tiếp theo của hàm allocateArray, một con trỏ tới mảng được truyền vào cùng với kích thước mảng và một giá trị để khởi tạo cho mỗi phần tử của mảng. Hàm trả về con trỏ cho thuận tiện. Tuy nhiên phiên bản này của hàm không cấp phát bộ nhớ, việc này sẽ được làm ở phiên bản sau nữa:

int* allocateArray(int *arr, int size, int value) {
    if(arr != NULL) {
        for(int i=0; i<size; i++) {
            arr[i] = value;
        }
    }
    return arr;
}

Một thói quen tốt khi truyền một con trỏ vào hàm là luôn luôn kiểm tra khác null trước khi sử dụng nó.

Sử dụng hàm như sau:

int* vector = (int*)malloc(5 * sizeof(int));
allocateArray(vector,5,45);

Nếu con trỏ là NULL, thì không hành động nào được thực hiện, do đó chương trình sẽ không bị dừng do lỗi bất thường.

Passing a Pointer to a Pointer

Khi con trỏ (pointer) được truyền vào hàm, nó được truyền bằng giá trị (passed by value). Nếu chúng ta muốn điều chỉnh (modify) một con trỏ gốc (original pointer), chứ không phải bản copy của nó. Chúng ta phải truyền nó như một con trỏ tới một con trỏ (a pointer to a pointer). Trong ví dụ này, một con trỏ tới mảng integer được truyền vào hàm. Con trỏ này sẽ được gán bộ nhớ (assign memory) và khởi tạo (initialize). Hàm sẽ trả về bộ nhớ được cấp phát (allocated memory) cho parameter thứ nhất. Trong hàm này, đầu tiên chúng ta cấp phát bộ nhớ rồi khởi tạo nó. Địa chỉ của bộ nhớ được cấp phát này được gán cho một con trỏ int (a pointer to an int). Để điều chỉnh con trỏ trong một hàm, ta phải truyền địa chỉ của con trỏ. Do đó, phải khai báo parameter là con trỏ tới con trỏ kiểu int (a pointer to a pointer to an int). Khi gọi hàm, ta cần truyền vào địa chỉ con trỏ:

void allocateArray(int **arr, int size, int value) {
    *arr = (int*)malloc(size * sizeof(int));
    if(*arr != NULL) {
        for(int i=0; i<size; i++) {
            *(*arr+i) = value;
        }
    }
}

Hãy test hàm bằng đoạn code sau:

int *vector = NULL;
allocateArray(&vector,5,45);

Parameter đầu tiên của hàm allocateArray() là một con trỏ tới con trỏ tới một integer (a pointer to a pointer to an integer). Khi gọi hàm, chúng ta cần truyền vào một giá trị kiểu này. Ta có thể truyền vào địa chỉ của biến vector. Địa chỉ trả về từ malloc được gán cho arr. Tham chiếu một con trỏ tới một con trỏ tới một integer (dereference a pointer to a pointer to an integer) cho kết quả là một con trỏ tới một integer (a pointer to an integer). Vì đây là địa chỉ của vector, do đó sẽ modify được vector.

Quá trình cấp phát bộ nhớ mô tả trong Figure 3-7. Ảnh trước (Before) vẽ stack sau khi malloc return và mảng được khởi tạo. Trong khi, ảnh sau (After) vẽ stack sau khi hàm return.

Để dễ xác định vấn đề như rò rỉ bộ nhớ (memory leak), hãy vẽ sơ đồ cấp phát bộ nhớ.

Figure 3-7. Passing a pointer to a pointer

Phiên bản dưới đây của hàm mô tả tại sao truyền một con trỏ đơn thuần sẽ không hiệu quả:

void allocateArray(int *arr, int size, int value) {
    arr = (int*)malloc(size * sizeof(int));
    if(arr != NULL) {
        for(int i=0; i<size; i++) {
            arr[i] = value;
        }
    }
}

Cách sử dụng hàm:

int *vector = NULL;
allocateArray(&vector,5,45);
printf("%p\n", vector);

Khi chương trình chạy, bạn sẽ thấy kết quả hiển thị là 0x0. Bởi vì khi vector được truyền vào hàm, chỉ là giá trị được copy vào parameter arr. Do đó modify arr không ảnh hưởng lên vector. Khi hàm return, giá trị lưu trong arr không được copy vào vector. Figure 3-8 vẽ quá trình cấp phát bộ nhớ. Ảnh Before malloc cho thấy trạng thái bộ nhớ ngay trước khi arr được gán giá trị mới. Ảnh After malloc vẽ trạng thái bộ nhớ sau khi hàm malloc thực thi trong hàm allocateArray và mảng được khởi tạo. Biến arr được modify để chỉ vào vùng địa chỉ mới trong heap. Ảnh After return là trạng thái bộ nhớ sau khi hàm return. Ngoài ra, chúng ta còn bị memory leak vì đã làm mất truy cập tới cụm bộ nhớ ở địa chỉ 600.

Figure 3-8. Passing pointers

Viết hàm free của riêng bạn (Writing your own free function)

Hàm free() không kiểm tra con trỏ truyền vào có khác NULL hay không, và cũng không set giá trị NULL cho con trỏ trước khi return để tránh bị Dangling Pointer. Điều này thường gây ra lỗi ‘Segmentations faults’ hoặc ‘double free’. Do đó để an toàn người ta thường tự viết thêm hàm free riêng để giữ cho chương trình không bị corrupt.

Dựa trên những nền tảng đã thảo luận ở phần “Passing and  Returning by Pointer” , chúng ta sẽ tạo một hàm free riêng có khả năng gán NULL cho con trỏ sau khi free. Hàm này cần sử dụng con trỏ tới con trỏ như dưới đây:

void saferFree(void **pp) {
    if (pp != NULL && *pp != NULL) {
        free(*pp);
        *pp = NULL;
    }
}

Hàm saferFree gọi hàm free để thực sự deallocate memory. Nó khai báo sử dụng parameter là một con trỏ tới con trỏ kiểu void (a pointer to a pointer to void). Sử dụng con trỏ tới con trỏ cho phép ta sửa đổi (modify) con trỏ truyền vào. Sử dụng kiểu void cho phép truyền bất kỳ kiểu dữ liệu nào vào hàm. Tuy nhiên, chúng ta sẽ nhận một warning nếu không ép kiểu tường minh (explicitly cast) thành kiểu con trỏ tới void mỗi khi gọi hàm. Nếu ta nhớ ép kiểu tường minh thì warning sẽ biến mất.

Định nghĩa thêm macro safeFree như bên dưới, gọi hàm safeFree cùng với ép kiểu void và sử dụng toán tử địa chỉ (address-of operator).   

#define safeFree(p) saferFree((void**)&(p))

Sử dụng macro như sau:

int main() {
    int *pi;
    pi = (int*) malloc(sizeof(int));
    *pi = 5;
    printf("Before: %p\n",pi);
    safeFree(pi);
    printf("After: %p\n",pi);
    safeFree(pi);
    return (EXIT_SUCCESS);
}

Giả sử hàm malloc trả về bộ nhớ ở địa chỉ 1000, output print ra màn hình sẽ là 1000, tiếp sau là 0. Lần thứ hai gọi macro safeFree với giá trị NULL không làm ngưng chương trình, do hàm đã phát hiện và bỏ qua nó.

Function Pointers

Con trỏ hàm là con trỏ chứa địa chỉ của hàm. Con trỏ có khả năng trỏ đến hàm đưa ra một tính năng quan trọng và hữu ích của C. Nó cung cấp một cách khác để thực thi các hàm theo một thứ tự không cụ thể tại thời điểm biên dịch và không phải sử dụng các câu lệnh điều kiện.

Tuy nhiên, một mối lo ngại về sử dụng con trỏ hàm là nguy cơ chương trình chạy chậm hơn. Bộ xử lý có thể không sử dụng được dự đoán nhánh (branch prediciton) kết hợp với đường ống (pipelining). Branch prediciton là một kỹ thuật nhờ đó bộ xử lý sẽ đoán xem chuỗi thực thi (multiple execution sequences) nào sẽ được thực hiện. Pipelining là một công nghệ phần cứng thường được sử dụng để cải thiện hiệu suất của bộ xử lý và đạt được bằng cách thực hiện lệnh chồng chéo (overlapping instruction execution). Trong sơ đồ này, bộ xử lý sẽ bắt đầu xử lý nhánh mà nó tin rằng sẽ được thực thi. Nếu bộ xử lý dự đoán thành công nhánh đúng, thì các lệnh (instruction) hiện tại trong đường ống (pipeline) sẽ không phải bị loại bỏ.

Sự chậm lại này có thể xảy ra hoặc không. Việc sử dụng con trỏ hàm trong các tình huống như tra cứu bảng (table lookup) có thể giảm thiểu các vấn đề về hiệu suất. Trong phần này, ta sẽ tìm hiểu cách khai báo (declare) con trỏ hàm, xem cách chúng được sử dụng để hỗ trợ các đường dẫn thực thi thay thế (alternate execution path) và khám phá các kỹ thuật khai thác tiềm năng của chúng.

Declaring Function Pointers

Cú pháp khai báo (declare) một con trỏ tới một hàm (a pointer to a function) có thể gây nhầm lẫn khi bạn lần đầu tiên nhìn thấy nó. Cũng như nhiều khía cạnh của C, một khi bạn đã quen với ký hiệu, mọi thứ sẽ bắt đầu đâu vào đó. Hãy bắt đầu với một khai báo đơn giản. Dưới đây, chúng ta khai báo một con trỏ tới một hàm được truyền void và trả về void:

void (*foo)();

Khai báo này trông giống như một nguyên mẫu hàm (function prototype). Nếu chúng ta loại bỏ bộ dấu ngoặc đơn đầu tiên, nó sẽ có vẻ là nguyên mẫu hàm cho hàm foo, hàm này được truyền void và trả về một con trỏ tới void. Tuy nhiên, dấu ngoặc đơn làm cho nó trở thành một con trỏ hàm có tên là foo. Dấu hoa thị (*) cho biết đó là một con trỏ. Figure 3-9 mô tả cụ thể các phần của khai báo con trỏ hàm.

Figure 3-8. Passing pointers

Khi con trỏ hàm được dùng, người lập trình phải cẩn thận đảm bảo rằng nó được dùng đúng. Bởi vì C không kiểm tra các tham số (parameter) được truyền có đúng không.

Một số ví dụ khai báo con trỏ hàm như dưới đây:

int (*f1)(double); // Passed a double and
                   // returns an int
void (*f2)(char*); // Passed a pointer to char and
                   // returns void
double* (*f3)(int, int); // Passed two integers and
                   // returns a pointer to a double

Đừng nhầm lẫn hàm trả về một con trỏ (function that return a pointer) với con trỏ hàm (function pointers). Dưới đây khai báo f4 là một hàm trả về một con trỏ tới một số nguyên, trong khi f5 là một con trỏ hàm trả về một số nguyên. Biến f6 là một con trỏ hàm trả về một con trỏ tới một số nguyên:

int *f4();
int (*f5)();
int* (*f6)();

Khoảng trắng trong các biểu thức này có thể được sắp xếp lại để nó đọc như sau:

int* f4();
int (*f5)();

Rõ ràng f4 là một hàm trả về một con trỏ tới một số nguyên. Tuy nhiên, khi sử dụng dấu ngoặc đơn với f5 để liên kết rõ ràng dấu hoa thị “con trỏ” với tên hàm, làm cho nó trở thành một con trỏ hàm.

Using a Function Pointer

Dưới đây là một ví dụ đơn giản về sử dụng con trỏ hàm, trong đó một hàm được truyền vào một số nguyên và trả về một số nguyên. Chúng ta cũng định nghĩa (define) một hàm square để bình phương một số nguyên và sau đó trả về giá trị bình phương. Để đơn giản hóa các ví dụ này, chúng ta bỏ qua khả năng tràn số nguyên (integer overflow).

int (*fptr1)(int);
    int square(int num) {
    return num*num;
}

Để sử dụng con trỏ hàm thực thi hàm square, chúng ta cần gán địa chỉ của hàm square cho con trỏ hàm, như code bên dưới. Cũng giống như tên mảng, khi chúng ta sử dụng tên của chính hàm đó, nó sẽ trả về địa chỉ của hàm đó. Chúng ta cũng khai báo một số nguyên mà chúng ta sẽ truyền vào hàm:

int n = 5;
fptr1 = square;
printf("%d squared is %d\n",n, fptr1(n));

Khi thực hiện nó sẽ hiển thị: “5 squared is 25”. Chúng ta có thể sử dụng toán tử địa chỉ (address-of) với tên hàm như sau, nhưng nó không cần thiết và dư thừa. Trình biên dịch sẽ bỏ qua toán tử address-of một cách hiệu quả khi được sử dụng trong ngữ cảnh này.

fptr1 = &square;

Figure 3-10 minh họa cách cấp phát bộ nhớ cho ví dụ này. Ở đây đặt hàm square bên dưới program stack. Đây chỉ là mục đích minh họa. Các hàm được allocate trong một segment khác với segment sử dụng bởi program stack. Vị trí thực tế của hàm thường không được quan tâm.

Location of functions
Figure 3-10. Location of functions

Người ta thường khai báo một định nghĩa kiểu (type definition) cho con trỏ hàm để sử dụng thuận tiện hơn. Cách làm như dưới đây. Định nghĩa kiểu như vậy có vẻ hơi lạ. Thông thường, tên của định nghĩa kiểu là phần tử cuối cùng của khai báo:

typedef int (*funcptr)(int);
...
funcptr fptr2;
fptr2 = square;
printf("%d squared is %d\n",n, fptr2(n));

Passing Function Pointers

Việc truyền một con trỏ hàm khá dễ thực hiện. Chỉ cần sử dụng khai báo con trỏ hàm làm tham số (parameter) của hàm. Chúng ta sẽ làm ví dụ truyền một con trỏ hàm bằng cách sử dụng các hàm add, sub compute như được khai báo bên dưới:

int add(int num1, int num2) {
    return num1 + num2;
}

int subtract(int num1, int num2) {
    return num1 - num2;
}

typedef int (*fptrOperation)(int,int);
  
int compute(fptrOperation operation, int num1, int num2) {
    return operation(num1, num2);
}

Hàm được sử dụng như sau:

printf("%d\n",compute(add,5,6));
printf("%d\n",compute(sub,5,6));

Output sẽ là 11 và –1. Địa chỉ của hàm add sub được truyền đến hàm compute. Những địa chỉ này sẽ được sử dụng để gọi phép tính tương ứng. Ví dụ này cũng chỉ ra cách viết code linh hoạt hơn thông qua sử dụng con trỏ hàm.

Returning Function Pointers

Để return con trỏ hàm ta cần khai báo kiểu trả về (return type) của hàm là một con trỏ hàm (function pointer). Chúng ta sẽ sử dụng lại hàm add sub cùng với định nghĩa kiểu đã tạo ra trong phần “Passing Function Pointers”.

Chúng ta sẽ sử dụng hàm select sau đây để return một con trỏ hàm cho một phép toán dựa trên một ký tự input. Nó sẽ return một con trỏ tới hàm add hoặc hàm subtract, tùy thuộc vào opcode được truyền:

fptrOperation select(char opcode) {
    switch(opcode) {
        case '+': return add;
        case '-': return subtract;
    }
}

Hàm evaluate liên kết các hàm này lại với nhau. Hàm được truyền hai số nguyên (integer) và một ký tự (character) đại diện cho phép tính sẽ được thực hiện. Nó truyền opcode tới hàm select, hàm này trả về một con trỏ tới hàm để thực thi. Trong câu lệnh return, nó thực thi hàm này và trả về kết quả:

int evaluate(char opcode, int num1, int num2) {
    fptrOperation operation = select(opcode);
    return operation(num1, num2);
}

Những hàm printf sau sẽ in kết quả khi sử dụng hàm này:

printf("%d\n",evaluate('+', 5, 6));
printf("%d\n",evaluate('-', 5, 6));

Output sẽ là 11 và –1.

Using an Array of Function Pointers

Chúng ta còn có thể sử dụng mảng con trỏ hàm (array of function pointers) để chọn hàm cần thiết cho việc evaluate. Sử dụng khai báo con trỏ hàm từ typedef làm kiểu của mảng. Mảng được khởi tạo với tất cả phần tử là NULL. Cách viết như dưới đây sẽ khởi tạo các phần tử trong mảng với cùng một giá trị. Nếu số lượng giá trị trong ngoặc nhọn nhỏ hơn kích thước của mảng, thì những giá trị này sẽ được sử dụng để khởi tạo mọi phần tử của mảng:

typedef int (*operation)(int, int);
operation operations[128] = {NULL};

Ngoài ra, chúng ta có thể khai báo mà không cần dùng typedef như sau;

int (*operations[128])(int, int) = {NULL};

Mục đích của mảng này là cho phép tra bằng ký tự danh mục (character index) để chọn hàm tương ứng tính toán. Ví dụ: ký tự ‘*’ sẽ tra ra hàm nhân (multiplication) nếu nó tồn tại. Chúng ta sử dụng chỉ mục ký tự vì mỗi ký tự bằng chữ cũng là một số nguyên. 128 phần tử tương là ứng với 128 ký tự ASCII đầu tiên. Chúng ta sử dụng định nghĩa này cùng với các hàm add subtract đã được develop ở phần “Returning Function Pointers”.

Khởi tạo mảng cho tất cả phần tử là NULL rồi, gán các hàm add subtract cho các phần tử tương ứng với các dấu cộng và dấu trừ:

void initializeOperationsArray() {
    operations['+'] = add;
    operations['-'] = subtract;
}

Hàm evaluate trước đó được viết lại thành evaluateArray. Thay vì gọi hàm select để lấy con trỏ hàm, chúng ta sử dụng mảng con trỏ hàm operations với ký tự phép toán (operation character) làm chỉ mục (index):

int evaluateArray(char opcode, int num1, int num2) {
    fptrOperation operation;
    operation = operations[opcode];
    return operation(num1, num2);
}

Kiểm tra hoạt động của hàm như sau:

initializeOperationsArray();
printf("%d\n",evaluateArray('+', 5, 6));
printf("%d\n",evaluateArray('-', 5, 6));

Kết quả thực thi chuỗi lệnh trên là 11 và –1. Ở phiên bản an toàn hơn của hàm evaluateArry, nên kiểm tra null các con trỏ hàm trước khi cố thực thi hàm đó.

Comparing Function Pointers

Con trỏ hàm có thể được so sánh với nhau bằng cách toán tử đẳng thức (equality) và bất đẳng thức (inequality). Trong ví dụ sau, chúng ta sử dụng định nghĩa kiểu fptrOperation và hàm add đã tạo từ phần “Passing Function Pointers”. Hàm add được gán cho con trỏ hàm fptr1, sau đó so sánh với địa chỉ của hàm add:

fptrOperation fptr1 = add;
if(fptr1 == add) {
    printf("fptr1 points to add function\n");
} else {
    printf("fptr1 does not point to add function\n");
}

Khi chạy đoạn code này, output sẽ chứng minh rằng con trỏ trỏ đến hàm add.

Trong thực tế việc so sánh các con trỏ hàm thường được áp dụng cho mảng các con trỏ hàm tượng trưng cho các bước (step) của một tác vụ (task).

Ví dụ chúng ta có thể áp dụng một chuỗi hàm để thao tác với một mảng hàng tồn kho (array of inventory parts). Một tập các thao tác (a set of operations) có thể là sắp xếp các phần, tính toán tổng tích lũy của số lượng, sau đó hiển thị mảng và hiển thị tổng. Tập operation thứ hai có thể là hiển thị mảng, tìm giá đắt nhất và giá rẻ nhất, sau đó hiển thị hiệu số của chúng.

typedef <return_type> (operation*) (...);

operation task1[] = {
    sort_the_parts,
    calculate_cumulative_sum,
    display_array, // log operation
    display_sum,
}

operation task2[] = {
    display_array, // log operation
    find_max_expensive,
    find_min_expensive,
    display_differene,
};

int index = 0;
while(1)
{
    // find log operation
    if(task1[index] == display_array)
    {
        task1[index] = NULL; // remove log operation
    }
    index++;
}

Mỗi operation có thể được định nghĩa bằng một mảng các con trỏ tới từng hàm. Thao tác ghi log (log operation) có thể tồn tại trong cả hai danh sách. Khả năng so sánh hai con trỏ hàm sẽ cho phép sự thay đổi động (dynamic modification) một operation bằng cách delete operation, chẳng hạn như ghi log, bằng cách tìm (find) và sau đó xóa (remove) hàm khỏi danh sách.

Casting Function Pointers

Một con trỏ hàm có thể được ép kiểu sang loại khác (cast to another type). Điều này nên được thực hiện cẩn thận vì khi hệ thống chạy (runtime system), nó không xác minh được các tham số (parameter) được sử dụng bởi một con trỏ hàm là chính xác hay không. Ta cũng có thể ép kiểu con trỏ hàm thành một kiểu con trỏ hàm khác rồi quay lại. Con trỏ kết quả sẽ bằng với con trỏ ban đầu. Kích thước của các con trỏ hàm được sử dụng không nhất thiết phải giống nhau. Đoạn code sau minh họa việc này:

typedef int (*fptrToSingleInt)(int);
typedef int (*fptrToTwoInts)(int,int);
int add(int, int);
fptrToTwoInts fptrFirst = add;
fptrToSingleInt fptrSecond = (fptrToSingleInt)fptrFirst;
fptrFirst = (fptrToTwoInts)fptrSecond;
printf("%d\n",fptrFirst(5,6));

Kết quả in ra sẽ là 11.

Chuyển đổi giữa con trỏ hàm (function pointer) và con trỏ dữ liệu (pointer to data) không đảm bảo sẽ hoạt động đúng.

Việc sử dụng void* không đảm bảo sẽ hoạt động đúng với con trỏ hàm. Do đó, chúng ta không nên gán một con trỏ hàm cho void* như sau:

void (*fptr)() = func;
void* pv = fptr;

Tuy nhiên, khi chuyển đổi các con trỏ hàm, người ta thường thấy một loại con trỏ hàm “cơ sở” (base) như được khai báo bên dưới. Điều này khai báo fptrBase là một con trỏ hàm tới một hàm, được truyền void và trả về void:

typedef void (*fptrBase)();

Đoạn code này mô tả việc sử dụng con trỏ base, ví dụ này được lặp lại từ ví dụ trước:

fptrBase basePointer;
fptrFirst = add;
basePointer = (fptrToSingleInt)fptrFirst;
fptrFirst = (fptrToTwoInts)basePointer;
printf("%d\n",fptrFirst(5,6));

Con trỏ base được dùng như một vật giữ chỗ (placeholder) để hoán chuyển giá trị con trỏ hàm.

Luôn luôn chắc chắn rằng bạn sử dụng đúng danh sách đối số (argument list) cho con trỏ hàm. Sử dụng sai có thể gây ra lỗi hành vi không xác định (indeterminate behavior).

Summary

 Hiểu cấu trúc program stack và heap góp phần giúp chúng ta nắm vững và có thể mô tả chi tiết về cách thức hoạt động của một chương trình và cách hoạt động của con trỏ. Chúng ta cũng đã tìm hiểu về stack, heap và stack frame. Những khái niệm này giúp giải thích cơ chế truyền (pass) con trỏ đến một hàm và trả lại (return) con trỏ từ một hàm.

Ví dụ việc return một con trỏ tới một biến cục bộ (local variable) là tệ vì bộ nhớ được cấp phát cho biến cục bộ sẽ bị ghi đè bởi các lệnh gọi hàm tiếp theo. Truyền một con trỏ tới constant data là việc làm hiệu quả và ngăn hàm sửa đổi dữ liệu được truyền. Truyền một con trỏ tới một con trỏ (a pointer to a pointer) cho phép con trỏ đối số (argument pointer) được gán lại địa chỉ khác trong bộ nhớ. Nhờ có stack và heap đã giúp minh họa chi tiết chức năng này.

Con trỏ hàm cũng đã được giới thiệu và giải thích. Loại con trỏ này rất hữu ích để kiểm soát trình tự thực thi các hàm trong một ứng dụng bằng khả năng thay thế hàm cần thực thi dựa trên nhu cầu của ứng dụng.

Icons made by Freepik from www.flaticon.com