2020年8月23日日曜日

1: 任意のバイト群シーケンスを文字列へデコードする方法

<このシリーズの前の記事 | このシリーズの目次 | このシリーズの次の記事>

'String'クラスや任意の'Reader'クラスは、バイト群シーケンスを文字列へデコードするのに、常には最適でなく、許容可でさえないこともあります。本方法のほうが優れています。

話題


About: Javaプログラミング言語

この記事の目次


開始コンテキスト


  • 読者は、Javaプログラミングの基本的知識を持っている。
  • 読者は、長いかもしれない、そして/またはエラーを含んでいるかもしれないバイト群シーケンスを文字列(文字群配列または文字列インスタンス)へデコードしたいと思っている。

ターゲットコンテキスト



  • 読者は、任意のバイト群シーケンスを文字列へ最適にデコードする方法を理解する。

オリエンテーション


Hypothesizer 7
あるバイト群シーケンスを文字列へデコードする必要がある際、1つの簡便な方法は、シーケンス全体をバイト群配列に格納し、'String'コンストラクタを呼ぶことだ、その配列とエンコーディングを指定して、以下のように。

@Java ソースコード
			String l_inputString = "aϴbΩ";
			String l_encoding = "UTF-8";
			byte [] l_inputBytes = l_inputString.getBytes (l_encoding);
			String	l_outputString = new String (l_inputBytes, l_encoding);

しかしながら、それは、常には最適でなく、許容可でさえないこともある。1つには、そのバイト群シーケンスは長くて、大きなメモリスペースを占有しなければならないかもしれないが、それは、好ましくない、コンピュータが偶然にも無限のメモリスペースを持っているというのでなければ。他にも、そのバイト群シーケンスの長さは予測不能であり、その配列を効率的にアロケートするのが困難かもしれない。他にも、そのシーケンス全体が正常にデコードできるわけではなく、間違っている箇所群を特定する必要であるかもしれないが、それは、'String'コンストラクタはやってくれない。実のところ、主に、私は、ストリーム(典型的には、ファイルのインプットストリーム)を読むことを考えている。

勿論、ある'Reader'(それは、バイト群シーケンスをデコードする)を使用するという方法があるが、私は、その方法も最適でないケースを考えている: 特定の長さのバイト群シーケンスを読む必要がある。どの'Reader'インスタンスを使用しても、読むべき文字群長は指定できるが、読むべきバイト群長は指定できないでしょう?. . . 確かに、読まれた文字群シーケンスを再エンコーディングして、読まれたバイト群長をチェックすることで、読むべきバイト群長を調整はできるが、それは、最適であるとは思われない、なぜなら、その再エンコーディングは不経済だと思われるから: なぜ、既に読まれており('Reader'インスタンスによって内部的に)、それ自体としては我々は欲しくない、元のバイト群シーケンスのコピーを作らなければいけないのか、ただ、長さをチェックするだけのために?

実は、'java.nio.charset.CharsetDecoder'というクラスがあり、それが、私の関心事に使えるようだ。本記事は、それが何であり、それをどのように使用できるかについてである。


本体


1: なぜ、そのバイト群シーケンスを固定長のピース群に切り分け、そのバイト群シーケンスをピース毎にデコードできないのか?


Hypothesizer 7
あるバイト群シーケンスが長すぎて、その全体を一度にメモリに置くのが好ましくない場合、そのバイト群シーケンスをピース群に切り分け、そのバイト群シーケンスをピース毎にデコードせざるをえない。しかしながら、そのバイト群シーケンスを固定長(最終ピースを除いて、勿論)のピース群にただ切り分けると、1つの文字のバイト群シーケンスを切り刻んでしまうかもしれず、そうした文字たちは正常にデコードすることができなくなる。

多くの主要なエンコーディングにおいて、各文字はそれ自身の長さを持っているから、どの文字も切り刻まれることはないと保証されるようなピース長などない。


2: 'java.nio.charset.CharsetDecoder'が何を行なうと私は考えたか


Hypothesizer 7
したがって、バイト群シーケンスをピース毎に受け取り、各ピースを、前ピース(複数かもしれない)からの文字断片(ないかもしれないし、複数あるかもしれない)をカレントピースの前に付加し、カレントピースの末尾にある(ないかもしれない)文字断片を認識・記憶して、適切に処理するデコーダが必要だ。

その関心事を念頭において'java.nio.charset.CharsetDecoder'のAPIドキュメントを読んだ後(いくらかぞんざいにであったことは認める)、それをそのクラスはしてくれるのだろう、と私は(誤って)考えた、なぜなら、私の世界観では、そのクラスのインターフェースを持つクラスは、すべからく、そうするだろうから。

そこで、私は以下のテストプログラムを書いた。そのテストプログラムは、2つの引数を取る: バイト群シーケンスを生成する元の文字列、およびそのバイト群シーケンスをプログラムがピース群へ切り分けるバイト群長だ。えーと、実際には、バイト群シーケンスはストリームから読まれるのだが、テストとして、私はバイト群シーケンスを第1引数から生成した。

@Java ソースコード
package theBiasPlanet.tests.bytesArrayDecodingTest1;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;

public class Test1Test {
	private Test1Test () {
	}
	
	public static void main (String [] a_arguments) throws Exception {
		Test1Test.test (a_arguments [0], Integer.parseInt (a_arguments [1]));
	}
	
	private static void test (String a_inputString, int a_dataBufferSize) throws Exception {
		testCharsetDecoder (a_inputString, a_dataBufferSize);
	}
	
	private static void testCharsetDecoder (String a_inputString, int a_dataBufferSize) throws Exception {
		String l_encoding = "UTF-8";
		byte [] l_inputBytes = a_inputString.getBytes (l_encoding);
		int l_inputBytesLength = l_inputBytes.length;
		ByteBuffer l_newInputBuffer = null;
		CharBuffer l_outputBuffer = CharBuffer.allocate (a_dataBufferSize);
		CoderResult l_decodingResult = null;
		CharsetDecoder l_instanceOfCharsetDecoder = Charset.forName(l_encoding).newDecoder ();
		boolean l_isLastIteration = false;
		for (int l_processedBytesLengthSoFar = 0, l_processedBytesLengthPerIteration = 0; ; ) {
			System.out.println (String.format ("### Decoding from the index, %d.", l_processedBytesLengthSoFar));
			l_processedBytesLengthPerIteration = Math.min (l_inputBytesLength - l_processedBytesLengthSoFar, a_dataBufferSize);
			l_newInputBuffer = ByteBuffer.wrap (l_inputBytes, l_processedBytesLengthSoFar, l_processedBytesLengthPerIteration);
			l_processedBytesLengthSoFar += l_processedBytesLengthPerIteration;
			l_isLastIteration = l_processedBytesLengthSoFar == l_inputBytesLength;
			l_decodingResult = l_instanceOfCharsetDecoder.decode (l_newInputBuffer, l_outputBuffer, l_isLastIteration);
			handleDecodingResult (l_decodingResult, l_outputBuffer);
			if (l_isLastIteration || l_decodingResult.isMalformed () || l_decodingResult.isUnmappable ()) {
				break;
			}
		}
	}
	
	private static void handleDecodingResult (CoderResult a_decodingResult, CharBuffer a_outputBuffer) {
		System.out.println (String.format ("### The decoding result is '%s'.", a_decodingResult.toString ()));
		a_outputBuffer.flip ();
		System.out.println (String.format ("### The output is '%s'.", a_outputBuffer.toString ()));
		a_outputBuffer.clear ();
		System.out.println ("");
	}
}

しかしながら、そのコードは、"aϴbΩ"および"1"を第1・第2引数として渡す時、以下を出力する。

@出力
### Decoding from the index, 0.
### The decoding result is 'UNDERFLOW'.
### The output is 'a'.

### Decoding from the index, 1.
### The decoding result is 'UNDERFLOW'.
### The output is ''.

### Decoding from the index, 2.
### The decoding result is 'MALFORMED[1]'.
### The output is ''. 

はあ?そのAPIの指示どおりにしたと思っていたが、第2の文字である'ϴ'がデコードできない. . .


3: 'java.nio.charset.CharsetDecoder'が実際にすること


Hypothesizer 7
そのAPIドキュメントの'decode'メソッドのところをもっと注意深く読んだところ、それが言うには、「In any case, if this method is to be reinvoked in the same decoding operation then care should be taken to preserve any bytes remaining in the input buffer so that they are available to the next invocation.(必ず、もしも、同一のデコーディング操作内で本メソッドが再度呼び出されるならば、インプットバッファに残っている任意のバイト群を保持しておき、それらを次呼び出しが使えるようにすること)」. . . うむ、オーケー、私はそうした、実のところ、だって、ピース毎に新たなByteBufferインスタンスを私は渡した(したがって、それより古いByteBufferインスタンスたちが不変のまま残されている)し、そのベースバイト群配列も、プロセスを通して不変のままだ: 各インプットバッファ内に残っている任意のバイト群は実際に保持されており、そのクラスは、それらの残存バイト群を読めるはずだ. . .

それじゃあ、何が悪いのか?. . .APIドキュメントをさらに注意深く読み直したところ、それが言うには、「Each invocation of the decode method will decode as many bytes as possible from the input buffer, writing the resulting characters to the output buffer.(デコードメソッドの各呼び出しは、インプットバッファから可能な限り多くのバイト群をデコードして、結果文字群をアウトプットバッファに書く)」. . .勿論、. . .待てよ!その文を以下のように解釈しなければいけないのか?: 'デコードメソッドの各呼び出しは、インプットバッファからのバイト群のみをデコードする、その際、可能な限り多くのバイト群をデコードして、結果文字群をアウトプットバッファに書く'?本当に?インプットバッファからのバイト群をデコードすることは、論理的に言って、その他のバイトをデコードしないことを必ずしも意味しないが、テストプログラムの結果を考えると、そのドキュメントはそう意図しているようだ。

しかし、そうなのであれば、'false'を'endOfInput'引数に渡す目的は何なのか?'endOfInput'に'false'を渡すのは、そのクラスインスタンスに文字断片(複数あるかもしれない)を覚えさせるためだと思っていたのだが: もしも、そのクラスが文字断片(複数あるかもしれない)をどのみち覚えず、ユーザーが自ら文字断片(複数あるかもしれない)をやりくりしなければならないのであれば、'endOfInput'が'true'か'false'かは、どうでもよいだろうに. . .

実のところ、それは、本当にどうでもよいのだ、もしも、バイト群シーケンスにエラーがないと確実に事前想定できるのであれば: 'endOfInput'の'false'は、残存する中途半端なバイト群シーケンスが、後続のピース(複数かもしれない)によって完結させられる文字断片でありうる際に、エラーステータスを返さないためだけなのだ。

オーケー、それじゃあ、私は'false'を渡すべきであり、文字断片が存在するか否かを判断しなければならない、なぜなら、もし存在すれば、その文字断片をやりくりしなければならないから、しかし、どうやって?. . .'decode'メソッドは、実のところ、その情報を、結果インスタンスによって私に知らせない. . .。なぜだ?. . .いずれにせよ、その情報および文字断片(「残存バイト群」ともいう)は、インプットバッファの状態を見ることで知ることができる: もしも、'position'が'limit'地点にないのであれば、文字断片が存在し、文字断片は、'position'から'limit'の直前までのバイト群だ。

このように、そのクラスは、カレントインプットバッファのみをデコードするだけで、文字断片をなんとか私たちが知れるようにし、その文字断片は、私たちがやりくりするにまかせる、というものであるようだ。


4: それでは、'java.nio.charset.CharsetDecoder'をどのように使えばよいのか?


Hypothesizer 7
'decode'メソッドが'endOfInput'を'false'にして初めて呼ばれた際、結果ステータスは、'Underflow'、'Malformed'、'Unmappable'のいずれかだ。

'Underflow'は、必ずしも、インプットバッファが本当にアンダーフローである(末尾に文字断片があるという意味)ことを意味せず、本当にアンダーフローであるか否かは、インプットバッファの状態を見て知らなければならない。もしも、本当にアンダーフローであれば、文字断片は、'position'から'limit'直前までのバイト群であり、それを次ピースの頭になんとかして付加しなければならない。

もしも、結果ステータスが'Malformed'または'Unmappable'であったら、エラーバイト群は、インプットバッファの'position'から、結果インスタンスの'length'の長さのものだ。その後どのようにするかは、私たち次第だ。

'decode'メソッドが'endOfInput'を'false'にして、後の繰り返し回で呼ばれた際、前繰り返し回にて準備されたピースを使い、結果は、最初の繰り返し回と同様に扱わなければならない。

'decode'メソッドが'endOfInput'を'true'にして、最終繰り返し回で呼ばれた際、前繰り返し回にて準備されたピースを使わなければならない。'Underflow'結果ステータスは、本当は'アンダーフローではなく'、'Malformed'および'Unmappable'の結果ステータス群は、初めの繰り返し回におけるのと同様に扱えばよい。

最後に、残存アウトプット(ないかもしれない)をアウトプットバッファへ絞り出すために、デコーダの'flush'メソッドを呼ぶ。


5: ラッパークラスを作成しよう


Hypothesizer 7
正直に言って、'java.nio.charset.CharsetDecoder'(およびそのドキュメント)が親切だとは私は思わない。そこで、そのデコーダが本来振る舞うべきであると私が考えるように振る舞うラッパークラスを作成しよう。実のところ、結果クラスも私は作成した、なぜなら、結果は、インプットバッファが本当にアンダーフローであるのか完全である(残存バイトがない)のかを区別すべきだと私は考えるから。

そうしたクラス群、'theBiasPlanet.coreUtilities.bytesArraysHandling.BytesBufferToCharactersBufferDecoder'および'theBiasPlanet.coreUtilities.bytesArraysHandling.BytesBufferToCharactersBufferDecodingResult'は、本ZIPファイルに含まれており、そのプロジェクト(Gradleプロジェクト)をビルドする方法は、この記事(その記事は、UNOプログラムたちを開発することについての別シリーズのものなので、本プロジェクトをビルドするだけのためには不必要な指示群も一部含んでいる: それらは各自の裁量で無視すればよい)で説明されている。

以下は、そのラッパークラスを使用する方法であり、先程と同じように2つの引数を取る: バイト群シーケンスを生成する元の文字列、およびそのバイト群シーケンスをプログラムがピース群に切り分ける際のバイト群長。

@Java ソースコード
package theBiasPlanet.tests.bytesBufferToCharactersBufferDecoderTest1;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import theBiasPlanet.coreUtilities.bytesArraysHandling.BytesBufferToCharactersBufferDecoder;
import theBiasPlanet.coreUtilities.bytesArraysHandling.BytesBufferToCharactersBufferDecodingResult;

public class Test1Test {
	private Test1Test () {
	}
	
	public static void main (String [] a_arguments) throws Exception {
		Test1Test.test (a_arguments [0], Integer.parseInt (a_arguments [1]));
	}
	
	private static void test (String a_inputString, int a_dataBufferSize) throws Exception {
		String l_encoding = "UTF-8";
		byte [] l_inputBytes = a_inputString.getBytes (l_encoding);
		int l_inputBytesLength = l_inputBytes.length;
		ByteBuffer l_newInputBuffer = null;
		CharBuffer l_outputBuffer = CharBuffer.allocate (a_dataBufferSize);
		BytesBufferToCharactersBufferDecodingResult l_decodingResult = null;
		BytesBufferToCharactersBufferDecoder l_bytesBufferToCharactersBufferDecoder = new BytesBufferToCharactersBufferDecoder (l_encoding);
		boolean l_isLastIteration = false;
		for (int l_processedBytesLengthSoFar = 0, l_processedBytesLengthPerIteration = 0; ; ) {
			System.out.println (String.format ("### Decoding from the index, %d.", l_processedBytesLengthSoFar));
			l_processedBytesLengthPerIteration = Math.min (l_inputBytesLength - l_processedBytesLengthSoFar, a_dataBufferSize);
			l_newInputBuffer = ByteBuffer.wrap (l_inputBytes, l_processedBytesLengthSoFar, l_processedBytesLengthPerIteration);
			l_processedBytesLengthSoFar += l_processedBytesLengthPerIteration;
			l_isLastIteration = l_processedBytesLengthSoFar == l_inputBytesLength;
			l_decodingResult = l_bytesBufferToCharactersBufferDecoder.decode (l_newInputBuffer, l_outputBuffer, l_isLastIteration);
			handleDecodingResult (l_decodingResult, l_outputBuffer);
			if (l_isLastIteration || l_decodingResult.isMalformed () || l_decodingResult.isUnmappable ()) {
				break;
			}
		}
	}
	
	private static void handleDecodingResult (BytesBufferToCharactersBufferDecodingResult a_decodingResult, CharBuffer a_outputBuffer) {
		System.out.println (String.format ("### The decoding result is '%s' while the inputs residue starting index = '%d'.", a_decodingResult.toString (), a_decodingResult.getInputsResidueStartingIndex ()));
		a_outputBuffer.flip ();
		System.out.println (String.format ("### The output is '%s'.", a_outputBuffer.toString ()));
		a_outputBuffer.clear ();
		System.out.println ("");
	}
}

'java.nio.charset.CharsetDecoder'の振る舞いの要旨に立ち入った後なので、それらのクラスの詳細に立ち入る必要はないだろう。しかし、単簡な説明だけはしておこう。'i_previousInputsResidueBuffer'は、ラッパーが先行ピース群からの残存バイト群を覚えておくためのバッファだ(ピースサイズが3より小さければ、残存バイト群は複数のピースから来ているかもしれない、なぜなら、4バイトUTF-8文字は、3ピースに分けられるかもしれないから)。実際には、ラッパーは、文字通りに残存バイト群を次ピースの前に付加するのではなく(Javaは、配列の前に追加するなど許さないから)、分割された文字を'i_previousInputsResidueBuffer'内で処理する。


6: 結び


Hypothesizer 7
これで、任意のバイト群シーケンスを文字列へデコードする方法を、私は理解したようだ。

バイト群シーケンスが十分短くて、バイト群シーケンスの全体が正常にデコードできることが確実である時は、'String'のコンストラクタを使えばよいだけだ。

バイト群シーケンスをストリームとして、バイト群長を指定する必要なく読む時は、ある'Reader'を使えばよいだけだ。

バイト群シーケンスが長すぎるが、いかなる'Reader'も、直面する関心事には適切でない時、バイト群シーケンスをピース群に切り分けてバイト群シーケンスをピース毎に処理するしかない。その場合には、'java.nio.charset.CharsetDecoder'クラスを使える。また、それは、バイト群シーケンスがエラーを含むかもしれない時にも有用だ。

しかしながら、そのクラスの振る舞いはあまり親切ではなく、そのAPIドキュメントも同じである。そこで、私はラッパークラスを作成した。


参考資料


<このシリーズの前の記事 | このシリーズの目次 | このシリーズの次の記事>