Spezielle Konzepte der strukturierten Programmierung in C++: call by reference, Rekursion, Function Templates

Funktionsaufrufe können in C++ entweder mit call by value oder call by reference realisiert werden; beide werden vorgestellt und diskutiert. Rekursionen ermöglichen oft schlanke Quelltexte, die aber schwerer verstĂ€ndlich sind als die entsprechende Implementierung mit Schleifen; einige Beispiele sollen an die Verwendung von Rekursionen heranfĂŒhren. Die sogenannten Function Templates werden in der C++-Standard-Bibliothek oft eingesetzt; sie sind in einem gewissen Sinn eine Verallgemeinerung des Überladens von Funktionen.
Noch keine Stimmen abgegeben
Noch keine Kommentare

Einordnung des Artikels

In den beiden vorhergehenden Kapiteln C++: Strukturierte Programmierung mit Funktionen und Weitere Konzepte der strukturierten Programmierung in C++: Einsatz von Vektoren, GĂŒltigkeitsbereiche wurde ein kleines Statistik-Projekt vorbereitet. Mit diesem wird hier weitergearbeitet und daran werden die neuen Konzepte erlĂ€utert.

Call by value und call by reference

Funktionsaufruf mit call by value

Mit dem VerstĂ€ndnis fĂŒr die GĂŒltigkeitsbereiche von Variablen (siehe Der GĂŒltigkeitsbereich einer Variable (''scope'') im Kapitel Elementare Syntax: Schleifen) ist auch besser zu verstehen, was beim Aufruf einer Funktion geschieht.

Die Funktion mean() soll wieder folgendermaßen implementiert sein:

// Implementierung der Funktion mean()
double mean(double x1, double x2)                           
{
    double mean = (x1 + x2) / 2;
    return mean;
}

Was passiert beim Aufruf der Funktion mean() — etwa aus der main()-Methode heraus — durch:

double meanValue = mean(1, 7);

Die Abarbeitung der main()-Methode unterbricht und im Prozessor wird die Funktion mean() abgearbeitet. Damit dies geschehen kann, mĂŒssen aber zunĂ€chst die beiden lokalen Variablen x1 und x2 mit den Werten 1 und 7 belegt werden. Das heißt aber nichts anderes als dass diese Werte in die entsprechenden Register kopiert werden mĂŒssen. Man nennt diesen Mechanismus eines Funktionsaufrufes, der immer mit dem Kopieren von Werten verbunden ist, call by value.

Oben wurde aber auch schon mit der ĂŒberladenen Funktion mean() gearbeitet, die auch in folgender Version verwendet wurde (hier nur die Deklaration):

// Berechnet den Mittelwert eines Vektors (Zufallsfolge randomSequence = rs)
double mean(std::vector<int> rs);

Beim Aufruf dieser Funktion muss ein Vektor kopiert werden.

Funktionsaufruf mit call by reference

Der Mechanismus call by value stĂ¶ĂŸt irgendwann an seine Grenzen: Werden beim Aufruf einer Funktion sehr große Objekte ĂŒbergeben (groß in dem Sinn, dass sie viel Speicherplatz belegen), ist dies immer mit aufwendigen KopiervorgĂ€ngen verbunden. Gibt es dafĂŒr eine effektivere Lösung?

Am Beispiel der swap()-Funktion soll nun ein anderer Mechanismus vorgestellt werden. Die swap()-Funktion wurde bisher nur im Pseudocode vorgestellt (siehe Lösung der Aufgabe zum Sortieren dreier Zahlen in Lösungshinweise zu den Aufgaben aus Bedingung, Alternative und Mehrfachalternative). Sie dient dem Vertauschen der Werte zweier Variablen:

int a {1};
int b {2};

swap(a, b);

cout << a << endl;          // 2
cout << b << endl;          // 1

Das Beispiel zeigt, wie man die swap()-Funktion einsetzen möchte: Nach ihrem Aufruf (Zeile 4) sind die Werte von a und b vertauscht (Zeile 6 und 7). Doch wie wird die swap()-Funktion realisiert?

Der Mechanismus call by value kopiert die Werte von a und b in den Funktions-Körper von swap(); aber von dort aus können die Variablen a und b (aus Zeile 1 und 2, also im GĂŒltigkeitsbereich außerhalb der swap()-Funktion) nicht verĂ€ndert werden.

Die Implementierung der swap()-Funktion sieht folgendermaßen aus:

void swap(int & x, int & y){

    int temp;
    temp = x;
    x = y;
    y = temp;
}

Dass die Eingabewerte hier vom Datentyp int sind ist irrelevant, man könnte auch double oder beliebige andere Datentypen wÀhlen.

Das wirklich Neue an der Funktion swap() sind die & -Zeichen in der Parameter-Liste, die bisher noch nicht besprochen wurden (Zeile 1); das & -Zeichen hat nichts mit dem logischen UND zu tun. Man sollte

int & x

folgendermaßen lesen: x ist eine Referenz auf eine int-Variable, nĂ€mlich diejenige Variable, die beim Aufruf der Funktion swap() an erster Stelle in den runden Klammern stand.

Gemeint ist damit, dass durch den Aufruf von swap(a, b) wie im Beispiel oben, die Möglichkeit geschaffen wird, im Funktionskörper von swap() auf die Variablen a und b zuzugreifen, die eigentlich ihren GĂŒltigkeitsbereich außerhalb der Funktion haben. Die beiden Variablen x und y, mit denen im Funktionskörper von swap() gearbeitet wird, sind also Stellvertreter fĂŒr a und b; man sagt daher auch, eine Referenz ist ein Alias-Name fĂŒr eine Variable, als ein neuer Name, unter dem die Variable angesprochen werden kann.

Man muss sich an dieser Stelle unbedingt klarmachen, dass die Berechnungen, die im Funktionskörper von swap() mit x und y ausgefĂŒhrt werden, die Variablen a und b verĂ€ndern. Jedes Erzeugen von Referenzen muss also gut bedacht sein, da man damit Seiteneffekte erzeugen kann, die womöglich unerwĂŒnscht sind. Gerade bei verschachtelten Funktionsaufrufen kann man leicht die Kontrolle verlieren, welche Variable durch welche Aktion verĂ€ndert wird.

Und man beachte, dass am Aufruf einer Funktion niemals erkennbar ist, ob es sich um call by reference oder call by value handelt; je nachdem besteht die Möglichkeit, dass im Funktionskörper die Variablen aus dem Funktionsaufruf verÀndert werden oder nicht. Mit welchem Mechanismus eine Funktion arbeitet erkennt man nur an der Deklaration der Funktion (die Argumente, die ein & -Zeichen enthalten, sind Referenzen).

Nachdem geklÀrt ist, was unter einer Referenz auf eine Variable zu verstehen ist, ergibt sich sofort, dass eine Funktion wie swap() nur mit Variablen aber niemals mit Zahlen aufgerufen werden kann:

int a {1};
int b {2};

swap(a, b);         // korrekt
swap(1, 2);         // Compiler-Fehler

Zeile 4: korrekter Aufruf mit Variablen, deren Werte vertauscht werden.

Zeile 5: Zahlen entsprechen nicht dem Datentyp, der fĂŒr die Argumente der swap()-Funktion verlangt wird.

Man beachte den Unterschied zu einer Funktion, die den Aufruf der Argumente mit call by value realisiert, wie zum Beispiel die mehrfach diskutierte Funktion mean() zur Mittelwert-Berechnung:

int a {1};
int b {2};

mean(a, b);         // korrekt
mean(1, 2);         // korrekt

Hier sind beide Aufrufe syntaktisch korrekt.

Nach dem was frĂŒher zum Überladen von Funktionen geasgt wurde, könnte man erwarten, dass zusĂ€tzlich zur oben definierten swap()-Funktion eine weitere Funktion mit anderer Signatur angeboten werden darf:

void swap(int & x, int & y);
void swap(int x, int y);

Dies ist nicht erlaubt, da jetzt der Aufruf der swap()-Funktion nicht mehr eindeutig ist, wenn er mit zwei Variablen erfolgt.

Einsatz von call by reference

Zugriff auf Variable außerhalb der Funktion

Die swap()-Funktion oben war ein erstes Beispiel, wie man den Mechanismus call by reference einsetzen kann (oder muss): möchte man aus einer Funktion heraus auf außerhalb definierte Variable zugreifen, mĂŒssen diese Variablen per Referenz an die Funktion ĂŒbergeben werden. Die Gefahr dabei ist natĂŒrlich, dass man Seiteneffekte erzeugt, die nicht erwĂŒnscht oder schwer kontrollierbar sind.

Vermeiden von KopiervorgÀngen

Der Einsatz von call by reference wird dann empfohlen, wenn man KopiervorgÀnge vermeiden möchte.

Beispiel:

Es wurde schon mehrfach die Mittelwert-Berechnung mit Hilfe der Funktion mean() besprochen:

double mean(double x1, double x2)
{
    return (x1 + x2) / 2;
}

Man möchte diese Funktion eventuell anbieten als eine Mittelwert-Berechnung fĂŒr zwei Vektoren (Implementierung angedeutet):

vector<double> mean(vector<double> x1, vector<double> x2)
{
    // neuen Vektor v definieren
    // Mittelwert komponentenweise berechnen und den Komponenten von v zuordnen
    // return v;    
}

Um die KopiervorgÀnge zu vermeiden, kann man dies auch mit call by reference realisieren:

// Mittelwert zweier Vektoren mit call by reference
void mean(vector<double> & x1, vector<double> & x2, vector<double> & result)
{
    int n = x1.size();                  // besser: alle drei Dimensionen abfragen und n = Minimum setzen
    for (int i = 0; i < n; i++)
    {
        result[i] = mean(x1[i], x2[i]);         // RĂŒckgriff auf die bekannte mean()-Funktion
    }
}

// Aufruf in main():
vector<double> x {1, 3, 5};             // Initialisierungsliste fĂŒr Vektor x
vector<double> y {5, 3, 1};             // Initialisierungsliste fĂŒr Vektor y

vector<double> m(3);                    // Deklaration des Vektors, der den Mittelwert annehmen soll

mean(x, y, m);                  // m = (3, 3, 3)

Zur ErklÀrung:

  1. Die neue mean()-Funktion besitzt jetzt drei Eingabewerte, die alle als Referenzen auf Vektoren definiert sind (Zeile 2).
  2. In der Implementierung werden diese drei Vektoren behandelt wie bereits definierte Vektoren (dass es sich um Referenzen handelt, ist nicht zu erkennen).
  3. Es wird kein RĂŒckgabewert berechnet, dieser ist in der Referenz des Vektors result enthalten; der Vektor wird in Zeile 7 mit neuen Werten belegt.
  4. In Zeile 7 wird dazu auf die bereits bekannte mean()-Funktion zurĂŒckgegriffen, die hier nicht nochmal aufgefĂŒhrt wird.
  5. Zum Aufruf der neuen mean()-Funktion werden zuerst zwei Vektoren initialisiert (mit Hilfe der Initialisierungsliste, Zeile 12 und 13).
  6. Der Vektor m, der spÀter den Mittelwert der beiden Vektoren x und y reprÀsentieren soll, wird nur deklariert, aber nicht initialisiert (Zeile 15).

Das SchlĂŒsselwort const: Vermeidung von Seiteneffekten

Die drei Vektoren x1, x2, result in der Funktion mean() oben unterscheiden sich grundlegend:

  1. Auf die Vektoren x1 und x2 wird nur lesend zugegriffen, sie werden wÀhrend der Abarbeitung der Befehle von mean() nicht verÀndert.
  2. Dagegen wird auf result schreibend zugegriffen: der Vektor wird sogar erst innerhalb von mean() initialisiert. Und diese Initialisierung muss dort sichtbar werden, von wo aus die Funktion mean() aufgerufen wurde.

Um diesen Unterschied auszudrĂŒcken und vor allem um zu vermeiden, dass innerhalb von mean() die Vektoren x1 und x2 verĂ€ndert werden, kann man x1 und x2 in der Definition on mean() als const kennzeichnen:

void mean(const vector<double> & x1, const vector<double> & x2, vector<double> & result);            // Deklaration von mean()

Versucht man jetzt in der Implementierung von mean() schreibend auf eine der const-Variablen zuzugreifen, erhĂ€lt man einen Compiler-Fehler (siehe Zeile 7 unten); man kann so unerwĂŒnschten Seiteneffekten vorbeugen.

void mean(const vector<double> & x1, const vector<double> & x2, vector<double> & result)
{
    int n = x1.size();
    for (int i = 0; i < n; i++)
    {
        result[i] = mean(x1[i], x2[i]);
        x1[i] = 0;                  // Compiler-Fehler: Zuweisung an read-only-Variable
    }
}

Rekursion

Bisher wurden Funktionen so eingesetzt, dass ihnen Konstanten (meist Zahlen) oder Variablen ĂŒbergeben werden. Wenn es der Prototyp der Funktion erlaubt, kann natĂŒrlich eine Funktion auch sich selbst aufrufen; man spricht in diesem Fall von einer rekursiven Funktion oder kurz Rekursion.

Die FakultÀt

Paradebeispiel einer rekursiven Funktion ist die FakultĂ€t, die fĂŒr natĂŒrliche Zahlen definiert ist:

n! = n · (n - 1)!

Diese Formel sieht zunÀchst nichtssagend aus. ErgÀnzt man sie aber um eine Abbruchbedingung:

n! = n · (n - 1)! , 1! = 1,

so sieht man leicht (indem man die Formel immer wieder in sich selbst einsetzt, bis man bei 1 ankommt), dass die FakultÀt auch iterativ berechnet werden kann:

n! = n · (n - 1) · ... · 2 · 1.

Man kann dieses sich selbst Aufrufen einer Funktion auch in C++ realisieren:

int factorial(int n)
{
    if (n == 1)
        return 1;
    
    return n * factorial(n - 1);
}

Umwandlung einer Dezimalzahl in eine Dualzahl

Rekursionen können Fluch und Segen zugleich sein: Einerseits lassen sich viele Algorithmen sehr kurz implementieren, andererseits entstehen dabei oft Quelltexte, die ohne ausfĂŒhrlichen Kommentar nicht zu verstehen sind.

Die folgende Funktion decToBin() wandelt eine gegebene Dezimalzahl in eine Dualzahl um; die Dualzahl wird sofort auf der Konsole ausgegeben und nicht als Variable abgespeichert.

#include <iostream>
using namespace std;

void decToBin(int n)
{
    if (n < 2)
    {
        cout << n;
        return;
    }

    decToBin(n / 2);
    cout << n % 2;
}

Aufgaben:

1. Trockentest: FĂŒhren Sie de Berechnungen der Funktion decToBin() fĂŒr n = 13 am Schreibtisch durch!

2. Warum liefert das Programm die falsche Ausgabe, wenn Zeile 12 und 13 vertauscht werden? Machen Sie wieder den Trockentest!

3. Schreiben Sie das entsprechende Programm, das eine Dezimalzahl in eine Oktalzahl (Basis 8) verwandelt!

Berechnung der Determinante mit dem Laplaceschen Entwicklungssatz

Der Laplacesche Entwicklungssatz reduziert das Problem der Berechnung einer Determinante (einer n × n-Matrix A) auf die Berechnung von n (kleineren) Determinanten (jetzt von (n - 1) × (n - 1)-Matrizen). Bei der Entwicklung nach der ersten Spalte entsteht folgende Rekursionsformel:

det (A) = ∑k (-1)k+1 · ak1 · det (Ak1)

(der Summationsindex k lÀuft von 1 bis n)

Dabei ist ak1 das Element der Matrix A in der k-ten Zeile und ersten Spalte; und Ak1 ist die (n - 1) × (n - 1)-Matrix, die entsteht, wenn man in A die k-te Zeile und die erste Spalte streicht. (Hier wird wie in der Mathematik ĂŒblich von 1 bis n durchnumeriert. Und eine 1 × 1-Matrix ist eine reelle Zahl, die zugleich die Determinante ist.)

Eine Matrix kann in C++ als Vektor von Vektoren dargestellt werden, also durch den Datentyp:

vector<vector<double>>

Die folgende Funktion realisiert den rekursiven Aufruf der Determinanten-Berechnung aus dem Laplaceschen Entwicklungssatz:

// Berechnung der Determinante nach dem Laplaceschen Entwicklungssatz
// rekursiver Aufruf bis 1x1 Matrix
double getDeterm(vector<vector<double>> matrix)
{
    if (matrix.size() == 1)         // 1x1 Matrix
    {
        return matrix [0][0];
    }

    double summe = 0;
    int faktor = 1;                 // FĂŒr Vorzeichen-Wechsel
    
    for (int i = 0; i < matrix.size(); i++)         // Entwicklung nach der ersten Spalte
    {
        summe += faktor * matrix [0][i] * getDeterm(getSubMatrix(matrix, 0, i));
        faktor *= -1;
    }
    return summe;
}

Dazu fehlt noch die Funktion

vector<vector<double>> getSubMatrix(vector<vector<double>> matrix, int i, int j));

die aus einer gegebenen Matrix die i-te Zeile und j-te Spalte streicht und die entstehende Submatrix zurĂŒckgibt (siehe Aufgaben unten).

Um eine Matrix zu erzeugen, muss ein Objekt Vektor von Vektoren angelegt werden, dazu kann folgende Funktion hilfreich sein:

// Erzeugen einer n x n Matrix mit Tetswerten; hier: obere Dreiecksmatrix
vector<vector<double>> createMatrix(int n)
{
    vector<vector<double>> matrix(n);           // Matrix als Vektor von n Vektoren

    for (int i = 0; i < n; i++)
    {
        // Vektor v erzeugen mit n Komp.
        vector<double> v (n);
        for (int k = 0; k < n; k++)
        {
            // Vektor v: k-tes Element setzen
            if (k >= i)             // obere Dreiecksmatrix
                v[k] = 2;       
            else
                v[k] = 0;            
        }
        matrix[i] = v;      // Matrix aus Vektoren v aufbauen
    }
    return matrix;
}

Die Funktion createMatrix() erzeugt eine n × n-Matrix; hier speziell eine obere Dreiecksmatrix (mit EintrĂ€gen 2). Dreiecksmatrizen sind zum Testen besonders gut geeignet, da deren Determinante das Produkt der EintrĂ€ge auf der Hauptdiagonale ist, hier also 2n. (Das Programm kennt diesen Sachverhalt aber nicht und berechnet dennoch die Rekursion — das Ergebnis lĂ€sst sich also leicht nachprĂŒfen.)

Aufgaben:

1. Schreiben Sie die noch fehlende Funktion getSubMatrix() zum Bilden der Streichmatrix Aij zu gegebener Matrix A.

2. Wieviele Determinanten-Berechnungen sind nötig, wenn man von einer n × n-Matrix ausgeht?

3. Eine beliebige Matrix kann auch durch Zeilenumformungen auf die Gestalt einer oberen Dreiecksmatrix gebracht werden. Wieviele Zeilenumformungen sind nötig, wenn man wieder von einer n × n-Matrix ausgeht?

4. LĂ€sst sich aus diesen Überlegungen ableiten, welches Verfahren zur Berechnung einer Determinante fĂŒr große n besser geeignet ist?

Quicksort

Der Algorithmus Quicksort zum Sortieren von Listen kann ebenfalls als Rekursion formuliert werden. Der Algorithmus lautet in umgangssprachlicher Formulierung:

  • Man geht von einer Liste L von n Zahlen aus.
  • Das erste Element der Liste sei x0.
  • Man vergleicht alle anderen n - 1 Zahlen mit x0 und bildet zwei Listen L< und L>, wobei in der ersten Liste alle Zahlen sind, die kleiner sind als x0, und in der zweiten Liste alle Zahlen, die grĂ¶ĂŸer sind als x0.
  • Die Liste L wird ersetzt durch die Liste (L<, x0, L>).
  • Die beiden Listen L< und L> sind noch nicht sortiert und werden nach dem selben Verfahren sortiert (Rekursion).

Aufgabe:

Schreiben Sie ein Programm, das den Quicksort-Algorithmus umsetzt.

Function Templates

Oftmals benötigt man mehrere Funktionen, die identische Aufgaben erledigen, aber dies mit Variablen von unterschiedlichen Datentypen. Hier hilft zwar das Überladen von Funktionen, die Mehrfach-Implementierungen sind aber fehleranfĂ€llig, wenn sie abgeĂ€ndert werden mĂŒssen: man darf keine Implementierung vergessen, man kann Implementierungen erzeugen, die leicht voneinander abweichen und so weiter.

Um diese Fehler zu vermeiden, werden sogenannte Function Templates angeboten (template = Schablone).

Beispiel:

Möchte man etwa zwei Varianten der mean()-Funktion anbieten, einmal mit int und einmal mit double, so muss man nicht beide Funktionen implementieren, wie das folgende Beispiel zeigt:

int mean(int x1, int x2)                            // mean() fĂŒr integer
{
    return (x1 + x2) / 2;
}

double mean(double x1, double x2)                   // mean() fĂŒr double
{
    return (x1 + x2) / 2;
}

Man kann stattdessen ein template erzeugen und darin einen formalen Typ-Parameter einsetzen — er wird meist mit T bezeichnet, um an Typ zu erinnern. Die Funktion zur Berechnung des Mittelwertes wird — um anzudeuten, dass hier ein neues Konzept eingefĂŒhrt wird, — als meanValue() bezeichnet:

template <typename T>
T meanValue(T x1, T x2)
{
    T meanValue = (x1 + x2) / 2;        // T wird hier wie ein bekannter Datentyp behandelt, da es als typename eingefĂŒhrt wurde
    return meanValue;
}

Das Template ersetzt jetzt sĂ€mtliche Funktionen, die zwei Eingabewerte von identischem Datentyp T und Typ des RĂŒckgabewertes T besitzen; und wie man in der Implementierung sieht, muss fĂŒr den Datentyp die Addition + definiert sein.

Die Syntax sollte klar sein:

  1. Das SchlĂŒsselwort template zeigt an, dass ein Funktions-Template folgt (Zeile 1).
  2. Der Typ-Parameter steht in spitzen Klammern und wird mit dem SchlĂŒsselwort typename versehen.
  3. Jetzt kann T wie ein Datentyp eingesetzt werden.
  4. Benötigt man mehrere Datentypen, steht in Zeile 1 etwa:
template <typename T, typename U, typename V>

Je nachdem wie die Funktion meanValue() jetzt aufgerufen wird, erzeugt der Compiler aus dem Template die Implementierung mit demjenigen Datentyp, der im Aufruf angezeigt wurde. Befinden sich etwa in der main()-Methode folgende Aufrufe:

int n {1};
int m {2};
cout << "int: " << meanValue(n, m) << endl;             // int: 1

double x {1};
double y {2};
cout << "double: " << meanValue(x, y) << endl;          // double: 1.5

wird zweimal der Quelltext fĂŒr die Funktion meanValue() erzeugt, nĂ€mlich mit den Implementierungen, die zu Beginn dieses Beispiels angegeben wurden.

Bei ĂŒblichen Funktionen ist es ratsam Deklaration und Implementierung in zwei getrennten Dateien abzulegen; so gliedern Sie Ihr Projekt besser und fĂŒr den Compiler ist es keine zusĂ€tzliche Arbeit, da er sowieso Deklaration und Implementierung getrennt verarbeitet (der Compiler bekommt erst Probleme, wenn Sie die include-guards vergessen).

Bei Funktions-Templates ist diese Aufteilung nur eingeschrĂ€nkt sinnvoll. Denn der Compiler erzeugt aus einem Template erst dann den Quelltext zu einem konkreten Datentyp, wenn ein entsprechender Funktionsaufruf stattfindet. Und erst jetzt findet die ÜberprĂŒfung der Syntax statt. Daher ist es sinnvoll:

  • Entweder das vollstĂ€ndige Template (wie das Beispiel oben) in die passende .h-Datei zu schreiben und diese .h-Datei dort zu inkludieren, wo sie aufgerufen wird.
  • Oder wieder Deklaration und Implementierung zu trennen, aber beide in eine .h-Datei zu schreiben; die .h-Datei wird dort inkludiert, von wo aus die Funktion aufgerufen wird. Die getrennte Deklaration und Implementierung wĂŒrde im Beispiel wie folgt aussehen:
// Deklaration:
template <typename T>           
T meanValue(T x1, T x2);

// weitere Deklarationen
// ...

// Implementierung:
template <typename T>           
T meanValue(T x1, T x2)
{
    T meanValue = (x1 + x2) / 2;        // T wird hier wie ein bekannter Datentyp behandelt, da es als typename eingefĂŒhrt wurde
    return meanValue;
}

(Manchmal wird auch empfohlen, fĂŒr die die Templates eigene .t-Dateien anzulegen.)

Aufgaben

Im Kapitel zur EinfĂŒhrung in C++, Anwendungen, wurden zahlreiche Aufgaben formuliert.

Überarbeiten Sie Ihre Lösungen im Sinne der strukturierten Programmierung. Falls Sie die Aufgaben noch nicht gelöst haben, entwickeln Sie die entsprechenden Programme, aber achten Sie von Anfang an darauf, wie Sie Funktionen geschickt einsetzen können.