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

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

Ở phần cuối bài viết trước, có một câu hỏi về mảng động, bạn còn nhớ không nào. Đương nhiên là câu trả lời sẽ nằm trong bài viết này nhưng không phải ngay bây giờ, bạn từ từ khám phá nhé.

1.3. Con trỏ và thủ tục, hàm

Truyền tham chiếu

Nhắc đến con trỏ và thủ tục, hàm, bạn thường liên tưởng ngay đến việc truyền tham số và một ví dụ kinh điển đó là hoán vị giá trị của 2 số. Hãy bắt đầu từ sai lầm:

#include <stdio.h>

void swap(int a, int b)
{
    printf("Address of a: %p\n", &a);
    int temp = a;
    a = b;
    b = temp;
    temp = a;
}

int main()
{
    int a = 10;
    int b = 20;

    printf("Address of a: %p\n", &a);
    printf("a: %d & b: %d\n", a, b);
    swap(a, b);
    printf("a: %d & b: %d\n", a, b);

    return 0;
}

Chắc hẳn bạn biết rằng thủ tục swap ở trên là sai và sẽ không hoán vị giá trị của 2 số được truyền vào. Ta đã truyền tham trị cho thủ tục swap do đó không nhận được kết quả mong muốn.

Giải thích rõ hơn về truyền tham trị, thủ tục swap đã tự tạo biến mới, biến cục bộ, và sao chép giá trị của các tham số vào những biến này. Sau đó, tất cả những hành động thay đổi giá trị đều xảy ra trên những biến cục bộ này và kết quả là những biến ta truyền vào chẳng hề bị tác động gì cả.

Để minh chứng rõ hơn cho điều này, mình đã thêm 2 dòng printf (1 trong main và 1 trong swap) để xuất ra địa chỉ của biến a.

Output:

Address of a: 0x7fff5fbff998
a: 10 & b: 20
Address of a: 0x7fff5fbff93c
a: 10 & b: 20

Rõ ràng rằng, địa chỉ của 2 biến a (tuy rằng cùng tên) là khác nhau nên những thay đổi của biến này sẽ hoàn toàn độc lập với biến kia. Đó là lý do vì sao giá trị của 2 biến vẫn chưa được hoàn đổi.

Để giải quyết vấn đề này, ta sẽ truyền tham chiếu và thực hiện việc đó qua con trỏ.

#include 

void swap(<strong>int *a, int *b</strong>)
{
    printf("Address of *a: %p\n", &(*a));
    printf("Address of a: %p\n", &a);
    int temp = <strong>*a</strong>;
    <strong>*a</strong> = <strong>*b</strong>;
    <strong>*b</strong> = temp;
    temp = <strong>*a</strong>;
}

int main()
{
    int a = 10;
    int b = 20;

    printf("Address of a: %p\n", &a);
    printf("a: %d & b: %d\n", a, b);
    swap(<strong>&a, &b</strong>);
    printf("a: %d & b: %d", a, b); 

    return 0;
}

Hãy để ý những chỗ in đậm, đó là những điểm cần lưu ý.

Hãy bắt đầu với thủ tục swap. Lần này, tham số truyền vào thủ tục swap không phải kiểu int nữa mà là kiểu int*, con trỏ đến kiểu int. Đây là bước đầu tiên cần làm khi muốn truyền tham chiếu, thay đổi kiểu của tham số, từ một kiểu bình thường (int, char,… hay kiểu bạn tự định nghĩa) thành con trỏ đến kiểu đó, như ở ví dụ trên là từ int thành int*. Bên trong thủ tục swap, bất cứ khi nào muốn truy xuất đến giá trị của ab đừng quên toán tử * (điều này mình đã trình bày khá rõ ở phần 1). Hãy tạm bỏ qua 2 dòng, printf, chút nữa ta sẽ quay lại sau.

Điểm cần lưu ý tiếp theo là việc gọi thủ tục swap và hơn hết là cách truyền tham số. Ở đây, để truyền tham giá cho thủ tục swap, ta viết &a, &b. Mình sắp đặt một câu hỏi, bạn đoán nó là gì? … Tại sao lại là &a, &b? a, b hay &a, b hay a, &b có được không?

Bạn còn nhớ cái này chứ?

int b = 100;
int *a = &b;

Chắc bạn vừa “À” lên sau khi xem lại và chắc cũng không cần câu trả lời của mình đâu nhỉ?

Được rồi, giờ hãy kết hợp mọi thứ lại nào.

Khi thủ tục swap được gọi, nó cũng sẽ tự tạo ra 2 biến cục bộ và giá trị của chúng sẽ là địa chỉ của biến a và b ở hàm main. Sau đó, ta gọi *a, *b, tức vùng nhớ thực sự lưu giá trị của 2 biến a và b trong thủ tục swap, hay nói cách khác là ta đang trực tiếp tác động lên giá trị của biến a và b ở hàm main.

Cũng để minh hoạ rõ, mình đã thêm một số dòng printf vào chương trình. Và đây là kết quả:

Address of a: 0x7fff5fbff998
a: 10 & b: 20
Address of *a: 0x7fff5fbff998
Address of a: 0x7fff5fbff938
a: 20 & b: 10

Bạn thấy đấy, địa chỉ của *a trong thủ tục swap giống với a trong hàm main. Vì vậy, dù đang thao tác với a và b trong thủ tục swap nhưng thực sự, ta đang tác động trực tiếp đến giá trị của a và b trong hàm main. Nói cách khác, ta thông qua a và b trong thủ tục swap để điều khiển giá trị của a và b trong hàm main.

Digging deeper

Nếu 2 biến a, b không phải kiểu int mà là int* thì như thế nào?
Cũng đơn giản thôi, lúc đó chương trình sẽ biến đổi một chút và thành như thế này:

#include <stdio.h>
#include <stdlib.h>

void swap(int *a, int *b)
{
    int temp = *a;

    *a = *b;
    *b = temp;
}

int main()
{
    <strong>int *a = malloc(sizeof(int)); int *b = malloc(sizeof(int));</strong>

    *a = 100;
    *b = 200;

    printf("a: %d & b: %d\n", *a, *b);
    swap(<strong>a, b</strong>);
    printf("a: %d & b: %d", *a, *b);

    return 0;
}

Chỉ có một chút thay đổi nhỏ lúc gọi thủ tục swap mà thôi.

Tuy nhiên, nếu a, b là một con trỏ và bạn thực sự hiểu rõ vấn đề, còn cách nữa như sau:

#include <stdio.h>
#include <stdlib.h>

void swap(<strong>int **a, int **b</strong>)
{
    <strong>int *temp</strong> = *a;

    *a = *b;
    *b = temp;
}

int main()
{
    int *a = malloc(sizeof(int));
    int *b = malloc(sizeof(int));

    *a = 100;
    *b = 200;

    printf("a: %d & b: %d\n", *a, *b);
    swap<strong>(&a, &b</strong>);
    printf("a: %d & b: %d", *a, *b);

    return 0;
}

Lúc này, cách thức giải quyết vấn đề hoàn toàn thay đổi. Nếu như cách trước đó, bạn hoán vị giá trị của 2 số bằng cách hoán đổi trực tiếp giá trị của 2 số thì ở cách này, bạn lại không làm như vậy. Thay vào đó, bạn sử dụng tính chất của con trỏ, thay đổi cách trỏ của a và b, a sẽ trỏ vào vùng nhớ thực sự lưu giá trị của b và ngược lại.

Truyền tham số là mảng

Như đã nói ở đầu bài viết này, ta còn một câu hỏi về mảng động và từ đây đến hết bài viết này, ta sẽ dần dần giải đáp câu hỏi đó.

#include <stdio.h>

void arr1(<strong>int a[]</strong>)
{
    printf("%d\n", a[0]);
}

int main()
{
    int a[] = {1, 3, 4};

    arr1(a);

    return 0;
}

Output:

1

Đoạn code trên truyền một mảng 1 chiều bình thường và hoàn toàn chạy tốt. Tiếp theo, hãy truyền một mảng 2 chiều bình thường.

#include <stdio.h>

void arr1(<strong>int a[][]</strong>)
{
    printf("%d\n", a[0][1]);
}

int main()
{
    int a[2][2];

    a[0][1] = 100;
    arr1(a);

    return 0;
}

Không ổn rồi, chương trình sẽ báo lỗi đấy mà cụ thể là ở dòng này void arr1(int a[][]). Nếu muốn đoạn code trên chạy được, bạn cần sửa thành thế này void arr1(int a[2][2]).

Về tính thực thi thì chẳng có vấn đề gì cả, chương trình vẫn sẽ chạy được, không báo lỗi. Nhưng về tính đúng đắn, đồng nhất và linh hoạt thì cách trên không đáp ứng được. Như một ví dụ sau:

//Ví dụ 1

#include <stdio.h>

void arr1(int a[5][5], int i, int j)
{
    printf("%d\n", a[i][j]);
}

int main()
{
    int b[100][100];

    b[99][99] = 100;
    arr1(b, 99, 99);

    return 0;
}

Output:

0

Bạn khai báo tham số cho thủ tục arr1 là một mảng 2 chiều có kích thước 5 x 5. Tuy nhiên, trong quá trình sử dụng, bạn cần truyền vào một mảng 2 chiều có kích thước lớn hơn, như trong ví dụ là 100 x 100. Chương trình vẫn sẽ chạy nhưng trả về kết quả không đúng.

Digging deeper

Về bản chất, khi truyền tham số là một mảng tức là truyền tham chiếu.

#include <stdio.h>

void arr2(int a[])
{
    printf("Address of a (in arr2): %p\n", &a);
    printf("Address of *a (in arr2): %p\n", a);
    printf("%d\n", *(a + 0));
    a[0] = 100;
}

int main()
{
    int a[5] = {4, 5, 6, 1, 0};

    printf("Address of a: %p\n", a);
    arr2(a);
    printf("%d\n", a[0]);

    return 0;
}

Output:

Address of a: 0x7fff5fbff980
Address of a (in arr2): 0x7fff5fbf5d08
Address of *a (in arr2): 0x7fff5fbff980
4
100

Vì là truyền tham chiếu nên địa chỉ của *a trong thủ tục arr2 sẽ giống với địa chỉ của a trong hàm main
và việc thay đổi giá trị trong thủ tục arr2 sẽ ảnh hưởng đến giá trị của a trong hàm main.

Đối với mảng 2 chiều, cũng sẽ là truyền tham chiếu. Nếu vậy, tại sao ví dụ 1 lại cho ra kết quả không đúng?

Vấn đề nằm ở chỗ khai báo. Cách tổ chức bộ  nhớ và tính toán địa chỉ giữa int b[100][100] và int b[5][5] sẽ khác nhau, dẫn đến kết quả khác nhau. Bạn sẽ thấy rõ hơn qua đoạn code sau:

#include <stdio.h>

void arr1(int b[10][10], int i, int j)
{
    /*
    printf("%d\n", a[0][1]);
    a[0][1] = 100;
     */

    printf("Address of *b (in arr1): %p\n", b);
    printf("Address of b[%d][%d] (in arr1): %p\n", i, j, &b[i][j]);
    printf("%d\n", b[i][j]);
    b[i][j] = 200;
}

int main()
{
    int b[100][100];    

    printf("Address of b: %p\n", b);
    printf("Address of b[2][2]: %p\n", &b[2][2]);
    b[2][2] = 100;
    arr1(b, 2, 2);

    return 0;
}

Output:

Address of b: 0x7fff5fbf5d40
Address of b[2][2]: 0x7fff5fbf6068
Address of *b (in arr1): 0x7fff5fbf5d40
Address of b[2][2] (in arr1): 0x7fff5fbf5d98
0

Vậy để giải quyết vấn đề trên ta sẽ sử dụng con trỏ (hay mảng động).

Đối với mảng 1 chiều:

void arr(int *a)
{
    ...
}

Đối với mảng 2 chiều:

void arr(int **a)
{
    ...
}

Cụ thể, ta có một ví dụ minh hoạ sau:

#include <stdio.h>
#include <stdlib.h>
void output(int *a, n)
{
    for (int i = 0; i < n; i++)
        printf("%5d", a[i]);
}

int main()
{
    int n = 10;
    int *a = (int*)malloc(n * sizeof(int));   

    for (int i = 0; i < n; i++)
        a[i] = i;
    output(a, n);

    return 0;
}

Output:

    0    1    2    3    4    5    6    7    8    9

Qua phần này, ta đã trả lời được phần nào câu hỏi ban đầu. Hãy tiếp tục và khám phá tiếp nào!

Giá trị trả về là mảng

Tình huống đặt ra là bạn phải viết một hàm trả về một mảng. Bạn sẽ viết như sau:

...
int[] returnArray()
{
    ...
}
...

Không đơn giản thế đâu bạn à! Chương trình sẽ báo lỗi vì C không cho phép viết như thế. Tuy nhiên, C lại cho phép ta viết thế này.

...
int* returnArray()
{
    ...
}
...

int* cái này thấy quen quen nhỉ, hình ta khai báo mảng động một chiều thế này int *a = (int*)malloc(n * sizeof(int)); thì phải? Quá rõ rồi, chắc mình cũng không cần nói gì thêm gì, hay nhất là cho một ví dụ:

#include <stdio.h>
#include <stdlib.h>
int* createArray(int n)
{
    int *a = (int*)malloc(n * sizeof(int));

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

    return a;
}

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

int main()
{
    output(createArray(10), 10);

    return 0;
}

Output:

    0    1    2    3    4    5    6    7    8    9

Digging deeper

Đến đây, chắc bạn đã có một câu trả lời riêng cho câu hỏi ở cuối bài viết trước.  Mảng động và đặc biệt là con trỏ không chỉ dừng lại ở đây mà còn rất nhiều điều thú vị đang chờ bạn khám phá đấy.

1.4. Tổng kết

Tổng kết lại phần 1, mình đã trình bày những vấn đề cơ bản liên quan đến con trỏ trong C như:

  • Khai báo con trỏ
  • Khai báo mảng sử dụng con trỏ
  • Liên hệ giữa con trỏ và hàm, thủ tục

Ngoài ra cũng có một số phát hiện của mình trong quá trình viết bài nhằm chia sẻ và giúp bạn hiểu rõ, nắm chắc vấn đề hơn.

Hy vọng qua phần 1 này, khả năng kiểm soát và vận dụng con trỏ trong C của bạn sẽ tăng lên, bạn sẽ không còn cảm thấy con trỏ là một vấn đề khó khăn, khó hiểu nữa.

Những vấn đề liên quan đến trong trỏ trong C còn khá nhiều nữa, hãy tiếp tục tìm hiểu và khám phá nó nhé!

Phần tiếp theo của loạt bài này sẽ là những vấn đề cơ bản liên quan đến con trỏ trong C++. Nếu cần thiết, hãy đọc lại những bài viết trong phần 1 rồi hãy đi tiếp nhé, nó sẽ giúp bạn đi nhanh và dễ hiểu hơn trong phần tiếp theo đấy.

(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: