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 => 'その他',
};
見た目はほぼ等価だ。しかし switch の case は緩い比較(==)、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 に任せるかを決め、直った結果を最後に見届けるのは ── 相変わらず、私である。