CodeGym /Kursy /C++ SELF /Lista kontrolna dochodzenia błędów

Lista kontrolna dochodzenia błędów

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

1. Po co lista kontrolna dochodzenia

Gdy program pada, mózg bardzo szybko zaczyna grać w dwie gry: „zaraz zgadnę przyczynę” i „zaraz to szybko „naprawię””. Obie gry są przyjemne, bo dają poczucie kontroli, ale w rzeczywistości często prowadzą do leczenia objawu, a nie pierwotnej przyczyny. Lista kontrolna jest po to, by działać jak inżynier: powtarzalnie, z rejestracją faktów i z minimalną ilością magii.

Najbardziej użyteczna zmiana myślenia w dochodzeniu — przestać postrzegać błąd jako „zdarzenie” i zacząć postrzegać go jako proces. Proces składa się z małych kroków i każdy krok powinien pozostawiać po sobie coś weryfikowalnego: reprodukowalność, minimalny scenariusz, konkretny punkt w kodzie, konkretną poprawkę i krótką weryfikację, że to nie wróci.

Poniżej — ogólna ścieżka, którą będziemy omawiać i „uzasadniać” na kodzie:

flowchart TD
    A[Odtworzyć] --> B[Zarejestrować sygnał: ASan/assert/crash]
    B --> C[Zminimalizować scenariusz]
    C --> D[Zlokalizować przyczynę]
    D --> E[Naprawić]
    E --> F[Ponownie sprawdzić pod diagnostyką]
    F --> G[Sprawdzenie regresji: scenariusz nie powinien wrócić]

Aby to było całkowicie „po roboczu”, miej w głowie małą ściągawkę:

Krok Co chcesz osiągnąć Co zachowujesz (artefakt) Jak zrozumieć, że krok zakończony
Odtworzyć Błąd powtarza się identycznie wejście/komenda/warunki pada/wywołuje się w 3–5 uruchomieniach z rzędu
Zminimalizować Najkrótszy scenariusz, który nadal się psuje krótkie wejście/kawałek kodu usunęliśmy zbędne elementy, ale usterka pozostała
Zlokalizować Miejsce, gdzie „po raz pierwszy stało się nieprawidłowo” konkretna linia/funkcja/kontrakt możesz sformułować pierwotną przyczynę słowami
Naprawić Usunięcie pierwotnej przyczyny diff / zmiana kodu diagnostyka milczy, zachowanie poprawne
Sprawdzenie regresji Błąd nie wróci po cichu assert/auto-test jeśli przywrócić stary błąd — test/prog zawiedzie

2. Odtwarzanie: przemieniamy „czasami pada” w „pada zawsze”

Słowo „odtworzyć” brzmi tak, jakbyśmy włączali muzykę, ale w praktyce to najbardziej niedoceniana część dochodzenia. Dopóki nie masz stabilnego odtworzenia, nie możesz odróżnić prawdziwej przyczyny od zbiegu okoliczności. Zmieniasz kod, błąd „znika” i mózg radośnie wyciąga wniosek „naprawione”. A po dwóch dniach błąd wraca, bo po prostu przestałeś trafiać w odpowiedni timing lub układ pamięci.

Rejestrujemy „jak dokładnie uruchomić”

Zacznij od prostego: jedna komenda / jedno wejście / jeden scenariusz. W edukacyjnych programach CLI zwykle oznacza to: albo stały input w konsoli, albo czytanie ze stringa, albo specjalny przełącznik --demo.

Kontynuujemy nasze edukacyjne aplikacje — konsolowy mini‑menedżer zadań TaskBook, gdzie zadania są w std::vector. Wyobraźmy sobie, że kiedyś dodaliśmy „wygodną optymalizację”: przechowywać wskaźnik do wybranego zadania, żeby szybko wypisać jego szczegóły. A potem niespodziewanie dostaliśmy zagadkowe padu przy dodawaniu nowych zadań.

Oto minimalny demonstracyjny fragment złej idei, który chcemy potrafić stabilnie odtworzyć:

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

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks{{1, "Write report"}};
    Task* selected = &tasks[0];          // zapamiętaliśmy adres elementu vector

    tasks.push_back({2, "Drink tea"});   // może nastąpić realokacja pamięci i "przeprowadzka"
    std::cout << selected->title << '\n';// UB: selected mógł stać się dangling
}

Chodzi nie o to, „czym jest UB” (to już omawialiśmy wcześniej), lecz o to, że potrzebujesz sterowanego uruchomienia, które czyni problem powtarzalnym.

Robimy odtwarzanie deterministycznym

Czasem jedno „wejście” to za mało: błąd zależy od tego, jak dokładnie rozdzieliła się pamięć. Twoim celem nie jest wtedy „ładnie to zrobić”, ale „zrobić przewidywalnym”.

Na przykład można celowo stworzyć warunki, przy których vector niemal na pewno zrealokuje pamięć: ograniczyć capacity.

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

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks;
    tasks.reserve(1);                    // chcemy capacity = 1 (lub blisko tego)
    tasks.push_back({1, "A"});
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});           // z dużym prawdopodobieństwem nastąpi reallocation
    std::cout << selected->title << '\n';// UB (może się "udać", ale to sygnał)
}

Tak, to trochę przypomina ustawianie eksperymentu w laboratorium. I to jest w porządku: na czas dochodzenia nie piszesz „idealnego produkcyjnego” kodu, stawiasz eksperyment nad błędem.

Co zapisać w „paszporcie odtworzenia”

Superpraktyka: dosłownie w komentarzu (albo w osobnej notatce) zarejestrować trzy rzeczy: jakie wejście, jaka komenda, jaki oczekiwany sygnał (ASan/crash/assert).

W kodzie może to wyglądać tak:

// Repro: run -> crashes/ASan complains after push_back
// Scenario: store pointer to tasks[0], then push_back one more task, then read old pointer.

To nie biurokracja. To ubezpieczenie przed sytuacją „wczoraj mi padało, a dziś nie i nie pamiętam jak”.

3. Minimalizacja: wyrzucamy wszystko poza przyczyną

Minimalizacja — to, gdy bierzesz duży, straszny projekt, gdzie „pada gdzieś przy zamykaniu”, i zmieniasz go w 15 linii, gdzie „pada przy drugim push_back”. Ten krok oszczędza godziny życia, bo zmniejsza pole poszukiwań i liczbę możliwych przyczyn. I tak: minimalizacja prawie zawsze ważniejsza niż pierwsza próba „od razu naprawić”.

Minimalizacja wejścia i minimalizacja kodu — to różne rzeczy

Czasami problem ujawnia się na konkretnym wejściu: długi string, duży plik, szczególny porządek komend. Wtedy minimalizujesz wejście, nie ruszając kodu. Czasami problem bierze się z architektury: wskaźnik przechowywany jest długo, gdzieś kontener się przeprowadza, gdzieś pamięć jest zwalniana. Wtedy minimalizujesz kod, nawet jeśli wejście pozostaje to samo.

Dla TaskBook wygodniej jest minimalizować kod: nie potrzebujemy całego CLI, menu, parsera komend. Potrzebujemy tylko: vector, wskaźnik, push_back, odczyt przez stary wskaźnik.

Technika „tnij po jednym”

Jeśli wyrzucasz od razu połowę programu, możesz przypadkowo usunąć i sam błąd, i potem nie zrozumieć, co było istotne.

Dobre zasady: wprowadzaj zmiany tak, aby po każdym kroku móc uczciwie powiedzieć: „błąd nadal się odtwarza”. To jak gra „Jenga”: wyciągasz po jednym klocku i sprawdzasz, czy wieża się nie zawaliła.

Na przykład, jeśli pierwotnie błąd występował w funkcji wypisującej wybrane zadanie, możesz tymczasowo zastąpić „ładne wypisywanie” jedną linią. To nie jest „finalny kod”, to latarka w ciemności:

#include <iostream>
#include <string>

struct Task { int id; std::string title; };

void print_selected(const Task* t) {
    std::cout << t->title << '\n'; // jeśli t dangling — tu może być crash/ASan narzeka
}

Teraz w minimalnym scenariuszu pozostają tylko ważne role: przechowywanie wskaźnika i moment jego użycia.

Minimalizacja jako sposób złapania fałszywej przyczyny

Najczęstsza pułapka: widzisz crash „w destruktorze stringa” lub „wewnątrz vectora” i wydaje się, że „string się zepsuł”. Minimalizacja często pokazuje, że string nie ma z tym nic wspólnego, a problem jest w tym, że odwołujesz się do pamięci po jej przeprowadzce/zwolnieniu.

Psychologicznie to użyteczne: gdy kod jest mały, mózg przestaje fantazjować i zaczyna widzieć zależności przyczynowo‑skutkowe.

4. Dokumentacja: czynimy dochodzenie powtarzalnym

Dokumentacja — to moment, kiedy mówisz: „to konkretnie ten mały scenariusz — nasz wzorcowy błąd”. Potrzebne to nie tylko po to, by „teraz naprawić”, ale też by za tydzień nie kłócić się z samym sobą: „co dokładnie się zepsuło?”

Ważne jest rozróżnienie dwóch rodzajów dokumentacji: dokumentacja scenariusza i dokumentacja oczekiwanego zachowania.

Rejestrujemy scenariusz w kodzie: demo_*

W projekcie edukacyjnym wygodnie trzymać takie rzeczy jako oddzielne funkcje, które można włączać/wyłączać. Na przykład:

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

struct Task { int id; std::string title; };

void demo_dangling_pointer() {
    std::vector<Task> tasks{{1, "A"}};
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});
    std::cout << selected->title << '\n'; // UB (demo-bug)
}

int main() {
    demo_dangling_pointer();
}

Teraz to „demo‑błąd”. Po naprawie ta sama funkcja może stać się „demo‑sprawdzeniem”: nie powinna się psuć i powinna zachowywać się poprawnie (lub wywołać twój assert, jeśli uznasz, że scenariusz jest nieakceptowalny zgodnie z kontraktem).

Rejestrujemy oczekiwane zachowanie

Tu zaczyna się dorosła część inżynierska: musisz odpowiedzieć, co w tej sytuacji aplikacja powinna robić.

Opcje (na poziomie zasad, bez zamieniania w ogromny dokument projektowy):

  • Jeżeli kontrakt mówi „nie przechowujemy surowego wskaźnika na element vector”, to poprawne zachowanie — nie dawać takiej możliwości w kodzie. Dokumentacja wtedy brzmi: „po refaktorze wskaźnik znika, a wybrane zadanie przechowywane jest inaczej”.
  • Jeżeli kontrakt mówi „przechowujemy selectedIndex i za każdym razem pobieramy element na nowo”, to poprawne zachowanie — wypisywać zadanie przez indeks i przed wypisaniem sprawdzać zakresy.
  • Jeżeli kontrakt mówi „wybrane zadanie powinno żyć niezależnie od kontenera”, to poprawne zachowanie — przechowywać kopię (lub własność), a nie referencję do wnętrza.

Nie wchodzimy dziś w architekturę własności (to osobne dni kursu), ale ideę dokumentacji zachowania warto omówić: bez niej „naprawa” staje się przypadkowym zbiorem poprawek.

5. Myślenie regresyjne: zapobiegamy powrotowi błędu

Myślenie regresyjne — to przyzwyczajenie myśleć nie „jak naprawić teraz”, lecz „jak zrobić, żeby tego nie zepsuto znowu”. To nie paranoja, a oszczędność czasu. Jeśli błąd został kiedyś znaleziony, już udowodnił, że jest realistyczny. Oznacza to, że prawdopodobieństwo jego powrotu jest niezerowe — zwłaszcza gdy zespół rośnie lub ty sam po miesiącu zapomnisz szczegóły.

Mini‑sprawdzenie regresji bez frameworków testowych: self_check() na assert

Na dzisiejszy plan nie podłączamy frameworków testowych, więc używamy najprostszej „kotwicy” — małej auto‑sprawdzenia na assert.

Na przykład, jeśli postanowiłeś przechowywać wybrane zadanie jako indeks, to sprawdzenie regresji może wyglądać tak:

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

struct Task { int id; std::string title; };

void self_check_selected_index() {
    std::vector<Task> tasks{{1, "A"}};
    std::size_t selected = 0;

    tasks.push_back({2, "B"});               // reallocation nie jest już groźne
    assert(tasks.at(selected).title == "A"); // sprawdzamy oczekiwane zachowanie
}

Zwróć uwagę na detal: at() tutaj jest użyte jako „kontrolny strzał” po zakresach. Jeśli indeks stanie się nieprawidłowy, dostaniesz jasny sygnał (wyjątek lub diagnostykę, zależnie od środowiska), a nie ciche UB.

Myślenie regresyjne jako bariera projektowa

Czasami najlepsza regresja to nie assert, a zakaz nieprawidłowego stanu.

Na przykład, jeśli w projekcie istnieje reguła „nie przechowywać Task* wskazujących na elementy vector<Task>”, możesz wymusić to stylem API: funkcje zwracają TaskId albo indeks, ale nie „adres zadania”. Tak, brzmi to jak architektura, ale nawet w małym projekcie można zrobić krok: przestać oddawać na zewnątrz adresy elementów.

W edukacyjnym TaskBook prosty krok wygląda tak: zamiast funkcji find_task(...) zwracającej wskaźnik, zrobić find_task_index(...) i dalej operować przez kontener.

Ściągawka na jeden ekran

Gdy jesteś w środku dochodzenia, głowa często zajęta emocjami: „dlaczego to znowu upadło”, „przecież nic nie zmieniałem”, „komputer mnie nie lubi”. W takim momencie pomocna jest krótka ściągawka, która odsyła cię na tory.

Poniżej — ten sam proces, ale bardziej operacyjnie, z sformułowaniami w stylu „zrób → uzyskaj → sprawdź”.

flowchart TD
    A[1 Odtworzyć] --> A1[Zanotuj wejście/komendę/warunki]
    A1 --> A2[Uzyskaj stabilność: 3–5 razy z rzędu]
    A2 --> B[2 Zminimalizować]
    B --> B1[Skróć wejście / usuń zbędny kod]
    B1 --> B2["Zostaw minimalny scenariusz (MRE)"]
    B2 --> C[3 Zlokalizować]
    C --> C1[Znajdź pierwszy punkt w twoim kodzie]
    C1 --> C2[Sformułuj pierwotną przyczynę słowami]
    C2 --> D[4 Naprawić]
    D --> D1[Poprawka usuwająca przyczynę, nie objaw]
    D1 --> E[5 Ponownie sprawdzić]
    E --> E1[Uruchom pod diagnostyką / z assert]
    E1 --> F[6 Sprawdzenie regresji]
    F --> F1[Krótkie self_check dla scenariusza]

Najważniejsze: jeśli łapiesz się na myśli „ja i tak wiem, co tam nie tak”, spróbuj mimo to przejść listę kontrolną. Zadziwiająco często wychwytuje dziury: na przykład okazuje się, że odtworzenie niestabilne i pół dnia „naprawiałeś” nieistniejący błąd.

6. Praktyczny przykład: od błędu do sprawdzenia regresji

Złóżmy historię w zwartą sekwencję: był UB z powodu wiszącego wskaźnika; wybraliśmy rozwiązanie „przechowujemy indeks”; dodaliśmy auto‑sprawdzenie.

Zła wersja

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

struct Task { int id; std::string title; };

int main() {
    std::vector<Task> tasks{{1, "A"}};
    Task* selected = &tasks[0];

    tasks.push_back({2, "B"});
    std::cout << selected->title << '\n'; // UB
}

Poprawiona wersja i sprawdzenie regresji

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

struct Task { int id; std::string title; };

void self_check() {
    std::vector<Task> tasks{{1, "A"}};
    std::size_t selected = 0;

    tasks.push_back({2, "B"});
    assert(tasks.at(selected).title == "A");
}

int main() {
    self_check();
}

To nie „cała architektura świata”. To minimalny przykład myślenia regresyjnego: nie tylko poprawiasz kod, zostawiasz mały latarnik, który powie „zepsuto ponownie”.

7. Typowe błędy w dochodzeniu

Błąd nr 1: zaczynać „naprawiać” bez osiągnięcia stabilnego odtworzenia.
Gdy błąd występuje „co drugi raz”, bardzo chcesz natychmiast wprowadzić poprawki — wydaje się, że to przyspieszy zwycięstwo. W praktyce dostajesz ruletkę: zmieniłeś kod, błąd się nie pojawił, uznałeś, że wygrałeś, a następnego dnia wrócił. Stabilne odtworzenie to twój zerowy kilometr; bez niego biegasz po lesie bez mapy.

Błąd nr 2: minimalizować zbyt agresywnie i stracić sam błąd.
Czasem studenci wyrzucają połowę programu jednym ruchem, błąd znika i zaczyna się panika: „to nie to”. Częściej znaczy to, że przypadkowo usunęli ważny element scenariusza. Minimalizuj stopniowo, sprawdzając po każdej zmianie, że usterka nadal występuje — inaczej tracisz związek przyczynowo‑skutkowy.

Błąd nr 3: „naprawiać miejsce awarii”, ignorując pierwotną przyczynę.
Bardzo kuszące jest zobaczyć crash na linii std::cout << ... i pomyśleć „to wina outputu”. W praktyce output często jest tym, który jako pierwszy dotyka uszkodzonej pamięci. Jeśli leczysz objaw, błąd może zmienić maskę i zacząć padać gdzie indziej, a ty uznasz, że „pojawił się nowy błąd”. Zwykle to ten sam stary błąd, tylko inne dekoracje.

Błąd nr 4: uważać „przestało padać” za dowód naprawy.
Pamiętne błędy są szczególnie przewrotne — mogą „milczeć” długo. Trochę zmieniłeś kolejność operacji — i UB przestał się ujawniać. Ale UB nie ma obowiązku się ujawniać (w tym jego toksyczność). Dlatego po naprawie potrzebne jest ponowne sprawdzenie pod diagnostyką i konieczne jest zarejestrowanie scenariusza regresji, choćby jako proste self_check().

Błąd nr 5: nie zostawiać po dochodzeniu artefaktów (scenariusza i sprawdzenia).
Jeśli po „naprawieniu” nie pozostał ani minimalny scenariusz, ani komentarz „jak odtworzyć”, ani małe sprawdzenie regresji, dochodzenie było jednorazowym wyczynem. Wyczyn ładny, ale drogi: następnym razem znowu będziesz męczyć się heroicznym wysiłkiem. Myślenie regresyjne to właśnie o to — cierpieć raz, a nie regularnie.

1
Zadanie
C++ SELF, poziom 47, lekcja 4
Niedostępne
Średnia wartość paragonu
Średnia wartość paragonu
1
Zadanie
C++ SELF, poziom 47, lekcja 4
Niedostępne
Przeniesienie wektora
Przeniesienie wektora
1
Zadanie
C++ SELF, poziom 47, lekcja 4
Niedostępne
Wybór po indeksie
Wybór po indeksie
1
Zadanie
C++ SELF, poziom 47, lekcja 4
Niedostępne
Parsowanie pary
Parsowanie pary
1
Ankieta/quiz
View‑typy i triage, poziom 47, lekcja 4
Niedostępny
View‑typy i triage
View‑typy i triage
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION