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 |
|
---|---|---|
|
|
|
|
|
analog |
|
|
analog |
|
|
Vergleichsoperationen geben immer ein Objekt vom Typ bool
zurück, also entweder True
oder False
:
b = 1<2
print("Das Ergebnis von 1<2 ist vom Typ", type(b), "und besitzt den Wert", b)
Das Ergebnis von 1<2 ist vom Typ <class 'bool'> und besitzt den Wert True
Vergleichsoperatoren können allerdings nur dann verwendet werden, wenn diese Operationen auch für die zu vergleichenden Datentypen definiert ist. Folgende Vergleiche funktionieren:
# int-int
print("1 ist kleiner 3:", 1<3)
# string-string (vergleiche alphabetische Reihenfolge)
print("'abc' ist kleiner 'bcd':", 'abc'<'bcd')
# int-float
print("2 ist größer oder gleich 2.0:", 2 >= 2.0)
1 ist kleiner 3: True
'abc' ist kleiner 'bcd': True
2 ist größer oder gleich 2.0: True
Bei komplexen Zahlen fehlt die “kleiner-als”-Vergleichsoperation offensichtlich:
(1+3j)<(2-4j)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[3], line 1
----> 1 (1+3j)<(2-4j)
TypeError: '<' not supported between instances of 'complex' and 'complex'
Übungsaufgabe
Vergleiche die Strings 'abc'
und 'bcd'
mit den üblichen Vergleichsoperatoren. Welche Beobachtung machen wir?
Boolsche Ausdrücke können auch miteinander verknüpft werden. Die Äquivalente der mathematischen Ausdrücke \(a\wedge b\), \(a\vee b\) und \(\neg a\) sind:
Operator |
Bedeutung |
---|---|
|
ergibt |
|
ergibt |
|
ergibt |
Um Beispielsweise zu testen, ob eine Zahl \(x\) im Intervall \([1/4,3/4)\) liegt schreiben wir
import random
# Generiere Zufallszahl zwischen 0 und 1
x = random.random()
# Teste, ob x im Intervall [1/4 , 3/4) liegt:
in_interval = x >= 1/4 and x < 3/4
# Konsolenausgabe
print("x =", x)
print("x liegt in [1/4,3/4):", in_interval)
x = 0.8825190653731094
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>:
# Wenn condition_1 True ist:
[do something]
...
[do something more]
elif <condition_2>:
# Wenn condition_1 False und condition_2 True ist:
[do something]
...
[do something more]
else:
# Wenn condition_1 und condition_2 False sind:
[do something]
...
[do something more]
Natürlich kann die Abfrage um beliebig viele elif
-Blöcke erweitert werden.
condition_1
und condition_2
müssen hier bool’sche Variablen, also entweder True
oder False
sein. 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. Beachte dazu die Ausgabe folgendes Codes:
x = 0.8
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") # Nur diese Zeile wird ausgeführt
Ich nicht
x = 0.2
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")
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. Dazu lernen wir gleich auch den sogenannten Modulo-Operator %
kennen, welcher den Divisionsrest bei Division zweier ganzer Zahlen zurückgibt. Beispiel: 8%3
ergibt 2, da \(8=2\cdot 3+\textbf{2}\). Dies wird 10 mal wiederholt. Eine Erläuterung zu for-Schleifen folgt im nächsten Abschnitt.
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")
1 ist eine ungerade Zahl
8 ist eine gerade Zahl
6 ist eine gerade Zahl
2 ist eine gerade Zahl
5 ist eine ungerade Zahl
8 ist eine gerade Zahl
3 ist eine ungerade Zahl
4 ist eine gerade Zahl
2 ist eine gerade Zahl
5 ist eine ungerade 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. Nutze eine if-Abfrage um alle dabei auftretenden Fälle (keine, eine oder zwei Nullstellen) gesondert zu behandeln.
2.3. Schleifen#
2.3.1. for-Schleifen#
Um die Notwendigkeit von Schleifen zu verstehen betrachten wir zunächst folgenden Code, in dem die Summe von Zahlen aus einer Liste berechnet wird:
a = [1,4,7,4,2]
value = 0
value += a[0]
value += a[1]
value += a[2]
value += a[3]
value += a[4]
print("Die Summe der Zahlen", a, "ist", value)
Die Summe der Zahlen [1, 4, 7, 4, 2] ist 18
Wir wollen hier offensichtlich alle Elemente einer Liste durchgehen. Problematisch wird es dann, wenn die Liste mehrere Tausend Elemente beinhaltet. Unser Code würde dann auf mehrere Tausend Zeilen anwachsen. Ferner haben wir hier ausgenutzt, dass wir bereits die Anzahl der Elemente in der Liste kennen. Die Zeilen value += a[i]
unterscheiden sich lediglich im Index i
und mit Schleifen lassen sich all diese Zeilen zusammenfassen.
Die erste Art von Schleifen, die wir hier diskutieren möchten, ist die for
-Schleife. 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, 4, 2] ist 18
Die Zeile value += elem
wird hier so oft wiederholt, wie die Liste Elemente beinhaltet, und die Variable elem
trägt nacheinander die Werte a[0]
, a[1]
, …, a[4]
.
Neben den oben genannten iterierbaren Datentypen gibt es noch die Klasse range
. Ein Range-Objekt repräsentiert ein Intervall für den Iterationsindex mit Start- und Endindex. Aus der Python-Dokumentation, die wir mit range?
angezeigt bekommen, erfahren wir beispielsweise
range(stop) -> range object
range(start, stop[, step]) -> range object
Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted!
Hier ein kleiner Test:
# 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: 7
Zufallszahl: 11
Zufallszahl: 74
Zufallszahl: 87
Zufallszahl: 93
Zufallszahl: 42
Zufallszahl: 47
Zufallszahl: 46
Zufallszahl: 29
Zufallszahl: 6
Zufallszahl: 38
Zufallszahl: 49
Zufallszahl: 82
Zufallszahl: 45
45 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 in einem Tupel zusammengefasst 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>[, ...])
Wir beginnen mit einem einfachen Beispiel. Die folgende Funktion summiert alle Elemente eines iterierbaren Objektes L
auf und gibt das Ergebnis zurück:
def sum(L):
val = 0
for elem in L:
val += elem
return val
Um diese Funktion nun auszuführen schreiben wir:
L = [1,4,7]
sum_L = sum(L)
print("Die Summe der Zahlen", L, "ist", sum_L)
L.pop()
L.append(9)
L.append(13)
sum_L = sum(L)
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. Strings 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)
Cell In[18], line 1
----> 1 sum("Test")
Cell In[15], line 4, 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 Kopie von a
angelegt wurde. Alle Operationen, welche diese Variable 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 : 140201323053424
b : 140201323053424
a und b sind gleich : True
a : 140201323053424
b : 140201323053456
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 : 140201221832000
b : 140201221954944
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 : 140201221901504
b : 140201221901504
a und b sind gleich : True
a : 140201221901504
b : 140201221901504
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 : 140201323053296
a : 140201323053328
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 : 140201221818880
L : 140201221818880
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 -> 140201323053296 Wert = 1
Funktionsbeginn : a -> 140201323053296 Wert = 1
Funktionsende : a -> 140201323053328 Wert = 2
Nach Funkt.-Aufruf : a -> 140201323053296 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.