推移律?
そんなものはこの世の果てに置いてきた。
"true" == 0;
0 == "0";
"true" == "0";
結果は順にtrue、true、falseです。
これがPHP7までの非厳密な比較(等価)演算子だったわけですが、まあおかしいよねってことで、この挙動がPHP8.0で変更になることになりました。
よもや今さら基本中の基本である比較演算子の動作を弄ってくるとは思わなかったぞ。
以下はSaner string to number comparisonsの日本語訳です。
PHP RFC: Saner string to number comparisons
Introduction
==
やその他の非厳密な比較演算子を用いた文字列と数値の比較は、現在は、文字列を数値にキャストし、その後整数か浮動小数の比較を行っています。
この結果、多数の不可解な結果が得られますが、中でも注目すべきは0 == "foobar"
がtrueになることです。
このRFCでは、文字列が実際に数値型文字列である場合にのみ数値型で比較を行うことにすることで、非厳密な比較をより直感的にし、問題になる動作が起こりにくくすることを提案します。
そうでない場合は、数値を文字列型に変換し、文字列比較を行うことにします。
PHPでは大別して2種類の比較演算子をサポートしています。
厳密な比較===
および!==
と、非厳密な比較==
、!=
、>
、>=
、<
、<=
、<=>
です。
両者の主な違いは、厳密な比較は両方のオペランドが同じ型であることが前提で、暗黙の型変換を行わないことです。
しかし、それ以外にもいくつか異なる点が存在します。
・厳密な比較は、strcmp()
で比較を行うが、非厳密な比較は、数値型文字列である場合は数値に変換する"スマートな"比較を行う。
・厳密な比較は、配列の順番も同じ必要があるが、非厳密な比較は、配列の順番が異なっていてもよい。
・厳密な比較は、オブジェクトが同一かをチェックするが、非厳密な比較は、オブジェクトの値を比較する。
非厳密な比較は常に避けるべきである、というのが現在のPHPでのドグマになっています。
バグの最大の原因は0 == "foobar"
がtrueになるという事実でしょう。
これはin_array()
やswitch
など、比較が暗黙のうちに行われている場合によく発生します。
$validValues = ["foo", "bar", "baz"];
$value = 0;
var_dump(in_array($value, $validValues)); // true ←
これは不幸なことです。
なぜならば、PHPのような言語では、非厳密な比較に価値がないということは決してないからです。
42
と"42"
を同じ値であると見なすことは多くの場合において有用です。
さらにPHPでは言語側が暗黙のうちに変換を行うことがあります。
たとえば配列のキーが整数型文字列の場合は整数に変換されます。
さらに、switchのような幾つかの構文は非厳密な比較しかサポートしていません。
非厳密な比較の考え方には多くのメリットが存在しますが、しかし残念なことに現在の比較のセマンティクスは明らかに間違っており、非厳密比較全体の有用性を大きく落としてしまっています。
このRFCでは、文字列と数値の比較をより合理的にすることを意図しています。
数値文字列を数値と比較する際には、文字列を数値に変換してから比較し、これは現在と同じ動作です。
それ以外の場合は、数値を文字列に変換して文字列比較を行います。
このRFCによって、幾つかの単純比較がどのように変更されるかを次の表に示します。
Comparison | Before | After |
---|---|---|
0 == "0" | true | true |
0 == "0.0" | true | true |
0 == "foo" | true | false |
0 == "" | true | false |
42 == " 42" | true | true |
42 == "42foo" | true | false |
この仕組みを理解するためには、数値型文字列の比較を見てみるとよいでしょう。
上の表と、下の数値型文字列と文字列の比較の結果表を見比べてみましょう。
(下の表は、このRFCでは変更されません)
Comparison | Result |
---|---|
"0" == "0" | true |
"0" == "0.0" | true |
"0" == "foo" | false |
"0" == "" | false |
"42" == " 42" | true |
"42" == "42foo" | false |
上記の説明は簡略化されたものです。
詳細な仕様は後述しますが、これだけで新しい仕様の直感的な動作と、どうしてこの仕様を選択したのかはわかると思います。
Proposal
このRFCは、以下の演算子と関数、およびこれ以外にも非厳密な比較を行う全ての操作に適用されます。
・演算子==
、!=
、>
、>=
、<
、<=
、<=>
・関数in_array()
、array_search()
、array_keys()
でstrict=true
を渡さない場合
・ソート関数sort()
、rsort()
、asort()
、arsort()
、`array_multisort()にSORT_REGULARを渡した場合
このRFCの正確な定義は以下の通りです。
●$int <=> $string
・ $string
が正規の整数型文字列である場合、$int <=> (int)$string
・ $string
が正規の浮動小数型文字列である場合、(float)$int <=> (float)$string
・ それ以外の場合、strcmp((string)$int, $string)
を1/0/-1に正規化した値
●$string <=> $int
・ return -($int <=> $string)
●$float <=> $string
・ $float
がNANであれば1
・ $string
が正規の整数型文字列である場合、$float <=> (float)$string
・ $string
が正規の浮動小数型文字列である場合、$float <=> (float)$string
・ それ以外の場合、strcmp((string)$int, $string)
を1/0/-1に正規化した値
○$string <=> $float
・ $float
がNANであれば1
・ return -($float <=> $string)
ここには幾つかの微妙な要素が要素が絡んでいます。
Well-formed numeric strings
正確な定義は言語仕様書に書かれていますが、正規の整数型文字列とは、オプションの空白の後に10進整数もしくは浮動小数リテラルが続くものであると簡潔に記載されています。
正規でない整数型文字列とは、正規の整数型文字列の末尾に追加の文字列があるものです。
それ以外の全ての文字列は、整数型文字列ではありません。
このRFCでは、正規の整数型文字列についての比較はこれまでと全く同じです。
これは42 == "42"
のような単純なケースだけではなく、数値が異なる形式で与えられた場合でも同様です。
// 前後で変更なし
var_dump(42 == "000042"); // true
var_dump(42 == "42.0"); // true
var_dump(42.0 == "+42.0E0"); // true
var_dump(0 == "0e214987142012"); // true
文字列による非厳密な比較においても結果は全く同じです。
// 前後で変更なし
var_dump("42" == "000042"); // true
var_dump("42" == "42.0"); // true
var_dump("42.0" == "+42.0E0"); // true
var_dump("0" == "0e214987142012"); // true
このRFCによる差異は、正規でない数値文字列、もしくは数値型でない文字列の場合にのみ発生します。
// Before | After | Type
var_dump(42 == " 42"); // true | true | 正規
var_dump(42 == "42 "); // true | false | 非正規
var_dump(42 == "42abc"); // true | false | 非正規
var_dump(42 == "abc42"); // false | false | 非数
var_dump( 0 == "abc42"); // true | false | 非数
目を引くのは、" 42"と"42 "において結果が異なるところです。
この矛盾は、別途Saner numeric stringsのRFCにおいて解消されます。
Precision
比較方法を単純に定義するのではなく、数値を文字列にキャストして非厳密な比較を行うという回りくどい方法を採用しているのは、PHPによる浮動小数から文字列への変換はini設定precision
の影響を受けるからです。
正規の浮動小数文字列との比較は、この設定に無関係に処理されます。
しかし、非正規の浮動小数文字列との比較では、以下のように影響してきます。
$float = 1.75;
ini_set('precision', 14); // 小数点以下14桁、デフォルト
var_dump($float < "1.75abc");
// ↑↓だいたい同じ
var_dump("1.75" < "1.75abc"); // true
ini_set('precision', 0); // 小数点以下0桁
var_dump($float < "1.75abc");
// ↑↓だいたい同じ
var_dump("2" < "1.75abc"); // false
Special values
浮動小数には、幾つかの特殊な非数値が存在します。
// Before | After
var_dump(INF == "INF"); // false | true
var_dump(-INF == "-INF"); // false | true
var_dump(NAN == "NAN"); // false | false
var_dump(INF == "1e1000"); // true | true
var_dump(-INF == "-1e1000"); // true | true
注目すべきが2点あります。
まず、INF
が"INF"
と等しくなるようになりました。
ただし、NAN
は"NAN"
と等しくなりません。
NAN
はあらゆる比較演算子にfalseを返し、<=>
はNAN
がどちらにあるかに関わらず1を返します。
これは、値が比較不可能であることを示すPHP内部の方法です。
NAN
の特別なセマンティクスはIEEE-754に従っており、NAN
を含む比較は常にfalseになります。
Backward Incompatible Changes
非厳密な比較のこの変更は、後方互換性がありません。
さらに悪いことに、このRFCはPHPコア機能を暗黙のうちに変更することになります。
PHP7.4ではある動作を行っていたコードが、PHP8.0では異なる動作を行うことになります。
そして影響を受けるケースを検出するために静的解析を使用すると、多くの誤検出が発生する可能性があります。
しかし比較の変更について調査を行ったところでは、この変更による実際の影響は、想像していたよりずっと小さいことがわかりました。
ただし、これはテスト対象のコードベースに大きく依存します。
投票
期間は2020/07/31まで、投票者の2/3の賛成で受理されます。
本RFCは賛成44反対1の圧倒的賛成多数で受理されました。
感想
簡単に言うと、0 == "hoge"
は
・PHP8より前は (int)0 == (int)"hoge"
・PHP8.0以降は (string)0 == (string)"hoge"
となります。
変更後の動作を見てみると、確かにこっちの方が元の動作よりも妥当っぽいよね、とは思うのですが、だからといって今さら==
の挙動に手を加えてくるとはさすがに思いませんでしたよ。
なかなか思いきったことをする。
この変更によって自然と
switch('foo'){
case 0:
'$fooは0だよ';
}
switchでよく問題となっていたこの挙動が発生しなくなります。
ということはつまりmatch式いらないのでは?と一瞬思わないでもないですが、switchはそれ以外にも色々アレなのでやっぱりあったほうがいいですね。
さて、この変更により、きっと予想より多くのコードに影響が出ることでしょう。
RFC中では影響は意外と少ないと言っていましたが、それはあくまで少なくともGitHubに上げることのできる程度の能力がある者が書いたコードだからであり、最低限の水準は維持されているからです。
そう、GitHubに上げる能力すらない者たちによって書かれた絶望と混沌が世の中には溢れているのですよ。
と思ったけど、そんなコードはそもそもPHP8では動かないだろうし、そしてバックエンドで動いているだろうPHP5が8にアップグレードされるなんてこともないだろうから、別に問題ないか。
それに動作が変更になるといっても、基本的には想定したように動くようになる変更ですしね。
即ち、現実的なソースコードにはほとんど影響ありません。