Появившиеся в 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 formT* 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
Интересные ссылки
Большое спасибо Валерию Артюхину за корректуру.