Привет! На связи Антон Полухин из Техплатформы Городских сервисов Яндекса, и сейчас я расскажу о софийской встрече Международного комитета по стандартизации языка программирования C++, в которой принимал активное участие. Это была
последняя встреча, на которой новые фичи языка, с предодобренным на прошлых встречах дизайном, ещё могли попасть в C++26.
И результат превзошёл все ожидания:
- compile-time-рефлексия
- рефлексия параметров функций
- аннотации
std::optional<T&>
- параллельные алгоритмы
Compile-time рефлексия
Рефлексия будет в C++26! Это просто великолепные новости, очень многие ожидали эту фичу — у многих разработчиков уже чешутся руки написать что-то интересное с её помощью.
Рефлексия в C++ отличается от рефлексии в большинстве других языков программирования, ведь она:
- Compile-time — происходит в момент компиляции единицы трансляции.
- Type-erased — результат рефлексирования любой сущности (типа данных, объекта, параметра, namespace) всегда представляет собой один и тот же тип:
std::meta::info
.
- Императивная — работа с рефлексией идёт в привычном императивном стиле программирования (в отличие от старого метапрограммирования через специализации шаблонов).
- Работает на уровне сущностей языка, а не на уровне токенов.
- Опционально учитывает права доступа (public, private) текущей области видимости.
- Обрабатывает ошибки через сompile-time-исключения.
Инструмент получился крайне мощный — он позволяет убрать множество boilerplate code при решении типовых (и не очень) задач.
Например, мы постоянно сталкиваемся с необходимостью задавать маппинг значения перечисления (enum) на его текстовое представление. В нашей кодовой базе для этого заводится специфичный bimap:
enum class Colors { kRed, kOrange, kYellow, kGreen, kBlue, kViolet };
constexpr userver::utils::TrivialBiMap kColorSwitch = [](auto selector) {
return selector()
.Case("Red", Colors::kRed)
.Case("Orange", Colors::kOrange)
.Case("Yellow", Colors::kYellow)
.Case("Green", Colors::kGreen)
.Case("Blue", Colors::kBlue)
.Case("Violet", Colors::kViolet);
};
TEST(TrivialBiMap, EnumToString) {
EXPECT_EQ(kColorSwitch.TryFind(Colors::kGreen), "Green");
EXPECT_EQ(kColorSwitch.TryFind("Orange"), Colors::kOrange);
}
Как видите, писать такие маппинги — весьма рутинная и скучная задача. С помощью рефлексии её можно проделать единожды:
namespace impl {
template <typename E>
consteval auto MakeEnumLambda() {
auto lambda = [](auto selector) {
auto s = selector();
template for (std::meta::info e : std::meta::enumerators_of(^^E)) {
s.Case(
std::meta::extract<E>(e),
std::meta::identifier_of(e).remove_prefix(1) // удаляем `k`
);
});
return s;
};
return lambda;
}
} // namespace impl
template <typename E>
requires std::is_enum_v<E>
inline constexpr userver::utils::TrivialBiMap kEnum = impl::MakeEnumLambda<E>();
И после этого переиспользовать решение:
enum class Colors { kRed, kOrange, kYellow, kGreen, kBlue, kViolet };
TEST(TrivialBiMap, EnumToString) {
EXPECT_EQ(kEnum<Colors>.TryFind(Colors::kGreen), "Green");
EXPECT_EQ(kEnum<Colors>.TryFind("Orange"), Colors::kOrange);
}
Что за utils::TrivialBiMap?
Это контейнер для хранения известных на compile-time-данных. Контейнер позволяет молниеносно искать по ключу и по значению за O(1). При этом искать намного быстрее, чем unordered-контейнеры и flat_map. Мы активно им пользуемся во фреймворке 🐙 userver в Техплатформе Городских сервисов Яндекса. Исходники его можно посмотреть
на Гитхабе, а описание принципа его работы есть
в видео.
Предложение по рефлексии и больше примеров можно увидеть в
P2996. С предложением на
template for
(expansion statement, compile-time развёрнутый цикл) можно ознакомиться в
P1306.
От меня, как от пользователя языка C++, огромное спасибо всем людям, которые сделали рефлексию возможной! Это был долгий путь, который начался в 2007 году с первого предложения на добавление
constepxr
. С тех пор Комитет расширял возможности compile-time-вычислений: добавил
constepxr
-алгоритмы, разметил классы как
constepxr
, ввёл
consteval
, реализовал
constepxr
-аллокации и использование исключений в
constepxr
… — и наконец пришёл к P2996!
Приятно осознавать, что
Рабочая Группа 21 тоже приложила руку к этому процессу:
P0031,
P0426,
P0639,
P0202,
P0858,
P0879,
P1032,
P2291,
P2417… Хотя наш вклад несравним с работой, проделанной Daveed Vandevoorde, Hana Dusíková, Faisal Vali, Andrew Sutton, Barry Revzin, Dan Katz, Peter Dimov, Wyatt Childers и многими другими людьми, годами работавшими над рефлексией и
constepxr
-вычислениями.
Рефлексия аннотаций и параметров функций
Праздник на предложении P2996 не закончился. Весьма неожиданно успели принять в стандарт
P3096 и
P3394.
Первое предложение позволяет получить типы и имена параметров функций, и в том числе — работать с конструкторами и операторами. Это мощный, но в то же время хрупкий инструмент. Например, реализации стандартных библиотек C++ вольно обходятся с именами параметров функций, и они не всегда совпадают с именами, описанными в стандарте. Так что привязываться к именам параметров без большой необходимости не рекомендуется.
Второе предложение позволяет делать собственные аннотации (не путать с атрибутами!), которые доступны для рефлексии и дают возможность дополнительно настраивать кодогенерацию.
У аннотаций есть синтаксис
[[=constant-expression]]
, где
constant-expression
может быть любым выражением, вычислимым на этапе компиляции.
Например, без аннотаций можно сделать вот такую функцию, которая будет выводить имена полей структуры вместе со значениями полей:
namespace my_reflection {
template <typename T>
void PrintKeyValue(const T& value) {
template for (constexpr auto field : nonstatic_data_members_of(^^T)) {
std::println("{}: {}", identifier_of(field), value.[: field :]);
}
}
} // namespace my_reflection
// Пример использования:
struct Pair {
int first;
int y;
};
my_reflection::PrintKeyValue(Pair{1, 2});
// Вывeдет в консоль:
// first: 1
// y: 2
А с помощью аннотаций можно переопределять имена полей:
namespace my_reflection {
struct Name{ std::string_view name; };
template <typename T>
void PrintKeyValue(const T& value) {
template for (constexpr auto field : nonstatic_data_members_of(^^T)) {
constexpr auto annotation_vec = annotations_of(field);
constexpr std::string_view name = (
annotation_vec.size() == 1
&& type_of(annotation_vec[0]) == ^^my_reflection::Name
? std::meta::extract<my_reflection::Name>(annotation_vec[0]).name
: identifier_of(field)
);
std::println("{}: {}", name, value.[: field :]);
}
}
} // namespace my_reflection
// Пример использования:
struct Pair {
int first;
[[=my_reflection::Name{"second"}]]
int y;
};
my_reflection::PrintKeyValue(Pair{1, 2});
// Вывeдет в консоль:
// first: 1
// second: 2
Ещё больше примеров доступно в самом
предложении P3394.
std::optional<T&>
Библиотека Boost.Optional долгое время позволяла создавать объекты
boost::optional<T&>
. В
P2988 эта функциональность доехала и до C++26.
Но если можно использовать просто
T*
, зачем же нужен
std::optional<T&>
? У последнего есть свои плюсы:
- запрещает арифметику указателей, избавляя от части возможных ошибок;
- не вызывает недоумения (а надо ли освобождать ресурсы по этому указателю?);
- имеет удобные для использования монадические интерфейсы и удобные value_or()-функции;
- может передаваться как диапазон в ranges.
Параллельные алгоритмы
Радостная новость для тех, кто пользуется параллельными алгоритмами. С принятием в C++26 предложения
P3179 можно использовать политики выполнения (например,
std::execution::par_unseq
) с алгоритмами в
std::ranges
.
Основной автор предложения, Ruslan Arutyunyan, подсвечивает интересную фишку из данного документа: начиная с
P3179 ranges
начинают использоваться как выходной параметр. Вместо
std::ranges::copy(std::execution::par, in, out.begin());
мы получаем более безопасный и короткий интерфейс вида
std::ranges::copy(std::execution::par, in, out);
.
Если выходной диапазон меньше, чем входной, не произойдёт проезда по памяти — скопируется лишь то количество элементов, которое можно скопировать в выходной диапазон. Более того, если пользователь передал выходной диапазон меньшего размера по ошибке, у него всегда есть возможность это определить: все алгоритмы возвращают точку, до которой они смогли дойти во входном диапазоне (входных диапазонах). Особенными в этом отношении являются ranges::reverse_copy и ranges::rotate_copy. Кому интересно, могут почитать о последних двух алгоритмах в
P3179 и в
P3709
Грустная новость: пока не получится использовать параллельные алгоритмы с schedulers и senders из принятого в C++26
P2300. Работа в этом направлении продолжится уже в C++29 (в
P2500).
В
P3111 для C++26 расширили возможности атомарных переменных. Им добавили методы
void store_
, которые, в отличие от методов
fetch_
, не возвращают значение, и, соответственно, у компилятора больше возможностей для их оптимизаций.
Казалось бы, что делает эта новость в разделе про параллельные алгоритмы? А вот что: по стандарту, нельзя использовать операции
atomic::fetch_
в параллельных алгоритмах с
std::execution::*unseq
. Операции
atomic::store_
как раз позволяют обойти эту проблему — их можно использовать вместе с
std::execution::*unseq
.
Ещё немного о ranges
Давайте поиграем в угадайку! Как вы думаете, почему следующий код не скомпилируется?
for (auto x : std::ranges::iota(0, some_vector.size())) {
std::cout << some_vector[x] << std::endl;
}
Разгадка
Код не скомпилируется со словами no matching function for call to 'iota_view(int, long unsigned int)'
, так как iota требует одинаковые типы входных параметров.
Как раз чтобы не сталкиваться с такой проблемой и не писать лишнего, в C++26 был добавлен
std::ranges::indices
в
P3060:
for (auto x : std::ranges::indices(some_vector.size())) {
std::cout << some_vector[x] << std::endl;
}
Продолжим с нашей угадайкой. Теперь загадка от Nicolai Josuttis. Что произойдёт в следующем примере?
std::vector<std::string> coll1{"Amsterdam", "Berlin", "Cologne", "LA"};
// Перемещаем длинные строки в обратном порядке в другой контейнер
auto large = [](const auto& s) { return s.size() > 5; };
auto sub = coll1 | std::views::filter(large)
| std::views::reverse
| std::views::as_rvalue
| std::ranges::to<std::vector>();
Разгадка
А вот тут будет проезд по памяти и Segmentation Fault. Почему? Воспользуйтесь
ссылкой и попробуйте раздебажить. Добавление
std::println
в фильтр может помочь.
Проблема кроется прямо в дизайне
std::views::filter
. Увы, фильтр позволяет проходить по диапазону несколько раз, при этом он не накладывает константность на данные. Как результат — данные можно «вытащить» или изменить, и при последующих прохождениях фильтр будет сходить с ума. Nicolai Josuttis приводит ещё пример, который является неопределённым поведением (undefined behavior, UB) с точки зрения стандарта:
// Возвращаем умерших монстров к жизни
auto dead = [] (const auto& m) { return m.isDead(); };
for (auto& m : monsters | std::views::filter(dead)) {
m.bringBackToLive(); // undefined behavior
}
Если бы после фильтра был ещё, например,
std::views::reverse
, код мог бы сломаться.
Чтобы обойти все эти ужасы c
std::views::filter
, в
P3725 (документ может быть пока недоступен) предлагается добавить
std::views::input_filter
, фактически убирая возможность несколько раз фильтровать один и тот же элемент, эквивалентен
filter_view(to_input_view(E), P)
. Возможно, эту новинку удастся внести в стандарт как багфикс и увидеть решение уже в C++26.
Прочие новинки
std::string
обзавёлся методом subview
, который работает по аналогии с substr
, но, в отличие от последнего, возвращает std::string_view
(P3044).
std::simd
оброс новыми методами и функциональностью в P2876, P3480, P2664, P3691.
- Из
std::exception_ptr
теперь можно достать исключение, не выкидывая его, а используя std::exception_ptr_cast<Exception>(exception_ptr)
(P2927). И можно это делать даже в compile-time (P3748, может быть доступен позже).
- В последний момент проскочило предложениеP3560, которое меняет способ сообщения об ошибке для рефлексии. То, что раньше было ошибкой компиляции, теперь стало исключением, выкинутым на этапе компиляции, — его можно ловить и обрабатывать.
- Из приятных мелочей — в C++26 добавили класс
std::constant_wrapper
и переменную constexpr std::cw
. Это более краткая замена для std::integral_constant
. При этом они обладают всеми операторами нижележащего типа, что позволяет использовать их как обычные числа, но передавать в функцию как compile-time-константы:
void sum_is_42(auto x, auto y) {
static_assert(x + y == 42);
}
sum_is_42(std::cw<40>, std::cw<2>);
std::cw
из предложения P2781 собенно удобен при работе с std::mdspan
. Например, std::mdspan(data, std::integral_constant<std::size_t, 10>{}, std::integral_constant<std::size_t, 20>{}, std::integral_constant<std::size_t, 30>{});
, превращается просто в std::mdspan(data, std::cw<10>, std::cw<20>, std::cw<30>);
- В executors добавили
std::execution::task
в P3552 и std::execution::write_env
+ std::execution::unstoppable
sender-адаптеры в P3284. Теперь можно совмещать executors и корутины, чтобы ещё сильнее смущать коллег на код-ревью.
- Наконец в P3697 продолжили завинчивание гаек с безопасностью, и ещё больше функций стандартной библиотеки обросли hardening-проверками.
Итоги
C++26 теперь feature complete! И рефлексия в нём будет!
Следующий этап стандартизации С++: представители стран посылают свои замечания к C++26, подсвечивая важные баги и проблемы. Тут и вы можете внести свою лепту! Если у вас есть замечания к C++26 или любимый многострадальный баг, а может, вы знаете о какой-то проблеме — пишите нашей рабочей группе в раздел раздел «
Предложения»: и мы отправим ваши (исправимые на данном этапе) замечания в ISO. Разборам и исправлениям багов будут посвящены как минимум две ближайшие встречи Международного комитета.
На этом у меня всё. Приходите пообщаться на
C++ Zero Cost Conf 2 августа, послушать интересные и практичные доклады и пообщаться с командой userver на стенде городских сервисов Яндекса.
Пишите в комментариях о самой ожидаемой или любимой фиче в предстоящем C++26. С радостью отвечу на ваши вопросы :)