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

Встреча ISO C++ в Софии: С++26 и рефлексия

Reading time9 min
Views7.4K


Привет! На связи Антон Полухин из Техплатформы Городских сервисов Яндекса, и сейчас я расскажу о софийской встрече Международного комитета по стандартизации языка программирования 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. С радостью отвечу на ваши вопросы :)
Tags:
Hubs:
+67
Comments59

Articles

Information

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