12/08/2018, 17:13

Giải thích về pointer trong 5 phút

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 ...

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.

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.

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 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ó 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 đó.

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ột int 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ên int.
  • Ở 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ến val và gán địa chỉ đó cho biến ptr. Chúng ta lưu địa chỉ vùng nhớ của biến val trong biến ptr.
  • Ở 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 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
", 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
", set);
17     	printf("ival = %d
", 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ên iptr và gán địa chỉ của ival 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ến int 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ến int 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.

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.

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
", *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]
}

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
", 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.

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
", (void *)&ptr, (void *)&val);
13      printf("ptr2ptr=%p, *ptr2ptr=%p, **ptr2ptr=%d
", (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ến val.
  • Ở dòng 7 chúng ta gán cho biến ptr2ptr địa chỉ của biến ptr. Gián tiếp kép. Biến ptr2ptr giữ địa chỉ của biến ptr và biến ptr lại giữ địa chỉ của biến val.
  • Ở dòng 8 chúng ta in ra địa chỉ của biến ptr và biến val.
  • Ở 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ủa val. 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.

0