Vorlesungsskript 22
Guter Programmierstil für Effizienz und Lesbarkeit
Wenn ein Programm tut, was es soll, heißt das noch nicht, dass es frei von Fehlern ist. Stilfehler können seine Effizienz und seine Lesbarkeit beeinträchtigen. Ein ineffizientes Programm verrichtet überflüssige Arbeit und nimmt damit unnötig Zeit, Rechenkapazität und Elektrizität in Anspruch. Ein schlecht lesbares Programm kann die nächste Programmiererin zur Verzweiflung treiben, wenn sie eine neue Funktionalität hinzufügen oder einen Fehler beseitigen soll. Das gilt auch für den ursprünglichen Programmierer – man vergisst schnell, was man selbst geschrieben hat.
In dieser Vorlesung schauen wir uns einige Typen von Stifehlern an und wie man sie vermeidet.
Effizienz: Unnötiges Kopieren von Datenstrukturen
Das wiederholte Kopieren von Datenstrukturen sollte, wenn möglich, vermieden werden, da jedes Mal alle Elemente kopiert werden müssen. Betrachten Sie z.B. folgende Funktion:
def read_file_into_list(path):
"""Reads the file at the given path and returns a list with its lines."""
f = open(path, 'r')
lines = []
for line in f:
line = line.rstrip()
lines = lines + [line]
f.close()
return lines
Das Problem liegt in der Zeile lines = lines +
[line]
: Der +
-Operator ist
nichtdestruktiv, er erstellt also jedes Mal eine neue Liste und kopiert die
Elemente seiner Operanden hinein, also die Elemente von lines
(der
alten Liste) und [line]
(einer Liste mit der neuen
Zeile). Dann wird die so erzeugte neue Liste in der Variablen lines
gespeichert und ist beim nächsten Mal die alte Liste.
Beim ersten Mal muss nur ein Element (die erste Zeile) kopiert werden. Beim
zweiten Mal müssen die erste Zeile (aus der alten Liste) und die zweite Zeile
kopiert werden. Beim dritten Mal müssen die erste und zweite Zeile (aus der
alten Liste) und die dritte Zeile kopiert werden. Und so weiter. Um eine Datei
mit 10 000 Zeilen zu lesen, muss diese Funktion 50 005 000
Elemente kopieren. Das ist eine große Verschwendung und macht das Programm
spürbar langsamer.
Die Lösung liegt darin, für das Aufbauen von Datenstrukturen nicht nichtdestruktive,
sondern destruktive Operationen zu verwenden – hier also zum Beispiel nicht den
+
-Operator, sondern die append
-Methode.
Die verbesserte Funktion sieht so aus:
def read_file_into_list(path):
"""Reads the file at the given path and returns a list with its lines."""
f = open(path, 'r')
lines = []
for line in f:
line = line.rstrip()
lines.append(line)
f.close()
return lines
append
erstellt nicht jedes Mal eine neue Liste, sondern
fügt der bestehenden Liste ein Element hinzu. Bei einer Datei mit 10 000 Zeilen
führt sie jetzt nicht mehr 50 005 000, sondern nur 10 000 Listenoperationen
aus – Größenordnungen weniger.
Effizienz: dasselbe mehrfach tun
Eine andere Quelle der Ineffizienz ist es, Operationen häufiger auszuführen,
als sie nötig sind. Ein Beispiel: Aus einer Liste sollen alle Vorkommnisse der
Zahl 2
entfernt werden. Hier ist unser erster
Versuch:
l = [1, 2, 1, 1, 2, 1, 2, 2, 1]
while 2 in l:
l.remove(2)
Das funktioniert – in unserem Beispiel wird der Schleifenkörper insgesamt
viermal ausgesführt, so oft, bis die letzte 2
entfernt ist. Auf dem Weg dahin durchlaufen jedoch sowohl der Check 2 in l
als auch die remove
-Methode immer und immer wieder die Liste vom
Anfang bis zur ersten 2
bis zum Ende. In unserem Beispiel wird
insgesamt 22-mal geprüft, ob ein Element 2
ist. Das ist unnötig oft.
Eine Lösung besteht darin, eine neue, gefilterte Liste zu erstellen, und alle Elemente,
die nicht 2
sind, in diese neue Liste zu kopieren:
l = [1, 2, 1, 1, 2, 1, 2, 2, 1]
l_filtered = []
for element in l:
if element != 2:
l_filtered.append(element)
Dieser Code durchläuft l
nur einmal, vom Anfang
bis zum Ende, und muss nur 9 Vergleiche vornehmen. Dazu kommen die append
-Aufrufe. Ob das in diesem Beispiel schneller ist,
sei dahingestellt. Bei längeren Listen oder wenn die Prüfoperation
komplizierter ist, lohnt sich die Verbesserung jedoch mit Sicherheit. Hier ist
zum Beispiel ein ineffizientes Stück Code zum Entfernen von Stopwörtern aus
einer Liste von Wörtern:
for stopword in stopwords:
while stopword in words:
words.remove(stopword)
Und hier ist eine verbesserte Version:
words_filtered = []
for word in words:
if word not in stopwords:
words_filtered.append(word)
Wir werden in der nächsten Vorlesung List Comprehensions kennenlernen, mit denen das Obige kürzer geschrieben werden kann.
Lesbarkeit: Variablennamen
Eine Python-Variable hat keinen Typ – sie kann im Prinzip alles enthalten, eine Zahl, einen String, eine Liste, ein Dateiobjekt – ganz egal. Um für Menschen, die den Code lesen, den Zweck einer Variable deutlich zu machen, ist es daher besonders wichtig, ihr einen aufschlussreichen Namen zu geben.
Das folgende Programm ist ein schlechtes Beispiel. Es liest einen Text aus einer Datei, zerlegt ihn in Wörter, liest eine Liste von Stopwörtern aus einer zweiten Datei, filtert die Stopwörter aus dem Text heraus und gibt die verbleibenden Wörter aus, eins pro Zeile:
import re
f = open('moby-dick.txt')
list = []
for word in f:
line = re.findall('\w+', word)
list.extend(line)
f.close()
f = open('stopwords.txt')
x = []
for line in f:
x.append(line.rstrip())
f.close()
for words in list:
if words not in x:
print(words)
Es ist leider schwer nachzuvollziehen, wie dieser Code funktioniert, weil mehrere Variablen unklar benannt sind. Nämlich:
list
– klar, eine Liste. Aber was enthält diese Liste? Zeilen? Wörter? Stopwörter? Außerdem istlist
eine eingebaute Python-Funktion, die wir dann nicht mehr verwenden können, wenn wir eine Variable so nennen (unser Editor weist uns mit einer speziellen Farbe darauf hin). Da diese Variable eine Liste mit Wörtern enthält, istwords
ein besserer Name. Oder, noch deutlicher:all_words
– um deutlich zu machen, dass in dieser Liste die Wörter aus allen Zeilen gesammelt werden.line
– in dieser Variable werden die aus einer Zeile extrahierten Wörter als Liste zwischengespeichert. Der Nameline
suggeriert jedoch einen String mit der ganzen Zeile. Ein besserer Name:line_words
.word
– so wurde hier die Iterationsvariable für das Iterieren über die erste Datei genannt. Dabei wissen wir, dassfor
-Schleifen zeilenweise über Dateien iterieren, und eine Zeile ist ein String, der mehrere Wörter, Leerzeichen, Satzzeichen usw. enthalten kann. Die Iterationsvariableword
zu nennen, ist also grob irreführend. Besser:line
x
– ein nichtssagender Name. Besser:stopwords
.words
– so wurde hier die Iterationsvariable genannt, mit der am Ende über die Liste der Wörter iteriert wird. Die Iterationsvariable enthält aber pro Schleifendurchlauf immer nur ein Element der Liste, also ein Wort. Daher sollte ihr Name ein Singular sein:word
.
Die verbesserte Version des Programms sieht so aus:
import re
f = open('moby-dick.txt')
all_words = []
for line in f:
line_words = re.findall('\w+', line)
all_words.extend(line_words)
f.close()
f = open('stopwords.txt')
stopwords = []
for line in f:
stopwords.append(line.rstrip())
f.close()
for word in all_words:
if word not in stopwords:
print(word)
Die Moral: Python ist es egal, wie Sie Variablen nennen, Sie haben da freie Hand. Diese Macht sollten Sie unbedingt nutzen, um die Variablen so aufschlussreich wie möglich zu benennen, um für sich selbst und andere klar zu machen, was in welcher Variable gespeichert wird. Übernehmen Sie nicht einfach die Variablennamen aus den Beispielen, sondern passen Sie die Namen Ihrem Programm entsprechend an.
Lesbarkeit: Formatierung
Python ist es auch im Prinzip egal, mit wie vielen Leerzeichen (oder Tabs) Sie
Codeblöcke einrücken, solange Sie es konsistent tun. Auch ist es Python egal, wie viele
Leerzeilen Sie zwischen Anweisungen lassen, wie lang Ihre Zeilen sind etc. Im Interesse anderer
Programmierer/innen, die Ihren Code womöglich eines Tages lesen und bearbeiten müssen, empfiehlt
es sich aber, sich an die einschlägigen Konventionen zu halten. Diese sind für Python in einem Dokument
namens PEP 8 -- Style Guide for Python Code
niedergelegt. Das Kommandozeilen-Programm pep8
kann verwendet werden, um
zu prüfen, ob sich ein Programm an diese Konventionen hält. Die gefundenen Abweichungen werden mit
Zeilennummern ausgegeben, z.B.:
$ pep8 sentences.py
sentences.py:35:77: E261 at least two spaces before inline comment
sentences.py:35:80: E501 line too long (90 > 79 characters)
sentences.py:40:80: E501 line too long (85 > 79 characters)
sentences.py:42:80: E501 line too long (95 > 79 characters)
sentences.py:72:17: E128 continuation line under-indented for visual indent
sentences.py:87:80: E501 line too long (80 > 79 characters)
sentences.py:88:17: E128 continuation line under-indented for visual indent
sentences.py:101:80: E501 line too long (85 > 79 characters)
sentences.py:108:21: E128 continuation line under-indented for visual indent
sentences.py:135:1: W293 blank line contains whitespace
sentences.py:173:80: E501 line too long (101 > 79 characters)
Wenn man alles behoben hat, ist pep8
zufrieden und gibt
gar nichts aus:
$ pep8 sentences.py
Weiterführende Lektüre
- The Little Book of Python Anti-Patterns: Eine Zusammenstellung häufiger Fehler beim Python-Programmieren, die man vermeiden sollte.