Elementare Syntax von C++: Schleifen

Wie im entsprechenden Kapitel zur Einführung in die Programmierung werden hier die kopfgesteuerte Schleife, die fußgesteuerte Schleife und die Schleife mit Zählvariable besprochen und einige Spitzfindigkeiten erklärt, die die C++-Syntax dazu bereithält. Weiter wird anhand der Schleifen das sehr viel weiter reichende Konzept des Gültigkeitsbereiches (scope) einer Variable vorgestellt.

Einordnung des Artikels

Dieses Kapitel zeigt die Syntax für Schleifen in C++; Schleifen im Pseudocode wurden ausführlich besprochen in

Übersicht

Dieses Kapitel setzt genaue Kenntnisse der entsprechenden Inhalte über Schleifen aus dem Kapitel über Pseudocode und formale Sprachen voraus. Dazu gehört auch, dass Sie die dort gestellten Aufgaben in Pseudocode formulieren.

Wie im Kapitel über Pseudocode werden die drei Arten von Schleifen (Iterationen) behandelt:

  1. kopfgesteuerte Schleife
  2. fußgesteuerte Schleife
  3. Schleife mit Zählvariable

Da man beim Implementieren von Schleifen immer wieder die Frage stellen muss:

wird hier ein damit zusammenhängendes — aber eigentlich sehr viel allgemeineres Konzept — vorgestellt: der Gültigkeitsbereich einer Variable (scope).

Die kopfgesteuerte Schleife

Im Pseudocode wurde die kopfgesteuerte Schleife mit den Textbausteinen solange und wiederhole formuliert:

solange (Bedingung)
    wiederhole (Anweisung);

In C++ steht anstelle von solange das Schlüsselwort while und die Anweisungen werden ohne einleitendes Schlüsselwort geschrieben. Im folgenden Beispiel wird die Summe der natürlichen Zahlen von 1 bis 100 berechnet; dazu wird eine Variable summe vorbereitet (deklariert und mit null initialisiert) und eine Variable i, mit deren Hilfe von 1 bis 100 gezählt wird.

/* Programm zur Berechnung der Summe der natürlichen Zahlen von 1 bis 100
    mit Hilfe einer kopfgesteuerten Schleife */

int summe {0};
short i {1};

while (i < 101)
{
    summe += i;
    i += 1;
}
    
cout << "Summe: " << summe << endl;     // Summe: 5050

Zur Erklärung:

1. Dass die Variable summe und die Zählvariable i unterschiedlichen Datentyp haben (Zeile 4 und 5), stört nicht, wenn sie addiert werden: die implizite Typumwandlung (hier: Aufweitung) sorgt dafür, dass summe eine int-Variable bleibt.

2. Mathematisch naheliegender wäre es gewesen, die Bedingung mit i ≤ 100 zu formulieren:

while (i <= 100)
 . . .

Sie sollten sich aber angewöhnen, wenn möglich echte Ungleichungen einzusetzen (spart meist einen logischen Vergleich und somit Rechenzeit).

3. In der Schreibweise oben mit Pseudocode war im Körper der Schleife (also der Teil nach wiederhole) nur eine Anweisung enthalten. Hier gibt es zwei Anweisungen und daher werden geschweifte Klammern gesetzt (Zeile 8 und 11).

4. Wenn die Schleife betreten wird, ist i = 1 und summe = 0. Beachten Sie die Reihenfolge, in der das Aufsummieren (Zeile 9) und das Hochzählen (Zeile 10) ausgeführt werden: irgendwann wird beim Hochzählen i = 101 gebildet, aber dann ist die Bedingung für das Betreten der Schleife nicht mehr erfüllt — die Schleife wird verlassen. Und zum Aufsummieren wurden also die i-Werte von 1 bis 100 verwendet.

Hätte man Zeile 9 und 10 vertauscht (und sonst alles identisch gelassen), erhält man ein anderes Ergebnis.

5. Wie im Kapitel über Pseudocode erklärt, ist das gewählte Beispiel einfacher mit einer Schleife mit Zählvariable zu formulieren — es muss aber eine gleichwertige kopfgesteuerte Schleife mit while geben.

Tip:

Testen Sie immer die Werte der relevanten Variablen beim Betreten und Verlassen der Schleife.

Aufgaben:

1. Welche Zahlen werden aufaddiert, wenn im Programm-Beispiel oben Zeile 9 und 10 vertauscht werden (alles andere bleibt gleich)?

2. Schreiben Sie obiges Programm-Beispiel mit Hilfe des Inkrement-Operators.

Die fußgesteuerte Schleife

Obiges Beispiel kann leicht als fußgesteuerte Schleife formuliert werden, die Schlüsselworte dazu sind do und while:

/* Programm zur Berechnung der Summe der natürlichen Zahlen von 1 bis 100
    mit Hilfe einer fußgesteuerten Schleife */

int summe {0};
short i {1};

do
{
    summe += i;
    i += 1;
}
while (i < 101);
    
cout << "Summe: " << summe << endl;     // Summe: 5050

Zur Erklärung:

  1. Im Schleifenkörper (do-Block) stehen wieder mehrere Anweisungen, daher sind geschweifte Klammern zu setzen (Zeile 8 und 11). Für eine einzige Anweisung ist dies nicht nötig, erleichtert aber das Lesen des Quelltextes.
  2. Nach der Bedingung (Zeile 12), die steuert, ob die Schleife erneut durchlaufen oder verlassen wird, folgt ein Strichpunkt.

for-Schleifen (mit Zählvariable)

Die Syntax der for-Schleife

Zuletzt soll die Variante vorgestellt werden, die dem Problem (Zahlen von 1 bis 100 aufaddieren) angemessen erscheint: Die Schleife mit Zählvariable, die mit dem Schlüsselwort for realisiert wird.

Der Quelltext dazu lautet:

/* Programm zur Berechnung der Summe der natürlichen Zahlen von 1 bis 100
    mit Hilfe einer for-Schleife */

int summe {0};

for (short i {1}; i < 101; i++)
{
    summe += i;
}
    
cout << "Summe: " << summe << endl;     // Summe: 5050

Zur Erklärung:

  1. Im Schleifenkörper steht wieder die Anweisung, die für die Summation sorgt (Zeile 8).
  2. Allerdings fehlt im Schleifenkörper jetzt die Anweisung zum Hochzählen der Zählvariable i.
  3. Ebenso fehlt vor der Schleife die Initialisierung der Zählvariable i.
  4. Die letzten beiden Punkte (Initialisierung der Zählvariable und Hochzählen) werden durch die for-Anweisung realisiert, die aus drei Bestandteilen besteht (Zeile 6):
    • Deklaration und Initialisierung der Zählvariable i
    • Bedingung unter der die Schleife erneut ausgeführt wird (i < 101); oder negativ formuliert: Abbruchbedingung (wenn i =101 erreicht wird).
    • Schrittweite (der Inkrement-Operator sorgt hier dafür, dass die Schrittweite gleich eins ist — das muss aber nicht sein).
  5. Diese drei Bestandteile der for-Anweisung sind durch Strichpunkte getrennt (Initialisierung der Zählvariable; Ausführungs-Bedingung; Schrittweite).
  6. Es ist sogar erlaubt (aber nicht empfehlenswert), jeden dieser drei Bestandteile durch mehrere Befehle zu ersetzen. Ein Beispiel folgt unten. Meist führt das aber dazu, dass Anweisungen, die entsprechend der Programm-Logik in den Schleifenkörper gehören, in die for-Anweisung verschoben werden. Aber dadurch entstehen meist nur unklare Quelltexte.

Aufgaben

Aufgaben:

1. Das obige Programm soll so abgeändert werden, dass zusätzlich die Summe der Quadratzahlen (von 1 bis 100) berechnet und ausgegeben wird.

Zur Kontrolle: Die relevanten Formeln lauten

1 + 2 + ... + n-1 + n = n(n+1)/2

12 + 22 + ... + (n-1)2 + n2 = n(n+1)(2n+1)/6

2. Testen Sie, welchen Unterschied es macht, ob man zum Inkrementieren von i den Befehl i++ oder ++i verwendet.

3. Die scheinbar so einfachen Formeln oben zur Berechnung der Summe der Zahlen beziehungsweise der Summe der Quadratzahlen, sind für die Realisierung in einem Programm problematisch:

Berechnet man etwa

n(n+1)/2

mit einer Zahl n vom Datentyp int (oder short), hängt das Ergebnis von der Reihenfolge der Ausführung der Operationen * und / ab. Denn man kann die Formel entweder wie

n * [(n + 1) / 2]       // zuerst: Division (Ausführung der Operationen von rechts nach links)

oder wie

[n * (n + 1)] / 2       // zuerst: Multiplikation (Ausführung der Operationen von links nach rechts)

lesen. Und ist jetzt n eine gerade Zahl, so ist n + 1 eine ungerade Zahl, was aber dazu führt, dass die Ganzzahl-Division im ersten Fall nicht das gewünschte Ergebnis liefert.

Lesen Sie in der Dokumentation nach, ob eine Formel wie

n * (n + 1) / 2

von rechts nach links (erste Version mit dem falschen Ergebnis) oder von links nach rechts (zweite Version mit dem richtigen Ergebnis) ausgewertet wird!

Zeigen Sie, dass

n(n+1)(2n+1)/6

immer ein ganzzahliges Ergebnis liefert.

Hinweis: 2n + 1 = (n +2) + (n - 1).

Lösungen:

1. Realisierung mit for-Schleife:

/* Programm zur Berechnung der Summe der Zahlen von 1 bis 100,
	sowie deren Quadratzahlen. */
	
int summe {0};
int summeQuad {0};          // Summe der Quadrate von i, i = 1,..., 100
short i {1};                // i wird VOR der Schleife deklariert und initialisiert (und nicht in der for-Anweisung), 
							// weil man später noch darauf zugreifen möchte

for (; i < 101; i++)        // Zählvariable muss nicht initialisiert werden: Anweisungsteil bleibt leer
{
	summe += i;
	summeQuad += i * i;
}

cout << "Summe: " << summe << endl;             // Summe: 5050
cout << "Summe der Quadrate: " << summeQuad << endl;        // Summe der Quadrate: 338350

i -= 1;         // ACHTUNG: i wurde bis 101 hochgezählt (wegen i++) 
				// und damit war die Bedingung für die Wiederholung der Schleife nicht mehr erfüllt,
				// aber für die Formeln benötigt man i = 100
			
summe = i * (i + 1) / 2;
summeQuad = summe * (2 * i + 1) / 3;

cout << "Zur Kontrolle: " << endl;
cout << "Summe: " << summe << endl;             // Summe: 5050
cout << "Summe der Quadrate: " << summeQuad << endl;        // Summe der Quadrate: 338350

2. Es macht keinen Unterschied, ob man i++ oder ++i verwendet: Dazu muss man wissen, das der Inkrement-Befehl immer so ausgeführt wird als würde er am Ende des Schleifenkörpers stehen.

3. Reihenfolge bei der Auswertung eines Termes

Gegenbeispiele

Allgemein besteht die for-Schleife aus folgenden Bestandteilen:

for ((Initialisierung); (Bedingung zur Wiederholung der Schleife); (Inkrement))
{
    (Anweisungen)
}

Ist die Bedingung zur Wiederholung der Schleife schon beim Betreten der Schleife nicht erfüllt (false), wird der Schleifenkörper übersprungen, es wird sofort die Anweisung nach der Schleife ausgeführt.

Es ist stets ratsam, die hier nochmals ausdrücklich benannten Bestandteile einer for-Schleife auch so einzusetzen wie es nach dieser Nomenklatur vorgesehen ist — andernfalls sind die Quelltexte nur schwerer zu lesen. In diesem Abschnitt werden einige Beispiele gezeigt, wie man for-Schleifen nicht einsetzen soll; die Beispiele sind zwar syntaktisch korrekt, haben aber schwer verständliche Quelltexte oder können zu nicht vorhersagbarem Verhalten führen.

Überladen der for-Anweisung

Die drei Bestandteile in der for-Anweisung sind durch Strichpunkte getrennt. Es ist sogar möglich anstelle jedes dieser Bestandteile mehrere Befehle einzusetzen, die dann durch Kommas getrennt werden. Ein — nicht nachahmenswertes — Beispiel zeigt, wie dies aussehen könnte:

// NICHT NACHAHMEN

int sum;
int i;

for (sum = 0,  i = 1; i < 101; sum += i, i++);

cout << sum << endl;

Das Programm berechnet wieder einmal die Summe der Zahlen von 1 bis 100. Auf den ersten Blick scheint das Programm schlanker zu sein als die bisher gezeigten Versionen; allerdings ist der Quelltext aus mehreren Gründen schwerer zu lesen:

1. Die Deklaration und Initialisierung der Variablen ist getrennt; wie schon gesagt, soll man dies nur aus gutem Grund voneinander trennen — hier besteht kein Grund dazu. Die Initialisierungen wurden hier nur in die for-Anweisung aufgenommen, um die Trennung der Befehle durch ein Komma zu zeigen.

2. Die eigentliche Aufgabe der Schleife, nämlich die Summe zu bilden mittels

sum += i;

geschieht jetzt zusammen mit dem Inkrement-Befehl für die Zählvariable (Zeile 6). Dies ist ganz schlechter Stil: Die Befehle sollen immer dort stehen, wo man sie logisch erwartet (Summation im Schleifenkörper, Inkrementieren als dritter Teil der for-Anweisung).

Zählvariable als Gleitkommazahl (float oder double)

Das folgende Programm macht, was man von ihm erwartet:

// NICHT NACHAHMEN

float sum {0};

for (float i {1.0}; i < 2.0; i += 0.1)
    {
        sum += i;
    }
cout << sum << endl;

Es werden die Zahlen 1.0, 1.1, 1.2, ... , 1.9 addiert und ausgegeben (bei i == 2.0 wird die Schleife verlassen).

Das folgende Programm erscheint auf den ersten Blick gleichwertig zu sein:

// NICHT NACHAHMEN

float sum {0};

for (float i {1.0}; i != 2.0; i += 0.1)
    {
        sum += i;
    }
cout << sum << endl;

Hier wurde nur bei der Bedingungsprüfung i < 2.0 durch i != 2.0 ersetzt. Man erwartet wieder, dass die Schleife bei i == 2.0 verlassen wird. Führt man dieses Programm aus, wird eine Endlos-Schleife ausgeführt.

Aufgrund von Rundungsfehlern bei der Verwendung von Gleitkommazahlen wird die Zahl 2.0 nicht erreicht und die Schleife läuft endlos weiter.

Tip:

Verwenden Sie niemals Gleitkommazahlen als Zählindex in einer Schleife; es kann immer zu unvorhersagbarem Verhalten kommen.

Mit ein wenig Bruchrechnen kann man die Summation oben auch mit Hilfe einer ganzzahligen Zählvariable realisieren:

int sum = 0;

for (int i = 10; i < 20; i++)
{
    sum += i;
}
cout << static_cast<float> (sum) / 10 << endl;

Jetzt werden die Zahlen i = 10, 11, ... , 19 addiert und am Ende wird durch 10 dividiert.

Verschachtelte Schleifen

Schleifen können — wie alle Strukturelemente eines Algorithmus — beliebig ineinander verschachtelt werden.

Im folgenden Beispiel soll ein Würfel zweimal geworfen werden und alle möglichen Kombinationen der Augenzahlen sollen ausgegeben werden:

/* Ausgabe aller Ergebnisse beim zweimaligen Werfen eines Würfels */

for (short i {1}; i < 7; i++)
{
    for (short k {1}; k < 7; k++)
    {
        cout << i << k << endl;
    }
}

Die Ausgabe besteht aus allen 36 Kombinationen, die aufsteigend angeordnet sind:

11
12
13
...
65
66

Aufgabe:

Der folgende Quelltext versucht die verschachtelten for-Schleifen durch eine einzige Schleife zu ersetzen, wobei die Komma-getrennten Anweisungen verwendet werden. Erzeugt der Quelltext die identische Ausgabe wie das Programm oben?

for (short i {1}, k {1}; i < 7, k < 7; i++, k++)
{
    cout << i << k << endl;
}

Das Komma zwischen i {1} und k {1} bedeutet hier: es werden zwei Variablen vom Typ short angelegt (und mit 1 initialisiert).

Welche Bedeutung hat die Warnung, die hier beim Compilieren erzeugt wird?

Die Schlüsselworte break und continue

So wie die Schleifen bisher behandelt wurden, kann man sie noch nicht sehr flexibel einsetzen: Allein die Bedingung steuert, ob die Schleife wiederholt oder verlassen wird und der gesamte Schleifenkörper muss bei jedem Durchlauf abgearbeitet werden. Manchmal ist es aber wünschenswert, dass

Diese beiden Möglichkeiten werden durch die Schlüsselworte break und continue realisiert; es folgen zwei Beispiele, die deren Einsatz demonstrieren.

1. Beispiel für die Verwendung von break:

Das Schlüsselwort break wurde schon bei der switch-Anweisung behandelt (siehe Elementare Syntax: Bedingung, Alternative und Mehrfachalternative); es kann auch bei Schleifen eingesetzt werden.

Im folgenden Programm wird die Summe

13 + 23 + ... + 993 + 1003

ähnlich wie in den einführenden Beispielen mit einer for-Schleife berechnet. Allerdings wird für die Summe der (ungeeignete) Datentyp short verwendet. Die Berechnung wird abgesichert: wenn durch den neu hinzukommende Summand der Wertebereich von short überschritten wird (siehe Zeile 16), wird die Schleife verlassen (Zeile 19).

/* Programm zur Berechnung der Summe der Zahlen i^3, i = 1,..., 100;
    Die Berechnung wird im folgenden Sinn abgesichert:
    Wenn der nächste Summand dafür sorgen würde, dass die Grenze von short überschritten wird,
    bricht die Summation ab.
    */

short summe {0};
short i {0};

// Maximum von short berechnen
const short MAX_SHORT = std::numeric_limits<short>::max();

for (; i < 101; i++)
{
    short summand = i * i * i;
    if (summe > MAX_SHORT - summand)
    {
        cout << "vorzeitiger Abbruch mit: i = " << i << "\t\t Summe (bis i - 1) = "<< summe << endl;
        break;
    }
    summe += summand;
}
if (i == 101)
    cout << "kein vorzeitiger Abbruch mit: i = " << i << "\t\t Summe = "<< summe << endl;
            // vorzeitiger Abbruch mit: i = 19          Summe (bis i - 1) = 29241

2. Beispiel für die Verwendung von continue:

Es sollen alle Zahlen von 1 bis 100 ausgegeben werden, allerdings sollen die durch 7 teilbaren Zahlen weggelassen werden.

/* Ausgabe aller Zahlen von 1 bis 100 mit Ausnahme der durch 7 teilbaren Zahlen */

for (short i {1}; i < 101; i++)
{
if (i % 7 == 0)
    continue;
cout << i << endl;
}

Mithilfe des modulo-Operators lässt sich feststellen, ob eine Zahl durch 7 teilbar ist (Zeile 5). Die Anweisung continue in Zeile 6 sorgt dafür, dass die folgenden Anweisungen des Schleifenkörpers nicht mehr ausgeführt werden (hier ist dies nur die Ausgabe der entsprechenden Zahl). Stattdessen wird die Zählvariable um 1 hochgezählt und der nächste Schleifendurchlauf gestartet.

Die Ausgabe des Programmes lautet:

1
2
3
4
5
6
8
9
10
11
12
13
15
16
17
18
19
20
22
. . .

Der Gültigkeitsbereich einer Variable (scope)

Oben wurden schon zwei Varianten zur Berechnung der Summe der Zahlen von 1 bis 100 vorgestellt, wobei auf den diffizilen Unterschied nicht eingegangen wurde. Die relevanten Quelltexte werden hier nochmals gezeigt:

1. Deklaration der Zählvariable vor der Schleife:

int summe {0};
short i {1};

for (; i < 101; i++)
{
    summe += i;
}

cout << "Summe = " << summe << endl;                    // Summe = 5050
cout << "Index bei Abbruch der Schleife: " << i << endl;        // i = 101

2. Deklaration der Zählvariable in der for-Anweisung:

int summe {0};

for (short i {1}; i < 101; i++)
{
    summe += i;
}

cout << "Summe = " << summe << endl;                // Summe = 5050
cout << "Index bei Abbruch der Schleife: " << i << endl;        // Compiler-Fehler: "error: 'i' was not declared in this scope"

Auf den ersten Blick unterscheiden sich die Quelltexte nur darin, wo der Zählindex i definiert wird (einmal vor der Schleife in Zeile 2, einmal in der Schleife in Zeile 3). Dieser kleine Unterschied hat aber weitreichende Konsequenzen, es gilt nämlich die Regel:

Eine Variable ist in dem Block definiert, in dem sie deklariert wurde.

Der Bereich, in dem eine Variable definiert (oder gültig oder sichtbar) ist, wird als ihr Gültigkeitsbereich (scope) bezeichnet.

Das folgende Beispiel zeigt dies vielleicht noch deutlicher als die beiden vorhergehenden:

/* Programm zur Berechnung der Summe der geraden beziehungsweise ungeraden Zahlen zwischen 1 und 100 */

int summe {0};

for (short i {1}; i < 101; i += 2)          // ungerade Zahlen
{
    summe += i;
}
cout << "Summe der ungeraden Zahlen: " << summe << endl;            // 2500

summe = 0;
for (short i {2}; i < 101; i += 2)          // gerade Zahlen
{
    summe += i;
}
cout << "Summe der geraden Zahlen: " << summe << endl;          // 2550

Der Zählindex i hat zwar in beiden Schleifen den identischen Namen, die beiden Variablen haben aber nichts miteinander zu tun; deshalb muss in der zweiten Schleife i neu deklariert werden. Hätte man dort nur initialisiert (mit i = 2), liefert dies einen Compiler-Fehler, da der Befehl i = 2 außerhalb des Gültigkeitsbereiches der Variable i (aus Zeile 5) liegt.

Dagegen ist die Variable summe vor den Schleifen definiert (Zeile 3): summe ist jetzt überall sichtbar. Und daher muss vor der zweiten Schleife summe auch wieder auf null zurückgesetzt werden (Zeile 11) — man würde sonst von 2500 ausgehend weitere Zahlen addieren.

Oben wurde gesagt, dass der Gültigkeitsbereich einer Variable immer der Block ist, in dem sie definiert wurde. Dies muss keine Schleife oder dergleichen sein, man kann einen derartigen Block auch künstlich erzwingen, indem man geschweifte Klammern setzt, wie das folgende Beispiel zeigt:

{
    short i {1};
    doSomething(i);
}

{
    short i {2};
    doSomething(i);
}

Wieder verwendet man identische Namen für die Variable; und wieder muss i neu deklariert werden (Zeile 7), da der Gültigkeitsbereich von i aus Zeile 2 nur innerhalb der ersten geschweiften Klammern liegt.

Identische Namen für Variablen aus unterschiedlichen Gültigkeitsbereichen sollte man vermeiden, sobald auch nur die geringste Verwechslungsgefahr besteht.

Fehlerquelle:

Verwenden Sie niemals identische Namen für Variablen, die einen unterschiedlichen Gültigkeitsbereich haben. Für den Compiler ist zwar jede Variable eindeutig definiert, als Programmierer kann man die Variablen aber leicht verwechseln und semantische Fehler erzeugen.

Im Zusammenhang mit objektorientierter Programmierung wird man sehen, dass das Konzept des Gültigkeitsbereiches sehr viel weitreichender ist als es hier erscheinen mag. Es ermöglicht die sogenannte Datenkapselung. Aber aus den hier vorgestellten Beispielen kann man vielleicht schon eine Grundregel erahnen:

Tip:

Versuchen Sie für jede Variable den Gültigkeitsbereich so klein wie möglich zu machen.

Aufgabe: Testen Sie, wie sich Variablen mit identischem Namen bei verschachtelten Gültigkeitsbereichen verhalten, etwa wie im folgenden Beispiel:

{
int i {1};

    {
    int i {2};
    cout << "i = " << i << endl;
    }
}

Welchen Wert liefert die Ausgabe von i?

Aufgaben zu Schleifen

Im Kapitel über Pseudocode und formale Sprachen wurden Aufgaben gestellt, die dort noch als Pseudocode zu formulieren waren.

Schreiben Sie nun die zugehörigen C++-Programme!