JavaRush /Курси /Kotlin SELF /Обхід директорій: walkTopDown, фільтри та відносні шляхи

Обхід директорій: walkTopDown, фільтри та відносні шляхи

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

1. Вступ

Коли ви чуєте «File Organizer», хочеться одразу писати copyTo() і радісно розкидати файли по директоріях — як кіт іграшки по квартирі. Але масові файлові операції небезпечні тим, що вони можуть бути незворотними (особливо MOVE). До того ж вони часто ламаються не там, де ви очікуєте: права доступу, дивні імена, надто довгі шляхи, файли без розширень, вкладені директорії тощо.

Тому нам потрібна дисципліна: спочатку ми обходимо вихідну директорію й збираємо список «кандидатів», потім фільтруємо його за правилами (наприклад, за розширеннями). І лише після цього будуємо план та виконуємо операції. Сьогодні — тільки перша частина конвеєра: «дерево → кандидати».

Невелика схема того, що ми будуємо (поки що без виконання операцій):

flowchart TD
    A[Вихідна директорія sourceDir] --> B[walkTopDown: отримуємо всі вузли дерева]
    B --> C[filter: лишаємо тільки файли isFile]
    C --> D[extensionOrNull: дістаємо розширення]
    D --> E[filter: ext in allowedExt]
    E --> F[relativize: відносний шлях усередині sourceDir]
    F --> G[Candidate: дані кандидата для подальшої обробки]

2. Обхід дерева: walkTopDown() і фільтр isFile

File і Path: чому нам потрібні обидва

У Kotlin/JVM робота з файлами часто виглядає так: «взяли java.io.File — і поїхали». І справді: File зручний для швидких перевірок exists(), isFile, isDirectory, для walkTopDown() і простих операцій. Такий стиль трапляється навіть в офіційних прикладах для консольних сценаріїв.

Але коли йдеться про шляхи як про структуру, а не як про рядки, зʼявляється другий герой: java.nio.file.Path. У Path є дуже важливі для нас операції:

  • relativize(...) — побудувати відносний шлях «усередині базової директорії»;
  • resolve(...) — поєднати частини шляху без ручної конкатенації рядків і без вгадування розділювачів (/ vs \).

Сьогодні нам критично потрібен relativize(...). Тому ми житимемо в «гібридному світі»: обхід робимо через File, а відносний шлях рахуємо через Path. Це нормально: це не «дві системи, які бʼються між собою», а два зручні інструменти для різних задач.

Базовий обхід: збираємо всі файли

Коли ми кажемо «обійти директорію», маємо на увазі таке: пройти всіма вкладеними директоріями й побачити кожен файл, який там лежить. У Kotlin це можна зробити різними способами, але для нашого проєкту найпрактичніший і найчитабельніший варіант — File.walkTopDown().

Важливий момент: walkTopDown() повертає «прогулянку по дереву», де траплятимуться і директорії, і файли. На цьому етапі директорії нам не потрібні як елементи списку кандидатів, тому перше, що ми робимо, — лишаємо тільки файли.

Ось мінімальна функція «зібрати всі файли взагалі»:

import java.io.File

fun collectAllFiles(sourceDir: File): List<File> {
    return sourceDir
        .walkTopDown()
        .filter { it.isFile }
        .toList()
}

Зверніть увагу на два нюанси.

Перший нюанс у тому, що walkTopDown() не зобовʼязаний «одразу все завантажити в памʼять». Обхід відбувається ліниво, а toList() змушує його реально пройти дерево й зібрати результат. Для початківців це добра новина: ви пишете «як конвеєр», а Kotlin виконує прохід тоді, коли ви просите підсумок.

Другий нюанс: isFile відсікає директорії, але не розвʼязує всіх питань на світі. Символічні посилання, спеціальні файли та проблеми доступу ми сьогодні не розбираємо — і це нормально. Наша мета — базовий стійкий прототип.

3. Розширення та фільтрація за allowedExt

Розширення файла: акуратно й передбачувано

Витягнути розширення здається елементарним: «беремо все після крапки». На практиці це одна з тих задач, де новачки стабільно наступають на граблі. І це не соромно: файлові імена не зобовʼязані бути «красивими».

Подумайте про такі імена:

  • photo.JPG — розширення є, але регістр «кричить»;
  • archive.tar.gz — крапок кілька, а яке розширення вважати «справжнім»?
  • .bashrc — крапка є, але це радше «приховане імʼя», а не розширення;
  • no_extension — крапки немає;
  • weird. — крапка є, але розширення порожнє.

Для File Organizer нам потрібна стійка й передбачувана функція: якщо розширення нормальне — повертаємо рядок у нижньому регістрі, якщо ні — повертаємо null.

Ось акуратний варіант:

import java.io.File

fun extensionOrNull(file: File): String? {
    val name = file.name
    val dot = name.lastIndexOf('.')

    if (dot <= 0) return null
    if (dot == name.lastIndex) return null

    return name.substring(dot + 1).lowercase()
}

Чому dot <= 0?

  • dot == -1 означає «крапки немає» — отже, розширення немає.
  • dot == 0 означає «крапка на самому початку» (наприклад, .bashrc). У більшості утиліт це не вважають розширенням — і ми теж не будемо. Інакше ви раптово отримаєте розширення "bashrc", а потім дивуватиметеся, чому ваш «конфіг термінала» полетів у категорію документів.

Чому dot == lastIndex?

  • Тому що weird. не має давати розширення "" (порожній рядок). Порожнє розширення — поганий кандидат. Його простіше одразу вважати відсутнім.

А що з archive.tar.gz?

  • Наш код поверне gz. Це не «істина», а правило. Для сьогоднішнього проєкту цього достатньо: ми робимо прототип і не заглиблюємося в розпізнавання складених розширень (це окрема тема й окрема політика).

Фільтрація за розширеннями: щоб .JPG не ламав вам життя

Тепер, коли ми вміємо діставати розширення, лишається просте: залишити тільки ті файли, чиє розширення входить до набору allowedExt з конфігурації.

Але тут є типова пастка: порівнювати «як є» не можна. Користувач може ввести JPG, .Jpg, jpg , а файл на диску може бути Photo.JPEG. Тому нам потрібна нормалізація в одному стилі.

Припустімо, що в минулій лекції ви вже зробили нормалізатор розширення:

fun normalizeExt(raw: String): String =
    raw.trim().removePrefix(".").lowercase()

Тоді правило таке: у конфігурації ми зберігаємо вже нормалізовані розширення. А з файла теж дістаємо нормалізоване (ми вже робимо lowercase()). І порівняння стає «нудним і надійним».

Ось збір кандидатів із фільтром:

import java.io.File

fun collectCandidates(sourceDir: File, allowedExt: Set<String>): List<File> {
    return sourceDir.walkTopDown()
        .filter { it.isFile }
        .filter { file ->
            val ext = extensionOrNull(file) ?: return@filter false
            ext in allowedExt
        }
        .toList()
}

Зверніть увагу: ми свідомо виключаємо файли без розширення (null). Це не «єдино правильно», це політика. Якщо згодом ви захочете підтримати файли без розширення, зробите це окремим правилом, а не «випадково, бо split так спрацював».

4. Відносні шляхи та модель кандидата

Відносний шлях: навіщо він потрібен

На цьому місці в новачків часто виникає чесне питання: «Навіщо нам відносний шлях, якщо у файла є file.absolutePath?».

Уявіть, що вихідна директорія така:

source/
  cats/
    cute.jpg
  docs/
    guide.pdf

Якщо ми зберігатимемо тільки абсолютний шлях, то втратимо важливу інформацію: де саме всередині source лежав файл. А інколи це корисно, щоб під час розкладання зберігати структуру піддиректорій.

Наприклад, ми хочемо (у майбутньому) отримати таке:

target/
  images/
    cats/
      cute.jpg
  docs/
    docs/
      guide.pdf

Щоб це стало можливим, нам потрібно вміти сказати: «cute.jpg лежить відносно source за шляхом cats/cute.jpg». Саме це й дає Path.relativize(...).

Мінімальна функція:

import java.io.File
import java.nio.file.Path

fun relativePath(sourceDir: File, file: File): Path {
    return sourceDir.toPath().relativize(file.toPath())
}

Але тут є підступний нюанс: relativize працює коректно, коли обидва шляхи «одного типу» — або обидва абсолютні, або обидва відносні (і на одній файловій системі). У реальному житті краще нормалізувати обидва шляхи до абсолютних.

Трохи більш «залізобетонний» варіант:

import java.io.File
import java.nio.file.Path

fun relativePath(sourceDir: File, file: File): Path {
    val src = sourceDir.toPath().toAbsolutePath().normalize()
    val f = file.toPath().toAbsolutePath().normalize()
    return src.relativize(f)
}

Це трохи довше, зате ви менше ризикуєте зловити дивні ефекти, якщо sourceDir передали як . або ./source, а файл виявився в абсолютному вигляді.

Модель кандидата: збираємо дані «достатньо, але не надто багато»

На цьому етапі ми вже вміємо:

  • пройти по дереву;
  • залишити тільки файли;
  • дістати розширення;
  • відфільтрувати за дозволеними розширеннями;
  • обчислити відносний шлях.

Тепер важливо зробити ще один крок «у бік архітектури»: не тягати по всьому проєкту «голі File», а зібрати модель кандидата — невеликий data class, який містить потрібні поля.

Чому це важливо? Тому що далі в нас будуть різні етапи обробки. І якщо в кожному місці заново обчислювати розширення та відносний шлях, ви почнете дублювати код і отримувати неузгоджені правила (а це улюблена їжа для багів).

Зробімо модель:

import java.io.File
import java.nio.file.Path

data class CandidateFile(
    val file: File,
    val ext: String,
    val relative: Path
)

Тут немає «всього на світі» (наприклад, розміру файла), тому що сьогодні ми збираємо мінімум, потрібний для подальших правил розкладання.

5. Збір кандидатів із конфігурації

Тепер зберемо все в одну функцію, яка приймає нашу конфігурацію (з минулої лекції) і повертає список кандидатів. Нагадаю ідею: конфігурація — це контракт утиліти, тож зручно, коли «вхід один».

Припустімо, у нас є:

data class OrganizerConfig(
    val sourceDir: String,
    val targetDir: String,
    val mode: Mode,
    val allowedExt: Set<String>,
    val conflictPolicy: ConflictPolicy,
    val dryRun: Boolean
)

Тоді функція збору кандидатів може виглядати так:

import java.io.File

fun collectCandidates(cfg: OrganizerConfig): List<CandidateFile> {
    val sourceDir = File(cfg.sourceDir)
    val srcPath = sourceDir.toPath().toAbsolutePath().normalize()

    return sourceDir.walkTopDown()
        .filter { it.isFile }
        .mapNotNull { file ->
            val ext = extensionOrNull(file) ?: return@mapNotNull null
            if (ext !in cfg.allowedExt) return@mapNotNull null

            val rel = srcPath.relativize(file.toPath().toAbsolutePath().normalize())
            CandidateFile(file = file, ext = ext, relative = rel)
        }
        .toList()
}

Зверніть увагу на стиль: ми використовуємо mapNotNull, щоб «файли, які не підходять», перетворювалися на null і автоматично відкидалися. Це читається так: «перетвори файл на кандидата, а якщо не виходить — просто пропусти».

І так, це той самий момент, де багато хто вперше розуміє, що Kotlin-колекції — це не лише for і if, а ще й «конвеєр перетворень». Не хвилюйтеся: якщо ви поки читаєте mapNotNull повільно, це нормально. За кілька днів ви читатимете такі ланцюжки помітно легше.

Мінідіагностика: перевіряємо, що ми не зібрали «все підряд»

На практиці після написання обходу хочеться переконатися, що функція не тягне, наприклад, директорії, або що allowedExt справді працює. Найпростіший спосіб — тимчасово вивести перші кілька кандидатів у консоль.

Додамо маленький фрагмент у main (у навчальному проєкті це нормально):

fun main() {
    val cfg = OrganizerConfig(
        sourceDir = "input",
        targetDir = "output",
        mode = Mode.COPY,
        allowedExt = setOf("jpg", "png", "pdf"),
        conflictPolicy = ConflictPolicy.SKIP,
        dryRun = true
    )

    val candidates = collectCandidates(cfg)
    println("Кандидатів: ${candidates.size}") // Кандидатів: 3 (приклад)

    for (c in candidates.take(3)) {
        println("${c.ext}: ${c.relative}")     // jpg: cats/cute.jpg (приклад)
    }
}

Тут є два корисні ефекти.

По-перше, ви миттєво бачите, що relative виглядає як «шлях усередині source», а не як повний абсолютний шлях на вашій машині.

По-друге, ви можете швидко помітити помилки нормалізації розширень. Якщо раптом бачите JPG замість jpg, значить, ви забули lowercase().

6. Типові помилки

Помилка №1: обходять дерево, але забувають filter { it.isFile }, а потім «обробляють директорії як файли».
Це виглядає невинно, доки ви не спробуєте витягнути розширення з директорії або «скопіювати» директорію як файл. У найкращому разі ви отримаєте дивні результати, у найгіршому — винятки та напівзламаний запуск. Рішення просте: одразу після walkTopDown() відфільтруйте isFile, щоб далі ваш код працював тільки з тим, що він справді вміє обробляти.

Помилка №2: витягають розширення через split(".") і отримують несподівані значення.
split створює багато шматочків, погано працює з іменами на кшталт archive.tar.gz, а ще легко ламається на .bashrc або weird.. Для утиліти важлива передбачуваність: краще один раз написати акуратну функцію з lastIndexOf('.') і граничними перевірками, ніж потім лагодити «чому воно інколи null, інколи порожній рядок, а інколи взагалі падає».

Помилка №3: порівнюють розширення без нормалізації й дивуються, що photo.JPG не потрапляє в jpg.
Файлова система не зобовʼязана допомагати вам бути акуратними. Якщо в allowedExt ви зберігаєте jpg, а з файла витягаєте JPG, то порівняння чесно скаже «не збіглося». Виправлення концептуальне: і конфігурацію, і розширення файла приводимо до одного вигляду (зазвичай lowercase() і «без крапки»).

Помилка №4: будують відносний шлях через рядки (substring) і ловлять помилки на розділювачах та довжинах.
Ручна робота з рядками в шляхах — це як лагодити годинник молотком: інколи виходить, але страшно дивитися. У Windows і Linux різні розділювачі, у шляхів буває різний «префікс», і дуже легко помилитися на один символ. Для цього й існує Path.relativize(...): він робить те, що ви хочете, і робить це однаково на різних ОС.

Помилка №5: викликають relativize на «різних типах шляхів» (один відносний, інший абсолютний) і отримують винятки або дивні результати.
Це тонкий момент, який рідко вгадують із першого разу. Якщо sourceDir задано як ".", а файл виявився абсолютним, то relativize може не спрацювати так, як ви очікували. Звичка, яка рятує нерви: перед relativize приводити обидва шляхи до toAbsolutePath().normalize(). Тоді порівняння стає стабільним і нудним (а нудьга в I/O — це комплімент).

Помилка №6: змішують збір кандидатів із виконанням операцій.
Дуже хочеться в collectCandidates одразу зробити copyTo, «адже ми вже знайшли файл». Але це призводить до коду, який важко тестувати й налагоджувати, а ще — майже неможливо безпечно запускати в dry-run режимі. Сьогоднішній принцип простий: обхід і відбір кандидатів — це чистий крок підготовки даних. Він нічого не змінює на диску, а лише повертає список структурованих кандидатів.

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