Trong C++, con trỏ và mảng có mối liên hệ chặt chẽ về mặt bản chất.
Nội dung chính
1. Phân rã mảng
Trong bài Mảng (Phần I), bạn đã học về cách khai báo một mảng cố định:
int array[5]{ 9, 7, 5, 3, 1 }; // declare a fixed array of 5 integers
Đối với chúng ta, ví dụ trên khai báo một mảng gồm 5 số nguyên, nhưng đối với trình biên dịch, mảng là một biến có kiểu dữ liệu int[5]. Chúng ta biết các giá trị của array[0], array[1], array[2], array[3], và array[4] lần lượt là (9, 7, 5, 3, và 1)
Trong tất cả các trường hợp, ngoại trừ hai trường hợp (mà chúng tôi sẽ trình bày bên dưới đây), khi một mảng cố định được sử dụng trong một biểu thức, mảng cố định sẽ được phân ra (được chuyển đổi một cách ngầm định) thành một con trỏ mà trỏ đến phần tử đầu tiên của mảng. Bạn có thể thấy điều này trong đoạn chương trình sau:
/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/
#include <iostream>
int main()
{
int array[5]{ 9, 7, 5, 3, 1 };
// print address of the array's first element
std::cout << "Element 0 has address: " << &array[0] << '\n';
// print the value of the pointer the array decays to
std::cout << "The array decays to a pointer holding address: " << array << '\n';
return 0;
}
Trên máy của tác giả, đoạn code trên in ra:
Element 0 has address: 0042FD5C
The array decays to a pointer holding address: 0042FD5C
Việc tin rằng một mảng và một con trỏ mà trỏ đến mảng là giống hết nhau là một sai lầm phổ biến trong C++. Chúng không giống nhau. Trong trường hợp trên, mảng có kiểu “int[5]”, và “giá trị” của nó chính là các phần tử mảng của chính nó. Một con trỏ trỏ đến mảng sẽ có kiểu “int *”, và giá trị của nó sẽ là địa chỉ của phần tử đầu tiên của mảng.
Chúng ta sẽ sớm thấy điểm tạo nên sự khác biệt này.
Tất cả các phần tử của mảng vẫn có thể được truy cập thông qua con trỏ (chúng ta sẽ thấy cách thức hoạt động của phương pháp này trong bài học tiếp theo), nhưng thông tin được trích xuất từ kiểu dữ liệu của mảng (chẳng hạn như mảng dài bao nhiêu) không thể được truy cập từ con trỏ.
Tuy nhiên, điều này cũng cho phép chúng ta xử lý các mảng cố định và con trỏ một cách tương tự nhau trong hầu hết các trường hợp.
Ví dụ, chúng ta có thể dereference cái mảng để lấy được giá trị của phần tử đầu tiên:
int array[5]{ 9, 7, 5, 3, 1 };
// dereferencing an array returns the first element (element 0)
std::cout << *array; // will print 9!
char name[]{ "Jason" }; // C-style string (also an array)
std::cout << *name << '\n'; // will print 'J'
Lưu ý rằng chúng ta sẽ không thực sự dereferencing bản thân cái mảng. Mảng (thuộc kiểu int[5]) được chuyển đổi ngầm định thành một con trỏ (kiểu int *), và chúng ta sẽ dereference con trỏ này để lấy được giá trị tại địa chỉ bộ nhớ mà con trỏ này đang giữ (giá trị của phần tử đầu tiên trong mảng).
Chúng ta cũng có thể gán cho một con trỏ để nó trỏ tới mảng:
#include <iostream>
int main()
{
int array[5]{ 9, 7, 5, 3, 1 };
std::cout << *array << '\n'; // will print 9
int *ptr{ array };
std::cout << *ptr << '\n'; // will print 9
return 0;
}
Đoạn code trên sẽ hoạt động vì mảng sẽ phân rã thành một con trỏ kiểu int *, và con trỏ của chúng ta (cũng thuộc kiểu int *) thì có cùng kiểu với nó.
2. Sự khác nhau giữa con trỏ và mảng cố định
Có một vài trường hợp trong đó sự khác nhau trong cách gõ giữa mảng cố định và con trỏ có thể thạo ra sự khác biệt. Điều này giúp minh họa rằng mảng cố định và con trỏ thì không giống nhau.
Sự khác biệt chính xuất hiện khi sử dụng toán tử sizeof(). Khi được sử dụng trên một mảng cố định, sizeof trả về kích thước của toàn bộ mảng (độ dải mảng nhân với kích thước phần tử mảng). Khi được sử dụng trên một con trỏ, sizeof sẽ trả về kích thước của một địa chỉ bộ nhớ (tính bằng bytes). Chương trình sau sẽ minh họa điều này:
/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/
#include <iostream>
int main()
{
int array[5]{ 9, 7, 5, 3, 1 };
std::cout << sizeof(array) << '\n'; // will print sizeof(int) * array length
int *ptr{ array };
std::cout << sizeof(ptr) << '\n'; // will print the size of a pointer
return 0;
}
Kết quả in ra:
20
4
Mảng cố định biết được độ dài của mảng mà nó đang trỏ tới là bao nhiêu. Con trỏ trỏ tới mảng thì không thực hiện được việc này.
Sự khác biệt thứ hai xuất hiện khi sử dụng toán tử địa chỉ-của (&). Việc nhận vào địa chỉ của một con trỏ sẽ trả về địa chỉ bộ nhớ của biến con trỏ. Nhận vào địa chỉ của mảng sẽ trả về một con trỏ trỏ tới toàn bộ mảng, con trỏ này cũng trỏ đến phần tử đầu tiên của mảng, nhưng thông tin về kiểu dữ liệu là khác nhau (trong ví dụ trên, int(*)[5]) (sẽ không bao giờ bạn phải sử dụng đến cái này).
3. Xem xét lại việc truyền mảng cố định cho các hàm
Quay lại bài 6.2 – Arrays (Phần II), chúng ta đã biết rằng việc sao chép các mảng lớn có thể làm tiêu tốn rất nhiều tài nguyên máy tính, C++ không thực hiện sao chép mảng khi truyền mảng vào một hàm. Khi tham số của hàm được khai báo là một con trỏ, việc truyền một mảng cố định làm đối số cho tham số này sẽ như sau: Mảng cố định sẽ được phân rã thành một con trỏ, và con trỏ này sẽ được truyền vào hàm:
#include <iostream>
void printSize(int *array)
{
// array is treated as a pointer here
std::cout << sizeof(array) << '\n'; // prints the size of a pointer, not the size of the array!
}
int main()
{
int array[]{ 1, 1, 2, 3, 5, 8, 13, 21 };
std::cout << sizeof(array) << '\n'; // will print sizeof(int) * array length
printSize(array); // the array argument decays into a pointer here
return 0;
}
Kết quả in ra là:
32
4
Lưu ý rằng, điều này xảy ra ngay cả khi tham số của hàm được khai báo là một mảng cố định:
/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/
#include <iostream>
// C++ will implicitly convert parameter array[] to *array
void printSize(int array[])
{
// array is treated as a pointer here, not a fixed array
std::cout << sizeof(array) << '\n'; // prints the size of a pointer, not the size of the array!
}
int main()
{
int array[]{ 1, 1, 2, 3, 5, 8, 13, 21 };
std::cout << sizeof(array) << '\n'; // will print sizeof(int) * array length
printSize(array); // the array argument decays into a pointer here
return 0;
}
Kết quả in ra là:
32
4
Trong ví dụ trên, C++ đã ngầm định chuyển đổi các tham số hàm (sử dụng cú pháp mảng – []) thành cú pháp con trỏ (*). Điều này có nghĩa là hai khai báo hàm bên dưới đây là tương tự nhau:
void printSize(int array[]);
void printSize(int *array);
Một số lập trình viên thích sử dụng cú pháp [] hơn bởi vì nó thể hiện rõ ràng rằng hàm này đang cần một mảng được truyền vào, chứ không phải là một con trỏ trỏ đến một giá trị nài đó. Tuy nhiên, trong hầu hết các trường hợp, bởi vì con trỏ không biết được mảng lớn đến mức nào, nên bạn sẽ phải truyền kích thước mảng như một tham số riêng biệt (Tuy nhiên điều này có ngoại lệ đối với các strings – chuỗi, vì trong C++, các strings là null terminated, tức là các strings luôn luôn được kết thúc bởi một ký tự null ở cuối cùng chuỗi).
Chúng tôi khuyên bạn nên sử dụng cú pháp con trỏ, vì nó thể hiện rõ rằng tham số hàm đang được coi là một con trỏ, không phải là một mảng cố định, và các thao tác cụ thể như sizeof(), sẽ hoạt động như thể tham số hàm là một con trỏ.
Khuyến nghị: Ưu tiên cú pháp con trỏ (*) hơn là cú pháp mảng ([]) khi cần truyền mảng vào hàm.
4. Giới thiệu về truyền địa chỉ – pass by address
Có một thực tế ta cần phải ghi nhớ đó là các mảng sẽ được phân rã thành các con trỏ khi được truyền tới một hàm, điều này sẽ lý giải những lý do bản chất về việc tại sao khi thay đổi một mảng bên trong một hàm, sẽ khiến cho mảng đối số thực tế truyền vào cũng sẽ bị thay đổi. Hãy xem ví dụ sau:
/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/
#include <iostream>
// parameter ptr contains a copy of the array's address
void changeArray(int *ptr)
{
*ptr = 5; // so changing an array element changes the _actual_ array
}
int main()
{
int array[]{ 1, 1, 2, 3, 5, 8, 13, 21 };
std::cout << "Element 0 has value: " << array[0] << '\n';
changeArray(array);
std::cout << "Element 0 has value: " << array[0] << '\n';
return 0;
}
Kết quả in ra là:
Element 0 has value: 1
Element 0 has value: 5
Khi hàm changeArray() được gọi, mảng array sẽ được phân rã thành một con trỏ, và giá trị của con trỏ đó (địa chỉ bộ nhớ của phần tử đầu tiên của mảng array) sẽ được sao chép vào tham số ptr của hàm changeArray(). Mặc dù giá trị trong ptr là một bản sao của địa chỉ của mảng array, nhưng ptr vẫn trỏ đến một mảng thật sự (không phải một bản sao!). Do đó, khi ptr được dereferenced, mảng thật sự sẽ được dereferenced!
Độc giả thông minh có thể đã nhận ra những thao tác con trỏ đối với mảng vừa được đề cập ở trên, cũng có thể được áp dụng đối với các giá trị không phải mảng. Chúng ta sẽ thảo luận về chủ đề này (được gọi là truyền địa chỉ – passing by address) chi tiết hơn trong chương tiếp theo.
5. Mảng nằm trong struct và class sẽ không bị phân rã
Cuối cùng, cần lưu ý rằng, trong trường hợp các mảng là một phần của struct hoặc class sẽ không bị phân rã khi toàn bộ struct hoặc class được truyền cho hàm. Điều nay mang lại một cách hữu ích để ngăn ngừa sự phân rã nếu muốn, và sẽ có giá trị sau này khi chúng ta viết các class có sử dụng mảng.
Trong bài học tiếp theo, chúng ta sẽ xem xét về các phép toán số học trên Con trỏ (pointer arithmetic), và nói về cách thức hoạt động của việc đánh chỉ số cho mảng (array indexing).