Фиксим неприятности
Сегодня коротко разберем, как обезопасить себя от проблем кода из предыдущего поста?
Просто надо использовать inline переменные! Но для этого понадобится С++17 и выше. Их и более менее все используют, но надо оговорку сделать.
Инлайн переменные также имеют внешнюю линковку, их определений может быть несколько в пределах одной программы, и по итогу компановщик также выберет одну из копий и весь остальной код будет ссылаться на нее. И в этом случае определение функции будет действительно единственным и доступным всем другим TU и это будет вполне легально.
Поэтому код будет выглядеть вот так:
constexpr и const имеют одинаковый линковочный смысл для переменных, поэтому замена вполне корректна. Да и просто constexpr переменная лучше смотрится с constexpr функций.
Fix your flaws. Stay cool.
#cpp17
Сегодня коротко разберем, как обезопасить себя от проблем кода из предыдущего поста?
Просто надо использовать 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 статической переменной, то компановщик объединит все ее копии в одну и в итоге все будут ссылаться на одну сущность.
Выглядеть это может примерно так:
Дальше. Не обязательно глобальная переменная принадлежит какому-то классу. Они может принадлежать самой этой функции и больше нигде не использоваться. А нужна она была для сохранения состояния между вызовами функции. Тут очень напрашивается просто поместить эту статическую глобальную переменную и тогда она станет статической локальной переменной. С константами это конечно абсолютно бессмысленно делать, но для "переменных" переменных можно и это стоит упоминания. Тогда мы уже не можем говорить про constexpr(будет зависимость от рантаймового значения), поэтому дальше разговор только про inline.
Статические локальные переменные не имеют никакой линковки(к ним нельзя получить доступ вне функции), поэтому не совсем понятно, корректно ли такая конструкция себя будет вести в инлайн функциях. И оказывается корректно(из cppreference):
Нам гарантируют, что все определения инлайн функции будут ссылаться на одну и ту же сущность-статическую локальную переменную.
Выглядеть это может так:
В общем, смешивать inline и static - можно, но очень осторожно. Не противоречьте стандарту и никакое UB не овладеет вашим кодом.
Mix things properly. Stay cool.
#cpp17 #cppcore #compiler
На мой взгляд предыдущее решение проблемы хоть и очень крутое, модное и молодежное, но иногда можно и лучше.
Например. Бывают случаи, когда в глобальную область выносят переменные, которые на самом деле не глобальные. Вообще, глобальные переменные - не самый хороший признак архитектуры кода. Они могут относиться к конкретным сущностям в коде, которые уже обособлены или могут быть выделены в будущем. Существование свободных функций хоть и допустимо, но тоже всегда должно подвергаться сомнению. Возможно эти функции про какую-то отдельную сущность и их стоит выделить в класс. Тогда можно попробовать некоторые другие вещи, помимо заинлайнивания переменной.
Если свободные функции перенести внутрь описания класса и сделать их явно или неявно 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 штука. Каждый линкер вправе делать это по-своему. Единственное, что стандарт нам гарантирует:
Все статические объекты инициализируются в порядке, в котором они определены(не объявлены) в единице трансляции. Причем происхождение этого статического объекта не важно. Например, скомпилировав и запустив такой пример в main.cpp:
Мы в выводе получим следующее:
Несмотря на то, что в классе Class поле
Это происходит из-за того, что статические поля класса практически никак с этим классом не связаны. Доступ к ним разве что через имя класса. А так это обычная статическая переменная, как и любая другая.
Именно поэтому в цитате из стандарта говорится обобщенно о статических объектах. Для линкера вообще никакой разницы между переменными
С инициализацией статических полей и объектов еще много нюансов, будем потихоньку все разбирать.
Define order of your life. Stay cool.
#cppcore
Под этот пост вы накидали хренову гору лайков, поэтому разбираем дальше тему инициализации. Сегодня рассмотрим, как она проходит для классов со статическими полями.
Начнем с того, что все статические поля всех классов инициализируются до входа в 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
Как думаете, может ли быть такое, что статическое поле класса инициализируется после завершения вызова конструктора класса? То есть объект уже создался, а статическое поле его класса еще не инициализированно? Подумайте пару секунд над этим вопросом самостоятельно.
После вчерашнего поста вам уже немного легче должны были даться рассуждения. Загвоздка в том, что статическое объекты инициализируются в порядке определения и не важно, какого класса эти объекты.
Посмотрим на пример.
Есть у нас класс, который сохраняет все свои инстансы по ключу в статическую мапу и все созданные инстансы доступны только через эту мапу.
Чтобы такое провернуть, создаем в классе статическую мапу, статический метод Create, который предоставляет доступ к созданию объектов и объявляем конструктор класса приватным, чтобы никто снаружи не смог втихаря создать объект. Таким образом, доступ к объектам есть только через метод Create и статическую мапу.
Не имея представления о нюансах инициализации, взбрело нам в голову создать и использовать объект перед определением мапы.
Запуская все это дело, получим сегфолт. И да, да, вы все правильно поняли. Все из-за порядка инициализации.
Так как линкеру пофиг на тип статических объектов, он спокойно может поставить инициализацию статического поля класса после завершения работы конструктора объекта в глобальном неймспейсе. Вот и получается конфуз: объект надо создавать, а используемое поле не инициализировано. От того и падаем.
Здесь ситуация игрушечная и довольно простая, потому что все находится в одной единице трансляции. Пример такой, потому что внутри одной единицы компиляции порядок инициализации детерминирован, тут легче показать причину и следствие. Но когда мы выходим за ее пределы и пытаемся создать объект InitializationTest в другой единице трансляции в глобальном скоупе, то поведение кода начинает зависеть от линкера. Порядок создания объектов между юнитами компиляции не определен и тут все будет, как решит компановщик. Можно конечно почитать документацию и действовать в соответствии с ней. Но этот код будет непереносим, а также не защитит вас от возможных изменений в поведении линкера.
Будьте аккуратны с инициализацией статических объектов и в принципе поменьше их используйте.
Be careful. Stay cool.
Как думаете, может ли быть такое, что статическое поле класса инициализируется после завершения вызова конструктора класса? То есть объект уже создался, а статическое поле его класса еще не инициализированно? Подумайте пару секунд над этим вопросом самостоятельно.
После вчерашнего поста вам уже немного легче должны были даться рассуждения. Загвоздка в том, что статическое объекты инициализируются в порядке определения и не важно, какого класса эти объекты.
Посмотрим на пример.
Есть у нас класс, который сохраняет все свои инстансы по ключу в статическую мапу и все созданные инстансы доступны только через эту мапу.
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?
В целом, ответ такой же: в порядке определения. Только эти определения теперь совмещены с объявлением, поэтому можно сказать, что инициализация происходит в порядке появления этих полей в описании класса. Спасибо Артему Кузнецову, что указал в комментариях на эту особенность)
Ну и для того, чтобы пост был не таким скучным, давайте попробуем смешать обычные статические мемберы и инлайновые и посмотрим, как эта смесь будет себя вести.
Выглядит это примерно так:
Вывод будет таким:
В целом, картина довольно понятная. Если линкер ставит инициализацию статиков по порядку их определения, то здесь прослеживается та же история. Первыми инициализируются инлайны по порядку появления их в классе, а последним инициализируется неинлайновое поле, даже с учетом того, что оно объявлено между двумя первыми.
Так что порядок следования определений - наше все.
Rely on explicitly stated rules. Stay cool.
#cpp17 #cppcore
В первой части мы разобрали порядок инициализации статических поле в случае их 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.
И хоть у кого-то сейчас под окном лежит снег, как у нас в Нижнем Новгороде, сегодня очень важный и теплый для нашей страны праздник - День Победы.
К нему можно по-разному относиться, дискутировать по этому поводу или ненавидеть, все что с ним связано. Но, на мой взгляд, это не имеет значения.
Значение имеет то, что конкретно наши с вами предки, конкретные люди сделали очень много для того, чтобы мы с вами просто жили.
Любой знаковый день - повод сделать что-то. Накидываю беспроигрышный вариант.
Давайте же просто сегодня вспомним своих бабушек и дедушек и искренне поблагодарим их за то, что мы живы. Они заслужили.
С праздником, дорогие подписчики! Благодарность свернет горы.
Tip your hat to your ancestors. Stay cool.
Всем привет! Мы в очередной раз подготовили серию грокательных постов о простом и не очень. В ближайшее время нас ждут следующие посты:
1. Память наследованных классов
2. Память виртуально наследованных классов
3. Девиртуализация доступа к полям виртуально наследованных классов
4. static_cast
5. reinterpret_cast
6. strict aliasing
7. Мотивация оптимизации пересекающихся областей памяти
8. bit_cast
9. const_cast
10. Идея динамического полиморфизма
11. Виртуальный деструктор
12. Вызов переопределенных методов в конструкторе / деструкторе
13. Идентификатор override
14. dynamic_cast
15. Идентификатор final
16. Как работает динамический полиморфизм?
17. Как работает dynamic_cast? RTTI!
18. Девиртуализация вызовов. Ч1
19. Девиртуализация вызовов. Ч2
20. C-style cast
Передаем привет нашему подписчику @xiran22, который просил нас написать о девиртуализации 👋
1. Память наследованных классов
2. Память виртуально наследованных классов
3. Девиртуализация доступа к полям виртуально наследованных классов
4. static_cast
5. reinterpret_cast
6. strict aliasing
7. Мотивация оптимизации пересекающихся областей памяти
8. bit_cast
9. const_cast
10. Идея динамического полиморфизма
11. Виртуальный деструктор
12. Вызов переопределенных методов в конструкторе / деструкторе
13. Идентификатор override
14. dynamic_cast
15. Идентификатор final
16. Как работает динамический полиморфизм?
17. Как работает dynamic_cast? RTTI!
18. Девиртуализация вызовов. Ч1
19. Девиртуализация вызовов. Ч2
20. C-style cast
Передаем привет нашему подписчику @xiran22, который просил нас написать о девиртуализации 👋
Память наследованных классов
А вы когда-нибудь задумывались, как бы выглядел рентген снимок матрёшки? Скорее всего, что нет, но, если в двух словах, это именно то, о чем сегодня мы будем говорить. Мне бы хотелось рассмотреть наследование классов в C++, с точки зрения представления данных в памяти.
Как мы уже знаем, при создании объекта какого-либо класса всегда выделяется память. Размер, преимущественно, зависит от количества полей и их типов, выравнивания, а так же наследованных классов. Наглядно продемонстрировать структуру памяти объектов нам поможет следующий набор флагов компиляции для Clang:
В качестве результата мы будем видеть разметку сырой памяти в классах.
Начнем с тривиальных примеров наследования, чтобы вам потом мысленно было легче декомпозировать более сложные. Рассмотрим живой пример 1 дампа памяти для класса
Структура
Компилятор знает «из чего состоит» дочерний класс
Можно сказать, что в иерархии классов с единственным родителем образуется «матрёшка», где каждый класс включает в себя предшествующий. Вот живой пример 2. Однако эта матрёшка на самом деле немного сложнее устроена, чем мы привыкли думать. Она должна уметь описывать логику для множественного наследования, когда родителей может быть больше одного!
Рассмотрим живой пример 3 для множественного наследования:
Порядок следования областей памяти зависит от порядка наследования классов:
При вычислении адреса класса
Вышеописанный пример можно усложнить - пусть
В первую очередь хочется отметить, что классы
В данном примере мы увидели, что родительские классы
#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
Память виртуально наследованных классов
Как было сказано в предыдущей статье, «просто так эти области памяти не могут быть объединены...». Возможность совместного использования памяти для общего родительского класса есть!
С помощью ключевого слова
Стандарт языка не регламентирует реализацию виртуального наследования и виртуальных методов. Большинство компиляторов придерживаются спецификации Itanium C++ ABI (в частности, GCC и LLVM Clang). Однако, различия всё равно могут быть. Нам важно получить именно понимание, какие могут быть нюансы и как они могут быть решены.
Давайте сразу посмотрим, как это будет представлено в памяти. Рассмотрим живой пример 5:
Мы тут же сталкиваемся с одной интересной проблемой. В рамках класса
Как может решаться эта проблема? В большинстве случаев, во все классы, которые используют виртуальное наследование, неявно добавляется виртуальный указатель на виртуальную таблицу смещений. Она генерируется компилятором и хранится в read-only памяти приложения. Размер класса, естественным образом, увеличивается сразу же на размер указателя для выбранной архитектуры:
При обращении к полям виртуально наследованного класса, будет выполняться дополнительная операция чтения виртуального указателя
Как вы догадываетесь, за такие фокусы приходится платить тактами процессора. Это подтверждают результаты бенчмарка. Действительно, доступ к памяти виртуально наследованных классов будет работать медленнее.
В следующей статье мы поговорим о возможном способе оптимизации скорости доступа к памяти виртуально наследованных классов.
#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.
Как вы уже догадались, запретить наследование можно с помощью идентификатора со специальным значением
Этого достаточно, чтобы гарантировать отсутствие наследников класса
Важно отметить, что приведение типа должно быть прямым от финального класса к виртуальной базе, без приведения к промежуточным наследникам! Иначе мы опять будем вынуждены обращаться к таблице виртуальных смещений.
Я видоизменил пример из предыдущей статьи и получил вот такого монстра бенчмарка. Тут появился шаблонный класс
Новые результаты демонстрируют, что теперь скорость доступа стала сопоставимой с невиртуально наследованным классом. Следовательно, можно сделать вывод, что девиртуализация доступа позволяет сократить лишние действия!
Нам так же следует поговорить о девиртуализации, когда разберем работу динамического полиморфизма. Всем удачи!
#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
В предыдущих статьях мы несколько раз упоминали оператор
Исходя из своих наблюдений, наиболее востребованным оператором приведения является
Оператор
Конечно, некоторые смысловые ошибки нельзя поймать, ведь с точки зрения типа, все хорошо. Например, приведение значения к
Правила приведения для фундаментальных (встроенных) типов в C++ определены заранее, а вот для пользовательских классов можно определить свои собственные преобразования с помощью оператора приведения к типу:
Эта ручка будет дергаться при явном и неявном приведении типов в живом примере 1:
Если такое предупреждение появляется, то вероятно, что что-то вы все таки упускаете в своем коде. Но иногда такие ситуации встречаются, когда полезная нагрузка от вашего действия есть, а предупреждение не к месту. Например, в следствие какой-нибудь препроцессорной директивы. Напоминаем, что в C++17 так же есть атрибут
Так же
Разберем эту тему подробнее, когда дойдем до динамического полиморфизма
В предыдущих статьях мы несколько раз упоминали оператор
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 };Лучше бы их все таки дополнять еще debug-only
// Should I run or fight?
action_e action = static_cast<action_e>(2);
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:
Где же нам такое может понадобиться? Если не брать в пример какой-нибудь зловещий хакинг чисел с плавающей запятой на уровне битов, то в основном, при работе с сырой памятью. Например, при записи и чтении данных в файл:
Наверняка вы знаете, что числа с плавающей точкой совершенно иначе представляются в системе, в отличии от целочисленных значений в двоичном виде. Это позволяет нам оперировать ну ооочень большими и маленькими десятичные значениями, выходящими за рамки возможного для целочисленных типов.
Давайте попробуем увидеть, как расставлены биты в типе
Так,
Однако, с точки зрения поставленной задачи, именно оно нам и нужно — тип
#cppcore
Исходя из имени этого оператора, он вводится для узкой специализации: "переосмыслить" значение, т.е. представить его в другом виде. Его используют для приведение несовместных типов: «указатель к объекту», «указатель к указателю». Из живого примера 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) Она инициализируется при первом достижении исполнения ее объявления. Стандарт нам говорит:
То есть нам дается очень важная гарантия: локальные статические переменные инициализируются потокобезопасно. Это значит, что даже если несколько потоков одновременно зайдут в функцию и попытаются проинициализировать переменную, то победителем в этой истории будет только один поток, который и проведет инициализацию, все остальные будут ждать. Эта гарантия появляется вместе с появлением новой модели памяти и исполнения в С++11. И обычно реализуется с помощью паттерна блокировки с двойной проверкой.
Однако, если переменная числового типа или инициализируется с помощью константного выражения, то инициализация может произойти раньше(какой смысл ждать, если все понятно как делать и делать это просто).
2) При выходе из скоупа функции для статической локальной переменной не вызывается деструктор. Она продолжает жить не тужить и сохраняет свое значение до следующего вызова функции.
3) При повторном заходе в функцию объявление переменной просто игнорируется и выполняется весь код, помимо инициализации. Здесь мы можем повторно использовать переменную, изменить ее значение и вообще много чего с ней делать.
4) После завершения функции main переменная разрушается. Press F умершим.
Пример:
Функция BytesToHex переводит любое количество байт от заданного указателя в их hex представление. Раз мы знаем, что hex представление содержит только 16 символов и больше нигде эти символы не нужны, то очень удобно поместить массив этих символов в саму функцию в качестве локальной статической переменной. Так мы инкапсулируем данные и сохраним возможность 1 раз создать переменную и пользоваться именно этим инстансом во всех вызовах функции.
Один интересный момент, что kHexDigits инициализируется не при первом вызове функции. Потому что в первый раз исполнение не прошло через ее декларацию. И только начиная со второго вызова она начинает существовать и разрушается только после выхода из main().
Combine your best sides. Stay cool.
#cpp11 #multitasking #cppcore
В этом давнишнем посте кратко резюмировали все стороны "употребления" ключевого слова 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! Этот термин употребляется для описания объектов, лишь время жизни которых является статическим. То есть более менее всех глобальных объектов. Все они существуют от момента создания до момента завершения программы. Поэтому просто написав:
считайте, что вы объявили переменную со 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
В прошлом посте поговорили про локальные статические переменные и их цикл жизни. Сегодня в общем рассмотрим рождение и смерть всех нелокальных глобальных переменных.
Здесь важна оговорка, что объекты со 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 "зануление объекта".
Так как для этого вида установки значения объектам нет своего выделенного синтаксиса в языке, то вот примеры того, в каких ситуациях она может быть выполнена:
Примеров на самом деле больше, но так описано в стандарте, поэтому надо уважить дедов. И вот почему примеров больше.
Нулевая инициализация в любом случае проводится для всех глобальных переменных, если их нельзя константно проинициализировать, раньше всех остальных видов инициализации. То есть создали вы объект в глобальном скоупе, который динамически проинициализировали(в рантайме). В констукторе можно напихать все, что угодно(чего не было в примерах выше), но этот объект все равно будет проинициализирован нулями на этапе компиляции.
Также она проводится для массивов символьных типов, если инициализирующая строка слишком короткая. Остаток будет заполняться нулями.
Ну и про "зануление". У zero-initialization следующие эффекты:
👉🏿 Если T - скалярный тип, объект инициализируется результатом превидения численного литерала
👉🏿 Если T - кастомный тип, то:
1️⃣ все паддинги инициализируются битами-нулями.
2️⃣ для всех нестатических мемберов проводится zero-initialization(немного рекурсии, но все рано или поздно сводится к скалярным типам и массивам).
3️⃣для подобъектов каждого базы класса проводится zero-initialization.
👉🏿 Если Т - массив, то каждый элемент zero-инициализирутся.
👉🏿 Если Т - ссылка, то гоняем лысого(ЗАЧЕРКНУТЬ)ничего не делаем.
Обычно zero-инициализированные объекты находятся в .bss секции бинарника, которую иногда обзывают .zerofill секцией.
Как и говорил, довольно просто, но это знание будет полезно в дальшейших статьях.
Stay useful. Stay cool.
#cppcore
Решил начать в нулевой инициализации, так как она как будто бы самая простая и понятная.
Выполняется она после попытки выполнить константную инициализацию глобальных объектов и представляет из себя 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
Мотивация оптимизации пересекающихся областей памяти
В предыдущей статье было рассказано о правиле
Давайте «поиграем в компилятор» и попробуем понять логику оптимизации этой функции:
Итак, можем ли мы оптимизировать данный код?
С одной стороны, мы можем предположить, что раз мы задали
Давайте попробует измерить с помощью игрушечного бенчмарка, будет ли это как-то влиять? Опустим момент, почему я его запустил с опцией
С другой стороны, если
Вышеописанная ситуация может показаться неоднозначной. В большинстве случаев, скорее всего, она будет легальна, но для каких-то совершенно недопустима. В случае, если бы вы разрабатывали свою программу, то вы бы могли решить этот вопрос просто посмотрев, где и как используется такая функция.
Но вообще говоря, это касается не только вырванных из контекста функций с их аргументами, а любых указателей и ссылок на пересекающуюся память. Можно ли подставлять значения в последующих выражениях без перечитывания памяти?
Для вашего компилятора это одновременно и возможность офигенно ускорить исполнение программ, и головная боль: как понять, происходит наложение памяти (aliasing) и оптимизироваться нельзя?
Попробуем рассуждать дальше. Условно, этапе компиляции можно попробовать отследить какие адреса должны быть в указателях. Но вопрос особенно остро встаёт, когда мы компилируем какую-то функцию для динамической библиотеки. По сути, перед нами только сама функция и всё! Контекста вызова этой функции нет. У компилятора не так много информации, которую он может использовать, поэтому в качестве критерия выступает тип ссылки/указателя. Вероятно, что представление пересекающейся памяти совершенно разными типами - это все таки очень редкий случай в рамках одного приложения.
Комитет стандартизации C/С++ предпочел регламентировать правила, по которым можно не ограничивать программистов и предоставить лучшую производительность. Компромиссный вариант.
Компилятор не может применить оптимизацию, если соблюдается правило strict aliasing. Во всех остальных случаях компилятор по умолчанию считает любые указатели/ссылки непересекающимися областями памяти и будет их оптимизировать, если не удается явно детектировать его нарушение... Это можно сделать, преимущественно в рамках тела одной функции, когда есть полный контекст взаимодействия с указателем. В этом случае будут сгенерированы инструкции, аналогичные корректному коду: живой пример. Вероятно, именно поэтому с такой проблемой разработчики сталкиваются реже, чем могли бы.
Но вот можно ли рассчитывать на то, что в другой версии компилятора это будет работать точно так же? Раз не стандартизировано и это UB, значит нет. В общем случае, такой код становится непереносимым.
#compiler
В предыдущей статье было рассказано о правиле
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.
Cегодня будет интересный формат взаимодействия с вами, когда вы можете написать свои ощущения по каналу: что нравится, что не нравится, что можно изменить.
Это довольно важная для нас активность, она помогает оставаться в контакте с аудиторией и делать некие adjustment'ы, чтобы вам больше заходил контент. Не буду лукавить, что весь канал ведется только для подписчиков. Не только. Мы из подготовки контента тоже многое для себя выносим. Но все-таки мы вещаем на довольно большую аудиторию и будет круто, если мы сможем найти точки соприкосновения с вами.👉🏿👈🏿
С прошлого раза пришло много народу, возможно коньюнктура поменялась. Возможно, вы новичкок в плюсах и хотите посты про базовые вещи, типа для чего нужен std::cout или что за монстр этот ваш std::unordered_map🤓. Возможно, вы хотите чуть разнообразнее контент. Например, нам нравится готовить длинные серии постов на одну тему. Глубокое погружение в материал дает большее понимание темы и нюансов, о которых можно вам рассказать. И нам кажется, что лучше постоянно быть в контексте одной темы, чтобы лучше ее понять и держать в голове все ее грани. И мы скорее всего не перестанем готовить посты в таком духе. Но мы можем периодически разбавлять серии постами на другие темы, если вас не будут смущать большое количество отсылок на предыдущие посты, когда серия будет возвращаться после перерыва.
Если вы хотите, чтобы мы рассказали какую-то тему - тоже пишите.
В общем, пишите все, что думаете. Ну не прям все, нам не обязательно знать, как бы вы сейчас с кайфом бабушкины блинчики с молоком хомячнули🥛🥞. Но по теме пишите все) Мы все читаем и, при достаточной поддержке, ваше предложение будет принято к сведению.
Голосовать за важность чьей либо идеи предлагаем просто пальцами: 👍или👎. Так мы поймем, что действительно важно для нас. Погнали! 👨💻
Build communication with people. Stay cool.
bit_cast
Начиная с C++20 появилась шаблонная функция
В конкретном примере переменные
Аналогичного результата можно добиться и с помощью
В отличие от альтернативных способов, шаблонная функция
Продемонстрирую проблему на примере с
Бывает и так, что изначально некоторые типы были реализованы тривиальными, но затем (в ходе доработок) потеряли такое свойство. Встроенные проверки
Нельзя назвать
Оставляйте реакции, считаете ли вы этот пост полезным для других! А мы, как и всегда, будем рады прочитать ваши комментарии и ответить вопросы 😉
#cppcore #cpp20
Начиная с C++20 появилась шаблонная функция
std::bit_cast
в заголовочном файле <bit>
. Она предоставляет возможность создавать побитовые копии объектов с другим типом:#include <bit>
double src = 42.0;
uint64_t dst = std::bit_cast<uint64_t>(src);
В конкретном примере переменные
dst
и src
имеют одинаковый размер 8 байт, поэтому их содержимое может быть интерпретировано по-разному, в зависимости от типа представления: беззнаковое целое или число с плавающей запятой.Аналогичного результата можно добиться и с помощью
union
или reinterpret_cast
. Однако, это нельзя было сделать в compile time! Функция std::bit_cast
поддерживает constexpr
выражения. Бонусом мы получаем достаточно лаконичное приведение и не нарушаем strict aliasing.В отличие от альтернативных способов, шаблонная функция
std::bit_cast
дополнительно проверяет, что исходный и целевой типы имеют одинаковый размер и могут быть тривиально скопированы. Последнее означает, что память объекта может быть просто скопирована без дополнительных действий и это будет рабочей копией. Если это не так, то такие операции могут нарушать жизненный цикл нового объекта.Продемонстрирую проблему на примере с
std::string
. Объекты данного типа, в общем случае, хранят строку где-то в другом месте, а сами выступают в роли умной оболочки (RAII). Клонирование такого объекта «в лоб» создает потенциально опасную ситуацию: два объекта будут ссылаться на один и тот же уникальный ресурс и пытаться управлять им. Например, они оба попытаются освободить ресурс. У первого объекта это получится, а у второго приведет к ошибке double free: живой пример. Отсюда и вытекает ограничение, что нельзя создавать побитовых клонов нетривиально копируемых объектов. Им необходимо обязательно вызвать конструктор копирования, который выделит собственный ресурс. Бывает и так, что изначально некоторые типы были реализованы тривиальными, но затем (в ходе доработок) потеряли такое свойство. Встроенные проверки
std::bit_cast
тут же сообщат о некорректности работы с таким типом.Нельзя назвать
std::bit_cast
оператором приведения, т.к. эта штука все таки не включена в семантику языка (в отличие от static_cast
, reinterpret_cast
и т.д.) и вынесена в пространство имен библиотеки STL. Однако её стоит упомянуть в текущем цикле статей.Оставляйте реакции, считаете ли вы этот пост полезным для других! А мы, как и всегда, будем рады прочитать ваши комментарии и ответить вопросы 😉
#cppcore #cpp20
const_cast
Оператор приведения
Это достаточно специфичное действие при разработке: удалить cv-спецификатор. Естественно, в большинстве случаев это ведет к UB, т.к. компиляторы ориентируются на cv-спецификатор при генерации инструкций. Будь то предоставление данных только на чтение, привязка к устройству ввода-вывода, запрет на кеширование значений — такие изменения сопряжены с рисками получить неопределенное поведение.
Приведу простой живой пример. Хоть ссылке
В случае с read-only, заставить явно перечитать память переменной можно, например, с помощью
Оправданное использование такого приведения встречается достаточно редко. Это может быть использовано для сохранения совместимости между устаревшими версиями API, когда в действительности данные не изменяются, но этого требует интерфейс.
Я не помню, когда последний раз использовал этот оператор. Единственный раз, когда я его встретил, он был нужен, чтобы наоборот установить const квалификатор. Это, пожалуй, единственное безопасное действие, которое можно им совершить.
#cppcore
Оператор приведения
const_cast
используется для добавления или удаления спецификаторов const
и volatile
:cpp
const int value = 42;
// or
volatile int value = 42;
int &reference = const_cast<int&>(value);
Это достаточно специфичное действие при разработке: удалить cv-спецификатор. Естественно, в большинстве случаев это ведет к UB, т.к. компиляторы ориентируются на cv-спецификатор при генерации инструкций. Будь то предоставление данных только на чтение, привязка к устройству ввода-вывода, запрет на кеширование значений — такие изменения сопряжены с рисками получить неопределенное поведение.
Приведу простой живой пример. Хоть ссылке
reference
и было присвоено значение 24 на соседней строчке, далее значение 42 подставляется напрямую. Казалось бы, это можно отследить на этапе компиляции, но нет! Даже никаких оптимизаций не указано, а компилятор всё равно проигнорирует это действие и подставит константу.В случае с read-only, заставить явно перечитать память переменной можно, например, с помощью
std::launder
(since C++ 17). Но можно ли гарантировать, что везде дальше по коду оно будет перечитываться? Нет, и еще раз нет, особенно при долгой поддержке решения, особенно когда хранители знаний уходят из компании.Оправданное использование такого приведения встречается достаточно редко. Это может быть использовано для сохранения совместимости между устаревшими версиями API, когда в действительности данные не изменяются, но этого требует интерфейс.
Я не помню, когда последний раз использовал этот оператор. Единственный раз, когда я его встретил, он был нужен, чтобы наоборот установить const квалификатор. Это, пожалуй, единственное безопасное действие, которое можно им совершить.
#cppcore
Константная инициализация. Ч1
Это первый шаг, который пытается выполнить компилятор, когда пробует инициализировать переменную. Для него требуется, чтобы инициализатор был константным выражением. То есть его можно было бы вычислить во время компиляции. И не путать с обычным const! Позже покажу почему.
Также гарантируется, что эта инициализация происходит до любой другой инициализации статиков. На практике же компиляторы вообще сразу в бинарь помещают предвычисленное значение объекта и во время выполнения с ним уже ничего не нужно делать. Пример:
С переменной
Точнее немного не так. Она будет проиниализирована последней, но аж 2 раза! Первый раз - zero-инициализацией на этапе компиляции, второй раз - динамической в рантайме.
Чтобы не пустословить по чем зря, покажу вырезки из ассембера, которые подкрепляют мои слова. Вот чего нашел:
Дальше мы переходим к data секции, в которой подряд инициализируются
И в последнюю очередь, уже в рантайме, динамически ини
Это рантаймовая рутина, которая запускается перед вызовом main() и инициализирует
Тут можно довольно простую аналогию провести. Константная инициализаци выполняется для тех объектов, которые можно пометить constexpr, и, не учитывая весь остальной код, компиляция после этого успешно завершится.
Define order of your life. Stay cool.
#cppcore #compiler
Это первый шаг, который пытается выполнить компилятор, когда пробует инициализировать переменную. Для него требуется, чтобы инициализатор был константным выражением. То есть его можно было бы вычислить во время компиляции. И не путать с обычным 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