Programowanie w Visual C++ 2010

Spis treści

Błędy w kodzie cz. 2 – błędy semantyczne i debugowanie

Dzisiaj przyjrzymy się kolejnej grupie błędów oraz zajmiemy się debugowaniem (ang. odpluskwianiem). Spokojnie, żadnych prawdziwych robaków nie będziemy dotykać! Pamiętasz jeszcze ciekawostkę z drugiego samouczka? Jeśli nie, to nic nie szkodzi – przypomnijmy ją sobie:

Przez termin debug rozumiemy czynności prowadzące do wykrycia i usunięcia błędów (czyli bugów) w kodzie (tym razem semantycznych, nie składniowych).
Nazwa bug (pluskwa, robak) znalazła się w słownictwie komputerowym już w 1945 roku, kiedy to przyczyną awarii komputera MARK II stała się z pozoru niewinna ćma.

Wprowadzenie

"Hmm... Zdaje się, że program działa nie tak, jak powinien... Przecież każdy wie, że 2+2=4. Mój program uparcie twierdzi, że wynikiem jest 0. Może komputer nie potrafi liczyć? Zupełnie nieprawdopodobne...Czyżbym pomyliła się gdzieś w kodzie? No tak... Zamiast znaku + napisałam – ... Czyli wszystko było w porządku, jedynie nacisnęłam nie ten klawisz, co powinnam. Przynajmniej od teraz wszystko działa za... ekhm... Chyżo! "

Tym razem programistka miała do czynienia z błędem semantycznym, tzn. znaczeniowym (ang. semantic error). Nie powoduje on problemów przy kompilacji, jedynie przez niego program nie działa zgodnie z oczekiwaniami programisty. Zazwyczaj jest on trudny do zdiagnozowania, jednak są narzędzia, które pomagają w jego wykryciu – poznamy je w tym samouczku.

Błędy semantyczne pojawiają się, gdy źle zaimplementujemy w danym języku programowania algorytm, w związku z czym otrzymamy realizację zupełnie innego algorytmu.

Przykładowe rodzaje błędów semantycznych

Nieprawidłowa kolejność wykonywania operatorów

Sprawdź, co wyświetli się w konsoli po wykonaniu poniższej instrukcji:

int max=100, min=50;
cout << max-min/2;

Sprawdź także, co będzie wynikiem w przypadku działania innej instrukcji:

cout << (max-min)/2;

Podobnie jak w matematyce, w programowaniu działania (np. arytmetyczne, logiczne) mają ustaloną kolejność wykonywania. Żeby zastosować inne zasady niż domyślne, używamy nawiasów. Znaki działań matematycznych są częścią zbioru pewnych konstrukcji C++, zwanych operatorami. Porządek wykonywania (priorytet) operatorów jest z góry określony, możemy go zobaczyć w następującej tabeli. Co ważne, wyższy priorytet, implikuje "pierwszeństwo" danego operatora nad innym (np. / w powyższym przykładzie jest "silniejsze" od ).

Priorytet Operatory Łączność
1. :: lewa
2. ++ -- (przyrostkowe)
. -> [] ()
prawa
3. ++ -- (przedrostkowe)
~ ! sizeof new delete * &
+ - (zmiana znaku liczby)
prawa
4. (typ_zmiennej) (rzutowanie) prawa
5. .* ->* lewa
6. * / % lewa
7. + - (dodawanie,odejmowanie) lewa
8. << >> (przesunięcia bitowe) lewa
9. < > <= >= lewa
10. == != lewa
11. & (iloczyn bitowy) lewa
12. ^ (bitowy XOR) lewa
13. | (suma bitowa) lewa
14. && lewa
15. || lewa
16. ?: lewa
17. = *= /= %= += -= >>= <<= &= ^= |= prawa
18. , lewa

Naturalnie, nikt nam nie każe uczyć się powyższej tabelki na pamięć. ;-) Jak widzimy, najczęściej używane operatory mają "zdroworozsądkową" kolejność wykonywania. Dysponując wiedzą na temat priorytetów, powinniśmy mieć mniej problemów m.in. z formułowaniem warunków logicznych w instrukcjach if czy pętlach.

Na wszelki wypadek, jeżeli nie jesteśmy pewni kolejności wykonywania operacji, zawsze możemy korzystać z nawiasów okrągłych. Już sama wątpliwość jest dobrym powodem, aby z nawiasów korzystać. Ułatwi to późniejsze sprawdzanie poprawności semantycznej kodu.

Znak(i) równości: przypisanie czy porównanie?

Czy w konsoli ujrzymy (zgodny, rzecz jasna, z prawdą) napis "Uwielbiam AiPP!"?
int a=0;
if (a=0) cout << "Uwielbiam AiPP!";

Dlaczego tak się dzieje?

Zwracajmy szczególną uwagę na liczbę znaków równości w warunkach. Pamiętajmy, że pojedynczy znak równości = oznacza przypisanie, z kolei podwójny == służy do sprawdzania równości zmiennych po obu stronach tego operatora.

Formalnie rzecz biorąc, warunek podany w instrukcji if nie musi być typu bool. C++ sprawdza wtedy, czy wartość jest różna od 0 (i wtedy interpretuje ją jako true), czy też jest równa 0 (false).

Literówki

Pół biedy, jeżeli popełnimy literówkę, którą "wyłapie" kompilator (taki przypadek zaliczamy do błędów syntaktycznych). Sprawa ma się nieco gorzej, gdy np. pomylimy się w użyciu dwóch zmiennych o podobnych nazwach (np. miast skorzystać z I532 napisaliśmy l532). Nie martwmy się jednak tym, ponieważ każdy błąd prędzej czy później da się zlokalizować: jak nie ręcznie, to za pomocą wspomnianego już debuggera. Lepiej jednak "dmuchać na zimne". Po co tracić czas?

Starajmy się tak nazywać zmienne, aby same "opisywały swoje przeznaczenie" – np. sumę ciągu liczb całkowitych dobrze jest przechowywać w zmiennej o nazwie suma (a nie np. dupa).

Przyjęło się również, że liczniki pętli są oznaczane literami: i, j, k.

W miarę możliwości unikajmy wprowadzania podobnych do siebie nazw dla zmiennych.

Niewłaściwe wydzielanie bloków

Jak pamiętasz, do wyodrębniania bloków kodu służą klamry { } (nawiasy wąsiaste ;-) ). Stosuje się je m.in. w pętlach czy instrukcjach warunkowych. Przyjrzyjmy się poniższemu listingowi:

double x;
cin >> x;
if(x<=2)
   if(x<1)
      cout << "Liczba jest mniejsza od 1" << endl;
else
   cout << "Liczba jest wieksza od 2" << endl;

Co się wyświetli w konsoli, gdy po uruchomieniu powyższego fragmentu kodu podasz liczbę:

  • 0.5,
  • 1.5?
Jak przetestować trzeci przypadek?

Zastanów się, w jaki sposób działają konstrukcje if-else. Rozwiązać ten problem, wydzielając odpowiednio fragmenty kodu za pomocą nawiasów klamrowych { }

.

Przepisz "ręcznie" powyższy kod (nie kopiując go) i sprawdź, w jaki sposób środowisko Visual Studio samodzielnie formatuje kod.
Prawidłowe formatowanie kodu (np. odpowiednie stosowanie wcięć) to podstawa. Wbrew pozorom, estetyka kodu jest bardzo ważna. To samo tyczy się pisania komentarzy. Docenisz to, programując na punktowanych laboratoriach lub przeglądając po jakimś czasie napisane przez siebie programy. Z drugiej strony, pamiętajmy, że dobre formatowanie kodu nie zastąpi konieczności poprawnego napisania danego fragmentu programu. Wspomaga ono po prostu jego czytanie i analizowanie.

Nieskończone pętle

Rozważmy następujący przykład.

Ile razy wykona się poniższa pętla?

	int i=0;
    while(1000)
	{
		cout << "Pomidor" << endl;
        i++;
	}

Każda pętla powinna być użyta tak, aby miała możliwość zakończenia swojego działania. Należy zatem pamiętać o podaniu odpowiedniego warunku logicznego, którego niespełnienie powoduje zakończenie działania pętli.

Jeżeli chcesz przerwać działanie aplikacji konsolowej, w trakcie jej pracy (gdy zauważysz, że dalsze wykonywanie nie prowadzi do niczego dobrego) wciśnij kombinację klawiszy CTRL+C.

Debugowanie

Wprowadzenie

Czas najwyższy nauczyć się "odpluskwiania" kodu! Nie jest wcale trudne. Mało tego, często przynosi wiele korzyści, pozwalając na szybsze usunięcie błędów semantycznych.

Aby uruchomić program w trybie debugowania, wybieramy z menu opcję Debug | Start Debugging, klikamy na skrót na pasku narzędziowym , bądź wciskamy klawisz F5.

Uruchamiając program w trybie debugowania, po poprawnym wykonaniu wszystkich operacji ekran konsoli od razu się samoczynnie zamyka (chyba że wstawiliśmy instrukcję system("pause")).

Breakpointy

Jedną z najczęściej wykorzystywanych funkcji trybu debugowania jest wstawianie w kodzie tzw. punktu stopu tudzież punktu kontrolnego, zwanego potocznie breakpointem (przerywnikiem). W miejscu, w którym umieścimy breakpointa, wykonywanie programu się zatrzymuje. Mamy wtedy możliwość podejrzenia kilku ciekawych informacji w panelu Locals, który powinien pojawić się na dole ekranu.

Utwórz nowy projekt o nazwie bledy2, dodaj nowy plik źródłowy debuggowanie.cpp i wklej weń poniższy kod:
#include <iostream>
using namespace std;

int main()
{
	int x;
	cout << "Uczymy sie korzystac z breakpointow!" << endl;
	x=10;
	cout << x << endl;
	return 0;
}

Ustawimy teraz 3 breakpointy. Aby to zrobić, ustawiamy kursor w linii, w której chcemy przerwać działanie programu, a następnie wykonujemy jedną z czterech poniższych czynności:

  • wybieramy z menu Debug | Toggle Breakpoint,
  • klikamy prawym przyciskiem myszy i wybieramy Breakpoint | Insert Breakpoint ,
  • klikamy lewym przyciskiem myszy na niebieskim marginesie z lewej strony, bądź
  • wciskajmy klawisz F9.

W wyniku powyższych działań na marginesie po lewej stronie kodu powinno ukazać się czerwone kółeczko .

Wstaw breakpointy w wierszach #7, #8 i #9.

Panele Locals oraz Breakpoints

Teraz pora skompilować i uruchomić projekt w trybie debugowania. Prawdopodobnie zaskoczy Cię to, że konsola nagle się gdzieś chowa i na pierwszym planie ukazuje się Visual Studio. Bardzo dobrze, że tak się stało. Zapewne zauważysz też, iż na lewym marginesie pierwsza czerwona kropka została opatrzona żółtą strzałką , która oznacza, że działanie programu zostało przerwane przy tej instrukcji. Przyjrzyjmy się panelom Locals oraz Breakpoints na dole ekranu: Panele Locals i Breakpoints

W panelu Locals znajdują się informacje nt. zmiennych lokalnych. Składa się on z trzech kolumn:

  • Name – nazwa zmiennej,
  • Value – wartość zmiennej,
  • Type – typ zmiennej.

Z kolei panel Breakpoints zawiera informacje o wszystkich wstawionych breakpointach w obrębie programu. Oto opis występujących w nim elementów:

  • Name – nazwa pliku i numer linii, w której znajduje się breakpoint; pogrubiona nazwa oznacza, że ten przerywnik jest aktualnie obsługiwany,
  • Labels – etykieta/nazwa breakpointa (można ją ustawić, klikając prawym przyciskiem myszy na breakpoincie i wybierając Edit labels),
  • Condition – warunek, dla którego zachodzi zatrzymanie się na breakpoincie,
  • Hit Count – liczba wywołań; bardziej zaawansowane informacje na ten temat można znaleźć tu (informacje w jęz. angielskim).

W kolumnie Name widzimy również pole do zaznaczania (checkbox) oraz ikonkę symbolizującą breakpointa. Odznaczając "ptaszka" sprawimy, że breakpoint przestanie być aktywny. To samo możemy zrobić, klikając prawym przyciskiem myszy na przerywniku i wybierając Disable Breakpoint lub wciskając CTRL+F9, gdy kursor jest ustawiony w tej samej linii co breakpoint. Od tej pory Visual Studio nie będzie się nim zajmować.

Aby usunąć breakpointa, wystarczy kliknąć na niego lewym przyciskiem myszy lub kliknąć przyciskiem prawym i wybrać Delete Breakpoint

Przechodzenie od breakpointa do breakpointa...

Wróćmy do analizowanego programu. Widzimy, że zmienna x została zainicjowana jakąś wartością, która tak naprawdę ma znaczenie symboliczne (śmieci). Jeżeli tuż po deklaracji tej zmiennej chcielibyśmy wyświetlić jej wartość, program zgłosi błąd wykonania.

Pora przejść do następnego breakpointa. Jak to zrobić? Odpowiedź jest prosta... Być może zauważyłeś/aś, że na pasku narzędziowym prawdopodobnie pojawiły się nowe ikonki: Pasek narzędziowy w trybie debuggowania

  • Zielona strzałka – przejście do kolejnego breakpointa,
  • Niebieski kwadracik – zatrzymanie debuggowania,
  • Niebieski kwadracik ze strzałką – zrestartowanie debuggowania.

Wciśnijmy zatem zieloną strzałeczkę (lub wciśnijmy klawisz F5). Aktualnie żółty wskaźnik znajduje się na drugim breakpoincie. Rzućmy okiem na zawartość panelu Locals. Zdawałoby się, że tym razem x powinien mieć wartość 10. Okazuje się jednak, że ta instrukcja nie została jeszcze wykonana. Oznacza to, że breakpoint zatrzymuje działanie programu tuż przed zrealizowaniem polecenia z danej linii.

Przejdźmy do ostatniego breakpointa. O, widać jakieś zmiany! Nie dość, że wartością zmiennej x jest liczba 10, to w panelu Locals została nawet wyróżniona kolorem czerwonym! Dzieje się tak, gdy zawartość danej zmiennej została w danej chwili podmieniona na inną.

W tej chwili możemy kliknąć niebieski kwadracik, aby zakończyć działanie debuggera albo zieloną strzałkę (co w tej sytuacji również skończy działanie debuggera). Pierwszy program został odpluskwiony – chociaż żadnego błędu nie usunęliśmy. Zajmiemy się tym już niedługo.

...lub krok po kroku

Teraz nauczymy się obserwowania działania programu krok po kroku (ang. step-by-step). Wystarczy, że ustawimy jednego breakpointa, od którego chcemy śledzić wykonywanie kolejnych instrukcji aplikacji. W naszym kodzie usuńmy po prostu breakpointy z linii #8 i #9.

W ten sposób można debugować również nie stawiając ani jednego breakpointa. Wystarczy, że ustawimy kursor w linii, od której chcemy śledzić działanie programu i z menu podręcznego wybieramy Run To Cursor lub wciskamy CTRL+F10.

Ponownie włączmy program w trybie debugowania. Żółta strzałka powinna być ustawiona na pozostawionym breakpoincie. Teraz, aby przejść do kolejnej instrukcji, wystarczy kliknąć na odpowiednią ikonkę na pasku narzędziowym lub po prostu wcisnąć klawisz F10. W odpowiednim momencie powinniśmy zobaczyć zmiany w panelu Locals.

Jeżeli tryb debugowania napotka np. konstrukcję if-else to strzałka przejdzie do tych poleceń, dla których został spełniony warunek w instrukcji warunkowej.
Zakończmy działanie w tym trybie najpóźniej na ostatniej napotkanej instrukcji w funkcji main(). W przeciwnym przypadku może otworzyć się kod jednej z bibliotek Visual Studio.

Najważniejsze skróty klawiszowe

Skrót Działanie
F9 Wstaw breakpoint
CTRL+F9 Dezaktywuj/aktywuj breakpointa
CTRL+SHIFT+F9 Usuń wszystkie breakpointy
F5 Uruchom w trybie debuggowania/kontynuuj
SHIFT+F5 Zatrzymaj debuggowanie
F10 Wykonywanie instrukcji krok po kroku
CTRL+F10 Rozpoczęcie debuggowania od miejsca, w którym znajduje się kursor
ALT+4 Pokaż panel Locals
ALT+9 Pokaż panel Breakpoints

Zastosuj debugowanie do znanego już przykładu:

   double x;
   cin >> x;
   if (x<=2)
      if (x<1)
         cout << "Liczba jest mniejsza od 1" << endl;
   else
      cout << "Liczba jest wieksza od 2" << endl;

Choć przykład jest stosunkowo prosty, tzn. do poprawy nie jest potrzebny tryb debugowania, jednakże dzięki niemu nabierzesz wprawy w korzystaniu z odpluskwiania. Spróbuj prześledzić jego działanie dla różnych danych wejściowych.

Przydatne sztuczki

Wyświetlanie "komunikatów kontrolnych"

W niektórych przypadkach możemy nie mieć pewności co do tego, czy jakiś fragment kodu (np. funkcja) w ogóle się wykonuje. W miejscu, co do którego mamy wątpliwości, możemy wstawić np. instrukcję:

cout << "Halo, jestem tutaj!" << endl;

Jeżeli taki napis nigdy nie pojawi się w konsoli (a powinien), oznacza to, że czeka nas (być może) żmudne poszukiwanie przyczyny tego stanu rzeczy.

Wyświetlanie napisów przydaje się również wtedy, gdy chcemy na bieżąco sprawdzać, czy program prawidłowo (tzn. tak, jak się tego spodziewamy) wyznacza wartości wyrażeń, np.

int suma=0;
for(int i=1; i<=10; i++)
{
    suma+=i;
    cout << suma << endl; // czy rzeczywiście o to nam chodzi?
}
W niektórych środowiskach programistycznych debugger wcale nie jest dostępny. Mało tego, niektórzy programiści (jak MG) wcale tego narzędzia nie lubią. Wówczas wypisywanie komunikatów kontrolnych jest właściwie jedynym dobrym sposobem znajdywania błędów semantycznych. Z drugiej strony, debugger jest taki wygodny...

Kartka papieru i ołówek

Mimo że żyjemy w czasach związanych z cyfrowymi technologiami, rozpisywanie działania programu na kartce wcale nie jest niedorzecznym pomysłem. Czasami podczas "kartkowej analizy" niektórych fragmentów kodu, możemy łatwiej wpaść na rozwiązanie problemu źle działającego programu albo wręcz na nowe ciekawe pomysły.

Czasem pomaga już samo narysowanie schematu blokowego implementowanego algorytmu. Autorka tego samouczka bardzo często korzysta z kartki papieru i ołówka, nie tylko podczas projektowania algorytmów.

Co więcej, przypomnij sobie, że na pierwszych zajęciach ćwiczeniowych naszym celem było właśnie śledzenie wykonania algorytmu na kartce. Zatem doskonale potrafisz już to robić!

Podsumowanie

Dzięki niniejszemu samouczkowi zdobyłeś/aś wiedzę na temat błędów semantycznych i sposobach ich usuwania. Debugowanie jest narzędziem, które jest pomocne w wielu sytuacjach problemowych. Pora jednak przekuć tę wiedzę na umiejętności i przez ćwiczenia nabrać wprawy.

CC By 3.0
Copyright © 2011-2016 by Katarzyna Fokow [Last update: 2017-02-01 17:17:04]
This work is licensed under a Creative Commons Attribution 3.0 Unported License