1. はじめに
もうストリーム(Stream)には慣れてきたよね。これはデータを順番に読み書きするための抽象クラスだよ。FileStreamを使ってバイト単位でファイルにアクセスしたり、StreamReaderやStreamWriterでテキストデータを便利に扱ったりしてきた。ちなみにこれらも内部的にはFileStreamを使ってて、エンコーディングも気にしてくれる。
でも、もしファイルにただのテキストじゃなくて、しっかり型が決まったデータを保存したい場合はどうする?たとえば整数(int)、浮動小数点数(double、float)、ブール値(bool)、日付(DateTime)、あるいは自作の構造体とか。もちろん全部文字列に変換してStreamWriterで書き込んで、読み込むときにまたパースすることもできる。でもこのやり方には大きなデメリットがあるんだ:
- 保存の非効率性: 数字12345をテキストで書くと5バイト(文字)使うけど、バイナリのintならたった4バイト。大量のデータだとこの差はかなり大きい。
- パフォーマンス: 数値を文字列に変換したり戻したりするのはCPUに余計な負担がかかる。
- データの正確さ: 小数を文字列にして戻すとき、丸め誤差で精度が落ちることがある。
- パースの面倒さ: テキストから色んな型のデータ(例:"123,45 TRUE 2024-06-21")を手作業で取り出すのはコードが複雑になるし、壊れやすい。
こういう問題を解決するために、BinaryReaderとBinaryWriterっていう専用クラスがあるんだ。これらはどんなStream(たいていはFileStream)の上に乗っかって、C#のプリミティブ型をバイナリ形式で読み書きする便利なメソッドを提供してくれる。バイト列と型の変換を全部やってくれるから、構造化されたバイナリファイルを扱うのがめっちゃ楽になるよ。
ポイント: BinaryReaderとBinaryWriterはそれ自体がストリームじゃなくて、既存のStreamの機能を強化して、バイトじゃなくてC#の型で操作できるようにしてくれるんだ。
2. BinaryWriterでデータを書き込む
BinaryWriterはC#の各プリミティブ型ごとにオーバーロードされたWrite()メソッドを持ってる。これを呼ぶと、値をバイナリ(バイト列)に変換して、ベースのStreamに書き込んでくれるよ。
例:ゲーム設定を保存する
たとえば、ゲームの設定を保存したいとしよう。音量(float)、現在のレベル(int)、音楽がオンかどうか(bool)、選択した難易度(string)を保存する場合だね。
string filePath = "settings.bin";
// 1. 書き込み用のFileStreamを作成
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
// 2. FileStreamの上にBinaryWriterを作成。文字列用にエンコーディングも指定
using BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8);
// 3. いろんな型のデータを書き込む
writer.Write(0.75f); // float(4バイト)
writer.Write(15); // int(4バイト)
writer.Write(true); // bool(1バイト)
writer.Write("Easy"); // string(長さのプレフィックス+バイト列)
Console.WriteLine($"設定を'{filePath}'に保存したよ。");
例の解説:
- FileStreamをFileMode.Createで作成。新しいファイルを作るか、既存のファイルを上書きする。
- 次にBinaryWriterを作成して、fsを渡す。ちなみにBinaryWriterはデフォルトでベースのストリーム(fs)もDispose()(usingブロックで自動的に呼ばれる)で閉じてくれる。
- writer.Write()のメソッドは直感的で、Write(float)、Write(int)、Write(bool)、Write(string)みたいに型ごとに用意されてる。何バイト書くかも自動でやってくれる。
- 文字列の場合、BinaryWriterは長さのプレフィックスをバイト列の前に自動で付けてくれる。これでBinaryReaderが何バイト読めばいいか分かる。
- settings.binをテキストエディタで開いても「ゴミ」に見えるよ。これはバイナリファイルだから。中身を見たいならHEXエディタを使おう。
3. BinaryReaderでデータを読み込む
BinaryReaderはReadXxx()(例:ReadInt32()、ReadBoolean()、ReadString())というメソッドを持ってて、ベースのStreamから必要なバイト数を読んで、C#の型に変換してくれる。
例:ゲーム設定を読み込む
さっき作ったsettings.binから設定を読み込んでみよう。
string filePath = "settings.bin";
// 1. 読み込み用のFileStreamを作成
using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
// 2. FileStreamの上にBinaryReaderを作成。同じエンコーディングを使う
using BinaryReader reader = new BinaryReader(fs, Encoding.UTF8);
// 3. 書き込んだのと同じ順番でデータを読む
float volume = reader.ReadSingle(); // float
int level = reader.ReadInt32(); // int
bool isMusicOn = reader.ReadBoolean(); // bool
string difficulty = reader.ReadString(); // string
Console.WriteLine($"'{filePath}'から設定を読み込んだよ:");
Console.WriteLine($"- 音量: {volume:P0}"); // パーセント表示(文字列フォーマットを使う)
Console.WriteLine($"- プレイヤーレベル: {level}");
Console.WriteLine($"- 音楽オン: {isMusicOn}");
Console.WriteLine($"- 難易度: {difficulty}");
例の解説:
- FileStreamをFileMode.Openで開いて読み込み。
- BinaryReaderをfsの上に作成。書き込み時と同じエンコーディングを指定するのが大事。
- 超重要: reader.ReadXxx()の呼び出し順は、BinaryWriterで書き込んだ順番と完全に一致してないとダメ。もしintを書いた場所でstringを読もうとすると、EndOfStreamException(stringが長い場合)や、変なデータを読むことになる。
- ReadXxx()メソッドは必要なバイト数を自動で読んで型に変換してくれる。ReadString()はBinaryWriterが書いた長さのプレフィックスを使って、必要なバイト数だけ読む。
4. 大事なポイントとベストプラクティス
順番は厳守:
これが一番大事。BinaryReaderとBinaryWriterは型情報のメタデータを持ってない。ただプリミティブ型ごとに何バイト使うか知ってるだけ。だから順番を絶対に合わせよう。
リソース管理(using):
.NETのほとんどのシステムリソース(ファイルやネットワーク接続など)を扱うクラスと同じく、BinaryReaderもBinaryWriterもIDisposableを実装してる。だから必ずusingブロックで囲もう。これでエラーが起きてもDispose()が自動で呼ばれて、リソースリークやファイルの閉じ忘れを防げるよ。
ちなみにBinaryWriterもBinaryReaderも、渡されたベースのストリーム(例:FileStream)もDispose()で自動的に閉じてくれる。
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
// ... ここで作業
文字列のエンコーディング:
BinaryWriter.Write(string)で書いた文字列をBinaryReader.ReadString()で正しく読むには、必ず同じエンコーディングをコンストラクタで指定しよう(例:Encoding.UTF8)。ASCIIに入らない文字があるときは特に注意。
例外処理:
ファイルの入出力は外部要因(ファイルがない、権限がない、ディスクがいっぱいなど)で失敗することがある。FileStreamやBinaryReader/BinaryWriterを使うコードは必ずtry-catchで囲んで信頼性を高めよう。
BaseStreamと位置:
ベースのストリームにはBaseStreamプロパティ(例:reader.BaseStreamやwriter.BaseStream)でアクセスできる。今の位置(BaseStream.Position)を知ったり、ファイル内を移動したいとき(BaseStream.Seek())に便利だよ。
// BaseStream.Positionの使い方例
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
writer.Write(123);
Console.WriteLine($"ストリームの現在位置: {writer.BaseStream.Position}"); // 4(intのサイズ)
writer.Write("Hello");
Console.WriteLine($"ストリームの現在位置: {writer.BaseStream.Position}"); // 4 + (1+5) = 10
⚠️ Write(string)メソッドは、まず文字列の長さを7ビット整数で書いてから、文字列のバイト列を書き込む。だから実際のサイズは1+文字列の長さとは限らないよ。
GO TO FULL VERSION