1. Зачем нужен «движок» массовых операций
Если честно, копировать один файл — задача уровня «мама, смотри, я программист». Но как только файлов становится 3000, внезапно выясняется, что мир состоит из сюрпризов: где-то нет прав, где-то имя слишком длинное, где-то уже лежит файл с таким же названием, где-то диск закончился, а где-то 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().
Сделаем маленький helper:
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, "cannot create target directory")
}
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, "cannot create target directory")
}
val ok = source.renameTo(target)
if (ok) OpResult(source.path, target.path, Status.MOVED)
else OpResult(source.path, target.path, Status.FAILED, "renameTo returned 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, "target exists")
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("Done. Stats: $stats") // Done. Stats: {COPIED=10, SKIPPED=2, FAILED=1}
}
Да, buildPlanSomehow() тут как заглушка — потому что сегодня мы учимся именно «движку», а не обходу директорий и не правилам раскладки. В реальном проекте вы подставите сюда ваши collectCandidates(...) и buildPlan(...).
5. Типичные ошибки при реализации движка массовых операций
Ошибка №1: один общий try/catch вокруг всего запуска.
Когда try/catch оборачивает весь batch, любая ошибка превращается в «всё упало». Пользователь получает меньше обработанных файлов, а вы — меньше информации. Гораздо устойчивее ловить ошибки на уровне «один файл → один 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 и обработка ошибок, а результат — это данные для отчётности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ