PHPのctype_digitにintを渡すとfalseになる理由 ― is_numericとの使い分け

ctype_digitとは、文字列が数字だけで構成されているかを調べるPHPの関数です。ただしint型の値を渡すと、文字コードとして解釈されるため思わぬ結果になります。

「IDっぽい値を受け取ったらctype_digitで数字チェック」というのは、フォームやAPIのバリデーションでよく見るコードです。$_GETや$_POSTから来る値は常に文字列なので、これ自体は正しく動きます。ただ、同じチェック処理を別の場所――たとえばすでにint化されたルートパラメータやデフォルト値――に使い回した瞬間、静かに壊れることがあります。

ctype_digit(42)がfalseになるのはなぜか

試しにこう書いてみます。

<?php
var_dump(ctype_digit('42'));  // bool(true)
var_dump(ctype_digit(42));    // bool(false)

同じ「42」のつもりなのに、文字列だとtrue、intだとfalseになります。これはバグではなく、マニュアルに書かれている仕様です。

整数は「文字コード」として解釈される

ctype_digitに-128〜255の範囲のintを渡すと、その数値はASCIIコードとして扱われます。42はASCIIコードで見ると記号の「*」なので、数字ではないと判定されてfalseになる、というわけです。

<?php
var_dump(ctype_digit(48));    // bool(true)  ASCIIの48は文字'0'
var_dump(ctype_digit(57));    // bool(true)  ASCIIの57は文字'9'
var_dump(ctype_digit(0));     // bool(false) ASCIIの0はNUL文字
var_dump(ctype_digit(256));   // bool(true)  範囲外なので「256」という文字列として扱われる

256を渡すと今度はtrueになる、というのがさらにややこしいところです。-128〜255の範囲だけ特別扱いで、それより外は普通に「数字の並びを表す文字列」として解釈されます。ctype系の関数はもともとCのctype.hに由来していて、1文字をintのASCIIコードで受け取る作法を引きずっているんですね。歴史的経緯があるとはいえ、呼ぶ側からすると罠でしかありません。

PHP 8.1以降は警告も出るようになった

この挙動があまりに事故りやすいからか、PHP 8.1.0からはctype_digitに文字列以外の値を渡すと非推奨の警告が出るようになりました。マニュアルでは、intを渡すなら明示的に文字列へキャストするか、chr()を使って意図をはっきりさせるよう案内されています。

<?php
ctype_digit((string) $id);  // 「42」という中身を数字として調べたい場合
ctype_digit(chr($id));      // ASCIIコードとして扱いたい場合(実務ではまず使わない)

実務でchr()側の使い道に出会うことはまずないと思います。ctype_digitを使うなら、引数は素直に文字列へキャストしておく、それだけで事故はほぼ防げます。

is_numericなら同じ書き方で困らないのか

「じゃあ最初からis_numericを使えばいいのでは」と思うかもしれません。is_numericはintを渡してもASCII解釈にはならず、素直に数値かどうかを見てくれます。

<?php
var_dump(is_numeric(42));     // bool(true)
var_dump(is_numeric('42'));   // bool(true)
var_dump(is_numeric('-3.5')); // bool(true)
var_dump(is_numeric('1e10')); // bool(true)

ただしis_numericは「数値として計算できる形かどうか」を見る関数なので、負の数も小数も指数表記も全部通ります。ページ番号やDBの主キーのように「符号なしの整数の並びだけを許可したい」場面では、is_numericだけでは緩すぎます。ctype_digitとis_numericは似た用途で語られがちですが、そもそも見ているものが違う、と考えたほうが分かりやすい気がしています。

実務ではどう書き分けるか

私が実際に書くときは、こんなふうに使い分けています。$_GETや$_POSTのように「必ず文字列で来る」と分かっている値の桁チェックにはctype_digitを、計算に使う値が数値として妥当かどうかの確認にはis_numericを使う、という具合です。

<?php
// URLの /users/{id} のようなパスパラメータを想定
function isValidUserId(string $id): bool
{
    // 空文字はfalseになるので、別途チェックは不要
    return ctype_digit($id);
}

引数の型をstringで固定してしまえば、呼び出し側が誤ってintを渡すこと自体をPHPの型検査で防げます。ctype_digitのASCII解釈が問題になるのは、だいたい「本当は文字列のはずが、いつの間にかintになっていた」というケースなので、入り口で型を固定しておくのが一番効きます。範囲チェックまで込みで検証したいなら、filter_varのFILTER_VALIDATE_INTにmin_range/max_rangeを添える方法もあります。

まとめ

ctype_digitは文字列の中身を見る関数で、intを渡すと文字コードとして解釈されるためfalseになったりtrueになったりします。原因を知らないと再現条件が分かりにくく、たちの悪いバグになりがちです。PHP 8.1以降は警告も出るようになったので、これを機に引数は文字列で渡す癖をつけておくと安心だと思います。

よくある質問

Q. ctype_digit(”)はどうなりますか?
A. falseになります。空文字列は「数字だけで構成されている」とは判定されないので、別途空文字のチェックを重ねる必要はありません。

Q. ctype_digitは負の数や小数を判定できますか?
A. できません。ctype_digitが見ているのは0-9の数字だけで、マイナス記号や小数点が混ざると即falseになります。符号や小数を含めて数値かどうか調べたいときはis_numericを使います。

Q. is_numericだけを使えばctype_digitは不要ですか?
A. 用途によります。is_numericは負の数や小数、指数表記まで数値として認めるので、ページ番号やIDのように「符号なしの整数の並びだけ」を許可したい場面では緩すぎます。そこはctype_digit(文字列にキャストした上で)のほうが向いています。

類似投稿

コメントを残す

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