[C++ cơ bản] Cấp phát bộ nhớ động với C++ (phần 3)

cppĐể chuẩn bị cho loạt bài “Hướng đối tượng ứng dụng thiết kế cấu trúc cây”, tôi viết trước bài này để các bạn có cái nhìn tổng quan và rõ hơn về việc cấp phát bộ nhớ động với các đối tượng trên vùng lưu trữ tự do. Kĩ thuật này không khác mấy với vấn đề đã được đề cập ở bài viết trước. Tuy nhiên, có vài điểm cần phải lưu ý khi cấp phát bộ nhớ trên Heap cho một đối tượng.

Nếu bạn chưa có cái nhìn tổng quan nhất về cấp phát bộ nhớ động, bạn có thẻ đọc lại phần 1 của bài này tại:

Phần 1: https://nvconghau.wordpress.com/2014/08/14/cc-co-ban-cap-phat-bo-nho-dong-voi-c/

Phần 2: https://nvconghau.wordpress.com/2014/09/27/c-co-ban-cap-phat-bo-nho-dong-voi-c-phan-2/

Sau đó, bạn có thể tiếp tục tại đây.

Phạm vi của dữ liệu được ghi trên vùng lưu trữ tự do

Như ta đã biết, biến toàn cục được lưu trên vùng toàn cục, nó có thể truy cập và thay đổi ở bất kỳ đâu trên chương trình. Điều đó có nghĩa là, dữ liệu được lưu trữ bằng biến toàn cục cực kỳ nguy hiểm khi mọi hàm hay mọi nơi trong chương trình đều có thể nay đổi được nó. Bạn có thể sẽ không kiểm soát nổi các biến toàn cục này, và việc sử dụng chúng thường được khuyến cáo là không nên.

Các biến cục bộ và tham số đầu vào của hàm sẽ được đưa vào ngăn xếp theo mô hình “vào sau, ra trước”. Có nghĩa là dữ liệu sẽ được nạp vào ngăn xếp (vùng nhớ dành cho hàm), sau khi kết thúc hàm, ngăn xếp sẽ được giải phóng và dữ liệu không còn tồn tại nữa trừ khi bạn trả về một giá trị nào đó.

Tuy nhiên, đối với ô nhớ đã được cấp phát trên vùng lưu trữ tự do, dữ liệu của bạn sẽ không bị giải phóng cho đến khi chương trình kết thúc (hàm main trả về một giá trị hoặc kết thúc cấu lệnh cuối cùng của hàm main) HOẶC bạn dùng lệnh “delete” để chủ động giải phóng bộ nhớ trên vùng lưu trữ tự do. Ví dụ bên dưới sẽ minh họa vấn đề này:

#include <iostream>
using namespace std;
int * temp;
void minhHoa() {
    int * pHeap = new int(5);
    cout << "Ban dang o trong ham minhHoa();" << endl;
    cout << "Ban vua tao mot bien int tren vung luu tru tu do co gia tri la " << *pHeap << endl;
    temp = pHeap;
}
int main() {
    minhHoa();
    cout << "\nBan da ra khoi ham minhHoa()" << endl;
    cout << "Du da ra khoi ham minhHoa(), vung nho int da tao tren Heap van con!!!" << endl;
    cout << "Gia tri cua no la " << *temp << endl;
    delete temp;
    cout << "Ban vua delete temp... " << *temp << endl;
    return 0;
}

Lưu ý là chương trình trên có tạo ra một con trỏ tên temp có phạm vi sử dụng toàn cục. Mục đích của việc này chỉ là để lưu lại địa chỉ của vùng nhớ trên Heap đã tạo và kiểm tra tính khả dụng của vùng nhớ đó sau khi kết thúc hàm minhHoa().

Chương trình bắt đầu, lệnh đầu tiên mà nó thực hiện đó là gọi hàm minhHoa(). Bên trong hàm minhHoa(), một con trỏ kiểu int tên pHeap được tạo và một vùng nhớ chứa kiểu int được khởi tạo và gán giá trị bằng 5. Con trỏ pHeap sẽ được trỏ vào vùng nhớ này.

Sau đó, hàm minhHoa() thực hiện việc xuất giá trị của ô nhớ vừa tạo trên Heap ra màn hình. Lệnh “temp = pHeap;” dùng để lưu lại địa chỉ của ô nhớ trên Heap.

Sua khi kết thúc hàm minhHoa(), chương trình báo rằng bạn đã ra khỏi hàm minhHoa(). Nó dùng con trỏ temp để cố gắng lấy giá trị của vùng nhớ nó trỏ tới. Nó đã thành công và xuất ra kết quả là 5. Sau đó nó sẽ thực hiện lệnh giải phóng vùng nhớ trên Heap mà con trỏ temp trỏ tới bằng lệnh “delete temp;”. Sau khi vùng nhớ này bị giải phóng, việc lấy nội dung của temp đã không còn khả dĩ.

Đưa đối tượng lên vùng lưu trữ tự do

Các object của chương trình C++ cũng được đối xử như các biến của bạn. Những object được khai báo toàn cục thì dữ liệu của nó được lưu trên vùng toàn cục, những object được khai báo cục bộ thì dữ liệu của nó được đưa vào ngăn xếp của hàm.

Đầu tiên ta sẽ khai báo lớp Cat

class Cat {
private: 
 int itsAge;
public:
 Cat(int age) {
    this->itsAge = age;
 } 
 
 ~Cat() {
 }
 
 int getAge() const { 
     return this->itsAge;
 }
};

Phần khai báo của lớp Cat khá là đơn giản. Ta sẽ có một hàm dựng (hàm khởi tạo giá trị cho thuộc tính của đối tượng) với việc nhận tham số là biến int age, nó sẽ gán giá trị của biến age và thuộc tính itsAge (tuổi) của mỗi chú mèo.

Một hàm có kiểu dữ liệu trả về int là getAge(), hàm này sẽ trả về tuổi của mỗi chú mèo.

Tiếp theo ta sẽ viết mã minh họa việc sử dụng các object phạm vi toàn cục và cục bộ như sau:

#include <iostream>
using namespace std;
Cat meoHoang(4);
void meoCuaMisa() {
   Cat mimi(3);
   cout << "Con day la meo cua Misa, no " << mimi.getAge() << " tuoi roi!" << endl;
}
int main() {
   cout << "Day la mot con meo hoang, no " << meoHoang.getAge() << " tuoi roi!" << endl;
    meoCuaMisa();
   return 0;
}

Đầu tiên, ta sẽ tạo ra một biến object toàn cục là meoHoang và cài đặt chú mèo hoang này 4 tuổi. Một hàm có tên là meoCuaMisa() cũng được khai báo. Trong hàm meoCuaMisa(), một chú mèo tên mimi cũng được khai báo với tuổi là 3. Hàm này sẽ thông báo về mèo của Misa và xuất ra màn hình tuổi của nó.

Chương trình bắt đầu với hàm mai(). Có một câu lệnh xuất dữ liệu ra màn hình thông báo về một chú mèo hoang 4 tuổi. biến meoHoang sở dĩ khả dĩ với hàm main() vì dữ liệu của nó được ghi trên vùng toàn cục.

Sau đó, hàm main tiếp tục gọi hàm meoCuaMisa() để hỏi thăm về chú mèo của nhà Misa. Sau khi kết thúc hàm meoCuaMisa(), hàm main biết nó không thể “bắt cóc” mèo Mimi của Misa về nên chả buồn bận tâm và kết thúc chương trình bằng lệnh “return 0;”.

Day la mot con meo hoang, no 4 tuoi roi!
Con day la meo cua Misa, no 3 tuoi roi!

Bây giờ, ta sẽ làm một điều thú vị khác là cho phép hàm main “bắt cóc” mèo Mimi về nhà. Chà, nên dùng từ cho nhẹ nhàng là anh main() sẽ ghé nhà cô Misa và đề nghị được dắt mèo Mimi đi dạo một vòng, cô Misa đang dở việc rửa bát nên đồng ý đưa Mimi cho anh main dắt đi dạo.

#include <iostream>
using namespace std;
Cat meoHoang(4);
Cat * meoCuaMisa() {
   Cat * mimi = new Cat(3);
   cout << "Con day la meo cua Misa, no " << mimi->getAge() << " tuoi roi!" << endl;
   return mimi;
}
int main() {
    cout << "Day la mot con meo hoang, no " << meoHoang.getAge() << " tuoi roi!" << endl;
    Cat * mimi = meoCuaMisa();
    cout << "\nMain da roi nha cua Misa..." << endl;
    cout << "Main dang dat meo Mimi, " << mimi->getAge() << " tuoi, di dao ne!" << endl;
    return 0; 
}

Ta sẽ thay đổi kiểu dữ liệu trả về của hàm “meoCuaMisa()” thành Cat * , tức là nó trả về con trỏ tới vùng nhớ chứa một đối tượng Cat. Trong hàm meoCuaMisa(), một con trỏ tới đối tượng Cat tên mimi được khởi tạo, đồng thời một vùng nhớ chứa đối tượng Cat trên Heap cũng được tạo. Chú mèo được lưu thông tin trên Heap này cũng được gán tuổi là 3. Con trỏ mimi được trỏ vào vùng nhớ chứa đối tượng Cat trên Heap đó.

Sau khi Misa giới thiệu về chú mèo của mình bằng việc sử dụng con trỏ mimi bình thường, Misa trả về địa chỉ của vùng nhớ chứ dữ liệu về mimi trên Heap.

Ở hàm main cũng tạo một con trỏ Cat tên “mimi” để tiếp nhận “mèo mimi” để dắt đi chơi. Ta thấy rằng, mimi của main được gán giá trị trả về của hàm meoCuaMisa(). Lúc này main đang giới thiệu rằng nó đang dắt mèo Mimi, 3 tuổi đi dạo.

Day la mot con meo hoang, no 4 tuoi roi!
Con day la meo cua Misa, no 3 tuoi roi!

Main da roi nha cua Misa...
Main dang dat meo Mimi, 3 tuoi, di dao ne!

Ta có thể thấy, việc đưa các object lên vùng lưu trữ tự do không khó khăn và hoàn toàn tương tự như việc làm đối với các biến bình thường khác. Ta biết rằng, dữ liệu thực chất của một object chính là dữ liệu chứa trong các thuộc tính của nó. Ví dụ, ta biết mimi và meoHoang đều có thuộc tính là itsAge, tuổi của chúng. itsAge được khai báo là kiểu int nên khi bạo tao meoHoang hay mimi, sẽ có vùng nhớ kích cỡ một biến int được tạo để phục vụ việc lưu trữ thông tin về mimi. Nếu như lớp Cat có thêm biến thành viên là int itsWeight nữa, thì sẽ có 2 vùng nhớ kiểu int được tao mỗi khi có một đối tượng Cat được khai báo.

Vậy, nếu ta tạo một đối tượng Cat trên vùng lưu trữ tự do thì thuộc tính của nó được tao ở đâu? Tất nhiên, thuộc tính của nó cũng được tạo trên vùng lưu trữ tự do!

Vậy có vấn đề nào không nếu biến thành viên itsAge của lớp Cat là một con trõ, khi một đối tượng Cat được tạo, một vùng nhớ trên Heap được khai báo để cho itsAge trỏ đến? Bạn hãy thử sửa lại lớp Cat như sau:

#include <iostream>
using namespace std;
class Cat {
private: 
   int *itsAge;
public:
 Cat(int age) {
    this->itsAge = new int(age);
 } 
 
 ~Cat() {
    cout << "Ban dang huy doi tuong Cat..." << endl;
    delete this->itsAge;
 }
 
 int *getAgeAddress() {
   return this->itsAge;
 }
 
 int getAge() const { 
    return *(this->itsAge);
 }
};

Như đã nói, trong phần khai báo biến thành viên itsAge, nó đã được khai báo thành một con trỏ int. Trong hàm dựng của lớp Cat, nó tạo một vùng nhớ int trên Heap và gán vào đó giá trị của tham số age bằng lệnh “new int(age)” . itsAge được thông báo là sẽ trỏ vào vùng nhớ vừa tạo trên.

Hàm hủy “~Cat()” của lớp Cat (tức hàm hủy thuộc tính khi một đối tượng bị hủy) sẽ có lệnh “delete this->itsAge” để giải phóng bộ nhớ trên Heap mỗi khi đối tượng Cat bị hủy. Hàm “~Cat()” này cũng có một thông báo rằng bạn đang hủy đối tượng Cat.

Có một hàm trả về con trỏ int là getAgeAddress(), mục đích của nó là lấy địa chỉ trên Heap chứa dữ liệu về tuổi của mỗi đối tượng Cat. Hàm này để làm gì sẽ được thảo luận sau!

Bạn thử viết chương trình sau để minh họa việc sử dụng lớp Cat mới này.

Cat meoHoang(4);
void meoCuaMisa() {
    Cat mimi(3);
    cout << "Con day la meo cua Misa, no " << mimi->getAge() << " tuoi roi!" << endl;
}
int main() {
    cout << "Day la mot con meo hoang, no " << meoHoang.getAge() << " tuoi roi!" << endl;
    meoCuaMisa();
     return 0; 
}

Chương trình trên cũng rất giống chương trình ban đầu mà ta sử dụng, dữ liệu xuất của nó là:

Day la mot con meo hoang, no 4 tuoi roi!
Con day la meo cua Misa, no 3 tuoi roi!
Ban dang huy doi tuong Cat...
Ban dang huy doi tuong Cat...

Ta thấy, chú meoHoang vẫn được tao với phạm vi truy cập toàn cục . Hàm main vẫn có thể sự dụng biến meoHoang này. Sau đó, main tiếp tục gọi hàm meoCuaMisa().

Bây giờ hãy lưu ý sự thay đổi của hàm meoCuaMisa(). Nó đã được khai báo là không trả về giá trị “void”! Hàm meoCuaMIsa() tạo một đối tượng mimi cục bộ như bình thường và giới thiệu về tuổi của mimi ra màn hình. Sau khi hàm meoCuaMisa kết thúc, nó sẽ bắt đầu ở câu lệnh tiếp theo ở main. Nhưng chờ đã! Lúc này bỗng nhiên màn hình xuất hiện câu “Ban dang huy doi tuong Cat…”! Đó chẳng phải là câu lệnh xuất bạn đã khai báo ở hàm hủy ~Cat() hay sao?! 

Đúng là như vậy! Vì lúc này mimi đã được khai báo phạm vi cục bộ trong hàm meoCuaMisa() . Do đó, ngay khi kết thúc hàm meoCuaMisa(), tiến trình hoạt động sẽ giải phóng ngăn xếp – vùng nhớ – dành cho hàm “meoCUaMisa()”. Vì ngăn xếp của hàm này được giải phóng, điều đó có nghĩa là đối tượng mimi cũng được “hủy”  nên hàm hủy của nó được gọi và sẽ giải phóng dữ liệu itsAge trên Heap của mimi.

Sau khi kết thúc lện gọi hàm meoCuaMisa(), tiến trình trở về main, main thấy mình chẳng còn việc gì để làm nên kết thúc chương trình. Nhưng…chờ đã! Cái quái gì thế này??? Lại có dòng  “Ban dang huy doi tuong Cat…” xuất hiện?! Rõ ràng hàm main không chứa bất kỳ đối tượng Cat nào cả mà!

Điều này cũng khá dễ giải thích, vì ta đã tạo một đối tượng toàn cục tên meoHoang. Khi ta kết thúc chương trình, mọi dữ liệu của chương trình được giải phóng. Do đó, dữ liệu của chú mèo hoang này trên vùng toàn cục cũng được giải phóng nốt và việc gọi hàm hủy của nó là hiển nhiên.

Tại sao hàm hủy lại có lệnh delete?

Đó là câu hỏi bạn nên đặt ra lúc này. Câu trả lời là vì ta đã khai báo itsAge là một con trỏ, và mỗi khi một đối tượng Cat được tạo, itsAge sẽ trỏ đến vùng nhớ trên Heap, và ta cần phải giải phóng nó mỗi khi không còn sử dụng, hay hủy một đối tượng Cat. Vậy, điều gì xảy ra nếu ta bỏ đi lệnh “delete…” này? Hãy thử sửa lại hàm ~Cat() như sau:

~Cat() {
     cout << "Ban dang huy doi tuong Cat..." << endl;
     // delete this->itsAge;
 }

Hãy thêm dấu comment trước lệnh delete. Sau đó sửa mã của chương trình như sau:

int * temp;
void meoCuaMisa() {
    Cat mimi(3);
    cout << "Con day la meo cua Misa, no " << mimi.getAge() << " tuoi roi!" << endl;
    temp = mimi.getAgeAddress();
}
int main() {
    meoCuaMisa();
    cout << "\nBan da ra khoi meoCuaMisa()..." << endl;
    cout << "Tuoi cua Mimi la " << *temp << endl;
    return 0; 
}

KẾT QUẢ XUẤT:

Con day la meo cua Misa, no 3 tuoi roi!
Ban dang huy doi tuong Cat...

Ban da ra khoi meoCuaMisa()...
Tuoi cua Mimi la 3

Phân tích mã: 

Ta bỏ đi đối tượng meoHoang vì đã không cần dùng đến nó để minh họa nữa. Đồng thời ta tạo một con trỏ int phạm vi toàn cục là “temp”. Trong hàm meoCuaMisa(), con trỏ temp sẽ được gán giá trị là địa chỉ của vùng nhớ chứa tuổi của mimi trên vùng lưu trữ tự do bằng việc gọi hàm “mimi.getAgeAddress()”.

Hàm main bắt đầu chương trình bằng việc gòi hàm meoCuaMisa(). Mọi việc diễn ra bình thường khi Misa giới thiệu về chú mèo của mình và kết thúc hàm. Hàm hủy ~Cat() được gọi để giải phóng ngăn xếp của hàm meoCuaMisa() vì hàm này đã kết thúc, một thông báo “Ban da huy doi tuong Cat…” được xuất ra màn hình.

Sau khi ra khỏi hàm meoCuaMisa(), main thông báo điều này trên màn hình. Và vì temp được trỏ đến vùng nhớ chứa dữ liệu về tuổi của Mimi, nên dù mimi đã được hủy, NHƯNG VÙNG NHỚ CHỨA TUỔI CỦA NÓ CHƯA ĐƯỢC HỦY TRÊN HEAP cho nên khi lấy giá trị của temp , bạn vẫn nhận được tuổi của mimi đã được khai báo trên hàm meoCuaMisa().

Rõ ràng, chương trình này mã kém hiệu quả và rất nguy hiểm khi mà bạn có thể tái sử dụng một dữ liệu mà mình nghĩ là đà hủy. Đối với những chương trình lớn, điều này càng nguy hiểm và phúc tạp hơn nữa.

Vì vậy, có một lời khuyên hữu ích là, BẤT CỨ KHI NÀO BẠN CÓ Ý ĐỊNH TẠO VÙNG NHỚ TRÊN HEAP, HÃY NGHĨ TỚI VIỆC BẠN SẼ GIẢI PHÓNG NÓ KHI NÀO.

Và, BẤT CỨ KHI NÀO MỘT BIẾN THÀNH VIÊN CỦA LỚP SẼ TRỎ VÀO MỘT VÙNG NHỚ TRÊN HEAP, HÃY GIẢI PHÓNG VÙNG NHỚ NÀY BẰNG HÀM HỦY.

Trên đây là một số ví dụ khi sử dụng biến object trên vùng lưu trữ tự do. Lúc này đây, bạn đã sẵn sàng để tạo một cấu trúc cây rồi đó! Hãy đón chờ bài viết tiếp theo!

Công Hậu, nvconghau1995@gmail.com

Gửi bình luận