9.3 — Typowe błędy semantyczne w C++

W lekcji 3.1 — Błędy składniowe i semantyczne, omówiliśmy syntax errors, które występują podczas pisania kodu, który nie jest poprawny zgodnie z gramatyką języka C++. Kompilator powiadomi Cię o takich błędach, więc są one łatwe do wyłapania i zazwyczaj łatwe do naprawienia.

Omówiliśmy także semantic errors, które pojawiają się, gdy piszesz kod, który nie robi tego, co zamierzałeś. Kompilator generalnie nie wyłapie błędów semantycznych (chociaż w niektórych przypadkach inteligentne kompilatory mogą być w stanie wygenerować ostrzeżenie).

Błędy semantyczne mogą powodować większość takich samych symptomów jak undefined behavior, takich jak spowodowanie, że program będzie generował błędne wyniki, błędne zachowanie, uszkodzenie danych programu, awarię programu - lub mogą nie mieć żadnego wpływu.

Podczas pisania programów prawie nieuniknione jest, że zrobisz to błędy semantyczne. Prawdopodobnie zauważysz niektóre z nich po prostu korzystając z programu: na przykład, jeśli piszesz grę w labirynty, a twoja postać potrafi przechodzić przez ściany. Testowanie programu (9.1 -- Wprowadzenie do testowania kodu) może również pomóc w wykryciu błędów semantycznych.

Ale jest jeszcze jedna rzecz, która może pomóc — wiedza o tym, jakie typy błędów semantycznych występują najczęściej, dzięki czemu możesz poświęcić trochę więcej czasu na upewnienie się, że w takich przypadkach wszystko działa prawidłowo.

W tej lekcji omówimy kilka najpopularniejszych typów błędów semantycznych występujących w C++ (z których większość musi wystarczyć z kontrolą przepływu).

Warunkowe błędy logiczne

Jednym z najczęstszych rodzajów błędów semantycznych są warunkowe błędy logiczne. błąd logiki warunkowej występuje, gdy programista błędnie koduje logikę instrukcji warunkowej lub warunku pętli. Oto prosty przykład:

#include <iostream>

int main()
{
    std::cout << "Enter an integer: ";
    int x{};
    std::cin >> x;

    if (x >= 5) // oops, we used operator>= instead of operator>
        std::cout << x << " is greater than 5\n";

    return 0;
}

Oto wykonanie programu, który wykazuje błąd logiki warunkowej:

Enter an integer: 5
5 is greater than 5

Gdy użytkownik wprowadzi 5 wyrażenie warunkowe x >= 5 wyniesie true, w związku z czym wykonywana jest powiązana instrukcja.

Oto kolejny przykład z wykorzystaniem pętli for:

#include <iostream>

int main()
{
    std::cout << "Enter an integer: ";
    int x{};
    std::cin >> x;

    // oops, we used operator> instead of operator<
    for (int count{ 1 }; count > x; ++count)
    {
        std::cout << count << ' ';
    }

    std::cout << '\n';

    return 0;
}

Ten program ma wypisać wszystkie liczby pomiędzy 1 i numer wprowadzony przez użytkownika. Ale oto, co faktycznie robi:

Enter an integer: 5

Nie drukuje niczego. Dzieje się tak, ponieważ przy wejściu do pętli for count > x Jest false pętla w ogóle nie wykonuje iteracji.

Nieskończone pętle

W lekcji 8.8 — Wprowadzenie do pętli i instrukcji while omówiliśmy nieskończone pętle i pokazaliśmy ten przykład:

#include <iostream>
 
int main()
{
    int count{ 1 };
    while (count <= 10) // this condition will never be false
    {
        std::cout << count << ' '; // so this line will repeatedly execute
    }
 
    std::cout << '\n'; // this line will never execute

    return 0; // this line will never execute
}

W tym przypadku zapomnieliśmy o inkrementacji count, więc warunek pętli nigdy nie będzie fałszywe i pętla będzie drukowana:

1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

… dopóki użytkownik nie zamknie programu.

Oto kolejny przykład, który nauczyciele uwielbiają zadawać w formie pytań quizowych. Co jest nie tak z następującym kodem?

#include <iostream>

int main()
{
    for (unsigned int count{ 5 }; count >= 0; --count)
    {
        if (count == 0)
            std::cout << "blastoff! ";
        else
          std::cout << count << ' ';
    }

    std::cout << '\n';

    return 0;
}

Ten program powinien wydrukować 5 4 3 2 1 blastoff! i to robi, ale na tym się nie kończy. W rzeczywistości wypisuje:

5 4 3 2 1 blastoff! 4294967295 4294967294 4294967293 4294967292 4294967291

, a następnie po prostu zmniejsza wartość. Program nigdy się nie zakończy, ponieważ count >= 0 nie może być false gdy count jest liczbą całkowitą bez znaku.

Błędy o jeden

An błąd o jeden to błąd pojawiający się, gdy pętla wykona się o jeden raz za dużo, raz za mało. Oto przykład, który omówiliśmy w lekcji 8.10 -- Dla instrukcji:

#include <iostream>

int main()
{
    for (int count{ 1 }; count < 5; ++count)
    {
        std::cout << count << ' ';
    }

    std::cout << '\n';

    return 0;
}

Programista zaplanował wydruk tego kodu 1 2 3 4 5. Jednakże użyto błędnego operatora relacyjnego (< zamiast <=), więc pętla wykonuje się o jeden mniej razy niż zamierzono, wypisując 1 2 3 4.

Nieprawidłowe pierwszeństwo operatora

Z lekcji 6.8 -- Operatory logiczne następujący program popełnia błąd pierwszeństwa operatora:

#include <iostream>

int main()
{
    int x{ 5 };
    int y{ 7 };

    if (!x > y) // oops: operator precedence issue
        std::cout << x << " is not greater than " << y << '\n';
    else
        std::cout << x << " is greater than " << y << '\n';

    return 0;
}

Ponieważ logical NOT ma wyższy priorytet niż operator>, warunek ocenia tak, jakby był napisany (!x) > y, co nie jest zamierzone przez programistę.

W rezultacie ten program wypisuje:

5 is greater than 7

Może się to również zdarzyć podczas mieszania logicznego OR i logicznego AND w tym samym wyrażeniu (logiczne ORAZ ma pierwszeństwo przed logicznym OR). Używaj jawnych nawiasów, aby uniknąć tego rodzaju błędów.

Problemy z precyzją w typach zmiennoprzecinkowych

Następująca zmienna zmiennoprzecinkowa nie ma wystarczającej precyzji, aby zapisać całą liczbę:

#include <iostream>

int main()
{
    float f{ 0.123456789f };
    std::cout << f << '\n';

    return 0;
}

Z powodu tego braku precyzji liczba jest lekko zaokrąglana:

0.123457

W lekcji 6.7 -- Operatory relacyjne i zmiennoprzecinkowe porównania, rozmawialiśmy o tym, jak użycie operator== i operator!= może być problematyczne w przypadku liczb zmiennoprzecinkowych z powodu małych błędów zaokrągleń (również co z tym zrobić). Oto przykład:

#include <iostream>

int main()
{
    double d{ 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 }; // should sum to 1.0

    if (d == 1.0)
        std::cout << "equal\n";
    else
        std::cout << "not equal\n";

    return 0;
}

Ten program wypisuje:

not equal

Im więcej arytmetyki wykonasz na liczbie zmiennoprzecinkowej, tym więcej będzie kumulować się małych błędów zaokrągleń.

Dzielenie na liczbach całkowitych

W poniższym przykładzie mamy zamiar wykonać dzielenie zmiennoprzecinkowe, ale ponieważ oba operandy są liczbami całkowitymi, zamiast tego wykonamy dzielenie całkowite:

#include <iostream>

int main()
{
    int x{ 5 };
    int y{ 3 };

    std::cout << x << " divided by " << y << " is: " << x / y << '\n'; // integer division

    return 0;
}

Wypisuje:

5 divided by 3 is: 1

W lekcji 6.2 — Operatory arytmetyczne pokazaliśmy, że możemy użyć static_cast do przekształcenia jednego z całkowitych operandów na wartość zmiennoprzecinkową w celu wykonania dzielenia zmiennoprzecinkowego.

Przypadkowe instrukcje zerowe

W lekcji 8.3 -- Wspólna instrukcja if problemy, omówiliśmy null statements, które są instrukcjami, które nic nie robią.

W poniższym programie chcemy wysadzić świat w powietrze tylko wtedy, gdy mamy pozwolenie użytkownika:

#include <iostream>

void blowUpWorld()
{
    std::cout << "Kaboom!\n";
} 

int main()
{
    std::cout << "Should we blow up the world again? (y/n): ";
    char c{};
    std::cin >> c;

    if (c == 'y');     // accidental null statement here
        blowUpWorld(); // so this will always execute since it's not part of the if-statement
 
    return 0;
}

Jednakże ze względu na przypadkowo null statement, wywołanie funkcji blowUpWorld() jest zawsze wykonywane, więc wysadzamy to w powietrze niezależnie od:

Should we blow up the world again? (y/n): n
Kaboom!

Nieużywanie instrukcji złożonej, gdy jest ona wymagana

Inny wariant powyższego programu, który zawsze wysadza w powietrze świat:

#include <iostream>

void blowUpWorld()
{
    std::cout << "Kaboom!\n";
} 

int main()
{
    std::cout << "Should we blow up the world again? (y/n): ";
    char c{};
    std::cin >> c;

    if (c == 'y')
        std::cout << "Okay, here we go...\n";
        blowUpWorld(); // Will always execute.  Should be inside compound statement.
 
    return 0;
}

Ten program wypisuje:

Should we blow up the world again? (y/n): n
Kaboom!

A dangling else (opisane w lekcji 8.3 -- Wspólna instrukcja if problemy) również należy do tej kategorii.

Używanie przypisania zamiast równości w warunku

Ponieważ operator przypisania (=) i operator równości (==) są podobne, możemy chcieć użyć równości, ale przypadkowo zamiast tego użyliśmy przypisania:

#include <iostream>

void blowUpWorld()
{
    std::cout << "Kaboom!\n";
} 

int main()
{
    std::cout << "Should we blow up the world again? (y/n): ";
    char c{};
    std::cin >> c;

    if (c = 'y') // uses assignment operator instead of equality operator
        blowUpWorld();
 
    return 0;
}

Ten program wypisuje:

Should we blow up the world again? (y/n): n
Kaboom!

Operator przypisania zwraca lewą stronę operand. c = 'y' wykonuje się jako pierwszy, który przypisuje y Do c i zwraca c. Następnie if (c) jest oceniany. Ponieważ wartość c jest teraz różna od zera, jest ona niejawnie konwertowana na bool wartości true i wykonywana jest instrukcja powiązana z instrukcją if.

Ponieważ przypisanie wewnątrz warunku prawie nigdy nie jest zamierzone, nowoczesne kompilatory często ostrzegają, gdy się z tym spotkają. Jeśli jednak nie masz w zwyczaju rozwiązywać wszystkich swoich ostrzeżeń, ostrzeżenia takie mogą łatwo zostać utracone.

Zapominanie o użyciu operatora wywołania funkcji podczas wywoływania funkcji

#include <iostream>

int getValue()
{
    return 5;
}

int main()
{
    std::cout << getValue << '\n';

    return 0;
}

Chociaż możesz oczekiwać, że ten program wydrukuje 5, najprawdopodobniej wydrukuje 1 (w niektórych kompilatorach wydrukuje adres pamięci w hex).

Zamiast używać getValue() (które wywołałoby funkcję i wygenerowało int wartość zwracaną), użyliśmy getValue bez operatora wywołania funkcji. W wielu przypadkach spowoduje to, że wartość zostanie przekonwertowana na bool wartości true).

W powyższym przykładzie to właśnie bool wartości true jest wyprowadzana, a na wydruku 1.

Dla zaawansowanych czytelników

Użycie nazwy funkcji bez jej wywołania zazwyczaj daje wskaźnik funkcji przechowujący adres funkcji. Taki wskaźnik funkcji zostanie niejawnie przekonwertowany na wartość bool . A ponieważ ten wskaźnik nigdy nie powinien mieć adresu 0, ta bool wartość zawsze będzie true.

Wskaźniki funkcji omówimy w lekcji 20.1 -- Wskaźniki funkcji.

Co jeszcze?

Powyższe stanowi dobrą próbkę najczęstszego rodzaju błędów semantycznych, które popełniają nowi programiści C++, ale jest ich znacznie więcej. Czytelnicy, jeśli macie jakieś dodatkowe, które uważacie za typowe pułapki, zostawcie notatkę w komentarzach.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
107 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze