+4

Bài 9: Con trỏ (phần 2) - Hoạt động nâng cao với con trỏ trong C++

Đây là bài viết số 2 thuộc series bài viết Tham chiếu, Địa chỉ và Con trỏ trong C++ của chuyên đề lập trình C++ cơ bản định hướng thi HSG Tin học.

Để hiểu rõ về bài viết này, các bạn hãy tìm đọc lại các bài viết trước đây trong series này:

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

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 (00) đến 0xFFFFFFFF (42949672954294967295) (xét trên hệ điều hành nền tảng 32 bits, còn nếu là các hệ điều hành cao hơn sẽ có nhiều địa chỉ hơn nữa).

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. Chính vì thế, ta có thể sử dụng con trỏ để thao tác trực tiếp với mảng một chiều - một cấu trúc dữ liệu có tổ chức lưu trữ tương tự với bộ nhớ ảo.

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

Địa chỉ của mảng một chiều được quy ước là địa chỉ của phần tử đầu tiên trong mảng đó.

Xét mảng một chiều arrarr với 55 phần tử được khai báo như dưới đây:

int arr[] = {1, 2, 3, 4, 5};

Theo định nghĩa, địa chỉ của mảng arrarr sẽ là địa chỉ của phần tử arr0=1arr_0 = 1. Đoạn chương trình dưới đây sẽ cho thấy một điểm đặc biệt của địa chỉ mảng một chiều trong C++:

//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ả:

<center>

</center>

Có thể thấy, ngoài việc địa chỉ của arr0arr_0 và mảng arrarr là giống nhau, thì C++ cũng cho phép sử dụng trực tiếp tên mảng một chiều là arrarr để truy cập vào địa chỉ của mảng một chiều. Vì thế, chúng ta có thể in ra địa chỉ của cả 55 phần tử của mảng arrarr bằng cách sau:

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

Và toán tử * cũng có thể được sử dụng để lấy giá trị của các phần tử mảng arrarr tại từng địa chỉ:

cout << *(arr) << endl;
cout << *(arr + 1) << endl;
cout << *(arr + 2) << endl;
cout << *(arr + 3) << endl;
cout << *(arr + 4) << endl;

Kết quả chạy chương trình sẽ như sau (đã bổ sung một số dòng thông báo trong code hoàn chỉnh):

2. Con trỏ tới mảng một chiều

Vẫn xét mảng arrarr ở ví dụ trên:

int arr[] = {1, 2, 3, 4, 5};

Mỗi phần tử của mảng đều có kiểu là int, do đó ta cũng có thể sử dụng con trỏ kiểu tương ứng để trỏ tới từng phần tử của mảng, như ví dụ dưới đây:

int *ptr = &arr[2]; 
cout << ptr << endl; // Kết quả: ptr = 0x104dfee0;
cout << *ptr; // Kết quả: 3.

Từ địa chỉ của phần tử arr2arr_2 mà con trỏ ptrptr đang nắm giữ, ta có thể sử dụng các toán tử +, -, ++ hay -- để dịch chuyển con trỏ này sang các vị trí khác trong mảng, 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; // In ra phần tử a[1].
cout << *(ptr + 2) << endl; // In ra phần tử a[4].

ptr--;
cout << *ptr << endl; // In ra phần tử arr[3].

Và chỉ cần sử dụng một con trỏ ptrptr có cùng kiểu int với mảng arr,arr, ta có thể quản lý và duyệt qua được mọi phần tử trong mảng, theo nhiều cách khác nhau:

// Cách 1.
for (ptr = &arr[0]; ptr <= &arr[4]; ptr++)
    cout << *ptr << ' ';

// Cách 2.
int *ptr = arr;
for (int i = 0; i < 5; ++i)
    cout << *(ptr + i) << ' ';

// Cách 3: Truy cập thông qua tên mảng. 
for (int i = 0; i < 5; i++)
    cout << *(arr + i) << ' ';

Tất cả các cách làm trên đều giúp ta in ra toàn bộ phần tử trong mảng, các bạn có thể sử dụng tùy theo ý thích cũng như tùy vào công việc cần làm trong bài toán cụ thể (có thể sử dụng để nhập mảng, thay cout bằng cin chẳng hạn). Tuy nhiên, nhược điểm của việc sử dụng con trỏ quản lý mảng một chiều là ta sẽ không biết được chính xác mảng đó có bao nhiêu phần tử. Cùng xem ví dụ dưới đây:

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

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

Toán tử sizeof() là toán tử sử dụng để lấy kích thước. Kết quả đoạn chương trình trên sẽ là:

Size of arr: 20
Size of ptr: 4

Lí do là vì, khi sử dụng với mảng arr,arr, 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ũ.

II. Con trỏ và hàm

1. Sử dụng con trỏ trong tham số của hàm

Chúng ta đã tìm hiểu về 22 kiểu tham số của hàm trong các bài học trước:

  • Hàm có tham số nhận giá trị: Giá trị truyền vào hàm có thể là giá trị của biến, một hằng số hoặc một biểu thức toán học...
  • Hàm có tham số kiểu tham chiếu: Giá trị truyền vào cho hàm là tên biến, và tham số của hàm sẽ tham chiếu trực tiếp đến vùng nhớ của biến đó (dùng thêm toán tử & ở trước tên tham số).

Chúng ta còn có thêm một kiểu truyền dữ liệu vào cho hàm nữa, đó là Truyền địa chỉ vào hàm (Pass arguments by address). Do đó, kiểu tham số của hàm có thể nhận giá trị là địa chỉ phải là con trỏ. Cùng xét ví dụ dưới đây:

void func(int *ptr)
{
    cout << "Int value at " << ptr << " is " << *ptr;
}

int main()	
{
    int value = 10;
    func(&value);

    return 0;
}

Trong đoạn chương trình trên, sau khi truyền địa chỉ của biến valuevalue vào hàm func(), thì tham số ptrptr sẽ giữ một bản sao của địa chỉ biến valuevalue. Mặc dù là truyền con trỏ nhưng cách làm này vẫn là truyền tham trị, nên hệ thống cũng sẽ copy ra một bản sao của giá trị địa chỉ truyền vào rồi mới gán vào tham số ptrptr. Kết quả in ra màn hình sẽ là:

Int value at 0x104dfefc is 10

Tuy nhiên, điều đặc biệt là nếu như vùng nhớ được truyền từ bên ngoài vào hàm không phải là một hằng số, thì ta có thể thay đổi giá trị của vùng nhớ đó ngay bên trong hàm, bằng cách sử dụng toán tử * như sau:

void change_value(int* ptr)
{
    *ptr = 10;
}

int main()
{
    int value = 5;
    cout << "value = " << value << endl;

    change_value(&value);
    cout << "value = " << value << endl;

    return 0;
}

Kết quả đoạn code trên sẽ là:

value = 5
value = 10

2. Sử dụng tham số hàm là tham chiếu vào con trỏ

Thực tế, khi ta truyền vào hàm một địa chỉ, thì chương trình vẫn sẽ tuân theo quy tắc truyền tham trị, đó là chỉ copy ra một bản sao của địa chỉ truyền vào, gán nó cho tham số hàm. Vì vậy, nếu như có câu lệnh thay đổi địa chỉ được truyền vào trong hàm, thì sự thay đổi đó kì thực chỉ diễn ra trên bản sao, chứ địa chỉ gốc không hề bị ảnh hưởng. Cùng xem xét ví dụ sau:

void set_to_null(int* ptr)
{
    ptr = NULL;
}

int main()
{
    int value = 5;
    int* p_value = &value;

    cout << "p_value point to " << p_value << endl;
    set_to_null(p_value);
    cout << "p_value point to " << p_value << endl;

    return 0;
}

Đoạn chương trình trên thực hiện gán địa chỉ của biến valuevalue cho con trỏ \text{p_value}, rồi truyền địa chỉ đó vào hàm set_to_null() để gán địa chỉ thành NULL. Tuy nhiên, ở hai lần in ra giá trị của con trỏ, ta đều thu được cùng một kết quả:

p_value point to 0x104efee8
p_value point to 0x104efee8

Lí do là vì, giá trị địa chỉ được truyền vào hàm chỉ là bản copy, từ đó chúng ta có thể sử dụng toán tử dereference để thao tác với vùng nhớ tại địa chỉ đó. Chúng ta cũng có thể cho tham số của hàm trỏ đến địa chỉ khác, nhưng không ảnh hưởng gì đến con trỏ gốc.

Vậy nếu trong một số trường hợp cụ thể, ta muốn thay đổi địa chỉ được truyền vào hàm và cập nhật lại ra con trỏ tham số thực sự bên ngoài, thì ta sẽ sử dụng cách truyền tham chiếu giống như khi thao tác với các biến thông thường (thêm toán tử & phía trước tham số hàm). Ví dụ, đoạn chương trình bên trên có thể viết lại như sau:

void set_to_null(int*& ptr)
{
    ptr = NULL;
}

int main()
{
    int value = 5;
    int* p_value = &value;

    cout << "p_value point to " << p_value << endl;
    set_to_null(p_value);
    cout << "p_value point to " << p_value << endl;

    return 0;
}

Bây giờ kết quả sẽ trở thành như thế này:

p_value point to 0x104efee8
p_value point to 0

3. Truyền mảng vào hàm thông qua con trỏ

Thông qua con trỏ, ta có thể tối giản việc truyền một mảng vào hàm bằng cách truyền vào hàm một con trỏ trỏ tới mảng cần truyền vào. Cú pháp như sau:

{Kiểu_hàm} {Tên_hàm}({Kiểu_phần_tử}* {Tên_con_trỏ_mảng})

Sau đó, khi truyền vào ta chỉ cần truyền tên mảng (đại diện cho địa chỉ đầu tiên của mảng) làm tham số thực sự, khi đó con trỏ tham số sẽ nhận địa chỉ đầu tiên của mảng truyền vào.

Đoạn chương trình dưới đây minh họa việc tính tổng mảng thông qua một hàm nhận tham số là con trỏ vào mảng:

#include <bits/stdc++.h>

using namespace std;

int a[101];

void array_sum(int n, int* ptr_a)
{
    int sum = 0;
    for (int i = 1; i <= n; ++i)
        sum += *(ptr_a + i);

    cout << "Tổng mảng đã nhập vào: " << sum;
}

int main()
{
    int n;
    cin >> n;

    for (int i = 1; i <= n; ++i)
        cin >> *(a + i); // Nhập dữ liệu vào phần tử a[i].

    // Gọi hàm tính tổng, truyền vào n và địa chỉ đầu tiên của mảng (chính là tên mảng).
    array_sum(n, a);

    return 0;
}

Khi chạy đoạn chương trình trên với input là a={1,2,3,4,5}a = \{1, 2, 3, 4, 5\} chẳng hạn, ta sẽ thu được kết quả:

Tổng mảng đã nhập vào: 15

4. Trả về một mảng từ hàm

Trong bài học về hàm, chúng ta đã biết về hàm có trả về giá trị và hàm không trả về giá trị (hàm kiểu void). Tuy nhiên, chúng ta lại chưa có cách nào để trả về trực tiếp một mảng tĩnh từ hàm. Việc trả về một địa chỉ từ hàm (return by address) sẽ giúp giải quyết vấn đề này.

Khi nói về việc trả về địa chỉ từ hàm, chúng ta hiểu rằng đó là địa chỉ của những biến hoạt động bên trong hàm. Địa chỉ này sẽ được trả về cho lời gọi hàm, và địa chỉ này thường được tiếp tục sử dụng bằng cách gán nó lại cho 1 con trỏ. Do đó, kiểu trả về của hàm cũng phải là kiểu con trỏ.

int* {tên_hàm}
{
    ...
}

Lợi dụng việc này, chúng ta có thể trả về một con trỏ trỏ tới một mảng một chiều. Tuy nhiên, nếu như mảng đó là một biến cục bộ của hàm, thì nó bắt buộc phải được khai báo bởi từ khóa static - theo quy định của ngôn ngữ C++. Chúng ta sẽ cùng tìm hiểu rõ hơn về vấn đề này khi học tới bài về Cấp phát bộ nhớ động.

Ví dụ dưới đây sẽ tạo ra một mảng gồm 1010 số ngẫu nhiên và trả về con trỏ vào mảng đó:

#include <iostream>
#include <ctime>
#include <stdlib.h>

using namespace std;

// Hàm tạo ra các số ngẫu nhiên, lưu vào một mảng r[10]. 
// Các bạn không cần hiểu hết mảng này, chỉ cần chú ý việc trả về mảng r cho hàm.
int* random_number()
{
    static int r[10];

    srand((unsigned) time(NULL)); // Hàm tạo số ngẫu nhiên ở mỗi lần chạy chương trình.
    for (int i = 0; i < 10; ++i)
    {
        r[i] = rand();
        cout << r[i] << endl;
    }

    return r;
}

int main()
{
    // Con trỏ p nhận kết quả trả về từ hàm. Giờ p trỏ vào một mảng.
    int* p = random_number();

    for ( int i = 0; i < 10; i++ )
    {
        cout << "Gia tri cua *(p + " << i << ") la: ";
        cout << *(p + i) << endl;
    }

    return 0;
}

Kết quả chạy đoạn chương trình trên sẽ là:

22335
31472
29539
27884
28506
5089
30722
5871
22248
1198
Gia tri cua *(p + 0) la: 22335
Gia tri cua *(p + 1) la: 31472
Gia tri cua *(p + 2) la: 29539
Gia tri cua *(p + 3) la: 27884
Gia tri cua *(p + 4) la: 28506
Gia tri cua *(p + 5) la: 5089
Gia tri cua *(p + 6) la: 30722
Gia tri cua *(p + 7) la: 5871
Gia tri cua *(p + 8) la: 22248
Gia tri cua *(p + 9) la: 1198

Như vậy, con trỏ pp đã được sử dụng để kiểm soát toàn bộ mảng một chiều được sinh ra từ hàm random_number(). Tuy nhiên, cách làm này không được khuyến khích khi sử dụng mảng, mà thay vào đó các bạn nên sử dụng biến toàn cục cho mảng. Đối với trường hợp muốn trả về kết quả là một mảng, thì ta nên sử dụng kiểu vector ở trong thư viện STL C++, sẽ được giới thiệu ở những bài học sau.

III. Con trỏ và hằng số

Chúng ta đã biết con trỏ cũng là một biến thông thường mà giá trị nó có thể chứa là địa chỉ của vùng nhớ khác. Như vậy, từ khóa const cũng có thể được sử dụng cho con trỏ như các biến có kiểu dữ liệu khác. Tuy nhiên, tùy vào vị trí đặt từ khóa const khi khai báo con trỏ mà nó lại có những ý nghĩa khác nhau.

1. Con trỏ trỏ tới hằng số

Xét ví dụ sau:

int value = 5;
int* ptr = &value;
*ptr = 10; // Thay đổi giá trị biến value thành 10.

Với đoạn code này, chương trình của chúng ta hoạt động bình thường. Nó đơn thuần chỉ là dùng một con trỏ có tên ptr trỏ đến địa chỉ của biến value. Bây giờ chúng ta có một chút thay đổi như sau:

const int value = 5;
int* ptr = &value; // Sẽ phát sinh dịch lỗi.

Nguyên do đoạn code trên dịch lỗi là vì vùng nhớ của biến valuevalue đã được điều chỉnh thành vùng nhớ hằng bằng từ khóa const, nghĩa là giá trị bên trong nó không thể bị thay đổi. Mặc dù chúng ta chỉ mới cho con trỏ ptr trỏ đến vùng nhớ hằng đó chứ chưa thực hiện câu lệnh nào liên quan đến việc thay đổi giá trị bên trong vùng nhớ của biến value,value, nhưng compiler ngăn chặn điều này để đảm bảo an toàn dữ liệu cho vùng nhớ của biến valuevalue.

Để có thể có được một con trỏ trỏ tới vùng nhớ hằng, ta cần sử dụng một công cụ khác là Pointer to const (Con trỏ trỏ tới hằng số). Rất đơn giản, ta thêm từ khóa const ở phía trước biến con trỏ:

const int value = 5;
const int *ptr = &value; 

Nhưng cần lưu ý rằng, việc sử dụng con trỏ trỏ tới hằng chỉ giúp chúng ta có thể gán địa chỉ của một hằng cho con trỏ, chứ giá trị của vùng nhớ hằng đó vẫn không thể thay đổi. Ngoài ra, con trỏ trỏ tới hằng số cũng vẫn có thể sử dụng để trỏ tới một vùng nhớ không phải hằng, như ví dụ dưới đây:

int value = 5;
const int* ptr = &value; // Câu lệnh chạy bình thường.
*ptr = 10; // Phát sinh dịch lỗi.

Khi chạy đoạn chương trình trên, câu lệnh *ptr = 10; sẽ bị báo lỗi, nguyên do là vì con trỏ trỏ tới hằng ptrptr mặc dù đang trỏ tới một vùng nhớ không phải hằng, nhưng nó lại có từ khóa const ở đằng trước, nên nó sẽ trở thành một con trỏ chỉ có chức năng đọc nội dung bên trong vùng nhớ mà không thể thay đổi giá trị bên trong vùng nhớ đó. Nếu chạy đoạn code trên, compiler sẽ báo lỗi sau:

assignment of read-only location '* ptr'

Nói cách khác, sử dụng pointer to const sẽ đảm bảo tính toàn vẹn dữ liệu cho vùng nhớ được nó trỏ đến. Cần lưu ý rằng, pointer to const không phải là một biến hằng, mà nó chỉ là một loại công cụ chỉ đọc, vì thế nó vẫn có thể trỏ tới các vùng nhớ khác sau khi khởi tạo. Điều này khác với con trỏ hằng (const pointer) mà tiếp theo chúng ta sẽ thảo luận.

2. Con trỏ hằng

Con trỏ hằng (Const pointer) là loại con trỏ chỉ gán được địa chỉ một lần khi khởi tạo, điều này có nghĩa sau khi trỏ đến vùng nhớ nào đó thì nó không thể trỏ đi nơi khác được. Để khai báo con trỏ hằng, chúng ta cần đặt từ khóa const giữa dấu * và tên con trỏ.

int value = 5;
int* const ptr = &value;

Giống như hằng số, con trỏ hằng bắt buộc phải được khởi tạo giá trị ngay khi khai báo, và địa chỉ đã được gán cho một con trỏ hằng thì không thể thay đổi nữa. Lấy ví dụ, đoạn chương trình sau đây sẽ báo lỗi khi ta cố gắng thay đổi địa chỉ của một con trỏ hằng đang nắm giữ:

int value1 = 5;
int value2 = 10;
int* const ptr = &value1;
ptr = &value2; // Dịch lỗi.

Tuy nhiên, điểm khác biệt của con trỏ hằng với con trỏ trỏ đến hằng là nếu như vùng nhớ được trỏ đến của con trỏ hằng không phải là hằng, thì con trỏ hằng vẫn thay đổi được giá trị của vùng nhớ đó. Xét ví dụ dưới đây:

int value = 5;
int* const ptr = &value;
*ptr = 10; // Câu lệnh chạy bình thường.

Biến valuevalue sẽ có thể thay đổi giá trị thông qua toán tử * trên con trỏ hằng ptrptr. Như vậy, con trỏ hằng có đầy đủ hai chức năng đọc và ghi giá trị lên vùng nhớ.

V. Tài liệu tham khảo


©️ Tác giả: Vũ Quế Lâm từ Viblo


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí