1. Навіщо потрібна фільтрація
Коли ви вперше запускаєте WalkDir, хочеться зробити ось що: «знайшов шлях — fmt.Println(path)». Це нормальний етап: мозку потрібен доказ, що магія працює, а не те, що ви випадково надрукували улюблену мантру розробника: «it works on my machine».
Але щойно обхід починає приносити десятки, сотні або тисячі шляхів, стає ясно: вивід перетворюється на кашу. У реальному коді майже ніколи не потрібно все підряд. Зазвичай потрібні тільки файли, тільки .go, усе, крім прихованих директорій, усе, що схоже на лог, або усе, що починається з task_. Саме тут і з’являється фільтрація: ми перетворюємо потік «усього» на список того, що нам справді потрібно.
Щоб було простіше тримати це в голові, корисно мислити так:
flowchart LR
A[WalkDir/ReadDir] --> B["Збирання результатів у []Item"]
B --> C[Фільтрація]
C --> D[Сортування]
D --> E[Виведення / подальша логіка]
Ми вже вміємо робити етапи A і B. Сьогодні доведемо до ладу C і D.
2. Модель даних і збирання результатів
Фільтрувати й сортувати рядки можна безпосередньо «на льоту» всередині колбеку, але в новачків це часто закінчується тим, що в одному місці змішані три завдання: обхід, умови відбору та форматування виводу. Код швидко стає схожим на навушники з кишені: ніби річ корисна, але розплутувати боляче.
Тому хороший практичний крок — ввести структуру результату, яку ми збиратимемо, а потім уже оброблятимемо окремо й послідовно. Нехай нашим навчальним застосунком дня буде маленька утиліта fspeek: вона обходить директорію, збирає знайдені елементи, фільтрує їх і друкує у передбачуваному порядку.
Міні-модель даних
Почнемо з простого типу:
package main
import "time"
type Item struct {
Path string
Name string
IsDir bool
Size int64
ModTime time.Time
}
Тут є все, що зазвичай потрібно для фільтрів і сортування: шлях — зручний для виводу, імʼя — зручне для правил HasPrefix/HasSuffix, ознака директорії та трохи метаданих для сортування за розміром і часом зміни.
Зверніть увагу на маленький, але важливий нюанс: поля Size і ModTime — це метадані. Ми не читаємо вміст файла. Ми не відкриваємо файл заради тексту. Ми просто дивимося «паспорт».
Збирання результатів
Зараз наша мета — не заново вивчити WalkDir, а отримати []Item, з яким можна працювати. Тому покажу мінімальний «збирач» — він стане основою для прикладів далі.
package main
import (
"os"
"path/filepath"
)
func collect(root string) ([]Item, error) {
var items []Item
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
items = append(items, Item{
Path: path,
Name: d.Name(),
IsDir: d.IsDir(),
Size: info.Size(),
ModTime: info.ModTime(),
})
return nil
})
return items, err
}
Так, тут є d.Info(), і він може бути «дорожчим», ніж просто d.Name()/d.IsDir(). Але для лекції про сортування за розміром і часом нам ці поля потрібні.
У реальному застосунку ви часто робите так: спочатку збираєте «легкий список», а метадані підтягуєте тільки якщо користувач попросив сортування за розміром. Поки залишимо саме так, щоб не розводити надто багато гілок.
3. Рядкові фільтри: HasPrefix, HasSuffix, Contains
Фільтрація за назвою файла — одна з найчастіших задач. Майже завжди у вас є правила рівня «не показувати приховане», «залишити тільки .go», «знайти все, схоже на конфіг».
Для цього в Go є пакет strings, який напрочуд простий: він не змушує вас спершу здобувати диплом із регулярних виразів і проходити посвяту в майстри regex.
Підключимо strings і зробимо кілька маленьких функцій-предикатів. Предикат — це функція, яка відповідає на запитання «так/ні» (bool). У нашому випадку — «залишаємо цей елемент чи викидаємо?».
Приховані файли й директорії: HasPrefix(name, ".")
Дуже типовий фільтр: у Unix-подібних системах приховані елементи часто починаються з крапки: .git, .idea, .DS_Store. Якщо ви робите звіт за проєктом, це майже завжди шум.
package main
import "strings"
func isHidden(name string) bool {
return strings.HasPrefix(name, ".")
}
Використання просте: якщо isHidden(it.Name), ми або пропускаємо елемент, або, навпаки, обираємо тільки приховані. Таке теж буває, коли ви шукаєте, куди зник .env.
Файли за розширенням: HasSuffix(name, ".go")
Розширення — другий за популярністю фільтр. Для навчального проєкту логічно показати тільки вихідники Go.
package main
import "strings"
func hasExt(name, ext string) bool {
return strings.HasSuffix(name, ext)
}
Наприклад, hasExt(it.Name, ".go") залишить Go-файли. hasExt(it.Name, ".md") — Markdown.
Пошук за підрядком: Contains
Іноді правило звучить так: «покажіть усе, де в назві трапляється test» або «знайдіть усе, схоже на лог: log».
package main
import "strings"
func nameContains(name, part string) bool {
return strings.Contains(name, part)
}
Тут важливо пам’ятати, що Contains чутливий до регістру. Тобто "Readme.md" не містить "readme". Для «людського» пошуку часто роблять нормалізацію через strings.ToLower.
package main
import "strings"
func nameContainsFold(name, part string) bool {
return strings.Contains(strings.ToLower(name), strings.ToLower(part))
}
4. Фільтрація як окремий крок: функція Filter і композиція умов
Тепер ми вміємо перевіряти умови. Але нам потрібна «машинка», яка бере []Item і залишає тільки потрібні елементи. Саме тут часто проявляється хороший стиль Go: проста функція, проста відповідальність, зрозуміла назва.
Базова функція Filter
package main
func Filter(items []Item, keep func(Item) bool) []Item {
var out []Item
for _, it := range items {
if keep(it) {
out = append(out, it)
}
}
return out
}
Ця функція робить одну річ: будує новий слайс. Вона не друкує, не сортує й не лізе у файлову систему. І це чудово: таку функцію легко читати, перевіряти та повторно використовувати.
Приклад: залишити тільки файли
package main
func onlyFiles(items []Item) []Item {
return Filter(items, func(it Item) bool {
return !it.IsDir
})
}
Якщо десь у душі ви любите писати все в один рядок, то так, можна зробити і коротше. Але ми зараз вчимося писати код так, щоб його могла прочитати людина після трьох чашок кави й одного випадкового дзвінка.
Приклад: прибрати приховані елементи
package main
func withoutHidden(items []Item) []Item {
return Filter(items, func(it Item) bool {
return !isHidden(it.Name)
})
}
Кілька умов одразу: «конвеєр» або один прохід
Дуже швидко з’являється запит: «залишити тільки файли», «тільки .go» і «не приховані». У новачків тут є спокуса зробити одну гігантську if-простирадлу, а потім ще додати туди друк, а потім ще й сортування… і все, ви в болоті.
Набагато приємніше мислити фільтрацію як конвеєр:
package main
func filterGoFiles(items []Item) []Item {
items = onlyFiles(items)
items = withoutHidden(items)
items = Filter(items, func(it Item) bool {
return hasExt(it.Name, ".go")
})
return items
}
Так, це кілька проходів по слайсу. Але для навчального рівня й більшості реальних проєктів, де йдеться про тисячі файлів, а не про мільйони, це зазвичай нормально й набагато читабельніше. А якщо колись цього стане замало, ви принаймні точно знатимете, яку ділянку оптимізувати, а не переписувати всю кашу.
Іноді все-таки хочеться один прохід. Це теж можливо, просто умова буде складнішою:
package main
func filterGoFilesOnePass(items []Item) []Item {
return Filter(items, func(it Item) bool {
return !it.IsDir && !isHidden(it.Name) && hasExt(it.Name, ".go")
})
}
Обидва підходи коректні. Перший частіше виграє читабельністю, другий — мінімальною роботою. У навчальному проєкті ми віддаватимемо перевагу читабельності.
5. Сортування: робимо вивід детермінованим
Після фільтрації в нас залишиться список, який захочеться показати користувачу. І тут з’являється несподівана правда: «природний порядок» — поняття слизьке. Навіть якщо вам здається, що WalkDir завжди поводиться однаково, не варто робити з цього контракт.
Детермінований вивід важливий для порівняння результатів, відтворюваності, майбутніх тестів і для того, щоб два запуски програми не виглядали як «рандомайзер файлів». Тому типовий крок перед виводом — сортування.
Сортування за шляхом
Найзрозуміліший критерій сортування — за шляхом:
package main
import "sort"
func sortByPath(items []Item) {
sort.Slice(items, func(i, j int) bool {
return items[i].Path < items[j].Path
})
}
Зверніть увагу: сортування змінює порядок на місці, прямо всередині items. Тому функція нічого не повертає.
Сортування за розміром і за часом зміни
Сортування за шляхом корисне майже завжди, але інколи хочеться побачити «топ найбільших файлів» або «найсвіжіші зміни». Тут ми використовуємо поля Size і ModTime.
За розміром — у спадному порядку, спочатку великі:
package main
import "sort"
func sortBySizeDesc(items []Item) {
sort.Slice(items, func(i, j int) bool {
return items[i].Size > items[j].Size
})
}
За часом зміни — спочатку нові:
package main
import "sort"
func sortByModTimeDesc(items []Item) {
sort.Slice(items, func(i, j int) bool {
return items[i].ModTime.After(items[j].ModTime)
})
}
After читається майже буквально як англійське «after»: i пізніше за j.
Tie-breaker: коли ключі рівні
Важливий нюанс: що, якщо два елементи мають однаковий розмір? Або однаковий час? Тоді ваш компаратор (less) повертає false і для «i менше j», і для «j менше i», бо жоден не є «меншим». У такій ситуації порядок «рівних» елементів може бути будь-яким.
Якщо вам потрібен передбачуваний порядок, додавайте другий критерій — наприклад, шлях. Це називається tie-breaker.
package main
import "sort"
func sortBySizeDescThenPath(items []Item) {
sort.Slice(items, func(i, j int) bool {
if items[i].Size == items[j].Size {
return items[i].Path < items[j].Path
}
return items[i].Size > items[j].Size
})
}
Тепер навіть якщо розміри рівні, порядок буде стабільним: за шляхом.
Стабільне сортування: sort.SliceStable
Іноді ви робите сортування у два проходи. Наприклад, ви хочете спочатку сортувати за шляхом, а всередині однакових директорій — за розміром. Або спочатку за розміром, а рівні елементи — зберегти «як було». Тоді вам може знадобитися стабільне сортування.
Стабільне сортування гарантує, що елементи, які є «рівними» за ключем, збережуть відносний порядок із початкового масиву.
package main
import "sort"
func sortStableByName(items []Item) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
}
У повсякденній практиці часто простіше зробити явний tie-breaker, як у прикладі вище, ніж сподіватися на «збереження початкового порядку». Але корисно знати, що SliceStable існує.
6. Мініпрограма fspeek: фільтр + сортування + друк
Тепер зробимо короткий main, який зв’язує все разом. Він буде обходити поточну директорію, залишати тільки файли .go (не приховані), сортувати за шляхом і друкувати кілька полів.
package main
import (
"fmt"
)
func main() {
items, err := collect(".")
if err != nil {
fmt.Println("збір:", err)
return
}
items = filterGoFiles(items)
sortByPath(items)
for _, it := range items {
fmt.Printf("%s size=%d\n", it.Path, it.Size)
// ./main.go size=1234
}
}
Тут важливо, що код читається як історія:
ми зібрали → відфільтрували → відсортували → вивели.
Якщо ви за тиждень повернетеся до цього коду, він не виглядатиме як «магічний ритуал із трьох вкладених циклів і двох continue підряд».
7. Шпаргалка: коли який рядковий фільтр використовувати
Іноді корисно мати маленьку шпаргалку. У Go пакет strings — ваш перший швейцарський ніж для імен файлів.
| Задача фільтрації | Функція | Приклад правила |
|---|---|---|
| Приховані елементи | |
імʼя починається з "." |
| Розширення | |
імʼя закінчується на ".go" |
| Підрядок | |
імʼя містить "test" |
| Пошук без урахування регістру | |
приводимо обидва рядки до нижнього регістру |
8. Типові помилки
Помилка №1: фільтрувати за path, коли насправді потрібно фільтрувати за іменем.
path може містити "./internal/.cache/file.tmp", і якщо ви перевіряєте HasPrefix(path, "."), ви майже нічого не знайдете: шлях зазвичай починається з "." лише у відносному записі на кшталт "./...", а це не те саме, що «прихований файл». Для правил виду «крапка на початку імені» використовуйте it.Name (або d.Name() під час обходу).
Помилка №2: зробити одну «суперфункцію», яка одночасно обходить, фільтрує, сортує й друкує.
Такий код спочатку здається швидким, а потім стає підступно важким: будь-яка правка може все поламати. Краще працює схема: спочатку зібрати []Item, потім окремою стадією відфільтрувати, далі відсортувати й наприкінці вивести. Навіть якщо це кілька проходів по даних, читабельність окупається.
Помилка №3: забути про tie-breaker і дивуватися «плаваючому» порядку.
Якщо сортувати тільки за розміром, два файли однакового розміру можуть мінятися місцями між запусками. Це особливо помітно, коли ви робите звіт і намагаєтеся порівняти результати очима. Додавайте другий критерій — зазвичай шлях — або використовуйте sort.SliceStable, якщо вам важлива стабільність.
Помилка №4: очікувати, що сортування гарантує порядок «рівних» елементів.
Звичайна sort.Slice не зобов’язана зберігати порядок елементів, які вважаються рівними за ключем. Ба більше, навіть за однакових вхідних даних різні версії Go або різні реалізації сортування можуть дати інший порядок рівних елементів. Якщо вам важлива відтворюваність виводу, робіть явні правила порівняння.
Помилка №5: випадково включити директорії до списку файлів і потім дивно інтерпретувати Size.
Розмір директорії (Size у FileInfo) не є «сумарним розміром усіх файлів усередині». Якщо ви сортуєте за розміром і не відфільтрували директорії, у вас у топі можуть опинитися папки з «несподіваними» числами, які не мають сенсу для людини. Зазвичай перед сортуванням за розміром файлів роблять onlyFiles(items).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ