1. Копировать по одному байту — это медленно
Если вы когда-нибудь пробовали переносить воду чайной ложкой, когда рядом стоит ведро, то вы уже понимаете идею этой лекции. Чтение и запись по одному байту технически возможны, но почти всегда приводит к огромному количеству обращений к системе ввода‑вывода. А операции I/O — это не «плюсик» в памяти, это поход «наружу» (в ОС, в файловую систему, на диск).
Наивный код типа «читаю байт → сразу пишу байт» чаще всего проигрывает по скорости в разы и даже в десятки раз. Причина не в том, что компьютер «не любит байты», а в том, что он не любит миллионы мелких операций I/O. Мы хотим сделать меньше операций — и переносить данные блоками, например по 8 КБ за раз.
Мини‑набросок «как НЕ надо» (это не «запрещённый приём», это «пожалейте свой SSD»):
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
while (true) {
val b = input.read()
if (b == -1) break
output.write(b)
}
}
}
}
Код корректный по смыслу, но слишком «мелкий» по порциям данных.
2. Буфер и буферизация: ByteArray vs Buffered...Stream
Сейчас будет важный момент, который спасает от половины типичных ошибок в этой теме. Когда мы говорим «буфер», мы можем иметь в виду две разные вещи, которые часто используются вместе, но решают разные задачи.
Первое: наш буфер — это обычный ByteArray, куда мы читаем байты и откуда их пишем. Это просто контейнер фиксированного размера. Здесь работает привычная логика массивов: индексы, size, и так далее. ByteArray — это primitive‑array в Kotlin (без boxing), что особенно логично для данных «байт‑в‑байт».
Второе: буферизация потоков — это обёртки вроде BufferedInputStream и BufferedOutputStream, которые внутри себя держат дополнительный буфер и уменьшают количество обращений к базовому потоку. Это оптимизация «уровнем ниже»: даже если вы читаете блоками, буферизованный поток может сделать это ещё эффективнее.
Удобно удерживать разницу в одной таблице:
| Понятие | Что это | Где живёт | Зачем нужно |
|---|---|---|---|
|
ваш рабочий массив байтов | в вашем коде | читать/писать chunks одинакового размера |
|
обёртка над потоком | внутри Java I/O | реже дёргать базовый поток (меньше I/O‑операций) |
То есть: наш ByteArray — это «миска», а Buffered...Stream — это «официант, который приносит сразу поднос, а не одну котлету».
3. Контракт read(buffer): значение n
Чтобы chunk‑копирование работало корректно, нужно принять простую, но очень важную мысль: вызов read(buffer) не обязан заполнить буфер целиком.
Метод read(buffer) возвращает Int, и это число означает: «сколько байт реально прочитали и положили в buffer». Возвращаемое значение:
- может быть меньше размера буфера, даже если файл ещё не кончился;
- будет -1, когда наступил EOF (конец потока).
И вот тут рождается ключевой инвариант копирования: писать в выходной поток нужно ровно n байт, а не весь буфер.
Давайте на очень маленьком примере (без файлов) увидим идею: будем читать из ByteArrayInputStream кусками по 4 байта. Да, это «игрушечный» источник, но он отлично демонстрирует контракт.
import java.io.ByteArrayInputStream
fun main() {
val data = byteArrayOf(10, 20, 30, 40, 50)
val input = ByteArrayInputStream(data)
val buffer = ByteArray(4)
val n = input.read(buffer)
println("n=$n") // n=4
println(buffer[0]) // 10
}
Если сделать второй read, то он вернёт 1, потому что остался всего один байт. И это нормально. Именно поэтому n нельзя игнорировать.
4. Канонический цикл chunk‑копирования
Сейчас будет «золотой шаблон», который нужно не просто понять, а привыкнуть писать почти автоматически. Он простой:
- Создаём ByteArray фиксированного размера (например, 8 * 1024).
- В цикле читаем n = input.read(buffer).
- Если n == -1 — выходим.
- Иначе пишем output.write(buffer, 0, n).
Вот минимальная правильная версия как функция (это удобнее, чем держать копирование прямо в main):
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
fun copyFileChunked(srcPath: String, dstPath: String) {
FileInputStream(srcPath).use { rawIn ->
FileOutputStream(dstPath).use { rawOut ->
BufferedInputStream(rawIn).use { input ->
BufferedOutputStream(rawOut).use { output ->
val buffer = ByteArray(8 * 1024)
while (true) {
val n = input.read(buffer)
if (n == -1) break
output.write(buffer, 0, n)
}
}
}
}
}
}
Обратите внимание на две вещи. Во‑первых, мы закрываем ресурсы через use {} — это идиоматичный Kotlin‑подход для всего, что реализует AutoCloseable: поток закроется даже если внутри случится ошибка. Во‑вторых, мы используем Buffered...Stream, но не заменяем ими наш буфер: мы всё равно читаем в ByteArray.
5. Почему output.write(buffer) — частая ошибка
Этот раздел обычно вызывает у новичков удивление уровня: «Ну я же заполнил буфер, чего не записать его весь?». Давайте разберёмся, почему нельзя так делать.
Если вы пишете output.write(buffer), вы записываете весь массив — то есть buffer.size байт. Но read(buffer) мог прочитать только n байт. Остальные элементы буфера либо остались с прошлого цикла, либо вообще содержат «старый мусор» (в смысле старые значения массива). Итог: в конце файла вы допишете лишние байты, и копия получится повреждённой.
Плохой пример (в нём ошибка специально):
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val buffer = ByteArray(1024)
val n = input.read(buffer)
if (n != -1) output.write(buffer) // ОШИБКА: пишет все 1024 байта
}
}
}
Правильный вариант: write(buffer, 0, n).
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val buffer = ByteArray(1024)
val n = input.read(buffer)
if (n != -1) output.write(buffer, 0, n) // пишем только прочитанное
}
}
}
Почему ошибка иногда «не палится»? Потому что если размер файла ровно кратен размеру буфера, то последний блок тоже будет полный, и баг спрячется. Это как баг «падает только по пятницам» — особенно любимый жанр у разработчиков.
6. Встраиваем копирование в мини‑утилиту
Простой main без лишней магии
Чтобы примеры не были набором отдельных кусочков, представим, что у нас растёт простая консольная утилита, условно назовём её FileTool. Её цель — выполнить одно действие: скопировать файл из A в B.
Пока без сложных команд, без парсинга аргументов и без богатого UX (у нас сегодня тема не про CLI‑архитектуру). Просто берём два пути и запускаем копирование.
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("out.bin")
copyFileChunked(src.path, dst.path)
println("Done. size=${dst.length()} bytes") // Done. size=...
}
В этой версии мы опираемся на то, что copyFileChunked уже корректно закрывает потоки через use. И да, File.length() — это простой первичный контроль: файл скопировался и имеет какой‑то размер. Но «какой‑то размер» — не гарантия корректности данных, это просто быстрый сигнал «мы точно что-то записали».
Быстрая проверка: размер и contentEquals
Очень хочется после копирования сказать «готово» и уйти пить чай. Но иногда полезно хотя бы минимально убедиться, что копия похожа на оригинал.
Самый быстрый и дешёвый тест — сравнить размеры:
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("out.bin")
println(src.length() == dst.length()) // true/false
}
Но одинаковый размер не доказывает, что байты совпадают: два разных файла вполне могут иметь один и тот же размер. Если файл маленький, можно сравнить содержимое в памяти. Главное — не пытаться так делать для гигабайтных файлов.
Сравнение массивов в Kotlin делается через contentEquals, а не через ==.
import java.io.File
fun main() {
val a = File("in.bin").readBytes()
val b = File("out.bin").readBytes()
println(a.contentEquals(b)) // true/false
}
Эта проверка «байт‑в‑байт», но ценой того, что оба файла загружаются в память. Поэтому она хороша как тест на небольших примерах и в учебных сценариях, но не как универсальная стратегия «для всего на свете».
7. Размер буфера и схема потока данных
Размер буфера: разумные значения
Про размер буфера очень любят спрашивать так, будто есть один «правильный размер на все времена». Спойлер: нет. Есть разумные значения, которые обычно работают хорошо, и есть крайности, которые обычно не дают пользы.
Почему часто выбирают 8 * 1024 (8 КБ)? Потому что это хороший компромисс: достаточно большой chunk, чтобы не делать слишком много итераций, и достаточно маленький, чтобы не раздувать память и не усложнять работу с кешами. В реальности часто встречаются 4 КБ, 8 КБ, 16 КБ, 64 КБ — все эти значения могут быть нормальными.
Если сделать буфер слишком маленьким (например, 1 байт или 16 байт), вы проиграете по скорости из-за количества read/write. Если сделать буфер слишком большим (например, десятки мегабайт), вы начнёте тратить память, а выигрыша можете не получить: всё упрётся в скорость диска/файловой системы, плюс гигантский массив не так дружелюбен к кешам и сборке мусора.
Полезная привычка: оформить размер буфера как константу, чтобы он был «в одном месте» и нормально читался.
private const val DEFAULT_BUFFER_SIZE_BYTES = 8 * 1024
fun makeBuffer(): ByteArray = ByteArray(DEFAULT_BUFFER_SIZE_BYTES)
И дальше использовать makeBuffer() в копировании. Код станет чуть длиннее, зато смысл «почему 8192» станет очевиднее.
Схема потока данных в chunk‑копировании
Чтобы в голове не было ощущения «я просто переписал шаблон и молюсь», полезно один раз увидеть картинку процесса.
flowchart TD
A[Файл-источник] --> B[FileInputStream]
B --> C[BufferedInputStream]
C --> D[ByteArray buffer]
D --> E[BufferedOutputStream]
E --> F[FileOutputStream]
F --> G[Файл-приёмник]
Важная мысль: ByteArray buffer — это не «архив в памяти», это просто рабочая область для одной итерации цикла. Мы читаем chunk, записываем chunk, и переиспользуем тот же массив снова и снова.
8. Типичные ошибки при chunk‑копировании
Ошибка №1: игнорировать n и писать весь буфер.
Это самая частая проблема: read(buffer) прочитал 137 байт, а вы записали 8192. В результате в конец файла прилетает «хвост» из старых значений буфера. Ошибка особенно коварна тем, что на некоторых файлах она не проявится, если размер файла кратен буферу, а на других сломает копию гарантированно.
Ошибка №2: считать, что одного read(buffer) достаточно для копирования.
Потоки не обязаны отдавать весь файл за один вызов read. Даже если файл маленький, корректная модель — «читаем порцию, пока не пришёл EOF». Один read — это максимум «прочитал начало».
Ошибка №3: путать «наш буфер» и BufferedInputStream.
Иногда новички думают, что если они обернули поток в BufferedInputStream, то ByteArray уже не нужен. Но BufferedInputStream не отменяет вашу задачу читать кусками и писать кусками. Он лишь делает это эффективнее внутри, уменьшая количество обращений к базовому потоку.
Ошибка №4: не закрывать потоки (или закрывать «как получится»).
Файловые потоки — это ресурсы, и закрывать их нужно гарантированно. В Kotlin для этого есть use {}: внутри блока вы работаете с ресурсом, а после выхода он закрывается автоматически, даже если случится исключение. Если потоки не закрывать, вы можете получить незаписанные данные, утечки дескрипторов и очень странные «иногда работает, иногда нет».
Ошибка №5: проверять равенство массивов через ==.
Если вы делаете тесты и хотите сравнить два ByteArray, используйте contentEquals. В Kotlin == для массивов проверяет не содержимое, а «это один и тот же объект или нет». Для сравнения содержимого как раз и существует contentEquals.
Ошибка №6: пытаться проверять корректность копии через «посмотрел в блокноте».
Бинарный файл в текстовом редакторе почти всегда выглядит как набор кракозябр, и это нормально. Визуальный контроль «открыл, вроде похоже» тут не работает. Для маленьких файлов используйте contentEquals, для больших хотя бы сравнение размеров, а ещё лучше — отдельные стратегии проверки (но это уже отдельная инженерная тема, и сегодня мы в неё не уходим).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ