[雑談] 試験の役に立たないJava講座 (4)

ということで引き続きインプレス社の「徹底攻略Java SE 11 Silver問題集」の章立てに沿って、少し雑談していきたいと思います。

以前よりお話ししているとおり問題集の内容に沿ったものではなく抜けているところも多々ありますので、認定資格を取得しようというのであれば問題集を購入して、そちらをしっかり勉強してください。

こちらは章立てに沿って適当に書き散らかしているものですので、試験の役には立ちません。

ということで今回は第2章の続き、文字列型とStringBuilderオブジェクトを扱います。

文字列型について

Javaでは文字列をStringクラスのオブジェクト型として扱いますが、普通のオブジェクト型 (については後程説明します) と異なり、一部プリミティブ型と同様の扱いができるようになっています。

文字列は広く使われている基本的なデータ型なので、一般的なオブジェクトと同じ扱いでは使いにくい、というよりやってられないと考えたものと思われます。

文字列型オブジェクトの特例的なふるまい

では、文字列型が一般的なオブジェクト型とどう違うかについて少し調べていきます。

文字列リテラルがある

文字列型の変数を初期化する場合、以下のように文字列リテラルを使用することができます。

String s = "Hello, World!";

一見当たり前のような気がしますが、この式をオブジェクトとして考えると面倒なことに突き当ります。

一般のオブジェクト型の場合、オブジェクトを取得する方法はいくつかあります。

AClass c = new AClass(someParameter);

簡単に説明するとnew AClass(someParameter)でAClass型のオブジェクトが1個生成され、それが変数cに設定されます。

定数的なものが必要な場合にはクラスのスタティックフィールドを使用して、以下のように書くこともできます。

AClass c0 = AClass.A_INSTANCE;

この場合、AClass型のオブジェクトA_INSTANCEAClassクラスがJava実行環境に読み込まれ、初期化が行われた段階で生成され、AClassのクラスオブジェクトが保持している形となります。

この形式では1個のスタティックフィールドからは1種類のオブジェクトしか取得できないという制約があります。

ちょっと凝ったやり方として、次のようなものもあります。

AClass c1 = AClass.getInstance(someParameter);

このやり方はオブジェクトの出自を隠すもので、いろいろと応用が利くのでよく使われますが、実体としてこれまでの2種類のいずれか、新しいオブジェクトが生成されて渡されるか、クラスオブジェクトに保持されているオブジェクトが返されるかのいずれかになります。

ここまでを確認したところで文字列の式に戻ってみると、=の右側に文字列型を示すStringが登場していない点が最初に気付く点です。

文字列型は当たり前のように使われるのでStringクラスであることを省略可能としたのだと思います。

次に作られた文字列型オブジェクトがどこに保持されているかが不明になっている点があります。

これらの特徴から、文字列リテラルを使用した式は以下の式と同等と考えてよさそうです。

String s = String.getInstace("Hello, World!"); // 実際にはこんな式は書けません

と思ったら文字列リテラルが出てきていますので他の書き方を考える必要があります。

例えば以下のようなものです。

String s = String.getInstance(new char[]{'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '!'}); // 実際にはこんな式は書けません

上のnew char[]{'H', 'e', ..., '!'}は文字配列オブジェクトを作成する手続きで、文字列を構成する文字を構成順序とともに渡しています。

これで文字列リテラルを使用しなくても済むようになりましたが、書き方としてはあり得ないほど面倒ですし、文字列を作るごとに文字配列オブジェクトを生成しては破棄するといった操作が行われるため、性能面でも問題になると思われます。

文字列は文字列型変数の初期化だけでなく、画面への文字出力をはじめ、さまざまな局面で広く使われいて、そのたびごとに文字配列を文字列に変換しているようでは大変すぎるということがあるので文字列リテラルが導入されたのでしょう。

なお、文字配列から文字列型オブジェクトを生成する方法を念のため、以下に2種類示しておきます。

String s0 = new String(new char[]{'H', 'e', 'l', 'l', 'o'});
String s1 = String.valurOf(new char[]{'H', 'e', 'l', 'l', 'o'});

上の式は新しい文字列オブジェクトを生成する例、下は文字列オブジェクトを取得する例で、下の式で得られる文字列オブジェクトが使い回しか新しいオブジェクトかは特に決められておらず、その時によって変わります。

文字列の連結

JavaではString s = "AB" + "CD";のように「+」演算子を使用して文字列を連結することができます。

現時点でJavaは演算子の再定義を認めていないので、数値演算以外で「+」などの算術演算子を算術演算以外で使用できるのはこの例だけです。

文字列の連結演算では文字列以外のものも文字列に変換して連結することができます。

例えば以下のような例が挙げられます。

int id = 3;
String idString = "ID: " + id; // "ID: 3"
int floor = 5;

String floorName = floor + "階"; // "5階"

char ch = 65; // 'A'

String ab = ch + "B"; // "AB"

1番目の例 (idString) のように「+」の左側が文字列で右側が文字列ではない場合、右側が文字列に変換されたうえで連結され「ID: 3」といった文字列になります。

2番目の例 (floorName) のように「+」の左側が文字列でなくとも右側が文字列であれば左側が文字列に変換されて連結され「5階」といった文字列となります。

3番目の例は文字型の変数を使用した場合です。文字型は整数として計算できますが、文字列と連結する場合には該当する文字に変換されたうえで文字列との連結が行われるので「65」に該当する「A」に変換されたうえで「AB」という文字列となります。

ここで注意しなければいけないのは、次のような例です。

String s = "ABC" + 1 + 2 + 3;

文字列連結の「+」と数値演算の「+」は同じ優先度 (そのうち説明します) なので、演算は左から順に行われ、まず「"ABC"」と「1」が文字列連結されて「"ABC1"」になり、「”ABC1”」と「2」が文字列連結されて「"ABC12"」になり、最後に「"ABC12"」と「3」が文字列連結され、「"ABC123"」という文字列になります。

先に数値計算を行いたい場合はかっこでくくって以下のようにします。

String s = "ABC" + (1 + 2 + 3);

このくらい簡単だと間違いようもありませんが、少し複雑な式になるとやらかす場合も多いようです。

文字列の同値比較

文字列の同値比較には「==」は使用できません。代わりにequalsメソッドを使用します。

どういうことかを千円札を使って説明してみたいと思います。

とりあえず千円札を2枚出してみてください (別に5千円札でも1万円札でも構いませんが)。どちらも千円札として使用できるという点では同じものですが、モノとして見ると別物ということになります。

しかし、5千円札と千円札ではどちらもお札ではあるものの5千円札の代わりに千円札は使えないわけで、そういった点で別物ということになります。

Javaの文字列型オブジェクトも同じような考え方になります。

文字列型オブジェクトが保持している文字列内容が同じであっても、独立した別のオブジェクトという場合があります。

==」による比較はオブジェクトが同じかどうか、千円札で言えばモノとしての千円札が同じかを比較するものなので、文字内容が同じであってもfalse (異なっている) という結果になる場合があります。

これに対してequalsメソッドは文字列型オブジェクトが保持している文字内容が同じかどうかの比較を行うので、文字列として比較したい場合はこちらを使う必要があります。

以下に例を示します。

String s0 = "ABCD";
String s1 = String.valueOf(new char[]{'A', 'B', 'C', 'D'});
System.out.println("s0 == s1: " + (s0 == s1)); // false

System.out.println("s0.equals(s1): " + s0.equals(s1)); // true

一般論として必要なことはここまでですが、文字列リテラルについては特別な扱いがされるので、ついでに触れておきます。

文字列リテラルはいくつ出てきても同じオブジェクトとして使いまわされます。

例えば以下のような例を考えてみます。

まず、helloパッケージに文字列リテラルをスタティックフィールドとして持つクラスを1個用意します。

package hello;
public class Strings2 {
public static final String S0 = "ABCD";

}

次に実際に比較するコードを別のパッケージのクラスに作成します。

String s0 = "ABCD";
String s1 = "ABCD";
String s2 = "AB" + "CD";

System.out.println("s0 == s1: " + (s0 == s1));

System.out.println("s0 == s2: " + (s0 == s2));

System.out.println("s0 == hello.Strings2.S0: " + (s0 == hello.Strings2.S0));

上記3種類の比較はすべてtrueとなり、同じオブジェクトであることがわかります。

まず、s0s1ですが、同じ「ABCD」という文字列リテラルなので、コンパイラがコンパイル時に1個の文字列オブジェクトに統合している可能性があります。

次のs2は「AB」と「CD」の2個の文字列リテラルを連結していますが、これもコンパイル時に連結が行われて「ABCD」という文字列が作られ、s0s1の文字列リテラルと統合されていると思われます。

3番目の他のクラスで使用されている文字列リテラルですが、コンパイルは別々に実行されるので、コンパイル時に統合されることはありません。

このため、実行時に文字列リテラルの統合が行われているはずです。

推測となりますが、どこかに文字列オブジェクトを蓄積する領域が用意されていて、クラスファイルが実行のために読み込まれるごとにクラスで使用している文字列リテラルがその領域に格納され、その際に文字列リテラルの統合が行われているのではないかと推測しています。

なお、文字列オブジェクトを蓄積する領域については次のinternメソッドでも触れます。

ということで文字列の同値比較についてまとめると、文字列の比較では原則「==」は使用せず、equalsメソッドを使用します。

文字列リテラルは統合されて、同じ文字列を持つ文字列リテラルはすべて一つのオブジェクトとなっています。

internメソッド

文字列の同値比較のところで文字列オブジェクトを蓄積する領域について推測を行いましたが、そういう領域があり、その領域を積極的に利用する方法としてinternメソッドがStringクラスに用意されています。

Java API仕様によると、internメソッドは文字列オブジェクトのプールを検索して、同じ文字内容の文字列オブジェクトがあればその文字列オブジェクトを返し、見つからなかった場合はその文字列オブジェクトをプールに保存したうえでそのまま返すものだそうです。

そして文字列オブジェクトのプールにはすべての文字列リテラルと文字列値定数式が記録されているとのことです。

このinternメソッドを使用すると、文字列内容の比較を「==」を使用して行うことができます。

例えばこんな感じです。

String s0 = "ABCD";
String s1 = String.valueOf(new char[]{'A', 'B', 'C', 'D'});
System.out.println("s0 == s1: " + (s0 == s1)); // false

System.out.println("s0.equals(s1): " + s0.equals(s1)); // true

System.out.println("s0.intern() == s1.intern(): " + (s0.intern() == s1.intern())); // true

以前の例に追加した3番目の行がinternメソッドを使用したもので、期待通りに動作しています。

ところで、internメソッドが使用している文字列オブジェクトのプールがどのような構成になっているかを考えるのは面白いと思います。

まず必要なこととして、検索に時間がかかってはいけないということ。

internメソッドを使用することはほとんどないかもしれませんが、クラスファイルを読み込んで初期化する時点でクラスファイル内のすべての文字列リテラルを登録していかなければならず、登録時には統合のための検索が必要になるので、検索性能が重要になってきます。

検索対象が文字列であることを考えると、文字列オブジェクトのプールはHashMapを使っているであろうことが推測できます。

次に登録するオブジェクトについてです。

文字列オブジェクトは使う必要がなくなれば削除され、ガベージコレクタによって回収されます。

しかし、文字列オブジェクトプールに登録されたままだとガベージコレクタが回収することができず、ごみとして残ってしまいます。

これを避けるために文字列オブジェクトプールには文字列オブジェクトではなく文字列オブジェクトへの弱い参照が登録されているだろうと推測されます。

そして文字列オブジェクトプール自体はStringクラスのスタティックフィールドとして確保されているのではないかと考えています。

と、ここまで考えてソースコードを見てみたらinternメソッドはネイティブ実装となっていて実装を確認することができませんでした。

たぶん、これまで考えてきたことより気合の入った実装がされているのでしょう。

swich文での使用

switch文で文字列が使用できるのはJavaの特徴と言っていいかもしれません。

switch文は値が1だったらこの処理、2だったらあの処理、3だったらその処理といった感じで分岐する制御構造で、if文でも同じ処理を書けるのですが、より分かりやすい書き方として重宝されています。

他のプログラミング言語では分岐先を決定する処理が簡単になるのでプリミティブ型の値だけを使用できるようにしているのですが、Javaはプリミティブ型に加えて文字列型も使用できるようにしています。

Javaはもともと列挙型 (enum) を用意していなかったので、列挙型の代わりに文字列を使う実装が広く行われていました。

このため、switch文も文字列をサポートしているのかと思ったらswitch文で文字列を使用できるようになったのが列挙型の導入後というのはちょっと謎です。

不変性

もう一つ気を付けておく必要があるのが、文字列オブジェクトは不変 (immutable) であるという点です。

文字列リテラルだけでなく、生成された文字列オブジェクトも生成されて時点で内容が確定し、内容を変更することはできません。

例えば特定の文字を別の文字に置き換えるreplaceメソッドを使用した、以下の例を考えます。

String s0 = "ABCDE";
String s1 = s0.replace('C', 'c');

このプログラムではs0.replace('C', 'c')のメソッド呼び出しで文字列s0の3番目にある「C」の文字が「c」に置き換えられた「ABcDE」がs1に設定されます。

この時s0には文字の置き換えは適用されないので文字列s0は「ABCDE」のままです。

上に挙げたreplaceや部分文字列を取り出すsubStringなどは勘違いしやすいので、気を付ける必要があります。

また、以下のように文字を連結するプログラムを考えてみます。

char[] chars = new char[] {'A', 'B', 'C', 'D', 'E'};
String s = "";
for (char c : chars) {

  s += c;

}

このプログラムを素直に読むと文字列sに文字を1個ずつ付け足していくように見えますが、実際には文字を付け足すごとに新しい文字列オブジェクトを生成し、直前の文字列オブジェクトを捨てるといった処理を繰り返しています。

キーボードやその他の入力装置から文字を読み取って文字列にするといった処理は時々発生します。そういった場合にこのようなコードを書くとオブジェクトの生成と破棄が大量に発生することになり、性能上の問題を引き起こす可能性があります。

Javaではこういった用途に使用するオブジェクトとしてStringBuilder/StringBufferクラスを用意しています。

StringBuilderについては次で説明します。

StringBuilder

上でも説明したように、文字列オブジェクトは不変という特性を持っているため、文字列への文字の追加、削除、変更などの編集を行うと文字列オブジェクトの生成と破棄が繰り返され、性能上の問題を引き起こす可能性があります。

このため、Javaでは文字編集用のオブジェクトとしてStringBuilderStringBufferというクラスを用意しています。

StringBuilderStringBufferの機能はほぼ同じで、使用できる条件に違いがあるだけですのでここではStringBuilderについて説明していきます。

感覚的な話をすると、文字列型オブジェクトは内部データとして固定化された文字列を保持しているのに対して、StringBuilderオブジェクトは内部データとして文字のリストを持っていて、文字のリストに対する操作メソッドを提供しているものと考えるのが適当だと思います。

StringBuilderオブジェクトの生成

StringBuilderオブジェクトを生成する際は以下のようなコードを使用します。

StringBuilder sb0 = new StringBuilder();
StringBuilder sb1 = new StringBuilder(32);
StringBuilder sb2 = new StringBuilder("Hello");

1行目の引数を持たない形式では、初期容量として16文字分の容量を持った空のStringBuilderオブジェクトが作られます。

StringBuilderオブジェクトは追加できる文字数を容量として決めています。

文字を追加していくことで容量を使い切って、文字が追加できなくなると自動的に容量を拡大します。

これにより1文字ごとに保存する領域を確保するといった性能面の損失を回避するとともに、必要に応じて容量を拡大することで容量不足による処理エラーも起きないようにしています (もちろんあつかえるメモリ容量を超えた場合は別ですけど)。

そして既定の初期容量を16文字と決めています。

初期容量16文字が適当でない場合、例えば1回あたり20文字くらいは必ず使用するといった場合には2番目の例にある整数引数を与える形式を使用します。

これを使用すると初期容量の文字数を引数で指定することができます。

また、文字列の編集をやりたいといった場合には3番目の引数として文字列を指定する形式を使用します。

これを使用すると引数で指定された文字列が挿入された状態でStringBuilderオブジェクトが生成され、初期容量としては指定した文字列の長さに16文字が加算された長さとなります。

StringBuilderオブジェクトへの文字の追加・挿入・削除

まず最初に文字の追加を扱います。

StringBuilderオブジェクトに保持されている文字列の末尾に文字を追加する場合はappendメソッドを使用します。

先程の文字列型オブジェクトで使用した例をStringBuilderオブジェクトで書き直すと以下のようになります。

char[] chars = new char[] {'A', 'B', 'C', 'D', 'E'};
StringBuilder sb = new StringBuilder();
for (char c : chars) {

  sb.append(c);

}

appendメソッドは引数として文字型以外にも数値や文字配列、文字列などさまざまな値を指定することができ、引数の型に応じた文字列が追加されます。

文字列の末尾ではなく、任意の位置に文字を挿入することもでき、その場合はinsertメソッドを使用します。

先程の例を少し変えて、逆順の文字列を生成する例を考えます。

char[] chars = new char[] {'A', 'B', 'C', 'D', 'E'};
StringBuilder sb = new StringBuilder();
for (char c : chars) {

  sb.insert(0, c);

}

insertメソッドの最初の引数は文字を挿入する位置で、「0」が指定されると文字列の先頭に文字が挿入されます。上の例では文字配列の各文字が順に先頭に追加されていくので、結果として文字配列の順序とは逆順の文字列となります。

ところで、StringBuilderオブジェクトに記録されている文字数を取得するにはlengthメソッドが使用できます。

これを使用して以下のように書くとinsertメソッドを使用してappendメソッドと同じことができます。

char[] chars = new char[] {'A', 'B', 'C', 'D', 'E'};
StringBuilder sb = new StringBuilder();
for (char c : chars) {

  sb.insert(sb.length(), c);

}

insertメソッドもappendメソッドと同様に文字型以外の引数を指定することができ、引数の型に応じた文字列として挿入されます。

次に削除について調べます。

1文字削除はdeleteCharAtメソッドを、複数の文字を一括して削除する場合はdeleteメソッドを使用します。

StringBuilder sb = new StringBuilder("ABCDE");
sb.deleteCharAt(2);
sb.delete(0, 2);

上の例の1番目はdeleteCharAtメソッドの引数が0始まりなので3番目の文字「C」が削除され、「ABDE」となります。

2番目はdeleteメソッドの例で、最初の引数が削除を始める位置、2番目の引数が削除終わりの次の位置を指定するので1番目の文字から3番目の文字の手前 (2文字目) までが「ABDE」から削除されて「DE」となります。

ちなみにすべての文字を削除するのは以下のように書けます。

sb.delete(0, sb.length());

deleteメソッドの2番目の引数が削除終わり位置ではなく、その次の文字位置であることに注意が必要です。

最後にStringBuilderオブジェクトからの文字列の取り出しについてです。

StringBuilderオブジェクトから編集された文字列を取り出す際にはtoStringメソッドを使用します。

StringBuilder sb = new StringBuilder();
sb.append(new char[] {'A', 'B', 'C', 'D', 'E'});
System.out.println("sb.toString() " + sb.toString());

System.out.println("sb: " + sb);

上の例の1行目がtoStringメソッドの使い方になります。

ところで、toStringメソッドはすべてのオブジェクトが実装していなければならないメソッドで、オブジェクトの値を適切な文字列と出力するという機能を持ちます。

文字列の連結のところで型に応じた文字列に変換すると言っていたのは、プリミティブ型は別ですが、オブジェクト型の場合にはtoStringメソッドが自動的に呼び出されるということを意味しています。

とはいえ、オブジェクトの中には文字列に変換する必要がないものも多々あります。

そういった場合にもtoStringメソッドを実装しなければいけないというのはあまりにご無体なので、クラスにtoStringメソッドが実装されていない場合には (あまり役に立たない) 既定の実装が使用されるようになっています。

これについてはオブジェクト型についてのところで触れていこうと思います。

StringBuilderオブジェクトにはこのほかにもメソッドがありますので必要に応じて調べてください。

まとめ

今回は文字列型と文字列を作成、編集するために使用するStringBuilderオブジェクトについて調べてみました。

Javaの文字列は以下のような特徴があります。

==」で同値比較はできないのでequalsメソッドを使用する必要がある

internメソッドを使うと文字列内容に応じた一意のオブジェクトを取得することができる

文字列型オブジェクトは不変なので、文字列を編集したい場合はStringBuilderオブジェクトを使用すべき

そしてStringBuilderオブジェクトは、文字列への文字の追加、挿入、削除を実行できるオブジェクトです

StringBuilderオブジェクトを使用する際の注意点としては、deleteメソッドの引数が示す文字位置が思っているものと違うかもしれないという点です。

最後におまけとして、文字とコードポイントについて触れておきます。

文字型のところでJavaは文字コードとしてユニコードを採用しているという話と、世界中の文字は16ビットの文字コードで表現できるという見通しだったので、Javaの文字型は16ビット整数型になっていること、そして残念ながらユニコードは16ビットで収まらなかったという話をしました。

16ビットの範囲からはみ出してしまった文字をどのように扱うかというと、サロゲートペアと呼ばれる特定のパターンを持った16ビットデータ2個で1文字を表現するようにしています。

こういった16ビットに収まらない文字をあつかうために文字列型オブジェクトとStringBuilderオブジェクトにはコードポイントをあつかうメソッドが用意されています。

普段使うことはあまりないと思いますが、多言語対応や特殊な文字を扱う場合には必要となる項目ですので、少なくともこういうものがあるということは確認しておく必要があると思います。

文字列については他のプログラミング言語でどのように扱っているかを調べてみるのも面白いと思います。


0コメント

  • 1000 / 1000