1. Вступ
Коли кажуть «прочитати файл», новачки часто уявляють собі щось на кшталт: «застосунок відкрив файл, як Блокнот, і показав літери». Насправді застосунок завжди робить нуднішу (і чеснішу) роботу: бере файл і перетворює його вміст на дані всередині вашого застосунку — найчастіше на String або List<String>.
Під час роботи з текстовими файлами в Kotlin/JVM зручно те, що стандартна бібліотека та File дають готові методи «з коробки». Тож ви можете добирати стиль читання під завдання: прочитати все цілком, завантажити рядки в памʼять або обробляти файл рядок за рядком, майже не витрачаючи памʼять.
Важливо зафіксувати одну думку: обираючи спосіб читання, ви обираєте форму даних. А форма даних сильно впливає і на те, наскільки легко вам буде писати код далі, і на те, наскільки «боляче» буде компʼютеру (за памʼяттю та часом).
Як обрати метод читання
Вибір між readText, readLines і forEachLine — це не питання «який метод правильний». Це питання «яка форма даних мені потрібна далі» та «наскільки великий файл».
Щоб не вгадувати щоразу «на кавовій гущі», корисно тримати перед очима коротке порівняння.
Таблиця вибору
| Метод | Що повертає | Памʼять | Коли зручно | Типовий «код після читання» |
|---|---|---|---|---|
|
|
читає все цілком | файл — «одна порція сенсу»: шаблон, невеликий текст, конфіг | робота з рядками: trim, split, replace, пошук підрядків |
|
|
читає все цілком | файл логічно складається з рядків, і ви хочете застосовувати операції колекцій | map/filter/count/groupBy, сортування, аналіз рядків |
|
нічого не повертає напряму (ви самі накопичуєте результат) | рядок за рядком | файл великий або результат можна порахувати «на льоту» | лічильники, пошук, максимум/мінімум, накопичення в MutableList |
Тут є важливий психологічний момент: readText() і readLines() дають вам готовий результат одразу, а forEachLine змушує думати: «як я накопичуватиму відповідь?». Це нормально. Обробка рядок за рядком майже завжди трохи більш «алгоритмічна».
Блок-схема вибору
Іноді простіше запамʼятати не таблицю, а невеликий маршрут вибору:
flowchart TD
A[Потрібно прочитати текст із файла] --> B{Потрібен весь файл як єдиний рядок?}
B -->|Так| C["readText()"]
B -->|Ні| D{Потрібно мати список рядків у памʼяті?}
D -->|Так| E["readLines()"]
D -->|Ні, можна обробити на льоту| F["forEachLine { ... }"]
Якщо ви спіймали себе на думці «мені незручно з forEachLine, я хочу список», це чесний сигнал, що вам, можливо, справді потрібен readLines(). Просто памʼятайте про ціну: список = памʼять.
Нюанс про продуктивність виведення
Іноді файл читають для того, щоб потім вивести багато рядків у консоль. І тут зʼявляється класична пастка: «я в циклі зроблю println для кожного рядка». Технічно це працює, але за великих обсягів виведення може бути повільно. У Kotlin-спільноті часто радять збирати виведення й друкувати одним println через joinToString("\n"), коли рядків багато.
Сьогодні ми не перетворюємо лекцію на гонитву за оптимізаціями. Але звичку «не друкувати мільйон println без причини» можна починати формувати вже зараз.
2. readText() — коли потрібен увесь файл цілком
Іноді файл — це просто «одна штука»: шаблон листа, невеликий конфіг, збережений звіт, текст нотатки. У таких випадках найзручніше отримати його як один рядок. Для цього в File є метод readText().
Після readText() у вас зʼявляється String, і ви можете робити з рядком усе, що вже вмієте: length, trim(), split(), replace(), шукати підрядки, будувати звіти через StringBuilder тощо.
Мінімальний приклад readText()
Спочатку — найменший приклад, щоб відчути механіку:
import java.io.File
fun main() {
val text: String = File("data/input.txt").readText()
println("chars=${text.length}") // chars=...
}
Якщо файл невеликий, цей спосіб майже завжди най«приємніший для мозку»: одна змінна, один тип — і далі чиста робота з рядками.
Приклад: прочитав → нормалізував → використав
Часто текст із файла хочеться спершу «привести до ладу»: прибрати зайві пробіли по краях, перевірити, що там узагалі не порожньо.
import java.io.File
fun main() {
val raw = File("data/title.txt").readText()
val title = raw.trim()
println("title='$title'") // title='...'
}
Зверніть увагу: trim() тут — не «косметика», а реальний захист від сюрпризів на кшталт «у кінці файла випадково був перенос рядка».
Коли readText() — погана ідея
readText() читає увесь файл у памʼять як один рядок. Якщо файл великий (наприклад, лог на сотні мегабайт), ви спочатку витрачаєте памʼять на величезний String, а потім ще й час на операції над цим рядком. У такій ситуації зазвичай уже хочеться обробки рядок за рядком.
Проста життєва евристика така: якщо ви можете відкрити файл у Блокноті, і він не змушує Блокнот замислитися про сенс життя, то readText() зазвичай підходить. Якщо ж Блокнот зависає, а ви встигаєте попити чаю — краще не треба.
3. readLines() — коли потрібен список рядків
Наступна типова ситуація: файл — це набір рядків, і кожен рядок щось означає. Наприклад, список покупок, список команд, список нотаток або список витрат. У таких задачах зручно одразу отримати List<String>, де кожен елемент — окремий рядок файла. Для цього існує readLines().
readLines() добрий тим, що перетворює файл на звичну колекцію, а з колекціями ми вже вміємо багато: map, filter, count, firstOrNull, sortedBy, групування тощо.
Мінімальний приклад readLines()
import java.io.File
fun main() {
val lines: List<String> = File("data/input.txt").readLines()
println("lines=${lines.size}") // lines=...
}
Тепер у вас є не «туманний рядок із переносами», а конкретні елементи списку. Це часто робить код простішим і читабельнішим.
Приклад: знайти перший непорожній рядок
Дуже типовий сценарій: у файлі можуть бути порожні рядки, а перший «корисний» рядок нам важливий.
import java.io.File
fun main() {
val firstNonBlank = File("data/input.txt")
.readLines()
.firstOrNull { it.isNotBlank() }
println(firstNonBlank) // наприклад: Hello
}
Тут важлива звичка: ми використовуємо firstOrNull, а не first. Тому що файл може виявитися порожнім, і тоді first() влаштує вам «вечірку винятків».
Приклад: читаємо витрати з текстового файла
Уявімо, що до цього моменту курсу в нас уже є модель витрати (вона зʼявлялася, коли ми переходили на класи й data class). Ми поки не обговорюємо «ідеальні формати», а беремо простий формат рядка:
amount|category|comment
Наприклад:
350|food|coffee
1200|transport|metro card
Тоді читання в памʼять через readLines() виглядає природно: ми хочемо отримати список рядків, а потім розібрати кожен рядок в обʼєкт.
import java.io.File
data class Expense(val amount: Int, val category: String, val comment: String)
fun parseExpenseOrNull(line: String): Expense? {
val parts = line.split("|")
val amount = parts.getOrNull(0)?.toIntOrNull() ?: return null
val category = parts.getOrNull(1)?.trim().orEmpty()
val comment = parts.getOrNull(2)?.trim().orEmpty()
return Expense(amount, category, comment)
}
А тепер читаємо файл і перетворюємо рядки на витрати:
import java.io.File
fun main() {
val lines = File("data/expenses.txt").readLines()
val expenses = lines.mapNotNull { parseExpenseOrNull(it) }
println("loaded=${expenses.size}") // loaded=...
}
Чому тут зручно саме readLines()? Тому що ми хочемо колекцію і хочемо застосувати до неї «колекційний стиль» (mapNotNull), замість ручних циклів і тимчасових змінних.
Обмеження readLines()
Як і readText(), readLines() читає файл цілком, тільки не в один рядок, а в список рядків. Тобто за памʼяттю це все ще «завантажити все одразу». Для невеликих і середніх файлів — чудово. Для дуже великих — краще перейти до обробки рядок за рядком.
4. forEachLine { ... } — обробка рядок за рядком
Іноді нам не потрібно зберігати файл цілком. Ми хочемо пройтися по ньому й порахувати, знайти, відфільтрувати, але без створення величезного списку рядків. Для цього в File є метод читання рядок за рядком forEachLine { line -> ... }. Ідея проста: Kotlin читає файл рядками і передає вам кожен рядок у лямбду.
Такий підхід часто використовують у завданнях на кшталт обробки логів або великих вхідних даних. У матеріалах Kotlin він згадується як зручний інструмент саме для читання файлів рядок за рядком.
Мінімальний приклад forEachLine
import java.io.File
fun main() {
var nonBlank = 0
File("data/input.txt").forEachLine { line ->
if (line.isNotBlank()) nonBlank++
}
println("nonBlankLines=$nonBlank") // nonBlankLines=...
}
Зауважте: ми не створювали List<String>. Ми просто пройшлися файлом і порахували.
Приклад: знайти максимальну суму витрати без збереження рядків
Якщо файл витрат може бути великим, а нам потрібен, наприклад, максимум за amount, то forEachLine добре підходить:
import java.io.File
fun main() {
var maxAmount = 0
File("data/expenses.txt").forEachLine { line ->
val expense = parseExpenseOrNull(line) ?: return@forEachLine
if (expense.amount > maxAmount) maxAmount = expense.amount
}
println("maxAmount=$maxAmount") // maxAmount=...
}
Тут зʼявився return@forEachLine. Це не «повернення з main», а «пропустити поточний рядок». Так роблять, якщо рядок битий або порожній.
Чому forEachLine часто безпечніший для памʼяті
forEachLine дозволяє працювати з файлом як зі «стрічкою»: рядок прийшов → ви його обробили → пішли далі. Памʼять не роздувається від списку рядків. Це особливо приємно, коли ви не знаєте заздалегідь, наскільки великий файл вам підсунуть (життя любить раптові сюрпризи).
5. Міні-збірка: читаємо файл і готуємо дані для застосунку
Зараз зробимо акуратний «скелет» читання для нашого консольного застосунку (умовно — трекера витрат). І зробимо це так, щоб усе залишалося простим та не заходило в теми наступної лекції (запис) і наступних днів (надійне I/O).
Сенс простий: нам потрібна одна функція, яка приймає File і повертає список коректних витрат. Усе. Без магії.
Функція читання через readLines()
import java.io.File
fun loadExpenses(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
return file.readLines()
.mapNotNull { line -> parseExpenseOrNull(line.trim()) }
}
Це читається майже як український текст: «якщо файла немає — порожньо; інакше прочитати рядки й розібрати».
Альтернатива через forEachLine
import java.io.File
fun loadExpensesStreaming(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
val result = mutableListOf<Expense>()
file.forEachLine { line ->
val e = parseExpenseOrNull(line.trim()) ?: return@forEachLine
result.add(e)
}
return result
}
Так, коду трохи більше. Зате він не вимагає тримати List<String> усіх рядків. Ми акуратно накопичуємо лише «хороші» записи.
6. Типові помилки під час читання тексту з файлів
Помилка №1: обрати readText() «за звичкою», а потім дивуватися, що застосунок їсть памʼять.
readText() — чудовий метод, але він буквально означає «прочитати ВСЕ». Якщо файл раптово виявився великим, ви отримаєте величезний String, а далі — важкі операції над ним. У таких випадках корисно перемкнутися на readLines() або forEachLine() і мислити рядками.
Помилка №2: плутати «рядок файла» і «рядок як String цілком».
Після readText() у вас один великий рядок, усередині якого є "\n". Новачки іноді очікують, що «раз у файлі багато рядків, то й змінна буде якось посторінково». Не буде. Якщо далі вам потрібно працювати саме з рядками, швидше (і ясніше) одразу взяти readLines().
Помилка №3: не нормалізувати рядки перед розбором.
Навіть «чистий» файл часто має хвостові пробіли, порожні рядки, зайві переноси. Якщо одразу робити split("|") без trim() і без перевірки isBlank(), ви отримаєте дивні баги, які виглядають як «учора працювало, сьогодні ні». Мінімальний захист — line.trim() і акуратні toIntOrNull().
Помилка №4: забувати, що файл може бути відсутнім, а шлях може вказувати на каталог.
Якщо ви одразу робите File("...").readLines(), а файла не знайдено, застосунок упаде з винятком. Ми вже вміємо базові перевірки exists() і isFile — і це саме той випадок, коли вони справді економлять нерви. Особливо в навчальних проєктах, де каталоги й файли постійно «переїжджають».
Помилка №5: намагатися «повернути значення» з forEachLine звичайним return.
Усередині лямбди forEachLine звичайний return може поводитися не так, як очікує новачок (там важливі правила повернення з лямбд). Якщо ви хочете пропустити рядок — використовуйте return@forEachLine. Якщо хочете накопичувати результат — заведіть змінну зовні й оновлюйте її всередині лямбди.
Помилка №6: змішувати читання і складну бізнес-логіку в одному величезному блоці.
Коли всередині forEachLine або після readLines() одразу починається «простирадло» з умов, підрахунків і форматування звіту, код стає важко читати й складно налагоджувати. Значно спокійніше тримати стиль: «прочитав → отримав структуру даних → далі обробив», навіть якщо структура даних поки проста.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ