Использование лямбда-выражений в необобщённом коде C++

от автора

Появившиеся в C++11 лямбды стали одной из самых крутых фич нового стандарта языка, позволив сделать обобщённый код более простым и читабельным. Каждая новая версия стандарта C++ добавляет новые возможности лямбдам, делая обобщённый код ещё проще и читабельнее. Вы заметили, что слово «обобщённый» повторилось дважды? Это неспроста – лямбды действительно хорошо работают с кодом, построенным на шаблонах. Но при попытке использовать их в необобщённом, построенном на конкретных типах коде, мы сталкиваемся с рядом проблем. Статья о причинах и путях решения этих проблем.

Вместо введения

Для начала определимся с терминологией: лямбдой мы называем lambda-expression – это выражение C++, определяющее объект замыкания (closure object). Вот цитата из стандарта C++:

[expr.prim.lambda.general]
A lambda-expression is a prvalue whose result object is called the closure object.
[Note 1: A closure object behaves like a function object. — end note]

Тип объекта замыкания – это уникальный безымянный класс.

[expr.prim.lambda.closure]
The type of a lambda-expression (which is also the type of the closure object) is a unique, unnamed non-union class type, called the closure type, whose properties are described below.

«Безымянный» в данном случае означает, что тип замыкания нельзя явно указать в коде, но получить его можно, чем мы ниже будем активно пользоваться. «Уникальный» означает, что каждая лямбда порождает новый тип замыкания, т. е. две абсолютно одинаковые с синтаксической точки зрения лямбды (будем называть такие лямбды однородными) имеют разные типы:

auto l1 = [](int x) { return x; }; auto l2 = [](int x) { return x; }; static_assert(!std::is_same_v<decltype(l1), decltype(l2)>);

Аналогично этот принцип распространяется и на обобщённый код, зависящий от типов замыканий:

template <typename Func> class LambdaDependent {  public:   explicit LambdaDependent(Func f) : f_{f} {}  private:   Func f_; };  LambdaDependent ld1{l1}; LambdaDependent ld2{l2}; static_assert(!std::is_same_v<decltype(ld1), decltype(ld2)>);

Это свойство не позволяет, например, складывать объекты замыканий в контейнеры (например, в std::vector<>).

Стандартные решения

Стандарт языка предоставляет готовое решение этой проблемы в виде std::function<>. Действительно, объект std::function<> может оборачивать однородные лямбды:

std::function f1{l1}; std::function f2{l2}; static_assert(std::is_same_v<decltype(f1), decltype(f2)>);

Такое решение действительно решает большинство проблем, однако подходит далеко не во всех случаях. Из объекта std::function<> нельзя получить сырой указатель на функцию, чтобы передать его, например, в какое-нибудь legacy API. Допустим, у нас есть функция:

int api_func(int(*fp)(int), int value) {   return fp(value); }

Интересно, что если мы попробуем передать в эту функцию любую из объявленных выше лямбд (l1 или l2), то код замечательно скомпилируется и запустится:

std::cout << api_func(l1, 123) << '\n'; // 123 std::cout << api_func(l2, 234) << '\n'; // 234

Так получается потому, что лямбда с пустым замыканием (их ещё называют лямбды без состояния) по стандарту может быть неявно преобразована в указатель на функцию:

[expr.prim.lambda.closure]
The closure type for a non-generic lambda-expression with no lambda-capture whose constraints (if any) are satisfied has a conversion function to pointer to function with C++ language linkage having the same parameter and return types as the closure type’s function call operator. The conversion is to “pointer to noexcept function” if the function call operator has a non-throwing exception specification. The value returned by this conversion function is the address of a function F that, when invoked, has the same effect as invoking the closure type’s function call operator on a default-constructed instance of the closure type. F is a constexpr function if the function call operator is a constexpr function and is an immediate function if the function call operator is an immediate function.

Для обобщённого кода можно применить явный static_cast<>:

LambdaDependent lf1{static_cast<int(*)(int)>(l1)}; LambdaDependent lf2{static_cast<int(*)(int)>(l2)}; static_assert(std::is_same_v<decltype(lf1), decltype(lf2)>);

Есть простой синтаксический трюк, позволяющий не писать громоздкий static_cast<> и не указывать явно сигнатуру функции:

LambdaDependent ls1{+l1}; LambdaDependent ls2{+l2}; static_assert(std::is_same_v<decltype(ls1), decltype(ls2)>);

Этот трюк работает из-за того, что унарный оператор + имеет встроенную перегрузку для любого типа.

[over.built]
For every type T there exist candidate operator functions of the form
T* operator+(T*);

Такая перегрузка, применённая к объекту замыкания, вызывает неявное преобразование к указателю на функцию, что аналогично явному static_cast<>.

Лямбды с состоянием

Описанный выше трюк отлично работает для лямбд с пустыми замыканиями, но как быть, если нужно передать лямбду с состоянием? Классический приём из старого доброго C – передавать указатель на функцию и указатель на контекст типа void*. Функция получает этот указатель, преобразовывает его к указателю на нужный тип и получает доступ к контексту.

int api_func_ctx(int(*fp)(void*, int), void* ctx, int value) {   return fp(ctx, value); }

Попробуем передать лямбду с состоянием в такую функцию:

int counter = 1; auto const_lambda = [counter](int value) {   return value + counter; };  std::cout << api_func_ctx([](void* ctx, int value) {   auto* lambda_ptr = static_cast<decltype(const_lambda)*>(ctx);   return (*lambda_ptr)(value); }, &const_lambda, 123) << '\n'; // 124

Здесь мы задаём новую лямбду, уже без состояния, которая неявно преобразуется в указатель на функцию. Эта лямбда получает контекст типа void*, преобразует его к указателю на тип замыкания, разыменовывает и вызывает как обычный функциональный объект. Кстати, это работает и с mutable лямбдами:

auto mutable_lambda = [&counter](int value) mutable {   ++counter;   return value * counter; };  std::cout << api_func_ctx([](void* ctx, int value) {   auto* lambda_ptr = static_cast<decltype(mutable_lambda)*>(ctx);   return (*lambda_ptr)(value); }, &mutable_lambda, 123) << ':' << counter << '\n'; // 246:2

Кажется, всё уже отлично работает. Но писать лямбду руками при каждом вызове api_func_ctx утомительно, хочется всё это обобщить и завернуть в красивую обёртку.

Наводим красоту

Технически для того, чтобы сохранить лямбду с состоянием, а потом восстановить её, достаточно 2х объектов:

  • контекст типа void* (это классический пример type erasure);

  • указатель на функцию, принимающую контекст и все параметры лямбды и возвращающую такой же тип.

Назовём тип для хранения «разобранного» объекта замыкания closure_erasure:

template <typename Ret, typename ...Args> struct closure_erasure {     Ret(*func)(void*, Args...);     void* ctx; }; 

Тут возникает проблема: как из типа замыкания выудить тип возвращаемого значения и параметров? На помощь нам приходит CTAD – этот тип может выводиться из типа параметра конструктора. Но что такого можно передать в конструктор от лямбды, из чего можно вывести все необходимые типы? Компилятор определяет для типа замыкания operator(), позволяющий вызывать объект замыкания как функциональный объект. Сигнатура этого оператора как раз и содержит всю необходимую информацию:

template<typename Lambda> explicit closure_erasure(Ret(Lambda::*)(Args...), void* ctx) :   func{     [](void* c, Args ...args) {       auto* lambda_ptr = static_cast<Lambda*>(c);       return (*lambda_ptr)(std::forward<Args>(args)...);     }   },   ctx{ctx} {} template<typename Lambda> explicit closure_erasure(Ret(Lambda::*)(Args...) const, void* ctx) :   func{     [](void* c, Args ...args) {       auto* lambda_ptr = static_cast<Lambda*>(c);       return (*lambda_ptr)(std::forward<Args>(args)...);     }   },   ctx{ctx} {} 

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

Остался последний шаг: обернуть создание обёртки в удобную функцию, чтобы не нужно было вручную извлекать указатель на operator() из типа замыкания:

auto make_closure_erasure = [](auto& lmb) {   return closure_erasure{     &std::remove_reference_t<decltype(lmb)>::operator(), &lmb}; };

Обратите внимание, что мы принимаем объект замыкания по неконстантной ссылке. Это важный момент, который намекает на важное ограничение применяемого механизма: мы несём ответственность за время жизни объекта замыкания!

Если мы ещё вспомним, что функция и лямбда может быть ещё noexcept, то финальная версия обёртки будет выглядеть так:

template <typename Ret, bool NoExcept, typename ...Args> struct closure_erasure {   Ret(*func)(void*, Args...) noexcept(NoExcept);   void* ctx;   template<typename Lambda>   explicit closure_erasure(Ret(Lambda::*)(Args...) noexcept(NoExcept), void* ctx) :     func{       [](void* c, Args ...args) noexcept(NoExcept) {         auto* lambda_ptr = static_cast<Lambda*>(c);         return (*lambda_ptr)(std::forward<Args>(args)...);       }     },     ctx{ctx} {}   template<typename Lambda>   explicit closure_erasure(Ret(Lambda::*)(Args...) const noexcept(NoExcept), void* ctx) :   func{     [](void* c, Args ...args) noexcept(NoExcept) {       auto* lambda_ptr = static_cast<Lambda*>(c);       return (*lambda_ptr)(std::forward<Args>(args)...);     }   },   ctx{ctx} {} };  auto make_closure_erasure = [](auto& lmb) {   return closure_erasure{     &std::remove_reference_t<decltype(lmb)>::operator(), &lmb}; };  auto li = make_closure_erasure(const_lambda); 	std::cout << api_func_ctx(li.func, li.ctx, 123) << '\n'; // 124 	li = make_closure_erasure(mutable_lambda); 	std::cout << counter << ':' << 	  api_func_ctx(li.func, li.ctx, 123) << '\n'; // 2:369 	std::cout << counter << ':' << 	  api_func_ctx(li.func, li.ctx, 123) << '\n'; // 3:492

Интересные ссылки

  1. Compiler Explorer с кодом из статьи

  2. Книга про лямбды «C++ Lambda Story»

  3. Back to Basics: Lambdas from Scratch — Arthur O’Dwyer — CppCon 2019

  4. C++ Weekly — Ep 246 — (+[](){})() What Does It Mean?

  5. Текущий черновик стандарта C++

Большое спасибо Валерию Артюхину за корректуру.

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


Комментарии

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

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