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)
{
// Przejdź przez każdy znak w ciągu
for (std::size_t index{ 0 }; index < string.length(); ++index)
// Jeśli znak pasuje do ch, zwróć jego indeks
if (string[index] == ch)
return index;
// Jeśli nie znaleziono dopasowania, zwróć -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 {}; // musimy teraz przekazać wartość bool, aby sprawdzić, czy wywołanie się powiodło
double result { divide(5, 3, success) };
if (!success) // i sprawdź to zanim użyjemy wyniku
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" }; // otwórz plik setup.ini do odczytu
// Jeśli plik nie mógł zostać otwarty (np. z powodu jego braku) zwróć jakiś błąd enum
if (!setupIni)
return ERROR_OPENING_FILE;
// Teraz przeczytaj kilka wartości z pliku
if (!readIntegerFromFile(setupIni, m_firstParameter)) // spróbuj odczytać liczbę całkowitą z pliku
return ERROR_READING_VALUE; // Zwróć wartość wyliczeniową wskazującą, że nie można odczytać wartości
if (!readDoubleFromFile(setupIni, m_secondParameter)) // spróbuj odczytać liczbę podwójną z pliku
return ERROR_READING_VALUE;
if (!readFloatFromFile(setupIni, m_thirdParameter)) // spróbuj odczytać liczbę zmiennoprzecinkową z pliku
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++.

