PHP7のタイプヒントベストプラクティス

(Last Updated On: 2018年9月26日)

PHP 7から基本的なデータ型(整数型、浮動小数点型、配列型)タイプヒントが追加されます。直感的に書くコードと正しいコードには乖離があります。PHP7でタイプヒントを使う場合のベストプラクティスを紹介します。

タイプヒントとタイプヒントの問題点については前回のブログを参照してください。

PHP7タイプヒントの注意点

PHPはWebシステムで利用され、データベースやJSONなどの外部データとのやり取りが必要になります。PHP7のタイプヒントはデータ型変換を伴うので

  • 入力データの形式/表現範囲
  • PHPデータ型の表現範囲
  • タイプヒント/キャストの動作

に注意する必要があります

入力データの形式

データベースの場合、入力データは基本的に”文字列”になります。データベースのデータ型をPHPなどの言語のデータ型と完全に一致するとは限りません。このためデータを”文字列”として渡さないとデータ範囲の丸めやオーバーフロー/アンダーフローなどが発生し、データが失われる場合があります。

データベース

データベースレコードのIDには符号付き64 bit整数(BIGINT)が利用されることが多いです。しかし、符号付き64 bit整数以外にもNUMERIC/DECIMALも利用可能です。PostgreSQLは符号付き64 bit整数しかサポートしませんが、MySQLでは符号無し64 bit整数もサポートしています。

MariaDB [test]> CREATE TABLE test (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY (id));
Query OK, 0 rows affected (0.29 sec)

普通は符号付き64 bit整数IDで十分ではないか?と思われますが、最適化/高速化の為にハッシュ値が64 bitとなる値をIDとして利用するする場合、符号無し64 bit整数が使える環境では符号無し64 bit整数を利用すると自然です。

データベースのNUMERIC/DECIMALがかなり大きな値を表現できることは言うまでもありません。SQLiteの場合、型親和性で表現できない数値は”文字列”として保存され、どのように大きな数値でも正確に保存します。

JSON

JSONの場合も入力データは基本的に”文字列”であるべきですが、現在のjson_encode関数はデフォルトでPHPデータ型に変換します。PHPデータ型が変換される場合、データが失われる可能性があります。このため、JSON_BIGINT_AS_STRINGオプションがあります。

<?php
var_dump(json_decode('{"int":999999999999999999999}'));
var_dump(json_decode('{"int":999999999999999999999}', false, 512, JSON_BIGINT_AS_STRING));
var_dump(json_decode('{"int":9999999}', false, 512, JSON_BIGINT_AS_STRING));

出力

object(stdClass)#1 (1) {
  ["int"]=>
  float(1.0E+21)
}
object(stdClass)#1 (1) {
  ["int"]=>
  string(21) "999999999999999999999"
}
object(stdClass)#1 (1) {
  ["int"]=>
  int(9999999)
}

このように、JSONの整数型データは”int”、”float”、 “string”になります。

JSONの数値は特定のデータ型を想定していません。どのような数値でも記載可能です。

配列の整数キー

配列のキーにも注意が必要です。PHP配列は整数キーと文字列キーの両方をサポートします。整数キーの場合、オーバーフロー/アンダーフローに注意が必要です。

<?php
var_dump(["999999999999999999999"=>1, 999999999999999999999=>1]);

出力

array(2) {
  ["999999999999999999999"]=>
  int(1)
  [3875820019684212736]=>
  int(1)
}

このように整数形式文字列/リテラルは整数型に変換できる場合は整数値に変換されます。オーバーフロー/アンダーフローを起こさない”文字列”として取り扱うとプログラマの期待通りの動作になります。※

※ 32 bit環境では符号付き32 bit整数の範囲までしか、整数キーは正しく動作しません。

PHPデータ型の表現範囲

特定のアプリケーション用であれば、数値範囲に特定のデータ型を想定しても問題はありません。アプリケーションはどのような仕様でも自由に選択できます。しかし”汎用”ライブラリの場合、特定のデータ型を想定するべきではありません。

プログラマは以下の点に注意しなければなりません。

  • 32ビット環境ではint型は符号付き32 bit整数
  • 64ビット環境ではint型は符号付き64 bit整数
  • float型はIEEE754倍精度浮動小数点(符号付き53 bit整数まで正確に保存可能)
  • データベースの整数型IDなど、外部入力の数値(整数、浮動小数点)はPHPのネイティブデータ型で表現可能な範囲を大幅に超える値を利用できる

今時32 bit環境のことなんて考えなくても、と思うかも知れませんがIoTデバイスの多くは32 bitです。PHPは32 bit環境の方が若干速く動作するので、64 bit OSが利用可能なハードウェアでも敢えて32 bitで利用しているユーザーも居ると思います。

「32ビット環境ではint型は符号付き32 bit整数」となることを十分理解してコードを書く必要があります。データベースのIDなどは「数値」として取り扱うのではなく「文字列」として取り扱う必要があります。

PHPはデータ型を自動変換する点にも注意が必要です。演算でfloat型を利用すると、結果がint型の範囲に収まる場合でも、float型になります。

[yohgaki@dev php-src]$ ./php-bin -v
PHP 7.0.0-dev (cli) (built: Mar 27 2015 19:14:21) (DEBUG)
Copyright (c) 1997-2015 The PHP Group
Zend Engine v3.0.0-dev, Copyright (c) 1998-2015 Zend Technologies
[yohgaki@dev php-src]$ ./php-bin -r "var_dump(123.0 * 10);"
float(1230)
[yohgaki@dev php-src]$ ./php-bin -r "var_dump(123 * 10.0);"
float(1230)

つまり、64 bit環境でも符号付き53bit整数の範囲までのデータしか取り扱えません。

PHPのキャスト動作

PHP7のタイプヒントではパラメータのデータ型キャストが必要になります。しかし、PHPのキャスト はオーバーフローでエラーを発生しません。

[yohgaki@dev php-src]$ php -d error_reporting=-1 -r "var_dump((float)'9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999');"
float(INF)
[yohgaki@dev php-src]$ ./php-bin -d error_reporting=-1 -r "var_dump((int)'99999999999999999999999');"
int(9223372036854775807)

キャストした後にINF/-INFまたはPHP_INT_MAX/MINであれば、キャストでオーバーフロー/アンダーフローしたことを確認できます。(PHP_INT_MAX/MINの場合、オーバーフローとは限りませんが、オーバーフローしたと仮定して処理しても大きな問題にはならないです)

しかし、整数演算の場合でint型の範囲を超える時はfloat型へキャストされます。

[yohgaki@dev php-src]$ ./php-bin -d error_reporting=-1 -r "var_dump('99999999999999999999'*999);"
float(9.99E+22)
[yohgaki@dev php-src]$ ./php-bin -r 'var_dump($v = PHP_INT_MAX, ++$v);'
int(9223372036854775807)
float(9.2233720368548E+18)

int型の演算結果がfloat型になった場合、演算によってオーバーフロー/アンダーフローが発生したことが判ります。 32 bit環境では必ずしも数値情報が失われませんが、64 bit環境では確実に数値情報が失われたことを意味します。

またキャストでは文字列を含む数値形式のデータも数値としてエラーなしにキャストできる点にも注意が必要です。

[yohgaki@dev php-src]$ ./php-bin -d error_reporting=-1 -r '$v = "123 <script>alert(1)</script>"; var_dump((int)$v, $v);'
int(123)
string(29) "123 <script>alert(1)</script>"

演算で必ずint型の範囲を超える時はfloat型へキャストさるのではなく、例えば、ビットシフト演算の場合はint型が保持され、負の値になります。(コンピュータの整数は2の補数なので負になる)

[yohgaki@dev php-src]$ ./php-bin -r 'var_dump($v = PHP_INT_MAX, $v << 1);'
int(9223372036854775807)
int(-2)

PHP関数が返す値は必ずしも”符号付き”整数ではありません。例えば、ファイルオフセットなど(C言語のsize_t型など)は”符号無し”整数が保存されています。大きなファイルのオフセットはPHPから見ると”負”であっても、実際には”正”の値として処理されます。

演算には注意が必要です。32 bit環境で演算を行う関数の場合、PHP7のintタイプヒントを使うと符号付き32 bit整数の範囲までしか正しく演算できなくなります。タイプヒントなしなら符号付き53bit整数の範囲まで正しく演算できます。

ベストプラクティス

色々基礎知識を解説しましたが、守るべきベストプラクティスは以下のようになります。

  • int型は符号付き32 bit整数にもなることを明確に意識する
  • データベース、JSONなどの外部入力の数値はPHPのint/float型にキャストしない
  • これらの入力はタイプヒントに頼るのではなく、”string”型のデータとして取り扱いバリデーションする
  • 汎用ライブラリの場合、上記の3つは必須事項
  • キャストする場合、オーバーフロー/アンダーフローを検出しエラー/例外を発生させる(演算などで結果的にキャストされる場合含む)

例えば、Database::FindByIDのようなAPIを作る場合、

class Database {
    function(string $id):array {
        //浮動小数点形式が許可されるがOKとする
        if (!is_numeric($id)) {
            throw new Exception;
        }
        //IDでレコード検索
    }
}

のようなコードにしなければなりません。もし、stringでなくintタイプヒントを使用すると、32 bit環境では約20億までのIDしか正しく取り扱うことができません。

ポイントは

  • function(string $id):array // OK

整数形式のデータであってもstringタイプヒントを使うことです。

  • function(int $id):array // NG

ではPHPのint型がサポートしない大きな整数を正しく取り扱うことができません

32 bit環境で大きな数値のIDを取り扱うことはあまりない、と考えるかも知れません。しかし、JSONやリモートデータベースが返してくるIDは大きな数値であることは珍しくありません。環境を問わず「正しく」動作するコードを書くことは、特に汎用ライブラリで重要です。

32bit環境で大きな整数値(符号付き53 bit整数まで)の演算が必要な場合、タイプヒントを使用してはなりません。大きな整数値を扱う場合は

function some_calc($a, $b) {
   $r = $a * $b;
   if (is_float($r)) {
     throw new Exception;
   }
   return $r;
}

としてタイプヒント無しで定義しなければなりません。

  • function some_calc($a, $b): int // NG

とintタイプヒントを使うと結果が大幅に丸められる可能性があります。結果のみでなく、引数にもタイプヒントを使ってはなりません。

  • function some_calc(int $a, int $b) // NG

32 bit環境では整数の範囲が符号付き32 bit整数の精度にまで丸められてしまいます。

まとめ

PHP7で採用されたタイプヒントは、環境によって意図通りに動作する/動作しないプログラムを簡単に作ってしまいます。 汎用ライブラリを書く方は十分に注意してください。

採用されなかったタイプヒントなら今まで通りの動作であるため、このような事をあまり気にせず、従来通りのあまり型を気にしないコーディングができました。例えば、データベースのIDは通常は文字列型なので、文字列であっても整数形式ならintタイプヒントが利用でき、丸められることもありません。some_calc関数のような関数でも、intタイプヒント使っても従来通り符号付き53 bit整数の範囲までは正しく計算できました。

PHPは型を意識しない言語から、型を十分に意識しながらプログラミングしなければならない言語に生まれ変わります。IoTは当分、32bit環境が中心でしょう。32bit環境では使い物にならないライブラリだらけにならないことを願います。

関連:

エンジニア必須の概念 – 契約による設計と信頼境界線

投稿者: yohgaki