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 по своей сути - похож на спички, с бензином, если вы знаете как этим пользоваться, вы получите большую скорость в сложных проектах, безопасность и предсказуемость фрагментации. В ином случае, ошибки такого масштаба, что их придется отлаживать в проекте неделями.
Итог
Несмотря на то что я попытался охватить обильный объем материала и многие тонкости, я по прежнему не раскрыл к примеру такие вещи как выравнивание типов, практическую реализацию и типичные ошибки.
Так или иначе, я не раз затрону многие аспекты этой темы в своих следующих публикациях, а также со временем принесу модификацию в эту статью, с чем надеюсь вы мне поможете.
Благодарю вас за чтение, если у вас остались дополнительные вопросы, советы по правке, а также также вы заметили ошибку, прошу оставьте информацию в комментариях, я обязательно дам обратную связь.
Автор: HAKU_17
