Uwagi dotyczące obsługi błędów

31

Problem:

Od dłuższego czasu martwię się o exceptionsmechanizm, 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 exceptionsi 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: exceptionMechanizm 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 exceptionmechanizmu, głównie w celu debugowania. Nie celuję w to użycie exceptionstutaj.

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ć, exceptionsa po prostu potrzebujesz catchich 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:

  1. Często występujące błędy są wykrywane na wczesnym etapie rozwoju i debugowane (co jest dobre).
  2. 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ć:

  1. Widoczne w kodzie, w którym nie jest zarządzany określony przypadek.
  2. Przekaż środowisko wykonawcze problemu do pokrewnego kodu (przynajmniej wywołującego), gdy taka sytuacja się zdarzy.
  3. Zapewnia mechanizmy odzyskiwania

Główną wadą exceptionsemantyki jako mechanizmu obsługi błędów jest IMO: łatwo jest sprawdzić, gdzie throwjest 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 successbardzo 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 Successklasy:

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.
Adrian Maire
źródło
25
Głosowano za „To pytanie pokazuje wysiłek badawczy; jest użyteczne i jasne”, nie dlatego, że się zgadzam: myślę, że niektóre myśli są błędne. (Szczegóły mogą nastąpić w odpowiedzi.)
Martin Ba
2
Oczywiście, rozumiem i zgadzam się na to! Celem tego pytania jest krytyka. I wynik pytania wskazujący dobre / złe pytania, ale nie to, że OP ma rację.
Adrian Maire
2
Jeśli dobrze rozumiem, głównym problemem związanym z wyjątkami jest to, że ludzie mogą je zignorować (w c ++) zamiast sobie z nimi poradzić. Jednak twoja konstrukcja Success ma tę samą wadę z założenia. Podobnie jak wyjątki, po prostu to zignorują. Co gorsza: jest bardziej gadatliwy, prowadzi do kaskadowych zwrotów i nie można go nawet „złapać” pod prąd.
Dagnelies,
3
Dlaczego po prostu nie użyć czegoś takiego jak monady? Sprawiają, że twoje błędy są niejawne, ale nie będą cicho podczas uruchamiania. Właściwie pierwszą rzeczą, o której pomyślałem patrząc na twój kod, były „monady, miło”. Spójrz na nie.
bash0r
2
Głównym powodem, dla którego lubię wyjątki, jest to, że pozwalają one wychwytywać wszystkie nieoczekiwane błędy z danego bloku kodu i konsekwentnie je obsługiwać. Tak, nie ma dobrego powodu, dla którego kod nie powinien wykonywać swojego zadania - „wystąpił błąd” jest złym powodem, ale wciąż się zdarza , a kiedy to się dzieje, chcesz zarejestrować przyczynę i wyświetlić komunikat lub spróbować ponownie. (Mam kod, który wykonuje złożoną, możliwą do ponownego uruchomienia interakcję z systemem zdalnym; jeśli system zdalny ulegnie awarii, chcę go zarejestrować i ponowić próbę od początku)
user253751

Odpowiedzi:

32

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:

  • Java sprawdziła wyjątki (i niezaznaczone),
  • Go używa kodów błędów / paniki,
  • Rdza używa typów sum / paniki).
  • Języki FP ogólnie.

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:

  • błędy powinny być sygnalizowane w paśmie,
  • wyjątek należy stosować w naprawdę wyjątkowych sytuacjach.

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 do std::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 Functortyp Hlasella:

class Functor m where
  fmap :: (a -> b) -> m a -> m b

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 fmapprzyjmuje funkcję jako pierwszy parametr, który jest nieco podobny do std::function<a,b>. Następną rzeczą jest m a. Możesz sobie wyobrazić mjako coś podobnego std::vectori m ajako coś podobnego std::vector<a>. Różnica polega jednak na tym, że m anie oznacza to, że musi to być jawne std:vector. Więc to też może być std::option. Mówiąc językowi, że mamy instancję klasy Functordla określonego typu, takiego jak std::vectorlub std::option, możemy użyć funkcji fmapdla tego typu. To samo należy zrobić dla typeclasses Applicative, AlternativeiMonadco pozwala wykonywać stanowe, możliwe niepowodzenia obliczeń. Te Alternativeabstrakcje odzyskiwania błędach narzędzia typeclass. Dzięki temu możesz powiedzieć coś w stylu a <|> boznaczającym albo termin, aalbo termin b. Jeśli żadne z obu obliczeń się nie powiedzie, nadal występuje błąd.

Rzućmy okiem na Maybetyp Haskella .

data Maybe a
  = Nothing
  | Just a

Oznacza to, że jeżeli można oczekiwać Maybe a, można uzyskać albo Nothingalbo Just a. Patrząc fmapz góry, może wyglądać implementacja

fmap f m = case m of
  Nothing -> Nothing
  Just a -> Just (f a)

case ... ofWyrażenie nazywa dopasowanie wzoru i przypomina, co jest znane w świecie jako OOP visitor pattern. Wyobraź sobie linię case m ofjako, m.apply(...)a kropki to wystąpienie klasy implementującej funkcje wysyłania. Wiersze pod case ... ofwyrażeniem są odpowiednimi funkcjami wysyłającymi, w których pola klasy są bezpośrednio objęte zakresem według nazwy. W Nothinggałęzi, którą tworzymy, Nothingw Just agałęzi nazywamy naszą jedyną wartość ai tworzymy kolejną Just ...z fzastosowaną funkcją transformacji a. 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 Maybejest inspiracją dla typu Rust Option.


Chciałbym zachęcić cię, abyś przerobił swoją Successklasę na „ Resultzamiast”. Alexandrescu faktycznie zaproponował coś naprawdę bliskiego expected<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żyjemy TRYmakra i wyrażenia instrukcji GCC do jego emulacji.

template <typename E>
struct Error {
    Error(E e): error(std::move(e)) {}

    E error;
};

template <typename E>
Error<E> error(E e) { return Error<E>(std::move(e)); }

template <typename T, typename E>
struct [[nodiscard]] Result {
    template <typename U>
    Result(U u): ok(true), data(std::move(u)), error() {}

    template <typename F>
    Result(Error<F> f): ok(false), data(), error(std::move(f.error)) {}

    template <typename U, typename F>
    Result(Result<U, F> other):
        ok(other.ok), data(std::move(other.data)),  error(std::move(other.error)) {}

    bool ok = false;
    T data;
    E error;
};

#define TRY(Expr_) \
    ({ auto result = (Expr_); \
       if (!result.ok) { return result; } \
       std::move(result.data); })

Uwaga: Resultjest to symbol zastępczy. Prawidłowa implementacja użyłaby enkapsulacji i union. Wystarczy jednak przejść przez punkt.

Co pozwala mi pisać ( patrz w akcji ):

Result<double, std::string> sqrt(double x) {
    if (x < 0) {
        return error("sqrt does not accept negative numbers");
    }
    return x;
}

Result<double, std::string> double_sqrt(double x) {
    auto y = TRY(sqrt(x));
    return sqrt(y);
}

co uważam za naprawdę fajne:

  • w odróżnieniu od użycia kodów błędów (lub Successklasy), zapomnienie o sprawdzeniu błędów spowoduje błąd wykonania 1, a nie pewne losowe zachowanie,
  • w przeciwieństwie do wyjątków, w witrynie wywoławczej widać, które funkcje mogą zawieść, więc nie jest zaskoczeniem.
  • ze standardem C ++ - 2X możemy uzyskać conceptsstandard. 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 w std::vectorrezultacie 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ą Resultimplementacją;)


Uwaga: w odróżnieniu od wyjątku, ten lekki Resultnie 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, gdy TRYużywa się makra, zasadniczo tworząc ręcznie ślad wstecz lub używając specyficznego dla platformy kodu i bibliotek, takich jak libbacktracelista symboli w stosie wywołań.


Jest jednak jedno duże zastrzeżenie: istniejące biblioteki C ++, a nawet stdoparte 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 ...

Matthieu M.
źródło
3
To makro wygląda ... bardzo źle. Zakładam, że ({...})jest jakieś rozszerzenie gcc, ale mimo to, prawda if (!result.ok) return result;? Twój stan pojawi się odwrotnie i zrobisz niepotrzebną kopię błędu.
Mooing Duck
@MooingDuck Odpowiedź wyjaśnia, że ({...})jest to wyrażenie instrukcji gcc .
jamesdlin
1
Polecam std::variantzaimplementować Resultjeśli używasz C ++ 17. Ponadto, aby otrzymać ostrzeżenie, jeśli zignorujesz błąd, użyj[[nodiscard]]
Justin
2
@Justin: To, czy użyć, std::variantczy nie, jest kwestią gustu, biorąc pod uwagę kompromisy związane z obsługą wyjątków. [[nodiscard]]jest rzeczywiście czystą wygraną.
Matthieu M.,
46

ROSZCZENIE: Mechanizm wyjątku jest semantycznym językiem służącym do obsługi błędów

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.

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

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 mySqrtfunkcja 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ład NaN), 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 ++.

Bezużyteczny
źródło
1
Dzięki twojej odpowiedzi dodaje światła do tematu. Wydaje mi się, że użytkownik nie zgodzi się z tym, and allowing my program to fail gracefully, and be re-rungdy właśnie stracił 2 godziny pracy:
Adrian Maire
14
Twoje rozwiązanie oznacza, że ​​w każdym miejscu, w którym możesz utworzyć plik, musisz poprosić użytkownika o naprawienie sytuacji i ponowienie próby. Następnie wszystkie inne rzeczy, które mogą pójść nie tak, musisz jakoś naprawić lokalnie. Z wyjątkami po prostu łapiesz std::exceptionna 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.
Bezużyteczne
13
@AdrianMaire: „Pozwolenie na niepowodzenie z wdziękiem i ponowne uruchomienie” można również zaimplementować jako 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.
Bart van Ingen Schenau
3
@ Ocena bez sensu Lazy nie ma nic wspólnego z użyciem monady Error, o czym świadczą surowe języki oceny, takie jak Rust, OCaml i F #, z których wszyscy intensywnie korzystają.
8bittree
1
@Useless IMO dla jakości oprogramowania, to ma sens, że „każde miejsce może utworzyć plik, trzeba monitować użytkownika, aby naprawić sytuację i spróbuj ponownie”. Pierwsi programiści często podejmowali niezwykłe wysiłki w celu odzyskiwania błędów, przynajmniej program Teutha Knutha jest ich pełen. A dzięki swojej strukturze „programowania piśmiennego” znalazł sposób, aby zachować obsługę błędów w innej sekcji, aby kod był czytelny, a odzyskiwanie błędów pisane z większą ostrożnością (ponieważ podczas pisania sekcji odzyskiwania błędów, o to chodzi, a programista wykonuje lepszą pracę).
ShreevatsaR
15

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?

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.

Ale kilka lat pracy jako programisty sprawia, że ​​widzę problem z innego podejścia:

Do rozwiązań tych problemów należy podchodzić na poziomie leadów technicznych lub na poziomie zespołu:

Programiści zwykle upraszczają swoje zadanie, wprowadzając wyjątki, gdy konkretny przypadek wydaje się zbyt rzadki, aby można go było ostrożnie 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.

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).

Programiści zwykle nie czytają dokładnie dokumentacji [...] Co więcej, nawet jeśli wiedzą, tak naprawdę nimi nie zarządzają.

Nie rozwiążesz tego, pisząc więcej kodu. Myślę, że najlepszym rozwiązaniem są skrupulatnie sprawdzane recenzje kodu.

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).

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.).

utnapistim
źródło
12
Cały kod po błędzie jest pomijany, jeśli pamiętasz, aby sprawdzać za każdym razem, gdy otrzymasz instancję jako argument . Mam na myśli to, że „im więcej kodu piszesz przy takim podejściu, tym więcej kodu błędu musisz dodać”. Będziesz musiał rozwikłać swój kod ifs w instancji sukcesu, a za każdym razem, gdy zapomnisz, jest to błąd. Drugi problem spowodowany przez zapomnienie o sprawdzeniu: kod, który jest wykonywany do momentu ponownego sprawdzenia, w ogóle nie powinien zostać wykonany (kontynuowanie, jeśli zapomnisz sprawdzić, powoduje uszkodzenie danych).
utnapistim
11
Nie, obsługa wyjątku (lub zwracanie kodu błędu) nie powoduje awarii - chyba że błąd / wyjątek jest logicznie śmiertelny lub nie zdecydujesz się go obsłużyć. Nadal masz możliwość obsługi przypadku błędu, bez konieczności jawnego sprawdzania na każdym kroku, czy błąd wystąpił wcześniej
Bezużyteczne
11
@AdrianMaire W prawie każdej aplikacji, nad którą pracuję, zdecydowanie wolę awarię niż cichą kontynuację. Pracuję na oprogramowaniu o znaczeniu krytycznym dla biznesu, w którym zebranie złej wydajności i dalsze działanie na nim może spowodować utratę dużej ilości pieniędzy. Jeśli poprawność jest kluczowa, a awarie dopuszczalne, wówczas wyjątki mają tutaj bardzo dużą zaletę.
Chris Hayes
1
@AdrianMaire - Myślę, że o wiele trudniej jest zapomnieć o obsłudze wyjątku, który jest twoją metodą zapominania instrukcji if ... Poza tym - główną zaletą wyjątków jest to, która warstwa je obsługuje. Możesz pozwolić, aby wyjątek systemowy pojawił się dalej, aby wyświetlić komunikat o błędzie na poziomie aplikacji, ale obsługuj sytuacje, o których wiesz na niższym poziomie. Jeśli korzystasz z bibliotek stron trzecich lub innych
programów dla
5
@Adrian Nie ma pomyłki, chyba źle zrozumiałeś to, co napisałem lub przegapiłem drugą połowę. Nie mam na myśli tego, że wyjątek zostanie zgłoszony podczas testowania / programowania i że programiści zdadzą sobie sprawę, że muszą sobie z nimi poradzić. Chodzi o to, że konsekwencja całkowicie nieobsługiwanego wyjątku w produkcji jest lepsza niż konsekwencja niesprawdzonego kodu błędu. jeśli przegapisz kod błędu, otrzymujesz i nadal używasz niewłaściwych wyników. Jeśli przegapisz wyjątek, aplikacja ulega awarii i nie można kontynuować działania, nie otrzymujesz żadnych wyników, a nie błędne wyniki . (ciąg dalszy)
Mr.Mindor