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

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

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Не вызывайте пользовательский код по локом
#опытным

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

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

А если функция сама использует блокировку? Локать несколько мьютексов подряд - плохая затея. Это повышает вероятность возникновения дедлока и придется заботиться о его предотвращении. Что довольно сложно сделать с кодом, который вы неполностью контролируете.

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

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

std::shared_ptr<Value> IStorage::GetDataWithCache(const Key& key) {
std::unique_lock ul{mtx_};
if (auto it = cache.find(key); it != cache.end()) {
return it->second;
} else {
ul.unlock(); // HERE
std::shared_ptr<Value> result = GetData(key);
ul.lock();
cache.insert({key, result});
return result;
}
}

GetDataWithCache - это метод интерфейсного класса IStorage и мы сделали приватный виртуальный метод GetData. За то, что делает GetData мы не отвечаем и там потенциально может быть много реализаций. При получении данных из файла там может быть еще один лок. А общение с базой может занять довольно много времени. Поэтому мы просто отпускаем замок перед вызовом этого метода и не паримся о последствиях.

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

Кстати, в этом примере есть одна не совсем очевидная проблема(помимо более очевидных хехе). Она не относится непосредственно к теме поста. Можете попробовать в комментах ее найти и предложить решение.

#goodpractice
​​Наследование? От лямбды? Ч1
#опытным

Наследованием в языке С++ никого не удивить. Все постоянно его видят в коде и используют. Но что, если я вам скажу, что вы можете наследоваться от лямбды! Как? Давайте разбираться.

Как это вообще возможно?

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

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

Значит это вполне легальный кандидат на наследование!

Придется конечно немного поколдовать вокруг отсутствия имени, но для С++ это не проблема.

template<class Lambda>
struct DerivedFromLambda : public Lambda
{
DerivedFromLambda(Lambda lambda) : Lambda(std::move(lambda)) {}
using Lambda::operator();
};

int main(){
auto lambda = []{return 42;};
DerivedFromLambda child{lambda};
std::cout << child() << std::endl;
}

// OUTPUT:
// 42


Ничего особенного. Мы просто создали класс-обертку над каким-то функциональным объектом и используем его оператор(), как свой.

Дальше создаем лямбду и создаем объект обертки, просто передавая лямбду в конструктор. Мы специально не указываем явно шаблонный параметр DerivedFromLambda, потому что мы не знаем настоящего имени лямбды. Мы даем возможность компилятору самому вывести нужный шаблонный тип на основании инициализатора. Это возможно благодаря фиче С++17 Class Template Argument Deduction.

Но даже и на С++11-14 можно написать подобное. Ведь у нас есть оператор decltype, который возвращает в точности тип того выражения, которое мы в него передали. Тогда мы бы создавали объект так:

auto lambda = []{return 42;};
DerivedFromLambda<decltype(lambda)> child{lambda};


Зачем это нужно только? К этому мы будем потихоньку подбираться следующие пару постов.

Do surprising things. Stay cool.

#template #cppcore #cpp11 #cpp17
Наследование? От лямбды? Ч2
#опытным

Лямбды - это функциональные объекты по своей сути. Объекты классов с перегруженным оператором(). Зачем вообще от такой сущности наследоваться? Можно же просто сделать обычный класс с такими же перегруженным оператором и расширять его сколько влезет.

Ну как будто бы да. От одной лямбды наследоваться особо нет смысла. А что насчет множественного наследования?

Идейно - наш наследник будет наследовать публичный интерфейс всех своих родителей. То есть в наследнике будет много перегруженных операторов() для разных входных параметров. Вот это уже чуть удобнее. Мы можем находу создавать объект, который единообразно с помощью перегрузок будет обрабатывать какие-то вещи.

Покажу чуть подробнее:

template<class Lambda1, class Lambda2>
struct DerivedFromLambdas : public Lambda1, Lambda2
{
DerivedFromLambdas(Lambda1 lambda1, Lambda2 lambda2)
: Lambda1(std::move(lambda1))
, Lambda2{std::move(lambda2)} {}
using Lambda1::operator();
using Lambda2::operator();
};

int main(){
DerivedFromLambdas child{[](int i){return "takes int";}, [](double d){return "takes double";}};
std::cout << child(42) << std::endl;
std::cout << child(42.0) << std::endl;
return 0;
}
// OUTPUT:
// takes int
// takes double


Логика и механика те же, что и в прошлом посте. Только теперь мы налету конструируем объект, который умеет в 2 перегрузки функции. Если эти перегрузки действительно небольшие, то не особо понятно, зачем мне определять их как отдельные перегрузки отдельной функции. Это нужно будет их потом по коду искать. А тут все рядышком и смотреть приятно.

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

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

Have a sense. Stay cool.

#template #cppcore #cpp17
Смешиваем std::visit и std::apply
#опытным

Подумал об интересном сочетании функций std::visit и std::apply. В прошлом посте про паттерн overload мы в цикле проходились по вектору вариантов и к каждому элементу применяли std::visit. Но прикольно было бы просто взять и за раз ко всем элементам коллекции применить std::visit. Ну как за раз. Без явного цикла.

И такую штуку можно сделать для tuple-like объектов, среди которых std::pair, std::tuple и std::array. Функция std::applyможет распаковать нам элементы этих коллекций и вызвать для них функцию, которая принимает в качестве аргументов все эти элементы по отдельности. Это же то, что нам нужно!

Давайте попробуем на примере std::array запихать все его элементы в функтор и лаконично вызвать std::visit.

template<typename ... Lambdas>
struct Visitor : Lambdas...
{
Visitor(Lambdas... lambdas) : Lambdas(std::forward<Lambdas>(lambdas))... {}
using Lambdas::operator()...;
};

using var_t = std::variant<int, double, std::string>;

int main(){
std::array<var_t, 3> arr = {1.5, 42, "Hello"};
Visitor vis{[](int arg) { std::cout << arg << ' '; },
[](double arg) { std::cout << std::fixed << arg << ' '; },
[](const std::string& arg) { std::cout << std::quoted(arg) << ' '; } };

std::apply([&](auto&&... args){(std::visit(vis, std::forward<decltype(args)>(args)), ...);}, arr);
}


Начало в целом такое же, только теперь у нас std::array. Нам интересна последняя строчка.

В std::apply мы должны передать функтор, который может принимать любое количество параметров. Благо у нас есть вариадик лямбды, которые позволяют сделать именно это. Компилятор сам сгенерирует структуру, которая сможет принимать ровно столько аргументов, сколько элементов в массиве arr.

Дальше мы все эти аргументы распаковываем в серию вызовов std::visit так, чтобы каждый элемент массива передавался в отдельный std::visit. Естественно, все делаем по-красоте, с perfect forwarding и fold-expression на операторе запятая.

В общем, делаем себе укол пары кубиков метапрограммирования.

Выглядит клёво!

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

Look cool. Stay cool.

#template #cpp17
Сколько минимально нужно мьютексов, чтобы вызвать дедлок?
#опытным

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

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

А что если мы не будем ориентироваться на общую универсальную для всех языков теорию многопоточного программирования и сфокусируется чисто на С++ и средствах, которые он предоставляет?

Какой тогда будет ответ?

#quiz
Ответ
#опытным

Правильный ответ на квиз из предыдущего поста- 0. Вообще ни одного мьютекса не нужно.
Много можно вариантов придумать. И даже в комментах написали несколько рабочих способов.

Вообще говоря, в определении дедлока не звучит слово "мьютекс". Потоки должны ждать освобождения ресурсов. А этими ресурсами может быть что угодно.
Для организации дедлока достаточно просто, чтобы 2 потока запустились в попытке присоединить друг друга. Естественно, что они будут бесконечно ждать окончания работы своего визави.

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

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

std::thread * t_ptr = nullptr;

void func1() {
std::this_thread::sleep_for(std::chrono::seconds(1));
t_ptr->join();
std::cout << "Never reached this point1" << std::endl;
}

void func2(std::thread& t) {
t.join();
std::cout << "Never reached this point2" << std::endl;
}

int main() {
std::thread t1{func1};
t_ptr = new std::thread(func2, std::ref(t1));
while(true) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}


Все просто. Вводим глобальный указатель на поток. В функции первого потока мы даем время инициализировать указатель и присоединяем поток по указателю. А тем временем в main создаем динамический объект потока и записываем его по указателю t_ptr. Таким образом первый поток получает доступ ко второму. В функцию второго потока передаем объект первого потока по ссылке и присоединяем его. Обе функции после инструкции join выводят на консоль запись.

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

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

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

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

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

Stay surprised. Stay cool.

#concurrency #cppcore
​​Дедлокаем один поток
#опытным

Мы привыкли, что для дедлоков нужно несколько потоков. Не удивительно. Давайте прочитаем определение дедлока по Коффману. Там речь про процессы, но если поменять слово "процесс" на "поток" ничего не изменится. Ну и перевод будет вольный.

Дедлок - это ситуация в коде, когда одновременно выполняются все следующие условия:

А ну, мальчики, играем поочереди. Только один поток может получить доступ к ресурсу в один момент времени.

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

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

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

Судя по этому определению, минимальное количество потоков, чтобы накодить дедлок - 2.

Но это такая общая теория работы с многозадачностью в программах.

Определение оперирует общим термином ресурс. И не учитывает поведение конкретного ресурса и деталей его реализации. А они важны!

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

std::mutex mtx;
mtx.lock();
mtx.lock();


Стандарт говорит, что будет UB. То есть поведение программы неопределено, возможно она заставит Ким Чен Ира спеть гангам стайл.

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

1️⃣ Компилятор имплементировал умный мьютекс, который может задетектить double lock и, например, кинуть в этом случае исключение.

2️⃣ Мьютекс у нас обычный, подтуповатый и он делает ровно то, что ему говорят. А именно пытается залочить мьютекс. Конечно у него ничего не получится и он вечно будет ждать его освобождения. Результат такого сценария - дедлок одного потока одним мьютексом!

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

Be self-sufficient. Stay cool.

#concurrency #cppcore #compiler
​​Как вызвать методы класса напрямую через vtable?
#опытным

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

Полиморфизм - там штука, которая сделала ООП и ОО дизайн такими мощными и широкоиспользуемыми инструментами. За счет чего он реализован в С++?

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

struct Base {
virtual void func1() {
std::cout << i+1<< std::endl;
}
virtual void func2() {
std::cout << i+2 << std::endl;
}
};

struct Derived: Base {
void func1() override {
std::cout << i+3 << std::endl;
}
void func2() override {
std::cout << i+4 << std::endl;
}
};

int main() {
auto basePtr = std::unique_ptr<Base>(new Derived);
uint64_t ** pVtable = (uint64_t **)basePtr.get();
printf("Vtable: %p\n", pVtable);
printf("First Entry of VTable: %p\n", (void*)pVtable[0][0]);
printf("Second Entry of VTable: %p\n", (void*)pVtable[0][1]);

using VoidFunc = void (*) (void*);
VoidFunc firstMethod = (VoidFunc) pVtable[0][0];
firstMethod(basePtr.get());
VoidFunc secondMethod = (VoidFunc) pVtable[0][1];
secondMethod(basePtr.get());
}


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

Далее нужно найти указатель на vtable. Обычно он всегда лежит в первых 8 байтах объектов полиморфных классов. Так как у нас двухуровневая косвенность (указатель на объект, а в нем указатель на таблицу), мы объявляем двойной указатель на 8-мибайтный инт и он и будет нашим vtable_ptr. Почему uint64_t? Да на самом деле косвенность трехуровненая, потому что в ячейках vtable лежат указатели на методы, а указатель легко представить в виде 8-байтного числа.

Самое сложное позади, мы нашли все, что нужно. Осталось только кастануть инты, лежащие в ячейках vtable к указателям на функцию правильного типа. И вуаля. Готово. Возможный вывод:

Vtable: 0x5a7841237eb0
First Entry of VTable: 0x5a781fd28ae2
Second Entry of VTable: 0x5a781fd28b22
3
4


Где это знание нужно?

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

Знание внутреннего устройства инструмента позволить вам понимать его преимущества и недостатки. Возможно в проектах с высокими требованиями к производительности(слабое железо как в embedded или супер-ультра-гипер low latency как в hft), вам предстоит выбирать между динамическим полиморфизмом и статическим полиморфизмом aka шаблонами. Чтобы сделать выбор качественно и аргументировать его, нужно знание базы.

А как говорится, пока сам не потрогаешь ручками - не поймешь.

Только не нужно так писать в продовом коде! А то будет, как на картинке внизу.

Stay hardwared. Stay cool.
Все грани new
#опытным

Не каждый знает, что в плюсах new - это как медаль, только лучше. У медали 2 стороны, а у new целых 3!

Сейчас со всем разберемся.

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

Выглядит оно так:

 // creates dynamic object of type int with value equal to 42
int* p_triv = new int(42);
// created an array of 42 dynamic objects of type int with values equal to zero
int* p_triv_arr = new int[42];

struct String {std::string str};

// creates dynamic object of custom type String using aggregate initialization
String* p_obj = new String{"qwerty"};
// created an array of 5 dynamic objects of custom type String with default initialization
String* p_obj = new String[5];


Эта штука делает 2 дела одновременно. А что, если мне не нужно выполнять сразу 2 этапа? Что, если мне нужна только аллокация памяти?

За это отвечает operator new. Это оператор делает примерно то же самое, что и malloc. То есть выделяет кусок памяти заданного размера. Выражение new именно этот оператор и вызывает, когда ему нужно выделить память. Но его можно вызвать и как обычную функцию, а также перегружать для конкретного класса:

// class-specific allocation functions
struct X
{
static void* operator new(std::size_t count)
{
std::cout << "custom new for size " << count << '\n';
// explicit call to operator new
return ::operator new(count);
}
 
static void* operator new[](std::size_t count)
{
std::cout << "custom new[] for size " << count << '\n';
return ::operator new;
}
};
 
int main()
{
X* p1 = new X;
delete p1;
X* p2 = new X[10];
delete[] p2;
}


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

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

alignas(T) unsigned char buf[sizeof(T)];
// after new in parentheses we specified location of future object
T* tptr = new(buf) T;
// You must manually call the object's destructor
tptr->~T();


В этом случае кстати вызывается специальная перегрузка operator new:

void* operator new  (std::size_t count, void* ptr);


Однако она не делает ничего полезного и просто возвращает второй аргумент наружу.

Вот так по разному в С++ можно использовать new. Главное - не запутаться!

Have a lot of sides. Stay cool.

#cppcore #memory
Безопасный для исключений new
#опытным

Большинство приложений не могут физически жить без исключений. Даже если вы проектируете все свои классы так, чтобы они не возбуждали исключений, то от одного вида exception'ов вы вряд ли уйдете. Дело в том, что оператор new может бросить std::bad_alloc - исключение, которое говорит о том, что система не может выделить нам столько ресурсов, сколько было запрошено.

Однако мы можем заставить new быть небросающим! Надо лишь в скобках передать ему политику std::nothow. Синтаксис очень похож на placement new. Это в принципе он и есть, просто у new есть перегрузка, которая принимает политику вместо указателя.

MyClass * p1 = new MyClass; // привычное использование
MyClass * p2 = new (std::nothrow) MyClass; // небросающая версия


В первом случае при недостатке памяти выброситься исключение. А во втором случае - вернется нулевой указатель. Прям как в std::malloc.

Так что, если хотите избавиться от исключений - вот вам еще один инструмент.

#cppcore #memory