System.IO.TextReader.Peek の戻り値でストリームの終わりを判定してはいけない
ref: Microsoftのバグ!? | 自由人usk-mの気ままなブログ
"StreamReader Peek" や "TextReader Peek" でググると Peek() の戻り値が -1 かどうかで判定しているコードがたくさん引っかかるが、実際には TextReader.Peek() はストリームの終わりでなくても -1 を返すことがある。
非常によく見かける上、案外知られていない&うっかりバグを生んでしまいやすい仕様なのでメモ。以前使ったことのある JSON ライブラリか何かにもこのバグがあってデバッグに苦労した覚えが・・・。
TextReader.Peek() の仕様
TextReader.Peek メソッド (System.IO) を読むと分かるとおり、このメソッドは
リーダーや文字の読み取り元の状態を変更せずに、次の文字を読み取ります。
戻り値
使用できる文字がないか、ストリームがシークをサポートしていない場合は -1
という趣旨のメソッドであるので、
- 読み取り元の状態を変更せずに得られる文字がない
- ストリームが終わっているわけではない
という二条件を偶然満たしてしまった場合、
- ストリームが終わっている訳ではないのに Peek が -1 を返す
という事が起こるのは仕様の範囲内であり、バグではない。
発生する条件の例
この状況は、
- MemoryStream や、ローカルなファイルに対するストリームで実験している分にはほぼ発生しない*1
- ネットワーク*2や、子プロセスとのパイプといったストリームを使っている
- ストリームの供給源よりも高速に読み取っている
- 運悪く、ストリームの内部バッファが空になっている瞬間に Peek した
というシチュエーションでもないと再現しないため、往々にして発生しづらく再現もしづらいバグになってしまう。
また、昔にどこかで BufferedStream でストリームをラップしておくと治ると言った記述を見つけたことがある*3が、BufferdStream にラップしたとしても、元のストリームと BufferdStream 双方のバッファの切れ目に当たってしまった場合、「読み取り元の状態を変更せず」に Peek できないため、-1 が返ってきてしまう*4。
Workaround
そもそも、ストリームの終端かどうかの判定は Peek でなく、StreamReader.EndOfStream プロパティ (System.IO) を使えば良いので、そうするべきである。
ただ、現実問題、Peek() を使うのは終端判定というよりも、むしろ単にストリームを読み進めずに次の文字を得たいという用途がほとんどである。そして、この用途ならば、たいていの場合は自前で一文字分のバッファを持つことで Read() だけで実現可能である。
具体的には、
TextReader src; int buff; void Init(){ // 初期化 this.buff = this.src.Read(); } int Read(){ int result = this.buff; if(result >= 0) this.buff = this.src.Read(); return result; } int Peek(){ return this.buff; }
のようにすることで、TextReader.Peek を使わずに目的を果たすことが出来る。
この workaround の難点は、初期化時に 1 文字読み取ってしまうため、ストリームから 0 文字だけ読み取って終了する*5ということが出来ないという点であるが、それが問題にならないのであれば、回避策として有用である。この現象に初めて遭遇したときの JSON パーサでは、JSON の仕様からして必ず 1 文字以上読み取る必要があったため、この workaround で問題なかった。もし、このストリームから最初に 1 文字読み取ってしまう挙動も問題なのであれば、TextReader のデコレータークラスを自前で作っておくことで、やはり解決できるであろう。