Хабрахабр

[Перевод] Функциональное программирование с PHP-генераторами

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

Яркий пример разницы между ранним и ленивым генерированием — функция range, которая берёт параметры start и end, а затем возвращает последовательность целочисленных значений, которая начинается со start и заканчивается за один элемент до end. В случае с обычной функцией вам придётся создать новый список, добавить в него все элементы, а затем уже вернуть список. При таком подходе range потребляет объём памяти, пропорциональный размеру диапазона. И в зависимости от вашей среды range(1, 10000000) может прибрать себе всю память, выделенную PHP-процессом. И происходит это из-за раннего создания всего списка элементов, ещё до возврата вызывающему.

С помощью функции-генератора можно создать ленивую range, использующую постоянный объём памяти. Это достигается с помощью while-цикла, который передаёт значение начального параметра и затем инкрементирует значение начального аргумента. Когда цикл заканчивается, функция доходит до конца его тела и возвращает значение, тем самым завершая работу генератора. То есть функции-генератору достаточно лишь отслеживать, какое значение последовательности нужно возвращать, а не хранить все значения в памяти. Если продолжить пример, то можно создать функцию-генератор бесконечного диапазона, которая берёт лишь начальный аргумент. В этом случае у нас будет while-цикл с предикатом, всегда равным true, так что цикл никогда не заканчивается. Это позволяет вызывающей функции самой решать, сколько значений считывать из генератора. И если бесконечный генератор будет вызван 100 раз, то он и сгенерирует только 100 значений. Если его больше не вызовут до завершения вызывающей функции, генератор встанет на паузу, и в конце концов сборщик мусора его вычистит. Иными словами, если вы будете с помощью foreach итерировать генератор, создающий бесконечный поток значений, то генератор станет итерироваться бесконечно. В документации PHP неплохо описана работа генераторов и пример с функцией range.

А вот более практичная иллюстрация: программе нужно из внешнего API получить 200 000 объектов, из каждого документа извлечь какое-то подмножество данных, а затем каждый модифицированный объект поместить в хранилище. Если всё делать сразу, то придётся сначала создать список 200 000 объектов, затем удалить из списка элементы и затем модифицировать каждый элемент перед внесением в хранилище. Если для этих этапов использовать инструменты вроде array_filter и array_map, то при каждой из перечисленных операций создаётся промежуточный список. Комбинируя генераторы, можно создать конвейер, каждый этап которого вычисляется ленивым образом. Например, можно применять генератор, который лениво и по отдельности забирает объекты из API. Такой генератор может быть использован другим генератором, который лишь создаёт объекты, проходящие определённую проверку, и уже этот второй генератор будет использоваться третьим генератором, который создаёт модифицированную версию объекта, полученного от API.

Генераторы в PHP

Поскольку функции-генераторы возвращают объекты, реализующиеIterator, их можно использовать там же, где и итераторы, как в случае с foreach. К сожалению, генераторы нельзя воткнуть везде, где может применяться массив. Это справедливо, к примеру, для array_map, array_reduce и array_filter. Жаль, ведь я предпочитаю эти функции вместо более императивных циклов for и foreach. Мы в iFixit используем несколько альтернатив на основе Iterator вместо массивов, обеспечивающих ту же функциональность, что и array_map, array_filter с array_reduce.

Примечание: во всех этих примерах нужна функциональность PHP 7.1. В предыдущих версиях языка код работать не будет.

Map

<?php declare(strict_types = 1); /** * Like array_map() but over an iterable, and it returns a new iterable with * mapping instead of a mapped array. The callable should take two arguments: * a value to map and its key in the stream. */
function iterator_map(callable $cb, iterable $itr): iterable { foreach ($itr as $key => $value) { yield $cb($value, $key); }
}

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

<?php
// assuming range_generator is a range function that returns a
// generator and not a list.
$bigList = range_generator(1, 100000000);
$bigListPlusOne = iterator_map(function($num) { return $num + 1;
}, $bigList);
// At this point no work has been done.
// $bigListPlusOne is a generator that will lazily produce
// all numbers from 2 to 100000000
// (100000000 because the end of the range is exclusive)

Filter

<?php declare(strict_types = 1); /** * Like array_filter() but over an iterable, and it returns a new iterable with * filtering instead of a filtered array. The callable, if non-null, should * take arguments like iterator_map(). When the callable is null, null values * will be filtered out (NOT falsey values, just x === null). */
function iterator_filter(?callable $cb, iterable $itr): iterable { $cb = $cb ?: function($x) { return $x !== null; }; foreach ($itr as $key => $value) { $keep = $cb($value, $key); if ($keep) { yield $value; } }
}

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

Reduce

<?php declare(strict_types = 1); /** * Like array_reduce() but over an iterable, and it returns a single value as * the result of calling $cb over the contents of iterable $itr. * * If $initial is not null, $initial is set to the value of the first element * of the iterable, and $cb is called with the first element as the carry value * and the second element of the array as the current value. */
function iterator_reduce(callable $cb, iterable $itr, $initial = null) { if (is_null($initial)) { $initial = $itr->current(); $itr->next(); } $carry = $initial; while ($itr->valid()) { $carry = $cb($carry, $itr->current()); $itr->next(); } return $carry;
}

Reduce берёт список и опциональное начальное значение, а выдаёт значение, являющееся результатом применения свёрточной (reducer) функции к каждому элементу. У свёрточной функции есть параметр carry, содержащий результат вызова этой функции применительно к предыдущему элементу, а также есть параметр current, представляющий собой текущий элемент в итерируемой последовательности. В некоторых языках эта операция называется свёртыванием (fold).

iterator_reduce немного отличается от предыдущих двух функций, поскольку у неё нет объявления возвращаемого типа iterable. Функция может создавать одиночные значения — числа, булевы или строчные. Всё это бывает полезно, когда нужно получить список или генератор и извлечь из него сгруппированные или агрегированные значения, такие как сумма свойств price в списке объектов Product.

Собираем всё вместе

Теперь давайте всё рассмотренное соберём вместе в одном маленьком примере. Будем извлекать данные из онлайнового сервиса хранения (назовём его Storeify). Задача программы: извлечь все заказы предыдущего дня и вычислить суммарный дневной доход от продажи.

В нашем гипотетическом мире в день может быть от 100 до 1 000 000 000 заказов, так что мы не можем просто получить их все у API без того, чтобы нам выставили огромный счёт за сервер, способный одновременно хранить заказы в памяти. Давайте создадим генератор для ленивого извлечения заказов из Storeify API по мере необходимости.

С помощью map, filter и reduce разделим проблему на задачи, чтобы легче было разобраться в программе и сопровождать её. Поскольку нам нужны только позиции или продукты в каждом заказе, используем iterator_map для возвращения позиций, а также функцию flatten для превращения списка списков позиций в единый список. После этого выберем iterator_filter для отфильтровывания тех позиций, что не являются продуктами, которые мы анализируем. Далее возьмём поток отфильтрованных продуктов и с помощью iterator_reduce свернём их поля price и quantity в общий доход по этому продукту за предыдущий день.

<?php declare(strict_types = 1); /** * Example code for consuming data from a Store API (called Storeify) and chaining generators * together to filter, map, and eventually reduce all Order data into a * daily revenue total. */ function getOrdersFromLastDay(): iterable { $limit = 20; $requestParams = [ 'lastUpdatedAfter' => new DateTime('yesterday'), 'limit' => $limit, 'offset' => 0 ]; $orders = []; do { // Grab a batch of orders from Storeify and yield from that list. $orders = Storeify::getOrders($requestParams); yield from $orders; $requestParams['offset'] += $limit; } while (!empty($orders));
} /** * Consumes a generator that produces lists of products * and produces a new generator that yields a flat list * of products. */
function flatten(iterable $itr): iterable { foreach ($itr as $products) { yield from $products; }
} /** * Assume orders have a Shipping Country and a list of Products * [ * 'ShipCountry' => 'US', * 'Products' => [ * [ 'name' => 'Pro Tech Toolkit', 'price' => 59.95, 'quantity' => 2], * [ 'name' => 'iPhone 6 Battery', 'price' => 24.99, 'quantity' => 1] * ] * ] */
$orders = getOrdersFromLastDay(); // Flatten list of orders into list of all products sold.
$allProducts = flatten(iterator_map(function($order) { return $order['Products'];
}, $orders)); // Only include 'Pro Tech Toolkit' purchases.
$toolkitProducts = iterator_filter(function($product) { return $product['name'] === 'Pro Tech Toolkit';
}, $allProducts); // Up until this point, no work has actually been done.
// $toolkitProducts is a generator can be passed around to other functions
// as a lazy stream of pro tech toolkit products. // Once iterator_reduce is called, it begins winding its way through
// the composed generators and actually pulling down resources from the Store API
// and mapping and filtering them.
$dailyToolkitRevenue = iterator_reduce(function($total, $toolkit) { return $total + ($toolkit['price'] * $toolkit['quantity']);
}, $toolkitProducts, 0);

Заключение

Пожалуй, применение генераторов в функциональном программировании можно назвать допингом. К сожалению, PHP — не идеальный язык, но, к счастью, у нас есть все инструменты для объединения генераторов с функциональными концепциями вроде map, filter и reduce.

Обновление (2018–03–18)

Комментатор из ветки r/programming упомянул библиотеку iter, написанную человеком, который реализовал генераторы в PHP. Эта библиотека реализует все примеры из этой статьи и многое другое, так что очень рекомендую пощупать её, если планируете использовать генераторы в своей кодовой базе.

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть