11.13 – Введение в лямбды (анонимные функции)

Добавлено 17 июня 2021 в 20:55

Рассмотрим фрагмент кода, который мы представили в уроке «10.25 – Знакомство с алгоритмами стандартной библиотеки»:

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
// static в этом контексте означает внутреннее связывание
static bool containsNut(std::string_view str)
{
  // std::string_view::find, если не находит подстроку, возвращает
  // std::string_view::npos, являющееся очень большим числом.
  // В противном случае он возвращает индекс, в котором подстрока
  // встречается в str.
  return (str.find("nut") != std::string_view::npos);
}
 
int main()
{
  constexpr std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
 
  // std::find_if принимает указатель на функцию
  const auto found{ std::find_if(arr.begin(), arr.end(), containsNut) };
 
  if (found == arr.end())
  {
    std::cout << "No nuts\n";
  }
  else
  {
    std::cout << "Found " << *found << '\n';
  }
 
  return 0;
}

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

Found walnut

И пока он работает, его можно улучшить.

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

Лямбды спешат на помощь

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

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

[ захват ] ( параметры ) -> возвращаемый_тип
{
    инструкции;
}

Захват и параметры могут быть пустыми, если они не нужны.

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

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

В качестве отступления...


Это означает, что определение простейшего лямбда-выражения выглядит так:

#include <iostream>
 
int main()
{
  []() {}; // определяем лямбда-выражение без захватов, без параметров и без возвращаемого типа
 
  return 0;
}

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

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
int main()
{
  constexpr std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" };
 
  // Определяем функцию там, где мы ее используем.
  const auto found{ std::find_if(arr.begin(), arr.end(),
                           [](std::string_view str) // вот наша лямбда, без захвата
                           {
                             return (str.find("nut") != std::string_view::npos);
                           }) };
 
  if (found == arr.end())
  {
    std::cout << "No nuts\n";
  }
  else
  {
    std::cout << "Found " << *found << '\n';
  }
 
  return 0;
}

Это работает так же, как и случай с указателем на функцию, и дает идентичный результат:

Found walnut

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

Тип лямбды

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

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

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

// Плохо: нам нужно прочитать лямбду, чтобы понять, что происходит.
return std::all_of(array.begin(), array.end(), [](int i){ return ((i % 2) == 0); });

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

// Хорошо: вместо этого мы можем сохранить лямбду в
// именованной переменной и передать ее функции.
auto isEven{
  [](int i)
  {
    return ((i % 2) == 0);
  }
};
 
return std::all_of(array.begin(), array.end(), isEven);

Обратите внимание, как хорошо читается последняя строка: «вернуть, все ли элементы в массиве четные»

Но какой тип у лямбды isEven?

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

Для продвинутых читателей


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

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

#include <functional>
 
int main()
{
  // Обычный указатель на функцию. Работает только с пустым списком захвата.
  double (*addNumbers1)(double, double){
    [](double a, double b) {
      return (a + b);
    }
  };
 
  addNumbers1(1, 2);
 
  // Использование std::function. Лямбда может иметь
  // непустой список захвата (следующий урок).
  // примечание: до C++17 используйте вместо
  // этого std::function<double(double, double)>
  std::function addNumbers2{ 
    [](double a, double b) {
      return (a + b);
    }
  };
 
  addNumbers2(3, 4);
 
  // Использование auto. Сохраняет лямбду с ее реальным типом.
  auto addNumbers3{
    [](double a, double b) {
      return (a + b);
    }
  };
 
  addNumbers3(5, 6);
 
  return 0;
}

Единственный способ использовать реальный тип лямбды – использовать auto. У auto также есть преимущество в отсутствии дополнительных затрат по сравнению с std::function.

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

#include <functional>
#include <iostream>
 
// Мы не знаем, какой будет fn. std::function работает
// с обычными функциями и с лямбдами.
void repeat(int repetitions, const std::function<void(int)>& fn)
{
  for (int i{ 0 }; i < repetitions; ++i)
  {
    fn(i);
  }
}
 
int main()
{
  repeat(3, [](int i) {
    std::cout << i << '\n';
  });
 
  return 0;
}

Вывод этой программы:

0
1
2

Если бы мы использовали auto для типа fn, вызывающий функцию не знал бы, какие параметры и возвращаемый тип должна иметь fn. Кроме того, функции с параметрами auto нельзя разделить на заголовочный и исходный файл. Мы объясняем причину этого ограничения, когда будем говорить о шаблонах.

Правило


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

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

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

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

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

Для продвинутых читателей


При использовании в контексте лямбда-выражения auto – это просто сокращение для шаблонного параметра.

Давайте посмотрим на обобщенную лямбду:

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
int main()
{
  constexpr std::array months{ // до C++17 используется std::array<const char*, 12>
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December"
  };
 
  // Поиск двух соседних месяцев, начинающихся с одной и той же буквы.
  const auto sameLetter{ std::adjacent_find(months.begin(), months.end(),
                                      [](const auto& a, const auto& b) {
                                        return (a[0] == b[0]);
                                      }) };
 
  // Убедимся, что два месяца нашлись.
  if (sameLetter != months.end())
  {
    // std::next возвращает следующий итератор после sameLetter
    std::cout << *sameLetter << " and " << *std::next(sameLetter)
              << " start with the same letter\n";
  }
 
  return 0;
}

Вывод программы:

June and July start with the same letter

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

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

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
int main()
{
  // до C++17 используется std::array<const char*, 12>
  constexpr std::array months{ 
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December"
  };
 
  // Подсчитаем, сколько месяцев состоит из 5 букв
  const auto fiveLetterMonths{ std::count_if(months.begin(), months.end(),
                                       [](std::string_view str) {
                                         return (str.length() == 5);
                                       }) };
 
  std::cout << "There are " << fiveLetterMonths << " months with 5 letters\n";
 
  return 0;
}

Вывод программы:

There are 2 months with 5 letters

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

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

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

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
int main()
{
  // Распечатываем значение и подсчитываем, сколько раз был вызван print.
  auto print{
    [](auto value) {
      static int callCount{ 0 };
      std::cout << callCount++ << ": " << value << '\n';
    }
  };
 
  print("hello"); // 0: hello
  print("world"); // 1: world
 
  print(1); // 0: 1
  print(2); // 1: 2
 
  print("ding dong"); // 2: ding dong
 
  return 0;
}

Вывод программы:

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

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

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

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

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

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

Например:

#include <iostream>
 
int main()
{
  auto divide{ [](int x, int y, bool bInteger) { // примечание: не указан тип
                                                 // возвращаемого значения
    if (bInteger)
      return x / y;
    else
      return static_cast<double>(x) / y; // ОШИБКА: тип возврата не соответствует
                                         // предыдущему типу возврата
  } };
 
  std::cout << divide(3, 2, true) << '\n';
  std::cout << divide(3, 2, false) << '\n';
 
  return 0;
}

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

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

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

Второй вариант – обычно лучший выбор:

#include <iostream>
 
int main()
{
  // примечание: явно указывает возврат типа double
  auto divide{ [](int x, int y, bool bInteger) -> double {
    if (bInteger)
      return x / y; // выполнит неявное преобразование в double
    else
      return static_cast<double>(x) / y;
  } };
 
  std::cout << divide(3, 2, true) << '\n';
  std::cout << divide(3, 2, false) << '\n';
 
  return 0;
}

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

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

Для распространенных операций (например, сложения, отрицания или сравнения) вам не нужно писать свои собственные лямбды, потому что стандартная библиотека поставляется с множеством базовых вызываемых объектов, которые можно использовать вместо этого. Они определены в заголовке <functional>.

Например:

#include <algorithm>
#include <array>
#include <iostream>
 
bool greater(int a, int b)
{
  // Порядок a перед b, если a больше, чем b.
  return (a > b);
}
 
int main()
{
  std::array arr{ 13, 90, 99, 5, 40, 80 };
 
  // Передаем greater в std::sort
  std::sort(arr.begin(), arr.end(), greater);
 
  for (int i : arr)
  {
    std::cout << i << ' ';
  }
 
  std::cout << '\n';
 
  return 0;
}

Вывод программы:

99 90 80 40 13 5

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

#include <algorithm>
#include <array>
#include <iostream>
#include <functional> // для std::greater
 
int main()
{
  std::array arr{ 13, 90, 99, 5, 40, 80 };
 
  // Передаем std::greater в std::sort
  // // примечание: фигурные скобки нужны для создания экземпляра объекта
  std::sort(arr.begin(), arr.end(), std::greater{}); 
 
  for (int i : arr)
  {
    std::cout << i << ' ';
  }
 
  std::cout << '\n';
 
  return 0;
}

Вывод программы:

99 90 80 40 13 5

Заключение

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

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

Небольшой тест

Вопрос 1

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

Проверьте код на следующем массиве.

std::array<Student, 8> arr{
  { { "Albert", 3 },
    { "Ben", 5 },
    { "Christine", 2 },
    { "Dan", 8 },        // больше всего баллов (8).
    { "Enchilada", 4 },
    { "Francis", 1 },
    { "Greg", 3 },
    { "Hagrid", 5 } }
};

Ваша программа должна напечатать

Dan is the best student

#include <algorithm>
#include <array>
#include <iostream>
#include <string>
 
struct Student
{
  std::string name{};
  int points{};
};
 
int main()
{
  const std::array<Student, 8> arr{
    { { "Albert", 3 },
      { "Ben", 5 },
      { "Christine", 2 },
      { "Dan", 8 },
      { "Enchilada", 4 },
      { "Francis", 1 },
      { "Greg", 3 },
      { "Hagrid", 5 } }
  };
 
  const auto best{
    std::max_element(arr.begin(), arr.end(), /* лямбда */)
  };
 
  std::cout << best->name << " is the best student\n";
 
  return 0;
}

#include <algorithm>
#include <array>
#include <iostream>
#include <string>
 
struct Student
{
  std::string name{};
  int points{};
};
 
int main()
{
  const std::array<Student, 8> arr{
    { { "Albert", 3 },
      { "Ben", 5 },
      { "Christine", 2 },
      { "Dan", 8 },
      { "Enchilada", 4 },
      { "Francis", 1 },
      { "Greg", 3 },
      { "Hagrid", 5 } }
  };
 
  const auto best{
    std::max_element(arr.begin(), arr.end(), [](const auto& a, const auto& b) {
      return (a.points < b.points);
    })
  };
 
  std::cout << best->name << " is the best student\n";
 
  return 0;
}

Вопрос 2

Используйте std::sort и лямбда-выражение в следующем коде, чтобы отсортировать сезоны по возрастанию средней температуры (температура приведена в Кельвинах).

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
struct Season
{
  std::string_view name{};
  double averageTemperature{};
};
 
int main()
{
  std::array<Season, 4> seasons{
    { { "Spring", 285.0 },
      { "Summer", 296.0 },
      { "Fall", 288.0 },
      { "Winter", 263.0 } }
  };
 
  /*
   * Используйте здесь std::sort
   */
 
  for (const auto& season : seasons)
  {
    std::cout << season.name << '\n';
  }
 
  return 0;
}

Программа должна напечатать

Winter
Spring
Fall
Summer

#include <algorithm>
#include <array>
#include <iostream>
#include <string_view>
 
struct Season
{
  std::string_view name{};
  double averageTemperature{};
};
 
int main()
{
  std::array<Season, 4> seasons{
    { { "Spring", 285.0 },
      { "Summer", 296.0 },
      { "Fall", 288.0 },
      { "Winter", 263.0 } }
  };
 
  // Чтобы отсортировать массив, мы можем сравнить среднюю температуру
  // (averageTemperature) двух аргументов.
  std::sort(seasons.begin(), seasons.end(),
            [](const auto& a, const auto& b) {
              return (a.averageTemperature < b.averageTemperature);
            });
 
  for (const auto& season : seasons)
  {
    std::cout << season.name << '\n';
  }
 
  return 0;
}

Теги

C++ / CppLearnCppstd::functionДля начинающихЛямбда / LambdaЛямбда-выражение / Lambda expressionЛямбда-функцияОбучениеПрограммированиеФункторФункциональный литерал

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.