Con trỏ phần 1: Giới thiệu cơ bản về con trỏ

Cơ bản

Lời mở đầu

Con trỏ thường được nêu ra trong những cuộc tranh cãi là phần khó hiểu nhất của C. Nhưng nó lại là tính năng mà khiến C trở thành một ngôn ngữ mạnh mẽ. Trong bài viết này, chúng ta sẽ cùng đi từ những khái niệm đơn giản nhất về con trỏ và cách sử dụng của nó với mảng, string, hàm.
Nào, cùng dành khoảng 30 phút, thư giãn, rót một ly coffee, và bắt đầu tìm hiểu về con trỏ nhé

Khái niệm về biến, giá trị của biến, địa chỉ của biến

Để hiểu cách sử dụng của con trỏ, chúng ta cần nắm vững khái niệm về địa chỉ ở trong C.
Điều gì sẽ xảy ra khi chúng ta viết dòng code dưới đây?

int digit = 42;

Ở dòng code trên, chúng ta đã khai báo 1 biến kiểu int có tên là digit với giá trị là 42.

Về mặt tổ chức bộ nhớ, chúng ta đã lấy ra 1 khối bộ nhớ (block of memory), đặt tên cho nó là digit và gán giá trị cho khối bộ đó là 42. Vì được khai báo là kiểu int nên khối này chiếm 4 bytes liên tiếp nhau trong bộ nhớ máy tính.

Khối bộ nhớ này được gán với 1 địa chỉ (address). Giá trị của địa chỉ này không quá quan trọng với chúng ta, vì nó là 1 giá trị random nào đó mà được máy tính tự động sinh ra. Nhưng, chúng ta có thể truy cập vào địa chỉ này bằng cách sử dụng toán tử &.

printf("dia chi cua bien digit: = %d",&digit);

Lưu ý: Các bạn có thể nhận được các địa chỉ khác nhau mỗi khi chạy dòng code trên do việc tổ chức bộ nhớ này được ngẫu nhiên thực hiện bởi máy tính.

Truy cập giá trị của biến thông qua địa chi

Để truy cập giá trị của biến thông qua địa chỉ, sử dụng toán tử *.

Ví dụ:

int digit = 42;
printf("Dia chi cua bien digit: %d\n", &digit);
printf("Gia tri cua bien digit: %d\n", *(&digit));


Output:
dia chi cua bien digit: 6422300
gia tri cua bien digit: 42

Vậy tóm lại biến là một thứ dùng để lưu giá trị, biến được lưu trong bộ nhớ và được cấp phát 1 địa chỉ riêng cho biến đó. Muốn biết địa chỉ của biến x là gì thì sử dụng toán tử &x. Muốn truy cập giá trị của biến thông qua địa chỉ, sử dụng toán tử *

Con trỏ

Định nghĩa:

Con trỏ trong C cũng chỉ là một biến, mang đầy đủ các chức năng mà một biến bình thường trong C có. Ví dụ như là có thể khai báo, khởi tạo và lưu trữ giá trị cũng như là có giá trị riêng của nó. Nhưng biến con trỏ không chỉ dùng để lưu một giá trị bình thường, nó là biến trỏ tới 1 địa chỉ khác, tức giá trị nó lưu là 1 địa chỉ của 1 ô nhớ khác.

Chúng ta cùng thống nhất 1 số khái niệm khi làm việc với con trỏ nhé:

  • Giá trị của con trỏ: địa chỉ mà con trỏ trỏ đến.
  • Địa chỉ của con trỏ: Địa chỉ của bản thân biến con trỏ đó
  • Giá trị của biến nơi con trỏ đang trỏ tới
  • Địa chỉ của biến nơi con trỏ đang trỏ tới = giá trị của con trỏ.

Như ở hình phía trên, các bạn có thể thấy con trỏ pointer lưu giá trị và 24650 và giá trị này chính xác là địa chỉ của biến digit. Khi này ta nói, con trỏ pointer trỏ đến địa chỉ 24650 hoặc trỏ tới biến digit. Cách khai báo

` <kiểu dữ liệu> * <tên biến>`

Tương tự như các biến bình thường, con trỏ cũng phải có kiểu dữ liệu và tên biến, tuy nhiên điều khác biệt là dấu * trước tên biến, đây là ký hiệu báo cho trình biên dịch biết ta đang khai báo con trỏ.

Những kiểu dữ liệu có thể là: void, int, float, double…. Tên con trỏ phải được đặt tuân theo quy tắc đặt tên biến như bình thường. - Ví dụ:

int *ptr_int; // khai báo con trỏ để trỏ tới biến kiểu int
char *ptr_char; // khai báo con trỏ để trỏ tới biến kiểu char
float *ptr_float;//khai báo con trỏ để trỏ tới biến kiểu float
void *ptr_void; // khai báo con trỏ kiểu void có thể trỏ tới mọi kiểu dữ liệu

Cách gán giá trị

Sau khi khai báo con trỏ, cũng giống như 1 biến bình thường, ta cần khởi tạo giá tri cho nó. Ví dụ:

int *ptr; // khai báo con trỏ
int digit = 42; // khai báo biến digit mang giá trị là 42
ptr = &digit; // gán giá trị của con trỏ = địa chỉ của biến digit

Hoặc:

char c = ‘a’; // khai báo biến c kiểu char mang giá trị là ‘a’
char *ptr = &c; // khai báo con trỏ kiểu char đồng thời khởi tạo giá trị của nó bằng địa chỉ của biến c

Lưu ý:
Con trỏ được khai báo là kiểu dữ liệu gì thì chỉ có thể trỏ tới biến có cùng kiểu giá trị đó ( trừ con trỏ mang kiểu void mà chúng ta sẽ đề cập ngay sau đây)

Tham chiếu ngược (dereference)

Khi chúng ta tham chiếu ngược (dereference) một địa chỉ, nó trả lại giá trị lưu trong địa chỉ ô nhớ đó. Vì bản chất con trỏ lưu địa chỉ, nên việc tham chiếu ngược tới con trỏ sẽ truy cập được giá trị của địa chỉ mà con trỏ trỏ tới.

Cú pháp  `*[tên_con_trỏ]`

Ví dụ:

int digit = 42;//khai báo biến kiểu int mang giá trị là 42
Int *ptr = &digit; // khai báo con trỏ kiểu int đồng thời khởi tạo giá trị của nó bằng địa chỉ của biến digit
printf("Gia tri khi tham chieu nguoc = %d\n", *ptr);

Output: Gia tri khi tham chieu nguoc = 42

Con trỏ rác ( Null Pointer)

int *null_ptr; // con tro rac
int digit = 42;
int *ptr = &digit;

Hãy ghi nhớ rằng chúng ta không nên để một con trỏ là rác ( tức là không được khởi tạo giá trị). Một con trỏ rác là con trỏ không trỏ tới cái gì cả, nếu bạn sử dụng nó thì nó sẽ trỏ tới 1 địa chỉ ` ngẫu nhiên ` nào đó và sẽ thật là nguy hiểm nếu địa chỉ đó đang được sử dụng với 1 mục đích khác.

Để đảm bảo rằng chúng ta không có con trỏ rác, hãy khởi tạo cho con trỏ với giá trị NULL, và mục đích là để sử dụng sau này nếu cần thiết.

int *ptr = NULL;
Hoặc
int *ptr = nullptr;


    
        Con trỏ Void ( Void Pointer)
            Một con trỏ void được sử dụng để trỏ tới biến có bất kỳ kiểu dữ liệu nào. Nó có thể tái sử dụng với bất kỳ biến nào mà chúng ta muốn. Nó được khai báo như sau:
        
void *ptr_void = nullptr;

Với sự “tiện lợi”, con trỏ void cũng mang tới những giới hạn ràng buộc. Bạn không thể lấy giá trị của địa chỉ mà con trỏ void trỏ tới nếu không ép kiểu tường minh.

Ví dụ

void *ptr_void = nullptr;
int number = 54;
ptr_void = &number;
printf("Gia tri cua number = %d\n", *ptr_void); // bien dich loi
printf("Gia tri cua number = %d\n", *(int *)ptr_void);

Bản chất của con trỏ trong C

Để hiểu về sức mạnh của con trỏ, hãy cùng xem xét ví dụ sau:

int number = 54;
int ptr = &number;
printf("Gia tri cua number = %d\n", number); // 54
printf("Dia chi cua number = %d\n", &number); // 6422300
printf("Gia tri cua con tro = %d\n", ptr); // 6422300
printf("Dia chi cua con tro = %d\n", &ptr); // 6422296
printf("Gia tri cua bien ma con tro p dang tro toi = %d\n", *ptr); // 54

*ptr = 100; // thay doi gia tri cua con tro
printf(“\n”);
printf("Gia tri cua number = %d\n", number); //100
printf("Gia tri cua bien ma con tro p dang tro toi = %d\n", *ptr); // 100

number = 1000;
printf(“\n”);
printf("Gia tri cua number = %d\n", number); //1000
printf("Gia tri cua bien ma con tro p dang tro toi = %d\n", *ptr); // 1000

Qua ví dụ trên chúng ta có thể đưa ra kết luận sau đây:

Con trỏ có thể thay đổi trực tiếp giá trị của biến mà nó đang trỏ tới Khi thay đổi giá trị của biến, thì rõ ràng nếu tồn tại con trỏ ptr trỏ tới biến đó thì *ptr cũng sẽ thay đổi theo giá trị của biến.

Các lỗi thường gặp

Nhầm lẫn giữa địa chỉ và giá trị. Con trỏ là biến trỏ tới địa chỉ, không phải giá trị

int number = 54;
int *ptr = number; // sai vi number la gia tri
int *ptr = &number; // dung vi number la dia chi

Có thể các bạn sẽ không phân biệt được dấu * khi khai báo con trỏ và khi truy cập vào giá trị của địa chỉ mà con trỏ đang trỏ tới.

int number = 54;
int *ptr = &number; // *ptr mang y nghia la khai bao con tro
*ptr = 100; // *ptr mang y nghia la truy cap gia tri cua dia chi ma con tro dang tro toi

B. Mối quan hệ của con trỏ và mảng, chuỗi

Tại sao lại là con trỏ và mảng?

Trong C, con trỏ và mảng có mối quan hệ rất gần gũi và mạnh mẽ với nhau. Thông thường bạn có thể truy cập phần tử mảng bằng cách arrayName[index], tuy nhiên con trỏ cung cấp cho chúng ta cách để truy cập đến các phần tử của mảng nhanh hơn và tối ưu hơn. Cụ thể hãy cùng mình tìm hiểu tiếp nhé!

Mảng 1 chiều

Hãy cùng xem điều gì xảy ra nếu chúng ta viết int myArrray[5];.

5 khối bộ nhớ liên tiếp bắt đầu từ myArray[0] tới myArray[4] được tạo ra với những giá trị rác chứa bên trong nó (do chưa khởi tạo). Mỗi khối bộ nhớ có kích thước là 4 bytes ( do là kiểu int).

Do đó, nếu địa chỉ của myArray[0] là 100, thì địa chỉ của các ô còn lại lần lượt là 104, 108, 112 và 116.

Hãy cùng xem xét ví dụ sau:

int prime[5] = {2,3,5,7,11};
printf("Ket qua khi dung &prime = %d\n",&prime);
printf("Ket qua khi dung prime = %d\n",prime);
printf("Ket qua khi dung &prime[0] = %d\n",&prime[0]);


/* Output */
Ket qua khi dung &prime = 6422016
Ket qua khi dung prime = 6422016
Ket qua khi dung &prime[0] = 6422016

&prime, prime và &prime[0] tất cả cùng trỏ đến một địa chỉ, phải không? Đúng vậy, nhưng đợi đã, có một điều bất ngờ ( nhưng cũng có thể gây bối rối).

Hãy cùng cộng vào mỗi con trỏ &prime, prime, và &prime[0] thêm 1

printf("Ket qua khi dung &prime = %d\n",&prime + 1);
printf("Ket qua khi dung prime = %d\n",prime + 1);
printf("Ket qua khi dung &prime[0] = %d\n",&prime[0] + 1);

    /* Output */
Ket qua khi dung &prime = 6422036
Ket qua khi dung prime = 6422020
Ket qua khi dung &prime[0] = 6422020

Wow! Tại sao &prime + 1 lại cho ra giá trị khác với 2 cái còn lại? Và tại sao prime + 1 và &prime[0] + 1 lại vẫn bằng nhau? Hãy cùng trả lời nhé.

Bản thân biến mảng (trong trường hợp này là prime) là một con trỏ trỏ tới phần tử đầu tiên của mảng (phần tử ở vị trí 0). Do đó trong ví dụ trên chúng ta thấy prime và &prime[0] có giá trị giống nhau. Vì bản chất đều trỏ tới phần tử đầu tiên của mảng. Ở đây, cả hai con trỏ trỏ tới phần tử đầu tiên có kích thước 4 bytes. Khi bạn thêm 1 vào chúng, chúng sẽ trỏ tới phần tử thứ 1 của mảng. Do đó kết quả là địa chỉ được cộng thêm 4.

&prime, ở khía cạnh khác, là 1 con trỏ tới mảng int có kích thước 5. Nó lưu địa chỉ “gốc” của mảng prime[5], địa chỉ này ban đầu bằng với địa chỉ của phần tử đầu tiên của mảng. Tuy nhiên, khi cộng thêm 1 sẽ có kết quả lại là địa chỉ cũ cộng thêm 5 x 4 = 20 bytes.

Tóm lại, arrayName và arrayName[0] trỏ tới phần tử đầu tiên trong khi &arrayName trỏ tới toàn bộ mảng

Chúng ta có thể truy cập phần tử của mảng khi sử dụng chỉ số như sau:

int prime[5] = {2,3,5,7,11};
for( int i = 0; i < 5; i++)
{
  printf("Chi so = %d, Dia chi= %d, Gia tri= %d\n", i, &prime[i], prime[i]);
}

Chúng ta có thể dùng con trỏ, điều này sẽ nhanh hơn sử dụng chỉ số:

int prime[5] = {2,3,5,7,11};
for( int i = 0; i < 5; i++)
{
  printf("Chi so = %d, Dia chi= %d, Gia tri= %d\n", i, prime + i, *(prime + i));
}

Cả 2 cách đều cho output là:

Chi so= 0, Dia chi= 6422016, Gia tri= 2
Chi so= 1, Dia chi= 6422020, Gia tri= 3
Chi so= 2, Dia chi= 6422024, Gia tri= 5
Chi so= 3, Dia chi= 6422028, Gia tri= 7
Chi so= 4, Dia chi= 6422032, Gia tri= 11

String

Mỗi chuỗi (string) là 1 mảng 1-chiều gồm các ký tự và được kết thúc bởi ký tự null (\0). Khi chúng ta viết char name[] = “ITMO_BRAIN”; , mỗi ký tự chiếm 1 byte trong bộ nhớ và mặc định ký tự cuối cùng luôn luôn phải là\0.

Tương tự như với mảng chúng ta đã làm phía trên, name và &name[0] trỏ tới ký từ thứ 0 của chuỗi, trong khi &nametrỏ tới toàn bộ chuỗi. Tương tự, name[i] cũng có thể viết thành *(name +i).

char champions[] = "Liverpool";

printf("Địa chỉ của cả chuỗi = %d\n", &champions);
printf("Giá trị sau khi thêm 1 %d\n", &champions + 1);
/* Output */
Địa chỉ của cả chuỗi = 6421974
Giá trị sau khi thêm 1 =  6421984
printf("Địa chỉ của phần tử đầu tiên = %d\n", &champions[0]);
printf("Giá trị sau khi cộng 1 vào địa chỉ của phần tử đầu tiên  %d\n", &champions[0] + 1);
/* Output */
Địa chỉ của phần tử đầu tiên = 6421974
Giá trị sau khi cộng 1 vào địa chỉ của phần tử đầu tiên = 6421975
printf("Địa chỉ của phần tử đầu tiên = %d\n", champions);
printf("Giá trị sau khi cộng 1 vào địa chỉ của phần tử đầu tiên %d\n", champions + 1);

/* Output */
Địa chỉ của phần tử đầu tiên = 6421974
Giá trị sau khi cộng 1 vào địa chỉ của phần tử đầu tiên 6421975
printf("Giá trị của ký tự thứ 4 = %c\n", champions[4]);
printf("Giá trị của ký tự thứ 4 dùng con trỏ = %c\n", *(champions + 4));

/* Output */
Giá trị của ký tự thứ 4 = r
Giá trị của ký tự thứ 4 dùng còn trỏ = r

Mảng các con trỏ

Như một mảng int và mảng char, chúng ta cũng có mảng các con trỏ. Loại mảng này đơn giản là tập hợp của các địa chỉ ô nhớ. Những ô nhớ này có thể trỏ tới các giá trị riêng lẻ hoặc cũng có thể trỏ tới các mảng khác.

Cú pháp để khai báo mảng con trỏ:

dataType *variableName[size];
/* Examples */
int *example1[5]; // khai báo mảng example1 chứa 5 con trỏ kiểu int
char *example2[8];// khai báo mảng example2 chứa 8 con trỏ kiểu char

Con trỏ trỏ tới mảng

Cũng giống như “con trỏ tới int” hoặc “con trỏ tới char”, chúng ta cũng có con trỏ trỏ tới mảng. Loại con trỏ này trỏ tới toàn mảng hoặc các phần tử của mảng đó.

Note: Kiến thức ở phần trước, &arrayName trỏ tới toàn bộ mảng.

Một con trỏ trỏ tới mảng có thể khai báo như sau:

dataType (*variableName)[size];
/* Examples */
int (*ptr1)[5];
char (*ptr2)[15];

Lưu ý: dấu ngoặc tròn () . Nếu không có nó, những gì chúng ta khai báo sẽ trở thành mảng các con trỏ chứ không phải con trỏ trỏ tới mảng.

Trong ví dụ thứ nhất thì ptr1 là con trỏ trỏ tới mảng chứa 5 số nguyên ( 5 integers).

int goals[] = { 85,102,66,69,67};
int (*pointerToGoals)[5] = &goals;
printf("Địa chỉ lưu trong pointerToGoals %d\n", pointerToGoals);
printf("giá trị %d\n",*pointerToGoals);

/* Output */
Địa chỉ lưu trong pointerToGoals 6422016
Giá trị 6422016

Khi chúng ta tham chiếu ngược (dereference) một con trỏ, nó trả lại giá trị lưu trong địa chỉ ô nhớ đó. Tương tự, khi tham chiếu ngược một con trỏ tới mảng, chúng ta có được mảng và tên của mảng trỏ tới địa chỉ gốc. Nếu chúng ta tham chiếu ngược 1 lần nữa, chúng ta sẽ nhận được giá trị lưu trong địa chỉ đó. Chúng ta thử in chúng ra bằng cách sử dụng pointerToGoals nhé.

for(int i = 0; i < 5; i++)
printf("%d ", *(*pointerToGoals + i));

/* Output */
85 102 66 69 67

Xem thêm phần 2: Sử dụng con trỏ với hàm trong lập trình C

Kết bài

Vậy là chúng ta đã đi qua các định nghĩa, bản chất, lỗi thường gặp, các toán tử cũng như là ứng dụng của con trỏ. Hy vọng bài viết này đã giúp cho các bạn phần nào hiểu thêm về con trỏ và cách sử dụng nó trong lập trình C. Bản thân mình khi học về con trỏ cũng phải nhào nặn lại kiến thức khá nhiều thì mới có thể tận dụng được sức mạnh của nó. Nên các bạn đừng lo lắng hay ngại ngần mà đọc lại bài viết để nắm rõ hơn phần kiến thức hay và quan trọng này nhé!