PHPの__callと__callStaticを実務で安全に使う

__call() は、存在しないメソッドや外から見えないメソッドを呼び出したときに、代わりに実行されるPHPのマジックメソッドです。静的呼び出しの場合は __callStatic() が担当します。

フレームワークのコードを読んでいて「このメソッド、定義がどこにも無いのに動いてる」と首をかしげたことはないでしょうか。だいたい犯人は __call です。ファサードもクエリビルダも、たいていこの上に乗っています。

便利なのは間違いないのですが、使い方を間違えると「grepしても出てこないメソッド」を量産することになります。今日は実務で踏みやすいところを中心に書いておきます。

いつ呼ばれるのか、を正確に押さえる

PHPマニュアルの言葉では「アクセスできないメソッド(inaccessible methods)」を呼んだときに発火します。これは「定義されていない」だけでなく「可視性のせいで呼べない」も含みます。ここが見落とされがちです。

<?php
declare(strict_types=1);

class Report
{
    public function __call(string $name, array $arguments): mixed
    {
        return "__call: {$name}";
    }

    private function secret(): string
    {
        return 'private';
    }
}

$r = new Report();
echo $r->secret(), PHP_EOL; // __call: secret

クラスの外から private メソッドを呼ぶと、Error にはならず __call に流れます。__call を持つクラスに private メソッドを足すときは、名前が外部から叩かれる可能性を一度考えたほうがいいですね。

まずは委譲(デコレータ)から使うのが無難

__call のいちばん行儀のいい使い道は、内部のオブジェクトへそのまま丸投げすることです。ログを挟む、計測する、といった薄い層を作るときに効きます。

<?php
declare(strict_types=1);

final class LoggingProxy
{
    public function __construct(
        private object $inner,
        private \Psr\Log\LoggerInterface $logger,
    ) {
    }

    public function __call(string $name, array $arguments): mixed
    {
        $start = hrtime(true);

        try {
            return $this->inner->{$name}(...$arguments);
        } finally {
            $ms = (hrtime(true) - $start) / 1_000_000;
            $this->logger->debug(sprintf('%s took %.2f ms', $name, $ms));
        }
    }
}

ポイントは ...$arguments でそのまま展開していることです。引数の数を数えたり switch を書いたりしなくていい。finally に置いているので、例外が飛んでも計測ログは残ります。

個人的には、__call を書くならこの「素通し」の形が一番安全だと思っています。振る舞いを増やすのではなく、既にある振る舞いを包むだけなので。

__callStatic は static を付け忘れると動かない

マニュアルのシグネチャはこうなっています。__call は非静的、__callStatic は静的。ここは対称ではありません。

<?php
declare(strict_types=1);

class Facade
{
    public function __call(string $name, array $arguments): mixed
    {
        return null;
    }

    public static function __callStatic(string $name, array $arguments): mixed
    {
        return static::resolve()->{$name}(...$arguments);
    }

    protected static function resolve(): object
    {
        // 実際はコンテナから取り出す
        return new \stdClass();
    }
}

そしてPHP 8.0からは、マジックメソッドのシグネチャが厳密にチェックされるようになりました。引数を string $name 以外の型で宣言したり、引数の数が合っていなかったりすると、実行前に致命的エラーで弾かれます。昔のコードを8系へ上げたときに、ここで止まるのはよくある話です。

なぜIDEも静的解析も黙ってしまうのか

__call で生えたメソッドは、コード上のどこにも定義がありません。だからIDEは補完できないし、PHPStanやPsalmは「未定義メソッド」として怒るか、あるいは何も見えないまま素通しします。どちらにしても嬉しくない。

対処は、クラスのDocコメントに @method を書いておくことです。地味ですが、これがあるかないかで実務の体感がかなり変わります。

<?php
declare(strict_types=1);

/**
 * @method self whereId(int $id)
 * @method self whereName(string $name)
 * @method array get()
 */
final class QueryBuilder
{
    /** @var array<int, array{string, mixed}> */
    private array $conditions = [];

    public function __call(string $name, array $arguments): mixed
    {
        if (str_starts_with($name, 'where')) {
            $column = strtolower(substr($name, 5));
            $this->conditions[] = [$column, $arguments[0] ?? null];

            return $this;
        }

        throw new \BadMethodCallException("Undefined method: {$name}");
    }
}

もうひとつ大事なのは、最後の throw です。想定外の名前を黙って null で返すと、呼び出し側は「メソッドはあるのに何も起きない」という最悪のデバッグ体験をします。知らない名前は BadMethodCallException で落とす。これは必ず入れたほうがいいと思います。

method_exists は false、is_callable は true

ここは実際にハマったところです。__call で受けるメソッドは実体が無いので、method_exists() は false を返します。一方 is_callable() は、__call を持つオブジェクトに対しては、どんな名前でも true を返します。

<?php
declare(strict_types=1);

$builder = new QueryBuilder();

var_dump(method_exists($builder, 'whereId')); // bool(false)
var_dump(is_callable([$builder, 'whereId'])); // bool(true)
var_dump(is_callable([$builder, 'nonsense'])); // bool(true)

つまり is_callable() は「呼べるかどうか」の判定にほぼ使えません。__call の中で例外を投げる設計なら、なおさらです。存在チェックをしたいなら、自前で許可リストを持つか、__call 側の判定ロジックを公開メソッドとして切り出すほうが確実です。

名前付き引数を渡すと $arguments のキーが文字列になる

PHP 8以降の落とし穴です。__call はシグネチャを持たないため、名前付き引数は可変長引数と同じ扱いになり、$arguments に文字列キーとして入ってきます。

<?php
declare(strict_types=1);

class Dumper
{
    public function __call(string $name, array $arguments): mixed
    {
        var_dump($arguments);

        return null;
    }
}

(new Dumper())->run(1, 2, limit: 10);
// array(3) { [0]=> int(1) [1]=> int(2) ["limit"]=> int(10) }

$arguments[0] のように添字で決め打ちしているコードは、呼び出し側が名前付き引数を使った瞬間に静かに壊れます。委譲するだけなら ...$arguments で展開すれば文字列キーは名前付き引数として正しく渡るので問題ありません。自前で中身を解釈するときだけ注意、という整理でいいと思います。

参照渡しは諦める

マニュアルに明記されていますが、マジックメソッドの引数は参照渡しできません。$arguments に入るのは値のコピーです。

委譲先のメソッドが参照で受け取る設計になっていると、__call 経由では期待どおりに動きません。これは回避策で頑張るところではなく、そもそも参照渡しのAPIを __call の向こう側に置かない、と決めるのが現実的だと思います。

まとめ

__call は「メソッドを動的に生やす道具」というより「呼び出しを横取りして転送する仕組み」と捉えたほうが、事故が減ります。委譲に使い、@method で見える化し、知らない名前は例外で落とす。この三つを守っていれば、だいぶ穏やかに付き合えます。

逆に、if 文を並べてメソッド名から挙動を分岐させ始めたら、それは普通のメソッドを書いたほうが早い合図かもしれません。魔法は少ないほど、あとで読む人が助かります。

よくある質問

Q. __call と __callStatic は両方定義しないといけませんか?
A. いいえ、必要なほうだけで構いません。オブジェクト経由の呼び出しは __call、クラス名からの静的呼び出しは __callStatic と、発火する条件がはっきり分かれています。

Q. __call の中から本当のメソッドを呼ぶと無限ループしませんか?
A. 実在して呼べるメソッドなら __call は発火しないのでループしません。危ないのは、存在しない名前を __call の中で $this->{$name}() のように呼んでしまうケースで、これは無限再帰になります。

Q. パフォーマンスへの影響は気にすべきですか?
A. 通常のメソッド呼び出しよりは確実に遅くなりますが、たいていのアプリでは誤差の範囲です。ただし何万回も回るループの内側で __call を通すのは避けたほうが無難です。

類似投稿

コメントを残す

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