Open

PHPにジェネリクスは入るのか?

7

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スクリプトの動作に影響を及ぼすことはありません。

脚注
  1. リフレクションでDocCommentを読み取って実行時に処理を行なったり、クラスをロードする前にコードを注入するようなメタプログラミングを行なっている場合(Go! AOPなど)を除きます。 ↩︎

型宣言できる場所

  • 引数
  • 戻り値 (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 として記述可能

resourceは特殊な型であり、PHP: リソース型の一覧 - Manualで列挙されている通り多様な実態があり、単一の型として扱うことは必ずしも妥当ではありません。

型宣言できる型の関係 (広い/狭い)

PHPで型宣言できる型の広い/狭いとは以下のような関係です。

  • mixed 擬似型は全てのデータ型よりも広い (トップ型)
  • float 浮動小数点数型は int 整数型よりも広い
  • object オブジェクト型は全てのクラスよりも広い
  • インターフェイスを実装したクラスはインターフェイスよりも広い
    • class Dog implements Animal として定義したとき、DogAnimalよりも広い
  • クラスを継承したクラスはインターフェイスよりも広い
    • class Chihuahua extends Dog として定義したとき、ChihuahuaDogよりも広い
  • nullable型?はnullableではない型よりも広い
    • ?FooFoo よりも広い
  • ユニオン型 A|B|C は、その部分 A B C および A|B A|C B|C よりも広い
  • iterable 擬似型はarrayTraversableよりも広い
    • ユニオン型 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 というクラスが実行時に定義されることもありうるからです。(とはいえ上記の例ではリテラル1int型以外であることはありえないので、8.0よりも後のバージョンでの振舞いが変わることもあるかもしれません)

型宣言された値を使う

さきほどPHPの多くのエラーはコンパイル時ではなく実行時に検出されるということを説明しましたが、関数やメソッドの型宣言された引数に不適な値を渡すと必ずTypeErrorが発生します。戻り値に対して不適な値をreturnしたときも同様です。

関数やメソッドの引数として受け取った値は、型宣言した型と同じか狭い型として取得できることが保証できます。ユニオン型やinterfaceabstract class、nullable型など、それ単体では実体化できない型は、より狭い具体的な型として得られます。また、floatintよりも広い型ですが、floatとして型宣言された場合は必ずintではなくfloatとしてキャストされた上で受け取ることができます。

脚注
  1. 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"が発生する
    • 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と違ってブロック形式で指定することはできず、ファイル単位のみです。

さて、この指定を行ったファイルでは上記のようなstringboolからintfloatへの変換は行なわれなくなり、TypeErrorが発生するようになります。ただし数値型であるintfloatは別です。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)であり、親クラスで宣言したものと同じものから変えることはできません。

ログインするとコメントできます