Hàm destructor (hàm hủy) là một loại hàm thành viên đặc biệt khác của class, được thực thi khi một đối tượng của class đó bị hủy. Trong khi các hàm constructors (hàm khởi tạo) được thiết kế để khởi tạo một class, thì các hàm destructors (hàm hủy) được thiết kế để hỗ trợ việc dọn dẹp bộ nhớ.

Khi một đối tượng nằm ngoài phạm vi vùng code mà chương trình đang chạy một cách bình thường, hoặc khi một đối tượng được cấp phát động bị xóa bỏ một cách tường minh bằng từ khóa delete, thì hàm destuctor của class sẽ tự động được gọi (nếu nó tồn tại) để thực hiện bất kỳ việc dọn dẹp bộ nhớ cần thiết nào trước khi xóa bỏ đổi tượng khỏi bộ nhớ. Đối với các class đơn giản (những class mà chỉ khởi tạo giá trị của các biến thành viên thuộc các kiểu dữ liệu cơ bản) thì không cần phải sử dụng hàm destructor vì C++ sẽ tự động dọn dẹp bộ nhớ cho bạn.

Tuy nhiên, nếu đối tượng thuộc class của bạn đang nắm giữ bất kỳ tài nguyên nào (ví dụ như: bộ nhớ động, hoặc một file, hay database handle) cần được giải phóng, hoặc nếu bạn cần thực hiện bất kỳ tác vụ duy trì nào trước khi đối tượng bị hủy, thì hàm destructor chính là nơi hoàn hảo để làm những điều này, bởi vì nó thường là nơi mà code được thực thi sau cùng, trước khi đối tượng bị hủy.

1. Việc đặt tên cho hàm destructor

Giống như các hàm constructor, các hàm destructors cũng có các quy tắc đặt tên riêng:

  • Hàm destructor phải có cùng tên với class, và thêm dấu ngã làm tiền tố.
  • Hàm destructor không thể nhận vào các đối số.
  • Hàm destructor không có kiểu dữ liệu trả về.

Lưu ý, Chỉ tồn tại duy nhất một hàm destructor bên trong mỗi class, do đó không có cách nào để overload (nạp chồng) các hàm destructors, bởi vì chúng không thể được phân biệt với nhau dựa trên các đối số truyền vào.

Nói chung bạn không nên gọi tường minh bất cứ hàm destructor nào (bởi vì chúng sẽ được gọi tự động khi đối tượng bị hủy đi), bởi vì rất hiếm khi gặp phải những trường hợp cần dọn dẹp một đối tượng nhiều hơn một lần. Tuy nhiên, các hàm destructor có thể gọi đến các hàm thành viên khác một cách an toàn, bởi vì đối tượng sẽ không bị hủy cho đến sau khi hàm destructor được thực thi xong.

2. Một ví dụ về hàm destructor

Chúng ta cùng xem một class đơn giản có sử dụng hàm destructor:


/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <cassert>
 
class IntArray
{
private:
	int *m_array;
	int m_length;
 
public:
	IntArray(int length) // constructor
	{
		assert(length > 0);
 
		m_array = new int[length]{};
		m_length = length;
	}
 
	~IntArray() // destructor
	{
		// Dynamically delete the array we allocated earlier
		delete[] m_array;
	}
 
	void setValue(int index, int value) { m_array[index] = value; }
	int getValue(int index) { return m_array[index]; }
 
	int getLength() { return m_length; }
};
 
int main()
{
	IntArray ar(10); // allocate 10 integers
	for (int count{ 0 }; count < ar.getLength(); ++count)
		ar.setValue(count, count+1);
 
	std::cout << "The value of element 5 is: " << ar.getValue(5) << '\n';
 
	return 0;
} // ar is destroyed here, so the ~IntArray() destructor function is called here

Mẹo nhỏ

Nếu bạn thử biên dịch đoạn code trên và bị lỗi sau:

If you compile the above example and get the following error:

error: 'class IntArray' has pointer data members [-Werror=effc++]|
error:   but does not override 'IntArray(const IntArray&)' [-Werror=effc++]|
error:   or 'operator=(const IntArray&)' [-Werror=effc++]|

Để khắc phúc lỗi này, bạn có thể loại bỏ cờ “-Weffc++” khỏi compile settings (những thiết lập về biên dịch) cho ví dụ này, hoặc là bạn có thể thêm hai dòng code sau vào trong class IntArray:

	IntArray(const IntArray&) = delete;
	IntArray& operator=(const IntArray&) = delete;

Chúng ta sẽ thảo luận về chức năng của hai câu lệnh này trong chương sau.

Đoạn chương trình trên sẽ in ra:

The value of element 5 is: 6

Trên dòng đầu tiên, chúng ta đã khởi tạo một đối tượng mới của class IntArray, được gọi là ar, và truyền vào độ dài mảng là 10. Việc khởi tạo này sẽ gọi đến hàm constructor nhằm cấp phát bộ nhớ động cho các biến thành viên của class IntArray. Chúng ta phải sử dụng cấp phát động tại đây bởi vì chúng ta không thể biết được tại thời điểm biên dịch (compile time) thì độ dài của mảng là bao nhiêu (caller – đối tượng gọi hàm sẽ quyết định điều đó).

Khi hàm main() kết thúc, lúc này đối tượng ar đã nằm ngoài phạm vi đoạn code mà chương trình đang chạy trên đó (tức là ar đã goes out of scope). Điều này sẽ làm cho hàm destructor ~IntArray() được gọi, để xóa đi mảng mà chúng ta đã cấp phát bên trong phần thân hàm của constructor!

3. Tính toán thời gian gọi hàm constructor và hàm destructor

Như đã đề cập trước đó, hàm constructor sẽ được gọi khi một đối tượng được tạo ra, và hàm destructor sẽ được gọi khi một đối tượng bị hủy đi. Trong ví dụ dưới đây, chúng ta sẽ sử dụng câu lệnh cout bên trong hàm constructor và hàm destructor để làm rõ điều này:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

class Simple
{
private:
    int m_nID;
 
public:
    Simple(int nID)
        : m_nID{ nID }
    {
        std::cout << "Constructing Simple " << nID << '\n';
    }
 
    ~Simple()
    {
        std::cout << "Destructing Simple" << m_nID << '\n';
    }
 
    int getID() { return m_nID; }
};
 
int main()
{
    // Allocate a Simple on the stack
    Simple simple{ 1 };
    std::cout << simple.getID() << '\n';
 
    // Allocate a Simple dynamically
    Simple *pSimple{ new Simple{ 2 } };
    
    std::cout << pSimple->getID() << '\n';
 
    // We allocated pSimple dynamically, so we have to delete it.
    delete pSimple;
 
    return 0;
} // simple goes out of scope here

Đoạn chương trình trên khi chạy sẽ in ra kết quả như sau:

Constructing Simple 1
1
Constructing Simple 2
2
Destructing Simple 2
Destructing Simple 1

Lưu ý rằng “Simple 1” bị hủy trước “Simple 2” bởi vì chúng ta đã xóa đối tượng pSimple trước khi hàm main() kết thúc, trong khi đối tượng simple chỉ bị hủy đi sau khi hàm main() đã kết thúc rồi.

Các biến toàn cục bên trong class thì đều được khởi tạo/xây dựng trước khi

hàm main() được thực thi, và được hủy đi sau khi hàm main() đã kết thúc rồi.

4. RAII

RAII (Resoure Acquisition Is Initialization – tạm dịch, Việc thu hồi tài nguyên chính là khi khởi tạo) là một kỹ thuật lập trình mà trong đó việc sử dụng tài nguyên được gắn liền với vòng đời của các đối tượng có thời lượng tồn tại được xử lý tự động (ví dụ, non-dynamically allocated objects – các đối tượng được cấp phát tĩnh). Trong C++, RAII được cài đặt thông qua các classes với các hàm constructors và destructors. Một tài nguyên (chẳng hạn như bộ nhớ, một file, hay database handle, v.v…) thường được thu thập bên trong phần thân hàm của constructor của đối tượng (mặc dù nó có thể được thu thập sau khi đối tượng được tạo ra, nếu điều đó hợp lý). Tài nguyên này sau đó có thể được sử dụng trong khi đối tượng đang còn sống. Khi đối tượng bị hủy, tài nguyên này cũng sẽ được giải phóng bên trong hàm destructor. Ưu điểm chính của RAII là nó giúp ngăn chặn việc rò rỉ tài nguyên (ví dụ: Bộ nhớ không được giải phóng) khi tất cả các đối tượng đang nắm giữ tài nguyên đều được dọn dẹp sạch một cách tự động.

Theo mô hình RAII, các đối tượng đang nắm giữ tài nguyên không nên được cấp phát động. Điều này là do các hàm destructors chỉ được gọi khi một đối tượng được hủy đi. Đối với các đối tượng được cấp phát trên vùng nhớ stack, điều này sẽ tự động diễn ra khi đối tượng nằm ngoài phạm vi đoạn code mà chương trình đang chạy trên đó (goes out of scope), do đó, không cần phải lo lắng về việc các tài nguyên cho đến thời điểm cuối cùng có được dọn dẹp sạch hay không. Tuy nhiên, đối với các đối tượng được cấp phát động, người dùng/lập trình viên phải có trách nhiệm giải phóng các tài nguyên sau khi đã sử dụng xong – Nếu quên làm điều này, thì hàm destructor sẽ không được gọi, dẫn đến việc phần bộ nhớ dành cho đối tượng của class và tài nguyên mà nó đang quản lý/nắm giữ bị rò rỉ!

Class IntA ở đầu bài học này là một ví dụ về một class có cài đặt RAII – thực hiện cấp phát bộ nhớ bên trong hàm constructor, và giải phóng bộ nhớ bên trong hàm destructor. std::string và std::vector là các ví dụ về những class bên trong thư viện standard mà tuân theo RAII – bộ nhớ động được lấy khi khởi tạo, và được tự động dọn sạch khi thực thi quá trình hủy.

Quy tắc: Nếu class của bạn cần cấp phát bộ nhớ động, hãy sử dụng mô hình RAII, chứ đừng nên cấp phát động trực tiếp cho những đối tượng của class của bạn.

5. Lưu ý về hàm exit()

Hãy lưu ý rằng nếu sử dụng hàm exit(), chương trình của bạn sẽ kết thúc và sẽ không có hàm destructors nào được gọi. Hãy thận trọng nếu bạn đang sử dụng và dựa vào các hàm destructors để thực hiện các công việc dọn dẹp cần thiết (ví dụ như: Viết gì đó vào một file log hoặc cơ sở dữ liệu, trước khi thoát chương trình).

6. Tổng kết

Bạn thấy đấy, khi các hàm constructors và các hàm destructors được sử dụng cùng với nhau, các class của bạn sẽ tự động khởi tạo và dọn dẹp sau khi chúng được sử dụng xong mà không cần lập trình viên phải thực hiện bất kỳ công việc đặc biệt nào! Điều này làm giảm xác suất tạo ra bug (lỗi), và làm cho các class dễ sử dụng hơn.

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!