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

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ểuint*
(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ếnsoNguyen
. - Bên trong hàm,
p
giờ đây chứa địa chỉ củasoNguyen
. - 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ếnsoNguyen
gốc. - Việc kiểm tra
if (p != nullptr)
là 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ự:
- Sử dụng cú pháp mảng:
void tenHam(kieuDuLieu tenMang[], ...)
- 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ậnint* mang
. Khi gọiinMang(mangSo, kichThuoc)
, tên mảngmangSo
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
đii
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ảngmang[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ụngnew 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ếnsoDong
. - 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ẽ là 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ếnptrHam
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ộtint
. ptrHam = cong;
gán địa chỉ của hàmcong
choptrHam
. Lưu ý rằng khi gán địa chỉ hàm, chúng ta chỉ sử dụng tên hàmcong
, không có ngoặc đơn()
.ptrHam(10, 5);
hoặc(*ptrHam)(10, 5);
là cách gọi hàmcong
(hoặctru
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:
- Trạng thái ban đầu: FullHouse Dev đối mặt hướng Bắc.
- Hành động: Mỗi giây, xoay 90 độ theo chiều kim đồng hồ.
- Yêu cầu: Tìm hướng đối mặt sau
X
giây. - 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++:
- Đọc số lượng bộ test: Bạn cần đọc số nguyên
T
từ đầu vào. - Lặp qua các bộ test: Sử dụng vòng lặp (ví dụ:
while
hoặcfor
) để xử lýT
lần. - 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ặcswitch
) để kiểm tra giá trị củaremainder
. - 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 số giây
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.
Comments