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 |
|---|---|---|---|
|
wynik działania programu | użytkownik / system sprawdzający | można zepsuć format outputu |
|
diagnostyka, błędy, debug | ty (jako deweloper) | format wyniku nie ucierpi |
|
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.
GO TO FULL VERSION