Лямбда-выражения (анонимные функции) в С++

  Дмитрий Бушуев  | 

  Обновл. 29 Июл 2020  | 

 8234

 ǀ   7 

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

Зачем нужны лямбда-выражения?

Рассмотрим следующий пример:

Этот код перебирает массив строк в поисках первого попавшегося элемента, который содержит подстроку nut. Таким образом, результат выполнения программы:

Found walnut

Хотя это и рабочий код, но мы можем его улучшить.

Проблема кроется в том, что функция std::find() требует указатель на функцию в качестве аргумента. Из-за этого мы вынуждены определить новую функцию, которая будет использована только один раз, дать ей имя и поместить её в глобальную область видимости (т.к. функции не могут быть вложенными!). При этом она будет настолько короткой, что быстрее и проще понять её смысл, посмотрев лишь на одну строку кода, нежели из описания комментариев и её имени.

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


Лямбда-выражение (или просто «лямбда») в программировании позволяет определить анонимную функцию внутри другой функции. Возможность сделать функцию вложенной является очень важным преимуществом, так как позволяет избегать как захламления пространства имен лишними объектами, так и определить функцию как можно ближе к месту её первого использования.

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

Лямбда-выражения имеют следующий синтаксис:

[ captureClause ] ( параметры ) -> возвращаемыйТип
{
стейтменты;
}

Поля captureClause и параметры могут быть пустыми, если они не требуются программисту.

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

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

Давайте перепишем предыдущий пример, но уже с использованием лямбда-выражений:

При этом всё работает точно так же, как и в случае с указателем на функцию. Результат выполнения программы аналогичен:

Found walnut

Обратите внимание, насколько наша лямбда похожа на функцию containsNut(). Они обе имеют одинаковые параметры и тела функций. Отметим, что у лямбды отсутствует поле captureClause (детально о captureClause мы поговорим на следующем уроке), т.к. оно не нужно. Также для краткости мы пропустили синтаксис типа возвращаемого значения trailing, но из-за того, что operator!= возвращает значение типа bool, наша лямбда также будет возвращать логическое значение.

Тип лямбда-выражений

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

Однако написание лямбды в той же строке, где она используется, иногда может затруднить чтение кода. Подобно тому, как мы можем инициализировать переменную с помощью литерала (или указателя на функцию) для использования в дальнейшем, так же мы можем инициализировать и лямбда-переменную с помощью лямбда-определения для её дальнейшего использования. Именованная лямбда вместе с удачным именем функции может облегчить чтение кода.

Например, в следующем фрагменте кода мы используем функцию std::all_of() для того, чтобы проверить, являются ли все элементы массива чётными:

Мы можем улучшить читабельность кода следующим образом:

Обратите внимание, как просто читается последняя строка кода: «… возвращаем все элементы массива, которые являются чётными …».

Но какого типа является лямбда в isEven?

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

Для продвинутых читателей: На самом деле, лямбды не являются функциями (что и помогает им избегать ограничений C++, которые накладываются на использование вложенных функций). Лямбды являются особым типом объектов, который называется функтором. Функторы — это объекты, содержащие перегруженный operator(), который и делает их вызываемыми подобно обычным функциям.

Хотя мы не знаем тип лямбды, есть несколько способов её хранения для использования после определения. Если лямбда ничего не захватывает, то мы можем использовать обычный указатель на функцию. Как только лямбда что-либо захватывает, указатель на функцию больше не будет работать. Однако std::function может использоваться для лямбд, даже если они что-то захватывают:

С помощью auto мы можем использовать фактический тип лямбды. При этом мы можем получить преимущество в виде отсутствия накладных расходов в сравнении с использованием std::function.

К сожалению, мы не всегда можем использовать auto. В тех случаях, когда фактический тип лямбды неизвестен (например, из-за того, что мы передаем лямбду в функцию в качестве параметра, и вызывающий объект сам определяет какого типа лямбда будет передана), мы не можем использовать auto. В таких случаях следует использовать std::function:

Результат выполнения программы:

0
1
2

Правило: Используйте auto при инициализации переменных с помощью лямбд и std::function, если вы не можете инициализировать переменную с помощью лямбд.

Общие/Обобщённые лямбды


По большей части лямбда-параметры работают так же, как и обычные параметры функций.

Одним примечательным исключением является то, что, начиная с C++14, нам разрешено использовать auto с параметрами функций (примечание: в C++20 обычные функции также могут использовать auto с параметрами). Если у лямбды есть один или несколько параметров auto, то компилятор определит необходимые типы параметров из вызовов лямбд-выражений.

Поскольку лямбды с одним или несколькими параметрами типа auto потенциально могут работать с большим количество типов данных, то они называются общими (или «обобщёнными» от англ. «generic lambdas») лямбдами.

Рассмотрим использование общей лямбды на практике:

Результат выполнения программы:

June and July start with the same letter

В примере, приведенном выше, мы использовали auto-параметры для захвата наших строк с использованием константной ссылки. Т.к. все строковые типы предоставляют доступ к своим отдельным символам через operator[], то нам не нужно волноваться о том, передает ли пользователь в качестве параметра std::string, строку C-style или что-то другое. Это позволяет нам написать лямбду, которая могла бы принять любой из этих объектов, то есть, если позже мы изменим тип months, — нам не придется переписывать лямбду.

Однако auto не всегда является лучшим выбором. Рассмотрим следующую программу:

Результат выполнения программы:

There are 2 months with 5 letters

В этом примере использование auto выводит тип const char*. Мы знаем, что со строками C-style трудно работать (кроме использования operator[]). Поэтому в данном случае для нас предпочтительнее явно определить тип параметра как std::string_view, который позволит нам работать с базовыми типами данных намного проще (например, мы можем запросить у представления значение длины строки, даже если пользователь передал в качестве аргумента массив C-style).

Общие лямбды и статические переменные

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

Результат выполнения программы:

0: hello
1: world
0: 1
1: 2
2: ding dong

В примере, приведенном выше, мы определяем лямбду и затем вызываем её с двумя различными параметрами (строковым литералом и целочисленным типом). При этом генерируются две различные версии лямбды (одна с параметром строкового литерала, а другая — с параметром в виде целочисленного типа).

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

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

Если бы мы хотели, чтобы callCount был общим для лямбд, то нам пришлось бы объявить его вне лямбды и захватить его по ссылке, чтобы он мог быть изменен лямбдой.

Вывод возвращаемого типа и возвращаемые типы trailing


Если использовался вывод возвращаемого типа, то возвращаемый тип лямбды выводится из стейтментов return внутри лямбды. Если использовался вывод возвращаемого типа, то все возвращаемые стейтменты внутри лямбды должны возвращать значения одного и того же типа (иначе компилятор не будет знать, какой из них ему следует использовать). Например:

Это приведет к ошибке компиляции, так как тип возвращаемого значения первого стейтмента return (int) не совпадает с типом возвращаемого значения второго стейтмента return (double).

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

   выполнить явные преобразования в один тип;

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

Второй вариант обычно является более предпочтительным:

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

Функциональные объекты Стандартной библиотеки С++

Для основных операций (например, сложения, вычитания или сравнения) вам не нужно писать свои собственные лямбды, потому что Стандартная библиотека С++ поставляется со многими базовыми вызываемыми объектами, которые вы можете использовать. Эти объекты определены в заголовочном файле functional. Например:

Результат выполнения программы:

99 90 80 40 13 5

Вместо преобразования функции greater() в лямбду, мы можем использовать std::greater:

Результат выполнения программы:

99 90 80 40 13 5

Заключение


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

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

Тест

Задание №1

Создайте структуру Student, которая будет хранить имя и баллы студента. Создайте массив студентов и используйте функцию std::max_element() для поиска студента с наибольшими баллами, а затем выведете на экран имя найденного студента. Функция std::max_element() принимает begin и end списка и функцию с двумя параметрами, которая возвращает true, если первый аргумент меньше второго.

При использовании следующего массива:

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

Dan is the best student

Показать подсказку

Ответ №1

Задание №2

Используйте std::sort() и лямбду в следующем коде для сортировки времен года по возрастанию средней температуры:

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

Winter
Spring
Fall
Summer

Ответ №2

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (42 оценок, среднее: 4,62 из 5)
Загрузка...

Комментариев: 7

  1. Аватар VEX:

    "Правило: Используйте auto при инициализации переменных с помощью лямбд и std::function, если вы не можете инициализировать переменную с помощью лямбд."

    А мне кажется, что лучше всегда использовать std::function, тем более что после C++17 и тип возврата, и типы параметров указывать необязательно. На мой взгляд ключевое слово auto уже немного устарел 🙂

  2. Аватар Viktor:

    Здравствуйте) Если кому то не сложно, не могли бы вы обьяснить предложение "Функция std::max_element() принимает begin и end списка и функцию с двумя параметрами, которая возвращает true, если первый аргумент меньше второго.". Я не совсем понимаю зачем в этой функции нужен третий параметр в виде лямбды. Заранее благодарю всех кто ответит.

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Так а как вы собираетесь сравнивать двух студентов между собой? Это же составной тип данных, С++ ничего не знает про сравнение подобных переменных. Поэтому вы сами должны написать эту функцию (в виде лямбды) 🙂

  3. Аватар Анастасия:

    Я прочитала этот урок дважды с перерывом в неделю. И всё равно есть чувство, что я поняла не всё.
    Во-первых, урок очень длинный.
    Во-вторых, в примерах используется std::string_view, о котором до конца апреля в этих уроках ничего не было. Также используются алгоритмы STL, которые здесь также объяснялись скорее для расширения кругозора.
    В-третьих, кусок "Если лямбда ничего не захватывает, мы можем использовать обычный указатель на функцию. Как только лямбда захватывает что-либо, указатель на функцию больше не будет работать. " не ясно, что значит, что лямбда что-то захватывает. Я подозреваю, что это связано с [Capture clause], который здесь никак не поясняется, но должен быть раскрыт в следующем уроке.
    В-четвёртых, главное правило данного урока "Используйте auto при инициализации переменных с помощью лямбд и std::function, если вы не можете инициализировать переменную с помощью лямбд." тоже не до конца раскрыто. Я прочитала пример, но до конца так и не поняла, в чём разница, и как это на практике разделять. Возможно, причина в том, что слово "лямбда" в данном уроке заменяет несколько разных смыслов, связанных с лямбда-выражениями. Скорей всего, речь идёт о том, что когда мы даём имя нашему лямбда-выражению, мы можем указать только тип auto. Или другие, более конкретные типы тоже возможны? Судя по тому, что я прочитала, нет, но ведь auto превращается в конкретный тип из return…
    В-пятых, абзац "Если использовался вывод возвращаемого типа, то возвращаемый тип лямбды выводится из стейтментов return внутри лямбды. Если использовался вывод возвращаемого типа, то все возвращаемые стейтменты внутри лямбды должны возвращать значения одного и того же типа…" Два предложения, начинающихся с одного и того же условия "Если использовался вывод возвращаемого типа" почему бы тогда не переписать это как-то так, чтобы не путать читателя:
    Если использовался вывод возвращаемого типа:
    1) возвращаемый тип лямбды выводится из стейтментов return внутри лямбды
    2) все возвращаемые стейтменты внутри лямбды должны возвращать значения одного и того же типа
    Хотя нет, даже так не понятно, что имеется в виду под "вывод" — печать на экране? В лямбда-выражении или в вызывающей функции?
    В общем, здесь я попыталась проанализировать, что же заставило меня прокрастинировать с этим уроком целую неделю и почему до сих пор даже после второго прочтения у меня нет чувства, что "всё понятно". Хотя, судя по всему, тема несложная.

    1. Юрий Юрий:

      Здравствуйте, Анастасия.

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

      2. Вместе с этим уроком были добавлены уроки по std::string_view и по алгоритмам.

      3. Есть отдельный урок и по лямбда-захватам (они же capture clause).

    2. Аватар Дмитрий:

      Тоже самое. Это самый сложный урок из всех, которые были до этого.

  4. Аватар Арбузик❤❤❤:

    Круто!

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

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