25.1 — Wskaźniki i odniesienia do klasy bazowej obiektów pochodnych

W poprzednim rozdziale nauczyłeś się wszystkiego o tym, jak używać dziedziczenia do wyprowadzania nowych klas z istniejących klas. W tym rozdziale skupimy się na jednym z najważniejszych i najpotężniejszych aspektów dziedziczenia — funkcjach wirtualnych.

Ale zanim omówimy, czym są funkcje wirtualne, ustalmy najpierw, dlaczego ich potrzebujemy.

W rozdziale o konstruowaniu klas pochodnych dowiedziałeś się, że gdy tworzysz klasę pochodną, składa się ona z wielu części: po jednej dla każdej dziedziczonej klasę i część dla siebie.

Na przykład oto prosty przypadek:

#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; }
};

Kiedy tworzymy obiekt pochodny, zawiera on część bazową (która jest konstruowana jako pierwsza) i część pochodna (która jest konstruowana jako druga). Pamiętaj, że dziedziczenie implikuje relację „jest” pomiędzy dwiema klasami. Ponieważ Derived jest bazą, właściwe jest, aby Derived zawierał część Base.

Wskaźniki, referencje i klasy pochodne

Powinno być dość intuicyjne, że możemy ustawić Derived wskaźniki i odniesienia do obiektów Derived:

#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;
}

To daje następujący wynik:

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

Jednakże ponieważ Derived ma część Base, bardziej interesującym pytaniem jest, czy C++ pozwoli nam ustawić wskaźnik bazowy lub odniesienie do obiektu pochodnego. Okazuje się, że tak!

#include <iostream>

int main()
{
    Derived derived{ 5 };

    // These are both legal!
    Base& rBase{ derived }; // rBase is an lvalue reference (not an rvalue reference)
    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;
}

Daje to wynik:

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

Ten wynik może nie być taki, jakiego na początku oczekiwałeś!

Okazuje się, że ponieważ rBase i pBase są referencją i wskaźnikiem Base, mogą widzieć tylko elementy członkowskie Base (lub dowolne klasy odziedziczone przez Base). Zatem mimo że Derived::getName() przesłania (ukrywa) Base::getName() dla obiektów Derived, wskaźnik/odniesienie Base nie może zobaczyć Derived::getName(). W rezultacie wywołują Base::getName() i dlatego rBase i pBase zgłaszają, że są bazą, a nie pochodną.

Zauważ, że oznacza to również, że nie jest możliwe wywołanie Derived::getValueDoubled() przy użyciu rBase lub pBase. Nie są w stanie niczego zobaczyć w Derived.

Oto kolejny nieco bardziej złożony przykład, na którym będziemy opierać się w następnej lekcji:

#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(std::string_view name)
        : m_name{ name }
    {
    }
    
    // To prevent slicing (covered later)
    Animal(const Animal&) = delete;
    Animal& operator=(const Animal&) = delete;

public:
    std::string_view getName() const { return m_name; }
    std::string_view speak() const { return "???"; }
};

class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const { return "Woof"; }
};

int main()
{
    const Cat cat{ "Fred" };
    std::cout << "cat is named " << cat.getName() << ", and it says " << cat.speak() << '\n';

    const Dog dog{ "Garbo" };
    std::cout << "dog is named " << dog.getName() << ", and it says " << dog.speak() << '\n';

    const 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;
}

Daje to wynik:

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 ???

Widzimy tutaj ten sam problem. Ponieważ pAnimal jest wskaźnikiem Animal, widzi tylko część klasy Animal. W związku z tym pAnimal->speak() wywołuje funkcję Animal::speak() zamiast funkcji Dog::Speak() lub Cat::speak().

Używaj do wskaźników i odniesień do klas bazowych

Teraz możesz powiedzieć: "Powyższe przykłady wydają się trochę głupie. Po co miałbym ustawiać wskaźnik lub odniesienie do klasy bazowej obiektu pochodnego, skoro mogę po prostu użyć obiektu pochodnego?" Okazuje się, że jest kilka dobrych powodów.

Załóżmy, że chcesz napisać funkcję, która wyświetli nazwę i dźwięk zwierzęcia. Bez użycia wskaźnika do klasy bazowej musiałbyś napisać ją przy użyciu przeciążonych funkcji, takich jak ta:

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';
}

Niezbyt trudne, ale zastanów się, co by się stało, gdybyśmy mieli 30 różnych typów zwierząt zamiast 2. Musiałbyś napisać 30 prawie identycznych funkcji! Poza tym, jeśli kiedykolwiek dodałeś nowy typ zwierzęcia, musiałbyś napisać nową funkcję również dla niego. Jest to ogromna strata czasu, biorąc pod uwagę, że jedyną rzeczywistą różnicą jest typ parametru.

Jednakże, ponieważ Kot i Pies pochodzą od Zwierzę, Kot i Pies mają część Zwierzę. Dlatego sensowne jest, że powinniśmy móc zrobić coś takiego:

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

Pozwoliłoby nam to przekazać dowolną klasę wywodzącą się z Animal, nawet tę, którą utworzyliśmy po napisaniu funkcji! Zamiast jednej funkcji na klasę pochodną otrzymujemy jedną funkcję, która działa ze wszystkimi klasami wywodzącymi się z Animal!

Problem polega oczywiście na tym, że ponieważ rAnimal jest referencją do Animal, rAnimal.speak() wywoła Animal::speak() zamiast pochodnej wersjipeak().

Na marginesie…

Możemy również użyć funkcji szablonu, aby zmniejszyć liczbę przeciążonych funkcji, które musimy wykonać napisz:

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

I chociaż to działa, ma swoje własne problemy:

  1. Nie jest jasne, jaki typ T ma być, ponieważ zgubiliśmy dokumentację, która T ma być Animal.
  2. Ta funkcja nie wymusza tego, że T jest Animal. Raczej zaakceptuje obiekt dowolnego typu, który zawiera getName() i speak() funkcję składową, niezależnie od tego, czy ma to sens, czy nie.

Po drugie, powiedzmy, że masz 3 koty i 3 psy, które chcesz umieścić w tablicy, aby zapewnić łatwy dostęp. Ponieważ tablice mogą przechowywać tylko obiekty jednego typu, bez wskaźnika lub odniesienia do klasy bazowej, dla każdego typu pochodnego należy utworzyć inną tablicę, na przykład:

#include <array>
#include <iostream>

// Cat and Dog from the example above

int main()
{
    const auto& cats{ std::to_array<Cat>({{ "Fred" }, { "Misty" }, { "Zeke" }}) };
    const auto& dogs{ std::to_array<Dog>({{ "Garbo" }, { "Pooky" }, { "Truffle" }}) };
    
    // Before C++20
    // const std::array<Cat, 3> cats{{ { "Fred" }, { "Misty" }, { "Zeke" } }};
    // const std::array<Dog, 3> 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;
}

Zastanów się teraz, co by się stało, gdybyś miał 30 różnych typów zwierząt. Potrzebowałbyś 30 tablic, po jednej dla każdego typu zwierzęcia!

Jednakże ponieważ zarówno Cat, jak i Dog pochodzą od Animal, logiczne jest, że powinniśmy móc zrobić coś takiego:

#include <array>
#include <iostream>

// Cat and Dog from the example above

int main()
{
    const Cat fred{ "Fred" };
    const Cat misty{ "Misty" };
    const Cat zeke{ "Zeke" };

    const Dog garbo{ "Garbo" };
    const Dog pooky{ "Pooky" };
    const Dog truffle{ "Truffle" };

    // Set up an array of pointers to animals, and set those pointers to our Cat and Dog objects
    const auto animals{ std::to_array<const Animal*>({&fred, &garbo, &misty, &pooky, &truffle, &zeke }) };
    
    // Before C++20, with the array size being explicitly specified
    // const std::array<const Animal*, 6> animals{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };
    
    for (const auto animal : animals)
    {
        std::cout << animal->getName() << " says " << animal->speak() << '\n';
    }

    return 0;
}

Podczas kompilacji i wykonania, niestety fakt, że każdy element tablicy „animals” jest wskaźnikiem do Animal oznacza, że animal->speak() wywoła Animal::speak() zamiast klasy pochodnej wersję funkcjipeak(), którą chcemy. Wynik jest

Fred says ???
Garbo says ???
Misty says ???
Pooky says ???
Truffle says ???
Zeke says ???

Chociaż obie te techniki mogłyby zaoszczędzić nam dużo czasu i energii, mają ten sam problem. Wskaźnik lub odwołanie do klasy bazowej wywołuje wersję bazową funkcji, a nie wersję pochodną. Gdyby tylko istniał sposób, aby te wskaźniki bazowe wywoływały wersję pochodną funkcji zamiast wersji podstawowej…

Chcesz zgadnąć, do czego służą funkcje wirtualne? :)

Czas quizu

  1. Powyższy przykład zwierzęcia/kota/psa nie działa tak, jak byśmy sobie tego życzyli, ponieważ referencja lub wskaźnik do zwierzęcia nie może uzyskać dostępu do pochodnej wersji funkcjipeak() potrzebnej do zwrócenia właściwej wartości dla kota lub psa. Jednym ze sposobów obejścia tego problemu byłoby udostępnienie danych zwracanych przez funkcję mówić() jako części klasy bazowej Animal (podobnie jak nazwa zwierzęcia jest dostępna poprzez element m_name).

Zaktualizuj klasy Animal, Cat i Dog z powyższej lekcji, dodając nowego członka do klasy Animal o nazwie m_speak. Zainicjuj go odpowiednio. Następujący program powinien działać poprawnie:

#include <array>
#include <iostream>

int main()
{
    const Cat fred{ "Fred" };
    const Cat misty{ "Misty" };
    const Cat zeke{ "Zeke" };

    const Dog garbo{ "Garbo" };
    const Dog pooky{ "Pooky" };
    const Dog truffle{ "Truffle" };

    // Set up an array of pointers to animals, and set those pointers to our Cat and Dog objects
    const auto animals{ std::to_array<const Animal*>({ &fred, &garbo, &misty, &pooky, &truffle, &zeke }) };
    
    // Before C++20, with the array size being explicitly specified
    // const std::array<const Animal*, 6> animals{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };
    
    for (const auto animal : animals)
    {
        std::cout << animal->getName() << " says " << animal->speak() << '\n';
    }

    return 0;
}

Pokaż rozwiązanie

  1. Dlaczego powyższe rozwiązanie nie jest optymalne?

Wskazówka: Pomyśl o przyszłym stanie Kota i Psa, w którym chcemy różnicować Koty i Psy na więcej sposobów.
Wskazówka: Zastanów się, w jaki sposób posiadanie elementu, który musi być ustawiony podczas inicjalizacji, ogranicza Cię.

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
211 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze