PHPのversion_compareは1.0と1.0.0が等しくならず、vを付けると古い扱いになる

version_compare()は、バージョン番号の文字列を「PHP標準の書式」で比較し、-1・0・1のいずれかを返す関数です。見た目は素直ですが、先頭のvや「1.0」と「1.0.0」の扱いなど、SemVerの感覚で使うとずれる箇所がいくつかあります。

プラグインの互換チェックや、設定ファイルのバージョン判定でさらっと使われる関数ですが、私は「v1.2.0」を渡したときの結果を見て、しばらく固まったことがあります。仕様を知っていれば避けられる種類のハマりどころなので、手元のPHP 8.3で実際に動かした結果をまとめておきます。

バージョンを比較演算子で比べるとなぜ壊れるのか

文字列として < や > で比べると、数値としては扱われず1文字ずつの比較になります。そのため、桁数が違った瞬間に順序が逆転します。

<?php
var_dump('8.3.6' < '8.10.0');                  // false (文字列として比較され、'3' > '1' で止まる)
var_dump(version_compare('8.3.6', '8.10.0', '<')); // true
var_dump(version_compare('1.10.0', '1.9.0'));     // 1 (1.10.0のほうが新しい)

version_compare()はドットで区切った各部分を整数として比べてくれるので、10は9より大きい、という当たり前の結果になります。バージョン文字列を演算子で直接比べているコードを見つけたら、まず疑ってよいと思います。

演算子つきの書き方と、無効な演算子で何が起きるのか

第3引数に演算子を渡すと、-1/0/1ではなくboolが返ります。条件分岐ではこちらを使うほうが読みやすいです。「>=」のような記号のほか、「ge」「lt」のような文字の書き方も使えます。

<?php
if (version_compare(PHP_VERSION, '8.1.0', '>=')) {
    // 8.1以降向けの処理
}

var_dump(version_compare('8.3.6', '8.1.0', 'ge')); // true

try {
    version_compare('1.0', '1.0', '=>'); // 「>=」の書き間違い
} catch (ValueError $e) {
    echo $e->getMessage();
    // version_compare(): Argument #3 ($operator) must be a valid comparison operator
}

手元のPHP 8.3では、書き間違えた演算子はValueErrorになりました。黙ってnullが返るよりずっと気づきやすいです。ただ、-1/0/1を返す形と取り違えて、戻り値をそのままif文に入れてしまうミスは起きやすいので、演算子つきで書くかどうかは統一しておくのが無難です。

「1.0」と「1.0.0」が等しくならないのはなぜか

桁数が違うバージョンは、足りない部分があるほうが古いものとして扱われます。「5.2」は「5.2.0」より小さい、という結果です。

<?php
var_dump(version_compare('1.0', '1.0.0'));         // -1
var_dump(version_compare('1.0', '1.0.0', '=='));   // false
var_dump(version_compare('1.2.3', '1.2.03'));      // 0 (先頭の0は無視される)

ユーザーが入力したバージョンと、設定側の「1.0.0」を==で突き合わせる場面では、これで引っかかります。比べる前に、双方を「メジャー.マイナー.パッチ」の3桁にそろえてしまうのが安全です。なお、先頭の0が無視されるので「1.2.03」と「1.2.3」は同じ扱いになります。

先頭のvを付けたままだと古い扱いになる

Gitのタグ名をそのまま渡すと、ここで事故が起きます。「v1.2.0」と「1.2.0」を比べると、vが付いているほうが小さい結果になりました。

<?php
var_dump(version_compare('v1.2.0', '1.2.0')); // -1
var_dump(version_compare('1.2.0', 'v1.2.0')); // 1

// タグ名はvを外してから比べる
var_dump(version_compare(ltrim('v1.2.0', 'vV'), '1.2.0')); // 0

どちらも同じバージョンを指しているはずなのに、等しくならないのが厄介です。vを付けたまま新旧を判定すると、新しいタグが古い扱いになって更新が検知されない、という地味な不具合になります。入口で一度だけ正規化しておくと、あとの比較が楽になります。

プレリリース表記はどの順番で並ぶのか

version_compare()は、dev・alpha・beta・RC・数字・pl(patch level)という特別な語を、この順の大小で扱います。実際に並べてみると、次のようになりました。

<?php
$vs = ['1.0.0', '1.0.0-rc1', '1.0.0-beta', '1.0.0-alpha', '1.0.0-dev', '1.0.0-pl1', '1.0.0-1'];
usort($vs, 'version_compare');
echo implode(' < ', $vs);
// 1.0.0-dev < 1.0.0-alpha < 1.0.0-beta < 1.0.0-rc1 < 1.0.0 < 1.0.0-1 < 1.0.0-pl1

「1.0.0-beta」は正式版の「1.0.0」より古く、「pl」が付いたものは正式版より新しい、という並びです。SemVerの「-」以降がプレリリースという考え方とはほぼ一致しますが、完全には同じではありません。たとえば「1.0.0-1」のような数字だけの接尾辞は、SemVerではプレリリース扱いで正式版より古くなりますが、ここでは新しい側に来ました。

また、辞書にない語は、devよりさらに古い扱いです。「1.0.0-foo」は「1.0.0-dev」より小さい結果でした。独自のラベルを付けている場合は、順序が思ったとおりにならない可能性があります。

「+build」のようなメタデータは無視されない

SemVerでは「+」以降のビルドメタデータは優先順位に影響しません。ところがversion_compare()には、そういう特別扱いがありません。

<?php
var_dump(version_compare('2.0.0+build5', '2.0.0'));              // -1
var_dump(version_compare('1.0.0+build5', '1.0.0+build9'));       // -1
var_dump(version_compare('1.0.0-beta.2', '1.0.0-beta.11'));      // -1

「2.0.0+build5」が「2.0.0」より古い、という結果になります。SemVerの厳密な比較が必要なら、「+」以降を切り落としてから渡すか、Composerのsemver実装(composer/semver)を使うほうが確実だと思います。ちなみに「beta.2」と「beta.11」は、ドット区切りの数字が整数として比較されるので、期待どおり11のほうが新しい扱いになります。

まとめ

version_compare()は、文字列比較より圧倒的に信頼できますが、「1.0」と「1.0.0」の差、先頭のv、「+」以降の扱いの3つは、SemVerの感覚と少しずれます。比較の前にvを外して桁数をそろえる、という小さな正規化関数を1つ置いておくだけで、だいぶ事故は減る気がしています。

よくある質問

Q. version_compareで「1.0」と「1.0.0」は等しくなりますか?
A. なりません。足りない桁があるほうが古い扱いになり、version_compare(‘1.0’, ‘1.0.0’) は -1 を返します。比較前に3桁にそろえてください。

Q. 先頭にvが付いたバージョン(v1.2.0)はそのまま比較できますか?
A. 手元のPHP 8.3では、「v1.2.0」は「1.2.0」より小さい結果になりました。ltrim($v, ‘vV’) のようにvを外してから比べるのが安全です。

Q. 「>=」などの演算子を書き間違えるとどうなりますか?
A. 手元のPHP 8.3では、ValueErrorが投げられます。有効な演算子は「<」「<=」「>」「>=」「==」「!=」と、「lt」「le」「gt」「ge」「eq」「ne」などの文字表記です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です