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: ${candidates.size}") // Candidates: 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 режиме. Сегодняшний принцип простой: обход и отбор кандидатов — это чистый шаг подготовки данных. Он ничего не меняет на диске, а только возвращает список структурированных кандидатов.

1
Задача
Kotlin SELF, 46 уровень, 1 лекция
Недоступна
Разведка каталога
Разведка каталога
1
Задача
Kotlin SELF, 46 уровень, 1 лекция
Недоступна
Расширение аккуратно
Расширение аккуратно
1
Задача
Kotlin SELF, 46 уровень, 1 лекция
Недоступна
Отбор кандидатов
Отбор кандидатов
1
Задача
Kotlin SELF, 46 уровень, 1 лекция
Недоступна
Карточки кандидатов
Карточки кандидатов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ