Урок №156. Конструкторы и инициализация дочерних классов

  Юрий  | 

  |

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

 60514

 ǀ   20 

На предыдущих уроках мы изучили основы наследования в языке C++ и порядок инициализации дочерних классов. На этом уроке мы подробнее рассмотрим роль конструкторов в инициализации дочерних классов.

Конструкторы и инициализация

А помогать нам в этом будут классы Parent и Child:

С обычными (не дочерними) классами конструктору нужно заморачиваться только с членами своего класса. Например, объект класса Parent создается следующим образом:

Вот что на самом деле происходит при инициализации объекта parent:

   выделяется память для объекта parent;

   вызывается соответствующий конструктор класса Parent;

   список инициализации инициализирует переменные;

   выполняется тело конструктора;

   точка выполнения возвращается обратно в caller.

Всё довольно-таки просто. С дочерними классами дела обстоят несколько сложнее:

Вот что происходит при инициализации объекта child:

   выделяется память для объекта дочернего класса (достаточная порция памяти для части Parent и части Child объекта класса Child);

   вызывается соответствующий конструктор класса Child;

   создается объект класса Parent с использованием соответствующего конструктора класса Parent. Если такой конструктор программистом не предоставлен, то будет использоваться конструктор по умолчанию класса Parent;

   список инициализации инициализирует переменные;

   выполняется тело конструктора класса Child;

   точка выполнения возвращается обратно в caller.

Единственное различие между инициализацией объектов обычного и дочернего класса заключается в том, что при инициализации объекта дочернего класса, сначала выполняется конструктор родительского класса (для инициализации части родительского класса) и только потом уже выполняется конструктор дочернего класса.

Инициализация членов родительского класса


Одним из недостатков нашего дочернего класса Child является то, что мы не можем инициализировать m_id при создании объекта класса Child. Что, если мы хотим задать значение как для m_value (части Child), так и для m_id (части Parent)?

Новички часто пытаются решить эту проблему следующим образом:

Это хорошая попытка и почти правильная идея. Нам определенно нужно добавить еще один параметр в наш конструктор, иначе C++ не будет понимать, каким значением мы хотим инициализировать m_id.

Однако C++ запрещает дочерним классам инициализировать наследуемые переменные-члены родительского класса в списке инициализации своего конструктора. Другими словами, значение переменной может быть задано только в списке инициализации конструктора, принадлежащего тому же классу, что и переменная-член.

Почему C++ так делает? Ответ связан с константными переменными и ссылками. Подумайте, что произошло бы, если бы m_id был const. Поскольку константы должны быть инициализированы значениями при создании, то конструктор родительского класса должен установить это значение при создании переменной-члена. В то же время конструктор дочернего класса выполняется только после выполнения конструкторов родительского класса. Каждый дочерний класс имел бы тогда возможность инициализировать эту переменную, потенциально изменяя её значение! Ограничивая инициализацию переменных конструктором класса, к которому принадлежат эти переменные, язык C++ гарантирует, что все переменные будут инициализированы только один раз.

Конечным результатом выполнения кода, приведенного выше, является ошибка, так как m_id унаследован от класса Parent, а только ненаследуемые переменные-члены могут быть изменены в списке инициализации конструктора класса Child.

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

Хотя подобное действительно сработает в данном случае, но это не сработает, если m_id будет константой или ссылкой (поскольку константы и ссылки должны быть инициализированы в списке инициализации конструктора). Кроме того, это неэффективно, так как для m_id присваивают значение дважды: первый раз в списке инициализации конструктора класса Parent, а затем в теле конструктора класса Child. И, наконец, что, если классу Parent необходим доступ к этому значению во время инициализации?

Итак, как правильно инициализировать m_id при создании объекта класса Child?

Во всех наших примерах, при создании объекта класса Child, вызывался конструктор по умолчанию класса Parent. Почему так? Потому что мы не указывали иначе!

К счастью, язык C++ предоставляет нам возможность явно выбирать конструктор класса Parent для выполнения инициализации части Parent! Для этого нам необходимо просто добавить вызов нужного нам конструктора в списке инициализации конструктора дочернего класса:

Теперь при выполнении следующего кода:

Конструктор Parent(int) будет использоваться для инициализации m_id значением 7, а конструктор дочернего класса будет использоваться для инициализации m_value значением 1.5!

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

ID: 7
Value: 1.5

Рассмотрим детально, что происходит:

   Выделяется память для объекта child.

   Вызывается конструктор Child(double, int), где value = 1.5, а id = 7.

   Компилятор смотрит, запрашиваем ли мы какой-нибудь конкретный конструктор класса Parent. И видит, что запрашиваем! Поэтому вызывается Parent(int) с параметром id, которому мы до этого присвоили значение 7.

   Список инициализации конструктора класса Parent присваивает для m_id значение 7.

   Выполняется тело конструктора класса Parent, которое ничего не делает.

   Завершается выполнения конструктора класса Parent.

   Список инициализации конструктора класса Child присваивает для m_value значение 1.5.

   Выполняется тело конструктора класса Child, которое ничего не делает.

   Завершается выполнения конструктора класса Child.

Это может показаться несколько сложным, но на самом деле всё очень просто. Всё, что происходит — это вызов конструктором класса Child конкретного конструктора класса Parent для инициализации части Parent объекта класса Child. Поскольку m_id находится в части Parent, то только конструктор класса Parent может инициализировать это значение.

Обратите внимание, не имеет значения, где в списке инициализации конструктора класса Child вызывается конструктор класса Parent — он всегда будет выполняться первым.

Теперь мы можем сделать наши члены private

Теперь, когда мы знаем о инициализации членов родительского класса, нет никакой необходимости сохранять наши переменные-члены открытыми. Мы сделаем их private, как и должно быть.

В качестве напоминания: Доступ к членам public открыт для всех. Доступ к членам private открыт только для других членов этого же класса. Обратите внимание, это означает, что дочерние классы не могут напрямую обращаться к закрытым членам родительского класса! Дочерним классам нужно использовать геттеры и сеттеры для доступа к этим членам.

Например:

В коде, приведенном выше, мы делаем m_id и m_value закрытыми. Для их инициализации используются соответствующие конструкторы, а для доступа — открытые функции доступа (геттеры).

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

ID: 7
Value: 1.5

Еще один пример


Рассмотрим еще пару классов, с которыми мы работали ранее:

Как мы уже знаем, класс BasketballPlayer только инициализирует свои собственные члены и не указывает использование конкретного конструктора класса Human. Это означает, что каждый созданный объект класса BasketballPlayer будет использовать конструктор по умолчанию класса Human, который будет инициализировать переменную-член name пустым значением, а age — значением 0. Поскольку мы хотим назвать нашего BasketballPlayer и указать его возраст при его создании, то мы должны изменить этот конструктор, добавив необходимые параметры.

Вот наши обновленные классы с членами private и с вызовом конкретного конструктора класса Human:

Теперь мы можем создавать объекты класса BasketballPlayer следующим образом:

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

Anton Ivanovuch
45
310

Как вы можете видеть, всё корректно инициализировано.

Цепочки наследований

Классы в цепочке наследований работают аналогичным образом:

В этом примере класс C наследует свойства класса B, который наследует свойства класса A. Что произойдет при создании объекта класса C? А вот что:

   функция main() вызовет C(int, double, char);

   конструктор класса C вызовет B(int, double);

   конструктор класса B вызовет A(int);

   поскольку A не наследует никакой класс, то построение начнется именно с этого класса;

   построение A выполнено, выводится значение 7 и выполнение переходит в B;

   класс B построен, выводится значение 5.4 и выполнение переходит в C;

   класс C построен, выводится D и выполнение возвращается обратно в main();

   Финиш!

Таким образом, результат выполнения программы:

A: 7
B: 5.4
C: D

Стоит отметить, что конструкторы дочернего класса могут вызывать конструкторы только того родительского класса, от которого они напрямую наследуют. Следовательно, конструктор класса C не может напрямую вызывать или передавать параметры в конструктор класса A. Конструктор класса C может вызывать только конструктор класса B (который уже, в свою очередь, вызывает конструктор класса A).

Деструкторы


При уничтожении дочернего класса, каждый деструктор вызывается в порядке обратном построению классов. В примере, приведенном выше, при уничтожении объекта класса С, сначала вызывается деструктор класса C, затем деструктор класса B, а затем уже деструктор класса A.

Заключение

При инициализации объектов дочернего класса, конструктор дочернего класса отвечает за то, какой конструктор родительского класса вызывать. Если этот конструктор явно не указан, то вызывается конструктор по умолчанию родительского класса. Если же компилятор не может найти конструктор по умолчанию родительского класса (или этот конструктор не может быть создан автоматически), то компилятор выдаст ошибку.

Тест

Реализуем наш пример с Фруктом, о котором мы говорили на уроке №153. Создайте родительский класс Fruit, который имеет два закрытых члена: name (std::string) и color (std::string). Создайте класс Apple, который наследует свойства Fruit. У Apple должен быть дополнительный закрытый член: fiber (тип double). Создайте класс Banana, который также наследует класс Fruit. Banana не имеет дополнительных членов.

Следующий код:

Должен выдавать следующий результат:

Apple(Red delicious, red, 7.3)
Banana(Cavendish, yellow)

Подсказка: Поскольку a и b являются const, то убедитесь, что ваши параметры и функции соответствуют const.

Ответ

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

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

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

  1. Sergei:

    В данной задаче getfiber() не нужен.

    Можно просто написать  a.m_fiber.

    Геттер нужен только для родительского класса.

  2. Руслан:

    ну вот так

  3. ArtPiT:

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

    1. Роман:

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

    2. Артём:

      Хорошее замечание. Я тоже реализовал через две обычные перегрузки operator<< прямо над самим main. Геттеры есть, поэтому усложнять классы дружественными функциями нет необходимости.

  4. Vitalt1158:

    Только у меня появился вопрос, можно ли создать только одну перегрузку оператора << в родительском классе и использовать ее для дочерних?

    1. nindemon:

      Дочерние классы наследуют только свойства (члены класса ) и поведение (методы) перегрузка оператора вывода выполняется ТОЛЬКО через дружественную функцию.

  5. S1yGus:

    А я вот так сделал:

    Не знаю, на сколько корректно производить явное преобразование при перегрузке оператора вывода?

    1. Сергей:

      Красиво! Только в 46-й строке потерял вывод m_fiber.

  6. Vladimir:

    Подскажите пожалуйста, а почему нельзя при наследовании, создать конструктор без параметров ?
    Ни в Apple, ни в Fruit не получается…

  7. Alex:

    Не полюбил я перегрузку операторов, забросил на время курс.
    Переписал иначе, проще выглядит, но не думаю, что метод лучше.
    Тест не сложный, подглянул все же, с дочерним немного не понял, что требовалось.

  8. Константин:

    Стал замечать, что выполнение тестовых заданий даётся немного легче, чем 100 уроков назад

  9. Анатолий:

    Зачем слово friend перед перегрузкой оператора <<, она же внутри класса?

    1. Stikkerrr:

      https://ravesli.com/urok-133-peregruzka-operatorov-vvoda-i-vyvoda/
      Перегрузку бинарных операторов, которые не изменяют свой левый операнд (например, оператор +) выполняйте через обычные или дружественные функции.

  10. Макс:

    А Вы сделали PDF — файл по всему курсу?

    1. Фото аватара Юрий:

      В скором времени будет pdf половины курса. Затем, как переведу все уроки, весь курс + пошаговое создание игры на С++.

      1. Макс:

        Спасибо. А где можно будет скачать файлы?

        1. Фото аватара Юрий:

          На этом же сайте, в отдельной странице. Всё будет 🙂

  11. Shom:

    На первый взгляд тестовая задача гораздо проще казалась. )

    1. korvell:

      Она таковой и является.

Добавить комментарий для Константин Отменить ответ

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