JavaRush /Курси /Kotlin SELF /Як мислити байтами й навіщо потрібен ByteArray

Як мислити байтами й навіщо потрібен ByteArray

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

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().
Перетворення «байти ↔ рядок» має сенс лише тоді, коли байти справді є закодованим текстом. Для картинок, архівів та інших бінарних форматів ця дія не додає сенсу — лише шум.

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