JavaRush /Курсы /Kotlin SELF /try/catch вокруг файловых операций

try/catch вокруг файловых операций

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

1. Почему try/catch нужен даже после предпроверок

Когда вы впервые делаете предпроверки, возникает ощущение: «Ну всё, я же проверил exists() и canRead() — значит, можно читать!» И вот тут Kotlin (и ОС) мягко напоминают: «ха-ха, нет». Предпроверки говорят лишь, что в момент проверки всё выглядело прилично. А вот реальная операция readText() — это уже настоящая попытка прочитать данные, и она всё равно может закончиться исключением.

Представим простую ситуацию: вы проверили exists() == true, а через миллисекунду файл удалили (или переименовали) другой процесс/пользователь. Это не фантастика — это нормальная жизнь на компьютере.

import java.io.File

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

    if (file.exists()) {
        // Между exists() и readText() файл может пропасть.
        val text = file.readText()
        println("OK: ${text.length}") // если повезло
    }
}

Если не повезло — получите исключение уже на чтении. И это нормально: try/catch — не замена предпроверкам, а второй барьер, который обязан стоять вокруг реального I/O.

2. Узкий try: внутрь — только реальный I/O

Самая частая ошибка новичка выглядит так: «оберну всё в один большой try, чтобы точно не упало». Это как надеть бронежилет на холодильник: тяжело, неудобно, и холодильнику всё равно. Большой try делает код мутным: непонятно, что именно могло сломаться, и в catch вы часто пытаетесь «как-нибудь продолжить», не понимая, в каком состоянии данные.

Антипример: слишком широкий try.

import java.io.File
import java.io.IOException

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

    try {
        val text = file.readText()
        val amount = text.trim().toInt() // это уже НЕ I/O, это формат данных
        println("amount=$amount")
    } catch (e: IOException) {
        println("Не удалось прочитать файл")
    }
}

Проблема здесь тонкая: toInt() может бросить NumberFormatException, и он не является IOException. То есть программа всё равно упадёт, но уже «как-то странно»: вы же «всё обернули». А обернули не всё — только I/O‑часть.

Правильнее разделить ответственность: чтение файла — это I/O и ловится как IOException, а разбор текста — это уже «ошибка данных/формата» и должна жить отдельно (даже если сегодня мы фокусируемся именно на I/O).

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? =
    try {
        file.readText()
    } catch (e: IOException) {
        null
    }

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

    val text = readTextOrNull(file) ?: run {
        println("Файл не прочитан: ${file.path}")
        return
    }

    val amount = text.trim().toIntOrNull()
    println("amount=$amount") // amount=123 или amount=null
}

Обратите внимание на ощущение кода: чтение отделено, try узкий, а дальше вы уже спокойно решаете, что делать с содержимым.

Небольшая «карта» ответственности полезна даже в консольных проектах:

flowchart TD
    A[CLI / main] --> B["loadRawText()"]
    B -->|I/O: readText| C[(File)]
    B -->|String? / ошибка| A
    A --> D[parse / validate]
    D -->|ошибка формата| A

3. try как выражение: возвращаем значение, а не «разваливаемся»

Kotlin умеет то, что делает обработку ошибок заметно приятнее: try/catch — это выражение, то есть оно может вернуть значение. Это позволяет писать функции с понятным контрактом: «если получилось — верну результат, если нет — верну запасной вариант». В Kotlin такой стиль считается идиоматичным: try возвращает одно значение, catch — другое.

Простейший вариант для чтения файла: «текст или null».

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    null
}

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

    val text = readTextOrNull(file)
    println(text?.take(20) ?: "NO DATA") // например: NO DATA
}

Точно так же для записи можно выбрать контракт «успех/неуспех».

import java.io.File
import java.io.IOException

fun writeTextSafely(file: File, text: String): Boolean = try {
    file.writeText(text)
    true
} catch (e: IOException) {
    false
}

fun main() {
    val ok = writeTextSafely(File("data/out/report.txt"), "Hello!\n")
    println("written=$ok") // written=true или written=false
}

Эта идея кажется простой, но она дисциплинирует программу: вместо того чтобы надеяться «ну оно не упадёт», вы заранее проектируете: что вернём при проблемах.

4. Контракт функции при I/O: T?, Boolean или проброс исключения

Когда вы ставите try/catch, вы всегда принимаете решение: что увидит вызывающий код? Для учебных консольных приложений обычно хватает трёх контрактов: вернуть T?, вернуть Boolean, либо не ловить исключение здесь и дать ему уйти выше (иногда это тоже нормальная стратегия).

Хорошо держать это сравнение в голове как таблицу:

Контракт функции Как выглядит Когда удобно Цена ошибки
T?
String?, List<String>?
Когда отсутствие результата — нормальная ветка логики Нужно не забыть обработать
null
Boolean
true/false
Когда данные не нужны, важно только «получилось ли» Диагностика слабее: почему
false
?
Пробросить исключение без try/catch (или throw дальше) Когда без файла программа не имеет смысла Программа может упасть, если выше не обработали

Важно: мы не выбираем «правильно раз и навсегда». Мы выбираем, что удобнее в вашем приложении и в этой точке кода.

Например, в нашем учебном консольном приложении пусть будет простая «копилка расходов»: мы храним расходы в текстовом файле и показываем короткий отчёт. Если файл не прочитался, можно показать понятное сообщение и стартовать с пустого списка — это отличный кандидат на контракт List<String>?.

import java.io.File
import java.io.IOException

fun readLinesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun main() {
    val file = File("data/expenses.txt")
    val lines = readLinesOrNull(file) ?: emptyList()

    println("loaded=${lines.size}") // loaded=0, если файла нет/не читается
}

А вот запись отчёта — можно сделать Boolean: не записали — просто сообщим.

5. Сообщения об ошибках: пользователю и вам

Сообщение об ошибке — это не художественное произведение и не «что-то пошло не так». Хорошее сообщение отвечает минимум на три вопроса: что хотели сделать, с чем именно (путь), и что за тип проблемы. Даже без системы логирования это уже сильно помогает.

Здесь полезно завести маленький форматтер сообщений для I/O. Он не должен быть умным — он должен быть стабильным и одинаковым по всему приложению.

import java.io.File
import java.io.IOException

fun ioErrorMessage(action: String, file: File, e: IOException): String {
    val type = e::class.simpleName ?: "IOException"
    val detail = e.message ?: "no details"
    return "Не удалось $action: ${file.path}. Причина: $type: $detail"
}

И использовать его в catch:

import java.io.File
import java.io.IOException

fun readTextOrNullVerbose(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    println(ioErrorMessage("прочитать файл", file, e))
    null
}

fun main() {
    val text = readTextOrNullVerbose(File("data/input.txt"))
    println(text?.length ?: "no text") // no text
}

Да, мы печатаем прямо в консоль — и это нормально для учебного CLI. В более серьёзных проектах вы бы делили «сообщение пользователю» и «диагностику для логов», но мы сознательно не уходим сегодня в логирование.

Несколько catch: порядок и смысл разделения

Иногда хочется ловить всё одним catch (IOException). Это нормально, но часто полезно различать хотя бы один частный случай: «файл не найден/не открывается», то есть FileNotFoundException. Тогда вы можете дать более человеческий текст: «проверь путь» вместо «ой, I/O».

Правило в Kotlin (и вообще на JVM): сначала ловим более конкретное, потом более общее, иначе конкретное никогда не сработает.

import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

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

    try {
        val text = file.readText()
        println("OK: ${text.length}") // OK: ...
    } catch (e: FileNotFoundException) {
        println("Файл не найден или не открывается: ${file.path}")
    } catch (e: IOException) {
        println("Ошибка ввода/вывода при чтении: ${file.path}")
        println("Детали: ${e.message}")
    }
}

Здесь важно не переусложнить: мы разделили только самый частый кейс, а всё остальное оставили под IOException.

6. Где ставить try/catch: граница ответственности

Очень хочется ловить исключения в main, потому что «там всё видно». Но по мере роста приложения это превращается в огромный комбайн: main и читает ввод, и парсит команды, и работает с данными, и ещё ловит все возможные проблемы. Хороший стиль — ловить I/O там, где оно происходит, и возвращать наружу понятный результат.

Для нашего трекера расходов это означает: функции «хранилища» сами оборачивают readText()/writeText() в try/catch, а main получает либо данные, либо сигнал «не получилось».

Схема получается простая:

flowchart TD
    M[main: команды пользователя] --> S[storage: read/write file]
    S -->|List⟨String⟩? / Boolean| M
    S -->|IOException внутри| S

Пример мини-слоя хранения (без «магии», всё коротко и явно):

import java.io.File
import java.io.IOException

fun loadExpensesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun saveExpenses(file: File, lines: List<String>): Boolean = try {
    file.writeText(lines.joinToString(separator = "\n"))
    true
} catch (e: IOException) {
    false
}

И использование в main без ощущения, что вы управляете атомной станцией:

import java.io.File

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

    val lines = loadExpensesOrNull(file)
    if (lines == null) {
        println("Не удалось загрузить расходы, стартуем с пустого списка.")
    } else {
        println("Загружено записей: ${lines.size}") // например: Загружено записей: 12
    }
}

Психологически это важная штука: main не обязан знать, как именно ОС отказала в чтении. main обязан принять решение: продолжать с пустыми данными, попросить пользователя исправить путь, завершиться — и так далее.

7. finally и use {}: когда они нужны

Слово finally часто звучит как «секретная техника джедаев». На самом деле оно нужно ровно для одного: гарантировать, что некоторый кусок кода выполнится в конце, даже если внутри try случилась ошибка. В файловых задачах это особенно важно для закрытия ресурсов, если вы открываете потоки/ридеры вручную.

Но Kotlin даёт ещё более удобный стиль: use {}, который автоматически закрывает ресурс (примерно как finally, но аккуратнее). Это считается идиоматичным подходом для AutoCloseable.

Мы не будем сегодня углубляться в потоки, но маленький пример полезен, чтобы увидеть границу: readText() не требует use, а bufferedReader() — уже да.

import java.io.File
import java.io.IOException

fun readFirstLineOrNull(file: File): String? = try {
    file.bufferedReader().use { reader ->
        reader.readLine()
    }
} catch (e: IOException) {
    null
}

fun main() {
    val first = readFirstLineOrNull(File("data/input.txt"))
    println(first ?: "<empty>") // например: <empty>
}

Запомните простое правило: если вы видите ...Reader(), ...Writer(), InputStream, OutputStream — почти всегда рядом должен быть use {} (или, как минимум, finally). Если вы используете «готовые» функции вроде readText()/writeText(), они сами внутри открывают и закрывают ресурсы, и вам не нужно вручную писать finally.

Небольшой нюанс Kotlin 2.x: компилятор стал лучше отслеживать smart-cast информацию через catch/finally, так что в некоторых сценариях он аккуратнее подсказывает, где переменная снова становится nullable. Это относится к улучшениям обработки исключений в K2.

8. Типичные ошибки при try/catch вокруг файловых операций

Ошибка №1: огромный try, внутри которого и чтение файла, и разбор данных, и половина бизнес-логики.
Такой код неприятен тем, что вы теряете понимание причины сбоя: это I/O, это формат, это ваша логика? Плюс в catch появляется соблазн «доделать что-нибудь», хотя данные могли не загрузиться. Лечится просто: выносите I/O в маленькие функции и держите try узким — вокруг readText()/writeText() и близких операций.

Ошибка №2: «глушить» исключение молча.
Возвратить null или false — это нормально, но если вы совсем ничего не сообщаете, то при реальной проблеме вы будете отлаживать «почему пусто» вместо «почему не прочиталось». Даже в учебном CLI полезно хотя бы один раз вывести: действие, путь, e.message. Иначе диагностика превращается в гадание.

Ошибка №3: ловить Exception «на всякий случай».
Это выглядит как забота о стабильности, но чаще это заметающий под ковёр стиль: вы начинаете путать I/O‑ошибки, ошибки формата и собственные баги. В результате программа «как бы работает», но делает странные вещи. Для файлов держите фокус на IOException (и иногда FileNotFoundException как частный случай), а остальные ошибки не прячьте без причины.

Ошибка №4: возвращать Boolean, но не давать никакой информации «почему false».
Boolean удобен, когда вам важен только факт успеха. Но если вам всё-таки иногда нужно понять причину, можно (в рамках сегодняшнего дня) хотя бы печатать сообщение внутри catch, либо возвращать null/String? с текстом ошибки. Главное — не создавать API, где false означает десять разных причин, и вы нигде не можете узнать, какую именно.

Ошибка №5: неправильный порядок catch.
Если сначала написать catch (e: IOException), а потом catch (e: FileNotFoundException), то второй блок станет недостижимым, потому что FileNotFoundException — это частный случай IOException. Порядок должен быть «сначала конкретное, потом общее», как и в классических правилах обработки исключений.

Ошибка №6: продолжать использовать данные после неуспешного чтения.
Это тонкий баг: вы поймали исключение, вывели «не удалось», но переменная осталась, например, пустой строкой, и вы дальше парсите её как будто это содержимое файла. В итоге получаете вторичную ошибку (например, NumberFormatException) и путаетесь, что сломалось на самом деле. Если чтение не удалось — возвращайте null/false и прекращайте этот путь выполнения явно.

Ошибка №7: считать canRead()/canWrite() гарантией.
Эти проверки полезны, но они не обещают успеха, потому что права могут измениться, файл может быть занят, файловая система может «отвалиться», и так далее. Поэтому правильная связка выглядит так: предпроверки улучшают сообщения и ранний отказ, а try/catch страхует реальную операцию.

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