Avons-nous besoin de valider l'utilisation complète du module ou seulement les arguments des méthodes publiques?

9

J'ai entendu dire qu'il est recommandé de valider les arguments des méthodes publiques:

La motivation est compréhensible. Si un module est utilisé de manière incorrecte, nous voulons lever immédiatement une exception au lieu de tout comportement imprévisible.

Ce qui me dérange, c'est que les mauvais arguments ne sont pas la seule erreur qui peut être faite lors de l'utilisation d'un module. Voici quelques scénarios d'erreur dans lesquels nous devons ajouter une logique de vérification si nous suivons les recommandations et ne voulons pas d'escalade d'erreur:

  • Appel entrant - arguments inattendus
  • Appel entrant - le module est dans un mauvais état
  • Appel externe - résultats inattendus renvoyés
  • Appel externe - effets secondaires inattendus (double entrée vers un module appelant, rupture d'autres états de dépendances)

J'ai essayé de prendre en compte toutes ces conditions et d'écrire un module simple avec une méthode (désolé, les gars non-C #):

public sealed class Room
{
    private readonly IDoorFactory _doorFactory;
    private bool _entered;
    private IDoor _door;
    public Room(IDoorFactory doorFactory)
    {
        if (doorFactory == null)
            throw new ArgumentNullException("doorFactory");
        _doorFactory = doorFactory;
    }
    public void Open()
    {
        if (_door != null)
            throw new InvalidOperationException("Room is already opened");
        if (_entered)
            throw new InvalidOperationException("Double entry is not allowed");
        _entered = true;
        _door = _doorFactory.Create();
        if (_door == null)
            throw new IncompatibleDependencyException("doorFactory");
        _door.Open();
        _entered = false;
    }
}

Maintenant c'est sûr =)

C'est assez flippant. Mais imaginez à quel point il peut être effrayant dans un vrai module avec des dizaines de méthodes, un état complexe et beaucoup d'appels externes (salut, amateurs d'injection de dépendances!). Notez que si vous appelez un module dont le comportement peut être remplacé (classe non scellée en C #), vous effectuez un appel externe et les conséquences ne sont pas prévisibles au niveau de la portée de l'appelant.

En résumé, quelle est la bonne façon et pourquoi? Si vous pouvez choisir parmi les options ci-dessous, répondez aux questions supplémentaires, s'il vous plaît.

Vérifiez l'utilisation complète du module. Avons-nous besoin de tests unitaires? Existe-t-il des exemples d'un tel code? L'injection de dépendances doit-elle être limitée dans son utilisation (car elle entraînera davantage de logique de vérification)? N'est-il pas pratique de déplacer ces vérifications au moment du débogage (ne pas inclure dans la version)?

Vérifiez uniquement les arguments. D'après mon expérience, la vérification des arguments - en particulier la vérification des valeurs nulles - est la vérification la moins efficace, car l'erreur d'argument conduit rarement à des erreurs complexes et à des escalades d'erreurs. La plupart du temps, vous obtenez un NullReferenceExceptionà la ligne suivante. Alors pourquoi les vérifications d'arguments sont-elles si spéciales?

Ne vérifiez pas l'utilisation du module. C'est une opinion assez impopulaire, pouvez-vous expliquer pourquoi?

astef
la source
Des vérifications doivent être effectuées pendant l'affectation sur le terrain pour s'assurer que les invariants sont conservés.
Basilevs
@Basilevs Intéressant ... Est-ce de l'idéologie des contrats de code ou quelque chose de plus ancien? Pouvez-vous recommander quelque chose à lire (lié à votre commentaire)?
astef
C'est une séparation fondamentale des préoccupations. Tous vos cas sont couverts, tandis que la duplication de code est minime et les responsabilités sont bien définies.
Basilevs
@Basilevs Donc, ne vérifiez pas du tout le comportement des autres modules mais vérifiez vos propres invariants d'état. Semble raisonnable. Mais pourquoi je ne vois pas ce simple reçu dans les questions connexes sur la vérification des arguments?
astef
Eh bien, certaines vérifications comportementales sont encore nécessaires, mais elles ne doivent être effectuées que sur les valeurs réellement utilisées, pas celles qui sont transmises ailleurs. Par exemple, vous comptez sur l'implémentation de la liste pour vérifier les erreurs OOB, par opposition à la vérification de l'index dans le code client. Habituellement, ce sont des échecs de structure de bas niveau et ne nécessitent pas d'être émis manuellement.
Basilevs

Réponses:

2

TL; DR: valider le changement d'état, s'appuyer sur [la validité de] l'état actuel.

Ci-dessous, je ne considère que les vérifications activées pour les versions. Les assertions actives de débogage uniquement sont une forme de documentation, utile à sa manière et hors de portée pour cette question.

Tenez compte des principes suivants:

  • Bon sens
  • Échec rapide
  • SEC
  • SRP

Définitions

  • Composant - une unité fournissant une API
  • Client - utilisateur de l'API du composant

État mutable

Problème

Dans les langues impératives, le symptôme d'erreur et sa cause peuvent être séparés par des heures de levage lourd. La corruption d'État peut se cacher et muter pour aboutir à un échec inexplicable, car l'inspection de l'état actuel ne peut pas révéler le processus complet de corruption et, par conséquent, l'origine de l'erreur.

Solution

Chaque changement d'état doit être soigneusement conçu et vérifié. Une façon de gérer l'état mutable est de le maintenir à son minimum. Ceci est réalisé par:

  • système de type (déclarations const et final des membres)
  • introduction d'invariants
  • vérifier chaque changement d'état du composant via des API publiques

Lorsque vous étendez l'état d'un composant, envisagez de le faire en laissant le compilateur imposer l'immuabilité des nouvelles données. De plus, appliquez toutes les contraintes d'exécution sensibles, en limitant les états résultants potentiels à un ensemble bien défini le plus petit possible.

Exemple

// Wrong
class Natural {
    private int number;
    public Natural(int number) {
        this.number = number;
    }
    public int getInt() {
      if (number < 1)
          throw new InvalidOperationException();
      return number;
    }
}

// Right
class Natural {
    private readonly int number;
    /**
     * @param number - positive number
     */
    public Natural(int number) {
      // Going to modify state, verification is required
      if (number < 1)
        throw new ArgumentException("Natural number should be  positive: " + number);
      this.number = number;
    }
    public int getInt() {
      // State is guaranteed by construction and compiler
      return number;
    }
}

Répétition et cohésion de responsabilité

Problème

La vérification des conditions préalables et des post-conditions des opérations entraîne la duplication du code de vérification à la fois dans le client et dans le composant. La validation de l'appel de composants oblige souvent le client à assumer certaines des responsabilités des composants.

Solution

Comptez sur le composant pour effectuer la vérification de l'état lorsque cela est possible. Les composants doivent fournir une API qui ne nécessite pas de vérification particulière de l'utilisation (vérification des arguments ou application de la séquence d'opérations, par exemple) pour garder l'état du composant bien défini. Ils obligent à vérifier les arguments d'invocation d'API selon les besoins, à signaler les échecs par les moyens nécessaires et à lutter contre leur corruption d'état.

Les clients doivent s'appuyer sur des composants pour vérifier l'utilisation de leur API. Non seulement la répétition est évitée, mais le client ne dépend plus des détails d'implémentation supplémentaires du composant. Considérez le cadre comme un composant. N'écrivez un code de vérification personnalisé que lorsque les invariants des composants ne sont pas assez stricts ou pour encapsuler l'exception des composants comme détail d'implémentation.

Si une opération ne change pas d'état et n'est pas couverte par des vérifications de changement d'état, vérifiez chaque argument au niveau le plus profond possible.

Exemple

class Store {
  private readonly List<int> slots = new List<int>();
  public void putToSlot(int slot, int data) {
    if (slot < 0 || slot >= slots.Count) // Unnecessary, validated by List, only needed for custom error message
      throw new ArgumentException("data");
    slots[slot] = data;
  }
}

class Natural {
   int _number;
   public Natural(int number) {
       if (number < 1)
          number = 1;  //Wrong: client can't rely on argument verification, additional state uncertainity is introduced.  Right: throw new ArgumentException(number);
       _number = number;
   }
}

Réponse

Lorsque les principes décrits sont appliqués à l'exemple en question, nous obtenons:

public sealed class Room
{
    private bool _entered = false;
    // Do not use lazy instantiation if not absolutely necessary, this introduces additional mutable state
    private readonly IDoor _door;
    public Room(IDoorFactory doorFactory)
    {
        // Rely on system null check
        IDoor door = _doorFactory.Create();
        // Modifying own state, verification is required
        if (door == null)
           throw new ArgumentNullException("Door");
        _door = door;
    }
    public void Enter()
    {
        // Room invariants do not guarantee _entered value. Door state is indirectly a part of our state. Verification is required to prevent second door state change below.
        if (_entered)
           throw new InvalidOperationException("Double entry is not allowed");
        _entered = true;     
        // rely on immutability for _door field to be non-null
        // rely on door implementation to control resulting door state       
        _door.Open();            
    }
}

Sommaire

L'état du client se compose de valeurs de champs propres et de parties de l'état du composant qui ne sont pas couvertes par ses propres invariants. La vérification ne doit être effectuée qu'avant le changement d'état réel d'un client.

Basilevs
la source
1

Une classe est responsable de son propre état. Validez donc dans la mesure où il garde ou met les choses dans un état acceptable.

Si un module est utilisé de manière incorrecte, nous voulons lever immédiatement une exception au lieu de tout comportement imprévisible.

Non, ne lancez pas d'exception, mais fournissez plutôt un comportement prévisible. Le corollaire de la responsabilité de l'État est de rendre la classe / application aussi tolérante que possible. Par exemple, passer nullà aCollection.Add()? N'ajoutez pas et continuez. Vous obtenez nulldes informations pour créer un objet? Créez un objet nul ou un objet par défaut. Ci-dessus, l' doorest déjà open? Alors quoi, continuez. DoorFactoryest nul? Créez-en un nouveau. Quand je crée un, enumj'ai toujours un Undefinedmembre. J'utilise libéralement Dictionarys et enumspour définir les choses explicitement; et cela va un long chemin vers la fourniture d'un comportement prévisible.

(Salut, les amateurs d'injection de dépendance!)

Oui, même si je marche dans l'ombre de la vallée des paramètres, je ne crains aucun argument. Pour ce qui précède, j'utilise autant que possible les paramètres par défaut et facultatifs.

Tout ce qui précède permet au traitement interne de continuer. Dans une application particulière, j'ai des dizaines de méthodes dans plusieurs classes avec un seul endroit où une exception est levée. Même alors, ce n'est pas à cause d'arguments nuls ou que je n'ai pas pu continuer le traitement c'est parce que le code a fini par créer un objet "non fonctionnel" / "null".

Éditer

citant mon commentaire dans son intégralité. Je pense que le design ne doit pas simplement "abandonner" lors de la rencontre avec "null". Surtout en utilisant un objet composite.

Nous oublions les concepts / hypothèses clés ici - encapsulation& single responsibility. Il n'y a pratiquement pas de vérification nulle après la première couche interagissant avec le client. Le code est robuste tolérant . Les classes sont conçues avec des états par défaut et fonctionnent donc sans être écrites comme si le code en interaction était un bug indésirable et vicié. Un parent composite n'a pas à descendre dans les couches enfants pour évaluer la validité (et implicitement, vérifier la valeur null dans tous les coins et recoins). Le parent sait ce que signifie l'état par défaut d'un enfant

terminer l'édition

radarbob
la source
1
L'ajout d'un élément de collection non valide est un comportement très imprévisible.
Basilevs
1
Si toutes les interfaces sont conçues de manière aussi tolérante, un jour, à cause d'une erreur banale, les programmes se réveilleront accidentellement et détruiront l'humanité.
astef
Nous oublions les concepts / hypothèses clés ici - encapsulation& single responsibility. Il n'y a pratiquement aucune nullvérification après la première couche interagissant avec le client. Le code est <strike> tolérant </strike> robuste. Les classes sont conçues avec des états par défaut et fonctionnent donc sans être écrites comme si le code interagissant était indésirable et indésirable. Un parent composite n'a pas besoin de descendre dans les couches enfants pour évaluer la validité (et implicitement, vérifier nulltous les coins et recoins). Le parent sait ce que signifie l'état par défaut d'un enfant
radarbob