Pull to refresh
818.06
Яндекс
Как мы делаем Яндекс

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

Reading time11 min
Views21K

На недавней встрече комитет 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++ — пишите. Поможем советом и делом.
Tags:
Hubs:
Total votes 66: ↑64 and ↓2+62
Comments119

Articles

Information

Website
www.ya.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия