1. Введение
Когда вы только начинаете работать с файлами, хочется простого счастья: «вот есть файл, хочу скопировать». И в этот момент вы узнаёте, что у файлов бывают потоки, буферы, циклы чтения по кускам, EOF и другие прекрасные слова, которыми удобно пугать новичков на Хэллоуин. Но правда в том, что иногда вам не нужен весь этот зоопарк: если файл небольшой, его можно честно взять целиком в память как массив байтов, и так же честно записать в другой файл — без интерпретации, без кодировок, без сложных циклов.
Именно для таких сценариев в Kotlin/JVM есть очень удобная пара: File.readBytes() и File.writeBytes(bytes). Они делают то, что обещают названием: читают и пишут байты «как есть». По сути это самый короткий путь от «у меня есть бинарный файл» до «у меня есть его копия».
Почему это выглядит как «методы» у File
Когда вы впервые видите код вроде File("a.bin").readBytes(), легко подумать: «О, значит, у класса File есть метод readBytes()». А дальше возникает второй вопрос: «Почему тогда у Java-класса File вдруг появились такие Kotlin-методы?».
Ответ очень по‑котлиновски элегантный: это extension-функции (функции-расширения).
Kotlin позволяет «добавлять» функции к типам, даже если вы не владеете их исходным кодом. Снаружи они вызываются так, будто это обычные методы, но на самом деле компилятор подставляет вызов обычной функции. Это базовая идея расширений: «вызываем как член класса, но класс не меняли».
Психологически это удобно: file.readBytes() читается почти как человеческая фраза, а не как «вызвать какую-то утилиту из какого-то пакета».
Минимальный пример: прочитать байты и посмотреть размер
Сначала сделаем максимально маленький и честный пример: прочитать файл целиком и вывести, сколько байтов получилось. Здесь мы даже ничего не копируем — просто убеждаемся, что ByteArray действительно приехал.
import java.io.File
fun main() {
val file = File("in.bin")
val bytes: ByteArray = file.readBytes()
println("bytesCount=${bytes.size}") // bytesCount=...
}
Обратите внимание: bytes.size — это число элементов в ByteArray, то есть количество байтов. Это не «символы», не «строчки», не «буквы». Это «сколько единичек данных лежит в файле».
2. Бинарная копия целиком: «прочитал → записал»
Теперь делаем то, ради чего всё начиналось: «прочитал → записал». Тут важный момент: мы не пытаемся понять, что внутри файла. Мы не превращаем байты в текст и обратно. Мы переносим их как кирпичи: «взял кирпичи из коробки A, переложил в коробку B».
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("copy.bin")
val data = src.readBytes()
dst.writeBytes(data)
println("copied: ${src.length()} -> ${dst.length()} bytes")
// copied: 123 -> 123 bytes
}
Здесь мы используем length() как быстрый индикатор размера файла на диске (в байтах). Это не 100% доказательство, что содержимое одинаковое, но для «первой проверки» подходит отлично.
3. Выносим копирование в copyBinaryWhole(...)
Если писать «копию» прямо в main, то через пару дней ваш main начнёт выглядеть как список дел на отпуск: вроде всё важно, но читать невозможно. Поэтому вынесем копирование в функцию.
Сделаем утилиту для нашего учебного консольного приложения (пусть это будет мини‑утилита FileTool — мы её аккуратно развиваем на лекциях про файлы).
import java.io.File
fun copyBinaryWhole(src: File, dst: File) {
val data = src.readBytes()
dst.parentFile?.mkdirs() // на случай "out/dir/copy.bin"
dst.writeBytes(data)
}
fun main() {
copyBinaryWhole(File("in.bin"), File("out/copy.bin"))
println("Done") // Done
}
Обратите внимание на dst.parentFile?.mkdirs(). Это маленькая забота о будущем: если путь «вложенный», директории могут не существовать. Мы не углубляемся в надёжность I/O и обработку исключений (это отдельная большая тема), но уже привыкаем писать код так, чтобы он не падал «из-за мелочи».
4. Контроль результата: «похоже» vs «точно»
Проверка результата — это не паранойя, а нормальная инженерная привычка. Особенно с файлами: копирование может оборваться, файл может быть занят, диск может закончиться, а вы можете случайно перепутать пути (и с радостью перезаписать не то).
Здесь удобно держать в голове два уровня контроля:
| Проверка | Что доказывает | Плюсы | Минусы |
|---|---|---|---|
|
«Размер совпал» | Быстро, не грузит память | Содержимое может отличаться |
|
«Байт‑в‑байт одинаково» | Надёжно | Оба файла окажутся в памяти |
Проверка по размеру
Начнём с простого: после копирования сравним размеры.
import java.io.File
fun main() {
val src = File("in.bin")
val dst = File("copy.bin")
dst.writeBytes(src.readBytes())
val ok = src.length() == dst.length()
println("sameSize=$ok") // sameSize=true
}
Если sameSize=false, значит копия явно не получилась. Если true, это хороший знак — но не гарантия.
Проверка по содержимому: contentEquals
Чтобы сравнить два массива байт по содержимому, нужно сравнивать именно содержимое, а не «ссылки на объект». Kotlin отдельно предупреждает: для массивов нельзя использовать == как «сравнение содержимого», потому что массивы — это объекты, и == для них не делает того, что многие ожидают. Для этого существует contentEquals().
import java.io.File
fun main() {
val a: ByteArray = File("a.bin").readBytes()
val b: ByteArray = File("b.bin").readBytes()
println(a.contentEquals(b)) // true или false
}
Тут важная мысль: ByteArray — это массив, и он сравнивается «особым способом». Ровно то же правило было для обычных массивов, просто сейчас это особенно полезно, потому что мы работаем с бинарными данными.
5. Почему a == b не подходит для ByteArray
Очень типичная ситуация у новичков: «Я же в Kotlin привык, что == — это сравнение значений. Почему тут оно не работает?». И вот тут Kotlin отвечает: «Потому что массивы — особенные». == проверяет структурное равенство для многих типов, но не для массивов — у массивов есть отдельные функции contentEquals/contentDeepEquals.
Если объяснить на аналогии, то a == b для массивов — это как спросить: «Это тот же самый пакет?» (тот же объект), а contentEquals — это «внутри пакета одинаковые вещи в одинаковом порядке?». Для файлов и байтов нас почти всегда интересует второе.
6. Границы подхода «целиком»: память не резиновая
Сейчас прозвучит банальность, но она полезная: оперативная память конечна. readBytes() читает весь файл целиком в ByteArray, а значит, этот массив должен где-то поместиться. Если вы так прочитаете файл на 2–3 ГБ, то программа может очень быстро познакомиться с исключением OutOfMemoryError (и это знакомство обычно без взаимной симпатии).
Поэтому «чтение целиком» — это инструмент, у которого есть зона комфорта: маленькие и средние файлы. В следующей лекции мы перейдём к потокам и чтению «по кускам», но уже сейчас можно добавить простую защиту: проверять размер до чтения.
import java.io.File
private const val MAX_BYTES: Long = 10L * 1024 * 1024 // 10 MB
fun main() {
val file = File("in.bin")
if (file.length() > MAX_BYTES) {
println("File is too large: ${file.length()} bytes") // File is too large: ...
return
}
val bytes = file.readBytes()
println("Loaded ${bytes.size} bytes") // Loaded ...
}
Это не «идеальная стратегия», но это честный и понятный барьер: вы явно говорите коду, какой размер вы готовы тянуть целиком.
7. Схема: копия целиком + проверка
Чтобы в голове не смешивались шаги, зафиксируем процесс в виде мини‑блок‑схемы. Да, программисты любят схемы, потому что они позволяют один раз подумать, а потом долго делать вид, что всё очевидно.
flowchart TD
A[Есть src файл] --> B{Размер приемлемый?}
B -->|нет| X[Сообщить и выйти]
B -->|да| C["src.readBytes()"]
C --> D["dst.writeBytes(data)"]
D --> E{Проверить результат}
E -->|size| F[src.length == dst.length]
E -->|content| G["data.contentEquals(dst.readBytes())"]
8. FileTool: интерактивная бинарная копия
Сейчас мы соберём маленький «практический» кусок кода, который делает копирование и предлагает вариант проверки. Мы не уходим в аргументы командной строки (это будет в другом дне курса), поэтому сделаем простой интерактивный сценарий через readln().
Модель выбора проверки
Нам нужно как-то представить выбор «проверять размером / проверять содержимым / не проверять». Для этой лекции достаточно строки, но чтобы не разводить магию строк везде, сделаем маленькую функцию нормализации.
fun normalizeMode(input: String): String =
input.trim().lowercase()
Мы уже умеем trim() и lowercase() из прошлых тем про строки, так что здесь всё должно быть знакомо.
Копирование + проверка по размеру
Сначала реализуем вариант, который не требует держать два файла в памяти.
import java.io.File
fun copyWholeWithSizeCheck(src: File, dst: File): Boolean {
dst.writeBytes(src.readBytes())
return src.length() == dst.length()
}
Boolean здесь удобен: функция говорит «успех/неуспех» простым значением. Мы не углубляемся в дизайн ошибок и исключений — наша цель пока сделать понятный базовый сценарий.
Копирование + проверка по содержимому
Теперь «железная проверка»: сравнение байт‑в‑байт через contentEquals, которая и предназначена для сравнения массивов по содержимому.
import java.io.File
fun copyWholeWithContentCheck(src: File, dst: File): Boolean {
val data = src.readBytes()
dst.writeBytes(data)
val copied = dst.readBytes()
return data.contentEquals(copied)
}
Да, тут читается «дважды»: один раз src, второй раз dst. Это нормально для учебного примера, но держим в голове: на больших файлах это может быть дорого.
Собираем main: один сценарий, три режима
Теперь соберём это в один main. Он будет коротким, но «живым»: спросит пути и режим проверки.
import java.io.File
fun main() {
print("Source file path: ")
val src = File(readln().trim())
print("Destination file path: ")
val dst = File(readln().trim())
print("Verify mode (none/size/content): ")
val mode = readln().trim().lowercase()
val ok = when (mode) {
"size" -> copyWholeWithSizeCheck(src, dst)
"content" -> copyWholeWithContentCheck(src, dst)
else -> { dst.writeBytes(src.readBytes()); true }
}
println("OK=$ok") // OK=true (если всё получилось)
}
Тут when используется как выражение, чтобы получить ok. Вы уже проходили when раньше по программе курса, поэтому конструкция должна читаться нормально.
Нюанс: почему в режиме none мы всё равно возвращаем true
Ветка none/else в нашем main делает копирование и говорит «вроде ок». Это осознанно упрощённый вариант: мы не проверяем результат, значит «не знаем», но для сценария «просто скопировать» пользователь именно этого и хотел.
Если вам хочется более честного контракта, можно вернуть не Boolean, а, например, строку статуса («COPIED_WITHOUT_VERIFICATION»). Но это уже начинает тянуть нас в проектирование результата операции, а сегодня наша цель — освоить readBytes/writeBytes и понять границы подхода.
9. Типичные ошибки при работе с readBytes() / writeBytes()
Ошибка №1: читать бинарный файл как текст «по привычке».
После дней с readText() и строками рука сама тянется прочитать всё «как строку», а потом удивляться «кракозябрам». Важно заранее задать себе вопрос: «Это текст, который кто-то будет читать глазами, или это байты (картинка/архив/исполняемый файл)?». Для бинарных данных текстовые методы — почти всегда неправильный инструмент.
Ошибка №2: считать, что src.length() == dst.length() гарантирует одинаковое содержимое.
Одинаковый размер — это хорошая новость, но не доказательство. Два разных файла могут иметь одинаковую длину. Если вам нужно именно «одинаково байт‑в‑байт», придётся сравнивать содержимое через contentEquals() (и помнить, что для массивов нельзя надеяться на ==).
Ошибка №3: сравнивать ByteArray через == и получать странные результаты.
Это классическая ловушка: «в Kotlin же == сравнивает значения!». Но массивы в Kotlin — отдельный случай, для них существуют функции сравнения содержимого.
Ошибка №4: бездумно читать огромные файлы целиком.
readBytes() удобен, но он не волшебный: он загрузит весь файл в память. На больших файлах это превращается в риск по памяти и производительности. Уже на этом шаге полезно привыкнуть проверять размер через length() и ставить ограничение, а для больших файлов использовать потоковый подход (мы к нему перейдём дальше по плану дня).
Ошибка №5: забыть про директории назначения.
Иногда вы пишете в путь вроде out/copy.bin, но папки out ещё нет. writeBytes() не «создаёт мир вокруг», он просто записывает файл. Поэтому либо заранее создавайте директории, либо хотя бы делайте dst.parentFile?.mkdirs() перед записью, чтобы не ловить ошибки на ровном месте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ