1. Ресурс: почему файл нужно закрывать
Когда вы читаете файл через readText() или readLines(), кажется, что это магия уровня «дай мне строку». Но под капотом происходит вполне «физическая» вещь: операционная система выделяет вашему процессу ресурс — открытый файловый дескриптор/поток. Пока ресурс открыт, ОС держит связь «программа ↔ файл» и ждёт, что программа аккуратно скажет: «я всё, можешь закрывать».
У ресурса есть неприятное свойство: если вы его не закрываете, он не исчезает «сам по себе» ровно тогда, когда вам удобно. Он может закрыться позже (когда сработает сборщик мусора), а может и не закрыться достаточно быстро, чтобы это стало проблемой прямо сейчас. Именно поэтому работа с файлами — это всегда история про дисциплину: открыл → поработал → закрыл, и желательно без «ой, я забыл».
Чтобы визуально закрепить идею, можно думать так:
flowchart TD
A["Открыли файл (Reader/Writer/Stream)"] --> B["Прочитали / записали данные"]
B --> C["Закрыли ресурс (close)"]
B -->|Если случилась ошибка| D["Исключение"]
D --> C
Главный смысл: закрытие должно произойти и в «успехе», и в «ошибке».
2. Два подхода к закрытию ресурсов
Ручной close()
На первых шагах легко поверить, что можно делать так: открыл BufferedReader, прочитал, потом в конце вызвал close(). На бумаге всё красиво. В живом коде — начинаются «ветвления судьбы»: ранние return, исключения, break, continue, ещё один return, потому что «ну тут же ошибка, зачем дальше».
И вот в какой-то ветке вы выходите из функции раньше, чем доберётесь до close(). Или внутри чтения внезапно прилетает исключение — и close() тоже не вызывается. В итоге файл зависает открытым, а вы смотрите на ошибку и думаете: «Кто-то украл мой дескриптор, но кто?»
Надёжный, но более «многословный» способ — try/finally. Он гарантирует выполнение finally даже при исключении, и это фундаментальная идея.
Мини-пример на «учебном ресурсе»:
class FakeResource {
fun work() {
println("Working...") // Working...
error("Boom") // имитируем сбой
}
fun close() {
println("Closed") // Closed
}
}
fun main() {
val r = FakeResource()
try {
r.work()
} finally {
r.close()
}
}
Работает? Да. Красиво? Скажем так: это как носить с собой отвертку, чтобы открыть бутылку. Можно, но хочется «кнопочку».
Этой «кнопочкой» в Kotlin и является use { ... }.
use {}: что это такое и какую гарантию даёт
use { ... } — это идиоматический способ Kotlin (в стиле Java try-with-resources, только без отдельного синтаксиса). Смысл тот же: вы даёте Kotlin ресурс и блок кода, Kotlin выполняет блок и затем автоматически закрывает ресурс — независимо от того, завершился блок нормально или вылетел с исключением.
Ещё одна важная деталь: use — это обобщённая (generic) функция, применимая к закрываемым ресурсам. То есть она работает для типов, которые удовлетворяют контракту «закрываемость» (например, Closeable).
Психологически удобно читать этот код так: «вот ресурс, используй его в блоке, а потом закрой».
Сравнение try/finally и use (не как «лучше/хуже», а как «что вы пишете руками»):
| Что делаем | |
|
|---|---|---|
| Гарантируем закрытие | Да | Да |
| Код длиннее | Обычно да | Обычно нет |
| Где закрытие | В finally | Автоматически после блока |
| Риск «забыл close» | Есть | Почти нет (если сразу пишете .use { ... }) |
Мини-пример с настоящим файлом (идея, без обработки ошибок):
import java.io.File
fun main() {
val firstLine = File("data/input.txt")
.bufferedReader()
.use { br -> br.readLine() }
println(firstLine) // например: Hello
}
Здесь BufferedReader будет закрыт автоматически после выхода из use-блока.
3. Чтение файла через bufferedReader().use { ... }
До этого дня вы уже читали файл «короткими методами» вроде readText() и readLines(). Они прекрасны, когда файл небольшой и вы правда хотите целиком получить его содержимое в память. Но как только появляется идея «читать постепенно» (например, искать первую подходящую строку или считать статистику), удобнее открыть reader и читать построчно.
Для этого и нужен bufferedReader(): он даёт вам объект, у которого есть readLine(). А чтобы не думать о закрытии — сразу оборачиваем в use.
Пример: прочитать первую непустую строку
import java.io.File
fun main() {
val firstNonBlank = File("data/input.txt").bufferedReader().use { br ->
var line: String?
while (true) {
line = br.readLine() ?: break
if (line.isNotBlank()) return@use line.trim()
}
null
}
println(firstNonBlank) // например: Title
}
Обратите внимание на return@use: это «возврат из лямбды», а не из main. Я специально использую метку, чтобы поведение было максимально очевидным, даже если вы уже знаете про inline и нелокальные return.
Пример: посчитать строки, не загружая файл целиком
import java.io.File
fun main() {
val count = File("data/input.txt").bufferedReader().use { br ->
var lines = 0
while (br.readLine() != null) lines++
lines
}
println("lines=$count") // lines=42
}
Ключевой стиль: внутри use мы делаем только работу с reader’ом. Как только вы начинаете внутри use строить половину бизнес-логики приложения, код становится тяжелее читать и отлаживать. use — это «короткий коридор до ресурса», а не «комната для переговоров на 3 часа».
4. Запись файла через bufferedWriter().use { ... }
У writeText() и appendText() тоже всё хорошо: они короткие и понятные. Но иногда вы хотите писать по кускам: заголовок, потом строки, потом итог, потом перенос строки. Можно склеивать строки заранее (например, через StringBuilder), но иногда проще и честнее писать по мере формирования.
Тогда удобно взять bufferedWriter() и опять же завернуть его в use. В Kotlin use закрывает ресурс автоматически, а закрытие writer’а обычно включает финальную «дозапись» буфера (вам не нужно вручную делать flush() в учебных примерах). Идея «закрыли — значит точно дописали» здесь очень приятная.
Пример: записать небольшой отчёт построчно.
import java.io.File
fun main() {
val out = File("data/reports/summary.txt")
out.parentFile?.mkdirs()
out.bufferedWriter().use { w ->
w.write("Report\n")
w.write("items=3\n")
w.write("ok=true\n")
}
}
Если внутри записи внезапно случится исключение, use всё равно попытается закрыть writer. Это именно та гарантия «cleanup при ошибке», ради которой мы и затеяли всю лекцию.
Отдельный нюанс про переносы строк: write() не добавляет "\n" сам, поэтому вы контролируете формат руками. Это хорошо: не будет «магических» переносов, но и ответственность ваша (да, программист — это человек, который сам ставит "\n", чтобы потом не плакать).
5. useLines{}: ленивые строки и границы блока
Иногда хочется получить удобство построчной обработки, но без ручного BufferedReader и while. Для этого есть useLines { ... }. Она открывает файл, даёт вам «поток строк» (по смыслу — ленивую последовательность), а после завершения блока гарантированно закрывает файл.
Это похоже на «я хочу работать со строками как с коллекцией, но не хочу хранить весь файл в памяти». Внутри блока вы обычно делаете что-то вроде count, filter, map, firstOrNull и так далее — и получаете итоговое значение.
Пример: посчитать непустые строки.
import java.io.File
fun main() {
val nonBlank = File("data/input.txt").useLines { lines ->
lines.count { it.isNotBlank() }
}
println("nonBlank=$nonBlank") // nonBlank=10
}
Пример: найти первую строку, которая похожа на заголовок (например, начинается с "#").
import java.io.File
fun main() {
val header = File("data/input.txt").useLines { lines ->
lines.firstOrNull { it.trim().startsWith("#") }
}
println(header) // например: # Shopping list
}
Теперь — важнейшая мысль, ради которой стоит остановиться.
useLines закрывает файл после блока. Значит, «строки» нельзя честно вернуть наружу и продолжить читать их потом — файл уже закрыт. Это типичная логическая ошибка: вы вроде бы «вернули Sequence», но у Sequence нет данных «внутри», он тянет их из файла при итерации. А файл к этому моменту уже закрыт.
Правильная стратегия простая: либо делайте вычисление внутри useLines и возвращайте готовый результат (число/строку/список), либо используйте readLines(), если вам действительно нужен список строк в памяти.
6. Практический пример: сохраняем и загружаем расходы
С этого момента в учебном приложении становится естественно завести простейшее текстовое хранилище. Не «базу данных», не «идеальный формат», а честный файл, который можно открыть в блокноте и понять глазами. Мы не обсуждаем кодировки и сложные форматы — сегодня цель именно в безопасном I/O-стиле.
Представим, что у нас в проекте уже есть модель расхода:
data class Expense(
val amount: Int,
val category: String,
val note: String
)
Сделаем очень простой формат строки: "amount;category;note". Да, точка с запятой — не «идеально», но зато легко парсить через split(";").
Сериализация (объект → строка):
fun serializeExpense(e: Expense): String {
val safeNote = e.note.replace(";", ",")
return "${e.amount};${e.category};$safeNote"
}
Парсинг (строка → объект или null, если строка битая):
fun parseExpenseOrNull(line: String): Expense? {
val parts = line.split(";")
if (parts.size < 3) return null
val amount = parts[0].toIntOrNull() ?: return null
return Expense(amount, parts[1], parts[2])
}
Теперь самое интересное: загрузка списка расходов через useLines, чтобы файл закрывался автоматически, а обработка была похожа на работу с коллекцией.
import java.io.File
fun loadExpenses(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
return file.useLines { lines ->
lines.mapNotNull { parseExpenseOrNull(it.trim()) }.toList()
}
}
Обратите внимание на стиль: мы возвращаем наружу уже готовый List<Expense>. Файл закрывается сразу после блока, и это именно то поведение, которое нам нужно.
Запись списка расходов через bufferedWriter().use { ... }:
import java.io.File
fun saveExpenses(file: File, items: List<Expense>) {
file.parentFile?.mkdirs()
file.bufferedWriter().use { w ->
for (e in items) w.write(serializeExpense(e) + "\n")
}
}
И маленький фрагмент main, который показывает, что всё связалось (без усложнения командного интерфейса):
import java.io.File
fun main() {
val storage = File("data/expenses.txt")
val items = loadExpenses(storage)
println("Loaded: ${items.size}") // Loaded: 0 (если файла ещё нет)
saveExpenses(storage, items + Expense(120, "food", "coffee"))
}
Здесь «магия» не в том, что мы умеем хранить расходы (это мы и раньше могли в списке), а в том, что код I/O не создаёт вам мин замедленного действия: ресурс закроется всегда, даже если что-то упадёт в середине. Именно это и делает use таким важным элементом взрослого стиля.
7. Типичные ошибки при работе с use {} и Reader/Writer
Ошибка №1: открыть bufferedReader() и забыть .use { ... }.
Такое чаще всего случается, когда вы «быстро проверяете идею» и пишете val br = file.bufferedReader(), а потом начинаете читать. Пока программа маленькая — вы не видите проблемы. Потом появляется исключение или ранний выход, close() не вызывается, и начинаются странности: файл «занят», данные «не дописались», на Windows что-то «не даёт удалить файл». Лечится привычкой: если увидели bufferedReader()/bufferedWriter() — рука автоматически дописывает .use { ... }.
Ошибка №2: пытаться вернуть lines из useLines наружу.
Это логическая ловушка: кажется, что вы вернули «строки», но на самом деле вы вернули механизм чтения из файла. А файл закрывается сразу после блока useLines. Правильный подход — внутри useLines посчитать/отфильтровать/собрать результат и вернуть уже готовое значение, например Int, String или List<String>.
Ошибка №3: писать много бизнес-логики внутри use-блока.
use должен быть «узким местом контакта с ресурсом»: прочитал/записал — и вышел. Если вы внутри блока начинаете обновлять кучу структур, парсить команды, строить отчёты и ещё печатать в консоль, то при отладке вы теряете простое правило: «внутри use — только I/O». Код становится вязким и сложнее тестируется.
Ошибка №4: забывать про "\n" при записи через write().
После appendText() многие привыкают, что «как-нибудь само». Но writer.write() пишет ровно то, что вы дали. Если вы забыли "\n", строки «слипнутся» в одну, а потом ваш парсер честно скажет: «частей не 3, а 1» — и будет прав. Поэтому при построчной записи либо добавляйте "\n" руками, либо используйте договорённость формата (например, «каждая сущность — отдельная строка»).
Ошибка №5: путать “закрыть ресурс” и “обработать ошибку”.
use гарантирует закрытие ресурса. Но он не превращает ошибки в успех и не «глушит» исключения автоматически. Если файл не существует или нет прав — вы получите исключение там, где выполняете I/O. Поэтому держите в голове разделение ответственности: use отвечает за закрытие, а обработка I/O-ошибок — это отдельная тема и отдельный дизайн (и вы уже знаете основы через try/catch, но не смешивайте всё в одну кучу).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ