先日ジェネリクスのRFCが提出されました。
PHPの利用者からは非常に好評であるいっぽう、PHP本体のコア開発者からは慎重な意見が多く見られます。
このギャップはどこから来ているのでしょうか?
ということで以下はPHPコア開発者のひとりLarry GarfieldによるジェネリクスRFCの評価記事、Is it finally time for PHP generics?の紹介です。
これを読むと、コア開発者がこのジェネリクスの導入に慎重な理由がすこしわかります。
ちなみにLarryはPHPにジェネリクスを導入しようとしている当人のひとりであり、立ち位置はジェネリクス推奨側です。
Is it finally time for PHP generics?
今週のはじめ、Seifeddine GmatiがPHPジェネリクスのRFCを公開しました。
これは今までに提案されてきたジェネリクス機能の中でも最も有望なアプローチであり、大きな注目を集めています。
The background
RFCの前半では、PHPの静的解析およびジェネリクスの歴史がわかりやすく解説されており、どうしてジェネリクスが必要であるかを明確に表しています。
簡単に説明すると、ジェネリクスは型安全性を保ちつつ、様々な型について動作するクラスや関数を生成する機能です。
たとえば以下のような例です。
class Test<A>
{
private A $value;
public function doStuff(A $val): A { ... }
}
$x = new Test::<DateTimeImmutable>();
$y = new Test::<Product>();
$xでは、AがDateTimeImmutableに置き換えられます。
$yでは、AがProductに置き換えられます。
2つのクラスを作成する必要はありません。
これは非常に多くのユースケースの存在する重要な機能ですが、RFCで詳しく解説されているのでここでは繰り返しません。
Different solutions
さてジェネリクスの実装方法は3種類が存在しており、それぞれに利点と欠点があります。
・具象化
全てのオブジェクトが、実行時までジェネリック型情報を保持します。
精度と安全性の恩恵を最大に得られますが、パフォーマンスに影響を与える可能性があります。
・モノモーフィズム
ジェネリクスが使用されるたびに、クラスをコピペし、実際の型に置き換えます。
パフォーマンスに大きな影響を与えるため、PHPのようなインタプリタ言語では困難です。
・消去
コンパイル時に型情報は破棄され、実行時には存在しません。
パフォーマンスへの影響は最も少ないですが、ソースと実行コードにギャップが生じる可能性があります。
言語によって様々なアプローチがありますが、コンパイル言語では型消去が一般的です。
コンパイル言語ではコンパイラがプログラム全体を解析し、通常型とジェネリック型両方の型情報が全て整合していることを確認するステップが存在しているからです。
確認が終われば、もはや型情報は不要になるため、削除しても問題ありません。
つまり、型消去ジェネリクスでは、実行時には型がTest<DateTimeImmutable>なのかTest<Product>なのか判別することができません。
But no good answer for scripting languages
しかし、スクリプト言語にはよい解決策がありません。
PHPは、全ての型が整合しているかを判断するための"アプリケーション全体"のビューを手に入れる経路が存在しません。
これはPHPだけでなく全てのスクリプト言語に共通する問題であり、Python・Ruby・JavaScriptなども同じ課題を抱えています。
JavaScriptは、この問題をTypeScriptによって解決しました。
TypeScriptは、コンパイル対象がJavaScriptであるコンパイル言語です。
そのため、アプリケーション全体を俯瞰して全ての型が整合していることを確認することができます。
Pythonは、型情報を完全に消去することでこの問題を解決しました。
Pythonには型構文が存在しますが、Python言語はそれを完全に無視します。
型の検証は全てが静的解析ツールに任されています。
そして多くのPython開発者は型情報を使わないので、Pythonのジェネリクスはちょっと変わったコメントと同じ意味です。
PHPは実行時に型を強制するアプローチを採用しています。
実行時に型をチェックする主要なインタプリタ言語は、PHPだけです。
たとえばint型のパラメータにstring型を渡すとTypeErrorが発生します。
パフォーマンスへの影響はわずかです。
またリフレクションを利用して型に関するあらゆる情報を取得できる、非常に堅牢なシステムを備えています。
問題は、実行時強制には処理能力の上限があり、そしてジェネリクスはその上限を超えてしまうということです。
そのためPHPStan・Psalm・Magoなどの静的解析ツールが不足を補っており、Pythonと同等のチェック機能を提供しています。
しかしそれはコメント欄を利用しているため、PHPの構文には何の影響も与えることがありません。
Erased...ish?
SeifによるこのRFCは『型消去ジェネリクス』と名付けられてますが、マーケティング的にはよくない名前です。
この名前では、Pythonと同じく実行時には全て無視される機能にすぎないと思われてしまうからです。
しかし実はもっと巧妙な仕組みになっており、実際には、このRFCではほとんどのルールをチェックします。
なぜなら、ほとんどのルールはファイル単位で検証可能か、継承単位で検証可能だからです。
Seifの主張は、それで十分であり、そのチェックをすり抜ける15%のルールについての検証は既存の静的解析ツールに任せればよいというものです。
Seifは、最も新しい静的解析ツールMagoの開発者です。
Psalmの開発者Daniil Gentiliと、PHPStanの開発者Ondřej Mirtesも、このアプローチに賛成しています。
他にも多くの開発者が賛成の立場をとっています。
しかし、このアプローチに疑問を呈する人も少なくなく、私もその一人です。
"Compile" time
昨年PHP Foundationのブログ記事でも述べたように、PHPでもジェネリクスの大部分はコンパイル時に処理することが可能です。
主にジェネリクスの宣言部分、すなわち子クラスが親クラスのジェネリクス規則に従うことです。
当時Gina Banyardは、"associated types"と呼ばれるジェネリクスの一種に取り組んでいました。
その構文に対するフィードバックは賛否両論であり、その後公の動きはなくなりました。
今回のRFCは、同じアイデアを可能なかぎり推し進めています。
これによって以下の制限が強制されます。
・シンタックス
・ジェネリクスのルール
・デフォルト型
・バリアンス ( 引数専用とマークされた型は返り値に使用できない )
・子クラスの型は、親クラスのジェネリクスに従わなければならない
・オブジェクト生成のジェネリクスは部分的にサポートされる
これはジェネリクスの大きな部分を占めます。
逆に、主に3つの機能がサポートされません。
class Group<T> {
public function __construct(T $val) { ... }
public function add(T $val) { ... }
}
$list = new Group::<User>(new Product());
$list->add(new Product());
このRFCでは、Groupクラスに型引数がひとつであること、そしてUserが有効な型であることはチェックします。
しかし、コンストラクタに渡される型がUser型でないこと、addメソッドに渡される型がUser型でないことはチェックされません。
function doStuff(Group<User> $group) { ... }
実行時に、$groupがGroupクラスであることはチェックされますが、Userクラスであることはチェックされません。
if ($g instanceof Group<User>) { ... }
これはそもそも有効な構文ではありません。
また、もうひとつ重要な指摘は、ジェネリクスがオプションであることです。
ジェネリクスの指定がない場合はmixed型が適用されます。
従って、
$g = new Group(new Product());
は有効です。
これによって、クラスの呼び出しの互換性を崩すことなく、クラスにジェネリクスを実装することが可能になります。
クラスの呼び出し側は、型引数を順次追加していくことができます。
正直に言って、これはもっと注目されるべき点です。
これによってジェネリクスの導入の敷居が大きく下がるからです。
Is it enough?
しかし、これは十分なのでしょうか。
RFCでは、静的解析ツールは既にジェネリクスを使用でき、ジェネリクスに関心のある人は既に静的解析ツールを導入しているはずだから、このRFCでチェックできないジェネリクスについては静的解析ツールで代替すれば問題ないとしています。
静的解析ツールの開発者たちは皆このRFCに対応すると表明しており、これは既に静的解析ツールを導入している開発者にとっては素晴らしい仕事です。
問題はここにあります。
『ジェネリクスを必要としている』と『既に静的解析ツールを使っている』の2つのグループがどのくらい重なっているのか、我々には全くわからないのです。
RFCではこのふたつをイコールであると仮定していますが、その根拠は薄弱です。
またジェネリクスが導入されれば、その現状も通用しなくなるでしょう。
ジェネリクスが導入されたあと、2年以内にジェネリクスに遭遇して対応しなければならなくなる開発者は9割を超えると思われます。
今後のPHPへの機能追加などもジェネリクスを前提とすることは間違いないので、ジェネリクスを避けることができなくなることは間違いありません。
しかし、静的解析ツールはどうでしょう?
静的解析ツールがどれくらい普及しているのか、だれにも分かりません。
私は使用していますし、多くの開発者が使用していることも知っていますが、使用していない開発者がたくさんいることも知っています。
彼らにとっては、一見機能している言語構造に見えて、実際は機能していない言語構造が存在するということです。
PHP本体に"何もしない"構文が導入されるのは、今回が初めてのことです。
The deciding factor
すなわち、最大の懸念点は、静的解析ツールを使っていないがゆえに"何もしない"構文に引っかかってしまう開発者の割合が、許容できるほどに低いのかということです。
このRFCはジェネリクスを実現するこれまでで最高のチャンスであり、それが許容できる程度であれば、ぜひ支持したいものです。
明示されている欠点以外は非常に優れているRFCであり、ほぼ全面的に気に入っています。
使用可能になり次第、すぐに使いたいと思っています。
しかし、その割合が許容できるほどに低くない場合、問題が発生します。
つまり、安全であると思い込んで安全でないコードを書いてしまう開発者が、相当数存在している可能性があるということです。
そして彼らは不可解な型エラーに遭遇することになります。
「杜撰なプログラマは杜撰なコードを書くものだ、そんな全員を救うことはできない」という意見もあるでしょうが、このRFCは今後の開発のベースラインとなるものです。
将来的にジェネリクスの型を強制できる範囲が拡大したとしましょう。
これまで問題なかったジェネリクス構文が、ある日突然実行時エラーになります。
Conclusion
この『概ね強制するジェネリクス』はどのくらいの地雷になるでしょうか。
正直なところ、わかりません。
その理由から、私はこのRFCについての判断を保留しています。
私は静的解析ツールを使用しており、機会さえあればジェネリクスを採用したいと思っているので、まさにこのRFCのターゲットユーザです。
しかし、プロジェクトとしてはターゲットユーザ以外への影響も考慮しなければなりません。
悪いコードは切り捨ててサポートしない、とするのも程度によっては正しいでしょう。
PHPは20年にわたり、本来許容されるべきではない杜撰なコードを許容していた穴を埋めるために努力してきました。
この流れを逆行させることは避けたいものです。
コミュニティからのフィードバックは、代表的なサンプルとは言えません。
静的解析ツールを使用しているユーザからしか回答が得られないでしょう。
RFC2は大きなメリットがあり、ぜひ導入したいのですが、蒸しすることのできないデメリットも存在します。
そして、そのバランスに今も取り組んでいます。
その他の意見
PHPコア開発者のひとり、Gina P. Banyardはもっと強硬に、明白に反対を主張しています。
他にもコア開発者の多くは、実行時に消えてしまう型消去ジェネリクスではなく、実行時まで残って型チェックを実際に行う具象化ジェネリクスを希望しています。
いっぽう静的解析ツールの開発者はというと、PHPStan・PsalmいずれもこのRFCに大賛成です。
Magoに至ってはそもそもRFCの作者です。
そしてGitHubやExternalに目を通すレベルの開発者は当然静的解析ツールも使っているので、ほぼ全員が賛成です。
感想
コア開発者とそれ以外の、このRFCに対する温度の差は、静的解析ツールを使っていない人のことを考える必要がある人と必要のない人の違いと言えるでしょう。
Magoの開発者はMagoを使っていない人のことなんて知ったこっちゃないですが、PHP本体の開発者は「なんかよくわからないけどerror_reporting(0)ってしたら動いた」という人まで相手にしないといけませんからね。
また、これまで『フラグによってオンオフできる機能』をPHP本体に導入してはそのたびに痛い目を見続てきた経験からしても、『とりあえず型消去ジェネリクスで実装して、具象化ジェネリクスは後から導入すればいい』という姿勢に忌避感を覚えているようです。
個人的にも、『一見正式なPHPの構文のように見えるけど実際はコメントと同じレベルの意味のない文』の導入はちょっと微妙感がありますね。
new Group::<User>(new Product());がエラーにならないのは気持ち悪すぎる。
そんなわけで具象化ジェネリクスが完成すれば八方全て丸く収まってめでたしめでたしなわけですが、なんかさくっとできたりしませんかね。
ところでPRを覗いてみたところ、
class Box<T> {
public function take(?T $v): void {
var_dump($v);
}
}
class IntBox extends Box<int> {}
$b = new IntBox();
$b->take("hello");
// Box::take(): Argument #1 ($v) must be of type ?int, string given, called in %s on line %d
なんかIntBoxがエラーになってるんだけどどゆこと?
どこかで修正された?
それとも型チェックするしないの条件をなんか見逃してたりするんですかね。
誰か教えてちょ。
というか、これがエラーになるのであればこのまま導入されても問題ないような(掌クルー)