Кодерские типсы и триксы: три правила кодинга на C++ для настоящих спецовВ прошлом номере мы снова начали знакомиться с небольшими трюками и правилами кодинга на C++. В этой статье мы продолжим изучать подводные камни этого объектно-ориентированного языка программирования. Если ты хочешь писать по-настоящему качественные программы, то обязательно прочти следующие пару страниц. Сегодня мы поговорим об операторе присваивания в языке C++. Казалось бы, что такого страшного может быть в этом маленьком знаке «=»? А нет, даже тут великий СРР готовит нам свои сюрпризы. Итак, поехали. Правилоs №1Одно из интересных свойств присваивания в C++ заключается в том, что его можно выполнять последовательно. Кроме того, оператор присваивания правоассоциативен. Что это значит? Продемонстрируем следующий код: Чудесный оператор присваивания
Здесь переменной z присваивается значение 15, затем результат присваивания (новое значение z) присваивается переменной y, после чего новое значение y присваивается переменной x. Достигается это за счет того, что оператор присваивания возвращает ссылку на свой левый аргумент, и этому соглашению мы должны следовать при реализации операторов присваивания в своих классах. Оператор «=» в своих классах
Это соглашение касается всех операторов присваивания, а не только стандартной его формы. Так, например, для операторов += и = с приведением типа аргумента их определение будет выглядеть так: Определение нестандартных операторов присваивания
Это всего лишь соглашение и, если мы не будем его придерживаться, наша программа все равно будет скомпилирована. Но этому соглашению следуют все встроенные типы и типы стандартной библиотеки, такие как string, vector и т.д. Если у нас нет веской причины нарушать его, лучше этого и не делать. Из первого правила нам нужно запомнить, что оператор присваивания следует писать так, чтобы он возвращал ссылку на *this. Правило №2Во втором правиле мы узнаем, что может случиться, если не осуществлять проверку на присваивание самому себе. Некоторые спросят: «Как такое возможно?». Можешь не сомневаться, вероятность такого присваивания очень высока. Следующий кусок кода покажет разные ситуации, в которых присваивание самому себе осуществимо: Потенциальное присваивание самому себе
Эти не совсем очевидные случаи присваивания себе являются результатом так называемого совмещения имен (aliasing), когда на один и тот же объект ссылаются несколькими разными способами. Программа, которая оперирует ссылками или указателями на объекты одного и того же типа, должна считаться с тем, что эти объекты могут совпадать. Необязательно даже, чтобы два объекта имели одинаковый тип. Если они принадлежат к одной иерархии классов, то ссылка или указатель на базовый класс могут в действительности относиться к производному классу. Еще одно присваивание себе
Если мы будем писать объект для управления ресурсами, о котором я рассказывал в прошлой статье, то нам непременно придется подумать о безопасности присваивания. Может сложиться ситуация, в которой мы случайно освободим ресурс до его использования. Предположим, что мы создали класс, который содержит указатель на динамический объект класса Bitmap. Ниже приведена реализация оператора присваивания, которая на первый взгляд кажется совершенно нормальной, но становится опасной в случае присваивания самому себе или при возникновении исключения. Опасный OPERATOR=
Проблема в том, что в теле operator= *this (чему присваивается значение) и rhs (что присваивается) могут оказаться одним и тем же объектом. В этом случае delete удалит не только старый Bitmap, принадлежащий текущему объекту, но и новый, который мы хотим присвоить. В результате, по завершению выполнения кода оператора мы получим в pb указатель на несуществующий объект, хотя рассчитывали там увидеть совсем другое. Традиционный способ решить эту проблему состоит в том, чтобы в начале operator= выполнить проверку на присваивание самому себе: Проверка на присваивание себе
Такая проверка позволяет избежать ошибки, описанной выше, но есть еще проблема с безопасностью в контексте исключений. Например, выражение new Bitmap может вызвать исключение по причине недостатка свободной памяти, или исключение возбудит конструктор копирования Bitmap. При таком развитии событий Widget также будет содержать указатель на несуществующий Bitmap. Подобные указатели — главные причины того, что многие программисты проводят долгие часы в компании отладчика. Но существует способ сделать реализацию operator= одновременно безопасной в плане исключений и безопасной по части присваивания самому себе. Способ достаточно прост — надо лишь провести серию присваиваний в правильном порядке. Следующий кусок кода безопасен и в контексте исключений, и в контексте присваивания себе: Полностью безопасный код
Здесь мы просто не удаляем pb до тех пор, пока не скопируем то, на что он указывает. Теперь, если new Bitmap возбудит исключение, то pb (а также и объект Widget, которому он принадлежит) останется неизменным. Даже без проверки на совпадение здесь обрабатывается присваивание самому себе, так как мы сделали копию исходного объекта Bitmap, удалили его, а затем направили указатель на сделанную копию. Кстати, применение такого подхода в большинстве случаев оправдано также и с точки зрения производительности. А вот вопрос «почему так?», — и будет твоим домашним заданием. Альтернативой ручному упорядочиванию инструкций в operator= может быть обеспечение безопасности в контексте исключений и присваивания самому себе за счет применения техники «копирования с обменом» («copy and swap»). Это достаточно распространенный способ написать оператор присваивания, и на него стоит взглянуть: «COPY AND SWAP» Здесь оператор присваивания можно объявить как принимающий аргумент по значению, а передача объекта по значению — фактически создание копии этого объекта. Надо запомнить, что всегда следует убеждаться в правильности поведения operator=, когда объект присваивается самому себе. Для этого можно сравнить адреса исходного и целевого объектов, аккуратно упорядочить инструкции или применить идиому копирования обменом. Также следует убедиться, что все функции, оперирующие более чем одним объектом, ведут себя корректно при совпадении этих объектов. Правило №3В хорошо спроектированных объектно-ориентированных программах, которые инкапсулируют внутреннее устройство объектов, копированием занимаются только две функции: оператор присваивания и конструктор копирования. Назовем их функциями копирования. Если мы самостоятельно не определяем поведение этих функций, то компилятор сгенерирует их код за нас. Эти автоматически созданные функции ведут себя в точности так, как мы это от них ожидаем — копируют все данные исходного объекта. В случае, когда мы сами решаем написать код копирующих функций, компилятор снимет с себя всю ответственность и не будет следить за тем, насколько полно мы копируем данные объекта. Рассмотрим следующий код, который реализует класс, представляющий заказчиков. Функции копирования в этом классе написаны вручную, и каждый их вызов протоколируется: Класс CUSTOMER
Все выглядит отлично. И на самом деле так оно и есть, до тех пор, пока мы не добавим в класс новый член. С этого момента существующие функции копирования копируют только часть объекта. Большинство компиляторов не выдадут никакого предупреждения, даже на самом высоком уровне диагностики. Решение тут очевидно — надо переписать конструктор копирования и все операторы присваивания. Казалось бы, задача достаточно проста, и про обновление копирующих функций не вспомнит только дурень, но поверь мне, это не совсем так. Есть еще более коварные случаи проявления такой ситуации. Например, наследование: Наследование и копирующие функции
На первый взгляд копирующие функции в классе PriorityCustomer обрабатывают все его члены, но на деле копируются только данные, объявленные в PriorityCustomer. Однако каждый объект PriorityCustomer содержит члены, унаследованные от Customer, а они-то и не копируются! Конструктор копирования PriorityCustomer не упоминает в своем списке инициализации члены Customer, поэтому эти данные будут инициализированы конструктором по умолчанию класса Customer. Для оператора присваивания PriorityCustomer ситуация ничуть не лучше. Он не выполняет попыток инициализировать данныечлены базового класса, поэтому они остаются неизменными. Чтобы решить проблему, надо скопировать части базового класса. Обычно они находятся в закрытом разделе класса, поэтому копирующие функции производного класса должны вызывать соответствующие функции базового класса: Правильные копирующие функции
Часто код в конструкторе копирования и операторе присваивания совпадает, и может возникнуть желание использовать одну функцию в теле другой для предотвращения дублирования кода. Но я настоятельно не советую этого делать. Нет смысла вызывать конструктор копирования из оператора присваивания, поскольку тем самым мы пытаемся создать объект, который уже существует. Попытка выполнить обратную операцию — из конструктора копирования вызвать оператор присваивания также бессмысленна. Конструктор инициализирует новые объекты, а оператор присваивания работает с уже существующими экземплярами. Разумеется, есть способ избежать дублирования кода, и он достаточно прост. Надо лишь создать закрытую функцию-член, которая будет выполнять этот дублирующийся код. Конструктор копирования и оператор присваивания будут вызывать эту функцию в своем теле. Из третьего правила надо запомнить, что копирующие функции должны гарантировать копирование всех членов-данных объекта и частей его базовых классов. Не пытайся реализовать одну из функций копирования через другую. Вместо этого лучше создать общую функцию и поместить в нее нужную функциональность. ЗаключениеВроде бы, такие простые вещи, как присваивание и копирование, не должны иметь подводных камней. Однако программирование — дело не такое простое, как иногда кажется. Многие особенности языка C++ начинают всплывать, только если разрабатывать понастоящему серьезные программы. |