開けたら閉めるを自動化する
try-finally を用いる
まずは try-finally を用いた、最も一般的なリソースの閉じ方です。いくつかやり方はあると思いますが、私は null チェックや null 初期化が大嫌いなので、以下のようにやっています。try {
FileReader r = new FileReader("hoge");
try {
process(r); // なんらかの処理
} finally {
r.close();
}
} catch (IOException e) {
handleException(e);
}
コードを追えば分かるんですけど、例外が発生しない場合は当然、例外がどこで発生しても必ず開けられたリソースは閉じられます。null 初期化して try 節を入れ子にしない方法とか、もうちょっと例外束縛を厳密にするとか、色々あります。しかしまぁ、この程度が最も一般的でしょう。開けて閉じるまでを自動化する
とまぁ、ここまでは実に面白みのないお話でした。本題はここからです。先ほどのイディオムって for(int i = 0; i < n; ++i) 並に当たり前のイディオムの癖に、やたらと長くて面倒です。さらに、try 節が入れ子になっているため、さらにその中にブロックを持たせようとすると、非常に読みにくくなります。平たく言えば面倒です。
最近のスクリプト言語では、こういったことを自動化する仕組み、構文が備わっています。有名どころは Ruby のブロック構文というやつですね。ファイルを開き、各行を読み込んで変数に束縛し、ファイル末端までループを廻すといった類のものです。ループを抜ける際にはファイルが閉じられます。私は Ruby のコードを書いたことがないので、Java の擬似コードで紹介します。大体次のようなイメージです。
for (String line : new File("hoge")) {
// line にはファイルの一行が束縛されている
}
// ここに来ると開いたファイルが閉じられている
見るからに便利です。この Ruby のブロック構文は行を受け渡していますが、他にも gauche (schemeの処理系) には入出力ポートを受け渡す関数があります。call-with-input-file, call-with-output-file という関数です。以下のような感じで使います。
(call-with-input-file path
(lambda (input-port)
(define (iter port)
(let ((line (read-line port)))
(if (eof-object? line)
#t
(begin (print line)
(iter port)))))
(iter input-port)))
第一引数に受け取った path の入力ポートを開き、第二引数の関数に渡します。関数の評価が終わった後は入力ポートが閉じられます。今回はこの後者の call-with-* を Java でやってみました。
CallWithInputFile クラス
さっきの関数をそのままクラスにしました。ただし、lambda を渡す部分を抽象メソッドとし、これを継承する実装クラスに実装をゆだねることで、処理の抽象化を実装しています。import java.io.*;
public abstract class CallWithInputFile {
public void with(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
try {
process(reader);
} finally {
reader.close();
}
}
protected abstract void process(BufferedReader reader) throws IOException;
}
例えば、ファイルの内容を標準出力にだらだら出すクラスならば、import java.io.*;
public class FilePrintOuter extends CallWithInputFile {
@Override
protected void process(BufferedReader reader) throws IOException {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
といった感じです。
問題点
まず、BufferedReader 固定というのが問題です。大概の場合はこれで済むとは思うんですが、これはよろしくない。なのでどうにか抽象化します。もう1つ、process が値を返せないのがいやです。このままだと process メソッド内で解析した結果を得るには、一時的にインスタンスフィールドに格納したり、別の外部リソースに突っ込んだりする必要があります。これはいやなのでどうにかします。
CallWithInputFile 改
先の問題点を踏まえて改良しました。
import java.io.*;
public abstract class CallWithInputFile2<V, R extends Reader> {
public V with(String path) throws IOException {
R reader = open(path);
try {
return process(reader);
} finally {
reader.close();
}
}
protected abstract R open(String path) throws IOException;
protected abstract V process(R reader) throws IOException;
}
どの Reader を使うかというのと、何を返すかというのを型パラメータとして渡します。先ほどの FilePrintOuter は以下のように変更されます。import java.io.*;
public class FilePrintOuter2 extends CallWithInputFile2<Void, BufferedReader> {
@Override
protected BufferedReader open(String path) throws IOException {
return new BufferedReader(new FileReader(path));
}
@Override
protected Void process(BufferedReader reader) throws IOException {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
return null;
}
}
型パラメータを指定して継承します。値を返さないことを表す Void を指定しても、return null; の記述がはずせないのはなんとも歯がゆいですが、これは仕様なのでしょうがないです。値が返せると何が嬉しいかと言うと、解析したデータを直接戻り値として渡せる点です。簡単な例で言えば、行数を数える CallWithInputFile などがあります。
import java.io.*;
public class LineCounter extends CallWithInputFile2<Integer, BufferedReader> {
@Override
protected BufferedReader open(String path) throws IOException {
return new BufferedReader(new FileReader(path));
}
@Override
protected Integer process(BufferedReader reader) throws IOException {
int c = 0;
while (reader.readLine() != null) {
++c;
}
return c;
}
}
値を返せなければこのようには行かず、先ほども説明した通り、一旦どこかに保存し、それを使用者に読ませるといった手順を踏まなければなりません。
さらに抽象化する
CallWithInputFile はファイルの入力のみをサポートするものでした。では出力の場合はまた似たようなクラスを作るのでしょうか。それはいやです。なので CallWithInputFile をさらに抽象化したクラス、CallWith クラスを作ります。
CallWith クラスが興味を持つのは、"開いて閉じることができるもの" です。開く操作に関しては open メソッドによって、抽象化されており、CallWith クラスが直接操作するものではありません。CallWith クラスがポートに対して直接操作するのは、close のみです。この辺は抽象度を上げられそうです。
さらに、CallWithInputFile クラスはパスを受け取り、ファイルを開くというものでした。開いて閉じることができるものならば何でもいいわけです。しかしそれが文字列のパスによって導かれるものかどうかは分かりません。
例えば Socket のストリームを読みたい時。当然文字列ではそれを開くことはできません。Socket を元に InputStreamReader を生成しなければなりません。
なのでここも抽象化します。
以下、CallWith クラスです。
import java.io.*;
public abstract class CallWith<V, K, P extends Closeable> {
public V with(K key) throws IOException {
P port = open(key);
try {
return process(port);
} finally {
port.close();
}
}
protected abstract P open(K key) throws IOException;
protected abstract V process(P port) throws IOException;
}
先ほどの LineCounter は次のようになります。import java.io.*;
public class LineCounter2 extends CallWith<Integer, String, BufferedReader> {
@Override
protected BufferedReader open(String path) throws IOException {
return new BufferedReader(new FileReader(path));
}
@Override
protected Integer process(BufferedReader reader) throws IOException {
int c = 0;
while (reader.readLine() != null) {
++c;
}
return c;
}
}
出力系の例として、何らかの OutputStream を受け取り、そこにデータを書き出す PrintWith クラスを書いてみました。
import java.io.*;
public class PrintWith extends CallWith<Void, OutputStream, PrintWriter> {
private final String data;
public PrintWith(String data) {
this.data = data;
}
@Override
protected PrintWriter open(OutputStream output) throws IOException {
return new PrintWriter(output);
}
@Override
protected Void process(PrintWriter writer) throws IOException {
writer.println(data);
return null;
}
}
出力する内容をコンストラクタ引数として受け取らなければならないのがださいですが、これはちょっと悩みました。別案としては、with メソッドおよび process メソッドを可変長引数を受け付けるようにし、出力する内容を一緒に渡すというものが考えられます。
しかしこうすると、こういった引数が必要ないクラスについても Object... みたいな引数を受け取る宣言にしなければならないため、正直うざいです。なので今回はコンストラクタ引数で渡す、インスタンス変数にしました。
残った問題点
先に挙げた問題点の他に、検査例外の処理ができないことが問題点として挙げられます。これについて完璧な解は考え付いていないので、またの機会にということで逃げておきます。しょぼい案としては、呼び出し側がエラーと判断する戻り値を返すなどでしょうか。
まとめ
後半はかなり自己満足、もといネタに走っていますが、総称型で遊ぶのは非常に楽しいです。タイプセーフを保ちながら抽象化することにおいて、非常に簡単かつ強力な仕組みですね。しょぼいと巷で叫ばれる Java の総称型でも、プログラムにかなりの幅を出すことができます。とにもかくにも言いたかったことは、開けたら閉めるを徹底するということです。開けたら閉めましょう。