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

в 7:32, , рубрики: generators, php, Блог компании Mail.Ru Group, никто не читает теги, Программирование, функциональное программирование

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

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

describe(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

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

Автор: AloneCoder

Источник

Поделиться

* - обязательные к заполнению поля