Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #132, ДЕКАБРЬ 2009 г.

Кодерские типсы и триксы: три правила кодинга на C++ для настоящих спецов


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

Сегодня мы поговорим об операторе присваивания в языке C++. Казалось бы, что такого страшного может быть в этом маленьком знаке «=»? А нет, даже тут великий СРР готовит нам свои сюрпризы. Итак, поехали.

Правилоs №1

Одно из интересных свойств присваивания в C++ заключается в том, что его можно выполнять последовательно. Кроме того, оператор присваивания правоассоциативен. Что это значит? Продемонстрируем следующий код:

Чудесный оператор присваивания

int x,y,z;
x = y = z = 15;
// Строка выше и строка ниже - в принципе, одно и то же.
// Скобки показывают последовательность выполнения присваиваний
x = (y = (z = 15));

Здесь переменной z присваивается значение 15, затем результат присваивания (новое значение z) присваивается переменной y, после чего новое значение y присваивается переменной x. Достигается это за счет того, что оператор присваивания возвращает ссылку на свой левый аргумент, и этому соглашению мы должны следовать при реализации операторов присваивания в своих классах.

Оператор «=» в своих классах

class Widget {
public:
...
// Возвращаемый тип — ссылка на текущий класс
Widget& operator=(const Widget& rhs)
{
...
// возвращаем объект из левой части выражения
return *this;
}
...
};

Это соглашение касается всех операторов присваивания, а не только стандартной его формы. Так, например, для операторов += и = с приведением типа аргумента их определение будет выглядеть так:

Определение нестандартных операторов присваивания

class Widget {
public:
...
// Соглашение распространяется на +=, -= и т.д.
Widget& operator+=(const Widget& rhs)
{
...
return *this;
}
// это относится даже к параметрам разных типов
Widget& operator=(int rhs)
{
...
return *this;
}
...
};

Это всего лишь соглашение и, если мы не будем его придерживаться, наша программа все равно будет скомпилирована. Но этому соглашению следуют все встроенные типы и типы стандартной библиотеки, такие как string, vector и т.д. Если у нас нет веской причины нарушать его, лучше этого и не делать.

Из первого правила нам нужно запомнить, что оператор присваивания следует писать так, чтобы он возвращал ссылку на *this.

Правило №2

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

Потенциальное присваивание самому себе

class Widget {...};
Widget w;
...
// очевидное присваивание самому себе
w = w;
// а это уже менее очевидно
a[i] = a[j];
*px = *py;

Эти не совсем очевидные случаи присваивания себе являются результатом так называемого совмещения имен (aliasing), когда на один и тот же объект ссылаются несколькими разными способами. Программа, которая оперирует ссылками или указателями на объекты одного и того же типа, должна считаться с тем, что эти объекты могут совпадать. Необязательно даже, чтобы два объекта имели одинаковый тип. Если они принадлежат к одной иерархии классов, то ссылка или указатель на базовый класс могут в действительности относиться к производному классу.

Еще одно присваивание себе

class Base {...};
class Derived: public Base {...};
// rb и *pb могут быть одним и тем же объектом
void doSomething(const Base& rb, Dereved *pb);

Если мы будем писать объект для управления ресурсами, о котором я рассказывал в прошлой статье, то нам непременно придется подумать о безопасности присваивания. Может сложиться ситуация, в которой мы случайно освободим ресурс до его использования. Предположим, что мы создали класс, который содержит указатель на динамический объект класса Bitmap. Ниже приведена реализация оператора присваивания, которая на первый взгляд кажется совершенно нормальной, но становится опасной в случае присваивания самому себе или при возникновении исключения.

Опасный OPERATOR=

class Bitmap {...};
class Widget {
...
private:
Bitmap *pb;
};
Widget& Widget::operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

Проблема в том, что в теле operator= *this (чему присваивается значение) и rhs (что присваивается) могут оказаться одним и тем же объектом. В этом случае delete удалит не только старый Bitmap, принадлежащий текущему объекту, но и новый, который мы хотим присвоить. В результате, по завершению выполнения кода оператора мы получим в pb указатель на несуществующий объект, хотя рассчитывали там увидеть совсем другое.

Традиционный способ решить эту проблему состоит в том, чтобы в начале operator= выполнить проверку на присваивание самому себе:

Проверка на присваивание себе

Widget& Widget::operator=(const Widget& rhs)
{
// проверка на совпадение
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

Такая проверка позволяет избежать ошибки, описанной выше, но есть еще проблема с безопасностью в контексте исключений. Например, выражение new Bitmap может вызвать исключение по причине недостатка свободной памяти, или исключение возбудит конструктор копирования Bitmap. При таком развитии событий Widget также будет содержать указатель на несуществующий Bitmap. Подобные указатели — главные причины того, что многие программисты проводят долгие часы в компании отладчика. Но существует способ сделать реализацию operator= одновременно безопасной в плане исключений и безопасной по части присваивания самому себе. Способ достаточно прост — надо лишь провести серию присваиваний в правильном порядке. Следующий кусок кода безопасен и в контексте исключений, и в контексте присваивания себе:

Полностью безопасный код

Widget& Widget::operator=(const Widget& rhs)
{
Bitmap *pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}

Здесь мы просто не удаляем pb до тех пор, пока не скопируем то, на что он указывает. Теперь, если new Bitmap возбудит исключение, то pb (а также и объект Widget, которому он принадлежит) останется неизменным. Даже без проверки на совпадение здесь обрабатывается присваивание самому себе, так как мы сделали копию исходного объекта Bitmap, удалили его, а затем направили указатель на сделанную копию. Кстати, применение такого подхода в большинстве случаев оправдано также и с точки зрения производительности. А вот вопрос «почему так?», — и будет твоим домашним заданием.

Альтернативой ручному упорядочиванию инструкций в operator= может быть обеспечение безопасности в контексте исключений и присваивания самому себе за счет применения техники «копирования с обменом» («copy and swap»). Это достаточно распространенный способ написать оператор присваивания, и на него стоит взглянуть:

«COPY AND SWAP»

class Widget {
…
void swap(Widget& rhs);
...
}
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs);
swap(temp);
return *this;
}

Здесь оператор присваивания можно объявить как принимающий аргумент по значению, а передача объекта по значению — фактически создание копии этого объекта. Надо запомнить, что всегда следует убеждаться в правильности поведения operator=, когда объект присваивается самому себе. Для этого можно сравнить адреса исходного и целевого объектов, аккуратно упорядочить инструкции или применить идиому копирования обменом. Также следует убедиться, что все функции, оперирующие более чем одним объектом, ведут себя корректно при совпадении этих объектов.

Правило №3

В хорошо спроектированных объектно-ориентированных программах, которые инкапсулируют внутреннее устройство объектов, копированием занимаются только две функции: оператор присваивания и конструктор копирования. Назовем их функциями копирования. Если мы самостоятельно не определяем поведение этих функций, то компилятор сгенерирует их код за нас. Эти автоматически созданные функции ведут себя в точности так, как мы это от них ожидаем — копируют все данные исходного объекта.

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

Класс CUSTOMER

// делает запись в протокол
void logCall (const std::string& funcName);
class Customer {
public:
…
Customer (const Customer& rhs);
Customer& operator= (const Customer& rhs);
…
private:
std::string name;
};
Customer::Customer (const Customer& rhs)
: name(rhs.name)
{
logCall("Конструктор копирования Customer");
}
Customer& Customer::operator=(const Customer& rhs)
{
logCall("Копирующий оператор присвоения
Customer");
name = rhs.name;
return this;
}

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

Большинство компиляторов не выдадут никакого предупреждения, даже на самом высоком уровне диагностики. Решение тут очевидно — надо переписать конструктор копирования и все операторы присваивания. Казалось бы, задача достаточно проста, и про обновление копирующих функций не вспомнит только дурень, но поверь мне, это не совсем так. Есть еще более коварные случаи проявления такой ситуации. Например, наследование:

Наследование и копирующие функции

class PriorityCustomer: public Customer {
public:
...
PriorityCustomer (const PriorityCustomer& rhs);
PriorityCustomer& operator= (
const PriorityCustomer& rhs);
...
private:
int priority;
};
PriorityCustomer::PriorityCustomer
(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
logCall("Конструктор копирования PriorityCustomer");
}
PriorityCustomer& PriorityCustomer::operator=(
const PriorityCustomer& rhs)
{
logCall("Копирующий оператор присвоения
PriorityCustomer");
priority = rhs.priority;
return this;
}

На первый взгляд копирующие функции в классе PriorityCustomer обрабатывают все его члены, но на деле копируются только данные, объявленные в PriorityCustomer. Однако каждый объект PriorityCustomer содержит члены, унаследованные от Customer, а они-то и не копируются! Конструктор копирования PriorityCustomer не упоминает в своем списке инициализации члены Customer, поэтому эти данные будут инициализированы конструктором по умолчанию класса Customer.

Для оператора присваивания PriorityCustomer ситуация ничуть не лучше. Он не выполняет попыток инициализировать данныечлены базового класса, поэтому они остаются неизменными. Чтобы решить проблему, надо скопировать части базового класса. Обычно они находятся в закрытом разделе класса, поэтому копирующие функции производного класса должны вызывать соответствующие функции базового класса:

Правильные копирующие функции

PriorityCustomer::PriorityCustomer
(const PriorityCustomer& rhs)
// вызываем копирующий конструктор
// базового класса
: Customer(rhs),
priority(rhs.priority)
{
logCall(
"Конструктор копирования PriorityCustomer");
}
PriorityCustomer&
PriorityCustomer::operator=
(const PriorityCustomer& rhs)
{
logCall("Копирующий оператор присвоения
PriorityCustomer");
// присваиваем значение данным-членам
// базового класса
Customer::operator=(rhs);
priority = rhs.priority;
return this;
}

Часто код в конструкторе копирования и операторе присваивания совпадает, и может возникнуть желание использовать одну функцию в теле другой для предотвращения дублирования кода. Но я настоятельно не советую этого делать. Нет смысла вызывать конструктор копирования из оператора присваивания, поскольку тем самым мы пытаемся создать объект, который уже существует. Попытка выполнить обратную операцию — из конструктора копирования вызвать оператор присваивания также бессмысленна. Конструктор инициализирует новые объекты, а оператор присваивания работает с уже существующими экземплярами.

Разумеется, есть способ избежать дублирования кода, и он достаточно прост. Надо лишь создать закрытую функцию-член, которая будет выполнять этот дублирующийся код. Конструктор копирования и оператор присваивания будут вызывать эту функцию в своем теле.

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

Заключение

Вроде бы, такие простые вещи, как присваивание и копирование, не должны иметь подводных камней. Однако программирование — дело не такое простое, как иногда кажется. Многие особенности языка C++ начинают всплывать, только если разрабатывать понастоящему серьезные программы.

Содержание