型付きプロパティのuninitializedは、nullでもundefinedでもない

型付きプロパティに初期値を書かないと、そのプロパティは null ではなく「未初期化(uninitialized)」という状態になります。読み出すと Error が投げられます。

これ、PHP 7.4で型付きプロパティが入ってからずっと踏まれ続けているやつですね。既存のコードに型を付けて回っていたら、テストが Typed property Foo::$bar must not be accessed before initialization で真っ赤になった、という経験は私にもあります。

厄介なのは、これが「バグを教えてくれるエラー」でもあるところです。単に黙らせるのではなく、なぜ第三の状態が必要なのかを分かった上で潰したい。今日はそのあたりを実務目線で。

nullではない、というのが全部の起点

まず素の挙動から。型なしプロパティは暗黙に null で初期化されますが、型付きはそうではありません。

class User
{
    public string $name;
    public ?string $email;   // nullable でも null にはならない
    public array $roles = [];
}

$u = new User();
var_dump($u->roles);   // array(0) {}  ← デフォルト値があるので初期化済み
echo $u->name;         // Error: Typed property User::$name must not be
                       // accessed before initialization

?string $email が null にならないのが引っかかりどころです。nullable は「null を代入してよい」であって「初期値が null」ではありません。null を初期値にしたいなら public ?string $email = null; と明示的に書く必要があります。

逆に言うと、PHPは「まだ何も設定されていない」と「null が設定された」を区別できるようになった、ということでもあります。個人的には、この区別ができること自体は正しい方向だと思っています。うっとうしいだけの仕様ではありません。

未初期化かどうかはどう判定する?

=== null では判定できません。読んだ瞬間に Error になるからです。使えるのは isset() と Reflection の2つです。

$u = new User();

var_dump(isset($u->name));          // false
var_dump($u->name ?? 'ゲスト');      // 'ゲスト'(?? は isset 相当なので安全)

$rp = new ReflectionProperty(User::class, 'name');
var_dump($rp->isInitialized($u));   // false

$u->name = 'のり';
var_dump(isset($u->name));          // true

ReflectionProperty::isInitialized() は型付きプロパティと同時に7.4で入ったメソッドで、isset() と違って「null が入っている」場合でも true を返します。ORMのハイドレーションや、シリアライザを自作するときはこちらが必要になります。isset() だけだと「未初期化」と「null が入っている」の区別がつきません。

なお、読む前に書くのは何の問題もありません。未初期化チェックが走るのは読み出しのときだけです。

class Config
{
    public string $path;

    public function boot(): void
    {
        $this->path = '/tmp';   // 読まずに書くだけなら合法
    }
}

unset()すると挙動が変わる、という罠

ここが一番ハマります。型付きプロパティを unset() すると未初期化状態に「戻る」……のですが、__get() が定義されている場合の振る舞いが、一度も代入していない場合と違います。

class Row
{
    public string $title;

    public function __get(string $name): mixed
    {
        return "lazy:{$name}";
    }
}

$r = new Row();
// echo $r->title;   // 一度も代入していない → __get は呼ばれず Error

$r->title = 'A';
unset($r->title);
echo $r->title;      // "lazy:title"  ← 今度は __get が呼ばれる

つまり unset() はプロパティを「宣言されていない」に近い状態にするので、マジックメソッドの入口が開いてしまう。遅延ロードを unset() + __get() で組んでいるライブラリはこの挙動を利用しているのですが、知らずに unset() を書くと、Error のはずのところが静かに変な値を返します。この差はPHP本体のバグトラッカーでも何度も議論になっている部分で、エラーメッセージの文言などはバージョンで揺れがあります。挙動に依存したコードは書かないほうが無難です。

なぜダンプに出てこないのか

未初期化のプロパティは「アクセスできないもの」として扱われるため、配列化系の関数からごっそり消えます。デバッグ中に「プロパティが存在しないことになっている」と混乱するのはこれが原因です。

class Dto
{
    public string $a;          // 未初期化
    public string $b = 'b';
    public $c;                 // 型なし → null で初期化済み
}

$d = new Dto();

var_dump(array_keys(get_object_vars($d)));  // ['b', 'c'] ... 'a' が消える
echo json_encode($d);                        // {"b":"b","c":null}

foreach でオブジェクトを回した場合も同じで、未初期化のものはスキップされます。var_dump() だけは例外的に uninitialized という表示で存在を教えてくれるので、原因調査のときは var_dump() を使ったほうが早いです。JSON APIのレスポンスからキーが1つだけ消える、みたいな地味な事故はだいたいこれですね。

readonlyと組み合わさると逃げ道がなくなる

PHP 8.1の readonly プロパティは、初期化済みのものを unset() できません。「一度リセットして詰め直す」が使えなくなります。

class Money
{
    public function __construct(
        public readonly int $amount,
    ) {}
}

$m = new Money(100);
unset($m->amount);   // Error: readonly プロパティは unset できない

これが効いてくるのがORMやシリアライザで、ReflectionClass::newInstanceWithoutConstructor() でコンストラクタを飛ばして生成すると、全プロパティが未初期化のインスタンスが出来上がります。そこから Reflection で値を流し込む、という流れになるのですが、readonly は宣言されたクラスのスコープからしか初期化できないので、素朴に書くと詰まります。Doctrine あたりが readonly 対応で苦労していたのは、ざっくりこの手の話です。

で、どう書くのが正解か

結論はシンプルで、「未初期化の窓を作らない」に尽きます。コンストラクタで全部埋めるか、デフォルト値を書くか、どちらかです。

class User
{
    public function __construct(
        public string $name,
        public ?string $email = null,   // null 許容なら明示的に = null
        public array $roles = [],
    ) {}
}

コンストラクタプロモーションを使えば、宣言と初期化がひとつの場所にまとまるので、そもそも未初期化の状態が存在しなくなります。「後から setter で入れる」を諦めると、この問題の9割は消えます。

どうしても遅延初期化が必要な場合(DBコネクションなど)は、プロパティを直接公開せず、getterの中で isset() を見て初期化するのが素直です。呼び出し側に未初期化状態を見せない、というのが要点ですね。

class Repository
{
    private PDO $conn;

    private function conn(): PDO
    {
        if (!isset($this->conn)) {
            $this->conn = new PDO('sqlite::memory:');
        }
        return $this->conn;
    }
}

まとめ

型付きプロパティの未初期化は、null でも undefined でもない第三の状態で、読み出すと Error になり、isset() では false、ダンプ系からは消えます。エラーとして表に出てくるぶん、旧来の「気づかないうちに null が流れていく」よりはずっと健全だと思います。設計としては、コンストラクタで埋めきってこの状態を作らないのが一番きれいです。それでも遅延初期化が要るときだけ、isset() で守ったgetterに閉じ込める。それくらいの温度感でいいのかなという気がしています。

よくある質問

Q. 未初期化かどうかを安全に調べるには?
A. isset($obj->prop)ReflectionProperty::isInitialized($obj) を使います。$obj->prop === null は読み出しが発生するため Error になり、判定には使えません。

Q. ?string $x; はなぜ null にならないのですか?
A. nullable は「null を代入できる」という意味であって、初期値の指定ではないためです。初期値を null にしたいなら public ?string $x = null; と書きます。

Q. 型を外せば回避できますか?
A. 型なしプロパティは null で初期化されるのでエラーは消えますが、型安全も一緒に消えます。デフォルト値かコンストラクタでの初期化で対応するほうが健全です。

類似投稿

コメントを残す

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