不変性は現代の言語がそろって強化している領域です。ただし、各言語の readonly や frozen が守る範囲は同じではありません。同じつもりで使うと、守られていると思っていた場所が書き換わります。
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 は既定が不変で、可変が例外です。所有権により、共有と変更が同時に起きないことまで保証されます。