1. Введение
Мы уже знакомы с потоками (Stream), которые представляют собой абстракцию для последовательного чтения или записи данных. Мы работали с FileStream для доступа к файлам на байтовом уровне, а также использовали StreamReader и StreamWriter для удобной работы с текстовыми данными, которые при этом под капотом используют FileStream и заботятся о кодировках.
Но что, если нам нужно хранить в файле не просто текст, а строго типизированные данные: целые числа (int), числа с плавающей точкой (double, float), булевы значения (bool), даты (DateTime) или даже пользовательские структуры? Конечно, можно преобразовать всё это в строки и записывать с помощью StreamWriter, а затем парсить обратно при чтении. Однако такой подход имеет существенные недостатки:
- Неэффективность хранения: Число 12345, записанное как текст, занимает 5 байт (символов). В бинарном виде int занимает всего 4 байта. Для больших объемов данных разница становится критичной.
- Производительность: Постоянные преобразования чисел в строки и обратно — это накладные расходы на процессорное время.
- Точность данных: Преобразование дробных чисел в текст и обратно может привести к потере точности из-за округления.
- Сложность парсинга: Ручная разборка текстовых строк для извлечения различных типов данных (например, "123,45 TRUE 2024-06-21") значительно усложняет код и делает его хрупким.
Для решения этих задач существуют специализированные классы BinaryReader и BinaryWriter. Эти классы являются специализированными адаптерами, которые работают поверх любого базового Stream (чаще всего FileStream) и предоставляют удобные методы для чтения и записи примитивных типов данных C# в их бинарном формате. Они берут на себя всю работу по преобразованию байтов в конкретные типы и обратно, существенно упрощая процесс работы со структурированными бинарными файлами.
Ключевая идея: BinaryReader и BinaryWriter — это не самостоятельные потоки. Они усиливают функциональность существующего Stream, добавляя методы для работы с C#-типами вместо сырых байтов.
2. Запись данных с помощью BinaryWriter
BinaryWriter предоставляет набор методов Write(), перегруженных для каждого примитивного типа данных C#. При вызове такого метода, BinaryWriter преобразует значение в его бинарное представление (последовательность байтов) и записывает эти байты в базовый Stream.
Пример: Сохраняем настройки игры
Представим, что мы хотим сохранить настройки игры: уровень громкости (дробное число), текущий уровень игрока (целое число), включена ли музыка (булево значение) и выбранный уровень сложности (строка).
string filePath = "settings.bin";
// 1. Создаем FileStream для записи
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
// 2. Создаем BinaryWriter поверх FileStream, указываем кодировку для строк (если будут)
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. Создаем BinaryReader поверх FileStream, используем ту же кодировку
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. Если вы попытаетесь прочитать string там, где был записан int, это приведет к ошибке EndOfStreamException (если string длиннее) или чтению некорректных данных.
- Методы ReadXxx() автоматически считывают нужное количество байтов и преобразуют их в запрошенный тип. ReadString() использует тот самый префикс длины, записанный BinaryWriter, чтобы определить, сколько байтов нужно прочитать для полной строки.
4. Важные нюансы и лучшие практики
Строгий порядок:
Это главное правило. BinaryReader и BinaryWriter не хранят метаданных о типах; они просто знают, сколько байтов занимает каждый примитивный тип. Вы должны обеспечивать соответствие порядка.
Управление ресурсами (using):
Как и большинство классов .NET, которые работают с системными ресурсами (например, файлами или сетевыми соединениями), и BinaryReader, и BinaryWriter реализуют интерфейс IDisposable. Поэтому всегда оборачивайте их в using-блок — так вы гарантируете автоматический вызов Dispose(), даже если произойдёт ошибка. Это защитит вас от утечек и правильно закроет файл.
Кстати, по умолчанию BinaryWriter и BinaryReader также вызовут Dispose() для переданного им базового потока (например, FileStream), так что он тоже будет закрыт автоматически.
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 + длина строки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ