Modèle de générateur: quand échouer?

45

Lors de la mise en œuvre du modèle de constructeur, je suis souvent confus quant au moment de laisser échouer la construction et je parviens même à prendre des positions différentes sur le sujet tous les deux ou trois jours.

D'abord quelques explications:

  • Par échec précoce, je veux dire que la construction d’un objet doit échouer dès qu’un paramètre non valide est transmis SomeObjectBuilder.
  • Par échec tardif, je veux dire que la construction d'un objet ne peut échouer que sur l' build()appel qui appelle implicitement un constructeur de l'objet à construire.

Puis quelques arguments:

  • En faveur de l'échec tardif: une classe de générateur ne devrait être qu'une classe qui contient simplement des valeurs. De plus, cela entraîne moins de duplication de code.
  • En faveur de l'échec précoce: Une approche générale de la programmation logicielle consiste à détecter les problèmes le plus tôt possible. Par conséquent, l'endroit le plus logique à vérifier serait dans le constructeur, la classe et la méthode de construction de la classe de construction.

Quel est le consensus général à ce sujet?

skiwi
la source
8
Je ne vois aucun avantage à échouer tard. Ce que quelqu'un dit d'une classe de construction "devrait" être n'a pas préséance sur une bonne conception, et il est toujours préférable d'attraper les bogues tôt que de les attraper tard.
Doval
3
Une autre façon de voir les choses est que le constructeur peut ne pas savoir quelles données sont valides. Si vous échouez tôt dans cette affaire, vous échouez dès que vous savez qu'il y a une erreur. Ne pas échouer plus tôt serait le constructeur retournant un nullobjet en cas de problème build().
Chris
Si vous n’ajoutez pas de moyen d’émettre un avertissement et d’offrir un moyen de corriger le constructeur, il est inutile d’échouer en retard.
Mark

Réponses:

34

Regardons les options, où nous pouvons placer le code de validation:

  1. À l'intérieur des setters dans le constructeur.
  2. Dans la build()méthode.
  3. À l'intérieur de l'entité construite: il sera invoqué dans la build()méthode lors de la création de l'entité.

L'option 1 nous permet de détecter les problèmes plus tôt, mais il peut y avoir des cas compliqués où nous pouvons valider une entrée ayant uniquement le contexte complet, réalisant ainsi au moins une partie de la validation dans la build()méthode. Ainsi, le choix de l'option 1 entraînera une incohérence du code, une partie de la validation étant effectuée à un endroit et une autre à un autre endroit.

L'option 2 n'est pas bien pire que l'option 1, car, en règle générale, les paramètres de configuration dans le générateur sont appelés juste avant les build()interfaces particulièrement fluides. Ainsi, il est toujours possible de détecter un problème suffisamment tôt dans la plupart des cas. Toutefois, si le générateur n'est pas le seul moyen de créer un objet, cela entraînera une duplication du code de validation, car vous devrez l'avoir partout où vous créez un objet. La solution la plus logique dans ce cas sera de placer la validation le plus près possible de l’objet créé, c’est-à-dire à l’intérieur de celui-ci. Et c'est l' option 3 .

Du point de vue de SOLID, placer la validation dans le générateur constitue également une violation de SRP: la classe de générateur a déjà la responsabilité d'agréger les données pour construire un objet. La validation consiste à établir des contrats sur son propre état interne. Il est de la nouvelle responsabilité de vérifier l'état d'un autre objet.

Ainsi, de mon point de vue, non seulement il vaut mieux échouer tard du point de vue de la conception, mais il est également préférable d'échouer à l'intérieur de l'entité construite, plutôt que dans le constructeur lui-même.

UPD: ce commentaire m'a rappelé une possibilité supplémentaire, lorsque la validation à l'intérieur du constructeur (option 1 ou 2) est logique. Cela a du sens si le constructeur a ses propres contrats sur les objets qu'il crée. Par exemple, supposons que nous ayons un générateur qui construit une chaîne avec un contenu spécifique, par exemple une liste de plages de nombres 1-2,3-4,5-6. Ce constructeur peut avoir une méthode comme addRange(int min, int max). La chaîne résultante ne sait rien de ces chiffres, elle ne devrait pas non plus avoir à le savoir. Le constructeur lui-même définit le format de la chaîne et les contraintes sur les nombres. Ainsi, la méthode addRange(int,int)doit valider les nombres saisis et lever une exception si max est inférieur à min.

Cela dit, la règle générale sera de ne valider que les contrats définis par le constructeur lui-même.

Ivan Gammel
la source
Je pense que cela vaut la peine de noter que si l' option 1 peut conduire à des temps de contrôle "incohérents", elle peut néanmoins être considérée comme cohérente si tout est "le plus tôt possible". Il est un peu plus facile de définir "le plus tôt possible" plus précis si la variante du constructeur, StepBuilder, est utilisée à la place.
Joshua Taylor
Si un générateur d'URI lève une exception si une chaîne NULL est passée, s'agit-il d'une violation de SOLID? Déchets
Gusdor
@Gusdor oui, s'il lève une exception elle-même. Cependant, du point de vue de l'utilisateur, toutes les options semblent qu'une exception est levée par un générateur.
Ivan Gammel
Alors pourquoi ne pas avoir un validate () appelé par build ()? De cette façon, il y a peu de duplication, de cohérence et pas de violation de SRP. Cela permet également de valider les données sans tenter de construire, et la validation est proche de la création.
StellarVortex
@StellarVortex dans ce cas, il sera validé deux fois - une fois dans le builder.build (), et, si les données sont valides et que nous passons au constructeur de l'objet, dans ce constructeur.
Ivan Gammel
34

Étant donné que vous utilisez Java, tenez compte des indications détaillées et faisant autorité fournies par Joshua Bloch dans l’article Création et destruction d’objets Java (la police en gras dans la citation ci-dessous est la mienne):

A l'instar d'un constructeur, un constructeur peut imposer des invariants à ses paramètres. La méthode de construction peut vérifier ces invariants. Il est essentiel de les vérifier après avoir copié les paramètres du générateur dans l'objet et de les vérifier sur les champs de l'objet plutôt que sur les champs du générateur (élément 39). Si des invariants sont violés, la méthode de construction doit lancer un IllegalStateException(Item 60). La méthode de détail de l'exception devrait indiquer quel invariant est violé (élément 63).

Une autre façon d'imposer des invariants impliquant plusieurs paramètres consiste à faire en sorte que les méthodes de définition prennent des groupes entiers de paramètres sur lesquels certains invariants doivent être conservés. Si l'invariant n'est pas satisfait, la méthode de définition émet un IllegalArgumentException. Cela présente l'avantage de détecter l'échec invariant dès que les paramètres non valides sont passés, au lieu d'attendre que la construction soit appelée.

Notez que, selon l' explication de l'éditeur concernant cet article, les "éléments" cités ci-dessus font référence aux règles présentées dans Effective Java, Second Edition .

L'article n'aborde pas en profondeur les raisons pour lesquelles cela est recommandé, mais si vous y réfléchissez, les raisons sont assez évidentes. Un conseil générique sur cette compréhension est fourni ici même dans l'article, dans l'explication du lien entre le concept de générateur et celui de constructeur - et les invariants de classe doivent être vérifiés dans le constructeur, et non dans tout autre code pouvant précéder / préparer son invocation.

Pour une compréhension plus concrète de la raison pour laquelle vérifier les invariants avant d'invoquer une construction serait une erreur, considérons un exemple populaire de CarBuilder . Les méthodes de générateur peuvent être appelées dans un ordre arbitraire. Par conséquent, il est impossible de savoir si un paramètre particulier est valide jusqu'à la construction.

Considérez que la voiture de sport ne peut avoir plus de 2 sièges. Comment savoir si setSeats(4)ça va ou non? C'est seulement à la construction que l'on peut savoir avec certitude s'il a setSportsCar()été invoqué ou non, ce qui signifie qu'il faut lancer TooManySeatsExceptionou non.

moucheron
la source
3
+1 pour recommander les types d'exceptions à lancer, exactement ce que je cherchais.
Xantix
Pas sûr que je reçois l'alternative. Il semble parler uniquement du moment où les invariants ne peuvent être validés que par groupes. Le générateur accepte des attributs uniques lorsqu'ils n'en impliquent pas d'autres et n'accepte des groupes d'attributs que lorsque le groupe a un invariant sur lui-même. Dans ce cas, l'attribut unique doit-il générer une exception avant la construction?
Didier A.
19

Les valeurs non valides, car elles ne sont pas tolérées, doivent être immédiatement signalées à mon avis. En d'autres termes, si vous n'acceptez que des nombres positifs et qu'un nombre négatif est transmis, il n'est pas nécessaire d'attendre jusqu'à l' build()appel. Je ne considérerais pas ces types de problèmes que vous "attendez" vraisemblablement, ce qui est une condition préalable à l'appel de la méthode. En d'autres termes, vous ne dépendriez probablement pas de l'échec de la définition de certains paramètres. Il est plus probable que vous supposiez que les paramètres sont corrects ou que vous effectuiez vous-même une vérification.

Cependant, pour des problèmes plus complexes qui ne sont pas aussi facilement validés, il peut être préférable de vous faire connaître lorsque vous appelez build(). Un bon exemple de ceci pourrait être l'utilisation des informations de connexion que vous fournissez pour établir une connexion à une base de données. Dans ce cas, alors que vous pouviez techniquement vérifier de telles conditions, cela n’est plus intuitif et cela ne fait que compliquer votre code. À mon avis, ce sont aussi les types de problèmes qui peuvent survenir et que vous ne pouvez pas anticiper tant que vous n’avez pas essayé. C'est un peu la différence entre faire correspondre une chaîne avec une expression régulière pour voir si elle peut être analysée comme un int et essayer simplement de l'analyser, en gérant les éventuelles exceptions pouvant en résulter.

En général, je n'aime pas lancer des exceptions lors de la définition des paramètres, car cela signifie qu'il faut attraper toute exception levée. Je préfère donc la validation dans build(). Donc, pour cette raison, je préfère utiliser RuntimeException car, encore une fois, les erreurs dans les paramètres passés ne devraient généralement pas se produire.

Cependant, il s’agit plus d’une meilleure pratique que tout. J'espère que cela répond à votre question.

Neil
la source
11

Autant que je sache, la pratique générale (ne pas savoir s’il existe un consensus) est d’échouer dès que possible pour découvrir une erreur. Cela rend également plus difficile l'utilisation abusive non intentionnelle de votre API.

S'il s'agit d'un attribut trivial pouvant être vérifié en entrée, tel qu'une capacité ou une longueur qui doit être non négative, il est préférable d'échouer immédiatement. En retenant l'erreur, la distance entre l'erreur et le retour d'information augmente, ce qui rend plus difficile la recherche de la source du problème.

Si vous avez le malheur de vous trouver dans une situation où la validité d'un attribut dépend d'autres attributs, vous avez le choix entre deux options:

  • Exiger que les deux attributs (ou plus) soient fournis simultanément (c'est-à-dire l'invocation d'une méthode unique).
  • Testez la validité dès que vous savez qu'il n'y a plus de changements en attente: à quel moment build()est-il appelé?

Comme dans la plupart des cas, il s’agit d’une décision prise dans un contexte. Si le contexte rend difficile ou compliqué d’échouer tôt, il est possible de choisir un délai pour reporter les contrôles à une date ultérieure, mais l’échec-rapide devrait être la valeur par défaut.

JvR
la source
Donc, pour résumer, vous dites qu'il est raisonnable de valider le plus tôt possible tout ce qui aurait pu être couvert dans un type objet / primitif? Comme unsigned, @NonNulletc.
skiwi
2
@skiwi Assez, oui. Contrôles de domaine, contrôles nuls, ce genre de chose. Je ne recommanderais pas d'y mettre beaucoup plus que cela: les constructeurs sont généralement des choses simples.
JvR
1
Il peut être intéressant de noter que si la validité d'un paramètre dépend de la valeur d'un autre, on ne peut légitimement rejeter une valeur de paramètre que si l'on sait que l'autre est "réellement" établi . S'il est permis de définir plusieurs fois une valeur de paramètre [le dernier paramètre étant prioritaire], dans certains cas, le moyen le plus naturel de configurer un objet peut être de définir le paramètre Xsur une valeur non valide compte tenu de la valeur actuelle de Y, mais avant d'appeler build()mis Yà une valeur qui rendrait Xvalide.
Supercat
Si par exemple on est la construction d' un Shapeet le constructeur a WithLeftet WithRightpropriétés, et on souhaite ajuster un entrepreneur pour construire un objet dans un endroit différent, exigeant que WithRightsoit d' abord appelé lors du déplacement d' un droit d'objet, et WithLeftlorsque vous le déplacez à gauche, ajouterait une complexité inutile par rapport à permettre WithLeftde définir le bord gauche à droite de l'ancien bord droit, à condition que WithRightle bord droit soit corrigé avant l' buildappel.
Supercat
0

La règle de base est "échouer tôt".

La règle légèrement plus avancée est "échouer le plus tôt possible".

Si une propriété est intrinsèquement invalide ...

CarBuilder.numberOfWheels( -1 ). ...  

... alors vous le rejetez immédiatement.

D'autres cas peuvent nécessiter une vérification combinée des valeurs et être mieux placés dans la méthode build ():

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
Phill W.
la source