13.12 – Конструктор копирования
Краткое описание типов инициализации
Поскольку мы собираемся много говорить об инициализации в следующих нескольких уроках, давайте сначала вспомним типы инициализации, которые поддерживает C++: прямая инициализация, унифицированная инициализация или копирующая инициализация.
Вот примеры всех их с использованием нашего класса Fraction
:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{};
int m_denominator{};
public:
// конструктор по умолчанию
Fraction(int numerator=0, int denominator=1) :
m_numerator{numerator}, m_denominator{denominator}
{
assert(denominator != 0);
}
friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
Мы можем выполнить прямую инициализацию:
// Прямая инициализация числа int
int x(5);
// Прямая инициализация Fraction, вызов конструктора Fraction(int, int)
Fraction fiveThirds(5, 3);
В C++11 мы можем выполнить унифицированную инициализацию:
// Унифицированная инициализация числа int
int x { 5 };
// Унифицированная инициализация Fraction, вызывает конструктор Fraction(int, int)
Fraction fiveThirds {5, 3};
И наконец, мы можем выполнить копирующую инициализацию:
// Копирующая инициализация числа int
int x = 6;
// Копирующая инициализация Fraction, вызовет Fraction(6, 1)
Fraction six = Fraction(6);
// Копирующая инициализация Fraction.
// Компилятор попытается найти способ преобразовать 7 в Fraction,
// что вызовет конструктор Fraction(7, 1).
Fraction seven = 7;
При прямой и унифицированной инициализациях создаваемый объект инициализируется напрямую. Однако копирующая инициализация немного сложнее. Мы рассмотрим копирующую инициализацию более подробно в следующем уроке. Но чтобы сделать это эффективно, нам нужно сделать небольшое отступление.
Конструктор копирования
Теперь рассмотрим следующую программу:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{};
int m_denominator{};
public:
// Конструктор по умолчанию
Fraction(int numerator=0, int denominator=1) :
m_numerator{numerator}, m_denominator{denominator}
{
assert(denominator != 0);
}
friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
int main()
{
// Прямая инициализация Fraction, вызов конструктора Fraction(int, int)
Fraction fiveThirds(5, 3);
// Прямая инициализация - каким конструктором?
Fraction fCopy(fiveThirds);
std::cout << fCopy << '\n';
}
Если вы скомпилируете эту программу, вы увидите, что она компилируется нормально и дает результат:
5/3
Давайте подробнее рассмотрим, как она работает.
Инициализация переменной fiveThirds
– это просто стандартная прямая инициализация, которая вызывает конструктор Fraction(int, int)
. Никаких сюрпризов. А как насчет следующей строки? Инициализация переменной fCopy
также явно является прямой инициализацией, и вы знаете, что функции-конструкторы используются для инициализации классов. Итак, какой конструктор вызывает эта строка?
Ответ заключается в том, что эта строка вызывает конструктор копирования Fraction
. Конструктор копирования – это особый тип конструктора, используемый для создания нового объекта как копии существующего объекта. И так же, как конструктор по умолчанию, если вы не предоставляете конструктор копирования для своих классов, C++ создаст для вас открытый (public) конструктор копирования. Поскольку компилятор мало что знает о вашем классе, созданный по умолчанию конструктор копирования использует метод инициализации, называемый поэлементной инициализацией. Поэлементная инициализация просто означает, что каждый член копии инициализируется напрямую членом копируемого класса. В приведенном выше примере fCopy.m_numerator
будет инициализирован из fiveThirds.m_numerator
и т.д.
Так же, как мы можем явно определить конструктор по умолчанию, мы также можем явно определить конструктор копирования. Конструктор копирования выглядит так, как вы можете ожидать:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{};
int m_denominator{};
public:
// Конструктор по умолчанию
Fraction(int numerator=0, int denominator=1) :
m_numerator{numerator}, m_denominator{denominator}
{
assert(denominator != 0);
}
// Конструктор копирования
Fraction(const Fraction &fraction) :
m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
// Обратите внимание: мы можем получить доступ к членам параметра fraction
// напрямую, потому что мы внутри класса Fraction
{
// здесь нет необходимости проверять знаменатель на 0,
// поскольку fraction уже должна быть корректным объектом Fraction
std::cout << "Copy constructor called\n"; // просто чтобы доказать, что это работает
}
friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
int main()
{
// Прямая инициализация Fraction, вызов конструктора Fraction(int, int)
Fraction fiveThirds(5, 3);
// Прямая инициализация - с конструктором копирования Fraction
Fraction fCopy(fiveThirds);
std::cout << fCopy << '\n';
}
При запуске этой программы вы получите:
Copy constructor called
5/3
Конструктор копирования, который мы определили в приведенном выше примере, использует поэлементную инициализацию и функционально эквивалентен тому, который мы получаем по умолчанию, за исключением того, что мы добавили инструкцию вывода, чтобы доказать, что он вызывается.
В отличие от конструкторов по умолчанию, конструктор копирования по умолчанию можно использовать, если он соответствует вашим потребностям.
Одно интересное замечание: вы уже видели несколько примеров перегруженного оператора operator<<
, в котором мы можем получить доступ к закрытым членам параметра f1
, потому что функция является другом класса Fraction
. Точно так же функции-члены класса могут обращаться к закрытым членам параметров одного и того же типа класса. Поскольку наш конструктор копирования Fraction
принимает параметр типа класса (для создания копии), мы можем напрямую обращаться к членам параметра дроби Fraction
, даже если это не неявный объект.
Предотвращение копирования
Мы можем предотвратить создание копий объектов наших классов, сделав конструктор копирования закрытым:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{};
int m_denominator{};
// Конструктор копирования (закрытый)
Fraction(const Fraction &fraction) :
m_numerator{fraction.m_numerator}, m_denominator{fraction.m_denominator}
{
// здесь нет необходимости проверять знаменатель на 0,
// поскольку fraction уже должна быть корректным объектом Fraction
std::cout << "Copy constructor called\n"; // просто чтобы доказать, что это работает
}
public:
// Конструктор по умолчанию
Fraction(int numerator=0, int denominator=1) :
m_numerator{numerator}, m_denominator{denominator}
{
assert(denominator != 0);
}
friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
int main()
{
// Прямая инициализация Fraction, вызов конструктора Fraction(int, int)
Fraction fiveThirds(5, 3);
// Конструктор копирования закрытый, эта строка вызовет ошибку компиляции
Fraction fCopy(fiveThirds);
std::cout << fCopy << '\n';
}
Теперь, когда мы попытаемся скомпилировать нашу программу, мы получим ошибку компиляции, поскольку для fCopy
необходимо использовать конструктор копирования, но он его не может увидеть, поскольку конструктор копирования был объявлен как закрытый.
Конструктор копирования может быть опущен
Теперь рассмотрим следующий пример:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator{};
int m_denominator{};
public:
// Конструктор по умолчанию
Fraction(int numerator=0, int denominator=1) :
m_numerator{numerator}, m_denominator{denominator}
{
assert(denominator != 0);
}
// Конструктор копирования
Fraction(const Fraction &fraction) :
m_numerator(fraction.m_numerator), m_denominator(fraction.m_denominator)
{
// здесь нет необходимости проверять знаменатель на 0,
// поскольку fraction уже должна быть корректным объектом Fraction
std::cout << "Copy constructor called\n"; // просто чтобы доказать, что это работает
}
friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
int main()
{
Fraction fiveThirds(Fraction(5, 3));
std::cout << fiveThirds;
return 0;
}
Рассмотрим, как работает эта программа. Сначала мы напрямую инициализируем анонимный объект Fraction
, используя конструктор Fraction(int, int)
. Затем мы используем этот анонимный объект Fraction
в качестве инициализатора для Fraction fiveThirds
. Поскольку анонимный объект является Fraction
, как и fiveThirds
, это должно вызывать конструктор копирования, верно?
Скомпилируйте и запустите этот код у себя. Вы, вероятно, ожидали получить такой результат (и можете его получить):
copy constructor called
5/3
Но на самом деле у вас больше шансов получить такой результат:
5/3
Почему не был вызван наш конструктор копирования?
Обратите внимание, что для инициализации анонимного объекта и последующего использования этого объекта для прямой инициализации нашего определенного объекта требуется два шага (один для создания анонимного объекта, второй для вызова конструктора копирования). Однако конечный результат по сути идентичен прямой инициализации, которая занимает всего один шаг.
По этой причине в таких случаях компилятору разрешено отказаться от вызова конструктора копирования и вместо этого просто выполнить прямую инициализацию. Этот процесс называется элизией (исключением).
Итак, хотя вы написали:
Fraction fiveThirds(Fraction(5, 3));
Компилятор может изменить это на:
Fraction fiveThirds(5, 3);
для чего требуется только один вызов конструктора (Fraction(int, int)
). Обратите внимание, что в случаях, когда используется исключение, любые инструкции в теле конструктора копирования не выполняются, даже если они могут вызывать побочные эффекты (например, печать на экране)!
До C++17 исключение копирования – это оптимизация, которую может сделать компилятор. Начиная с C++17, некоторые случаи исключения копирования (включая приведенный выше пример) стали обязательными.
Наконец, обратите внимание, что если вы сделаете конструктор копирования закрытым, любая инициализация, которая будет использовать конструктор копирования, вызовет ошибку компиляции, даже если конструктор копирования опущен!