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

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

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Как узнать, что constexpr функция вычисляется в compile time

C constexpr функциями есть один прикол - они могут вычисляться и в рантайме, и компайлтайме. Но вот какой момент: мы же не зря пометили их constexpr. Нам важно, чтобы в тот момент, когда мы хотели разгрузить рантайм, он действительно разгружался. Не всегда просто бывает сходу понять, что все аргументы функции тоже являются вычислимыми во время компиляции. А это, собственно, одно из основных условий того, что и сама функция вычислится в это время.

Так вот интересно: а можно ли как-то убедиться в том, что выражение вычислено в compile-time?

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

⚡️ Присвоить значение выражения constexpr переменной. Они могут инициализироваться только значениями, доступными на этапе компиляции, поэтому вам сразу же компилятор выдаст ошибку "constexpr variable must be initialized by a constant expression", если вы попытаетесь передать в функцию не constexpr значение.

constexpr int JustRandomUselessFunction(int num) {
return num + 1;
}

int main() {
int usual_runtime_var = 0;
constexpr int error = JustRandomUselessFunction(usual_runtime_var);
//👆🏿Error: constexpr variable must be initialized by a constant expression
constexpr int constexpr_var = 5;
constexpr int ok = JustRandomUselessFunction(constexpr_var);
//👆🏿 OK since constexpr_var is constexpr
}


Правда, здесь есть ограничение, что void выражения таким макаром не проверить.

⚡️ Поместить вызов функции в static_assert. Эта штука может проверять только вычислимые на этапе компиляции условия, поэтому компилятор опять же вам подскажет, облажались вы или нет.

constexpr int JustRandomUselessFunction(int num) {
return num + 1;
}

int main() {
int usual_runtime_var = 0;
constexpr int constexpr_var = 5;
static_assert(JustRandomUselessFunction(usual_runtime_var));
//👆🏿 Error: static assertion expression is not an integral constant expression
static_assert(JustRandomUselessFunction(constexpr_var)); // OK
}



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

⚡️ Использовать фичу С++20 - std::is_constant_evaluated(). Если коротко, то она позволяет внутри функции определить в каком контексте она вычисляется: constant evaluation context или runtime context. Здесь есть и практическая польза: в зависимости от контекста мы можем использовать constexpr-френдли операции(их набор довольно сильно ограничен и придется попотеть, чтобы что-то сложное реализовать) или обычные. Но для наших целей мы можем вот как использовать: мы можем заветвиться по контексту и вернуть из функции какое-то уникальное значение, которое соответствует только compile-time ветке. И уже по итоговому результату понять, когда произошли вычисления. А дальше уже набрасывать реальный код в ветки. Например:

constexpr int CalculatePeaceNumber(double base)
{
if (std::is_constant_evaluated())
{
return 666; // That's how we can be sure about compile time evaluation
}
else
{
// some peaceful code
return 0; // true balance and peace
}
}

constexpr double pi = 3.14;

int main() {
const int result = CalculatePeaceNumber(pi);
std::cout << result << " " << CalculatePeaceNumber(pi) << std::endl;
}

//Output
666 0


Обратите внимание, что здесь используется обычный if, потому что в if constexpr is_constant_evaluated будет всегда возвращать true(в нем условие всегда в compile-time поверяется).

Наверняка, есть еще способы. Если знаете, напишите их в комментарии)

Check context of your life. Stay cool.

#cpp20 #cpp11 #compiler
Встраивание шаблонов

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

Вот здесь мы поговорили о том, что методы класса - это по факту те же самые обычные функции, только для них первым параметром передается this. И если подумать 1.34 секунды, то можно понять, что взаимодействие класса с внешним миром происходит только за счет методов. А поля класса - это просто кусок памяти, из которого в разных ситуациях компилятор может достать ту или иную информацию. Получается, что низкоуровневый "код класса" - это набор низкоуровневого кода его методов(то есть обычных функций) и не более.

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

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

Обычные функции тоже могут встраиваться.

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

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

Получается, что если мы в какой-то единице трансляции указываем явное объявление инстанциации с помощью extern template, и рассчитываем на неявную инстанциацию в другой единице трансляции, то мы спокойно можем нарваться на undefined reference.

Происходит это примерно так:

// ship.hpp
#pragma once

template<typename T>
struct Ship
{
int i = 0;
void TurnShip(T command);
};

template <class T>
void Ship<T>::TurnShip(T command) {i++;}

// ship.cpp

#include "ship.hpp"
#include <string>
#include <iostream>

void foo() {
Ship<std::string> ship{};
ship.TurnShip(std::string{"Turn upside down"});
std::cout << ship.i << std::endl;
}

// main.cpp

#include "ship.hpp"
#include <string>

extern template class Ship<std::string>;

int main() {
Ship<std::string> ship;
ship.TurnShip(std::string{"Turn upside down"});
}


Знакомый пример, только пара модификаций. В хэдэре только объявление и определение шаблона. В ship.cpp пытаемся неявно инстанцировать строковую специализацию. Чтобы компилятор полностью не убирал код внутри foo за ненадобностью(тогда и ничего инстанцировать не нужно будет), сделаем так, чтобы она влияла на внешний мир. Добавим в шаблон поле, в методе его будем инкрементировать, и в foo выведем поле после модификации. В мэйне будем полагаться на инстанциацию в другой единице трансляции за счет extern template.

Вот если это попытаться скомпилировать(с оптимизациями) и собрать, то на линковке произойдет undefined reference. Компилятор увидел, что метод TurnShip слишком простой и его спокойно можно встроить и не генерировать для него определение. Что и происходит. А линкер в свою очередь из-за этого и не смог найти определение метода.

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

Так что помните простое правило: на любое явное объявление инстанциации обязательно нужно предоставить явное определение инстанциации(1 на всю программу на каждое конкретное определение).

Rely on explicitly stated things. Stay cool.

#cppcore #template #compiler
Не всегда инстанциация шаблона нужна для работы программы

Возьмем пример из прошлого поста, объединим в хэдэре объявление шаблона с его определением и выкинем ship.cpp. И попробуем скомпилировать только main.cpp.

// ship.hpp
#pragma once

template<typename T>
struct Ship
{
int i = 0;
void TurnShip(T command) {i++;}
};

// main.cpp

#include "ship.hpp"
#include <string>
#include <iostream>

extern template class Ship<std::string>;

int main() {
Ship<std::string> ship;
ship.TurnShip(std::string{"Turn upside down"});
std::cout << ship.i << std::endl;
}


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

Дело в том, что любой метод, определенный внутри описания класса, неявно помечается inline. А на инлайн сущности не работает эффект подавления неявной специализации. Стандарт вот что говорит об этом:

Except for inline functions and class template specializations, 
explicit instantiation declarations have the effect of suppressing
the implicit instantiation of the entity to which they refer.


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

Естественно, на это полагаться нельзя, потому что не любой метод может быть встроен, а значит компилятору придется проводить неявную инстанциацию. А мы как раз и добивались, чтобы этого не было. И правило "на любое явное объявление инстанциации обязательно нужно предоставить явное определение инстанциации" по-прежнему работает.

Просто интересно было показать, как такое небольшое изменение может развернуть ситуацию на 180. И кстати, если все-таки держать отдельно описание класса и его определение, но пометить метод inline, то будет тот же эффект, который я описал выше.

Pay attention to small details. Stay cool.

#template #compiler #cppcore
Преимущества использования extern template

C++ известен долгой компиляцией программ. И одной из причин такого поведения является наличие шаблонов в языке. За счет того, что стандарт допускает больше одной конкретной инстанциации шаблона в программе, люди сильно расслабляются. Мало кто знает, как правильно организовывать и использовать шаблонный код. В большинстве случаев он просто находится в хэдэре и подключается во всевозможные места. Соответсвенно, в каждой TU, куда подключается хэдэр, будет своя копия инстанциации. Но это же не просто сама копия, на компиляцию всех копий тратится время.

А если используются какие-нибудь кодогенераторы, типа grpc-шного или soap-ного, то там реально может быть очень много единиц трансляции. И в каждой будет своя копия какого-нибудь вектора или опшинала.

И хотя для STL-ных сущностей extern template ничем не поможет(об этом в другом посте), для самописных шаблонов, расположенных в хэдэрах - подойдет. Адекватную организацию кода при использовании extern template, мы осветили тут https://t.me/grokaemcpp/226.

Но какие конкретно преимущества дает такой способ организации шаблонного кода?

1) Уменьшение размеров объектных файлов скомпилированных единиц трансляции. При сборке больших проектов у вас скорее всего сгенерируются сотни, если не тысячи объектных файлов или либок, которые за счет загромождения инстанциациями могут весить десятки и сотни мегабайт. Не каждый себе может позволить на машинке такой билд, который помимо полезных исполняемых файлов будет содержать огромное количество объектников с дублирующимся миллион раз кодом. extern template запрещает неявную инстанциацию шаблонов, а значит среди всех объектников будет только один, который и будет содержать нужную инстанциацию.

2) Уменьшение времени компиляции. Компилятору запретили генерировать код, а значит много лишних действий убирается и сокращается время компиляции.

3) Сокращение времени линковки. Вот это не прям очевидный пункт. Чем объемнее объектные файлы - тем больше линкеру работы. А учитывая, что для дедуплицирования инстанциаций нужно найти эти одинаковые дубли, сопоставить их, выбрать один и убрать все остальные, то задача уже не кажется такой простой.

4) Любой код, который подключит ваш заголовочник, сможет инстанцировать или найти любой explicit template instantiation, который захочет. Через extern template мы запретим компилятору самостоятельно генерировать эти инстанциации. Но если для какого-то шаблонного параметра не будет прописан extern template, то компилятор сможет сам неявно конкретизировать шаблон с этим параметром. Поэтому в этом плане, это очень гибкий инструмент.

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

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

Find benefits of using different things in various situations. Stay cool.

#cppcore #template #compiler
Другой способ организации шаблонного кода

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

Не зря люди придумали разделение кода на объявление сущности в хэдэре и ее реализацию в цппшнике. Внешний интерфейс класса/сигнатура функции меняются не так часто. А вот изменение деталей реализации при разработке - дело само собой разумеющееся и это происходит на каждой итерации билда при отладке кода.

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

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

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

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

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

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

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

И не нужно нигде писать никакой extern! Компилятор из объявления сам ничего не может инстанцировать, поэтому главная задача extern template решается автоматически.

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

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

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

Choose the proper tool. Stay cool.

#template #compiler #cppcore
extern template с шаблонами STL

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

Оговорюсь, что буду говорить за реализацию стандартной библиотеки от gcc.

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

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

А в недавнем посте "Не всегда инстанциация шаблона нужна для работы программы" мы поговорили о том, что методы, определенные внутри описания класса, неявно помечаются inline. А для inline функций запрет на неявное инстанцирование не работает!

Поэтому для шаблонов STL(под этим акронимом я понимаю все шаблонные классы стандартной библиотеки) вы просто не сможете получить никакого профита от extern template. Стандарт это явно запрещает.

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

Use your tools in proper places. Stay cool.

#cpcore #template #compiler
Правильно смешиваем static с inline

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

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

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

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

//header.hpp
struct StrangeSounds {
static constexpr int gaga() {
return krya;
}
static const int krya = 3;
};

//first.cpp
#include "header.hpp"
void boo() {
StrangeSounds::gaga();
}

//second.cpp
#include "header.hpp"
void kak_delaut_gucy() {
StrangeSounds::gaga();
}


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

Статические локальные переменные не имеют никакой линковки(к ним нельзя получить доступ вне функции), поэтому не совсем понятно, корректно ли такая конструкция себя будет вести в инлайн функциях. И оказывается корректно(из cppreference):

Function-local static objects in all definitions 
of the same inline function (which may be
implicitly inline) all refer to the same object
defined in one translation unit, as long as the
function has external linkage.


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

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

//header.hpp

inline int gaga() {
static int krya = 3;
return krya++;
}
//first.cpp
#include "header.hpp"
int boo() {
return gaga();
}

//second.cpp
#include "header.hpp"
int kak_delaut_gucy() {
return gaga();
}


В общем, смешивать inline и static - можно, но очень осторожно. Не противоречьте стандарту и никакое UB не овладеет вашим кодом.

Mix things properly. Stay cool.

#cpp17 #cppcore #compiler
Память наследованных классов

А вы когда-нибудь задумывались, как бы выглядел рентген снимок матрёшки? Скорее всего, что нет, но, если в двух словах, это именно то, о чем сегодня мы будем говорить. Мне бы хотелось рассмотреть наследование классов в C++, с точки зрения представления данных в памяти.

Как мы уже знаем, при создании объекта какого-либо класса всегда выделяется память. Размер, преимущественно, зависит от количества полей и их типов, выравнивания, а так же наследованных классов. Наглядно продемонстрировать структуру памяти объектов нам поможет следующий набор флагов компиляции для Clang:
-Xclang -fdump-record-layouts

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

Начнем с тривиальных примеров наследования, чтобы вам потом мысленно было легче декомпозировать более сложные. Рассмотрим живой пример 1 дампа памяти для класса B:
struct A    {..};
struct B: A {..};

Layout of B:
0: [ Memory of struct A ] <- A*, B*
8: [ Memory of struct B ]


Структура B включает в себя родительский класс A, память родителя предшествует дочернему классу. Указатель на объект класса B смотрит на начало всей области памяти и совпадает с приведенным указателем на родительский класс:
// Выполняется
assert(address_of_B == address_of_A);

Компилятор знает «из чего состоит» дочерний класс B. Следовательно, ему известно смещение от начала выделенной области памяти до полей родительского класса A, а далее и B. Это достаточно удобное представление.

Можно сказать, что в иерархии классов с единственным родителем образуется «матрёшка», где каждый класс включает в себя предшествующий. Вот живой пример 2. Однако эта матрёшка на самом деле немного сложнее устроена, чем мы привыкли думать. Она должна уметь описывать логику для множественного наследования, когда родителей может быть больше одного!

Рассмотрим живой пример 3 для множественного наследования:
struct P1            {..};
struct P2 {..};
struct Child: P2, P1 {..};

Layout of Child:
0: [ Memory of struct P2 ] <- P2*, Child*
8: [ Memory of struct P1 ] <- P1*
16: [ Memory of struct Child ]

Порядок следования областей памяти зависит от порядка наследования классов: P2, P1. Картина всё еще кажется нам похожей, только вот нюанс заключается в следующем:
// Выполняется
assert(address_of_Child != address_of_P1);
assert(address_of_Child == address_of_P2);

При вычислении адреса класса P1 мы получаем другое значение указателя. При работе с классом P1, абстрагировано от Child, компилятор не знает о каких-либо смещениях, известных для Child. Следовательно, чтобы сохранить корректность дальнейшей работы, необходимо вернуть указатель, смещенный до начала сырой памяти P1.

Вышеописанный пример можно усложнить - пусть P1 и P2 станут наследниками класса Base. Теперь мы получим ромбовидное наследование в живом примере 4:
struct Base          {..};
struct P1 : Base {..};
struct P2 : Base {..};
struct Child: P2, P1 {..};

Layout of Child:
0: [ Memory of struct P2::Base ] <- P2*, P2::Base*, Child*
8: [ Memory of struct P2 ]
16: [ Memory of struct P1::Base ] <- P1*, P1::Base*
24: [ Memory of struct P1 ]
32: [ Memory of struct Child ]

В первую очередь хочется отметить, что классы P1 и P2 имеют индивидуальные области памяти для своих родителей Base. Примерные дети! Просто так эти области памяти не могут быть объединены, т.к. в общем случае класс P1 никак не зависит от класса P2. Следовательно, ему нужен свой собственный независимый кусочек памяти для Base, куда можно писать и читать всё что угодно без оглядки на P2 и наоборот.

В данном примере мы увидели, что родительские классы P1 и P2 имеют независимые области памяти для своих родителей Base. В некоторых случаях таким образом удобно именно раздельно представлять данные в памяти, но порой этот родительский класс должен быть один и использован совместно несколькими наследниками с помощью виртуального наследования. Разберем эту тему в следующем посте!

#cppcore #compiler
Память виртуально наследованных классов

Как было сказано в предыдущей статье, «просто так эти области памяти не могут быть объединены...». Возможность совместного использования памяти для общего родительского класса есть!

С помощью ключевого слова virtual, объявляется виртуально наследованный класс:
struct Base                 {..};
struct P1 : virtual Base {..};
struct P2 : virtual Base {..};
struct Child : P2, P1 {..};

Таким образом мы сообщаем компилятору, что память родительского класса Base будет использоваться совместно классами P2 и P1, которые виртуально наследуются от него.

Стандарт языка не регламентирует реализацию виртуального наследования и виртуальных методов. Большинство компиляторов придерживаются спецификации Itanium C++ ABI (в частности, GCC и LLVM Clang). Однако, различия всё равно могут быть. Нам важно получить именно понимание, какие могут быть нюансы и как они могут быть решены.

Давайте сразу посмотрим, как это будет представлено в памяти. Рассмотрим живой пример 5:
struct Base                {..};
struct P1 : virtual Base {..};
struct P2 : virtual Base {..};
struct Child: P2, P1 {..};

Layout of Child:
0: [ Memory of struct P2 ] <- P2*, Child*
16: [ Memory of struct P1 ] <- P1*
32: [ Memory of struct Child ]
40: [ Memory of struct Base ] <- Child::Base*, P2::Base*, P1::Base*

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

Мы тут же сталкиваемся с одной интересной проблемой. В рамках класса Child мы можем вычислить смещение до полей класса Base. Но сам по себе класс Child может быть далеко не единственным классом, который наследует P1 или P2. Я хочу сказать, что мы не можем просто "запомнить" это смещение и использовать его для других классов. Нам так же непонятно, как вычислить это смещение, если мы работаем абстрагировано с классом P1 или P2. Вдруг это самостоятельный объект, а может быть это родительский класс Child или Child2? Более того, сам Child может быть унаследован другими классами, что добавит новые поля и изменит итоговое смещение. Вообще говоря, эта информация может даже меняться во время выполнения программы. Вот тут и начинается веселье!

Как может решаться эта проблема? В большинстве случаев, во все классы, которые используют виртуальное наследование, неявно добавляется виртуальный указатель на виртуальную таблицу смещений. Она генерируется компилятором и хранится в read-only памяти приложения. Размер класса, естественным образом, увеличивается сразу же на размер указателя для выбранной архитектуры:
struct Base                {..};
struct P1 : virtual Base {..};

Layout of P1:
0 | (P1 vtable pointer) // + 8 байт
8 | uint64_t data_of_P1
16 | struct Base (virtual base)
16 | uint64_t data_of_Base

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

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

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

#cppcore #compiler
Девиртуализация доступа к полям виртуально наследованных классов

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

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

То, что мы хотим сделать, называется девиртуализацией. Мы хотим выполнить оптимизацию, которая позволит получить прямой доступ к полям класса, минуя таблицу виртуальных смещений. Мы действительно можем это сделать — достаточно лишь понять суть проблемы: компилятору неизвестно, будет ли текущий класс наследован кем-то другим из других единиц компиляции. Новые наследники добавят какое-то количество байт под свои поля и смещение изменится (но в текущей единице компиляции нам это будет неизвестно). Получается, нам достаточно явно ограничить возможность наследования от конкретного класса!

Тут следует сделать оговорку, что стандарт C++ никак не регламентирует реализацию оптимизаций. Следовательно, это необходимо дополнительно проверять для вашего компилятора. И теперь вы будете знать, что именно! 😉 Мы проверяли на компиляторах gcc, llvm clang, icc/icx под x86-64.

Как вы уже догадались, запретить наследование можно с помощью идентификатора со специальным значением final:
struct Child final : P2, P1 {};

Этого достаточно, чтобы гарантировать отсутствие наследников класса Child в других единицах трансляции. Следовательно, при работе с данным наследником смещение может быть вычислено на этапе компиляции и использовано без обращения к виртуальной таблице:
auto *pointer = new Child();

// Direct cast Child* -> Base*
auto *base = static_cast<Base*>(pointer);

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

Я видоизменил пример из предыдущей статьи и получил вот такого монстра бенчмарка. Тут появился шаблонный класс inheritance_cast, который в зависимости от булевой константы вызывает либо одну, либо другую реализацию для приведения типа (мотивация выше). Это нужно исключительно для моего бенчмарка. Писать такие вспомогательные классы вам нет никакого смысла, ведь вы должны знать, с каким классом вы работаете.

Новые результаты демонстрируют, что теперь скорость доступа стала сопоставимой с невиртуально наследованным классом. Следовательно, можно сделать вывод, что девиртуализация доступа позволяет сократить лишние действия!

Нам так же следует поговорить о девиртуализации, когда разберем работу динамического полиморфизма. Всем удачи!

#cppcore #compiler
Цикл жизни non-local static storage duration переменных

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

Здесь важна оговорка, что объекты со static storage duration не обязаны быть помечены ключевым словом static! Этот термин употребляется для описания объектов, лишь время жизни которых является статическим. То есть более менее всех глобальных объектов. Все они существуют от момента создания до момента завершения программы. Поэтому просто написав:

int var = 1;



считайте, что вы объявили переменную со static storage duration.

Для краткости, вместо "объект со static storage duration" буду писать"глобальный объект".

Так вот. Для таких объектов существует строгий порядок инициализации, который состоит из определенных шагов и подшагов.

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

👉🏿 Первым, если возможно, идет константная инициализация. Проводится, когда инициализатор - константное выражение.

👉🏿 Во всех остальных случаях проводится Zero-инициализация.

2️⃣ Динамическая инициализация. Только после того, как проведена статическая инициализация, вступает в игру динамическая. Которая и является причиной static initialization order fiasco. Потому что дает очень мало гарантий по поводу порядка инициализации, одна из которых описана тут. Но в общем и целом, порядок инициализации глобальных объектов в разных юнитах трансляции не определен.
Обычно она происходит в рантайме, но если компилятор может, то он производит ее в compile-time при наличии определенных условий.

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

Если чуть подробнее и конкретнее про завершение, то при выходе из функции main происходят все стандартные процессы разрушения локальных переменных, но еще и вызов std::exit с возращаемым из мэйна значением в качестве аргумента. И вот std::exit одним из своих шагов триггерит вызов деструкторов глобальных объектов.

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

Это суперобобщенно, поэтому дальше будем раскрывать все эти стадии.

Cегодня без картинки, но вместо этого можете посмотреть короткий веселый видосик про цикл жизни программиста

Define cycle of your life. Stay cool.

#cppcore #compiler
Please open Telegram to view this post
VIEW IN TELEGRAM
Мотивация оптимизации пересекающихся областей памяти

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

Давайте «поиграем в компилятор» и попробуем понять логику оптимизации этой функции:
auto set_default(int &ival, float &dval)
{
ival = 0;
dval = 2.0;

return std::pair(ival, dval);
}

Итак, можем ли мы оптимизировать данный код?

С одной стороны, мы можем предположить, что раз мы задали ival и dval конкретные константы и больше никаких операций с этими ссылками не делали, то мы можем заранее вычислить объект std::pair(ival, dval) вот так:
auto set_default(int &ival, float &dval)
{
ival = 0;
dval = 2.0;

// Вычислим в compile time
constexpr auto result = std::pair(0, 2.0);

return result;
}

Давайте попробует измерить с помощью игрушечного бенчмарка, будет ли это как-то влиять? Опустим момент, почему я его запустил с опцией -O0, но мы видим прирост на ~30%. Получается, что наша ручная оптимизация имеет значение. И это лишь одна оптимизация, которая может комбинироваться с другими.

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

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

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

Для вашего компилятора это одновременно и возможность офигенно ускорить исполнение программ, и головная боль: как понять, происходит наложение памяти (aliasing) и оптимизироваться нельзя?

Попробуем рассуждать дальше. Условно, этапе компиляции можно попробовать отследить какие адреса должны быть в указателях. Но вопрос особенно остро встаёт, когда мы компилируем какую-то функцию для динамической библиотеки. По сути, перед нами только сама функция и всё! Контекста вызова этой функции нет. У компилятора не так много информации, которую он может использовать, поэтому в качестве критерия выступает тип ссылки/указателя. Вероятно, что представление пересекающейся памяти совершенно разными типами - это все таки очень редкий случай в рамках одного приложения.

Комитет стандартизации C/С++ предпочел регламентировать правила, по которым можно не ограничивать программистов и предоставить лучшую производительность. Компромиссный вариант.

Компилятор не может применить оптимизацию, если соблюдается правило strict aliasing. Во всех остальных случаях компилятор по умолчанию считает любые указатели/ссылки непересекающимися областями памяти и будет их оптимизировать, если не удается явно детектировать его нарушение... Это можно сделать, преимущественно в рамках тела одной функции, когда есть полный контекст взаимодействия с указателем. В этом случае будут сгенерированы инструкции, аналогичные корректному коду: живой пример. Вероятно, именно поэтому с такой проблемой разработчики сталкиваются реже, чем могли бы.

Но вот можно ли рассчитывать на то, что в другой версии компилятора это будет работать точно так же? Раз не стандартизировано и это UB, значит нет. В общем случае, такой код становится непереносимым.

#compiler
Константная инициализация. Ч1

Это первый шаг, который пытается выполнить компилятор, когда пробует инициализировать переменную. Для него требуется, чтобы инициализатор был константным выражением. То есть его можно было бы вычислить во время компиляции. И не путать с обычным const! Позже покажу почему.

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

constexpr double constexpr_var{1.0};
double const_intialized_var1{constexpr_var};
const double const_var{const_intialized_var1};
double const_intialized_var2{3.0};

С переменной constexpr_var все хорошо, константа присваивается константному выражению и инициализируется эта переменная первой. Далее устанавливается значение для const_intialized_var1. Несмотря на то, что эта переменная не константа, ее инициализатор - константное выражение, а этого достаточно для выполнения константной инициализации. Интересно, что дальше устанавливается значение переменной const_intialized_var2, а не const_var. Хоть const_var и константа, ее инициализатор не является константным выражением! Все потому, что у переменной const_intialized_var1 нет пометки const(constexpr), а значит, хоть она и проинициализирована константой, сама таковой не является. И const_var будет инициализироваться последней уже в рантайме.

Точнее немного не так. Она будет проиниализирована последней, но аж 2 раза! Первый раз - zero-инициализацией на этапе компиляции, второй раз - динамической в рантайме.

Чтобы не пустословить по чем зря, покажу вырезки из ассембера, которые подкрепляют мои слова. Вот чего нашел:
  .section  __DATA,__data
.globl _const_intialized_var1 ## @const_intialized_var1
.p2align 3, 0x0
_const_intialized_var1:
.quad 0x3ff0000000000000 ## double 1

.globl _const_intialized_var2 ## @const_intialized_var2
.p2align 3, 0x0
_const_intialized_var2:
.quad 0x4008000000000000 ## double 3

.section __TEXT,__const
.p2align 3, 0x0 ## @_ZL13constexpr_var
__ZL13constexpr_var:
.quad 0x3ff0000000000000 ## double 1

.zerofill __DATA,__bss,__ZL9const_var,8,3 ## @_ZL9const_var
.section __DATA,__mod_init_func,mod_init_funcs
.p2align 3, 0x0
.quad __GLOBAL__sub_I_main.cpp


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

Дальше мы переходим к data секции, в которой подряд инициализируются const_intialized_var1 и _const_intialized_var2. И после всего этого в секции .zerofill у нас заполняется нулями const_var.

И в последнюю очередь, уже в рантайме, динамически ини
циализируется const_var.


.section __TEXT,__StaticInit,regular,pure_instructions
.p2align 4, 0x90 ## -- Begin function __cxx_global_var_init
___cxx_global_var_init: ## @__cxx_global_var_init
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movsd _const_intialized_var1(%rip), %xmm0 ## xmm0 = mem[0],zero
movsd %xmm0, __ZL9const_var(%rip)
popq %rbp
retq
.cfi_endproc


Это рантаймовая рутина, которая запускается перед вызовом main() и инициализирует const_var.

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

Define order of your life. Stay cool.

#cppcore #compiler
Константная инициализация. Ч2

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

Вот такой пример они дают:

struct S
{
static const int static_class_var;
};

static const int const_var = 10 * S::static_class_var;
const int S::static_class_var = 5;

int main()
{
std::cout << &const_var << std::endl; // ODR-use for explicit generation of symbol
std::array<int, S::static_class_var> a1; // OK
// std::array<int, const_var> a2; // ERROR
}


И говорится, что static_class_var на момент создания const_var не имеет инициализатора. А так как компилятор в первую очередь выполняет константную инициализацию, поэтому он не может сейчас установить значение для const_var. Поэтому в начале инициализируется static_class_var. И вот уже дальше - const_var.

Но видимо при проведении константной инициализации компилятор только один раз проходит сверху вниз программы. По факту, static_class_var - константа, инициализированная константным выражением. И по всем канонам должна сама стать константным выражением. Так и получается, ведь мы можем создать std::array из нее. Но вот из const_var - не можем. Хотя эта переменная тоже проинициализирована константным выражением. Но так, как ее инициализация происходит после константной инициализации, то этот факт не дает ей шанса стать нормальным constant expression.

Еще более интересные вещи происходят в ассемблер
е.

.section __TEXT,__const
.globl __ZN1S16static_class_varE ## @_ZN1S16static_class_varE
.p2align 2, 0x0
__ZN1S16static_class_varE:
.long 5 ## 0x5

.p2align 2, 0x0 ## @_ZL9const_var
__ZL9const_var:
.long 50 ## 0x32


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

Проблема в том, что const_var выглядит такой же compile-time константой, как и static_class_var. Хотя по идее тут должна быть какая-нибудь zero-инициализация + динамическая в рантайме. Но array мы не можем создать с const_var🗿.

Дело в том, что здесь замешано одно интересное право компилятора. Ему в определенных случаях разрешено устанавливать начальные значения переменным в compile-time, если он уверен, что их значения не изменится на момент начала старта программы.

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

Accept side affects. Stay cool.

#cpcore #compiler
Вот когда точно статики инициализируются после main

Все-таки есть стопроцентный способ создать условия, чтобы этот эффект проявился.

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

Так вот есть способы в любой момент исполнения программы руками подгрузить shared library и использовать ее символы, даже ничего не зная о ней на этапе линковки объектников!

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

Естественно, что раз бинарник ничего не знал о сущностях библиотеки до ее explicit подгрузки, а библиотека просто лежала камнем в файловой системе, то буквально никакой код библиотеки не может быть выполнен до ее подгрузки. А значит, если мы открываем либу в main(), то только в этот момент начинается вся динамическая инициализация сущностей либы. Поэтому значение ее переменных со static storage duration устанавливается после входа в main()!

Минимальный пример:

// lib.cpp
struct CreationMomentShower {
CreationMomentShower(int num=0) : data{num} {
std::cout << "Created object with data " << num << std::endl;
}
int data;
};

struct Use {
static inline CreationMomentShower help{6};
};

// main.cpp
#include <iostream>
#include <dlfcn.h>

int main()
{
std::cout << "Main has already started" << std::endl;
void* libraryHandle = dlopen("libsource.so", RTLD_NOW);
if (libraryHandle == nullptr) {
std::cerr << dlerror() << std::endl;
return 1;
}
dlclose(libraryHandle);
}


Вывод:

Main has already started
Created object with data 6


Чтобы запустить это дело(на примере gcc), нужно:

1️⃣ Скомпилировать объектный файл из source.cpp g++ -c -fpic -std=c++17 source.cpp

2️⃣ Превратить его в библиотеку g++ -shared -o libsource.so source.o

3️⃣ Скомпилировать main.cpp g++ -o test main.cpp -std=c++17

4️⃣ Запустить ./test

Важно отметить, что о существовании библиотеки исполняемый файл test вообще не в курсе. Также не нужно добавлять путь до либы в какой-нибудь $LD_LIBRARY_PATH.

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

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

Dig deeper. Stay cool.

#compiler
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
Невероятные вероятности

Зачастую, когда мы пишем какие-то условия, то предполагаем, что какая-то ветка будет выполняться чаще другой. Самый простой пример - проверка чего-то на корректность. И если это что-то некорректно, то мы делаем какие-то действия, сигнализирующие о проблеме. И логично предположить, что наша программа хорошо написана (по крайней мере мы в это охотно верим). Поэтому ошибка - некая экстренная ситуация, которая не должна появляться часто. В принципе, любой не happy path может рассматриваться, как пример такой ситуации.

Может ли нам это знание как-то помочь? Вполне. В процессорах есть такой модуль - предсказатель переходов. На основе кода он по определенным эвристикам пытается понять, какая из веток выполниться с большей вероятностью. Он заранее подгружает данные и код для этой ветки, чтобы в случае удачного предсказания сократить время простоя вычислительного конвейера. И на самом деле, современные процессоры - настоящие Ванги! Их модуль предсказания переходов принимает правильные решения примерно в 90% случаев! Что не мало. Но все равно не идеально.

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

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

Потому что в С++20 появились стандартные аттрибуты [[likely]] и [[unlikely]]!

Допустим, мы пишем свой вектор интов. Причины покататься на байсикле мы отбросим в сторону и сконцентрируемся на сути. И мы дошли до метода MyVector::at, который по индексу выдает элемент. Но фишка в том, что этот метод проверяет индекс на нахождение в границах дозволенного и кидает исключение, если нештатная ситуация все-таки произошла.

int MyVector::at(size_t index) {
if (index >= this->size) [[unlikely]] {
throw std::out_of_range ("MyVector index is out of range");
}
return this->data[index];
}


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

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

Predict people's actions. Stay cool.

#cpp20 #compiler #performance
Директивы ifdef, ifndef, if
#новичкам

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

Этот способ - использование директив препроцессора #ifdef, #ifndef, #if. Все три - условные конструкции. Первая смотрит, определен ли в коде какой-то макрос. Если да, то делаем одни действия, если нет - другие. Второй наоборот, входит в первую ветку условия, если макрос не определен, и входит во вторую, если определен. Директива #if проверяет какое-то условие, ничего необычного. Все три директивы могут иметь как полные формы(с веткой в случае если условие ложно), так и неполные(без "else").

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

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

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == 0
// mmx|sse|avx code
#elif CPU_TYPE == 1
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}


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

Если мы хотим оптимизировать только под интеловские процессоры, то можем написать чуть проще:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#ifdef OPTIMIZATION_ON
// mmx|sse|avx code
#else
for (int i = 0; i < vec1.size(); ++i)
result += vec1[i] * vec2[i];
#endif
return result;
}


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

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

Широко известно, что такой способ не только устарел, но еще и опасен. Завтра посмотрим, чем конкретно.

Choose the right path. Stay cool.

#compiler
Опасности использования директив препроцессора

Вчерашний способ выбора ветки кода имеет несколько недостатков:

⛔️ Препроцессор работает с буквами/текстом программы, но не понимает программных сущностей. Это значит, что типабезопасность уходит из окна, и открывается простор для разного рода трудноотловимых багов.

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

⛔️ Вы ограничены возможностями препроцессора. Это значит, что вы не можете использовать в условии compile-time вычисления (аля результат работы constexpr функции).

⛔️ Отсюда же вытекает отсутствие возможности проверки условий, основанных на шаблонных параметрах кода. Это все из-за того, что препроцессор работает до начала компиляции программы. Он в душе не знает, что вы вообще программу пишите. Ему в целом ничего не мешает обработать текст Войны и Мира. Именно из-за отсутствия понимания контекста программы, мы и не можем проверять условия, основанные на compile-time значениях или шаблонных параметрах. Если вы хотите проверить, указатель ли к вам пришел в функцию или нет, или собрать какую-то метрику с constexpr массива и на ее основе принять решение - у вас ничего не выйдет.

⛔️ Вы очень сильно ограничены возможностями препроцессора. Попробуйте например сравнить какой-нибудь макрос с фиксированной строкой. Спойлер: у вас скорее всего ничего не выйдет. Например, как в примере из поста выше мы не можем написать так:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == "INTEL"
// mmx|sse|avx code
#elif CPU_TYPE == "ARM"
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}

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

⛔️ С препроцессором в принципе опасно работать и еще труднее отлаживать магические баги. Могут возникнуть например вот такие трудноотловимые ошибки. Вам придется смотреть уже обработанную единицу трансляции, причем иногда даже не понимая, где может быть проблема. А со всеми включенными бинарниками и преобразованиями препроцессора это делать очень долго и больно. А потом оказывается, что какой-то умник заменил в макросах функцию DontWorryBeHappy на ILovePainGiveMeMore.

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

Поделитесь в комментах своими интересными кейсами простреленных ступней из-за макросов.

Avoid dangerous tools. Stay cool.

#compiler #cppcore