PHP 8で == の比較ルールが変わった話と、まだ残っている罠

PHP 8の緩い比較(==)は、数値と文字列を比べるとき、文字列が数値文字列ならこれまで通り数値として、そうでなければ数値のほうを文字列に変換して比較します。0 == "foo" が false になったのはこのためです。

PHP 7時代に「文字列と0を比べるな」と口を酸っぱくして言っていた身としては、8.0のこの変更はかなり大きな出来事でした。ただ、変わったのは「数値と文字列の比較」だけで、罠が全部消えたわけではありません。移行のときに実際に踏んだところを中心に書いておきます。

0 == “foo” は何が変わったのか

まずは挙動の差から。同じコードでもPHPのバージョンで結果が変わる、という気持ち悪さがある部分です。

var_dump(0 == 'foo');    // PHP 7: true  / PHP 8: false
var_dump(0 == '');       // PHP 7: true  / PHP 8: false
var_dump('abc' == 0);    // PHP 7: true  / PHP 8: false

var_dump(0 == '0');      // どちらも true
var_dump(42 == '42');    // どちらも true
var_dump(42 == '000042');// どちらも true
var_dump(100 == '1e2');  // どちらも true

PHP 7までは「文字列を数値に変換してから比べる」だったので、数値に見えない文字列は軒並み0扱いになっていました。PHP 8では、文字列が数値文字列でないと判断された時点で、数値のほうを文字列に寄せて比較します。0"0" になって "foo" と比べられるので、当然一致しない、というわけですね。

このルールは == だけでなく !=<><=> といった非厳密な比較全般に効きます。ソートの比較関数の中でうっかり緩い比較をしていると、並び順が静かに変わることがあります。

なぜ「気づかないまま壊れる」のか

この変更が厄介なのは、エラーも警告も出ないところです。動きだけが変わる。しかも == を直接書いていない場所にも効きます。

// 既定では緩い比較を使う関数たち
in_array(0, ['apple', 'banana']);   // PHP 7: true / PHP 8: false
array_search('abc', [0, 1, 2]);     // PHP 7: 0(添字) / PHP 8: false

// switch も内部は緩い比較
switch ($code) {
    case 0:
        // $code が 'abc' のとき、PHP 7では通っていた
        break;
}

正直なところ、PHP 8の挙動のほうが期待に近いはずです。ただ「PHP 7の壊れた挙動に依存して、たまたま通っていた分岐」があると、移行時にそこだけ動かなくなります。in_arrayarray_search は第3引数に true を渡して厳密比較にしておくと、バージョンに関係なく結果が固定されるので安心だと思います。

文字列同士の比較は変わっていない

ここが見落としやすいところで、今回変わったのは「数値と文字列」の比較です。文字列と文字列の比較ルールは以前のままで、両方が数値文字列なら数値として比較されます。

var_dump('1' == '01');       // true(数値として比較)
var_dump('10' == '1e1');     // true
var_dump('0e1' == '00e2');   // true(どちらも数値としては0)

var_dump('1' === '01');      // false(=== なら文字列として比較)

つまり、ハッシュ値のような「見た目は文字列だが中身が 0e123... の形をしている」ものを == で比べる書き方は、PHP 8でも安全にはなっていません。ハッシュやトークンの照合には ===、より正確には hash_equals() を使う、という原則はそのまま残ります。

数値文字列の定義も少し広がった

PHP 8.0では、末尾の空白を含む文字列も数値文字列として扱われるようになりました。それまでは先頭の空白だけが許容されていて、末尾に空白があると「先頭が数値の文字列」という中途半端な扱いになっていたんですね。

var_dump(is_numeric('35 '));   // PHP 7: false / PHP 8: true
var_dump(is_numeric(' 35'));   // どちらも true

フォーム入力の末尾に紛れ込んだ空白が、PHP 8では黙って数値として通るようになった、と考えると分かりやすいと思います。地味な変更ですが、入力バリデーションを is_numeric に頼っているコードでは判定結果が変わります。

実務ではどう書くのが安全か

結局のところ、比較のルールを暗記して立ち回るより、比較の前に型をそろえてしまうほうが早いです。入力を受け取った境界で整数なら整数にする。それだけでこの手の話はほとんど消えます。

$raw = $_GET['id'] ?? null;

// 検証と変換を同時にやる。失敗は false で返る
$id = filter_var($raw, FILTER_VALIDATE_INT);

if ($id === false) {
    throw new InvalidArgumentException('idが不正です');
}

// ここから先は int 同士。=== で素直に比べられる
if ($id === 0) {
    // ...
}

ポイントは === false で判定することです。filter_var は成功すると int を返しますが、入力が "0" のときは int(0) が返るので、if (!$id) と書くと有効な0を弾いてしまいます。ここは自分でも何度かやらかしました。

分岐そのものを厳密比較に寄せるなら match も手です。match=== 相当の比較をするので、switch のような取り違えが起きません。

まとめ

PHP 8の比較は、数値と文字列を比べたときに「文字列らしさ」を尊重するようになりました。0 == "foo" が false になったのは素直な改善ですが、文字列同士の比較は昔のままで、数値文字列は今も数値として比べられます。結局、== の挙動を覚えて使いこなすより、境界で型をそろえて === で比べるほうが読む人にも優しい、という気がしています。移行のときは in_arrayswitch のあたりを先に見ておくと、あとが楽です。

よくある質問

Q. PHP 8にすれば == を安心して使えますか?
A. 数値と非数値文字列の比較はまともになりましたが、文字列同士の比較ルールは変わっていません。'1' == '01' は今も true なので、値の同一性を確認したい場面では === を使うのが無難です。

Q. 移行時にまず確認すべき箇所はどこですか?
A. in_arrayarray_search の第3引数なし呼び出し、switch の数値 case、そして緩い比較を使っているソートの比較関数です。いずれもエラーを出さずに結果だけが変わります。

Q. is_numeric の変更で困るのはどんなケースですか?
A. 入力チェックを is_numeric だけで済ませている場合です。PHP 8では '35 ' のような末尾空白付きの文字列も通るようになったので、DBに空白入りの値を渡してしまわないよう、trimfilter_var で正規化してから使うのが安全です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です