Chuyển tới nội dung chính

Con trỏ

Con trỏ thường bị coi là phần khó nhất khi học C. Khó không phải vì nhiều cú pháp, mà vì nó buộc ta nhìn xuống một tầng thấp hơn: thay vì chỉ nghĩ "biến a chứa số 42", ta phải nghĩ thêm "biến a nằm ở đâu trong bộ nhớ". Mỗi ý mới ở đây đều kèm một sơ đồ bộ nhớ để bạn nhìn thấy chuyện gì đang xảy ra bên trong máy.

Hãy bắt đầu bằng một tình huống ai học C cũng gặp. Bạn viết một hàm nhận số tuổi rồi tăng thêm một. In bên trong hàm thì thấy tuổi đã tăng, nhưng quay về main in lại biến gốc thì con số vẫn y như cũ. Trình biên dịch không sai: C truyền tham số theo trị, hàm chỉ nhận một bản sao của con số, nên sửa bản sao thì biến gốc không hề hay biết. Muốn hàm chạm được vào biến thật, ta phải trao cho nó đường đi tới biến thật, và con trỏ chính là cách C ghi lại một đường đi như vậy. Con trỏ cũng là nền của mọi thứ đáng học tiếp trong C: chuỗi ký tự, mảng cấp phát lúc chạy, danh sách liên kết, cả việc mở tệp cũng trả về một con trỏ.

Thử ngay: xem con trỏ chạy từng bước

Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi: hai biến a, b của main được vẽ như hai ô nhớ; bên trong swap, hai con trỏ px, py trỏ ngược lên hai ô đó, nên ghi qua con trỏ là sửa thẳng biến gốc. Hai dấu &* trong đoạn mã làm nhiệm vụ gì, các mục bên dưới sẽ giải thích từ đầu.

Hoán đổi hai biến qua con trỏ C
1#include <stdio.h>
2void swap(int *px, int *py) {
3 int t = *px;
4 *px = *py;
5 *py = t;
6}
7int main(void) {
8 int a = 3, b = 7;
9 swap(&a, &b);
10 printf("%d %d\n", a, b);
11 return 0;
12}
Ngăn xếp stack
main()
a→ #1b→ #2
Bộ nhớ động heap
#1int a
value3
#2int b
value7
Trong main: a = 3, b = 7. Ta vẽ hai biến này như hai ô nhớ #1 và #2 để con trỏ có thể trỏ tới.
1/6

Địa chỉ và con trỏ

Khi chương trình chạy, mọi biến đều được đặt vào bộ nhớ. Hãy hình dung bộ nhớ như một dãy rất dài các ô, mỗi ô có một số thứ tự duy nhất gọi là địa chỉ (address). Mỗi biến vì thế có ba thứ gắn liền nhau: cái tên ta đặt, cái địa chỉ nơi nó nằm, và cái giá trị nó đang chứa.

Một con trỏ (pointer) là một biến mà giá trị của nó là một địa chỉ bộ nhớ. Người ta nói con trỏ trỏ tới ô nhớ có địa chỉ đó. Con trỏ cũng là một biến bình thường, cũng nằm trong bộ nhớ, cũng có địa chỉ riêng; điểm khác biệt duy nhất là thứ nó chứa không phải số đếm hay ký tự, mà là một địa chỉ.

Bộ nhớ giống dãy phòng ký túc xá: giá trị là đồ trong phòng, địa chỉ là số phòng ghi ngoài cửa, con trỏ là mẩu giấy ghi số phòng. Đưa mẩu giấy cho người khác thì người đó vào đúng phòng 307 chứ không phải một phòng sao chép giống hệt. Đó là lý do truyền con trỏ cho phép hàm sửa biến gốc còn truyền giá trị thì không.

Con trỏ luôn gắn với một kiểu, và đây là điểm hay bị bỏ qua. Bản thân địa chỉ chỉ nói "bắt đầu từ ô nhớ này", nó không nói đọc bao nhiêu byte kể từ đó và hiểu chuỗi byte ấy ra sao. Kiểu của con trỏ trả lời hai câu hỏi đó: con trỏ tới int báo máy đọc bốn byte và hiểu là số nguyên, con trỏ tới char báo đọc một byte và hiểu là ký tự. Ghi nhớ điều này để hiểu vì sao số học con trỏ ở phần sau nhảy theo phần tử chứ không theo byte.

Hai toán tử trung tâm: lấy địa chỉ và truy cập gián tiếp

  • Toán tử lấy địa chỉ đặt ngay trước tên biến cho ra địa chỉ của biến đó. Bạn đã gặp nó khi viết hàm nhập scanf ở chương trước.
  • Toán tử truy cập gián tiếp (dereference) đặt trước con trỏ nghĩa là "giá trị nằm ở ô mà con trỏ đang trỏ tới". Đi theo mũi tên rồi đọc, hoặc ghi vào đó.

Khi khai báo con trỏ ta phải nói rõ nó trỏ tới kiểu nào, bằng dấu sao giữa kiểu và tên.

#include <stdio.h>

int main(void) {
int a = 42;
int *p = &a; // p holds the address of a
printf("%d\n", *p); // read through p, prints 42
*p = 100; // write through p: change a to 100
printf("%d\n", a); // a is now 100, not 42
return 0;
}

Hãy đọc đoạn mã trên theo cách máy đọc, tức là đi theo bộ nhớ chứ không đi theo chữ. Dòng int a = 42; xin cho a một ô nhớ bốn byte và đặt 42 vào đó; giả sử ô nhớ ấy ở địa chỉ 3000. Dòng int *p = &a; xin thêm một ô nhớ khác cho p, chẳng hạn ở 3008, và thứ bỏ vào đó không phải 42 mà là con số 3000. Ta có hai biến tách biệt: a giữ dữ liệu, p giữ đường đi tới dữ liệu. Khi máy gặp *p, nó đọc nội dung của p thấy 3000, rồi coi 3000 là địa chỉ, đi tới ô nhớ 3000, đọc bốn byte ở đó và hiểu là một int, cho ra 42.

Dòng *p = 100; chạy đúng cơ chế đó nhưng theo chiều ghi: máy đọc p để biết đích là 3000 rồi ghi 100 vào ô nhớ 3000. Biến a đổi thành 100, còn p không suy suyển, vẫn chứa 3000. Nhiều bạn nhầm rằng *p = 100 làm p mang giá trị 100. Không phải. Muốn đổi chính con trỏ, ta viết p = &b; không có dấu sao. Có dấu sao là thao tác trên ô được trỏ tới, không dấu sao là thao tác trên chính con trỏ. Cũng vì vậy a in ra 100 dù không dòng nào gán trực tiếp cho a: ô nhớ của nó bị sửa từ một lối vào khác. Đây vừa là sức mạnh vừa là mối nguy của con trỏ.

Phân biệt hai dấu sao

Dấu sao lúc khai báo (int *p;) chỉ để nói "p là con trỏ". Dấu sao lúc dùng (*p) là toán tử truy cập gián tiếp: đi theo con trỏ để tới ô nhớ. Còn &a cho địa chỉ của a, ngược lại với truy cập gián tiếp.

Con trỏ NULL

Một con trỏ vừa khai báo mà chưa gán gì sẽ chứa một địa chỉ rác, trỏ tới một nơi vô nghĩa. Để có một giá trị an toàn nghĩa là "con trỏ này hiện chưa trỏ tới đâu cả", C dùng con trỏ NULL.

Vì sao cần một giá trị đặc biệt như vậy? Hãy nghĩ về hàm tìm kiếm trả về con trỏ tới phần tử tìm thấy: tìm thấy thì trả địa chỉ, còn không tìm thấy thì trả cái gì? C dành riêng một giá trị con trỏ bảo đảm không trùng địa chỉ của bất kỳ đối tượng thật nào, đó là NULL, mang đúng một nghĩa là không có gì ở đây. Thư viện chuẩn theo quy ước này: malloc trả NULL khi hết bộ nhớ, fopen trả NULL khi không mở được tệp. Ngược lại, con trỏ chưa khởi tạo chứa mớ byte ngẫu nhiên trông giống một địa chỉ hợp lệ nên không phép so sánh nào phân biệt nổi, vì thế nó còn nguy hiểm hơn NULL nhiều. Quy tắc thực hành: khai báo con trỏ mà chưa có gì để trỏ tới thì gán NULL ngay.

#include <stdio.h>

int main(void) {
int *p = NULL; // p points to nothing yet
if (p != NULL) { // always check before dereferencing
printf("%d\n", *p);
} else {
printf("p is null, nothing to read\n");
}
return 0;
}
Quy tắc vàng

Truy cập gián tiếp một con trỏ NULL làm chương trình dừng đột ngột (lỗi thường gọi là segmentation fault). Luôn kiểm tra p != NULL trước khi đọc giá trị qua con trỏ.

Con trỏ treo, nguy hiểm hơn con trỏ NULL

Con trỏ treo (dangling pointer) vẫn giữ một địa chỉ, nhưng ô nhớ ở địa chỉ đó đã hết hiệu lực. Hai nguồn gốc phổ biến: trả về địa chỉ của biến cục bộ (biến cục bộ bị thu hồi ngay khi hàm kết thúc), và dùng lại con trỏ sau khi đã free vùng nhớ đó. Nguyên nhân gốc luôn là một: tuổi thọ của vùng nhớ ngắn hơn tuổi thọ của con trỏ trỏ vào nó. Kiểm tra p != NULL vô dụng ở đây vì con trỏ treo không phải NULL, nó đang giữ một địa chỉ trông rất hợp lệ. Cách phòng tránh: gán p = NULL; ngay sau free(p);, và không bao giờ trả về địa chỉ của biến cục bộ.

Số học con trỏ

Con trỏ chứa một con số nên cộng trừ được, nhưng C định nghĩa phép cộng ấy theo cách rất riêng. Với p là con trỏ tới int, biểu thức p + 1 không phải địa chỉ tăng thêm một byte mà là địa chỉ tăng thêm đúng sizeof(int) byte. Cộng một vào con trỏ nghĩa là nhảy sang phần tử kế tiếp cùng kiểu, không phải dịch sang byte kế tiếp. Thiết kế này có lý: con trỏ hầu như luôn dùng để duyệt một dãy phần tử liền nhau, mà người lập trình nghĩ theo phần tử chứ không nghĩ theo byte. Cái giá phải trả là cùng biểu thức p + 1 nhảy bốn byte khi p trỏ tới int nhưng chỉ nhảy một byte khi p trỏ tới char: kiểu con trỏ quyết định.

Các phép hợp lệ gồm cộng trừ một số nguyên để dịch chuyển, tăng giảm bằng ++--, trừ hai con trỏ cùng kiểu trong cùng một mảng để biết chúng cách nhau mấy phần tử, và so sánh hai con trỏ để biết cái nào đứng trước. Cộng hai con trỏ với nhau là vô nghĩa nên C không cho phép: tổng của hai địa chỉ không ứng với khái niệm nào cả.

Con trỏ và mảng

Trong C, con trỏ và mảng có quan hệ rất gần. Khi khai báo int arr[5];, cái tên arr khi dùng trong biểu thức gần như là một con trỏ trỏ tới phần tử đầu tiên. Cộng 1 vào con trỏ không cộng 1 byte mà nhảy đúng một phần tử, nên giá trị ở vị trí thứ i lấy được qua con trỏ chính bằng phần tử thứ i của mảng. Thực ra cú pháp chỉ số mảng chỉ là cách viết gọn của số học con trỏ.

#include <stdio.h>

int main(void) {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // same as p = &arr[0]
printf("%d\n", *p); // prints 10, the first element
printf("%d\n", *(p + 2)); // prints 30, same as arr[2]
return 0;
}

Hãy phân tích đoạn này trong bộ nhớ. Mảng arr chiếm một dải năm ô int liền nhau, giả sử bắt đầu ở địa chỉ 2000, nên các phần tử nằm ở 2000, 2004, 2008, 2012 và 2016. Dòng int *p = arr; đặt vào p con số 2000. Biểu thức p + 2 không cho ra 2002 mà cho ra 2008, vì cộng hai với con trỏ tới int là nhảy qua hai phần tử bốn byte. Truy cập gián tiếp ở 2008 cho ra 30, đúng bằng arr[2]. Đây không phải trùng hợp: chuẩn C định nghĩa arr[i] chính là cách viết khác của phép truy cập gián tiếp tại arr + i. Cú pháp ngoặc vuông chỉ là lớp áo dễ đọc phủ lên số học con trỏ.

Mảng không phải là con trỏ

Nói "tên mảng là con trỏ" là nói tắt, và nói tắt thì hay sinh hiểu nhầm. Sự thật là tên mảng tự chuyển thành con trỏ tới phần tử đầu khi xuất hiện trong hầu hết biểu thức, còn bản thân mảng vẫn là một dải bộ nhớ chứ không phải một biến con trỏ. Ba hệ quả. Thứ nhất, sizeof(arr) cho kích thước cả mảng, còn sizeof(p) chỉ cho kích thước một con trỏ, không dính gì tới số phần tử. Thứ hai, p = arr; hợp lệ nhưng arr = p; bị báo lỗi, vì tên mảng không phải chỗ để gán vào. Thứ ba, khi truyền mảng vào hàm, hàm chỉ nhận con trỏ tới phần tử đầu, thông tin về số phần tử mất sạch. Đó là lý do mọi hàm nhận mảng trong C đều phải nhận thêm một tham số độ dài.

Xem chạy thêm: số học con trỏ chạy trên mảng

Bản trace ở đầu bài cho thấy con trỏ trỏ tới một biến. Trace này cho thấy con trỏ dịch chuyển: mỗi lần cộng 1, địa chỉ nhảy đúng một phần tử (sizeof(int) byte) chứ không phải một byte, nên p, p+1, p+2 lần lượt trỏ ba phần tử kề nhau. Cột địa chỉ ở mỗi ô giúp bạn thấy rõ giá trị và địa chỉ là hai thứ khác nhau.

Con trỏ chạy trên mảng: p, p+1, p+2 C
1#include <stdio.h>
2int main(void) {
3 int arr[3] = {10, 20, 30};
4 int *p = arr;
5 printf("%d\n", *p);
6 p = p + 1;
7 printf("%d\n", *p);
8 p++;
9 printf("%d\n", *p);
10 return 0;
11}
Ngăn xếp stack
main()
arr[0] @2000= 10arr[1] @2004= 20arr[2] @2008= 30
Bộ nhớ động heap
(trống)
Mảng arr gồm 3 ô int liền nhau. Giả sử bắt đầu ở địa chỉ 2000, nên arr[0] ở 2000, arr[1] ở 2004, arr[2] ở 2008 (mỗi int 4 byte).
1/8

Con trỏ tới con trỏ

Con trỏ cũng là biến, nên nó cũng có địa chỉ, nên hoàn toàn có thể có một con trỏ khác giữ địa chỉ đó. Con trỏ tới con trỏ khai báo bằng hai dấu sao: int **pp; nghĩa là pp giữ địa chỉ của một biến kiểu int *.

#include <stdio.h>

int main(void) {
int a = 42;
int *p = &a; // p holds the address of a
int **pp = &p; // pp holds the address of p
printf("%d\n", a); // 42, read directly
printf("%d\n", *p); // 42, one hop: through p
printf("%d\n", **pp); // 42, two hops: pp to p, then p to a
**pp = 7; // write through two levels, changes a
printf("%d\n", a); // now 7
return 0;
}

Đọc **pp từ trong ra ngoài thì dễ hình dung: dấu sao trong cùng đi từ pp sang p, dấu sao thứ hai đi tiếp từ p sang a. Hai dấu sao là hai cú nhảy nối tiếp. Cũng vì thế *pp = NULL; không đụng tới a mà đặt lại chính con trỏ p, còn **pp = 7; mới ghi vào a. Thêm hay bớt một dấu sao đều làm đích đến của phép ghi lệch đi một tầng.

Điều này giải một bài toán rất thực tế: làm sao để hàm sửa được một con trỏ của hàm gọi. Quy tắc chung là muốn hàm sửa biến kiểu T thì truyền cho nó giá trị kiểu T *. Thay T bằng int ta được int *; thay T bằng int * ta được int **. Đây đúng là tình huống của hàm thêm nút vào đầu danh sách liên kết ở bài sau: hàm phải đổi con trỏ head của main nên nó nhận Node **head. Nếu chỉ nhận Node *head, hàm sửa được nội dung các nút nhưng không tài nào bắt head ngoài main trỏ sang chỗ khác, vì nó chỉ cầm một bản sao.

Truyền địa chỉ vào hàm

Đây là lý do quan trọng nhất khiến ta cần con trỏ. C truyền tham số theo trị: hàm nhận một bản sao của đối số, nên sửa bản sao bao nhiêu cũng không ảnh hưởng biến gốc. Cách sửa là đưa cho hàm địa chỉ của biến: hàm nhận con trỏ rồi đi theo con trỏ để sửa thẳng vào biến gốc. Ví dụ kinh điển là hàm hoán đổi hai biến.

#include <stdio.h>

void swap(int *px, int *py) { // receive addresses of a and b
int t = *px; // read value at a
*px = *py; // write b's value into a
*py = t; // write old a into b
}

int main(void) {
int a = 3, b = 7;
swap(&a, &b); // pass the addresses
printf("%d %d\n", a, b); // now prints 7 3, swapped
return 0;
}

Để thấy vì sao bản này chạy được, hãy hình dung bản sai mà ai cũng viết lần đầu: hàm nhận int x, int y rồi hoán đổi x với y. Khi gọi, máy tạo hai ô nhớ mới cho xy rồi chép 3 và 7 vào đó. Hàm hoán đổi hai ô mới này rất chăm chỉ, in bên trong hàm thì thấy đã đổi, nhưng hai ô ấy là tài sản riêng của hàm và bị xoá sạch khi hàm kết thúc, còn hai ô của ab chưa bao giờ bị chạm tới.

Bản đúng chỉ đổi một điều: thứ được sao chép giờ là hai địa chỉ. Bản sao của một địa chỉ vẫn dẫn tới đúng ô nhớ gốc, giống như chép số phòng ra mẩu giấy khác thì vẫn là cùng căn phòng, nên *px = *py; ghi thẳng vào ô nhớ của a. Biến tạm t là bắt buộc: bỏ nó đi thì giá trị cũ của a bị đè mất và cả hai cùng bằng 7. Kỹ thuật này cũng gỡ một hạn chế lớn của C là hàm chỉ return được một giá trị: muốn trả nhiều kết quả, ta truyền địa chỉ của các biến kết quả rồi để hàm ghi qua con trỏ.

Quên dấu lấy địa chỉ khi gọi scanf

Viết scanf("%d", n); thay vì scanf("%d", &n); là lỗi phổ biến nhất của học kỳ đầu. Nguyên nhân gốc chính là nội dung bài này: scanf phải ghi vào biến của bạn, mà muốn ghi được thì nó cần địa chỉ. Truyền n là truyền giá trị hiện tại của n, thường là một số rác, và scanf sẽ diễn giải số rác đó thành một địa chỉ rồi ghi vào đấy. Kết quả là chương trình sập hoặc phá hỏng một vùng nhớ nào đó. Lưu ý mẹo nhỏ: với mảng ký tự thì scanf("%s", ten); lại không cần dấu lấy địa chỉ, vì tên mảng đã tự chuyển thành con trỏ tới phần tử đầu rồi.

Bài tập thực hành

Bài tập 1: in giá trị, địa chỉ và truy cập gián tiếp

Khai báo biến int x = 25; và một con trỏ p trỏ tới x. In ra: giá trị của x, địa chỉ của x, giá trị mà con trỏ p đang chứa, và giá trị lấy được qua truy cập gián tiếp. Giải thích vì sao địa chỉ của x và giá trị p chứa in ra giống nhau.

Lời giải chi tiết

Con trỏ p chứa đúng địa chỉ của x, nên giá trị bên trong p bằng địa chỉ của x. Truy cập gián tiếp qua p đi theo địa chỉ đó về lại ô của x, cho ra 25. Định dạng %p dùng để in con trỏ và địa chỉ.

#include <stdio.h>

int main(void) {
int x = 25;
int *p = &x; // p points to x
printf("value of x = %d\n", x); // 25
printf("address of x = %p\n", (void *)&x); // some address
printf("p contains = %p\n", (void *)p); // same address as &x
printf("value via p = %d\n", *p); // 25, follow the pointer
return 0;
}

Ý chính: con trỏ chứa một địa chỉ; truy cập gián tiếp đi theo địa chỉ đó về biến gốc.

Bài tập 2: hàm đổi dấu một số qua con trỏ

Viết hàm void doi_dau(int *p) nhận địa chỉ một số nguyên rồi đổi dấu số đó (dương thành âm và ngược lại). Trong main, nhập một số, gọi hàm và in kết quả để chứng minh biến gốc đã đổi.

Lời giải chi tiết

Hàm nhận con trỏ p trỏ tới biến gốc. Ghi qua truy cập gián tiếp là ghi thẳng vào biến ở main, nên sau lời gọi biến gốc đã đổi dấu mà không cần trả về gì.

#include <stdio.h>

void doi_dau(int *p) {
*p = -(*p); // negate the value the pointer refers to
}

int main(void) {
int n;
scanf("%d", &n); // pass the address so doi_dau can change n
doi_dau(&n);
printf("%d\n", n); // n now has the opposite sign
return 0;
}

Ý chính: muốn hàm sửa được biến gốc, truyền địa chỉ của biến và để hàm nhận con trỏ.

Bài tập 3: tìm nhỏ nhất và lớn nhất qua hai con trỏ

Viết hàm void min_max(int a[], int n, int *pmin, int *pmax) duyệt mảng an phần tử và ghi giá trị nhỏ nhất, lớn nhất qua hai con trỏ. In hai kết quả trong main.

Lời giải chi tiết

Một hàm chỉ trả về được một giá trị. Để "trả về" cả hai kết quả, ta truyền vào hai con trỏ và ghi kết quả qua chúng. Bên trong hàm, ghi qua con trỏ là ghi thẳng vào hai biến của main.

#include <stdio.h>

void min_max(int a[], int n, int *pmin, int *pmax) {
*pmin = a[0];
*pmax = a[0];
for (int i = 1; i < n; i++) {
if (a[i] < *pmin) *pmin = a[i]; // write smaller value back
if (a[i] > *pmax) *pmax = a[i]; // write larger value back
}
}

int main(void) {
int a[6] = {5, 2, 9, 1, 7, 3};
int lo, hi;
min_max(a, 6, &lo, &hi); // pass addresses of lo and hi
printf("min = %d, max = %d\n", lo, hi);
return 0;
}

Ý chính: truyền nhiều con trỏ là cách cho một hàm trả nhiều kết quả qua biến gốc.

Bài tập 4: tính tổng mảng bằng con trỏ chạy

Viết hàm int tong(int *p, int n) tính tổng n phần tử của một mảng, nhưng bên trong hàm chỉ được dùng số học con trỏ, không dùng cú pháp ngoặc vuông. In thêm hiệu của con trỏ cuối và con trỏ đầu để thấy nó bằng đúng số phần tử.

Lời giải chi tiết

Ý tưởng là cho một con trỏ chạy từ phần tử đầu tới ô ngay sau phần tử cuối. Địa chỉ "ngay sau phần tử cuối" được chuẩn C cho phép dùng để so sánh, chỉ không được truy cập gián tiếp vào. Mỗi vòng, *p đọc phần tử hiện tại rồi p++ nhảy sang phần tử kế tiếp, tức địa chỉ tăng thêm sizeof(int) byte chứ không phải một byte.

#include <stdio.h>

int tong(int *p, int n) {
int s = 0;
int *het = p + n; // one past the last element
while (p < het) { // compare two pointers
s += *p; // read the current element
p++; // jump to the next element
}
return s;
}

int main(void) {
int a[5] = {2, 4, 6, 8, 10};
printf("tong = %d\n", tong(a, 5)); // 30
int *dau = a;
int *cuoi = a + 5;
printf("khoang cach = %ld\n", (long)(cuoi - dau)); // 5, not 20
return 0;
}

Chú ý dòng cuối: hiệu hai con trỏ cho ra 5, tức số phần tử giữa chúng, chứ không phải 20 byte. Trừ hai con trỏ trả về số phần tử, đúng theo cùng quy ước với phép cộng.

Ý chính: con trỏ dịch chuyển theo đơn vị phần tử; so sánh và trừ con trỏ cùng mảng là hợp lệ và rất tiện để duyệt.

Bài tập 5: hàm đặt lại con trỏ của hàm gọi

Viết hàm void gan_null(int **pp) nhận địa chỉ của một con trỏ và đặt con trỏ đó thành NULL. Viết thêm bản sai nhận int *p, chạy cả hai và giải thích vì sao bản sai không có tác dụng.

Lời giải chi tiết

Bản sai chỉ cầm một bản sao của con trỏ, nên p = NULL; bên trong hàm chỉ làm bản sao thành NULL còn con trỏ gốc không đổi. Muốn sửa chính con trỏ gốc, hàm phải nhận địa chỉ của nó, tức kiểu int **, rồi ghi qua một tầng truy cập gián tiếp.

#include <stdio.h>

void gan_null(int **pp) {
*pp = NULL; // write into the caller's pointer variable
}

void gan_null_sai(int *p) {
p = NULL; // only changes the local copy, useless
}

int main(void) {
int a = 42;
int *p = &a;
gan_null_sai(p);
printf("sau ban sai : %s\n", (p == NULL) ? "NULL" : "van tro toi a");
gan_null(&p); // pass the address of the pointer itself
printf("sau ban dung: %s\n", (p == NULL) ? "NULL" : "van tro toi a");
return 0;
}

Ý chính: con trỏ tới con trỏ là công cụ để hàm thay đổi được chính con trỏ của hàm gọi.

Tự kiểm tra

Vì sao hàm swap truyền theo trị lại không hoán đổi được?

Vì hàm nhận bản sao của hai số. Nó hoán đổi hai bản sao, nhưng khi hàm kết thúc các bản sao biến mất, còn biến gốc ngoài main không hề thay đổi. Phải truyền địa chỉ thì hàm mới sửa được biến gốc.

Câu hỏi tự kiểm

Kiểm tra nhanh: con trỏ và địa chỉ0/4 đúngchưa trả lời
  1. 1Với int a; int *p = &a; câu lệnh *p = 100; làm gì?
  2. 2p là con trỏ tới int. Biểu thức p + 1 làm địa chỉ tăng thêm bao nhiêu?
  3. 3Muốn một hàm sửa được chính con trỏ head của hàm gọi (cho head trỏ sang chỗ khác), tham số phải có kiểu gì?
  4. 4Về mảng arr và con trỏ p, phát biểu nào đúng?

Tóm tắt

Địa chỉ là số thứ tự của ô nhớ, con trỏ là biến chứa một địa chỉ. Toán tử lấy địa chỉ đi từ biến ra địa chỉ, toán tử truy cập gián tiếp đi ngược lại từ địa chỉ về biến: hai chiều của cùng một mũi tên.

Kiểu của con trỏ không phải trang trí. Nó quyết định máy đọc bao nhiêu byte khi truy cập gián tiếp và quyết định p + 1 nhảy bao xa, nhờ đó số học con trỏ làm việc theo đơn vị phần tử và cú pháp chỉ số mảng chỉ là cách viết gọn của số học con trỏ. Nhưng mảng vẫn không phải con trỏ: nó chỉ tự chuyển thành con trỏ trong biểu thức, còn sizeof và phép gán vẫn phân biệt rạch ròi. Về an toàn, luôn kiểm tra NULL trước khi truy cập gián tiếp; nguy hiểm hơn NULL là con trỏ chưa khởi tạo và con trỏ treo, vì chúng mang địa chỉ trông hợp lệ nên không phép kiểm tra nào bắt được.

Lý do thực dụng nhất để học con trỏ: C truyền tham số theo trị, nên muốn hàm sửa được biến của hàm gọi thì phải truyền địa chỉ của biến đó. Từ quy tắc này suy ra tất cả phần còn lại, kể cả scanf phải nhận địa chỉ, hàm trả nhiều kết quả qua nhiều con trỏ, và con trỏ tới con trỏ khi thứ cần sửa lại chính là một con trỏ.

Ghi nhớ

Lấy địa chỉ và truy cập gián tiếp là hai toán tử ngược nhau. Luôn kiểm tra NULL trước khi truy cập gián tiếp. Muốn một hàm sửa được biến của hàm gọi, hãy truyền địa chỉ của biến đó.