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

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



В первой части серии мы рассмотрели лямбды с точки зрения 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<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

В коде используется 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 <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(); };
}

Еще кое-что:

Обратите внимание, что если вы пишете [=] в функции-члене, 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<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++. Например, до 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 июня.
OTUS. Онлайн-образование
646,88
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Комментарии 29

    0
    auto foo() {
    return [this] { print(); };
    }

    Круглых скобочек на хватает после [this]?

    Хмммм… А компилятор сожрал.
    Это какой-то новый синтаксис? С каких пор?
      0

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

        0
        С 11-ого стандарта ещё, то есть с самого начала. 5.1.2.4:
        If a lambda-expression does not include a lambda-declarator, it is as if the lambda-declarator were ().
          0
          Лмбды без аргументов не требуют () начиная с С++11, такое поведение было всегда.
          +1
          auto foo = []<typename T>(const auto& vec) { 
                  std::cout<< std::size(vec) << '\n';
                  std::cout<< vec.capacity() << '\n';
              };


          Тут в примере ошибка как мне кажется <typename T\> вроде лишнее
            +1

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

              0
              Что же тогда учить?
                +2

                Зависит от того, чем хочется заниматься. Думаю если бы я сейчас делал выбор, выбирал бы между Scala и C#, но выбрал бы скорее второе. Если бы я жил в счастливом будущем, где куча вакансий на Rust, я, бы выбрал Rust

                  0
                  Ох, я тоже люблю Rust, может доживём до кучи вакансий на него :-)
                  +1
                  Это ж сильно зависит от предметной области. Где-то и Питона хватит, где-то принято в основном писать на Java/C#, а еще где-то C++ фактически безальтернативен. One size does not fit all.
                    0

                    Rust вполне себе альтернатива плюсам. А там где не альтернатива, скорее всего нужен Си, а не Си++. Си, в свою очередь, рекомендую выучить вообще всем, хотя бы на уровне "одну книжку прочитал".


                    Вообще, я и начал свой комментарий со слов "смотря чем хочешь заниматься") Я пишу бизнес приложения, огромные и монструозные. Поэтому и назвал Scala и C#)

                      0

                      А я вот занимался разработкой всяких embedded систем, управление промышленным оборудованием, вот это вот все. Традиционно тут писали на C, но в последнее время C++ все больше начинают использовать, потому что банально удобнее, а где надо, там всегда можно написать куски в стиле C. Rust… Ну не знаю — не знаю, язык, который требует танцев с бубном для того, чтобы получить больше одной ссылки на один и тот же объект, в этой области — это такое себе.

                        –2

                        Разрабатывая как-то драйвера под Linux на плюсах (да, это возможно), а так же написав в жизни одну прошивку под bare metal, не нашел ничего удобного в плюсах, чего нельзя было бы достичь на сях.


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


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


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

                      0
                      Так-то развитие C# идет огромными шагами.
                      Например, он стал мультиплатформенным, что перебило самый главный плюс Java.
                      Так же разработчики открыли исходные коды, что дало возможность сообществу самим развивать платформу.

                      А в последней версии .NET CORE так вообще производительность стандартных типов на порядок подняли, что повысит производительность в существующей кодовой базе.

                      На мой взгляд, мест где сейчас требуются языки с прямой возможностью управления памяти все меньше и меньше.
                    0
                    Да, если сравнивать лаконичной и изящность C# с Си++, то разница на лицо.

                    Имхо, если бы из Си++ выпилили бы все легаси конструкции и пересмотрели бы спорные синтаксические конструкции, то порог вхождения понизился бы.
                      –1

                      Если бы это сделали — получился бы Rust :) Rust — это С++ без боли и отчаяния :)

                    +3

                    Есть ещё такой весьма неприятный баг clang, произошедший из-за добавления и отката proposal по спискам захвата лямбд.


                    Clang выдает ошибку
                    error: 'i' in capture list does not name a variable
                    error: reference to local binding 'i' declared in enclosing function
                    при использовании в списке захвата или при захвате лямбдой переменных из structured binding.


                    Вот этот код не скомпилируется clang
                        auto [i, d, b] = makeTuple();
                        auto lambda = [=, &i]() {
                            auto result = b ? 0 : int(i * d);
                            i *= 2;
                            return result; 
                        };

                    WANDBOX
                    Proposal, который был изменен в R1 и возвращён в R0, в стандарт (N4659) попал R0
                    Баг clang
                    (не) Баг GCC, в котором объясняется описанные выше

                      +1
                      Немного не в тему:
                      А кто-нибудь знаком с рекламируемым курсом обучения?
                      Хотелось бы узнать мнение сообщества на его счет.
                        0
                        У нас есть чат в телеграмме, в котором много студентов, как действующих, так и уже выпустившихся, а также преподаватели. Можете спросить у них, чтоб узнать мнение, так сказать из первых уст t.me/joinchat/AAAAAAo-Vju7cjFSfjZeeg
                          +1
                          Спасибо!
                        0

                        Пользуясь случаем хотел спросить у знающих людей, как вывести тип std:: function принимающей в качестве аргумента саму себя? Как вы вести тип лямбды понятно, а вот чтобы std:: function, не очень.

                          0
                          боюсь что такое нельзя сделать, если нужна рекурсия то можно просто захватить
                          std::function<void()> tmp = [&tmp]()
                          {
                              tmp();
                          }
                          
                            0

                            Захват не подходит

                              0
                              Из описания я понял, что Вы хотите учинить бесконечную compile-time-рекурсию, а это illformed.
                                0
                                Код взят отсюда
                                auto callSelf = [](auto& func) {func(func);};
                                callSelf(callSelf);

                                Вопрос, как сделать из этого std:: function?
                                  0
                                  Условие выхода из рекурсии хотя бы предусмотрено?
                                    0

                                    Ну это смотря куда выходить

                          0
                          Не туда ответил.
                            0
                            «We have also clarifications related to capturing this» => «У нас также есть разъяснения, связанные с захватом этого»

                            «If you really need to capture this..» => «Если вам действительно нужно запечатлеть это..»

                            Гугл-транслейт — наше всё?

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

                            Самое читаемое