Bài 31.1: Khái niệm con trỏ và địa chỉ trong C++

Bài 31.1: Khái niệm con trỏ và địa chỉ trong C++
Chào mừng trở lại với series blog về C++! Hôm nay, chúng ta sẽ cùng nhau giải mã một trong những khái niệm mạnh mẽ và cũng là một trong những chủ đề gây bối rối nhất cho người mới bắt đầu: con trỏ (pointers) và địa chỉ (addresses) trong bộ nhớ. Đừng lo lắng, chúng ta sẽ đi từng bước một cách chi tiết để hiểu rõ bản chất của chúng và cách sử dụng chúng một cách hiệu quả.
Hiểu về con trỏ và địa chỉ không chỉ giúp bạn viết code C++ mạnh mẽ hơn mà còn mở ra cánh cửa để khám phá những kỹ thuật lập trình nâng cao như quản lý bộ nhớ động, làm việc với cấu trúc dữ liệu phức tạp, và hiểu sâu hơn cách chương trình tương tác với phần cứng máy tính.
Bộ Nhớ Máy Tính và Địa Chỉ
Hãy tưởng tượng bộ nhớ RAM của máy tính như một dãy các ngăn kéo, mỗi ngăn kéo có một địa chỉ duy nhất. Khi bạn khai báo một biến trong chương trình C++, thực chất bạn đang yêu cầu hệ điều hành cấp phát một hoặc nhiều ngăn kéo trong bộ nhớ để lưu trữ giá trị của biến đó. Mỗi ngăn kéo (hay nhóm các ngăn kéo) này sẽ có một địa chỉ cụ thể.
Ví dụ, khi bạn khai báo int age = 30;
, hệ thống sẽ tìm một vùng nhớ đủ lớn để chứa một số nguyên (thường là 4 byte trên các hệ thống 32/64-bit), gán cho nó cái tên age
, và lưu giá trị 30 vào đó. Vùng nhớ đó nằm ở một địa chỉ cụ thể trong RAM.
Toán Tử Lấy Địa Chỉ: &
Trong C++, chúng ta có một toán tử đặc biệt gọi là toán tử lấy địa chỉ, ký hiệu là &
. Khi bạn đặt toán tử &
trước tên một biến, nó sẽ trả về địa chỉ bộ nhớ nơi biến đó được lưu trữ.
Hãy xem một ví dụ đơn giản:
#include <iostream>
int main() {
int age = 30;
double salary = 50000.50;
string name = "Alice";
cout << "Gia tri cua bien age: " << age << endl;
cout << "Dia chi cua bien age: " << &age << endl; // Su dung & de lay dia chi
cout << "-----------------------" << endl;
cout << "Gia tri cua bien salary: " << salary << endl;
cout << "Dia chi cua bien salary: " << &salary << endl; // Lay dia chi cua salary
cout << "-----------------------" << endl;
cout << "Gia tri cua bien name: " << name << endl;
cout << "Dia chi cua bien name: " << &name << endl; // Lay dia chi cua name
return 0;
}
Giải thích:
- Chúng ta khai báo ba biến với các kiểu dữ liệu khác nhau:
int
,double
, vàstring
. - Khi in ra
&age
,&salary
, và&name
, chương trình sẽ hiển thị các giá trị (thường ở dạng hệ thập lục phân - hexadecimal) đại diện cho địa chỉ bộ nhớ nơi các biếnage
,salary
, vàname
được lưu trữ. - Các địa chỉ này sẽ khác nhau mỗi lần bạn chạy chương trình vì hệ điều hành cấp phát bộ nhớ động cho mỗi lần thực thi.
Toán tử &
là bước đầu tiên để làm việc với con trỏ, vì con trỏ chính là biến dùng để lưu trữ những địa chỉ bộ nhớ này.
Con Trỏ là Gì?
Nếu địa chỉ bộ nhớ là "địa chỉ nhà" của một biến, thì con trỏ chính là một biến đặc biệt được thiết kế để lưu trữ địa chỉ nhà đó.
Thay vì lưu trữ một giá trị thông thường (như số 30, hay chuỗi "Alice"), một biến con trỏ lưu trữ một địa chỉ bộ nhớ. Nó "trỏ" (point) đến vị trí trong bộ nhớ nơi có một biến khác đang lưu trữ dữ liệu thực tế.
Khai Báo Con Trỏ
Để khai báo một biến con trỏ, bạn sử dụng ký hiệu dấu sao *
đặt sau kiểu dữ liệu mà con trỏ đó sẽ trỏ tới.
Cú pháp chung:
kieu_du_lieu* ten_con_tro;
Ví dụ:
int* ptr_age; // Khai bao mot con tro co the tro den mot bien kieu int
double* ptr_salary; // Khai bao mot con tro co the tro den mot bien kieu double
string* ptr_name; // Khai bao mot con tro co the tro den mot bien kieu string
Lưu ý quan trọng: Kiểu dữ liệu trước dấu *
(int
, double
, string
) không phải là kiểu của con trỏ mà là kiểu dữ liệu của biến mà con trỏ đó sẽ trỏ tới. Con trỏ tự nó thường có kích thước cố định trên một kiến trúc máy tính nhất định (ví dụ: 4 byte trên hệ 32-bit, 8 byte trên hệ 64-bit) vì nó chỉ lưu trữ một địa chỉ.
Gán Địa Chỉ cho Con Trỏ
Sau khi khai báo con trỏ, nó chưa "trỏ" đến đâu cả (hoặc trỏ đến một địa chỉ ngẫu nhiên, điều này nguy hiểm!). Để con trỏ trỏ đến một biến cụ thể, chúng ta dùng toán tử lấy địa chỉ &
mà chúng ta đã học và gán địa chỉ đó cho con trỏ.
#include <iostream>
int main() {
int count = 100; // Bien kieu int
int* ptr_count = &count; // Khai bao con tro ptr_count va gan no bang dia chi cua bien count
cout << "Gia tri cua bien count: " << count << endl;
cout << "Dia chi cua bien count: " << &count << endl;
cout << "-----------------------" << endl;
// Gia tri cua con tro ptr_count chinh la dia chi cua bien count
cout << "Gia tri cua con tro ptr_count: " << ptr_count << endl;
// Dia chi cua chinh con tro ptr_count (no cung la mot bien, nen co dia chi rieng)
cout << "Dia chi cua con tro ptr_count: " << &ptr_count << endl;
return 0;
}
Giải thích:
- Biến
count
được lưu trữ ở một địa chỉ nào đó trong bộ nhớ. - Biến
ptr_count
là một biến con trỏ kiểuint*
. - Câu lệnh
int* ptr_count = &count;
vừa khai báoptr_count
vừa gán cho nó địa chỉ của biếncount
. Lúc này,ptr_count
đang "trỏ" đếncount
. - Khi in
ptr_count
, chúng ta in ra giá trị của con trỏ, và giá trị này chính là địa chỉ củacount
. - Con trỏ
ptr_count
cũng là một biến, nên nó cũng được lưu trữ ở một địa chỉ nào đó trong bộ nhớ.&ptr_count
sẽ cho bạn biết địa chỉ của chính con trỏ.
Toán Tử Truy Cập Giá Trị (Dereference): *
Okay, con trỏ đang giữ địa chỉ của một biến. Nhưng làm sao để truy cập hoặc thay đổi giá trị thực tế mà biến đó đang giữ thông qua con trỏ? Đây là lúc chúng ta cần đến toán tử truy cập giá trị hay còn gọi là toán tử dereference, cũng sử dụng ký hiệu dấu sao *
.
Lưu ý: Ký hiệu *
có hai ý nghĩa tùy ngữ cảnh:
- Khi dùng trong khai báo (
int* ptr;
): Nó chỉ ra rằngptr
là một con trỏ trỏ đến kiểuint
. - Khi dùng với một biến con trỏ đã tồn tại (
*ptr
): Nó có nghĩa là "lấy giá trị tại địa chỉ màptr
đang trỏ tới".
Hãy xem ví dụ sử dụng toán tử *
để truy cập giá trị:
#include <iostream>
int main() {
double price = 99.99; // Bien kieu double
double* ptr_price = &price; // Con tro ptr_price tro den dia chi cua bien price
cout << "Gia tri cua bien price (truy cap truc tiep): " << price << endl;
// Su dung toan tu * de lay gia tri tai dia chi ma ptr_price dang tro toi
cout << "Gia tri cua bien price (qua con tro *ptr_price): " << *ptr_price << endl;
cout << "-----------------------" << endl;
// Bay gio, chung ta thay doi gia tri cua bien price THONG QUA con tro
*ptr_price = 75.50; // Gan gia tri 75.50 vao vi tri bo nho ma ptr_price dang tro toi
cout << "Gia tri moi cua bien price (sau khi thay doi qua con tro): " << price << endl;
cout << "Gia tri moi cua bien price (qua con tro *ptr_price): " << *ptr_price << endl; // *ptr_price van truy cap dung gia tri moi
return 0;
}
Giải thích:
- Chúng ta có biến
price
và con trỏptr_price
trỏ đến nó. price
trực tiếp cho bạn giá trị 99.99.ptr_price
cho bạn địa chỉ củaprice
.*ptr_price
sử dụng toán tử*
trên con trỏptr_price
để "đi đến" địa chỉ màptr_price
đang giữ và lấy giá trị được lưu trữ ở đó (ban đầu là 99.99).- Dòng
*ptr_price = 75.50;
là một ví dụ về việc sử dụng toán tử*
để ghi dữ liệu vào địa chỉ mà con trỏ đang trỏ tới. Thao tác này thay đổi giá trị của biến gốc (price
). - Sau khi thay đổi, cả việc truy cập
price
trực tiếp lẫn truy cập qua con trỏ*ptr_price
đều cho ra giá trị mới 75.50.
Điều này chứng tỏ con trỏ không chỉ giúp đọc giá trị từ một địa chỉ mà còn có thể dùng để thay đổi giá trị tại địa chỉ đó.
Tại Sao Cần Dùng Con Trỏ?
Nghe có vẻ phức tạp hơn việc sử dụng biến thông thường, vậy tại sao C++ lại có con trỏ và tại sao chúng ta cần học về nó? Con trỏ mang lại sức mạnh và sự linh hoạt vượt trội trong nhiều trường hợp:
- Truy Cập Trực Tiếp Bộ Nhớ: Con trỏ cho phép bạn làm việc trực tiếp với các vị trí trong bộ nhớ. Điều này là nền tảng cho nhiều kỹ thuật lập trình cấp thấp và hiệu quả.
- Quản Lý Bộ Nhớ Động: Khi bạn không biết chính xác lượng bộ nhớ cần thiết cho đến khi chương trình đang chạy (ví dụ: đọc dữ liệu từ file, xử lý dữ liệu có kích thước không cố định), bạn cần cấp phát bộ nhớ động (runtime). C++ sử dụng con trỏ (
new
) để trả về địa chỉ của vùng nhớ được cấp phát động. - Truyền Tham Chiếu Hiệu Quả cho Đối Tượng Lớn: Khi bạn truyền một đối tượng lớn (ví dụ: một struct hoặc class phức tạp) vào một hàm, mặc định C++ sẽ tạo một bản sao của đối tượng đó (truyền tham trị - pass by value). Việc sao chép này có thể tốn kém về thời gian và bộ nhớ. Bằng cách truyền địa chỉ của đối tượng (truyền tham chiếu bằng con trỏ - pass by pointer), bạn chỉ truyền một giá trị nhỏ (địa chỉ), giúp cải thiện hiệu suất, đặc biệt với các hàm được gọi nhiều lần.
- Làm Việc với Mảng và Chuỗi: Trong C++, tên mảng thường được hiểu là con trỏ trỏ đến phần tử đầu tiên của mảng. Hiểu về con trỏ giúp bạn làm việc hiệu quả hơn với mảng, thực hiện các phép toán con trỏ (pointer arithmetic).
- Xây Dựng Cấu Trúc Dữ Liệu Phức Tạp: Các cấu trúc dữ liệu như danh sách liên kết (linked lists), cây (trees), đồ thị (graphs) đều sử dụng con trỏ để liên kết các "nút" (nodes) với nhau trong bộ nhớ một cách linh hoạt.
Con trỏ là công cụ mạnh mẽ nhưng cũng tiềm ẩn rủi ro nếu không được sử dụng cẩn thận (ví dụ: truy cập vào địa chỉ không hợp lệ có thể gây lỗi chương trình hoặc lỗ hổng bảo mật). Tuy nhiên, một khi đã nắm vững, chúng sẽ là trợ thủ đắc lực cho bạn.
Ví Dụ Tổng Hợp
Hãy cùng xem một ví dụ cuối cùng kết hợp các khái niệm đã học:
#include <iostream>
#include <string> // Can thiet cho string
int main() {
int quantity = 50; // Bien nguyen
double unit_cost = 10.75; // Bien so thuc
string product_name = "Laptop"; // Bien chuoi
// Khai bao cac con tro tro toi cac bien tren
int* ptr_quantity = &quantity;
double* ptr_unit_cost = &unit_cost;
string* ptr_product_name = &product_name;
cout << "--- Thong tin bien va dia chi ---" << endl;
cout << "Bien 'quantity': Gia tri = " << quantity << ", Dia chi = " << &quantity << endl;
cout << "Bien 'unit_cost': Gia tri = " << unit_cost << ", Dia chi = " << &unit_cost << endl;
cout << "Bien 'product_name': Gia tri = " << product_name << ", Dia chi = " << &product_name << endl;
cout << "\n--- Thong tin con tro ---" << endl;
// In gia tri cua con tro (chinh la dia chi ma no tro toi)
cout << "Con tro 'ptr_quantity': Gia tri (chua dia chi) = " << ptr_quantity << endl;
cout << "Con tro 'ptr_unit_cost': Gia tri (chua dia chi) = " << ptr_unit_cost << endl;
cout << "Con tro 'ptr_product_name': Gia tri (chua dia chi) = " << ptr_product_name << endl;
cout << "\n--- Truy cap gia tri qua con tro (Dereference) ---" << endl;
// Su dung toan tu * de lay gia tri tai dia chi con tro dang tro toi
cout << "Gia tri 'quantity' qua con tro *ptr_quantity: " << *ptr_quantity << endl;
cout << "Gia tri 'unit_cost' qua con tro *ptr_unit_cost: " << *ptr_unit_cost << endl;
cout << "Gia tri 'product_name' qua con tro *ptr_product_name: " << *ptr_product_name << endl;
cout << "\n--- Thay doi gia tri qua con tro ---" << endl;
// Thay doi gia tri cua bien quantity thong qua con tro ptr_quantity
*ptr_quantity = 75;
// Thay doi gia tri cua bien unit_cost thong qua con tro ptr_unit_cost
*ptr_unit_cost = 12.99;
// Thay doi gia tri cua bien product_name thong qua con tro ptr_product_name
*ptr_product_name = "Mouse";
cout << "Gia tri moi cua 'quantity': " << quantity << endl; // quantity da thay doi
cout << "Gia tri moi cua 'unit_cost': " << unit_cost << endl; // unit_cost da thay doi
cout << "Gia tri moi cua 'product_name': " << product_name << endl; // product_name da thay doi
// Kiem tra lai gia tri qua con tro sau khi thay doi
cout << "\n--- Gia tri sau khi thay doi qua con tro (Truy cap lai qua con tro) ---" << endl;
cout << "Gia tri moi 'quantity' qua con tro *ptr_quantity: " << *ptr_quantity << endl;
cout << "Gia tri moi 'unit_cost' qua con tro *ptr_unit_cost: " << *ptr_unit_cost << endl;
cout << "Gia tri moi 'product_name' qua con tro *ptr_product_name: " << *ptr_product_name << endl;
return 0;
}
Giải thích:
Ví dụ này minh họa toàn bộ chu trình:
- Khai báo các biến thông thường.
- Khai báo các con trỏ tương ứng với kiểu dữ liệu của biến.
- Sử dụng toán tử
&
để lấy địa chỉ của biến và gán cho con trỏ, thiết lập mối liên kết "trỏ tới". - In ra địa chỉ của biến và giá trị của con trỏ để thấy rằng chúng giống nhau.
- Sử dụng toán tử
*
(dereference) để truy cập giá trị gốc thông qua con trỏ. - Sử dụng toán tử
*
(dereference) ở vế trái của phép gán để thay đổi giá trị gốc thông qua con trỏ. - Kiểm tra lại giá trị của biến gốc để xác nhận sự thay đổi.
Qua ví dụ này, bạn có thể thấy rõ ràng mối quan hệ giữa biến, địa chỉ của biến, con trỏ lưu trữ địa chỉ đó, và cách toán tử &
và *
cho phép chúng ta thao tác với các khái niệm này.
Bài tập ví dụ: C++ Bài 15.A1: Giao bóng cầu lông
Giao bóng cầu lông
FullHouse Dev đang chơi cầu lông hôm nay. Luật giao bóng trong trận đấu đơn cầu lông như sau:
- Người chơi bắt đầu trận đấu sẽ giao bóng từ bên phải sân của họ.
- Bất cứ khi nào một người chơi giành được điểm, họ sẽ được giao bóng tiếp theo.
- Nếu người giao bóng đã giành được số điểm chẵn trong một ván, họ sẽ giao bóng từ bên phải sân cho điểm tiếp theo.
- FullHouse Dev sẽ là người chơi bắt đầu trận đấu.
Cho biết số điểm P mà FullHouse Dev đạt được khi kết thúc trận đấu, hãy xác định xem FullHouse Dev đã giao bóng từ bên phải sân bao nhiêu lần.
Đề bài
Input:
- Dòng đầu tiên chứa một số nguyên T - số lượng test case.
- Mỗi test case gồm một dòng chứa số nguyên P - số điểm FullHouse Dev đạt được.
Output:
- Với mỗi test case, in ra một dòng duy nhất số lần FullHouse Dev giao bóng từ bên phải sân.
Ràng buộc:
- \(1 ≤ T ≤ 10^3\)
- \(0 ≤ P ≤ 10^9\)
Ví dụ:
Input:
4
2
9
53
746
Output:
2
5
27
374
Giải thích:
- Test case 1: FullHouse Dev đạt được 2 điểm. Anh ấy giao bóng từ bên phải 2 lần, khi điểm số là 0 và 2.
- Test case 2: FullHouse Dev đạt được 9 điểm. Anh ấy giao bóng từ bên phải 5 lần, khi điểm số là 0, 2, 4, 6, 8. Chào bạn, đây là hướng dẫn giải bài tập "Giao bóng cầu lông" bằng C++:
Phân tích bài toán và luật chơi:
- Ai giao bóng trước? FullHouse Dev (FHD) giao bóng đầu tiên.
- Lượt giao bóng đầu tiên từ đâu? Từ bên phải sân của FHD (khi điểm số là 0).
- Khi nào FHD giao bóng? Bất cứ khi nào FHD giành được điểm, anh ấy sẽ giao bóng ở lượt tiếp theo.
- Giao bóng từ đâu khi FHD là người giao bóng?
- Nếu điểm số hiện tại của FHD là số chẵn, anh ấy giao bóng từ bên phải.
- Nếu điểm số hiện tại của FHD là số lẻ, anh ấy giao bóng từ bên trái.
Xác định khi nào FHD giao bóng từ bên phải:
Dựa vào luật 3 và 4, FHD sẽ giao bóng sau khi anh ấy ghi điểm. Lượt giao bóng này diễn ra khi điểm số của anh ấy đã được cập nhật.
- Lúc bắt đầu trận đấu, điểm của FHD là 0 (chẵn). Anh ấy giao bóng từ bên phải (luật 2). Đây là lần giao bóng phải đầu tiên.
- Giả sử FHD ghi được điểm thứ 1. Điểm của anh ấy là 1. Anh ấy sẽ giao bóng lượt tiếp theo. Điểm hiện tại là 1 (lẻ), nên anh ấy giao bóng từ bên trái.
- Giả sử FHD ghi được điểm thứ 2. Điểm của anh ấy là 2. Anh ấy sẽ giao bóng lượt tiếp theo. Điểm hiện tại là 2 (chẵn), nên anh ấy giao bóng từ bên phải.
- Giả sử FHD ghi được điểm thứ 3. Điểm của anh ấy là 3. Anh ấy sẽ giao bóng lượt tiếp theo. Điểm hiện tại là 3 (lẻ), nên anh ấy giao bóng từ bên trái.
- Giả sử FHD ghi được điểm thứ 4. Điểm của anh ấy là 4. Anh ấy sẽ giao bóng lượt tiếp theo. Điểm hiện tại là 4 (chẵn), nên anh ấy giao bóng từ bên phải.
Nhận xét:
FHD giao bóng khi điểm số của anh ấy là 0, 1, 2, 3, 4, ..., P (sau khi anh ấy ghi được điểm thứ P). Anh ấy giao bóng từ bên phải khi điểm số tại thời điểm giao bóng là số chẵn.
Như vậy, chúng ta cần đếm số lần điểm số của FHD là một số chẵn trong dãy 0, 1, 2, ..., P.
Các điểm số chẵn trong dãy 0, 1, 2, ..., P là: 0, 2, 4, 6, ..., (số chẵn lớn nhất <= P).
Tìm công thức đếm:
Chúng ta cần đếm số các số chẵn k (với k >= 0) sao cho k <= P.
Các số chẵn có dạng 2 m, với m là số nguyên không âm (0, 1, 2, ...).
Ta cần tìm số lượng giá trị m sao cho 2 m <= P, hay m <= P / 2.
Vì m là số nguyên không âm, m có thể là 0, 1, 2, ..., floor(P/2).
Số lượng các giá trị của m là floor(P/2) + 1.
Trong C++, phép chia nguyên P / 2
chính là floor(P/2) đối với P >= 0.
Vậy, số lần FHD giao bóng từ bên phải là P / 2 + 1
.
Kế hoạch triển khai C++:
- Đọc số lượng test case
T
. - Sử dụng vòng lặp (ví dụ:
while
hoặcfor
) để xử lý từng test case. - Trong mỗi test case:
- Đọc số điểm
P
mà FHD đạt được. - Sử dụng công thức
P / 2 + 1
để tính số lần giao bóng từ bên phải. - In kết quả ra màn hình, theo sau bởi ký tự xuống dòng.
- Đọc số điểm
- Sử dụng các thư viện chuẩn của C++ (
iostream
cho nhập/xuất).
Lưu ý về kiểu dữ liệu:
P
có thể lên tới 10^9. Kết quả P / 2 + 1
cũng sẽ trong khoảng 10^9 / 2 + 1, vẫn nằm trong phạm vi của kiểu int
thông thường (32-bit signed int có giá trị tối đa khoảng 2*10^9). Do đó, có thể sử dụng kiểu int
cho cả P
và kết quả.
Ví dụ áp dụng công thức:
- P = 2: Kết quả = 2 / 2 + 1 = 1 + 1 = 2.
- P = 9: Kết quả = 9 / 2 + 1 = 4 + 1 = 5.
- P = 53: Kết quả = 53 / 2 + 1 = 26 + 1 = 27.
- P = 746: Kết quả = 746 / 2 + 1 = 373 + 1 = 374.
Công thức hoạt động đúng với các ví dụ.
Hướng dẫn code (không cung cấp code hoàn chỉnh):
- Bắt đầu bằng cách include thư viện cần thiết (
<iostream>
). - Sử dụng
cin
để đọc input vàcout
để in output. - Để chương trình chạy nhanh hơn với lượng input lớn, bạn có thể thêm dòng sau vào đầu hàm
main
:ios_base::sync_with_stdio(false); cin.tie(NULL); cout.tie(NULL);
- Khai báo biến cho
T
vàP
với kiểu dữ liệu phù hợp (int
). - Viết vòng lặp
while (T--)
hoặcfor
để xử lý các test case. - Bên trong vòng lặp, đọc giá trị
P
, tính kết quảP / 2 + 1
, và in kết quả.
Comments