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

Вирмэейкерские типсы и триксы. Три правила кошерного кодинга


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

Библиотека TR1

TR1 («Technical Report 1») — это спецификация новой функциональности, добавленной в стандартную библиотеку C++. Она оформлена в виде новых шаблонов классов и функций, предназначенных для реализации хэш-таблиц, «интеллектуальных » указателей с подсчетом ссылок, регулярных выражений и многого другого. Все компоненты TR1 находятся в пространстве имен tr1, которое вложено в пространство имен std.

Правило №1

Первое правило будет посвящено виртуальным функциям, а точнее — виртуальным деструкторам. Все мы знаем, для чего нужны эти самые виртуальные функции, но не все догадываются, зачем нужно делать виртуальным деструктор. Сразу начнем с примеров.
Допустим, решили мы написать базовый класс для отслеживания времени (назовем его TimeKeeper) и сделали много-много от него наследников. В программном коде это выглядит так:

TimeKepper и его потомки

class TimeKeeper
{
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public
TimeKeeper {...};
class WaterClock: public TimeKeeper {...};
class WristClock: public TimeKeeper {...};

Многие клиенты (под клиентом имеется в виду код, который использует наши классы) захотят иметь простой способ доступа к данным о времени, не заботясь о деталях. В этом случае обычно используют так называемые фабричные функции. Они создают экземпляр нужного класса, а возвращают указатель на базовый. В нашем случае фабричная функция будет, например, создавать объект класса AtomicClock, а возвращать указатель на TimeKeeper. Как это хорошо и удобно, я тут рассказывать не буду, а вот проблемы, связанные с таким подходом, опишу подробно.

Итак, мы создали объект класса AtomicClock и используем его через указатель на TimeKeeper. После всего, что мы с ним сделали, он нам становится ненужен и объект надо удалить с помощью оператора delete. Выглядит это примерно так:

Работа с производным объектом

// Объявление фабричной функции
TimeKeeper *getTimeKeeper();
...
// получаем динамически выделенный объект
// из иерархии TimeKeeper
TimeKeeper *ptk = getTimeKeeper();
…
delete ptk;

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

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

Теперь все будет работать, как надо

class TimeKeeper {
public:
TimeKeeper();
// Объявляем деструктор виртуальным
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk;

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

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

std::string в качестве базового класса
class SpecialString: public std::string {
...
};
SpecialString *pss =
new SpecialString("Надвигающаяся опасность!");
std::string *ps;
…
ps = pss;
...
// вот тут получаем утечку памяти
delete ps;

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

А в классах, непредназначенных для использования в качестве базовых или для полиморфного применения, не следует объявлять эти самые виртуальные деструкторы.

Правило №2

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

Стандартный алгоритм работы с динамическими объектами

void f()
{
// вызов фабричной функции
TimeKeeper *ptk = getTimeKeeper();
// использование ptk
...
// освобождение памяти, занятой объектом
delete ptk;
}

На первый взгляд все хорошо, но есть несколько случаев, когда f не удалит объект, полученный от getTimeKeeper. Где-нибудь внутри функции может встретиться оператор return. Если он будет выполнен, то управление никогда не достигнет оператора delete. Это может случиться, если фабричную функцию и delete поместить в цикл, и этот цикл будет прерван в результате выполнения continue или goto.
Наконец, какая-либо из функций в коде может возбудить исключение и оператор delete опять не будет выполнен.

Независимо от того, что произошло, delete будет пропущен, а мы потеряем не только память, выделенную для объекта, но и все ресурсы, которые он захватил.

Конечно, тщательное продумывание архитектуры кода и внимательное программирование может предотвратить ошибки подобного рода, но ведь написанный код придется со временем изменять или дополнять, и, возможно, это будет делать совсем другой человек. Он может не понять весь хитрый план с «return и delete» и просто воткнуть где-нибудь оператор, обходящий стороной удаление объекта. Еще хуже, если некая функция внутри части «...» начнет неожиданно генерировать исключения, вследствие чего программа, отлично работавшая долгое время, начнет падать и глючить.

Чтобы обеспечить освобождение ресурса, возвращаемого фабричной функцией, нужно инкапсулировать этот ресурс внутри объекта, деструктор которого автоматически освободит его, когда управление покинет функцию f. Многие ресурсы динамически выделяются из «кучи», используются внутри функции и должны быть освобождены, когда управление покидает эту функцию. Для таких ситуаций можно использовать класс стандартной библиотеки под названием auto_ptr. Он представляет собой так называемый «интеллектуальный указатель». Деструктор auto_ptr автоматически вызывает delete для того, на что он указывает. Вот как можно использовать auto_ptr для предотвращения утечек памяти в функции f:

Использование auto_ptr

void f()
{
// вызов фабричной функции
std::auto_ptr<TimeKeeper> ptk(getTimeKeeper());
// использование ptk как раньше
...
}
// автоматическое удаление ptk деструктором auto_ptr

Этот простой пример демонстрирует два наиболее важных аспекта применения объектов для управления ресурсами:

1. Ресурс захватывается и сразу преобразуется в объект, управляющий им.
2. Управляющие ресурсами объекты используют свои деструкторы для гарантии освобождения ресурсов.

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

Копирование auto_ptr

// ptk1 указывает на объект, возвращаемый
getTimeKeeper
std::auto_ptr<TimeKeeper> ptk1(getTimeKeeper());
// ptk2 теперь указывает на объект,
// а ptk1 равен null
std::auto_ptr<TimeKeeper> ptk2(ptk1);
// теперь ptk1 указывает на объект,
// а ptk2 равен null
ptk1 = ptk2;

Такое странное поведение при копировании не всегда подходит. К примеру, STL-контейнеры требуют, чтобы их содержимое при копировании вело себя «нормально», поэтому помещать в них объекты auto_ptr нельзя. В таких случаях нам помогут интеллектуальные указатели с подсчетом ссылок (reference-counting smart pointer – RCSP).

Они отслеживают, сколько объектов указывает на определенный ресурс, и автоматически удаляют ресурс, когда на него никто не ссылается. RCSP ведет себя почти так же, как сборщик мусора. Примером подобного интеллектуального указателя является класс tr1::shared_ptr из библиотеки TR1. Использование его ничем не отличается от auto_ptr, но зато при копировании он ведет себя гораздо более естественно.

Копирование shared_ptr

void f()
{
...
// ptk1 указывает на объект, возвращаемый getTimeKeeper
std::tr1::shared_ptr<TimeKeeper>
ptk1(getTimeKeeper());
// теперь оба объекта ptk1 и ptk2 указывают на объект
std::tr1::shared_ptr<TimeKeeper> ptk2(ptk1);
ptk1 = ptk2; // ничего не изменилось
...
}
// ptk1 и ptk2 уничтожены, а объект,
// на который они указывали,
// автоматически удален

Поскольку копирование объектов tr1::shared_ptr работает «как положено», то они могут быть использованы в качестве элементов STL-контейнеров. Но не стоит уповать на shared_ptr и auto_ptr. Они лишь освобождают динамически выделенную память в своих деструкторах с помощью оператора delete. Часто придется писать собственные объекты управления ресурсами. Так, ни один из вышеописанных интеллектуальных указателей не может корректно работать с динамически выделенными массивами (подробнее об этом будет в правиле №3). Часто такие массивы можно заменить векторами или строками, но если сильно нужно использовать именно динамические массивы, то обрати внимание на классы boost::scoped_array и boost::shared_array из библиотеки Boost.

Итак, из этого правила надо запомнить, что для предотвращения утечки ресурсов следует использовать управляющие объекты, которые захватывают ресурсы в своих конструкторах и освобождают в деструкторах. Два таких часто используемых класса — это auto_ptr и tr1::shared_ptr. Остановить свой выбор лучше на shared_ptr, так как он умеет подсчитывать ссылки на ресурс.

Правило №3

В правиле №2 я говорил, что auto_ptr и tr1::shared_ptr не умеют правильно работать с указателями на динамически выделенные массивы, то есть созданные с помощью оператора new. Все потому, что в своем деструкторе они вызывают delete. Рассмотрим небольшой пример:

Создание и уничтожение массива

std::string *stringArray = new std::string[100];
...
delete stringArray;

На первый взгляд все отлично: мы выделили кусок из кучи под массив размером в 100 элементов, а затем вернули память обратно. Но на самом деле, как минимум, 99 из 100 объектов не будут корректно уничтожены вследствие выполнения этого кода.

При использовании оператора new происходит два события: выделение определенного объема памяти и вызов одного или нескольких конструкторов. Для delete все выглядит аналогично — вызов одного или нескольких деструкторов и возвращение памяти системе. Ключевой момент здесь в том, что только программист знает, выделялась ли память для одного объекта или для целого массива.
При резервировании памяти под массив объектов компилятор сохраняет еще и информацию о размерности этого массива. При освобождении памяти мы должны сообщить компилятору, что хотим удалить целый массив, а не один экземпляр объекта. Сделать это достаточно просто — надо лишь вызвать delete[] вместо delete:

Правильная работа с динамической памятью

std::string *stringPtr1 = new std::string;
std::string *stringPtr2 = new std::string[100];
...
delete stringPtr1;
delete[] stringPtr2;

В примере все будет работать как надо, но если к stringPtr1 применить delete[], а к stringPtr2 — delete, то мы гарантировано получим баг. Правило тут достаточно простое: если память выделялась с помощью new[], то и удалять ее надо с помощью delete[].
Особое внимание на это надо обратить любителям директивы typedef. Автор определения типа должен документировать, какую форму delete использовать для удаления объектов объявленного им типа.

Коварный typedef

typedef std::string AddressLines[5];
std::string *pal = new AddressLines;
delete pal; // неправильно
delete[] pal; // правильно

Кстати, лучше вообще не использовать typedef для определения типов массивов. В подавляющем большинстве случаев есть прекрасная замена в виде классов string и vector. Так, в примере выше, AddressLines можно было определить как вектор строк:

vector<string>

Главное, что надо тут запомнить, — если в new использовать [], то и в delete тоже надо использовать []. И наоборот, если мы НЕ используем квадратные скобки [] в выражении new, то и в delete их не надо использовать. Это очень важно.

Заключение

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

Библиотека BOOST

Boost — это организация, которая предлагает переосмысленные, тщательно проверенные библиотеки C++ с открытым исходным кодом. Большая часть TR1 базируется на работе, выполненной Boost, и до тех пор, пока поставщики компиляторов не включат TR1 в дистрибутивы C++, Boost будет оставаться для разработчиков главным источником реализаций TR1. Boost предоставляет больше, чем включено в TR1, однако в любом случае о нем полезно знать.

Содержание