web-dev-qa-db-fra.com

Pourquoi préférer les variables locales aux variables d'instance?

La base de code sur laquelle je travaille utilise fréquemment des variables d'instance pour partager des données entre diverses méthodes triviales. Le développeur d'origine est catégorique sur le fait que cela respecte les meilleures pratiques énoncées dans le livre Clean Code de l'oncle Bob/Robert Martin: "La première règle des fonctions est qu'elles doivent être petites." et "Le nombre idéal d'arguments pour une fonction est zéro (niladique). (...) Les arguments sont difficiles. Ils prennent beaucoup de pouvoir conceptuel."

Un exemple:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  private byte[] encodedData;
  private EncryptionInfo encryptionInfo;
  private EncryptedObject payloadOfResponse;
  private URI destinationURI;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    getEncodedData(encryptedRequest);
    getEncryptionInfo();
    getDestinationURI();
    passRequestToServiceClient();

    return cryptoService.encryptResponse(payloadOfResponse);
  }

  private void getEncodedData(EncryptedRequest encryptedRequest) {
    encodedData = cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private void getEncryptionInfo() {
    encryptionInfo = cryptoService.getEncryptionInfoForDefaultClient();
  }

  private void getDestinationURI() {
    destinationURI = router.getDestination().getUri();
  }

  private void passRequestToServiceClient() {
    payloadOfResponse = serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }
}

Je voudrais refactoriser cela dans ce qui suit en utilisant des variables locales:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    byte[] encodedData = cryptoService.decryptRequest(encryptedRequest, byte[].class);
    EncryptionInfo encryptionInfo = cryptoService.getEncryptionInfoForDefaultClient();
    URI destinationURI = router.getDestination().getUri();
    EncryptedObject payloadOfResponse = serviceClient.handle(destinationURI, encodedData,
      encryptionInfo);

    return cryptoService.encryptResponse(payloadOfResponse);
  }
}

Ceci est plus court, il élimine le couplage de données implicite entre les différentes méthodes triviales et il limite les portées variables au minimum requis. Pourtant, malgré ces avantages, je n'arrive toujours pas à convaincre le développeur d'origine que cette refactorisation est justifiée, car elle semble contredire les pratiques de l'oncle Bob mentionnées ci-dessus.

D'où mes questions: quelle est la justification objective et scientifique de privilégier les variables locales aux variables d'instance? Je n'arrive pas à mettre le doigt dessus. Mon intuition me dit que les couplages cachés sont mauvais et qu'une portée étroite vaut mieux qu'une portée large. Mais quelle est la science pour étayer cela?

Et inversement, y a-t-il des inconvénients à cette refactorisation que j'ai peut-être négligés?

113
Alexander

Quelle est la justification objective et scientifique de privilégier les variables locales aux variables d'instance?

La portée n'est pas un état binaire, c'est un gradient. Vous pouvez les classer du plus grand au plus petit:

Global > Class > Local (method) > Local (code block, e.g. if, for, ...)

Edit: ce que j'appelle une "portée de classe" est ce que vous entendez par "variable d'instance". À ma connaissance, ceux-ci sont synonymes, mais je suis un développeur C #, pas un développeur Java. Par souci de concision, j'ai regroupé toutes les statistiques dans la catégorie globale, car les statistiques ne sont pas le sujet de la question.

Plus la portée est petite, mieux c'est. La justification est que les variables doivent vivre dans le plus petit champ possible. Cela présente de nombreux avantages:

  • Cela vous oblige à réfléchir à la responsabilité de la classe actuelle et vous aide à vous en tenir à SRP.
  • Il vous permet de ne pas avoir à éviter les conflits de nommage globaux, par ex. si deux ou plusieurs classes ont une propriété Name, vous n'êtes pas obligé de les préfixer comme FooName, BarName, ... Gardez ainsi vos noms de variables aussi propres et concis que possible.
  • Il désencombre le code en limitant les variables disponibles (par exemple pour Intellisense) à celles qui sont contextuellement pertinentes.
  • Il permet une certaine forme de contrôle d'accès afin que vos données ne puissent pas être manipulées par un acteur que vous ne connaissez pas (par exemple, une classe différente développée par un collègue).
  • Cela rend le code plus lisible car vous vous assurez que la déclaration de ces variables essaie de rester aussi proche que possible de l'utilisation réelle de ces variables.
  • Déclarer des variables de manière abusive dans une portée trop large indique souvent un développeur qui ne comprend pas tout à fait OOP ou comment l'implémenter. Voir des variables trop étendues agit comme un indicateur rouge qu'il y a probablement quelque chose se tromper avec l'approche OOP (avec le développeur en général ou la base de code en particulier).
  • (Commentaire de Kevin) L'utilisation des locaux vous oblige à faire les choses dans le bon ordre. Dans le code d'origine (variable de classe), vous pourriez déplacer à tort passRequestToServiceClient() en haut de la méthode, et il compilerait toujours. Avec les locaux, vous ne pouvez faire cette erreur que si vous passez une variable non initialisée, ce qui est, espérons-le, suffisamment évident pour que vous ne le fassiez pas réellement.

Pourtant, malgré ces avantages, je n'arrive toujours pas à convaincre le développeur d'origine que cette refactorisation est justifiée, car elle semble contredire les pratiques de l'oncle Bob mentionnées ci-dessus.

Et inversement, y a-t-il des inconvénients à cette refactorisation que j'ai peut-être négligés?

Le problème ici est que votre argument pour les variables locales est valide, mais vous avez également apporté des modifications supplémentaires qui ne sont pas correctes et provoquent l'échec de votre test d'odeur.

Bien que je comprenne votre suggestion "pas de variable de classe" et que cela ait du mérite, vous avez également supprimé les méthodes elles-mêmes, et c'est un tout autre jeu de balle. Les méthodes auraient dû rester, et à la place vous auriez dû les modifier pour renvoyer leur valeur plutôt que de la stocker dans une variable de classe:

private byte[] getEncodedData() {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
}

private EncryptionInfo getEncryptionInfo() {
    return cryptoService.getEncryptionInfoForDefaultClient();
}

// and so on...

Je suis d'accord avec ce que vous avez fait dans la méthode process, mais vous auriez dû appeler les sous-méthodes privées plutôt que d'exécuter leurs corps directement.

public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    byte[] encodedData = getEncodedData();
    EncryptionInfo encryptionInfo = getEncryptionInfo();

    //and so on...

    return cryptoService.encryptResponse(payloadOfResponse);
}

Vous voudriez cette couche supplémentaire d'abstraction, en particulier lorsque vous rencontrez des méthodes qui doivent être réutilisées plusieurs fois. Même si vous ne réutilisez pas actuellement vos méthodes , c'est toujours une bonne pratique de déjà créer des sous-méthodes le cas échéant, ne serait-ce que pour faciliter la lisibilité du code .

Quel que soit l'argument de variable locale, j'ai immédiatement remarqué que la correction suggérée est considérablement moins lisible que l'original. Je concède que l'utilisation gratuite de variables de classe nuit également à la lisibilité du code, mais pas à première vue par rapport à vous ayant empilé toute la logique dans une seule méthode (maintenant longue).

169
Flater

Le code d'origine utilise des variables membres comme des arguments. Quand il dit de minimiser le nombre d'arguments, ce qu'il veut vraiment dire, c'est de minimiser la quantité de données dont les méthodes ont besoin pour fonctionner. Mettre ces données dans des variables membres n'améliore rien.

79
Alex

D'autres réponses ont déjà parfaitement expliqué les avantages des variables locales, il ne reste donc que cette partie de votre question:

Pourtant, malgré ces avantages, je n'arrive toujours pas à convaincre le développeur d'origine que cette refactorisation est justifiée, car elle semble contredire les pratiques de l'oncle Bob mentionnées ci-dessus.

Ça devrait être facile. Dirigez-le simplement vers la citation suivante dans le code propre de l'oncle Bob:

N'ont pas d'effets secondaires

Les effets secondaires sont des mensonges. Votre fonction promet de faire une chose, mais elle fait aussi d'autres choses cachées. Parfois, il apportera des modifications inattendues aux variables de sa propre classe. Parfois, il les rendra aux paramètres passés dans la fonction ou aux globaux du système. Dans les deux cas, il s'agit de fausses vérités sournoises et dommageables qui entraînent souvent d'étranges couplages temporels et des dépendances d'ordre.

(exemple omis)

Cet effet secondaire crée un couplage temporel. Autrement dit, checkPassword ne peut être appelé qu'à certains moments (en d'autres termes, lorsqu'il est sûr d'initialiser la session). S'il est appelé hors service, les données de session peuvent être perdues par inadvertance. Les couplages temporels prêtent à confusion, surtout lorsqu'ils sont cachés comme effet secondaire. Si vous devez avoir un couplage temporel, vous devez le préciser dans le nom de la fonction. Dans ce cas, nous pourrions renommer la fonction checkPasswordAndInitializeSession, bien que cela viole certainement "Faites une chose".

Autrement dit, l'oncle Bob ne dit pas seulement qu'une fonction devrait prendre peu d'arguments, il dit également que les fonctions devraient éviter d'interagir avec un état non local chaque fois que possible.

47
meriton

"Cela contredit ce que pense l'oncle de quelqu'un" n'est JAMAIS un bon argument. JAMAIS. Ne prenez pas la sagesse des oncles, pensez par vous-même.

Cela dit, les variables d'instance doivent être utilisées pour stocker des informations qui doivent être stockées de manière permanente ou semi-permanente. Les informations ici ne le sont pas. Il est très simple de vivre sans les variables d'instance, donc elles peuvent disparaître.

Test: rédigez un commentaire de documentation pour chaque variable d'instance. Pouvez-vous écrire quelque chose qui n'est pas complètement inutile? Et écrivez un commentaire de documentation aux quatre accesseurs. Ils sont également inutiles.

Le pire est de supposer la façon de décrypter les modifications, car vous utilisez un cryptoService différent. Au lieu d'avoir à modifier quatre lignes de code, vous devez remplacer quatre variables d'instance par des variables différentes, quatre getters par des variables différentes et modifier quatre lignes de code.

Mais bien sûr, la première version est préférable si vous êtes payé par la ligne de code. 31 lignes au lieu de 11 lignes. Trois fois plus de lignes à écrire et à maintenir indéfiniment, à lire lorsque vous déboguez quelque chose, à s'adapter lorsque des changements sont nécessaires, à dupliquer si vous prenez en charge un deuxième cryptoService.

(Vous avez manqué le point important selon lequel l'utilisation de variables locales vous oblige à effectuer les appels dans le bon ordre).

25
gnasher729

Quelle est la justification objective et scientifique de privilégier les variables locales aux variables d'instance? Je n'arrive pas à mettre le doigt dessus. Mon intuition me dit que les couplages cachés sont mauvais et qu'une portée étroite vaut mieux qu'une portée large. Mais quelle est la science pour étayer cela?

Les variables d'instance servent à représenter les propriétés de leur objet hôte, pas à représenter les propriétés spécifiques aux threads de calcul de portée plus étroite que l'objet lui-même. Certaines des raisons pour lesquelles une telle distinction semble ne pas avoir déjà été abordée tournent autour de la concurrence et de la réentrance. Si les méthodes échangent des données en définissant les valeurs des variables d'instance, deux threads simultanés peuvent facilement s'encombrer mutuellement les valeurs de ces variables d'instance, générant des bogues intermittents et difficiles à trouver.

Même un seul thread peut rencontrer des problèmes dans ce sens, car il existe un risque élevé qu'un modèle d'échange de données reposant sur des variables d'instance rende les méthodes non réentrantes. De même, si les mêmes variables sont utilisées pour transmettre des données entre différentes paires de méthodes, il existe un risque qu'un seul thread exécutant même une chaîne non récursive d'invocations de méthodes se heurte à des bogues concernant des modifications inattendues des variables d'instance impliquées.

Afin d'obtenir des résultats corrects de manière fiable dans un tel scénario, vous devez soit utiliser des variables distinctes pour communiquer entre chaque paire de méthodes lorsque l'une invoque l'autre, soit faire en sorte que chaque implémentation de méthode prenne en compte tous les détails d'implémentation de tous les autres. méthodes qu'elle invoque, directement ou indirectement. C'est fragile et il évolue mal.

13
John Bollinger

En discutant simplement de process(...), l'exemple de vos collègues est beaucoup plus lisible au sens de la logique métier. Inversement, votre contre-exemple prend plus qu'un simple coup d'œil pour extraire tout sens.

Cela étant dit, un code propre est à la fois lisible et de bonne qualité - pousser l'État local dans un espace plus global n'est qu'un assemblage de haut niveau, donc un zéro pour la qualité.

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest request) {
    checkNotNull(encryptedRequest);

    return encryptResponse
      (routeTo
         ( destination()
         , requestData(request)
         , destinationEncryption()
         )
      );
  }

  private byte[] requestData(EncryptedRequest encryptedRequest) {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private EncryptionInfo destinationEncryption() {
    return cryptoService.getEncryptionInfoForDefaultClient();
  }

  private URI destination() {
    return router.getDestination().getUri();
  }

  private EncryptedObject routeTo(URI destinationURI, byte[] encodedData, EncryptionInfo encryptionInfo) {
    return serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }

  private void encryptResponse(EncryptedObject payloadOfResponse) {
    return cryptoService.encryptResponse(payloadOfResponse);
  }
}

Il s'agit d'un rendu qui supprime le besoin de variables à n'importe quelle portée. Oui, le compilateur les générera, mais l'important est qu'il contrôle cela afin que le code soit efficace. Tout en étant relativement lisible.

Juste un point sur la dénomination. Vous voulez le nom le plus court qui soit significatif et qui développe les informations déjà disponibles. c'est à dire. destinationURI, l'URI est déjà connu par la signature de type.

9
Kain0_0

Je supprimerais simplement ces variables et méthodes privées. Voici mon refactor:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    return cryptoService.encryptResponse(
        serviceClient.handle(router.getDestination().getUri(),
        cryptoService.decryptRequest(encryptedRequest, byte[].class),
        cryptoService.getEncryptionInfoForDefaultClient()));
  }
}

Pour la méthode privée, par ex. router.getDestination().getUri() est plus clair et plus lisible que getDestinationURI(). Je répéterais même cela si j'utilise deux fois la même ligne dans la même classe. Pour voir les choses d'une autre manière, s'il y a besoin d'une getDestinationURI(), alors elle appartient probablement à une autre classe, pas à la classe SomeBusinessProcess.

Pour les variables et les propriétés, le besoin commun pour elles est de conserver les valeurs à utiliser plus tard dans le temps. Si la classe n'a pas d'interface publique pour les propriétés, elles ne devraient probablement pas être des propriétés. Le pire type de propriétés de classe est probablement utilisé pour transmettre des valeurs entre des méthodes privées par le biais d'effets secondaires.

Quoi qu'il en soit, la classe n'a qu'à faire process() et ensuite l'objet sera jeté, il n'est pas nécessaire de garder un état en mémoire. Un autre potentiel de refactorisation serait de retirer le CryptoService de cette classe.

Sur la base des commentaires, je veux ajouter que cette réponse est basée sur la pratique du monde réel. En effet, dans la revue de code, la première chose que je choisirais serait de refactoriser la classe et de déplacer le travail de cryptage/décryptage. Une fois cela fait, je demanderais si les méthodes et les variables sont nécessaires, sont-elles nommées correctement et ainsi de suite. Le code final sera probablement plus proche de cela:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;

  public Response process(Request request) {
    return serviceClient.handle(router.getDestination().getUri());
  }
}

Avec le code ci-dessus, je ne pense pas qu'il ait besoin d'un remaniement supplémentaire. Comme pour les règles, je pense qu'il faut de l'expérience pour savoir quand et quand ne pas les appliquer. Les règles ne sont pas des théories qui ont fait leurs preuves dans toutes les situations.

D'un autre côté, la révision du code a un impact réel sur la durée avant qu'un morceau de code puisse passer. Mon astuce est d'avoir moins de code et de le rendre facile à comprendre. Un nom de variable peut être un point de discussion, si je peux le supprimer, les réviseurs n'auraient même pas besoin d'y penser.

7
imel96

réponse de Flater couvre assez bien les problèmes de portée, mais je pense qu'il y a un autre problème ici aussi.

Notez qu'il y a une différence entre une fonction qui traite les données et une fonction qui simplement accède aux données .

Le premier exécute la logique métier réelle, tandis que le second enregistre la saisie et peut-être ajoute de la sécurité en ajoutant une interface plus simple et plus réutilisable.

Dans ce cas, il semble que les fonctions d'accès aux données n'enregistrent pas la saisie et ne sont réutilisées nulle part (sinon il y aurait d'autres problèmes à les supprimer). Ces fonctions ne devraient donc tout simplement pas exister.

En ne conservant que la logique métier dans les fonctions nommées, nous obtenons le meilleur des deux mondes (quelque part entre réponse de Flater et réponse d'imel96 ):

public EncryptedResponse process(EncryptedRequest encryptedRequest) {

    byte[] requestData = decryptRequest(encryptedRequest);
    EncryptedObject responseData = handleRequest(router.getDestination().getUri(), requestData, cryptoService.getEncryptionInfoForDefaultClient());
    EncryptedResponse response = encryptResponse(responseData);

    return response;
}

// define: decryptRequest(), handleRequest(), encryptResponse()
4
user673679

La première chose et la plus importante: l'oncle Bob semble parfois être un prédicateur, mais déclare qu'il y a des exceptions à ses règles.

L'idée de Clean Code est d'améliorer la lisibilité et d'éviter les erreurs. Il existe plusieurs règles qui se violent mutuellement.

Son argument sur les fonctions est que les fonctions niladiques sont les meilleures, mais que jusqu'à trois paramètres sont acceptables. Personnellement, je pense que 4 sont également ok.

Lorsque des variables d'instance sont utilisées, elles doivent former une classe cohérente. Cela signifie que les variables doivent être utilisées dans de nombreuses, sinon toutes les méthodes non statiques.

Les variables qui ne sont pas utilisées à de nombreux endroits de la classe doivent être déplacées.

Je ne considérerais ni la version originale ni la version refactorisée comme optimale, et @Flater a déjà très bien expliqué ce qui peut être fait avec les valeurs de retour. Il améliore la lisibilité et réduit les erreurs d'utilisation des valeurs de retour.

2
kap

Les variables locales réduisent la portée, ce qui limite la façon dont les variables peuvent être utilisées et contribue ainsi à empêcher certaines classes d'erreur et améliore la lisibilité.

La variable d'instance réduit les façons d'appeler la fonction, ce qui contribue également à réduire certaines classes d'erreur et améliore la lisibilité.

Dire que l'un a raison et que l'autre est faux peut bien être une conclusion valable dans un cas particulier, mais comme conseil général ...

TL; DR: Je pense que la raison pour laquelle vous sentez trop de zèle est qu'il y a trop de zèle.

1
drjpizzle

Les deux choses font la même chose et les différences de performances sont imperceptibles, donc je ne pense pas qu'il y ait un argument scientifique. Il s'agit alors de préférences subjectives.

Et moi aussi j'ai tendance à aimer votre façon de faire mieux que celle de votre collègue. Pourquoi? Parce que je pense que c'est plus facile à lire et à comprendre, malgré ce que dit un auteur de livre.

Les deux voies accomplissent la même chose, mais sa voie est plus étendue. Pour lire ce code, vous devez basculer entre plusieurs fonctions et variables membres. Tout n'est pas condensé en un seul endroit, vous devez vous souvenir de tout cela dans votre tête pour le comprendre. C'est une charge cognitive beaucoup plus grande.

En revanche, votre approche emballe tout beaucoup plus densément, mais pas de façon à la rendre impénétrable. Vous venez de le lire ligne après ligne, et vous n'avez pas besoin de mémoriser autant pour le comprendre.

Cependant, s'il est tilisé pour coder une telle disposition, je peux imaginer que pour lui, ce pourrait être l'inverse.

0
Vilx-

Malgré le fait que les méthodes commençant par get ... ne doivent pas retourner nulles, la séparation des niveaux d'abstractions au sein des méthodes est donnée dans la première solution. Bien que la deuxième solution soit plus étendue, il est encore plus difficile de raisonner sur ce qui se passe dans la méthode. Les affectations de variables locales ne sont pas nécessaires ici. Je garderais les noms de méthode et remanierais le code à quelque chose comme ça:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    return getEncryptedResponse(
            passRequestToServiceClient(getDestinationURI(), getEncodedData(encryptedRequest) getEncryptionInfo())
        );
  }

  private EncryptedResponse getEncryptedResponse(EncryptedObject encryptedObject) {
    return cryptoService.encryptResponse(encryptedObject);
  }

  private byte[] getEncodedData(EncryptedRequest encryptedRequest) {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private EncryptionInfo getEncryptionInfo() {
    return cryptoService.getEncryptionInfoForDefaultClient();
  }

  private URI getDestinationURI() {
    return router.getDestination().getUri();
  }

  private EncryptedObject passRequestToServiceClient(URI destinationURI, byte[] encodedData, EncryptionInfo encryptionInfo) {
    return serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }
}
0
Roman Weis