
やってはいけないJavaプログラミング
|
2.やらないほうがいい
「これまでのポイントを守っていくと、確実にバグの数は減り、デバッグ作業も容易になっていくでしょう。しかし、ソフトウエアに求められるのはそれだけではありません。仕様変更の容易さや開発速度、必ずしも仕様として明文化されていなくても常識的に満たすべき性能などの、ソフトウエア価値の向上を意識しなくてはなりません。
ここでは、柔軟性や開発速度を向上する要素として可読性について、またパフォーマンスについて検討してみたいと思います。
可読性
プログラミングを始めたばかりの頃は、考えなくてはいけないこと、調べたり覚えたりしなくてはならないことが多すぎて、筆者もそうでしたが、なかなか可読性とか保守性なんてことまで気が回らない人がほとんどだと思います。「可読性ってなに? どうして可読性が高くなければいけないの?」という、実は非常に重要なポイントについて説明します。その後、Javaで可読性の高いプログラムを書くコツをご紹介しましょう。
プログラムが、明文化されている仕様を満たすことは最低限のことであり、文書化されていない「暗黙の仕様」というのも当然存在します。たとえば仕様書に「このボタンを200X年X月X日00:00ちょうどに押した場合も、システムを致命的に破壊する危険性はない」と書かれて*いない*からといって、そういう危険性があるソフトウエアにしてしまってはならないのは当然のことですね。それはむしろ「~の場合危険性がある」と書かれていない限りは満たさなければならない、暗黙の裡に定義されている仕様と考えるべきです。
しかし、これが実は難しいところです。暗黙の了解というのは常に誤解の危険を含みつつ成り立っていると言えます。発注者側の前提と開発者側の前提とでは特に大きな隔たりがありますし、開発プロジェクト内の開発者間でも、あるいはそのプログラムを何年か後にメンテナンスする保守者との間でも、常に「暗黙の仕様」を裏切る誤解が起こり得ます。
だからといって、そういった「暗黙の仕様」をすべて明文化された仕様にすることは現実的ではありません。電子レンジの取扱説明書に「犬・猫を乾かすことに使用しないでください。生命に関わる危険があります」と書く必要があるかどうか。次には「じゃあハムスターはいいの?」と云われたら困るから「小動物を乾かすことに使用しないでください」と書く、「洗濯物を乾かすのには使っていいの?」と言われたときのために「金属製のボタンやジッパーがついている衣類を乾かすのには使用しないでください、火災の危険があります」とも書く……。そこまで極端ではなくとも、現実には、作成するコストの面からも普通に読んで役に立つドキュメントにするためにも(注12)、仕様書に記述されていない暗黙の仕様に頼らざるを得ません。
それでは、どのようにしてそういった誤解を避けていけばよいのでしょうか。発注者と開発者というスコープの話は置くとして、プロジェクト内の開発者同士、あるいはプロジェクトの終了後に機能追加などを行う後継開発者との間で、究極的にはソースコードがコミュニケーションの道具になります。ソースコード上での表現力を高める工夫が、多くのバグをなくし、少なくともデバッグの労力を低減してくれます(注13)。
・クラス/メソッドの命名
たとえば、運用時に予想外の例外が発生した原因を調査することを考えます。
リスト18-1
java.lang.NullPointerException at example.ConfigEntry.key(TestApplication.java:37) at example.ConfigMap.add(TestApplication.java:25) at example.Config.init(TestApplication.java:19) at example.TestApplication.init(TestApplication.java:10) at example.TestApplication.main(TestApplication.java:6)
リスト18-2
java.lang.Exception at example.E.proc1(A1.java:37) at example.C.proc(A1.java:25) at example.B.proc1(A1.java:19) at example.A1.proc0(A1.java:10) at example.A1.main(A1.java:6)
リスト18-1とリスト18-2 のどちらが読みやすいスタックトレースでしょうか。前者であればソースコードを開いてみなくとも、「初期化時に設定情報マップを作っている個所で、初期化データのどこかに欠落があったという障害であろうか」とあたりをつけることは容易ですから、設定ファイルをチェックして障害を復旧することも可能です。しかし後者の情報から原因を想像することはほとんど不可能でしょう。そこで誰かの書いたソースコードを開いて見ることになりますが、そこで目にするのはリスト19のようなコードなのです。
リスト19
public class A1 {
static public void main(String args[]) {
A1 a1 = new A1();
a1.proc0();
}
void proc0() {
B x = new B();
x.proc1();
}
}
class B {
C c;
void proc1() {
c = new C();
D n = new D();
c.proc(n.funcA());
}
}
:
:
こんなプログラムをメンテナンスせざるを得ないような目に遭ったとしたら、筆者であれば真剣に転職を検討しはじめることでしょう。
クラスやメソッドの命名は難しいものですが、適切な名前を考える労力を惜しんではいけません。簡単にクラスやメソッドの内容を言い表せるような適切な名前がどうしても思い付かないときは、おそらくクラス設計に問題があります。そのクラスがそもそも何のために必要なのか、本当に必要なのか、そのメソッドは「なにをどうする」メソッドなのかを考え直しましょう。
●コーディング規約
使い捨てのプロトタイプ以外のプログラムは、メンテナンス期間も考えれば、必ず二人以上のプログラマがソースコードを読むことになります(注14)。そのことを考えるとコーディング規約は大変重要です。開くファイル開くファイルがそれぞれにタブ文字を使っていたりいなかったり、インデント幅もまちまちになっていたら、制御構造を追うだけでも一苦労でしょう。
ただ、多くのコーディング規約策定者には、細かいことまで規定しすぎる嫌いがあるように思われます。開発中のプログラマがいろんなアイデアをコードに落とし込んでいく間、規約を守らなくてはという意識が思考を妨げてばかりでは害が大きすぎると言えます。
また、一人前のプログラマはそれぞれにコーディングスタイルを持っており、美しいと感じるインデント幅や{}の位置が異なっているものです。そのため規約は議論の元になったり、開発のあいだ中プログラマの美意識を傷つけ続けたりすることがあります。規約はできるだけゆるくあるべきです。
幸いなことに、Javaでは総本山の米SunMicrosystemsがコーディング規約を発表しています(注15)。多くのJavaプログラマにとって、もっとも読みなれたソースコードであるJDK付属のソースも、この規約に則っています。あなたが規約を策定する立場にあるのならば、これを適用することで論争を避け、抵抗感を低減するのが賢明でしょう。
また、美しくないと思われる規約の下で開発するプログラマであれば、ソースコードの中でこっそり自己主張するのはやめましょう。どんな規約であれ、全体としてみたときには、規約を守っているというただそのことが可読性を向上させます。
●Outパラメータ -- 参照渡し? 値渡し?
参照渡し、値渡し、という言葉を聞いたことがあるでしょうか。これはそれぞれ英語のcall-by-refernceとcall-by-valueの訳語で、メソッド(あるいは関数)呼び出しの際に、実引数が仮引数にどのように渡されるか、を区別する用語です。
Javaにおける引数の渡し方はすべて「値渡し」です。実引数そのものへの参照(注16)が渡されるわけではないので、呼び出されたメソッド側で仮引数の値をどう書き換えようと、呼び出し元で保持している実引数には何の影響もありません。ただし、引数そのものが参照であったとするならば、引数から参照している先への操作はメソッド側でも可能になります。そして、Javaの変数はプリミティブ型を除いては、すべてObjectクラスのインスタンスへの参照です。
リスト20
package example;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;
public class CallByReference {
private static DateFormat df = new SimpleDateFormat("yyyy/MM/dd");
public void callByValueOfReference(Calendar calendar) {
calendar.add(Calendar.YEAR, 1); // [1]
System.out.println("2):" + df.format(calendar.getTime()));
calendar = new GregorianCalendar(); // [2]
calendar.add(Calendar.MONTH, 1);
System.out.println("3):" + df.format(calendar.getTime()));
}
public void testCall() {
Calendar cal = new GregorianCalendar();
System.out.println("1):" + df.format(cal.getTime()));
callByValueOfReference(cal);
System.out.println("4):" + df.format(cal.getTime()));
}
}
上の例はどのような出力を行うでしょうか。testCall()を呼び出すmainメソッドを追加したとすると、以下のような出力になります。
C:\K2\tmp>java -classpath . example.CallByReference 1):2001/12/13 2):2002/12/13 3):2002/01/13 4):2002/12/13
testCall()ではCalendarクラスのインスタンスをnewし、それへの参照をローカル変数calに保持します。続いてcalを実引数としてcallByValueOfReference()を呼び出します。呼び出されたcallByValueOfReference()では、calが保持しているインスタンスへの参照値を仮引数calendarで受けます。callByValueOfReference()内でcalendar.add()する[1]ことで、仮引数calendarと実引数calがともに参照しているインスタンスの内部状態が変更されます。しかし、仮引数calendarに別のインスタンスへの参照を代入[2]しても、実引数であるcalにはなんの影響も与えません。C++などでいうところの参照渡しであった場合、calにもcalendarに代入されたのと同じ新しいインスタンスへの参照が代入されてしまいます。これが参照の値渡しです。
さて、メソッドがOut(In/Out)パラメータを持つことについてですが、これを実現するためには参照渡しが必要になります。たとえば以下のC++のコードはIn/Outパラメータの例です。
リスト21
#include <iostream.h>
int func(int &xref) {
int tmp = 3;
tmp *= xref;
xref *= 2;
return tmp;
}
void main(void) {
int x = 10;
int ret = func(x);
cout << "x:" << x << endl << "ret:" << ret << endl;
}
この例では、func は参照渡しとして宣言された仮引数xrefを関数内部での計算に使った上で、その計算結果としてxref自体の値を変更すると共に別の計算結果を返却しようとしています。このプログラムの実行結果は以下のようになります。
[k2@tech k2]$ ./a.out x:20 ret:30
これと同じ処理をJavaにおいて実現しようとした場合、プリミティブへの参照渡しを実現するためのラッパークラスを導入して以下のように書くことができます。
リスト22
package example;public class OutParameter {
public int func(IntWrapper xref) {
int tmp = 3;
tmp *= xref.i;
xref.i *= 2;
return tmp;
}
public static void main(String args[]) {
OutParameter test = new OutParameter();
IntWrapper x = new IntWrapper(10);
int ret = test.func(x);
System.out.print("x:" + x.i + "\nret" + ret);
} static class IntWrapper {
int i;
IntWrapper(int i) {
this.i = i;
}
}
}
これで処理はC++のものと全く同様にできますが、ご覧の通りにJavaとしてはトリッキーなコードになってしまっています。こうしたコードは書くべきではありません。このメソッドは例のために作ったものなので本来の意図というものはありませんが、実際のプログラムであれば、そのメソッドはなにをするメソッドであるのか、引数と戻り値にはどのような関連性があって、どうしてそれらが組み合わせて扱われているのか、という理由が明らかになっているはずです。そうであれば、その変数のセットに対して適切な名前を付けクラスを定義し、それを戻り値とする方がJava的です。Javaプログラマにとって読みやすいコードはJavaらしいコードです。
パフォーマンス
プログラミング中は、過剰にパフォーマンスのことを意識すべきではありません。パフォーマンスの最適化は、優れた設計やメモリ効率、可読性などとのトレードオフになるからです。全体を結合して動かしてみた上で、パフォーマンス的に問題があるようであれば、それからはじめて取り組んでも遅くはありません。優れた設計を行っていれば、ボトルネックの特定や改善もしやすくなっているはずだからです。コード上でのパフォーマンス最適化は、必ずボトルネックについてのみ実施します(注17)。
ただ、パフォーマンス改善のテクニックについて知っておくことは重要です。いざというときのためのみならず、イディオム的に使いこなせば、可読性を下げることなくパフォーマンスを「悪化させない」ことができるからです。
●String/StringBuffer
いくつかのアプリケーションをプロファイリングしてみると、かなりの確率で、最も多く生成され最も多くの時間を使っているのがStringクラスであることがわかります。設定ファイルの読み込みにはじまり、HTMLの出力、入力の解析やログまで、文字列の操作は、行われる頻度が高い処理です。
ひとつ覚えておかなくてはならないことは、JavaにおけるStringは不変オブジェクトであるということです。不変オブジェクトとは、生成されて以降は値・状態が変化することのないオブジェクトです。
リスト23
String errMsg = "Error:"; errMsg += “Closed connection.";
このエラーメッセージを生成している例は、素直にコードを読み下すと、「errMsgに対して、エラーをあらわすために先頭に"Error:"を代入し、詳細として"Closed connection."というメッセージを繋ぐ」、Stringオブジェクトはひとつだけ生成されて、そこに続きの文字列が追加されていくかのように思えます。しかしこれが大きな間違いなのです。
Stringは不変オブジェクトなので、"Error:"として生まれたStringオブジェクトが、"Error: Closed connection."というオブジェクトに変化することはありません。このコードの中では目に見えるだけでも、"Error:"、"Closed connection."、それと最終的な"Error: Closed connection."という3つのStringオブジェクトが生成されています。さらに、文字列の連結のために内部的にはStringBufferオブジェクトが生成されます。
一般にオブジェクトの生成はコストの高い処理になりますし、生成されたオブジェクトはガベージコレクトされますので、そこにもコストがかかります。パフォーマンスを向上させるコツとして、オブジェクト生成をできるだけ少なく抑えることが挙げられます。この場合であれば、定数として最終的に必要な"Error: Closed connection."を定義することで、変数1つとオブジェクト3つの生成が省略できます(注18)。
これはあまりにも単純過ぎるので、もう少し一般的な例を見てみましょう。
リスト24
String errMsg = "Errors:";
while(errors.hasNext()) {
errMsg += "\t" + errors.next() + "\n";
} この例の場合だと、コンパイルやコーディングに値を決定することができませんが、このままでは実行時にerrorsの件数に比例した数のStringおよびStringBufferのオブジェクトが、中間データとして生成されては削除されることになります。こうした無駄なオブジェクトの生成を削減したいと思います。
リスト25
StringBuffer errBuf =new StringBuffer("Errors:");
while(errors.hasNext()) {
errBuf.append("\t");
errBuf.append(errors.next());
errBuf.append("\n");
}
String errMsg = errBuf.toString(); このようにタイプ量は増えますが、文字列の連結を行う場合はStringBufferを利用する、とイディオムとして覚えておきましょう。Stringを+で連結するのに比べて、場合によっては数百倍もの速度向上が可能になります。ぜひ簡単なテストコードを書いて、実際に速度を比較してみてください。
こうしたチューニングは、繰り返し実行されることがないとわかっている個所ではあえて行う必要はありませんが、ログ出力ライブラリなど、不確定なStringを扱う利用頻度の高いクラスに適したイディオムです。
さらに速度を向上させたい場合は、StringBufferのコンストラクタで最大サイズを指定し、サイズ拡張が行われないようにすることが効果的です。ただし、バッファサイズという余計な定数が増えてしまうので、この手法は実測後のチューニングに取っておいたほうがいいでしょう。
(注12)引き継いだプロジェクトに数冊に渡るようなドキュメントがあれば、「これは助かった」と思います。ところが、時間をかけて調べてみてもなんの役にも立たないことばかり書いてあって、本当に知りたいことはソースを読むしかないというのは、実際によくある話です。そんなドキュメントならいっそ最初からなければ……。
(注13)「このプログラムに機能追加してくれ」「わかりました。それでは仕様書をいただけますか?」「そんなものはないよ。ソースに全部書いてあるだろう、それ見てやってくれ」「……(涙)」という目に遭うプログラマの助けになるように、と思ってください。筆者は記憶力に自信がないので、数ヶ月前に自分が書いたソースコードに隠された意図を思い出すことができません。そのかわいそうなプログラマは、数ヶ月後の自分かもしれません。
(注14)もちろん数ヶ月後の自分も含めて。
(注15)http://java.sun.com/docs/codeconv/html/CodeConvTOC.doc.html
(注16)このあたりでは、特に「参照」あるいは「ポインタ」という用語については議論があります。以下のURLから始まるスレッドなどが参考になるでしょう。ここではこれ以上深入りすることはしません。
http://java-house.jp/ml/archive/j-h-b/028625.html
(注17)80/20ルール:「全体のボリュームのうち20%が、使われる頻度の80%を占める」という一般的な法則があります。この場合、「コード全体の20%を実行している時間が全体の実行時間の80%を占める」ということになりますので、その20%以外をいくらカリカリにチューニングしたとしてもなんの性能向上にもならないということです。
(注18)コンパイラによる最適化で、コンパイル時に決定できる文字列のリテラルはひとつにまとめられることも多いと思われますが、コンパイラの実装依存になりますので、それについてはここでは考慮に入れていません。
