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