確実にand&&演算にしてるのにor||演算になった理由は何だったのかについて
!($x === 0 && $x === null) が常に true になった話
PHPで条件式を書いていて、「明らかに false になるはずの式が true を返す」という場面に出くわした。原因はド・モルガンの法則だったのだが、結論にたどり着くまでに何段階か勘違いを踏んだので、その過程をそのまま記録しておく。
1. 発端
ある変数が「0 でも null でもない」ことを確認したかった。こう書いた。
!($nowNotification === 0 && $nowNotification === null)
動作確認のため、比較の途中経過も並べて出力してみた。
print_r(
!($nowNotification === 0) . "と" .
!($nowNotification === null) . "と" .
!($nowNotification === 0 && $nowNotification === null)
);
出力はこうなった。
1とと1
2. 最初の疑問
PHPでは真偽値を文字列連結すると、true は "1"、false は ""(空文字)になる。この挙動自体は知っていたので、出力は次のように読める。
| 式 | 結果 |
|---|---|
!($x === 0) |
true → 1 |
!($x === null) |
false → “ |
!($x === 0 && $x === null) |
true → 1 |
ここから変数の中身が null であることは分かった。分からなかったのは3つ目だ。
1 と 0 を && で繋いだのに、なぜ 1 が返るのか。
false && true は false のはずで、期待していたのは false だった。しかし返ってきたのは true。しかもこの返り方、どこかで見たことがある。まるで or のような挙動だった。or にしたつもりは一切ないのに。
3. 勘違いその1 — ! が届く範囲
最初の誤りはここだった。1つ目と2つ目の出力(1 と空文字)を && で繋いだ結果が3つ目だと思い込んでいた。
しかし ! はカッコの外にある。関数と引数の形に置き換えると、何が起きているかがはっきりする。
// 私が想定していたもの
and( not(A), not(B) )
// 実際に書いていたもの
not( and(A, B) )
not は引数を1つしか取らない。受け取るのは and がまとめた答え1個だけで、内側の A や B には手が届かない。それぞれを個別に反転して回る動きはしない。
実際の計算はこうだった。
A: $x === 0 → false
B: $x === null → true
and(false, true) → false
not(false) → true ← これが出力された 1
1つ目・2つ目に表示されていた値は not を通した後の値で、3つ目の内側で使われているのは not を通す前の値。加工後の結果と材料を取り違えていた。
4. 勘違いその2 — && の日本語訳
そもそも式の意味自体もズレていた。
日本語で「0 と null の場合」と言うと、ふつうは2つのケースを並べている意味になる。しかし && はそういう意味ではない。&& が聞いているのは、たった1個の値についての1回きりの質問だ。
$xの中身は、0 であり、かつ同時に null でもあるか?
=== は型まで含めた厳密比較なので、ある値が「int の 0 でありながら同時に null」になることは絶対にない。つまり内側の and は $x に何が入っていても必ず false。それを外の ! が反転するので、この式は常に true を返す。
動いているように見えて、実際には何も判定していない式だった。
5. 核心 — 反転は演算子にも及ぶ
そして「まるで or だ」という違和感。あれは正しかった。実際に or に化けていた。
! をカッコの中に配ると、中の値だけでなく演算子そのものも入れ替わる。
!(A && B) ≡ !A || !B // && が || になる
!(A || B) ≡ !A && !B // || が && になる
いわゆるド・モルガンの法則だが、身近な例で考えれば直感的に納得できる。
「傘とカバンの両方を持っている」
これを否定すると「傘もカバンも持っていない」……ではない。片方だけ忘れた人も否定側に入るからだ。正しくはこうなる。
「傘が無い、または カバンが無い」
「両方そろっている」の反対は「どれか欠けている」。欠けているのは片方で足りるので、&&(両方)が ||(どちらか)に変わる。
なお「傘もカバンも持っていない」(!傘 && !カバン)という式自体は成立するし意味もある。ただし元の否定としては条件が強すぎる。片方だけ持っている人が前者には含まれるのに後者からは漏れるので、カバーする範囲が違う。
これを自分のコードに当てはめると、書いていた式の正体はこうだった。
!($x === 0 && $x === null)
// ↓ 展開すると
$x !== 0 || $x !== null
$x に何が入っていても、どちらか片方は必ず成立する(0 なら「null じゃない」が真、null なら「0 じゃない」が真)。だからいつでも true。or に見えたのではなく、or だった。
6. 正しい書き方
「0 でも null でもない」を判定したいなら、こうだった。
$x !== 0 && $x !== null // 素直に書く
!($x === 0 || $x === null) // !ごと外に出すなら、中は || になる
逆に「0 か null のどちらかである」なら。
$x === 0 || $x === null
結論
今回つまずいた原因は、知識の欠落というより想定範囲の狭さだった。
!が値を反転させること、その概念自体は知っていた- しかし 反転が演算子にまで及ぶこと が想定になかった
「! は値をひっくり返すもの」という理解のまま ! をカッコの外に置いたため、&& はそのまま残ると思い込んでいた。実際には && ごと || に変わっていて、結果として自分が一度も書いたつもりのない or の式が動いていた。
覚えておくべきルールは1行で済む。
!をカッコの外に出し入れするときは、中の&&と||も必ず反転させる。
値だけ反転して演算子を据え置きにすると、まったく別の式になる。
この性質は PHP 固有のものではなく、JavaScript でも Python でも C でも同じように成り立つ。一度掴んでおけばどの言語でも使い回せる。
おまけ
真偽値の確認に print_r を使うと、false が空文字になって画面から消える。今回の 1とと1 のように「何が出力されていないのか」を目視で追う羽目になるので、デバッグ時は var_dump() のほうが良い。
var_dump(!($x === 0 && $x === null));
// bool(true)
型と値が明示されるので、こうした取り違えが起きにくくなる。
そう書いたAIですが私はこの問題についてprint_rを使って空文字で誤解を生んだのではなく、()で囲んだすべての反転が演算子を巻き込むことを想定になかったことが事の発端だったというのが答えです。