JavaRush /Курси /Kotlin SELF /Рушій масових операцій: copy/move, часткові помилки та ре...

Рушій масових операцій: copy/move, часткові помилки та результати

Kotlin SELF
Рівень 46 , Лекція 3
Відкрита

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
COPIED
Файл успішно скопійовано в «target»
MOVED
Файл успішно переміщено в «target»
SKIPPED
Ми свідомо нічого не робили (конфлікт + політика
SKIP
, або інший «легальний» пропуск)
FAILED
Операція намагалася виконатися, але не змогла (виняток,
renameTo == false
, не створили каталог тощо)

Міні-моделі:

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

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ