В этой главе:
□ динамическое определение функций с помощью лямбда-выражений;
□ добавление полиморфизма путем оборачивания лямбда-выражений в конструкцию
std::function
;
□ создание функций с помощью конкатенации;
□ создание сложных предикатов с помощью логической конъюнкции;
□ вызов нескольких функций с одними и теми же входными данными;
□ реализация
transform_if
с применением std::accumulate
и лямбда-выражений;
□ генерация декартова произведения на основе любых входных данных во время компиляции.
Одной из важных новых функций C++11 были лямбда-выражения. В C++14 и C++17 они получили новые возможности, и это сделало их еще мощнее. Но что же такое лямбда-выражение?
Лямбда-выражения или лямбда-функции создают замыкания. Замыкание — очень обобщенный термин для безымянных объектов, которые можно вызывать как функции. Чтобы предоставить подобную возможность в С++, такой объект должен реализовывать оператор вызова функции
()
, с параметрами или без. Создание аналогичного объекта без лямбда-выражений до появления С++11 выглядело бы так:
#include
#include
int main() {
struct name_greeter {
std::string name; void operator()() {
std::cout << "Hello, " << name << '\n';
}
};
name_greeter greet_john_doe {"John Doe"};
greet_john_doe();
}
Экземпляры структуры
name_greeter
, очевидно, содержат строку. Обратите внимание: тип этой структуры и объект не являются безымянными, в отличие от лямбда-выражений. С точки зрения замыканий можно утверждать, что они захватывают строку. Когда экземпляр-пример вызывается как функция без параметров, на экран выводится строка "Hello, John Doe
", поскольку мы указали строку с таким именем.
Начиная с С++11 создавать подобные замыкания стало проще:
#include
int main() {
auto greet_john_doe ([] {
std::cout << "Hello, John Doe\n";
});
greet_john_doe();
}
На этом все. Целая структура
name_greeter
заменяется небольшой конструкцией [] { /* сделать что-то */ }
, которая на первый взгляд выглядит странно, но уже в следующем разделе мы рассмотрим все возможные случаи ее применения.
Лямбда-выражения помогают поддерживать код обобщенным и чистым. Они могут применяться как параметры для обобщенных алгоритмов, чтобы уточнить их при обработке конкретных типов, определенных пользователем. Они также могут служить для оборачивания рабочих пакетов и данных, чтобы их можно было запускать в потоках или просто сохранять работу и откладывать само выполнение пакетов. После появления С++11 было создано множество библиотек, работающих с лямбда-выражениями, поскольку они стали естественной частью языка С++. Еще одним вариантом использования лямбда-выражений является метапрограммирование, поскольку они могут быть оценены во время выполнения программы. Однако мы не будем рассматривать этот вопрос, так как он не относится к теме данной книги.
В текущей главе мы в значительной мере будем опираться на отдельные шаблоны функционального программирования, которые могут показаться странными для новичков и даже опытных программистов, еще не работавших с такими шаблонами. Если в последующих примерах вы увидите лямбда-выражения, возвращающие лямбда-выражения, которые опять-таки возвращают лямбда-выражения, то, пожалуйста, не теряйтесь. Мы несколько выходим за рамки привычного стиля программирования, чтобы подготовиться к работе с современным языком С++, в котором шаблоны функционального программирования встречаются все чаще и чаще. Если код какого-то примера выглядит слишком сложным, то уделите время тому, чтобы подробнее разобрать его. Как только вы с этим разберетесь, сложные лямбда-выражения в реальных проектах больше не будут вас смущать.
Применяя лямбда-выражения, можно инкапсулировать код, чтобы вызвать его позже или даже в другом месте, поскольку его разрешено копировать. Кроме того, можно инкапсулировать код несколько раз с несколько различающимися параметрами, при этом не нужно будет реализовывать новый класс функции для этой задачи.
Синтаксис лямбда-выражений выглядел новым в С++11, и к С++17 он несколько изменился. В этом разделе мы увидим, как сейчас выглядят лямбда-выражения и что они означают.
Как это делается
В этом примере мы напишем небольшую программу, в которой поработаем с лямбда-выражениями, чтобы понять основные принципы взаимодействия с ними.
1. Для работы с лямбда-выражениями не нужна поддержка библиотек, но мы будем выводить сообщения на консоль и использовать строки, поэтому понадобятся соответствующие заголовочные файлы:
#include
#include
2. В данном примере все действие происходит в функции
main
. Мы определим два объекта функций, которые не принимают параметры, и вернем целочисленные константы со значениями 1
и 2
. Обратите внимание: выражение return
окружено фигурными скобками {}
, как это делается в обычных функциях, а круглые скобки ()
, указывающие на функцию без параметров, являются необязательными, мы не указываем их во втором лямбда-выражении. Но квадратные скобки []
должны присутствовать:
int main()
{
auto just_one ( [](){ return 1; } );
auto just_two ( [] { return 2; } );
3. Теперь можно вызвать оба объекта функций, просто написав имя переменных, которые в них сохранены, и добавив скобки. В этой строке их не отличить от обычных функций:
std::cout << just_one() << ", " << just_two() << '\n';
4. Забудем о них и определим еще один объект функции, который называется
plus
, — он принимает два параметра и возвращает их сумму:
auto plus ( [](auto l, auto r) { return l + r; } );
5. Использовать такой объект довольно просто, в этом плане он похож на любую другую бинарную функцию. Мы указали, что его параметры имеют тип auto, вследствие чего объект будет работать со всеми типами данных, для которых определен оператор +, например со строками.
std::cout << plus(1, 2) << '\n';
std::cout << plus(std::string{"a"}, "b") << '\n';
6. Не нужно сохранять лямбда-выражение в переменной, чтобы использовать его. Мы также можем определить его в том месте, где это необходимо, а затем разместить параметры для данного выражения в круглых скобках сразу после него (1, 2):
std::cout
<< [](auto l, auto r){ return l + r; }(1, 2)
<< '\n';
7. Далее определим замыкание, которое содержит целочисленный счетчик. При каждом вызове значение этого счетчика будет увеличиваться на
1
и возвращать новое значение. Для указания на то, что замыкание содержит внутренний счетчик, разместим в скобках выражение count = 0
— оно указывает, что переменная count
инициализирована целочисленным значением 0
. Чтобы позволить ему изменять собственные переменные, мы используем ключевое слово mutable
, поскольку в противном случае компилятор не разрешит это сделать:
auto counter (
[count = 0] () mutable { return ++count; }
);
8. Теперь вызовем объект функции пять раз и выведем возвращаемые им значения с целью увидеть, что значение счетчика увеличивается:
for (size_t i {0}; i < 5; ++i) {
std::cout << counter() << ", ";
}
std::cout << '\n';
9. Мы также можем взять существующие переменные и захватить их по ссылке вместо того, чтобы создавать копию значения для замыкания. Таким образом, значение переменной станет увеличиваться в замыкании и при этом будет доступно за его пределами. Для этого мы поместим в скобках конструкцию
&a
, где символ &
означает, что мы сохраняем ссылку на переменную, но не копию:
int a {0};
auto incrementer ( [&a] { ++a; } );
10. Если это работает, то можно вызвать данный объект функции несколько раз, а затем пронаблюдать, действительно ли меняется значение переменной
a
:
incrementer();
incrementer();
incrementer();
std::cout
<< "Value of 'a' after 3 incrementer() calls: "
<< a << '\n';
11. Последний пример демонстрирует каррирование. Оно означает, что мы берем функцию, принимающую некоторые параметры, а затем сохраняем ее в другом объекте функции, принимающем меньше параметров. В этом случае мы сохраняем функцию
plus
и принимаем только один параметр, который будет передан в функцию plus
. Другой параметр имеет значение 10
; его мы сохраняем в объекте функции. Таким образом, мы получаем функцию и назовем ее plus_ten
, поскольку она может добавить значение 10
к единственному принимаемому ею параметру.
auto plus_ten ( [=] (int x) {
return plus(10, x);});
std::cout << plus_ten(5) << '\n';
}
12. Перед компиляцией и запуском программы пройдем по коду еще раз и попробуем предугадать, какие именно значения выведем в терминале. Затем запустим программу и взглянем на реальные выходные данные:
1, 2
3
ab 3
1, 2, 3, 4, 5,
Value of a after 3 incrementer() calls: 3
15
Как это работает
То, что мы сейчас сделали, выглядит не слишком сложно: сложили числа, а затем инкрементировали их и вывели на экран. Мы даже выполнили конкатенацию строк с помощью объекта функций, который был реализован для сложения чисел. Но для тех, кто еще незнаком с синтаксисом лямбда-выражений, это может показаться запутанным.
Итак, сначала рассмотрим все особенности, связанные с лямбда-выражениями (рис. 4.1).
Как правило, можно опустить большую часть этих параметров, чтобы сэкономить немного времени. Самым коротким лямбда-выражением является выражение
[]{}
. Оно не принимает никаких параметров, ничего не захватывает и, по сути, ничего не делает.
Что же значит остальная часть?
Список для захвата
Определяет, что именно мы захватываем и выполняем ли захват вообще. Есть несколько способов сделать это. Рассмотрим два «ленивых» варианта.
1. Если мы напишем
[=]
()
{...}
, то захватим каждую внешнюю переменную, на которую ссылается замыкание, по значению; т.е. эти значения будут скопированы.
2. Запись
[&]
()
{...}
означает следующее: все внешние объекты, на которые ссылается замыкание, захватываются только по ссылке, что не приводит к копированию.
Конечно, можно установить настройки захвата для каждой переменной отдельно. Запись
[a, &b]
()
{...}
означает, что переменную a
мы захватываем по значению, а переменную b
— по ссылке. Для этого потребуется напечатать больше текста, но, как правило, данный способ безопаснее, поскольку мы не можем случайно захватить что-то ненужное из-за пределов замыкания.
В текущем примере мы определили лямбда-выражение следующим образом:
[count=0]
()
{...}
. В этом особом случае мы не захватываем никаких переменных из-за пределов замыкания, только определили новую переменную с именем count
. Тип данной переменной определяется на основе значения, которым мы ее инициализировали, а именно 0
, так что она имеет тип int
.
Кроме того, можно захватить одни переменные по значению, а другие — по ссылке, например:
□
[a, &b]
()
{...}
— копируем a
и берем ссылку на b
;
□
[&, a]
()
{...}
— копируем a и применяем ссылку на любую другую переданную переменную;
□
[=, &b, i{22}, this]
()
{...}
— получаем ссылку на b
, копируем значение this
, инициализируем новую переменную i
значением 22
и копируем любую другую использованную переменную.
Если вы попытаетесь захватить переменную-член некоторого объекта, то не сможете сделать это с помощью конструкции
[member_a]
()
{...}
. Вместо этого нужно определить либо this
, либо *this
.
mutable (необязательный)
Если объект функции должен иметь возможность модифицировать получаемые им переменные путем копирования (
[=]
), то его следует определить как mutable
. Это же касается вызова неконстантных методов захваченных объектов.
constexpr (необязательный)
Если мы явно пометим лямбда-выражение с помощью ключевого слова
constexpr
, то компилятор сгенерирует ошибку, когда это выражение не будет соответствовать критериям функции constexpr
. Преимущество использования функций constexpr
и лямбда-выражений заключается в том, что компилятор может оценить их результат во время компиляции, если они вызываются с параметрами, постоянными на протяжении данного процесса. Это приведет к тому, что позднее в бинарном файле будет меньше кода.
Если мы не указываем явно, что лямбда-выражения являются
constexpr
, но эти выражения соответствуют всем требуемым критериям, то они все равно будут считаться constexpr
, только неявно. Если нужно, чтобы лямбда-выражение было constexpr
, то лучше явно задавать его таковым, поскольку иначе в случае наших неверных действий компилятор начнет генерировать ошибки.
exception attr (необязательный)
Здесь определяется, может ли объект функции генерировать исключения, если при вызове столкнется с ошибкой.
return type (необязательный)
При необходимости иметь полный контроль над возвращаемым типом, вероятно, не нужно, чтобы компилятор определял его автоматически. В таких случаях можно просто использовать конструкцию
[] () -> Foo {}
, которая укажет компилятору, что мы всегда будем возвращать объекты типа Foo
.
Предположим, нужно написать функцию-наблюдатель для какого-то значения, которое может изменяться время от времени, что приведет к оповещению других объектов, например индикатора давления газа, цены на акцию т.п. При изменении значения должен вызываться список объектов-наблюдателей, которые затем по-своему на это отреагируют.
Для реализации задачи можно поместить несколько объектов функции-наблюдателя в вектор, все они будут принимать в качестве параметра переменную типа
int
, которая представляет наблюдаемое значение. Мы не знаем, что именно станут делать данные функции при вызове, но нам это и неинтересно.
Какой тип будут иметь объекты функций, помещенные в вектор? Нам подойдет тип
std::vector
, если мы захватываем указатели на функции, имеющие сигнатуры наподобие void f(int);
. Данный тип сработает с любым лямбда-выражением, которое захватывает нечто, имеющее совершенно другой тип в сравнении с обычной функцией, поскольку это не просто указатель на функцию, а объект, объединяющий некий объем данных с функцией! Подумайте о временах до появления С++11, когда лямбда-выражений не существовало. Классы и структуры были естественным способом связывания данных с функциями, и при изменении типов членов класса получится совершенно другой класс. Это естественно, что вектор не может хранить значения разных типов, используя одно имя типа.
Не стоит указывать пользователю, что он может сохранить объекты функции наблюдателя, которые ничего не захватывают, поскольку это ограничивает варианты применения. Как же позволить ему сохранять любые объекты функций, ограничивая лишь интерфейс вызова, принимающий конкретный диапазон параметров в виде наблюдаемых значений?
В этом разделе мы рассмотрим способ решения данной проблемы с помощью объекта
std::function
, который может выступать в роли полиморфической оболочки для любого лямбда-выражения, независимо от того, какие значения оно захватывает.
Как это делается
В этом примере мы создадим несколько лямбда-выражений, значительно отличающихся друг от друга, но имеющих одинаковую сигнатуру вызова. Затем сохраним их в одном векторе с помощью
std::function
.
1. Сначала включим необходимые заголовочные файлы:
#include
#include
#include
#include
#include
2. Реализуем небольшую функцию, которая возвращает лямбда-выражение. Она принимает контейнер и возвращает объект функции, захватывающий этот контейнер по ссылке. Сам по себе объект функции принимает целочисленный параметр. Когда данный объект получает целое число, он добавит его в свой контейнер.
static auto consumer (auto &container){
return [&] (auto value) {
container.push_back(value);
};
}
3. Еще одна небольшая вспомогательная функция выведет на экран содержимое экземпляра контейнера, который мы предоставим в качестве параметра:
static void print (const auto &c)
{
for (auto i : c) {
std::cout << i << ", ";
}
std::cout << '\n';
}
4. В функции
main
мы создадим объекты классов deque
, list
и vector
, каждый из которых будет хранить целые числа:
int main()
{
std::deque d;
std::list l;
std::vector v;
5. Сейчас воспользуемся функцией consumer для работы с нашими экземплярами контейнеров
d
, l
и v:
создадим для них объекты-потребители функций и поместим их в экземпляр vector
. Эти объекты функций будут захватывать ссылку на один из объектов контейнера. Последние имеют разные типы, как и объекты функций. Тем не менее вектор хранит экземпляры типа std::function
. Все объекты функций неявно оборачиваются в объекты типа std::function
, которые затем сохраняются в векторе:
const std::vector> consumers
{consumer(d), consumer(l), consumer(v)};
6. Теперь поместим десять целочисленных значений во все структуры данных, проходя по значениям в цикле, а затем пройдем в цикле по объектам функций-потребителей, которые вызовем с записанными значениями:
for (size_t i {0}; i < 10; ++i) {
for (auto &&consume : consumers) {
consume(i);
}
}
7. Все три контейнера теперь должны содержать одинаковые десять чисел. Выведем на экран их содержимое:
print(d);
print(l);
print(v);
}
8. Компиляция и запуск программы дадут следующий результат, который выглядит именно так, как мы и ожидали:
$ ./std_function
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
Как это работает
Самой сложной частью этого примера является следующая строка:
const std::vector> consumers
{consumer(d), consumer(l), consumer(v)};
Объекты
d
, l
и v
обернуты в вызов consumer(...)
. Он возвращает объекты функций, каждый из которых захватывает ссылки на один из объектов — d
, l
или v
. Хотя все объекты функций принимают в качестве параметров целочисленные значения, тот факт, что они захватывают абсолютно разные переменные, также делает их типы совершенно разными. Это похоже на попытку разместить в векторе переменные типов A
, B
и C
, когда сами типы не имеют ничего общего.
Чтобы это исправить, нужно найти общий тип, способный хранить разные объекты функций, например
std::function
. Объект типа std::function
может хранить любой объект функции или традиционную функцию, которая принимает целочисленный параметр и ничего не возвращает. С помощью полиморфизма он отвязывает тип от лежащего в его основе типа объекта функции.
Представьте, будто мы написали такой код:
std::function f (
[&vector](int x) { vector.push_back(x); });
Здесь объект функции, создаваемый на основе лямбда-выражения, обернут в объект типа
std::function
, и всякий раз вызов f(123)
приводит к виртуальному вызову функции, который перенаправляется реальному объекту функции, находящемуся внутри.
При сохранении объектов функции экземпляры
std::function
применяют некоторую логику. При захвате все большего и большего количества переменных лямбда-выражение будет увеличиваться. Если его размер относительно мал, то std::function
может хранить его внутри себя. Если же размер сохраненного объекта функций слишком велик, то std::function
выделит фрагмент памяти в куче и сохранит объект там. Возможности нашего кода в подобном случае затронуты не будут, но знать об этом нужно, поскольку такая ситуация может повлиять на его производительность.
Многие программисты-новички думают или надеются, что
std::function<...>
на самом деле выражает тип лямбда-выражения. Отнюдь. Это полиморфическая вспомогательная библиотечная функция, которая полезна для оборачивания лямбда-выражений и сокрытия различий в их типах.
Многие проблемы можно решить, не полагаясь исключительно на собственный код. Например, взглянем на то, как решается задача поиска уникальных слов в тексте на языке программирования Haskell. В первой строке определяется функция
unique_words
, а во второй показывается ее использование на примере строки (рис. 4.2).
Ого! Программа получилась действительно короткой. Не вдаваясь особо в синтаксис языка Haskell, взглянем на то, что делает код. Определяется функция
unique_words
, в которой к входным данным применяется набор функций. Сначала все символы преобразуются в строчные с помощью map toLower
. Таким образом, слова наподобие FOO и foo могут считаться одним словом. Далее функция words
разбивает предложение на отдельные слова. Например, из строки "foo bar baz"
мы получим массив ["foo", "bar", "baz"]
. Следующий шаг — сортировка нового списка слов. В результате последовательность слов ["a", "b", "a"]
будет выглядеть как ["a", "a", "b"]
. Теперь в дело вступает функция group
. Она группирует последовательные слова в списки, т.е. конструкция ["a", "a", "b"]
получит вид [["a", "a"], ["b"]]
. Задача практически выполнена, и теперь нужно сосчитать, сколько получилось групп одинаковых слов. В этом поможет функция length
.
Это замечательный стиль программирования, так как мы можем прочитать код справа налево, поскольку, по сути, описываем процесс преобразования предложения. Нет нужды знать, как реализованы отдельные фрагменты (если только они не будут работать слишком медленно и с ошибками).
Однако мы здесь не ради восхваления Haskell, а чтобы улучшить навыки работы с языком С++. Он позволяет написать аналогичный код. Мы не сможем достичь того же уровня элегантности, какой видели в языке Haskell, зато у нас под рукой самый быстрый язык программирования. В этом разделе показано, как инициировать конкатенацию функций в С++ с помощью лямбда-выражений.
Как это делается
В данном примере мы определим несколько простых объектов функций и сконкатенируем их, чтобы получить одну функцию, которая применяет такие же функции одну за другой для полученных входных данных. Для этого напишем собственную вспомогательную функцию для конкатенации.
1. Сначала включим несколько заголовочных файлов:
#include
#include
2. Далее реализуем вспомогательную функцию
concat
, которая принимает множество параметров. Таковыми выступят функции наподобие f
, g
и h
, а результатом будет еще один объект функции, применяющий функции f(g(h(...)))
для любых входных данных.
template
auto concat(T t, Ts ...ts)
{
3. Теперь задача усложняется. Когда пользователь предоставит функции
f
, g
и h
, мы оценим это выражение как f(concat(g, h))
, которое будет распаковано в f(g(concat(h)))
, на чем рекурсия остановится, и мы получим выражение f(g(h(...)))
. Данная цепочка вызовов функций, представляющая конкатенацию пользовательских функций, захватывается лямбда-выражением, которое затем может принять какие-то параметры p
и передать в вызов f(g(h(p)))
. Мы будем возвращать это лямбда-выражение. Конструкция if constexpr
проверяет, находимся ли мы на шаге рекурсии, требующем сконкатенировать более чем одну функцию:
if constexpr (sizeof...(ts) > 0) {
return [=](auto ...parameters) {
return t(concat(ts...)(parameters...));
};
}
4. Еще одна ветвь конструкции
if constexpr
будет выбрана компилятором в том случае, если достигнут конец рекурсии. В таких ситуациях просто возвращаем функцию, t
, поскольку она является единственным оставшимся параметром:
else {
return t;
}
}
5. Теперь применим нашу новую функцию конкатенации, передав в нее несколько функций. Начнем с функции
main
, где определим два дешевых объекта функций:
int main()
{
auto twice ([] (int i) { return i * 2; });
auto thrice ([] (int i) { return i * 3; });
6. Выполним конкатенацию. Объединим два объекта функций умножения с помощью функции STL
std::plus
, которая принимает два параметра и возвращает их сумму. Таким образом, получим функцию, выполняющую вызов twice(thrice(plus(a, b )))
.
auto combined (
concat(twice, thrice, std::plus{})
);
7. Воспользуемся тем, что получилось. Функция
combined
теперь выглядит как обычная, и компилятор может объединять эти функции без особых задержек:
std::cout << combined(2, 3) << '\n';
}
8. Компиляция и запуск программы дадут следующий результат, и он не будет неожиданным, поскольку
2*3*(2+3)
равно 30
:
$ ./concatenation
30
Как это работает
Самой сложной частью этого раздела является функция
concat
. Она выглядит очень мудреной, поскольку разворачивает набор параметров ts
в другое лямбда-выражение, которое рекурсивно снова вызывает функцию concat
, теперь уже с меньшим количеством параметров:
template
auto concat(T t, Ts ts)
{
if constexpr (sizeof...(ts) > 0) {
return [=](auto ...parameters) {
return t(concat(ts...)(parameters...));
};
} else {
return [=](auto ...parameters) {
return t(parameters...);
};
}
}
Напишем более простую версию этой функции, которая объединяет ровно три функции:
template
auto concat(F f, G g, H h)
{
return [=](auto ... params) {
return f(g(h(params...)));
};
}
Эта функция выглядит аналогично, но уже не так сложна. Мы возвращаем лямбда-выражение, которое захватывает
f
, g
и h
. Оно принимает произвольно большое количество параметров и просто перенаправляет их по цепочке вызовов f
, g
и h
. Если мы пользуемся конструкцией auto combined(concat(f,g,h))
, а затем вызываем данный объект функции с двумя параметрами, например combined(2,3)
, то 2
, 3
представлены набором параметров из предыдущей функции concat
.
Повторный взгляд на гораздо более сложную обобщенную функцию
concat
позволяет увидеть следующее: единственное, что мы действительно делаем по-другому, — выполняем конкатенацию f(g(h(params...)))
. Вместо этого мы пишем выражение f(concat(g,h))(params...)
, которое будет преобразовано в конструкцию f(g(concat(h)))(params...)
при следующем рекурсивном вызове, а затем — в конструкцию f(g(h(params...)))
.
При фильтрации данных с помощью обобщенного кода мы определяем предикаты, которые указывают, какие именно данные нужны. Иногда предикаты являются комбинациями нескольких «собратьев».
При фильтрации строк, например, можно реализовать предикат, который возвращает значение
true
, если входная строка начинается со слова "foo
". Еще один предикат должен возвращать значение true
, если входная строка заканчивается словом "bar
".
Вместо того чтобы постоянно писать собственные предикаты, можно повторно использовать предикаты, объединив их. Для фильтрации строк, которые начинаются со слова "
foo
" и заканчиваются словом "bar
", можно просто выбрать уже существующие предикаты и объединить их с помощью логического И
. В данном разделе мы будем работать с лямбда-выражениями, чтобы найти удобный способ сделать это.
Как это делается
В этом примере мы реализуем очень простые предикаты для фильтрации строк, а затем объединим их с помощью небольшой вспомогательной функции, которая создаст их комбинацию в обобщенном виде.
1. Как обычно, сначала включим несколько заголовочных файлов:
#include
#include
#include
#include
#include
2. Поскольку они понадобятся нам в дальнейшем, реализуем две простые функции-предиката. Одна из них говорит о том, начинается ли строка с символа
'a'
, а вторая — заканчивается ли строка символом 'b'
:
static bool begins_with_a (const std::string &s)
{
return s.find("a") == 0;
}
static bool ends_with_b (const std::string &s)
{
return s.rfind("b") == s.length() - 1;
}
3. Теперь реализуем вспомогательную функцию и назовем ее
combine
. Она принимает бинарную функцию в качестве первого параметра — это может быть, например, логическое И
либо логическое ИЛИ
. Затем она принимает два других параметра, представляющих собой две функции-предиката, которые нужно объединить:
template
auto combine(F binary_func, A a, B b)
{
4. Просто возвращаем лямбда-выражение, которое захватывает новую комбинацию предикатов. Оно направляет параметр обоим предикатам, а затем помещает результаты работы их обоих в бинарную функцию и возвращает ее результат:
return [=](auto param) {
return binary_func(a(param), b(param));
};
}
5. Укажем, что будем использовать пространство имен
std
с целью сэкономить немного времени при написании функции main
:
using namespace std;
6. Теперь объединим две функции-предиката в другую функцию-предикат, говорящую, начинается ли заданная строка с символа
a
и заканчивается ли символом b
, как, например, строки "ab"
или "axxxb"
. На роль бинарной функции выбираем std::logical_and
. Это шаблонный класс, экземпляр которого нужно создавать, вследствие чего будем использовать его вместе с фигурными скобками. Обратите внимание: мы не предоставляем дополнительный параметр шаблона, поскольку для данного класса он по умолчанию будет равен void
. Эта специализация класса самостоятельно выводит все типы параметров:
int main()
{
auto a_xxx_b (combine(
logical_and<>{},
begins_with_a, ends_with_b));
7. Итерируем по стандартному потоку ввода и выводим на экран все слова, которые удовлетворяют условиям предиката:
copy_if(istream_iterator{cin}, {},
ostream_iterator{cout, ", "},
a_xxx_b);
cout << '\n';
}
8. Компиляция и запуск программы дадут следующий результат. Мы передаем программе четыре слова, но только два из них удовлетворяют условиям предиката:
$ echo "ac cb ab axxxb" | ./combine
ab, axxxb,
Дополнительная информация
Библиотека STL уже предоставляет несколько функциональных объектов наподобие
std::logical_and
, std::logical_or
, а также множество других, поэтому не нужно реализовывать их в каждом проекте. Ознакомиться со справочным материалом по С++ и исследовать доступные варианты можно на http://en.cppreference.com/w/cpp/utility/functional.
Существует множество задач, чьи решения требуют написания повторяющегося кода. Большую его часть можно легко заменить лямбда-выражениями, и очень просто создать вспомогательную функцию, которая будет оборачивать подобные повторяющиеся задачи.
В данном разделе мы поработаем с лямбда-выражениями, чтобы направить один вызов функции со всеми его параметрами нескольким получателям. Мы сделаем это, не задействовав никаких дополнительных структур данных, так что перед компилятором будет стоять простая задача: сгенерировать бинарный файл без лишних затрат.
Как это делается
В этом примере мы напишем вспомогательную функцию для работы с лямбда-выражениями, которая направляет один вызов нескольким объектам, и еще одну такую функцию, направляющую один вызов нескольким функциям. Эту комбинацию мы используем в нашем примере, чтобы вывести на экран одно сообщение с помощью разных функций-принтеров.
1. Включим заголовочный файл, необходимый для вывода данных на экран:
#include
2. Сначала реализуем функцию
multicall
, которая является основной для этого примера. Она принимает произвольное количество функций в качестве параметров и возвращает лямбда-выражение, принимающее один параметр. Она перенаправляет данный параметр всем функциям, предоставленным ранее. Таким образом, можно определить функцию auto call_all(multicall(f,g,h))
, а затем вызов call_all(123)
приведет к серии вызовов f(123); g(123); h(123);
. Эта функция уже выглядит сложной, поскольку требуется распаковать набор параметров, в котором находятся функции, в набор вызовов с помощью конструктора std::initializer_list
.
static auto multicall (auto ...functions)
{
return [=](auto x) {
(void)std::initializer_list{
((void)functions(x), 0)...
};
};
}
3. Следующая вспомогательная функция принимает функцию
f
и набор параметров xs
. После этого она вызывает функцию f
для каждого из параметров. Таким образом, вызов for_each(f,1,2,3)
приводит к серии вызовов: f(1); f(2); f(3);
. Функция, по сути, использует такой же синтаксический прием для распаковки набора параметров xs
в набор вызовов функций, как и функция, показанная ранее.
static auto for_each (auto f, auto ...xs) {
(void)std::initializer_list{
((void)f(xs), 0)...
};
}
4. Функция
brace_print
принимает два символа и возвращает новый объект функции, принимающий один параметр x
. Она выводит его на экран, окружив двумя символами, которые мы только что захватили.
static auto brace_print (char a, char b) {
return [=] (auto x) {
std::cout << a << x << b << ", ";
};
}
5. Теперь наконец можно использовать все эти функции в функции
main
. Сначала определим функции f
, g
и h
. Они представляют функции print
, которые принимают значения и выводят их на экран, окружив разными скобками. Функция nl
принимает любой параметр и просто выводит на экран символ переноса строки.
int main()
{
auto f (brace_print('(', ')'));
auto g (brace_print('[', ']'));
auto h (brace_print('{', '}'));
auto nl ([](auto) { std::cout << '\n'; });
6. Объединим все эти функции с помощью вспомогательной функции
multicall
:
auto call_fgh (multicall(f, g, h, nl));
7. Мы хотим, чтобы каждое предоставленное нами число было выведено на экран трижды в разных скобках. Таким образом, нужно выполнить один вызов функции, который приведет к пяти вызовам нашей мультифункции, а та, в свою очередь, выполнит четыре вызова для функций
f
, g
, h
и nl
:
for_each(call_fgh, 1, 2, 3, 4, 5);
}
8. Перед компиляцией и запуском программы подумаем, какой результат должны получить:
$ ./multicaller
(1), [1], {1},
(2), [2], {2},
(3), [3], {3},
(4), [4], {4},
(5), [5], {5},
Как это работает
Вспомогательные функции, которые мы только что реализовали, выглядят очень сложными. Так произошло из-за распаковки набора параметров с помощью
std::initializer_list
. Почему мы вообще использовали эту структуру данных? Еще раз взглянем на for_each
:
auto for_each ([](auto f, auto ...xs) {
(void)std::initializer_list{
((void)f(xs), 0)...
};
});
Сердцем данной функции является выражение
f(xs).xs
— набор параметров, и нужно распаковать его, чтобы получить отдельные значения и передать их отдельным вызовам функции f
. К сожалению, мы не можем просто написать конструкцию f(xs)...
с помощью нотации ...
, с которой уже знакомы.
Вместо этого можно создать список значений с помощью
std::initializer_list
, имеющего конструктор с переменным числом параметров. Выражение наподобие return std::initializer_list{f(xs)...};
решает задачу, но имеет недостатки. Взглянем на реализацию функции for_each
, которая тоже работает и при этом выглядит проще нашего варианта:
auto for_each ([](auto f, auto ...xs) {
return std::initializer_list{f(xs)...};
});
Она более проста для понимания, но имеет следующие недостатки.
1. Создает список инициализаторов для возвращаемых значений на основе вызовов функции
f
. К этому моменту нас не волнуют возвращаемые значения.
2. Возвращает данный список, а нам нужна функция, работающая в стиле «запустил и забыл», которая не возвращает ничего.
3. Вполне возможно, что
f
— функция, которая не возвращает ничего, в таком случае код даже не будет скомпилирован.
Гораздо более сложная функция
for_each
решает все эти проблемы. Она делает следующее.
1. Не возвращает список инициализаторов, а приводит все выражение к типу
void
с помощью (void)std::initializer_list{...}
.
2. Внутри инициализирующего выражения преобразует выражение
f(xs)...
в выражение (f(xs),0)
. Это приводит к тому, что возвращаемое выражение отбрасывается, а значение 0
все еще помещается в список инициализаторов.
3. Конструкция
f(xs)
в выражении (f(xs),0)
. также преобразуется к типу void
, поэтому возвращаемое значение, если таковое существует, нигде не обрабатывается.
Объединение этих особенностей, к сожалению, ведет к появлению уродливой конструкции, но она корректно работает и компилируется для множества объектов функций независимо от того, возвращают ли они какое-то значение.
Приятной особенностью описанного механизма является тот факт, что порядок вызовов функций будет сохраняться в строгой последовательности.
Выполнять преобразование конструкции
(void)выражение
в рамках старой нотации языка С не рекомендуется, поскольку в языке С++ имеются собственные операции преобразования. Вместо этого стоит использовать конструкцию reinterpret_cast(выражение)
, но данный вариант еще больше снизит удобочитаемость кода.
Большинство разработчиков, применяющих
std::copy_if
и std::transform
, могли задаваться вопросом, почему не существует функции std::transform_if
. Функция std::copy_if
копирует элементы из исходного диапазона по месту назначения, но опускает элементы, не соответствующие определенной пользователем функции-предикату. Функция std::transform
безусловно копирует все элементы из исходного диапазона по месту назначения, но при этом преобразует их в процессе. Это происходит с помощью функции, которая определена пользователем и может выполнять как нечто простое (например, умножение чисел), так и полные преобразования к другим типам.
Эти функции существуют достаточно давно, но функции
std::transform_if
все еще нет. Ее можно легко создать, реализовав функцию, которая итерирует по диапазонам данных и копирует все элементы, соответствующие предикату, выполняя в процессе их преобразование. Однако мы воспользуемся случаем и разберем решение данной задачи с точки зрения лямбда-выражений.
Как это делается
В этом примере мы создадим собственную функцию
transform_if
, которая работает, передавая алгоритму std::accumulate
правильные объекты функций.
1. Как и всегда, включим некоторые заголовочные файлы:
#include
#include
#include
2. Сначала реализуем функцию с именем
map
. Она принимает функцию преобразования входных данных и возвращает объект функции, который будет работать с функцией std::accumulate
:
template
auto map(T fn)
{
3. Мы будем возвращать объект функции, принимающий функцию
reduce
. Когда данный объект вызывается с этой функцией, он возвращает другой объект функции, который принимает аккумулятор и входной параметр. Он вызывает функцию reduce
для этого аккумулятора и преобразованной входной переменной fn
. Если это описание кажется вам слишком сложным — не волнуйтесь, далее мы соберем все вместе и посмотрим, как работают эти функции.
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
return reduce_fn(accum, fn(input));
};
};
}
4. Теперь реализуем функцию
filter
. Она работает точно так же, как и функция map
, но не затрагивает входные данные, в то время как map
преобразует их с помощью функции transform
. Вместо этого принимаем функцию-предикат и опускаем те входные переменные, которые не соответствуют данному предикату, не выполняя для них функцию reduce
.
template
auto filter(T predicate)
{
5. Два лямбда-выражения имеют такие же сигнатуры функций, что и выражения в функции
map
. Единственное отличие заключается в следующем: входной параметр остается неизменным. Функция-предикат используется для определения того, будем ли мы вызывать функцию reduce_fn
для входных данных или же получим доступ к аккумулятору, не внося никаких изменений.
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
if (predicate(input)) {
return reduce_fn(accum, input);
} else {
return accum;
}
};
};
}
6. Теперь воспользуемся этими вспомогательными функциями. Создадим экземпляры итераторов, которые позволяют считать целочисленные значения из стандартного потока ввода:
int main()
{
std::istream_iterator it {std::cin};
std::istream_iterator end_it;
7. Далее определим функцию-предикат
even
, которая возвращает значение true
, если перед нами четное число. Функция преобразования twice
умножает свой целочисленный параметр на 2
:
auto even ([](int i) { return i % 2 == 0; });
auto twice ([](int i) { return i * 2; });
8. Функция
std::accumulate
принимает диапазон значений и аккумулирует их. Аккумулирование по умолчанию означает суммирование значений с помощью оператора +
. Мы хотим предоставить собственную функцию аккумулирования. Таким образом, хранить сумму значений не нужно. Мы присвоим каждое значение из диапазона разыменованному итератору it
, а затем вернем его после продвижения вперед.
auto copy_and_advance ([](auto it, auto input) {
*it = input; return ++it;
});
9. Наконец мы готовы собрать все воедино. Мы итерируем по стандартному потоку ввода и предоставляем вывод
ostream_iterator
, который выдает значения в консоль. Объект функции copy_and_advance
работает с этим итератором вывода, присваивая ему целые числа, полученные от пользователя. По сути, данное действие выводит на экран присвоенные элементы. Но мы хотим видеть только четные числа, полученные от пользователя, и умножить их. Для этого оборачиваем функцию copy_and_advance
в фильтр even, а затем — в преобразователь twice
.
std::accumulate(it, end_it,
std::ostream_iterator{std::cout, ", "},
filter(even)(
map(twice)(
copy_and_advance
)
));
std::cout << '\n';
}
10. Компиляция и запуск программы дадут следующий результат. Значения
1
, 3
и 5
отбрасываются, поскольку являются нечетными, а 2
, 4
и 6
выводятся на экран после их умножения на два.
$ echo "1 2 3 4 5 6" | ./transform_if
4, 8, 12,
Как это работает
Этот пример выглядит довольно сложным, поскольку мы активно использовали вложенные лямбда-выражения. Чтобы понять, как все работает, взглянем на внутреннее содержимое функции
std::accumulate
. Именно так она выглядит в обычной реализации, предлагаемой в библиотеке STL:
template
T accumulate(InputIterator first, InputIterator last, T init, F f)
{
for (; first != last; ++first) {
init = f(init, *first);
}
return init;
}
Параметр функции
f
выполняет здесь остальную работу, а цикл собирает результаты в предоставленной пользователем переменной init
. В обычном варианте использования диапазон итераторов может представлять собой вектор чисел, например 0
, 1
, 2
, 3
, 4
, а переменная init
будет иметь значение 0
. Функция f
является простой бинарной функцией, которая может определять сумму двух элементов с помощью оператора +
.
В нашем примере цикл просто складывает все элементы и записывает результат в переменную
init
, это выглядит, например, так: init = (((0+1)+2)+3)+4
. Подобная запись помогает понять, что std::accumulate
представляет собой функцию свертки. Выполнение свертки для диапазона значений означает применение бинарной операции для переменной-аккумулятора и каждого элемента диапазона пошагово (результат каждой операции является значением-аккумулятором для последующей операции). Поскольку эта функция обобщена, с ее помощью можно решать разные задачи, например реализовать функцию std::transform_if
! Функция f
в таком случае будет называться функцией reduce (свертки).
Очень прямолинейная реализация функции
transform_if
будет выглядеть так:
template
typename P, typename Transform>
OutputIterator transform_if(InputIterator first, InputIterator last,
OutputIterator out,
P predicate, Transform trans)
{
for (; first != last; ++first) {
if (predicate(*first)) {
*out = trans(*first);
++out;
}
}
return out;
}
Данная реализация очень похожа на
std::accumulate
, если считать параметр out
переменной init
и каким-то образом заменить функцией f
конструкцию if-construct
и ее тело!
Мы на самом деле сделали это — создали данную конструкцию
if-construct
и ее тело с помощью объекта бинарной функции, предоставленного в качестве параметра функции std::accumulate
:
auto copy_and_advance ([](auto it, auto input) {
*it = input;
return ++it;
});
Функция
std::accumulate
помещает переменную init
в параметр бинарной функции it
. Второй параметр — текущее значение из диапазона, получаемое в цикле. Мы предоставили итератор вывода как параметр init
функции std::accumulate
. Таким образом, функция std::accumulate
не считает сумму, а перемещает элементы, по которым итерирует, в другой диапазон. Это значит следующее: мы повторно реализовали функцию std::copy
, которая пока что не имеет предикатов и преобразований.
Фильтрацию с помощью предиката добавим путем обертывания функции
copy_and_advance
в другой объект функции, который пользуется функцией-предикатом:
template
auto filter(T predicate)
{
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
if (predicate(input)) {
return reduce_fn(accum, input);
} else {
return accum;
}
};
};
}
Эта конструкция на первый взгляд выглядит сложной, но посмотрим на конструкцию
if
. Если функция-предикат вернет значение true
, то параметры будут перенаправлены функции reduce_fn
, в роли которой в нашем случае выступает функция copy_and_advance
. Если же предикат вернет значение false
, то переменная accum
, выступающая в роли переменной init
функции std::accumulate
, будет возвращена без изменений. Так мы реализуем ту часть операции фильтрации, где пропускаем элемент. Конструкция if
находится внутри лямбда-выражения, которое имеет такую же сигнатуру бинарной функции, что и функция copy_and_advance;
это делает ее хорошей заменой.
Теперь мы можем отфильтровать элементы, но все еще не выполняем их преобразование. Это делается с помощью вспомогательной функции
map
:
template
auto map(T fn)
{
return [=] (auto reduce_fn) {
return [=] (auto accum, auto input) {
return reduce_fn(accum, fn(input));
};
};
}
Теперь код выглядит гораздо проще. Он тоже содержит внутреннее лямбда-выражение, которое имеет такую же сигнатуру, как и функция
copy_and_advance
, поэтому способен заменить ее. Реализация просто направляет дальше входные значения, но притом преобразует правый параметр вызова бинарной функции с помощью функции fn
.
Далее, когда мы воспользуемся этими вспомогательными функциями, напишем следующее выражение:
filter(even)(
map(twice)(
copy_and_advance
)
)
Вызов
filter(even)
захватывает предикат even
и дает функцию, которая принимает бинарную функцию, чтобы обернуть ее в другую бинарную функцию, выполняющую фильтрацию. Функция map(twice)
делает то же самое с функцией преобразования twice
, но оборачивает бинарную функцию copy_and_advance
в другую бинарную функцию, всегда преобразующую правый параметр.
Не выполнив оптимизацию, мы получим сложнейшую конструкцию, состоящую из вложенных функций, которые вызывают другие функции и при этом выполняют совсем немного работы. Однако компилятор может легко оптимизировать подобный код. Полученная бинарная функция так же проста, как и результат более прямолинейной реализации функции
transform_if
. Мы ничего не теряем с точки зрения производительности, приобретая компонуемость, свойственную функциям, поскольку смогли объединить предикат even
и функцию преобразования twice
столь же легко, как если бы на их месте были детали «Лего».
Лямбда-выражения и наборы параметров можно использовать для решения сложных задач. В этом разделе мы реализуем объект функции, который принимает произвольное количество входных параметров и генерирует декартово произведение данного множества, умноженного само на себя.
Декартово произведение — математическая операция. Она обозначается как
A x B
, что означает «декартово произведение множества А на множество В». Результатом будет одно множество, содержащее пары всех комбинаций элементов из множеств А и В. Операция, по сути, означает комбинирование каждого элемента из множества А с каждым элементом множества В. Эта операция показана на рис. 4.3.
Согласно схеме, если
A = (x,y,z)
, а B = (1,2,3)
, то декартово произведение этих множеств будет равно (x,1)
, (x,2)
, (x,3)
, (y,1)
, (y,2)
и т.д.
Если мы решим, что множества A и B одинаковы, например
(1,2)
, то их декартово произведение будет равно (1,1)
, (1,2)
, (2,1)
и (2,2)
. В некоторых случаях это может оказаться избыточным, поскольку комбинация элементов с самими собой (например, (1,1)
) или избыточные комбинации (1,2)
и (2,1)
способны стать ненужными. В таких случаях декартово произведение можно отфильтровать с помощью простого правила.
В этом разделе мы реализуем декартово произведение, не используя циклы, но применяя лямбда-выражения и распаковку набора параметров.
Как это делается
В примере мы реализуем объект функции, принимающий функцию
f
и набор параметров. Объект создаст декартово произведение набора параметров, отфильтрует избыточные части и вызовет для каждой пары функцию f
.
1. Включим только тот заголовочный файл STL, который нужен для печати:
#include
2. Затем определим простую вспомогательную функцию, которая выводит на экран пару значений, и начнем реализовывать функцию
main
:
static void print(int x, int y)
{
std::cout << "(" << x << ", " << y << ")\n";
}
int main()
{
3. Теперь начинается сложная часть. Сначала реализуем вспомогательную функцию для функции
cartesian
, которую напишем на следующем шаге. Данная функция принимает параметр f
, являющийся функцией вывода на экран. Другие ее параметры — это x
и набор параметров rest
. Они содержат реальные элементы, для которых мы будем искать декартово произведение. Взглянем на выражение f(x,rest)
: для x=1
и rest=2,3,4
мы получим вызовы f(1,2); f(1,3); f(1,4);
. Проверка (x < rest)
нужна для избавления от избыточности в сгенерированных парах. Мы рассмотрим этот вопрос более подробно позднее.
constexpr auto call_cart (
[=](auto f, auto x, auto ...rest) constexpr {
(void)std::initializer_list{
(((x < rest)
? (void)f(x, rest)
: (void)0)
,0)...
};
});
4. Функция
cartesian
— самая сложная часть кода всего примера. Она принимает набор параметров xs
и возвращает захватывающий его объект функции. Полученный объект функции принимает объект функции f
.
Для набора параметров
xs=1,2,3
внутреннее лямбда-выражение сгенерирует следующие вызовы: call_cart(f,1,1,2,3); call_cart(f,2,1,2,3); call_cart(f,3,1,2,3);
. Из этого набора вызовов можно сгенерировать все необходимые пары произведения.
Обратите внимание: мы дважды используем нотацию
...
для распаковки набора параметров xs
, что на первый взгляд может показаться странным. Первое включение конструкции ...
распаковывает весь набор параметров xs
в вызов call_cart
. Второе включение приводит к нескольким вызовам функции call_cart
, имеющим разные вторые параметры.
constexpr auto cartesian ([=](auto ...xs) constexpr {
return [=] (auto f) constexpr {
(void)std::initializer_list {
((void)call_cart(f, xs, xs...), 0)...
};
};
});
5. Теперь сгенерируем декартово произведение для численного множества
1
, 2
, 3
и выведем полученные пары на экран. Если не учитывать избыточные пары, то мы должны получить следующий результат: (1,2)
, (2,3)
и (1,3)
. Другие комбинации невозможны при условии, что не важен порядок и не нужны одинаковые числа в паре. Т.е. не нужны пары вроде (1,1)
, а пары (1,2)
и (2,1)
считаются одинаковыми.
Сначала сгенерируем объект функции, который содержит все возможные пары и принимает функцию
print
. Далее используем его, чтобы позволить вызывать данную функцию для всех этих пар. Объявляем переменную print_cart
с модификатором constexpr;
это позволит гарантировать, что хранимый ею объект функции (и все сгенерированные пары) будет создаваться во время компиляции:
constexpr auto print_cart(cartesian(1,2,3));
print_cart(print);
}
6. Компиляция и запуск программы дадут следующий ожидаемый результат. Можно убрать условие
(x < xs)
из функции call_cart
, чтобы увидеть полное декартово произведение, содержащее избыточные пары и пары с одинаковыми номерами:
$ ./cartesian_product
(1, 2)
(1, 3)
(2, 3)
Как это работает
Мы создали еще одну очень сложную конструкцию с помощью лямбда-выражений. Но после того, как разберемся с ней, нас больше не запутают никакие другие лямбда-выражения!
Взглянем на нее более внимательно. Нам следует получить представление о том, что должно произойти (рис. 4.4).
Работа проходит в три шага.
1. Берем наше множество
1
, 2
, 3
и создаем на его основе три новых. Первая часть каждого из этих множеств — один элемент множества, а вторая — все множества.
2. Объединяем первый элемент с каждым элементом множества и получаем все пары.
3. Из полученных пар выбираем те, которые не являются избыточными (например, пары
(1,2)
и (2,1)
избыточны) и не содержат одинаковых чисел (как, скажем, (1,1)
).
Теперь вернемся к реализации:
constexpr auto cartesian ([=](auto ...xs) constexpr {
return [=](auto f) constexpr {
(void)std::initializer_list {
((void)call_cart(f, xs, xs...), 0)...
};
};
});
Внутреннее выражение,
call_cart(xs, xs...)
, явно представляет собой разделение множества (1,2,3)
на эти новые множества наподобие 1
, [1,2,3]
. Полное выражение, ((void)call_cart(f,xs, xs...),0)...
, имеющее снаружи дополнительную конструкцию ...
, выполняет такое разделение для каждого значения множества, так что мы также получаем множества 2
, [1,2,3]
и 3
, [1,2,3]
.
Шаги 2 и 3 выполняются с помощью
call_cart
:
auto call_cart ([](auto f, auto x, auto ...rest) constexpr {
(void)std::initializer_list{
(((x < rest)
? (void)f(x, rest)
: (void)0)
,0)...
}
});
Параметр
x
всегда содержит одно значение, взятое из множества, а rest
включает все множество. Опустим условие (x < rest)
. Здесь выражение f(x, rest)
и распакованный набор параметров ...
генерируют вызовы функции f(1, 1)
, f(1, 2)
и т.д., что приводит к появлению пар на экране. Это был шаг 2.
Шаг 3 достигается за счет фильтрации всех пар, к которым применяется условие
(x < rest)
Мы указали, что все лямбда-выражения и переменные, их содержащие, имеют модификатор
constexpr
. Это гарантирует, что компилятор оценит их код во время компиляции и скомпилирует бинарный файл, который уже содержит все числовые пары, вместо того, чтобы делать это во время работы программы. Обратите внимание: так происходит только в том случае, если все аргументы, которые мы предоставляем функции с модификатором constexpr
, известны на этапе компиляции.