JavaRush /Курси /Kotlin SELF /Файлові потоки: FileInputStream/FileOutputStream, EOF і u...

Файлові потоки: FileInputStream/FileOutputStream, EOF і use

Kotlin SELF
Рівень 43 , Лекція 2
Відкрита

1. Вступ

Коли ви лише починаєте, дуже хочеться, щоб усе було як у казці: val text = file.readText() — і готово. Але «зручні методи» закінчуються там, де починається реальне життя й реальні розміри файлів.

По‑перше, не всі файли — текстові. Якщо ви спробуєте «прочитати як текст» PNG‑зображення, отримаєте набір дивних символів (і, можливо, відчуття, ніби компʼютер свариться з вами давньою мовою). По‑друге, навіть якщо файл текстовий, він може бути великим: читати його цілком означає завантажити весь файл у памʼять. Іноді це нормально, а іноді — дуже дорого.

Потоки розвʼязують обидві проблеми: вони дають модель «читаю/пишу поступово», маленькими порціями. І найважливіше — потокова модель однаково працює і для бінарних файлів, і для текстових (хоча для тексту часто зручніше використовувати високорівневі Reader/Writer).

Байтові потоки та файлові реалізації

Потоки на JVM найпростіше уявити через побутову аналогію. InputStream — це кран, з якого ви можете набирати воду (байти). OutputStream — це раковина (або шланг у каналізацію), куди ви можете зливати воду (байти). І ви не зобовʼязані одразу вилити басейн — можна працювати кухлем: налив, перелив, налив, перелив.

У термінах коду:

  • InputStream — джерело байтів; у нього ми викликаємо read(...).
  • OutputStream — приймач байтів; у нього ми викликаємо write(...).

Зручно те, що ваш код при цьому може бути доволі універсальним: якщо ви навчилися читати з InputStream, вам уже майже байдуже, звідки ці байти: файл, мережа, ZIP‑архів — що завгодно.

FileInputStream і FileOutputStream

Тепер — трохи конкретики. На JVM є окремі реалізації потоків для файлів:

  • FileInputStream читає байти з файлу.
  • FileOutputStream записує байти у файл.

Вони живуть у пакеті java.io, і їх можна створити за рядковим шляхом або через File.

Важливо памʼятати два практичні моменти.

Перший: потік — це ресурс. Відкривши потік, ви «тримаєте» файл. Якщо ви забудете закрити потік, програма може утримувати файл зайнятим довше, ніж вам здається. Інколи це призводить до проблем у Windows (та й не лише там).

Другий: потокова модель не обіцяє, що «один виклик read» прочитає вам усе, що ви хочете. Вона гарантує лише чесний контракт: «я прочитав стільки, скільки зміг; ось кількість байтів». Саме цей контракт — серце сьогоднішньої лекції.

2. Читання і запис: read, EOF і write

read() і значення -1

InputStream.read() у найпростішій формі читає один байт. Але повертає не Byte, а Int. Спершу це виглядає як дивна забаганка розробників Java, але насправді все дуже практично: потрібно десь зберігати спеціальне значення «більше байтів немає».

Контракт read() (один байт):

  • Повернене значення 0..255 означає «прочитано байт із таким числовим значенням».
  • Повернене значення -1 означає EOF — End Of File, «досягли кінця файлу».

Тобто -1 — це не байт. Це сигнал: «файл закінчився».

Міні‑приклад: читаємо перший байт файлу і друкуємо його.

import java.io.FileInputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        val first = input.read()
        println("first=$first") // first=0..255 або first=-1, якщо файл порожній
    }
}

Тут use гарантує закриття потоку. І так: якщо файл порожній, read() одразу поверне -1. Це не помилка, а нормальна ситуація: «читати нічого».

Пастка про Byte у Kotlin

У Kotlin тип Byte — знаковий (-128..127). А байт у файлі — це просто 8 біт (0..255, якщо дивитися як на число без знака). Тому потік повертає Int: так простіше не плутати «байт як дані» і -1 «як сигнал».

read(buffer) — читаємо порцію даних і отримуємо n

Читати по одному байту можна, але це майже завжди повільно й незручно. Тому частіше використовують читання в масив: read(buffer).

І тут зʼявляється новий герой: ByteArray. Це «масив байтів», тобто контейнер для сирих даних. У Kotlin він належить до примітивних масивів, як IntArray, DoubleArray тощо.

Контракт read(buffer: ByteArray): Int:

  • Повертає n > 0: реально прочитано n байтів і записано в buffer[0]..buffer[n-1].
  • Повертає -1: EOF, байтів більше немає (і нічого корисного в буфері читати не треба).

Важливо: read(buffer) може прочитати менше, ніж розмір буфера. Навіть якщо файл великий. Навіть якщо ви дуже просили. Це нормальна частина контракту: «скільки зміг — стільки й дав».

Міні‑приклад: пробуємо прочитати до 16 байтів і друкуємо, скільки вийшло.

import java.io.FileInputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        val buffer = ByteArray(16)
        val n = input.read(buffer)
        println("n=$n") // n=1..16 або -1
    }
}

Якщо n == 6, це означає: актуальні дані лише в buffer[0]..buffer[5]. Усе інше в buffer — або старі значення (якщо ви перевикористовуєте буфер), або нулі (якщо масив щойно створено). Але це вже не «прочитані дані».

OutputStream.write(...): записуємо рівно те, що прочитали

Тепер — дзеркальна сторона: запис. У OutputStream є кілька варіантів write, але нас зараз цікавлять два:

  • write(byteArray) — записати весь масив.
  • write(byteArray, offset, length) — записати частину масиву.

І ось тут важливо повʼязати запис із читанням: якщо ви читали в буфер і отримали n, то записувати потрібно рівно n байтів, а не весь буфер цілком. Інакше ви «дописуєте хвіст» — зайві байти, яких насправді не було у файлі.

Міні‑приклад: прочитали порцію, записали рівно її.

import java.io.FileInputStream
import java.io.FileOutputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        FileOutputStream("out.bin").use { output ->
            val buffer = ByteArray(16)
            val n = input.read(buffer)

            if (n != -1) {
                output.write(buffer, 0, n) // пишемо лише реальні байти
            }
        }
    }
}

Зверніть увагу: це не повноцінне копіювання файлу. Ми прочитали лише одну порцію й один раз записали. Повне перенесення даних роблять циклом — і це якраз тема наступної лекції. Сьогодні ж нам важливо довести до автоматизму саму звʼязку: «read повертає n → записувати потрібно 0..n-1».

3. Закриття потоків і use {}

Потоки потрібно закривати. Завжди. Навіть якщо ви «зараз швиденько» прочитали 3 байти й пішли.

У Java для цього часто використовували try-with-resources, а в Kotlin ідіоматичний варіант — use {}. Він працює для ресурсів, які потрібно закривати (зокрема, для файлових потоків), і гарантує закриття навіть якщо всередині блока стався виняток.

Щоб краще відчути різницю, корисно порівняти два варіанти: вручну через try/finally і через use.

Варіант вручну: try/finally

import java.io.FileInputStream

fun main() {
    val input = FileInputStream("in.bin")
    try {
        val b = input.read()
        println("b=$b")
    } finally {
        input.close()
    }
}

Це працює, але потребує дисципліни: не забути finally, не забути close(), не загубити потік у разі раннього return.

Варіант Kotlin‑стандарт: use

import java.io.FileInputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        val b = input.read()
        println("b=$b")
    }
}

Зміст той самий, але шанс «забути закрити» значно нижчий. А коли ваш код стане складнішим (а він стане), це почне економити час і нерви.

Вкладені use — це нормально

Коли вам потрібно відкрити і вхідний, і вихідний потік, зазвичай роблять вкладеність. Так, виглядає як «матрьошка», але читається цілком нормально, особливо якщо блоки невеликі:

import java.io.FileInputStream
import java.io.FileOutputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        FileOutputStream("out.bin").use { output ->
            val x = input.read()
            if (x != -1) output.write(x)
        }
    }
}

4. Міні-проєкт FileLab: peekbin

Щоб приклади не були «у вакуумі», уявімо, що в нас уже є простий консольний застосунок FileLab, який ми використовували для читання/запису тексту. Сьогодні ми додамо в нього невелику можливість: «піддивитися перші байти файлу», щоб переконатися, що ми справді працюємо з бінарними даними, а не з текстом.

Нехай у нас буде функція readFirstBytes, яка читає до maxCount байтів (однією порцією) і повертає фактично прочитану кількість. Зверніть увагу: ми свідомо робимо один read(buffer) без циклу, тому що коректний цикл — окрема тема наступної лекції.

import java.io.FileInputStream

fun readFirstBytes(path: String, maxCount: Int): ByteArray {
    FileInputStream(path).use { input ->
        val buffer = ByteArray(maxCount)
        val n = input.read(buffer)
        if (n == -1) return ByteArray(0)
        return buffer.copyOf(n) // залишаємо лише реальні байти
    }
}

Тут є кілька важливих думок.

Ми створюємо ByteArray(maxCount) як «контейнер‑приймач». Потім читаємо й отримуємо n. Якщо n == -1, це означає, що файл порожній (або ми вже на кінці) — повертаємо порожній масив. Якщо n > 0, то повертаємо не весь буфер, а copyOf(n), щоб результат був чесним: рівно стільки байтів, скільки реально прочитали.

Тепер можна зробити маленький main, який виводить байти «як числа»:

fun main() {
    val bytes = readFirstBytes("in.bin", maxCount = 8)
    println("read=${bytes.size}") // read=0..8
    println(bytes.joinToString()) // наприклад: 137, 80, 78, 71, ...
}

Якщо ви візьмете PNG‑файл і прочитаєте перші байти, то часто побачите сигнатуру PNG (починається з 137, 80, 78, 71…), і це хороший момент «ага»: файл — це байти, і ми можемо працювати з ними напряму, не вдаючи, що це текст.

Таблиця-шпаргалка за контрактами read і write

Іноді новачкам бракує короткої «карти місцевості»: яку функцію викликали — що отримали — що перевіряти. Зафіксуймо це в табличці.

Операція Що робить Що повертає Що обовʼязково перевірити
input.read()
читає 1 байт Int: 0..255 або -1 -1 означає EOF
input.read(buffer)
читає порцію байтів Int: 1..buffer.size або -1 n — це скільки байтів реально в buffer[0..n-1]
output.write(buffer)
записує весь масив нічого (Unit) переконатися, що масив справді «актуальний цілком»
output.write(buffer, 0, n)
записує частину масиву нічого (Unit) n має бути тим самим, що повернув read

Цю табличку корисно тримати в голові до автоматизму, тому що далі (і в копіюванні файлів, і в ZIP‑архівах) у вас буде все те саме: «прочитав → отримав n → записав n».

5. Типові помилки під час роботи з InputStream/OutputStream, EOF і use

Помилка № 1: ігнорувати -1 і вважати, що read() завжди повертає байт.
Часто новачки пишуть код, який бере результат read() і одразу використовує його як дані. Але -1 — це не байт, а сигнал EOF. Якщо його не обробити, ви починаєте «записувати» у вихідний файл зайве значення або будувати логіку на смітті. Правильна звичка — завжди тримати в голові: -1 означає «усе, закінчили».

Помилка № 2: писати output.write(buffer) після val n = input.read(buffer).
Це, мабуть, найпоширеніша логічна помилка в потоках. read(buffer) майже ніколи не гарантує, що заповнить увесь буфер, особливо на останній порції даних. Якщо ви записуєте весь буфер, то додаєте «хвіст» — байти, які насправді не були прочитані з файлу. Через це копія файлу виходить пошкодженою, а ви потім пів дня шукаєте, чому «зображення не відкривається».

Помилка № 3: очікувати, що одного read(buffer) вистачить, щоб прочитати потрібну кількість.
Потоки — це поступова модель. read(buffer) може повернути менше, ніж buffer.size, і це нормально. Щоб гарантовано перенести весь файл або прочитати рівно N байтів, зазвичай потрібен цикл, який повторює read до EOF або до досягнення потрібного розміру. Сьогодні ми це лише відчули на рівні контракту; повноцінний шаблон буде в наступній лекції.

Помилка № 4: не закривати потоки або закривати їх «десь потім».
Файловий потік — ресурс, який має закриватися швидко й гарантовано. Якщо ви закриваєте його «десь потім», то за винятку або раннього виходу потік залишиться відкритим, і ви отримаєте ефекти рівня «файл не видаляється», «файл зайнятий», «дані не дописалися». use {} — практично стандарт де-факто, і краще одразу зробити це автоматичною звичкою.

Помилка № 5: плутати байт як число і Byte у Kotlin.
Byte у Kotlin знаковий, і тому значення байта 200 виглядатиме як відʼємне, якщо ви почнете бездумно приводити типи. Це не «зламані дані», це лише представлення. Якщо вам потрібно друкувати байти як числа 0..255, зручніше працювати з Int (наприклад, через toInt() and 0xFF) або памʼятати, що InputStream.read() повертає Int, а ByteArray зберігає сирі байти «як є».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ