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 хранит сырые байты «как есть».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ