1. Зачем нужны предпроверки
Когда вы пишете file.readText() или file.writeText(...), вы как будто говорите ОС: «Дай мне данные» или «Запиши вот это». И ОС может ответить: «Ок»… или «Нет, потому что…».
Предпроверки — это не попытка “отменить существование ошибок”. Это попытка раньше заметить очевидные проблемы и сказать человеку (или себе) нормальную причину, а не “что-то пошло не так”. Это особенно полезно в CLI-приложениях: пользователь любит конкретику, а не философию.
Важно сразу принять неприятную, но полезную правду: предпроверки не гарантируют успех операции. Даже если вы проверили exists() и canRead(), файл может исчезнуть через миллисекунду. Поэтому предпроверки — это первая линия обороны, а реальная операция всё равно должна быть готова к I/O-сбою (обработка исключений будет отдельно, а сегодня держим фокус именно на проверках).
Нарисуем короткую ментальную модель:
flowchart TD
A[Хотим прочитать/записать файл] --> B[Делаем предпроверки]
B -->|проверки не прошли| C[Не начинаем I/O, объясняем проблему]
B -->|проверки прошли| D[Пробуем реальный I/O]
D -->|успех| E[Работаем с данными]
D -->|сбой| F[Это уже область обработки исключений]
2. File как путь и вопросы к файловой системе
Перед тем как проверять чтение и запись, важно привыкнуть к мысли: File("data/input.txt") — это не “файл в руках”, а объект пути, с помощью которого вы задаёте вопросы файловой системе: существует ли что-то по этому пути, папка это или файл, можно ли читать/писать и так далее.
В Kotlin/JVM вы обычно используете java.io.File. У него есть базовые методы, которые мы будем комбинировать:
- exists() — существует ли что-то по пути
- isFile — это файл?
- isDirectory — это директория?
- canRead(), canWrite() — есть ли права на чтение/запись (важное “кажется”, а не “гарантирую”)
- parentFile — родительская директория (может быть null)
- mkdirs() — создать директорию и всех родителей
Давайте начнём с маленькой «диагностической привычки»: прежде чем спорить с компьютером, полезно спросить у него, что он видит по этому пути.
import java.io.File
fun main() {
val file = File("data/input.txt")
println("path=${file.path}") // path=data/input.txt
println("exists=${file.exists()}") // например: exists=false
println("isFile=${file.isFile}") // например: isFile=false
println("isDirectory=${file.isDirectory}") // например: isDirectory=false
}
Да, это выглядит как “лишние строчки”. Но когда у вас однажды будет ситуация «почему не читается?!», эти четыре строки экономят полчаса жизни и одну микровспышку ненависти к человечеству.
Что проверять перед чтением
Когда мы читаем файл, нам нужно убедиться в трёх вещах: по пути что-то есть, это именно файл (а не папка), и у нас есть право читать.
Самый практичный минимум для чтения обычно выглядит так:
import java.io.File
fun canReadFile(file: File): Boolean {
return file.exists() && file.isFile && file.canRead()
}
fun main() {
val ok = canReadFile(File("data/input.txt"))
println("canReadFile=$ok") // например: canReadFile=false
}
Обратите внимание на порядок: exists() → isFile → canRead(). Он не только логичный, но и психологически приятный: мы идём от “вообще есть ли что-то” к “это правильного типа” и только потом к правам.
Почему одного exists() недостаточно
Очень частая ошибка новичка — сделать вот так:
if (file.exists()) {
val text = file.readText()
}
Проблема в том, что exists() говорит: «да, что-то есть». Но это “что-то” может быть директорией. А чтение директории как текста — это уже игра «угадай, какую ошибку вернёт ОС сегодня».
Поэтому isFile — это не придирка, а защита от логической ошибки: “я думал это файл”.
3. canRead() и canWrite(): полезные, но не гарантия
Права доступа — тема, где легко впасть в иллюзию контроля. Методы canRead() и canWrite() действительно дают полезный сигнал: «скорее всего, читать/писать можно». Но они не являются договором с реальностью.
Почему так? Потому что между “проверил” и “сделал” может поменяться всё: файл мог быть заблокирован, права могли измениться, файл мог исчезнуть, вы могли работать в среде с сетевой файловой системой, где проверки и реальный доступ ведут себя хитрее.
С точки зрения разработки это означает простое правило поведения: canRead()/canWrite() помогают не начинать заведомо обречённую операцию, но не заменяют обработку реального сбоя ввода/вывода. Ошибки такого типа обычно “живут” под зонтиком IOException.
Потрогаем эти флаги руками:
import java.io.File
fun main() {
val file = File("data/input.txt")
println("exists=${file.exists()}") // например: true
println("canRead=${file.canRead()}") // например: true
println("canWrite=${file.canWrite()}") // например: false
}
Если canWrite() внезапно false, это не “Kotlin сломался”. Это подсказка: возможно, вы пытаетесь писать туда, где у вас нет прав (например, в системную директорию), или файл помечен read-only.
4. Предпроверки перед записью: родительская директория
Запись отличается от чтения тем, что файла может ещё не существовать — и это нормально. Поэтому набор проверок тут другой.
Ключевая мысль: чтобы записать файл "data/out/report.txt", вам в первую очередь нужна директория "data/out". И именно её состояние чаще всего ломает запись.
То есть перед writeText() нас интересует:
- существует ли родительская папка
- является ли она директорией (не файлом)
- можем ли мы в неё писать
- если папки нет — можем ли мы её создать
parentFile и почему он может быть null
Если путь относительный и без папок, например File("report.txt"), то parentFile будет null. Это не ошибка, просто “родитель не указан”.
В таком случае удобно считать, что родитель — текущая директория ".".
import java.io.File
fun parentOrCurrent(target: File): File {
return target.parentFile ?: File(".")
}
fun main() {
val a = File("data/out/report.txt")
val b = File("report.txt")
println(parentOrCurrent(a).path) // data/out
println(parentOrCurrent(b).path) // .
}
mkdirs(): готовим директорию под запись
Теперь самое практичное: функция, которая гарантирует (насколько это возможно), что директория существует и является директорией. Если её нет — пытается создать.
Важно: mkdirs() возвращает Boolean. Это не “для галочки”. Это единственный прямой сигнал “получилось / не получилось”.
import java.io.File
fun ensureDir(dir: File): Boolean {
if (dir.exists()) return dir.isDirectory
return dir.mkdirs()
}
fun main() {
val dir = File("data/out/reports")
val ok = ensureDir(dir)
println("dirReady=$ok") // например: dirReady=true
println("exists=${dir.exists()}") // exists=true
println("isDir=${dir.isDirectory}") // isDir=true
}
Здесь мы сознательно не используем списки проверок “всё и сразу”, а читаем код как историю: “если есть — убедись, что папка; если нет — создай”.
mkdir() vs mkdirs() на пальцах
mkdir() создаёт одну директорию. Если вам нужно создать "data/out/reports", а "data/out" не существует, mkdir() обычно не справится.
mkdirs() пытается создать всю цепочку.
Для новичка правило простое: почти всегда для вложенных путей нужен mkdirs().
Готовность к записи через parentFile
Соберём это в ещё одну маленькую утилиту: подготовить родительскую директорию под файл.
import java.io.File
fun ensureParentDir(target: File): Boolean {
val parent = target.parentFile ?: File(".")
return ensureDir(parent)
}
fun main() {
val out = File("data/out/report.txt")
println("parentReady=${ensureParentDir(out)}") // например: parentReady=true
}
Эта функция кажется скучной (а скучное — это часто надёжное). Но именно она чаще всего превращает сценарий “не записывается, потому что нет папки” в сценарий “папка создалась, всё ок”.
Шпаргалка: что проверяем перед чтением и записью
Иногда полезно зафиксировать логику в виде таблицы, чтобы не держать всё в голове. Считайте это “шпаргалкой” на крайний случай.
| Действие | Что хотим гарантировать (насколько можем) | Типовые проверки |
|---|---|---|
| Чтение | По пути есть файл, и мы можем читать | |
| Запись | Есть директория, куда можно записать файл | |
Обратите внимание: для записи мы чаще проверяем директорию, а не “сам файл”. Файл может появиться только в момент записи — и это нормальный сценарий.
5. Пример: CLI-приложение с предпроверками
Представим, что у нас есть простое консольное приложение, которое хранит данные в текстовом файле "data/storage.txt" и иногда пишет отчёт в "data/out/report.txt". Неважно, что именно внутри — расходы, заметки или список “обид на компилятор”. Нам важно другое: как аккуратно проверять пути перед операцией.
Загрузка данных
import java.io.File
fun loadStorageText(storageFile: File): String? {
if (!canReadFile(storageFile)) return null
return storageFile.readText()
}
fun main() {
val file = File("data/storage.txt")
val text = loadStorageText(file)
println(text?.length ?: "NO DATA") // например: NO DATA
}
Здесь нет обработки исключений — и это намеренно. Сегодня мы фокусируемся на предпроверках как на фильтре. Обработка реальных I/O-сбоев будет следующей лекцией дня. Но даже в этом виде вы уже получили более “умное” поведение: если файла нет — мы не лезем читать и не провоцируем падение.
Сохранение отчёта
import java.io.File
fun saveReportText(reportFile: File, report: String): Boolean {
if (!ensureParentDir(reportFile)) return false
reportFile.writeText(report)
return true
}
fun main() {
val ok = saveReportText(
File("data/out/report.txt"),
"Отчёт: всё хорошо\n"
)
println("saved=$ok") // например: saved=true
}
Если папка "data/out" не существовала — она создастся. Если создать нельзя — вернётся false, и вы можете корректно завершить сценарий (или попросить пользователя выбрать другой путь).
6. Быстрая диагностика пути
Когда начинаются I/O-проблемы, вы обычно хотите быстро понять: что именно у меня по этому пути? файл? папка? существует? читается?
Сделаем маленький “диагностический текст”, который можно печатать в консоль (и который вы потом скажете себе спасибо, что написали).
import java.io.File
fun describe(file: File): String {
return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}, canRead=${file.canRead()}, canWrite=${file.canWrite()}"
}
fun main() {
val file = File("data/input.txt")
println(describe(file))
// например: path=data/input.txt, exists=false, isFile=false, isDir=false, canRead=false, canWrite=false
}
Это не “логирование” (до него ещё далеко), а просто человеческая диагностика в стиле “что видит файловая система”.
7. Типичные ошибки при предпроверках
Ошибка №1: проверять только exists() и считать, что этого достаточно.
exists() не говорит, что это файл, и не говорит, что его можно читать. Очень легко получить ситуацию “exists=true”, но “isDirectory=true”, и тогда чтение ломается. Привыкайте к формуле exists() + isFile + canRead() для чтения.
Ошибка №2: при записи проверять canWrite() у файла, который ещё не существует.
Это звучит логично (“я же буду писать в файл”), но на практике часто бессмысленно: если файла нет, то и право “писать в файл” оценивать нечего. Реально важно, можно ли писать в директорию, где файл должен появиться. Поэтому проверка начинается с parentFile.
Ошибка №3: вызывать mkdirs() и игнорировать результат.
mkdirs() может вернуть false, и это не редкость: нет прав, конфликт имён (по пути уже лежит файл), странный путь, проблемы с диском. Если вы проигнорировали результат, то позже “падение” будет выглядеть загадочнее и дальше от причины.
Ошибка №4: считать canRead()/canWrite() гарантией успеха.
Эти методы полезны как ранний фильтр, но среда может измениться между проверкой и операцией, а ещё реальные ошибки бывают сложнее (например, временная блокировка или проблемы с файловой системой). Держите в голове: предпроверки снижают число ошибок и улучшают сообщения, но не отменяют реальный риск I/O-сбоев, которые относятся к семейству IOException.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ