Trong phần trước, bạn đã tìm hiểu tất cả về cách sử dụng tính kế thừa để kế thừa lại mọi thứ của lớp cơ sở cho lớp dẫn xuất. Trong chương này, chúng ta sẽ tập trung vào một trong những khía cạnh quan trọng và mạnh mẽ nhất của kế thừa – đó là các hàm ảo(Virtual Functions).

Nhưng trước khi chúng ta thảo luận về các hàm ảo(Virtual Functions) là gì, trước tiên, hãy tìm hiểu lý do tại sao chúng ta cần nó.

Trong phần về xây dựng các lớp dẫn xuất, bạn đã học được rằng khi bạn tạo một lớp dẫn xuất, nó bao gồm nhiều phần: một phần từ lớp được kế thừa và một phần từ chính nó.

Ví dụ một trường hợp đơn giản:

/**
* 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 <string_view>
 
class Base
{
protected:
    int m_value;
 
public:
    Base(int value)
        : m_value{ value }
    {
    }
 
    std::string_view getName() const { return "Base"; }
    int getValue() const { return m_value; }
};
 
class Derived: public Base
{
public:
    Derived(int value)
        : Base{ value }
    {
    }
 
    std::string_view getName() const { return "Derived"; }
    int getValueDoubled() const { return m_value * 2; }
};

Khi chúng ta tạo một đối tượng Derived, nó chứa một phần của Base (được xây dựng trước) và một phần Derived (được xây dựng thứ hai). Hãy nhớ rằng sự kế thừa ngụ ý một mối quan hệ một-là giữa hai lớp. Vì Derived là một Base, nên có nghĩa là Derived chứa một phần Base.

1. Con trỏ, tham chiếu và các lớp dẫn xuất

Nó khá trực quan khi chúng ta có thể thiết lập các con trỏ và tham chiếu đến các đối tượng Derived:

/**
* 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>
 
int main()
{
    Derived derived{ 5 };
    std::cout << "derived is a " << derived.getName() << " and has value " << derived.getValue() << '\n';
 
    Derived &rDerived{ derived };
    std::cout << "rDerived is a " << rDerived.getName() << " and has value " << rDerived.getValue() << '\n';
 
    Derived *pDerived{ &derived };
    std::cout << "pDerived is a " << pDerived->getName() << " and has value " << pDerived->getValue() << '\n';
 
    return 0;
}

Kết quả in ra:

derived is a Derived and has value 5
rDerived is a Derived and has value 5
pDerived is a Derived and has value 5

Tuy nhiên, vì Derived có một phần Base, nên một câu hỏi thú vị hơn là liệu C++ có cho phép chúng ta đặt một con trỏ Base hoặc tham chiếu đến một đối tượng đã tạo hay không. Thực chất thì chúng ta có thể!

/**
* 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>
 
int main()
{
    Derived derived{ 5 };
 
    // These are both legal!
    Base &rBase{ derived };
    Base *pBase{ &derived };
 
    std::cout << "derived is a " << derived.getName() << " and has value " << derived.getValue() << '\n';
    std::cout << "rBase is a " << rBase.getName() << " and has value " << rBase.getValue() << '\n';
    std::cout << "pBase is a " << pBase->getName() << " and has value " << pBase->getValue() << '\n';
 
    return 0;
}

Điều này tạo ra kết quả:

derived is a Derived and has value 5
rBase is a Base and has value 5
pBase is a Base and has value 5

Kết quả này có thể không hoàn toàn như những gì bạn mong đợi lúc đầu!

Nó chỉ ra rằng vì rBase và pBase là một tham chiếu và con trỏ base, nên họ chỉ có thể thấy các thành viên của Base (hoặc bất kỳ lớp nào mà Base kế thừa). Vì vậy, con trỏ / tham chiếu base sẽ không thể thấy Derived :: getName(). Do đó, họ sẽ gọi Base :: getName(), đó là lý do tại sao rBase và pBase được rằng họ là một Base chứ không phải là Derived.

Lưu ý rằng điều này cũng có nghĩa là không thể gọi Derived :: getValueDoubled() bằng cách sử dụng rBase hoặc pBase. Họ không thể nhìn thấy bất cứ điều gì trong Derived.

Ở đây, một ví dụ khác phức tạp hơn một chút mà chúng ta sẽ xây dựng trong bài học tiếp theo:

/**
* 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>
#include <string>
 
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"; }
};
 
int main()
{
    Cat cat{ "Fred" };
    std::cout << "cat is named " << cat.getName() << ", and it says " << cat.speak() << '\n';
 
    Dog dog{ "Garbo" };
    std::cout << "dog is named " << dog.getName() << ", and it says " << dog.speak() << '\n';
 
    Animal *pAnimal{ &cat };
    std::cout << "pAnimal is named " << pAnimal->getName() << ", and it says " << pAnimal->speak() << '\n';
 
    pAnimal = &dog;
    std::cout << "pAnimal is named " << pAnimal->getName() << ", and it says " << pAnimal->speak() << '\n';
 
    return 0;
}

Điều này tạo ra kết quả:

cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
pAnimal is named Fred, and it says ???
pAnimal is named Garbo, and it says ???

Chúng tôi thấy vấn đề tương tự ở đây. Bởi vì pAnimal là một con trỏ Animal, nó chỉ có thể nhìn thấy phần Animal. Do đó, pAnimal-> speak() gọi Animal :: speak() chứ không phải là hàm Dog :: speak() hoặc Cat :: speak().

2. Sử dụng cho con trỏ và tham chiếu đến các lớp cơ sở

Bây giờ bạn có thể nói, những ví dụ trên có vẻ ngớ ngẩn. Tại sao tôi lại đặt con trỏ hoặc tham chiếu đến lớp cơ sở của đối tượng dẫn xuất trong khi tôi có thể sử dụng đối tượng dẫn xuất?.

Đầu tiên, hãy để nói rằng bạn muốn viết một hàm in tên và âm thanh của động vật. Không sử dụng con trỏ tới lớp cơ sở, bạn phải viết nó bằng cách sử dụng các hàm quá tải, như thế này:

void report(const Cat &cat)
{
    std::cout << cat.getName() << " says " << cat.speak() << '\n';
}
 
void report(const Dog &dog)
{
    std::cout << dog.getName() << " says " << dog.speak() << '\n';
}

Không quá khó, nhưng hãy xem xét điều gì sẽ xảy ra nếu chúng ta có 30 loại động vật khác nhau thay vì 2. Bạn đã phải viết 30 chức năng gần như giống hệt nhau! Thêm vào đó, nếu bạn đã từng thêm một loại động vật mới, bạn cũng phải viết một hàm mới cho loại động vật đó. Đây là một sự lãng phí rất lớn thời gian khi xem xét sự khác biệt thực sự duy nhất là loại tham số.

Tuy nhiên, vì Cat và Dog có nguồn gốc từ Animal, Cat và Dog có một phần Animal. Do đó, điều hợp lý là chúng ta sẽ có thể làm một cái gì đó như thế này:

void report(const Animal &rAnimal)
{
    std::cout << rAnimal.getName() << " says " << rAnimal.speak() << '\n';
}

Điều này sẽ cho phép chúng ta sử dụng trong bất kỳ lớp nào có nguồn gốc từ Animal, ngay cả những lớp mà chúng ta đã tạo sau khi chúng ta viết hàm! Thay vì một hàm cho mỗi lớp dẫn xuất, chúng ta có một hàm hoạt động với tất cả các lớp có nguồn gốc từ Animal!

Tất nhiên, vấn đề là bởi vì rAnimal là một tham chiếu của Animal, rAnimal.speak() sẽ gọi Animal :: speak() thay vì phiên bản dẫn xuất của speak().

Thứ hai, hãy nói với bạn rằng bạn có 3 con mèo và 3 con chó mà bạn muốn nuôi trong một mảng để dễ dàng truy cập. Vì các mảng chỉ có thể chứa các đối tượng của một loại, không có con trỏ hoặc tham chiếu đến lớp cơ sở, nên bạn phải tạo một mảng khác nhau cho mỗi loại dẫn xuất, như sau:

/**
* 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>
 
int main()
{
    Cat cats[]{ { "Fred" }, { "Misty" }, { "Zeke" } };
    Dog dogs[]{ { "Garbo" }, { "Pooky" }, { "Truffle" } };
 
    for (const auto &cat : cats)
        std::cout << cat.getName() << " says " << cat.speak() << '\n';
 
    for (const auto &dog : dogs)
        std::cout << dog.getName() << " says " << dog.speak() << '\n';
 
    return 0;
}

Bây giờ, hãy xem xét những gì sẽ xảy ra nếu bạn có 30 loại động vật khác nhau. Bạn cần 30 mảng, một mảng cho mỗi loại động vật!

Tuy nhiên, vì cả Cat và Dog đều có nguồn gốc từ Animal, nên có ý nghĩa rằng chúng ta sẽ có thể làm một cái gì đó như thế này:

#include <iostream>
 
int main()
{
    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';
 
    return 0;
}

Trong khi điều này biên dịch và thực thi, thật không may, thực tế là mỗi phần tử của mảng animal là một con trỏ đến Animal có nghĩa là animal[iii] -> speak() sẽ gọi Animal :: speak() thay vì phiên bản của lớp dẫn xuất () mà chúng tôi muốn.

Mặc dù cả hai kỹ thuật này có thể giúp chúng ta tiết kiệm rất nhiều thời gian và năng lượng, nhưng chúng có cùng một vấn đề. Con trỏ hoặc tham chiếu đến lớp cơ sở để chỉ gọi hàm của lớp cơ sở chứ không phải là lớp dẫn xuất. Nhưng chúng ta có một cách để giúp các tham chiếu , con trỏ của lớp cơ sở gọi các hàm dẫn xuất, thay vì gọi các hàm của lớp cơ sở.

Đó chính là sử dụng hàm ảo(Virtual Functions). Vậy nó là gì? Chúng ta hãy cùng tìm hiểu trong bài tiếp theo.

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