Tiếp tục loạt bài trong series về con trỏ, trong bài này chúng ta sẽ tìm hiểu những nội dung sau:
- Mối liên hệ giữa con trỏ và mảng một chiều
- Địa chỉ của mảng một chiều và các phần tử trong mảng
- Con trỏ trỏ đến mảng một chiều
- Sự khác nhau khi sử dụng mảng một chiều và con trỏ trỏ đến mảng một chiều
Contents
1. Mối liên hệ giữa con trỏ và mảng một chiều
– Nhắc lại khái niệm về mảng một chiều:
Mảng một chiều là tập hợp các phần tử có cùng kiểu dữ liệu được lưu trữ liên tiếp nhau trên bộ nhớ ảo, nếu mảng một chiều có một hoặc nhiều hơn một phần tử, thì địa chỉ của phần tử đầu tiên cũng chính là địa chỉ của mảng một chiều.
– Ở bài viết trước khái niệm và cách sử dụng của con trỏ trong ngôn ngữ C/C++, chúng ta biết rằng con trỏ có khả năng lưu trữ một địa chỉ của một vùng nhớ trên bộ nhớ ảo (virtual memory), tận dụng sức mạnh của con trỏ chúng ta có thể dùng nó để quản lý vùng nhớ tại địa chỉ mà con trỏ đang giữ, kích thước vùng nhớ đó là bao nhiêu còn tùy thuộc vào kiểu dữ liệu chúng ta khai báo cho con trỏ.
=> Chính vì con trỏ có thể thao tác trực tiếp với bộ nhớ ảo, nên chúng ta cũng có thể sử dụng con trỏ để thao tác trực tiếp với mảng một chiều. Trong C/C++ có mối quan hệ chặt chẽ giữa con trỏ và mảng. Các phần tử của mảng có thể được xác định được bằng chỉ số hoặc thông qua con trỏ.
2. Địa chỉ của mảng một chiều và các phần tử trong mảng
– Ví dụ chúng ta có một mảng một chiều được khai báo 5 phần tử:
int arr[] = { 32, 13, 66, 11, 22 };
– Như chúng ta đã biết, địa chỉ của mảng một chiều cũng là địa chỉ của phần tử đầu tiên, vì thế, đoạn chương trình bên dưới sẽ in ra 2 giá trị địa chỉ giống nhau:
1 2 3 | printf ( "Dia chi cua bien mang arr = %p" , &arr ); printf ( "Dia chi phan tu dau tien cua mang arr = %p" , &arr[0] ); |
– Có một điểm đặc biệt của mảng một chiều trong C/C++, nếu ta lấy địa chỉ của biến mảng arr mà không sử dụng &arr, mà ghi trực tiếp là arr thì sẽ thế nào?
1 2 3 4 5 6 7 | // Địa chỉ của arr trong bộ nhớ ảo (virtual memory) printf ( "arr = %p\n" , arr ); printf ( "===========================\n" ); printf ( "&arr = %p\n" , &arr ); // Địa chỉ của phần tử đầu tiên của mảng arr printf ( "&arr[0] = %p\n" , &arr[0] ); |
=> Điều này chứng tỏ rằng việc sử dụng tên mảng một chiều cũng chính là chúng ta đang sử dụng địa chỉ của mảng một chiều (&arr tương đương với arr).
Vì thế, chúng ta có thể in ra địa chỉ của cả 5 phần tử của mảng arr bằng cách sau (Mình viết bằng C++ cho dễ nhìn):
1 2 3 4 5 6 7 8 9 10 11 12 | cout << arr << endl; cout << arr + 1 << endl; cout << arr + 2 << endl; cout << arr + 3 << endl; cout << arr + 4 << endl; // Hoặc theo cách thông thường: //cout << &arr[0] << endl; //cout << &arr[1] << endl; //cout << &arr[2] << endl; //cout << &arr[3] << endl; //cout << &arr[4] << endl; |
– Từ kết quả trên ta thấy: arr, arr + 1, arr + 2, arr + 3, arr + 4 chính là địa chỉ của 5 phần tử của mảng arr. Các bạn có thấy nó giống như các con trỏ không vì vậy chúng ta có thể sử dụng toán tử * để truy xuất giá trị của chúng:
1 2 3 4 5 6 7 8 9 10 11 12 | cout << *(arr) << endl; cout << *(arr + 1) << endl; cout << *(arr + 2) << endl; cout << *(arr + 3) << endl; cout << *(arr + 4) << endl; // Hoặc theo cách thông thường: //cout << arr[0] << endl; //cout << arr[1] << endl; //cout << arr[2] << endl; //cout << arr[3] << endl; //cout << arr[4] << endl; |
=> Từ cách viết trên, ta thấy ngoài việc dùng vòng for() in ra các giá trị của mảng bằng cách dùng chỉ số như thông thường thì ta cũng có thể làm như sau:
1 2 3 4 5 6 7 8 9 10 | for ( int i = 0; i < 5; i++) { cout << *(arr + i) << " " ; } // Hoặc theo cách thông thường: //for (int i = 0; i < 5; i++) //{ // cout << arr[i] << " "; //} |
Tổng kết:
– Tên mảng là một hằng địa chỉ (hằng con trỏ), nó chính là địa chỉ của phần tử đầu tiên của mảng.
– Địa chỉ:
- arr <=> &arr[0] <=> &arr
- (arr + i) <=> &arr[i]
– Giá trị:
- *arr <=> arr[0]
- *(arr + i) <=> arr[i]
3. Con trỏ trỏ đến mảng một chiều
– Chúng ta sẽ tiếp tục làm việc với mảng một chiều trên:
int arr[] = { 32, 13, 66, 11, 22 };
– Mỗi phần tử bên trong mảng đều có kiểu int, do đó, chúng ta sẽ sử dụng 1 con trỏ có kiểu dữ liệu tương ứng (int *) để thao tác với mảng arr
int *p;
– Vì tên mảng và con trỏ bản chất đều là địa chỉ của vùng nhớ, nên ta có thể gán địa chỉ của hằng con trỏ arr cho con trỏ p
p = arr; // Hoặc: p = &arr[0];
– Lúc này thì với các code minh họa ở mục 2. phía trên, ở tất cả những chỗ có chữ arr, chúng ta thay bằng p khi chạy chương trình thì kết quả là hoàn toàn giống nhau ;).
Và để truy cập từng phần tử trong mảng arr , thì 4 cách sau là tương đương nhau:
- arr[i]
- *(arr + i) // Lấy giá trị trong ô địa chỉ (arr + i)
- p[i]
- *(p + i)
Notes: Đến đây các bạn thử nghĩ xem khi chạy code bên dưới địa chỉ của *p và p sẽ như thế nào? printf("Address in *p: %p\n", *p); printf("Address in p: %p\n", p);
– Bây giờ chúng ta sẽ cho con trỏ p thao tác với mảng arr, mình cho con trỏ p trỏ đến phần tử thứ 3 trong mảng arr.
p = &arr[2]; // Hoặc: p = arr + 2;
– Lúc này, chúng ta sử dụng toán tử * để lấy giá trị của con trỏ p sẽ được giá trị 66.
cout << *p << endl;
– Từ địa chỉ của arr[2] mà con trỏ p đang nắm giữ, chúng ta cũng có thể sử dụng toán tử「+」hoặc「-」để truy xuất đến tất cả các phần tử còn lại trong mảng arr vì các phần tử của mảng có địa chỉ nối tiếp nhau trên bộ nhớ ảo.
// Dịch chuyển con trỏ lên trước 1 ô nhớ cout << *(p - 1) << endl; // access the second element(13) of arr
// Dịch chuyển con trỏ lùi ra sau 2 ô nhớ cout << *(p + 2) << endl; // access the last element(22) of arr
– Tương tự như trên, chúng ta cũng có thể sử dụng toán tử「++」hoặc「–」 để truy xuất đến phần tử tiếp theo hoặc phần tử đứng trước đó:
p++; cout << *p << endl; // Output: 11
p--; cout << *p << endl; // Output: 13
– Như các bạn thấy, chỉ với một con trỏ có kiểu dữ liệu tương ứng với kiểu của mảng một chiều, chúng ta có thể quản lý được toàn bộ phần tử trong mảng:
1 2 3 4 5 6 7 8 9 10 11 12 | p = arr; for ( p = &arr[0]; p <= &arr[4]; p++ ) { cout << *p << " " ; } // Hoặc: for ( p = arr; p <= arr + 4; p++ ) { cout << *p << " " ; } |
Vòng lặp for() trên: ban đầu khởi tạo bằng cách gán địa chỉ phần tử đầu tiên của mảng arr cho con trỏ p (p = &arr[0]), khi nào địa chỉ mà p nắm giữ vẫn còn nhỏ hơn hoặc bằng địa chỉ của phần tử cuối cùng (p <= &arr[4]) thì tiếp tục in giá trị mà p trỏ đến, cứ thế cho p dịch chuyển đến ô nhớ tiếp theo (p++) trong mảng.
– Cũng là in ra toàn bộ giá trị của các phần tử trong mảng arr, nhưng sử dụng con trỏ chúng ta có rất nhiều cách viết khác nhau:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | p = arr; for ( int i = 0; i < 5; i++) { cout << p[i] << " " ; } // Hoặc: for ( int i = 0; i < 5; i++) { cout << *(p + i) << " " ; } // Hoặc: for ( int i = 0; i < 5; i++) { cout << *(arr + i) << " " ; } |
Bên lề:
■ Giả sử chúng ta có 2 mảng một chiều kiểu int có cùng kích thước như sau:
int src[5] = { 3, 1, 5, 7, 4 }; int des[5];
– Việc copy dữ liệu từ mảng src sang mảng des có thể thực hiện như sau:
1 2 3 4 5 6 7 8 9 10 | for ( int i = 0; i < 5; i++) { des[i] = src[i]; } // Hoặc: for ( int i = 0; i < 5; i++) { *(des + i) = *(src + i); } |
■ Đối với mảng kí tự bản chất nó cũng tương tự như mảng một chiều, chúng ta có thể trực tiếp in nội dung của chuỗi kí tự. Ví dụ:
1 2 3 4 5 6 7 8 9 | // Khai báo mảng ký tự char myName[50]; // Nhập chuỗi cout << "Enter your name: " ; gets_s( myName ); // Xuất chuỗi cout << "Hello " << myName << endl; |
– Và nếu chúng ta sử dụng một con trỏ kiểu (char *) để trỏ đến mảng myName, chúng ta có thể dùng tên con trỏ để in mảng đó ra màn hình:
1 2 3 | char *pName = myName; cout << "Hello " << pName << endl; |
– Bên cạnh đó, chúng ta có thể cho con trỏ kiểu (char *) trỏ đến một chuỗi kí tự cố định nào đó, và vẫn có thể in nội dung mà con trỏ đó đang trỏ đến. Ví dụ:
1 2 3 | char *p_str = "This is an example string" ; cout << p_str << endl; |
*** Nhưng vùng nhớ của chuỗi kí tự này được xem là hằng số (const) nên chúng ta chỉ có thể xem nội dung mà p_str trỏ đến chứ không thể thay đổi kí tự bên trong chuỗi. Chúng ta sẽ tìm hiểu về vấn đề này trong các bài viết tiếp theo.
4. Sự khác nhau khi sử dụng mảng một chiều và con trỏ trỏ đến mảng một chiều
Sau khi con trỏ trỏ đến mảng một chiều, chúng ta có thể sử dụng tên con trỏ thay vì sử dụng tên mảng. Tuy vậy, giữa chúng vẫn có một số điểm khác biệt.
#1. Mảng một chiều sau khi khai báo thì sẽ có ĐỊA CHỈ CỐ ĐỊNH trên bộ nhớ ảo, con trỏ sau khi trỏ đến mảng một chiều, nếu muốn chúng ta vẫn có thể cho nó trỏ đi nơi khác được.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | int arr[5] = { 32, 13, 66, 11, 22 }; int *p; // Cho con trỏ p trỏ đến mảng arr p = arr; // In giá trị con trỏ p cout << "*p = " << *p << endl; int a = 9; // Cho con trỏ p trỏ đến biến a p = &a; // In giá trị con trỏ p cout << "*p = " << *p << endl; |
#2. Khi sử dụng toán tử sizeof() thì:
- Đối với mảng một chiều: sẽ trả về kích thước của toàn bộ phần tử bên trong mảng.
- Đối với con trỏ đến mảng: sẽ trả về kích thước vẫn là 4 bytes (trên hệ điều hành 32 bits) như bình thường.
=> Như vậy, sử dụng mảng một chiều chúng ta có thể biết được chính xác số lượng phần tử chúng ta cần quản lý trong khi con trỏ không làm được điều này.
1 2 3 4 5 6 | int arr[5]; int *p = arr; cout << "Size of arr = " << sizeof ( arr ) << endl; cout << "Size of p = " << sizeof ( p ) << endl; |
#3. Con trỏ khi khai báo thì chưa được cấp phát bộ nhớ, còn mảng thì được cấp phát bộ nhớ.