Pull to refresh

LINQ для PHP. Часть 2. Если гора не идёт к Магомету, Магомет идёт к горе

Reading time 9 min
Views 5.3K
Как можно было заметить из моей предыдущей статьи со сравнением библиотек LINQ для PHP, библиотек много, а качества мало: ленивые вычисления не реализованы ни в одной библиотеке, тесты есть в половине случаев, типы коллбэков ограничены, а иногда и вовсе незнамо что выдаётся за LINQ. Поэтому я написал свою библиотеку. Встречайте:

YaLinqo — Yet Another LINQ to Objects for PHP

Возможности:

  • Самый полный порт .NET LINQ на PHP, со многими дополнительными методами. Некоторые методы отсутствуют, но работа ведётся. Всего реализовано более 70 методов.
  • Ленивые вычисления, текст исключений и многое другое, как в оригинальном LINQ.
  • Детальная документация PHPDoc к каждому методу. Текст статей адаптирован из MSDN.
  • 100% покрытие юнит-тестами.
  • Коллбэки можно задавать замыканиями, «указателями на функцию» в виде строк и массивов, строковыми «лямбдами» с поддержкой нескольких синтаксисов.
  • Ключам уделяется столько же внимания, сколько значениям: преобразования можно применять и к тем, и к другим; большинство коллбэков принимает на вход и то, и другое; ключи по возможности не теряются при преобразованиях.
  • Минимальное изобретение велосипедов: для итерации используются Iterator, IteratorAggregate и др. (и их можно использовать наравне с Enumerable); исключения по возможности используются родные похапэшные и т.п.

Пример кода:

// Отфильтровать продукты с ненулевым количеством, поместить в соответствующие категории,
// отсортированные по имени. Продукты отсортировать сначала по убыванию количества, потом по имени.

from($categories)
    ->orderBy('$v["name"]')
    ->groupJoin(
        from($products)
            ->where('$v["quantity"] > 0')
            ->orderByDescending('$v["quantity"]')
            ->thenBy('$v["name"]'),
        '$v["id"]', '$v["catId"]', 'array("name" => $v["name"], "products" => $e)'
    );

Реализованные методы

  • Генерация: cycle, emptyEnum (empty), from, generate, toInfinity, toNegativeInfinity, matches, returnEnum (return), range, rangeDown, rangeTo, repeat, split;
  • Проекция, фильтрация: ofType, select, selectMany, where;
  • Группировка, соединение: groupJoin, join, groupBy;
  • Агрегация: aggregate, aggregateOrDefault, average, count, max, maxBy, min, minBy, sum;
  • Множества: all, any, contains;
  • Паджинация: elementAt, elementAtOrDefault, first, firstOrDefault, firstOrFallback, last, lastOrDefault, lastOrFallback, single, singleOrDefault, singleOrFallback, indexOf, lastIndexOf, findIndex, findLastIndex, skip, skipWhile, take, takeWhile;
  • Конвертация: toArray, toArrayDeep, toList, toListDeep, toDictionary, toJSON, toLookup, toKeys, toValues, toObject, toString;
  • Действия: call (do), each (forEach), write, writeLine.

Пример

Теперь рассмотрим приведённый выше пример детальнее. На самом деле существует несколько вариантов записи этого запроса: с помощью замыканий и с помощью строковых лямбд. У лямбд тоже два синтаксиса: можно использовать имена переменных по умолчанию (v и k для значения и ключа, соответственно), можно задать осмысленные.

Исходные данные (либо из базы, либо из какого-нибудь сервиса JSON пришёл, либо «железные» константы, либо ещё какой источник):

$products = array(
    array('name' => 'Keyboard',    'catId' => 'hw', 'quantity' =>  10, 'id' => 1),
    array('name' => 'Mouse',       'catId' => 'hw', 'quantity' =>  20, 'id' => 2),
    array('name' => 'Monitor',     'catId' => 'hw', 'quantity' =>   0, 'id' => 3),
    array('name' => 'Joystick',    'catId' => 'hw', 'quantity' =>  15, 'id' => 4),
    array('name' => 'CPU',         'catId' => 'hw', 'quantity' =>  15, 'id' => 5),
    array('name' => 'Motherboard', 'catId' => 'hw', 'quantity' =>  11, 'id' => 6),
    array('name' => 'Windows',     'catId' => 'os', 'quantity' => 666, 'id' => 7),
    array('name' => 'Linux',       'catId' => 'os', 'quantity' => 666, 'id' => 8),
    array('name' => 'Mac',         'catId' => 'os', 'quantity' => 666, 'id' => 9),
);
$categories = array(
    array('name' => 'Hardware',          'id' => 'hw'),
    array('name' => 'Operating systems', 'id' => 'os'),
);

Собственно, задача: отфильтровать продукты с ненулевым количеством, поместить в соответствующие категории. Продукты отсортировать сначала по убыванию количества, потом по имени. Категории отсортировать по имени. Должно получиться следущее (для краткости переформатировал):

Array (
    [hw] => Array (
        [name] => Hardware
        [products] => Array (
            [0] => Array ( [name] => Mouse       [catId] => hw [quantity] =>  20 [id] => 2 )
            [1] => Array ( [name] => CPU         [catId] => hw [quantity] =>  15 [id] => 5 )
            [2] => Array ( [name] => Joystick    [catId] => hw [quantity] =>  15 [id] => 4 )
            [3] => Array ( [name] => Motherboard [catId] => hw [quantity] =>  11 [id] => 6 )
            [4] => Array ( [name] => Keyboard    [catId] => hw [quantity] =>  10 [id] => 1 )
        )
    )
    [os] => Array (
        [name] => Operating systems
        [products] => Array (
            [0] => Array ( [name] => Linux       [catId] => os [quantity] => 666 [id] => 8 )
            [1] => Array ( [name] => Mac         [catId] => os [quantity] => 666 [id] => 9 )
            [2] => Array ( [name] => Windows     [catId] => os [quantity] => 666 [id] => 7 )
        )
    )
)

Ниже приведён пример с использованием замыканий из PHP 5.3. Самая длинная запись, однако наилучшая поддержка в разнообразных IDE.

from($categories)
    ->orderBy(function ($cat) { return $cat['name']; })
    ->groupJoin(
        from($products)
            ->where(function ($prod) { return $prod["quantity"] > 0; })
            ->orderByDescending(function ($prod) { return $prod["quantity"]; })
            ->thenBy(function ($prod) { return $prod["name"]; }),
        function ($cat) { return $cat["id"]; },
        function ($prod) { return $prod["catId"]; },
        function ($cat, $prods) { return array("name" => $cat["name"], "products" => $prods); }
    );

Запись с помощью строковых лямбд. Слева от оператора "==>" имена аргументов, справа — возвращаемое значение.

from($categories)
    ->orderBy('$cat ==> $cat["name"]')
    ->groupJoin(
        from($products)
            ->where('$prod ==> $prod["quantity"] > 0')
            ->orderByDescending('$prod ==> $prod["quantity"]')
            ->thenBy('$prod ==> $prod["name"]'),
        '$cat ==> $cat["id"]',
        '$prod ==> $prod["catId"]',
        '($cat, $prods) ==> array("name" => $cat["name"], "products" => $prods)'
    );

И наконец самая краткая запись. Если нет оператора "==>", то используются имена переменных по умолчанию: v для значения, k для ключа, a и b для сравниваемых значений и т.п.

from($categories)
    ->orderBy('$v["name"]')
    ->groupJoin(
        from($products)
            ->where('$v["quantity"] > 0')
            ->orderByDescending('$v["quantity"]')
            ->thenBy('$v["name"]'),
        '$v["id"]', '$v["catId"]', 'array("name" => $v["name"], "products" => $e)'
    );

(Сомнительные) архитектурные решения

Просто так один-в-один оригинальный LINQ не скопируешь: разные языки, разные возможности, разные особенности. Поэтому часто приходилось делать выбор. Насколько хороший или плохой — судить вам. Обсуждение приветствуется.

Ключи

Начнём с самого сомнительного: ключи объявляются важной частью данных. Причина: они явно присутствуют в родных похапэшных итераторах, они важны в массивах, они важны при преобразовании в JSON. В общем и целом, в PHP повсеместно используются ключи, поэтому пренебрегать ими, как в некоторых других библиотеках, мне не хотелось бы.

Однако в оригинальном LINQ никаких ключей у последовательностей нет, поэтому приходится увеличивать количество аргументов как у коллбэков (теперь они все могут работать с ключом, если это возможно), так и у самих методов LINQ: resultSelector превращается в resultSelectorValue + resultSelectorKey. Однако в большинстве случаев разработчику об этом думать не нужно: коллбэки можно передавать с меньшим количеством аргументов, а у всех методов LINQ для аргументов типа resultSelectorKey заданы значения по умолчанию.

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

Если вам информация о ключах не нужна, то есть два простых способа от них избавиться:
  • Заключительной операцией вместо toArray/toArrayDeep вызвать toList/toListDeep.
  • Вызвать метод toValues — эквивалент array_values, но ленивый, как select.

Порядок аргументов

На втором месте по сомнительности решение о порядке аргументов в функциях и коллбэках. Они всегда идут в порядке от (теоретически) наиболее используемого к наименее используемому. В коллбэках поэтому на первом месте обычно идёт значение, а потом ключ, потому что значение важно почти всегда, а ключ — нет. Однако порядок аргументов, возможно, теперь сложнее запомнить. Например, в select сначала идёт выборка значения, а в toDictionary — выборка ключа.

Впрочем, нам, похапэшникам, к такому безобразию не привыкать — весь язык испещрён совершенно случайным порядком аргументов (тех же needle и haystack).

Индексы элементов

Неочевидное решение для тех, кто пользовался оригинальным LINQ: методы типа indexOf, elementAt работают с ключами, а не числовым положением элемента в перечилении. Если вам нужно положение, то предварительно вызовите toValues — ключи станут последовательными: 0, 1, 2, 3 и т.д. Также для методов типа select нет перегрузок с коллбэками, принимающими положение элемента. Аналогично, используйте toValues.

Аргументы лямбд

В библиотеке linq.js, которой я вдохновлялся при написании, у всех коллбэков аргументы называются $, $$, $$$, $$$$. В PHP такого не бывает. Можно сделать преобразование строк, но хотелось бы оставить код валидным, пусть даже он внутри строчки. Называть аргументы бессодержательно $a, $b, $c тоже не хочется. Поэтому принято решение использовать имена, соответствующие содержимому:

  • Обычно v для значения, k для ключа
  • Если значений несколько, то v1 и v2
  • Если значение — последовательность, то e
  • Для аккумулятора при агрегации — a
  • Для методов сравнения — a и b
  • (Может, что-то ещё забыл)

Недостаток: имена нужно знать. Впрочем, при детальной документации это не должно быть проблемой.

«Сомнительные» коллекции

Класса List нет, метод toList возвращает то же самое, что toArray, только с последовательными ключами (0, 1, 2 и т.д.)

Класс Dictionary есть. Изначально задумывался исключительно как база для Lookup, но сейчас стал отдельной полноценной коллекцией. Отличие от обычных массивов — ключами могут быть объекты (возможно в оригинальном LINQ). Но фактически в самом LINQ объекты-ключи поддерживаются далеко не везде, потому что PHP не позволяет использовать объекты-ключи в foreach. Можно все циклы переписать, но насколько игра стоит свеч — вопрос.

Класс Lookup есть. По ключу возвращает список значений (или пустой массив, если ключа нет).

Обе коллекции поддерживают метод toArray, который возвращает внутренний массив.

Документация из MSDN

Ко всем методам скопирована справка из MSDN, затем адаптирована под реалии порта. Где-то описания спёрты из других проектов. Где-то — написаны самостоятельно. Если найдёте ошибки — сообщайте.

В целом, справка получилась весьма солидная. У некоторых методов нехилые такие статьи.

Имена методов

Некоторые слова в PHP нагло захапаны самим языком, причём во всех регистрах. Даже empty нельзя использовать как имя метода. Поэтому, где есть конфликты, методы переименованы (в списке методов в начале статьи оригинальные имена методов даны в скобках). В частности, run/forEach стали call/each.

Имена исключений

В PHP нет встроенных исключений, которые есть в .NET. Однако я постарался избежать создания ненужных классов. Так, вместо InvalidOperationException используется UnexpectedValueException. В конце концов, недопустимой операция становится при неожиданных значениях.

Стабильная сортировка

Сортировка нестабильная. То есть при сортировке массива [[0,1], [1,0], [0,2]] по первому элементу вложенных массивов никто не гарантирует, что [0,1] и [0,2] будут идти друг за другом именно в таком порядке. Результатом может оказаться как [[0,1], [0,2], [1,0]], так и [[0,2], [0,1], [1,0]].

Почему? Потому что в PHP нет функций для стабильной сортировки, а внутри библиотеки используется usort. Теоретически можно сделать сортировку стабильной, как в оригинальном LINQ, но нужно ли это? За стабильность всем придётся платить временем выполнения и расходуемой памятью. Я решил, что, раз уж следуем «пути PHP», то и нестабильность должна быть такой же, как в самом PHP.

Прочее

Покрытие юнит-тестами — практически 100%.

Лицензия — упрощённая BSD (двухпунктовая).

Требования — PHP 5.3.

Использование:

require_once __DIR__ . '/lib/Linq.php'; // заменить на свой путь
use \YaLinqo\Enumerable; // по вкусу укоротить имя
use \YaLinqo\Enumerable as E; // или так

// Можно вызывать или глобальную функцию from, или статический метод в Enumerable — разницы нет
Enumerable::from(array(1, 2, 3));
from(array(1, 2, 3));

Сравнение с другими библиотеками

Для наглядности добавил в таблицу ещё и библиотеки для JavaScript. Их сравнение будет в отдельной статье.



Легенда как в Википедии, но с дополнительным значением:

  • красный — ни в какие ворота
  • жёлтый — третий сорт не брак
  • зелёный — самое то
  • голубой — офигительно

Прошу прощения за английский в таблице. По-русски слишком длинно получалось.

Спасибо мне

Работал на халяву, денег никто не даст. Если чувствуете приступ щедрости, можете просто проголосовать за эти фичи в PHP и PHPStorm. Авось заметят и использовать библиотеку станет приятнее.

PHP

  1. Iterator::key() разрешено возвращать только числа и строки.
    1. 45684 A request for foreach to be key-type agnostic
  2. Была фича с укорачиванием синтаксиса замыканий, причём прилагались патчи, анализ и прочее — оформивший фичу разработчик постарался на славу. Но фичу закрыли с результатом «нафиг надо». :-(

PHPStorm IDE

  1. Код PHP внутри строк
    • WI-3477 Inject PHP language inside assert('literal'), eval and similar
    • WI-2377 No autocompletion for php variables inside string with injected language
  2. Анализ PHP кода
    • WI-11110 Undefined method: Undefined method wrongly reported when using closures
  3. Комментарии PHPDoc
    • WI-8270 Error in PhpDoc quick documentation if {link} used twice in a line

Ссылка

Скачать Yet Another LINQ to Objects for PHP с GitHub

P.S. Подскажите, пожалуйста, куда можно запостить аналогичную статью на английском.
Tags:
Hubs:
+41
Comments 24
Comments Comments 24

Articles