12.8 – Перекрывающиеся и делегирующие конструкторы
Конструкторы с перекрывающимся функционалом
Когда вы создаете экземпляр нового объекта, конструктор объекта вызывается неявно. Нередко у классов бывает несколько конструкторов, которые содержат перекрывающуюся функциональность. Рассмотрим следующий класс:
class Foo
{
public:
Foo()
{
// код для выполнения A
}
Foo(int value)
{
// код для выполнения A
// код для выполнения B
}
};
Этот класс имеет два конструктора: конструктор по умолчанию и конструктор, принимающий целочисленное значение. Поскольку часть конструктора «код для выполнения A» требуется обоим конструкторам, этот код дублируется в каждом конструкторе.
Как вы (надеюсь) уже поняли, дублирования кода следует максимально избегать. Поэтому давайте рассмотрим несколько способов решения этой проблемы.
Очевидное решение не работает
Очевидным решением было бы, чтобы конструктор Foo(int)
вызывал конструктор Foo()
для выполнения части A.
class Foo
{
public:
Foo()
{
// код для выполнения A
}
Foo(int value)
{
Foo(); // используем показанный выше конструктор для выполнения A (не работает)
// код для выполнения B
}
};
Однако, если вы попытаетесь заставить один конструктор вызвать другой конструктор таким образом, код скомпилируется и, возможно, вызовет предупреждение, но он не будет работать так, как вы ожидаете. И вы, вероятно, даже с отладчиком потратите много времени, пытаясь выяснить, почему. Что происходит на самом деле: Foo();
создает новый объект Foo
, который немедленно отбрасывается, поскольку он не хранится в переменной.
Делегирующие конструкторы
Конструкторам разрешено вызывать другие конструкторы. Это называется делегирующими конструкторами (или цепочкой конструкторов).
Чтобы один конструктор вызывал другой, просто вызовите конструктор в списке инициализаторов членов. Это тот случай, когда прямой вызов другого конструктора приемлем. Применительно к нашему примеру выше:
class Foo
{
private:
public:
Foo()
{
// код для выполнения A
}
Foo(int value): Foo{} // использовать конструктор по умолчанию Foo() для выполнения A
{
// код для выполнения B
}
};
Это работает именно так, как вы ожидали. Убедитесь, что вы вызываете конструктор из списка инициализаторов членов, а не в теле конструктора.
Вот еще один пример использования делегирующих конструкторов для уменьшения избыточности кода:
#include <string>
#include <iostream>
class Employee
{
private:
int m_id{};
std::string m_name{};
public:
Employee(int id=0, const std::string &name=""):
m_id{ id }, m_name{ name }
{
std::cout << "Employee " << m_name << " created.\n";
}
// Использование делегирующего конструктора для минимизации избыточного кода
Employee(const std::string &name) : Employee{ 0, name }
{ }
};
Этот класс имеет 2 конструктора, один из которых делегирует конструктору Employee(int, const std::string&)
. Таким образом, количество избыточного кода сводится к минимуму (нам нужно написать только одно тело конструктора вместо двух).
Несколько дополнительных замечаний о делегирующих конструкторах. Во-первых, конструктору, который делегирует выполнение другому конструктору, не разрешается выполнять инициализацию каких-либо членов самостоятельно. Итак, конструкторы могут либо делегировать, либо инициализировать, но не то и другое одновременно.
Во-вторых, один конструктор может делегировать другому конструктору, который делегирует обратно первому конструктору. Это создаст бесконечный цикл и приведет к тому, что ваша программа исчерпает пространство стека и завершится со сбоем. Вы можете избежать этого, убедившись, что все ваши конструкторы вычисляются в конструктор без делегирования.
Лучшая практика
Если у вас есть несколько конструкторов с одинаковой функциональностью, используйте делегирующие конструкторы, чтобы избежать дублирования кода.
Использование отдельной функции
Соответственно, вы можете оказаться в ситуации, когда захотите написать функцию-член, чтобы повторно инициализировать класс до значений по умолчанию. Поскольку у вас, вероятно, уже есть конструктор, который делает это, у вас может возникнуть соблазн попытаться вызвать этот конструктор из вашей функции-члена. Однако попытка вызвать конструктор напрямую обычно приводит к неожиданному поведению, как мы показали выше. Многие разработчики просто копируют код из конструктора в функцию инициализации, что работает, но приводит к дублированию кода. Лучшее решение в этом случае – переместить код из конструктора в новую функцию и заставить конструктор вызывать эту функцию для выполнения работы по «инициализации» данных:
class Foo
{
public:
Foo()
{
init();
}
Foo(int value)
{
init();
// делаем что-нибудь с value
}
void init()
{
// код для "инициализации" Foo
}
};
Конструкторам разрешено вызывать функции, не являющиеся конструкторами класса. Просто будьте осторожны, чтобы любые члены, которые использует функция, не являющаяся конструктором, уже были инициализированы. Хотя у вас может возникнуть соблазн скопировать код из первого конструктора во второй конструктор, наличие дублирующего кода затрудняет понимание вашего класса и затрудняет его обслуживание.
Мы говорим «инициализировать», но это не настоящая инициализация. К тому времени, когда конструктор вызывает init()
, члены уже существуют и были инициализированы значениями по умолчанию или не инициализированы. Функция init
может только присваивать значения членам. Существуют типы, экземпляры которых невозможно создать без аргументов, потому что у них нет конструктора по умолчанию. Если какой-либо из членов класса принадлежит такому типу, функция init
не сработает, и конструкторы должны сами инициализировать эти члены.
Довольно часто добавляют функцию init()
, которая инициализирует переменные-члены их значениями по умолчанию, а затем каждый конструктор вызывает эту функцию init()
перед выполнением задач, зависящих от параметров. Это сводит к минимуму дублирование кода и позволяет явно вызывать init()
из любого места.
Одно небольшое предостережение: будьте осторожны при использовании функций init()
и динамически выделяемой памяти. Поскольку функции init()
могут быть вызваны кем угодно в любое время, динамически выделяемая память может быть уже выделена, а может и не быть выделена при вызове init()
. Будьте осторожны, чтобы обработать эту ситуацию правильно – это может немного сбивать с толку, поскольку ненулевой указатель может либо указывать на динамически выделенную память, либо быть неинициализированным указателем!