9.4 — Wykrywanie i obsługa błędów

W lekcji 9.3 — Typowe błędy semantyczne w C++, omówiliśmy wiele typów typowych błędów semantycznych C++, na które napotykają nowi programiści C++ w tym języku. Jeśli błąd wynika z niewłaściwego użycia funkcji językowych lub błędu logicznego, można go po prostu poprawić.

Ale większość błędów w programie nie powstaje w wyniku niezamierzonego niewłaściwego użycia funkcji językowych — raczej większość błędów powstaje w wyniku błędnych założeń przyjętych przez programistę i/lub braku prawidłowego wykrywania/obsługi błędów.

Na przykład w funkcji zaprojektowanej do sprawdzania ocen ucznia możesz mieć zakłada się:

  • Szukany uczeń będzie istniał.
  • Wszystkie imiona uczniów będą unikalne.
  • W zajęciach stosowana jest ocena literowa (zamiast oceny zaliczony/niezaliczony).

Co się stanie, jeśli którekolwiek z tych założeń nie będzie prawdziwe? Jeśli programista nie przewidział takich przypadków, program prawdopodobnie ulegnie awarii lub ulegnie awarii, gdy takie przypadki wystąpią (zwykle w pewnym momencie w przyszłości, długo po napisaniu funkcji).

Istnieją trzy kluczowe miejsca, w których zazwyczaj pojawiają się błędy założeń:

  • Gdy funkcja powraca, programista mógł założyć, że wywołana funkcja odniosła sukces, choć tak nie było.
  • Gdy program otrzymuje dane wejściowe (od użytkownika lub plik), programista mógł założyć, że dane wejściowe były w poprawnym formacie i semantycznie poprawne, choć tak nie było.
  • Gdy funkcja została wywołana, programista mógł założyć, że argumenty będą semantycznie poprawne, gdy tak nie było.

Wielu nowych programistów pisze kod, a następnie testuje tylko szczęśliwą ścieżkę: tylko przypadki, w których nie ma błędów. Ale powinieneś także planować i testować swoje smutne ścieżki, na których wszystko może pójść nie tak i będzie. W lekcji 3.10 — Znajdowanie problemów, zanim staną się problemami, zdefiniowaliśmy programowanie defensywne jako praktykę polegającą na próbie przewidzenia wszystkich sposobów niewłaściwego użycia oprogramowania przez użytkowników końcowych lub programistów (samego programistę lub inne osoby). Kiedy już zauważysz (lub odkryjesz) niewłaściwe użycie, następną rzeczą, którą musisz zrobić, to się z nim uporać.

W tej lekcji omówimy strategie obsługi błędów (co zrobić, gdy coś pójdzie nie tak) wewnątrz funkcji. Na kolejnych lekcjach omówimy sprawdzanie danych wprowadzanych przez użytkownika, a następnie przedstawimy przydatne narzędzie pomagające dokumentować i weryfikować założenia.

Obsługa błędów w funkcjach

Funkcje mogą nie działać z wielu powodów — osoba wywołująca mogła przekazać argument z nieprawidłową wartością lub coś może nie działać w treści funkcji. Na przykład funkcja otwierająca plik do odczytu może nie działać, jeśli pliku nie można znaleźć.

W takiej sytuacji masz do dyspozycji sporo opcji. Nie ma najlepszego sposobu poradzenia sobie z błędem — to naprawdę zależy od charakteru problemu i tego, czy da się go naprawić.

Istnieją 4 ogólne strategie, których można użyć:

  • Obsługa błędu w funkcji
  • Przekaż błąd z powrotem do wywołującego, aby się nim zajął
  • Zatrzymaj program
  • Wyrzuć błąd wyjątek

Obsługa błędu w funkcji

Jeśli to możliwe, najlepszą strategią jest naprawienie błędu w tej samej funkcji, w której wystąpił błąd, aby można było powstrzymać błąd i go poprawić bez wpływu na kod poza funkcją. Istnieją dwie możliwości: ponów próbę aż do skutku lub anuluj wykonywaną operację.

Jeśli błąd wystąpił z powodu czegoś, na co program nie ma wpływu, program może ponawiać próby, aż do osiągnięcia sukcesu. Na przykład, jeśli program wymaga połączenia z Internetem, a użytkownik utracił połączenie, program może wyświetlić ostrzeżenie, a następnie użyć pętli do okresowego ponownego sprawdzania łączności z Internetem. Alternatywnie, jeśli użytkownik wprowadził nieprawidłowe dane, program może poprosić użytkownika o ponowną próbę i wykonywać pętlę do momentu, aż użytkownikowi uda się wprowadzić prawidłowe dane. W następnej lekcji pokażemy przykłady obsługi nieprawidłowych danych wejściowych i wykorzystania pętli do ponowienia próby (9.5 — std::cin i obsługa nieprawidłowych danych wejściowych).

Alternatywną strategią jest po prostu zignorowanie błędu i/lub anulowanie operacji. Na przykład:

// Silent failure if y=0
void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
}

W powyższym przykładzie, jeśli użytkownik przekazał nieprawidłową wartość for y, po prostu ignorujemy żądanie wydrukowania wyniku operacji dzielenia. Głównym wyzwaniem związanym z wykonaniem tej czynności jest to, że osoba dzwoniąca lub użytkownik nie ma możliwości zidentyfikowania, że ​​coś poszło nie tak. W takim przypadku pomocne może być wydrukowanie komunikatu o błędzie:

void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
    else
        std::cout << "Error: Could not divide by zero\n";
}

Jeśli jednak funkcja wywołująca oczekuje, że wywołana funkcja zwróci wartość lub jakiś przydatny efekt uboczny, wówczas po prostu zignorowanie błędu może nie wchodzić w grę.

Przekazywanie błędów z powrotem do osoby wywołującej

W wielu przypadkach błędu nie można rozsądnie obsłużyć w funkcji, która go wykrywa. Rozważmy na przykład następującą funkcję:

int doIntDivision(int x, int y)
{
    return x / y;
}

Jeśli y Jest 0, co powinniśmy zrobić? Nie możemy po prostu pominąć logiki programu, ponieważ funkcja musi zwrócić jakąś wartość. Nie powinniśmy prosić użytkownika o wprowadzenie nowej wartości y ponieważ jest to funkcja obliczeniowa i wprowadzenie do niej procedur wejściowych może, ale nie musi, być odpowiednie dla programu wywołującego tę funkcję.

W takich przypadkach najlepszą opcją może być przekazanie błędu osobie wywołującej w nadziei, że osoba dzwoniąca będzie w stanie sobie z tym poradzić.

Jak możemy to zrobić?

Jeśli funkcja ma void typ zwracany, można go zmienić, aby zwracał a bool co oznacza sukces lub porażkę. Na przykład zamiast:

void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
    else
        std::cout << "Error: Could not divide by zero\n";
}

Możemy to zrobić:

bool printIntDivision(int x, int y)
{
    if (y == 0)
    {
        std::cout << "Error: could not divide by zero\n";
        return false;
    }
    
    std::cout << x / y;

    return true;
}

W ten sposób osoba wywołująca może sprawdzić wartość zwracaną, aby sprawdzić, czy funkcja z jakiegoś powodu nie powiodła się.

Jeśli funkcja zwraca wartość normalną, sytuacja jest nieco bardziej skomplikowana. W niektórych przypadkach nie jest używany pełny zakres zwracanych wartości. W takich przypadkach możemy użyć wartości zwracanej, która w przeciwnym razie nie mogłaby wystąpić normalnie, aby wskazać błąd. Rozważmy na przykład następującą funkcję:

// The reciprocal of x is 1/x
double reciprocal(double x)
{
    return 1.0 / x;
}

Odwrotność jakiejś liczby x definiuje się jako 1/x, a liczba pomnożona przez jej odwrotność wynosi 1.

Co się jednak stanie, jeśli użytkownik wywoła tę funkcję jako reciprocal(0.0)? Otrzymujemy divide by zero błąd i awarię programu, więc oczywiste jest, że powinniśmy się przed tym przypadkiem zabezpieczyć. Ale ta funkcja musi zwracać podwójną wartość, więc jaką wartość powinniśmy zwrócić? Okazuje się, że ta funkcja nigdy nie wygeneruje 0.0 jako uzasadniony wynik, abyśmy mogli wrócić 0.0 aby wskazać przypadek błędu.

// The reciprocal of x is 1/x, returns 0.0 if x=0
constexpr double error_no_reciprocal { 0.0 }; // could also be placed in namespace

double reciprocal(double x)
{
    if (x == 0.0)
       return error_no_reciprocal;

    return 1.0 / x;
}

A wartość wartownicza to wartość, która ma jakieś specjalne znaczenie w kontekście funkcji lub algorytmu. w naszym reciprocal() funkcja powyżej, 0.0 jest wartością sygnalizacyjną wskazującą, że funkcja nie powiodła się. Osoba wywołująca może przetestować wartość zwracaną, aby sprawdzić, czy odpowiada ona wartości wskaźnikowej — jeśli tak, to osoba wywołująca wie, że funkcja się nie powiodła. Chociaż funkcje często zwracają wartość wskaźnikową bezpośrednio, zwrócenie stałej opisującej wartość wskaźnikową może zwiększyć czytelność.

Jeśli jednak funkcja może wygenerować pełny zakres wartości zwracanych, wówczas użycie wartości wskaźnikowej do wskazania błędu jest problematyczne (ponieważ osoba wywołująca nie byłaby w stanie stwierdzić, czy zwracana wartość jest wartością prawidłową, czy wartością błędu).

Powiązana treść

W takim przypadku zwrócenie std::optional (lub std::expected) byłoby lepszy wybór. Omówimy std::optional w lekcji 12.15 -- std::opcjonalne.

Błędy krytyczne

Jeśli błąd jest tak poważny, że program nie może dalej działać prawidłowo, nazywa się to nienaprawialnym błędem (zwanym także błędem krytycznym błąd). W takich przypadkach najlepszym rozwiązaniem jest zamknięcie programu. Jeśli Twój kod znajduje się w main() lub funkcja jest wywoływana bezpośrednio z main(), najlepiej jest main() zwrócić niezerowy kod stanu. Jeśli jednak jesteś głęboko w jakiejś zagnieżdżonej podfunkcji, propagowanie błędu aż do main() może nie być wygodne lub możliwe. W takim przypadku można zastosować halt statement (taki jak std::exit()).

Na przykład:

double doIntDivision(int x, int y)
{
    if (y == 0)
    {
        std::cout << "Error: Could not divide by zero\n";
        std::exit(1);
    }
    return x / y;
}

Wyjątki

Ponieważ zwracanie błędu z funkcji z powrotem do osoby wywołującej jest skomplikowane (a wiele różnych sposobów prowadzi do niespójności, a niespójność prowadzi do błędów), C++ oferuje całkowicie oddzielny sposób przekazywania błędów z powrotem do osoby wywołującej: exceptions.

Podstawową ideą jest to, że gdy błąd nastąpi, wyjątek zostanie „zgłoszony”. Jeśli bieżąca funkcja nie „złapie” błędu, osoba wywołująca funkcję ma szansę wyłapać błąd. Jeśli osoba wywołująca nie wyłapie błędu, osoba dzwoniąca ma szansę wyłapać błąd. Błąd stopniowo przesuwa się w górę stosu wywołań, dopóki nie zostanie przechwycony i obsłużony (w tym momencie wykonywanie jest kontynuowane normalnie) lub do chwili, gdy funkcja main() nie obsłuży błędu (w tym momencie program zakończy się z błędem wyjątku).

Obsługę wyjątków omawiamy w rozdziale 27 tej serii tutoriali.

Kiedy to zrobić użyj std::cout vs std::cerr vs logowanie

W lekcji 3.4 — Podstawowa taktyka debugowania, którą wprowadziliśmy std::cerr. Być może zastanawiasz się, kiedy (lub czy) powinieneś używać std::cerr vs std::cout a zamiast logowania do pliku tekstowego.

Domyślnie oba std::cout i std::cerr drukują tekst do konsoli. Jednak nowoczesne systemy operacyjne umożliwiają przekierowywanie strumieni wyjściowych do plików, dzięki czemu dane wyjściowe można przechwycić w celu późniejszego przeglądu lub automatycznego przetworzenia.

W tej dyskusji przydatne jest rozróżnienie dwóch typów aplikacji:

  • Aplikacje interaktywne to te, z którymi użytkownik będzie wchodzić w interakcję po uruchomieniu. Większość samodzielnych aplikacji, takich jak gry i aplikacje muzyczne, należy do tej kategorii.
  • Aplikacje nieinteraktywne to aplikacje, które do działania nie wymagają interakcji użytkownika. Dane wyjściowe tych programów mogą zostać wykorzystane jako dane wejściowe dla innej aplikacji

W ramach aplikacji nieinteraktywnych istnieją dwa typy:

  • Narzędzia to aplikacje nieinteraktywne, które są zwykle uruchamiane w celu uzyskania natychmiastowego rezultatu, a następnie kończą się po uzyskaniu takiego wyniku. Przykładem tego jest uniksowa komenda grep, która jest narzędziem wyszukującym w tekście linie pasujące do pewnego wzorca.
  • Usługi to nieinteraktywne aplikacje, które zazwyczaj działają cicho w tle, aby wykonywać pewne bieżące funkcje. Przykładem może być skaner antywirusowy.

Oto kilka praktycznych zasad:

  • Użyj std::cout w przypadku całego konwencjonalnego tekstu skierowanego do użytkownika.
  • W przypadku programu interaktywnego użyj std::cout w przypadku normalnych komunikatów o błędach wyświetlanych użytkownikowi (np. „Wprowadzone dane były nieprawidłowe”). Użyj std::cerr lub pliku dziennika, aby uzyskać informacje o stanie i diagnostyce, które mogą być pomocne w diagnozowaniu problemów, ale prawdopodobnie nie będą interesujące dla zwykłych użytkowników. Może to obejmować ostrzeżenia techniczne i błędy (np. nieprawidłowe dane wejściowe funkcji x), aktualizacje statusu (np. pomyślnie otwarto plik x, nie udało się połączyć z usługą internetową x), procent ukończenia długich zadań (np. kodowanie ukończone w 50%) itp....
  • W przypadku programu nieinteraktywnego (narzędzia lub usługi) użyj std::cerr tylko do wyświetlania błędów (np. nie można otworzyć pliku x). Umożliwia to wyświetlanie lub analizowanie błędów oddzielnie od normalnego wyjścia.
  • W przypadku dowolnego typu aplikacji o charakterze transakcyjnym (np. takiej, która przetwarza określone zdarzenia, np. interaktywna przeglądarka internetowa lub nieinteraktywny serwer sieciowy), użyj pliku dziennika, aby utworzyć dziennik transakcyjny zdarzeń, który można przejrzeć później. Może to obejmować wypisanie do pliku dziennika informacji o przetwarzaniu pliku, aktualizacje procentu ukończenia, sygnatury czasowe rozpoczęcia określonych etapów obliczeń, komunikaty ostrzegawcze i o błędach itp.
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:  
145 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze