Если вы не знаете, что такое LINQ, и зачем он сдался на PHP, смотрите предыдущую статью по YaLinqo.
С остальными продолжаем. Сразу предупреждаю: если вы считаете, что итераторы — это ненужная штука, которую зачем-то притащили в PHP, что производительность из-за всех этих новомодных штучек с анонимными функциями зверски проседает, что нужно вымерять каждую микросекунду, что ничего лучше старого-доброго for не придумано — то проходите мимо. Библиотека и статья не для вас.
С остальными продолжаем. LINQ — это замечательно, но насколько проседает производительность от его использования? Если сравнивать с голыми циклами, то скорость меньше раз в 3-5. Если сравнивать с функциями для массивов, которым передаются анонимные функции, то раза в 2-4. Так как предполагается, что с помощью библиотеки обрабатываются небольшие массивы данных, а сложная обработка данных находится за пределами скрипта (в базе данных, в стороннем веб-сервисе), то на деле в масштабах всего скрипта потери небольшие. Главное — читаемость.
Так как со времени создания моей библиотеки YaLinqo на свет появилось ещё два конкурента, которые действительно являются LINQ (то есть поддерживают ленивые вычисления и прочие базовые возможности), то возникают позывы библиотеки сравнить. Самое простое и логичное — сравнить функциональность и производительность. По крайней мере это не будет избиением младенцев, как в прошлом сравнении.
(А также появление конкурентов наконец-то мотивировало меня выложить документацию YaLinqo онлайн.)
Дисклеймер: это тесты «на коленке». Они не дают оценить все потери в производительности. В частности, я совершенно не рассматриваю потребление памяти. Отчасти потому что я не знаю, как это нормально сделать. Если что, pull requests are welcome, что называется.
Конкуренты
YaLinqo — Yet 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 классов. Документация средней паршивости: в лучшем случае указаны сигнатуры. Главная особенность — итераторы, что позволяет использовать библиотеку и построением запросов в виде цепочки методов, и с помощью вложенных итераторов.
Pinq — PHP Integrated Query, a real LINQ library for PHP. Единственная библиотека, которая позволяет работать и с объектами, и с базами данных (ну… теоретически позволяет). Поддерживает только анонимные функции, но умеет парсить код с помощью PHP-Parser. Документация не самая детальная (если вообще есть), но зато имеет симпатичный сайтик. Самая массивная библиотека из представленных: больше 500 классов, не считая 150 классов тестов (если честно, в код я даже не лез, потому что страшно).
У всех представленных библиотек с тестами и прочими признаками качества всё в порядке. Лицензии пермиссивные: BSD, MIT. Все поддерживают Composer и представлены на Packagist.
Тесты
Здесь и далее в функцию
Тесты гоняются на PHP 5.5.14, Windows 7 SP1. Так как тесты «на коленке», то не привожу спеки железа — задача оценить потери на глаз, а не измерить всё до миллиметра. Если хотите точных тестов, то исходный код доступен на гитхабе, можете улучшать, пулл-реквесты принимаются.
Начнём с плохого — чистого оверхеда.
Генерирующая функция
И результаты:
Итераторы нещадно съедают скорость.
Но гораздо сильнее бросается в глаза страшное проседание по скорости у последней библиотеки — в 30 раз. Должен предупредить: эта библиотека ещё успеет попугать числами, поэтому удивляться рано.
Теперь вместо простой итерации сгенерируем массив последовательных чисел.
И результаты:
Теперь YaLinqo проигрывает только в два раза относительно решения в лоб на цикле. У остальных библиотек результаты похуже, но жить можно.
Теперь займёмся подсчётом в тестовых данных: посчитаем заказы с более, чем пятью пунктами заказа; посчитаем заказы, у которых более двух пунктов с количеством более пяти.
Заметно три нюанса. Во-первых, функциональный стиль на стандартных функциях для массивов превращает код в забавную нечитаемую лесенку. Во-вторых, строковыми лямбдами воспользоваться не удаётся, потому что экранировать код внутри экранированного кода — это вынос мозга. В-третьих, Pinq не предоставляет функции
Смотрим результаты:
Результаты более-менее предсказуемы, если не считать пугающего результата Pinq. Я посмотрел код. Там генерируется вся коллекция, а потом на ней вызывается
Займёмся фильтрацией. Всё как в прошлый раз, но вместо подсчёта генерируем коллекции.
Код на функциях для массивов уже начинает заметно попахивать. Не в последнюю очередь из-за того, что у
Код с использованием запросов намеренно менее оптимальный: объекты генерируются, даже если они потом будут отфильтрованы. Это, в общем-то, традиция LINQ, который предполагает создание по пути «анонимных типов» с промежуточными результатами вычислений.
Результаты, если сравнивать с предыдущими тестами, достаточно ровные:
Перейдём к сортировке:
Код сравнивающей функции для
Результаты удивляют:
Во-первых, Pinq вырывается вперёд, хоть и незначительно. Спойлер: это случилось в первый и последний раз.
Во-вторых, доступ к свойствам в Ginq ужасающе просаживает производительность, то есть в реальном коде этой фичей уже не воспользуешься. Синтаксис не стоит потери скорости в 50 раз.
Переходим к весёлому — к джойнам, ака соединению двух коллекций по ключу.
Синтаксически выделилась Pinq, где одна по сути функция разделена на несколько вызовов. Пожалуй, так более читаемо, но для привыкших к цепочкам методов в LINQ такой синтаксис может быть менее привычен.
И… результаты:
Нет, здесь нет ошибки. Pinq действительно убивает скорость в шесть тысяч раз. Сначала я думал, что скрипт повис, но в конце концов он завершился, и выдал это невообразимое число. Я не нашёл, где в исходниках Pinq код для этого набора функций, но у меня ощущение, что там for-for-if без массивов-словарей. Вот вам и ООП.
Рассмотрим ещё один простой тест — аггрегацию (или аккумуляцию, или свёртку — как угодно):
В первом наборе функций объяснять особо нечего. Единственное что, я разделил вычисление на отдельные проходы во всех случаях.
Во втором наборе вычисляется произведение. Pinq опять подвела: она не предоставляет перегрузку, принимающую стартовое значение, вместо этого всегда берёт первый элемент (и возвращает null при отсутствии элементов, а не бросает исключение...), в результате приходится дополнительно мапить значения.
Результаты:
Pinq и строковые свойства в Ginq показали страшненькие результаты, YaLinqo опечалил, встроенные функции опечалили не меньше. For рулит.
Ну и на десерт, пример из ReadMe YaLinqo — запрос со всеми функциями вместе взятыми:
Код на голом PHP написан общими усилиями здесь на Хабре.
Результаты:
GroupJoin убил производительность Pinq. Остальные показали более-менее ожидаемые результаты.
Подробнее о библиотеках
Так как Pinq — единственная из представленных библиотек, которая умеет формировать запросы SQL, распарсивая PHP, то статья будет неполной, если не рассмотреть эту возможность. К сожалению, как выяснилось, единственный провайдер — для MySQL, при этом он в виде «демонстрации». По сути, эта фича заявлена и может быть реализована на базе Pinq, но на деле воспользоваться ей невозможно.
Выводы
Если нужно быстренько отфильтровать сотню-другую результатов, полученных от веб-сервиса, то библиотеки LINQ вполне способны удовлетворить потребность.
Среди библиотек безоговорочный победитель по производительности — YaLinqo. Если нужно отфильтровать объекты с помощью запросов, то это самый логичный выбор.
Ginq может понравиться тем, кто предпочитает пользоваться не цепочками методов, а вложенными итераторами. Не знаю, есть ли такие ценители итераторов SPL.
Pinq на поверку оказался монструозной библиотекой, в которой некоторые возможности реализованы отвратительно, несмотря на множество слоёв абстракции. У этой библиотеки есть потенциал за счёт поддержки запросов к БД, но на данный момент он остаётся нереализованным.
Если нужны запросы к БД, то до сих пор остаётся единственный вариант — PHPLinq. Но использовать библиотеку весьма сомнительного качества нет смысла, потому что есть нормальные ORM библиотеки.
Ссылки
С остальными продолжаем. Сразу предупреждаю: если вы считаете, что итераторы — это ненужная штука, которую зачем-то притащили в PHP, что производительность из-за всех этих новомодных штучек с анонимными функциями зверски проседает, что нужно вымерять каждую микросекунду, что ничего лучше старого-доброго for не придумано — то проходите мимо. Библиотека и статья не для вас.
С остальными продолжаем. LINQ — это замечательно, но насколько проседает производительность от его использования? Если сравнивать с голыми циклами, то скорость меньше раз в 3-5. Если сравнивать с функциями для массивов, которым передаются анонимные функции, то раза в 2-4. Так как предполагается, что с помощью библиотеки обрабатываются небольшие массивы данных, а сложная обработка данных находится за пределами скрипта (в базе данных, в стороннем веб-сервисе), то на деле в масштабах всего скрипта потери небольшие. Главное — читаемость.
Так как со времени создания моей библиотеки YaLinqo на свет появилось ещё два конкурента, которые действительно являются LINQ (то есть поддерживают ленивые вычисления и прочие базовые возможности), то возникают позывы библиотеки сравнить. Самое простое и логичное — сравнить функциональность и производительность. По крайней мере это не будет избиением младенцев, как в прошлом сравнении.
(А также появление конкурентов наконец-то мотивировало меня выложить документацию YaLinqo онлайн.)
Дисклеймер: это тесты «на коленке». Они не дают оценить все потери в производительности. В частности, я совершенно не рассматриваю потребление памяти. Отчасти потому что я не знаю, как это нормально сделать. Если что, pull requests are welcome, что называется.
Конкуренты
YaLinqo — Yet 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 классов. Документация средней паршивости: в лучшем случае указаны сигнатуры. Главная особенность — итераторы, что позволяет использовать библиотеку и построением запросов в виде цепочки методов, и с помощью вложенных итераторов.
Pinq — PHP 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
PQR
Интересно узнать насчёт yield — какие плюсы/удобства получились при его применении? Стал ли код библиотеки компактнее или выросла производительность? Какие были проблемы при переписывании на yield?
Athari Автор
Код изменился радикально. Из набора костылей и подпорок вот такого нечитаемого вида:
код стал вот такой конфеткой:
В принципе и колбэк лишний можно убрать, но я пока не определился: можно ли полагаться на то, что итератор не будет выполнять дорогие операции в конструкторе?
Скорость не мерял, но с точки зрения количества вызовов мой код был близок к Ginq, то есть скорость возрасла раза в два-три. Когда в язык добавляли yield, тормознутость реализации через итераторы была одним из аргументов (кроме основного — читаемости кода).
Проблем не было, была радость от удаления такого количества мусора. :) Ну и со стопроцентным покрытием тестами нервничать особо не приходилось.
Заодно выпилил к чёртовой бабушке все «коллекции» и прочий хлам и заменил на обычные массивы. Потерялась возможность класть объекты и массивы в ключи, но PHP-программистам такая идея в принципе чужда, поэтому невелика потеря.
PerlPower
Вот честно сказать — оба варианта выглядят не очень.
Athari Автор
Это одна из самых тяжеловесных функций со сложной логикой. Остальные функции в большинстве своём гораздо проще. Ну вот
where
в полном составе, например:(Строчку
return new Enumerable(function () use ($predicate)
в принципе можно выкинуть. Это наследие от первой версии, надо убедиться, что это не сломает какую-то логику.)Ну и один из источников сложностей — это что в одной функции приходится рассматривать все варианты, в то время как во многих других языках есть перегрузки методов.
PQR
Спасибо за развёрнутый ответ, наглядно!
Возник ещё один теоретический вопрос по генераторам. В PHP 7 планируется поддержка «return» внутри функции-генератора (https://wiki.php.net/rfc/generator-return-expressions) — это как-нибудь пригодится? Видите для себя область применения return?
Athari Автор
Мне не повредили бы лямбды (тут всё ясно), extension-методы (чтобы оставить чистые итераторы вместо обёрток), какой-то способ семантично возвращать ключ и значение не виде массива или итератора (чтобы упростить маловменяемые сигнатуры)…
Возвращаемые значения у генераторов — это, по сути, генерация значения + итератора. Единственное место, где такая сущность имеет смысл — это GroupBy, но там нужна независимость значений в паре, поэтому фича не подойдёт даже для упрощения кода на пару строчек.
Судя по спеке, эта штука нужна для сопрограмм в общем виде, а в частном случае генераторов последовательностей пользы мало.