Кодерские типсы и триксы. Три правила кодинга на C++ для настоящих спецовdeeonis (deeonis@gmail.com) Продолжаем изучать подводные камни языка С++. В третьей части этого эпического повествования мы, как всегда, узнаем три правила, благодаря которым жизнь простого cpp-кодера станет чуточку проще. Читаем и просвещаемся! В общем случае разработка программы сводится к определению классов и объявлению функций. Если сделать это правильно, то реализация будет не так уж и сложна. Тем не менее, на некоторые моменты внимание обращать стоит. Например, слишком раннее определение переменных может плохо повлиять на производительность кода. Частое привидение типов может замедлить исполнение программы, а также провоцировать трудно устранимые ошибки. Возврат дескрипторов внутренних данных объекта может нарушить принцип инкапсуляции и порождать «висячие дескрипторы». Решение всех этих проблем мы и обсудим ниже. Правило №1Каждый раз при объявлении переменной, принадлежащей типу, который имеет конструктор или деструктор, программа тратит время и на его создание, когда поток управления достигает ее определения, и на уничтожение — при выходе переменной из области видимости. Эти расходы системных ресурсов приходится нести даже когда переменные не используются и поэтому избегать их очень желательно. Многие могут подумать, что в их коде нет таких висячих переменных, тем более что практически все современные компиляторы предупреждают программиста о таких случаях. Но на самом деле, объявить переменную, а потом «забыть» про нее, гораздо проще, чем кажется. Рассмотрим следующий пример: Не используемая переменная encrypted
В этом коде функция encryptPassword возвращает зашифрованный пароль при условии, что его длина не меньше некоторого минимума. В случае, если передаваемый пароль не удовлетворяет требуемым параметрам, функция возбуждает исключение. Нельзя сказать, что объект encrypted совсем не используется в коде, но в случае возбуждения исключения конструктор и деструктор переменной проработают впустую. Исправить это можно, отложив определение encrypted до того момента, когда она будет нужна: Определяем encrypted в правильном месте
Код еще не настолько эффективен, как мог бы быть, так как переменная encrypted определена без начального значения. Следовательно, при ее объявлении будет использован конструктор по умолчанию. Часто перед использованием объектов приходится инициализировать их с помощью оператора присваивания. В большинстве случаев гораздо оптимальнее инициализировать объекты сразу с помощью их конструкторов. Это справедливо и для encrypted. Допустим, у нас есть функция, которая выполняет трудную часть шифрования: void encrypt(std::string& s). Тогда encryptPassword может быть реализована следующим образом: Возможная реализация encryptPassword
Но гораздо лучше, и даже можно сказать правильней, будет инициализировать encrypted не с помощью оператора присваивания, а по средствам конструктора. Нужно не только откладывать определение переменной до момента ее использования, но и постараться отложить это определение до момента получения аргументов для ее инициализации. Правильная реализация encryptPassword
Теперь немного поговорим о циклах. Из того, что мы узнали выше, можно заключить, что есть два способа использования переменных внутри цикла: объявить ее вне тела цикла и каждый раз присваивать новое значение, или конструировать новый объект при каждой итерации. В коде это будет выглядеть так: Что лучше?
Здесь мы специально переходим к объектам класса Widget, чтобы уйти от конкретики. В общем случае накладные расходы для подхода A равны «1 конструктор + 1 деструктор + n присваиваний». Для подхода B – «n конструкторов + n деструкторов». Подход A оказывается более эффективным для классов, у которых стоимость операции присваивания меньше, чем пары «конструктор + деструктор». В противном случае, возможно, подход B лучше. Если нет точной информации, что присваивание обходится дешевле, чем пара «конструктор + деструктор», и речь идет о коде, производительность которого критична, то по умолчанию лучше использовать подход B. Правило №2C++ очень строго относится к типизации переменных. Это дает определенную гарантию, что если твоя программа компилируется без ошибок, то она не выполняет никаких бессмысленных и опасных действий. Механизм приведения типов обходит эту систему, что может вызвать достаточно серьезные проблемы. Программисты, пришедшие в C++ из мира Java, C или C#, должны относиться к приведению типов в этом языке с гораздо большим почтением, поскольку в приплюснутом си это очень тонкая операция. В C++ существует три типа синтаксиса для приведения типов: синтаксис в стиле языка C (выглядит примерно так: (T) expression), функциональный синтаксис (T (expression)) и синтаксис в стиле С++ (const_cast<T>(expression), dynamic_cast<T>(expression), reinterpret_cast<T>(expression) и static_cast<T>(expression)). Первые два синтаксиса по сути идентичны, а вот приведение типов в стиле C++ отличается узкой направленностью каждого из четырех операторов. Рассказывать сейчас об отличиях этих операторов я не буду. Скажу лишь, что предпочтительнее использовать приведение в стиле C++, так как, во-первых, их легче найти в коде (и человеку, и специализированным инструментам), что упрощает поиск узких мест в типизации, а во-вторых, узкоспециализированное назначение каждого из операторов позволяет компилятору эффективнее выявлять ошибки типизации. Например, если попытаться избавиться от константности, используя любой оператор приведения в стиле C++, кроме const_cast, код не откомпилируется. Многие программисты думают, что приведение типов лишь указывает компилятору как трактовать ту или иную переменную, но это не всегда так. В большинстве случаев при приведении генерируется дополнительный исполнительный код. Так, например, если преобразовывать переменную типа int к типу double, то в подавляющем большинстве случаев будет создан исполнительный код, так как во многих архитектурах внутреннее представление int отличается от double. Более того, если мы возьмем указатель дочернего класса и приведем его к базовому, то и в этом случае мы получаем исполнительный код. Компилятор может прибавлять смещение для получения нужного адреса. Получается, что у одного и того же объекта может быть несколько адресов в памяти. Такое невозможно ни в C#, ни в Java и C, но возможно в C++. При использовании множественного наследования такое случается сплошь и рядом. Все усугубляется еще и тем, что компилятор может прибавлять смещение, а может и нет. Каждая версия размещает объекты в памяти по собственным правилам, поэтому программист не должен делать никаких предположений относительно размещения этого объекта в памяти, а тем более выполнять на основе этих предположений преобразования типов. Оператор dynamic_cast требует отдельного разговора. Самая распространенная его реализация основывается на сравнении имен классов по средствам функции strcmp. Из-за этого вызовы оператора приведения очень накладны. Особенно это становится заметно, когда dynamic_cast применяется к большим иерархиям классов с множественным наследованием. Необходимость в этом операторе обычно появляется из-за того, что программист желает выполнить операции, определенные в производном классе, для объекта, который предположительно принадлежит производному классу, но при этом в распоряжении имеется только указатель на базовый класс, посредством которого нужно манипулировать объектом. Типовое использование dynamic_cast
Есть два основных способа избежать подобного. Первый — использовать контейнеры для хранения указателей на сами объекты произвольных классов, тогда отпадет необходимость манипулировать этими объектами через интерфейсы базового класса. Например, если в нашей иерархии Window/SpecialWindow только SpecialWindow поддерживает мерцание (blinking), то dynamic_cast можно заменить так: Используем контейнеры вместо dynamic_cast Конечно, такой подход не позволяет хранить указатели на объекты всех возможных производных от Window-классов в одном и том же контейнере. Но есть альтернатива — предусмотреть виртуальные функции в базовом классе, которые позволяют делать именно то, что нужно. Например, можно ввести поддержку мерцания в базовом классе в виде виртуальной функции, которая не производит никаких действий: Виртуальные функции вместо dynamic_cast
Ни один из этих подходов не является универсальным, но во многих случаях они представляют полезную альтернативу dynamic_cast. Чего действительно стоит избегать, так это каскадов операторов dynamic_cast. В этом случае генерируется объемный и медленный код, который к тому же еще и довольно нестабилен. В итоге надо запомнить, что следует по возможности избегать приведения типов, особенно с использованием dynamic_cast, в критичном по производительности коде. Когда приведение типа необходимо, нужно постараться скрыть его внутри функции. Кроме того, предпочтительнее использовать приведения в стиле C++. Правило №3Итак, мы подошли к самому интересному. Представим, что у нас есть класс, который работает с прямоугольником. Параметры фигуры в нем заданы левым верхним и правым нижним углом прямоугольника. Чтобы объект Rectangle оставался компактным, можно описать определяющие его точки во вспомогательной структуре. При этом сам объект будет предоставлять интерфейс для чтения этих точек. Описание класса прямоугольника
Функции upperLeft и lowerRight возвращают ссылку на объект класса Point, поскольку пользовательские типы гораздо эффективнее передавать по ссылке или указателю. Исходный код в примере откомпилируется, но он неправильный! С одной стороны, upperLeft и lowerRight объявлены как константные функции-члены, поскольку они предназначены только для того, чтобы предоставить клиенту способ получить информацию о точках прямоугольника, не давая ему возможности модифицировать объект Rectangle. С другой стороны, обе функции возвращают ссылки на закрытые внутренние данные, с помощью которых затем можно модифицировать внутренние данные. Все плохо
В этом примере мы удачно изменяем одно из внутренних значений объекта, хотя по задумке этого сделать невозможно. Если бы мы возвращали итераторы или указатели, проблема была бы той же самой. Возвращение такого «дескриптора» внутренних данных объекта - прямой путь к нарушению принципов инкапсуляции. Решить проблему довольно просто, достаточно лишь применить квалификатор const к возвращаемому типу. Правильное объявление функций В результате такого изменения пользователи смогут читать объекты Point, но не смогут изменить их. Что касается проблемы инкапсуляции, то мы с самого начала намеревались дать клиентам возможность видеть объекты Point, поэтому в данном случае ослабление инкапсуляции намеренное. Но даже так upperLeft и lowerRight возвращают «дескрипторы» внутренних данных объекта, что может вызвать проблему так называемых «висячих дескрипторов», то есть дескрипторов, ссылающихся на части уже несуществующих объектов. Рассмотрим пример: Висячий дескриптор
Вызов boundingBox вернет временный объект Rectangle. Затем вызовется функция-член upperLeft этого временного объекта, и этот вызов вернет ссылку на внутренние данные временного объекта, в нашем случае - на один из объектов класса Point. Присвоив это значение переменной pUpperLeft, можно надеяться, что оно останется неизменным, но временный объект, возвращаемый boundingBox, уничтожится сразу в конце предложения и pUpperLeft будет ссылаться на несуществующий в памяти объект. Вот почему опасна любая функция, которая возвращает «дескриптор» внутренних данных объекта. Это не значит, что никогда не следует писать такие функции. Иногда они бывают необходимы. Например, operator[] возвращает ссылку на данные в контейнере, которые уничтожаются вместе с контейнером. Но это все же исключение, а не правило. Следует избегать возвращать «дескрипторы» (ссылки, указатели, итераторы) внутренних данных объекта. Это повышает степень инкапсуляции и минимизирует вероятность появления «висячих дескрипторов». ЗаключениеНа этом сегодняшнее повествование можно считать законченным. В следующий раз мы продолжим знакомиться с правилами и советами, касающимися особенностей реализации программ на C++. Общими усилиями мы победим это великое творение Бьерна Страуструпа. До встречи! |