Perché progettare un linguaggio moderno senza un meccanismo di gestione delle eccezioni?


47

Molti linguaggi moderni offrono funzionalità avanzate di gestione delle eccezioni , ma il linguaggio di programmazione Swift di Apple non fornisce un meccanismo di gestione delle eccezioni .

Forte di eccezioni come me, ho problemi a pensare a cosa significhi. Swift ha asserzioni e, naturalmente, valori di ritorno; ma ho difficoltà a immaginare come il mio modo di pensare basato sulle eccezioni si associ a un mondo senza eccezioni (e, del resto, perché un tale mondo sia desiderabile ). Ci sono cose che non posso fare in una lingua come Swift che potrei fare con le eccezioni? Guadagno qualcosa perdendo le eccezioni?

Come ad esempio potrei esprimere al meglio qualcosa del genere

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

in una lingua (Swift, ad esempio) in cui manca la gestione delle eccezioni?


11
Potresti voler aggiungere Vai all'elenco , se ignoriamo panicche non è esattamente lo stesso. Inoltre, ciò che viene detto lì, un'eccezione non è molto più di un modo sofisticato (ma comodo) per eseguire un GOTO, sebbene nessuno lo chiami in quel modo, per ovvie ragioni.
JensG,

3
La risposta ordinata alla tua domanda è che hai bisogno del supporto linguistico per le eccezioni per poterle scrivere. Il supporto linguistico generalmente include la gestione della memoria; poiché un'eccezione può essere generata ovunque e catturata ovunque, deve esserci un modo per disporre di oggetti che non si basano sul flusso di controllo.
Robert Harvey,

1
Non ti sto seguendo abbastanza, @Robert. C ++ riesce a supportare le eccezioni senza garbage collection.
Karl Bielefeldt,

3
@KarlBielefeldt: a grandi spese, da quello che ho capito. Inoltre, c'è qualcosa che viene intrapreso in C ++ senza grandi spese, almeno nello sforzo e nella conoscenza del dominio richiesta?
Robert Harvey,

2
@RobertHarvey: buon punto. Sono una di quelle persone che non pensano abbastanza a queste cose. Sono stato indotto a pensare che ARC sia GC, ma, ovviamente, non lo è. Quindi sostanzialmente (se sto afferrando, approssimativamente), le eccezioni sarebbero una cosa disordinata (nonostante C ++) in un linguaggio in cui lo smaltimento degli oggetti si basava sul flusso di controllo?
orome,

Risposte:


33

Nella programmazione integrata, le eccezioni non erano tradizionalmente consentite, poiché il sovraccarico dello stack che si doveva svolgere era considerato una variabilità inaccettabile quando si cercava di mantenere prestazioni in tempo reale. Mentre gli smartphone potrebbero tecnicamente essere considerati piattaforme in tempo reale, sono abbastanza potenti ora che i vecchi limiti dei sistemi embedded non si applicano più. Lo sollevo solo per motivi di completezza.

Le eccezioni sono spesso supportate nei linguaggi di programmazione funzionale, ma utilizzate così raramente che potrebbero anche non esserlo. Uno dei motivi è la valutazione pigra, che viene eseguita occasionalmente anche in lingue che non sono pigre per impostazione predefinita. Avere una funzione che viene eseguita con uno stack diverso rispetto al luogo in cui è stata messa in coda per l'esecuzione rende difficile determinare dove posizionare il gestore eccezioni.

L'altro motivo è che le funzioni di prima classe consentono costrutti come opzioni e futuri che offrono i vantaggi sintattici delle eccezioni con maggiore flessibilità. In altre parole, il resto della lingua è abbastanza espressivo che le eccezioni non ti comprano nulla.

Non ho familiarità con Swift, ma il poco che ho letto sulla sua gestione degli errori suggerisce che intendevano che la gestione degli errori seguisse schemi più funzionali. Ho visto esempi di codice con successe failureblocchi che assomigliano molto al futuro.

Ecco un esempio usando a Futureda questo tutorial di 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)
}

Puoi vedere che ha all'incirca la stessa struttura del tuo esempio usando le eccezioni. Il futureblocco è come un try. Il onFailureblocco è come un gestore di eccezioni. In Scala, come nella maggior parte dei linguaggi funzionali, Futureviene implementato completamente usando il linguaggio stesso. Non richiede alcuna sintassi speciale come fanno le eccezioni. Ciò significa che puoi definire i tuoi costrutti simili. Forse aggiungere un timeoutblocco, ad esempio, o la funzionalità di registrazione.

Inoltre, è possibile passare il futuro, restituirlo dalla funzione, archiviarlo in una struttura di dati o altro. È un valore di prima classe. Non sei limitato come le eccezioni che devono essere propagate direttamente nello stack.

Le opzioni risolvono il problema di gestione degli errori in un modo leggermente diverso, che funziona meglio per alcuni casi d'uso. Non sei bloccato con l'unico metodo.

Queste sono le cose che "guadagni perdendo le eccezioni".


1
Quindi Futureè essenzialmente un modo per esaminare il valore restituito da una funzione, senza smettere di aspettarlo. Come Swift, è basato sul valore di ritorno, ma a differenza di Swift, la risposta al valore di ritorno può verificarsi in un secondo momento (un po 'come le eccezioni). Giusto?
orome,

1
Hai capito Futurebene, ma penso che potresti essere in errore nel caratterizzare Swift. Vedi la prima parte di questa risposta StackOverflow , per esempio.
Karl Bielefeldt,

Hmm, sono nuovo di Swift, quindi questa risposta è un po 'difficile per me da analizzare. Ma se non sbaglio: essenzialmente passa un gestore che può essere invocato in un secondo momento; giusto?
orome,

Sì. Fondamentalmente stai creando un callback quando si verifica un errore.
Karl Bielefeldt,

Eithersarebbe un esempio migliore IMHO
Paweł Prażak,

19

Le eccezioni possono rendere il codice più difficile da ragionare. Sebbene non siano abbastanza potenti come i goto, possono causare molti degli stessi problemi a causa della loro natura non locale. Ad esempio, supponiamo che tu abbia un pezzo di codice imperativo come questo:

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

Non puoi dire a colpo d'occhio se una di queste procedure può generare un'eccezione. Devi leggere la documentazione di ciascuna di queste procedure per capirlo. (Alcune lingue lo rendono leggermente più semplice aumentando la firma del tipo con queste informazioni.) Il codice sopra verrà compilato bene indipendentemente dal fatto che una qualsiasi delle procedure venga lanciata, rendendo davvero facile dimenticare di gestire un'eccezione.

Inoltre, anche se l'intenzione è quella di propagare l'eccezione al chiamante, è spesso necessario aggiungere un codice aggiuntivo per evitare che le cose vengano lasciate in uno stato incoerente (ad esempio se la tua caffettiera si rompe, è comunque necessario ripulire il caos e tornare la tazza!). Pertanto, in molti casi il codice che utilizza le eccezioni sembrerebbe tanto complesso quanto il codice che non ha funzionato a causa dell'ulteriore pulizia richiesta.

Le eccezioni possono essere emulate con un sistema di tipo sufficientemente potente. Molte delle lingue che evitano le eccezioni utilizzano i valori di ritorno per ottenere lo stesso comportamento. È simile a come avviene in C, ma i sistemi di tipo moderno di solito rendono più elegante e anche più difficile dimenticare di gestire la condizione di errore. Possono anche fornire zucchero sintattico per rendere le cose meno ingombranti, a volte quasi pulite come sarebbe con le eccezioni.

In particolare, incorporando la gestione degli errori nel sistema dei tipi anziché implementandoli come una funzionalità separata, è possibile utilizzare le "eccezioni" per altre cose che non sono nemmeno correlate agli errori. (È noto che la gestione delle eccezioni è in realtà una proprietà delle monadi.)


È corretto quel tipo di sistema di tipi che Swift ha, compresi gli optional, è il tipo di "sistema di tipi potenti" che raggiunge questo obiettivo?
orome,

2
Sì, gli optional e, più in generale, i tipi di somma (indicati come "enum" in Swift / Rust) possono ottenere questo risultato. Ci vuole un po 'di lavoro extra per renderli piacevoli da usare, tuttavia: in Swift, questo si ottiene con la sintassi di concatenamento opzionale; in Haskell, ciò si ottiene con la notazione monadica.
Rufflewind

Può un "sistema di tipo sufficientemente potente" dare una traccia dello stack, se non è abbastanza inutile
Paweł Prażak,

1
+1 per indicare che le eccezioni oscurano il flusso di controllo. Proprio come una nota ausiliaria: è ovvio che le eccezioni non siano in realtà più malvagie di goto: gotoè limitato a una singola funzione, che è un intervallo piuttosto piccolo a condizione che le funzioni siano davvero piccole, l'eccezione si comporta più come una croce di gotoe come from(vedi en.wikipedia.org/wiki/INTERCAL ;-)). Può praticamente collegare due pezzi di codice, saltando eventualmente il codice su alcune terze funzioni. L'unica cosa che non può fare, che gotopuò, è tornare indietro.
cmaster

2
@ PawełPrażak Quando si lavora con molte funzioni di ordine superiore, le tracce dello stack non sono altrettanto preziose. Forti garanzie su input e output ed evitamento di effetti collaterali sono ciò che impedisce a questa indiretta di causare bug confusi.
Jack,

11

Ci sono alcune grandi risposte qui, ma penso che un motivo importante non sia stato abbastanza enfatizzato: quando si verificano eccezioni, gli oggetti possono essere lasciati in stati non validi. Se riesci a "rilevare" un'eccezione, il codice del gestore eccezioni sarà in grado di accedere e lavorare con quegli oggetti non validi. Ciò andrà terribilmente male a meno che il codice per quegli oggetti non sia stato scritto perfettamente, il che è molto, molto difficile da fare.

Ad esempio, immagina di implementare Vector. Se qualcuno crea un'istanza di Vector con un set di oggetti, ma durante l'inizializzazione si verifica un'eccezione (forse, diciamo, durante la copia dei tuoi oggetti nella memoria appena allocata), è molto difficile codificare correttamente l'implementazione Vector in modo tale che no la memoria perde. Questo breve articolo di Stroustroup copre l'esempio di Vector .

E questa è semplicemente la punta dell'iceberg. E se, ad esempio, avessi copiato alcuni degli elementi, ma non tutti gli elementi? Per implementare correttamente un contenitore come Vector, devi quasi rendere reversibile ogni azione intrapresa, quindi l'intera operazione è atomica (come una transazione di database). Questo è complicato e la maggior parte delle applicazioni sbaglia. E anche se fatto correttamente, complica notevolmente il processo di implementazione del contenitore.

Quindi alcune lingue moderne hanno deciso che non ne vale la pena. Rust, ad esempio, ha delle eccezioni, ma non possono essere "catturati", quindi non c'è modo per il codice di interagire con oggetti in uno stato non valido.


1
lo scopo della cattura è rendere coerente un oggetto (o terminare qualcosa se non è possibile) dopo che si è verificato un errore.
JoulinRouge,

@JoulinRouge Lo so. Ma alcune lingue hanno deciso di non darti questa opportunità, ma invece di bloccare l'intero processo. Quei designer linguistici conoscono il tipo di pulizia che vorresti fare ma hanno concluso che è troppo complicato dartelo, e i compromessi coinvolti nel fare ciò non varrebbero la pena. Mi rendo conto che potresti non essere d'accordo con la loro scelta ... ma è utile sapere che hanno fatto la scelta consapevolmente per questi motivi particolari.
Charlie Flowers,

6

Una cosa che inizialmente mi ha sorpreso del linguaggio Rust è che non supporta le eccezioni di cattura. Puoi generare eccezioni, ma solo il runtime può rilevarle quando muore un'attività (pensa al thread, ma non sempre a un thread del sistema operativo separato); se si avvia un'attività da soli, è possibile chiedere se è uscito normalmente o se è stato modificato fail!().

Come tale non è idiomatico failmolto spesso. I pochi casi in cui ciò accade, ad esempio, si trovano nel cablaggio di test (che non sa come sia il codice utente), come livello principale di un compilatore (invece la maggior parte dei compilatori fork) o quando si richiama un callback su input dell'utente.

Invece, il linguaggio comune è usare il Resultmodello per trasmettere esplicitamente errori che dovrebbero essere gestiti. Ciò è reso molto più semplice per la try!macro , che è può essere avvolto intorno qualsiasi espressione che produce un risultato e cede il braccio di successo se ce n'è uno, o altrimenti restituisce presto dalla funzione.

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

1
Quindi è giusto dire che anche questo (come l'approccio di Go) è simile a Swift, che ha assert, ma no catch?
orome,

1
In Swift, prova! significa: Sì, lo so che potrebbe non riuscire, ma sono sicuro che non lo farà, quindi non lo gestisco, e se fallisce il mio codice è sbagliato, quindi per favore crash in quel caso.
gnasher729,

6

A mio avviso, le eccezioni sono uno strumento essenziale per rilevare errori di codice in fase di esecuzione. Sia nelle prove che nella produzione. Rendi i loro messaggi abbastanza dettagliati, così in combinazione con una traccia dello stack puoi capire cosa è successo da un registro.

Le eccezioni sono principalmente uno strumento di sviluppo e un modo per ottenere ragionevoli segnalazioni di errori dalla produzione in casi imprevisti.

A parte la separazione delle preoccupazioni (percorso felice con solo errori previsti rispetto al fallire fino a raggiungere un gestore generico per errori imprevisti) essere una buona cosa, rendendo il tuo codice più leggibile e mantenibile, è infatti impossibile preparare il tuo codice per tutto il possibile casi imprevisti, anche gonfiandolo con codice di gestione degli errori per completare illeggibilità.

Questo è in realtà il significato di "imprevisto".

A proposito, cosa ci si aspetta e cosa no è una decisione che può essere presa solo sul sito di chiamata. Ecco perché le eccezioni verificate in Java non hanno funzionato: la decisione viene presa al momento dello sviluppo di un'API, quando non è affatto chiaro cosa ci si aspetti o imprevisto.

Esempio semplice: l'API di una mappa hash può avere due metodi:

Value get(Key)

e

Option<Value> getOption(key)

il primo genera un'eccezione se non trovato, il secondo ti dà un valore opzionale. In alcuni casi, quest'ultimo ha più senso, ma in altri, il tuo codice deve aspettarsi che ci sia un valore per una determinata chiave, quindi se non ce n'è uno, è un errore che questo codice non può risolvere perché un l'ipotesi è fallita. In questo caso, in realtà è il comportamento desiderato che esca dal percorso del codice e scenda a qualche gestore generico nel caso in cui la chiamata fallisca.

Il codice non dovrebbe mai tentare di gestire ipotesi di base fallite.

Tranne controllandoli e lanciando eccezioni ben leggibili, ovviamente.

Lanciare eccezioni non è male ma catturarle potrebbe esserlo. Non tentare di correggere errori imprevisti. Cattura le eccezioni in alcuni punti in cui desideri continuare qualche ciclo o operazione, registrale, magari segnala un errore sconosciuto e il gioco è fatto.

I blocchi di cattura in tutto il luogo sono una pessima idea.

Progetta le tue API in modo da esprimere facilmente le tue intenzioni, ovvero dichiarare se ti aspetti un determinato caso, come chiave non trovata o meno. Gli utenti della tua API possono quindi scegliere la chiamata di lancio solo per casi davvero imprevisti.

Immagino che la ragione per cui le persone risentono delle eccezioni e vanno troppo lontano omettendo questo strumento cruciale per l'automazione della gestione degli errori e una migliore separazione delle preoccupazioni dalle nuove lingue sono brutte esperienze.

Questo e alcuni equivoci su ciò a cui sono effettivamente utili.

Simulandoli facendo TUTTO attraverso l'associazione monadica, il codice diventa meno leggibile e mantenibile e si finisce senza una traccia dello stack, il che peggiora questo approccio.

La gestione degli errori di stile funzionale è ottima per i casi di errore previsti.

Lascia che la gestione delle eccezioni si occupi automaticamente di tutto il resto, ecco a cosa serve :)


3

Swift usa gli stessi principi qui di Objective-C, solo di conseguenza. In Objective-C, le eccezioni indicano errori di programmazione. Non vengono gestiti se non dagli strumenti di segnalazione degli arresti anomali. La "gestione delle eccezioni" viene eseguita correggendo il codice. (Ci sono alcune eccezioni ahem. Ad esempio nelle comunicazioni tra processi. Ma questo è abbastanza raro e molte persone non vi si imbattono mai. E Objective-C in realtà ha provato / cattura / finalmente / lancia, ma raramente le usate). Swift ha appena rimosso la possibilità di rilevare eccezioni.

Swift ha una funzione che assomiglia alla gestione delle eccezioni ma è solo la gestione degli errori forzata. Storicamente, Objective-C aveva un modello di gestione degli errori abbastanza pervasivo: un metodo restituiva un BOOL (SÌ per il successo) o un riferimento all'oggetto (zero per il fallimento, non zero per il successo) e aveva un parametro "puntatore a NSError *" che verrebbe utilizzato per memorizzare un riferimento NSError. Swift converte automagicamente le chiamate a tale metodo in qualcosa che assomiglia alla gestione delle eccezioni.

In generale, le funzioni Swift possono facilmente restituire alternative, come risultato se una funzione ha funzionato bene e un errore se fallisce; ciò rende la gestione degli errori molto più semplice. Ma la risposta alla domanda originale: i progettisti di Swift hanno ovviamente ritenuto che la creazione di un linguaggio sicuro e la scrittura di codice di successo in tale linguaggio, sia più semplice se la lingua non ha eccezioni.


Per Swift, questa è la risposta corretta, IMO. Swift deve rimanere compatibile con i framework di sistema Objective-C esistenti, quindi sotto il cofano non hanno eccezioni tradizionali. Ho scritto un post sul blog qualche tempo fa su come funziona ObjC per la gestione degli errori: 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;

In C finiresti con qualcosa del genere sopra.

Ci sono cose che non posso fare in Swift che potrei fare con le eccezioni?

No, non c'è niente. Finisci per gestire i codici risultato anziché le eccezioni.
Le eccezioni consentono di riorganizzare il codice in modo che la gestione degli errori sia separata dal codice del percorso felice, ma questo è tutto.


E, allo stesso modo, quelle chiamate per ...throw_ioerror()restituire errori piuttosto che generare eccezioni?
orome,

1
@raxacoricofallapatorius Se affermiamo che non esistono eccezioni, suppongo che il programma segua il solito schema di restituzione dei codici di errore in caso di errore e 0 in caso di successo.
stonemetal

1
@stonemetal Alcune lingue, come Rust e Haskell, usano il sistema dei tipi per restituire qualcosa di più significativo di un codice di errore, senza aggiungere punti di uscita nascosti come fanno le eccezioni. Una funzione Rust, ad esempio, può restituire un Result<T, E>enum, che può essere un Ok<T>, o un Err<E>, Tessendo il tipo desiderato se presente ed Eessendo un tipo che rappresenta un errore. La corrispondenza dei modelli e alcuni metodi particolari semplificano la gestione di successi e insuccessi. In breve, non dare per scontato che la mancanza di eccezioni significhi automaticamente codici di errore.
8

1

Oltre alla risposta di Charlie:

Questi esempi di gestione delle eccezioni dichiarate che vedi in molti manuali e libri sembrano molto intelligenti solo su esempi molto piccoli.

Anche se metti da parte l'argomento sullo stato dell'oggetto non valido, causano sempre un dolore enorme quando si tratta di un'app di grandi dimensioni.

Ad esempio, quando si ha a che fare con IO, usando una certa crittografia, si possono avere 20 tipi di eccezioni che possono essere scartate da 50 metodi. Immagina la quantità di codice di gestione delle eccezioni di cui avrai bisogno. La gestione delle eccezioni richiederà molte volte più codice del codice stesso.

In realtà sai quando l'eccezione non può apparire e non hai mai bisogno di scrivere tanta gestione delle eccezioni, quindi usi solo alcune soluzioni alternative per ignorare le eccezioni dichiarate. Nella mia pratica, solo un 5% circa delle eccezioni dichiarate deve essere gestito nel codice per avere un'app affidabile.


Bene, in realtà, queste eccezioni possono spesso essere gestite in un solo posto. Ad esempio, in una funzione di "download di aggiornamento dei dati" se SSL non riesce o il DNS non è risolvibile o il server Web restituisce un 404, non importa, prenderlo in alto e segnalare l'errore all'utente.
Zan Lynx,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.