17.4 – Конструкторы и инициализация производных классов

Добавлено 31 июля 2021 в 12:58

В последних двух уроках мы изучили основы наследования в C++ и порядок инициализации производных классов. В этом уроке мы более подробно рассмотрим роль конструкторов в инициализации производных классов. Для этого мы продолжим использовать простые классы Base и Derived, которые мы разработали в предыдущем уроке:

class Base
{
public:
    int m_id;
 
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};
 
class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0)
        : m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};

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

int main()
{
    Base base{ 5 }; // использовать конструктор Base(int)
 
    return 0;
}

Вот что на самом деле происходит при создании экземпляра Base:

  1. выделяется память для Base;
  2. вызывается соответствующий конструктор Base;
  3. список инициализации инициализирует переменные;
  4. выполняется тело конструктора;
  5. управление возвращается вызывающей функции.

Всё довольно просто. С производными классами всё немного сложнее:

int main()
{
    Derived derived{ 1.3 }; // использовать конструктор Derived(double)
 
    return 0;
}

Вот что на самом деле происходит при создании экземпляра Derived:

  1. выделяется память для Derived (достаточная и для части Base, и для части Derived);
  2. вызывается соответствующий конструктор Derived;
  3. сначала создается объект Base с использованием соответствующего конструктора Base. Если конструктор Base не указан, будет использоваться конструктор по умолчанию;
  4. список инициализации инициализирует переменные;
  5. выполняется тело конструктора;
  6. управление возвращается вызывающей функции.

Единственное реальное различие между этим случаем и случаем без наследования состоит в том, что прежде, чем конструктор Derived сможет сделать что-либо существенное, сначала вызывается конструктор Base. Конструктор Base создает часть Base объекта, управление возвращается конструктору Derived, и конструктору Derived разрешается завершить свою работу.

Инициализация членов базового класса

Один из текущих недостатков нашего класса Derived в том виде, в котором он написан, заключается в том, что при создании объекта Derived нет возможности инициализировать m_id. Что, если при создании объекта Derived мы хотим установить и m_cost (из части Derived объекта), и m_id (из части Base объекта)?

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

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        // не работает
        : m_cost{ cost }, m_id{ id }
    {
    }
 
    double getCost() const { return m_cost; }
};

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

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

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

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

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

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        : m_cost{ cost }
    {
        m_id = id;
    }
 
    double getCost() const { return m_cost; }
};

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

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

До сих пор во всех примерах, когда мы создавали экземпляр объекта класса Derived, часть Base класса создавалась с использованием конструктора Base по умолчанию. Почему он всегда использовал конструктор Base по умолчанию? Потому что мы никогда не указывали иное!

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

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        : Base{ id }, // вызывает конструктор Base(int) со значением id!
            m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};

Теперь, когда мы выполняем этот код:

int main()
{
    Derived derived{ 1.3, 5 }; // использовать конструктор Derived(double, int)
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';
 
    return 0;
}

Конструктор базового класса Base(int) будет использоваться для инициализации m_id значением 5, а конструктор производного класса будет использоваться для инициализации m_cost значением 1.3!

Таким образом, программа напечатает:

Id: 5
Cost: 1.3

Вот что происходит более подробно:

  1. выделяется память для Derived;
  2. вызывается конструктор Derived(double, int), где cost = 1.3, а id = 5;
  3. компилятор проверяет, запрашивали ли мы конкретный конструктор для класса Base. Так и есть! Поэтому он вызывает Base(int) с id = 5;
  4. список инициализации конструктора класса Base устанавливает m_id равным 5;
  5. выполняется тело конструктора класса Base, которое ничего не делает;
  6. конструктор класса Base возвращает выполнение;
  7. список инициализации конструктора класса Derived устанавливает m_cost равным 1,3;
  8. выполняется тело конструктора класса Derived, которое ничего не делает;
  9. конструктор класса Derived возвращает выполнение.

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

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

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

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

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

Рассмотрим следующий код:

#include <iostream>
 
class Base
{
private: // теперь наш член закрыт
    int m_id;
 
public:
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};
 
class Derived: public Base
{
private: // теперь наш член закрыт
    double m_cost;
 
public:
    Derived(double cost=0.0, int id=0)
        : Base{ id }, // вызов конструктора Base(int) со значением id!
            m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};
 
int main()
{
    Derived derived{ 1.3, 5 }; // использовать конструктор Derived(double, int)
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';
 
    return 0;
}

В приведенном выше коде мы сделали m_id и m_cost закрытыми. Это нормально, поскольку мы используем соответствующие конструкторы для их инициализации и открытые методы доступа для получения значений.

Этот код печатает следующее, как и ожидалось:

Id: 5
Cost: 1.3

Подробнее о спецификаторах доступа мы поговорим в следующем уроке.

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

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

#include <string>
 
class Person
{
public:
    std::string m_name;
    int m_age;
 
    Person(const std::string& name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }
 
    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }
};
 
// BaseballPlayer публично наследует Person
class BaseballPlayer : public Person
{
public:
    double m_battingAverage;
    int m_homeRuns;
 
    BaseballPlayer(double battingAverage = 0.0, int homeRuns = 0)
       : m_battingAverage{ battingAverage },
         m_homeRuns{ homeRuns }
    {
    }
};

Как мы уже писали ранее, BaseballPlayer инициализирует только свои собственные члены и не указывает, какой конструктор Person использовать. Это означает, что каждый созданный нами BaseballPlayer будет использовать конструктор Person по умолчанию, который инициализирует имя пустой строкой и возраст значением 0. Поскольку имеет смысл дать нашему BaseballPlayer имя и возраст при его создании, мы должны изменить его конструктор, чтобы добавить эти параметры.

Вот наши обновленные классы, которые используют закрытые члены, причем класс BaseballPlayer вызывает соответствующий конструктор Person для инициализации унаследованных переменных-членов Person:

#include <iostream>
#include <string>
 
class Person
{
private:
    std::string m_name;
    int m_age;
 
public:
    Person(const std::string& name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }
 
    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }
 
};

// BaseballPlayer публично наследует Person
class BaseballPlayer : public Person
{
private:
    double m_battingAverage;
    int m_homeRuns;
 
public:
    BaseballPlayer(const std::string& name = "", int age = 0,
        double battingAverage = 0.0, int homeRuns = 0)
        // вызов Person(const std::string&, int) для инициализации этих полей
        : Person{ name, age }, 
            m_battingAverage{ battingAverage }, m_homeRuns{ homeRuns }
    {
    }
 
    double getBattingAverage() const { return m_battingAverage; }
    int getHomeRuns() const { return m_homeRuns; }
};

Теперь мы можем создавать бейсболистов так:

int main()
{
    BaseballPlayer pedro{ "Pedro Cerrano", 32, 0.342, 42 };
 
    std::cout << pedro.getName() << '\n';
    std::cout << pedro.getAge() << '\n';
    std::cout << pedro.getHomeRuns() << '\n';
 
    return 0;
}

Этот код выводит:

Pedro Cerrano
32
42

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

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

Классы в цепочке наследования работают точно так же.

#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{ 5, 4.3, 'R' };
 
    return 0;
}

В этом примере класс C является производным от класса B, который является производным от класса A. Итак, что происходит, когда мы создаем экземпляр объекта класса C?

Сначала main() вызывает C(int, double, char). Конструктор C вызывает B(int, double). Конструктор B вызывает A(int). Поскольку A ни от кого не наследуется, это первый класс, который мы создадим. A создается, печатает значение 5 и возвращает управление B. B создается, печатает значение 4.3 и возвращает управление C. C создается, печатает значение 'R' и возвращает управление main(). Готово!

Таким образом, эта программа печатает:

A: 5
B: 4.3
C: R

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

Деструкторы

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

Резюме

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

На этом этапе вы достаточно понимаете наследование в C++, чтобы создавать свои собственные наследованные классы!

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

Вопрос 1

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

Должна запуститься следующая программа:

int main()
{
    const Apple a{ "Red delicious", "red", 4.2 };
    std::cout << a << '\n';
 
    const Banana b{ "Cavendish", "yellow" };
    std::cout << b << '\n';
 
    return 0;
}

Она должна напечатать следующее:

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

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

#include <string>
#include <iostream>
 
class Fruit
{
private:
    std::string m_name;
    std::string m_color;
 
public:
    Fruit(const std::string& name, const std::string& color)
        : m_name{ name }, m_color{ color }
    {
    }
 
    const std::string& getName() const { return m_name; }
    const std::string& getColor() const { return m_color; }
 
};
 
class Apple : public Fruit
{
private:
    double m_fiber;
 
public:
    Apple(const std::string& name, const std::string& color, double fiber)
        :Fruit{ name, color },
        m_fiber{ fiber }
    {
    }
 
    double getFiber() const { return m_fiber; }
 
};
 
std::ostream& operator<<(std::ostream& out, const Apple& a)
{
    out << "Apple(" << a.getName() << ", " << a.getColor() << ", " << a.getFiber() << ')';
    return out;
}
 
class Banana : public Fruit
{
public:
    Banana(const std::string& name, const std::string& color)
        :Fruit{ name, color }
    {
    }
};
 
std::ostream& operator<<(std::ostream& out, const Banana& b)
{
    out << "Banana(" << b.getName() << ", " << b.getColor() << ')';
    return out;
}
 
int main()
{
    const Apple a{ "Red delicious", "red", 4.2 };
    std::cout << a << '\n';
 
    const Banana b{ "Cavendish", "yellow" };
    std::cout << b << '\n';
 
    return 0;
}

Теги

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

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

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