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