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 или 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 и так далее. Даже если текст на английском — это всё равно лучше, чем полная тишина.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ