C – Con Trỏ (Pointer)

Trong lập trình C, con trỏ (pointer) là một chủ đề quan trọng và thú vị. Vài tác vụ chương trình C được thực hiện dễ dàng hơn bằng con trỏ, như là cấp phát bộ nhớ động (dynamic memory allocation). Cấp phát bộ nhớ động không thể thực hiện được nếu không sử dụng con trỏ. Do đó để trở thành một lập trình viên C giỏi, cần phải tìm hiểu về con trỏ. Hãy bắt đầu bằng những bước đơn giản trước.

Như đã biết, mỗi biến là một khu vực bộ nhớ (memory location) và mỗi khu vực bộ nhớ có địa chỉ (address) riêng. Địa chỉ xác định cái nào được truy cập. Để truy cập, chúng ta sử dụng toán tử ampersand (&) tượng trưng cho một địa chỉ trong bộ nhớ. Hãy xem ví dụ in địa chỉ của mỗi biến ra màn hình.

#include <stdio.h>

int main() {
   int  var1;
   char var2[10];

   printf("Address of var1 variable: %x\n", &var1);
   printf("Address of var2 variable: %x\n", &var2);

   return 0;
}

Khi đoạn code trên được biên dịch và thực thi, ta được kết quả sau.

Address of var1 variable: bff5a400
Address of var2 variable: bff5a3f6

Con Trỏ Là Gì?

Con trỏ (Pointer) là một biến chứa giá trị là địa chỉ của một biến khác, tức là địa chỉ trực tiếp của vị trí bộ nhớ (memory location). Giống như bất kỳ biến hoặc hằng số nào, bạn phải khai báo một con trỏ trước khi sử dụng nó để lưu trữ địa chỉ của bất kỳ biến nào. Dạng tổng quát của khai báo biến con trỏ là:

type *var-name;

Ở đây, type là kiểu gốc của con trỏ, nó phải là một kiểu dữ liệu C hợp lệ, còn var-name là tên của biến con trỏ. Dấu hoa thị (asterisk) * được dùng để khai báo con trỏ cũng chính là dấu làm phép toán nhân. Tuy nhiên, trong phát biểu này dấu hoa thị được hiểu là tạo một biến làm con trỏ.

Ví dụ khai báo con trỏ hợp lệ:

int    *ip;    /* pointer to an integer */
double *dp;    /* pointer to a double */
float  *fp;    /* pointer to a float */
char   *ch     /* pointer to a character */

Mọi kiểu con trỏ, dù là integer, float, character, hoặc loại khác, thì giá trị của nó đều là một số thập lục phân dài (a long hexadecimal number) tượng trưng cho một địa chỉ bộ nhớ (VD: 0x0200A000). Điểm khác biệt duy nhất giữa các con trỏ có kiểu dữ liệu khác nhau là kiểu dữ liệu của biến (variable) hoặc hằng (constant) mà con trỏ chỉ tới.

Cách Sử Dụng Con Trỏ

Ba phép toán (operation) quan trọng mà chúng ta sẽ thực hiện thường xuyên với con trỏ. (a) Định nghĩa một biến con trỏ, (b) gán địa chỉ của một biến cho một con trỏ (c) và cuối cùng truy cập giá trị tại địa chỉ có sẵn trong biến con trỏ. Bằng cách sử dụng toán tử một ngôi * (unary operator) trả về giá trị của biến nằm tại địa chỉ được chỉ định bởi toán hạng (operand) của nó. Ví dụ sau sử dụng các phép toán này:

#include <stdio.h>

int main() {
   int var = 20;   /* actual variable declaration */
   int *ip;        /* pointer variable declaration */

   ip = &var;  /* store address of var in pointer variable*/

   printf("Address of var variable: %x\n", &var);

   /* address stored in pointer variable */
   printf("Address stored in ip variable: %x\n", ip);

   /* access the value using the pointer */
   printf("Value of *ip variable: %d\n", *ip);

   return 0;
}

Kết quả in ra như sau:

Address of var variable: bffd8b3c
Address stored in ip variable: bffd8b3c
Value of *ip variable: 20

Con trỏ NULL

Gán giá trị NULL cho một con trỏ trong trường hợp bạn không có địa chỉ cụ thể để gán, là một thói quen tốt nhất. Điều này nên làm tại thời điểm khai báo biến. Một con trỏ được gán NULL được gọi là con trỏ null.

Con trỏ NULL là một hằng số (constant) có giá trị bằng 0. Giá trị này được định nghĩa trong một số thư viện tiêu chuẩn (standard library). Xét chương trình sau:

#include <stdio.h>

int main() {
   int *ptr = NULL;

   printf("The value of ptr is : %x\n", ptr);
 
   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

The value of ptr is 0

Trong hầu hết các hệ điều hành, chương trình không được phép truy cập bộ nhớ tại địa chỉ 0, vì bộ nhớ đó được hệ điều hành để dành riêng (reserved). Địa chỉ bộ nhớ 0 có ý nghĩa đặc biệt. Nó báo hiệu rằng con trỏ không có ý trỏ đến một vị trí bộ nhớ có thể truy cập (an accessible memory location). Nhưng theo quy ước, nếu một con trỏ chứa giá trị null (rỗng), thì nó được coi là không trỏ đến gì.

Để kiểm tra con trỏ null, bạn có thể sử dụng câu lệnh ‘if’ như sau:

if(ptr)     /* succeeds if p is not null */
if(!ptr)    /* succeeds if p is null */

Khái niệm quan trọng

Con trỏ có nhiều khái niệm và chúng rất quan trọng đối với lập trình C. Các khái niệm quan trọng sau đây đối với bất kỳ lập trình viên C nào cũng đều phải nắm vững.

Số học con trỏ (Pointer arithmetic):
Có 4 toán tử số học (arithmetic operator) được sử dụng cho con trỏ: ++, –, +, –

Mảng con trỏ (Array of pointers):
Bạn có thể định nghĩa các mảng để chứa nhiều con trỏ.

Con trỏ tới con trỏ (Pointer to pointer):
C cho phép bạn có con trỏ trên một con trỏ, và v.v.

Truyền con trỏ tới hàm trong C (Passing pointers to functions in C):
Việc truyền đối số theo tham chiếu hoặc theo địa chỉ cho phép thay đổi giá trị đối số đã truyền trong hàm được gọi.

Trả về con trỏ từ hàm trong C (Return pointer from functions in C):
C cho phép một hàm trả về một con trỏ tới biến cục bộ, biến tĩnh và cả bộ nhớ được cấp phát động.

Số học con trỏ (Pointer arithmetic)

Trong C, một con trỏ là một địa chỉ, là một giá trị số. Do đó, bạn có thể thực hiện các phép toán số học (arithmetic operation) lên một con trỏ giống như với một giá trị số. Có bốn toán tử số học có thể được sử dụng lên con trỏ: ++, –, + và –

Để hiểu số học con trỏ (pointer arithmetic), chúng ta hãy coi ptr là một con trỏ số nguyên (an integer pointer) trỏ đến địa chỉ 1000. Giả sử số nguyên 32 bit, chúng ta hãy thực hiện phép toán số học sau trên con trỏ.

ptr++;

Sau phép toán trên, ptr sẽ trỏ đến vị trí 1004 vì mỗi lần ptr được tăng lên, nó sẽ trỏ đến vị trí số nguyên tiếp theo cách vị trí hiện tại 4 byte. Phép toán này sẽ di chuyển con trỏ tới vị trí bộ nhớ tiếp theo mà không tác động đến giá trị được lưu tại vị trí bộ nhớ. Nếu ptr trỏ đến một ký tự (character) có địa chỉ là 1000, thì phép toán trên sẽ trỏ đến vị trí 1001 vì ký tự tiếp theo sẽ có sẵn ở 1001.

Tăng con trỏ (Incrementing a Pointer)

Chúng ta thường thích sử dụng con trỏ (pointer) trong chương trình thay vì mảng (array) vì con trỏ có thể tăng lên (increment), còn mảng không thể tăng lên vì nó là một con trỏ cố định (constant pointer). Chương trình sau tăng con trỏ để truy cập từng phần tử kế tiếp của mảng.

#include <stdio.h>

const int MAX = 3;

int main() {

   int var[] = {10, 100, 200};
   int i, *ptr;

   /* let us have array address in pointer */
   ptr = var;
	
   for (i = 0; i < MAX; i++) {

      printf("Address of var[%d] = %x\n", i, ptr);
      printf("Value of var[%d] = %d\n", i, *ptr);

      /* move to the next location */
      ptr++;
   }
	
   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Address of var[0] = bf882b30
Value of var[0] = 10
Address of var[1] = bf882b34
Value of var[1] = 100
Address of var[2] = bf882b38
Value of var[2] = 200

Giảm con trỏ (Decrementing a Pointer)

Tương tự áp dụng cho việc giảm (decrement) một con trỏ, làm giảm giá trị của nó theo số byte của kiểu dữ liệu của nó như được dưới đây:

#include <stdio.h>

const int MAX = 3;

int main() {
   int var[] = {10, 100, 200};
   int i, *ptr;

   /* let us have array address in pointer */
   ptr = &var[MAX-1];
	
   for (i = MAX; i > 0; i--) {

      printf("Address of var[%d] = %x\n", i-1, ptr);
      printf("Value of var[%d] = %d\n", i-1, *ptr);

      /* move to the previous location */
      ptr--;
   }
	
   return 0;
}

Execute đoạn code trên sẽ cho ra kết quả sau:

Address of var[2] = bfedbcd8
Value of var[2] = 200
Address of var[1] = bfedbcd4
Value of var[1] = 100
Address of var[0] = bfedbcd0
Value of var[0] = 10

So sánh con trỏ (Pointer Comparisons)

Con trỏ có thể được so sánh bằng cách sử dụng các toán tử quan hệ (relational operator), chẳng hạn như ==, <, >, <=, >=, !=. Nếu p1 và p2 chỉ tới các biến có liên quan với nhau, chẳng hạn như các phần tử của cùng một mảng, thì p1 và p2 có thể được so sánh có ý nghĩa.

Sử dụng lại chương trình trước làm ví dụ và sửa lại một chút. Vòng lặp thực thi nếu thỏa điều kiện so sánh con trỏ với mọi giá trị địa chỉ nhỏ hơn hoặc bằng địa chỉ của phần tử cuối cùng của mảng, là &var[max - 1]

#include <stdio.h>

const int MAX = 3;

int main() {
   int var[] = {10, 100, 200};
   int i, *ptr;

   /* let us have address of the first element in pointer */
   ptr = var;
   i = 0;
	
   while (ptr <= &var[MAX - 1]) {
      printf("Address of var[%d] = %x\n", i, ptr);
      printf("Value of var[%d] = %d\n", i, *ptr);

      /* point to the next location */
      ptr++;
      i++;
   }
	
   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Address of var[0] = bfdbcb20
Value of var[0] = 10
Address of var[1] = bfdbcb24
Value of var[1] = 100
Address of var[2] = bfdbcb28
Value of var[2] = 200

Mảng con trỏ

Trước khi tìm hiểu về khái niệm mảng con trỏ (array of pointers), hãy xem xét ví dụ sau, sử dụng một mảng 3 số nguyên.

#include <stdio.h>
 
const int MAX = 3;
 
int main() {
   int var[] = {10, 100, 200};
   int i;
 
   for (i = 0; i < MAX; i++) {
      printf("Value of var[%d] = %d\n", i, var[i]);
   }
   
   return 0;
}

Kết quả:

Value of var[0] = 10
Value of var[1] = 100
Value of var[2] = 200

Trong trường hợp khi chúng ta muốn sủ dụng một mảng, mà có thể chứa các con trỏ tới một int hoặc char hoặc kiểu dữ liệu khác. Dưới đây là khai báo một mảng con trỏ tới một số nguyên.

int *ptr[MAX];

Nó khai báo ptr là một mảng có MAX con trỏ integer. Do đó, mỗi phần tử trong ptr giữ một con trỏ tới giá trị int. Ví dụ sau sử dụng ba số nguyên (integer) được lưu trong một mảng con trỏ:

#include <stdio.h>
 
const int MAX = 3;
 
int main() {
   int var[] = {10, 100, 200};
   int i, *ptr[MAX];
 
   for (i = 0; i < MAX; i++) {
      ptr[i] = &var[i]; /* assign the address of integer. */
   }
   
   for (i = 0; i < MAX; i++) {
      printf("Value of var[%d] = %d\n", i, *ptr[i]);
   }
   
   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Value of var[0] = 10
Value of var[1] = 100
Value of var[2] = 200

Bạn cũng có thể sử dụng một mảng con trỏ tới kí tự (an array of pointers to character) để lưu một danh sách string như dưới đây:

#include <stdio.h>
 
const int MAX = 4;
 
int main() {
   char *names[] = {
      "Zara Ali",
      "Hina Ali",
      "Nuha Ali",
      "Sara Ali"
   };

   int i = 0;

   for ( i = 0; i < MAX; i++) {
      printf("Value of names[%d] = %s\n", i, names[i] );
   }
   
   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Value of names[0] = Zara Ali
Value of names[1] = Hina Ali
Value of names[2] = Nuha Ali
Value of names[3] = Sara Ali

Con trỏ tới con trỏ

Một con trỏ tới một con trỏ (a pointer to a pointer) là một dạng của nhiều hướng hoặc một chuỗi các con trỏ. Thông thường, một con trỏ chứa địa chỉ của một biến. Khi chúng ta định nghĩa một con trỏ tới một con trỏ, thì con trỏ thứ nhất chứa địa chỉ của con trỏ thứ hai, con trỏ thứ hai trỏ tới vị trí chứa giá trị của biến như hình bên dưới.

Một biến là con trỏ tới con trỏ phải được khai báo như vậy. Điều này được thực hiện bằng cách đặt thêm một dấu hoa thị trước tên của nó. Ví dụ sau khai báo một con trỏ tới một con trỏ kiểu intMột biến là con trỏ tới con trỏ phải được khai báo như vậy. Điều này được thực hiện bằng cách đặt thêm một dấu hoa thị trước tên của nó. Ví dụ sau khai báo một con trỏ tới một con trỏ kiểu int.

int **var;

Khi một giá trị đích được trỏ gián tiếp bởi một con trỏ tới một con trỏ, việc truy cập giá trị đó yêu cầu toán tử dấu hoa thị được áp dụng hai lần, như trong ví dụ bên dưới.

#include <stdio.h>
 
int main() {
   int var;
   int *ptr;
   int **pptr;

   var = 3000;

   /* take the address of var */
   ptr = &var;

   /* take the address of ptr using address of operator & */
   pptr = &ptr;

   /* take the value using pptr */
   printf("Value of var = %d\n", var);
   printf("Value available at *ptr = %d\n", *ptr);
   printf("Value available at **pptr = %d\n", **pptr);

   return 0;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Value of var = 3000
Value available at *ptr = 3000
Value available at **pptr = 3000

Truyền con trỏ tới hàm trong C (Passing pointers to functions in C)

Chương trình C cho phép truyền (pass) con trỏ vào hàm. Để làm vậy, chỉ cần khai báo tham số (parameter) có kiểu con trỏ.

Dưới đây là một ví dụ đơn giản khi truyền một con trỏ unsigned long cho một hàm và thay đổi giá trị bên trong hàm mỗi khi hàm được gọi.

#include <stdio.h>
#include <time.h>
 
void getSeconds(unsigned long *par);

int main() {
   unsigned long sec;
   getSeconds(&sec);

   /* print the actual value */
   printf("Number of seconds: %ld\n", sec);

   return 0;
}

void getSeconds(unsigned long *par) {
   /* get the current number of seconds */
   *par = time(NULL);
   return;
}

Compile và execute đoạn code trên sẽ cho ra kết quả sau:

Number of seconds :1294450468

Hàm nhận một con trỏ cũng có thể sử dụng bằng một mảng. Xem ví dụ sau:

#include <stdio.h>
 
/* function declaration */
double getAverage(int *arr, int size);
 
int main() {
   /* an int array with 5 elements */
   int balance[5] = {1000, 2, 3, 17, 50};
   double avg;
 
   /* pass pointer to the array as an argument */
   avg = getAverage(balance, 5);
 
   /* output the returned value  */
   printf("Average value is: %f\n", avg);
   return 0;
}

double getAverage(int *arr, int size) {
   int i, sum = 0; 
   double avg; 
 
   for (i = 0; i < size; ++i) {
      sum += arr[i];
   }
 
   avg = (double)sum / size;
   return avg;
}

Kết quả như sau:

Average value is: 214.40000

Trả về con trỏ từ hàm trong C (Return pointer from functions in C)

Chúng ta đã biết rằng lập trình C cho phép hàm trả về một mảng. Tương tự, C cũng cho phép hàm trả về một con trỏ. Để làm được, chúng ta cần khai báo hàm trả về con trỏ như sau:

int * myFunction() {
   .
   .
   .
}

Cần lưu ý rằng không nên trả về địa chỉ của một biến cục bộ (address of a local variable) bên ngoài hàm, bạn cần phải định nghĩa biến cục bộ là biến static.

Bây giờ, hãy xem ví dụ về hàm sau. Hàm sẽ tạo ra 10 số ngẫu nhiên (random number) và return chúng dưới dạng một tên mảng tượng trưng cho một con trỏ, hoặc địa chỉ của phần tử thứ nhất.

#include <stdio.h>
#include <time.h>
 
/* function to generate and return random numbers. */
int * getRandom() {
   static int r[10];
   int i;
 
   /* set the seed */
   srand((unsigned)time(NULL));
	
   for (i = 0; i < 10; ++i) {
      r[i] = rand();
      printf("%d\n", r[i]);
   }
 
   return r;
}
 
/* main function to call above defined function */
int main() {
   /* a pointer to an int */
   int *p;
   int i;

   p = getRandom();
	
   for (i = 0; i < 10; i++) {
      printf("*(p + [%d]) : %d\n", i, *(p + i));
   }
 
   return 0;
}

Khi chạy đoạn code trên, kết quả in ra như sau:

1523198053
1187214107
1108300978
430494959
1421301276
930971084
123250484
106932140
1604461820
149169022
*(p + [0]) : 1523198053
*(p + [1]) : 1187214107
*(p + [2]) : 1108300978
*(p + [3]) : 430494959
*(p + [4]) : 1421301276
*(p + [5]) : 930971084
*(p + [6]) : 123250484
*(p + [7]) : 106932140
*(p + [8]) : 1604461820
*(p + [9]) : 149169022
Icons made by Freepik from www.flaticon.com