18.1 – Указатели и ссылки базового класса на объекты производных классов

Добавлено 15 августа 2021 в 11:08

В предыдущей главе вы узнали, как использовать наследование для получения новых классов из существующих. В этой главе мы сосредоточимся на одном из самых важных и мощных аспектов наследования – на виртуальных функциях.

Но прежде чем мы обсудим, что такое виртуальные функции, давайте сначала определим, зачем они нам нужны.

В статье о создании объектов производных классов вы узнали, что когда вы создаете объект производного класса, он состоит из нескольких частей: по одной части для каждого наследуемого класса и часть для собственного класса.

Например, вот простой случай:

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

Когда мы создаем объект Derived, он содержит часть Base (которая создается первой) и часть Derived (которая создается второй). Помните, что наследование подразумевает между двумя классами связь «является чем-либо». Поскольку Derived «является» Base, уместно, чтобы объект Derived содержал часть Base.

Указатели, ссылки и производные классы

Установка указателей и ссылок типа Derived на объекты 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;
}

Это дает следующий результат:

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

Однако, поскольку Derived содержит часть Base, более интересный вопрос заключается в том, позволит ли C++ установить указатель или ссылку типа Base на объект Derived. Оказывается, мы можем это сделать!

#include <iostream>
 
int main()
{
    Derived derived{ 5 };
 
    // Оба этих случая допустимы!
    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;
}

Это дает следующий результат:

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

Этот результат может быть не совсем таким, как вы ожидали вначале!

Оказывается, поскольку rBase и pBase являются ссылкой и указателем типа Base, они могут видеть только члены класса Base (или любых классов, от которых Base наследуется). Таким образом, даже если Derived::getName() затеняет (скрывает) Base::getName() для объектов Derived, указатель/ссылка типа Base не может видеть Derived::getName(). Следовательно, они вызывают Base::getName(), поэтому rBase и pBase сообщают, что они являются объектами Base, а не Derived.

Обратите внимание, что это также означает, что с помощью rBase или pBase нельзя вызвать Derived::getValueDoubled(). Они ничего не видят в классе Derived.

Вот пример чуть сложнее, который мы рассмотрим в следующем уроке:

#include <iostream>
#include <string_view>
#include <string>
 
class Animal
{
protected:
    std::string m_name;
 
    // Мы делаем этот конструктор защищенным, потому что
    // не хотим, чтобы люди создавали объекты Animal напрямую,
    // но мы по-прежнему хотим, чтобы производные классы могли
    // его использовать.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }
    
    // Для предотвращения "нарезки" (будет рассмотрено позже)
    Animal(const Animal&) = default;
    Animal& operator=(const Animal&) = default;
 
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;
}

Этот дает следующий результат:

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

Мы видим здесь ту же проблему. Поскольку pAnimal является указателем Animal, он может видеть только часть, относящуюся к классу Animal. Следовательно, pAnimal->speak() вызывает функцию Animal::speak(), а не Dog::speak() или Cat::speak().

Использование указателей и ссылок на базовые классы

Теперь вы можете сказать: «Приведенные выше примеры кажутся глупыми. Зачем мне устанавливать указатель или ссылку базового класса на объект производного класса, если я могу просто использовать объект производного класса? » Оказывается, на то есть немало веских причин.

Во-первых, допустим, вы хотите написать функцию, которая печатала бы имя и звук животного. Без использования указателя базового класса вам придется писать это с помощью перегруженных функций, например:

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

Не так уж сложно, но подумайте, что бы произошло, если бы вместо двух у нас было 30 различных типов животных. Вам бы пришлось написать 30 почти идентичных функций! Кроме того, если вы когда-нибудь добавите новый тип животных, вам придется написать новую функцию и для него. Это огромная трата времени, учитывая, что единственная реальная разница – это тип параметра.

Однако, поскольку класс Cat и Dog являются производными от Animal, классы Cat и Dog содержат часть, относящуюся Animal. Следовательно, имеет смысл сделать что-то вроде этого:

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

Это позволит нам передавать объекты любых классов, производных от Animal, даже тех, которые мы создали после того, как написали эту функцию! Вместо отдельной функции на каждый производный класс мы получаем одну функцию, которая работает со всеми классами, производными от Animal!

Проблема, конечно, в том, что, поскольку rAnimal является ссылкой на Animal, rAnimal.speak() будет вызывать Animal::speak() вместо производной версии speak().

Во-вторых, предположим, что у вас есть 3 кошки и 3 собаки, которых вы хотите поместить в массив для облегчения доступа к ним. Поскольку массивы могут содержать объекты только одного типа, без указателей или ссылок базового класса вам придется создавать разные массивы для каждого производного типа, например:

#include <array>
#include <iostream>
 
// классы Cat и Dog из примера выше
 
int main()
{
    const auto& cats{ std::to_array<Cat>({{ "Fred" }, { "Misty" }, { "Zeke" }}) };
    const auto& dogs{ std::to_array<Dog>({{ "Garbo" }, { "Pooky" }, { "Truffle" }}) };
    
    // До 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;
}

А теперь подумайте, что бы произошло, если бы у вас было 30 разных видов животных. Вам понадобится 30 наборов, по одному на каждый вид животных!

Однако, поскольку и Cat, и Dog являются производными от Animal, имеет смысл сделать что-то вроде этого:

#include <array>
#include <iostream>
 
// Классы Cat и Dog из примера выше
 
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" };
 
    // Создаем массив указателей на животных и устанавливаем
    // эти указатели на наши объекты Cat и Dog
    const auto animals{ std::to_array<const Animal*>({&fred, &garbo, &misty,
                                                      &pooky, &truffle, &zeke }) };
    
    // До C++ 20 с явно указанным размером массива
    // 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;
}

Хотя это компилируется и выполняется, но, к сожалению, тот факт, что каждый элемент массива animals является указателем на Animal, означает, что animal->speak() будет вызывать Animal::speak() вместо версии speak() производного класса, которая нам нужна. На выходе мы получаем:

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

Хотя оба этих метода могут сэкономить нам много времени и энергии, у них одна и та же проблема. Указатель или ссылка на базовый класс вызывает базовую версию функции, а не производную версию. Если бы только был способ заставить эти указатели базового класса вызывать производную версию функции вместо базовой версии...

Можете догадаться, для чего нужны виртуальные функции? :)

Небольшой тест

Вопрос 1

Наш приведенный выше пример с Animal/Cat/Dog не работает так, как мы хотим, потому что ссылка или указатель на Animal не может получить доступ к производной версии speak(), необходимой для возврата значения, правильного для Cat или Dog. Один из способов обойти эту проблему – сделать данные, возвращаемые функцией speak(), доступными как часть базового класса Animal (так же, как название животного доступно через член m_name).

Обновите классы Animal, Cat и Dog из примера выше, добавив в Animal новый член с именем m_speak. Инициализируйте его соответствующим образом. Следующая программа должна работать правильно:

#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" };
 
    // Создаем массив указателей на животных и устанавливаем
    // эти указатели на наши объекты Cat и Dog
    const auto animals{ std::to_array<const Animal*>({ &fred, &garbo, &misty,
                                                       &pooky, &truffle, &zeke }) };
    
    // До C++ 20 с явно указанным размером массива
    // 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;
}

#include <array>
#include <string>
#include <string_view>
#include <iostream>
 
class Animal
{
protected:
    std::string m_name;
    std::string m_speak;
 
    // Мы делаем этот конструктор защищенным, потому что
    // не хотим, чтобы люди создавали объекты Animal напрямую,
    // но мы по-прежнему хотим, чтобы производные классы могли
    // его использовать.
    Animal(std::string_view name, std::string_view speak)
        : m_name{ name }, m_speak{ speak }
    {
    }
    
    // Для предотвращения "нарезки" (будет рассмотрено позже)
    Animal(const Animal&) = delete;
    Animal& operator=(const Animal&) = delete;
 
public:
    const std::string_view getName() const { return m_name; }
    const std::string_view speak() const { return m_speak; }
};
 
class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name, "Meow" }
    {
    }
};
 
class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name, "Woof" }
    {
    }
};
 
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" };
 
    // Создаем массив указателей на животных и устанавливаем
    // эти указатели на наши объекты Cat и Dog
    const auto animals{ std::to_array<const Animal*>({ &fred, &garbo, &misty,
                                                       &pooky, &truffle, &zeke }) };
    
    // До C++ 20 с явно указанным размером массива
    // const std::array<const Animal*, 6> animals{ &fred, &garbo, &misty,
    //                                             &pooky, &truffle, &zeke };
    
    // animal не является ссылкой, потому что мы перебираем указатели
    for (const auto animal : animals)
    {
        std::cout << animal->getName() << " says " << animal->speak() << '\n';
    }
 
    return 0;
}

Вопрос 2

Почему это решение неоптимально?

Подсказка: подумайте о будущем состоянии классов Cat и Dog, в котором мы хотим различать кошек и собак большим количеством способов.

Подсказка: подумайте о том, как ограничивает вас наличие члена, который должен быть установлен при инициализации.

Текущее решение не является оптимальным, потому что нам нужно добавить новый член для каждого способа, которым мы хотим различать классы Cat и Dog. Со временем наш класс Animal может стать довольно сложным и большим с точки зрения используемой памяти!

Кроме того, это решение работает только в том случае, если член базового класса может быть определен во время инициализации. Например, если speak() возвращает случайный результат для каждого объекта Animal (например, вызов Dog::speak() может возвращать "woof", "arf" или "yip"), такого рода решения начинают становиться неудобными и разваливаются.

Теги

C++ / CppLearnCppВиртуальная функцияДля начинающихКласс (программирование)НаследованиеОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.