Pull to refresh

CИ++: Закон Большой Двойки

Reading time9 min
Views10K
Original author: Бьярн Карлссон и Мэттью Уилсон
оригинал: www.artima.com/cppsource/bigtwo.html
авторы: Бьярн Карлссон и Мэттью Уилсон (Bjorn Karlsson и Matthew Wilson)
1 октября, 2004

Краткое содержание

Добро пожаловать в первый выпуск, посвященный Умным Указателям, в ежемесячную колонку, написанную исключительно для «The C++ Source». В этой колонке программисты Бьярн и Мэттью осторожно будут рассматривать С++ идиомы, триксы и мощные техники. Чтобы вы не погрязли во всей сложности рассматриваемых серьезных тем, мы время от времени будем разбавлять их программерскими шутками. Итак, кто сказал что здесь нету таких вещей как бесплатный ланч? В этом выпуске авторы пересмотрят «Закон Большой Тройки», и объяснят какие из этих трех магических составляющих зачастую не нужны.

Все верно, вы правильно прочли заголовок статьи. Хорошо известное и очень важное правило, известное как «Закон Большой Тройки», утверждает, что если вам когда-либо нужен (не-виртуальный) конструктор копии, копирующий оператор присваивания, или деструктор, то скорее всего вам также потребуется реализовать и все остальные из них. Этот набор особых функций, — конструктора копии, копирующего оператора присваивания, и деструктора, с гордостью называется «Большой Тройкой» С++ программистами по всему свету; это название ему дал Маршалл Клайн (Marshall Cline) в C++ FAQ-ах. Мы утверждаем, что одна из этих трех особых функций не будет проблемой для большинства классов. И эта статья пояснит, почему это так.

Происхождение

Чтобы понять, что вообще понимается под «Законом Большой Тройки», посмотрим, что произойдет когда мы динамически выделим память под ресурсы в классе (SomeResource* p_ в коде ниже):

class Example {
SomeResource* p_;
public:
Example(): p_(new SomeResource()) {}
};

Теперь, т.к. мы выделили в конструкторе память под ресурс, нам будет необходимо освободить её в деструкторе, т.е. добавить:

~Example() {
delete p_;
}

Вот и все, все окей, верно? Неверно! Как только кто-нибудь решит создать класс при помощи конструктора копии, все пойдет к чертям. Причина в том, что сгенерированный компилятором конструктор копии просто создаст копию указателя p_; у него нет никаких способов узнать, что ему также нужно выделить память под новый SomeResource. Таким образом, когда первый объект класса Example будет удален, его деструктор освободит p_. Дальнейшее использование ресурсов в другом объекте класса Example (включая удаление этого ресурса в его деструкторе, естественно) приведет к повторной попытке освободить уже освобожденную память, потому что объект типа SomeResource уже не существует. Проверим это:

class Example {
SomeResource* p_;
public:
Example(): p_(new SomeResource()) {
std::cout << «Creating Example, allocating SomeResource!\n»;
}
~Example() {
std::cout << «Deleting Example, freeing SomeResource!\n»;
delete p_;
}
};

int main() {
Example e1;
Example e2(e1);
}

Запуск этой программы гарантированно приводит к плачевным результатам. Запускаем:

C:\projects>bigthree.exe
Creating Example, allocating SomeResource!
Deleting Example, freeing SomeResource!
Deleting Example, freeing SomeResource!
6 [main] bigthree 2664 handle_exceptions:
Exception: STATUS_ACCESS_VIOLATION
1176 [main] bigthree 2664 open_stackdumpfile:
Dumping stack trace to bigthree.exe.stackdump

Ясно, что нам потребуется позаботиться о конструкторе копии, который бы корректно копировал SomeResource:

Example(const Example& other): p_(new SomeResource(*other.p_)) {}

Полагая, что SomeResource имеет доступный конструктор копии, это немного улучшит ситуацию; но по прежнему эта программа грохнется, как только кто-нибудь решит попробовать присвоить объект класса Example другому объекту этого же класса:

int main() {
Example e1;
Example e2;
e2=e1;
}

Последствия будут более трагичны в таком случае; взглянем на вывод:

C:\projects>bigthree.exe
Creating Example, allocating SomeResource!
Creating Example, allocating SomeResource!
Deleting Example, freeing SomeResource!
Deleting Example, freeing SomeResource!
5 [main] bigthree 3780 handle_exceptions:
Exception: STATUS_ACCESS_VIOLATION
1224 [main] bigthree 3780 open_stackdumpfile:
Dumping stack trace to bigthree.exe.stackdump

Как мы видим, была выделена память под два объекта типа SomeResource, и оба они были удалены. Так в чем же проблема? Что ж, проблема в том, что оба объекта класса Example указывают на один и тот же объект типа SomeResource! Это имеет место из-за автоматически сгенерированного копирующего оператора присваивания, который всего лишь знает, как приравнять указатель на SomeResource. Таким образом, нам также необходимо реализовать соответствующий копирующий оператор копии для того, чтобы разобраться с конструктором копии:

Example& operator=(const Example& other) {
// Self assignment?
if (this==&other)
return *this;

*p_=*other.p_; // используем SomeResource::operator=
return *this;
}

Вы заметите, что этот оператор сначала проверяет попытку самоприсваивания, и в таком случае попросту возвращает *this. С учетом exception safety копирующий оператор копии обеспечивает её базовые гарантии.

Теперь программа ведет себя верно! Урок, показанный выше, в точности именно то что и называется «Законом Большой Тройки»: как только появляется нужда в не-тривиальном деструкторе, убедитесь, что конструктор копии и копирующий оператор присваивания также действуют верно. В большинстве случаев это проверяется вручную, в ходе их реализации.

Запрет использования Конструктора Копии и Копирующего Оператора Присваивания

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

class SelfishBeastie
{
...

private:
SelfishBeastie(const SelfishBeastie&);
SelfishBeastie& operator=(const SelfishBeastie&);
};

Другим вариантом будет использование класса boost::noncopyable из библиотеки Boost; наследование от такого класса будет вполне корректным, т.к. этот класс поясняет, что он не поддерживает копирование и присваивание (по крайней мере для тех, кто знаком с noncopyable!).

class SelfishBeastie
: boost::noncopyable
{
...

Еще одним способом запрета конструктора копии и копирующего оператора присваивания является изменение типа одного или более членов класса на ссылку или const (или на const ссылку, для бОльшей осторожности) — это эффективно отрубит способность компилятора генерировать эти особые функции члены. Как писал Мэттью в своей книге «Imperfect C++», это нежелательный способ создания некопирующегося класса, т.к. этот способ нарушает взаимодействие с интерфейсом класса для его пользователей. Однако, это великолепный способ достичь решений проектирования; таким образом, это механизм для «общения» исходных решений по интерфейсу с будущими реализациями этого класса, и тем более для документирования семантики класса. (Конечно, при такой технике всем конструкторам класса понадобится инициализировать члены ссылки (вместо того чтобы перезаписывать их в теле конструктора), что само по себе является хорошей идеей).

Большой Тройки Недостаточно

Хотя мы и пришли к способу создания работоспособного и корректного класса Example, легко запутаться в исключительных случаях, которые могут быть. Давайте добавим еще один указатель на SomeResource в наш класс Example, например так:

class Example {
SomeResource* p_;
SomeResource* p2_;
public:
Example():
p_(new SomeResource()),
p2_(new SomeResource()) {
std::cout << «Creating Example, allocating SomeResource!\n»;
}

Example(const Example& other):
p_(new SomeResource(*other.p_)),
p2_(new SomeResource(*other.p2_)) {}

Example& operator=(const Example& other) {
// Self assignment?
if (this==&other)
return *this;

*p_=*other.p_;
*p2_=*other.p2_;
return *this;
}

~Example() {
std::cout << «Deleting Example, freeing SomeResource!\n»;
delete p_;
delete p2_;
}
};

Теперь попробуем предположить, что произойдет когда в процессе создания объекта класса Example второй объект типа SomeResource (на который указывает p2_) выбросит исключение. Объект, на который указывает p_ уже был размещен в памяти, но все же деструктор не будет вызван! Причина в том, что с точки зрения компилятора объект класса Example никогда не существовал, потому что его конструктор не был завершен. Это, к сожалению, значит то, что Example не exception-safe, в силу возможности утечки ресурсов.

Чтобы сделать его безопасным, можно как вариант поместить инициализацию вне ctor-initializer, например, так:

Example(): p_(0),p2_(0)
{
try {
p_=new SomeResource();
p2_=new SomeResource(«H»,true);
std::cout << «Creating Example, allocating SomeResource!\n»;
}
catch(...) {
delete p2_;
delete p_;
throw;
}
}

Хотя мы и справились с проблемой exception-safety, это не совсем подходящее решение, т.к. мы, С++ программисты, предпочитаем инициализацию, а не присваивание. Как вы вскоре увидите, для этого на помощь нам придет старый и надежный способ.

RAII Спасает День

Мы могли бы применить в нашем случае вездесущую технику RAII (Resource Acquisition Is Initialization, Получение/Выделение Ресурсов Есть Инициализация), т.к. мы ищем как раз то, что и является по сути сущностью RAII, а именно, то свойство, что конструктор локального объекта управляет выделением ресурсов, а его деструктор освобождает их.
Пользуясь этой идиомой невозможно забыть свободить ресурсы; также эта идиома не требует помнить о хитрых сложностях, с которыми мы воевали вручную в классе Example. Простой класс-враппер, предназначенный в основном для добавления RAII функциональности в простые классы подобные SomeResource, мог бы выглядеть так:

template class RAII {
T* p_;
public:
explicit RAII(T* p): p_(p) {}

~RAII() {
delete p_;
}

void reset(T* p) {
delete p_;
p_=p;
}

T* get() const {
return p_;
}

T& operator*() const {
return *p_;
}

void swap(RAII& other) {
std::swap(p_,other.p_);
}

private:
RAII(const RAII& other);
RAII& operator=(const RAII& other);
};

Единственные задачи, которые есть у этого класса — хранение указателя, возвращение его по запросу, и корректное освобождение памяти, на которую он указывает, при разрушении. Применение такого класса очень сильно упростит класс Example; как сейчас, так и при дальнейших его изменениях.

class Example {
RAII p_;
RAII p2_;
public:
Example():
p_(new SomeResource()),
p2_(new SomeResource()) {}

Example(const Example& other)
: p_(new SomeResource(*other.p_)),
p2_(new SomeResource(*other.p2_)) {}

Example& operator=(const Example& other) {
// Self assignment?
if (this==&other)
return *this;

*p_=*other.p_;
*p2_=*other.p2_;
return *this;
}

~Example() {
std::cout << «Deleting Example, freeing SomeResource!\n»;
}
};

Мы по сути вернулись к тому, откуда начали, используя изначальную версию класса, которая не заботилась об exception safety.

Это подводит нас к очень важной мысли — деструктор теперь не выполняет никакой работы кроме вывода простого логгирующего сообщения:

~Example() {
std::cout << «Deleting Example, freeing SomeResource!\n»;
}

Это значит, что любой мог бы (и даже должен был) убрать деструктор из класса, и вместо этого полагаться на версию, сгенерированную компилятором. Одна функция из Большой Тройки внезапно осталась без работы для класса Example! Однако, мы должны учесть после такого размышления, что наш простой пример всего лишь работал с сырыми указателями (raw pointers); в реальных программах гораздо больше ресурсов, чем raw pointers. И хотя многие из них обеспечивают способы освобождения при удалении (опять, работая через RAII), другие не заботятся об этом. Всего этого можно добиться без деструктора, и это будет темой следующего раздела.

На заметку: прилежные читатели могут заметить, что вышеописанный класс RAII — это не единственная реализация RAII, да она и не должна быть единственной, потому что в Стандартной Библиотеке С++ уже есть похожая реализация, которая называется std::auto_ptr. Она главным образом работает по тому же принципу, что и наш RAII класс выше, только лучше. Зачем же писать свою версию? Потому что auto_ptr определяет public конструктор копии и копирующий оператор присваивания, оба из которых предполагают владение ресурсом, в то время как нам может потребоваться сделать его копируемым (класс RAII также не заботится об этом, но по крайней мере он напоминает нам о том, что это надо бы сделать). Нам нужно скопировать ресурс, а не по-тихому передать бразды по его управлению, поэтому для простоты этого примера нам вполне хватило изобрести вышеописанное колесо:)

Умные Указатели или Умные Ресурсы?

Всё то, что мы показали в этой статье в терминах управления ресурсами, существует в реализации каждого класса умных указателей (сотни программистов считают, что реализации умных указателей в Boost наиболее хорошие среди них). Но, как уже было упомянуто, управление ресурсами — это не только вызов delete, и оно может требовать какую-то особую логику по управлению, или просто другие способы освобождения ресурсов (например, вызвать close() ). Это является причиной того, что все больше и больше умных указателей становятся умными ресурсами; помимо поддержки автоматического удаления динамически выделенных ресурсов, они позволяют различные возможности по вызову определенных пользователем этого класса способов освобождения занятой памяти, или даже возможность объявления освобождающей память фунции заинлайненной (inline) (это стало возможным при помощи bind expressions и lambda expressions, например из Boost.Lambda). Большая часть кода, которая ранее помещалась в деструктор аггрегирущего класса теперь более тесно совмещается с ресурсами (или с resource holder-ом), которые идеально реализуют эту операцию. Будет интересно посмотреть на дальнейшие баги в этой области. С учетом многопоточности и exception-safety управления, которое уходит далеко за пределы того, к чему многие из нас ранее обращались (по крайней мере это так для авторов), умные способы по управлению ресурсами становятся все более и более важными.

Заключение

«Закон Большой Тройки» сыграл и продолжает играть свою важную роль в С++. Однако, мы думаем что есть причина убрать деструктор как из дискуссии так и из реализации, когда это возможно, что тем самым приводит нас к производному «Закону Большой Двойки». Причина в том, что зачастую следует избегать в членах класса сырых указателей, и они должны заменяться на умные указатели. В ином случае роль конструкторов копии и копирующих операторов присваивания зачастую забывается и игнорируется; мы надеемся, что эта статья поможет указать на это в некоторых случаях.

Благодарности

Мы хотели бы поблагодарить Маршалла Клайна (Marshall Cline) за изобретение легко запоминающегося названия «Большая Тройка» в C++ FAQах. Это помогло многим программистам помнить о добавлении конструкторов копии и копирующих операторов присваивания как нужно.
Спасибо Бьярну Страуструпу (Bjarne Stroustrup) за краткое объяснение RAII в «C++ Style And Technique FAQ» и в его бессмертном томе «The C++ Programming Language (3rd Edition)».
Спасибо Чак Эллисону (Chuch Allison) за редактирование этой статьи (и многих наших других статей_ и за её (их) улучшения.
Спасибо Андрею Александреску (Andrei Alexandrescu), Дэвиду Абрахамсу (David Abrahams) и Торстен Оттосену (Thorsten Ottose) за просмотр статьи.
Спасибо Андрею Александреску (Andrei Alexandrescu), Бьярну Страуструпу (Bjarne Stroustrup), Чак Эллисону (Chuch Allison), Дэвиду Абрахамсу (David Abrahams), Натан Майерсу (Nathan Myers), и Вальтеру Брайту (Walter Bright) за обсуждение ins и outs в function- try-blocks[13].
Спасибо Дэниелу Тэске (Daniel Teske), Мехулю Махимтура (Mehul Mahimtura), и Веса Карвонену (Vesa Karvonen) за значимые заметки и комментарии.
Tags:
Hubs:
Total votes 12: ↑5 and ↓7-2
Comments13

Articles