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 «точно»
Перевірка результату — це не параноя, а нормальна інженерна звичка. Особливо з файлами: копіювання може обірватися, файл може бути зайнятий, на диску може закінчитися місце, а ви можете випадково переплутати шляхи (і з радістю перезаписати не те).
Тут зручно тримати в голові два рівні контролю:
| Перевірка | Що доводить | Плюси | Мінуси |
|---|---|---|---|
|
«Розмір збігся» | Швидко, не навантажує памʼять | Вміст може відрізнятися |
|
«Байт у байт однаково» | Надійно | Обидва файли опиняться в памʼяті |
Перевірка за розміром
Почнемо з простого: після копіювання порівняймо розміри.
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() перед записом, щоб не ловити помилки на рівному місці.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ