Reflectionで依存を自動解決する — 簡易DIコンテナで学ぶ実行時型情報

PHPのReflectionは、クラスやメソッド、引数の型情報を実行時に取得・操作するための標準APIです。DIコンテナやORM、テストのモック生成は、ほぼこれで動いています。

フレームワークのコンテナに型を書いておくだけでインスタンスが降ってくる、あれを最初に見たときは正直「どうやってるんだろう」と思いました。種を割ると、やっていることは驚くほど素直です。ReflectionClassでコンストラクタを覗き、引数の型を見て、その型をまた解決する。それだけ。

ただ、素直なぶん、型システムの端っこに当たったときのハマり方も素直ではありません。今日はその辺りを中心に。

コンストラクタから依存を辿るとはどういうことか

まず動く最小のコンテナを書いてみます。再帰で依存を解決し、一度作ったものは使い回す、という一番よくある形です。

final class Container
{
    /** @var array<string, object> */
    private array $instances = [];

    public function get(string $class): object
    {
        return $this->instances[$class] ??= $this->build($class);
    }

    private function build(string $class): object
    {
        $ref = new ReflectionClass($class);

        if (!$ref->isInstantiable()) {
            throw new RuntimeException("{$class} はインスタンス化できません");
        }

        $ctor = $ref->getConstructor();
        if ($ctor === null) {
            return new $class();
        }

        $args = [];
        foreach ($ctor->getParameters() as $param) {
            $args[] = $this->resolve($param);
        }

        return $ref->newInstanceArgs($args);
    }

    private function resolve(ReflectionParameter $param): mixed
    {
        $type = $param->getType();

        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
            return $this->get($type->getName());
        }

        if ($param->isDefaultValueAvailable()) {
            return $param->getDefaultValue();
        }

        if ($type !== null && $type->allowsNull()) {
            return null;
        }

        throw new RuntimeException("引数 \${$param->getName()} を解決できません");
    }
}

ポイントはisBuiltin()です。intstringのような組み込み型はtrue、クラス名やインターフェース名ならfalseを返す。つまり「falseなら再帰的に解決していい型」という判定に使えます。ここが分岐の軸になっているおかげで、コンテナ側は具体的なクラス名を一つも知らなくて済みます。

なぜ nullable な依存で挙動が変わるのか

上のコードには、ぱっと見では気づきにくい穴があります。?LoggerInterface $logger = null のような引数です。

getType()が返すのはReflectionNamedTypeで、getName()"LoggerInterface"を返します。?は名前に含まれません。そしてisBuiltin()false。結果、「省略可能な依存」のつもりで書いたものが、問答無用で解決されにいきます。nullかどうかはallowsNull()で別に聞かないと分からない、というのが引っかかりどころですね。

「デフォルト値があるなら、まずそれを尊重する」に順序を変えるだけで直ります。

private function resolve(ReflectionParameter $param): mixed
{
    $type = $param->getType();

    // 明示的に「省略できる」と宣言されているものを先に拾う
    if ($param->isDefaultValueAvailable()) {
        return $param->getDefaultValue();
    }

    if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
        return $this->get($type->getName());
    }

    if ($type?->allowsNull()) {
        return null;
    }

    throw new RuntimeException("引数 \${$param->getName()} を解決できません");
}

どちらを先にするかは設計思想の問題で、正解が一つあるわけではありません。ただ、「デフォルト値を書いた人の意図」より「型」を優先する挙動は、書いた側から見ると不意打ちになりやすい。個人的には、デフォルト値優先のほうが説明しやすいと思っています。

union型が来たときに何が返ってくるか

PHP 8.0でunion型が入ってから、getType()の戻りはReflectionNamedTypeだけではなくなりました。ReflectionUnionTypeが返ることがあり、こちらにはgetName()がありません。ReflectionIntersectionType(PHP 8.1で追加)も同様です。

instanceof ReflectionNamedTypeで先に絞っていれば落ちはしませんが、union型の引数が黙って「解決できません」になるのは分かりにくい。候補を平らに取り出す小さなヘルパを挟んでおくと、後で読み返したときに何をしているか分かります。

/**
 * @return list<ReflectionNamedType>
 */
private function namedTypes(?ReflectionType $type): array
{
    if ($type instanceof ReflectionNamedType) {
        return [$type];
    }

    if ($type instanceof ReflectionUnionType
        || $type instanceof ReflectionIntersectionType) {
        $out = [];
        foreach ($type->getTypes() as $inner) {
            // PHP 8.2 の DNF 型では、ここにさらに複合型が入りうる
            $out = array_merge($out, $this->namedTypes($inner));
        }
        return $out;
    }

    return [];
}

再帰にしてあるのは、PHP 8.2のDNF型((A&B)|null のような書き方)で、ReflectionUnionType::getTypes()の中にReflectionIntersectionTypeが混ざりうるからです。実務でDNF型を書く機会はそう多くないと思いますが、他人のコードを走査する側に回ると突然当たります。

private を覗くのに setAccessible はもう要らない

古い記事を参考にすると、まだこう書いてあることがあります。

$prop = new ReflectionProperty(User::class, 'passwordHash');
$prop->setAccessible(true);   // PHP 8.1.0 以降は何もしない
$hash = $prop->getValue($user);

PHP 8.1.0以降、ReflectionProperty::setAccessible()ReflectionMethod::setAccessible()は何もしないメソッドになりました。リフレクション経由なら最初から private も protected も読み書きできます。さらにPHP 8.5では、効果がないことを理由に非推奨になりました。8.5に上げた瞬間に大量の Deprecated が出る、というパターンです。今のうちに消しておいて損はありません。

$hash = (new ReflectionProperty(User::class, 'passwordHash'))
    ->getValue($user);

行が減るだけでなく、「なぜ setAccessible を呼ぶのか」を後任に説明する手間が消えます。

コンストラクタを通さずにオブジェクトを作る

ORMやシリアライザが「バリデーション付きのコンストラクタを持つ値オブジェクト」をDBの行から復元するとき、コンストラクタを呼ぶと都合が悪いことがあります。そこで使われるのがnewInstanceWithoutConstructor()です。

$ref   = new ReflectionClass(Money::class);
$money = $ref->newInstanceWithoutConstructor(); // コンストラクタは走らない

$prop = $ref->getProperty('amount');
$prop->setValue($money, 1000);

これで不変条件を素通りできてしまうので、アプリケーションコードで気軽に使うものではありません。使う場所はインフラ層のハイドレーションに限る、くらいの線引きが無難だと思います。

readonly プロパティも、まだ初期化されていなければリフレクション経由で値を入れられます。ただし一度初期化されたものは書き換えられませんし、親クラスで宣言された readonly を子クラス側から触ると「Cannot initialize readonly property from scope …」で止まることがあります。この辺りは踏むまで気づきにくいので、実際に使うPHPバージョンで一度試しておくのが確実です。

Reflectionは遅いのか

「Reflectionは遅い」はよく言われますが、実際に効いてくるのは呼び出し回数のほうです。1リクエストで同じクラスを何十回も解析していれば、そりゃ遅い。逆に、解析結果を持ち回れば問題になることはあまりありません。

final class ParamCache
{
    /** @var array<string, list<ReflectionParameter>> */
    private static array $cache = [];

    /** @return list<ReflectionParameter> */
    public static function of(string $class): array
    {
        return self::$cache[$class] ??= (new ReflectionClass($class))
            ->getConstructor()?->getParameters() ?? [];
    }
}

プロセス内で使い回すだけでも体感はだいぶ変わります。主要なフレームワークが本番向けに「コンテナのコンパイル」を用意しているのは、これをさらに進めて、解析結果を素のPHPコードとして書き出してしまう発想ですね。Reflectionを速くするのではなく、Reflectionを走らせる回数をゼロに近づける。考え方としては、こちらのほうが筋がいいと思います。

まとめ

Reflectionは「フレームワークの中の人が使うもの」に見えますが、コンストラクタの引数を辿って型を見る、というだけの単純な道具です。難しいのはAPIそのものではなく、nullable、union、DNF、readonly といったPHPの型の周辺が、そのまま反射して返ってくるところ。そこを一つずつ潰していくと、普段使っているコンテナが裏で何をやってくれていたのかが見えてきます。自作のコンテナを本番投入する必要はありませんが、一度書いてみると、フレームワークのエラーメッセージが急に読めるようになる、という副産物があります。

よくある質問

Q. Reflectionは本番環境で使っても大丈夫ですか?
A. 解析結果をキャッシュする前提なら実用範囲です。問題になるのは、毎リクエスト同じクラスを何度も解析している場合です。主要フレームワークは本番向けに解決結果をPHPコードへ書き出す仕組みを持っており、そちらを使えばReflectionの実行自体をほぼ避けられます。

Q. private プロパティを読むのに setAccessible(true) は必要ですか?
A. PHP 8.1.0以降は不要です。呼んでも何も起きません。PHP 8.5では非推奨になったため、残しているとDeprecated警告の原因になります。

Q. get_class_methods() などの関数では代用できませんか?
A. 名前の一覧だけなら取れます。ただし引数の型、デフォルト値の有無、戻り値の型、Attributeまで見たい場合はReflectionが必要です。DIコンテナのように型で分岐する処理は、関数群だけでは組み立てられません。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です