Онлайн библиотека PLAM.RU


  • Правило 49: Разберитесь в поведении обработчика new
  • Правило 50: Когда имеет смысл заменять new и delete
  • Правило 51: Придерживайтесь принятых соглашений при написании new и delete
  • Правило 52: Если вы написали оператор new с размещением, напишите и соответствующий оператор delete
  • Глава 8

    Настройка new и delete

    В наши дни, когда вычислительные среды снабжены встроенной поддержкой «сборки мусора» (как, например, Java и. NET), ручной подход C++ к управлению памятью может показаться несколько устаревшим. Однако многие разработчики, создающие требовательные к ресурсам прикладные программы, выбирают C++ именно потому, что он позволяет управлять памятью вручную. Такие разработчики изучают, как используется память в их программах, и разрабатывают собственные процедуры распределения и освобождения памяти с целью достичь максимально возможной производительности (с точки зрения как времени, так и потребления памяти) своих систем.

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

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

    Следует помнить и о том, что операторы new и delete применимы только к выделению и освобождению одиночных объектов. Память для массивов выделяет operator new[] и освобождает operator delete[] (в обоих случаях «[]» является частью имен функций). Если явно не оговорено противное, то все сказанное об операторах new и delete касается также new[] и delete[].

    И наконец, отмечу, что в случае STL-контейнеров выделением памяти из кучи управляют объекты-распределители, ассоциированные с самими контейнерами, а не напрямую new и delete. В этой главе ничего не говорится о распределителях памяти в STL.

    Правило 49: Разберитесь в поведении обработчика new

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

    Прежде чем возбудить исключение в ответ на невозможность удовлетворить запрос на выделение памяти, оператор new вызывает определенную пользователем функцию, называемую обработчиком new (new-handler). (На самом деле это не совсем так. Реальное поведение new несколько сложнее. Подробности описаны в правиле 51.) Чтобы задать функцию, обрабатывающую нехватку памяти, клиенты вызывают set_new_handler – стандартную библиотечную функцию, объявленную в заголовочном файле <new>:


    namespace std {

    typedef void (*new_handler)();

    new_handler set_new_handler(new_handler p) throw();

    }


    Как видите, new_handler – это typedef для указателя на функцию, которая ничего не принимает и не возвращает, а set_new_handler – функция, которая принимает и возвращает new_handler (конструкция throw() в конце объявления set_new_handler – это спецификация исключения; по существу, она сообщает, что функция не возбуждает никаких исключений, хотя на самом деле все несколько интереснее; подробности см. в правиле 29).

    Параметр set_new_handler – это указатель на функцию, которую operator new вызывает при невозможности выделить запрошенную память. Возвращаемое set_new_handler значение – указатель на функцию, которая использовалась для этой цели перед вызовом set_new_handler.

    Используется set_new_handler следующим образом:


    // функция, которая должна быть вызвана, если operator new

    // не может выделить память

    void outOfMem()

    {

    std::cerr << “Невозможно удовлетворить запрос на выделение памяти\n”;

    std::abort();

    }

    int main()

    {

    std::set_new_handler(outOfMem);

    int *pBigDataArray = new int[100000000L];

    ...

    }


    Если operator new не может выделить память для размещения 100 000 000 целых чисел, будет вызвана функция outOfMem, и программа завершится, выдав сообщение об ошибке. (Кстати, подумайте, что случится, если память должна быть динамически выделена во время вывода сообщения об ошибке в cerr…)

    Когда operator new не может удовлетворить запрос в памяти, он будет вызывать обработчик new до тех пор, пока он не сумеет найти достаточно памяти. Код, приводящий к повторным вызовам, показан в правиле 51, но и такого высокоуровневого описания достаточно, чтобы сделать вывод о том, что правильно спроектированная функция-обработчик new должна делать что-то одно из следующего списка:

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

    Установить другой обработчик new. Если текущий обработчик не может выделить память, то, возможно, ему известен какой-то другой, который сможет это сделать. Если так, то текущий обработчик может установить вместо себя другой (вызвав set_new_handler). В следующий раз, когда operator new обратится к обработчику, будет вызван последний установленный. (В качестве альтернативы обработчик может изменить собственное поведение, чтобы при следующем вызове сделать что-то другое. Добиться этого можно, например, путем модификации некоторых статических, определенных в пространстве имен или глобальных данных, влияющих на его поведение.)

    Убрать обработчик new, то есть передать нулевой указатель set_new_handler. Если обработчик не установлен, то operator new сразу возбудит исключение при неудачной попытке выделить память.

    Возбудить исключение типа bad_alloc либо некоторого типа, унаследованного от bad_alloc. Такие исключения не перехватывает operator new, поэтому они распространяются до того места, где была запрошена память.

    Не возвращать управление – обычно вызвав abort или exit.

    Эти варианты выбора обеспечивают вам достаточную гибкость в реализации функций-обработчиков new.

    Иногда обработать ошибки при выделении памяти можно и другими способами, зависящими от класса распределяемого объекта:


    class X {

    public:

    static void outOfMemory();

    ...

    };

    class Y {

    public:

    static void outOfMemory();

    ...

    };

    X *p1 = new X; // если выделить память не удалось,

    // вызвать X::outOfMemory

    Y *p2 = new Y; // если выделить память не удалось,

    // вызвать Y::outOfMemory


    С++ не поддерживает специфичных для класса обработчиков new, но он и не нуждается в них. Вы можете реализовать такое поведение самостоятельно. Для этого просто в каждом классе определяете собственную версию set_new_handler и operator new. Определенная в классе функция set_new_handler класса позволит пользователям задать обработчик new для класса (точно так же, как обычный set_new_handler устанавливает глобальный обработчик new). Принадлежащий классу operator new гарантирует, что при выделении памяти для объектов этого класса вместо глобального обработчика new будет использован тот, что определен в данном классе.

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


    class Widget {

    public:

    static std::new_handler set_new_handler(std::new_handler p) throw();

    static void *operator new(std::size_t size) throw(std::bad_alloc);

    private:

    static std::new_handler currentHandler;

    };


    Статические члены класса должны быть определены вне самого класса (если только они не константные целые – см. правило 2), поэтому:


    std::new_handler Widget::currentHandler = 0; // инициализировать нулем

    // в файле реализации класса


    Функция set_new_handler в классе Widget сохранит переданный ей указатель и вернет тот указатель на функцию, действовавшую ранее. Так же поступает и стандартная версия set_new_handler:


    static std::new_handler set_new_handler(std::new_handler p) throw()

    {

    std::new_handler oldHandler = currentHandler;

    currentHandler = p;

    return oldHandler;

    }


    А вот что должен делать operator new из класса Widget.

    1. Вызвать стандартный set_new_handler, указав в качестве параметра функцию-обработчик ошибок из класса Widget. В результате обработчик new из класса Widget будет установлен в качестве глобального.

    2. Вызвать глобальный operator new для реального выделения памяти. Если произойдет ошибка, глобальный operator new вызовет обработчик new, принадлежащий Widget, поскольку эта функция была установлена в качестве глобального обработчика. Если это ни к чему не приведет, то глобальный operator new возбудит исключение bad_alloc. В этом случае operator new из класса Widget должен восстановить исходный обработчик new, а затем распространить исключение. Чтобы гарантировать, что исходный обработчик всегда восстанавливается, класс Widget трактует его как ресурс и следует совету правила 13 об использовании управляющих ресурсами объектов для предотвращения утечек.

    3. Если глобальный operator new в состоянии выделить достаточно памяти для объекта Widget, то operator new класса Widget возвращает указатель на выделенную память. Деструктор объекта, самостоятельно управляющего глобальным обработчиком new, автоматически восстанавливает тот глобальный обработчик, который был установлен перед вызовом operator new класса Widget.

    Теперь посмотрим, как все это выразить на C++. Начнем с класса, управляющего ресурсами, который не содержит ничего, кроме основных операций, диктуемых идиомой RAII: захват ресурса во время конструирования объекта и освобождение при его уничтожении (см. правило 13):


    class NewHandlerHolder {

    public:

    explicit NewHandlerHolder(std::new_handler nh) // получить текущий

    :handler(nh) {} // обработчик new

    ~NewHandlerHolder() // освободить его

    { std::set_new_handler(handler);}

    private:

    std::new_handler handler; // запомнить его

    NewHandlerHolder(const NewHandlerHolder&); // предотвратить

    NewHandlerHolder& // копирование

    operator=(const NewHandlerHolder&); // (см. правило 14)

    };


    Это делает реализацию оператора new для Widget совсем простой:


    void Widget::orerator new(std::size_td size) throw(std::bad_aloc)

    {

    NewHandlerHolder // установить обработчик

    h(std::set_new_handler(currentHandler)); // new из класса Widget

    return ::operator new(size); // выделить память или

    // возбудить исключение

    } // восстановить глобальный

    // обработчик new


    Пользователи класса Widget применяют эти средства следующим образом:


    void outOfMem(); // объявление функции, которую нужно

    // вызвать, если выделить память

    // для Widget не удается

    Widget::set_new_handler(outOfmem); // установка outOfMem в качестве

    // обработчика new для Widget

    Widget *pw1 = new Widget; // если выделить память не удалось,

    // вызывается outOfMem

    std::string *ps = new std::string; // если выделить память не удалось,

    // вызывается глобальный обработчик new

    // (если есть)

    Widget::set_new_handler(0); // отменяет обработчик new

    Widget *pw1 = new Widget; // если выделить память не удалось,

    // сразу же возбуждается исключение (никакого

    // обработчика new сейчас нет)


    Код, реализующий эту схему, один и тот же (независимо от класса), поэтому разумно было бы повторно использовать его в других местах. Простой способ сделать это – создать «присоединяемый» базовый класс, то есть базовый класс, который предназначен для того, чтобы подклассы могли унаследовать одно-единственное средство, в данном случае способность устанавливать специфичный для класса обработчик new. Затем превратите базовый класс в шаблон, чтобы каждый производный класс мог получать разные копии данных.

    При таком подходе принадлежащая базовому классу часть позволяет подклассам наследовать необходимые им функции set_new_handler и operator new, а шаблонная часть гарантирует, что у каждого подкласса будет собственный член данных currentHandler. Звучит сложновато, но код выглядит обнадеживающе знакомым. Фактически единственным отличием является то, что теперь он доступен любому классу:


    template<typename T> // «присоединяемый» базовый класс для

    class NewHandlerSupport { // поддержки специфичной для класса

    public: // функции set_new_handler

    static std::new_handler set_new_handler(std::new_handler p) throw();

    static void *operator new(std::size_t size) throw(std::bad_alloc);

    ... // другие версии оператора new – см. правило 52

    private:

    static std::new_handler currentHandler;

    };

    template<typename T>

    std::new_handler

    NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw()

    {

    std::new_handler oldHandler = currentHandler;

    currentHandler = p;

    return oldHandler;

    }

    template<typename T>

    void *NewHandlerSupport<T>::operator(std::size_t size)

    throw(std::bad_alloc)

    {

    NewHandlerHolder h(std::set_new_handler(currentHandler);

    return ::operator new(size);

    }

    // currentHandler в любом классе инициализируется значением null

    template<typename T>

    std::new_handler NewHandlerSupport<T>::currentHandler = 0;


    С этим шаблоном класса добавление поддержки set_new_handler к Widget очень просто: Widget просто наследуется от NewHandlerSupport<Widget>. (Это может показаться экстравагантным, но ниже я подробно объясню, что здесь происходит.)


    class Widget: public NewHandlerSupport<Widget> {

    ... // как раньше, но без декларации

    }; // set_new_handler или operator new


    Это все, что нужно сделать в классе Widget, чтобы предоставить специфичный для класса обработчик set_new_handler.

    Но может быть, вас беспокоит тот факт, что Widget наследует классу New-HandlerSupport<Widget>? Если так, то ваше беспокойство усилится, когда вы заметите, что NewHandlerSupport никогда не использует свой параметр типа T. Он не нуждается в нем. Единственное, что нам нужно, – это отдельная копия NewHandlerSupport, а точнее его статический член currentHandler для каждого класса, производного от NewHandlerSupport. Параметр шаблона T просто отличает один подкласс от другого. Механизм шаблонов автоматически генерирует копию currentHandler для каждого типа T, для которого он конкретизируется.

    А что касается того, что Widget наследует шаблонному базовому классу, который принимает Widget как параметр типа, не пугайтесь, это только поначалу кажется непривычным. На практике это очень удобная техника, имеющая собственное название, которое отражает тот факт, что никому из тех, кто видит ее в первый раз, она не кажется естественной. А называется она курьезный рекурсивный шаблонный паттерн (curious recurring template pattern – CRTP). Честное слово.

    Однажды я опубликовал статью, в которой писал, что лучше было бы это назвать ее «Сделай Это Для Меня», потому что Widget наследует NewHandler-Support<Widget> и как бы говорит: «Я – Widget, и я хочу наследовать классу NewHandlerSupport для Widget». Никто не станет пользоваться предложенным мной названием (даже я сам), но если думать о CRTP как о способе сказать «сделай это для меня», то вам будет проще понять смысл наследование шаблону.

    Шаблоны, подобные NewHandlerSupport, упрощают добавление специфичных для класса обработчиков new к любому классу, которому это нужно. Однако наследование присоединяемому классу приводит к множественному наследованию, и прежде чем вставать на этот путь, вам, возможно, стоит перечитать правило 40.

    До 1993 года C++ требовал, чтобы оператор new возвращал нулевой указатель, если не мог выделить нужную память. Теперь же operator new в этом случае должен возбуждать исключение bad_alloc. Но огромный объем кода на C++ был написан до того, как компиляторы стали поддерживать новую спецификацию. Комитет по стандартизации C++ не хотел отметать весь код, основанный на сравнении с null, поэтому было решено предоставить альтернативные формы operator new, обеспечивающие традиционное поведение с возвращением null при неудаче. Эти формы известны под названием «nothrow» (не возбуждающие исключений), потому что в них используются объекты nothrow (определенные в заголовке <new>) в точке, где используется new:


    class Widget {...};

    Widget *pw1 = new Widget; // возбуждает bad_alloc, если

    // выделить память не удалось

    if(pw1 == 0) ... // эта проверка должна

    // завершиться неудачно

    Widget *pw2 = new (std::nothrow)Widget; // возвращает 0, если выделить

    // память не удалось

    if(pw2 == 0) ... // эта проверка может

    // завершиться успешно


    Не возбуждающий исключений оператор new предоставляет менее надежные гарантии относительно исключений, чем кажется на первый взгляд. В выражении «new (std::nothrow)Widget» происходят две вещи. Во-первых, nothrow-версия оператора new вызывается для выделения памяти, достаточной для размещения объекта Widget. Если получить память не удалось, то оператор new возвращает нулевой указатель, как нам и хотелось. Если же память выделить удалось, то вызывается конструктор Widget, и в этой точке все гарантии заканчиваются. Конструктор Widget может делать все, что угодно. Он может сам по себе запросить с помощью new какую-то память, и если он это делает, никто не заставляет его использовать nothrow. Хотя вызов оператора new в «new (std::nothrow)Widget» не возбуждает исключений, к конструктору Widget это не относится. И если он возбудит исключение, то оно будет распространяться как обычно. Вывод? Применение nothrow new гарантирует только то, что данный operator new не возбудит исключений, но не дает никаких гарантий относительно выражения, подобного «new (std::nothrow)Widget». А потому вряд ли стоит вообще прибегать к nothrow new.

    Независимо от того, используете вы «нормальный» (возбуждающий исключения) new или же вариант nothrow, важно, чтобы вы понимали поведение обработчика new, поскольку он вызывается обеими формами.

    Что следует помнить

    • set_new_handler позволяет указать функцию, которая должна быть вызвана, если запрос на выделение памяти не может быть удовлетворен.

    • Полезность nothrow new ограничена, поскольку эта форма применимо только для выделения памяти; последующие вызовы конструктора могут по-прежнему возбуждать исключения.

    Правило 50: Когда имеет смысл заменять new и delete

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

    Чтобы обнаруживать ошибки применения. Если не освобождать (с помощью оператора delete) память, выделенную оператором new, это приведет к утечкам памяти. Вызов delete более одного раза для одного и того же участка памяти, выделенного new, приведет к неопределенному поведению программы. Если оператор new будет вести список выделенных адресов, а оператор delete удалять адреса освобожденных областей из списка, то такие ошибки легко обнаружить. Аналогично различные ошибки в программе могут приводить к записи за концом выделенного блока (переполнению буфера) либо с адреса, предшествующего началу выделенного блока. Специализированная версия оператора new может запрашивать блоки большего размера и в неиспользуемое место до и после области, доступной пользователям, записывать некоторую комбинацию битов («сигнатуры»). При этом оператор delete может проверять наличие такой сигнатуры. Если ее нет, значит, память была затерта, и оператор delete может запротоколировать этот факт вместе со значением указателя, для которого это обнаружилось.

    Чтобы повысить эффективность. Версии операторов new и delete, поставляемые вместе с компилятором, универсальны. Они должны быть приемлемы как для долго работающих программ (например, Web-серверов), так и для программ, работающих менее одной секунды. Они должны уметь обрабатывать серии запросов на выделение больших блоков памяти, малых блоков, а также смеси тех и других. Они должны адаптироваться к широкому диапазону вариантов использования – от динамического выделения нескольких блоков большого размера, которые существуют на протяжении всего времени работы программы, до выделения и освобождения памяти для большого количества мелких объектов с малым временем жизни. Они должны предотвращать фрагментацию «кучи», ибо если этого не делать, то в конце концов будет невозможно удовлетворить запрос на выделение большого блока памяти, даже если суммарно такой объем имеется, но разнесен по множеству мелких участков.

    Учитывая все требования, предъявляемые к менеджерам памяти, неудивительно, что поставляемые с компиляторами операторы new и delete придерживаются усредненной стратегии. Они работают достаточно хорошо для всех, но оптимально – ни для кого. Если вы хорошо представляете, как динамическая память используется в вашей программе, вы сможете написать собственные версии операторов new и delete, превосходящие по эффективности стандартные. Под «превосходством» я подразумеваю, что они работают быстрее (иногда на много порядков) и требуют меньше памяти (до 50 %). Для некоторых, но отнюдь не для всех, приложений замена поставляемых new и delete собственными версиями – простой способ ощутимого повышения производительности.

    Чтобы собирать статистику использования. Прежде чем перейти к написанию собственных new и delete, благоразумно собрать информацию о том, как ваша программа использует динамическую память. Как распределены выделяемые блоки по размерам? Как распределяется их время жизни? Порядок выделения и освобождения в основном следует принципу FIFO («первым вошел – первым вышел») или же LIFO («последним вошел – первым вышел»)? Или никакой закономерности не наблюдается? Изменяется ли характер использования памяти со временем, то есть существует ли разница в порядке выделения-освобождения памяти между разными стадиями исполнения? Какой максимальный объем динамически выделенной памяти используется в каждый момент времени?

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


    static const int signature = 0xDEADBEEF;

    typedef unsigned char Byte;

    // в этом коде есть несколько дефектов – см. ниже

    void *operator new(std:size_t size) throw(std::bad_alloc)

    {

    using namespace std;

    size_t realSize=size+2*sizeof(int); // увеличить размер запрошенного

    // блока, чтобы можно было разместить

    // сигнатуры

    void *pMem = malloc(realSize); // вызвать malloc для получения памяти

    if(!pMem) throw(bad_alloc);

    // записать сигнатуру в первое и последнее слово выделенного блока

    *(static_cast<int>pMem)) = signature;

    *(reinterpret_cast<int*>(static_cast<Byte*>(pMem)+realSize-sizeof(int))) =

    signature;

    // вернуть указатель на память сразу за начальной сигнатурой

    return static_cast<Byte*>(pMem)+sizeof(int);

    }


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

    Многие компьютерные архитектуры требуют, чтобы данные конкретных типов были размещены в памяти по вполне определенным адресам. Например, архитектура может требовать, чтобы указатели размещались по адресам, кратным четырем (то есть были выровнены на границу четырехбайтового слова), а данные типа double начинались с адреса, кратного восьми. Если не придерживаться этого соглашения, то возможны аппаратные ошибки во время исполнения. Другие архитектуры более терпимы, хотя и могут демонстрировать более высокую производительность, если удовлетворены требования выравнивания. Например, на архитектуре Intel x86 значения типа double могут быть выровнены по границе любого байта, но доступ к ним будет значительно быстрее, если они выровнены по восьмибайтовым границам.

    Выравнивание важно, потому что C++ требует, чтобы все указатели, возвращаемые оператором new, были выровнены для любого типа данных. Функция malloc подчиняется этим же требованиям, поэтому использование указателя, возвращенного malloc, безопасно. Но в приведенном выше операторе new мы не возвращаем указатель, полученный от malloc, а возвращаем указатель, смещенный от возвращенного malloc на размер int. Нет никаких гарантий, что это безопасно! Если клиент вызовет оператор new, чтобы получить память, достаточную для размещения double (либо если мы напишем оператор new[] для выделения памяти под массив значений типа double), а потом запустим программу на машине, где int занимает 4 байта, а значения double должны быть выровнены по границам восьмибайтовых блоков, то, скорее всего, вернем неправильно выровненный указатель. Это может вызвать аварийную остановку программы. Или же просто замедлить ее работу. В любом случае, это совсем не то, что мы хотели.

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

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

    Другой вариант – менеджеры памяти с открытым кодом. Они есть для многих платформ, поэтому можете скачать и попробовать. Один из таких распределителей памяти с открытым кодом – библиотека Pool из проекта Boost (см. правило 55). Библиотека Pool предлагает распределители памяти, оптимизированные для использования в одной из наиболее часто встречающихся ситуаций, где может быть оказаться полезным нестандартный менеджер памяти: распределение памяти для большого количества мелких объектов. Во многих книгах по C++, включая и ранние редакции этой, приводится код высокопроизводительного распределителя памяти для мелких объектов, но часто опускаются такие «скучные» детали, как переносимость, соглашения о выравнивании, безопасность относительно потоков и т. п. В реальные библиотеки включен гораздо более устойчивый код. Даже если вы решите написать собственные new и delete, знакомство версий с открытым кодом, вероятно, даст вам понимание тех деталей, которые отличают «почти работающие» системы от действительно работающих. Выравнивание – одна из таких деталей. Стоило бы отметить, что в отчет TR1 (см. правило 54) включена поддержка для выявления требований выравнивания, специфичных для конкретного типа.

    Тема настоящего правила – вопрос о том, когда имеет смысл подменять версии new и delete по умолчанию – на глобальном уровне или на уровне класса. Теперь мы можем ответить на этот вопрос более подробно.

    • Чтобы обнаруживать ошибки использования (как было сказано выше).

    • Чтобы собирать статистику об использовании динамически распределенной памяти (также было сказано выше).

    • Для ускорения процесса распределения и освобождения памяти. Распределители общего назначения часто (хотя и не всегда) работают намного медленнее, чем оптимизированные версии, особенно если последние специально разработаны для объектов определенного типа. Специфичные для класса распределители являются примерами выделения блоков фиксированного размера, вроде тех, что представляет библиотека Pool из проекта Boost. Если ваше приложение однопоточное, но менеджер памяти, поставляемый с компилятором, по умолчанию потокобезопасный, то вы можете получить заметный рост производительности, написав менеджер памяти для однопоточных приложений. Конечно, прежде чем решить, что нужно переписывать операторы new и delete для повышения скорости, убедитесь с помощью профилирования, что эти функции действительно являются узким местом.

    • Чтобы уменьшить накладные расходы, характерные для стандартного менеджера памяти. Менеджеры памяти общего назначения часто (хотя не всегда) не только медленнее оптимизированных версий, но и потребляют больше памяти. Это происходит из-за того, что с каждым выделенным блоком связаны некоторые накладные расходы. Распределители, оптимизированные для мелких объектов (как, например, Pool), позволяют почти избавиться от этих расходов.

    • Чтобы компенсировать субоптимальное выравнивание в распределителях по умолчанию. Как я уже упоминал, самый быстрый доступ к значениям double на архитектуре x86 получается тогда, когда они выровнены по восьмибайтным границам. К сожалению, операторы new, поставляемые с некоторыми компиляторами, не гарантируют восьмибайтового выравнивания при динамическом выделении double. В этих случаях замена оператора new по умолчанию на специальный, который гарантирует такое выравнивание, может дать заметный рост производительности программы.

    • Чтобы сгруппировать взаимосвязанные объекты друг с другом. Если вы знаете, что определенные структуры данных обычно используются вместе, и хотите минимизировать частоту ошибок из-за отсутствия страницы в физической памяти при работе с такими данными, то, возможно, имеет смысл создать отдельную кучу для подобных структур, чтобы они были собраны вместе, или на столь небольшом числе страниц, насколько возможно. Версии операторов new и delete с размещением (см. правило 52) могут обеспечить такую группировку.

    • Чтобы получить нестандартное поведение. Иногда может понадобиться, чтобы операторы new и delete делали нечто, чего поставляемые с компилятором версии делать не умеют. Например, вам нужно распределять и освобождать блоки памяти в разделяемой памяти, но для операций с такой памятью у вас только программный интерфейс C. Написание специальных версий new и delete (возможно, с размещением – см. правило 52) позволит вам обернуть C API в классы C++. Вы также можете написать специальный оператор delete, который заполняет освобождаемую память нулями, чтобы повысить степень защиты данных в приложении.

    Что следует помнить

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

    Правило 51: Придерживайтесь принятых соглашений при написании new и delete

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

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

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

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

    Забавно, но C++ требует, чтобы оператор new возвращал корректный указатель даже тогда, когда запрошено 0 байтов памяти. (Такое странное поведение упрощает реализацию некоторых вещей в других местах языка.) С учетом этого случая псевдокод для оператора new (нечлена класса) выглядит так:


    void *operator new(std::size_t size) throw(std::bad_alloc)

    { // ваш оператор new может принимать

    using namespace std; // дополнительные параметры

    if (size == 0) { // обработать запрос на 0 байтов,

    size = 1; // считая, что нужно выделить 1 байт

    }

    while(true) {

    попытка выделить size байтов;

    if(выделить удалось)

    return (указатель на память);

    // выделить память не удалось; проверить, установлена ли

    // функция-обработчик new (см. ниже)

    new_handler globalHandler = set_new_handler(0);

    set_new_handler(globalHandler);

    if(globalHandler) (*globalHandler)();

    else throw std::bad_alloc();

    }

    }


    Трактовка запроса на 0 байтов так, как если бы запрашивался 1 байт, выглядит сомнительно, но это просто, это корректно, это работает, к тому же на сколько часто вы собираетесь запрашивать 0 байтов?

    Вам также может не понравиться то место в псевдокоде, где указатель на функцию-обработчик устанавливается в нуль, а затем восстанавливается его прежнее значение. К сожалению, нет способа непосредственно получить указатель на текущий обработчик new, поэтому приходится вызывать set_new_handler, чтобы получить его текущее значение. Грубо, но тоже эффективно, по крайней мере, в однопоточной программе. В многопоточной среде, возможно, понадобится какой-то механизм синхронизации для безопасного манипулирования (глобальными) структурами данных, связанными с функцией-обработчиком new.

    В правиле 49 отмечено, что оператор new содержит бесконечный цикл, и в приведенном выше коде этот цикл присутствует: «while(true)». Единственный способ выйти из цикла – успешно выделить память либо выполнить в функции-обработчике одно из описанных в правиле 49 действий: сделать доступной больше памяти, установить другой обработчик, убрать текущий обработчик, возбудить исключение типа, производного от bad_alloc, либо не возвращать управления вовсе. Теперь вам должно быть ясно, почему обработчик new должен вести себя подобным образом. Если он нарушит это соглашение, то цикл внутри оператора new никогда не завершится.

    Многие не понимают, что функция-член operator new наследуется производными классами. Это может привести к некоторым интересным осложнениям. Заметьте, что в приведенном псевдокоде operator new производится попытка выделить size байтов (если только size не равно нулю). Естественно, ведь size – это аргумент, переданный функции. Однако, как объясняется в правиле 50, одной из причин написания специального менеджера памяти является оптимизация размещения объектов определенного класса, но не любых его подклассов. Иными словами, если в классе X определен оператор new, то предполагается, что он рассчитан на объекты размера sizeof(X) – ни больше, ни меньше. Из-за наследования, однако, появляется возможность вызвать оператор new базового класса, чтобы выделить память для объекта производного класса:


    class Base {

    public:

    static void *operator new(std::size_t size) throw(std::bad_alloc);

    ...

    };

    class Derived: public Base // в подклассе не объявлен operator new

    {...};

    Derived *p = new Derived; // вызывается Base::operator new!


    Если определенный в классе Base оператор new не был спроектирован с учетом этой проблемы (а такая вероятность есть), то для корректной работы в случае, когда поступил запрос на выделение памяти «неправильного» размера, лучше всего обратиться к стандартному оператору new:


    void *Base::operator new(std::size_t) throw(std::bad_alloc)

    {

    if(size != sizeof(Base)) // если size «неправильный»

    return ::operator new(size); // вызвать стандартный оператор new

    // для обработки запроса

    ... // в противном случае обработать запрос

    // здесь

    }


    Я слышу возгласы: «Подождите! Вы забыли проверить патологический случай с нулевым размером!» На самом деле нет. Проверка присутствует, просто она является частью сравнения с sizeof(Base). C++ иногда предъявляется странные требования, например все автономные объекты должны иметь ненулевой размер (см. правило 39). По определению, sizeof(Base) никогда не может вернуть нуль, поэтому если size равно нулю, то запрос будет переадесован::operator new, и обязанность правильно обработать запрос возлагается на него.

    Если вы хотите управлять распределением памяти для массивов на уровне класса, то нужно будет реализовать оператор new[] – специально для массивов. (Эта функция обычно называется «new для массивов», потому что трудно представить, как надо произносить «operator new[]»). Если вы решите написать operator new[], то помните, что она должна лишь выделить блок неформатированной памяти. Вы не можете ничего делать с еще не существующими объектами в этом массиве. Фактически вы даже не можете определить, сколько объектов будет в этом массиве. Во-первых, вы не знаете размер объекта. А ведь из-за наследования может быть вызван оператор new[] базового класса для выделения памяти под массив объектов производного класса, которые обычно больше объектов базового класса. Поэтому вы не можете предполагать внутри Base::operator new[], что размер каждого объекта равен sizeof(Base), а значит, нельзя предполагать, что общее количество объектов в массиве будет равно (запрошенное число байmов)/sizeof(Base). Во-вторых, параметр size_t, переданный оператору new[], может соответствовать большему объему памяти, чем займут сами объекты, потому что, как объясняется в правиле 16, в динамически выделенных массивах может резервироваться место для хранения числа элементов массива.

    Это все соглашения, которым вы должны следовать при написании оператора new[]. Что касается оператора delete, то с ним все проще. Почти все, что вам нужно знать, – это то, что C++ гарантирует безопасность освобождения памяти по нулевому адресу, поэтому и вы должны предоставить такую гарантию. Вот псевдокод для оператора delete, не являющегося членом класса:


    void operator delete(void *rawMemory) throw()

    {

    if(rawMemory == 0) return; // ничего не делать, если передан нулевой

    // указатель

    освободить память, на которую указывает rawMemory

    ;

    }


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


    class Base { // то же, что и раньше, но добавлено

    public: // объявление operator delete

    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; // delete для обработки запроса

    }

    освободить память, на которую указывает rawMemory

    ;

    return;

    }


    Интересно, что значение типа size_t, которое C++ передает оператору delete, может быть неправильным, если удаляется объект, производный от класса, в котором нет виртуального деструктора. Одного этого уже достаточно, чтобы требовать от базового класса наличия виртуального деструктора, но в правиле 7 описана и другая, более существенная причина. Пока просто отметьте, что если вы опустили виртуальный деструктор в базовом классе, то функция operator delete может работать неправильно.

    Что следует помнить

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

    • Оператор delete не должен ничего делать при передаче ему нулевого указателя. Версии оператора delete уровня класса должны обрабатывать запросы на освобождение блоков, которые больше, чем ожидается.

    Правило 52: Если вы написали оператор new с размещением, напишите и соответствующий оператор delete

    Операторы new и delete с размещением встречаются в C++ не слишком часто, поэтому в том, что вы с ними не знакомы, нет ничего страшного. Вспомните (правила 16 и 17), что когда вы пишете такое выражение new:


    Widget *pw = new Widget;


    то вызываются две функции: оператор new, чтобы выделить память, и конструктор Widget по умолчанию.

    Предположим, что первый вызов завершился успешно, а второй возбудил исключение. В этом случае необходимо отменить выделение памяти, выполненное на шаге 1. В противном случае мы получим утечку памяти. Пользовательский код не может освободить память, потому что конструктор Widget возбудил исключение и pw ничего так и не было присвоено. Следовательно, пользователь так и не получил указатель на память, которая должна быть освобождена. Поэтому ответственность за отмену шага 1 возлагается на систему времени исполнения C++.

    Исполняющая система рада бы вызвать оператор delete, соответствующий использованному на шаге 1 оператору new, но сделать это может лишь тогда, когда знает, какой именно вариант оператора delete – а их много – нужно вызвать. Это не проблема, если вы пользуетесь формами new и delete с обычными сигнатурами, потому что обычный оператор new:


    void *operator new(std::size_t size) throw(std::bad_alloc);


    соответствует обычному оператору delete:


    void operator delete(void *rawMemory) throw(); // обычная сигнатура

    // в глобальной области

    // видимости

    void operator delete(void *rawMemory, // наиболее распространенная

    std::size_t size) throw(); // сигнатура в области

    // видимости класса


    Если вы пользуетесь только обычными формами new и delete, то исполняющая система легко найдет тот вариант delete, который знает, как отменить действие, выполненное оператором new. Проблема поиска правильного варианта delete возникает тогда, когда вы объявляете необычные формы оператора new – такие, которые принимают дополнительные параметры.

    Например, предположим, что вы написали оператор new уровня класса, который требует задания потока ofstream, куда должна выводиться отладочная информация о выделении памяти, и вместе с ним написали также обычный оператор delete уровня класса:


    class Widget {

    public:

    ...

    static void *operator new(std:size_t size, // необычная

    std::ostream& logStream) // форма new

    throw(std::bad_alloc);

    static void operator delete(void *pMemory, // обычная

    std:size_t size) throw(); // форма delete

    // уровня класса

    ...

    };


    Такое решение наверняка приведет к ошибкам, но чтобы понять, почему это так, придется познакомиться с некоторыми терминами.

    Функция operator new, принимающая дополнительные параметры (помимо обязательного аргумента size_t), называется оператором new с размещением или размещающим оператором new (placement new). Приведенный выше оператор new как раз и является таковым. Особенно полезным бывает размещающий оператор new, для которого вторым аргументом служит указатель на область памяти, где объект должен быть сконструирован. Этот оператор new выглядит так:


    void *operator new(std::size_t, void *pMemory) throw(); // “размещающий new”


    Эта версия new является частью стандартной библиотеки C++, и вы получаете к ней доступ, включая в исходный текст директиву #include <new>. Кстати говоря, такой оператор new используется в реализации класса vector для создания объектов в выделенной для вектора памяти. Это также первоначальная версия оператора new с размещением; именно она и получила название «placement new». Таким образом, сам термин «размещающий new» перегружен. Обычно, когда говорят о размещающем new, имеют в виду эту конкретную функцию: оператор new, принимающий дополнительный аргумент типа void*. Реже так говорят о любой другой версии new, принимающей дополнительные аргументы. Обычно контекст исключает противоречивые толкования, но важно понимать, что общий термин «размещающий new» означает любую версию new, принимающую дополнительные аргументы, поскольку выражение «размещающий delete» или «delete с размещением» (которое мы сейчас обсудим) происходит от него.

    Но вернемся к объявлению класса Widget, которое я не одобрил. Проблема в том, что этот класс открывает возможность утечки памяти. Рассмотрим следующий пользовательский код, который протоколирует информацию о выделении памяти в поток cerr при динамическом создании объектов Widget:


    Widget *pw = new (std::cerr) Widget; // вызвать оператор new, передав cerr

    // в качестве параметра типа ofstream;

    // это ведет к утечке памяти в случае,

    // когда конструктор Widget возбуждает

    // исключение


    Если выделение памяти прошло успешно, но конструктор Widget возбуждает исключение, то исполняющая система отвечает за освобождение той памяти, которую успел выделить оператор new. Исполняющая система понятия не имеет, как работает вызванная версия оператора new, поэтому не может отменить результат операции самостоятельно. Вместо этого исполняющая система ищет версию оператора delete, которая принимает то же количество аргументов того же типа, что и new, и если находит его, то вызывает. В данном случае оператор new принимает дополнительный аргумент типа ostream&, поэтому соответствующий оператор delete должен иметь следующую сигнатуру:


    void operator delete(void *, std::ostream&) throw();


    По аналогии с размещающими версиями new версии оператора delete, которые принимают дополнительные параметры, называются размещающими delete. Но в классе Widget не объявлена размещающая версия оператора delete, поэтому исполняющая система не знает, как отменить то, что сделал размещающий new. В результате она не делает ничего. В этом примере никакой оператор delete не вызывается, если конструктор Widget возбуждает исключение!

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


    class Widget {

    public:

    ...

    static void *operator new(std:size_t size, std::ostream& logStream)

    throw(std::bad_alloc);

    static void operator delete(void *pMemory) throw();

    static void operator delete(void *pMemory, std::ostream& logStream)

    throw();

    ...

    };


    С этим изменением, если конструктор Widget возбудит исключение в предложении


    Widget *pw = new (std::cerr) Widget; // как раньше, но теперь никаких

    // утечек


    то автоматически будет вызван соответственный размещающий оператор delete, так что Widget гарантирует, что никаких утечек памяти по этой причине не будет.

    Посмотрим, что произойдет, если никаких исключений нет (как обычно и бывает), а в пользовательском коде присутствует явный вызов delete:


    delete pw; // вызов обычного оператора delete


    Как сказано в комментарии, здесь вызывается обычный оператор delete, а не размещающая версия. Размещающий delete вызывается, только если возбуждает исключение конструктор, следующий за вызовом размещающего new. Если delete применяется к указателю (в примере выше – pw), то версия delete с размещением никогда не будет вызвана.

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

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


    class Base {

    public:

    ...

    static void *operator new(std::size_t size, // скрывает обычные

    std::ostream& logStream) // глобальные формы

    throw(std::bad_alloc);

    ...

    };

    Base *pb = new Base; // ошибка! Обычная форма

    // оператора new скрыта

    Base *pb = new (std::cerr)Base; // правильно, вызывается

    // размещающий new из Base


    Аналогично оператор new в производных классах скрывает и глобальную, и унаследованную версии оператора new:


    class Derived: public Base {

    public:

    ...

    static void *operator new(std::size_t size) // переопределяет

    throw(std::bad_alloc); // обычную форму new

    ...

    };

    Derived *pd = new (std::cerr)Derived; // ошибка! заменяющая

    // форма теперь скрыта

    Derived *pd = new Derived; // правильно, вызывается

    // оператор new из Derived


    В правиле 33 достаточно подробно рассмотрен этот вид сокрытия имен в классе, но при написании функций распределения памяти нужно помнить, что по умолчанию C++ представляет следующие формы оператора new в глобальной области видимости:


    void operator new(std::size_t) throw(bad_alloc); // обычный new

    void operator new(std::size_t, void*) throw(bad_alloc); // размещающий new

    void operator new(std::size_t, // new, не возбуждающий

    const std::nothrow_t&) throw(); // исключений –

    // см. правило 49


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

    Самый простой способ – создать базовый класс, содержащий все нормальные формы new и delete:


    class StandardNewDeleteForms {

    public:

    // нормальные new/delete

    static void *operator new(std::size_t size) throw(bad_alloc)

    { return ::operator new(size);}

    static void operator delete(void *pMemory) throw()

    { ::operator delete(pMemory);}

    // размещающие new/delete

    static void *operator new(std::size_t size, void *ptr) throw(bad_alloc)

    { return ::operator new(size, ptr);}

    static void operator delete(void *pMemory, void *ptr) throw()

    { ::operator delete(pMemory, ptr);}

    // не возбуждающие исключений new/delete

    static void *operator new(std::size_t, const std::nothrow_t& nt) throw()

    { return ::operator new(size, nt)}

    static void operator delete(void *pMemory, const std::nothrow_t&) throw()

    { ::operator delete(pMemory, nt);}

    };


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


    class Widget: public StandardNewDeleteForms { // наследование

    public: // стандартных форм

    using StandardNewDeleteForms::operator new; // сделать эти формы

    using StandardNewDeleteForms::operator delete; // видимыми

    static void *operator new(std::size_t size, // добавляется

    std::ostream& logStream) // специальный

    throw(bad_alloc); // размещающий new

    static void operator delete(void *pMemory, // добавляется

    std::ostream& logStream) // соответствующий

    throw(); // размещающий delete

    ...

    };


    Что следует помнить

    • Когда вы пишете размещающую версию оператора new, убедитесь, что не забыли о соответственном размещающем операторе delete. Если его не будет, то в вашей программе могут возникать тонкие, трудноуловимые утечки памяти.

    • Объявляя размещающие версии new и delete, позаботьтесь о том, чтобы нечаянно не скрыть нормальных версий этих функций.









    Главная | Контакты | Нашёл ошибку | Прислать материал | Добавить в избранное

    Все материалы представлены для ознакомления и принадлежат их авторам.