----> Lời khuyên cho các bạn bắt đầu học lập trình: nếu có tham khảo code trên mạng thì các bạn nên xem nó và tự viết lại rồi tìm hiểu xem từng phần trong code ý nó có mục đích là gì chứ đừng nên copy và paste một cách máy móc mà mình chả hiểu được code ý nó viết gì.
Con trỏ (pointer) là một đặc điểm vô cùng mạnh mẽ của C/C++. Nó cho phép chúng ta giải quyết nhiều vấn đề phức tạp một cách hiệu quả và linh hoạt. Có thể kể ra một vài ứng dụng cơ bản của con trỏ như: cho phép truyền đối số theo tham chiếu, tạo và thao tác trên các cấu trúc dữ liệu động (dynamic data structure) như danh sách liên kết (linked list), ngăn xếp (stack), hàng đợi (queue), cài đặt các cơ sở dữ liệu, con trỏ hàm … Bài này mình sẽ giới thiệu những đặc điểm và cách sử dụng cơ bản của con trỏ
1. Con trỏ là gì?
Con trỏ hiểu nôm na là một biến lưu trữ địa chỉ của một vùng nhớ. Thông thường nó lưu trữ địa chỉ của các biến khác. Khi một con trỏ ptr lưu trữ địa chỉ của biến x, ta nói “ptr trỏ tới x” (ptr points to x). Do biến có thể thuộc nhiều kiểu dữ liệu khác nhau nên sẽ có nhiều kiểu con trỏ tương ứng. Đối với một kiểu T, thì kiểu con trỏ tương ứng sẽ là T*, và được gọi là “con trỏ đến kiểu T” (pointer to T). Điều này có nghĩa là, con trỏ là một biến (như một biến bình thường khác) có kiểu T* và có thể lưu trữ địa chỉ của một đối tượng kiểu T. Ví dụ sau minh họa cách khai báo và gán giá trị cho con trỏ.
int* pi; // khai báo một con trỏ kiểu int*
pi=&x; // gán địa chỉ của x cho pi
Đoạn code ngắn trên có một số điều cần lưu ý:
Thứ nhất: khi khai báo con trỏ chúng viết là int* pi, hay int *pi ? (dấu * viết gần kiểu int hay gần biến pi?). Xin trả lời là viết kiểu nào cũng được, compiler nó hiểu hai cách viết là như nhau. Vấn đề ở đây chỉ là style. Tuy nhiên, tại sao C++ không thống nhất cách viết mà bày vẽ ra những hai cách làm gì cho nó mệt? Để hiểu rõ vấn đề này cần phải nhìn lại lịch sử C++ một chút. C++ được Bjarne Stroustrup phát minh vào cuối những năm 70 của thế kỷ trước (khoảng năm 1979), nhưng mãi đến 1994 ANSI/ ISO mới dự thảo chuẩn hóa cho C++. Và quá trình chuẩn hóa này diễn ra trong tới 4 năm trời, chỉ vì một gã điên khùng có tên là Alexander Stepanove tự dưng nghĩ ra STL (Standard Template Library) và việc phải xem xét thêm một thư viện khổng lồ này làm chậm lại đáng kể quy trình chuẩn hóa. Nghĩa là từ lúc C++ ra đời đến phiên bản chuẩn hóa đầu tiên của nó mất tới 30 năm! Trong 30 năm đó, người ta đã dùng C++ như một ngôn ngữ chuyên nghiệp để lập trình và có tới hàng tá phiên bản cài đặt khác nhau của C++ được tung ra. Rõ ràng với một lượng code lớn như vậy hiện hữu trong các hệ thống máy tính đang vận hành thì chuẩn mới của C++ không thể đặt ra những cú pháp mới tinh được, mà phải dựa trên những cái có sẵn, nếu hợp lý thì cho làm chuẩn. Trong bản phác thảo đầu tiên của Stroustrup thì dấu * được viết gần với kiểu dữ liệu (int* pi thay vì int *pi) điều này phản ánh * là một phần của kiểu dữ liệu (tức kiểu int*) chứ không phải là một ký hiệu lạ, hay một toán tử mới. Và mình cũng thích viết theo cách này hơn. Trong tất cả các đoạn code của mình khi khai báo con trỏ hay tham chiếu thì dấu * hoặc & luôn được viết cùng với kiểu dữ liệu vì điều này phản ánh bản chất thực sự của con trỏ và tham chiếu. Tuy nhiên một số người lại thích viết theo kiểu: int *pi; hơn. Cách viết này không phải là không có cái lý của nó. Hãy xem xét đoạn code sau.
int *pi, *a; // cả pi và a đều là con trỏ int
Câu lệnh thứ nhất rất dễ làm cho người ta lầm tưởng pi và a đều là hai con trỏ kiểu int*, nhưng thực tế chỉ có pi là con trỏ, con a lại là một biến nguyên. Vì vậy người ta nghĩ viết theo cách thứ hai sẽ hạn chế được những hiểu lầm như vậy. Tuy nhiên khi bạn đã hiểu rõ bản chất của con trỏ rồi thì vấn đề chỉ còn là phong cách. Bạn muốn viết theo style nào cũng được. Riêng mình vẫn thích cách viết int* hơn. Thêm nữa, mỗi khai báo nên đặt trên một dòng, để còn tiện comment khi cần thiết. Nhà giàu tiếc gì con lợn con, hờ hờ . Như vậy sẽ không còn sự nhập nhằng nữa.
int* p2; // comment vào chỗ này
Thứ hai: để ý đến câu lệnh
Câu lệnh gán này thực hiện gán địa chỉ của biến x cho con trỏ pi, sau câu lệnh này ta nói pi trỏ đến x. Toán tử & là toán tử lấy địa chỉ, nó là toán tử một ngôi (unary operator) trả về địa chỉ của toán hạng (operand) bên phải nó (ở đây là x).
Chúng ta sẽ xem xét thêm một số cú pháp khai báo các kiểu con trỏ hay gặp bao gồm con trỏ đa cấp, hàm trả về con trỏ, con trỏ hàm, mảng con trỏ, ...
char** ppc; // con trỏ hai cấp – con trỏ trỏ tới con trỏ char*
int* ap[100]; // mảng 100 con trỏ kiểu int*
int* func(char*); // hàm func nhận đối đầu vào là con trỏ char* và trả về con trỏ int*
int (*funcp)(char*); // con trỏ tới hàm funcp (hàm funcp nhận một đối là con trỏ char* và trả về kiểu int)
2. Thao tác trên con trỏ
Hai toán tử * và & là hai toán tử quan trọng nhất thao tác trên con trỏ. Toán tử &, như đã nói ở trên, là toán tử một ngôi, trả về địa chỉ của toán hạng bên phải nó. Để hình dung khái niệm địa chỉ ta coi bộ nhớ như một khu phố. Bộ nhớ được chia thành các thành các vùng nhớ, giống như khu phố được phân chia thành các hộ gia đình. Mỗi một vùng nhớ được đánh số thứ tự giống như mỗi hộ gia đình có một số nhà. Số thứ tự này gọi là địa chỉ của vùng nhớ đó. Ta cũng cần phân biệt giữa địa chỉ vùng nhớ và nội dung chứa trong vùng nhớ. Địa chỉ giống như số nhà còn nội dung hay giá trị (value) chứa trong vùng nhớ giống như tài sản trong nhà vậy. Câu lệnh:
đơn thuần chỉ cung cấp địa chỉ của x cho pi, nó không ảnh hưởng gì đến giá trị của x cả. Giả sử ban đầu x=5 thì sau câu lệnh trên x vẫn bằng 5. Khi con trỏ pi đã có trong tay địa chỉ của x, nó có thể “đột nhập” đến biến x để xem nội dung của x là gì, thậm chí thay đổi nó. Để làm điều này ta dùng toán tử truy xuất giá trị * (dereference). Toán tử * là toán tử một ngôi, trả về nội dung chứa trong vùng nhớ mà con toán hạng bên phải nó trỏ tới. Xem xét đoạn chương trình sau:
pi=&x;
cout << *pi << endl; // in ra giá trị nằm trong vùng nhớ mà pi trỏ tới, tức in ra 5
*pi=100; // thay đổi nội dung của x thông qua con trỏ pi
cout << x << endl; // in ra nội dung x, tức 100
Sử dụng con trỏ như trên được gọi là gián tiếp (indirection) vì nó thay đổi nội dung của biến thông qua một biến khác (biến trỏ).
3. Chú ý về kiểu và ép kiểu con trỏ
Một câu hỏi được đặt ra là: thông qua con trỏ ta có thể gián tiếp thao tác trên biến. Vậy giả sử ta muốn copy giá trị của một biến a kiểu int, sang cho biến b kiểu int thông qua một con trỏ thì làm thế nào trình biên dịch biết được số lượng byte cần copy là bao nhiêu? (giả thiết môi trường của ta là 32 bit). Ví dụ đoạn chương trình sau.
pa=&a; // pa trỏ đến a
b=*pa; // gán nội dung của biến a cho b thông qua con trỏ pa, tức b=5
Khi muốn copy giá trị của biến a cho biến p thông qua con trỏ pa trỏ tới a thì pa phải có kiểu tương thích với kiểu của a, tức pa có kiểu int*. Khi ta dùng pa để thao tác trên một vùng nhớ nào đó, pa luôn tự nhủ rằng mình đang trỏ đến một vùng nhớ kiểu int và mình chỉ được tọc mạch vào 4 bytes thôi, đo đó nó nó sẽ copy đúng 4 bytes dữ liệu tương ứng với địa chỉ được chỉ định. Chú ý rằng địa chỉ của a mà pa trỏ tới không phải là địa chỉ của cả 4 bytes dữ liệu của a, mà là địa chỉ byte đầu tiên của vùng nhớ đó. Còn việc đếm 3 byte còn lại như thế nào thì là việc của compiler. Nếu pa là kiểu int* còn a, b là kiểu double, muốn thực hiện đoạn code trên ta phải “ép kiểu con trỏ”. Xem xét chương trình sau.
using namespace std;
int main(){
double a=12.34, b;
int* pa;
pa=(int*)&a; // ép kiểu double* thành int*
b=*pa;
cout << b << endl;
return 0;
}
Bạn hãy chạy thử chương trình trên và xem kết quả ! Tại sao kết quả lại sai? Rất đơn giản khi câu lệnh
được thực hiện thì pa vẫn nhận đúng địa chỉ của vùng nhớ của a, nhưng khi nó mò tới vùng nhớ này, thay vì nó copy đủ 8 bytes dữ liệu của a cho b, thì nó chỉ copy 4 bytes ! (vì nó vẫn tự nhủ với mình rằng nó là con trỏ kiểu int* và nó chỉ được tọc mạch vào 4 bytes chứ không phải 8 bytes) Đây là một lỗi logic hết sức tinh vi, nếu không nắm rõ bản chất của con trỏ bạn sẽ rất dễ mắc lỗi này khi thực hiện “ép kiểu” linh tinh.
4. Con trỏ NULL
Sau khi một con trỏ được khai báo, nếu như ta không gán giá trị cho nó, nó sẽ chứa một giá trị tùy ý (tức trỏ lung tung tới một vùng nhớ nào đó). Nếu ta sử dụng nó trước khi gán cho nó một giá trị hợp lệ, thì không chỉ chương trình của ta có thể bị “toi” mà có khi cả hệ điều hành của ta cũng bị “đi” luôn. Bởi vì rất có thể khi khởi tạo, con trỏ trỏ tới một vùng nhớ có chứa dữ liệu quan trọng của HĐH. Và khi ta gián tiếp thay đổi nội dung vùng nhớ đó thông qua con trỏ thì hậu quả không biết đằng nào mà lần. Theo quy ước, nếu một con trỏ chứa giá trị là 0 thì nó được hiểu là không trỏ tới cái gì cả. Bởi vì một lý do hết sức đơn giản: không một đối tượng nào được phép tồn tại trong khu vực địa chỉ là 0. Bất cứ con trỏ nào đều có thể được khởi tạo là NULL khi khai báo bằng cách gán nó bằng 0. Ví dụ:
double* pd=0;
char* pc=0;
using namespace std;
int main(){
int a[10];
int* pa=a;
cout << "Before: " << (void*)pa << endl; // in địa chỉ pa trước khi dịch chuyển
pa=pa+5; // dịch pa đi 5 đơn vị
cout << "After: " << (void*)pa << endl; // địa chỉ pa sau khi dịch chuyển
return 0;
}
using namespace std;
int main(){
int a[10];
int* p1;
int* p2;
p1=&a[0]; // p1 trỏ đến phần tử đầu mảng
p2=&a[9]; // p2 trỏ đến phần tử cuối mảng
cout << p2-p1 << endl; // in ra khoảng cách giữa hai con trỏ
return 0;
}
using namespace std;
int main(){
int a[10]; // mảng 10 số nguyên
int* p=&a[9]; // con trỏ p trỏ đến phần tử cuối mảng
cout << "Input 10 integers: \n"; // nhập vào 10 số nguyên từ cuối đến đầu mảng
while(p>=a){ // khi p còn chưa trỏ về đầu mảng
cin >> *p; // nhập dữ liệu vào các phần tử tương ứng
p--; // dịch con trỏ về đầu mảng
}
cout << "Reverse: \n"; // in ra thứ tự đảo ngược
p=a; // cho con trỏ p trỏ về đầu mảng
while(p<=&a[9]){ // chừng nào p chưa trôi về cuối mảng
cout << *p << endl; // in ra các phần tử mảng tương ứng
p++; // dịch con trỏ p về cuối mảng
}
return 0;
}
using namespace std;
int main(){
int a[5];
int* pa=a; // con trỏ pa trỏ đến đầu mảng
cout << "Input array:\n";
for(int i=0; i<5; i++){
cin >> pa[i]; // thao tác qua p, với chỉ số
}
cout << "Ouput array:\n";
for(int i=0; i<5; i++){
cout << pa[i] << " "; // thao tác qua p, với chỉ số
}
return 0;
}
const char ch[]={‘I’, ‘H’, ‘A’, ‘T’, ‘E’, ‘U’}; // ch là con trỏ hằng (const pointer) trỏ tới phần tử đầu mảng
const int number; // Error: phải khởi tạo hằng ngay khi khai báo
char str[]=”I am first_pace, from congdongCViet”; // str là con trỏ hằng, nhưng các str[i] không phải hằng
char* p1;
char* p2;
p1=str; // ok: vì p1 không phải con trỏ hằng nên được phép trỏ đi chỗ khác
str=p2; // error: vì str là con trỏ hằng nên không thể trỏ đi chỗ khác được
const char* p=str; // con trỏ p (pointer to const) trỏ đến đầu mảng str, và p “hiểu” str[i] là các hằng
p[3]='X'; // Error: không được, vì p chỉ được đọc dữ liệu chứ không được ghi dữ liệu lên str[i]
str[3]='X'; // Ok: vì str[i] chỉ được coi là hằng theo “cách hiểu” của p, với str thì các str[i] vẫn là biến
int *const px=&x; // bây giờ px luôn luôn trỏ đến x, không thể thay đổi được
px=&y; // Error: px là con trỏ hằng, không được phép trỏ đi chỗ khác
int const* p2=&x; // hoàn toàn giống p1, con trỏ tới hằng int (pointer to const char)
int *const p3=&x; // con trỏ hằng tới biến char (pointer to const char)
const int* p1=&x; // Ok: con trỏ p1 trỏ tới hằng x
int* p2=&x; // Error: con trỏ p2 là “con trỏ tự do”, không được phép trỏ tới hằng x.