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. Це й є та гарантія «прибирання навіть у разі помилки», заради якої ми й затіяли всю лекцію.
Окремий нюанс про перенесення рядків: 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, але не змішуйте все в одну купу).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ