Code-Smells und Design-Prinzipien

Code-Smells sind Codierungsmuster, die darauf hinweisen, dass mit dem Entwurf eines Programms etwas nicht stimmt. Zum Beispiel ist die übermäßige Verwendung von isinstance-Prüfungen gegen konkrete Klassen ein Code-Smell, da das Programm dadurch schwieriger zu erweitern ist, um mit neuen Typen in der Zukunft umzugehen.

Erkennen von Code-Smells

Eine Möglichkeit, Code-Smells besser zu erkennen, besteht darin, die Merkmale von Code zu beschreiben. Notiert euch die Dinge, die euch auffallen; fügt alle Muster hinzu, die ihr seht, die ihr mögt oder nicht versteht. Möglicherweise können euch die folgenden Fragen zu weiteren Überlegungen anregen:

  • Gibt es Methoden, die die gleiche Form haben?

  • Gibt es Methoden, die ein Argument mit demselben Namen haben?

  • Bedeuten gleichnamige Argumente immer das Gleiche?

  • Wenn ihr eine private Methode einer Klasse hinzufügen wollt, wo würde sie hingehören?

  • Wenn ihr die Klasse in zwei Teile aufteilen würdet, wo wäre die Trennlinie?

  • Haben die Tests in den Bedingungen etwas gemeinsam?

  • Wie viele Verzweigungen haben die Bedingungen?

  • Enthalten die Methoden außer der Bedingung noch weiteren Code?

  • Hängt jede Methode mehr vom übergebenen Argument ab oder von der Klasse als Ganzes?

SOLID-Prinzipien

SOLID ist ein Akronym für:

S – Single-Responsibility-Prinzip

Die Methoden einer Klasse sollten auf einen einzigen Zweck ausgerichtet sein.

O – Open-Closed-Prinzip

Objekte sollten offen für Erweiterungen, aber geschlossen für Änderungen sein.

L – Liskovsches Substitutionsprinzip

Unterklassen sollten durch ihre Oberklassen substituierbar sein.

I – Interface-Segregation-Prinzip

Objekte sollten nicht von Methoden abzuhängen, die sie nicht verwenden.

D – Dependency-Inversion-Prinzip

Abstraktionen sollten nicht von Details abhängen.

Open-Closed-Prinzip

Die Entscheidung, ob eine Refaktorierung überhaupt durchgeführt werden soll, sollte davon abhängen, ob euer Code bereits offen für neue Anforderungen ist. Offen meint hier, dass euer Code offen für Erweiterungen sein sollte, ohne hierfür jedoch bestehenden Code ändern zu müssen. Refaktorierungen sollten nicht mit dem Hinzufügen neuer Funktionen vermischt werden. Stattdessen sollten diese beiden Vorgänge voneinander getrennt werden. Wenn ihr mit einer neuen Anforderung konfrontiert werdet, ordnet zunächst den vorhandenen Code so um, dass er für die neue Funktion offen ist, und fügt den neuen Code erst hinzu, wenn dies abgeschlossen ist.

Unter Refaktorierung versteht man den Prozess, ein Softwaresystem so zu verändern, dass das äußere Verhalten des Codes nicht verändert, aber seine innere Struktur verbessert wird.

Martin Fowler: Refactoring

Bemerkung

Sicheres Refactoring ist auf Tests angewiesen. Wenn ihr den Code wirklich umgestaltet, ohne das Verhalten zu ändern, sollten die vorhandenen Tests bei jedem Schritt weiterhin erfolgreich sein. Die Tests sind ein Sicherheitsnetz, das das Vertrauen in die neue Anordnung des Codes rechtfertigt. Wenn sie versagen,

  • habt ihr den Code versehentlich beschädigt,

  • oder die vorhandenen Tests sind fehlerhaft.

Single-Responsibility-Prinzip

Das Single-Responsibility-Prinzip besagt, dass jede Klasse nur eine Aufgabe erfüllen soll:

Es sollte nie mehr als einen Grund geben, eine Klasse zu ändern.

Robert C. Martin: SRP: The Single Responsibility Principle

Liskovsches Substitutionsprinzip

Das Liskovsche Substitutionsprinzip besagt, dass Unterklassen durch ihre Oberklassen ersetzbar sein müssen. Das Liskov-Substitutionsprinzip gilt auch für Duck-Typing: jedes Objekt, das behauptet, eine Ente zu sein, muss die API der Ente vollständig implementieren. Duck-Types sollten gegeneinander austauschbar sein. Die Logik über verschiedene Datentypen von Objekten hinweg anzuwenden, nennt sich Polymorphie.

Interface-Segregation-Prinzip

Das Interface-Segregation-Prinzip wendet das Single-Responsibility-Prinzip auf Schnittstellen an um ein bestimmtes Verhalten zu isolieren. Wenn eine Änderung an einem Teil eures Codes erforderlich ist, eröffnet die Extraktion eines Objekts, das eine Rolle spielt, die Möglichkeit, das neue Verhalten unterstützen, ohne dass der bestehende Code geändert werden muss. Dies ist kodierten Konkretisierungen vorzuziehen.

In diesem Zusammenhang ist auch das Gesetz von Demeter interessant, das besagt, dass Objekte nur mit Objekten in ihrer unmittelbaren Umgebung kommunizieren sollen. Damit wird die Liste der anderen Objekte wirksam eingeschränkt, an die ein Objekt eine Nachricht senden kann und die Kopplung zwischen Objekten verringert: ein Objekt kann nur mit seinen Nachbarn sprechen, nicht aber mit den Nachbarn seiner Nachbarn; Objekte können nur Nachrichten an direkt Beteiligte senden.

Dependency-Inversion-Prinzip

Das Dependency-Inversion-Prinzip kann definiert werden als

Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen.

Robert C. Martin: The Dependency Inversion Principle

Typische Code-Smells in Python

Funktionen, die Objekte sein sollten

Python unterstützt neben der objektorientierten auch die prozedurale Programmierung mithilfe von Funktionen und vererbbaren Klassen. Beide Paradigmen sollten jedoch auf die passenden Probleme angewendet werden.

Typische Symptome von funktionalem Code, der in Klassen umgestaltet werden sollte, sind

  • ähnliche Argumente über Funktionen hinweg

  • hohe Anzahl eindeutiger Halstead-Operanden

  • Mix aus mutable und immutable Funktionen

So können z.B. drei Funktionen mit unklarer Verwendung so reorganisiert werden, dass load_image() durch .__init__() ersetzt wird, crop() eine Klassenmethode wird und get_thumbnail() eine Eigenschaft:

class Image(object):
    thumbnail_resolution = 128
    def __init__(self, path):
        ...

    def crop(self, width, height):
        ...

    @property
    def thumbnail(self):
        ...
        return thumb

Objekte, die Funktionen sein sollten

Manchmal sollte jedoch auch objektorientierter Code besser in Funktionen aufgelöst werden, z.B. wenn in einer Klasse außer .__init__() nur eine weitere Methode oder nur statische Methoden enthalten sind.

Bemerkung

Ihr müsst nicht händisch nach solchen Klassen suchen, sondern es gibt eine pylint-Regel dafür:

$ pipenv run pylint --disable=all --enable=R0903 requests
************* Module requests.auth
requests/auth.py:72:0: R0903: Too few public methods (1/2) (too-few-public-methods)
requests/auth.py:100:0: R0903: Too few public methods (1/2) (too-few-public-methods)
************* Module requests.models
requests/models.py:60:0: R0903: Too few public methods (1/2) (too-few-public-methods)

-----------------------------------
Your code has been rated at 9.99/10

Dies zeigt uns, dass in auth.py zwei Klassen mit nur einer öffentlichen Methode definiert wurden und zwar in den Zeilen 72ff. und 100ff. Auch in models.py gibt es ab Zeile 60 eine Klasse mit nur einer öffentlichen Methode.

Verschachtelter Code

«Flat is better than nested.»

– Tim Peters, Zen of Python

Verschachtelter Code erschwert das Lesen und Verstehen. Ihr müsst die Bedingungen verstehen und merken, wenn ihr durch die Zweige geht. Objektiv erhöht sich die zyklomatische Komplexität bei steigender Anzahl der Code-Verzweigungen.

Ihr könnt verschachtelte Methoden mit mehreren ineinandergesteckten if-Anweisungen reduzieren, indem ihr Ebenen durch Methoden ersetzt, die ggf. False zurückgeben. Anschließend könnt ihr mit .count() überprüfen, ob die Anzahl der Fehler > 0 ist.

Eine andere Möglichkeit besteht in der Verwendung von List Comprehensions. So kann der Code

results = []
for item in iterable:
    if item == match:
        results.append(item)

ersetzt werden durch:

results = [item for item in iterable if item == match]

Bemerkung

Die itertools der Python-Standardbibliothek sind häufig ebenfalls gut geeignet, um die Verschachtelungstiefe zu reduzieren indem Funktionen zum Erstellen von Iteratoren aus Datenstrukturen erstellt werden.

Bemerkung

Zudem könnt ihr mit den itertools auch filtern, z.B. mit filterfalse:

>>> from itertools import filterfalse
>>> from math import isnan
>>> from statistics import median
>>> data = [20.7, float('NaN'),19.2, 18.3, float('NaN'), 14.4]
>>> sorted(data)
[20.7, nan, 14.4, 18.3, 19.2, nan]
>>> median(data)
16.35
>>> sum(map(isnan, data))
2
>>> clean = list(filterfalse(isnan, data))
>>> clean
[20.7, 19.2, 18.3, 14.4]
>>> sorted(clean)
[14.4, 18.3, 19.2, 20.7]
>>> median(clean)
18.75

Query-Tools für komplexe Dicts

JMESPath, glom, asq und flupy können die Abfrage von Dicts in Python deutlich vereinfachen.

Code reduzieren mit dataclasses und attrs

dataclasses

sollen die Definition von Klassen vereinfachen, die hauptsächlich zum Speichern von Werten erstellt werden, und auf die dann über die Attributsuche zugegriffen werden kann. Einige Beispiele sind collections.namedtuple(), typing.NamedTuple, Rezepte zu Records und Verschachtelte Dicts. dataclasses ersparen euch das Schreiben und Verwalten dieser Methoden.

Siehe auch

attrs

ist ein Python-Paket, das es schon viel länger als dataclasses gibt, umfangreicher ist und auch mit älteren Versionen von Python verwendet werden kann.

Siehe auch