7. Übung
Beispiel 7.1
Klassengesellschaft
Bereits in der ersten Übung hatten wir uns den Datentyp verschiedener Variablen anzeigen lassen. Damals hatten wir vorerst nur zur Kenntnis genommen, dass uns für eine Variable a=42
als Datentyp <class 'int'>
ausgegeben wird. Nun wollen wir etwas mehr Licht ins Dunkel der Pythonschen "Klassengesellschaft" bringen, denn in Python gehören einfach alle Daten einer Klasse an. Bevor wir dies weiter vertiefen, schauen wir uns noch ein Beispiel an:
1# Wir definieren 2 Integervariablen ...
2a = 42
3b = int(73)
4
5# ... und schauen uns deren Datentypen an ...
6print('Datentyp von a=', type(a))
7print('Datentyp von b=', type(b))
8
9# ... addieren a und b "konventionell" ...
10c = a + b
11print('Datentyp von c=', type(c))
12
13# ... und etwas "unkonventioneller" ...
14d = a.__add__(b)
15print('Datentyp von d=', type(d))
16
17# Zur Kontrolle lassen wir uns noch einmal alle Variablen ausgeben.
18# NEU: Hier sehen wir gleich ein Beispiel, wie man bei 'print()' die Ausgabe formatieren kann.
19print('a=%d b=%d c=%c d=%d' %(a,b,c,d))
Zur Erinnerung ist in Zeile 3 noch einmal eine explizite Instanziierung des Wertes 73 als Integervariable vorgenommen worden - in Zeile 2 erfolgt das Ganze implizit hinter den Kulissen durch den Python-Interpreter. In Zeile 14 wird es dann so richtig "obskur": dort addieren wir die beiden Variablen a und b nicht durch den '+'-Operator, sondern durch den Aufruf der Methode __add()__ der Klasse int. Methoden, also Funktionen, die zu einer Klasse gehören, haben wir schon verschiedentlich eingesetzt, z.B. beim Einsatz der Matplotlib oder von NumPy, aber noch nicht weiter vertieft. Tatsächlich ist es so, dass auch im Fall des Additionsoperators '+' eigentlich die Methode __add()__ der Klasse int aufgerufen wird. Ebenso gibt es für alle Operatoren, die für Integerzahlen definiert sind entsprechende Klassen-Methoden, die stattdessen aufgerufen werden. Dieser Aufruf erfolgt für uns transparent durch den Python-Interpreter und hat die schöne Bezeichnung "Operator-Overloading". Alternativ könnten wir aber tatsächlich auch überall direkt mit den Methoden arbeiten - nur ist die Schreibweise für uns natürlich viel gewohnter und auch kürzer und übersichtlicher. Und zur weiteren Erinnerung hier noch einmal ein kurzer Codeausschnitt, bei dem die Variable a erst als Integer-Variable und danach als Float-Variable instanziiert wird:
1# Variable 'a' ist erst ein Integer ...
2a = 42 # explizit: int(42)
3print('Datentyp von a=', type(a))
4print(a.__add__(73))
5
6# und wird dann zum Float
7a = 3.1415 # explizit: float(3.1415)
8print('Datentyp von a=', type(a))
9print(a.__add__(2.71))
Die Methoden __add__(), die in beiden Fällen aufgerufen werden, sind tatsächlich unterschiedliche Methoden, da ja a eine anderen Klasse hat: im ersten Fall (Zeile 4) wird eine Integer-Addition ausgeführt, im zweiten Fall (Zeile 9) eine Float-Addition.
Eigene Klassen definieren
Das Beste an der Sache ist aber, dass es kinderleicht ist, auch eigene Klassen zu definieren und mit diesen genauso zu arbeiten wie mit vordefinierten Klassen:
1# Zuerst definieren wir die Klasse, die Python sich - ebenso wie Funktionen - im Vorbeigehen für die spätere Verwendung "merkt"
2class MeineKlasse():
3 # ... die Methoden werden - wie üblich - eingerückt ...
4 def __init__(self):
5 # ... sind ansonsten aber ganz normale Python-Funktionen
6 pass
7
8# Hier beginnt das eigentliche "Hauptprogramm" mit der Definition einer Instanz unserer Klasse
9mein_objekt = MeineKlasse()
10print('Datentyp von m_objekt=', type(m_objekt))
Mit diesem einfachen Beispiel haben wir eine Klasse namens MeineKlasse definiert, die allerdings außer der Methode __init__() keine weiteren Methoden und auch keine Attribute (=Datenelemente) enthält, mit der wir also so noch nichts anfangen können. Die Methode __init__() muss allerdings jede Klasse besitzen, denn diese wird von Python aufgerufen, wenn wir ein Element der Klasse instanziieren wollen, also eine Variable vom Typ dieser Klasse anlegen wollen. Genau das haben wir in Zeile 5 gemacht und somit ein Objekt mein_objekt der Klasse MeineKlasse angelegt. Nichts anderes passiert im Übrigen, wenn wir a = int(42)
schreiben - dann wird die __init__()-Methode der Klasse int aufgerufen.
Klassen und Objekte
Gemeinhin spricht man beim Einsatz von Klassen in der Programmierung und den dafür verwendeten Programmiersprachen von Objektorientierter Programmierung. Diese Begrifflichkeit ist in Programmierkreisen nicht ganz unumstritten, da viele der Meinung sind der Begriff Klassenorientierte Programmierung wäre zutreffender. Dies ist aber eher als einer (von gar nicht so wenigen) Glaubenskonflikten untern Programmierern zu sehen und muss uns überhaupt nicht stören - wir bleiben beim Begriff der Objektorientierten Programmierung oder kurz OOP, merken uns aber den Unterschied zwischen Klassen und Objekten:
- Klasse:
Bauplan/Schablone für einen (neuen) Datentyp der Datenelemente als Attribute und Methoden, die auf diesen Attributen arbeiten, enthält. Eine Klasse selbst ist ein abstraktes Konstrukt und enthält i.d.R. keine konkreten Daten (bis auf Ausnahmefälle).
- Objekt:
Ein Objekt ist ein Datenelement, welches bei der Programmierung als konkrete Instanz einer vor- oder selbstdefinierten Klasse angelegt wird. Es enthält für jedes Attribut, das in der Klasse definiert ist, einen ihm eigenen Wert, der sich von den Attributwerten anderer Objekte der gleichen Klasse i.d.R. unterscheidet.
Um das Konzept der OOP etwas weiter zu veranschaulichen, wollen wir uns an einer Klasse zu Repräsentation komplexer Zahlen versuchen:
1# Definition der Klasse
2class Komplex():
3 # Konstruktor mit Defaultwerten für die Argumente
4 def __init__(self, real=0.0, imag=0.0):
5 # Die Attribute der Klasse sollten im Konstruktor definiert werden
6 self.real = real # Realteil der komplexen Zahl
7 self.imag = imag # Imaginärteil der komplexen Zahl
8
9# Eine Instanz mit Defaultwerten anlegen ...
10k1 = Komplex()
11print('Datentyp von k1=', type(k1))
12print('Realteil=%f Imaginärteil=%f' %(k1.real, k1.imag))
13
14# ... und eine mit Übergabe der Initialisierungswerte
15k2 = Komplex(-1.0, 1.5)
16print('Datentyp von k2=', type(k2))
17print('Realteil=%f Imaginärteil=%f' %(k2.real, k2.imag))
Hier haben wir die __init()__-Methode (auch Konstruktor genannt) mit Leben gefüllt und können beim Anlegen einer Instanz der Klasse Initialisierungswerte für den Real- und den Imaginärteil übergeben. Sofern wir nichts übergeben, werden die Standardwerte (0.0, j0.0) genommen. Die Attributwerte einer Klasse können und sollten wir immer im Konstruktor anlegen. Dabei benutzen wir den Ausdruck self, der allen Methoden einer Klasse als implizites erstes Argument übergeben wird. self stellt die Referenz auf die jeweilige Instanz der Klasse dar, denn die beiden Instanzen k1 und k2 unterscheiden sich ja i.d.R. durch den jeweiligen Wert ihrer Attribute. Damit wir mit den Attributen einer Instanz arbeiten, müssen wir den Attributen stets self. voranstellen, wie dies in den Zeilen 6 und 7 zu sehen ist. (Anmerkung: In C++ und Java wird die hier self genannte Referenz auf die jeweilige Instanz mit this bezeichnet.)
Außer dem Konstruktor bietet unsere Beispielklasse noch keinerlei Funktionalität, also bauen sie noch etwas weiter aus:
1class Komplex():
2 # Konstruktor
3 def __init__(self, real=0.0, imag=0.0):
4 print('Objekt der Klasse \'Komplex\' angelegt')
5 self.real = real
6 self.imag = imag
7
8 # Destruktor - wird bei der Zerstörung aufgerufen
9 def __del__(self):
10 print('Objekt der Klasse \'Komplex\' gelöscht')
11
12 # Methode zur passenden print-Ausgabe
13 def print(self):
14 if self.imag >= 0.0:
15 print('(%3.2f+j%3.2f)' %(self.real, self.imag))
16 else:
17 print('(%3.2f-j%3.2f)' %(self.real, abs(self.imag)))
18
19 # Methode zum Addieren zweier komplexer Zahlen
20 def add(self,ks):
21 return Komplex(self.real+ks.real, self.imag+ks.imag)
22
23#---------------------------------------------
24# Hauptprogramm: Einstiegspunkt
25if __name__ == '__main__':
26
27 # 1. Instanz
28 k1 = Komplex(2.0, -1.5)
29 k1.print()
30
31 # 2. Instanz
32 k2 = Komplex(-1.0, 2.5)
33 k2.print()
34
35 # 3. Instanz als Ergebnis der Addition von k1 und k2
36 k3 = k1.add(k2)
37 k3.print()
38#---------------------------------------------
Die wesentlichen Anmerkungen zum Vorgehen sollten aus den Kommentaren ersichtlich sein. Neu ist allerdings die Zeile 25, die nichts mit dem Thema OOP zu tun hat: Wir haben ja schon erfahren, dass jede Python-Datei gleichzeitig auch ein Modul darstellt, dass wir in anderen Modulen importieren und verwenden können. Um zu unterscheiden, ob ein Modul direkt vom Python-Interpreter aufgerufen wird, weil wir Python mit dem Modulnamen gestartet haben, bekommt dieses "Start"-Modul den (internen) Laufzeitnamen __main__ (für diesen Durchlauf). Durch Abfrage des Laufzeitnamens können wir nun dafür sorgen, dass der nachfolgende, eingerückte Code nur ausgeführt wird, wenn es das "Start"-Modul ist, aber nicht, falls es von einem anderen Modul importiert worden ist.
Hintergrund: OOP "lite"
Der Entwurf umfangreicherer Softwaresysteme ist eine komplexe Angelegenheit, die viel Planung und viele Entwürfe erfordert. Man spricht dann auch von Softwarearchitekturen, die z.B. im Vorfeld der Implementierung durch Modelle definiert werden. Erst nach ganz am Ende erfolgt dann die technische Umsetzung in einer Programmiersprache. Nicht nur im Rahmen von Softwaresystemen, sondern auch ganz allgemein bei anderen Systemen, gibt es den Begriff der "Black Box". Als solche bezeichnet man ein System, das auf bestimmte Eingaben mit bestimmten Ausgaben reagiert. Man kennt also das definierte Verhalten des Systems (das man u.U. selber spezifiziert hat), aber nicht die Umsetzung wie die "Black Box arbeitet". Dieses Design hat den Vorteil (neben einigen Nachteilen), dass die Implementierung beliebig geändert werden kann (scheller, billiger, resourcensparener) ohne das sich an der zur Verfügung gestellten Funktion etwas ändert. Die Implementierung ist sozusagen das "Geheimnis" der Black-Box. Ein ganz ähnliches Ziel verfolgt die OOP mit den Klassen, deren Attributen und Methoden: Nach außen sollen möglichst nur die Methoden(aufrufe) (die "Ein-" und "Ausgänge") der Klasse sichtbar sein. Weder die Attribute noch die konkrete Implementierung einer Klasse sollen sichtbar sein. Nur dann haben wir bei der Implementierung der Klasse die volle Freiheit intern alles zu ändern und nur die Methoden als Schnittstellen unverändert zu lassen. Um dies technisch zu unterstützen kennen Objektorientierte Programmiersprachen ein abgestuftes Geheimniskonzept mit den (meist) drei Stufen:
- public
Attribut/Methode von außerhalb der Klasse zugreifbar, also komplett ungeschützt.
- protected
Attribut/Methode nur innerhalb der Klasse und der "Verwandtschaft" zugreifbar, also vollkommen teilweise geschützt. (Python: dem Namen des Attributs/der Methode ein '_' (ein Unterstrich) voranstellen.)
- private
Attribut/Methode nur innerhalb der Klasse zugreifbar, also vollkommen geschützt. (Python: dem Namen des Attributs/der Methode ein '__' (zwei Unterstriche) voranstellen.)
Hinter der OOP steckt also eine ganze Ecke mehr, als wir bislang kennen. Da dies aber einen Kurs über Programmiertechniken in Python ist und kein vertiefender Kurs in objektorientiertem Softwareentwurf, werden wir mit dem Thema OOP sehr pragmatisch umgehen. D.h. wir werden die strukturellen Methoden des Klassendesigns einsetzen, uns aber nicht strikt an die eigentlichen OOP-Paradigmen halten.
Beispiel 7.2
Wir wollen nun an einem uns bekannten Beispiel den Übergang von der "gewohnten " prozeduralen Programmierung zur objektorientierten Programmierung demonstrieren. Dazu betrachten wir als uns bekannte Aufgabe die Berechnung der Sinus-Funktion und deren grafische Darstellung. Bislang waren wir immer so verfahren, dass wir erst alle Werte berechnet (und gespeichert) haben und dann erst haben wir das Ergebnis grafisch dargestellt. Bei vielen praktischen Aufgaben hingegen (z.B. Messwerterfassung) kommen laufend neue Werte hinzu, die schritthaltend ausgegeben werden müssen:
1"""
2Iterative Berechnung der Sinusfunktion mit schritthaltender grafischer Ausgabe
3"""
4# Importieren der notwendigen Module
5import numpy as np
6import matplotlib.pyplot as plt
7
8# Anzahl der Stützpunkte
9n_gesamt = 200
10# Darstellungsintervall
11t_gesamt = 2.0*np.pi
12# Inkrement zwischen 2 Stützpunkten
13dt = t_gesamt / n_gesamt
14
15# Vektor für Zeitwerte
16t_vec = np.zeros(n_gesamt)
17# Vektor für Ergebniswerte
18sin_vec = np.zeros_like(t_vec)
19
20# Plot anlegen ...
21fig, ax = plt.subplots()
22
23# ... und den ersten Iterationsschritt vorab berechnen
24i=0
25sin_vec[i] = np.sin(t_vec[i])
26
27# Ersten Stützpunkt "normal" plotten ...
28# Die ax.plot()-Funktion liefert uns eine Referenz auf die Plotdaten zurück
29sin_plot, = ax.plot(t_vec[:i], sin_vec[:i])
30# ... und das Diagramm groß genug machen für die weiteren Punkte
31ax.set_xlim(-1,2.0*np.pi)
32ax.set_ylim(-1.5,+1.5)
33
34# Jetzt den Plot mit kurzer Pause ausgeben ohne anzuhalten ...
35plt.pause(0.1)
36
37# ... und jetzt die restlichen Stützpunkte iterativ berechnen und nach jedem
38# 10. Schritt den Plot aktualisieren
39for i in range(1,n_gesamt):
40 t_vec[i] += t_vec[i-1]+dt
41 sin_vec[i] = np.sin(t_vec[i])
42
43 if i % 10 == 0:
44 # Hier nutzen wir die Referenz auf die Plotdaten und können diese einfach
45 # aktualisieren - die Darstellung wird automatisch ...
46 sin_plot.set_data(t_vec[:i],sin_vec[:i])
47 # ... mit aktualisiert, wenn wir eine kurze Pause einlegen
48 plt.pause(0.1)
Jetzt wollen wir im ersten Schritt die Plot-Funktionalität in eine separate Klasse auslagern:
1# Unsere Klasse für die Plotausgabe
2class SinPlot():
3 # Konstruktor
4 def __init__(self):
5 # Plot anlegen ...
6 self.fig, self.ax = plt.subplots()
7
8 # Aufruf für die erste Plot-Ausgabe
9 def init_plot(self, t_vec, sin_vec):
10 # Ersten Stützpunkt "normal" plotten ...
11 self.sin_plot, = self.ax.plot(t_vec, sin_vec)
12 # ... und das Diagramm groß genug machen für die weiteren Punkte
13 self.ax.set_xlim(-1,2.0*np.pi)
14 self.ax.set_ylim(-1.5,+1.5)
15
16 # Aufruf für die weiteren Plot-Ausgaben
17 def update_plot(self, t_vec, sin_vec):
18 self.sin_plot.set_data(t_vec,sin_vec)
19
20#---------------------------------------------
21# Hauptprogramm: Einstiegspunkt
22if __name__ == '__main__':
23 # Anzahl der Stützpunkte
24 n_gesamt = 200
25 # Darstellungsintervall
26 t_gesamt = 2.0*np.pi
27 # Inkrement zwischen 2 Stützpunkten
28 dt = t_gesamt / n_gesamt
29
30 # Vektor für Zeitwerte
31 t_vec = np.zeros(n_gesamt)
32 # Vektor für Ergebniswerte
33 sin_vec = np.zeros_like(t_vec)
34
35 # Plot(-Klasse) anlegen ...
36 sin_plot = SinPlot()
37
38 # ... und den ersten Iterationsschritt vorab berechnen
39 i=0
40 sin_vec[i] = np.sin(t_vec[i])
41
42 # Erste Plot-Ausgabe erzeugen
43 sin_plot.init_plot(t_vec[:i], sin_vec[:i])
44 # Jetzt den Plot mit kurzer Pause ausgeben ohne anzuhalten ...
45 plt.pause(0.1)
46
47 # ... und jetzt die restlichen Stützpunkte iterativ berechnen und nach
48 # jedem 10. Schritt den Plot aktualisieren
49 for i in range(1,n_gesamt):
50 t_vec[i] += t_vec[i-1]+dt
51 sin_vec[i] = np.sin(t_vec[i])
52
53 if i % 10 == 0:
54 sin_plot.update_plot(t_vec[:i], sin_vec[:i])
55 plt.pause(0.1)
56#---------------------------------------------
Und im nächsten Schritt auch die Funktionalität der Sinus-Berechnung, die wir - damit es nicht zu trivial wird - um die explizite Berücksichtigung von Amplitude und Kreisfrequenz erweitert haben:
1# Unsere Klasse für die Sinus-Berechnung
2class Sinus():
3 def __init__(self, A=1.0, omega=1.0):
4 # Amplitude
5 self.A = A
6 # Kreisfrequenz
7 self.omega = omega
8
9 # "Setter"-Funktion für die Amplitude
10 def set_A(self, A):
11 self.A = A
12
13 # "Setter"-Funktion für die Kreisfrequenz
14 def set_omega(self, omega):
15 self.omega = omega
16
17 # Berechnung des Sinus
18 def calc(self,t):
19 return self.A * np.sin(self.omega*t)
20
21# Unsere Klasse für die Plotausgabe
22class SinPlot():
23 # Konstruktor
24 def __init__(self):
25 # Plot anlegen ...
26 self.fig, self.ax = plt.subplots()
27
28 # Aufruf für die erste Plot-Ausgabe
29 def init_plot(self, t_vec, sin_vec):
30 # Ersten Stützpunkt "normal" plotten ...
31 self.sin_plot, = self.ax.plot(t_vec, sin_vec)
32 # ... und das Diagramm groß genug machen für die weiteren Punkte
33 self.ax.set_xlim(-1,2.0*np.pi)
34 self.ax.set_ylim(-1.5,+1.5)
35
36 # Aufruf für die weiteren Plot-Ausgaben
37 def update_plot(self, t_vec, sin_vec):
38 self.sin_plot.set_data(t_vec,sin_vec)
39
40#---------------------------------------------
41# Hauptprogramm: Einstiegspunkt
42if __name__ == '__main__':
43 # Anzahl der Stützpunkte
44 n_gesamt = 1000
45 # Darstellungsintervall
46 t_gesamt = 2.0*np.pi
47 # Inkrement zwischen 2 Stützpunkten
48 dt = t_gesamt / n_gesamt
49
50 # Vektor für Zeitwerte
51 t_vec = np.zeros(n_gesamt)
52 # Vektor für Ergebniswerte
53 sin_vec = np.zeros_like(t_vec)
54
55 # Sinus-Objekt anlegen ...
56 sinus = Sinus()
57 # Plot-Objekt anlegen ...
58 sin_plot = SinPlot()
59
60 # Wir ergänzen ein paar Parameter für die Sinus-Funktion
61 omega = 0.0
62 domega = 0.025
63 A = 1.0
64 dA = -0.001
65 # ... und den ersten Iterationsschritt vorab berechnen
66 i=0
67 sinus.set_A(A)
68 sinus.set_omega(omega)
69 sin_vec[i] = sinus.calc(t_vec[i])
70
71 # Erste Plot-Ausgabe erzeugen
72 sin_plot.init_plot(t_vec[:i], sin_vec[:i])
73 # Jetzt den Plot mit kurzer Pause ausgeben ohne anzuhalten ...
74 plt.pause(0.1)
75
76 # ... und jetzt die restlichen Stützpunkte iterativ berechnen und nach
77 # jedem 10. Schritt den Plot aktualisieren
78 for i in range(1,n_gesamt):
79 t_vec[i] += t_vec[i-1]+dt
80 # omega durch"wobbeln"
81 omega += domega
82 sinus.set_omega(omega)
83 # A durch"wobbeln"
84 A += dA
85 sinus.set_A(A)
86 sin_vec[i] = sinus.calc(t_vec[i])
87
88 if i % 10 == 0:
89 sin_plot.update_plot(t_vec[:i], sin_vec[:i])
90 plt.pause(0.1)
91#---------------------------------------------
Das vorangegangene Beispiel sollte im Wesentliche für sich selber sprechen im Vergleich zur "konventionellen" prozeduralen Variante. Es soll in der Hauptsache als Muster für die nachfolgende Aufgabe dienen, in der ganz ähnliche Punkte zu bearbeiten sind.
Aufgabe 7.1
In dieser Aufgabe wollen wir in Analogie zu Beispiel 7.2 unser Resultat der Aufgabe 6.1 in eine objektorientierte Darstellung überführen.
To Do
Es soll das Resultat der Aufgabe 6.2 unter den folgenden Vorgaben neu formuliert werden:
Es sollen zwei Klassen mit folgender Signatur eingeführt werden:
# Klasse zur grafischen Ausgabe der Bewegungsgrößen class MotionPlot(): def init_plot(self, t_vec, v_soll, v_ist, a_ist, s_ist) ToDo: - Plot initial ausgeben - Plotreferenz merken def update_plot(self, t_vec, v_soll, v_ist, a_ist, s_ist) ToDo: - Plotdaten aktualisieren mit neuen Daten # Klasse zur Repräsentation der Objektbewegung # (Berechnung von v_ist, a_ist, s_ist) class MotionObject(): def set_v_soll(self, v_soll) ToDo: - neues v_soll setzen def move(self, dt) ToDo: - Bewegungsgrößen für den Zeitschritt dt berechnen - Ergebnisse in der Form (self.v_ist, self.a_ist, self.s_ist) zurückgeben
Die Visualisierung soll schritthaltend mit der Berechnung in jedem 10. Iterationsschritt erfolgen.
Lösungshinweise:
Bei der Lösung kann man sich gut an der Vorgehensweise von Beispiel 7.2 orientieren und diese auf mehrere zu berechnende und darzustellende Größen erweitern.
Analog bietet es sich an, die Adaption in getrennten Teilschritten für jede der beiden Klassen durchzuführen und zu testen.