Mọi biến trong chương trình đều nằm ở đâu đó trong bộ nhớ, và mỗi "đâu đó" đều có một con số: địa chỉ của nó. Con trỏ (pointer) chỉ là một biến lưu một trong những con số đó. Toàn bộ ý tưởng chỉ có vậy. Mọi thứ khác trong bài này là cú pháp và hệ quả.
Con trỏ quan trọng đến vậy trong C vì C luôn đưa cho mỗi hàm một bản sao của các đối số. Một hàm nhận con số 7 có thể sửa bản sao đó bao nhiêu tùy thích; biến của bên gọi vẫn không hề nhúc nhích. Đưa cho hàm một địa chỉ thay vào đó, và nó có thể với ngược lại để thay đổi bản gốc. Mảng, chuỗi, bộ nhớ động và mọi cấu trúc dữ liệu bạn từng xây dựng trong C đều dựa trên khả năng duy nhất đó.
Bộ nhớ, địa chỉ và vì sao thứ gì cũng có địa chỉ
Khi bạn viết int score = 42;, trình biên dịch dành ra vài byte bộ nhớ và đặt cho chúng một cái tên bạn dùng được. Những byte đó còn có một địa chỉ dạng số. Hãy hình dung bộ nhớ như một con phố rất dài với những ngôi nhà được đánh số: score là gia đình sống ở nhà số 0x7ffd4c2a. Cái tên dành cho bạn, con số dành cho máy.
Toán tử & hỏi ra con số đó:
Chạy thử đi. Ba địa chỉ khác nhau vì ba biến chiếm những byte khác nhau, và chúng sẽ thay đổi sau mỗi lần chạy. Điều đó bình thường, và cũng là lý do bạn không bao giờ viết cứng một địa chỉ. Chú ý phép ép kiểu (void *): %p được quy định là nhận một void *, và truyền bất kỳ kiểu con trỏ nào khác về mặt kỹ thuật là undefined behavior. Hãy tập thói quen ép kiểu.
Địa chỉ không đoán trước được cũng chẳng thành vấn đề, vì bạn gần như không bao giờ quan tâm địa chỉ là bao nhiêu. Bạn chỉ quan tâm là mình có nó.
Khai báo con trỏ
Khai báo con trỏ ghi rõ kiểu mà nó trỏ tới:
int *p; // p can hold the address of an int
double *q; // q can hold the address of a double
char *name; // name can hold the address of a char
Hãy đọc int *p; là "*p là một int": dereference p sẽ cho bạn một số int. Cách đọc này vẫn đúng với mọi khai báo phức tạp mà C có thể đưa ra cho bạn sau này.
Đặt dấu * ở đâu là chuyện phong cách mà trình biên dịch không quan tâm: int* p, int *p và int * p đều khai báo cùng một thứ. Phần lớn code C viết int *p, và có lý do thực tế:
int* a, b; // a is a pointer to int; b is a PLAIN int, not a pointer
int *a, *b; // both are pointers - the * binds to the name, not the type
Dấu * gắn với tên được khai báo, không gắn với kiểu. Viết nó sát tên giúp điều đó luôn dễ thấy.
& và *: hai toán tử
Hai toán tử làm toàn bộ công việc, và chúng ngược hẳn nhau.
&x: lấy địa chỉ. Cho ra địa chỉ nơixnằm.*p: dereference. Đi tới địa chỉ trongpvà đưa cho bạn đối tượng nằm ở đó.
Hai dòng cuối là toàn bộ ý nghĩa của con trỏ. Không có phép gán nào trực tiếp vào age, vậy mà age vẫn thay đổi, vì *p = 31 nghĩa là "lưu 31 vào địa chỉ mà p giữ", và địa chỉ đó là địa chỉ của age.
Đây là hình ảnh trong bộ nhớ:
p age
+------------+ +----------+
| 0x7ffd1c40 | --------> | 31 |
+------------+ +----------+
at 0x7ffd1c38 at 0x7ffd1c40
p -> the address 0x7ffd1c40
*p -> the int stored there, 31
&age -> 0x7ffd1c40, the same address p holds
&p -> 0x7ffd1c38, where p itself lives
Hãy để ý dòng cuối: bản thân con trỏ cũng là một biến nằm đâu đó trong bộ nhớ, nên nó cũng có địa chỉ riêng. Đó không phải câu đố mẹo, chỉ là sự nhất quán, và cũng là cách con trỏ tới con trỏ (int **) hoạt động.
Vì & và * triệt tiêu lẫn nhau, *&age chính là age, và &*p chính là p.
Vì sao kiểu con trỏ quan trọng
Con trỏ lưu một địa chỉ, mà địa chỉ chỉ là một con số, vậy tại sao trình biên dịch nhất quyết phải biết nó trỏ tới int hay double? Có hai lý do:
- Đọc bao nhiêu byte. Dereference một
int *đọc 4 byte (thông thường); dereference mộtdouble *đọc 8 byte. Không có kiểu,*psẽ vô nghĩa. - Diễn giải các byte đó thế nào. Cùng một dãy bit là một con số khi coi là
int, và là một con số hoàn toàn khác khi coi làfloat.
Ngược lại, kích thước của bản thân con trỏ không phụ thuộc vào thứ nó trỏ tới, vì địa chỉ nào cũng là địa chỉ:
Trên máy 64 bit, cả ba con trỏ đều là 8 byte, trong khi những thứ chúng trỏ tới là 4, 8 và 1 byte. Chính kiểu được trỏ tới mới làm cho *p và số học con trỏ có ý nghĩa.
Gán qua lại giữa các kiểu con trỏ khác nhau là lỗi mà trình biên dịch sẽ phàn nàn, và có lý do chính đáng: pi = &d; sẽ khiến bạn đọc 4 byte của một số double rồi gọi kết quả là một số nguyên.
Động lực kinh điển: swap()
Đây là hàm mà khóa học C nào cũng dùng, vì nó thất bại theo đúng cách giải thích được con trỏ.
Hàm báo rằng đã hoán đổi xong, còn main lại báo rằng chẳng có gì xảy ra. broken_swap nhận được bản sao của x và y. Nó hoán đổi các bản sao một cách hoàn hảo, rồi các bản sao đó biến mất khi hàm trả về.
Thay vào đó, hãy đưa cho nó các địa chỉ:
Bây giờ a và b vẫn là bản sao, nhưng là bản sao của hai địa chỉ, và bản sao của một địa chỉ vẫn trỏ tới đúng biến mà bản gốc trỏ tới. *a chính là x, dù hàm được gọi từ đâu.
Đây là khuôn mẫu đứng sau mọi hàm C thay đổi dữ liệu của bên gọi, và đứng sau tham số hàm nói chung: C không có truyền tham chiếu, nên bạn truyền một con trỏ rồi dereference nó. Đó cũng là lý do scanf cần dấu &:
int n;
scanf("%d", &n); // scanf must be able to write into n, so it needs n's address
Trả về nhiều hơn một giá trị
Một hàm C chỉ trả về một giá trị. Con trỏ là cách vượt qua giới hạn đó: truyền vào địa chỉ cho các kết quả bổ sung.
Giá trị trả về mang trạng thái, còn các con trỏ mang kết quả. Cách chia này có mặt khắp nơi trong thư viện chuẩn của C và trong API của hệ điều hành.
Con trỏ tới con trỏ
Vì con trỏ là một biến, bạn có thể lấy địa chỉ của nó, và kiểu của địa chỉ đó là "con trỏ tới con trỏ tới int":
pp p value
+------+ +------+ +------+
| &p | ----> | &val | -----> | 8 |
+------+ +------+ +------+
Bạn sẽ gặp ** thật sự khi một hàm cần thay đổi một con trỏ mà bên gọi đang giữ, chẳng hạn một hàm cấp phát gán con trỏ của bên gọi tới vùng nhớ mới, và trong char *argv[] của đối số dòng lệnh.
Bốn lỗi nên biết trước khi mắc phải
Dùng một con trỏ chưa từng được trỏ tới đâu. Con trỏ chưa khởi tạo giữ bất kỳ byte nào đã có sẵn ở đó. Dereference nó là đọc hoặc ghi vào một địa chỉ ngẫu nhiên.
int *p; // p holds garbage
*p = 10; // undefined behavior - likely a crash
Hãy khởi tạo mọi con trỏ, bằng một địa chỉ thật hoặc bằng NULL. Xem bài con trỏ NULL để biết kỷ luật xoay quanh điều đó.
Nhầm *p với p. p = 5; đặt con trỏ thành địa chỉ 5 (vô nghĩa); *p = 5; lưu 5 vào nơi nó trỏ tới. Trình biên dịch sẽ cảnh báo ở trường hợp đầu, và cảnh báo thì đáng đọc.
Quên dấu & trong scanf. scanf("%d", n) truyền giá trị của n như một địa chỉ. Nó biên dịch kèm một cảnh báo rồi ghi vào bất kỳ vùng nhớ nào mà con số đó chỉ tới.
Trả về địa chỉ của một biến cục bộ. Biến cục bộ biến mất ngay khi hàm trả về; địa chỉ đó giống như tờ biên nhận của một ngôi nhà đã bị phá dỡ.
int *bad(void) {
int local = 42;
return &local; // the caller gets a dangling pointer
}
Nếu cần vùng nhớ tồn tại lâu hơn lời gọi hàm, hãy cấp phát nó: đó là việc của bộ nhớ động.
Tiếp theo nên đọc gì
Con trỏ không dừng ở chỗ "giữ một địa chỉ". Cộng vào con trỏ sẽ dịch nó qua bộ nhớ theo đơn vị bằng kích thước kiểu của nó, đó là chủ đề của bài số học con trỏ; và tên mảng trong C suy biến thành con trỏ tới phần tử đầu tiên, nên khi đã nắm cả hai thì con trỏ và mảng thực chất là cùng một chủ đề. Hãy đọc hai bài đó tiếp theo, và phần còn lại của C sẽ không còn giống phép màu nữa.
Câu hỏi thường gặp
Con trỏ trong C là gì?
Con trỏ là một biến có giá trị là một địa chỉ bộ nhớ, tức vị trí của một đối tượng khác trong bộ nhớ. int *p = &age; khiến p giữ địa chỉ của age, và khi đó *p đọc hoặc ghi biến age thông qua địa chỉ đó.
* và & trong C khác nhau thế nào?
&x là toán tử lấy địa chỉ: nó cho ra địa chỉ nơi x nằm. *p là toán tử dereference: nó đi tới địa chỉ lưu trong p và đưa cho bạn đối tượng ở đó. Hai toán tử triệt tiêu lẫn nhau, nên *&x chính là x.
Vì sao dấu * xuất hiện hai lần, lúc khai báo và lúc dùng con trỏ?
Đó là hai cách dùng khác nhau của cùng một ký hiệu. Trong int *p;, dấu * là một phần của kiểu, nói rằng "p là con trỏ tới int". Trong *p = 5;, dấu * là toán tử dereference, nói rằng "lưu 5 vào địa chỉ mà p giữ". Đọc khai báo theo kiểu "*p là một int" sẽ khiến cả hai cách dùng đều hợp lý.
Vì sao cần con trỏ trong C?
C truyền mọi đối số theo giá trị, nên một hàm không bao giờ thay đổi được biến của bên gọi trừ khi nó được trao địa chỉ. Con trỏ còn giúp duyệt mảng hiệu quả, trả về nhiều hơn một kết quả, xây dựng các cấu trúc liên kết và dùng bộ nhớ cấp phát lúc chạy bằng malloc.