Python beherrschen: 7 Strategien zum Schreiben von klarem, organisiertem und effizientem Code
Optimieren Sie Ihren Python-Workflow: Bewährte Techniken zur Erstellung produktionsbereiten Codes
Haben Sie Ihren Python-Code schon einmal mit dem Code erfahrener Entwickler verglichen und dabei einen deutlichen Unterschied gespürt? Obwohl Sie Python aus Online-Ressourcen erlernen, besteht oft eine Lücke zwischen Code für Anfänger und Experten. Das liegt daran, dass erfahrene Entwickler sich an die von der Community festgelegten Best Practices halten. Diese Praktiken werden in Online-Tutorials oft übersehen, sind aber für groß angelegte Anwendungen von entscheidender Bedeutung. In diesem Artikel teile ich 7 Tipps, die ich in meinem Produktionscode verwende, um den Code klarer und besser zu organisieren.
1. Geben Sie Hinweise und Anmerkungen ein
Python ist eine dynamisch typisierte Programmiersprache, bei der die Variablentypen zur Laufzeit abgeleitet werden. Während es Flexibilität ermöglicht, verringert es die Lesbarkeit und das Verständnis des Codes in einer kollaborativen Umgebung erheblich.
Python bietet Unterstützung für Typhinweise in Funktionsdeklarationen, die als Annotation der Funktionsargumenttypen und Rückgabetypen dienen. Auch wenn Python diese Typen zur Laufzeit nicht erzwingt, ist es dennoch hilfreich, da es Ihren Code für andere Personen (und Sie selbst!) leichter verständlich macht.
Ausgehend von einem einfachen Beispiel finden Sie hier eine einfache Funktionsdeklaration mit Typhinweis:
def sum(a: int, b: int) -> int:
return a + b
Obwohl die Funktion ziemlich selbsterklärend ist, sehen wir hier, dass die Funktionsparameter und Rückgabewerte als int-Typ bezeichnet werden. Der Funktionskörper kann wie hier eine einzelne Zeile oder mehrere hundert Zeilen sein. Dennoch können wir die Vorbedingungen und Rückgabetypen verstehen, indem wir uns einfach die Funktionsdeklaration ansehen.
Es ist wichtig zu wissen, dass diese Anmerkungen nur der Klarheit und Orientierung dienen; Sie erzwingen die Typen während der Ausführung nicht. Selbst wenn Sie also Werte unterschiedlichen Typs übergeben, z. B. Zeichenfolgen anstelle von Ganzzahlen, wird die Funktion weiterhin ausgeführt. Aber seien Sie vorsichtig: Wenn Sie nicht die erwarteten Typen angeben, kann es während der Laufzeit zu unerwartetem Verhalten oder Fehlern kommen. Im bereitgestellten Beispiel erwartet die Funktion sum() beispielsweise zwei ganze Zahlen als Argumente. Wenn Sie jedoch versuchen, eine Zeichenfolge und eine Ganzzahl hinzuzufügen, löst Python einen Laufzeitfehler aus. Warum? Weil es nicht weiß, wie man einen String und eine ganze Zahl addiert! Es ist, als würde man versuchen, Äpfel und Orangen hinzuzufügen – es macht einfach keinen Sinn. Wenn jedoch beide Argumente Zeichenfolgen sind, werden sie problemlos verkettet.
Hier ist die verdeutlichte Version mit Testfällen:
print(sum(2,5)) # 7
# print(sum('hello', 2)) # TypeError: can only concatenate str (not "int") to str
# print(sum(3,'world')) # TypeError: unsupported operand type(s) for +: 'int' and 'str'
print(sum('hello', 'world')) # helloworld
Typisierungsbibliothek für erweiterte Typhinweise
Für erweiterte Anmerkungen enthält Python die Typisierungsstandardbibliothek. Sehen wir uns seine Verwendung in einem interessanteren Ansatz an.
from typing import Union, Tuple, List
import numpy as np
def sum(variable: Union[np.ndarray, List]) -> float:
total = 0
# function body to calculate the sum of values in iterable
return total
Hier ändern wir dieselbe Summationsfunktion, die jetzt ein Numpy-Array oder eine iterierbare Liste akzeptiert. Es berechnet ihre Summe und gibt sie als Gleitkommawert zurück. Wir verwenden die Union-Annotation aus der Typisierungsbibliothek, um die möglichen Typen anzugeben, die der Variablenparameter akzeptieren kann.
Lassen Sie uns die Funktionsdeklaration weiter ändern, um zu zeigen, dass die Listenmitglieder auch vom Typ float sein sollten.
def sum(variable: Union[np.ndarray, List[float]]) -> float:
total = 0
# function body to calculate the sum of values in iterable
return total
Dies sind nur einige Beispiele für Anfänger, die das Verständnis von Typhinweisen in Python erleichtern sollen. Wenn Projekte wachsen und Codebasen modularer werden, verbessern Typanmerkungen die Lesbarkeit und Wartbarkeit erheblich. Die Typisierungsbibliothek bietet zahlreiche Funktionen, darunter optionale, verschiedene Iterables, Generics und Unterstützung für benutzerdefinierte Typen, die es Entwicklern ermöglichen, komplexe Datenstrukturen und Beziehungen präzise und klar auszudrücken.
2. Defensive Funktionen schreiben und Eingabevalidierung
Auch wenn Typhinweise hilfreich erscheinen, sind sie dennoch fehleranfällig, da die Anmerkungen nicht erzwungen werden. Dies ist lediglich eine zusätzliche Dokumentation für die Entwickler, die Funktion wird jedoch weiterhin ausgeführt, wenn andere Argumenttypen verwendet werden. Daher besteht die Notwendigkeit, die Voraussetzungen für eine Funktion und einen Code auf defensive Weise durchzusetzen. Daher überprüfen wir diese Typen manuell und lösen bei Verstößen gegen die Bedingungen entsprechende Fehler aus.
Die folgende Funktion zeigt, wie Zinsen anhand der Eingabeparameter berechnet werden.
def calculate_interest(principal, rate, years):
return principal * rate * years
Es handelt sich um einen einfachen Vorgang. Funktioniert diese Funktion jedoch für jede mögliche Lösung? Nein, nicht für die Randfälle, in denen die ungültigen Werte als Eingabe übergeben werden. Wir müssen sicherstellen, dass die Eingabewerte innerhalb eines gültigen Bereichs liegen, damit die Funktion korrekt ausgeführt wird. Im Wesentlichen müssen einige Voraussetzungen erfüllt sein, damit die Funktionsimplementierung korrekt ist.
Wir machen das wie folgt:
from typing import Union
def calculate_interest(
principal: Union[int, float],
rate: float,
years: int
) -> Union[int, float]:
if not isinstance(principal, (int, float)):
raise TypeError("Principal must be an integer or float")
if not isinstance(rate, float):
raise TypeError("Rate must be a float")
if not isinstance(years, int):
raise TypeError("Years must be an integer")
if principal <= 0:
raise ValueError("Principal must be positive")
if rate <= 0:
raise ValueError("Rate must be positive")
if years <= 0:
raise ValueError("Years must be positive")
interest = principal * rate * years
return interest
Beachten Sie, dass wir bedingte Anweisungen zur Eingabevalidierung verwenden. Python verfügt auch über Assertion-Anweisungen, die manchmal für diesen Zweck verwendet werden. Allerdings sind Assertions zur Eingabevalidierung keine bewährte Methode, da sie leicht deaktiviert werden können und zu unerwartetem Verhalten in der Produktion führen. Die Verwendung expliziter Python-Bedingungsausdrücke ist für die Eingabevalidierung und die Durchsetzung von Vorbedingungen, Nachbedingungen und Codeinvarianten vorzuziehen.
3. Lazy Loading mit Generatoren und Ertragsaussagen
Stellen Sie sich ein Szenario vor, in dem Ihnen ein großer Datensatz an Dokumenten zur Verfügung steht. Sie müssen die Dokumente verarbeiten und für jedes Dokument bestimmte Vorgänge ausführen. Aufgrund der Größe ist es jedoch nicht möglich, alle Dokumente gleichzeitig in den Speicher zu laden und vorzuverarbeiten.
Eine mögliche Lösung besteht darin, ein Dokument nur bei Bedarf in den Speicher zu laden und jeweils nur ein einzelnes Dokument zu verarbeiten, was auch als verzögertes Laden bezeichnet wird. Obwohl wir wissen, welche Dokumente wir benötigen, laden wir eine Ressource erst dann, wenn sie benötigt wird. Es besteht keine Notwendigkeit, den Großteil der Dokumente im Speicher aufzubewahren, wenn sie nicht aktiv in unserem Code verwendet werden. Genau so gehen Generatoren und Ertragsaussagen an das Problem heran.
Generatoren ermöglichen Lazy-Loading, was die Speichereffizienz der Python-Codeausführung verbessert. Werte werden bei Bedarf spontan generiert, wodurch der Speicherbedarf reduziert und die Ausführungsgeschwindigkeit erhöht wird.
import os
def load_documents(directory):
for document_path in os.listdir(directory):
with open(document_path) as _file:
yield _file
def preprocess_document(document):
filtered_document = None
# preprocessing code for the document stored in filtered_document
return filtered_document
directory = "docs/"
for doc in load_documents(directory):
preprocess_document(doc)
In der obigen Funktion verwendet die Funktion „load_documents“ das Schlüsselwort yield. Die Methode gibt ein Objekt vom Typ
4. Verhindern von Speicherlecks mithilfe von Kontextmanagern
Für jede Sprache ist die effiziente Nutzung von Ressourcen von größter Bedeutung. Wie oben erläutert, laden wir durch den Einsatz von Generatoren nur bei Bedarf etwas in den Speicher. Es ist jedoch ebenso wichtig, eine Ressource zu schließen, wenn sie von unserem Programm nicht mehr benötigt wird. Wir müssen Speicherlecks verhindern und einen ordnungsgemäßen Ressourcenabbau durchführen, um Speicher zu sparen.
Kontextmanager vereinfachen den allgemeinen Anwendungsfall der Einrichtung und Demontage von Ressourcen. Es ist wichtig, Ressourcen freizugeben, wenn sie nicht mehr benötigt werden, auch im Falle von Ausnahmen und Fehlern. Kontextmanager reduzieren das Risiko von Speicherlecks durch automatische Bereinigung und sorgen gleichzeitig dafür, dass der Code präzise und lesbar bleibt.
Ressourcen können mehrere Varianten haben, z. B. Datenbankverbindungen, Sperren, Threads, Netzwerkverbindungen, Speicherzugriff und Dateihandles. Konzentrieren wir uns auf den einfachsten Fall: Dateihandles. Die Herausforderung besteht darin, sicherzustellen, dass jede geöffnete Datei genau einmal geschlossen wird. Das Versäumnis, eine Datei zu schließen, kann zu Speicherverlusten führen, während der Versuch, ein Dateihandle zweimal zu schließen, zu Laufzeitfehlern führt. Um dieses Problem zu beheben, sollten Dateihandles in einen try-exclusive-finally-Block eingeschlossen werden. Dadurch wird sichergestellt, dass die Datei ordnungsgemäß geschlossen wird, unabhängig davon, ob während der Ausführung ein Fehler auftritt. So könnte die Implementierung aussehen:
file_path = "example.txt"
file = None
try:
file = open(file_path, 'r')
contents = file.read()
print("File contents:", contents)
finally:
if file is not None:
file.close()
Python bietet jedoch eine elegantere Lösung mithilfe von Kontextmanagern, die die Ressourcenverwaltung automatisch übernehmen. So können wir den obigen Code mithilfe des Dateikontextmanagers vereinfachen:
file_path = "example.txt"
with open(file_path, 'r') as file:
contents = file.read()
print("File contents:", contents)
In dieser Version müssen wir die Datei nicht explizit schließen. Der Kontextmanager kümmert sich darum und verhindert mögliche Speicherlecks.
Während Python integrierte Kontextmanager für die Dateiverwaltung bietet, können wir auch eigene für benutzerdefinierte Klassen und Funktionen erstellen. Für die klassenbasierte Implementierung definieren wir die Dunder-Methoden __enter__ und __exit__. Hier ist ein einfaches Beispiel:
class CustomContextManger:
def __enter__(self):
# Code to create instance of resource
return self
def __exit__(self, exc_type, exc_value, traceback):
# Teardown code to close resource
return None
Jetzt können wir diesen benutzerdefinierten Kontextmanager innerhalb von ‘with’-Blöcken verwenden:
with CustomContextManger() as _cm:
print("Custom Context Manager Resource can be accessed here")
Dieser Ansatz behält die saubere und prägnante Syntax von Kontextmanagern bei und ermöglicht es uns gleichzeitig, Ressourcen nach Bedarf zu verwalten.
5. Trennung der Belange von Dekorateuren
Wir sehen oft mehrere Funktionen mit derselben Logik, die explizit implementiert sind. Dies ist ein weit verbreiteter Codegeruch, und eine übermäßige Codeduplizierung macht die Wartung des Codes schwierig und unskalierbar. Dekoratoren werden verwendet, um ähnliche Funktionen an einem einzigen Ort zu kapseln. Wenn eine ähnliche Funktionalität von mehreren anderen Funktionen verwendet werden soll, können wir die Codeduplizierung reduzieren, indem wir gemeinsame Funktionen innerhalb eines Dekorators implementieren. Es folgt der aspektorientierten Programmierung (AOP) und dem Prinzip der Einzelverantwortung.
Dekoratoren werden häufig in Python-Webframeworks wie Django, Flask und FastAPI verwendet. Lassen Sie mich die Wirksamkeit von Dekoratoren erklären, indem ich sie als Middleware in Python für die Protokollierung verwende. In einer Produktionsumgebung müssen wir wissen, wie lange es dauert, eine Anfrage zu bearbeiten. Es handelt sich um einen häufigen Anwendungsfall, der von allen Endpunkten gemeinsam genutzt wird. Lassen Sie uns also eine einfache Decorator-basierte Middleware implementieren, die die Zeit protokolliert, die für die Bearbeitung einer Anfrage benötigt wird.
Die folgende Dummy-Funktion wird zur Bearbeitung einer Benutzeranfrage verwendet.
def service_request():
# Function body representing complex computation
return True
Jetzt müssen wir die Zeit protokollieren, die die Ausführung dieser Funktion benötigt. Eine Möglichkeit besteht darin, die Protokollierung innerhalb dieser Funktion wie folgt hinzuzufügen:
import time
def service_request():
start_time = time.time()
# Function body representing complex computation
print(f"Time Taken: {time.time() - start_time}s")
return True
Dieser Ansatz funktioniert zwar, führt jedoch zur Codeduplizierung. Wenn wir weitere Routen hinzufügen, müssten wir den Protokollierungscode in jeder Funktion wiederholen. Dies erhöht die Codeduplizierung, da diese gemeinsame Protokollierungsfunktionalität zu jeder Implementierung hinzugefügt werden muss. Wir beseitigen dies durch den Einsatz von Dekorateuren.
Die Protokollierungs-Middleware wird wie folgt implementiert:
def request_logger(func):
def wrapper(*args, **kwargs):
start_time = time.time()
res = func()
print(f"Time Taken: {time.time() - start_time}s")
return res
return wrapper
In dieser Implementierung ist die äußere Funktion der Dekorator, der eine Funktion als Eingabe akzeptiert. Die innere Funktion implementiert die Protokollierungsfunktionalität und die Eingabefunktion wird innerhalb des Wrappers aufgerufen.
Jetzt dekorieren wir einfach die ursprüngliche Funktion service_request mit unserem request_logger-Dekorator:
@request_logger
def service_request():
# Function body representing complex computation
return True
Durch die Verwendung des @-Symbols wird die Funktion service_request an den Dekorator request_logger übergeben. Es protokolliert die benötigte Zeit und ruft die ursprüngliche Funktion auf, ohne ihren Code zu ändern. Diese Trennung der Belange ermöglicht es uns, die Protokollierung auf ähnliche Weise wie folgt einfach zu anderen Dienstmethoden hinzuzufügen:
@request_logger
def service_request():
# Function body representing complex computation
return True
@request_logger
def service_another_request():
# Function body
return True
6. Match-Case-Anweisungen
Match-Anweisungen wurden in Python3.10 eingeführt, es handelt sich also um eine relativ neue Ergänzung der Python-Syntax. Es ermöglicht einen einfacheren und besser lesbaren Mustervergleich und verhindert übermäßige Boilerplates und Verzweigungen in den typischen if-elif-else-Anweisungen.
Für den Mustervergleich sind Match-Case-Anweisungen die natürlichere Schreibweise, da sie nicht unbedingt boolesche Werte zurückgeben müssen wie bei bedingten Anweisungen. Das folgende Beispiel aus der Python-Dokumentation zeigt, wie Match-Case-Anweisungen Flexibilität gegenüber bedingten Anweisungen bieten.
def make_point_3d(pt):
match pt:
case (x, y):
return Point3d(x, y, 0)
case (x, y, z):
return Point3d(x, y, z)
case Point2d(x, y):
return Point3d(x, y, 0)
case Point3d(_, _, _):
return pt
case _:
raise TypeError("not a point we support")
Laut Dokumentation würde die Implementierung dieser Funktion ohne Mustervergleich mehrere isinstance()-Prüfungen, einen oder zwei len()-Aufrufe und einen komplizierteren Kontrollfluss erfordern. Unter der Haube lassen sich das Match-Beispiel und die traditionelle Python-Version in ähnlichen Code übersetzen. Wenn Sie jedoch mit dem Mustervergleich vertraut sind, wird wahrscheinlich der Match-Case-Ansatz bevorzugt, da er eine klarere und natürlichere Syntax bietet.
Insgesamt bieten Match-Case-Anweisungen eine verbesserte Alternative für den Mustervergleich, der in neueren Codebasen wahrscheinlich häufiger vorkommen wird.
7. Externe Konfigurationsdateien
In der Produktion basiert der Großteil unseres Codes auf externen Konfigurationsparametern wie API-Schlüsseln, Passwörtern und verschiedenen Einstellungen. Aus Skalierbarkeits- und Sicherheitsgründen wird es als schlechte Praxis angesehen, diese Werte direkt in den Code zu kodieren. Stattdessen ist es wichtig, die Konfigurationen vom Code selbst zu trennen. Dies erreichen wir üblicherweise mithilfe von Konfigurationsdateien wie JSON oder YAML zum Speichern dieser Parameter. So stellen wir sicher, dass sie für den Code leicht zugänglich sind, ohne direkt darin eingebettet zu sein.
Ein alltäglicher Anwendungsfall sind Datenbankverbindungen, die über mehrere Verbindungsparameter verfügen. Wir können diese Parameter in einer separaten YAML-Datei speichern.
# config.yaml
database:
host: localhost
port: 5432
username: myuser
password: mypassword
dbname: mydatabase
Um diese Konfiguration zu handhaben, definieren wir eine Klasse namens DatabaseConfig:
class DatabaseConfig:
def __init__(self, host, port, username, password, dbname):
self.host = host
self.port = port
self.username = username
self.password = password
self.dbname = dbname
@classmethod
def from_dict(cls, config_dict):
return cls(**config_dict)
Hier dient die Klassenmethode from_dict als Builder-Methode für die Klasse DatabaseConfig, die es uns ermöglicht, eine Datenbankkonfigurationsinstanz aus einem Wörterbuch zu erstellen.
In unserem Hauptcode können wir Parameterhydratation und die Builder-Methode verwenden, um eine Datenbankkonfiguration zu erstellen. Durch Lesen der externen YAML-Datei extrahieren wir das Datenbankwörterbuch und verwenden es, um die Konfigurationsklasse zu instanziieren:
import yaml
def load_config(filename):
with open(filename, "r") as file:
return yaml.safe_load(file)
config = load_config("config.yaml")
db_config = DatabaseConfig.from_dict(config["database"])
Durch diesen Ansatz entfällt die Notwendigkeit, Datenbankkonfigurationsparameter direkt im Code fest zu codieren. Es bietet auch eine Verbesserung gegenüber der Verwendung von Argumentparsern, da wir nicht mehr bei jeder Codeausführung mehrere Parameter übergeben müssen. Darüber hinaus können wir durch den Zugriff auf den Konfigurationsdateipfad über einen Argumentparser sicherstellen, dass der Code flexibel bleibt und nicht auf fest codierte Pfade angewiesen ist. Diese Methode erleichtert die Verwaltung von Konfigurationsparametern, die jederzeit geändert werden können, ohne dass Änderungen an der Codebasis erforderlich sind.
Schlussbemerkungen
In diesem Artikel haben wir einige der Best Practices besprochen, die in der Branche für produktionsbereiten Code verwendet werden. Hierbei handelt es sich um gängige Branchenpraktiken, die zahlreiche Probleme lindern, mit denen man in realen Situationen konfrontiert werden kann.
Dennoch ist es erwähnenswert, dass trotz all dieser Best Practices Dokumentation, Docstrings und testgetriebene Entwicklung bei weitem die wichtigsten Praktiken sind. Es ist wichtig, darüber nachzudenken, was eine Funktion tun soll, und dann alle Designentscheidungen und Implementierungen für die Zukunft zu dokumentieren, da sich die Leute, die an einer Codebasis arbeiten, im Laufe der Zeit ändern. Wenn Sie Erkenntnisse oder Praktiken haben, auf die Sie schwören, teilen Sie uns dies bitte im Kommentarbereich unten mit.