C++ и память
Danila KuteninКак мы знаем, C++ достаточно много в своей семантике уделяет времени памяти. Исторический контекст такой, что в C большинство программистов очень много работают с памятью руками. Некоторые языки, как Java, Python и Go выбирают подход сборщика мусора. C++ в свою очередь имеет полуавтоматическую аллокацию/деаллокацию памяти с подходом, что контроль за памятью всё же есть, но если писать в определённом стиле, ошибиться достаточно сложно. Я лично считаю, что работа с памятью в C++ реализована как никогда лучше, чем в других языках. Также для меня это топ2 фича языка, больше в C++ мне нравится только функциональщина (лямбды, std::function, function overloading).
Аккуратная работа с памятью сразу отличает новичка от опытного программиста и, к сожалению, с семантики памяти в C++ слезать крайне сложно. Например, по разговорам, те, кто пробовал писать на Go после долгого опыта в C++ задают крайне много вопросов по поводу, где находится память и что удаляется, а что нет и когда.
Разберём несколько идиом в работе с памятью, которые сильно упростят жизнь, а также поговорим про умные указатели со всех их сторон.
Немного про низкие уровни памяти
В C++ можно переопределить операторы new и delete. Обычно это отдельная наука и существует много стандартных аллокаторов. Например, есть отличный рассказ про устройство стандартного аллокатора, когда вы компилируете по дефолту на Linux машине https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/. Аллокаторы обычно имеют один глобальный стейт, чтобы все контейнеры использовали zero based оптимизацию. Поэтому все контейнеры наследуются от std::allocator, который сам по себе пустой класс.
Все привыкли, что аллокации бывают на стеке и на куче, но я скажу, что понимать это стоит когда вы понимаете механику владения, конструирования и разрушения объектов. Только потом стоит задумываться о том, что выделяется на стеке, что на куче, почему так много мелких аллокаций и тому подобное.
Всегда помните, что лучшая фича C++ -- закрывающаяся скобка
При закрытии скобки объекты, которые находятся в этом скоупе, разрушаются в обратном порядке -- поэтому если есть зависимости между объектами, их можно декларировать только сверху вниз, и разрушатся они правильно автоматически. В Go решили эту проблему с помощью defer.
void SomeFunc(...) {
Chocolate choco;
Beans beans;
Mars mars(&choco, &beans);
...
// Разрушение mars
// Разрушение beans
// Разрушение choco
} // <- киллер фича C++
При закрытии скобки происходит свобода оптимизации компиляторам в том числе -- компиляторы намного лучше понимают, какие регистры переиспользовать, что становится свободным и тд. Я обычно при написании кода сокращаю скоупы переменных как только возможно, даже с C++17 это становится делать чуть приятнее с if-init-initialization, например, для локов:
if (MyLock lock(&mutex); condition) {
// do true condition
} else {
// do false condtion. lock still holds.
} // lock is released.
Не используйте new и delete, пока вам это крайне необходимо. То есть примерно никогда
new и delete это почти замена malloc, free, только ещё кидающие исключения и имеющие кое-какие опции по выравниванию.
Типичные ошибки. Рассмотрим очень простой пример
T* object = new T(arg1, arg2, arg3); std::string s = "roflan pominki"; // do some job... delete object;
Проблема в этом коде, что `s` может кинуть исключение, если памяти в компьютере не хватает, кинется исключение и тогда это будет утечка памяти, так как object не удалится.
Чуть более сложный пример и более часто встречающийся.
std::vector<std::unique_ptr<T>> v; v.push_back(new T(arg1, arg2, arg3));
Если new вернёт объект, а вектор увеличится и ему нужна будет аллокация, на которой вектору не хватит памяти, то кинется исключение, и объект будет "подвешенным", что приведёт к утечке памяти. Это чинится довольно просто, но об этом чуть позже.
Мораль простая -- не используйте new/delete в обычном коде. Но это как-то популярно всем известно, расскажу про исключения из правил.
В очень low-level контейнерах оправдать new, delete можно, но в повседневном программировании есть несколько случаев, когда без new не обойтись. У меня есть простое правило, чтобы понять, можно ли обойтись без new. Я задаю себе 2 достаточно редких вопроса "а удаляется ли этот объект в этом же потоке?" и "нужен ли общий доступ из многих потоков?". Есть 4 варианта.
Да. Нет. -- однозначное использование умных указателей. Обычно std::unique_ptr.
{Что_угодно}. Да. -- в большинстве случаев это использование std::shared_ptr, у которого есть своя цена. Также иногда нужны глобальные переменные и создавать их с нетривиальным деструктором ведёт к огромным проблемам и очень сложным багам (потому что порядок разрушения ничем не гарантируется), и люди пишут
static T getT() {
static T* obj = new T(arg1, arg2, arg3);
return *obj;
}
Это потокобезопасный способ сделать так называевый синглтон или просто глобальную переменную. Это не является утечкой памяти, так как пямять недоступна на разрушение, и ни один санитайзер не ругнётся. Также не будет вызван деструктор T. Здесь new может быть оправдан.
Нет. Да. Это значит, что вы насильно передаёте информацию в какой-то возможно другой поток. Например, в асинхронном программировании. Много библиотек реализовывают асинхронное программирование на callbackах -- вы передаёте функцию, которая обязательно вызовется 1 раз с каким-то результатом выполнения в какой-то момент времени, и это ваша задача поставить точки синхронизации на этот коллбек. Точка синхронизации может быть в любом месте, так что работа с памятью чуть усложняется.
Рассмотрим пример --
using DoneFunction = std::function<void(const Result&)>;
void GetResult(DoneFunction done) {
Info* info = new Info;
auto completely_done = [info, done = std::move(done)](const Result& result) {
std::unique_ptr<Info> info_deleter(info);
// Do something with filled info.
...
// Guaranteed call for done if completely_done has call guarantees.
done(result);
// info is destroyed.
};
service.GetAsyncInfoFromNetwork(info, std::move(completely_done));
}
Мы передаём `info` в service.GetAsyncInfoFromNetwork, чтобы функция когда-нибудь его заполнила и вызвала completely_done. Так как асинхронные функции возвращают немедленно, мы не хотим, чтобы info разрушилось. Поэтому мы передаём владение разрушения info в лямбда функцию, а также сохраняем валидным указатель в GetAsyncInfoFromNetwork. В этом случае GetResult можно спокойно добавлять в thread pool и звать асинхронно. Это работает только если есть гарантии на вызов коллбека, иначе это обычная утечка памяти. В распределённых системах почти всегда есть такие или похожие конструкции. Также это работает в основном, если нет исключений, иначе многие инварианты рушатся. Я бы сказал, что в распределённых асинхронных системах исключения являются достаточно спорным моментом -- я бы их отключал совсем. Всё равно всё упадёт :)
В любом асинхронном программировании нужна какая-то идея вне классического C++, например корутины или коллбек механизмы, которые требуют немного больше за рамки каноничных советов по написанию C++.
Создавайте объекты в member listах конструктора. Всегда.
Я говорю про следующий синтаксис
class Fabric {
private:
Chocolate choco;
Beans beans;
Wafers wafer;
Mars mars;
Twix twix;
public:
Fabric(Amount choco_amount, Amount beans_amount, Amount wafer_amount)
: choco(choco_amount) // <- вот этот вот
, beans(beans_amount)
, wafer(wafer_amount)
, mars(&choco, &beans)
, twix(&choco, &beans, &wafer) {}
};
Почему это супер важно? Потому что самые странные баги, которые фиксятся днями связаны с неправильным порядком инициализации. А именно в примере mars и twix зависят от choco, beans и wafer. А представьте, что инициализация происходит в теле конструктора:
class Fabric {
private:
Chocolate choco;
Beans beans;
Mars mars;
Twix twix;
Wafers wafer;
public:
Fabric(Amount choco_amount, Amount beans_amount, Amount wafer_amount) {
choco = Chocolate(choco_amount);
beans = Beans(beans_amount);
wafer = Wafers(wafer_amount);
mars = Mars(&choco, &beans);
twix = Twix(&choco, &beans, &wafer);
}
};
И вы когда-то забыли добавить вафли в твикс, а потом решили добавить (я в последнем примере поменял порядок). Теперь ваш код вообще может падать в случайных местах, потому что wafer разрушается первым, а потом уже twix, который зависит от wafer. При member list такого произойти не может, и все компиляторы имеют ворнинги на некорректный порядок инициализации. И только потом вступает причина копирующего оператора вместо конструктора, всё же самое главное -- порядок создания и обратный порядок разрушения.
Баги с телом конструктора могут не воспроизводиться, флапать, случайно падать. Являются бомбой замедленного действия, что является очень опасным особенно в языке C++. К сожалению, пока такие ошибки ловятся руками и поделать что-то прям сейчас сложно, так как конструктор может иметь любой сложный код внутри.
Используйте обычные указатели как невладеющие
Я люблю обычные указатели, а не ссылки, так как они более читаемые при вызовах функций.
Например,
void CalcSomething(int a, int b, float& c, double& d); CalcSomething(a, b, c, d);
намного меньше читаемо, чем
void CalcSomething(int a, int b, float* c, double* d); CaclSomething(a, b, &c, &d);
во втором примере понятно, что будет вычислено, а что является входными параметрами -- много компаний придерживаются такого стайлгайда. Также с точки зрения перформанса это одно и тоже.
Поэтому для читателей кода всегда приятно иметь некоторые инварианты в голове, и один из них -- понимание "кто ответственный за память". Особенно это полезно понимать в случае -- эта функция или структрура не владеет никакой памятью, она тривиальная, она хорошо оптимизируется и прочее. Даже в асинхронном программировании с чистыми new понимание владения памяти уходит в коллбек, что должно обязательно бросаться в глаза после нескольких минут чтения асинхронного кода.
Я немного старой закалки C++, и я намного больше люблю невладеющие указатели как поля класса, чем ссылки. В основном по причине ̶с̶г̶о̶р̶е̶в̶ш̶е̶й̶ ж̶о̶п̶ы̶ нетривиальной копируемости ссылок в классах, что может влиять на оптимизации.
Например, ссылки некопируемы дефолтно, надо указывать свой assignment operator, что приводит к нетривиальной копируемости структуры, и компиляторы не могут это оптимизировать. Например, поэтому std::pair -- очень плохой и медленный класс, так как он не может быть тривиальным из-за поддержки шаблонов ссылок std::pair<int&, int&>, например.
https://gcc.godbolt.org/z/xsMV6x
Проблема обычных указателей в том, что они могут быть нулевыми и иногда на это стоит ставить проверки. Это единственный минус, с которым можно столкнуться.
В своё время люди из комитета стали думать над автоматическим удалением указателей и придумали std::auto_ptr, который при копировании отдавал свой ownership и тем самым не был "честно" копируемым и не мог быть использоваться в векторах и тд. Вот ссылка кратко объясняющая что же фундаментально сломано в std::auto_ptr, в C++17 этот класс умер и никогда не вернётся в современный C++. https://www.quantstart.com/articles/STL-Containers-and-Auto_ptrs-Why-They-Dont-Mix
std::unique_ptr
Указатель, предоставляющий механику уникального владения через move семантику.
std::unique_ptr<int> x = std::make_unique<int>(5); // no need to call new, make_unique does it std::unique_ptr<int> y = x; // error! you cannot copy what is meant to have 1 ownership std::unique_ptr<int> y = std::move(x); // x contains nullptr, y contains ptr to 5.
Класс крайне полезен, когда надо что-то не создавать, использовать PImpl идиому, иметь одного владельца всё время, быть совместимым с контейнерами, например, в примере выше, чтобы избежать утечки при кидании исключения, стоит делать
v.push_back(std::make_unique<T>(arg1, arg2, arg3));
std::unique_ptr можно передавать как значение функции
void f(std::unique_ptr<int> p); std::unique_ptr<int> p = std::make_unique<int>(5); f(std::move(p));
Все думают, что std::unique_ptr абсолютно дешёвый и инлайнится во всех местах. Это неправда и популярно это рассказал Chandler Carruth на этом CppCon (смотреть с 17:30)
Если коротко, то при передаче std::unique_ptr по значению, мы должны вызвать деструктор unique_ptr, если вылетело исключение, что не является нулевым оверхедом по сравнению с обычными указателями. Дальше даже если отключить исключения, мы должны создавать временный объект для передачи по значению, дальше (глубоко вздыхаю), после добавления && к unique_ptr, мы изменяем calling conventions, добавляя ссылку на unique_ptr, потом мы выясняем, что unique_ptr вообще всегда медленнее, чем обычный указатель на минимум 2 инструкции, что бы мы не делали и вообще расстраиваемся, что стоимость умного указателя никогда не ноль.
Указатель полезен для работы даже с С структурами и абстракциями. Потому что можно передать deleter, который удалит указатель по вашей логике, например, он может закрыть файл при разрушении.
std::unique_ptr<FILE, decltype(&fclose)> fp(fopen("demo.txt", "r"), &fclose);
В C++20 можно будет писать ещё проще:
std::unique_ptr<FILE, decltype([](FILE* f) { fclose(f); })> ptr(fopen("demo.txt", "r"));
Также std::unique_ptr умеет работать с массивами. Так как вектор вызывает дефолтный конструктор на все элементы, то std::unique_ptr<int[]> выделит просто сырую память для типа int. Крайне полезно, если вам нужны последние проценты производительности, и вы не готовы платить за инициализацию вектора.
std::shared_ptr, std::weak_ptr
Этот (shared_ptr) указатель достаточно сложен в своей имплементации, но его суть в том, чтобы иметь много пользователей одного объекта. Внутри есть отдельно от указателя так называемый control block, который хранит два числа -- количество strong_shared ссылок и количество weak_shared ссылок. При копировании std::shared_ptr количество сильных ссылок увеличивается. Счётчики являются атомарными, потому что в своё время решили, что иначе будет достаточно бесполезный указатель и все напишут свой.
{
std::shared_ptr<int> x;
{
std::shared_ptr<int> p = std::make_shared<int>(5);
x = p; // x шарит одинаковый контрольный блок и указатель
} // счётчик strong_shared делает -1
} // счётчик strong_shared делает -1 и разрушает всё.
weak счётчик может увеличивать std::weak_ptr и только после достижения обоих счётчиков нуля удалится вся метаинформация аллоцированная для контрольного блока. Можно считать std::weak_ptr обычным указателем ссылающимся на std::shared_ptr, но в отличие от простого указателя, std::weak_ptr может атомарно проверить и получить shared_ptr, если он ещё жив. Поэтому если вам надо просто разбить цикл зависимостей между shared_ptr, то лучше использовать обычные указатели, а если вам надо атомарно узнавать, жив ли объект, лучше использовать std::weak_ptr.
{
std::weak_ptr<int> x;
{
std::shared_ptr<int> p = std::make_shared<int>(5);
x = p; // x шарит одинаковый контрольный блок и указатель, но делает только слабую ссылку
} // счётчик strong_shared делает -1 и разрушает указатель
} // счётчик weak_shared делает -1 и разрушает метаинформацию о контрольном блоке
Рассмотрим следующий код
struct Bad
{
std::shared_ptr<Bad> getptr() {
return std::shared_ptr<Bad>(this);
}
};
std::shared_ptr<Bad> bp1 = std::make_shared<Bad>();
std::shared_ptr<Bad> bp2 = bp1->getptr();
Каждый указатель будет думать, что он единственный владелец Bad, потому что заново будет создаваться контрольный блок и указатель, что ведёт к double free и UB.
Чтобы избежать этой проблемы, придумали класс под названием std::enable_shared_from_this
struct Good : public std::enable_shared_from_this<Good>
{
std::shared_ptr<Good> getptr() {
return shared_from_this();
}
};
enable_shared_from_this реализован достаточно просто, он хранит weak_ptr на себя и при вызове shared_from_this, конструирует shared_ptr от этого weak. В итоге получается, что getptr шарится между вызовами, если объект типа Good был уже создан от shared_ptr (иначе нет информации передать информацию о контрольном блоке в базовый класс).
То есть такой код невалидный
Good not_so_good; std::shared_ptr<Good> gp1 = not_so_good.getptr(); // нет информации для базового enable_shared_from_this, непонятно что создавать.
А такой валидный и рабочий
std::shared_ptr<Good> gp1 = std::make_shared<Good>(); // в базовый класс заносим информацию о контрольном блоке std::shared_ptr<Good> gp2 = gp1->getptr(); // gp1 и gp2 ссылаются на один и тот же указатель
instrusive_ptr
Этот указатель, как по мне является очень важным, но по какой-то причине его нет в стандарте. А именно intrusive_ptr хочет, чтобы вы сами указывали, что делать с инкрементами и декрементами счётчика. Интерфейс чуть более гибкий получается. Также счётчики не хранятся в самом instrusive_ptr, а вы сами ответственны за это, то есть этот указатель как "доспехи" для обычного указателя, и вы сами ответственны за их прочность. То есть потенциально меньший футпринт памяти итд, про instrusive_ptr можно подробно почитать здесь
Также на странице boost есть замечательный референс по поводу исторической справки по тому, как развивались умные указатели (оказывается, аж с 1994 года), а также очень много примеров по поводу того, что и когда стоит использовать.