PHPにジェネリクスは入るのか?
PHPの型宣言
PHPは言語仕様として型宣言があります。
PHP 5.x時代までは型ヒント(type hinting)と呼ばれていましたが、2015年12月にリリースされたPHP 7.0からは正式に型宣言(type declaration)になりました。
型宣言とPHPDoc
PHPでは関数やクラス、プロパティなどの上の /** ... */
で括られたコメント(DocComment)の中に型を記述する慣習があります。
/**
* @param int|false $a
* @param int|false $b
* @return int|false
*/
function add($a, $b)
{
return $a + $b;
}
一方で、PHP 7.0以降では以下のように関数の引数と戻り値に型を記述できるようになりました。
function add(int $a, int $b): int
{
return $a + $b;
}
この記事では、特に断りのない限り、PHPDocに書く型を「型注釈 (type annotation)」、引数や戻り値にそのまま書く型を「型宣言 (type declaration)」と呼ぶことにします。
型注釈は所詮はコメントなので、基本的には[1]PHPスクリプトの動作に影響を及ぼすことはありません。
型宣言できる場所
- 引数
- 戻り値 (PHP 7.0)
- プロパティ (PHP 7.4)
ローカル変数に型を宣言する方法は現時点ではありませんが、PHP 7.4でプロパティへの型宣言を提案したPHP: rfc:typed_properties_v2では変数参照による型付けが示唆されており、sj-iさんによってこれを一般化したtypistというライブラリを公開していらっしゃいます。この背景についてはPHP で型付のローカル変数を定義するライブラリを作った - Qiitaが詳しいです。
かつてPHP 5.x時代にはSPL_Typesという仕組みがあり、C拡張によってローカル変数に型を付けることができました。prefixの SPL とは Standard PHP Libraryのことですが、現実には一般的に利用されておらず、現在ではメンテナンスもされていません。
型宣言できる型の種類
2020年12月にリリースされた8.0であっても、実はPHPの全てのデータ型が型宣言できるわけではありません。
PHP 8.0までに型宣言に使える型には以下のものがあります。
- クラス名
-
array
配列型 self
parent
-
callable
擬似型 - スカラー型 (PHP 7.0以降)
-
bool
真理値型 -
float
浮動小数点数型 -
int
整数型 -
string
文字列型
-
-
?
nullable型 -
iterable
擬似型 (PHP 7.1以降) -
void
擬似型- 関数/メソッドの戻り値にのみ記述可能
-
object
型 (PHP 7.2以降) -
mixed
擬似型 (PHP 8.0以降) -
|
ユニオン型 (PHP 8.0以降)- ユニオン型の一部としてのみ
false
null
を記述可能
- ユニオン型の一部としてのみ
型宣言できない型 (特殊型)
特殊型と呼ばれている二種類の型の値は単独で型宣言できません
-
resource
リソース型 -
null
ヌル型- PHP 7.1以降はnullable型
?Foo
として記述可能 - PHP 8.0以降はユニオン型の一部
Foo|Bar|null
として記述可能
- PHP 7.1以降はnullable型
resource
は特殊な型であり、PHP: リソース型の一覧 - Manualで列挙されている通り多様な実態があり、単一の型として扱うことは必ずしも妥当ではありません。
型宣言できる型の関係 (広い/狭い)
PHPで型宣言できる型の広い/狭いとは以下のような関係です。
-
mixed
擬似型は全てのデータ型よりも広い (トップ型) -
float
浮動小数点数型はint
整数型よりも広い -
object
オブジェクト型は全てのクラスよりも広い - インターフェイスを実装したクラスはインターフェイスよりも広い
-
class Dog implements Animal
として定義したとき、Dog
はAnimal
よりも広い
-
- クラスを継承したクラスはインターフェイスよりも広い
-
class Chihuahua extends Dog
として定義したとき、Chihuahua
はDog
よりも広い
-
- nullable型
?
はnullableではない型よりも広い-
?Foo
はFoo
よりも広い
-
- ユニオン型
A|B|C
は、その部分A
B
C
およびA|B
A|C
B|C
よりも広い -
iterable
擬似型はarray
とTraversable
よりも広い- ユニオン型
array|Traversable
と等価
- ユニオン型
これらの関係はあくまで型宣言に限って考えた場合です。広い型の引数/戻り値として受け取った値の実態が狭い型の値であることはありえますが、狭い型として受け取った値を広い型として変換が可能だということはありません。
PHPランタイムでの型について
PHPのランタイム(実行時)
ランタイム(run time)とはプログラムが実際に動作するフェイズ(実行時)のことで、実行の前処理フェイズであるコンパイル時(compile time)に対応する概念です。
いくつかのプログラミング言語に触れた経験がある方は「PHPはインタプリタ言語だから実行時とかコンパイル時とかないんじゃないの?」と思われるかもしれませんが、そうではありません。PHP 4以降はVM型のインタプリタであり、構文解析されたPHPコードは実行前にZend Engineというバーチャルマシンのバイトコード(PHPではopcode/オペコードと呼ぶ)にコンパイルされてから実行されます。
特にPHP 7.0以降はコンパイルフェイズで、実行するまでもなく間違ったコードには直接Fatal error(致命的なエラー)を発するようになりました。
たとえば以下のようなコードが顕著な例です。
<?php
printf("PHP %d\n", defined('PHP_MAJOR_VERSION') ? PHP_MAJOR_VERSION : PHP_VERSION);
if (true) {
echo 1 + array();
}
echo "Finished\n";
動作確認: https://3v4l.org/DutCZ
PHPでは+
演算子は1 + 1
のように数字を足すとき、['a' => 1] + ['b' + 2]
のように配列を足すときに利用できます。一方で1 + []
のように数字と配列を足すのは意図された演算ではないので、Fatal errorが発生します。
このコードはPHP 4, 5, 8.0ではPHPのバージョン番号を出力してからFatal errorが発生しますが、PHP 7.x系では全てバージョン番号すら表示せずにFatal errorになります。また、 if (true)
を if (false)
にすると、PHP 4, 5, 8.0では問題なく Finished
までたどりつきますが、PHP 7.xでは何も変わりません。
これは何を意味しているかというと、PHP 7.xではいわゆるsyntax error(Parse error)と同じように、実行する前の段階でFatal errorが起こされているということです[1]。
一方で、以下のように if (false)
に置き換え、+
のオペランドを変数に分割すると、どのバージョンのPHPにおいてもFatal errorは発生しなくなります。
<?php
printf("PHP %d\n", defined('PHP_MAJOR_VERSION') ? PHP_MAJOR_VERSION : PHP_VERSION);
if (false) {
$a = 1;
$b = array();
echo $a + $b;
}
echo "Finished\n";
動作確認: https://3v4l.org/TrMbW
ここから導ける事実は、8.0までのPHPにおいては演算子に直接リテラルを用いた定数畳み込みが可能なような式に関してはコンパイル時に検証することもあるが、定数伝播のような最適化までは行なわないということです。
ほかにも、以下のように: void
で型宣言されているのに値を返している場合はコンパイル時にFatal errorとなります。
<?php
function f(): void
{
return 1;
}
ここではコンパイル時という概念が存在することを確認するために例を出しましたが、実際のところPHPでコンパイル時に検出できるエラーはかなり少数派で、基本的には実行時エラーとなってしまいます。
たとえば、以下のコードは戻り値のint
と書くべきところをimt
と書いていますが、コンパイル時のエラーではありません。
funtcion f(): imt
{
return 1;
}
PHPの型は定義済みの型でなければすべてクラス名として扱われ、imt
というクラスが実行時に定義されることもありうるからです。(とはいえ上記の例ではリテラル1
がint
型以外であることはありえないので、8.0よりも後のバージョンでの振舞いが変わることもあるかもしれません)
型宣言された値を使う
さきほどPHPの多くのエラーはコンパイル時ではなく実行時に検出されるということを説明しましたが、関数やメソッドの型宣言された引数に不適な値を渡すと必ずTypeErrorが発生します。戻り値に対して不適な値をreturn
したときも同様です。
関数やメソッドの引数として受け取った値は、型宣言した型と同じか狭い型として取得できることが保証できます。ユニオン型やinterface
、abstract class
、nullable型など、それ単体では実体化できない型は、より狭い具体的な型として得られます。また、float
はint
よりも広い型ですが、float
として型宣言された場合は必ずint
ではなくfloat
としてキャストされた上で受け取ることができます。
-
PHP 8.0でFatal errorが起こされるタイミングが変わった経緯は把握していませんが、このケースについては実行してないのにエラーにするのはやりすぎだと判断して実行時Fatal errorに戻したのかもしれません ↩︎
強い型 弱い型
PHP 7.0以降、ファイルの冒頭に declare(strict_types=1);
と書くことで厳密な型検査モードで関数を呼び出しできるようになります。この宣言時をしないと何が起こるのでしょうか。
デフォルトの挙動
以下の関数を考えてみましょう。
function add(int $a, int $b): int
{
return $a + $b;
}
これを以下のようなコードで実行してみます。
$result = add($_GET['a'], $_GET['b']);
このコードがどのように呼び出されるかは実行環境と外部からのリクエストに依存しますが、起こる現象は意外にシンプルです。
- クエリパラメータ
?a=1&b=2
のとき-
add('1', '2')
として呼び出され、int(3)
が返る
-
- クエリパラメータ
?a=1.1&b=2.2
のとき-
add('1.1', '2.2')
として呼び出され、int(3)
が返る
-
- クエリパラメータ
?a=&b=
のとき-
add('', '')
として呼び出され、例外TypeError: add(): Argument #1 ($a) must be of type int, string given
が発生する
-
- クエリパラメータ
?a=x&b=y
のとき-
add('', '')
として呼び出され、例外TypeError: add(): Argument #1 ($a) must be of type int, string given
が発生する
-
- クエリパラメータ
?b=2
のとき-
error_reporting(E_NOTICE)
のとき- Warning (PHP 7系まではNotice) レベルのエラー
Undefined array key "a"
が発生する
- Warning (PHP 7系まではNotice) レベルのエラー
-
error_reporting(~E_NOTICE)
のとき-
add(null, '2')
として呼び出され、例外TypeError: add(): Argument #1 ($a) must be of type int, null given
が発生する
-
-
- クエリパラメータ
?a[]=1&b=2.2
のとき-
add(['1'], '2')
として呼び出され、例外TypeError: add(): Argument #1 ($a) must be of type int, array given
-
ここでわかることは、'1'
や1.1
のような文字列を渡すとint
型に変換され、''
や'a'
やnull
のような値が渡されると例外が発生するということです。これは単純に(int)$_GET['a']
のようにキャストするのとはまったく違いますし、配列のキーにセットしたときの振舞いとも異なります。そのような振舞いにも関わらず、int
型宣言された引数にbool
型の値を渡してadd(true, false)
のように呼び出すと、$a = 1, $b = 0
に変換されてしまうということです。
このような現象を防ぐためにはfilter_input()
および検証フィルタなどを駆使して型検査および意図しない入力をハンドリングして処理を抑止しなければいけません。
declare(strict_types=1);
declare(strict_types=1);
は関数呼び出しのように見えますが実はまったく関係なく、declare
文はディレクティブとも呼ばれ、ファイルまたはブロック単位でPHP処理系の動作を制御します。関数ではないということを強調するためか、declare(strict_types = 1);
のようにスペースが空けられることは通常ありません。PHP 8.0で追加された関数の名前付き呼び出しともまったく別です。また、declare(strict_type=1);
はほかのdeclare
と違ってブロック形式で指定することはできず、ファイル単位のみです。
さて、この指定を行ったファイルでは上記のようなstring
やbool
からint
やfloat
への変換は行なわれなくなり、TypeError
が発生するようになります。ただし数値型であるint
とfloat
は別です。float
型宣言された引数や戻り値にint
の値を渡すとfloat
にキャストされます。これは後述する共変や反変とは別の振舞いなので気をつけてください。
可変長引数と型宣言
可変長引数 ...
に型宣言をすると、引数のすべてが同じ型でないとエラーとすることができます。
function f(Book ...$args): array
{
return $args;
}
$books = [
new Book(),
new Book(),
null,
new Book(),
];
f(...$books);
詳細はPHP 5.6と7の新機能を使った画期的バリデータの実装 - Qiitaに書きました。
共変性と反変性
ジェネリクスの話に入る前に共変(covariant)と反変(contravariant)について理解しておきましょう。
一般的な意味は共変性と反変性 (計算機科学) - Wikipediaに、PHPでの実装についてはPHP: 共変性と反変性 - Manual、PHPStanという型チェッカーの作者による説明(の翻訳)はPHPDocを使ったPHPのジェネリクス - 超PHPerになろうにあるので、お読みください。
クラスを継承したとき、型について以下のような制約が生まれます。
- 引数は反変(contravariant)であり、親クラスと同じか、より広い型(抽象的な型)として宣言できる
- 戻り値は共変(covariant)であり、親クラスと同じか、より狭い型(具体的な型)として宣言できる
ちなみにプロパティは不変(invariant)であり、親クラスで宣言したものと同じものから変えることはできません。