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 giaTri = 100;
    int* conTro = &giaTri;
    int** ctCt = &conTro;

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

    cout << "\n--- Qua con tro cap 1 (conTro) ---" << endl;
    cout << "Gia tri cua 'conTro' (dia chi cua 'giaTri'): " << conTro << endl;
    cout << "Gia tri tai dia chi ma 'conTro' tro toi (*conTro): " << *conTro << endl;
    cout << "Dia chi cua 'conTro': " << &conTro << endl;

    cout << "\n--- Qua con tro cap 2 (ctCt) ---" << endl;
    cout << "Gia tri cua 'ctCt' (dia chi cua 'conTro'): " << ctCt << endl;
    cout << "Gia tri tai dia chi ma 'ctCt' tro toi (*ctCt): " << *ctCt << " (chinh la 'conTro')" << endl;
    cout << "Gia tri tai dia chi ma con tro '*ctCt' (tuc la 'conTro') tro toi (**ctCt): " << **ctCt << " (chinh la 'giaTri')" << endl;

    **ctCt = 200;
    cout << "\n--- Sau khi thay doi gia tri qua **ctCt ---" << endl;
    cout << "Gia tri moi cua 'giaTri': " << giaTri << endl;

    return 0;
}

Output:

Dia chi cua 'giaTri': 0x...
Gia tri cua 'giaTri': 100

--- Qua con tro cap 1 (conTro) ---
Gia tri cua 'conTro' (dia chi cua 'giaTri'): 0x...
Gia tri tai dia chi ma 'conTro' tro toi (*conTro): 100
Dia chi cua 'conTro': 0x...

--- Qua con tro cap 2 (ctCt) ---
Gia tri cua 'ctCt' (dia chi cua 'conTro'): 0x...
Gia tri tai dia chi ma 'ctCt' tro toi (*ctCt): 0x... (chinh la 'conTro')
Gia tri tai dia chi ma con tro '*ctCt' (tuc la 'conTro') tro toi (**ctCt): 100 (chinh la 'giaTri')

--- Sau khi thay doi gia tri qua **ctCt ---
Gia tri moi cua 'giaTri': 200

Giải thích:

  • Chúng ta có biến giaTri.
  • conTro là con trỏ cấp 1, nó giữ địa chỉ của giaTri.
  • ctCt là con trỏ cấp 2, nó giữ địa chỉ của conTro.
  • Khi dùng *ctCt, chúng ta truy cập đến nội dung tại địa chỉ mà ctCt giữ. Nội dung này chính là giá trị của conTro (địa chỉ của giaTri).
  • Khi dùng **ctCt, chúng ta truy cập đến nội dung tại địa chỉ mà (*ctCt) giữ. Vì (*ctCt)conTro (địa chỉ của giaTri), nên **ctCt truy cập đến nội dung tại địa chỉ của giaTri, tức là giá trị của giaTri.
  • Việc gán **ctCt = 200; cũng tương đương với *conTro = 200; hoặc giaTri = 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>

void taoGanCt(int** diaChiCt) {
    int* giaTriMoi = new int(99);
    *diaChiCt = giaTriMoi;
}

int main() {
    int* ctCuaToi = nullptr;
    cout << "ctCuaToi truoc khi goi ham: " << ctCuaToi << endl;

    taoGanCt(&ctCuaToi);

    cout << "ctCuaToi sau khi goi ham: " << ctCuaToi << endl;
    if (ctCuaToi != nullptr) {
        cout << "*ctCuaToi sau khi goi ham: " << *ctCuaToi << endl;
        delete ctCuaToi;
        ctCuaToi = nullptr;
    }

    return 0;
}

Output:

ctCuaToi truoc khi goi ham: 0x0
ctCuaToi sau khi goi ham: 0x...
*ctCuaToi sau khi goi ham: 99

Giải thích:

  • Hàm taoGanCt 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, &ctCuaToi là địa chỉ của con trỏ ctCuaToi, và nó có kiểu int**.
  • Bên trong hàm, *diaChiCt truy cập đến chính biến ctCuaToi (vì diaChiCt trỏ tới ctCuaToi).
  • Khi chúng ta gán *diaChiCt = giaTriMoi;, chúng ta thực sự đang thay đổi giá trị của biến ctCuaToi trong main, làm cho nó trỏ đến địa chỉ của giaTriMoi thay vì nullptr.
  • Nếu chỉ truyền ctCuaToi vào hàm (dưới dạng int* p), 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 ctCuaToi 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>

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

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

int main() {
    int (*pOp)(int, int);

    pOp = tong;
    int kq1 = pOp(10, 5);
    cout << "Su dung con tro, ket qua tong(10, 5): " << kq1 << endl;

    pOp = hieu;
    int kq2 = pOp(10, 5);
    cout << "Su dung con tro, ket qua hieu(10, 5): " << kq2 << endl;

    return 0;
}

Output:

Su dung con tro, ket qua tong(10, 5): 15
Su dung con tro, ket qua hieu(10, 5): 5

Giải thích:

  • Chúng ta khai báo pOp 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.
  • pOp = tong; gán địa chỉ của hàm tong cho con trỏ.
  • pOp(10, 5); gọi hàm mà pOp đ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 hieu cho cùng con trỏ pOp, và lần gọi tiếp theo sẽ thực thi hàm hieu.

Ứ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>

int cong(int a, int b) { return a + b; }
int tru(int a, int b) { return a - b; }
int nhan(int a, int b) { return a * b; }
int chia(int a, int b) {
    if (b == 0) {
        cerr << "Loi: Chia cho 0!" << endl;
        return 0;
    }
    return a / b;
}

void thucHienPhepTinh(int x, int y, int (*op)(int, int)) {
    cout << "Thuc hien phep tinh voi " << x << " va " << y << endl;
    int kq = op(x, y);
    cout << "Ket qua: " << kq << endl;
}

int main() {
    thucHienPhepTinh(20, 10, cong);
    cout << endl;

    thucHienPhepTinh(20, 10, tru);
    cout << endl;

    thucHienPhepTinh(20, 10, nhan);
    cout << endl;

    thucHienPhepTinh(20, 10, chia);
    cout << endl;

    thucHienPhepTinh(20, 0, chia);
    return 0;
}

Output:

Thuc hien phep tinh voi 20 va 10
Ket qua: 30

Thuc hien phep tinh voi 20 va 10
Ket qua: 10

Thuc hien phep tinh voi 20 va 10
Ket qua: 200

Thuc hien phep tinh voi 20 va 10
Ket qua: 2

Thuc hien phep tinh voi 20 va 0
Loi: Chia cho 0!
Ket qua: 0

Giải thích:

  • Hàm thucHienPhepTinh 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 cong, tru, v.v.
  • Khi gọi thucHienPhepTinh, chúng ta truyền địa chỉ của hàm cong, tru, nhan, hoặc chia.
  • Bên trong thucHienPhepTinh, dòng int kq = op(x, y); gọi hàm tương ứng thông qua con trỏ op.
  • Điều này cho thấy sự linh hoạt: cùng một hàm thucHienPhepTinh 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.

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ỏ:

#include <iostream>

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    int a[1000];
    for (int i = 0; i < n; ++i) {
        cin >> a[i];
    }

    long long tong = 0;
    // Theo ví dụ, "vị trí chẵn" nghĩa là 1-indexed chẵn: a[1], a[3], ... (0-indexed)
    int* p = a + 1;

    while (p < a + n) {
        tong += *p;
        p += 2;
    }

    cout << tong << endl;

    return 0;
}

Output (với input mẫu):

1

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.