一畳のくつろぎタイム

このブログでは紹介する商品画像をAmazonアソシエイトより借りています。画像やリンクにはアフィリエイト広告が含まれる事があります

2023年7月29日土曜日

foreachの書き方に見える初心者PHPプログラマーのよくないところ

phpは色々便利関数が用意されており、「そんな気が利いた関数があるの?」となることがよくあります。
とても便利な言語だと思いますが、スクリプト言語ゆえによくないコードも知らずに書いてしまう恐れがあります。

繰り返し処理をプログラミングするにあたり、初回や最後だけ特別な処理したいケースがあります。繰り返し文の外へ出してしまえば簡単に解決するケースもありますが、どうしても繰り返し文のなかでやりたい場合もあります。

「php foreach 最後」や「php foreach 最初」というキーワードでGoogle検索すると

以下のようなコードを掲載したサイトがたくさんでてきます。

$array = array(1, 2, 3, 4, 5);
foreach ($array as $value) {
    if ($value === reset($array)) {
        // 最初
    }
    if ($value === end($array)) {
        // 最後
    }
}

動作確認をとったところ期待したように動くコードではあるようですが、このコードは私にはいろいろ気持ち悪さと疑問が浮かぶコードです。 そのまま採用してはいけません。

foreach文というものは見えない部分で、イテレーター(配列のようなデータから順に取り出せるような仕組み)であるPHPの配列へ自動でnext()関数を呼び配列のポインター(今の位置)を1つづらす事で、次の値を自動的に取得できるようにしてくれています。

プログラマーが配列操作をしないで済むので大変便利な関数?(言語コンテキスト?)です。

foreach($array as $value) {
	print $value;
}

たったこれだけで配列$arrayの中身を全部printで画面へ出力することができます。 終了条件を考える必要がないので楽だし、ミスも防げるので配列の中身を順次処理したいだけならばオススメです。

 日本語で書くとわかりにくいのですが、foreach文がやってくれている事をfor文で書き直すと理解しやすいです。

つぎのようなコードとなります。

for( $value = reset($array); key($array) !== null; $value = next($array)) {

        print $value;

}

初回にポインター先頭に戻して、最初の値を取得し、printが終わったらnext()関数で次の値を取得します。
次に取り出せる値が無い場合はnext()はfalseが戻りますが、値としてfalseが入っている可能性もあるため、

// こんなの
$array = array(1,2,false,4,5);

key()を使い本当に配列の最後か調べます。key()はnullが返る場合は本当に配列の最後です。nullが返ったら繰り返しは終了します。

もう一度、先ほどのコードを見てみましょう。

$array = array(1, 2, 3, 4, 5);
foreach ($array as $value) {
    if ($value === reset($array)) {
        // 最初
    }
    if ($value === end($array)) {
        // 最後
    }
}

reset()は$arrayのポインターを初期化し、最初の要素を指し示すようにするものです。
end()は$arrayのポインターを最後の位置へ動かし、最後の要素を指し示すようにするものです。

Objective-Cの経験値を持っていると、ここで疑念が生じます。
Objective-Cではやってはならない操作だったからです。
$arrayという配列が1つだった場合、イテレーターが保持する今の位置は1つであり。reset()やend()で指し示す位置がコロコロ変わって大丈夫なわけがない。
Objective-Cの場合はポインターがぐちゃぐちゃになって期待したような処理になりません。

繰り返しreset処理をすれば永遠に終了条件となるnullが返らず無限ループに陥る気がするし、繰り返しの中でend()なんて呼ぼうものなら、終了条件のnulが返ってしまい繰り返し即終了となりそうです。

でもPHPはこれが正しく動くようです。でも私には大変気持ちが悪いコードです。
気持ち悪さを解消するために仕組みを調べに行きました。

こちらのQuitaの記事では、同じような問題のあるコードが掲載されているのですが、コメント欄が素晴らしい

 https://qiita.com/potetopote_/items/2f199099566e6a0e01a1

まず気持ち悪さとは別に、mpywさんが指摘しているバグ、値の重複の問題

 値がユニーク(同じ値がない)のであればよいのですが、

$array = array(1, 2, 3, 4, 5);

次のように最後に重複する値が入っていると、end()が取得する値は2なので2周目でループが止まります。

$array = array(1, 2, 3, 4, 2);

 

それから本命の気持ち悪さについてngyukiさんの解説

 次のような感じで、見えないところで配列がreset用とend用の3つに増えているからそれぞれ位置を持っており問題ないという事

// こんなイメージ
$array = array(1, 2, 3, 4, 5);   // ループ用
$arrayend = array(1, 2, 3, 4, 5); // end用
$arraystart = array(1, 2, 3, 4, 5); // reset用

 

 最適化戦略のCopy-on-Write(COW)で、ポインターを操作した際に配列の複製が作られるため、このようなコードを書いても期待した動作になるという事ですが、複製を作るという事は無駄なメモリを消費します。

ただ最初と最後を判定するためだけで、本来必要のない配列の複製を作ってしまっている事になります。

サンプルプログラムレベルのarray(1,2,3,4,5)程度の小さな配列ならば問題ないのですが、実務で必要となるような大き目のデータの読み込みでやるとresetで2倍、endで3倍となるわけで大変だなと感じます。

やはりC言語に近いメモリを意識する言語は一度は学ぶべきかと感じます。
昨今コンピューターのメモリは8ギガや16ギガなど大容量となり、意識しなくても済むようになってきています。

しかし、PHPはサーバーで動かす事が多い言語で、インターネット上のサーバーは私物のコンピューターであるケースは少なく、借りて使ってる事がほとんどでしょう。

借りているコンピューターの場合、メモリ量はお金に直結するので必要最小限の量にすることが多いです。

Webサーバーなどでアクセスが多い場合は、1処理×30アクセスとか同時にそのプログラムが動く場合もあります。

仮に動作に30MByte消費する場合、30x30=900MByte消費されます。
この基準とした30が2倍の60、3倍の90となった場合計算できますね。
無駄にメモリを消費するプログラムは書く癖はなくすべきだと思います。

コメント欄の方々がメモリ消費量の確認方法についても書いてくれているので、よく読んでおくとよいと思います。

 今回のケースはmpywさんが示したコードのように、普通の配列でもキーを取り出して判定するか、もう普通のfor文で配列インデックスを操作するのが解です。