バイナリストリームと例外のスロー

コンピュータとデータ

コンピュータ上のデータは、全て 0 と 1 の二種類の値の組み合わせで表現されている。例えば、"Hello, world!" という文字列は、次のような値の組み合わせで表現できる。

01001000011001010110110001101100
01101111001011000010000001110111
01101111011100100110110001100100
00100001

テキストエディタやブラウザなどのテキストを表示できるプログラムでは、このデータの組み合わせを読み取り、"Hello, world!" という文字列であると解釈して、画面上に "Hello, world!" という文字列をプリントしている。

この、0 と 1 を表しているデータ一つ分を bit (binary digit) と呼ぶ。

ただし、全てのデータを 0 と 1 だけで表示すると、上記のようにとても長くなってしまい扱いにくい。普通のプログラムでは、bit 8 個分をまとめて一つのかたまりとして扱うことが多い。この 8 bit のかたまりを byte と呼ぶ。

ビットの表現では 0 と 1 のみを扱うことができ、そのようなデータは 2進数で扱うことが多い。例えば、'H' という文字は 01001000 という1バイトで表され、これを2進数として読むと、72 となる。バイトは 8 ビットをまとめたものであるので、28種類のデータを扱うことができる。これは、16進数 (0,1,..,9,a,b,..,f) 2文字で扱うことが多い (ca, 1d, fe など)。

例えば、先ほどの "Hello, world!" という文字列はバイトごとにあらわすと次のようになっている。

文字 ビット列 (2進数) バイト (16進数)
'H' 01001000 48
'e' 01100101 65
'l' 01101100 6c
'l' 01101100 6c
'o' 01101111 6f
',' 00101100 2c
' ' 00100000 20
'w' 01110111 77
'o' 01101111 6f
'r' 01110010 72
'l' 01101100 6c
'd' 01100100 64
'!' 00100001 21

通常のテキストファイルやインターネット上の HTML と呼ばれるウェブページを表現しているデータは、テキストに変換できるデータの列だけで表されているため、テキストデータ (文字のみで構成されているデータ) と呼ばれ、テキストに変換できないデータ列で表されているデータのことを バイナリデータ (binary = 2値の) と呼ばれる。今回は、このバイナリデータを扱うプログラムについて解説する。

バイナリデータの例としては、クラスファイルや .exe ファイルなどの実行可能ファイルなどある。

テキストエディタとバイナリエディタ

テキストデータを表示するには、例えば Windows ならば notepad (メモ帳) というプログラムが標準でインストールされている (スタート>全てのプログラム>アクセサリ>メモ帳)。

このプログラムを開いて、例えば簡単なプログラムのクラスファイルを開くと、次のように表示される。


クラスファイルはテキストデータではないため、テキストエディタでは開けない。バイナリデータを表示するプログラムは Windows には簡単なものが用意されていないため、単純なバイナリエディタを用意した。

上記ファイルをダウンロードして、ダブルクリックするとウィンドウが開かれる。メニューにある File から Open を選ぶと、ファイル選択画面になるため、そこから先ほどのクラスファイルを開く。


この画面の見方は、画面上に表示されているセル一つ一つが 1 バイトのデータを 2 桁の 16 進数で表していて、行の左にある16進数の値と列の上端にある16進数を足した値がそのデータの位置である。データの位置とは、ファイルの先頭から何バイト目にそのデータが存在しているかということを表す。

また、このプログラムは全て Java で書かれている。Java でもこのようにバイナリデータを扱うことができる。

テキストファイルも bit の列からなっているため、バイナリエディタでも閲覧することができる。「Hello, world!」とだけ書いたテキストファイルを開いてみると、次のように表示される。


バイナリファイルの作成

ファイルにバイナリデータを書き込む場合、java.io.FileOutputStreamというクラスを使う。用意されているメソッドやコンストラクタが java.io.FileWriter と似ているため、すぐに使えるはずである。ただし、FileWriter はテキストデータを扱っていたのに対し、FileOutputStream はバイナリデータを扱う

例えば、8バイトのデータ列「12 34 56 78 9a bc de f0」をファイル U:\binary.dat に書き込む場合、次のようなプログラムを書けばよい。

package j2.lesson09.example;

import java.io.FileOutputStream;
import java.io.IOException;

public class BinaryOutput {
    public static void main(String[] args) throws IOException {
        // FileOutputStream でファイルを開く (FileWriter の時と同じように指定)
        FileOutputStream out = new FileOutputStream("U:/binary.dat");
        try {
            // 順番にバイナリデータを書き込む
            out.write(0x12);
            out.write(0x34);
            out.write(0x56);
            out.write(0x78);
            out.write(0x9a);
            out.write(0xbc);
            out.write(0xde);
            out.write(0xf0);
        }
        finally {
            // try-finally で確実にファイルを閉じる (FileWriter の時と同じ)
            out.close();
        }
        
    }
}

Java では、int 型の定数を書く際に先頭に 0x とつけると、その値は 16 進数として解釈される。上記の write を 10 進数表記で次のように書いてもかまわない。

out.write(18);  // 0x12
out.write(52);  // 0x34
out.write(86);  // 0x56
out.write(120); // 0x78
out.write(154); // 0x9a
out.write(188); // 0xbc
out.write(222); // 0xde
out.write(240); // 0xf0

書き出したファイルをバイナリエディタで閲覧すると、次のようになっている。


ファイルの先頭から順に「12 34 56 78 9a bc de f0」というデータが作成されていることが確認できる。

ストリーム

FileOutputStream の「Stream」というのは、「データの流れ」という意味を持っている。FileOutputStream というのは「ファイルに出力するためのデータの流れ」と訳すことができる。


Java でファイルの入出力を扱う場合、このストリームという考え方を用いることが多い。FileOutputStream を用いると、ファイルとプログラムの間にストリームが作成され、このストリームに対して write メソッドを呼び出すことにより、ファイルの先頭から順にバイナリデータを書き込むことができる。

このようなバイナリデータを書き込むことができるストリームを バイナリストリーム と呼び、FileReader や FileWriter のように文字を読み書きできるストリームのことを キャラクタ(文字)ストリーム と呼ぶ。

バイナリファイルの読み込み

ファイルからバイナリデータを読み込む場合、java.io.FileInputStreamというクラスを使う。用意されているメソッドのほとんどが java.io.FileReader と似ているため、すぐに使えるはずである。ただし、FileReader はテキストデータを扱っていたのに対し、FileInputStream はバイナリデータを扱う

先ほど作成したファイル U:\binary.dat を読み込んで表示するには、次のようなプログラムを書けばよい。

package j2.lesson09.example;

import java.io.FileInputStream;
import java.io.IOException;

public class BinaryInput {
    public static void main(String[] args) throws IOException {
        // FileInputStream でファイルを開く (FileReader の時と同じように指定)
        FileInputStream in = new FileInputStream("U:/binary.dat");
        try {
            while (true) {
                // 1 バイトのデータを読み込む
                int b = in.read();
                
                // FileReader 同様、ファイルの終端では -1 が返る
                if (b == -1) {
                    break;
                }
                
                // print (bを16進表記したもの) + " "
                // Integer.toHexString(int) で16進表記に変換できる
                System.out.print(Integer.toHexString(b) + " ");
            }
        }
        finally {
            in.close();
        }
    }
}

このプログラムを実行すると、次のように表示される。

12 34 56 78 9a bc de f0 

バイナリエディタで表示した内容と同じになった。


FileInputStream は「ファイルから入力を読み込むためのデータの流れ」である。このクラスを用いると、ファイルとプログラムの間にストリームが作成され、このストリームに対して read メソッドを呼び出すことにより、ファイルの先頭から順にバイナリデータを読み込むことができる。


FileReader と同様に、FileInputStream では開こうとしたファイルが見つからなかった場合に java.io.FileNotFoundException が発生する。ファイルが見つからなかった場合に適切な処理を記述したい場合は、下記のように FileInputStream を new する際に try-catch で囲んでやればよい。

FileInputStream in;
try {
    in = new FileInputStream("U:/binary.dat");
}
catch (FileNotFoundException e) { // import しておく
    // ファイルが見つからなかった場合の処理
}

java.io.InputStream と java.io.OutputStream

java.io.FileInputStreamjava.io.FileOutputStream の親クラスに、java.io.InputStreamjava.io.OutputStream というクラスがある。

FileInputStream と FileOutputStream は、「プログラムとファイルの間にデータの流れを作る」ためのクラスであった。その親クラスの InputStream と OutputStream は「プログラムと何かの間にデータの流れを作る」というクラスである。ここでは、「何か」というものを抽象的なものにしておくことによって、データを読み書きする先の装置 (= 何か) を気にすることなく、ストリームに対するプログラムのみで様々な装置を扱うことができる。


この「何か」というものは、例えば次のようなものが挙げられる。

FileInputStream や FileOutputStream で使用していたメソッドのほとんど (read/write, close など) は、その親クラスの InputStream や OutputStream でも定義されている。そのため、ストリームの一般的な機能のみを扱う場合は、FileInputStream や FileOutputStream として扱うのではなく、InputStream や OutputStream として扱ってもよい。

// FileInputStream のインスタンスを、単に InputStream として扱う
InputStream is = new FileInputStream("U:/binary.dat");
.. = is.read();

// FileOutputStream のインスタンスを、単に OutputStream として扱う
OutputStream os = new FileOutputStream("U:/binary.dat");
os.write(..);

文字の入出力

まず、バイナリストリームとキャラクタストリームを接続する方法を紹介する。コンピュータで扱うデータは、全て 0 と 1 で表されたデータだということを解説した。テキストを扱うプログラムでは、この 0 と 1 のデータを何らかの形で文字に変換して処理を行っている。

Java では、java.io.InputStreamReaderjava.io.OutputStreamWriter クラスを用いるとバイナリデータをテキストデータとして読むことができるようになる (そもそもテキストデータとして扱えないデータはそのようなことはできない)。


まず、InputStreamReader は、他のバイナリストリーム (InputStream) と接続し、読み込んだバイナリデータをテキストデータに変換して、キャラクタストリーム (Reader) として振る舞う。つまり、このクラスを使うと、バイナリストリーム上のデータをテキストデータとして読むことができるようになる

例えば、U:\hello2.txt というファイルを作成し、次のようなテキストを書き込んだとする。

Hello, world!
こんにちは、世界!

このテキストをプログラムから読み込むには、これまでは FileReader を使っていた。これを FileInputStream と InputStreamReader の組み合わせでプログラムを書くと下記のようになる。

package j2.lesson09.example;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

public class CharacterInputStream {
    public static void main(String[] args) throws IOException {
        FileInputStream in = new FileInputStream("U:/hello2.txt");
        try {
            InputStreamReader reader = new InputStreamReader(in);
            while (true) {
                int c = reader.read();
                // Reader のサブクラスも -1 でストリームの終端
                if (c == -1) {
                    break;
                }
                // キャラクタストリームに変換したので、char 型に変換できる
                System.out.print((char) c);
            }
        }
        finally {
            in.close();
        }
    }
}

このプログラムを実行すると、次のように U:\hello2.txt の内容が表示される。

Hello, world!
こんにちは、世界!

このプログラムで InputStreamReader を用いないで、FileInputStream から読み込んだバイナリを直接 char 型に変換して表示した場合を考える。

package j2.lesson09.example;

import java.io.FileInputStream;
import java.io.IOException;

public class CharacterInputStream2 {
    public static void main(String[] args) throws IOException {
        FileInputStream in = new FileInputStream("U:/hello2.txt");
        try {
            while (true) {
                int c = in.read();
                if (c == -1) {
                    break;
                }
                System.out.print((char) c);
            }
        }
        finally {
            in.close();
        }
    }
}

上記のプログラムを実行すると、次のように表示されてしまう。

Hello, world!
?±?????????A?¢?E?I

アルファベットの部分は問題なく表示されているが、「こんにちは、世界!」の部分が期待した値と異なる。英数字の種類は数が限られていて 0~255 の値、つまり 1 バイトで表現できるが、日本語はひらがな, カタカナ, 漢字と全て合わせて 30000 種類以上が存在する。そのために 1 バイトで表現できなくなってしまい、1 バイトずつ読み込んでそれを文字として扱った場合に正しく日本語に変換することができず、上記のように表示されてしまう。

FileReader ではなく、FileInputStream と InputStreamReader の組み合わせによって文字を読み込む利点は、対象のストリームがファイルから他の装置に変わった場合にもプログラムの修正が容易であるという点である。例えば、読込先の装置が現在はファイル (記憶デバイス) であるが、これがインターネット上のリソースなどに変わっても、プログラムをすぐに変更できる。

また、OutputStreamWriter は、キャラクタストリーム (Writer) として振る舞うが、他のバイナリストリーム (OutputStream) と接続すると、書き込んだテキストデータをバイナリデータに変換して、そのデータを接続されたバイナリストリームに write してくれる。つまり、このクラスを使うと、テキストデータをバイナリストリームに書き込むことができるようになる

package j2.lesson09.example;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;

public class CharacterOutputStream {
    public static void main(String[] args) throws IOException {
        FileOutputStream out = new FileOutputStream("U:/hello3.txt");
        try {
            OutputStreamWriter writer = new OutputStreamWriter(out);
            writer.write("Hello, world!\n");
            writer.write("こんにちは、世界!\n");
            // ファイルを閉じる前に、ストリームの中身を全てファイルに書き出す
            writer.flush();
        }
        finally {
            out.close();
        }
    }
}

このうち、writer.flush() というメソッドは、ストリーム上に溜まっていて実際にはまだファイルに書き込んでいないデータを、強制的にファイルに書き出すことができる。プログラムを書いていてストリームを接続する場合、最後に flush() メソッドを呼び出すように心がけるとよい。

上記のプログラムを実行すると、 U:\hello3.txt に次の内容のテキストファイルが作成される。

Hello, world!
こんにちは、世界!

コンソール入出力

次に、コンソール入出力をストリームとして扱う方法を紹介する。

これまでにも、InputStreamReader を使ってコンソール入力を文字列に変換し、BufferedReader を使って文字列を一行ずつ読む例は何度も紹介してきた。

その際に必ず出てきた「System.in」という System クラスのクラスフィールド in は標準入力と呼ばれ、通常はコンソール入力 (キーボード) と接続されている。また、コンソールに表示するための「System.out.println」は、System クラスのクラスフィールド out を介して実現している。この out は標準出力と呼ばれ、通常はコンソール出力 (ディスプレイ) と接続されている。


コンソール入出力を InputStream と OutputStream として扱った場合、次のようなプログラムを書くことができる。

package j2.lesson09.example;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

public class ConsoleEcho {
    public static void main(String[] args) throws IOException {
        // 標準入力をテキストストリームに変換
        BufferedReader r = new BufferedReader(new InputStreamReader(System.in));
        // 標準出力をテキストストリームに変換
        BufferedWriter w = new BufferedWriter(new OutputStreamWriter(System.out));
        
        while (true) {
            String line = r.readLine();
            if (line == null || line.equals(".")) {
                break;
            }
            w.write(line);
            w.newLine();
            // ストリームの途中にあるデータをコンソールに強制的に書き出す
            w.flush();
        }
        
        // コンソールは close()によって閉じる必要はない
    }
}

InputStreamReader と、OutputStreamWriter はそれぞれ ReaderWriter のサブクラスである。そのため、そのインスタンスを BufferedReaderBufferedWriter に接続することによってストリームに readLine() メソッドや newLine() メソッドをそれぞれ提供できる。

一行ごとに flush() メソッドを呼び出しているのは、BufferedWriter は write されたテキストデータをバッファに一度保存して、一度にデータを書き込もうとするため、flush で強制的にコンソールに書き込んでいる (表示している) ためである。

このプログラムを実行してコンソールに文字列を入力すると、一行ごとに同じ内容が繰り返し表示される。

コンソール入力の例
コンソール入力の例
同じ内容がエコーされる。
同じ内容がエコーされる。
.

なお、コンソール入出力に関しては close() によって閉じる必要はない。プログラム内で明示的に開いたストリームだけ処理すればよく、コンソール入出力は OS や Java の実行環境自体が開いてくれている。

インターネット上のデータ

インターネット上にあるデータ (ウェブページなど) も、ストリームを介して取得することができる。

例えば、http://example.com/ にあるページを取得したい場合、次のように java.net.URL というクラスのインスタンスをアドレスを指定して作成し、そのインスタンスに対して getInputStream() というメソッドを呼び出すことにより、対象ページが持つデータを取得できる InputStream が作成され、対象ページが持つデータを取得することができる。

package j2.lesson09.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;

public class HttpInput {
    public static void main(String[] args) throws IOException {
        // http://example.com/ のURLを指定
        URL url = new URL("http://example.com/");
        // そのデータを取得できる InputStream を作成する
        InputStream in = url.openStream();
        try {
            // ウェブページはテキストデータであるため、キャラクタストリームに変換
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
            while (true) {
                // ウェブページから一行読む
                String line = reader.readLine();
                if (line == null) {
                    break;
                }
                System.out.println(line);
            }
        }
        finally {
            // ストリームは必ず閉じる
            in.close();
        }
    }
}

上記のプログラムを実行すると、次のように表示される。

<HTML>
<HEAD>
  <TITLE>Example Web Page</TITLE>
</HEAD> 
<body>  
<p>You have reached this web page by typing &quot;example.com&quot;,
&quot;example.net&quot;,
  or &quot;example.org&quot; into your web browser.</p>
<p>These domain names are reserved for use in documentation and are not available 
  for registration. See <a href="http://www.rfc-editor.org/rfc/rfc2606.txt">RFC 
  2606</a>, Section 3.</p>
</BODY>
</HTML>

また、以下のように指定していたURLを

URL url = new URL("http://example.com/");

次のように file:/// から始まる URL に書き換えると、ファイルシステム上のファイル (通常のファイル) 内のデータを読み出すこともできる。

URL url = new URL("file:///U:/hello.txt");

http:// から始まる URL を指定すると getInputStream() メソッドによりインターネット上のデータを取得するための InputStream が作成され、file:/// から始まる URL を指定すると通常のファイルのデータを取得するための InputStream が作成される。

このプログラムで重要なことは URL によって読み出し先の装置が異なるが、どのように設定しても java.io.InputStream の子クラスのインスタンスが返されるということである。


プログラムを書く側は、ストリームの先にある装置がインターネット上のものでもファイルでも関係なく、単に InputStream として扱うだけで対象の装置からデータを受け取ることができる

バッファ付き入出力

ファイルやインターネット上のデータを操作する場合、read() メソッドや write() メソッドで 1 バイトずつ処理すると極端に性能が落ちる場合がある。このような場合、前回にも触れた「バッファ付き入出力」を使って処理を書くとよい。

バッファ付き入出力は、次の2つのクラスがある。

これらのクラスは、コンストラクタに他の InputStream または OutputStream を指定することで、対象のストリームにバッファを持たせることができる。イメージとしては、対象のストリーム (コンストラクタの引数に指定したもの) に対してバッファつきのストリームを接続させるといったものである。


BufferedInputStream と BufferedOutputStream は、BufferedReader や BufferedWriter のようにストリームに機能を追加することはない。そのため、通常の InputStream や OutputStream を扱うようにメソッドを呼び出せばよい。

下記の例は、先ほどの http://example.com/ 上のデータを読み出す際、データを取得するための InputStream に BufferedInputStream を接続して、ストリームにバッファを付加している。

package j2.lesson09.example;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;

public class BufferedHttpInput {
    public static void main(String[] args) throws IOException {
        URL url = new URL("http://example.com/");
        // そのデータを取得できる InputStream を作成する際に
        // BufferedInputStream を使ってバッファを追加する
        InputStream in = new BufferedInputStream(url.openStream());
        System.out.println(in);
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
            while (true) {
                String line = reader.readLine();
                if (line == null) {
                    break;
                }
                System.out.println(line);
            }
        }
        finally {
            in.close();
        }
    }
}

DataInputStream と DataOutputStream

int 型は -2147483648 ~ 2147483647 の範囲の整数を表すデータ型で、これは通常 4 バイトのデータとして表される。OutputStream や InputStream では、1 バイト単位のデータの入出力を行うメソッドのみ提供されているため、int 型のデータなどを扱う際には少し面倒である。

// 書き込むデータ
int n = 107772156;

// 4 バイトのデータを 1 バイトずつに分割して書き込む
writer.write((n >> 24) & 0xff);
writer.write((n >> 16) & 0xff);
writer.write((n >> 8) & 0xff);
writer.write(n & 0xff);

ここで、>> という演算子は 右ビットシフト演算子 を表す。ビットシフトとは、ビット列 (2進数) で表現された値の各桁をずらす (シフトする) ようなオペレーションのことである。例えば、99 という値は 2 進数で表すと 01100011 であり、これを右方向に 1 ビットシフトすると 00110001 (=49) となる。また、& という演算子は 論理積 (AND) 演算子 を表す。0xff という値は 10進数で 255, 2進数で 11111111 という値を表し、先ほどの int を 4 バイトのデータに分割する計算は次のようになっている。

               n = aaaaaaaabbbbbbbbccccccccdddddddd (2進数)

         n >> 24 = ????????????????????????aaaaaaaa
(n >> 24) & 0xff = 000000000000000000000000aaaaaaaa

         n >> 16 = ????????????????aaaaaaaabbbbbbbb
(n >> 16) & 0xff = 000000000000000000000000bbbbbbbb

          n >> 8 = ????????aaaaaaaabbbbbbbbcccccccc
(n >> 16) & 0xff = 000000000000000000000000cccccccc

        n & 0xff = 000000000000000000000000dddddddd

int 型のデータを書き込むために、毎回このような作業をするのは面倒である。さらに、double 型も 8 バイトのデータとして表すことができる (IEEE754 で規定されている) が、このデータを 8 バイトのデータに分割するのはさらに大変な作業である。

Java には、バイナリストリーム上で int や double などのプリミティブ型を容易に扱えるようにするために、java.io.DataInputStream クラスと java.io.DataOutputStream クラスが用意されている。

メソッドを簡単に紹介すると、次のようなものがある。

クラス int 型を扱う double 型を扱う String 型を扱う
java.io.DataInputStream readInt() readDouble() readUTF()
java.io.DataOutputStream writeInt(int) writeDouble(double) writeUTF(String)

String 型はプリミティブ型ではないが、基本的な型として扱えるようになっている。

例えば、DataOutputStream を用いてさまざまな型のデータをファイルに書き込む場合、次のようなプログラムが書ける。

package j2.lesson09.example;

import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class DataOut {
    public static void main(String[] args) throws IOException {
        // DataOutputStream -> BufferedOutputStream -> FileOutputStream
        DataOutputStream out = new DataOutputStream(
            new BufferedOutputStream(new FileOutputStream("U:/dataout.bin")));
        try {
            // int 型の値を書き込む
            out.writeInt(305419896); // = 0x12345678
            
            // double 型の値を書き込む
            out.writeDouble(Math.PI);
            
            // String 型の値を書き込む
            out.writeUTF("hello");
        }
        finally {
            // ファイルを開いたので finally で閉じる
            out.close();
        }
    }
}

このファイルを実行すると、U:\dataout.bin というファイルが作成される。このデータをバイナリエディタで閲覧してみると、次のようになっている。


最初の 4 バイトは int 型の値 0x12345678 が書き込めていることが確認できる。以降は読むのが大変であるので、実際に書き込んだ値を読み込むプログラムを作成する。この場合は、DataInputStream を用いて先ほどのファイルに対して書き込んだ順序と同じ順序でデータを読み込んでやればよい

package j2.lesson09.example;

import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.FileInputStream;
import java.io.IOException;

public class DataIn {
    public static void main(String[] args) throws IOException {
        // DataInputStream <- BufferedInputStream <- FileInputStream
        DataInputStream in = new DataInputStream(
            new BufferedInputStream(new FileInputStream("U:/dataout.bin")));
        try {
            // int 型のデータを読み込む
            System.out.println(in.readInt());
            
            // double 型のデータを読み込む
            System.out.println(in.readDouble());
            
            // String 型のデータを読み込む
            System.out.println(in.readUTF());
        }
        finally {
            in.close();
        }
    }
}

上記のプログラムを実行すると、先ほどのプログラムで期待した値が書き込めていたことが確認できる。

305419896
3.141592653589793
hello

DataInputStream や DataOutputStream は、他のストリームと接続することによって、接続されたストリームに新しい機能 (readInt など) を加えている。そういう意味では、BufferedInputStream や BufferedOutputStream でも同じ考え方ができて、こちらは接続されたストリームにバッファの機能を加えている。

クラスに機能を追加するには、これまでには主にクラスの継承という方法を用いていた。しかし、上記のように様々なデータ型を扱う機能や、バッファの機能を付け加える作業を継承によって行った場合、次のように様々なクラスを用意しなければならない。

さらに、FileInputStream 以外のストリームを作った場合は大変である。例えば、ウェブ上のデータを取得する HttpInputStream クラスを作った場合、さらに次のようなクラスを用意しなければならない。

それに対し、DataInputStream や BufferedInputStream のように、他のストリームと接続するストリームを作るという考え方ならば上記のような問題は起こらない。今までの例にあったように、上記のクラスのかわりに様々な機能を接続させたストリームを作ればよい。

上記に出現するクラスは、次の 4 種類だけである。

ストリームを作成するときにプログラムが長くなってしまうという欠点はあるが、少ないクラスで様々な機能を提供できるようになる。また、新しい InputStream を追加したとしても、「new DataInputStream(new BufferedInputStream(new HogeInputStream(..)))」のように、すぐに DataInputStream や BufferedInputStream の機能を付け加えることができる。

例外処理 (2)

これまでの例外の取り扱いは、try-catch によって捕捉するか、throws によって呼び出し元に例外処理を任せるかのどちらかであった。これに加えて、例外を自分でスローする (発生させる) 方法と例外の種類を自分で作る方法を紹介する。

例外のスロー

プログラムの任意の場所で例外を発生させたい場合、throw 文により java.lang.Exception のインスタンスをスローさせればよい。

package j2.lesson09.example;

public class Thrower {
    public static void main(String[] args) throws Exception {
        // Exception のインスタンスを作り、それをスロー
        throw new Exception();
    }
}

上記のプログラムを実行した場合、次のように表示される。

Exception in thread "main" java.lang.Exception
     at j2.lesson09.example.Thrower.main(Thrower.java:6)

例外をスローすると、そのメソッドはそこで実行が打ち切られて例外処理を開始する。上記の例では、throws Exception の指定があるため、呼び出し元に例外処理を任せることになる。

throw 文で指定できるインスタンスは、java.lang.Exception 型であればどのようなものでもよい。つまり、java.lang.Exception そのものだけではなく、java.lang.Exception クラスのサブクラスをインスタンス化したものでもよい。例えば、java.io.IOException は Exception クラスの子クラスである。そのため、次のように書けば「入出力例外」を表す IOException をスローさせることができる。

package j2.lesson09.example;

import java.io.IOException;

public class Thrower2 {
    public static void main(String[] args) throws IOException {
        // IOException のインスタンスを作り、それをスロー
        throw new IOException();
    }
}

Exception や IOException はどちらもコンパイラにチェックされる例外であった。そのため、throws にそれぞれの例外を指定している。これに対して、チェックされない例外をスローしたい場合にはjava.lang.RuntimeExceptionやそのサブクラスのインスタンスを throw 文に指定すればよい。

package j2.lesson09.example;

public class Thrower3 {
    public static void main(String[] args) {
        // RuntimeException なので、throws の指定がいらなくなった
        throw new RuntimeException();
    }
}

通常はやらないとは思うが、throw 文自体を try-catch で囲むことにより、自分でスローした例外を自分でキャッチすることもできる。

package j2.lesson09.example;

import java.io.IOException;

public class Thrower4 {
    public static void main(String[] args) {
        try {
            // ほとんどの例外クラスは、引数にメッセージを取れる
            throw new IOException("テスト例外");
        }
        catch (IOException e) {
            System.out.println("caught!");
            // スタックトレース情報を表示する
            e.printStackTrace();
        }
    }
}

上記のように、ほとんどの例外クラスは引数に「例外が発生した際の情報に関する」メッセージを取ることができる。例外をスローする際には、ここに適切なメッセージを付与しておくと分かりやすい。ここに書いたメッセージは、誰も例外を catch しないでプログラムが終了した際や、上記のように printStackTrace メソッドを呼び出した際などに一緒に表示される。

caught!
java.io.IOException: テスト例外
    at j2.lesson09.example.Thrower4.main(Thrower4.java:9)

throw 文で指定できるインスタンスは、正確には java.lang.Exception 型だけではなく、さらにその親であるクラス java.lang.Throwable 型である。しかし、通常は java.lang.Exception のサブクラスを使うほうがよい。

引数チェック

例外をスローする例として、「実引数のチェック」を紹介する。

第16週目演習で、時計を表すクラス Clock というプログラムを作成した。

package j2.lesson03;

public class Clock {
    
    // 時刻の内部表現
    private int time;
    
    // 時刻を設定するコンストラクタ
    public Clock(int hour, int minute, int second) {
        this.time = hour * 3600 + minute * 60 + second;
    }
    
    // 時刻を n 秒進める
    public void tick(int n) {
        this.time += n;
        this.time %= 86400;
    }
    
    // 時刻を表示する
    public void show() {
        // 現在時刻の 時, 分, 秒 を  h, m, s で表すとして 
        // print h + "時" + m + "分" + s + "秒", 改行
        int h = this.time / 3600;
        int m = (this.time / 60) % 60;
        int s = this.time % 60;
        System.out.println(h + "時" + m + "分" + s + "秒");
    }
}

上記のプログラムには問題点がある。例えば、次のようなプログラムを書くことができてしまう。

// -1 時 -2 分 7443 秒
Clock c = new Clock(-1, -2, 7443);
c.show();
// -> 1時2分3秒

現実世界では、「-1 時 -2 分 7443 秒」という表記は普通しない。このような引数は不正な値であるため、何らかの形で例外を発生すべきである。

このように、「引数に不正な値が渡された」ことを表すような例外として、java.lang.IllegalArgumentException という例外クラスがあらかじめ用意されている。メソッドを書く際には、この例外のスローを常に考慮に入れるようにするとよい

この例外クラスを使ってコンストラクタを書き直すと、次のようになる。

public Clock(int hour, int minute, int second) {
    if (hour < 0 || hour >= 24) {
        throw new IllegalArgumentException("hour = " + hour);
    }
    if (minute < 0 || minute >= 60) {
        throw new IllegalArgumentException("minute = " + minute);
    }
    if (second < 0 || second >= 60) {
        throw new IllegalArgumentException("second = " + second);
    }
    
    this.time = hour * 3600 + minute * 60 + second;
}

java.lang.IllegalArgumentException クラスは、RuntimeException の子クラスである。そのため、チェックされない例外として扱われるため throws IllegalArgumentException の指定は必要ない。

このように書き直して、先ほどの「new Clock(-1, -2, 7443)」を行うプログラムを実行すると、今度は次のように例外が表示された。

Exception in thread "main" java.lang.IllegalArgumentException: hour = -1
    at j2.lesson09.example.Clock.<init>(Clock.java:11)
    at j2.lesson09.example.Clock.main(Clock.java:41)

また、同様に tick(int) メソッドも次のように書ける。

public void tick(int n) {
    if (n < 0) {
        throw new IllegalArgumentException("n is negative (" + n + ")");
    }
    this.time += n;
    this.time %= 86400;
}

例外クラスの作成

大きなプログラムを書く場合、例外クラスを自分で用意する場合がある。

自分で例外クラスを作成するのは簡単で、クラス作成時に java.lang.Exception クラス (またはその他の例外クラス) を継承するだけである。

例えば、「不正な入力」を表す例外 IllegalInputException を作成する場合、次のように書けばよい。

package j2.lesson09.example;

public class IllegalInputException extends Exception {
    public IllegalInputException(String message) {
        // 親クラスのコンストラクタにメッセージを渡す
        super(message);
    }
}

上記の例外を使って、「正の値をコンソールから入力させ、その値を表示する」というプログラムを作成する。

package j2.lesson09.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class IllegalInputSample {
    // IllegalInputException と IOException をスローする可能性がある
    public static void main(String[] args) throws IllegalInputException, IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        
        System.out.print("正の値を入力:");
        String input = reader.readLine();
        int n;
        // NumberFormatException を捕捉するための try-catch 
        try {
            n = Integer.parseInt(input);
        }
        catch (NumberFormatException e) {
            // 数値の形式が不正である場合は、入力自体が不正
            throw new IllegalInputException("不正な入力 " + input);
        }
        
        if (n < 0) {
            // 負の値が入力された場合は、不正な入力
            throw new IllegalInputException("不正な入力 " + input);
        }
        
        System.out.println("入力された正の値は" + n);
    }
}

このように、入力が不正であると分かった時点で IllegalInputException のインスタンスを作成し、throw 文によってスローすればよい。

例外の連鎖

先ほどの例では、IllegalInputException クラスにコンストラクタを一つだけ用意していた。

package j2.lesson09.example;

public class IllegalInputException extends Exception {
    public IllegalInputException(String message) {
        // 親クラスのコンストラクタにメッセージを渡す
        super(message);
    }
}

java.lang.Exception はコンストラクタを4つ持ち、その中に java.lang.Throwable という型の引数を取れるものがある。この Throwable というクラスは Exception クラスの親クラスで、このコンストラクタの引数に別の例外インスタンスを渡すと その例外の原因となった例外 という情報を例外インスタンスに与えることができる。

実際に使ってみたほうが分かりやすいと思うので、先ほどの IllegalInputException を次のように書き換える。

package j2.lesson09.example;

public class IllegalInputException extends Exception {

    public IllegalInputException(String message) {
        // 親クラスのコンストラクタにメッセージを渡す
        super(message);
    }
    public IllegalInputException(String message, Throwable cause) {
        // 親クラスのコンストラクタに引数をそのまま渡す
        super(message, cause);
    }
}

そして、先ほどの例 IllegalInputSample クラスでは、入力された値を Integer.parseInt で整数に変換し、その際に発生する NumberFormatException を捕捉して IllegalInputException 型の例外をスローしていた。

try {
    n = Integer.parseInt(input);
}
catch (NumberFormatException e) {
    // 数値の形式が不正である場合は、入力自体が不正
    throw new IllegalInputException("不正な入力 " + input);
}

この IllegalInputException は、入力された値が数値に変換できなかったため結果として不正な入力とみなしている。つまり、この IllegalInputException の原因は NumberFormatException である

そこで、new 演算子によって例外インスタンスを生成する部分を少し書き換える。

package j2.lesson09.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class IllegalInputSample {
    public static void main(String[] args) throws IllegalInputException, IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        
        System.out.print("正の値を入力:");
        String input = reader.readLine();
        int n;
        try {
            n = Integer.parseInt(input);
        }
        catch (NumberFormatException e) {
            // 数値の形式が不正である場合は、入力自体が不正
            // この例外の原因は、「NumberFormatException e」である。
            throw new IllegalInputException("不正な入力 " + input, e);
        }
        
        if (n < 0) {
            throw new IllegalInputException("不正な入力 " + input);
        }
        
        System.out.println("入力された正の値は" + n);
    }
}

このプログラムを実行し、"abc" と入力すると次のように表示される。

正の値を入力:abc
Exception in thread "main" j2.lesson09.example.IllegalInputException: 不正な入力 abc
    at j2.lesson09.example.IllegalInputSample.main(IllegalInputSample.java:20)
Caused by: java.lang.NumberFormatException: For input string: "abc"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:48)
    at java.lang.Integer.parseInt(Integer.java:447)
    at java.lang.Integer.parseInt(Integer.java:497)
    at j2.lesson09.example.IllegalInputSample.main(IllegalInputSample.java:15)

「Caused by」以降に、この例外の元となった NumberFormatException の情報も記載されている。このように例外を別の例外に書き換える際に「原因」となった例外情報を書き換えた例外に保存することを例外のチェインと呼び、例外の原因となった情報を連鎖させてより多くの情報を与えることができるようになる。