CodeGym /Kursy /C++ SELF /Mini‑toolbox diagnostyczny: print, assert, komunikaty log...

Mini‑toolbox diagnostyczny: print, assert, komunikaty logów

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

1. Problem

Nawet jeśli kompilator zgodził się zbudować twój kod, to nie znaczy, że program robi to, co zamierzałeś. Czasami wypisuje niepoprawną odpowiedź, czasami się zawiesza, a czasami „działa czasami”. Wtedy zaczyna się dorosłe życie programisty: nie tylko piszesz kod, ale weryfikujesz hipotezy o tym, co się w nim dzieje. Dobra wiadomość: nawet bez debuggera i zaawansowanych narzędzi możesz znacznie zwiększyć szanse szybkiego znalezienia błędu.

Na tym poziomie złożymy mini‑zestaw technik, który działa prawie zawsze: staranne drukowanie (i rozdzielenie zwykłego outputu od diagnostyki), sprawdzanie kluczowych założeń przez assert oraz proste komunikaty logów (żeby nie mnożyć chaosu z dziesięciu std::cout).

Aby nie było abstrakcyjnie, rozwiniemy małą aplikację konsolową: MiniTasks — lista zadań, w której można dodać zadanie, wypisać listę i oznaczyć zadanie jako wykonane.

Przykład szkoleniowy: MiniTasks

Potrzebujemy teraz maksymalnie prostego szkielettu programu, żeby na nim pokazywać diagnostykę. Ważne: specjalnie piszemy kod „na surowo”, bez architektonicznych wymyślnych rozwiązań — dziś trenujemy umiejętność zobaczenia, co program robi naprawdę.

Zaczniemy od szkicu: przechowujemy zadania w std::vector<std::string>, czytamy polecenia linią przez std::getline, parsujemy komendę jako „pierwsze słowo + reszta”. To nie jest idealny parser, ale jest uczciwy i przejrzysty.

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

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

    while (true) {
        std::cout << "cmd> ";

        std::string line;
        if (!std::getline(std::cin, line)) {
            break; // EOF / błąd wejścia
        }

        if (line == "quit") {
            break;
        } else if (line.rfind("add ", 0) == 0) { // zaczyna się od "add "
            std::string text = line.substr(4);
            tasks.push_back(text);
            std::cout << "added\n"; // added
        } else if (line == "list") {
            for (std::size_t i = 0; i < tasks.size(); ++i) {
                std::cout << i << ": " << tasks[i] << '\n';
            }
        } else {
            std::cout << "unknown command\n"; // unknown command
        }
    }
}

Na razie brak tutaj done, brak kontroli, brak estetyki — ale to świetny poligon do diagnostyki: łatwo możesz zrobić błąd w indeksach, w parsowaniu, w logice rozgałęzień, a potem szybko go złapać.

2. Diagnostyczne wypisywanie: std::cout vs std::cerr

Gdy program zachowuje się dziwnie, pierwsza reakcja początkującego to „wypiszę wszystko przez std::cout”. To naturalny instynkt (jak latarka w piwnicy), ale jest niuans: std::cout często jest częścią oczekiwanego outputu. Jeśli twój program ma rozwiązywać zadanie na platformie testowej lub musi wypisywać dokładnie według formatu, diagnostyka w std::cout psuje wynik.

Zasada jest prosta: zwykły output — do std::cout, diagnostyka — do std::cerr. Wtedy „normalna” odpowiedź programu pozostaje czysta, a komunikaty o tym, co się dzieje wewnątrz, idą do osobnego strumienia błędów.

Porównajmy w formie tabeli:

Narzędzie Do czego Kto to czyta Co się stanie, jeśli „nasypać” tam debug
std::cout
wynik działania programu użytkownik / system sprawdzający można zepsuć format outputu
std::cerr
diagnostyka, błędy, debug ty (jako deweloper) format wyniku nie ucierpi
assert
sprawdzanie wewnętrznych założeń ty (jako deweloper) program zatrzyma się, jeśli założenie zostanie naruszone

Dodajmy do MiniTasks diagnostykę odczytu linii, ale do std::cerr:

#include <iostream>
#include <string>

int main() {
    std::string line;
    std::getline(std::cin, line);

    std::cerr << "[debug] got line: '" << line << "'\n";
    std::cout << "ok\n"; // ok
}

Jeśli uruchamiasz ręcznie, zobaczysz dwa różne strumienie. W większości IDE i Web‑IDE są one często wyświetlane razem, ale logicznie to dwa kanały: jeden „dla użytkownika”, drugi „dla dewelopera”.

4. Użyteczne wypisywanie: kontekst ważniejszy niż ilość

Wypisywanie staje się naprawdę przydatne, gdy odpowiada na pytanie „co teraz sprawdzam?” i „które wartości są istotne?”. Jeśli po prostu wypisujesz „tu był A” i potem „tu był B”, szybko utoniesz we własnych komunikatach i zaczniesz nienawidzić debugowania (i trochę ludzkości).

Dobry debugowy output zwykle zawiera kontekst: nazwę komendy, rozmiar kontenera, indeks, wartości pośrednie. Wyobraźmy sobie, że dodaliśmy komendę add, ale czasem zadania dodawane są puste. Chcemy wiedzieć: czy linia jest naprawdę pusta, czy źle tniemy substr.

Dodajmy jedną linię diagnostyczną:

// ... wewnątrz gałęzi add
std::string text = line.substr(4);
std::cerr << "[debug] add text length=" << text.size() << "\n";
tasks.push_back(text);

Teraz, jeśli ktoś wpisze add (a potem spację i nic więcej), wyraźnie zobaczysz length=0. A jeśli gdzieś przesunąłeś substr, zobaczysz nieoczekiwaną długość lub zawartość.

I oto ważny „lifehack bez magii”: drukujemy nie „wszędzie”, a zgodnie z hipotezą. Hipoteza: „do text trafia coś nie tego”. Zatem drukujemy text. Hipoteza: „indeks wychodzi poza zakres”. Zatem drukujemy i i tasks.size() — najlepiej jeszcze przed dostępem do elementu.

5. Sprawdzenia i wczesne wyjście: if kontra chaos

Wiele błędów czasu wykonania (szczególnie u początkujących) to nie „trudne obliczenia”, ale brak elementarnych sprawdzeń granicznych i przypadków pustych. Diagnostyka zaczyna się od prostego pytania: „co się stanie, jeśli danych braknie?”.

Dodajmy komendę done N, która usuwa zadanie po indeksie (uważamy, że „zrealizować” = usunąć z listy). Naiwna implementacja może wyglądać tak:

// ZŁE: póki bez sprawdzeń
std::size_t index = /* jakoś sparsowano */;
tasks.erase(tasks.begin() + index);

Jeśli index jest nieprawidłowy, zachowanie będzie złe: od crasha po „dziwne usunięcie niewłaściwego elementu”. Rozsądna wersja zaczyna się od sprawdzenia i komunikatu o błędzie:

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

bool remove_task(std::vector<std::string>& tasks, std::size_t index) {
    if (index >= tasks.size()) {
        std::cerr << "[error] index out of range: index=" << index
                  << " size=" << tasks.size() << "\n";
        return false;
    }

    tasks.erase(tasks.begin() + index);
    return true;
}

Zwróć uwagę na styl: nie „upadamy”, mówimy dokładnie, co jest nie tak, i zwracamy false. To już przypomina dobrą inżynierską praktykę: najpierw nie pozwolić programowi wykonać niebezpiecznego kroku, potem wyjaśnić, dlaczego odmówiliśmy.

assert też wykonuje sprawdzenia, ale ma inną filozofię. Omówimy to teraz.

6. assert: sprawdzamy wewnętrzne założenia

assert to sposób powiedzenia: „jeśli to wyrażenie jest fałszywe, to w programie wydarzyło się coś, czego nie powinno być”. Chodzi tu nie o „użytkownik podał źle”, lecz o „to my naruszyliśmy wewnętrzny kontrakt”. Czyli assert to zabezpieczenie przed błędem programisty.

Dołącza się go tak:

#include <cassert>

I używa tak:

#include <cassert>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    std::size_t i = 2;
    assert(i < v.size());   // jeśli fałsz — awaryjne zakończenie

    int x = v[i];
}

Jeśli wyrażenie jest fałszywe, program awaryjnie się zatrzyma i wskaże miejsce (zazwyczaj plik i linia). To bardzo wygodne, gdy chcesz „upadać wcześnie”, zamiast dostawać niezrozumiały crash dziesięć linii dalej.

Ważne są dwie cechy assert.

Pierwsza — assert często jest wyłączany w buildzie produkcyjnym (zwykle przy pewnym makrze). Nie wchodzimy dziś w detale konfiguracji builda, ale sens jest taki: assert to narzędzie deweloperskie, a nie „grzeczny komunikat dla użytkownika”.

Druga — w assert nie powinno być efektów ubocznych. Bo jeśli assert zostanie wyłączony, efekty uboczne znikną — i program zacznie działać inaczej. Przykład złego kodu:

#include <cassert>

int main() {
    int x = 0;
    assert(++x == 1); // źle: zmieniamy x wewnątrz assert
}

Tu „odpaliliśmy” ++x, ale w buildzie, gdzie assert jest wyłączony, x w ogóle się nie zmieni. Powstaje trudny do wykrycia błąd: w debug działa, w release — nie. Tego unikamy (a błędy to uwielbiają).

7. assert w MiniTasks: łapiemy błędy wcześniej

Teraz doprowadźmy MiniTasks do komendy done N. Potrzebne będzie parsowanie liczby. Na tym etapie możesz użyć std::stoi jako demonstracji (wiesz już, że przy złym inputcie mogą być problemy). Zrobimy krótką, ostrożną wersję: jeśli parsowanie nie powiodło się — raportujemy i pomijamy komendę.

Oto fragment obsługi done:

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

int main() {
    std::vector<std::string> tasks{"learn C++", "drink tea"};

    std::size_t index = 1;

    assert(index < tasks.size()); // wewnętrzne założenie
    tasks.erase(tasks.begin() + index);

    std::cout << tasks.size() << "\n"; // 1
}

To ilustracja „wewnętrznej pewności”: jeśli index pochodzi z miejsca, gdzie jest gwarantowany jako poprawny, assert ma sens. Ale jeśli index pochodzi od użytkownika, lepiej zrobić if i pisać do std::cerr, bo użytkownik może się mylić (i nie powinniśmy „obrażać się” upadkiem programu).

Kombinacja wygląda tak: najpierw if dla wejścia od użytkownika, a assert — dla wewnętrznej logiki, która „nie powinna się zepsuć”.

8. Logi: funkcje i flaga

Proste log_debug i log_error

Gdy projekt trochę urośnie, pojawia się typowy problem: komunikaty debugowe mnożą się jak... no cóż, jak króliki w podręczniku do biologii. Zaczynasz kopiować [debug] ręcznie, zapominasz gdzieś \n, mieszasz „błąd” i „tylko do podglądu”, a potem połowę trzeba usuwać.

Dlatego nawet na podstawowym poziomie warto mieć dwie małe funkcje: log_debug i log_error. To nie „prawdziwy system logowania”, a sposób na zdyscyplinowanie wypisywania.

Oto najprostsza wersja:

#include <iostream>
#include <string>

void log_debug(const std::string& msg) {
    std::cerr << "[debug] " << msg << "\n";
}

void log_error(const std::string& msg) {
    std::cerr << "[error] " << msg << "\n";
}

I użycie:

log_debug("start reading command");
log_error("unknown command");

Od razu łatwiej czytać i łatwiej usuwać: szybko znajdziesz log_debug wyszukując i usuniesz/wyłączysz.

Włączanie i wyłączanie debug‑logów

Czasami chcesz zostawić log_debug w kodzie, ale nie chcesz, żeby on zawsze wypisywał. Tutaj przydaje się pomysł „flagi”: włączysz diagnostykę — dostaniesz szczegóły, wyłączysz — cisza.

Skoro znasz preprocesor, można zrobić prostą rzecz: makro ENABLE_DEBUG_LOGS. Gdy jest 1 — wypisujemy, gdy 0 — milczymy.

#include <iostream>
#include <string>

#define ENABLE_DEBUG_LOGS 1

void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
    std::cerr << "[debug] " << msg << "\n";
#else
    (void)msg; // żeby kompilator nie narzekał na unused
#endif
}

Teraz zmieniasz jedną liczbę i przełączasz „gadliwość” programu. To wygodne, kiedy naprawiłeś błąd, ale chcesz zostawić możliwość szybkiego włączenia śledzenia, gdyby znów coś poszło nie tak.

Ważne: nie rób z tego kultu. Makra są potężnym narzędziem, ale działają tekstowo i nie „rozumieją” semantyki C++. Używaj ich oszczędnie: do prostego włączania/wyłączania diagnostyki.

9. MiniTasks: komendy i diagnostyka

Połączmy teraz pomysły w bardziej spójny fragment: czytamy linię, rozpoznajemy komendy add, list, done, wypisujemy błędy do std::cerr, a debug‑komunikaty włączamy flagą.

Kod nadal jest nieduży, ale już „przypomina program”:

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

#define ENABLE_DEBUG_LOGS 1

void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
    std::cerr << "[debug] " << msg << "\n";
#else
    (void)msg;
#endif
}

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

    while (true) {
        std::cout << "cmd> ";

        std::string line;
        if (!std::getline(std::cin, line)) break;

        log_debug("line='" + line + "'");

        if (line == "quit") break;

        if (line.rfind("add ", 0) == 0) {
            std::string text = line.substr(4);
            if (text.empty()) {
                std::cerr << "[error] task text is empty\n";
                continue;
            }
            tasks.push_back(text);
            continue;
        }

        if (line == "list") {
            for (std::size_t i = 0; i < tasks.size(); ++i) {
                std::cout << i << ": " << tasks[i] << '\n';
            }
            continue;
        }

        if (line.rfind("done ", 0) == 0) {
            std::string arg = line.substr(5);
            int idx = std::stoi(arg); // demonstracyjnie: zły input może "zrzucić"
            if (idx < 0 || static_cast<std::size_t>(idx) >= tasks.size()) {
                std::cerr << "[error] bad index: " << idx << "\n";
                continue;
            }

            std::size_t index = static_cast<std::size_t>(idx);
            assert(index < tasks.size()); // po if to już "wewnętrzna pewność"
            tasks.erase(tasks.begin() + index);
            continue;
        }

        std::cerr << "[error] unknown command\n";
    }
}

Tak, tu jeszcze można poprawić parsowanie (np. obsługiwać wyjątki stoi, ale wyjątki jako mechanizm omówimy szczegółowo w osobnym dniu). Ważniejsze: widzisz już „szkielet” diagnostyki, który można wykorzystać w niemal każdej ćwiczeniowej aplikacji.

10. Jak myśleć podczas debugowania: krótki cykl

Gdy wszystko się psuje, łatwo zacząć wprowadzać chaotyczne poprawki: „dodajmy tu +1”, „zmieńmy tu <=”. Czasem to przypadkowo naprawi… ale częściej tworzy bug nr 2, żeby bug nr 1 nie był sam.

Warto trzymać proces debugowania w głowie jako krótki cykl:

flowchart TD
    A[Obserwujemy problem] --> B[Sformułuj hipotezę]
    B --> C[Wstaw punktowe wypisanie do std::cerr]
    C --> D[Sprawdź inwariant przez assert lub if]
    D --> E[Znajdź miejsce, gdzie oczekiwanie się łamie]
    E --> F[Popraw kod]
    F --> G[Usuń/wyłącz debug-loga]
    G --> A

Słowo kluczowe to „punktowe”. Diagnostyka jest dobra, gdy działa jak narzędzie chirurgiczne, a nie jak wiadro farby po całym pokoju.

11. Typowe błędy

Błąd nr 1: debug przez std::cout i zepsucie formatu outputu.
Gdy wypisujesz diagnostykę do std::cout, mieszając „wynik programu” i „wewnętrzne myśli dewelopera”, możesz zepsuć format wymagany przez testy. Przyzwyczaj się: diagnostyka mieszka w std::cerr.

Błąd nr 2: „wypiszmy wszystko” i utoniemy w hałasie.
Dużo wypisywania nie równa się dobrej diagnostyce. Gdy komunikatów jest za dużo, przestajesz je czytać i zaczynasz ignorować także ważne. Lepiej wstawić jedną‑dwie linie z konkretnym kontekstem (np. index i size) tam, gdzie podejrzewasz problem.

Błąd nr 3: używać assert do błędów wejścia od użytkownika.
assert dotyczy „tego nie powinno się zdarzyć według logiki programu”. Użytkownik może się pomylić: wpisać nie to, pominąć argument, napisać done kotek. Na to potrzebne są if i komunikaty o błędzie, a nie awaryjne zatrzymanie programu. assert zostaw dla wewnętrznych kontraktów: indeksów po sprawdzeniach, niemożliwych gałęzi, założeń o stanie kontenera.

Błąd nr 4: efekty uboczne wewnątrz assert.
assert(++i < n) wygląda „wygodnie”, ale to pułapka: w konfiguracjach, gdzie assert jest wyłączony, ++i nie zostanie wykonane i zachowanie programu się zmieni. W assert powinno być czyste wyrażenie, bez modyfikacji zmiennych, I/O i wywołań funkcji z efektami.

Błąd nr 5: leczyć symptomy zamiast przyczyny.
Jeśli program pada z powodu wyjścia poza zakres, można próbować „podrasować indeks”, ale lepiej najpierw ustalić, dlaczego indeks stał się niepoprawny. Dobra diagnostyka pokazuje drogę danych: skąd przyszedł indeks, jak się przekształcał, jaki był rozmiar kontenera w tym momencie, i dopiero potem zmieniasz kod.

1
Zadanie
C++ SELF, poziom 30, lekcja 4
Niedostępne
Sprawdzenie diagnostyczne
Sprawdzenie diagnostyczne
1
Zadanie
C++ SELF, poziom 30, lekcja 4
Niedostępne
Sterowany logger
Sterowany logger
1
Zadanie
C++ SELF, poziom 30, lekcja 4
Niedostępne
Bezpieczny znak
Bezpieczny znak
1
Zadanie
C++ SELF, poziom 30, lekcja 4
Niedostępne
Konsolowy planer
Konsolowy planer
1
Ankieta/quiz
Co robi kompilator, poziom 30, lekcja 4
Niedostępny
Co robi kompilator
Co robi kompilator
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION