こんにちは。
最近Haskellを書いていない、型エンジニアのaiya000です!
皆さんは下記コードのreadonly
で、x.a
は変更されることがないと思っていませんか?
残念ですが……完全にはそうはなりません……。
const x: { readonly a: number } = { a: 42 }
TypeScriptの静的型付けの方針(?)で、静的に代入を許すことができてしまいます。
しかもここで紹介する、x.a
を変更する方法では、as
などのunsafeな操作は必要としません。
結論
結論からお話します。
下記コードで、x.a
が10
に変更できます!
const x: { readonly a: number } = { a: 42 }
const y: { a: number } = x
// x.aが変更されている
y.a = 10
console.log(x) // { a: 10 }
// コンパイルエラーはない
{ a: number } extends { readonly a: number }
になっているから
= { a: number }
の変数に{ readonly a: number }
の値を代入できるからです!
読み始める前に
本稿の主題は「静的型付き下でreadonlyの制約を破る」であり、
実行時(例えば下記コード)を考慮して制約を破ることは目標としていません。
(それは簡単すぎるので!)
const x: { readonly a: number } = { a: 42 }
const y: any = x
y.a = 10
const
とreadonly
とは?
const
上述のコードのx.a
は、ある方法で変更され得ります。
しかし本来は変更されるべきではないです。
どうして上述のコードが不変であるべきなのか、改めて再確認してみましょう。
まずconst
は「再代入」を許しません。
次の例で表せます。
const x: number = 42
x = 10 // コンパイルエラー
大丈夫です、このコードは正しくコンパイルエラーになります。
readonly
次にreadonly
です。
下記に示すコードも、正しくコンパイルエラーになります。
let x: { readonly a: number } = { a: 42 }
// ここはコンパイルエラーにならないので、
// 不変性を求める場合は通常、letではなくconstと組み合わせる。
x = { a: 10 }
// ここはコンパイルエラーになる。
x.a = 20
x.a
(readonlyプロパティ)への再代入は、コンパイルエラーになります。
const
+ readonly
TypeScriptでは、上述の2つを組み合わせて、不変なオブジェクトの変数を作ります。
const x: { readonly a: number } = { a: 42 }
x = { a: 10 } // コンパイルエラー
x.a = 10 // コンパイルエラー
これでx
はいかなる場合にも、プロパティの値が変わらないオブジェクトの変数になりました。
アプリケーションの設定など、アプリケーションの実行時に変更されたくない変数はこのテクニックを使うことで、実現することができます。
……
ごめんなさいというのは嘘です。
実際は、このx
のような変数は、変更され得ります。
不変性と部分型付け
なぜconst x: { readonly a: number } = { a: 42 }
のような変数は変更されうるのでしょうか。
その秘密にせまるために、通常あるべき、不変性の部分型付けを見てみます。
次のコードは、プログラミング意味論的に、通るべきです。
なぜなら「可変なオブジェクトは、一時的に不変にしても破綻しない」からです。
// ある不変なオブジェクトの型
type Immutable = {
readonly a: number
}
// ある可変なオブジェクトの型
type Mutable = {
a: number
}
function f(x: Immutable): void {
// xを使って処理をする
console.log(x)
}
const x: Mutable = { a: 42 }
f(x)
例えばKotlinでは上述と同様に、MutableList
の変数を、(Immutable)List
の変数に代入することができます。
逆に次のコードは、プログラミング意味論を破綻させます。
なぜなら「不変なオブジェクトを一時的に可変にすると、不変性が破綻する」からです。
// ある不変なオブジェクトの型
type Immutable = {
readonly a: number
}
// ある不変でないオブジェクトの型
type Mutable = {
a: number
}
function f(x: Mutable): void {
// xを使って処理をする
x.a = 10
}
const x: Immutable = { a: 42 }
f(x)
console.log(x)
具体的には{ readonly a: number } extends { a: number }
であるべきで、
{ a: number } extends { readonly a: number }
であるべきではありません。
……
勘のいい人は気づいてしまったかもしれません。
このコードillegal.ts
は、現在のTypeScriptではコンパイルエラーになりません!
結論で申し上げた通り、{ a: number } extends { readonly a: number }
だからです!
(
これは本来、そうあるべきではない挙動です。
KotlinがMutableList
の変数に(Immutale)List
の値を代入できないように、
つまり{ a: number }
の変数に{ readonly a: number }
の値を代入できるべきではないのです。
)
まとめ
例をシンプルに書き直します。
これはx.a
を書き換えます。
不変性が壊れているのが、容易にわかります。
const x: { readonly a: number } = { a: 42 }
const y: { a: number } = x
// x.aが変更されている
y.a = 10
console.log(x) // { a: 10 }
// コンパイルエラーはない
as const
でも同様です。
const x = { a: 42 } as const
const y: { a: number } = x
y.a = 10
console.log(x)
// コンパイルエラーはない
おまけ
どうしてもTypeScriptで絶対の不変性が必要な場合には、Object.freeze()
を使用して、実行時にエラーを送出させる必要があります。
const x: { readonly a: number } = Object.freeze({ a: 42 })
const y: { a: number } = x
// ここで例外が送出される
y.a = 10
TypeScriptの{readonly a: number}
型では、a
プロパティは変更不可になりません!