Help us understand the problem. What is going on with this article?

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

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

by Hiraku
1 / 12

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

https://www.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っぽいものを作ってみます。
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();

一方で、ループではこうはいきません。「ループの内部を関数でくくりだせばいい」って? 残念、不可能です。なぜならば、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の類は、たまーに作りたくなるんですが、性能比を見て使う気がなくなり、大抵本番で使われることはありません。

こちらからは以上です。

Hiraku
PHP, Go界隈をうろうろしています。最近はgRPCと戦ってる。 特に明示していなければ、記事中のソースコード片は `CC-0 1.0` とします。出典表示無しで自由にコピペして頂いて構いません。 ただ、記事自体をコピペされるのは嫌なので、ソースコード部分以外の文章は通常通り全ての著作権を私が保持するものとします。 引用を超える範囲のコピペは止めて下さい。
http://blog.tojiru.net/
mercari
フリマアプリ「メルカリ」を、グローバルで開発しています。
https://tech.mercari.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした