Хабрахабр

[Перевод] Лямбды: от C++11 до C++20. Часть 2

Привет, хабровчане. В связи со стартом набора в новую группу по курсу «Разработчик C++», делимся с вами переводом второй части статьи «Лямбды: от C++11 до C++20». Первую часть можно прочитать тут.

В этой статье я описал побуждения, стоящие за этой мощной фичей C++, базовое использование, синтаксис и улучшения в каждом из языковых стандартов. В первой части серии мы рассмотрели лямбды с точки зрения C++03, C++11 и C++14. Я также упомянул несколько пограничных случаев.
Теперь пришло время перейти к C++17 и немного заглянуть в будущее (очень близкое!): C++20.

Вступление

Небольшое напоминание: идея этой серии пришла после одной из наших недавних встреч C++ User Group в Кракове.

Беседу вел эксперт по С++ Томас Каминский (см. У нас был живой сеанс программирования об «истории» лямбда-выражений. Вот это событие:
Lambdas: From C++11 to C++20 — C++ User Group Krakow. профиль Томаса в Linkedin).

Я решил взять код у Томаса (с его разрешения!) и на его основе написать статьи.В первой части серии я рассказывал о лямбда-выражениях следующее:

  • Основной синтаксис
  • Тип лямбды
  • Оператор вызова
  • Захват переменных (mutable, глобальные, статические переменные, члены класса и указатель this, move-able-only объекты, сохранение констант):
    • Return type
    • IIFE — Immediately Invoked Function Expression
    • Conversion to a function pointer
    • Возвращаемый тип
    • IIFE — Немедленно вызываемые выражения
    • Преобразование в указатель на функцию
  • Улучшения в C++14
    • Вывод возвращаемого типа
    • Захват с инициализатором
    • Захват переменной-члена
    • Обобщенные лямбда-выражения

Приведенный выше список является лишь частью истории лямбда-выражений!

Теперь давайте посмотрим, что изменилось в C++17 и что мы получим в C++20!

Улучшения в C++17

C++17 привнес два значительных улучшения в лямбда-выражения: Стандарт (черновик перед публикацией) N659 раздел про лямбды: [expr.prim.lambda].

  • constexpr лямбды
  • Захват *this

Что эти нововведения означают для нас? Давайте разберемся.

constexpr лямбда-выражения

Начиная с C++17, стандарт неявно определяет operator() для типа лямбды как constexpr, если это возможно:

Из expr.prim.lambda #4:
Оператор вызова функции является функцией constexpr, если за объявлением параметра условия соответствующего лямбда-выражения следует constexpr, или он удовлетворяет требованиям для функции constexpr.

Например:

constexpr auto Square = [] (int n) ; // implicitly constexpr
static_assert(Square(2) == 4);

Напомним, что в C++17 constexpr функция должна следовать таким правилам:

  • она не должна быть виртуальной (virtual);
    • ее возвращаемый тип должен быть литеральным типом;
    • каждый из типов ее параметров должен быть литеральным типом;
    • ее тело должно быть = delete, = default или составным оператором, который не содержит
      • asm-определений,
      • выражений goto,
      • меток,
      • блок try или
      • определение переменной не литерального типа, статической переменной или переменной потоковой памяти, для которой не выполняется инициализация.

Как насчет более практического примера?

template<typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(const Range& range, Func func, T init) { for (auto &&elem: range) { init += func(elem); } return init;
} int main() { constexpr std::array arr{ 1, 2, 3 }; static_assert(SimpleAccumulate(arr, [](int i) { return i * i; }, 0) == 14);
}

Поиграться с кодом можно здесь: @Wandbox

Алгоритм использует несколько элементов C++17: дополнения constexpr к std::array, std::begin и std::end (используемые в цикле for с диапазоном) теперь также являются constexpr, так что это означает, что весь код может быть выполнен во время компиляции. В коде используется constexpr лямбда, а затем она передается в простой алгоритм SimpleAccumulate.

Конечно, это еще не все.

Вы можете захватывать переменные (при условии, что они также являются constexpr):

constexpr int add(int const& t, int const& u) { return t + u;
} int main() { constexpr int x = 0; constexpr auto lam = [x](int n) { return add(x, n); }; static_assert(lam(10) == 10);
}

Но есть интересный случай, когда вы не передаете захваченную переменную дальше, например:

constexpr int x = 0;
constexpr auto lam = [x](int n) { return n + x };

В этом случае в Clang мы можем получить следующее предупреждение:

warning: lambda capture 'x' is not required to be captured for this use

Вероятно, это связано с тем, что х можно менять на месте при каждом использовании (если вы не передадите его дальше или не возьмете адрес этого имени).

Я нашел только (из cppreference) (но я не могу найти его в черновике...) Но, пожалуйста, сообщите мне, если вы знаете официальные правила для такого поведения.

Менять его точно нельзя.) (Примечание переводчика: как пишут наши читатели, наверное, имеется в виду подстановка значения 'x' в каждом месте, где он используется.

Лямбда-выражение может прочитать значение переменной, не захватывая ее, если переменная
* имеет константный non-volatile целочисленный или перечисляемый тип и была инициализирована с constexpr или
* является constexpr и не имеет изменяемых членов.

Будьте готовы к будущему:

Ваш код будет выглядеть одинаково для версии времени выполнения, а также для версии constexpr (версии времени компиляции)! В C++20 у нас будут constexpr стандартные алгоритмы и, возможно, даже некоторые контейнеры, поэтому constexpr лямбды будут очень полезны в этом контексте.

В двух словах:

constexpr лямбда позволяет вам согласовываться с шаблонным программированием и, возможно, иметь более короткий код.

Теперь давайте перейдем ко второй важной фиче, доступной в C++17:

Capture of *this
Захват *this

По умолчанию мы захватываем this (как указатель!), и поэтому у нас могут возникнуть проблемы, когда временные объекты выходят из области видимости… Это можно исправить, используя метод захвата с инициализатором (см. Вы помните нашу проблему, когда мы хотели захватить член класса? Но теперь, в C++17 у нас есть другой путь. в первой части серии). Мы можем обернуть копию *this:

#include <iostream> struct Baz { auto foo() { return [*this] { std::cout << s << std::endl; }; } std::string s;
}; int main() { auto f1 = Baz{"ala"}.foo(); auto f2 = Baz{"ula"}.foo(); f1(); f2();
}

Поиграться с кодом можно здесь: @Wandbox

Захват нужной переменной-члена с помощью захвата с инициализатором защищает вас от возможных ошибок с временными значениями, но мы не можем делать то же самое, когда хотим вызвать метод типа:

Например:

struct Baz { auto foo() { return [this] { print(); }; } void print() const { std::cout << s << '\n'; } std::string s;
};

В C++14 единственный способ сделать код более безопасным захватывать this с инициализатором:

auto foo() { return [self=*this] { self.print(); };
} Но в C ++ 17 это можно сделать чище: auto foo() { return [*this] { print(); };
}

Еще кое-что:

Это может привести к ошибкам в будущем… и это устареет в C++20. Обратите внимание, что если вы пишете [=] в функции-члене, this захватывается неявно!

Вот мы и подошли к следующему разделу: будущее.

Будущее с C++20

В C++20 мы получим следующие функции:

  • Разрешить [=, this] как лямбда-захват — P0409R2 и отменить неявный захват этого через [=] — P0806
  • Расширение пакета в lambda init-capture: ... args = std::move (args)] () {} — P0780
  • статический, thread_local и лямбда-захват для структурированных привязок — P1091
  • шаблон лямбды (также с концепциями) — P0428R2
  • Упрощение неявного лямбда-захвата — P0588R1
  • Конструктивные и присваиваемые лямбда без сохранения состояния по умолчанию — P0624R2
  • Лямбды в невычисляемом контексте — P0315R4

В большинстве случаев нововведенные функции «очищают» лямбда-использование, и они допускают некоторые расширенные варианты использования.

Например, с P1091 вы можете захватить структурированную привязку.

В C++20 вы получите предупреждение, если захватите [=] в методе: У нас также есть разъяснения, связанные с захватом этого.

struct Baz { auto foo() { return [=] { std::cout << s << std::endl; }; } std::string s;
}; GCC 9: warning: implicit capture of 'this' via '[=]' is deprecated in C++20

Если вам действительно нужно запечатлеть это, вы должны написать [=, this].

Существуют также изменения, связанные с расширенными вариантами использования, такими как неоцененные контексты и лямбды без сохранения состояния, которые можно конструировать по умолчанию.

С обоими изменениями вы сможете написать:

std::map<int, int, decltype([](int x, int y) { return x > y; })> map;

Прочитайте мотивы этих функций в первой версии предложений: P0315R0 и P0624R0.

Но давайте посмотрим на одну интересную особенность: лямбда-шаблоны.

Шаблон лямбд

В C++14 мы получили обобщенные лямбды, это означает, что параметры, объявленные как auto, являются параметрами шаблона.

Для лямбды:

[](auto x) { x; }

Компилятор генерирует оператор вызова, который соответствует следующему шаблонному методу:

template<typename T>
void operator(T x) { x; }

Но не было никакого способа изменить этот параметр шаблона и использовать реальные аргументы шаблона. В C++20 это будет возможно.

Например, как мы можем ограничить нашу лямбду, чтобы работать только с векторами некоторого типа?

Мы можем написать общую лямбду:

auto foo = []<typename T>(const auto& vec) { std::cout<< std::size(vec) << '\n'; std::cout<< vec.capacity() << '\n'; };

Но если вы вызовете его с параметром int (например, foo(10);), вы можете получить какую-то трудночитаемую ошибку:

prog.cc: In instantiation of 'main()::<lambda(const auto:1&)> [with auto:1 = int]':
prog.cc:16:11: required from here
prog.cc:11:30: error: no matching function for call to 'size(const int&)' 11 | std::cout<< std::size(vec) << '\n';

В С++20 мы можем написать:

auto foo = []<typename T>(std::vector<T> const& vec) { std::cout<< std::size(vec) << '\n'; std::cout<< vec.capacity() << '\n'; };

Вышеупомянутая лямбда разрешает оператор шаблонного вызова:

<typename T>
void operator(std::vector<T> const& s) { ... }

Параметр шаблона следует после предложения захвата [].

Если вы вызываете его с помощью int (foo(10);), вы получите более приятное сообщение:

note: mismatched types 'const std::vector<T>' and 'int'

Поиграться с кодом можно здесь: @Wandbox

В приведенном выше примере компилятор может предупредить нас о несоответствиях в интерфейсе лямбды, нежели в коде внутри тела.

Поэтому, если вы хотите получить к нему доступ, вы должны использовать decltype(x) (для лямбда-выражения с аргументом (auto x)). Другим важным аспектом является то, что в универсальной лямбде у вас есть только переменная, а не ее тип шаблона. Это делает некоторый код более многословным и сложным.

Например (используя код из P0428):

auto f = [](auto const& x) { using T = std::decay_t<decltype(x)>; T copy = x; T::static_function(); using Iterator = typename T::iterator;
}

Теперь можно записать как:

auto f = []<typename T>(T const& x) { T::static_function(); T copy = x; using Iterator = typename T::iterator;
}

В приведенном выше разделе у нас был краткий обзор C ++ 20, но у меня есть еще один дополнительный пример использования для вас. Эта техника возможна даже в C++14. Так что читайте дальше.

Бонус — LIFTинг с лямбдами

В настоящее время у нас есть проблема, когда у вас есть перегрузки функций, и вы хотите передать их в стандартные алгоритмы (или все, что требует некоторого вызываемого объекта):

// two overloads:
void foo(int) {}
void foo(float) {} int main()
{ std::vector<int> vi; std::for_each(vi.begin(), vi.end(), foo);
}

Мы получаем следующую ошибку из GCC 9 (trunk):

error: no matching function for call to for_each(std::vector<int>::iterator, std::vector<int>::iterator, <unresolved overloaded function type>) std::for_each(vi.begin(), vi.end(), foo); ^^^^^

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

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

std::for_each(vi.begin(), vi.end(), [](auto x) { return foo(x); });

И в наиболее общей форме нам нужно немного больше набирать:

#define LIFT(foo) \ [](auto&&... x) \ noexcept(noexcept(foo(std::forward<decltype(x)>(x)...))) \ -> decltype(foo(std::forward<decltype(x)>(x)...)) \ { return foo(std::forward<decltype(x)>(x)...); }

Довольно сложный код… верно? 🙂

Давайте попробуем расшифровать его:

Чтобы определить его правильно, нам нужно указать noexcept и тип возвращаемого значения. Мы создаем обобщенную лямбду и затем передаем все аргументы, которые мы получаем. Вот почему мы должны дублировать вызывающий код — чтобы получить правильные типы.
Такой макрос LIFT работает в любом компиляторе, который поддерживает C++14.

Поиграться с кодом можно здесь: @Wandbox

Вывод

В этом посте мы посмотрели на значительные изменения в C++17, и сделали обзор новых возможностей в C++20.

Например, до C++17 мы не могли использовать их в контексте constexpr, но теперь это возможно. Можно заметить, что с каждой итерацией языка лямбда-выражения смешиваются с другими элементами C++. Я что-то пропустил? Аналогично с обобщенными лямбдами начиная с C++14 и их эволюцией в C++20 в форме шаблонных лямбд. Пожалуйста, дайте мне знать в комментариях! Может быть, у вас есть какой-нибудь захватывающий пример?

Ссылки

C++11 — [expr.prim.lambda]
C++14 — [expr.prim.lambda]
C++17 — [expr.prim.lambda]
Lambda Expressions in C++ | Microsoft Docs
Simon Brand — Passing overload sets to functions
Jason Turner — C++ Weekly — Ep 128 — C++20’s Template Syntax For Lambdas
Jason Turner — C++ Weekly — Ep 41 — C++17’s constexpr Lambda Support

Приглашаем всех на традиционный бесплатный вебинар по курсу, который состоится уже завтра 14 июня.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть