Xem thêm

Tự học C++: Khám phá về con trỏ trong C++

Huy Erick
Trong bài viết này, chúng ta sẽ đi sâu vào kiến thức về con trỏ trong ngôn ngữ lập trình C++. Con trỏ là một khái niệm quan trọng và mạnh mẽ trong C++, đồng...

Trong bài viết này, chúng ta sẽ đi sâu vào kiến thức về con trỏ trong ngôn ngữ lập trình C++. Con trỏ là một khái niệm quan trọng và mạnh mẽ trong C++, đồng thời gây khó khăn cho rất nhiều người mới học ngôn ngữ này. Tuy nhiên, khi hiểu rõ về con trỏ, bạn sẽ nhận thấy chúng thực sự hữu ích và có thể giúp bạn xử lý các tình huống phức tạp một cách dễ dàng và hiệu quả hơn.

Định nghĩa biến và địa chỉ bộ nhớ

Trong ngôn ngữ C++, một biến là một 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ụ, khi ta khai báo biến x, một phần bộ nhớ từ RAM sẽ được sử dụng để cấp phát cho biến x. Để dễ hiểu, 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 quan tâm đến đị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 viết 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'; // in ra giá trị của biến x
  std::cout << &x << '\n'; // in ra địa chỉ bộ nhớ của biến x
  return 0;
}

Khi chạy đoạn code trên, chúng ta sẽ nhận được kết quả:

5
0x7ffe87682a98

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

#include <iostream>
int main() {
  int x = 5;
  std::cout << x << '\n'; // in ra giá trị của biến x
  std::cout << &x << '\n'; // in ra địa chỉ bộ nhớ của biến x
  std::cout << *(&x) << '\n'; // in ra giá trị tại địa chỉ bộ nhớ của biến x
  return 0;
}

Khi chạy đoạn code trên, chúng ta sẽ nhận được kết quả:

5
0x7ffe87682a98
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 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à toán tử dereference, giờ đây chúng ta có thể nói về con trỏ. Một con trỏ là một biến chứa một địa chỉ bộ nhớ làm giá trị của nó. Con trỏ thường được xem là một trong những khái niệm phức tạp nhất trong ngôn ngữ C++, nhưng khi biết cách sử dụng chúng, bạn sẽ thấy chúng hết sức đơn giản và hữu ích.

3.0 Khai báo một con trỏ

Các biến con trỏ đượ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 sao (*) 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 sao 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; // con trỏ kiểu int
double* dPtr; // con trỏ kiểu double
int* iPtr2; // cú pháp hợp lệ, nhưng không được ưa chuộng
int *iPtr3; // cú pháp hợp lệ, nhưng không nên sử dụng
int *iPtr4, *iPtr5; // khai báo hai con trỏ tới biến kiểu int

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

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

int* iPtr6, iPtr7; // iPtr6 là con trỏ kiểu int, nhưng iPtr7 chỉ là một biến kiểu int đơn thuần

Vì lý do này, khi khai báo một biến, chúng ta khuyên bạn nên đặt dấu sao 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 sao 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 sao 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 sao ở bên cạnh kiểu dữ liệu trả về của hàm.

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ớ, 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ử địa chỉ-của (&):

int v = 5;
int* ptr = &v; // gán cho ptr địa chỉ của biến v

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

Pointer illustration

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 v, vì vậy chúng ta nói rằng ptr "đang trỏ tới" v.

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

int v = 5;
int* ptr = &v; // gán cho ptr địa chỉ của biến v
std::cout << &v << '\n'; // in ra địa chỉ của biến v
std::cout << ptr << '\n'; // in ra địa chỉ mà ptr đang giữ

Khi chạy đoạn code trên, chúng ta sẽ nhận được kết quả:

0x7ffe87682a98
0x7ffe87682a98

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

int iValue = 5;
double dValue = 7.0;
int* iPtr = &iValue; // hợp lệ
double* dPtr = &dValue; // hợp lệ
iPtr = &dValue; // không hợp lệ - con trỏ kiểu int không thể trỏ đến địa chỉ của biến kiểu double
dPtr = &iValue; // không hợp lệ - con trỏ kiểu double không thể trỏ đến địa chỉ của biến kiểu int

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

int* ptr = 5;

Điều này là 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ẽ báo lỗi vì không thể chuyển đổi một số nguyên thành một con trỏ kiểu nguyên.

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

double* dPtr = 0x7ffe87682a98; // không hợp lệ, được coi là gán một literal số nguyên

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 chuỗi. Thay vào đó, nó trả về một con trỏ chứa địa chỉ của toán hạng, với kiểu dữ liệu được suy ra từ đối số.

Chẳng hạn, trong đoạn code sau:

int x(4);
std::cout << typeid(&x).name();

Trên Visual Studio 2013, đoạn code trên sẽ 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 giá trị theo mong muốn.

3.3 Dereferencing pointers

Khi đã 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; // in ra địa chỉ của biến value
std::cout << value; // in ra giá trị của biến value
int* ptr = &value; // ptr trỏ tới biến value
std::cout << ptr; // in ra địa chỉ mà ptr đang giữ
std::cout << *ptr; // dereference ptr (lấy giá trị mà ptr đang trỏ tới)

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

0x7ffe87682a98
5
0x7ffe87682a98
5

Đây là lý do tại sao 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 trỏ tới value1
std::cout << *ptr; // in ra 5
ptr = &value2; // ptr trỏ tới value2
std::cout << *ptr; // in ra 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 trỏ tới value
*ptr = 7; // *ptr tương đương value, gán giá trị 7 cho value
std::cout << value; // in ra 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 truy cập vào 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 minh họa điều này, và có thể bị crash khi bạn chạy nó:

#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; // khởi tạo một con trỏ chưa được gán giá trị (con trỏ rác)
  foo(p); // Đánh lừa trình biên dịch rằng chúng ta sẽ gán giá trị hợp lệ cho p
  std::cout << *p; // Dereference con trỏ rác
  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à chương trình được biên dịch. Một chương trình 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 chương trình 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ỏ luôn đúng, không quan trọng con trỏ đang trỏ tới cái gì.

char* chPtr; // kiểu char có kích thước 1 byte
int* iPtr; // kiểu int thường là 4 byte
struct Something {
  int x, y, z;
};
Something* somethingPtr; // kiểu Something có thể có kích thước 12 byte
std::cout << sizeof(chPtr) << '\n'; // in ra 4
std::cout << sizeof(iPtr) << '\n'; // in ra 4
std::cout << sizeof(somethingPtr) << '\n'; // in ra 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ỏ chỉ là một địa chỉ bộ nhớ, và số bits cần thiết để truy cập 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 củ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 trong C++ đượ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).
  • Con trỏ là cách duy nhất để 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ỏ.
  • Con trỏ 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ả).
  • Con trỏ 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.
  • Con trỏ 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.
  • Con trỏ có thể được sử dụng để đạt đượ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ư danh sách liên kết và cây.

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 khám phá sâu hơn về các trường hợp sử dụng con trỏ trong các bài viết tiếp theo.

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ỉ mà 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 sao 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 sao bên cạnh kiểu dữ liệu trả về của hàm.

1