Hàm hủy là gì
Ở trang trước bạn đã thấy hàm khởi tạo: những hàm đặc biệt chạy khi đối tượng ra đời để thiết lập trạng thái ban đầu. Hàm hủy (destructor) là hình ảnh phản chiếu: một hàm đặc biệt chạy khi đối tượng kết thúc vòng đời, để dọn dẹp sau nó.
Bạn khai báo hàm hủy bằng tên lớp có dấu ngã (~) phía trước. Nó không nhận tham số, không trả về gì, và mỗi lớp có đúng một hàm hủy. Bạn gần như không bao giờ tự gọi nó: C++ gọi giúp bạn vào đúng thời điểm.
Để ý thông báo của hàm hủy được in ra sau khi thân main chạy xong nhưng trước khi chương trình thoát. Khi log ra khỏi phạm vi tại dấu ngoặc nhọn đóng, C++ chạy ~Logger() giúp bạn.
Khi nào hàm hủy chạy
Thời điểm chính xác phụ thuộc vào nơi đối tượng sống:
- Đối tượng trên stack (cục bộ) bị hủy khi ra khỏi phạm vi, tại dấu
}đóng khối. - Đối tượng trên heap (tạo bằng
new) bị hủy khi bạn gọidelete. Nếu quêndelete, hàm hủy không bao giờ chạy và bạn bị rò rỉ bộ nhớ.
Ví dụ này cho thấy rõ sự khác biệt:
Các đối tượng bị hủy theo thứ tự ngược với lúc khởi tạo. a được tạo đầu tiên nên bị hủy cuối cùng. Thứ tự LIFO (vào sau, ra trước) này quan trọng khi các đối tượng phụ thuộc lẫn nhau.
Vì sao hàm hủy quan trọng: RAII
Sức mạnh thật sự của hàm hủy là chúng khiến việc dọn dẹp trở nên tự động và an toàn trước ngoại lệ. Thay vì phải nhớ giải phóng tài nguyên trên mọi nhánh code, bạn đặt việc giải phóng vào hàm hủy và để ngôn ngữ bảo đảm nó sẽ chạy. Mẫu này gọi là RAII (Resource Acquisition Is Initialization), và nó là xương sống của C++ hiện đại.
Ở đây một lớp sở hữu một buffer trên heap: nó cấp phát trong hàm khởi tạo và giải phóng trong hàm hủy, nên nơi gọi không bao giờ phải tự đụng tới new/delete.
Điểm mấu chốt: kể cả khi một ngoại lệ bị ném ra sau khi squares được tạo, stack vẫn được tháo dần và ~IntArray() vẫn chạy. Chính sự bảo đảm đó khiến RAII đáng tin cậy, và cũng là lý do code C++ tốt hiếm khi có một lệnh delete trần trụi.
Rule of Three (và Five)
Một lớp có hàm hủy tự viết gần như luôn sở hữu một tài nguyên thô, và điều đó tạo ra một mối nguy ngầm. Hàm khởi tạo sao chép và toán tử gán sao chép do trình biên dịch sinh ra chỉ sao chép nông (shallow copy): chúng sao chép con trỏ, không sao chép buffer mà con trỏ trỏ tới. Lúc này hai đối tượng giữ cùng một con trỏ, và cả hai hàm hủy đều sẽ delete nó, gây ra lỗi double free làm chương trình sập.
IntArray a(5);
IntArray b = a; // shallow copy: a.data and b.data are the SAME pointer
// at scope end: b's destructor frees the buffer,
// then a's destructor frees it AGAIN -> undefined behavior (double free)
Từ đó có Rule of Three: nếu bạn viết một trong ba thứ là hàm hủy, hàm khởi tạo sao chép hoặc toán tử gán sao chép, gần như chắc chắn bạn cần cả ba. Từ C++11 trở đi, quy tắc mở rộng thành Rule of Five, thêm hàm khởi tạo di chuyển và toán tử gán di chuyển.
Tuy vậy còn một quy tắc tốt hơn, Rule of Zero: thiết kế lớp sao cho bạn hoàn toàn không phải quản lý tài nguyên thô. Hãy giữ dữ liệu trong std::vector, std::string hoặc smart pointer, và hàm hủy do trình biên dịch sinh ra sẽ tự làm đúng mà bạn không tốn công gì.
Mặc định hãy theo Rule of Zero. Chỉ tự viết hàm hủy khi bạn thật sự sở hữu một tài nguyên thô mà không kiểu chuẩn nào bọc sẵn cho bạn.
Hàm hủy ảo
Khi bạn xóa một đối tượng qua con trỏ kiểu lớp cơ sở, hàm hủy phải là virtual; nếu không, chỉ phần lớp cơ sở bị hủy còn phần lớp dẫn xuất bị rò rỉ. Đây là một trong những bug phổ biến nhất trong code đa hình, và mặc định trình biên dịch sẽ không cảnh báo bạn.
Nếu ~Base không có virtual, delete p sẽ chỉ gọi ~Base(): đó là undefined behavior, và phần Derived của đối tượng không bao giờ được dọn dẹp. Quy tắc chung: mọi lớp có hàm ảo (một lớp cơ sở đa hình) đều cần hàm hủy ảo. Bạn sẽ thấy rõ vì sao điều này quan trọng khi bắt đầu viết lớp dẫn xuất.
Lỗi thường gặp và điểm dễ nhầm
Vài cái bẫy mà gần như ai cũng vấp phải:
new/delete không khớp. Nếu cấp phát bằng new[], hãy giải phóng bằng delete[]. Trộn new[] với delete thường (hoặc ngược lại) là undefined behavior.
Quên virtual ở hàm hủy của lớp cơ sở. Như trên, xóa đối tượng dẫn xuất qua con trỏ lớp cơ sở khi không có hàm hủy ảo sẽ làm rò rỉ phần dẫn xuất. Nếu bạn viết một lớp để người khác kế thừa, hãy cho hàm hủy là virtual.
Để ngoại lệ thoát ra khỏi hàm hủy. Một hàm hủy ném ngoại lệ trong lúc stack đang được tháo dần sẽ làm chương trình kết thúc ngay. Trong C++ hiện đại, hàm hủy ngầm định là noexcept: đừng để code dọn dẹp ném ngoại lệ, hoặc hãy bắt và nuốt ngoại lệ ngay trong hàm hủy.
Viết hàm hủy không cần thiết. Nếu các thành viên đã tự dọn dẹp được, một ~ClassName() {} rỗng chỉ thêm rối và có thể âm thầm vô hiệu hóa các thao tác di chuyển. Khi không có gì để dọn, đừng viết hàm hủy nào cả.
Tiếp theo: kế thừa
Giờ bạn đã thấy trọn vòng đời của một đối tượng: hàm khởi tạo đưa nó vào hoạt động, hàm hủy dọn dẹp nó, và hàm hủy virtual giữ việc dọn dẹp đó đúng khi một lớp được xây trên lớp khác. Điểm cuối cùng ấy là bước dạo đầu cho ý tưởng lớn tiếp theo: kế thừa (inheritance), khi một lớp tái sử dụng và mở rộng dữ liệu cùng hành vi của lớp khác. Trang tiếp theo cho bạn thấy cách tạo lớp dẫn xuất từ một lớp khác, cách khởi tạo và hủy nối tiếp nhau qua cây kế thừa, và cách những gì bạn vừa học khớp lại với nhau.
Câu hỏi thường gặp
Hàm hủy trong C++ là gì?
Hàm hủy (destructor) là một hàm thành viên đặc biệt tên ~ClassName(), tự động chạy khi đối tượng bị hủy, tức là khi nó ra khỏi phạm vi hoặc khi bạn delete nó. Nhiệm vụ của nó là dọn dẹp: giải phóng bộ nhớ, đóng tệp hoặc trả lại mọi tài nguyên đối tượng đang sở hữu. Hàm hủy không có tham số, không có kiểu trả về, và mỗi lớp chỉ có một hàm hủy.
Khi nào hàm hủy được gọi trong C++?
Với đối tượng cục bộ (trên stack), hàm hủy chạy khi đối tượng ra khỏi phạm vi, tại dấu } đóng khối. Với đối tượng trên heap tạo bằng new, hàm hủy chạy khi bạn gọi delete. Sau đó các thành viên và lớp cơ sở được hủy tự động, theo thứ tự ngược với lúc khởi tạo.
Có phải lúc nào cũng cần viết hàm hủy trong C++ không?
Không. Nếu lớp của bạn chỉ chứa các thành viên tự dọn dẹp được (như std::string, std::vector hay smart pointer), hàm hủy do trình biên dịch sinh ra là đủ, đừng tự viết. Bạn chỉ cần hàm hủy riêng khi lớp sở hữu một tài nguyên thô, như vùng nhớ cấp phát bằng new hoặc một tệp đang mở.