1001 Tips: Con trỏ và hàm (Pointer & Function) trong C++

Con trỏ và tham số của hàm

Chúng ta đã tìm hiểu về 2 kiểu tham số của hàm:

  • Hàm có tham số nhận giá trị: giá trị truyền vào hàm có thể là giá trị của biến, một hằng số hoặc một biểu thức toán học…
  • Hàm có tham số kiểu tham chiếu: giá trị truyền vào cho hàm là tên biến, và tham số của hàm sẽ tham chiếu trực tiếp đến vùng nhớ của biến đó.

Chúng ta còn có thêm một kiểu truyền dữ liệu vào cho hàm nữa, đó là truyền địa chỉ vào hàm (Pass arguments by address). Do đó, kiểu tham số của hàm có thể nhận giá trị là địa chỉ phải là con trỏ.

void foo(int *iPtr)
{
	cout << "Int value at " << iPtr << " is " << *iPtr << endl;
}

int main()	
{
	int iValue = 10;
	foo(&iValue);
	
	system("pause");
	return 0;
}

Trong đoạn chương trình trên, sau khi truyền địa chỉ của biến iValue vào hàm foo, tham số iPtr bây giờ sẽ giữ địa chỉ của biến iValue, và chúng ta có thể sử dụng toán tử dereference cho con trỏ iPtr. Kết quả in ra màn hình trên máy tính của mình là:

Int value at 0xBFBA144C is 10

Nếu vùng nhớ tại địa chỉ được sử dụng làm đối số cho hàm không phải là hằng, chúng ta có thể thay đổi giá trị của vùng nhớ đó ngay bên trong hàm thông qua toán tử dereference:

void changeValue(int *iPtr)
{
	*iPtr = 10;
}

int main()
{
	int iValue = 5;
	cout << "Value = " << iValue << endl;
	
	changeValue(&iValue);
	cout << "Value = " << iValue << endl;
	
	system("pause");
	return 0;
}

Kết quả in ra:

Value = 5
Value = 10

Như vậy, chúng ta có thể hoán vị giá trị của 2 số nguyên thông qua hàm như sau:

void swapIntValue(int *ptr1, int *ptr2)
{
	int temp = *ptr1;
	*ptr1 = *ptr2;
	*ptr2 = temp;
}

int main()
{
	int value1 = 2;
	int value2 = 5;
	
	cout << "Before swap: " << value1 << " " << value2 << endl;
	swapIntValue(&value1, &value2);
	cout << "After swap : " << value1 << " " << value2 << endl;
	
	system("pause");
	return 0;
}

Kết quả:

Before swap: 2 5
After swap : 5 2

Như các bạn thấy, con trỏ khi làm tham số cho hàm cũng có khả năng thay đổi giá trị của vùng nhớ không phải hằng như con trỏ thông thường thông qua toán tử dereference.

Chúng ta còn có thể truyền địa chỉ của mảng một chiều vào cho tham số kiểu con trỏ của hàm. Ví dụ:

void printArray(int *arr, int length)
{
	for (int i = 0; i < length; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;
}

int main()
{
	int iArr[] = { 3, 2, 5, 1, 7, 10, 32 };
	printArray(iArr, sizeof(iArr) / sizeof(int));
	
	system("pause");
	return 0;
}

Lưu ý, chúng ta không thể biết chính xác kích thước của mảng một chiều thông qua con trỏ, do đó, chúng ta cần tính toán trước kích thước của mảng trước khi truyền vào cho hàm.

Sử dụng Pointer to const để làm tham số cho hàm

Như các bạn đã biết, Pointer to const là loại con trỏ chỉ có chức năng để đọc (read-only). Do đó, sử dụng Pointer to const làm tham số cho hàm sẽ đảm bảo rằng giá trị tại vùng nhớ được truyền vào cho hàm sẽ không bị thay đổi.

void printArray(const int *arr, int length)
{
	for (int i = 0; i < length; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;
}

int main()
{
	int arr[] = {};
	int length = sizeof(arr) / sizeof(int);
	
	printArray(arr, length);
	
	system("pause");
	return 0;
}

Lúc này, chúng ta có thể đảm bảo rằng giá trị của các phần tử trong mảng arr sẽ không bị thay đổi bởi hàm printArray.

Tham số của hàm là tham chiếu vào con trỏ

Khi chúng ta truyền đối số cho hàm là một địa chỉ, cái địa chỉ này cũng chỉ là bản copy của địa chỉ ban đầu. Về bản chất, truyền địa chỉ vào hàm là truyền đối số là giá trị (pass by value). Địa chỉ của đối số sẽ được copy và gán lại cho tham số con trỏ của hàm. Nếu bên trong hàm có câu lệnh thay đổi địa chỉ được truyền vào, chúng chỉ thay đổi bản sao của địa chỉ gốc. Để dễ hình dung hơn, chúng ta xem xét ví dụ sau:

void setToNull(int *ptr)	
{
	ptr = NULL; // (4)
}  // (5)

int main()
{
	int value = 5;
	int *pValue = &value; // (1)
	
	cout << "pValue point to " << pValue << endl; // (2)
	setToNull(pValue); // (3)
	cout << "pValue point to " << pValue << endl; // (6)
	
	system("pause");
	return 0;
}

Có 6 bước để nói về đoạn chương trình trên:

(1) Gán địa chỉ của biến value cho con trỏ pValue.
(2) In ra địa chỉ mà con trỏ pValue đang nắm giữ.
(3) Truyền giá trị của con trỏ đang nắm giữ cho hàm setToNull
(4) Sau khi con trỏ ptr trong hàm setToNull nhận được giá trị đầu vào, con trỏ ptr này được gán lại giá trị NULL.
(5) Ra khỏi phạm vi của hàm setToNull, con trỏ ptr bị hủy.
(6) In ra lại giá trị của con trỏ pValue. Lúc này, chúng ta có thể thấy giá trị của pValue không hề thay đổi, nó vẫn còn trỏ đến địa chỉ của biến value.

Như vậy, giá trị địa chỉ được truyền vào hàm được nắm giữ bởi tham số con trỏ của hàm, từ đó chúng ta có thể sử dụng toán tử dereference để thao tác với vùng nhớ tại địa chỉ đó. Chúng ta cũng có thể cho tham số của hàm trỏ đến địa chỉ khác, nhưng không ảnh hưởng gì đến con trỏ gốc.

Trong một số trường hợp cụ thể, chúng ta muốn thay đổi địa chỉ của con trỏ đối số đang trỏ đến, chúng ta có thể sử dụng tham chiếu cho con trỏ đối số. Xét đoạn chương trình bên dưới:

void setToNull(int *&ptr)	
{
	ptr = NULL;
}

int main()
{
	int value = 5;
	int *pValue = &value;
	
	cout << "pValue point to " << pValue << endl;
	setToNull(pValue);
	if(pValue == NULL)
		cout << "pValue point to NULL" << endl;
	else
		cout << "pValue point to " << pValue << endl;
	
	return 0;
}

Kết quả của đoạn chương trình này cho thấy con trỏ pValue sau khi truyền vào hàm setToNull đã được gán giá trị NULL. Do tham số con trỏ của hàm setToNull là một tham chiếu kiểu (int *), nó sẽ tham chiếu đến đối số được truyền vào, trong trường hợp này, tham số tham chiếu con trỏ ptr có cùng địa chỉ với pValue, việc thay đổi giá trị mà ptr nắm giữ cũng làm thay đổi giá trị của pValue.

  Modern C++ binary RPC framework gọn nhẹ, không cần code generation

Con trỏ và kiểu trả về của hàm

Chúng ta đã cùng tìm hiểu 2 kiểu giá trị trả về của hàm có kiểu trả về:

  • Hàm trả về giá trị.
  • Hàm trả về tham chiếu.

Bây giờ, chúng ta sẽ cùng tìm hiểu một số vấn đề về kiểu giá trị trả về của hàm là địa chỉ (return by address).

Khi nói về việc trả về địa chỉ từ hàm, chúng ta hiểu rằng đó là địa chỉ của những biến hoạt động bên trong hàm. Địa chỉ này sẽ được trả về cho lời gọi hàm, và địa chỉ này thường được tiếp tục sử dụng bằng cách gán nó lại cho 1 con trỏ. Do đó, kiểu trả về của hàm cũng phải là kiểu con trỏ.

Ví dụ:

int * createAnInteger(int value = 0)
{
	int myInt = value;
	return &myInt;
}

int main()
{
	int *pInt = createAnInteger(10);
	cout << *pInt << endl;
	
	return 0;
}

Sau khi nhìn vào kết quả, chúng ta thấy có vẻ chương trình đã cho ra kết quả như mong muốn:

10

Nhưng thực chất, đoạn chương trình trên đã gây ra lỗi nghiêm trọng. Lý do là biến myInt được khai báo bên trong hàm là biến cục bộ, được cấp phát bằng kỹ thuật Automatic memory allocation, và vùng nhớ được cấp phát cho biến myInt được lưu trữ trên phân vùng Stack của bộ nhớ ảo. Do đó, ngay sau khi ra khỏi hàm, vùng nhớ của biến myInt đã bị hệ điều hành thu hồi, nhưng địa chỉ của biến myInt trước đó đã được trả về cho lời gọi hàm, nên con trỏ pInt trong hàm main được gán một địa chỉ của một vùng nhớ không thuộc quyền quản lý của chương trình hiện hành nữa.

Như mình đã nói, nếu không may, một chương trình khác yêu cầu cấp phát vùng nhớ ngay tại địa chỉ của biến myInt lúc chưa bị hủy, nội dung bên trong vùng nhớ này sẽ bị các chương trình khác thay đổi, dẫn đến việc sử dụng toán tử dereference đến vùng nhớ đó không cho ra kết quả như ban đầu nữa. Các bạn có thể chạy đoạn chương trình sau để kiểm chứng:

int * createAnInteger(int value = 0)
{
	int myInt = value;
	return &myInt;
}

int main()
{
	int *pInt = createAnInteger(10);
	cout << "Print immediately:         " << *pInt << endl;
	_sleep(1000);
	cout << "After a fews seconds:   " << *pInt << endl;

	system("pause");
	return 0;
}

Kết quả trên máy tính của mình:

Như các bạn thấy, chỉ sau thời điểm vùng nhớ của biến myInt bị hủy mới có 1 giây mà đã có chương trình khác sử dụng vùng nhớ đó, làm cho giá trị in ra màn hình console không còn như ban đầu nữa. Và nếu không may hơn nữa, nếu chương trình khác sử dụng cơ chế đồng bộ của kỹ thuật multithreading lên vùng nhớ này, việc dereference vào vùng nhớ đó cũng có thể gây crash chương trình.

Nguyên nhân của những hệ quả mà mình vừa kể ra đều là do vùng nhớ được cấp phát trên Stack thông qua kỹ thuật Automatic memory allocation sẽ bị thu hồi tự động bởi hệ điều hành. Để giải quyết vấn đề này, chúng ta cần sử dụng phân vùng Heap để có thể tự quản lý thời điểm giải phóng vùng nhớ để trả lại cho hệ điều hành quản lý.

int * createAnInteger(int value = 0)
{
	return new int(value);
}

int main()
{
	int *pInt = createAnInteger(10);
	
	cout << "Print immediately:   " << *pInt << endl;
	_sleep(5000);
	cout << "After a few seconds: " << *pInt << endl;
	
	system("pause");
	return 0;
}

Kết quả lúc này đã được đảm bảo do chúng ta biết rằng vùng nhớ cấp phát trên Heap chỉ bị hệ điều hành thu hồi khi toàn bộ chương trình kết thúc.


Tổng kết

Trong bài học này, chúng ta đã biết cách truyền tham số là địa chỉ (hoặc con trỏ) vào cho hàm, và trả về địa chỉ cho lời gọi hàm. Bên cạnh đó, chúng ta cũng đã biết được một số vấn đề phát sinh khi sử dụng các kỹ thuật này. Vẫn còn nhiều vấn đề cần phải nói khi sử dụng con trỏ, chúng ta sẽ cùng tiếp tục tìm hiểu trong các bài học tiếp theo.

Bài tập cơ bản

Xét đoạn chương trình của ví dụ trên.

#include <iostream>
using namespace std;

int * createAnInteger(int value = 0)
{
	return new int(value);
}

int main()
{
	int *pInt = createAnInteger(10);
	
	cout << "Print immediately:   " << *pInt << endl;
	_sleep(5000);
	cout << "After a few seconds: " << *pInt << endl;
	
	system("pause");
	return 0;
}

Đoạn chương trình trên cho ra kết quả đúng, giá trị được in ra khi sử dụng toán tử dereference để truy xuất không bị thay đổi theo thời gian, nhưng nó lại phát sinh một vấn đề khác. Đó là vấn đề gì?

TopDev via daynhauhoc.com

  1001 Tips: Con trỏ và hàm (Pointer & Function) trong C++
Lương của Developer (C++, PHP) tại Cốc Cốc có thể lên đến $2,000?”]