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
Іноді новачкам бракує короткої «карти місцевості»: яку функцію викликали — що отримали — що перевіряти. Зафіксуймо це в табличці.
| Операція | Що робить | Що повертає | Що обовʼязково перевірити |
|---|---|---|---|
|
читає 1 байт | Int: 0..255 або -1 | -1 означає EOF |
|
читає порцію байтів | Int: 1..buffer.size або -1 | n — це скільки байтів реально в buffer[0..n-1] |
|
записує весь масив | нічого (Unit) | переконатися, що масив справді «актуальний цілком» |
|
записує частину масиву | нічого (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 зберігає сирі байти «як є».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ