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 tìm hiểu về những lỗi thường gặp ở những bạn mới học lập trình khi sử dụng con trỏ trong chương trình. Những lỗi này thường xuất hiện do các bạn mới học chưa hiểu rõ cách quản lý vùng nhớ trong chương trình, do cách tổ chức chương trình chưa hợp lý, hoặc do sơ ý… Trong đó, một số lỗi không nghiêm trọng sẽ không gây ảnh hưởng nhiều đến hoạt động của chương trình, một số lỗi nghiêm trọng có thể làm chương trình phải kết thúc ngay lập tức. Dù lỗi gây có có nghiêm trọng hay không, chúng ta chủ động ngăn chặn thì vẫn tốt hơn.
Con trỏ trỏ đến vùng nhớ nằm ngoài phạm vi chương trình đang quản lý
Mình có đoạn chương trình như sau:
int main()
{
//allocate memory on Heap
int *p = new int;
//p point to somewhere
p -= 10000;
//dereference to the area of other program
*p = 1;
system("pause");
return 0;
}
Đầu tiên, chương trình cấp phát một vùng nhớ có kích thước 4 bytes trên phân vùng Heap và cho con trỏ nắm giữ địa chỉ trả về của toán tử new. Sau đó, mình lại cho con trỏ p trỏ lung tung trên bộ nhớ ảo và cố tình thay đổi nội dung bên trong vùng nhớ mới trỏ đến. Khi để ở chế độ Debug, Visual Studio sẽ chặn chương trình của chúng ta lại và đưa ra cảnh báo. Nhưng nếu chúng ta chuyển sang chế độ Release và build ra sản phẩm thành một phần mềm, chạy phần mềm này sẽ gây crash và phần mềm phải kết thúc ngay lập tức.
Thật ra cũng không có ai rãnh rỗi đến mức gán địa chỉ sai cho con trỏ, điều mà mình muốn nói ở đây là khi chương trình chúng ta viết bị crash khi chạy, có thể lỗi này đến từ việc gán địa chỉ không hợp lý.
Trong thực tế, lỗi này sẽ xuất hiện dưới một hình thức khác. Ví dụ:
int main()
{
int *p = new int[10];
cout << p << endl;
delete[] p;
cout << p << endl;
_sleep(10000);
for (int i = 0; i < 10; i++)
cin >> p[i];
return 0;
}
Chương trình này yêu cầu cấp phát 1 dãy địa chỉ có kích thước 40 bytes trên Heap. Sau đó in ra địa chỉ của vùng nhớ vừa được cấp phát thành công. Tiếp theo, mình không muốn sử dụng vùng nhớ này nữa nên mình trả lại cho hệ điều hành thông qua toán tử delete. Trên thực tế, có thể mình sẽ không giải phóng vùng nhớ ngay lập tức mà sử dụng xong rồi mới giải phóng nó đi. Sau khi giải phóng vùng nhớ đó, mình in ra lại địa chỉ mà con trỏ p đang nắm giữ thì thấy p vẫn đang trỏ đến địa chỉ mà mình đã giải phóng. Vậy là chúng ta đã gặp phải lỗi mà mình đưa ra ở ví dụ đầu tiên trong bài học này, đó là con trỏ trỏ đến một vùng nhớ không chịu sử quản lý của chương trình. Lúc này, mình chưa dereference đến vùng nhớ đó ngay lập tức mà vẫn cho chương trình tiếp tục thực thi. Cuối cùng, mình không nhớ rằng vùng nhớ ban đầu đã được giải phóng ở đâu đó nên vẫn tiếp tục sử dụng bằng cách nhập dữ liệu vào đó thông qua con trỏ p.
Kết quả là phần mềm đang chạy thì bị crash vì có thể hệ điều hành đã cấp phát vùng nhớ đã được giải phóng cho phần mềm khác sử dụng. Khi chạy chương trình dưới chế độ Debug trong Visual Studio, lỗi này có thể không phát hiện được do nó vẫn chạy bình thường mà không có thông báo mà cũng không bị crash. Điều này làm chúng ta tưởng rằng chương trình hoạt động tốt, và build nó ra thành phần mềm lỗi.
Để khắc phục trường hợp này, chúng ta nên cho con trỏ quản lý vùng nhớ được cấp phát trỏ về NULL ngay sau khi giải phóng vùng nhớ đó.
int main()
{
int *p = NULL;
p = new int[10];
cout << p << endl;
delete[] p;
p = NULL;
cout << p << endl;
_sleep(10000);
if (p != NULL) {
for (int i = 0; i < 10; i++)
cin >> p[i];
}
return 0;
}
Bất cứ khi nào sử dụng vùng nhớ thông qua con trỏ, chúng ta cũng nên kiểm tra xem con trỏ có khác NULL hay không. Nếu con trỏ khác NULL thì chúng ta hiểu rằng vùng nhớ đó vẫn chưa được giải phóng. Đây chỉ là một cách quy ước mình tự đặt ra giúp cách viết chương trình của mình an toàn hơn, cách của mình có thể khác với một số lập trình viên khác.
Nhưng lỗi này còn có thể xuất hiện dưới một hình thức khác nữa. Đó là sử dụng 2 con trỏ cùng trỏ đến một vùng nhớ trong chương trình.
#include <iostream>
#include <cstring>
using namespace std;
char * getName(char *fullname) {
if (fullName == NULL)
return NULL;
char *pTemp = strrchr(fullname, ' ');
if (pTemp == NULL)
return fullname;
else
return pTemp + 1;
}
int main() {
char *fullName = new char[50];
cout << "Enter your full name: ";
cin.getline(fullName, 50);
cout << "Your last name is: ";
char *name = getName(fullName);
delete[] fullName;
cout << name << endl;
return 0;
}
Đoạn chương trình này thực hiện công việc yêu cầu người dùng nhập vào đầy đủ họ và tên, sau đó in ra tên mà người dùng vừa nhập vào (bỏ qua họ và tên đệm). Nhưng trước đó, mình cần yêu cầu cấp phát một vùng nhớ trên Heap đủ để người dùng nhập vào họ tên.
Cách hoạt động của hàm getName như sau:
(1) Nhận vào đối số là địa chỉ của một địa chỉ của một chuỗi kí tự, trong trường hợp này là full name của người dùng.
(2) Sử dụng hàm strrchr trong thư viện cstring để trả về địa chỉ xuất hiện kí tự khoảng trắng cuối cùng trong chuỗi kí tự.
(3) Nếu không có kí tự khoảng trắng thì trả về địa chỉ đầu tiên của chuỗi kí tự (cho rằng người dùng chỉ nhập tên chứ không nhập họ và tên đệm), nếu có xuất hiện khoảng trắng thì trả về địa chỉ của phần tử đứng sau khoảng trắng.
Như chúng ta thấy, địa chỉ của tên người dùng được trả về từ hàm getName cũng thuộc phạm vi vùng nhớ được cấp phát và đang được quản lý thông qua con trỏ fullName. Tuy nhiên, trong đoạn chương trình trên, lập trình viên này đã nghĩ rằng sau khi sử dụng xong hàm getName thì không cần sử dụng đến con trỏ fullName nữa, vậy là delete luôn vùng nhớ mà con trỏ fullName đang nắm giữ, dẫn đến việc con trỏ name đã trỏ tới một vùng nhớ không còn thuộc quyền quản lý của chương trình nữa. Và kết quả cho ra không đúng với mong đợi:
Tuy nhiên, khi mình chạy chương trình trên, kết quả vẫn đúng. Đó là do mình sử dụng vùng nhớ đó ngay sau khi trả lại cho hệ điều hành. Hệ điều hành lúc này vẫn chưa tác động gì đến vùng nhớ đã được giải phóng, hoặc nếu đã có cấp phát cho chương trình khác thì chương trình đó vẫn chưa thay đổi nội dung trong phạm vi này. Bây giờ mình giả sử chúng ta thực thi công việc khác, sau một vài giây sau mới cần in ra kết quả thì sẽ dễ phát hiện lỗi hơn:
int main() {
char *fullName = new char[50];
cout << "Enter your full name: ";
cin.getline(fullName, 50);
cout << "Your last name is: ";
char *name = getName(fullName);
delete[] fullName;
_sleep(5000);
cout << name << endl;
return 0;
}
Để khắc phục trường hợp này, chúng ta cần xác định rằng khi nào thực sự không còn sử dụng đến vùng nhớ nào đó thì mới giải phóng. Sửa lại đoạn chương trình trên như sau:
int main() {
char *fullName = new char[50];
cout << "Enter your full name: ";
cin.getline(fullName, 50);
cout << "Your last name is: ";
char *name = getName(fullName);
cout << name << endl;
delete[] fullName;
fullName = NULL;
return 0;
}
Hoặc chúng ta vẫn muốn sử dụng tiếp vùng nhớ quản lý bởi con trỏ fullName mà không muốn sử dụng đến con trỏ name nữa, chúng ta nên sửa lại như sau:
int main() {
char *fullName = new char[50];
cout << "Enter your full name: ";
cin.getline(fullName, 50);
cout << "Your last name is: ";
char *name = getName(fullName);
cout << name << endl;
name = NULL;
//keep using fullName
//and then deallocate it
delete[] fullName;
return 0;
}
Con trỏ name chỉ trỏ đến địa chỉ bên trong vùng nhớ được cấp phát cho con trỏ fullName, nên chúng ta không nên sử dụng toán tử delete cho con trỏ name.
Trường hợp con trỏ trỏ đến vùng nhớ không chịu sự quản lý của chương trình cũng thường xuất hiện khi trả về địa chỉ của biến cục bộ trong hàm.
int * newIntValue(int value = 0)
{
int n = value;
return &n;
}
int main() {
int *pInt = newIntValue(0);
return 0;
}
Như các bạn đã biết, biến cục bộ sẽ bị đưa ra khỏi Stack khi ra khỏi phạm vi khối lệnh. Dó đó, địa chỉ của biến n trong hàm newIntValue vẫn được trả về trước khi bị hủy. Chúng ta nên thay bằng Dynamic memory allocation:
int * newIntValue(int value = 0)
{
return new int(value);
}
int main()
{
int *p = newIntValue(0);
delete p;
return 0;
}
memory leak
Memory leak là trường hợp cấp phát vùng nhớ cho chương trình (thường là cấp phát trên Heap) nhưng vùng nhớ không được sử dụng hoặc không được giải phóng. Điều này làm giảm dung lượng bộ nhớ có thể sử dụng được cho những chương trình khác, khiến các chương trình hoạt động chậm hơn hoặc có thể làm crash chương trình.
Đây là một ví dụ thường gặp ở những lập trình viên mới học về kỹ thuật Dynamic memory allocation:
int *ptr = new int[10];
//................
ptr = NULL;
Trong đoạn chương trình này, lập trình viên tự ý cho con trỏ ptr trỏ đi nơi khác. Điều này dẫn đến việc vùng nhớ được cấp phát trước đó không thể quản lý được nữa. Muốn quản lý một vùng nhớ được cấp phát trên Heap, chúng ta cần sử dụng ít nhất một con trỏ. Nhưng trong trường hợp này, không còn con trỏ nào được dùng để quản lý vùng nhớ đã được cấp phát. Do đó, vùng nhớ được cấp phát chỉ có thể được giải phóng khi toàn bộ chương trình kết thúc.
Để khắc phục trường hợp này, chúng ta cần có một con trỏ khác thay thế vị trí của con trỏ ptr trước khi cho con trỏ ptr trỏ đi nơi khác:
int *ptr = new int[10];
//.................
int *pTemp = ptr;
ptr = NULL;
Việc resize kích thước của vùng nhớ cũng có thể gây ra lỗi memory leak nếu sơ ý:
void resizeArray(int *&p, int oldLength, int newLength)
{
int *pTemp = p;
p = allocateArray(newLength);
//copy data
if(oldLength < newLength)
{
for(int i = 0; i < oldLength; i++)
{
p[i] = pTemp[i];
}
}
else
{
for(int i = 0; i < newLength; i++)
{
p[i] = pTemp[i];
}
}
}
int main() {
int length = 10;
int *p = new int[length];
int newLength = 20;
resizeArray(p, length, newLength);
return 0;
}
Khi cấp phát lại vùng nhớ, con trỏ p được gán vào địa chỉ mới, vùng nhớ ban đầu được con trỏ pTemp quản lý, nhưng khi ra khỏi hàm thì con trỏ pTemp bị hủy (vì pTemp cũng là biến cục bộ, được cấp phát trên Stack). Như vậy, vùng nhớ cũ không còn được quản lý nữa.
Chúng ta nên sửa lại đoạn chương trình trên như sau:
void resizeArray(int *&p, int oldLength, int newLength)
{
int *pTemp = p;
p = allocateArray(newLength);
//copy data
if(oldLength < newLength)
{
for(int i = 0; i < oldLength; i++)
{
p[i] = pTemp[i];
}
}
else
{
for(int i = 0; i < newLength; i++)
{
p[i] = pTemp[i];
}
}
delete[] pTemp;
}
int main() {
int length = 10;
int *p = new int[length];
int newLength = 20;
resizeArray(p, length, newLength);
delete[] p;
return 0;
}
Còn một trường hợp thường thấy nữa, đó là việc sử dụng sai toán tử delete cho con trỏ:
int main() {
int *p = new int[10];
delete p;
return 0;
}
Như các bạn thấy, chúng ta yêu cầu cấp phát một dãy vùng nhớ cho 10 phần tử kiểu int, nhưng khi giải phóng thì sử dụng toán tử delete để giải phóng một biến đơn. Visual Studio không báo lỗi cho trường hợp này, do đó lỗi này thường cũng khó nhận ra. Chúng ta nên sửa lại như sau:
int main() {
int *p = new int[10];
delete[] p;
return 0;
}
Tổng kết
Trong bài học này, chúng ta đã cùng tìm hiểu một số nguyên nhân gây ra lỗi khi sử dụng con trỏ trong ngôn ngữ C++. Đây là một số lỗi thường gặp ở những người mới học lập trình C++. Như các bạn thấy, việc quản lý vùng nhớ một cách thủ công khá là phức tạp. Trong chuẩn C++ mới đã có hổ trợ cho chúng ta Smart Pointer giúp chúng ta tránh được những lỗi thường gặp này. Chúng ta sẽ tìm hiểu về Smart Pointer trong những bài học sau.
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