不変性は現代の言語がそろって強化している領域です。ただし、各言語の readonlyfrozen が守る範囲は同じではありません。同じつもりで使うと、守られていると思っていた場所が書き換わります。

2 つの軸で整理する

不変性は何を固定するかどこまで固定するかの 2 軸で分かれます。

  • 束縛の不変 — 変数が別の値を指せなくなる。指している中身は変わりうる。
  • 値の不変 — 値そのものを変更できない。
  • 浅い不変 — 直下のフィールドだけ守る。
  • 深い不変 — 入れ子の中身まで守る。

多くの言語の readonly は「値の不変・浅い」です。ここを取り違えると事故になります。

PHP: readonly は初期化後の再代入を止める

readonly class UserData
{
    public function __construct(
        public int $id,
        public string $name,
    ) {
    }
}

readonly class は全プロパティを初期化後に変更不可にします。実行時に検査され、再代入すると Error になります。

ただし守るのはプロパティの参照です。プロパティがオブジェクトなら、そのオブジェクトの中身は変更できます。

readonly class Order
{
    public function __construct(
        public ArrayObject $items,
    ) {
    }
}

$order = new Order(new ArrayObject([]));
$order->items[] = 'apple';   // 通る

$order->items を別のオブジェクトに差し替えることはできませんが、中身は追加できます。浅い不変です。

Python: frozen も浅い

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class User:
    id: int
    name: str

frozen=True は属性の再代入を防ぎます。user.name = "x"FrozenInstanceError になります。

しかし中に可変オブジェクトを入れれば、そこは自由に変わります。

@dataclass(frozen=True)
class Order:
    items: list[str]


order = Order(items=[])
order.items.append("apple")   # 通る

tuple も同じです。tuple 自体は変更できませんが、要素のリストは変更できます。frozen も tuple も浅い不変である、と覚えておくのが安全です。

深くしたいなら、中に入れる値を最初から不変なものに限る設計が要ります。

Final はさらに性質が違います。

from typing import Final

MAX_RETRY: Final = 3

これは実行時に再代入を止めるものではなく、型検査器へ意図を伝える注釈です。mypy は再代入を指摘しますが、CPython は素通しします。

TypeScript: readonly は型の上だけ

class Money {
  constructor(
    readonly amount: number,
    readonly currency: string,
  ) {}
}

TypeScript の readonly はコンパイル時にのみ存在します。再代入はコンパイルエラーになりますが、生成後の JavaScript には何も残りません。外部から as any 経由で書き換えれば通ります。

実行時にも固定したいなら Object.freeze() を併用します。こちらは JavaScript の機能で、実行時に効きます。そして例によって浅い凍結です。

const order = Object.freeze({ items: [] });
order.items.push("apple");   // 通る

Rust: 既定が不変で、可変が例外

Rust だけ出発点が逆です。

let user = User { id: 1, name: String::from("alice") };
user.name = String::from("bob");   // コンパイルエラー

変更したいなら明示します。

let mut user = User { id: 1, name: String::from("alice") };
user.name = String::from("bob");   // 通る

さらに所有権と借用により、「誰が今この値を書き換えられるか」がコンパイル時に一意に定まります。可変参照は同時に 1 つしか存在できません。ここが他の言語の readonly と決定的に違う点で、浅い・深いという区別以前に、共有と変更が同時に起きないことが保証されます。

比較

言語 構文 効く時点 深さ
PHP readonly 実行時 浅い
Python frozen=True 実行時 浅い
Python Final 型検査時のみ 束縛のみ
TypeScript readonly コンパイル時のみ 浅い
JavaScript Object.freeze() 実行時 浅い
Rust 既定(mut で解除) コンパイル時 所有権で保証

まとめ

  • 不変性は「束縛の不変か値の不変か」「浅いか深いか」の 2 軸で見ると整理できます。
  • PHP の readonly、Python の frozen、TypeScript の readonly はいずれも浅い不変です。中に可変オブジェクトを入れれば守られません。
  • Python の Final と TypeScript の readonly は型検査器・コンパイラの中だけの約束で、実行時には残りません。
  • 深い不変が欲しいなら、構文に頼らず「中に入れる値を最初から不変にする」設計で担保します。
  • Rust は既定が不変で、可変が例外です。所有権により、共有と変更が同時に起きないことまで保証されます。