2. Programmablauf

Wir wollen hier die grundlegenden Konzepte zu Programmabläufen vorstellen, die es in ziemlich jeder Programmiersprache gibt. Um einen logischen Programmablauf zu realisieren benötigen wir Fallunterscheidungen und Schleifen. Ferner lernen wir eigene Funktionen zu implementieren, um den Programmablauf besser zu strukturieren und oft verwendete Codezeilen auszulagern.

2.1. Logische Operatoren

Logische Operatoren werden in if-Abfragen und Schleifen verwendet um den Programmablauf zu steuern:

Operator

Bedeutung

a==b

a und b sind gleich

a < b

a ist kleiner als b

analog >

a <= b

a ist kleiner oder gleich b

analog >=

a != b

a ist ungleich b

Dies funktioniert natürlich nur, wenn die entsprechende Operation für die verwendeten Datentypen auch definiert ist.

print("1 ist kleiner 3:", 1<3)
print("'abc' ist gleich 'bcd':", 'abc'=='bcd')
print("2 ist größer oder gleich 2.0:", 2 >= 2.0)
1 ist kleiner 3: True
'abc' ist gleich 'bcd': False
2 ist größer oder gleich 2.0: True

Übungsaufgabe

Vergleiche die Strings 'abc' und 'bcd' mit den üblichen Vergleichsoperatoren. Welche Beobachtung machen wir?

Um boolsche Ausdrücke zu verknüpfen verwenden wir die üblichen Operatoren:

Operator

Bedeutung

a and b

True, wenn a und b True sind

a or b

True wenn a oder b True sind

not a

True wenn a False ist

Um Beispielsweise zu testen, ob eine Zahl \(x\) im Intervall \([1/4,3/4)\) liegt schreiben wir

import random

x = random.random()
in_interval = x >= 1/4 and x < 3/4
print("x =", x)
print("x liegt in [1/4,3/4):", in_interval)
x = 0.7830597861741738
x liegt in [1/4,3/4): False

2.2. Fallunterscheidungen (if-Abfragen)

Fallunterscheidungen können in Python wie folgt realisiert werden:

if <condition_1>:
    # if condition_1 is true
    [do something]
    ...
    [do something more]
elif <condition_2>:
    # if condition_1 is false and condition_2 is true
    [do something]
    ...
    [do something more]
else:
    # if conditions 1 and 2 are false
    [do something]
    ...
    [do something more]

Dabei sind condition_1 und condition_2 bool’sche Ausdrücke, die entweder True oder False sind. Die Einrückung des unter der if-Anweisung stehenden Codes bestimmt was bei Gültigkeit von condition_1 alles getan wird. Der Codeblock, der jeweils ausgeführt wird endet bei der letzten eingerückten Zeile. Natürlich kann die Abfrage um beliebig viele elif-Blocks erweitert werden.

x = random.random() # Erzeuge Zufallszahl zwischen 0 und 1
print("x =", x)

if x < 0.5:
    print("Ich gehöre zur Fallunterscheidung")
    print("Ich auch")
    
if x < 0.5:
    print("Ich gehöre zur Fallunterscheidung")
print("Ich nicht")
x = 0.02830246418251192
Ich gehöre zur Fallunterscheidung
Ich auch
Ich gehöre zur Fallunterscheidung
Ich nicht

In folgendem Beispiel wird eine Zufallszahl zwischen 1 und 10 erzeugt und getestet, ob es sich um eine gerade oder ungerade Zahl handelt. Dafür verwenden wir den sogenannten Modulo-Operator %, welcher den Divisionsrest berechnet. Beispiel: 8%3 ergibt 2, da \(8=2\cdot 3+\textbf{2}\). Dies wird 10 mal wiederholt.

for k in range(10):
    x = random.randint(1,11)
    if x % 2 == 0:
        print(x, "ist eine gerade Zahl")
    else:
        print(x, "ist eine ungerade Zahl")
10 ist eine gerade Zahl
2 ist eine gerade Zahl
1 ist eine ungerade Zahl
4 ist eine gerade Zahl
11 ist eine ungerade Zahl
1 ist eine ungerade Zahl
10 ist eine gerade Zahl
8 ist eine gerade Zahl
11 ist eine ungerade Zahl
2 ist eine gerade Zahl

Übungsaufgabe

Erzeuge 2 Zufallszahlen \(p, q\) im Intervall \([-2,2]\). Berechne alle Nullstellen des Polynoms \( f(x) = x^2 + p\,x+q \) und geben Sie die Lösung auf der Konsole aus.

2.3. Schleifen

2.3.1. for-Schleifen

Betrachte folgendes Beispiel:

a = [1,4,7]
value = 0

value += a[0]
value += a[1]
value += a[2]

print("Die Summe der Zahlen", a, "ist", value)
Die Summe der Zahlen [1, 4, 7] ist 12

Wir wollen hier offensichtlich alle Elemente einer Liste durchgehen. Dies lässt sich eleganter in einer for-Schleife realisieren. Die allgemeine Syntax ist wie folgt:

for <elem> in <iterable_object>:
    [do something]
    ...
    [do something more]

Dabei ist <iterable_object> ein beliebiges Objekt, welches einen Iterator bereitstellt (mehre dazu in Abschnitt Iteratoren). Dies kann beispielsweise eine Liste, ein Tupel oder Numpy-Array (siehe Abschnitt Lineare Algebra mit NumPy) sein. Auch über Strings kann man iterieren. Das obere Beispiel können wir dementsprechend vereinfachen:

value = 0
for elem in a:
    value += elem
print("Die Summe der Zahlen", a, "ist", value)
Die Summe der Zahlen [1, 4, 7] ist 12

Eine häufig verwendete iterierbare Klasse ist range. Ein Range-Objekt repräsentiert lediglich ein Intervall für den Iterationsindex.

# 5 Schleifendurchläufe
for i in range(5):
    print("Schleifendurchlauf", i)
Schleifendurchlauf 0
Schleifendurchlauf 1
Schleifendurchlauf 2
Schleifendurchlauf 3
Schleifendurchlauf 4
# 3 Schleifendurchläufe, beginnend mit Iterationsindex 2
for i in range(2,5):
    print("Schleifendurchlauf", i)
Schleifendurchlauf 2
Schleifendurchlauf 3
Schleifendurchlauf 4

Übungsaufgabe

Berechne \(10!\). Verwende das range-Objekt.

Merke: Falls vor dem ersten Schleifendurchlauf bereits die Anzahl der Schleifendurchläufe feststeht, sollte eine for-Schleife verwendet werden. Andernfalls, eine while-Schleife, welche wir im folgenden Abschnitt diskutieren.

Scheifen können mit den Befehlen continue und break innerhalb des Schleifenblocks weiter beeinflusst werden. Mit break lässt sich ein weiteres Abbruchkriterium einbauen. Im Beispiel wird in einer Liste die Zahl 5 gesucht und beim ersten Auftauchen dieser Zahl wird die Schleife unterbrochen.

L = [1,4,5,7,9]
for x in L:
    if x == 5:
        print("Liste enthält", 5)
        break;
    else:
        print(x, "ist nicht 5")
1 ist nicht 5
4 ist nicht 5
Liste enthält 5

Beachte, dass die die Schleife nach dem dritten Durchlauf nicht weiter ausgeführt wird.

Im Gegensatz dazu kann man mit continue den aktuellen Schleifendurchlauf unterbrechen und direkt zum nächsten Durchlauf springen. Im folgenden Beispiel wird die Summe aller geraden Zahlen in der Liste berechnet:

val = 0
for x in range(1,11):
    if x%2 == 1: # falls x ungerade ist 
        continue
        
    val += x
print("2+4+...+10 =", val)
2+4+...+10 = 30

2.3.2. while-Schleifen

Bei for-Schleifen haben wir gesehen, dass das Abbruchkriterium der Schleife schon bereits vor dem ersten Schleifendurchlauf fest stand. Ändert sich das Abbruchkriterium während der Schleifendurchläufe, verwendet man stattdessen while-Schleifen. Die allgemeine Syntax lautet:

while <condition>:
    [do something]
    ...
    [do something more]

Dabei ist <condition> wieder ein bool’scher Ausdruck. Der eingerückte Schleifenblock wird so lange ausgeführt, solange <condition> den Wert True ergibt. Um nicht in einer Endlosschleife zu landen muss vom Programmierer sichergestellt werden, dass <condition> auch eine Chance hat nach endlich vielen Durchläufen den Wert False zu erreichen.

In folgendem Beispiel werden so lange Zufallszahlen erzeugt, bis eine durch 5 teilbare Zahl erzeugt wurde:

number = 1 # Initialisiere mit 1 um die Schleifenbedingung zu erfüllen

while not number%5 == 0:
    number = random.randint(1,100)
    print("Zufallszahl:", number)
    
print(number, "ist unsere durch 5 teilbare Zufallszahl")
Zufallszahl: 21
Zufallszahl: 38
Zufallszahl: 3
Zufallszahl: 81
Zufallszahl: 32
Zufallszahl: 33
Zufallszahl: 84
Zufallszahl: 98
Zufallszahl: 39
Zufallszahl: 64
Zufallszahl: 56
Zufallszahl: 20
20 ist unsere durch 5 teilbare Zufallszahl

Auch bei while-Schleifen wirken die Befehle continue und break.

Übungsaufgabe

Berechne die Fibonacci-Zahlen bis 1000. Beginnend mit \(a_0=a_1=1\) ergeben sich die Fibonacci-Zahlen aus der rekursiv definierten Folge \(a_n = a_{n-1}+a_{n-2}\).

2.4. Funktionen

2.4.1. Eigene Funktionen definieren

Oft wiederverwendete Programmbestandteile können zur besseren Lesbarkeit des Programmcodes in Funktionen ausgelagert werden. Eine Funktion muss vor deren ersten Aufruf definiert werden. Die allgemeine Syntax einer Funktionsdefinition lautet

def <function_name>(<param1>, <param2>[, ...]):
    [do something]
    ...
    [do something more]
    return <ret1>, <ret2>[, ...]

Die Parameter der Funktion <param1>, <param2> etc. werden beim Funktionsaufruf übergeben und die Rückgabewerte <ret1>, <ret2>, etc. werden als Tupel zurückgegeben. Falls die Funktion keine Werte zurückgeben soll, beispielsweise bei einer Funktion welche nur etwas auf der Konsole ausgeben soll, kann die Zeile mit return auch weggelassen werden. Beachte, dass beim Aufruf von return die Funktion verlassen wird und falls noch weiterer Programmcode folgt, so wird dieser nicht mehr ausgeführt.

Die Syntax eines Funktionsaufrufs lautet

<ret1>, <ret2>[, ...] = <function_name>(<param1>, <param2>[, ...])

So können wir beispielsweise eine Funktion implementieren, welche die Summe der Elemente eines iterierbaren Objektes berechnet:

def sum(L):
    val = 0
    for elem in L:
        val += elem
    return val

und diese im weitere Programmcode aufrufen:

L = [1,4,7]
print("Die Summe der Zahlen", L, "ist", sum(L))
L.pop()
L.append(9)
L.append(13)
print("Die Summe der Zahlen", L, "ist", sum(L))
Die Summe der Zahlen [1, 4, 7] ist 12
Die Summe der Zahlen [1, 4, 9, 13] ist 27

Beachte, dass wir unsere Funktion sum mit Parametern eines beliebigen Typs aufrufen können, für den alle in sum verwendeten Operationen und Funktionen definiert sind. Unsere Summenfunktion lässt sich so auch mit Tupeln aufrufen,

sum((1,2,3))
6

aber nicht mit Strings. String sind zwar auch iterierbar, was die Definition der for-Schleife erlaubt, aber die Operation += zwischen Integer (beachte: val ist wegen der Zeile val = 0 ein Integer) und einem String ist nicht definiert:

sum("Test")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [15], in <module>
----> 1 sum("Test")

Input In [12], in sum(L)
      2 val = 0
      3 for elem in L:
----> 4     val += elem
      5 return val

TypeError: unsupported operand type(s) for +=: 'int' and 'str'

2.4.2. Lokale und globale Variablen

Zu beachten ist noch, dass alle Variablen, welche innerhalb der Funktionen definiert wurden, lediglich lokale Variablen sind, also außerhalb der Funktion nicht mehr gesehen werden. Existiert bereits eine globale Variable mit dem gleichen Namen, so wird innerhalb der Funktion nur mit der lokalen Variable gearbeitet:

value = 1 # globale Variable namens 'value'

def get_successor(a):    
    value = a+1 # lokale Variable namens 'value'
    return value

print("Value ist", value) # Ausgabe der globalen Variable 'value'
print("Der Nachfolger von 3 ist", get_successor(3))
print("Value ist", value) # Ausgabe der globalen Variable 'value'
Value ist 1
Der Nachfolger von 3 ist 4
Value ist 1

Im letzten Beispiel wurde erst durch die Zeile value = ... die lokale Variable value angelegt. Fehlt diese Zeile, beispielsweise in einer Funktion, die lediglich den Wert von value lesen, aber nicht schreiben muss, dann wird innerhalb der Funktion die globale Variable des gleichen Namens gelesen:

value = 1

def print_global_variable():    
    print("Value is", value) # Ausgabe der globalen Variable
    
print_global_variable()
Value is 1

Möchte man die globale Variable in der Funktion dennoch schreiben, dann muss man zu Beginn der Funktion klar stellen, dass man keine lokale Variable anlegen möchte. Das geschieht mit dem Schlüsselwort global:

value = 1

def increment_value():
    global value
    value = value + 1 # Schreibt in die globale Variable 'value'
    
print("Value is", value)
increment_value()
print("Value is", value)
Value is 1
Value is 2

Letztere Variante sollte man allerdings nach Möglichkeit vermeiden. Andere Funktionen verlassen sich vielleicht darauf, dass der Wert der globalen Variablen nicht geändert wird.

2.4.3. Mutable vs. Immutable

Eine weitere interessante Frage ist, was passiert eigentlich mit den Funktionsparametern, wenn wir diese innerhalb der Funktion verändern? Die Antwort liefert ein einfacher Test:

def add_something(a):
    a += 5

a = 2
print("a =", a)
add_something(a)
print("a =", a)
a = 2
a = 2

Obwohl wir a also in der Funktion verändern haben, ändert sich der Wert von a außerhalb der Funktion nicht. Das liegt daran, dass beim Aufruf der Funktion add_something eine Kopien von a wurde. Alle Operationen, welche diesen Parameter verändern, werden lediglich auf die Kopie angewendet.

Jetzt wird es allerdings verwirrend. Im folgenden Beispiel ist der Funktionsparameter eine Liste, welche innerhalb der Funktion verändert wird. Wenn innerhalb der Funktion nur mit einer Kopie gearbeitet wird, sollte sich die liste L nach dem Funktionsaufruf nicht geändert haben. Dies ist offensichtlich nicht der Fall, wie folgendes Beispiel zeigt:

def append_something(L):
    L.append(5)

L = [1,2,3,4]
print("Liste vor Funktionsaufruf  :", L)
append_something(L)
print("Liste nach Funktionsaufruf :", L)
Liste vor Funktionsaufruf  : [1, 2, 3, 4]
Liste nach Funktionsaufruf : [1, 2, 3, 4, 5]

Offensichtlich wurde innerhalb von append_something nicht mit einer Kopie von L gearbeitet, sondern direkt auf dem Objekt L.

Um das eben beobachtete Verhalten zu verstehen hilft ein genauerer Einblick die Funktionsweise von Variablen und deren Bezeichner. Mit der Funktion id, welche eine Identifikationsnummer einer Variable zurückgibt, können wir uns anschauen, wo der Wert einer Variablen im Speicher abgelegt wird. Im folgenden Beispiel legen wir 2 Variablen an, welche offensichtlich die gleiche ID besitzen:

a = 5
b = 5
print("a :", id(a))
print("b :", id(b))
print("a und b sind gleich :", a is b)

b = b+1
print("a :", id(a))
print("b :", id(b))
print("a und b sind gleich :", a is b)
a : 139935669141872
b : 139935669141872
a und b sind gleich : True
a : 139935669141872
b : 139935669141904
a und b sind gleich : False

Dies kann so interpretiert werden, dass a und b im Prinzip nur Namen sind, welche wir an ein Objekt binden (hier die Konstante 5, die irgendwo im Speicher liegt). Beim Verändern des Wertes der Variable wird ein neues Objekt mit dem neuen Wert im Speicher angelegt und der Variablenname wird an dieses neue Objekt gebunden. Das gespeicherte Objekt wird also im Speicher nicht verändert.

Implementieren wir einen ähnlichen Test für Listen-Objekte:

a = [1,2,3]
b = [1,2,3]
print("a :", id(a))
print("b :", id(b))
print("a und b sind gleich :", a is b)
a : 139935170026432
b : 139935169966272
a und b sind gleich : False

Hier sind a und b zwar Listen mit genau dem gleichen Inhalt, die Namen zeigen aber auf verschiedene Objekte im Speicher. Listen funktionieren also anders als Integers, Floats, Strings, etc.. Wir verändern das obere Beispiel leicht:

a = [1,2,3]
b = a

print("a :", id(a))
print("b :", id(b))
print("a und b sind gleich :", a is b)

b.append(4)
print("a :", id(a))
print("b :", id(b))
print("a und b sind gleich :", a is b)
print("a =", a)
print("b =", b)
a : 139935170031616
b : 139935170031616
a und b sind gleich : True
a : 139935170031616
b : 139935170031616
a und b sind gleich : True
a = [1, 2, 3, 4]
b = [1, 2, 3, 4]

Durch die Zuweisung b=a haben wir b an das gleiche Objekt wie a gebunden. Und nun müssen wir aufpassen. Wir haben lediglich das Objekt hinter b durch b.append(4) verändert, da a und b aber an die gleichen Objekte gebunden sind hat diese Operation auch a verändert. Die Position des Objektes im Speicher hat sich dabei nicht verändert. Damit ist das Objekt selbst also veränderbar.

Beachte

In Python wird also zwischen mutable (veränderlichen) und immutable (unveränderlichen) Objekten unterschieden.

Wie unsere Beispiele gezeigt haben ist ein Objekt vom Typ int immutable, was bedeutet, dass dieses nicht verändert werden kann. Verändert man den Wert eines Integers, so wird ein neues Objekt erzeugt an den der ursprüngliche Name gebunden wird:

a = 1
print("a :", id(a))
a+= 1
print("a :", id(a))
a : 139935669141744
a : 139935669141776

Listen sind hingegen mutable (veränderlich) und verhalten sich anders:

L = [1,2]
print("L :", id(L))
L.append(3)
print("L :", id(L))
L : 139935170028800
L : 139935170028800

Dies erklärt auch das unterschiedliche Verhalten bei der Verwendung von Listen als Funktionsparameter. Das Verhalten bei immutable (veränderlichen) Datentypen erklärt sich leicht, wenn wir unser Beispiel von oben um einige Ausgaben ergänzen:

def add_something(a_):
    print("Funktionsbeginn    : a ->", id(a_), " Wert =", a_)
    a_+=1
    print("Funktionsende      : a ->", id(a_), " Wert =", a_)
    
a = 1
print("Vor Funkt.-Aufruf  : a ->", id(a), " Wert =", a)
add_something(a)
print("Nach Funkt.-Aufruf : a ->", id(a), " Wert =", a)
Vor Funkt.-Aufruf  : a -> 139935669141744  Wert = 1
Funktionsbeginn    : a -> 139935669141744  Wert = 1
Funktionsende      : a -> 139935669141776  Wert = 2
Nach Funkt.-Aufruf : a -> 139935669141744  Wert = 1

Der Name a ist zunächst an das Objekt mit der Konstanten 1 gebunden. Der Name wird an die Funktion add_something übergeben, heißt nun a_ (zur besseren Unterscheidung, wir hätten es auch weiterhin a nennen können), ist aber immer noch an das gleiche Objekt gebunden. Innerhalb der Funktion wird nun eine Operation auf dieses Objekt angewendet, hier +=. Dabei wird eine Kopie erstellt, da Integer immutable sind, und der Name a_ zeigt auf dieses neue Objekt, welches die Konstante 2 beinhaltet. Der Name a außerhalb der Funktion add_something ist allerdings weiter an das alte Objekt gebunden und bleibt dadurch unbeeinflusst von dem, was innerhalb der Funktion passiert.

Wäre a im vorherigen Beispiel eine Liste, also ein mutable Objekt, würde sich bei einer Operation, die die Liste verändert, die Bindung des Namens an das Objekt nicht ändern.

2.4.4. Rekursive Funktionen

Rekursive Funktionen sind Funktionen, welche sich, bis zu einem bestimmten Abbruchkriterium, immer wieder selbst aufrufen. Ein typisches Beispiel wäre folgende Funktion zur Berechnung der Fakultät:

def faculty(n):
    if n==1: 
        # Abbruchbedingung der Rekursion
        return 1
    else:
        # Rekursiver Aufruf
        return n*faculty(n-1)
    
print("5! =", faculty(5))
5! = 120

Beachte, dass viele Algorithmen sowohl rekursiv, als auch iterativ (mit einer Schleife) realisierbar sind. Aus Effizienzgründen ist in diesem Fall immer die iterative Variante zu bevorzugen.

Übungsaufgabe

Implementiere den modernen Euklidischen Algorithmus (siehe Wikipedia) zur Berechnung des größten gemeinsamen Teilers zweier Funktionen, sowohl in der rekursiven, als auch iterativen Variante.