Quantcast
Channel: rana_kualuの記事 - Qiita
Viewing all articles
Browse latest Browse all 331

PHPの繰り返し処理大全

$
0
0

PHP7.3時代の話です。
PHP8や9のころには、また別の結論になっているかもしれません。

最初に結論

・全要素繰り返しはforeach
・途中で打ち切るのはwhile/for
・それ以外はいらん

繰り返し処理一覧

foreach

PHPのforeachは非常に優秀です。
あらゆる反復可能な値を繰り返し処理することができます。

$arr = [1, 2, 3];
foreach($arr as $val){
  echo $val; // 順に1, 2, 3
}

PHPの配列は順序付きリストなので、順番も保証されます。
また、キーと値を両方とも取れるので、inとofどっちだったっけとか悩む必要もありません。

$arr = [3 => 1, 2 => 2, 1 => 3];
foreach ($arr as $key => $val) {
    echo $key, $val; // 31, 22, 13 順番が変わったりはしない
}

オブジェクトにも使えます。

class Test{
    public $a = 1;
    protected $b = 2;
    private $c = 3;

    public function loop(){
        foreach($this as $val){
            echo $val;
        }
    }
}

$test = new Test();
foreach($test as $val){
    echo $val; // 1
}

$test->loop(); // 1,2,3

オブジェクトに使った場合、可視なプロパティが順にアクセスされます。
すなわち外部からはpublicプロパティしか見えず、内部からならprotectedやprivateも見えるということです。

さらに他言語では御法度の、ループ中で自身や別要素を削除するという芸当がPHPでは可能です。

$array = range(0, 10);

foreach ($array as $k => $v) {
    unset($array[$k - 1]);
}

var_dump($array); // [10=>10]

ループ中で、前回ループの値を削除しています。
他言語でこんなことをやるとどんな動作になるかわかったものではありませんが、PHPでは全要素をきちんとループした上で、最終的に$arrayの値は想定通りになります。
配列をforeachした時点で配列のコピーが作成されるので、ループ内で変なことをしでかしてもループ自体には影響が及ばないように対策されているのです。
おかげで何も考えずにfilterが実装できます。

$array = [1, 2, 3, 4, 5, 6];

// 偶数以外削除
foreach ($array as $k => $v) {
    if ($v % 2) {
        unset($array[$k]);
    }
}

var_dump($array); // [2, 4, 6]

while

条件がtrueっぽい値であるかぎり、ループを繰り返します。

$i = 1;
while ($i <= 10) {
    echo $i;
    $i++;
}

foreachとの使い分けですが、foreachは配列などの全要素にアクセスする用途に使います。
whileは何らかの条件でループを脱出する用途に使います。

// 30回ループしたら脱出
$i = 0;
while ($i < 30) {
    echo $i;
    $i++;
}

// 30秒経ったら中断
while(true){
    sleep(1);

    if(time() - $_SERVER['REQUEST_TIME'] > 30){
        break;
    }
}

// 標準入力を出力
while( ($line = fgets(STDIN)) !== false){
    echo $line;
}

うっかり使うと無限ループになるので使用は避けましょう
いや嘘。
whileを使うべきところにforやforeachを使ったりはせず、適切に使い分けましょう。

for

whileで多くの場合必要となる『初期値設定』『カウント処理』を最初から文法に組み込んだものです。

// 30回ループしたら脱出
for ($i = 0; $i < 30; $i++) {
    echo $i;
}

whileではループ前後に書かざるをえなかった定型処理がひとつの文にまとまったことで、わかりやすくなって見た目もすっきりしました。

whileは常にforで書き換え可能なので、実はwhileは必ずしも存在する必要はありません。
しかし初期値設定などが必要ない簡易的なループはwhileで書いた方がわかりやすいので、forとwhileは処理内容によって使い分けるとよいでしょう。

// forを使うほどではない
for(; true; ){
    sleep(1);

    if(time() - $_SERVER['REQUEST_TIME'] > 30){
        break;
    }
}

PHPでは、配列要素などにアクセスする用途でforを使用する理由はありません。

$array = [1, 2, 3];

for ($key = 0; $key < count($array); $key++) {
    echo $key, $array[$key]; // 01, 12, 23
}

$array = [0 => 1, 2 => 3]; // 死
$array = ['answer' => 42]; // 死

歯抜けのない純粋配列にしか使用できず、うっかり連想配列や歯抜けのある配列にforでアクセスすると死にます。
要素に対するアクセスには必ずforeachを使いましょう。

forの存在価値はループのためではなく、条件分岐のためにあります。

for(; time() - $_SERVER['REQUEST_TIME'] <= 30; sleep(1));

条件部分やループカウント部分にあらゆる処理を詰め込むことでforの本文を空にすることもできますが、見づらくなるだけなので止めましょう。

do - while

条件のチェックが最後に行われるということ以外はwhileと同じです。
つまり、たとえ条件が偽でも必ず一回だけは実行されるwhileということです。

$i = 100;
do {
    echo $i++;
} while ($i < 30);

正直、whileではなくこちらを使わなければならないシーンを思いつきません。

関数型関数

PHPには組み込みでarray_walkarray_reducearray_productといった、関数型のように書ける関数が多数用意されています。
これらを使うことで反復処理をやめ、関数型プログラミングっぽい記述にすることが可能になります。

$sum = 0;
foreach($array as $val){
    $sum += $val;
}
echo $sum;

echo array_sum($array); // ↑と同じ

が、基本的にこれらを使う必要はないです。
上記のようなわかりやすい例はむしろ例外で、PHPでは文法の都合上、大抵の処理は関数型で書くと却ってわかりづらくなります。

以下はPHPで高速オシャレな配列操作を求めてより借用した例です。

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

// 関数型
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;
        }
    )
);

// 普通に書く
for ($sum = $v = 0; $v <= 10000; ++$v) {
    if ($v % 2){ continue; }
    $v **= 2;
    if ($v <= 20){ continue; }
    $sum += $v;
}
echo $sum;

明らかに普通に書いた方がわかりやすいですね。
そもそも関数型の例でよく出てくる『○○を抽出する』みたいな処理は、PHPであればSQL発行する時点で絞っておけって話ですし。

filter_varなど使いこなすと色々楽しいこともできるのですが、実用的かと言われると首が傾いてしまいますね。
もちろん関数型で書いてもわかりやすい場合もありますが、別にforeachで書いたところで可読性も大してかわらないので、なら最初から全部foreachで書いた方が手っ取り早いです。

反復処理の定義

オブジェクトに対するループ処理の挙動を、PHPでは任意に定義可能です。
上のほうでオブジェクトをforeachするとpublicプロパティが順番に出てくると言いましたが、それはデフォルトの動作であって、やろうと思えば変更できるということです。

Iteratorインターフェイス

反復処理実装の基本です。
Iteratorインターフェイスをimplementsして各メソッドを実装します。

class Test implements Iterator
{
    public $dummy = '出てこない';
    private $data1 = [1, 2, 3];
    private $data2 = [4, 5, 6];
    private $current = 0;

    /**
     * @Override
     * 現在の値を返す
     * @return mixed 現在の値
     */
    public function current(){
        return $this->data1[$this->current] ?? $this->data2[$this->current - 3] ?? null;
    }

    /**
     * @Override
     * 現在のキーを返す
     * @return string|int 現在のキー
     */
    public function key(){
        return $this->current;
    }

    /**
     * @Override
     * ポインタを次に移動する
     */
    public function next(){
        $this->current++;
    }

    /**
     * @Override
     * ポインタを初期化する
     */
    public function rewind(){
        $this->current = 0;
    }

    /**
     * @Override
     * 現在のポインタが有効か
     * @return boolean 有効ならtrue
     */
    public function valid(){
        return isset($this->data1[$this->current]) ?: isset($this->data2[$this->current - 3]);
    }
}

$test = new Test();
foreach ($test as $key => $val) {
    echo $key, '=>', $val; // [0=>1, 1=>2, 2=>3, 3=>4, 4=>5, 5=>6]
}

publicであるはずの$dummyは出てこなくなり、currentで返した結果が表示されるようになります。
このように、Iteratorインターフェイスを使うことでループで返す値を好き勝手に変更することができるようになります。
ただ、変なことをやってもわかりにくくなるだけなので、基本的にあまり使わないほうがいいと思います。

IteratorAggregateインターフェイス

Iteratorインターフェイスは必ず5個のメソッドを実装する必要があって面倒です。
PHPには最初からイテレータが幾つも用意されており、それらで賄える範囲であれば簡単に実装できるIteratorAggregateがあります。

class Test implements IteratorAggregate
{
    public $dummy = '出てこない';
    private $data1 = [1, 2, 3];
    private $data2 = [4, 5, 6];

    /**
     * @Override
     * イテレータを返す
     * @return Iterator
     */
    public function getIterator(){
        $iter = new AppendIterator();
        $iter->append(new ArrayIterator($this->data1));
        $iter->append(new ArrayIterator($this->data2));
        return $iter;
    }
}

$test = new Test();
foreach ($test as $key => $val) {
    echo $key, '=>', $val; // [0=>1, 1=>2, 2=>3, 0=>4, 1=>5, 2=>6]
}

IteratorAggregate::getIteratorに適当なイテレータを投げれば、foreachループでそれが出てくるようになります。
今回は配列をイテレータにするArrayIterator、複数のイテレータを順にまとめるAppendIteratorを使って、$data1$data2が順に出てくるようにしました。

もちろん、書くのが楽になったからといって使いまくると何が出てくるかわからないブラックボックスになってしまいます。
RecursiveRegexIteratorあたりまで来ると正直何言ってるかわからないので、変なイテレータには手を出さない方がいいと思います。

ジェネレータ

ジェネレータはクロージャとセットにされがちですが、単に何度もreturn(yield)できる関数という認識でいいと思います。

function getPrimeNumber(){
    yield 2;
    yield 3;
    yield 5;
    yield 7;
    yield 11;
    yield 13;
    yield 17;
    return 19; // returnは出ない
}

$primes = getPrimeNumber();
foreach($primes as $prime){
    echo $prime; // 2, 3, 5, 7, 11, 13, 17
}

returnのかわりにyieldというキーワードを使います。
yieldキーワードが入った関数は、関数呼び出しの返り値が自動的にGeneratorインスタンスになります。
たとえ絶対にyieldを通らない実装だったとしてもそうなるので、少し注意が必要です。

返ってくるのはオブジェクトなので、その返り値をforeachでループすることができるわけですが、その際は関数の最初からではなくyieldで止まったところの次の文から処理が再開されます。
関数内の変数値などは維持されるので、何も考えずにメモ化ができたりします。
上で出した例では全く意味がありませんが、無限数列などを作ったりする際にはジェネレータがとても役立ちます。

// フィボナッチ数を求めるジェネレータ
function getFibonacci(){
    $fa = 0;
    yield $fa;
    $fb = 1;
    yield $fb;

    while (true) {
        $fib = $fa + $fb;
        $fa = $fb;
        $fb = $fib;
        yield $fib;
    }
}

$count = 0;
foreach (getFibonacci() as $fibonacci) {
    echo $fibonacci . "\n";
    // 適当に切らないと無限ループする
    if ($count++ > 30) {
        break;
    }
}

再帰もメモ化もgotoもなんもなしに、普通に定義通りのフィボナッチ数を求める関数が書けてしまいました。

ジェネレータは頻繁に出番があるかというと無いですが、覚えておくといざというとき便利な機能です。

まとめ

自発的に使うのはforeachとwhile/forだけでいいよ。

それ以外はあまり出てくるものでもないので、出てきたときに調べるくらいで大丈夫でしょう。

使う必要のないもの

foreachのリファレンス

foreachでリファレンスが取れますが、使用してはいけません。

$array = range(0, 5);
foreach ($array as &$val) {
    $val *= 2;
}
var_dump($array); // [2, 4, 6, 8, 10]

$val = 42;
var_dump($array); // [2, 4, 6, 8, 42] ←

そもそもリファレンスはあらゆる場面で一切使用禁止です。
リファレンスで高速化が云々とか言ってる人は全員間違い1なので、生暖かい目でスルーしましょう。

do - whileの早期return

do-while記法は、簡易的な早期returnに使用できます。
以下はマニュアルに載っている例です。

do {
    if ($i < 5) {
        echo "i は十分大きくはありません。";
        break;
    }
    $i *= $factor;
    if ($i < $minimum_limit) {
        break;
    }
    echo "iはOKです。";

    /* 実際の処理 */

} while (0);

breakはループを抜ける文なので、$i<5だったり$i < $minimum_limitの場合は、このwhileループを抜けます。
条件に当てはまらずループの最後まで来た場合、条件が常に偽であるため、そのままループを終了して先に進みます。
はい、早期returnできました。

もちろんこのような書き方はせず、メソッドなどに出してください。

current / reset / next / prev / end

配列ポインタを手動で操作することが可能です。

$array = range(0, 5);

while(true){
    echo key($array), current($array);
    if(next($array) === false){
        break;
    }
}

配列ポインタ操作関数はresetnextprevendなどひととおり揃っています。
が、あえてこれらの関数を使わなければならない場面はありません。

さらにマニュアルには書かれていないのですが、実はこいつらの引数はZ_PARAM_ARRAY_OR_OBJECT_HTであり、つまりオブジェクトを受け付けます。

class Test{
    public $a = 1;
    protected $b = 2;
    private $c = 3;
}
$array = new Test();

while(true){
    var_dump(key($array), current($array) );
    if(next($array) === false){
        break;
    }
}

01.png

マジかよ。

each

PHP7.2でDeprecatedになったため、使ってはいけません。

いにしえのPHPではメモリ節約のためにeachを使おうなどとされていた時代もありましたが、PHP7時代においては間違った記述です。

上のほうでforeachしたら配列のコピーが作成され云々とか言いましたが実は嘘で、現在ではforeachループするだけならコピーは作成されません。
ループ中で元の配列を変更しようとしたときに初めてコピーが作成されます。
これはコピーオンライトと呼ばれる技術で、最近のPHPの最適化技術のひとつです。
現在のPHPは非常に最適化が進んでいるので、下手なことを考えるより標準機能を使った方がよっぽど使用メモリも少なく速度も速いです。

ジェネレータの変な使い方

素のジェネレータはforeachしかできず、whileやforで書くことができないのですが、実はGeneratorクラスはIteratorインターフェイスをimplementsしているので、手動でループさせることもできます。

$fibonacci = getFibonacci();

while ($fibonacci->key() < 28) {
    $fibonacci->next();
    echo $fibonacci->current(); // 1, 1, 2, 3, 5, 8, …
}

こんな処理が必要になるのであれば、ジェネレータではなく他の機能を使った方がよいでしょう。

yield fromキーワードを使って、別のジェネレータの返り値を取り込むことができます。

function gen()
{
    yield 1;
    yield from [2, 3];
    yield from gen2();
}

function gen2()
{
    yield 4;
    yield 5;
}

$gen = gen();
foreach ($gen as $v) {
    echo $v; // 1, 2, 3, 4, 5
}

こんな処理を作り込むよりも、データ構造を見直した方がよいでしょう。

ソート

usortなんかは関数型っぽい書き方なのでループと言えないこともない可能性がなきにしもあらずな気がしないでもないんだけどループの範疇に含めるべきものだろうか?

$arr = [3, 1, 2];

usort($arr, function ($a, $b) {
    return $a <=> $b;
});

var_dump($arr); // 1, 2, 3

やはりループっぽくはないですね。

その他

思いついたのを並べたらこんなかんじでした。
見落としや間違いがあったら誰かがプルリクしてくれるはず。


  1. 0.1%くらい正しいことを言っている可能性もあるが、見分けは付かないので全て間違いと考えてかまわない。 


Viewing all articles
Browse latest Browse all 331

Trending Articles