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

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

1vwnecq2qaxmin6vtdxqcuv77ja.png

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

Вступление

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

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

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

  • Основной синтаксис
  • Тип лямбды
  • Оператор вызова
  • Захват переменных (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

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

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


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

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

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

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


Например:

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


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

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


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

template
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

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

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

Вы можете захватывать переменные (при условии, что они также являются 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 и не имеет изменяемых членов.

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

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

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

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

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

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

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

#include 

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(); };
}


Еще кое-что:

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

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

Будущее с 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 y; })> map;


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

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

Шаблон лямбд

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

Для лямбды:

[](auto x) { x; }


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

template
void operator(T x) { x; }


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

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

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

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


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

prog.cc: In instantiation of 'main():: [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 = [](std::vector const& vec) { 
        std::cout<< std::size(vec) << '\n';
        std::cout<< vec.capacity() << '\n';
    };


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


void operator(std::vector const& s) { ... }


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

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

note:   mismatched types 'const std::vector' and 'int'

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

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

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

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

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


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

auto f = [](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 vi;
  std::for_each(vi.begin(), vi.end(), foo);
}


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

error: no matching function for call to 
for_each(std::vector::iterator, std::vector::iterator,
 )
   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(x)...))) \
   -> decltype(foo(std::forward(x)...)) \
  { return foo(std::forward(x)...); }


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

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

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

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

Вывод

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

Можно заметить, что с каждой итерацией языка лямбда-выражения смешиваются с другими элементами C++. Например, до C++17 мы не могли использовать их в контексте constexpr, но теперь это возможно. Аналогично с обобщенными лямбдами начиная с 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 июня.

© Habrahabr.ru