LINQ для PHP: скорость имеет значение

    Если вы не знаете, что такое 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
    Метки:
    • +12
    • 12,3k
    • 6
    Поделиться публикацией
    Похожие публикации
    Комментарии 6
    • +2
      Интересно узнать насчёт yield — какие плюсы/удобства получились при его применении? Стал ли код библиотеки компактнее или выросла производительность? Какие были проблемы при переписывании на yield?
      • +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-программистам такая идея в принципе чужда, поэтому невелика потеря.
        • 0
          Вот честно сказать — оба варианта выглядят не очень.
          • 0
            Это одна из самых тяжеловесных функций со сложной логикой. Остальные функции в большинстве своём гораздо проще. Ну вот 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) в принципе можно выкинуть. Это наследие от первой версии, надо убедиться, что это не сломает какую-то логику.)

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

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

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

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

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.