
Wenn von "schlechtem Code" die Rede ist, meinen die Leute mit ziemlicher Sicherheit "komplexen Code" unter anderen populären Problemen. Die Sache mit der Komplexität ist, dass sie aus dem Nichts kommt. Eines Tages starten Sie Ihr ziemlich einfaches Projekt, am anderen Tag finden Sie es in Trümmern. Und niemand weiß, wie und wann es passiert ist.
Dies geschieht jedoch letztendlich aus einem bestimmten Grund! Die Codekomplexität wird auf zwei Arten in Ihre Codebasis eingegeben: mit großen Blöcken und inkrementellen Ergänzungen. Und die Leute sind schlecht darin, beide zu überprüfen und zu finden.
Wenn ein großer Teil des Codes eingeht, wird der Prüfer aufgefordert, den genauen Ort zu finden, an dem der Code komplex ist, und was er dagegen tun soll. Dann muss die Überprüfung den Punkt beweisen: Warum dieser Code überhaupt komplex ist. Und andere Entwickler könnten anderer Meinung sein. Wir alle kennen diese Art von Codeüberprüfungen!

Die zweite Möglichkeit, Komplexität in Ihren Code zu integrieren, ist die inkrementelle Hinzufügung: Wenn Sie eine oder zwei Zeilen an die vorhandene Funktion senden. Und es ist äußerst schwer zu bemerken, dass Ihre Funktion vor einem Commit in Ordnung war, aber jetzt ist sie zu komplex. Es erfordert einen guten Teil der Konzentration, der Überprüfung der Fähigkeiten und der guten Code-Navigationspraxis, um sie tatsächlich zu erkennen. Den meisten Menschen (wie mir!) Fehlen diese Fähigkeiten und die Komplexität kann regelmäßig in die Codebasis eingegeben werden.
Was kann also getan werden, um zu verhindern, dass Ihr Code komplex wird? Wir müssen Automatisierung verwenden! Lassen Sie uns einen tiefen Einblick in die Komplexität des Codes und die Möglichkeiten geben, ihn zu finden und schließlich zu lösen.
In diesem Artikel werde ich Sie durch Orte führen, an denen Komplexität lebt und wie man sie dort bekämpft. Anschließend werden wir diskutieren, wie gut geschriebener einfacher Code und Automatisierung die Möglichkeit der Entwicklungsstile "Continuous Refactoring" und "Architecture on Demand" ermöglichen.
Komplexität erklärt
Man kann sich fragen: Was genau ist "Codekomplexität"? Und obwohl es vertraut klingt, gibt es versteckte Hindernisse beim Verständnis des genauen Komplexitätsorts. Beginnen wir mit den primitivsten Teilen und gehen dann zu übergeordneten Entitäten über.
Denken Sie daran, dass dieser Artikel "Complexity Waterfall" heißt? Ich werde Ihnen zeigen, wie die Komplexität von den einfachsten Primitiven in die höchsten Abstraktionen übergeht.
Ich werde python
als Hauptsprache für meine Beispiele und wemake-python-styleguide
als Hauptwerkzeug verwenden, um die Verstöße in meinem Code zu finden und meinen Standpunkt zu veranschaulichen.
Ausdrücke
Ihr gesamter Code besteht aus einfachen Ausdrücken wie a + 1
und print(x)
. Obwohl Ausdrücke selbst einfach sind, können sie Ihren Code irgendwann unbemerkt mit Komplexität überfluten. Beispiel: Stellen Sie sich vor, Sie haben ein Wörterbuch, das ein User
, und Sie verwenden es folgendermaßen:
def format_username(user) -> str: if not user['username']: return user['email'] elif len(user['username']) > 12: return user['username'][:12] + '...' return '@' + user['username']
Es sieht ziemlich einfach aus, nicht wahr? Tatsächlich enthält es zwei ausdrucksbasierte Komplexitätsprobleme. Es verwendet die overuses 'username'
und verwendet die magische Nummer 12
(warum verwenden wir diese Nummer überhaupt, warum nicht 13
oder 10
?). Es ist schwer, solche Dinge alleine zu finden. So würde die bessere Version aussehen:
Es gibt auch verschiedene Probleme mit dem Ausdruck. Wir können auch überstrapazierte Ausdrücke haben : Wenn Sie das Attribut some_object.some_attr
überall verwenden, anstatt eine neue lokale Variable zu erstellen. Wir können auch zu komplexe logische Bedingungen oder einen zu tiefen Punktzugriff haben .
Lösung : Erstellen Sie neue Variablen, Argumente oder Konstanten. Erstellen und verwenden Sie gegebenenfalls neue Dienstprogrammfunktionen oder -methoden.
Linien
Ausdrücke bilden Codezeilen (bitte verwechseln Sie Zeilen nicht mit Anweisungen: Eine einzelne Anweisung kann mehrere Zeilen umfassen und mehrere Anweisungen können sich in einer einzelnen Zeile befinden).
Die erste und offensichtlichste Komplexitätsmetrik für eine Linie ist ihre Länge. Ja, du hast es richtig gehört. Deshalb halten wir (Programmierer) es vor, uns an die Regel mit 80
Zeichen pro Zeile zu halten, und nicht, weil sie zuvor in den Teletypewritern verwendet wurde. In letzter Zeit gibt es viele Gerüchte darüber, dass es keinen Sinn macht, in 2k19 80
Zeichen für Ihren Code zu verwenden. Aber das stimmt offensichtlich nicht.
Die Idee ist einfach. Sie können in einer Zeile mit 160
Zeichen doppelt so viel Logik haben wie in einer Zeile mit nur 80
Zeichen. Deshalb sollte dieses Limit festgelegt und durchgesetzt werden. Denken Sie daran, dies ist keine stilistische Wahl . Es ist eine Komplexitätsmetrik!
Die zweite Metrik für die Komplexität der Hauptleitung ist weniger bekannt und wird weniger verwendet. Es heißt Jones Complexity . Die Idee dahinter ist einfach: Wir zählen Code- (oder ast
) Knoten in einer einzigen Zeile, um ihre Komplexität zu ermitteln. Schauen wir uns das Beispiel an. Diese beiden Linien unterscheiden sich grundlegend in Bezug auf die Komplexität, haben jedoch genau die gleiche Breite in Zeichen:
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))
Zählen wir die Knoten im ersten: einen Anruf, drei Namen. Insgesamt vier Knoten. Der zweite hat einundzwanzig ast
. Nun, der Unterschied ist klar. Aus diesem Grund verwenden wir die Jones Complexity-Metrik, um die erste lange Zeile zuzulassen und die zweite nicht zuzulassen, basierend auf einer internen Komplexität, nicht nur auf der Rohlänge.
Was tun mit Linien mit einem hohen Jones Complexity Score?
Lösung : Teilen Sie sie in mehrere Zeilen auf oder erstellen Sie neue Zwischenvariablen, Dienstprogrammfunktionen, neue Klassen usw.
print( first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2), )
Jetzt ist es viel lesbarer!
Strukturen
Der nächste Schritt ist die Analyse von Sprachstrukturen wie if
, for
, with
usw., die aus Linien und Ausdrücken gebildet werden. Ich muss sagen, dass dieser Punkt sehr sprachspezifisch ist. Ich werde auch einige Regeln aus dieser Kategorie mit python
.
Wir fangen mit if
. Was kann einfacher sein als ein guter Alter, if
? Eigentlich, if
es richtig schnell knifflig wird. Hier ist ein Beispiel, wie man einen reimplement switch
kann reimplement switch
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): ...
Was ist das Problem mit diesem Code? Stellen Sie sich vor, wir haben Dutzende von Datentypen, die abgedeckt werden sollten, einschließlich Zolltypen, die uns noch nicht bekannt sind. Dann ist dieser komplexe Code ein Indikator dafür, dass wir hier ein falsches Muster wählen. Wir müssen unseren Code umgestalten, um dieses Problem zu beheben. Zum Beispiel kann man singledispatch
oder singledispatch
. Sie haben den gleichen Job, aber schöner.
python
hört nie auf, uns zu amüsieren. Zum Beispiel können Sie mit einer beliebigen Anzahl von Fällen schreiben, was zu mental komplex und verwirrend ist:
with first(), second(), third(), fourth(): ...
Sie können auch Verständnisse mit einer beliebigen Anzahl von if
und for
Ausdrücken schreiben, was zu komplexem, unlesbarem Code führen kann:
[ (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 ]
Vergleichen Sie es mit der einfachen und lesbaren Version:
[ (x, y, z) for x, y, x in itertools.product(x_coords, y_coords, z_coords) if valid_coordinates(x, y, z) ]
Sie können auch versehentlich multiple statements inside a try
Fall multiple statements inside a try
, was unsicher ist, da eine Ausnahme an einer erwarteten Stelle ausgelöst und behandelt werden kann:
try: user = fetch_user()
Und das sind nicht einmal 10% der Fälle, die mit Ihrem python
Code schief gehen können und werden. Es gibt viele, viele weitere Randfälle , die verfolgt und analysiert werden sollten.
Lösung : Die einzig mögliche Lösung besteht darin, einen guten Linter für die Sprache Ihrer Wahl zu verwenden. Und Refactor komplexe Orte, die dieser Linter hervorhebt. Andernfalls müssen Sie das Rad neu erfinden und benutzerdefinierte Richtlinien für genau dieselben Probleme festlegen.
Funktionen
Ausdrücke, Anweisungen und Strukturen bilden Funktionen. Die Komplexität dieser Entitäten fließt in Funktionen ein. Und hier beginnen die Dinge faszinierend zu werden. Weil Funktionen buchstäblich Dutzende von Komplexitätsmetriken haben: sowohl gute als auch schlechte.
Wir beginnen mit den bekanntesten: zyklomatische Komplexität und Funktionslänge, gemessen in Codezeilen. Die zyklomatische Komplexität gibt an, wie viele Umdrehungen Ihr Ausführungsablauf dauern kann: Sie entspricht fast der Anzahl der Komponententests, die erforderlich sind, um den Quellcode vollständig abzudecken. Es ist eine gute Metrik, da sie die Semantik respektiert und dem Entwickler hilft, das Refactoring durchzuführen. Andererseits ist die Länge einer Funktion eine schlechte Metrik. Es stimmt nicht mit der zuvor erläuterten Jones Complexity-Metrik überein, da wir bereits wissen: Mehrere Zeilen sind leichter zu lesen als eine große Zeile mit allem darin. Wir werden uns nur auf gute Metriken konzentrieren und schlechte ignorieren.
Nach meiner Erfahrung sollten anstelle der Länge der regulären Funktion mehrere nützliche Komplexitätsmetriken gezählt werden:
- Anzahl der Funktionsdekorateure; niedriger ist besser
- Anzahl der Argumente; niedriger ist besser
- Anzahl der Anmerkungen; höher ist besser
- Anzahl lokaler Variablen; niedriger ist besser
- Anzahl der Renditen, Renditen, erwartet; niedriger ist besser
- Anzahl der Aussagen und Ausdrücke; niedriger ist besser
Die Kombination all dieser Prüfungen ermöglicht es Ihnen wirklich, einfache Funktionen zu schreiben (alle Regeln werden auch auf Methoden angewendet).
Wenn Sie versuchen, einige böse Dinge mit Ihrer Funktion zu tun, werden Sie sicherlich mindestens eine Metrik brechen. Und das wird unseren Linter enttäuschen und Ihren Build sprengen. Dadurch wird Ihre Funktion gespeichert.
Lösung : Wenn eine Funktion zu komplex ist, besteht die einzige Lösung darin, diese Funktion in mehrere Funktionen aufzuteilen.
Klassen
Die nächste Abstraktionsebene nach Funktionen sind Klassen. Und wie Sie bereits vermutet haben, sind sie noch komplexer und flüssiger als Funktionen. Da Klassen möglicherweise mehrere Funktionen enthalten (die als Methode bezeichnet werden) und andere einzigartige Funktionen wie Vererbung und Mixins, Attribute auf Klassenebene und Dekoratoren auf Klassenebene aufweisen. Wir müssen also alle Methoden als Funktionen und den Klassenkörper selbst überprüfen.
Für Klassen müssen wir die folgenden Metriken messen:
- Anzahl der Dekorateure auf Klassenebene; niedriger ist besser
- Anzahl der Basisklassen; niedriger ist besser
- Anzahl der öffentlichen Attribute auf Klassenebene; niedriger ist besser
- Anzahl der öffentlichen Attribute auf Instanzebene; niedriger ist besser
- Anzahl der Methoden; niedriger ist besser
Wenn eines davon zu kompliziert ist, müssen wir den Alarm auslösen und den Build nicht bestehen!
Lösung : Refaktorieren Sie Ihre fehlgeschlagene Klasse! Teilen Sie eine vorhandene komplexe Klasse in mehrere einfache auf oder erstellen Sie neue Dienstprogrammfunktionen und verwenden Sie die Komposition.
Bemerkenswerte Erwähnung: Sie können auch Kohäsions- und Kopplungsmetriken verfolgen, um die Komplexität Ihres OOP-Designs zu überprüfen.
Module
Module enthalten mehrere Anweisungen, Funktionen und Klassen. Und wie Sie vielleicht bereits erwähnt haben, empfehlen wir normalerweise, Funktionen und Klassen in neue aufzuteilen. Deshalb müssen wir die Komplexität der Module im Auge behalten: Sie fließt buchstäblich aus Klassen und Funktionen in Module.
Um die Komplexität des Moduls zu analysieren, müssen wir Folgendes überprüfen:
- Die Anzahl der Importe und importierten Namen; niedriger ist besser
- Die Anzahl der Klassen und Funktionen; niedriger ist besser
- Die durchschnittliche Komplexität der Funktionen und Klassen im Inneren; niedriger ist besser
Was machen wir bei einem komplexen Modul?
Lösung : Ja, Sie haben es richtig gemacht. Wir teilen ein Modul in mehrere auf.
Pakete
Pakete enthalten mehrere Module. Zum Glück ist das alles, was sie tun.
Daher kann die Anzahl der Module in einem Paket bald zu groß werden, sodass Sie am Ende zu viele davon haben. Und es ist die einzige Komplexität, die mit Paketen gefunden werden kann.
Lösung : Sie müssen Pakete in Unterpakete und Pakete unterschiedlicher Ebenen aufteilen.
Komplexität Wasserfalleffekt
Wir haben jetzt fast alle möglichen Arten von Abstraktionen in Ihrer Codebasis behandelt. Was haben wir daraus gelernt? Die wichtigste Erkenntnis ist vorerst, dass die meisten Probleme gelöst werden können, indem die Komplexität auf dieselbe oder eine höhere Abstraktionsebene ausgeworfen wird.

Dies führt uns zu der wichtigsten Idee dieses Artikels: Lassen Sie Ihren Code nicht mit der Komplexität überlaufen. Ich werde einige Beispiele geben, wie es normalerweise passiert.
Stellen Sie sich vor, Sie implementieren eine neue Funktion. Und das ist die einzige Änderung, die Sie vornehmen:
Sieht in Ordnung aus, ich würde diesen Code bei der Überprüfung weitergeben. Und nichts Schlimmes würde passieren. Aber der Punkt, den ich vermisse, ist, dass die Komplexität diese Linie überflutet hat! Das wird wemake-python-styleguide
berichten:

Ok, wir müssen jetzt diese Komplexität lösen. Lassen Sie uns eine neue Variable erstellen:
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: ... ... ...
Jetzt ist die Leitungskomplexität gelöst. Aber warte eine Minute. Was ist, wenn unsere Funktion jetzt zu viele Variablen hat? Weil wir eine neue Variable erstellt haben, ohne zuerst ihre Nummer innerhalb der Funktion zu überprüfen. In diesem Fall müssen wir diese Methode in mehrere Methoden aufteilen:
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 ...
Jetzt sind wir fertig! Richtig? Nein, denn wir müssen jetzt die Komplexität der Product
überprüfen. Stellen Sie sich vor, dass es jetzt zu viele Methoden gibt, seit wir eine neue _has_paid_sub
Methode erstellt haben.
Ok, wir lassen unseren Linter laufen, um die Komplexität erneut zu überprüfen. Und es stellt sich heraus, dass unsere Product
derzeit in der Tat zu komplex ist. Unsere Handlungen? Wir haben es in mehrere Klassen aufgeteilt!
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 ... ...
Bitte sagen Sie mir, dass es die letzte Iteration ist! Nun, es tut mir leid, aber wir müssen jetzt die Komplexität des Moduls überprüfen. Und weißt du was? Wir haben jetzt zu viele Modulmitglieder. Wir müssen also Module in separate aufteilen! Dann überprüfen wir die Paketkomplexität. Und möglicherweise auch in mehrere Unterpakete aufgeteilt.
Hast du es gesehen Aufgrund der genau definierten Komplexitätsregeln stellte sich heraus, dass unsere einzeilige Änderung eine große Refactoring-Sitzung mit mehreren neuen Modulen und Klassen war. Und wir haben selbst keine einzige Entscheidung getroffen: Alle unsere Refactoring-Ziele wurden von der internen Komplexität und dem Linter bestimmt, der sie offenbart.
Das nenne ich einen "Continuous Refactoring" -Prozess. Sie sind gezwungen, das Refactoring durchzuführen. Immer.
Dieser Prozess hat auch eine interessante Konsequenz. Es ermöglicht Ihnen "Architecture on Demand". Lass mich erklären. Mit der Philosophie "Architecture on Demand" fangen Sie immer klein an. Zum Beispiel mit einer einzelnen Datei logic/domains/user.py
Und Sie fangen an, alles, was mit dem User
tun hat, dort User
. Denn in diesem Moment wissen Sie wahrscheinlich nicht, wie Ihre Architektur aussehen wird. Und es ist dir egal. Sie haben nur drei Funktionen.
Einige Leute fallen in die Falle von Architektur gegen Codekomplexität. Sie können ihre Architektur von Anfang an mit den vollständigen Repository- / Service- / Domänenschichten übermäßig komplizieren. Oder sie können den Quellcode ohne klare Trennung übermäßig komplizieren. Kämpfe und lebe jahrelang so (wenn sie jahrelang mit dem Code wie diesem leben können!).
Das Konzept "Architecture on Demand" löst diese Probleme. Sie fangen klein an, wenn es soweit ist - Sie teilen und überarbeiten Dinge:
- Sie beginnen mit
logic/domains/user.py
und legen alles dort ab - Später erstellen Sie die Datei
logic/domains/user/repository.py
wenn Sie über genügend datenbankbezogene Inhalte verfügen - Dann teilen Sie es in
logic/domains/user/repository/queries.py
und logic/domains/user/repository/queries.py
wenn die Komplexität Sie dazu auffordert - Dann erstellen Sie
logic/domains/user/services.py
mit http
bezogenen Sachen - Anschließend erstellen Sie ein neues Modul mit dem Namen
logic/domains/order.py
- Und so weiter und so fort
Das war's. Es ist ein perfektes Werkzeug, um Ihre Architektur und Codekomplexität in Einklang zu bringen. Und holen Sie sich so viel Architektur, wie Sie im Moment wirklich brauchen.
Fazit
Guter Linter kann viel mehr als nur fehlende Kommas und schlechte Anführungszeichen finden. Mit Good Linter können Sie sich bei Architekturentscheidungen darauf verlassen und beim Refactoring-Prozess helfen.
Zum Beispiel kann wemake-python-styleguide
Ihnen bei der Komplexität des python
Quellcodes helfen. Es ermöglicht Ihnen wemake-python-styleguide
:
- Bekämpfe erfolgreich die Komplexität auf allen Ebenen
- Erzwingen Sie die enorme Menge an Namensstandards, Best Practices und Konsistenzprüfungen
- Integrieren Sie es einfach mit Hilfe der
diff
Option oder des flakehell
Tools in eine Legacy-Codebasis, sodass alte Verstöße vergeben werden, neue jedoch nicht zulässig sind - Aktivieren Sie es in Ihrem [CI] (), auch als Github-Aktion
Lassen Sie nicht zu, dass die Komplexität Ihren Code überflutet. Verwenden Sie einen guten Linter !