そらえふのブリキ

イミュータブルなオブジェクトの利点

ミュータブルなオブジェクト

ミュータブルなオブジェクトとは、作成したオブジェクトがあとから変更可能となるような性質のオブジェクトのことです。 Dart言語では次のようにミュータブルなオブジェクトを定義できます。座標を表すオブジェクトを題材としてみます。

class MPoint {
  double x;
  double y;

  MPoint(this.x, this.y);
}

void main() {
  final p = MPoint(0, 0);
  // p.x = 0, p.y = 0

  p.x = 10;
  // p.x = 10, p.y = 0
}

ミュータブルなオブジェクトではMPointクラス(頭のMはMutableのM)をインスタンス化したあとに、オブジェクトのインスタンス変数を変更することができます。

イミュータブルなオブジェクト

一方でイミュータブルなオブジェクトは、一度作成したオブジェクトがあとから変更できない性質を持っています。 Dart言語ではイミュータブルなオブジェクトは次のように書くことができます。

class IPoint {
  final double x;
  final double y;

  IPoint(this.x, this.y);
}

void main() {
  final p = IPoint(0, 0);
  // p.x = 0, p.y = 0

  p.x = 10; // エラーが起きる
}

ミュータブルなクラス定義との違いはインスタンス変数の定義がfinalになっていることです。 こうすることでp.x = 10のようにイミュータブルなオブジェクトに変更を加えようとすると、エラーが起きてしまいます。 ただ注意しなければいけないのは、finalを指定した変数がリストなどのミュータブルな型の場合、内部の状態を変更することができてしまうため、完全にイミュータブルなオブジェクトとはならない点です。

座標を表すオブジェクトの例でいうとこのような場合になります。

class Point {
  final List<double> coordinates;

  Point(double x, double y) :
    coordinates = [x, y];
  
  double get x => coordinates[0];
  double get y => coordinates[1];
}

void main() {
  final p = Point(0, 0);
  // p.x = 0, p.y = 0

  p.coordinates[0] = 10; // エラーは起きない
  // p.x = 10, p.y = 0
}

若干無理くりな実装ではありますが、このようにインスタンス変数がリストのような場合ではfinalを指定していても、ミュータブルになってしまいます。 イミュータブルにする場合に、このようなパターンがあることは知っておくべきでしょう。

イミュータブルの利点

わざわざイミュータブルにする利点は2つあると考えてます。

  1. 副作用がなくなるため、コードの複雑さが減る
  2. オブジェクトの生成条件を限定することで、コードの複雑さが減る

コードを書く上で最も大切なのは、複雑さを減らすことだと考えています。 他にも、可読性や効率性のような観点があると思いますが、極力複雑さを排除したいです。 イミュータブルは複雑さを減らすのに重要な性質です。

1. 副作用について

ミュータブルなクラスでは副作用が生じます。 副作用(プログラム)によると式の評価(メソッドの実行など)による作用には、主たる作用とそれ以外の副作用が存在して、メソッドの実行の返り値が主たる作用で、それ以外の状態を変更するような作用のことを副作用と呼びます。

副作用が起きる身近な例でいうと、javascriptでDateを扱う際などによく問題が起こります。 javascriptのDateはミュータブルなオブジェクトです。 例えば単純に今日の日付と明日の日付を出力するプログラムを作成してみます。

const today = new Date();
console.log("今日は", today.getDate(), "日"); // 今日は8日

today.setDate(today.getDate() + 1); // 日付を1日加算

const tomorrow = today
console.log("明日は", tomorrow.getDate(), "日"); // 明日は9日

このコードは正しく動作します。しかしもう一度今日の日付を表示するために、conosole.logを実行すると次のように出力されてしまいます。

console.log("今日は", today.getDate(), "日"); // 今日は9日

これは副作用により、todayが書き換えられてしまったため、起こる問題です。

これはコードの近くに副作用を起こす操作をしてるため、ミスに気が付きやすいですが、副作用が非同期にコードのどこかで起きた場合、見つけるのは大変難しいです。

ミュータブルな操作では、オブジェクトが変更されてしまうため、再利用しにくいインターフェースになってしまいがちですし、他の場所に影響が出ないか注意する必要もあります。

一方でDartで日時を扱うDateTime型はイミュータブルなオブジェクトを生成できます。

Dartで同じようなコードは次のようにかけます。

final today = DateTime.now();
print("今日は${today.day}日"); // 今日は8日

final tomorrow = today.add(Duration(days: 1));
print("明日は${tomorrow.day}日"); // 明日は9日

このコードには副作用がないため、再度今日の日付を表示する際にも、次のように意図したとおり表示することができます。

print("今日は${today.day}日"); // 今日は8日

イミュータブルな操作であれば、影響の範囲がその場にしかないため、気持ち的にも気楽に操作できます。

2. オブジェクトの生成条件を限定する

イミュータブルなクラスを使う理由として、オブジェクトの生成条件を限定することで複雑さを減らすことができるというメリットがあります。 これは厳密にはミュータブルなオブジェクトでも同様のことができるますし、どちらかというとテクニカルなことなのですが、自分はこの性質を利用してプログラムを書くことがあるので、利点にあげました。

例えば、ちょっと恣意的なたとえになってしまうのですが、最初の座標の例で、y座標はx座標を上回ってはいけないというルールがあったとします。

このときミュータブルなMPointクラスにこの条件を課す場合、次のように書くことができます。


class MPoint {
  double _x;
  double _y;

  MPoint(this._x, this._y) {
    _validate(_x, _y);
  }

  set x(double value) {
    _validate(value, _y);
    _x = value;
  }
  
  set y(double value) {
    _validate(_x, value);
    _y = value;
  }
  
  double get x => _x;
  double get y => _y;
  
  void _validate(double x, double y) {
    if (y > x) throw Error();
  }
}

void main() {
  final p = MPoint(2, 1);
  final p2 = MPoint(1, 2); // Error
  p.x = -1; // Error
  p.y = 3; // Error
}

このように書くことで、不正な値が入るのを防ぐことができます。 しかし、コンストラクタ、xのセッター、yのセッターの3つの場所でバリデーションを行わないといけないため、コードが長くなりバリデーションを忘れてしまう可能性も高くなります。

一方でイミュータブルなIPointクラスの実装では同様のコードを次のようにかけます。

class IPoint {
  final double x;
  final double y;

  IPoint(this.x, this.y) {
    if (y > x) throw Error();
  }
}

void main() {
  final p = IPoint(2, 1);
  final p2 = IPoint(1, 2); // Error
}

IPointクラスは生成されたら変更されないイミュータブルなオブジェクトのため、生成時にコンストラクタでチェックをするだけで良くてバリデーションが簡単になりました。 イミュータブルではコンストラクタで生成条件を強制できるため、オブジェクトが不正な状態になるのを阻止することができるのです。

そらえふ

ソフトウェアエンジニア。趣味は競馬、写真、ゲーム。

お問い合わせはXのDMでお願いします。