「イミュータブル」って多義的だよね

κeenです。「XX言語はデフォルトイミュータブルだ」とか「この機能を使うとイミュータブルになる」とかのもの、よく混乱が見受けられますよね。 ユーザの勘違いもありますし言語毎に指しているものが違ったりするので整理してみます

シャドーイング

イミュータブルなはずなのに変数が上書きできてしまう、あれ?というやつです。

以下のようなClojureのコードを考えます。

(def x 1)
(println x)
(def x 2)
(println x)

馴れてないと(どちらかというと束縛と代入を一緒くたにする言語に毒されてると)「xに2回代入しようとしてるからエラーになるか2回目は無視されて1のままのはず」と考えてしまいます。 しかし実際の実行結果はエラーにはならず、

1
2

と更新されています。 これは先述のとおり束縛と(再)代入を混同していると起きる勘違いです。 (def x 2)はもともとあったxという変数を上書きしているわけではなく、xとは別の新しい変数xを作っているだけです。 わかりやすく書くとこうです

(def x_1 1)
(println x_1)
(def x_2 2)
(println x_2)

変数束縛の度に新たにスコープが導入されると考えるのです。 Clojureには再代入がないので分かりづらいですがイミュータブルな変数宣言と再代入の両方があるScalaならわかりやすいでしょう。

object Main extends App {
  val x = 1;
  println(x);
  // x = 2  // error: reassignment to val
  locally {
    val x = 2;
    println(x);
  }
}
1
2

これはユーザの勘違いでよく起きる問題です。

イミュータブルな値

これは半分は言語機能、半分はインターフェースの問題なのですが、値を変更できない(変更するような言語機能や関数が存在しない)言語もあります。 たとえばSMLには

{ x = 1 }

という値(レコード)を変更する手段はありません。 しかし

val a = Array.fromList([1, 2, 3]);
a (* => [|1,2,3|] *)
Array.update(a, 0, 2);
a (* => [|2,2,3|] *)

のように変更するインターフェースを持つ型もあります。

「デフォルトイミュータブル」といったときに「ほとんどの/全ての型は変更するインターフェースを持たない」ことを指す言語もあるようです。

(イ)ミュータビリティの推移律

Scalaにはイミュータブルな変数宣言がありますが、値を変更することができます

import scala.collection.mutable.ArrayBuffer;

object Main extends App {
  // イミュータブルな変数を宣言
  val a = ArrayBuffer(1);
  println(a)
  // aに破壊的変更を加える
  a += 2;
  println(a);
}

これを実行すると

ArrayBuffer(1)
ArrayBuffer(1, 2)

と書き換わってます。varを使っていないにもかかわらず変更ができてしまうんですね。

同じようなことはJSでもできます。 constといういかにも定数を導入しそうな変数束縛の構文があります。

const a = {
    x: 1
};
console.log(a);
a.x = 2;
console.log(a);

これを実行するとこうなります。

{ x: 1 }
{ x: 2 }

一方、Rustで同じようなことをやるとエラーになります。

fn main() {
    let a = String::new();
    println!("{}", a);
    a.push('a'); // cannot borrow immutable local variable `a` as mutable
    println!("{}", a);
}

この「イミュータブルな変数に束縛されたオブジェクトを破壊的変更できるか」は(イ)ミュータビリティの推移律という言葉で分類されるそうです。イミュータブルな変数に束縛されたオブジェクトを変更できないなら推移律が成り立ち、逆ならば成り立たないというそう。 何かの言語を学ぶときに「この変数宣言はイミュータブルな変数を作る」と書いてあったら推移律が成り立つか気にしてみると学習が速くなるかもしれません。

因みにここまで出てきた例だと方針に一貫性はあります。 変数に束縛してあるのはポインタだから再代入を禁止するのはポインタまでで、そのポイント先は自由に変更可能という考え方ですね。 Rustの値は暗黙に参照になっておらず、そのまま変数に束縛されているのでイミュータブルになるという考えるのです。 Rustでも(ミュータブルな)ポインタを経由すればイミュータブルな変数に束縛された値(のポイント先の値)を変更できます。

fn main() {
    let mut a = 1;
    let b = &mut a;
    println!("{}", b);
    *b = 2;
    println!("{}", b);
}
1
2

ただしこの考え方は飽くまでここに挙げた言語ではの話で、他にこの規則を破る言語もあるかと思います。

追記

Rustのミュータビリティの扱いは突き詰めると中々複雑なのでおいとくとして、「ミュータビリティの推移律」と言っていたのは「ミュータビリティの継承」(継承したミュータビリティの方が近い?)の間違いでした。 これはひどい。

Written by κeen