Bài 31.3: Con trỏ và hàm trong C++

Chào mừng trở lại với series C++! Chúng ta đã cùng nhau đi qua những kiến thức cơ bản về con trỏ. Hôm nay, chúng ta sẽ kết hợp con trỏ với một khái niệm cốt lõi khác của lập trình: Hàm. Việc sử dụng con trỏ trong hàm mở ra nhiều khả năng mạnh mẽ và là chìa khóa để hiểu sâu hơn về cách C++ quản lý bộ nhớ và dữ liệu.

1. Truyền Tham Số Bằng Con Trỏ: Mở Khóa Khả Năng Thay Đổi Dữ Liệu Gốc

Trước khi tìm hiểu về con trỏ, khi bạn truyền một biến thông thường vào hàm, C++ mặc định sử dụng cơ chế truyền theo giá trị (pass-by-value). Điều này có nghĩa là hàm nhận một bản sao của giá trị gốc. Mọi thay đổi bên trong hàm sẽ chỉ ảnh hưởng đến bản sao đó, chứ không phải biến gốc bên ngoài.

Đây là ví dụ nhắc lại:

#include <iostream>
using namespace std;

void tangGT(int x) {
    cout << "Ben trong ham (theo gia tri): x = " << (x + 1) << endl;
}

int main() {
    int so = 10;
    cout << "Truoc khi goi ham: so = " << so << endl;

    tangGT(so);

    cout << "Sau khi goi ham: so = " << so << endl;

    return 0;
}

Output:

Truoc khi goi ham: so = 10
Ben trong ham (theo gia tri): x = 11
Sau khi goi ham: so = 10

Giải thích: Biến soNguyen vẫn giữ nguyên giá trị 10 sau khi hàm tangGiaTriTheoGiaTri được gọi, bởi vì hàm chỉ làm việc với một bản sao của nó.

Tuy nhiên, có những lúc chúng ta muốn hàm thực sự thay đổi giá trị của biến gốc. Đây chính là lúc con trỏ thể hiện sức mạnh đặc biệt của nó. Bằng cách truyền địa chỉ của biến (thông qua con trỏ), hàm có thể truy cập và thao tác trực tiếp trên vùng nhớ của biến gốc. Cơ chế này được gọi là truyền theo địa chỉ (pass-by-pointer).

Hãy xem ví dụ sử dụng con trỏ:

#include <iostream>
using namespace std;

void tangDC(int* p) {
    if (p != nullptr) {
        (*p)++;
        cout << "Ben trong ham (theo dia chi): gia tri tai " << p << " la " << (*p) << endl;
    } else {
        cerr << "Loi: Con tro null duoc truyen vao ham." << endl;
    }
}

int main() {
    int so = 10;
    cout << "Truoc khi goi ham: so = " << so << endl;

    tangDC(&so);

    cout << "Sau khi goi ham: so = " << so << endl;

    return 0;
}

Output:

Truoc khi goi ham: so = 10
Ben trong ham (theo dia chi): gia tri tai 0x... la 11
Sau khi goi ham: so = 11

Giải thích:

  • Hàm tangGiaTriTheoDiaChi nhận một tham số kiểu int* (con trỏ tới số nguyên).
  • Khi gọi hàm, chúng ta truyền &soNguyen, tức là địa chỉ của biến soNguyen.
  • Bên trong hàm, p giờ đây chứa địa chỉ của soNguyen.
  • Chúng ta sử dụng (*p) để truy cập giá trị tại địa chỉ đó (đây gọi là dereferencing). Khi chúng ta thực hiện (*p)++, chúng ta thực sự đang tăng giá trị của biến soNguyen gốc.
  • Việc kiểm tra if (p != nullptr)thực hành tốt, tránh lỗi khi cố gắng truy cập vào một địa chỉ không hợp lệ.

Đây là một trong những ứng dụng quan trọng nhất của con trỏ khi làm việc với hàm: cho phép hàm thay đổi các biến được định nghĩa bên ngoài phạm vi của nó.

2. Truyền Mảng vào Hàm Sử Dụng Con Trỏ

Trong C++, khi bạn truyền tên một mảng vào hàm, nó thường suy biến (decay) thành một con trỏ tới phần tử đầu tiên của mảng đó. Điều này có nghĩa là hàm nhận được địa chỉ bắt đầu của mảng, chứ không phải toàn bộ mảng (trừ một vài trường hợp đặc biệt như truyền mảng cố định kích thước theo tham chiếu). Do đó, làm việc với mảng trong hàm thường liên quan chặt chẽ đến con trỏ.

Bạn có thể khai báo tham số hàm nhận mảng theo hai cách phổ biến, và chúng thường có ý nghĩa tương tự:

  1. Sử dụng cú pháp mảng: void tenHam(kieuDuLieu tenMang[], ...)
  2. Sử dụng cú pháp con trỏ: void tenHam(kieuDuLieu* tenConTro, ...)

Ví dụ minh họa việc truyền mảng và truy cập các phần tử bằng con trỏ:

#include <iostream>
using namespace std;

void inM(int* a, int n) {
    cout << "Cac phan tu cua mang (qua con tro): ";
    for (int i = 0; i < n; ++i) {
        cout << *(a + i) << (i < n - 1 ? ", " : "");
    }
    cout << endl;
}

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int n = sizeof(arr) / sizeof(arr[0]);

    inM(arr, n);

    return 0;
}

Output:

Cac phan tu cua mang (qua con tro): 10, 20, 30, 40, 50

Giải thích:

  • Hàm inMang được khai báo nhận int* mang. Khi gọi inMang(mangSo, kichThuoc), tên mảng mangSo tự động được chuyển thành địa chỉ của phần tử đầu tiên (&mangSo[0]), và địa chỉ này được gán cho con trỏ mang trong hàm.
  • Bên trong hàm, chúng ta có thể truy cập các phần tử bằng cú pháp con trỏ *(mang + i). Điều này có nghĩa là "đi từ địa chỉ mang đi i bước (mỗi bước có kích thước bằng kiểu dữ liệu) và lấy giá trị tại đó". Cú pháp mảng mang[i] chỉ là một dạng "đường tắt" (syntactic sugar) cho *(mang + i).

Điều này giải thích tại sao khi truyền mảng vào hàm trong C++, bạn thường phải truyền thêm kích thước của mảng, vì con trỏ nhận được chỉ biết địa chỉ bắt đầu chứ không "nhớ" kích thước ban đầu của mảng đó.

3. Trả Về Con Trỏ từ Hàm

Hàm cũng có thể trả về một con trỏ. Điều này cực kỳ hữu ích khi bạn cần một hàm để tạo một vùng nhớ (thường là cấp phát động) và trả về địa chỉ của vùng nhớ đó để sử dụng ở phần khác của chương trình.

Tuy nhiên, khi trả về con trỏ, bạn cần hết sức cẩn trọng về phạm vi (scope) của biến mà con trỏ đó trỏ tới. Tuyệt đối không được trả về con trỏ tới một biến cục bộ (local variable) được khai báo bên trong hàm. Biến cục bộ sẽ bị hủy khi hàm kết thúc, khiến con trỏ trở thành "dangling pointer" (con trỏ lơ lửng) và việc truy cập thông qua nó sẽ dẫn đến hành vi không xác định (undefined behavior) - thường là lỗi chương trình.

Ứng dụng phổ biến và an toàn nhất của việc trả về con trỏ từ hàm là khi bạn cấp phát bộ nhớ động bên trong hàm và trả về con trỏ tới vùng nhớ đó.

Ví dụ trả về con trỏ tới bộ nhớ cấp phát động:

#include <iostream>
#include <new>
using namespace std;

int* taoSo(int gT) {
    int* p = nullptr;
    try {
        p = new int;
        *p = gT;
        cout << "Ben trong ham: Da cap phat va gan gia tri " << gT << " tai dia chi " << p << endl;
    } catch (const bad_alloc& e) {
        cerr << "Loi cap phat bo nho: " << e.what() << endl;
    }
    return p;
}

int main() {
    int* pSo = taoSo(123);

    if (pSo != nullptr) {
        cout << "Trong main: Gia tri tai dia chi " << pSo << " la " << *pSo << endl;

        delete pSo;
        pSo = nullptr;
        cout << "Trong main: Da giai phong bo nho tai dia chi " << pSo << endl;
    } else {
        cout << "Trong main: Khong the tao so nguyen dong do loi cap phat." << endl;
    }

    return 0;
}

Output:

Ben trong ham: Da cap phat va gan gia tri 123 tai dia chi 0x...
Trong main: Gia tri tai dia chi 0x... la 123
Trong main: Da giai phong bo nho tai dia chi 0x0

Giải thích:

  • Hàm taoSoNguyenDong sử dụng new int để cấp phát bộ nhớ động trên heap. Vùng nhớ này tồn tại cho đến khi bạn giải phóng nó một cách rõ ràng.
  • Con trỏ p lưu địa chỉ của vùng nhớ đó và được trả về bởi hàm.
  • Trong main, chúng ta nhận con trỏ này vào biến soDong.
  • Quan trọng nhất, chúng ta phải sử dụng delete soDong; để giải phóng vùng nhớ đã cấp phát khi không còn dùng nữa, tránh rò rỉ bộ nhớ (memory leak).
  • Đặt soDong = nullptr; sau khi giải phóng là một thực hành tốt để tránh việc vô tình sử dụng lại con trỏ đã bị giải phóng (dangling pointer).

4. Con Trỏ Hàm (Function Pointers)

Một khái niệm nâng cao hơn một chút nhưng rất mạnh mẽcon trỏ hàm. Giống như con trỏ có thể lưu địa chỉ của biến, con trỏ hàm có thể lưu địa chỉ của một hàm. Điều này cho phép bạn truyền hàm như một tham số cho hàm khác, lưu trữ danh sách các hàm, hoặc thực hiện các callback.

Cú pháp khai báo con trỏ hàm có vẻ hơi lạ lúc đầu:

kieu_tra_ve (*ten_con_tro)(tham_so1_kieu1, tham_so2_kieu2, ...);

Nó giống như khai báo một hàm bình thường, nhưng tên hàm được thay thế bằng (*ten_con_tro), và toàn bộ phần (*ten_con_tro) được đặt trong ngoặc đơn để phân biệt với hàm trả về con trỏ.

Ví dụ về cách sử dụng con trỏ hàm:

#include <iostream>
using namespace std;

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

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

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

    pFunc = cong;
    cout << "Ket qua cong (qua con tro): " << pFunc(10, 5) << endl;

    pFunc = tru;
    cout << "Ket qua tru (qua con tro): " << pFunc(10, 5) << endl;

    return 0;
}

Output:

Ket qua cong (qua con tro): 15
Ket qua tru (qua con tro): 5

Giải thích:

  • Chúng ta khai báo int (*ptrHam)(int, int);. Điều này tạo ra một biến ptrHam có khả năng lưu trữ địa chỉ của bất kỳ hàm nào nhận hai đối số int và trả về một int.
  • ptrHam = cong; gán địa chỉ của hàm cong cho ptrHam. Lưu ý rằng khi gán địa chỉ hàm, chúng ta chỉ sử dụng tên hàm cong, không có ngoặc đơn ().
  • ptrHam(10, 5); hoặc (*ptrHam)(10, 5); là cách gọi hàm cong (hoặc tru sau khi gán lại) thông qua con trỏ ptrHam.

Con trỏ hàm rất hữu ích trong các tình huống cần sự linh hoạt trong việc chọn hàm để thực thi, chẳng hạn như trong các hàm phân loại dữ liệu (sort) mà bạn muốn cung cấp một hàm so sánh tùy chỉnh, hoặc trong các hệ thống xử lý sự kiện.

Bài tập ví dụ: C++ Bài 15.A3: Tìm hướng

Tìm hướng

FullHouse Dev hiện đang đối mặt với hướng bắc. Mỗi giây, anh ấy xoay đúng 90 độ theo chiều kim đồng hồ. Hãy tìm hướng mà FullHouse Dev đang đối mặt sau đúng X giây.

Lưu ý: Chỉ có 4 hướng: Bắc, Đông, Nam, Tây (theo thứ tự chiều kim đồng hồ).

Dữ liệu vào

  • Dòng đầu tiên chứa T, số lượng bộ test.
  • Mỗi bộ test chứa một số nguyên X.

Dữ liệu ra

  • Với mỗi bộ test, in ra hướng mà FullHouse Dev đang đối mặt sau đúng X giây.

Ràng buộc

  • 1 ≤ T ≤ 100
  • 1 ≤ X ≤ 1000

Ví dụ

Dữ liệu vào:

3
1
3
6

Dữ liệu ra:

East
West
South

Giải thích

FullHouse Dev ban đầu đối mặt với hướng Bắc.

Bộ test 1: Sau 1 giây, anh ấy xoay 90 độ theo chiều kim đồng hồ và giờ đối mặt với hướng Đông.

Bộ test 2:

  • Hướng sau 1 giây: Đông
  • Hướng sau 2 giây: Nam
  • Hướng sau 3 giây: Tây

Bộ test 3: Sau 6 giây, anh ấy đối mặt với hướng Nam. Chào bạn, đây là hướng dẫn giải bài tập "Tìm hướng" bằng C++ theo yêu cầu của bạn:

Phân tích bài toán:

  1. Trạng thái ban đầu: FullHouse Dev đối mặt hướng Bắc.
  2. Hành động: Mỗi giây, xoay 90 độ theo chiều kim đồng hồ.
  3. Yêu cầu: Tìm hướng đối mặt sau X giây.
  4. Các hướng: Bắc, Đông, Nam, Tây (theo thứ tự chiều kim đồng hồ).

Nhận xét và tìm quy luật:

  • Xuất phát: Bắc (0 giây, 0 lần xoay)
  • Sau 1 giây: Bắc -> Đông (1 lần xoay 90 độ)
  • Sau 2 giây: Đông -> Nam (2 lần xoay 90 độ tổng cộng)
  • Sau 3 giây: Nam -> Tây (3 lần xoay 90 độ tổng cộng)
  • Sau 4 giây: Tây -> Bắc (4 lần xoay 90 độ tổng cộng)
  • Sau 5 giây: Bắc -> Đông (5 lần xoay 90 độ tổng cộng)

Ta thấy hướng của FullHouse Dev lặp lại sau mỗi 4 giây (tương ứng với 4 lần xoay 90 độ, tức 360 độ). Điều này có nghĩa là hướng đối mặt sau X giây chỉ phụ thuộc vào số dư của X khi chia cho 4.

  • Nếu X % 4 == 0: Hướng sẽ giống hướng ban đầu (Bắc).
  • Nếu X % 4 == 1: Hướng sẽ là hướng sau 1 giây (Đông).
  • Nếu X % 4 == 2: Hướng sẽ là hướng sau 2 giây (Nam).
  • Nếu X % 4 == 3: Hướng sẽ là hướng sau 3 giây (Tây).

Hướng dẫn giải bằng C++:

  1. Đọc số lượng bộ test: Bạn cần đọc số nguyên T từ đầu vào.
  2. Lặp qua các bộ test: Sử dụng vòng lặp (ví dụ: while hoặc for) để xử lý T lần.
  3. Trong mỗi bộ test:
    • Đọc số giây X từ đầu vào.
    • Tính số dư của X khi chia cho 4: int remainder = X % 4;
    • Sử dụng cấu trúc điều kiện (ví dụ: if-else if-else hoặc switch) để kiểm tra giá trị của remainder.
    • Dựa vào giá trị của remainder (0, 1, 2, hoặc 3), in ra hướng tương ứng:
      • remainder == 0: "North"
      • remainder == 1: "East"
      • remainder == 2: "South"
      • remainder == 3: "West"
    • Đảm bảo in ra một dấu xuống dòng sau mỗi kết quả.

Các thư viện C++ cần thiết:

  • Sử dụng <iostream> để đọc dữ liệu từ bàn phím (cin) và in kết quả ra màn hình (cout).

Lưu ý:

  • Bạn chỉ cần tính toán số dư, không cần mô phỏng từng bước xoay.
  • Sử dụng các cấu trúc điều khiển cơ bản của C++ để chọn hướng in ra.
  • Nhớ xử lý tất cả T bộ test.

Bạn có thể sử dụng cin, cout, toán tử %, và các lệnh if/else if/else hoặc switch để hoàn thành bài giải. Code sẽ khá ngắn gọn vì chỉ cần một phép tính và vài lệnh điều kiện.

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

Comments

There are no comments at the moment.