Dlaczego warto skorzystać z wyjątku Over (sprawdzone)?

17

Nie tak dawno temu zacząłem używać Scali zamiast Javy. Częścią procesu „konwersji” między językami była dla mnie nauka używania Eithers zamiast (zaznaczonych) Exceptions. Kodowałem w ten sposób od jakiegoś czasu, ale ostatnio zacząłem się zastanawiać, czy to naprawdę lepszy sposób.

Jedną z głównych korzyści Eitherjest przez Exceptionto lepsze wyniki; Exceptionmusi zbudować stosu śladu i jest wyrzucane. O ile rozumiem, rzucanie Exceptionnie jest wymagającą częścią, ale budowanie śladu stosu jest.

Ale wtedy zawsze można skonstruować / dziedziczą Exceptionsz scala.util.control.NoStackTrace, a jeszcze bardziej, widzę mnóstwo przypadków, gdzie lewa strona Eitherjest w rzeczywistości Exception(rezygnując z zwiększenie wydajności).

Kolejną zaletą Eitherjest bezpieczeństwo kompilatora; kompilator Scala nie będzie narzekał na nieobsługiwane Exceptions (w przeciwieństwie do kompilatora Java). Ale jeśli się nie mylę, ta decyzja jest uzasadniona takim samym uzasadnieniem, jak omawiane w tym temacie, więc ...

Pod względem składni wydaje mi się, że Exception-style jest o wiele jaśniejszy. Sprawdź następujące bloki kodu (oba osiągają tę samą funkcjonalność):

Either styl:

def compute(): Either[String, Int] = {

    val aEither: Either[String, String] = if (someCondition) Right("good") else Left("bad")

    val bEithers: Iterable[Either[String, Int]] = someSeq.map {
        item => if (someCondition(item)) Right(item.toInt) else Left("bad")
    }

    for {
        a <- aEither.right
        bs <- reduce(bEithers).right
        ignore <- validate(bs).right
    } yield compute(a, bs)
}

def reduce[A,B](eithers: Iterable[Either[A,B]]): Either[A, Iterable[B]] = ??? // utility code

def validate(bs: Iterable[Int]): Either[String, Unit] = if (bs.sum > 22) Left("bad") else Right()

def compute(a: String, bs: Iterable[Int]): Int = ???

Exception styl:

@throws(classOf[ComputationException])
def compute(): Int = {

    val a = if (someCondition) "good" else throw new ComputationException("bad")
    val bs = someSeq.map {
        item => if (someCondition(item)) item.toInt else throw new ComputationException("bad")
    }

    if (bs.sum > 22) throw new ComputationException("bad")

    compute(a, bs)
}

def compute(a: String, bs: Iterable[Int]): Int = ???

Ta ostatnia wygląda dla mnie o wiele czystiej, a kod obsługujący awarię (albo dopasowanie wzorca na Eitherlub try-catch) jest dość jasny w obu przypadkach.

Więc moje pytanie brzmi - po co używać Either(sprawdzone) Exception?

Aktualizacja

Po przeczytaniu odpowiedzi zdałem sobie sprawę, że mogłem nie przedstawić sedna mojego dylematu. Moim zmartwieniem nie jest brak try-catch; można albo „złapać” za Exceptionpomocą Try, albo użyć catchdo owinięcia wyjątku Left.

Mój główny problem z Either/ Trypojawia się, gdy piszę kod, który może zawieść w wielu punktach po drodze; w tych scenariuszach, gdy napotykam awarię, muszę propagować tę awarię w całym moim kodzie, przez co kod jest znacznie bardziej kłopotliwy (jak pokazano we wspomnianych przykładach).

Istnieje inny sposób na złamanie kodu bez użycia Exceptions return(co w rzeczywistości jest kolejnym „tabu” w Scali). Kod byłby jeszcze bardziej przejrzysty niż Eitherpodejście, a chociaż byłby nieco mniej czysty niż Exceptionstyl, nie obawiałby się, że nie zostanie złapany Exception.

def compute(): Either[String, Int] = {

  val a = if (someCondition) "good" else return Left("bad")

  val bs: Iterable[Int] = someSeq.map {
    item => if (someCondition(item)) item.toInt else return Left("bad")
  }

  if (bs.sum > 22) return Left("bad")

  val c = computeC(bs).rightOrReturn(return _)

  Right(computeAll(a, bs, c))
}

def computeC(bs: Iterable[Int]): Either[String, Int] = ???

def computeAll(a: String, bs: Iterable[Int], c: Int): Int = ???

implicit class ConvertEither[L, R](either: Either[L, R]) {

  def rightOrReturn(f: (Left[L, R]) => R): R = either match {
    case Right(r) => r
    case Left(l) => f(Left(l))
  }
}

Zasadniczo return Leftzamienia throw new Exceptioni niejawna metoda na obu rightOrReturn, jest dodatkiem do automatycznej propagacji wyjątków w górę stosu.

Eyal Roth
źródło
Przeczytaj to: mauricio.github.io/2014/02/17/…
Robert Harvey
@RobertHarvey Wydaje się, że większość omawianych artykułów Try. W części dotyczącej Eithervs Exceptionpodano jedynie, że Eithernależy użyć, gdy drugi przypadek metody jest „nietypowy”. Po pierwsze, jest to bardzo, bardzo niejasne imho definicja. Po drugie, czy naprawdę jest warta kary składni? To znaczy, naprawdę nie miałbym nic przeciwko użyciu Eithers, gdyby nie narzut, jaki prezentują składnie.
Eyal Roth,
Eitherdla mnie wygląda jak monada. Używaj go, gdy potrzebujesz funkcjonalnych korzyści z kompozycji, jakie zapewniają monady. A może nie .
Robert Harvey
2
@RobertHarvey: Mówiąc technicznie, Eithersam w sobie nie jest monadą. Projekcja albo do lewej lub prawej stronie jest monada, ale Eithersama w sobie nie jest. Możesz zrobić z niego monadę, „odchylając” ją jednak do lewej lub prawej strony. Jednak wtedy przekazujesz pewien semantyczny po obu stronach Either. Scala Eitherbyła pierwotnie bezstronna, ale ostatnio była tendencyjna, tak że obecnie jest w rzeczywistości monadą, ale „monadność” nie jest nieodłączną właściwością, Eitherale raczej wynikiem uprzedzeń.
Jörg W Mittag,
@ JörgWMittag: Cóż, dobrze. Oznacza to, że możesz go używać dla całej swojej monadycznej dobroci i nie przejmuj się szczególnie tym, jak „czysty” jest wynikowy kod (chociaż podejrzewam, że kod kontynuacji w stylu funkcjonalnym byłby faktycznie czystszy niż wersja Exception).
Robert Harvey

Odpowiedzi:

13

Jeśli użyjesz tylko bloku Eitherimperatywnego try-catch, będzie to oczywiście wyglądać jak inna składnia, aby zrobić dokładnie to samo. Eitherswartością . Nie mają takich samych ograniczeń. Możesz:

  • Przestań nawyk utrzymywania małych bloków prób i ciągłości. Część „catch” Eithersnie musi znajdować się tuż obok części „try”. Może być nawet współdzielony we wspólnej funkcji. To znacznie ułatwia prawidłowe rozdzielanie problemów.
  • Przechowuj Eithersw strukturach danych. Możesz je przeglądać i konsolidować komunikaty o błędach, znajdować pierwszy, który nie zawiódł, leniwie je oceniać lub podobnie.
  • Rozszerz i dostosuj je. Nie podoba ci się jakiś szablon, którym musisz pisać Eithers? Możesz napisać funkcje pomocnicze, aby ułatwić ich obsługę w określonych przypadkach użycia. W przeciwieństwie do try-catch, nie utkniesz na stałe tylko z domyślnym interfejsem wymyślonym przez projektantów języka.

Uwaga dla większości przypadków użycia, Tryma prostszy interfejs, ma większość powyższych zalet i zwykle spełnia również twoje potrzeby. Jest Optionto jeszcze prostsze, jeśli liczy się tylko sukces lub porażka i nie potrzebujesz żadnych informacji o stanie, takich jak komunikaty o błędach dotyczące przypadku awarii.

Z mojego doświadczenia Eithersnie są tak naprawdę preferowane, Tryschyba że Leftjest to coś innego niż Exception, być może komunikat o błędzie sprawdzania poprawności, i chcesz agregować Leftwartości. Na przykład przetwarzasz niektóre dane wejściowe i chcesz zebrać wszystkie komunikaty o błędach, a nie tylko błąd pierwszego. Takie sytuacje nie zdarzają się często w praktyce, przynajmniej dla mnie.

Spójrz poza model try-catch, a będziesz o Eitherswiele przyjemniejszy w pracy.

Aktualizacja: to pytanie wciąż przyciąga uwagę i nie widziałem aktualizacji OP wyjaśniającej pytanie składniowe, więc pomyślałem, że dodam więcej.

Jako przykład wyjścia z myślenia wyjątkowego, tak zazwyczaj napisałbym twój przykładowy kod:

def compute(): Either[String, Int] = {
  Right(someSeq)
    .filterOrElse(someACondition,            "someACondition failed")
    .filterOrElse(_ forall someSeqCondition, "someSeqCondition failed")
    .map(_ map {_.toInt})
    .filterOrElse(_.sum <= 22, "Sum is greater than 22")
    .map(compute("good",_))
}

Tutaj widać, że semantyka filterOrElseidealnego uchwycenia tego, co robimy przede wszystkim w tej funkcji: sprawdzanie warunków i kojarzenie komunikatów o błędach z danymi awariami. Ponieważ jest to bardzo częsty przypadek użycia, filterOrElseznajduje się w standardowej bibliotece, ale gdyby tak nie było, można go utworzyć.

To jest punkt, w którym ktoś wymyślił kontrprzykład, którego nie da się rozwiązać dokładnie tak samo jak mój, ale próbuję nadmienić, że nie ma tylko jednego sposobu użycia Eithers, w przeciwieństwie do wyjątków. Istnieją sposoby na uproszczenie kodu w większości sytuacji związanych z obsługą błędów, jeśli zapoznasz się ze wszystkimi funkcjami dostępnymi Eithersi jesteś otwarty na eksperymenty.

Karl Bielefeldt
źródło
1. Możliwość oddzielenia tryi catchjest uczciwym argumentem, ale czy nie można tego łatwo osiągnąć Try? 2. Przechowywanie Eitherw strukturach danych może odbywać się równolegle z throwsmechanizmem; czy nie jest to po prostu dodatkowa warstwa nad błędami w obsłudze? 3. Zdecydowanie możesz rozszerzyć Exceptions. Jasne, nie możesz zmienić try-catchstruktury, ale obsługa awarii jest tak powszechna, że ​​zdecydowanie chcę, aby język dał mi do tego podstawową płytę (której nie jestem zmuszony używać). To tak, jakby powiedzieć, że niezrozumienie jest złe, ponieważ stanowi podstawę dla operacji monadycznych.
Eyal Roth,
Właśnie zdałem sobie sprawę, że powiedziałeś, że Eithermożna rozszerzyć, tam gdzie wyraźnie nie mogą (są sealed traittylko dwie implementacje, oba final). Czy możesz to rozwinąć? Zakładam, że nie miałeś na myśli dosłownego znaczenia rozszerzenia.
Eyal Roth,
1
Miałem na myśli rozszerzony jak w angielskim słowie, a nie słowo kluczowe programowania. Dodanie funkcjonalności poprzez utworzenie funkcji do obsługi typowych wzorców.
Karl Bielefeldt,
8

Oba są bardzo różne. EitherSemantyka jest znacznie bardziej ogólna niż tylko reprezentowanie potencjalnie nieudanych obliczeń.

Eitherpozwala zwrócić jeden z dwóch różnych typów. Otóż ​​to.

Teraz jeden z tych dwóch typów może , ale nie musi, reprezentować błąd, a drugi może , ale nie musi, reprezentować sukces, ale jest to tylko jeden z możliwych przypadków użycia wielu. Eitherjest o wiele bardziej ogólny niż to. Równie dobrze możesz mieć metodę, która odczytuje dane wejściowe od użytkownika, który może albo wprowadzić nazwę, albo zwrócić datę Either[String, Date].

Lub pomyśl o literałach lambda w C♯: albo oceniają na an Funclub Expression, tzn. Albo oceniają na anonimową funkcję, albo oceniają na AST. W C♯ dzieje się to „magicznie”, ale jeśli chcesz nadać mu odpowiedni typ, byłoby to możliwe Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>>. Jednak żadna z tych dwóch opcji nie jest błędem i żadna z dwóch nie jest ani „normalna”, ani „wyjątkowa”. Są to tylko dwa różne typy, które mogą zostać zwrócone z tej samej funkcji (lub w tym przypadku przypisane do tego samego literału). [Uwaga: w rzeczywistości Funcmusi być podzielony na dwa kolejne przypadki, ponieważ C♯ nie ma Unittypu, więc coś, co niczego nie zwraca, nie może być reprezentowane w ten sam sposób, co coś, co zwraca,Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>>

Teraz Either można go użyć do przedstawienia typu sukcesu i typu niepowodzenia. A jeśli zgodzisz się na spójne uporządkowanie tych dwóch typów, możesz nawet stronniczość Either preferować jeden z tych dwóch typów, umożliwiając w ten sposób rozprzestrzenianie awarii poprzez łańcuch monadyczny. Ale w takim przypadku przekazujesz pewien semantyczny Eitherfakt, że nie musi on sam w sobie posiadać.

Ten, Eitherktóry ma jeden z dwóch typów ustalony jako typ błędu i jest stronniczy w celu propagowania błędów, jest zwykle nazywany Errormonadą i byłby lepszym wyborem do przedstawienia potencjalnie nieudanego obliczenia. W Scali ten rodzaj jest nazywany Try.

Znacznie bardziej sensowne jest porównywanie użycia wyjątków z Tryniż Either.

Jest to zatem powód nr 1, dlaczego Eithermożna go stosować w stosunku do sprawdzonych wyjątków: Eitherjest znacznie bardziej ogólny niż wyjątki. Może być stosowany w przypadkach, w których wyjątki nawet nie mają zastosowania.

Najprawdopodobniej jednak pytałeś o ograniczony przypadek, w którym po prostu używasz Either(lub bardziej prawdopodobne Try) jako zamiennika wyjątków. W takim przypadku nadal istnieją pewne zalety, a raczej możliwe różne podejścia.

Główną zaletą jest to, że można odroczyć obsługę błędu. Po prostu przechowujesz wartość zwracaną, nawet nie sprawdzając, czy jest to sukces, czy błąd. (Poradzę sobie później.) Lub przekaż to gdzie indziej. (Niech ktoś się tym martwi.) Zbierz je na liście. (Obierz je wszystkie naraz.) Możesz użyć łańcuchów monadycznych, aby propagować je wzdłuż potoku przetwarzania.

Semantyka wyjątków jest wbudowana w język. W Scali, na przykład, wyjątki rozwijają stos. Jeśli zezwolisz im na przeniesienie się do procedury obsługi wyjątku, program obsługi nie może wrócić do miejsca, w którym się zdarzyło, i naprawić. OTOH, jeśli złapiesz go niżej na stosie przed rozwinięciem go i zniszczeniem niezbędnego kontekstu, aby go naprawić, możesz znajdować się w komponencie, który jest na zbyt niskim poziomie, aby podjąć świadomą decyzję o tym, jak postępować.

Inaczej jest w Smalltalk, na przykład, gdy wyjątki nie odprężyć stosu i są wznawialną i można złapać wyjątek wysoko w górę w stosie (ewentualnie tak wysoko jak debugger), naprawić błąd waaaaayyyyy dół w stos i wznów wykonywanie stamtąd. Lub warunki CommonLisp, w których podnoszenie, łapanie i obsługa to trzy różne rzeczy zamiast dwóch (podnoszenie i chwytanie), jak w wyjątkach.

Użycie rzeczywistego typu zwracanego błędu zamiast wyjątku pozwala robić podobne rzeczy i więcej, bez konieczności modyfikacji języka.

A potem w pokoju jest oczywisty słoń: wyjątki są efektem ubocznym. Skutki uboczne są złe. Uwaga: nie mówię, że to koniecznie prawda. Ale jest to punkt widzenia niektórych osób, w takim przypadku przewaga typu błędu nad wyjątkami jest oczywista. Tak więc, jeśli chcesz wykonać programowanie funkcjonalne, możesz użyć typów błędów tylko z tego powodu, że są to podejście funkcjonalne. (Pamiętaj, że Haskell ma wyjątki. Pamiętaj też, że nikt nie lubi ich używać).

Jörg W Mittag
źródło
Uwielbiam odpowiedź, chociaż wciąż nie rozumiem, dlaczego miałbym rezygnować z Exceptionodpowiedzi. Eitherwydaje się świetnym narzędziem, ale tak, konkretnie pytam o obsługę awarii i wolę użyć do mojego zadania bardziej szczegółowego narzędzia niż wszechmocnego. Odroczenie obsługi błędów jest słusznym argumentem, ale można po prostu użyć Trymetody rzucającej i, Exceptionjeśli nie chce się w tej chwili z tym obchodzić. Czy odwijanie śledzenia stosu nie wpływa również na Either/ Try? Możesz powtórzyć te same błędy, niepoprawnie je propagując; wiedza, kiedy poradzić sobie z awarią, nie jest trywialna w obu przypadkach.
Eyal Roth,
I tak naprawdę nie mogę się kłócić z „czysto funkcjonalnym” rozumowaniem, prawda?
Eyal Roth,
0

Zaletą wyjątków jest to, że kod źródłowy sklasyfikował już wyjątkową okoliczność. Różnica między IllegalStateExceptiona a BadParameterExceptionjest znacznie łatwiejsza do odróżnienia w silniejszych językach pisanych. Jeśli spróbujesz parsować „dobre” i „złe”, nie korzystasz z niezwykle potężnego narzędzia, które masz do dyspozycji, a mianowicie kompilatora Scala. Własne rozszerzenia Throwableoprócz tekstu ciąg wiadomości mogą zawierać tyle informacji, ile potrzebujesz.

Jest to prawdziwa użyteczność Tryw środowisku Scala, Throwableponieważ działa jak opakowanie na lewe przedmioty, aby ułatwić życie.

BobDalgleish
źródło
2
Nie rozumiem twojego punktu widzenia.
Eyal Roth,
Eitherma problemy ze stylem, ponieważ nie jest to prawdziwa monada (?); arbitralność leftwartości odwraca się od wyższej wartości dodanej, gdy odpowiednio zawrzesz informacje o wyjątkowych warunkach w odpowiedniej strukturze. Tak więc porównanie użycia Eitherw jednym przypadku zwracania ciągów po lewej stronie, try/catchw drugim przypadku użycie poprawnie wpisanego tekstu Throwables nie jest właściwe. Ponieważ wartości Throwablesza dostarczanie informacji oraz sposób zwięzły, że Tryuchwyty Throwables, Tryjest to lepsze niż albo ... Eitheralbotry/catch
BobDalgleish
Właściwie to nie martwię się o try-catch. Jak powiedzieliśmy w pytaniu, ten ostatni wygląda dla mnie o wiele czystiej, a kod obsługujący błąd (albo dopasowywanie wzorca na Either albo try-catch) jest dość jasny w obu przypadkach.
Eyal Roth,