Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Như các bạn đã biết từ những bài học đầu tiên về ngôn ngữ lập trình, khi ta muốn sử dụng một biến với kiểu dữ liệu nguyên thủy, thì biến đó cần được khai báo. Sau khi khai báo một biến, thì hệ điều hành sẽ tìm đến một vùng nhớ trống trên các thiết bị lưu trữ tạm thời của máy tính (RAM, hoặc ngăn xếp hay các vùng lưu trữ khác,...); nếu như tìm được một vùng nhớ có đủ khoảng trống cho kích thước của biến đó thì biến sẽ nắm giữ vùng nhớ vừa tìm được.

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Minh họa một biến varvar chiếm vùng nhớ 4 bytes trên RAM

Tuy nhiên, sau khi một vùng nhớ đã được cấp phát cho một biến, thì làm sao để chương trình dịch biết được chính xác vị trí của biến đó trên bộ nhớ để thực hiện các lệnh với biến? Rất đơn giản, mỗi biến sau khi được khai báo sẽ có một địa chỉ vùng nhớ trên thiết bị lưu trữ mà biến đó đang được lưu.

2. Địa chỉ của biến

Các thiết bị nhớ cung cấp bộ nhớ tạm thời (là bộ nhớ được sử dụng trong quá trình máy tính làm việc để lưu trữ dữ liệu) đều được tạo nên bởi các ô nhớ liên tiếp nhau, mỗi ô nhớ tương ứng với một byte và đều có một số thứ tự đại diện cho vị trí của ô nhớ đó trong thiết bị. Số thứ tự đó được gọi là địa chỉ của ô nhớ.

Các địa chỉ của ô nhớ là những con số ảo được tạo ra bởi hệ điều hành, mà con người chúng ta rất khó đọc. Hãy cứ tưởng tượng các ô nhớ được đánh số từ 0,0, và địa chỉ cuối cùng được đánh số tương đương với số ô nhớ của thiết bị đó.

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

3. Lấy địa chỉ của một biến trong C++

Giả sử ta khai báo một biến xx với kiểu dữ liệu bất kỳ trong các kiểu dữ liệu nguyên thủy. Muốn lấy ra địa chỉ của biến xx này, các bạn chỉ cần thêm toán tử

int x = 10;
int & x_reference = x;

6 phía trước nó.


# include 
using namespace std;
main()
{
    int x;
    cout << &x;
}

Thử chạy chương trình này, ta thu được kết quả là một dãy địa chỉ của biến xx đã khai báo:

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

4. Tham chiếu (Reference)

Chúng ta đã nói tới khái niệm này khi học về Hàm trong C++. Tuy nhiên, trong bài này tôi sẽ nói kĩ hơn về tham chiếu.

Một tham chiếu cũng là một kiểu dữ liệu cơ bản, nó giống như các bạn tạo ra một tên khác (tên giả) cho một biến đã có.

Để tạo ra một tham chiếu, các bạn thêm toán tử

int x = 10;
int & x_reference = x;

6 giữa kiểu dữ liệu và tên biến trong lời khai báo biến. Ngoài ra, biến tham chiếu bắt buộc phải được khởi tạo bằng với một biến đã có sẵn.

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

Lấy ví dụ:

int x = 10;
int & x_reference = x;

Cùng in ra giá trị của hai biến này với đoạn lệnh dưới đây:


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

Ta thấy kết quả như sau:

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

Như các bạn thấy, giá trị của hai biến này giống nhau. Vậy phải chăng biến tham chiếu là một bản sao của biến gốc? Hoàn toàn không phải! Hãy cùng in ra thêm địa chỉ của hai biến:


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference << endl;
    cout << "Địa chỉ của x: " << &x << endl;
    cout << "Địa chỉ của tham chiếu tới x: " << &x_reference;
}

Ta có kết quả:

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10
Địa chỉ của x: 0x104dfee8
Địa chỉ của tham chiếu tới x: 0x104dfee8

Có thể thấy, hai biến có chung địa chỉ. Về bản chất, toán tử

int x = 10;
int & x_reference = x;

6 không có nghĩa là "địa chỉ của", mà nó có nghĩa là "tham chiếu tới". Khi thực hiện tham chiếu từ biến x_reference\text{x\_reference} tới biến x,x, thì biến x_reference\text{x\_reference} sẽ cùng kiểm soát vùng nhớ có địa chỉ là địa chỉ của biến xx.

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Nói cách khác, hai biến này là hai tên khác nhau nhưng cùng kiểm soát một địa chỉ vùng nhớ. Điều này đồng nghĩa với việc, khi các bạn thay đổi giá trị của biến x_reference,\text{x\_reference}, thì giá trị của biến xx cũng sẽ thay đổi theo và ngược lại. Đó chính là cơ chế của việc truyền tham chiếu trong hàm ở C++.

Lưu ý:

  • Một biến tham chiếu chỉ được phép tham chiếu tới một biến cùng kiểu, và khi đã tham chiếu rồi thì không thể tham chiếu tới một biến khác.
  • Không thể khai báo một biến tham chiếu tới một hằng số, vì hằng số không thể thay đổi mà biến tham chiếu thì có, do vậy sẽ gây xung đột.

II. Con trỏ trong C++

1. Khái niệm con trỏ (pointer)

Một con trỏ (a pointer) là một biến được dùng để lưu trữ địa chỉ của biến khác.

Khác với tham chiếu, con trỏ là một biến có địa chỉ độc lập, nhưng giá trị trong vùng nhớ của con trỏ lại chính là địa chỉ của biến mà nó trỏ tới (hoặc một địa chỉ ảo).

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Trong ví dụ trên, ta có một biến con trỏ được cấp phát vùng nhớ tại địa chỉ 3255,3255, và nó trỏ đến vùng nhớ 1224,1224, nghĩa là giá trị của nó là 12241224 (tất nhiên chỉ là do người viết minh họa một cách dễ hiểu, còn thực tế các địa chỉ phức tạp hơn nhiều).

2. Khai báo con trỏ

Để khai báo một con trỏ, ta sử dụng thêm toán tử

int x = 10;
int & x_reference = x;

9 trong lời khai báo (không cần thiết phải đặt sát cạnh tên biến)

// Cách 1.
{Kiểu_dữ_liệu} *{Tên_con_trỏ};
// Cách 2.
{Kiểu_dữ_liệu}* {Tên_con_trỏ};

Tuy nhiên các bạn nên dùng cách thứ 22 để phân biệt hẳn với việc lấy giá trị của một biến lặp trong C++ (phần này sẽ được đề cập ở trong bài về thư viện STL C++).

Khi khai báo một biến con trỏ, thì biến đó chỉ được phép trỏ vào địa chỉ của các biến có cùng kiểu đã khai báo.

Chẳng hạn, tôi sẽ khai báo một con trỏ kiểu


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

0 , thì biến con trỏ này chỉ được phép trỏ vào các địa chỉ của biến kiểu


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

0:

int* ptr;

Lưu ý: Khi khai báo một con trỏ mà chưa khởi tạo địa chỉ trỏ đến cho nó, thì việc in ra giá trị của con trỏ có thể gây ra lỗi và chương trình sẽ bị đóng luôn. Nguyên nhân là do khi chưa khởi tạo, thì con trỏ sẽ nắm giữ một giá trị rác nào đó, có thể là một địa chỉ vượt quá giới hạn của bộ nhớ ảo.

Để khắc phục, khi khởi tạo một con trỏ mà chưa sử dụng đến ngay, các bạn nên gán cho nó một giá trị là


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 hoặc


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

3 (chuẩn C++ 11). Đây là các macro được định nghĩa sẵn trong C++, khi gán một con trỏ bằng


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 hoặc


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

3 nghĩa là con trỏ đó chưa trỏ đến giá trị nào cả. Nó được định danh sẵn trong C++:


# define NULL 0

Ví dụ:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

0

Lúc này, đoạn chương trình sẽ chạy bình thường, và kết quả in ra là:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

1

3. Các phép toán cơ bản với con trỏ

Gán giá trị cho con trỏ

Ta chỉ được phép gán giá trị của con trỏ bằng với địa chỉ của một biến khác (hoặc một con trỏ khác) cùng kiểu dữ liệu với nó. Tức là chỉ có giá trị kiểu con trỏ (có được nhờ toán tử

int x = 10;
int & x_reference = x;

6, hoặc từ một biến con trỏ cùng kiểu khác) mới có thể gán được cho biến con trỏ.

Muốn gán địa chỉ của biến thông thường cho con trỏ, trước hết cần sử dụng toán tử

int x = 10;
int & x_reference = x;

6 để lấy ra địa chỉ ảo của biến, sau đó mới gán địa chỉ đó cho con trỏ được. Còn nếu như gán một con trỏ khác cho con trỏ thì chỉ cần chúng cùng kiểu là được.

Lấy ví dụ:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

2

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Khác với tham chiếu, một con trỏ sau khi được khai báo, hoàn toàn có thể trỏ đến địa chỉ của nhiều biến khác nhau sau khi được gán giá trị. Còn tham chiếu không thể thay đổi địa chỉ sau lần tham chiếu đầu tiên.

Ví dụ dưới đây sẽ minh họa điều đó:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

3

Kết quả của đoạn chương trình trên là:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

4

Ta thấy biến con trỏ đã lần lượt trỏ vào địa chỉ của 55 phần tử trên mảng a,a, chính là 55 địa chỉ liên tiếp nhau trên bộ nhớ ảo.

Truy xuất giá trị ở vùng nhớ mà con trỏ trỏ đến

Khi đã có một con trỏ trỏ đến địa chỉ nào đó trong thiết bị nhớ, muốn đưa ra giá trị của vùng nhớ mà con trỏ đang trỏ tới, các bạn sử dụng toán tử

int x = 10;
int & x_reference = x;

9 ở phía trước biến con trỏ.

Ví dụ:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

5

Kết quả đoạn chương trinh trên là:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

6

Và tất nhiên, theo cách này chúng ta cũng có thể thay đổi được giá trị của vùng nhớ mà con trỏ đang trỏ đến, bằng cách gán trực tiếp giá trị đó:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

7

Đoạn code trên sẽ cho kết quả là


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

9, bởi vì biến value\text{value} đã bị thay đổi giá trị thành 1010 với câu lệnh

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

0.

Tăng và giảm con trỏ

Giống như các biến thông thường, các con trỏ cũng có thể sử dụng những toán tử tăng giảm, chúng bao gồm:

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

1,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

2,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

3,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

4,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

5,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

6. Tuy nhiên, tác động của các toán tử này lên con trỏ sẽ có đôi chút khác biệt.

Trước hết, ta khai báo một biến con trỏ kiểu


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

0 và xem kết quả chạy chương trình dưới đây:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

8

Kết quả đoạn chương trình trên như sau:

{Kiểu_dữ_liệu} & {Tên_biến_tham_chiếu} = {Tên_biến_có_sẵn}

9

Ta thấy hai địa chỉ này khác nhau, tất nhiên. Nhưng khác nhau như thế nào? Cần biết rằng, các địa chỉ trong bộ nhớ ảo được biểu diễn bằng số hệ thập lục phân (cơ số 1616). Kí hiệu

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

8 ở đầu địa chỉ thể hiện số đứng phía sau là thập lục phân. Quy đổi hai giá trị

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

9 và


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference << endl;
    cout << "Địa chỉ của x: " << &x << endl;
    cout << "Địa chỉ của tham chiếu tới x: " << &x_reference;
}

0 ra hệ thập phân, ta được hai giá trị:

  • Trước khi tăng: 273546984273 546 984 (bytes).
  • Sau khi tăng: 273546988273 546 988 (bytes).

Hai giá trị này chênh nhau đúng 44 đơn vị, vừa bằng kích thước của kiểu dữ liệu


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

0 là 44 byte. Như vậy, toán tử

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

1 sẽ làm con trỏ trỏ đến địa chỉ tiếp theo trên bộ nhớ ảo, với khoảng cách đúng bằng kích thước của kiểu dữ liệu đã khai báo cho nó.

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Tương tự như trên, các bạn cũng có thể mường tượng ra cách hoạt động của các toán tử

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

2,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

3,

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

4 đối với con trỏ. Còn

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

5 và

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

6 chỉ là cách viết ngắn gọn của phép gán

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

3 và

Giá trị của x: 10
Giá trị của tham chiếu tới x: 10

4 mà thôi.

Đoạn code dưới đây sẽ minh họa hết tác dụng của những toán tử tăng giảm còn lại:

int x = 10;
int & x_reference = x;

0

Kết quả chạy chương trình:

int x = 10;
int & x_reference = x;

1

Quy đổi các địa chỉ trên từ hệ thập lục phân sang hệ thập phân, các bạn sẽ thấy chênh lệch của chúng đúng bằng độ tăng giảm tương ứng.

4. Con trỏ


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2

Con trỏ trong ngôn ngữ C/C++ vốn không an toàn. Nếu sử dụng con trỏ không hợp lý có thể gây lỗi chương trình.

Khác với tham chiếu, biến con trỏ có thể không cần khởi tạo giá trị ngay khi khai báo. Nhưng thực hiện truy xuất giá trị của con trỏ bằng toán tử

int x = 10;
int & x_reference = x;

9 khi chưa gán địa chỉ cụ thể cho con trỏ, chương trình có thể bị đóng bởi hệ điều hành. Nguyên nhân là do con trỏ đang nắm giữ một giá trị rác, giá trị rác đó có thể là địa chỉ thuộc một vùng nhớ đang được ứng dụng khác sử dụng, hoặc giá trị vượt quá giới hạn của bộ nhớ ảo.

Lấy ví dụ, trong Visual Studio 2015, nếu như xảy ra trường hợp nói trên thì khi chạy thử chương trình, nó sẽ bị cảnh báo và ngăn chặn thực thi:

int x = 10;
int & x_reference = x;

2

Khi nhấn F5 để chạy thử chương trình này, nó sẽ báo lỗi như sau:

Hướng dẫn gọi struct theo kểu con trỏ năm 2024

Chính vì thế, khi khai báo một con trỏ mà chưa có địa chỉ trỏ đến cụ thể, chúng ta nên gán cho nó giá trị


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2.


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 là một macro đã được định nghĩa sẵn trong ngôn ngữ C/C++.


# define NULL 0

Tuy nhiên, đối với con trỏ,


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 là một giá trị đặc biệt, khi gán


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 cho con trỏ, điều đó có nghĩa là con trỏ đó chưa trỏ đến địa chỉ nào cả. Con trỏ đang giữ giá trị


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 được gọi là con trỏ


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2 (NULL pointer). Trong C++11 trở lên, các bạn có thể sử dụng thêm từ khóa


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

3 cũng có ý nghĩa giống như


# include 
using namespace std;
main()
{
    int x = 10;
    int & x_reference = x;
    cout << "Giá trị của x: " << x << endl;
    cout << "Giá trị của tham chiếu tới x: " << x_reference;
}

2.

int x = 10;
int & x_reference = x;

4

Kết quả chạy đoạn chương trình trên là:

int x = 10;
int & x_reference = x;

5

Trong bài tiếp theo về con trỏ, chúng ta sẽ cùng đến với ứng dụng của con trỏ đối với một số trường hợp nâng cao hơn, chẳng hạn như con trỏ đối với mảng hay đối với hàm.