「イミュータブル」ってそもそも何?
まず言葉から整理します。
「イミュータブル(immutable)」は「不変」という意味です。
Java の String はイミュータブルなので、
一度作られた String オブジェクトの中身(文字列)は、絶対に書き換わらない
という性質を持っています。
ここがめちゃくちゃ重要です。
「書き換わらない」ということは、String に対して「変更しそうに見えるメソッド」を呼んでも、実際には元のオブジェクトは変わりません。
すべて「新しい String が返ってくるだけ」です。
この「元は変わらない」「新しいのができるだけ」という感覚を、体で覚えるイメージでいきましょう。
典型的な誤解:メソッドを呼べば中身が変わると思ってしまう
toUpperCase や replace の落とし穴
まず、よくある誤解から。
次のコードを見てください。
String s = "hello";
s.toUpperCase();
System.out.println(s);
Java直感的には「大文字にしたから HELLO と出そう」と思うかもしれません。
でも実際の出力は
hello
です。
理由は、toUpperCase() がこういうメソッドだからです。
元の String を変更せず
大文字にした新しい String インスタンスを返すだけ
つまり、さっきのコードは
「大文字版の新しい String を作ったけど、どこにも変数として受け取っていない」
状態です。
正しくはこう書きます。
String s = "hello";
String upper = s.toUpperCase();
System.out.println(s); // hello
System.out.println(upper); // HELLO
Javareplace や substring なども、全部同じです。
String s = "abcabc";
String t = s.replace("a", "x");
System.out.println(s); // abcabc(元はそのまま)
System.out.println(t); // xbcxbc(新しいオブジェクト)
Java「String のメソッドは元を変えない。新しい String を返す」というルールを、まず頭に刻んでください。
なぜわざわざイミュータブルにしているのか(重要な理由)
スレッドセーフであること
マルチスレッドを考えると、このイミュータブル性はものすごく大事になります。
あるスレッド A が String を読んでいる最中に
別のスレッド B がその String を書き換えた
という状況をイメージしてみてください。
途中で中身が変わると、A は「読み始めたときと違うもの」を読まされることになります。
でも String は不変なので、そもそも誰も中身を書き換えられません。
つまり、どれだけのスレッドから同じ String を触っても
読み途中で中身が変わることは絶対にない
ので、非常に安全です。
String が、あちこちで安心して共有できるのは、この不変性のおかげです。
equals / hashCode の結果が安定する
String はコレクションのキーとしてよく使います。
Map<String, User>Set<String>
などですね。
このとき、キーである String の中身が途中で変わってしまうと、HashMap や HashSet の動作がおかしくなります。
理由は、hashCode が変わってしまうからです。
イミュータブルであれば、一度作った String の中身は変わらないので、equals と hashCode の結果もずっと同じです。
だから HashMap<String, ...> に安心してキーとして入れられます。
もし String が可変だったら、
Map に入れた後にキーの中身が変わる
hashCode が変わる
でも Map は古い hashCode の位置にキーを置きっぱなし
というカオスな状態になってしまいます。
String のイミュータブル性は、「キーとして超優秀である」という性質にも繋がっています。
参照の共有が安全になる
同じ String をいろいろな場所で参照しても、
誰も中身を変えられないので、共有しても安全です。
例えば
String name = "Taro";
String a = name;
String b = name;
Javaa も b も同じ String を参照していますが、
どちらからも中身は変更できないので、共有に何の問題もありません。
「共有しても誰にも壊されない」
これも、不変オブジェクトの大きな魅力です。
String のイミュータブル性が招く「別の注意点」:連結のコスト
連結のたびに新しいオブジェクトができる
イミュータブルだからこそ、気をつけるべきポイントもあります。
代表が「大量連結」です。
例えばこういうコード。
String s = "";
for (int i = 0; i < 5; i++) {
s = s + i;
}
System.out.println(s); // 01234
Java見た目はシンプルですが、中で何が起きているかを考えてみます。
最初の s = "" から、
1回目: s + 0 で新しい String(”0″)を作る
2回目: 今の s は “0”。"0" + 1 で新しい “01” を作る
3回目: 今の s は “01”。"01" + 2 で新しい “012” を作る
というように、毎回「新しい String オブジェクト」が作られます。
ループ回数が少なければ問題になりませんが、
これが 1万回、10万回となると、
かなりの数の String インスタンスを作っては捨てることになります。
String がイミュータブルだからこそ、
「文字列をちょっと足すたびに新しいオブジェクトが必要になる」というコストが発生するわけです。
大量連結は StringBuilder を使う
この問題に対応するためにあるのが StringBuilder です。
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
sb.append(i);
}
String s = sb.toString();
System.out.println(s); // 01234
JavaStringBuilder 自体はイミュータブルではなく、
中のバッファを書き換えながら文字列を構築します。
最後に toString() でイミュータブルな String に変換するイメージです。
初心者のうちは
ちょっとした連結なら + で良い
ループでゴリゴリ連結するときは StringBuilder を使う
くらいを意識しておけば十分です。
イミュータブルだからこそ起こる「代入の感覚のズレ」
「変更」と「新しい参照の代入」は全く別物
もう一つ、感覚の整理をしておきます。
次のコードを見てください。
String s = "hello";
s = "world";
Javaここで起きているのは、「s が指しているオブジェクトを変えた」だけです。"hello" という String インスタンスは、一切変わっていません。
ただ s 変数が、別の String(”world”)を指すようになっただけです。
これはフィールドでも同じです。
class User {
String name;
}
User user = new User();
user.name = "Taro";
user.name = user.name.toUpperCase(); // こう書いたとき
Javauser.name.toUpperCase() が返した新しい String への参照を、name フィールドに代入し直しているだけです。
元の “Taro” インスタンスは変わっていません。
「変数(フィールド)が指す先を変える」ことと
「オブジェクト自身の中身を変える」ことを、頭の中でちゃんと分けて考えてください。
String は「中身を変えることができない」ので、
いつもやっているのは「指す先を別の String に変えている」だけです。
まとめ:String のイミュータブル性をどう意識すればいいか
ここまでを、初心者向けに整理し直すとこうなります。
String の中身は一度作ったら絶対に変わらない
変更系に見えるメソッド(toUpperCase, replace, substring など)は、全部「新しい String を返すだけ」
代入しているのは「変数が指す先」であって、オブジェクトの中身を書き換えているわけではない
イミュータブルなおかげで、スレッドセーフ・Map のキーとして安全・共有しても壊れない
一方で、大量連結すると「新しい String 乱発」で重くなるので、そこだけ StringBuilder を使う
