8.7 Con trỏ trỏ đến con trỏ

Chào các bạn đang theo dõi khóa học lập trình trực tuyến ngôn ngữ C++.

Trong bài học này, chúng ta sẽ cùng nhau tìm hiểu một khái niệm nâng cao của con trỏ: "Con trỏ trỏ đến con trỏ".

Pointer to pointer

Pointer to pointer là một loại con trỏ dùng để lưu trữ địa chỉ của biến con trỏ.

Mình lấy ví dụ về việc sử dụng con trỏ thông thường:

int value;
int *ptr = &value;

Chúng ta gán được địa chỉ của biến value cho con trỏ ptr vì biến value là biến kiểu int, và sử dụng toán tử address-of cho biến value sẽ trả về giá trị kiểu (int *) giống với kiểu dữ liệu của con trỏ ptr.

Như vậy, nếu chúng ta muốn Pointer to pointer trỏ đến được một pointer khác, trước hết chúng ta cần xem kiểu dữ liệu khi sử dụng toán tử address-of cho con trỏ sẽ trả về giá trị kiểu gì.

int *ptr = NULL;
cout <<	typeid(&ptr).name() << endl;

Kết quả:

Như chúng ta thấy, chúng ta cần khai báo biến có kiểu dữ liệu (int **) để có thể gán địa chỉ của con trỏ kiểu (int *) cho nó. Let's try:

int *ptr = NULL;
int **p_to_p = &ptr;

Con trỏ p_to_p được gọi là một Pointer to pointer.

Cũng tương tự như khi sử dụng con trỏ thông thường, chúng ta có thể sử dụng toán tử dereference cho một Pointer to pointer.

int main()	{

	int value = 100;
	int *ptr = &value;
	int **p_to_p = &ptr;

	cout << p_to_p << endl; //print address of ptr
	cout << *p_to_p << endl; //print address which hold by ptr
	cout << **p_to_p << endl; //print value at address which hold by ptr

	return 0;
}

Bản chất của Pointer to pointer vẫn là một pointer, nên khi truy xuất giá trị của p_to_p chúng ta lấy được địa chỉ mà nó trỏ đến (địa chỉ của biến ptr).

p_to_p; //là &ptr

Khi chúng ta sử dụng 1 toán tử dereference cho 1 pointer to pointer, cũng đồng nghĩa chúng ta đang truy xuất đến giá trị tại địa chỉ mà con trỏ ptr nắm giữ (địa chỉ đang được lưu trữ trong biến ptr).

*p_to_p; //là ptr

Và khi sử dụng 2 toán tử dereference cho 1 pointer to pointer, có thể viết lại như sau:

*(*p_to_p); //là *ptr

Chúng ta có thể thấy việc sử dụng pointer to pointer cũng tương tự như việc đi hỏi tìm một người bạn mà không biết nhà nó ở đâu, chỉ biết nhà của những người biết về nó. Vậy là chúng ta đi hỏi từng người một.

Ví dụ chúng ta là A, đang cần gặp C nhưng không biết nó ở đâu, chúng ta hỏi (sử dụng toán tử dereference) chú B thì chú B bảo đến địa chỉ mà C đang ở, chúng ta đến địa chỉ mà chú B nắm giữ và truy xuất vào đó là sẽ tìm được thằng C.

Tóm tắt lại ví dụ trên, chúng ta có thể viết:

A giữ địa chỉ nhà chú B => A = &B;
Chú B biết địa chỉ nhà thằng C => B = &C;

Như vậy:

(*A) tương đương (*(&B)) tương đương &C;
*(*A) tương đương *(*(&B)) tương đương C;

Áp dụng lại cho ví dụ:

int main()	{

	int value = 100;
	int *ptr = &value;
	int **p_to_p = &ptr;

	cout << p_to_p << endl; //print address of ptr
	cout << *p_to_p << endl; //print address which hold by ptr
	cout << **p_to_p << endl; //print value at address which hold by ptr

	return 0;
}

Chúng ta có thể viết:

p_to_p giữ địa chỉ của ptr => p_to_p = &ptr;
ptr giữ địa chỉ của value  => ptr = &value;

Như vậy:

(*p_to_p) tương đương ptr tương đương &value;
**p_to_p tương đương *ptr tương đương value;

Kết quả:

Chúng ta không thể gán trực tiếp như sau:

int **p_to_p = &&value; //not valid

Vì p_to_p là lvalue, &&value là rvalue. https://msdn.microsoft.com/en-us/library/f90831hc.aspx

Và cũng tương tự như những con trỏ khác, Pointer to pointer có thể gán giá trị NULL:

int **p_to_p = NULL;

Array of pointers

Pointer to pointer có thể được dùng để quản lý mảng một chiều kiểu con trỏ (int *[]). Ví dụ:

int main()	{

	int *p1 = NULL;
	int *p2 = NULL;
	int *p3 = NULL;

	int *p[] = { p1, p2, p3 };

	int **p_to_p = p;

	return 0;
}

Trong trường hợp này, p_to_p[0] tương đương với p[0].

Thông thường, chúng ta sẽ sử dụng pointer to pointer để quản lý vùng nhớ được cấp phát trên Heap cho mảng một chiều chứa các con trỏ.

int *p1 = NULL;
int *p2 = NULL;
int *p3 = NULL;

int **p_to_p = new int*[3];
p_to_p[0] = p1;
p_to_p[1] = p2;
p_to_p[2] = p3;

delete[] p_to_p;

Tương tự như con trỏ kiểu (int *) dùng để trỏ đến mảng các phần tử kiểu int, con trỏ kiểu (int **) dùng để trỏ đến mảng các phần tử kiểu (int *).

2D dynamically allocated array

Một cách sử dụng khác của Pointer to pointer là dùng để quản lý mảng hai chiều được cấp phát trên Heap.

Với mảng hai chiều cấp phát trên Stack, chúng ta chỉ cần khai báo như sau:

int arr2D[5][10];

Nhưng với mảng hai chiều cấp phát trên Heap sẽ rắc rối hơn.

Chúng ta biết rằng, mảng hai chiều là một tập hợp của các mảng một chiều có cùng kích thước. Chúng ta cũng đã biết cách cấp phát vùng nhớ cho mảng một chiều trên Heap bằng cách dùng toán tử new đi kèm với toán tử [ ]. Ví dụ:

int *arr1 = new int[10];
int *arr2 = new int[10];
//........

Như vậy, một mảng các con trỏ được dùng để quản lý tập hợp các mảng một chiều này sẽ tạo thành mảng 2 chiều. Ví dụ:

int *pToArrPtr[3];

for(int i = 0; i < 3; i++)
{
	pToArrPtr[i] = new int[5];
}

Kết quả của đoạn chương trình này cho chúng ta một vùng nhớ có kích thước (3 x 5) phần tử kiểu int. Và chúng ta có thể truy xuất từng giá trị thông qua con trỏ pToArrPtr:

for(int i = 0; i < 3; i++)
{
	for(int j = 0; j < 5; j++)
	{
		cin >> pToArrPtr[i][j];
	}
}

cout << "--------------------------------" << endl;

for(int i = 0; i < 3; i++)
{
	for(int j = 0; j < 5; j++)
	{
		cout << pToArrPtr[i][j] << " ";
	}
	cout << endl;
}

Kết quả hoàn toàn giống với mảng hai chiều thông thường. Nhưng lúc này, 3 con trỏ pToArrPtr[0] và pToArrPtr[1] và pToArrPtr[2] vẫn là biến được cấp phát trên Stack. Để chuyển những con trỏ quản lý các mảng một chiều con này sang Heap, chúng ta cần sử dụng Pointer to pointer. Dưới đây là toàn bộ chương trình mẫu cho việc cấp phát và giải phóng vùng nhớ 2 chiều hoạt động tương tự như mảng hai chiều thông thường:

#include <iostream>
using namespace std;

int main()	{

	int **pToArrPtr;

	//Cấp phát vùng nhớ cho 3 con trỏ kiểu (int *)
	pToArrPtr = new int*[3];
	
	//Mỗi con trỏ kiểu (int *) sẽ quản lý 5 phần tử kiểu int
	for (int i = 0; i < 3; i++)
	{
		pToArrPtr[i] = new int[5];
	}

	for (int i = 0; i < 3; i++)
	{
		for (int j = 0; j < 5; j++)
		{
			cin >> pToArrPtr[i][j];
		}
	}

	cout << "--------------------------------" << endl;

	for (int i = 0; i < 3; i++)
	{
		for (int j = 0; j < 5; j++)
		{
			cout << pToArrPtr[i][j] << " ";
		}
		cout << endl;
	}

	//Giải phóng vùng nhớ cho từng dãy vùng nhớ mà 3 con trỏ đang quản lý
	for (int i = 0; i < 3; i++)
	{
		delete[] pToArrPtr[i];
	}
	
	//Giải phóng cho 3 biến con trỏ chịu sự quản lý của pToArrPtr
	delete[] pToArrPtr;

	return 0;
}

Pointer to pointer to pointer to ...

Chúng ta có thể khai báo những con trỏ có dạng như sau:

int ***ptrX3;
int ******ptrX6;

Tuy nhiên, việc thao tác với những con trỏ như thế này khá phức tạp và rất ít gặp trong thực tế nên mình không đề cập trong bài học này.


Tổng kết

Pointer to pointer là một phần nâng cao của con trỏ. Việc thao tác cấp phát và giải phóng vùng nhớ khá phức tạp. Do đó, các bạn mới học có thể hoàn toàn bỏ qua bài học này. Mình cũng khuyên các bạn nên tránh sử dụng Pointer to pointer trừ khi không còn giải pháp nào thay thế.


Hẹn gặp lại các bạn trong bài học tiếp theo trong khóa học lập trình C++ hướng thực hành.

Mọi ý kiến đóng góp hoặc thắc mắc có thể đặt câu hỏi trực tiếp tại diễn đàn

www.daynhauhoc.com

  • Khóa học C++
    • Giới thiệu tổng quan khóa học
    • C++ cơ bản
    • Cấu trúc rẽ nhánh
    • Cấu trúc vòng lặp
    • Nâng cao về biến, kiểu dữ liệu
    • Kiểu dữ liệu mảng
    • Kiểu chuỗi kí tự
    • Cơ bản về Function
    • Con trỏ
    • Kiểu dữ liệu tự định nghĩa
    • Nhập, xuất, streams (Input & Output)
    • Standard Template Library
    • Smart pointer
    • Quản lý mã nguồn
    • Một số feature trong C++11, C++14