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

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

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Правильный ответ - {мусор} {мусор} 5.

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

Для начала приведу цитату из стандарта:

In a non-delegating constructor, initialization proceeds in the following order:

- First, and only for the constructor of the most derived class, virtual base classes
are initialized in the order they appear on a depth-first left-to-right traversal of the
directed acyclic graph of base classes, where “left-to-right” is the order of appearance
of the base classes in the derived class base-specifier-list.

- Then, direct base classes are initialized in declaration order as they appear in the 
base-specifier-list(regardless of the order of the mem-initializer)

- Then, non-static data members are initialized in the order they were declared in the
class definition (again regardless of the order of the mem-initializer()

- Finally, the compound-statement of the constructor body is executed.


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

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

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

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

Какие из этого выводы?

Самый главный - поведение абсолютно определено стандартом. Здесь нет никакого UB! Список инициализации лишь задает способ инициализации, но никак не влияет на порядок. Порядк определяется только порядком следования полей в описании класса(сверху вниз).

То есть в нашем примере в начале будет инициализировать b, потом a и, наконец, c. И не смотря на то, что в списке инициализации c стоит первым, на самом деле его инициализация будет проходить последней. А так как все остальные поля зависят от значения c и инициализируются раньше него, то в них будет содержаться мусор.

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

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

Также вы можете(должны?) добавить флаг компиляции, чтобы превращать все предупреждения в ошибки. Тогда вы точно ничего важного не пропустите. Как говорится, флаг -Werror вам в руки!

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

Еще есть нюансы с default member initializer и его сочетанием со списком инициализации конструктора. Короче, много нюансов, как и во всех плюсах)
В один пост все все равно не влезет. Будем разделять и властвовать!

Devide et empera. Stay cool.

#cppcore
Линковка constexpr с другими TU

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

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

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

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

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

Статические же сущности уникальны для каждой единицы компиляции и никому не позволительно иметь к ним доступ при линковке. Эдакие эндемики своей TU.

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

И тут мы приходим в первой ситуации: мы определили inline функцию в одной единице трансляции и пытаемся из другой единицы получить к ней доступ. Условно так:

//first.cpp
static const int a = 3;
constexpr int gaga() {
return a;
}

//second.cpp
int gaga();
void boo() {
gaga();
}


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

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

А они есть.

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

//header.hpp
static const int const_var = 3;
constexpr int gaga() {
return const_var;
}
//first.cpp
#include "header.hpp"
void boo() {
gaga();
}

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


Этот чудокод теперь собирается без проблем, компилятор встроит все вызовы и будет все хорошо. Но вот что будет, если функция gaga будет чуть сложнее для того, чтобы ее встраивать? Что будет, если для first.cpp и second.cpp компилятор все-таки будет генерировать определение gaga?

А будет UB. Тут применимо вот такое правило.
If an inline function [...] with external 
linkage is defined differently in
different translation units,
the behavior is undefined.

Но почему же определения разные? Мы же один и тот же код с одной и той же константой просто копируем в нужные единицы трансляции.

Только вот константы на самом деле разные. В каждой единице трансляции будет своя копия const_var и каждое определение gaga будет ссылаться на разные сущности-копии const_var.

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

Конечно, компилятор скорее всего оптимизирует использование такой простой переменной и все будет работать как ожидается. Но просто сам формат организации кода и зависимостей сущностей может привести к UB. Оно вам надо? Оно вам не надо.

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

Sometimes mixing certain ingredients just won't work. Stay cool.

#cpp11 #cpcore
Фиксим неприятности

Сегодня коротко разберем, как обезопасить себя от проблем кода из предыдущего поста?

Просто надо использовать inline переменные! Но для этого понадобится С++17 и выше. Их и более менее все используют, но надо оговорку сделать.

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

Поэтому код будет выглядеть вот так:

//header.hpp
inline constexpr int const_var = 3;
constexpr int gaga() {
return const_var;
}
//first.cpp
#include "header.hpp"
void boo() {
gaga();
}

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


constexpr и const имеют одинаковый линковочный смысл для переменных, поэтому замена вполне корректна. Да и просто constexpr переменная лучше смотрится с constexpr функций.

Fix your flaws. Stay cool.

#cpp17
Правильно смешиваем 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
Инициализация статических полей класса. Ч1

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

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

Objects with static storage duration 
defined in namespace scope in the same
translation unit and dynamically initialized
shall be initialized in the order in which
their definition appears in the translation unit.


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

struct Helper {
Helper(int num) : data{num} {
std::cout << "Helper " << num << std::endl;
}
private:
int data;
};

struct Class {
static Helper a;
static Helper b;
};

Helper Class::b{1};
static Helper c{2};
Helper Class::a{3};

int main() {}


Мы в выводе получим следующее:

Helper 1
Helper 2
Helper 3


Несмотря на то, что в классе Class поле a было объявлено первым, инициализируется оно самым последним, потому что оно определено самым последним. А переменная c вообще не относится к классу, но была проинициализирована между полями Class'а, потому что ее определение расположено между ними.

Это происходит из-за того, что статические поля класса практически никак с этим классом не связаны. Доступ к ним разве что через имя класса. А так это обычная статическая переменная, как и любая другая.

Именно поэтому в цитате из стандарта говорится обобщенно о статических объектах. Для линкера вообще никакой разницы между переменными a, b и c нету. Это просто сущности с разными именами, но абсолютно равными правами.

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

Define order of your life. Stay cool.

#cppcore
Инициализация статических полей класса. Ч2

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

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

Посмотрим на пример.

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

class InitializationTest {
public:
static std::map<std::string, std::unique_ptr<InitializationTest>> map;
static bool Create(std::string ID) {
map.insert({ID, std::move(std::unique_ptr<InitializationTest>{new InitializationTest})});
return true;
}
private:
Test() = default;
};

static bool creation_result = InitializationTest::Create("qwe");
// Somehow handle result and process object
std::map<std::string, std::unique_ptr<InitializationTest>> InitializationTest::map{};

int main() {}


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

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

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

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

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

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

Be careful. Stay cool.
Инициализация статических полей класса. Ч3

В первой части мы разобрали порядок инициализации статических поле в случае их out-of-class определения. Однако в современных плюсах редко, кто вне описания класса инициализирует статические поля. Все потому что в С++17 появились инлайн переменные, которые позволяют не нарушать ODR при наличии их определения в разных единицах трансляции. Подробнее об этом тут. Эта фича позволила определять статические поля сразу внутри описания класса. Более подробно об этом тут.

Ну и встает вопрос: в каком порядке инициализируются inline static class members?

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

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

Выглядит это примерно так:

struct Helper {
Helper(int num) : data{num} {
std::cout << "Helper " << num << std::endl;
}
private:
int data;
};

struct Class {
static inline Helper a{1};
static Helper b;
static inline Helper c{2};
};

Helper Class::b{3};

int main() {}


Вывод будет таким:

Helper 1
Helper 2
Helper 3


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

Так что порядок следования определений - наше все.

Rely on explicitly stated rules. Stay cool.

#cpp17 #cppcore
Особый день

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

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

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

Любой знаковый день - повод сделать что-то. Накидываю беспроигрышный вариант.

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

С праздником, дорогие подписчики! Благодарность свернет горы.

Tip your hat to your ancestors. Stay cool.
Память наследованных классов

А вы когда-нибудь задумывались, как бы выглядел рентген снимок матрёшки? Скорее всего, что нет, но, если в двух словах, это именно то, о чем сегодня мы будем говорить. Мне бы хотелось рассмотреть наследование классов в 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
static_cast

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

Исходя из своих наблюдений, наиболее востребованным оператором приведения является static_cast, т.к. в основном большинство приходится на преобразование между совместимыми друг с другом типами:
int32_t value_i32 = 42;
int64_t value_i64 = static_cast<int64_t>(value_i32);

float value_f32 = 42.314;
int16_t value_i16 = static_cast<int16_t>(value_f32);

Оператор static_cast так же проверяет корректность выполняемого приведения. Например, запрещает приведение указателя к значению:
// error: invalid 'static_cast' from type 'int*' to type 'int'
static_cast<int>(&value);

Конечно, некоторые смысловые ошибки нельзя поймать, ведь с точки зрения типа, все хорошо. Например, приведение значения к enum class может привести к непредвиденным сценариям 🤭:
enum class action_e : int { RUN = 0, FIGHT = 1 };

// Should I run or fight?
action_e action = static_cast<action_e>(2);

Лучше бы их все таки дополнять еще debug-only assert или вообще условным ветвлением.

Правила приведения для фундаментальных (встроенных) типов в C++ определены заранее, а вот для пользовательских классов можно определить свои собственные преобразования с помощью оператора приведения к типу: operator Type():
class specific_error_t
{
...
// Оператор приведение к типу `bool`
operator bool() const
{
return m_code < 0;
}
...
};

Эта ручка будет дергаться при явном и неявном приведении типов в живом примере 1:
specific_error_t internal_code = -1;

// Приведение `internal_code` к типу `bool`
bool has_internal_code = static_cast<bool>(internal_code);

Один из неочевидных способов применения этого оператора является приведение к типу void. Казалось бы, зачем? Но это помогает подавить предупреждение компилятора о неиспользуемой переменной / не присвоенном значении:
void foo()
{
    int result = read_and_do_something();

#ifdef DEBUG
    // Debug build check only
    assert(result == 0);
#endif

    static_cast<void>(result);
}


Если такое предупреждение появляется, то вероятно, что что-то вы все таки упускаете в своем коде. Но иногда такие ситуации встречаются, когда полезная нагрузка от вашего действия есть, а предупреждение не к месту. Например, в следствие какой-нибудь препроцессорной директивы. Напоминаем, что в C++17 так же есть атрибут [[maybe_unused]], который решает эту проблему.

Так же static_cast позволяет выполнить приведение к типу родительского класса (upcasting) и к типу наследников (downcasting) в рамках одной иерархии классов:
Child *pointer   = new Child();

// Upcasting
Base *base_ptr = static_cast<Base*>(pointer);

// Downcasting
Child *child_ptr = static_cast<Child*>(base_ptr);

Важным моментом является тот факт, что static_cast не может обеспечить проверку корректности совершенного преобразования к наследнику (downcasting)! Если наследник выбран неправильно и вы допустили ошибку преобразования к другому типу, то вам все равно дадут скомпилироваться: живой пример 2. У компилятора действительно не хватает информации, чтобы это проверить на этапе компиляции.

Разберем эту тему подробнее, когда дойдем до динамического полиморфизма
reinterpret_cast

Исходя из имени этого оператора, он вводится для узкой специализации: "переосмыслить" значение, т.е. представить его в другом виде. Его используют для приведение несовместных типов: «указатель к объекту», «указатель к указателю». Из живого примера 1:
double  *pointer_f64 = new double(42);
int64_t *pointer_i64 = reinterpret_cast<int64_t *>(pointer_f64);
int64_t  value_i64   = reinterpret_cast<int64_t> (pointer_i64);
int32_t *pointer_i32 = reinterpret_cast<int32_t *>(value_i64);

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

Где же нам такое может понадобиться? Если не брать в пример какой-нибудь зловещий хакинг чисел с плавающей запятой на уровне битов, то в основном, при работе с сырой памятью. Например, при записи и чтении данных в файл:
void save(const double &hp)
{
file.write(reinterpret_cast<const uint8_t*>(&hp), sizeof(hp));
}

void load(double &hp)
{
file.read(reinterpret_cast<uint8_t*>(&hp), sizeof(hp));
}

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

Давайте попробуем увидеть, как расставлены биты в типе double в живом примере 2. При решении этой задачи можно воспользоваться готовой стандартной структурой std::bitset(unsigned long long) для распечатки битов. Однако, есть нюанс! Тип данных double можно привести к соразмерному unsigned long long двумя разными способами, которые дают совершенно разный результат.

Так, static_cast отбрасывает дробную часть и преобразует к целочисленному значению, а reinterpret_cast просто иначе интерпретирует расставленные биты. В следствие этого из 42.0 мы получаем не 42, а какое-то другое и явно отличное от исходного число: 4631107791820423168. Очень похоже на то, как мы иногда в шутку пытаемся услышать слова родного языка в иностранных песнях.

Однако, с точки зрения поставленной задачи, именно оно нам и нужно — тип unsigned long long просто выступает в роли «грузового контейнера» для транспортировки 64 битов, которые std::bitset потом благополучно печатает в консоль.

#cppcore
static local variables

В этом давнишнем посте кратко резюмировали все стороны "употребления" ключевого слова static. Сегодня поговорим про статические локальные переменные.

Это довольно интересная сущность, которая сочетает в себе поведение локального объекта функции и глобальной переменной.

От локального объекта она берет область видимости. То есть к этой переменной по имени никак нельзя обратиться вне ее функции. Можно, например, вернуть из функции ссылку на эту переменную и иметь возможность ее читать и модифицировать. Но по имени к ней можно обратиться только внутри функции. Соответственно, у static local variable нет никакого собственного типа линковки, это бессмысленно.

От глобальной переменной она берет статическое время жизни. То есть, начиная с момента своей инициализации, она продолжает существовать, пока не вызовется std::exit aka завершение программы.

Разберем немного цикл жизни такой переменной.

1) Она инициализируется при первом достижении исполнения ее объявления. Стандарт нам говорит:
such a variable is initialized the first 
time control passes through its declaration; [...]
If control enters the declaration concurrently
while the variable is being initialized,
the concurrent execution shall wait for
completion of the initialization.


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

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

2) При выходе из скоупа функции для статической локальной переменной не вызывается деструктор. Она продолжает жить не тужить и сохраняет свое значение до следующего вызова функции.

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

4) После завершения функции main переменная разрушается. Press F умершим.

Пример:

std::string BytesToHex(const void* bytes, size_t size)
{
if (size) {
static const char kHexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
std::string output;
output.reserve(size * 2);
auto c = static_cast<const uint8_t*>(bytes);
for (size_t i = 0; i < size; ++i) {
uint8_t value = *(c + i);
output.push_back(kHexDigits[value >> 4]);
output.push_back(kHexDigits[value & 0xf]);
}
return output;
}
else {
return "";
}
}

int main()
{
std::cout << BytesToHex("", 0) << std::endl;
std::cout << BytesToHex("123", 3) << std::endl;
std::cout << BytesToHex("abc", 3) << std::endl;
}


Функция BytesToHex переводит любое количество байт от заданного указателя в их hex представление. Раз мы знаем, что hex представление содержит только 16 символов и больше нигде эти символы не нужны, то очень удобно поместить массив этих символов в саму функцию в качестве локальной статической переменной. Так мы инкапсулируем данные и сохраним возможность 1 раз создать переменную и пользоваться именно этим инстансом во всех вызовах функции.

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

Combine your best sides. Stay cool.

#cpp11 #multitasking #cppcore
Цикл жизни 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
Zero initialization

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

Выполняется она после попытки выполнить константную инициализацию глобальных объектов и представляет из себя literally "зануление объекта".

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

static T object;
T();
T t= {};
T{};
CharT array[n] = "short-sequence";


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

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

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

Ну и про "зануление". У zero-initialization следующие эффекты:

👉🏿 Если T - скалярный тип, объект инициализируется результатом превидения численного литерала 0 к типу T.

👉🏿 Если T - кастомный тип, то:

1️⃣ все паддинги инициализируются битами-нулями.

2️⃣ для всех нестатических мемберов проводится zero-initialization(немного рекурсии, но все рано или поздно сводится к скалярным типам и массивам).

3️⃣для подобъектов каждого базы класса проводится zero-initialization.

👉🏿 Если Т - массив, то каждый элемент zero-инициализирутся.

👉🏿 Если Т - ссылка, то гоняем лысого(ЗАЧЕРКНУТЬ)ничего не делаем.

Обычно zero-инициализированные объекты находятся в .bss секции бинарника, которую иногда обзывают .zerofill секцией.

Как и говорил, довольно просто, но это знание будет полезно в дальшейших статьях.

Stay useful. Stay cool.

#cppcore
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
Ретроспектива с подписчиками #2

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

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

С прошлого раза пришло много народу, возможно коньюнктура поменялась. Возможно, вы новичкок в плюсах и хотите посты про базовые вещи, типа для чего нужен std::cout или что за монстр этот ваш std::unordered_map🤓. Возможно, вы хотите чуть разнообразнее контент. Например, нам нравится готовить длинные серии постов на одну тему. Глубокое погружение в материал дает большее понимание темы и нюансов, о которых можно вам рассказать. И нам кажется, что лучше постоянно быть в контексте одной темы, чтобы лучше ее понять и держать в голове все ее грани. И мы скорее всего не перестанем готовить посты в таком духе. Но мы можем периодически разбавлять серии постами на другие темы, если вас не будут смущать большое количество отсылок на предыдущие посты, когда серия будет возвращаться после перерыва.

Если вы хотите, чтобы мы рассказали какую-то тему - тоже пишите.

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

Голосовать за важность чьей либо идеи предлагаем просто пальцами: 👍или👎. Так мы поймем, что действительно важно для нас. Погнали! 👨‍💻

Build communication with people. Stay cool.