Bài 31.4: Con trỏ cấp cao trong C++

Chào mừng trở lại với loạt bài về C++! Sau khi chúng ta đã làm quen với những kiến thức cơ bản nhất về con trỏ – cách nó lưu trữ địa chỉ của các biến khác và cho phép chúng ta truy cập dữ liệu một cách linh hoạt – đã đến lúc chúng ta nâng cấp kiến thức lên một tầm cao mới. Hôm nay, chúng ta sẽ cùng tìm hiểu về "con trỏ cấp cao" trong C++, cụ thể là con trỏ tới con trỏcon trỏ tới hàm. Những khái niệm này không chỉ giúp bạn hiểu sâu hơn về cách bộ nhớ hoạt động, mà còn mở ra những cánh cửa mới để xử lý các cấu trúc dữ liệu phức tạp và triển khai các kỹ thuật lập trình mạnh mẽ hơn.

1. Con trỏ tới Con trỏ (Pointer to Pointer)

Bạn đã biết rằng một con trỏ thông thường (int*, char*, v.v.) lưu trữ địa chỉ của một biến nào đó (int, char, v.v.). Vậy, điều gì sẽ xảy ra nếu chúng ta muốn lưu trữ địa chỉ của chính con trỏ đó? Đây chính là lúc khái niệm con trỏ tới con trỏ xuất hiện.

Một con trỏ tới con trỏ là một biến con trỏ mà giá trị nó lưu giữ là địa chỉ bộ nhớ của một con trỏ khác. Hay nói cách khác, nó là con trỏ trỏ tới một con trỏ.

Cú pháp khai báo:

Để khai báo một con trỏ tới con trỏ, chúng ta sử dụng hai dấu sao (**):

kiểu_dữ_liệu **tên_con_trỏ_cấp_2;

Ví dụ: int **pptr;

Ở đây:

  • kiểu_dữ_liệu là kiểu dữ liệu mà con trỏ cấp 1 (con trỏ mà con trỏ cấp 2 này trỏ tới) đang trỏ đến.
  • ** cho biết đây là con trỏ cấp 2. * thứ nhất chỉ con trỏ cấp 1, * thứ hai chỉ con trỏ cấp 2 trỏ tới con trỏ cấp 1 đó.

Truy cập giá trị qua con trỏ tới con trỏ:

Việc truy cập giá trị qua con trỏ tới con trỏ đòi hỏi hai lần "dereferencing" (sử dụng toán tử *):

  • *tên_con_trỏ_cấp_2: Sẽ trả về con trỏ cấp 1 mà con trỏ cấp 2 đang trỏ tới (là giá trị địa chỉ).
  • **tên_con_trỏ_cấp_2: Sẽ trả về giá trị của biến gốc mà con trỏ cấp 1 đang trỏ tới.

Hãy xem một ví dụ đơn giản:

#include <iostream>

int main() {
    int value = 100;      // Một biến int
    int* ptr = &value;    // Con trỏ cấp 1, trỏ tới 'value'
    int** ptr_to_ptr = &ptr; // Con trỏ cấp 2, trỏ tới con trỏ 'ptr'

    cout << "Dia chi cua 'value': " << &value << endl;
    cout << "Gia tri cua 'value': " << value << endl;

    cout << "\n--- Thong qua con tro cap 1 (ptr) ---" << endl;
    cout << "Gia tri cua 'ptr' (dia chi cua 'value'): " << ptr << endl;
    cout << "Gia tri tai dia chi ma 'ptr' tro toi (*ptr): " << *ptr << endl;
    cout << "Dia chi cua 'ptr': " << &ptr << endl;

    cout << "\n--- Thong qua con tro cap 2 (ptr_to_ptr) ---" << endl;
    cout << "Gia tri cua 'ptr_to_ptr' (dia chi cua 'ptr'): " << ptr_to_ptr << endl;
    cout << "Gia tri tai dia chi ma 'ptr_to_ptr' tro toi (*ptr_to_ptr): " << *ptr_to_ptr << " (chinh la 'ptr')" << endl;
    cout << "Gia tri tai dia chi ma con tro *ptr_to_ptr' (tuc la 'ptr') tro toi (**ptr_to_ptr): " << **ptr_to_ptr << " (chinh la 'value')" << endl;

    // Thay đổi giá trị của 'value' thông qua con trỏ cấp 2
    **ptr_to_ptr = 200;
    cout << "\n--- Sau khi thay doi gia tri qua **ptr_to_ptr ---" << endl;
    cout << "Gia tri moi cua 'value': " << value << endl;

    return 0;
}

Giải thích:

  • Chúng ta có biến value.
  • ptr là con trỏ cấp 1, nó giữ địa chỉ của value.
  • ptr_to_ptr là con trỏ cấp 2, nó giữ địa chỉ của ptr.
  • Khi dùng *ptr_to_ptr, chúng ta truy cập đến nội dung tại địa chỉ mà ptr_to_ptr giữ. Nội dung này chính là giá trị của ptr (địa chỉ của value).
  • Khi dùng **ptr_to_ptr, chúng ta truy cập đến nội dung tại địa chỉ mà (*ptr_to_ptr) giữ. Vì (*ptr_to_ptr)ptr (địa chỉ của value), nên **ptr_to_ptr truy cập đến nội dung tại địa chỉ của value, tức là giá trị của value.
  • Việc gán **ptr_to_ptr = 200; cũng tương đương với *ptr = 200; hoặc value = 200;, cho thấy chúng ta có thể thao tác trên biến gốc thông qua con trỏ cấp 2.

Ứng dụng của Con trỏ tới Con trỏ:

  • Cấp phát mảng 2 chiều động: Đây là ứng dụng phổ biến nhất. Một mảng 2 chiều động trong C++ thường được biểu diễn bằng một con trỏ tới một mảng các con trỏ (kiểu_dữ_liệu **). Mỗi con trỏ trong mảng đó lại trỏ tới hàng (một mảng 1 chiều) của mảng 2 chiều.
  • Truyền con trỏ theo tham chiếu vào hàm: Nếu bạn muốn một hàm có thể thay đổi chính con trỏ (làm cho nó trỏ tới một vị trí khác hoặc cấp phát bộ nhớ mới cho nó), bạn cần truyền địa chỉ của con trỏ đó vào hàm. Địa chỉ của một con trỏ cấp 1 chính là một con trỏ cấp 2. Hoặc trong C++, cách hiện đại hơn là sử dụng tham chiếu đến con trỏ (kiểu_dữ_liệu*&). Tuy nhiên, hiểu con trỏ tới con trỏ giúp bạn hiểu cách kiểu_dữ_liệu*& hoạt động ngầm định.

Hãy xem ví dụ về việc dùng con trỏ tới con trỏ để thay đổi một con trỏ thông thường bên trong một hàm:

#include <iostream>

// Hàm này nhận địa chỉ của một con trỏ int
// Bằng cách nhận int** (pointer to pointer), hàm có thể thay đổi
// con trỏ int gốc mà nó được truyền vào (myPointer trong main)
void createAndAssignPointer(int** ptr_address) {
    // Cấp phát bộ nhớ mới cho một số nguyên
    int* newValue = new int(99);

    // Thay đổi con trỏ gốc (thông qua địa chỉ của nó ptr_address)
    // để nó trỏ tới vùng nhớ mới được cấp phát
    *ptr_address = newValue; // Gán newValue vào biến con trỏ mà ptr_address trỏ tới

    // Lưu ý: ptr_address là con trỏ cấp 2.
    // *ptr_address là con trỏ cấp 1 mà ptr_address trỏ tới (chính là myPointer trong main).
    // **ptr_address là giá trị mà con trỏ cấp 1 trỏ tới (chính là 99 sau khi gán).
}

int main() {
    int* myPointer = nullptr; // Khởi tạo con trỏ rỗng (không trỏ đi đâu cả)
    cout << "myPointer truoc khi goi ham: " << myPointer << endl;
    // cout << "*myPointer truoc khi goi ham: " << *myPointer << endl; // Lỗi nếu cố gắng truy cập nullptr

    // Truyền địa chỉ của myPointer cho hàm createAndAssignPointer
    // &myPointer có kiểu là int**, nên phù hợp với tham số int** ptr_address
    createAndAssignPointer(&myPointer);

    cout << "myPointer sau khi goi ham: " << myPointer << endl;
    // Bây giờ myPointer đã trỏ tới vùng nhớ mới, chúng ta có thể truy cập nó
    if (myPointer != nullptr) {
        cout << "*myPointer sau khi goi ham: " << *myPointer << endl;

        // Quan trọng: Giai phong bo nho da cap phat trong ham!
        delete myPointer;
        myPointer = nullptr; // Gan lai nullptr sau khi giai phong
    }

    return 0;
}

Giải thích:

  • Hàm createAndAssignPointer nhận một int**. Điều này có nghĩa là nó nhận địa chỉ của một con trỏ kiểu int*.
  • Trong main, &myPointer là địa chỉ của con trỏ myPointer, và nó có kiểu int**.
  • Bên trong hàm, *ptr_address truy cập đến chính biến myPointer (vì ptr_address trỏ tới myPointer).
  • Khi chúng ta gán *ptr_address = newValue;, chúng ta thực sự đang thay đổi giá trị của biến myPointer trong main, làm cho nó trỏ đến địa chỉ của newValue thay vì nullptr.
  • Nếu chỉ truyền myPointer vào hàm (dưới dạng int* ptr), hàm chỉ nhận một bản sao của địa chỉ và việc thay đổi con trỏ bên trong hàm sẽ không ảnh hưởng đến myPointer gốc trong main.

Con trỏ tới con trỏ là một công cụ mạnh mẽ nhưng cần được sử dụng cẩn thận để tránh nhầm lẫn và rò rỉ bộ nhớ (memory leaks) khi làm việc với cấp phát động.

2. Con trỏ tới Hàm (Pointer to Function)

Trong C++, hàm cũng có địa chỉ bộ nhớ! Giống như bạn có thể có một con trỏ trỏ tới một biến int, bạn cũng có thể có một con trỏ trỏ tới một hàm. Một con trỏ tới hàm lưu trữ địa chỉ bộ nhớ nơi bắt đầu mã lệnh của một hàm.

Điều này cho phép chúng ta coi các hàm như là dữ liệu: chúng ta có thể gán địa chỉ hàm cho biến con trỏ, truyền con trỏ hàm làm đối số cho các hàm khác, lưu trữ con trỏ hàm trong các cấu trúc dữ liệu (như mảng hoặc danh sách), và gọi hàm thông qua con trỏ.

Cú pháp khai báo:

Cú pháp khai báo con trỏ tới hàm hơi phức tạp và dễ gây nhầm lẫn nếu không quen:

kiểu_dữ_liệu_trả_về (*tên_con_trỏ_hàm)(danh_sách_tham_số);

Lưu ý đặc biệt đến dấu ngoặc đơn () quanh *tên_con_trỏ_hàm. Điều này là bắt buộc để phân biệt con trỏ tới hàm với việc khai báo một hàm trả về một con trỏ.

Ví dụ: int (*addPtr)(int, int); Đây là con trỏ tới một hàm nhận hai tham số int và trả về một int.

Gán địa chỉ hàm và Gọi hàm thông qua con trỏ:

Để gán địa chỉ của một hàm cho con trỏ hàm, bạn chỉ cần sử dụng tên hàm (không có dấu ngoặc đơn ()). Tên hàm đứng một mình trong ngữ cảnh này sẽ tự động được hiểu là địa chỉ của hàm đó.

Để gọi hàm thông qua con trỏ, bạn có thể sử dụng cú pháp (*tên_con_trỏ_hàm)(tham_số1, tham_số2, ...) hoặc cách viết ngắn gọn hơn và phổ biến hơn là tên_con_trỏ_hàm(tham_số1, tham_số2, ...).

Hãy xem ví dụ:

#include <iostream>

// Các hàm mẫu
int sum(int a, int b) {
    return a + b;
}

int difference(int a, int b) {
    return a - b;
}

int main() {
    // Khai báo con trỏ tới hàm: nhận 2 int, trả về int
    int (*operationPtr)(int, int);

    // Gán con trỏ tới hàm 'sum'
    operationPtr = sum; // Gán địa chỉ của hàm sum

    // Gọi hàm sum thông qua con trỏ
    int result1 = operationPtr(10, 5);
    cout << "Su dung con tro, ket qua sum(10, 5): " << result1 << endl;

    // Gán con trỏ tới hàm 'difference'
    operationPtr = difference; // Gán địa chỉ của hàm difference

    // Gọi hàm difference thông qua con trỏ
    int result2 = operationPtr(10, 5);
    cout << "Su dung con tro, ket qua difference(10, 5): " << result2 << endl;

    // Cach goi tuong minh hon (nhung it dung)
    // int result3 = (*operationPtr)(10, 5);
    // cout << "Using explicit call (*ptr)(args): " << result3 << endl;


    return 0;
}

Giải thích:

  • Chúng ta khai báo operationPtr là một con trỏ có thể trỏ tới bất kỳ hàm nào nhận hai int và trả về một int.
  • operationPtr = sum; gán địa chỉ của hàm sum cho con trỏ.
  • operationPtr(10, 5); gọi hàm mà operationPtr đang trỏ tới với các đối số 10 và 5.
  • Sau đó, chúng ta gán địa chỉ của hàm difference cho cùng con trỏ operationPtr, và lần gọi tiếp theo sẽ thực thi hàm difference.

Ứng dụng của Con trỏ tới Hàm:

Con trỏ tới hàm cực kỳ hữu ích và có nhiều ứng dụng trong thực tế:

  • Callbacks: Truyền một hàm (thông qua con trỏ của nó) vào một hàm khác để hàm nhận có thể gọi lại hàm truyền vào vào một thời điểm thích hợp hoặc trong một điều kiện cụ thể. Ví dụ: các hàm sắp xếp trong C++ STL (như sort) có thể nhận một con trỏ tới hàm so sánh tùy chỉnh.
  • Function Tables / Dispatch Tables: Sử dụng mảng các con trỏ hàm để thực hiện các hành động khác nhau dựa trên một chỉ mục hoặc sự lựa chọn nào đó. Điều này có thể thay thế cấu trúc switch dài hoặc chuỗi if/else if.
  • Chiến lược (Strategy Pattern): Thiết kế cho phép một thuật toán được chọn tại thời gian chạy. Bạn có thể có một lớp hoặc hàm nhận một con trỏ tới hàm biểu diễn một phần của thuật toán, và thay đổi con trỏ hàm để thay đổi hành vi.

Hãy xem ví dụ về việc truyền con trỏ hàm làm đối số (callback):

#include <iostream>

// Các hàm phép toán
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) {
    if (b == 0) {
        cerr << "Loi: Chia cho 0!" << endl;
        return 0; // Trả về giá trị mặc định khi lỗi
    }
    return a / b;
}

// Hàm thực hiện phép toán, nhận một con trỏ hàm làm đối số
void performCalculation(int x, int y, int (*operation)(int, int)) {
    cout << "Thuc hien phep tinh voi " << x << " va " << y << endl;
    int result = operation(x, y); // Gọi hàm được truyền vào thông qua con trỏ
    cout << "Ket qua: " << result << endl;
}

int main() {
    // Su dung ham add
    performCalculation(20, 10, add);

    cout << endl; // Xuống dòng

    // Su dung ham subtract
    performCalculation(20, 10, subtract);

    cout << endl; // Xuống dòng

    // Su dung ham multiply
    performCalculation(20, 10, multiply);

    cout << endl; // Xuống dòng

    // Su dung ham divide
    performCalculation(20, 10, divide);

    return 0;
}

Giải thích:

  • Hàm performCalculation không biết trước nó sẽ thực hiện phép toán nào. Nó chỉ biết rằng nó sẽ nhận hai số nguyên và một con trỏ tới một hàm có cùng signature (kiểu trả về và danh sách tham số) như các hàm add, subtract, v.v.
  • Khi gọi performCalculation, chúng ta truyền địa chỉ của hàm add, subtract, multiply, hoặc divide.
  • Bên trong performCalculation, dòng int result = operation(x, y); gọi hàm tương ứng thông qua con trỏ operation.
  • Điều này cho thấy sự linh hoạt: cùng một hàm performCalculation có thể thực hiện các hành động khác nhau chỉ bằng cách thay đổi con trỏ hàm được truyền vào.

Bài tập ví dụ: C++ Bài 15.A4: Tổng phần tử ở vị trí chẵn

Cho số nguyên dương \(n\) và mảng \(a\) có kích thước \(n\). Hãy sử dụng con trỏ để tính tổng các phần tử ở vị trí chẵn trong mảng.

INPUT FORMAT

1 dòng gồm số nguyên dương \(n(1 \leq n \leq 10^3)\). Dòng tiếp theo, mỗi dòng gồm \(n\) số nguyên dương \(a_{ij}(1 \leq a_{ij} \leq 10^3)\).

OUTPUT FORMAT

In ra tổng các phần tử ở vị trí chẵn trong mảng

Ví dụ:

Input
3
2 1 3
Output
1
Giải thích ví dụ mẫu:
  • Ví dụ Input: 3 2 1 3

  • Giải thích: Phần tử ở vị trí chẵn (tính từ 0) là 1, do đó tổng là 1. <br>

Bài toán yêu cầu tính tổng các phần tử có chỉ số (vị trí) là số chẵn trong mảng (ví dụ: phần tử ở vị trí 0, 2, 4,...).

Hướng dẫn giải chi tiết:

  1. Khai báo và Nhập:

    • Bao gồm thư viện iostream để sử dụng nhập/xuất chuẩn (cin, cout).
    • Đọc số nguyên dương n từ đầu vào.
    • Khai báo một mảng (ví dụ: int a[1000]; hoặc cấp phát động) có kích thước đủ lớn (hoặc đúng bằng n) để lưu trữ n số nguyên tiếp theo.
    • Sử dụng vòng lặp để đọc n phần tử vào mảng vừa khai báo.
  2. Khởi tạo biến tổng:

    • Khai báo một biến kiểu số nguyên (ví dụ: long long sum) và khởi tạo giá trị ban đầu cho nó bằng 0. Biến này sẽ lưu trữ tổng các phần tử cần tính.
  3. Sử dụng Con trỏ để Duyệt và Tính Tổng:

    • Khai báo một con trỏ kiểu int (ví dụ: int* p).
    • Gán con trỏ này trỏ tới phần tử đầu tiên của mảng (phần tử ở vị trí 0). Con trỏ trỏ tới mảng a có thể được gán bằng p = a; hoặc p = &a[0];.
    • Sử dụng một vòng lặp để duyệt qua các phần tử. Thay vì dùng chỉ số i, chúng ta sẽ điều khiển vòng lặp bằng con trỏ.
    • Vòng lặp nên bắt đầu từ con trỏ p trỏ đến phần tử đầu tiên.
    • Điều kiện dừng của vòng lặp là khi con trỏ p vượt ra khỏi giới hạn của mảng (cụ thể, vượt qua địa chỉ của phần tử cuối cùng). Địa chỉ ngay sau phần tử cuối cùng của mảng a kích thước na + n. Vì vậy, điều kiện có thể là p < a + n.
    • Bên trong vòng lặp:
      • Truy cập giá trị của phần tử mà con trỏ p đang trỏ tới bằng cách sử dụng toán tử * (ví dụ: *p).
      • Cộng giá trị này vào biến sum.
      • Quan trọng: Để chỉ xét các phần tử ở vị trí chẵn (0, 2, 4, ...), sau khi xử lý phần tử hiện tại mà p đang trỏ tới, ta cần tăng con trỏ lên 2 vị trí để nhảy tới phần tử chẵn tiếp theo. Sử dụng phép toán con trỏ: p = p + 2; hoặc gọn hơn là p += 2;.
    • Vòng lặp sẽ tự động chỉ duyệt qua các địa chỉ tương ứng với các chỉ số 0, 2, 4, ... cho đến khi con trỏ vượt qua cuối mảng.
  4. In kết quả:

    • Sau khi vòng lặp kết thúc, giá trị của biến sum chính là tổng các phần tử ở vị trí chẵn.
    • Sử dụng cout để in giá trị của sum ra màn hình.

Tóm tắt logic vòng lặp dùng con trỏ:

// ... (khai báo mảng a, đọc n, đọc mảng a, khởi tạo sum = 0)

int* p = a; // Con trỏ p trỏ đến phần tử đầu tiên (a[0])

// Lặp khi con trỏ còn nằm trong giới hạn của mảng a
while (p < a + n) {
    // Cộng giá trị tại vị trí con trỏ đang trỏ tới vào tổng
    sum += *p;

    // Nhảy tới phần tử kế tiếp ở vị trí chẵn (tăng con trỏ 2 vị trí)
    p += 2;
}

// ... (in sum)

Bạn có thể chuyển đổi vòng while trên thành vòng for cho gọn hơn nếu muốn.

Đây là các bước cần thực hiện để giải bài toán sử dụng con trỏ. Hãy dựa vào hướng dẫn này để viết mã C++ hoàn chỉnh của bạn. Chú ý cách sử dụng toán tử * để lấy giá trị tại địa chỉ con trỏ trỏ tới và phép toán += với con trỏ để di chuyển con trỏ.

Làm thêm nhiều bài tập miễn phí tại đây

Comments

There are no comments at the moment.