Je suis confronté à des problèmes avec ce que je ressens comme étant trop d'abstraction dans la base de code (ou du moins à y faire face). La plupart des méthodes de la base de code ont été abstraites pour prendre le parent A le plus élevé dans la base de code, mais l'enfant B de ce parent a un nouvel attribut qui affecte la logique de certaines de ces méthodes. Le problème est que ces attributs ne peuvent pas être vérifiés dans ces méthodes car l'entrée est abstraite de A, et A bien sûr n'a pas cet attribut. Si j'essaie de créer une nouvelle méthode pour gérer B différemment, elle est appelée pour la duplication de code. La suggestion de mon responsable technique est de créer une méthode partagée qui accepte les paramètres booléens, mais le problème avec cela est que certaines personnes considèrent cela comme un "flux de contrôle caché", où la méthode partagée a une logique qui peut ne pas être évidente pour les futurs développeurs. , et cette méthode partagée deviendra trop complexe/alambiquée une fois si de futurs attributs doivent être ajoutés, même si elle est décomposée en méthodes partagées plus petites. Cela augmente également le couplage, diminue la cohésion et viole le principe de responsabilité unique, comme l'a souligné un membre de mon équipe.
Essentiellement, une grande partie de l'abstraction dans cette base de code aide à réduire la duplication de code, mais cela rend l'extension/la modification des méthodes plus difficiles lorsqu'elles sont faites pour prendre l'abstraction la plus élevée. Que dois-je faire dans une telle situation? Je suis au centre du blâme, même si tout le monde ne peut pas s'entendre sur ce qu'ils considèrent comme bon, donc ça me fait mal au final.
Si j'essaie de créer une nouvelle méthode pour gérer B différemment, elle est appelée pour la duplication de code.
Toutes les duplications de code ne sont pas créées égales.
Supposons que vous ayez une méthode qui prend deux paramètres et les additionne ensemble appelée total()
. Disons que vous en avez un autre appelé add()
. Leurs implémentations semblent complètement identiques. Doivent-ils être fusionnés en une seule méthode? NON!!!
Le Don't-Repeat-Yourself ou principe DRY ne consiste pas à répéter du code. Il s'agit de diffuser une décision, une idée, de sorte que si vous changez votre idée, vous devez réécrire partout où vous répandez cette idée. Blegh. C'est terrible. Ne le fais pas. Utilisez plutôt DRY pour vous aider prenez des décisions en un seul endroit .
Le principe DRY (Ne vous répétez pas)) stipule:
Chaque élément de connaissance doit avoir une représentation unique, non ambiguë et faisant autorité au sein d'un système.
Mais DRY peut être corrompu en une habitude de scanner du code à la recherche d'une implémentation similaire qui semble être un copier-coller d'un autre endroit. Ceci est la forme morte de DRY. Enfer, vous pourriez faites-le avec un outil d'analyse statique. Cela n'aide pas car il ignore le point de DRY qui est à garder le code flexible.
Si mes besoins totaux changent, je devrai peut-être modifier mon implémentation total
. Cela ne signifie pas que je dois changer mon implémentation add
. Si un goober les a réunis en une seule méthode, je souffre maintenant d'un peu de douleur inutile.
Quelle douleur? Je pourrais sûrement copier le code et créer une nouvelle méthode quand j'en aurais besoin. Donc ce n'est pas grave, non? Malarky! Si rien d'autre vous me coûte un bon nom! Les bons noms sont difficiles à trouver et ne répondent pas bien lorsque vous jouez avec leur sens. Les bons noms, qui rendent l'intention claire, sont plus importants que le risque que vous copiiez un bogue qui, franchement, est plus facile à corriger lorsque votre méthode a le bon nom.
Donc, mon conseil est d'arrêter de laisser les réactions instinctives à un code similaire lier votre base de code en nœuds. Je ne dis pas que vous êtes libre d'ignorer le fait qu'il existe des méthodes et de copier et coller à la place. Non, chaque méthode doit avoir une putain de bonne réputation qui soutient la seule idée dont il s'agit. Si sa mise en œuvre correspond à la mise en œuvre d'une autre bonne idée, à l'heure actuelle, qui s'en soucie?
D'un autre côté, si vous avez une méthode sum()
qui a une implémentation identique ou même différente de total()
, mais à chaque fois que vos exigences totales changent, vous devez changer sum()
alors il y a de fortes chances que ce soit la même idée sous deux noms différents. Non seulement le code serait plus flexible s'il était fusionné, mais il serait moins déroutant à utiliser.
Quant aux paramètres booléens, oui, c'est une odeur de code désagréable. Non seulement le contrôle du flux est un problème, mais il montre que vous avez coupé une abstraction à un mauvais moment. Les abstractions sont censées rendre les choses plus simples à utiliser, pas plus compliquées. Passer des bools à une méthode pour contrôler son comportement, c'est comme créer un langage secret qui décide quelle méthode vous appelez vraiment. Aïe! Ne me fais pas ça. Donnez à chaque méthode son propre nom, sauf si vous en avez du polymorphisme honnête à Die en cours.
Maintenant, vous semblez épuisé par l'abstraction. C'est dommage car l'abstraction est une chose merveilleuse lorsqu'elle est bien faite. Vous l'utilisez beaucoup sans y penser. Chaque fois que vous conduisez une voiture sans avoir à comprendre le système de pignon et crémaillère, chaque fois que vous utilisez une commande d'impression sans penser aux interruptions du système d'exploitation, et chaque fois que vous vous brossez les dents sans penser à chaque poil individuel.
Non, le problème auquel vous semblez confronté est une mauvaise abstraction. Abstraction créée pour servir un objectif différent de vos besoins. Vous avez besoin d'interfaces simples dans des objets complexes qui vous permettent de demander que vos besoins soient satisfaits sans jamais avoir à comprendre ces objets.
Lorsque vous écrivez du code client qui utilise un autre objet, vous savez quels sont vos besoins et ce dont vous avez besoin de cet objet. Ce n'est pas le cas. C'est pourquoi le code client possède l'interface. Lorsque vous êtes le client, rien ne vous dit quels sont vos besoins, sauf vous. Vous mettez en place une interface qui montre quels sont vos besoins et exigez que tout ce qui vous est remis réponde à ces besoins.
Voilà l'abstraction. En tant que client, je ne sais même pas à quoi je parle. Je sais juste ce dont j'ai besoin. Si cela signifie que vous devez envelopper quelque chose pour changer son interface avant de me le remettre très bien. Je m'en fiche. Faites juste ce que j'ai besoin de faire. Arrêtez de compliquer les choses.
Si je dois regarder à l'intérieur d'une abstraction pour comprendre comment l'utiliser, l'abstraction a échoué. Je ne devrais pas avoir besoin de savoir comment cela fonctionne. Juste que ça marche. Donnez-lui un bon nom et si je regarde à l'intérieur, je ne devrais pas être surpris par ce que je trouve. Ne me faites pas continuer à regarder à l'intérieur pour vous souvenir de comment l'utiliser.
Lorsque vous insistez sur le fait que l'abstraction fonctionne de cette façon, le nombre de niveaux derrière elle n'a pas d'importance. Tant que vous ne regardez pas derrière l'abstraction. Vous insistez pour que l'abstraction soit conforme à vos besoins et ne s'adapte pas à la sienne. Pour que cela fonctionne, il doit être facile à utiliser, avoir un bon nom et non fuite .
C'est l'attitude qui a engendré l'injection de dépendance (ou tout simplement faire référence si vous êtes de la vieille école comme moi). Cela fonctionne bien avec préférez la composition et la délégation à l'héritage . L'attitude porte de nombreux noms. Mon préféré est dites, ne demandez pas .
Je pourrais te noyer dans les principes toute la journée. Et il semble que vos collègues le soient déjà. Mais voici le problème: contrairement à d'autres domaines de l'ingénierie, ce logiciel a moins de 100 ans. Nous sommes tous encore en train de le comprendre. Alors, ne laissez pas quelqu'un avec beaucoup de livres intimidants vous intimider pour écrire du code difficile à lire. Écoutez-les, mais insistez pour qu'ils aient un sens. Ne prenez rien sur la foi. Les gens qui codent d'une certaine façon juste parce qu'on leur a dit que c'était le chemin sans savoir pourquoi faire le plus gros gâchis de tous.
Le dicton habituel que nous lisons tous ici et là est:
Tous les problèmes peuvent être résolus en ajoutant une autre couche d'abstraction.
Eh bien, ce n'est pas vrai! Votre exemple le montre. Je proposerais donc la déclaration légèrement modifiée (n'hésitez pas à réutiliser ;-)):
Chaque problème peut être résolu en utilisant LE BON niveau d'abstraction.
Il y a deux problèmes différents dans votre cas:
Les deux sont corrélés:
Shape
peut calculer sa surface()
de manière spécialisée.Si vous faites abstraction d'une opération où il existe un modèle de comportement général commun, vous avez deux choix:
De plus, cette approche pourrait entraîner un effet de couplage abstrait au niveau de la conception. Chaque fois que vous souhaitez ajouter une sorte de nouveau comportement spécialisé, vous devrez l'abstraire, changer le parent abstrait et mettre à jour toutes les autres classes. Ce n'est pas le genre de propagation de changement que l'on peut souhaiter. Et ce n'est pas vraiment dans l'esprit des abstractions qui ne dépendent pas de la spécialisation (au moins dans la conception).
Je ne connais pas votre design et je ne peux pas vous aider davantage. C'est peut-être vraiment un problème très complexe et abstrait et il n'y a pas de meilleur moyen. Mais quelles sont les chances? Les symptômes d'une surgénéralisation sont là. Il est peut-être temps de le revoir, et considérez la composition plutôt que la généralisation ?
Chaque fois que je vois une méthode dont le comportement active le type de son paramètre, je considère immédiatement si cette méthode appartient réellement au paramètre de la méthode. Par exemple, au lieu d'avoir une méthode comme:
public void sort(List values) {
if (values instanceof LinkedList) {
// do efficient linked list sort
} else { // ArrayList
// do efficient array list sort
}
}
Je ferais ceci:
values.sort();
// ...
class ArrayList {
public void sort() {
// do efficient array list sort
}
}
class LinkedList {
public void sort() {
// do efficient linked list sort
}
}
Nous déplaçons le comportement à l'endroit qui sait quand l'utiliser. Nous créons une vraie abstraction où vous n'avez pas besoin de connaître les types ou les détails de l'implémentation. Pour votre situation, il peut être plus judicieux de déplacer cette méthode de la classe d'origine (que j'appellerai O
) pour taper A
et la remplacer dans le type B
. Si la méthode est appelée doIt
sur un objet, déplacez doIt
vers A
et remplacez par le comportement différent dans B
. S'il y a des bits de données d'où doIt
est appelé à l'origine, ou si la méthode est utilisée à suffisamment d'endroits, vous pouvez laisser la méthode d'origine et déléguer:
class O {
int x;
int y;
public void doIt(A a) {
a.doIt(this.x, this.y);
}
}
Nous pouvons cependant plonger un peu plus profondément. Examinons la suggestion d'utiliser un paramètre booléen à la place et voyons ce que nous pouvons apprendre sur la façon dont votre collègue pense. Sa proposition est de faire:
public void doIt(A a, boolean isTypeB) {
if (isTypeB) {
// do B stuff
} else {
// do A stuff
}
}
Cela ressemble énormément au instanceof
que j'ai utilisé dans mon premier exemple, sauf que nous extériorisons cette vérification. Cela signifie que nous devrions l'appeler de deux manières:
o.doIt(a, a instanceof B);
ou:
o.doIt(a, true); //or false
Dans le premier cas, le point d'appel n'a aucune idée du type de A
dont il dispose. Par conséquent, devrions-nous passer des booléens tout le long? Est-ce vraiment un modèle que nous voulons partout dans la base de code? Que se passe-t-il s'il existe un troisième type dont nous devons tenir compte? Si c'est ainsi que la méthode est appelée, nous devons la déplacer vers le type et laisser le système choisir l'implémentation pour nous de manière polymorphe.
Dans la deuxième manière, nous doit déjà savoir le type de a
au point d'appel. Habituellement, cela signifie que nous y créons l'instance ou prenons une instance de ce type comme paramètre. Créer une méthode sur O
qui prend un B
ici fonctionnerait. Le compilateur saurait quelle méthode choisir. Lorsque nous traversons des changements comme celui-ci, la duplication est préférable à la création de la mauvaise abstraction , au moins jusqu'à ce que nous sachions où nous allons vraiment. Bien sûr, je suggère que nous n'avons pas vraiment terminé, peu importe ce que nous avons changé jusqu'à présent.
Nous devons examiner de plus près la relation entre A
et B
. En général, on nous dit que nous devrions privilégier la composition à l'héritage . Ce n'est pas vrai dans tous les cas, mais c'est vrai dans un nombre surprenant de cas une fois que nous creusons. B
hérite de A
, ce qui signifie que nous pensons que B
est un A
. B
doit être utilisé comme A
, sauf que cela fonctionne un peu différemment. Mais quelles sont ces différences? Pouvons-nous donner un nom plus concret aux différences? N'est-ce pas B
est un A
, mais vraiment A
a un X
qui pourrait être A'
ou B'
? À quoi ressemblerait notre code si nous faisions cela?
Si nous déplacions la méthode sur A
comme suggéré précédemment, nous pourrions injecter une instance de X
dans A
, et déléguer cette méthode à X
:
class A {
X x;
A(X x) {
this.x = x;
}
public void doIt(int x, int y) {
x.doIt(x, y);
}
}
Nous pouvons implémenter A'
et B'
, et supprimez B
. Nous avons amélioré le code en donnant un nom à un concept qui aurait pu être plus implicite, et nous nous sommes permis de définir ce comportement lors de l'exécution au lieu de la compilation. A
est également devenu moins abstrait. Au lieu d'une relation d'héritage étendue, il appelle des méthodes sur un objet délégué. Cet objet est abstrait, mais plus axé uniquement sur les différences de mise en œuvre.
Il y a une dernière chose à regarder cependant. Revenons à la proposition de votre collègue. Si sur tous les sites d'appels nous connaissons explicitement le type de A
que nous avons, alors nous devrions faire des appels comme:
B b = new B();
o.doIt(b, true);
Nous avons supposé plus tôt lors de la composition que A
a un X
qui est soit A'
ou B'
. Mais peut-être même que cette hypothèse n'est pas correcte. Est-ce le seul endroit où cette différence entre A
et B
est importante? Si c'est le cas, nous pourrions peut-être adopter une approche légèrement différente. Nous avons toujours un X
qui est soit A'
ou B'
, mais il n'appartient pas à A
. Seulement O.doIt
s'en soucie, alors passons-le uniquement à O.doIt
:
class O {
int x;
int y;
public void doIt(A a, X x) {
x.doIt(a, x, y);
}
}
Maintenant, notre site d'appel ressemble à:
A a = new A();
o.doIt(a, new B'());
Encore une fois, B
disparaît et l'abstraction se déplace dans le X
plus ciblé. Cette fois, cependant, A
est encore plus simple en sachant moins. C'est encore moins abstrait.
Il est important de réduire la duplication dans une base de code, mais nous devons considérer pourquoi la duplication se produit en premier lieu. La duplication peut être le signe d'abstractions plus profondes qui tentent de sortir.
L'abstraction par l'héritage peut devenir assez moche. Hiérarchies de classes parallèles avec les usines typiques. Le refactoring peut devenir un mal de tête. Et aussi le développement ultérieur, l'endroit où vous vous trouvez.
Il existe une alternative: points d'extension, d'abstractions strictes et de personnalisation à plusieurs niveaux. Dites une personnalisation des clients gouvernementaux, basée sur cette personnalisation pour une ville spécifique.
Un avertissement: Malheureusement, cela fonctionne mieux lorsque toutes (ou la plupart) des classes sont rendues extensibles. Aucune option pour vous, peut-être en petit.
Cette extensibilité fonctionne en ayant une classe de base d'objet extensible contenant des extensions:
void f(CreditorBO creditor) {
creditor.as(AllowedCreditorBO.class).ifPresent(allowedCreditor -> ...);
}
En interne, il existe un mappage paresseux des objets aux objets étendus par classe d'extension.
Pour les classes et composants GUI, la même extensibilité, en partie avec héritage. Ajout de boutons et autres.
Dans votre cas, une validation doit vérifier si elle est étendue et se valider par rapport aux extensions. L'introduction de points d'extension juste pour un cas ajoute du code incompréhensible, pas bon.
Il n'y a donc pas d'autre solution que d'essayer de travailler dans le contexte actuel.
"Contrôle de flux caché" me semble trop ondulatoire.
Toute construction ou élément pris hors contexte peut avoir cette caractéristique.
Les abstractions sont bonnes. Je les tempère avec deux lignes directrices:
Mieux vaut ne pas résumer trop tôt. Attendez plus d'exemples de motifs avant d'abstraction. "Plus" est bien sûr subjectif et spécifique à la situation difficile.
Évitez trop de niveaux d'abstraction simplement parce que l'abstraction est bonne. Un programmeur devra garder ces niveaux dans sa tête pour le code nouveau ou modifié car ils sonder la base de code et aller 12 niveaux en profondeur. Le désir d'un code bien résumé peut conduire à tant de niveaux qu'ils sont difficiles à suivre pour de nombreuses personnes. Cela conduit également à des bases de code "uniquement maintenues par les ninjas".
Dans les deux cas, "plus et" trop "ne sont pas des nombres fixes. Ça dépend. C'est ce qui rend les choses difficiles.
J'aime aussi cet article de Sandi Metz
https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction
la duplication est beaucoup moins chère que la mauvaise abstraction
et
préfère la duplication à la mauvaise abstraction