Лямбды: от C++11 до C++20. Часть 1

Original author: https://bfilipek.us8.list-manage.com/subscribe?u=e93417593cbf4da3dba03d672&id=a2dd686b21
  • Translation
Добрый день, друзья. Сегодня мы подготовили для вас перевод первой части статьи «Лямбды: от C++11 до C++20». Публикация данного материала приурочена к запуску курса «Разработчик C++», который стартует уже завтра.

Лямбда-выражения являются одним из наиболее мощных дополнений в C++11 и продолжают развиваться с каждым новым стандартом языка. В этой статье мы пройдемся по их истории и посмотрим на эволюцию этой важной части современного C++.



Вторая часть доступна по ссылке:
Lambdas: From C++11 to C++20, Part 2

Вступление

На одном из местных собраний C++ User Group у нас был живой сеанс программирования по «истории» лямбда-выражений. Беседу вел эксперт по С++ Томас Каминский (Tomasz Kamiński) (см. профиль Томаса в Linkedin). Вот это событие:

Lambdas: From C++11 to C++20 — C++ User Group Krakow

Я решил взять код у Томаса (с его разрешения!), описать его и создать отдельную статью.

Мы начнем с изучения C++03 и с необходимости в компактных локальных функциональных выражениях. Затем мы перейдем к C++11 и C++14. Во второй части серии мы увидим изменения в C++17 и даже взглянем на то, что произойдет в C++ 20.

«Лямбды» в C++03

С самого начала STL std::algorithms, такие как std::sort, могли принимать любой вызываемый объект и вызывать его для элементов контейнера. Однако в C++03 это предполагало только указатели на функции и функторы.

Например:

#include <iostream>
#include <algorithm>
#include <vector>

struct PrintFunctor {
    void operator()(int x) const {
        std::cout << x << std::endl;
    }
};

int main() {
    std::vector<int> v;
    v.push_back(1);
    v.push_back(2);
    std::for_each(v.begin(), v.end(), PrintFunctor());   
}

Запускаемый код: @Wandbox

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

В качестве потенциального решения вы могли бы подумать о написании локального класса функторов — поскольку C++ всегда поддерживает этот синтаксис. Но это не работает…

Посмотрите на этот код:

int main() {
    struct PrintFunctor {
        void operator()(int x) const {
            std::cout << x << std::endl;
        }
    };

    std::vector<int> v;
    v.push_back(1);
    v.push_back(2);
    std::for_each(v.begin(), v.end(), PrintFunctor());   
}

Попробуйте скомпилировать его с -std=c++98, и вы увидите следующую ошибку в GCC:

error: template argument for 
'template<class _IIter, class _Funct> _Funct 
std::for_each(_IIter, _IIter, _Funct)' 
uses local type 'main()::PrintFunctor'

По сути, в C++98/03 вы не можете создать экземпляр шаблона с локальным типом.
Из-за всех этих ограничений Комитет начал разрабатывать новую фичу, которую мы можем создавать и вызывать «на месте»… «лямбда-выражения»!

Если мы посмотрим на N3337 — окончательный вариант C++11, то увидим отдельный раздел для лямбд: [expr.prim.lambda].

Далее к C++11

Я думаю, что лямбды были добавлены в язык с умом. Они используют новый синтаксис, но затем компилятор «расширяет» его до реального класса. Таким образом, у нас есть все преимущества (а иногда и недостатки) реального строго типизированного языка.

Вот базовый пример кода, который также показывает соответствующий объект локального функтора:

#include <iostream>
#include <algorithm>
#include <vector>

int main() {
    struct {
        void operator()(int x) const {
            std::cout << x << '\n';
        }
    } someInstance;

    std::vector<int> v;
    v.push_back(1);
    v.push_back(2);
    std::for_each(v.begin(), v.end(), someInstance);
    std::for_each(v.begin(), v.end(), [] (int x) { 
            std::cout << x << '\n'; 
        }
    );    
}

Пример: @WandBox

Вы также можете проверить CppInsights, который показывает, как компилятор расширяет код:

Посмотрите на этот пример:

CppInsighs: lambda test

В этом примере компилятор преобразует:

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


Во что-то похожее на это (упрощенная форма):

struct {
    void operator()(int x) const {
        std::cout << x << '\n';
    }
} someInstance;

Синтаксис лямбда-выражения:

[] ()   { код; }
^  ^  ^
|  |  |
|  |  опционально: mutable, exception, trailing return, ...
|  |
|  список параметров
|
объявитель лямбды со списком захвата

Некоторые определения, прежде чем мы начнем:

Из [expr.prim.lambda#2]:

Вычисление лямбда-выражения приводит к временному prvalue. Этот временный объект называется объектом-замыканием (closure object).

И из [expr.prim.lambda#3]:

Тип лямбда-выражения (который также является типом объекта-замыкания) является уникальным безымянным non-union типом класса, который называется типом замыкания (closure type).

Несколько примеров лямбда-выражений:

Например:

[](float f, int a) { return a*f; }
[](MyClass t) -> int { auto a = t.compute(); return a; }
[](int a, int b) { return a < b; }

Тип лямбды

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

auto myLambda = [](int a) -> double { return 2.0 * a; }

Более того [expr.prim.lambda]:
Тип замыкания, связанный с лямбда-выражением, имеет удаленный ([dcl.fct.def.delete]) конструктор по умолчанию и удаленный оператор присваивания.

Поэтому вы не можете написать:

auto foo = [&x, &y]() { ++x; ++y; };
decltype(foo) fooCopy;

Это приведет к следующей ошибке в GCC:

error: use of deleted function 'main()::<lambda()>::<lambda>()'
       decltype(foo) fooCopy;
                   ^~~~~~~
note: a lambda closure type has a deleted default constructor 

Оператор вызова

Код, который вы помещаете в тело лямбды, «транслируется» в код operator() соответствующего типа замыкания.

По умолчанию это встроенный константный метод. Вы можете изменить его, указав mutable после объявления параметров:

auto myLambda = [](int a) mutable { std::cout << a; }

Хотя константный метод не является «проблемой» для лямбды без пустого списка захвата… он имеет значение, когда вы хотите что-то захватить.

Захват

[] не только вводит лямбду, но также содержит список захваченных переменных. Это называется «список захвата».

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

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

  • [&] — захват по ссылке, все переменные в автоматическом хранилище объявлены в области охвата
  • [=] — захват по значению, значение копируется
  • [x, & y] — явно захватывает x по значению, а y по ссылке

Например:

int x = 1, y = 1;
{
    std::cout << x << " " << y << std::endl;
    auto foo = [&x, &y]() { ++x; ++y; };
    foo();
    std::cout << x << " " << y << std::endl;
}

Вы можете поиграться с полным примером здесь: @Wandbox

Хотя указание [=] или [&] может быть удобно — поскольку оно захватывает все переменные в автоматическом хранилище, более очевидно захватывать переменные явно. Таким образом, компилятор может предупредить вас о нежелательных эффектах (см., например, примечания о глобальных и статических переменных)

Вы также можете прочитать больше в пункте 31 «Эффективного современного C++» Скотта Мейерса: «Избегайте режимов захвата по умолчанию».

И важная цитата:
Замыкания С++ не увеличивают время жизни захваченных ссылок.


Mutable

По умолчанию operator() типа замыкания является константным, и вы не можете изменять захваченные переменные внутри тела лямбда-выражения.
Если вы хотите изменить это поведение, вам нужно добавить ключевое слово mutable после списка параметров:

int x = 1, y = 1;
std::cout << x << " " << y << std::endl;
auto foo = [x, y]() mutable { ++x; ++y; };
foo();
std::cout << x << " " << y << std::endl;

В приведенном выше примере мы можем изменить значения x и y… но это только копии x и y из прилагаемой области видимости.

Захват глобальных переменных

Если у вас есть глобальное значение, а затем вы используете [=] в лямбде, вы можете подумать, что глобальное значение также захвачено по значению… но это не так.

int global = 10;

int main()
{
    std::cout << global << std::endl;
    auto foo = [=] () mutable { ++global; };
    foo();
    std::cout << global << std::endl;
    [] { ++global; } ();
    std::cout << global << std::endl;
    [global] { ++global; } ();
}

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

Захватываются только переменные в автоматическом хранилище. GCC может даже выдать следующее предупреждение:

warning: capture of variable 'global' with non-automatic storage duration

Это предупреждение появится только в том случае, если вы явно захватите глобальную переменную, поэтому, если вы используете [=], компилятор вам не поможет.
Компилятор Clang более полезен, так как генерирует ошибку:

error: 'global' cannot be captured because it does not have automatic storage duration

Смотрите @Wandbox

Захват статических переменных

Захват статических переменных аналогичен захвату глобальных:

#include <iostream>

void bar()
{
    static int static_int = 10;
    std::cout << static_int << std::endl;
    auto foo = [=] () mutable { ++static_int; };
    foo();
    std::cout << static_int << std::endl;
    [] { ++static_int; } ();
    std::cout << static_int << std::endl;
    [static_int] { ++static_int; } ();
}

int main()
{
   bar();
}

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

Вывод:

10
11
12

И снова, предупреждение появится, только если вы явно захватите статическую переменную, поэтому, если вы используете [=], компилятор вам не поможет.

Захват члена класса

Знаете ли вы, что произойдет после выполнения следующего кода:

#include <iostream>
#include <functional>

struct Baz
{
    std::function<void()> foo()
    {
        return [=] { std::cout << s << std::endl; };
    }

    std::string s;
};

int main()
{
   auto f1 = Baz{"ala"}.foo();
   auto f2 = Baz{"ula"}.foo(); 
   f1();
   f2();
}

Код объявляет объект Baz, а затем вызывает foo(). Обратите внимание, что foo() возвращает лямбду (хранящуюся в std::function), которая захватывает член класса.

Поскольку мы используем временные объекты, мы не можем быть уверены, что произойдет, при вызове f1 и f2. Это проблема висячих ссылок, которая порождает неопределенное поведение.

Аналогично:

struct Bar { 
    std::string const& foo() const { return s; }; 
    std::string s; 
};
auto&& f1 = Bar{"ala"}.foo(); // висячая ссылка

Поиграйте с кодом @Wandbox

Опять же, если вы укажете захват явно ([s]):

std::function<void()> foo()
{
    return [s] { std::cout << s << std::endl; };
}

Компилятор предотвратит вашу ошибку:

In member function 'std::function<void()> Baz::foo()':
error: capture of non-variable 'Baz::s'
error: 'this' was not captured for this lambda function
...

Смотри пример: @Wandbox

Move-able-only объекты

Если у вас есть объект, который может быть только перемещен (например, unique_ptr), то вы не можете поместить его в лямбду в качестве захваченной переменной. Захват по значению не работает, поэтому вы можете захватывать только по ссылке… однако это не передаст его вам во владение, и, вероятно, это не то, что вы хотели.

std::unique_ptr<int> p(new int[10]);
auto foo = [p] () {}; // не компилируется....

Сохранение констант

Если вы захватываете константную переменную, то константность сохраняется:

int const x = 10;
auto foo = [x] () mutable { 
    std::cout << std::is_const<decltype(x)>::value << std::endl;
    x = 11;
};
foo();

Смотри код: @Wandbox

Возвращаемый тип

В C++11 вы можете пропустить trailing возвращаемый тип лямбды, и тогда компилятор выведет его за вас.

Первоначально вывод возвращаемого типа значения был ограничен лямбдами, содержащими один оператор return, но это ограничение было быстро снято, поскольку не было проблем с реализацией более удобной версии.

См. C++ Standard Core Language Defect Reports and Accepted Issues (спасибо Томасу за нахождение правильной ссылки!)

Таким образом, начиная с C++11, компилятор может вывести тип возвращаемого значения, если все операторы return могут быть преобразованы в один и тот же тип.
Если все операторы return возвращают выражение и типы возвращаемых выражений после преобразования lvalue-to-rvalue (7.1 [conv.lval]), array-to-pointer (7.2 [conv.array]) и function-to-pointer (7.3 [conv.func]) такое же, как у общего типа;
auto baz = [] () {
    int x = 10; 
    if ( x < 20) 
        return x * 1.1; 
    else
        return x * 2.1;
};

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

В вышеприведенной лямбде есть два оператора return, но все они указывают на double, поэтому компилятор может вывести тип.

IIFE — Немедленно вызываемые выражения (Immediately Invoked Function Expression)

В наших примерах я определял лямбду, а затем вызвал ее, используя объект замыкания… но ее также можно вызывать немедленно:

int x = 1, y = 1;
[&]() { ++x; ++y; }(); // <-- call ()
std::cout << x << " " << y << std::endl;

Такое выражение может быть полезно при сложной инициализации константных объектов.

const auto val = []() { /* несколько строк кода... */ }();

Я писал больше об этом в посте IIFE for Complex Initialization.

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

Например:

#include <iostream>

void callWith10(void(* bar)(int))
{
    bar(10);
}

int main()
{
    struct 
    {
        using f_ptr = void(*)(int);

        void operator()(int s) const { return call(s); }
        operator f_ptr() const { return &call; }

    private:
        static void call(int s) { std::cout << s << std::endl; };
    } baz;

    callWith10(baz);
    callWith10([](int x) { std::cout << x << std::endl; });
}

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

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

Стандарт N4140 и лямбды: [expr.prim.lambda].

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

  • Захваты с инициализатором
  • Общие лямбды

Эти фичи решают несколько проблем, которые были видны в C++11.

Возвращаемый тип

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

[expr.prim.lambda#4]
Возвращаемый тип лямбды — auto, который заменяется trailing возвращаемым типом, если он предоставляется и/или выводится из операторов возврата, как описано в [dcl.spec.auto].
Захваты с инициализатором

Короче говоря, мы можем создать новую переменную-член типа замыкания и затем использовать ее внутри лямбда-выражения.

Например:

int main() {
    int x = 10;
    int y = 11;
    auto foo = [z = x+y]() { std::cout << z << '\n'; };
    foo();
}

Это может решить несколько проблем, например, с типами, доступными только для перемещения.

Перемещение

Теперь мы можем переместить объект в член типа замыкания:

#include <memory>

int main()
{
    std::unique_ptr<int> p(new int[10]);
    auto foo = [x=10] () mutable { ++x; };
    auto bar = [ptr=std::move(p)] {};
    auto baz = [p=std::move(p)] {};
}

Оптимизация

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

#include <iostream>
#include <algorithm>
#include <vector>
#include <memory>
#include <iostream>
#include <string>

int main()
{
    using namespace std::string_literals;
    std::vector<std::string> vs;
    std::find_if(vs.begin(), vs.end(), [](std::string const& s) {
     return s == "foo"s + "bar"s; });
    std::find_if(vs.begin(), vs.end(), [p="foo"s + "bar"s](std::string const& s) { return s == p; });
}

Захват переменной-члена

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

Например:

struct Baz
{
    auto foo()
    {
        return [s=s] { std::cout << s << std::endl; };
    }

    std::string s;
};

int main()
{
   auto f1 = Baz{"ala"}.foo();
   auto f2 = Baz{"ula"}.foo(); 
   f1();
   f2();
}

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


В foo() мы захватываем переменную-член, копируя ее в тип замыкания. Кроме того, мы используем auto для вывода всего метода (ранее, в C++11 мы могли использовать std::function).

Обобщенные лямбда-выражения

Еще одно существенное улучшение — это обобщенная лямбда.
Начиная с C++14 можно написать:

auto foo = [](auto x) { std::cout << x << '\n'; };
foo(10);
foo(10.1234);
foo("hello world");

Это эквивалентно использованию объявления шаблона в операторе вызова типа замыкания:

struct {
    template<typename T>
    void operator()(T x) const {
        std::cout << x << '\n';
    }
} someInstance;

Такая обобщенная лямбда может быть очень полезна, когда трудно вывести тип.

Например:

std::map<std::string, int> numbers { 
    { "one", 1 }, {"two", 2 }, { "three", 3 }
};

// каждый раз запись копируется из pair<const string, int>!
std::for_each(std::begin(numbers), std::end(numbers), 
    [](const std::pair<std::string, int>& entry) {
        std::cout << entry.first << " = " << entry.second << '\n';
    }
);

Я ошибся здесь? У entry правильный тип?
.
.
.
Вероятно, нет, так как типом значения для std::map является std::pair<const Key, T>. Так что мой код будет делать дополнительные копии строк…
Это можно исправить с помощью auto:

std::for_each(std::begin(numbers), std::end(numbers), 
    [](auto& entry) {
        std::cout << entry.first << " = " << entry.second << '\n';
    }
);

Вы можете поиграться кодом здесь: @Wandbox

Вывод

Что за история!

В этой статье мы начали с первых дней лямбда-выражений в C++03 и C++11 и перешли к улучшенной версии в C++14.

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

В следующей части статьи мы перейдем к C++17 и познакомимся с будущими фичами C++20.

Вторая часть доступна здесь:

Lambdas: From C++11 to C++20, Part 2


Ссылки

C++11 — [expr.prim.lambda]
C++14 — [expr.prim.lambda]
Lambda Expressions in C++ | Microsoft Docs
Demystifying C++ lambdas — Sticky Bits — Powered by FeabhasSticky Bits – Powered by Feabhas


Ждем ваши комментарии и приглашаем всех заинтересованных на курс «Разработчик С++».
  • +24
  • 15.4k
  • 8
OTUS. Онлайн-образование
702.23
Цифровые навыки от ведущих экспертов
Share post

Comments 8

    +3
    надо доработать статью с учетом изменений с++17 и готовящихся в с++20. А именно:
    с++17: constexpr лямбды, захват *this
    с++20: generic lambdas:
    auto a = []<class T>(T a, T b) { ... };

    Еще надо бы поподробнее про захват this (особенно его deprecated since c++20 вариант с захватом this по указателю в [=]).
      +2
      Мы, к сожалению, не можем дорабатывать данную статью, так как не являемся ее авторами, а просто публикуем перевод. Но подумаем над публикацией своего авторского материала на жту тему
        0
        Я правильно понимаю, в С++17 можно нормально получить досуп к членам класса из лябды?
          +1
          нет. В с++17 можно захватить объект создающего лямбду класса по значению. Для этого объект копируется. Это не всегда (достаточно редко, я бы сказал) желаемое поведение.
            +1
            Ды и в С++11 с этим проблем нет, если я правильно понял о чём вы.
              0
              Кажется вы правы. Я при реализации столкнулся с другой проблемой какой-то, а тут что-то не то вспомнилось.
          0
          Хорошая статья, огромное спасибо за перевод. Жду вторую часть.

        Only users with full accounts can post comments. Log in, please.