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>

void tangGiaTriTheoGiaTri(int x) {
    x = x + 1; // Chi thay doi ban sao cua x
    cout << "Ben trong ham (theo gia tri): x = " << x << endl;
}

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

    tangGiaTriTheoGiaTri(soNguyen); // Truyen ban sao cua soNguyen

    cout << "Sau khi goi ham: soNguyen = " << soNguyen << endl; // soNguyen van la 10

    return 0;
}

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>

void tangGiaTriTheoDiaChi(int* p) { // Ham nhan mot con tro toi int
    if (p != nullptr) { // Kiem tra con tro co hop le khong (rat quan trong!)
        (*p)++; // Su dung toan tu dereference (*) de truy cap va thay doi gia tri tai dia chi ma p tro toi
        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 soNguyen = 10;
    cout << "Truoc khi goi ham: soNguyen = " << soNguyen << endl;

    tangGiaTriTheoDiaChi(&soNguyen); // Truyen DIA CHI cua soNguyen (su dung toan tu &)

    cout << "Sau khi goi ham: soNguyen = " << soNguyen << endl; // soNguyen da duoc tang len 11!

    return 0;
}

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>

// Ham nhan mot con tro toi phan tu dau cua mang va kich thuoc mang
void inMang(int* mang, int kichThuoc) {
    cout << "Cac phan tu cua mang (qua con tro): ";
    for (int i = 0; i < kichThuoc; ++i) {
        // Chung ta co the dung arr[i] (cu phap mang) hoac *(arr + i) (cu phap con tro)
        // Ca hai deu tro den cung mot vi tri nho: vi tri bat dau + i * kich thuoc phan tu
        cout << *(mang + i) << (i < kichThuoc - 1 ? ", " : "");
    }
    cout << endl;
}

int main() {
    int mangSo[] = {10, 20, 30, 40, 50};
    int kichThuoc = sizeof(mangSo) / sizeof(mangSo[0]); // Tinh kich thuoc mang

    inMang(mangSo, kichThuoc); // Ten mang 'mangSo' suy bien thanh con tro toi phan tu dau mangSo[0]

    return 0;
}

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> // Can cho bad_alloc

// Ham tao mot so nguyen tren bo nho dong va tra ve con tro toi no
int* taoSoNguyenDong(int giaTri) {
    int* p = nullptr; // Khoi tao con tro null
    try {
        p = new int; // Cap phat bo nho dong cho mot int
        *p = giaTri; // Gan gia tri cho vung nho vua cap phat
        cout << "Ben trong ham: Da cap phat va gan gia tri " << giaTri << " tai dia chi " << p << endl;
    } catch (const bad_alloc& e) {
        // Xu ly truong hop cap phat bo nho that bai
        cerr << "Loi cap phat bo nho: " << e.what() << endl;
        // p van la nullptr hoac co the xu ly loi khac tuy vao yeu cau
    }
    return p; // Tra ve con tro toi vung nho dong
}

int main() {
    int* soDong = taoSoNguyenDong(123); // Goi ham va nhan con tro

    if (soDong != nullptr) { // Kiem tra xem viec cap phat co thanh cong khong
        cout << "Trong main: Gia tri tai dia chi " << soDong << " la " << *soDong << endl;

        // **** RAT QUAN TRONG: GIAI PHONG BO NHO DA CAP PHAT DONG ****
        delete soDong;
        soDong = nullptr; // Dat con tro ve nullptr sau khi giai phong de tranh loi "dangling pointer"
        cout << "Trong main: Da giai phong bo nho tai dia chi " << soDong << endl; // soDong bay gio la 0x0 hoac tuong duong
    } else {
        cout << "Trong main: Khong the tao so nguyen dong do loi cap phat." << endl;
    }

    return 0;
}

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>

// Hai ham co cung kieu tra ve va danh sach tham so
int cong(int a, int b) {
    return a + b;
}

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

int main() {
    // Khai bao con tro ham: nhan 2 int, tra ve int
    int (*ptrHam)(int, int);

    // Gan con tro ham toi dia chi cua ham cong
    ptrHam = cong; // Ten ham khong co ngoac () tu dong suy bien thanh dia chi cua ham

    // Goi ham cong thong qua con tro ham
    cout << "Ket qua cong (qua con tro): " << ptrHam(10, 5) << endl; // Hoac (*ptrHam)(10, 5);

    // Gan con tro ham toi dia chi cua ham tru
    ptrHam = tru;

    // Goi ham tru thong qua con tro ham
    cout << "Ket qua tru (qua con tro): " << ptrHam(10, 5) << endl;

    return 0;
}

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.