Перегрузка в C++. Часть III. Перегрузка операторов new/delete

  • Tutorial

Продолжаем серию «C++, копаем в глубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Эта статья посвящена перегрузке операторов new/delete. Это третья статья из серии, первая, посвященная перегрузке функций и шаблонов, находится здесь, вторая, посвященная перегрузке операторов, находится здесь. Статья завершает цикл из трех статей, посвященный перегрузке в C++.


Оглавление



1. Стандартные формы операторов new/delete


C++ поддерживает несколько вариантов операторов new/delete. Их можно разделить на основные стандартные, дополнительные стандартные и пользовательские. В этом разделе и разделе 2 рассматриваются стандартные формы, пользовательские формы будут рассмотрены в разделе 3.

1.1. Основные стандартные формы


Основные стандартные формы операторов new/delete, используемые при создании и удалении объекта и массива типа T следующие:

new T(/* аргументы конструктора */)
new T[/* длина массива */]
delete ptr;
delete[] ptr;

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

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

Внимание! Необходимо вызывать правильную форму оператора delete в зависимости от того, удаляется одиночный объект или массив. Это правило надо соблюдать неукоснительно, иначе можно получить неопределенное поведение, то есть может случиться все, что угодно: утечки памяти, аварийное завершение и т.д. Подробнее см. [Meyers1].

В приведенном выше описании необходимо сделать одно уточнение. Для так называемых тривиальных типов (встроенные типы, структуры в стиле С), конструктор по умолчанию может не вызываться, а деструктор в любом случае ничего не делает.

Стандартные функции выделения памяти при невозможности удовлетворить запрос выбрасывают исключение типа std::bad_alloc. Но это исключение можно перехватить, для этого надо установить глобальный перехватчик с помощью вызова функции set_new_handler(), подробнее см. [Meyers1].

Любую форму оператора delete безопасно применять к нулевому указателю.

При создании массива оператором new[] размер может быть установлен нулевым.

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

new int{42}
new int[8]{1,2,3,4}

1.2. Дополнительные стандартные формы


При подключении заголовочного файла <new> становятся доступными еще 4 стандартные формы оператора new:

new(ptr) T(/* аргументы конструктора*/);
new(ptr) T[/* длина массива */];
new(std::nothrow) T(/* аргументы конструктора */);
new(std::nothrow) T[/* длина массива */];

Первые две из них называются размещающим оператором new (non-allocating placement new). Аргумент ptr — это указатель на область памяти, размер которой достаточен для размещения экземпляра или массива. Также область памяти должна иметь соответствующее выравнивание. Этот вариант оператора new не выделяет памяти, он обеспечивает только вызов конструктора. Таким образом данный вариант позволяет разделить фазы выделения памяти и инициализации объектов. Эта возможность активно используется в стандартных контейнерах. Оператор delete для объектов, созданных таким способом, вызывать, конечно, нельзя. Для удаление объекта надо прямо вызвать деструктор, а затем освободить память способом, зависящим от способа выделения памяти.

Вторые два варианта называются не выбрасывающим исключений оператором new (nothrow new) и отличаются тем, что при невозможности удовлетворить запрос возвращают nullptr, а не выбрасывают исключение типа std::bad_alloc. Удаление объекта происходит с помощью основного оператора delete. Эти варианты считаются устаревшими и не рекомендованы для использования.

1.3. Функции выделения и освобождения памяти


Стандартные формы операторов new/delete используют следующие функции выделения и освобождения памяти (allocation and deallocation functions):

void* operator new(std::size_t size);
void operator delete(void* ptr);
void* operator new[](std::size_t size);
void operator delete[](void* ptr);
void* operator new(std::size_t size, void* ptr);
void* operator new[](std::size_t size, void* ptr);
void* operator new(std::size_t size, const std::nothrow_t& nth);
void* operator new[](std::size_t size, const std::nothrow_t& nth);

Эти функции определены в глобальном пространстве имен. Функции выделения памяти для размещающих операторов new ничего не делают и просто возвращают ptr.

C++17 поддержал дополнительные формы функций выделения и освобождения памяти, с указанием выравнивания. Вот некоторые из них:

void* operator new (std::size_t size, std::align_val_t al);
void* operator new[](std::size_t size, std::align_val_t al);

Эти формы непосредственно пользователю недоступны, их использует компилятор для объектов у которых требования по выравниванию превосходят __STDCPP_DEFAULT_NEW_ALIGNMENT__, поэтому главная проблема состоит в том, чтобы пользователь случайно их не скрыл (см. раздел 2.2.1). Напомним, что в C++11 появилась возможность явно задавать выравнивание пользовательских типов.

struct alignas(32) X { /* ... */ };

2. Перегрузка стандартных форм операторов new/delete


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

2.1. Перегрузка в глобальном пространстве имен


Пусть, например, в некотором модуле в глобальном пространстве имен определены пользовательские функции:

void* operator new(std::size_t size)
{
// ...
}

void operator delete(void* ptr)
{
// ...
}

В этом случае произойдет фактически подмена (replacement) стандартных функций выделения и освобождения памяти для всех вызовов операторов new/delete для любых классов (в том числе и стандартных) во всем модуле. Это может привести к полному хаосу. Отметим, что описанный механизм подмены — это особый механизм, реализованный только для этого случая, а не какой-то общий механизм C++. В этом случае при реализации пользовательских функций выделения и освобождения памяти становится невозможным вызов соответствующих стандартных функций, они полностью скрыты (оператор :: не помогает) и при попытке их вызвать возникает рекурсивный вызов пользовательской функции.

Определенная в глобальном пространстве имен функция

void* operator new(std::size_t size, const std::nothrow_t& nth)
{
// ...
}

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

Такая же ситуация с функциями для массивов.

Перегрузка операторов new/delete в глобальном пространстве имен настоятельно не рекомендуется.

2.2. Перегрузка в классе


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

class X
{
// ...
public:
    void* operator new(std::size_t size)
    {
        std::cout << "X new\n";
        return ::operator new(size);
    }

    void operator delete(void* ptr)
    {
        std::cout << "X delete\n";
        ::operator delete(ptr);
    }

    void* operator new[](std::size_t size)
    {
        std::cout << "X new[]\n";
        return ::operator new[](size);
    }

    void operator delete[](void* ptr)
    {
        std::cout << "X delete[]\n";
        ::operator delete[](ptr);
    }
};

В этом примере к стандартным операциям просто добавляется трассировка. Теперь в выражениях new X() и new X[N] будут использоваться эти функции для выделения и освобождения памяти.

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

2.2.1. Доступ к стандартным формам операторов new/delete


Операторы new/delete можно использовать с дополнительным оператором разрешения области видимости, например ::new(p) X(). В этом случае функция operator new(), определенная в классе, будет игнорироваться, а будет использована соответствующая стандартная. Таким же способом можно использовать и оператор delete.

2.2.2. Сокрытие других форм операторов new/delete


Если теперь для класса X мы попробуем использовать размещающий или не выбрасывающий исключений new, то получим ошибку. Дело в том что, функция operator new(std::size_t size) будет скрывать (hide) другие формы operator new(). Проблему можно решить двумя способами. В первом надо добавить соответствующие варианты в класс (эти варианты должны просто делегировать операцию стандартной функции). Во втором надо использовать оператор new с оператором разрешения области видимости, например ::new(p) X().

2.2.3. Стандартные контейнеры


Если мы попробуем разместить экземпляры X в каком-нибудь стандартном контейнера, например std::vector<X>, то увидим, что наши функции для выделения и освобождения памяти не используются. Дело в том, что все стандартные контейнеры имеют собственный механизм выделения и освобождения памяти (специальный класс-аллокатор, являющийся шаблонным параметром контейнера), а для инициализации элементов используют размещающий оператор new.

2.2.4. Наследование


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

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

2.2.5. Альтернативная форма функции operator delete()


В классе (особенно, когда используется наследование) иногда удобно применить альтернативную форму функции освобождения памяти:

void operator delete(void* p, std::size_t size);
void operator delete[](void* p, std::size_t size);

Параметр size задает размер элемента (даже в варианте для массива). Такая форма позволяет использовать разные функции для выделения и освобождения памяти в зависимости от конкретного производного класса.

3. Пользовательские операторы new/delete


C++ может поддержать пользовательские формы оператора new следующего вида:

new(/* аргументы */) T(/* аргументы конструктора */)
new(/* аргументы */) T[/* длина массива */]

Для того, чтобы эти формы были поддержаны, необходимо определить соответствующие функции выделения и освобождения памяти:

void* operator new(std::size_t size, /* доп. параметры */);
void* operator new[](std::size_t size, /* доп. параметры */);
void operator delete(void* p, /* доп. параметры */);
void operator delete[](void* p, /* доп. параметры */);

Список дополнительных параметров функций выделения памяти должен быть не пуст и не состоять из одного void* или const std::nothrow_t&, то есть их сигнатура не должна совпадать с одной из стандартных. Списки дополнительных параметров в operator new() и operator delete() должны совпадать. Аргументы, передаваемые в оператор new, должны соответствовать дополнительным параметрам функций выделения памяти. Пользовательская функция operator delete() также может быть в форме с дополнительным параметром размера.

Эти функции можно определить в глобальном пространстве имен или в классе, но не в пространстве имен, отличном от глобального. Если они определены в глобальном пространстве имен, то они не подменяют, а перегружают стандартные функции выделения и освобождения памяти, поэтому их использование предсказуемо и безопасно, а стандартные функции всегда доступны. Если они определены в классе, то скрывают стандартные формы, но доступ к стандартным формам можно получить с помощью оператора ::, это подробно описано в разделе 2.2.

Пользовательские формы оператора new называют пользовательским размещающим оператором new (user-defined placement new). Их не надо путать со стандартным (non-allocating) размещающим оператором new, описанным в разделе 1.2.

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

Вот пример (в глобальной области видимости).

void* operator new(std::size_t size, int a, const char* b)
{
    std::cout << "new " << a << " + " << b << "\n";
    return ::operator new(size);
}
void operator delete(void* p, int a, const char* b)
{
    std::cout << "delete " << a << " + " << b << "\n";
    ::operator delete(p);
}

class X {/* ... */};
X* p = new(42, "meow") X(); // вывод: new 42 + meow
delete p; // вызов стандартной ::operator delete()

4. Определение функций выделения памяти


В приведенных примерах пользовательские функции operator new() и operator delete() делегировали операцию соответствующей стандартной функции. Иногда и такой вариант полезен, но главная цель перегрузки new/delete является создание нового механизма выделения/освобождения памяти. Задача это не простая, и прежде, чем браться за нее, надо тщательно все продумать. Скотт Мейерс [Meyers1] обсуждает возможные мотивы для принятия подобного решения (конечно, главные из них — это эффективность). Также он обсуждает основные технические проблемы связанные с правильной реализацией пользовательских функций выделения и освобождения памяти (использование функции set_new_handler(), многопоточная синхронизация, выравнивание). В [Guntheroth] приведен пример реализации относительно простых пользовательских функций выделения и освобождения памяти. Прежде, чем создавать свой вариант, следует поискать готовые решения, в качестве примера можно привести библиотеку Pool из проекта Boost.

5. Классы-аллокаторы стандартных контейнеров


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

Список литературы


[Guntheroth]
Гантерот, Курт. Оптимизация программ на C++. Проверенные методы для повышения производительности.: Пер. с англ. — СПб.: ООО «Альфа-книга», 2017.
[Meyers1]
Мэйерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.

Комментарии 12

    +1

    По моему опыту, кастомные аллокаторы нужны для двух вещей: обеспечения выравнивания и эффективной аллокации в контейнерах. C++17 очень прокачал обе эти темы с alignas и pmr, так что о перегрузке операторов можно было бы уже забыть, но в статье к сожалению эта тема не раскрыта вообще (а pmr даже не упоминается).

      +1

      Ещё есть ситуации, где ограничено использование malloc и нужно использовать специальную версию malloc. Например написание расширений для PostgreSQL, там рекомендуется использовать palloc:


      When allocating memory, use the PostgreSQL functions palloc and pfree instead of the corresponding C library functions malloc and free. The memory allocated by palloc will be freed automatically at the end of each transaction, preventing memory leaks.
        0
        Согласен, что кастомные аллокаторы вещь довольно редкая. Аллокаторы для стандартных контейнеров тема весьма обширная, требует отдельной статьи. Именно там уместно обсудить pmr. На первый взгляд pmr ничего особенного из себя не представляют, достаточно традиционная кастомизация аллокатора с помощью виртуальных функция. Небольшие дополнительные накладные расходы на вызов виртуальных функций и за это полное отделения интерфейса от реализации. Я пока эту тему глубоко не копал, может там действительно есть что-то интересное.
        0
        Может быть стоило бы написать о том, чем отличается new int[42]; от new int[42]();/new int[42]{};?

        Потому что я видел реальных людей, которые пытылись «лишнее» убрать…
          0
          int является тривиальным типом, поэтому при выполнении new int[42] для элементов не будет вызван конструктор по умолчанию (который для int просто обнуляет) и вектор будет инициализирован случайными значениями. Для не тривиальных типов гарантирован вызов конструктора по умолчанию. Два других варианта как раз и гарантируют вызов конструктора по умолчанию для любых типов, в том числе и тривиальных и в приведенных примерах вектор будет обнулен. Вариант с фигурными скобками позволяет задать произвольные инициализирующие значения для элементов вектора.
            0
            Спасибо — я-то про это знаю… но ваша статья вроде как для новичков… они могут не знать. Ну раз уж всё равно упоминаете про инициализаторы — то упомяните ещё и том, что память может быть неинициализирована.

            Для выходцев со всяких Java/PHP/Python это часто оказывается неожиданностью… а людей, переходящих с C на C++ сегодня немного…
              0
              Я бы включил эти вопросы в статью про тривиальные типы и неинициализированные переменные. Тема на самом деле довольно интересная, правда материала не очень много, так на пару страниц.
          +1

          Не знал про пользовательские new/delete, даже стало интересно применяет ли их кто.


          Мне нравится такой формат — много деталей в одном месте и понятным образом описано.

            +2
            Не знал про пользовательские new/delete, даже стало интересно применяет ли их кто.
            Крайне редко. У них есть недостаток: вы можете сделать, условно, operator new для какого-то класса Node… но вот для какого-нибудь std::set<Node> они применяться не будут. И сделать тут ничего нельзя.

            Потому обычно используются аллокаторы… а перегружаемые new/delete так и остались памятником плохому дизайну…
              0

              Пришло в голову что-то вроде выделение в определенной арене:auto t = new(current_frame_arena) Animation(...), но если подумать, это не есть что-то такое что нельзя было бы сделать без этой фичи (auto t = current_frame_arena.Allocate<Animation>(...)).


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

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

                Но это всё появилось позже!
              +1
              Спасибо! Рад, что формат понравился. Надеюсь через пару недель будет еще статья. Действительно пользовательские new/delete используются редко (лично я сам никогда). Но мне хотелось закрыть тему про перегрузку в C++ и без пользовательские new/delete это бы не получилось.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое