Giải thích về pointer trong 5 phút
Bài đăng này đã không được cập nhật trong 6 năm
Nếu bạn đang đọc bài này thì có nghĩa là bạn muốn biết thêm về pointer trong C. Đó là 1 điều tốt. Kể cả nếu bạn không lập trình với C nhiều thì việc có những hiểu biết về pointer sẽ giúp bạn có thêm những hiểu biết sâu hơn về cách bộ nhớ hoạt động. Học về pointer cũng giúp bạn trở thành một lập trình viên tốt hơn. Trong bài viết này chúng ta sẽ bắt đầu với các biến và bộ nhớ. Chúng ta sẽ xem những thứ đó có liên quan gì đến pointer. Chúng ta sẽ nói về lí do tại sao pointer lại được sinh ra. Chúng ta sẽ thảo luận về các operation của pointer. Cuối cùng là kết thúc bằng các loại pointer mà bạn sẽ gặp trong khi lập trình.
Biến là gì?
Hãy bắt đầu bằng việc trả lời 1 câu hỏi đơn giản: biến trong lập trình là gì? Nhiều người sẽ nói biến là 1 cái tên đặt cho 1 phần dữ liệu trong 1 chương trình. Cũng đúng nhưng đó chỉ là bề nổi của tảng băng chìm mà thôi.
int main(int argc, char **argv)
{
// some variables
int anum = 1;
char achar = 'a';
}
Khi 1 biến được định nghĩa, bộ nhớ được dùng để giữ biến của kiểu đó sẽ được cấp phát tại một vùng nhớ đang không được sử dụng. Vị trí được cấp phát đó chính là địa chỉ của biến. Một trình biên dịch biết 2 thứ về mọi biến đó là tên và kiểu của biến. Với biến int anum
ở trên, anum
chính là kí hiệu được dịch ra thành một địa chỉ vùng nhớ. Kiểu của biến (int
) cho trình biên dịch biết rằng cần lượng bộ nhớ là bao nhiêu tại địa chỉ đó.
Một trình biên dịch C sẽ chuyển đổi code C sang code assembly. Trong quá trình chuyển đổi đó, tên của các biến sẽ được đổi thành địa chỉ vùng nhớ tương ứng.
Pointer là gì?
Chương trình C có nhiều kiểu của biến bao gồm int
, float
, array
, char
, struct
, và pointer. Một biến int
giữ một số nguyên, một biến float
giữ một số thực dấu phẩy động. Một array giữ nhiều giá trị. Một pointer là một biến giữ địa chỉ vùng nhớ của một biến khác.
Tại sao lại dùng pointer
Tại sao pointer lại được sinh ra? Dùng nó để làm gì? Câu trả lời đơn giản là vì nó hiệu quả. Hồi mà C mới được tạo ra thì máy tính chậm hơn bây giờ rất nhiều. Hầu hết phần mềm hồi đó được viết bằng assembly. Lập trình viên cần phải cẩn thận và hiệu quả hơn nhiều khi giải quyết các bài toán.
Câu trả lời rõ ràng hơn liên quan đến ngữ nghĩa gọi hàm. Ngôn ngữ C là ngôn ngữ tham trị. Khi bạn gọi 1 hàm trong C, giá trị của các tham số được truyền trực tiếp vào call stack của hàm đó. Cho vào 1 số nguyên int
, 4 byte sẽ được truyền vào hàm. Cho vào 1 char
thì 1 byte sẽ được truyền vào hàm. Điều gì sẽ xảy ra khi bạn cần đưa vào 100k phần tử của một array thuộc kiểu int
vào trong 1 hàm? Bạn không muốn phải truyền 400.000 byte vào hàm đó. Như thế thì không hiệu quả chút nào. Thay vào đó bạn sử dụng một pointer tham chiếu tới array. Pointer đó, tất cả 4 hay 8 byte của nó, sẽ được truyền vào trong hàm và khi đó nó có thể được tham chiếu ngược (dereference) để lấy được giá trị của array. Tương tự đối với những struct lớn. Đừng truyền cả struct vào mà hãy dùng 1 pointer trỏ đến struct.
Các toán tử
Có 2 toán tử chính để làm việc với pointer, đó là toán tử *
và toán từ &
. Còn có 1 toán tử nữa là ->
nhưng chúng ta sẽ nói về nó sau.
Toán tử *
được dùng khi định nghĩa 1 pointer và khi tham chiếu ngược một pointer. Định nghĩa 1 pointer cũng giống như định nghĩa biến. Trình biên dịch sẽ cấp phát vùng nhớ cần thiết cho pointer. Kích thước của pointer, số byte cần dùng để chứa một pointer phụ thuộc vào cấu trúc của máy tính. Với máy chạy 32 bit, pointer sẽ có kích thước là 4 byte/32 bit. Với máy 64 bit thì nó sẽ là 8 byte/64 bit.
Toán tử &
được dùng để lấy địa chỉ của một biến khác. Nó được dùng để gán giá trị cho pointer. Cho &
ra đằng trước một biến khác sẽ trả về một pointer trỏ đến biến đó và thuộc kiểu của biến đó.
Cách sử dụng
Hãy xem ví dụ đơn giản dưới đây về cách dùng 2 toán tử trên:
1 // declare an int pointer name ptr
2 int *ptr;
3
4 // declare an int with the value of 1
5 int val = 1;
6
7 // get the address of the val variable and store it in ptr
8 ptr = &val;
9
10 // dereference the ptr variable to get the int value at the address stored
11 int deref = *ptr;
12
13 // dereference the ptr variable to set the int value at the address stored
14 *ptr = 2;
- Ở dòng 2 chúng ta dùng toán tử
*
để định nghĩa mộtint
pointer. Nói cách khác thì chúng ta định nghĩa một biến để giữ địa chỉ vùng nhớ và tại địa chỉ đó là một số nguyênint
. - Ở dòng 5 chúng ta định nghĩa một biến
int
và gán cho nó giá trị là 1. - Ở dòng 8 chúng ta sử dụng toán tử
&
để lấy địa chỉ của biếnval
và gán địa chỉ đó cho biếnptr
. Chúng ta lưu địa chỉ vùng nhớ của biếnval
trong biếnptr
. - Ở dòng 11 chúng ta tham chiếu ngược biến
ptr
và lấy ra giá trị của địa chỉ đang được lưu. - Ở dòng 14 chúng ta tham chiếu ngược biến
ptr
và gán một giá trị mới cho địa chỉ đang được lưu.
Định nghĩa một pointer rất đơn giản. Nó cũng giống như chúng ta định nghĩa 1 biến, điểm khác biệt duy nhất là toán tử *
được đặt trước tển biến để chỉ ra đó là 1 pointer. Gán giá trị cho pointer cũng dễ, chúng ta sử dụng toán tử &
để lấy ra địa chỉ của một biến. Tham chiếu ngược thường là chỗ mà chúng ta hay hiểu sai.
Tham chiếu ngược một pointer
Tham chiếu ngược là một quá trình gián tiếp. Nó nói với trình biến dịch rằng, "Tao có địa chỉ của một biến trong pointer. Tao muốn truy cập vào địa chỉ đó để lấy ra giá trị hoặc gán lại giá trị". Một pointer giữ tham chiếu tới một biến; Tham chiếu đó chính là địa chỉ vùng nhớ nằm trong pointer. Khi chúng ta truy cập giá trị của tham chiếu đó, chúng ta tham chiếu ngược pointer.
Tham chiếu ngược có thể được dùng để gián tiếp lấy giá trị từ một địa chỉ của pointer hay gán một giá trị mới cho địa chỉ của pointer.
Hãy cùng xem ví dụ sau:
1 #include <stdio.h>
2
3 int main(int argc, char **argv)
4 {
5 // declare int ival and int pointer iptr. Assign address of ival to iptr.
6 int ival = 1;
7 int *iptr = &ival;
8
9 // dereference iptr to get value pointed to, ival, which is 1
10 int get = *iptr;
11 printf("*iptr = %d\n", get);
12
13 // dereference iptr to set value pointed to, changes ival to 2
14 *iptr = 2;
15 int set = *iptr;
16 printf("*iptr = %d\n", set);
17 printf("ival = %d\n", ival);
18 }
- Ở dòng 6 chúng ta định nghĩa một biến
int
tên làival
và gán cho nó giá trị là 1. - Ở dòng 7 chúng ta định nghĩa một pointer kiểu
int
têniptr
và gán địa chỉ củaival
cho nó. - Ở dòng 10 chúng ta tham chiếu ngược biến
iptr
để lấy ra giá trị của nó và gán cho một biếnint
tên làget
. - Ở dòng 11 chúng ta in giá trị của biến
get
. - Ở dòng 14 chúng ta tham chiếu ngược biến
iptr
để gán cho nó 1 giá trị mới là 2. - Ở dòng 15 chúng ta tham chiếu ngược biến
iptr
một lần nữa để lấy ra giá trị của nó và gán giá trị đó cho một biếnint
tên làset
. - Ở dòng 16 chúng ta in ra giá trị của biến
set
. Nó có giá trị là 2. - Ở dòng 17 chúng ta in ra giá trị của biến
ival
. Nó giờ cũng có giá trị là 2.
Nếu chạy đoạn code trên sẽ cho ra kết quả sau:
*iptr = 1
*iptr = 2
ival = 2
Trong ví dụ trên chúng ta đã sử dụng tham chiếu ngược để vừa lấy ra giá trị và gán lại giá trị. Nhiều người thường nhầm lẫn rằng tham chiếu ngược chỉ dùng để lấy ra giá trị. Tuy nhiên tham chiếu ngược có nghĩa là gián tiếp truy cập vào địa chỉ lưu trong pointer. Bạn có thể lấy ra giá trị, như ở dòng 6 trong đoạn code trên, hoặc bạn có thể gán cho nó giá trị khác như cách mà chúng ta làm ở dòng 10.
Pointer và kiểu
Hãy xem đoạn code sau:
1 #include <stdio.h>
2
3 int main(int argc, char **argv)
4 {
5 // declare an int value and an int pointer
6 int ival = 1;
7 int *iptr = &ival;
8
9 // declare a float value and a float pointer
10 float fval = 1.0f;
11 float *fptr = &fval;
12
13 // declare a char value and a char pointer
14 char cval = 'a';
15 char *cptr = &cval;
16
17 // can't do this, doesn't make sense
18 // iptr = &fval;
19 // fptr = &ival;
20 // iptr = &cval;
}
Khi chúng ta định nghĩa một pointer kiểu int
, chúng ta định nghĩa biến đó là 1 pointer, biến đó giữ địa chỉ tới một biến khác, và giá trị ở tại địa chỉ đó là 1 số nguyên int
. Tương tự đối với float
pointer, char
pointer, hay bất cứ kiểu nào khác. Định nghĩa một pointer thuộc một kiểu xác định sẽ giúp cho trình biên dịch biết rằng khi chúng ta tham chiếu ngược tới một pointer đó thì nó sẽ trỏ đến giá trị thuộc kiểu nào.
Bạn sẽ thấy rằng trong ví dụ trên, chúng ta định nghĩa ra pointer thuộc một kiểu nào đó và gán địa chỉ của một giá trị thuộc cùng kiểu. Nếu bạn bỏ comment mấy dòng cuối và thử compile nó thì sẽ bị lỗi “assignment from incompatible pointer type” (gán giá trị sai kiểu) và code không thể compile được. Bạn chỉ có thể gán địa chỉ của một giá trị cho pointer cùng kiểu với nó.
Toán tử &
trả về một pointer thuộc kiểu của biến mà nó đứng trước. Trong đoạn code trên &ival
trả về một pointer thuộc kiểu int
, fval
trả về một pointer thuộc kiểu float
và &cval
trả về pointer thuộc kiểu char
. Những chỗ nào mà bạn có thể dùng pointer thì cũng có thể sử dụng một biến &val
tương ứng.
Pointer trỏ tới array
Cũng như việc bạn có pointer trỏ đến int
hoặc float
, bạn cũng có thể có một pointer trỏ tới một array, miễn là pointer đó có cùng kiểu với các phần tử trong array.
int myarray[4] = {1,2,3,0};
int *ptr = myarray;
Khá đơn giản. Thật ra nếu bạn để ý thì sẽ thấy int *ptr
nhìn giống y hệt một pointer thuộc kiểu int
đúng không, đó là bởi vì nó chính là pointer thuộc kiểu int
. Khi một array được tạo ra, int myarray[4] = {1,2,3,0};
, trình biên dịch sẽ cấp phát bộ nhớ cho toàn bộ array và sau đó gán một pointer cho biến array, trong trường hợp này thì là myarray
, giữ địa chỉ của biến đầu tiên nằm trong array.
Nhiều người sẽ bị nhầm lẫn và sẽ nghĩ là chúng ta có thể hoán đổi pointer với array. Bạn không thể. Bạn có thể gán một biến array cho một pointer cùng kiểu nhưng không thể làm ngược lại. Khi một array được tạo ra, biến array đó không thể được gán lại giá trị.
Dưới đây là 1 ví dụ:
#include <stdio.h>
int main(int argc, char **argv)
{
int myarray[4] = {1,2,3,0};
// you can do this, myarray is a valid int pointer pointing to the first element of myarray
int *ptr = myarray;
printf("*ptr=%d\n", *ptr);
// Bạn không thể làm như thế này, biến array không thể gán lại được giá trị
// myarray = ptr
// myarray = myarray2
// myarray = &myarray2[0]
}
Pointers trỏ tới struct
Cũng giống như array, một pointer trỏ tới một struct sẽ giữ địa chỉ vùng nhớ của phần tử đầu tiên của struct. Sau đây là 1 vài ví dụ việc định nghĩa và sử dụng pointer struct.
1 #include <stdio.h>
2
3 int main(int argc, char **argv)
4 {
5 struct person {
6 int age;
7 char *name;
8 };
9 struct person first;
10 struct person *ptr;
11
12 first.age = 21;
13 first.name = "full name";
14 ptr = &first;
15
16 printf("age=%d, name=%s\n", first.age, ptr->name);
17 }
- Ở dòng 5-10 chúng ta định nghĩa struct
person
, một biến để giữ struct này, và một pointer tới nó. Việc định nghĩa pointer trỏ tới struct cũng tương tự như tới các kiểu khác. - Ở dòng 12-13 chúng ta set giá trị
age
vàname
cho struct. - Ở dòng 14 chúng ta gán địa chỉ của biến đầu tiên cho struct pointer
ptr
. - Ở dòng 16 chúng ta in ra giá trị của struct.
Nếu chạy đoạn code trên thì chúng ta sẽ được
age=21, name=full name
Ở dòng 16 chúng ta có một toán tử mới ptr->name
. Toán tử ->
được dùng để truy cập giá trị từ pointer struct. Nó tương tự với việc viết là (*ptr).field
, tại đó đầu tiên chúng ta tham chiếu ngược struct pointer và sau đó truy cập tới field sử dụng kí hiệu .
. Việc truy cập vào field từ một struct pointer là rất phổ biến nên toán tử ->
sinh ra để chúng ta dễ sử dụng hơn.
Pointer trỏ tới pointer
Một pointer có thể trỏ đến một biến pointer khác. Bạn có thể có một pointer trỏ tới một pointer, hoặc một pointer trỏ tới một pointer trỏ tới một pointer và cứ thế. Trong thực tế thì hiếm khi chúng ta gặp nhiều hơn là một pointer trỏ tới một pointer khác. Thường thì 2 bậc gián tiếp là đủ rồi.
Hãy xem đoạn code sau:
1 #include <stdio.h>
2
3 int main(int argc, char **argv)
4 {
5 int val = 1;
6 int *ptr = 0;
7 // declare a variable ptr2ptr which holds the value-at-address of
8 // an *int type which in holds the value-at-address of an int type
9 int **ptr2ptr = 0;
10 ptr = &val;
11 ptr2ptr = &ptr;
12 printf("&ptr=%p, &val=%p\n", (void *)&ptr, (void *)&val);
13 printf("ptr2ptr=%p, *ptr2ptr=%p, **ptr2ptr=%d\n", (void *)ptr2ptr, (void *)*ptr2ptr, **ptr2ptr);
14 }
Nếu bạn chạy đoạn code này, bạn sẽ có output tương tự như sau nhưng với địa chỉ vùng nhớ khác.
&ptr=0x7fff390fa6f8, &val=0x7fff390fa70c
ptr2ptr=0x7fff390fa6f8, *ptr2ptr=0x7fff390fa70c, **ptr2ptr=1
- Ở dòng 1-2 chúng ta định nghĩa một biến
int
tên làval
và một int pointer tên làptr
. - Ở dòng 5 chúng ta có biến
ptr2ptr
giữ địa chỉ của một biến int pointer khác. - Ở dòng 6 chúng ta gán cho biến
ptr
địa chỉ của biếnval
. - Ở dòng 7 chúng ta gán cho biến
ptr2ptr
địa chỉ của biếnptr
. Gián tiếp kép. Biếnptr2ptr
giữ địa chỉ của biếnptr
và biếnptr
lại giữ địa chỉ của biếnval
. - Ở dòng 8 chúng ta in ra địa chỉ của biến
ptr
và biếnval
. - Ở dòng 9 chúng ta in ra giá trị của biến
ptr2ptr
và nó cũng có cùng giá trị với&ptr
. Khi chúng ta tham chiếu ngược địa chỉ đó chúng ta sẽ lấy được địa chỉ củaval
. Khi chúng ta tham chiếu ngược tiếp chúng ta sẽ có giá trị là 1.
Qua bài viết này hi vọng các bạn đã có những hiểu biết cơ bản nhất về pointer trong C. Bài viết được dịch từ The 5-Minute Guide to C Pointers của tác giả Dennis Kubes.
All rights reserved