Extraction de méthode vs hypothèses sous-jacentes

27

Lorsque je divise de grandes méthodes (ou procédures ou fonctions - cette question n'est pas spécifique à la POO, mais comme je travaille dans les langues de POO 99% du temps, c'est la terminologie avec laquelle je suis le plus à l'aise) en beaucoup de petites , Je suis souvent mécontent des résultats. Il devient plus difficile de raisonner sur ces petites méthodes que lorsqu'elles n'étaient que des blocs de code dans la grande, car lorsque je les extrait, je perds beaucoup d'hypothèses sous-jacentes qui viennent du contexte de l'appelant.

Plus tard, quand je regarde ce code et que je vois des méthodes individuelles, je ne sais pas immédiatement d'où elles sont appelées et je les considère comme des méthodes privées ordinaires qui peuvent être appelées de n'importe où dans le fichier. Par exemple, imaginez une méthode d'initialisation (constructeur ou autre) divisée en une série de petites: dans le contexte de la méthode elle-même, vous savez clairement que l'état de l'objet est toujours invalide, mais dans une méthode privée ordinaire, vous passez probablement de l'hypothèse que cet objet est déjà initialisé et est dans un état valide.

La seule solution que j'ai vue pour cela est la whereclause de Haskell, qui vous permet de définir de petites fonctions qui ne sont utilisées que dans la fonction "parent". Fondamentalement, cela ressemble à ceci:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

Mais d'autres langues que j'utilise n'ont rien de tel - la chose la plus proche est de définir un lambda dans une portée locale, ce qui est probablement encore plus déroutant.

Donc, ma question est - rencontrez-vous cela, et voyez-vous même que c'est un problème? Si vous le faites, comment le résolvez-vous généralement, en particulier dans les langages de POO "grand public", comme Java / C # / C ++?

Modifier les doublons: Comme d'autres l'ont remarqué, il y a déjà des questions sur les méthodes de fractionnement et les petites questions qui sont à une ligne. Je les ai lus et ils ne discutent pas de la question des hypothèses sous - jacentes qui peuvent être dérivées du contexte de l'appelant (dans l'exemple ci-dessus, objet en cours d'initialisation). C'est le point de ma question, et c'est pourquoi ma question est différente.

Mise à jour: Si vous avez suivi cette question et cette discussion ci-dessous, vous pourriez apprécier cet article de John Carmack sur le sujet , en particulier:

Outre la connaissance du code en cours d'exécution, les fonctions en ligne ont également l'avantage de ne pas permettre d'appeler la fonction depuis d'autres endroits. Cela semble ridicule, mais il y a un point à cela. Au fur et à mesure qu'une base de code se développe au fil des années d'utilisation, il y aura de nombreuses opportunités de prendre un raccourci et d'appeler simplement une fonction qui ne fait que le travail qui, selon vous, doit être fait. Il peut y avoir une fonction FullUpdate () qui appelle PartialUpdateA () et PartialUpdateB (), mais dans certains cas particuliers, vous pouvez réaliser (ou penser) que vous n'avez besoin que de faire PartialUpdateB (), et vous êtes efficace en évitant les autres travail. Beaucoup, beaucoup de bugs en découlent. La plupart des bogues sont dus au fait que l'état d'exécution n'est pas exactement ce que vous pensez qu'il est.

Max Yankov
la source
@gnat la question à laquelle vous avez lié traite de l'extraction ou non des fonctions, alors que je ne la remets pas en question. Au lieu de cela, je remets en question la méthode la plus optimale pour le faire.
Max Yankov
2
@gnat, il existe d'autres questions connexes liées à partir de là, mais aucune d'entre elles ne discute du fait que ce code peut s'appuyer sur des hypothèses spécifiques qui ne sont valables que dans le contexte de l'appelant.
Max Yankov
1
@Doval d'après mon expérience, c'est vraiment le cas. Quand il y a des méthodes d'aide gênantes qui traînent comme vous le décrivez, l'extraction d'une nouvelle classe cohésive s'en occupe
gnat

Réponses:

29

Par exemple, imaginez une méthode d'initialisation divisée en une série de petites: dans le contexte de la méthode elle-même, vous savez clairement que l'état de l'objet est toujours invalide, mais dans une méthode privée ordinaire, vous passez probablement de l'hypothèse que l'objet est déjà initialisé et est dans un état valide. La seule solution que j'ai vue pour cela est ...

Votre préoccupation est fondée. Il y a une autre solution.

Prendre du recul. À quoi sert fondamentalement une méthode? Les méthodes ne font qu'une ou deux choses:

  • Produire une valeur
  • Provoquer un effet

Ou, malheureusement, les deux. J'essaie d'éviter les méthodes qui font les deux, mais beaucoup le font. Disons que l'effet produit ou la valeur produite est le "résultat" de la méthode.

Vous notez que les méthodes sont appelées dans un "contexte". Quel est ce contexte?

  • Les valeurs des arguments
  • L'état du programme en dehors de la méthode

Ce que vous signalez essentiellement, c'est que l'exactitude du résultat de la méthode dépend du contexte dans lequel elle est appelée .

Nous appelons les conditions requises avant qu'un corps de méthode commence pour que la méthode produise un résultat correct ses conditions préalables , et nous appelons les conditions qui seront produites après que le corps de méthode retourne ses postconditions .

Donc, essentiellement, ce que vous signalez est: lorsque j'extrais un bloc de code dans sa propre méthode, je perds des informations contextuelles sur les conditions préalables et postconditions .

La solution à ce problème est de rendre explicites les conditions préalables et postconditions dans le programme . En C #, par exemple, vous pouvez utiliser Debug.Assertou Code Contracts pour exprimer les conditions préalables et postconditions.

Par exemple: je travaillais sur un compilateur qui passait par plusieurs "étapes" de compilation. D'abord, le code serait lexiqué, puis analysé, puis les types seraient résolus, puis les hiérarchies d'héritage seraient vérifiées pour les cycles, etc. Chaque morceau du code était très sensible à son contexte; il serait désastreux, par exemple, de demander "ce type est-il convertible en ce type?" si le graphique des types de base n'était pas encore connu pour être acyclique! Par conséquent, chaque bit de code a clairement documenté ses conditions préalables. Nous assertdans la méthode pour la convertibilité du type que nous avions déjà passé vérifié le chèque « types de base acyclique », et il est ensuite devenu clair pour le lecteur où la méthode pourrait être appelé et où il ne pouvait pas être appelé.

Bien sûr, il existe de nombreuses façons dont une bonne conception de méthode atténue le problème que vous avez identifié:

  • faire des méthodes utiles pour leurs effets ou leur valeur mais pas les deux
  • faire des méthodes aussi «pures» que possible; une méthode "pure" produit une valeur qui ne dépend que de ses arguments et ne produit aucun effet. Ce sont les méthodes les plus faciles à raisonner car le "contexte" dont elles ont besoin est très localisé.
  • minimiser la quantité de mutation qui se produit dans l'état du programme; les mutations sont des points où le code devient plus difficile à raisonner
Eric Lippert
la source
+1 pour être la réponse qui explique le problème en termes de conditions préalables / postconditions.
QuestionC
5
J'ajouterais qu'il est souvent possible (et une bonne idée!) De déléguer la vérification des pré et post-conditions au système de type. Si vous avez une fonction qui prend un stringet l'enregistre dans la base de données, vous risquez une injection SQL si vous oubliez de la nettoyer. Si, d'autre part, votre fonction prend un SanitisedString, et la seule façon d'obtenir un SantisiedStringest en appelant Sanitise, alors vous avez exclu les bogues d'injection SQL par construction. Je me retrouve de plus en plus à chercher des moyens pour que le compilateur rejette le code incorrect.
Benjamin Hodgson
+1 Une chose importante à noter est qu'il y a un coût à diviser une grande méthode en morceaux plus petits: ce n'est généralement pas utile à moins que les conditions préalables et postconditions soient plus détendues qu'elles ne l'auraient été à l'origine, et vous pouvez finir par devoir payer le coût en refaisant des chèques que vous auriez autrement déjà fait. Ce n'est pas un processus de refactorisation complètement "gratuit".
Mehrdad
"Quel est ce contexte?" juste pour clarifier, je voulais surtout dire l'état privé de l'objet sur lequel cette méthode est appelée. Je suppose que c'est inclus dans la deuxième catégorie.
Max Yankov
C'est une excellente réponse qui donne à réfléchir, merci. (Pour ne pas dire que les autres réponses sont en quelque sorte mauvaises, bien sûr). Je ne marquerai pas la question comme ayant été répondue pour l'instant, car j'aime vraiment la discussion ici (et elle a tendance à cesser lorsque la réponse est marquée comme ayant été répondue) et j'ai besoin de temps pour la traiter et y réfléchir.
Max Yankov
13

Je vois souvent cela et je reconnais que c'est un problème. Habituellement, je le résous en créant un objet méthode : une nouvelle classe spécialisée dont les membres sont les variables locales de la méthode originale, trop volumineuse.

La nouvelle classe a tendance à avoir un nom comme «Exportateur» ou «Tabulation», et elle obtient toutes les informations nécessaires pour effectuer cette tâche particulière dans un contexte plus large. Ensuite, il est libre de définir des extraits de code d'assistance encore plus petits qui ne risquent pas d'être utilisés pour autre chose que la tabulation ou l'exportation.

Kilian Foth
la source
J'aime beaucoup cette idée, plus j'y pense. Il peut s'agir d'une classe privée à l'intérieur de la classe publique ou interne. Vous n'encombrez pas votre espace de noms avec des classes qui ne vous intéressent que très localement, et c'est une façon de marquer que ce sont des "aides au constructeur" ou des "aides à l'analyse" ou autre.
Mike soutient Monica
Récemment, j'étais juste dans une situation qui serait idéale pour cela du point de vue de l'architecture. J'ai écrit un logiciel de rendu avec une classe de moteur de rendu et une méthode de rendu publique, qui avait BEAUCOUP de contexte qu'elle appelait d'autres méthodes. J'envisageais de créer une classe RenderContext distincte pour cela, cependant, il semblait tout simplement extrêmement inutile d'allouer et de désallouer ce projet à chaque image. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov
6

De nombreuses langues vous permettent d'imbriquer des fonctions comme Haskell. Java / C # / C ++ sont en fait des valeurs aberrantes relatives à cet égard. Malheureusement, ils sont si populaires que les gens en viennent à penser: "Ce doit être une mauvaise idée, sinon ma langue" dominante "préférée le permettrait."

Java / C # / C ++ pense fondamentalement qu'une classe devrait être le seul regroupement de méthodes dont vous ayez jamais besoin. Si vous avez tellement de méthodes que vous ne pouvez pas déterminer leurs contextes, il y a deux approches générales à suivre: les trier par contexte ou les diviser par contexte.

Le tri par contexte est une recommandation faite dans Clean Code , où l'auteur décrit un modèle de «paragraphes TO». Il s'agit essentiellement de placer vos fonctions d'assistance immédiatement après la fonction qui les appelle, de sorte que vous pouvez les lire comme des paragraphes dans un article de journal, obtenir plus de détails plus vous lisez. Je pense que dans ses vidéos, il les met même en retrait.

L'autre approche consiste à diviser vos classes. Cela ne peut pas être poussé très loin, en raison du besoin ennuyeux d'instancier des objets avant de pouvoir appeler des méthodes dessus, et des problèmes inhérents à décider laquelle de plusieurs petites classes devrait posséder chaque élément de données. Cependant, si vous avez déjà identifié plusieurs méthodes qui ne rentrent vraiment que dans un seul contexte, elles sont probablement un bon candidat à envisager de mettre dans leur propre classe. Par exemple, une initialisation complexe peut être effectuée dans un modèle de création comme le générateur.

Karl Bielefeldt
la source
Les fonctions d'emboîtement ... n'est-ce pas ce que les fonctions lambda réalisent en C # (et Java 8)?
Arturo Torres Sánchez
Je pensais plutôt à une fermeture définie avec un nom, comme ces exemples de python . Les lambdas ne sont pas le moyen le plus clair de faire quelque chose comme ça. Ils sont plus pour des expressions courtes comme un prédicat de filtre.
Karl Bielefeldt
Ces exemples Python sont certainement possibles en C #. Par exemple, la factorielle . Ils peuvent être plus verbeux, mais ils sont 100% possibles.
Arturo Torres Sánchez
2
Personne n'a dit que ce n'était pas possible. L'OP a même mentionné l'utilisation de lambdas dans sa question. C'est juste que si vous extrayez une méthode dans un souci de lisibilité, ce serait bien si elle était plus lisible.
Karl Bielefeldt
Votre premier paragraphe semble impliquer que ce n'est pas possible, en particulier avec votre citation: "Ce doit être une mauvaise idée, sinon mon langage" dominant "préféré le permettrait."
Arturo Torres Sánchez
4

Je pense que la réponse dans la plupart des cas est le contexte. En tant que développeur qui écrit du code, vous devez supposer que votre code sera modifié à l'avenir. Une classe peut être intégrée à une autre classe, remplacer son algorithme interne ou être divisée en plusieurs classes afin de créer une abstraction. Ce sont des choses que les développeurs débutants ne prennent généralement pas en compte, ce qui entraîne un besoin de solutions de contournement compliquées ou de révisions complètes plus tard.

L'extraction des méthodes est bonne, mais dans une certaine mesure. J'essaie toujours de me poser ces questions lors de l'inspection ou avant d'écrire du code:

  • Ce code est-il uniquement utilisé par cette classe / fonction? restera-t-il le même à l'avenir?
  • Si je dois désactiver une partie de l'implémentation concrète, puis-je le faire facilement?
  • Les autres développeurs de mon équipe peuvent-ils comprendre ce qui est fait dans cette fonction?
  • Le même code est-il utilisé ailleurs dans cette classe? vous devez éviter les doublons dans presque tous les cas.

Dans tous les cas, pensez toujours à une seule responsabilité. Une classe devrait avoir une responsabilité, ses fonctions devraient servir un seul service constant, et si elles effectuent un certain nombre d'actions, ces actions devraient avoir leurs propres fonctions, il est donc facile de les différencier ou de les changer plus tard.

Tomer Blu
la source
1

Il devient plus difficile de raisonner sur ces petites méthodes que lorsqu'elles n'étaient que des blocs de code dans la grande, car lorsque je les extrait, je perds beaucoup d'hypothèses sous-jacentes qui viennent du contexte de l'appelant.

Je ne me suis pas rendu compte de l'ampleur du problème jusqu'à ce que j'adopte un ECS qui encourage les fonctions système plus grandes et en boucle (les systèmes étant les seuls à avoir des fonctions) et les dépendances qui se dirigent vers les données brutes , pas les abstractions.

À ma grande surprise, cela a produit une base de code tellement plus facile à raisonner et à maintenir par rapport aux bases de code dans lesquelles j'ai travaillé dans le passé où, pendant le débogage, vous deviez tracer toutes sortes de petites fonctions minuscules, souvent par le biais d'appels de fonctions abstraites via des interfaces pures menant à qui sait où jusqu'à ce que vous y traçiez, seulement pour engendrer une cascade d'événements qui mènent à des endroits que vous n'auriez jamais pensé que le code devrait jamais mener.

Contrairement à John Carmack, mon plus gros problème avec ces bases de code n'était pas les performances, car je n'avais jamais eu cette demande de latence ultra-serrée des moteurs de jeu AAA et la plupart de nos problèmes de performances concernaient davantage le débit. Bien sûr, vous pouvez également commencer à rendre de plus en plus difficile l'optimisation des hotspots lorsque vous travaillez dans des limites de plus en plus étroites de fonctions et de classes pour adolescents et adolescents sans que cette structure ne vous gêne (vous obligeant à fusionner toutes ces petites pièces) à quelque chose de plus grand avant que vous puissiez même commencer à y faire face efficacement).

Pourtant, le plus gros problème pour moi était de ne pas pouvoir raisonner en toute confiance sur l'exactitude globale du système malgré tous les tests réussis. Il y avait trop de choses à prendre dans mon cerveau et à comprendre parce que ce type de système ne vous permettait pas de le raisonner sans prendre en compte tous ces petits détails et les interactions sans fin entre de minuscules fonctions et des objets qui se passaient partout. Il y avait trop de «et si?», Trop de choses qui devaient être appelées au bon moment, trop de questions sur ce qui se passerait si elles étaient appelées au mauvais moment (qui commencent à devenir soulevées au point de paranoïa lorsque vous avoir un événement déclenchant un autre événement déclenchant un autre vous menant à toutes sortes de lieux imprévisibles), etc.

Maintenant, j'aime mes fonctions de 80 lignes de gros cul ici et là, tant qu'elles exercent toujours une responsabilité singulière et claire et n'ont pas comme 8 niveaux de blocs imbriqués. Ils donnent l'impression qu'il y a moins de choses dans le système à tester et à comprendre, même si les versions plus petites et découpées de ces fonctions plus importantes n'étaient que des détails d'implémentation privés ne pouvant être appelés par personne d'autre ... on a tendance à penser qu'il y a moins d'interactions dans tout le système. J'aime même une duplication de code très modeste, tant que ce n'est pas une logique complexe (disons juste 2-3 lignes de code), si cela signifie moins de fonctions. J'aime le raisonnement de Carmack à propos de l'inclusion de rendre cette fonctionnalité impossible à appeler ailleurs dans le fichier source. Là'

La simplicité ne réduit pas toujours la complexité au niveau global si l'option est entre une fonction charnue et 12 fonctions ultra-simples qui s'appellent les unes les autres avec un graphique complexe de dépendances. À la fin de la journée, vous devez souvent raisonner sur ce qui se passe au-delà d'une fonction, sur ce que ces fonctions totalisent pour finalement faire, et il peut être plus difficile de voir cette vue d'ensemble si vous devez la déduire de la les plus petites pièces du puzzle.

Bien sûr, le code de type bibliothèque très polyvalent qui est bien testé peut être exempté de cette règle, car ce code polyvalent fonctionne souvent bien et se suffit à lui-même. De plus, il a tendance à être minuscule par rapport au code un peu plus proche du domaine de votre application (des milliers de lignes de code, pas des millions), et si largement applicable qu'il commence à faire partie du vocabulaire quotidien. Mais avec quelque chose de plus spécifique à votre application où les invariants à l'échelle du système que vous devez maintenir vont bien au-delà d'une seule fonction ou classe, j'ai tendance à trouver utile d'avoir des fonctions plus charnues pour une raison quelconque. Je trouve qu'il est beaucoup plus facile de travailler avec des pièces de puzzle plus grandes en essayant de comprendre ce qui se passe avec la grande image.


la source
0

Je ne pense pas que ce soit un gros problème, mais je conviens que c'est gênant. Habituellement, je place juste l'assistant juste après son bénéficiaire et j'ajoute un suffixe "Helper". Cela plus le privatespécificateur d'accès devraient clarifier son rôle. S'il y a un invariant qui ne tient pas lorsque l'aide est appelée, j'ajoute un commentaire dans l'aide.

Cette solution a l'inconvénient malheureux de ne pas saisir l'étendue de la fonction qu'elle aide. Idéalement, vos fonctions sont petites, donc j'espère que cela n'entraînera pas trop de paramètres. Normalement, vous résoudre ce problème en définissant de nouvelles structures ou classes pour regrouper les paramètres, mais la quantité de passe-partout requise pour cela peut facilement être plus longue que l'aide elle-même, puis vous êtes de retour où vous avez commencé sans aucun moyen évident d'associer la structure avec la fonction.

Vous avez déjà mentionné l'autre solution - définir l'aide à l'intérieur de la fonction principale. C'est peut-être un idiome quelque peu inhabituel dans certaines langues, mais je ne pense pas que ce serait déroutant (à moins que vos pairs ne soient confondus par les lambdas en général). Cela ne fonctionne que si vous pouvez définir facilement des fonctions ou des objets similaires. Je n'essaierais pas cela en Java 7, par exemple, car une classe anonyme nécessite l'introduction de 2 niveaux d'imbrication, même pour la plus petite "fonction". Cela se rapproche le plus possible d'une clause letor where; vous pouvez vous référer aux variables locales avant la définition et l'assistant ne peut pas être utilisé en dehors de cette portée.

Doval
la source