Грокаем C++
7.53K subscribers
25 photos
3 files
336 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Проблема Construct on first use idiom
#опытным

Прошлый пост показывает решение проблемы static initialization order fiasco. Однако даже этот прием имеет свои проблемы.

Дело в том, что мы сильно фокусировались на инициализации объекта и решали проблемы с ней. Но как насчет разрушения объекта? Мы подумали об этом? Not really.

Давайте возьмем классы, которые могут быть использованы для создания и статических объектов и любых других.

// ClassA.h
class ClassA {
public:
int makeSomethingUsefull(){}
~ClassA() { another_global.use_it();}
};

static ClassA& GetStaticClassA() {
static ClassA inst{};
return inst;
}

//another_singleton.h
#include "singleton.h"

class ClassB {
public:
ClassB(int param) : data{param} {};
~ClassB() { another_global.use_it();}
private:
int data;
};

static ClassB& GetStaticClassB() {;
static ClassB inst{GetStaticClassA().makeSomethingUsefull()};
return inst;
}


У нас все также 2 класса, но они уже не синглтоны, а могут создаваться в какой угодно области. Нам нужны статические объекты этих классов. И мы, как умные дяди, оградили себя от проблемы инициализации статиков, используя construct on first use idiom. Однако замечу, что в деструкторах наших классов они используют глобальную переменную another_global. И например, для объектов с автоматическим временем жизни это вообще не проблема, они свободно создаются и разрушаются.

Но что же будет, если так получится, что another_global удалится раньше, чем статические объекты наших классов? Правильно. Static deinitialization order fiasco. Обращение к уже разрушенному объекту - такое же UB, как и обращение к еще не инициализированному.

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

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

И это проблема не идиомы в целом, а подхода к созданию объекта. Есть и другой способ это делать:

// ClassA.h

// Here Class A definition

static ClassA& GetStaticClassA() {
static ClassA* inst = new ClassA{};
return *inst;
}

//another_singleton.h
#include "singleton.h"

// Here ClassB definition

static ClassB& GetStaticClassB() {;
static ClassB* inst = new ClassB{GetStaticClassA().makeSomethingUsefull()};
return *inst;
}


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

Мы никогда не вызываем delete. В конце программы разрушится только указатель, но не объект, на который он указывает. Обычно такая ситуация называется data leak, но в этом случае "вы не понимаете, это другое". Потому что при завершении программы ОС сама освобождает всю память, которая была занята программой и на самом деле ничего не утекает. Утечка памяти - это постоянное увеличение использования памяти программы со временем ее жизни. А тут мы один раз захватили эту память(и только эту!), но просто не отдали. Потребление памяти в течение программы не увеличивается. Как говорится: "Это норма!".

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

See drawbacks of your solutions. Stay cool.

#goodpractice #design #cppcore
Еще один способ решения Static Initialization Order Fiasco
#опытным

Предыдущий пост навел меня на еще один метод решения SIOF. Это в догонку к этому посту с решениями.

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

Это позволит вам в одном месте инициализировать связанные объекты сразу и в том порядке, в котором это не вызовет неприятных эффектов. Вы полностью контролируете ситуацию.

// header.hpp
struct Class {
Class(int num) : field{num} {}
int field;
};

// source.cpp
Class * static_ptr2 = nullptr;

//main.cpp
int * static_ptr1;
extern Class * static_ptr2;

void Init() {
static_ptr1 = new int{6};
static_ptr2 = new Class{*static_ptr1};
}

int main() {
Init();
std::cout << static_ptr2->field << std::endl;
}

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

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

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

// header.hpp
struct Class {
Class(int num) : field{num} {}
int field;
};

// source.cpp
std::unique_ptr<Class> static_ptr2 = nullptr;

//main.cpp
std::unique_ptr<int> static_ptr1 = nullptr;
extern std::unique_ptr<Class> static_ptr2;

void Init() {
static_ptr1 = std::make_unique<int>(6);
static_ptr2 = std::make_unique<Class>(*static_ptr1);
}

int main() {
Init();
std::cout << static_ptr2->field << std::endl;
}

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

Stay safe. Stay cool.

#cpprore #cpp11 #STL #pattern
Как работает dynamic_cast? RTTI!
#опытным #fun

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

Как мы видели ранее, для полиморфных объектов существует специальный оператор dynamic_cast. Стандарт не регламентирует его реализацию, но чаще всего, для работы требуется дополнительная информация о типе полиморфного объекта RTTI (Run Time Type Information). Посмотреть эту структуру можно с помощью оператора typeid:
cpp
const auto &RTTI = typeid(object);

Обратите внимание, typeid возвращает read-only ссылку на объект std::type_info, т.к. эту область памяти нельзя изменять — она была сгенерирована компилятором на этапе компиляции.

Содержимое RTTI зависит от компилятора, но как минимум там хранится hash полиморфного класса и его имя, которые доступны из std::type_info. Маловероятно, что вам на этом потребуется построить какую-то логику приложения, но эта штука могла бы быть вам полезна при отладке / подсчёте статистики и т.д.

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

Как же нам найти начало объекта RTTI? Не боги горшки обжигают, есть просто специальный указатель, который расположен прямо перед началом таблицы виртуальных методов. Он и ведёт к объекту RTTI:
┌-─|   ptr to RTTI  |   vtable pointer
| |----------------| <- looks here
| | vtable methods |
| |----------------|
└─>| RTTI object |


Получив доступ к дополнительной информации остаётся выполнить приведение типа: upcast, downcast, sidecast/crosscast. Эта задача требует совершить поиск в ориентированном ациклическом графе (DAG, directed acyclic graph), что в рамках этой операции может быть трудоёмким, но необходимым для обработки общего случая. Теперь мы можем даже ответить, почему dynamic_cast такой медленный.

Можем ли мы как-то ускорить работу? Мы можем просто запретить использовать dynamic_cast 😄 Это можно сделать, отключив RTTI с помощью флага компиляции:
-fno-rtti

И такое ограничение будет автоматически подталкивать к пересмотру полученной архитектуры решения или разработке собственного механизма приведения типов.

На счет последнего надо много и долго думать. На стыке двух динамических библиотек, которые могут ничего не знать друг о друге, придется как-то проверять, что лежит в динамическом типе. Так же необходимо учитывать особенности множественного и виртуального наследования. От них можно и в принципе отказаться, но как запретить вышеупомянутые виды наследования в коде? Меня бы в первую очередь интересовала автономная и независимая жизнь проекта без пристального надзора хранителей знаний. Это задача, которая имеет много подводных камней или требует введения в проект ограничений, дополнительного контроля.

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

#cppcore #howitworks
Please open Telegram to view this post
VIEW IN TELEGRAM
Еще одна проблема при разрушении статиков
#опытным

Идею для поста подкинул Михаил в этом комменте

Суть в чем. Все глобальные переменные, не помеченные thread_local, создаются и уничтожаются в главном потоке, в котором выполняется main(). Но использовать мы их можем и в других потоках, адресное пространство-то одно. И вот здесь скрывается опасность: мы можем использовать в другом потоке глобальную переменную, которая уже была уничтожена!

Вы просите объяснений? Их есть у меня.

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

По пунктам

1️⃣ Статические переменные удаляются при вызове std::exit, что происходит после завершения main(). Значит, нам нужно выйти из main'а.

2️⃣ Получается, что второй поток должен продолжать выполняться даже после завершения main. Тут только один вариант: отделить тред от его объекта, чтобы его не нужно было джойнить. Делается это с помощью метода detach().

3️⃣ Использование переменной вторым потоком должно быть между разрушением глобальной переменной и завершением std::exit, потому что эта функция завершает процесс. И естественно, что после завершения процесса уже никакие потоки выполняться не могут.

Вот такие незамысловатые условия. Давайте посмотрим на примере.


struct A {
~A() {
std::this_thread::sleep_for(std::chrono::seconds(5));
}
};

struct B {
std::string str = "Use me";
~B() {
std::cout << "B dtor" << std::endl;;
}
};

A global_for_waiting_inside_globals_dectruction;
B violated_global;

void Func() {
for (int i = 0; i < 20; ++i) {
std::cout << violated_global.str << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}

int main() {
std::thread th{Func};
th.detach();
std::this_thread::sleep_for(std::chrono::seconds(3)); // aka some usefull work
}


Быстренькое пояснение. Создал 2 простеньких класса, которые позволят наглядно показать процесс удаления переменной и использования ее после удаления. Деструктор первого класса заставляет главный тред уснуть на 5 секунд, что помещает программу в опасное состояние как раз между ее завершением и разрушением статиков. Второй класс мы как раз и будем использовать для создания шаренного объекта, который использует второй тред. У него в деструкторе выводится сообщение-индикатор удаления. Давайте посмотрим на вывод:

Use me
Use me
Use me
B dtor
Use me
Use me
Use me
Use me
Use me


Поймана за хвост, паршивка! Мы используем поле удаленного объекта, что чистой воды UB!

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

Если я что-то упустил, то пусть Михаил меня поправит в комментах.

Avoid dangerous practices. Stay cool.

#cppcore #cpp11 #concurrency
Девиртуализация вызовов. Ч2
#опытным

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

Получается, что нам достаточно ограничить внешнее связывание? Рассмотрим в примерах дальше 😊

Запрет на внешнее связывание 1
Итак, мы ведь знаем, что для конкретной функции можно запретить внешнее связывание, например, с помощью static. Из живого примера:
// direct call!
static void bar(Base &da, Base &db)
{
// push  rbx
// mov rax, [rdi]
// mov   rbx, rsi
da.vmethod(); // call DerivedA::vmethod()
// mov   rdi, rbx
// pop   rbx
db.vmethod(); // jmp   DerivedB::vmethod()
}

Вызов функции bar - единственный в данной единице трансляции, с конкретными наследниками Base. Следовательно, мы можем доказать П.2, П.4, П.3 (терминология из первой части).

Кстати, П.2 может быть доказан лишь частично! Например, bar можно вызывать с разными аргументами, тогда оптимизация будет совершена лишь частично:
// indirect + direct call
static void bar(Base &da, Base &db)
{
// push  rbx
// mov rax, [rdi]
// mov   rbx, rsi
da.vmethod(); // call  [[rax]]
// mov   rdi, rbx
// pop   rbx
db.vmethod(); // jmp   DerivedB::vmethod()
}

В данном случае, с учетом всех наборов аргументов при вызове foo, только второй vmethod может быть оптимизирован.

Запрет на внешнее связывание 2
В предыдущих способах можно заметить, что сложности возникают с доказательством П.2 и П.4. Компилятор опасается, что в других единицах трансляции появятся либо новые перегрузки, либо будут вызваны функции с объектами других наследников полиморфных классов.

Учитывая особенности сборки проекта, разработчик может намеренно сообщить компилятору, что других единиц трансляции не будет. В частности, для LLVM Clang можно применить следующие опции:
-flto -fwhole-program-vtables -fvisibility=hidden

В GCC можно вообще указать, что компилируемая единица и есть вся программа с помощью флага:
-fwhole-program

Он буквально разрешает считать, что компилятор знает ВСЕ известные перегрузки и их вызовы. Короче, отметит все функции ключевым словом static: живой пример.

Запрет на внешнее связывание 3
Еще один способ показать компилятору, что новых полиморфных перегрузок не появится. Можно использовать unnamed namespace:
namespace
{
struct Base
{
virtual void vmethod();
};

struct Derived : public Base
{
void vmethod() override;
};
}

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

Вот такими несложными действиями можно сократить количество обращений к таблице виртуальных методов и ускорить выполнение вашего приложения 😉

#cppcore #hardcore #howitworks
Double-Checked Locking Pattern Classic
#опытным

Ядро идеи этого паттерна - тот факт, что решение из предыдущего поста неоптимально. Нам на самом деле нужно всего один раз взять замок для того, чтобы создать объект и потом не возвращаться к этом шагу. Если кто-то увидит, что наш указатель - ненулевой, то он даже не будет пытаться что-то делать и сразу вернется из функции.

Поэтому в паттерне блокировки с двойной проверкой, нулёвость указателя проверяется перед локом. Таким образом мы откидываем просадку производительности для подавляющего большинства вызова геттера синглтона. Однако у нас теперь остается узкое место - момент инициализации. И вот где появляется вторая проверка(всю обертку уже не буду писать для краткости).

static Singleton* Singleton::instance() {
if (inst_ptr == NULL) {
Lock lock;
if (inst_ptr == NULL) {
inst_ptr = new Singleton;
}
}
return inst_ptr;
}


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

Это классическая реализация, многие подписчики, думаю, видели ее. Однако от того, что она классическая, не следует, что она корректная.

Давайте посмотрим на вот эту строчку поближе:

inst_ptr = new Singleton;


Что здесь происходит? На самом деле происходят 3 шага:

1️⃣ Аллокация памяти под объект.

2️⃣ Вызов его конструктора на аллоцированной памяти.

3️⃣ Присваивание inst_ptr'у нового значения.

И вот мы, как наивные чукотские мальчики, думаем, что все эти 3 шага происходят в этом конкретном порядке. А вот фигушки! Компилятор, мать его ети. Иногда он может просто взять и переставить шаги 2 и 3 местами! И вот к чему это может привести.

Давайте посмотрим эквивалентный плюсовый код, когда компилятор переставил шаги:

static Singleton* Singleton::instance() {
if (inst_ptr == NULL) {
Lock lock;
if (inst_ptr == NULL) {
inst_ptr = // step 3
operator new(sizeof(Singleton)); // step 1
new(inst_ptr) Singleton; // step 2
}
}
return inst_ptr;
}


Че здесь происходит. Здесь просто явно показаны шаги. С помощью operator new мы выделяем память(1 шаг), дальше присваиваем указатель на эту память inst_ptr'у(шаг 3). И в конце конструируем объект. И напомню, это не программист так пишет. Это эквивалентный код тому, что может сгенерировать компилятор.

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

Тред №1 входит в первое условие, берет лок и выполняет шаги 1 и 3 и потом засыпает по воле планировщика. И мы имеем состояние, когда указатель проинициализирован, а объекта на этой памяти еще нет(шаг 2 не выполнен).

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

Что можно сделать? Вообще говоря, ничего. Если сам язык не подразумевает многопоточности, то компилятор даже не думает о таких штуках и с его точки зрения все валидно. Даже volatile предотвращает реордеринг инструкций в рамках только одного потока. Но мы же в многоядерной среде и там существуют совершенно другие эффекты, о которых "безпоточные" С и С++ в душе не знают. Напоминаю, что мы до сих пор в эре до С++11. Завтра чуть ближе посмотрим на конкретные проблемы, при которых мы сталкиваемся, находясь в многопоточном окружении.

Criticize your solutions. Stay cool.

#concurrency #cppcore #compiler #cpp11
Рабочий Double-Checked Locking Pattern
#опытным

Мы уже довольно много говорим о нем и его проблемах. Давайте же сегодня обсудим решение.

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

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

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance;
... // insert acquire memory barrier
if (tmp == NULL) {
Lock lock;
tmp = m_instance;
if (tmp == NULL) {
tmp = new Singleton;
... // insert release memory barrier
m_instance = tmp;
}
}
return tmp;
}


Вот как выглядела бы более менее работающая реализация паттерна блокировки с двойной проверкой до нашей эры(до С++11). Так как в то время в языке и стандартной библиотеке не было ничего, что связано с потоками, то для барьеров приходилось использовать platform-specific инструкции, часто с ассемблерными вставками.

Acquire барьер предотвращает переупорядочивание любого чтения, которое находится сверху от него, с любыми чтением/записью, которые следуют после барьера. Одна из проблем кода без барьеров: мы можем считать ненулевой указатель в tmp, но при этом результат операции инициализации объекта к нам еще не подтянется. Мы вернем из геттера неинициализированный указатель, что UB. Именно для предотвращения такого эффекта, в данном случае такой барьер нужен сверху для того, чтобы мы подтянули инициализированный объект из кэша другого ядра в случае, если мы все-таки считали ненулевой указатель.

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

Release барьер предотвращает переупорядочивание любого чтения/записи, которое находится сверху от него, с любой записью, которые следуют после барьера. Здесь также 2 составляющие. Первая: предотвращает переупорядочивание иницализации синглтона с присваиванием его указателя к m_instance. Это дает четкий порядок: в начале создаем объект, а потом m_instance указываем на него. Вторая гарантирует нам правильный порядок "отправки" изменений из текущего треда в точки назначения.

Объяснения не самые подробные и точные, но опять же, не было такой цели. Кто понимает - поймет, а кто не понимает - ждите статьи по модели памяти)

И вот как выглядела бы реализация этого паттерна на современном С++, если бы статические локальные переменные не гарантировали бы потокобезопасной инициализации:

std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}


Здесь мы только на всякий случай обернули указатель синглтона в атомик указатель, чтобы полностью быть так сказать в lock-free контексте. Барьеры на своих местах, а для залочивания мьютекса используем стандартный std::lock_guard с CTAD из 17-х плюсов.

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

Establish your barriers. Stay cool.

#concurrency #cpp11 #cpp17
Ассемблер инициализации статических локальных переменных
#опытным

Пример из предыдущего поста - рабочая версия паттерна. Однако, нам, вообще говоря, можно всего этого не писать. Ведь начиная с С++11 нам гарантируют тред-сэйф инициализацию статических локальных переменных и можно просто писать:

Singleton& Singleton::getInstance() {
static Singleton instance;
return instance;
}


Мы посмотрели, как вся защита может выглядеть на уровне С++ кода. Но в примере сверху никакой защиты на этом уровне нет. А это значит, что она лежит ниже, на уровне машинных инструкций. Которые мы можем с горем-пополам прочитать в виде ассемблера.

Сейчас будет очень страшно, но я попытался оставить самые важные куски и места и опустил неважное. Показываю ассемблер под x86-64, сгенерированный gcc.

Singleton::getInstance():
1 movzbl guard variable for Singleton::getInstance()::instance(%rip), %eax
2 testb %al, %al
3 je .L19
4 movl $Singleton::getInstance()::instance, %eax
5 ret
.L19:
...
6 call __cxa_guard_acquire
7 testl %eax, %eax
8 jne .L20
.L9:
9 movl $Singleton::getInstance()::instance, %eax
10 popq %rbx
11 ret
.L20:
12 movl $Singleton::getInstance()::instance, %esi
{Constructor}
13 movl $guard variable for Singleton::getInstance()::instance, %edi
14 call __cxa_guard_release
{safe instance and return}



Так как код оперирует объектом, а не указателем, то и в ассемблере это отражено. Но да не особо это важно. Сейчас все поймете. Для удобства обращения к коду, пометил строчки номерами.

Итак, мы входим в функцию. И тут же на первой строчке у нас появляется строжевая гвардия для переменной instance. Гвардия защищена барьером памяти и она показывает, инициализирована уже instance или нет. Так как мы без указателей, то вместо загрузки указателя и установки барьера памяти тут просто происходит загрузка гард-переменной для instance в регистр eax. Дальше на второй строчке мы проверяем, была ли инициализирована instance. al - это младший байт регистра eax. Соотвественно, если al - ноль, то инструкция testb выставляет zero-flag и в условном прыжке на 3-ей строчке мы прыгаем по метке. Если al - не ноль, то наш синглтон уже инициализирован и мы можем вернуть его из функции. Получается, что это наша первая проверка на ноль.

На метке .L19 мы берем лок с помощью вызова __cxa_guard_acquire, которая используется для залочивания мьютексов. И снова проверяем переменную-гард на пустоту(напоминаем себе, что она в eax загружена), если до сих пор она нулевая, то прыгаем в .L20. Если уже не ноль, то есть переменная инициализирована, то проваливаемся в .L9, где кладем созданную переменную в регистр возврата значения на 9-й строчке и выходим из функции(10 и 11). Это была вторая проверка

На метке .L20 мы на 12-й строчке кладем наш неинициализированный синглтон в регистр для последующей обработки, а именно для конструирования объекта. На 13-й строчке кладем адрес гарда в регистр, чтобы чуть позже записать туда ненулевое значение aka синглтон инициализирован. Далее мы отпускаем лок с помощью __cxa_guard_release, делаем все необходимые завершающие действия и выходим из функции.

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

Стоит еще раз обратить внимание на то, что __cxa_guard_acquire и __cxa_guard_release - это не барьеры памяти! Это захват мьютекса. Барьеры памяти напрямую здесь не нужны. Нам важно только защитить гард-переменную для синглтона, потому что проверяется только она.

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

Dig deeper. Stay cool.

#concurrency #cppcore
Сочетание member initialization list и default member initializer
#опытным

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

Также в С++11 у нас появилась фича под названием default member initializer. Это та самая штуковина, которая позволяет вам инициализировать нестатические поля класса не в конструкторе, а прям inplace. Типа того:

struct Class {
int field = 5;
};


Фича полезная, многие ей часто пользуются. Но вот возникает вопрос: как список инициализации конструктора взаимодействует с default member initializer? Если я инициализирую поля вне конструктора и компилятор видит эти значения явным образом, то возможно эти поля и получают значение первыми? Сейчас все узнаем.

Посмотрим на такой пример:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
c{'3'},
e{'5'} {}
Char a;
Char b = '2';
Char c;
Char d = '4';
Char e;
};


Есть простенький класс Char, который выводит на консоль момент создания объекта. И тестовый класс, на котором мы и проводим эксперимент. И в этом эксперименте мы и проверим, в каком порядке свои значения получают поля b и d, относительно a, c, e.

На самом деле здесь правило ровно такое же. Нестатические поля класса инициализируются в порядке их появления в описании класса. Поэтому вывод будет таким:

Char 1
Char 2
Char 3
Char 4
Char 5


С этим разобрались.

И тут назревает вопрос: а что будет, если я в начале проициализирую поле inplace, а потом еще раз в constructor initializer list? Какая из инициализаций победит другую? Или быть может они произойдут обе в какой-то очередности?

Выглядеть это может так:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
b{'2'},
c{'3'},
d{'4'},
e{'5'} {}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};


Опять в подопытные мы взяли поля b и d и задали им значения с помощью default member initializer. А вдогонку еще и в списке инициализации присвоили им значение.

В такой ситуации default member initializer не играет никакой роли, блаженно складывает лапки и отдает бразды правления списку инициализации. Вывод будет тем же, что и в прошлом примере:

Char 1
Char 2
Char 3
Char 4
Char 5


Но это только список инициализации так работает. Если для инициализации поля вы используете обычный конструктор, то оно первый раз проинициализируется с помощью default member initializer(которая обязательно происходит до входа в тело конструктора), а второй раз - в теле конструктора.

struct TestClass {
TestClass() : a{'1'},
c{'3'},
d{'4'},
e{'5'} {b = '2';}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};
// Output

Char 1
Char b
Char 3
Char 4
Char 5
Char 2


Пишите в комменты, если есть еще какие-то интересные кейсы взаимодействия этих сущностей. В будущем, разберем их на канале.

Mix things properly. Stay cool.

#cpp11 #cppcore
​​Short circuit для кастомных операторов
#опытным

Есть одно важное уточнение, которое не было упомянуто в посте про short-circuit операторы, но несколько комментаторов на это указывали. Прочитайте, кстати, пост, если впервые видите словосочетание short circuit.

В плюсах короткосхемностью обладают операторы && и ||. Из коробки их операндами могут быть переменные логического, целочисленного и указательного типа. Однако они все так или иначе приводятся к типу булеан. Поэтому в принципе корректно говорить, что логические операторы работают только с логическими типами. Что в целом довольно логичная логика.

Однако есть в этом Эдеме есть и змий искуситель, который портит всю малину. Эти операторы можно перегружать для кастомных типов. И тогда они теряют свои короткосхемные свойства.

Взгляните на следующий код:


struct CustomStruct
{
int number = 0;
bool operator&&(const CustomStruct& other)
{
return number && other.number;
}
};

static int check = 0;

CustomStruct func()
{
check = 1;
return CustomStruct{};
}

int main() {
CustomStruct a{};
a && func();
std::cout << check << std::endl;
}


Здесь мы создаем самую простую структурку и перегружаем для нее оператор логического И. Дальше, чтобы проверить ленивость вычисления оператора, пишем простую функцию, которая при исполнении изменяет статическую переменную. Так мы сможем наверняка убедиться, выполнилась ли функция или нет: если выполнилась, то переменная check будет выставлена в единицу, если нет, то останется нулем.

И вывод будет реально "1". Что выглядит довольно печально.

Ну и кстати, такое поведение довольно легко объяснить. Когда мы перегружаем операторы, то мы создаем новые функции. И я хочу акцентировать на этом внимание: это именно пользовательские функции, как бы они там не назывались. А аргументы пользовательских функций должны быть вычислены ДО захода в функцию. Поэтому любые операнды должны быть полностью вычислены до вычисления значения всего выражения. Это и приводит к отсутствию свойства short circuit.

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

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

Однако есть способ разрешить ленивое вычисление логического И или ИЛИ для кастомных типов. Но об этом в следующий раз.

Don't loose your properties. Stay cool

#cppcore
Виртуальный деструктор и std::shared_ptr
#опытным

Плюсы - поистине удивительный язык. Вот подписчик изучил у нас на канале пользу виртуального деструктора и пошел в комментарии. А там Василий прислал пример, который говорит о том, что в определенном случае виртульность деструктора не важна и без него все работает корректно. И подписчик действительно удивляется: "What the fuck is going on?!?!?!?". Разберем все по порядку.

Пример вот такой:
struct Base {
~Base() {
std::cout << "Base::~Base()" << std::endl;
}
};

struct Derived : Base {
~Derived() {
std::cout << "Derived::~Derived()" << std::endl;
}
};

int main() {
std::shared_ptr<Base> p1 = std::make_shared<Derived>();
}


Прикол в том, что при удалении p1 вызовется деструктор наследованного класса:

Derived::~Derived()
Base::~Base()


Почему так?

Во время создания std::shared_ptr вы можете задать свой кастомный делитер
. Но даже если вы его не предоставили, делитер все равно создается. Просто компилятор сам выведет по его мнению подходящий удалятель. И сохранит его в контрол блок умного указателя.

Так вот логично, что, если мы создаем указатель от объекта тип Derived, то и делитер выбирается соотвествующий. И в контрол блоке правого шареда будет делитер, который удаляет Derived*. Далее при присваивании указатель на этот конкретный контрол блок копируется левому шареду. После этого контрольный блок p1 содержит тот самый изначальный делитер, который условно говоря сделает перед удалением указателя каст к классу наследника(delete static_cast<Derived*>(ptr)).

Именно поэтому и вызывается деструктор наследника.

Если мы попытаемся создать std::shared_ptr вот так:

std::shared_ptr<Base> shared(static_cast<Base*>(new Derived));


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

Ну и с уникальным указателем с одним шаблонным параметров такая штука тоже не сработает. Там делитер оптимизирован и выбирается по умолчанию std::default_delete для типа шаблонного параметра, он не хранится в объекте. Поэтому для такой строчки:

std::unique_ptr<Base> p1 = std::make_unique<Derived>();


для p1 не вызовется деструктор наследника, потому что делитер типа std::unique_ptr<Base> удаляет только указатели на базовый класс. Чтобы объект удалялся корректно, нужен виртуальный деструктор базового класса. Без него никак.

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

Stay amazed. Stay cool.

#cpp11 #cppcore
​​Шаблонный сеттер
#опытным

Увидел на ревью интересный кейс. Мы о нем уже говорили, что не сильно акцентировали внимание. Сегодня больше времени уделим одному интересному явлению.

Если у вас есть какой-то шаблонный класс, который хранит тип Т, и в этом классе есть сеттер на этот тип, то по давней привычке(еще с 98 стандарта) его можно написать вот так:

template <class T>
struct TemplateClass {
void SetValue(const T& value) {
value_ = value;
}
private:
T value_;
};


Привычка - дело хорошее и экономит ресурс мозга на выполнение действий. Не так много когнитивного внимания нужно тратить на деятельность.

Но иногда привычки ограничивают нас. Мы-то уже в modern C++ эре. И в данном случае как раз такой кейс.

Что будет, если мы захотим передать в этот метод временный объект? Например так:

struct ShowConstruct {
ShowConstruct() = default;
ShowConstruct(int value) : field{value} {
std::cout << "Param construct " << field << std::endl;}
ShowConstruct& operator=(const ShowConstruct& other) {
field = other.field;
std::cout << "Copy assign " << field << std::endl;
return *this;}
ShowConstruct& operator=(ShowConstruct&& other) {
field = other.field;
std::cout << "Move assign " << field << std::endl;
return *this;}
int field = 0;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});


На экран выведется:

Param construct 5
Copy assign 5


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

Потому что в сеттере value уже относится к категории lvalue. А при присваивании объекта от lvalue будет вызываться копирующий оператор присваивания.

А нам бы хотелось, чтобы вызывался перемещающий оператор. Как этого достичь?

Использовать универсальную ссылку. Скажет прошаренный читатель.

Для шаблонного кода мы можем пометить параметр метода двумя амперсандами и дальше внутри передавать его во все места через std::forward. Таким образом, если нам на вход пришел именованный объект, то std::forward скастует его к lvalue ссылке, а если временный, то к rvalue ссылке. И это поможет нам в нужных случая вызывать правильный оператор присваивания. И std::forward и universal reference доступны с 11-го стандарта вместе с введением мув-семантики.

template <class T>
struct TemplateClass {
void SetValue(T&& value) {
value_ = std::forward<T>(value);
}
private:
T value_;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});


Теперь мы получаем нужный вывод:

Param construct 5
Move assign 5


Однако этот прошаренный читатель оказался не таким уж и прошаренным! Такая штука не сработает для шаблонных параметров класса!

in class template argument deduction, template parameter 
of a class template is never a forwarding reference


Универсальная ссылка(она же forwarding reference) появляется только, когда тип параметра функции Т&& и Т - шаблонной параметр самой функции. В нашем случае нет никакого вывода - тип Т известен из класса. Поэтому и никакой универсальной ссылки не появляется.

Мы просто определили метод, который принимает rvalue ссылку. При попытке передать туда lvalue будет ошибка:

TemplateClass<ShowConstruct> obj;
ShowConstruct lvalue{7};
obj.SetValue(lvalue);

//ERROR: rvalue reference to type 'ShowConstruct'
// cannot bind to lvalue of type 'ShowConstruct'


Какой выход? Просто рядышком с сеттером для константной lvalue ссылки написать сеттер для rvalue ссылки.

template <class T>
struct TemplateClass {
void SetValue(const T& value) {
value_ = value;
}
void SetValue(T&& value) {
value_ = std::move(value);
}
private:
T value_;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});
ShowConstruct lvalue{7};
obj.SetValue(lvalue);


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

Stay universal. Stay cool.

#cpp11
​​Swap idiom. Pros and cons
#опытным

В этом посте поговорили про суть swap идиомы. Сегодня обсудим ее плюсы и минусы.

Плюсы вроде как обсуждали, но я финализирую, когда можно рассмотреть внедрение swap idiom:

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

Если вы хотите красивый, лаконичный и понятный код без повторений действий.

Вы не очень беспокоитесь о потенциальных потерях производительности.

Погнали по минусам:

❗️ Не всегда можно написать nothrowing swap. Для базовых типов и указателей - да. Но swap нетривиальных типов использует временный объект. При создании которого и может возникнуть исключение. Сейчас swap делается с помощью перемещающих операций, но например в С++03 std::string мог кинуть исключение в копирующем конструкторе. Да и сейчас поля класса могут быть немувабельными и бросающими при копировании. Это надо иметь ввиду.

❗️ Каждый раз при присваивании мы выполняем 2 операции: конструктор копирования + swap или конструктор перемещения + swap. "Потери производительности" надо конечно тестить и смотреть реальные результаты, но в голове все равно надо держать потенциальные просадки.

❗️ Самостоятельно писать деструктор для менеджинга ресурсов в 2к24 - такая себе практика в большинстве случаев. Давно есть std::unique_ptr<T[]>, указатели с кастомными делитерами и прочие вещи. Одно из ключевых преимуществ идиомы - сокращение и переиспользование кода. Так вот с отсутствием деструктора вам вообще может не понадобится кастомное присваивание и вы сможете объявить операции дефолтными, поэтому надобность в идиоме сама по себе отпадет.

❗️❗️ Часто пропускаемый огромный минус: технически у нас есть оператор перемещения, который может принимать rvalue ссылки. Однако мы явным образом не реальзовывали присваивание перемещением, поэтому по правилу 5, компилятор не будет его генерировать за нас и у класса просто будет отсутствовать оператор присваивания перемещением.

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

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

struct FirstField {
FirstField() = default;
FirstField(const FirstField& other) {
std::cout << "FirstField Copy ctor" << std::endl;
}
FirstField& operator=(FirstField other) {
std::cout << "FirstField assign" << std::endl;
return *this;
}
FirstField(FirstField&& other) {
std::cout << "FirstField Move ctor" << std::endl;
}
};

struct SecondField {
SecondField() = default;
SecondField(const SecondField& other) {
std::cout << "SecondField Copy ctor" << std::endl;
}
SecondField& operator=(const SecondField& other) {
std::cout << "SecondField Copy assign" << std::endl;
return *this;
}
SecondField(SecondField&& other) {
std::cout << "SecondField Move ctor" << std::endl;
}
SecondField& operator=(SecondField&& other) {
std::cout << "SecondField Copy assign" << std::endl;
return *this;
}
};

struct Wrapper {
FirstField ff;
SecondField sf;
};

Wrapper w;
w = std::move(Wrapper{});

// OUTPUT:
// FirstField Move ctor
// FirstField assign
// SecondField Copy assign


Выбор использовать или не исопльзовать - как всегда за вам. Тестируйте гипотезы и выбирайте из них лучшую.

Analyse your solutions. Stay cool.

#cppcore #cpp11
​​Вектор ссылок
#опытным

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

Дело в том, что вы не можете создать вектор ссылок. Не можете и все. Попробуйте написать что-то такое и запустить сборку:
std::vector<int&> vec;


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

В сущности это происходит по одной причине. Шаблонный тип vec не удовлетворяет требованиям к типам элементов вектора.

До C++11 и появления мув-семантики элементы вектора должны были удовлетворять требованиям CopyAssignable и CopyConstructible. То есть из этих объектов должны получаться валидные копии, притом что исходный объект оказывается нетронутым. Это условие, кстати, не выполняется для запрещенного в РФ иноагента std::auto_ptr. Так вот ссылочный тип - не CopyAssignable. При попытке присвоить ссылке что-то копирования не происходит, а происходит просто перенаправление ссылки на другой объект.

После С++11 требования немного смягчились и теперь единственный критерий, которому тип элементов вектора должен удовлетворять - Erasable. Но ссылки также не попадают под этот критерий(для них не определен деструктор). Поэтому сидим без вектора ссылок. Или нет?

Можно хакнуть этот ваш сиплюсплюс и создать вектор из std::reference_wrapper. Это такая тривиальная обертка над ссылками, чтобы ими можно было оперировать, как обычными объектами. В смысле наличия у них всех специальных методов классов.

Но будьте осторожны(!), потому что есть одна большая проблема со ссылками. Вот мы создали и заполнили контейнер ссылками на какие-то объекты. И потом вышли из скоупа, где были объявлены объекты, на которые ссылки указывают. Вектор есть, ссылки есть, а объектов нет. Это чистой воды undefined behavior. Ссылки будут указывать на уже удаленные объекты. Пример:

std::vector<std::reference_wrapper<int>> vec;
int * p = nullptr;
{
int i;
for (i = 0, p = &i; i < 5; i++) {
vec.emplace_back(i);
}
}

*p = 10;

for (int i = 0; i < 5; i++) {
std::cout << vec[i] << std::endl;
}


Вывод будет такой:
10
10
10
10
10


Подумайте пару секунд, почему так. Переменная i меняется и мы добавляем ссылки на эту переменную в вектор. По итогу все элементы вектора указывают на одну и ту же переменную. Поэтому и элементы все одинаковы.

Но раз ссылка - это обертка над указателем, то элементы вектора по факту хранят адрес того места, где была переменная i. Поэтому все изменения ячейки памяти этой переменной будут отражаться на ссылках, даже если переменная уже удалена. Вот мы и сделали грязь: сохранили адрес ячейки и изменили его после выхода из скоупа цикла и удаления переменной i. Так обычно и происходит на стеке: переменная кладется на стек, с ней работают, она удаляется при выходе из скоупа и потом другие объект занимают место удаленной переменной в памяти. Мы здесь сымитировали такой процесс.

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

Будьте аккуратны со ссылками. В этом случае проще использовать какой-нибудь умный указатель. Все будет чинно и цивильно. И никакого UB.

Be careful. Stay cool.

#cpp11 #cppcore #STL