Menu

Con trỏ và mảng trong C: decay, arr[i] = *(arr+i) và bẫy sizeof

Trong C, tên mảng biến thành con trỏ tới phần tử đầu tiên ở gần như mọi chỗ bạn dùng nó. Bài này giải thích sự suy biến đó, vì sao arr[i] chính là *(arr+i), vì sao kích thước mảng phải truyền riêng vào hàm, và con trỏ tới mảng khác mảng con trỏ thế nào.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Mảng và con trỏ trong C là hai thứ khác nhau nhưng phần lớn thời gian lại hoạt động giống nhau. Chính cái "phần lớn thời gian" đó khiến chủ đề này dễ trượt chân: code coi mảng như con trỏ thường vẫn chạy, cho đến ngày nó rơi vào một trong hai chỗ mà sự khác biệt có ý nghĩa và kết quả âm thầm sai.

Bài này vạch rõ ranh giới đó.

Mảng suy biến: một quy tắc đứng sau mọi thứ

Trong gần như mọi biểu thức, tên mảng tự động được chuyển thành con trỏ tới phần tử đầu tiên. Hiện tượng này gọi là suy biến (decay). arr trở thành &arr[0], với kiểu int *.

Tên mảng và địa chỉ phần tử đầu tiên in ra giống hệt nhau. Đó là sự suy biến đang diễn ra, và cũng là lý do bạn có thể gán mảng cho một con trỏ mà không cần &.

Có đúng ba chỗ sự suy biến không xảy ra: khi là toán hạng của sizeof, khi là toán hạng của &, và khi khởi tạo một mảng ký tự từ một chuỗi hằng. Ở mọi chỗ khác, hãy coi như mảng đã suy biến.

arr[i] chính là *(arr + i)

Chuẩn C định nghĩa toán tử chỉ số dựa trên số học con trỏ: a[b] nghĩa là *(a + b). Truy cập theo chỉ số không phải một tính năng riêng, mà chỉ là cách viết tắt.

Từ đó rút ra hai hệ quả. Thứ nhất, con trỏ cũng dùng được chỉ số: p[2] là *(p + 2), nên các hàm nhận con trỏ vẫn dùng được cú pháp arr[i] quen thuộc. Thứ hai, 2[arr] vẫn biên dịch được: *(2 + arr) là cùng địa chỉ với *(arr + 2). Ngoài các bài đố ra thì chẳng ai viết như vậy, nhưng nó trả lời dứt khoát câu hỏi truy cập theo chỉ số có "thực sự" là số học con trỏ hay không.

Truyền mảng vào hàm

Vì có sự suy biến, một hàm không bao giờ nhận được mảng. Nó nhận một con trỏ.

Có ba điều đáng rút ra từ ví dụ trên.

Kích thước trong int arr[100] chỉ để làm tài liệu, không hơn. Trình biên dịch viết lại tham số thành int *arr và không bao giờ kiểm tra bạn có truyền vào đủ 100 phần tử hay không.

Độ dài phải được truyền riêng. Hàm có một địa chỉ nhưng không biết dữ liệu kéo dài tới đâu. Đó là lý do gần như mọi hàm C nhận mảng đều nhận thêm một số lượng: memcpy(dst, src, n), fread(ptr, size, count, f), qsort(base, nmemb, size, cmp). Chuỗi là ngoại lệ chỉ vì chúng tự mang theo dấu kết thúc của mình, ký tự '\0'.

Truyền con trỏ thì rẻ và dữ liệu được dùng chung. Không có bản sao nào của mảng được tạo ra, nên hàm có thể sửa các phần tử của bên gọi, và đó thường đúng là điều bạn muốn:

Hãy đánh dấu các tham số chỉ đọc là const int * như print_all đã làm. Nó ghi rõ ý định và để trình biên dịch bắt được những lần vô tình ghi vào.

Cái bẫy sizeof

Đây là bug mà lập trình viên C nào cũng dính một lần. sizeof là một trong hai chỗ sự suy biến không xảy ra, nên nó cho kích thước thật của mảng ở nơi mảng được khai báo, và cho kích thước của một con trỏ ở bất kỳ đâu mảng đã suy biến.

Trong main, sizeof data là 40 byte và độ dài tính ra bằng 10. Bên trong inspect, sizeof arr là kích thước một con trỏ, tức 8 trên máy 64 bit, và "độ dài" tính ra bằng 2. Code trông giống hệt nhau nhưng lại sai một cách lặng lẽ.

Quy tắc rút ra: chỉ tính độ dài mảng trong phạm vi nơi mảng được khai báo, rồi truyền giá trị đó đi tiếp từ đó.

& của một mảng có kiểu khác

Chỗ còn lại mà sự suy biến không xảy ra là toán tử &. &arr là con trỏ tới toàn bộ mảng, không phải tới phần tử đầu tiên. Cả hai giữ cùng một địa chỉ; khác biệt nằm ở ý nghĩa của "cộng 1".

Cùng địa chỉ xuất phát, khác bước nhảy. arr có kiểu int * và bước đi một phần tử; &arr có kiểu int (*)[5] và bước đi cả mảng. Đây là cơ chế giúp truyền một hàng của mảng 2 chiều hoạt động được, được giải thích trong bài mảng nhiều chiều.

Con trỏ tới mảng và mảng con trỏ

Hai khai báo trông na ná nhau nhưng mang nghĩa trái ngược:

int *p[5];     // array of 5 pointers to int
int (*q)[5];   // pointer to an array of 5 ints

[] có độ ưu tiên cao hơn *, nên int *p[5] được hiểu là "p là một mảng, gồm các con trỏ". Cặp ngoặc trong int (*q)[5] buộc * gắn trước: "q là một con trỏ, tới một mảng".

int *p[5]                     int (*q)[5]

 p[0] -> [ int ]               q -> [ int | int | int | int | int ]
 p[1] -> [ int ]                     one contiguous block of 5
 p[2] -> [ int ]
 p[3] -> [ int ]
 p[4] -> [ int ]
 five separate addresses

Mảng con trỏ là loại phổ biến, và bạn đã từng dùng nó: char *argv[] là một mảng các con trỏ tới chuỗi, mỗi con trỏ ứng với một đối số dòng lệnh. Đó cũng là cách lưu một danh sách chuỗi có độ dài khác nhau mà không lãng phí bộ nhớ.

Lưu ý (*q)[0]: dereference con trỏ để lấy mảng, rồi truy cập theo chỉ số. Cặp ngoặc lại là bắt buộc, vì cùng lý do độ ưu tiên.

Những chỗ mảng và con trỏ thực sự khác nhau

Nhớ bảng ngắn này là hết nhầm lẫn:

Mảng int arr[10]Con trỏ int *p
Bản chất10 số int nằm trong bộ nhớmột biến giữ một địa chỉ
sizeof40, cả khối nhớ8, chỉ riêng con trỏ
Gán được khôngkhông: arr = x; là lỗicó: p = x; hợp lệ
& cho raint (*)[10]int **
Bộ nhớ lấy từ đâuđược khai báo cùng mảngbất cứ đâu bạn trỏ tới

Tên mảng không phải một biến giữ địa chỉ: nó chính là vùng lưu trữ, còn địa chỉ được tính ra khi cần. Đó là lý do bạn không thể gán cho nó.

Câu hỏi thường gặp

Mảng và con trỏ trong C có phải là một không?

Không, nhưng rất dễ nhầm vì tên mảng suy biến (decay) thành con trỏ tới phần tử đầu tiên trong gần như mọi biểu thức. Bản thân mảng là một khối phần tử có kích thước xác định; con trỏ là một biến duy nhất giữ một địa chỉ. sizeof và & là hai chỗ để lộ sự khác biệt.

Vì sao sizeof(arr) cho kết quả sai bên trong hàm?

Vì tham số đó không phải là mảng. void f(int arr[]) được âm thầm viết lại thành void f(int *arr), nên sizeof(arr) đo kích thước một con trỏ (8 byte trên hầu hết các máy), không phải mảng ban đầu. Hãy truyền độ dài thành một tham số riêng.

int *p[5] và int (*p)[5] khác nhau thế nào?

int *p[5] là một mảng gồm 5 con trỏ tới int. int (*p)[5] là một con trỏ duy nhất tới một mảng 5 số int. Cặp ngoặc buộc * gắn với tên trước; không có chúng, [] thắng vì có độ ưu tiên cao hơn.

arr[i] có thực sự giống *(arr + i) không?

Có: chuẩn C định nghĩa a[b] là *(a + b). Đó cũng là lý do cách viết trông kỳ lạ i[arr] vẫn biên dịch và chạy được: phép cộng có tính giao hoán, nên *(i + arr) là cùng một phần tử. Đừng bao giờ viết như vậy trong code thật, nhưng nó chứng minh quy tắc.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu