Menu

Con trỏ trong C++: toán tử &, * và nullptr

Con trỏ C++ giải thích từ đầu: khai báo con trỏ, toán tử lấy địa chỉ & và giải tham chiếu *, nullptr, con trỏ tới mảng, cùng những cái bẫy con trỏ treo và con trỏ chưa khởi tạo gây crash chương trình.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Một biến chứa địa chỉ

Mọi biến đều nằm ở đâu đó trong bộ nhớ, tại một vị trí được đánh số gọi là địa chỉ. Phần lớn thời gian bạn không cần biết nó ở đâu, chỉ cần dùng tên biến. Con trỏ (pointer) thì ngược lại: đó là một biến mà giá trị của nó chính là một địa chỉ. Thay vì chứa 42, nó chứa "nơi đang lưu 42".

Chính cơ chế gián tiếp này làm nên sức mạnh của con trỏ. Hàm có thể thay đổi biến của nơi gọi thông qua con trỏ, các cấu trúc dữ liệu như danh sách liên kết dùng con trỏ để nối các node, và (như bạn sẽ thấy ở phần bộ nhớ động) con trỏ là cách để truy cập vùng nhớ bạn cấp phát lúc chạy.

Dấu & trong &score là toán tử lấy địa chỉ: nó cho ra vị trí của score. Dấu * trong *p là toán tử giải tham chiếu (dereference): nó đi theo địa chỉ để quay về giá trị đang nằm ở đó.

Hai toán tử: & và *

Điều gây bối rối nhất cho người mới là * mang hai nghĩa khác nhau tùy vị trí của nó. Hãy phân biệt rõ:

int* p;     // DECLARATION: "p is a pointer to int"
p = &x;     // & = address-of: store the address of x in p
int y = *p; // * = dereference: read the value p points at
*p = 99;    // dereference on the left: write through the pointer

Trong khai báo, * là một phần của kiểu. Trong biểu thức, * thực hiện thao tác. Khi con trỏ đã được thiết lập, giải tham chiếu cho bạn toàn quyền đọc và ghi vào biến gốc:

Để ý rằng sau dòng 1 bạn không hề đụng đến health qua tên của nó, vậy mà giá trị của nó vẫn liên tục thay đổi. Đó chính là mục đích: hp là một tên khác trỏ tới cùng vùng nhớ. Cách đặt dấu cách (int* p, int *p, int*p) chỉ là hình thức, với trình biên dịch thì như nhau; tài liệu này dùng int* p.

nullptr: không trỏ tới đâu cả

Một con trỏ không trỏ tới đâu nên được gán nullptr (C++11). Đây là cách rõ ràng, an toàn về kiểu để nói "chưa có đích", và cho bạn thứ để kiểm tra trước khi giải tham chiếu.

Hãy dùng nullptr thay cho macro NULL cũ hay số 0 trần. Vì nullptr có kiểu con trỏ thật sự, nó không bao giờ bị hiểu nhầm thành số nguyên 0 khi chọn hàm nạp chồng, một bug khó thấy mà kiểu viết cũ có thể gây ra.

Cái bẫy: giải tham chiếu con trỏ null. Đọc hoặc ghi qua một con trỏ null (hoặc chưa khởi tạo) là hành vi không xác định, thường khiến chương trình crash ngay lập tức:

int* p = nullptr;
cout << *p;   // CRASH - dereferencing null is undefined behavior

Luôn kiểm tra bằng if (p) (hoặc if (p != nullptr)) trước khi giải tham chiếu bất cứ thứ gì có thể là null.

Con trỏ và mảng

Tên của một mảng tự chuyển thành con trỏ tới phần tử đầu tiên, nên con trỏ và mảng gắn bó rất chặt với nhau. Cộng 1 vào con trỏ không phải cộng thêm một byte mà là tiến thêm một phần tử, và đó là điều làm nên số học con trỏ:

p[i] và *(p + i) đúng nghĩa đen là cùng một biểu thức, và sự tương đương này là lý do mảng đánh chỉ số từ 0. Bug kinh điển ở đây là đi quá cuối mảng: nums + 4 là mốc hợp lệ ngay sau phần tử cuối để so sánh, nhưng giải tham chiếu *(nums + 4) là đọc ra ngoài phạm vi. Lỗi lệch một đơn vị với con trỏ là nguyên nhân hàng đầu gây crash và hỏng dữ liệu âm thầm, nên hãy cân nhắc kỹ điều kiện dừng.

const và con trỏ

const có thể áp dụng cho thứ mà con trỏ trỏ tới, cho chính con trỏ, hoặc cả hai. Đọc khai báo từ phải sang trái để hiểu:

const int* p;        // pointer to const int  - can't change *p, can repoint p
int* const p = &x;   // const pointer to int  - can change *p, can't repoint p
const int* const p = &x; // both locked

Điều này xuất hiện liên tục trong code thực tế. Một hàm cam kết không sửa dữ liệu của bạn sẽ nhận một con trỏ tới const:

Đánh dấu đối tượng được trỏ tới là const vừa thể hiện ý định, vừa để trình biên dịch chặn những lần ghi vô tình: an toàn miễn phí, không tốn chi phí lúc chạy.

Cái bẫy lớn: con trỏ treo

Một con trỏ treo (dangling pointer) trỏ tới vùng nhớ không còn chứa giá trị bạn mong đợi: biến đã ra khỏi phạm vi, hoặc vùng nhớ đã được giải phóng. Giải tham chiếu nó là hành vi không xác định, và điều đáng sợ là nó thường có vẻ vẫn chạy được cho đến khi hỏng.

int* makeBad() {
    int local = 5;
    return &local;   // BUG: local dies when the function returns
}                    // the returned pointer now dangles

Địa chỉ vẫn là một con số hợp lệ, nhưng nó trỏ tới một ô trên stack đã bị thu hồi: đọc nó sẽ ra giá trị rác hoặc crash. Điều tương tự xảy ra nếu bạn giữ con trỏ tới một đối tượng trên heap đã bị delete, hoặc tới một phần tử của vector mà sau đó cấp phát lại bộ nhớ.

Ba quy tắc giúp bạn an toàn:

  • Không bao giờ trả về địa chỉ của biến cục bộ. Hãy trả về theo giá trị, hoặc để nơi gọi sở hữu vùng nhớ.
  • Gán con trỏ về nullptr sau khi thứ nó trỏ tới không còn nữa, và kiểm tra trước khi dùng.
  • Với quyền sở hữu và vòng đời, hãy dùng smart pointer thay cho new/delete thô: chúng tự giải phóng bộ nhớ và loại bỏ phần lớn loại bug này.

Tiếp theo: tham chiếu và con trỏ

Con trỏ không phải cách duy nhất để gián tiếp trỏ tới một biến khác. C++ còn có tham chiếu, trông giống nhưng không thể null, không thể gắn lại sang biến khác và có cú pháp gọn hơn. Ở trang tiếp theo, chúng ta sẽ đặt chúng cạnh nhau trong bài tham chiếu và con trỏ để bạn biết chính xác nên dùng công cụ nào, và vì sao C++ hiện đại ưu tiên tham chiếu khi có thể.

Câu hỏi thường gặp

Con trỏ trong C++ là gì?

Con trỏ là một biến lưu địa chỉ bộ nhớ của một giá trị khác thay vì lưu chính giá trị đó. Bạn khai báo con trỏ bằng * (ví dụ int* p), lấy địa chỉ bằng toán tử & (p = &x), và đọc hoặc ghi giá trị mà nó trỏ tới bằng cách giải tham chiếu với *p.

& và * trong con trỏ C++ khác nhau thế nào?

Khi làm việc với con trỏ, & là toán tử lấy địa chỉ: &x cho bạn địa chỉ của x. * làm hai việc: trong khai báo (int* p) nó đánh dấu biến là con trỏ, còn trong biểu thức (*p) nó giải tham chiếu con trỏ để lấy giá trị được lưu tại địa chỉ đó.

nullptr trong C++ là gì và tại sao nên dùng thay cho NULL?

nullptr là literal con trỏ null an toàn về kiểu, có từ C++11. Nó có nghĩa là "không trỏ tới đâu cả". Nên dùng nó thay cho NULL hay 0 kiểu cũ vì nullptr có kiểu con trỏ thật sự, nên không bao giờ bị nhầm với số nguyên khi chọn hàm nạp chồng. Luôn kiểm tra if (p) trước khi giải tham chiếu: giải tham chiếu con trỏ null là hành vi không xác định.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu