Wir hatten in der Vorlesung 7 in das Konzept der objektorientierten Programmierung eingeführt und gesehen, dass man mittels des Konzeptes einer C++ Klasse selbstdefinierte Objekte im Programm realisieren kann. Eine Klasse stellt eine formale Beschreibung dar (ein Bauplan), wie das Objekt (bzw. die in Programmcode formulierte Idee ) beschaffen ist, und definiert, welche Merkmale (Instanzvariablen bzw. Daten-Member der Klasse) und Verhaltensweisen (Methoden der Klasse bzw. Member-Funktionen) das zu beschreibende Objekt hat. Bei der Konstruktion einer Klassenstruktur eines Programmes kann es nun geschehen, dass man das Programm in mehrere Teilideen/Teilkonzepte unterteilen kann und die so entworfenen Klassen stehen dann häufig in Beziehung zueinander. So können z.B. einige Klassen des Programms auch die Daten-Member und Verhaltensweisen von anderen Klassen verwenden (Vererbung von Merkmalen) und es stellt sich jetzt die Frage, wie man diese Beziehungen in der Programmiersprache C++ direkt ausdrücken kann. Das Konzept der abgeleiteten Klasse und die damit verbundenen C++ Sprachmechanismen dienen dazu, hierarchische Beziehungen, d.h. Gemeinsamkeiten zwischen den Klassen, auszudrücken.
In diesem Unterpunkt werden wir in den Themenbereich der abgeleiteten Klassen und Vererbung einführen, den man grob in die neben abgebildeten zwei Varianten einordnen kann. Bei der Implementierungsvererbung spart sich der Programmierer oft viel Schreibarbeit und zusätzlich wird durch die Vererbungsstruktur seiner Klassen das Programmkonzept übersichtlicher und ist leichter zu verstehen. Bei der Schnittstellenvererbung spricht man auch von Polymorphie zur Laufzeit, bzw. von dynamischer Polymorphie. Die Basisklasse stellt hierbei einen Schnittstelle dar, die ähnlich einer switch-Anweisung, aus unterschiedlichen Programmsträngen auswählt und die einzelnen, möglichen case-Marken sind durch die jeweiligen abgeleiteten Klassen im Programm implementiert. Eine solche polymorphe Struktur kann man, alternativ auch elegant mittels des Template-Konzeptes formulieren, wobei man in einem solchen Fall von Polymorphie zur Übersetzungszeit, bzw. von statischer Polymorphie spricht.
Es wird nun die formale Struktur einer abgeleiteten Klasse (auch Subklasse) vorgestellt, die von einer Basisklasse (auch Oberklasse oder Superklasse) abgeleitet wird und mittels des Sprachkonzeptes der Vererbung Merkmale übernimmt.
Die abgeleitete Klasse mit dem Namen 'Sub' erbt die Klassenmerkmale der Basisklasse 'Base' und dies wird mit dem Doppelpunkt gekennzeichnet. Die zusätzliche Bezeichnung 'public' ( class Sub : public Base {...} ) kennzeichnet, dass alle die in der Basisklasse als öffentliche Daten-Member und Member-Funktionen deklarierten Größen auch für die abgeleitete Klasse als öffentlich gelten sollen. Würde man hingegen lediglich 'class Sub : Base {...}' schreiben, so wären alle vererbten Merkmale von Base privat und die abgeleitete Klasse könnte auf diese direkt nicht zugreifen.
In dem folgenden Programmbeispiel wird von einer Basisklasse 'Base' die Subklasse 'Sub' abgeleitet und so alle in ihr definierten Merkmale vererbt.
/* Beispiel einer einfachen abgeleiteten Klasse */ #include <iostream> // Ein- und Ausgabebibliothek using namespace std; // Benutze den Namensraum std //Definition der Basisklasse 'Base' class Base{ // Private Instanzvariablen (Daten-Member) der Klasse unsigned int n; double x; // Oeffentlicher Bereich der Klasse public: // Konstruktor mit zwei Argumenten Base(unsigned int set_n = 0, double set_x = 0) : n{set_n}, x{set_x} { printf("Konstruktor der Klasse Base \n"); } // Member-Funktionen der Klasse // als const deklariert, da sie die privaten Instanzvariablen nicht veraendern unsigned int get_Nummer() const {return n;} double get_Ort() const {return x;} }; //Definition der abgeleiteten Klasse 'Sub' class Sub : public Base{ // Private Instanzvariablen (Daten-Member) der Klasse double m; // Oeffentlicher Bereich der Klasse public: // Konstruktor mit drei Argumenten Sub(unsigned int set_n = 0, double set_x = 0, double set_m = 1) : Base(set_n, set_x), m{set_m} { printf("Konstruktor der Klasse Sub \n"); } // Member-Funktionen der Klasse // als const deklariert, da sie die privaten Instanzvariablen nicht veraendern double get_m() const {return m;} }; int main(){ // Hauptfunktion Base Ding_0 = Base(0, 2.5); // Konstruktor bilded eine Instanz der Klasse Base Sub Ding_1 = Sub(1, 3.5, 10.2); // Konstruktor bilded eine Instanz der abgeleiteten Klasse Sub // Terminalausgabe printf("Das Ding %2d befindet sich am Ort x=%5.2f \n", Ding_0.get_Nummer(), Ding_0.get_Ort()); printf("Das Ding %2d befindet sich am Ort x=%5.2f und hat die Masse m=%5.2f \n", Ding_1.get_Nummer(), Ding_1.get_Ort(), Ding_1.get_m()); }
Die Superklasse Base hat dabei die Struktur der in der 7. Vorlesung vorgestellten Klasse 'Ding' und besitzt zwei private Datenmember (n und x), einen öffentlichen Konstruktor und zwei öffentliche const Member-Funktionen (get_Nummer() und get_Ort()). Die von ihr abgeleitete Klasse 'Sub' erbt nun die öffentlichen Merkmale ihrer Oberklasse und kann diese so verwenden, als wären sie direkt in der Subklasse definiert. In der Subklasse wird zusätzlich eine weitere Eigenschaft, die Masse 'm' des 'Dings' als privater Datenmember definiert, auf den man von Außen mittels der öffentlichen const Member-Funktion 'get_m()' zugreifen kann. Der Konstruktor der Klasse 'Sub' hat somit ein Argument mehr als der Konstruktor der Klasse 'Base' und beim Aufruf des Konstruktors dieser abgeleiteten Klasse wird die Masse des Dings initialisiert und zusätzlich ein Objekt der Klasse 'Base' erzeugt.
Im Hauptprogramm wird dann eine Instanz (ein Objekt) der Klasse 'Base' (Base Ding_0 = Base(0, 2.5);) und ein Objekt der Klasse 'Sub' (Sub Ding_1 = Sub(1, 3.5, 10.2);) erstellt und die Eigenschaften dieser Objekte mittels der öffentlichen const Member-Funktionen im Terminal ausgegeben (siehe Abbildung unten links).
Der Umweg des Zugriffs über die öffentlichen const Member-Funktionen ist hierbei aufgrund des 'Geheimnisprinzip' der Klasse (auch 'Information Hiding', siehe Vorlesung 7) nötig, da die Kapselung der als privat deklarierten Instanzvariablen dies erfordert. Man hätte hingegen auch die gesamten Merkmale der Klassen als öffentlich deklarieren können, sodass ein Zugriff über öffentliche const Member-Funktionen nicht mehr nötig wäre. Dies ist in dem folgenden Quelltext exemplarisch gezeigt:
/* Beispiel einer einfachen abgeleiteten Klasse (alle Merkmale öffentlich) */ #include <iostream> // Ein- und Ausgabebibliothek using namespace std; // Benutze den Namensraum std //Definition der Basisklasse 'Base' class Base{ // Oeffentlicher Bereich der Klasse public: // Instanzvariablen (Daten-Member) unsigned int n; double x; // Konstruktor mit zwei Argumenten Base(unsigned int set_n = 0, double set_x = 0) : n{set_n}, x{set_x} { printf("Konstruktor der Klasse Base \n"); } }; //Definition der abgeleiteten Klasse 'Sub' class Sub : public Base{ // Oeffentlicher Bereich der Klasse public: // Instanzvariablen (Daten-Member) der Klasse double m; // Konstruktor mit drei Argumenten Sub(unsigned int set_n = 0, double set_x = 0, double set_m = 1) : Base(set_n, set_x), m{set_m} { printf("Konstruktor der Klasse Sub \n"); } }; int main(){ // Hauptfunktion Base Ding_0 = Base(0, 2.5); // Konstruktor bilded eine Instanz der Klasse Base Sub Ding_1 = Sub(1, 3.5, 10.2); // Konstruktor bilded eine Instanz der abgeleiteten Klasse Sub // Terminalausgabe printf("Das Ding %2d befindet sich am Ort x=%5.2f \n", Ding_0.n, Ding_0.x); printf("Das Ding %2d befindet sich am Ort x=%5.2f und hat die Masse m=%5.2f \n", Ding_1.n, Ding_1.x, Ding_1.m); }
Der Zugriff auf die öffentlichen Daten-Member erfolgt nun einfach mit dem Punkt-Operator (z.B. 'Ding_1.x'), wobei man nun deutlich erkennt, dass die Daten-Member der Klasse 'Base' direkt an die Klasse 'Sub' vererbt wurden und der Zugriff so erfolgt, als wäre diese direkt in der Klasse 'Sub' definiert.
Bei der Konstruktion einer Verflechtung von mehreren Klassen erschafft der Programmierer mittels der unterschiedlichen Varianten des Vererbungsmechanismus (Schnittstellenvererbung und Implementierungsvererbung) ein eigenes Konstrukt, welches dann der Anwender nutzen kann. Wir stellen uns im Folgenden die Aufgabe mittels einer Klassen-Verflechtung ein Konstrukt zu Erschaffen, welches man benutzen kann, um unterschiedlichste Formen von Pendelbewegungen mittels des Computers zu berechnen. Mit diesem Programm soll, unter anderem, die Bewegung des physikalischen und mathematischen Pendels simuliert werden können. Beim Entwurf versuchen wir so allgemein wie möglich zu sein und verwenden deshalb die im vorigen Unterpunkt konstruierte Klasse 'dsolve' zur Berechnung der Pendelbewegung, obwohl diese ja konstruiert wurde um damit allgemeine Differentialgleichungssysteme lösen zu können.
Wir möchten die Bewegung des Pendels für einen gewissen Zeitraum simulativ berechnen. Betrachten wir z.B. das einfache physikalische Pendel aus dem Themenbereich der Mechanik (siehe z.B. W.Greiner, Klassische Mechanik I). Die zugrundeliegende Differentialgleichung (DGL) des Problems lautet \[ \begin{equation} \frac{d^2 \theta(t)}{dt^2} = - \frac{g}{l} \cdot \hbox{sin}\left( \theta(t) \right) - \frac{\beta}{m} \cdot \frac{d \theta(t)}{dt} \quad , \end{equation} \] wobei $g$ die Erdbeschleunigung, $m$ und $l$ die Masse und Länge des Pendels, $\beta$ der Reibungsparameter (Stokesscher Ansatz) und $\theta(t)$ die zeitliche Entwicklung des Pendelwinkels beschreibt.
Wir schreiben nun die obere Bewegungsgleichung zweiter Ordnung in ein System von zwei miteinander gekoppelten Differentialgleichungen erster Ordnung um. Wir machen dafür die vorgegebene Variablenumbenennung ($u_1(t)=\theta(t)$ , $u_2(t)=\dot{\theta}(t) = \frac{d \theta(t)}{dt}$) und definieren das System von DGLs wie folgt: $$ \begin{eqnarray} \dot{u}_1(t) &=& \frac{d u_1}{dt} = \frac{d \theta(t)}{dt} = u_2(t) \\ \dot{u}_2(t) &=& \frac{d u_2}{dt} = \frac{d \dot{\theta}}{dt} = \frac{d^2 \theta(t)}{dt^2} = - \frac{\beta}{m} \cdot u_2(t) - \frac{g}{l} \cdot \hbox{sin}\left( u_1(t) \right) := f(t,u_1,u_2) \end{eqnarray} $$
Die numerische Lösung dieses Problems werden wir mittels der im vorigen Unterpunkt 'Projekt: Die schwingende Kette' konstruierten Klasse 'dsolve' machen (siehe C++ Programm SchwingendeKette.cpp). Das folgende C++ Programm berechnet die numerische Lösung des oben abgebildeten Systems von Differentialgleichungen und simuliert die Bewegung des physikalischen Pendels.
/* Das physikalische Pendel mit Reibung (Stokesscher Ansatz) * Berechnung der Lösung einer Differentialgleichung zweiter Ordnung * bzw. eines Systems von zwei Differentialgleichungen erster Ordnung * mittels der Runge-Kutta Ordnung vier Methode * Verfahren zur Loesung der DGL ist in eine Klasse ausgelagert * Zeitentwicklung fuer unterschiedliche t-Werte in [a,b] * Konstruktor: dsolve(Anfangszeit a, Endzeit b, Anzahl der Punkte N, Anfangswerte alpha, DGLs als Funktion) */ #include <iostream> // Standard Input- und Output Bibliothek in C, z.B. printf(...) #include <cmath> // Bibliothek für mathematisches (e-Funktion, Betrag, ...) #include <vector> // Vector-Container der Standardbibliothek #include <functional> // Funktionen in der Argumentenliste von Funktionen using namespace std; // Benutze den Namensraum std class dsolve{ //Definition der Klasse 'dsolve' double a = 0; // Untergrenze des Zeit-Intervalls [a,b] in dem die Loesung berechnet werden soll double b = 1; // Obergrenze des Intervalls [a,b] int N = 10; // Anzahl der Punkte in die das t-Intervall aufgeteilt wird vector<double> alpha = {1,0}; // Zwei Anfangswerte (Anfangswinkel und Winkelgeschwindigkeit bei t=a) vector<vector<double>> y_RungeK_4; // Deklaration eine double Vektor-Matrix zum speichern der Loesungen vector<double> Zeit; // Deklaration eines double Vektors zum speichern der Zeit-Werte public: // Konstruktor mit fünf Argumenten (Initialisierung der Parameter, Uebergabe der DGL als Funktion, Berechnung der Loesung der DGL) dsolve(double a_, double b_, int N_, vector<double> alpha_, function< vector<double>(double, vector<double>)> f) : a(a_),b(b_),N(N_),alpha(alpha_) { double h = (b - a)/N; // Abstand dt zwischen den aequidistanten Punkten des t-Intervalls (h=dt) int n = alpha_.size(); // Anzahl der Anfangswerte bzw. DGL-Gleichungen vector<double> k1(n),k2(n),k3(n),k4(n); // Deklaration der vier Runge-Kutta Parameter fuer jede Loesung vector<double> y(n); // Temporaerer Hilfsvektor Zeit.push_back(a_); // Zum Zeit-Vektor die Anfangszeit eintragen y_RungeK_4.push_back(alpha_); // Zum Loesungs-Vektor die Anfangswerte eintragen for(int i=0; i < N; ++i){ // for-Schleife ueber die einzelnen Punkte des t-Intervalls for(int j=0; j < n; ++j){k1[j] = h*f(Zeit[i],y_RungeK_4[i])[j];} // for-Schleife ueber die n Runge-Kutta k1-Parameter for(int j=0; j < n; ++j){y[j] = y_RungeK_4[i][j] + k1[j]/2;} // Temporaerer Hilfsvektor for(int j=0; j < n; ++j){k2[j] = h*f(Zeit[i]+h/2,y)[j];} // for-Schleife ueber die n Runge-Kutta k2-Parameter for(int j=0; j < n; ++j){y[j] = y_RungeK_4[i][j] + k2[j]/2;} // Temporaerer Hilfsvektor for(int j=0; j < n; ++j){k3[j] = h*f(Zeit[i]+h/2,y)[j];} // for-Schleife ueber die n Runge-Kutta k3-Parameter for(int j=0; j < n; ++j){y[j] = y_RungeK_4[i][j] + k3[j];} // Temporaerer Hilfsvektor for(int j=0; j < n; ++j){k4[j] = h*f(Zeit[i]+h,y)[j];} // for-Schleife ueber die n Runge-Kutta k4-Parameter for(int j=0; j < n; ++j){y[j] = y_RungeK_4[i][j] + (k1[j] + 2*k2[j] + 2*k3[j] + k4[j])/6;} // Temporaerer Hilfsvektor y_RungeK_4.push_back(y); // Zum Loesungs-Vektor den neuen Wert eintragen Zeit.push_back(a + (i+1)*h); // Zum Zeit-Vektor die neue Zeit eintragen } // Ende for-Schleife ueber die einzelnen Punkte des t-Intervalls }; // Ende des Konstruktors const vector<vector<double>>& get_y() const { return y_RungeK_4; } // Definition der konstanten Member-Funktion get_y(), Rueckgabewert Vektor-Matrix der Loesung der DGL const vector<double>& get_zeit() const { return Zeit; } // Definition der konstanten Member-Funktion get_zeit(), Rueckgabewert vector der zeit-Punkte }; // Ende der Klasse 'dsolve' // Definition der Bewegungsgleichung des physikalischen Pendels mit Reibung (Stokesscher Ansatz) vector<double> dgls(double t, vector<double> u_vec) { double m = 1.0; // Masse des Pendels double l = 0.5; // Länge des Pendels double beta = 0.0; // Reibungskoeffizient vector<double> du_dt(u_vec.size()); // Die zwei DGLs erster Ordnung werden als Standard-Vektor definiert du_dt[0] = u_vec[1]; // Zwei DGLs erster Ordnung du_dt[1] = - 9.81 / l * sin(u_vec[0]) - beta / m * u_vec[1]; // DGl des physikalischen Pendels mit Reibung (Stokesscher Ansatz) return du_dt; // Rueckgabewert der DGLs als Standard-Vektor } // Ende: Definition der Bewegungsgleichung // Hauptfunktion int main(){ vector<double> u_init {0.0,8.2}; // Vektor der zwei Anfangswerte (Anfangswinkel und Winkelgeschwindigkeit bei t=a) dsolve Loes1 {0,5,1000,u_init,dgls}; // Benutzt Konstruktor mit a=0, b=5, N=1000 // Terminalausgabe printf("# 0: Index i \n# 1: t-Wert \n# 2: Winkel-Koordinate theta \n# 3: Winkel-Geschwindigkeit dtheta/dt \n"); for(size_t i=0; i < Loes1.get_zeit().size(); ++i){ printf("%3ld %19.15f %19.15f %19.15f \n", i,Loes1.get_zeit()[i],Loes1.get_y()[i][0],Loes1.get_y()[i][0]); } } // Ende der Hauptfunktion
Die Klasse 'dsolve' besitzt die Fähigkeit, ihre Kernaufgabe (die Lösung einer Bewegungsgleichung) auf verschiedene ganz unterschiedliche Objekte anwenden zu können, als wären sie gleich. Sie stellt somit eine Art von Schnittstelle dar und entkoppeln die eigentliche Darstellung der konkreten Umsetzung der Idee von der Klasse selbst. Dies ist ein gutes Beispiel von Polymorphismus und wird in C++ durch Vererbung und virtuelle Funktionen erreicht. Im Folgenden möchten wir das Konzept der abgeleiteten Klasse auf das obere Programm anwenden. Wir werden dabei die Klasse 'dsolve' als eine abstrakte Basisklasse definieren, die eine reine virtuelle Funktion 'dgls' enthält. Die Klasse 'dsolve' dient dabei als eine allgemeine Schnittstelle für das Runge-Kutta-Verfahren und speichert unter anderem die Lösung der Bewegungsgleichung. Dabei kann die Klasse 'dsolve' jedoch nicht selbst instanziiert werden, da sie von sich aus kein eigenes Objekt bilden kann, ohne eine Bewegungsgleichung des Systems zu spezifizieren. Die Bewegungsgleichung wird dann erst in einer abgeleiteten Sub-Klasse (hier die Klasse 'Pendel') definiert und damit eine entsprechende Instanz gebildet. Das folgende C++ Programm verfolgt die angegebene Klassenstruktur und berechnet wieder die numerische Lösung der Bewegung des physikalischen Pendels.
/* Das physikalische Pendel mit Reibung (Stokesscher Ansatz) * Berechnung der Lösung einer Differentialgleichung zweiter Ordnung * bzw. eines Systems von zwei Differentialgleichungen erster Ordnung * mittels der Runge-Kutta Ordnung vier Methode * Verfahren zur Loesung der DGL ist in einer abstrakten Basisklasse ausgelagert * Berechnung der Lösung innerhalb einer Methode (Klassenfunktion) Runge-Kutta-Verfahren * Überschreibung der virtuellen Funktion der Bewegungsgleichungen in der abgeleiteten Klasse (Subklasse) Pendel * Zeitentwicklung der fuer unterschiedliche t-Werte in [a,b] * Konstruktor: Pendel(Masse des Pendels, Länge des Pendels l, Reibung beta, Anfangszeit a, Endzeit b, Anzahl der Punkte N, Anfangswerte alpha) * Ausgabe zum Plotten mittels Python Jupyter Notebook Pendel.py: "./a.out > Pendel.dat */ #include <iostream> // Standard Input- und Output Bibliothek in C, z.B. printf(...) #include <cmath> // Bibliothek für mathematisches (e-Funktion, Betrag, ...) #include <vector> // Vector-Container der Standardbibliothek using namespace std; // Abstrakte Basisklasse 'dsolve' class dsolve { public: // Daten-Member der Klasse double a; // Untergrenze des Zeit-Intervalls double b; // Obergrenze des Intervalls int N; // Anzahl der Punkte vector<double> alpha; // Anfangswerte vector<vector<double>> ym; // Lösungsmatrix vector<double> t; // Zeit-Vektor // Virtuelle Funktion dgls, wird an anderer Stelle definiert virtual vector<double> dgls(double t, vector<double> u_vec) = 0; // Konstruktor mit vier Argumenten (Initialisierung der Daten-Member) dsolve(double a_, double b_, int N_, vector<double> alpha_) :a(a_), b(b_), N(N_), alpha(alpha_) { t.push_back(a_); // Zum Zeit-Vektor die Anfangszeit eintragen ym.push_back(alpha_); // Zur Loesungs-Matrix die Anfangswerte eintragen } // Methode der Runge-Kutta-Methode void solve() { double h = (b - a)/N; // Abstand dt zwischen den aequidistanten Punkten des t-Intervalls (h=dt) int n = alpha.size(); // Anzahl der Anfangswerte bzw. DGL-Gleichungen (hier n=2) vector<double> k1(n),k2(n),k3(n),k4(n); // Deklaration der vier Runge-Kutta Parameter fuer jede Loesung vector<double> y(n); // Temporaerer Hilfsvektor for (int i = 0; i < N; ++i) { // for-Schleife ueber die einzelnen Punkte des t-Intervalls for (int j = 0; j < n; ++j) k1[j] = h * dgls(t[i], ym[i])[j]; // for-Schleife ueber die n Runge-Kutta k1-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k1[j] / 2; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k2[j] = h * dgls(t[i] + h / 2, y)[j]; // for-Schleife ueber die n Runge-Kutta k2-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k2[j] / 2; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k3[j] = h * dgls(t[i] + h / 2, y)[j]; // for-Schleife ueber die n Runge-Kutta k3-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k3[j]; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k4[j] = h * dgls(t[i] + h, y)[j]; // for-Schleife ueber die n Runge-Kutta k4-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + (k1[j] + 2 * k2[j] + 2 * k3[j] + k4[j]) / 6; // Temporaerer Hilfsvektor ym.push_back(y); // Zum Loesungs-Vektor den neuen Wert eintragen t.push_back(a + (i + 1) * h); // Zum Zeit-Vektor die neue Zeit eintragen } // Ende for-Schleife ueber die einzelnen Punkte des t-Intervalls } // Ende des Methode 'solve()' }; // Ende der Klasse 'dsolve' // Unterklasse 'Pendel' class Pendel : public dsolve { public: double m; // Masse des Pendels double l; // Länge des Pendels double beta; // Reibungskoeffizient // Überschreibung der virtuellen Funktion dgls vector<double> dgls(double t, vector<double> u_vec) override { vector<double> du_dt(u_vec.size()); // Die zwei DGLs erster Ordnung werden als Standard-Vektor definiert du_dt[0] = u_vec[1]; // Zwei DGLs erster Ordnung du_dt[1] = - 9.81 / l * sin(u_vec[0]) - beta / m * u_vec[1]; // DGl des physikalischen Pendels mit Reibung (Stokesscher Ansatz) return du_dt; // Rueckgabewert der DGLs als Standard-Vektor } // Ende: Definition der Bewegungsgleichung // Konstruktor mit sieben Argumenten (Initialisierung der Instanzvariablen (m,l,beta), vier Argumente für dsolve) Pendel(double m_ = 1.0, double l_ = 1.0, double beta_ = 0.0, double a_ = 0, double b_ = 1, int N_ = 10, vector<double> alpha_ = {0.1, 0}) : dsolve(a_, b_, N_, alpha_), m(m_), l(l_), beta(beta_) {} // Methode der Ausgabe der kartesische Koordinaten des Pendels am Stützpunkt i vector<double> r(size_t i) { vector<double> w = {0.0,0.0}; if (i < t.size()) { w = {l*sin(ym[i][0]), -l*cos(ym[i][0])};} return w; } // Methode der Ausgabe der Geschwindigkeit des Pendels in kartesische Koordinaten am Stützpunkt i vector<double> v(size_t i) { vector<double> w = {0.0,0.0}; if (i < t.size()) { w = {l*cos(ym[i][0])*ym[i][1], l*sin(ym[i][0])*ym[i][1]};} return w; } }; // Ende der Unterklasse 'Pendel' // Hauptprogramm int main() { double m = 1.0; // Masse des Pendels double l = 0.5; // Länge des Pendels double beta = 0; // Reibungskoeffizient double a = 0; // Untergrenze des Zeit-Intervalls double b = 5; // Obergrenze des Intervalls int N = 1000; // Anzahl der Punkte vector<double> alpha = {0.0,8.2}; // Anfangswerte Pendel P_A = Pendel(m,l,beta,a,b,N,alpha); // Konstruktor bilded eine Instanz der Klasse Pendel P_A.solve(); // Methode der Runge-Kutta-Methode ausführen // Terminalausgabe printf("# 0: Index i \n# 1: t-Wert \n# 2: x-Koordinate \n# 3: y-Koordinate \n# 4: Geschwindigkeit dx/dt \n# 5: Geschwindigkeit dy/dt \n"); for(size_t i=0; i < P_A.t.size(); ++i){ printf("%3ld %19.15f %19.15f %19.15f %19.15f %19.15f \n",i,P_A.t[i],P_A.r(i)[0],P_A.r(i)[1],P_A.v(i)[0],P_A.v(i)[1]); } } // Ende Hauptprogramm
Die Klasse 'dsolve' agiert hierbei als eine abstrakte Schnittstellen-Basisklasse, welche die Eigenschaft besitzt, eine Bewegungsgleichung lösen zu können, jedoch für eine Objekt-Erzeugung noch die Festlegung der Differentialgleichung des betrachteten Systems benötigt. Allgemein bezeichnet man eine Klasse mit mindestens einer reinen virtuellen Funktion als abstrakt ('rein' bedeutet hier, dass die virtuelle Funktion nur deklariert wird und die eigentliche Definition außerhalb der Klasse geschieht). Die Klasse 'dsolve' ist somit aufgrund der rein virtuellen Funktion 'virtual vector<double> dgls(double t, vector<double> u_vec) = 0;' eine abstrakte Klasse und kann nicht instanziiert werden. Der Konstruktor in 'dsolve' initialisiert dabei nur die Daten-Member und die eigentliche Runge-Kutta-Methode der Lösung der Bewegungsgleichung ist innerhalb einer Methode 'solve()' definiert. Bei abstrakten Klassen wird generell ein virtueller Destruktor empfohlen, um auch bei komplizierteren Subklassen ein ordnungsgemäßes Aufräumen sicherzustellen (z.B. mittels virtual ~dsolve() = default;) - in unserem Fall ist dies jedoch nicht zwingend erforderlich.
Von der abstrakten Basisklasse 'dsolve' wurde dann die Unterklasse 'Pendel' abgeleitet. Diese Subklasse 'Pendel' überschreibt die rein virtuelle Funktion 'dgls' und definiert somit die Bewegungsgleichung des physikalischen Pendels. Mittels des Konstruktors der Pendel-Klasse ist es nun möglich, eine Instanz eines Pendel-Objektes zu erstellen. Der Pendel-Konstruktor ruft dabei den Konstruktor der abstrakten Basisklasse auf und initialisiert seine eigenen Instanzvariablen (Masse $m$ und Länge $l$ des Pendels und der Reibungskoeffizient $\beta$). Zusätzlich werden in der Pendel-Klasse noch zwei Methoden definiert, die den Ort und die Geschwindigkeit des Pendels an einer gewünschten zeitlichen Stützstelle in kartesischen Koordinaten ausgeben. Im Hauptprogramm wird dann eine Instanz der Pendelklasse erstellt und die vererbte Funktion 'solve()' aufgerufen. Am Ende werden die berechneten Lösungswerte der Pendelbewegung im Terminal ausgegeben. Das bis jetzt besprochene Beispiel einer abgeleiteten Klasse ist hauptsächlich eine Art der Schnittstellen-Vererbung. Im Folgenden werden wir eine weitere Variante der Vererbung kennenlernen, die sogenannte 'gemeinsame Implementierungs' Variante. Zusätzlich werden wir sehen, wie man Unterklassen von Unterklassen (Sub-Subklassen) erstellt.
Wir möchten nun auch die lineare Näherung des Pendels (den harmonischen Oszillator mit Reibung) simulieren. Da wir viele der schon in der Pendelklasse definierten Konstrukte wieder benötigen, ist es sinnvoll, die neue Klasse des mathematischen Pendels als eine abgeleitete Klasse der Klasse 'Pendel' zu definieren (class Pendel_math : public Pendel { ... }). Der Übersichtlichkeit halber trennen wir den Quelltext des Hauptprogramms (Pendel.cpp) von dem Quelltext der Klassen (Pendel.hpp). Die Header-Datei, welche die gesamte Klassenhierarchie zusammenfasst, besitzt das folgende Aussehen:
/* Header-Datei * Das physikalische und mathematische Pendel mit Reibung (Stokesscher Ansatz) * Berechnung der Lösung einer Differentialgleichung zweiter Ordnung * bzw. eines Systems von zwei Differentialgleichungen erster Ordnung * mittels der Runge-Kutta Ordnung vier Methode * Verfahren zur Loesung der DGL ist in einer abstrakten Basisklasse ausgelagert * Berechnung der Lösung innerhalb einer Methode (Klassenfunktion) Runge-Kutta-Verfahren * Überschreibung der virtuellen Funktion der Bewegungsgleichungen in der abgeleiteten Klasse (Subklasse) Pendel * Zeitentwicklung der fuer unterschiedliche t-Werte in [a,b] * Konstruktor: Pendel(Masse des Pendels, Länge des Pendels l, Reibung beta, Anfangszeit a, Endzeit b, Anzahl der Punkte N, Anfangswerte alpha) * bzw. Konstruktor: Pendel_math(...) */ #include <cmath> // Bibliothek für mathematisches (e-Funktion, Betrag, ...) #include <vector> // Vector-Container der Standardbibliothek using namespace std; // Benutze den Namensraum std // Abstrakte Basisklasse 'dsolve' class dsolve { public: // Daten-Member der Klasse double a; // Untergrenze des Zeit-Intervalls double b; // Obergrenze des Intervalls int N; // Anzahl der Punkte vector<double> alpha; // Anfangswerte vector<vector<double>> ym; // Lösungsmatrix vector<double> t; // Zeit-Vektor // Virtuelle Funktion dgls, wird an anderer Stelle definiert virtual vector<double> dgls(double t, vector<double> u_vec) = 0; // Konstruktor mit vier Argumenten (Initialisierung der Daten-Member) dsolve(double a_, double b_, int N_, vector<double> alpha_) :a(a_), b(b_), N(N_), alpha(alpha_) { t.push_back(a_); // Zum Zeit-Vektor die Anfangszeit eintragen ym.push_back(alpha_); // Zur Loesungs-Matrix die Anfangswerte eintragen } // Methode der Runge-Kutta-Methode void solve() { double h = (b - a)/N; // Abstand dt zwischen den aequidistanten Punkten des t-Intervalls (h=dt) int n = alpha.size(); // Anzahl der Anfangswerte bzw. DGL-Gleichungen (hier n=2) vector<double> k1(n),k2(n),k3(n),k4(n); // Deklaration der vier Runge-Kutta Parameter fuer jede Loesung vector<double> y(n); // Temporaerer Hilfsvektor for (int i = 0; i < N; ++i) { // for-Schleife ueber die einzelnen Punkte des t-Intervalls for (int j = 0; j < n; ++j) k1[j] = h * dgls(t[i], ym[i])[j]; // for-Schleife ueber die n Runge-Kutta k1-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k1[j] / 2; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k2[j] = h * dgls(t[i] + h / 2, y)[j]; // for-Schleife ueber die n Runge-Kutta k2-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k2[j] / 2; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k3[j] = h * dgls(t[i] + h / 2, y)[j]; // for-Schleife ueber die n Runge-Kutta k3-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + k3[j]; // Temporaerer Hilfsvektor for (int j = 0; j < n; ++j) k4[j] = h * dgls(t[i] + h, y)[j]; // for-Schleife ueber die n Runge-Kutta k4-Parameter for (int j = 0; j < n; ++j) y[j] = ym[i][j] + (k1[j] + 2 * k2[j] + 2 * k3[j] + k4[j]) / 6; // Temporaerer Hilfsvektor ym.push_back(y); // Zum Loesungs-Vektor den neuen Wert eintragen t.push_back(a + (i + 1) * h); // Zum Zeit-Vektor die neue Zeit eintragen } // Ende for-Schleife ueber die einzelnen Punkte des t-Intervalls } // Ende des Methode 'solve()' }; // Ende der Klasse 'dsolve' // Sub-Klasse 'Pendel' class Pendel : public dsolve { public: double m; // Masse des Pendels double l; // Länge des Pendels double beta; // Reibungskoeffizient // Überschreibung der virtuellen Funktion dgls vector<double> dgls(double t, vector<double> u_vec) override { vector<double> du_dt(u_vec.size()); // Die zwei DGLs erster Ordnung werden als Standard-Vektor definiert du_dt[0] = u_vec[1]; // Zwei DGLs erster Ordnung du_dt[1] = - 9.81 / l * sin(u_vec[0]) - beta / m * u_vec[1]; // DGl des physikalischen Pendels mit Reibung (Stokesscher Ansatz) return du_dt; // Rueckgabewert der DGLs als Standard-Vektor } // Ende: Definition der Bewegungsgleichung // Konstruktor mit sieben Argumenten (Initialisierung der Instanzvariablen (m,l,beta), vier Argumente für dsolve) Pendel(double m_ = 1.0, double l_ = 1.0, double beta_ = 0.0, double a_ = 0, double b_ = 1, int N_ = 10, vector<double> alpha_ = {0.1, 0}) : dsolve(a_, b_, N_, alpha_), m(m_), l(l_), beta(beta_) {} // Methode der Ausgabe der kartesische Koordinaten des Pendels am Stützpunkt i vector<double> r(size_t i) { vector<double> w = {0.0,0.0}; if (i < t.size()) { w = {l*sin(ym[i][0]), -l*cos(ym[i][0])};} return w; } // Methode der Ausgabe der Geschwindigkeit des Pendels in kartesische Koordinaten am Stützpunkt i vector<double> v(size_t i) { vector<double> w = {0.0,0.0}; if (i < t.size()) { w = {l*cos(ym[i][0])*ym[i][1], l*sin(ym[i][0])*ym[i][1]};} return w; } }; // Ende der Unterklasse 'Pendel' //Definition der Sub-Sub-Klasse 'Pendel_math' (mathematische Pendel, harmonischer Oszillator) als abgeleitete Klasse der Unterklasse 'Pendel' class Pendel_math : public Pendel { public: // Konstruktor mit sieben Argumenten (Initialisierung der Instanzvariablen (m,l,beta), vier Argumente für dsolve) Pendel_math(double m_ = 1.0, double l_ = 1.0, double beta_ = 0.0, double a_ = 0, double b_ = 1, int N_ = 10, vector<double> alpha_ = {0.1, 0}) : Pendel(m_, l_, beta_, a_, b_, N_, alpha_){} // Überschreibung der virtuellen Funktion dgls vector<double> dgls(double t, vector<double> u_vec) override { vector<double> du_dt(u_vec.size()); // Die zwei DGLs erster Ordnung werden als Standard-Vektor definiert du_dt[0] = u_vec[1]; // Zwei DGLs erster Ordnung du_dt[1] = - 9.81 / l * u_vec[0] - beta / m * u_vec[1]; // DGl des mathematischen Pendels mit Reibung (Stokesscher Ansatz) return du_dt; // Rueckgabewert der DGLs als Standard-Vektor } // Ende: Definition der Bewegungsgleichung }; // Ende der Klasse 'Pendel_math'
Diese Header-Datei bindet man im folgenden Hauptprogramm Pendel.cpp mittels '#include "Pendel.hpp"'ein. Die zusätzlich eingefügte Klasse 'Pendel_math' wird als Subklasse der Klasse Pendel definiert und die Klasse erbt somit ihre Daten-Member und Member-Funktionen und man spart sich somit viel Schreibarbeit durch diese 'gemeinsame Implementierung'. Der Konstruktor der Klasse 'Pendel_math' bildet eine Instanz der Klasse 'Pendel' und überschreibt die virtuelle Funktion 'dgls' (nun DGL des harmonischen Oszillators mit Reibung). Die Klasse 'Pendel_math' ist ein typisches Beispiel einer Implementierungsvererbung, wobei die abgeleitete Klasse die Instrumente der ihrer Oberklasse 'Pendel' erbt. Der Programmierer spart sich durch eine solche Implementierung Schreibaufwand und die übergeordnete Struktur des Programms wird übersichtlicher. Da die Klasse 'Pendel' selbst eine Unterklasse der abstrakten Klasse 'dsolve' ist, bezeichnet man die Klasse 'Pendel_math' als eine Sub-Subklasse.
Im Hauptprogramm Pendel.cpp (siehe folgender C++ Quelltext) wird nun eine Instanz der Klasse 'Pendel' ( Pendel P_A = Pendel(m,l,beta,a,b,N,alpha); ) und eine Instanz der Klasse 'Pendel_math' ( Pendel_math P_B = Pendel_math(m,l,beta,a,b,N,alpha); ) erzeugt, wobei die folgenden, gleichen Parameter an die beiden Konstruktoren der Klasse übergeben wurden: $m=1$ [Kg], $l=0.5$ [meter], $\beta = 0$ [Kg/s], $\theta(0)=0$ [rad], $\dot{\theta}(0)=8.2$ [rad/s]. Die Bewegung der beiden Pendel wird von $a=0$ [s] bis $b=5$ [s] simuliert, wobei $N=1000$ Zeit-Gitterpunkte bei der Runge-Kutta Methode verwendet werden.
/* Das physikalische und mathematische Pendel mit Reibung (Stokesscher Ansatz) * Berechnung der Lösung einer Differentialgleichung zweiter Ordnung * bzw. eines Systems von zwei Differentialgleichungen erster Ordnung * mittels der Runge-Kutta Ordnung vier Methode * Verfahren zur Loesung der DGL ist in einer abstrakten Basisklasse ausgelagert * Berechnung der Lösung innerhalb einer Methode (Klassenfunktion) Runge-Kutta-Verfahren * Überschreibung der virtuellen Funktion der Bewegungsgleichungen in der abgeleiteten Klasse (Subklasse) Pendel * Zeitentwicklung der fuer unterschiedliche t-Werte in [a,b] * Konstruktor: Pendel(Masse des Pendels, Länge des Pendels l, Reibung beta, Anfangszeit a, Endzeit b, Anzahl der Punkte N, Anfangswerte alpha) * bzw. Konstruktor: Pendel_math(...) * Ausgabe zum Plotten mittels Python Jupyter Notebook Pendel.py: "./a.out > Pendel.dat */ #include "Pendel.hpp" // Header-Datei des physikalischen und mathematischen Pendels mit Reibung (Stokesscher Ansatz) #include <iostream> // Standard Input- und Output Bibliothek in C, z.B. printf(...) using namespace std; // Hauptprogramm int main() { double m = 1.0; // Masse des Pendels double l = 0.5; // Länge des Pendels double beta = 0; // Reibungskoeffizient double a = 0; // Untergrenze des Zeit-Intervalls double b = 5; // Obergrenze des Intervalls int N = 1000; // Anzahl der Punkte vector<double> alpha = {0.0,8.2}; // Anfangswerte Pendel P_A = Pendel(m,l,beta,a,b,N,alpha); // Konstruktor bilded eine Instanz der Klasse Pendel P_A.solve(); // Methode der Runge-Kutta-Methode ausführen Pendel_math P_B = Pendel_math(m,l,beta,a,b,N,alpha); // Konstruktor bilded eine Instanz der Klasse Pendel_math P_B.solve(); // Methode der Runge-Kutta-Methode ausführen // Terminalausgabe, Beschreibung der ausgegebenen Groessen printf("# 0: Index i \n# 1: t-Wert \n"); printf("# 2: x(t) , physikalisches Pendel \n# 3: y(t) , physikalisches Pendel \n"); printf("# 4: v_x(t) , physikalisches Pendel \n# 5: v_y(t) , physikalisches Pendel \n"); printf("# 6: x(t) , mathematisches Pendel \n# 7: y(t) , mathematisches Pendel \n"); printf("# 8: v_x(t) , mathematisches Pendel \n# 9: v_y(t) , mathematisches Pendel \n"); // Terminalausgabe, ausgegeben der Werte for(size_t i=0; i < P_A.t.size(); ++i){ printf("%3ld %19.15f ",i,P_A.t[i]); printf("%19.15f %19.15f %19.15f %19.15f ",P_A.r(i)[0],P_A.r(i)[1],P_A.v(i)[0],P_A.v(i)[1]); printf("%19.15f %19.15f %19.15f %19.15f ",P_B.r(i)[0],P_B.r(i)[1],P_B.v(i)[0],P_B.v(i)[1]); printf("\n"); } } // Ende Hauptprogramm
Beim Ausführen des Programmes werden die berechneten Daten des physikalischen und mathematischen Pendels im Terminal ausgegeben:
Mittels des folgenden Pythonskriptes kann man sich dann die Bewegungen der beiden Pendel visualisieren. Hierfür speichern Sie bitte wieder die Terminalausgabe mit './a.out > Pendel.dat' in die Datei 'Pendel.dat'.
# Python Programm zum Visualisieren der Daten des Pendel.cpp Programms, # Mathematisches vs. physikalisches Pendel ohne Reibung # Es werden hier mehrere Bilder der zeitlichen Entwicklung des Systems in einem Ordner 'Bilder' gespeichert # !!!! Sie muessen vor der Ausfuehrung des Programms den Ordner Bilder erstellen !!!! # Die einzelnen Bilder kann mann dann mittels des folgenden Terminalbefehls zu einem Video binden: # ffmpeg -framerate 200 -i './Pendel_%04d.jpg' -s 800x800 Pendel.mp4 # (die Framerate ergibt sich durch N_sim/b = 1000/5) import matplotlib import matplotlib.pyplot as plt # Python Bibliothek zum Plotten (siehe https://matplotlib.org/ ) import numpy as np # Python Bibliothek fuer Mathematisches (siehe https://numpy.org/ ) import matplotlib.gridspec as gridspec params = { 'figure.figsize' : [8,8], # 'text.usetex' : True, 'axes.titlesize' : 18, 'axes.labelsize' : 15, 'xtick.labelsize' : 15 , 'ytick.labelsize' : 15 } matplotlib.rcParams.update(params) data = np.genfromtxt("./Pendel.dat") # Einlesen der berechneten Daten von Pendel.cpp for it in range(len(data[:,0])): # for-Schleife fuer die zeitliche Entwicklung der Dinge in der Kiste print(it) # Terminalausgabe der Erstellung des i-ten Bildes plt.cla() plt.title(r'Mathematisches Pendel ohne Reibung') # Titel der Abbildung # plt.title(r'Physikalisches Pendel ohne Reibung') # Titel der Abbildung plt.xlabel('x') # Beschriftung x-Achse plt.ylabel('y') # Beschriftung y-Achse plt.xlim(-0.6, 0.6) # Limitierung der x-Achse plt.ylim(-0.6, 0.6) # Limitierung der y-Achse plt.scatter( data[it,6], data[it,7], s=50, marker='o', c="red") # Zeichnen des Pendel-Koerpers plt.plot( [0 , data[it,6]] , [0 , data[it,7]] ,c="blue",linewidth=1) # Zeichnen der Pendel-Stange # plt.scatter( data[it,2], data[it,3], s=50, marker='o', c="red") # Zeichnen des Pendel-Koerpers # plt.plot( [0 , data[it,2]] , [0 , data[it,3]] ,c="blue",linewidth=1) # Zeichnen der Pendel-Stange # Bild-Ausgabe mit Speicherung eines individuellen Iteration-Namens pic_name = "./Bilder/" + "Pendel_" + "{:0>4d}".format(it) + ".jpg" plt.savefig(pic_name, dpi=200,bbox_inches="tight",pad_inches=0.05,format="jpg") plt.close()
Das Pythonskript Pendel.py erstellt 1001 separate Bilder der zeitlichen Entwicklung eines der Pendel und speichert diese in einem Unterordner 'Bilder', den Sie vor dem Ausführen des Python-Programmes erstellt haben müssen. Die produzierten Bilder kann man sich dann in einer Animation als Film zusammenfügen. Hierfür wurde das Programm FFmpeg (siehe auch FFmpeg, Dokumentation und FFmpeg, Ubuntu Wiki) verwendet. Nachdem man sich das Programm installiert hat, öffnet man sich ein Terminal in dem Verzeichnis 'Bilder' und erzeugt mittels des folgenden Befehls das Video 'Pendel.mp4': 'ffmpeg -framerate 200 -i './Pendel_%04d.jpg' -s 800x800 Pendel.mp4'. Die Framerate bezeichnet hierbei die Geschwindigkeit der Abfolge der einzelnen Bilder in Bilder/Sekunde. Der gewählte Wert von '-framerate 200' ergibt sich dadurch, dass wir 1001 Bilder in einem Zeitintervall von $(b-a)=5$ [s] produziert haben, und man somit eine Bildgeschwindigkeit von $1001/5 \approx 200$ [Bilder/s] hat. Die unten abgebildeten Animationen visualisieren die Bewegung des physikalischen und mathematischen Pendels.
Führt man die Simulation mit Stokesscher Reibung durch (hier speziell $\beta = 0.8$), so erhält man die folgenden gedämpften Bewegungen der beiden Pendel:
Abschließend sollte erwähnt werden, dass eine rein abstrakte Klasse dadurch definiert, dass sie ausschließlich virtuelle Daten-Member und Funktionen definiert, die dann in den jeweiligen Unterklassen überschrieben werden. Die in den Pendelklassen verwendete abstrakte Klasse 'dsolve' stellt somit keine rein abstrakte Klasse dar.
In dem vorigen Unterpunkt wurde in das C++ Konstrukt der abgeleiteten Klasse eingeführt. Besitzt eine abgeleitete Klasse gleichzeitig mehrere Oberklassen, so spricht man von einer Mehrfachvererbung. Die C++ Struktur einer solchen Mehrfachvererbung besitzt formal das folgende Aussehen:
Die abgeleitete Klasse mit dem Namen 'Sub' erbt hierbei die Klassenmerkmale von der Basisklassen 'Base_1' und 'Base_1'. In umfangreichen C++ Programmen baut sich somit eine Klassenhierarchie bestehend aus Basis-, Sub- und Sub-Subklassen auf.