Trong bài học trước về con trỏ và tham chiếu đến lớp cơ sở của các đối tượng dẫn xuất, chúng ta đã xem xét một số ví dụ trong đó sử dụng con trỏ hoặc tham chiếu đến lớp cơ sở có hàm đơn giản. Tuy nhiên, trong mọi trường hợp, chúng ta gặp phải vấn đề là con trỏ cơ sở hoặc tham chiếu chỉ có thể gọi lớp cơ sở của hàm, không phải là lớp dẫn xuất.

/**
* 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 <string_view>
 
class Base
{
public:
    std::string_view getName() const { return "Base"; }
};
 
class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
};
 
int main()
{
    Derived derived;
    Base &rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

Ví dụ này in ra kết quả:

rBase is a Base

Vì rBase là một tham chiếu của Base, nên nó gọi Base :: getName(), mặc dù nó thực sự tham chiếu tới Base của một đối tượng đã tạo.

Trong bài học này, chúng ta sẽ chỉ cho bạn cách giải quyết vấn đề này bằng các hàm ảo.

1. Hàm ảo và đa hình

Hàm ảo là một loại hàm đặc biệt, khi được gọi, sẽ tự động hiểu và chọn đúng đối tượng gốc để gọi đúng hàm của đối tượng đó giữa lớp cơ sở và lớp dẫn xuất. Khả năng này được gọi là đa hình. Hàm dẫn xuất được coi là khớp với lớp cơ sở nếu nó có cùng tên, loại tham số (cho dù đó là const) và kiểu trả về của hàm trong lớp cơ sở. Các hàm như vậy được gọi là ghi đè(overriding).

Để tạo một hàm ảo, chỉ cần đặt từ khóa virtual trước khi khai báo hàm.

Dưới đây, ví dụ trên với một hàm ảo:

/**
* 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 <string_view>
 
class Base
{
public:
    virtual std::string_view getName() const { return "Base"; } // note addition of virtual keyword
};
 
class Derived: public Base
{
public:
    virtual std::string_view getName() const { return "Derived"; }
};
 
int main()
{
    Derived derived;
    Base &rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

Ví dụ này in kết quả:

rBase is a Derived

Vì rBase là một tham chiếu đến lớp Base của một đối tượng đã tạo, khi rBase.getName() được thực thi, thông thường nó sẽ gọi Base :: getName(). Tuy nhiên, Base :: getName() là ảo, nên chương trình sẽ xem xét liệu và tìm đúng đối tượng dẫn xuất đã kế thừa hàm ảo đó không. Trong trường hợp này, nó sẽ tìm thấy đối tượng Derived và sẽ gọi Derived :: getName =()!

Hãy cùng xem một ví dụ phức tạp hơn một chút:

#include <iostream>
#include <string_view>
 
class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};
 
class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};
 
class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};
 
class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};
 
int main()
{
    C c;
    A &rBase{ c };
    std::cout << "rBase is a " << rBase.getName() << '\n';
 
    return 0;
}

Bạn nghĩ chương trình này sẽ ra sao?

Hãy nhìn vào cách thức hoạt động của nó. Đầu tiên, chúng ta khởi tạo một đối tượng lớp C. rBase là một tham chiếu A, mà chúng ta đặt để tham chiếu phần A của đối tượng C. Cuối cùng, chúng ta gọi rBase.getName (). rBase.getName() ước tính thành A :: getName(). Tuy nhiên, A :: getName() là ảo, vì vậy trình biên dịch sẽ tìm và gọi đối tượng gốc nhất giữa A và C. Trong trường hợp này, đó là C :: getName(). Lưu ý rằng nó sẽ không gọi D :: getName(), vì đối tượng ban đầu của chúng ta là C, không phải D, do đó chỉ xem xét các hàm giữa A và C.

Kết quả là, chương trình của chúng ta sẽ in ra:

rBase is a C

2. Một ví dụ phức tạp hơn

Hãy để một cái nhìn khác về ví dụ Động vật mà chúng ta đã làm việc trong bài học trước. Ở đây, lớp học ban đầu, cùng với một số mã kiểm tra:

/**
* 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 <string>
#include <string_view>
 
class Animal
{
protected:
    std::string m_name;
 
    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(const std::string &name)
        : m_name{ name }
    {
    }
 
public:
    const std::string &getName() const { return m_name; }
    std::string_view speak() const { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(const std::string& name)
        : Animal{ name }
    {
    }
 
    std::string_view speak() const { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(const std::string &name)
        : Animal{ name }
    {
    }
 
    std::string_view speak() const { return "Woof"; }
};
 
void report(const Animal &animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}
 
int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };
 
    report(cat);
    report(dog);
 
    return 0;
}

Bản in này:

Fred says ???
Garbo says ???

Ở đây, lớp tương đương với hàm speak() được tạo ảo:

/**
* 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 <string>
#include <string_view>
 
class Animal
{
protected:
    std::string m_name;
 
    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(const std::string &name)
        : m_name{ name }
    {
    }
 
public:
    const std::string &getName() const { return m_name; }
    virtual std::string_view speak() const { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(const std::string &name)
        : Animal{ name }
    {
    }
 
    virtual std::string_view speak() const { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(const std::string& name)
        : Animal{ name }
    {
    }
 
    virtual std::string_view speak() const { return "Woof"; }
};
 
void report(const Animal &animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}
 
int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };
 
    report(cat);
    report(dog);
 
    return 0;
}

Chương trình này tạo ra kết quả:

Fred says Meow
Garbo says Woof

Nó hoạt động!

Khi đánh giá Animal.speak(), chương trình lưu ý rằng Animal :: speak() là một hàm ảo. Trong trường hợp động vật tham chiếu lớp Animal của vật thể Cat, chương trình sẽ xem xét tất cả các lớp giữa Animal và Cat để xem liệu nó có thể tìm thấy một hàm từ đối tượng gốc không. Trong trường hợp đó, nó tìm thấy Cat :: speak(). Trong trường hợp động vật tham chiếu lớp Animal của đối tượng Dog, chương trình sẽ gọi hàm Dog :: speak().

Lưu ý rằng chúng ta đã làm cho Animal :: getName() ảo. Điều này làm cho getName() không bao giờ bị ghi đè trong bất kỳ lớp dẫn xuất nào, do đó không cần.

Tương tự, ví dụ mảng sau đây hoạt động như mong đợi:

Cat fred{ "Fred" };
Cat misty{ "Misty" };
Cat zeke{ "Zeke" };
 
Dog garbo{ "Garbo" };
Dog pooky{ "Pooky" };
Dog truffle{ "Truffle" };
 
// Set up an array of pointers to animals, and set those pointers to our Cat and Dog objects
Animal *animals[]{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };
 
for (const auto *animal : animals)
    std::cout << animal->getName() << " says " << animal->speak() << '\n';

Tạo ra kết quả:

Fred says Meow
Garbo says Woof
Misty says Meow
Pooky says Woof
Truffle says Woof
Zeke says Meow

Mặc dù hai ví dụ này chỉ sử dụng Cat và Dog, bất kỳ lớp nào khác mà chúng ta lấy từ Animal cũng sẽ hoạt động với hàm report() và mảng animal mà không cần sửa đổi thêm! Đây có lẽ là lợi ích lớn nhất của các hàm ảo – khả năng cấu trúc code của bạn theo cách mà các lớp mới xuất phát sẽ tự động làm việc với code cũ mà không cần sửa đổi!

Một lời cảnh báo: các từ của hàm lớp dẫn xuất phải khớp chính xác với từ của hàm ảo lớp trong lớp cơ sở để sử dụng hàm trong lớp dẫn xuất. Nếu hàm lớp dẫn xuất có các loại tham số khác nhau, chương trình có thể vẫn sẽ biên dịch tốt, nhưng hàm ảo sẽ không gọi như mong đợi của chúng ta.

3. Sử dụng từ khóa virtual

Nếu một hàm được đánh dấu là virtual, tất cả các phần ghi đè phù hợp cũng được coi là virtual, ngay cả khi chúng không được đánh dấu rõ ràng như vậy. Tuy nhiên, việc có từ khóa virtual trên các hàm dẫn xuất không gây hại và nó đóng vai trò như một lời nhắc hữu ích rằng hàm này là một hàm ảo chứ không phải là một hàm bình thường. Do đó, nói chung là một ý tưởng tốt để sử dụng từ khóa virtual cho các hàm ảo trong các lớp dẫn xuất mặc dù nó không thực sự cần thiết.

4. Trả về các loại hàm ảo

Trong trường hợp bình thường, kiểu trả về của hàm ảo và hàm ghi đè của nó phải khớp. Hãy xem xét ví dụ sau:

class Base
{
public:
    virtual int getValue() const { return 5; }
};
 
class Derived: public Base
{
public:
    virtual double getValue() const { return 6.78; }
};

Trong trường hợp này, Derived :: getValue() không được coi là ghi đè phù hợp cho Base :: getValue() (nó được coi là một hàm hoàn toàn riêng biệt).

5. Không gọi các hàm ảo từ các hàm tạo hoặc hàm hủy

Ở đây, Bạn không nên gọi các hàm ảo từ các hàm tạo hoặc hàm hủy. Tại sao?

Hãy nhớ rằng khi một lớp Derived được tạo, phần Base được xây dựng trước. Nếu bạn đã gọi một hàm ảo từ hàm tạo cơ sở và phần lớp Derived thậm chí chưa được tạo, thì nó không thể gọi hàm của Derived vì không có đối tượng Derived được khởi tạo để gọi hàm. Trong C ++, nó sẽ gọi hàm trong class Base thay thế.

Một vấn đề tương tự tồn tại cho hàm huỷ. Nếu bạn gọi một hàm ảo trong hàm hủy của lớp Cơ sở, nó sẽ luôn gọi hàm của lớp Cơ sở, bởi vì phần Derived của lớp đã bị hủy.

Quy tắc: Không bao giờ gọi các hàm ảo từ các hàm tạo hoặc hàm hủy.

Bài tập thực hành về tính đa hình trong C++

6. Nhược điểm của các hàm ảo

Vì hầu hết thời gian bạn sẽ muốn các hàm của mình là ảo, tại sao không làm cho tất cả các hàm trở nên ảo? Câu trả lời là bởi vì nó không hiệu quả – việc giải quyết một cuộc gọi hàm ảo mất nhiều thời gian hơn là giải quyết một cuộc gọi thông thường. Hơn nữa, trình biên dịch cũng phải cấp phát một con trỏ phụ cho mỗi đối tượng lớp có một hoặc nhiều hàm ảo. Chúng tôi sẽ nói về điều này nhiều hơn trong các bài học trong tương lai trong của phần này.

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