1. Три шари надійного завантаження
Коли новачок уперше додає завантаження з файлу, уява малює просту картину: «відкрив → прочитав → використовую». На практиці файл часто поводиться як «коробка з проводами від минулих версій програми, кота та всесвіту»: у ньому можуть бути порожні рядки, пошкоджені записи, дивні символи й несподівані формати. Тому мислитимемо шарами. Інакше баги почнуть мігрувати проєктом, мов перелітні птахи.
Зручно уявляти логіку як «ланцюжок відповідальності» з трьох шарів:
- I/O: фізично читаємо байти або рядки з файлу й перевіряємо, що потік не перейшов у помилковий стан.
- Парсинг: перетворюємо рядок на поля (наприклад, id, done, title).
- Валідація: перевіряємо, що поля мають сенс для нашої моделі й не порушують інваріанти.
Зручно тримати в голові таку схему — вона й буде нашим «контрактом завантаження»:
flowchart TD
A[Відкрити файл] --> B{Потік відкрився?}
B -- ні --> X[Помилка відкриття]
B -- так --> C[Читати посторінково getline]
C --> D{Рядок прочитано?}
D -- ні, EOF --> Z[Успіх: кінець файлу]
D -- ні, помилка --> Y[Помилка читання]
D -- так --> E[Парсинг рядка в поля]
E --> F{Парсинг успішний?}
F -- ні --> G[Помилка формату запису]
F -- так --> H[Валідація інваріантів]
H --> I{Валідно?}
I -- ні --> J[Помилка сенсу даних]
I -- так --> K[Додати запис у vector]
K --> C
2. Міні‑модель для прикладів: список задач
Щоб не пояснювати все «у вакуумі», візьмемо просту модель, схожу на те, що ви вже не раз робили в консольних задачах: список задач (todo). Вона досить проста, щоб не відволікати, і водночас достатньо «жива», щоб показати і парсинг, і валідацію, і різні політики помилок.
Нехай у памʼяті маємо таку модель:
#include <string>
struct Task {
int id{};
bool done{};
std::string title;
};
Тепер домовимося про формат файлу: це звичайний текст, де «один рядок = один запис»:
id;done;title
Де done — це 0 або 1. Наприклад:
1;0;Buy milk
2;1;Learn C++ streams
3;0;Stop writing while(!eof())
Чому я вибрав ;? Бо пробіли в title для нас важливі, а «читання токенами через >>» саме їх і відтинає. Тому логіка буде така: «читаємо рядок цілком → розбираємо його всередині».
3. Парсинг і валідація одного рядка
Акуратний розбір рядка
Перш ніж сперечатися, що краще — fail fast чи best effort, треба навчитися стабільно розбирати один рядок. Тут є важлива психологічна пастка: здається, ніби парсинг — це «усього ж три поля». Але якщо змішати парсинг, валідацію та ще й читання файлу в одному місці, то вже за тиждень навіть ви самі дивитиметеся на цей код як на давній манускрипт без перекладача.
Зробимо невелику функцію, яка розбиває рядок на три частини. Для текстового формату з роздільником зручно використати std::getline усередині std::stringstream:
#include <sstream>
#include <string>
bool split_task_line(const std::string& line,
std::string& id_s,
std::string& done_s,
std::string& title) {
std::stringstream ss(line);
return std::getline(ss, id_s, ';')
&& std::getline(ss, done_s, ';')
&& std::getline(ss, title);
}
Тут важливий момент: третє getline(ss, title) читає «до кінця», тобто title може містити пробіли — і це нормально. Якщо в рядку немає двох ;, функція поверне false, і ми чесно скажемо: «формат пошкоджений».
Розбір чисел і прапорців через from_chars
Тепер крок, який робить ваше завантаження відчутно дорослішим: акуратний розбір чисел без винятків. Ми вже говорили в курсі, що stoi може спричинити помилку виконання на некоректних даних. Під час завантаження файлу некоректні дані — не виняток, а звична ситуація: файл могли відредагувати вручну, пошкодити іншою версією програми або він просто може виявитися порожнім.
Використаємо std::from_chars: він не кидає винятків, а повертає код помилки. Це ідеально для «надійного завантаження».
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Зверніть увагу на невелику перевірку ptr != end: вона відсікає рядки на кшталт "12abc". Інакше ви «успішно» прочитали б 12, а хвіст мовчки проігнорували. А це один із найнеприємніших типів багів: «програма працює, але неправильно».
Для done зробимо так само, але з вузьким набором значень:
#include <optional>
#include <string_view>
std::optional<bool> parse_done(std::string_view s) {
if (s == "0") return false;
if (s == "1") return true;
return std::nullopt;
}
Валідація інваріантів
Коли рядок успішно розпарсився на три поля, легко видихнути й сказати: «ура, запис готовий». Але це лише «готовий за формою», а нам ще треба перевірити, чи «готовий за змістом». Це і є валідація.
Змістові правила (інваріанти) для нашої Task можна сформулювати так: id має бути більшим за нуль, заголовок не має бути порожнім, а також не має бути надто довгим.
Зробимо функцію валідації, яка повертає текст помилки або порожній рядок:
#include <string>
std::string validate_task(const Task& t) {
if (t.id <= 0) return "id must be > 0";
if (t.title.empty()) return "title must not be empty";
if (t.title.size() > 200) return "title is too long";
return "";
}
Чому рядок, а не bool? Тому що bool без повідомлення майже марний для діагностики. А повідомлення, особливо з номером рядка, може заощадити вам години життя.
Функція parse_task_line: парсинг + валідація
Тепер можемо зібрати все у функцію, яка отримує рядок і або створює Task, або повертає опис проблеми. У межах «базового» стилю покажемо варіант із std::optional<Task> і окремим рядком помилки — він зрозумілий і компілюється майже всюди.
#include <optional>
#include <string>
std::optional<Task> parse_task_line(const std::string& line, std::string& error) {
std::string id_s, done_s, title;
if (!split_task_line(line, id_s, done_s, title)) {
error = "bad format, expected: id;done;title";
return std::nullopt;
}
auto id = parse_int(id_s);
auto done = parse_done(done_s);
if (!id || !done) {
error = "bad number in id/done";
return std::nullopt;
}
Task t{.id = *id, .done = *done, .title = title};
error = validate_task(t);
if (!error.empty()) return std::nullopt;
return t;
}
Так, це трохи більше ніж 10 рядків, але зверніть увагу на ідею: ми чітко розділили «формат», «розбір чисел» та «інваріанти». Якщо щось ламається, ви одразу розумієте, що саме, а не просто бачите: «щось пішло не так, щасти».
4. Стратегії обробки помилок
Fail fast і best effort: що це і коли застосовувати
У цьому місці зазвичай хочеться запитати: «То який варіант правильний?» Fail fast і best effort — це не «сильний» і «слабкий» підходи, а дві політики, які обирають залежно від задачі. Проблема новачків у тому, що політика часто обирається випадково — «як само вийшло». Ми зробимо навпаки: оберемо її свідомо.
Порівняймо їх у таблиці, щоб різниця була не абстрактною:
| Політика | Що робимо, коли трапляється перший поганий запис | Що повертаємо | Коли зручно |
|---|---|---|---|
| Fail fast | Зупиняємо завантаження й вважаємо файл некоректним | «успіх із усіма даними» або «помилка» | Конфігураційні файли, критичні дані, міграції формату, коли часткове завантаження небезпечне |
| Best effort | Пропускаємо поганий запис і рухаємося далі | Список валідних записів + звіт про проблеми | Журнали, імпорт користувацьких даних, «брудні» файли, коли краще показати хоч щось |
Важлива деталь: навіть best effort зазвичай не повинен ігнорувати I/O‑помилку читання файлу. Якщо потік зламався посередині через проблеми з диском, «пропустити рядок» не допоможе. Best effort стосується поганих записів, а не помилки читання як такої.
Fail fast: реалізація завантажувача
Fail fast простіше тестувати і простіше пояснювати користувачеві: «файл пошкоджений, завантаження скасовано». Це зручно, коли файл — частина вашого контракту і має бути цілком коректним.
Зробімо завантажувач, який читає рядок за рядком, парсить дані, а коли натрапляє на проблему, припиняє роботу зі зрозумілим повідомленням і номером рядка:
#include <fstream>
#include <optional>
#include <string>
#include <vector>
std::optional<std::vector<Task>> load_tasks_fail_fast(const std::string& filename,
std::string& error) {
std::ifstream in(filename);
if (!in) { error = "cannot open file"; return std::nullopt; }
std::vector<Task> tasks;
std::string line;
int line_no = 0;
while (std::getline(in, line)) {
++line_no;
if (line.empty()) continue;
std::string local_error;
auto t = parse_task_line(line, local_error);
if (!t) { error = "line " + std::to_string(line_no) + ": " + local_error; return std::nullopt; }
tasks.push_back(*t);
}
if (!in.eof()) { error = "read error"; return std::nullopt; }
return tasks;
}
Зверніть увагу на if (!in.eof()) після циклу. Ми вже говорили, що getline може зупинитися не лише через EOF. Ця перевірка — ваш «датчик чесності»: вона не дає випадково прийняти помилку читання за «нормальний кінець файлу».
Якщо ваш компілятор підтримує C++23 std::expected, то тут дуже добре лягає сигнатура «або значення, або помилка». У стандарті C++ справді є заголовок <expected>, але його доступність залежить від конкретної стандартної бібліотеки та версії компілятора.
Best effort: реалізація завантажувача та звіт
Best effort зазвичай обирають там, де дані «брудні за своєю природою»: користувач експортував CSV з Excel, щось підправив руками, загубив один роздільник — і ось уже половина рядків пошкоджені. Політика best effort дає змогу сказати: «я завантажив 93 задачі, 7 рядків пропустив — ось чому».
Зробімо структуру звіту:
#include <string>
#include <vector>
struct LoadReport {
std::vector<Task> tasks;
std::vector<std::string> problems;
};
І сам завантажувач:
#include <fstream>
#include <string>
LoadReport load_tasks_best_effort(const std::string& filename) {
std::ifstream in(filename);
LoadReport r;
if (!in) { r.problems.push_back("cannot open file"); return r; }
std::string line;
int line_no = 0;
while (std::getline(in, line)) {
++line_no;
if (line.empty()) continue;
std::string err;
auto t = parse_task_line(line, err);
if (!t) {
r.problems.push_back("line " + std::to_string(line_no) + ": " + err);
continue;
}
r.tasks.push_back(*t);
}
if (!in.eof()) r.problems.push_back("read error (not EOF)");
return r;
}
Тут добре видно, що «помилка відкриття» перетворюється на проблему всередині звіту, а не на виняток чи аварійне завершення. Для best effort це нормально: звіт сам по собі є «мʼяким результатом».
Ще один практичний момент: список problems може стати величезним. У реальному застосунку часто додають обмеження: наприклад, зберігають лише перші 50 проблем, а далі пишуть: «і ще N проблем…».
Інтеграція в main
Коли ви додаєте завантаження в застосунок, дуже важливо, щоб поведінка була передбачуваною. Типова помилка новачків — «я щось завантажив, але якщо не завантажив, програма все одно продовжує жити в дивному стані». Ми хочемо, щоб точка ухвалення рішення була явною: або дані є, або їх немає, або вони часткові — і це прямо відображено в логіці.
Ось приклад для fail fast:
#include <iostream>
int main() {
std::string error;
auto tasks = load_tasks_fail_fast("tasks.txt", error);
if (!tasks) {
std::cerr << "Load failed: " << error << '\n';
return 1;
}
std::cout << "Loaded tasks: " << tasks->size() << '\n'; // Loaded tasks: 3
}
А ось приклад для best effort:
#include <iostream>
int main() {
auto report = load_tasks_best_effort("tasks.txt");
std::cout << "Loaded tasks: " << report.tasks.size() << '\n';
std::cout << "Problems: " << report.problems.size() << '\n';
if (!report.problems.empty()) {
std::cerr << report.problems.front() << '\n'; // line 4: bad format...
}
}
Зауважте: best effort не «вдає, що все ідеально». Він чесно повідомляє про проблеми. Інакше це перетворюється на «тихий баг», який потім знаходять користувачі.
5. Типові помилки
Помилка № 1: змішувати читання файлу, парсинг і валідацію в одному циклі без чіткої структури.
Коли все робиться «всередині while(getline)», код швидко перетворюється на кашу: частина перевірок стосується формату, частина — змісту, частина — стану потоку. Виправлення зазвичай просте: виокремити parse_* і validate_* в окремі функції, щоб у завантажувачі читався «сценарій», а не «кухонні подробиці».
Помилка № 2: вважати будь-яку зупинку getline нормальним завершенням через EOF.
while (getline(...)) справді зупиняється і на EOF, і на помилці. Якщо після циклу не перевірити in.eof(), можна «прийняти» реальну проблему читання за штатний кінець файлу й завантажити половину даних без жодного попередження.
Помилка № 3: використовувати stoi без захисту й дивуватися аварійному завершенню на пошкодженому файлі.
stoi зручний у демонстраціях, але в надійному завантаженні він часто перетворюється на міну: файл «трохи‑трохи» зіпсувався, а програма просто падає. У таких місцях краще використовувати безвинятковий парсинг (наприклад, from_chars) або щонайменше перетворювати помилку на діагностований результат.
Помилка № 4: у best effort «ковтати» помилки мовчки.
Best effort — не про мовчання, а про частковий успіх. Якщо ви пропускаєте погані рядки й не зберігаєте інформацію про те, що саме було пропущено, користувач побачить втрачені дані й вирішить, що програма ненадійна. Навіть один рядок виду "line 17: bad number in id" різко підвищує довіру, бо це чесно й дає змогу все перевірити.
Помилка № 5: не фіксувати політику завантаження як частину контракту функції.
Дуже неприємний сценарій: одна частина проєкту думає, що завантаження fail fast (і після нього дані гарантовано коректні), а інша — що best effort (і допускає «дірки»). У підсумку ви або падаєте там, де не очікували, або працюєте з неповними даними. Лікується просто: обираєте політику й відображаєте її в типі результату (optional / помилка або звіт), а не в коментарях на кшталт «ну тут наче має бути ок».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ