PHP:文字列が整数かの検証にはis_numeric, ctype_digit, filter_varどれが適切か？

ユーザ入力の文字列が整数かどうかを検証するにあたって、`is_numeric`や`ctype_digit`は適切ではなく、アプリケーションの仕様によっては`filter_var`も妥当でない場合がある。CakePHP3のバリデーションライブラリについても問題点があることが明らかになった。なお、Symfonyは`is_int`や`is_numeric`、`ctype_digit`などに移譲される実装になっており、Laravelは`filter_var`に依存しているため事情は同様である。

結論としては、`filter_var`がアプリケーションの仕様と合致する場合はそれを使い、そうでない場合は正規表現で検証するか組み合わせるほうが良い。CakePHP3のバリデーションライブラリを使うときも同様である。ちなみに、文字列に対して`is_int`や`intval`をかけることは何の検証にもならないので注意されたし。

参考までに`is_numeric`、`filter_var`、`ctype_digit`そしてCakePHPのバリデーターと評価結果の関係性をまとめた表を示す。背景色が緑のところが`true`が返るパターンだ。加えて、参考までに`intval`の振る舞いも示す。整数文字表現はさまざまな形式が考えれるが、本稿では下記図表の「理想」にて「o」で示した値を整数、それ以外を非整数として扱う。

![](https://qiita-image-store.s3.amazonaws.com/0/889/059f6b46-63a6-fbdd-74e1-d436c7c72a34.png)

## `is_numeric`

`is_numeric`は「数字かどうか」をチェックする関数であり、整数に限られない。小数も`true`になるだけでなく、指数表記や`PHP_INT_MAX`を超える数も`true`扱いである。したがって、「整数かどうか」をチェックする場面では適切ではない。

```php
assert(is_numeric('0'));
assert(is_numeric('1'));
assert(is_numeric('1 ') === false); // 1と半角スペース
assert(is_numeric('a') === false);
assert(is_numeric('0.0'));
assert(is_numeric('.123'));
assert(is_numeric('123.'));
assert(is_numeric('-1'));
assert(is_numeric('-1.0'));
assert(is_numeric('+1'));
assert(is_numeric('+1.0'));
assert(is_numeric('042')); // 8進数
assert(is_numeric('08')); // 0で始まる10進数「8」
assert(is_numeric('1e2')); // 指数表現で100
assert(is_numeric('1.001e2')); // 指数表現100.1
assert(is_numeric('1e+2')); // 指数表現で100
assert(is_numeric('1e-2')); // 指数表現で0.01
assert(is_numeric('0xA') === false); // 16進数で10
assert(is_numeric('9223372036854775807')); // PHP_INT_MAX
assert(is_numeric('9223372036854775808')); // PHP_INT_MAX + 1
assert(is_numeric('-9223372036854775808')); // PHP_INT_MIN
assert(is_numeric('-9223372036854775809')); // PHP_INT_MIN - 1
assert(is_numeric('INF') === false);
assert(is_numeric('NAN') === false);
```

## `ctype_digit`

`ctype_digit`は「数字かどうかを調べる」関数である。文字通り文字列が半角数字文字`'0'`~`'9'`だけで構成されているかをチェックするものである。負の数は`false`になる。したがって、「整数かどうか」をチェックする場面では適切ではない。

```php
assert(ctype_digit('0'));
assert(ctype_digit('1'));
assert(ctype_digit('1 ') === false); // 1と半角スペース
assert(ctype_digit('a') === false);
assert(ctype_digit('0.0') === false);
assert(ctype_digit('.123') === false);
assert(ctype_digit('123.') === false);
assert(ctype_digit('-1') === false);
assert(ctype_digit('-1.0') === false);
assert(ctype_digit('+1') === false);
assert(ctype_digit('+1.0') === false);
assert(ctype_digit('042')); // 8進数
assert(ctype_digit('08')); // 0で始まる10進数「8」
assert(ctype_digit('1e2') === false); // 指数表現で100
assert(ctype_digit('1.001e2') === false); // 指数表現100.1
assert(ctype_digit('1e+2') === false); // 指数表現で100
assert(ctype_digit('1e-2') === false); // 指数表現で0.01
assert(ctype_digit('0xA') === false); // 16進数で10
assert(ctype_digit('9223372036854775807')); // PHP_INT_MAX
assert(ctype_digit('9223372036854775808')); // PHP_INT_MAX + 1
assert(ctype_digit('-9223372036854775808') === false); // PHP_INT_MIN
assert(ctype_digit('-9223372036854775809') === false); // PHP_INT_MIN - 1
assert(ctype_digit('INF') === false);
assert(ctype_digit('NAN') === false);
```

## `filter_var`

`filter_var`は値のフィルタリングを目的とする関数であり、検証に特化した関数というわけではない。第二引数に`FILTER_VALIDATE_INT`を渡すことで、フィルタリングに成功すると`int`型が、失敗すると`false`が返る。この仕様を利用して、値の検証に使われることがある。

```php
assert(filter_var('0', FILTER_VALIDATE_INT) === 0);
assert(filter_var('A', FILTER_VALIDATE_INT) === false);
```

しかし、`filter_var`には空白文字をtrimする性質があり`'1 '`(1と半角スペース)については`int(1)`を返す。この性質が許容できる場合は`filter_var`関数単体で十分と言えるが、そうでない場合はこれ単体での使用はおすすめできない。

```php
assert(filter_var('1 ', FILTER_VALIDATE_INT) === 1);
```

また、正の整数の表現に`+`をつけることが許される。`string("+1")`のような入力の検証結果を妥当としたくない場合は、正規表現や`strpos`関数などで追加のチェックをする必要がある。

```php
assert(filter_var('+1', FILTER_VALIDATE_INT) === 1);
```

ちなみに、Laravelのバリデーターは`filter_var`を使った実装になっているため、このケースと全く同じことが言える。LaravelのGitHubでは[空白が入っている整数の場合は`false`を返すようにして変更ほしいというプルリクエスト](https://github.com/laravel/framework/pull/13654)が送られたことがあったが、下位互換性が壊れるという理由で既存のバリデーターを変更することは却下されている。

次のコードは`filter_var`の仕様を示す:

```php
$filter_var = function (string $value): bool {
    return filter_var($value, FILTER_VALIDATE_INT) !== false;
};

assert($filter_var('0'));
assert($filter_var('1'));
assert($filter_var('1 ')); // 1と半角スペース
assert($filter_var('a') === false);
assert($filter_var('0.0') === false);
assert($filter_var('.123') === false);
assert($filter_var('123.') === false);
assert($filter_var('-1'));
assert($filter_var('-1.0') === false);
assert($filter_var('+1'));
assert($filter_var('+1.0') === false);
assert($filter_var('042') === false); // 8進数
assert($filter_var('08') === false); // 0で始まる10進数「8」
assert($filter_var('1e2') === false); // 指数表現で100
assert($filter_var('1.001e2') === false); // 指数表現100.1
assert($filter_var('1e+2') === false); // 指数表現で100
assert($filter_var('1e-2') === false); // 指数表現で0.01
assert($filter_var('0xA') === false); // 16進数で10
assert($filter_var('9223372036854775807')); // PHP_INT_MAX
assert($filter_var('9223372036854775808') === false); // PHP_INT_MAX + 1
assert($filter_var('-9223372036854775808')); // PHP_INT_MIN
assert($filter_var('-9223372036854775809') === false); // PHP_INT_MIN - 1
assert($filter_var('INF') === false);
assert($filter_var('NAN') === false);
```

## CakePHP3のバリデーター

CakePHP3のバリデーターで整数かどうかをチェックしてくれるのが[Cake\Validation\Validation::isInteger()](https://api.cakephp.org/3.6/class-Cake.Validation.Validation.html#_isInteger)メソッドだ。`filter_var`と近いふるまいをするが、8進数や`PHP_MAX_INT`などは考慮されていないため、これ単体だけでバリデーションするのは避けたほうが良い。

```php
/**
 * Check that the input value is an integer
 * This method will accept strings that contain only integer data
 * as well.
 * @param string $value The value to check
 * @return bool
 */
function cakephp($value) // ライブラリから抜粋した実装
{
    if (!is_scalar($value) || is_float($value)) {
        return false;
    }
    if (is_int($value)) {
        return true;
    }
    return (bool) preg_match('/^-?[0-9]+$/', $value);
}

assert(cakephp('0'));
assert(cakephp('1'));
assert(cakephp('1 ') === false); // 1と半角スペース
assert(cakephp('a') === false);
assert(cakephp('0.0') === false);
assert(cakephp('.123') === false);
assert(cakephp('123.') === false);
assert(cakephp('-1'));
assert(cakephp('-1.0') === false);
assert(cakephp('+1') === false);
assert(cakephp('+1.0') === false);
assert(cakephp('042')); // 8進数
assert(cakephp('08')); // 0で始まる10進数「8」
assert(cakephp('1e2') === false); // 指数表現で100
assert(cakephp('1.001e2') === false); // 指数表現100.1
assert(cakephp('1e+2') === false); // 指数表現で100
assert(cakephp('1e-2') === false); // 指数表現で0.01
assert(cakephp('0xA') === false); // 16進数で10
assert(cakephp('9223372036854775807')); // PHP_INT_MAX
assert(cakephp('9223372036854775808')); // PHP_INT_MAX + 1
assert(cakephp('-9223372036854775808')); // PHP_INT_MIN
assert(cakephp('-9223372036854775809')); // PHP_INT_MIN - 1
assert(cakephp('INF') === false);
assert(cakephp('NAN') === false);
```

## `intval`

`intval`は検証のための関数でないため、検証に使うのは不適切だが、参考までにそのふるまいを示す。

```php
assert(intval('0') === 0);
assert(intval('1') === 1);
assert(intval('1 ') === 1); // 1と半角スペース
assert(intval('a') === 0);
assert(intval('0.0') === 0);
assert(intval('.123') === 0);
assert(intval('123.') === 123);
assert(intval('-1') === -1);
assert(intval('-1.0') === -1);
assert(intval('+1') === 1);
assert(intval('+1.0') === 1);
assert(intval('042') === 42); // 8進数
assert(intval('08') === 8); // 0で始まる10進数「8」
assert(intval('1e2') === 100); // 指数表現で100
assert(intval('1.001e2') === 100); // 指数表現100.1
assert(intval('1e+2') === 100); // 指数表現で100
assert(intval('1e-2') === 0); // 指数表現で0.01
assert(intval('0xA') === 0); // 16進数で10
assert(intval('9223372036854775807') === PHP_INT_MAX); // PHP_INT_MAX
assert(intval('9223372036854775808') === PHP_INT_MAX ); // PHP_INT_MAX + 1
assert(intval('-9223372036854775808') === PHP_INT_MIN); // PHP_INT_MIN
assert(intval('-9223372036854775809') === PHP_INT_MIN); // PHP_INT_MIN - 1
assert(intval('INF') === 0);
assert(intval('NAN') === 0);
```

## 所感

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">PHPらしいさがあるが、厳密に整数かどうかをチェックしたいときは、is_numericはもちろんのことfilter_var、ctype_digitは避け、正規表現を使うか併用するのが良さそうです。自力で実装した際には、ここで示した入力値はPHPUnitのテストケースにぜひ組み込んでください。<a href="https://t.co/GwGTqBvBm9">https://t.co/GwGTqBvBm9</a></p>&mdash; ❄️suin (@suin) <a href="https://twitter.com/suin/status/1030824994178125824?ref_src=twsrc%5Etfw">2018年8月18日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 関連

* [PHPのis_numeric関数は使うべきでないという話 - hnwの日記](https://hnw.hatenablog.com/entry/20180414) - _“私がこの関数について問題だと思うのは、「numeric」と言われたときに想像するものが人によって違う点です。”_