27.8 — Wyjątki i zagrożenia i wady

Jak prawie wszystko, co ma zalety, wyjątki również mają pewne potencjalne wady. Ten artykuł nie ma charakteru wyczerpującego, ale ma jedynie wskazać kilka głównych kwestii, które należy wziąć pod uwagę podczas korzystania z wyjątków (lub podejmowania decyzji o ich użyciu).

Czyszczenie zasobów

Jednym z największych problemów, na jakie napotykają nowi programiści podczas korzystania z wyjątków, jest kwestia czyszczenia zasobów w przypadku wystąpienia wyjątku. Rozważmy następujący przykład:

#include <iostream>

try
{
    openFile(filename);
    writeFile(filename, data);
    closeFile(filename);
}
catch (const FileException& exception)
{
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

Co się stanie, jeśli funkcja WriteFile() nie powiedzie się i zgłosi wyjątek FileException? W tym momencie otworzyliśmy już plik, a teraz przepływ sterowania przeskakuje do procedury obsługi FileException, która wyświetla błąd i kończy działanie. Pamiętaj, że plik nigdy nie został zamknięty! Ten przykład należy przepisać w następujący sposób:

#include <iostream>

try
{
    openFile(filename);
    writeFile(filename, data);
}
catch (const FileException& exception)
{
    std::cerr << "Failed to write to file: " << exception.what() << '\n';
}

// Make sure file is closed
closeFile(filename);

Ten rodzaj błędu często pojawia się w innej formie, gdy mamy do czynienia z dynamicznie alokowaną pamięcią:

#include <iostream>

try
{
    auto* john { new Person{ "John", 18, PERSON_MALE } };
    processPerson(john);
    delete john;
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

Jeśli procesPerson() zgłosi wyjątek, przepływ sterowania przeskakuje do procedury obsługi catch. W rezultacie John nigdy nie zostaje zwolniony! Ten przykład jest trochę bardziej skomplikowany niż poprzedni — ponieważ John jest lokalny w bloku try, po wyjściu bloku try wykracza poza zakres. Oznacza to, że procedura obsługi wyjątków w ogóle nie może uzyskać dostępu do Johna (została już zniszczona), więc nie ma możliwości zwolnienia pamięci.

Są jednak dwa stosunkowo proste sposoby naprawienia tego problemu. Najpierw zadeklaruj John poza blokiem try, aby nie wyszedł poza zakres po wyjściu bloku try:

#include <iostream>

Person* john{ nullptr };

try
{
    john = new Person("John", 18, PERSON_MALE);
    processPerson(john);
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

delete john;

Ponieważ john jest zadeklarowany poza blokiem try, jest on dostępny zarówno w bloku try, jak i w procedurach obsługi catch. Oznacza to, że procedura obsługi catch może poprawnie przeprowadzić czyszczenie.

Drugim sposobem jest użycie zmiennej lokalnej klasy, która wie, jak sama oczyścić, gdy wyjdzie poza zakres (często nazywana „inteligentnym wskaźnikiem”). Biblioteka standardowa udostępnia klasę o nazwie std::unique_ptr, której można użyć do tego celu. std::unique_ptr to klasa szablonowa, która przechowuje wskaźnik i zwalnia go, gdy wykracza poza zakres.

#include <iostream>
#include <memory> // for std::unique_ptr

try
{
    auto* john { new Person("John", 18, PERSON_MALE) };
    std::unique_ptr<Person> upJohn { john }; // upJohn now owns john

    ProcessPerson(john);

    // when upJohn goes out of scope, it will delete john
}
catch (const PersonException& exception)
{
    std::cerr << "Failed to process person: " << exception.what() << '\n';
}

Powiązana treść

Omawiamy std::unique_ptr w lekcji 22.5 — std::unique_ptr.

Najlepszą opcją (jeśli to możliwe) jest preferowanie do stosu przydzielania obiektów, które implementują RAII (automatycznie przydzielaj zasoby podczas budowy, zwalniaj zasoby po zniszczeniu). W ten sposób, gdy obiekt zarządzający zasobem z jakiegoś powodu wyjdzie poza zakres, zostanie on automatycznie zwolniony w razie potrzeby, więc nie musimy się o to martwić!

Wyjątki i destruktory

W przeciwieństwie do konstruktorów, gdzie zgłaszanie wyjątków może być przydatnym sposobem wskazania, że utworzenie obiektu nie powiodło się, wyjątków nie należy nigdy nie należy zgłaszać destruktory.

Problem występuje, gdy w destruktorze zostanie zgłoszony wyjątek podczas procesu odwijania stosu. Jeśli tak się stanie, kompilator znajdzie się w sytuacji, w której nie będzie wiedział, czy kontynuować proces rozwijania stosu, czy obsłużyć nowy wyjątek. Efektem końcowym jest natychmiastowe zakończenie programu.

W związku z tym najlepszym sposobem działania jest po prostu powstrzymanie się od używania wyjątków w destruktorach. Zamiast tego napisz komunikat do pliku dziennika.

Reguła

Jeśli podczas odwijania stosu zostanie zgłoszony wyjątek z destruktora, program zostanie zatrzymany.

Problemy z wydajnością

Wyjątki wiążą się z niewielką ceną za wydajność. Zwiększają rozmiar pliku wykonywalnego i mogą również powodować jego wolniejsze działanie ze względu na konieczność przeprowadzenia dodatkowego sprawdzania. Jednak główny spadek wydajności w przypadku wyjątków ma miejsce, gdy faktycznie zostanie zgłoszony wyjątek. W takim przypadku należy rozwinąć stos i znaleźć odpowiednią procedurę obsługi wyjątków, co jest operacją stosunkowo kosztowną.

Jak należy zauważyć, niektóre nowoczesne architektury komputerów obsługują model wyjątków zwany wyjątkami o zerowym koszcie. Wyjątki o zerowym koszcie, jeśli są obsługiwane, nie powodują dodatkowych kosztów czasu wykonania w przypadku, gdy nie występują błędy (w takim przypadku najbardziej zależy nam na wydajności). Jednakże w przypadku wykrycia wyjątku naliczana jest jeszcze większa kara.

Kiedy zatem powinienem używać wyjątków?

Obsługę wyjątków najlepiej stosować, gdy spełnione są wszystkie poniższe warunki:

  • Obsługiwany błąd prawdopodobnie będzie pojawiał się rzadko.
  • Błąd jest poważny i w przeciwnym razie wykonanie nie mogłoby być kontynuowane.
  • Błąd nie może zostać obsłużony w miejscu, w którym się pojawił wystąpi.
  • Nie ma dobrego alternatywnego sposobu na zwrócenie kodu błędu z powrotem do osoby wywołującej.

Jako przykład, rozważmy przypadek, w którym napisałeś funkcję, która oczekuje od użytkownika przekazania nazwy pliku na dysku. Twoja funkcja otworzy ten plik, odczyta pewne dane, zamknie plik i przekaże jakiś wynik wywołującemu. Załóżmy teraz, że użytkownik podaje nazwę pliku, który nie istnieje, lub ciąg zerowy. Czy to dobry kandydat na wyjątek?

W tym przypadku pierwsze dwa punkty powyżej zostały spełnione w trywialny sposób — nie zdarza się to często i funkcja nie może obliczyć wyniku, jeśli nie ma żadnych danych do pracy. Funkcja również nie radzi sobie z błędem — zadaniem tej funkcji nie jest ponowne monitowanie użytkownika o podanie nowej nazwy pliku, a to może nawet nie być właściwe, w zależności od tego, jak zaprojektowano program. Czwarty punkt jest kluczem — czy istnieje dobry alternatywny sposób zwrócenia kodu błędu z powrotem do osoby dzwoniącej? To zależy od szczegółów programu. Jeśli tak (np. możesz zwrócić wskaźnik zerowy lub kod stanu wskazujący błąd), jest to prawdopodobnie lepszy wybór. Jeśli nie, rozsądny byłby wyjątek.

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