Первые новинки C++26: итоги летней встречи ISO

от автора

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

  • улучшенный static_assert,
  • переменная _,
  • оптимизация и улучшение для std::to_string,
  • Hazard Pointer,
  • Read-Copy-Update (так же известное как RCU),
  • native_handle(),
  • целая вереница классов *function*,
  • множество доработок по constexpr,
  • std::submdspan,
  • и прочие приятные мелочи.

Для тех, кто предпочитает видеоформат

Если вам больше нравится слушать, чем читать, то этот пост доступен в формате видеолекции с C++ Zero Cost Conf. Кстати, там есть и другие интересные доклады. Надеюсь, вам понравится!

static_assert

static_assert — замечательный инструмент для диагностики неправильного использования класса или функции. Но до C++26 у него всё ещё оставался недостаток.

Вот, например, класс для реализации идиомы PImpl без динамической аллокации utils::FastPimpl из фреймворка userver:

template <class T, std::size_t Size, std::size_t Alignment> class FastPimpl final { public:      // ...     ~FastPimpl() noexcept { // Used in `*cpp` only         Validate<sizeof(T), alignof(T)>();         reinterpret_cast<T*>(&storage_)->~T();     }  private:     template <std::size_t ActualSize, std::size_t ActualAlignment>     static void Validate() noexcept {         static_assert(Size == ActualSize, "invalid Size: Size == sizeof(T) failed");         static_assert(Alignment == ActualAlignment,             "invalid Alignment: Alignment == alignof(T) failed");     }      alignas(Alignment) std::byte storage_[Size]; }; 

Пользователь класса должен предоставить правильные размер и alignment для класса, после чего можно заменять в заголовочных файлах std::unique_ptr<Pimpl> pimpl_; на utils::FastPimpl<Pimpl, Размер, Выравнивание> pimpl_; и получать прирост в производительности. static_assert внутри функции Validate() уже в cpp-файле проверят переданные пользователем размеры и если размеры неверные, выдадут сообщение об ошибке:

<source>: error: static assertion failed: invalid Size: Size == sizeof(T) failed <... десяток строк диагностики...> 

После этого у разработчика сразу возникает вопрос: «А какой размер правильный?» И тут незадача: подсказка располагается на десяток строк ниже сообщения об ошибке, в параметрах шаблона:

<source>: In instantiation of 'void FastPimpl<T, Size, Alignment>::validate() [with int ActualSize = 32; int ActualAlignment = 8; T = std::string; int Size = 8; int Alignment = 8]' 

Вот тут-то и приходит на помощь static_assert из C++26:

private:     template <std::size_t ActualSize, std::size_t ActualAlignment>     static void Validate() noexcept {         static_assert(                 Size == ActualSize,                 fmt::format("Template argument 'Size' should be {}", ActualSize).c_str()         );         static_assert(                 Alignment == ActualAlignment,                 fmt::format("Template argument 'Alignment' should be {}", ActualAlignment).c_str()         );     } 

Начиная с C++26, можно формировать сообщение об ошибке на этапе компиляции, а результат передавать вторым аргументом в static_assert. В результате диагностика становится намного лучше, а код становится приятнее писать:

<source>: error: static assertion failed: Template argument 'Size' should be 32 

Переменная _

Посмотрим на функцию подсчёта элементов в контейнере, у которого нет метода size():

template <class T> std::size_t count_elements(const T& list) {     std::size_t count = 0;     for ([[maybe_unused]] const auto& x: list) {         ++ count;     }     return count; } 

Алгоритм прост и понятен, но дискомфорт вызывает [[maybe_unused]]. Из-за него код становится громоздким, читать его неприятно. И убрать его нельзя, ведь компилятор начнёт ругаться: «Вы не используете переменную x, это подозрительно!»

<source>:12:19: warning: unused variable 'x' [-Wunused-variable] for (const auto& x: list) {                  ^ 

Зачастую в коде возникают ситуации, когда мы и не собираемся пользоваться переменной. Неплохо было бы это показать, не прибегая к большим и громоздким словам. Поэтому в C++26 приняли особые правила для переменных с именем _. Теперь можно писать лаконичный код, который похож на Python:

template <class T> std::size_t count_elements(const T& list) {     std::size_t count = 0;     for (const auto& _: list) {         ++ count;     }     return count; } 

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

std::unique_lock _{list_lock};  auto _ = list.insert(1); auto _ = list.insert(2); auto _ = list.insert(3);  auto [_, _, value, _] = list.do_something(); value.do_something_else();  // Вызываются деструкторы для каждой из _ переменных // в обратном порядке их создания 

std::to_string(floating_point)

Начиная аж с C++11, в стандартной библиотеке есть метод std::to_string для преобразования числа в строку. Многие годы он отлично работает для целых чисел, а вот с числами с плавающей точкой есть нюансы:

auto s = std::to_string(1e-7); 

Этот код вернёт вам не строку «1e-7», а строчку наподобие «0.000000». На этом сюрпризы не заканчиваются: есть возможность получить ту же строчку с другим разделителем «0,000000», если вдруг какая-то функция меняет глобальную локаль.

Из-за последнего пункта std::to_string(1e-7) ещё и медленный: работа с локалями для получения разделителя может тормозить сильнее, чем само преобразование числа в строку.

Всё это безобразие исправили в C++26. Теперь std::to_string обязан возвращать максимально точное и короткое представление числа, при этом не используя локали. Так что в C++26 std::to_string(1e-7) будет возвращать всегда «1e-7». Пусть это и ломающее обратную совместимость изменение, однако люди из комитета не нашли в открытых кодовых базах мест, где код бы сломался. Однако лучше подстраховаться заранее, и если вы используете std::to_string(floating_point), то лучше добавить побольше тестов на места использования.

Hazard Pointer

Радостная новость для всех высоконагруженных приложений, где есть что-то похожее на кэши. Начиная с C++26, в стандарте есть Hazard Pointer — низкоуровневый примитив синхронизации поколений данных (wait-free на чтении данных).

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

Давайте прямо сейчас сделаем свой кэш! Опишем структуру, которая хранит наши данные, и отнаследуем её от std::hazard_pointer_obj_base:

struct Data : std::hazard_pointer_obj_base<Data> { /* members */ }; 

Теперь заведём атомарный указатель на актуальное поколение данных:

std::atomic<Data*> pdata_; 

Чтение данных надо защитить через std::hazard_pointer:

template <typename Func> void reader_op(Func userFn) {     std::hazard_pointer h = std::make_hazard_pointer();     Data* p = h.protect(pdata_);     userFn(p); } 

Вся сложность в обновлении данных:

void writer(Data* newdata) {     Data* old = pdata_.exchange(newdata);     old->retire(); } 

Мы меняем атомарный указатель, чтобы он указывал на новое поколение данных, но старые данные нельзя удалять сразу! Кто-то из читателей может продолжать работать со старым поколением данных из другого потока. Надо дождаться, пока всё поколение читателей не сменится. То есть дождаться, чтобы отработали все деструкторы объектов std::hazard_pointer, созданных на старом поколении данных, и только после этого удалять объект. Для этого зовётся old->retire();.

Метод retire() удаляет объекты, для которых нет активных читателей. Если читатели для old всё ещё есть, то выполнение программы продолжится, а объект будет удалён позже, когда это будет безопасно: при вызове retire() для другого объекта или при завершении приложения, если retire() больше никто не позовёт.

Что-то это напоминает…

Да, это кусочек Garbage Collector (GC) в нашем любимом C++!

Однако у него есть кардинальные отличия от классического GC из языков программирования Java или C#. Во-первых, полный контроль над тем, где и для чего использовать GC. Во-вторых, отсутствует проход по ссылкам внутри объекта и тяжёлая работа GC (проход по графу зависимостей, обнаружение циклических ссылок на рантайме и прочее).

Read-Copy-Update (RCU)

Hazard Pointer хорошо подходит для небольших кэшей. Однако когда ваши кэши занимают несколько гигабайт, вы вряд ли захотите держать несколько поколений кэшей в памяти. Например, при обновлении старое поколение данных не подчистится, если есть активные читатели, и будет находиться в памяти, пока не подоспеет новое (третье) поколение и не будет вызван retire() на втором поколении. Затраты по памяти ×3 — нехорошо.

Как раз для таких случаев в C++26 и добавили RCU, предоставляющий полный контроль над данными. Его использование очень похоже на Hazard Pointer:

RCU Hazard Pointer
struct Data   : std::rcu_obj_base<Data> { /* members */ };  std::atomic<Data*> pdata_;  template <typename Func> void reader_op(Func userFn) {   std::scoped_lock _{             std::rcu_default_domain()};   Data* p = pdata_;   userFn(p); }  void writer(Data* newdata) {   Data* old = pdata_.exchange(newdata);   old->retire(); } 

struct Data   : std::hazard_pointer_obj_base<Data> { /* members */ };  std::atomic<Data*> pdata_;  template <typename Func> void reader_op(Func userFn) {   auto h = std::make_hazard_pointer();    Data* p = h.protect(pdata_);   userFn(p); }  void writer(Data* newdata) {   Data* old = pdata_.exchange(newdata);   old->retire(); } 

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

Однако с RCU мы получаем ещё и возможность подождать завершения текущего поколения данных и можем явно позвать деструкторы устаревших объектов:

void shutdown() {     writer(nullptr);     std::rcu_synchronize(); // подождать конца поколения     std::rcu_barrier(); // удалить retired объекты } 

Также здесь предусмотрен ручной механизм управления памятью, при котором не надо наследовать свои данные от std::rcu_obj_base:

struct Data { /* members */ };  std::atomic<Data*> pdata_;  template <typename Func> void reader_op(Func userFn) {     std::scoped_lock l(std::rcu_default_domain());     Data* p = pdata_;     if (p) userFn(p); }  void writer(Data* newdata) {     Data* old = pdata_.exchange(newdata);     std::rcu_synchronize(); // дождаться завершения старых читателей     delete old; }  void shutdown() {     writer(nullptr); } 

Наконец, за счёт того что RCU не требуется знание о защищаемых объектах, можно защищать одновременно несколько объектов:

struct Data { /* members */ }; struct Data2 { /* members */ };  std::atomic<Data*> pdata_; std::atomic<Data2*> pdata2_{getData2()};  template <typename Func> void reader_op(Func userFn) {     std::scoped_lock l(std::rcu_default_domain());     userFn(pdata1_.load(), pdata2_.load()); } 

Это очень удобно, если вы разрабатываете lock-free или wait-free алгоритмы. Вы можете сделать свой маленький Garbage Collector и отделить задачу написания алгоритма от задачи менеджмента памяти.

native_handle()

С++26 теперь предоставляет доступ к файловому дескриптору (handle) для std::*fstream классов. Появляется возможность вызывать специфичные для платформы методы и при этом продолжать использовать классы стандартной библиотеки:

std::ofstream ofs{"data.txt"}; ofs << "Hello word!"; ofs.flush();  // передать из внутренних буферов данные в систему flush(ofs.native_handle());  // дождаться записи на диск 

*function*

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

Возьмём, к примеру, std::function:

  • Он требует копируемости объекта. Но в современном коде функциональные объекты могут быть не копируемыми, а лишь перемещаемыми (или даже неперемещаемыми).
  • Не работает с noexcept. Зачастую хочется указать в интерфейсе, что функциональный объект не должен бросать исключения (например, std::function<int(char) noexcept>.
  • Не передаётся в регистрах. Тип нетривиален, из-за чего многие платформы не могут его передавать в функции максимально эффективно.
  • У него сломан const. Можно сохранить в std::function функциональный объект с состоянием, которое будет меняться при вызове. При этом всё ещё можно звать std::function::operator().

Чтобы побороть эти проблемы, в C++23 и C++26 были добавлены новые классы:

  • std::move_only_function — C++23 владеющий класс, который работает с const +noexcept и позволяет принимать во владение некопируемые объекты.
  • std::copyable_function — C++26 владеющий класс, который работает с const +noexcept. Фактически это исправленный и осовремененный std::function.
  • std::function_ref — C++26 невладеющий класс, который работает с const + noexcept. Максимально эффективно передаётся компилятором через параметры функций. Фактически это type-erased ссылка на функцию.

Со всеми этими новинками намного проще выражать требования к функциональным объектам прямо в коде:

// Ссылка на функциональный объект, который не должен менять своё состояние и // не должен выкидывать исключения std::string ReadUntilConcurrent(std::function_ref<bool(int) const noexcept> pred);  // Владеющий функциональный объект, который может менять своё состояние и // не должен выкидывать исключения std::string AsyncReadUntil(std::move_only_function<bool(int) noexcept> pred);  // Владеющий функциональный объект, который не должен менять своё состояние и // может копироваться внутри метода AsyncConcurrentReadUntil std::string AsyncConcurrentReadUntil(std::copyable_function<bool(int) const> pred); 

constexpr

Хорошие новости для всех поклонников compile time вычислений. В C++26 больше математических функций из <cmath> и <complex> были помечены как constexpr.

Также разрешили делать static_cast указателя к void* и преобразование из void* к указателю на тип данных, который действительно находится по данному указателю. С помощью этих нововведений можно написать std::any, std::function_ref, std::move_only_function, std::copyable_function и другие type-erased классы, которыми можно пользоваться в compile time.

А ещё функции std::*stable_sort, std::*stable_partition и std::*inplace_merge тоже стали constexpr.

А разве complex и стандартные алгоритмы не были уже constexpr?

Когда в 2017 году я начал размечать <complex> и стандартные алгоритмы, constexpr был ещё молод.

Не было возможности делать динамические аллокации в constexpr, поэтому аллоцирующие алгоритмы стандартной библиотеки std::*stable_sort, std::*stable_partition и std::*inplace_merge не были помечены как constexpr.

C <complex> немного другая история: не было опыта написания constexpr функций, потенциально работающих с глобальным состоянием. Со временем опыт набрался, возможности constexpr расширились. И я очень рад, что доработали вещи, с которых я начинал в комитете. Ребята молодцы!

std::submdspan

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

std::vector<short> image = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18}; // R G B,R G B,R G B, R G B, R G B, R G B enum Colors: unsigned { kRed, kGreen, kBlue, kTotalColors }; 

Начиная с C++23, есть класс std::mdspan. Он позволяет представить единый кусок памяти в виде многомерного массива. Например, массива с размерностями 2 x 3 x kTotalColors:

auto int_2d_rgb = std::mdspan(image.data(), 2, 3, (int)kTotalColors); 

С его помощью можно вывести все зелёные составляющие пикселя с помощью подобного кода:

std::cout << "\nGreens by row:\n"; for(size_t row = 0; row != int_2d_rgb.extent(0); row++) {   for(size_t column = 0; column != int_2d_rgb.extent(1); column++)     std::cout << int_2d_rgb[row, column, (int)kGreen] << ' ';   std::cout << "\n"; } 

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

Greens by row: 2 5 8 11 14 17 

А вот дальше — новинка C++26. Можно создавать вью над отдельными размерностями массива. Например, вью над строкой с индексом 1 по зелёным пикселям:

auto greens_of_row0 = std::submdspan(int_2d_rgb, 1, std::full_extent, (int)kGreen); 

Воспользуемся ей:

std::cout << "Greens of row 1:\n"; for(size_t column = 0; column != greens_of_row0.extent(0); column++)   std::cout << greens_of_row0[column] << ' '; 

Получим:

Greens of row 1: 11 14 17 

Другой пример. Вью над всеми зелёными пикселями:

std::cout << "\nAll greens:\n"; auto pixels = std::mdspan(int_2d_rgb.data_handle(), int_2d_rgb.extent(0) * int_2d_rgb.extent(1), (int)kTotalColors); auto all_greens = std::submdspan(pixels, std::full_extent, std::integral_constant<int, (int)kGreen>{}); for(size_t i = 0; i != all_greens.extent(0); i++)   std::cout << all_greens[i] << ' ';  

Вывод такой:

All greens: 2 5 8 11 14 17 

А почему где-то std::integral_constant, а где-то просто чиселка?

Функция std::submdspan шаблонная и она может принимать как рантайм параметры, так и compile time std::integral_constant. С последним компилятор может изредка чуть лучше оптимизировать код.

Прочие новинки

Чтобы проще было работать с std::to_chars и std::from_chars, в структуру результата этих функций добавили explicit operator bool(). Теперь можно писать if (!std::to_chars(begin, end, number)) throw std::runtime_error();.

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

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

Кстати о контейнерах. Была добавлена последняя пачка недостающих перегрузок для работы с гетерогенными ключами. Теперь все методы at, operator[], try_emplace, insert_or_assign, insert, bucket не требуют временных копий ключей при использовании с ключами другого типа.

std::bind_front и std::bind_back обзавелись возможностью принимать member-pointer шаблонным параметром, что уменьшает размер итогового функционального объекта, и позволяет ему лучше попадать в преаллоцированные буферы и регистры. Пустячок, а приятно.

Дальнейшие планы

Международный комитет активно взялся за std::simd и executors. Есть все шансы увидеть последний в течение года в стандарте.

Наша Рабочая Группа 21 потихоньку начала расширяться. К нам присоединились Роман Русяев и Тимур Думлер. Надеемся, что работа над идеями и прототипами ускорится.

Следующая встреча международного комитета будет в ноябре. Если вы нашли какие-то недочёты в стандарте или у вас есть идеи по улучшению языка C++ — пишите. Поможем советом и делом.


ссылка на оригинал статьи https://habr.com/ru/articles/753260/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *