Po co projektować nowoczesny język bez mechanizmu obsługi wyjątków?

47

Wiele współczesnych języków zapewnia bogate funkcje obsługi wyjątków , ale język programowania Swift firmy Apple nie zapewnia mechanizmu obsługi wyjątków .

Pogrążony w wyjątkach, tak jak ja, mam problem z otuleniem się, co to znaczy. Swift ma asercje i oczywiście zwraca wartości; ale mam problem z wyobrażeniem sobie, w jaki sposób moje myślenie oparte na wyjątkach mapuje świat bez wyjątków (i, w takim razie, dlaczego taki świat jest pożądany ). Czy są rzeczy, których nie mogę zrobić w języku takim jak Swift, które mogę zrobić z wyjątkami? Czy coś zyskuję tracąc wyjątki?

Jak na przykład najlepiej wyrazić coś takiego

try:
    operation_that_can_throw_ioerror()
except IOError:
    handle_the_exception_somehow()
else:
     # we don't want to catch the IOError if it's raised
    another_operation_that_can_throw_ioerror()
finally:
    something_we_always_need_to_do()

w języku (na przykład Swift), który nie obsługuje wyjątków?

orome
źródło
11
Możesz dodać Idź do listy , jeśli po prostu zignorujemy panicto, co nie jest takie samo. Oprócz tego, co tam powiedziano, wyjątek nie jest niczym więcej niż wyrafinowanym (ale wygodnym) sposobem na wykonanie GOTO, choć z oczywistych powodów, nikt tak go nie nazywa.
JensG
3
Sortowana odpowiedź na twoje pytanie polega na tym, że potrzebujesz wyjątków, aby je zapisać. Obsługa języków zasadniczo obejmuje zarządzanie pamięcią; ponieważ wyjątek może zostać rzucony w dowolnym miejscu i złapany w dowolnym miejscu, musi istnieć sposób na pozbycie się obiektów, które nie zależą od przepływu sterowania.
Robert Harvey
1
Nie do końca cię śledzę, @Robert. C ++ obsługuje wyjątki bez odśmiecania.
Karl Bielefeldt
3
@KarlBielefeldt: Z wielkich kosztów, z tego co rozumiem. Z drugiej strony, czy jest coś , co jest podejmowane w C ++ bez wielkich kosztów, przynajmniej wysiłku i wymaganej wiedzy w dziedzinie?
Robert Harvey
2
@RobertHarvey: Dobra uwaga. Jestem jednym z tych ludzi, którzy nie myślą wystarczająco dużo o tych sprawach. Uświadomiłem sobie, że ARC to GC, ale oczywiście nie. Więc w zasadzie (jeśli w przybliżeniu chwytam) wyjątki byłyby bałaganem (pomimo C ++) w języku, w którym usuwanie obiektów polegało na przepływie kontroli?
orome

Odpowiedzi:

33

W programowaniu wbudowanym wyjątki tradycyjnie nie były dozwolone, ponieważ narzut związany z odwijaniem stosu, który musisz zrobić, został uznany za niedopuszczalną zmienność podczas próby utrzymania wydajności w czasie rzeczywistym. Chociaż technicznie smartfony można uznać za platformy czasu rzeczywistego, są one teraz wystarczająco mocne, gdy stare ograniczenia systemów wbudowanych już tak naprawdę nie obowiązują. Po prostu podnoszę to ze względu na dokładność.

Wyjątki są często obsługiwane w funkcjonalnych językach programowania, ale są tak rzadko stosowane, że równie dobrze mogą nie być. Jednym z powodów jest leniwa ocena, która jest czasami przeprowadzana nawet w językach, które nie są domyślnie leniwe. Posiadanie funkcji, która wykonuje się na innym stosie niż miejsce, w którym została wykonana w kolejce, utrudnia określenie, gdzie umieścić program obsługi wyjątków.

Innym powodem jest to, że funkcje pierwszej klasy pozwalają na konstrukcje takie jak opcje i kontrakty terminowe, które zapewniają większą elastyczność dzięki wyjątkom składniowym. Innymi słowy, reszta języka jest na tyle wyrazista, że ​​wyjątki nic ci nie kupują.

Swift nie jest mi obyty, ale trochę przeczytałem o jego obsłudze błędów sugeruje, że zamierzali obsługiwać błędy, by podążać za wzorcami bardziej funkcjonalnymi. Widziałem przykłady kodu z blokami successi, failurektóre wyglądają bardzo podobnie do futures.

Oto przykład przy użyciu Futurez tym tutorialu Scala :

val f: Future[List[String]] = future {
  session.getRecentPosts
}
f onFailure {
  case t => println("An error has occured: " + t.getMessage)
}
f onSuccess {
  case posts => for (post <- posts) println(post)
}

Możesz zobaczyć, że ma mniej więcej taką samą strukturę jak twój przykład z wyjątkami. futureBlok jest niczym try. onFailureBlok jest jak obsługi wyjątków. W Scali, podobnie jak w większości języków funkcjonalnych, Futurejest wdrażany całkowicie przy użyciu samego języka. Nie wymaga żadnej specjalnej składni, jak wyjątki. Oznacza to, że możesz zdefiniować własne podobne konstrukcje. Może timeoutna przykład dodać blok lub funkcję rejestrowania.

Dodatkowo możesz przekazywać przyszłość, zwracać ją z funkcji, przechowywać w strukturze danych lub cokolwiek innego. To wartość pierwszej klasy. Nie jesteś ograniczony jak wyjątki, które muszą być propagowane bezpośrednio na stosie.

Opcje rozwiązują problem obsługi błędów w nieco inny sposób, co działa lepiej w niektórych przypadkach użycia. Nie utkniesz tylko z jedną metodą.

Takie rzeczy „zyskujesz, tracąc wyjątki”.

Karl Bielefeldt
źródło
1
Tak Futurejest w istocie sposobem rozpatrywania wartość zwracana z funkcji, bez zatrzymywania się czekać na niego. Podobnie jak Swift, opiera się na wartości zwracanej, ale w przeciwieństwie do Swift, odpowiedź na wartość zwracaną może wystąpić później (trochę jak wyjątki). Dobrze?
orome
1
Rozumiesz Futurepoprawnie, ale myślę, że możesz źle opisywać Swift. Zobacz na przykład pierwszą część odpowiedzi dotyczącej przepełnienia stosu.
Karl Bielefeldt
Hmm, jestem nowy w Swift, więc odpowiedź jest dla mnie trudna do przeanalizowania. Ale jeśli się nie mylę: to zasadniczo przekazuje moduł obsługi, który można wywołać w późniejszym czasie; dobrze?
orome
Tak. Zasadniczo tworzysz wywołanie zwrotne, gdy wystąpi błąd.
Karl Bielefeldt
Eitherbyłby lepszym przykładem IMHO
Paweł Prażak
19

Wyjątki mogą utrudnić zrozumienie kodu. Chociaż nie są tak potężne jak gotos, mogą powodować wiele takich samych problemów z powodu ich nielokalnego charakteru. Załóżmy na przykład, że masz fragment kodu imperatywnego takiego jak ten:

cleanMug();
brewCoffee();
pourCoffee();
drinkCoffee();

Na pierwszy rzut oka nie można stwierdzić, czy którakolwiek z tych procedur może zgłosić wyjątek. Musisz to przeczytać w dokumentacji każdej z tych procedur. (Niektóre języki ułatwiają to nieco poprzez rozszerzenie podpisu o te informacje). Powyższy kod będzie dobrze kompilował się niezależnie od tego, czy którakolwiek z procedur zostanie wygenerowana, dzięki czemu naprawdę łatwo zapomnieć o obsłudze wyjątku.

Ponadto, nawet jeśli celem jest propagowanie wyjątku z powrotem do osoby dzwoniącej, często trzeba dodać dodatkowy kod, aby zapobiec pozostawieniu rzeczy w niespójnym stanie (np. Jeśli ekspres do kawy się zepsuje, nadal musisz wyczyścić bałagan i wrócić kubek!). Tak więc w wielu przypadkach kod wykorzystujący wyjątki wyglądałby tak samo skomplikowany jak kod, który nie zrobiłby tego ze względu na dodatkowe wymagane czyszczenie.

Wyjątki można emulować za pomocą wystarczająco wydajnego systemu typów. Wiele języków, które unikają wyjątków, używa wartości zwracanych, aby uzyskać takie samo zachowanie. Jest podobny do tego w C, ale nowoczesne systemy typu zwykle sprawiają, że jest bardziej elegancki, a także trudniej zapomnieć o obsłudze błędu. Mogą również dostarczać cukier syntaktyczny, aby uczynić rzeczy mniej uciążliwymi, czasami prawie tak czystymi, jak by to było z wyjątkami.

W szczególności, poprzez osadzenie obsługi błędów w systemie typów zamiast implementacji jako osobnej funkcji, można zastosować „wyjątki” dla innych rzeczy, które nawet nie są związane z błędami. (Dobrze wiadomo, że obsługa wyjątków jest w rzeczywistości własnością monad.)

Rufflewind
źródło
Czy to prawda, że ​​taki system typów, który ma Swift, w tym opcjonalne, jest rodzajem „potężnego systemu typów”, który to osiąga?
orome
2
Tak, opcjonalne i, bardziej ogólnie, typy sum (w Swift / Rust nazywane „enum”) mogą to osiągnąć. Jednak potrzeba więcej pracy, aby były przyjemne w użyciu: w Swift można to osiągnąć dzięki opcjonalnej składni łańcuchów; w Haskell osiąga się to za pomocą notacji monadycznej.
Rufflewind
Czy „wystarczająco mocny system typu” może dać ślad stosu, jeśli nie, jest całkiem bezużyteczny
Paweł Prażak
1
+1 za wskazanie, że wyjątki przesłaniają kontrolę. Podobnie jak uwaga pomocnicza: ma uzasadnienie, czy wyjątki nie są tak naprawdę bardziej złe niż goto: gotoOgranicza się do pojedynczej funkcji, która jest dość małym zakresem, pod warunkiem, że funkcja jest naprawdę niewielka, wyjątek działa bardziej jak krzyżówka gotoi come from(patrz en.wikipedia.org/wiki/INTERCAL ;-)). Może w zasadzie łączyć dowolne dwa fragmenty kodu, prawdopodobnie pomijając niektóre trzecie funkcje. Jedyne, czego nie może zrobić, co gotomoże, to powrót.
cmaster
2
@ PawełPrażak Podczas pracy z wieloma funkcjami wyższego rzędu ślady stosu nie są tak cenne. Silne gwarancje dotyczące nakładów i wyników oraz unikanie skutków ubocznych zapobiegają powodowaniu mylących błędów przez to pośrednictwo.
Jack
11

Jest tu kilka świetnych odpowiedzi, ale myślę, że jeden ważny powód nie został wystarczająco podkreślony: kiedy zdarzają się wyjątki, obiekty mogą pozostać w nieprawidłowych stanach. Jeśli możesz „złapać” wyjątek, kod obsługi wyjątków będzie mógł uzyskać dostęp do tych nieprawidłowych obiektów i pracować z nimi. To pójdzie okropnie źle, chyba że kod dla tych obiektów zostanie napisany idealnie, co jest bardzo, bardzo trudne do zrobienia.

Wyobraź sobie na przykład implementację Vector. Jeśli ktoś utworzy instancję urządzenia Vector z zestawem obiektów, ale podczas inicjowania wystąpi wyjątek (być może, powiedzmy, podczas kopiowania obiektów do nowo przydzielonej pamięci), bardzo trudno jest poprawnie zakodować implementację Vector w taki sposób, aby nie pamięć wyciekła. Ten krótki artykuł autorstwa Stroustroup obejmuje przykład Vector .

A to tylko wierzchołek góry lodowej. Co jeśli na przykład skopiowałeś niektóre elementy, ale nie wszystkie z nich? Aby poprawnie zaimplementować kontener, taki jak Vector, prawie wszystkie operacje, które podejmujesz, muszą być odwracalne, więc cała operacja ma charakter atomowy (jak transakcja w bazie danych). Jest to skomplikowane i większość aplikacji źle to robi. I nawet jeśli jest to zrobione poprawnie, znacznie komplikuje proces wdrażania kontenera.

Dlatego niektóre współczesne języki zdecydowały, że nie warto. Na przykład Rust ma wyjątki, ale nie można ich „złapać”, więc nie ma możliwości interakcji kodu z obiektami w niepoprawnym stanie.

Charlie Flowers
źródło
1
celem catch jest ujednolicenie obiektu (lub zakończenie czegoś, jeśli nie jest to możliwe) po wystąpieniu błędu.
JoulinRouge
@JoulinRouge Wiem. Ale niektóre języki postanowiły nie dawać ci takiej możliwości, ale zamiast tego zawiesić cały proces. Ci projektanci języków znają porządek, który chcesz zrobić, ale doszli do wniosku, że jest to zbyt trudne, aby dać ci to, a kompromisy związane z tym nie byłyby tego warte. Zdaję sobie sprawę, że możesz nie zgodzić się z ich wyborem ... ale warto wiedzieć, że dokonali wyboru świadomie z tych szczególnych powodów.
Charlie Flowers,
6

Jedną rzeczą, którą początkowo byłem zaskoczony językiem Rust, jest to, że nie obsługuje wyjątków catch. Możesz zgłaszać wyjątki, ale tylko środowisko wykonawcze może je wychwycić, gdy umiera zadanie (pomyśl wątek, ale nie zawsze osobny wątek systemu operacyjnego); jeśli sam zaczniesz zadanie, możesz zapytać, czy zakończyło się normalnie, czy też zostało fail!()edytowane.

Jako taki failczęsto nie jest idiomatyczny . Kilka przypadków, w których tak się dzieje, to na przykład uprząż testowa (która nie wie, jaki jest kod użytkownika), jako najwyższy poziom kompilatora (większość kompilatorów zamiast tego wykonuje rozwidlenie) lub podczas wywoływania wywołania zwrotnego na wejście użytkownika.

Zamiast wspólnej idiom jest użycie ten Resultszablon jawnie przepuścić błędy, które powinny być traktowane. Jest to znacznie łatwiejsze, na try!makro , który może być owinięty wokół każdego ekspresję, który daje w wyniku i plonów pomyślne ramię jeżeli istnieje, albo w przeciwnym przypadku przejście wcześniej z tej funkcji.

use std::io::IoResult;
use std::io::File;

fn load_file(name: &Path) -> IoResult<String>
{
    let mut file = try!(File::open(name));
    let s = try!(file.read_to_string());
    return Ok(s);
}

fn main()
{
    print!("{}", load_file(&Path::new("/tmp/hello")).unwrap());
}
o11c
źródło
1
Czy można więc powiedzieć, że to (podobnie jak podejście Go) jest podobne do Swift, który ma assert, ale nie catch?
orome
1
W Swift spróbuj! oznacza: Tak, wiem, że to może się nie powieść, ale jestem pewien, że tak się nie stanie, więc nie radzę sobie z tym, a jeśli to się nie powiedzie, mój kod jest nieprawidłowy, więc w takim przypadku proszę o awarię.
gnasher729,
6

Moim zdaniem wyjątki są niezbędnym narzędziem do wykrywania błędów kodu w czasie wykonywania. Zarówno w testach, jak i podczas produkcji. Spraw, aby ich wiadomości były wystarczająco szczegółowe, aby w połączeniu ze śledzeniem stosu można było dowiedzieć się, co się stało z dziennika.

Wyjątki stanowią głównie narzędzie programistyczne i sposób uzyskiwania rozsądnych raportów o błędach z produkcji w nieoczekiwanych przypadkach.

Oprócz oddzielenia problemów (szczęśliwa ścieżka z oczekiwanymi błędami w porównaniu do wpadnięcia do jakiegoś ogólnego programu obsługi nieoczekiwanych błędów) jest dobra rzecz, dzięki czemu twój kod jest bardziej czytelny i łatwy w utrzymaniu, w rzeczywistości niemożliwe jest przygotowanie kodu na wszystkie możliwe nieoczekiwane przypadki, nawet nadmuchując go kodem obsługi błędów, aby zakończyć nieczytelność.

To właściwie oznacza „nieoczekiwany”.

Btw., Czego się spodziewać, a czego nie, to decyzja, którą można podjąć tylko na stronie połączenia. Dlatego sprawdzone wyjątki w Javie nie zadziałały - decyzja jest podejmowana w momencie tworzenia API, kiedy nie jest jasne, czego się spodziewać lub nieoczekiwanego.

Prosty przykład: interfejs API mapy skrótów może mieć dwie metody:

Value get(Key)

i

Option<Value> getOption(key)

pierwszy zgłasza wyjątek, jeśli go nie znaleziono, ten drugi daje wartość opcjonalną. W niektórych przypadkach ten drugi ma większy sens, ale w innych kod musi po prostu oczekiwać wartości dla danego klucza, więc jeśli go nie ma, jest to błąd, którego ten kod nie może naprawić, ponieważ podstawowy założenie się nie powiodło. W tym przypadku pożądane jest wypadnięcie ze ścieżki kodu i przejście do jakiejś ogólnej procedury obsługi w przypadku niepowodzenia wywołania.

Kod nigdy nie powinien próbować radzić sobie z nieudanymi podstawowymi założeniami.

Oczywiście z wyjątkiem sprawdzania ich i rzucania dobrze czytelnych wyjątków.

Rzucanie wyjątków nie jest złem, ale ich złapanie może być. Nie próbuj naprawiać nieoczekiwanych błędów. Złap wyjątki w kilku miejscach, w których chcesz kontynuować pętlę lub operację, zaloguj je, być może zgłoś nieznany błąd i to wszystko.

Bloki połowowe w tym miejscu to bardzo zły pomysł.

Zaprojektuj swoje interfejsy API w taki sposób, aby ułatwić wyrażenie swojej woli, tj. Oświadczenie, czy oczekujesz określonego przypadku, na przykład brak klucza, czy nie. Użytkownicy Twojego interfejsu API mogą wtedy wybrać wywołanie wyrzucające tylko w naprawdę nieoczekiwanych przypadkach.

Sądzę, że powodem, dla którego ludzie nie lubią wyjątków i idą za daleko, pomijając to kluczowe narzędzie do automatyzacji obsługi błędów i lepszego oddzielania problemów od nowych języków, są złe doświadczenia.

To i pewne nieporozumienia na temat tego, do czego są naprawdę dobre.

Symulacja ich poprzez wykonanie WSZYSTKIEGO poprzez monadowe wiązanie sprawia, że ​​kod jest mniej czytelny i łatwy do utrzymania, a ty kończysz się bez śladu stosu, co pogarsza to podejście.

Obsługa błędów funkcjonalnych jest świetna w przypadku oczekiwanych błędów.

Niech obsługa wyjątków automatycznie zajmie się całą resztą, właśnie po to :)

szlachetka
źródło
3

Swift stosuje tutaj te same zasady, co Cel C, a tym bardziej konsekwentnie. W celu C wyjątki wskazują na błędy programowania. Nie są obsługiwane przez narzędzia do zgłaszania awarii. „Obsługa wyjątków” odbywa się poprzez naprawienie kodu. (Istnieje kilka wyjątków. Na przykład w komunikacji międzyprocesowej. Jest to jednak dość rzadkie i wiele osób nigdy na nią nie natrafia. A Objective-C faktycznie próbuje / łapie / w końcu / rzuca, ale rzadko ich używasz). Swift właśnie usunął możliwość wyłapywania wyjątków.

Swift ma funkcję, która wygląda jak obsługa wyjątków, ale jest po prostu wymuszona obsługa błędów. Historycznie, Objective-C miał dość powszechny wzorzec obsługi błędów: metoda zwracała BOOL (TAK dla sukcesu) lub odwołanie do obiektu (zero dla niepowodzenia, nie zero dla sukcesu) i miała parametr „wskaźnik do NSError *” który byłby używany do przechowywania odwołania NSError. Swift automatycznie przekształca wywołania takiej metody w coś, co wygląda jak obsługa wyjątków.

Ogólnie rzecz biorąc, funkcje Swift mogą łatwo zwracać alternatywy, na przykład wynik, jeśli funkcja działała dobrze i błąd, jeśli się nie powiódł; znacznie ułatwia to obsługę błędów. Ale odpowiedź na pierwotne pytanie: projektanci Swift najwyraźniej uważali, że stworzenie bezpiecznego języka i pisanie udanego kodu w takim języku jest łatwiejsze, jeśli język nie ma wyjątków.

gnasher729
źródło
Dla Swift jest to poprawna odpowiedź, IMO. Swift musi pozostać kompatybilny z istniejącymi strukturami systemu Objective-C, więc pod maską nie mają tradycyjnych wyjątków. Jakiś czas temu napisałem post na blogu o tym, jak ObjC działa w zakresie obsługi błędów: orangejuiceliberationfront.com/…
uliwitness
2
 int result;
 if((result = operation_that_can_throw_ioerror()) == IOError)
 {
  handle_the_exception_somehow();
 }
 else
 {
   # we don't want to catch the IOError if it's raised
   result = another_operation_that_can_throw_ioerror();
 }
 result |= something_we_always_need_to_do();
 return result;

W C skończyłbyś z czymś takim jak wyżej.

Czy w Swift są rzeczy, których nie mogę zrobić z wyjątkami?

Nie, nic nie ma. Po prostu obsługujesz kody wynikowe zamiast wyjątków.
Wyjątki pozwalają na reorganizację kodu, tak aby obsługa błędów była oddzielna od kodu szczęśliwej ścieżki, ale o to chodzi.

kamieniste
źródło
Podobnie, te wezwania do ...throw_ioerror()zwracania błędów zamiast zgłaszania wyjątków?
orome
1
@raxacoricofallapatorius Jeśli twierdzimy, że wyjątki nie istnieją, zakładam, że program stosuje zwykły wzorzec zwracania kodów błędów w przypadku niepowodzenia i 0 w przypadku sukcesu.
stonemetal
1
@stonemetal Niektóre języki, takie jak Rust i Haskell, używają systemu typów do zwracania czegoś bardziej znaczącego niż kod błędu, bez dodawania ukrytych punktów wyjścia, tak jak robią to wyjątki. Funkcja Rust może na przykład zwracać Result<T, E>wyliczenie, które może być albo Ok<T>, albo an Err<E>, z Ttypem, który chcesz, jeśli istnieje, i Etypem reprezentującym błąd. Dopasowywanie wzorców i niektóre szczególne metody upraszczają obsługę zarówno sukcesów, jak i niepowodzeń. Krótko mówiąc, nie zakładaj, że brak wyjątków automatycznie oznacza kody błędów.
8bittree
1

Oprócz odpowiedzi Charliego:

Te przykłady zadeklarowanej obsługi wyjątków, które można znaleźć w wielu instrukcjach i książkach, wyglądają bardzo elegancko tylko na bardzo małych przykładach.

Nawet jeśli odłożysz na bok argument o nieprawidłowym stanie obiektu, zawsze powodują one naprawdę ogromny ból podczas obsługi dużej aplikacji.

Na przykład, gdy masz do czynienia z IO, używając niektórych kryptografii, możesz mieć 20 rodzajów wyjątków, które można wyrzucić z 50 metod. Wyobraź sobie, ile kodu obsługi wyjątków potrzebujesz. Obsługa wyjątków zajmie kilka razy więcej kodu niż sam kod.

W rzeczywistości wiesz, kiedy wyjątek nie może się pojawić, i po prostu nigdy nie musisz pisać tak dużo obsługi wyjątków, więc po prostu zastosuj obejścia, aby zignorować zadeklarowane wyjątki. W mojej praktyce tylko około 5% deklarowanych wyjątków wymaga obsługi kodu, aby mieć niezawodną aplikację.

konmik
źródło
Cóż, w rzeczywistości wyjątki te można często rozwiązać w jednym miejscu. Na przykład w funkcji „pobierz aktualizację danych”, jeśli SSL zawiedzie lub DNS jest nie do rozwiązania lub serwer sieciowy zwraca 404, nie ma znaczenia, złap go u góry i zgłoś błąd użytkownikowi.
Zan Lynx,