web-dev-qa-db-fra.com

Concevoir une classe pour prendre des classes entières comme paramètres plutôt que comme propriétés individuelles

Disons, par exemple, que vous avez une application avec une classe largement partagée appelée User. Cette classe expose toutes les informations sur l'utilisateur, son identifiant, son nom, les niveaux d'accès à chaque module, le fuseau horaire, etc.

Les données utilisateur sont évidemment largement référencées dans tout le système, mais pour une raison quelconque, le système est configuré de sorte qu'au lieu de passer cet objet utilisateur dans les classes qui en dépendent, nous en transmettons simplement des propriétés individuelles.

Une classe qui nécessite l'ID utilisateur, nécessitera simplement le GUID userId comme paramètre, parfois nous pourrions également avoir besoin du nom d'utilisateur, de sorte qu'il soit transmis en tant que paramètre séparé Dans certains cas, cela est transmis à des méthodes individuelles, donc les valeurs ne sont pas du tout conservées au niveau de la classe.

Chaque fois que j'ai besoin d'accéder à une information différente de la classe User, je dois apporter des modifications en ajoutant des paramètres et lorsque l'ajout d'une nouvelle surcharge n'est pas approprié, je dois également modifier chaque référence à la méthode ou au constructeur de classe.

L'utilisateur n'est qu'un exemple. Ceci est largement pratiqué dans notre code.

Ai-je raison de penser qu'il s'agit d'une violation du principe ouvert/fermé? Pas seulement l'acte de changer les classes existantes, mais de les mettre en place en premier lieu afin que des changements généralisés soient très probablement nécessaires à l'avenir?

Si nous venons de passer dans l'objet User, je pourrais apporter une petite modification à la classe avec laquelle je travaille. Si je dois ajouter un paramètre, je devrai peut-être apporter des dizaines de modifications aux références à la classe.

Y a-t-il d'autres principes brisés par cette pratique? Inversion de dépendance peut-être? Bien que nous ne référençons pas une abstraction, il n'y a qu'un seul type d'utilisateur, il n'est donc pas vraiment nécessaire d'avoir une interface utilisateur.

Y a-t-il d'autres principes non SOLIDES violés, tels que les principes de programmation défensive de base?

Si mon constructeur ressemble à ceci:

MyConstructor(GUID userid, String username)

Ou ca:

MyConstructor(User theUser)

Posté:

Il a été suggéré de répondre à la question sous "ID passe ou objet?". Cela ne répond pas à la question de savoir comment la décision d'aller dans les deux sens affecte une tentative de suivre les principes SOLID, qui est au cœur de cette question.

30
Jimbo

Il n'y a absolument rien de mal à passer un objet User entier comme paramètre. En fait, cela pourrait aider à clarifier votre code et à rendre plus évident pour les programmeurs ce qu'une méthode prend si la signature de la méthode nécessite un User.

Passer des types de données simples est agréable, jusqu'à ce qu'ils signifient autre chose que ce qu'ils sont. Considérez cet exemple:

public class Foo
{
    public void Bar(int userId)
    {
        // ...
    }
}

Et un exemple d'utilisation:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user.Id);

Pouvez-vous repérer le défaut? Le compilateur ne peut pas. L '"ID utilisateur" transmis n'est qu'un entier. Nous nommons la variable user mais initialisons sa valeur à partir de l'objet blogPostRepository, qui renvoie vraisemblablement BlogPost objets, pas User objets - pourtant le code compile et vous se retrouver avec une erreur d'exécution bancale.

Considérons maintenant cet exemple modifié:

public class Foo
{
    public void Bar(User user)
    {
        // ...
    }
}

Peut-être que la méthode Bar utilise uniquement l '"ID utilisateur" mais la signature de la méthode nécessite un objet User. Revenons maintenant au même exemple d'utilisation qu'avant, mais modifions-le pour passer l'intégralité de l '"utilisateur" dans:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user);

Nous avons maintenant une erreur de compilation. La méthode blogPostRepository.Find Renvoie un objet BlogPost, que nous appelons habilement "utilisateur". Ensuite, nous transmettons cet "utilisateur" à la méthode Bar et obtenons rapidement une erreur de compilation, car nous ne pouvons pas passer un BlogPost à une méthode qui accepte un User.

Le système de type du langage est utilisé pour écrire le code correct plus rapidement et identifier les défauts au moment de la compilation plutôt qu'au moment de l'exécution.

Vraiment, devoir refactoriser beaucoup de code parce que les informations utilisateur changent n'est qu'un symptôme d'autres problèmes. En passant un objet User entier, vous bénéficiez des avantages ci-dessus, en plus de ne pas avoir à refactoriser toutes les signatures de méthode qui acceptent les informations utilisateur lorsque quelque chose à propos de la classe User change.

31
Greg Burghardt

Ai-je raison de penser qu'il s'agit d'une violation du principe ouvert/fermé?

Non, ce n'est pas une violation de ce principe. Ce principe vise à ne pas modifier User de manière à affecter les autres parties du code qui l'utilisent. Vos modifications dans User pourraient constituer une telle violation, mais elles ne sont pas liées.

Y a-t-il d'autres principes brisés par cette pratique? Peut-être une inversion de dépendance?

Non. Ce que vous décrivez - en injectant uniquement les parties requises d'un objet utilisateur dans chaque méthode - est le contraire: c'est une inversion de dépendance pure.

Y a-t-il d'autres principes non SOLIDES violés, tels que les principes de programmation défensive de base?

Non. Cette approche est un moyen de codage parfaitement valable. Il ne viole pas de tels principes.

Mais l'inversion de dépendance n'est qu'un principe; ce n'est pas une loi incassable. Et la DI pure peut ajouter de la complexité au système. Si vous constatez que l'injection des valeurs utilisateur nécessaires dans les méthodes plutôt que de passer l'intégralité de l'objet utilisateur dans la méthode ou le constructeur crée des problèmes, ne le faites pas de cette façon. Il s'agit de trouver un équilibre entre les principes et le pragmatisme.

Pour répondre à votre commentaire:

Il y a des problèmes à analyser inutilement une nouvelle valeur en bas de cinq niveaux de la chaîne, puis à modifier toutes les références aux cinq méthodes existantes ...

Une partie du problème ici est que vous n'aimez clairement pas cette approche, selon le commentaire "inutilement [passer] ...". Et c'est assez juste; il n'y a pas de bonne réponse ici. Si vous trouvez cela pénible, ne le faites pas de cette façon.

Cependant, en ce qui concerne le principe ouvert/fermé, si vous suivez cela strictement alors "... changer toutes les références aux cinq de ces méthodes existantes ..." est une indication que ces méthodes ont été modifiées, quand elles devraient être fermé à modification. En réalité cependant, le principe ouvert/fermé est logique pour les API publiques, mais n'a pas beaucoup de sens pour les internes d'une application.

... mais il est certain qu'un plan pour respecter ce principe dans la mesure du possible inclurait des stratégies pour réduire le besoin de changements futurs?

Mais alors vous vous promenez dans territoire YAGNI et ce serait toujours orthogonal au principe. Si vous avez la méthode Foo qui prend un nom d'utilisateur et que vous voulez que Foo prenne également une date de naissance, suivant le principe, vous ajoutez une nouvelle méthode; Foo reste inchangé. Encore une fois, c'est une bonne pratique pour les API publiques, mais c'est un non-sens pour le code interne.

Comme mentionné précédemment, il s'agit d'équilibre et de bon sens pour une situation donnée. Si ces paramètres changent souvent, alors oui, utilisez directement User. Cela vous évitera les changements à grande échelle que vous décrivez. Mais s'ils ne changent pas souvent, transmettre uniquement ce qui est nécessaire est également une bonne approche.

17
David Arno

Oui, la modification d'une fonction existante est une violation du principe ouvert/fermé. Vous modifiez quelque chose qui devrait être fermé en raison de la modification des exigences. Une meilleure conception (pour ne pas changer lorsque les exigences changent) serait de passer à l'utilisateur des choses qui devraient fonctionner sur les utilisateurs.

Mais cela pourrait aller à l'encontre du principe de ségrégation d'interface, car vous pourriez transmettre plus plus d'informations que la fonction n'a besoin pour faire son travail.

Donc, comme avec la plupart des choses - cela dépend.

En utilisant uniquement un nom d'utilisateur, la fonction sera plus flexible, en travaillant avec les noms d'utilisateur, peu importe d'où ils viennent et sans avoir besoin de créer un objet utilisateur pleinement fonctionnel. Il offre une résilience au changement si vous pensez que la source des données va changer.

L'utilisation de l'ensemble de l'Utilisateur clarifie l'utilisation et conclut un contrat plus ferme avec ses appelants. Il offre une résilience au changement si vous pensez que davantage d'Utilisateur sera nécessaire.

10
Telastyn

Cette conception suit le Parameter Object Pattern . Il résout les problèmes résultant de la présence de nombreux paramètres dans la signature de la méthode.

Ai-je raison de penser qu'il s'agit d'une violation du principe ouvert/fermé?

Non. L'application de ce modèle active le principe d'ouverture/fermeture (OCP). Par exemple, des classes dérivées de User peuvent être fournies comme paramètres qui induisent un comportement différent dans la classe consommatrice.

Y a-t-il d'autres principes brisés par cette pratique?

Cela peut arriver. Permettez-moi de vous expliquer sur la base des principes SOLID.

Le principe de responsabilité unique (SRP) peut être violé s'il a la conception comme vous l'avez expliqué:

Cette classe expose toutes les informations sur l'utilisateur, son identifiant, son nom, les niveaux d'accès à chaque module, le fuseau horaire, etc.

Le problème est avec toutes les informations. Si la classe User possède de nombreuses propriétés, elle devient un énorme Data Transfer Object qui transporte des informations indépendantes du point de vue des classes consommatrices. Exemple: Du point de vue d'une classe consommatrice UserAuthentication la propriété User.Id et User.Name sont pertinents, mais pas User.Timezone.

Le principe de ségrégation d'interface (ISP) est également violé avec un raisonnement similaire mais ajoute une autre perspective. Exemple: supposons qu'une classe consommatrice UserManagement nécessite la propriété User.Name à diviser en User.LastName et User.FirstName la classe UserAuthentication doit également être modifiée pour cela.

Heureusement, le FAI vous offre également une solution possible au problème: généralement, ces objets paramètres ou objets de transport de données commencent petit et augmentent avec le temps. Si cela devient compliqué, envisagez l'approche suivante: introduisez des interfaces adaptées aux besoins des classes consommatrices. Exemple: introduisez des interfaces et laissez la classe User en dériver:

class User : IUserAuthenticationInfo, IUserLocationInfo { ... }

Chaque interface doit exposer un sous-ensemble de propriétés connexes de la classe User nécessaire à une classe consommatrice pour remplir son fonctionnement. Recherchez des groupes de propriétés. Essayez de réutiliser les interfaces. Dans le cas de la classe consommatrice UserAuthentication utilisez IUserAuthenticationInfo au lieu de User. Ensuite, si possible, divisez la classe User en plusieurs classes concrètes en utilisant les interfaces comme "stencil".

3
Theo Lenndorff

Voyons simplement les aspects individuels de SOLID:

  • Responsabilité unique: éventuellement fiolée si les gens ont tendance à ne faire circuler que des sections de la classe.
  • Ouvert/fermé: Peu importe où les sections de la classe sont transmises, uniquement là où l'objet complet est transmis. (Je pense que c'est là que la dissonance cognitive entre en jeu: vous devez changer le code lointain mais la classe elle-même semble bien.)
  • Substitution de Liskov: Non, nous ne faisons pas de sous-classes.
  • Inversion de dépendance (dépend des abstractions, pas des données concrètes). Oui, c'est violé: les gens n'ont pas d'abstractions, ils retirent des éléments concrets de la classe et les transmettent. Je pense que c'est le principal problème ici.

Une chose qui tend à confondre les instincts de conception est que la classe est essentiellement destinée aux objets globaux et essentiellement en lecture seule. Dans une telle situation, la violation des abstractions ne fait pas beaucoup de mal: la lecture de données non modifiées crée un couplage assez faible; ce n'est que lorsqu'elle devient un énorme tas que la douleur devient perceptible.
Pour rétablir les instincts de conception, supposez simplement que l'objet n'est pas très global. De quel contexte une fonction aurait-elle besoin si l'objet User pouvait être modifié à tout moment? Quels composants de l'objet seraient probablement mutés ensemble? Ceux-ci peuvent être divisés en User, que ce soit en tant que sous-objet référencé ou en tant qu'interface qui expose juste une "tranche" de champs associés n'est pas si important.

Un autre principe: regardez les fonctions qui utilisent des parties de User et voyez quels champs (attributs) ont tendance à aller ensemble. C'est une bonne liste préliminaire de sous-objets - vous devez certainement vous demander s'ils vont vraiment ensemble.

C'est beaucoup de travail, et c'est un peu difficile à faire, et votre code deviendra légèrement moins flexible car il deviendra un peu plus difficile d'identifier le sous-objet (sous-interface) qui doit être transmis à une fonction, en particulier si les sous-objets se chevauchent.

Le fractionnement de User up sera en fait laid si les sous-objets se chevauchent, alors les gens seront confus quant à celui à choisir si tous les champs requis proviennent du chevauchement. Si vous vous séparez hiérarchiquement (par exemple, vous avez UserMarketSegment qui, entre autres, a UserLocation), les gens ne seront pas sûrs du niveau auquel la fonction qu'ils écrivent est: est-ce qu'il s'agit de l'utilisateur des données au niveau Location ou au niveau MarketSegment? Cela n'aide pas vraiment que cela puisse changer avec le temps, c'est-à-dire que vous revenez à changer les signatures de fonction, parfois sur toute une chaîne d'appels.

En d'autres termes: à moins que vous ne connaissiez vraiment votre domaine et que vous n'ayez une idée assez claire de quel module traite quels aspects de User, cela ne vaut pas vraiment la peine d'améliorer la structure du programme.

2
Toolforger

Lorsque confronté à ce problème dans mon propre code, j'ai conclu que les classes/objets de modèle de base sont la réponse.

Un exemple courant serait le modèle de référentiel. Souvent, lors de l'interrogation d'une base de données via des référentiels, de nombreuses méthodes du référentiel prennent en grande partie les mêmes paramètres.

Mes règles générales pour les référentiels sont:

  • Lorsque plusieurs méthodes prennent les mêmes 2 paramètres ou plus, les paramètres doivent être regroupés en tant qu'objet modèle.

  • Lorsqu'une méthode prend plus de 2 paramètres, les paramètres doivent être regroupés en tant qu'objet modèle.

  • Les modèles peuvent hériter d'une base commune, mais seulement lorsque cela a vraiment du sens (généralement mieux refactoriser plus tard que de commencer par l'héritage à l'esprit).


Les problèmes liés à l'utilisation de modèles provenant d'autres couches/zones ne deviennent apparents que lorsque le projet commence à devenir un peu complexe. Ce n'est qu'alors que moins de code crée plus de travail ou plus de complications.

Et oui, il est tout à fait correct d'avoir 2 modèles différents avec des propriétés identiques qui servent différentes couches/objectifs (c.-à-d. ViewModels vs POCO).

2
Dom

Je trouve qu'il vaut mieux passer le moins de paramètres possible et autant que nécessaire. Cela facilite les tests et ne nécessite pas de créer des objets entiers.

Dans votre exemple, si vous n'utilisez que l'ID utilisateur ou le nom d'utilisateur, c'est tout ce que vous devez transmettre. Si ce modèle se répète plusieurs fois et que l'objet utilisateur réel est beaucoup plus grand, mon conseil est de créer une ou des interfaces plus petites pour cela. Il pourrait être

interface IIdentifieable
{
    Guid ID { get; }
}

ou

interface INameable
{
    string Name { get; }
}

Cela rend le test avec la simulation beaucoup plus facile et vous savez instantanément quelles valeurs sont vraiment utilisées. Sinon, vous devez souvent initialiser des objets complexes avec de nombreuses autres dépendances bien qu'à la fin vous ayez juste besoin d'une ou deux propriétés.

1
t3chb0t

Voici quelque chose que j'ai rencontré de temps en temps:

  • Une méthode prend un argument de type User (ou Product ou autre) qui a beaucoup de propriétés, même si la méthode n'en utilise que quelques-unes.
  • Pour une raison quelconque, une partie du code doit appeler cette méthode même si elle n'a pas d'objet User entièrement rempli. Il crée une instance et initialise uniquement les propriétés dont la méthode a réellement besoin.
  • Cela arrive plusieurs fois.
  • Après un certain temps, lorsque vous rencontrez une méthode qui a un argument User, vous devez trouver des appels à cette méthode pour trouver d'où vient le User afin de savoir quelles propriétés sont remplies . S'agit-il d'un "vrai" utilisateur avec une adresse e-mail, ou a-t-il simplement été créé pour transmettre un ID utilisateur et certaines autorisations?

Si vous créez un User et ne remplissez que quelques propriétés car ce sont celles dont la méthode a besoin, alors l'appelant en sait vraiment plus sur le fonctionnement interne de la méthode qu'il ne devrait.

Pire encore, lorsque vous avez une instance de User, vous devez savoir d'où elle provient pour savoir quelles propriétés sont remplies. Vous ne voulez pas avoir à le savoir.

Au fil du temps, lorsque les développeurs voient User utilisé comme conteneur pour les arguments de méthode, ils peuvent commencer à lui ajouter des propriétés pour des scénarios à usage unique. Maintenant, ça devient laid, car la classe est encombrée de propriétés qui seront presque toujours nulles ou par défaut.

Une telle corruption n'est pas inévitable, mais elle se produit encore et encore lorsque nous passons un objet juste parce que nous avons besoin d'accéder à quelques-unes de ses propriétés. La zone de danger est la première fois que vous voyez quelqu'un créer une instance de User et remplir simplement quelques propriétés afin de pouvoir la transmettre à une méthode. Posez votre pied dessus car c'est un chemin sombre.

Dans la mesure du possible, donnez le bon exemple au prochain développeur en ne transmettant que ce dont vous avez besoin.

1
Scott Hannen

C'est une question vraiment intéressante. Cela dépend.

Si vous pensez que votre méthode peut changer à l'avenir en interne pour nécessiter différents paramètres de l'objet User, vous devez certainement passer le tout. L'avantage est que le code externe à la méthode est ensuite protégé contre les modifications au sein de la méthode en termes de paramètres qu'il utilise, ce qui, comme vous le dites, entraînerait une cascade de modifications externes. Ainsi, le passage à l'ensemble de l'utilisateur augmente l'encapsulation.

Si vous êtes sûr que vous n'aurez jamais besoin d'utiliser autre chose que de dire l'e-mail de l'utilisateur, vous devez le transmettre. L'avantage de ceci est que vous pouvez ensuite utiliser la méthode dans un plus large éventail de contextes: vous pouvez l'utiliser par exemple avec l'e-mail d'une entreprise ou avec un e-mail que quelqu'un vient de taper. Cela augmente la flexibilité.

Cela fait partie d'un éventail plus large de questions sur la création de classes pour avoir une portée large ou étroite, y compris l'opportunité d'injecter ou non des dépendances et si des objets sont disponibles globalement. Il y a actuellement une tendance malheureuse à penser qu'une portée plus étroite est toujours bonne. Cependant, il y a toujours un compromis entre l'encapsulation et la flexibilité comme dans ce cas.

1
James Ellis-Jones