Perché utilizzare Eccezione sopra (controllata)?

11

Non molto tempo fa ho iniziato a utilizzare Scala anziché Java. Parte del processo di "conversione" tra le lingue per me stava imparando a usare Either s invece di (controllato) Exception s. Ho scritto questo codice per un po ', ma recentemente ho iniziato a chiedermi se fosse davvero un modo migliore di andare.

Uno dei principali vantaggi Either ha oltre Exception è una prestazione migliore; un Exception deve compilare una traccia dello stack e viene lanciato. Per quanto ho capito, però, il lancio di Exception non è la parte più impegnativa, ma è la costruzione della traccia dello stack.

Ma poi, si può sempre costruire / ereditare Exception s con scala.util.control.NoStackTrace , e ancora di più, vedo un sacco di casi in cui il lato sinistro di Either è in effetti un Exception (in attesa del incremento delle prestazioni).

Un altro vantaggio Either ha è compilatore-sicurezza; il compilatore di Scala non si lamenterà della percentuale non gestita di Exception s (diversamente dal compilatore di Java). Ma se non sbaglio, questa decisione è ragionata con lo stesso ragionamento che viene discusso in questo argomento, quindi ...

In termini di sintassi, mi sembra che Exception -style sia molto più chiaro. Esaminare i seguenti blocchi di codice (entrambi con la stessa funzionalità):

Either stile:

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 stile:

@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 = ???

Quest'ultimo aspetto mi sembra molto più pulito e il codice che gestisce l'errore (sia la corrispondenza del modello su Either o try-catch ) è abbastanza chiaro in entrambi i casi.

Quindi la mia domanda è: perché usare Either sopra (controllato) Exception ?

Aggiorna

Dopo aver letto le risposte, mi sono reso conto che avrei potuto non presentare il nocciolo del mio dilemma. La mia preoccupazione è non con la mancanza di try-catch ; puoi "catturare" un Exception con Try , oppure usare catch per avvolgere l'eccezione con Left .

Il mio problema principale con Either / Try arriva quando scrivo codice che potrebbe fallire in molti punti lungo la strada; in questi scenari, quando si verifica un errore, devo propagare tale errore nell'intero mio codice, rendendo il codice molto più ingombrante (come mostrato negli esempi precedenti).

In realtà c'è un altro modo di infrangere il codice senza Exception s usando return (che di fatto è un altro "tabù" in Scala). Il codice sarebbe ancora più chiaro dell'approccio Either e, pur essendo un po 'meno pulito dello stile Exception , non ci sarebbe alcun timore di non catturato Exception s.

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

Sostanzialmente return Left sostituisce throw new Exception e il metodo implicito su entrambi, rightOrReturn , è un supplemento per la propagazione automatica delle eccezioni nello stack.

    
posta Eyal Roth 08.09.2016 - 18:14
fonte

3 risposte

13

Se utilizzi solo Either esattamente come un blocco imperativo try-catch , ovviamente sembrerà una sintassi diversa per fare esattamente la stessa cosa. Eithers è un valore . Non hanno gli stessi limiti. Puoi:

  • Smetti di abituarti a mantenere i tuoi blocchi di prova piccoli e contigui. La parte "catch" di Eithers non deve necessariamente essere accanto alla parte "try". Può anche essere condiviso in una funzione comune. Questo rende molto più facile separare le preoccupazioni correttamente.
  • Memorizza Eithers nelle strutture dati. Puoi eseguirne il looping e consolidare i messaggi di errore, trovare il primo che non ha esito negativo, valutarli pigramente o simili.
  • Estendi e personalizzali. Non ti piace qualche testo standard che sei costretto a scrivere con Eithers ? Puoi scrivere le funzioni di supporto per renderle più facili da utilizzare per i tuoi casi d'uso specifici. A differenza del try-catch, non sei permanentemente bloccato solo con l'interfaccia predefinita che i designer di linguaggio hanno creato.

Nota per la maggior parte dei casi d'uso, un Try ha un'interfaccia più semplice, ha la maggior parte dei vantaggi di cui sopra e di solito soddisfa anche le tue esigenze. Un Option è ancora più semplice se tutto quello che ti interessa è il successo o il fallimento e non hai bisogno di informazioni sullo stato come messaggi di errore sul caso di errore.

Nella mia esperienza, Eithers non è realmente preferito su Trys a meno che Left non sia qualcosa di diverso da Exception , forse un messaggio di errore di convalida e desideri aggregare i valori di Left . Ad esempio, stai elaborando qualche input e vuoi raccogliere tutti i messaggi di errore, non solo l'errore sul primo. Quelle situazioni non si presentano molto spesso nella pratica, almeno per me.

Guarda oltre il modello try-catch e troverai Eithers molto più piacevole con cui lavorare.

Aggiornamento: questa domanda sta ancora ottenendo attenzione, e non avevo visto l'aggiornamento dell'OP chiarire la domanda di sintassi, quindi ho pensato di aggiungere altro.

Come esempio di rottura dell'eccezionale mentalità, questo è il modo in cui tipicamente scrivo il codice di esempio:

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",_))
}

Qui puoi vedere che la semantica di filterOrElse cattura perfettamente ciò che stiamo facendo principalmente in questa funzione: controllando le condizioni e associando i messaggi di errore con i guasti dati. Poiché questo è un caso d'uso molto comune, filterOrElse è nella libreria standard, ma se non lo fosse, potresti crearlo.

Questo è il punto in cui qualcuno presenta un contro-esempio che non può essere risolto esattamente come il mio, ma il punto che sto cercando di fare è che non c'è solo un modo per usare Eithers , a differenza delle eccezioni. Ci sono modi per semplificare il codice per la maggior parte delle situazioni di gestione degli errori, se esplori tutte le funzioni disponibili da utilizzare con Eithers e sono aperte alla sperimentazione.

    
risposta data 08.09.2016 - 19:17
fonte
6

I due sono in realtà molto diversi. La semantica di Either è molto più generica del semplice per rappresentare i calcoli potenzialmente in errore.

Either ti consente di restituire uno di due tipi diversi. Questo è tutto.

Ora, uno di questi due tipi potrebbe o potrebbe non rappresentare un errore e l'altro potrebbe o potrebbe non rappresentare un successo, ma questo è solo un possibile caso d'uso molti. Either è molto, molto più generale di quello. Potresti anche avere un metodo che legge l'input dell'utente che può inserire un nome o una data restituire un Either[String, Date] .

Oppure, pensa ai letterali lambda in C: valutano a Func o a Expression , cioè valutano una funzione anonima o valutano un AST. In C♯, questo accade "magicamente", ma se si volesse dare un tipo corretto ad esso, sarebbe Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>> . Tuttavia, nessuna delle due opzioni è un errore e nessuno dei due è "normale" o "eccezionale". Sono solo due tipi diversi che possono essere restituiti dalla stessa funzione (o in questo caso, attribuiti allo stesso valore letterale). [Nota: in realtà, Func deve essere diviso in altri due casi, perché C♯ non ha un tipo Unit , quindi qualcosa che non restituisce nulla non può essere rappresentato allo stesso modo di qualcosa che restituisce qualcosa , quindi il tipo effettivo sarebbe più simile a Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>> .]

Ora, Either può essere utilizzato per rappresentare un tipo di successo e un tipo di errore. E se sei d'accordo su un ordinamento coerente dei due tipi, puoi anche bias Either per preferire uno dei due tipi, rendendo così possibile propagare i fallimenti attraverso il concatenamento monadico. Ma in tal caso, stai impartendo una certa semantica su Either che non necessariamente possiede da sola.

Un Either che ha uno dei suoi due tipi corretto per essere un tipo di errore ed è polarizzato da un lato per propagare gli errori, è solitamente chiamato Error monad, e sarebbe una scelta migliore per rappresentare un potenziale errore calcolo. In Scala, questo tipo di tipo è chiamato Try .

Ha molto più senso confrontare l'uso delle eccezioni con Try rispetto a Either .

Quindi, questo è il motivo n. 1 perché Either potrebbe essere utilizzato per le eccezioni controllate: Either è molto più generale delle eccezioni. Può essere utilizzato nei casi in cui le eccezioni non si applicano nemmeno.

Tuttavia, probabilmente ti chiedevi del caso limitato in cui utilizzi solo Either (o più probabilmente Try ) come sostituzione per le eccezioni. In quel caso, ci sono ancora alcuni vantaggi, o piuttosto approcci diversi possibili.

Il vantaggio principale è che è possibile posticipare la gestione dell'errore. È sufficiente memorizzare il valore restituito, senza nemmeno verificare se si tratta di un successo o un errore. (Gestiscilo più tardi.) Oppure passa da qualche altra parte. (Lascia che qualcun altro si preoccupi di questo.) Raccoglili in un elenco. (Gestiscili tutti contemporaneamente). Puoi utilizzare il concatenamento monadico per propagarli lungo una pipeline di elaborazione.

La semantica delle eccezioni è cablata nella lingua. In Scala, ad esempio, le eccezioni si svolgono nello stack. Se li si lascia diventare un gestore di eccezioni, il gestore non può tornare indietro dove è successo e correggerlo. OTOH, se lo prendi più in basso nella pila prima di sganciarlo e distruggere il contesto necessario per risolverlo, allora potresti trovarti in un componente che è troppo basso per prendere una decisione informata su come procedere.

Questo è diverso in Smalltalk, ad esempio, dove le eccezioni non risolvono lo stack e sono ripristinabili, e puoi catturare l'eccezione in alto nello stack (possibilmente in alto come il debugger), correggere l'errore waaaaayyyyy giù nello stack e riprendi l'esecuzione da lì. O condizioni CommonLisp in cui l'innalzamento, la cattura e la gestione sono tre cose diverse invece di due (sollevamento e gestione della cattura) come con le eccezioni.

L'uso di un tipo di errore effettivo anziché un'eccezione ti consente di fare cose simili e altro ancora, senza dover modificare la lingua.

E poi c'è l'ovvio elefante nella stanza: le eccezioni sono un effetto collaterale. Gli effetti collaterali sono malvagi. Nota: non sto dicendo che questo è necessariamente vero. Ma è un punto di vista che alcune persone hanno, e in tal caso, il vantaggio di un tipo di errore rispetto alle eccezioni è ovvio. Pertanto, se si desidera eseguire la programmazione funzionale, è possibile utilizzare i tipi di errore per il solo motivo che rappresentano l'approccio funzionale. (Nota che Haskell ha delle eccezioni. Nota anche che a nessuno piace usarle.)

    
risposta data 08.09.2016 - 19:51
fonte
0

La cosa bella delle eccezioni è che il codice di origine ha già classificato la circostanza eccezionale per te. La differenza tra IllegalStateException e BadParameterException è molto più facile da distinguere in linguaggi tipizzati più forti. Se cerchi di analizzare "buono" e "cattivo", non stai sfruttando lo strumento estremamente potente a tua disposizione, ovvero il compilatore Scala. Le tue estensioni su Throwable possono includere tutte le informazioni di cui hai bisogno, oltre alla stringa di messaggi testuali.

Questa è la vera utilità di Try nell'ambiente Scala, con Throwable che funge da wrapper degli elementi di sinistra per semplificarti la vita.

    
risposta data 08.09.2016 - 19:19
fonte

Leggi altre domande sui tag