Эволюция лямбд в C++14, C++17 и C ++20 +17


Лямбда-выражения — одна из самых популярных фич современного C++. С тех пор, как они были представлены в C++11, лямбды проникли практически в каждую кодовую базу на C++.

И с момента их появления в C++11 их не переставали развивать, добавляя серьезные фичи для работы с ними. Некоторые из этих фич помогают писать более выразительный код, и, поскольку использование лямбда-выражений стало таким распространенным, каждому из нас определенно стоит потратить немного времени на изучение того, что мы можем с ними делать.

Цель этой статьи — рассказать об основных эволюционных этапах в истории лямбда-выражений, опустив некоторые мелкие детали. Всесторонний обзор лямбда-выражений уже больше тянет на отдельную книгу, нежели небольшую статью. Если вы хотите узнать больше, я рекомендую вам почитать книгу Бартоломея Филипика (Bartłomiej Filipek) C++ Lambda Story, которая раскрывает эту тему целиком и полностью.

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

Эта статья требует от вас наличие базовых знаний о лямбда-выражениях C++11. Ну что ж, начнем с C++14.

Лямбда-выражения в C++14

В C++14 лямбда-выражения получили четыре серьезных усовершенствования:

  • параметры по умолчанию;

  • шаблонные параметры;

  • обобщенный захват;

  • возврат лямбды из функции.

Параметры по умолчанию 

Начиная с C++14 лямбда-выражения могут принимать параметры по умолчанию, как и любая другая функция:

auto myLambda = [](int x, int y = 0){ std::cout << x << '-' << y << '\n'; };

std::cout << myLambda(1, 2) << '\n';
std::cout << myLambda(1) << '\n';

Этот код выводит следующее:

1-2
1-0

Шаблонные параметры 

В C++11 мы должны определить тип параметров лямбда-выражений:

auto myLambda = [](int x){ std::cout << x << '\n'; };

Начиная с C++14 мы можем заставить их принимать любой тип:

auto myLambda = [](auto&& x){ std::cout << x << '\n'; };

Даже если вам не нужно обрабатывать несколько типов, эта фича может быть очень полезной, чтобы избежать повторений и сделать код более компактным и читабельным. Например, такая лямбда:

auto myLambda = [](namespace1::namespace2::namespace3::ACertainTypeOfWidget const& widget) { std::cout << widget.value() << '\n'; };

становится такой:

auto myLambda = [](auto&& widget) { std::cout << widget.value() << '\n'; };

Обобщенный захват

В C++11 лямбда-выражения могут захватывать только существующие в их области видимости объекты:

int z = 42;
auto myLambda = [z](int x){ std::cout << x << '-' << z + 2 << '\n'; };

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

int z = 42;
auto myLambda = [y = z + 2](int x){ std::cout << x << '-' << y << '\n'; };

myLambda(1);

Этот код выводит следующее:

1-44

Возврат лямбда-выражения из функции

Лямбда-выражения приобрели кое-что для себя и благодаря другой фиче C++14: возможности возвращать auto из функции без указания возвращаемого типа. Поскольку тип лямбды генерируется компилятором, в C++11 мы не могли вернуть лямбду из функции:

/* какой тип нам следует здесь указать ?? */ f()
{
    return [](int x){ return x * 2; };
}

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

void f()
{
    // ...
    int z = 42;
    auto myLambda = [z](int x)
                    {
                        // ...
                        // ...
                        // ...
                    };
    // ...
}

Мы можем обернуть лямбду в другую функцию, тем самым введя новый уровень абстракции:

auto getMyLambda(int z)
{
    return [z](int x)
           {
               // ...
               // ...
               // ...
           };
}

void f()
{
    // ...
    int z = 42;
    auto myLambda = getMyLambda(z);
    // ...
}

Чтобы узнать больше об этом методе, советую вам почитать о внешних лямбда-выражениях.

Лямбда-выражения в C++17

C++17 привнес очень существенное улучшение в лямбда-выражения: их можно объявлять constexpr:

constexpr auto times2 = [] (int n) { return n * 2; };

Затем такие лямбды можно использовать в контекстах, оцениваемых во время компиляции:

static_assert(times2(3) == 6);

Это особенно полезно при работе с шаблонами.

Однако следует отметить, что constexpr лямбды становятся гораздо более полезными в C++20. Действительно, только в C++20 std::vector и большинство алгоритмов STL также становятся constexpr, и их можно использовать с constexpr лямбдами для создания сложных манипуляций с коллекциями, оцениваемыми во время компиляции.

Однако есть одно исключение - контейнер std::array. Неизменяющие операции доступа std::array становятся constexpr в C++14, а изменяющие - в C++17.

Захват копии *this

Еще одна фича, которую лямбда-выражения получили в C++17, — это простой синтаксис для захвата копии *this. Рассмотрим следующий пример:

struct MyType{
    int m_value;
    auto getLambda()
    {
        return [this](){ return m_value; };
    }
};

Эта лямбда захватывает копию this (указателя). Это может вызвать ошибки памяти, если лямбда переживет объект, например, как в следующем примере:

auto lambda = MyType{42}.getLambda();
lambda();

Поскольку MyType уничтожается в конце первого выражения, вызов лямбды во втором операторе разыменовывает this для доступа к его m_value, а он указывает на уже уничтоженный объект. Это приводит к неопределенному поведению (обычно к крашу приложения).

Один из возможных способов решить эту проблему — захватить копию всего объекта внутри лямбды. C++17 предоставляет для этого следующий синтаксис (обратите внимание на * перед this):

struct MyType
{
    int m_value;
    auto getLambda()
    {
        return [*this](){ return m_value; };
    }
};

Обратите внимание, что уже в C++14 можно было добиться такого же результата с помощью обобщенного захвата:

struct MyType
{
    int m_value;
    auto getLambda()
    {
        return [self = *this](){ return self.m_value; };
    }
};

C++17 только улучшает этот синтаксис.

Лямбда-выражения в C++20

Лямбды продолжили свою эволюцию и в C++20, но на этот раз получили менее фундаментальные фичи, чем в C++14 или C++17.

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

auto myLambda = []<typename T>(T&& value){ std::cout << value << '\n'; };

Это упрощает доступ к типу шаблонного параметра по сравнению с шаблонными лямбда-выражениями C++14, в которых использовались такие выражения, как auto&&.

Другим улучшением является возможность захвата вариативного (variadic) пакета параметров:

template<typename... Ts>
void f(Ts&&... args)
{
    auto myLambda = [...args = std::forward<Ts>(args)](){};
}

Погружение в лямбды

Мы рассмотрели то, что я считаю основными улучшениями лямбда-выражений от C++14 до C++20. Но это еще не все. Эти важные фичи идут в сопровождении ряда небольших улучшений, которые упрощают написание лямбда-кода.

Более глубокое погружение в лямбда-выражения — это отличная возможность лучше понять язык C++, и я думаю, что это стоящая инвестиция времени. Чтобы пойти дальше, лучший известный мне ресурс — это книга Бартоломея Филипика C++ Lambda Story, которую я уже рекомендовал вам.


Перевод статьи подготовлен в преддверии старта специализации "C++ Developer".




Комментарии (15):

  1. AndreyAf
    /#24476760 / -2

    auto myLambda = [](int x, int y = 0){ std::cout << x << '-' << y << '\n'; };
    
    std::cout << myLambda(1, 2) << '\n';
    std::cout << myLambda(1) << '\n';

    Этот код выводит следующее:

    1-2
    1-0

    конечно же нет.. будет ошибка:

    no match for «operator<<» (operand types are «std::ostream» {aka «std::basic_ostream»} and «void»)

    Начиная с C++14 мы можем заставить их принимать любой тип:

    auto myLambda = [](auto&& x){ std::cout << x << '\n'; };

    очень странно использовать здесь move симантику, если это не threads

    • AndreySu
      /#24476924 / +1

      очень странно использовать здесь move симантику, если это не threads

      Не понятно в чём проблема?

    • 4eyes
      /#24477062 / +2

      Да, автор опечатался, кроме указания опечатки можно предлагать исправления:

      myLambda(1, 2);
      myLambda(1);

      очень странно использовать здесь move симантику, если это не threads

      Универсальная ссылка, может быть lvalue, может быть rvalue. Еще и проще читается, чем const auto& x. По-моему, сплошные преимущества.

    • Rahl
      /#24478944 / +1

      Здесь не move-симантика, а perfect forwarding. И как move-семантика коррелирует с threads?

      PS: Смысл данной статьи не понимаю от слова совсем, какая-то протокольная распечатка эпизода Cᐩᐩ Weekly. В чем полезность?

  2. kovserg
    /#24478060

    Интереснее всего передавать лямбду в виртуальную функцию.

    • victor79
      /#24478648 / -1

          auto fn1 = []() { std::cout << 123 << std::endl; };
          using TT = decltype(fn1);
      
          struct A {
              virtual void a(TT fn) { fn(); }
          };
      
          struct B : public A {
              void a(TT fn) override { fn(); fn(); }
          };
      
          B a;
          a.a(fn1);
      

      • kovserg
        /#24478666 / +1

        a.a( []() { } ); // ups
        

        • victor79
          /#24479014 / -1

          Конечно упс, Вы же в нее пихаете значение другого типа. Какой вопрос, такой и ответ. Если такое хотите, то юзайте параметр типа function<void()>.

        • 0x1b6e6
          /#24480402

          Замените одну строку:

          using TT = std::function<void()>;
          

          Будет работать. Только говорят что std::function<T> медленный. Ручаться не буду, но имейте ввиду.

          • kovserg
            /#24480466 / -1

            Теперь следующий вопрос: как вы предлагаете экспортировать такую функцию из динамической библиотеки?

          • victor79
            /#24481732 / +1

            Там нужно типа такого:


            virtual void a(const std::function<void()>& fn);

            При этом использование std::function действительно чуть медленнее, но не существенно. Основные тормоза буду, если будет void a(std::function<void()> fn), особенно при рекурсиях, т.к. объект std::function будет каждый раз конструироваться, и в довесок занимать лишние ~32 байта в стеке, против 8 байт, если передается по ссылке.

            • kovserg
              /#24482000 / -1

              И как это потом использовать не из c++. Я к тому что лямбды сделаны чуть более чем через жопу не правильно.

              Что мешало сделать что-то подобное?

              #include <iostream>
              
              struct Callback {
              	typedef void (*fn_t)(void *ctx); fn_t fn;void* ctx;
              	void operator() () { if (fn) fn(ctx); }
              	Callback(fn_t fn=0,void* ctx=0) : fn(fn), ctx(ctx) {}
              	template<class Q>Callback(Q q) {
              		struct L { static void fn(void *ctx) { Q* q=(Q*)ctx; (*q)(); } };
              		fn=L::fn; ctx=(void*)&q;
              	}
              };
              
              auto fn1 = []() { std::cout << 123 << std::endl; };
              using TT = Callback;
              
              struct A {
                  virtual void a(TT fn) { fn(); }
              };
              
              struct B : public A {
                  void a(TT fn) override { fn(); fn(); }
              };
              
              
              int main(int argc, char const *argv[]) {
                  B b;
                  b.a(fn1);
                  b.a([](){ std::cout<<sizeof(TT)<<std::endl; });
              
                  Callback f=fn1;
                  f();
                  return 0;
              }
              

              • mayorovp
                /#24482366

                Извините, а как вы собрались использовать виртуальные функции не из С++?

                • kovserg
                  /#24482798

                  Это тоже в C++ требует дополнительных костылей.

              • victor79
                /#24484318

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


                Ваш пример не оптимален для С++, поскольку не удобно поддерживать различные параметры вызова. std::function это шаблонная структура, как раз предком которой могла бы быть структура подобная Вашей.


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