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:

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