JavaRush /Курси /Kotlin SELF /Робота з байтами: readBytes...

Робота з байтами: readBytes() / writeBytes()

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

1. Вступ

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

Саме для таких сценаріїв у Kotlin/JVM є дуже зручна пара: File.readBytes() і File.writeBytes(bytes). Вони роблять рівно те, що обіцяють назвою: читають і записують байти «як є». По суті, це найкоротший шлях від «у мене є бінарний файл» до «у мене є його копія».

Чому це виглядає як «методи» у File

Коли ви вперше бачите код на кшталт File("a.bin").readBytes(), легко подумати: «О, значить, у класу File є метод readBytes()». А потім виникає друге питання: «Чому тоді в Java-класу File раптом зʼявилися такі Kotlin-методи?».

Відповідь дуже по-котлінівськи елегантна: це функції-розширення.

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() як швидкий індикатор розміру файлу на диску (у байтах). Це не стовідсотковий доказ, що вміст однаковий, але для «першої перевірки» підходить чудово.

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 «точно»

Перевірка результату — це не параноя, а нормальна інженерна звичка. Особливо з файлами: копіювання може обірватися, файл може бути зайнятий, на диску може закінчитися місце, а ви можете випадково переплутати шляхи (і з радістю перезаписати не те).

Тут зручно тримати в голові два рівні контролю:

Перевірка Що доводить Плюси Мінуси
src.length() == dst.length()
«Розмір збігся» Швидко, не навантажує памʼять Вміст може відрізнятися
src.readBytes().contentEquals(dst.readBytes())
«Байт у байт однаково» Надійно Обидва файли опиняться в памʼяті

Перевірка за розміром

Почнемо з простого: після копіювання порівняймо розміри.

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.length()} bytes") // Файл занадто великий: ...
        return
    }

    val bytes = file.readBytes()
    println("Завантажено ${bytes.size} bytes") // Завантажено ...
}

Це не «ідеальна стратегія», але це чесний і зрозумілий барʼєр: ви явно кажете коду, який розмір готові тягнути цілком.

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("Шлях до вихідного файла: ")
    val src = File(readln().trim())

    print("Шлях до файла призначення: ")
    val dst = File(readln().trim())

    print("Режим перевірки (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() перед записом, щоб не ловити помилки на рівному місці.

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