1. Два стиля API в std::filesystem
Когда вы пишете код, который работает с диском, вы внезапно начинаете жить в мире, где внешняя реальность может в любой момент сказать: «нет». Файл могли удалить, директорию могли переименовать, права доступа могли запретить чтение — и это не всегда «катастрофа», иногда это штатная ветка логики. Если на каждую такую ситуацию бросать исключение и ловить его, код становится нервным, шумным и похожим на сериал, где каждый эпизод заканчивается драматической музыкой.
Пример «ожидаемой ошибки»: вы хотите удалить временный файл. Его может и не быть — это нормально, вы просто хотели «подчистить хвосты». Или вы проверяете существование директории: её может не быть, вы тогда создадите её. В таких сценариях исключения часто избыточны.
Бросающие функции и error_code‑перегрузки
В std::filesystem у многих операций есть два стиля API.
Первый — «бросающий»: если что-то пошло не так, функция кидает исключение (обычно std::filesystem::filesystem_error).
Второй — «не бросающий»: функция принимает ссылку на std::error_code, записывает туда ошибку и не кидает исключение. Такой стиль как раз и нужен, когда ошибка ожидаема по сценарию. Идея перегрузок с error_code — нормальная часть дизайна filesystem.
Выглядит это обычно так: есть функция fs::exists(p) (бросающая форма) и рядом fs::exists(p, ec) (форма «ошибка как значение»). То же касается многих других операций.
Смысл простой: вы выбираете стиль обработки ошибок под вашу задачу. Если ошибка — неожиданная (например, «не смог создать важную рабочую директорию приложения»), исключения могут быть уместны. Если ошибка — ожидаемая (например, «временного файла могло не быть»), то error_code‑перегрузка чаще удобнее.
Мини‑анатомия std::error_code
std::error_code — это небольшой объект, который внутри хранит «код ошибки» и «категорию» (упрощённо: откуда эта ошибка и как её интерпретировать). В повседневном коде нам важны три вещи: можно ли проверить «ошибка есть?», можно ли её обнулить, и можно ли получить человекочитаемое сообщение. Именно для этого обычно используют: if (ec), ec.clear() и ec.message().
Сразу важный психологический момент: std::error_code — это не исключение, он никого не «роняет». Он просто лежит рядом и говорит: «если ты спросишь, я расскажу, что пошло не так». В этом есть плюс: вы сами решаете, когда ошибка критична, а когда — просто информация.
Небольшая иллюстрация (не про filesystem, а просто чтобы почувствовать стиль):
#include <iostream>
#include <system_error>
int main() {
std::error_code ec; // ec пустой (ошибки нет)
std::cout << std::boolalpha;
std::cout << "has error: " << static_cast<bool>(ec) << '\n'; // has error: false
ec = std::make_error_code(std::errc::permission_denied);
std::cout << "has error: " << static_cast<bool>(ec) << '\n'; // has error: true
std::cout << "message: " << ec.message() << '\n'; // message: Permission denied (формулировка зависит от ОС)
}
В std::filesystem вы чаще будете получать ec от самой операции, а не создавать вручную.
Бросающие операции и try/catch
Исключения в filesystem работают логично: вызвали операцию — если не получилось, прилетело исключение, вы его поймали. Для действительно «нештатных» ситуаций это нормально. Но если вы делаете много маленьких проверок/действий, код превращается в ковёр из try/catch, а читателю хочется выйти в окно (аккуратно, через std::filesystem::path).
Типичный пример «бросающего» стиля:
#include <filesystem>
#include <iostream>
int main() {
namespace fs = std::filesystem;
try {
fs::rename("missing.txt", "moved.txt");
std::cout << "renamed\n"; // renamed
} catch (const fs::filesystem_error& e) {
std::cerr << "filesystem_error: " << e.what() << '\n';
}
}
Проблема не в том, что это «плохо». Проблема в том, что для ожидаемых ошибок этот стиль слишком «громкий». Если у вас по сценарию файл может отсутствовать, вы не хотите каждый раз устраивать мини‑расследование уровня «кто украл missing.txt».
error_code‑стиль: базовый шаблон
С error_code‑перегрузками вы пишете код более «ветвящийся», но зато без исключений и с прозрачным управлением потоком. Ключевой навык — строго соблюдать порядок: сначала проверяем ec, и только потом трактуем bool/результат. Потому что некоторые функции в случае ошибки возвращают «похожее на нормальный ответ» (например, false) — и без ec вы не поймёте, это «не существует» или «не смог проверить».
Шаблон обычно такой:
#include <filesystem>
#include <iostream>
#include <system_error>
int main() {
namespace fs = std::filesystem;
std::error_code ec;
fs::rename("missing.txt", "moved.txt", ec);
if (ec) {
std::cerr << "rename error: " << ec.message() << '\n';
return 1;
}
std::cout << "renamed\n"; // renamed (если получилось)
}
Здесь важно, что rename в error_code‑стиле не бросает исключение, а просто заполняет ec.
Важная тонкость: false не всегда означает ошибку
С std::filesystem есть классический «подвох для новичка»: некоторые операции возвращают bool, где false означает «ничего не изменилось», но это может быть нормально. При этом ошибка отдельно живёт в ec. Это прямо то, из‑за чего порядок «сначала ec, потом bool» должен стать привычкой.
Показательный пример — remove. Он возвращает true, если что-то реально удалили, и false, если удалять было нечего. Это не обязано быть ошибкой.
#include <filesystem>
#include <iostream>
#include <system_error>
int main() {
namespace fs = std::filesystem;
std::error_code ec;
bool removed = fs::remove("maybe_tmp.log", ec);
if (ec) {
std::cerr << "remove error: " << ec.message() << '\n';
return 1;
}
std::cout << std::boolalpha;
std::cout << "removed: " << removed << '\n'; // removed: false (если файла не было)
}
Смысл: removed == false может означать «файл отсутствовал, всё ок». А вот ec означает «операция не смогла корректно выполниться».
На примере exists: «не существует» vs «не смог проверить»
Функция exists кажется очень простой: есть путь или нет. Но реальность сложнее: «не смог проверить» — это третье состояние. Например, нет прав доступа к директории, где лежит файл, или путь битый с точки зрения ОС. В бросающем стиле это было бы исключение. В error_code‑стиле это отражается как ec != 0.
И вот тут появляется ловушка: exists(p, ec) в случае ошибки может вернуть false. То есть false означает и «не существует», и «не удалось проверить». Поэтому вы обязаны проверять ec.
#include <filesystem>
#include <iostream>
#include <system_error>
int main() {
namespace fs = std::filesystem;
std::error_code ec;
bool ok = fs::exists("some/path", ec);
if (ec) {
std::cerr << "exists error: " << ec.message() << '\n';
return 1;
}
std::cout << std::boolalpha << "exists: " << ok << '\n';
}
Если вы забудете if (ec), вы получите программу, которая на проблемах доступа будет говорить «ну, наверное, не существует» — и вы будете чинить не то место.
Дисциплина работы с ec: не тащить «старую ошибку» дальше
std::error_code — обычная переменная. А значит, она умеет хранить старое значение. Это звучит очевидно, но это одна из самых частых причин «странных» багов: вы сделали одну операцию, она записала ошибку, потом сделали вторую операцию (которая могла вообще не трогать ec в том месте, где вы ожидали), а вы проверили ec и думаете, что ошибка от второй операции.
Есть два хороших стиля.
Первый стиль — создавать ec на каждую операцию отдельно, прямо рядом:
std::error_code ec;
bool ok = fs::create_directories(dir, ec);
if (ec) { /* обработать */ }
Второй стиль — использовать один ec, но перед каждым вызовом делать ec.clear():
ec.clear();
fs::rename(from, to, ec);
if (ec) { /* обработать */ }
Для новичка первый стиль обычно проще: меньше шансов перепутать контекст.
3. Пример: мини‑утилита NoteVault
Чтобы не осталось ощущения «это всё теория ради теории», давайте встроим это в простое консольное приложение. Пусть это будет мини‑утилита NoteVault: заметки хранятся в директории notes/, каждая заметка — обычный текстовый файл. В прошлые дни мы бы читали/писали файл через ifstream/ofstream, а сегодня наша задача — безопасно управлять путями и файловыми операциями: создать директорию, проверить существование, переименовать, удалить — и не превращать отсутствие файла в трагедию.
Сделаем три маленьких функции: получить путь к директории заметок, гарантировать, что директория существует, и удалить заметку «мягко» (если её нет — это не ошибка).
База: create_directories без исключений
Начнём с функции EnsureNotesDir, которая создаёт notes/ при необходимости. Ошибка тут уже может быть важной: если вы вообще не можете создать рабочую директорию, приложение толком не сможет работать.
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
fs::path NotesDir() {
return fs::path{"notes"};
}
bool EnsureNotesDir(std::error_code& ec) {
ec.clear();
fs::create_directories(NotesDir(), ec);
return !ec;
}
Обратите внимание: create_directories возвращает bool (создали или уже было), но в нашей логике важнее другое: удалось ли это сделать вообще. Поэтому функция возвращает !ec.
«Мягкое удаление»: отсутствие файла — нормальный исход
Теперь функция удаления. Идея такая: пользователь мог попросить удалить заметку, но файла может уже не быть. Это не повод падать, это повод сказать «удалять нечего».
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
bool RemoveNote(std::string_view name, std::error_code& ec) {
ec.clear();
fs::path p = NotesDir() / fs::path{name};
p.replace_extension(".txt");
bool removed = fs::remove(p, ec);
if (ec) return false;
return removed; // true = удалили, false = не было
}
Заметьте приятный бонус fs::path: мы заменяем расширение через replace_extension, а не через ручной поиск точки в строке. Это как минимум экономит вам один вечер жизни.
Переименование заметки в error_code‑стиле
Переименование — операция, где ошибки могут быть ожидаемыми: исходного файла может не быть; целевой файл может уже существовать; могут быть права доступа. Мы хотим корректно показать сообщение, а не ловить исключения.
#include <filesystem>
#include <system_error>
#include <string_view>
namespace fs = std::filesystem;
bool RenameNote(std::string_view from, std::string_view to, std::error_code& ec) {
ec.clear();
fs::path pFrom = NotesDir() / fs::path{from};
pFrom.replace_extension(".txt");
fs::path pTo = NotesDir() / fs::path{to};
pTo.replace_extension(".txt");
fs::rename(pFrom, pTo, ec);
return !ec;
}
Весь контракт в одном месте: если ec пустой — переименовали.
Скелет main: действия и понятные сообщения
Теперь сделаем очень простой main, который не является полноценным CLI‑парсером (это отдельная большая тема), но уже демонстрирует нормальный стиль: действие → проверка ec → сообщение человеку.
#include <filesystem>
#include <iostream>
#include <string>
#include <system_error>
namespace fs = std::filesystem;
fs::path NotesDir();
bool EnsureNotesDir(std::error_code& ec);
bool RemoveNote(std::string_view name, std::error_code& ec);
bool RenameNote(std::string_view from, std::string_view to, std::error_code& ec);
int main() {
std::error_code ec;
if (!EnsureNotesDir(ec)) {
std::cerr << "Cannot create notes dir: " << ec.message() << '\n';
return 1;
}
bool removed = RemoveNote("temp", ec);
if (ec) {
std::cerr << "Remove failed: " << ec.message() << '\n';
return 1;
}
std::cout << std::boolalpha << "Removed temp: " << removed << '\n'; // Removed temp: false
bool renamed = RenameNote("draft", "done", ec);
if (!renamed) {
std::cerr << "Rename failed: " << ec.message() << '\n';
return 1;
}
std::cout << "Renamed draft -> done\n"; // Renamed draft -> done
return 0;
}
Да, это пока «в лоб». Но по нему уже видно главное: мы не ловим исключения для ожидаемых ситуаций, и при этом не теряем диагностические сообщения.
4. Как выбрать: исключения или error_code
Здесь нет универсального «всегда делай так». Но есть очень практичное правило, которое хорошо работает в учебных и реальных проектах.
Если ошибка означает «мы не можем продолжать сценарий нормально» и это действительно нештатная ситуация, исключения могут быть удобны: код остаётся линейным, и вы ловите проблему «на верхнем уровне». Если же ошибка является частью обычной логики (файла может не быть, директория может быть недоступна, пользователь мог ошибиться в имени), то error_code‑перегрузки дают вам спокойный, управляемый код без постоянных try/catch.
В рамках сегодняшней темы мы тренируемся как раз во втором подходе: сделать приложение, которое не считает внешний мир обязанным быть идеальным.
5. Типичные ошибки при работе с error_code в std::filesystem
Ошибка №1: проверять только bool и игнорировать ec.
Так часто происходит с exists и remove: вы видите false и делаете вывод «не существует» или «не удалилось». Но это может быть не «не существует», а «не смог проверить», и не «не удалилось», а «ошибка доступа». Если вы не проверили ec, вы фактически отключили себе диагностику и оставили в коде загадку.
Ошибка №2: повторно использовать один и тот же std::error_code и забыть очистить.
ec не очищается сам по себе «потому что вы так задумали». Если в нём осталась старая ошибка, следующая проверка if (ec) может сработать на ровном месте, и вы будете обвинять в этом невиновный rename, хотя виноват предыдущий remove.
Ошибка №3: смешивать в одном сценарии исключения и error_code.
Технически так можно, но читать и сопровождать такое тяжело. В одном месте код «падает» исключением, в другом — тихо пишет в ec. Новичок в такой код заходит бодрым, а выходит слегка поседевшим. Обычно лучше выбрать один стиль на один сценарий.
Ошибка №4: печатать только ec.message() без контекста.
Сообщение ошибки полезно, но без пути и описания операции оно иногда малоинформативно. Хороший минимум: печатать «что делали» и «с каким путём». Иначе лог превращается в набор загадочных фраз вроде «Permission denied», как будто это не программа, а охранник в ночном клубе.
Ошибка №5: пытаться через error_code «замести под ковёр» критические ошибки.
error_code — не способ сделать вид, что проблем нет. Это способ аккуратно обработать ожидаемую проблему. Если приложение не может создать рабочую директорию или записать важный файл, это обычно надо явно сообщить и завершиться с ошибкой, а не продолжать в поломанном состоянии «ну ладно, как‑нибудь без данных».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ