ググったら似たような記事がいくつもヒットすると思うけど、あまりにもモヤモヤしたのでここにまとめておく。すんまへん...
ことの発端
とあるJavaカリキュラムでオブジェクト指向の勉強をしている最中に、「文字列の比較にはequals()
メソッドを使うんだよ!==
は使えないよ!」と説明してあり、「ふーん。そうなんだねー...」と思ったまま、特にその時は何の疑問ももたずに学習を進めていた。しかぁし!、その2つくらい先の章の最初のサンプルコードにぃ〜?、下のように書いてあるじゃぁ、あ〜りませんかぁ!
boolean sampleMethod(String s1, String s2) {
if(s1 == "Hello" && s2 == "World") {
...
...あれ?==
使ってるやん!?しかも、これについて何の解説もされてなく、さも当たり前かのように書いてあるので、どういうことだと思ってちょっと調べてみることにした。
結論
文字列リテラルを直接クラス型変数に代入するインスタンスの生成方法で、同一のメモリ位置を参照しているので、上の「コード1」のような書き方もできる。ただ、Stringもオブジェクト型の一つなので、あんまり良い感じはしない。
Stringクラスの特徴
Stringって他の一般的なクラスと違ってちょっと浮いてるよね?...ということで、Stringクラスの主な特徴をまずは列挙しておく。
1. 不変(イミュータブル)なオブジェクト
Stringオブジェクトは一度生成されると、その内容を変更することはできない。これを、 イミュータブル なオブジェクトという。例えば、既存のStringオブジェクトに新しい文字列を追加したり、一部の文字を変更したりすると、新しいStringオブジェクトが生成される。
2. メモリ効率
Stringリテラルが生成されると、それは 文字列プール(string pool)1 と呼ばれる特別なメモリ領域に格納される。同じ文字列のリテラルが再度登場すると、新しいオブジェクトを生成する代わりに、このプールから既存の文字列を再利用する。
3. 多様な操作
長さを取得するlength()
, 部分文字列を抽出するsubstring()
, 連結を行う concat()
, 比較を行うequals()
など、多くの便利なメソッドが提供されている。
4. StringBuilderとStringBuffer
Stringのイミュータブル性が問題となる場合、例えば、頻繁に文字列を変更する必要がある場合などに、StringBuilder
またはStringBuffer
クラスというものを別途使用する。これらは可変(ミュータブル)なオブジェクトを生成し、パフォーマンスの向上に役立つ。
5. コンパイル時の最適化
Javaコンパイラは、文字列連結などの操作を効率化するために、コンパイル時に最適化を行う。例えば、「String a = "Hello" + "World";」は、実行時ではなくコンパイル時に連結され、「String a = "HelloWorld";」と同等になる。
6. インターン?
Stringクラスのintern()
メソッドを使うと、文字列がプール内にある場合、その参照を返す(「コード4」参考)。プール内にない場合は、新しい文字列をプールに追加し、その参照を返す。こういったちょっと特殊なメソッドも存在するようだ。
2通りのインスタンス生成方法
1. リテラルをクラス型変数に直接代入する方法
Stringクラスのインスタンスを生成する最も一般的な方法。この場合、文字列リテラルはJavaの文字列プールに格納される。
String str1 = "Hello";
String str2 = "Hello";
この例では、str1とstr2は同じ文字列リテラルを参照し、同一のメモリ位置を共有している。
2. new
キーワードを使用する方法
クラスからインスタンスを生成する汎用的な方法。この場合、文字列プールは使用されず、新しいStringインスタンスがヒープメモリ2上に作成される。
String str3 = new String("Hello");
String str4 = new String("Hello");
この例では、str3とstr4は別々のインスタンスであり、異なるメモリ位置をもつ。
new
キーワードを使用すると、新しいインスタンスが毎回生成されるため、メモリ効率が低下してしまう。特に、同じ文字列リテラルが頻繁に使用される場合、不要なインスタンスが大量に生成される可能性がある。
2通りの比較による違い
new
キーワードを使用して生成されたStringオブジェクトは、同じ文字列リテラルを使用しても異なるオブジェクトとして扱われる。
String str1 = "Hello";
String str2 = "Hello";
String str3 = new String("Hello");
String str4 = new String("Hello");
String str5 = str3.intern();
String str6 = str4.intern();
System.out.println(str1 == str2); // true
System.out.println(str3 == str4); // false
System.out.println(str1 == str3); // false
System.out.println(str2.equals(str4)); // true
System.out.println(str1 == str5); // true
System.out.println(str4 == str6); // false
重要なのは、比較演算子==
は “参照” を比較するのに対し、equals()
メソッドは “内容" を比較するという点である。上記「コード4」より、同じ文字列リテラルをクラス型変数に直接代入する場合、文字列プール内のリテラルを再利用するため、同一のメモリ位置、つまり参照先は同じになり、そこから生成されたオブジェクト同士の比較結果は true になる。
対照的に、new
キーワードを使用して生成されたオブジェクトは、常に異なるメモリ位置上に作成されるため当然参照先も異なり、比較結果は falseになる。しかし、equals()
メソッドを使えば、内容同士の比較、つまり文字列自体の比較になるため、全く同じ文字列同士なら比較結果は true になる。
ちなみに、new
キーワードを使用して生成されたオブジェクトに対してintern()
メソッドを使いさらにオブジェクトを生成する時、まず、生成元のオブジェクトの内容物である文字列がプール内に存在するかをチェックする。今回の例で言うと、上記「コード4」に文字列"Hello"がリテラルとして格納されていることが分かるため、その参照が返される。すると、文字列プールから参照されたオブジェクトと同一になるため、それ同士との参照比較とそうじゃないオブジェクトとの参照比較が、前述と同様に異なる結果になることが分かる。
このように、同じ内容の文字列が複数箇所で使用される可能性があるとき、文字列プールやintern()
メソッドを利用することで、無駄なメモリの使用を避け、効率化を図ることができる。
文字列プールやintern()
メソッドにもデメリットはある。
このメソッドで毎回プール内を確認しにいく処理が発生するため、頻繁に呼び出すと逆にパフォーマンスが低下する可能性がある。また、プール内に格納された文字列はガーベジコレクション3の対象外になるため、使用されなくなった文字列がメモリ上に残り続ける可能性もある。
2通りのデータ型
Javaは、大きく2種類のデータ型に分けられる。
1. プリミティブ型(Primitive Types)
Javaに元々組み込まれている基本的なデータ型。以下の8つがある。
- char
- byte
- short
- int
- long
- float
- double
- boolean
これらはメモリ内に直接値を格納する。
2. オブジェクト型(Object Types)/ リファレンスタイプ(Reference Types)
プリミティブ型以外のデータ型。主に以下のものがある。
- クラス(String、Integer、ArrayList など)
- インターフェース
- 配列
- 列挙型(enum)
これらはオブジェクトの参照(ポインタ)を格納する。
// プリミティブ型
int primitiveInt = 42;
// オブジェクト型(リファレンスタイプ)
Integer referenceInt = new Integer(42);
String referenceString = "Hello";
int[] referenceArray = {1, 2, 3};
プリミティブ型の変数はその値を直接保持するのに対し、オブジェクト型の変数は、オブジェクトへの参照を保持する。実際のオブジェクトはヒープメモリ上に格納され、変数はそのオブジェクトのメモリ位置を指している。
Javaにも “ポインタ” の概念がある!?
厳密に言うと、C言語のように明示的なポインタではないが、いわゆるポインタに似た概念は存在する。JavaやPythonのような高水準なプログラミング言語でも、オブジェクトの参照を扱うことで、ポインタに似た動作を実現している。
ポインタと参照の違い
- ポインタ(CやC++)
メモリ上のアドレスを直接手動(プログラム上)で操作するための変数。明示的にアドレスの取得(&
演算子)や参照(*
演算子)を行う。
int a = 10;
int *p = &a; // pは、aのアドレスを指すポインタ
printf("%d", *p); // 10
- 参照(Java、Pythonなど)
オブジェクトや変数のメモリ位置を間接的に指すが、直接アドレスを操作することはできない。メモリ管理やアドレスの操作は自動的に行われる。
String str1 = new String("Hello");
String str2 = str1; // str2は、str1と同じオブジェクトを参照
System.out.println(str2); // Hello
ポインタと参照の利点と欠点
- ポインタの利点
- 高度なメモリ管理が可能で、効率的なプログラムを作成できる
- データ構造やシステムプログラミングにおいて強力な機能を提供
- ポインタの欠点
- メモリ管理の複雑さとエラーのリスク(ダングリングポインタ、メモリリークなど)
- 安全性の問題(不正なメモリアクセスによるクラッシュやセキュリティホール)
- 参照の利点
- メモリ管理が自動化されているため、プログラミングが簡単で安全
- ポインタに関連するエラーが少ない
- 参照の欠点
- メモリ管理の制御が限定されており、効率が劣る場合がある
- 高度なメモリ操作が必要な場合に柔軟性が低い
他の言語における類似概念
- Python
変数はオブジェクトの参照を保持する。ポインタのようにアドレスを操作することはできないが、オブジェクトの参照を介して間接的に操作を行える。
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]
- C#
参照型と値型があり、参照型(クラスや配列)はJavaのようにオブジェクトへの参照を持つ。値型(基本データ型や構造体)は直接値を持つ。
string str1 = "Hello";
string str2 = str1; // str2は、str1と同じオブジェクトを参照
まとめ
さて、ここで最初のコードをもう一度確認する。
boolean sampleMethod(String s1, String s2) {
if(s1 == "Hello" && s2 == "World") {
...
これは、見れば分かるとおり、boolean型のsampleMethod()メソッドの一部である。中のif
文の条件式に、equals()
メソッドじゃなくて比較の==
演算子が使ってある!おかしい!というところから今回の話が始まった。改めて見直すと、こちらの書き方の方がより自然に見える(あれ?笑)。なぜなら、Stringクラスのオブジェクトに文字列を格納するとき、わざわざ「コード3」のようにnew
演算子を使わないからである。「コード2」の書き方が普通だし、プリミティブ型でも普通に==
演算子を使って値を比較するから可読性も高くてヨシ!
...いや、分からない。当該メソッドの仮引数はあくまでも「コード2」や「コード3」の左辺であって、呼び出し側のコードでどのようにインスタンスを生成しているのかは定義側からは見えないので、equals()
メソッドを使った方が無難なのかもしれない。
また、あくまでもStringはオブジェクト型の一つであり、クラス型変数にはオブジェクトの参照(つまり、メモリ位置でもありアドレスでもありポインタでもある)を格納するというのが大前提なので、良からぬ問題が起きないように、念のためにもやっぱりequals()
メソッドを使うに越した事がないのであろう。
それにしても、何かしら注釈は付けておいてほしかった...章の前後でそれぞれ別のかたが執筆されていたのかな?^^;
実務ではどちらを使うのが好ましいのだろうか?やっぱりequals()
?
あと、今回メモリについても少し記述したけど、「ガーベジコレクション」と「ガベージコレクション」てどっちが正しいんだろう?(笑)これもカリキュラムに両方で説明されてるんだよな...^^;
有識者のかた、コメントお待ちしておりますー!↓↓ (YouTuber並の勧誘)
参考にした記事とツール
-
近いうちに使い回しそうな○○をあらかじめ準備して一時的に溜めておく仕組みのことを、IT用語で ○○プール という。 ↩
-
コンピュータプログラムが実行時に使用するメモリ領域の一つで、任意に確保や解放を繰り返すことができるものを ヒープ領域 、あるいは ヒープメモリ(heap memory) という。 ↩
-
クラス型変数にnullを代入してどのインスタンスも指し示さなくなったり、特定のインスタンスの参照(アドレス)を記録しているクラス型変数が失くなったりしたら、それらのインスタンスはJavaの判断によって自動的に破棄され、メモリ上からも消去される。この仕組みのことを ガーベジコレクション という。 ↩