ジェネリクスで調べると総称型とか型の抽象化とか意味のわからない単語が出てきてなんのこっちゃとなりますが、具体的には使うときに型を決めることができる方法と考えるといいでしょう。
引数の型を指定する関数を普通に書くとこうなりますね。
// string型のみ
function foo(string $param):string{
return $param;
}
// int型のみ
function bar(int $param):int{
return $param;
}
関数fooとbarは中身が同じなので一緒にしたいじゃないですか。
// intとstring型
function foo(string|int $param):string|int{
return $param;
}
一見できたようにも見えますが、実際はだめです。
元の関数はintを渡せばint型が、stringを渡せばstringが帰ってくることがシグネチャで保証されていました。
しかしこの関数では、intを渡してstringが帰ってきても矛盾しません。
stringを渡した時はstringを返し、intを渡した時はintを返すことが決められている関数をひとつで書けないものでしょうか?
そんなときこそジェネリクスの出番です。
// 現在のPHPでは動かない
function fooT>(T $param):T{
return $param;
}
fooint>(1);
foostring>('str');
fooint>('str'); // エラー
この書式がPHPに導入される予定はいまのところありませんが、概念的にはこんなかんじです。
は型引数という名前で、名前のとおり型を渡す引数です。T
の文字そのものには意味がなくてFOO
でもHOGE
でもなんでもいいです。
関数を呼び出すときに具体的な型を渡すと、それがT
の部分に展開されるというわけです。
stringを渡した時はstringを返し、intを渡した時はintを返す関数をひとつで書けるようになりました。
ただまあ、どちらかというと、こちらが欲しいPHPerが大多数ではないでしょうか。
// string型しか入れられない
$arr = arraystring>;
$arr[] = 'a'; // OK
$arr[] = 1; // エラー
$arr
はstring型しか入れることのできない配列です。
こんな配列ほしいですよね。
上記のとおり、PHPにはいまのところジェネリクスがありません。
外部ライブラリを使ったり自力でがんばったりなんか曲芸っぽいことをしたりしないといけません。
そんな現状はPHP開発側ももちろん理解していて、実は昔から何度も研究がなされていました。
そんなわけで先日PHPファウンデーションでなんかジェネリクスの現状についてブログが公開されました。
以下は該当の記事、State of Generics and Collectionsの紹介です
ジェネリクスは、長年にわたり多くのPHP開発者が欲しい機能リストの筆頭に上げられてきました。
この話題は、PHPの新機能に関するカンファレンスや質疑応答でも頻繁に取り上げられます。
この記事では、ジェネリクスの現状とアプローチについて解説します。
Full Reified generics
ジェネリクスを使用することで、プロパティ・メソッドの型プレースホルダを持つクラスを定義できます。
プレースホルダは、クラスをインスタンス化するときに指定します。
これによって、異なるデータ型間でのコードの再利用性や型安全性が実現します。
“具象化”ジェネリクスは、型情報が定義され、実行時に継承される実装であり、実行時にジェネリクスの要件を満たすことができます。
PHPで表すと以下のようになります。
class EntryKeyType, ValueType>
{
public function __construct(protected KeyType $key, protected ValueType $value)
{
}
public function getKey(): KeyType
{
return $this->key;
}
public function getValue(): ValueType
{
return $this->value;
}
}
new Entryint, BlogPost>(123, new BlogPost());
インスタンスでは、ジェネリクス型KeyType
はint
に置き換えられ、ValueType
はBlogPost
に置き換えられます。
最終的に、以下のように動作するオブジェクトが生成されます。
class IntBlogPostEntry
{
public function __construct(protected int $key, protected BlogPost $value)
{
}
public function getKey(): int
{
return $this->key;
}
public function getValue(): BlogPost
{
return $this->value;
}
}
この機能をPHPに追加する試みは過去幾度もありました。
Nikita Popovは、2016年のRFCおよび残された課題を踏まえ、2020年ごろに最も包括的な実装を試みました。
2024年はじめ、PHPファウンデーション支援の下で、Arnaud Le Blancがこの取り組みを再開しました。
技術的問題の多くは解決されたものの、さらに多くの問題が未解決のまま残っています。
深刻な問題のひとつが型推論です。
ジェネリクス型を使用すると、ジェネリクス型を参照するたびにジェネリクス型を指定する必要があるため、コードの冗長性が増加します。
たとえば以下のようになります。
function f(ListEntryint,BlogPost>> $entries): Mapint, BlogPost>
{
return new Mapint, BlogPost>($entries);
}
function g(ListBlogPostId> $ids): ListBlogPost>
{
return mapint, BlogPostId, BlogPost>($ids, $repository->find(...));
}
型推論では、コンパイラが適切な型を自動的に推論することによってこの問題を軽減します。
たとえば上記例では、new Map()
やmap()
の引数の型はコンパイラが自動的に決定させるべきでしょう。
ところがPHPではこれが困難です。
Nikitaの言葉を借りると、「PHPコンパイラが持っているコードビューは非常に限られている(一度に1ファイルしか認識できない)」
以下の例を考えてみましょう。
class BoxT>
{
public function __construct(public T $value) {}
}
new Box(getValue());
getValue()
の返り値の型は、実際に実行されるまで不明です。
従って、コンパイル時にBoxの型Tを推論することができません。
関数の戻り値に基いて実行時に型Tを決定する場合は、型付けが不安定になります。
上記例では、new Box()'の型は
getValue()`の戻り値に依存しており、特殊すぎる型です。
Boxは不変であることを考えると、このBoxインスタンスを使って何かをしようとするコードはすぐ動かなくなるでしょう。
型付けは、実装に依存しない、コンパイル時に解決する情報に基づいている場合に最も有用です。
ジェネリッククラスが不変であるとは、その型プレースホルダが読み取りと書き込みの両方で同時に使用されている場合に不変です。
プロパティ型は読み取りと書き込みの両方で使用されます。
次の例を考えてみましょう。
function changeValue(BoxValueInterface> $box)
{
$box->value = new B();
}
関数changeValue()
の型はBox
であり、ValueInterfaceの任意のサブタイプを受け入れられます。
ところがValueInterfaceのサブタイプであるAを渡すと、$box->value
にはAのサブタイプでない型Bが代入されることになってしまい、制約が破られてしまいます。
他言語における一般的な解決法は、型パラメータが読み取りと書き込みの片方だけで使用される場合は、一方向のみ可能にするマーク(通常はin/out)をつけることです。
これによって、その型パラメータは共変・反変になります。
Hybrid Approach to Type Inference
これらの課題に対して、我々はハイブリッドアプローチを検討しました。
この方法は、コンパイル時に完全な情報が得られなくても、ジェネリクス型の静的型推論を実装することができます。
このアプローチでは、コンパイル時に未知な型はシンボルとして扱います。
たとえばgetValue()
の型はfcall
です。
シンボリック型は、関数やクラスがロードされた後で必要に応じて実行時に解決します。
実行時に全体を解決するアプローチよりコストもはるかに低くなります。
PHPStan/Psalmと同じ動作を持つジェネリクス型パラメータの概念実証が実装されました。
このアプローチは機能し、他の種類の型推論の実験にも使用できます。
Performance Considerations
ジェネリクスに関するもう一つの懸念事項は、パフォーマンスへの影響です。
ベンチマークでは以下の結果が示されました。
・非ジェネリックなコードのパフォーマンスには影響を与えません。
・単純なジェネリクスは、通常のコードに比べて1-2パーセントわずかにパフォーマンスが低下します。
調査において、複合型などは超線形な計算量をもたらし、深刻なパフォーマンス低下をもたらす可能性があることが明らかになりました。
たとえばA|B
がB
を受け入れるかのチェックは線形ですが、Box()
やBox()
の計算量はO(nm)
です。
シンボリック型に複合型が混ざる場合も、計算量が超線形に達します。
Future Directions
“具象化”ジェネリクスは、以下のような課題が残されています。
・複合型や極端なケースの影響。
・型チェックを行うインラインキャッシュの実装、複合型をチェックするより洗練されたアルゴリズム。
・シンボリック型を減らすための先行ロード等の手法の検討。
Collections
ジェネリクスの使用例としてよく挙げられるものが型つき配列です。
PHPでは、配列型は万能であり、あらゆる場面で活用(そして濫用)されます。
そして現時点では、キーや値として特定の型に限定することはできません。
完全なジェネリクスとは異なる、より扱いやすい代替手段として、専用のコレクション構文の開発が別途進んでいます。
コレクションにはセット・シーケンス・辞書の3種類があり、セットとシーケンスは値のみ、辞書はキーと値の型を持ちます。
class Article
{
public function __construct(public string $subject) {}
}
collection(Seq) ArticlesArticle>
{
}
collection(Dict) YearBooksint => Book>
{
}
コレクションは、通常のクラスと同じようにインスタンス化できます。
$a1 = new Articles();
$b1 = new YearBooks();
コレクションには多くのメソッドが定義されており、PHP組み込みのarray_*
関数と同じように多くの機能が提供されます。
コレクションの要素を追加・更新する場合、値の型はコレクション定義時に決めたものと一致する必要があります。
上記例では、辞書型YearBooks
では追加メソッドadd
の引数のキーはint型、値はBook型でなければなりません。
主要な操作メソッドadd
・get
・unset
・isset
などは、ArrayAccess形式や演算子オーバーロードでも同様にアクセス可能です。
コレクションの欠点のひとつは、宣言が必要なことです。
さらに従来の慣習に従うと、コレクションひとつごとにひとつのファイルで1行の宣言を行うことになります。
もうひとつの問題は、PHPが各クラスに対して全てのエントリを保持しなければならないため、メモリの増加が見込まれることです。
みっつめの問題は、型に継承関係があってもコレクションには継承関係がないことです。
次に例を示します。
class A {}
class B extends A {}
seq AsA> {}
seq BsB> {}
new B() instanceof A // true
new Bs() instanceof As // false
namespace Foo;
seq AsA> {}
namespace Bar;
seq AsA> {}
namespace;
new Foo\As instanceof Bar\As; // false
コレクションは、ジェネリクスほど強力ではないものの、多くのユースケースにおいて手頃な代替となりえます。
実装も簡単であり、実験的実装も利用可能です。
ただ完全なジェネリクスが搭載された暁には、コレクションもジェネリクスの上に実装されるのが望ましいでしょう。
他言語におけるコレクションAPIの状況について、Larry Garfieldが調査を行いました。
コレクションはまだ実装途中ですが、”全部やる”で同意されています。
文書の最後に、これからの方針が示されています。
Other alternatives
その他検討された代替案。
Static Analysis
近年は静的解析ツールが伸長しています。
PHPStan・Psalmはいずれもドキュメントアノテーションでジェネリクスをサポートしており、オープンソースライブラリではよく使用されています。
以下はPHPStanとPsalmによるジェネリックDictクラスの例です。
/**
* @template Key
* @template Value
*/
class Dict
{
/**
* @param array $entries
*/
public function __construct(private array $entries) {}
/**
* @param Key $key
* @param Value $value
*/
public function set($key, $value): self
{
$this->entries[$key] = $value;
return $this;
}
}
/** @param Dict $dict */
function f($dict) {}
$dict = new Dict([1 => 'foo']);
$dict->set("foo", "bar"); // Static analyser error
$dict->set(1, "bar"); // Ok
f($dict); // Static analyser error
歴史的理由からtemplate
という名前が使われていますが、Javaに非常に近いジェネリクスが実現されています。
これらは静的解析時にチェックされ、実行時にはなにもしません。
このアプローチには以下の欠点があります。
・ドキュメントが冗長になりやすい。
・型チェックには別のツールが必要。
・実行時には型情報を利用できない。
・実行時には何もチェックしない。
Erased Generic Type Declarations
PHPコアで”具象化”ジェネリクスを実現することが困難であることから、構文のみを実装し、型チェックは静的解析にゆだねるというアイデアが提案されました。
この案では、PHPは型宣言、クラス宣言、関数宣言がジェネリクス構文を受け入れるようになりますが、チェックは行いません。
PHPエンジンは実行時にこれらを単に無視するため、”消去された”型宣言と呼ばれます。
上記Dictと同じ例です。
class DictKey,Value>
{
public function __construct(private arrayKey,Value> $entries) {}
public function set(Key $key, Value $value): self
{
$this->entries[$key] = $value;
return $this;
}
}
function f(Dictstring,string> $dict) {}
$dict = new Dict([1 => 'foo']);
$dict->set("foo", "bar"); // Static analyser error
$dict->set(1, "bar"); // Ok
f($dict); // Static analyser error
静的解析におけるドキュメント冗長性の問題は解決されましたが、通常の型宣言とジェネリック型宣言で異なる動作が発生します。
class StringList
{
public function add(string $value)
{
$this->values[] = $value;
}
}
class ListT>
{
public function add(T $value)
{
$this->values[] = $value;
}
}
$list = new StringList();
$list->add(123); // string型になる
$list = new Liststring>();
$list->add(123); // string型にならない
このシナリオでは、StringList::add()
では引数がstring型になりますが、List
では型変換されません。
従来の型システムの上にジェネリクスを乗せたJavaのような言語では、コンパイラが型チェックを行うため、このような矛盾は発生しません。
しかしPHPではこれを避けることができません。
消去されたジェネリック型宣言のもう一つの欠点は、実行時に型を参照できないことです。
これによってたとえば型引数でパターンマッチングできなくなります。
Fully Erased Type Declarations
消去されたジェネリック型による不整合問題に対処する方法のひとつは、型宣言を完全に消去することです。
これはdeclare文で宣言します。
この案では、PHPエンジンはあらゆる型チェックを行いません。
前例のadd()
はいずれも型変換を行わないし、型の強制も行いません。
解析ツールを用いて型をチェックするのはユーザの責任になります。
この手法は、インタプリタ言語では珍しいものではありません。
Javascript・Python・Rubyは完全に消去された型宣言を持っています。
また、型チェックを行わないのでパフォーマンスが向上します。
空でない文字列型、int型、条件付き型など様々な高度な型を追加して型システムを拡張することも可能です。
しかしながら、重大な欠点が存在します。
・リフレクションや、リフレクションに依存するライブラリにどのような影響を与えるかが不明。
・開発者は現在の厳密な型付けモードと緩い型付けモードに加え、さらに3つ目の型モードを考慮しなければならなくなる。
・一部の型は強制し、一部の型は強制しない、という要望には使えない。
・主要なスクリプト言語の中で、PHPは型強制を持つ唯一の言語である。このメリットを失うのはもったいない。
Generic Arrays
ここまでオブジェクトについて解説してきました。
配列はどうでしょうか。
Fluid Arrays
動的配列。
配列はコピーオンライトです。
すなわち、呼び出し先で配列を変更した際には新しいコピーが作成されるので、他所で変更されることを気にせず安全に受け渡しが可能です。
型付けの観点から見ると、配列の型は常にその内容によって定義され、配列の変更はコピーであり、型は変更できないということを意味します。
ジェネリクスの観点から見ると非常に便利な特性です。
配列はジェネリックでないクラスと同様に、スーパータイプとサブタイプを持つことができます。
以下のコードは型安全です。
class A {}
class B extends A {}
function f(array $a) {}
function g(arrayA> $a) {}
function h(arrayB> $a) {}
$array = [new B()];
f($array);
g($array);
h($array);
ジェネリクス配列を実装する自然な方法は、上で説明したとおりです。
すなわち、配列の内容によって型を定義します。
これは型安全性を実現します。
API教会で型がチェックされます。
すなわち、関数に引数を渡すとき、値を返す時、プロパティの更新時などです。
function f(arrayint> $a) {}
$a = [1];
f($a); // OK
$b = [new A()];
f($b); // エラー
PoCは既に実装されていますが、パフォーマンスへの影響は未知数です。
最大の問題はリファレンスが混ざってきたときであり、サポートできない可能性があります。
Static Arrays
静的配列。
動的ではなく、インスタンス化する際に型を固定する方法もあります。
$a = arrayint>(1); // array
$a[] = new A(); // エラー
これは現在のPHPにおける配列の使われ方とは大きく異なります。
また、配列は不変になります。
function f(arrayint> $a) {}
function g(array $a) {}
$a = [1];
f($a); // OK
g($a); // エラー
g($a)
はエラーになります。
g()
はarray
を受け取ります。
つまり、任意の型の要素を受け取れるはずです。
ところがarray
を渡すと、この規約が破られます。
従って、g()
はarray
を受け取ることができません。
不変性は配列の使用を著しく困難にします。
ライブラリはユーザコードを壊さずにジェネリクスの型宣言を導入することができません。
ユーザはライブラリが型宣言に対応するまでジェネリクス配列を渡すことができません。
Conclusion
この記事では、オブジェクト、コレクションにジェネリクスを導入するための様々なオプションについて説明しました。
どのオプションがPHPに最もふさわしいのか、そして実現可能なのかを判断するにはまだ時間が必要であり、現在も作業は続いています。
・具象化されたジェネリクスの型推論についてさらに調査する。最善の選択肢になる可能性は高い。
・この記事では型消去について言及していない。
・コレクションにハッシュマップ(=配列)以外のデータ構造を適用できないかの調査。
・型付き配列については検討を打ち切る。
正直$a = [];
を$a =
ってするだけだろ?とか考えてたレベルだったので、ここまで様々な方向に問題が連鎖する大変なものだとは思っていませんでした。
いまの言語仕様的にそのままジェネリクスを導入するのは難しそうですが、今後何かよい道が見つかるといいですね。
Views: 0