Trong bài biến – Cái nhìn đầu tiên về các biến, chúng ta đã biết rằng một biến là tên của một phần bộ nhớ nơi mà nó chứa một giá trị. Khi chương trình khởi tạo một biến, một địa chỉ bộ nhớ trống/khả dụng sẽ tự động được gán cho biến đó và bất kỳ giá trị nào chúng ta gán cho biến đó, sẽ được lưu trong địa chỉ bộ nhớ này.

Ví dụ:

int x;

Khi câu lệnh này được CPU thực thi, một phần bộ nhớ từ RAM sẽ được sử dụng để cấp phát cho biến x. Để dễ minh họa, giả sử biến x được gán cho vị trí bộ nhớ 140. Bất cứ khi nào chương trình nhìn thấy biến x trong một biểu thức hoặc câu lệnh, nó sẽ biết rằng nó nên tìm kiếm trong vị trí bộ nhớ 140 để lấy được giá trị của biến x.

Chúng ta không cần phải bận tâm về việc địa chỉ bộ nhớ cụ thể nào được chỉ định/cấp phát cho một biến. Chúng ta chỉ quan tâm đến tên/định danh của biến, và trình biên dịch sẽ dịch tên này thành địa chỉ bộ nhớ đã được cấp phát một cách
hợp lý.

Tuy nhiên, cách tiếp cận này có một số hạn chế, mà chúng ta sẽ thảo luận trong bài học này và trong tương lai.

1. Toán tử địa chỉ-của (&)

Toán tử địa chỉ-của (&) cho phép chúng ta biết được địa chỉ bộ nhớ nào đã đựơc gán cho một biến. Ví dụ trực quan:

#include <iostream>
 
int main()
{
    int x = 5;
    std::cout << x << '\n'; // print the value of variable x
    std::cout << &x << '\n'; // print the memory address of variable x
 
    return 0;
}

Trên máy tính của tác giả bài viết này, đoạn code trên sẽ in ra:

5
0027FEA0

Lưu ý: Mặc dù toán tử địa chỉ-của trông giống như một toán tử AND khi thao tác bits (bitwise-and operator), nhưng bạn hoàn toàn có thể phân biệt chúng bởi vì toán tử địa chỉ-của là toán tử một ngôi (unary operator), trong khi đó toán tử AND khi thao tác bits (bitwise-and operator) là toán tử hai ngôi (binary operator).

2. Toán tử dereference (*)

Việc lấy được địa chỉ của một biến thường không hữu ích.

Toán tử dereference (*) cho phép chúng ta truy cập tới giá trị tại một địa chỉ cụ thể.

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
int main()
{
    int x = 5;
    std::cout << x << '\n'; // print the value of variable x
    std::cout << &x << '\n'; // print the memory address of variable x
    std::cout << *(&x) << '\n'; /// print the value at the memory address of variable x (parenthesis not required, but make it easier to read)
 
    return 0;
}

Trên máy của tác giả bải viết này, đoạn chương trình trên sẽ in ra:

5
0027FEA0
5

Lưu ý: Mặc dù toán tử dereference trông giống như toán tử nhân, nhưng bạn hoàn toàn có thể phân biệt chúng bởi vì toán tử dereference là toán tử một ngôi (unary operator), trong khi toán tử nhân là toán tử hai ngôi (binary operator).

3. Con trỏ (Pointers)

Với toán tử địa chỉ-của và các toán tử dereference vừa mới tìm hiểu ở trên, giờ đây chúng ta có thể nói về con trỏ (pointers). Một con trỏ là một biến chứa một địa chỉ bộ nhớ làm giá trị của nó (Giải thích thêm: Hiểu đơn giản rằng đối với biến bình thường thì sẽ lưu một giá trị nào đó bên trong nó, còn đối với biến con trỏ thì giá trị lưu bên trong biến con trỏ sẽ là một giá trị địa chỉ bộ nhớ).

Con trỏ thường được xem là một trong những phần khó hiểu nhất của ngôn ngữ C++, nhưng bạn sẽ thấy chúng hết sức đơn giản đến mức đáng ngạc nhiên, khi được giải thích một cách chính xác.

3.0 Khai báo một con trỏ

Các biến con trỏ đều được khai báo giống như các biến bình thường, chỉ khác là sẽ có thêm một dấu hoa thị giữa kiểu dữ liệu và tên biến. Lưu ý rằng, khi khai báo biến con trỏ, dấu hoa thị không phải để mô tả việc dereference con trỏ, mà nó là một phần của cú pháp khai báo con trỏ.


int *iPtr; // a pointer to an integer value
double *dPtr; // a pointer to a double value
 
int* iPtr2; // also valid syntax (acceptable, but not favored)
int * iPtr3; // also valid syntax (but don't do this)
 
int *iPtr4, *iPtr5; // declare two pointers to integer variables

Về mặt cú pháp, C++ sẽ chấp nhận dấu hoa thị đặt bên cạnh kiểu dữ liệu, bên cạnh tên biến, hoặc thậm chí là ở giữa.

Tuy nhiên, khi khai báo nhiều biến con trỏ, dấu hoa thị phải được đặt cho từng biến. Mọi người thường rất dễ quên phải làm việc này vì đã quen với việc gắn dấu hoa thị vào kiểu dữ liệu thay vì tên biến.

int* iPtr6, iPtr7; // iPtr6 is a pointer to an int, but iPtr7 is just a plain int!

Vì lý do này, khi khai báo một biến, chúng tôi khuyên bạn nên đặt dấu hoa thị bên cạnh tên biến.

Thực hành tốt: Khi khai báo một biến con trỏ, hãy đặt dấu hoa thị bên cạnh tên biến.

Tuy nhiên, khi trả về một con trỏ từ một hàm, ta nên đặt dấu hoa thị bên cạnh kiểu trả về:

int* doSomething();

Điều này làm code trở nên rõ ràng hơn, người đọc code sẽ hiểu rõ rằng hàm này đang trả về một giá trị thuộc kiểu int* chứ không phải kiểu int.

Thực hành tốt: Khi khai báo một hàm trả về kiểu dữ liệu con trỏ, hãy đặt dấu hoa thị ở bên cạnh kiểu dữ liệu trả về của hàm. (như ví dụ về hàm doSomething() ở bên trên).

Cũng giống như các biến bình thường, con trỏ không được khởi tạo khi khai báo. Nếu không được khởi tạo với một giá trị nào đó, con trỏ sẽ chứa một giá trị rác.

Một lưu ý về cách đặt tên con trỏ: “X pointer – Con trỏ X” (trong đó X là một kiểu dữ liệu nào đó) là một cách viết tắt thường được sử dụng cho “pointer to an X – Con trỏ trỏ đến một kiểu dữ liệu X nào đó”. Vì vậy, khi nói “Một con trỏ kiểu số nguyên – integer”, điều này có ý nghĩa thật sự là “Một con trỏ, trỏ đến một số nguyên – integer”.

3.1 Gán giá trị cho một con trỏ

Bởi vì con trỏ chỉ giữ các địa chỉ bộ nhớ, vậy nên khi ta gán một giá trị cho một con trỏ, giá trị đó phải là một địa chỉ. Một trong những điều phổ biến nhất thường được làm với con trỏ là để chúng giữ địa chỉ của một biến khác.

Để lấy địa chỉ của một biến, chúng ta sử dụng toán tử &:

int v = 5;
int *ptr = &v; // initialize ptr with address of variable v

Về mặt khái niệm, bạn có thể nghĩ về đoạn code ở trên như sau:

Hình vẽ trên đã mô tả lý do vì sao những biến chứa địa chỉ lại được gọi là con trỏ. ptr đang giữ địa chỉ của biến value, vì vậy chúng ta nói rằng ptr “đang trỏ tới” value.

Có thể dễ hình dung hơn thông qua đoạn code sau:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
int main()
{
    int v = 5;
    int *ptr = &v; // initialize ptr with address of variable v
 
    std::cout << &v << '\n'; // print the address of variable v
    std::cout << ptr << '\n'; // print the address that ptr is holding
 
    return 0;
}

Trên máy của tác giả, đoạn code trên in ra:

0012FF7C
0012FF7C

Con trỏ phải có cùng kiểu dữ liệu với biến được nó trỏ tới:

int iValue = 5;
double dValue = 7.0;
 
int *iPtr = &iValue; // ok
double *dPtr = &dValue; // ok
iPtr = &dValue; // wrong -- int pointer cannot point to the address of a double variable
dPtr = &iValue; // wrong -- double pointer cannot point to the address of an int variable

Lưu ý rằng đoạn code sau cũng không hợp lệ:

int *ptr = 5;

Điều này là bởi vì con trỏ chỉ có thể giữ các địa chỉ, và số nguyên 5 không có một địa chỉ bộ nhớ nào. Nếu bạn thử đoạn code trên, trình biên dịch sẽ nói với bạn rằng nó không thể chuyển đổi một số nguyên thành một con trỏ kiểu nguyên.

C++ cũng sẽ không cho phép bạn gán trực tiếp các địa chỉ bộ nhớ dưới dạng chữ cho một con trỏ:

double *dPtr = 0x0012FF7C; // not okay, treated as assigning an integer literal

3.2 Toán tử địa chỉ-của trả về một con trỏ

Cần lưu ý rằng toán tử địa chỉ-của (&) không trả về địa chỉ của toán hạng của nó dưới dạng chữ. Thay vào đó, nó trả về một con trỏ chứa địa chị của toán hạng, cái mà có kiểu dữ liệu được suy ra từ đối số (ví dụ: Lấy địa chỉ của một kiểu int, thì sẽ trả về địa chỉ trong một con trỏ kiểu int).

Chúng ta có thể thấy điều này trong đoạn code sau:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <typeinfo>
 
int main()
{
	int x(4);
	std::cout << typeid(&x).name();
 
	return 0;
}

Trên Visual Studio 2013, đoạn code trên in ra:

int *

(Với gcc, kết quả in ra sẽ là “pi” – viết tắt của pointer to int)

Con trỏ này sau đó có thể được in ra hoặc gán theo mong muốn.

3.3 Dereferencing pointers

Khi đã có được một biến con trỏ đang trỏ tới cái gì đó, việc tiếp theo cần phải làm với con trỏ này là dereference nó để lấy được giá trị của cái mà nó đang trỏ tới. Một dereferenced pointer sẽ cho biết với phần nội dung của địa chỉ mà nó đang trỏ tới. 

int value = 5;
std::cout << &value; // prints address of value
std::cout << value; // prints contents of value
 
int *ptr = &value; // ptr points to value
std::cout << ptr; // prints address held in ptr, which is &value
std::cout << *ptr; // dereference ptr (get the value that ptr is pointing to)

Chương trình trên sẽ in ra:

0012FF7C
5
0012FF7C
5

Đây là lý do tại sao mà con trỏ phải có một kiểu dữ liệu. Nếu không có kiểu dữ liệu cụ thể, một con trỏ sẽ không biết cách diễn giải phần nội dung mà nó đã trỏ đến, khi nó được dereferenced. Đó cũng là lý do vì sao con trỏ và địa chỉ biến mà nó được gán phải có cùng kiểu dữ liệu. Nếu không làm vậy, khi con trỏ được dereferenced, nó sẽ diễn giải sai các bits thành một kiểu dữ liệu khác.

Sau khi đã được gán, giá trị của một con trỏ có thể được gán lại thành một giá trị khác:

int value1 = 5;
int value2 = 7;
 
int *ptr;
 
ptr = &value1; // ptr points to value1
std::cout << *ptr; // prints 5
 
ptr = &value2; // ptr now points to value2
std::cout << *ptr; // prints 7

Khi địa chỉ của biến value được gán cho ptr, những điều sau là đúng:

  • ptr&value là một
  • *ptr được coi là value

Bởi vì *ptr được coi là value, bạn có thể gán các giá trị cho nó, giống như khi gán giá trị cho biến value! Đoạn chương trình dưới đây in ra 7:

int value = 5;
int *ptr = &value; // ptr points to value
 
*ptr = 7; // *ptr is the same as value, which is assigned 7
std::cout << value; // prints 7

3.4 Cảnh báo về việc dereferencing con trỏ không hợp lệ

Con trỏ trong C++ vốn không an toàn và việc sử dụng con trỏ không đúng cách là một trong những nguyên nhân phổ biến làm cho ứng dụng của bạn bị crash.

Khi một con trỏ được dereferenced, ứng dụng sẽ cố gắng đi đến vị trí bộ nhớ được lưu trong con trỏ và truy xuất phần nội dung của vị trí bộ nhớ này. Vì lý do bảo mật, các hệ điều hành hiện đại thường chạy các ứng dụng bên trong một môi trường sandbox để ngăn chúng tương tác sai với các ứng dụng khác và để bảo vệ sự ổn định của chính hệ điều hành. Nếu một ứng dụng cố gắng truy cập vào một vị trí bộ nhớ mà hệ điều hành không cấp phát cho nó, hệ điều hành có thể tắt luôn ứng dụng này đi. 

Đoạn chương trình dưới đây sẽ minh họa điều này, và có thể bị crash khi bạn chạy nó (đừng lo, hãy thử chạy nó, bạn sẽ không làm hỏng máy tính của mình đâu ^^):

#include <iostream>
 
void foo(int *&p)
{
    // p is a reference to a pointer.  We'll cover references (and references to pointers) later in this chapter.
    // We're using this to trick the compiler into thinking p could be modified, so it won't complain about p being uninitialized.
    // This isn't something you'll ever want to do intentionally.
}
 
int main()
{
    int *p; // Create an uninitialized pointer (that points to garbage)
    foo(p); // Trick compiler into thinking we're going to assign this a valid value
	    
    std::cout << *p; // Dereference the garbage pointer
 
    return 0;
}

3.5 Kích thước của con trỏ

Kích thước của một con trỏ phụ thuộc vào kiến trúc mà file thực thi được biên dịch. Một file thực thi 32-bit sử dụng các địa chỉ bộ nhớ 32-bit – do đó, một con trỏ trên máy tính 32 bits có kích thước là 32 bits (4 bytes). Đối với file thực thi 64-bit, một con trỏ sẽ có kích thước là 64 bits (8 bytes). Lưu ý rằng, các quy tắc về kích thước con trỏ ở trên luôn đúng, không quan trọng con trỏ đang trỏ tới cái gì:

char *chPtr; // chars are 1 byte
int *iPtr; // ints are usually 4 bytes
struct Something
{
    int nX, nY, nZ;
};
Something *somethingPtr; // Something is probably 12 bytes
 
std::cout << sizeof(chPtr) << '\n'; // prints 4
std::cout << sizeof(iPtr) << '\n'; // prints 4
std::cout << sizeof(somethingPtr) << '\n'; // prints 4

Như bạn có thể thấy, kích thước của con trỏ luôn luôn giống nhau. Điều này là do một con trỏ vốn chỉ là một địa chỉ bộ nhớ, và số bits cần thiết để truy cập tới một địa chỉ bộ nhớ trên một máy tính cụ thể nào đó, luôn luôn không đổi. 

3.6 Ưu điểm ủa con trỏ

Tại thời điểm này, con trỏ trông có vẻ hơi ngớ ngẩn, mang tính hàn lâm, và khó hiểu. Tại sao chúng ta lại phải sử dụng con trỏ, trong khi chúng ta có thể chỉ cần dùng các biến thông thường?

Trên thực tế, con trỏ rất hữu ích trong nhiều trường hợp khác nhau:

  • Các mảng đều được triển khai bằng cách sử dụng con trỏ. Con trỏ có thể được sử dụng để duyệt/lặp qua một mảng (như một cách thứ hai bên cạnh cách sử dụng chỉ số mảng)
  • Chúng là cách duy nhất bạn có thể sử dụng để cấp phát bộ nhớ động trong C++. Đây là trường hợp sử dụng phổ biến nhất của con trỏ.
  • Chúng có thể được sử dụng để truyền một lượng lớn dữ liệu cho một hàm, mà không cần phải sao chép dữ liệu (là một cách không hiệu quả).
  • Chúng có thể được sử dụng để truyền vào một hàm như là một tham số cho một hàm khác.
  • Chúng có thể được sử dụng để đạt được tính đa hình khi xử lý vấn đề kế thừa trong lập trình hướng đối tưởng.
  • Chúng có thể được sử dụng để có được một struct/class trỏ đến một struct/class khác, nhằm tạo thành một chuỗi liên kết. Điều này rất hữu ích trong một số cấu trúc dữ liệu nâng cao hơn, chẳng hạn như linked list và trees.

Vì vậy, thực sự có một số lượng đáng ngạc nhiên các cách sử dụng con trỏ. Nhưng đừng lo lắng nếu bạn cảm thấy chưa hiểu phần lớn mảng kiến thức này. Bây giờ bạn đã hiểu con trỏ ở mức cơ bản là gì, chúng ta có thể bắt đầu xem xét sâu hơn về các trường hợp khác nhau trong đó việc sử dụng con trỏ là hữu ích, trong các bài học tiếp theo.

Bài tập C về Con trỏ

Bài tập C++ về Con trỏ

4. Tổng kết

Con trỏ là các biến chứa một địa chỉ bộ nhớ. Chúng có thể được dereferenced bằng cách sử dụng toán tử dereference (*) để lấy giá trị tại địa chỉ chúng đang giữ. Việc dereferencing một con trỏ rác có thể làm ứng dụng của bạn bị crash.

Khuyên bạn: Khi khai báo một biến con trỏ, hãy đặt dấu hoa thị bên cạnh tên biến.

Khuyên bạn: Khi khai báo một hàm, hãy đặt dấu hoa thị bên cạnh kiểu dữ liệu trả về của hàm.

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!