Problem:
Od dłuższego czasu martwię się o exceptions
mechanizm, ponieważ uważam, że tak naprawdę nie rozwiązuje tego, co powinien.
ROSZCZENIE: Na ten temat toczą się długie debaty i większość z nich ma trudności z porównywaniem exceptions
i zwracaniem kodu błędu. To zdecydowanie nie jest tutaj temat.
Próbując zdefiniować błąd, zgodziłbym się z CppCoreGuidelines z Bjarne Stroustrup & Herb Sutter
Błąd oznacza, że funkcja nie może osiągnąć reklamowanego celu
ROSZCZENIE: exception
Mechanizm jest semantycznym językiem do obsługi błędów.
OŚWIADCZENIE: Dla mnie „nie ma usprawiedliwienia” dla funkcji nieosiągnięcia zadania: albo źle zdefiniowaliśmy warunki przed / po, aby funkcja nie mogła zapewnić wyników, albo jakiś szczególny wyjątkowy przypadek nie jest uważany za wystarczająco ważny, aby poświęcić czas na rozwój rozwiązanie. Biorąc pod uwagę, że IMO, różnica między obsługą normalnego kodu a kodem błędu jest (przed wdrożeniem) bardzo subiektywną linią.
ROSZCZENIE: Wykorzystanie wyjątków do wskazania, kiedy warunek wstępny lub końcowy nie jest zachowany, jest kolejnym celem exception
mechanizmu, głównie w celu debugowania. Nie celuję w to użycie exceptions
tutaj.
W wielu książkach, samouczkach i innych źródłach mają tendencję do pokazywania obsługi błędów jako dość obiektywnej nauki, którą można rozwiązać, exceptions
a po prostu potrzebujesz catch
ich do posiadania solidnego oprogramowania, które jest w stanie wyjść z każdej sytuacji. Ale kilka lat pracy jako programisty sprawia, że widzę problem z innego podejścia:
- Programiści mają tendencję do upraszczania swoich zadań, zgłaszając wyjątki, gdy konkretny przypadek wydaje się zbyt rzadki, aby można go było dokładnie wdrożyć. Typowe przypadki to: problemy z brakiem pamięci, problemy z zapełnieniem dysku, problemy z uszkodzonymi plikami itp. Może to być wystarczające, ale nie zawsze jest to podejmowane z poziomu architektury.
- Programiści zwykle nie czytają uważnie dokumentacji dotyczącej wyjątków w bibliotekach i zwykle nie są świadomi, która i kiedy funkcja wyrzuca. Co więcej, nawet jeśli wiedzą, tak naprawdę nimi nie zarządzają.
- Programiści zwykle nie wychwytują wyjątków wystarczająco wcześnie, a kiedy to robią, najczęściej rejestrują i rzucają dalej. (patrz pierwszy punkt).
Ma to dwie konsekwencje:
- Często występujące błędy są wykrywane na wczesnym etapie rozwoju i debugowane (co jest dobre).
- Rzadkie wyjątki nie są zarządzane i powodują awarię systemu (z ładnym komunikatem dziennika) w domu użytkownika. Czasami błąd jest zgłaszany lub nawet nie.
Biorąc to pod uwagę, głównym celem mechanizmu błędów IMO powinno być:
- Widoczne w kodzie, w którym nie jest zarządzany określony przypadek.
- Przekaż środowisko wykonawcze problemu do pokrewnego kodu (przynajmniej wywołującego), gdy taka sytuacja się zdarzy.
- Zapewnia mechanizmy odzyskiwania
Główną wadą exception
semantyki jako mechanizmu obsługi błędów jest IMO: łatwo jest sprawdzić, gdzie throw
jest kod źródłowy, ale absolutnie nie jest oczywiste, czy określona funkcja mogłaby rzucić, patrząc na deklarację. To przynosi cały problem, który przedstawiłem powyżej.
Język nie wymusza i nie sprawdza kodu błędu tak ściśle, jak ma to miejsce w przypadku innych aspektów języka (np. Silne typy zmiennych)
Próba rozwiązania
Aby to poprawić, opracowałem bardzo prosty system obsługi błędów, który stara się ustawić obsługę błędów na tym samym poziomie ważności, co normalny kod.
Chodzi o:
- Każda (odpowiednia) funkcja otrzymuje odniesienie do
success
bardzo lekkiego obiektu i może w razie potrzeby ustawić status błędu. Obiekt jest bardzo lekki, dopóki nie zostanie zapisany błąd tekstu. - Funkcja jest zachęcana do pominięcia swojego zadania, jeśli podany obiekt zawiera już błąd.
- Błąd nigdy nie może zostać zastąpiony.
Pełny projekt oczywiście dokładnie uwzględnia każdy aspekt (około 10 stron), a także sposób zastosowania go do OOP.
Przykład Success
klasy:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Stosowanie:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
Użyłem tego w wielu (własnych) kodach i zmusza to programistę (mnie) do dalszego zastanowienia się nad możliwymi wyjątkowymi przypadkami i jak je rozwiązać (dobrze). Ma jednak krzywą uczenia się i nie integruje się dobrze z kodem, który go teraz używa.
Pytanie
Chciałbym lepiej zrozumieć konsekwencje zastosowania takiego paradygmatu w projekcie:
- Czy przesłanka problemu jest poprawna? lub Czy przegapiłem coś istotnego?
- Czy to dobry pomysł na architekturę? czy cena jest zbyt wysoka?
EDYTOWAĆ:
Porównanie metod:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
źródło
Odpowiedzi:
Obsługa błędów jest prawdopodobnie najtrudniejszą częścią programu.
Ogólnie rzecz biorąc, uświadomienie sobie, że wystąpił warunek błędu, jest łatwe; jednak zasygnalizowanie go w sposób, którego nie można obejść i odpowiednie obchodzenie się z nim (patrz poziomy bezpieczeństwa wyjątku Abrahama ) jest naprawdę trudne.
W C błędy sygnalizacji są wykonywane przez kod powrotu, który jest izomorficzny dla twojego rozwiązania.
C ++ wprowadzono wyjątki ze względu na krótki pochodzących z takiego podejścia; a mianowicie działa tylko wtedy, gdy dzwoniący pamiętają, aby sprawdzić, czy wystąpił błąd, i nie rozpada się strasznie inaczej. Ilekroć mówisz „Wszystko w porządku, dopóki za każdym razem ...” masz problem; ludzie nie są aż tak drobiazgowi, nawet jeśli ich to obchodzi.
Problem polega jednak na tym, że wyjątki mają swoje własne problemy. Mianowicie niewidoczny / ukryty przepływ sterowania. Miało to na celu: ukrycie przypadku błędu, aby logika kodu nie została zaciemniona przez tabelę obsługi błędów. Sprawia, że „szczęśliwa ścieżka” jest znacznie wyraźniejsza (i szybsza!), Kosztem uczynienia ścieżek błędów prawie nieodgadnionymi.
Interesujące jest dla mnie, jak inne języki podchodzą do tego problemu:
W C ++ istniała pewna forma sprawdzonych wyjątków, być może zauważyłeś, że została ona przestarzała i uproszczona w stosunku do podstawowej
noexcept(<bool>)
: albo funkcja jest zgłaszana do rzucenia, albo nigdy. Sprawdzone wyjątki są nieco problematyczne, ponieważ nie mają możliwości rozszerzenia, co może powodować niewygodne mapowania / zagnieżdżanie. I zawiłe hierarchie wyjątków (jednym z głównych przypadków wirtualnego dziedziczenia są wyjątki ...).Natomiast Go i Rust przyjmują podejście, które:
Ta ostatnia jest raczej widoczna w tym, że (1) nazywają swoje wyjątki paniką i (2) nie ma tutaj hierarchii typów / skomplikowanej klauzuli. Język nie oferuje możliwości kontroli treści „paniki”: brak hierarchii typów, brak treści zdefiniowanych przez użytkownika, po prostu „ups, sprawy poszły tak źle, że nie ma możliwości odzyskania”.
To skutecznie zachęca użytkowników do korzystania z właściwej obsługi błędów, jednocześnie pozostawiając łatwy sposób na ratowanie w wyjątkowych sytuacjach (takich jak: „poczekaj, jeszcze tego nie wdrożyłem!”).
Oczywiście podejście Go jest niestety podobne do twojego, ponieważ możesz łatwo zapomnieć o sprawdzeniu błędu ...
... podejście Rdza koncentruje się jednak głównie na dwóch typach:
Option
, Która jest podobna dostd::optional
,Result
, który jest wariantem dwóch możliwości: Ok i Err.jest to o wiele ładniejsze, ponieważ nie ma możliwości przypadkowego użycia wyniku bez sprawdzenia sukcesu: jeśli to zrobisz, program wpadnie w panikę.
Języki FP tworzą swoją obsługę błędów w konstrukcjach, które można podzielić na trzy warstwy: - Functor - Applicative / Alternative - Monads / Alternative
Spójrzmy na
Functor
typ Hlasella:Po pierwsze, typy są nieco podobne, ale nie równe interfejsom. Sygnatury funkcji Haskella na pierwszy rzut oka wyglądają trochę przerażająco. Ale rozszyfrujmy je. Funkcja
fmap
przyjmuje funkcję jako pierwszy parametr, który jest nieco podobny dostd::function<a,b>
. Następną rzeczą jestm a
. Możesz sobie wyobrazićm
jako coś podobnegostd::vector
im a
jako coś podobnegostd::vector<a>
. Różnica polega jednak na tym, żem a
nie oznacza to, że musi to być jawnestd:vector
. Więc to też może byćstd::option
. Mówiąc językowi, że mamy instancję klasyFunctor
dla określonego typu, takiego jakstd::vector
lubstd::option
, możemy użyć funkcjifmap
dla tego typu. To samo należy zrobić dla typeclassesApplicative
,Alternative
iMonad
co pozwala wykonywać stanowe, możliwe niepowodzenia obliczeń. TeAlternative
abstrakcje odzyskiwania błędach narzędzia typeclass. Dzięki temu możesz powiedzieć coś w stylua <|> b
oznaczającym albo termin,a
albo terminb
. Jeśli żadne z obu obliczeń się nie powiedzie, nadal występuje błąd.Rzućmy okiem na
Maybe
typ Haskella .Oznacza to, że jeżeli można oczekiwać
Maybe a
, można uzyskać alboNothing
alboJust a
. Patrzącfmap
z góry, może wyglądać implementacjacase ... of
Wyrażenie nazywa dopasowanie wzoru i przypomina, co jest znane w świecie jako OOPvisitor pattern
. Wyobraź sobie linięcase m of
jako,m.apply(...)
a kropki to wystąpienie klasy implementującej funkcje wysyłania. Wiersze podcase ... of
wyrażeniem są odpowiednimi funkcjami wysyłającymi, w których pola klasy są bezpośrednio objęte zakresem według nazwy. WNothing
gałęzi, którą tworzymy,Nothing
wJust a
gałęzi nazywamy naszą jedyną wartośća
i tworzymy kolejnąJust ...
zf
zastosowaną funkcją transformacjia
. Przeczytaj ją jako:new Just(f(a))
.To może teraz obsłużyć błędne obliczenia, jednocześnie odciągając rzeczywiste kontrole błędów. Istnieją inne implementacje dla innych interfejsów, dzięki czemu tego rodzaju obliczenia są bardzo wydajne. W rzeczywistości
Maybe
jest inspiracją dla typu RustOption
.Chciałbym zachęcić cię, abyś przerobił swoją
Success
klasę na „Result
zamiast”. Alexandrescu faktycznie zaproponował coś naprawdę bliskiegoexpected<T>
, dla którego złożono standardowe propozycje .Będę trzymać się nazewnictwa i API Rust tylko dlatego, że ... jest udokumentowany i działa. Oczywiście, Rust posiada sprytny
?
operator sufiksu, który znacznie uprości kod; w C ++ użyjemyTRY
makra i wyrażenia instrukcji GCC do jego emulacji.Uwaga:
Result
jest to symbol zastępczy. Prawidłowa implementacja użyłaby enkapsulacji iunion
. Wystarczy jednak przejść przez punkt.Co pozwala mi pisać ( patrz w akcji ):
co uważam za naprawdę fajne:
Success
klasy), zapomnienie o sprawdzeniu błędów spowoduje błąd wykonania 1, a nie pewne losowe zachowanie,concepts
standard. Sprawiłoby to, że tego rodzaju programowanie byłoby znacznie przyjemniejsze, ponieważ moglibyśmy pozostawić wybór ponad rodzajem błędu. Np. Z wdrożeniem wstd::vector
rezultacie możemy obliczyć wszystkie możliwe rozwiązania naraz. Lub możemy ulepszyć obsługę błędów, zgodnie z propozycją.1 Z odpowiednio enkapsulowaną
Result
implementacją;)Uwaga: w odróżnieniu od wyjątku, ten lekki
Result
nie ma śladów wstecz, co powoduje, że rejestrowanie jest mniej wydajne; przydatne może być zapisanie co najmniej numeru pliku / linii, pod którym generowany jest komunikat o błędzie, i ogólnie napisanie bogatego komunikatu o błędzie. Można to pogarszać, przechwytując plik / linię za każdym razem, gdyTRY
używa się makra, zasadniczo tworząc ręcznie ślad wstecz lub używając specyficznego dla platformy kodu i bibliotek, takich jaklibbacktrace
lista symboli w stosie wywołań.Jest jednak jedno duże zastrzeżenie: istniejące biblioteki C ++, a nawet
std
oparte są na wyjątkach. Korzystanie z tego stylu będzie ciężką bitwą, ponieważ interfejs API każdej biblioteki innej firmy musi być zapakowany w adapter ...źródło
({...})
jest jakieś rozszerzenie gcc, ale mimo to, prawdaif (!result.ok) return result;
? Twój stan pojawi się odwrotnie i zrobisz niepotrzebną kopię błędu.({...})
jest to wyrażenie instrukcji gcc .std::variant
zaimplementowaćResult
jeśli używasz C ++ 17. Ponadto, aby otrzymać ostrzeżenie, jeśli zignorujesz błąd, użyj[[nodiscard]]
std::variant
czy nie, jest kwestią gustu, biorąc pod uwagę kompromisy związane z obsługą wyjątków.[[nodiscard]]
jest rzeczywiście czystą wygraną.wyjątki to mechanizm kontroli przepływu. Motywacją dla tego mechanizmu kontroli przepływu było oddzielenie obsługi błędów od kodu niepowodującego błędów, w typowym przypadku obsługa błędów jest bardzo powtarzalna i nie ma większego znaczenia dla głównej części logiki.
Zastanów się: próbuję utworzyć plik. Urządzenie pamięci jest pełne.
Nie jest to jednak brak określenia moich warunków wstępnych: ogólnie nie możesz użyć „musi być wystarczającej ilości miejsca”, ponieważ wspólne przechowywanie podlega warunkom wyścigowym, które uniemożliwiają spełnienie tego warunku.
Czy mój program powinien w jakiś sposób zwolnić miejsce, a następnie przejść pomyślnie, w przeciwnym razie jestem zbyt leniwy, by „opracować rozwiązanie”? Wydaje się to szczerze bezsensowne. „Rozwiązanie” do zarządzania pamięcią współdzieloną jest poza zakresem mojego programu i pozwala mojemu programowi na awarię bezproblemowo i zostać ponownie uruchomionym, gdy użytkownik zwolni trochę miejsca lub doda więcej pamięci, jest w porządku .
To, co robi klasa sukcesu, bardzo wyraźnie przeplata obsługę błędów z logiką programu. Każda funkcja musi sprawdzić przed uruchomieniem, czy wystąpił już jakiś błąd, co oznacza, że nie powinien nic robić. Każda funkcja biblioteki musi być zapakowana w inną funkcję, z jeszcze jednym argumentem (i, mam nadzieję, doskonałym przekazywaniem), co robi dokładnie to samo.
Zauważ też, że twoja
mySqrt
funkcja musi zwrócić wartość, nawet jeśli nie powiodła się (lub poprzednia funkcja zawiodła). Tak więc albo zwracasz magiczną wartość (na przykładNaN
), albo wstrzykujesz do swojego programu nieokreśloną wartość i masz nadzieję, że nic z niej nie skorzysta bez sprawdzenia stanu powodzenia, jaki udało ci się prześledzić przez wykonanie.Dla poprawności - i wydajności - o wiele lepiej jest zrezygnować z kontroli poza zakresem, gdy nie można zrobić żadnego postępu. Udało się to osiągnąć dzięki wyjątkom i jawnemu sprawdzaniu błędów w stylu C z wczesnym powrotem .
Dla porównania, przykładem twojego pomysłu, który naprawdę działa, jest monada błędu w Haskell. Przewagą nad systemem jest to, że zapisujesz większość logiki normalnie, a następnie zawijasz ją w monadzie, która dba o zatrzymanie oceny, gdy jeden krok się nie powiedzie. W ten sposób jedynym kodem dotykającym bezpośrednio systemu obsługi błędów jest kod, który może zawieść (wyrzucić błąd) i kod, który musi poradzić sobie z awarią (złapać wyjątek).
Nie jestem jednak pewien, czy styl monady i leniwa ocena dobrze przekładają się na C ++.
źródło
and allowing my program to fail gracefully, and be re-run
gdy właśnie stracił 2 godziny pracy:std::exception
na wyższym poziomie operacji logicznej, mówisz użytkownikowi „X nie powiodło się z powodu ex.what ()” i oferuje ponawianie całej operacji, kiedy i jeśli będzie gotowy.showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try
. Jest to wdzięczna obsługa problemu, którego zwykle nie można wykonać na podstawie kodu wykrywającego, że pierwsza lokalizacja magazynu jest pełna.Twoje podejście wiąże się z dużymi problemami w kodzie źródłowym:
polega na kodzie klienta, który zawsze pamięta o sprawdzeniu wartości
s
. Jest to powszechne w przypadku użycia kodów powrotu do podejścia do obsługi błędów i jednym z powodów wprowadzenia wyjątków do języka: z wyjątkami, jeśli zawiedziesz, nie zawiedziesz po cichu.im więcej kodu piszesz przy użyciu tego podejścia, tym więcej kodu błędu należy dodać, aby obsługiwać błędy (kod nie jest już minimalistyczny), a nakład pracy na utrzymanie rośnie.
Do rozwiązań tych problemów należy podchodzić na poziomie leadów technicznych lub na poziomie zespołu:
Jeśli cały czas radzisz sobie z każdym rodzajem wyjątku, który może zostać zgłoszony, projekt nie jest dobry; Jakie błędy zostaną naprawione, należy decydować zgodnie ze specyfikacjami projektu, a nie zgodnie z tym, jak czują się deweloperzy.
Należy rozwiązać ten problem, konfigurując testy automatyczne, oddzielając specyfikację testów jednostkowych i implementację (poproś o to dwie różne osoby).
Nie rozwiążesz tego, pisząc więcej kodu. Myślę, że najlepszym rozwiązaniem są skrupulatnie sprawdzane recenzje kodu.
Prawidłowa obsługa błędów jest trudna, ale mniej uciążliwa w przypadku wyjątków niż w przypadku wartości zwracanych (niezależnie od tego, czy są one zwracane, czy przekazywane jako argumenty we / wy).
Najtrudniejszą częścią obsługi błędów nie jest sposób ich otrzymania, ale upewnienie się, że aplikacja zachowuje spójny stan w przypadku wystąpienia błędów.
Aby temu zaradzić, należy poświęcić więcej uwagi identyfikacji i działaniu w warunkach błędu (więcej testów, więcej testów jednostkowych / integracyjnych itp.).
źródło