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

По всем вопросам (+ реклама) @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
​​std::cout

Кажется, что на начальном этапе становления про-с++-ером, вывод в использование конструкции:

std::cout << "Print something in consol\n";


воспринимается, как "штука, которая выводит текст на консоль".

Даже со временем картинка не до конца складывается и на вопрос "что такое std::cout?", многие плывут. Сегодня закроем этот вопрос.

В этой строчке мы вызываем такой оператор:

std::ostream& operator<< (std::ostream& stream, const char * str)


Получается, что std::cout - объект класса std::ostream. И ни какой-то там временный. Раз он принимается по левой ссылке, значит он уже где-то хранится в памяти.

Но мы же ничего не делаем для его создания? Откуда он взялся?

Мы говорили о том, что есть "невидимые" для нас вещи, которые происходят при старте программы. Так вот, это одна из таких вещей.

std::cout - глобальный объект типа std::ostream. За его создание отвечает класс std::ios_base::Init, инстанс которого явно или неявно определяется в библиотеке <iostream>.

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

Полазаем по исходникам gcc. Ссылочки кликабельные для пытливых умов.

А в хэдэре iostream мы можем найти вот это:

extern istream cin;  ///< Linked to standard input
extern ostream cout; ///< Linked to standard output
extern ostream cerr; ///< Linked to standard error (unbuffered)
extern ostream clog; ///< Linked to standard error (buffered)
...
static ios_base::Init __ioinit;


Здесь определяются символы стандартных потоков и создается глобальная переменная класса ios_base::Init. Пойдемте тогда в конструктор:

ios_base::Init::Init()
{
if (__gnu_cxx::__exchange_and_add_dispatch(&_S_refcount, 1) == 0)
{
// Standard streams default to synced with "C" operations.
_S_synced_with_stdio = true;

new (&buf_cout_sync) stdio_sync_filebuf<char>(stdout);
new (&buf_cin_sync) stdio_sync_filebuf<char>(stdin);
new (&buf_cerr_sync) stdio_sync_filebuf<char>(stderr);

// The standard streams are constructed once only and never
// destroyed.
new (&cout) ostream(&buf_cout_sync);
new (&cin) istream(&buf_cin_sync);
new (&cerr) ostream(&buf_cerr_sync);
new (&clog) ostream(&buf_cerr_sync);
cin.tie(&cout);
cerr.setf(ios_base::unitbuf);
// _GLIBCXX_RESOLVE_LIB_DEFECTS
// 455. cerr::tie() and wcerr::tie() are overspecified.
cerr.tie(&cout);
...
__gnu_cxx::__atomic_add_dispatch(&_S_refcount, 1);


Немножко разберем происходящее.

В условии проверяется ref_count, чтобы предотвратить повторную инициализацию. Так как не предполагается, что такие объекты, как cout будут удалены, они просто создаются через placement new с помощью инстансов stdio_sync_filebuf<char>. Это внутренний буфер для объектов потоков, который ассоциирован с "файлами" stdout, stdin, stderr. Буферы как раз и предназначены для получения/записи io данных.

Хорошо. Мы видим как и где создаются объекты. Но это же placement new. Для объектов уже должная быть подготовлена память для их размещения. Где же она?

В файлике globals_io.cc:

 // Standard stream objects.
// NB: Iff <iostream> is included, these definitions become wonky.
typedef char fake_istream[sizeof(istream)]
attribute ((aligned(alignof(istream))));
typedef char fake_ostream[sizeof(ostream)]
attribute ((aligned(alignof(ostream))));
fake_istream cin;
fake_ostream cout;
fake_ostream cerr;
fake_ostream clog;


то есть, объекты - это пустые символьные массивы правильного размера и выравнивания.

Все это должно вам дать довольно полное представление, что такое стандартные потоки ввода-вывода.

#cppcore #compiler
👍61🔥1712🤯6
​​Линкуем массивы к объектам

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

extern std::ostream cout;
extern std::istream cin;
...


А в другой

 // Standard stream objects.
// NB: Iff <iostream> is included, these definitions become wonky.
typedef char fake_istream[sizeof(istream)]
attribute ((aligned(alignof(istream))));
typedef char fake_ostream[sizeof(ostream)]
attribute ((aligned(alignof(ostream))));
fake_istream cin;
fake_ostream cout;
fake_ostream cerr;
fake_ostream clog;


Что за приколы такие? Почему массивы нормально линкуются на объекты кастомных классов?

В С++ кстати запрещены такие фокусы. Типы объявления и определения сущности должны совпадать.

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

// header.hpp
#pragma once

struct TwoFields {
int a;
int b;
};

struct ThreeFields {
char a;
int b;
long long c;
};

// source.cpp

ThreeFields test = {1, 2, 3};

// main.cpp

#include <iostream>
#include "header.hpp"

extern TwoFields test;

int main() {
std::cout << test.a << " " << test.b << std::endl;
}


На консоли появится "1 2". Но ни типы, ни размеры типов, ни выравнивания у объектов из объявления и определения не совпадают. Поэтому здесь явное UB.

Но в исходниках GCC так удачно сложилось, что массивы реально представляют собой идеальные сосуды для объектов io-потоков. На них даже сконструировали реальные объекты. Поэтому такие массивы можно интерпретировать как сами объекты.

Это, естественно, все непереносимо. Но поговорка "спички детям - не игрушка" подходит только для тех, кто плохо понимает, что делает. А разработчики компилятора явно не из этих ребят.

Take conscious risks. Stay cool.

#cppcore #compiler
🔥47🤯10❤‍🔥4👍42
​​Что происходит до main?

Рассмотрим простую программу:

#include <iostream>
#include <random>

int a;
int b;

int main() {
a = rand();
b = rand();
std::cout << (a + b);
}


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

Скомпилировав эту программу, мы сможем посмотреть ее ассемблер и увидеть просто набор меток, соответствующих разным сущностям кода(переменным a и b, функции main). Но вы не увидите какого-то "скрипта". Типа как в питоне. Если питонячий код не оборачивать в функции, то мы точно будем знать, что выполнение будет идти сверху вниз. Так вот, такой простыни ассемблера вы не увидите. Код будет организован так, как будто бы им кто-то будет пользоваться.

И это действительно так! Убирая сложные детали, можем увидеть вот такое:

a:
.zero 4

b:
.zero 4

main:

push rbp
mov rbp, rsp
call rand
...
call std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
mov eax, 0
pop rbp
ret


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

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

💥 Программа загружается в оперативную память.

💥 Аллокация памяти для стека. Для исполнения функций и хранения локальных переменных обязательно нужен стек.

💥 Аллокация памяти для кучи. Для программы нужна дополнительная память, которую она берет из кучи.

💥 Инициализация регистров. Там их большое множество. Например, нужно установить текущий указатель на вершину стека(stack pointer), указатель на инструкции(instruction pointer) и тд.

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

💥 Положить на стек аргументы argc, argv(мб envp). Это аргументы для функции main.

💥 Загрузка динамических библиотек. Программа всегда линкуется с разными динамическими либами, даже если вы этого явно не делаете)

💥 Вызов всякий преинициализирующих функций.

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

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

Она уже выполняет более прикладные чтоли вещи:

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

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

👉🏿 Инициализация стандартного ввода-вывода. Об этом мы говорили тут.

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

И только вот после этого всего, когда состояние программы приведено в соответствие с ожиданиями стандарта С++, функция _start вызывает main.

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

See what's underneath. Stay cool.

#OS #compiler
❤‍🔥44👍1611🔥6👎1🤔1
​​Фикс баги с инициализацией инта

В прошлом посте говорили об одной неприятности при использовании универсальной инициализации интов. При таком написании:

auto i = {0};

i будет иметь тип std::initializer_list<int>.

С++17 исправил такое поведение. Но для полного понимания мы должны определить два способа инициализации: копирующая и прямая. Приведу примеры

  auto x = foo();  // копирующая инициализация
auto x{foo()}; // прямая инициализация,
// проинициализирует initializer_list (до C++17)
int x = foo(); // копирующая инициализация
int x{foo()}; // прямая инициализация

Для прямой инициализации вводятся следующие правила:

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

Примеры:

auto x1 = { 1, 2 }; // decltype(x1) -  std::initializer_list<int> 
auto x2 = { 1, 2.0 }; // ошибка: тип не может быть выведен,
// потому что внутри скобок объекты разных типов
auto x3{ 1, 2 }; // ошибка: не один элемент в скобках
auto x4 = { 3 }; // decltype(x4) - std::initializer_list<int>
auto x5{ 3 }; // decltype(x5) - int


Этот фикс компиляторы реализовали задолго до того, как стандарт с++17 был окончательно утвержден. Поэтому даже с флагом -std=c++11 вы можете не увидеть некорректное поведение. Оно воспроизводится только на древних версиях. Можете убедиться тут.

Fix your flaws. Stay cool.

#cpp11 #cpp17 #compiler
19👍11🔥4❤‍🔥1👎1
​​Как header only либы обходят ODR
#новичкам

В С++ есть одно очень важное правило, которое действует при компиляции и линковке программы. Это правило одного определения. Или One Definition Rule(ODR). Оно говорит о том, что во всей программе среди всех ее единиц трансляции должно быть всего одно определение сущности.

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

Тогда встает вопрос: А как тогда header-only библиотеки обходят это требование? Сами посудите, подключаем какую-нибудь json заголовочную либу, везде ее используем, линкуем программу и все как-то работает. Хотя во многих единицах трансляции есть определение одних и тех же сущностей.

В чем подвох?

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

Сами посмотрите на некоторые примеры: cereal для сериализации, nlohmann для json'ов, почти весь Boost. Там все жестко шаблонами и измазано.

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

В общем, в С++ есть много средств обхода ODR и ими всеми активно пользуются header-only библиотеки.

Bypass the rules. Stay cool.

#compiler #design
🔥19👍104👏1
​​Дедлокаем один поток
#опытным

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Be self-sufficient. Stay cool.

#concurrency #cppcore #compiler
👍17🔥85😁5🤣43
​​No new line

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

Небольшой пример:

Файлик foo.hpp:

// I love code
// I love C++<no newline>

Файлик bar.cpp:

#include "foo.hpp"
#include "baz.hpp"


А теперь вспоминаем, что препроцессор вставляет все содержимое хэдера на место инклюда И(!) не вставляет после него символ конца строки. То есть спокойно может получится следующее:

// I love code
// I love C++#include "baz.hpp"


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

Стандарт нам говорит:
... If a source file that is not empty does not end in a new-line character,
or ends in a new-line character immediately preceded by a backslash
character before any such splicing takes place, the behavior is undefined.


Так что ub без кода - вполне существующая вещь.

Или уже нет?

На самом деле приведенная цитата была из стандарта 2003 года.

С++11 пофиксил эту проблему и обязал препроцессоры вставлять new line в конце подключаемых файлов:

A source file that is not empty and that does not end in a new-line character, 
or that ends in a new-line character immediately preceded by a backslash
character before any such splicing takes place, shall be processed
as if an additional new-line character were appended to the file.


Так что теперь проблемы нет.

Решил написать об этом, просто потому что очень весело, что в плюсах можно было такими неочевидными способами отстрелить себе конечность.

Ну и хорошо, что стандарт все-таки не только новую функциональность вводит, а фиксит вот такие вот недоразумения.

Fix your flaws. Stay cool.

#compiler
👍4910🔥9🤯32
Передача объекта в методы по значению
#опытным

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

С помощью deducing this мы можем вызывать методы не для ссылки(под капотом которой указатель), а для значения объекта.

Семантика будет ровно такая, как вы ожидаете. Объект скопируется внутрь метода и все операции будут происходить над копией.

Давайте посмотрим на пример:

struct just_a_little_guy {
int how_small;
int uwu();
};

int main() {
just_a_little_guy tiny_tim{42};
return tiny_tim.uwu();
}


Здесь используется старая нотация с неявным this.

Посмотрим, какой код может нам выдать компилятор:

sub     rsp, 40                           
lea rcx, QWORD PTR tiny_tim$[rsp]
mov DWORD PTR tiny_tim$[rsp], 42
call int just_a_little_guy::uwu(void)
add rsp, 40
ret 0


Пройдемся по строчкам и посмотрим, что тут происходит:

- первая строчка аллоцирует 40 байт на стеке. 4 байта для объекта tiny_tim, 32 байта теневого пространства для метода uwu и 4 байта паддинга.
- инструкция lea загружает адрес tiny_tim в регистр rcx, в котором метод uwu ожидает свой неявный параметр.
- mov помещает число 42 в поле объекта tiny_tim.
- вызываем функцию-метод uwu
- наконец деаллоцируем памяти и выходим из main

А теперь применим deducing this с параметром по значению и посмотрим на ассемблер:

struct just_a_little_guy {
int how_small;
int uwu(this just_a_little_guy);
};


Ассемблер:

mov     ecx, 42                           
jmp static int just_a_little_guy::uwu(this just_a_little_guy)


Мы переместили 42 в нужный регистр и сразу же прыгнули в функцию uwu, а не вызвали ее. Поскольку мы не передаем объект в метод по ссылке, нам ничего не нужно аллоцировать на стеке. А значит и деаллоцировать ничего не нужно. Раз нам не нужно за собой подчищать, то можно просто прыгнуть в функцию и не возвращаться оттуда.

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

Optimize yourself. Stay cool.

#cpp23 #optimization #compiler
18🔥14👍7❤‍🔥3
Неочевидное преимущество шаблонов
#новичкам

Давайте немного разбавим рассказ о фичах 23-го стандарта чем-нибудь более приземленным

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

Можно и обойтись. Возьмем хрестоматийный пример std::qsort. Это скоммунизденная реализация сишной стандартной функции qsort. Сигнатура у нее такая:

void qsort( void *ptr, std::size_t count, std::size_t size, /* c-compare-pred */* comp );
extern "C" using /* c-compare-pred */ = int(const void*, const void*);
extern "C++" using /* compare-pred */ = int(const void*, const void*);


Как видите, здесь много void * указателей на void. В том числе с помощью него достигается полиморфизм в С(есть еще макросы, но не будем о них).

Как это работает?

Функция qsort спроектирована так, чтобы с ее помощью можно было сортировать любые POD типы. Но не хочется как-то пеерегружать функцию сортировки для всех потенциальных типов. Поэтому придумали обход. Передавать void указатель, чтобы мочь обрабатывать данные любых типов. Но void* - это нетипизированный указатель, поэтому фунции нужно знать размер типа данных, которые она сортирует, и количество данных. А также предикат сравнения.

Вот тут немного поподробнее. Предикат для интов может выглядеть примерно так:

[](const void* x, const void* y)
{
const int arg1 = *static_cast<const int*>(x);
const int arg2 = *static_cast<const int*>(y);
const auto cmp = arg1 <=> arg2;
if (cmp < 0)
return -1;
if (cmp > 0)
return 1;
return 0;
}


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

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

Тип шаблонных параметров, напротив, известен на этапе компиляции.

template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp );


Значит код компаратора шаблонной функции может быть включен в код сортировки. Именно поэтому функция std::sort намного быстрее std::qsort при включенных оптимизациях(а без них примерно одинаково)

Казалось бы плюсы, а быстрее сишки. И такое бывает, когда используешь шаблоны.

Use advanced technics. Stay cool.

#template #goodoldc #goodpractice #compiler
50🔥35👍952👎1
Динамический полиморфизм: разделяемые библиотеки
#опытным

В тему указателей на функции вкину еще один способ реализации полиморфизма в С++ - разделяемые или динамические библиотеки.

Обычно разделяемые библиотеки загружаются на самом старте программы(какие-нибудь libc и libstdc++ например неявно подгружаются на старте). Основную часть таких библиотек мы прописываем в опциях линковки.

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

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

Тут пример будет довольно длинный, поэтому начнем с начала.

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

class PluginInterface {
public:
virtual int method() = 0;
};

extern "C" PluginInterface* create_plugin();

extern "C" void destroy_plugin(PluginInterface* obj);


Эта команда берет и реализует этот интерфейс:

#include "PluginInterface.hpp"
#include <iostream>

class MyPlugin : public PluginInterface {
public:
virtual void method() override;
};

int MyPlugin::method() {
std::cout << "Method is called\n";
return 42;
}

extern "C" PluginInterface* create_plugin() {
return new MyPlugin();
}

extern "C" void destroy_plugin(PluginInterface* obj) {
delete obj;
}


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

Функции create_plugin и destroy_plugin обязаны иметь сишную линковку, чтобы достать указатели на них по их имени из библиотеки с помощью dlsym:

#include "PluginInterface.hpp"
#include <dlfcn.h>
#include <iostream>

typedef PluginInterface *(*creatorFunction)();
typedef void (*destroyerFunction)(PluginInterface *);

int main() {
void *handle = dlopen("myplugin.so", RTLD_LAZY);
if (!handle) {
std::println("dlopen failure: {}", dlerror());
return 1;
}
creatorFunction create = reinterpret_cast<creatorFunction>(dlsym(handle, "create_plugin"));
destroyerFunction destroy = reinterpret_cast<destroyerFunction>(dlsym(handle, "destroy_plugin"));

PluginInterface *plugin = (*create)();
std::println("{}", plugin->method());
(*destroy)(plugin);
dlclose(handle);
}


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

Разве по имени функции можно получить указатель на нее? Похоже на какую-то рефлексию с первого взгляда.

Тут дело в именах функций и отображении их в символы бинарного файла при компиляции. В С нет никакого манглинга имен, поэтому в готовом бинарном файле можно найти символ, соответствующий названию функции, и связанный с ним адрес этой фукнции. Именно поэтому create_plugin и destroy_plugin помечены extern "C", чтобы их имена обрабатывались по правилам С.

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

Choose the right name. Stay cool.

#cppcore #OS #compiler
1029👍9🔥8
#include <filename> vs #include "filename"
#новичкам

Тот нюанс, который зеленые программисты С++ впитывают из окружающей среды, но зачастую не понимают его деталей.

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

Стандарт нам дает отписку, что поведение при обоих подходах implementation defined, поэтому надо смотреть на то, как ведут себя большинство компиляторов.

Для начала: #include - это директива препроцессора. На месте этой директивы на этапе препроцессинга вставляется тело включаемого файла. Но для того, чтобы вставить текст файла, его надо в начале найти. И вот здесь главное различие.

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

Так вот #include <filename> в начале ищет файл в системных директориях. Например, в линуксе хэдэры устанавливаемых библиотек могут оказаться в директориях /usr/include/, /usr/local/include/ или /usr/include/x86_64-linux-gnu/(на x86 системах).

А #include "filename" в начале ищет файл в текущей директории и в директориях, предоставленных через опцию компиляции.

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

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

Используем #include <filename> для включения стандартных файлов и хэдэров сторонних библиотек. Так как они скорее всего установлены по стандартным директориям, логично именно там начинать их поиск.

#include <stdio.h> // Стандартный заголовочник
#include <curl/curl.h> // Заголовочник из системной директории

int main(void) {
CURL *curl = curl_easy_init();
if(curl) {
printf("libcurl version: %s\n", curl_version());
curl_easy_cleanup(curl);
}
return 0;
}


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

// include/mylib.hpp - объявляем функцию из нашего проекта
#pragma once
void print_hello();

// src/main.cpp - используем локальный заголовочник через " "
#include <iostream> // Системный заголовочник
#include "mylib.hpp" // Заголовочник локального проекта, ищем в указанных путях

int main() {
print_hello();
return 0;
}

void print_hello() {
std::cout << "Hello from my project!\n";
}

// компиляция: g++ -Iinclude/ src/main.cpp -o my_program -std=c++17


See the difference. Stay cool.

#cppcore #compiler
161👍25🔥9
​​Zero-Cost Abstractions
#новичкам

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

Абстракции упрощают программирование. А что с перфомансом?

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

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

Однако есть и такие абстракции, которые не накладывают оверхэд на рантайм! Они называются Zero-Cost абстракциями.

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

За примерами долго ходить не надо. Можно сложить элементы вектора руками, а можно использовать std::accumulate:

void foo(std::vector<int>& vec) {
int sum = 0;
for(int i = 0; i < vec.size(); i++) {
sum += vec[i];
}
std::cout << sum;
}

void bar(std::vector<int>& vec) {
int sum = std::accumulate(vec.begin(), vec.end(), 0);
std::cout << sum;
}


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

На каких китах стоит возможность использовать "бесплатные" абстракции в С++?

🐳 Полиморфизм времени компиляции, compile-time вычисления и метапрограммирование. Тут все просто: переносим вычисления с рантайма в компайл тайм с помощью шаблонов, constexpr и меты и радуемся жизни. Параллельно можно еще и кофеек себе заваривать, пока проект билдится.

🐳 Инлайнинг. Одна из основных оптимизаций компилятора. Позволяет встраивать код функции в вызывающий код. С помощью инлайнинга 4 вложенных вызова функций могут превратиться в одну сплошную портянку низкоуровневого кода без дорогостоящих инструкций call.

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

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

Don't pay for abstraction. Stay cool
#cppcore #compiler
👍3315🔥6😁3
Unity build
#опытным

Чем знаменит С++? Конечно же своим гигантским временем сборки программ. Пока билдится плюсовый билд, где-то в Китае строится новый небоскреб.

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

Компиляция всяких шаблонов сама по себе долгая, особенно, если использовать какие-нибудь рэнджи или std::format. Но помните, что конкретная инстанциация шаблона будет компилироваться независимо в каждой единице трансляции. В одном цппшнике использовали std::vector<int> - компилируем эту инстанциацию. В другом написали std::vector<int> - заново скомпилировали эту инстанциацию. То есть большая проблема в компиляции одного и того же кучу раз.

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

Обе эти проблемы можно решить одним махом - просто берем и подключаем все цппшники в один большооой и главный цппшник. И компилируем только его. Такой себе один большой main. Такая техника называется Unity build (aka jumbo build или blob build)

Условно. Есть у вас 2 цппшника и один хэдэр:

// header.hpp
#pragma once
void foo();

// source1.cpp
#include "header.hpp"
void foo() {
std::cout << "You are the best!" << std::endl;
}

// source2.cpp
#include "header.hpp"
int main() {
foo();
}


Вы все цппшники подключаете в один файл unity_build.cpp:

#include "source1.cpp"
#include "source2.cpp"


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

Или нет?

У этой техники есть ряд недостатков:

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

Потенциальные конфликты имен. Конфликты статических переменных и функций с одинаковыми именами в разных файлах, конфликты символов из анонимных namespace'ов, неожиданное разрешение перегрузки функций - все это может подпортить вам жизнь.

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

У кого был опыт с unity билдами, отпишитесь по вашим впечатлениям.

Solve the problem. Stay cool.

#cppcore #compiler #tools
122🔥7👍6😁2🗿1
​​Распределенные компиляторы
#опытным

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

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

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

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

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

Наиболее известный представитель систем распределенной компиляции с открытым исходным кодом — это distcc. Он состоит из демона-сервера, принимающего задания на сборку по сети, и обёртки (wrapper) для компилятора, которая распределяет задания по доступным узлам сборки в сети.

Вот примерная схема его работы(у кого-то может некорректно отображаться, ничего поделать не можем):

┌─────────────────┐    ┌─────────────────────────────────┐
│ Клиентская │ │ Ферма компиляции │
│ машина │ │ │
│ │ │ ┌───────┐ ┌───────┐ ┌───────┐│
│ ┌─────────────┐│ │ │Worker │ │Worker │ │Worker ││
│ │ Координатор │◄──────►│ 1 │ │ 2 │ │ N ││
│ └─────────────┘│ │ └───────┘ └───────┘ └───────┘│
│ │ │ │
│ ┌─────────────┐│ │ ┌─────────────────────────────┐│
│ │ Кэш .o ││ │ │ Distributed Cache ││
│ │ файлов │◄──────►│ ││
│ └─────────────┘│ │ └─────────────────────────────┘│
└─────────────────┘ └─────────────────────────────────┘

- Координатор анализирует зависимости между файлами и распределяет задачи компиляции

- Компиляционные ноды выполняют фактическую компиляцию, их набор конфигурируется на клиенте

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

Этапы работы примерно такие:

1️⃣ Все цппшники проекта проходят этап препроцессинга на локальной машине и уже в виде единиц трансляции перенаправляются на ноды компиляции.

2️⃣ Ноды компиляции преобразуют единицы трансляции в объектные файлы и пересылают их на клиентскую машину.

3️⃣ Последним этапом идет бутылочное горлышко всей системы - линковка. Для нее необходим доступ ко многим объектым файлам одновременно и эта задача слабо параллелится, поэтому и выполняется на клиентской машине.

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

Speed up processes. Stay cool.

#compiler #tools
1👍249🔥8
​​ccache
#опытным

Еще один полезный и простой во внедрении инструмент для ускорения компиляции - ccache.

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

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

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

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

👉🏿 идентификатор компилятора
👉🏿 использованные флаги компилятора
👉🏿 содержимое входного исходного файла,
👉🏿 содержимое подключаемых заголовочных файлов (и их транзитивное замыкание).

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

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

Если распределенный компилятор distcc каждый раз выполняет препроцессинг, то для ccache это не обязательно. В одном из режимов работы ccache вычисляет хэши MD4 для каждого включаемого заголовочного файла отдельно и сохраняет результаты в так называемом манифесте. Поиск в кеше выполняется путём сравнения хэшей исходного файла и всех его включений с содержимым манифеста; если все хэши попарно совпадают, мы имеем попадание. В текущих версиях ccache прямой режим включён по умолчанию.

Для того, чтобы начать пользоваться ccache, достаточно его установить, добавить в PATH и в cmake'е прописать CMAKE_CXX_COMPILER_LAUNCHER=ccache. Это можно сделать и через команду запуска, и через установку переменной окружения. Вот вам ссыль.

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

Compile fast. Stay cool.

#compiler #tools
1👍2112🔥9🤯1
​​ccache vs cmake
#опытным

И давайте раскроем очевидный вопрос: чем кэширование ccache отличается от кэширования самого cmake'а? Ведь при искрементальной сборке cmake пересобирает только те файлы, которые поменялись.

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

Проще понять разницу на примерах:

🔍 Вы скомпилировали проект и случайно или специально(бывает нужно, если cmake троит) удалили папку с билдом. Без ccache нужно перекомпилировать все, а с ним - ничего, только линковку сделать.

🔍 Вы плотно работаете с несколькими ощутимо отличающимися бранчами, собираете, коммитите и переключаетесь. Без ccache нужно будет перекомпилировать все измененные при переключении бранчей файлы. С ccache - только те, которые вы сами изменили после последней сборки.

🔍 Если вы правите только комменты в файле, то голый cmake пойдет перекомпилировать его. ccache - нет, потому что работает с препроцесснутым файлом.

🔍 Вы активно переключаетесь между конфигурациями при сборке. Например между релизом и дебагом. cmake будет полностью пересобирать проект при изменении типа билда. А ccahce после одной сборки на каждую конфигурацию все запомнит и вы будете компилировать только последние изменения.

Not much, но каждый из нас частенько сталкивается с одним из этих пунктов. Поэтому ставьте ccache. Это сделать просто, но импакт дает ощутимый в определенных кейсах.

Compile fast. Stay cool.

#compiler #tools
133👍14🔥8
​​Быстрые линкеры
#опытным

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

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

С ld можно бесшовно перейти на другой совместимый компоновщик с помощью опции -fuse-ld. То есть буквально:

g++ -fuse-ld=<my_linker> main.cpp -o program


И ваша программа будет собираться с помощью my_linker. Ну или в cmake:

# Установка линкера для всего проекта
set(CMAKE_LINKER my_linker)

# Или для конкретной цели
target_link_options(my_target PRIVATE "LINKER:my_linker")


Какие альтернативные компоновщики существуют?


GNU Gold. Еще один официальный линковщик из пакета GNU. Создавался как более быстрая альтернатива ld для линковки ELF файлов. Он действительно быстрее ld, но теперь его уже никто не поддерживает и недавно в binutils задепрекейтили его.

lld (LLVM Linker). Линковщик от проекта llvm. Активно развивается и имеет интерфейсную совместимость с дефолтовым ld, как и clang имеет в gcc. Быстрее Gold.

mold. Или modern linker. В несколько раз быстрее lld и является самым быстрым drop-in опенсорсным линковщиком. Он использует более оптимизированные структуры данных и каким-то образом линкует в параллель! Благодаря этому достигается фантастическая скорость работы.

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

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

Be faster. Stay cool.

#tools #compiler
3🔥3715👍10❤‍🔥42
​​Самая надежная гарантия отсутствия исключений
#опытным

Исключения не любят не только и не столько потому, что они нарушают стандартный поток исполнения программы, могут привести к некорректному поведению системы и приходится везде писать try-catch блоки. Исключения - это не zero-cost абстракция. throw требуют динамические аллокации, catch - RTTI, а в машинном коде компилятор обязан генерировать инструкции на случай вылета исключений. Плюс обработка исключений сама по себе медленная.

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

Но можно решить проблему накорню. Так сказать отрезать ее корешок под самый корешок.

Есть такой флаг компиляции -fno-exceptions. Он запрещает использование исключений в программе. Но что значит запрет на использование исключений?

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

int main() {
throw 1; // even this doesn't compile
}


👉🏿 Ошибка компиляции при попытке обработать исключение. Ну а че, если вы живете в мире без исключений, зачем вам их обрабатывать?

int main() {
// even this doesn't compile
try {
} catch(...) {
}

}


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

std::map<int, int> map;
std::cout << map.at(1) << std::endl;


Моментальное завершение работы
. Оно как бы и понятно. Метод мапы at() кидает std::out_of_range исключение, если ключа нет в мапе. Обрабатывать исключение нельзя, поэтому чего вола доить, сразу терминируемся. И никакой вам раскрутки стека и graceful shutdown. Просто ложимся и умираем, скрестив ручки.

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

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

Как тогда код писать? А об этом через пару постов.

Handle errors. Stay cool.

#cppcore #compiler
👍2511🔥6😁3❤‍🔥2🤔1
​​Как стандартная библиотека компилируется с -fno-exceptions?
#опытным

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

Ответ прост. Макросы, товарищи. Вся магия в них. Вот на что заменяется обработка исключений:

#if __cpp_exceptions
# define __try try
# define __catch(X) catch(X)
# define __throw_exception_again throw
#else
# define __try if (true)
# define __catch(X) if (false)
# define __throw_exception_again
#endif


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

Ну и для большинства классов, унаследованных от exception, существуют соответствующие функции с C-линковкой:

#if __cpp_exceptions
void __throw_bad_exception()
{ throw bad_exception(); }
#else
void __throw_bad_exception()
{ abort(); }
#endif


Тогда любая функция, которая бросает исключения должна триггерить std::abort. Или нет?

Нет. Вот примерчик.

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

Чтобы это исправить, можно собрать ее с запретом исключений. Примерно так:

git clone git://gcc.gnu.org/git/gcc.git
cd gcc
git checkout <target_release_tag>
./configure
--disable-libstdcxx-exceptions
CXXFLAGS="-fno-exceptions <all_flags_that_you_need>"

make -j$(nproc)
make install


Тогда у вас действительно всегда будет вызываться abort. Потому что все эти макросы также находятся в сорс файлах.

Extend your limits. Stay cool.

#compiler
14👍9🔥9❤‍🔥3🤔2
​​Что не так с модулями?
#опытным

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

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

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

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

Короткий пример:

// math.cppm - файл модуля
export module math; // Объявление модуля

import <vector>; // Импорт, а не включение

// Макросы НЕ экспортируются!
#define PI 3.14159

// Явный экспорт - только то, что нужно
export double calculate_circle_area(double radius);

// Внутренние функции скрыты
void internal_helper();


и его использование:

// main.cpp - обычный С++ файл
import math; // Импорт интерфейса, не всего кода

// Используем экспортированную функцию
double area = calculate_circle_area(10);

// internal_helper(); // ERROR! функция скрыта
// double x = PI; // ERROR! макросы не экспортируются


Модули призваны решать следующие проблемы:

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

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

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

На словах - прекрасные плюсы будущего. Но на словах мы все Львы Толстые, а на деле...

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

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

😡 Бинарный формат модулей нестандартизирован. Каждый компилятор выдумывает свое представление, которое несовместимо между компиляторами или даже версиями одного компилятора.

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

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

😡 Ускорение компиляции может неоправдать затрат. В среднем ускорение составляет порядка 30%. И это просто не стоит усилий.

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

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

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

Use new features. Stay cool.

#cppcore #compiler #tools
16👍6🔥5