PHPのselfとstaticの戻り値型を使い分ける

戻り値型の self は「その宣言が書かれたクラス」、static は「実際に呼ばれたクラス」を指します。継承した子クラスで型が食い違うときは、たいてい static が正解です。

ファサードやビルダーで return $this; を並べていると、あるとき静的解析が急に文句を言い出します。「メソッドチェーンの途中で子クラスのメソッドが見つからない」というやつですね。親クラスの戻り値型が self のままだと、子クラスのインスタンスをチェーンしていても、解析器は親の型としてしか追ってくれません。

実行時には動くのに型だけが合わない、という地味にストレスの溜まるパターンです。今日はこのあたりの整理をしておきます。

selfとstaticは何が違うのか

self はコンパイル時に決まる静的な名前解決で、そのコードが書かれているクラスを指します。対して static は遅延静的束縛(Late Static Binding)で、実行時に呼び出し元のクラスへ解決されます。継承が絡んだ瞬間に、この差がそのまま型の差になります。

<?php
declare(strict_types=1);

class Base
{
    public static function makeSelf(): self
    {
        return new self();   // 常に Base
    }

    public static function makeStatic(): static
    {
        return new static(); // 呼ばれたクラス
    }
}

class Child extends Base {}

var_dump(Child::makeSelf() instanceof Child);   // bool(false)
var_dump(Child::makeStatic() instanceof Child); // bool(true)

new self() は何をどう継承しても Base しか返しません。名前付きコンストラクタを親に置いたのに子で使えない、という事故はほぼこれが原因です。

なぜチェーンでstaticが要るのか

フルエントインターフェースは return $this; を返すだけなので、実行時は何の問題もありません。困るのは型情報のほうです。親の戻り値型が self だと、子クラス独自のメソッドへ続けられなくなります。

<?php
declare(strict_types=1);

abstract class QueryBuilder
{
    /** @var list<string> */
    private array $wheres = [];

    public function where(string $cond): static
    {
        $this->wheres[] = $cond;
        return $this;
    }

    public function toSql(): string
    {
        return 'WHERE ' . implode(' AND ', $this->wheres);
    }
}

final class UserQuery extends QueryBuilder
{
    public function active(): static
    {
        return $this->where('deleted_at IS NULL');
    }
}

$sql = (new UserQuery())
    ->where('age >= 20')
    ->active()       // where() が self だとここで型が落ちる
    ->toSql();

echo $sql; // WHERE age >= 20 AND deleted_at IS NULL

実行結果は self でも同じですが、静的解析やIDE補完が通るかどうかが変わります。チェーンを提供する側のメソッドは、基本的に static にしておくのが無難だと思います。

イミュータブルなwither にも効く

値オブジェクトで withXxx() のような複製メソッドを書くときも同じ話です。clone $this は実際のクラスを複製するので、宣言だけ self にしていると型と実体がずれます。

<?php
declare(strict_types=1);

class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency = 'JPY',
    ) {}

    public function withAmount(int $amount): static
    {
        // readonly は clone しても外から再代入できないので、作り直す
        return new static($amount, $this->currency);
    }
}

ここで一つ注意があります。readonly プロパティは clone したオブジェクトでも書き換えられません(PHP 8.3 以降でも、書き換えられるのは __clone() の中だけです)。なので withメソッドは素直に new static(...) で組み立てるほうが安全です。

ただし new static() を使うと、子クラスがコンストラクタのシグネチャを変えたときに壊れます。継承させたくないなら final を付けてしまうのが手っ取り早いですね。

staticが使えない場所

static は戻り値型としてのみ許可されています。引数の型やプロパティの型には書けません。

<?php
class Foo
{
    // NG: Fatal error - static is not allowed as a property type
    // public ?static $next = null;

    // NG: Fatal error - static is not allowed as a parameter type
    // public function add(static $other): void {}

    // OK: 戻り値型だけは許可される
    public function me(): static { return $this; }
}

理屈としては、引数の型に static を許すと共変の向きが逆になって型安全が崩れるためです。引数側で「同じクラスであること」を要求したい場合は、self を使うか、実行時に $other instanceof static で確かめるしかありません。

親がselfでも子はstaticにできる

staticself の部分集合なので、戻り値型の共変性の範囲に収まります。つまり、親が self と宣言していても、子では static に狭められます。

<?php
class A
{
    public function make(): self { return new self(); }
}

class B extends A
{
    public function make(): static { return new static(); } // OK(共変)
}

逆に、親が static で子が self は不可です。エラーになります。既存クラスの戻り値型を self から static へ変えるのは後方互換を壊しにくい変更なので、リファクタリングの入口としては扱いやすいところです。

まとめ

継承される可能性のあるクラスで、自分自身を返すメソッドを書くなら、まず static を検討する。self を選ぶのは「常にこのクラスを返す」と決めているときだけ。この判断だけで、フルエントAPIや名前付きコンストラクタまわりの型の食い違いはかなり減ります。

一方で new static() は子クラスのコンストラクタ次第で簡単に壊れるので、継承を前提にしないクラスには final を付けておく。結局、型を正しく書くことと継承を許すかどうかを決めることはセットなのだ、という気がしています。

よくある質問

Q. staticの戻り値型はどのPHPバージョンから使えますか?
A. PHP 8.0 からです。それ以前は @return static というPHPDocで静的解析にだけ伝える形が一般的でした。

Q. 引数の型にstaticと書けないのはなぜですか?
A. 引数の型は反変(親より広くはできても狭くできない)である必要があり、static を許すと子クラスで型が狭まってしまい、リスコフの置換原則が崩れるためです。戻り値型だけが共変なので、そこでのみ許可されています。

Q. トレイトの中でnew static()を書いても大丈夫ですか?
A. 動きます。トレイトは使用先のクラスに展開されるため、static は実際に呼ばれたクラスへ解決されます。ただし self はトレイトを使用しているクラスを指すので、こちらも意図とずれやすい点は同じです。

類似投稿

コメントを残す

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