Если вы не знаете, что такое LINQ, и зачем он сдался на PHP, смотрите предыдущую статью по YaLinqo.

С остальными продолжаем. Сразу предупреждаю: если вы считаете, что итераторы — это ненужная штука, которую зачем-то притащили в PHP, что производительность из-за всех этих новомодных штучек с анонимными функциями зверски проседает, что нужно вымерять каждую микросекунду, что ничего лучше старого-доброго for не придумано — то проходите мимо. Библиотека и статья не для вас.

С остальными продолжаем. LINQ — это замечательно, но насколько проседает производительность от его использования? Если сравнивать с голыми циклами, то скорость меньше раз в 3-5. Если сравнивать с функциями для массивов, которым передаются анонимные функции, то раза в 2-4. Так как предполагается, что с помощью библиотеки обрабатываются небольшие массивы данных, а сложная обработка данных находится за пределами скрипта (в базе данных, в стороннем веб-сервисе), то на деле в масштабах всего скрипта потери небольшие. Главное — читаемость.

Так как со времени создания моей библиотеки YaLinqo на свет появилось ещё два конкурента, которые действительно являются LINQ (то есть поддерживают ленивые вычисления и прочие базовые возможности), то возникают позывы библиотеки сравнить. Самое простое и логичное — сравнить функциональность и производительность. По крайней мере это не будет избиением младенцев, как в прошлом сравнении.

(А также появление конкурентов наконец-то мотивировало меня выложить документацию YaLinqo онлайн.)

Дисклеймер: это тесты «на коленке». Они не дают оценить все потери в производительности. В частности, я совершенно не рассматриваю потребление памяти. Отчасти потому что я не знаю, как это нормально сделать. Если что, pull requests are welcome, что называется.
Конкуренты
YaLinqoYet Another LINQ to Objects for PHP. Поддерживает запросы только к объектам: массивам и итераторам. Имеет две версии: для PHP 5.3+ (без yield) и для PHP 5.5+ (с yield). Последняя версия полагается исключительно на yield и массивы для всех операций. В дополнение к анонимным функциям поддерживает «строковые лямбды». Самая минималистичная из представленных библиотек: содержит всего лишь 4 класса. Из особенностей — весьма массивная документация, адаптированная из MSDN.

Ginq'LINQ to Object' inspired DSL for PHP . Аналогично, поддерживает запросы только к объектам. Основана на итераторах SPL, поэтому в требованиях PHP 5.3+. В дополнение к анонимным функциям поддерживает «property access» из Symfony. Средняя по масштабности библиотека: портированы коллекции, компареры, пары ключ-значение и прочее добро из .NET; итого 70 классов. Документация средней паршивости: в лучшем случае указаны сигнатуры. Главная особенность — итераторы, что позволяет использовать библиотеку и построением запросов в виде цепочки методов, и с помощью вложенных итераторов.

PinqPHP Integrated Query, a real LINQ library for PHP. Единственная библиотека, которая позволяет работать и с объектами, и с базами данных (ну… теоретически позволяет). Поддерживает только анонимные функции, но умеет парсить код с помощью PHP-Parser. Документация не самая детальная (если вообще есть), но зато имеет симпатичный сайтик. Самая массивная библиотека из представленных: больше 500 классов, не считая 150 классов тестов (если честно, в код я даже не лез, потому что страшно).

У всех представленных библиотек с тестами и прочими признаками качества всё в порядке. Лицензии пермиссивные: BSD, MIT. Все поддерживают Composer и представлены на Packagist.

Тесты

Здесь и далее в функцию benchmark_linq_groups передаётся массив функций: для голого PHP, YaLinqo, Ginq и Pinq, соответственно.

Тесты гоняются на PHP 5.5.14, Windows 7 SP1. Так как тесты «на коленке», то не привожу спеки железа — задача оценить потери на глаз, а не измерить всё до миллиметра. Если хотите точных тестов, то исходный код доступен на гитхабе, можете улучшать, пулл-реквесты принимаются.

Начнём с плохого — чистого оверхеда.

benchmark_linq_groups("Iterating over $ITER_MAX ints", 100, null,
    [
        "for" => function () use ($ITER_MAX) {
            $j = null;
            for ($i = 0; $i < $ITER_MAX; $i++)
                $j = $i;
            return $j;
        },
        "array functions" => function () use ($ITER_MAX) {
            $j = null;
            foreach (range(0, $ITER_MAX - 1) as $i)
                $j = $i;
            return $j;
        },
    ],
    [
        function () use ($ITER_MAX) {
            $j = null;
            foreach (E::range(0, $ITER_MAX) as $i)
                $j = $i;
            return $j;
        },
    ],
    [
        function () use ($ITER_MAX) {
            $j = null;
            foreach (G::range(0, $ITER_MAX - 1) as $i)
                $j = $i;
            return $j;
        },
    ],
    [
        function () use ($ITER_MAX) {
            $j = null;
            foreach (P::from(range(0, $ITER_MAX - 1)) as $i)
                $j = $i;
            return $j;
        },
    ]);

Генерирующая функция range в Pinq отсутствует, документация говорит пользоваться стандартной функцией. Что, собственно, мы и делаем.

И результаты:

Iterating over 1000 ints
------------------------
  PHP     [for]               0.00006 sec   x1.0 (100%)
  PHP     [array functions]   0.00011 sec   x1.8 (+83%)
  YaLinqo                     0.00041 sec   x6.8 (+583%)
  Ginq                        0.00075 sec   x12.5 (+1150%)
  Pinq                        0.00169 sec   x28.2 (+2717%)

Итераторы нещадно съедают скорость.

Но гораздо сильнее бросается в глаза страшное проседание по скорости у последней библиотеки — в 30 раз. Должен предупредить: эта библиотека ещё успеет попугать числами, поэтому удивляться рано.

Теперь вместо простой итерации сгенерируем массив последовательных чисел.

benchmark_linq_groups("Generating array of $ITER_MAX integers", 100, 'consume',
    [
        "for" =>
            function () use ($ITER_MAX) {
                $a = [ ];
                for ($i = 0; $i < $ITER_MAX; $i++)
                    $a[] = $i;
                return $a;
            },
        "array functions" =>
            function () use ($ITER_MAX) {
                return range(0, $ITER_MAX - 1);
            },
    ],
    [
        function () use ($ITER_MAX) {
            return E::range(0, $ITER_MAX)->toArray();
        },
    ],
    [
        function () use ($ITER_MAX) {
            return G::range(0, $ITER_MAX - 1)->toArray();
        },
    ],
    [
        function () use ($ITER_MAX) {
            return P::from(range(0, $ITER_MAX - 1))->asArray();
        },
    ]);

И результаты:

Generating array of 1000 integers
---------------------------------
  PHP     [for]               0.00025 sec   x1.3 (+32%)
  PHP     [array functions]   0.00019 sec   x1.0 (100%)
  YaLinqo                     0.00060 sec   x3.2 (+216%)
  Ginq                        0.00107 sec   x5.6 (+463%)
  Pinq                        0.00183 sec   x9.6 (+863%)

Теперь YaLinqo проигрывает только в два раза относительно решения в лоб на цикле. У остальных библиотек результаты похуже, но жить можно.

Теперь займёмся подсчётом в тестовых данных: посчитаем заказы с более, чем пятью пунктами заказа; посчитаем заказы, у которых более двух пунктов с количеством более пяти.

benchmark_linq_groups("Counting values in arrays", 100, null,
    [
        "for" => function () use ($DATA) {
            $numberOrders = 0;
            foreach ($DATA->orders as $order) {
                if (count($order['items']) > 5)
                    $numberOrders++;
            }
            return $numberOrders;
        },
        "array functions" => function () use ($DATA) {
            return count(
                array_filter(
                    $DATA->orders,
                    function ($order) { return count($order['items']) > 5; }
                )
            );
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->orders)
                ->count(function ($order) { return count($order['items']) > 5; });
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->orders)
                ->count('$o ==> count($o["items"]) > 5');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->orders)
                ->count(function ($order) { return count($order['items']) > 5; });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->orders)
                ->where(function ($order) { return count($order['items']) > 5; })
                ->count();
        },
    ]);

benchmark_linq_groups("Counting values in arrays deep", 100, null,
    [
        "for" => function () use ($DATA) {
            $numberOrders = 0;
            foreach ($DATA->orders as $order) {
                $numberItems = 0;
                foreach ($order['items'] as $item) {
                    if ($item['quantity'] > 5)
                        $numberItems++;
                }
                if ($numberItems > 2)
                    $numberOrders++;
            }
            return $numberOrders;
        },
        "array functions" => function () use ($DATA) {
            return count(
                array_filter(
                    $DATA->orders,
                    function ($order) {
                        return count(
                            array_filter(
                                $order['items'],
                                function ($item) { return $item['quantity'] > 5; }
                            )
                        ) > 2;
                    })
            );
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->orders)
                ->count(function ($order) {
                    return E::from($order['items'])
                        ->count(function ($item) { return $item['quantity'] > 5; }) > 2;
                });
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->orders)
                ->count(function ($order) {
                    return G::from($order['items'])
                        ->count(function ($item) { return $item['quantity'] > 5; }) > 2;
                });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->orders)
                ->where(function ($order) {
                    return P::from($order['items'])
                        ->where(function ($item) { return $item['quantity'] > 5; })
                        ->count() > 2;
                })
                ->count();
        },
    ]);

Заметно три нюанса. Во-первых, функциональный стиль на стандартных функциях для массивов превращает код в забавную нечитаемую лесенку. Во-вторых, строковыми лямбдами воспользоваться не удаётся, потому что экранировать код внутри экранированного кода — это вынос мозга. В-третьих, Pinq не предоставляет функции count, принимающей предикат, поэтому приходится строить цепочку методов. Как позже выяснится, это далеко не единственное ограничение Pinq: в ней очень мало методов и они очень сильно ограничены.

Смотрим результаты:

Counting values in arrays
-------------------------
  PHP     [for]               0.00023 sec   x1.0 (100%)
  PHP     [array functions]   0.00052 sec   x2.3 (+126%)
  YaLinqo                     0.00056 sec   x2.4 (+143%)
  YaLinqo [string lambda]     0.00059 sec   x2.6 (+157%)
  Ginq                        0.00129 sec   x5.6 (+461%)
  Pinq                        0.00382 sec   x16.6 (+1561%)

Counting values in arrays deep
------------------------------
  PHP     [for]               0.00064 sec   x1.0 (100%)
  PHP     [array functions]   0.00323 sec   x5.0 (+405%)
  YaLinqo                     0.00798 sec   x12.5 (+1147%)
  Ginq                        0.01416 sec   x22.1 (+2113%)
  Pinq                        0.04928 sec   x77.0 (+7600%)

Результаты более-менее предсказуемы, если не считать пугающего результата Pinq. Я посмотрел код. Там генерируется вся коллекция, а потом на ней вызывается count()… Но удивляться всё ещё рано!

Займёмся фильтрацией. Всё как в прошлый раз, но вместо подсчёта генерируем коллекции.

benchmark_linq_groups("Filtering values in arrays", 100, 'consume',
    [
        "for" => function () use ($DATA) {
            $filteredOrders = [ ];
            foreach ($DATA->orders as $order) {
                if (count($order['items']) > 5)
                    $filteredOrders[] = $order;
            }
            return $filteredOrders;
        },
        "array functions" => function () use ($DATA) {
            return array_filter(
                $DATA->orders,
                function ($order) { return count($order['items']) > 5; }
            );
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->orders)
                ->where(function ($order) { return count($order['items']) > 5; });
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->orders)
                ->where('$order ==> count($order["items"]) > 5');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->orders)
                ->where(function ($order) { return count($order['items']) > 5; });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->orders)
                ->where(function ($order) { return count($order['items']) > 5; });
        },
    ]);

benchmark_linq_groups("Filtering values in arrays deep", 100,
    function ($e) { consume($e, [ 'items' => null ]); },
    [
        "for" => function () use ($DATA) {
            $filteredOrders = [ ];
            foreach ($DATA->orders as $order) {
                $filteredItems = [ ];
                foreach ($order['items'] as $item) {
                    if ($item['quantity'] > 5)
                        $filteredItems[] = $item;
                }
                if (count($filteredItems) > 0) {
                    $order['items'] = $filteredItems;
                    $filteredOrders[] = [
                        'id' => $order['id'],
                        'items' => $filteredItems,
                    ];
                }
            }
            return $filteredOrders;
        },
        "array functions" => function () use ($DATA) {
            return array_filter(
                array_map(
                    function ($order) {
                        return [
                            'id' => $order['id'],
                            'items' => array_filter(
                                $order['items'],
                                function ($item) { return $item['quantity'] > 5; }
                            )
                        ];
                    },
                    $DATA->orders
                ),
                function ($order) {
                    return count($order['items']) > 0;
                }
            );
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->orders)
                ->select(function ($order) {
                    return [
                        'id' => $order['id'],
                        'items' => E::from($order['items'])
                            ->where(function ($item) { return $item['quantity'] > 5; })
                            ->toArray()
                    ];
                })
                ->where(function ($order) {
                    return count($order['items']) > 0;
                });
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->orders)
                ->select(function ($order) {
                    return [
                        'id' => $order['id'],
                        'items' => E::from($order['items'])->where('$v["quantity"] > 5')->toArray()
                    ];
                })
                ->where('count($v["items"]) > 0');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->orders)
                ->select(function ($order) {
                    return [
                        'id' => $order['id'],
                        'items' => G::from($order['items'])
                            ->where(function ($item) { return $item['quantity'] > 5; })
                            ->toArray()
                    ];
                })
                ->where(function ($order) {
                    return count($order['items']) > 0;
                });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->orders)
                ->select(function ($order) {
                    return [
                        'id' => $order['id'],
                        'items' => P::from($order['items'])
                            ->where(function ($item) { return $item['quantity'] > 5; })
                            ->asArray()
                    ];
                })
                ->where(function ($order) {
                    return count($order['items']) > 0;
                });
        },
    ]);

Код на функциях для массивов уже начинает заметно попахивать. Не в последнюю очередь из-за того, что у array_map и array_filter аргументы в разном порядке, в результате сложно понять, что после чего происходит.

Код с использованием запросов намеренно менее оптимальный: объекты генерируются, даже если они потом будут отфильтрованы. Это, в общем-то, традиция LINQ, который предполагает создание по пути «анонимных типов» с промежуточными результатами вычислений.

Результаты, если сравнивать с предыдущими тестами, достаточно ровные:

Filtering values in arrays
--------------------------
  PHP     [for]               0.00049 sec   x1.0 (100%)
  PHP     [array functions]   0.00072 sec   x1.5 (+47%)
  YaLinqo                     0.00094 sec   x1.9 (+92%)
  YaLinqo [string lambda]     0.00094 sec   x1.9 (+92%)
  Ginq                        0.00295 sec   x6.0 (+502%)
  Pinq                        0.00328 sec   x6.7 (+569%)

Filtering values in arrays deep
-------------------------------
  PHP     [for]               0.00514 sec   x1.0 (100%)
  PHP     [array functions]   0.00739 sec   x1.4 (+44%)
  YaLinqo                     0.01556 sec   x3.0 (+203%)
  YaLinqo [string lambda]     0.01750 sec   x3.4 (+240%)
  Ginq                        0.03101 sec   x6.0 (+503%)
  Pinq                        0.05435 sec   x10.6 (+957%)

Перейдём к сортировке:

benchmark_linq_groups("Sorting arrays", 100, 'consume',
    [
        function () use ($DATA) {
            $orderedUsers = $DATA->users;
            usort(
                $orderedUsers,
                function ($a, $b) {
                    $diff = $a['rating'] - $b['rating'];
                    if ($diff !== 0)
                        return -$diff;
                    $diff = strcmp($a['name'], $b['name']);
                    if ($diff !== 0)
                        return $diff;
                    $diff = $a['id'] - $b['id'];
                    return $diff;
                });
            return $orderedUsers;
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->users)
                ->orderByDescending(function ($u) { return $u['rating']; })
                ->thenBy(function ($u) { return $u['name']; })
                ->thenBy(function ($u) { return $u['id']; });
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->users)->orderByDescending('$v["rating"]')->thenBy('$v["name"]')->thenBy('$v["id"]');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->users)
                ->orderByDesc(function ($u) { return $u['rating']; })
                ->thenBy(function ($u) { return $u['name']; })
                ->thenBy(function ($u) { return $u['id']; });
        },
        "property path" => function () use ($DATA) {
            return G::from($DATA->users)->orderByDesc('[rating]')->thenBy('[name]')->thenBy('[id]');
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->users)
                ->orderByDescending(function ($u) { return $u['rating']; })
                ->thenByAscending(function ($u) { return $u['name']; })
                ->thenByAscending(function ($u) { return $u['id']; });
        },
    ]);

Код сравнивающей функции для usort страшненький, но, приноровившись, можно писать такие функции, не задумываясь. Сортировка с помощью LINQ выглядит практически идеально чисто. Также это первый случай, когда можно воспользоваться прелестями «доступа к свойствам» в Ginq — красивее код уже не сделать.

Результаты удивляют:

Sorting arrays
--------------
  PHP                         0.00037 sec   x1.0 (100%)
  YaLinqo                     0.00161 sec   x4.4 (+335%)
  YaLinqo [string lambda]     0.00163 sec   x4.4 (+341%)
  Ginq                        0.00402 sec   x10.9 (+986%)
  Ginq    [property path]     0.01998 sec   x54.0 (+5300%)
  Pinq                        0.00132 sec   x3.6 (+257%)

Во-первых, Pinq вырывается вперёд, хоть и незначительно. Спойлер: это случилось в первый и последний раз.

Во-вторых, доступ к свойствам в Ginq ужасающе просаживает производительность, то есть в реальном коде этой фичей уже не воспользуешься. Синтаксис не стоит потери скорости в 50 раз.

Переходим к весёлому — к джойнам, ака соединению двух коллекций по ключу.

benchmark_linq_groups("Joining arrays", 100, 'consume',
    [
        function () use ($DATA) {
            $usersByIds = [ ];
            foreach ($DATA->users as $user)
                $usersByIds[$user['id']][] = $user;
            $pairs = [ ];
            foreach ($DATA->orders as $order) {
                $id = $order['customerId'];
                if (isset($usersByIds[$id])) {
                    foreach ($usersByIds[$id] as $user) {
                        $pairs[] = [
                            'order' => $order,
                            'user' => $user,
                        ];
                    }
                }
            }
            return $pairs;
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->orders)
                ->join($DATA->users,
                    function ($o) { return $o['customerId']; },
                    function ($u) { return $u['id']; },
                    function ($o, $u) {
                        return [
                            'order' => $o,
                            'user' => $u,
                        ];
                    });
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->orders)
                ->join($DATA->users,
                    '$o ==> $o["customerId"]', '$u ==> $u["id"]',
                    '($o, $u) ==> [
                        "order" => $o,
                        "user" => $u,
                    ]');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->orders)
                ->join($DATA->users,
                    function ($o) { return $o['customerId']; },
                    function ($u) { return $u['id']; },
                    function ($o, $u) {
                        return [
                            'order' => $o,
                            'user' => $u,
                        ];
                    });
        },
        "property path" => function () use ($DATA) {
            return G::from($DATA->orders)
                ->join($DATA->users,
                    '[customerId]', '[id]',
                    function ($o, $u) {
                        return [
                            'order' => $o,
                            'user' => $u,
                        ];
                    });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->orders)
                ->join($DATA->users)
                ->onEquality(
                    function ($o) { return $o['customerId']; },
                    function ($u) { return $u['id']; }
                )
                ->to(function ($o, $u) {
                    return [
                        'order' => $o,
                        'user' => $u,
                    ];
                });
        },
    ]);

Синтаксически выделилась Pinq, где одна по сути функция разделена на несколько вызовов. Пожалуй, так более читаемо, но для привыкших к цепочкам методов в LINQ такой синтаксис может быть менее привычен.

И… результаты:

Joining arrays
--------------
  PHP                         0.00021 sec   x1.0 (100%)
  YaLinqo                     0.00065 sec   x3.1 (+210%)
  YaLinqo [string lambda]     0.00070 sec   x3.3 (+233%)
  Ginq                        0.00103 sec   x4.9 (+390%)
  Ginq    [property path]     0.00200 sec   x9.5 (+852%)
  Pinq                        1.24155 sec   x5,911.8 (+591084%)

Нет, здесь нет ошибки. Pinq действительно убивает скорость в шесть тысяч раз. Сначала я думал, что скрипт повис, но в конце концов он завершился, и выдал это невообразимое число. Я не нашёл, где в исходниках Pinq код для этого набора функций, но у меня ощущение, что там for-for-if без массивов-словарей. Вот вам и ООП.

Рассмотрим ещё один простой тест — аггрегацию (или аккумуляцию, или свёртку — как угодно):

benchmark_linq_groups("Aggregating arrays", 100, null,
    [
        "for" => function () use ($DATA) {
            $sum = 0;
            foreach ($DATA->products as $p)
                $sum += $p['quantity'];
            $avg = 0;
            foreach ($DATA->products as $p)
                $avg += $p['quantity'];
            $avg /= count($DATA->products);
            $min = PHP_INT_MAX;
            foreach ($DATA->products as $p)
                $min = min($min, $p['quantity']);
            $max = -PHP_INT_MAX;
            foreach ($DATA->products as $p)
                $max = max($max, $p['quantity']);
            return "$sum-$avg-$min-$max";
        },
        "array functions" => function () use ($DATA) {
            $sum = array_sum(array_map(function ($p) { return $p['quantity']; }, $DATA->products));
            $avg = array_sum(array_map(function ($p) { return $p['quantity']; }, $DATA->products)) / count($DATA->products);
            $min = min(array_map(function ($p) { return $p['quantity']; }, $DATA->products));
            $max = max(array_map(function ($p) { return $p['quantity']; }, $DATA->products));
            return "$sum-$avg-$min-$max";
        },
    ],
    [
        function () use ($DATA) {
            $sum = E::from($DATA->products)->sum(function ($p) { return $p['quantity']; });
            $avg = E::from($DATA->products)->average(function ($p) { return $p['quantity']; });
            $min = E::from($DATA->products)->min(function ($p) { return $p['quantity']; });
            $max = E::from($DATA->products)->max(function ($p) { return $p['quantity']; });
            return "$sum-$avg-$min-$max";
        },
        "string lambda" => function () use ($DATA) {
            $sum = E::from($DATA->products)->sum('$v["quantity"]');
            $avg = E::from($DATA->products)->average('$v["quantity"]');
            $min = E::from($DATA->products)->min('$v["quantity"]');
            $max = E::from($DATA->products)->max('$v["quantity"]');
            return "$sum-$avg-$min-$max";
        },
    ],
    [
        function () use ($DATA) {
            $sum = G::from($DATA->products)->sum(function ($p) { return $p['quantity']; });
            $avg = G::from($DATA->products)->average(function ($p) { return $p['quantity']; });
            $min = G::from($DATA->products)->min(function ($p) { return $p['quantity']; });
            $max = G::from($DATA->products)->max(function ($p) { return $p['quantity']; });
            return "$sum-$avg-$min-$max";
        },
        "property path" => function () use ($DATA) {
            $sum = G::from($DATA->products)->sum('[quantity]');
            $avg = G::from($DATA->products)->average('[quantity]');
            $min = G::from($DATA->products)->min('[quantity]');
            $max = G::from($DATA->products)->max('[quantity]');
            return "$sum-$avg-$min-$max";
        },
    ],
    [
        function () use ($DATA) {
            $sum = P::from($DATA->products)->sum(function ($p) { return $p['quantity']; });
            $avg = P::from($DATA->products)->average(function ($p) { return $p['quantity']; });
            $min = P::from($DATA->products)->minimum(function ($p) { return $p['quantity']; });
            $max = P::from($DATA->products)->maximum(function ($p) { return $p['quantity']; });
            return "$sum-$avg-$min-$max";
        },
    ]);

benchmark_linq_groups("Aggregating arrays custom", 100, null,
    [
        function () use ($DATA) {
            $mult = 1;
            foreach ($DATA->products as $p)
                $mult *= $p['quantity'];
            return $mult;
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->products)->aggregate(function ($a, $p) { return $a * $p['quantity']; }, 1);
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->products)->aggregate('$a * $v["quantity"]', 1);
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->products)->aggregate(1, function ($a, $p) { return $a * $p['quantity']; });
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->products)
                ->select(function ($p) { return $p['quantity']; })
                ->aggregate(function ($a, $q) { return $a * $q; });
        },
    ]);

В первом наборе функций объяснять особо нечего. Единственное что, я разделил вычисление на отдельные проходы во всех случаях.

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

Результаты:

Aggregating arrays
------------------
  PHP     [for]               0.00059 sec   x1.0 (100%)
  PHP     [array functions]   0.00193 sec   x3.3 (+227%)
  YaLinqo                     0.00475 sec   x8.1 (+705%)
  YaLinqo [string lambda]     0.00515 sec   x8.7 (+773%)
  Ginq                        0.00669 sec   x11.3 (+1034%)
  Ginq    [property path]     0.03955 sec   x67.0 (+6603%)
  Pinq                        0.03226 sec   x54.7 (+5368%)

Aggregating arrays custom
-------------------------
  PHP                         0.00007 sec   x1.0 (100%)
  YaLinqo                     0.00046 sec   x6.6 (+557%)
  YaLinqo [string lambda]     0.00057 sec   x8.1 (+714%)
  Ginq                        0.00046 sec   x6.6 (+557%)
  Pinq                        0.00610 sec   x87.1 (+8615%)

Pinq и строковые свойства в Ginq показали страшненькие результаты, YaLinqo опечалил, встроенные функции опечалили не меньше. For рулит.

Ну и на десерт, пример из ReadMe YaLinqo — запрос со всеми функциями вместе взятыми:

benchmark_linq_groups("Process data from ReadMe example", 5,
    function ($e) { consume($e, [ 'products' => null ]); },
    [
        function () use ($DATA) {
            $productsSorted = [ ];
            foreach ($DATA->products as $product) {
                if ($product['quantity'] > 0) {
                    if (empty($productsSorted[$product['catId']]))
                        $productsSorted[$product['catId']] = [ ];
                    $productsSorted[$product['catId']][] = $product;
                }
            }
            foreach ($productsSorted as $catId => $products) {
                usort($productsSorted[$catId], function ($a, $b) {
                    $diff = $a['quantity'] - $b['quantity'];
                    if ($diff != 0)
                        return -$diff;
                    $diff = strcmp($a['name'], $b['name']);
                    return $diff;
                });
            }
            $result = [ ];
            $categoriesSorted = $DATA->categories;
            usort($categoriesSorted, function ($a, $b) {
                return strcmp($a['name'], $b['name']);
            });
            foreach ($categoriesSorted as $category) {
                $categoryId = $category['id'];
                $result[$category['id']] = [
                    'name' => $category['name'],
                    'products' => isset($productsSorted[$categoryId]) ? $productsSorted[$categoryId] : [ ],
                ];
            }
            return $result;
        },
    ],
    [
        function () use ($DATA) {
            return E::from($DATA->categories)
                ->orderBy(function ($cat) { return $cat['name']; })
                ->groupJoin(
                    from($DATA->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
                        );
                    }
                );
        },
        "string lambda" => function () use ($DATA) {
            return E::from($DATA->categories)
                ->orderBy('$cat ==> $cat["name"]')
                ->groupJoin(
                    from($DATA->products)
                        ->where('$prod ==> $prod["quantity"] > 0')
                        ->orderByDescending('$prod ==> $prod["quantity"]')
                        ->thenBy('$prod ==> $prod["name"]'),
                    '$cat ==> $cat["id"]', '$prod ==> $prod["catId"]',
                    '($cat, $prods) ==> [
                            "name" => $cat["name"],
                            "products" => $prods
                        ]');
        },
    ],
    [
        function () use ($DATA) {
            return G::from($DATA->categories)
                ->orderBy(function ($cat) { return $cat['name']; })
                ->groupJoin(
                    G::from($DATA->products)
                        ->where(function ($prod) { return $prod['quantity'] > 0; })
                        ->orderByDesc(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
                        );
                    }
                );
        },
    ],
    [
        function () use ($DATA) {
            return P::from($DATA->categories)
                ->orderByAscending(function ($cat) { return $cat['name']; })
                ->groupJoin(
                    P::from($DATA->products)
                        ->where(function ($prod) { return $prod['quantity'] > 0; })
                        ->orderByDescending(function ($prod) { return $prod['quantity']; })
                        ->thenByAscending(function ($prod) { return $prod['name']; })
                )
                ->onEquality(
                    function ($cat) { return $cat['id']; },
                    function ($prod) { return $prod['catId']; }
                )
                ->to(function ($cat, $prods) {
                    return array(
                        'name' => $cat['name'],
                        'products' => $prods
                    );
                });
        },
    ]);

Код на голом PHP написан общими усилиями здесь на Хабре.

Результаты:

Process data from ReadMe example
--------------------------------
  PHP                         0.00620 sec   x1.0 (100%)
  YaLinqo                     0.02840 sec   x4.6 (+358%)
  YaLinqo [string lambda]     0.02920 sec   x4.7 (+371%)
  Ginq                        0.07720 sec   x12.5 (+1145%)
  Pinq                        2.71616 sec   x438.1 (+43707%)

GroupJoin убил производительность Pinq. Остальные показали более-менее ожидаемые результаты.

Подробнее о библиотеках

Так как Pinq — единственная из представленных библиотек, которая умеет формировать запросы SQL, распарсивая PHP, то статья будет неполной, если не рассмотреть эту возможность. К сожалению, как выяснилось, единственный провайдер — для MySQL, при этом он в виде «демонстрации». По сути, эта фича заявлена и может быть реализована на базе Pinq, но на деле воспользоваться ей невозможно.

Выводы

Если нужно быстренько отфильтровать сотню-другую результатов, полученных от веб-сервиса, то библиотеки LINQ вполне способны удовлетворить потребность.

Среди библиотек безоговорочный победитель по производительности — YaLinqo. Если нужно отфильтровать объекты с помощью запросов, то это самый логичный выбор.

Ginq может понравиться тем, кто предпочитает пользоваться не цепочками методов, а вложенными итераторами. Не знаю, есть ли такие ценители итераторов SPL.

Pinq на поверку оказался монструозной библиотекой, в которой некоторые возможности реализованы отвратительно, несмотря на множество слоёв абстракции. У этой библиотеки есть потенциал за счёт поддержки запросов к БД, но на данный момент он остаётся нереализованным.

Если нужны запросы к БД, то до сих пор остаётся единственный вариант — PHPLinq. Но использовать библиотеку весьма сомнительного качества нет смысла, потому что есть нормальные ORM библиотеки.

Ссылки

  • YaLinqo — библиотека YaLinqo
  • YaLinqo Docs — документация к библиотеке YaLinqo
  • YaLinqo Perf — тесты на производительность YaLinqo, Ginq, Pinq
  • Ginq — библиотека Ginq
  • Pinq — библиотека Pinq

Комментарии (6)


  1. PQR
    01.06.2015 16:04
    +2

    Интересно узнать насчёт yield — какие плюсы/удобства получились при его применении? Стал ли код библиотеки компактнее или выросла производительность? Какие были проблемы при переписывании на yield?


    1. Athari Автор
      01.06.2015 17:05
      +3

      Код изменился радикально. Из набора костылей и подпорок вот такого нечитаемого вида:

      return new Enumerable(function () use ($self, $inner, $outerKeySelector, $innerKeySelector, $resultSelectorValue, $resultSelectorKey)
      {
          /** @var $self Enumerable */
          /** @var $inner Enumerable */
          /** @var $arrIn array */
          $itOut = $self->getIterator();
          $itOut->rewind();
          $lookup = $inner->toLookup($innerKeySelector);
          $arrIn = null;
          $posIn = 0;
          $key = null;
      
          return new Enumerator(function ($yield) use ($itOut, $lookup, &$arrIn, &$posIn, &$key, $outerKeySelector, $resultSelectorValue, $resultSelectorKey)
          {
              /** @var $itOut \Iterator */
              /** @var $lookup \YaLinqo\collections\Lookup */
              while ($arrIn === null || $posIn >= count($arrIn)) {
                  if ($arrIn !== null)
                      $itOut->next();
                  if (!$itOut->valid())
                      return false;
                  $key = call_user_func($outerKeySelector, $itOut->current(), $itOut->key());
                  $arrIn = $lookup[$key];
                  $posIn = 0;
              }
              $args = array($itOut->current(), $arrIn[$posIn], $key);
              $yield(call_user_func_array($resultSelectorValue, $args), call_user_func_array($resultSelectorKey, $args));
              $posIn++;
              return true;
          });
      });

      код стал вот такой конфеткой:

      return new Enumerable(function () use ($inner, $outerKeySelector, $innerKeySelector, $resultSelectorValue, $resultSelectorKey) {
          $lookup = $inner->toLookup($innerKeySelector);
          foreach ($this as $ok => $ov) {
              $key = $outerKeySelector($ov, $ok);
              if (isset($lookup[$key]))
                  foreach ($lookup[$key] as $iv)
                      yield $resultSelectorKey($ov, $iv, $key) => $resultSelectorValue($ov, $iv, $key);
          }
      });

      В принципе и колбэк лишний можно убрать, но я пока не определился: можно ли полагаться на то, что итератор не будет выполнять дорогие операции в конструкторе?

      Скорость не мерял, но с точки зрения количества вызовов мой код был близок к Ginq, то есть скорость возрасла раза в два-три. Когда в язык добавляли yield, тормознутость реализации через итераторы была одним из аргументов (кроме основного — читаемости кода).

      Проблем не было, была радость от удаления такого количества мусора. :) Ну и со стопроцентным покрытием тестами нервничать особо не приходилось.

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


      1. PerlPower
        01.06.2015 21:53

        Вот честно сказать — оба варианта выглядят не очень.


        1. Athari Автор
          01.06.2015 22:02

          Это одна из самых тяжеловесных функций со сложной логикой. Остальные функции в большинстве своём гораздо проще. Ну вот where в полном составе, например:

          public function where ($predicate)
          {
              $predicate = Utils::createLambda($predicate, 'v,k');
              return new Enumerable(function () use ($predicate) {
                  foreach ($this as $k => $v)
                      if ($predicate($v, $k))
                          yield $k => $v;
              });
          }

          (Строчку return new Enumerable(function () use ($predicate) в принципе можно выкинуть. Это наследие от первой версии, надо убедиться, что это не сломает какую-то логику.)

          Ну и один из источников сложностей — это что в одной функции приходится рассматривать все варианты, в то время как во многих других языках есть перегрузки методов.


          1. PQR
            02.06.2015 14:59

            Спасибо за развёрнутый ответ, наглядно!

            Возник ещё один теоретический вопрос по генераторам. В PHP 7 планируется поддержка «return» внутри функции-генератора (https://wiki.php.net/rfc/generator-return-expressions) — это как-нибудь пригодится? Видите для себя область применения return?


            1. Athari Автор
              02.06.2015 18:46

              Мне не повредили бы лямбды (тут всё ясно), extension-методы (чтобы оставить чистые итераторы вместо обёрток), какой-то способ семантично возвращать ключ и значение не виде массива или итератора (чтобы упростить маловменяемые сигнатуры)…

              Возвращаемые значения у генераторов — это, по сути, генерация значения + итератора. Единственное место, где такая сущность имеет смысл — это GroupBy, но там нужна независимость значений в паре, поэтому фича не подойдёт даже для упрощения кода на пару строчек.

              Судя по спеке, эта штука нужна для сопрограмм в общем виде, а в частном случае генераторов последовательностей пользы мало.