Введение
C ++ обрабатывает переменные пользовательских типов с семантикой значения .
Это означает, что объекты неявно копируются в различных контекстах,
и мы должны понимать, что на самом деле означает «копирование объекта».
Давайте рассмотрим простой пример:
class person
{
std::string name;
int age;
public:
person(const std::string& name, int age) : name(name), age(age)
{
}
};
int main()
{
person a("Bjarne Stroustrup", 60);
person b(a); // What happens here?
b = a; // And here?
}
(Если вы озадачены частью name(name), age(age)
,
это называется список инициализаторов .)
Специальные функции-члены
Что значит скопировать объект person
?
Функция main
показывает два различных сценария копирования.
Инициализация person b(a);
выполняется конструктором копирования .
Его работа заключается в создании нового объекта на основе состояния существующего объекта.
Назначение b = a
выполняется оператором копирования .
Его работа, как правило, немного сложнее,
потому что целевой объект уже находится в каком-то допустимом состоянии, с которым нужно иметь дело.
Поскольку мы сами не объявили ни конструктор копирования, ни оператор присваивания (ни деструктор),
они неявно определены для нас. Цитата из стандарта:
Конструктор [...] копирования и оператор присваивания копии, [...] и деструктор являются специальными функциями-членами.
[ Примечание : Реализация будет неявно объявлять эти функции-члены
для некоторых типов классов, когда программа явно не объявляет их.
Реализация будет неявно определять их, если они используются. [...] конечная нота ]
[n3126.pdf раздел 12 §1]
По умолчанию копирование объекта означает копирование его членов:
Неявно определенный конструктор копирования для класса X, не являющегося объединением, выполняет пошаговую копию своих подобъектов.
[n3126.pdf раздел 12.8 §16]
Неявно определенный оператор присваивания копии для класса X, не являющегося объединением, выполняет присваивание поочередно для копирования
его подобъектов.
[n3126.pdf раздел 12.8 §30]
Неявные определения
Неявно определенные специальные функции-члены для person
выглядят так:
// 1. copy constructor
person(const person& that) : name(that.name), age(that.age)
{
}
// 2. copy assignment operator
person& operator=(const person& that)
{
name = that.name;
age = that.age;
return *this;
}
// 3. destructor
~person()
{
}
Копирование по элементам именно то, что мы хотим в этом случае:
name
и age
копируются, поэтому мы получаем автономный, независимый person
объект.
Неявно определенный деструктор всегда пуст.
Это также хорошо в этом случае, так как мы не получили никаких ресурсов в конструкторе.
Деструкторы членов неявно вызываются после завершения деструктора person
:
После выполнения тела деструктора и уничтожения любых автоматических объектов, размещенных в теле,
деструктор для класса X вызывает деструкторы для прямых [...] членов X
[n3126.pdf 12.4 §6]
Управление ресурсами
Так когда же мы должны объявить эти специальные функции-члены явно?
Когда наш класс управляет ресурсом , то есть
когда объект класса отвечает за за этот ресурс.
Обычно это означает, что ресурс получен в конструкторе.
(или передается в конструктор) и освобождается в деструкторе.
Давайте вернемся назад к предварительному стандарту C ++.
Не было такого понятия, как std::string
, и программисты были влюблены в указатели.
Класс person
мог бы выглядеть так:
class person
{
char* name;
int age;
public:
// the constructor acquires a resource:
// in this case, dynamic memory obtained via new[]
person(const char* the_name, int the_age)
{
name = new char[strlen(the_name) + 1];
strcpy(name, the_name);
age = the_age;
}
// the destructor must release this resource via delete[]
~person()
{
delete[] name;
}
};
Даже сегодня люди все еще пишут уроки в этом стиле и попадают в неприятности:
« Я толкнул человека в вектор, и теперь я получаю сумасшедшие ошибки памяти! »
Помните, что по умолчанию копирование объекта означает копирование его членов,
но копирование элемента name
просто копирует указатель, не массив символов, на который он указывает!
Это имеет несколько неприятных эффектов:
- Изменения через
a
можно наблюдать через b
.
- Как только
b
уничтожено, a.name
является висящим указателем.
- Если
a
уничтожено, удаление висящего указателя приводит к неопределенному поведению .
- Поскольку в присвоении не учитывается то, на что указывал
name
до присвоения,
Рано или поздно у вас появятся утечки памяти повсюду.
Явные определения
Поскольку копирование по элементам не дает желаемого эффекта, мы должны явно определить конструктор копирования и оператор назначения копирования, чтобы сделать глубокие копии массива символов:
// 1. copy constructor
person(const person& that)
{
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}
// 2. copy assignment operator
person& operator=(const person& that)
{
if (this != &that)
{
delete[] name;
// This is a dangerous point in the flow of execution!
// We have temporarily invalidated the class invariants,
// and the next statement might throw an exception,
// leaving the object in an invalid state :(
name = new char[strlen(that.name) + 1];
strcpy(name, that.name);
age = that.age;
}
return *this;
}
Обратите внимание на разницу между инициализацией и назначением:
мы должны разрушить старое состояние перед присвоением name
, чтобы предотвратить утечки памяти.
Также мы должны защищаться от самостоятельного присвоения формы x = x
.
Без этой проверки delete[] name
удалит массив, содержащий строку source ,
потому что когда вы пишете x = x
, оба this->name
и that.name
содержат один и тот же указатель.
Исключительная безопасность
К сожалению, это решение не будет работать, если new char[...]
выдает исключение из-за исчерпания памяти.
Одно из возможных решений - ввести локальную переменную и изменить порядок операторов:
// 2. copy assignment operator
person& operator=(const person& that)
{
char* local_name = new char[strlen(that.name) + 1];
// If the above statement throws,
// the object is still in the same state as before.
// None of the following statements will throw an exception :)
strcpy(local_name, that.name);
delete[] name;
name = local_name;
age = that.age;
return *this;
}
Это также заботится о самостоятельном назначении без явной проверки.
Еще более надежное решение этой проблемы - идиома копирования и замены ,
но я не буду вдаваться в детали безопасности исключений здесь.
Я упомянул только исключения, чтобы подчеркнуть следующее: Написание классов, которые управляют ресурсами, является трудным делом.
Некопируемые ресурсы
Некоторые ресурсы не могут или не должны копироваться, например, дескрипторы файлов или мьютексы.
В этом случае просто объявите конструктор копирования и оператор присваивания копии как private
, не задавая определение:
private:
person(const person& that);
person& operator=(const person& that);
Кроме того, вы можете наследовать от boost::noncopyable
или объявить их как удаленные (в C ++ 11 и выше):
person(const person& that) = delete;
person& operator=(const person& that) = delete;
Правило трех
Иногда вам нужно реализовать класс, который управляет ресурсом.
(Никогда не управляйте несколькими ресурсами в одном классе,
это приведет только к боли.)
В этом случае помните правило о трех :
Если вам нужно явно объявить либо деструктор,
Скопируйте конструктор или скопируйте оператор присваивания самостоятельно,
вам, вероятно, нужно явно объявить все три из них.
(К сожалению, это «правило» не применяется стандартом C ++ или любым известным мне компилятором.)
Правило пяти
Начиная с C ++ 11, объект имеет 2 дополнительные специальные функции-члены: конструктор перемещения и назначение перемещения. Правило пяти состояний для реализации этих функций также.
Пример с подписями:
class person
{
std::string name;
int age;
public:
person(const std::string& name, int age); // Ctor
person(const person &) = default; // Copy Ctor
person(person &&) noexcept = default; // Move Ctor
person& operator=(const person &) = default; // Copy Assignment
person& operator=(person &&) noexcept = default; // Move Assignment
~person() noexcept = default; // Dtor
};
Правило нуля
Правило 3/5 также называется правилом 0/3/5. Нулевая часть правила гласит, что вам не разрешается писать какие-либо специальные функции-члены при создании вашего класса.
Рекомендации
В большинстве случаев вам не нужно самостоятельно управлять ресурсом,
потому что существующий класс, такой как std::string
, уже делает это за вас.
Просто сравните простой код, используя член std::string
к замысловатой и подверженной ошибкам альтернативе, использующей char*
, и вы должны быть убеждены.
Пока вы держитесь подальше от необработанных членов-указателей, правило трех вряд ли будет касаться вашего собственного кода.