На недавней встрече комитет 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 приняли особые правила для переменных с именем
_
. Теперь можно писать лаконичный код, 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 (проход по графу зависимостей, обнаружение циклических ссылок на рантайме и прочее).
Однако у него есть кардинальные отличия от классического GC из языков программирования Java или C#. Во-первых, полный контроль над тем, где и для чего использовать GC. Во-вторых, отсутствует проход по ссылкам внутри объекта и тяжёлая работа GC (проход по графу зависимостей, обнаружение циклических ссылок на рантайме и прочее).
Read-Copy-Update (RCU)
Hazard Pointer хорошо подходит для небольших кэшей. Однако когда ваши кэши занимают несколько гигабайт, вы вряд ли захотите держать несколько поколений кэшей в памяти. Например, при обновлении старое поколение данных не подчистится, если есть активные читатели, и будет находится в памяти, пока не подоспеет новое (третье) поколение и не будет вызван
retire()
на втором поколении. Затраты по памяти ×3 — нехорошо.Как раз для таких случаев в C++26 и добавили RCU, предоставляющий полный контроль над данными. Его использование очень похоже на Hazard Pointer:
RCU | Hazard Pointer |
---|---|
|
|
Разница в том, что мы наследуем наши данные от другого базового класса, и что защищаемся, не указывая конкретный указатель.
Однако с 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::ofsteram 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> и стандартные алгоритмы,
Не было возможности делать динамические аллокации в
C <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
. Он позволяет представить единый кусок памяти в виде многомерного массива. Например, массива с размерностями 2x3xkTotalColors
: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++ — пишите. Поможем советом и делом.