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っぽいものを作ってみます。
https://www.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();
一方で、ループではこうはいきません。「ループの内部を関数でくくりだせばいい」って? 残念、不可能です。なぜならば、continue
やbreak
は括りだすことができないからです。
$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の類は、たまーに作りたくなるんですが、性能比を見て使う気がなくなり、大抵本番で使われることはありません。
こちらからは以上です。