27.1 — Potrzeba stosowania wyjątków

W poprzedniej lekcji na temat obsługa błędów, rozmawialiśmy o sposobach użycia funkcji Assert(), std::cerr i exit() do obsługi błędów. Jednakże odłożyliśmy na później kolejny temat, którym zajmiemy się teraz: wyjątki.

Gdy kody powrotne zawodzą

Podczas pisania kodu wielokrotnego użytku obsługa błędów jest koniecznością. Jednym z najczęstszych sposobów radzenia sobie z potencjalnymi błędami jest użycie kodów zwrotnych. Na przykład:

#include <string_view>

int findFirstChar(std::string_view string, char ch)
{
    // Step through each character in string
    for (std::size_t index{ 0 }; index < string.length(); ++index)
        // If the character matches ch, return its index
        if (string[index] == ch)
            return index;

    // If no match was found, return -1
    return -1;
}

Ta funkcja zwraca indeks pierwszego znaku pasującego do ch w ciągu. Jeśli znaku nie można znaleźć, funkcja zwraca -1 jako wskaźnik, że znaku nie odnaleziono.

Podstawową zaletą tego podejścia jest to, że jest niezwykle proste. Jednakże używanie kodów powrotu ma wiele wad, które mogą szybko stać się widoczne w nietrywialnych przypadkach:

Po pierwsze, zwracane wartości mogą być tajemnicze — jeśli funkcja zwraca -1, czy próbuje wskazać błąd, czy też jest to w rzeczywistości poprawna wartość zwracana? Często trudno to stwierdzić bez zagłębienia się w głąb funkcji lub zapoznania się z dokumentacją.

Po drugie, funkcje mogą zwracać tylko jedną wartość, więc co się stanie, gdy trzeba zwrócić zarówno wynik funkcji, jak i możliwy kod błędu? Rozważ następującą funkcję:

double divide(int x, int y)
{
    return static_cast<double>(x)/y;
}

Ta funkcja rozpaczliwie potrzebuje obsługi błędów, ponieważ ulegnie awarii, jeśli użytkownik przekaże 0 dla parametru y. Jednak musi również zwrócić wynik x/y. Jak to może zrobić jedno i drugie? Najczęstszą odpowiedzią jest to, że albo wynik, albo obsługa błędów będą musiały zostać przekazane z powrotem jako parametr referencyjny, co powoduje, że brzydki kod jest mniej wygodny w użyciu. Na przykład:

#include <iostream>

double divide(int x, int y, bool& outSuccess)
{
    if (y == 0)
    {
        outSuccess = false;
        return 0.0;
    }

    outSuccess = true;
    return static_cast<double>(x)/y;
}

int main()
{
    bool success {}; // we must now pass in a bool value to see if the call was successful
    double result { divide(5, 3, success) };

    if (!success) // and check it before we use the result
        std::cerr << "An error occurred" << std::endl;
    else
        std::cout << "The answer is " << result << '\n';
}

Po trzecie, w sekwencjach kodu, w których wiele rzeczy może pójść nie tak, należy stale sprawdzać kody błędów. Rozważ następujący fragment kodu, który obejmuje analizowanie pliku tekstowego pod kątem wartości, które powinny się w nim znajdować:

    std::ifstream setupIni { "setup.ini" }; // open setup.ini for reading
    // If the file couldn't be opened (e.g. because it was missing) return some error enum
    if (!setupIni)
        return ERROR_OPENING_FILE;

    // Now read a bunch of values from a file
    if (!readIntegerFromFile(setupIni, m_firstParameter)) // try to read an integer from the file
        return ERROR_READING_VALUE; // Return enum value indicating value couldn't be read

    if (!readDoubleFromFile(setupIni, m_secondParameter)) // try to read a double from the file
        return ERROR_READING_VALUE;

    if (!readFloatFromFile(setupIni, m_thirdParameter)) // try to read a float from the file
        return ERROR_READING_VALUE;

Nie omówiliśmy jeszcze dostępu do plików, więc nie martw się, jeśli nie rozumiesz, jak działa powyższe - po prostu zwróć uwagę na fakt, że każde wywołanie wymaga sprawdzenia błędów i wróć do osoby dzwoniącej. Teraz wyobraź sobie, że istnieje dwadzieścia parametrów różnych typów — zasadniczo sprawdzasz, czy nie wystąpił błąd i zwracasz ERROR_READING_VALUE dwadzieścia razy! Całe to sprawdzanie błędów i zwracanie wartości sprawia, że ​​określenie co funkcji jest znacznie trudniejsze do rozpoznania.

Po czwarte, kody powrotu nie łączą się zbyt dobrze z konstruktorami. Co się stanie, jeśli tworzysz obiekt i coś wewnątrz konstruktora pójdzie katastrofalnie nie tak? Konstruktory nie mają typu zwracanego, który umożliwiałby przekazanie wskaźnika stanu, a przekazywanie go z powrotem za pomocą parametru referencyjnego jest kłopotliwe i należy je jawnie sprawdzić. Co więcej, nawet jeśli to zrobisz, obiekt i tak zostanie utworzony, a następnie będzie musiał się nim zająć lub się go pozbyć.

W końcu, gdy do osoby wywołującej zostanie zwrócony kod błędu, osoba wywołująca może nie zawsze być przygotowana do obsługi błędu. Jeśli osoba wywołująca nie chce obsłużyć błędu, musi go zignorować (w takim przypadku zostanie utracony na zawsze) lub zwrócić błąd na stosie do funkcji, która go wywołała. Może to powodować bałagan i prowadzić do wielu problemów opisanych powyżej.

Podsumowując, głównym problemem związanym z kodami powrotu jest to, że kod obsługi błędów jest ściśle powiązany z normalnym przepływem kontroli kodu. To z kolei ogranicza zarówno układ kodu, jak i rozsądną obsługę błędów.

Wyjątki

Obsługa wyjątków zapewnia mechanizm oddzielający obsługę błędów lub innych wyjątkowych okoliczności od typowego przepływu kontroli w kodzie. Zapewnia to większą swobodę obsługi błędów wtedy i w sposób najbardziej przydatny w danej sytuacji, łagodząc większość (jeśli nie całość) bałaganu powodowanego przez kody zwracające.

W następnej lekcji przyjrzymy się, jak działają wyjątki w C++.

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:  
58 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze