概要
PHP で型のあるローカル変数を定義するライブラリを作った。
誤った型の値を代入しようとすると \TypeError
を投げる。
型を書ける奴だから TypeWriter か、とも思ったけど、長かったので Typist という名前にした。
PHP 7.4 以降で利用可能。
インストール
composer require sj-i/typist
基本的な使い方
use function Typist\int;
use function Typist\string;
// int 型と string 型のローカル変数を定義
$_ = [
int($hoge_id, 1),
string($hoge_name, 'name'),
];
// 渡した値で初期化される
assert(1 === $hoge_id);
assert('name' === $hoge_name);
// int 型の変数へ文字列を突っ込もうとすると TypeError
$hoge_id = 'a';
// string 型の変数へ int の値を突っ込もうとすると TypeError
$hoge_name = 1;
どうなってんの?
PHP 7.4 で型付プロパティが導入された。
こいつには面白い性質があって、ある変数が型付プロパティと同じものを参照している間、その変数は同じものを参照する全ての型付プロパティで宣言された型の制約を引き継いで受けることになる。
よって指定した型の型付プロパティを持つオブジェクトを生成し、参照渡し経由で変数を型付プロパティに参照させ、オブジェクトは GC されないよう使わない変数 $_
でつかまえておくことで、この型付ローカル変数も実現できる。
なるほど分からん
型付プロパティの RFC に詳しく書いてある。
ピンとこないとしたら、たぶん PHP における参照がどういう意味のものか、がピンとこないのだと思う。俺もこの RFC を見るまでよく分かっていなかった。ので、PHP の参照について簡単に説明しよう。
PHP の参照
$a = 1;
$b =& $a;
$c =& $b;
上記のようなコードについて考えよう。
最初の 1 行目 $a = 1;
の状態を雑に図示するとこう。
超シンプル。
次に 2 行目の $b =& $a;
の状態を、同じく雑に図示するとこう。
$b
は $a
を指すというより、$a
が指すものと同じものを指す仲間に加わる感じ。
そしてお察しのとおり、更にその次の $c =& $b;
の状態はこうなる。
$c
が $b
を指すように、と数珠つなぎになっていくわけではなく、やはり同じものを指す仲間に加わる感じ。
同じものを指す仲間達をひとまとめにし、(型付プロパティの RFC の言葉を借りて)参照セットとでも呼ぶことにしよう。参照セットにあるどの変数への代入も、同じセット内の他の変数の値へ影響する。同時に一括代入しているようなもの、というとらえ方もできる(実際の実装は異なるが、概念的にはそうもとらえられる)。
参照と型付プロパティ
さて、この参照セットのメンバへ、型付プロパティが含まれている場合は何が起きるだろうか?
プロパティだと $a
とか $b
のままじゃ不自然なので、オブジェクト $o
のプロパティだということにしよう。
$o = new class() {
public int $a = 1;
public float $b;
};
$o->b =& $o->a;
$c =& $o->b;
型付プロパティは、プロパティが宣言された型の値を持つこと、読み込み時に指定した型の値だけが得られることを保証する仕組み。これを実現するには代入時に値の型の検査が必要。
$o->a
へ何かの値を代入するとする。
$o->a = 1; // 合法
$o->a = 'abc'; // $o->a の型宣言は int なので違法、TypeError
分かりやすい。
しかし、次の例ではどうだろう。
$o->b = 1.5; // 合法……?
var_dump($o->a); // $o->a の型宣言は int なのに 1.5 とか出ちゃうの……?
あるいはまた、次の例ではどうだろう。
$c = new DateTime(); // 合法……?
var_dump($o->a); // $o->a の型宣言は int なのに現在日時が出ちゃうの……?
同じ参照セットに含まれる全ての型付プロパティの型で型検査を行い、すべての型検査に通る値だけが代入に成功するのでなければまずい。型宣言によって得られる筈の保証が台無しになってしまう。そして実際に、PHP はそのように順繰りの型検査を行う。
1
は int
の型検査にも float
の型検査にも通るので代入可能。
1.5
は int
の型検査を通らないので代入不可能。
日時型は int
の型検査にも float
の型検査にも通らないので代入不可能。
このようにして、型付プロパティと同じ参照セットのメンバであるただのローカル変数 $c
は、めでたく代入時に型検査の行われる変数になった、というわけだ。
この参照と型付プロパティについての挙動を把握すると、うまくやれば型付ローカル変数を定義するためのライブラリを作れそうな気がしてくる。
既存の実装
先に今回とってない実装方針について、他実装を紹介しつつざっくり説明。
型付プロパティの参照を取り出して参照代入でローカル変数へ突っ込めば、その変数は元の型付プロパティと同じ型の検査を受けるローカル変数になる。
比類なき nikic の人が Poor Man's Typed Variables として紹介してるのはこちらの方針。
function &int(int $i) {
$obj = new class { public int $prop; };
$obj->prop = $i;
$GLOBALS['huge_memory_leak'][] = $obj;
return $obj->prop;
}
$i =& int(0);
$i = "foobar";
// Uncaught TypeError: Cannot assign string to
// reference held by property class@anonymous::$prop
// of type int
このスライドの実装を見ると分かる通り、型付プロパティを持つオブジェクト(以下型付プロパティオブジェクト)を GC させないよう、refcount1 を保つためにグローバル変数へ生成したオブジェクトを突っ込んでる。もちろんめっちゃメモリリークする。
azjezz/typed という、型付プロパティの参照で型付ローカル変数を、というのをやってる別ライブラリがある。こちらもおおむね同じやり方。虚空から欲しい型の値を生成してる感を出すため、各変数について生成する型付プロパティオブジェクトはグローバルステートへ突っ込み、refcount を維持する。もちろんめっちゃメモリリークするので、手動のメソッド呼び出しで型付プロパティオブジェクトを開放する方法を提供している。
らなくあさんが以前に紹介していたこの手法の例示も、先にオブジェクトを生成し、プロパティの参照代入でローカル変数へ持ってくるというやり方↓。
これらの実装を見ての感想は↓のような感じ。
-
なんとなく参照代入の
&
がかわいくない気がする - メモリ管理をそんなに頑張りたくない
- 利用側で謎オブジェクトを生成するのは嫌、なんかそれっぽい関数経由で変数定義したい
今回の実装
-
&
が露出しないよう参照渡しを利用した- 型付きプロパティが型を付けたい変数と同じ参照セットに入ればよいのだから、プロパティからの参照代入ではなくプロパティへの参照代入でも同じことができる、というわけだ
- 型指定で参照渡しした変数は psalm が別の型による再代入へ警告をくれるので、静的解析へも対応
- 参照渡しの利用によって返り値の枠が空いたので、メモリ管理を頑張らないで済むよう型付プロパティオブジェクト(Enforcer)をそのまま return し、ローカル変数で受け取って、呼び出し側でライフタイムを管理する仕組みにした
- refcount 維持用に持つだけでアクセスの必要はないオブジェクトなので、見た目の存在感が薄めのローカル変数として
$_
という配列を用意し、そこへ突っ込んでいく -
$_
は psalm のような静的解析ツールで未使用変数警告を出さず無視してくれる名前でもある
- refcount 維持用に持つだけでアクセスの必要はないオブジェクトなので、見た目の存在感が薄めのローカル変数として
- 静的メソッドと通常関数の二通りのインターフェースを用意した
- 今のところスカラ型とクラス / interface、nullable の型指定に対応
- iterable とか array もやれそう、ただ要素型指定できない奴とか要らない気もする
- callable はとりあえず諦め
お前この裏技に否定的じゃなかったっけ?使い道は?
- PHP 7.4 リリース前の去年 1 月時点でわりと否定的なことを言ってた
- つい最近また同様に否定的なことを言う機会があった
- その流れで、PHP 8 の機能を使って何か面白いことやれないかな、とガチャガチャ試す時、そういえばこんなのあったなと少し書いてみるのにつながる
- ガチャガチャやってる間にそれっぽいライブラリになってしまったので、一応公開、という形
- ローカル変数の型を実行時に強制するのがそんなに有用か、には引き続き懐疑的姿勢
- PHP 7.4 までの言語の型機能と静的解析ツールの型推論に型アノーテーション、それに加えて webmozart/assert あたりで用足りるのではないか
- クラスだのメソッドだの関数だの適度に小分けにコード書いてると、ローカル変数はごく短命となる筈で、引数や返り値のような断続的な型チェックでもわりと用が足りてしまい、こういうハックの有用性は薄れる筈
- ローカル変数の寿命が長く型の動きが不安なコード、であれば、こういうのも多少使う意味があるかもしれない(PHP 7.4 だけど魂はレガシーコード、みたいな奴があれば)
- 静的解析向けの型アノーテーションは油断すると嘘をつく場合もあるので、不安な箇所でランタイムのチェックも足す用、という意味はあるかもしれない
おまけ
- PHP 8 でクラス定数に class-string を突っ込んで
A::B::C::D::E
のように延々解決していく、という謎の連鎖ができるようになったので、記念に やや言語内 DSL っぽい雰囲気を感じる書き方 を PHP8 ではできるようにしてみた。はやく PHP8 も来るといいな!
-
(「参照カウント」と呼ぶと紛らわしい話の流れなので refcount 呼ばわり) ↩