CodeGym /Kursy /C++ SELF /Wyświetlanie modeli — jednolity format wyjścia

Wyświetlanie modeli — jednolity format wyjścia

C++ SELF
Poziom 19, Lekcja 4
Dostępny

1. Jednolity format wyjścia: po co i jaki format wybierzemy

Dopóki program jest mały, wydaje się, że wyświetlanie to po prostu „no, kilka std::cout i gotowe”. Ale gdy pojawia się kilka miejsc, w których drukujesz ten sam model (np. lista zadań, wynik dodania zadania, wynik zmiany statusu), zaczyna się klasyczny problem: w jednym miejscu wypisuje się done, w innym — Done, w trzecim — liczba 2, a w czwartym — ogólnie „chyba zrobione”. W efekcie użytkownik widzi bałagan, a Ty, próbując debugować program, widzisz jeszcze większy bałagan (bo bałagan w logach to nie potrawa, a diagnoza).

Jednolity format wyjścia to mały kontrakt: te same dane powinny wyglądać jednakowo we wszystkich częściach programu. I wtedy dzieje się magia (nie prawdziwa, a inżynierska): kod jest łatwiejszy w utrzymaniu, inwarianty prościej sprawdzić wzrokiem, a gdy zdecydujesz zmienić format — zmieniasz go w jednym miejscu, a nie w dwudziestu.

Dodatkowy miły bonus: strumieniowe wyjście w C++ to standardowy mechanizm z formatowaniem (szerokość, wyrównanie itp.), i możemy go użyć ostrożnie, nie zamieniając drukowania w „ASCII-art na kolanie”.

Uzgodnijmy format: co dokładnie drukujemy i jak to wygląda

Zanim napiszemy funkcje, warto (jak dorośli ludzie) uzgodnić docelowy format. To zaskakująco ważne: jeśli nie ustalisz formatu wcześniej, każde kolejne wyświetlenie będzie „trochę inne”, i wrócisz do bałaganu.

Dla naszej aplikacji zadań (mikro-todo) przyjmijmy taki format tabelaryczny:

Pole Przykład Po co
id
1
Unikalny identyfikator, po którym wyszukujemy/usuwamy/zmieniamy
title
Buy milk
Nazwa zadania
status
todo / in_progress
Aktualny stan

Uzgodnijmy, że drukujemy tak:

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

To nie jest jedyny słuszny format, ale ma tę zaletę, że czyta się go „skanowaniem wzrokiem”: kolumny są wyrównane, wiersze krótkie, statusy zwarte i spójne.

2. Architektura wyjścia: rozdzielamy odpowiedzialności

Mini-reguła: model przechowuje dane, wyświetlanie — w osobnych funkcjach

Teraz ważna zasada, która ratuje projekty przed przemianą w „spaghetti z drukarką”. Model (struct Task) powinien przechowywać dane. Logika operacji na kolekcji powinna żyć w funkcjach takich jak AddTask, RemoveById, MarkDone. A prezentacja (czyli drukowanie/format) powinna być w osobnych funkcjach odpowiedzialnych za wyświetlanie.

Dlaczego to lepsze? Bo format to szczegół interfejsu. Dzisiaj drukujesz w konsoli, jutro zechcesz drukować inaczej, a pojutrze — drukować tylko część pól. Jeśli drukowanie jest rozproszone po funkcjach dodawania/usuwania, zaczniesz „refaktoryzację przez łzy”. Jeśli drukowanie jest scentralizowane — zmieniasz je szybko i bezpiecznie.

Dzisiaj świadomie robimy drukowanie zewnętrznymi funkcjami, a nie „wewnątrz struct”. To idealnie pasuje do poziomu tego kursu: minimum magii, maksimum przejrzystości.

Włączamy drukowanie do aplikacji, nie mieszając obowiązków

Ważne: nie chcemy, aby AddTask() samo drukowało listę, albo RemoveById() samo drukowało „usunięto/nie znaleziono”, albo MarkDone() drukowało cokolwiek innego. To szybko zamienia kod w serial: każda funkcja komunikuje się z użytkownikiem jak chce.

Lepiej trzymać operacje „czystymi” (na ile to możliwe na naszym poziomie): zmieniają dane i zwracają bool (lub indeks, albo coś podobnego), a decyzję „co drukować” podejmuje main() lub funkcja kontrolująca.

Oto mini-scenariusz: utworzymy parę zadań, wyświetlimy je, oznaczymy jedno jako wykonane i wyświetlimy ponownie.

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

// Załóżmy, że Task/Status/ToString/PrintTasks są już zadeklarowane wyżej.

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; // w poprzednich wykładach robilibyśmy to przez funkcję
    PrintTasks(tasks);
    //   id | title                | status
    //    1 | Buy milk              | done
    //    2 | Pay rent              | in_progress
}

Tak, tutaj zmieniamy tasks[0].status bezpośrednio — tylko po to, by pokazać, że wyświetlanie nie zależy od tego, w jaki sposób aktualizowałeś dane. W idealnym przypadku zrobiłaby to funkcja aktualizująca (którą pisałeś w wykładach o operacjach na kolekcjach), ale wyświetlanie od tego nie powinno się rozjeżdżać.

Dlaczego funkcje drukujące powinny tylko czytać dane

Teraz idea, która wydaje się oczywista... dopóki nie złapiesz buga. Funkcje drukujące nie powinny modyfikować danych. W ogóle. Nawet „lekkie poprawienie łańcucha”, nawet „ustawienie domyślnego statusu”, nawet „przycięcie spacji i zapisanie z powrotem”.

Powód jest prosty: drukowanie to obserwacja. Jeśli obserwacja zmienia obiekt, otrzymujesz efekt „kota Schrödingera”, tylko w programowaniu: spojrzałeś na zadanie — i ono się zmieniło. Debugowanie takiego efektu jest wyjątkowo nieprzyjemne.

Jeśli trzeba normalizować dane (np. usunąć zbędne spacje w title), powinno to odbywać się w miejscach zmiany danych: przy dodawaniu, przy zmianie nazwy, przy ładowaniu. Drukowanie powinno być pasywne: przyjmuje const Task& i tylko prezentuje.

Schemat odpowiedzialności: dane osobno, wyświetlanie osobno

Czasem warto zobaczyć nie tylko kod, ale i „schemat” odpowiedzialności. Oto prosty schemat tego, co budujemy:

flowchart TD
    A[Operacje na danych Add/Update/Remove] --> B[Kolekcja vector<Task>]
    B --> C["Wyświetlanie jednego modelu PrintTaskRow(Task)"]
    B --> D["Wyświetlanie listy PrintTasks(vector<Task>)"]
    C --> E[std::cout]
    D --> E[std::cout]

Sens: operacje pracują na danych, wyświetlanie pracuje na danych, ale operacje nie muszą drukować, a drukowanie nie powinno modyfikować danych. Gdy role są rozdzielone, kod staje się spokojniejszy i bardziej przewidywalny.

3. Implementacja: ToString i funkcje drukujące

enum class sam w sobie nie drukuje się „ładnie”: robimy ToString(Status)

Kiedy drukujemy int lub std::string, wszystko jest proste. Ale enum class Status sam w sobie nie musi mieć „czytelnej nazwy”. Kompilator wie, że to Status::Done, ale std::cout nie musi zgadywać, że chcesz wypisać słowo "done".

Dlatego tworzymy oddzielną funkcję: ToString(Status).

Ważne: to jedno źródło prawdy. Jeśli jutro uznasz, że zamiast in_progress chcesz doing, zmieniasz to w jednym miejscu.

#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"; // bezpieczna opcja na wypadek dziwnych sytuacji
}

Zwróć uwagę na styl: pokryliśmy wszystkie wartości enum class przez switch i na wszelki wypadek zwracamy "unknown". W praktyce przy poprawnym użyciu enum class nie powinieneś trafiać do "unknown", ale taki „spadochron” czyni zachowanie bardziej przewidywalnym, jeśli coś pójdzie nie tak (np. później pojawi się nowy status, a ToString nie zostanie zaktualizowane).

Drukujemy jedno zadanie: PrintTaskRow(const Task&)

Teraz tworzymy kolejny „klocek”: funkcję, która drukuje jeden wiersz tabeli.

Dlaczego to ważne? Bo drukowanie listy to „drukowanie jednego wiersza wiele razy”. Jeśli nie wyodrębnisz „jednego wiersza” osobno, pojawi się pokusa drukowania różnie w różnych pętlach.

Najpierw zdefiniujmy model:

#include <string>

enum class Status { Todo, InProgress, Done };

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

A teraz drukowanie jednego wiersza. Skorzystamy z <iomanip> i std::setw, aby kolumny wyglądały równo. Ważne pamiętać: std::setw(n) działa tylko na następny wyjściowy element, natomiast std::left/std::right mogą „przyklejać się” do strumienia, dopóki ich nie zmienisz.

#include <iomanip>
#include <iostream>

std::string ToString(Status s); // zadeklarowane, implementacja powyżej/poniżej

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';
}

Jeśli wywołasz to dla zadania {1, "Buy milk", Status::Todo}, otrzymasz coś w stylu:

   1 | Buy milk             | todo

(Liczba spacji zależy od szerokości pól, ale idea jest taka, że kolumny są równe.)

Drukujemy listę zadań: nagłówek + pętla + zachowanie dla pustej listy

Teraz jesteśmy gotowi złożyć wyświetlanie listy: nagłówek tabeli, a potem wiersze.

Tu łatwo popełnić typowy błąd początkującego: drukować nagłówek tylko czasami, albo zapomnieć obsłużyć pustą listę. W efekcie użytkownik widzi albo nic (i myśli, że program się zepsuł), albo krzywe wyświetlenie bez nazw kolumn.

Zaimplementujmy funkcję 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";
    }
}

Jest tu mały aspekt designerski: możesz wypisywać (no tasks) przed nagłówkiem albo po nim — to kwestia gustu. Ja drukuję po, by format był spójny: nagłówek zawsze jest, a dalej albo wiersze, albo informacja.

Pułapka formatowania: std::left i std::right zmieniają stan strumienia

Formatowanie strumienia to stan. Niektóre manipulatory (np. std::left i std::right) zmieniają stan strumienia i działają dalej.

Czyli jeśli gdzieś ustawisz std::left, a potem drukujesz liczby i liczysz, że będą wyrównane w prawo — możesz dostać „dziwnie sformatowaną” konsolę.

Aby uniknąć niespodzianek, prosty dydaktyczny sposób to: w funkcji drukującej wiersz jawnie podawać wyrównanie tam, gdzie to istotne.

Na przykład, niech id będzie wyrównane w prawo (zwykle ładniej dla liczb), a title — w lewo:

#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';
}

Teraz nawet jeśli gdzieś indziej w programie ktoś „pobawił się” formatowaniem, Twój wiersz będzie wyglądał stabilnie.

Jednolity format dla Status: dlaczego ciągi są lepsze od liczb

Początkujący często pytają: „Czy mogę wypisywać status jako liczbę? Typu 0/1/2 — przecież to zwięzłe”. Można. Ale to dokładnie ten przypadek, gdy „zwięzłe” = „nieczytelne”.

Liczba 2 w konsoli nic nie mówi. Wymaga pamiętania mapowania: 2 == Done. Natomiast ciąg "done" mówi sam za siebie. Łatwiej go czytać, łatwiej znaleźć wzrokiem, łatwiej porównywać w logach. Dlatego funkcja ToString(Status) — to mała inwestycja w wygodę czytania, a nie „zbędna biurokracja”.

4. Typowe błędy przy drukowaniu modeli

Błąd nr 1: drukowanie rozproszone po całym projekcie i ten sam model jest wyświetlany na różne sposoby.
Zwykle zaczyna się niewinnie: „tutaj jest jeden cout”. Potem pojawia się kolejny cout, potem jeszcze jeden, a potem zauważasz, że w jednym miejscu status wypisuje się jako done, w innym jako Done, a w trzecim jako 2. Rozwiązanie jest proste: jedna funkcja drukująca wiersz modelu (PrintTaskRow) i jedna funkcja drukująca listę (PrintTasks). Koniec — innych sposobów nie powinno być.

Błąd nr 2: wypisywanie enum class jako liczby przez static_cast<int>, i tekstowe statusy znikają.
Technicznie działa, ale tracisz czytelność i wracasz do „magicznych wartości”, tylko tym razem w wyjściu. Lepiej trzymać ToString(Status) i drukować czytelny dla człowieka tekst.

Błąd nr 3: zapomnienie o formatowaniu i otrzymanie „schodów” zamiast tabeli.
Bez std::setw nazwy o różnej długości rozjeżdżają się i lista zadań staje się trudna do czytania. Szczególnie dokuczliwe, gdy zadań jest więcej niż pięć: oczy zaczynają skakać. Używaj stałych szerokości kolumn i separatorów " | " — to prosta poprawa, ale efekt ogromny.

Błąd nr 4: nie uwzględnianie, że manipulatory formatowania mogą zachowywać stan strumienia.
Ustawiłeś std::left w jednym miejscu, a potem dziwisz się, dlaczego liczby „pojechały” w innym. Wewnątrz PrintTaskRow lepiej jasno określać wyrównanie (std::right dla id, std::left dla łańcuchów), by funkcja była samowystarczalna i stabilna.

Błąd nr 5: funkcja drukująca zaczyna „naprawiać dane” i zmienia model.
To szczególnie podstępne: program wydaje się działać, ale dane nagle się zmieniają po wyświetleniu. Drukowanie powinno przyjmować const Task& i nie modyfikować obiektu. Jeśli trzeba normalizować title lub sprawdzać inwarianty — rób to w funkcjach dodawania/aktualizacji, a nie w drukowaniu.

1
Zadanie
C++ SELF, poziom 19, lekcja 4
Niedostępne
Status jako ciąg znaków
Status jako ciąg znaków
1
Zadanie
C++ SELF, poziom 19, lekcja 4
Niedostępne
Wiersz tabeli
Wiersz tabeli
1
Zadanie
C++ SELF, poziom 19, lekcja 4
Niedostępne
Wypisywanie listy
Wypisywanie listy
1
Zadanie
C++ SELF, poziom 19, lekcja 4
Niedostępne
Aktualizacja i lista
Aktualizacja i lista
1
Ankieta/quiz
Modelowanie danych, poziom 19, lekcja 4
Niedostępny
Modelowanie danych
Modelowanie danych
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION