C++ доверяет программисту больше, чем любой другой популярный язык. Он дает вам спички и бензин, полагая, что вы хотите разжечь костер, а не поджечь дом. New и Delete - именно такие спички невероятно мощные, но и очень опасные в непонятных руках.
примечания:
В момент когда я говорю new, чаще всего я не конкретизирую, а имею введу сразу как new, так и delete
Я не считаю себя гуру в 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, вопрос интересный, так что я также ��отел бы затронуть его в этой статье.
классический new не возращает nullptr(за исключением nothrow new), а выкидывает исключение std::bad_alloc, в отличии от malloc
new, вызывает конструкторы объектов, в то время как malloc инициализирует сырую память.
new поддерживает обработку при нехватки памяти, делая программу в разы гибче. (new_handler) (см. ниже)
Если при инициализации передать в new нулевое значение, то он все равно вернет уникальный указатель(см. выше).
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 по своей сути - похож на спички, с бензином, если вы знаете как этим пользоваться, вы получите большую скорость в сложных проектах, безопасность и предсказуемость фрагментации. В ином случае, ошибки такого масштаба, что их придется отлаживать в проекте неделями.
Итог
Несмотря на то что я попытался охватить обильный объем материала и многие тонкости, я по прежнему не раскрыл к примеру такие вещи как выравнивание типов, практическую реализацию и типичные ошибки.
Так или иначе, я не раз затрону многие аспекты этой темы в своих следующих публикациях, а также со временем принесу модификацию в эту статью, с чем надеюсь вы мне поможете.
Благодарю вас за чтение, если у вас остались дополнительные вопросы, советы по правке, а также также вы заметили ошибку, прошу оставьте информацию в комментариях, я обязательно дам обратную связь.
