Edited at

PHPで高速オシャレな配列操作を求めて

More than 1 year has passed since last update.

PHPには大量の配列操作関数が用意されています。

http://jp2.php.net/manual/ja/ref.array.php

これらの関数、イマイチ書き味が悪いということで、よくPHPがDISられるポイントになっています。


お題として、こんな感じのコードを書きたいとしましょう。(意味は特にないです)


0~10000のうち、偶数だけを抽出して自乗し、結果が20を超えるものを足しあわせよ



array_xxx系の関数だけで入れ子にしながら書くとこんなことになります。

echo array_sum(

array_filter(
array_map(
function ($v) {
return $v ** 2;
},
array_filter(range(0, 10000), function ($v) {
return $v % 2 === 0;
})
),
function ($v) {
return $v > 20;
}
)
);

読めたもんじゃないですね。



関数の構文について


配列関数の書き味を改善する

まあ、よく訓練されたPHPerであれば、この手の書き味の悪さを改善するのは数分あればできますよね。メソッドチェーン形式に変換するような言語内DSLを書けば良いだけです。


<?php

class Collection extends ArrayObject
{
function map($fn)
{
return new self(array_map($fn, (array)$this));
}

function __call($method, $args)
{
$method = preg_replace('/[A-Z]/', '_\0', $method);
$func = 'array_' . $method;
if (!function_exists($func)) {
throw new \BadMethodCallException("func is not exists.");
}

$args = array_merge([(array)$this], $args);
$res = call_user_func_array($func, $args);

if (is_array($res)) {
return new self($res);
} else {
return $res;
}
}
}

array_mapだけ引数の法則が違うので自作してますが、他はだいたい規則があるので、__callで全部処理してしまっています。


このクラスを使うと、冒頭のお題はこんな感じに書けます。


0~10000のうち、偶数だけを抽出して自乗し、結果が20を超えるものを足しあわせよ


echo (new Collection(range(0, 10000)))

->filter(function($v){ return $v % 2 === 0; })
->map(function($v){ return $v ** 2; })
->filter(function($v){ return $v > 20; })
->sum()
;


だいぶ平べったくなりました。しかしfunctionってダサいですね。残念ながら今のPHPでは~>などの短い記法でクロージャを宣言できません。


仕方ないので、create_functionを使って少し改善することにしましょう。

ソースがもう少し長くなるのでgithubを見てください。

https://github.com/hirak/php-collection-test/blob/master/array.php


echo (new Collection(range(0, 10000)))

->filter('$_ % 2 === 0')
->map('$_ ** 2')
->filter('$_ > 20')
->sum()
;


だいぶ短くなりました。というわけで、記法に関してはライブラリ/フレームワークでいくらでもいじることができます。



配列以外への拡張

ただ、上記のCollectionクラスには致命的な欠点があります。PHPでforeachできるものは配列だけではありません。Traversableなオブジェクトは全部、同じように処理できてほしいものです。

iterator_to_array()で配列化すればよいのですが、メモリに載り切らないような巨大なデータをストリーミングで処理する場合に詰みます。

そこで、イテレータを使って同じことができるようにしてみましょう。PHPのイテレータは普段使いするには書きづらいので、これまたDSLっぽいものを作ってみます。

http://jp2.php.net/manual/ja/class.callbackfilteriterator.php

array_xxxに比べると、SPLのイテレータは初期実装が少なく、結構作りこまないとCollectionと同等のものは再現できません。

<?php

// 短くするためにPSR-2のコーディングスタイルは無視してるよ
require_once __DIR__ . '/lambda.php';

class LazyCollection implements IteratorAggregate {
private $ite;

function __construct(Iterator $ite) { $this->ite = $ite; }

function getIterator() { return $this->ite; }

static function range($start, $end) {
$gen = function ($start, $end) {
for ($i = $start; $i <= $end; ++$i) {
yield $i;
}
};
return new self($gen($start, $end));
}

function filter($fn) {
if (!is_callable($fn)) {
$fn = Lambda::create('$_', $fn, true);
}
$this->ite = new CallbackFilterIterator($this->ite, $fn);
return $this;
}

function map($fn) {
if (!is_callable($fn)) {
$fn = Lambda::create('$_', $fn, true);
}
$gen = function ($ite, $fn) {
foreach ($ite as $v) {
yield $fn($v);
}
};
$this->ite = $gen($this->ite, $fn);
return $this;
}

function sum() {
$sum = 0;
foreach ($this as $v) {
$sum += $v;
}
return $sum;
}
}

こんな感じ。

echo LazyCollection::range(0, 10000)

->filter('$_ % 2 === 0')
->map('$_ ** 2')
->filter('$_ > 20')
->sum()
;

書き味はおなじで、配列以外にも拡張することができました。


性能を見てみる

ここまでの書き方について、軽くベンチマークを取ってみましょう。

https://github.com/hirak/php-collection-test/blob/master/bench.php

書き方
100回実行にかかった時間例 (秒)

array_xxx (改善版)
1.4272809028625

Iterator (改善版)
6.1677799224854

array_xxx を直接書く
2.5250308513641

イテレータを使ったバージョンの場合、遅さが引き立ちます。PHPは関数の呼び出しコストが結構かかります。イテレータの場合、foreach中の各フェーズ全てで関数の呼び出しを行って制御しているため、遅くなるのです。

ちなみに、改善版の方が生のarray_xxxより速いのは、create_functionを使っているためです。

create_functionは普通の関数を生成するのに対し、クロージャはオブジェクトを作って__invoke()するので、若干速度が劣ります。(まあ、これはPHP本体が改善してマシになる時が来るかもしれません)


※create_functionはそのうちdeprecatedになる恐れもあり、使い方にかなり注意が必要なので、これを真に受けて気軽に使うのはやめてくださいね



ソースコード生成 + evalで改善できないか

一応、ソースコードを生成して一発evalするというやり方で改善を試みたのですが、かなり危険なコードになっているにも関わらず、そんなに改善されなかったことをここに記しておきます。

https://github.com/hirak/php-collection-test/blob/master/compile.php


結論:PHPらしい配列操作の書き方

というわけで、filterやmapはよっぽど簡潔になるケースを除いては使わず、foreachで中間変数を作りながら書いていくのが通常です。

多少流派があるにせよ、だいたいこんなもんでしょう。

$sum = 0;

for ($v = 0; $v <= 10000; ++$v) {
if ($v % 2) continue;
$v **= 2;
if ($v <= 20) continue;

$sum += $v;
}

echo $sum;

そして、配列関数を使わない方が高速です。関数呼び出しが間に一つもないのですから、まあ当然といえば当然。

その差は、他の書き方の4000~10万倍。桁が違います。 => 追記:ベンチマークが間違っていて、流石にそんな差は出ませんでした。10~50倍といった程度。

書き方
100回実行にかかった時間例 (秒)

array_xxx (改善版)
1.4272809028625

Iterator (改善版)
6.1677799224854

コード生成 + eval
0.87097501754761

生ループ
0.12252283096313

array_xxx を直接書く
2.5250308513641


ロジックの分割と再利用

さて、生ループが他を寄せ付けない速さを見せますが、一方で生ループには苦手なことがあります。分割しづらいのです。


0~10000のうち、偶数だけを抽出して自乗し、結果が20を超えるものを足しあわせよ


例えば、「偶数だけを抽出して自乗する」という部分だけを切り出したいとします。

最初に作ったメソッドチェーンの場合、これは非常に簡単です。filterとmapの行をコピペして、前後にちょこちょこっと関数定義をすれば終わり。どの断面でも区切れるし、どの断面でもつなぎ合わせられます。任意箇所で分割できるということは、任意箇所だけをテストできるということでもあります。

簡単便利。

echo (new Collection(range(0, 10000)))

->filter('$_ % 2 === 0')
->map('$_ ** 2')
->filter('$_ > 20')
->sum()
;

// 一部だけ切り出す
function my_special_logic($arr) {
return $arr
->filter('$_ % 2 === 0')
->map('$_ ** 2');
}

// 再利用
echo my_special_logic(new Collection(range(0, 10000)))
->filter('$_ > 20')
->sum();

一方で、ループではこうはいきません。「ループの内部を関数でくくりだせばいい」って? 残念、不可能です。なぜならば、continuebreakは括りだすことができないからです。

$mapped = [];

for ($v = 0; $v <= 10000; ++$v) {
if ($v % 2) continue;
$v **= 2;
if ($v <= 20) continue;

$mapped[] = $v;
}

echo array_sum($mapped);

// こんな関数は作れない
function my_special_logic($v) {
if ($v % 2) continue;
$v **= 2;
return $v;
}

困りました。だから配列関数を使う、、というのでは、面白みがありません。

なので最近のPHPでは、ループを任意の箇所で分割するために、 ジェネレーター を使います。

function my_special_logic($arr) {

foreach ($arr as $v) {
if ($v % 2) continue;
$v **= 2;
yield $v;
}
}

これを使うと、先ほどの生ループは、こう書けます。

$sum = 0;

foreach (my_special_logic(range(0, 10000)) as $v) {
if ($v <= 20) continue;

$sum += $v;
}

echo $sum;

「ループを任意の箇所で分割する」というのは、若干語弊がありますね。ジェネレーターにできるのは、玉ねぎの皮を剥くように、ループのロジックを外側から引き剥がしていくことです。

大体のケースにおいて、これができれば十分だと思います。


まとめ


  • 基本的に生ループでベタっと書く

  • テストしづらければジェネレーターにして切り分ける

  • 簡潔になる場合のみ、array_xxxを使う

って感じが基本戦略でしょうか。

ちなみに、sortやshuffle、intersectやdiff、reverseなど、配列が全部揃わないと手続きできないような処理は、array_xxxx系を使っても簡潔になる印象はあります。sortなんてわざわざ自前で実装したくないでしょう。

入れ子にならないように、適宜中間変数を作りながら扱えば、そんなに気にならないと思います。

filterやmap、reduce、walkあたりの逐次処理に置き換えられるものは、特に使う必要ない、というのが個人的感覚です。

こういうDSLの類は、たまーに作りたくなるんですが、性能比を見て使う気がなくなり、大抵本番で使われることはありません。

こちらからは以上です。