W poprzedniej lekcji omawialiśmy strategię wyszukiwania problemów poprzez uruchamianie naszych programów i domyślanie się, gdzie występuje problem. Podczas tej lekcji omówimy kilka podstawowych taktyk umożliwiających domyślanie się i zbieranie informacji pomocnych w znajdowaniu problemów.
Taktyka debugowania nr 1: komentowanie kodu
Zacznijmy od łatwego. Jeśli Twój program zachowuje się błędnie, jednym ze sposobów zmniejszenia ilości kodu, który musisz przeszukiwać, jest dodanie komentarza do kodu i sprawdzenie, czy problem nadal występuje. Jeśli problem pozostaje niezmieniony, prawdopodobnie zakomentowany kod nie jest za to odpowiedzialny.
Rozważ następujący kod:
int main()
{
getNames(); // ask user to enter a bunch of names
doMaintenance(); // do some random stuff
sortNames(); // sort them in alphabetical order
printNames(); // print the sorted list of names
return 0;
}Powiedzmy, że ten program ma drukować nazwy wprowadzone przez użytkownika w kolejności alfabetycznej, ale drukuje je w odwrotnej kolejności alfabetycznej. Gdzie jest problem? Czy getNames wpisuje nazwy niepoprawnie? Czy sortNames sortuje je wstecz? Czy printNames drukuje je wstecz? Może to być dowolna z tych rzeczy. Możemy jednak podejrzewać, że funkcja doMaintenance() nie ma nic wspólnego z problemem, więc skomentujmy to.
int main()
{
getNames(); // ask user to enter a bunch of names
// doMaintenance(); // do some random stuff
sortNames(); // sort them in alphabetical order
printNames(); // print the sorted list of names
return 0;
}Istnieją trzy prawdopodobne wyniki:
- Jeśli problem zniknie, przyczyną problemu musi być doMaintenance i na tym powinniśmy skupić swoją uwagę.
- Jeśli problem pozostaje niezmieniony (co jest bardziej prawdopodobne), to możemy rozsądnie założyć, że doMaintenance nie zawinił i możemy na razie wykluczyć całą funkcję z naszych poszukiwań. Nie pomaga nam to zrozumieć, czy rzeczywisty problem występuje przed, czy po wywołaniu doMaintenance, ale zmniejsza ilość kodu, który musimy później przeglądać.
- Jeśli skomentowanie doMaintenance powoduje, że problem przekształca się w inny powiązany problem (np. program przestaje drukować nazwy), to prawdopodobne jest, że doMaintenance robi coś pożytecznego, od czego zależny jest jakiś inny kod. W takim przypadku prawdopodobnie nie jesteśmy w stanie stwierdzić, czy problem dotyczy doMaintenance czy gdzie indziej, więc możemy odkomentować doMaintenance i spróbować innego podejścia.
Ostrzeżenie
Nie zapomnij, które funkcje skomentowałeś, aby móc je odkomentować później!
Po wprowadzeniu wielu zmian związanych z debugowaniem bardzo łatwo jest przegapić cofnięcie jednej lub dwóch. Jeśli tak się stanie, naprawisz jeden błąd, ale wprowadzisz inne!
Posiadanie dobrego systemu kontroli wersji jest tutaj niezwykle przydatne, ponieważ możesz porównać swój kod z gałęzią główną, aby zobaczyć wszystkie wprowadzone zmiany związane z debugowaniem (i upewnić się, że zostaną one cofnięte przed zatwierdzeniem zmiany).
Wskazówka
Alternatywnym podejściem do wielokrotnego dodawania/usuwania lub odkomentowywania/komentowania instrukcji debugowania jest użycie biblioteki innej firmy, która będzie pozwalają pozostawić instrukcje debugowania w kodzie, ale skompilować je w trybie wydania za pomocą makra preprocesora. dbg to jedna z takich bibliotek zawierających tylko nagłówek, która istnieje, aby pomóc to ułatwić (poprzez DBG_MACRO_DISABLE makro preprocesora).
Biblioteki zawierające tylko nagłówek omawiamy w lekcji 7.9 -- Funkcje wbudowane i zmienne.
Taktyka debugowania nr 2: Walidacja przepływu kodu
Kolejnym problemem powszechnym w bardziej złożonych programach jest to, że program wywołuje funkcję zbyt wiele lub zbyt mało razy (w tym wcale).
W takich przypadkach pomocne może być umieszczenie instrukcji na górze funkcji, aby wydrukować nazwę funkcji. Dzięki temu po uruchomieniu programu możesz zobaczyć, które funkcje są wywoływane.
Wskazówka
Podczas drukowania informacji w celu debugowania użyj std::cerr zamiast std::cout. Jednym z powodów jest to, że std::cout może być buforowany, co oznacza, że może upłynąć trochę czasu pomiędzy poproszeniem std::cout o wypisanie tekstu a momentem, w którym faktycznie to nastąpi. Jeśli wysyłasz dane za pomocą std::cout , a następnie program ulega awarii natychmiast po tym, std::cout może jeszcze nie wykonać wyjścia. Może to wprowadzić Cię w błąd co do tego, gdzie leży problem. Z drugiej strony std::cerr jest niebuforowany, co oznacza, że wszystko, co do niego wyślesz, zostanie natychmiast wysłane. Pomaga to zapewnić, że wszystkie wyniki debugowania pojawią się tak szybko, jak to możliwe (kosztem pewnej wydajności, na którą zwykle nie zwracamy uwagi podczas debugowania).
Użycie std::cerr pomaga także wyjaśnić, że generowane informacje dotyczą przypadku błędu, a nie normalnego przypadku.
Omawiamy, kiedy używać std::cout vs std::cerr w dalszej części lekcji 9.4 — Wykrywanie i obsługa błędów.
Rozważ następujący prosty program, który nie działa poprawnie:
#include <iostream>
int getValue()
{
return 4;
}
int main()
{
std::cout << getValue << '\n';
return 0;
}Być może będzie konieczne wyłączenie opcji „Traktuj ostrzeżenia jako błędy”, aby powyższe się skompilowało.
Chociaż oczekujemy, że ten program wydrukuje wartość 4, powinien wydrukować wartość:
1
W Visual Studio (i prawdopodobnie w niektórych innych kompilatorach) może zamiast tego wydrukować następujące informacje:
00101424
Powiązana treść
Omawiamy, dlaczego niektóre kompilatory print 1 w odniesieniu do adresu (i co zrobić, jeśli kompilator wydrukuje 1 ale chcesz, aby wydrukował adres) w lekcji 20.1 -- Wskaźniki funkcji.
Dodajmy do tych funkcji kilka instrukcji debugujących:
#include <iostream>
int getValue()
{
std::cerr << "getValue() called\n";
return 4;
}
int main()
{
std::cerr << "main() called\n";
std::cout << getValue << '\n';
return 0;
}Wskazówka
Podczas dodawania tymczasowych instrukcji debugowania pomocne może być niewcinanie ich. Ułatwia to ich późniejsze odnalezienie i usunięcie.
Jeśli używasz formatu clang do sformatowania kodu, spróbuje on automatycznie dodać wcięcie w tych liniach. Możesz wyłączyć automatyczne formatowanie w ten sposób:
// clang-format off
std::cerr << "main() called\n";
// clang-format onTeraz, gdy te funkcje zostaną wykonane, wyświetli się ich nazwa, wskazując, że zostały wywołane:
main() called 1
Teraz widzimy, że funkcja getValue nie została wywołana. Musi występować jakiś problem z kodem wywołującym funkcję. Przyjrzyjmy się bliżej tej linijce:
std::cout << getValue << '\n';Och, spójrz, zapomnieliśmy o nawiasie w wywołaniu funkcji. Powinno to wyglądać następująco:
#include <iostream>
int getValue()
{
std::cerr << "getValue() called\n";
return 4;
}
int main()
{
std::cerr << "main() called\n";
std::cout << getValue() << '\n'; // added parenthesis here
return 0;
}To teraz wygeneruje poprawny wynik
main() called getValue() called 4
I możemy usunąć tymczasowe instrukcje debugowania.
Taktyka debugowania nr 3: Drukowanie wartości
W przypadku niektórych typów błędów program może obliczać lub przekazywać niewłaściwą wartość.
Możemy również wypisać wartość zmiennych (w tym parametrów) lub wyrażeń, aby upewnić się, że są one prawidłowe poprawne.
Rozważ następujący program, który ma dodawać dwie liczby, ale nie działa poprawnie:
#include <iostream>
int add(int x, int y)
{
return x + y;
}
void printResult(int z)
{
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
return x;
}
int main()
{
int x{ getUserInput() };
int y{ getUserInput() };
int z{ add(x, 5) };
printResult(z);
return 0;
}Oto wynik działania tego programu:
Enter a number: 4 Enter a number: 3 The answer is: 9
To nie jest prawda. Czy widzisz błąd? Nawet w tak krótkim programie może być trudno to dostrzec. Dodajmy trochę kodu do debugowania naszych wartości:
#include <iostream>
int add(int x, int y)
{
return x + y;
}
void printResult(int z)
{
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
return x;
}
int main()
{
int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
printResult(z);
return 0;
}Oto powyższy wynik:
Enter a number: 4 main::x = 4 Enter a number: 3 main::y = 3 main::z = 9 The answer is: 9
Zmienne x i y otrzymują prawidłowe wartości, ale zmienna z nie. Problem musi znajdować się pomiędzy tymi dwoma punktami, co sprawia, że funkcja add jest kluczową podejrzaną.
Zmodyfikujmy funkcję i dodaj:
#include <iostream>
int add(int x, int y)
{
std::cerr << "add() called (x=" << x <<", y=" << y << ")\n";
return x + y;
}
void printResult(int z)
{
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
return x;
}
int main()
{
int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
printResult(z);
return 0;
}Teraz otrzymamy wynik:
Enter a number: 4 main::x = 4 Enter a number: 3 main::y = 3 add() called (x=4, y=5) main::z = 9 The answer is: 9
Zmienna y miała wartość 3, ale w jakiś sposób nasza funkcja add otrzymała wartość 5 dla parametru y. Musieliśmy przekazać zły argument. Rzeczywiście:
int z{ add(x, 5) };To jest to. Jako argument przekazaliśmy literał 5 zamiast wartości zmiennej y . To łatwa poprawka, a następnie możemy usunąć instrukcje debugowania.
Jeszcze jeden przykład
Ten program jest bardzo podobny do poprzedniego, ale również nie działa tak, jak powinien:
#include <iostream>
int add(int x, int y)
{
return x + y;
}
void printResult(int z)
{
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
return --x;
}
int main()
{
int x{ getUserInput() };
int y{ getUserInput() };
int z { add(x, y) };
printResult(z);
return 0;
}Jeśli uruchomimy ten kod i zobaczymy:
Enter a number: 4 Enter a number: 3 The answer is: 5
Hmmm, coś jest nie tak. Ale gdzie?
Przystosujmy ten kod do debugowania:
#include <iostream>
int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
return x + y;
}
void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cerr << "getUserInput() called\n";
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
return --x;
}
int main()
{
std::cerr << "main() called\n";
int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
printResult(z);
return 0;
}Teraz uruchommy program ponownie z tymi samymi danymi wejściowymi:
main() called getUserInput() called Enter a number: 4 main::x = 3 getUserInput() called Enter a number: 3 main::y = 2 add() called (x=3, y=2) main::z = 5 printResult() called (z=5) The answer is: 5
Teraz możemy od razu zobaczyć, że coś idzie nie tak: użytkownik wprowadza wartość 4, ale główna x otrzymuje wartość 3. Coś musi pójść nie tak pomiędzy miejscem, w którym użytkownik wprowadza dane wejściowe, a momentem przypisania tej wartości do głównej zmiennej x. Upewnijmy się, że program pobiera poprawną wartość od użytkownika, dodając kod debugowania do funkcji getUserInput:
#include <iostream>
int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
return x + y;
}
void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cerr << "getUserInput() called\n";
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
std::cerr << "getUserInput::x = " << x << '\n'; // added this additional line of debugging
return --x;
}
int main()
{
std::cerr << "main() called\n";
int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
printResult(z);
return 0;
}A wynik:
main() called getUserInput() called Enter a number: 4 getUserInput::x = 4 main::x = 3 getUserInput() called Enter a number: 3 getUserInput::x = 3 main::y = 2 add() called (x=3, y=2) main::z = 5 printResult() called (z=5) The answer is: 5
Dzięki tej dodatkowej linii debugowania możemy zobaczyć, że dane wejściowe użytkownika są poprawnie odbierane do zmiennej getUserInput x. A jednak w jakiś sposób zmienna main x otrzymuje niewłaściwą wartość. Problem musi leżeć pomiędzy tymi dwoma punktami. Jedynym winowajcą pozostaje wartość zwracana przez funkcję getUserInput. Przyjrzyjmy się tej linii bliżej.
return --x;Hmmm, to dziwne. Co to za -- symbol przed x? Nie omawialiśmy tego jeszcze w tych samouczkach, więc nie martw się, jeśli nie wiesz, co to znaczy. Ale nawet nie wiedząc, co to oznacza, dzięki debugowaniu możesz być całkiem pewien, że przyczyną problemu jest ta konkretna linia — a zatem prawdopodobne jest, że przyczyną problemu jest -- symbol.
Ponieważ naprawdę chcemy, aby getUserInput zwracała tylko wartość x, usuńmy the -- i zobacz co się stanie:
#include <iostream>
int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
return x + y;
}
void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
std::cout << "The answer is: " << z << '\n';
}
int getUserInput()
{
std::cerr << "getUserInput() called\n";
std::cout << "Enter a number: ";
int x{};
std::cin >> x;
std::cerr << "getUserInput::x = " << x << '\n';
return x; // removed -- before x
}
int main()
{
std::cerr << "main() called\n";
int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
printResult(z);
return 0;
}A teraz wynik:
main() called getUserInput() called Enter a number: 4 getUserInput::x = 4 main::x = 4 getUserInput() called Enter a number: 3 getUserInput::x = 3 main::y = 3 add() called (x=4, y=3) main::z = 7 printResult() called (z=7) The answer is: 7
Program działa teraz poprawnie. Nawet nie rozumiejąc, co -- robił, byliśmy w stanie zidentyfikować konkretną linię kodu powodującą problem, a następnie go naprawić.
Dlaczego używanie instrukcji drukowania do debugowania nie jest dobrym rozwiązaniem
Dodawanie instrukcji debugowania do programów w celach diagnostycznych jest powszechną, podstawową techniką i funkcjonalną (zwłaszcza gdy z jakiegoś powodu debuger jest niedostępny), nie jest zbyt dobre w przypadku wielu powody:
- Instrukcje debugowania zaśmiecają kod.
- Instrukcje debugowania zaśmiecają dane wyjściowe programu.
- Instrukcje debugowania wymagają modyfikacji kodu w celu dodania i usunięcia, co może wprowadzić nowe błędy.
- Instrukcje debugowania muszą zostać usunięte po ich zakończeniu, co sprawia, że nie nadają się do ponownego użycia.
My może zrobić lepiej. Przeanalizujemy, jak to zrobić w przyszłych lekcjach.

