なんかPHPソースコードに448ファイル15000行のプルリクがきていました。
final readonly class C<+T> {
public function __construct(public T $value) {}
public function dup(): static<T> { return new static($this->value); }
}
$c = new C::<string>("ok");
echo $c->dup()->value; // "ok"
ジェネリクスじゃないか。
作者azjezzはRust製PHPツールチェインMagoをつくった人です。
また他にもPHP Standard LibraryやCELのPHP実装といった多くのプロジェクトのトップコントリビュータです。
したがって十分な実績を持っている力のある開発者であると言っていいでしょう。
プルリクへのスタンプは全員がプラスの反応であり、非常に期待されています。
RFCでは次のPHP8.xへの導入を期待しています。
すなわち最短では2026年12月リリースのPHP8.6で使えるようになるということですが、さすがに規模や影響範囲からして厳しいような気もしますね。
増えたファイルのほとんどはテストファイルであり、実際の実装ファイル数はそこまでではないとはいえ、相当大規模な改修であることはまちがいありません。
さすがにこの規模の変更 ( 指摘へのフォローアップも非常に速い ) はAIを全面利用しているのだと思いますが、そのあたりは明記されていないのでわかりませんでした。
ここでは文法などを軽く紹介してみます。
なおジェネリクスをいまいちわかっていないので適当な部分がある可能性があります。
そのあたりはきっとコメントとかで指摘が入るはず。
Bound-Erased Generic Types
基本文法
クラス、インターフェース、トレイト、関数、メソッド、クロージャ、アロー関数など、型宣言できるところで型パラメータの宣言ができるようになります。
// PHP 8.5
function pick(mixed $left, mixed $right): mixed
{
return rand(0, 1) ? $left : $right;
}
// PHP8.x
function pick<L, R>(L $left, R $right): L|R
{
return rand(0, 1) ? $left : $right;
}
pick::<int, string>($a, $b);
LやRが型パラメータです。
これは特定の型を表しているわけではなく、呼び出すときに実際の型を指定するためのダミーの値です。
呼び出すときにpick::<int, string>($a, $b);というふうにすると、型パラメータLには実際の型intが、型パラメータRには実際の型stringが入り、function pick(int $left, string $right)が実行される、ということになります。
型パラメータ <>
型パラメータを表す記号として、大抵の言語で<>が採用されています。
PHPも前例に倣うことを選択しました。
型引数 ::
呼び出し時の文法は、Java等でよく見るpick<int, string>($a, $b);ではなく、謎の::<>が入ったpick::<int, string>($a, $b);という形になっています。
これはturbofishと呼ばれ、Rustで使われている文法です。
どうしてこの文法が採用されたのかというと、もちろん既存構文と喧嘩するからです。
[A<B, B>(C)]は現在合法な文法であり、::を付けないジェネリクスだとこの文はarray(A<B, B>C)なのかarray(A::<B, B>(C))なのか区別することができなくなってしまいます。
ということでturbofish構文が採用されました。
デフォルト型 =
使用する側で型パラメータを書かなかった場合のデフォルト型を記述することができます。
class Cache<K = string, V = mixed >{ }
new Cache; // K = string, V = mixed
new Cache::<int>; // K = int, V = mixed
new Cache::<int, Order>; // K = int, V = Order
デフォルト値のある型の後でデフォルト値のない型を記述することはできず、無関係な型を記述することもできません。
このあたりは引数の値と同じような動作です。
class Bad<T = int , U >{ } // Error: required U cannot follow optional T
class Animal{ }
class Box<T:Animal = int>{ } // Error at declaration: int does not satisfy Animal
共変反変 +-
function transform <-I , +O > ( I $input ) : O { /* ... */ }
この+-って知らなかったのでなにこれ?ってなったのですが、+Tは共変、-Tは反変の位置にのみ設置可能というマーカーだそうです。
+Tは返り値の型に書くことはできるけど、引数の型に書くことはできません。
-Tは引数の型に書くことはできるけど、返り値の型に書くことはできません。
ただし、これは実装を書く側がうっかり宣言ミスを防ぐための目印であり、利用する側はあんまり気にしないでいいみたい。
またこの文法は、投票でin・outになる可能性があります。
こっちならC#で見たことあるからわかるわ。
extends :
サブクラス制約をつけたい場合、他言語での文法はおおまかに2種類です。
// Java・TypeScriptはextends
public <K extends Enum<?>> String getString(K key);
// Kotlin・Scalaは:
fun <K : Enum<*>> getString(key: K): String {}
普通にextendsを書くか、かわりに:を使うかです。
PHPは後者を選択しました。
// 採用
class C<T : Foo> extends D{}
// 不採用
class C<T extends Foo> extends D{}
extendsが並ぶと分かりにくいからだそうです。
その他
全てオプトイン
呼び出し側で型引数を指定しない場合は、一切のチェックを行いません。
function id<T : object>(T $v): T{return $v;}
id($x); // OK
class Container<T>{}
new Container(); // OK
この場合は型パラメータを何も書いていない場合と同じ動作になります。
これは既存のプログラムと互換を保つためです。
そのため、ライブラリ側は利用者を気にせず型パラメータを実装することが可能になります。
また、この場合は処理コストが全くかからずベンチマークが遅くならないように構成されているそうです。
型消去ジェネリクス
このRFCのジェネリクスは型消去ジェネリクスです。
型消去ジェネリクスとは、ざっくり言うとコンパイル後に型情報が削除されるというものです。
ジェネリクスは2種類あって、実行時まで型情報が残るものと残らないものです。
前者はC#やKotlinのreifiedなどがあり、TypeScriptやJavaなどは後者です。
Javaが後者なのはなかなか意外ですね。
PHPももちろん後者を選択しました。
class Box<T> {
public int $x = 1;
}
var_dump(new Box::<int>);
/*
object(Box)#1 (1) {
["x"]=>
int(1)
}
*/
// 全部true
class C {}
$c = new C;
var_dump($c instanceof C); // true
var_dump($c instanceof C<string, int>); // true
var_dump()するときれいさっぱり型パラメータが消えた状態になります。
instanceofの型パラメータも無視され、instanceof C<string, int>はinstanceof Cと同じになります。
リフレクション
リフレクションを用いると、消される前の型パラメータを取得することができます。
$f = fn<T>(T $x): T => $x;
$r = new ReflectionFunction($f);
var_dump($r->isGeneric()); // true
var_dump($r->getGenericParameters()[0]->getName()); // T
静的コンテキストには使用できない
staticプロパティやメソッド等には使用できません。
class Box < T > {
public static T $shared ; // エラー
public static function make ( T $x ) : T { } // エラー
}
どうしてできないのかって、Tの型を指定するところがありませんからね。
型付き配列は対象外
たぶんPHPユーザが一番期待してるジェネリクスはarray<int, string> $stringArrayだと思うんですよ。
残念ながら本RFCでは対象外です。
このRFCは規模が大きいため、今回の実装は見送られました。
今後別のRFCで導入されるかもしれません。
一カ所あたり型パラメータ数の上限は127
一カ所あたり型パラメータ数の上限は127です。
1ファイルやプログラム全体とかではなく1つの<>内の数なので、大きなプロジェクトだからといって上限を超える心配はありません。
class A<T1, T2, …, T127>{} // OK
class B<T1, T2, …, T128>{} // エラー
こんなの超える奴おらんやろ。
様々なサンプル
// 基本的な使い方
function f<T>($x) { return $x; }
var_dump(f::<int>(42)); // 42
// 第一級オブジェクト
function id<T>($x) { return $x; }
$cl = id::<int>(...);
var_dump($cl(7)); // 7
// マーカー
function take<-T>(T $x): int { return 0; }
function make<+T>(): T { return null; }
function transform<-I, +O>(I $input): O { return null; }
// クロージャ、アロー関数
$closure_take = function <-T>(T $x): int { return 0; };
$closure_make = function <+T>(): T { return null; };
$arrow_take = fn<-T>(T $x): int => 0;
$arrow_make = fn<+T>(): T => null;
// クラス、メソッド
class A<+T> {
public function map<-A_, +B>(A_ $a): B { return null; }
}
// - は引数
function f<-T>(): T {} // Fatal error
// + は返り値
function f<+T>(T $x): void {} // Fatal error
// デフォルト型
class Pair<K, V = mixed> {}
class P1 extends Pair<int> {}
class P2 extends Pair<int, string> {}
// 継承で狭めることはできる、広げることはできない
class Animal {}
class Dog extends Animal {}
interface A<T : Animal> {}
interface B<Y : Dog> extends A<Y> {} // OK
interface A<T : Dog> {}
interface B<Y : Animal> extends A<Y> {} // エラー
// 交差型・UNION型・DNF
interface A {}
interface B {}
interface C {}
class Holder<T: (A&B)|C> {
public function take(T $x): void {}
public function get(): T {}
}
// ネスト
class B<K, V> {}
function f<T>($x) { return $x; }
var_dump(f::<B<int, string>>(["a", "b"]));
今後の展望
この項目は、本RFCには含まれません。
#[ReifiedGenerics]
class Box < T : object > { /* ... */ }
ReifiedGenericsアトリビュートを指定すると具象ジェネリクスになり、実行時まで型情報が残るようになる。
type Result<T> = T | Error;
型エイリアスを使えるようにする。
後方互換性のない変更
後方互換性のない変更はありません。
今回採用された文法は全て、以前のPHPでは文法エラーです。
他言語のジェネリクス実装
・Hack: https://docs.hhvm.com/hack/generics/introduction/
・Java: https://docs.oracle.com/javase/tutorial/java/generics/genTypes.html
・Rust: https://doc.rust-lang.org/reference/glossary.html#turbofish.
・Scala: https://docs.scala-lang.org/tour/generic-classes.html.
・Kotlin: https://kotlinlang.org/docs/generics.html
・TypeScript: https://nodejs.org/api/typescript.html#type-stripping
本RFCのジェネリクスは、他言語の文法・思想のいいとこどり(寄せ集め)です。
感想
これまで何度も何度も提案されては規模や後方互換性や実装コストの問題で見送られてきたジェネリクスですが、なんかいきなり動作するプルリクが送り付けられてきてびっくりですね。
ところでひとつ大問題がありまして、
class Box<T> {
public function test(T $v): T {
return $v;
}
}
class IntBox extends Box<int> {}
$b = new IntBox();
echo $b->test("hello"); // "hello"
これ、エラーが出ずに動くみたいです。
ええ???
100人中100人はIntBoxにはint型しか入れられないだろうと考えますが、そうはなりません。
Javaと同じ型消去ジェネリクスという手段を選択したので、Javaと同じ問題が発生するというわけです。
またジェネリクスの仕様として型消去を選んだ理由はPHPのコンパイルの仕様によるものであり、C#タイプのジェネリクスの導入は現状では困難なようです。
Javaの場合はコンパイルフェーズが入るのでまだエラーを検出しやすいですが、PHPはそこがないので、せっかくジェネリクスという素晴らしい武器を手に入れたのに適切な使い方がなされないせいで結局意味がなくなってしまいかねません。
そのあたりが、特にこれまで杜撰なコードの互換性問題に延々悩まされてきたPHPコア開発者に懸念を呼び起こしているようで、メーリングリストでも賛多めの賛否両論といったところです。
まあ個人的にも、このコードはコンパイルエラーを出してほしいですね。
もちろん静的解析ツールはこれを検出できるように対応するでしょうが、それなら結局外部ツールが必要になるのでPHPの文法として導入する意味も薄れてしまいます。
構文自体は非常に素晴らしく、ぜひ導入してほしいところなので、なにかいい落としどころが見つかるといいですね。