На предыдущих уроках мы изучили основы наследования в языке C++ и порядок инициализации дочерних классов. На этом уроке мы подробнее рассмотрим роль конструкторов в инициализации дочерних классов.
Конструкторы и инициализация
А помогать нам в этом будут классы Parent и Child:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Parent { public: int m_id; Parent(int id=0) : m_id(id) { } int getId() const { return m_id; } }; class Child: public Parent { public: double m_value; Child(double value=0.0) : m_value(value) { } double getValue() const { return m_value; } }; |
С обычными (не дочерними) классами конструктору нужно заморачиваться только с членами своего класса. Например, объект класса Parent создается следующим образом:
1 2 3 4 5 6 |
int main() { Parent parent(7); // вызывается конструктор Parent(int) return 0; } |
Вот что на самом деле происходит при инициализации объекта parent
:
выделяется память для объекта parent
;
вызывается соответствующий конструктор класса Parent;
список инициализации инициализирует переменные;
выполняется тело конструктора;
точка выполнения возвращается обратно в caller.
Всё довольно-таки просто. С дочерними классами дела обстоят несколько сложнее:
1 2 3 4 5 6 |
int main() { Child child(1.5); // вызывается конструктор Child(double) return 0; } |
Вот что происходит при инициализации объекта child
:
выделяется память для объекта дочернего класса (достаточная порция памяти для части Parent и части Child объекта класса Child);
вызывается соответствующий конструктор класса Child;
создается объект класса Parent с использованием соответствующего конструктора класса Parent. Если такой конструктор программистом не предоставлен, то будет использоваться конструктор по умолчанию класса Parent;
список инициализации инициализирует переменные;
выполняется тело конструктора класса Child;
точка выполнения возвращается обратно в caller.
Единственное различие между инициализацией объектов обычного и дочернего класса заключается в том, что при инициализации объекта дочернего класса, сначала выполняется конструктор родительского класса (для инициализации части родительского класса) и только потом уже выполняется конструктор дочернего класса.
Инициализация членов родительского класса
Одним из недостатков нашего дочернего класса Child является то, что мы не можем инициализировать m_id
при создании объекта класса Child. Что, если мы хотим задать значение как для m_value
(части Child), так и для m_id
(части Parent)?
Новички часто пытаются решить эту проблему следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) // Не сработает : m_value(value), m_id(id) { } double getValue() const { return m_value; } }; |
Это хорошая попытка и почти правильная идея. Нам определенно нужно добавить еще один параметр в наш конструктор, иначе C++ не будет понимать, каким значением мы хотим инициализировать m_id
.
Однако C++ запрещает дочерним классам инициализировать наследуемые переменные-члены родительского класса в списке инициализации своего конструктора. Другими словами, значение переменной может быть задано только в списке инициализации конструктора, принадлежащего тому же классу, что и переменная-член.
Почему C++ так делает? Ответ связан с константными переменными и ссылками. Подумайте, что произошло бы, если бы m_id
был const. Поскольку константы должны быть инициализированы значениями при создании, то конструктор родительского класса должен установить это значение при создании переменной-члена. В то же время конструктор дочернего класса выполняется только после выполнения конструкторов родительского класса. Каждый дочерний класс имел бы тогда возможность инициализировать эту переменную, потенциально изменяя её значение! Ограничивая инициализацию переменных конструктором класса, к которому принадлежат эти переменные, язык C++ гарантирует, что все переменные будут инициализированы только один раз.
Конечным результатом выполнения кода, приведенного выше, является ошибка, так как m_id
унаследован от класса Parent, а только ненаследуемые переменные-члены могут быть изменены в списке инициализации конструктора класса Child.
Однако наследуемые переменные могут по-прежнему изменять свои значения в теле конструктора через операцию присваивания. Следовательно, новички часто пытаются сделать следующее:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) : m_value(value) { m_id = id; } double getValue() const { return m_value; } }; |
Хотя подобное действительно сработает в данном случае, но это не сработает, если m_id
будет константой или ссылкой (поскольку константы и ссылки должны быть инициализированы в списке инициализации конструктора). Кроме того, это неэффективно, так как для m_id
присваивают значение дважды: первый раз в списке инициализации конструктора класса Parent, а затем в теле конструктора класса Child. И, наконец, что, если классу Parent необходим доступ к этому значению во время инициализации?
Итак, как правильно инициализировать m_id
при создании объекта класса Child?
Во всех наших примерах, при создании объекта класса Child, вызывался конструктор по умолчанию класса Parent. Почему так? Потому что мы не указывали иначе!
К счастью, язык C++ предоставляет нам возможность явно выбирать конструктор класса Parent для выполнения инициализации части Parent! Для этого нам необходимо просто добавить вызов нужного нам конструктора в списке инициализации конструктора дочернего класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) : Parent(id), // вызывается конструктор Parent(int) со значением id! m_value(value) { } double getValue() const { return m_value; } }; |
Теперь при выполнении следующего кода:
1 2 3 4 5 6 7 8 |
int main() { Child child(1.5, 7); // вызывается конструктор Child(double, int) std::cout << "ID: " << child.getId() << '\n'; std::cout << "Value: " << child.getValue() << '\n'; return 0; } |
Конструктор 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 открыт только для других членов этого же класса. Обратите внимание, это означает, что дочерние классы не могут напрямую обращаться к закрытым членам родительского класса! Дочерним классам нужно использовать геттеры и сеттеры для доступа к этим членам.
Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
#include <iostream> class Parent { private: // наш m_id теперь закрытый int m_id; public: Parent(int id=0) : m_id(id) { } int getId() const { return m_id; } }; class Child: public Parent { private: // наш m_value теперь закрытый double m_value; public: Child(double value=0.0, int id=0) : Parent(id), // вызывается конструктор Parent(int) со значением id! m_value(value) { } double getValue() const { return m_value; } }; int main() { Child child(1.5, 7); // вызывается конструктор Child(double, int) std::cout << "ID: " << child.getId() << '\n'; std::cout << "Value: " << child.getValue() << '\n'; return 0; } |
В коде, приведенном выше, мы делаем m_id
и m_value
закрытыми. Для их инициализации используются соответствующие конструкторы, а для доступа — открытые функции доступа (геттеры).
Результат выполнения программы:
Еще один пример
Рассмотрим еще пару классов, с которыми мы работали ранее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> #include <string> class Human { public: std::string m_name; int m_age; Human(std::string name = "", int age = 0) : m_name(name), m_age(age ) { } std::string getName() const { return m_name; } int getAge() const { return m_age; } }; // BasketballPlayer открыто наследует класс Human class BasketballPlayer: public Human { public: double m_gameAverage; int m_points; BasketballPlayer(double gameAverage = 0.0, int points = 0) : m_gameAverage(gameAverage), m_points(points) { } }; |
Как мы уже знаем, класс BasketballPlayer только инициализирует свои собственные члены и не указывает использование конкретного конструктора класса Human. Это означает, что каждый созданный объект класса BasketballPlayer будет использовать конструктор по умолчанию класса Human, который будет инициализировать переменную-член name
пустым значением, а age
— значением 0
. Поскольку мы хотим назвать нашего BasketballPlayer и указать его возраст при его создании, то мы должны изменить этот конструктор, добавив необходимые параметры.
Вот наши обновленные классы с членами private и с вызовом конкретного конструктора класса Human:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
#include <string> class Human { private: std::string m_name; int m_age; public: Human(std::string name = "", int age = 0) : m_name(name), m_age(age ) { } std::string getName() const { return m_name; } int getAge() const { return m_age; } }; // BasketballPlayer открыто наследует класс Human class BasketballPlayer: public Human { private: double m_gameAverage; int m_points; public: BasketballPlayer(std::string name = "", int age = 0, double gameAverage = 0.0, int points = 0) : Human(name, age), // вызывается Human(std::string, int) для инициализации членов name и age m_gameAverage(gameAverage), m_points(points) { } double getGameAverage() const { return m_gameAverage; } int getPoints() const { return m_points; } }; |
Теперь мы можем создавать объекты класса BasketballPlayer следующим образом:
1 2 3 4 5 6 7 8 9 10 |
int main() { BasketballPlayer anton("Anton Ivanovuch", 45, 300, 310); std::cout << anton.getName() << '\n'; std::cout << anton.getAge() << '\n'; std::cout << anton.getPoints() << '\n'; return 0; } |
Результат выполнения программы:
Anton Ivanovuch
45
310
Как вы можете видеть, всё корректно инициализировано.
Цепочки наследований
Классы в цепочке наследований работают аналогичным образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#include <iostream> class A { public: A(int a) { std::cout << "A: " << a << '\n'; } }; class B: public A { public: B(int a, double b) : A(a) { std::cout << "B: " << b << '\n'; } }; class C: public B { public: C(int a , double b , char c) : B(a, b) { std::cout << "C: " << c << '\n'; } }; int main() { C c(7, 5.4, 'D'); return 0; } |
В этом примере класс 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 не имеет дополнительных членов.
Следующий код:
1 2 3 4 5 6 7 8 9 10 |
int main() { const Apple a("Red delicious", "red", 7.3); std::cout << a; const Banana b("Cavendish", "yellow"); std::cout << b; return 0; } |
Должен выдавать следующий результат:
Apple(Red delicious, red, 7.3)
Banana(Cavendish, yellow)
Подсказка: Поскольку a
и b
являются const, то убедитесь, что ваши параметры и функции соответствуют const.
Ответ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
#include <iostream> #include <string> class Fruit { private: std::string m_name; std::string m_color; public: Fruit(std::string name, std::string color) : m_name(name), m_color(color) { } std::string getName() const { return m_name; } std::string getColor() const { return m_color; } }; class Apple : public Fruit { private: double m_fiber; public: Apple(std::string name, std::string color, double fiber) :Fruit(name, color), m_fiber(fiber) { } double getFiber() const { return m_fiber; } friend std::ostream& operator<<(std::ostream &out, const Apple &a) { out << "Apple (" << a.getName() << ", " << a.getColor() << ", " << a.getFiber() << ")\n"; return out; } }; class Banana : public Fruit { public: Banana(std::string name, std::string color) :Fruit(name, color) { } friend std::ostream& operator<<(std::ostream &out, const Banana &b) { out << "Banana (" << b.getName() << ", " << b.getColor() << ")\n"; return out; } }; int main() { const Apple a("Red delicious", "red", 7.3); std::cout << a; const Banana b("Cavendish", "yellow"); std::cout << b; return 0; } |
В классах есть геттеры. Зачем объявлять перегрузку оператора << дружественной?
Если нет необходимости иметь доступ к приватным полям класса, то нет необходимость делать функцию-оператор дружественной.
Только у меня появился вопрос, можно ли создать только одну перегрузку оператора << в родительском классе и использовать ее для дочерних?
Дочерние классы наследуют только свойства (члены класса ) и поведение (методы) перегрузка оператора вывода выполняется ТОЛЬКО через дружественную функцию.
А я вот так сделал:
Не знаю, на сколько корректно производить явное преобразование при перегрузке оператора вывода?
Красиво! Только в 46-й строке потерял вывод m_fiber.
Подскажите пожалуйста, а почему нельзя при наследовании, создать конструктор без параметров ?
Ни в Apple, ни в Fruit не получается…
Не полюбил я перегрузку операторов, забросил на время курс.
Переписал иначе, проще выглядит, но не думаю, что метод лучше.
Тест не сложный, подглянул все же, с дочерним немного не понял, что требовалось.
Стал замечать, что выполнение тестовых заданий даётся немного легче, чем 100 уроков назад
Зачем слово friend перед перегрузкой оператора <<, она же внутри класса?
https://ravesli.com/urok-133-peregruzka-operatorov-vvoda-i-vyvoda/
Перегрузку бинарных операторов, которые не изменяют свой левый операнд (например, оператор +) выполняйте через обычные или дружественные функции.
А Вы сделали PDF — файл по всему курсу?
В скором времени будет pdf половины курса. Затем, как переведу все уроки, весь курс + пошаговое создание игры на С++.
Спасибо. А где можно будет скачать файлы?
На этом же сайте, в отдельной странице. Всё будет 🙂
На первый взгляд тестовая задача гораздо проще казалась. )
Она таковой и является.