Subterranean Flower

JavaScriptでイミュータブルなプログラミングをする

Author
古都こと
ふよんとえんよぷよぐやま!!!

「イミュータブル(immutable)」という言葉を聞いたことがあるでしょうか。イミュータブルは「不変」「変わらない」という意味の単語で、プログラミング界隈で盛り上がりつつある概念です。

ですが、そもそもプログラムとは基本的に値を変化させて何かを実現することがほとんどで、「イミュータブル」とは程遠いように思われます。

プログラミングにイミュータブルを導入するとはどういうことでしょう?どんなメリットが得られるのでしょう?

イミュータブルって?

プログラミングにおいてイミュータブルとは、オブジェクトの状態が変わらないことを指します。

オブジェクトの状態、と言ってもいまいちピンとこないかもしれません。わかりやすい例としては配列の状態があります。以下のコードを見てください:

const array = [1, 2, 3];

for(let i = 0; i < array.length; i++) {
  array[i] = array[i] * 2;
}

この例では、配列の中の値を2倍にしています。arrayの中身は[2, 4, 6]になります。このとき配列の値、つまり「状態」を直接変更しています。オブジェクト(配列)の状態を変更しているので、配列はミュータブル(変更可能)なオブジェクトとなります。

一方で、イミュータブルなコードはどうなるのでしょう?先ほどの例をイミュータブルに書くと、以下のようになります:

const array = [1, 2, 3];
const array2 = array.map((e) => e*2);

こちらの例では、arrayの内容を直接は変更せず、mapメソッドで新しい配列を作成して、別の変数(array2)に割り当てています。元の配列の値は変更せず、値を変更した新しい配列を作成する、これがイミュータブル(不変)なコードになります。

これがミュータブルとイミュータブルの違いです。たったこれだけのことですが、一体なんの役に立つのでしょう?

イミュータブルは予測可能

イミュータブルの利点は、挙動が予測可能なところです。物事が単純になり、見通しが良くなります。また、JavaScriptではあまり恩恵を受けられませんが、イミュータブルにすると自動的にスレッドセーフが実現できます。

イミュータブルが意識されたプログラムでは、一度作られたオブジェクトの状態は変わりません。つまり、オブジェクトは作成されたときのままであることが保証されています。

逆に言うと、非イミュータブルなオブジェクトでは、状態は保証されません。例えば以下のコードをご覧ください:

const array = [1, 2, 3];
doSomethingWithArray(array);
console.log(array); // どうなる??

ここでdoSomethingWithArray関数は「何か」をする関数です。何をするのかはわかりません。配列の値を全く書き換えてしまうかもしれません。それを調査するには、ドキュメントの精査、様々なパターンでの実行、あるいはコードの読解が必要になります。それらの検証は、当然大きなコストになります。

上の例ぐらいシンプルならまだマシで、実際はもう少し複雑です。例えばミュータブルな商品(Item)オブジェクトとショッピングカートオブジェクトがあるとします。

class Item {
  constructor(name, priceYen) {
    this.name = name;
    this.priceYen = priceYen;
  }
}

class ShoppingCart {
  constructor() {
    this.itemList = [];
  }
  
  addItem(item) {
    this.itemList.push(item);
  }
}

const cart = new ShoppingCart();
const item = new Item('USB Cable', 200);

cart.addItem(item);

// たとえば、セールでこんな風にあとで勝手に値段を書き換えられるかも……?
item.priceYen = 100;

// 最悪の場合、商品名まで書き換えられて、全くの別物になる。
item.name = 'HDMI Cable';

このとき商品オブジェクトがミュータブルなので、ショッピングカートに商品を入れた後に価格や商品名を変えてしまうことが可能です。どこで何を書き換えられるのか、わかりません。予測不可能です。このミュータブルな「商品」というオブジェクトは、全く信用できないのです。

一方でイミュータブルであることが保証されていれば、「渡したオブジェクトの状態は変わらない」ことが保証されるので、結果は予測可能です。

もともとわりとイミュータブル

JavaScriptはもともとイミュータブルな部分も多い言語です。具体的に言うとプリミティブ型(数値、文字列、ブール値、など)は全てイミュータブルです。

たとえば「3 + 5」は常に8になります。「3」の中身は常に3で、「5」の中身は常に5だからです。ここで数値型がミュータブルで、「3」の中身を9に書き換えられたら、「3 + 5」は14になってしまいます。そんなわけのわからないことにならないために、数値はイミュータブルになっています。

文字列もイミュータブルです。一度生成した文字列は書き換えることはできません。よくよく思い返してみると、文字列型のメソッドには、イミュータブル(元の文字列を変更せずに、新しい文字列を生成する)なものしか存在しません。また、文字列の一部分を直接書き換えようとしても、無視されます。

const str = 'Hello';
str[0] = 'A';     // 書き換えようとしても……
console.log(str); // 'Hello'のまま 

逆に、オブジェクト型(配列含む)はデフォルトではミュータブルです。Object.freeze()メソッドを使えばイミュータブルにできますが、明示的に指定しなければいけません。

オブジェクトをイミュータブルに処理する

ミュータブルなのはオブジェクト型だけなので、ここさえなんとかできればJavaScriptにおけるイミュータブルは実現できそうです。

JavaScriptはイミュータブルを強く押し出した言語ではないので、途中で多少ミュータブルな状態が混ざりますが、そこは我慢してください。

基本的に、コピーしてから操作する、ということを徹底していればイミュータブルなオブジェクトっぽく処理することができます。例えば以下のようにします:

const john = { name: 'John', age: 20 };

// コピーしてから操作する。
const nextYearJohn = Object.assign({}, john);
nextYearJohn.age = 21;

// 元のオブジェクトは書き換わらず、
// 新しいオブジェクトが作成されている。
console.log(john, nextYearJohn);

Object.assign(target, source)メソッドはtargetオブジェクトにsourceオブジェクトの内容を書き込んで返します。targetオブジェクトに空オブジェクトを指定すれば、オブジェクトのシャローコピーが実現できます。

作ったコピーの値を書き換えることで、元のオブジェクトに影響を与えず、新しい値を持ったオブジェクトを作ることができます。

配列についても同じように実現できます:

const array = [1, 2, 3];

// コピーしてから値を書き換える。
const copied = [...array];
copied[0] = 5;

// コピーして値を追加する。
const copied2 = [...array, 4];

// イミュータブルなメソッドを使う。
const doubled = array.map((e) => e * 2);

配列のシャローコピーはスプレッド演算(…)で行うことができます。また、配列には様々なイミュータブルなメソッドが存在するので、そちらを活用しても良いでしょう。

より徹底するなら、各オブジェクトをObject.freeze()メソッドで凍結するといいかもしれません。

独自クラスをイミュータブルにする

現実には素のオブジェクトを扱うことはそこまで多くなく、クラスを通した扱いをすることの方が多いのではないでしょうか。

自作のクラスをイミュータブルにするには、以下のようにします:

class Item {
  constructor(name, priceYen) {
    // プロパティ名の先頭にアンダースコアをつける。
    // 強制力はないが、「直に触るな」という暗黙の了解がある。
    this._name = name;
    this._priceYen = priceYen;
  }
  
  // getterを通してプロパティにアクセスする
  get name() {
    return this._name;
  }
  
  get priceYen() {
    return this._priceYen;
  }
}

const item = new Item('USB Cable', 200);

// 書き換えようとしてもできない
item.priceYen = 100;
item.name = 'HDMI Cable';
console.log(item); // USB Cable, 200

// セールのときには新しいオブジェクトを作る
const saleItem = new Item(item.name, item.priceYen * 0.5);
console.log(saleItem);

まず、プロパティの名前をアンダースコアから始まるものにします。これは「直接この値を触るな」という意味になります。強制力はありませんが、JavaScript界隈では通じるはずです。

将来的にはJavaScriptにもプライベート機能(外からプロパティをさわれなくする)が導入される予定があるので、実装されたらそちらを使う方がいいでしょう。

あとは外部で必要そうなプロパティを、getterを使って露出するだけです。

これでほぼイミュータブルなクラスが完成します。外部から状態を変更しようとしてもできず、状態が常に一定であることが保証されます。

なお、プロパティにオブジェクトを含む時は注意が必要です。オブジェクトを直接返すと、外側で変更を加えることが可能になるからです。

そういうときは、オブジェクトのコピーを返すようにすれば、外からは変更できなくなります。

class ShoppingCart {
  constructor() {
    this._itemList = [];
  }
  
  get itemList() {
    // コピーしてから返す。
    return [...this._itemList];
  }
}

オブジェクトの中にオブジェクト

オブジェクトの中にオブジェクトが存在するのも珍しくはありません。そういった場合はシャローコピーでは参照値のコピーしかできないので、中のオブジェクトのコピーはできません。

// 配列(オブジェクト)の中にオブジェクト
const array = [
  { name: 'John', age:20 },
  { name: 'Jane', age:18 }
];

// シャローコピー
const copied = [...array];

// 値を書き換える
copied[0].age = 21;

// 両方ともJohnのageが21に変わっている。
console.log(array, copied);

シャローコピーだけでは同じオブジェクトを指してしまうので、変更が共有されてしまいます。

対処法としては以下のふたつがあります:

  1. ディープコピーする
  2. すべてイミュータブルオブジェクトにする

1はシャローコピー(参照値だけコピーする)の代わりにディープコピー(中のオブジェクトごとコピーする)を用いる方法です。ただしJavaScriptには標準でのディープコピー機能がないので、ライブラリを使うか、自作する必要があります。

2は中のオブジェクトもイミュータブルにしてしまう方法です。これならばそもそも中のオブジェクトの状態は固定なので、参照値だけ渡っても特に問題は起こりません。

2の方法が簡単そうなので、今回はこちらでやることにします。

// イミュータブルなPersonクラス。
class Person {
  constructor(name, age) {
    this._name = name;
    this._age = age;
  }
  
  get name() {
    return this._name;
  }
  
  get age() {
    return this._age;
  }
}

const array = [
  new Person('John', 20),
  new Person('Jane', 18)
];

// 中身がイミュータブルなのでシャローコピーだけで大丈夫。
const copied = [...array];
copied[0].age = 21; // 変更できない
console.log(array, copied);

このように、オブジェクトの中に含まれる全てのオブジェクトをイミュータブルにしておけば、シャローコピーでも全く問題なくなります。

イミュータブルに書いてみる

ここまでの知識を活かして、実際にイミュータブルに書いてみましょう。

例えばベクトルの足し算をするコードは以下のようになります:

// イミュータブルなベクトルクラス。
class Vector2 {
  constructor(x, y) {
    this._x = x;
    this._y = y;
  }
  
  get x() {
    return this._x;
  }
  
  get y() {
    return this._y;
  }
  
  // ベクトルを加算するメソッド。
  // 自身の状態は変えずに、新しいベクトルを作って返す。
  add(other) {
    return new Vector2(this.x + other.x, this.y + other.y);
  }
}

const vec1 = new Vector2(1, 2);
const vec2 = new Vector2(3, 4);
const vec3 = vec1.add(vec2); // 新しいベクトルが返ってくる。

// 元のベクトルは変わらない。
console.log(vec1, vec2, vec3);

ベクトルクラスをイミュータブルとし、足し算の際には新しいベクトルを作って返すようにしています。これで元々のベクトルの値に変更を加えずに、新しいベクトルの値を作ることができています。

イミュータブルなプログラムでは、値が常に一貫しているので、コードを読んだ通りに解釈するだけで流れを追うことができます。イミュータブルを意識してコードを書くことで、簡潔で見通しが良く、バグの少ないプログラムを作ることができます。余裕があったら、ぜひ挑戦してみてください。