Cascade de complexité et architecture à la demande

Le logo


Lorsqu'on parle de «mauvais code», les gens signifient presque certainement «code complexe» parmi d'autres problèmes courants. Le truc avec la complexité, c'est qu'elle vient de nulle part. Un jour, vous commencez votre projet assez simple, l'autre jour vous le trouvez en ruine. Et personne ne sait comment et quand cela s'est produit.


Mais cela arrive finalement pour une raison! La complexité du code entre dans votre base de code de deux manières possibles: avec de gros morceaux et des ajouts incrémentiels. Et les gens sont mauvais pour les examiner et les trouver tous les deux.


Lorsqu'un gros morceau de code arrive, le réviseur sera mis au défi de trouver l'emplacement exact où le code est complexe et ce qu'il faut faire à ce sujet. Ensuite, l'examen devra prouver le point: pourquoi ce code est complexe en premier lieu. Et d'autres développeurs pourraient ne pas être d'accord. Nous connaissons tous ce genre de revues de code!


Nombre de lignes à réviser et taux de commentaires


Le deuxième moyen de complexité d'entrer dans votre code est l'ajout incrémentiel: lorsque vous soumettez une ou deux lignes à la fonction existante. Et il est extrêmement difficile de remarquer que votre fonction était correcte il y a un commit, mais maintenant elle est trop complexe. Il faut une bonne partie de concentration, un examen des compétences et de bonnes pratiques de navigation dans le code pour le détecter. La plupart des gens (comme moi!) Manquent de ces compétences et permettent à la complexité d'entrer régulièrement dans la base de code.


Alors, que peut-on faire pour éviter que votre code ne devienne complexe? Nous devons utiliser l'automatisation! Jetons un coup d'œil à la complexité du code et aux moyens de le trouver et enfin de le résoudre.


Dans cet article, je vais vous guider à travers des endroits où vit la complexité et comment la combattre. Ensuite, nous verrons dans quelle mesure le code simple et l'automatisation sont bien écrits et permettent de développer les styles de développement «Refactoring continu» et «Architecture à la demande».


La complexité expliquée


On peut se demander: quelle est exactement la «complexité du code»? Et même si cela semble familier, il existe des obstacles cachés dans la compréhension de l'emplacement exact de la complexité. Commençons par les parties les plus primitives, puis passons aux entités de niveau supérieur.


Rappelez-vous, que cet article est nommé "Complexity Waterfall"? Je vais vous montrer comment la complexité des primitives les plus simples déborde dans les abstractions les plus élevées.


J'utiliserai python comme langue principale pour mes exemples et wemake-python-styleguide comme outil principal de linting pour trouver les violations dans mon code et illustrer mon propos.


Expressions


Tout votre code se compose d'expressions simples telles a + 1 et print(x) . Bien que les expressions elles-mêmes soient simples, elles pourraient à certains moments déborder votre code de complexité. Exemple: imaginez que vous avez un dictionnaire qui représente un modèle User et que vous l'utilisez comme ceci:


 def format_username(user) -> str: if not user['username']: return user['email'] elif len(user['username']) > 12: return user['username'][:12] + '...' return '@' + user['username'] 

Cela semble assez simple, non? En fait, il contient deux problèmes de complexité basés sur l'expression. Il sur- overuses 'username' et utilise le nombre magique 12 (pourquoi utilisons-nous ce nombre en premier lieu, pourquoi pas 13 ou 10 ?). Il est difficile de trouver ce genre de choses par vous-même. Voici à quoi ressemblerait la meilleure version:


 #: That's how many chars fit in the preview box. LENGTH_LIMIT: Final = 12 def format_username(user) -> str: username = user['username'] if not username: return user['email'] elif len(username) > LENGTH_LIMIT: # See? It is now documented return username[:LENGTH_LIMIT] + '...' return '@' + username 

Il y a également différents problèmes d'expression. Nous pouvons également avoir des expressions surutilisées : lorsque vous utilisez l'attribut some_object.some_attr partout au lieu de créer une nouvelle variable locale. Nous pouvons également avoir des conditions logiques trop complexes ou un accès point trop profond .


Solution : créez de nouvelles variables, arguments ou constantes. Créez et utilisez de nouvelles fonctions ou méthodes utilitaires si nécessaire.


Lignes


Les expressions forment des lignes de code (veuillez ne pas confondre les lignes avec les instructions: une seule instruction peut prendre plusieurs lignes et plusieurs instructions peuvent se trouver sur une seule ligne).


La première et la métrique de complexité la plus évidente pour une ligne est sa longueur. Oui, vous l'avez bien entendu. C'est pourquoi nous (les programmeurs) préférons nous en tenir à la règle des 80 caractères par ligne et non pas parce qu'elle était précédemment utilisée dans les téléscripteurs. Il y a beaucoup de rumeurs à ce sujet ces derniers temps, disant qu'il ne fait aucun sens d'utiliser 80 caractères pour votre code en 2k19. Mais ce n'est évidemment pas vrai.


L'idée est simple. Vous pouvez avoir deux fois plus de logique dans une ligne avec 160 caractères qu'en ligne avec seulement 80 caractères. C'est pourquoi cette limite doit être fixée et appliquée. N'oubliez pas que ce n'est pas un choix stylistique . C'est une métrique de complexité!


La deuxième métrique de complexité de la ligne principale est moins connue et moins utilisée. Cela s'appelle Jones Complexity . L'idée derrière cela est simple: nous comptons les nœuds de code (ou ast ) sur une seule ligne pour obtenir sa complexité. Jetons un coup d'œil à l'exemple. Ces deux lignes sont fondamentalement différentes en termes de complexité mais ont exactement la même largeur en caractères:


 print(first_long_name_with_meaning, second_very_long_name_with_meaning, third) print(first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2)) 

Comptons les nœuds du premier: un appel, trois noms. Quatre nœuds totalement. Le second a vingt et un nœuds ast . Eh bien, la différence est claire. C'est pourquoi nous utilisons la métrique de complexité Jones pour autoriser la première ligne longue et interdire la seconde en fonction d'une complexité interne, et pas uniquement de la longueur brute.


Que faire des lignes avec un score de complexité Jones élevé?


Solution : divisez-les en plusieurs lignes ou créez de nouvelles variables intermédiaires, des fonctions utilitaires, de nouvelles classes, etc.


 print( first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2), ) 

Maintenant, c'est beaucoup plus lisible!


Structures


L'étape suivante consiste à analyser les structures du langage comme if , for , with , etc. qui sont formées à partir de lignes et d'expressions. Je dois dire que ce point est très spécifique à la langue. Je présenterai également plusieurs règles de cette catégorie en utilisant python .


Nous commencerons par if . Quoi de plus facile qu'un bon vieux if ? En fait, if commence à devenir difficile très rapidement. Voici un exemple de la façon dont on peut reimplement switch avec if :


 if isinstance(some, int): ... elif isinstance(some, float): ... elif isinstance(some, complex): ... elif isinstance(some, str): ... elif isinstance(some, bytes): ... elif isinstance(some, list): ... 

Quel est le problème avec ce code? Eh bien, imaginez que nous ayons des dizaines de types de données qui devraient être couverts, y compris ceux des douanes que nous ne connaissons pas encore. Ensuite, ce code complexe est un indicateur que nous choisissons ici un mauvais modèle. Nous devons refactoriser notre code pour résoudre ce problème. Par exemple, on peut utiliser des typeclass es ou une singledispatch . Ils ont le même travail, mais plus gentils.


python ne cesse de nous amuser. Par exemple, vous pouvez écrire with un nombre arbitraire de cas , ce qui est trop complexe mentalement et déroutant:


 with first(), second(), third(), fourth(): ... 

Vous pouvez également écrire des compréhensions avec un nombre if for expressions if et for , ce qui peut conduire à un code complexe et illisible:


 [ (x, y, z) for x in x_coords for y in y_coords for z in z_coords if x > 0 if y > 0 if z > 0 if x + y <= z if x + z <= y if y + z <= x ] 

Comparez-le avec la version simple et lisible:


 [ (x, y, z) for x, y, x in itertools.product(x_coords, y_coords, z_coords) if valid_coordinates(x, y, z) ] 

Vous pouvez également accidentellement inclure multiple statements inside a try cas d' multiple statements inside a try , ce qui n'est pas sûr car il peut déclencher et gérer une exception à un emplacement attendu:


 try: user = fetch_user() # Can also fail, but don't expect that log.save_user_operation(user.email) # Can fail, and we know it except MyCustomException as exc: ... 

Et ce n'est même pas 10% des cas qui peuvent et vont mal avec votre code python . Il y a beaucoup, beaucoup plus de cas marginaux qui devraient être suivis et analysés.


Solution : La seule solution possible est d'utiliser un bon linter pour la langue de votre choix. Et refactoriser les endroits complexes que ce linter met en valeur. Sinon, vous devrez réinventer la roue et définir des politiques personnalisées pour les mêmes problèmes.


Les fonctions


Les expressions, les instructions et les structures forment des fonctions. La complexité de ces entités se transforme en fonctions. Et c'est là que les choses commencent à devenir intrigantes. Parce que les fonctions ont littéralement des dizaines de mesures de complexité: à la fois bonnes et mauvaises.


Nous commencerons par les plus connus: complexité cyclomatique et longueur de fonction mesurée en lignes de code. La complexité cyclomatique indique le nombre de tours que peut prendre votre flux d'exécution: il est presque égal au nombre de tests unitaires requis pour couvrir entièrement le code source. C'est une bonne métrique car elle respecte la sémantique et aide le développeur à refactoriser. D'un autre côté, la longueur d'une fonction est une mauvaise métrique. Il ne coopère pas avec la métrique de complexité de Jones expliquée précédemment, car nous le savons déjà: plusieurs lignes sont plus faciles à lire qu'une seule grande ligne avec tout à l'intérieur. Nous nous concentrerons uniquement sur les bonnes mesures et ignorerons les mauvaises.


Sur la base de mon expérience, plusieurs métriques de complexité utiles devraient être comptées au lieu de la longueur de la fonction régulière:


  • Nombre de décorateurs de fonction; plus c'est bas
  • Nombre d'arguments; plus c'est bas
  • Nombre d'annotations; plus c'est mieux
  • Nombre de variables locales; plus c'est bas
  • Nombre de retours, rendements, attentes; plus c'est bas
  • Nombre de déclarations et d'expressions; plus c'est bas

La combinaison de toutes ces vérifications vous permet vraiment d'écrire des fonctions simples (toutes les règles s'appliquent également aux méthodes).


Lorsque vous essaierez de faire des choses désagréables avec votre fonction, vous casserez sûrement au moins une métrique. Et cela décevra notre linter et soufflera votre construction. En conséquence, votre fonction sera enregistrée.


Solution : lorsqu'une fonction est trop complexe, la seule solution que vous avez est de diviser cette fonction en plusieurs.


Cours


Le niveau suivant d'abstraction après les fonctions sont les classes. Et comme vous l'avez déjà deviné, elles sont encore plus complexes et fluides que les fonctions. Parce que les classes peuvent contenir plusieurs fonctions à l'intérieur (appelées méthode) et avoir d'autres fonctionnalités uniques comme l'héritage et les mixins, les attributs au niveau de la classe et les décorateurs au niveau de la classe. Nous devons donc vérifier toutes les méthodes en tant que fonctions et le corps de classe lui-même.


Pour les classes, nous devons mesurer les métriques suivantes:


  • Nombre de décorateurs au niveau de la classe; plus c'est bas
  • Nombre de classes de base; plus c'est bas
  • Nombre d'attributs publics au niveau de la classe; plus c'est bas
  • Nombre d'attributs publics au niveau de l'instance; plus c'est bas
  • Nombre de méthodes; plus c'est bas

Lorsque l'un de ces éléments est trop compliqué - nous devons sonner l'alarme et échouer la construction!


Solution : refactorisez votre classe ratée! Divisez une classe complexe existante en plusieurs classes simples ou créez de nouvelles fonctions utilitaires et utilisez la composition.


Mention notable: on peut également suivre les mesures de cohésion et de couplage pour valider la complexité de votre conception POO.


Modules


Les modules contiennent plusieurs instructions, fonctions et classes. Et comme vous l'avez peut-être déjà mentionné, nous vous conseillons généralement de diviser les fonctions et les classes en nouvelles. C'est pourquoi nous devons garder à l'œil la complexité des modules: elle coule littéralement dans les modules des classes et des fonctions.


Pour analyser la complexité du module, nous devons vérifier:


  • Le nombre d'importations et de noms importés; plus c'est bas
  • Le nombre de classes et de fonctions; plus c'est bas
  • La complexité moyenne des fonctions et des classes à l'intérieur; plus c'est bas

Que fait-on dans le cas d'un module complexe?


Solution : oui, vous avez bien compris. Nous avons divisé un module en plusieurs modules.


Forfaits


Les packages contiennent plusieurs modules. Heureusement, c'est tout ce qu'ils font.


Ainsi, le nombre de modules dans un package peut bientôt devenir trop volumineux, vous vous retrouverez donc avec un trop grand nombre d'entre eux. Et c'est la seule complexité que l'on puisse trouver avec les packages.


Solution : vous devez diviser les packages en sous-packages et packages de différents niveaux.


Effet cascade complexe


Nous avons maintenant couvert presque tous les types d'abstractions possibles dans votre base de code. Qu'en avons-nous appris? Le principal point à retenir, pour l'instant, est que la plupart des problèmes peuvent être résolus en éjectant la complexité au même niveau d'abstraction ou au niveau supérieur.


Cascade de complexité


Cela nous amène à l'idée la plus importante de cet article: ne laissez pas votre code déborder de complexité. Je vais donner plusieurs exemples de la façon dont cela se produit habituellement.


Imaginez que vous implémentez une nouvelle fonctionnalité. Et c'est le seul changement que vous apportez:


 +++ if user.is_active and user.has_sub() and sub.is_due(tz.now() + delta): --- if user.is_active and user.has_sub(): 

Semble ok, je passerais ce code lors de l'examen. Et rien de mal ne se passerait. Mais, le point qui me manque, c'est que la complexité a débordé cette ligne! C'est ce que wemake-python-styleguide :


wemake-python-styleguide-output


Ok, nous devons maintenant résoudre cette complexité. Faisons une nouvelle variable:


 class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... is_sub_paid = sub.is_due(tz.now() + delta) if user.is_active and user.has_sub() and is_sub_paid: ... ... ... 

Maintenant, la complexité de la ligne est résolue. Mais attendez une minute. Et si notre fonction a trop de variables maintenant? Parce que nous avons créé une nouvelle variable sans vérifier d'abord leur nombre dans la fonction. Dans ce cas, nous devrons diviser cette méthode en plusieurs comme ceci:


 class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid ... 

Maintenant, nous avons terminé! Non? Non, car nous devons maintenant vérifier la complexité de la classe Product . Imaginez qu'il a maintenant trop de méthodes puisque nous en avons créé une nouvelle _has_paid_sub .


Ok, nous exécutons notre linter pour vérifier à nouveau la complexité. Et il s'avère que notre classe de Product est en effet trop complexe en ce moment. Nos actions? Nous l'avons divisé en plusieurs classes!


 class Policy(object): ... class SubcsriptionPolicy(Policy): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid class Product(object): _purchasing_policy: Policy ... ... 

Veuillez me dire que c'est la dernière itération! Eh bien, je suis désolé, mais nous devons maintenant vérifier la complexité du module. Et devinez quoi? Nous avons maintenant trop de membres du module. Nous devons donc diviser les modules en modules séparés! Ensuite, nous vérifions la complexité du package. Et aussi éventuellement le diviser en plusieurs sous-packages.


L'avez-vous vu? En raison des règles de complexité bien définies, notre modification sur une seule ligne s'est avérée être une énorme session de refactoring avec plusieurs nouveaux modules et classes. Et nous n'avons pas pris une seule décision nous-mêmes: tous nos objectifs de refactoring étaient motivés par la complexité interne et le linter qui le révèle.


C'est ce que j'appelle un processus de "Refactoring continu". Vous êtes obligé de refactoriser. Toujours.


Ce processus a également une conséquence intéressante. Il vous permet d'avoir "Architecture à la demande". Laisse-moi t'expliquer. Avec la philosophie «Architecture à la demande», vous commencez toujours petit. Par exemple avec un seul fichier logic/domains/user.py Et vous commencez à y mettre tout ce qui concerne l' User . Parce qu'en ce moment, vous ne savez probablement pas à quoi ressemblera votre architecture. Et tu t'en fous. Vous n'avez que trois fonctions.


Certaines personnes tombent dans le piège de l'architecture contre la complexité du code. Ils peuvent trop compliquer leur architecture dès le début avec les couches complètes de référentiel / service / domaine. Ou ils peuvent compliquer excessivement le code source sans séparation claire. Luttez et vivez comme ça pendant des années (s'ils pourront vivre pendant des années avec le code comme ça!).


Le concept «Architecture à la demande» résout ces problèmes. Vous commencez petit, le moment venu - vous divisez et remodelez les choses:


  1. Vous commencez avec la logic/domains/user.py et vous y mettez tout
  2. Plus tard, vous créez logic/domains/user/repository.py lorsque vous avez suffisamment de choses liées à la base de données
  3. Ensuite, vous le logic/domains/user/repository/queries.py en logic/domains/user/repository/queries.py et logic/domains/user/repository/commands.py lorsque la complexité vous le demande
  4. Ensuite, vous créez logic/domains/user/services.py avec des éléments liés à http
  5. Ensuite, vous créez un nouveau module appelé logic/domains/order.py
  6. Et ainsi de suite et ainsi de suite

Voilà. C'est un outil parfait pour équilibrer votre architecture et la complexité du code. Et obtenez autant d'architecture que vous en avez vraiment besoin en ce moment.


Conclusion


Un bon linter fait bien plus que trouver des virgules manquantes et de mauvaises citations. Un bon linter vous permet de vous y fier pour les décisions d'architecture et de vous aider dans le processus de refactoring.


Par exemple, wemake-python-styleguide peut vous aider avec la complexité du code source python , il vous permet de:


  • Combattez avec succès la complexité à tous les niveaux
  • Appliquez une énorme quantité de normes de dénomination, de meilleures pratiques et de contrôles de cohérence
  • Intégrez-le facilement dans une base de code héritée à l'aide de l' option diff ou de l'outil flakehell , donc les anciennes violations seront pardonnées, mais les nouvelles ne seront pas autorisées
  • Activez-le dans votre [CI] (), même en tant qu'action Github

Ne laissez pas la complexité déborder votre code, utilisez un bon linter !

Source: https://habr.com/ru/post/fr472876/


All Articles