Guter Python-Code benötigt mehr als PEP8: Wie idiomatischer Stil Lesbarkeit und Wartbarkeit verbessert.
Adrien Wehrung | Software-Architekt
Wenn über Code-Qualität gesprochen wird, fällt ein Begriff fast immer zuerst: Styling. Keiner kann behaupten, qualitativ hochwertigen Code zu schreiben, ohne sich an konsistente Formatierungsrichtlinien, sogenannte Code-Style-Richtlinien, zu halten. Doch was oft vergessen wird: Das ist nur der erste Schritt. Um von guter Code-Qualität wirklich zu profitieren, z. B. von besserer Lesbarkeit, Wartbarkeit, Testbarkeit und einer langfristig höheren Entwicklungsgeschwindigkeit, ist mehr nötig. Die gute Nachricht: Das bedeutet nicht automatisch hohen Aufwand. Vielmehr geht es darum, gute Gewohnheiten zu entwickeln und dem Code die richtigen Fragen zu stellen.
Um diese Prinzipien anhand eines konkreten Beispiels zu veranschaulichen, nutzen wir historisches Material: Vor zehn Jahren hielt der Python-Experte Raymond Hettinger eine sehr beliebte Session auf der PyCon2015. Darin zeigt er eindrucksvoll, wie Code-Qualität weit über reine Style-Guides hinausgeht und wie mächtig idiomatischer Code sein kann. Seit dieser Konferenz ist schon eine Ewigkeit vergangen, dennoch sind seine Ideen aus dieser Session aktueller denn je und problemlos auch auf andere Sprachen übertragbar.
In diesem Artikel fassen wir für Dich die Erkenntnisse jener Session zusammen – mit aktualisierten Code-Beispielen für Python 3 (damals war noch Python 2 state-of-the-art). Das Video zu seiner Session findest Du hier: YouTube - Raymond Hettinger - Beyond PEP 8
Die Geschichte startet wie folgt: Wir haben den folgenden Code-Review-Kommentar von unserem Chef bekommen.
"Gutes Exception-Handling und Logging 👍
Code-Formatierung bitte nachziehen (PEP 8), das liest sich aktuell ein bisschen schwer
Sollten wir das als Template für andere kleine Skripte nutzen, die mit NetworkElement arbeiten?"
Der Code dazu:
import jnettool.tools.elements.NetworkElement, \
jnettool.tools.Routing, \
jnettool.tools.RouteInspector
ne=jnettool.tools.elements.NetworkElement( '171.0.2.45' )
try:
routing_table=ne.getRoutingTable() # fetch table
except jnettool.tools.elements.MissingVar:
# Record table fault
logging.exception( '''No routing table found''' )
# Undo partial changes
ne.cleanup( '''rollback''' )
else:
num_routes=routing_table.getSize() # determine table size
for RToffset in range ( num_routes ):
route=routing_table.getRouteByIndex( RToffset )
name=route.getName() # route name
ipaddr=route.getIPAddr() # ip address
print( '%15s -> %s'% (name,ipaddr) ) # format nicely
ne.cleanup( '''commit''' ) # lockin changes
finally:
ne.disconnect()
Als Erstes sollten wir sicherstellen, dass wir verstehen, was der Code macht. Es handelt sich um ein kleines Skript, das eine Library nutzt, um bestimmte Routing-Tables zu einer IP-Adresse anzuzeigen. Es verfügt über rudimentäres Fehlerhandling.
Nun zur Bitte aus dem Code-Review: Code-Formatierung nachziehen. Kein Problem, wir können das Code-Styling anpassen. Das sollte den Code deutlich lesbarer machen. Die PEP8-Richtlinie gibt uns einfache Aktionspunkte:
snake_case
. Die Methoden aus der Library können nicht angepasst werden, aber unsere eigenen Variablen schon.=
", keine Leerzeichen nach "(
" und vor ")
". "y=f( x )
" wird zu "y = f(x)
".So erhalten wir nun folgenden Code:
import jnettool.tools.elements.NetworkElement
ne = jnettool.tools.elements.NetworkElement("171.0.2.45")
try:
routing_table = ne.getRoutingTable()
except jnettool.tools.elements.MissingVar:
logging.exception("No routing table found")
ne.cleanup("rollback")
else:
num_routes = routing_table.getSize()
for rt_offset in range(num_routes):
route = routing_table.getRouteByIndex(rt_offset)
name = route.getName()
ipaddr = route.getIPAddr()
print(f"{name:>15} -> {ipaddr}")
ne.cleanup("commit")
finally:
ne.disconnect()
Es sei angemerkt, dass heutzutage alle diese Anpassungen nicht mehr alle händisch vorgenommen werden müssen, sondern in den meisten Python-IDEs nur einen Klick weit entfernt sind.
So ist der Code auf jeden Fall deutlich lesbarer. Doch es ist noch immer kein "guter" Python Code! Er ist weiterhin nicht pythonisch.
Nur weil Code PEP8-konform ist, ist er noch lange nicht pythonisch. In der Python-Community bezeichnet der Begriff pythonisch (engl. pythonic) Code, der sich an den Idiomen der Sprache orientiert – lesbar, prägnant, elegant. Solcher Code lässt sich typischerweise leichter schreiben und lesen, er "passt gut" in die Gegebenheiten der Programmiersprache. Mehr zu diesem Thema: What is Pythonic? und The Zen of Python
Zurück zu unserem Beispiel:
Wir haben uns bisher nur auf die PEP8-Richtlinie fokussiert und halten diese nun ein. Dennoch eignet sich unser Code nicht gut, um als Template für andere Skripte genutzt zu werden. Zu leicht vergisst man disconnect()
oder cleanup()
, baut kontraintuitive Iterationen über die Routing-Tables ein oder bekommt unerwartete Exceptions zur Laufzeit, wenn man nicht genau weiß, wie die darunterliegende Library funktioniert und was alles bei der Nutzung zu beachten ist. Wir erinnern uns: dass unser Code als Template verwendet werden kann, war jedoch ein Wunsch aus dem Review unseres Chefs.
Wie kommen wir also nun zur Lösung?
Die Methoden und Struktur unseres Code-Beispiels erinnern stark an Java: tatsächlich ist die "jnettools
" Library aus einer Java-Bibliothek konvertiert worden, daher die Java-ähnliche Package-Struktur, die try-catch-finally-Struktur und die Methodennamen in camelCase
.
Auf unserer Suche nach Code-Qualität lautet die Frage, die wir uns jetzt stellen sollten: Wie könnte eine "ideale" pythonische Version von diesem Skript aussehen, wenn die Library für Python geschrieben worden wäre und nicht nur von Java konvertiert?
Unser Zielbild könnte zum Beispiel so aussehen:
from nettools import NetworkElement # 1
with NetworkElement('171.0.2.45') as ne: # 2
for route in ne.routing_table: # 3
print(f"{route.name:>15} -> {route.ipaddr}") # 4
Dieser Code hat folgende wesentliche Unterschiede zu unserem:
with
) für die Behandlung der Verbindung..
Die nächste Frage, die wir uns nun stellen müssen, ist: Wie erreichen wir dieses Zielbild?
Eine Lösung in diesem Fall ist das Adapter-Pattern. Einen Adapter zuschreiben, der die Nutzung der Library intuitiv und pythonisch macht, ist gar nicht so schwer. Wir starten mit neuen Klassen als Wrapper für die Library-Klassen und definieren auch eine eigene Exception in dem Modul:
import jnettool.tools.elements.NetworkElement
class NetworkElementError(Exception):
pass
class NetworkElement:
def __init__(self, ipaddr):
self.ipaddr = ipaddr
self._old_ne = jnettool.tools.elements.NetworkElement(ipaddr)
class RoutingTable:
def __init__(self, old_rt):
self._old_rt = old_rt
class Route:
def __init__(self, old_route):
self._old_route = old_route
So muss der Nutzer unseres Adapters nicht mehr mit den Klassen der ursprünglichen Library interagieren. Alle "old_
"-Attribute entsprechen Objekten der ursprünglichen Library. Keines davon gehört zu unserem public Interface
Adapter. Vom Zielergebnis haben wir fast die erste Zeile geschafft:
from nettools import NetworkElement
ne = NetworkElement('171.0.2.45')
Nun zum Context-Manager: Für eine angenehmere Behandlung von Ressourcen bietet Python die Nutzung von Context-Managern mit dem "with
" Keyword an. Damit eine Klasse als Context-Manager benutzt werden kann, muss sie zwei Methoden definieren. Die erste wird beim Aufmachen des Kontextes aufgerufen (in unserem Fall macht sie nichts) und die zweite beim Schließen. Bei Exceptions bekommt sie die Details (Typ, Instanz, Traceback), um eine saubere Schließung zu gewährleisten. In unserem Fall soll bei Fehlern eine Log-Zeile geschrieben und dann auf die gewrappte Klasse ein Rollback durchgeführt werden, sonst ein Commit, und in jedem Fall ein Disconnect.
Notiz: Alternativ könnte das Context-Management über einen mit @contextlib.contextmanager
dekorierten Generator erfolgen. Da hier aber der Kontext auf dem gesamten Objekt angewendet wird, ist der Einsatz von __enter__
/__exit__
weniger fehleranfällig.
Damit können wir schon das Context-Handling realisieren:
from nettools import NetworkElement
with NetworkElement('171.0.2.45') as ne:
pass # TODO: use "ne"
Den Getter
auf die Routing-Table können wir durch eine Property ersetzen:
# in class NetworkElement
@property
def routing_table(self):
try:
return RoutingTable(self._old_ne.getRoutingTable())
except jnettool.tools.elements.MissingVar:
raise NetworkElementError("No routing table found")
Diese Syntax erlaubt es, die Routing-Table mit ".routing_table
" zu holen, als wäre es ein Attribut. Wird eine Exception
beim Aufruf geworfen, so wird der Context-Manager darauf reagieren und regelrecht das Logging und Rollback vor dem Disconnect durchführen.
Soweit haben wir die Möglichkeit geschaffen, die erste Hälfte unseres pythonischen Code zu realisieren:
from nettools import NetworkElement
with NetworkElement('171.0.2.45') as ne:
ne.routing_table # TODO: iterate over this
Objekte können in Python "iterable" gemacht werden (sprich, wie eine Liste behandelt), in dem sie zwei Methoden definieren: Eine, um die Länge zu bekommen, und eine, um Elemente nach Index zu holen. In diesen zwei Methoden können wir die ursprünglichen Library-Methoden wrappen:
# in class RoutingTable
def __len__(self):
return self._old_rt.getSize()
def __getitem__(self, index):
if index >= len(self):
raise IndexError
return Route(self._old_rt.getRouteByIndex(index))
Und schon ist es möglich, mit "for ... in ...
" über das Objekt zu iterieren:
from nettools import NetworkElement
with NetworkElement('171.0.2.45') as ne:
for route in ne.routing_table:
pass # TODO: pretty print entries
Schließlich definieren wir noch Properties für die "Route
"-Attribute:
# in class Route
@property
def name(self):
return self._old_route.getName()
@property
def ipaddr(self):
return self._old_route.getIPAddr()
Und unser pythonischer Code ist vollständig funktionsfähig:
from nettools import NetworkElement
with NetworkElement('171.0.2.45') as ne:
for route in ne.routing_table:
print(f"{route.name:>15} -> {route.ipaddr}")
Binnen weniger Zeilen Code haben wir eine zwar gültige, aber nicht python-freundliche Library so adaptiert, dass ihre Nutzung jetzt einfach und konform zu den Python-Prinzipien gemacht wurde.
Durch die Kombination aus Style-Korrekturen und idiomatischer API-Umgestaltung haben wir ein kleines Skript deutlich verbessert – nicht nur in Bezug auf Lesbarkeit, sondern auch auf Wartbarkeit und Wiederverwendbarkeit.
Die ganze ursprüngliche Komplexität, die der Java-Konvertierung verschuldet war, ist nun vom Endnutzer weg abstrahiert. Anstatt der ursprünglichen Library, können wir nun den Adapter mit anderen Entwicklern teilen und nutzen. Die resultierenden vier Code-Zeilen entsprechen wirklich einem Template oder Beispiel, das für andere Skripte dieser Art wieder benutzt werden kann.
Dieses Ergebnis war nur möglich, weil wir die Code-Qualität nicht rein anhand von Style-Richtlinien betrachtet haben, sondern darüber hinaus gedacht haben. Wir haben darauf geachtet, dass wir bei der Programmierung die Idiome unserer Programmiersprache beachten.
Was zeigt uns dieses Beispiel?
Code-Qualität beginnt beim Stil, aber sie endet dort nicht. Richtig gute Code-Qualität entsteht, wenn wir die Stärken der Sprache ausschöpfen – mit sauberem Design, sinnvollen Abstraktionen und einem klaren Fokus auf den Entwickler als Leser.