[Перевод] Лямбды: от 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 июня.