JavaRush /Курси /C++ SELF /Друк моделей: єдиний формат виведення

Друк моделей: єдиний формат виведення

C++ SELF
Рівень 19 , Лекція 4
Відкрита

1. Єдиний формат виведення: навіщо він потрібен і який формат оберемо

Поки програма маленька, здається, що друк — це просто «ну, пару std::cout, і готово». Але щойно в програмі зʼявляється кілька місць, де ви друкуєте одну й ту саму модель, наприклад список задач, результат додавання задачі чи результат зміни статусу, починається класичний біль: в одному місці виводиться done, в іншому — Done, у третьому — число 2, а в четвертому — взагалі щось на кшталт «ніби готово». У підсумку користувач бачить кашу, а ви, коли намагаєтеся налагодити програму, — ще більшу кашу. Бо каша в логах — це не страва, а діагноз.

Єдиний формат виведення — це невеликий контракт: одні й ті самі дані мають виглядати однаково в усіх частинах програми. І тоді стається магія. Не справжня, а інженерна: код стає простішим у підтримці, інваріанти легше перевіряти на око, а якщо ви вирішите змінити формат, то зробите це в одному місці, а не в двадцяти.

Є й приємний бонус: потокове виведення в C++ — це стандартний механізм із вбудованим форматуванням, наприклад шириною поля та вирівнюванням. Тож ми можемо користуватися ним акуратно, не перетворюючи друк на «ASCII-арт на коліні».

Домовимося про формат: що саме друкуємо і який вигляд це має

Перш ніж писати функції, корисно — буквально як дорослі люди — домовитися про цільовий формат. Це несподівано важливо: якщо не визначити формат заздалегідь, кожне наступне виведення буде «трішки іншим», і ви швидко повернетеся до тієї самої каші.

Для нашого застосунку задач, невеликого todo-застосунку, візьмемо такий табличний формат:

Поле Приклад Навіщо воно
id
1
Унікальний ідентифікатор: за ним шукаємо, видаляємо й змінюємо
title
Buy milk
Назва задачі
status
todo / in_progress
Поточний статус

І домовимося, що друкуємо так:

  id | title                 | status
   1 | Buy milk              | todo
   2 | Pay rent              | in_progress

Це не єдино правильний формат, але він добрий тим, що його легко «сканувати очима»: стовпці вирівняні, рядки короткі, статуси компактні й однакові.

2. Архітектура виведення: розділяємо відповідальність

Мініправило: модель зберігає дані, а виведення живе в окремих функціях

Зараз буде важливий принцип, який рятує проєкти від перетворення на «спагеті з принтером». Модель (struct Task) має зберігати дані. Логіка операцій над колекцією — жити у функціях на кшталт AddTask, RemoveById, MarkDone. А представлення, тобто друк і формат, — в окремих функціях друку.

Чому так краще? Тому що формат — це деталь інтерфейсу. Сьогодні ви друкуєте в консоль, завтра захочете друкувати трохи інакше, а післязавтра — лише частину полів. Якщо друк розмазаний по логіці додавання й видалення, ви почнете «рефакторинг крізь сльози». Якщо ж друк централізований, змінити його буде швидко й безпечно.

Сьогодні ми свідомо реалізуємо друк зовнішніми функціями, а не «всередині struct». Це ідеально вписується в поточний рівень курсу: мінімум магії, максимум прозорості.

Вбудовуємо друк у застосунок, не змішуючи обовʼязки

Важливо: ми не хочемо робити так, щоб AddTask() сам друкував список, а RemoveById() — повідомлення «видалено/не знайдено», а MarkDone() — ще щось своє. Це швидко перетворює код на серіал: кожна функція розмовляє з користувачем по-своєму.

Краще тримати операції «чистими» — настільки, наскільки це можливо на цьому етапі. Нехай вони змінюють дані й повертають bool (або індекс, або щось подібне), а рішення, що саме друкувати, ухвалює main() або керувальна функція.

Ось простий сценарій: створимо пару задач, виведемо їх, позначимо одну як виконану й виведемо знову.

#include <iostream>
#include <string>
#include <vector>

// Припустімо, що Task/Status/ToString/PrintTasks уже оголошені вище.

int main() {
    std::vector<Task> tasks;

    tasks.push_back(Task{1, "Buy milk", Status::Todo});
    tasks.push_back(Task{2, "Pay rent", Status::InProgress});

    PrintTasks(tasks);
    //   id | title                | status
    //    1 | Buy milk              | todo
    //    2 | Pay rent              | in_progress

    tasks[0].status = Status::Done; // у попередніх лекціях ми робили б це через функцію
    PrintTasks(tasks);
    //   id | title                | status
    //    1 | Buy milk              | done
    //    2 | Pay rent              | in_progress
}

Так, тут ми змінюємо tasks[0].status напряму — просто щоб показати, що друк не залежить від того, як саме ви оновили дані. В ідеалі це робитиме ваша функція оновлення, яку ви писали в лекціях про операції з колекцією, але виведення не повинне через це розповзатися.

Чому функції друку мають лише читати дані

Зараз буде думка, яка здається очевидною… доки ви не зловите баг. Функції друку не мають змінювати дані. Узагалі. Навіть «трошки підправити рядок», навіть «встановити статус за замовчуванням», навіть «обрізати пробіли й зберегти назад».

Причина проста: друк — це спостереження. Якщо спостереження змінює обʼєкт, ви отримуєте ефект «кота Шредінґера», тільки у програмуванні: ви подивилися на задачу — і вона змінилася. Налагоджувати таке неймовірно неприємно.

Якщо вам потрібно нормалізувати дані, наприклад прибрати зайві пробіли в title, це має відбуватися в точках зміни даних: під час додавання, перейменування або завантаження. Друк має бути пасивним: отримав const Task& — і просто показав.

Схема відповідальності: дані окремо, виведення окремо

Іноді корисно побачити не лише код, а й «проводку» відповідальності. Ось проста схема того, що ми зараз будуємо:

flowchart TD
    A[Операції над даними: додавання, оновлення, видалення] --> B[Колекція vector<Task>]
    B --> C["Друк однієї моделі: PrintTaskRow(Task)"]
    B --> D["Друк списку: PrintTasks(vector<Task>)"]
    C --> E[std::cout]
    D --> E[std::cout]

Сенс простий: операції працюють із даними, друк теж працює з даними, але операції не зобовʼязані друкувати, а друк не зобовʼязаний змінювати дані. Коли ролі розділені, код стає спокійнішим і передбачуванішим.

3. Реалізація: ToString і функції друку

enum class сам по собі не друкується «гарно»: робимо ToString(Status)

Коли ми друкуємо int або std::string, усе просто. Але enum class Status сам по собі не зобовʼязаний мати «людське імʼя». Компілятор знає, що це Status::Done, але std::cout не зобовʼязаний здогадуватися, що ви хочете вивести слово "done".

Тому ми робимо окрему функцію: ToString(Status).

Важливо: це одна точка правди. Якщо завтра ви вирішите, що замість in_progress хочете doing, достатньо буде змінити це в одному місці.

#include <string>

enum class Status { Todo, InProgress, Done };

std::string ToString(Status s) {
    switch (s) {
        case Status::Todo:       return "todo";
        case Status::InProgress: return "in_progress";
        case Status::Done:       return "done";
    }
    return "unknown"; // захисний варіант на випадок дивної поведінки
}

Зверніть увагу: ми охопили всі значення enum class через switch і про всяк випадок повернули "unknown". На практиці, якщо enum class використано коректно, потрапляти в "unknown" ви не повинні. Але такий «парашут» робить поведінку зрозумілішою, якщо щось піде не так, наприклад десь пізніше зʼявиться новий статус, а ToString забудуть оновити.

Друкуємо одну задачу: PrintTaskRow(const Task&)

Тепер переходимо до наступної «цеглинки»: функції, яка друкує один рядок таблиці.

Чому це важливо? Тому що друк списку — це, по суті, «друк одного рядка багато разів». Якщо не винести «один рядок» окремо, дуже швидко зʼявиться спокуса друкувати по-різному в різних циклах.

Спочатку визначимо модель:

#include <string>

enum class Status { Todo, InProgress, Done };

struct Task {
    int id;
    std::string title;
    Status status = Status::Todo;
};

А тепер — друк одного рядка. Ми скористаємося <iomanip> і std::setw, щоб стовпці були рівними. Важливо памʼятати: std::setw(n) діє лише на наступне виведення, а от std::left/std::right можуть «прилипати» до потоку, доки ви не зміните їх назад.

#include <iomanip>
#include <iostream>

std::string ToString(Status s); // оголосили, реалізація вище/нижче

void PrintTaskRow(const Task& t) {
    std::cout << std::setw(4) << std::right << t.id
              << " | " << std::setw(20) << std::left  << t.title
              << " | " << std::setw(12) << std::left  << ToString(t.status)
              << '\n';
}

Якщо викликати цю функцію для задачі {1, "Buy milk", Status::Todo}, вийде щось на кшталт:

   1 | Buy milk             | todo

(Кількість пробілів залежить від ширини полів, але сама ідея проста: стовпці мають бути рівними.)

Друкуємо список задач: шапка + цикл + поведінка для порожнього списку

Тепер ми готові зібрати виведення списку: шапка таблиці, а потім — рядки.

Тут легко припуститися типової новачкової помилки: виводити шапку лише іноді або забути обробити порожній список. У результаті користувач або не бачить нічого й думає, що програма зламалася, або отримує криве виведення без назв стовпців.

Зробімо функцію PrintTasks.

#include <iostream>
#include <vector>

void PrintTaskRow(const Task& t);

void PrintTasks(const std::vector<Task>& tasks) {
    std::cout << "  id | title                | status\n";

    for (const Task& t : tasks) {
        PrintTaskRow(t);
    }

    if (tasks.empty()) {
        std::cout << "(no tasks)\n";
    }
}

Тут є маленький дизайнерський нюанс: можна друкувати (no tasks) до шапки або після — це питання смаку. Тут ми друкуємо після, щоб формат залишався однаковим: шапка є завжди, а далі йдуть або рядки, або пояснення.

Пастка форматування: std::left і std::right змінюють стан потоку

Форматування потоку — це стан. Деякі маніпулятори, наприклад std::left і std::right, змінюють стан потоку й продовжують діяти далі.

Тобто якщо ви десь зробили std::left, а потім друкуєте числа й сподіваєтеся, що вони будуть вирівняні вправо, — можете отримати «дивно відформатовану» консоль.

Щоб не ловити таких сюрпризів, є простий навчальний підхід: у функції друку рядка явно задавати вирівнювання там, де воно справді важливе.

Наприклад, нехай id буде вирівняний вправо — так зазвичай охайніше для чисел, — а title — вліво:

#include <iomanip>
#include <iostream>

void PrintTaskRow(const Task& t) {
    std::cout << std::setw(4) << std::right << t.id
              << " | " << std::setw(20) << std::left  << t.title
              << " | " << std::setw(12) << std::left  << ToString(t.status)
              << '\n';
}

Тепер, навіть якщо десь вище чи нижче в програмі хтось «погрався» з форматуванням, ваш рядок матиме стабільний вигляд.

Єдиний формат для Status: чому рядки кращі за числа

Новачки часто запитують: «А можна я друкуватиму статус як число? Мовляв, 0/1/2 — компактно ж». Можна. Але це саме той випадок, коли «компактно» = «незрозуміло».

Число 2 у консолі саме по собі не несе сенсу. Воно вимагає памʼятати відповідність: 2 == Done. А рядок "done" зрозумілий без додаткових пояснень. Його простіше читати, простіше шукати очима, простіше порівнювати в логах. Тому функція ToString(Status) — це маленька інвестиція у зручність читання, а не «зайва бюрократія».

4. Типові помилки під час друку моделей

Помилка № 1: друк розмазаний по всьому проєкту, і одна й та сама модель виводиться по-різному.
Зазвичай це починається невинно: «та тут один cout». Потім зʼявляється ще один cout, потім іще, а далі ви помічаєте, що в одному місці статус друкується як done, в іншому — як Done, а в третьому — як 2. Лікування просте: одна функція друку рядка моделі (PrintTaskRow) і одна функція друку списку (PrintTasks). Усе, інших варіантів бути не повинно.

Помилка № 2: enum class виводять як число через static_cast<int>, і текстові статуси зникають.
Технічно це працює, але ви втрачаєте читабельність і повертаєтеся до «магічних значень», тільки тепер уже у виведенні. Правильніше мати ToString(Status) і друкувати зрозумілий людині текст.

Помилка № 3: забувають про форматування й отримують «драбину» замість таблиці.
Без std::setw назви різної довжини розʼїжджаються, і список задач стає важко читати. Особливо боляче, коли задач понад пʼять: очі починають стрибати. Використовуйте фіксовані ширини стовпців і розділювачі " | " — це просте поліпшення, але ефект величезний.

Помилка № 4: не враховують, що маніпулятори форматування можуть зберігати стан потоку.
Ви встановили std::left в одному місці, а потім дивуєтеся, чому числа «поїхали» в іншому. Усередині PrintTaskRow краще явно вказувати вирівнювання (std::right для id, std::left для рядків), щоб функція була самодостатньою й стабільною.

Помилка № 5: функція друку починає «лагодити дані» й змінювати модель.
Це особливо підступно: програма ніби працює, але дані раптово змінюються після виведення. Друк має приймати const Task& і не змінювати обʼєкт. Якщо потрібно нормалізувати title або перевіряти інваріанти — робіть це у функціях додавання й оновлення, а не під час друку.

1
Опитування
Моделювання даних, рівень 19, лекція 4
Недоступний
Моделювання даних
Моделювання даних
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ