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("size=${bytes.size}")   // size=4
    println("first=${bytes[0]}")    // first=1
}

ByteArray поддерживает обычную индексацию (индексы с нуля) — это общее правило для массивов в Kotlin.

Теперь пример с «пустым» массивом нужного размера — это уже похоже на буфер, который мы скоро будем использовать при работе с файлами:

fun main() {
    val buffer = ByteArray(8 * 1024) // 8 KB

    buffer[0] = 10
    buffer[1] = 20

    println("bufferSize=${buffer.size}")  // bufferSize=8192
    println("first=${buffer[0]}")         // first=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("chunk: ${source.copyOfRange(i, end).contentToString()}")
        i += chunkSize
    }
    // chunk: [10, 20, 30]
    // chunk: [40, 50, 60]
    // chunk: [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 expenses: $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 = "Hello, bytes!"
    val bytes = original.encodeToByteArray()
    val restored = bytes.decodeToString()

    println(original)  // Hello, bytes!
    println(restored)  // Hello, bytes!
}

Очень аккуратная мысль: не любые байты являются текстом. Эти байты — текст, потому что мы сами их так сделали. Если бы это были байты 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().
Преобразование «байты ↔ строка» имеет смысл только тогда, когда байты действительно являются закодированным текстом. Для картинок, архивов и прочих бинарных форматов это действие не добавляет смысла — только шум.

1
Задача
Kotlin SELF, 43 уровень, 0 лекция
Недоступна
Четыре байта
Четыре байта
1
Задача
Kotlin SELF, 43 уровень, 0 лекция
Недоступна
Странный байт
Странный байт
1
Задача
Kotlin SELF, 43 уровень, 0 лекция
Недоступна
Текст в байтах
Текст в байтах
1
Задача
Kotlin SELF, 43 уровень, 0 лекция
Недоступна
Два сравнения
Два сравнения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ