Правила кодинга на C++ для настоящих спецов Продолжаем изучать тонкости управления памятью в C++. Следующие пара страниц будут посвящены углубленному изучению операторов new и delete. Из них ты узнаешь, какие требования предъявляет стандарт C++ к пользовательским реализациям этих операторов.

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

Если ты не читал прошлые трюки, крайне советую прочитать августовский номер, поскольку в этой статье мы будем считать, что прошлые трюки ты все-таки осилил.
Итак, к реализации пользовательских версий операторов new и delete C++ предъявляет определенные требования. Одно из этих требований мы рассмотрели в прошлом номере — это функция-обработчик new.

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

Соглашения при написании оператора new Первым делом пользовательский new должен возвращать правильное значение. В случае успешного выделения памяти оператор должен вернуть указатель на нее. Если же что-то пошло не так, следует возбудить исключение типа bad_alloc.

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

Псевдокод пользовательской реализации оператора new

void *operator new(std::size_t size)
throw(std::bad_alloc)
{
using namespace std;
// обработать запрос на 0 байтов,
// считая, что нужно выделить 1 байт
if (size == 0)
size = 1;
while(true)
{
// попытка выделить size байтов;
if (выделить удалось)
return (указатель на память);
// выделить память не удалось
// проверить, установлена ли функция-обработчик
new_handler globalHandler = set_new_handler(0);
set_new_handler(globalHandler);
if (globalHandler)
(*globalHandler) ();
else
throw std::bad_alloc();
}
}

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

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

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

Отдельно следует рассмотреть случай, когда new является функциейчленом какого-либо класса. Обычно пользовательские версии операторов работы с памятью пишутся для более оптимизированного распределения памяти. Например, new для класса Base заточен под выделение памяти объемом sizeof(Base) — ни больше, ни меньше.

Но что будет, если мы создадим класс, который наследуется от Base? В дочернем классе также будет использоваться версия оператора new, определенного в Base. Но размер наследуемого класса (назовем его Derived), скорее всего, будет отличаться от размера базового: sizeof(Derived) != sizeof(Base). Из-за этого вся польза от собственной реализации new может сойти на нет. О чем, кстати, многие забывают и испытывают потом нечеловеческие страдания.

 

Проблема наследования оператора new

class Base {
public:
static void *operator new (std::size_t size)
throw(std::bad_alloc);
...
};
// в подклассе не объявлен оператор new
class Derived: public Base
{...};
//вызывается Base::operator new
Derived *p = new Derived;

Решить проблему достаточно просто, но делать это надо заранее. Достаточно в базовом классе, в теле оператора new, выполнять проверку размера выделяемой памяти. Если количество запрашиваемых байтов не совпадает с количеством байтов в объекте класса Base, то работу по выделению памяти лучше всего передать стандартной реализации new. К тому же, так мы сразу же решаем вопрос с обработкой запроса на выделение нуля байтов памяти — этим уже будет заниматься штатная версия оператора.

 

Решение проблемы наследования оператора new

void *operator new (std::size_t size)
throw(std::bad_alloc)
{
// если size неправильный, вызвать стандартный new
if(size != sizeof(Base))
return ::operator new(size);
// в противном случае обработать запрос
...
}

На уровне класса можно также определить new для массивов (operator new[]). Этот оператор не должен ничего делать, кроме как выделять блок неформатированной памяти. Мы не можем совершать какие-либо операции с еще не созданными объектами. Да и, к тому же, нам неизвестен размер этих объектов, ведь они могут быть наследниками класса, в котором определен new[]. То есть количество объектов в массиве необязательно равно (запрошенное число байтов)/sizeof(Base). Более того, для динамических массивов может выделяться большее количество памяти, чем займут сами объекты, для обеспечения резерва.

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

 

Псевдокод пользовательской реализации оператора delete

void *operator delete (void *rawMemory) throw()
{
// если нулевой указатель, ничего не делать
if(rawMemory == 0) return;
// освободить память, на которую указывает
rawMemory;
}

Если оператор delete является функцией-членом класса, то, как и в случае с new, следует позаботиться о проверке размера удаляемой памяти. Если пользовательская реализация new для класса Base выделила sizeof(Base) байтов памяти, то и самописный delete должен освободить ровно столько же байтов. В противном случае, если размер удаляемой памяти не совпадает с размером класса, в котором определен оператор, следует передать всю работу стандартному delete.

 

Псевдокод функции-члена delete

class Base {
public:
static void *operator new (std::size_t size)
throw(std::bad_alloc);
static void *operator delete
(void *rawMemory, std::size_t size) throw();
...
};
void* Base::operator delete (void *rawMemory,
std::size_t size) throw()
{
// если нулевой указатель, ничего не делать
if(rawMemory == 0) return;
if (size != sizeof(Base)) {
::operator delete(rawMemory);
return;
}
// освободить память, на которую указывает
rawMemory;
}

 

Операторы new и delete с размещением

Функция operator new, принимающая дополнительные параметры, называется «оператором new с размещением». Обычно в качестве дополнительного параметра выступает переменная типа void*. Таким образом, определение размещающего new выглядит примерно так:

void *operator new(std::size_t, void *pMemory)

В более широком смысле new с размещением может принимать любое количество дополнительных параметров любого типа. Оператор delete называется «размещаемым» по такому же принципу — он тоже должен помимо основных принимать и дополнительные параметры.

Теперь давай рассмотрим случай, когда мы динамически создаем объект какого-либо класса. Код такой операции должен быть всем хорошо знаком:

widget *pw = new Widget

Создание объекта происходит в два этапа. На первом выделяется требуемый объем памяти стандартным оператором new, а на втором вызывается конструктор класса Widget, который инициализирует объект. Может возникнуть ситуация, когда память на первом шаге будет выделена, а конструктор возбудит исключение, и указатель *pw останется неинициализированным. Ахтунг! Таким образом мы получим потенциальную утечку памяти. Чтобы этого не произошло, за дело должна взяться система времени исполнения C++. Она обязана вызвать оператор delete для выделенной памяти на первом этапе создания объекта. Но есть один маленький нюанс, который может все испортить. C++ вызовет delete, сигнатура которого совпадает с сигнатурой new, используемого для выделения памяти. Когда мы пользуемся стандартными формами new и delete, проблем не возникает, но если мы напишем собственный new с размещением и забудем накодить соответствующую форму delete, то мы практически со стопроцентной вероятностью получим утечку памяти при возбуждении исключения в конструкторе класса.

 

Такой код может вызвать утечки памяти

class Widget {
public:
...
static void *operator new(std::size_t size,
std::ostream& logStream) throw(std::bad_alloc);
static void *operator delete(void *pMemory,
std::size_t size) throw();
...
};
Widget *pw = new (std::cerr) Widget;

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

Теперь утечек не должно быть

class Widget {
public:
...
static void *operator new(std::size_t size,
std::ostream& logStream)
throw(std::bad_alloc);
static void *operator delete(void *pMemory,
std::size_t size)
throw();
static void *operator delete(void *pMemory,
std::ostream& logStream)
throw();
...
};
Widget *pw = new (std::cerr) Widget;

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

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

 

Сокрытие имен

class Base {
public:
static void *operator new (std::size_t size,
std::ostream& logStream)
throw(std::bad_alloc);

};
// Ошибка! Обычная форма new скрыта
Base *pb = new Base;
// Правильно, вызывается размещенный new из Base
Base *pb = new (std::cerr) Base;

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

 

Заключение

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии