30/09/2018, 22:59

Con trỏ và hàm (Pointer & Function)

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

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.

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ì?


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

Hoàng Công Nhật Nam viết 01:07 ngày 01/10/2018

Trả lời bài tập cơ bản: Vấn đề là mình cấp phát bằng new int() mà mình chưa delete phải không ạ?

kyo huu viết 01:01 ngày 01/10/2018

cho em đóng góp 1 ý kiến :v
trong các bài này em thấy 1 cơ chế khá hợp lý khi gọi hàm ( toàn bộ các trường hợp đã đc nêu trong các bài ) là :

int Foo( int parameter )
{…}
//…
Foo(argument);

--> khi gọi hàm Foo thì 1 biến parameter đc tạo ra và được init với biến argument -->  int parameter = argument;
  với biến parameter có scope thuộc hàm Foo nên sẽ bị hủy đi ra khỏi hàm

/
Em thấy dùng cơ chế này hợp lý nếu áp dụng nó cho trường hợp của “Tham số của hàm là tham chiếu vào con trỏ”
khi đó nó k phải là tạo ra 1 bản copy của pValue mà là gán cho giá trị của ptr bằng với giá trị của pValue --> ta đc 2 con trỏ riêng biệt cùng trỏ đến 1 vùng nhớ (nhưng 2 con trỏ này k tồn tại song song vì có scope khác nhau). Nên khi thay đổi giá trị của ptr thì k ảnh hưởng đến pValue

Không biết em nghỉ vậy có đúng k anh @nguyenchiemminhvu ? :v

Anh Tuấn Hoàng viết 01:02 ngày 01/10/2018

Mình muốn hỏi lại 1 chút. Trong ví dụ cuối khởi tạo new int(value)
Khi nào chương trình chưa delete thì vùng nhớ trên heap vẫn do mình nắm giữ? Delete được hiểu là báo Os tao ko dùng vùng nhớ đó nữa?
Nó chính là câu trl của phần bài tập. Chưa delete vùng nhớ

Bài liên quan
0