Л10. Файли, текстові дані й обробка помилок
Коротко про лекцію
Побудуємо повний шлях даних від читання тексту до готового результату. Простежимо розбір записів, перетворення типів, перевірку правил і формування відповіді. Окремо розберемо помилки на кожному етапі.
До цього моменту дані були записані безпосередньо у коді. Реальна програма часто отримує їх із файлу або іншого зовнішнього джерела. Тоді між «текст прочитано» і «дані готові для алгоритму» з’являється кілька окремих кроків.
Практичний сенс. Надійна програма не змішує читання, розбір, перевірку та обчислення в одному великому блоці. Кожний етап має свій контракт і свої помилки.
1. Файл зберігає дані поза виконанням програми
Файл — це іменовані дані, збережені у файловій системі. Програма може відкрити файл для читання або запису.
Текстовий файл можна уявити як послідовність символів, часто поділену на рядки.
Наприклад:
ручка 3 12.5
книга 2 40
Людина бачить два записи. Для програми це спочатку лише текст. Після розбору 3 стане int, а 12.5 — double.
1.1. Текстовий і бінарний файл мають різні контракти
Текстовий файл зберігає дані у вигляді символів певного кодування. Його можна читати рядками, а числові поля потрібно перетворювати з тексту.
Бінарний файл розглядають як послідовність байтів із форматом, визначеним програмою або специфікацією. Для такого файла недостатньо побачити байти: треба знати їхню структуру, порядок і спосіб кодування значень.
У C++ бінарний режим відкриття позначають прапорцем std::ios::binary. У цій дисципліні основний практичний маршрут використовує текстові файли, бо на них прозоріше видно етапи читання, розбору й валідації. Різницю режимів треба розуміти під час читання реального коду.
2. Потік переносить дані між джерелом і програмою
У C++ для послідовного читання та запису використовують потік.
Потік можна уявити як канал, через який дані надходять у програму або виходять із неї.
Термін: потік
Потік послідовно переносить дані між програмою та джерелом або приймачем. Операції читання змінюють стан потоку, тому успіх наступного читання треба перевіряти.
Ми вже користувалися вихідним потоком std::cout. Стандартний вхідний потік має ім’я std::cin. У звичайній консольній програмі запис std::cin >> value читає значення, яке вводить користувач.
Термін: std::cin
std::cin — стандартний вхідний потік C++. Оператор >> читає з нього значення відповідно до типу змінної.
Наприклад, локальна консольна програма може прочитати кількість так:
int quantity{0};
std::cin >> quantity;
Для відтворюваного розбору тексту зручно почати з потоку, який читає зі звичайного std::string. Такий тип називається std::istringstream.
Для нього підключаємо:
#include <sstream>
2.1. Оператор >> витягує типізоване значення з вхідного потоку
Запис
input >> quantity;
input >> quantity; просить потік прочитати наступне значення як тип змінної quantity. Якщо quantity має тип int, текст повинен містити ціле число у відповідному місці. Кілька полів можна читати послідовно:
input >> quantity >> price;
Перший результат потрапить у quantity, другий — у price.
#include <iostream>
#include <sstream>
#include <string>
int main() {
const std::string text{"3 12.5"};
std::istringstream input{text};
int quantity{0};
double price{0.0};
if (input >> quantity >> price) {
std::cout << quantity << " × " << price << "\n";
}
}
Рядок "3 12.5" перетворюється на int quantity = 3 і double price = 12.5.
3. Розбір перетворює текст на структуровані дані
Розбір даних — це процес, у якому текстова форма перетворюється на значення та поля, зручні для подальших алгоритмів.
Термін: розбір
Розбір відповідає за перетворення зовнішнього тексту на структуровані значення. Він перевіряє форму, потрібну для такого перетворення.
Нехай один рядок має формат:
name quantity price
У Л9 ми навчилися групувати пов’язані поля через struct. Тому запис продажу представимо так:
struct Sale {
std::string name;
int quantity;
double price;
};
3.1. Функція розбору має повідомляти про успіх
Функція отримує текстовий рядок і заповнює Sale через звичайне посилання. Результат bool повідомляє, чи вдалося прочитати всі три поля.
Після операції input >> ... потік можна перевірити як логічний стан: успішне читання відповідає true, помилка формату — false.
Стан потоку можна явно перетворити на bool.
static_cast<bool>(input >> ...)
#include <iostream>
#include <sstream>
#include <string>
struct Sale {
std::string name;
int quantity;
double price;
};
// Ця функція відповідає лише за форму запису; правила допустимих значень перевіряються окремо.
bool parse_sale(const std::string& line, Sale& result) {
std::istringstream input{line};
return static_cast<bool>(input >> result.name >> result.quantity >> result.price);
}
int main() {
Sale sale{};
if (parse_sale("ручка 3 12.5", sale)) {
std::cout << sale.name << " " << sale.quantity << " " << sale.price << "\n";
}
}
Функція parse_sale підтверджує, що три потрібні значення можна прочитати. Проте вона ще не перевіряє, чи після ціни залишився зайвий текст.
Коли формат вимагає рівно три поля, корисна суворіша версія. Після читання трьох значень вона пробує прочитати ще одне слово. Успішне додаткове читання означає порушення формату.
#include <iostream>
#include <sstream>
#include <string>
struct Sale {
std::string name;
int quantity;
double price;
};
bool parse_sale_strict(const std::string& line, Sale& result) {
std::istringstream input{line};
std::string extra;
// Спочатку вимагаємо три обов’язкові поля з правильними типами.
if (!(input >> result.name >> result.quantity >> result.price)) {
return false;
}
// Будь-який четвертий токен порушує контракт формату з рівно трьома полями.
return !(input >> extra);
}
int main() {
Sale sale{};
std::cout << parse_sale_strict("ручка 3 12.5", sale) << "\n";
std::cout << parse_sale_strict("ручка 3 12.5 зайве", sale) << "\n";
}
Межа розбору
Політика формату має бути явною. Якщо зайві поля дозволені, початковий parse_sale достатній. Якщо формат фіксований, перевіряйте кінець запису.
4. Успішний розбір ще не означає коректні дані задачі
Рядок
ручка 0 12.5
успішно розбирається: усі три поля мають правильну текстову форму. Але кількість 0 може порушувати правило предметної області.
Тому після розбору потрібна окрема перевірка даних.
Термін: валідація
Валідація перевіряє правила предметної області вже після успішного розбору. Наприклад, кількість товару може мати правильний тип, але недопустиме значення 0.
Наприклад:
name не порожнє
quantity > 0
price >= 0
#include <iostream>
#include <string>
struct Sale {
std::string name;
int quantity;
double price;
};
// Після успішного розбору окремо перевіряємо правила предметної області.
bool valid_sale(const Sale& sale) {
return !sale.name.empty() && sale.quantity > 0 && sale.price >= 0.0;
}
int main() {
const Sale sale{"ручка", 0, 12.5};
if (valid_sale(sale)) {
std::cout << "коректно\n";
} else {
std::cout << "помилка даних\n";
}
}
Розбір відповідає на питання «чи має текст потрібну форму?». Перевірка відповідає на питання «чи допустимі отримані значення для задачі?».
flowchart TD
A["Текстовий рядок"] --> B{"Розбір успішний?"}
B -- "ні" --> X["Помилка формату"]
B -- "так" --> C["Типізовані поля"]
C --> D{"Значення допустимі?"}
D -- "ні" --> Y["Помилка даних"]
D -- "так" --> E["Готовий запис"]
5. Рядки зручно читати через std::getline
Для текстових даних часто природно спочатку отримати цілий рядок, а потім розбирати його окремо.
Функція
std::getline(input, line)
читає символи з потоку input до кінця поточного рядка та записує їх у line.
Результат можна використовувати як умову циклу. Поки читання рядка успішне, цикл продовжується.
Спочатку подивимося на той самий механізм через рядковий потік у пам’яті.
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::istringstream input{"альфа\nбета\nгамма\n"};
std::string line;
int number{1};
// getline читає один повний рядок за раз і завершує цикл, коли рядків більше немає.
while (std::getline(input, line)) {
std::cout << number << ": " << line << "\n";
++number;
}
}
Кожна ітерація отримує один повний рядок. Далі цей рядок можна передати функції розбору.
5.1. Той самий розбір працює з рядком, введеним користувачем
std::getline(std::cin, line) читає цілий рядок зі стандартного вводу. Після цього рядок можна передати тій самій функції розбору, яку ми перевіряли на відтворюваних тестових даних.
Введіть ручка 3 12.5. Потім повторіть запуск із рядком ручка три 12.5 і порівняйте дві гілки результату.
#include <iostream>
#include <sstream>
#include <string>
struct Sale {
std::string name;
int quantity;
double price;
};
bool parse_sale(const std::string& line, Sale& result) {
std::istringstream input{line};
return static_cast<bool>(input >> result.name >> result.quantity >> result.price);
}
int main() {
// Спочатку зберігаємо весь рядок, щоб розбір залишався окремою операцією.
std::string line;
std::cout << "Введіть назву, кількість і ціну: ";
std::getline(std::cin, line);
Sale sale{};
// Рішення про прийняття запису залежить від результату розбору, а не від способу введення.
if (parse_sale(line, sale)) {
std::cout << "\nПрийнято: " << sale.name << ' ' << sale.quantity << ' ' << sale.price << "\n";
} else {
std::cout << "\nПомилка формату\n";
}
}
Консоль відповідає лише за отримання рядка. parse_sale() відповідає за його структуру. Так інтерактивність не змішує джерело даних із правилами розбору.
6. Повний конвеєр розділяє відповідальності
Послідовність обробки зручно бачити як конвеєр: результат одного етапу стає входом наступного.
текст
→ рядок
→ розбір полів
→ перевірка значень
→ обчислення
→ результат
flowchart TD
F["Джерело тексту"] --> L["Один рядок"]
L --> P{"parse_sale успішний?"}
P -- "ні" --> R["Відхилити запис"]
P -- "так" --> V{"valid_sale істинний?"}
V -- "ні" --> R
V -- "так" --> C["Обчислення"]
C --> O["Додати до підсумку"]
Наступний приклад використовує std::vector рядків, щоб виконання було відтворюваним прямо в лекції.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
struct Sale {
std::string name;
int quantity;
double price;
};
// Розбір відповідає за структуру рядка і не вирішує, чи допустимі отримані значення.
bool parse_sale(const std::string& line, Sale& result) {
std::istringstream input{line};
return static_cast<bool>(input >> result.name >> result.quantity >> result.price);
}
// Валідація застосовує правила задачі вже до структурованого запису.
bool valid_sale(const Sale& sale) {
return !sale.name.empty() && sale.quantity > 0 && sale.price >= 0.0;
}
int main() {
const std::vector<std::string> lines{
"ручка 3 12.5",
"помилка",
"книга 2 40"
};
double total{0.0};
int rejected{0};
// Один неправильний рядок відхиляємо локально, щоб обробка решти даних продовжилася.
for (const std::string& line : lines) {
Sale sale{};
if (!parse_sale(line, sale) || !valid_sale(sale)) {
++rejected;
continue;
}
// До підсумку потрапляють лише записи, що пройшли обидва етапи перевірки.
total += sale.quantity * sale.price;
}
std::cout << "Сума: " << total << "\n";
std::cout << "Відхилено: " << rejected << "\n";
}
Один рядок "помилка" відхиляється на етапі розбору. Два коректні записи формують суму 117.5.
6.1. Чому етапи краще розділяти
Якщо підсумок неправильний, можна перевірити послідовно:
- чи отримано правильний рядок;
- чи правильно розібрано поля;
- чи правильно перевірено допустимість;
- чи правильна формула обчислення.
Кожна межа між етапами звужує місце пошуку помилки.
7. Файл для читання відкривається через std::ifstream
Тепер перейдемо від рядка у пам’яті до реального файлу.
Тип std::ifstream представляє вхідний файловий потік. Для нього підключаємо:
#include <fstream>
Відкриття можна записати так:
std::ifstream input{"sales.txt"};
Метод is_open() повідомляє, чи вдалося відкрити файл.
if (!input.is_open()) {
// Якщо файл не відкрито, далі не можна читати з цього потоку.
}
Файл може бути відсутнім, шлях може бути неправильним або середовище може заборонити доступ. Тому успішне відкриття не можна просто припускати.
#include <fstream>
#include <iostream>
int main() {
std::ifstream input{"missing-demo-file.txt"};
// Помилку відкриття обробляємо до будь-якої спроби читати з потоку.
if (!input.is_open()) {
std::cout << "Файл не відкрито\n";
return 0;
}
std::cout << "Файл відкрито\n";
}
Цей приклад навмисно використовує ім’я відсутнього файла та показує гілку помилки відкриття.
7.1. Читання файла по рядках
Після успішного відкриття той самий std::getline працює з std::ifstream:
std::string line;
while (std::getline(input, line)) {
// Кожен рядок передаємо функції розбору окремо, не змішуючи читання файла з розбором.
}
Механізм розбору працює з готовим рядком. Джерелом може бути файл, мережа або тестовий std::string.
Це важлива властивість декомпозиції: чисту логіку розбору можна перевіряти окремо від файлової системи.
8. Файл для запису відкривається через std::ofstream
Тип std::ofstream представляє вихідний файловий потік.
std::ofstream output{"result.txt"};
Після успішного відкриття оператор << працює подібно до std::cout:
output << "42\n";
Спочатку також перевіряємо is_open().
#include <fstream>
#include <iostream>
int main() {
std::ofstream output{"demo-output.txt"};
// Запис починаємо лише після підтвердження, що вихідний файл справді відкрито.
if (!output.is_open()) {
std::cout << "Не вдалося створити файл\n";
return 0;
}
output << "42\n";
std::cout << "Запис завершено\n";
}
У цьому прикладі файловий потік є власником відкритого файла. Коли output завершує час життя, потік закривається автоматично. Це ще один приклад RAII з Л9.
9. Помилки мають різні межі
У завершеній програмі корисно розрізняти щонайменше чотири класи проблем.
9.1. Помилка доступу до джерела
Файл не відкрився або недоступний.
Перевіряється на межі:
файлова система → потік
9.2. Помилка формату
Рядок не містить потрібних полів або типів.
Перевіряється на межі:
текст → типізовані поля
9.3. Помилка значень
Поля мають правильні типи, але порушують правила задачі.
Перевіряється на межі:
типізовані поля → допустима модель
9.4. Помилка алгоритму
Дані коректні, але формула або обробка дає неправильний результат.
Перевіряється через очікувані значення та тести функцій.
flowchart TD
A["відкрити джерело"] --> B{"успіх?"}
B -- "ні" --> E1["помилка доступу"]
B -- "так" --> C["прочитати і розібрати"]
C --> D{"формат правильний?"}
D -- "ні" --> E2["помилка формату"]
D -- "так" --> F{"значення допустимі?"}
F -- "ні" --> E3["помилка даних"]
F -- "так" --> G["алгоритм"]
G --> H["результат"]
Такий поділ називатимемо межами помилок: кожний етап відповідає за свій клас проблем.
10. Політика помилки повинна бути явною
Виявити помилку недостатньо. Треба визначити, що робить програма далі.
Для різних задач можливі різні рішення:
- завершити програму, якщо основний файл не відкрився;
- пропустити один пошкоджений рядок;
- порахувати кількість відхилених записів;
- показати користувачеві зрозуміле повідомлення;
- не записувати підсумок, якщо дані неповні.
Політика залежить від вимог задачі. Код повинен робити її видимою.
11. Виняток передає помилку через межу викликів
Статус bool добре працює, коли невдалий результат є звичайною частиною контракту. Іноді функція стикається з помилкою, після якої поточний рівень не може продовжити нормальну роботу. Рішення про відновлення тоді належить вищому рівню програми. C++ має для цього винятки.
Інструкція throw створює сигнал про помилку та перериває звичайний шлях поточного виклику. Блок try позначає код, де така ситуація очікується, а catch описує реакцію.
Термін: виняток
Виняток — окремий шлях передавання інформації про помилку. throw сигналізує про проблему, а відповідний catch визначає місце її обробки.
Для простого збою під час виконання використаємо стандартний тип std::runtime_error. Він зберігає текстове пояснення причини.
#include <iostream>
#include <stdexcept>
double checked_price(double price) {
// Від’ємна ціна порушує контракт функції, тому звичайного результату тут немає.
if (price < 0.0) {
throw std::runtime_error{"Ціна не може бути від’ємною"};
}
return price;
}
int main() {
// Межа main() перетворює виняток на зрозуміле повідомлення для користувача.
try {
std::cout << checked_price(-5.0) << "\n";
} catch (const std::runtime_error& error) {
std::cout << "Помилка: " << error.what() << "\n";
}
}
11.1. Трасуємо шлях винятку крок за кроком
Для винятку трасування відстежує поточне місце виконання та наступний перехід. Головний орієнтир тут — шлях керування.
| Крок | Поточне місце | Подія | Що виконується далі |
|---|---|---|---|
| 1 | main, блок try |
викликається checked_price(-5.0) |
керування переходить у функцію |
| 2 | checked_price |
умова price < 0.0 істинна |
виконується throw |
| 3 | throw |
створюється std::runtime_error |
функція завершує поточний шлях без return |
| 4 | main |
знаходиться catch потрібного типу |
error посилається на об’єкт винятку |
| 5 | catch |
error.what() повертає повідомлення |
друкується Помилка: Ціна не може бути від’ємною |
| 6 | після catch |
обробник завершився | виконання продовжується після конструкції try/catch |
Виклик checked_price(-5.0) завершується через throw. Тому рядок return price; для цього виклику пропускається. Вираз із std::cout також не завершує звичайний шлях.
std::runtime_error зберігає повідомлення про помилку. Метод what() повертає це повідомлення для діагностики.
Коли виконується throw, C++ шукає відповідний catch. Під час такого виходу локальні RAII-об’єкти завершують час життя. Тому автоматичне керування ресурсами особливо важливе.
Коли не потрібен виняток
Звичайне відхилення некоректного рядка можна представити статусом і продовжити обробку наступного рядка. Виняток доречний, коли нормальний результат відсутній і рішення треба передати іншому рівню програми.
12. Тести для конвеєра даних
Окремі функції дозволяють тестувати конвеєр по частинах.
Для розбору перевірте:
- правильний рядок;
- пропущене поле;
- текст замість числа;
- зайві або порожні дані, якщо формат їх регулює.
Для перевірки значень:
- мінімальне допустиме значення;
- значення на межі;
- значення за межею.
Для всього конвеєра:
- усі записи коректні;
- один запис пошкоджений;
- усі записи відхилені;
- порожній вхід.
Ці тести дозволяють локалізувати дефект до конкретного етапу.
13. Архітектура завершеної невеликої C++ програми
Після Л10 програму з обробки даних корисно будувати шарами:
main
├─ відкрити вхід
├─ читати рядки
├─ parse_* → текст у структуру
├─ valid_* → правила предметної області
├─ process_* → алгоритм
└─ записати результат
main() координує послідовність. Маленькі функції виконують окремі перетворення.
flowchart TD
M["main"] --> O["відкрити"]
O --> R["прочитати рядок"]
R --> P["розбір"]
P --> V["валідація"]
V --> C["обчислення"]
C --> W["запис"]
W --> R
Ця структура поєднує поняття попередніх лекцій: типи, умови, цикли, послідовності, функції, передумови, моделі даних, RAII та тести.
14. Інтерактивна самоперевірка лекції
Самоперевірка вимагає відрізняти помилку відкриття від помилки формату, трасувати розбір рядка та вибирати правильну межу перевірки.