JavaRush /Курсы /Kotlin SELF /use {} для I/O: закрываем файлы правильно

use {} для I/O: закрываем файлы правильно

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

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 (не как «лучше/хуже», а как «что вы пишете руками»):

Что делаем
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, но не смешивайте всё в одну кучу).

1
Задача
Kotlin SELF, 42 уровень, 4 лекция
Недоступна
Послание курьера
Послание курьера
1
Задача
Kotlin SELF, 42 уровень, 4 лекция
Недоступна
Заголовок из шума
Заголовок из шума
1
Задача
Kotlin SELF, 42 уровень, 4 лекция
Недоступна
Счётчик строк
Счётчик строк
1
Задача
Kotlin SELF, 42 уровень, 4 лекция
Недоступна
Отчёт контролёра
Отчёт контролёра
1
Опрос
Текстовые файлы, 42 уровень, 4 лекция
Недоступен
Текстовые файлы
Текстовые файлы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ