👽

PHP 8 になるまでの sort は相当ヤバい

2024/08/15に公開

サイボウズ Garoon開発チームの荒瀬です。
この記事では PHP 8 より前の sort 系関数に翻弄された私が感じた、そのヤバさをお伝えします。
そうっとしておくことはできなかったよ、sort だけにね、ガハハ。

PHP 8 より前の sort は何が問題だったのか

実際に問題となるコードの結果を見てもらえるとわかるかと思います。

<?php
// PHP 7.4.33
var_dump(11 < '12');   // true
var_dump('12' < 'a1'); // true
var_dump('a1' < 10);   // true
var_dump(10 < 11);     // true

こちらのリンクからも結果を確認できます。

このコードの結果を見てみると、結果が循環してしまい、矛盾が生じていることがわかります。
この結果をイラストにするとこうなります。

このように PHP 8 より前の比較の仕様はおかしいため、それを利用した sort もまたおかしな結果になるというわけです。

実際に sort を使用して検証してみましょう。

<?php
$arr1 = [11, 10, '12', 'a1'];
sort($arr1);
var_export($arr1);
echo PHP_EOL;

$arr2 = ['12', 'a1', 11, 10];
sort($arr2);
var_export($arr2);
echo PHP_EOL;

var_dump($arr1 === $arr2);

上記のコードの実行結果は以下のようになります。(こちらのリンクからも結果を確認できます。)

# PHP 7.4.33
array (
  0 => 10,
  1 => 11,
  2 => '12',
  3 => 'a1',
)
array (
  0 => '12',
  1 => 'a1',
  2 => 10,
  3 => 11,
)
bool(false)

要素の内容は一緒ですが、元の要素の順番によって結果が変わってしまっています。

なぜこのような結果になるのか

結論から言うと、PHP 8 より前では数値と文字列を比較する際、文字列を数値にキャストしてから比較しているためです。
上記の例では文字列は '12''a1' ですが、それぞれ int にキャストすると以下のようになります。

<?php
var_dump((int) '12'); // int(12)
var_dump((int) 'a1'); // int(0)

これにより 'a1'10 を比較する際には a1 が 0 とみなされます。
よって 'a1' < 10 の結果が true となり、矛盾が起きたというわけです。

もっと簡単に矛盾を表すこともできます。

<?php
// PHP 7.4.33
var_dump('hoge' == 0);   // true ← おかしくね?
var_dump(0 == '0');      // true
var_dump('hoge' == '0'); // false

これは以下のような厳密でない比較を行っている箇所全てで同じ仕様です。

  • ==, !=, >, >=, <, <= および <=>
  • switch
  • sort 系関数 (sort, rsort, asort など)で SORT_REGULAR を使用している場合
  • in_array(), array_search() および array_keys()strict = true を指定していない場合

これは sort 以外も相当ヤバい。

PHP 8.0 からどう変わったのか

PHP 8.0 からは、この問題が修正されました。
具体的には、ざっくり言うと「数値形式の文字列」と「数値」の比較の場合は従来通り文字列を数値にキャストしますが、それ以外の場合は数値を文字列にキャストしてから比較するように変更されました。

数値形式の文字列とは?

PHP の文字列は、 intfloat と解釈できる場合は数値と見なされ、「数値形式の文字列(numeric strings)」として扱われます。
具体的には '11', '000011', '11.0', '+11.0E0' などです。
またこれとは別に、「数値から始まる文字列(leading-numeric strings)」という概念も存在します。
これは数値形式の文字列から始まり、その後に任意の文字が続く文字列で、11a, 11 などが該当します。
これら以外の文字列は全て数値形式の文字列ではありません(non-numeric strings)。
より詳しい言語使用については公式のマニュアルおよび PHP の言語仕様ドキュメントを参照してください。
https://www.php.net/manual/ja/language.types.numeric-strings.php
https://github.com/php/php-langspec/blob/master/spec/05-types.md#the-string-type

これにより、先に列挙した厳密でない比較がより正しく行われるようになりました、イェイイェイ。

<?php
// PHP 8.0.0
var_dump(11 < '12');   // true
var_dump('12' < 'a1'); // true
var_dump('a1' < 10);   // false
var_dump(10 < 11);     // true
追記:それでも矛盾するパターン

全ての「数値」と「数値形式の文字列」で正しく比較ができるようになったわけではなく、以下のようなパターンではまだ矛盾があるように見えます。

<?php
// PHP 8.0.0
var_dump(11 < '12');   // true
var_dump('12' < '3a'); // true
var_dump('3a' < '4');  // true
var_dump('4' < 11);    // true

このように想像通りの結果とはならないケースもありますので、文字列と数値を比較する際はその結果をあらかじめ確認するようにした方が良さそうです。
このあたりは後日詳しく検証してみて、後述するPHPカンファレンス沖縄で発表しようと思います。
xでご指摘いただきました、ありがとうございます🙇

PHP 8.0 からの変更についてより詳しく知りたい方は PHP RFC: Saner string to number comparisons をご覧ください。[1]

プロダクトにどのような影響が出たのか

私が開発に関わっている Garoon では、PHP 8.0 にアップグレードした際に比較演算子の挙動変更の影響をもろに受けました。
PHP 8.0 アップグレード時の詳しい話については、以下のURLにて別のメンバーが登壇してお話ししたときの動画などが公開されておりますので、もしよろしければご覧ください。
https://fortee.jp/phpcon-2022/proposal/8f29f20e-1275-49eb-89c0-fe684e28d110
Garoon では PHP を自前でビルドしており、ビルド時に独自パッチを当てています。
PHP 8.0 からの比較演算子の挙動の変更の対策として、この独自パッチにより実際に比較演算子周りの挙動変更が検知された場合にはエラーを発生させるようにしています。

幸いなことに PHP 8.0 の時点では sort 系関数の仕様変更の影響はほとんどありませんでした。
しかし、PHP 8.2 に上げた際に、ksortkrsort を使用している箇所で比較演算子の挙動変更が検知され、独自パッチによりエラーが発生してしまいました。
ksortkrsort は配列のキーを基準に並び替えを行う関数です。
Garoon で特定の操作を行った際、配列のキーに数値と文字列が混在した連想配列が ksort を通してソートされる場合があったのです。

ちなみにこれが判明したのが PHP 8.2 対応バージョンのリリース1週間前くらいで、めちゃくちゃ焦りました😭
この挙動の変更はプロダクト自体の動作には影響しなかったこと、およびほかの箇所ではエラーが発生していなかったことから、最終的には独自パッチを編集し ksortkrsort の挙動変更ではエラーを発生させない、という修正方針で落ち着き、無事にリリースすることができました。
配列のキーに数値と文字列が混ざるような実装には危機感持った方がいい。厳しいって(厳しかった)。

なぜ ksortkrsort だけ PHP 8.2 で修正されたのか

RFC: Make sorting stableでは ksortkrsort も仕様変更の対象でしたが、実装モレがあったようで、PHP 8.0 では対応されなかったようです。
似たような関数である uksort (ユーザー定義の比較関数を用いて、キーで配列をソートする sort 関数)は PHP 8.0 の時点で変更されていました。
この問題はこのまま見つからずに PHP 8.1 リリース時にも放置されていましたが、こちらのPRでバグとして修正され、PHP 8.2 から ksortkrsort もそのほかの sort 関数と同じ挙動となりました。

おわりに

以上、PHP 8 になるまでの sort のヤバさについてお伝えしました。
PHP のアップグレードは仕様変更という痛みを伴いますが、バージョンを重ねるごとにかなり良くなってきており、その恩恵も大きいです。
まだ PHP 8 に移行していないプロダクトがありましたら、ぜひ移行を検討してみてください!

なおこのブログの内容は2024年9月28日(土)開催の「PHPカンファレンス沖縄2024」でも発表いたします。[2]
サイボウズはスポンサーとして協賛しており、私含め弊社のメンバーも多数参加いたします!
みなさんぜひ沖縄でお会いしましょう!
https://phpcon.okinawa.jp/

脚注
  1. rana_kualu さんによる日本語訳がとてもわかりやすいです。こちら大いに参考になりました、感謝いたします👏
    【PHP8.0】非厳密な比較演算子==の挙動が今さら変更になる ↩︎

  2. 登壇発表内容はこのブログから変更になる可能性があります、お楽しみに👋 ↩︎

Discussion