web-dev-qa-db-fra.com

Retourner une valeur magique, lever une exception ou retourner faux en cas d'échec?

Je finis parfois par devoir écrire une méthode ou une propriété pour une bibliothèque de classes pour laquelle il n'est pas exceptionnel de ne pas avoir de vraie réponse, mais un échec. Quelque chose ne peut pas être déterminé, n'est pas disponible, introuvable, actuellement impossible ou il n'y a plus de données disponibles.

Je pense qu'il existe trois solutions possibles pour une telle situation relativement non exceptionnelle pour indiquer l'échec en C # 4:

  • retourne une valeur magique qui n'a pas de sens autrement (comme null et -1);
  • lever une exception (par exemple KeyNotFoundException);
  • return false et fournissez la valeur de retour réelle dans un paramètre out, (tel que Dictionary<,>.TryGetValue ).

Les questions sont donc: dans quelle situation non exceptionnelle dois-je lever une exception? Et si je ne dois pas lancer: quand renvoie une valeur magique supérieure à l'implémentation d'un Try* méthode avec un paramètre out? (Pour moi, le paramètre out semble sale, et c'est plus de travail de l'utiliser correctement.)

Je recherche des réponses factuelles, telles que des réponses impliquant des directives de conception (je ne connais pas Try* méthodes), la convivialité (comme je le demande pour une bibliothèque de classes), la cohérence avec le BCL et la lisibilité.


Dans la bibliothèque de classes de base .NET Framework, les trois méthodes sont utilisées:

Notez que Hashtable a été créé à l'époque où il n'y avait pas de génériques en C #, il utilise object et peut donc retourner null comme valeur magique. Mais avec les génériques, les exceptions sont utilisées dans Dictionary<,>, et initialement il n'avait pas TryGetValue. Apparemment, les idées changent.

Évidemment, la dualité Item-TryGetValue et Parse-TryParse est là pour une raison, donc je suppose que lever des exceptions pour les échecs non exceptionnels est dans C # 4 pas fait . Cependant, le Try* les méthodes n'existaient pas toujours, même lorsque Dictionary<,>.Item existait.

86

Je ne pense pas que vos exemples soient vraiment équivalents. Il existe trois groupes distincts, chacun ayant sa propre justification pour son comportement.

  1. La valeur magique est une bonne option lorsqu'il existe une condition "jusqu'à" telle que StreamReader.Read ou lorsqu'il existe une valeur simple à utiliser qui ne sera jamais une réponse valide (-1 pour IndexOf).
  2. Lance une exception lorsque la sémantique de la fonction est que l'appelant est sûr que cela fonctionnera. Dans ce cas, une clé inexistante ou un mauvais format double est vraiment exceptionnel.
  3. Utilisez un paramètre out et renvoyez un booléen si la sémantique consiste à sonder si l'opération est possible ou non.

Les exemples que vous fournissez sont parfaitement clairs pour les cas 2 et 3. Pour les valeurs magiques, on peut dire si c'est une bonne décision de conception ou pas dans tous les cas.

Le NaN retourné par Math.Sqrt est un cas particulier - il suit la norme à virgule flottante.

58
Anders Abel

Vous essayez de communiquer à l'utilisateur de l'API ce qu'il doit faire. Si vous lancez une exception, rien ne les oblige à l'attraper, et seule la lecture de la documentation va leur faire savoir quelles sont toutes les possibilités. Personnellement, je trouve lent et fastidieux de fouiller dans la documentation pour trouver toutes les exceptions qu'une certaine méthode peut lever (même si c'est dans intellisense, je dois encore les copier manuellement).

Une valeur magique vous oblige toujours à lire la documentation, et éventuellement à référencer une table const pour décoder la valeur. Au moins, il n'a pas la surcharge d'une exception pour ce que vous appelez un événement non exceptionnel.

C'est pourquoi même si les paramètres out sont parfois désapprouvés, je préfère cette méthode, avec le Try... syntaxe. C'est la syntaxe canonique .NET et C #. Vous communiquez à l'utilisateur de l'API qu'il doit vérifier la valeur de retour avant d'utiliser le résultat. Vous pouvez également inclure un deuxième paramètre out avec un message d'erreur utile, ce qui aide à nouveau au débogage. Voilà pourquoi je vote pour le Try... avec out solution de paramètres.

Une autre option consiste à renvoyer un objet "résultat" spécial, bien que je trouve cela beaucoup plus fastidieux:

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

Ensuite, votre fonction regarde à droite car elle n'a que des paramètres d'entrée et ne renvoie qu'une chose. C'est juste que la seule chose qu'il retourne est une sorte de Tuple.

En fait, si vous aimez ce genre de choses, vous pouvez utiliser le nouveau Tuple<> classes introduites dans .NET 4. Personnellement, je n'aime pas le fait que la signification de chaque champ soit moins explicite car je ne peux pas donner Item1 et Item2 noms utiles.

33
Scott Whitlock

Comme vos exemples le montrent déjà, chacun de ces cas doit être évalué séparément et il existe un spectre considérable de gris entre les "circonstances exceptionnelles" et le "contrôle de flux", surtout si votre méthode est destinée à être réutilisable et peut être utilisée dans des modèles très différents. qu'il a été initialement conçu pour. Ne vous attendez pas à ce que nous soyons tous d'accord ici sur ce que signifie "non exceptionnel", surtout si vous discutez immédiatement de la possibilité d'utiliser des "exceptions" pour mettre en œuvre cela.

Nous pourrions également ne pas être d'accord sur la conception qui rend le code plus facile à lire et à maintenir, mais je suppose que le concepteur de bibliothèque a une vision personnelle claire de cela et n'a besoin que de l'équilibrer avec les autres considérations impliquées.

Réponse courte

Suivez vos sentiments intestinaux, sauf lorsque vous concevez des méthodes assez rapides et attendez-vous à une possibilité de réutilisation imprévue.

Réponse longue

Chaque futur appelant peut librement traduire entre les codes d'erreur et les exceptions comme il le souhaite dans les deux sens; cela rend les deux approches de conception presque équivalentes, sauf pour les performances, la convivialité du débogueur et certains contextes d'interopérabilité restreints. Cela se résume généralement aux performances, alors concentrons-nous sur cela.

  • En règle générale, attendez-vous à ce que la levée d'une exception soit 200 fois plus lente qu'un retour régulier (en réalité, il y a une variation importante à cela).

  • En règle générale, lever une exception peut souvent permettre un code beaucoup plus propre par rapport aux valeurs magiques les plus grossières, car vous ne comptez pas sur le programmeur qui convertit le code d'erreur en un autre code d'erreur, car il parcourt plusieurs couches de code client vers un point où il y a suffisamment de contexte pour le gérer de manière cohérente et adéquate. (Cas particulier: null a tendance à mieux se porter ici que les autres valeurs magiques en raison de sa tendance à se traduire automatiquement en NullReferenceException dans le cas de certains, mais pas de tous les types de défauts; généralement, mais pas toujours, assez proche de la source du défaut.)

Alors quelle est la leçon?

Pour une fonction qui n'est appelée que quelques fois pendant la durée de vie d'une application (comme l'initialisation de l'application), utilisez tout ce qui vous donne un code plus propre et plus facile à comprendre. La performance ne peut pas être une préoccupation.

Pour une fonction jetable, utilisez tout ce qui vous donne un code plus propre. Ensuite, effectuez un profilage (si nécessaire) et modifiez les exceptions pour renvoyer les codes s'ils figurent parmi les principaux goulots d'étranglement suspectés en fonction des mesures ou de la structure globale du programme.

Pour une fonction réutilisable coûteuse, utilisez tout ce qui vous donne un code plus propre. Si, fondamentalement, vous devez toujours subir un aller-retour réseau ou analyser un fichier XML sur disque, le surcoût lié au lancement d'une exception est probablement négligeable. Il est plus important de ne pas perdre les détails d'une défaillance, même accidentelle, que de revenir d'une "défaillance non exceptionnelle" très rapidement.

Une fonction lean réutilisable nécessite plus de réflexion. En utilisant des exceptions, vous forcez quelque chose comme un ralentissement de 100 fois sur les appelants qui verront l'exception sur la moitié de leurs (nombreux) appels, si le le corps de la fonction s'exécute très rapidement. Les exceptions sont toujours une option de conception, mais vous devrez fournir une alternative à faible surcharge pour les appelants qui ne peuvent pas se le permettre. Regardons un exemple.

Vous donnez un excellent exemple de Dictionary<,>.Item, qui, en gros, est passé de renvoyer des valeurs de null à lancer KeyNotFoundException entre .NET 1.1 et .NET 2.0 (uniquement si vous êtes prêt à envisager Hashtable.Item pour être son précurseur non générique pratique). La raison de ce "changement" n'est pas sans intérêt ici. L'optimisation des performances des types de valeur (plus de boxe) a fait de la valeur magique d'origine (null) une non-option; Les paramètres out ne feraient que ramener une petite partie du coût des performances. Cette dernière considération de performance est complètement négligeable par rapport à la surcharge de lancer un KeyNotFoundException, mais la conception d'exception est toujours supérieure ici. Pourquoi?

  • les paramètres ref/out entraînent leurs coûts à chaque fois, pas seulement dans le cas de "défaillance"
  • Quiconque s'en soucie peut appeler Contains avant tout appel à l'indexeur, et ce modèle se lit tout naturellement. Si un développeur souhaite mais oublie d'appeler Contains, aucun problème de performances ne peut se glisser; KeyNotFoundException est suffisamment fort pour être remarqué et corrigé.
17
Jirka Hanika

Quelle est la meilleure chose à faire dans une situation relativement non exceptionnelle pour indiquer un échec, et pourquoi?

Vous ne devez pas autoriser l'échec.

Je sais, c'est onduleux et idéaliste, mais écoutez-moi. Lors de la conception, il existe un certain nombre de cas où vous avez la possibilité de privilégier une version qui n'a pas de modes de défaillance. Au lieu d'avoir un "FindAll" qui échoue, LINQ utilise une clause where qui renvoie simplement un énumérable vide. Au lieu d'avoir un objet qui doit être initialisé avant d'être utilisé, laissez le constructeur initialiser l'objet (ou initialiser lorsque le non initialisé est détecté). La clé est de supprimer la branche d'échec dans le code consommateur. C'est le problème, alors concentrez-vous dessus.

Une autre stratégie pour cela est le KeyNotFound sscenario. Dans presque toutes les bases de code sur lesquelles j'ai travaillé depuis 3.0, quelque chose comme cette méthode d'extension existe:

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];
    }
}

Il n'y a pas de véritable mode de défaillance pour cela. ConcurrentDictionary a un GetOrAdd similaire intégré.

Cela dit, il y aura toujours des moments où c'est tout simplement inévitable. Tous les trois ont leur place, mais je privilégierais la première option. Malgré tout ce qui est fait du danger nul, il est bien connu et s'inscrit dans une grande partie des scénarios "élément non trouvé" ou "résultat non applicable" qui composent l'ensemble "échec non exceptionnel". Surtout lorsque vous créez des types de valeurs nullables, la signification de "cela pourrait échouer" est très explicite dans le code et difficile à oublier/bousiller.

La deuxième option est assez bonne lorsque votre utilisateur fait quelque chose de stupide. Vous donne une chaîne avec le mauvais format, essaie de régler la date au 42 décembre ... quelque chose que vous voulez que les choses explosent rapidement et spectaculairement pendant les tests afin que le mauvais code soit identifié et corrigé.

La dernière option est celle que je déteste de plus en plus. Les paramètres de sortie sont maladroits et ont tendance à violer certaines des meilleures pratiques lors de la création de méthodes, comme se concentrer sur une chose et ne pas avoir d'effets secondaires. De plus, le paramètre de sortie n'a généralement de sens qu'en cas de succès. Cela dit, ils sont essentiels pour certaines opérations qui sont généralement limitées par des problèmes de concurrence ou des considérations de performances (où vous ne voulez pas faire un deuxième voyage vers la base de données par exemple).

Si la valeur de retour et le paramètre out ne sont pas triviaux, la suggestion de Scott Whitlock concernant un objet résultat est préférée (comme la classe Match de Regex).

10
Telastyn

Préférez toujours lever une exception. Il a une interface uniforme parmi toutes les fonctions qui pourraient échouer, et il indique l'échec aussi bruyamment que possible - une propriété très souhaitable.

Notez que Parse et TryParse ne sont pas vraiment la même chose en dehors des modes de défaillance. Le fait que TryParse peut également renvoyer la valeur est quelque peu orthogonal, vraiment. Considérez la situation où, par exemple, vous validez une entrée. Vous ne vous souciez pas vraiment de la valeur, tant qu'elle est valide. Et il n'y a rien de mal à offrir une sorte de fonction IsValidFor(arguments). Mais il ne peut jamais s'agir du mode de fonctionnement primaire.

3
DeadMG

Comme d'autres l'ont noté, la valeur magique (y compris une valeur de retour booléenne) n'est pas une si bonne solution, sauf en tant que marqueur de "fin de gamme". Raison: la sémantique n'est pas explicite, même si vous examinez les méthodes de l'objet. Vous devez en fait lire la documentation complète de l'objet entier jusqu'à "oh ouais, s'il renvoie -42, cela signifie bla bla bla".

Cette solution peut être utilisée pour des raisons historiques ou pour des raisons de performances, mais doit être évitée autrement.

Cela laisse deux cas généraux: le sondage ou les exceptions.

Ici, la règle générale est que le programme ne doit pas réagir aux exceptions, sauf pour gérer quand le programme/involontairement/viole une condition. Le sondage doit être utilisé pour s'assurer que cela ne se produit pas. Par conséquent, une exception signifie soit que le sondage pertinent n'a pas été effectué à l'avance, soit que quelque chose de totalement inattendu s'est produit.

Exemple:

Vous souhaitez créer un fichier à partir d'un chemin donné.

Vous devez utiliser l'objet File pour évaluer à l'avance si ce chemin est légal pour la création ou l'écriture de fichiers.

Si votre programme finit toujours par essayer d'écrire sur un chemin qui est illégal ou non accessible en écriture, vous devriez obtenir une excaption. Cela peut se produire en raison d'une condition de concurrence (un autre utilisateur a supprimé le répertoire ou l'a rendu en lecture seule, après que vous avez rencontré un problème)

La tâche de gérer un échec inattendu (signalé par une exception) et de vérifier si les conditions sont bonnes pour l'opération à l'avance (sondage) sera généralement structurée différemment et devrait donc utiliser des mécanismes différents.

2
Anders Johansen

Si vous êtes intéressé par l'itinéraire "valeur magique", une autre façon de résoudre ce problème est de surcharger l'objectif de la classe Lazy. Bien que Lazy soit destiné à différer l'instanciation, rien ne vous empêche vraiment d'utiliser comme un peut-être ou une option. Par exemple:

    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));
    }
0
zumalifeguard

Je pense que le modèle Try est le meilleur choix, lorsque le code indique simplement ce qui s'est passé. Je déteste param et comme objet nullable. J'ai créé la classe suivante

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

donc je peux écrire le code suivant

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

Ressemble à nullable mais pas seulement pour le type de valeur

Un autre exemple

    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;
        }
    }
0
GSerjo