8.2 Con trỏ và mảng một chiều

Chào các bạn đang theo dõi khóa học lập trình trực tuyến ngôn ngữ C++.

Qua một số bài học tìm hiểu về 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 chức năng của con trỏ là để 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ỏ.

Trước khi vào phần trọng tâm bài học, chúng ta cùng xem lại một chút về khái niệm virtual memory. Virtual memory là một kĩ thuật quản lý bộ nhớ được thực hiện bởi cả phần cứng lẫn phần mềm trên máy tính chúng ta đang sử dụng. Mục đích của việc sử dụng kỹ thuật này là tổ chức các vùng bộ nhớ có thể sử dụng được trên các thiết bị lưu trữ (RAM, Hard disk drive, ...) thành một dãy địa chỉ ảo liên tiếp nhau từ 0x00000000 (0) đến 0xFFFFFFFF (4294967295) (giả sử mình đang xét trên hệ điều hành nền tảng 32 bits).

Khi thao tác với virtual memory chúng ta sẽ có cảm giác như đang làm việc với những vùng nhớ có dãy địa chỉ liên tục nhau. Và với con trỏ trong ngôn ngữ C/C++, chúng ta có thể làm việc trực tiếp với các vùng nhớ trên bộ nhớ ảo.

Các bạn có thấy cấu trúc tổ chức lưu trữ của virtual memory giống với cấu trúc dữ liệu nào mà chúng ta đã cùng tìm hiểu không? Đó chính là 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ử, đị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.

Như mình đã nói ở trên, con trỏ trong ngôn ngữ C/C++ có thể thao tác trực tiếp với bộ nhớ ảo, vậy thì 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.

Địa chỉ của mảng một chiều và các phần tử trong mảng một chiều

Mình lấy một ví dụ về mảng một chiều được khai báo với 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ị giống nhau:

//show address of arr in virtual memory
cout << &arr << endl;

//show address of the first element of arr
cout << &arr[0] << endl;

Có một điểm đặc biệt của mảng một chiều trong C/C++, đoạn chương trình sau sẽ cho thấy điều đó:

//show address of arr in virtual memory
cout << &arr << endl;

//show address of the first element of arr
cout << &arr[0] << endl;

cout << "==============================" << endl;
cout << arr << endl;

Kết quả ghi nhận được trên máy tính của mình:

Điều này chứng tỏ rằng việc sử dụng tên mảng một chiều cũng đồng nghĩa đ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:

cout << arr << enld;
cout << arr + 1 << endl;
cout << arr + 2 << endl;
cout << arr + 3 << endl;
cout << arr + 4 << endl;

Mảng arr là một tập hợp các phần tử số nguyên được cấp phát địa chỉ liên tiếp nhau trên bộ nhớ ảo. Các bạn cũng đã biết, sử dụng toán tử address-of sẽ trả về giá trị kiểu con trỏ, mình sử dụng toán tử (+) cho con mảng arr sẽ lấy được địa chỉ của các phần tử đứng sau phần tử đầu tiên của mảng arr.

Với những địa chỉ này, chúng ta cũng có thể sử dụng toán tử dereference để truy xuất giá trị của chúng:

cout << *(arr) << enld;
cout << *(arr + 1) << endl;
cout << *(arr + 2) << endl;
cout << *(arr + 3) << endl;
cout << *(arr + 4) << endl;
Con trỏ trỏ đến mảng một chiều

Mình lấy lại ví dụ mảng một chiều có 5 phần tử kiểu int giống như trên:

int arr[] = { 3, 5, 65, 23, 11 };

Vì mỗi phần tử bên trong mảng đều có kiểu int, do đó, chúng ta có thể sử dụng 1 con trỏ có kiểu dữ liệu tương ứng (int *) để trỏ đến từng phần tử của mảng arr.

int *ptr = &arr[2]; //ptr point to the 3rd element

Mình cho con trỏ ptr trỏ đến phần tử có chỉ số là 2 trong mảng arr.

Lúc này, chúng ta sử dụng dereference operator để truy xuất giá trị của ptr sẽ được giá trị 65.

cout << *ptr << endl;

Từ địa chỉ của arr[2] mà con trỏ ptr đ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.

cout << *(ptr - 1) << endl; //access the second element of arr
cout << *(ptr + 2) << endl; //access the last element of arr

Chúng ta cũng có thể sử dụng toán tử (++) hoặc (--) để cho con trỏ ptr trỏ đến phần tử tiếp theo hoặc phần tử đứng trước đó:

ptr++;
cout << *ptr << endl; //ptr is now point to &arr[3]

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:

for (ptr = &arr[0]; ptr <= &arr[4]; ptr++)
{
	cout << *ptr << " ";
}

Vòng lặp for ở ví dụ 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ỏ ptr, khi nào địa chỉ mà ptr 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 thì tiếp tục in giá trị mà ptr trỏ đến, cuối vòng lặp là cho ptr trỏ đến phần tử tiếp theo trong mảng.

Chúng ta có thể thay phép gán ptr = &arr[0]; bằng phép gán ptr = &arr; hoặc ngắn gọn hơn là ptr = arr;&arr[0], &arr hoặc arr đều cho chúng ta địa chỉ của phần tử đầu tiên trong mảng arr.

Vì thế, chúng ta có thể viết lại như sau:

for (ptr = arr; ptr <= arr + 4; ptr++)
{
	cout << *ptr << " ";
}

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:

int *ptr = arr; //ptr point to &arr[0]
for (int i = 0; i < 5; i++)
{
	cout << *(ptr + i) << " ";
}

Chúng ta có thể sử dụng dereference operator để truy xuất giá trị của từng phần tử thông qua tên của mảng:

for (int i = 0; i < 5; i++)
{
	cout << *(arr + i) << " ";
}

Sau khi cho con trỏ trỏ đến mảng một chiều, chúng ta còn có thể sử dụng toán tử [] cho con trỏ để truy xuất đến các phần tử thay vì dùng tên mảng:

int *ptr = arr;
for (int i = 0; i < 5; i++)
{
	cout << ptr[i] << " ";
}

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 được bằng 2 con trỏ:

int *p_src = src;
int *p_des = des;

for (int i = 0; i < 5; i++)
{
	*(p_des + i) = *(p_src + i);
}

Đối với mảng kí tự (C-style string), chúng ta có thể trực tiếp in nội dung của chuỗi kí tự sử dụng đối tượng cout. Ví dụ:

char my_name[50];
cout << "Enter your name: ";
gets_s(my_name);

cout << "Hello " << my_name << endl;

Như vậy chúng ta chỉ cần cung cấp cho đối tượng cout địa chỉ của mảng kí tự my_name, toàn bộ nội dung của mảng kí tự my_name sẽ được in ra màn hình. Và nếu chúng ta sử dụng một con trỏ kiểu (char *) để trỏ đến mảng my_name, chúng ta có thể dùng tên con trỏ để in mảng đó ra màn hình:

char *p_name = my_name;
cout << "Hello " << p_name << 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ể sử dụng đối tượng cout để in nội dung mà con trỏ đó đang trỏ đến. Ví dụ:

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 học tiếp theo.

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. Dễ nhận thấy nhất là khi sử dụng toán tử sizeof(). Ví dụ:

int arr[5];
int *ptr = arr;

cout << "Size of arr: " << sizeof(arr) << endl;
cout << "Size of ptr: " << sizeof(ptr) << endl;

Debug đoạn chương trình này trên nền tảng 32 bits chúng ta thu được kết quả:

Size of arr: 20
Size of ptr: 4

Khi sử dụng mảng một chiều, toán tử sizeof trả về kích thước của toàn bộ phần tử bên trong mảng. Trong khi đó, con trỏ sau khi trỏ đến mảng một chiều vẫn có kích thước 4 bytes (trên hệ điều hành 32 bits) như cũ.

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.

Ngoài ra, mảng một chiều sau khi khai báo có địa chỉ cố định trên bộ nhớ ảo, con trỏ sau khi trỏ đến mảng một chiều vẫn có thể được trỏ đi nơi khác.


Tổng kết

Trong bài học này, chúng ta đã cùng tìm hiểu một số đặc điểm giống và khác giữa mảng một chiều và con trỏ trong ngôn ngữ C/C++. Việc sử dụng con trỏ để quản lý mảng một chiều thường được dùng khi viết các hàm thao tác với mảng. Mình sẽ đề cập vấn đề này trong một số bài học tiếp theo.


Hẹn gặp lại các bạn trong bài học tiếp theo trong khóa học lập trình C++ hướng thực hành.

Mọi ý kiến đóng góp hoặc thắc mắc có thể đặt câu hỏi trực tiếp tại diễn đàn.

www.daynhauhoc.com


Link Videos khóa học

https://www.udemy.com/c-co-ban-danh-cho-nguoi-moi-hoc-lap-trinh/learn/v4/overview

  • Khóa học C++
    • Giới thiệu tổng quan khóa học
    • C++ cơ bản
    • Cấu trúc rẽ nhánh
    • Cấu trúc vòng lặp
    • Nâng cao về biến, kiểu dữ liệu
    • Kiểu dữ liệu mảng
    • Kiểu chuỗi kí tự
    • Cơ bản về Function
    • Con trỏ
    • Kiểu dữ liệu tự định nghĩa
    • Nhập, xuất, streams (Input & Output)
    • Standard Template Library
    • Smart pointer
    • Quản lý mã nguồn
    • Một số feature trong C++11, C++14