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」などの文字表記です。