JavaRush /Курсы /Kotlin SELF /Стабилизируем поведение программы

Стабилизируем поведение программы

Kotlin SELF
44 уровень , 2 лекция
Открыта

1. Введение

Когда вы пишете программу, вы почти всегда запускаете её на своей машине, со своим редактором, со своими настройками. И в этот момент легко поверить в добрую сказку: «Ну раз у меня файл открылся нормально, значит так будет у всех». Увы, мир шире вашего ноутбука, и там бывают другие ОС, другие настройки локали, другие инструменты, которые создают файлы с неожиданными параметрами. В результате вы получаете классическую ситуацию: у меня работает, а у коллеги вместо “Привет” — непонятный набор символов.

Здесь важно разделить две вещи. Во‑первых, некоторые API (особенно из Java‑мира) при чтении/записи текста могут использовать “кодировку по умолчанию” системы, и она реально может отличаться. Во‑вторых, даже если конкретный Kotlin‑метод имеет разумное значение по умолчанию, оно всё равно остаётся «скрытым договором»: читая код, вы не видите, в каком формате лежит файл. А файл — это внешний контракт, и лучше, чтобы этот контракт был написан прямо в коде, а не “в голове автора”.

Коротко формула такая: явный Charset делает поведение воспроизводимым. Это как val вместо “ну я постараюсь не менять переменную”. Не потому что вы плохой человек, а потому что люди забывают, а код — нет.

2. Charset и Charsets.*: как выбрать кодировку в коде

Если в прошлых лекциях мы обсуждали кодировку как идею («правило преобразования байты ↔ текст»), то сейчас нам нужно приземлиться в конкретные объекты и вызовы. В Kotlin/JVM кодировка чаще всего представлена типом java.nio.charset.Charset, а стандартные варианты удобно брать из Charsets.

Простейшая практическая привычка: вынести кодировку приложения в одну константу и использовать её везде на границе I/O. Так вы не получите хаос «тут UTF‑8, тут забыли, тут кто-то поставил UTF‑16, потому что “так красивее”».

import java.nio.charset.Charset

private val APP_CHARSET: Charset = Charsets.UTF_8

Почему это удобно:

  1. вы один раз выбрали формат файлов и дальше придерживаетесь его,
  2. если когда-нибудь понадобится поменять формат — вы меняете одну строчку (и осознанно принимаете последствия),
  3. код становится самодокументируемым: видно, что файл ожидается в UTF‑8.

Небольшая ремарка про ByteArray: когда мы читаем файл как байты, мы обычно кладём их именно в ByteArray — это «примитивный массив байтов» в Kotlin, без упаковки в объекты. Это тот самый базовый контейнер для сырого содержимого файла.

3. Чтение и запись текста с явным Charset

Когда вы только начинаете, хочется один универсальный рецепт: «скажи, какую одну функцию выучить, и я пойду жить». Хорошая новость: на старте вам реально хватит двух методов — “прочитать весь файл в строку” и “записать строку целиком”. Плохая новость: жизнь разнообразнее, и иногда нужно читать построчно или писать аккуратно через writer. Но начнём с простого.

Представим, что мы развиваем маленькое консольное приложение NotesApp, которое хранит заметки в файле data/notes.txt. На этом курсе мы стараемся держать примеры маленькими, но связанными, поэтому сегодня мы просто стабилизируем слой чтения/записи, чтобы завтра и послезавтра любая логика парсинга не зависела от “магии окружения”.

Читаем весь файл как String (задаём кодировку)

import java.io.File

private val APP_CHARSET = Charsets.UTF_8

fun main() {
    val file = File("data/notes.txt")
    val text = file.readText(APP_CHARSET)

    println("Символов в файле: ${text.length}") // например: Символов в файле: 42
}

Обратите внимание на важную дисциплину: readText(APP_CHARSET) — это не “лишние буквы”. Это часть контракта. Вы тем самым говорите: «мой файл — UTF‑8». И если он не UTF‑8, это уже полезная информация: значит, проблема не в вашем парсинге заметок, а в формате входных данных.

Записываем String в файл (задаём кодировку)

import java.io.File

private val APP_CHARSET = Charsets.UTF_8

fun main() {
    val out = File("data/notes.txt")
    out.parentFile?.mkdirs()

    out.writeText("Первая заметка\nВторая заметка\n", APP_CHARSET)
    println("Записали файл notes.txt") // Записали файл notes.txt
}

Снова тот же принцип: вы не просто записали текст, вы записали его в конкретной кодировке, и это значит, что другой код (или вы через неделю) сможет прочитать файл тем же правилом.

4. Почему кодировка по умолчанию — риск

В этом разделе важно не впасть в религию. Нет цели «запретить любые дефолты» и навсегда изгнать краткость из кода. Цель — убрать скрытые внешние зависимости там, где они реально вредят.

Кодировка по умолчанию опасна тем, что:

она зависит от окружения запуска, а окружение может меняться между вашей машиной, CI, компьютером коллеги и сервером. Ещё она может зависеть от того, как именно файл создавали (редактор, экспорт из Excel, скачивание из веба). Если вы “угадали” кодировку случайно, вы построили программу на песке. Она не сломалась только потому, что вам пока везло.

Проверить, какая кодировка считается «дефолтной» в текущей JVM, можно так:

import java.nio.charset.Charset

fun main() {
    val def = Charset.defaultCharset()
    println("Default charset = $def") // например: Default charset = UTF-8
}

Этот вывод полезен как диагностика, но как стратегия разработки — слабоват. Надёжнее: не полагаться на default там, где читаем/пишем внешние файлы, а передавать Charsets.UTF_8 (или другой заранее выбранный формат).

Когда удобнее читать байты (readBytes) и декодировать вручную

Если файл “обычный и чистый”, readText(charset) — идеальный вариант. Но иногда вам нужно сделать шаг назад и взять под контроль самый первый этап: байты. Обычно это бывает, когда вы хотите:

аккуратно диагностировать содержимое, посмотреть первые байты, понять “что вообще пришло”, либо очистить или нормализовать данные перед тем, как превращать их в строку.

Тут полезна схема (она же чек-лист в голове):

flowchart LR
    A[Файл на диске] --> B[ByteArray]
    B --> C["Декодирование: String(bytes, charset)"]
    C --> D[Обработка строки: replace/trim/split]
    D --> E[Логика приложения]

И код, который прямо отражает эти шаги:

import java.io.File

private val APP_CHARSET = Charsets.UTF_8

fun main() {
    val file = File("data/notes.txt")

    val bytes: ByteArray = file.readBytes()
    val text: String = String(bytes, APP_CHARSET)

    println(text)
}

Обратите внимание: мы осознанно используем ByteArray как контейнер сырых данных. Это стандартный “байтовый” тип в Kotlin, который напрямую соответствует массиву байтов.

5. Потоковое чтение и запись с Charset

Читать файл целиком — удобно, пока файл маленький. Но как только файл становится больше, хочется читать его постепенно: построчно или блоками. Мы пока не уходим в “стриминговое декодирование гигабайтов”, но базовый паттерн полезно знать уже сейчас: bufferedReader(charset) / bufferedWriter(charset) + use {}.

Смысл простой: вы открываете reader/writer, гарантируете закрытие через use, и внутри работаете привычными методами.

Читаем первую непустую строку заметок

import java.io.File

private val APP_CHARSET = Charsets.UTF_8

fun main() {
    val file = File("data/notes.txt")

    val firstNonEmpty = file.bufferedReader(APP_CHARSET).use { reader ->
        reader.lineSequence()
            .map { it.trim() }
            .firstOrNull { it.isNotEmpty() }
    }

    println("Первая непустая строка: $firstNonEmpty")
}

Да, тут есть Sequence, но воспринимайте это как “ленивый список строк”: мы не грузим весь файл в память, а берём строки по мере необходимости.

Дописываем заметку в конец файла

import java.io.File

private val APP_CHARSET = Charsets.UTF_8

fun main() {
    val file = File("data/notes.txt")
    file.parentFile?.mkdirs()

    file.appendText("Новая заметка\n", APP_CHARSET)
    println("Добавили строку в notes.txt") // Добавили строку в notes.txt
}

Ключевой момент: кодировка должна совпадать у всех операций с одним и тем же файлом. Если вы один раз записали файл в UTF‑8, а потом дописали в другой кодировке, вы создадите “слоёный пирог”, который будет очень весело ломать парсинг.

6. Практические паттерны: перекодирование и мини‑API

Перекодирование файла: два шага без магии

Иногда встречается задача: “у меня файл в одной кодировке, а нужно сохранить в другой”. Важно понимать: перекодирование всегда проходит через строку (то есть через Unicode-представление внутри программы). Это не копирование байтов один-в-один. Это перевод текста по правилам.

Правильная схема:

flowchart LR
    A[Bytes in old charset] -->|decode| B[String]
    B -->|encode| C[Bytes in new charset]

И минимальный пример (без усложнений):

import java.io.File

fun main() {
    val input = File("data/in.txt")
    val output = File("data/out.txt")

    val text = input.readText(Charsets.UTF_8)          // decode
    output.writeText(text, Charsets.UTF_16)            // encode

    println("Готово") // Готово
}

В реальности вы должны знать, какая кодировка у входного файла, иначе вы не перекодируете, а “угадываете”. Угадывание мы сегодня не рассматриваем: это отдельная сложная тема, и она часто заканчивается тем, что программа уверенно делает неправильное.

Мини‑API: одна точка правды про кодировку

Когда проект растёт, самая частая проблема новичка — “вот тут прочитал так, вот тут по-другому, а вот тут вообще забыл”. Лучшее лекарство — сделать маленький слой утилит: функции readAppText(...), writeAppText(...), которые всегда используют одну кодировку и прячут её от остального кода.

И тут нам пригодится хорошая привычка из практики: файлы и функции стоит группировать логично, чтобы код не превращался в свалку. Kotlin‑рекомендации прямо поощряют держать связанные вещи рядом и давать файлам осмысленные имена, вместо “Util.kt” на все случаи жизни.

Сделаем файл TextFiles.kt (имя по делу), а внутри — две функции.

import java.io.File
import java.nio.charset.Charset

private val APP_CHARSET: Charset = Charsets.UTF_8

fun readAppText(file: File): String {
    return file.readText(APP_CHARSET)
}

fun writeAppText(file: File, text: String) {
    file.parentFile?.mkdirs()
    file.writeText(text, APP_CHARSET)
}

Теперь main нашего NotesApp становится спокойнее и чище:

import java.io.File

fun main() {
    val file = File("data/notes.txt")

    val current = if (file.exists()) readAppText(file) else ""
    val updated = current + "Заметка, добавленная программой\n"

    writeAppText(file, updated)
    println("Ок, обновили notes.txt") // Ок, обновили notes.txt
}

Что мы выиграли:

мы отделили “грязный внешний мир файлов” от остальной логики, а ещё сделали кодировку частью инфраструктуры. В результате любая функция, которая “просто работает со строкой”, вообще не обязана знать, что файл — это байты и что там есть UTF‑8. Она работает со строкой — и живёт спокойно.

7. Типичные ошибки при явном задании Charset

Ошибка №1: указать кодировку только в одном месте.
Переход на явный Charset часто начинается с энтузиазма (“сейчас всё исправлю!”), а заканчивается неожиданными багами (“почему стало хуже?”). Самый частый промах — прописать кодировку при чтении, но оставить запись “как было”, или наоборот. Тогда половина файла обрабатывается по одному правилу, половина — по другому, и вы получаете проблемы, которые выглядят как случайные. На практике кодировка должна быть согласована на всех границах “байты ↔ строка”.

Ошибка №2: пытаться «перекодировать строку» без байтов.
Перекодирование — это всегда пара действий: decode encode. Если вы уже получили String, то внутри программы это нормализованный текст в памяти, и у него нет “встроенной кодировки”. Строка не хранит информацию о том, в какой кодировке она была прочитана. Кодировка существует только в момент преобразования между байтами и текстом. Поэтому “менять кодировку строки” без работы с байтами — логическая ошибка.

Ошибка №3: путать количество символов и количество байтов.
String.length возвращает количество символов (точнее, Char), а размер файла — это количество байтов. Для UTF‑8 и UTF‑16 разница может быть существенной: визуально вы видите “6 символов”, но в файле они занимают больше места. Попытки оценить размер файла через text.length почти всегда приводят к неправильным выводам. Если нужен реальный размер, используйте file.length() или считайте bytes.size.

Ошибка №4: использовать String(bytes) без явного указания кодировки.
Конструктор String(bytes) выглядит удобно, и поэтому его легко написать “на автомате”. Но именно такие места чаще всего становятся источником неожиданных проблем, потому что неочевидно, по какому правилу байты преобразуются в текст. Поведение может зависеть от настроек среды или платформы. Гораздо безопаснее писать явно: String(bytes, Charsets.UTF_8). Немного длиннее — зато однозначно и предсказуемо.

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