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. Це й є та гарантія «прибирання навіть у разі помилки», заради якої ми й затіяли всю лекцію.

Окремий нюанс про перенесення рядків: 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
Опитування
Текстові файли, рівень 42, лекція 4
Недоступний
Текстові файли
Текстові файли
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ