Как стать автором
Обновить

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

Время на прочтение6 мин
Количество просмотров7.7K

Появившиеся в 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++

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

Теги:
Хабы:
Всего голосов 11: ↑10 и ↓1+16
Комментарии6

Публикации

Истории

Работа

Программист C++
112 вакансий
QT разработчик
10 вакансий

Ближайшие события