JavaRush /Курси /C# SELF /Робота з байтовими потоками

Робота з байтовими потоками

C# SELF
Рівень 36 , Лекція 4
Відкрита

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 + довжина рядка.

1
Опитування
Потоки вводу-виводу, рівень 36, лекція 4
Недоступний
Потоки вводу-виводу
Читання та запис файлів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ