JavaRush /Курсы /Kotlin SELF /Ошибки I/O: IOException

Ошибки I/O: IOException и FileNotFoundException

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

1. Почему файловые операции ломаются

Когда вы пишете код, очень хочется верить, что компьютер — это послушный калькулятор: сказал «прочитай файл» — он прочитал. На практике компьютер ближе к коту: иногда делает вид, что вас не существует. Файл могли удалить, переместить, закрыть доступ, папку могли заменить на файл (да, так бывает), диск мог закончиться, антивирус мог заблокировать операцию, а на Windows файл может быть «занят» другим процессом.

Ключевая мысль: I/O (input/output) — это зона повышенной неопределённости. В арифметике 2 + 2 почти всегда 4. В файлах «прочитай текст» может означать «попробуй договориться с операционной системой, файловой системой, правами доступа и текущим состоянием диска». И если «договориться» не получилось — вы получаете исключение.

exists() — не гарантия

Самая частая логическая ловушка новичка звучит так: «Я проверил exists(), значит readText() точно сработает». К сожалению, нет. Между проверкой и реальной операцией чтения мир может измениться. Файл могли удалить, права могли поменяться, директорию могли переименовать, диск мог отвалиться (да, иногда буквально).

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

Плохой (наивный) код:

import java.io.File

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

    if (file.exists()) {
        val text = file.readText() // всё равно может упасть
        println(text.length)
    } else {
        println("Файла нет")
    }
}

Более взрослый минимум (проверка + защита):

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

fun main() {
    val file = File("data/input.txt")
    println("Перед чтением: exists=${file.exists()}")

    try {
        val text = file.readText()
        println("Прочитано: ${text.length}") // например: Прочитано: 120
    } catch (e: IOException) {
        println("Чтение не удалось: ${file.path}")
    }
}

Идея не в том, чтобы «всё обмазать проверками», а в том, чтобы принять: проверка — это подсказка, а try/catch — это последняя линия обороны.

2. IOException и FileNotFoundException на JVM

Прежде чем ловить исключения, полезно понимать, что мы вообще ловим. В Kotlin (на JVM) исключения — это объекты, и все они являются наследниками Throwable. Дальше идут Error и Exception, и уже внутри Exception живут разные «рабочие» проблемы, которые мы обычно умеем обрабатывать.

Где в иерархии живёт IOException

Очень практичное чтение этого факта такое: IOException — “зонтик” для всего, что связано с чтением/записью/открытием потоков. Вы не обязаны помнить каждый конкретный подкласс, но важно понимать стиль: если вы делаете реальное I/O, оно может бросить IOException.

Мини-пример «ловим общий зонтик»:

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

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

    try {
        val text = file.readText()
        println("OK: ${text.length}") // например: OK: 42
    } catch (e: IOException) {
        println("I/O ошибка при чтении: ${file.path}")
        println("Причина: ${e.message}")
    }
}

Даже если вы вообще ничего не знаете про файловые системы, вы уже можете сделать программу заметно более устойчивой — не «упасть», а сообщить, что пошло не так.

FileNotFoundException: «файла нет» и другие сюрпризы

Название FileNotFoundException звучит так, будто причина всегда одна: «файл не найден». И иногда это действительно так. Но в реальности этот тип часто означает более широкую категорию: «не удалось открыть файл как файл».

То есть вы могли попасть в один из сценариев: файл реально отсутствует, путь указывает на директорию, у процесса нет прав на чтение, или файл существует, но доступ к нему запрещён/невозможен на уровне ОС. Для начинающего программиста это неприятно: вы вроде бы проверили глазами, что файл «там», а программа всё равно говорит “not found”. А она имеет в виду: «я не смогла открыть этот путь как читаемый файл».

В этом месте полезно начать мыслить как диагност: мы не спорим с исключением («да он есть!»), мы уточняем реальность: что это за объект по пути и какие базовые свойства у него сейчас.

Вот минимальная проверка, которая часто сразу снимает половину вопросов:

import java.io.File

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

    println("exists=${file.exists()}")       // exists=true/false
    println("isFile=${file.isFile}")         // isFile=true/false
    println("isDir=${file.isDirectory}")     // isDir=true/false
}

Если вы увидели exists=true, но isFile=false и isDir=true, то вы пытались читать директорию как файл. Это не баг «в Kotlin», это баг «в ожиданиях».

Аккуратный try/catch: конкретное → общее

Многим новичкам хочется ловить сразу Exception, потому что «так точно поймаем всё». Проблема в том, что потом вы сами себе выключаете мозг: у вас нет привычки различать причины. Kotlin (и JVM) поощряют ловить более конкретные исключения первыми, а более общий тип — дальше. Поэтому порядок catch важен: от более специфичного к более общему.

Хороший базовый шаблон для чтения:

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, length=${text.length}")
    } catch (e: FileNotFoundException) {
        println("Не найдено/не открывается как файл: ${file.path}")
        println("Диагностика: ${describePath(file)}")
    } catch (e: IOException) {
        println("I/O ошибка при чтении: ${file.path}")
        println("Причина: ${e::class.simpleName}: ${e.message}")
    }
}

Здесь важно, что мы не делаем вид, будто это «одинаковые ошибки». Если файл не открывается как файл — мы говорим об этом отдельно. Если это уже «что-то более общее I/O» — мы тоже умеем сообщить понятный минимум.

3. Диагностика I/O-ошибок

Когда вы ловите IOException, вы видите верхушку айсберга: «не удалось». Пользователю (и вам в будущем) нужно понять почему. Хорошая новость: типовых причин не так много, и их удобно держать в голове как карту.

Мини-карта причин

Ниже — небольшая таблица (без попытки охватить вселенную), которая помогает быстро «поставить диагноз»:

Что вы пытались сделать Частая реальная причина Как это выглядит
Прочитать файл Файла нет / путь неправильный
FileNotFoundException
Прочитать файл Путь — это директория FileNotFoundException или IOException
Прочитать файл Нет прав на чтение часто FileNotFoundException или AccessDenied… в сообщении
Записать файл Нет родительской директории иногда FileNotFoundException (потому что открыть на запись не удалось)
Записать файл Диск «полный» IOException, сообщение может намекать на space/quota
Прочитать/записать Файл занят/заблокирован чаще IOException, зависит от ОС

Сейчас наша цель не «научиться чинить всё на свете», а научиться корректно отличать хотя бы две большие ситуации:

1) «Не нашли / не открыли как файл» (часто FileNotFoundException)
2) «Открыли, но дальше сломалось чтение/запись» (часто общий IOException)

И под это мы строим обработку и сообщения.

Что печатать, чтобы не гадать

Есть соблазн сделать так: поймали исключение — вывели «Что-то пошло не так» — пошли пить чай. Но это путь к вечному чаю и вечной боли. Диагностика в файловых операциях почти всегда строится вокруг трёх вещей: действие, путь, краткие свойства объекта по пути.

Представьте, что вам прислали скриншот ошибки (без контекста). Что вам хотелось бы увидеть? Обычно вот это: «я пытался READ или WRITE», «вот путь», «вот что там было», «вот тип исключения и сообщение». Этого достаточно, чтобы в большинстве случаев понять причину.

Сделаем маленькую функцию, которая описывает путь. Это не «будущая архитектура», а просто удобный печатный ярлык:

import java.io.File

fun describePath(file: File): String {
    return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}"
}

fun main() {
    val file = File("data/input.txt")
    println(describePath(file))
    // например: path=data/input.txt, exists=false, isFile=false, isDir=false
}

Теперь, когда у вас произойдёт ошибка, вы сможете вывести одну строчку и резко повысить свои шансы на быстрое понимание причины.

Пример: читаем расходы и не «роняем» приложение

Чтобы всё это не осталось теорией, давайте мысленно продолжим наше учебное консольное приложение (пусть это будет простой трекер расходов). Мы хотим научиться читать файл data/expenses.txt, чтобы данные переживали перезапуск программы.

И вот тут внезапно оказывается: если файла нет — это не «катастрофа», это обычная ситуация. Например, пользователь запускает программу впервые. Поэтому нам нужен понятный контракт: либо мы прочитали строки, либо возвращаем пустой список и печатаем диагностику.

Сделаем функцию, которая не падает, а возвращает список строк. Сейчас мы тренируемся именно в диагностике ошибок чтения, без атомарной записи и без сложной надёжности.

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

fun loadExpenseLines(file: File): List<String> {
    return try {
        file.readLines()
    } catch (e: FileNotFoundException) {
        println("Нет файла с расходами: ${file.path}")
        emptyList()
    } catch (e: IOException) {
        println("Не удалось прочитать расходы: ${file.path}")
        println("Причина: ${e.message}")
        emptyList()
    }
}

И используем это в main:

import java.io.File

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

    println("Строк расходов: ${lines.size}") // например: Строк расходов: 0
}

Почему это хороший стиль для новичка? Потому что приложение продолжает жить. Оно может показать меню, дать пользователю добавить расход и потом уже сохранить файл (про сохранение и надёжность — позже). А самое главное: если что-то пошло не так, вы уже видите какой файл и что именно не получилось.

4. Типичные ошибки при работе с IOException и диагностикой

Ошибка №1: ловить Exception и писать “что-то пошло не так”.
Такой код формально «устойчивый», но практически бесполезный: вы скрыли от себя причину. Минимальный стандарт качества — в сообщении упомянуть действие (read/write), путь (file.path) и e.message. Иначе вы сами превращаете отладку в гадание.

Ошибка №2: считать FileNotFoundException буквальным “файл отсутствует”.
Новички часто спорят с исключением: «да он есть же!». В реальности это часто означает «не получилось открыть как файл»: путь может указывать на директорию, доступ может быть запрещён, или файл существует, но его нельзя открыть в текущих условиях. Поэтому при таком исключении особенно полезно вывести диагностику exists/isFile/isDirectory.

Ошибка №3: думать, что exists() гарантирует успех чтения/записи.
Проверка существования — это фотография мира в одну конкретную миллисекунду. Дальше мир может измениться. Поэтому проверки хороши как раннее предупреждение и как часть сообщения, но реальная операция всё равно должна быть в try/catch, потому что именно она сталкивается с реальностью.

Ошибка №4: терять контекст “что мы делали” и “с чем”.
Если в приложении несколько файлов (например, data/expenses.txt, data/categories.txt, data/report.txt), то сообщение “Не удалось прочитать файл” без пути превращается в загадку. Правило простое: в каждом сообщении об I/O ошибке должен быть путь и операция. Тогда даже пользователь (не программист) сможет вам пересказать проблему нормально.

Ошибка №5: смешивать I/O-ошибку и ошибку данных в один котёл.
Если файл прочитался, но внутри мусор (например, строка не парсится), это уже другая категория проблем: «формат данных». Сегодня мы сознательно держим фокус на I/O: «прочитали/не прочитали», «записали/не записали». Если вы смешаете это в один catch, потом будет сложно понять, что чинить: путь или парсер.

Ошибка №6: делать вид, что сообщение исключения не важно.
Иногда разработчик печатает только тип исключения и забывает про e.message. А именно там операционная система часто оставляет ключевую подсказку: access denied, no such file, disk full и так далее. Даже если текст на английском — это всё равно лучше, чем полная тишина.

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