Java Beginner's Hint

概要

Javaの基本的な特徴について補足資料です。DBFluteと直接関係はありませんが、 現場ではJava初心者がいきなり開発することもありますので、 ここでは現場で重要と思われる、Java初心者向けのヒント(心得)を幾つかピックアップして補足的に書きたいと思います。 (基本的な文法や詳しい挙動などはJavaの本などで学んでください)

オブジェクト指向であること

これだけは押さえておきたい概念

Javaは、オブジェクト指向の考え方をプログラムに置き換えることによって効率化を測っています。 ここでは深くオブジェクト指向は説明しませんが、これだけは押さえておきたい概念を紹介します。

データ(情報)とそれを取り扱う処理(振舞い)を一緒に管理し、振舞いの再利用性を高くしています。

e.g. Object Oriented @Java
// this object has file path and connection to the file
File file = new File("/tmp/java-beginners-workshop.txt");

if (file.exists()) { // behavior (determination about the file)
    file.delete(); // behavior (manipulation to the file)
}

ここでは、Fileクラスを例にしています。Fileクラスは、ファイルパスという情報を保持しています。 そのパスが示すファイルシステム上の物理ファイルへ接続し、存在有無や削除処理などの振舞い(メソッド)を呼び出すことができます。

重要なのは、ファイル自体にそのファイルを取り扱うための振舞い(メソッド)が存在しているというところです。 delete(file) ではなく file.delete() というのがポイントです。

これだけですが、これを伝えたい

実はここで説明するのはこれだけです。 カプセル化やポリモーフィズムなどオブジェクト指向の重要なポイントがもっともっとありますが、 ここではとりあえず上記のポイント、これをしっかり伝えておきたいと考えました。 (オブジェクト指向の基本は本などで学んでください)

ただ、実際のJavaのプログラムでは、必ずしもこのようになっているとは限りません。 思いっきり delete(file) っぽい書き方があったりします。全てがオブジェクト指向わけではありません。 あくまでオブジェクト指向は効率化のための一つの道具でありそれ自体が目的ではないので、あまり業務にフィットしない場面ではその限りではないのです。

一方で、その線引きは曖昧であるため、オブジェクト指向っぽく作った方が良い場面でも、delete(file) になってしまうことも多くあります。これはなかなか解決されていない問題です。 また、そのときそのときのアーキテクチャに依存する面もあり、単純に delete(file) or file.delete() という 1/0 の問題でもありません。 ものすごい単純な概念でありながら、とても奥深い面も持ち合わせていて難しい問題でもあります。 それを理解した上で、Javaのソースコードを読んでいく必要があるでしょう。

例外メッセージとスタックトレース

Javaにおけるエラーの読み方は必ず理解しておいたほうが良いでしょう。 エラーが出ても目を背けず、しっかり読み解いていくことが速い原因究明に結びつきます。

スタックトレースの読み方

例えば、以下のような例外メッセージとスタックトレースがあるとします。(一部省略)

e.g. Exception Message & StackTrace @Log
java.lang.IllegalStateException: The status code was illegal: abc
    at ...logic.FooLogic.handle(FooLogic.java:54)
    at ...action.FooAction.execute(FooAction.java:44)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

この例外情報から読み取れることは以下の通り。

例外クラス
IllegalStateException
例外メッセージ
The status code was illegal: abc
例外発生個所
FooLogicの54行目、handle()メソッド内
handle()呼び出し
FooActionの44行目、execute()メソッド内でhandle()を呼び出し
execute()呼び出し
リフレクションからの呼び出し (Native Method)

例外クラスと例外メッセージで、原因を漠然と想像することができます。 また、スタックトレースを追跡することで、例外発生個所とそこまでの呼び出し階層を知ることができます。 この情報から実際のソースコードを追っていけば、多くの場合原因がわかります。 (例外メッセージがどれだけ親切かどうかが鍵)

不正なステータスコード "abc" が指定されたようです。さて、そんな適当なステータスコード、どこからやってきたんでしょう? 変なテストデータがあるのでしょうか?違う値をステータスコードとして扱ってしまってはいないでしょうか? そのように追っていくことで、原因を突き止めていけるでしょう。

逆に、自分が例外を発生させる場合に、例外メッセージにどんな情報を入れておけば原因を追求しやすいのか、これがわかれば心温まる優しいメッセージを作ることができるでしょう。

ネストした例外を見逃さない

例外はネストしている可能性があります。例えば、以下のコードでは例外(Inner)が別の例外(Wrapper)にラップされています。

e.g. Exception Message & StackTrace @Java
public void check() throws Exception {
    try {
        throwWrapperException();
    } catch (ExampleWrapperException e) {
        log(e);
    }
}

protected void throwWrapperException() {
    try {
        throwInnerException();
    } catch (ExampleInnerException e) {
        throw new ExampleWrapperException("Wrapper Exception", e);
    }
}

protected void throwInnerException() {
    throw new ExampleInnerException("Inner Exception");
}

"Inner Exception" が発生して、それを途中で catch して "Wrapper Exception" に詰め替えてまた例外を発生させています。 すると、例外情報を以下のようになります。(一部省略)

e.g. Exception Message & StackTrace @Log
java.lang.ExampleWrapperException: Wrapper Exception
    at ...FooLogic.throwWrapperException(FooLogic.java:54)
    at ...FooLogic.check(FooLogic.java:44)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    ...
Caused by: java.lang.ExampleInnerException: Inner Exception
    at ...FooLogic.throwInnerException(FooLogic.java:59)
    at ...FooLogic.throwWrapperException(FooLogic.java:52)
    ... 20 more

まず一番上には、いつも通りの例外情報が表示され、スタックトレースの途中でネストした例外、ここでは "Inner Exception" が表示されます。これは何層にも渡る可能性があります。

なぜこのように例外をラップするのか? 末端の処理におけるその例外の原因と、それを呼び出した側の処理におけるその例外の原因のニュアンスが一致するとは限りません。 そのままラップせずに例外を素通り(throw)してしまうと、最終的に原因がよくわからない可能性もあるのです。 呼び出し側で例外をしっかりラップして、ラップ例外のメッセージの方に呼び出し側でしか知り得ない情報を載せておくことで、 より正確な原因を通知することができるのです。(これを "例外の翻訳" と呼びます)

例えば、DBFluteでは、MySQLのJDBCドライバが発生させる例外をラップして、DBFluteの例外に翻訳しています。 このようにすることで、DBFluteでしか知り得ない情報をメッセージに載せることができ、より迅速な原因究明ができるようにしています。

とはいえ、ラップ例外のメッセージだけで原因が完全にわかるわけではありません。 ラップする方の例外のメッセージは、そのメッセージ次第ですがより抽象度が高い内容になるものです。 "詳しくはラップされた例外のメッセージを見てください" というニュアンスを含んでると考えていいです。 必ず、ネストされた(ラップされた)例外のメッセージも読んだ上で原因を考えましょう。

デバッガー起動前に読みましょう

例外メッセージを正しく読めれば、かつ、例外メッセージが親切であれば、それだけで原因が判明することが多いです。 それを読まずにいきなり(あてもなく)デバッガーを起動してデバッグし始めるのは時間がもったいないと考えられます。

行番号を表示しましょう

例えば Eclipse のエディターは、デフォルトで行番号が表示されていません。 いくら行番号がわかっても、エディター上で行番号がわからなければ意味がありません。 ワークスペースの設定 "Window - Preferences" の "General - Editors - Text Editors" にて、行番号を表示するチェックがありますので、行番号が表示されていない場合はぜひ表示しましょう。

NullPointerException

Javaでは避けられない事案です。

NullPointerの読み解き方

値の入っていない(null状態の)変数に対して、メソッド呼び出しなどを行うと NullPointerException は発生します。Java自体がこの例外をthrowします。

e.g. Basic NullPointerException @Java
(53行目) String nullStr = null;
(54行目) int len = nullStr.length(); // NullPointerException here

この場合の例外情報は以下のようになります。

e.g. StackTrace of NullPointerException @Java
java.lang.NullPointerException
    at ...FooLogic.handle(FooLogic.java:54)
    at ...FooAction.execute(FooAction.java:23)

この例外からわかることは、NullPointerExceptionが発生したということ、そして、FooLogicの54行目、handle()メソッド内にて発生したということ。 54行目を見たときに、nullに対してメソッド呼び出ししている可能性のある変数は、nullStr です。

特定できないNullPointer

しかしながら、以下のようなコードだと、何が null なのかが特定できません。

e.g. Basic NullPointerException @Java
(54行目) check(member.getMemberStatus().getDisplayOrder());

54行目でNullPointerExceptionが発生したとして、nullの可能性があるのは二つ。 member変数がnull、もしくは、getMemberStatus()の戻り値がnull。例外情報からは特定ができません。 前後関係や可能性を推測して、"memberよりもgetMemberStatus()がnullの方があり得るなぁ" と突き詰めていくことになります。

そのようにならないためにも、getMemberStatus()の戻り値を一旦変数に置いて、NullPointerException の可能性を常に一行につき一つにしておくのもプログラミングのお作法の一つではあります。 ただ、割り切ってそのようには書かないことも多いのも事実です。

ちなみに、getDisplayOrder()の戻り値がnullかどうかは無関係です。 check()メソッドの引数にその戻り値を指定しているわけですが、メソッドの引数がnullでも別に問題はありません。 check()メソッドがnullを受け付けないにしても、それはそれでcheck()メソッド内でNullPointerExceptionが発生するはずです。

ラッパー型変換NullPointer

nullの変数に対するメソッド呼び出し以外にも、NullPointerException を発生させる要因があります。 nullのInteger型のラッパー型の変数に対して足し算や掛け算を行うと発生します。

e.g. Wrapper Type Null Pointer @Java
Integer count = null;
Integer plusOne = count + 1; // null pointer

NullPointerExceptionが発生して行番号を確かめてみて変数に "." ドットとしている箇所が見当たらなくても、こういうパターンも想定されるので注意です。

equals()と "=="

Javaが誕生以来、なかなか無くならないバグです。

基本的な違い

簡単に説明すると以下のような違いがあります。

equals()
各オブジェクトルールに従った比較
"=="
インスタンスが同じかどうかの比較

equals()メソッドは、Objectクラスに定義された全てのクラスに存在するメソッドです。 それぞれのクラスはこのメソッドをオーバーライドして、そのクラスに適した等値判定を行えるようにします。

"==" は、そのクラスのルールがなんであれ、とにかくその二つのオブジェクトのインスタンスが同じかどうかを比較します。 全く同じ値を持ったオブジェクトだとしてもインスタンスが違えば false です。

e.g. equals() and "==" @Java
if (new File("/tmp/memo.txt").equals(new File("/tmp/memo.txt"))) { // true
}

if (new File("/tmp/memo.txt") == new File("/tmp/memo.txt")) { // false
}

File file1 = new File("/tmp/memo.txt");
File file2 = file1;
if (file1 == file2) { // true
}

Integerのキャッシュに注意

Integerクラスでは、さらにややこしくなります。 Integer.valueOf()メソッドは、内部である程度キャッシュしているIntegerインスタンスを戻します。 1, 2, 3 など小さな値は、よく利用される数値として常に同じインスタンスが戻ってきます。

e.g. equals() and "==" of Integer @Java
if (Integer.valueOf(2) == Integer.valueOf(2)) { // true
}

if (Integer.valueOf(1000) == Integer.valueOf(1000)) { // false
}

小さな値の時はうまく動いていても、値が大きくなったら動かなくなるというとても危険なコードです。 キャッシュされたIntegerかどうかは意識せずに、一律 equals() を使うようにした方が良いでしょう。

どういう風に区別していく?

インスタンスが同じかどうかを業務的に判定する場面はほとんどありません。Integerのインスタンスが同じか違うかを意識することに業務的な意味はほぼありません。 つまり、"==" を使う場面はかなり限られるでしょう。ただ、intなどのプリミティブ型に関しては、そもそもオブジェクトではないため equals() が利用できません。強制的に "==" を使っていくことになります。

また、ENUMに関しては、もともとそれぞれの要素が絶対にシングルトン(インスタンスが一つしかない)であるため、"==" を使っても大丈夫だったりします。その方が見やすいということで実際に "==" で結ばれているコードは多く見かけます。 また、間違った型違いのインスタンスと比較しようとしたときにコンパイルエラーになってくれるのとミスも防げます。 しかしながら、ENUMだから "==" でOK、他のオブジェクトは equals() という区別を常に意識するのも思ったよりも大変で、"== やっちゃいけないところでやっちゃった事件" はなかなかなくなりません。 なので、とにかく "." (ドット) で補完して equals() が出てこなかったら (つまりプリミティブ型だったら) "==" を初めて使うというようなルールでも問題ないかと思います。 ("==" によるトラブルの方が潜在化しやすいやっかいなバグになりやすいので、そちらの方が防ぎたいという考え方)

プリミティブ型とラッパー型

Javaは完全なオブジェクト指向言語とは言えない要素があります。その一つが、プリミティブ型と言われる型の存在です。 int型やlong型など、従来の手続き型のような "値" だけを表す型です。 これらプリミティブ型には、振舞い(メソッド)がありません。単なる値でしかないのです。 equals()メソッドもありませんし、toString()もありません。Object型を継承しているわけではありません。 オブジェクトではないので null も入りません。

e.g. Primitive Type @Java
int count = 0;
++count;
if (count == 1) {
}
count.xxx // you cannot call its method
count = null; // you cannot set null

それではオブジェクト指向のようなメリットを得られないので、その "値" をラップしたクラスがそれぞれ用意されています。 例えば、int型であれば java.lang.Integer です。

byte
java.lang.Byte
short
java.lang.Short
int
java.lang.Integer
long
java.lang.Long
float
java.lang.Float
double
java.lang.Double
boolean
java.lang.Boolean
char
java.lang.Character
e.g. Wrapper Type @Java
Integer count = Integer.valueOf(0);
++count;
if (count.equals(1)) {
}
String countStr = count.toString() // you can call
count = null; // you can set null
count.toString(); // you can get NullPointerException

Java5からオートボクシングが導入され、プリミティブ型とラッパー型の変換は自動で行われるようになり、 あまり意識せずに実装できるようにはなりました。

e.g. Wrapper Type @Java
Integer count = 0; // you can set int value
if (count == 1) { // you can use "=="
}

ただ、これも単に変換がやりやすくなったというだけで、プログラム上にプリミティブ型とラッパー型が混在していることには変わりはありません。 Javaのプログラミングにおいては、どうしてもこの区別をつけた上で実装する必要があります。

プリミティブ型のケース

オブジェクト指向言語としてのメリットを享受するなら、もうプリミティブ型を使わなくてもいいのでは?と思われるかもしれません。 確かに一理ありますが、実際の現場ではなかなかそうなっていないのが現状です。自分が実装する時も、ソースを読む時もそれを踏まえておく必要があります。

nullを防ぐ型として

プリミティブ型でもラッパー型でもどっちでもいいような場合、かつ、nullが絶対に入らない、nullが入って欲しくないような場合に、 プリミティブ型を使うことがあります。for文のカウンターとしてのint型が主な例です。0から数えていくので、nullが入る必要がありません。

ただ、他のオブジェクトは全て null が入る可能性があるのに、数値や判定だけそこに対して神経質になるのもちょっとバランスが悪いとも考えられます。 なので、どこまで厳密にこのきっかけでプリミティブ型を使っていくかは曖昧です。

ただ、その中でもboolean型だけは厳密に近いものと言えます。どちらかというと、boolean型はプリミティブ型の方が多く利用されます。 trueかfalseかの二択を表現するものであることから "nullはどっち!?" という紛れが入るととても紛らわしくなります。 逆にBoolean型が利用されていると、"これは null が入るからあえてラッパー型を使っているのかな!?" と読み手では想像してしまうくらいです。

メモリ節約として

利用されるメモリ領域は、当然のことながらプリミティブ型の方が少ないです。ほとんどのプログラムにおいて、そこまで神経質になる必要はないでしょう。 ただ、高負荷が想定されるフレームワークの内部処理などにおいて、局所的にこのきっかけでプリミティブ型の利用を検討することがあります。

例えば、String型のsubstring()の引数や、indexOf()の戻り値などは全てint型です。 String型のような基本的なAPIは、まるで息を吸うかのごとく数多くの場所で利用されることが想定されます。 substring()は、nullを受け付ける意味がないのでintという面もありますが、indexOf()は見つからなかった時は -1 が戻ってきます。NotFound は nullで表現しても構わないとも考えられますが、 仮にIntegerを使った場合に比べれば、int型を使うことによって無駄なインスタンスの生成は抑制されているでしょう

これは、"配列を使うのか?リスト型を使うのか?" という話にも関連します。

APIの都合次第で

プリミティブ型を使うきっかけがなくても、利用するAPIの受け取る型がプリミティブ型である場合は、 こちらの処理の中でも最初からプリミティブ型で取り扱うことがあります。 無駄な変換が発生させないようにする、という面もありますが、そこまで深く考えずAPIに合わせるという面もあります。 APIの方では、nullを防ぐとか、メモリ節約とか、色々ときっかけがあったのかも知れません。

マジックナンバー注意

逆にプリミティブ型である必要がないのにプリミティブ型になっている場合に注意です。 値が存在しない、処理ができなかった、数値を判断できなかった場合など、素直に null で表現してもいいのでは!? と思うような場面で、"-1" や "999999999" などのあり得ない値を null 代わりに利用してしまうと、 思わぬ誤動作を生む可能性もあります。

例えば、その数値が別の数値と計算されるような場合、そのあり得ない値も含めて計算されてしまい、 エラーで落ちずにあたかも正常に動作したかのように間違った結果を生んでしまいます。何が一番怖いかって、その間違いに気付かないことが怖いです。 null の Integer に対する足し算や掛け算であれば NullPointerException が発生するので間違いに気付くことができます。

StringのindexOf()の戻り値は -1 で見つからなかったことを表現していますが、 作った人に聞いたわけではないので確かにではありませんが、 計算に利用されるような値ではないという想定と、メモリ節約の考慮を優先してint型になっていると想像できます。