Hàm ảo (Virtual Function) trong C++: Định nghĩa, Quy tắc và Ứng dụng

Hàm ảo (Virtual Function) là một hàm thành viên được khai báo trong một lớp cơ sở (base class) và được định nghĩa lại (ghi đè – overridden) bởi một lớp dẫn xuất (derived class). Khi bạn tham chiếu đến một đối tượng của lớp dẫn xuất bằng cách sử dụng một con trỏ hoặc tham chiếu đến lớp cơ sở, bạn có thể gọi một hàm ảo cho đối tượng đó và thực thi phiên bản hàm của lớp dẫn xuất.

  • Hàm ảo đảm bảo rằng hàm chính xác được gọi cho một đối tượng, bất kể loại tham chiếu (hoặc con trỏ) được sử dụng để gọi hàm.
  • Chúng chủ yếu được sử dụng để đạt được tính đa hình lúc chạy (Runtime polymorphism).
  • Các hàm được khai báo với từ khóa virtual trong lớp cơ sở.
  • Việc phân giải lệnh gọi hàm được thực hiện tại thời gian chạy.

Quy tắc cho Hàm ảo

  1. Hàm ảo không thể là tĩnh (static).
  2. Một hàm ảo có thể là một hàm bạn (friend function) của một lớp khác.
  3. Hàm ảo nên được truy cập bằng cách sử dụng con trỏ hoặc tham chiếu của kiểu lớp cơ sở để đạt được tính đa hình lúc chạy.
  4. Nguyên mẫu (prototype) của các hàm ảo phải giống nhau trong cả lớp cơ sở và lớp dẫn xuất.
  5. Chúng luôn được định nghĩa trong lớp cơ sở và được ghi đè trong một lớp dẫn xuất. Không bắt buộc lớp dẫn xuất phải ghi đè (hoặc định nghĩa lại hàm ảo), trong trường hợp đó, phiên bản hàm của lớp cơ sở được sử dụng.
  6. Một lớp có thể có hàm hủy ảo (virtual destructor) nhưng nó không thể có một hàm tạo ảo (virtual constructor).

So sánh Hành vi Liên kết Sớm (Compile time) và Liên kết Muộn (Runtime) của Hàm ảo

Xem xét chương trình đơn giản sau đây cho thấy hành vi thời gian chạy của các hàm ảo.

#include <iostream>
using namespace std;

class base {
public:
    virtual void print ()
    {
        cout<< "print base class"<<endl;
    }
    void show ()
    {
        cout<< "show base class"<<endl;
    }
};

class derived:public base {
public:
    void print ()
    {
        cout<< "print derived class"<<endl;
    }
    void show ()
    {
        cout<< "show derived class"<<endl;
    }
};

int main()
{
    base *bptr;
    derived d;
    bptr = &d;

    //Virtual function, binded at runtime
    bptr->print();       

    // Non-virtual function, binded at compile time
    bptr->show();       

    return 0;
}

Đầu ra:

print derived class
show base class

Giải thích: Tính đa hình thời gian chạy chỉ đạt được thông qua một con trỏ (hoặc tham chiếu) của kiểu lớp cơ sở. Ngoài ra, một con trỏ lớp cơ sở có thể trỏ đến các đối tượng của lớp cơ sở cũng như các đối tượng của lớp dẫn xuất. Trong đoạn mã trên, con trỏ lớp cơ sở ‘bptr’ chứa địa chỉ của đối tượng ‘d’ của lớp dẫn xuất. Việc liên kết muộn (Runtime) được thực hiện phù hợp với nội dung của con trỏ (tức là vị trí được trỏ bởi con trỏ) và việc liên kết sớm (Compile time) được thực hiện theo loại của con trỏ, vì hàm print() được khai báo bằng từ khóa virtual nên nó sẽ được liên kết tại thời gian chạy (đầu ra là print derived class vì con trỏ đang trỏ đến đối tượng của lớp dẫn xuất) và show() là phi ảo nên nó sẽ được liên kết trong thời gian biên dịch (đầu ra là show base class vì con trỏ thuộc kiểu cơ sở).

Lưu ý: Nếu chúng ta đã tạo một hàm ảo trong lớp cơ sở và nó đang được ghi đè trong lớp dẫn xuất thì chúng ta không cần từ khóa virtual trong lớp dẫn xuất, các hàm sẽ tự động được coi là các hàm ảo trong lớp dẫn xuất.

Cách thức hoạt động của hàm ảo (khái niệm VTABLE và VPTR)

Như đã thảo luận ở đây, nếu một lớp chứa một hàm ảo thì trình biên dịch sẽ tự thực hiện hai việc.

  1. Nếu đối tượng của lớp đó được tạo thì một con trỏ ảo (VPTR) được chèn làm thành viên dữ liệu của lớp để trỏ đến VTABLE của lớp đó. Đối với mỗi đối tượng mới được tạo, một con trỏ ảo mới được chèn làm thành viên dữ liệu của lớp đó.
  2. Bất kể đối tượng có được tạo hay không, lớp chứa một thành viên một mảng tĩnh các con trỏ hàm được gọi là VTABLE. Các ô của bảng này lưu trữ địa chỉ của mỗi hàm ảo có trong lớp đó.

Xem xét ví dụ dưới đây:

VTABLE và VPTR trong C++VTABLE và VPTR trong C++

#include <iostream>
using namespace std;

class base {
public:
    virtual void fun_1() { cout << "base-1" << endl; }
    virtual void fun_2() { cout << "base-2" << endl; }
    virtual void fun_3() { cout << "base-3" << endl; }
    virtual void fun_4() { cout << "base-4" << endl; }
};

class derived : public base {
public:
    void fun_2() { cout << "derived-2" << endl; }
    void fun_4(int x) { cout << "derived-4" << endl; } // Overload, not override
};

int main() {
    base* ptr = new derived();
    ptr -> fun_1();
    ptr -> fun_2();
    ptr -> fun_3();
    ptr -> fun_4();
    return 0;
}

Đầu ra:

base-1
derived-2
base-3
base-4

Giải thích: Ban đầu, chúng ta tạo một con trỏ kiểu lớp cơ sở và khởi tạo nó với địa chỉ của đối tượng lớp dẫn xuất. Khi chúng ta tạo một đối tượng của lớp dẫn xuất, trình biên dịch tạo một con trỏ làm thành viên dữ liệu của lớp chứa địa chỉ của VTABLE của lớp dẫn xuất.

Khái niệm tương tự về Liên kết Muộn và Liên kết Sớm được sử dụng như trong ví dụ trên. Đối với lệnh gọi hàm fun_1(), phiên bản hàm của lớp cơ sở được gọi, fun_2() được ghi đè trong lớp dẫn xuất nên phiên bản lớp dẫn xuất được gọi, fun_3() không được ghi đè trong lớp dẫn xuất và là hàm ảo nên phiên bản lớp cơ sở được gọi, tương tự fun_4() không được ghi đè nên phiên bản lớp cơ sở được gọi.

Lưu ý: fun_4(int) trong lớp dẫn xuất khác với hàm ảo fun_4() trong lớp cơ sở vì nguyên mẫu của cả hai hàm là khác nhau.