Хабрахабр

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

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»