Генераторы классные. Они облегчают написание итераторов, определяя функции вместо создания целых классов, реализующих 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. Эта библиотека реализует все примеры из этой статьи и многое другое, так что очень рекомендую пощупать её, если планируете использовать генераторы в своей кодовой базе.