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

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

deeonis, deeonis@gmail.com


Продолжаем изучать подводные камни языка С++. В четвертой части нашего эпического повествования мы, как всегда, узнаем три правила, благодаря которым жизнь простого cpp-кодера станет чуточку проще. Читаем и просвещаемся, ведь следующие несколько страниц расскажут нам про безопасность относительно исключений, inline-функции и уменьшение зависимости файлов при компиляции.

Правило №1

Предположим, что у нас есть некий класс, который отображает пункты меню с фоновым рисунком (backround). Класс рассчитан на работу в многопоточном приложении. Для смены бэкраунда используется функция changeBackround:

Класс PrettyMenu

class PrettyMenu {
public:
…
void changeBackround(std::istream& imgSrc);
…
private:
Mutex mutex;

Image *bgImage;
int imageChanges;
};

void PrettyMenu::changeBackround(std::istream& imgSrc)
{
lock(&mutex);

delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);

unlock(&mutex);
}

С точки зрения безопасности в плане исключений эта функция чуть лучше, чем просто ужасна. А все потому, что она не выполняет два основных требования: «не допускать утечки ресурсов» и «гарантировать целостность структур данных».

Код функции changeBackround, в случае возбуждения исключения строкой new Image(imgSrc), никогда не освободит мьютекс потому, что управление просто не дойдет до функции unloсk. Исправить это достаточно просто, нужно лишь использовать объекты управления ресурсами. В предыдущих статьях мы подробно говорили о них. Тело функции changeBachround после применения управляющего объекта будет выглядеть примерно так:

Использование управляющего объекта класса Lock

void PrettyMenu::changeBackround(std::istream& imgSrc)
{
Lock ml(mutex);

delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}

Мало того, что мы теперь точно знаем, что мьютекс всегда будет освобожден по завершению функции (в том числе и при возбуждении исключения), так еще и код стал намного удобнее для восприятия. Целостность данных наша функция тоже не может гарантировать. Если new Image(imgSrc) возбудит исключение, в bgImage останется указатель на удаленный объект. Более того, счетчик imageChanges увеличится, несмотря на то, что новая картинка не будет установлена. Отлично, будем разбираться.

Существуют три вида гарантии безопасности структур данных:

  • базовая гарантия;
  • строгая гарантия;
  • гарантия отсутствия исключений.

Базовая гарантия подразумевает, что любой сценарий выполнения функции не приведет к порче данных. То есть, даже в случае неудачного конструирования объекта imgSrc указатель bgImage будет ссылаться на корректную картинку. Тут нет однозначности. Этой картинкой может быть предыдущий фон, а может быть фон по умолчанию и т. д. Пользователи кода не смогут точно знать, что там находится и нам придется вводить дополнительные функции.

Строгая гарантия подразумевает только два состояния: «до выполнения функции» и «после». Иначе говоря, функция фактически является атомарной и в случае, если что-то пойдет не так, гарантирует, что состояние всех переменных будет такое же, как и до ее запуска. Гарантия отсутствия исключений подразумевает под собой, что код, обеспечивающий такую гарантию, никогда не возбуждает исключений, а всегда делает то, что должен. Этого можно добиться, если работать исключительно со встроенными типами. Но в рамках языка C++ очень трудно предоставить такую гарантию, поскольку любой класс, использующий динамическое распределение памяти, может возбуждать исключение bad_alloc.

Для функции changeBackround предоставить «почти строгую» гарантию нетрудно. Во-первых, нужно обернуть bgImage умным указателем (объектом управления ресурсами). Строго говоря, так надо делать всегда, это позволит избежать утечки ресурсов. Во-вторых, требуется изменить порядок предложений в функции changeBackround, чтобы значение счетчика не увеличивалось до тех пор, пока картинка не будет заменена. Вот какой код получается в результате:

Строгая гарантия

class PrettyMenu {
public:
…
std::tr1::shared_ptr<Image> bgImage;
…
};

void PrettyMenu::changeBackround(std::istream& imgSrc)
{
Lock ml(mutex);

bgImage.reset(new Image(imgSrc));
++imageChanges;
}

Теперь не надо вручную удалять старую картинку; за нас это делает интеллектуальный указатель. Удаление происходит только в случае, если новая картинка уже создана. Но не всегда можно обеспечить «строгую гарантию», особенно если функция вызывает другие, которые не дают такой гарантии. Например, у нас есть функция someFunc, которая вызывает f1 и f2:

void someFunc()

void someFunc()
{
…
f1();
f2();
…
}

Ясное дело, что если f1 и f2 не обеспечивают строгих гарантий, то будет трудно их обеспечить и для someFunc. Более того, даже если эти функции и дают такие гарантии, ситуация в целом не поменяется. Предположим, что f1 успешно отработала, а f2 вызвала исключение. Тогда someFunc уже не сможет восстановить начальные значения всех переменных, так как f1 уже, возможно, что-то изменила и пользователи этого кода могут работать с новыми значениями переменных (например, если f1 изменяет записи в БД).

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

Правило №2

Теперь немного о встраиваемых функциях. Этот замечательный механизм служит заменой макросов в C++. При использовании inline-функций мы не только избегаем накладных расходов, связанных с вызовом функций, но и получаем более высокий уровень оптимизации кода. Дело в том, что оптимизация, выполняемая компилятором, наиболее эффективна на участке кода, не содержащем вызовов функций.

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

Директива inline — всего лишь совет компилятору, а не команда, поэтому компилятор может встроить функцию, а может и нет. Инлайн-функцию можно объявить неявно, определив тело функции внутри определения класса:

Неявное определение inline-функции

class Person {
public:
…
// неявный запрос на встраивание
int age() const { return theAge;}
…
private:
int theAge;
};

Большинство компиляторов отвергают встраивание функций, которые представляются слишком сложными, или если функция объявлена виртуальной. Дескриптор virtual означает «какую точно функцию вызвать, определяется в момент исполнения», а inline – «перед исполнением заменить вызов функции ее кодом». Если компилятор не знает, какую функцию ему вызвать, то и встроить он ее не может. Большинство компиляторов выдают предупреждение о невозможности выполнить запрошенное встраивание.

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

Указатель на inline-функцию

inline void f() {…}

void (*pf)() = f;
…
// этот вызов будет встроенным
f();
// а этот, скорее всего, нет
pf();

Самыми неудачными кандидатами на встраивание будут конструкторы и деструкторы. Рассмотрим пример:

Кандидаты на встраивание

class Base {
public:
…
private:
std::string bm1, bm2;
};

class Derived: public Base {
public:
Derived(){}
…
private:
std::string dm1, dm2, dm3;
};

Конструктор дочернего класса выглядит как идеальная функция для встраивания, поскольку ее тело не содержит никакого кода. Но это не совсем так. Стандарты языка C++ накладывают довольно строгие обязательства на конструкторы и деструкторы. Первые должны инициализировать внутренние переменные, вызывать конструкторы родительских классов, корректно обрабатывать исключения, возникшие в результате неудачного создания внутренних переменных и т. д. Деструкторы делают то же самое с точностью до наоборот.

Все это происходит не само собой; для этих целей компилятором генерируется специальный управляющий код. Он иногда помещается в конструкторы и деструкторы. В итоге, тело Derived может выглядеть примерно так:

Код, генерируемый компилятором

Derived::Derived()
{
Base::Base();

try {dm1.std::string::string();}
catch(...) {
Base::~Base();
throw;
}

try {dm2.std::string::string();}
catch(...) {
dm1.std::string::~string();
Base::~Base();
throw;
}

try {dm3.std::string::string();}
catch(...) {
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
}

Получается, наш конструктор не так уж и подходит для встраивания, а если учесть, что конструкторы родительских классов и внутренних переменных тоже могут быть встроенными, то налицо многократное разбухание кода. Что не преминет сказаться на его производительности.

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

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

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

Правило №3

Представим самую обычную ситуацию: мы открываем нашу программу на C++, вносим в реализацию какого-либо класса изменения и запускаем процесс компиляции. Если программа достаточно большая, то в ней много зависимостей, что приведет к долгой перекомпиляции. А изменения мы вносили даже не в интерфейс класса, а всего лишь в реализацию (например, добавили переменную в раздел private), то есть в ту часть класса, которая скрыта от его клиентов. Компиляция всего кода, использующего класс с измененной реализацией, в этом случае выглядит нелогично. Все дело в том, что C++ не проводит сколько-нибудь значимого различия между интерфейсом и реализацией:

Интерфейс и реализация

#include <string>
#include “date.h”
#include “address.h”

class Person {
public:
Person (const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
…
private:
// детали реализации
std::string theName;
Date theBirthDate;
Address theAddress;
};

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

Суть первого метода заключается в том, что нужно создать два класса: один — для представления интерфейса, а другой — для его реализации. Если класс, содержащий реализацию, назвать PersonImpl, то Person должен выглядеть так:

Класс-дескриптор

#include <string>
#include <memory>

// опережающие определения
class PersonImpl;
class Date;
class Address;

class Person {
public:
Person (const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
…
private:
std::tr1::shared_ptr<PersonaImpl> pImpl;
};

Здесь главный класс Person не содержит никаких данных членов, кроме указателя на свой класс реализации. При таком дизайне пользователи класса Person не видят деталей реализации — дат, адресов и имен. Реализация может быть модифицирована как угодно, при этом модифицировать программы, в которых используется Person, не придется. Функции-члены класса Person просто переадресовывают все вызовы соответствующим классам реализаций.

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

Интерфейсный класс

class Person {
public:
virtual ~Person ();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
…
};

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

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

Оба метода предполагают снижение производительности программы, но зато мы получаем более гибкий и масштабируемый код, что в будущем поможет избежать множества ошибок и проблем.

Заключение

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

Содержание