1.Stringの値を変更する(!)
JavaのString型は一般的には不変な(immutable)オブジェクトであり、複数の変数が同じStringの参照を指していたとしても、その値が不意に更新されることを心配する必要はありません。
そう、通常ならば。
import java.lang.reflect.Field;
public class Hoge {
public static void main(String[] args) throws Exception {
String hoge = "hoge";
String hoge2 = hoge;
// hoge の内部のフィールドを変更する
// java コマンドにオプション --add-opens java.base/java.lang=ALL-UNNAMED を指定すること
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
byte[] bytes = (byte[]) field.get(hoge);
bytes[1] = 'i';
System.out.println(hoge);
System.out.println(hoge2);
}
}
出力は
hige
hige
なんと不変なはずの文字列の値が変わってしました!
上記例では変数hogeとhoge2は同じ参照を見ています。
例えString型であろうと、オブジェクトのフィールドを直接弄ってしまえば、その影響は同じオブジェクトを参照している全ての変数に波及します。
もっとも、String型は通常の手段ではオブジェクトの値が途中では変わらない不変オブジェクトとして設計されていますから、これを心配することはありません。
2.不変オブジェクト
String型はJavaを触った人ならまず100%知っている馴染み深い型ではありますが、不変オブジェクトの説明にはあまり適さない型だと思われます。
余計なノイズが多いからです。
例えば不変オブジェクトの例として java.math.BigInteger型はどうでしょうか?
var x = BigInteger.valueOf(1000);
var x2 = x;
var y = BigInteger.valueOf(234);
// ありがちな過ち
x.add(y);
System.out.println("x:"+x);
System.out.println("x2:"+x2);
出力は
x:1000
x2:1000
おや? 1000 + 234 で 1234 となるはずでは?
BigIntegerは不変オブジェクトであることを思い出さねばなりません。変数 x の値が変わるわけではないのです。
x.add(y) は戻り値を返すことに注意してください。
var x = BigInteger.valueOf(1000);
var x2 = x;
var y = BigInteger.valueOf(234);
var z = x.add(y);
System.out.println("x:"+x);
System.out.println("x2:"+x2);
System.out.println("z:"+z);
出力は
x:1000
x2:1000
z:1234
今度はうまくいきましたね。答えはaddの戻り値で別のオブジェクトとなって返されるわけです。
3.可変オブジェクト
対比して数値を扱う可変なオブジェクトとなると丁度良いものが難しいのですが、java.util.concurrent.atomic.AtomicInteger型はどうでしょう。
これはマルチスレッド下でも原子性(Atomicity)を保った操作ができる可変な数値型です。原子性についてはDBのACID特性として聞いたことのある人が多いのではないでしょうか。
var a = new AtomicInteger(100);
var a2 = a;
var _ = a.addAndGet(8);
System.out.println("a:"+a);
System.out.println("a2:"+a2);
出力は
a:108
a2:108
今度は最初100だったaも108に変化していますね。値が変わっている、まさに可変なオブジェクトなわけです。addAndGet() という名前のメソッドはちょっと不思議に思うかもしれませんが、足すことと結果取得を原子性をもって行っているためです。(※ただしここでは取得した値は使わないので捨てていますが)
マルチスレッド環境下では、足した後、改めて値を取得してくるその刹那に他のスレッドに値を変えられてしまうかもしれません。
AtomicInteger はそうした部分をスレッドセーフにしてくれます。
さて、本題にもどりますと、可変オブジェクトの場合はオブジェクト自体の値が変化しました。a と a2 は同じオブジェクトを見ていたわけですから、System.out.println()してみると同じ値が見えます。
でも、これって、不変オブジェクトのケースでBigIntegerの x と x2 が同じ値だったことと変わりありませんよね。同じ参照を見ているから同じ。それだけのことです。
何かに惑わされて何か複雑に見えてしまうことがある。それは一体なんなのでしょう?
4.可変と不変の話を惑わすもの
可変オブジェクトと不変オブジェクトの話をしたいのだとしても、その説明をする際にいろいろなノイズが入ってきます。そうしたもので惑わされてしまうと話を複雑にしてしまいかねません。
4.1 再代入
finalではない変数は繰り返し代入することが出来ます。
var x = BigInteger.valueOf(1000);
var x2 = x;
x = BigInteger.valueOf(2000);
初学者はここで x2 の値は?と聞かれると惑うかもしれません。x2 と x が一緒になったのだから x を変更したら x2 にも波及する???
代入演算子 = は、右辺の評価値を左辺に代入します。JLS §15.26.1
「評価する」という表現はプログラミング言語特有の言い回しで慣れないと分かりにくいかもしれませんが、ごく大雑把にはこの時点で値が何かを見られると思ってください。
x2 = x とあれば、この時点で x が何者であるか見極められる。つまり実体である値が1000のBigIntegerのインスタンスだな、じゃあ x2 には値が 1000 のBigIntegerの参照を代入しよう、となるわけです。惑わされてはいけません。
再代入に対しては評価値が用いられます。変数が連動したりはしません。
4.2 リテラル
不変オブジェクトの例としてStringを用いると混乱する理由には文字列リテラルが挙げられます。文字列リテラルとは "hoge" のような値のことです。プリミティブ型各種とnullそしてStringはソースコード中で特別に値を表記することが出来ます。
ここで厄介なのですが、
var str1 = "hoge";
var str2 = "hoge";
System.out.println(Objects.toIdentityString(str1));
System.out.println(Objects.toIdentityString(str2));
このようなコードを実行すると、出力は
java.lang.String@41629346
java.lang.String@41629346
といったようになります。この Objects.toIdentityStringメソッド は参照のハッシュ値が分かるのです。
str1とstr2は直接代入をしたわけでもないのに同じオブジェクトを参照していることが分かります。これがStringリテラルの紛らわしい点で、リテラルを用いているがために別のオブジェクトのつもりで同じオブジェクトを見ていた、ということが起きます。
またリテラルの参照をもって「新しいオブジェクトが作成される」と誤解する方もいるかもしれません。Stringリテラルは生成されるタイミングが異なり、リテラルを使用している該当行が動くよりも前にインスタンスが作られてしまいます。そのため、オブジェクトの生成のタイミングを説明するうえではとても厄介です。Stringのコンストラクタにブレイクポイントを張ってデバッグ実行してみると実感できるかもしれません。
var str3 = "ho"+"ge";
System.out.println(Objects.toIdentityString(str3));
var str4 = new String("hoge");
System.out.println(Objects.toIdentityString(str4));
この出力は
java.lang.String@41629346
java.lang.String@404b9385
で、注意して欲しいのはstr3がstr1やstr2と同じハッシュ値である点です。これは詳しくは後述するんですが、リテラルの参照はこういうことが起こるということを覚えておいてください。
new Stringしている str4 は別の参照になっているのがわかりますね。
4.3 internプール
前述のStringリテラル、同じ値のものは同じ参照になっていました。これは インターン(intern)と呼ばれるもので、String.intern()メソッドによって行われます。
文字列リテラルはインターン化されるということが言語仕様には書かれています。JLS §3.10.5
このインターン化はリテラルだけでなく、newして作成したStringであってもintern()を呼び出すことでVMのconstant poolにある既知のものがあれば探し出してその参照を返しますし、なければ登録してそのStringの参照を返すという動きをします。
また、リテラル同士の+結合はコンパイル時に行われるため str3 = "ho"+"ge" のようなケースでも str1やstr2と同じ参照となったわけです。
4.4 +演算子と+=演算子
String型はJavaの中では特別な型で、特別に+演算子による結合が行えます。(JLS §15.18.1)
特に結合と代入とを同時に行う += 演算子の存在は、あたかもStringオブジェクトの値を書き換えているように錯覚するかもしれません。
var str = "甲";
str += "乙";
str += "丙";
System.out.println(str);
出力は
甲乙丙
これはString型だけの特例であり、これがあるからこそString型を不変オブジェクトの例として取り上げたくないのです。
実際の挙動としては += 演算子で結合するたびに別のStringのインスタンスに成り代わっていくわけなのですが。
var str = "甲";
System.out.println(Objects.toIdentityString(str));
str += "乙";
System.out.println(Objects.toIdentityString(str));
str += "丙";
System.out.println(Objects.toIdentityString(str));
java.lang.String@41629346
java.lang.String@6d311334
java.lang.String@682a0b20
4.5 オートボクシングとvalueOf
Stringの代わりの不変オブジェクトの例としてInteger型とかはどうでしょうか?
Integerなどのプリミティブ型のラッパー型では、プリミティブ型からのオートボクシング(JLS §5.1.7)という仕組みがインスタンス生成をややこしいものにしてしまいます。
オートボクシングが走るタイミングについては踏み込むととてもややこしいのでここでは触れません。
Integerの場合はこのオートボクシングに加え、valueOfでインスタンスを作る際
このメソッドは、-128から127の範囲(両端含む)の値を常にキャッシュしますが、この範囲に含まれないその他の値をキャッシュすることもあります。
とある点に注意してください。Integer.valueOf
こうした事情から、ラッパー型は避けた方が混乱が少ないでしょう
5.classとrecord
現代Javaで不変オブジェクトの話をするのであれば是非ともrecordについて取り上げて欲しいところです。(JLS §8.10)
従来のclassというのはフィールドを宣言し、コンストラクタを宣言し、setter/getterを宣言し、となかなか面倒くさいものでした。
public class MutablePoint {
int x;
int y;
public MutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
}
これが不変オブジェクトとしてrecordで宣言するならこうです
public record ImmutablePoint(int x, int y) {
}
とてもシンプル!
var point = new ImmutablePoint(10, 5);
System.out.println(point.x());
System.out.println(point.y());
利用の際はこのようにx()やy()といったように宣言した際の名前のgetterメソッドがあるかのように振舞います。そしてsetterはありません。
recordにはメソッドも宣言することが出来ますので、add()といった、計算をして別のインスタンスをnewして返すようなメソッドを宣言しておくと使い勝手が良くなるでしょう。
6.補足
さて、本記事ではキャッチーな掴みとして黒魔術を披露しつつ、その実としては不変オブジェクトの例としてString型は避けた方が良いという話をしてきました。
String型は身近すぎて実感がないかもしれませんが、言語仕様上は非常に特殊な型です。
先日のとある記事 では不変オブジェクトの例としてString型を使ってしまった故に、可変オブジェクトとの対比がうまく行えていないように窺えました。
6.1 セキュリティについて
Password漏洩の危険から敢えてStringを使わない例もあり、今となっては昔話ではありますがSwingなどGUIのAPIではString型で値を取得するメソッドが非推奨となっております。
代替のgetPassword()メソッドでは戻り値がchar[]となっているのは過去のセキュリティの事情からなのです。
char[]であれば、用済みとなった場合に値をゼロ埋めするなどして潰してしまうことができます。Stringの場合は 不変オブジェクトであるがゆえに、そのStringの値をゼロ埋めするようなことが出来ません! GC任せなので全ての強参照がなくならないと回収されませんし、なかなかプログラマ側でコントロールできないわけです。
長くメモリにパスワードが留まるのなら、メモリダンプなどが取れる状況にあれば(それは既にまずい状況と言えますが)メモリからパスワードが漏洩するリスクは高まります。
String型はinternによってVMのconstant poolに値が格納されてしまうと、GCではまず消えません。うっかりでintern()しないようにしましょう。