12.5 – Конструкторы
Когда все члены класса (или структуры) являются открытыми, для инициализации этого класса (или структуры) мы можем использовать агрегатную инициализацию напрямую, используя инициализацию списком:
class Foo
{
public:
int m_x;
int m_y;
};
int main()
{
Foo foo { 6, 7 }; // инициализация списком
return 0;
}
Однако, как только мы сделаем какие-либо переменные-члены закрытыми, мы больше не сможем инициализировать классы таким образом. И это понятно: если у вас нет прямого доступа к переменной (поскольку она является закрытой), у вас не должно быть возможности напрямую инициализировать ее.
Итак, как же инициализировать класс с закрытыми переменными-членами? Ответ: через конструкторы.
Конструкторы
Конструктор – это особый вид функции-члена класса, которая автоматически вызывается при создании экземпляра объекта этого класса. Конструкторы обычно используются для инициализации переменных-членов класса соответствующими значениями по умолчанию или пользовательскими значениями или для выполнения любых шагов настройки, необходимых для использования класса (например, открытие файла или базы данных).
В отличие от обычных функций-членов, у конструкторов есть определенные правила того, как они должны называться:
- конструкторы должны иметь то же имя, что и класс (с такими же заглавными буквами);
- конструкторы не имеют возвращаемого типа (даже не
void
).
Конструкторы по умолчанию
Конструктор, который не принимает параметров (или все параметры имеют значения по умолчанию), называется конструктором по умолчанию. Конструктор по умолчанию вызывается, если не предоставлены значения инициализации, предоставляемые пользователем.
Вот пример класса, у которого есть конструктор по умолчанию:
#include <iostream>
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
Fraction() // конструктор по умолчанию
{
m_numerator = 0;
m_denominator = 1;
}
int getNumerator() { return m_numerator; }
int getDenominator() { return m_denominator; }
double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};
int main()
{
Fraction frac; // Поскольку аргументов нет, вызывает конструктор по умолчанию Fraction()
std::cout << frac.getNumerator() << "/" << frac.getDenominator() << '\n';
return 0;
}
Этот класс был разработан для хранения дробного значения в виде целочисленных числителя и знаменателя. Мы определили конструктор по умолчанию с именем Fraction
(такое же, как у класса).
Поскольку мы создаем экземпляр объекта типа Fraction
без аргументов, то сразу после выделения памяти для объекта будет вызван конструктор по умолчанию, и наш объект будет инициализирован.
Эта программа дает следующий результат:
0/1
Обратите внимание, что числитель и знаменатель были инициализированы значениями, которые мы установили в конструкторе по умолчанию! Без конструктора по умолчанию числитель и знаменатель будут иметь мусорные значения, пока мы явно не присвоим им осмысленные значения или не инициализируем их другими способами (помните: переменные базовых типов не инициализируются значениями по умолчанию).
Прямая и унифицированная инициализации с использованием конструкторов с параметрами
Хотя конструктор по умолчанию отлично подходит для обеспечения инициализации наших классов осмысленными значениями по умолчанию, часто мы хотим, чтобы экземпляры нашего класса имели определенные значения, которые мы предоставляем. К счастью, конструкторы также можно объявлять с параметрами. Вот пример конструктора, который принимает два целочисленных параметра, которые используются для инициализации числителя и знаменателя:
#include <cassert>
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
Fraction() // конструктор по умолчанию
{
m_numerator = 0;
m_denominator = 1;
}
// Конструктор с двумя параметрами, один из которых имеет значение по умолчанию
Fraction(int numerator, int denominator=1)
{
assert(denominator != 0);
m_numerator = numerator;
m_denominator = denominator;
}
int getNumerator() { return m_numerator; }
int getDenominator() { return m_denominator; }
double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};
Обратите внимание, что теперь у нас есть два конструктора: конструктор по умолчанию, который будет вызываться в случае по умолчанию, и второй конструктор, который принимает два параметра. Благодаря перегрузке функций эти два конструктора могут мирно сосуществовать в одном классе. Фактически, вы можете определить столько конструкторов, сколько захотите, при условии, что каждый имеет уникальную сигнатуру (количество и типы параметров).
Итак, как нам использовать этот конструктор с параметрами? Это просто! Мы можем использовать инициализацию списком или прямую инициализацию:
Fraction fiveThirds{ 5, 3 }; // Инициализация списком, вызывает Fraction(int, int)
Fraction threeQuarters(3, 4); // Прямая инициализация, также вызывает Fraction(int, int)
Как всегда, мы предпочитаем инициализацию списком. Причины для использования прямой инициализации при вызове конструкторов (шаблоны и std::initializer_list
) мы узнаем позже в этой серии статей. Существует еще один специальный конструктор, который может заставить инициализацию с фигурными скобками делать что-то другое, в этом случае мы должны использовать прямую инициализацию. Об этих конструкторах мы поговорим позже.
Обратите внимание, что мы дали второму параметру конструктора с параметрами значение по умолчанию, поэтому следующее также допустимо:
// вызывает конструктор Fraction(int, int),
// второй параметр использует значение по умолчанию
Fraction six{ 6 };
Значения по умолчанию для конструкторов работают точно так же, как и с любыми другими функциями, поэтому в приведенном выше случае, когда мы вызываем six{6}
, функция Fraction(int, int)
вызывается со вторым параметром, по умолчанию равным 1.
Правило
Для инициализации объектов класса используйте инициализацию с фигурными скобками.
Копирующая инициализация с использованием оператора присваивания при работе с классами
Как и в случае с переменными базовых типов, инициализировать классы также можно, используя копирующую инициализацию:
// Копирующая инициализация Fraction, вызовет Fraction(6, 1)
Fraction six = Fraction{ 6 };
// Копирующая инициализация Fraction. Компилятор попытается
// найти способ преобразовать 7 во Fraction, который вызовет
// конструктор Fraction(7, 1).
Fraction seven = 7;
Однако при работе с классами мы рекомендуем избегать этой формы инициализации, поскольку она может быть менее эффективной. Хотя прямая инициализация, унифицированная инициализация и копирующая инициализация работают одинаково с базовыми типами, копирующая инициализация с классами работают не одинаково (хотя конечный результат часто бывает одинаковым). Мы рассмотрим различия более подробно в следующей главе.
Уменьшение количества конструкторов
В приведенном выше объявлении двух конструкторов класса Fraction
конструктор по умолчанию на самом деле несколько избыточен. Мы могли бы упростить этот класс следующим образом:
#include <cassert>
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
// Конструктор по умолчанию
Fraction(int numerator=0, int denominator=1)
{
assert(denominator != 0);
m_numerator = numerator;
m_denominator = denominator;
}
int getNumerator() { return m_numerator; }
int getDenominator() { return m_denominator; }
double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};
Хотя этот конструктор по-прежнему является конструктором по умолчанию, теперь он определен таким образом, что может принимать одно или два значения, предоставленных пользователем.
Fraction zero; // вызовет Fraction(0, 1)
Fraction zero{}; // вызовет Fraction(0, 1)
Fraction six{ 6 }; // вызовет Fraction(6, 1)
Fraction fiveThirds{ 5, 3 }; // вызовет Fraction(5, 3)
При реализации конструкторов подумайте, как вы можете уменьшить их количество за счет разумной установки значений по умолчанию.
Напоминание о параметрах по умолчанию
Правила определения и вызова функций с параметрами по умолчанию (описанные в уроке «8.12 – Аргументы по умолчанию») применимы и к конструкторам. Напомним, что при определении функции с параметрами по умолчанию все параметры по умолчанию должны следовать после любых параметров, отличных от параметров по умолчанию, т.е. после параметра по умолчанию не может быть параметров, не заданных по умолчанию.
Это может привести к неожиданным результатам для классов, которые имеют несколько параметров по умолчанию разных типов. Рассмотрим следующий код:
class Something
{
public:
// Конструктор по умолчанию
// позволяет нам создать Something(int, double), Something(int) или Something()
Something(int n = 0, double d = 1.2)
{
}
};
int main()
{
Something s1 { 1, 2.4 }; // вызывает Something(int, double)
Something s2 { 1 }; // вызывает Something(int, double)
Something s3 {}; // вызывает Something(int, double)
// не будет компилироваться, так как нет конструктора для обработки Something(double)
Something s4 { 2.4 };
return 0;
}
В s4
мы попытались создать Something
, предоставив только double
. Это не будет компилироваться, поскольку правила соответствия аргументов параметрам по умолчанию не позволят нам пропустить не крайний правый параметр (в данном случае крайний левый параметр типа int
).
Если мы хотим иметь возможность создать Something
только c double
, нам нужно добавить второй (не используемый по умолчанию) конструктор:
class Something
{
public:
// Конструктор по умолчанию
// позволяет нам создать Something(int, double), Something(int) или Something()
Something(int n = 0, double d = 1.2)
{
}
Something(double d)
{
}
};
int main()
{
Something s1 { 1, 2.4 }; // вызывает Something(int, double)
Something s2 { 1 }; // вызывает Something(int, double)
Something s3 {}; // вызывает Something(int, double)
Something s4 { 2.4 }; // вызывает Something(double)
return 0;
}
Неявно созданный конструктор по умолчанию
Если в вашем классе нет конструкторов, C++ автоматически сгенерирует для вас открытый конструктор по умолчанию. Иногда это называют неявным конструктором («implicit constructor», или неявно сгенерированным конструктором).
Рассмотрим следующий класс:
class Date
{
private:
int m_year;
int m_month;
int m_day;
// Пользовательских конструкторов нет,
// скомпилятор генерирует конструктор по умолчанию.
};
У этого класса нет конструктора. Следовательно, компилятор сгенерирует конструктор, который позволит нам создать объект Date
без аргументов.
Этот конкретный неявный конструктор позволяет нам создать объект Date
без аргументов, но не инициализирует ни один из его членов, если мы не создадим объект Date
с помощью прямой инициализации или инициализации списком (поскольку все члены принадлежат базовым типам, а те при создании не инициализируется). Если бы у Date
были члены, которые сами принадлежат типам классов, например std::string
, конструкторы этих членов вызывались бы автоматически.
Чтобы обеспечить инициализацию переменных-членов, мы можем инициализировать их при их объявлении.
class Date
{
private:
int m_year{ 1900 };
int m_month{ 1 };
int m_day{ 1 };
};
Хотя вы не видите неявно созданный конструктор, вы можете доказать, что он существует:
class Date
{
private:
int m_year{ 1900 };
int m_month{ 1 };
int m_day{ 1 };
// Конструктор не предоставлен, поэтому C++ создает
// для нас открытый конструктор по умолчанию
};
int main()
{
Date date{}; // вызывает неявный конструктор
return 0;
}
Приведенный выше код компилируется, потому что объект Date
будет использовать неявный конструктор (который является открытым).
Если в вашем классе есть какие-либо другие конструкторы, неявно сгенерированный конструктор предоставлен не будет. Например:
class Date
{
private:
int m_year{ 1900 };
int m_month{ 1 };
int m_day{ 1 };
public:
Date(int year, int month, int day) // обычный конструктор не по умолчанию
{
m_year = year;
m_month = month;
m_day = day;
}
// Неявный конструктор не предоставлен, потому что
// мы уже определили наш собственный конструктор
};
int main()
{
// ошибка: невозможно создать экземпляр объекта,
// потому что конструктор по умолчанию не существует,
// и компилятор не сгенерирует его
Date date{};
// today инициализируется датой 19 января 2020 г.
Date today{ 2020, 1, 19 };
return 0;
}
Чтобы разрешить создание Date
без аргументов, добавьте в конструктор аргументы по умолчанию, добавьте пустой конструктор по умолчанию или явно добавьте конструктор по умолчанию:
class Date
{
private:
int m_year{ 1900 };
int m_month{ 1 };
int m_day{ 1 };
public:
// Сообщаем компилятору создать конструктор по умолчанию, даже если
// уже есть другие конструкторы, предоставленные пользователем.
Date() = default;
Date(int year, int month, int day) // обычный конструктор не по умолчанию
{
m_year = year;
m_month = month;
m_day = day;
}
};
int main()
{
Date date{}; // date инициализируется датой 1 января 1900 г.
Date today{ 2020, 10, 14 }; // today инициализируется датой 14 октября 2020 г.
return 0;
}
Использование = default
– это почти то же самое, что добавление конструктора по умолчанию с пустым телом. Единственное отличие состоит в том, что = default
позволяет нам безопасно инициализировать переменные-члены, даже если у них нет инициализатора:
class Date
{
private:
// Обратите внимание: никаких инициализаций при объявлениях членов
int m_year;
int m_month;
int m_day;
public:
// Явно заданный конструктор по умолчанию
Date() = default;
};
class Date2
{
private:
// Обратите внимание: никаких инициализаций при объявлениях членов
int m_year;
int m_month;
int m_day;
public:
// Пустой конструктор, предоставленный пользователем
Date2() {};
};
int main()
{
Date today{}; // today равно 0, 0, 0
Date2 tomorrow{}; // члены tomorrows неинициализированы
return 0;
}
Использование = default
длиннее, чем написание конструктора с пустым телом, но лучше выражает ваши намерения (создать конструктор по умолчанию) и безопаснее. = default
также работает для других специальных конструкторов, о которых мы поговорим в будущем.
Правило
Если у вас в вашем классе есть конструкторы, и вам нужен конструктор по умолчанию, который ничего не делает, используйте = default
.
Классы, содержащие классы
Класс может содержать в качестве переменных-членов другие классы. По умолчанию, когда создается внешний класс, у переменных-членов вызываются конструкторы по умолчанию. Это происходит до выполнения тела конструктора.
Это можно продемонстрировать следующим образом:
#include <iostream>
class A
{
public:
A() { std::cout << "A\n"; }
};
class B
{
private:
A m_a; // B содержит A как переменную-член
public:
B() { std::cout << "B\n"; }
};
int main()
{
B b;
return 0;
}
Этот код печатает:
A
B
Когда создается переменная b
, вызывается конструктор B()
. Перед выполнением тела конструктора инициализируется m_a
, вызывая конструктор по умолчанию класса A
. Это печатает "А". Затем управление возвращается конструктору B
, и выполняется тело конструктора B
.
Это имеет смысл, если подумать, что конструктор B()
может захотеть использовать переменную m_a
, поэтому сначала лучше инициализировать m_a
!
Отличие от последнего примера в предыдущем разделе в том, что m_a
принадлежит типу класса. Члены типа класса инициализируются, даже если мы не инициализируем их явно.
В следующем уроке мы поговорим о том, как инициализировать эти переменные-члены класса.
Замечания о конструкторах
Многие начинающие программисты не понимают, создают ли конструкторы объекты или нет. Они этого не делают – компилятор выполняет выделение памяти для объекта до вызова конструктора.
Конструкторы на самом деле служат двум целям. Во-первых, они определяют, кому разрешено создавать объект. То есть объект класса может быть создан только в том случае, если может быть найден соответствующий конструктор.
Во-вторых, конструкторы можно использовать для инициализации объектов. Вопрос о том, действительно ли конструктор выполняет инициализацию, зависит от программиста. Синтаксически допустимо иметь конструктор, который вообще не выполняет инициализацию (конструктор по-прежнему служит цели создания объекта, как указано выше).
Однако, как и при инициализации всех локальных переменных, при создании объекта рекомендуется инициализировать все переменные-члены. Это можно сделать либо с помощью конструктора, либо с помощью других средств, которые мы покажем в будущих уроках.
Лучшая практика
Всегда инициализируйте все переменные-члены в ваших объектах.
Наконец, конструкторы предназначены для использования для инициализации только при создании объекта. Не следует пытаться вызвать конструктор для повторной инициализации существующего объекта. Хотя это может компилироваться, результаты будут не такими, как вы планировали (вместо этого компилятор создаст временный объект, а затем отбросит его).
Небольшой тест
Вопрос 1
Напишите класс мяча с именем Ball
. Ball
должен иметь две закрытые переменные-члены со значениями по умолчанию: m_color
("black") и m_radius
(10.0). Ball
должен предоставить конструкторы для установки только m_color
, установки только m_radius
, установки обоих или ни одного из значений. В этом вопросе теста не используйте параметры по умолчанию для ваших конструкторов. Также напишите функцию для печати цвета и радиуса мяча.
Следующая программа-пример должна скомпилироваться:
int main()
{
Ball def{};
def.print();
Ball blue{ "blue" };
blue.print();
Ball twenty{ 20.0 };
twenty.print();
Ball blueTwenty{ "blue", 20.0 };
blueTwenty.print();
return 0;
}
и выдавать следующий результат:
color: black, radius: 10
color: blue, radius: 10
color: black, radius: 20
color: blue, radius: 20
Ответ
#include <iostream> #include <string> class Ball { private: std::string m_color{}; double m_radius{}; public: // Конструктор по умолчанию без параметров Ball() { m_color = "black"; m_radius = 10.0; } // Конструктор только с параметром цвета (радиус будет использовать значение по умолчанию) Ball(const std::string &color) { m_color = color; m_radius = 10.0; } // Конструктор только с параметром радиуса (цвет будет использовать значение по умолчанию) Ball(double radius) { m_color = "black"; m_radius = radius; } // Конструктор с параметрами цвета и радиуса Ball(const std::string &color, double radius) { m_color = color; m_radius = radius; } void print() { std::cout << "color: " << m_color << ", radius: " << m_radius << '\n'; } }; int main() { Ball def{}; def.print(); Ball blue{ "blue" }; blue.print(); Ball twenty{ 20.0 }; twenty.print(); Ball blueTwenty{ "blue", 20.0 }; blueTwenty.print(); return 0; }
b) Обновите свой ответ на предыдущий вопрос, чтобы использовать конструкторы с параметрами по умолчанию. Используйте как можно меньше конструкторов.
Ответ
#include <iostream> #include <string> class Ball { private: std::string m_color{}; double m_radius{}; public: // Конструктор только с параметром радиуса // (цвет будет использовать значение по умолчанию) Ball(double radius) { m_color = "black"; m_radius = radius; } // Конструктор с параметрами цвета и радиуса // обрабатывает отсутствие параметров, только цвет и цвет + радиус. Ball(const std::string &color="black", double radius=10.0) { m_color = color; m_radius = radius; } void print() { std::cout << "color: " << m_color << ", radius: " << m_radius << '\n'; } }; int main() { Ball def{}; def.print(); Ball blue{ "blue" }; blue.print(); Ball twenty{ 20.0 }; twenty.print(); Ball blueTwenty{ "blue", 20.0 }; blueTwenty.print(); return 0; }
Вопрос 2
Что произойдет, если вы не объявите конструктор по умолчанию?
Ответ
Если вы не определили никаких других конструкторов, компилятор создаст для вас пустой открытый конструктор по умолчанию. Это означает, что ваши объекты будут создаваться без параметров.
Если вы определили другие конструкторы (по умолчанию или нет), компилятор не создаст для вас конструктор по умолчанию. Предполагая, что вы сами не предоставили конструктор по умолчанию, ваши объекты не будут создаваться без аргументов.