1. Зачем нужен обход директорий
Если вы только начинаете, кажется, что программа всегда работает с конкретным файлом: input.txt, config.json, notes.md. Но реальная жизнь быстро подкидывает задачи, где файлов много, и вы не хотите перечислять их руками. Например, вы хотите показать пользователю список всех файлов в папке logs, найти все .txt в data, или рекурсивно пройтись по проекту и вывести «дерево» файлов.
И вот тут у новичков возникает соблазн «написать свой обход», разбирая вывод командной строки или склеивая пути строками. Это обычно заканчивается тем, что программа работает на одном компьютере, а на другом внезапно ломается (и вы такие: «но у меня же работало!»). std::filesystem как раз и создан, чтобы эти сценарии делать стандартно, переносимо и с понятным контрактом.
В этой лекции мы рассмотрим две «ноги» обхода:
- fs::directory_iterator — пройти только один уровень (только содержимое конкретной папки);
- fs::recursive_directory_iterator — пройти всё дерево (папка, подпапки, подпапки подпапок… да, это может быть бесконечно, если вы сделаете глупость, но сегодня мы будем мудрыми).
Подготовка: directory_entry и модель элемента
Перед тем как писать циклы, полезно понять, что именно мы получаем на каждой итерации. В std::filesystem элемент директории представлен как fs::directory_entry. Это удобный объект: он хранит путь, и у него есть методы вроде is_regular_file() и is_directory(). То есть вместо «я нашёл строку report.txt и теперь сам решаю, что это» вы получаете нормальную «карточку» элемента.
Для читаемости почти всегда вводят псевдоним пространства имён:
#include <filesystem>
#include <iostream>
int main() {
namespace fs = std::filesystem;
std::cout << "filesystem is ready\n"; // filesystem is ready
}
Теперь начнём собирать наше учебное мини-приложение. Пусть это будет утилита DirInspector: она спрашивает у пользователя путь к папке и печатает содержимое. Сначала одним уровнем, потом рекурсивно, а затем — «без исключений», через error_code.
2. fs::directory_iterator: обход одного уровня
Самая частая задача: «покажи, что лежит в этой папке». Не в подпапках, не вглубь на 20 уровней, а именно содержимое «текущей коробки».
fs::directory_iterator(dir) создаёт итератор, по которому можно пройти range-for’ом. Внутри цикла мы получаем fs::directory_entry, и уже из него достаём путь.
Скелет выглядит почти как магия, но это «хорошая магия», из стандартной библиотеки:
#include <filesystem>
#include <iostream>
int main() {
namespace fs = std::filesystem;
for (const fs::directory_entry& e : fs::directory_iterator(".")) {
std::cout << e.path().filename().string() << '\n';
}
}
Обратите внимание на filename(). Если печатать e.path().string(), вы получите полный путь (или относительный, смотря как создали итератор). А filename() — это «последний кусочек», то есть имя элемента. Для «красивого списка» обычно печатают именно его.
Мини-рефакторинг: функция печати элемента
Чтобы main() не превращался в «лапшу из cout», вынесем формат вывода в функцию. Это мы уже умеем по дням про функции.
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void print_entry(const fs::directory_entry& e) {
std::cout << e.path().filename().string() << '\n';
}
И применим:
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
void print_entry(const fs::directory_entry& e) {
std::cout << e.path().filename().string() << '\n';
}
int main() {
for (const auto& e : fs::directory_iterator(".")) {
print_entry(e);
}
}
Здесь const auto& — наш старый друг: не копируем directory_entry, просто читаем.
Фильтрация: только файлы
Часто нужно не всё подряд, а, например, только файлы. У directory_entry есть метод is_regular_file():
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
for (const auto& e : fs::directory_iterator(".")) {
if (e.is_regular_file()) {
std::cout << e.path().filename().string() << '\n';
}
}
}
Точно так же можно фильтровать директории через e.is_directory().
Здесь важно не путать уровни абстракции: e.is_regular_file() — это вопрос к файловой системе. А e.path().extension() — это вопрос к строковому представлению пути. Расширение не гарантирует, что это файл, и уж точно не гарантирует, что этот файл читается или вообще существует прямо сейчас (в многопользовательской системе всё может исчезнуть между двумя строчками кода).
4. fs::recursive_directory_iterator: обход дерева
Когда вы слышите слово «рекурсивный», можно на секунду напрячься: «О нет, опять рекурсия?» Спокойно: рекурсия будет внутри стандартной библиотеки, а мы просто воспользуемся готовым итератором.
fs::recursive_directory_iterator(dir) делает обход «в глубину» по дереву директорий. Для новичка это выглядит почти как «включить чит-код»:
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
for (const auto& e : fs::recursive_directory_iterator(".")) {
if (e.is_regular_file()) {
std::cout << e.path().string() << '\n';
}
}
}
Здесь мы печатаем path().string() целиком, потому что при рекурсивном обходе одинаковые filename() легко повторяются в разных папках. Если вы выведете только filename(), получите список вида:
main.cpp
main.cpp
main.cpp
и будете думать, что у вас троится код. Хотя, возможно, он и правда троится — но это уже другой курс.
Таблица сравнения итераторов
Иногда удобнее один раз увидеть «сравнение характеров»:
| Итератор | Что обходит | Типичный вывод | Главный риск |
|---|---|---|---|
|
Один уровень | имена элементов папки | ошибки доступа/исчезновение файлов |
|
Всё дерево | полный путь до файлов | ошибок ещё больше + можно залезть куда не ждали |
И да, ошибок при рекурсивном обходе обычно больше просто потому, что вы проходите больше объектов. Это не «плохая библиотека», это реальная жизнь: права доступа, временные файлы, «папка исчезла», «файл занят», «антивирус решил вмешаться».
5. Ошибки обхода и безопасный режим
Очень хочется думать, что обход директории — это «ну цикл и цикл». Но он обращается к внешнему миру (к файловой системе), а значит ошибки — это не редкость, а один из нормальных исходов.
Важно понимать: даже если вы уже сделали exists(dir), это не даёт гарантии, что обход не упадёт. Между проверкой и началом обхода директория может стать недоступной. Между итерациями файл может исчезнуть. Между чтением и печатью у вас может смениться рабочая директория. Мир не обязан быть стабильным ради нашей красоты.
Забавный (и чуть грустный) факт: даже в формулировках стандарта исторически было много обсуждений про то, как именно должны вести себя directory_iterator и recursive_directory_iterator при ошибках, включая вопросы «становится ли итератор равным end при ошибке?» и насколько чётко специфицирован increment(error_code&). То есть вы не одиноки: даже комитет C++ периодически смотрит на файловую систему и говорит: «ммм… давайте уточним».
Из этого следует практическое правило: если вы пишете «учебный код для себя» — можно использовать исключения, чтобы было проще. Если вы пишете «утилиту, которая должна жить» — стиль с std::error_code часто предсказуемее, потому что вы явно видите, где и что пошло не так, и решаете, продолжать или остановиться.
Безопасный обход без исключений: error_code + increment(ec)
Сейчас будет самый «инженерный» кусок лекции. Он не сложный, но требует дисциплины: после операций с файловой системой мы проверяем ec. Идея такая: мы создаём итератор с ec, а затем двигаем его через it.increment(ec).
Вот шаблон «без исключений» для обычного (не рекурсивного) обхода:
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
int main() {
std::error_code ec;
fs::directory_iterator it{".", ec}, end{};
if (ec) { std::cerr << ec.message() << '\n'; return 0; }
for (; it != end; it.increment(ec)) {
if (ec) { std::cerr << ec.message() << '\n'; break; }
std::cout << it->path().filename().string() << '\n';
}
}
Обратите внимание на it->path(): итератор разыменовывается как указатель на directory_entry. Это нормально: итераторы в STL часто ведут себя «как умные указатели».
Почему такой шаблон не выглядит как «красивый range-for»? Потому что range-for прячет инкремент внутри себя, а нам нужно уметь передать ec именно в increment(ec). Мы платим несколькими строчками кода за контроль над ошибками. Обычно это честная сделка.
Рекурсивный обход в стиле error_code
Идея та же: у рекурсивного итератора тоже есть increment(ec). В учебном варианте нам достаточно запомнить принцип: «итератор + end + increment(ec) + проверка ec рядом».
Схематично это можно представить так:
Создали iterator(dir, ec) → если ec не пустой: сообщили ошибку и вышли.
Если ec пустой: печатаем *it → делаем increment(ec).
Если после increment(ec) ec не пустой: решаем, что делать (break или continue).
Если ec пустой: возвращаемся к печати *it и продолжаем.
Эта схема хороша тем, что показывает главный смысл: не надо пытаться «победить все ошибки». Надо сделать так, чтобы ошибка была обработана понятным способом: либо вы прекращаете обход, либо пропускаете проблемный элемент и идёте дальше (сегодня мы чаще будем прекращать, чтобы не уходить в сложные политики).
6. Мини-приложение DirInspector: читаем путь и печатаем список
Теперь соберём всё в один небольшой сценарий, чтобы это было не «набор разрозненных фокусов», а заготовка для полезной утилиты.
Мы будем:
- читать путь строкой (getline, чтобы пробелы не ломали ввод),
- строить fs::path,
- пробовать обойти директорию в безопасном стиле,
- печатать элементы.
Сделаем функцию list_dir_flat(...):
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
void list_dir_flat(const fs::path& dir) {
std::error_code ec;
fs::directory_iterator it{dir, ec}, end{};
if (ec) { std::cerr << "open: " << ec.message() << '\n'; return; }
for (; it != end; it.increment(ec)) {
if (ec) { std::cerr << "iter: " << ec.message() << '\n'; break; }
std::cout << it->path().filename().string() << '\n';
}
}
А теперь main, который спрашивает путь:
#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
void list_dir_flat(const fs::path& dir);
int main() {
std::cout << "Enter directory: ";
std::string s;
std::getline(std::cin, s);
list_dir_flat(fs::path{s});
}
Да, fs::path{s} можно создать даже если директории нет. Это нормально: path — просто значение. Реальные проблемы мы увидим при попытке создать итератор, и там же аккуратно обработаем ошибку.
Если вы хотите добавить «рекурсивный режим», можно сделать вторую функцию list_dir_recursive(...) и вызывать её по выбору пользователя. Но в рамках обзора достаточно чётко понимать разницу итераторов и общий паттерн.
7. Типичные ошибки
Ошибка №1: пытаться обходить директорию, «склеивая пути строками».
Новички часто делают base + "/" + name и думают, что это «везде работает». На практике вы быстро получаете кашу из лишних слэшей, неправильных разделителей и проблем с относительными путями. Лекарство простое: как только речь о путях — используйте std::filesystem::path и операции над ним, а строки оставьте для ввода/вывода.
Ошибка №2: печатать только filename() при рекурсивном обходе и удивляться «дубликатам».
Это особенно смешно, если вы обходите проект, где в каждой папке есть CMakeLists.txt или main.cpp. Вывод превращается в «день сурка», и вы начинаете искать баг в итераторе. На самом деле баг в формате вывода: при рекурсивном обходе чаще нужен полный путь (path().string()), чтобы видеть, где именно найден файл.
Ошибка №3: думать, что exists(dir) гарантирует, что обход не упадёт.
Проверки «перед действием» полезны, но они не защищают от изменений внешнего мира. Даже если объект существовал секунду назад, сейчас он может стать недоступным. Поэтому операции обхода всё равно должны иметь обработку ошибок: либо через try/catch, либо через error_code.
Ошибка №4: смешивать исключения и error_code в одном и том же фрагменте обхода.
Получается код, где часть ошибок «ловится» через catch, часть лежит в ec, а часть вообще игнорируется. В итоге невозможно предсказать поведение: то ли программа упадёт, то ли промолчит. Выберите один стиль на сценарий и придерживайтесь его: для «тихого» утилитарного обхода чаще удобен error_code.
Ошибка №5: в стиле error_code проверять только bool-результаты и забывать смотреть на ec.
Некоторые filesystem-функции возвращают bool, но это не всегда «успех/ошибка». Например, remove может вернуть false, потому что «удалять нечего», и это нормальный исход. При обходе аналогично: важно различать «итерация закончилась» и «итерация прервалась ошибкой». Поэтому дисциплина такая: сначала смотрим на ec, и только потом трактуем то, что вернула функция или что произошло в цикле.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ