Java | Java 標準ライブラリ:String のイミュータブル性

Java Java
スポンサーリンク

「イミュータブル」ってそもそも何?

まず言葉から整理します。
「イミュータブル(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
Java

replacesubstring なども、全部同じです。

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 の中身が途中で変わってしまうと、
HashMapHashSet の動作がおかしくなります。
理由は、hashCode が変わってしまうからです。

イミュータブルであれば、一度作った String の中身は変わらないので、
equalshashCode の結果もずっと同じです。
だから HashMap<String, ...> に安心してキーとして入れられます。

もし String が可変だったら、

Map に入れた後にキーの中身が変わる
hashCode が変わる
でも Map は古い hashCode の位置にキーを置きっぱなし

というカオスな状態になってしまいます。

String のイミュータブル性は、「キーとして超優秀である」という性質にも繋がっています。

参照の共有が安全になる

同じ String をいろいろな場所で参照しても、
誰も中身を変えられないので、共有しても安全です。

例えば

String name = "Taro";
String a = name;
String b = name;
Java

ab も同じ 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
Java

StringBuilder 自体はイミュータブルではなく、
中のバッファを書き換えながら文字列を構築します。
最後に 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();  // こう書いたとき
Java

user.name.toUpperCase() が返した新しい String への参照を、
name フィールドに代入し直しているだけです。
元の “Taro” インスタンスは変わっていません。

「変数(フィールド)が指す先を変える」ことと
「オブジェクト自身の中身を変える」ことを、頭の中でちゃんと分けて考えてください。

String は「中身を変えることができない」ので、
いつもやっているのは「指す先を別の String に変えている」だけです。


まとめ:String のイミュータブル性をどう意識すればいいか

ここまでを、初心者向けに整理し直すとこうなります。

String の中身は一度作ったら絶対に変わらない
変更系に見えるメソッド(toUpperCase, replace, substring など)は、全部「新しい String を返すだけ」
代入しているのは「変数が指す先」であって、オブジェクトの中身を書き換えているわけではない
イミュータブルなおかげで、スレッドセーフ・Map のキーとして安全・共有しても壊れない
一方で、大量連結すると「新しい String 乱発」で重くなるので、そこだけ StringBuilder を使う

タイトルとURLをコピーしました