hnwの日記

PHP 5.4.4から==の挙動が一段と難しくなりました

PHPの==は両辺を適当に型キャストしてから比較するような演算子です。この型キャストの規則は難解すぎる上にドキュメントも不十分なため、PHPプログラマでも完璧に理解している人はほとんど居ないくらいの印象です。バグの原因になりかねないため、なるべく==を使わないようにしているPHPプログラマも多いはずです。

ところで、この==演算子の挙動がPHP 5.4.4から変更されていることはあまり知られていません。本稿ではこの内容を紹介します。

Bug #54547 の騒動

まずはこの仕様変更の経緯を紹介します。

2年ほど昔、Hacker Newsで2^63付近の整数に対応する文字列をPHPで比較したときの挙動がおかしいというスレッドが盛り上がったことがありました。具体的には、PHPでは「'9223372036854775807' == '9223372036854775808'」がtrueになるという話題で、PHPの仕様をDISるような流れでした。

僕のように古くからのPHPユーザーにとっては==の挙動がキモいことは常識ですし、この例で浮動小数点数比較されているのも一目瞭然、他の言語の人が叩きに来るのも日本ではよくあることで、海外も周回遅れで同じ流れになってるのかなーというくらいの印象でした。僕からするとこれは仕様ですし、==の実装は複雑すぎて整合性を保ちつつ不満を解消するような仕様変更は不可能だと感じていたため、いくら叩かれようが修正はありえないと思っていました。

ところがPHPの中の人がムシャクシャしていたのか何なのかはわかりませんが、これをバグとして修正する流れになりました。そのバグ報告と修正までの流れは「PHP :: Bug #54547 :: wrong equality of string numbers」で確認することができます。これがPHP 5.4.4から取り込まれており、今回紹介する内容ということになります。

PHP 5.4.4での変更内容

PHP 5.4.4からは、上で紹介した'9223372036854775807' == '9223372036854775808'」がfalseを返すようになっています。

この変更の詳細についてマニュアルなどには記述が見当たりませんが、ソースパッケージの./UPGRADINGに次のように書いてあります。

Long numeric strings that do not fit in integer or double (such as
"92233720368547758070") are compared using string comparison if
they could otherwise result in precision loss - since 5.4.4.

翻訳すると次のようになります。

整数や浮動小数点数におさまらないような巨大な数値文字列("92233720368547758070"など)を比較する場合に、
文字列比較しないと精度が落ちるような場合には文字列比較が使われます。(PHP5.4.4から)

そもそも言葉足らずなのですが、これは数値文字列同士を比較する場合の話題です。PHP 5.4.3以前では数値文字列同士を比較する場合はかならず整数または浮動小数点数にキャストされてから比較されていました。しかし、キャストで精度が落ちるような数値文字列同士の場合は文字列比較するよ、というのがPHP 5.4.4での変更ということになります。

説明のため、64bit環境のPHP 5.5.11での実行例を示します。

<?php
$x1 = "9223372036854775807";
$y1 = "0X7FFFFFFFFFFFFFFF";
var_dump($x1==$y1); // bool(true), 数値比較している(文字列比較していたらfalseのはず)

$x2 = "9223372036854775808";
$y2 = "0X8000000000000000";
var_dump($x2==$y2); // bool(false), 文字列比較している(数値比較していたらtrueのはず, PHP 5.4.3以前ではtrue)

この例の$x1,$y1は整数の上限値となるような数値文字列です。これは整数としてピッタリ表現できるので、数値比較されます。これはPHP 5.4.3以前と同じ挙動です。

一方、$x2、$y2は整数の上限値を超えており、数値比較するとしたら浮動小数点数にキャストして比較することになります。実際、PHP 5.4.3までは浮動小数点数として数値比較されていました。しかし、これらの整数は大きすぎるため、浮動小数点数にキャストすると精度が落ちてしまい比較演算が不正確になるおそれがあります。このような場合にPHP 5.4.4では文字列比較するようになったというわけです。

整数に対応する数値文字列の場合、浮動小数点数にキャストして精度が落ちると判断されるのは-2^63-1以下または2^63以上(整数の範囲外かどうか、64bit環境の場合)のとき、もしくは-2^53以下または2^53以上(浮動小数点数仮数部で表現できるかどうか、32bit環境の場合)のときになります。

仕様変更での考え漏れ

ところで、この仕様変更では浮動小数点数に対応する数値文字列について仕様上の考え漏れがあるように思います。つまり、浮動小数点数の場合には「文字列比較しないと精度が落ちる」とはどういうことか、自明ではありません。

PHPソースコードを確認してみたところ、64bit環境では10^19以上の浮動小数点数に対応する数値文字列同士を浮動小数点数にキャストして差が0だった場合は文字列比較するという実装になっていました。10^19というのは浮動小数点数にとって意味のある境界値では無いため、深い考えがあるようには思えません。パッチを当てた人はそんな境界値になっていることすら気づいていない可能性があると思います。

上記の内容は、ソースコードZend/zend_operators.cのzendi_smart_strcmp()とZend/zend_operators.hのis_numeric_string_ex()を追えば確認できます。

浮動小数点数に対応する数値文字列の比較について、実行例を示します。

<?php
$x3 = "9999999999999999999.0";
$y3 = "9999999999999999999.1";
var_dump($x3==$y3); // bool(true), 数値比較している(文字列比較していたらfalseのはず)

$x4 = "10000000000000000000.0";
$y4 = "10000000000000000000.00";
var_dump($x4==$y4); // bool(false), 文字列比較している(数値比較していたらtrueのはず, PHP 5.4.3以前ではtrue)

ちなみに、$x3、$y3、$x4、$y4をそれぞれ浮動小数点数にキャストすると全て等しい数になります。にもかかわらず、数値文字列同士の比較では10進表記の桁数によって挙動が変わるということです。

まとめ

  • PHPで==を使って数値文字列同士を比較する場合、原則として数値比較されます
  • PHP5.4.4以降では、比較する数値文字列同士が大きくて両者が近すぎる場合には文字列比較になりました
    • 整数であれば-2^63-1以下または2^63以上(64bit環境)の場合
    • 浮動小数点数であれば-10^19以下または10^19以上(64bit環境)の場合
  • 比較演算子も影響を受けるので、ソート処理などにも注意が必要です

今まで特に悲鳴も聞こえてきていませんし、かなり大きい数にしか影響しないため、この変更による実害は無いだろうと考えています。とはいえ、こんなキモい修正について2年間誰も騒いでこなかったのはどうなのよ、とも思いますね。皆もう少し本体のソースコードを読みましょうよ。そう言う僕も2年間読んでなかったわけですけどね…。