Nói về con trỏ [P. 2]

Các bài viết trong loạt bài “Nói về con trỏ”

1.2. Khai báo mảng động

Mảng 1 chiều

Trước khi đi tiếp, hãy kiểm tra lại chút kiến thức nhé. Thế mảng động là gì nhỉ?

Chắc phần lớn câu trả lời sẽ là mảng khai báo sau khi biết kích thước.

Vậy xem thử cái này nhé:

#include <stdio.h>

int main()
{
    int n;

    printf("Enter n: ");
    scanf("%d", &n);

    int a[n];

    for (int i = 0; i < n; i++)
        a[i] = i;
    for (int i = 0; i < n; i++)
        printf("%5d", a[i]);

    return 0;
}

Chắc chắn bạn sẽ nghĩ rằng sẽ phát sinh lỗi vì bạn thường được giảng viên bảo rằng n (trong int a[n];) phải là hằng số, vậy mà trong đoạn code trên n lại là biến số. Bạn là một sinh viên ngoan và rất nhớ lời giảng viên dạy đấy. Nhưng tiếc là bạn thiếu một chút phá phách để phát hiện ra một vài thứ nho nhỏ hay ho khác.

Input:

Enter n: 10

Output:

0 1 2 3 4 5 6 7 8 9

Chương trình vẫn chạy hoàn toàn tốt đấy chứ.

Một vài tia sáng đã hé lộ, quay lại câu hỏi lúc đầu nào. Bạn đã nhận ra câu trả lời bên trên có gì đó chưa ổn đúng không? Nếu thật sự mảng động là như thế thì khai báo như trên cho rồi, đơn giản, dễ hiểu, cần gì phải con trỏ cho rắc rối (nói về cách khai báo và sử dụng, chứ về bản chất nó vẫn là một con trỏ). Chắc bạn cũng đồng ý với mình về điều này.

Thật sự, một mảng được gọi là động (mảng động) không nằm ở chỗ khai báo đúng kích thước ta cần mà nằm ở chỗ, nó có khả năng thay đổi linh hoạt kích thước sau đó. Về điều này thì cách khai báo mảng như trên không đáp ứng được. Do đó, cách khai báo như trên được gọi là khai báo mảng tĩnh (hay thường gọi đơn giản là khai báo mảng).

Vậy để khai báo một mảng động, ta cần sử dụng đến con trỏ, do nó có khả năng trỏ đến trỏ lui, thay đổi linh hoạt vùng nhớ lưu giá trị thực sự, điều này đáp ứng được yêu cầu của một mảng động theo như định nghĩa bên trên.

int *a = NULL; //Khai báo
a = (int*)malloc(sizeof(int[10])); //Khởi tạo

Bên trên là cách khai báo và khởi tạo mảng một chiều.

Vậy tại sao lại là a = (int*)malloc(sizeof(int[10])); mà không phải là *a = (int*)malloc(sizeof(int[10]));? Hãy để dành câu hỏi đó, đọc tiếp và bạn sẽ thấy câu trả lời.

Hai câu lệnh trên sẽ được hiểu như thế này: khai báo một con trỏ a, trỏ đến kiểu int nhưng ngay sau đó lại gán cho nó bằng NULL, tức không trỏ đến đâu hết. Tiếp theo đó, (int*)malloc(sizeof(int[10])) tương ứng với việc cấp phát một vùng nhớ với 10 ô nhớ liên tiếp có kiểu là int và gán địa chỉ của ô nhớ đầu tiên (tương đương a[0]) vào con trỏ a.

Một khi bạn đã hiểu rõ được từng câu lệnh làm gì, việc kiểm soát chúng sẽ trở nên dễ dàng hơn.

Quay lại câu hỏi vừa nãy, bạn đã có câu trả lời chưa?

Có 2 lý do để phải là a chứ không phải *a:

  1. (int*)malloc(sizeof(int[10])) sẽ trả về địa chỉ của ô nhớ đầu tiên như đã trình bày ở trên. Như vậy, để hứng lấy giá trị trả về đó bắt buộc phải dùng một con trỏ.
  2. Kiểu dữ liệu của vùng nhớ lưu giá trị thực sự mà a trỏ đến là int chứ không phải một con trỏ (chú ý chỗ này nhé, chút nữa chúng ta sẽ cần đến nó đấy). Chưa kể đến việc ban đầu đã gán cho a bằng NULL, tức chưa hề tồn tại vùng nhớ lưu giá trị thực sự của a. Bạn có thể thử ban đầu không gán NULL cho a nhưng kết quả cũng như vậy thôi, còn về lý do thì mình vừa trình bày đấy.

Sau khi có được con trỏ trỏ đến một dãy liên tiếp các ô nhớ có kiểu dữ liệu giống nhau (thường gọi là mảng), việc cần thiết tiếp theo là truy xuất vào từng phần tử trong mảng đó mà việc này thì dễ thôi. Có 2 cách cơ bản sau:

//Cách 1: cách này thường thấy nhất và cũng dễ dùng nhất
a[i];

//Cách 2:
//(a + i): sẽ trả về địa chỉ của ô nhớ thứ i bằng cách
//lấy địa chỉ của ô nhớ đầu tiên cộng với tích của i và kích thước của một ô nhớ
//Công thức:
//    địa chỉ(a + i) = địa chỉ(a) + i * kích thước của một ô nhớ
//Sau đó, dùng toán tử * (đã trình bày ở phần 1.1) để truy xuất đến địa chỉ đó,
//vùng nhớ lưu giá trị thực sự
*(a + i);

Phew, khai báo mảng động đã xong, truy xuất đến từng phần tử cũng xong, vậy động chỗ nào chứ?

Một câu hỏi đúng vì nãy giờ cũng chỉ khai báo, truy xuất chưa thấy động chỗ nào cả. Bình tĩnh nào, ngay đây thôi.

Một mảng động động ở chỗ này đây (int*)malloc(sizeof(int[10])). Bạn có thể cấp phát một vùng nhớ mới và trỏ a đến vùng nhớ này. Có nghĩa là, với cùng tên biến là a, bạn có thể trỏ đến những vùng nhớ khác nhau, dẫn đến việc kích thước của mảng có thể thay đổi và điều đó làm cho mảng có tính động.

...
int *a = NULL;
...
a = (int*)malloc(sizeof(int[10]));
...
a = (int*)malloc(sizeof(int[5]));
...
a = (int*)malloc(sizeof(int[22]));
...

Nói thêm một chút về động. Tính động ở đây không chỉ là việc có thể thay đổi kích thước linh hoạt mà còn có thể thay đổi cấu trúc dữ liệu. Đó có thể chỉ là một số nguyên, cũng có thể là một mảng một chiều, mảng hai chiều, hay thậm chí là n chiều. Con trỏ thực sự đã thể hiện rất tốt tính động này.

Quay lại về mảng động, tuy có thể thay đổi kích thước linh hoạt nhưng nó vẫn còn tồn tại một số khuyết điểm như việc thêm và xoá chưa thật sự gọn gàng lắm. Ví dụ như bạn muốn xoá phần tử a[2], bạn không thể viết đơn giản thế này free(a + 2) mà phải qua một số công đoạn lằng nhằng khác. Đó là lý do vì sao một số kiểu dữ liệu khác ra đời dùng con trỏ để biểu diễn thay cho mảng như: danh sách liên kết, stack, queue,… Tuy nhiên, tất cả những thứ đó lại là một câu chuyện khác và sẽ nằm ở một bài viết khác.

Digging deeper

Có một câu hỏi thú vị ở đây là khi cấp phát một vùng nhớ mới cho con trỏ thì vùng nhớ cũ thế nào, vẫn còn đó hay bị xoá đi?

Đoạn code sau sẽ làm rõ vấn đề:

#include <stdio.h>
#include <stdlib.h>
int main()
{
    int *a = NULL;

    a = (int*)malloc(sizeof(int[10]));

    for (int i = 0; i < 10; i++)
        a[i] = i;

    printf("\n");
    for (int i = 0; i < 10; i++)
        printf("%5d", *(a + i));

    int *b = a + 5; //Khai báo con trỏ b trỏ đến a[5]

    a = (int*)malloc(sizeof(int[5])); //Cấp phát vùng nhớ mới và trỏ a đến
    printf("\n-----------\n");
    for (int i = 0; i < 5; i++)
        a[i] = 2 * i;
    for (int i = 0; i < 5; i++)
        printf("%5d", *(a + i));

    //Kiểm tra vùng nhớ cũ của a đã bị xoá chưa
    printf("\n-----------\n");
    printf("Old a[6]: %d\n", *(b + 1));
    printf("Old a[9]: %d", *(b + 4));

    return 0;
}

Output:

    0    1    2    3    4    5    6    7    8    9
-----------
    0    2    4    6    8
-----------
Old a[6]: 6
Old a[9]: 9

Đã quá rõ rồi chứ, vùng nhớ cũ không bị xoá đi!

Điều này nhắc ta phải cẩn thận trong việc cấp phát vùng nhớ mới. Nếu quá lạm dụng và không quản lý tốt vùng nhớ cũ, bạn đang vứt rác vào bộ nhớ đấy, nó sẽ nhanh chóng đầy và điều này thì không tốt chút nào phải không?

Chờ đã, còn một phần nữa khá thú vị trong phần này mà mình có nhắc tới. Đó là mảng nhiều chiều hay thường dùng nhất là mảng 2 chiều và hãy để dành nó trong bài viết sau nhé.

Còn tiếp…

Tags: , , , ,

About ninjapro

It is better to feel by yourself about me

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: