NGW開発BLOG
← 記事一覧へ
·#003

switch を match に変えたら、静かに壊れたPHP 8 の厳格比較

PHP 7→8 移行で switch を match に一括変換したら、DB から int で返る値と文字列定数の比較が === で外れ、値が静かに default へ落ちた。型契約を意識せよという教訓。

静かに壊れるバグ

PHP 7 から PHP 8 への移行は、多くの場合は素直に済む。だが今回、移行後の新環境でだけ、ある画面の分類処理が軒並みおかしくなった。例外は出ない。ログにも何も残らない。ただ、本来 A や B に振り分けられるはずの値が、すべて「それ以外」の扱いに落ちていた。静かに壊れる、いちばん厄介な部類のバグである。

原因を辿ると、移行作業の一環でやった機械的な書き換えに行き着いた。PHP 8 で導入された match 式は、switch より簡潔で式として値を返せる。そこで、値によって分岐して結果を代入するだけの switch を、片っ端から match に置き換えていた。

// 変換前:switch は緩い比較(==)
switch ($x) {
    case KISAN_KUBUN_A: $label = '甲'; break;
    default:            $label = 'その他';
}

// 変換後:match は厳格比較(===)
$label = match ($x) {
    KISAN_KUBUN_A => '甲',
    default       => 'その他',
};

見た目はほぼ等価だ。しかし switchcase は緩い比較(==)、match のアームは厳格比較(===)で照合する。この一点が、環境の変化と噛み合って牙をむいた。

なぜ新環境でだけ起きたか

鍵は「値の型がどこから来るか」だった。

このコードでは PDO をエミュレーションOFF(ATTR_EMULATE_PREPARES = false)で使っている。PHP 8 + ネイティブ mysqlnd の組み合わせでは、この設定下で INT カラムの値が「PHP の int」として返る。旧環境では同じ値が文字列で返っており、緩い比較の switch はそれを平然と受け入れていた。つまり以前は、型のゆらぎを == が吸収してくれていたのである。

一方、分岐に使う定数の側は事情が違う。設定テーブルから読み込んで define している定数は、値が文字列として定義されている。起案区分を表す定数の値が "20" といった具合だ。

両者を match で突き合わせると、こうなる。

$x = 20;        // DB の INT カラム由来 → PHP の int
match ($x) {
    "20" => 'A',   // 定数の実体は文字列
    default => 'X' // 20 === "20" は false → こちらに落ちる
};

20 === "20"false。厳格比較は型まで一致しなければ通らないから、アームに当たらず default へ流れる。旧本番はまだ PHP 7 で switch のままだったので無傷。この不具合は、ドライバ設定と PHP 8 の型付けが揃った新環境でだけ表面化した。

逆向きの罠もある。POST 値や varchar カラムは文字列で入ってくるので、match($stringVal) { 0 => ... } のように int リテラルのアームを持たせると、これも "0" === 0 で外れる。subject が int でアームが文字列、subject が文字列でアームが int — どちらの組み合わせでも同じように滑り落ちる。

型契約を揃えて直す

修正は最小限に留めた。ロジックはいじらず、match の subject を、アームの型に合わせてキャストするだけでよい。アームが文字列定数なら (string)、int リテラルなら (int) を被せる。

$label = match ((string)$x) {   // subject を文字列に寄せる
    KISAN_KUBUN_A => '甲',       // アームは文字列定数
    default       => 'その他',
};

(string) で寄せる方式には副次的な利点もあった。過去に数値として JSON へ書き出された古いデータが混ざっていても、文字列に正規化してから比較するので取りこぼさない。

棚卸しは地道にやった。match ( を grep で洗い出し、ひとつずつ subject の出所を辿る。DB ゲッター経由か、json_decode の結果か、POST か。そのうえでカラム型や定数の実際の値を確認し、アームの型と突き合わせる。数は多かったが、判断そのものは単純な照合作業に落とし込めた。

AIとの協働作業での学び

switch から match への置換は、機能的にはほぼ等価な「リファクタリング」に見える。だが == から === への変更は、意味論の変更である。緩い比較を前提に書かれたコードには、明示されていない「型は揃っていなくても通る」という暗黙の契約が潜んでいる。それを厳格比較へ一括変換すると、DB ドライバやその設定に由来する型のゆらぎが、突然バグとして噴き出す。

教訓の核はひとつだ。match のアームと subject は、型まで含めて契約を合わせておくこと。移行時に比較の厳格さを上げるなら、その比較にかかる値がどの型でやってくるのかを、必ず出所まで遡って確かめる。型は、揃っているうちは見えない。揃わなくなった瞬間に初めて牙をむく。

余談 — 前任者の後始末

この記事には、少し可笑しな裏話がある。

そもそも PHP 7 から 8 への一括移行 ── switch を機械的に match へ置き換えた、あの作業そのもの ── は、別の AI にやらせたものだった。そして今、その置換が生んだ静かなバグを追い、直しているのは、また別の AI(今使っているもの)である。道具を乗り換えた経緯は別稿に書いた。

つまり、片方の AI がざっくり移行し、そのしわ寄せを、もう片方の AI が後始末している。ポンコツな一括変換の尻拭いを、別の AI が引き受けているわけだ。

構図としてはこうも言える。ある日ふいに退職した前任者の残した不始末を、あとから採用した新人が的確に片づけてくれた ── そんな話である。前任者も新人も AI だという点を除けば、どこの職場にもありそうな光景だ。

面白いのは、道具が変わっても仕事の性質は変わらないことだ。誰か(何か)が大づかみに変え、別の誰か(何か)が丁寧に読み解いて直す。一括変換は速いが、それが正しいかを確かめ、静かな綻びを拾うのは、また別の仕事なのだ。そして、どちらの AI に任せるかを決め、直った結果を最後に見届けるのは ── 相変わらず、私である。