Restituisce il valore magico, genera un'eccezione o restituisce false in caso di fallimento?

78

A volte finisco per dover scrivere un metodo o una proprietà per una libreria di classi per la quale non è eccezionale avere una risposta reale, ma un fallimento. Qualcosa non può essere determinato, non è disponibile, non è stato trovato, non è attualmente possibile o non ci sono più dati disponibili.

Penso che ci siano tre possibili soluzioni per una situazione relativamente non eccezionale per indicare un errore in C # 4:

  • restituisce un valore magico che non ha altro significato (come null e -1 );
  • genera un'eccezione (ad esempio KeyNotFoundException );
  • restituisce false e fornisce il valore di ritorno effettivo in un parametro out , (ad esempio Dictionary<,>.TryGetValue ).

Quindi le domande sono: in quale situazione non eccezionale dovrei lanciare un'eccezione? E se non dovessi lanciare: quando restituisce un valore magico perferito sopra implementando un Try* metodo con un parametro out ? (Per me il parametro out sembra sporco, ed è più difficile utilizzarlo correttamente.)

Sto cercando risposte concrete, come le risposte che riguardano le linee guida di progettazione (non ne conosco i metodi Try* ), l'usabilità (come chiedo questo per una libreria di classi), l'uniformità con il BCL e la leggibilità .

Nella libreria di classi base di .NET Framework, vengono utilizzati tutti e tre i metodi:

Si noti che quando Hashtable è stato creato nel momento in cui non c'erano generici in C #, utilizza object e può quindi restituire null come valore magico. Ma con i generici, le eccezioni sono usate in Dictionary<,> e inizialmente non aveva TryGetValue . A quanto pare le intuizioni cambiano.

Ovviamente, il Item - TryGetValue e Parse - TryParse dualità è lì per un motivo, quindi presumo che lanciare eccezioni per errori non eccezionali sia in C # 4 non fatto . Tuttavia, i metodi Try* non sono sempre esistiti, anche se esisteva Dictionary<,>.Item .

    
posta Daniel Pelsmaeker 01.08.2012 - 21:05
fonte

8 risposte

53

Non penso che i tuoi esempi siano davvero equivalenti. Ci sono tre gruppi distinti, ognuno con la propria motivazione per il suo comportamento.

  1. Il valore magico è una buona opzione quando c'è una condizione "until" come StreamReader.Read o quando c'è un valore semplice da usare che non sarà mai una risposta valida (-1 per IndexOf ).
  2. Genera un'eccezione quando la semantica della funzione è che il chiamante è sicuro che funzionerà. In questo caso, una chiave non esistente o un cattivo formato doppio sono davvero eccezionali.
  3. Utilizza un parametro out e restituisci un bool se la semantica è per verificare se l'operazione è possibile o meno.

Gli esempi forniti sono perfettamente chiari per i casi 2 e 3. Per i valori magici, si può argomentare se questa è una buona decisione di progettazione o no in tutti i casi.

Il NaN restituito da Math.Sqrt è un caso speciale - segue lo standard in virgola mobile.

    
risposta data 01.08.2012 - 21:11
fonte
29

Stai provando a comunicare all'utente dell'API cosa devono fare. Se lanci un'eccezione, non c'è nulla che li costringa a prenderlo, e solo leggendo la documentazione gli farai sapere quali sono tutte le possibilità. Personalmente trovo lento e noioso scavare nella documentazione per trovare tutte le eccezioni che un certo metodo potrebbe generare (anche se è in intellisense, devo ancora copiarle manualmente).

Un valore magico richiede ancora di leggere la documentazione e possibilmente fare riferimento a qualche tabella const per decodificare il valore. Almeno non ha il sovraccarico di un'eccezione per ciò che si chiama un evento non eccezionale.

Questo è il motivo per cui anche se i parametri out vengono talvolta disapprovati, preferisco questo metodo, con la sintassi Try... . È la sintassi canonica di .NET e C #. Stai comunicando all'utente dell'API che devono controllare il valore restituito prima di utilizzare il risultato. Puoi anche includere un secondo parametro out con un messaggio di errore utile, che di nuovo aiuta con il debug. Ecco perché voto per Try... con out parametro solution.

Un'altra opzione è quella di restituire un oggetto "risultato" speciale, sebbene trovo questo molto più noioso:

interface IMyResult
{
    bool Success { get; }
    // Only access this if Success is true
    MyOtherClass Result { get; }
    // Only access this if Success is false
    string ErrorMessage { get; }
}

Quindi la tua funzione sembra giusta perché ha solo parametri di input e restituisce solo una cosa. È solo che l'unica cosa che ritorna è una specie di tupla.

In effetti, se ti piace questo tipo di cose, puoi utilizzare le nuove classi Tuple<> introdotte in .NET 4. Personalmente non mi piace il fatto che il significato di ogni campo sia meno esplicito perché non posso dare Item1 e Item2 nomi utili.

    
risposta data 02.08.2012 - 03:53
fonte
17

Come i tuoi esempi mostrano già, ognuno di questi casi deve essere valutato separatamente e c'è un considerevole spettro di grigio tra "circostanze eccezionali" e "controllo del flusso", specialmente se il tuo metodo è destinato a essere riutilizzabile e potrebbe essere usato in modelli abbastanza diversi da quelli per cui era stato originariamente progettato. Non aspettatevi che tutti noi siamo d'accordo su cosa significa "non eccezionale", specialmente se discutete immediatamente della possibilità di usare "eccezioni" per implementarlo.

Potremmo anche non essere d'accordo su quale progettazione rende il codice più facile da leggere e mantenere, ma supporrò che il progettista della biblioteca abbia una chiara visione personale di questo e abbia solo bisogno di bilanciarlo con le altre considerazioni coinvolte.

Risposta breve

Segui i tuoi sentimenti viscerali tranne quando stai progettando metodi abbastanza veloci e prevedi una possibilità di riutilizzo imprevisto.

Risposta lunga

Ogni futuro chiamante può liberamente tradurre tra i codici di errore e le eccezioni come desidera in entrambe le direzioni; questo rende i due approcci progettuali quasi equivalenti tranne che per le prestazioni, la compatibilità con il debugger e alcuni contesti di interoperabilità ristretti. Questo di solito si riduce alle prestazioni, quindi concentriamoci su quello.

  • Come regola generale, ti aspettiamo che lanciare un'eccezione sia 200 volte più lenta di un ritorno regolare (in realtà, c'è una varianza significativa in questo).

  • Come altra regola, lanciare un'eccezione può spesso consentire un codice molto più pulito rispetto ai valori magici più crudi, perché non stai facendo affidamento sul programmatore che traduce il codice di errore in un altro codice di errore, mentre attraversa più livelli di codice client verso un punto in cui esiste un contesto sufficiente per gestirlo in modo coerente e adeguato. (Caso speciale: null tende a fare meglio qui rispetto ad altri valori magici a causa della sua tendenza a tradursi automaticamente in NullReferenceException in caso di alcuni, ma non tutti i tipi di difetti, di solito, ma non sempre, abbastanza vicini a la fonte del difetto.)

Quindi qual è la lezione?

Per una funzione chiamata solo poche volte durante il ciclo di vita di un'applicazione (come l'inizializzazione dell'app), usa qualsiasi cosa che ti dia un codice più pulito e più facile da capire. Le prestazioni non possono essere un problema.

Per una funzione throw-away, usa qualsiasi cosa che ti dia un codice più pulito. Quindi esegui qualche profilazione (se necessario) e modifica le eccezioni per restituire i codici se sono tra i presunti colli di bottiglia più alti in base alle misurazioni o alla struttura complessiva del programma.

Per una funzione riutilizzabile, usa tutto ciò che ti dà un codice più pulito. Se fondamentalmente devi sempre sottoporti ad un roundtrip di rete o analizzare un file XML su disco, il sovraccarico di generare un'eccezione è probabilmente trascurabile. È più importante non perdere i dettagli di un eventuale errore, nemmeno accidentalmente, piuttosto che tornare da un "errore non eccezionale" molto più veloce.

Una funzione snella riutilizzabile richiede più riflessione. Impiegando eccezioni, si sta forzando qualcosa come un rallentamento di 100 volte sui chiamanti che vedranno l'eccezione su metà delle loro (molte) chiamate, se il corpo della funzione viene eseguito molto velocemente. Le eccezioni sono ancora un'opzione di progettazione, ma dovrai fornire un'alternativa a basso costo per i chiamanti che non possono permetterselo. Diamo un'occhiata a un esempio.

Elencate un ottimo esempio di Dictionary<,>.Item , che, vagamente parlando, è cambiato da restituire null valori a generare KeyNotFoundException tra .NET 1.1 e .NET 2.0 (solo se siete disposti a considerare Hashtable.Item a essere il suo precursore non generico pratico). La ragione di questo "cambiamento" non è senza interesse qui. L'ottimizzazione delle prestazioni dei tipi di valore (non più la boxe) ha reso il valore magico originale ( null ) una non-opzione; I parametri out porteranno solo una piccola parte dei costi delle prestazioni. Quest'ultima considerazione prestazionale è completamente trascurabile rispetto al sovraccarico di lancio di un KeyNotFoundException , ma il design delle eccezioni è ancora superiore qui. Perché?

    I parametri
  • ref / out sostengono i loro costi ogni volta, non solo nel caso di "fallimento"
  • Chiunque abbia interesse può chiamare Contains prima di qualsiasi chiamata all'indicizzatore e questo modello si legge in modo completamente naturale. Se uno sviluppatore vuole, ma dimentica di chiamare Contains , nessun problema di prestazioni può insinuarsi; KeyNotFoundException è abbastanza strong da essere notato e corretto.
risposta data 02.08.2012 - 01:49
fonte
10

What is the best thing to do in such a relatively non-exceptional situation to indicate failure, and why?

Non dovresti consentire errori.

Lo so, è mano-ondeggiante e idealista, ma ascoltami. Quando si esegue la progettazione, ci sono un certo numero di casi in cui si ha la possibilità di favorire una versione che non ha modalità di errore. Invece di avere un 'FindAll' che non riesce, LINQ usa una clausola where che restituisce semplicemente una enumerable vuota. Invece di avere un oggetto che deve essere inizializzato prima dell'uso, lasciare che il costruttore inizializzi l'oggetto (o inizializzi quando viene rilevato il non inizializzato). La chiave sta rimuovendo il ramo di errore nel codice del consumatore. Questo è il problema, quindi concentrati su di esso.

Un'altra strategia per questo è il KeyNotFound sscenario. In quasi tutti i codebase su cui ho lavorato dal 3.0, esiste qualcosa di simile a questo metodo di estensione:

public static class DictionaryExtensions {
    public static V GetValue<K, V>(this IDictionary<K, V> arg, K key, Func<K,V> ifNotFound) {
        if (!arg.ContainsKey(key)) {
            return ifNotFound(key);
        }

        return arg[key];
    }
}

Per questo non esiste una vera modalità di errore. ConcurrentDictionary ha un GetOrAdd simile incorporato.

Tutto ciò detto, ci saranno sempre momenti in cui è semplicemente inevitabile. Tutti e tre hanno il loro posto, ma preferirei la prima opzione. Nonostante tutto ciò che è fatto del pericolo di null, è ben noto e rientra in molti degli scenari "non trovato" o "risultato non applicabile" che costituiscono il set di "fallimento non eccezionale". Soprattutto quando si creano tipi di valori nullable, il significato di "questo potrebbe fallire" è molto esplicito nel codice e difficile da dimenticare / rovinare.

La seconda opzione è abbastanza buona quando l'utente fa qualcosa di stupido. Ti dà una stringa con il formato sbagliato, prova a impostare la data al 42 dicembre ... qualcosa che vuoi far esplodere rapidamente e in modo spettacolare le cose durante i test in modo che il codice errato venga identificato e risolto.

L'ultima opzione è una che non mi piace più. I parametri out sono scomodi e tendono a violare alcune delle migliori pratiche quando si realizzano metodi, come concentrarsi su una cosa e non avere effetti collaterali. Inoltre, il parametro out di solito è significativo solo durante il successo. Detto questo, sono essenziali per determinate operazioni che sono generalmente limitate da preoccupazioni relative alla concorrenza, o considerazioni sulle prestazioni (in cui non si desidera effettuare un secondo viaggio nel DB, ad esempio).

Se il valore di ritorno e il parametro out non sono banali, allora il suggerimento di Scott Whitlock su un oggetto risultato è preferito (come la classe Match di Regex).

    
risposta data 02.08.2012 - 04:36
fonte
2

Come altri hanno notato, il valore magico (incluso un valore di ritorno booleano) non è una soluzione così grande, se non come un indicatore di "fine intervallo". Motivo: la semantica non è esplicita, anche se si esaminano i metodi dell'oggetto. Devi effettivamente leggere la documentazione completa per l'intero oggetto fino a "oh sì, se restituisce -42 che significa bla bla bla".

Questa soluzione può essere utilizzata per ragioni storiche o per motivi di prestazioni, ma dovrebbe altrimenti essere evitata.

Questo lascia due casi generali: Probing o eccezioni.

Qui la regola generale è che il programma non deve reagire alle eccezioni tranne che per gestire quando il programma / involontariamente / viola alcune condizioni. Il sondaggio dovrebbe essere usato per garantire che ciò non avvenga. Quindi, un'eccezione significa che il rilevamento pertinente non è stato eseguito in anticipo, o che qualcosa di completamente inatteso è accaduto.

Esempio:

Vuoi creare un file da un determinato percorso.

Dovresti utilizzare l'oggetto File per valutare in anticipo se questo percorso è legale per la creazione o scrittura di file.

Se il tuo programma in qualche modo finisce per cercare di scrivere su un percorso che è illegale o altrimenti non scrivibile, dovresti ottenere un'esclusione. Questo potrebbe accadere a causa di una condizione di competizione (qualche altro utente ha rimosso la directory o lo ha reso di sola lettura, dopo averlo provato)

Il compito di gestire un errore imprevisto (segnalato da un'eccezione) e verificare se le condizioni sono giuste per l'operazione in anticipo (sondaggio) di solito sarà strutturato diversamente e dovrebbe quindi utilizzare meccanismi diversi.

    
risposta data 02.08.2012 - 11:17
fonte
1

Preferisci sempre lanciare un'eccezione. Ha un'interfaccia uniforme tra tutte le funzioni che potrebbero fallire e indica il fallimento nel modo più rumoroso possibile - una proprietà molto desiderabile.

Tieni presente che Parse e TryParse non sono la stessa cosa, a parte le modalità di errore. Il fatto che TryParse possa anche restituire il valore è in qualche modo ortogonale, davvero. Considera la situazione in cui, ad esempio, stai convalidando qualche input. Non ti interessa davvero quale sia il valore, finché è valido. E non c'è niente di sbagliato nell'offrire un tipo di funzione IsValidFor(arguments) . Ma non può mai essere la modalità operativa primaria .

    
risposta data 02.08.2012 - 05:04
fonte
0

Penso che il modello Try sia la scelta migliore, quando il codice indica solo cosa è successo. Odio i param e come oggetti nullable. Ho creato la seguente classe

public sealed class Bag<TValue>
{
    public Bag(TValue value, bool hasValue = true)
    {
        HasValue = hasValue;
        Value = value;
    }

    public static Bag<TValue> Empty
    {
        get { return new Bag<TValue>(default(TValue), false); }
    }

    public bool HasValue { get; private set; }
    public TValue Value { get; private set; }
}

quindi posso scrivere il seguente codice

    public static Bag<XElement> GetXElement(this XElement element, string elementName)
    {
        try
        {
            XElement result = element.Element(elementName);
            return result == null
                       ? Bag<XElement>.Empty
                       : new Bag<XElement>(result);
        }
        catch (Exception)
        {
            return Bag<XElement>.Empty;
        }
    }

Sembra nullable ma non solo per il tipo di valore

Un altro esempio

    public static Bag<string> TryParseString(this XElement element, string attributeName)
    {
        Bag<string> attributeResult = GetString(element, attributeName);
        if (attributeResult.HasValue)
        {
            return new Bag<string>(attributeResult.Value);
        }
        return Bag<string>.Empty;
    }

    private static Bag<string> GetString(XElement element, string attributeName)
    {
        try
        {
            string result = element.GetAttribute(attributeName).Value;
            return new Bag<string>(result);
        }
        catch (Exception)
        {
            return Bag<string>.Empty;
        }
    }
    
risposta data 01.08.2012 - 23:07
fonte
0

Se ti interessa il percorso del "valore magico", un altro modo per risolverlo è quello di sovraccaricare lo scopo della classe Lazy. Sebbene Lazy abbia lo scopo di posticipare l'istanziazione, nulla ti impedisce di utilizzare come un Forse o un'opzione. Ad esempio:

    public static Lazy<TValue> GetValue<TValue, TKey>(
        this IDictionary<TKey, TValue> dictionary,
        TKey key)
    {
        TValue retVal;
        if (dictionary.TryGetValue(key, out retVal))
        {
            var retValRef = retVal;
            var lazy = new Lazy<TValue>(() => retValRef);
            retVal = lazy.Value;
            return lazy;
        }

        return new Lazy<TValue>(() => default(TValue));
    }
    
risposta data 04.12.2014 - 03:48
fonte

Leggi altre domande sui tag