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

  • 360
    いいね
  • 4
    コメント

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の類は、たまーに作りたくなるんですが、性能比を見て使う気がなくなり、大抵本番で使われることはありません。

こちらからは以上です。