Twitter und Facebook-Anbindung
X
Tweet Follow @twitterapi
!!! Anbindung an twitter und facebook öffnen !!!

Wenn Ihnen mein Online-Buch gefällt,
dann bedanken Sie sich doch mit einer kleinen Spende...

E Prozedurales Programmieren

E Prozedurales Programmieren

Zum Seitenanfang
Zum Inhaltsverzeichnis

6 Präprozessordirektiven

6 Präprozessordirektiven

Bisher haben Sie unmerklich einen Mechanismus benutzt, welcher zu den Präprozessordirektiven gehört, nämlich die "includes". Jede dieser Direktiven ist dadurch gekennzeichnet, dass sie zum einen mit einer "#" beginnen und in den meisten Fällen nicht mit einem Semikolon enden. Doch was hat es mit diesen Direktiven auf sich? Dazu muss ich ein wenig ausholen.

Immer dann, wenn Sie Ihren Quelltext kompilieren wollen, laufen mehrere Mechanismen im Hintergrund ab. Vor der Syntaxprüfung und dem eigentlichen Kompilieren, wird der s.g. Präprozessor aktiv. Er bereitet den Quelltext für die weitere Verarbeitung vor. Neben dem Zusammensetzen von Dateien, werden auch Quelltextstücke ersetzt bzw. sogar generiert (wie ich Ihnen am Ende des Tutorials noch zeigen werde). Mit den Präprozessordirektiven gibt man eben genau diesem Prozess an, was er wie machen soll und genau darum wird es in diesem Kapitel gehen.

Diese Direktiven werden immer zu Beginn einer Datei deklariert und haben nichts in einer Funktion zu suchen. Oftmals ist es sogar klug, sich eine separate Datei zu erstellen, in welche man nur solche Definitionen vereinbart (gerade wenn es sehr viele solcher Direktiven gibt).

Zum Seitenanfang
Zum Inhaltsverzeichnis

6.2 Bedingtes Kompilieren mit #if, #elif und #endif

6.2 Bedingtes Kompilieren mit #if, #elif und #endif

Jetzt wird die ganze Sache wesentlich interessanter. Mit den folgenden Direktiven, kann man steuern, wie oder besser gesagt welcher Teil eines Quelltextes kompiliert werden soll, indem man ein paar Bedingungen und ein paar Schalter einbaut, was gerade im Zusammenhang mit "defines" interessant ist. So kann man beispielsweise ein Programm in mehreren Sprachen bauen. Die Syntax erinnert sehr stark an die normale if Anweisung, nur dass man hier keine geschweiften Klammern setzen braucht/darf und dass man mit "elif" mehrere Fälle behandeln kann, so wie das in etwa bei einer switch Anweisung der Fall ist. Man braucht zwar keine Klammern zu setzen, aber trotzdem muss man dem Präprozessor sagen, wo der Anweisungsblock zu Ende ist. Dies geschieht mittels der Direktive "endif".

Im folgendem Beispiel werde ich eine einfache Begrüßung ausgeben und der Programmierer, also Sie, können entscheiden, in welcher Sprache das Programm kompiliert werden soll.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
#define DEF_SPRACHE spanisch

#if DEF_SRPACHE == spanisch
	#define DEF_STRING_AUSGABE "Ola\n"
#elif DEF_SPRACHE == englisch
	#define DEF_STRING_AUSGABE "Hello\n"
#else
	#define DEF_STRING_AUSGABE "Hallo\n"
#endif



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	printf(DEF_STRING_AUSGABE);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Ola
		

Wenn Sie also hinter DEF_SPRACHE z.B. englisch schreiben, würde Hello ausgegeben werden.

Zum Seitenanfang
Zum Inhaltsverzeichnis

6.4 Bedingtes Kompilieren mit #ifdef und #ifndef

6.4 Bedingtes Kompilieren mit #ifdef und #ifndef

Satt zu prüfen, ob ein "define" einen bestimmten Wert hat, kann man auch prüfen, ob eine Marke überhaupt definiert wurde oder nicht. "ifdef" steht für "if defined", also "wurde etwas definiert" und "ifndef" steht für "if not defined", also "wurde etwas nicht definiert". So könnte man also obiges Beispiel auch wie folgt bauen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
#define DEF_SPANISCH

#ifdef DEF_SPANISCH
	#define DEF_STRING_AUSGABE "Ola\n"
#elif defined DEF_ENGLISCH
	#define DEF_STRING_AUSGABE "Hello\n"
#else
	#define DEF_STRING_AUSGABE "Hallo\n"
#endif



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	printf(DEF_STRING_AUSGABE);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Ola
		

Das selbe Spiel funktioniert auch umgekehrt, also mit "ifndef", welches in der Regel sehr häufig mit Header-Dateien benutzt wird und was ich im nächsten großen Kapitel ausführlich behandeln werde.

Zum Seitenanfang
Zum Inhaltsverzeichnis

6.5 Importieren von Dateien mit #include

6.5 Importieren von Dateien mit #include

Mit dieser Direktive kann man sich ganze Quelltextdateien genau an die Stelle hereinholen, an welcher die Anweisung steht. Es ist also ganz grob gesagt eine Importfunktion, um aus mehreren Quelltextdateien eine zu machen und genau das wird mit Header-Dateien gemacht, worum es im nächsten großen Kapitel gehen wird.

Wichtig an dieser Stelle ist nur, wie man so ein "include" definiert. Es gibt zwei Arten, nämlich einmal mit einer Suche in den festgelegten Includeverzeichnissen des Projektes bzw. auch der Entwicklungsumgebung oder mit der Suche im aktuellen Projektverzeichnis bzw. auch an absoluten Pfadadressen.

Das Schema sieht so aus: #include [<|"]<Dateipfad>["|>]

Und hier noch ein kleines Beispiel mit beiden Varianten.

 1
 2
 3
 4
 5
 6
					
// Sucht in den Includeverzeichnissen von MS Visual Studio
#include <stdio.h>
// Sucht im aktuellen Programmverzeichnis, wo auch die main.cpp liegt
#include "res.h"
// Sucht Auf Laufwerk C
#include "C:\test.h"
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

6.3 Compiler spezifische Direktiven mit #pragma

6.3 Compiler spezifische Direktiven mit #pragma

Wie die Überschrift schon vermuten lässt, handelt es sich bei einem "#pragma", um keinen Standard, was zur Folge hat, dass ein Quelltext mit dieser Direktive, zwar in Visual Studio, aber nicht mit dem "gcc" kompiliert werden kann und umgekehrt. Was Sie alles genau mit dieser Direktive anfangen können, müssen Sie in der Beschreibung des Compilers nachlesen, den Sie einsetzen. Hier sei nur schon einmal so viel erwähnt, dass z.B. Visual Studio diese Direktive in Zusammenhang mit Klassen und Header-Dateien benutzt, aber dazu auch später mehr.

Zum Seitenanfang
Zum Inhaltsverzeichnis

6.1 Quelltextbausteine mit #define

6.1 Quelltextbausteine mit #define

Ein sehr nützliches Werkzeug sind die s.g. "defines". Mit ihnen kann man Werte/Konstanten, ganze Textbausteine, oder sogar Codefragmente generieren. Der größte Vorteil liegt meines Erachtens darin, dass man eine zentrale Stelle hat, an der Sachen vereinbart werden können, die z.B. dann für das gesamte Projekt gelten. So spart man sich stundenlanges suchen nach Ausgabetexten, um Rechtschreibfehler zu verbessern - man hat alles auf einen Blick. Des Weiteren wird dafür kein zusätzlicher Speicherplatz verbraucht, da der Präprozessor im gesamten Quelltext nach diesen Bausteinen sucht und sie mit dem ersetzt, was Sie definiert haben. Die eigentlichen Definitionen sind dann am Ende nicht mehr in Ihrem Programm. Es handelt sich also um ein reines Suchen und Ersetzen.

Das Schema sieht so aus: #define <SuchMarke> <ErsetzterQuelltext>

Hier mal ein kleines Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
					
#define	DEF_STRING_EINGABE	"Bitte geben Sie einen Wert ein: "
#define	DEF_STRING_AUSGABE	"Sie haben %i eingegeben!\n"



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	int iWert;
	printf(DEF_STRING_EINGABE);
	scanf("%i", &iWert);
	printf(DEF_STRING_AUSGABE, iWert);
	
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Bitte geben Sie einen Wert ein: 15
Sie haben 15 eingegeben!
		

Wie Sie vielleicht schon erkennen, wird der Textbaustein DEF_STRING_EINGABE mit der dazugehörigen Definition ersetzt, nämlich "Bitte geben Sie einen Wert ein". Dabei ist wichtig, dass der Name eines "defines", immer nur aus einem Wort bestehen darf. Dahinter darf so viel stehen, wie Sie lusstig sind und das wird dann wirklich eins zu eins ersetzt (also auch alle Sonderzeichen wie Semikolon oder Anführungszeichen). Des Weiteren sollten Sie "defines" immer komplett in Großbuchstaben schreiben, damit man dann später im weiteren Quelltext sofort sieht, dass dort etwas ersetzt wird. Ich bevorzuge sogar immer noch den Typ mit einfließen zu lassen, aber das ist Geschmackssache.

Mit diesem Werkzeug kann man aber auch, wie gesagt, Quelltext zusammen bauen, da man "defines" auch ineinander verschachteln kann. Allerdings sollte man mit so etwas vorsichtig umgehen, da man so schnell Verwirrung stiften kann. Trotzdem möchte ich Ihnen kurz zeigen, wie das geht.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
#define	DEF_INT_ERROR		1
#define	DEF_STRING_ERROR	"Der Fehler %i ist aufgetreten!", DEF_INT_ERROR
#define	DEF_PRESS_KEY		printf("Druecken Sie <ENTER> um fortzufahren!");\
				fflush(stdin);\
				getchar();



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	printf(DEF_STRING_ERROR);
	DEF_PRESS_KEY
	
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Der Fehler 1 ist aufgetreten
Druecken Sie <ENTER> um fortzufahren!
		

Ein "define" muss normalerweise innerhalb einer Zeile stehen. Möchte man aber, aus Gründen der Übersichtlichkeit, doch einen Zeilenumbruch machen, dann setzt man ein "\" am Ende der Zeile und signalisiert somit, dass die Definition in der nächsten Zeile weiter geht.

Hin und wieder kommt es vor, dass man so ein Textbaustein zwischendurch neu belegen möchte. Auch dies ist möglich und sieht wie folgt aus.

 1
 2
 3
					
#define	DEF_STRING	"Hallo"
#undef	DEF_STRING
#define	DEF_STRING	"Hallo Welt"
					

Aber trotz der ganzen Bequemlichkeit, sollten Sie für Quelltextfragmente entweder wirklich Funktionen bauen oder, wie ich später noch zeigen werde, Templates bauen. Dieser Mechanismus wurde vorwiegend in der Vergangenheit gerne mal eingesetzt um das Programm noch ein wenig schneller laufen zu lassen, da ein Funktionsaufruf zeit kostet (was auf den heutigen schnellen Rechnern nicht mehr von Bedeutung ist). Für Konstanten und Textbausteine wie Ein - und Ausgaben, sind "defines" allerdings sehr genial.

Abschließend möchte ich noch erwähnen, dass es einen ganz großen Haken an diesem Mechanismus gibt. Wenn man ein Programm debuggen will, kann man nicht sehen, welcher Wert oder welche Codezeilen sich hinter einem "define" verbergen. Das kann sehr nervig sein und die Arbeit ungemein schwerer machen. Das ist genau der Grund, warum dieser Mechanismus langsam "ausstirbt". Fangen Sie jetzt also nicht an, nur noch "defines" zu benutzen – setzen Sie diese mit Bedacht ein, wenn Sie sie verwenden möchten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5 Funktionen

5 Funktionen

Funktionen sind ganz nützliche Hilfsmittel und wurden lediglich erfunden, um es dem Programmierer leichter zu machen. Der Anwender bekommt in der Regel nicht mit, ob Funktionen genutzt werden und schon gar nicht wie viele.

Das Konstrukt Funktion wurde erfunden damit:

  1. Der Quelltext wird besser strukturiert, damit nicht alles an einem Stück steht (Spaghetti-Code). Dies fördert die Übersichtlichkeit. (z.B. Man fasst Blöcke wie Eingaben oder Ausgaben zusammen und verpackt sie in seperate Funktionen)
  2. Funktionen lassen sich in der Regel in andere Dateien auslagern und somit wird jede Quelldatei nicht so riesig und somit wiederum übersichtlicher.
  3. Oft werden Sachen die gleich sind, aber häufig genutzt werden, in Funktionen ausgelagert, damit der Programmierer nicht 1000 mal das gleiche schreiben muss. Dies hat zudem den Vorteil, dass im Falle eines Fehler nur an einer Stelle was getan werden muss und nicht an 1000.

Das Konzept der Funktion sollte Ihnen aus dem Mathematikunterricht durchaus bekannt sein. Beispielsweise lautete mal die Aufgabe in der neunten Klasse wie folgt. Berechnen Sie die y Werte der Funktion f(x)=x2 für x=1 und x=5. Formal dachte man sich dann im Kopf f(1)=12 und f(5)=52 und wer des Kopfrechnens nicht mächtig war, gab das dann in seinen Taschenrechner ein. Aber was ist hier rein formal passiert? Nun, man hat eine Funktion mit dem Namen "f" und einem s.g. Übergabeparameter Namens "x". Im Kopf ruft man jetzt die Funktion f auf und übergibt ihr die 1 bzw. die 5. Innerhalb der Funktion wird jetzt jedes x durch die 1 bzw. 5 ersetzt und das Ergebnis ist dann der s.g. Rückgabewert der Funktion, der der Variablen y zugeordnet wird. Der einzige Unterschied beim Programmieren ist, dass eine Funktion nicht zwangsläufig ein Rückgabewert, also Ergebnis, haben muss und das man bessere Namen für die Funktionen und Übergabeparameter vergibt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.1 Wie werden Funktionen definiert

5.1 Wie werden Funktionen definiert

Das Schema sieht so aus: <DatentypDesRückgabewertes> <Funktionsname>([<Datentyp> <Variable1>, <Datentyp> <Variable2>, ...]) { <Anweisung>|[return <Wert>] }

Bei mehreren Funktionen ist die Reihenfolge im Quelltext wichtig, da man nur Funktionen aufrufen kann, die im Quelltext weiter oben stehen. Definiert man erst "Funktion1" und dann "Funktion2", kann dann zwar "Funktion2" "Funktion1" aufrufen aber nicht umgekehrt. Das kann man sich vorstellen wie in diesem Tutorial. Wenn man davon ausgeht, dass Sie bis jetzt alles gelesen haben, dann kann ich mich zu diesem Zeitpunkt nur auf Sachen beziehen, die bereits behandelt wurden, also weiter oben stehen, wohingegen ich mich schlecht auf Sachen beziehen kann, die erst in Kapitel 20 behandelt werden.

Um auf die kleine Matheaufgabe zurückzukommen, möchte ich ganz kurz zeigen, wie man dies in C programmieren würde, bevor ich dann ein etwas komplexeres Beispiel konstruiere.

 1
 2
 3
 4
 5
 6
 7
 8
					
int f(x) {
	return x * x;
}

// ...

int y1 = f(1);
int y2 = f(2);
					

Prinzipiell können Funktionen jede Art von Datentyp zurückgeben. Falls eine Funktion nichts zurückgeben soll, benutzt man den Pseudodatentyp "void". In diesem Fall darf die Funktion kein "return" besitzen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
34
					
// Funktion die zwar aus auf der Konsole ausgibt, aber nichts zurück gibt ///////
void ErsteAusgabe() {
	printf("Hallo ");
} // ErsteAusgabe /////////////////////////////////////////////////////////////



// Auch hier wieder keine Rückgabe //////////////////////////////////////////////
void ZweiteAusgabe() {
	printf("Welt\n");
} // ZweiteAusgabe //////////////////////////////////////////////////////////////



// Funktion addiert die zwei übergebenen Werte und gibt das Ergebnis zurück /////
int Addiere(int iSummand1, int iSummand2) {
	return iSummand1 + iSummand2;
} // Addiere ////////////////////////////////////////////////////////////////////



// DIESE FUNKTION WIRD IN JEDEM FALL ZUERST AUFGERUFEN //////////////////////////
int main(int argc, char** argv) {
	ErsteAusgabe();		// Die Klammern sind in jedem Fall wichtig
	ZweiteAusgabe();	// Die Aufrufe funktionieren, da die zwei Funktionen über dieser definiert wurden.
	
	int iZahl1 = 10;
	int iZahl2 = 20;
	
	printf("%i", Addiere(iZahl1, iZahl2));

	return 0;
} // main ///////////////////////////////////////////////////////////////////////
					

Ausgabe:

Hallo Welt
30
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

5.5 Standardparameter von Funktionen

5.5 Standardparameter von Funktionen

In gewissen Situationen kommt es vor, dass man eine Funktion bauen soll, welche eine bestimmte Aufgabe erledigen soll, die von verschiedenen Parametern abhängen, die der Programmierer aber, für den Normalfall, nicht immer angeben möchte. Dafür wurden s.g. Defaultparameter erfunden. Man braucht sie beim Funktionsaufruf nur anzugeben, wenn es quasi ein Spezialfall ist.

Hier mal ein Beispiel:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
					
// Gibt das Schema einer linearen Funktion an /////////////////////////////////
void PrintLinearFunction(float fAnstieg = 1.0f, float fVerschiebung = 0.0f) {
	// Wenn der Standardanstieg übergeben wurde
	if (fAnstieg == 1.0f) {
		// Wenn die Standardverschiebung übergeben wurde
		if (fVerschiebung == 0.0f) {
			printf("y = x");
		} else {
			printf("y = x + %g", fVerschiebung);
		} // end of if
	} else {
		// Wenn die Standardverschiebung übergeben wurde
		if (fVerschiebung == 0.0f) {
			printf("y = %gx");
		} else {
			printf("y = %gx + %g", fAnstieg, fVerschiebung);
		} // end of if
	} // end of if
} // PrintLinearFunction //////////////////////////////////////////////////////



// Hauptfunktion des Programms ////////////////////////////////////////////////
int main(int argc, char** argv) {
	// Normalerweise würde man hier den Anwender fragen, ob er den Anstieg und die Verschiebung
	// angeben möchte und in einer verschachtelten if Anweisung die Werte einlesen und entsprechend
	// Die Ausgabefunktion aufrufen, aber darum geht es hier nicht.

	PrintLinearFunction();	// Nutzt beide Defaultwerte
	PrintLinearFunction(5.0f);	// Nutzt nur den zweiten Defaultwert

	PrintLinearFunction(8.0f, 3.0f);
	PrintLinearFunction(1.0f, 7.0f);
	
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

y = x
y = 5x
y = 8x + 3
y = x + 7
		

Die Angaben der Standardwerte müssen mit in der Header-Datei angegeben werden.

Wenn Sie "Zwangsvariablen" und Defaultparameter definieren, müssen Sie darauf aufpassen, dass die Defaultparameter zu Letzt stehen, weil sonst der Compiler dies zurück weißt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.7 Funktionsprototypen

5.7 Funktionsprototypen

Ihnen ist vielleicht schon aufgefallen, dass ich bisher nur Funktionen aufgerufen habe, welche im Quelltext darüber standen. Im Normalfall muss das auch so sein, da ein Quelltext von oben nach unten abgearbeitet wird und man so nur auf Sachen verweisen kann, die bereits erwähnt wurden. Sie müssen sich das wie in einem Buch vorstellen. Angenommen Sie sind in Kapitel 2 und der Autor schreibt "„... Die Begründung haben wir bereits in Kapitel 20 kennen gelernt.". Aus Sicht des Lesers macht das keinen Sinn, da er ein Buch üblicherweise auch von vorne nach hinten ließt und man somit nicht Sachen wissen kann, welche erst später erwähnt werden.

Man muss also immer darauf achten, dass man seine Funktionen so anordnet, dass benötigte Funktionen weiter oben stehen. Aber muss das wirklich so sein? Angenommen Sie hätten so ein Buch, in welchem der Autor Sie auf ein späteres Kapitel verweist. Was machen Sie? Sicherlich werden Sie zu diesem Kapitel hin springen und dort schnell vorlesen. Aber wie genau funktioniert dieses hin springen? Klappen Sie das Buch an einer willkürlichen Stelle auf und schon haben Sie das richtige Kapitel? Wahrscheinlich nicht. Was Sie sicherlich tun werden ist, das Inhaltsverzeichnis am Anfang des Buches aufzuschlagen, um dort die benötigte Seitenzahl in Erfahrung zu bringen und genau so eine Art Inhaltsverzeichnis kann man auch für Funktionen bauen. Das benötigte Hilfsmittel sind die s.g. Funktionsprototypen. Falls Sie sich also keine Gedanken über die Reihenfolge Ihrer Funktionen machen wollen, dann können Sie am Anfang der Datei alle Funktionen (außer die "main") formal definieren und dann weiß; der Compiler schon im Vorfeld, dass es die Funktion gibt und wo er danach suchen muss.

Als erstes möchte ich noch einmal verdeutlichen, wie die Situation ohne Funktionsprototypen aussieht.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
int Kapitel1() {
	// Kann keine dieser Funktionen aufrufen
}

int main(int argc, char* argv) {
	// Kann nur Kapitel1 aufrufen
}

void Kapitel2(int iWert) {
	// Kann nur Kapitel1 aufrufen
}
					

Hier sehen Sie noch einmal, welche Funktion normalerweise welche Funktion aufrufen darf/kann. Um dies zu umgehen, werde ich im folgenden Quelltext, besagte Funktionsprototypen einführen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
// Inhaltsverzeichnis für diese Datei ///////////////////////////////////
int		Kapitel1();		// Funktionsprototyp für Kapitel1
void		Kapitel2(int iWert);	// Funktionsprototyp für Kapitel2
//////////////////////////////////////////////////////////////////////////



int Kapitel1() {
	// Kann jetzt Kapitel2 aufrufen
}

int main(int argc, char* argv) {
	// Kann Kapitel1 und jetzt auch Kapitel2 aufrufen
}

void Kapitel2(int iWert) {
	// Kann Kapitel1 aufrufen
}
					

Was Ihnen sofort auffallen sollte ist, dass man also die formale Deklaration des Funktionskopfes, an den Anfang der Datei kopiert. Der einzige und wichtigste Unterschied ist jedoch, dass man nach dem Funktionskopf ein Semikolon setzt! Hier benutzt man keine geschweiften klammern. Würde man dies tun, hätte man keinen Inhaltsverzeichniseintrag, sondern eine normale Funktion. Sie können sich folgende Eselsbrücke merken. Steht hinter einem Funktionskopf ein Semikolon, dann ist dies wie eine Seitenzahl anzusehen, welche nur im Inhaltsverzeichnis eines Buches, hinter einem Kapitelnamen steht. Steht hinter einem Funktionskopf eine geöffnete geschweifte Klammer, dann stellt jene Abschnitt einen ganz normalen Fließtext dar, also diverse Sätze und so etwas finden Sie nur innerhalb des Buches und somit handelt es sich um das eigentliche Kapitel, also um die eigentliche Funktion.

Im übernächsten Kapitel werde ich das Thema Header-Dateien ansprechen und genau dort sind solche Inhaltsverzeichnisse von Nöten und dort werde ich auf die Funktionsweise von Funktionsprototypen nicht weiter eingehen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.2 Was passiert bei einem Funktionsaufruf

5.2 Was passiert bei einem Funktionsaufruf

Zunächst müssen Sie Sich bewusst sein, dass ein Programm auch nur Daten darstellen, die ebenfalls im Speicher liegen. Bei der Ausführung gibt es einen so genannten Programmpointer, welcher immer auf die Zeile verweist, welche ausgeführt werden soll. Dieser kann mit einem Sprungbefehl auf einen anderen Speicherbereich verweisen und genau dies wird bei einem Funktionsaufruf getan. Genau dies soll folgende Grafik veranschaulichen.

Programmablauf bei einem Funktionsaufruf

Wie Sie im rechten Teil sehen können, kann es vorkommen, dass es nicht zwangsläufig zu einem Sprung kommen muss. Der Compiler ist bei einfachen Funktionen in der Lage, den Funktionsaufruf durch den entsprechenden Code zu ersetzen. Dies hat zur Folge, dass die resultierende Anwendung etwas größer wird, allerdings entfallen alle Sachen, die ich gleich erklären werde. Da man aber nicht unbedingt Einfluss darauf hat, ob der Compiler inlint oder nicht (streng genommen schon, aber daran hängen mehrere wichtige Faktoren, auf die es mir jetzt nicht ankommt), gehen wir jetzt mal vom linken Fall aus.

Dabei passieren jetzt mehrere Sachen. Als aller erstes werden alle Registerinhalte gesichert und auf Standartwerte gesetzt. Dies kostet logischerweise Speicherplatz. Als nächstes wird eine Rücksprungadresse auf dem Stack gesichert, damit dem Computer nach Beendigung der Funktion bekannt ist, welche Zeile als nächstes ausgeführt werden soll. Hier wird wieder zusätzlicher Platz benötigt.

Als nächstes wird die komplette Funktion analysiert und alle Variablen auf dem Stack erzeugt, die evtl. benötigt werden. Dieser Aspekt ist ganz interessant, denn ggf. werden gar nicht alle benötigt. Hier zeigt sich, dass es im Grunde egal ist, an welche Stelle man in der Funktion Variablen deklariert. Der Compiler kann bis auf wenige Sonderfälle nicht entscheiden, ob eine Variable tatsächlich benötigt wird und somit erzeugt er alle.

Nach der Abarbeitung der Funktion, werden nun zuerst alle Variablen der Unterfunktion freigegeben. Anschließend springt der Computer zur gespeicherten Rücksprungadresse und weißt ggf. den Wert des Funktionsergebnisses, einer Variable zu. Abschließend werden das Funktionsergebnis und die Rücksprungadresse vom Stack entfernt und die gesicherten Register werden zurückgeholt.

Vereinfachter Aufbau des Stacks bei einem Funktionsaufruf

Der graue Bereich ist also der Bereich, der bei jedem Funktionsaufruf zusätzlich im Stack angelegt werden muss. Daraus ergeben sich jetzt offensichtliche Nachteile.

Mit jedem Funktionsaufruf müssen also viele Sachen im Hintergrund gemacht werden, welche Rechenzeit benötigen. Wenn man also rekursive Funktionen baut, also solche, die sich selbst aufrufen, kann es schnell vorkommen, dass der Stack voll läuft (meistens 1 MB). Der Sprung im Code führt dazu, dass der neue Code nachgeladen werden muss (also vom RAM in den Cache) und in ganz seltenen, aber extremen Fällen, muss der neue Codeabschnitt sogar von der Festplatte nachgeladen werden, was noch mehr Verzögerung bedeutet.

Sie sehen also, dass ein Funktionsaufruf teuer sein kann. Wir sprechen hier von Millisekunden, aber wenn man eine Schleife mit einer Million Durchläufen hat, in welcher eine Funktion aufgerufen wird, können sich diese Millisekunden zu Minuten aufsummieren.

Ich möchte Sie jetzt auf keinen Fall verschrecken! Sie sollten nur im Hinterkopf behalten, dass ein Funktionsaufruf den Quelltext zwar übersichtlicher macht, aber dass man sich diese Übersichtlichkeit teuer erkauft. Ein guter Programmierer setzt Funktionen mit Bedacht ein. Es macht keinen Sinn, eine kleine Berechnung in eine Funktion zu packen (vorausgesetzt der Compiler schafft nicht es, diese Berechnung zu inlinen, wovon man ausgehen muss). Hat man aber eine einzige Funktion, die über 100 Zeilen geht, ist es doch ratsam, jene aufzuteilen.

Spielentwickler handeln hier eher paranoid und versuchen so wenig wie möglich Funktionen zu benutzen, da jede Millisekunde darüber entscheidet, ob das Spiel flüssig laufen wird oder ruckelt. Sie sollten aber fürs erste lieber an die Übersichtlichkeit denken und eher mehr als zu wenige Funktionen benutzen. Erst in dem Moment, an denen das Programm tatsächlich schneller laufen muss (weil Wartezeiten unerträglich werden), sollten Sie anfangen zu Optimieren und Funktionen vereinigen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.3 call-by-value

5.3 call-by-value

Wenn man eine Funktion mit einem oder mehreren Werten aufruft, wird nicht die Variable an sich übergeben, sondern die Funktion legt sich eine neue Variable mit einem neuen Namen an (der durchaus auch gleich sein kann) und der Wert wird rein kopiert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
					
// Addiert die zwei übergebenen Werte, addiert eins drauf und gibt Ergebnis zurück
int AddiereUndPlus1(int iSummand1, int iSummand2) {
	// Im Hintergrund wurden die zwei neuen Variablen iSummand1 und iSummand2 erzeugt, wobei
	// der Wert von iZahl1 in iSummand1 rein kopiert wurde und der Wert von iZahl2 in
	// iSummand2. Die Werte in iZahl1 und iZahl2 bleiben ansonsten unberührt. 
	
	int iErgebnis	= iSummand1 + iSummand2;
	iSummand1	= iSummand1 + 1;
	iSummand2	+= 1;	// sieht anders aus wie die Zeile darüber, macht aber das Gleiche
	
	printf("In der Funktion:\n");
	printf("%i\n", iSummand1);
	printf("%i\n", iSummand2);

	return iSummand1 + iSummand2;
} // AddiereUndPlus1 //////////////////////////////////////////////////////////



// DIESE FUNKTION WIRD IN JEDEM FALL ZUERST AUFGERUFEN ////////////////////////
int main(int argc, char** argv) {	
	int iZahl1 = 10;
	int iZahl2 = 20;
	
	printf("Vor dem Aufruf:\n");
	printf("%i\n", iZahl1);
	printf("%i\n", iZahl2);
	
	AddiereUndPlus1(iZahl1, iZahl2);	// Die Funktion gibt zwar was zurück, aber den Wert
						// brauchen wir jetzt nicht
	
	printf("Nach dem Aufruf:\n");
	printf("%i\n", iZahl1);
	printf("%i\n", iZahl2);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Vor dem Aufruf:
10
20
In der Funktion:
11
21
Nach dem Aufruf:
10
20

		

Was ist jetzt eigentlich passiert. Um dies zu verstehen, schauen wir uns am besten folgende Grafik an.

Programmablauf und Speicherabbild bei call-by-value

In der Funktion "main" werden zuerst zwei Variablen "iZahl1" und "iZahl2" auf dem Stack erzeugt und anschließend mit den Werten "10" und "20" initialisiert. Dies verdeutlicht der erste obere Kasten.

In Zeile 29 erfolgt jetzt der Funktionsaufruf. Dabei passieren wieder mehrere Sachen, welche ich im lertzten Unterkapitel erläuterte. Wichtig ist momentan nur, dass zuerst die Rücksprungadresse auf den Stack gepackt wird und anschließend werden alle Variablen, die in der Funktion vorkommen, erzeugt. Dabei werden die Funktionsargumente, also die Variablen im Funktionskopf, initialisiert. Dies soll der zweite Kasten verdeutlichen. Das Augenmerk sollte hier darauf liegen, dass die Variablen "iSummand1" und "iSummand2" neue zusätzliche Variablen sind und eine Kopie des Originalwertes erhalten.

Nach den ganzen Berechnungen sieht der Speicher wie der dritte Kasten aus und das entspricht dem, was in den Zeilen 11 bis 13 ausgegeben wird.

Nach dem Funktionsende werden die erzeugten Variablen vom Stack entfernt und der Computer macht in der Programmzeile weiter, welche ebenfalls auf dem Stack abgelegt wurde, also Zeile 32. Der Speicher sieht dann wieder so aus, wie im vierten Kasten. Wie Sie sehen, sind die Ausgangsvariablen unverändert und das erklärt auch die Ausgabe in den Zeilen 32 bis 34.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.6 Überladen von Funktionen

5.6 Überladen von Funktionen

Hin und wieder gibt es Situationen, in welchen zwei Funktionen fast identischen Code ausführen, nur mit dem Unterschied, dass die Übergabeparameter einen anderen Type besitzen. Der Programmierer hat somit doppelten Aufwand. Zudem kommt hinzu, dass auch eine Doppelte Fehlerquelle da ist, da man Fehler ggf. an zwei Stellen beheben müsste.

Um dem ganzen aus dem Weg zu gehen, wurde ein Mechanismus erfunden, welcher sich Überladen nennt. Im Folgenden Beispiel wird nicht viel Code ausgeführt und von daher sehen Sie da nicht die Code-Ersparnis, aber es verdeutlicht die Funktionsweise ganz gut.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
					
// Berechnet eine Potenz ////////////////////////////////////////////////////// 
double CalcPower(float fBasis, int iExponent) {
	double dResult	= 1;

	// Nur für positive Exponenten
	if (iExponent >= 0) {
		// Multiplizieren
		for (int iCounter=1; iCounter<=iExponent; iCounter++) {
			dResult *= fBasis;
		} // end of if
	} else {
		return 0;
	} // end of if

	return dResult;
} // CalcPower ////////////////////////////////////////////////////////////////



// Benutzt die Ausgansfunktion, damit nicht alles doppelt geschrieben werden muss
int CalcPower(int iBasis, int iExponent) {
	double dResult = CalcPower((float)iBasis, iExponent);
	return (int)dResult;
} // CalcPower ////////////////////////////////////////////////////////////////



// Hauptfunktion des Programms ////////////////////////////////////////////////
int main(int argc, char* argv[]) {
	float	fBasis1		= 5.2;
	int	iBasis2		= 10;
	int	iExponent	= 3;

	printf("%g\n", CalcPower(fBasis1, iExponent));
	printf("%i\n", CalcPower(iBasis2, iExponent));	// benutzt überladende Funktion

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

140.608
1000
		

Gerade bei gezeigtem Konstrukt, muss darauf geachtet werden, dass die Funktion mit dem eigentlichen Algorithmus, an oberster Stelle steht und alle anderen, die jene benutzen, darunter.

Beim Überladen ist wichtig, dass etwas unterschiedlich ist. Sei es die Anzahl der Übergabeparameter oder, wenn es genauso viele sind, der Typ einer oder aller Variablen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.4 call-by-reference

5.4 call-by-reference

Manchmal ist es vonnöten, dass Funktionen mehrere Werte zurück geben können müssen. Dies geht aber nicht. Von daher hat man sich etwas anderes überlegt. Man übergibt nicht nur die Werte der Variablen, sondern gleich die Adressen bzw. die Referenz der Variable. Somit ist die Funktion in der Lage die Originalwerte zu manipulieren. Die Funktion erzeugt also keine Kopie. (allerdings dürfen die zwei verfahren gemischt werden, aber das lasse ich hier weg). Sie haben dieses Prinzip bereits bei der Funktion "scanf" kennen gelernt. Streng genommen passierte da zwar etwas anderes, aber um es sich vorstellen zu können was im Hintergrund passiert, reicht der Vergleich.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
					
// Addiert die zwei übergebenen Werte, addiert eins drauf und gibt Ergebnis zurück
int AddiereUndPlus1(int &iSummand1, int &iSummand2) {
	// Auffällig ist hier, dass vor dem Namen der Variablen noch so ein & steht. Wir
	// greifen jetzt mit iSummand1 direkt auf iZahl1 zu und können ihren Wert
	// manipulieren. Das Gleiche gilt für iSummand2 und iZahl2. iSummand1 und iSummand2
	// sind jetzt also keine neuen Variablen sondern ein (vereinfacht) "Alias"
	
	int iErgebnis	= iSummand1 + iSummand2;
	iSummand1	+= 1;
	iSummand2++;	// sieht anders aus wie die Zeile darüber, macht aber das gleiche
	
	printf("In der Funktion:\n");
	printf("%i\n", iSummand1);
	printf("%i\n", iSummand2);

	return iSummand1 + iSummand2;
} // AddiereUndPlus1 //////////////////////////////////////////////////////////



// DIESE FUNKTION WIRD IN JEDEM FALL ZUERST AUFGERUFEN ////////////////////////
int main(int argc, char** argv) {
	int iZahl1 = 10;
	int iZahl2 = 20;

	printf("Vor dem Aufruf:\n");
	printf("%i\n", iZahl1);
	printf("%i\n", iZahl2);

	AddiereUndPlus1(iZahl1, iZahl2);	// Die Funktion gibt zwar was zurück, aber den Wert
						// brauchen wir jetzt nicht

	printf("Nach dem Aufruf:\n");
	printf("%i\n", iZahl1);
	printf("%i\n", iZahl2);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Vor dem Aufruf:
10
20
In der Funktion:
11
21
Nach dem Aufruf:
11
21
		

Wie Sie jetzt anhand der Ausgabe sehen, war die Funktion in der Lage, die übergebenen Variablen so zu verändern, dass diese Änderung auch nach der Funktion ersichtlich ist. Im Gegensatz zu "scanf", stand der "&" Operator allerdings nicht beim Funktionsaufruf, sondern im Funktionskopf bei der Parameterdeklaration. Nach außen hin, passiert das Gleiche, aber wie erwähnt, macht es intern schon einen Unterschied. Dies verdeutlicht noch einmal folgende Grafik.

Programmablauf und Speicherabbild bei call-by-reference

Der erste Kasten sieht wie vorhin aus, denn in der Funktion "main" ist alles wie gehabt.

Nach dem Funktionsaufruf sieht die Sache aber etwas anders aus. Es wird wieder die Rücksprungadresse auf den Stack gepackt, aber dieses Mal wird nur noch die Variable "iErgebnis" neu erzeugt. Da wir jetzt eine Referenz übergeben haben, kann der Computer jetzt mit den ursprünglichen Variablen arbeiten. Dies verdeutlicht der zweite Kasten.

Nachdem wieder die Berechnungen durchgeführt wurden, sieht der Speicher wie im dritten Kasten aus und das erklärt wiederum die Ausgaben in den Zeilen 11 bis 13. Beachten Sie bitte, dass hier tatsächlich die Originalvariablen manipuliert werden. Sie werden in der Funktion lediglich durch eine Art Alias angesprochen.

Nach dem Funktionsaufruf springt der Computer wieder in die entsprechende Zeile und alle nicht mehr benötigten Variablen werden wieder entfernt. Dies zeigt Kasten vier und erklärt auch die Ausgaben in den Zeilen 33 bis 35.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.8 Konstante Funktionsparameter

5.8 Konstante Funktionsparameter

Gerade bei erfahrenen Programmierern sieht man, dass oft das Schlüsselwort "const" bei der Deklaration der Argumente von Funktionen benutzt wird. Dies hat zur Folge, dass die Werte der Parameter nicht innerhalb der Funktion geändert werden können. Dies macht man aber nicht um sich selber einzuschränken, sondern um den Programmierer, welcher diese Funktion aufruft zu gewährleisten bzw. im zu versichern, dass genau mit diesen Werten gearbeitet wird. Hier mal ein Beispiels.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
// Addiert zwei Zahlen miteinander und gibt Ergebnis zurück ///////////////////
int Addieren(const int iSummand1, const int iSummand2) {
	return iSummand1 + iSummand2;
} // Addieren ///////////////////////////////////////////////////////////////////

// ...

int iZahl1	= 10;
int iZahl2	= 20;
int iErgebnis	= Addiere(iZahl1, iZahl2);
					

In erster Linie macht man dies nur für einen besseren Programmierstil. Keiner ist gezwungen sich darüber Gedanken machen zu müssen, ob man die Argumente tatsächlich konstant halten will oder nicht. Allerdings macht man sich dann doch mehr Gedanken, wenn man Referenzen übergibt und vor allem dann, wenn man Schnittstellen baut.

Intern hat diese Vorgehensweise auch Auswirkungen. Beispielsweise werden diese Funktionsargumente nicht auf dem Stack angelegt, wie vorhin beschrieben. Sie werden in dem Teil des Programmspeichers abgelegt, welcher speziell für Konstanten gedacht ist. Das hat dann zur Folge, dass man bei einem Funktionsaufruf weniger zusätzlichen Speicher im Stack benötigt.

Der erfahrene Programmierer verwendet also diese Art des Funktionsaufrufes wo immer er kann, aber für den Anfang sollten Sie Sich lieber davon distanzieren, da diese Schreibweise zu Beginn eher umständlich ist und noch andere Nebeneffekte hat, welche man bedenken muss bzw. welche gravierende Auswirkungen auf den Quelltext haben können. Deshalb werde ich auch auf diese Schreibweise, zu Gunsten der Verständlichkeit, verzichten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

5.9 Übungsaufgaben IV

5.9 Übungsaufgaben IV

  1. Erstellen Sie in der "main" eine Variable, die ein Zeichen Aufnehmen kann und benennen Sie sie "cEingabe".
  2. Erstellen Sie oberhalb der "main" eine Funktion mit dem Namen "MainMenu", welche ein Zeichen zurück gibt und keine Übergabeparameter hat. In ihr sollen drei Punkte zur Auswahl stehen (1 = Division von Integern, 2 = Division von Floats, 3 = Programm beenden). Anschließend soll ein Zeichen eingelesen werden. Bauen Sie um die Ein - und Ausgabe eine Schleife, welche so lange dieses Menü zeigen und die Eingabe tätigen soll, bis einer der drei erlaubten Werte eingegeben wurde. Sobald ein gültiger Wert eingegeben wurde, soll jener zurückgegeben werden.
  3. Weißen Sie den Rückgabewert des Menüs der vorhin deklarierten Variable als Initialwert zu und bauen Sie anschließend ein Fallunterscheidung ein, welche auf '1' oder '2' reagiert. Im Standardfall soll der Text "Auf Wiedersehen!" ausgegeben werden.
  4. Erstellen Sie eine Funktion mit dem Namen "Convert", welche einen booleschen Wert zurück geben soll und als Parameter ein Integer als Referenz haben soll. In der Funktion soll nun geprüft werden, ob der Parameter gleich 0 ist und falls dem so ist, soll er soll er auf 1 gesetzt und "true" zurückgegeben werden. Anderen Falles soll nur "false" zurück gegeben werden. Später könnte man so feststellen, dass eine Konvertierung notwendig war und ggf. die Eingabe wiederholen lassen.
  5. Bauen Sie jetzt eine Funktion, welche jene aus Aufgabe 4, überlädt. Anstelle eines Integers, soll ein Float übergeben werden. Die Funktion soll das Gleiche machen, wie die in Aufgabe 4.
  6. Erstellen Sie zwei inline Funktionen, welche sich auch überladen sollen, mit dem Namen "Divide". Ihr sollen einmal zwei Integer und einmal zwei Floats übergeben werden oder den Defaultparameter 1 haben. Der Rückgabetyp soll der gleiche sein, wie die Übergabeparameter. In der Funktion soll der zweite Parameter konvertiert werden und das Ergebnis aus Wert 1 geteilt durch Wert 2, soll zurückgegeben werden. Die Konvertierung ist hier notwendig, damit kein Fehler beim Teilen durch Null auftritt.
  7. Erweitern Sie nun noch die "main", um die notwendigen Ein - und Ausgaben in den Fallunterscheidungen (zwei Werte einlesen, mit Hilfe der Funktionen Berechnung durchführen und Ergebnis ausgeben). Erstellen Sie zum Schluss noch Funktionsprototypen am Anfang der Datei.
Zum Seitenanfang
Zum Inhaltsverzeichnis

1 Variablen I

1 Variablen I

Eine Variable ist ein Container, welcher eine bestimmte Art an Informationen aufnehmen kann. Sie dienen zum Speichern von Eingaben, Ergebnissen oder Zuständen und man kann mit ihnen auch Rechnen. Manche Variablen können nur ganze Zahlen, andere gebrochene Zahlen und wieder andere nur Buchstaben bzw. Symbole aufnehmen oder gar nur ein logisches ja oder nein. Die Art der Information, welche eine Variable aufnehmen kann, verleiht ihr den Typ. Man spricht also von einem Variablentypen. Variablen mit einem unterschiedlichen Typ, benötigen einen unterschiedlichen Speicherbedarf. Es gibt auch Typen, welche den gleichen Speicherbedarf benötigen, allerdings wird jener dann anders interpretiert. Man muss sich das in etwa wie verschiedene Töpfe und Pfannen vorstellen. Eine kleine Pfanne benutzt man beispielsweise für ein Spiegelei und man braucht nur eine kleine Herdplatte. Ein großer Topf für Nudeln, kann wesentlich mehr aufnehmen und benötigt eine große Herdplatte. Eine sehr große Bratenpfanne nimmt noch mehr auf und braucht deshalb zwei Herdplatten.

Variablentypen und deren Speicherbedarf

So ähnlich ist es auch bei Variablen. Kleine Variablen, welche nur Buchstaben aufnehmen, benötigen nur ein Byte. Variablen für ganze Zahlen benötigen mehr Platz, nämlich vier Byte, wohingegen sehr große Gleitkommazahlen, mit einer hohen Genauigkeit, acht Byte benötigen.

In C++ gibt es viele verschiedene Variablentypen, welche zur Verfügung stehen. Für dieses Kapitel beschränke ich mich auf die einfachsten Typen. Sie werden nach folgendem Schema deklariert und initialisiert:
<VariablenTyp> <NameDerVariable> [= <WertDenSieBekommenSoll>];

Zum Seitenanfang
Zum Inhaltsverzeichnis

1.1 Einfache Variablen

1.1 Einfache Variablen

Die einfachen Variablen werden im s.g. Stack des Arbeitsspeichers gehalten. Der RAM unterteilt sich in zwei Teile, dem Stack (verhältnismäßig sehr klein) und dem Heap (der ganze große Rest). Alle Variablen die im Stack gehalten werden, werden durch das System reserviert und freigegeben. Daraus resultiert, dass man sich nicht um die Speicherverwaltung kümmern muss.

Bevor man anfangen kann zu Kochen, muss man sich einen Topf aus dem Schrank holen und genauso muss man beim Programmieren erst eine Variable zur Verfügung stellen, bevor man etwas in ihr ablegen kann. Dieses Bereitstellen nennt man definieren einer Variable. Bevor man aber mit einer Variable arbeitet, sollte man ihr gleich zu Beginn einen sinnvollen Wert zuweisen. Dies ist wichtig, weil bei der Definition einer Variable, irgendein Speicherbereich zur Verfügung gestellt wird, welcher vorher einem anderen Programm zur Verfügung stand. Ergo können Sie niemals vorher sagen, was an dieser Stelle im RAM steht und somit ist die Variable zu Beginn mit einem scheinbar willkürlichen Wert gefüllt. Sie müssen sich das vorstellen, als wohnten Sie in einer WG mit schlampigen Mitbewohnern, die den Topf in den Schrank stellen, ohne ihn vorher abgewaschen zu haben. Bevor Sie den Topf also benutzen können, müssen Sie sicher stellen, dass er sauber ist, ihn also abwaschen und können dann gleich Wasser einfüllen. Das nennt man Initialisieren einer Variable.

Im folgendem zeige ich wie man sie definiert und gleich initialisiert. Hier mal die Wichtigsten:

Zum Seitenanfang
Zum Inhaltsverzeichnis

1.6 Datentypen in 64 Bit Systemen

1.6 Datentypen in 64 Bit Systemen

In 64 Bit Systemen kommt es vor, dass manche Datentypen, wie z.B. ein Integer oder ein Handle, statt 32 Bit, 64 Bit besitzt. Dies hängt aber nicht alleine vom Betriebssystem ab, sondern auch von der Entwicklungsumgebung bzw. viel mehr vom Compiler. Der vergrößerte Speicherbereich, hängt mit dem größeren Adressraum zusammen, der verwaltet werden muss. Deswegen sind Pointer größer.

Des Weiteren gibt es auch in 32 Bit Systemen s.g. "WideChar" Datentypen, welche dafür benutzt werden, um Unicode oder UTF-16 aufzunehmen (für erweiterten Schriftsatz um z.B. chinesische oder russische Zeichen darzustellen). Gerade in der "WinAPI" findet man häufig Funktionen in drei verschiedenen Ausführungen, wobei eine davon immer auf "A" und die dritte immer auf "W" endet. Der Hintergrund ist, dass die Funktionen mit dem A einen 8 Bit Zeichensatz und die mit W einen 16 Bit Zeichensatz verlangen. Die Funktionen ohne einen zusätzlichen Buchstaben, sind die intelligenten Funktionen und entscheiden, anhand von internen Schaltern (darauf werde ich später noch eingehen), welche der anderen zwei Funktionen tatsächlich benutzt wird.

Zum Seitenanfang
Zum Inhaltsverzeichnis

1.3 Rechnen mit Variablen

1.3 Rechnen mit Variablen

Der Wert von Variablen kann stets überschrieben werden. Zudem kann man Variablen mathematisch verknüpfen und das Ergebnis der Operation einer anderen Variable zuweisen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
					
int iGeldVonHans	= 10;				// Variable bekommt Wert 10
int iGeldVonPaul	= 100;				// Variable bekommt Wert 100
int iGesamt		= 0;				// Variable bekommt Wert 0

iGeldVonHans		= iGeldVonHans * 2;		// Geld von Hans verdoppeln
iGeldVonPaul		= iGeldVonPaul - 200;		// Paul macht schulden
iGesamt			= iGeldVonHans + iGeldVonPaul;	// Paul und Hans haben
							// zusammen 80 Schulden
iGesamt			+= 1000;			// Oma Schenkt den beiden 1000
							// sie haben jetzt 920
iGesamt			*= 2;				// Der Gesamtbetrag wird beim
							// Glücksspiel verdoppelt
					

Bei der Verknüpfung zweier Werte, mittels mathematischen Operationszeichen, ist der Typ des Ergebnisses vom größten eingegangenen Typs (nicht Wert). Dies sollte man immer im Hinterkopf behalten, denn das sind beliebte Fehlerquellen.

 1
 2
 3
 4
 5
 6
 7
 8
					
int	iZahl1		= 5;
int	iZahl2		= 10;

float	fErgebnis1	= iZahl1 / iZahl2;		// = 0, da int der größte
							// eingegangene Wert war
float	fErgebnis2	= float(iZahl1) / iZahl2;	// = 0.5, da float der
							// größte Wert war
float	fErgebnis3	= 1.0f / 4.0f;			// = 0.25
					

Einer der wichtigsten Operatoren, ist der s.g. Modulo-Operator "%". Mit ihm bekommt man den Divisionsrest. Mit ihm kann man beispielsweise prüfen, ob eine Zahl, durch eine andere teilbar ist. Gerade im Bereich der Verschlüsslung oder dem Ermitteln von Prüfsummen, kommt dieser Operator ständig zum Einsatz. Hier mal ein kleines Beispiel.

 1
 2
 3
					
int iZahl	= 13;
int iRest	= iZahl % 5;	// iRest bekommt den Wert 3,
				// weil 13 / 5 = 2 Rest 3
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

1.2 Typumwandlungen

1.2 Typumwandlungen

Einfache Variablentypen lassen sich mittels Typumwandlungen (cast) ineinander überführen. Dabei ist allerdings zu beachten, dass es einen Verlust geben kann, wenn man eine höherwertige Variable in eine niederwertige umwandelt (z.B. float zu int).

In diesem Unterkapitel werde ich mich auf die einfachste Art der Typumwandlung beschränken. Später sehen Sie dann, welche anderen Möglichkeiten es gibt und was der Unterschied ist.

Variante 1: (<Zieldatentyp>)<Variablenname>|<Wert>

 1
 2
 3
 4
 5
 6
 7
 8
					
float	fGewicht		= (float)548.7354;
int	iAbgerundet		= (int)fGewicht;	// = 548

char	cZeichen		= '5';
int	iEingabe		= (int)cZeichen;	// = 53 (ASCII-Wert)

int	iBreite			= 18;
float	fGenaueBreite		= (float)iBreite;	// = 18.0000000
					

Variante 2: <Zieldatentyp>(<Variablenname>|<Wert>)

 1
 2
					
float	fGewicht		= float(548.7354);
int	iAbgerundet		= int(fGewicht);	// eher selten genutzt
					

Castings werden in der objektorientierten Programmierung eine große Rolle spielen, aber mehr dazu später.

Zum Seitenanfang
Zum Inhaltsverzeichnis

1.4 Wichtige Datentypen im klassischen ANSI C (16 Bit)

1.4 Wichtige Datentypen im klassischen ANSI C (16 Bit)

Typ Größ Zahlenbereich Formatierungszeichen
short 2 Byte -32.768 - 32.767 hi
unsigned short 2 Byte            0 - 65.535 hu
int 2 oder 4Byte i
unsigned int 2 Byte            0 - 65.535 u
long 4 Byte -2.147.483.648 - 2.147.483.647 li
unsigned long 4 Byte                             0 - 4.294.967.295     lu
float 4 Byte 3,4*10-34 - 3,4*1034 f

Zum Seitenanfang
Zum Inhaltsverzeichnis

1.5 Wichtige Datentypen für C++ (32 Bit)

1.5 Wichtige Datentypen für C++ (32 Bit)

Typ Größ Zahlenbereich Formatierungszeichen
bool 1 Byte 0 oder 1 i
char 1 Byte      0 - 255 c
unsigned char 1 Byte -128 - 127 hu
short int 2 Byte -32.768 - 32.767 hi
int 4 Byte -2.147.483.648 - 2.147.483.647 i
unsigned int 4 Byte                             0 - 4.294.967.295    u
long 4 Byte -2.147.483.648 - 2.147.483.647 li
unsigned long 4 Byte                         0 - 4.294.967.295 lu
float 4 Byte 3,4*10-34 - 3,4*1034 f, e oder g
long long int 8 Byte       -9,2 * 1018 - 9,2 * 1018/td> lli
double 8 Byte 1,7*10-308 - 1,7*10308 lf, le, oder lg
long double 10 Byte 3,4*10-4932 - 1,1*104932 lf le oder lg
Zum Seitenanfang
Zum Inhaltsverzeichnis

4 Schleifen

4 Schleifen

Ein weiteres wichtiges Werkzeug für die Programmierung sind die Schleifen. C++ stellt hierfür drei verschiedene zur Verfügung, die nachstehend erklärt werden. Prinzipiell würde man in C++ nur mit der for Schleife auskommen, aber aus Gründen der Übersichtlichkeit und des Verständnisses, werden von Fall zu Fall andere benutzt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

4.2 Die Kopf gesteuerte abweisende while Schleife

4.2 Die Kopf gesteuerte abweisende while Schleife

Bei dieser Schleife wird, noch bevor die Schleife betreten, wird geprüft, ob die Bedingung zutrifft. Ist dem nicht so, wird die Schleife übersprungen. Diese Schleife wird oft benutzt, wenn noch nicht absehbar ist, wie viele Durchläufe benötigt werden.

Das Schema lautet: while ([<Bedingungen>]} [<Anweisung>]

Strukturgramm einer while Schleife

Den PAP spare ich mir an dieser Stelle, weil er genauso aussieht, wie bei der for Schleife, aber ein kleines Beispiel wird wieder folgen, in welchem ich durch den Anwender eine Zahl einlesen lasse und dann so lange zurück zähle, bis 0 erreicht wird. Da hier also zur Entwurfszeit nicht fest steht, wie oft die Schleife durchlaufen werden muss, nimmt man besser eine while Schleife. Aber wie gesagt, man bekommt das auch mit einer for Schleife hin.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
int iAnzahl;

printf("Bitte eine Zahl eingeben: ");
scanf("%i", &iAnzahl);

// Solange, bis die Zahl 0 ist
while (iAnzahl >= 0) {
	printf("%i\n", iAnzahl--);
} // end of while

printf("ENDE");
					

Ausgabe:

Bitte eine Zahl eingeben: 0
ENDE
		

oder

Bitte eine Zahl eingeben: 5
5
4
3
2
1
0
ENDE
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

4.4 Übungen III

4.4 Übungen III

  1. Lassen Sie alle Zahlen zwischen 1 und 100 mit einer for Schleife ausgeben.
  2. Lassen Sie vom Benutzer zwei positive Ganzzahlen eingeben und berechnen Sie die eine hoch die andere. Benutzen Sie wieder die for Schleife.
  3. Geben Sie das kleine ein mal eins aus. Benutzen Sie dazu zwei ineinander geschachtelte for Schleifen. Achten Sie zudem darauf, dass nach Jeder Reihe ein Zeilenumbruch gemacht wird (zehn Zeilen entstehen mit je zehn Werten).
  4. Ändern Sie Aufgabe 3 so ab, dass die Berechnungen deren Ergebnis kleiner 10 ist, um jeweils ein Zeichen eingerückt werden, so dass es keine Verschiebung in der Tabelle gibt und die Einerstellen unter den Einerstellen stehen. Versuchen Sie dies in zwei verschiedenen Varianten zu lösen.
  5. Lassen Sie vom Benutzer eine positive Ganzzahl eingeben und geben Sie dann von dieser Zahl aus, alle Zahlen bis zur 1 aus. Benutzen Sie eine kopfgesteuerte While-Schleife.
  6. Lassen Sie so lange vom Benutzer ein Zeichen eingeben, bis er "j" oder "J" gedrückt hat. Fordern Sie den Benutzer immer wieder zu dieser Eingabe auf und benutzen Sie die fußgesteuerte do while Schleife.
  7. Berechnen Sie π mit der Formel Formel für die Berechnung von PI Testen Sie selbst aus, wie große der Bereich gewählt werden muss, damit das Annäherungsverfahren auf sechs Stellen genau ist. Kommen Sie aber nicht auf die Idee, -1 hoch irgendwas zu rechnen. Das geht auch anders, wenn man sich überlegt, was passieren soll. Zur Kontrolle, π lautet 3,141592.
Zum Seitenanfang
Zum Inhaltsverzeichnis

4.3 Die fußgesteuerte nicht abweisende do while Schleife

4.3 Die fußgesteuerte nicht abweisende do while Schleife

Sie funktioniert so ähnlich wie die einfache while-Schleife, nur dass sie auf jeden Fall einmal durchlaufen wird, weil die Prüfung am Ende steht. Dies wird oftmals benutzt, um etwas so lange zu machen, wie etwas nicht stimmt (z.B. eine Eingabe).

Das Schema lautet: do [<Anweisung>] while ([<Bedingungen>]);

Strukturgramm einer do while Schleife

PAP einer do while Schleife

Im Strukturgramm sieht man wieder schön, dass erst etwas gemacht wird, und dann erst eine Prüfung statt findet. Auch hier gibt es wieder eine vereinfachte grafische Darstellung.

Im nachfolgenden Beispiel wird der Anwender so lange aufgefordert ein j einzugeben, bis er dies tut. Dieses Beispiel lässt sich gut für Hauptmenüs verwenden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
char cZeichen;

do {	// Solange bis "j" eingegeben wurde
	printf("Geben Sie j ein um das Programm zu beenden: ");

	fflush(stdin);
	scanf("%c", &cZeichen);
} while (cZeichen != 'j');

printf("Auf Wiedersehen!");
					

Ausgabe:

Geben Sie j ein um das Programm zu beenden: k
Geben Sie j ein um das Programm zu beenden: 15
Geben Sie j ein um das Programm zu beenden: ja
Geben Sie j ein um das Programm zu beenden: j
Auf Wiedersehen!
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

4.1 Die kopfgesteuerte Zählschleife for

4.1 Die kopfgesteuerte Zählschleife for

Wie bereits erwähnt, kann man mit dieser Schleife jeden Anwendungsfall abdecken, bei dem man eine Schleife benötigt, aber wie der Name schon sagt, benutzt man diese Hauptsächlich zum Ab - / Durchzählen von abzählbaren Mengen.

Das Schema lautet: for ([<Anweisung>]; [<Bedingungen>]; [<Anweisung>]) [<Anweisung>]

Strukturgramm einer for Schleife

Vollständiger und gekürzter PAP einer for Schleife

Gerade im PAP sieht man sehr schön, dass hier ein ganzer Block von Befehlen immer und immer wieder ausgeführt wird, bis eine bestimmte Bedingung nicht mehr gültig ist (weil z.B. der ab zuzählende Bereich durchlaufen ist). Rechts daneben sehen Sie eine andere gekürzte Form, wie man solche Schleifen grafisch darstellen kann.

Nachfolgend nun noch ein kleines Beispiel für eine for Schleife.

 1
 2
 3
 4
					
// Gebe alle geraden Zahlen zwischen 1 bis 10 aus
for (int iCounter = 2; iCounter <= 10; iCounter = iCounter + 2) {
	printf("%i ", iCounter);
} // end of for
					

Ausgabe:

2 4 6 8 10
		

Als erstes wird nachgesehen, ob im ersten Teil des Schleifenkopfes etwas steht und wenn dem so ist, wird diese Anweisung ausgeführt. Üblicherweise definiert man sich hier eine Zählvariable und initialisiert sie. Als nächstes wird die Schleifenbedingung geprüft und nur wenn diese Bedingung wahr ist, wird der Schleifenkörper ausgeführt. Wenn die Bedingung von vornherein nicht gültig ist, wird die Schleife komplett übersprungen. Nachdem der Schleifenkörper ausgeführt wurde, wird der dritte Teil des Schleifenkopfes ausgeführt. In dieser Anweisung zählt man üblicherweise die Zählvariable hoch bzw. herunter. Anschließend wird erneut die Schleifenbedingung geprüft und falls sie immer noch einen wahren Ausdruck liefert, wird erneut der Schleifenkörper ausgeführt usw.

Mit dem Befehl "continue" kann man den Rest des Schleifenkörpers überspringen und somit gleich den ggf. nächsten Durchlauf anstoßen. Dies macht z.B. dann Sinn, wenn der Schleifenkörper nur ausgeführt werden soll, wenn eine Randbedingung zutrifft. Sicher kann man auch um den entsprechenden Quelltext eine if Anweisung herum bauen, aber somit würde der Inhalt weiter eingerückt, was die Lesbarkeit evtl. beeinträchtigen könnte. Somit ist "continue" also ein rein stilistisches Mittel.

Außerdem gibt es noch den Befehl "break". Wie Sie sich vielleicht denken können, kann man mit ihm, vorzeitig die Schleife verlassen. Normalerweise kann man auch auf das "break" verzichten, da man nur seine Schleifenbedienung entsprechen anpassen kann, aber es gibt trotzdem Fälle, in welchen man auf diese Anweisung nicht verzichten kann. Angenommen es sollen in einer Schleife mehrere Personenangaben in eine Datei geschrieben werden und mitten drin steigt die Festplatte aus, was zur Folge hat, dass die Datei nicht länger beschrieben werden kann (ist nicht ganz richtig, da hier der Cache und der RAM eine Rolle spielen, aber es geht auch mehr ums Prinzip). Somit ist es nicht weiter sinnvoll, auch noch die restlichen Angaben einer Person schreiben zu wollen und die anderen noch anstehenden Schleifendurchläufe wären auch hinfällig. Hier macht ein vorzeitiges Beenden Sinn.

Sie merken, dass ich auch hier ein wenig ins rudern gerate, weil sowohl "continue" und "break" innerhalb von schleifen selten benutzt werden und somit ist es schwierig, ein passendes Beispiel zur Veranschaulichung zu finden.

Zum Seitenanfang
Zum Inhaltsverzeichnis

14 Showcase I

14 Showcase I

coming soon...

Zum Seitenanfang
Zum Inhaltsverzeichnis

13 Dateien

13 Dateien

So richtig macht Programmieren nur Spaß und vor allem auch Sinn, wenn man mit Dateien arbeiten kann, sei es lesend oder schreibend und genau darum soll es in diesem Kapitel gehen. Doch bevor ich anfang kann, muss ich mit Ihnen ein paar grundlegende Dinge behandeln.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.1 Allgemeines zu Dateien und Verzeichnissen

13.1 Allgemeines zu Dateien und Verzeichnissen

Ihnen dürfte klar sein, was eine Datei ist, aber wussten Sie schon, dass Verzeichnisse auch Dateien sind? In einer "Verzeichnis-Datei" befinden sich Informationen der Dateien, welche im Verzeichnis enthalten sind.

Dateien lassen sich in zwei Typen unterscheiden. Es gibt Textdateien, welche mit jedem Texteditor geöffnet werden können und es gibt Binärdateien, welche nur von zugehörigen Anwendungen geöffnet werden können. Sicher kann man diese auch mit einem Texteditor öffnen, aber man wird nur komische Symbole in ihr finden. Deshalb werde ich auf diese zwei Arten in getrennten Kapiteln eingehen.

Um auf Dateien zugreifen zu können, benötigt man die Hilfe des Betriebssystems, weil dieses weiß, wie man mit den verschiedenen Dateisystemen umgeht (FAT, NTFS, ISO9660, UDF und und und). Dies wird unter Windows über die Windows API geregelt, aber keine Angst, denn Visual Studio besitzt bereits fertige Mechanismen, um Ihnen den Umgang zu erleichtern. Dir sollten aber trotzdem wissen, dass man nicht selbst mit den Dateien arbeitet, sondern das Windows dies tut.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.1.1 Handles

13.1.1 Handles

Bevor Sie mit Dateien arbeiten, sollten Sie grob wissen, was ein Handle ist. Wie bereits erwähnt, regelt das Betriebssystem den Dateizugriff. Dem Programmierer wird beim öffnen eine Art Referenz zur Verfügung gestellt, welche dann immer mit angegeben werden muss, wenn etwas mit der Datei gemacht werden soll. Somit weiß Windows, welche Datei gemeint ist. In C wurde um diese Handles ein Mechanismus herum gebastelt, aber prinzipiell wissen Sie jetzt, dass ein Handle eine Referenz ist, mit welcher das Betriebssystem später eine Zuordnung machen kann.

Handels werden aber nicht nur für Dateien verwendet, sondern kommen überall dort zum Einsatz, wo das Betriebssystem für einen etwas machen soll. Dies betrifft z.B. die Steuerung anderer Programme, Prozesse, Bibliotheken und Fenster. Möchte man z.B. eine Funktion einer DLL benutzen, benötigt man zunächst ein Handle darauf. Will man ein Fenster in den Vordergrund holen oder ein anderes Programm steuern, wird ebenfalls ein Handle benötigt. Dies soll Sie aber jetzt nicht verwirren, sondern nur den Begriff Handle näher bringen.

Da ein Handle wie gesagt nur eine Referenz ist, handelt es sich nur um eine Zahl, welche meistens vom Typ Integer ist.

Zugriff auf eine Datei mittels Handles

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.1.2 Die Tücken der Dateiarbeit

13.1.2 Die Tücken der Dateiarbeit

Der Umgang mit Dateien ist prinzipiell nicht sonderlich schwer, aber es gibt eine Menge Sachen zu beachten, auf die ich hier kurz eingehen werde.

Die erste Schwierigkeit ist dadurch begründet, dass wir heute in der Regel mit Mehrbenutzersystemen arbeiten. D.h. es kann vorkommen, dass wir auf Dateien keinen Zugriff bekommen, da sie von anderen Benutzern oder Prozessen verwendet werden. Deshalb sollten Sie vor Dateizugriffen prüfen, ob Sie auf die gewüschte Datei zugreifen kann.

Ein weiteres Problem ist, dass Sie evtl. gar nicht das Recht haben, auf eine gewisse Datei zugreifen zu können, weil sie einem anderen Benutzer gehören, welcher Ihnen das Leserecht entzogen hat. Auch hierfür gibt es prinzipiell Möglichkeiten, wie man so etwas herausfinden kann, aber da dies sehr stark vom Betriebssystem abhängt, werde ich in diesem Tutorial nicht explizit darauf eingehen. Wichtig ist nur, dass so etwas vorkommen kann, und dass ein berufstätiger Programmierer, auf solche Dinge achten muss.

Ein weiterer ungünstiger Umstand, ist der Anwender. Zum einen könnte eine eingegebene Pfadangabe nicht existieren und zum anderen könnte eine Datei, z.B. auf einem USB-Stick liegen, welche während der Dateiarbeit abgezogen wird. Auch hier sollten Vorkehrungen getroffen werden, dass das Programm nicht abstürzt bzw. sich aufhängt.

Was können Sie also tun? Nun, es gibt zwei gängige Herangehensweisen, welche in enger Kombination verwendet werden sollten. Zum einen kann man ganz paranoid haufenweise Funktionen bauen, die die verschiedensten Sachen im Vorfeld prüfen und zum anderen, sollte man um Dateizugriffe "try catch Statements" bauen. Das könnte dann folgendermaßen aussehen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
// Wenn es die Datei gibt
if (FileExist(astrPath) && IsReadable(astrPath)) {
	try {
		// Dateizugriffe
	} catch (...) { // Wenn irgend ein Fehler/Ausnahme auftritt
		// Dieser Bereich wird ausgeführt, wenn es im Oberen zu
		// einem Absturz kommt
		printf("Beim Zugriff der Datei ist ein Fehler aufgetreten!");
	} // end of try
} // end of if
					

Sie können da sehr paranoid sein und im Zweifelsfall sollten Sie das auch, aber aus Gründen der Verständlichkeit, werde ich in folgenden Quelltexten darauf verzichten. Falls Sie im Web auf die Suche nach Codeschnipseln gehrn, dann wundern Sie sich nicht, wenn Sie sehr verschachtelte Konstrukte finden, denn diese wurden gemacht, um den von mir erwähnten Anfälligkeiten entgegen zu treten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.2 Textdateien

13.2 Textdateien

So, das war erst einmal genügend Theorie. Fangen wir also an. Am besten dafür sind die Textdateien, da man hier schnell seine Ergebnisse kontrollieren kann. Eines sei aber vorher noch erwähnt. Wenn Sie Pfadangaben in Strings, statisch als Konstante, implementieren, dann denken Sie daran, ein doppeltes Backslash zum abtrennen von Datei und Verzeichnissen zu nehmen, da ein einzelnes Backslash ein Escapezeichen einleiten würde.

Zum Beispiel: C:\\TestVerzeichnis\\Unterverzeichnis\\Testdatei.txt

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.2.3 Die Funktionen fgets() und fputs()

13.2.3 Die Funktionen fgets() und fputs()

Ein kleiner Nachteil ergibt sich durch die zwei eben genannten Funktionen. Im Falle von Strings, wird immer nur ein Wort eingelesen bzw. geschrieben. So muss man extra für Leerzeichen sorgen. Des Weiteren hat man keine Chance, Zeilenumbrüche zu erkennen.

Abhilfe schaffen die Funktionen "fgets", zum einlesen ganzer Zeilen und "fputs", zum schreiben ganzer Zeilen. Folgendes Beispiel öffnet wieder eine Datei und holt, dieses Mal Zeile für Zeile, den Inhalt heraus und packt ihn in eine neue Datei. Anschließend sieht die Zieldatei exakt so aus, wie die Quelldatei.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
					
char*	pcstrFileNameSrc	= "C:\\Testdatei_Quelle.txt";
char*	pcstrFileNameDst	= "C:\\Testdatei_Ziel.txt";

FILE*	hFileSrc		= fopen(pcstrFileNameSrc, "r");
FILE*	hFileDst		= fopen(pcstrFileNameDst, "w+");

// Wenn die Dateien geöffnet werden konnten
if (hFileSrc != NULL) {
	if (hFileDst != NULL) {
		try {
			char astrTemp[1000];
			bool bCouldRead	= true;

			// Solange das Dateiende noch nicht erreicht ist
			while (bCouldRead) {
				fgets(astrTemp, 999, hFileSrc);

				// Wenn etwas eingelesen werden konnte
				if (bCouldRead = !feof(hFileSrc)) {
					fputs(astrTemp, hFileDst);
				} // end of if
			} // end of while
		} catch (...) {
			printf("Es ist ein Fehler aufgetreten!\n");
		} // end of try
		
		fclose(hFileSrc);
	} // end of if
	
	fclose(hFileDst);
} // end of if
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

13.2.1 Die Funktionen fopen(), feof() und fclose()

13.2.1 Die Funktionen fopen(), feof() und fclose()

Die Funktion "fopen" veranlasst das Betriebssystem, eine Datei für einen Zugriff zu öffnen. Dabei werden der Pfad auf die gewünschte Datei und ein s.g. Modus, mit übergeben. Zurück erhält man das bereits erwähnte Datei-Handle. Der Modus gibt an, für welchen Zugriff man die Datei öffnen will (z.B. lesen oder schreiben) und was geschehen soll, wenn die Datei noch nicht vorhanden ist. Optional kann noch angegeben werden, mit welchem Zeichensatz die Datei ausgelesen bzw. beschrieben werden soll. Folgende wichtigen Modi gibt es.

Mit der Funktion "feof" kann man testen, ob man am Dateiende angekommen ist (end of file). Wie Sie sich denken können, benötigt die Funktion lediglich das Datei-Handle als Übergabeparameter. Im Gegensatz zu anderen Sprachen, ist diese Funktion aber mit Vorsicht zu genießen, da sie erst "true" zurückgibt, wenn einmal erfolglos versucht wurde, aus einer Datei zu lesen bzw. in sie zu schreiben. Somit ließt man einmal umsonst aus einer Datei.

Die Funktion "fclose" beendet den Dateizugriff und gibt die Datei für den Zugriff durch andere Benutzer und oder Programme, frei. Auch ihr wird lediglich der entsprechende Datei-Handle übergeben. Vergisst man die Freigabe, kann die Datei bis zum nächsten Neustart des Computers, nicht mehr verwendet werden! Das Schließen einer nicht geöffneten Datei, führt zu einem Fehler bzw. zu einer Exception.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.2.2 Die Funktionen fscanf() und fprintf()

13.2.2 Die Funktionen fscanf() und fprintf()

Wie Sie sich vielleicht denken könnt, stehen die Funktionen für "File-ScanF" und "File-PrintF". Sie funktionieren wie die bekannten Funktionen "scanf" und "printf", nur mit dem Unterschied, dass man beim Aufruf noch das Datei-Handle mit übergibt und die Ein - bzw. Ausgabe, nichts mit der Konsole, sondern mit Dateien zu tun hat.

Im folgenden Beispiel, werde ich eine Textdatei öffnen, den Inhalt Stück für Stück auslesen und ihn in eine andere Datei schreiben. Damit wird ein (langsames) Kopieren einer Datei, realisiert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
					
char*	pcstrFileNameSrc	= "C:\\Testdatei_Quelle.txt";
char*	pcstrFileNameDst	= "C:\\Testdatei_Ziel.txt";

FILE*	hFileSrc		= fopen(pcstrFileNameSrc, "r");
FILE*	hFileDst		= fopen(pcstrFileNameDst, "w+");

// Wenn die Dateien geöffnet werden konnten
if (hFileSrc != NULL) {
	if (hFileDst != NULL) {
		try {
			char astrTemp[100];
			bool bCouldRead	= true;

			// Solange das Dateiende noch nicht erreicht ist
			while (bCouldRead) {
				fscanf(hFileSrc, "%s", astrTemp);

				// Wenn das Einlesen funktioniert hat
				if (bCouldRead = !feof(hFileSrc)) {
					fprintf(hFileDst, "%s ", astrTemp);
				} // end of if
			} // end of while
		} catch (...) {
			printf("Es ist ein Fehler aufgetreten!\n");
		} // end of try
	
		fclose(hFileSrc);
	} // end of if

	fclose(hFileDst);
} // end of if
					

Genauso wie man das von der Funktion "scanf" kennt, kann man auch Integer und andere Typen, gezielt einlesen. Allerdings muss man dann darauf gefasst sein, dass der gelesene Inhalt nicht dem entspricht, was man erwartet (jemand könnte die Datei manipuliert haben). Deshalb ist es stets am klügsten, erst einmal Strings einzulesen und jene, nach entsprechender Prüfung, in das gewünschte Format zu Konvertieren. Wie Sie in Zeile 19 sehen, prüfe ich vor dem Schreiben, ob das Einlesen funktioniert hat. Würde ich dies nicht tun, würde in der Zieldatei, der letzte Eintrag der Quelldatei doppelt vorkommen, da "fscanf" im Fehlerfall die übergebene Variable für den Inhalt, nicht anfasst und somit noch der letzte Wert drin steht.

Alternativ, stehen auch die sicheren Funktionen "fscanf_s" und "fprintf_s" zur Verfügung, mit welchen Sie besser auf evtl. auftretende Fehler reagieren können. Mehr zu diesen Funktionen findet Sie in der Hilfe oder im MSDN.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.2.4 Testdateien mit Streams

13.2.4 Testdateien mit Streams

Eine elegantere Variante mit Dateien zu arbeiten, sind die Dateistreams. Sie nehmen Ihnen ein wenig Arbeit ab, da sie selbst Objekte sind, die viele Dinge kapseln, also im Hintergrund ausführen, um die Sie sich nicht mehr kümmern müssen. Eigentlich ist dieses Kapitel an der falschen Stelle, da ich Objekte erst im nächsten großen Abschnitt bespreche, aber auf der anderen Seite, gehört es der Vollständigkeit halber, hier her und bietet zudem eine schöne Einleitung zum Thema objektorientierte Programmierung.

Im folgenden Quelltext werde ich genau das gleiche machen, wie eben, also eine Textdatei händisch kopieren, nur dass ich jetzt Dateistreams benutzen werde. Nebenbei sei noch erwähnt, dass ich dafür die Header-Datei "fstream" benutze.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
					
char*		pcstrFileNameSrc	= "C:\\Testdatei_Quelle.txt";
char*		pcstrFileNameDst	= "C:\\Testdatei_Ziel.txt";

std::ifstream	oSrcFileStream(pcstrFileNameSrc);
std::ofstream	oDstFileStream(pcstrFileNameDst);

// Wenn die Dateien ge�ffnet werden konnten
if (oSrcFileStream.is_open() && oDstFileStream.is_open()) {
	try {
		char cValue;
		bool bCouldRead		= true;

		// Solange gelesen werden kann
		while (bCouldRead) {
			oSrcFileStream >> cValue;
		
			// Wenn das Einlesen funktioniert hat
			if (bCouldRead = !oSrcFileStream.eof()) {
				oDstFileStream << cValue;
			} // end of if
		} // end of while
	} catch (...) {
		printf("Es ist ein Fehler aufgetreten!\n");
	} // end of try
} // end of if
					

In Zeile 4 und 5 sehen Sie, dass bereits das öffnen einer Datei mit Streams, anders aussieht. Dies liegt daran, dass man die Datei nicht selbst öffnet, sondern dies der Kontrolle der Objekte überlässt. In den beiden Zeilen werden also lediglich zwei Streamobjekte erzeugt und es wird ihnen mitgeteilt, welche Dateien sie benutzen sollen. Normalerweise muss man auch hier noch einen Modus mit angeben, allerdings gibt es für jene Parameter entsprechende Defaultwerte, welche genau so definiert sind, wie ich sie brauche, um in Textdateien zu lesen, bzw. zu schreiben.

Einen weiteren Unterschied zu eben sehen Sie in Zeile 8. Vorhin habe ich getrennt geprüft, ob die Dateien geöffnet werden konnten, damit ich sie auch nur in diesem Fall schließe. Auch hier kommen Ihnen die Streamobjekte entgegen. Da sie, wie erwähnt, den kompletten Dateizugriff Kapseln (auch das Schließen der Dateien), brauche ich keine getrennten Prüfungen und spare mir damit ein paar Zeilen Quelltext.

In Zeile 15 wird nun aus der Datei bzw. aus dem Stream, Zeichenweise gelesen. Entsprechend ähnlich verhält sich die Sache in Zeile 19, nur mit dem Unterschied, dass hier das Schreiben in die Datei realisiert wird.

Leider sind Streams keine Allzweckwaffe. Zum einen muss man trotzdem immer prüfen, ob das Einlesen funktioniert hat und zum anderen sieht das Ergebnis, also die Zieldatei, nicht so aus wie gewünscht. Es fehlen wieder sämtliche Leerzeichen und auch alle Zeilenumbrüche. Wie man dies beheben kann, sehen Sie dann, wenn ich zeige, wie man Binärdateien mit Streams bearbeitet. Abschließend sei nochmals erwähnt, dass man die Dateien nicht wieder freigeben braucht, da dies geschieht, sobald man den Gültigkeitsbereich der Objekte verlässt und sie im Zuge dessen, freigegeben werden (es passiert im Destruktor der Streams).

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4 Durchsuchen von Verzeichnissen

13.4 Durchsuchen von Verzeichnissen

Bisher habe ich Ihnen zwar gezeigt, wie man auf Dateien zugreifen kann, aber nicht, wie man eine Datei findet. Genau darum geht es in diesem Unterkapitel. Bevor wir loslegen können, muss ich Ihnen ein paar Worte zur Windows API erzählen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.2 Hinweise zu den Variablentypen der Windows API

13.4.2 Hinweise zu den Variablentypen der Windows API

Ein weiterer Stolperstein der Windows API ist, dass es sehr viele Typdefinitionen, also Aliasnamen, für bekannte Datentypen gibt und das diese auch oftmals verlangt, bzw. zurückgegeben werden. Dies wurde aber nicht aus reiner Schikane gemacht, sondern um eine Vereinheitlichung vorzunehmen. Ich hatte ja mal erwähnt, dass ein Integer früher nur zwei Byte groß war und heute meist vier Byte groß ist. Der durch Windows spezifizierte Datentyp "DWORD" steht für "double Word" und entspricht einem heutigen Integer. Früher wusste man durch das "double", dass der Datentyp größer war als ein Integer. Genauso tauchen Datentypen wie "LPCSTR" auf, was heißt "large pointer of const string", also ein heutiger "const char*". Sie fragen sich jetzt bestimmt wieder, warum "large pointer"? Die Begründung fällt wie eben aus. Pointer waren schon immer Integervariablen und da heute die Integer größer sind, sind auch die Pointer größer (streng genommen ist es anders herum, da man mehr Arbeitsspeicher adressieren können wollte). Es gibt noch unzählige andere Datentypen, aber wenn man schaut wo sie definiert sind, sieht man was dahinter steckt und man hat wieder die bisher bekannten Typen vor sich.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.4 Vorbemerkung zur Verzeichnissuche

13.4.4 Vorbemerkung zur Verzeichnissuche

Wie schon erwähnt, sind Verzeichnisse auch nur Dateien. Jene haben aber ein ganz bestimmtes Dateiattribut (Bitmaske). Des Weiteren bekommt man keine Information darüber, wie viele Verzeichnisse und/oder Dateien sich in einem Verzeichnis befinden. Möchte man dies wissen, muss man es wie gleich gezeigt durchlaufen und selber die gefundenen Einträge zählen.

Ein weiterer Irrglaube ist, dass man alle Einträge in alphabetischer Reihenfolge bekommt. Oftmals ist dem so, aber eine Garantie dafür gibt es nicht. Des Weiteren werden auch Dateien und Verzeichnisse gemischt ermittelt. Falls man also diese Angaben trennen möchte, muss man wieder selbst Hand anlegen.

Am wichtigsten jedoch ist, dass ein Durchsuchen viele Gemeinsamkeiten mit dem Öffnen und Schließen einer Datei hat. Deswegen ist es extrem wichtig, dass jede Suche mit einem speziellen Befehl wieder beendet wird. Andernfalls bekommt man mit diesem Verzeichnis so lange Probleme (gerade wenn man es löschen oder umbenennen will), bis Windows neu gestartet wurde.

Wenn ein Verzeichnis durchsucht wird, bekommt man meist zu Beginn zwei Einträge, die oft nicht benötigt werden. Es sind die Einträge "." und "..", welche für DOS eine große Rolle spielen. Der einzelne Punkt steht für das aktuelle Verzeichnis und der doppelte für das übergeordnete Verzeichnis. Ggf. müssen Sie diese zwei Einträge also ausfiltern.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.5 Implementierung der Dateisuche

13.4.5 Implementierung der Dateisuche

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
					
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

// ...

WIN32_FIND_DATAA	sFileInfo;
HANDLE			hFileHandle	= FindFirstFileA("C:\\*.*", &sFileInfo);
bool			bGoOn		= hFileHandle != INVALID_HANDLE_VALUE;

// Solange Einträge gefunden werden
while (bGoOn) {
	// Wenn ein Verzeichnis gefunden wurde
	if (sFileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
		// Nur relevante Verzeichnisse
		if ((strcmp(sFileInfo.cFileName, ".") != 0) &&
		    (strcmp(sFileInfo.cFileName, "..") != 0)) {

			printf("Verzeichnis:  %s\n", sFileInfo.cFileName);
		} // end of if
	} else {
		printf("Datei:        %s\n", sFileInfo.cFileName);
	} // end of if

	bGoOn				= FindNextFileA(hFileHandle, &sFileInfo);
} // end of while

FindClose(hFileHandle);
					

Wie Sie sehen, gibt es drei wichtige Funktionen, nämlich "FindFirstFileA", "FindNextFileA" und "FindClose". Erstere Startet die Suche und dort gibt man auch das Verzeichnis an, welches durchsucht werden soll. Wenn man kein Pfad angibt, wird das Verzeichnis durchsucht, in welchem das Programm liegt. Das "*.*" bedeutet, dass alle Einträge beachtet werden sollen. Würde man z.B. "*.jpg" angeben, würden alle JPEG Dateien (Bilder) gefunden werden, wobei die Groß - und Kleinschreibung nicht berücksichtigt wird.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.6 Auflisten aller vorhandener Laufwerke

13.4.6 Auflisten aller vorhandener Laufwerke

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
					
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

// ...

char* pstrDrive		= new char[4];
pstrDrive[1]		= ':';
pstrDrive[2]		= '\\';
pstrDrive[3]		= '\0';		// Terminierungszeichen

// Durchlaufe alle möglichen Laufwerke
for (char cDrive = 'A'; cDrive <= 'Z'; cDrive++) {
	pstrDrive[0]	= cDrive;

	// Je nachdem, um welches Laufwerk es sich handelt
	switch (GetDriveTypeA(pstrDrive)) {
		case DRIVE_REMOVABLE:
			printf("%c: - Auswerfbares Laufwerk\n", cDrive);
			break;
		case DRIVE_FIXED:
			printf("%c: - Festplatte\n", cDrive);
			break;
		case DRIVE_REMOTE:
			printf("%c: - Netzlaufwerk\n", cDrive);
			break;
		case DRIVE_CDROM:
			printf("%c: - Optisches Laufwerk\n", cDrive);
			break;
	} // end of switch
} // end of for

delete [] pstrDrive;
					

Da die Funktion "GetDriveTypeA" Angaben der Art "C:\" benötigt, muss ich mir zunächst in den Zeilen 8 bis 10 und 14, diese Pfadangabe zusammen bauen. Der Rest ist ganz einfach. Man klappert alle möglichen Laufwerksbuchstaben von A bis Z ab und schaut, welche Informationen man bekommt. Hierbei sei aber noch erwähnt, dass hier alle Laufwerke aufgelistet werden, egal ob z.B. in einem CD-ROM Laufwerk eine CD liegt oder nicht. Des Weiteren gilt z beachten, dass die Laufwerkstypen etwas schwammig sind. "DRIVE_CDROM" heißt auch DVD oder BluRay und "DRIVE_REMOVABLE" kann für einen USB-Stick (wird allerdings nur erkannt, wenn er am Rechner angeschlossen ist) oder ein Kartenleser stehen.

Die Windows API stellt noch weitere nützliche Funktionen bereit (Freier Speicherplatz oder Pfadangaben zum Windowsverzeichnis oder dem Benutzerverzeichnis), auf welche ich jetzt nicht im Detail eingehen möchte. Schauen Sie also ins MSDN und erkunden Sie die anderen Möglichkeiten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.2 Bitmasken

13.4.2 Bitmasken

Ein weiterer Aspekt ist, dass oft Masken übergeben werden sollen. Es handelt sich hierbei wieder um einfache Integer, welche aber durch Bitoperationen mehrere Werte bzw. Flags aufnehmen können. Ich hatte bereits über Bitfelder gesprochen, wobei ich aber keine richtigen Bitfelder gezeigt habe, sondern einen neuen Mechanismus gezeigt habe, welcher wesentlich komfortabler war. Jetzt kommen wir aber nicht mehr umher, dies nachzuholen.

Die große Frage also lautet, wie kann ich jedes der 32 Bit eines Integers einzeln abfragen und setzen? Die Antwort ist ganz einfach. Zum Lesen verwendet man eine AND Operation, zum Setzen eine OR Operation und zum Entfernen eine XOR Operation. Wie und warum das Funktioniert, sollen folgende Grafiken zeigen.

Bitweise Verknüpfung mit AND

Wie Sie sehen können, kann man mit dem "&" Operator eine bitweise Verknüpfung machen und nur wenn der kleinere Wert im größeren enthalten ist, erhält man als Ergebnis den kleineren Wert. Ansonsten bekommt man einen anderen. Da ein Flag meistens nur durch ein oder maximal 2 benachbarte Bits signalisiert wird, kann man getrost davon ausgehen, dass das Ergebnis der Operation 0 ist, wenn das Flag nicht enthalten ist. Beachten Sie, dass dieses einzelne "&" an dieser Stelle nichts mit dem Referenzierungsoperator zu tun hat und verwechseln Sie es bitte auch nicht mit dem Verknüpfungsoperator "&&", welcher bei Bedingungen zum Einsatz kommt.

Bitweise Verknüpfung mit OR

Den Operator "|" müssen Sie sich wie ein intelligentes Plus vorstellen. Sie können damit ein Flag zu einer Maske dazu addieren und falls es schon enthalten ist, stört das nicht weiter.

Bitweise Verknüpfung mit XOR

Mit dem "^" Operator können Sie wieder Flags von einer Maske entfernen, aber wie Sie sehen, müssen Sie aufpassen. Dieser Operator verhält sich wie ein Plus und ein Minus. Falls Sie also ein Flag entfernen wollen, was gar nicht gesetzt war, setzen Sie es. Bevor Sie diese Operation anwenden, müssen sie also mit "&" prüfen, ob es enthalten ist. Und falls Sie sich wundern, dass ich zu Beginn behauptet hatte, dass es in C kein XOR gibt, dann weiße ich Sie nochmal auf den Unterschied zwischen einem Bitoperator und einem Verknüpfungsoperator hin. In einer Bedingung kann man zwei Ausdrücke nicht mit XOR koppeln, aber bei Bitoperationen steht einem ein XOR zur Verfügung.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.4.1 Funktionshinweise zur Windows API

13.4.1 Funktionshinweise zur Windows API

Zunächst einmal sollten Sie an dieser Stelle nicht zurück schrecken, denn wenn man sich ein paar dieser Funktionen anschaut, wird man ein wenig verunsichert. Ich möchte hier ein wenig Licht ins Dunkle bringen.

Als erstes muss man bedenken, dass die heutigen Windowsversionen weitestgehend abwärtskompatibel sind. Das heißt jetzt nicht unbedingt, dass man unter Windows 7 noch ein Windows 95 Programm ausführen kann. Vielmehr meine ich den internen Aufbau. Das hat zur Folge, dass es viele Funktionen in mindestens zwei Versionen gibt. Einmal die Klassische und dann die Moderne. Meistens macht sich dies dadurch bemerkbar, dass man zum einen ganz normale Strings übergibt bzw. zurück bekommt und das andere Mal bekommt man "WideStrings" im Unicode 16 Format. Letztere benötigen nicht nur ein Byte pro Zeichen, sondern zwei (damit kann man z.B. auch deutsche Umlaute und kyrillische Buchstaben darstellen). Dazu kommt noch, dass es meistens noch eine allgemeine Version der Funktion gibt, welche im Hintergrund nur eine der anderen beiden anspricht (je nach Systemvoreinstellung). Hier mal ein Beispiel.

Da der Umgang mit den "WideChars" etwas gewöhnungsbedürftig ist (zumal man für alle bekannten Stringfunktionen äquivalente andere aufrufen muss, welche oft mit "w" oder "_t" beginnen), werde ich mich ausschließlich auf die ASCII Funktionen beschränken. Falls Sie jetzt also mal eine Funktion mit einem großen A oder W am Ende sehen, wissen Sie jetzt, was dies zu bedeuten hat.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.3 Binärdateien

13.3 Binärdateien

Gerade wenn man wichtige Informationen, wie Strukturen und Einstellungen, speichern und laden möchte, sind Textdateien nicht der beste Weg (mal abgesehen von INI oder XML Dateien). Wie eben erwähnt, kann man nicht davon ausgehen, auch das zu finden, was man erwartet, weil andere die Datei händisch manipuliert haben könnten. Dies ist zwar bei Binärdateien prinzipiell auch möglich, aber nicht so verlockend wie bei Textdateien.

Zudem ergeben sich aus der Verwendung von Binärdateien weitere Vorteile. Zum einen können in ihr ganze Strukturen abgelegt bzw. aus ihr entnommen werden. Daraus ergibt sich wiederum, dass man zumindest grundlegend erst einmal testen kann, ob die Datei konsistent ist. Dies könnte man so anstellen, dass man prüft, ob die Dateigröße ein vielfaches des Speicherbedarfs des zu erwarteten Typs ist (Ist der einzulesende Typ 2 KB und die Datei 11 KB groß weiß man schon vor dem öffnen der Datei, dass etwas faul ist).

Der Inhalt der Datei sieht dann so aus, wie man die Variablen im Hauptspeicher des Rechners vorfinden würde. Ein Integer würde dann also genau vier Byte (Zeichen) einnehmen. Für kleine werte (<1000) erscheint dies zwar als Speicherverschwendung, dafür spart man aber Platz bei großen werden. In der Regel spart man durchschnittlich, zumal man kleinere Werte in kleineren Datentypen unterbringen kann.

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.3.1 Die Funktionen fread() und fwrite()

13.3.1 Die Funktionen fread() und fwrite()

Das Öffnen und Schließen von Binärdateien funktioniert grundlegend genauso wie mit Textdateien, nur mit dem Unterschied, dass man beim Öffnen einen speziellen Modus angibt.

Mit der Funktion "fread" kann man nun aus der Datei lesen und mit "fwrite" in sie schreiben. Den Funktionen wird jetzt, neben der Variable mit dem Inhalt bzw. der Variable in welcher der Inhalt kommen soll und dem Datei-Handle, noch zwei Weitere Werte übergeben, welche angeben, wie groß der einzulesende Wert ist und wie viele davon einzulesen sind. So ist es theoretisch möglich, gleich ein ganzes Array mit einem Schlag zu speichern bzw. es auszulesen.

Im folgenden Beispiel werde ich durch den Anwender Personenangaben einlesen und jene in einer Struktur speichern. Jenes wird dann in eine Datei gespeichert und anschließend wieder ausgelesen. Zunächst implementiere ich eine Struktur, welche verschiedene Personeninformationen aufnehmen kann.

 1
 2
 3
 4
 5
 6
					
struct SPerson {
	char	astrVorname[80];
	char	astrNachname[80];
	int	iAlter;
	float	fGewicht;
};
					

Dieser Typ soll verwendet werden. Er hat eine Größe von 168 Byte. Die Dateigrößen werden also immer ein Vielfaches dieser Zahl sein. Kümmern wir uns nun um die Eingabe der Werte und das Abspeichern in die Binärdatei.

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
					
char*	pcstrFileName		= "C:\\Test.bin";
bool	bGoOn			= true;
FILE*	hFile			= fopen(pcstrFileName, "w+b");

// Wenn die Datei erstellt werden konnte
if (hFile != NULL) {
	printf("== Eingabe ========================\n");

	// Solange Personen hinzugefügt werden sollen
	while (bGoOn) {
		SPerson sPerson;
		char	cSign;

		printf("Vorname:  ");
		fflush(stdin);
		scanf("%s", &(sPerson.astrVorname));
		
		printf("Nachname: ");
		fflush(stdin);
		scanf("%s", &(sPerson.astrNachname));
		
		printf("Alter:    ");
		scanf("%i", &(sPerson.iAlter));
		
		printf("Gewicht   ");
		scanf("%f", &(sPerson.fGewicht));

		try {
			fwrite(&sPerson, sizeof(SPerson), 1, hFile);
		} catch(...) {
			printf("Beim Speichern ist ein Fehler aufgetreten!\n");
		} // end of try
		
		printf("\n\nNoch eine Person einlesen? (j/n)");
		fflush(stdin);
		scanf("%c", &cSign);
		bGoOn		= (cSign == 'j' || cSign == 'J');
	} // end of while

	fclose(hFile);
} // end of if
					

Als nächstes folgen das Auslesen der eben erstellten Datei und die Ausgabe auf dem Bildschirm. Dabei werde ich dieselben Variablen benutzen, wie eben. Besonders auffällig ist nun das doppelte Prüfen auf "end of file". Sie können sich ja mal überlegen, was passiert, wenn man die innere Prüfung weg lässt.

50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
					
hFile			= fopen(pcstrFileName, "rb");

// Wenn die Datei geöffnet werden konnte
if (hFile != NULL) {
	printf("== Ausgabe ========================\n\n");

	// Solange es noch etwas zum auslesen gibt
	while (!feof(hFile)) {
		SPerson sPerson;

		try {
			fread(&sPerson, sizeof(SPerson), 1, hFile);

			// Nur wenn noch was eingelesen werden konnte
			if (!feof(hFile)) {
				printf("Vorname:  %s\n", sPerson.astrVorname);
				printf("Nachname: %s\n", sPerson.astrNachname);
				printf("Alter:    %i\n", sPerson.iAlter);
				printf("Gewicht:  %g\n\n", sPerson.fGewicht);
			} // end of if
		} catch (...) {
			printf("Beim Einlesen ist ein Fehler aufgetreten!\n");
		} // end of try
	} // end of while

	fclose(hFile);
} // end of if						
					

So könnte nun eine Ausgabe des Programms aussehen.

== Eingabe ========================
Vorname:  Hans
Nachname: Müller
Alter:    20
Gewicht   80.5

Noch eine Person einlesen? (j/n)j

Vorname:  Paul
Nachname: Meier
Alter:    21
Gewicht   75.25

Noch eine Person einlesen? (j/n)n

== Ausgabe ========================
Vorname:  Hans
Nachname: Müller
Alter:    20
Gewicht:  80.5

Vorname:  Paul
Nachname: Meier
Alter:    21
Gewicht:  75.25
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

13.3.2 Binärdateien mit Streams

13.3.2 Binärdateien mit Streams

Nun werde ich noch abschließend zeigen, wie man eben gezeigten Quelltext mit Streams realisieren kann. Ich werde dabei den gleichen Datentyp benutzen wie eben.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
					
char*		pcstrFileName		= "C:\\Test.bin";
bool		bGoOn			= true;

std::ofstream	oOutFileStream(pcstrFileName, std::ios_base::binary);

// Wenn die Dateien geöffnet werden konnten
if (oOutFileStream.is_open()) {
	try {
		printf("== Eingabe ========================\n");

		// Solange Personen hinzugefügt werden sollen
		while (bGoOn) {
			SPerson sPerson;
			char	cSign;

			printf("Vorname:  ");
			fflush(stdin);
			scanf("%s", &(sPerson.astrVorname));
			
			printf("Nachname: ");
			fflush(stdin);
			scanf("%s", &(sPerson.astrNachname));
			
			printf("Alter:    ");
			scanf("%i", &(sPerson.iAlter));
			
			printf("Gewicht   ");
			scanf("%f", &(sPerson.fGewicht));

			try {
				oOutFileStream.write((char*)&sPerson, sizeof(SPerson));
			} catch(...) {
				printf("Beim Speichern ist ein Fehler aufgetreten!\n");
			} // end of try

			printf("\n\nNoch eine Person einlesen? (j/n)");
			fflush(stdin);
			scanf("%c", &cSign);

			bGoOn		= (cSign == 'j' || cSign == 'J');
		} // end of while
	} catch (...) {
		printf("Es ist ein Fehler aufgetreten!\n");
	} // end of try
} // end of if

oOutFileStream.close();

std::ifstream	oInFileStream(pcstrFileName, std::ios_base::binary);

// Wenn die Dateien geöffnet werden konnten
if (oInFileStream.is_open()) {
	try {
		printf("== Ausgabe ========================\n\n");

		// Solange es noch etwas zum auslesen gibt
		while (!oInFileStream.eof()) {
			SPerson sPerson;

			try {
				oInFileStream.read((char*)&sPerson, sizeof(SPerson));

				// Nur wenn noch was eingelesen werden konnte
				if (!oInFileStream.eof()) {
					printf("Vorname:  %s\n", sPerson.astrVorname);
					printf("Nachname: %s\n", sPerson.astrNachname);
					printf("Alter:    %i\n", sPerson.iAlter);
					printf("Gewicht:  %g\n\n", sPerson.fGewicht);
				} // end of if
			} catch (...) {
				printf("Beim Einlesen ist ein Fehler aufgetreten!\n");
			} // end of try
		} // end of while
	} catch (...) {
		printf("Es ist ein Fehler aufgetreten!\n");
	} // end of try
} // end of if
					

Die Ausgabe sieht wieder genauso aus, wie vorhin, aber vom Quelltext her hat sich einiges geändert. Es geht schon in Zeile 4 los. Genauso wie bei den Textdateien, habe ich ein Streamobjekt erzeugt, jedoch mit einem zusätzlichen Parameter, um zu signalisieren, dass die zu bearbeitende Datei eine Binärdatei ist.

In Zeile 22 wird jetzt das schreiben in die Datei angestoßen. Allerdings benutze ich dieses mal nicht den Serialisierungsoperator, da jener nur mit reinem Text funktioniert bzw. wenn man mit Textdateien arbeitet. Sehr merkwürdig ist hier, dass ich die zu schreibende Variable in einen char Pointer caste. Dies ist notwendig und hat folgenden Hintergrund. Der Stream schreibt und ließt immer Byteweise. Jeder Datentyp ist ja ohne Rest in n Bytes teilbar (auch für ein bool wird intern ein Byte Speicher benötigt). Diese eingelesenen Bytes, werden also in den RAM bzw. die Datei geschrieben, ohne wirkliche Chars zu sein (ein Char ist nun mal ein elementarer Datentyp, der genau ein Byte groß ist). Da die Methode "write" ein Pointer erwartet, muss ich nicht die Struktur selber, sondern seine Adresse übergeben, was das "&" erklärt.

In Zeile 37 schließe ich jetzt händisch die Datei, da ich sie sonst in Zeile 38 nicht wieder mit dem anderen Streamobjekt öffnen kann (auch hier wieder im Binärmodus).

In Zeile 51 wird das Lesen aus der Datei veranlasst und wie eben schon+ angedeutet, ist auch hier wieder ein Casten auf einen Char-Pointer notwendig und man kann ebenfalls nicht die Serialisierungsoperatoren verwenden.

Wenn man dieses Beispiel adaptiert, kann man auch Textdateien händisch kopieren und bekommt dann auch alle Sonderzeichen, wie Leerzeichen und Zeilenumbrüche, mit. Als Variable muss man lediglich ein einzelnes Char benutzen (niemals eine Zeichenkette, da sonst am Ende der Datei, unter Umständen, etwas fehlen könnte).

Zum Seitenanfang
Zum Inhaltsverzeichnis

13.5 Übungsaufgaben X

13.5 Übungsaufgaben X

  1. Lassen Sie vom Benutzer seinen Nachnamen, Vornamen und Geburtsdatum eingeben und speichern Sie die Eingaben in einer HTML-Datei, wobei die Daten in einer Tabelle gespeichert werden sollen. Der Benutzer soll außerdem noch angeben können, wo und unter welchem Namen die Datei gespeichert werden soll.
  2. Schreiben Sie ein Programm, welches ein Verzeichnis rekursiv durchsucht und alle gefundenen Dateien in eine HTML-Datei speichert. Beim Programmstart soll der Benutzer nach einem Verzeichnis (leere Eingabe ist aktuelles Verzeichnis), einer Suchmaske (leere Eingabe ist „*.*“) und eine Rekursionstiefe angeben. Zuletzt soll er wieder angeben können, wo die erstellte Datei gespeichert werden soll.
  3. Schreiben Sie ein kleines Programm, welches aus einer Textdatei Integer einließt (Datei besteht nur aus zahlen, durch Leerzeichen getrennt), daraus den durchschnitt berechnet und diesen errechneten Wert ans Ende dieser Datei schreibt (in eine neue Zeile).
Zum Seitenanfang
Zum Inhaltsverzeichnis

8 Variablen II

8 Variablen II

In diesem Kapitel geht es um Gültigkeitsbereiche von lokaler und globaler Variablen, konstanten Variablen und erweiterte Datentypen, wie statische Arrays und Strukturen, welche es erlauben, mehr wie eine Variable/Wert zu halten. Dies ist gerade dann nützlich, wenn man viele gleichartige Werte zusammen fassen möchte bzw. mehrere onterschiedliche Typen gruppieren möchte.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.4 Strukturen

8.4 Strukturen

Ein großer Nachteil an Feldern ist, dass man dort immer nur eine Sorte an Werten ablegen kann und dass der Zugriff auf jene mit ein wenig Denkarbeit verbunden ist, da man bei 0 anfängt zu zählen. Dazu kommt, das eine Zuordnung fehlt, also was steht in welchem Feld.

Hier kommen die Strukturen ins Spiel. Wie der Name bereits verrät, kann man "Eigenschaften" Werte zuordnen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.4.3 Strukturen für Profis

8.4.3 Strukturen für Profis

Wenn wir jetzt also das Byte Padding im Hinterkopf haben, sollten wir den Aufbau einer Struktur durchdenken. Strukturen werden in der Regel immer am Stück gehalten und in der Reihenfolge gespeichert, in der sie definiert wurden. Das hat zur Folge, dass ein Byte Padding auftritt und mehr Speicherplatz benötigt wird, als man bräuchte. Dem kann man entgegentreten, indem man die Reihenfolge ändert. Hier mal ein Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
struct SLarger {
	char	a;	// 1 Byte
	int	b;	// 4 Byte
	char	c;	// 1 Byte
	short	d;	// 2 Byte
};

struct SSmaler {
	char	a;	// 1 Byte
	char	c;	// 1 Byte
	short	d;	// 2 Byte
	int	b;	// 4 Byte
};

// ...

printf("SLarger = %2i Byte\n", sizeof(SLarger));
printf("SSmaler = %2i Byte\n", sizeof(SSmaler));
					

Ausgabe:

SLarger = 12 Byte
SSmaler =  8 Byte
		

Wie Sie sehen können, besitzen beide Strukturen zwar den gleichen Inhalt, werden aber mit einer unterschiedlichen Größe im Speicher verwaltet. Folgende Grafik veranschaulicht dies noch einmal.

Speicherverwaltung von Strukturen mit Byte Padding

Die roten Bereiche sind die künstlich aufgefüllten Blöcke. Aber was hat das jetzt für folgen? Nur der extreme Programmierer setzt sich jedes Mal mit Taschenrechner hin und rechnet aus, in welcher Reihenfolge er was definiert. Sie können diesen Aspekt vorerst vernachlässigen. Viel wichtiger ist es, dass Ihr Programm läuft. Hinterher kann man optimieren. Dazu kommt, dass derartige Optimierungen eigentlich nur ins Gewicht fallen, wenn man sehr sehr viele dieser Strukturen benötigt (z.B. ein Array mit einer Million solcher Elemente). Falls ihr Programm aber mal schneller laufen könnte und oder zu viel Arbeitsspeicher benötigt wird, sind dies die ersten Ansatzpunkte im Quellcode, an denen man Zeit und Platz gutmachen kann.

Eine weitere Schlussfolgerung aus diesem Kapitel soll sein, dass die Größen von Arrays möglichst immer eine Zweierpotenz sein sollte. Es werden somit unnötige RAM Zugriffe gespart und jeder Zugriff nutzt den Bus vollständig aus.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.4.2 Byte Padding

8.4.2 Byte Padding

Bisher habe ich ein paar Sachen verschwiegen, welche essentiell sind, wenn man richtig schnelle und performante Programme entwickeln möchte. Und zwar geht es mir um die interne Speicherverwaltung. Ich werde jetzt nicht die komplette Speicherverwaltung erklären. Abgesehen davon, dass ich Sie damit jetzt nur langweilen würde, ist dies auch nicht notwendig.

Ich hatte ja schon erwähnt, dass ein Boolean ein Byte groß ist, obwohl ein Bit ausreichend wäre und hatte dies ein wenig darauf geschoben, dass dieser Datentyp neu sei und aus Abwärtskompatibilitätsgründen so groß ist. Nun, eigentlich steckt dahinter noch ein weiterer viel wichtigerer Grund. Der Arbeitsspeicher ist u.a. in Blöcke eingeteilt, welche ein Byte oder ein vielfaches von einem Byte groß sind. Dies liegt daran, dass jeder Bereich adressiert werden muss, um Daten abzulegen bzw. sie holen zu können. Würde man jedes einzelne Bit adressieren wollen, bräuchte man viel größere Adressräume und viel Sinn würde dies auch nicht machen, da man eh in 99,99% der Fälle größere Bereiche benötigt.

Des Weiteren werden Daten immer erst auf Größe analysiert, bevor sie abgelegt werden. Zudem werden sie immer so platziert, dass sie immer an einer Adresse liegen, welche ein Vielfaches ihrer Größe entspricht. Wie Sie sich denken können folgt daraus, dass die Daten nicht ganz zusammenhängend im Speicher liegen. Man spricht von einer Fragmentierung. Der Platz zwischen den Variablen kann jetzt aber nicht einfach so genutzt werden, sondern wird mit Nullen aufgefüllt. Dies nennt man das Byte Padding.

Speicherverwaltung mit unterschiedlichen Datentypen

Aber warum werden die Variablen so im Speicher abgelegt, wenn man doch dadurch Platz verschwendet? Der Grund ist genauso einfach wie genial. Immer wenn die CPU auf den RAM zugreift, bzw. Daten verlangt, werden intern immer gleich mehrere Blöcke mit einem Schlag geholt (meistens 64 oder 128, je nach Busbreite). Das macht auch Sinn, weil viele Variablen eh größer als ein Block sind und so wird sie mit einem einzigen s.g. "fetch" geholt, statt vieler einzelner. Warum das gut ist? Die Zeitspanne zwischen dem Anfordern und dem Erhalt, dauert für die CPU eine halbe Ewigkeit und je mehr Zugriffe man benötigt, desto langsamer wird ein Programm (und das erheblich – deshalb gibt es in der CPU auch einen Cache).

Aber was hat das damit zu tun, dass die Variablen dann an einer bestimmten Adresse stehen müssen? Stellen Sie sich vor, die CPU kann immer nur 64 Blöcke mit einem mal holen und Sie hätten eine Variable, welche vier Blöcke benötigt und an den Adressen 62 bis einschließlich 65 stehen würde. Die Konsequenz daraus ist, dass man zwei Zugriffe benötigen würde (den von 0 bis 63 und den von 64 bis 127), obwohl einer reichen würde. Wenn diese Variable also an einer durch vier teilbaren Stelle liegt (60 oder 64), reicht wieder ein Zugriff. Sie fragen sich jetzt vielleicht, warum dann nicht z.B. Block 10 bis 73 geholt wird? Abgesehen von anderen Mechanismen bei der Speicherverwaltung (siehe Paging und Cachingverfahren) hat dies einen ganz einfachen Grund. Der Computer kann super gut mit Werten umgehen, welche einer Zweierpotenz entsprechen. Rechenoperationen mit diesen Werten können sehr effizient durchgeführt werden und sind leicht in Hardware zu gießen. Alle Anderen Zahlen würden einen viel höheren Rechenaufwand bedeuten und das gesamte System ausbremsen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.4.1 Definition und Umgang mit Strukturen

8.4.1 Definition und Umgang mit Strukturen

Das Schema sieht so aus: struct <NameDesStructTypes> { <Structdefinition> };

Mit "Strukturdefinition" ist gemeint, dass hier jetzt eine reine von Variablen deklariert (nicht initialisiert) werden, also erst Datentyp und dann Variablenname.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
					
struct SDatum {
	int iTag;
	int iMonat;
	int iJahr;
};

struct SPerson {
	int	iAlter;
	float	fGewicht;
	SDatum	sGeburtstag;
};

SPerson sThomas;				// Der Typname hat ein großes S und die Variable ein kleines s

sThomas.iAlter			= 26;
sThomas.iGewicht		= 85.3;
sThomas.sGeburtstag.iTag	= 6;
sThomas.sGeburtstag.iMonat	= 9;
sThomas.sGeburtstag.iJahr	= 1984;

sSwillingVonThomas		= sThomas;	// ACHTUNG - In anderen Sprachen geht das nicht

printf("Zwilling von Thomas wurde am %02i.%02i.%i geboren.",	sSwillingVonThomas.sGeburtstag.iTag,
								sSwillingVonThomas.sGeburtstag.iMonat,
								sSwillingVonThomas.sGeburtstag.iJahr);
					

Ausgabe:

Zwilling von Thomas wurde am 06.09.1984 geboren.
		

Wie ich gezeigt habe, kann man zum einen auf die Unterelemente einer Struktur mit einem "." zugreifen und Strukturen lassen sich auch ineinander verschachteln. Sie können sich das in etwa wie mit einer Festplatte vorstellen, wobei jedes Verzeichnis eine Struktur ist und die enthaltenen Dateien eine Eigenschaft dieser Struktur. Nur benutzt man bei den Strukturen ein Punkt anstelle eines Backslashs (nicht C:\Windows\Fonts\arial.tft sondern C.Windows.Fonts.arial_tft).

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.1 Gültigkeitsbereiche von Variablen

8.1 Gültigkeitsbereiche von Variablen

Wie in den meisten Programmiersprachen kann man auch in C bzw. Auch C++ nicht von überall auf alle Variablen zugreifen und evtl. ist Ihnen das auch schon passiert. Aber warum ist das so? Dies hängt immer vom Gültigkeitsbereich der Variable ab. Prinzipiell kann auf eine Variable zugegriffen werden, wenn sie im selben Block (gekennzeichnet durch geöffnete und geschlossene geschweifte Klammern) bzw. in einem darüber liegenden Block, definiert wurde und im Quelltext oberhalb des Zugriffsortes steht und somit Speicher für Sie zur Verfügung steht.

Variablen die außerhalb einer Funktion deklariert wurden, nennt man globale Variablen und alle anderen lokale. Allerdings hängt diese Sichtweise, ganz streng genommen, immer vom Ort der Betrachtung ab. Definiere ich mir am Anfang einer Funktion eine Variable A und anschließend innerhalb einer Schleife eine Variable B, dann ist B für die Schleife lokal und A im Bezug auf die Schleife global. Betrachtet man das Projekt als ganzes, sind A und B lokal.

Anfänger sollten sich aus Gründen der Übersichtlichkeit alle Variablen die sie brauchen, im obersten Bereich einer Funktion definieren. Somit ist zum einen sichergestellt, dass sie im Quelltext oberhalb stehen und zum anderen fördert es die Lesbarkeit. Erfahrene Programmierer definieren sich die Variablen erst kurz bevor sie sie benötigen.

Beide Vorgehensweisen haben klare Vor - und Nachteile. Wie erwähnt ist das Definieren aller Variablen am Funktionsanfang übersichtlicher und ich erwähnte auch schon, dass sie sowieso alle an dieser Stelle angelegt werden, da der Compiler ja nicht entscheiden kann, ob ein weiter darunter liegender Quelltextabschnitt erreicht wird oder nicht. Ich war früher auch dem Irrglaube verfallen, dass eine Variable erst dann angelegt wird, wenn man sie im Quelltext definiert. Der Grund, warum man es später macht ist ein anderer. Möchte man in einem neuen Projekt Sachen aus einem alten Projekt übernehmen, ist es selten, dass man ganze Funktionen benutzt. Meistens braucht man nur kleine Abschnitte. Kopiert man jetzt z.B. nur eine Berechnung, fehlen einem immer die benötigten Variablen. Wenn diese alle am Funktionsanfang stehen, neigt man dazu, einfach alle zu kopieren. Ansonsten müsste man ja wieder nachdenken, welche man tatsächlich braucht und welche einem anderen Zweck dienen. Definiert man die benötigten Variablen aber unmittelbar vor einer Berechnung, ist später klar, dass nur diese benötigt werden. Sie müssen also abwägen, was für Sie besser ist und von Fall zu Fall selber entscheiden.

Immer dann, wenn man den Gültigkeitsbereich einer Variablen verlässt, wird sie freigegeben und der Speicherplatz im RAM steht wieder zur Verfügung.

Folgender Grundsatz gilt: So lokal wie möglich und so global wie nötig! (Gilt aber nur für primitive Datentypen, die auf dem Stack abgelegt werden.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
					
///////////////////////////////////////////////////////////////////////////////
void Aufgabe1() {
	// Hier kann nicht auf iZahl zugegriffen werden.
	// Hier kann nicht auf iAnzahl zugegriffen werden.

	int iAnzahl;	// lokale Variable

	// Hier kann auf iAnzahl zugegriffen werden.
} // Aufgabe1 /////////////////////////////////////////////////////////////////



// Hier steht iAnzahl nicht mehr zur Verfügung
int iZahl = 10;	// globale Variable



///////////////////////////////////////////////////////////////////////////////
void Aufgabe2() {
	// Hier kann auf iZahl zugegriffen werden

	int iZahl;	// lokale Variable

	// Hier wird ab jetzt immer die lokale iZahl benutzt.
	// An die globale kommt man nicht mehr ran.
} // Aufgabe2 /////////////////////////////////////////////////////////////////



///////////////////////////////////////////////////////////////////////////////
void Aufgabe3(int iZahl) {
	// Hier gibt es eine eigene iZahl und somit kommt man
	// an die globale nicht mehr ran.
} // Aufgabe3 /////////////////////////////////////////////////////////////////
				

oder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
					
///////////////////////////////////////////////////////////////////////////////
void BeispielFunktion() {
	for (int iCounter=0; iCounter<10; iCounter++) {
		// Nur hier kann man auf iCounter zugreifen.
	} // end of for

	// Hier kann man auf iCounter nicht mehr zugreifen.

	if (1 < 19) {
		int iMaximum;

		// Hier kann man auf iMaximum zugreifen.
	} else {
		// Hier kann man nicht auf iMaximum zugreifen, da die Variable
		// zwar oberhalb definiert wurde, aber in einem Block steht,
		// auf welchen nicht zugegriffen werden kann, weil er in der
		// Hierarchie nicht oberhalb steht, sondern parallel.
	} // end of if

	// !!! ACHTUNG !!!

	do {
		int iGesammt	= 10;
	} while (iGesammt < 5);	// BÖSE

	// Das wird dem Compiler nicht gefallen, da iGesammt innerhalb
	// des Schleifenblockes definiert wurde und die Bedingung
	// außerhalb steht.
} // BeispielFunktion /////////////////////////////////////////////////////////
					

Um besser zwischen rein lokalen Variablen, die es nur innerhalb einer Funktion gibt und rein globalen Variablen, die es die ganze Zeit über gibt, unterscheiden zu können, ist es ratsam, vor den Variablenname einer globalen Variable ein "g_" voranzustellen (z.B. g_iGlobaleZahl). Dies verdeutlicht die Verfügbarkeit der Variable und zeigt außerdem, dass der beinhaltete Wert von überall verändert werden kann.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.5 Assoziationen / Enumerationen

8.5 Assoziationen / Enumerationen

Enumerationen dienen assoziativen Aufzählungen. Oft kommen sie bei einer Menüführung zum Einsatz oder generell dort, wo eine Auswahl getroffen wird. Prinzipiell ist ein "enum" vom Typ "unsigned int", aber er erleichtert dem Programmierer das Leben, da man mit ihm einen übersichtlicheren Quelltext erzeugt. Was ich genau damit meine, verdeutlicht nachstehendes Beispiel.

Das Schema sieht so aus: enum <NameDesEnumTyps> {<Bezeichner>[=<Wert>][, ...]};

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
					
enum EAuswahl {eCreate=1, eDelete, eExit=8};

// ...

EAuswahl eAuswahl = eNone;

// Solange etwas gemacht werden soll
while (eAuswahl != eExit) {
	printf("1 = Erstellen\n");
	printf("2 = Loeschen\n");
	printf("8 = Programm beenden\n");
	printf("Bitte etwas auswaehlen: ");
	scanf_s("%i", &eAuswahl);

	switch (eAuswahl) {
		case eCreate: 
			printf("Etwas wurde erstellt\n\n");
			break;

		case eDelete:
			printf("Etwas wurde geloescht\n\n");
			break;

		case eExit:
			printf("Programm wird beendet\n");
			return 0;
	} // end of switch
} // end of while
					

Ausgabe:

0 = Erstellen
1 = Loeschen
2 = Programm beenden
Bitte etwas auswaehlen: 0
Etwas wurde erstellt

0 = Erstellen
1 = Loeschen
2 = Programm beenden
Bitte etwas auswaehlen: 2
Programm wird beendet
		

Wie Sie sehen können, ist die Switch-Anweisung jetzt viel besser lesbar.

Assoziationen fangen standardmäßig immer bei 0 an und jeder darauf folgende Bezeichner ist um einen Wert größer. Dies muss aber nicht sein, denn wie Sie im Beispiel gesehen haben, können den Bezeichnern zwischendurch auch andere Werte zugewiesen werden. Die darauf folgenden Bezeichner sind wieder um je einen Wert größer.

Ich empfehle immer noch die 2 Werte "Count" und "None" mit aufzunehmen. Das hat zum einen den Vorteil, dass "Count" genau die Anzahl der implementierten Möglichkeiten enthält (vorausgesetzt, man weißt zwischendurch nicht andere Werte zu). Somit kann man mit der Zahl der Auswahlmöglichkeiten Berechnungen durchführen. "None" wird meist immer dann eingesetzt, wenn es sich um einen nicht unterstützten Wert handelt. Auch hier kann man dann wieder sehr bequeme Abfragen stellen, die noch dazu gut lesbar sind. Hier mal ein kurzes vereinfachtes Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
					
enum		EDateiTypen	{edtText, edtBild, edtVideo, edtMusik, edtCount, edtNone};
EDateiTypen	eDateityp	= edtVideo;

//...

printf("Momentan werden folgende %i Typen unterstuetzt.\n", edtCount);

// Durchlaufe alle unterstützten Dateitypen
for (int iCount = 0; iCount < edtCount; ++iCount) {
	// Je nachdem, was vorliegt
	switch ((EDateiTypen)iCount) {
		case edtText:
			printf("Textdatei\n");
			break;

		case edtBild:
			printf("Bilddatei\n");
			break;

		case edtVideo:
			printf("Videodatei\n");
			break;

		case edtMusik:
			printf("Musikdatei\n");
			break;

		default:
			printf("Der Dateityp wird nicht unterstützt!\n");
	} // end of switch
} // end of for
					

Ausgabe:

Momentan werden folgende 4 Typen unterstuetzt.
Textdatei
Bilddatei
Videodatei
Musikdatei
		

Wie Sie sehen, können Enumerationen durchaus in Schleifen verwendet werden und auch Typumwandlungen funktionieren reibungslos.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.3 Statische Felder

8.3 Statische Felder

Mit Felder bzw. Arrays ist es möglich, mehrere Werte gleichen Typs zusammenzufassen. Auf jene kann dann, mittels eines s.g. Index, zugegriffen werden. Dabei ist Wichtig, dass der erste Index immer die 0 ist.

Das Schema sieht so aus: <VariablentypDerElemente> <Feldname>[[<AnzahlDerElemente>]]

Die Veranschaulichung eines Arrays an einem Zug

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
						
int aZug[5];	// Definition eines Arrays mit 5 Integer-Elementen

aZug[0] = 143;
aZug[1] = 834;
aZug[2] = 63;
aZug[3] = 6546;
aZug[4] = 42;  

// Durchlaufe das Array
for (int iWagonZaehler=0; iWagonZaehler<5; iWagonZaehler++) {
        printf("Wagon %i: %i\n", iWagonZaehler, aZug[iWagonZaehler]);
} // end of for
						

Ausgabe:

Wagon 0: 143;
Wagon 1: 834;
Wagon 2: 63;
Wagon 3: 6546;
Wagon 4: 42;
			

oder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
						
float aErrechneteWertwe[5];	// Definition eines Arrays mit 5 Floatelementen

aErrechneteWertwe[0]   = 5.1f;
aErrechneteWertwe[1]   = 4.2f;
aErrechneteWertwe[2]   = 3.3f;
aErrechneteWertwe[3]   = 2.4f;
aErrechneteWertwe[4]   = 1.5f;

float fDurchschnitt    = 0.0f;

// Summiere alle Felder
for (int iCounter=0; iCounter<5; iCounter++) {
        fDurchschnitt += aErrechneteWerte[iCounter];
} // end of for

fDurchschnitt          /= 5.0f;
printf("Durchschnitt = %g", fDurchschnitt);
						

Ausgabe:

Durchschnitt = 3.3
			

Arrays lassen sich auch ineinander verschachteln um eine Matrix aufzubauen. Man hat dann quasi ein Array mit Arrays, die wiederum irgendwelche Werte halten. Dies sieht dann z.B. so aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
						
float aMatrix[3][2];	// 3 Zeilen mit je 2 Spalten

aMatrix[0][0] = 1.1;
aMatrix[0][1] = 1.2;
aMatrix[1][0] = 2.1;
aMatrix[1][1] = 2.2;
aMatrix[2][0] = 3.1;
aMatrix[2][1] = 3.2;

// Gebe alle Felder aus
for (int iZeilen=0; iZeilen<3; iZeilen++) {
        for (int iSpalten=0; iSpalten<2; iSpalten++) {
               printf("%g ", aMatrix[iZeilen][iSpalten]);
        } // end of for

        printf("\n");
} // end of for
						

Ausgabe:

1.1 1.2
2.1 2.2
3.1 3.2
			

Dieses Spiel lässt sich natürlich auch noch fast bis ins Unendliche weiter treiben. Es ist also kein Problem, 20 Arrays ineinander zu verschachteln. Ob das dann Sinn macht, ist eine andere Frage.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.7 Bitfelder

8.7 Bitfelder

Bisher hatte ich immer verschwiegen, dass eine Variable nicht zwangsläufig so groß sein muss, wie vom System vorgegeben. Besser gesagt, sie ist intern meist trotzdem so groß, aber es ist möglich, nur einen Teil davon zu benutzen. Wenn man nach dem Variablennamen ein Doppelpunkt setzt, kann man anschließend angeben, wie viele Bits tatsächlich genutzt werden sollen.

Das Schema sieht so aus: <Datentyp> <VariablenName>:<Bitanzahl>;

Sie fragen sich jetzt bestimmt, wozu man das braucht? Nun, dafür möchte ich Ihnen einen zwar etwas komplizierten, aber richtig genialen Trick zeigen.

Normalerweise ist es ja so, dass jede Variable n Bytes benötigt. Eine boolesche Variable (also ein simples ja oder nein) benötigt so trotzdem 8 Bit, obwohl eins reichen würde. Gerade wenn man mehrere Flags benötigt, verbraucht man so viel Speicher. An dieser Stelle kommen Bitfelder ins Spiel, welche mit Unions effizienter werden. Ein Beispiel wäre hier verschiedene Rechte einer Datei. Sie kann z.B. schreibgeschützt und oder versteckt sein. Im folgenden Beispiel zeige ich, wie man diese Informationen alle in einer Variable bzw. in einem Byte speichern kann.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
					
union UFileStatus {
	// Anonyme Struktur
	struct {
		bool bHidden:1;
		bool bReadOnly:1;
		bool bEditable:1;
		bool bDeletable:1;
	}; unsigned char cFlags;
};

//...

UFileStatus	uStatus;
bool		bFlag;

printf("Size of UFileStatus %i\n", sizeof(UFileStatus));
printf("Size of bool        %i\n", sizeof(bool));

// Alle Flags deaktivieren
uStatus.cFlags		= 0;

// Zugriff auf einzelnes Flag
uStatus.bEditable	= true;
uStatus.bDeletable	= true;

printf("Wert von uStatus = %i\n", uStatus.cFlags);
					

Ausgabe:

Size of UFileStatus 1
Size of bool        1
Wert von uStatus = 12
		

Was ist jetzt hier passiert? Zunächst habe ich mir eine union gebaut, welche entweder eine Struktur bzw. ein vorzeichenloses Zeichen beinhaltet. Der Trick liegt hier in der anonymen Struktur. Sie ist so angelegt, dass sie nur vier Bit benötigt, wobei die erste Variable der Struktur das hinterste (also rechte) Bit ist. Das Zeichen wird in diesem Fall als eine Ganzzahl im Bereich von 0 bis 255 benutzt. In Zeile 16 beweise ich, dass die Variable tatsächlich nur ein Byte groß ist.

In Zeile 20 sehen Sie, wie man alle Flags zugleich auf "0" bzw. "false" setzen kann. In den Zeilen 23 bis 26 sehen Sie dann, wie man auf die einzelnen Bits zugreifen kann, ohne umständliche Bitoperationen ausführen zu müssen. Folgende Grafik soll noch einmal veranschaulichen, was im Speicher passiert.

Veranschaulichung eines Bitfeldes im Speicher

Die hinteren vier Bit werden also wie gewünscht benutzt. Die vorderen vier Bits sind nicht benutzt und deren Inhalt ist zufällig, wenn man sie nicht initialisiert.

Ich habe hier gezeigt, wie man also ein Byte bitweise ansteuern kann. Das Prinzip kann aber auch auf zwei oder mehr Byte angewendet werden. Dafür muss nur die Summe der benötigten Bits in der Struktur, zu einem Datentyp passen. Braucht man also in der Summe z.B. 24 Bit, also drei Byte, hätte "cFlags" mindestens ein Integer (32 Bit) sein müssen (es gibt standardmäßig keinen Datentyp, der drei Byte groß ist).

Natürlich ist es auch möglich, zwei oder mehr Bits pro Flag zu benutzen (beispielsweise wenn man nur Zahlen zwischen null und neun Speichern möchte, braucht man nur vier Bit), aber dies sollte mit Vorsicht genossen werden, da intern doch Bitoperationen durchgeführt werden müssen und jene sind wesentlich aufwendiger, wenn es sich um mehr als ein Bit handelt. Das hat zur Folge, dass das Programm geringfügig langsamer wird.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.6 Unions

8.6 Unions

Ich habe lange überlegt, ob ich dieses Thema mit in mein Tutorial aufnehme, da Unions etwas sehr merkwürdiges sind und ich sie zudem selten benötigt. Im Grunde kann man mit ihnen mehrere Variablentypen in einem Abbilden. Dabei wird immer der Speicherplatz benötigt, welcher dem des größten Datentyps entspricht. Dabei ist aber zu beachten, dass zwar der gleiche Speicherplatz genutzt wird, jener aber entsprechend interpretiert werden muss. Die Union sorgt nicht dafür, dass ein automatisches typecast durchgeführt wird! Wie ich das meine, verdeutlicht unten stehendes Beispiel.

Das Schema sieht so aus: union <NameDesUnionTyps> {<Typ> <Name>; [...]};

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
union IntAndDouble {
	int	AsInt;
	double	AsDouble;
};

// ...

IntAndDouble uZahl;

uZahl.AsInt = 15;
printf("Als Ganzzahl:        %i\n", uZahl.AsInt);
printf("Unbestimmt:          %i\n", uZahl.AsDouble);
printf("Unbestimmt:          %g\n\n", uZahl.AsDouble);

uZahl.AsDouble = 15.3;
printf("Als gebrochene Zahl: %g\n", uZahl.AsDouble);
printf("Unbestimmt:          %g\n", uZahl.AsInt);
printf("Unbestimmt:          %i\n", uZahl.AsInt);
					
Als Ganzzahl:        15
Unbestimmt:          15
Unbestimmt:          -9.25596e+061

Als gebrochene Zahl: 15.3
Unbestimmt:          1.2732e-314
Unbestimmt:          -1717986918
		

Wie Sie sehen können, muss man sich irgendwo merken, was man zuletzt in der Union gespeichert hat und wie dies dann zu interpretieren ist. Da dies im Allgemeinen zu Verwirrungen führen kann, rate ich davon ab, diesen Datentyp für solche gezeigten Aktionen zu benutzen.

Im Internet findet man zu diesem Thema auch teilweise Konstrukte, in welchem in einer Union eine Matrix und ein Vektor enthalten ist, sprich ein zweidimensionales und ein eindimensionales Array. Dieser Anwendungsfall macht sogar teilweise Sinn, gerade wenn es um das Erzeugen des Speichers geht. Nichts desto trotz ist so etwas im Nachhinein nur noch schwer nachvollziehbar und somit nur für Profis interessant.

Zum Seitenanfang
Zum Inhaltsverzeichnis

8.8 Übungsaufgaben VI

8.8 Übungsaufgaben VI

  1. Zuerst soll ein Ganzzahlkonstante mit dem Namen "c_iElements" angelegt werden, welches den Wert 10 repräsentieren soll. Erstellen Sie nun ein globales Array "g_aWerte" mit "c_iElements" Ganzzahlen und eine globale Ganzzahl mit dem Namen "g_iErgebnis". Anschließend soll in einer Funktion "Eingabe" vom Benutzer "c_iElements" Werte eingegeben werden, welche im Array landen sollen, die dann durch die Funktion "Addieren" zusammen addiert werden, wobei das Ergebnis in der entsprechenden globalen Variable abgelegt wird und von einer Funktion "Ausgabe" ausgegeben werden soll. Rufen Sie in der "main" diese drei Funktionen auf.
  2. Erstellen Sie zuerst einen Aufzählungstyp "EEingabeTyp" mit den Elementen "eetZahl1", "eetOperator" und "eetZahl2", sowie einen zweiten Namens "EOperator" mit den Elementen "eoPlus", "eoMinus", "eoMal" und "eoGeteilt". Legen Sie als nächstes drei globale Variablen an, wobei 2 von ihnen Gleitkommazahlen aufnehmen können sollen und die dritte den Aufzählungstyp "EOperator". Erstellen Sie jetzt eine Funktion "Eingabe", welche den Typ "EEingabeTyp" übergeben bekommt und die entsprechende Eingabe vom Benutzer anfordert. Die Eingabe soll dann in der entsprechenden Variable gespeichert werden. Nun erstellen Sie noch eine Funktion "Berechnung", welche die Berechnung durchführt und als Ergebnis zurückgibt und eine Funktion "Ausgabe", welchen den Berechneten Wert ausgibt. Rufen Sie zum Schluss die entsprechenden Funktionen in der "main" auf.
  3. Erstellen Sie eine "define" mit dem Namen "DEF_MAX" und weißen Sie eine Ganzzahl (z.B. 4) zu. Anschließend soll in der "main" ein zweidimensionales Array mit "DEF_MAX" Spalten und Zeilen erzeugt werden. Überlegen Sie sich nun, wie Sie diese Matrix diagonal befüllen können, damit das Ergebnis z.B. wie folgt aussieht.
     1  3  6 10
     2  5  9 13
     4  8 12 15
     7 11 14 16
    Geben Sie anschließend die Matrix aus, um zu kontrollieren, ob die Befüllung richtig funktioniert.
    TIPP: Das Befüllen geschieht in einer einfachen Schleife, die "DEF_MAX2" mal durchlaufen wird. In ihr muss nun der aktuelle Wert an eine Position geschrieben werden, die anschließend neu berechnet wird. Die Berechnung selbst muss dann entweder die neue Startposition für die nächste Diagonale oder die neue Position innerhalb der aktuellen Diagonale ermitteln. Andere Lösungsansätze sind denkbar.
  4. Entwerfen Sie eine Struktur "SBruch", welche einen Bruch repräsentiert (also Zähler und Nenner). Anschließend soll der Benutzer zur Eingabe von zwei Brüchen aufgefordert werden. Entwerfen Sie dafür eine geeignete Funktion, welche den eingegebenen Bruch zurück gibt. Die zwei Brüche sollen miteinander multipliziert werden und in einer geeigneten Ausgabefunktion ausgegeben werden. Dabei kann auf ein Kürzen verzichtet werden. Anschließend soll der Benutzer immer wieder gefragt werden ob er weiter machen will und wenn dem so ist, soll er einen neuen Bruch eingeben und dieser soll zum alten Ergebnis dazu multipliziert und ausgegeben werden.
  5. Jetzt soll das Spiel "Tic Tac Tou" programmiert werden. Erstellen Sie zu Beginn eine globales zweidimensionales Array mit jeweils drei Zeichenelementen, welches das Spielfeld repräsentieren soll. Erstellen Sie eine Funktion "Initialize", welche die Matrix mit Leerzeichen befüllt. Erstellen Sie als nächstes eine Funktion "Eingabe", welche als Parameter den Spieler (1 oder 2) übergeben bekommt. In der Funktion soll der Benutzer dann so lange nach einer Spalte und Zeile gefragt werden, bis er ein freies Feld erwischt (eins mit Leerzeichen). Entsprechend der Spielernummer, soll dann an die angegebene Position ein "X" oder ein "O" eingetragen werden. Schreiben Sie nun eine weitere Funktion "Ausgabe", welche das Spielfeld ausgibt. Benutzen Sie aus der Bibliothek "stdlib.h" den Funktionsaufruf "system("cls")", um vor der Ausgabe, die Konsole zu löschen. Als nächstes müssen zwei Funktion gebaut werden, wobei erste prüfen soll, ob jemand gewonnen hat und wenn ja wer (ggf. auch Unentschieden). Zweitere soll prüfen, ob weiter gespielt werden kann/muss. Überlegen Sie sich einen geeigneten Programmcode und definieren Sie ggf. neue Typen. Jetzt muss noch eine Funktion her, die das Spiel steuert, also immer die Eingaben, Ausgaben und Prüfungen aufruft und am Ende ausgeben soll, wer gewonnen hat bzw. ob ein Unentschieden vorliegt. Zum Schluss rufen Sie dann diese Steuerungsfunktion so lange auf, bis man das Spiel verlassen will.
Zum Seitenanfang
Zum Inhaltsverzeichnis

8.2 Konstanten

8.2 Konstanten

Konstanten sind vom Prinzip her ganz normale Variablen, welche sowohl global, als auch lokal definiert werden können. Der einzige Unterschied ist, dass man sie nach ihrer Definition mit gleichzeitiger Initialisierung, nicht mehr verändern kann, sie also schreibgeschützt sind. Zudem schreibt man vor ihrer Deklaration das Schlüsselwort "const". Üblicherweise deklariert man sie global und stellt ihrem Namen ein "c_" voran, um kenntlich zu machen, dass man diese Variable nicht mehr ändern kann. Gerade in C und C++ sieht man aber eher selten globale Konstanten, da man sich eher den "defines" bedient, da so nicht die ganze Zeit extra Speicherplatz benötigt wird.

Hier mal ein kleines Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
const c_iMin = 1;	// Initialisierung nur hier möglich
const c_iMax = 10;

// ...

int iWert = 5;

// Nur wenn der Wert im Bereich liegt
if ((iWert >= c_iMin) && (iWert <= c_iMax)) {
	// ...
} // end of if
					

Es kann auch vorkommen, dass vor Funktionen das Schlüsselwort "const" geschrieben wird. Dies hat aber eine ganz andere Funktion und spielt im Zusammenhang mit Objekten eine Rolle, worauf ich später ausführlich eingehen werde.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10 Variablen IV

10 Variablen IV

Da Sie nun die grundlegenden Datentypen kennen, werde ich Ihnen in den folgenden Kapiteln zeigen, was Sie mit den gegebenen Werkzeugen anstellen können, denn jetzt kommen die wirklich abstrakten Geschichten, nähmlich die Listen und Bäume.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.3 Bäume

10.3 Bäume

Ein Baum besteht aus einer "Wurzel", welche auf andere Bäume werweist. Die Liste ist ein Spezialfall des Baumes. Man hat wieder ein Wurzelelement mit mehreren Zeigern, welche auf unterschiedliche Elemente verweisen. Dieses Prinzip lässt sich an einem Stammbaum gut veranschaulichen.

Grundschema eines Binärbaumes

Das erste Element heißt wie gesagt "Wurzel" oder auch "Vaterelement". Die Zeiger sind "Zweige" und verweisen auf die "Tochterelemente" oder auch "Knotenpunkt" genannt. Jene können ihrerseits Wurzelelemente von Unterbäumen, also "Ästen" sein. Die letzten Elemente an einem Baum, nennt man "Blätter". Die zwei Zeiger in den Blattelementen, zeigen auf "NULL".

Zum Anlegen und Löschen von Elementen in einem Baum, ist es zwingend notwendig zu wissen, wie man erst einmal ein Element findet, an welches man ein weiteren Baum anhängen oder löschen will. Hierfür gibt es drei Methoden, mit denen man wirklich jedes Element in einem Baum findet.

Alle drei Methoden funktionieren ähnlich und sind Rekursiv. Man geht immer nach links, bis man "NULL" erreicht. Danach schaut man, ob es nach rechts geht. Ist dies der Fall, tut man dies einmal und geht wieder immer nach links, bis man letztendlich ein Blatt erreicht, also eines der letzten Elemente. Danach geht man wieder einen Schritt zurück, also zum Vaterelement des Blattes. Dort wieder angelangt, geht man nach rechts, es sei denn, man war dort schon. In diesem Fall geht man wieder ein Schritt zurück und so weiter. Der Unterschied zwischen diesen drei Verfahren, ist der Zeitpunkt der Ausgabe der Werte. Ich werde dies nun an einem Beispiel verdeutlichen:

Ausgeglichener Binärbaum mit sortierten numerischen Elementen

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.3.2 Inorder

10.3.2 Inorder

Inorder heißt, die Ausgabe erfolgt mittendrin, also erst das nach links gehen, dann die Ausgabe und schließlich das nach rechts Gehen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
					
// ...

// Erst links, dann Ausgabe, dann rechts //////////////////////////////////////
void InorderPrint(PIntegerTreeItem pHelpItem) {
	// Rekursionsbedingung
	if (pHelpItem != NULL)  {
		// Selbstaufruf der Funktion (nach links gehen)
		InorderPrint(pHelpItem->pLeftItem);

		printf("%i ", pHelpItem->iValue);

		// Selbstaufruf der Funktion (nach rechts gehen)
		InorderPrint(pHelpItem->pRightItem);
	} // end of if
} // InorderPrint /////////////////////////////////////////////////////////////

// ...

InorderPrint(pRootItem);

// ...
					

Ausgabe:

25 50 75 100 125 150 175
		

Dieses Verfahren ist das am häufigsten verwendetest. Wenn der Computer z.B. ein Verzeichnis mit allen Unterverzeichnissen und seinen Dateien auf ein Schlüsselwort durchsucht, geht er so vor.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.3.1 Preorder

10.3.1 Preorder

Preorder heißt, es erfolgt erst die Ausgabe, dann das nach links Gehen und schließlich das nach rechts Gehen.

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

// Erst Ausgabe, dann links, dann rechts //////////////////////////////////////
void PreorderPrint(PIntegerTreeItem pHelpItem) {
	// Rekursionsbedingung
	if (pHelpItem != NULL)  {
		printf("%i ", pHelpItem->iValue);
		
		// Selbstaufruf der Funktion (nach links gehen)
		PreorderPrint(pHelpItem->pLeftItem);
		// Selbstaufruf der Funktion (nach rechts gehen)
		PreorderPrint(pHelpItem->pRightItem);
	} // end of if
} // PreorderPrint ////////////////////////////////////////////////////////////

// ...

PreorderPrint(pRootItem);

// ...
					

Ausgabe:

100 50 25 75 150 125 175
		

Erklärung

Wird die Funktion aufgerufen, wird zunächst einmal der Zeiger übergeben, welcher auf das Wurzelelement verweist. Die Variable "pHelpItem" erhält nun diese Adresse/Referenz. Nun beginnt die Suche, es sei denn, der Baum ist leer. Als erstes wird der erste Wert ausgegeben, die "100". Jetzt ruft sich die Funktion erneut auf und übergibt den Zeiger, welcher auf den linken Baum zeigt. Die Variable "pHelpItem" zeigt nun auf das linke Tochterelement. Da hier noch nicht Schluss ist, wird die "50" ausgegeben und der Zeiger nach links wird wieder, im Selbstaufruf der Funktion, übergeben. Das Laufelement verweist wiederum auf die nächste linke Tochter (Enkelin). Auch hier ist noch nicht Schluss, also das Hilfselement ist nicht "NULL". Die "25" wird ausgegeben. Auch jetzt findet wieder der Selbstaufruf statt. Da aber nun "NULL" übergeben wird, läuft die Funktion normal durch, überspringt aber die if Anweisung. Die Funktion ist beendet. Aber nun sind ja noch Aufgaben offen. Der Computer springt zurück an die Stelle, wo er das letzte Mal nach links gegangen ist und setzt dort gleich wieder mit dem nächsten Befehl ein. In diesem Fall wäre dies jetzt der Selbstaufruf nach Rechts. Auch hier wird "NULL" übergeben. Dieser Unterast ist nun abgearbeitet. Der Computer springt ein weiteres Mal zurück, weil immer noch Aufgaben ausstehen. Wiederum steht als nächstes der Selbstaufruf an und ein Zeiger nach rechts wird wieder übergeben. Das Hilfselement verweist nun auf das rechte Tochterelement, des linken Tochterelementes des Wurzelelementes. Nun wird die "75" ausgegeben und es findet wieder ein Selbstaufruf nach links statt.
...

Hat der Computer alle Aufgaben erledigt, so ist die Rekursion beendet und man hat alle Werte.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.3.4 Löschen von Elementen

10.3.4 Löschen von Elementen

Das Erstellen von Bäumen kann man nicht verallgemeinern, da die Kriterien für die Ordnung im Baum je nach Aufgabenstellung unterschiedlich sein können. Oftmals ist der Baum auch nicht sortiert und oder ausgeglichen, also der linke Ast ist beispielsweise viel größer als der rechte.

Das Löschen eines kompletten Baumes ist jedoch einfach. Um alle Elemente zu löschen, muss man erst einmal alle finden. Wie dies funktioniert, habe ich eben beschrieben. Man nimmt hierfür immer die Postorder. Anstatt der Ausgabe steht nun ein "delete" (bzw. ein "free", wenn man mit "malloc" arbeitet).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
// ...

// Löscht ein Element/Ast aus dem Baum ////////////////////////////////////////
void DeleteTree(PIntegerTreeItem pHelpItem) {
	// Rekursionsbedingung
	if (pHelpItem != NULL) {
		DeleteTree(pHelpItem->pLeftItem);
		DeleteTree(pHelpItem->pRightItem);

		delete pHelpItem;
	} // end of if
} // DeleteTree ///////////////////////////////////////////////////////////////

// ...

DeleteTree(pRootItem);

// ...
					

Andere Verfahren würden die Knotenpunkte frühzeitig löschen und man könnte alle Unterbäume an ihnen nicht mehr freigeben. Mit Preorder würde beispielsweise gleich das Wurzelelement gelöscht werden, jedoch nicht seine Äste. Was dies zur Folge hat, wurde von mir bereits erwähnt.

Wenn man ein einzelnes Element aus dem Baum löschen will, so bedarf es ein wenig Denkarbeit. Zunächst muss man das entsprechende Element suchen (wie, ist egal) und dann muss man prüfen, um was es sich für ein Element handelt. Hierbei können vier Fälle auftreten.

Abschließend möchte ich zu diesem Thema noch sagen, dass das Sortieren von Bäumen mindestens genauso spaßig ist, wie das Löschen. Im Endeffekt läuft es aber darauf hinaus, dass man so lange Element sucht, welche nicht an der richtigen Stelle stehen, bis alles ok ist. Die falsch positionierten Elemente löscht man aus dem Baum (also nur abhängen und nicht freigeben) und sortiert sie anschließend neu ein (man lässt sie durch rieseln). Alternativ kann man auch einen komplett neuen Baum aufbauen, was aber nicht unbedingt schneller sein muss (auch wenn man es im ersten Moment vermuten könnte). Allerdings sortiert man nicht im Nachhinein Bäume, sondern sorgt gleich beim aufbauen, dass eine Sortierung vorliegt. Dafür gibt es auch verschiedene Konzepte, wie z.B. s.g. AVL-Bäume, Rot-Schwarz-Bäume oder die s.g. B bzw. B* Bäume. Für die entsprechenden Konzepte gibt es dann auch spezielle Regeln, wie Elemente gelöscht werden. Da dies aber eher in die theoretische Informatik geht und ich mich mehr auf C bzw. C++ konzentrieren möchte, belasse ich es hiermit dabei. Notfalls wissen Sie jetzt aber, über welche Themen Sie sich noch informieren müssten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.3.3 Postorder

10.3.3 Postorder

Postorder heißt, die Ausgabe erfolgt als letztes, also erst das nach links gehen, dann das nach rechts gehen und erst danach die Ausgabe.

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

// Erst links, dann rechts, dann Ausgabe //////////////////////////////////////
void PreorderPrint(PIntegerTreeItem pHelpItem) {
	// Rekursionsbedingung
	if (pHelpItem != NULL)  {
		// Selbstaufruf der Funktion (nach links gehen)
		PostorderPrint(pHelpItem->pLeftItem);
		// Selbstaufruf der Funktion (nach rechts gehen)
		PostorderPrint(pHelpItem->pRightItem);

		printf("%i ", pHelpItem->iValue);
	} // end of if
} // PreorderPrint ////////////////////////////////////////////////////////////

// ...

PostorderPrint(pRootItem);

// ...
					

Ausgabe:

25 75 50 125 175 150 100
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

10.1 Zeichenketten / Strings

10.1 Zeichenketten / Strings

Die Strings sind mit die wichtigsten Datentypen in der Programmierung, da viele Problemstellungen auf den Umgang mit solchen Zeichenketten beruhen. Das fängt beim Bearbeiten von Dateien an und hört bei der Netzwerkkommunikation auf. Allerdings ist dieses Kapitel, gerade in C, eines der kompliziertesten überhaupt. Wenn Sie mit Strings umgehen können, können Sie behaupten, Pointer verstanden zu haben. Komplizierter wird es dann nicht mehr.

Die Grundidee von Strings ist, sie in einem Array abzubilden, sodass jedes Zeichen in einem separaten Feld steht. Im Gegensatz zu anderen Programmiersprachen, gibt es in C nur s.g. Null-Terminierte-Strings (in C++ gibt es dann auch andere klassenbasierte Strings). Dies bedeutet, dass jeder Zeichenkette, das ASCII-Zeichen "\0" (also der Wert nul und nicht die Zahl null) an gehangen wird, um zu signalisieren, dass das Wort bzw. der Satz, zu Ende ist. Dies muss immer berücksichtigt werden, wenn man Speicher reserviert bzw. eine Puffergröße angeben soll.

Ein Stringarray anhand eines Zuges

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.1.2 Dynamische Strings

10.1.2 Dynamische Strings

Da Strings ja, wie bereits erwähnt, nur Felder sind, kann man auch sie dynamisch erzeugen und verwalten.

Das Schema sieht so aus:
char* <Variable> [= "<Zeichenkette>"]|[= new char[<AnzahlDerZeichen>]]

 1
 2
 3
 4
 5
					
char* pcstrZeigerAufKonstante	= "Ich bin ein Zeiger auf eine statische Konstante.";
printf("%s\n", pcstrZeigerAufKonstante);

pcstrZeigerAufKonstante		= "Jetzt zeige ich auf eine andere Konstante.";
printf("%s\n", pcstrZeigerAufKonstante);
					

Ausgabe:

Ich bin ein Zeiger auf eine statische Konstante.
Jetzt zeige ich auf eine andere Konstante.
		

Obwohl ich in Zeile 1 einen Pointer deklariert haben, darf dieser in diesem Spezialfall nicht freigegeben werden! Der Grund dafür ist folgender. Intern legt der Compiler eine Konstante an und die Referenz auf diese Konstante wird an den Pointer übergeben. Versuchen man jetzt, den Speicher freizugeben, wird dies zu einer Schutzverletzung führen, da man mit Konstanten nichts machen darf.

Genauso dürfen Sie jetzt nicht den Inhalt der Zeichenkette ändern, weder per Funktion noch per Indexzugriff. Das einzige was Sie machen dürfen ist, wie in Zeile 4 gezeigt, den Zeiger auf eine andere Konstante zeigen lassen. Dabei sei erwähnt, dass dabei kein Speicher verloren geht, weil sich der Compiler um die Speicherverwaltung von Konstanten kümmert. Das war auch der Grund, warum ich hier keine Größe für den String angeben musste.

 1
 2
 3
 4
 5
 6
 7
 8
					
char* pstrZeigerAufDynamischenString	= new char[80];

strcpy_s(pstrZeigerAufDynamischenString, 80, "Ich bin ein dynamischer String.");
printf("%s\n", pstrZeigerAufDynamischenString);

pstrZeigerAufDynamischenString[30]	= '!';
printf("%s\n", pstrZeigerAufDynamischenString);
delete [] pstrZeigerAufDynamischenString;
					

Ausgabe:

Ich bin ein dynamischer String.
Ich bin ein dynamischer String!
		

In diesem Beispiel zeige ich, wie man selbst die Speicherverwaltung für Strings übernimmt und genau das, sehen Sie in Zeile 1 und 8. Jetzt muss man allerdings aufpassen, dass man erstens genug Speicher reserviert und man darf unter keinen Umständen, dem String eine Referenz auf eine Konstante zuweisen, wie ich das im Vorherigen Beispiel getan habe. Der Grund dafür liegt auf der Hand. Mit dem Zuweisen einer neuen Referenz, verliert man den Bezug zum vorher reservierten Speicher und somit kann jener nicht mehr freigegeben werden (Speicherleck).

Jetzt darf man aber wieder Stringfunktionen benutzen und man kann auch wieder den Inhalt über den Indexzugriff manipulieren. Dies zeige ich in Zeile 3 und 6.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.1.1 Statische Strings

10.1.1 Statische Strings

Wie Sie das von Arrays kennen, kann man logischerweise auch die Strings statisch erzeugen. Dies hat zur Folge, dass Sie sich nicht um die Speicherreservierung kümmern müssen.

Die statischen Arrays sind am leichtesten zu verwalten und man braucht auch nicht so viele Dinge zu beachten, aber leider werden sie im Stack des Arbeitsspeichers gehalten und somit kann man keine Massen an Informationen halten, wie das z.B. eine Textverarbeitung macht. Nichts desto trotz, werde ich kurz demonstrieren, was man machen kann und Hinweise geben, was Sie unterlassen sollten.

Vorab sei noch erwähnt, dass man einzelne Zeichen in einfache Anführungszeichen setzt, wohingegen ganze Zeichenketten, in doppelte Anführungszeichen gesetzt werden. Man kann auch ein einzelnes Zeichen in doppelte Anführungszeichen setzen, allerdings wird dieses Zeichen dann wie ein String behandelt und somit wird noch das ASCII Zeichen "0" an gehangen, wodurch zwei Byte benutzt werden, um einen Buchstaben zu speichern. Seien Sie sich dessen immer bewusst.

Das Schema sieht so aus: char <Variable>[<AnzahlDerZeichen>] [= "<Zeichenkette>"]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
					
#include <string.h>

// ...

char astrStatischerString1[80]		= "Mein Inhalt wurde von einer Konstante kopiert.";
printf("%s\n", astrStatischerString1);

astrStatischerString1[45]		= '!';	// Einzelnes Zeichen wird manipuliert
printf("%s\n", astrStatischerString1);

char astrStatischerString2[20];
strcpy_s(astrStatischerString2, 20, "Ich bin statisch.");
printf("%s\n", astrStatischerString2);

astrStatischerString2[16]		= '!';
printf("%s\n\n", astrStatischerString2);

char astrStatischerString3[1001];
printf("Bitte Satz eingeben (max 1000 Zeichen):\n");

gets_s(astrStatischerString3, 1001);
printf("Ihre Eingabe lautet:\n%s\n", astrStatischerString3);
					

Ausgabe:

Mein Inhalt wurde von einer Konstante kopiert.
Mein Inhalt wurde von einer Konstante kopiert!
Ich bin statisch.
Ich bin statisch!

Bitte Satz eingeben (max 1000 Zeichen):
Hallo Welt
Ihre Eingabe lautet:
Hallo Welt
		

Ihnen ist vielleicht aufgefallen, dass meine Arrays alle größer sind, als sie es sein müssten. In Zeile 5 reserviere ich 80 Elemente, obwohl 46 + 1 (Terminator) ausreichen würden. Diese Vorgehensweise ist allerdings üblich, da man zum einen zu faul zum zählen ist und zum Anderen nie weiß, was noch kommen könnte. Wie der Name "statisch" bereits suggeriert, kann man die Größe im Nachhinein nicht mehr ändern. Des Weiteren sehen Sie, wie ich gleich eine Konstante zuweise, oder um ganz korrekt zu sein, dem Compiler in Auftrag gebe, den Inhalt einer konstanten Zeichenkette, in mein Array zu kopieren.

In Zeile 8 und 15 manipuliere ich den Inhalt meines Arrays. Ich mache aus dem Punkt im Satz, ein Ausrufezeichen. Sie lernen also daraus, dass Sie statische Zeichenketten nach Belieben manipulieren können.

In den Zeilen 6, 9, 13, 16, 19 und 22 zeige ich, wie man Strings einfach ausgeben kann, ohne das Array durch iterieren zu müssen. Man benutzt "%s". Achtung! Dies funktioniert nur mit Zeichenketten. Arrays mit Integern z.B. können nicht so ausgegeben werden.

Zeile 12 zeigt, wie Sie mit Stringfunktionen umgehen müssen, wenn Sie statische Arrays benutzen. Entscheidend bei den Funktionen mit "_s" ist, dass man immer noch die Größe des Arrays angeben muss. (Achtung - Nicht die Länge des Wortes, welches ja unter Umständen kürzer sein kann!)

Im dritten Abschnitt, ab Zeile 21 zeige ich, wie man in einen String Werte/Sätze einlesen kann. Hierbei gibt es zwei Besonderheiten. Zum einen braucht man den "&" Operator nicht, da ein Array ja schon ein Zeiger ist und es besser ist die Funktion "gets" bzw. "gets_s" zu benutzen, da "scanf" bzw. "scanf_s" lediglich ein Wort einlesen. Sobald in der Eingabe ein Leerzeichen auftaucht, wird der String abgeschnitten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.1.4 Konstante Strings und Stringkonstanten

10.1.4 Konstante Strings und Stringkonstanten

Um noch einen drauf zu setzen, möchte ich Ihnen noch die konstanten Strings zeigen. Sie werden in einem separaten Konstantenspeicher gehalten und vom Compiler verwaltet. Im Stack befindet sich dann nur noch eine Referenz, also ein Pointer auf diese Konstante. Man hat also einen quasi dynamischen String. Als es eben um dynamische Strings ging, habe ich schon kurz gezeigt, dass man einen Pointer auf einen konstanten Wert zeigen lassen kann. Ich habe auch dazu gesagt, dass es möglich ist, diesen Zeiger dann zur Laufzeit auf eine andere Konstante umzubiegen. Wenn man aber bei der Variablendeklaration den Modifikator "const" mit vor den Typ schreibt, ist dies auch nicht mehr möglich. Dies wird in der Regel aber nur in Funktionsköpfen getan, um den Nutzer der Funktion zu garantieren, dass sein String unverändert bleibt.

An folgendem Quelltext möchte ich noch einmal die Unterschiede klar machen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
					
const char*	astrConstString		= "konstant";
char*		pcstrConstString	= "konstant";
char		aStaticString[10]	= "statisch";

char*	pstrDynamicString		= new char[10];
strcpy(pstrDynamicString, "dynamisch");

printf("%s\n", astrConstString);	
printf("%s\n", pcstrConstString);
printf("%s\n", astrStaticString);
printf("%s\n", pstrDynamicString);

aConstString2				= "Konstant";	// Verweis auf neue Konstante
aStaticString[0]			= 'S';		// Zeichen Austauschen
pstrDynamicString[0]			= 'D';		// Zeichen Austauschen

printf("%s\n", aConstString2);
printf("%s\n", aStaticString);
printf("%s\n\n", pstrDynamicString);

delete [] pstrDynamicString;
					

Ausgabe:

konstant
konstant
statisch
dynamisch

Konstant
Statisch
Dynamisch
		

Speicherverwaltung verschiedener Stringtypen vor Änderungen

Hier sind zunächst die drei Speicherbereiche gut ersichtlich. Der Stack ist ja der Speicher, welcher zwar nicht so groß ist, aber auf welchem wir bedenkenlos Daten anlegen können, ohne uns um deren Verwaltung zu kümmern.

Der Const-Speicher ist ein Speicherbereich, welcher vom Compiler bereit gestellt wird und mit jedem Programmstart reserviert wird. Der Zugriff ist ausschließlich lesend und die Verwaltung übernimmt der Compiler. Er ist es auch, welcher für eine automatische Größe sorgt. Versucht man einen String auf eine Konstante zu löschen, geht das zwar im ersten Moment, aber spätestens beim Programmende, wird es einen Fehler geben, weil der Compiler Code eingefügt hat, um dies zu tun (und somit würde es doppelt passieren).

Der Heap ist der größte Bereich des Speichers und auf ihn kann man nur über eine Referenzierung heran kommen. Speicherplatz muss selbst erstellt und freigegeben werden. Wenn man versucht einen Zeiger auf einen solchen String auf eine Konstante biegt, ist das zwar im Moment ok, aber der reservierte Platz kann nicht mehr freigegeben werden, da man nicht mehr weiß, wo der Speicherbereich ist, der freizugeben ist.

In der Grafik ist auch schön zu erkennen, dass sowohl der Zeiger auf die Konstante, als auch der Zeiger auf den dynamischen String, im Stack liegen, aber dann wo anders hin verweisen. Lediglich der statische String wird komplett im Stack gehalten. Schaut man sich aber den Quelltext dazu an, sieht man nicht so schnell den Unterschied.

Gerade der Unterschied zwischen Zeile 2 und 3 ist wichtig. Ich hatte ja mal gesagt, dass man ein Array bei der Definition entweder mit dem Stern am Variablentyp oder mit den eckigen Klammern am Variablenname setzen kann. Der Unterschied bei Strings ist nun, dass die Angabe mit den eckigen Klammern und einer Größenangabe, ein Array im Stack anlegt. Zusätzlich existiert eine Konstante im Const-Speicher, welche dann in das Array kopiert wird. Somit kann man den Inhalt verändern, wie in Zeile 14 gezeigt. Die Angabe mit dem Punk ist lediglich ein Zeiger. Hier kann kein Array im Stack angelegt werden, da ja die Größe nicht explizit angegeben wird. Somit wird ein Verweis auf den Inhalt im Const-Speicher gesetzt. Dieser Verweis kann zwar zur Laufzeit umgebogen werden, wie in Zeile 13 gezeigt, aber man kann den Inhalt nicht ändern, solange der Zeiger auf den Const-Speicher zeigt.

Auch wenn das Verwirrend klingt, aber Sie müssen immer unterscheiden zwischen einem konstanten String und einer Stringkonstante. Ein konstanter String ist ein nicht veränderbarer Pointer auf einen String und dieser kann nicht umgebogen werden. Falls der Zeiger aber auf einen Bereich im Stack oder Heap zeigt, kann man den Inhalt durchaus ändern. Eine Stringkonstante hingegen ist ein Bereich im Const-Speicher. Die Referenz kann umgebogen werden, aber der Inhalt kann nicht manipuliert werden.

Um noch einen drauf zu setzen, gibt es auch konstante Stringkonstanten. Das sieht man in Zeile 1. Hier habe ich einen String definiert, welcher auf eine Stringkonstante zeigt. Dieser Zeiger kann auch später nicht mehr umgebogen werden und der String selbst ist nicht änderbar.

Nach den änderungen ab Zeile 13, sieht der Speicher wie folgt aus.

Speicherverwaltung verschiedener Stringtypen nach Änderungen

Sie sehen hier sehr schön, dass "pcstrConstString" zwar scheinbar geändert wurde, er aber im Grunde nur auf einen anderen Bereich im Const-Speicher verweist. Bei dem statischen und dem dynamischen String hingegen, wurde tatsächlich der Inhalt geändert.

Ich kann gut verstehen, wenn Sie jetzt einen Knoten im Kopf haben sollten. Wenn dem so ist, lesen Sie dieses Kapitel einfach noch einmal in Ruhe durch. Ich muss selber auch zugeben, dass ich jedes Mal aufs Neue nachdenken muss. Jetzt verstehen Sie vielleicht warum man sagt, dass Strings in C das komplizierteste Thema ist.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.1.3 Einlesen von Strings

10.1.3 Einlesen von Strings

Bisher habe ich Werte immer mit der Funktion "scanf" bzw. "scanf_s" eingelesen. Dies funktioniert auch mit Strings, allerdings ergeben sich daraus ein paar Fallstricke, was folgendes Beispiel demonstrieren soll.

statische Variante dynamische Variante
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
					
#define DEF_MAX_LENGTH 80

char astrKurzerSatz[DEF_MAX_LENGTH];

printf("Bitte schreiben Sie einen kurzen Satz:\n");
fflush(stdin);
scanf_s("%s", astrKurzerSatz, DEF_MAX_LENGTH);
printf("Sie schrieben:\n%s\n\n", astrKurzerSatz);

printf("Bitte schreiben Sie noch einen kurzen Satz:\n");
fflush(stdin);
gets_s(astrKurzerSatz, DEF_MAX_LENGTH);
printf("Sie schrieben: \n%s", astrKurzerSatz);
					
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
#define DEF_MAX_LENGTH 80

char* pstrKurzerSatz = new char[DEF_MAX_LENGTH];

printf("Bitte schreiben Sie einen kurzen Satz:\n");
fflush(stdin);
scanf_s("%s", pstrKurzerSatz, DEF_MAX_LENGTH);
printf("Sie schrieben:\n\"%s\"\n\n", pstrKurzerSatz);

printf("Bitte schreiben Sie noch einen kurzen Satz:\n");
fflush(stdin);
gets_s(pstrKurzerSatz, DEF_MAX_LENGTH);
printf("Sie schrieben: \n\"%s\"", pstrKurzerSatz);

delete [] pstrKurzerSatz;
					

Ausgabe:

Bitte schreiben Sie einen kurzen Satz:
Hallo Welt
Sie schrieben:
"Hallo"

Bitte schreiben Sie noch einen kurzen Satz:
Hallo Welt
Sie schrieben:
"Hallo Welt"
		

Wie Sie sehr schön im ersten Teil sehen können, ließt die Funktion "scanf_s" nur ein Wort ein und keinen ganzen Satz. Genauso kritisch ist es, wenn man so versucht ein einzelnes Leerzeichen einzulesen. Dies leistet diese Funktion nicht, im Gegenteil, es ist von den Entwicklern sogar so gewollt. Um also ein einzelnes Leerzeichen oder einen Satz einlesen zu können, muss man die Funktion "gets" bzw. "gets_s" benutzen.

Besonders auffällig ist, dass beim Einlesen von Strings, die Variable normal übergeben wird und nicht ihre Adresse, also mit einem "&" Operator. Dies liegt daran, dass ein Array ja quasi schon ein Zeiger ist. Streng genommen sind zwar nur dynamische Arrays Zeiger, aber in diesem Fall läuft es auf das Gleiche hinaus.

Ein weiterer bedeutender Fakt ist, dass bei den "_s" Funktionen immer eine Puffergröße mit angegeben werden muss, da es sonst zu Abstürzen kommt (gerade bei "scanf_s"). Dabei bezieht sich der Puffer auf die Variable, welche den Wert bekommen soll, oder im Falle von "strcat_s" oder "strcpy_s", auf die erste Variable, also das Ziel (Target). Wählt man einen kleineren Puffer, so wird die entsprechende Operation abgeschnitten oder es kommt auch zu Abstürzen. Hier ist also höchste Aufmerksamkeit geboten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.2 Listen

10.2 Listen

Bisher wurden Listen immer nur in Arrays abgebildet. Dies ist allerdings sehr unvorteilhaft, da die Erweiterung oder Verkürzung umständlich ist. Deswegen hat man sich überlegt, ein neues Konstrukt zu erfinden, um eine bessere Implementierung zu erreichen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.2.1 Einfach verkettete Listen

10.2.1 Einfach verkettete Listen

Die grundlegende Idee der Listen ist, ein Zeiger auf eine Struktur verweisen zu lassen, welches u.a. einen Zeiger desselben Typs beinhaltet. So erhält man die Grundlage einer Liste. Ich verdeutliche dies mal einem Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
// Listenelement
struct SItemStudent {
	char		astrFirstName[80];
	char		astrLastName[80];
	int		iAge;
	SItemStudent*	pNextItem;
};

// Zeiger auf ein/das erste Element der Liste
typedef SItemStudent* PListOfStudents;
					

"pNextItem" ist der besagte Zeiger in der Struktur, welcher auf eine andere gleichartige Liste verweist. Die Definition einer Liste ist also: "Eine Liste besteht aus einem Element, welches auf eine weitere Liste verweist". Das erste Element einer Liste nennt man "Wurzel" bzw. "Wurzelelement", denn von der Wurzel geht alles aus. Die Definition einer Liste ist also rekursiv.

Grundschema einer einfachen Liste

Im Bezug auf mein Beispiel, zeigt der erste Student der Liste, auf den Rest der Liste/Studenten, also auf die "Restliste". Bildlich gesehen würde dieser Student auf die Position/Adresse einer der anderen Studenten seines Jahrgangs, mit dem Finger zeigen. Jener andere Studenten würde nun auch auf die Position eines anderen (dem nächsten) Studenten seines Jahrgangs zeigen. Nun stellt sich die Frage, wohin der letzte noch übrig bleibende Student zeigen soll. Die Antwort ist schlicht und einfach, "auf nichts", also auf "NULL".

Im folgenden Beispiel werde ich zeigen, wie man eine solche Studentenliste mit 3 Studenten anlegt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
					
// Befüllen eines Eintrages ///////////////////////////////////////////////////
void FillItem(PListOfStudents pItem) {
	// Wenn ein Listenelement initialisiert wird,
	// sollte der Zeiger in ihm auf NULL gesetzt werden
	pItem->pNextItem	= NULL;

	// Wurzel verweist auf den Namen des Schülerelementes der Liste
	printf("Vorname:  ");
	fflush(stdin);
	scanf_s("%s", pItem->astrFirstName, 80);

	printf("Nachname: ");
	fflush(stdin);
	scanf_s("%s", pItem->astrLastName, 80);

	printf("Alter:    ");
	scanf_s("%i", &pItem->iAge);
	
	printf("\n");
} // FillItem /////////////////////////////////////////////////////////////////



// Ausgabe eines Eintrages ////////////////////////////////////////////////////
void OutputItem(PListOfStudents pItem) {
	printf("\n%s ", pItem->astrFirstName);
	printf("%s ", pItem->astrLastName);
	printf("- %i\n", pItem->iAge);
} // OutputItem ///////////////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	PListOfStudents	pRootItem	= NULL;
	PListOfStudents	pHelpItem	= NULL;

	// Speicher wird für erstes Element reserviert
	pRootItem			= new SItemStudent;
	FillItem(pRootItem);

	// Speicher wird für nüchstes Element reserviert
	pHelpItem			= new SItemStudent;
	FillItem(pHelpItem);

	// Der Zeiger im ersten Element zeigt auf die nächste Liste, die Hilfsliste
	pRootItem->pNextItem		= pHelpItem;

	// Speicher wird für nächstes Element reserviert
	pHelpItem			= new SItemStudent;
	// Die Variable Hilfselement zeit nun nicht mehr auf das alte Element, 
	// also das zweite in der Liste, sondern auf eine neue Liste,
	// welche dann wieder an das zweite Element der ersten Liste angehängt wird.

	FillItem(pHelpItem);

	pRootItem->pNextItem->pNextItem	= pHelpItem;

	// ...
					

Gerade die Zeile 57 zeigt, dass es sinnvoll wäre, gerade bei langen Listen, zusätzlich einen Laufzeiger zu definieren. Jener würde immer am letzten Element der Hauptliste warten, bis alle Einträge in der Hilfsliste eingetragen sind. Somit ist das Anhängen einfacher.

Wie vorhin schon erwähnt, muss am Ende der gesamte Speicher für die Elemente, welcher reserviert wurden, wieder freigegeben werden. Dies sähe in meinem Beispiel mit drei Studenten, wie folgt aus.

59
60
61
62
63
64
65
66
67
68
69
70
71
					
	//...

	pHelpItem 			= pRootItem->pNextItem->pNextItem;
	OutputItem(pHelpItem);
	delete pHelpItem;

	pHelpItem 			= pRootItem->pNextItem;
	OutputItem(pHelpItem);
	delete pHelpItem;

	OutputItem(pRootItem);
	delete pRootItem;
} // main /////////////////////////////////////////////////////////////////////
					

Wichtig! Listen sollten wie im Beispiel, immer von hinten nach vorne gelöscht werden. Würde man das erste Element zuerst löschen, also den Speicher wieder Freigeben, würde so auch der Zeiger des ersten Elementes nicht mehr existiert und so hat man keine Chance mehr, die Position, also die Adresse, der anderen Elemente herauszubekommen. Somit kann man sie nicht mehr löschen und der Speicherplatz im RAM wäre, wie bereits erwähnt, bis zum Programmende sinnlos vergeben und könnte nicht mehr genutzt werden. Auch hier macht es bei längeren Listen Sinn, eine Laufvariable zu benutzen. Jene könnte immer zum letzten Element durchlaufen und es dann löschen. Dies könnte folgendermaßen aussehen.

59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
					
	PListOfStudents	pWalkPointer;

	// Solange, bis nichts mehr am Wurzelelement dran hängt
	while (pRootItem->pNextItem != NULL) {
		pWalkPointer		= pRootItem;
		pHelpItem		= pRootItem;

		// Solange, bis das Ende der Liste erreicht wurde
		while (pWalkPointer->pNextItem != NULL) {
			pHelpItem	= pWalkPointer;
			pWalkPointer	= pWalkPointer->pNextItem;
		} // end of while

		OutputItem(pWalkPointer);

		delete pWalkPointer;
		pHelpItem->pNextItem	= NULL;
		pWalkPointer		= NULL;
	} // end of while

	OutputItem(pRootItem);
	delete pRootItem;
} // main /////////////////////////////////////////////////////////////////////
					

Das sieht zwar jetzt viel komplizierter aus, stellt aber sicher, dass alle Elemente der Liste entfernt werden und das für beliebig lange Listen.

Problematischer ist es schon, wenn man ein Element löschen will, welches sich mitten in einer Liste befindet. Dies kann leicht dazu führen, dass man nicht nur das einzelne Element löscht, sondern auch den Rest der Liste unzugänglich macht. Ein Laufzeiger müsste wiederum bis zu dem Element davor Laufen. Die Abfrage hierfür hängt davon ab, nach welchem Kriterium man ein bestimmtes Element, welches es zu löschen gilt, sucht. Der Zeiger "pHelpItem" soll dann auf "pWalkPointer->pNextItem" zeigen. Jetzt sagt man, dass der Zeiger des Laufelementes, auf das Übernächste zeigen soll.

 1
 2
 3
					
pWalkPointer->pNextItem	= pWalkPointer->pNextItem->pNextItem 
// oder
pWalkPointer->pNextItem	= pHelpItem->pNextItem
					

"pHelpItem" kann nun mit "delete" gelöscht werden.

Ich empfehle Ihnen drinend, sich für Listen eine Skizze anzufertigen, da man schnell durcheinander kommt, wie man nun was abruft und der Gleichen.

Löschen aus einer Liste I

Löschen aus einer Liste II

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.2.2 Doppelt verkettete Listen

10.2.2 Doppelt verkettete Listen

Bei einfach verketteten Listen, befindet sich in der Struktur ein Zeiger, welcher auf die Restliste verweist. Bei doppelt verketteten Listen, ist dies derselbe Fall, nur dass es nun noch einen zusätzlichen Zeiger gibt, welcher auf das vorhergehende Element verweist. Man muss hier nur beachten, dass der "Previous-Zeiger" im Wurzelelement, auf "NULL" zeigt.

Grundschema einer doppelt verketteten Liste

Natürlich muss man beim bearbeiten dieser Listen (einfügen, vertauschen und löschen), doppelt so sehr aufpassen, dass man alle Zeiger richtig umbiegt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.2.3 Ringlisten

10.2.3 Ringlisten

Ringlisten sind im Grunde genommen das Gleiche wie einfach verkettete Listen, nur dass das der Zeiger des letzten Elementes nicht auf "NULL", sondern auf das "Wurzelelement" verweist. Somit gibt es kein richtiges Wurzelelement mehr, da ein Ring, also ein Kreis, keinen Anfang und kein Ende besitzt. Jedoch sollte man immer darauf achten, dass ein Zeiger immer auf ein Element der Liste verweist, da man sonst keinen Zugriff mehr erhält. Auf welches Element ein Zeiger verweist, ist hierbei relativ egal.

Grundschema einer einfachen Ringliste

Zum Seitenanfang
Zum Inhaltsverzeichnis

10.4 Übungsaufgaben VIII

10.4 Übungsaufgaben VIII

  1. Schreiben Sie ein kleines Programm, welches aus einem konstanten String die Anzahl des Vorkommens eines gewünschten Buchstabes z%auml;hlt. Schreiben Sie zu diesen Zweck eine extra Funktion mit folgender Signatur:
    int CountSign(const char* const pcstrSource, const char& cSign);
    Testen Sie Ihr Programm mit sinnvollen Werten und fangen Sie auch den Fall ab, dass "NULL" übergeben wird (Rückgabewert -1).
  2. Schreiben Sie ein kleines Programm, welches aus einem konstanten String die Startposition eines anderen konstanten Strings sucht und zurückgibt. Benutzen Sie folgende Signatur:
    int strpos(const char* const pcstrSource, const char* const pcstrSearch); Falls irgendetwas nicht ok ist, soll -1 zurückgegeben werden.
    TIPP: Die Funktion "strstr" ist ganz hilfreich.
  3. In den folgenden Aufgaben wird es darum gehen, eine Warteschlange zu implementieren. Erstellen Sie dafür ein Programm und legen Sie schon per Konstante fest, wie viele Einträge maximal möglich sind (vier Einträge reichen für den Anfang). Zudem soll diese Warteschlange als ringförmige Liste abgebildet werden, wobei die Elemente dynamische Strings enthalten sollen. Erzeugen Sie alle notwendigen Datentypen.
    TIPP: Speichern Sie sich irgendwo, wie viele Strings momentan enthalten sind. Das wird Ihnen später ein paar Abfragen erleichtern.
  4. Schreiben Sie eine Funktion "Initialize", welche die Ringliste anlegt und die Zeiger auf die Strings der Elemente, auf "NULL" zeigen lässt. Geben Sie die erzeugte Ringliste zurück. Beachten Sie, dass es auch möglich sein soll, dass die Warteschlange nur ein oder gar kein Element aufnehmen soll/kann.
    TIPP: Malen Sie sich das Verhalten auf, um mehr Durchblick zu erhalten.
  5. Schreiben Sie nun eine Funktion "Append", welche einen übergebenen String kopiert und diese Kopie hinten anhängt. Falls die Warteschlange voll ist, soll "false" zurückgegeben werden. Achten Sie darauf, dass die Stringkopie nicht unnötig groß ist. Beachten Sie, dass das Ende der Schlange jetzt wo anders ist, da sich die Anzahl der Einträge erhöht.
  6. Schreiben Sie als nächstes eine Funktion "Remove", welche den vordersten String aus der Warteschlange löscht. Die Funktion soll "false" zurückgeben, wenn es nichts zum Entfernen gibt. Beachten Sie, dass der Anfang der Schlange jetzt wo anders ist, da sich die Anzahl der Einträge verringert.
  7. Schreiben Sie eine Funktion "PrintFirst", welche das erste Element auf der Konsole ausgibt.
  8. Schreiben Sie anschließend eine Funktion "Finalize", welche alle noch in der Schlange befindlichen Strings löscht und die Warteschlange, samt aller Elemente, frei gibt.
  9. Schreiben Sie abschließend eine Funktion "main", um Ihre eben erstellten Funktionen zu testen. Besonders wichtig ist dabei, dass nichts mehr hinzugefügt werden kann, wenn die Liste voll ist und dass das Hinzufügen wieder funktioniert, sobald wieder ein Platz frei ist.
Zum Seitenanfang
Zum Inhaltsverzeichnis

7 Die Header-Dateien

7 Die Header-Dateien

Header-Dateien sind wie Inhaltsverzeichnisse der zugehörigen CPP-Datei anzusehen. Ich hatte mit Ihnen die Thematik der Funktionsprototypen bereits besprochen. Ich hatte Ihnen gezeigt, dass man diese Prototypen an den Anfang der CPP-Datei packt. Dies ist aber nur wirklich sinnvoll, wenn die "main.cpp" die einzige Quelltextdatei des Projektes ist. Immer dann, wenn Sie mehrere CPP-Dateien haben, müssen Sie eine s.g. Header-Datei anlegen, welche alle Funktionsprototypen der zugehörigen CPP enthält. Eine Ausnahme stellt wieder die "main.cpp" dar, da das System im Hintergrund selbst eine solche Datei anlegt, wobei dort nur die Funktion "main" eingetragen wird. Deshalb baut man ein Projekt so auf, dass in der "main.cpp" nur das aller nötigste enthalten ist (also nur Funktionsaufrufe etc.). Alles andere lagert man in andere Dateien aus.

Eine Header-Datei heißt üblicherweise genauso wie die zugehörige CPP-Datei, nur dass ihre Dateiendung ".h" lautet. Sie enthält wie erwähnt Funktionsprototypen und oftmals auch Konstanten und "defines". Ebenso ist es möglich, ganze Funktionen in die Header zu packen, aber davon rate ich dringlichst ab, zumal, um bei der Analogie zu bleiben, im Inhaltsverzeichnis eines Buches, keine Fließtexte etwas zu suchen haben. Ich erwähne dies nur, weil einem hin und wieder so etwas über den Weg läuft.

Ein weiterer Grund, warum man möglichst wenig Logik in Header-Dateien bringt ist, dass diese nicht mit kompiliert werden bzw. sie nicht in Bytecode umgesetzt werden. Wenn Sie Ihr Projekt weiter geben und der andere nicht sehen soll, wie Sie gewisse Sachen gelöst haben, reicht es ihm die vor kompilierten "obj" Dateien (das wird aus cpp Dateien) und die Header als Klartext zu geben. Somit darf in den Headern nichts stehen, was ein "Geheimnis" bleiben soll. Wenn man doch eine Funktion in die Header-Datei einbaut, sollte es nur etwas banales sein.

Zum Seitenanfang
Zum Inhaltsverzeichnis

7.3 Inlinefunktionen

7.3 Inlinefunktionen

Ich hatte kurz erwähnt, dass man hin und wieder auch in Header-Dateien Funktionen einbaut. Wenn dies getan wird, handelt es sich oftmals um s.g. Inlinefunktionen, welche in der Regel aus sehr wenig Quelltext bestehen (oft nur eine Zeile). Doch was hat es mit diesem "inline" auf sich? Um dies zu verdeutlichen, habe ich mir ein kleines Beispiel ausgedacht.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
inline int Summe(int iSummand1, int iSummand2) { return iSummand1 + iSummand2; };



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char* argv) {
	printf("%i\n", Summe(5, 3));

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

In Zeile 1 habe ich eine Inlinefunktion deklariert. Auf den ersten Blick, sieht dies wie ein Funktionsprototyp aus, aber wenn Sie genauer hinschauen, sehen Sie, dass da noch mehr steht. Zu Beginn einer solchen Funktion schreibt man das Schlüsselwort "inline" und man muss am Ende ein Semikolon setzen. Des Weiteren wird an dieser Stelle gleich der Quelltext der Funktion mit hingeschrieben, welcher in geschweifte Klammern gesetzt wird. Aber wozu dieser ganze Aufwand? Inlinefunktionen sind etwas ganz besonderes, da der Präprozessor diese 1 zu 1 an die entsprechenden Stellen des Quelltextes kopiert. Der Funktionsaufruf wird also mit dem Inhalt der Inlinefunktion ersetzt und die Funktion selbst, taucht im resultierenden Programm nicht mehr auf. Der Quelltext, welcher dann tatsächlich im Hintergrund ausgeführt wird, sieht wie folgt aus.

 1
 2
 3
 4
 5
 6
					
// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char* argv) {
	printf("%i\n", 5 + 3);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Warum braucht man so etwas? Nun, Funktionen sind immer gut, um seinen Quelltext zu strukturieren bzw. häufig verwendete Sachen, auslagern zu können. Sie erleichtern somit das Leben des Programmierers. Allerdings hat die ganze Sache einen Haken. Jeder Funktionsaufruf kostet wie bereits besprochen Zeit und benötigt zusätzlichen Speicherplatz. Inlinefunktionen wirken dieser ganzen Sache entgegen, weil sie keine richtigen Funktionen sind. Nur in dem Fall, dass der Präprozessor der Meinung ist, dass der Inhalt der Funktion doch zu groß ist bzw. sich der Inhalt nicht einfach an die entsprechende Stelle einfügen lässt, macht er tatsächlich eine normale Funktion daraus. Sie als Programmierer haben also keinen richtigen Einfluss darauf. Manchmal werden auch Funktionen als inline behandelt, bei welchen Sie gar nicht dieses Schlüsselwort geschrieben haben.

Zum Seitenanfang
Zum Inhaltsverzeichnis

7.4 Übungsaufgaben V

7.4 Übungsaufgaben V

  1. Erstellen Sie ein Dateipaar "Square.h" und "Square.cpp" und ein Dateipaar "Circle.h" und "Circle.cpp". Implementieren Sie eine Funktion mit einem sprechenden Namen für die Berechnung des Umfanges und eine weitere für die Berechnung des Flächeninhaltes. Beiden soll eine Gleitkommazahl, die Kantenlänge bzw. den Radius, übergeben werden und eine Gleitkommazahl, mit dem Ergebnis, zurückgeben. In diesen Funktionen, sollen weder Ein -, noch Ausgaben getätigt werden. Vergessen Sie nicht, in den Header-Dateien die Prototypen und Includewächter zu deklarieren.
  2. Erstellen Sie zudem noch jeweils 2 weitere Funktionen, welche sich um die Eingabe und um die Ausgabe kümmern. Benutzen sie einen geeigneten Parameter, um zwischen Umfang oder Flächeninhalt unterscheiden zu können. Benutzen Sie "defines" für auszugebende Texte und bauen Sie deren Deklarationen eine separate Header-Datei mit dem Namen "Strings.h".
  3. Erstellen Sie in der "main.cpp" eine Funktion mit den Namen "MainMenu", welche das Hauptmenü auf dem Bildschirm ausgibt und den Benutzer fragt, was er ausgerechnet haben will (Flächeninhalt oder Umfang von Kreis oder Quadrat). Definieren Sie für die Ausgaben entsprechende "defines", welche ebenfalls in der entsprechenden Header-Datei stehen sollen.
  4. Rufen Sie in der Funktion "main" das Hauptmenü auf und speichern Sie das Ergebnis der Funktion in einer geeigneten Variable. Bauen Sie anschließend noch eine Fallunterscheidung ein, in welcher Sie die entsprechenden Funktionen für die Eingaben, Berechnungen und Ausgaben aufrufen. Zudem soll die Auswahl und die Berechnungen mehrfach ausgeführt werden können, ohne das Programm ständig neu starten zu müssen. Überlegen Sie sich dafür einen geeigneten Mechanismus.
  5. Implementieren Sie ein weiteres Dateipaar "Faculty.h" und "Faculty.cpp". Erstellen Sie dort die vier Funktionen "FacultyInput", "FacultyIterative", "FacultyRecursive" und "FacultyPutput". Erstere soll die Fakultät rein iterativ (mit einer Schleife) berechnen (zur Erinnerung - 5! = 5 * 4 * 3 * 2 * 1). Die zweite Funktion soll, wie der Name schon verrät, diese Berechnung rekursiv durchführen (5! = 5 * 4!). Damit soll gezeigt werden, dass sich Funktionen auch durchaus selbst aufrufen können.
  6. Erweitern Sie nun das Hauptmenü und die "main", um auch diese Berechnungen durchführen zu können.
  7. Bauen Sie nun das ganze Programm so um, dass man mit einem entsprechendem "define", zwischen deutsch und englisch umschalten kann. Natürlich müssen dazu alle "defines" doppelt vorkommen und übersetzt werden. Hierbei kommt es nicht auf ein perfektes englisch an.
Zum Seitenanfang
Zum Inhaltsverzeichnis

7.2 Beispiel

7.2 Beispiel

Ich benutze für dieses Beispiel weiterhin die Datei "beispiel.cpp" die ich eben erklärt habe. Allerdings möchte ich, die Header-Datei so absichern, dass sie niemals zweimal eingebunden wird.

beispiel.h
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
								
// Nur wenn noch nicht geschrieben wurde "Ich bin da"
#ifndef BEISPIEL_H
	// Schreie "Ich bin da"
	#define BEISPIEL_H
	
	// Erste Überschrift
	void Ausgabe(int iWert);

	// Zweite Überschrift
	int Addiere(int iSummand1, iSummand2);

	// Dritte Überschrift
	void Tausche(int &iZahl1, int &iZahl2);
#endif
								
main.cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
								
#include <stdio.h>
#include "beispiel.h"



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc char** argv) {
	int iWert1;
	int iWert2;

	printf("Bitte irgendeine Zahl eingeben:  ");
	scanf("%i", iWert1);

	printf("Bitte eine andere Zahl eingeben: ");
	scanf("%i", iWert1);
	
	Ausgabe(Addiere(iWert1, iWert2));
	
	Tausche(iWert1, iWert2);
	Ausgabe(iWert1);
	Ausgabe(iWert2);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
								

Ausgabe:

Bitte irgendeine Zahl eingeben:  5
Bitte eine andere Zahl eingeben: 8
13
8
5
		

Wie Sie in der Header-Datei sehen können, habe ich "defines" und "ifndef" benutzt, damit es durch Verschachtelungen nicht vorkommen kann, dass ein Header mehrfach eingebunden wird, was zur Folge hätte, dass der Linker nicht mehr weiß, welcher Funktionsprototyp zu welcher Funktionsimplementierung gehört.

Um dies zu verdeutlichen, habe ich mir mal ein komplexeres Beispiel ausgedacht.

Struktur eines größeren Projektes

Die roten Pfeile stellen die tatsächlichen "includes" dar, während die gelben zeigen, was indirekt mit eingebunden wird.

Warum muss man jetzt also die Header vor einem doppelten Import schützen? Nun, die "main.cpp" braucht die "Volumen.h" und die wiederum die "MathematischenKonstanten.h" und jene die "Textbausteine.h". Nun braucht die "main.cpp" aber auch die "Oberflaechen.h", die ihrerseits auch die "MathematischenKonstanten.h" usw. braucht. Dadurch würde in der "main.cpp" zweimal die mathematischen Konstanten und zweimal die Textbausteine stehen. Sie können aber auch nicht einfach erst die "volumen.h" importieren und dann in der "Oberflaechen.h" einfach auf die "includes" verzichten. Theoretisch würde das zwar in diesem Spezialfall klappen, aber dann dürfte niemand die Reihenfolge der "includes" ändern und Sie könnten die Funktionalitäten der Oberflächenberechnungen in keinem anderen Projekt verwenden, ohne dass Sie Umbauten vornehmen müssten (die entsprechenden "includes" müssten wieder aufgenommen werden).

Sie sehen also, dass es klug ist, sich solche s.g. Includewächter mit "ifndef" zu bauen, da man sich dann um keine Konflikte bzw. Dopplungen mehr Sorgen machen muss. Zudem ist es ratsam, sich die Struktur eines Projektes so aufzuzeichnen, wie ich das oben gemacht habe. Dann sieht man, dass man ggf. das eine oder andere "include" gar nicht benötigt (überflüssige "includes" wurden in der oberen Grafik auskommentiert).

Zum Seitenanfang
Zum Inhaltsverzeichnis

7.1 Aufbau eines Headers

7.1 Aufbau eines Headers

beispiel.h
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
								
// Dies ist die einfachste Version einer Header-Datei.
// Bei dem unten stehenden Beispiel werde ich
// diesen #ifndef Kram mit rein nehmen

// Erste Überschrift
void Ausgabe(int iWert);

// Zweite Überschrift
int Addiere(int iSummand1, iSummand2);

// Dritte Überschrift
void Tausche(int &iZahl1, int &iZahl2);
							
beispiel.cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
							
#include <stdio.h>
#include "beispiel.h"



// Erstens und Text dazu //////////////////////////////////////////////////////
void Ausgabe(int iWert) {
	printf("%i", iWert);
	printf("\n");
} // Ausgabe //////////////////////////////////////////////////////////////////



// Zweitens und Text dazu /////////////////////////////////////////////////////
int Addiere(int iSummand1, iSummand2) {
	return iSummand1 + iSummand2;
} // Addiere //////////////////////////////////////////////////////////////////



// Drittens und Text dazu /////////////////////////////////////////////////////
void Tausche(int &iZahl1, int &iZahl2) {
	int iTemp	= iZahl1;
	iZahl1		= iZahl2;
	iZahl2		= iTemp1;
} // Tausche //////////////////////////////////////////////////////////////////
								

Für die "main.cpp" wird in der Regel kein Header angelegt, da dies automatisch im Hintergrund geschieht, bzw. es nicht nötig sein sollte, da man Hilfsfunktionen in der Regel in anderen Dateien unterbringt und in der main nur das Nötigste rein gehört.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9 Variablen III

9 Variablen III

In diesem Kapitel geht es um s.g. abstrakte Datentypen und Sie sollten sich wirklich Zeit nehmen für diesen Abschnitt, da die ganze Thematik nicht ganz einfach ist. Ich hatte Ihnen bereits erzählt, dass eine Variable aus drei Teilen besteht, nämlich ihrer Speicheradresse, ihrem Namen und ihrem Wert.

Darstellung einer Variable

Genau dieses Konzept müssen Sie sich in diesem Kapitel immer genau vor Augen führen. Jede Wertangabe mit einem "0x" deutet auf eine hexadezimale Speicheradresse hin, wobei diese Werte in der Realität immer ein Vielfaches von vier bzw. acht und je nach System entweder 32 oder 64 Bit groß sind. Der Einfachheit halber, habe ich mich aber auf besser übersichtlichere Werte beschränkt, welche nicht mit "0x" beginnen, kein vielfaches sind und noch dazu kleiner. Allerdings benutze ich in den folgenden Grafiken immer Adressen mit Buchstaben, damit man schneller sieht, dass es sich hierbei nicht um einen normalen Wert handelt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.6 Übungsaufgaben VII

9.6 Übungsaufgaben VII

  1. Erstellen Sie eine Funktion "Create", welche keine Übergabeparameter besitzt und ein Zeiger auf ein zweidimensionales Array mit Zeigern auf Integer zurück gibt. Speichern Sie die Dimensionen global ab.
  2. Die Funktion "Create" soll den Benutzer auffordert, zwei positive Zahlen einzugeben. Anschließend soll damit ein dynamisches zweidimensionales Array erzeugen und zurückgeben werden.
  3. Erstellen Sie eine Funktion "Initialize", welche einen Zeiger auf das Array übergeben bekommt, welches die Funktion "Create" erzeugt hat.
  4. Die Funktion "Initialize" soll das Array befüllen, wobei der Wert das Produkt aus Zeilen und Spaltenwert sein soll.
  5. Erstellen Sie eine Funktion "GetInnerArray", welches anhand eines Indexwertes, ein Pointer auf ein Unterarray des zweidimensionalen Arrays zurückgibt. Überlegen Sie selbst, welche Parameter sie benötigen und was sinnvoll wäre, wenn ein ungültiger Index angegeben wurde.
  6. Erstellen Sie eine Funktion "OutputInnerArray", welches ein übergebenes Unterarray ausgibt.
  7. Erstellen Sie eine Funktion "Finalize", welche das zweidimensionale Array sauber wieder freigibt.
  8. Erstellen Sie eine "main" Funktion, welche die erstellten Funktionen aufruft und implementieren Sie ggf. zusätzliche Ein- bzw. Ausgaben.
  9. Optimieren Sie Ihren Quelltext so, dass Sie zum einen gezielte Typdefinitionen zur besseren Verständlichkeit einsetzen und zum anderen Referenzen als Übergabeparameter einsetzen. Versuchen Sie auch wenn möglich das Schlüsselwort "const" bei Funktionsargumenten zu verwenden, bei denen es sich anbietet.
Zum Seitenanfang
Zum Inhaltsverzeichnis

9.3 Dynamische Felder

9.3 Dynamische Felder

Mit den bisherigen Mitteln war es nur möglich, zur Entwicklungszeit die Größe von Feldern statisch festzusetzen. Nun möchte ich Ihnen zeigen, wie ein solcher Datentyp dynamisch, zur Laufzeit verwaltet werden kann. Dynamische Felder lassen sich nicht in normalen Variablen speichern. Hierfür benötigt man Zeiger auf einen beliebigen Datentyp, nur dass man noch eine Anzahl mit angibt. Intern werden dann entsprechende Variablen im Speicher, unmittelbar nebeneinander, reserviert. Das Anlegen eines Zeigers auf einen Integer, unterscheidet sich also nicht von einem dynamischen Array mit einem einzigen Integer.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.3.2 Die Funktion realloc()

9.3.2 Die Funktion realloc()

Mit dieser Funktion ist es möglich, den Speicherbedarf einer Variable oder besser gesagt den Bereich, worauf ein Pointer zeigt, zu verändern. Dabei wird aber im seltensten Fall der Bereich einfach größer gemacht. Meistens wird ein ganz neuer Bereich im RAM reserviert, der alte Inhalt dorthin verschoben und der alte Bereich freigegeben. Das scheint wie eben bereits erwähnt, ziemlich Zeitaufwendig zu sein, aber ganz so schlimm ist dies auch nicht, da intern viel bessere Mechanismen benutzt werden, als ,am das von Hand hinbekommen würde.

Das Schema sieht so aus:
<Zeigervariable> = (<VariablenTypeDesZeigers>)realloc(<Zeigervariable>, <Größe>)

Im folgenden Beispiel sehen Sie, wie ich zunächst ein Array anlege und mich später dafür entscheide, es doch größer haben zu wollen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
					
// Ein Array mit Platz für drei Integer
int* pArray	= (int*)malloc(3 * sizeof(int));
pArray[0]	= 10;
pArray[1]	= 20;
pArray[2]	= 30;

// Array vergrößern, da noch ein vierter Integer rein muss
pArray		= (int*)realloc(pArray, 4 * sizeof(int));
pArray[3]	= 40;

printf("%i %i %i %i\n", pArray[0], pArray[1], pArray[2], pArray[3]);

free(pArray);
					

Ausgabe:

4
8.7
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

9.3.1 Unveränderliche dynamische Felder

9.3.1 Unveränderliche dynamische Felder

Als erstes möchte ich Ihnen zeigen, wie man schnell und einfach dynamische Arrays mit "new" anlegt, mit ihnen arbeitet und anschließend mit "delete" wieder freigibt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
					
int iAnzahl;

printf("Bitte geben Sie an, wie viele Floats im Array sein sollen: ");
scanf("%i", &iAnzahl);
printf("\n");

// Wenn ein ungültiger Bereich angegeben wurde
if (iAnzahl < 1) {
	iAnzahl			= 1;
} // end of if

// Pointer auf ein dynamisches Array mit Floats anlegen
float* pArrayOfFloats		= new float[iAnzahl];

// Durchlaufe die Elemente im Array
for (int iCount=0; iCount<iAnzahl; iCount++) {
	float fEingabe;

	printf("Bitte ein float eingeben: ");
	scanf("%f", &fEingabe);

	pArrayOfFloats[iCount]	= fEingabe;
} // end of for

printf("\n");

// Durchlaufe die Elemente im Array
for (int iCount=0; iCount<iAnzahl; iCount++) {
	printf("[%i] = %g\n", iCount, pArrayOfFloats[iCount]);
} // end of for

// Reservierten Speicher wieder freigeben
delete [] pArrayOfFloats;
					

Ausgabe:

Bitte geben Sie an, wie viele Floats im Array sein sollen: 3

Bitte ein float eingeben: 1.2
Bitte ein float eingeben: 2.3
Bitte ein float eingeben: 3.4

[0] = 1.2
[1] = 2.3
[2] = 3.4
		

Allerdings ergibt sich jetzt hier bereits eines der erwähnten Nachteile von "new" und "delete". Man kann im Nachhinein keine weiteren Elemente hinzufügen bzw. entfernen. Um dies zu realisieren, müsste man den Inhalt des gesamten Feldes irgendwo zwischenspeichern, dass original mit "delete" freigeben und mit "new" und einer neuen Größenangabe neu anlegen. Dieser Mechanismus ist nicht nur umständlich, sondern auch Zeitaufwändig.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.5 Vereinfachter Umgang mit Pointern

9.5 Vereinfachter Umgang mit Pointern

Wie Sie gesehen haben, ist der Umgang mit Pointern wirklich etwas gewöhnungsbedürftig, zumal man ständig mit diesen Sternen arbeiten muss und man immer überlegen muss, wie viele man braucht. In diesem Teilkapitel soll es darum gehen, wie man sich das alles vereinfachen kann.

Folgende Tabelle soll noch einmal zeigen, wie man sich merken kann, wann man wie viele Sterne braucht.

Deklarierter Datentyp Dereferenzierter Datentyp Resultierender Datentyp
Typ*** pppVariable *pppVariable Typ** ppVariable
Typ** ppVariable *ppVariable Typ* pVariable
Typ* pVariable *pVariable Typ tVariable
Typ*** pppVariable **pppVariable Typ* pVariable
Typ** ppVariable **ppVariable Typ tVariable
Typ*** pppVariable ***pppVariable Typ tVariable

Als Faustregel gilt also, mit jedem Stern vor einem Variablenname, resultiert der Datentyp mit einem Stern weniger.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.5.2 Vereinfachter Funktionsaufruf

9.5.2 Vereinfachter Funktionsaufruf

Ich habe bisher mehrfach erwähnt, dass bei einem Funktionsaufruf mehrere Sachen im Hintergrund passieren und auch was dies für den Speicherbedarf bedeutet. Gerade wenn man viele Funktionsparameter benötigt, wird der Stack schnell sehr voll. Hier kommen Pointer ins Spiel. Schauen Sie sich folgenden Quelltext an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
					
// Zeichnet ein Rechteck //////////////////////////////////////////////////////
void Draw(int iWidth, int iHeight, int iPosLeft, int iPosTop) {
	//...
} // Draw /////////////////////////////////////////////////////////////////////

//...

int iWidth		= 200;
int iHeight		= 10;
int iPosLeft		= 10;
int iPosTop;

// 10 Rechtecke erzeugen
for (int iCount = 0; iCount < 10; ++iCount) {
	iPosTop		= (iCount + 1) * 20;
	Draw(iWidth, iHeight, iPosLeft, iPosTop);
} // end of for
					

Im Grunde sollen hier nur 10 Rechtecke erzeugt und gezeichnet werden. Wie das eigentliche Zeichnen aussieht, spielt hier keine Rolle, aber schauen wir doch mal an, was auf dem Stack passiert.

Funktionsaufruf mit Primitivtypen

Wie Sie sich bestimmt denken konnten, werden also ständig viele Werte auf den Stack gepackt und wieder abgeräumt, nur um sie dann wieder darauf zu packen und wieder abzur/auml;umen. Hier ist es also sinnvoller, sich für viele Parameter eine Struktur zu bauen und anschließend nur noch einen Pointer darauf zu übergeben.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
					
struct SRectangle {
	int iWidth;
	int iHeight;
	int iPosLeft;
	int iPosTop;
};

typedef SRectangle* PRectangle;



// Zeichnet ein Rechteck //////////////////////////////////////////////////////
void Draw(PRectangle& pArea) {
	//...
} // Draw /////////////////////////////////////////////////////////////////////

// ...

PRectangle	pRect	= new SRectangle;
pRect->iWidth	= 200;
pRect->iHeight	= 10;
pRect->iPosLeft	= 10;

// 10 Rechtecke erzeugen
for (int iCount = 0; iCount < 10; ++iCount) {
	pRect->iPosTop	= (iCount + 1) * 20;
	Draw(pRect);
} // end of for

delete pRect;
					

Schauen wir uns zunächst auch hier an, was im Speicher passiert.

Funktionsaufruf mit referenziertem Pointer auf Struktur

Wenn Sie sich das jetzt anschauen, sehen Sie, dass wieder ständig etwas auf den Stack gepackt wird. Allerdings ist es jetzt nur noch die Rücksprungadresse. Der Pointer selbst benötigt keinen zusätzlichen Platz, da wir ihn ja als Referenz übergeben und die eigentlichen Nutzdaten liegen im Heap. Somit wird der Funktionsaufruf wesentlich schneller.

Wenn Sie jetzt noch ein wenig weiter denken, fragen Sie sich bestimmt, warum man nicht einfach die ganzen Nutzdaten als Referenz übergibt? Das hätte doch den gleichen Effekt? Nun, bei erster Betrachtung stimmt dies, aber mit diesem Lösungsweg haben wir wesentlich mehr Vorteile.

Was passiert, wenn wir irgendwann noch die Rahmen- und Hintergrundfarbe mit übergeben wollten oder evtl. noch die Art des Stiftes, mit welchem das Rechteck gezeichnet werden soll? Der Funktionskopf würde sich nicht nur weiter Aufblähen und noch unübersichtlicher werden, nein, man müsste ggf. noch den Funktionsprototypen ändern und noch jede Stelle im Code, an welcher diese Funktion aufgerufen wird (wenn man nicht gerade Standardparameter festgelegt hat). Mit der Vorgeschlagenen Lösung ist man da viel flexibler, da man lediglich die Struktur und das Erzeugen erweitern müsste.

Eine Frage bleibt aber noch offen. Wann übergibt man Pointer und wann Referenzen? Nun das ist eigentlich ganz einfach zu beantworten. Bedenken Sie, dass Pointer auch auf nichts sinnvolles zeigen können. Wenn Sie also einer Funktion einen puren Pointer übergeben, müssen Sie immer damit rechnen, dass "NULL" übergeben wird. Dem zur Folge müssen Sie in der Funktion erst prüfen, ob es sich um einen gültigen Pointer handelt. Wenn Sie allerdings eine Referenz übergeben, schließen Sie diesen Fall aus und benötigen in der Funktion keine extra Abfrage. Sie müssen also immer überlegen, ob es tatsächlich gewollt passieren kann (beispielsweise, wenn Sie den Inhalt noch erzeugen wollen, wenn "NULL" übergeben wird), dass ein ungültiger Zeiger übergeben wird oder nicht. Wenn dies Ihrer Meinung nach nie passieren kann/darf, benutzen Sie Referenzen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.5.1 Vereinfachung durch Typdefinitionen

9.5.1 Vereinfachung durch Typdefinitionen

In C hat man die Möglichkeit, sich neue Typen zu erzeugen bzw. für Bestehende einen Aliasnamen zu vergeben. Damit erspart man sich eine komplizierte Schreibweise

Das Schema sie so aus: typedef <ZuErsetzenderTyp> <ZuErsetzenderTyp>;

Angenommen man hat ein Array mit Pointern auf Integer, dann benötigt man folgende Schreibweise.

 1
 2
 3
					
int*	aArray[];
// oder
int**	aArray;
					

Gerade letztere kann Verwirrung stiften und deswegen gibt es von mir an dieser Stelle die Empfehlung, dies durch geeignete Typdefinitionen zu verschönern.

 1
 2
					
typedef	int*		PInteger;	// Pointer auf ein Integer
typedef	PInteger*	AIntArray;	// Array mit Pointern auf Integer
					

Wichtig ist, dass bei einer Typdefinition keine eckigen Klammern benutzt werden können, aber was bringt einem das jetzt genau? Schauen Sie sich dafür folgende zwei Funktionen und deren Aufruf an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
					
// Variante 1 /////////////////////////////////////////////////////////////////
int** CreateArray1(int iLength) {
	int** pResult		= new int*[iLength];

	// Alle Integer erzeugen
	for (int iCount = 0; iCount < iLength; ++iCount) {
		pResult[iCount]	= new int;
	} // end of for

	return pResult;
} // CreateArray1 ///////////////////////////////////////////////////////////////



// Variante 2 /////////////////////////////////////////////////////////////////
AIntArray CreateArray2(int iLength) {
	AIntArray pResult	= new PInteger[iLength];

	// Alle Integer erzeugen
	for (int iCount = 0; iCount < iLength; ++iCount) {
		pResult[iCount]	= new int;
	} // end of for

	return pResult;
} // CreateArray2 ///////////////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	int**		pArray1	= CreateArray1(10);	// Variante 1
	AIntArray	pArray2	= CreateArray2(10);	// Variante 2

	delete [] pArray1;
	delete [] pArray2;
	
	return 0;
} // main ///////////////////////////////////////////////////////////////////////
					

Wie Sie sehen können, ist die zweite Variante wesentlich besser lesbar und es wurde kein Stern mehr benutzt. Zudem kann es keine Verwirrung mehr darüber geben, ob man ein Array mit Pointern oder ein Pointer auf einen Pointer hat. Beim Kompilieren wandelt der Präprozessor alle Typdefinitionen um und es resultiert der gleiche Code. Es passiert bei beiden Varianten also haargenau das gleiche. Setzen Sie also ruhig des Öfteren Typdefinitionen ein, um sich den Umgang zu erleichtern. Achten Sie aber darauf, dass die von Ihnen vergebenen Aliasnamen aussagekräftig sind.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2 Komplexe Zeigerstrukturen

9.2 Komplexe Zeigerstrukturen

Achtung! Jetzt lege ich noch einen drauf und ich empfehle Ihnen, extrem gut hin zuschauen, damit Sie zum einen versteht was passiert und zum anderen auch mitbekommt, warum etwas so läuft und nicht anders. Falls Sie etwas nicht auf Anhieb verstehen, dann sollten Sie das letzte Unterkapitel noch einmal lesen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2.2 Die Funktionen malloc(), calloc() und free()

9.2.2 Die Funktionen malloc(), calloc() und free()

Wenn man Zeiger auf Werte erstellen möchte, ohne dass man vorher eine normale Variable definiert hat und dann auf jene verweist, muss man sich zunächst Speicher im RAM reservieren und zum Ende wieder freigeben. Allerdings ist diese eigenständige und dynamische Speicherverwaltung etwas Trickreich und man muss gut aufpassen, dass man alles wieder freigibt, was man reserviert hat, da sonst entsprechender Speicher nicht mehr benutzt werden kann, solange das Programm läuft. Wenn das Programm schließlich beendet wird, sorgt der s.g. Garbage Collector dafür, dass auch noch der Rest freigegeben wird, welchen man vergessen hat. An dieser Stelle sei aber erwähnt, dass dies erst seit Windows 2000 so ist. Dies ist u.a. ein Grund dafür, warum Windows 98 nie länger als 78 Stunden am Stück lief, da es irgendwelche Programme gab, die s.g. Speicherlecks verursachten. Irgendwann war der Speicher voll und der PC somit handlungsunfähig (andere Faktoren spielten allerdings auch eine Rolle).

Mit "malloc" kann man sich Speicher reservieren, wobei man angeben muss, wie viel Speicher man haben möchte. Zudem muss ggf. das Ergebnis von "malloc" gecastet werden, da manche Compiler oben erklärten "void" Pointer zurückgeben. Falls beim Reservieren des Speichers etwas schief läuft, gibt die Funktion "NULL" zurück.

Das Schema sieht so aus: <Zeigervariable> = (<VariablenTypeDesZeigers>)malloc(<Größe>)

 1
 2
 3
 4
 5
 6
 9
10
11
12
13
14
15
16
					
// Zeiger auf einen float vorbereiten
float* pZeigerAufFloat;

// Speicher für eine Float-Variable reservieren
pZeigerAufFloat	= (float*)malloc(sizeof(float));

// Wenn das Reservieren des Speichers funktioniert hat (ganz paranoid)
if (pZeigerAufFloat != NULL) {
	// Reservierung hat funktioniert
} else {
	// Reservierung ist fehlgeschlagen, weil z.B. der RAM voll ist
} // end of if

// ...
					

Alternativ zur Funktion "malloc" kann man auch "calloc" benutzen, wobei sich ein paar kleine Vorteile ergeben. Die Funktion calloc benötigt zwar eine zusätzliche Variable, welche die Anzahl der zu reservierenden Elemente angibt, aber (gerade bei Arrays interessant) zudem werden alle Elemente mit dem Wert 0 initialisiert.

Das Schema sieht so aus:
<Zeigervariable> = (<VariablenTypeDesZeigers>)calloc(<AnzahlDerFelder>, <Größe>)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
// Zeiger auf einen float vorbereiten
float* pZeigerAufFloat;

// Speicher für eine Float-Variable reservieren
pZeigerAufFloat	= (float*)calloc(1, sizeof(float));

// Wenn das Reservieren des Speichers funktioniert hat (ganz paranoid)
if (pZeigerAufFloat != NULL) {
	// Reservierung hat funktioniert
	// der Wert, worauf pZeigerAufFloat zeigt, ist 0
} else {
	// Reservierung ist fehlgeschlagen, weil z.B. der RAM voll ist
} // end of if

// ...
					

Wie bereits erwähnt, muss man reservierten Speicher auf wieder freigeben und dafür gibt es die Funktion "free". Sie übergeben einfach den Zeiger des Elementes, dessen Speicher gelöscht werden soll.

Das Schema sieht so aus: free(<Zeigervariable>)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
// Zeiger auf einen float vorbereiten
float* pZeigerAufFloat;

// Speicher für eine Float-Variable reservieren
pZeigerAufFloat	= (float*)calloc(1, sizeof(float));

// Nur wenn der Speicher auch reserviert wurde
if (pZeigerAufFloat != NULL) {
	free(pZeigerAufFloat)
} // end of if
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2.5 Das komplexe Beispiel

9.2.5 Das komplexe Beispiel

Normalerweise beginnt man mit der main Funktion, aber die spielt in diesem Beispiel eine untergeordnete Rolle, von daher werde ich den Quelltext von oben nach unten erklären. Damit Sie wissen, um was es geht, habe ich eine Grafik erstellt, die zum einen zeigt wie es im Arbeitsspeicher aussehen könnte und zum anderen, wie die Zusammenhänge sind.

Veranschaulichung des folgenden Beispieles

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
					
#include <stdio.h>

struct SPunkt3D {
	int iX;			// X - Koordinate
	int iY;			// Y - Koordinate
	int iZ;			// Z - Koordinate
};

struct SDreieck3D {
	SPunkt3D* pPunktA;	// Zeigt auf einen SPunkt3D
	SPunkt3D* pPunktB;	// Zeigt auf einen SPunkt3D
	SPunkt3D* pPunktC;	// Zeigt auf einen SPunkt3D
};
					

Ich denke mal, dass bis hierher noch alles klar sein sollte was passiert. Wenn nicht, dann schlage ich vor, noch einmal Kapitel 8.4 und 9.1 zu lesen.

 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
					
// Kopiert eine SDreieck3D mit all seinen SPunkt3D //////////////////////////(())
// und deren Koordinaten in eine komplett neues SDreieck3D
SDreieck3D* Dreieck3DDeepCopy(SDreieck3D* pSource) {
	// Neues "leeres" Dreieck erzeugen
	SDreieck3D*	pTarget	= new SDreieck3D;



	// "leere" Punkte für das Dreieck erzeugen ///////////////////////////

	// Variante 1
	pTarget->pPunktA	= new SPunkt3D;

	// Variante 2
	(*pTarget).pPunktB	= new SPunkt3D;
	(*pTarget).pPunktC	= new SPunkt3D;



	// Werte Kopieren /////////////////////////////////////////////////////

	// Variante 1
	*(pTarget->pPunktA)	= *(pSource->pPunktA);
	
	// Variante 2
	*((*pTarget).pPunktB)	= *(*pSource).pPunktB;
	
	// Variante 3
	pTarget->pPunktC->iX	= pSource->pPunktC->iX;
	pTarget->pPunktC->iY	= (*pSource->pPunktC).iY;
	pTarget->pPunktC->iZ	= (*(*pSource).pPunktC).iZ;



	return pTarget;
} // Dreieck3DDeepCopy ////////////////////////////////////////////////////////
					

In Dieser Funktion wird diese Dreiecksstruktur mit samt ihren Inhalt in eine neue Variable kopiert und zurückgegeben. Der Einfachheit halber arbeite ich in diesem Beispiel nur mit "new" und "delete". Die fett gedruckten Zeilen sind die Zeilen, welche am einfachsten verständlich sind und deren Stiel Sie sich angewöhnen sollten.

Die Zeile 20 sollte klar sein. Interessanter ist schon Zeile 27 und 31 bzw. 32. Hier werden die einzelnen Punkte des Dreiecks angelegt bzw. es wird Speicherplatz für sie reserviert. Ich habe hier absichtlich zwei verschiedene Schreibweisen gewählt, damit Sie sehen, dass sie äquivalent sind.

Die Zeilen 39 und 42 sind wieder äquivalent. Man ließt dies folgendermaßen: "Die Struktur, die sich dort befindet, worauf "PunktA" zeigt, die zu dem Dreieck gehört, worauf "pSource" zeigt, wird dort hin kopiert, wo "PunktA" hin zeigt, welcher zu dem Dreieck gehört, worauf "pTarget" zeigt".

Alternativ dazu könnte man auch, wie in Zeile 45 bis 47, jeden einzelnen Wert per Hand kopieren. Dies ist zwar einfacher nachzuvollziehen, aber spätestens wenn man dies für eine Struktur mit 100 Elementen machen möchte/muss, wird man davon Abstand nehmen. Auf der rechten Seite des "=" habe ich wieder drei verschiedene Schreibweisen verwendet, welche zwar alle äquivalent sind, sich aber mehr oder weniger gut handhaben bzw. lesen lassen.

 52
 53
 54
 58
 59
 60
 61
 62
 63
					
// Gibt die Koordinaten eines SPunkt3D aus ////////////////////////////////////
void PrintPunkt3D(SPunkt3D* pPunkt) {
	// Variante 1
	printf("%i ", pPunkt->iX);

	// Variante 2
	printf("%i ", (*pPunkt).iY);
	printf("%i\n",(*pPunkt).iZ);
} // PrintPunkt3D /////////////////////////////////////////////////////////////
					

Auch hier sehen Sie wieder wunderschön, die zwei Schreibweisen mit dem "*" und dem "->" und wieder habe ich die leichtere Variante fett hervorgehoben.

 67
 68
 69
 70
 71
 72
 73
 74
 75
					
// Gibt ein SDreieck3D mit all seinen SPunkt3D aus ////////////////////////////
void PrintDreieck3D(SDreieck3D* pDreieck) {
	// Variante 1
	PrintPunkt3D(pDreieck->pPunktA);

	// Variante 2
	PrintPunkt3D((*pDreieck).pPunktB);
	PrintPunkt3D((*pDreieck).pPunktC);
} // PrintDreieck3D ///////////////////////////////////////////////////////////
					

In dieser Funktion möchte ich schon einmal einen kleinen Hinweis dafür geben, wie man ein objektorientiertes Programm aufbaut. Statt eine Ausgabefunktion zu haben, welche sich durch jede der Strukturen hangelt und die einzelnen Elemente ausgibt, verlagert man das Problem auf untergeordnete Funktionen (später auf untergeordnete Objektmethoden). Vereinfacht ausgedrückt heißt das, wenn ich von allen Studenten ein Foto machen soll, frag ich lieber die Studenten ob sie mir ein Bild von sich geben können.

 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
					
// Hauptfunktion des Programms ////////////////////////////////////////////////
int main(int argc, char** argv) {
	// Erzeugen der abstrakten Struktur
	SDreieck3D* pDreieck1	= new SDreieck3D;

	pDreieck1->pPunktA	= new SPunkt3D;
	pDreieck1->pPunktB	= new SPunkt3D;
	pDreieck1->pPunktC	= new SPunkt3D;



	// Befüllen mit Werten

	pDreieck1->pPunktA->iX	= 1;
	pDreieck1->pPunktA->iY	= 2;
	pDreieck1->pPunktA->iZ	= 3;

	pDreieck1->pPunktB->iX	= 4;
	pDreieck1->pPunktB->iY	= 5;
	pDreieck1->pPunktB->iZ	= 6;

	pDreieck1->pPunktC->iX	= 7;
	pDreieck1->pPunktC->iY	= 8;
	pDreieck1->pPunktC->iZ	= 9;



	// Ausgabe

	PrintDreieck3D(pDreieck1);
	printf("\n");

	SDreieck3D* pDreieck2;
	pDreieck2		= Dreieck3DDeepCopy(pDreieck1);

	PrintDreieck3D(pDreieck2);



	// Freigeben des Speichers ////////////////////////////////////////////

	// Variante 1
	delete pDreieck1;
	pDreieck1		= NULL;

	// Variante 2
	delete pDreieck2->pPunktA;
	(*pDreieck2).pPunktA	= NULL;
	
	delete (*pDreieck2).pPunktB;
	(*pDreieck2).pPunktB	= NULL;
	
	delete (*pDreieck2).pPunktC;
	(*pDreieck2).pPunktC	= NULL;

	delete pDreieck2;
	pDreieck2		= NULL;
	
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

In den Zeilen 82 bis 86 lege ich zunächst das Dreieck an und dann jeweils die Punkte. Zu beachten ist, dass mit der Anlage des Dreiecks noch lange nicht die Punkte existieren, wohl aber die Zeiger (welche vorerst irgendwohin zeigen). In Zeile 92 bis 102 weiße ich den Punkten, mehr oder weniger sinnvolle Werte (Koordinaten) zu, denn mit der Anlage der Punkte existierten zwar bereits die Struktur, allerdings waren auch ihre Werte nicht definiert.

In den Zeilen 108 - 114 wird zuerst das erste Dreieck ausgegeben. Anschließend wird es kopiert und abschließend wird die Kopie zur Kontrolle ausgegeben.

Zuletzt müssen die Dreiecke wieder gelöscht werden. Am einfachsten geht dies wie in Zeile 121. Anschließend sollten Sie, der Form halber, den Zeiger auf "NULL" setzen, da er nun auf einen Bereich zeigt, der ihm nicht mehr gehört. Extrem vorteilhaft an dieser Variante ist, dass das "delete" sehr intelligent ist und nicht nur das Dreieck wieder freigibt, sondern auch die drei Punktstrukturen, mit den Koordinaten, auf welche die Zeiger in der Dreiecksstruktur zeigen. Möchte man, wie in Variante 2, nur einzelne Punkte löschen, weil man die anderen evtl. noch benötigt, ist es extrem wichtig die entsprechenden Zeiger nach dem "delete" auf "NULL" zu setzen. Würde man dies versäumen, gäbe es bei der Freigabe des Dreiecks einen Absturz, da der Zeiger des Punktes ja noch irgendwo hin zeigt, was ihm nicht gehört. Das Programm würde dann versuchen genau diesen Bereich freizugeben und dabei käme es zu einer Speicherzugriffsverletzung. Am Hässlichsten daran ist, dass Sie z.B. ganz weit Oben im Quelltext schon vergessen haben könntet, diesen Zeiger auf "NULL" zu setzen und der Fehler tritt erst bei Programmende auf, was einem nahe legt, den Fehler am Ende des Programms zu suchen, was aber vergeblich wäre.

Achtung! Wenn Sie mit den Funktionen "malloc" und "calloc" arbeitet, müssen Sie die einzelnen Elemente mit "free" freigeben wie in Variante 2. Die Funktion "free" besitzt diesbezüglich keinen Automatismus.

1 2 3
4 5 6
7 8 9

1 2 3
4 5 6
7 8 9
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2.1 VOID-Pointer

9.2.1 VOID-Pointer

Zeiger auf "void" sind s.g. Dummy-Zeiger. Sie dient dazu, gerade in der objektorientierten Programmierung, Zeiger auszutauschen, welche auf unterschiedliche Datentypen zeigen können, die erst zur Laufzeit festgelegt sind. So kann man beispielsweise universelle Sortierfunktionen schreiben, die für jede Art von Datentypen funktionieren. Hier mal ein kleines Anwendungsbeispiel, in welchem ich drei Pointer auf unterschiedliche Variablentypen definiere und sie dann in ein Array aus "void" Pointern packe. Somit ist es mir quasi möglich, verschiedene Variablentypen in einem Array abzulegen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
int	iWert			= 4;
int*	pZeigerAufInt		= &iWert;

float	fWert			= 8.2f;
float*	pZeigerAufFloat		= &fWert;

char	cZeichen		= 'c';
char*	pZeigerAufChar		= &cZeichen;

void*	aVoidArray[3];
aVoidArray[0]			= pZeigerAufInt;
aVoidArray[1]			= pZeigerAufFloat;
aVoidArray[2]			= pZeigerAufChar;

printf(	"%i\t%g\t%c\n",
	*(int*)aVoidArray[0],
	*(float*)aVoidArray[1],
	*(char*)aVoidArray[2]);
					

Ausgabe:

4	8.2	c
		

Bis einschließlich Zeile 8 sollte alles klar sein. In Zeile 10 erzeuge ich jetzt ein Array mit drei Pointern auf "void". Anschließend Speichere ich die Adressen der Zeiger im Array ab, wobei ich hier keine Typumwandlung vornehmen muss, was eine Besonderheit ist (ansonsten muss man immer diese Konvertierung vornehmen - auch bei Zeigern). In den Zeilen 15 bis 18 gebe ich dann den Inhalt des Arrays aus, wobei hier die Typumwandlung notwendig ist. Allgemein rate ich dringend davon ab, in einem Array, mehrere verschiedene Typen zu halten, wie ich dies gerade demonstriert habe, da man keine Kontrolle bzw. Information darüber hat, was wo gespeichert ist.

Zuletzt sei noch erwähnt, dass man "void" Pointer nicht dereferenzieren kann. Zudem kann man den Wert eines "void" Pointern, nicht ohne Typumwandlung an einem anderen Pointer übergeben, welcher selbst kein "void" Pointern ist (dies war ja in der Ausgabe von eben klar zu sehen).

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2.4 Der -> Operator

9.2.4 Der -> Operator

Dieser Operator wurde mit C++ neu eingeführt und soll dem Programmierer erheblich das Leben erleichtern. Man kann mit ihm ein Zeiger und eine Struktur bzw. Objekte miteinander verbinden ohne den "*" Operator und Klammern benutzen zu müssen.

Unter der Annahme, dass A ein Zeiger auf eine Struktur X ist, welche B enthält, so gilt (*a).b ist das gleiche wie a->b.

Ich denke, die Vorteile die sich daraus ergeben sind nicht zu übersehen, da wirklich eine Vereinfachung stattfindet. Zudem ist zu beachten, dass der Pfeil stark bindet.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.2.3 Die Präfixoperatoren new und delete

9.2.3 Die Präfixoperatoren new und delete

Wie im Kapitel 9.2.2 gezeigt, ist das Reservieren von Speicher etwas umständlich und gewöhnungsbedürftig. Eine Vereinfachung wird durch "new" und "delete" erreicht (erst ab C++), wobei man aber strengstens darauf achten muss, dass man alle Sachen, die mit "new" angelegt wurden, auch mit "delete" freigegeben werden. Ebenso müssen alle Sachen die mit "malloc" bzw. "calloc" erstellt wurden, auch mit "free" wieder freigegeben werden!

Das Schema sieht so aus: <Zeigervariable> = new <ReferenzierterVariablentyp>

 1
 2
 3
 4
					
// Speicher für einen Float reservieren
float* pZeigerAufFloat = new float;

delete pZeigerAufFloat;
					

Allerdings ergeben sich auch Nachteile bei der Verwendung dieser Präfixoperatoren, da "new" und "delete" eigentlich für Objekte gedacht sind. Einmal angelegter Speicher kann nicht mehr verändert werden. Hat man also flexible Typen, wie dynamische Arrays oder Listen, kann man diese zwei nicht verwenden bzw. nur sehr eingeschränkt, aber dazu später mehr.

An dieser Stelle möchte ich Ihnen noch einen kleinen Rat mit auf den Weg geben. Vermeiden Sie es, ständig "new" und "delete" zu verwenden. Dadurch wird nicht nur ihr Hauptspeicher fragmentiert, sondern ggf. hängen an diesen Operationen auch Auslagerungsaktionen mit daran. Unter Umständen kann es also wirklich lange dauern, bis der Speicher tatsächlich zur Verfügung steht. Deshalb ist es sinnvoll, alle Sachen wenn möglich bei Programmstart zu erzeugen und dann bei Programmende wieder freizugeben. Das Programm wird zwar dann zur ganzen Laufzeit mehr Speicher benötigen als vielleicht im Moment notwendig, aber dafür läuft es zwischen drin schneller, da die Speicherverwaltung des Betriebssystems nicht angeleiert werden muss und die Daten zudem meist hintereinander im Speicher liegen, was sehr günstig für den Cache und somit ihr Programm ist.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.1 Einführung in Zeiger

9.1 Einführung in Zeiger

Wie der Name schon verrät, handelt es sich bei Zeigern nicht im klassischen Sinne um Variablen die Werte beinhalten, sondern um Verweise, die auf Speicherbereiche zeigen, an welchen Werte stehen (ähnlich einer Verknüpfung bei auf eine Datei). Natürlich haben Zeiger oder auch Pointer genannt, auch Werte, da sie erst einmal ganz normale Variablen sind. Jene Werte repräsentieren aber nur eine Adresse im Speicher, an welcher man einen Wert eines bestimmten Typs findet. Der Verweis kann, muss aber nicht, auf eine Variable zeigen.

Gerade Neulinge tun sich schwer mit Pointern oder haben auch in gewisser Weise Angst vor ihnen. Ich möchte versuchen diese Einstellung zu ändern, denn Pointer sind das wichtigste bei der Programmierung und intern wird fast ausschließlich mit ihnen gearbeitet. Wann immer der Prozessor Daten benötigt, benutzt er eine Speicheradresse, welche dann an die entsprechenden Steuerwerke geschickt werden. Jene holen dann genau die Daten aus dem Arbeitsspeicher, welche an der gewünschten Adresse stehen. Auch der Stack benötigt einen s.g. Stackpointer, welcher auf die nächste freie Stelle zeigt. Wenn eine Funktion aufgerufen wird, steht im Bytecode auch nur eine Speicheradresse, an welche gesprungen werden soll.

Wenn Sie einen Brief schreiben, dann schreiben Sie auf den Briefumschlag auch eine Adresse, damit die Post weiß, wohin der Brief geschickt werden soll. Wenn Sie jemanden anrufen, dann benutzen Sie auch eine Art Adresse, nämlich die Telefonnummer. Selbst beim Surfen im Internet benutzen Sie Adressen. Sie sehen also, dass das Prinzip einer Adressierung für Sie nicht neu sein dürfte. Der Umgang mit Adressen beim Programmieren ist zwar zu Beginn gewöhnungsbedürftig, aber erlernbar.

Zeiger verweist auf Variable

Hier sehen Sie, dass die Zeigervariable "pZeiger", welche sich selbst bei Adresse "0xFFFF4824" befindet, auf die Variable "iWert" verweist. Deshalb entspricht auch der Wert von "pZeiger" der Speicheradresse von "iWert", also "0xFFFF4828". Stellen Sie sich einfach vor, "pZeiger" wäre ihr Telefon und die Speicheradresse die Telefonnummer ihres Telefons, also "FFFF4828". Jetzt wollen Sie "iWert" anrufen. Sie geben also die Speicheradresse bzw. die Telefonnummer von "iWert" an. Nach dem wählen besteht eine Verbindung zwischen Ihrem Telefon und des anderen. Wenn man über Ihr Telefon fragt, wie alt bist du, kann der andere beispielsweise "15" antworten. Man bekommt also über Ihr Telefon heraus, wie alt der andere ist und so kann man auch über einen Pointer den Wert einer anderen Variable in Erfahrung bringen.

Das Schema sieht so aus: <Variablentyp> *<Zeigername> [= <KonstanterAusdruck>]

Wenn man einen Zeiger mit "nichts" initialisieren will, dann weist man ihm den Wert "NULL" zu (ähnlich eines Telefons, was noch nicht angemeldet wurde). Diesen Wert sollten Sie Zeigern immer zuweisen, wenn sie momentan nicht gebraucht werden. So kann man besser prüfen, ob der Zeiger auf etwas Sinnvolles zeigt oder nicht. Der Wert "0" hingegen, repräsentiert eine gültige Speicheradresse und ist somit nicht geeignet für nicht benutzte Pointer.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.1.3 Einfache Beispiele an Bildern erklärt

9.1.3 Einfache Beispiele an Bildern erklärt

Die Figuren "iThomas" und "iTobias" sind normale Variablen und dadurch gekennzeichnet, dass sie ihren Wert in der Hand halten. "pNils" hingegen ist ein Zeiger und dadurch kenntlich gemacht, dass er nichts in der Hand hält, sondern auf etwas zeigt.

 1
 2
 3
					
int iThomas;
int *pNils	= NULL;
int iTobias	= 18;
					

Initialisierung der Variablen und des Zeigers

Hier wurden zunächst zwei normale Integervariablen "iThomas" und "iTobias" definiert, wobei "iTobias" auch gleich einen Wert bekommt. Der Wert von "iThomas" ist erst einmal undefiniert. In Zeile 2, wird jetzt ein Zeiger namens "pNils" erzeugt, welcher zunächst auf nichts zeigt, also "NULL". Die Blöcke neben jeder Figur, sollen ihre Speicheradressen repräsentieren.

 1
					
pNils = &iTobias;
					

Zeiger verweist jetzt auf eine Variable

Hier wurde dem Zeiger "pNils" die Adresse von "iTobias" zugewiesen. "pNils" zeigt von nun an auf "iTobias" und hat also als Wert die Speicheradresse von "iTobias".

 1
					
iThomas = *pNils;
					

Variable bekommt Wert durch Dereferenzierung eines Zeigers

Jetzt möchte "iThomas" gerne den Wert haben, auf welchen "pNils" zeigt. "pNils" fragt jetzt quasi "iTobias" "Was hast du für einen Wert "iTobias"?", worauf "iTobias" ihm ins Ohr flüstert: "18!". Diese Information gibt "pNils" nun an "iThomas" weiter und der neue Wert von "iThomas" ist 18.

 1
					
iThomas = (int)pNils;
					

Vektor aus Pointer Kopieren

Weil "iThomas" neugierig ist, wohin "pNils" zeigt, bzw. sich "iTobias" im Speicher aufhält, fragt er "pNils" "Sag mal, worauf zeigst du eigentlich die ganze Zeit?", worauf ihm "pNils" antwortet: "Na dahin wo "iTobias" im Speicher steht!". Also bekommt "iThomas" als Wert den Vektor von "pNils" und somit die Adresse von "iTobias".

ACHTUNG! "iThomas" ist jetzt kein Zeiger, auch wenn man ihn mit einem "*" benutzten würde (evtl. weißt der Compiler diese Zuweisung sogar ab, da die formalen Typen nicht übereinstimmen). Er hat die Adresse zwar als Wert gespeichert, aber man kann/darf trotzdem keine Dereferenzierung durchführen. Allerdings könnte der Wert von "iThomas" einem neuen Zeiger zugewiesen werden, welcher dann auch auf "iTobias" zeigen würde. Jener Wert könnte z.B. "pJakob" zugewiesen werden und somit würde er auch auf "iTobias" zeigen.

 1
					
*pNils = 7;
					

Wertzuweisung einer Variable über Dereferenzierung eines Zeigers

Hier sagt das Programm "pNils": "Ey du, gib mal deinem Kollegen den Wert 7!", worauf "pNils" folgendes in das Ohr von "iTobias" flüstert: "Nur damit du Bescheid weißt, wenn man dich fragt, dann antworte von jetzt an immer mit 7!".

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.1.2 Der & Operator

9.1.2 Der & Operator

Diesen Operator hatte ich bereits kurz angesprochen. Immer wenn vor einer Variable ein "&" Operator steht, wird nicht der Wert der Variable betrachtet, sondern die Adresse der Variable im Speicher. Dies kommt oft vor, wenn man Pointer initialisiert bzw. einen Pointer auf eine Variable verweisen lassen will und wenn man Variablen (nicht ihren Wert) einer Funktion übergeben möchte, welche sie manipulieren soll (alternative zu call-by-reference, wie dies bereits bei der Funktion "scanf" angerissen wurde). Wie dies funktionieren könnte, sehen Sie auch in Kapitel 9.2.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.1.1 Der * Operator

9.1.1 Der * Operator

Immer wenn vor einer Variable ein * steht, dann wird ihr Wert als Adresse angesehen und somit dereferenziert. In Folge wird nun im Hintergrund zu dieser verwiesenen Adresse gesprungen und die gewünschte Aktion (Auslesen oder Beschreiben) durchgeführt (entspricht dem Doppelklick auf eine Windows-Verknüpfung - nicht die Verknüpfung wird geöffnet, sondern die Datei, auf welche verwiesen wird).

An dieser Stelle seine schon erwähnt, dass dieser Operator eine schwache Bindung hat, was ich in Kapitel 9.2 an einem Beispiel demonstrieren werde.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.4 Mehrdimensionale Felder

9.4 Mehrdimensionale Felder

Wie ich bereits erwähnte, kann man Arrays auch ineinander verschachteln und das beliebig oft. Für die statischen Arrays hatte ich dies bereits angedeutet. Allerdings ist der Umgang mit dynamischen Arrays ein wenig kniffliger, da es sehr stark davon abhängt, was man möchte bzw. was man meint. Deswegen werde ich nun vier verschiedene Herangehensweisen, für ein zweidimensionales Array, aufzeigen. Dabei werde ich auch jeweils etwas zur Speicherverwaltung sagen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.4.1 Statische mehrdimensionale Arrays

9.4.1 Statische mehrdimensionale Arrays

Wie bereits erwähnt, wird bei statischen Arrays die Größe zur Entwicklungszeit, durch den Programmierer, fest vorgegeben und kannen somit zur Laufzeit nicht mehr verändert werden. Nun kann man eine Matrix bauen, welche die Werte direkt enthält und eine, die nur Zeiger auf Werte enthält.

Variante 1 Variante 2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
					
int  iZeilen							= 2;
int  iSpalten							= 3;
int  iCounter							= 0;

int  aStaticIntArray[2][3];		// Variante 1
int* aStaticPointerArray[2][3];		// Variante 2

// Erzeugen

// Durchlaufe die Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Erzeugen der Spalteninhalte
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		aStaticPointerArray[iRowCount][iColCount]	= new int;
	} // end of for
} // end of for

// Befüllen

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Befüllen der Spalten
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		aStaticIntArray[iRowCount][iColCount]		= iCounter;
		*aStaticPointerArray[iRowCount][iColCount]	= iCounter;

		iCounter++;
	} // end of for
} // end of for

// Ausgeben

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Ausgeben der Spalten
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		printf("%i ", aStaticIntArray[iRowCount][iColCount]);
		printf("%i \t\t", *aStaticPointerArray[iRowCount][iColCount]);
	} // end of for

	printf("\n");
} // end of for

// Freigeben

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Löschen der Spalteninhalte
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		delete aStaticPointerArray[iRowCount][iColCount];
	} // end of for
} // end of for
					

Ausgabe:

0 0        1 1        2 2
3 3        4 4        5 5
		

Die Variante 1 benötigt keinerlei Speicherverwaltung. Variante 2 benötigt für den Aufbau des Arrays ebenso keine Speicherverwaltung, jedoch für die Werte, auf welche die Pointer zeigen und genau diese müssen abschließend auch wieder freigegeben werden.

Zum Seitenanfang
Zum Inhaltsverzeichnis

9.4.2 Dynamische mehrdimensionale Arrays

9.4.2 Dynamische mehrdimensionale Arrays

Als wäre das nicht schon alles kompliziert genug, packe ich jetzt noch einen drauf. Falls zur Laufzeit die Größe des Arrays erst festgelegt werden soll, benötigt man dynamische Arrays. Hierbei gibt es wieder eine Fallunterscheidung. Man kann nur die zweite Dimension dynamisch machen, oder die komplette Matrix. Erstere Variante ist ein statisches Array mit Zeigern auf dynamische Arrays. Die zweite Variante ist ein Zeiger auf ein dynamisches Arrays, welches wiederum Zeiger auf dynamische Arrays beinhaltet.

Variante 1 Variante 2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
					
int iZeilen							= 2;
int iSpalten							= 3;
int iCounter							= 0;

int* aStaticArrayWithDynArray[2];	// Variante 1
int** pDynArrayWithDynArray;		// Variante 2

// Erzeugen

pDynArrayWithDynArray						= new int*[iZeilen];

// Durchlaufe die Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	aStaticArrayWithDynArray[iRowCount]			= new int[iSpalten];
	pDynArrayWithDynArray[iRowCount]			= new int[iSpalten];
} // end of for

// Bef�llen

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Befüllen der Spalten
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		aStaticArrayWithDynArray[iRowCount][iColCount]	= iCounter;
		pDynArrayWithDynArray[iRowCount][iColCount]	= iCounter;

		iCounter++;
	} // end of for
} // end of for

// Ausgeben

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	// Ausgeben der Spalten
	for (int iColCount=0; iColCount<iSpalten; iColCount++) {
		printf("%i ", aStaticArrayWithDynArray[iRowCount][iColCount]);
		printf("%i\t\t", pDynArrayWithDynArray[iRowCount][iColCount]);
	} // end of for

	printf("\n\n");
} // end of for

// Freigeben

// Durchlaufen der Zeilen
for (int iRowCount=0; iRowCount<iZeilen; iRowCount++) {
	delete [] aStaticArrayWithDynArray[iRowCount];
	delete [] pDynArrayWithDynArray[iRowCount];
} // end of for

delete [] pDynArrayWithDynArray;
					

Die Ausgabe ist wieder exakt das Gleiche wie eben (daher hab ich sie hier weggelassen), aber hinter den Kulissen läuft einiges anders. Das Befüllen und das Zugreifen auf die Werte ist weitestgehend gleich, aber die Speicherverwaltung ist grundlegend anders.

Bei Variante 1 ist das äußere Array statisch und somit müssen nur die Spalten erzeugt und abschließend wieder freigegeben werden. Bei Variante 2 muss auch noch das äußere Array erzeugt und ganz zum Schluss wieder freigegeben werden. Gerade hier ist die Reihenfolge wichtig, da man sich sonst den Ast absägt auf dem man sitzt und somit Speicherleichen produzieren würde.

Natürlich würden mir jetzt noch ein paar Möglichkeiten einfallen, aber ich denke mal, bei den vier gezeigten kann man es belassen, da in der Praxis weitere Variationen kaum bis gar nicht auftreten. Notfalls lassen sie sich aber aus den vier gezeigten Beispielen ab - bzw. herleiten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

3 Entscheidungen

3 Entscheidungen

Eine Programmiersprache bringt einem nicht viel, wenn man keine Fallunterscheidungen treffen kann. Dafür stehen in C und C++ zwei Mechanismen zur Verfügung. Zum einen die if Anweisung, bei der ein einfaches "Entweder oder ansonsten" reicht oder die switch Anweisung für eine komplexe Fallunterscheidung.

Bedingungen können zum einen boolesche Variablen und Integer (0 = false, alles andere true) sein oder logische Ausdrücke. Es gibt folgende Vergleichsoperatoren:

Bedingungen können auch logisch miteinander verknüpft werden. Dafür stehen folgende Operatoren zur Verfügung:

Leider gibt es in C und C++ kein XOR, also das entweder oder aus dem Sprachgebrauch, welches verlangt, dass eines von beiden zutreffen muss, aber nicht beide. Dies kann aber durch geschickte Kombination erreicht werden.

(Ausdruck 1 oder Ausdruck 2) und (Ausdruck 1 ungleich Ausdruck 2)
(a || b) && (a != b)

Die Klammersetzung ist hier ganz entscheidend, da das UND (vergleichbar mit einer Multiplikation) stärker bindet als das ODER (vergleichbar mit der Addition). Das Negationszeichen besitzt die stärkste Bindung und wirkt sich ohne Klammern nur auf den Ausdruck aus, vor welchem es steht.

Wie Sie in den Tabellen eben sehen konnten, besitzen auch Zahlen einen booleschen Wert, wobei nur die 0 als falsch und alle anderen Zahlen, auch negative, als wahr interpretiert werden.

Zum Seitenanfang
Zum Inhaltsverzeichnis

3.3 Das switch case Statement

3.3 Das switch case Statement

Möchte man eine komplexere Fallunterscheidung treffen, ist die switch Anweisung geeigneter und wesentlich übersichtlicher, da man nicht so viele if Anweisungen ineinander zu verschachteln braucht.

Das Schema sieht so aus: switch (<Ganzzahlausdruck>) { (case <Ganzzahlkonstante>: [<Anweisung> ... [break]]) ... [default: <Anweisung> ...] }

Zudem gibt es die Möglichkeit, einen Standardfall, den "default" zu definieren. Er ist optional und verhält sich wie ein Else-Block. Er wird ausgeführt, wenn keine der Fälle zutrifft.

Strukturgramm einer Switch-Anweisung

Allerdings gibt es da zwei Sachen, die Sie beachten sollte. Man kann erstens nur eine Unterscheidung von ganzen Zahlen oder einzelnen Zeichen machen und zweitens sollte jede Fallunterscheidung mit einer "break" Anweisung abgeschlossen werden, da ansonsten der nächste Fall auch ausgeführt wird. Dies kann sogar manchmal gewollt sein, ist aber dennoch die häufigste Fehlerursache bei Anfängern, die sich wundern, warum mehr passiert als gewollt. Im Nachstehendem PAP habe ich dieses Verhalten einmal aufgezeigt.

PAP einer switch Anweisung (mit und ohne "break")

Nachstehend sehen Sie zwei Quelltexte, in welchen ich den Umgang mit der switch Anweisung demonstriere. Ersteres benutzt die "break" Anweisung und letzteres nicht.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
					
int iZahl	= 0;

// Je nachdem, welche Zahl vorliegt
switch (iZahl) {
	case 0:	// da hier kein break steht, wird case 1 benutzt
	case 1:	// da hier kein break steht, wird case 2 benutzt
	case 2:	// da hier kein break steht, wird case 3 benutzt
	case 3:	// iZahl = 3
		printf("iZahl ist kleiner gleich 3");
		break;
		
	case 10:	
		printf("iZahl = 10");
		break;
		
	default:
		printf("iZahl ist weder kleiner 0, noch kleiner gleich 3, noch 10");
		// break braucht hier nicht zu stehen, da nach default nichts mehr kommen darf
} // end of switch
					

Ausgabe:

iZahl ist kleiner gleich 3
		

oder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
					
int iBasis			= 5;
int iExponent			= 3;
int iErgebnis			= 1;

// Je nachdem, welche Zahl vorliegt
switch (iExponent) {
	case 5:
		iErgebnis	*= iBasis;
	case 4:
		iErgebnis	*= iBasis;
	case 3:
		iErgebnis	*= iBasis;
	case 2:
		iErgebnis	*= iBasis;
	case 1:
		iErgebnis	*= iBasis;
} // end of switch

printf("iErgebnis = %i", iErgebnis);
					

Ausgabe:

iErgebnis = 125
		

Wie Sie an der letzten Ausgabe sehen, habe ich hier gezielt den Effekt genutzt, dass die anderen Fälle durchlaufen werden, denn sonst würde nicht 125 ausgegeben werden, sondern 5. Ein sinnvoller Anwendungsfall für ein solches Verhalten wäre z.B. eine Rechtevergabe mit zunehmenden Rechten. Man prüft z.B. was für eine Art Person vorliegt und fängt dann mit der Person an, die am meisten darf und dann die, die immer weniger dürfen. Besucher dürfen z.B. nur etwas lesen, interne Mitarbeiter etwas lesen und umbenennen und nur der Chef darf alles, also lesen, umbenennen und löschen. Ist der Fall Chef der erste, bekommt er sein Sonderrecht + das Recht des Mitarbeiters und noch das Recht des Besuchers.

Zum Seitenanfang
Zum Inhaltsverzeichnis

3.1 Das if else Statement

3.1 Das if else Statement

Die if Anweisung ist der einfachste Weg, um zu Prüfen, ob etwas zutrifft. Ist dem so, wird der Nachstehende Befehl ausgeführt. Optional kann man auch einen Else-Block definieren, der ausgeführt wird, wenn die Bedingung nicht zutrifft. Der Befehl für das Zutreffen der Bedingung muss immer vorhanden sein. Falls man mehr wie einen Befehl ausführen will, muss man entsprechende Befehle mittels geschweifter Klammern zu einem Block zusammen fassen. Jener Block stellt dann quasi ein Befehl dar. Aus Gründen der Übersichtlichkeit, benutze ich immer geschweifte Klammern, auch wenn ich nur einen Befehl ausführen will.

Strukturgramm der if Anweisung

Links im Strukturgramm sehen Sie eine if Anweisung ohne und rechts mit else Block.

Programmablaufplan einer if Anweisung

Links im Programmablaufplan (PAP) sehen Sie eine if Anweisung ohne und rechts mit else Block

Das Schema sieht so aus: if (<Bedingungen>) <Anweisung> [else <Anweisung>]

Hier mal ein ganz kleines Beispiel, welches entscheidet, ob eine Zahl größer als 5 ist oder nicht.

 1
 2
 3
 4
 5
 6
 7
 					
int iZahl = 10;

if (iZahl > 5) {
	// iZahl ist > 5.
} else {
	// iZahl ist <= 5.
} // end of if
					

Gerade für Anfänger empfiehlt es sich allerdings, die geschweiften Klammern in eine extra Zeile zu schreiben, da man so schneller erkennen kann, wo evtl. eine Klammer vergessen wurde. Dies sieht dann für obiges Beispiel wie folgt aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 					
int iZahl = 10;

if (iZahl > 5)
{
	// iZahl ist > 5.
}
else
{
	// iZahl ist <= 5.
} // end of if
					

Aus Gründen der Platzersparnis, werde ich im weiteren Verlauf dieses Tutorials, die platzsparende Schreibweise benutzen. Sie sollten für Sich selbst entscheiden, welche Schreibweise Sie angenehmer finden und diese dann konsequent anwenden. Wenn Sie sich aber angewöhnen, erst "if ( ) { }" zu schreiben und anschließend etwas in die Klammern packen, gehen Sie von Anfang an fehlenden Klammern aus dem Weg und Sie können getrost die gekürzte Schreibweise verwenden. Das ist wie gesagt Geschmackssache. Viel wichtiger ist allerdings, dass Sie wirklich innerhalb der geschweiften Klammern einrücken, wie Sie das an den Kommentaren sehen können.

Wenn man Bedingungen angibt, das trifft auch später auf die Schleifen zu, muss man nicht zwangsläufig zwei Variablen/Werte miteinander vergleichen. Mann muss nur dafür sorgen, dass der Ausdruck entweder wahr oder falsch ist. Man kann genauso gut eine boolesche Variable angeben bzw. eine Funktion aufrufen, welche einen solchen Wert zurück gibt oder auch nur schlicht eine Zahl (ist der Wert 0, wird dies als "false" angesehen, wobei jeder andere Wert als "true" interpretiert wird).

Nun habe ich mir auch angewöhnt, hinter die letzte geschlossene geschweifte Klammer noch den Kommentar "end of if" zu schreiben. Dies ist auf keinen Fall notwendig. Es dient mir nur dazu, zu erkennen, was genau die geschlossene Klammer beendet. Wenn ich Ihnen dann noch Schleifen zeige und dann darin noch if Anweisungen einbaue, welche wiederum Schleifen beinhalten, verliert man schnell den Überblick. Ein Kommentar kann dann sehr hilfreich sein. Nun könnten Sie noch berechtigt einwerfen, dass ich eigentlich hätte "end of else" schreiben müssen. Nun, dies kann man machen, aber der Kommentar soll weniger sagen, dass hier das Else zu Ende ist, als dass der ganze Block zu Ende ist. Wie oder ob Sie das überhaupt machen, ist auch wieder Geschmackssache. In meiner jahrelangen Erfahrung als Programmierer, hat sich allerdings dieser Stiel für mich am günstigsten heraus kristallisiert.

An dieser Stelle möchte ich Ihnen aber noch erklären, warum man überhaupt diese geschweiften Klammern setzen muss. Normalerweise kann der Compiler nicht wissen, wann eine if Anweisung zu Ende ist. Im Fall des ersten Blockes mag dies vielleicht nochg ehen, da man ja ein "else" hat, welches als klare Abtrennung fungieren könnte. Nun muss es aber zum einen keinen "else" Zweig geben und zum anderen muss man ja auch das Ende des "else" Zweiges signalisieren. Spätestens jetzt sollte klar sein, warum Sie die geschweiften Klammern setzen müssen. Aber es gibt auch Fälle, in welchen man auf sie verzichten kann. Immer dann, wenn ein Block nur aus einer Anweisung besteht, können die Klammern weggelassen werden und dann kann man wieder etwas Platz sparen, was folgender Quelltext demonstrieren soll.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
					
int	iZahl1				= 50;
int	iZahl2				= 50;
bool	bAllesOk			= true;

// Wenn iZahl1 kleiner oder größer ist als iZahl2
if ((iZahl1 < iZahl2) || (iZahl1 > iZahl2))
	if (iZahl1 < iZahl2)
	 	printf("%i < %i", iZahl1, iZahl2);
	else
		// Wenn sie erst gleich waren dürfen sie nicht auf einmal gleich sein
		if (iZahl1 == iZahl2)
			bAllesOk	= false;
		else
			printf("%i > %i", iZahl1, iZahl2);
else
	printf("Beide Zahlen sind gleich");

// Wenn ein Fehler aufgetreten ist
if (!bAllesOk)
	printf("!!! Etwas ist schief gelaufen !!!");
					

Der erfahrene C Programmierer geht sogar noch einen Schritt weiter und kürzt selbst dies noch weiter, in dem er die Anweisungen gleich hinter das "if" bzw. "else" schreibt. Dies sieht dann folgendermaßen aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
					
int	iZahl1				= 50;
int	iZahl2				= 50;
bool	bAllesOk			= true;

// Wenn iZahl1 kleiner oder größer ist als iZahl2
if ((iZahl1 < iZahl2) || (iZahl1 > iZahl2))
	if (iZahl1 < iZahl2)		printf("%i < %i", iZahl1, iZahl2);
	else	if (iZahl1 == iZahl2)	bAllesOk	= false;
		else			printf("%i > %i", iZahl1, iZahl2);
else					printf("Beide Zahlen sind gleich");

// Wenn ein Fehler aufgetreten ist
if (!bAllesOk)				printf("!!! Etwas ist schief gelaufen !!!");
					

Sie sehen also, dass man in C viele Sachen geschickt kürzen kann. Statt 25 Zeilen, benötige ich im ersten Schritt nur noch 20 und im zweiten Schritt sogar nur noch 13 Zeilen. Allerdings erkaufe ich mir diese Platzersparnis durch die Tatsache, dass der Quelltext weit komplizierter zu verstehen ist, weshalb ich Ihnen dringend ans Herz lege, auf solche Kürzungen so weit wie möglich zu verzichten. Erst wenn Sie die Sprache C sicher beherrschen, können Sie anfangen, auf solche Tricks zurückzugreifen und dann ist es auch extrem wichtig, auf den Stil zu achten (gerade die Einrückungen sind extrem wichtig).

Zum Seitenanfang
Zum Inhaltsverzeichnis

3.4 Übungsaufgaben II

3.4 Übungsaufgaben II

  1. Lassen Sie vom Benutzer eine Zahl einlesen und geben Sie nur die Zahl aus, wenn sie kleiner als 10 ist.
  2. Lassen Sie vom Benutzer zwei Zahlen eingeben und prüfen Sie, ob die erste Zahl kleiner ist als die zweite. Ist dem so, Soll "Ja" ausgegeben werden und ansonsten soll der Benutzer darauf hingewiesen werden, dass die erste Zahl größer ist als die zweite.
  3. Erweitern Sie auf Aufgabe 2 so ab, dass noch eine dritte Zahl durch den Benutzer eingelesen wird. Falls die erste Zahl kleiner ist als die zweite, soll geprüft werden, ob die dritte Zahl zwischen der ersten und zweiten liegt. Ist dem so, geben Sie die dritte Zahl aus, ansonsten soll darauf hingewiesen werden, dass die Zahl nicht im Bereich liegt.
  4. Lassen Sie vom Benutzer zwei Zahlen eingeben und geben Sie die größere von beiden aus.
  5. Erweitern Sie Aufgabe 4 so, dass noch eine dritte eingelesen wird und geben Sie die größte von ihnen aus. Verschachteln Sie dafür die if Anweisungen.
  6. Lassen sie vom Benutzer eine Gleitkommazahl eingeben. Sorgen Sie dafür, dass die Zahl nachträglich positiv ist und geben sie den Wert aus. Benutzen Sie dafür den "?" Operator.
  7. Lassen Sie vom Benutzer eine vorzeichenlose Ganzzahl einlesen. Ist der Wert 0, soll "NULL" ausgegeben werden. Ist der Wert 1, soll "EINS" ausgegeben werden. Ist der Wert 2, soll "ZWEI" ausgegeben werden. Genau dieses Prinziep soll noch für die Zahlen 3, 4, 5, 6, 7, 8 und 9 geschehen. Wenn der Wert größer ist, geben Sie den Wert so aus, wie er ist. Verwenden Sie die switch Anweisung.
Zum Seitenanfang
Zum Inhaltsverzeichnis

3.2 Der ? Operator

3.2 Der ? Operator

Mit dem "?" Operator kann man eine komplette if Anweisung in eine Zeile schreiben, vorausgesetzt, man will einer Variablen nur den einen oder anderen Wert zuweisen. Dieses Konstrukt lässt sich auch ineinander verschachteln. Allerdings rate ich Ihnen, zunächst die Finger von solchen Geschichten zu lassen, da man so richtig fiese Ausdrücke bauen kann, die alles andere als trivial sind. Der Vollständigkeit halber möchte ich Ihnen dieses Werkzeug aber nicht vorenthalten, da es, gerade von Profis, sehr häufig eingesetzt wird.

Das Schema sieht so aus: <Bedingungen> ? <Ausdruck> [: <Ausdruck>]

Möchte man z.B. verhindern, dass eine Variable keinen negativen Wert hat. So könnte der Quelltext wie folgt aussehen.

 1
 2
 3
					
int	iWert		= 15;
// Folgende Zeile sorgt dafür, dass iPositiv mindestens 0 ist
int	iPositiv	= (iWert >= 0)? iWert : 0;
					

Wenn man jetzt verhindern möchte, dass der Wert negativ und nicht größer als z.B. 100 ist, so sähe der Quelltext, mit verschachteltem "?" Operator, wie folgt aus.

 1
 2
 3
					
int	iWert		= 15;
// Folgende Zeile sorgt dafür, dass iPositiv immer mindestens 0 und maximal 100 ist
int	iPositiv	= (iWert < 0)? 0 : (iWert > 100)? 100 : iWert;
					

Spätestens dieser Quelltext sollte Ihnen Respekt vor diesem Operator verschaffen.

Aber der eigentliche Zweck dieses Operators ist, dass man bedingte Berechnungen bauen kann, was mit einer if Anweisung so nicht möglich ist (in Excel oder Calc darf man das). Man benutzt den Operator also nicht um Platz zu sparen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

11 Der Zufall

11 Der Zufall

Da der Zufall in der Informatik viel zu wichtig ist, um ihn dem Zufall zu überlassen, werde ich in diesem Kapitel kurz darüber sprechen, wie man Zufallszahlen generiert und was es dabei zu beachten gilt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

11.2 Der zufällige Zufall

11.2 Der zufällige Zufall

Wie schaffen wSie es nun, immer eine andere Folge von zufälligen Zahlen zu erzeugen? Sicherlich wird man auf die Idee kommen, den Initialwert zufällig zu wählen, aber beißt sich da die Katze nicht in den Schwanz? Ohne weitere Überlegungen, würde sie das tun, aber es gibt Mittel und Wege einen zufälligen Initialwert zu bekommen, ohne "rand" aufrufen zu müssen. Der Trick ist, man benutzt das aktuelle Datum und die aktuelle Zeit. Vom Prinzip her könnte man zwar dann auch immer den gleichen Zufall bekommen, wenn man die Systemzeit zurück setzt, aber da die Uhrzeit auf die Millisekunde genau ist, ist es extrem schwer, es so zu organisieren, dass genau zu einem Zeitpunkt X der Computer die Funktion "srand" aufruft (um nicht zu sagen unmöglich).

Folgendes Beispiel wird wieder genau so sein wie eben, nur dass ich jetzt die Zeit für die Initialisierung des Zufalls verwenden werde.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
#include <stdlib.h>
#include <time.h>

// ...

srand((unsigned int)time(NULL));

// 5 Zufallszahlen erzeugen
for (int iCount = 0; iCount < 5; iCount++) {
	printf("%i ", rand());
} // end of for
					

In Zeile 6 sehen Sie jetzt, wie ich die aktuelle Zeit ermittle und das bringt mich auch gleich zu einem sehr wichtigen Hinweis. Bauten Sie die Funktion "srand" niemals in eine Schleife ein bzw. in eine rekursive Funktion. Jene werden meistens innerhalb einer Millisekunde ausgeführt, was zur Folge hat, dass man immer dieselbe Zeit ermittelt und somit immer den gleichen Startwert für den Zufall bekommt. Noch schlimmer wird es, wenn man in einer Schleife einmal "srand" und einmal "rand" aufruft. In diesem Fall bekommt man sogar immer die gleiche Zahl. Falls Sie sich also irgendwann mal wundern sollten, warum Ihtr zufällige Zahl gar nicht zufällig ist, dann liegt es an eben beschriebenem Szenario.

Zum Schluss möchte ich noch einen Lösungsansatz dafür aufzeigen, wie man zufällige Zahlen in einem bestimmten Intervall erzeugen kann, solange er unterhalb von 32767 liegt. Der Trick ist hier, dass man auf die zufällige Zahl den Modulo-Operator anwendet und anschließend das Ergebnis in den gewünschten Bereich verschiebt. Dies könnte z.B. so aussehen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
					
#include <stdlib.h>
#include <time.h>

// ...

srand((unsigned int)time(NULL));

int iVon	= 10;
int iBis	= 20;

// 5 Zufallszahlen erzeugen
for (int iCount = 0; iCount < 20; iCount++) {
	printf("%i ", rand() % (iBis-iVon+1) + iVon);
} // end of for
					

Ausgabe:

10 19 11 13 14 18 16 16 10 17 18 20 10 19 11 19 14 11 15 12
		

Wie mSie sehen, erzeuge ich mit obigen Code, 20 Zufallszahlen, welche im Bereich zwischen / einschließlich 10 und 20 liegen.

Eines sei an dieser Stelle noch erwähnt. Der intern verwendete Algorithmus zur Generierung der Zahlen ist zwar gut, aber nicht perfekt. Das heißt, manche Zahlen kommen öfters als andere vor. Die Gleichverteilung ist also nicht gegeben, sie kommt dem nur nahe. Wenn Sie es mir nicht glauben, können Sie ja mal ein Array von 1 bis 1000 bauen und eine Million mal eine zufällige Zahl erzeugen lassen und jene als Index für den Arrayzugriff benutzen, um den enthaltenen Wert zu inkrementieren. Wenn Sie sich jenes Array anschließend anschaut, werden Sie feststellen, dass die Werte teilweise stark variieren (was ja eigentlich nicht vorkommen dürfte) und dass sich bei mehrmaligem Ausführen ein Muster abzeichnet.

Zum Seitenanfang
Zum Inhaltsverzeichnis

11.1 Der statische Zufall

11.1 Der statische Zufall

Wie erstellt der Computer Zufallszahlen? Dafür gibt es einen Algorithmus, welcher mit einem Initialwert nacheinander komplexe Berechnungen durchführt und so immer eine vermeintlich zufällige Zahl liefert. Aber wie man aus dieser Erklärung vielleicht schon ableiten kann, wird mit einem gleichen Initialwert, immer der gleiche Zufall erzeugt. Schauen wir uns mal ein kleines Beispiel an, in welchem ich fünf zufällige Zahlen erzeuge und ausgebe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
#include <stdlib.h>

// ...

srand(7);

// 5 Zufallszahlen erzeugen
for (int iCount = 0; iCount < 5; iCount++) {
	printf("%i ", rand());
} // end of for
					

Vorausgesetzt Sie haben nicht eine andere Version der "stdlib.h", sollten die Ausgaben immer wie folgt aussehen.

61 17422 15215 7040 15521
		

Falls bei Ihnen nicht diese Werte herauskommen, dann spielt das auch keine Rolle. Wichtig ist nur, dass, auch wenn es andere Werte sind, es immer die gleichen Werte sind.

Mit der Funktion "srand" können wir also einen Initialwert setzen, welcher Einfluss auf die zufälligen Zahlen hat. Mit der Funktion "rand" wird dann die eigentliche Zahl erzeugt, welche im Bereich zwischen / einschließlich 0 und 32767 liegt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

11.3 Übungsaufgaben IX

11.3 Übungsaufgaben IX

  1. Schreiben Sie ein kleines Programm, welches ermittelt, wie gut der Zufall ist. Lassen Sie vom Benutzer den Wertebereich angeben (Maximalzahl) und eine Anzahl von Durchläufen. Anschließend soll eine Funktion "GenerateRandom" erstellt und darin ein Array angelegt werden, welches genau so groß ist wie der Wertebereich. Nachdem alle Werte mit 0 initialisiert wurden, soll eine Schleife mit der gewünschten Anzahl an Durchläufen, eine zufällige Zahl im Wertebereich erzeugen und an der entsprechenden Stelle im Array, den dortigen Wert um eins erhöhen. Abschließend soll die Funktion das Array zurück geben. Geben Sie in der "main" die Werte im Array aus und überprüfen Sie die Verteilung (theoretisch sollten überall ähnliche Werte sein, wenn der Zufall gut ist)
  2. Schreiben Sie ein kleines Programm zum generieren von Passwörtern. Dafür soll eine Funktion "GeneratePassword" erstellt werden, welche eine Länge übergeben bekommt und einen String zurück gibt. Das Passwort soll aus kleinen und große Buchstaben, sowie Ziffern bestehen, wobei sie mit der gleichen Wahrscheinlichkeit vorkommen sollen (1/3 klein, 1/3 groß, 1/3 Ziffer). In der "main" Funktion soll der Benutzer so lange Passwörter erzeugen können, bis er 0 als Länge angibt.
    TIPP: Es ist wahrscheinlich einfacher, wenn man erst entscheidet, was erzeugt werden soll und dann wiederum entscheidet was genau.
  3. Schreiben Sie ein Programm, welches das Spiel "Schere Stein Papier" realisiert. Dabei reicht es, wenn man nur gegen den Computer spielt und er mit gleicher Wahrscheinlichkeit, Schere, Stein oder Papier wählt. Versuchen Sie Ihr Programm so aufzubauen, dass die Eingabe und die Gewinnprüfung in extra Funktionen ausgelagert werden. In der "main" Funktion soll der Benutzer so lange das Spiel spielen können, bis er nicht mehr mag. Entscheiden Sie sich selbst für eine geeignete Abbruchbedingung. Während des Spiels, soll zudem mitgezählt werden, wie oft jeder gewonnen hat. Folgende Regeln sollen gelten: TIPP: Wenn Sie Enumerationen benutzen, wird ihr Quelltext Übersichtlicher.
Zum Seitenanfang
Zum Inhaltsverzeichnis

12 Ausnahmebehandlungen I

12 Ausnahmebehandlungen I

In diesem Kapitel werde ich Ihnen verraten, wie man Fehler abfangen kann, welche nicht oder nur schwer, vorherzusehen sind. Ich spreche von dem s.g. Exceptionhandling. Doch was meine ich damit, dass man Fehler schwer vorhersehen kann?

Stellen Sie sich vor, Sie arbeiten mit einer Datei auf einem USB Stick und während des Schreibens, zieht jemand den Stick aus dem Rechner. Schon haben Sie eine unvorhersehbare, aber trotzdem wahrscheinliche Fehlersituation, um die Sie sich kümmern müssen. Genau dieses Beispiel werde ich dann im nächsten Kapitel, wenn es um Dateien geht, noch einmal aufgreifen. An dieser Stelle werde ich aber nur ein paar Grundlagen diskutieren und ein paar sehr einfache Beispiele bringen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

12.1 Exceptions

12.1 Exceptions

Eine Exception ist eine Ausnahme und kein Fehler! Mit ihr kann man der aufrufenden Funktion signalisieren, dass etwas schief gelaufen ist bzw. nicht stimmt. Immer dann, wenn Ihnen beim ausführen eines Programmes, die Meldung entgegen kommt "Zugriffsverletzung im Speicherbereich xy", handelt es sich nicht um einen Fehler, sondern um eine Exception, die durch die Speicherverwaltung von Windows erzeugt wurde. Klar könnte man jetzt ganz naiv sagen, dass dies doch ein Fehler ist, aber streng genommen ist es das nicht. Wäre es ein tatsächlicher Fehler, würde überhaupt keine Meldung kommen und das System sich gnadenlos aufhängen.

Eine Behandlung von Exception ist erst seit C++ möglich und intern werden auch Objekte erzeugt und umher "geschmissen". Wie Sie diese Objekte benutzen bzw. sogar selber erstellen können, werde ich allerdings erst in einem späteren Kapitel mit Ihnen besprechen, sobald ich Ihnen gezeigt habe, was ein Objekt ist und wie man mit ihm umgeht.

Zum Seitenanfang
Zum Inhaltsverzeichnis

12.3 __try und __finally

12.3 __try und __finally

In vielen anderen Sprachen, gibt es einen s.g. "try finally" Mechanismus, mit welchen man dafür sorgt, dass auch im Fehlerfall, definitiv noch Operationen ausgeführt werden, wie z.B. das nachträgliche Freigeben von Speicher. Dieser Mechanismus ist im reinen C++ Standard nicht enthalten, aber in Visual Studio hat sich Microsoft etwas einfallen lassen, um dies nachzustellen. Normalerweise braucht man diesen Mechanismus auch nicht, da im Fehlerfall sowieso der "catch" Block ausgeführt wird und es anschließend ganz normal weiter geht. Das Freigeben des Speichers könnte man also auch außerhalb des "try catch" Blockes packen. Hin und wieder wird aber trotzdem ein "try finally" gebaut, da der Quelltext so etwas besser lesbarer wird bzw. so nachdrücklich betont wird, dass aus jeden Fall noch etwas getan werden muss. Es handelt sich also nur um eine reine stilistische Angelegenheit, was erklärt, warum dieser Mechanismus nicht zum C++ Standard gehört.

Im Nachfolgenden Quelltextfragment sehen Sie, wie man ein solches Konstrukt baut.

 1
 2
 3
 4
 5
					
__try {
	// Geschützter Quelltext
} __finally {
	// Abschließende Arbeiten
} // end of try
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

12.2 try und catch

12.2 try und catch

Wie Sie vielleicht an der Überschrift schon erkennen können, geht es jetzt um das Absichern von Quelltextteilen. Immer dann, wenn man um einen Quelltext "try" und "catch" herum baut, wird der darin befindliche Code, in einer Art geschützten Umgebung ausgeführt und wenn was schief läuft, wird sofort in den "catch" Abschnitt gesprungen, um dort eine entsprechende Meldung abzugeben, bzw. den Fehler weiter delegieren zu können.

Das Schema sieht so aus: try <Anweisung> catch ((<Ausnahmetyp> <Variable>)|...) <Anweisung>

Hier mal ein kleines Beispiel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
try {
	int iWert = 10;

	// Von -5 bis 5
	for (int iCounter=-2; iCounter<=2; iCounter++) {
		printf("%i\n", iWert / iCounter);
	} // end of for
} catch (...) {
	printf("Geteilt durch 0 ist nicht erlaubt!\n");
} // end of try
					

Ausgabe:

-5
-10
Geteilt durch 0 ist nicht erlaubt!
		

Wie Sie sehen können, wird in der Schleife irgendwann eine Division durch 0 auftreten und dann würde das Programm abstürzen. Dies liegt daran, dass intern eine Exception ausgelöst wird, in dem Fall ein Run-Time-Error 200. Genau in diesem Fall, wird in den "catch" Block gesprungen und man kann dem Anwender auf diese Situation hinweisen.

Auffällig ist, dass ich in den Klammern des "catch" drei Punkte gesetzt habe. Normalerweise kommt hier ein Fehlertyp hin, aber wenn man nicht weiß, was alles für Fehler auftreten können bzw. nicht weiß, wie auftretende Fehler definiert sind, kann man diesen Platzhalter benutzen und allgemein reagieren. Erst wenn man den Typ kennt und angibt, kann man sich aus ihm nähere Informationen zur Fehlerursache holen. Dies werde ich aber wie erwähnt erst später behandeln.

Des Weiteren sollten Sie sehen, dass der obige Block sofort verlassen wird, falls ein Fehler auftritt und dass man nicht wieder zurückkehren kann. In meinem Beispiel bedeutet dies, dass die Ausgabe nach dem Fehler beendet wird. Deshalb sollte man genau überlegen, wo man ein "try" einbaut. Im Falle des Beispiels wäre es also besser, das Abfangen des Fehlers, innerhalb der Schleife zu machen. Aber werden Sie bitte nicht Faul. Eine Division durch 0 kann man auch im Vorfeld mit einer if Anweisung abfangen, da ein "try" um vieles langsamer ausgeführt wird. Alles was Sie also geschickt mit einer Bedingung abfangen können, sollten Sie unter allen Umständen auch so abfangen. Nur dann, wenn man absolut nicht weiß, wie man etwas mit einer Bedingung lösen kann, benutzt man es in ein "try".

Zum Seitenanfang
Zum Inhaltsverzeichnis

2 Ein - und Ausgabe

2 Ein - und Ausgabe

Für die Eingabe benutzt man z.B. die Funktion "scanf()" (alternativ "cin"). Für die Ausgabe empfiehlt sich z.B. die Funktion "printf()" (alternativ "cout"). Ein - und Ausgabe sind in der Programmierung wesentliche Bestandteile und werden in fast jedem Beispiel vorkommen. Nach diesem großen Kapitel 2 werde ich diese Funktionen nicht weiter erklären.

Zum Seitenanfang
Zum Inhaltsverzeichnis

2.7 Kombination der Funktionen

2.7 Kombination der Funktionen

Variante 1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
					
int iSummand1;
int iSummand2;

printf("Bitte eine Zahl eingeben:      ");
scanf("%i", &iSummand1);

printf("Bitte noch eine Zahl eingeben: ");
scanf("%1", &iSummand2);

printf("Die Summe beider Zahlen ist %i.", (iSummand1 + iSummand2));
printf("\n\n");
printf("Auf Wiedersehen!");	
					

Variante 2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
					
int iSummand1;
int iSummand2;

cout << "Bitte eine Zahl eingeben:      ";
cin >> iSummand1;

cout << "Bitte noch eine Zahl eingeben: ";
cin >> &iSummand2;

cout << "Die Summe beider Zahlen ist " << (iSummand1 + iSummand2) << ".";
cout << endl << endl;
cout << "Auf Wiedersehen!";
					

Ausgabe:

Bitte eine Zahl eingeben:      5
Bitte noch eine Zahl eingeben: 10
Die Summe beider Zahlen ist 15.	

Auf Wiedersehen!
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

2.8 Übungsaufgabem I

2.8 Übungsaufgabem I

  1. Schreiben Sie ein "hello world" Programm, welches nur auf dem Bildschirm "Hello World" ausgeben soll. Benutzen Sie dazu die Funktion "printf".
  2. Bauen Sie das Programm aus Aufgabe 1 so um, dass "cout" benutzt wird.
  3. Geben Sie das Ergebnis der Berechnung 15 / 4 so auf dem Bildschirm aus, dass das Ergebnis eine ganze Zahl ist und der Rest mit da Steht.
  4. Geben Sie das genaue Ergebnis der Berechnung 17 / 16 als Gleitkommazahl aus.
  5. Lassen Sie vom Benutzer zwei ganzzahlige Werte einlesen und geben Sie diese in umgekehrter Reihenfolge wieder aus. Benutzen Sie dafür die Funktionen "printf" und "scanf".
  6. Bauen Sie das Programm aus Aufgabe 5 so um, dass "cout" und "cin" benutzt wird.
  7. Lassen Sie den Benutzer zwei Gleitkommazahlen eingeben und geben Sie das Produkt aus.
Zum Seitenanfang
Zum Inhaltsverzeichnis

2.6 Eingaben mit getch und getche

2.6 Eingaben mit getch und getche

Bisher musste der Benutzer immer ENTER drücken, um seine Eingabe zu bestätigen. Dies muss aber nicht sein, denn es gibt auch Funktionen, welche sofort auf einen Tastendruck reagieren. Um dies zu realisieren, gibt es in der Bibliothek "conio.u" zum einen die Funktion "getch", welche versteckte Eingaben macht (z.B. für Passwortabfragen oder kleine Spiele ganz nützlich, wie Sie gleich sehen werden) und "getche", welche die Eingabe auf der Console ausgibt. Allerdings läuft es mit diesen Funktionen etwas anders, als bisher. Zum einen übergibt man ihnen keine Referenz auf eine Variable, sondern die Funktionen liefert einen Tastencode zurück und zum anderen ist dieser Code ein Integer und kein Zeichen. Dies ist aber nicht weiter schlimm, da Sie ja wissen, wie man Variablen ineinander Konvertiert.

Des Weiteren sei noch erwähnt, dass nicht alle Tasten auf ihrer Tastatur gleichberechtigt sind. Es gibt Zeichen-Tasten (a-z, 1-0 usw.), Steuerungs-Tasten (Strg, Alt, Tab usw.) und Sondertasten (F1-F12, Bild hoch/runter, Einfg, Entf usw.). Die zwei Funktionen reagieren nur auf die Zeichen-Tasten und die Sondertasten, wobei die Sondertasten durch zwei Zeichen dargestellt werden. Die einzige Ausnahme ist die Steuertaste ENTER, welche dem ASCII-Code 10 entspricht.

Folgendes Beispiel zeigt, wie man so lange wartet, bis der Benutzer eine Taste drückt

 1
 2
 3
 4
 5
 6
 7
 8
 9
					
#include <conio.h>

// ...

printf("Bitte Taste druecken, um fortzufahren!\n");
fflush(stdin);
getch();

// ...
					

Mit dieser Methode finden Sie auch ganz einfach heraus, welcher Tastencode zu welchem Buchstabe passt. Ein Testprogramm könnte wie folgt aussehen.

 1
 2
 3
 4
					
printf("Der Code zu Taste ");
fflush(stdin);
int iEingabe = getche();
printf(" lautet %i\n", iEingabe);
					

Wie Sie sehen konnten, ist es auch hier notwendig, vorher den Tastaturpuffer zu leeren, da noch ein Steuerzeichen enthalten sein könnte.

Zum Seitenanfang
Zum Inhaltsverzeichnis

2.4 Die Funktion scanf()

2.4 Die Funktion scanf()

Diese Funktion ließt Werte für Variablen von der Tastatur ein und benutzt auch einen Formatstring. Man übergibt ihr aber nicht den Wert einer Variable, sondern die Adresse im RAM/Arbeitsspeicher wo die Variable steht, also die Angabe wo die Tastatur den Wert hin tun soll. Stellen Sie sich das so vor. Ich erwähnte bereits die Metapher der Kochtöpfe. Wenn also jemand für Sie Wasser in einen Topf füllen soll, dann bringt es nichts, diesem jemanden den leeren Inhalt des Topfes zu geben, sondern sie müssen auf den Topf zeigen oder der Person den Topf in die Hand drücken. Erst dann weiß derjenige, wo er Wasser einfüllen soll und deswegen übergibt man nicht den Wert der Variable, sondern die Position.

Referenz einer Variable

Doch wie kommt man jetzt an die Adresse bzw. Referenz einer Variable heran? Dies geschieht mittels des "&" Operators. Wenn man wissen will, welche Herdplatte an ist, dann schaut man sich die Knöpfe an und sieht anhand des kleinen Bildchens, welche Platten beheizt werden. Wenn man nun in diesem Bildchen das ganze einkreist, erhält man eine Art Kaufmanns-Und. Dies können Sie sich als Eselsbrücke merken, wenn es darum geht, nicht den Wert einer Variable zu übergeben, sondern ihre Position.

Das Schema sieht so aus: scanf(<Formatstring>, &<Variable>[, &<Variable> ...])

Nun ergibt sich da noch eine kleine Schwierigkeit. Die Tastatur hat einen Puffer, in dem alle Sachen abgelegt werden, die man auf der Tastatur drückt. Dies betrifft nicht nur Zahlen und Buchstaben, sondern auch die ENTER Taste. Liest man Zahlen ein, stört dies nicht weiter, da das ENTER zunächst mit eingelesen, aber nicht mit interpretiert wird. Wenn aber ein einzelnes Zeichen eingelesen werden soll, dann verbleibt das ENTER im Puffer und wenn anschließend noch ein Zeichen eingelesen werden soll, wird dieses verbleibende ENTER genommen und der Benutzer wird nach keiner erneuten Eingabe gefragt. Aus diesem Grund gibt es Funktionen, mit welchen Sie den Tastaturpuffer leeren können und jene sollten Sie immer aufrufen, bevor Sie ein einzelnes Zeichen oder Zeichenketten einlesen wollen. Wie dies aussehen könnte, sehen Sie in folgendem Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
					
int  iZahl;
char cZeichen;

// Wartet bis der Anwender eine natürliche Zahl eingegeben hat
// und ENTER gedrückt hat
scanf("%i", &iZahl);

// Tastaturpuffer leeren
fflush(stdin);
// Wartet bis Anwender eine Zahl oder Buchstaben eingegeben hat
// und ENTER gedrückt hat
scanf("%c", &cZeichen);
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

2.1 Die Funktion printf()

2.1 Die Funktion printf()

Diese Funktion benutzt Formatstrings. Dies sind Zeichenketten, welche Platzhalter beinhalten können. Jene Platzhalter werden mit dem Inhalt von Variablen oder Konstanten ersetzt, welche der Funktion mit übergeben werden. Wichtig dabei ist, dass die Anzahl und Reihenfolge der Platzhalter und die Anzahl der übergebenen Variablen/Konstanten übereinstimmt.

Das Schema sieht so aus: print(<Formatstring>[, <Variable1>|<Wert>, <Variable2>|<Wert>, ...])

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
#include <stdio.h>

// ...

int iZahl = 15;

// Normaler Text wird ausgegeben			
printf("String ohne Platzhalter\n");
// %i = gebe an dieser Stelle ein Integerwert aus
printf("Zahl = %i\n", iZahl);
// Hier wird eine Konstante übergeben
printf("andere Zahl = %i\n", 18);
// Hier werden drei Variablen/Werte übergeben
printf("%i + %i = %i\n", iZahl, iZahl, (iZahl + iZahl));
// Hier wird ein ASCII-Wert übergeben
printf("%c", 100);
					

Ausgabe:

String ohne Platzhalter
Zahl = 15
andere Zahl = 18
15 + 15 = 30
d
		

Die Ausgaben können auch formatiert werden. Ich nenne jetzt nur ein paar Beispiele, aber wenn ihr das Wort "printf" in Visual Studio hinschreibt und "F1" drückt, bekommt ihr eine Liste mit allen Möglichkeiten.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
					
#include <stdio.h>

// ...

// %f = gebe an dieser Stelle ein Floatwert aus
printf("%f\n", 1.5);
// Mindestens 9 Stellen
printf("%9f\n", 1.5);
// Mindestens 10 Stellen, da es aber nur sechs Nachkommastellen + , gibt,
//werden 3 vor dem Komma reserviert
printf("%10f\n", 1.5);
printf("%f\n", 100.25);
// Mindestens 6 Stellen, davon maximal 2 nach dem Komma
printf("%6.2f\n", 10.25)
// Mindestens 7 Stellen, davon maximal 3 nach dem Komma;
printf("%7.3f\n", 1.25)
// Vor dem Komma ist egal, aber danach maximal 3 Stellen;
printf("%.3f\n", 1.25);
// Obwohl ein float übergeben wird, wird nur der Integerwert ausgegeben	
printf("%i", 1.25);
					

Ausgabe:

1.5000000
 1.5000000
  1.5000000
100.2500000
 10.25
  1.250
1.250
1
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

2.3 Ausgaben mit cout

2.3 Ausgaben mit cout

Der modernere Ansatz unter C++, ist die Ausgabe mittels dem statischen Objekt "cout" (gesprochen C-Out). Ich habe lange damit gerungen, ob ich dieses Kapitel an dieser Stelle bringe, da hier ein Mechanismus benutzt wird, der erst ganz am Ende des Buches erklärt wird und ich Ihnen nahe gelegt habe, nichts zu benutzen, was Sie nicht zu 100% verstehen. Dennoch muss das Kapitel kommen, da man zum einen in der Literatur fast nur Ausgaben mit "cout" findet und es zum anderen für Anfänger leichter zu verstehen ist als "printf".

Das Schema sieht so aus: cin << <Variable>|<Wert> [<< <Variable>|<Wert> ...]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
					
#include <iostream>
using namespace std;

// ...

int iZahl = 15;

cout << "String ohne Platzhalter" << endl;
cout << "Zahl = " << iZahl << endl;
cout << "andere Zahl = " << 18 << endl;
cout << iZahl << " + " << iZahl << " = " << (iZahl + iZahl) << endl;
cout << (int)100;
					

Die Ausgabe sieht wieder so aus wie im vorletzten Beispiel, allerdings hat sich einiges am Quelltext getan. Zum einen benötigt man ein anderes Include und dann braucht man noch den Befehl "using namespace std". Letzteres ist wichtig, da man sonst vor jedes "cout" und "endl" noch "std::" schreiben müsste, was auf Dauer sehr nervig sein kann. Neu ist auch, dass ich keine Escape-Zeichen mehr benutze, was aber durchaus möglich ist. "\n" wurde beispielsweise mit "endl" ersetzt.

Der größte Unterschied liegt aber jetzt darin, dass man keine Ausgabefunktion benutzt, sondern das Objekt "cout" und das jede Ausgabe serialisiert, also der "<<" Operator benutzt wird. Man schiebt buchstäblich das Auszugebende in das Objekt rein und es kümmert sich um den Rest.

Es ist außerdem möglich, auch hier die Ausgaben zu formatieren. Dies sieht dann so aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
#include <iostream>
using namespace std;

// ...

cout << 1.5 << endl;
cout.width(4);
cout << 1.5 << endl;
cout.width(5);
cout << 1.5 << endl;
					

Ausgabe:

1.5
 1.5
   1.5
		

Zum manipulieren der Breite der Ausgabe, muss man also im Vorfeld eine extra Funktion aufrufen. Des Weiteren hat man keinerlei Einfluss auf die Darstellung von Nachkommastellen, denn da werden immer nur so viele Stellen angegeben, wie nötig. Es ist also nicht möglich "1,0" auszugeben.

Für welche Form der Ausgabe Sie sich entscheiden überlasse ich Ihnen, allerdings handelt man sich mit "cout" einige Nachteile ein bzw. man ist nicht mehr so flexibel. Wie gezeigt hat man keinen Einfluss auf Gleitkommazahlen, des Weiteren ist die Syntax nicht unbedingt übersichtlicher und man kann es nicht erreichen, dass eine Variable in einem anderen Format dargestellt wird. Hierfür muss man explizit ein Typecast benutzen. Der größte Kritikpunk ist allerdings der starre Aufbau der Ausgabe. Gerade wenn es um Internationalisierung (l14n) geht, also ein Programm Ausgaben in mehreren Sprachen haben soll, bekommt man zunehmend Schwierigkeiten. Feste Wörter kann man in Variablen speichern und die zusammen puzzeln. Soll aber in die Ausgabe noch ein dynamisch berechneter Wert einfließen, der im deutschen wo anders im Satz steht als im englischen, hat man schon mehr zu kämpfen. Aus diesen genannten Gründen, werde ich im weiteren Verlauf meines Buches nur die Funktion "printf" benutzen. Zudem werde ich Ihnen später noch weitere Funktionen zeigen, welche eine ähnliche Syntax benutzen wie "printf" und wenn Sie den Umgang dieser Funktion beherrschen, brauchen Sie sich an nichts Neues zu gewöhnen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

2.5 Eingaben mit cin

2.5 Eingaben mit cin

Eine weitere Möglichkeit Werte einzulesen, ist "cin" (gesprochen C-In). Sie funktioniert ähnlich wie "cout", nur mit dem kleinen Unterschied, dass man jetzt den ">>" Operator benutzt. Das was das Objekt "cin" durch die Tastatur einlesen lässt, wird buchstäblich in die entsprechende rechts stehende Variable rein geschoben.

Das Schema sieht so aus: cin >> <Variable> [>> <Variable> ...]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
					
int  iZahl;
char cZeichen;

// Wartet bis der Anwender eine natürliche Zahl eingegeben hat
// und ENTER gedrückt hat
cin >> iZahl;

// Wartet bis Anwender eine Zahl oder Buchstaben eingegeben hat
// und ENTER gedrückt hat
cin >> cZeichen;
					

Auch hier gibt es wieder Vor - und Nachteile gegenüber der Funktion "scanf". Sehr positiv ist, dass der Variablentyp wieder automatisch erkannt wird (was aber wie gesagt auch Nachteile haben kann). Allerdings benötigt man jetzt keinen "&" Operator. Sie werden sich jetzt sagen, dass das doch gut bzw. einfacher ist. Dies mag zwar stimmen, aber warum das so ist, muss ich Ihnen an dieser Stelle noch vorenthalten. Wenn ich dann später das Kapitel mit den Funktionen und benutzerdefinierten Operatoren besprochen habe, werde ich noch einmal kurz auf "cin" und "cout" zurückkommen. Da ich Ihnen also hiermit wieder ein Mittel in die Hand drücke, was Sie nicht zu 100% verstehen, rate ich eher dazu, die Funktion "scanf" zu benutzen, zumal Sie dadurch üben, wie man an die Adresse einer Variable heran kommt (was dann später bei den Zeigern intensiv benutzt werden muss). Ein weiterer klarer Vorteil ist, dass man sich mit "cin" nicht um den Tastaturpuffer kümmern muss.

Zum Seitenanfang
Zum Inhaltsverzeichnis

2.2 Escapezeichen

2.2 Escapezeichen

Dem aufmerksamen Leser ist in den vorhergehenden Beispielen bestimmt etwas aufgefallen. Ich spreche von den Escapezeichen oder auch Escapesequenzen genannt, die u.a. für Formatierungen sorgen. Sie werden mit einem "\" eingeleitet. Hier mal die Wichtigsten:

Mehr dazu finden Sie in der Online-Hilfe zu "printf"

Zum Seitenanfang
Zum Inhaltsverzeichnis

© Copyright by Thomas Weiß, 2009 - 2012