Урок №162. Указатели, Ссылки и Наследование

  Юрий  | 

  |

  Обновл. 15 Сен 2021  | 

 46562

 ǀ   5 

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

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

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

При создании объекта класса Child сначала выполняется построение части Parent, а затем уже части Child. Помните, что тип отношений в наследовании — «является». Поскольку Child «является» Parent, то логично, что Child содержит часть Parent.

Мы можем дать команду указателям и ссылкам класса Child указывать на другие объекты класса Child:

Результат:

child is a Child and has value 7
rChild is a Child and has value 7
pChild is a Child and has value 7

Интересно, поскольку Child имеет часть Parent, то можем ли мы дать команду указателю или ссылке класса Parent указывать на объект класса Child? Оказывается, можем!

Результат:

child is a Child and has value 7
rParent is a Parent and has value 7
pParent is a Parent and has value 7

Но это может быть не совсем то, что вы ожидали увидеть!

Поскольку rParent и pParent являются ссылкой и указателем класса Parent, то они могут видеть только члены класса Parent (и члены любых других классов, которые наследует Parent). Таким образом, указатель/ссылка класса Parent не может увидеть Child::getName(). Следовательно, вызывается Parent::getName(), а rParent и pParent сообщают, что они относятся к классу Parent, а не к классу Child.

Обратите внимание, это также означает, что невозможно вызвать Child::getValueDoubled() через rParent или pParent. Они не могут видеть что-либо в классе Child.

Вот еще один более сложный пример:

Результат выполнения программы:

cat is named Matros, and it says Meow
dog is named Barsik, and it says Woof
pAnimal is named Matros, and it says ???
pAnimal is named Barsik, and it says ???

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

Указатели, ссылки и родительские классы


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

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

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

Однако, поскольку Cat и Dog наследуют Animal, Cat и Dog имеют часть Animal, поэтому мы можем сделать следующее:

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

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

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

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

Однако, поскольку Cat и Dog наследуют Animal, то можно сделать следующее:

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

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

Угадайте теперь, зачем нужны виртуальные функции? 🙂

Тест

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

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

Ответ

Примечание: Вы также можете сделать m_speak типа std::string, но недостатком будет то, что каждый объект класса Animal будет содержать лишнюю копию строки speak, а построение объектов Animal займет больше времени, так как глубокое копирование std::string будет выполняться медленнее, нежели копирование указателя, указывающего на константную строку C-style.


Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (170 оценок, среднее: 4,86 из 5)
Загрузка...

Комментариев: 5

  1. Максим:

    В примечании к тесту указано, что для члена m_speak предпочтительнее использовать тип const char*, чтобы не плодить лишние копии строки speak, и глубокое копирование std::string будет выполняться медленнее.
    Но в то же время в классе есть член m_name, который имеет тип std::string со всеми вытекающими недостатками.
    Не правильнее было бы, используя данные доводы, сделать все члены типа const char* ?
    Или же с членом m_name "под капотом" происходят другие процессы, отличные от члена m_speak?

    1. Леонид К.:

      Да, как-то неоднозначно получается. Используются и const char* и std::string для одних и тех же членов класса.
      К тому же в конце 79 урока жирным шрифтом констатируется:
      Правило: Используйте std::string вместо строк C-style.
      И в этом весь C++ )))

      1. Петр:

        Ну и причем же тут C++?

        Каждому инструменту свое место. Не важно, C++ у вас или Python, или еще что. Вы в любом случае должны знать весь инструментарий, который дает язык и уметь адекватно его применять.

        1. Леонид К.:

          Может быть тогда так:

    2. Петр:

      Разница в том, что m_name хранит *копию* имени, переданного *извне* (т.е. заданного пользователем класса), а для m_speak автор заложил неявный контракт, что его инициализация будет происходить только в наследнике и больше нигде, и только таким безопасным способом, как использование строкового литерала. Таким образом он слегка сэкономил на спичках.

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *