Con Trỏ trong C++ – Pointer — Modern C++

Con trỏ hay pointer là gì?

Con trỏ – pointer là một loại biến chuyên lưu trữ địa chỉ bộ nhớ (memory address).

Một biến lưu trữ giá trị như:

int length = 

5;


char domain[] = "STDIO.VN";

… sẽ được lưu trữ tại 1 vị trí cụ thể trên bộ nhớ. Biến con trỏ có khả năng lưu trữ vị trí này.

Minh hoạ con trỏ trong C++

Trong hình minh hoạ trên, biến con trỏ (có địa chỉ là FF) sẽ quản lý vùng nhớ của chuỗi STDIO.VN (bắt đầu từ địa chỉ D0).

Cách sử dụng

Cú pháp:

dataType * variableName;

Ví dụ

int * p;

Giống như biến thông thường, con trỏ cũng có kiểu dữ liệu và cần được khai báo trước khi sử dụng. Kiểu dữ liệu có thể là:

  • Được định nghĩa sẵn (built-in data type):

    int, float, char, …

  • Cấu trúc do người dùng định nghĩa (user-define data type):

    struct, union.

  • Lớp do người dùng định nghĩa.

Chương trình sau minh họa việc khai báo con trỏ đối với một số kiểu dữ liệu thường dùng:

int main()
{
	int *iPtr;	// Pointer to an integer
	float *fPtr;	// Pointer to a float
	char *cPtr;	// Pointer to a character
	void *vPtr;	// Pointer to an unidentified data type
	
	return 0;
}

Sử dụng * giữa kiểu dữ liệu và tên biến có nghĩa là biến đang khai báo là một biến con trỏ. Trong ngữ cảnh này * không phải là phép nhân.

Ngoài ra, không có sự khác biệt nếu * gần với kiểu dữ liệu hơn hay gần với tên biến hơn. Tuy nhiên, trong trường hợp khai báo nhiều hơn một biến trên cùng một dòng, chỉ những biến nào có kí tự * phía trước tên biến (không kể khoảng trắng) mới là biến con trỏ. Ví dụ:

int *iPtr, *pPtr, *arrPtr; // iPtr, pPtr, arrPtr are pointers
char* str, arr;            // arr is not a pointer
float * a, b, * c;         // b is not a pointer

Con trỏ chỉ lưu giữ địa chỉ nên khi gán giá trị cho một con trỏ, giá trị đó phải là địa chỉ. Để lấy địa chỉ của một biến, sử dụng address-of operator &.

int main()
{
	int a = 5;
	int *iPtr = &a;

	return 0;
}

Khi đó biến iPtr sẽ lưu trữ địa chỉ byte đầu tiên của biến a. Lưu ý toán tử & ở đây là unary operator – toán tử một ngôi, hoàn toàn khác với toán tử & hai ngôi (bitwise).

Một toán tử khác thường sử dụng với con trỏ là dereference operator *. Dereference operator dùng để lấy nội dung của địa chỉ mà biến đó trỏ vào.

#include <iostream>
using namespace std;

int main()
{
	int a = 5;

	int *iPtr = &a;
	cout << *iPtr << endl;

	void *vPtr = &a;
	cout << *(int*)vPtr << endl;
	
	return 0;
}

Ở chương trình trên, để lấy giá trị được lưu trữ trong biến a có hai cách: gọi trực tiếp biến a hoặc gọi gián tiếp qua con trỏ iPtr.

Khi đó, có thể thay đổi giá trị của biến a thông qua con trỏ ip như sau: *iPtr = 11. Con trỏ kiểu void có thể lưu địa chỉ của biến bất kì, nhưng không thể dereference trực tiếp, do đó cần định kiểu lại hoặc gán cho một con trỏ khác có kiểu dữ liệu cụ thể trước khi sử dụng.

Ngoài ra có thể gán trực tiếp một con trỏ cho một con trỏ khác. Tuy nhiên, khi đó hai con trỏ sẽ cùng lưu trữ địa chỉ giống nhau, dễ xảy ra tình trạng “dangling pointer” và gây ra lỗi rò rỉ bộ nhớ – memory leak trong trường hợp cấp phát động.

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

Những biến có kiểu dữ liệu khác nhau sẽ được cấp phát vùng nhớ có kích thước khác nhau.

Biến con trỏ dùng để lưu trữ địa chỉ của một biến khác hoặc vùng nhớ nói chung. Dù cho biến con trỏ được khai báo như thế nào đi nữa thì giá trị được lưu trữ trong nó cũng là một địa chỉ. Kích thước của nó là một hằng số, bất kể kiểu dữ liệu mà nó được khai báo. Hằng số này phụ thuộc vào OS, IDE, Compiler, … trên hệ điều hành 32-bit, hằng số này là 4 bytes.

Nếu kích thước của con trỏ là một hằng số, tại sao lại có nhiều kiểu biến con trỏ như vậy? Xét ví dụ:

#include <iostream>

using namespace std;

int main ()
{
	int a = 257;
	char *cPtr = (char*)&a;

	cout << *cPtr;

	return 0;
}

Kết quả in ra màn hình là ký tự ☺.

Giải thích:

Vùng nhớ của biến a có dạng:

0001 0001 0000 0000 0000 0000 0000 0000

* Cách thức lưu trữ của biến a bị ngược do lưu trữ kiểu Little Endian.

Do kiểu char có kích thước 1 byte, khi ép kiểu char *, biến cPtr chỉ nhận được dữ liệu trong byte đầu tiên (byte trái nhất) của biến a, dẫn đến việc đọc thiếu một phần dữ liệu có trong biến a. Kiểu dữ liệu sẽ quy định cho biến con trỏ đọc số byte tương ứng kể từ ô nhớ đầu tiên đó. Kết luận, kiểu dữ liệu của con trỏ sẽ quyết định khi lấy hoặc gán dữ liệu tương tác vào bao nhiêu byte tính từ địa chỉ con trỏ đang lưu giữ.

Các phép toán trên con trỏ

C++ cho phép thực hiện các phép lấy địa chỉ của phần tử sau + hoặc trước đó -, ngoài ra còn có ++, --.

Khi đó, giá trị địa chỉ lưu trữ trong con trỏ sẽ được tăng, giảm số byte tương ứng với kiểu dữ liệu của con trỏ. Thao tác tương tự như với dữ liệu kiểu số:

int main()
{
	int a = 5;
	int *iPtr = &a;    // iPtr == 100
	
	cout << iPtr + 1;
cout << iPtr + 2; cout << iPtr++; cout << ++iPtr; return 0; }

Giả sử a có địa chỉ là 100.

  • iPtr == 100.
  • iPtr + 1 do iPtr kiểu int * nên quản lý vùng nhớ kiểu int (4 byte), vậy phần tử kế tiếp phải là 104.
  • iPtr + 2 do iPtr tương tự, phần tử kế tiếp phải là 104 + 2 * 4112.
  • iPtr++ sẽ có kết quả là 116.
  • ++iPtr sẽ có kết quả là 120.

Nếu iPtr kiểu short * thì 1 “bước nhảy” sẽ là 2 byte.

Dấu + hoặc - trong phép toán với địa chỉ sẽ mang ý nghĩa là “các phần tử kế tiếp” hoặc “các phần tử trước đó” như cách hiểu trong mảng.

  • *(iPtr + 2) sẽ tương ứng iPtr[2].
  • *(iPtr + 3) sẽ tương ứng iPtr[3].

Một số ứng dụng của con trỏ

  • Quản lý dữ liệu trong vùng nhớ heap – heap segment thông qua cấp phát động.
  • Hỗ trợ hiện thực late binding và cho ra đời rất nhiều tính năng hữu ích như con trỏ hàm hay tính đa hình.