Con trỏ (pointer) trong C là gì? Cú pháp và cách hoạt động

Con trỏ trong C là gì? Hiểu địa chỉ bộ nhớ, toán tử & và *, con trỏ và mảng, truyền tham chiếu cho hàm cùng các lỗi hay gặp có ví dụ.

6 phút

Con trỏ trong C là biến lưu địa chỉ của một vùng nhớ khác, thay vì lưu trực tiếp một giá trị. Đây là phần khiến nhiều người học C thấy khó. Nhưng nếu đi chậm từng bước thì nó không đáng sợ như lời đồn. Bài này đi qua địa chỉ bộ nhớ, cú pháp và mối quan hệ với mảng. Sau đó chúng ta sẽ xem cách truyền tham chiếu cho hàm.

Địa chỉ bộ nhớ và biến

Địa chỉ bộ nhớ và biến
Địa chỉ bộ nhớ và biến

Mọi biến trong chương trình đều nằm ở một vị trí trong bộ nhớ. Vị trí đó có một địa chỉ. Bạn lấy địa chỉ của biến bằng toán tử &.

#include <stdio.h>

int main(void) {
    int x = 42;
    printf("Giá trị: %d\n", x);
    printf("Địa chỉ: %p\n", (void *)&x);
    return 0;
}

Địa chỉ in ra mỗi lần chạy có thể khác nhau, vì hệ điều hành xếp bộ nhớ khác nhau. Đừng lo về giá trị cụ thể của nó. Điều cần nhớ là nó tồn tại và ta có thể lưu nó lại.

Khai báo và sử dụng con trỏ

Khai báo và sử dụng con trỏ
Khai báo và sử dụng con trỏ
int x = 42;
int *p = &x;      // p lưu địa chỉ của x
printf("%d\n", *p);   // 42: đọc giá trị tại địa chỉ p
*p = 100;              // ghi giá trị vào x thông qua p
printf("%d\n", x);    // 100

Dấu sao có hai vai trò dễ nhầm. Khi khai báo, int *p nghĩa là p là con trỏ tới int. Khi dùng trong biểu thức, *p nghĩa là lấy giá trị nằm tại địa chỉ mà p đang giữ. Cách nhớ của chúng tôi: dấu & là “lấy địa chỉ”, dấu * là “đi theo địa chỉ”.

Con trỏ chưa được gán địa chỉ hợp lệ là nguồn lỗi lớn. Hãy luôn khởi tạo bằng NULL nếu chưa có địa chỉ để trỏ. Ngoài ra, hãy kiểm tra p != NULL trước khi dùng *p.

Con trỏ và mảng

Con trỏ và mảng
Con trỏ và mảng

Tên mảng trong hầu hết biểu thức tự chuyển thành địa chỉ phần tử đầu tiên. Do đó bạn có thể duyệt mảng bằng con trỏ và số học con trỏ.

int a[] = {10, 20, 30};
int *p = a;
for (int i = 0; i < 3; i++) {
    printf("%d ", *(p + i));   // giống a[i]
}

Khi cộng 1 vào con trỏ int *, địa chỉ tăng thêm đúng kích thước một int. Thường đó là 4 byte, chứ không phải 1 byte. Nhờ vậy p + 1 trỏ tới phần tử kế tiếp. Biểu thức a[i] thực chất được định nghĩa là *(a + i). Muốn duyệt nhiều phần tử như vậy, bạn sẽ dùng vòng lặp for trong C.

Truyền tham chiếu cho hàm

Truyền tham chiếu cho hàm
Truyền tham chiếu cho hàm

C luôn truyền tham số theo giá trị, tức hàm nhận một bản sao. Vì vậy hàm đổi chỗ hai số sau đây sẽ không có tác dụng nếu bạn truyền thẳng giá trị.

void doiCho(int *a, int *b) {
    int tam = *a;
    *a = *b;
    *b = tam;
}

int main(void) {
    int x = 1, y = 2;
    doiCho(&x, &y);
    printf("%d %d\n", x, y);   // 2 1
    return 0;
}

Truyền địa chỉ cho phép hàm sửa trực tiếp biến của nơi gọi. Cách này cũng tránh sao chép dữ liệu lớn khi truyền cấu trúc hay mảng.

Cấp phát động với malloc

Khi chưa biết trước cần bao nhiêu phần tử, bạn xin bộ nhớ lúc chương trình đang chạy bằng malloc. Khi xong việc, hãy trả lại bằng free.

#include <stdlib.h>

int n = 5;
int *a = malloc(n * sizeof(int));
if (a == NULL) { return 1; }
for (int i = 0; i < n; i++) a[i] = i * i;
free(a);
a = NULL;

Quên free gây rò rỉ bộ nhớ. Gọi free hai lần trên cùng con trỏ gây lỗi nghiêm trọng hơn. Dùng con trỏ sau khi giải phóng cũng vậy. Gán lại NULL sau khi giải phóng là một thói quen rẻ mà hữu ích.

Các lỗi hay gặp

  • Dùng con trỏ chưa khởi tạo, thường dẫn tới segmentation fault.
  • Trả về địa chỉ của biến cục bộ trong hàm; biến đó bị hủy khi hàm kết thúc.
  • Truy cập ngoài giới hạn mảng qua số học con trỏ.
  • Nhầm giữa *p++ và (*p)++; hai biểu thức này làm hai việc khác nhau.

Con trỏ và chuỗi ký tự

Chuỗi trong C là mảng ký tự kết thúc bằng \0. Ta thường làm việc với chúng qua con trỏ char *. Khai báo char *s = "xin chao"; cho bạn một con trỏ trỏ tới chuỗi hằng nằm ở vùng nhớ chỉ đọc. Nếu cố sửa ký tự qua con trỏ đó, chương trình sẽ lỗi. Muốn sửa được, hãy khai báo mảng: char s[] = "xin chao";.

char ten[] = "An";
char *p = ten;
while (*p != '\0') {
    printf("%c\n", *p);
    p++;
}

Vòng lặp trên đi qua từng ký tự bằng cách tăng con trỏ cho đến khi gặp ký tự kết thúc. Đây là cách các hàm thư viện như strlen hoạt động bên trong.

Con trỏ tới con trỏ và con trỏ hàm

Con trỏ cũng là biến, nên nó có địa chỉ riêng. Vì vậy ta có thể có con trỏ trỏ tới con trỏ, viết int **pp. Bạn sẽ gặp kiểu này khi hàm cần thay đổi chính con trỏ của nơi gọi. Nó cũng xuất hiện với mảng chuỗi như tham số char **argv của hàm main. Con trỏ hàm thì cho phép truyền một hàm như đối số. Đây là cách qsort nhận hàm so sánh. Hai chủ đề này khó hơn, nên chỉ học khi bạn đã vững con trỏ cơ bản.

Mẹo học con trỏ hiệu quả

Hãy vẽ ra giấy. Mỗi biến là một ô, bên trên ghi địa chỉ giả định, bên trong ghi giá trị. Con trỏ là ô mà giá trị bên trong là địa chỉ của ô khác. Bạn vẽ một mũi tên nối hai ô.

Sau vài lần vẽ cho các ví dụ ở trên, khái niệm sẽ trở nên cụ thể hơn nhiều. Hãy đọc bài về câu lệnh if và tập kết hợp nó với con trỏ để kiểm tra NULL.

Nếu bạn mới bắt đầu, hãy ôn lại cách cài đặt môi trường C. Sau đó gõ lại mọi ví dụ trên. Tra cứu thêm hàm cấp phát tại cppreference.com. Cố tình in địa chỉ và giá trị của vài biến để hình dung bộ nhớ là cách luyện tập tốt nhất.

Chuyên mục