PHPのis_numericは1e3や末尾スペースも数値と判定する(int)キャストとの食い違いとfilter_var(FILTER_VALIDATE_INT)での整数チェック

PHPのis_numericは「数値として解釈できる文字列か」を調べる関数で、整数かどうかは判定しません。”1e3″、”.5″、”12.5″、PHP 8.0以降は末尾に空白のある”12 “もtrueになるため、IDやページ番号のチェックにそのまま使うと意図しない値が通ります。

クエリパラメータの ?page=2 を受けて、is_numericで確認してから(int)でキャストする。よく見る書き方ですし、私も何度も書いてきました。ただこの2つは「数値っぽい」の基準が微妙にずれていて、そのずれがそのまま穴になります。

is_numericは何をtrueにするのか

先頭の空白、符号、小数点、指数表記はOKです。一方で16進の”0x1A”、2進の”0b11″、桁区切りの”1_000″、全角数字はfalseになります。実際に動かしてみます。

<?php
foreach (['12', ' 12', '12 ', '12abc', '1e3', '0x1A', '.5', '5.', '', '+7', '12.5'] as $s) {
    echo json_encode($s), ' => ', var_export(is_numeric($s), true), "\n";
}
// "12" => true
// " 12" => true
// "12 " => true     (PHP 8.0以降。7.x ではfalse)
// "12abc" => false
// "1e3" => true
// "0x1A" => false
// ".5" => true
// "5." => true
// "" => false
// "+7" => true
// "12.5" => true

末尾空白の扱いは、PHP 8.0の「Saner numeric strings」で変わった部分です。それまでは先頭の空白だけが許されていて、末尾は許されませんでした。バージョンをまたぐ移行では、ここで挙動差が出ることがあります。

(int)キャストはis_numericがfalseでも数値を返す

キャストは「先頭から読める分だけ読む」ので、”12abc”は12になります。is_numericがfalseの文字列でも、キャスト結果は0とは限りません。逆に”.5″はis_numericがtrueですが、整数にすると0です。

<?php
var_dump((int)'12abc');  // int(12)
var_dump((int)'1e3');    // int(1000)
var_dump((int)'.5');     // int(0)
var_dump((int)'12.5');   // int(12)
var_dump((int)'1_000');  // int(1)
var_dump(intval('0x1A', 16)); // int(26)
var_dump(intval('0x1A', 0));  // int(26)

“1_000″が1になるのは、アンダースコアのところで読み取りが止まるからです。ソースコード上の数値リテラル 1_000 は1000ですが、文字列の中では効きません。入力欄にカンマやアンダースコアが混ざる国内の帳票系では、これで静かに値が欠けることがあります。

整数だけを通したいときはどう書く?

「整数の文字列だけ許可する」が目的なら、filter_varのFILTER_VALIDATE_INTが素直です。通ったときは整数、だめならfalseが返ります。前後の空白は取り除かれ、先頭ゼロや指数表記、小数は弾かれます。

<?php
var_dump(filter_var('12', FILTER_VALIDATE_INT));    // int(12)
var_dump(filter_var(' 12 ', FILTER_VALIDATE_INT));  // int(12)
var_dump(filter_var('1e3', FILTER_VALIDATE_INT));   // bool(false)
var_dump(filter_var('12.5', FILTER_VALIDATE_INT));  // bool(false)
var_dump(filter_var('012', FILTER_VALIDATE_INT));   // bool(false)

$page = filter_var($_GET['page'] ?? '', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 1000],
]);
if ($page === false) {
    $page = 1;
}

戻り値が0のときもあり得るので、判定は必ず === false で行います。if (!$page) と書くと、0を許可している場面で有効な値を捨ててしまいます。範囲指定をオプションで一緒に書けるのも、地味に便利なところです。

緩い比較でも数値文字列は同じ顔をする

PHP 8では “abc” == 0 がfalseになりましたが、両方とも数値文字列なら数値として比べられます。”10″ == “1e1” や 100 == “1e2” はtrueです。IDやトークンを==で突き合わせていると、桁の違う文字列が一致することがあるので、文字列同士の同一性が欲しいなら === を使います。

<?php
var_dump('abc' == 0);     // bool(false)  PHP 8以降
var_dump('1' == '01');    // bool(true)
var_dump('10' == '1e1');  // bool(true)
var_dump(100 == '1e2');   // bool(true)
var_dump('10' === '1e1'); // bool(false)

緩い比較のルール変更は別の記事でも書きましたが、数値文字列が絡む部分は8でも変わっていません。

演算に渡すと警告か例外になる

“12abc” + 1 のような先頭だけ数値の文字列は、PHP 8では13になるものの警告(Warning)が出ます。まったく数値でない文字列を演算に使うとTypeErrorです。警告で済んでいるうちは動いてしまうので、ログに積み上がって初めて気づくパターンが多いです。

まとめ

is_numericは「数値として読めるか」、(int)は「先頭から読めるだけ読む」、FILTER_VALIDATE_INTは「整数としてきちんと書かれているか」を見ていて、基準が三者三様です。整数のチェックが目的なら最初からfilter_varに寄せておくのが、いちばん事故が少ないという気がしています。

よくある質問

Q. is_numericで”12 “(末尾に空白)はtrueになりますか?
A. PHP 8.0以降はtrueです。PHP 7.x ではfalseでした。先頭の空白は以前からtrueです。

Q. is_numericで整数かどうかを判定できますか?
A. できません。”12.5″や”1e3″もtrueになります。整数だけ通したいときはfilter_var($v, FILTER_VALIDATE_INT)を使い、結果を === false で判定します。

Q. (int)”1e3″ の結果はいくつですか?
A. 1000です。数値文字列として読めるので指数表記も展開されます。一方 (int)”12abc” は先頭の12だけを読んで12になります。

類似投稿

コメントを残す

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