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>
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ể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>
// 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ậ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> // 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ụ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>
// 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ế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