1. Файл на диску — це байти
Сьогодні ми зробимо важливий «зсув мислення»: перестанемо сприймати файл як «рядок на диску» й почнемо бачити в ньому те, чим він є насправді, — послідовність байтів. Звучить сухо, але на практиці це заощаджує години болю, коли ви вперше намагаєтеся «прочитати картинку як текст» і отримуєте… ну, скажімо так, поезію машинного походження.
Коли ви звикли до readText(), легко повірити, що файл «складається з літер». Насправді файл на диску складається з байтів — маленьких чисел, записаних одне за одним. «Текст» зʼявляється лише тоді, коли ми домовляємося, як інтерпретувати ці байти як символи. І саме ця домовленість — головна причина, чому один і той самий набір байтів іноді перетворюється на нормальний текст, а іноді — на «кракозябри».
Уявіть просту схему:
flowchart LR
A[Файл на диску] --> B[Байти: 0..255]
B --> C{Інтерпретуємо як текст?}
C -- так --> D[Декодування за правилами кодування]
D --> E[String: символи]
C -- ні --> F[Бінарні дані: картинка/архів/аудіо...]
Головна думка проста: рядок — це інтерпретація, а байти — це реальність.
2. Текст і байти: коли readText() не підходить
У попередніх лекціях ми читали файли як текст — і все було пристойно: ви читаєте рядки, обрізаєте пробіли, парсите числа — краса. Але щойно ви берете файл, який за змістом не є текстом (наприклад, PNG або ZIP), спроба «прочитати його рядком» перетворюється на беззмістовну кашу. І це не «помилка Kotlin». Це помилка нашої ідеї: ми застосували текстову лінзу до даних, у яких тексту немає.
Зручно тримати в голові просте порівняння:
| Що у нас у руках | Що лежить у файлі на диску | Чим читати | Чому |
|---|---|---|---|
| .txt, .csv, .json (зазвичай) | байти, що є закодованими символами | readText(), readLines() | ми хочемо отримати символи |
| .png, .zip, .pdf, .mp3 | байти, що є структурою формату | readBytes(), потоки | ми хочемо отримати дані як є |
У Kotlin/JVM є окрема категорія масивів для примітивів (як-от IntArray, DoubleArray і наш герой ByteArray). Це не «звичайний Array<Byte>», а спеціальний і ефективніший тип.
3. ByteArray — контейнер для «сирих» даних
Якщо досі ви жили у світі String, то ByteArray — це вхід у світ «даних без інтерпретації». І гарна новина: за поведінкою він дуже схожий на масиви, які ви вже проходили. У нього є size, доступ за індексом через [], і він чудово працює в циклах. Погана новина: він не намагається бути зручним «текстом», тож із ним треба думати трохи уважніше (але ми впораємося: ми ж уже пережили null і try/catch — нас так просто не налякати).
Почнемо з найпростішого: створимо масив байтів і подивимося, що всередині.
fun main() {
val bytes = byteArrayOf(1, 2, 3, 4)
println("розмір=${bytes.size}") // розмір=4
println("перший=${bytes[0]}") // перший=1
}
ByteArray підтримує звичайну індексацію (індекси з нуля) — це загальне правило для масивів у Kotlin.
А тепер приклад із «порожнім» масивом потрібного розміру. Це вже схоже на буфер, який ми скоро використовуватимемо під час роботи з файлами:
fun main() {
val buffer = ByteArray(8 * 1024) // 8 KB
buffer[0] = 10
buffer[1] = 20
println("розмірБуфера=${buffer.size}") // розмірБуфера=8192
println("перший=${buffer[0]}") // перший=10
}
Зверніть увагу на підхід: ми створюємо масив один раз, а потім змінюємо його вміст. Це важлива звичка для I/O: повторне використання буфера часто краще, ніж створення нових масивів для кожної дрібниці.
4. Корисні нюанси: Byte, буфер і порівняння масивів
Тип Byte у Kotlin: він «маленький і трохи буркотливий»
Досі в нас були Int і Long, які поводяться «очікувано»: великі числа — і все добре. Byte — це маленьке ціле число розміром 1 байт, і в Kotlin воно знакове: діапазон -128..127. Це іноді дивує новачків, бо «байт у файлі» часто уявляють як 0..255. Але в Kotlin тип Byte — саме такий.
Подивімося на приклад:
fun main() {
val a: Byte = 120
val b: Byte = 127
println("a=$a") // a=120
println("b=$b") // b=127
}
А тепер — обережно: демонстрація того, «чому не можна просто взяти й запхати 200 у Byte»:
fun main() {
val x = 200
val asByte: Byte = x.toByte()
println("x=$x") // x=200
println("asByte=$asByte") // asByte=-56
}
Це не баг: так працює перетворення. Річ у тім, що Byte фізично не може зберігати 200 у своєму діапазоні, і відбувається «обрізання» значення до того, що вміщується у 8 біт. Поки що вам не потрібно глибоко занурюватися в двійкову арифметику — важливо просто запамʼятати практичне правило: байт — маленький, і під час перетворень дані можуть «загубитися».
Буфер: ByteArray, який працює як «відро»
Слово «буфер» звучить так, ніби це щось із системного програмування, доступне лише тим, хто пʼє каву без цукру й пише драйвери. Насправді буфер — це просто масив фіксованого розміру, який ми використовуємо як «відро»: зачерпнули порцію даних, обробили, зачерпнули наступну.
Зараз ми ще не торкаємося файлових потоків, але можемо потренувати саму ідею «порціями» на звичайному масиві: зімітуємо копіювання даних шматками.
fun main() {
val source = byteArrayOf(10, 20, 30, 40, 50, 60, 70)
val chunkSize = 3
var i = 0
while (i < source.size) {
val end = minOf(i + chunkSize, source.size)
println("шматок: ${source.copyOfRange(i, end).contentToString()}")
i += chunkSize
}
// шматок: [10, 20, 30]
// шматок: [40, 50, 60]
// шматок: [70]
}
Тут важлива не функція copyOfRange сама по собі, а ідея: останній шматок майже завжди менший за звичайний розмір порції. Саме це потім стане критичним під час читання файлів: остання порція даних рідко буває рівно 8 KB або 16 KB — вона «обрізана» кінцем файла.
Порівняння ByteArray: чому == порівнює не те
Є класичний момент, у якому новачки (а інколи й досвідчені люди в понеділок зранку) потрапляють у пастку: порівняння масивів. Інтуїтивно хочеться зробити так: «якщо два масиви байтів однакові, то a == b має бути true». Але масиви мають особливість: оператор == для них часто означає порівняння посилань, а не вмісту. Тобто «це один і той самий обʼєкт?» замість «це однакові дані?».
Для масивів даних нам майже завжди потрібне порівняння за вмістом — і для цього є contentEquals(...).
fun main() {
val a = byteArrayOf(1, 2, 3)
val b = byteArrayOf(1, 2, 3)
println(a == b) // false (зазвичай)
println(a.contentEquals(b)) // true
}
Практично це надважливо для файлової теми: якщо ви робите «копію файла», то перевірка «копія дорівнює оригіналу» — це саме перевірка байт у байт, а не «чи посилаються вони на один обʼєкт».
5. Міні-оновлення практичного застосунку: текст → байти
Ми намагаємося розвивати один застосунок поступово. До цього моменту ми впевнено зберігали дані як текст: будували рядки звіту й записували їх у файл. Сьогодні додамо маленьку, але концептуально важливу сходинку: навчимося отримувати байтове подання наших даних. Це підготовка до бінарної роботи (копіювання, архівування тощо), але без занурення в наступні теми.
Припустімо, у нас є функція, яка будує текст звіту (у попередні дні ви могли писати її через StringBuilder або просто конкатенацією). Тепер зробімо функцію, яка повертає вже ByteArray.
fun reportText(total: Int): String {
return "Загальні витрати: $total"
}
fun reportBytes(total: Int): ByteArray {
val text = reportText(total)
return text.encodeToByteArray()
}
fun main() {
val bytes = reportBytes(total = 1200)
println("bytes=${bytes.size}") // bytes=20 (може відрізнятися)
}
Що тут важливо зрозуміти на рівні сьогоднішньої лекції:
String і ByteArray — це різні «форми» даних. Рядок — це символи (у памʼяті мови), а ByteArray — це конкретні байти, які вже можна записувати «як є» або передавати в низькорівневі API. Функції encodeToByteArray() і зворотна decodeToString() дають місток між цими світами.
І покажімо зворотне перетворення — суто як демонстрацію того, що байти можуть бути текстом, якщо ви точно знаєте, що це байти тексту:
fun main() {
val original = "Привіт, байти!"
val bytes = original.encodeToByteArray()
val restored = bytes.decodeToString()
println(original) // Привіт, байти!
println(restored) // Привіт, байти!
}
Дуже важлива думка: не будь-які байти є текстом. Ці байти — текст, бо ми самі так їх зробили. Якби це були байти PNG-картинки, decodeToString() був би приблизно як спроба прочитати штрихкод очима без сканера: формально можна, але сенс вислизає.
6. Ланцюжок на майбутнє: «дані → байти → зберігання»
Щоб далі не плутатися (коли зʼявляться реальні файли, потоки й буферизація), корисно заздалегідь зафіксувати такий мисленнєвий ланцюжок:
flowchart TD
A[Дані застосунку: числа, рядки, структури] --> B[String-звіт / формат]
B --> C[ByteArray: байти]
C --> D["Файл/архів/копія (пізніше)"]
Сьогодні ми впевнено стоїмо на кроці ByteArray. Наступні лекції дня навчатимуть, як ці байти правильно читати й записувати у файл та переносити шматками. А поки наша ціль — перестати плутати текст і бінарні дані та спокійно жити з ідеєю «байти — це нормально».
7. Типові помилки: як упізнавати за симптомами
Помилка №1: читати бінарний файл через readText() «просто щоб подивитися».
Це майже завжди закінчується «сміттям» у консолі й хибним відчуттям, що файл пошкоджений. Насправді пошкоджена лише наша інтерпретація. Якщо файл за змістом не текстовий, базова модель має бути «байти», тобто ByteArray, а не String.
Помилка №2: думати, що Byte зберігає діапазон 0..255.
У Kotlin Byte знаковий, тож під час перетворень на кшталт 200.toByte() ви отримаєте відʼємне число. Це не «зламалося» — це «не влізло». Лікується не панікою, а акуратним розумінням типів: Byte маленький, і дані можуть губитися.
Помилка №3: порівнювати масиви байтів через == і дивуватися.
Коли ви порівнюєте два набори даних (особливо якщо далі перевірятимете, чи «копія файла збігається з оригіналом»), вам потрібне порівняння вмісту: contentEquals. Інакше ви порівнюєте не дані, а «чи це один і той самий обʼєкт», що майже ніколи не є тим, чого ви хотіли.
Помилка №4: створювати нові масиви на кожному кроці, замість того щоб повторно використовувати буфер.
Навіть без файлів видно, що робота «порціями» майже завжди передбачає повторне використання одного й того самого контейнера (буфера). Якщо в майбутньому ви почнете читати великі дані й постійно створюватимете нові ByteArray, ви просто влаштуєте своїй програмі фітнес зі збирання сміття (GC), а собі — пригоду з профілювання.
Помилка №5: намагатися перетворювати будь-які байти назад у рядок через decodeToString().
Перетворення «байти ↔ рядок» має сенс лише тоді, коли байти справді є закодованим текстом. Для картинок, архівів та інших бінарних форматів ця дія не додає сенсу — лише шум.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ