Java : 不変オブジェクト(イミュータブル) とは
不変オブジェクトとは、オブジェクト作成後にその状態が決して変わらないオブジェクトです。
Javaの標準APIでは不変オブジェクトが積極的に採用されています。
代表的なのはString(文字列)ですね。
標準APIで積極的に採用されている理由は、やはり不変オブジェクトにメリットがあるためです。
本記事では、そんな不変オブジェクトの基本について解説していきます。
概要
不変(イミュータブル) オブジェクトとは、オブジェクト作成後に決して
- 状態が変わらない
- 変えることができない
オブジェクトのことをいいます。
使う側から見ると、不変オブジェクトのアクセスできるすべてのメソッド、フィールドは
- いつでも
- どんなときでも
- なんどでも
- 複数のスレッドから同時にアクセスされたとしても
その結果は変わりません。変わってはいけません。
別の言い方をすると、不変オブジェクトは定数です。(定数というよりかは定オブジェクト?)
不変オブジェクトには、いくつかのメリットがあります。
デメリット…というよりかは向いていないケースもあります。
それでは、実際に不変オブジェクトについて見ていきましょう。
不変オブジェクトの例
Javaの標準APIで、不変オブジェクトの代表といえば String(文字列)クラスです。
API仕様にも、不変であることが念入りに明記されていますね。
Stringクラスには、
- 文字列を変換(小文字から大文字へ変換、などなど)
- 文字列を置換
といった文字列を変換するメソッドがいくつかあります。
もちろんStringは不変オブジェクトなので、オブジェクトの状態(値)は変更できません。
変換した結果となる文字列は、新しいオブジェクトとして、新規に作成されます。
final var src = "abcd";
System.out.println(src); // "abcd"
// 大文字へ変換します。
final var dst1 = src.toUpperCase();
System.out.println(dst1); // "ABCD"
// a を X に置換します。
final var dst2 = src.replace('a', 'X');
System.out.println(dst2); // "Xbcd"
// インスタンスは別になります。
System.out.println(src != dst1); // true
System.out.println(src != dst2); // true
// 元となるオブジェクトは変更されません。
System.out.println(src); // "abcd"
元となるオブジェクト(src)に対して、どのメソッドをいくら呼び出しても、srcの状態(値="abcd")は決して変わりません。
逆に、変更可能な文字列もあります。
StringBuilder や StringBuffer です。
final var src = new StringBuilder("abcd");
System.out.println(src); // "abcd"
// 1文字目と2文字目を大文字に変換。
src.setCharAt(0, 'A');
src.setCharAt(1, 'B');
System.out.println(src); // "ABcd"
StringBuilderは不変オブジェクトではないため、srcの状態(値="abcd")が変わることがわかりますね。
メリット
スレッドセーフ
通常、変更可能なオブジェクトをスレッドセーフにしようとすると、syncrhonized による同期処理が必要となります。
同期処理にはコストがかかりますし(パフォーマンス低下など)、デッドロックの危険性もあります。
それに比べて、不変オブジェクトはそのままでスレッドセーフです。
syncrhonized は必要ありません。同期処理のコストもかかりませんしデッドロックも発生しません。
複数のスレッドからアクセスされるオブジェクトは、可能な限り不変オブジェクトにすることをおすすめします。
syncrhonized の危険性については別途記事にしていますので、そちらもご参照ください。
関連記事: synchronizedの多いコードは危険信号
複製やコピーが不要
不変オブジェクトは、オブジェクトの複製(clone)やコピーが必要ありません。
なぜなら、オブジェクトを共有して参照すればよいからです。
オブジェクトの状態が変わらないので、いつでも、だれからでも、どれだけ参照されても問題ありません。
これは単純に、
- コピーする処理が不要となりパフォーマンス向上
- オブジェクトを共有するためメモリ節約
というメリットになります。
一方、可変オブジェクトを複製するときは注意が必要です。
そのオブジェクトを複製先でも参照していると、意図せず状態が変わってしまうかもしれません。
例を見てみましょう。
public class Sample implements Cloneable {
private final StringBuilder value;
public Sample(String value) {
this.value = new StringBuilder(value);
}
public void append() {
value.append("XYZ");
}
@Override
public Sample clone() {
try {
return (Sample) super.clone();
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}
@Override
public String toString() {
return value.toString();
}
}
Sampleクラスは可変オブジェクトです。
append メソッドの呼び出しで状態が変わります。
final var sample1 = new Sample("abcd");
final var sample2 = sample1.clone();
System.out.println(sample1); // abcd
System.out.println(sample2); // abcd
// sample1だけ更新します。
sample1.append();
// どちらにも反映されます。
System.out.println(sample1); // abcdXYZ
System.out.println(sample2); // abcdXYZ
複製(clone)した結果、valueオブジェクトは複製先のオブジェクトでも参照されます。
そのため、片方のオブジェクトでvalueを更新すると、もう片方にも変更が反映されてしまいます。
通常、これは意図した結果ではありません。
この問題については、不変オブジェクトの話題から少し外れていくため割愛します。
もう少し詳しく知りたい方は、
- シャローコピー (浅いコピー)
- ディープコピー (深いコピー)
というキーワードで検索してみるのもおすすめです。
状態がシンプルになる
プログラムの不具合の多くは、オブジェクトの状態が意図しないものになったときに発生する、と個人的には感じています。
そのような不具合の解析では、いつだれが状態を更新したのか?ということを調べていくことになります。
可変オブジェクトの場合、状態は…
- オブジェクト生成時の初期状態
- setterなどのメソッド呼び出しによる状態更新
という2つを意識しなければなりません。
特に2番目は、いつでもなんどでも呼び出されるため、状態の更新を追うのが大変です。
不変オブジェクトであれば、
- オブジェクト生成時の初期状態
だけです。
オブジェクトが生成されたときだけを意識すればよいので、不具合解析もやりやすくなるのかな、と思います。
向いていないケース
不変オブジェクトは、状態の更新が多く必要となるようなオブジェクトには向いていません。
例として、文字列の連結をかなりの回数実行するケースで見てみましょう。
まずは不変オブジェクトのStringを使います。
final var start = System.nanoTime();
var s = "";
for (int i = 0; i < 100000; i++) {
s += String.valueOf(i);
}
// 連結した文字列の表示
System.out.println(s);
// 処理にかかった時間
System.out.printf("%f 秒\n", (System.nanoTime() - start) / 1000000000.0);
// 結果
// ↓
//01234567891011121314151617181920 ... 省略
//3.237851 秒
10万回文字列を連結するのに約3.2秒かかりました。
次に、可変オブジェクトである StringBuilder を使ってみます。
final var start = System.nanoTime();
final var s = new StringBuilder();
for (int i = 0; i < 100000; i++) {
s.append(i);
}
// 連結した文字列の表示
System.out.println(s);
// 処理にかかった時間
System.out.printf("%f 秒\n", (System.nanoTime() - start) / 1000000000.0);
// 結果
// ↓
//01234567891011121314151617181920 ... 省略
//0.031249 秒
同じ処理ですが、こちらは約0.03秒です。
だいぶ違いがありますね。
このように、高頻度に更新が必要となるケースでは、不変オブジェクトはパフォーマンスが悪くなることがあります。
不変オブジェクトは有用ですが、用途によっては可変オブジェクトも検討してみましょう。
- 基本は不変オブジェクト
- ダメなところだけ可変オブジェクト
という方針がよいのかなと思います。
不変オブジェクトのクラス定義
自分で定義するクラスも、もちろん不変オブジェクトにできます。
不変オブジェクトとは、アクセスできるすべてのメソッド、フィールドに対して
- いつでも
- どんなときでも
- なんどでも
- 複数のスレッドから同時にアクセスされたとしても
その結果は変わりません。変わってはいけません。
このルールを守るようにクラス定義を行います。
それでは、実際に不変オブジェクトのクラス定義を見ていきましょう。
基本形
public final class ImmutableA {
private final int num;
private final String str;
public ImmutableA(int num, String str) {
this.num = num;
this.str = str;
}
public int getNum() {
return num;
}
public String getStr() {
return str;
}
}
final var a = new ImmutableA(1234, "abcd");
System.out.println(a.getNum()); // 1234
System.out.println(a.getStr()); // "abcd"
上記のImmutableAは不変クラスです。
インスタンスを生成すると、それは不変オブジェクトとなります。
不変オブジェクトのクラス定義の基本は、
- すべてのフィールドに final キーワードをつける。
- すべてのフィールドは不変オブジェクト、もしくは プリミティブ型 (基本データ型) とする。
- クラスの拡張(継承)ができないように final をつける。
です。
フィールドに final をつけることで、オブジェクト生成時にフィールド変数が固定されます。
また、オブジェクト作成後に、setterなどによるフィールド変数の書き換えができなくなります。
間違って書き換えようとしても、コンパイルエラーとなるので安心です。
public final class ImmutableA {
private final int num;
private final String str;
... 省略 ...
public void setNum(int num) {
this.num = num; // ★コンパイルエラー
}
}
次に、フィールドを不変オブジェクトもしくはプリミティブ型にします。
こうすることで、フィールドのオブジェクトの状態が固定されます。
もしフィールドを可変オブジェクトにすると、状態の更新が可能となってしまいます。
実際に問題のある例を見てみましょう。
先ほどのクラスを、String(不変)ではなくStringBuilder(可変)に変更してあります。
public final class MutableA {
private final int num;
private final StringBuilder sb; // ★
public MutableA(int num, StringBuilder sb) {
this.num = num;
this.sb = sb;
}
public int getNum() {
return num;
}
public StringBuilder getSb() {
return sb;
}
}
このMutableAは不変オブジェクトとはなりません。
状態が変わってしまう穴があります。
private final StringBuilder sb;
フィールドに final をつけているので、sb変数自体は変更できません。
しかし、sb の参照している StringBuilder が可変オブジェクトのため、状態の更新が可能となっています。
final var a = new MutableA(1234, new StringBuilder("abcd"));
System.out.println(a.getNum()); // 1234
System.out.println(a.getSb()); // "abcd"
// 更新可能です。
a.getSb().append("XYZ");
System.out.println(a.getSb()); // abcdXYZ
MutableAのオブジェクトが、オブジェクト生成後なのに状態が変わってしまいました。
このような問題が発生しないように、フィールドは不変オブジェクト、もしくはプリミティブ型にしましょう。
最後に、クラスの拡張(継承)ができないようにクラス宣言に final をつけます。
public final class ImmutableA {
... 省略 ...
もし継承できてしまうと、メソッドをオーバーライドされ、なんでもありになってしまうからです。
public class ImmutableA {
... 省略 ...
}
public class SubA extends ImmutableA {
private int count = 0;
public SubA(int num, String str) {
super(num, str);
}
@Override
public int getNum() {
// ★オーバーライドして状態を更新します。
count++;
return count;
}
}
final ImmutableA a = new SubA(1234, "abcd");
System.out.println(a.getNum()); // 1
System.out.println(a.getStr()); // "abcd"
System.out.println(a.getNum()); // 2
System.out.println(a.getNum()); // 3
とはいえ、あるクラスをベースとして複数の不変クラスを作りたい、ということもあるかもしれません。
そのときはドキュメントでしっかりと継承のポリシーを書いたほうがよいでしょう。
もしくは、Java 17 で追加されたシールクラスを使ってみるのもよいかもです。
応用
基本形のところでは、
- フィールドは不変オブジェクトまたはプリミティブ型にする
と解説しました。
しかし、フィールドが可変オブジェクトでも、不変オブジェクトとしてのクラス定義は可能です。
ただパフォーマンスが悪くなることも多いため、あまりおすすめはしません。
配列を例に、実際に見ていきましょう。
配列
最初に言ってしまうと、配列は不変オブジェクトには向いていません。
なぜなら、配列は可変オブジェクトだからです。
代わりに List を使うことをおすすめします。
問題のある例を詳しく見ていきましょう。
基本形のところで解説したことと一部かぶるところもありますが、大事なところなのでご了承ください。
public final class Sample {
private final int[] array;
public Sample(int[] array) {
this.array = array;
}
public int[] getArray() {
return array;
}
}
final int[] src = {1, 2, 3};
System.out.println(Arrays.toString(src)); // [1, 2, 3]
final var sample = new Sample(src);
final var dst = sample.getArray();
System.out.println(Arrays.toString(dst)); // [1, 2, 3]
上記の Sampleクラスには、不変クラスとして2箇所問題があります。
どこが問題かわかりますでしょうか?
まずは1つ目の問題を見てみましょう。
final int[] src = {1, 2, 3};
System.out.println(Arrays.toString(src)); // [1, 2, 3]
final var sample = new Sample(src);
final var dst = sample.getArray();
System.out.println(Arrays.toString(dst)); // [1, 2, 3]
// ★
src[0] = 999;
final var dst2 = sample.getArray();
System.out.println(Arrays.toString(dst2)); // [999, 2, 3]
Sample.getArray の結果は、最初は [1, 2, 3] でしたが [999, 2, 3] に変わりました。
つまり、Sampleは不変オブジェクトではありません。
この問題は、配列(可変オブジェクト)である srcオブジェクトを、Sampleでも共有してしまっているためです。
それでは問題を解決してみましょう。
public final class Sample {
private final int[] array;
public Sample(int[] array) {
// ★
this.array = Arrays.copyOf(array, array.length);
}
public int[] getArray() {
return array;
}
}
Arrays.copyOf は配列のコピーを作成します。
Sampleのコンストラクタで指定された配列はそのまま使わずに、コピーしてそれを保持するようにします。
final int[] src = {1, 2, 3};
System.out.println(Arrays.toString(src)); // [1, 2, 3]
final var sample = new Sample(src);
final var dst = sample.getArray();
System.out.println(Arrays.toString(dst)); // [1, 2, 3]
// ★
src[0] = 999;
final var dst2 = sample.getArray();
System.out.println(Arrays.toString(dst2)); // [1, 2, 3]
無事、1つめの問題は解決しました。
srcオブジェクトを更新しても、Sampleには影響しなくなりました。
それではもう1つの問題も見てみましょう。
final int[] src = {1, 2, 3};
System.out.println(Arrays.toString(src)); // [1, 2, 3]
final var sample = new Sample(src);
final var dst = sample.getArray();
System.out.println(Arrays.toString(dst)); // [1, 2, 3]
// ★
src[0] = 999;
dst[1] = 888;
final var dst2 = sample.getArray();
System.out.println(Arrays.toString(dst2)); // [1, 888, 3]
srcの更新は問題なくなりましたが、今度は dst を更新すると Sample オブジェクトにも反映されてしまいます。
この問題も解決してみましょう。
public final class Sample {
private final int[] array;
public Sample(int[] array) {
this.array = Arrays.copyOf(array, array.length);
}
public int[] getArray() {
// ★
return Arrays.copyOf(array, array.length);
}
}
final int[] src = {1, 2, 3};
System.out.println(Arrays.toString(src)); // [1, 2, 3]
final var sample = new Sample(src);
final var dst = sample.getArray();
System.out.println(Arrays.toString(dst)); // [1, 2, 3]
// ★
src[0] = 999;
dst[1] = 888;
final var dst2 = sample.getArray();
System.out.println(Arrays.toString(dst2)); // [1, 2, 3]
getArrayメソッドで、配列の複製(コピー)を返すようにします。
これで Sample は問題のない不変オブジェクトとなりました。
さて、不変オブジェクトに配列を持たせようとすると、場合によってはパフォーマンスが悪くなるでしょう。
特に、getArrayメソッドで毎回、配列のコピーを作るのはコストが高いですね…
このように、配列は不変オブジェクトと相性が悪いです。
もし可能であれば、配列の代わりに List を使いましょう。
List
Listは、不変オブジェクトにすることもできれば、可変オブジェクトにすることもできます。
実際に例を見ていきましょう。
まずは問題が発生する例です。
public final class Sample {
private final List<String> list;
public Sample(List<String> list) {
this.list = list;
}
public List<String> getList() {
return list;
}
}
final List<String> src = new ArrayList<>();
src.add("a");
src.add("b");
src.add("c");
System.out.println(src); // [a, b, c]
final var sample = new Sample(src);
final var dst = sample.getList();
System.out.println(dst); // [a, b, c]
// ★
src.set(0, "XXX");
dst.set(1, "YYY");
final var dst2 = sample.getList();
System.out.println(dst2); // [XXX, YYY, c]
ArrayList は可変オブジェクトです。よって、配列と同様の問題が発生します。
src や dst の変更が、Sampleオブジェクトにも影響します。
まずは、srcオブジェクトの共有をやめてみましょう。
public final class Sample {
private final List<String> list;
public Sample(List<String> list) {
// ★
this.list = new ArrayList<>(list);
}
public List<String> getList() {
return list;
}
}
コンストラクタで指定された Listオブジェクトはそのまま使わず、コピーしたオブジェクトを保持します。
final List<String> src = new ArrayList<>();
src.add("a");
src.add("b");
src.add("c");
System.out.println(src); // [a, b, c]
final var sample = new Sample(src);
final var dst = sample.getList();
System.out.println(dst); // [a, b, c]
// ★
src.set(0, "XXX");
dst.set(1, "YYY");
final var dst2 = sample.getList();
System.out.println(dst2); // [a, YYY, c]
これで srcを更新しても、Sampleオブジェクトには影響しないようになりました。
次に dst についてですが、List … というよりかはコレクションには、変更不能オブジェクトを作成するユーティリティがあります。
Collections.unmodifiableList を使うと、指定した List を不変オブジェクトとしてラッピングしてくれます。
List.set などの状態を更新するメソッドを呼び出すと、UnsupportedOperationException が発生します。
つまり状態の更新はできません。
public final class Sample {
private final List<String> list;
public Sample(List<String> list) {
// ★
this.list = Collections.unmodifiableList(new ArrayList<>(list));
}
public List<String> getList() {
return list;
}
}
final List<String> src = new ArrayList<>();
src.add("a");
src.add("b");
src.add("c");
System.out.println(src); // [a, b, c]
final var sample = new Sample(src);
final var dst = sample.getList();
System.out.println(dst); // [a, b, c]
// ★
src.set(0, "XXX");
final var dst2 = sample.getList();
System.out.println(dst2); // [a, b, c]
// ★ UnsupportedOperationException が発生
dst.set(1, "YYY");
dst による変更ができなくなりました。
これで Sample は問題のない不変オブジェクトです。
コンストラクタで List のコピーが1回発生しますが、それ以降はコピー不要なので、配列よりはだいぶパフォーマンスがよくなります。
また、コンストラクタの処理は、
public Sample(List<String> list) {
this.list = Collections.unmodifiableList(new ArrayList<>(list));
}
の部分を、copyOfという1つのAPIにまとめることができます。
public Sample(List<String> list) {
this.list = List.copyOf(list);
}
だいぶシンプルになりました。
補足
配列でも List でもそうですが、その要素は不変オブジェクトである必要があります。
可変オブジェクトだと状態の更新が可能となってしまいます。
問題のある例です。
public final class Sample {
private final List<StringBuilder> list;
public Sample(List<StringBuilder> list) {
this.list = List.copyOf(list);
}
public List<StringBuilder> getList() {
return list;
}
}
final List<StringBuilder> src = new ArrayList<>();
src.add(new StringBuilder("a"));
src.add(new StringBuilder("b"));
src.add(new StringBuilder("c"));
System.out.println(src); // [a, b, c]
final var sample = new Sample(src);
final var dst = sample.getList();
System.out.println(dst); // [a, b, c]
// ★
src.get(0).append("XXX");
final var dst2 = sample.getList();
System.out.println(dst2); // [aXXX, b, c]
Listの要素は、可変オブジェクトの StringBuilder です。
そのため、List自体は不変でも、その要素の状態の変更ができてしまいます。
標準APIの不変オブジェクト
標準APIで使われている代表的な不変オブジェクトをいくつかご紹介します。
文字列
String は文字列を表します。
Javaの不変オブジェクトの代表ですね。
不変オブジェクトのメリットであるコピー不要…つまり共有できることも明記されています。
ファイルパス
Path はインタフェースですが、その実装は不変であることが明記されています。
また、不変オブジェクトのメリットであるスレッドセーフについても記載がありますね。
日付・時刻
Java 7までは、日付や時刻を扱うには Date や Calenderクラスが使われていました。
これらは可変オブジェクトです。
そして、Java 8 で日付・時刻に関連したAPIが刷新されました。
追加された java.time パッケージのすべてのクラスが不変オブジェクトとなります。
あれ、でも例えば、2021/12/1 という日付があって、月だけ変更したいときに不便になるのでは…と思ったかたもいるかもしれません。
そんなときのために with~ というメソッドが用意されています。
// 2021年12月1日の日付を表す不変オブジェクトを生成します。
final var src = LocalDate.of(2021, 12, 1);
System.out.println(src); // 2021-12-01
// 月だけ変更した新しいオブジェクトを返します。
final var dst = src.withMonth(4);
System.out.println(dst); // 2021-04-01
// 元のオブジェクトは変更されていません。
System.out.println(src); // 2021-12-01
このようなメソッドを追加してまで不変オブジェクトであることを優先した、ということですね。
関連記事:Date, CalendarではなくLocalDateTime, ZonedDateTimeを使おう
まとめ
不変オブジェクトとは、アクセスできるすべてのメソッド、フィールドに対して
- いつでも
- どんなときでも
- なんどでも
- 複数のスレッドから同時にアクセスされたとしても
その結果は変わりません。変わってはいけません。
不変オブジェクトのメリットは、最初は感じにくいかもしれません。
しかし、プログラムの規模が大きくなってくると、じわじわと実感できていくと思います。
Java標準APIでも積極的に採用されています。
ぜひ有効に活用していきましょう。
関連記事
- synchronizedの多いコードは危険信号
- シールクラスの基本(Sealed Class)
- FileクラスよりもPathとFilesを使おう
- Date, CalendarではなくLocalDateTime, ZonedDateTimeを使おう
- 標準APIにならう命名規則
- コメントが少なくて済むコードを目指そう
- シングルトン・パターンの乱用はやめよう
- メソッドのパラメータ(引数)は使う側でチェックしよう
- 依存性の注入(DI)をもっと気軽に
- 不要になったコードはコメントアウトで残さずに削除しよう
- 簡易的な Builder パターン
- 読み取り専用(const) のインタフェースを作る
- 図解/デザインパターン一覧 (GoF)
- Abstract Factory パターン
- Adapter パターン
- Bridge パターン
- Builder パターン
- Chain of Responsibility パターン
- Command パターン
- Composite パターン
- Decorator パターン
- Facade パターン
- Factory Method パターン
- Flyweight パターン
- Interpreter パターン
- Iterator パターン
- Mediator パターン
- Memento パターン
- Observer パターン
- Prototype パターン
- Proxy パターン
- Singleton パターン
- State パターン
- Strategy パターン
- Template Method パターン
- Visitor パターン