La modification d'un objet transmis par référence est-elle une mauvaise pratique?

12

Dans le passé, j'ai généralement effectué la majeure partie de ma manipulation d'un objet dans la méthode principale en cours de création / mise à jour, mais je me suis retrouvé à adopter une approche différente récemment, et je suis curieux de savoir si c'est une mauvaise pratique.

Voici un exemple. Disons que j'ai un référentiel qui accepte une Userentité, mais avant d'insérer l'entité, nous appelons certaines méthodes pour nous assurer que tous ses champs sont définis sur ce que nous voulons. Maintenant, plutôt que d'appeler des méthodes et de définir les valeurs de champ à partir de la méthode Insert, j'appelle une série de méthodes de préparation qui façonnent l'objet avant son insertion.

Ancienne méthode:

public void InsertUser(User user) {
    user.Username = GenerateUsername(user);
    user.Password = GeneratePassword(user);

    context.Users.Add(user);
}

Nouvelles méthodes:

public void InsertUser(User user) {
    SetUsername(user);
    SetPassword(user);

    context.Users.Add(user);
}

private void SetUsername(User user) {
    var username = "random business logic";

    user.Username = username;
}

private void SetPassword(User user) {
    var password = "more business logic";

    user.Password = password;
}

Fondamentalement, la pratique de définir la valeur d'une propriété à partir d'une autre méthode est-elle une mauvaise pratique?

JD Davis
la source
6
Juste pour qu'il soit dit ... vous n'avez rien passé par référence ici. Passer des références par valeur n'est pas du tout la même chose.
cHao
4
@cHao: C'est une distinction sans différence. Le comportement du code est le même malgré tout.
Robert Harvey
2
@JDDavis: Fondamentalement, la seule vraie différence entre vos deux exemples est que vous avez donné un nom significatif aux actions set user name et set password.
Robert Harvey
1
Tous vos appels sont référencés ici. Vous devez utiliser un type de valeur en C # pour passer par valeur.
Frank Hileman
2
@FrankHileman: Tous les appels sont en valeur ici. C'est la valeur par défaut en C #. Passer une référence et passer par référence sont des bêtes différentes, et la distinction est importante. Si useron passé par référence, le code pourrait tirer sur ce dernier des mains et de le remplacer par de l'appelant simplement en disant, par exemple, user = null;.
cHao

Réponses:

10

Le problème ici est qu'un Userpeut contenir en fait deux choses différentes:

  1. Une entité utilisateur complète, qui peut être transmise à votre magasin de données.

  2. L'ensemble des éléments de données requis de l'appelant pour commencer le processus de création d'une entité utilisateur. Le système doit ajouter un nom d'utilisateur et un mot de passe avant qu'il ne soit vraiment un utilisateur valide comme au n ° 1 ci-dessus.

Cela comprend une nuance non documentée de votre modèle d'objet qui n'est pas du tout exprimée dans votre système de type. Il vous suffit de le "connaître" en tant que développeur. Ce n'est pas génial, et cela conduit à des modèles de code étranges comme celui que vous rencontrez.

Je suggère que vous ayez besoin de deux entités, par exemple une Userclasse et une EnrollRequestclasse. Ce dernier peut contenir tout ce que vous devez savoir pour créer un utilisateur. Il ressemblerait à votre classe d'utilisateurs, mais sans le nom d'utilisateur et le mot de passe. Ensuite, vous pouvez faire ceci:

public User InsertUser(EnrollRequest request) {
    var userName = GenerateUserName();
    var password = GeneratePassword();

    //You might want to replace this with a factory call, but "new" works here as an example
    var newUser = new User
    (
        request.Name, 
        request.Email, 
        userName, 
        password
    );
    context.Users.Add(user);
    return newUser;
}

L'appelant démarre avec uniquement les informations d'inscription et récupère l'utilisateur terminé après son insertion. De cette façon, vous évitez de muter des classes et vous avez également une distinction sécurisée entre un utilisateur qui est inséré et un autre qui ne l'est pas.

John Wu
la source
2
Une méthode avec un nom comme InsertUserest susceptible d'avoir l'effet secondaire de l'insertion. Je ne sais pas ce que "icky" signifie dans ce contexte. Quant à l'utilisation d'un "utilisateur" qui n'a pas été ajouté, vous semblez avoir manqué le point. S'il n'a pas été ajouté, ce n'est pas un utilisateur, juste une demande de création d'un utilisateur. Vous êtes bien sûr libre de travailler avec la demande.
John Wu
@candiedorange Ce ne sont pas des effets secondaires, ce sont les effets désirés d'appeler la méthode. Si vous ne voulez pas ajouter immédiatement, vous créez une autre méthode qui satisfait le cas d'utilisation. Mais ce n'est pas le cas d'utilisation passé dans la question, alors disons que la préoccupation n'est pas importante.
Andy
@candiedorange Si le couplage facilite le code client, je dirais que ça va. Ce que les développeurs ou les poseurs pourraient penser à l'avenir est sans importance. Si ce moment arrive, vous changez le code. Rien n'est plus inutile que d'essayer de créer du code capable de gérer tout éventuel futur cas d'utilisation.
Andy
5
@CandiedOrange Je vous suggère d'essayer de ne pas coupler étroitement le système de type défini avec précision de l'implémentation avec les entités conceptuelles en anglais simple de l'entreprise. Un concept commercial d '"utilisateur" peut certainement être implémenté en deux classes, sinon plus, pour représenter des variantes fonctionnellement significatives. Un utilisateur qui n'est pas persistant n'a pas la garantie d'un nom d'utilisateur unique, n'a pas de clé primaire à laquelle les transactions pourraient être liées et ne peut même pas se connecter, donc je dirais qu'il comprend une variante importante. Pour un profane, bien sûr, l'EnrollRequest et l'Utilisateur sont tous deux des «utilisateurs», dans leur langage vague.
John Wu
5

Les effets secondaires sont corrects tant qu'ils ne surviennent pas de façon inattendue. Il n'y a donc rien de mal en général lorsqu'un référentiel a une méthode acceptant un utilisateur et change son état interne. Mais à mon humble avis, un nom de méthode comme InsertUser ne le communique pas clairement , et c'est ce qui le rend sujet aux erreurs. Lors de l'utilisation de votre repo, je m'attendrais à un appel comme

 repo.InsertUser(user);

pour modifier l'état interne des référentiels, pas l'état de l'objet utilisateur. Ce problème existe dans les deux de vos mises en œuvre, comment le InsertUserfait est interne complètement hors de propos.

Pour résoudre ce problème, vous pouvez soit

  • initialisation distincte de l'insertion (donc l'appelant InsertUserdoit fournir un Userobjet entièrement initialisé , ou,

  • intégrer l'initialisation dans le processus de construction de l'objet utilisateur (comme suggéré par certaines des autres réponses), ou

  • essayez simplement de trouver un meilleur nom pour la méthode qui exprime plus clairement ce qu'elle fait.

Alors , choisissez un nom de méthode comme PrepareAndInsertUser, OrchestrateUserInsertion, InsertUserWithNewNameAndPasswordou tout ce que vous préférez faire l'effet secondaire plus clair.

Bien sûr, un nom de méthode aussi long indique que la méthode fait peut-être «trop» (violation SRP), mais parfois vous ne voulez pas ou ne pouvez pas facilement résoudre ce problème, et c'est la solution la moins intrusive et pragmatique.

Doc Brown
la source
3

J'examine les deux options que vous avez choisies et je dois dire que je préfère de loin l'ancienne méthode plutôt que la nouvelle méthode proposée. Il y a plusieurs raisons à cela, même si elles font fondamentalement la même chose.

Dans les deux cas, vous définissez le user.UserNameet user.Password. J'ai des réserves sur l'élément de mot de passe, mais ces réserves ne sont pas pertinentes pour le sujet traité.

Implications de la modification des objets de référence

  • Cela rend la programmation simultanée plus difficile - mais toutes les applications ne sont pas multithread
  • Ces modifications peuvent être surprenantes, surtout si rien dans la méthode ne suggère que cela se produirait
  • Ces surprises peuvent rendre la maintenance plus difficile

Ancienne méthode vs nouvelle méthode

L'ancienne méthode rendait les choses plus faciles à tester:

  • GenerateUserName()est testable indépendamment. Vous pouvez écrire des tests sur cette méthode et vous assurer que les noms sont générés correctement
  • Si le nom requiert des informations de l'objet utilisateur, vous pouvez modifier la signature GenerateUserName(User user)et conserver cette testabilité

La nouvelle méthode masque les mutations:

  • Vous ne savez pas que l' Userobjet change jusqu'à 2 couches de profondeur
  • Les modifications apportées à l' Userobjet sont plus surprenantes dans ce cas
  • SetUserName()fait plus que définir un nom d'utilisateur. Ce n'est pas vrai dans la publicité, ce qui rend plus difficile pour les nouveaux développeurs de découvrir comment les choses fonctionnent dans votre application
Berin Loritsch
la source
1

Je suis entièrement d'accord avec la réponse de John Wu. Sa suggestion est bonne. Mais il manque légèrement votre question directe.

Fondamentalement, la pratique de définir la valeur d'une propriété à partir d'une autre méthode est-elle une mauvaise pratique?

Pas intrinsèquement.

Vous ne pouvez pas aller trop loin, car vous rencontrerez un comportement inattendu. Par exemple PrintName(myPerson), ne devrait pas changer l'objet personne, car la méthode implique qu'elle ne s'intéresse qu'à la lecture des valeurs existantes. Mais c'est un argument différent de celui de votre cas, car SetUsername(user)cela implique fortement qu'il va définir des valeurs.


C'est en fait une approche que j'utilise souvent pour les tests unitaires / d'intégration, où je crée une méthode spécifiquement pour modifier un objet afin de définir ses valeurs à une situation particulière que je veux tester.

Par exemple:

var myContract = CreateEmptyContract();

ArrrangeContractDeletedStatus(myContract);

Je m'attends explicitement à ce que la ArrrangeContractDeletedStatusméthode change l'état de l' myContractobjet.

Le principal avantage est que la méthode me permet de tester la suppression de contrat avec différents contrats initiaux; Par exemple, un contrat qui a un long historique de statut ou qui n'a pas d'historique de statut précédent, un contrat qui a un historique de statut intentionnellement erroné, un contrat que mon utilisateur de test n'est pas autorisé à supprimer.

Si j'avais fusionné CreateEmptyContractet ArrrangeContractDeletedStatusen une seule méthode; Je devrais créer plusieurs variantes de cette méthode pour chaque contrat différent que je voudrais tester dans un état supprimé.

Et bien que je puisse faire quelque chose comme:

myContract = ArrrangeContractDeletedStatus(myContract);

C'est soit redondant (puisque je change de toute façon l'objet), soit je me force maintenant à faire un clone profond de l' myContractobjet; ce qui est excessivement difficile si vous voulez couvrir tous les cas (imaginez si je veux plusieurs propriétés de navigation sur plusieurs niveaux. Dois-je les cloner toutes? Seule l'entité de niveau supérieur? ... Tant de questions, tant d'attentes implicites )

Changer l'objet est le moyen le plus simple d'obtenir ce que je veux sans avoir à faire plus de travail juste pour éviter de ne pas changer l'objet par principe.


Donc, la réponse directe à votre question est que ce n'est pas une mauvaise pratique en soi, tant que vous ne masquez pas que la méthode est susceptible de changer l'objet transmis. Pour votre situation actuelle, cela est clairement expliqué par le nom de la méthode.

Flater
la source
0

Je trouve l'ancienne comme la nouvelle problématique.

À l'ancienne:

1) InsertUser(User user)

En lisant ce nom de méthode qui apparaît dans mon IDE, la première chose qui me vient à l'esprit est

Où l'utilisateur est-il inséré?

Le nom de la méthode doit être lu AddUserToContext. Au moins, c'est ce que la méthode fait à la fin.

2) InsertUser(User user)

Cela viole clairement le principe de la moindre surprise. Disons, j'ai fait mon travail jusqu'à présent et créé une nouvelle instance de a Useret lui ai donné un nameet mis un password, je serais surpris par:

a) cela n'insère pas seulement un utilisateur dans quelque chose

b) cela sape également mon intention d'insérer l'utilisateur tel quel; le nom et le mot de passe ont été remplacés.

c) cela indique-t-il qu'il s'agit également d'une violation du principe de responsabilité unique.

Nouvelle façon:

1) InsertUser(User user)

Violation toujours du SRP et principe de la moindre surprise

2) SetUsername(User user)

Question

Définir le nom d'utilisateur? À quoi?

Mieux: SetRandomNameou quelque chose qui reflète l'intention.

3) SetPassword(User user)

Question

Définir le mot de passe? À quoi?

Mieux: SetRandomPasswordou quelque chose qui reflète l'intention.


Suggestion:

Ce que je préférerais lire serait quelque chose comme:

public User GenerateRandomUser(UserFactory Uf){
    User u = Uf.GetUser();
    u.Name = GenerateRandomUserName();
    u.Password = GenerateRandomUserPassword();
    return u;
}

...

public void AddUserToContext(User u){
    this.context.Users.Add(u);
}

Concernant votre question initiale:

La modification d'un objet transmis par référence est-elle une mauvaise pratique?

Non, c'est plus une question de goût.

Thomas Junk
la source