Menu

Số học con trỏ trong C: cộng, trừ và duyệt bộ nhớ

Cộng 1 vào một con trỏ không phải là cộng 1 byte: nó chuyển tới đối tượng tiếp theo cùng kiểu. Bài này nói về ptr+1, tăng và giảm con trỏ, hiệu hai con trỏ, so sánh con trỏ, duyệt mảng bằng con trỏ và quy tắc vị trí ngay sau phần tử cuối.

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.

Con trỏ giữ địa chỉ, và địa chỉ là các con số, nên việc kỳ vọng làm toán được trên chúng là hợp lý. Đúng là làm được, nhưng không hoàn toàn giống số học số nguyên thông thường. C nhân mọi phép toán trên con trỏ với kích thước của thứ được trỏ tới, biến "cộng một" thành một thao tác hữu ích hơn nhiều: "chuyển tới phần tử tiếp theo".

Chính quyết định thiết kế đó khiến việc duyệt mảng bằng con trỏ trong C tự nhiên như duyệt bằng chỉ số.

Cộng 1 là dịch đi một đối tượng, không phải một byte

Lấy một con trỏ, cộng 1, rồi in cả hai địa chỉ ra:

int * nhảy 4 byte, double * nhảy 8 byte, char * nhảy 1 byte: mỗi con trỏ đều rơi đúng vào chỗ đối tượng tiếp theo cùng kiểu sẽ bắt đầu. Quy tắc rất đơn giản:

ptr + n  ==  (address in ptr) + n * sizeof(*ptr)

Đây chính là lý do con trỏ cần có kiểu. Một địa chỉ trơn không gắn kiểu sẽ không biết "tiếp theo" là xa bao nhiêu.

Duyệt mảng bằng con trỏ

Giờ là lúc thấy lợi ích. Các phần tử của mảng nằm liên tiếp trong bộ nhớ, nên dịch một con trỏ từng bước sẽ đi qua chúng theo thứ tự:

Cả hai vòng lặp in ra cùng năm con số. Ở vòng thứ hai, scores suy biến thành con trỏ tới phần tử đầu tiên, p++ tiến lên một int, và *p đọc phần tử nằm ở đó. Trong bộ nhớ, chúng được sắp xếp như sau:

 scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
|   88    |   92    |   75    |   60    |   100   |
+---------+---------+---------+---------+---------+
 ^         ^                                       ^
 p         p+1                                     scores+5
 (start)   (4 bytes later)                         (one past the end)

Quan hệ giữa hai cách viết là chính xác tuyệt đối và đáng để ghi nhớ: scores[i] được định nghĩa là *(scores + i). Xem thêm trong bài con trỏ và mảng.

Tăng, giảm và cái bẫy *p++

++ và -- dùng được với con trỏ, với cùng cách nhân kích thước.

Biểu thức khiến ai cũng bối rối là *p++. ++ hậu tố gắn chặt hơn *, nên nó có nghĩa là "lấy p hiện tại, tăng p, rồi dereference giá trị cũ":

Ba biểu thức, ba ý nghĩa khác nhau:

Biểu thứcCon trỏ sau đóGiá trị trả về
*p++đã tiến lênphần tử trước khi dịch
*++pđã tiến lênphần tử sau khi dịch
(*p)++không đổigiá trị cũ, và phần tử được tăng lên

*p++ là cách viết chuẩn mực của C, các đoạn sao chép chuỗi và bộ đệm đều được viết như vậy, nhưng hãy thêm dấu ngoặc khi bạn muốn nói điều khác.

Trừ hai con trỏ

Lấy một con trỏ trừ đi một con trỏ khác cho ra số phần tử nằm giữa chúng, không phải số byte:

Kiểu kết quả là ptrdiff_t, một kiểu số nguyên có dấu trong <stddef.h>, được in ra bằng %td. Trừ hai con trỏ trỏ vào hai mảng khác nhau cho ra kết quả không xác định: chuẩn C chỉ định nghĩa phép toán này bên trong một đối tượng.

Bạn cũng có thể trừ một số nguyên khỏi con trỏ (p - 3 lùi lại ba phần tử), nhưng không thể cộng hai con trỏ với nhau. "Tổng của hai địa chỉ" chẳng mang ý nghĩa gì.

So sánh con trỏ

Các toán tử quan hệ dùng được với con trỏ trỏ vào cùng một mảng, và ý nghĩa của chúng đúng như cách bố trí bộ nhớ gợi ý: p < q đúng khi p trỏ tới một phần tử đứng trước.

front < back là toàn bộ điều kiện dừng của vòng lặp, và nó đúng vì cả hai con trỏ luôn nằm trong cùng một mảng. == và != cũng là cách tự nhiên để so một vòng lặp với điểm kết thúc của nó, và điều đó dẫn ta tới quy tắc khiến tất cả những điều này hợp lệ.

Quy tắc vị trí ngay sau phần tử cuối

C cho phép một cách tường minh việc tạo con trỏ tới vị trí ngay sau phần tử cuối cùng của mảng. Bạn có thể tính nó, lưu nó và so sánh với nó. Bạn không được dereference nó.

int arr[4];

 arr[0]   arr[1]   arr[2]   arr[3]   (no element)
+------+ +------+ +------+ +------+  +- - - -+
|      | |      | |      | |      |  |       |
+------+ +------+ +------+ +------+  +- - - -+
 ^                                    ^
 arr                                  arr + 4
 legal to use                         legal to FORM and COMPARE
                                      never legal to dereference

Quy tắc đó khiến dạng vòng lặp chuẩn trở nên hợp lệ:

for (int *p = arr; p != arr + n; p++) {
    /* ... */
}

Ở lần lặp cuối, p trở thành arr + n, phép so sánh thất bại và vòng lặp kết thúc, mà không bao giờ đọc vị trí đó.

Dù vậy vẫn còn hai điều là undefined. Tạo một con trỏ tới vị trí thứ hai sau phần tử cuối, hoặc một vị trí trước phần tử đầu, là undefined behavior ngay cả khi bạn không bao giờ dereference nó:

int *bad1 = arr + n + 1;   // undefined - too far past
int *bad2 = arr - 1;       // undefined - before the beginning

Điều thứ hai quan trọng trong thực tế: một vòng lặp ngược viết dạng for (int *p = arr + n - 1; p >= arr; p--) sẽ tính ra arr - 1 ở lần giảm cuối cùng. Nó chạy được trên mọi trình biên dịch phổ biến nhưng về mặt hình thức vẫn là undefined. Vòng lặp ngược viết sạch sẽ tránh được điều đó:

Con trỏ void và những phép toán không làm được

Một void * giữ địa chỉ mà không gắn kiểu nào, nhờ đó malloc và qsort có thể dùng chung cho mọi kiểu. Vì không có kích thước phần tử, chuẩn C không cho phép làm số học con trỏ trên void *: hãy ép kiểu sang một kiểu cụ thể trước (hoặc sang char * khi làm việc ở mức byte).

void *v = buffer;
// v + 1;              // not standard C - no size to scale by
char *b = v;
b + 1;                 // fine - one byte forward

GCC và Clang chấp nhận số học trên void * như một phần mở rộng, coi nó giống char *, nên code dựa vào điều này vẫn biên dịch được cho đến ngày nó được build ở một nơi khắt khe hơn. Biên dịch với -std=c17 -pedantic nếu bạn muốn được cảnh báo.

Bạn được gì từ điều này

Số học con trỏ không phải là mẹo tối ưu thỉnh thoảng mới dùng: nó là cơ chế nằm bên dưới việc truy cập mảng theo chỉ số, xử lý chuỗi và mọi lần duyệt bộ đệm trong thư viện chuẩn. strlen là một con trỏ được dịch tới ký tự kết thúc '\0' rồi trừ đi vị trí bắt đầu. memcpy là hai con trỏ cùng bước song song.

Tiếp theo, hãy xem sự tương đương giữa mảng và con trỏ thực sự hoạt động thế nào, kể cả cái bẫy sizeof mà ai cũng mắc lần đầu truyền mảng vào hàm: con trỏ và mảng.

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

ptr + 1 trong C làm gì?

Nó dịch con trỏ lên một đối tượng, không phải một byte. Với int * trên một máy thông thường, đó là 4 byte; với double * là 8 byte. Trình biên dịch tự nhân với sizeof(*ptr) giúp bạn, và đó là lý do kiểu của con trỏ quan trọng.

Có thể trừ hai con trỏ trong C không?

Có, khi cả hai cùng trỏ vào một mảng. end - start cho ra số phần tử nằm giữa chúng, với kiểu ptrdiff_t (in ra bằng %td). Trừ hai con trỏ trỏ vào những đối tượng không liên quan là undefined behavior.

Trỏ tới vị trí ngay sau phần tử cuối của mảng có hợp lệ không?

Có: tạo một con trỏ tới vị trí ngay sau phần tử cuối được cho phép một cách tường minh, để những vòng lặp như for (int *p = arr; p != arr + n; p++) chạy được. Bạn có thể tính và so sánh con trỏ đó, nhưng dereference nó là undefined behavior.

Vì sao *p++ không giống (*p)++?

*p++ tăng con trỏ và dereference giá trị cũ: nó đọc phần tử hiện tại rồi tiến lên. (*p)++ giữ nguyên con trỏ và tăng giá trị mà nó trỏ tới. ++ hậu tố gắn chặt hơn *, nên chính cặp ngoặc làm thay đổi ý nghĩa.

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