Trong lập trình hướng đối tượng, khả năng đa hình (polymorphism) đóng vai trò then chốt, cho phép các lớp dẫn xuất định nghĩa lại hành vi thừa kế từ lớp cơ sở. Để kích hoạt tính năng này khi trỏ hoặc tham chiếu đến đối tượng thông qua kiểu dữ liệu của lớp cha, ngôn ngữ C++ sử dụng cơ chế hàm ảo.
Khái Niệm Và Cú Pháp
Hàm ảo là phương thức thành viên của một lớp được khai báo với từ khóa virtual. Điều này thông báo cho trình biên dịch rằng hàm này cần thực hiện việc liên kết động (dynamic binding) tại thời điểm chạy chương trình, bất kể đối tượng cụ thể thuộc về lớp con nào.
virtual void methodDemo();
Vị trí đặt từ khóa virtual luôn nằm trước kiểu trả về của hàm.
Nguyên Lý Hoạt Động
Mục đích chính của hàm ảo là cho phép lớp dẫn xuất định nghĩa lại phương thức có cùng tên với lớp cơ sở, đồng thời đảm bảo rằng việc gọi thông qua con trỏ hay tham chiếu của lớp mẹ sẽ thực thi đúng phiên bản hàm tương ứng với kiểu dữ liệu thực tế của đối tượng đang tồn tại.
Tại lớp con (lớp dẫn xuất), việc khai báo lại từ khóa virtual là không bắt buộc nếu chữ ký (signature) bao gồm tên hàm, số lượng tham số và loại tham số giữ nguyên như lớp cha. Tuy nhiên, tốt nhất vẫn nên duy trì tính nhất quán hoặc sử dụng từ khóa override (phiên bản C++11 trở lên) để nhấn mạnh ý đồ thiết kế. Dù vậy, một điều kiện quan trọng là: khi một hàm đã được đánh dấu là ảo tại lớp gốc, nó tự động mang đặc tính này ở mọi lớp kế tiếp theo sau trong hệ thống phân cấp, dù có ghi lại từ khóa đó hay không.
Lưu ý thêm:
- Khai báo và định nghĩa hàm ảo hoàn toàn có thể tách biệt nhau giống các hàm thường.
- Bạn không thể dùng từ khóa
statictrên các hàm ảo. Lý do là hàm tĩnh không thuộc về một đối tượng cụ thể mà thuộc về cả lớp, mâu thuẫn với bản chất tìm kiếm đối tượng tại runtime của hàm ảo. - Hàm nội tuyến (
inline) cũng không thể được làm ảo. Mặc dù cả hai đều liên quan đến tối ưu hóa hiệu suất, nhưng cơ chế gộp mã nguồn của hàm inline ngăn cản việc xác định vị trí thực thi động cần thiết cho hàm ảo.
Trường Hợp Đặc Biệt: Hàm Phá Hủy
Một trường hợp hiếm gặp khác về việc áp dụng hàm ảo là hàm phá hủy (destructor - ~ClassName). Trong khi các hàm tạo (constructor) tuyệt đối không thể là ảo, thì hàm phá hủy lại rất khuyến khích được định nghĩa là ảo.
Lý do nằm ở quy trình giải phóng bộ nhớ động. Nếu bạn cấp phát đối tượng bằng toán tử new trên lớp con nhưng sử dụng con trỏ kiểu lớp mẹ để xóa nó bằng delete, trình biên dịch sẽ dựa vào loại của con trỏ để quyết định gọi hàm phá hủy nào. Nếu thiếu từ khóa virtual, chỉ hàm phá hủy của lớp mẹ sẽ được gọi, gây rò rỉ bộ nhớ (memory leak) đối với tài nguyên lớp con.
Xét ví dụ dưới đây minh họa sự khác biệt khi sử dụng và không sử dụng hàm ảo cho quá trình khởi tạo và hủy diệt:
Trường hợp 1: Không dùng hàm ảo cho destructor
#include <iostream>
#include <string>
class Base {
public:
Base() { std::cout << "Constructor Base\n"; }
~Base() { std::cout << "Destructor Base\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << "Constructor Derived\n"; }
~Derived() { std::cout << "Destructor Derived\n"; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // Chỉ gọi ~Base(), bị bỏ quên phần Derived
return 0;
}
Kết quả chạy cho thấy chỉ có dòng Destructor Base xuất hiện, dẫn đến việc các tài nguyên riêng của Derived không được dọn dẹp đúng cách.
Trường hợp 2: Sử dụng hàm ảo cho destructor
#include <iostream>
#include <string>
class Base {
public:
Base() { std::cout << "Constructor Base\n"; }
virtual ~Base() { std::cout << "Destructor Base\n"; } // Đánh dấu virtual
};
class Derived : public Base {
public:
Derived() { std::cout << "Constructor Derived\n"; }
~Derived() { std::cout << "Destructor Derived\n"; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // Gọi chuỗi phá hủy đầy đủ
return 0;
}
Với từ khóa virtual xuất hiện ở hàm phá hủy lớp cha, cơ chế vtable sẽ nhận diện chính xác đối tượng thực tế là Derived. Kết quả đầu ra sẽ hiển thị lần lượt Destructor Derived rồi đến Destructor Base, đảm bảo quy trình dọn dẹp diễn ra tuần tự và an toàn cho bộ nhớ.