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

примечания:

  1. В момент когда я говорю new, чаще всего я не конкретизирую, а имею введу сразу как new, так и delete

  2. Я не считаю себя гуру в computer science, так что вероятно в этой статьи могут быть найдены технические ошибки, и я хотел бы вас попросить дать мне знать о них в комментариях, заранее вас благодарю.

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

Оператор New(new expression)

Оператор New - это именно тот оператор, который мы вызываем чаще всего - его основная задача заключается в создании объекта в динамической памяти, в заранее заготовленной сырой памяти, и он выполняет её всего лишь в два конкретных действия.

class_type* ptr = new class_type;
//стандартное создание обькта в куче

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

2) Инициализация обьекта - После чего на полученном участке памяти, оператор new вызывает конструктор объекта, инициализируя его в сырой памяти.

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

Функция New

Функция new - это уже функция из стандартной библиотеки - её задача, заключается в выделении той самой сырой памяти под объект, вызываемым оператором New,

void* ptr = ::operator new(sizeof(class_type));
//выделение памяти без инициализации объекта

если поверхностно описать её основные действия, то это:

1) В бесконечным цикле, пытаться выделить память (в случае успеха возвращая указатель на участок памяти)

2) В случае если память не была выделена, получить текущий обработчик через std::get_new_handler(), для попытки освобождения памяти(об этом ниже)

3) Если обработчика нету, то по просту бросить исключение std::bad_alloc

внутренняя реализация функции new:

#include <new> 
#include <cstdlib>

void* operator new(size_t size) noexcept(false) {
  // стандарт C++ требует, чтобы мы возращали ункальное количество памяти, даже если был передан "0"
  
  if(size == 0) size = 1;

  // в бесконечном цикле обращаемся к malloc для выделения памяти
  while(true) {
    void* ptr = malloc(size);
    if (ptr) 
      return ptr;

    // проверка наличия обработчика
    std::new_handler handler= std::get_new_handler();
    if(!handler) 
      throw std::bad_alloc();
    //вызов обработчика и повтор цикла (если нужно)
    handler();
  }
}
void operator delete(void* ptr) {
  free(ptr);
}

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

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

Версия new без исключений

Как вы уже могли заметить, эта реализация new, бросает исключение, однако порой, в программировании возникает ситуация, когда исключения нежелательны, к примеру в модульных системах, а также для обратной совместимости, с legacy кодом, в котором на ранних версиях языка, оператор new, не выкидывал исключения, а возвращал nullptr, как malloc. так что мы также рассмотрим реализацию и работу nothrow версии.

noexcept new

//вызов new, без исключений, без переопредения
struct nothrow_t {} nothrow;

char* ptr = new(std::nothrow) char[1000000000000l];
// ptr == nullptr

Взглянем на примерную реализацию new, без исключений

//вариант с try/catch
void* operator new(size_t size, const std::nothrow_t&) noexcept {
  try {
     return ::operator new(size);
  } catch(...) {
      return nullptr 
  }
} 

// помимо этого, мы можем взглянуть на версию, не использующую try/catch (эффективнее)
void* operator new(void* ptr, const std::nothrow_t&) noexcept {
  //напрямую выделяем сырую память
  void* ptr = malloc(size);
  if (ptr == nullptr) {

      //получаем обработчик 
      std::new_handler handler = std::get_new_handler();

      // в бесконечном цикле вызываем обрабочик и malloc, в надежде выделить память 
      while(handler && ptr == nullptr) {
        handler();
        ptr = malloc(size); 
      }
    }
  return ptr;
}

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

New[] | Delete[]

Версия new[], пожалуй одна из самых интересных, и неоднозначных.

Ключевая проблема классических new | delete, заключается в том, что delete, технически не может знать, какое количество деструкторов нужно вызвать для очищения целого массива и тд, так что были придуманы - new[] | delete[] , хранящие информацию о размере массива - это называется(cookie)

My_class* ptr = new My_class[10]; //10 конструкторов
delete[] ptr ; //информация что нужно вызвать 10 деструкторов из куки

На практике, new[], выделяет чуть больше памяти, чем размер, который мы ему передаем для хранение куки прямо перед массивом.

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

Тип считается тривиально разрушаемым, если деструктор класса не объявлен пользователем, либо объявлен как default, а также

  • не виртуален

  • все нестатические члены и базовые классы, также тривиально разрушаемы

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

Если тривиально разрушаем, деструктору не обязательно знать количеств�� элементов - он может по просту освободить целый блок памяти, без вызова деструкторов. В этом случае, компилятор часто отпускает куки, и генерирует код, подобный ”free()”.

Если же тип нетривиально разрушаем, delete[] обязан вызвать деструктор для каждого элемента в обратно порядке. Для этого он должен где либо хранить куки, и мы разберём это ниже.

Как мы упомянули ранее, нам обязательно нужно хранить количество элементов, так что сделаем примерную реализацию new[]

void* operator new(size_t size) {
  //для взврата уникального указателя
  if (size == 0) size = 1;

  const cookie_size = sizeof(size_t);
  const size_t total_size = cookie_size + size;
  void* ptr = malloc(total_size);

  if(!ptr) {
    // получаем обработчик 
    std::new_handler handler = std::get_new_handler();

    while(handler) {
      handler();
      ptr = malloc(total_size);
      if (ptr) break; 
    }
  }
  if(!ptr) throw std::bad_alloc();
  //записываем куки
  *static_cast<size_t*>(ptr) = size;
  return static_cast<char*>(ptr) + cookie_size;
}

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

void operator delete[](void* ptr) noexcept {
  if (!ptr) return;
  //отступаем назад, получая куки
  const size_t cookie_size = sizeof(size_t);
  void* block = static_cast<char*>(ptr) - cookie_size;

  //освобождаем память
  free(block); 
}

New vs Malloc

Однажды на одном из собеседований, меня спросили, ключевые отличия между new и malloc, вопрос интересный, так что я также ��отел бы затронуть его в этой статье.

  1. классический new не возращает nullptr(за исключением nothrow new), а выкидывает исключение std::bad_alloc, в отличии от malloc

  2. new, вызывает конструкторы объектов, в то время как malloc инициализирует сырую память.

  3. new поддерживает обработку при нехватки памяти, делая программу в разы гибче. (new_handler) (см. ниже)

  4. Если при инициализации передать в new нулевое значение, то он все равно вернет уникальный указатель(см. выше).

  5. new, гарантирует выравнивание по типу обьекта

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

new_handler

Теперь я предлагаю немного поговорить об обработчике восстановления памяти - new_handeler,

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

std::new_handler handler = std::get_new_handler();

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

#include <iostream> 
#include <cstdlib>
#include <new> 
#include <string>
//пример для логирования

void hand_new() {
  throw std::string("Не хватает памяти для выделения");
}

int main() {
  // передаем указатель на функцию
  std::set_new_handler(hand_new); 
  try {
    char* ptr = new char[1000000000000000l];
  }
  catch(std::string& e) {
    std::cout << e << std::endl;
  }
}

Placement New

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

Полезно: https://habr.com/ru/articles/505632/

#include <iostream> 

struct A {
  int x = 10;
};

int main() {
  //создаем выравненный по структуре "А" буффер
  alignas(A) char buffer[sizeof(A) * 10];

  // конструируем обьекты в buffer
  A* ptr = new(buffer) A;
  A* ptr2 = new(buffer + sizeof(A)) A;

  // обязательно явно вызываем деструктор
  ptr->~A();
  ptr2->~A();
}

При использовании placement new, на вас дополнительно ложатся несколько задач, которые зачастую за нас делают компилятор и аллокаторы, нам необходимо явно вызывать деструктор, для каждого сконструированного обьекта, а также гарантировать правильное выравнивание памяти, в противном случае, вы получите Undefined behaviour в виде утечки ресурсов и вероятным повреждении данных.

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

void* operator new(size_t size, void* ptr) noexcept {
    // без выделения памяти, простой возврат переданного указателя
    // оператор new вызывает конструктор обьекта.
    return ptr; 
}

Создаем выровненный по размеру структуры буффер, и конструируем на нём наш обьект, инициализируя значение val = 100

alignas(my_class) char buffer[sizeof(my_class) * 3];
my_class* ptr1 = new(buffer) my_class(100);

Зачастую компилятор переводит этот код в примерно следующие:

 try {
    // возращаем указатель 
    void* mem = operator new (sizeof(my_class),buffer);
    // явно приводим тип
    ptr1 = static_cast<my_class*>(mem);
    //явно вызываем конструктор обьекта
    ptr1->my_class::my_class(100); 
} catch(...) {
    // в случае если конструктор бросил исключение, очищаем память, перебрасываем исключение дальше
    operator delete(mem, buffer);
    throw;
}

Также хочу обратить ваше внимание на реализацию delete для placement new

void operator delete(void* ptr, void* placeholder) noexcept {
 //нечего  
}

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

Перегрузка new

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

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

//переопределение глобального new
void operator new(size_t size) noexcept(false);
void operator delete(void* ptr) noexcept
//переопределение new для класса
class MyClass {
public:
  static void* operator new(size_t size) {
    return ::operator new(size);
  }
};

Перегрузка new, позволяет реализовывать пул аллокаторы, логгирование статистики и кастомные стратегии выделения. Эта тема масштабна и заслуживает отдельной статьи, которая уже скоро будет опубликована.

На последок

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

А также стараетесь переопределять глобальный new, только если вы точно знаете что делаете, иначе присутствует весомый риск сломать STL.

Также, C++17 требует наличие aligned new.

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

Итог

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

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

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