Menu

Hàm ảo trong C++: giải thích tính đa hình

Hàm ảo cho phép một con trỏ lớp cha gọi phiên bản phương thức của lớp con lúc chạy. Tìm hiểu virtual, override, lớp trừu tượng và vì sao hàm hủy của lớp cha phải là hàm ảo.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Vì sao chỉ kế thừa thôi là chưa đủ

Ở trang trước, bạn đã xây dựng một hệ phân cấp lớp: lớp con kế thừa các thành viên từ lớp cha. Nhưng có một vấn đề. Khi bạn gọi một phương thức qua con trỏ lớp cha, C++ quyết định chạy hàm nào dựa trên kiểu của con trỏ, không phải kiểu thật của đối tượng. Vì vậy một Animal* thực chất đang trỏ đến một Dog vẫn gọi phiên bản phương thức của Animal.

Đó gần như không bao giờ là điều bạn muốn. Phần lớn thời gian, bạn có một tập các con trỏ lớp cha, mỗi con trỏ trỏ đến một đối tượng lớp con khác nhau, và bạn muốn mỗi đối tượng cư xử đúng như chính nó. Hàm ảo (virtual function) giúp điều đó xảy ra.

Đối tượng thực sự là một Dog, vậy mà a->speak() lại chạy Animal::speak(). Vì speak không phải hàm ảo, trình biên dịch chọn hàm lúc biên dịch dựa trên kiểu tĩnh Animal*. Đây chính là bug mà hàm ảo sinh ra để sửa.

Biến một hàm thành hàm ảo

Thêm từ khóa virtual vào phương thức của lớp cha. Giờ lời gọi được xác định lúc chạy dựa trên kiểu thật của đối tượng, đó là dynamic dispatch.

Một vòng lặp qua Animal*, ba hành vi khác nhau. Con trỏ lớp cha "biết" kiểu thật lúc chạy và gọi đúng hàm tương ứng. Chính cơ chế này, một giao diện với nhiều cách cài đặt, là ý nghĩa của tính đa hình (polymorphism) trong C++.

Lưu ý virtual chỉ cần xuất hiện ở khai báo trong lớp cha; một khi hàm đã là hàm ảo, nó tự động vẫn là hàm ảo trong mọi lớp con. Viết lại từ khóa này ở lớp con là không bắt buộc và thừa.

Luôn dùng từ khóa override

Trong ví dụ trên, mỗi phương thức của lớp con đều được đánh dấu override. Code vẫn chạy nếu thiếu nó, nhưng bạn nên coi nó là bắt buộc. override (C++11) yêu cầu trình biên dịch xác minh rằng bạn thật sự đang ghi đè một hàm ảo của lớp cha với chữ ký khớp nhau. Nếu bạn viết sai chữ ký dù chỉ một chút, bạn sẽ nhận được lỗi rõ ràng thay vì một bug âm thầm.

struct Animal {
    virtual void speak() const { }   // note: const
};

struct Dog : Animal {
    void speak() { }            // NOT const - this is a NEW function, not an override!
    void speak() override { }   // error: 'speak' does not override - tells you immediately
};

Không có override, hàm speak() đầu tiên vẫn biên dịch bình thường nhưng không bao giờ được gọi qua Animal*, vì chữ ký của nó khác với lớp cha (thiếu const). Bạn có thể mất cả buổi chiều thắc mắc vì sao hàm ghi đè của mình không làm gì. Có override, trình biên dịch bắt được sự không khớp ngay lập tức. Hãy thêm nó vào mọi hàm ghi đè.

Hàm thuần ảo và lớp trừu tượng

Đôi khi lớp cha không có cách cài đặt mặc định hợp lý: một "Animal" chung chung thì kêu thế nào? Khi đó, hãy khai báo hàm là thuần ảo (pure virtual) bằng cách gán = 0. Hàm sẽ không có thân, và lớp trở thành lớp trừu tượng (abstract class), không thể tự tạo đối tượng. Nó chỉ tồn tại để định nghĩa một giao diện mà các lớp con phải thực hiện.

Mọi lớp con cụ thể phải cài đặt area(), nếu không nó cũng vẫn là lớp trừu tượng. Đây là cách C++ thể hiện "interface": một lớp trừu tượng chỉ có các hàm thuần ảo tương đương với interface trong các ngôn ngữ như Java.

Quy tắc hàm hủy ảo

Đây là cái bẫy mà ai cũng dính ít nhất một lần. Khi bạn delete một đối tượng qua con trỏ lớp cha, C++ gọi hàm hủy mà nó tìm thấy, và nếu hàm hủy đó không phải hàm ảo, nó chỉ chạy hàm hủy của lớp cha. Phần lớp con không bao giờ bị hủy, làm rò rỉ mọi thứ nó sở hữu. Chuẩn C++ gọi đây là hành vi không xác định.

Cách sửa chỉ là một từ: khai báo hàm hủy của lớp cha là virtual. Khi đó delete p chạy ~Derived trước, rồi đến ~Base, đúng như mong đợi.

struct Base {
    virtual ~Base() { cout << "~Base\n"; }   // correct
};
// now: ~Derived then ~Base

Quy tắc chung: ngay khi một lớp có bất kỳ hàm ảo nào, hãy cho nó cả hàm hủy ảo. Nếu một lớp được thiết kế làm lớp cha và dùng qua con trỏ, hàm hủy của nó phải là hàm ảo.

Lỗi thường gặp và cái bẫy

Thêm vài cái bẫy cần để ý khi bạn đã quen với hàm ảo:

Object slicing. Nếu bạn truyền hoặc lưu một đối tượng lớp con bằng giá trị vào một biến lớp cha, phần lớp con bị "cắt bỏ" và bạn chỉ còn một đối tượng lớp cha thuần, dynamic dispatch không còn gọi được hàm ghi đè. Hãy luôn dùng con trỏ hoặc tham chiếu cho tính đa hình:

Dog d;
Animal a = d;   // SLICED: a is now just an Animal, the Dog part is gone
a.speak();      // runs Animal::speak even though virtual

Animal& ref = d;   // OK: reference keeps the real type
ref.speak();       // runs Dog::speak

Đừng gọi hàm ảo trong hàm khởi tạo hoặc hàm hủy. Trong lúc khởi tạo, phần lớp con chưa tồn tại, nên lời gọi hàm ảo sẽ chạy phiên bản của lớp hiện tại chứ không phải hàm ghi đè của lớp con, hiếm khi là điều bạn muốn.

Dynamic dispatch có một chi phí nhỏ. Mỗi lời gọi hàm ảo đi qua một bảng con trỏ hàm ẩn (gọi là "vtable"), thêm một bước gián tiếp cho mỗi lời gọi. Chi phí rẻ nhưng không phải miễn phí, nên đừng biến một hàm thành hàm ảo trừ khi bạn thật sự cần ghi đè.

Cố ý gọi phiên bản của lớp cha. Bên trong một hàm ghi đè, bạn vẫn có thể gọi tường minh phần cài đặt của lớp cha bằng Base::method(), hữu ích khi hành vi của lớp con mở rộng chứ không thay thế lớp cha.

Tiếp theo: nạp chồng toán tử

Hàm ảo cho phép các đối tượng tùy biến hành vi thông qua một giao diện chung. Trang tiếp theo cho thấy cách tùy biến các toán tử tác động lên đối tượng của bạn: với nạp chồng toán tử (operator overloading), bạn có thể dạy các kiểu tự định nghĩa phản hồi với +, ==, << và nhiều toán tử khác, để Vector + Vector hay cout << myObject đọc tự nhiên như với các kiểu có sẵn.

Câu hỏi thường gặp

Hàm ảo trong C++ là gì?

Hàm ảo là một hàm thành viên được khai báo với từ khóa virtual trong lớp cha, để khi bạn gọi nó qua một con trỏ hoặc tham chiếu lớp cha, C++ chạy phiên bản ghi đè của lớp con thay vì phiên bản của lớp cha. Việc chọn hàm lúc chạy này gọi là dynamic dispatch và là nền tảng của tính đa hình.

Hàm ảo và hàm thuần ảo khác nhau thế nào?

Hàm ảo có thân hàm và có thể bị ghi đè. Hàm thuần ảo được khai báo với = 0 và không có thân hàm trong lớp cha: nó buộc mọi lớp con cụ thể phải cung cấp phần cài đặt. Bất kỳ lớp nào có ít nhất một hàm thuần ảo đều là lớp trừu tượng và không thể tạo đối tượng trực tiếp.

Vì sao lớp cha cần hàm hủy ảo trong C++?

Nếu bạn delete một đối tượng lớp con qua con trỏ lớp cha mà hàm hủy của lớp cha không phải hàm ảo, chỉ hàm hủy của lớp cha được chạy: phần lớp con không bao giờ được dọn dẹp, gây rò rỉ tài nguyên và là hành vi không xác định. Hãy khai báo hàm hủy là virtual cho mọi lớp được dùng theo kiểu đa hình.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu