C++-трюки из userver
Всем привет! Всем доброе утро! Спасибо, что пришли на первый доклад. Уверен, для многих из вас это было нелегко. Давайте знакомиться. Меня зовут Василий Куликов, я являюсь экспертом по нашему первому докладу "Трюки в userver".
Вообще, поднимите руку, кто хоть что-нибудь слышал про сервер, что-нибудь смотрел, знает, пробовал? Руки есть? Но на всякий случай скажу для тех, кто ничего не слышал про сервер.
userver — это такой фреймворк, написанный на языке C++, предназначенный для написания высокопроизводительных, отказоустойчивых серверных приложений на языке C++.
Сервер открыт, то есть вы можете зайти на GitHub, посмотреть сегодняшние примеры, попробовать и, может быть, остаться.
Сервер у нас активно применяется в Яндексе, то есть во многих бизнес-юнитах: в Яндекс.Такси, в Яндекс.Доставке, в еде, в лавке, в банке и во многих других. То есть это уже такое мейнстрим-решение для серверных приложений.
Давайте сразу перейдем к докладу. Я представляю вашему вниманию Антона Полухина, нашего спикера сегодняшнего. Антон является членом международного комитета по стандартизации C++ в ISO от России, является членом стандартной библиотеки C++ и автором многих новых библиотек. Он исполняет многие другие интересные и полезные функции в сообществе. Ну, например, выступает на такого рода мероприятиях. Так что давайте поприветствуем Антона с сегодняшним докладом.
И напомню, что вы можете задавать свои вопросы после доклада. Есть QR-код, связанный с этим докладом, и обязательно оценивайте этот доклад, ставьте звездочки. Итак, мы начинаем. Тебе слово.
Всем с добрым утром! Я тут собираюсь рассказать про всякие прикольные веселые штучки из C++, которые мы используем в повседневной нашей работе.
Я тут недавно вспомнил, что я уже что-то подобное рассказывал года 3-4-5 назад, в общем, давно. Тогда мы еще были только в Яндекс.Такси, и доклад назывался "C++ трюки из Такси". Я там рассказывал, как сделать pimpl без динамической локации, как прикольные точки кастомизации.
С тех пор утекло много воды, мы теперь Яндекс Go, а это значит, что мы Яндекс.Лавка и Яндекс.Доставка, такси и все, что еще Вася там говорил. И еще тот фреймворк, о котором я рассказывал тогда, теперь доступен в open source.
Где-то полгодика назад мы вышли в open source, и все, о чем я рассказываю, можно взять, самим посмотреть, пощупать, воспользоваться в своих проектах. Надеюсь, вам понравится. Если что, заходите на сайт userver.tech, там и документация, и исходники, и все шаблоны, чтобы завести свои микросервисы.
Ну а мы начинаем. Сегодня я расскажу о том, как система типов издевается над разработчиками, и потом мы поиздеваемся над ней в ответ. После чего будет душераздирающая история о том, как больно жить с C++. Что такое ODR Violation и о том, как приложение может само себя диагностировать на ODR Violation, если сильно постараться. Почти под конец будет рассказ про очень чудную бимапу, которая невероятно быстрая, от которой размер бинарника становится меньше, чем если бы мы использовали unordered_map. И при этом она немного чудная, потому что ее размер всегда один байт, сколько бы данных мы туда не вставляли. Ну и на самый напоследок бонус -- большая загадка про shared pointer. Так погнали!
Полгода назад мы столкнулись с тем, что нам пришлось работать с C++17 библиотекой сторонней. Там, кажется, что-то связанное с gRPC было, и библиотека эта была несколько своеобразной. Там был какой-то класс Something, и у него в приватной секции находилось что-то типа std::mutex, то есть что-то, что нельзя ни копировать, ни перемещать.
Получается, что класс Something тоже нельзя копировать, нельзя перемещать. Нет ни конструктора, ни move-саймона, вообще ничего нету. И даже нет обычных публичных конструкторов, всё там в приватной секции. Этот класс Something можно было создать только через фабричный метод Create, и на этом все.
1#include <mutex>
2#include <vector>
3
4namespace third_party_lib {
5    class Something {
6    public:
7        static Something Create(std::size_t value);
8
9        // ...
10
11    private:
12        std::mutex mutex_;
13    };
14}
Ну, есть такой класс, и есть. Ладно, надо с ним работать. При этом нам нужно было много экземпляров такого класса. Мы взяли std::list от этих Something, назвали переменную clients, и этих клиентов мы создаем там 42 штучки в цикле:
1class Component {
2public:
3    Component() {
4        constexpr std::size_t kClientsCount = 42;
5        for (std::size_t i = 0; i < kClientsCount; ++i) {
6            clients.emplace_back(
7                third_party_lib::Something::Create(i);
8            );
9        }
10    }
11
12    // ...
13
14private:
15    std::list<third_party_lib::Something> clients_;
16};
Ну и как не сложно догадаться, если мы пытаемся переместить класс, который не перемещаемый, компилятор говорит нам, что мы не правы:
ОК, а как тогда вообще авторы этого класса предполагали, что им будут пользоваться? Почему там есть какая-то функция Create, и она как бы вроде как должна работать, но почему-то не работает? А ответ тут заключается в том, что библиотека рассчитана на C++17, и все C++17 есть правила, которые называются guaranteed copy elision.
1error: no matching function for call to
2'construct_at(third_party_lib::Something*&, third_party_lib::Something)'
3  518 |           std::construct_at(__p, std::forward<_Args>(__args)...);
4      |           ~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Если человеческим языком его объяснять, оно звучит следующим образом: если у вас есть объект, которому нельзя обратиться по имени, например, из функции возвращается вот Something:
Объект? Имени у него нет? Нету. Такие объекты называются prvalue. И когда идет вызов конструктора от prvalue, и тип prvalue совпадает с типом того, что мы конструируем, конструктор вызывать вообще не надо. Он даже если написан, вызов этого конструктора, он не происходит, и вот это prvalue, это значение проходит через все конструкторы, не вызывая их, и материализуется прямо в итоговую переменную.
1Something Create(); // prvalue
Загвоздка в том, что когда мы пытаемся использовать этот Create с emplace_back, emplace_back принимает что-то по правой ссылке, и у этого что-то теперь есть имя. Объект перестает быть prvalue.
Теперь, если мы пытаемся от него вызвать конструктор, компилятор будет просить, чтобы этот конструктор действительно присутствовал. Вот есть имя внутри листа, внутри функции emplace_back есть код, где там вызывается placement new оператор, но уже провоцированную, но вызывается конструктор Something от v, и в этом месте компилятор-то и скажет, что мы не правы:
1auto list::emplace_back(Something&& v) { // не prvalue
2    // ...
3    new (&node.value) Something(v);      // v - не prvalue, Something(v) - prvalue
4    // ...
5}
Если поменяем Something на шаблонный аргумент, как это сделано в стандартной библиотеке (все ближе и ближе к тому, что в стандартной библиотеке), то есть в сигнатуре функции emplace_back, то ничего не поменяется, и v все еще не prvalue, Something от v потребует наличие конструктора:
1template <typename Arg>
2auto list::emplace_back(Arg&& v) {  // не prvalue
3    // ...
4    new (&node.value) Something(v); // v - не prvalue, Something(v) - prvalue
5    // ...
6}
Но Антон Жилин из моей команды придумал следующий интересный хак, который назвал LazyPrvalue:
Это класс, у него есть в приватной части функция, он этой функцией инициализируется. И у этого класса есть оператор явного преобразования. Внутри этого оператора вызывается функция, и происходит чудо: вызываем функцию, функция возвращает объект, у объекта нет имени, это prvalue. Даже если написать какие-то конструкторы, компилятор их отбросит. Результат этого prvalue возвращается из оператора неявного преобразования, там все еще нету имени у объекта, это все еще prvalue, и все чудесным образом попадает внутрь emplace_back.
1namespace utils {
2    template <typename Func>
3    class LazyPrvalue final {
4    public:
5        constexpr explicit LazyPrvalue(Func&& func) : func_(std::move(func)) {}
6
7        LazyPrvalue(LazyPrvalue&&) = delete;
8        LazyPrvalue& operator=(LazyPrvalue&&) = delete;
9
10        constexpr /* implitic */ operator std::invoke_result<Func&&>() && {
11            return std::move(func_)();
12        }
13
14    private:
15        Func func_;
16    };
17}
То есть вот в этом месте, где вызывается placement new, конструктор от Something, вместо v используем LazyPrvalue:
Передаем LazyPrvalue в emplace_back, оно идет через все внутренности emplace_back, доходит до того места, где пытаются создать объект Something от v, вызывается оператор неявного преобразования, он возвращает prvalue, конструктор от prvalue звать не надо, и Something материализовался прямо в node. Красота!
1template <typename Arg>
2auto list::emplace_back(LazyPrvalue&& v) { // не prvalue
3    // ...
4    new (&node.value) Something(v);        // v - не prvalue, Something(v) - prvalue
5    // ...
6}
Таким образом, вот этот вот не компилирующийся код с помощью LazyPrvalue можно превратить в компилирующийся код. Добавляем в LazyPrvalue внутрь в конструктор Something::Create , и готово, все работает:
1class Component {
2public:
3    Component() {
4        constexpr std::size_t kClientsCount = 42;
5        for (std::size_t i = 0; i < kClientsCount; ++i) {
6            clients.emplace_back(
7                utils::LazyPrvalue([i]() {
8                    return third_party_lib::Something::Create(i);
9                })
10            );
11        }
12    }
13
14    // ...
15private:
16    std::list<third_party_lib::Something> clients_;
17};
Казалось бы, достаточно редкие случаи, когда нужен LazyPrvalue, но спустя какое-то время мы столкнулись с еще неприятной библиотекой. Тоже какая-то библиотека, в ней есть тоже класс Some, и у него тоже есть что-то наподобие мьютекса. У этого класса уже, правда, наконец-таки есть нормальный конструктор, и с ним тоже надо работать.
1#include <mutex>
2#include <vector>
3
4namespace third_party_lib {
5
6class Some {
7public:
8    explicit Some(std::size_t value);
9
10    // ...
11
12private:
13    std::mutex mutex_;
14};
15
16}
Этот конструктор не дефолтный, он принимает какой-то аргумент.
1explicit Some(std::size_t value);
Опять используем std::list от клиентов. В этот раз на рантайме нам на вход передается количество клиентов, которые надо создать, количество инстансов этого класса Some, которые надо держать в списке. И каждый из инстансов Some мы инициализируем какой-то там константой 100500, и все работает, все компилируется, все хорошо.
1class Component {
2public:
3
4    Component(std::size_t count) {
5        for (std::size_t i = 0; i < count; ++i) {
6            clients.emplace_back(100500);
7        }
8    }
9
10    // ...
11private:
12    std::list<third_party_lib::Some> clients_;
13};
Но хочется чуть лучше, потому что std::list — не самый оптимальный класс стандартной библиотеки. Он хранит элементы, раскиданные хаотично в динамической памяти. Это не дружелюбно к кэшам процессора. Кэшу процессора приятнее, если все элементы находятся рядом. Также у листа есть недостаток в том, что мы не можем за константное время обратиться к i-му элементу клиента, а нам это может быть очень нужно, например, для балансирования нагрузки между этими клиентами.
1private:
2    std::list<third_party_lib::Some> clients_;
Хочется что-то более шустрое, например, std::vector.
1private:
2    std::vector<third_party_lib::Some> clients_;
Но как только мы заменим std::list на std::vector, компилятор опять начнет ругаться, что "извини, человек, ты хочешь конструктор позвать, а move-конструктора-то у типа нет".
1error: static assertion failed: result type must be constructible from input type
2    90 |      static_assert(is_constructible<_ValueType, _Tp>::value,
3       |                                                       ^~~~~
Что можно сделать, какие контейнеры нам подходят? Вектор не подходит, он требует перемещаемости типа. Почему? Вектор проаллоцировал кусок памяти на 4 элемента. Мы вызываем push_back, все хорошо, размещаются в этом куске памяти элементы. Но когда мы попытаемся в вектор вставить пятый элемент, он в этот кусок памяти не поместится. Вектору нужно проаллоцировать новый большой кусок памяти и из старого куска памяти переместить элементы в новый кусок памяти, или скопировать, в зависимости от разных факторов. Собственно, в этом месте вектору и нужен move-конструктор или copy-конструктор, а у нас класс Some его не имеет. Плохо.
Также для данной задачи вектор не очень приколен тем, что он хранит capacity. Мы в задаче хотим создать массив один раз, проинициализировать этих клиентов и больше их не удалять, не добавлять новых — capacity не нужен.
Попробуем заменить вектор на std::unique_ptr от массива заданного на этапе компиляции размера. Тут проблема в том, что размер задан на этапе компиляции, а мы его не знаем. А еще у std::unique_ptr есть неприятность — нет поэлементной инициализации. Класс Some инициализируется с помощью аргумента 100500, а у нас нет возможности этот 100500 в конструктор std::unique_ptr подставить. Плохо.
1std::unique<ptr<T[N]>
Если взять std::unique_ptr от массива размером, известным на рантайме. Становится чуть получше, но не сильно. Такой std::unique_ptr не помнит свой размер, а мы бы хотели по клиентам итерироваться, получать количество этих клиентов. И его где-то надо будет их прикапывать. И все та же проблема — нет поэлементной инициализации.
1std::unique<ptr<T[]>
Стандартные контейнеры не подходят. Сделаем свой, который не требует перемещаемости от типа, размер задается на рантайме, хранит только size и указатель на данные, может инициализировать все элементы одним и тем же значением или одними теми же значениями, и даже для самых сложных и стремных случаев, чтобы он мог инициализировать элементы из функции, в которую мы передаём индекс элемента, который инициализируется.
Как ни странно, такой контейнер очень легко написать, при этом он будет continuous layout, то есть как и вектор, эффективный для кэша процессора, память будет минимально кушать.
Назвали мы этот класс FixedArray. Он очень простой, у него там наборы typedef-ов, дефолтный конструктор и все те функции, что есть у вектора: для работы с элементами, для получения размера. В приватной секции указатель на данные и количество этих данных.
1template <class T>
2class FixedArray final {
3    public:
4        using iterator = T*;
5        using const_iterator = const T*;
6
7        FixedArray() = default;
8
9        /// Make an array and initialize each element with "args"
10        template <class... Args>
11        explicit FixedArray(std::size_t size, Args&... args);
12
13        FixedArray(FixedArray&& other) noexcept;
14        FixedArray& operator=(FixedArray&& other) noexcept;
15
16        FixedArray(const FixedArray& other) = delete;
17        FixedArray& operator=(const FixedArray& other) = delete;
18
19        ~FixedArray();
20
21        std::size_t size() const noexcept { return size_; }
22        bool empty() const noexcept { return size_ == 0; }
23        const T& operator[](std::size_t i) const noexcept;
24
25        // ...
26    private:
27        T* storage{nullptr};
28        std::size_t size_{0};
29};
Самое интересное в этом классе — это его конструктор. Это практически единственное интересное в этом классе.
1template <class T>
2template <class... Args>
3FixedArray<T>::FixedArray(std::size_t size, Args&&... args) : size_(size)
4{
5    if (size_ == 0) return;
6    storage_ = std::allocator<T>{}.allocate(size_);
7    auto* begin = data();
8    try {
9        for (auto* end = begin + size - 1; begin != end; ++begin) {
10            new (begin) T(args...);
11        }
12        new (begin) T(std::forward<Args>(args)...);
13    } catch (...) {
14        std::destroy(data(), begin);
15        std::allocator<T>{}.deallocate(storage_, size);
16        throw;
17    }
18}
Конструктор принимает на вход количество элементов, после чего аллоцирует большой кусок памяти, чтобы все эти элементы можно было в нем поместить.
1storage_ = std::allocator<T>{}.allocate(size_);
Потом берёт указатель на начало этого куска памяти и создает элементы с первого по предпоследний, вызывая placement new и на каждом шаге передавая аргументы, которые были переданы в конструктор этого класса.
1for (auto* end = begin + size - 1; begin != end; ++begin) {
2    new (begin) T(args...);
3}
Небольшая хитрость с последним элементом: он может создаваться сильно быстрее, чем предыдущий, за счет того, что на последнем элементе мы можем использовать perfect forwarding. Соответственно, если нам какую-нибудь временную строчку передали в этот FixedArray, мы эту временную строчку переместим в последний элемент. На первых элементах так делать нельзя, потому что после перемещения строчка будет пустой, и мы не тем проинициализируем все остальные элементы, кроме первого.
1new (begin) T(std::forward<Args>(args)...);
Разумеется, обработка ошибок: если случилось что-то плохое, нужно уничтожить все то, что мы сейчас понасоздавали, освободить память и кинуть исключение дальше, чтобы оно приятно кем-нибудь залогировалось.
1} catch (...) {
2    std::destroy(data(), begin);
3    std::allocator<T>{}.deallocate(storage_, size);
4    throw;
5}
После этого с FixedArray мы можем заменить std::list на новый тип данных, и внезапно код становится короче, код становится быстрее. У нас появляется константный доступ, у нас меньше памяти тратится, работает со всякими экзотическими типами, у которых нет перемещения, forward-ит аргументы.
1class Component {
2public:
3
4    Component(std::size_t count)
5        : clients_(count, 100500)
6    {}
7
8    // ...
9private:
10    utils::FixedArray<third_party_lib::Some> clients_;
11};
Даже может работать с тем очень страшным и чудным классом из самого первого примера. Мы можем просто сказать, позвать функцию GenerateFixedArray, передать на вход количество элементов и лямбду. Лямбда будет возвращать результат вызова Something::Create. Create возвращает prvalue, этот prvalue возвращается из лямбды, это все еще prvalue, он попадает в то место, где мы вызываем placement new, и конструктор вызывать не надо, все значение сразу материализовалось по месту.
1class Component {
2public:
3
4    Component(std::size_t count)
5        : clients_(utils::GenerateFixedArray(count, [](std::size_t i) {
6            return third_party_lib::Something::Create(i);
7        }))
8    {}
9
10    // ...
11private:
12    utils::FixedArray<third_party_lib::Something> clients_;
13};
Теперь линкер начинает мучить нас. Год назад у нас был большой переезд, мы переезжали с одной системы сборки на другую, попутно меняли стандартную библиотеку, меняли компилятор. В общем, все сразу переделали и столкнулись с неприятностью. Внезапно неприятность оказалась вот в этой функции на две строчки:
Функция BufferWriter, что она делает? Она шаблонная, она принимает шаблонный класс T, какой-то value, и из этого шаблонного класса через трейты выводит тип форматтера. То есть это некий сериализатор — как этот value сериализовать и передать по проводам.
Тип форматтера пользователь может настраивать. Есть какой-то дефолтный, который в большинстве случаев подходит. Пользователь может взять и предоставить свой форматтер: хочет он, чтобы вот эта строчка в базе данных писалась не как строчка, а как enum, и делает специальный свой форматтер. И казалось бы, в чем проблема?
1template <typename T>
2typename traits::IO<T>::FormatterType BufferWriter(const T& value) {
3    using Formatter = typename traits::IO<T>::FormatterType;
4    return Formatter(value):
5}
А вот в чём. У нас есть кодогенерация. Для C++ это уже проблема, что C++ не может всё сделать сам без кодогенерации. Ну да ладно. В ямликах или в прото-файликах люди описывают какие-то структуры. И кодогенерация генерит эти структуры с какими-то там вспомогательными, хорошими, полезными методами.
Вот, например, пользователь писал структуру MyCodegeneratedStructure, и кодогенерация сгенерила заголовочный файл generated.hpp:
1struct MyCodegeneratedStructure { /*...*/ };
А ещё пользователь хочет особую сериализацию для этой структуры. Если он сериализацию напишет в файле generated.hpp, то следующая кодогенерация эту сериализацию потрёт, и ничего хорошего не получится. Надо написать эту сериализацию в отдельном файлике my_writer.hpp. Там пользователь делает forward-декларацию для структуры MyCodegeneratedStructure, пишет эту сериализацию, специализирует шаблоны.
1struct MyCodegeneratedStructure;
2template <>
3struct storages::postgres::io::CppToUserPg<MyCodegeneratedStructure> {
4    // ...
5};
Ну а дальше в файлике a.cpp подключает generate.hpp, подключает my_writer.hpp и пишет бизнес-логику. И бизнес-логика работает, всё замечательно.
1#include <generated.hpp>
2#include <my_writer.hpp>
Проходит время, и этот же разработчик, либо другой разработчик должен написать ещё новую бизнес-логику. И пишет её в файлике b.cpp и забывает подключить хедер-файл my_writer.hpp, где указано, какой сериализацией пользоваться.
1#include <generated.hpp>
С этого момента начинается боль, потому что в зависимости от инклюдов мы получаем разные форматтеры в функции BufferWriter. И при всём при этом шаблонный аргумент один и тот же, а значит, если имя у функции одно, шаблонный параметр один и тот же, то для линкера эти функции одинаковые, несмотря на то, что у них разные тела.
1template <typename T>
2typename traits::IO<T>::FormatterType BufferWriter(const T& value) {
3    using Formatter = typename traits::IO<T>::FormatterType;
4    return Formatter(value):
5}
Линкер, когда происходит линковка вашего объектника/бинарника, выбирает какое-то первое попавшееся тело функции. Возможно, вам повезёт, и он выберет то, что нужно. А когда вы начнёте мигрировать с одной системы сборки на другую, линкер может поменяться и начнёт выбирать вообще не то, что нужно.
При этом ошибка проявляется весьма неожиданно. Кто-то добавил новый cpp-файл с правильными инклюдами, а линкер взял и сломался в совершенно стороннем месте. И кажется, что это кто-то там другой тесты неправильно написал, но нет, это линкер хулиганит. При этом ошибку невероятно сложно диагностировать, потому что транзитивные инклюды вы из-за специфики C++ не отследите, где там пришел нужный сериализатор, не пришёл нужный сериализатор, воспользовались мы им где-то заранее или он там где-то в инклюдах, не воспользовались первый раз.
Также, поменяв порядок инклюдов, можно случайно сломать сервис: обновили какой-то автоформаттер, он сортирует инклюды, и сервисы сломались. Совсем неприятно.
А ещё неприятно, что компилятор никак вам не подскажет. Компилятор работает на уровне единиц трансляции, для него есть файлик a.cpp и всё, что там происходит, он может провалидировать, и там вроде всё в порядке. Есть файлик b.cpp, всё, что там происходит, там тоже всё в порядке. Проблема возникает, когда эти два файлика сливаются в бинарник, то есть это видит только линкер. В принципе есть разные сторонние тулзы, которые пытаются поймать эту проблему. Загвоздка в том, что они её не ловят, особенно если функция маленькая, они чего-то пасуют.
Но мы попробовали починить пару сервисов вручную, починили. Попробовали дальше переезжать, ошибка опять возникает, и опять. Было мучительно. Мы решили, что надо придумать, как эту штуку диагностировать. И мы придумали.
В дебаге теперь мы вставляем вот такую вот страшненькую строчку. В ней есть какой-то CheckForBufferWriterODR, и он принимает два шаблонных параметра: тип, тот который пришёл на вход в эту функцию, и тип форматтера, который мы получили из трейтов, из всего вот этого вот. И вызывается функция RequireInstance.
1template <typename T>
2typename traits::IO<T>::FormatterType BufferWriter(const T& value) {
3    using Formatter = typename traits::IO<T>::FormatterType;
4#ifndef NDEBUG
5    detail::CheckForBufferWriterODR<T, Formatter>::content.RequireInstance();
6#endif
7    return Formatter(value):
8}
Что там происходит? Тут намешано очень много всякой интересной магии.
1#ifndef NDEBUG
2class WritersRegistrator final {
3    public:
4        WritersRegistrator(std::type_index type, std::type_index formatter_type, const char* base_file);
5        void RequireInstance() const;
6};
7
8namespace{
9template <class Type, class Writer>
10struct CheckForBufferWriterODR final {
11    static inline WritersRegistrator content{typeid(Type), typeid(Writer), __BASE_FILE__};
12};
13} // namespace
14
15#endif
Вначале CheckForBufferWriterODR, у него внутри находится статическая инлайн-переменная. То есть как только начинается использование переменной content, как только кто-то пишет CheckForBufferWriterODR и подставляет туда два типа, компилятор эту статическую инлайн-переменную в единице трансляции инстанцирует, помещает её туда, и от неё будет зваться конструктор.
Конструктор этой переменной принимает типы на вход, тип writer-а, форматтера, а ещё он принимает супер-пупер волшебный макрос, которым практически никто не пользуется. Макрос называется __BASE_FILE__. Как ни странно, он есть в большинстве современных компиляторов, и он раскрывается в имя cpp-файла, из которого был подключен вот этот хедер. Да, тот код, что видите, это всё в хедере.
1namespace{
2template <class Type, class Writer>
3struct CheckForBufferWriterODR final {
4    static inline WritersRegistrator content{typeid(Type), typeid(Writer), __BASE_FILE__};
5};
6} // namespace
Дальше еще страшнее. Здесь внезапно можно допустить ещё один ODR Violation, то есть ту проблему, когда тела разные, а имя одно, потому что Type и Writer могут совпадать в разных единицах трансляции, но __BASE_FILE__ точно будет там разный. Мы из одного cpp-файлика, из другого cpp-файлика, шаблонные аргументы одни и те же, __BASE_FILE__ разный. Чтобы линкер лишнее не выкинул, мы в хедер-файле всё это оборачиваем в анонимный неймспейс, что является анти-паттерном, и поэтому мы эту штуку используем только в дебаге, чтобы у нас в релизе бинарники в размере не увеличивались. С анонимным спейсом линкер перестаёт думать, что эти типы одинаковые, они в анонимном спейсе и перестаёт их выкидывать.
1namespace{
2template <class Type, class Writer>
3struct CheckForBufferWriterODR final {
4    static inline WritersRegistrator content{typeid(Type), typeid(Writer), __BASE_FILE__};
5};
6} // namespace
А дальше все очень просто: WritersRegistrator — это та статическая инлайн-переменная, у которой вызывается конструктор. Он принимает typeid типа, typeid форматтера, base_file, и внутри себя содержит какую-нибудь std::unordered_map от типа и форматтера, с которыми этот тип использовался, и base_file.
1class WritersRegistrator final {
2    public:
3        WritersRegistrator(std::type_index type, std::type_index formatter_type, const char* base_file);
4        void RequireInstance() const;
5};
И теперь при первом вызове RequireInstance мы можем в дебаге пройтись по этой std::unordered_map и посмотреть, что у каждого типа только один форматтер.
1template <typename T>
2typename traits::IO<T>::FormatterType BufferWriter(const T& value) {
3    using Formatter = typename traits::IO<T>::FormatterType;
4#ifndef NDEBUG
5    detail::CheckForBufferWriterODR<T, Formatter>::content.RequireInstance();
6#endif
7    return Formatter(value):
8}
А если это не так, выдать красивую и понятную диагностику, что MyCodegeneratedStructure имеет разные инстанцирования форматтеров. Есть дефолтный форматтер, есть пользовательский форматтер, и дефолтный форматтер располагается в файлике b.cpp, а пользовательский форматтер в файлике a.cpp.
1Type 'MyCodegeneratedStructure' has conflicting instantiation
2of formatters: 'pg::DefaultFormatter' vs
3'storages::postgres::io::CppToUserPg<MyCodegeneratedStructure>' in base
4files [b.cpp] vs [a.cpp]
Такой диагностикой мы проблемы все починили часа за два, что ли. В общем, было очень быстро и приятно и прикольно.
А теперь фановая часть. Как-то раз нам было скучно, нам захотелось какого-нибудь веселья. Ну, что-нибудь пооптимизировать. Хотелось сделать очень быстрый преобразователь из строки в enum, потому что у нас много мест, где мы преобразовываем из строки в enum. И std::unordered_map показался нам медленным, хотелось сильно быстрее. Ну а чтобы это была не просто оптимизация ради оптимизации, мы ещё решили докинуть функциональных требований.
Например, у нас была проблема с тем, что преобразование из строки в enum можно описать через std::unordered_map, а из enum в строку надо где-то отдельно описывать. Неприятно. Хочется один раз задать маппинг и чтобы он умел в одну сторону конвертировать и в другую сторону. То есть нужна бимапа. Также нам хотелось иметь возможность получить все значения из enum-а, получить все значения строк, что очень удобно, когда вы даёте пользователю диагностику. Например, можно будет выдавать диагностику: "Пользователь, ты ввел значение строки foo, а у нас enum понимает только значение bar и baz". И всё, и разработчику понятно, что он не передал что-то не так, и легко понять, где именно он передал что-то не так.
Первый подход сделали в лоб, взяли и сделали flat-мапу. Сегодня, кстати, будет рассказ про flat-мапы, обещает быть интересным.
1template <class Key, class Value, std::size_t N>
2class ConstinitMap {
3    public:
4        constinit explicit ConstinitMap(std::pair<Key, Value>(&&map)[N]) {
5            CompileTimeSlowSort(map);
6            for (std::size_t i = 0; i < N; ++i) {
7                keys_[i] = map[i].first;
8                values_[i] = map[i].second;
9            }
10            CompileTimeAssertUnique(keys_);
11        }
12
13        constexpr bool Contains(const Key& key) const noexcept;
14        constexpr const Value* FindOrNullptr(const Key& key) const noexcept;
15
16        // ...
17
18    private:
19        Key keys_[N] = {};
20        Value values_[N] = {};
21};
Вооружились современными практиками про flat-мапы, сделали два массивчика: один массив ключей, другой массив значений. Почему так? Чтобы ключи располагались ближе друг другу в памяти, и поиск по ключам был более кэш-дружелюбным к процессору. И работало всё чутка быстрее.
1private:
2    Key keys_[N] = {};
3    Value values_[N] = {};
Дальше мы написали конструктор этой мапы, и он хитрый. Он помечен словом constinit, то есть конструктор обязан выполниться на этапе компиляции. И всё, что в нем, выполняется на этапе компиляции, поэтому тут можно взять и медленно — пузырьком — посортировать элементы, которые нам пришли, после чего спокойно взять эти элементы, скопировать массив ключей, массив значений, на compile-time проверить, что все ключи уникальны, а если это не так, выдать на compile-time ошибку, прервать пользователю компиляцию, и сказать, что "что-то ты не то задал".
Сделали, попробовали, побенчмаркали. В полтора раза быстрее, чем std::unordered_map. Классно, Классно прям! Взяли, похвастались об этом в чатике procxx, и достаточно быстро нам прилетел коммент: "А посмотрите на llvm::StringSwitch".
1constinit explicit ConstinitMap(std::pair<Key, Value>(&&map)[N]) {
2    CompileTimeSlowSort(map);
3    for (std::size_t i = 0; i < N; ++i) {
4        keys_[i] = map[i].first;
5        values_[i] = map[i].second;
6    }
7    CompileTimeAssertUnique(keys_);
8}
Мы посмотрели, по функционалу совсем не то, что нам нужно, но вот как он работает — это прям круто. Смотрите, у него есть конструктор, в этом конструкторе мы передаем там какой-то тип, который нужно вернуть в результате, и передаем x — это runtime-значение строки, то есть цвет какой-то. Дальше через .Case, через вызовы функций, описывается маппинг строки на enum. Если пришла строка red, тогда enum Red. Если пришла строка orange, тогда enum нужно вернуть Orange. Напоследок дефолтное значение можно вернуть, которое вернётся, если мы ничего не нашли.
1enum Color {
2    Red, Orange, Yellow, UnknownColor,
3};
4
5Color color = llvm::StringSwitch<Color>(x)
6    .Case("red", Red)
7    .Case("orange", Orange)
8    .Case("yellow", Yellow)
9    .Default(UnknownColor);
Вся эта конструкция преобразовывается компилятором в упрощённом виде вот приблизительно в это. Конструктор llvm::StringSwitch создаёт внутри себя пустой std::optional, а каждый кейс преобразовывается в блок if, где мы проверяем, что если std::optional пустой, тогда сравниваем x с той строкой, которая была в кейсе. Если сравнение говорит, что true, заходим внутрь этого if и в std::optional записываем то значение, которое было вторым аргументом в кейсе. Повторяем, повторяем, и наконец, дефолтное значение — если в std::optional пусто, возвращаем дефолт.
1std::optional<Color> color;
2if (!color && x == "red") {
3    color = Red;
4}
5if (!color && x == "orange") {
6    color = Orange;
7}
8if (!color && x == "yellow") {
9    color = Yellow;
10}
11
12return color.value_or(UnknownColor);
Сделали нечто подобное, назвали TrivialBiMap. Инициализируется она лямбдой, которая принимает селектор, и на этом селекторе у нас вызываются все эти кейсы.
1constexpr utils::TrivialBiMap kMyEnumDescription3 = [](auto selector) {
2    return selector()
3    .Case("a", 9)
4    .Case("ab", 10)
5    .Case("abc", 11)
6    .Case("abcd", 12)
7    .Case("abcde", 13)
8    .Case("abcdef", 14)
9    .Case("abcdefg", 15)
10    .Case("abcdefgz", 16)
11    .Case("abcdefgzx", 17)
12    .Case("abcdefgzxz", 18)
13};
14
15int StringCase3(std::string_view param) {
16    return *kMyEnumDescription3.TryFind(param);
17}
Более чем в 10 раз быстрее, чем std::unordered_map. Даже на самых вырожденных случаях, которые явно должны плохо себя вести на этой тривиальной бимапе, где там 50 значений, все они одинаковой длины. TrivialBiMap все равно чуть-чуть быстрее, чем std::unordered_map. Нереально круто!
1Benchmark                           Time            CPU     Iterations
2----------------------------------------------------------------------
3MappingSmallTrivialBiMap         12.2 ns        12.0 ns       57766546
4MappingSmallUnordered             167 ns         164 ns        3844217
5
6MappingMediumTrivialBiMap        19.5 ns        19.1 ns       33153861
7MappingMediumUnordered            210 ns         207 ns        3130815
8
9MappingHugeTrivialBiMap          72.2 ns        71.0 ns        9917775
10MappingHugeUnordered              264 ns         264 ns        2584118
11
12MappingHugeTrivialBiMapLast      19.1 ns        19.0 ns       37326680
13MappingHugeUnorderedLast         23.0 ns        22.6 ns       30076471
Но хочется понять, почему так. Стали смотреть в ассемблер. Тут есть много чего интересного. Например, что GCC, что Clang откуда-то берут какую-то циферку и сразу начинают сравниваться по этой цифре. Clang на шестой строчке вообще взял какую-то циферку и сразу прыжок на какой-то отдельный участок кода. Мы кажется, ничего такого не писали. Что же произошло?
Компилятор увидел вот этот код и приблизительно следующий порядок оптимизации он произвёл.
1std::optional<Color> color;
2if (!color && x == "red") {
3    color = Red;
4}
5if (!color && x == "orange") {
6    color = Orange;
7}
8if (!color && x == "yellow") {
9    color = Yellow;
10}
11
12return color.value_or(UnknownColor);
Он заметил, что после того, как мы в std::optional что-то записали, это значение уже дальше внутри if-ов мы не заходим, значение сразу возвращается. И, соответственно, он соптимизировал вот этот код до вот такого.
1if (x == "red") {
2    return Red;
3}
4if (x == "orange") {
5    return Orange;
6}
7if (x == "yellow") {
8    return Yellow
9}
10
11return UnknownColor;
Но на этом он не остановился. x — это std::string, либо std::string_view, и сравнение std::string_view с литералом — с массивом чаров — это сравнение их размеров, а потом посимвольное сравнение.
1if (x.size() == 3 && x[0] == 'r' && x[1] == 'e' && x[2] == 'd') {
2    return Red;
3}
4if (x.size() == 6 && x[0] == 'o' && x[1] == 'r' && x[2] == 'a' && x[3] == 'n' && x[4] == 'g' && x[5] == 'e') {
5    return Orange;
6}
7if (x.size() == 6 && x[0] == 'y' && x[1] == 'e' && x[2] == 'l' && x[3] == 'l' && x[4] == 'o' && x[5] == 'w') {
8    return Yellow
9}
10
11return UnknownColor;
И фактически первая часть в if везде одна и та же, чуть-чуть различается константа. Компилятор берёт и одинаковое выражение выносит из if, превращая выражение вот в такое. У нас есть switch по размеру, а дальше ветки кейсов по размеру пришедшей на вход строки. Другими словами, компилятор взял и сделал нам unordered-мапу, где в качестве хэша выступает длина строки. Вместо линейного поиска мы получили бац и хитрую unordered-мапу, которая даже хэш не вычисляет, а сразу переходит в нужную длину строки.
И именно это мы там на Clang видим на шестой строчке. Вот этот jump — это как раз switch по длине строки. Казалось бы, круто! Вообще офигенно! Но на этом не всё, чудеса не заканчиваются. Оба компилятора сравнивают строчку с жутко длинными непонятными константами. Почему так?
1switch (x.size()) {
2case 3:
3    return (x[0] == 'r' && x[1] == 'e' && x[2] == 'd') ? Red : UnknownColor;
4
5case 6:
6    if (x[0] == 'o' && x[1] == 'r' && x[2] == 'a' && x[3] ...) {
7        return Orange;
8    }
9    if (x[0] == 'y' && x[1] == 'e' && x[2] == 'l' && x[3] ...) {
10        return Yellow;
11    }
12}
13return UnknownColor;
Компилятор видит: вот здесь идёт посимвольное сравнение. Мы точно знаем, что в этой переменной x длина такая-то, там три символа или шесть символов. Если мы делаем посимвольное сравнение, это сделать равенство, потом битовое И, ну или логическое И, потом опять сравнение, потом опять логическая операция — дофига кода. Но можно же не посимвольно сравнивать.
1// todo
2switch (x.size()) {
3case 3:
4    return (x[0] == 'r' && x[1] == 'e' && x[2] == 'd') ? Red : UnknownColor;
5
6case 6:
7    if (x[0] == 'o' && x[1] == 'r' && x[2] == 'a' && x[3] ...) {
8        return Orange;
9    }
10    if (x[0] == 'y' && x[1] == 'e' && x[2] == 'l' && x[3] ...) {
11        return Yellow;
12    }
13}
14return UnknownColor;
Можно же взять и сразу по 4 символа сравнивать, по 8 символов сравнивать, по 16 символов сравнивать, в зависимости от вашей платформы. И компилятор берёт и эту строчку представляет в виде интегральной константы, которая правильна для данной платформы, в зависимости от endian-а там, от многих факторов.
1switch (x.size()) {
2case 3:
3    return (x == 12731231283121) ? Red : UnknownColor;
4
5case 6:
6    if (x == 123123123123123123) {
7        return Orange;
8    }
9    if (x == 789789798789789789) {
10        return Yellow;
11    }
12}
13return UnknownColor;
И таким образом, мало того, что компилятор взял и сделал нам аналог unordered-мапы, он ещё финальное сравнение векторизировал, ускорив его там раз в 10. Мы получили вот такой вот красивый ассемблер. Кстати, размер этого бинарного кода меньше, чем размер скомпилированного std::unordered_map раза так в два-три. То есть ещё и размер бинарника становится меньше.
Бонусом мы ещё получили constexpr. Всё, что с этой unordered-мапой происходит, можно делать на compile-time, и также мы получили case-sensitive сравнение строк. Записали в unordered-мапе в нижнем регистре — можно сравнивать пришедшие строки в произвольном регистре, компилятор там тоже понаоптимизирует.
Теперь как все это дело реализовать? TrivialBiMap принимает на вход функцию, вот та вот здоровенная generic-лямбда, которая была на слайде, вот он её берет и сохраняет в приватной секции.
1template <typename BuilderFunc>
2class TrivialBiMap final {
3        using TypesPair = std::invoke_result_t<const BuilderFunc&, impl::SwitchTypesDetector>;
4
5    public:
6        using First = typename TypesPair::first_type;
7        using Second = typename TypesPair::second_type;
8
9        constexpr TrivialBiMap(BuilderFunc func) noexcept;
10
11        constexpr std::optional<Second> TryFindByFirst(First value) const noexcept {
12            return func_(impl::SwitchByFirst<First, Second>{value}).Extract();
13        }
14
15    private:
16        const BuilderFunc func_;
17};
И фактически, это все, что он держит. Он не держит данные, он держит функцию, и она там один байт занимает в trivial-бимапе.
1private:
2    const BuilderFunc func_;
Вся функциональность реализовывается через визиторы в эту функцию, то есть мы внутри функции передаем селектор, и на этом селекторе вызывается .Case.
1constexpr std::optional<Second> TryFindByFirst(First value) const noexcept {
2    return func_(impl::SwitchByFirst<First, Second>{value}).Extract();
3}
Селектор, если мы ищем по первому значению из кейса, выглядит весьма знакомо.
1template <typename First, typename Second>
2class SwitchByFirst final {
3    public:
4        constexpr explicit SwitchByFirst(First search) noexcept : search_(search) {}
5
6        constexpr SwitchByFirst& Case(First first, Second second) noexcept {
7            if (!result_ && search_ == first) {
8                result_.emplace(second);
9            }
10            return *this;
11        }
12
13        [[nodiscard]] constexpr std::optional<Second> Extract() noexcept {
14            return result_;
15        }
16
17    private:
18        const First search_;
19        std::optional<Second> result_{};
20};
Это приблизительно то же самое, что было в llvm::StringSwitch, где в начале у нас есть какой-то std::optional, мы его проверяем, а потом проверяем рантаймовое значение строки с тем, что у нас записано в кейсе.
1constexpr SwitchByFirst& Case(First first, Second second) noexcept {
2    if (!result_ && search_ == first) {
3        result_.emplace(second);
4    }
5    return *this;
6}
Вот эти вот кейсы вызываются уже на визиторе, на SwitchByFirst.
1constexpr utils::TrivialBiMap kMyEnumDescription3 = [](auto selector) {
2    return selector()
3    .Case("a", 9)
4    .Case("ab", 10)
5    .Case("abc", 11)
6    .Case("abcd", 12)
7    .Case("abcde", 13)
8    .Case("abcdef", 14)
9    .Case("abcdefg", 15)
10    .Case("abcdefgz", 16)
11    .Case("abcdefgzx", 17)
12    .Case("abcdefgzxz", 18)
13};
14
15int StringCase3(std::string_view param) {
16    return *kMyEnumDescription3.TryFind(param);
17}
Если вы захотите написать такую же или подобную бимапу, будьте очень аккуратны. Компилятор легко может потеряться, запутаться и перестать оптимизировать как нужно.
Первый подводный камень: если вы начинаете сравнивать string-литералы, вы можете закончить тем, что вы сравниваете массивы char, два указателя. Они, скорее всего, будут не равны. То есть нужно то, что приходит в Case, ещё преобразовывать в std::string_view. Также вам нужно преобразовывать эти литералы в std::string_view, потому что иначе компилятор теряет информацию о размере и перестаёт сравнивать ваши литералы — runtime-строчку и литерал на вход — по размеру первым шагом, и unordered-мапы не происходит. Так что аккуратность нужна, нужно явно вывести типы First и Second, которые попадают в этот Case в визиторах. И вычисляемые эти типы тоже используют ту generic лямбда-функцию, которая нам пришла на вход.
Для этого мы используем SwitchTypesDetector, который уже возвращает некий тип, из которого можно достать First и Second.
1    using TypesPair = std::invoke_result_t<const BuilderFunc&, impl::SwitchTypesDetector>;
2
3public:
4    using First = typename TypesPair::first_type;
5    using Second = typename TypesPair::second_type;
Такой SwitchTypesDetector реализовать, как ни странно, весьма просто.
1template <typename First, typename Second>
2struct SwitchTypesDetected final {
3    using first_type = First;
4    using second_type = Second;
5    constexpr SwitchTypesDetected& Case(First, Second) noexcept { return *this; }
6};
7
8struct SwitchTypesDetector final {
9    template <typename First, typename Second>
10    constexpr auto Case(First, Second) noexcept {
11        using first_type = std::conditional_t<std::is_convertible_v<First, std::string_view>, std::string_view, First>;
12        using second_type = std::conditional_t<std::is_convertible_v<Second, std::string_view>, std::string_view, Second>;
13        return SwitchTypesDetected<first_type, second_type>{};
14    }
15};
У него есть Case, он принимает First, принимает Second, и это всё, что его интересует. Его не интересуют эти значения, его интересуют только типы.
1struct SwitchTypesDetector final {
2    template <typename First, typename Second>
3    constexpr auto Case(First, Second) noexcept {
После чего, если First можно преобразовать к std::string_view, то тип First у нас будет std::string_view. Нельзя преобразовать к std::string_view — кто-то передал значение enum — значит, First-тип у нас — это значение enum.
1using first_type = std::conditional_t<std::is_convertible_v<First, std::string_view>, std::string_view, First>;
С Second-типом то же самое: если что-то нам передали, что превращается в строчку, возвращаем std::string_view. Если нет, то возвращаем enum. Таким образом, можно делать даже преобразование из одной строки в другую строку или из одного enum в другой enum.
1using second_type = std::conditional_t<std::is_convertible_v<Second, std::string_view>, std::string_view, Second>;
Один раз посчитали все эти conditional-ы и возвращаем другой тип данных, уже SwitchTypesDetected. То есть все последующие кейсы, кроме первого, вызываются на вот этом SwitchTypesDetected.
1return SwitchTypesDetected<first_type, second_type>{};
А этот SwitchTypesDetected очень простой: у него Case вообще ничего не делает. Всё, что делает этот SwitchTypesDetected, он хранит в себе first_type, second_type, готово. Мы получили реальную хитрую шуструю бимапу.
1template <typename First, typename Second>
2struct SwitchTypesDetected final {
3    using first_type = First;
4    using second_type = Second;
5    constexpr SwitchTypesDetected& Case(First, Second) noexcept { return *this; }
6};
И напоследок, как-то раз, опять-таки Антон Жилин из нашей команды, меня очень удивил вот таким кодом.
1return std::shared_ptr<Logger>(
2    std::shared_ptr<void>{},
3    &GetNullLogger()
4);
Есть идея, что здесь происходит? Это функция, она сразу возвращает что-то. <ответы из зала>. Это конструктор std::shared_ptr. У меня заняло больше времени понять, что здесь происходит, да. Смотрите, что здесь происходит, и зачем это вообще нужно.
Вот конструируется std::shared_ptr. При этом это не простой конструктор std::shared_ptr от сырого указателя, а так называемый aliasing-конструктор std::shared_ptr. Для чего он нужен? Вот есть у вас std::shared_ptr на какую-то структуру, и вам очень нужен std::shared_ptr на int, который находится в этой структуре. Если вы возьмете просто указатель на этот int, то вообще ничего не понятно, как это сделать. Вам нужно взять из std::shared_ptr на структуру счетчик, который счетчик использований, и сделать так, чтобы новый std::shared_ptr, который указывает на int, переиспользовал счетчик от того std::shared_ptr. Если старый std::shared_ptr умрет, то структура все еще жива, её не удалили, и она будет жить, пока жив и второй std::shared_ptr. То есть aliasing-конструктор — это возможность сделать std::shared_ptr разных типов, которые при этом используют один и тот же reference counter.
И как раз передается std::shared_ptr, из которого он достается reference counter, а потом передается указатель на ту переменную, которой std::shared_ptr владеет. То, что он возвращает, то, чем он владеет. Загвоздка тут в том, что первым параметром в конструктор std::shared_ptr был передан std::shared_ptr от void, дефолтно сконструированный, то есть он ничего не проаллоцировал, у него нет счетчика ссылок вообще.
Да, я удивился, что эта конструкция работает. Я проверил по стандарту, там прям в стандарте такая маленькая сносочка, что вот эта конструкция на самом деле, да, она обязана работать, и на выходе вы получаете std::shared_ptr, у которого нет счетчика ссылок, он при копированиях ничего не инкрементит, в деструкторах ничего не декрементит, но при этом его value — это то, что было передано вторым параметром. И таким std::shared_ptr можно пользоваться. Только на самом деле это не std::shared_ptr, это сырой указатель, который притворяется std::shared_ptr. Зачем?
У нас есть API, которое работает с логированиями. То есть там в куче мест нужно людям подставить свой логгер — дефолтный логгер, какой-то специфичный логгер. Как правило это логгеры, они достаточно сложные, создаются на рантайме, на них нужен std::shared_ptr, и этот std::shared_ptr мы куда-то внутрь засовываем, чтобы потом этим логгером пользоваться. И там ещё куча у нас всяких хитростей, чтобы счётчики не инкрементить, когда std::shared_ptr действительно кто-то пользуется. Об этом в следующий раз как-нибудь расскажу.
Так вот. А здесь мы возвращаем NullLogger — логгер, который ничего не делает. Ему данные передают, а он ничего не делает. И такой логгер-то динамически аллоцировать не нужно. Достаточно одного экземпляра одного этого логгера на всех, потому что он весь константный, ничего не содержит, ничего не делает, в общем. Но нужен std::shared_ptr на него. И вот эта конструкция, что здесь на экране, это позволяет вам сделать std::shared_ptr на NullLogger, при этом std::shared_ptr лайфтаймом этого NullLogger не владеет, и вообще это фактически сырой указатель, который притворяется std::shared_ptr, который можно передавать в разные части API, и будет хорошо.
1Logger& GetNullLogger() noexcept {
2    static NullLogger null_logger{};
3    return null_logger;
4}
5
6std::shared_ptr<Logger> MakeNullLogger() {
7    return std::shared_ptr<Logger>(
8        std::shared_ptr<void>{},
9        &GetNullLogger()
10    );
11}
А на этом у меня, пожалуй, все. Приходите на сайт userver.tech, смотрите, там есть много всяких интересных хитростей. Надеюсь, вам понравилось. Спасибо за внимание.
Итак, мы переходим к секции вопросов и ответов. Если у нас вопросы в зале, пожалуйста, подойдите с микрофоном.
Слушатель: Скорее даже не вопрос, а дополнение. Вот про этот std::shared_ptr невладеющий. Если у него взять std::weak_ptr, то он будет сразу expired. То есть вот такая особенность. А так вопросов нет.
Антон: Я смотрю, мы не первые, кто пользуется этим трюком.
Слушатель: Не подойдет ли тут boost-контейнер static_vector, если известен верхний предел размера? Если нет, то может стоит законтрибьютить такого рода фиксированный вектор?
Антон: Так, законтрибьютить? Да, я всегда за контрибьюты. Особенно в Boost или в стандартную библиотеку. А по поводу static_vector — да, поможет. Но вам придется тогда комбинировать его с LazyPrvalue, если вы захотите передавать туда какие-нибудь хитрые объекты, у которых нет никаких конструкторов, через emplace_back. В противном случае, да, можно. Кстати, мы FixedArray внезапно начали использовать в огромном количестве мест. Как только он у нас появился, мы поняли, что мы хотим создавать там массивы атомиков, массивы специальных наших собственных мьютексов. И прям можно поискать по кодовой базе, много где используется. Boost мы в проекте стараемся в публичные хедер-файлы особо не выставлять, потому что он тащит огромное количество других библиотек Boost, зависимости, и замедляет время компиляции. Хорошая новость: в конце этого года мы все исправим, и Boost отбрасывает обратную совместимость с C++03. То есть с этого конца этого года только C++11, и соответственно, можно не заморачиваться, не использовать бустовые static_assert, type_traits, все библиотеки Boost, которые перестанут заморачиваться, станут значительно легче для компиляции. Как бы 10 лет прошло – кажется, пора. Точнее, 20 лет с C++03. Вот теперь точно пора.
Слушатель: Вопрос про оптимизации сравнения строчек. Будет ли вот это работать, если, например, нет std::string_view и там будет my::string_view или boost_string_view? Сумеет ли это компилятор оптимизировать?
Антон: Мы сделаем так, что кейс принимает std::string_view. Насколько я помню, boost::string_view в последних версиях Boost конвертируется в std::string_view. Поэтому да, оно будет работать. Просто boost::string_view, который передадите, он сконвертируется в std::string_view, за константное время сконвертируется, все готово, без динамических локаций. Все работает.
Слушатель: Тут более хитрый вопрос: допустим, у меня C++14, и std::string_view физически отсутствует.
Антон: Дело плохо. Тогда то, что у нас есть, прямо в том виде, что есть, не заведется у вас. У нас требуется C++17 для userver, но можно сделать свой string_view. Главное, чтобы он сравнивался в начале по size с другим string_view, и тогда компилятору станет хорошо и приятно.
Слушатель: Наверное, стоит проверить. Проверю.
Антон: Обязательно. Через ассемблер, когда такое делаете, там страшненько. Мы как-то передали что-то по ссылке, и компилятор перестал оптимизировать, растерялся.
Слушатель: Бимап сравнивается не unordered-мап неспортивно, она заведомо медленна и за гарантии по итераторам и она runtime. Если список compile-time known, то почему не boost::hana::map?
Антон: hana? Неет. Сам автор ханы говорит, что это экспериментальный проект, он не очень-то предназначен для прода, и компилируется он жутко долго. И это говорит сам автор Ханны. Вообще надо посмотреть, что там в hana-мап происходит, может, что-то можно будет и сделать оттуда. Но мне кажется, что там аналогичный подход используется. Надо глянуть. Спасибо за идею.
Слушатель: Спасибо за доклад. Прошу прощения сразу за вопрос, но вот про бимап хотелось бы задать вопрос. Вы посмотрели, если я правильно услышал, на llvm, и решили изобрести свой. Значит ли это, что вы как-то развиваете некоторые подмножества библиотеки в рамках своего фреймворка, про который рассказываете, и какое-то позиционирование здесь происходит? Или просто эта зависимость на llvm как бы пропала, да? Или с какой целью? То есть это пользователи, используя фреймворк, сразу как бы получают также какую-то библиотеку в нагрузку? Или как так получилось, просто интересно.
Антон: Во фреймворке там очень много всего. Там есть контейнеры, которых нет в стандартной библиотеке. Вот какой-нибудь FixedArray там есть, вот эти бимапы там есть, корутины, драйвера асинхронные к базам данных, stackful-корутины, примитивы синхронизации на корутинах. В общем, всё, всё, всё. Потихоньку мы хорошие наработки из фреймворка что-то перетаскиваем в стандартную библиотеку, что-то перетаскиваем в Boost, что-то просто говорим комитету: "Смотрите, у нас вот так вот это хорошо получилось". Комитет говорит: "О, мы вот сейчас подшаманим, будет ещё лучше". Прогресс движется.
Слушатель: Почему не использовать raw pointer в последнем кейсе?
Антон: У нас сигнатура всех API, что есть, она требует std::shared_ptr, когда мы выставляем новый логгер, то есть нам тут требуется именно std::shared_ptr от логгера. Можно сделать std::shared_ptr от логгера через динамическую аллокацию и создания NullLogger-а свеженького, но нам как бы одного достаточно. Зачем динамически что-то аллоцировать, если одного логгера хватит всем? Вот и всё.
Слушатель: Почему не сырые pointer-ы?
Антон: Сырые pointer-ы — ой, страшно-то как. Нет, сырые поинтеры — не надо, пожалуйста, это страшно. Можно, кстати, как-нибудь рассказать, как у нас устроено логирование, потому что мы std::shared_ptr с недавнего времени не копируем, не инкрементим, когда делается логирование, и при этом оно асинхронное и там много всего интересного, страшного происходит, смачного. Вот как-то так.
Слушатель: Про бимапу еще разок. Мне конкретно интересует из строки в int. Когда начали формулировать задачу, сразу подумал, что я бы сделал это на двух switch-ах, это даже можно сделать на С++03. Первый switch был бы просто int -> return строка. А для второго я бы использовал horner hash, то есть посчитал бы в compile time хэш от строк, которые нужны. А в рантайме я бы считал один раз хэш от строки и сравнивал его с ключами. Тут получился бы как бы switch от строк, которого штатно нет, но можно его сэмулировать. Вы же на вход получаете строчку, которая известна в рантайме. Всё равно нужно её номер узнать, чтобы сравнить её с остальными. Всё равно как будто бы считается хэш.
Антон: Почему у нас самого начала был подход, как раз где мы писали две функции: одна switch по инт-у, вторая то, что вы говорите, аналог. Но проблема в том, что это две функции, там два раза этот маппинг надо писать. Не хорошо. А еще из двух функций мы не вытащим все значения строк и все значения enum. А нам хотелось и в одну стороно конвертировать, и в другую сторону конвертировать, и все значения получать, и size получать, и при этом написать всё один раз, чтобы случайно не ошибиться или при рефакторинге, когда новые значения enum-а появляются, чтобы мы не забыли его добавить сразу во все места. Если один маппинг, всё просто — в одно место добавили, всё работает. Со switch-ами подход жизнеспособный, но здесь функциональность сильно удобнее становится. Просто меньше кода значительно и меньше шанс ошибки, и функционал больше, и диагностика приятнее. По поводу того, что хэш происходит в самом начале. Вместо хэша выступает размер строки. Компилятор делает вот это преобразование, и здесь в качестве хэша выступает размер строки. Его считать не надо, он пришел от runtime-строки, и там прям размер в переменной хранится, его не надо считать.
Слушатель: Это да, потом просто как бы хэш считается еще один раз. Вот x взять и получить из этого всего длинный int. Это фактически есть как horner hash. Именно это он и делает.
Антон: Да, но нет. Мы перескочили в нужный кейс. У нас есть runtime строка, это указатель на кусок данных в памяти, и компилятор трактует указатель как указатель на int32 и сравнивает тот int32 по указателю с тем int-ом, который вот прям зашит в коде, большой цифрой. За раз читает в данном примере 4 символа. Может трактовать этот указатель на кусок памяти как указатель на int64 и тогда сравнивает то значение по указателю сразу с зашитой в вход константой 64-битного int. todo Здесь записано что-то типа "Yellow".
Слушатель: Про длинные строки. Вы пробовали сравнивать? То есть не там из 6-8 символов, которые в int64 можно раскрыть? Строки 100 символов, 150
Антон: Длинные строки, да. Есть, присутствуют. Компилятор делает несколько сравнений с большими int-ами. Если вы делаете enum-ы, в которых значение заполняются строкой в 100 символах, вашим пользователям очень больно конфигурировать и пользоваться этой штукой. TrivialBiMap, он изначально ориентирован на то, что конвертируется строка в enum, enum в строку, строка в строку, и эти строки где-то используются. Это параметры на вход, либо это что-то конфигурационно понятное, а не что-то сгенерированное машиной для машины. И соответственно, там строчка имеет понятное читаемое имя. Или, например, мы используем TrivialBiMap где-то там при логированиях. У нас есть специальные теги, которые при логировании нельзя использовать, например, имя модуля или thread id. И если пользователь их задаёт, мы через TrivialBiMap смотрим, есть такое, нету. Если пользователь задал значение, значит, не надо так делать, и на рантайме выдаем ошибку. Быстрый поиск.