1. Навіщо потрібен «рушій» масових операцій
Скопіювати один файл — завдання рівня «мамо, дивіться, я програміст». Але щойно файлів стає 3 000, раптом зʼясовується, що світ повен сюрпризів: десь немає прав, десь імʼя надто довге, десь уже лежить файл із такою самою назвою, десь закінчилося місце на диску, а десь renameTo() просто сказав «false» і пішов, нічого не пояснивши (як кіт, якого попросили помитися).
Саме тому нам потрібен рушій масових операцій: шар коду, який бере план (список «що куди») і виконує операції для кожного елемента окремо, акуратно накопичуючи результати. Головний принцип лекції: помилка на одному файлі має перетворюватися на запис у звіті, а не на завершення програми.
Щоб тримати думки в порядку, корисно уявляти процес як конвеєр:
flowchart TD
A[PlanItem list: джерело -> ціль] --> B[Операція над одним файлом]
B --> C[OpResult: статус/помилка]
C --> D[Список результатів]
D --> E[Звіт/статистика]
2. Результати операцій: статуси та звіт
Статуси та модель результату
Перш ніж щось змінювати на диску, домовімося: що саме ми вважаємо «результатом»? У масових операціях результат — це не лише «успіх», а й «пропуск» або «помилка». Якщо звести все до Boolean, ми втратимо пояснення, чому файл пропущено або чому операція «впала». А потім доведеться «лікувати» застосунок шаманськими танцями й перезапусками.
Тому введемо Status як enum class, а OpResult — як data-модель результату. До речі, у enum у Kotlin є зручна колекція entries для перебору значень (у нових версіях Kotlin це окрема можливість, яка робить переліки трохи приємнішими в щоденній роботі).
Таблиця значень статусів — щоб було простіше читати звіт:
| Статус | Що означає в нашому File Organizer |
|---|---|
|
Файл успішно скопійовано в «target» |
|
Файл успішно переміщено в «target» |
|
Ми свідомо нічого не робили (конфлікт + політика , або інший «легальний» пропуск) |
|
Операція намагалася виконатися, але не змогла (виняток, , не створили каталог тощо) |
Міні-моделі:
enum class Status { COPIED, MOVED, SKIPPED, FAILED }
data class OpResult(
val source: String,
val target: String,
val status: Status,
val error: String? = null
)
Зверніть увагу: ми зберігаємо шляхи як String. Так, у нас десь є File і Path, але для звіту людині зручніше бачити саме рядки. До того ж рядок простіше серіалізувати або записати в текстовий лог (у цій лекції ми лише готуємо дані; гарний звіт буде пізніше).
Накопичення результатів і проста статистика
На перший погляд List<OpResult> — звичайний список. На практиці це найважливіший «артефакт» запуску. Якщо ви його втратите або забудете заповнити, ви позбавитеся можливості нормально пояснити користувачу, що сталося.
У списку результатів є два основні застосування.
Перше — звіт, зрозумілий людині (пізніше ми навчимося гарно збирати текст). Друге — статистика: скільки успішно, скільки пропущено, скільки «впало». І це можна зробити буквально одним рядком через groupBy, адже OpResult — нормальна структура даних.
fun countByStatus(results: List<OpResult>): Map<Status, Int> {
return results.groupBy { it.status }
.mapValues { (_, list) -> list.size }
}
Тут важливо розуміти ідею: toList() та подібні операції створюють «знімок» колекції в певний момент часу. Це часто корисно, коли ви хочете відокремити вхідні дані від результату.
Ми поки не заглиблюємося в продуктивність або тонкощі, але принцип «результати — це окремі дані, їх можна аналізувати» — ключовий.
3. Операції над одним файлом: копіювання, переміщення, помилки
Чому важлива операція «один файл → один результат»
Ззовні масова обробка виглядає як звичайний for. Але з погляду архітектури це дві різні задачі: «як обробити один файл» і «як обробити тисячу файлів». Якщо змішати їх в один гігантський блок, там швидко оселиться хаос: половина перевірок буде про конфлікти, чверть — про каталоги, ще частина — про try/catch, а наприкінці раптом виявиться, що ви забули додати результат у список.
Правильна декомпозиція звучить так: робимо невелику функцію, яка намагається обробити один файл і повертає OpResult. А цикл по всьому плану просто викликатиме її багато разів і складатиме результати.
І тут є тонка, але дуже практична вигода: ви зможете тестувати й налагоджувати логіку на одному прикладі (один файл), не проганяючи щоразу весь план.
Підготовка каталогу призначення
Один із найчастіших сюрпризів новачка: «Я копіюю файл — чому все впало, хоча шлях до “target” має гарний вигляд?» Відповідь зазвичай нудна: тому що каталогу призначення ще не існує. Файл ви хочете покласти в target/images/2026/january/photo.jpg, а каталог target/images/2026/january/ ще не створено.
Тому перед будь-яким записом/копіюванням/переміщенням ми маємо переконатися, що target.parentFile існує, або спробувати створити його через mkdirs().
Зробимо невелику допоміжну функцію:
import java.io.File
fun ensureParentDir(target: File): Boolean {
val parent = target.parentFile ?: return false
return parent.exists() || parent.mkdirs()
}
Тут ми повертаємо Boolean, бо це суто «технічна підготовка»: вийшло — чудово, не вийшло — далі немає сенсу намагатися копіювати.
Копіювання одного файлу: «вузький» try/catch
Тепер ми готові до операції «скопіювати один файл». Тут важливо не намагатися ловити взагалі все на світі — і водночас не падати від першої ж проблеми. Компроміс для навчального проєкту простий: ловимо IOException (і за потреби — більш загальний Exception як «страховку») та перетворюємо це на FAILED.
Також важливий нюанс: copyTo() у Kotlin працює з File і вміє кидати винятки. Ми не намагаємося їх «перемогти» — ми їх фіксуємо в результаті.
import java.io.File
import java.io.IOException
fun copyOne(source: File, target: File): OpResult {
return try {
if (!ensureParentDir(target)) {
return OpResult(source.path, target.path, Status.FAILED, "не вдалося створити каталог призначення")
}
source.copyTo(target)
OpResult(source.path, target.path, Status.COPIED)
} catch (e: IOException) {
OpResult(source.path, target.path, Status.FAILED, e.message)
}
}
Зверніть увагу на стиль: ми робимо ранній return у разі проблеми з каталогом. Це називають «guard clause» (захисна умова), і такий прийом добре рятує від зайвої вкладеності if усередині try.
Переміщення одного файлу: чому renameTo() може мовчки не спрацювати
З переміщенням трохи цікавіше, бо багато хто очікує, що «перемістити» — це просто «вирізати й вставити». На практиці часто роблять source.renameTo(target). І от тут Kotlin/Java іноді поводяться по-котячому: жодних винятків — просто false. Тобто операцію не виконано, але «помилки немає». Дякую, дуже інформативно.
Тому переміщення ми теж оформимо як «один файл → один результат» і обовʼязково перевіримо Boolean:
import java.io.File
fun moveOne(source: File, target: File): OpResult {
return try {
if (!ensureParentDir(target)) {
return OpResult(source.path, target.path, Status.FAILED, "не вдалося створити каталог призначення")
}
val ok = source.renameTo(target)
if (ok) OpResult(source.path, target.path, Status.MOVED)
else OpResult(source.path, target.path, Status.FAILED, "renameTo повернув false")
} catch (e: Exception) {
OpResult(source.path, target.path, Status.FAILED, e.message)
}
}
Ми поки не заглиблюємося в платформні тонкощі файлових систем. Для навчального проєкту достатньо чітко засвоїти практичне правило: якщо renameTo() повернув false, це така сама помилка, як виняток, — просто без stack trace.
Де ставити try/catch, щоб обробка була «живучою»
Коли люди вперше стикаються з помилками I/O, у них виникає сильне бажання зробити так:
try {
// обробка ВСЬОГО плану
} catch (e: Exception) {
// сумний println
}
Проблема в тому, що це перетворює одну помилку (наприклад, один «битий» файл) на провал усього запуску. Користувач отримує половину роботи (або взагалі нуль) і мінімум інформації.
Нам потрібна модель «часткових помилок»: ми обробляємо список, і кожен файл або успішний, або дає помилку-результат, — але решта продовжують оброблятися.
Тому try/catch має стояти усередині операції над одним файлом (copyOne/moveOne) або навколо логіки одного елемента плану, а не навколо всього циклу. Тоді «поганий» файл перетворюється на один запис FAILED, і ми рухаємося далі.
Це звучить майже філософськи, але на практиці означає дуже просту річ: «ловимо помилки вузько».
4. Виконання плану: цикл, конфлікти та склейка в main
executePlan(): один цикл і єдиний протокол конфліктів
Тепер найприємніше: у нас є PlanItem (із попередньої лекції про план розкладки), є режим Mode і ConflictPolicy (із контракту), і є функції copyOne/moveOne. Залишилося зібрати все це в один рушій — executePlan().
Спочатку нагадаю мінімальні моделі (щоб код нижче читався цілісно). Ми не будемо ускладнювати їх зайвими полями — нам достатньо того, що потрібно для виконання й звіту.
import java.io.File
import java.nio.file.Path
data class PlanItem(
val source: File,
val relative: Path,
val target: File
)
enum class Mode { COPY, MOVE }
enum class ConflictPolicy { SKIP, BACKUP }
Зауважте: relative тут уже майже не потрібен для виконання (target уже обчислено), але він може знадобитися для звіту — тож нехай буде.
Тепер сам «рушій». У цій лекції ми реалізуємо лише SKIP як політику конфліктів (а BACKUP ми вже обговорювали раніше й зможемо підʼєднати, якщо в нас є helper). Головне — показати структуру, у якій конфлікт обробляється перед спробою copy/move.
fun executePlan(plan: List<PlanItem>, mode: Mode, policy: ConflictPolicy): List<OpResult> {
val results = mutableListOf<OpResult>()
for (item in plan) {
if (item.target.exists() && policy == ConflictPolicy.SKIP) {
results += OpResult(item.source.path, item.target.path, Status.SKIPPED, "ціль уже існує")
continue
}
val r = when (mode) {
Mode.COPY -> copyOne(item.source, item.target)
Mode.MOVE -> moveOne(item.source, item.target)
}
results += r
}
return results
}
Зверніть увагу, що when тут дуже читабельний: він буквально каже «залежно від режиму робимо копію або переміщення». І це рівно той випадок, коли enum кращий за Boolean. Із Boolean довелося б тримати в голові, що означає true (копіювати? переміщати? чи, може, «dry run»?), а Mode.MOVE пояснює себе сам.
Міні-склейка в main
Щоб усе виглядало як єдиний застосунок (а не набір розрізнених функцій), корисно уявити фінальну склейку в main: раніше ми збирали конфіг, кандидатів і план, а тепер викликаємо рушій і друкуємо коротку зведену інформацію. Тут я покажу «скелет» без подробиць про обхід і план (вони були в попередніх лекціях).
fun main() {
val plan: List<PlanItem> = buildPlanSomehow() // з попередніх лекцій
val results = executePlan(plan, mode = Mode.COPY, policy = ConflictPolicy.SKIP)
val stats = countByStatus(results)
println("Готово. Статистика: $stats") // Готово. Статистика: {COPIED=10, SKIPPED=2, FAILED=1}
}
Так, buildPlanSomehow() тут — заглушка, бо сьогодні ми вчимося саме рушію, а не обходу каталогів і не правилам розкладки. У реальному проєкті ви підставите сюди ваші collectCandidates(...) і buildPlan(...).
5. Типові помилки під час реалізації рушія масових операцій
Помилка № 1: один спільний try/catch навколо всього запуску.
Коли try/catch обгортає всю пакетну обробку, будь-яка помилка перетворюється на «все впало». Користувач отримує менше оброблених файлів, а ви — менше інформації. Значно надійніше ловити помилки на рівні «один файл → один OpResult», щоб решта обробки продовжувалася.
Помилка № 2: не створювати каталог призначення перед копіюванням/переміщенням.
Дуже легко забути, що файл не можна покласти в папку, якої немає. Якщо не зробити target.parentFile.mkdirs(), копіювання падатиме (або повертатиме помилку) навіть за абсолютно коректних шляхів. Звичка «спочатку ensureParentDir, потім операція» робить поведінку передбачуваною.
Помилка № 3: вважати renameTo() надійним і не перевіряти результат.
renameTo() може повернути false без винятку, і якщо ви це проігноруєте, отримаєте «тихий провал»: програма скаже «все ок», а файл залишиться на місці. У рушії масових операцій false має перетворюватися на Status.FAILED зі зрозумілим повідомленням. Інакше налагодження перетворюється на детектив.
Помилка № 4: втрачати причину помилки.
Якщо в catch ви робите просто Status.FAILED без тексту, ви знищуєте найкориснішу частину результату — пояснення. Навіть e.message (хай інколи й null) уже дуже допомагає зрозуміти, що сталося: «Permission denied», «No space left on device», «File not found».
Помилка № 5: змішувати побудову плану та виконання операцій в одному місці.
Коли функція «категоризує» файл і тут же копіює його, ви втрачаєте контроль: складніше зробити «dry-run», складніше тестувати, складніше писати звіт. Значно чистіше тримати правило: план — це чисті обчислення, рушій — це I/O й обробка помилок, а результат — це дані для звітності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ