Benutzer:Dirk Huenniger/cpp
Wie lese ich dieses Buch?
[Bearbeiten]Egal, ob Sie schon C++ können, eine andere Programmiersprache beherrschen oder kompletter Programmieranfänger sind: Wenn Sie in C++ programmieren wollen, werden Sie mit diesem Buch etwas anfangen können. Es steht Ihnen frei, das Buch ganz normal von Anfang an zu lesen oder sich die Kapitel herauszupicken, die Sie interessieren. Allerdings sollten Sie beim Herauspicken darauf achten, dass Sie den Stoff der vorherigen Kapitel beherrschen, andernfalls werden Sie wahrscheinlich Schwierigkeiten haben, das Kapitel Ihres Interesses zu verstehen.
Dieses Buch gliedert sich in verschiedene Abschnitte, von denen jeder einen eigenen Schwierigkeitsgrad und entsprechend eine eigene Zielgruppe hat. Die meisten Abschnitte vermitteln Wissen über C++ oder sollen die praktische Anwendung zeigen, um das bisherige Wissen zu festigen. Andere weisen auf Besonderheiten und „Stolpersteine“ hin oder vermitteln Hintergrundwissen.
- Jeder Abschnitt enthält eine Anzahl von Kapiteln, welche einen komplexeren Zusammenhang erklären. Die Abschnitte sind in sich abgeschlossen, setzen aber voraus, dass Sie den Stoff der vorherigen Abschnitte verstanden haben.
- Am Ende jedes Kapitels stehen meistens ein paar Fragen und/oder Aufgaben, die Ihnen helfen sollen zu überprüfen, ob Sie verstanden haben, was im entsprechenden Kapitel stand. Die Antworten auf die Fragen lassen sich einfach ausklappen, für die Aufgaben gibt es je einen Verweis auf eine Seite mit einer Musterlösung. Diese Musterlösung ist natürlich nicht die einzige Variante, um die Aufgabe korrekt zu erfüllen, sie dient lediglich als Beispiel, wie Sie es machen könnten. Zu beachten ist allerdings, dass das im Kapitel erworbene Wissen genutzt werden sollte, um die Aufgabe zu erfüllen.
- Jeder Abschnitt schließt mit einer Zusammenfassung ab, die das vermittelte Wissen kurz und bündig auf den Punkt bringt. Wer schnell etwas nachschlagen will, kann sich in der Regel an die Zusammenfassungen halten. Auf Abweichungen von dieser Regel wird explizit hingewiesen.
„Für Programmieranfänger“ ist der nächste Abschnitt. Wenn Sie schon in einer anderen Programmiersprache Programme geschrieben haben, können Sie diesen Abschnitt getrost überspringen. Es geht nicht speziell um C++, sondern eher darum, zu begreifen, was überhaupt eine Programmiersprache ist und wie man dem Rechner sagen kann, was er zu tun hat.
Wenn Sie eine andere Programmiersprache schon sehr sicher beherrschen, reicht es wahrscheinlich, wenn Sie von den nächsten Abschnitten nur die Zusammenfassungen lesen, aber es kann Ihnen in keinem Fall schaden, auch die kompletten Abschnitte zu lesen. Die Zusammenfassungen sollen Ihnen zwar einen groben Überblick geben, was in diesem Abschnitt vermittelt wurde, aber es ist nun einmal nicht möglich, zur gleichen Zeit kurz und vollständig zu sein.
Sofern Sie C++ schon sicher beherrschen, können Sie sich einfach ein Kapitel aussuchen, es lesen und wenn nötig, in einem früheren Kapitel nachschlagen. Umsteigern ist dieses Verfahren hingegen nicht zu empfehlen, weil C++ eben doch etwas anderes ist als die meisten anderen Programmiersprachen.
In jedem Fall hoffen wir, dass dieses Buch Ihnen hilft, Programmieren zu lernen und Ihre Fähigkeiten zu verbessern, aber vergessen Sie niemals, dass ein Buch nur eine Stütze sein kann. Programmieren lernt man durchs Programmieren!
Es war einmal…
[Bearbeiten]C++ wurde von Bjarne Stroustrup ab 1979 in den AT&T Bell Laboratories entwickelt. Ausgangspunkt waren Untersuchungen des UNIX-Betriebssystemkerns in Bezug auf verteiltes Rechnen.
Auf die Idee für eine neue Programmiersprache war Stroustrup schon durch Erfahrungen mit der Programmiersprache Simula im Rahmen seiner Doktorarbeit an der Cambridge University gekommen. Simula erschien zwar geeignet für den Einsatz in großen Software-Projekten, die Struktur der Sprache erschwerte aber die für viele praktische Anwendungen erforderliche Erzeugung hocheffizienter Programme. Demgegenüber ließen sich effiziente Programme zwar mit der Sprache BCPL schreiben, für große Projekte war BCPL aber wiederum ungeeignet.
Mit den Erfahrungen aus seiner Doktorarbeit erweiterte Stroustrup nun die Programmiersprache C um ein Klassenkonzept, für das die Sprache Simula-67 das primäre Vorbild war. Die Wahl fiel auf die Programmiersprache C, eine Mehrzwecksprache, die schnellen Code produzierte und einfach auf andere Plattformen zu portieren war. Als dem Betriebssystem UNIX beiliegende Sprache hatte C außerdem eine nicht unerhebliche Verbreitung. Zunächst fügte er der Sprache Klassen (mit Datenkapselung) hinzu, dann abgeleitete Klassen, ein strengeres Typsystem, Inline-Funktionen und Standard-Argumente.
Während Stroustrup „C with Classes“ („C mit Klassen“) entwickelte (woraus später C++ wurde), schrieb er auch cfront, einen Compiler, der aus C with Classes zunächst C-Code als Zwischenresultat erzeugte. Die erste kommerzielle Version von cfront erschien im Oktober 1985.
1983 wurde C with Classes in C++ umbenannt. Erweiterungen darin waren: virtuelle Funktionen, Überladen von Funktionsnamen und Operatoren, Referenzen, Konstanten, änderbare Freispeicherverwaltung und eine verbesserte Typüberprüfung. Die Möglichkeit von Kommentaren, die an das Zeilenende gebunden sind, wurde wieder aus BCPL übernommen (//
).
1985 erschien die erste Version von C++, die eine wichtige Referenzversion darstellte, da die Sprache damals noch nicht standardisiert war. 1989 erschien die Version 2.0 von C++. Neu darin waren Mehrfachvererbung, abstrakte Klassen, statische Elementfunktionen, konstante Elementfunktionen und die Erweiterung des Schutzmodells um protected. 1990 erschien das Buch „The Annotated C++ Reference Manual“, das als Grundlage für den darauffolgenden Standardisierungsprozess diente.
Relativ spät wurden der Sprache Templates, Ausnahmen, Namensräume, neuartige Typumwandlungen und boolesche Typen hinzugefügt.
Im Zuge der Weiterentwicklung der Sprache C++ entstand auch eine gegenüber C erweiterte Standardbibliothek. Erste Ergänzung war die Stream-I/O-Bibliothek, die Ersatz für traditionelle C-Funktionen wie zum Beispiel printf()
und scanf()
bietet. Eine der wesentlichen Erweiterungen der Standardbibliothek kam später durch die Integration großer Teile der bei Hewlett-Packard entwickelten Standard Template Library (STL) hinzu.
Nach jahrelanger Arbeit wurde schließlich 1998 die endgültige Fassung der Sprache C++ (ISO/IEC 14882:1998) genormt. 2003 wurde ISO/IEC 14882:2003 verabschiedet, eine Nachbesserung der Norm von 1998.
Die vorhandenen Performance-Probleme der Sprache C++, die Rechenzeit und Speicherplatz betreffen, wurden auf hohem Niveau zusammen mit Lösungen im Technical Report ISO/IEC TR 18015:2006 diskutiert. Das Dokument ist zum Download von ISO freigegeben. (http://www.open-std.org/jtc1/sc22/wg21/docs/TR18015.pdf)
Die aktuelle Version wurde am 11. Oktober 2011 als ISO/IEC 14882:2011 von der ISO veröffentlicht. Inoffiziell wird der Name C++11 verwendet, der Entwurf lief lange Zeit unter der inoffiziellen Bezeichnung C++0x. Die Entwürfe des C++-Standards können unter http://www.open-std.org/JTC1/SC22/WG21/ heruntergeladen werden. Das Dokument http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3337.pdf stimmt mit C++11 bis auf minimale Änderungen überein.
Der offizielle Standard muss käuflich erworben werden, bei der ISO ist er für 238 Schweizer Franken zu haben, beim DIN zahlt man 659,60 €, beim ANSI steht der Preis bei 285 US-Dollar, bzw. 761 US-Dollar wenn man die britische Ausgabe kauft. Das INCITS verkauft ihn unter der Beizeichnung INCITS/ISO/IEC 14882-2012 für 30 US-Dollar. Es lohnt sich also die Preise der unterschiedlichen Herausgeber bzw. Anbieter zu vergleichen.
Der Name „C++“
[Bearbeiten]Der Name ist eine Wortschöpfung von Rick Mascitti und wurde zum ersten Mal im Dezember 1983 benutzt. Der Name kommt von der Verbindung der Vorgängersprache C und dem Inkrement-Operator „++“, der den Wert einer Variable um eins erhöht.
Weiterentwicklung der Programmiersprache C++
[Bearbeiten]Um mit den aktuellen Entwicklungen der sich schnell verändernden Computer-Technik Schritt zu halten, aber auch zur Ausbesserung bekannter Schwächen, erarbeitet das C++-Standardisierungskomitee derzeit die nächste größere Revision von C++, die inoffiziell mit C++16 abgekürzt wird, worin die Ziffernfolge eine grobe Einschätzung des möglichen Erscheinungstermins andeuten soll. Derzeit ist 2016 als Termin im Gespräch, doch ist eine Verschiebung um wenige Jahre durchaus nicht unwahrscheinlich.
Erweiterung der Programmbibliothek
[Bearbeiten]Im April 2006 gab das C++-Standardisierungskomitee den so genannten ersten technischen Report (TR1) heraus, eine nichtnormative Ergänzung zur damals gültigen, 1998 definierten Bibliothek, mit der Erweiterungsvorschläge vor einer möglichen Übernahme in die C++-Standardbibliothek auf ihre Praxistauglichkeit hin untersucht werden sollen.
Enthalten sind im TR1 u.a. reguläre Ausdrücke, verschiedene intelligente Zeiger, ungeordnete assoziative Container, eine Zufallszahlenbibliothek, Hilfsmittel für die C++-Metaprogrammierung, Tupel sowie numerische und mathematische Bibliotheken. Die meisten dieser Erweiterungen stammen aus der Boost-Bibliothek, woraus sie mit minimalen Änderungen übernommen wurden. Außerdem sind viele Bibliothekserweiterungen der 1999 überarbeiteten Programmiersprache C (C99) in einer an C++ angepassten Form enthalten.
Mit Ausnahme der numerischen und mathematischen Bibliotheken wurde die Übernahme aller TR1-Erweiterungen in die aktuelle Sprachnorm C++11 vom C++-Standardisierungskomitee beschlossen.
In Vorbereitung auf den nächsten Standard wird die Erweiterung im Bereich der Netzwerke, des Dateisystems und der Verwendung von XML und möglicherweise auch HTML diskutiert.
Compiler
[Bearbeiten]Was passiert beim Übersetzen
[Bearbeiten]Als Übersetzen oder compilieren bezeichnet man den Vorgang, den als Text geschriebenen Quellcode (engl. source code) in eine Sprache zu überführen, die der Computer versteht. Also in Einsen und Nullen. Dies passiert bei C++ in drei Schritten. Um diese zu verstehen, ist zunächst ein Verständnis für die Dateistruktur von C++-Quellcode erforderlich.
Grob gesagt gibt es zwei Kategorien von Quellcode-Dateien in C++: Quelldateien und Headerdateien. Quelldateien sollten den Code enthalten, der die eigentlichen Anweisungen enthält und somit das Verhalten des Programms beschreibt. Headerdateien sollten Daten enthalten, welche die Struktur des Programms beschreiben. Um zu definieren, wie das Verhalten mit der Struktur verknüpft ist, bindet typischerweise jede Quelldatei mehrere Headerdateien ein.
Dies ist wie gesagt eine sehr grobe Beschreibung des Aufbaus, die jedoch beim Grundverständnis helfen kann. Damit können wir nun zum Übersetzungsprozess kommen.
Als erstes ist der Präprozessor (engl. Preprocessor) dran, dessen Hauptaufgabe es ist, alle benötigten Headerdateien in die Quellcodedateien zu kopieren. Auf diesen vom Präprozessor zusammengefügten Quelldateien arbeitet nun der Compiler. Er erzeugt aus jeder dieser Dateien eine sogenannte Objektdatei (engl. object file), die nicht mehr C++, sondern Maschinensprache enthält. Um aus diesen Objektdateien ein ausführbares Programm zu machen, müssen die Objektdateien schließlich vom Linker gebunden werden. (denglisch linken, engl. linking)
Das Ganze noch mal in Stichpunkten:
- Präprozessor: in jede Quelle alle nötigen Headern einfügen
- Compiler: jede vom Präprozessor erstellte C++-Quelle in Maschinensprache übersetzen
- Linker: in Maschinensprache übersetzte Dateien zu einem einzigen Programm zusammensetzen
Werkzeuge
[Bearbeiten]Wie es immer mit Software ist, stellt sich am Anfang die Frage, auf welcher Plattform bzw. welchem Betriebssystem man arbeitet. Ich (Prog) verwende seit vielen Jahren Linux und bin gerade beim Thema Softwareentwicklung sehr zufrieden damit. Auch Mac OS X soll sich sehr gut zum Programmieren eignen, wobei ich hier nur Erfahrungsberichte kenne. Microsoft Windows hingegen macht mir das Leben jedes Mal zur Hölle, wenn ich gezwungen bin dies zu verwenden. Immerhin ist die Situation in den letzten Jahren etwas besser geworden, da Microsoft sich beispielsweise um Tools zur automatisierten Einrichtung von Drittbibliotheken bemüht und generische Werkzeuge wie Git und die dazugehörigen Plattformen wie github.com oder gitlab.com unter allen Betriebssystemen funktionieren.
Es gibt natürlich eine ganze Reihe von C++-Compilern, und wenn ich im Folgenden von Compilern spreche, meine ich auch Präprozessor und Linker, da diese drei Komponenten üblicherweise in einem Programm zusammengefasst sind. Die drei verbreitetsten Compiler sind GCC (OpenSource), clang (OpenSource) und Visual C++ (proprietär). Visual C++ darf in der kostenfreien Variante von Visual Studio nur für private Zwecke verwendet werden. GCC und clang dürfen auch kommerziell eingesetzt werden.
- clang (hohe Standardkonformität, gute Fehlermeldungen)
- Betriebssysteme: Linux, Mac OS X, Windows
- GCC (hohe Standardkonformität)
- Betriebssysteme: Linux, Mac OS X, Windows
- Visual C++ (schlechtere Standardkonformität und wesentlich langsamer als clang und GCC, holt aber langsam auf)
- Betriebssysteme: Windows
Ich entwickle gern »direkt von der Kommandozeile« aus, wobei ich für größere Projekte Boost.Build einsetze. Auf diese Weise hat man eine hohe Flexibilität und maximale Kontrolle über alle Vorgänge. Meine Entwicklungsumgebung besteht also im Wesentlichen aus einem Texteditor (ich verwende unter Linux Kate, den Standardeditor des KDE-Desktops, unter Windows Notepad++) und eine Kommandozeile, um den Compiler aufzurufen.
Die meisten Entwickler bevorzugen eine Integrierte Entwicklungsumgebung (kurz IDE) die ihnen den direkten Kontakt mit dem Compiler abnimmt. Speziell wenn Sie unter Windows arbeiten, ist eine IDE dringend zu empfehlen, weil die Kommandozeile von Windows eine absolute Katastrophe ist.
Im Folgenden eine kurze Liste der aus meiner Sicht wichtigsten IDEs:
- Visual Studio Code (nicht verwechseln mit Visual Studio!)
- Betriebssysteme: Linux, Mac OS X, Windows
- Compiler: GCC, clang, Visual C++
- QtCreator
- Betriebssysteme: Linux, Mac OS X, Windows
- Compiler: GCC, clang, Visual C++
- XCode (von Apple)
- Betriebssysteme: Mac OS X
- Compiler: clang
- Visual Studio (von Microsoft)
- Betriebssysteme: Windows
- Compiler: Visual C++, (clang – experimentell)
Ich empfehle sehr den QtCreator als IDE, da er Betriebssystem- und Compiler-Übergreifend eingesetzt werden kann und obendrein vergleichsweise einfach in der Bedienung ist. Windows-Nutzer können eine Version herunterladen, in die der GCC für Windows bereits integriert ist. Alternativ kann auch erst Visual Studio installiert werden und anschließend der QtCreator, der dann den Microsoft Compiler verwendet.
Wenn Sie QtCreator einsetzen und als Compiler GCC (Voreinstellung) oder clang verwenden, dann müssen Sie eventuell explizit den C++-Standard (C++11, C++14, C++17 …) angeben. Da sich das Vorgehen hierzu ändert, verwenden Sie bitte eine Suchmaschine um herauszufinden, wie dies geht.
Unter Mac OS X verwenden die meisten Entwickler wohl XCode. Ich kenne dazu wieder nur Erfahrungsberichte, die sind allerdings in der Regel positiv.
Visual Studio Code verwende ich erst seit kurzem und bislang nicht für C++. Das Programm ist recht gut, eine Empfehlung kann ich aufgrund mangelnder eigener Erfahrung zur Zeit noch nicht aussprechen.
Visual Studio ist als IDE ganz gut, auch wenn es einige Macken hat, die sich auch gerne mal von PC zu PC unterscheiden können. Es ist sehr umfangreich, was allerdings auch zu einer recht komplexen Bedienung führt. Gerade für Anfänger kann es daher schwierig zu bedienen sein. Der größte Nachteil von Visual Studio ist allerdings die Festlegung auf Visual C++ als Compiler. Fakt ist, dass Visual Studio in der Industrie weit verbreitet ist.
Falls Sie Windows verwenden und bislang keine Erfahrung mit Programmierung haben, kann ich empfehlen, VirtualBox zu installieren und darin dann ein Linux (z. B. Kubuntu Long Term Support) laufen zu lassen. Das erlaubt es clang oder GCC ohne die massiven Schwierigkeiten zu verwenden, die man unter Windows mit diesen Compiler und auch in einigen Aspekten der eigentlichen Entwicklung hat.
Was genau VirtualBox ist und wie es funktioniert finden Sie mit einem (Video)-Tutorial Ihrer Wahl sicher schnell heraus. Die Felsen die Windows einem in den Weg legt, kompensieren nach meiner Erfahrung recht schnell den Aufwand, ein paar Sachen rund um die Nutzung von Linux lernen zu müssen. Allein die Kommandozeile von Linux ist im Vergleich zur "CMD" diesen Aufwand wert.
GCC & clang
[Bearbeiten]GCC steht für Gnu Compiler Collection und ist eine Sammlung von freien Compilern, darunter auch den C++-Compiler g++
. Der C++-Compiler clang++
gehört hingegen zum LLVM-Projekt. Achten Sie bei clang
auf das ++
, da clang
ohne ++
(ein C-Compiler) zwar ebenfalls C++ übersetzen kann, wenn man den C++-Standard angibt, aber dann meist beim Linken Probleme macht.
Sowohl GCC als auch LLVM stellen auch Compiler für viele andere Sprachen bereit. Beide sind weit verbreitet. GCC ist deutlich älter, während sich LLVM in den letzten Jahren immer weiter steigender Beliebtheit erfreut und die Compiler der GCC inzwischen in einigen Punkten überholt hat. Beide sind für viele Betriebssysteme und Prozessorarchitekturen verfügbar, wobei GCC hier noch leicht die Nase vorn hat. Die wichtigsten Kommandozeilen-Parameter sind bei beiden identisch, so dass es egal ist, welchen Sie primär verwenden. Sie können jederzeit mal eben auch mit dem jeweils anderen übersetzen, um »eine zweite Meinung« zu Ihrem Code zu bekommen, falls Probleme auftreten.
Der größte Unterschied zwischen den beiden, ist ein Politischer. Die GCC steht unter der GNU General Public License (GNU GPL), LLVM-Projekte stehen unter BSD- und MIT-ähnlichen Lizenzen. Das heißt, beide sind Freie Software und jeder ist berechtigt, den Quellcode einzusehen, zu verändern und natürlich auch beliebigen Quellcode mit den Compilern zu übersetzen. Sie dürfen also sowohl private Projekte, als auch kommerzielle Projekte ohne Einschränkung mit diesen Compilern übersetzen. Bei Visual Studio benötigen Sie hingegen eine gekaufte Lizenz, wenn Sie kommerzielle Produkte damit übersetzen wollen.
Allerdings müssen Änderungen am Quellcode von GCC wiederum unter die GNU GPL gestellt werden. Änderungen an LLVM-Projekten dürfen unter eine beliebige Lizenz gestellt werden. Für Sie als Entwickler, der die Compiler nur verwendet, und keine Änderungen daran vornimmt, spielt das keine Rolle, aber für Firmen, die den Compiler in ihre Produkte integrieren wollen. Sie sind im Falle von GCC gezwungen, mit der Community zusammenzuarbeiten, und können ihre eigenen Änderungen an GCC nicht vor der Welt verstecken, sondern müssen sie wieder freigeben.
Auf fast allen GNU/Linux ist GCC üblicherweise der Standardcompiler. Unter Mac OS X und FreeBSD (ein weiteres freies Unixoides Betriebssystem) ist clang inzwischen der Standardcompiler. Unter Windows gibt es in dem Sinne keinen Compiler, weil es unter Windows nie üblich war, dass Benutzer selbst Programme übersetzten. Dies liegt vor allem daran, dass Windows, verglichen mit dem Stammbaum der Unixoiden Systeme (BSD, Mac OS X, Linux & viele weitere) ein sehr junges Betriebssystem ist und zum Zeitpunkt der Einführung Software schon nicht mehr im Quellcode ausgeliefert wurde.
Aber jetzt ist Schluss mit der Theorie. Schauen wir uns erst einmal an, wie g++ benutzt wird. Im folgenden wird angenommen, dass eine Datei mit dem Namen prog.cpp vorhanden ist. In dieser Datei könnte zum Beispiel folgendes stehen:
Dieses Programm müssen Sie noch nicht verstehen, im Kapitel Hallo, du schöne Welt! wird es erklärt. Nun geben Sie auf der Kommandozeile (GNU/Linux: Shell, Windows: Eingabeaufforderung) folgendes ein:
g++ prog.cpp
g++ ist der Name des Programms, welches aufgerufen wird, also der Compiler g++. prog.cpp ist der Name der Datei, die kompiliert werden soll. Wenn Sie diesen Befehl ausführen, sehen Sie entweder Fehlermeldungen auf dem Bildschirm, oder aber Sie bekommen eine Datei mit dem Namen a.out. Manchmal bekommen Sie auch sogenannte „warnings“, also Warnungen. Bei diesen Warnungen wird der Code zwar kompiliert, aber Sie sollten versuchen, warnungsfreie Programme zu schreiben. Ein Beispiel für eine Warnung könnte z.B. sein:
g++ prog.cpp prog.cpp: In function `int main()': prog.cpp:17: warning: comparison between signed and unsigned integer expressions
In diesem Beispiel würde die Warnung bedeuten, dass wir eine Zahl ohne Vorzeichen (unsigned
) und eine mit Vorzeichen (signed
) vergleichen, und zwar innerhalb der Funktion `int main()
', genauer gesagt in Zeile 17. Was unsigned
und signed
ist, erfahren Sie im Kapitel Variablen, Konstanten und ihre Datentypen.
Es gibt auch einige, zum Teil hilfreiche Warnungen, die nicht angezeigt werden. Um diese zu sehen, müssen Sie die Option -Wall hinzufügen.
g++ -Wall prog.cpp
Um sich noch mehr Warnungen anzeigen zu lassen (-Wall zeigt auch nicht alle), können Sie auch noch -Wextra benutzen:
g++ -Wall -Wextra prog.cpp
Es wird empfohlen, diese Möglichkeit zu nutzen, vor allem wenn Sie auf der Suche nach Fehlern in Ihrem Programm sind.
Möchten Sie, dass das fertige Programm einen anderen Namen als a.out hat, so können Sie es natürlich jedesmal umbenennen. Dies ist aber umständlich und somit nicht zu empfehlen. Ein viel eleganterer Weg ist das Benutzen der Option -o.
g++ -o tollername prog.cpp
Der Dateiname nach der Option -o gibt an, in welche Datei das kompilierte Programm gespeichert werden soll. So erhalten Sie in diesem Beispiel die ausführbare Datei tollername.
Sollten Sie sich einmal nicht mehr erinnern, was eine bestimmte Funktion bewirkte oder wie sie hieß, so können Sie g++ einfach nur mit der Option --help aufrufen.
g++ --help
g++ gibt dann eine kurze Auflistung der Optionen aus. Wer gerne eine detailliertere Version hätte, kann unter GNU/Linux auch das Manualprogramm „man“ benutzen:
man g++
Ausführlichere und übersichtlichere Dokumentation, die nicht nur die Kommandozeilenoptionen vorstellt, sind im Dokumentationssystem info
zu finden, das auf vielen GNU/Linux- und BSD-Systemen installiert ist:
info g++
Für Fortgeschrittenere
[Bearbeiten]Jetzt sollten Sie erst einmal den Rest des Kapitels überspringen und mit einigen der folgenden Buchkapitel weitermachen, denn für den Rest ist ein wenig Vorwissen sehr hilfreich. Sobald Sie mit den ersten paar Kapiteln fertig sind, können Sie hierher zurückkehren und den Rest lesen.
Was passiert überhaupt, wenn Sie g++ aufrufen? Nun, als erstes wird der Code vom Präprozessor durchgeschaut und bearbeitet. (Natürlich bleibt Ihre Quelldatei, wie sie ist.) Dabei werden beispielsweise Makros ersetzt und Kommentare gelöscht. Dieser bearbeitete Code wird dann vom Compiler in die Assemblersprache übersetzt. Die Assemblersprache ist auch eine Programmiersprache, welche aber nur die Maschinensprache so darstellt, dass ein Mensch sie (leichter) lesen kann. Schließlich wird diese Assemblersprache von einem Assembler in Maschinencode umgewandelt. Zum Schluss wird noch der Linker aufgerufen, der die einzelnen Programmdateien und die benutzten Bibliotheken "verbindet".
Darum, dass dies alles in korrekter Reihenfolge richtig ausgeführt wird, brauchen Sie sich nicht zu kümmern; g++ erledigt das alles für Sie. Allerdings kann es manchmal nützlich sein, dass das Programm noch nicht gelinkt wird, etwa wenn Sie mehrere Quelldateien haben. Dann kann man einfach dem g++ die Option -c mitgeben.
g++ -c -o prog.o prog.cpp
Durch diesen Aufruf erhalten wir eine Datei prog.o, die zwar kompiliert und assembliert, aber noch nicht gelinkt ist. Deswegen wurde die Datei auch prog.o genannt, da ein kompiliertes und assembliertes, aber nicht gelinktes Programm als Objektdatei vorliegt. Objektdateien bekommen üblicherweise die Endung *.o. Ohne die -o Option hätten Sie möglicherweise eine Datei gleichen Namens erhalten, aber das ist nicht absolut sicher. Es ist besser den Namen der Ausgabedatei immer mit anzugeben; so können Sie sicher sein, dass die ausgegeben Dateien auch wirklich den von Ihnen erwarteten Namen haben.
Bei g++ gibt es Unmengen von Optionen, mit denen Sie fast alles kontrollieren können. So gibt es natürlich auch eine Option, durch die kompiliert, aber nicht assembliert wird. Diese Option heißt -S.
g++ -S prog.cpp
Diese Option wird allerdings fast nie benötigt, es sei denn Sie interessieren sich dafür, wie Ihr Compiler Ihren Code in Assembler umsetzt. Die Option -E ist schon nützlicher. Mit ihr wird nur der Präprozessor ausgeführt:
g++ -E prog.cpp
So können Sie z. B. sehen, ob mit den Makros alles ordnungsgemäß geklappt hat oder die Headerdateien eventuell in einer von Ihnen unerwarteten Reihenfolge inkludiert wurden. Eine Warnung sollen Sie hier aber mit auf den Weg bekommen. Wenn der Präprozessor durchgelaufen ist, stehen auch alle mittels #include
eingebundenen Headerdateien der Standardbibliothek mit im Quelltext, die Ausgabe kann also ziemlich lang werden. Allein das Einbinden von iostream
produziert bei g++ 4.6.3 knapp 19 000 Zeilen Code. Welche Zeit es in Anspruch nimmt, den Quellcode nach dem Übersetzen in Assembler (Option -S) zu lesen, dürfte damit geklärt sein.
Sie sollten auch die Option -ansi kennen. Da g++ einige C++ Erweiterungen beinhaltet, die nicht im C++-Standard definiert sind oder sogar mit ihm im Konflikt stehen, ist es nützlich, diese Option zu verwenden, wenn Sie ausschließlich mit Standard-C++ arbeiten wollen. In der Regel ist das zu empfehlen, da sich solcher Code viel leichter auf andere Compiler portieren lässt. Im Idealfall müssten Sie gar keine Änderungen mehr vornehmen. Da aber weder g++ noch irgendein anderer Compiler absolut standardkonform sind, ist das selten der Fall. Ein Negativbeispiel für standardkonforme Compiler kommt immer mal wieder aus Redmond. Die dort ansässige Firma produziert in der Regel Produkte, die zu ihren selbstdefinierten Standards in einer bestimmten Version (also nicht mal untereinander) kompatibel sind. Beschweren Sie sich bitte nicht, wenn einige Programmbeispiele mit deren Compiler nicht kompiliert werden. Der Fairness halber soll trotzdem angemerkt werden, dass langsam aber doch sicher Besserung in Sicht ist.
g++ -ansi -o prog prog.cpp
Dies bewirkt, dass diese nicht standardkonformen Erweiterungen des g++-Compilers abgeschaltet werden. Solcher Code ist in der Regel die bessere Wahl, da er auch in Zukunft, wenn es neue Compiler oder Compiler-Versionen geben wird, noch mit ihnen übersetzt werden kann.
Möchten Sie eine Warnung erhalten, wenn nicht standardkonforme Erweiterungen von g++ verwendet werden, dann nutzen Sie die Option -pedantic:
g++ -pedantic -o prog prog.cpp
Um die Optimierung zuzuschalten, nutzen Sie die Option -Ox, wobei das x für eine Zahl von 1 bis 3 steht. 3 bedeutet stärkste Optimierung.
g++ -O3 -o prog prog.cpp
Programme, die mit Optimierung übersetzt wurden, sind kleiner und laufen schneller. Der Nachteil besteht in der höheren Zeit, welche für die Übersetzung an sich benötigt wird. Daher sollten Sie die Optimierung nur zuschalten, wenn Sie eine Programmversion erstellen, die Sie auch benutzen möchten. Beim Austesten während der Entwicklung ist es besser, ohne Optimierung zu arbeiten, da häufig kompiliert wird. Eine lange Wartezeit, nur um dann einen Programmdurchlauf zu machen, ist schlicht nervtötend.
Externe Bibliotheken benutzen
[Bearbeiten]Die C++-Standardbibliothek ist zwar eine wohl durchdachte und vielfach bewährte Bibliothek für Ihre Programme; allerdings ist sie kein Wundermittel für alle Probleme. Um auch andere Bibliotheken mit einem Programm nutzen zu können, bietet der Compiler spezielle Optionen an.
Headerdateien finden
[Bearbeiten]Damit die Quelldateien, die die externe Bibliothek benutzen, übersetzt werden können, muss der Präprozessor die Headerdateien finden. Mit dem Befehl cpp -v werden die Standardverzeichnisse ausgegeben, in denen nach Headerdateien gesucht wird. Befinden sich die Dateien der Bibliothek in einem anderen Verzeichnis, muss dieses mit der Option -I angegeben werden. Die imaginäre Bibliothek libfoo bietet z.B. eine Headerdatei foo.h im Verzeichnis /usr/include/libfoo an. Folgender Befehl wäre damit nötig:
g++ -I/usr/include/libfoo -o prog prog.cpp
Die Quelldatei kann die Headerdatei nun wie jede andere Datei der Standardbibliothek einbinden:
Mit Bibliothek linken
[Bearbeiten]Es reicht aber noch nicht aus, dass die Headerdateien eingebunden werden können. Der Linker muss den compilierten Code der Quelldateien zusammen mit der Bibliothek linken. Dafür bietet der Compiler die Option -l an, die den Namen der Bibliothek benötigt. Die Zeichenkette lib kann weggelassen werden, aus libfoo wird also einfach nur foo:
g++ -I/usr/include/libfoo -lfoo -o prog prog.cpp
Makefiles
[Bearbeiten]Was ist make?
[Bearbeiten]Bei make handelt es sich um ein Werkzeug, mit dem man die Abhängigkeiten eines Build Prozesses auflösen kann. Dieses Stück Software ist schon sehr alt und in unterschiedlichsten Implementierungen verfügbar, die verbreitesten sind wohl GNU make und BSD make. Leider sind die verschiedenen Varianten untereinander nicht ganz kompatibel.
Makefiles per Hand erstellen
[Bearbeiten]make kann sehr viel mehr, als hier beschrieben werden könnte, es folgt daher lediglich eine kurze Erläuterung, um was es eigentlich geht. In einem Makefile lassen sich Regeln beschreiben, wie bestimmte "Targets" (Ziele) erstellt werden können. Diese können von anderen Zielen oder Dateien abhängen.
Beispielsweise erstellt man eine Objektdatei aus einer Sourcedatei, indem man sie kompiliert. Dazu muss aber natürlich die Sourcedatei vorhanden sein. Eine solche Regel könnte zum Beispiel so aussehen:
hello.o: hello.c
$(CC) -c $(CFLAGS) hello.c
Die erste Zeile besagt, dass zum Erstellen von hello.o
die Datei hello.c
benötigt wird. Die Zweite sagt aus, wie das Erstellen von hello.o
zu bewerkstelligen ist. Variablen werden mit $
eingeleitet. So beinhaltet zum Beispiel $(CC)
in der Regel den Namen des C Compilers.
Derartige Regeln kann man auch mit Wildcards versehen, so kann man eine Regel erstellen, mit der man ausdrücken kann, wie generell aus einer *.c eine *.o Datei zu erstellen ist:
%.o: %.c
$(CC) -c $(CFLAGS) $<
Dabei steht die spezielle Variable $<
für den Namen der tatsächlichen Source Datei, wie zum Beispiel hello.c
.
Beispiel:
CC = gcc
OBJECTS = cbg.o
LIBS = -lcurl
CFLAGS = -Wall -O2
BINDIR = $(DESTDIR)/usr/bin
NAME = cbg
cbg: $(OBJECTS)
$(CC) -o $(NAME) $(OBJECTS) $(LIBS)
%.o: %.c
$(CC) -c $(CFLAGS) $<
install:
install --mode=755 $(NAME) $(BINDIR)/
clean:
rm *.o $(NAME)
uninstall:
rm $(BINDIR)/$(NAME)
Damit ist es nun möglich, die einzelnen Ziele zu erstellen:
make install make clean make uninstall
Wird kein Ziel angegeben, so wird das erste Ziel erstellt, in obigen Beispiel also cbg.
Automake
[Bearbeiten]Boost Build
[Bearbeiten]Eine sehr gute Alternative zu Makefiles. http://www.boost.org/doc/tools/build/index.html
GUIs und C++
[Bearbeiten]Was ist ein GUI?
[Bearbeiten]GUI kommt vom englischen „Graphical User Interface“, was soviel heißt wie „Graphische Benutzerschnittstelle“. Grob gesagt ist es das, was der Benutzer von den meisten Programmen zu sehen bekommt. Also: Die Menüleiste, Textfelder, Buttons u.s.w.
Weiterhin gibt es natürlich noch das „Command Line Interface“ (kurz CLI, zu Deutsch "Kommandozeilenschnittstelle"), das vielen als Fenster mit schwarzem Hintergrund und mit weißer Schrift bekannt ist.
C++ und GUIs
[Bearbeiten]C++ bringt von Haus aus kein GUI mit, da es als hardwarenahe plattformunabhängige Sprache erstellt wurde und GUIs stark vom verwendeten Betriebssystem abhängen. Dieser „Mangel“ könnte einige Neueinsteiger vielleicht auf den ersten Blick abschrecken, da sie sich einen Einstieg in C++ oder gar in die gesamte Programmierung mit vielen Buttons, Textfeldern, Statusanzeigen und Menüeintragen erhofft haben.
Wer jedoch einmal damit angefangen hat, kleinere Programme zu schreiben, wird schnell merken, dass der Reiz hier nicht von bunten Farben und blinkenden Symbolen ausgeht. Ganz im Gegenteil. Gerade für den Anfänger ist es am besten, wenn die eigentliche Funktion nicht durch Tonnen von "Design-Code" überdeckt und unleserlich gemacht wird. Hat man erst einmal die Grundlagen erlernt, so ist es nicht schwer auch ansprechende Oberflächen zu erstellen.
Es gibt eine ganze Reihe von Bibliotheken, die eine weitgehend plattformübergreifende Programmierung mit C++ als Sprache ermöglichen. Aber auch die Programmierung über die API (für engl. „application programming interface“, deutsch: „Schnittstelle zur Anwendungsprogrammierung“) des spezifischen Betriebssystems ist natürlich möglich.
Einsatz von GUI
[Bearbeiten]Sobald man mit C++ dann doch ein GUI programmieren will, sollte auf externe Bibliotheken zurückgegriffen werden. Alle GUIs erfordern ein Verständnis der C++ Grundlagen. Nachfolgend sind einige Bibliotheken zur GUI-Programmierung aufgelistet. Viele der Bibliotheken lassen sich auch mit anderen Programmiersprachen einsetzen.
Qt
[Bearbeiten]Qt ist eine leistungsstarke plattformübergreifende Klassenbibliothek mit Meta-Object Compiler (genannt: moc), die von der finnischen Firma Trolltech entwickelt wurde. Trolltech wurde 2008 von Nokia übernommen, welches jedoch wirtschaftliche Schwierigkeiten hatte und in der Folge Qt 2011 an das finnische Softwareunternehmen Digia verkaufte.
Die Entwicklung wird inzwischen vom Qt Project vorangetrieben, so dass jeder sich daran beteiligen kann. Die Klassenbibliothek ist unter der GNU General Public License (GPL) und der GNU Lesser General Public License (LGPL) lizenziert. Es gibt außerdem eine proprietäre Lizenz von Digia, die allerdings lediglich zusätzlichen technischen Support beinhaltet.
Speziell aufgrund der Veränderungen, die mit Qt 5 und C++11 Einzug in das Framework halten, ist Qt eine der mächtigsten Bibliotheken für C++. Ein weiterer Vorteil von Qt ist die vollständig freie Entwicklungsumgebung Qt Creator, die insbesondere Anfängern das Programmieren leichter macht.
Das Buch „Qt für C++-Anfänger“ gibt einen Einstieg in die Programmierung mit Qt. Es behandelt nicht viele Themen und ist somit wirklich nur zum Kennenlernen des Frameworks geeignet. Dafür sind die vorhandenen Seiten aber auch gut gefüllt.
GTK+
[Bearbeiten]Das GIMP-Toolkit (abgekürzt: GTK+) ist eine freie Komponentenbibliothek, die in C geschrieben ist und unter der GNU Lesser General Public License (LGPL) steht. Um dieses Framework in C++ zu nutzen gibt es ein Framework namens Gtkmm. Wie Qt ist es für viele Plattformen verfügbar.
wxWidgets
[Bearbeiten]wxWidgets ist ein auf C++ basierendes Open-Source-Framework zur plattformunabhängigen Entwicklung von Anwendungen mit grafischer Benutzeroberfläche (GUI). Die wxWidgets-Lizenz ist eine leicht modifizierte LGPL und erlaubt daher ebenfalls die freie Verwendung in proprietärer und freier Software und den weiteren Vertrieb unter einer selbst gewählten Lizenz.
MFC
[Bearbeiten]Die Microsoft Foundation Classes (MFC) sind ein C++ Framework, vor allem zur GUI-Programmierung unter Microsoft Windows. Es verwendet Entwurfsmuster, insbesondere Document-View (Model-View-Controller), Singleton, Bridge und Observer, um Funktionalität der Windows-API zu kapseln. Außerdem gibt es eine enorme Anzahl an Hilfsmakros zur Unterstützung bei RTTI („Runtime Type Information“), Nachrichtenbehandlung, dynamischer Instanziierung etc.. Da sie seit 1992 in jeder -nicht Express- Version von Microsoft Visual C++ enthalten sind ist ihre Verbreitung auf der Windows-Plattform sehr hoch. Die Verwendung ist an eine kommerzielle Lizenz gebunden, den Besitz einer kostenpflichtigen Version von Visual C++, die MFC-Laufzeitumgebung ist kostenlos. Für die GUI-Entwicklung mit C++ -nicht C++/CLI- auf Windows sind die MFC das am weitesten verbreitete Framework das direkt auf der Windows-API aufsetzt.
Hallo, du schöne Welt!
[Bearbeiten]Es ist eine alte Tradition, eine neue Programmiersprache mit einem „Hello-World“-Programm einzuweihen. Auch dieses Buch soll mit der Tradition nicht brechen, hier ist das „Hello-World“-Programm in C++:
Zugegebenermaßen ist es nicht die Originalversion, sondern eine Originellversion von „Hello-World“. Wenn Sie das Programm ausführen, bekommen Sie den Text „Hallo, du schöne Welt!“ am Bildschirm ausgegeben. Sie wissen nicht, wie Sie das Programm ausführen können? Dann lesen Sie doch einmal das Kapitel über Compiler.
#include <iostream>
stellt die nötigen Befehle zur Ein- und Ausgabe bereit. Als nächstes beginnt die „Hauptfunktion“ main()
. Diese Hauptfunktion wird beim Ausführen des Programms aufgerufen. Sie ist also der zentrale Kern des Programms. Wenn Funktionen im Text erwähnt werden, stehen dahinter übrigens immer Klammern, um sie besser von anderen Sprachbestandteilen wie beispielsweise Variablen unterscheiden zu können.
std::cout
beschreibt den Standardausgabe-Strom. Dabei wird der Text meist in einem Terminal angezeigt (wenn die Ausgabe nicht in eine Datei oder an ein anderes Programm umgeleitet wird). Die beiden Pfeile (<<
) signalisieren, dass der dahinterstehende Text auf die Standardausgabe „geschoben“ wird. Das std::endl
gibt einen Zeilenumbruch aus und sorgt dafür, dass der Text jetzt am Bildschirm ausgegeben wird.
return 0
beendet das Programm und zeigt dem Betriebssystem an, dass es erfolgreich ausgeführt wurde. Auf die Einzelheiten wird in den folgenden Kapiteln (oder Abschnitten) noch ausführlich eingegangen. Diese Zeile ist optional; wird sie nicht angegeben, gibt der Compiler implizit 0 zurück.
Im Moment sollten Sie sich merken, dass jeder C++-Befehl mit einem Semikolon (;
) abgeschlossen wird und dass geschweifte Klammern ({...}
) Zusammengehörigkeit symbolisieren – so auch oben in der Hauptfunktion. Alles was zwischen den geschweiften Klammern steht, gehört zu ihr. Leerzeichen, Tabulatorzeichen und Zeilenumbrüche spielen für den C++-Compiler keine Rolle. Sie können das folgende Programm genauso übersetzen wie seine gut lesbare Version von weiter oben:
Vorsichtig sollten Sie bei Zeilen sein, die mit #
beginnen. Leerzeichen und Tabulatoren sind zwar auch hier bedeutungslos, aber Zeilenumbrüche dürfen nicht stattfinden.
Es ist übrigens (für Ihren Rechner) auch irrelevant, ob Sie etwas wie // Ein- und Ausgabebibliothek
mit in Ihr Programm schreiben oder nicht. Es handelt sich dabei um sogenannte Kommentare, die Sie in Kürze auch genauer kennenlernen werden. Beachten sollten Sie übrigens, dass bei C++ die Groß- und Kleinschreibung relevant ist. Schlüsselwörter und Namen der Standardbibliothek werden stets kleingeschrieben. Für die Groß-/Kleinschreibung von selbstdefinierten Namen gibt es gewisse Konventionen, auf welche in einem späteren Kapitel eingegangen wird.
Bei allen in diesem Buch beschriebenen Programmen handelt es sich um so genannte Kommandozeilenprogramme. Falls Sie eine IDE zur Entwicklung benutzen und das Programm direkt aus dieser heraus aufrufen, kann es Ihnen passieren, dass Sie nur einen kurzen Blitz von Ihrem Programm sehen, weil sich das Kommandozeilenfenster nach der Programmbeendigung sofort schließt.
In diesem Fall haben Sie zwei Optionen:
- Rufen Sie eine Kommandozeile auf und führen Sie das Programm von dort manuell aus. (Das wird empfohlen!)
- Schauen Sie nach, ob es in Ihrer IDE eine Funktion gibt, die das Fenster nach Programmende noch einen Tastendruck lang offen hält. (Eine solche Option muss nicht vorhanden sein!)
Einfache Ein- und Ausgabe
[Bearbeiten]Ein- und Ausgaberoutinen geben Ihnen die Möglichkeit, mit einem Programm zu interagieren. Dieses Kapitel beschäftigt sich mit der Eingabe über die Tastatur und der Ausgabe auf der Konsole in C++-typischer Form. Die C-Variante werden Sie später noch kennenlernen.
Um die C++-Ein- und Ausgabe nutzen zu können, müssen Sie die Bibliothek iostream
einbinden. Das geschieht mit:
Danach müssen die Befehle daraus bekanntgegeben werden, da sie sich in einem Namensraum (engl. namespace
) befinden. Was Namensräume sind und wofür man sie einsetzt, werden Sie später noch erfahren. Um nun die Ein- und Ausgabebefehle nutzen zu können, müssen Sie dem Compiler sagen: Benutze den Namensraum std
. Dafür gibt es zwei Möglichkeiten: Sie können die folgende Zeile verwenden, um alle Namen aus dem Namensraum std
verfügbar zu machen:
Oder Sie können den Namensraum „std
“ immer direkt angeben. Er enthält alle Komponenten der Standardbibliothek von C++. In den Programmen dieses Buches wird der Namensraum immer direkt angegeben. Das heißt, wenn beispielsweise ein Objekt benutzt werden soll, das im Namensraum „std
“ liegt, wird ihm „std::
“ vorangestellt. Die beiden Doppelpunkte heißen Bereichsoperator.
Die Variante mit using namespace
sollte üblicherweise nur innerhalb von Funktionen genutzt werden, da bei Verwendung im globalen Namensraum das ganze Konzept der Namensräume obsolet wird. In gedruckten Büchern wird es gern global verwendet, da dies etwas Platz spart und der in solchen Büchern knapp ist. Lassen Sie sich davon nicht beirren, es ist schlechter Stil.
Einfache Ausgabe
[Bearbeiten]Nun wollen wir aber endlich auch mal was Praktisches tun. Zugegebenermaßen nichts Weltbewegendes und im Grunde nicht einmal etwas wirklich Neues, denn Text haben wir ja schon im „Hello-World“-Programm ausgegeben.
Wie der Text bereits selbst sagte, erscheint er in der Kommandozeile und der zweite schließt sich ohne Unterbrechung an. Der letzte von den drei abschließenden Punkten steht in einer zusätzlichen Zeile, um Ihnen zu demonstrieren, dass man einzelne Zeichen nicht in Anführungszeichen sondern in Apostrophe setzt. Das gilt aber nur für einzelne Zeichen, ab zwei Zeichen ist es schon eine Zeichenkette und dann verwendet man normale Anführungszeichen.
Wenn Sie innerhalb einer Zeichenkette einen Zeilenumbruch einfügen möchten, gibt es zwei Möglichkeiten. Sie können die Escape-Sequenz \n
in die Zeichenkette einfügen oder den Manipulator endl
(für end-line) benutzen. Was genau Escape-Sequenzen oder Manipulatoren sind, ist Thema eines späteren Kapitels, aber folgendes Beispiel demonstriert schon mal die Verwendung für den Zeilenumbruch:
#include <iostream>
int main(){
std::cout << "Text in der Kommandozeile!\n"; // Escape-Sequenz \n
std::cout << "Dieser Text schließt sich an...\n"; // Das steht in einer eigenen Zeile
std::cout << std::endl; // Leerzeile mittels endl
std::cout << "Text in der Kommandozeile!" << std::endl; // Zeilenumbruch mit endl
std::cout << "Dieser Text schließt sich an..." << std::endl; // Das steht in einer eigenen Zeile
}
Text in der Kommandozeile!
Dieser Text schließt sich an...
Text in der Kommandozeile!
Dieser Text schließt sich an...
Beide Methoden haben scheinbar den gleichen Effekt. Verwenden Sie die Escape-Sequence Methode mit "\n"
. std::endl
ist langsamer, da es nicht nur einen Zeilenumbruch ausgibt, sondern auch den Ausgabepuffer leert. Dies wird später noch genauer erläutert.
Einfache Eingabe
[Bearbeiten]Für die Eingabe muss ein wenig vorgegriffen werden, denn um etwas einzulesen, ist etwas nötig, worin das Eingelesene gespeichert werden kann. Dieser „Behälter“ nennt sich Variable. Eine Variable muss zunächst einmal angelegt werden. Am besten lässt sich die Eingabe an einem Beispiel erklären:
Es wird, wie bereits erwähnt, erst eine Variable angelegt (int integer;
), welcher dann ein Wert zugewiesen wird (cin >> integer;
). Diese zweite Zeile bedeutet so viel wie: Lies eine ganze Zahl von der Tastatur und speichere sie in der Variablen integer
.
cin
(gesprochen c-in) ist sozusagen die Tastatur, integer
ist die Variable und >>
bedeutet so viel wie „nach“. Zusammen ergibt sich „Tastatur nach Variable“, es wird also der „Inhalt“ der Tastatur in die Variable integer
verschoben. Dass eine Ganzzahl von der Tastatur gelesen wird, ist übrigens vom Datentyp der Variablen abhängig, aber dazu später mehr.
Um den Inhalt der Variablen wieder auszugeben, müssen Sie nichts weiter tun, als sie mit einem weiteren Schiebeoperator (<<
) hinter cout
(gesprochen c-out) anzuhängen. Es ist ohne Weiteres möglich, mehrere solcher Schiebeoperatoren hintereinander zu schalten, solange Sie nur die letzte Ein- oder Ausgabe mit einem Semikolon (;
) abschließen. Bei der Eingabe muss natürlich der >>
-Operator statt dem <<
-Operator benutzt werden. Die Reihenfolge der Ein- oder Ausgabe bei solchen Konstruktionen entspricht der eingegebenen Folge im Quelltext. Was zuerst hinter cout
oder cin
steht, wird also auch zuerst ausgeführt.
Im Anhang zu diesem Kapitel finden Sie:
- Fragen und die dazugehörigen Antworten.
Kommentare
[Bearbeiten]In allen Programmiersprachen gibt es die Möglichkeit, im Quelltext Notizen zu machen. Für andere oder auch für sich selbst, denn nach ein paar Wochen werden Sie möglicherweise Ihren eigenen Quelltext nicht mehr ohne Weiteres verstehen. Kommentare helfen Ihnen und anderen besser und vor allem schneller zu verstehen, was der Quelltext bewirkt. In C++ gibt es zwei Varianten, um Kommentare zu schreiben:
// Ein Kommentar, der mit zwei Schrägstrichen eingeleitet wird, geht bis zum Zeilenende
/* Ein Kommentar dieser Art kann
sich über mehrere Zeilen
erstrecken oder ... */
a = b /* ... vor dem Zeilenende enden. */ + c;
Die erste, bis zum Zeilenende geltende Sorte, ist die moderne Art des Kommentars. Sie ist in der Regel vorzuziehen, da sie einige Vorteile gegenüber der alten, noch aus C stammenden Variante hat. Die zweite Sorte (manchmal auch als C-Kommentar bezeichnet) in seiner mehrzeiligen Form sollte nur an Stellen verwendet werden, an denen längere Textpassagen stehen und möglichst nicht zwischen Code. Anwendung finden solche Kommentare oft am Dateianfang, um den Inhalt kurz zusammenzufassen oder Lizenzrechtliches zu regeln.
Der hauptsächliche Nachteil bei den mehrzeiligen Kommentaren besteht darin, dass man sie nicht „verschachteln“ kann. Oft werden beispielsweise Teile des Quellcodes zu Testzwecken kurzweilig auskommentiert. Folgendes Beispiel soll dies demonstrieren:
#include <iostream> /* Ein- und Ausgabebibliothek */
int main(){ /* Hauptfunktion */
/*
std::cout << "Hallo, du schöne Welt!" << std::endl; /* Ausgabe */
*/
}
Das würde nicht funktionieren. Wenn hingegen die anderen Kommentare benutzt werden, gibt es solche Probleme nicht:
#include <iostream> // Ein- und Ausgabebibliothek
int main(){ // Hauptfunktion
/*
std::cout << "Hallo, du schöne Welt!" << std::endl; // Ausgabe
*/
}
Im ersten Beispiel wird die Einleitung von /* Ausgabe */
einfach ignoriert. Der abschließende Teil beendet den Kommentar, der die Code-Zeile auskommentieren soll und der eigentliche Abschluss führt zu einem Kompilierfehler. Zugegeben, das ist nicht weiter schlimm, denn solch ein Fehler ist schnell gefunden, aber ihn von vorn herein zu vermeiden, ist eben noch zeitsparender. Im übrigen muss bei einzeiligen Kommentaren oft weniger geschrieben werden. Eine Ausnahme bilden Kommentare, die einfach zu lang sind, um sie auf eine Zeile zu schreiben. Dennoch sollten auch sie durch //
-Kommentare realisiert werden.
viel Code…
// Ich bin ein Beispiel für einen langen Kommentar, der durch doppelte
// Schrägstriche über mehrere Zeilen geht. Würde ich am Dateianfang stehen,
// hätte man mich wahrscheinlich mit anderen Kommentarzeichen ausgestattet,
// aber da ich hier eindeutig von einer Riesenmenge Quelltext umgeben bin,
// hat man sich trotz der erhöhten Schreibarbeit für // entschieden
noch viel mehr Code…
Viele Texteditoren enthalten eine Tastenkombination, über die sich Text ein- und auskommentieren lässt. Besonders für längere Codepassagen ist dies nützlich.
Rechnen (lassen)
[Bearbeiten]In diesem Kapitel soll unser Rechner einmal das tun, was er ohnehin am besten kann: Rechnen. Wir werden uns derweil zurücklehnen und zusehen oder besser gesagt, werden wir das tun, nachdem wir ihm mitgeteilt haben, was er rechnen soll.
Einfaches Rechnen
[Bearbeiten]Zugegebenermaßen hätten Sie diese Rechnung wahrscheinlich auch im Kopf lösen können aber warum sollten Sie sich so unnötig anstrengen. Ihr Rechner liefert doch auch das richtige Ergebnis, und wenn Sie dies mit der Eingabe von Zahlen kombinieren, können Sie sogar bei jedem Programmdurchlauf zwei unterschiedliche Zahlen addieren:
#include <iostream>
int main(){
int summand1, summand2; // Anlegen von zwei Variablen
std::cin >> summand1 >> summand2; // Zwei Zahlen eingeben
std::cout << summand1 << " + " << summand2 // beide durch " + " getrennt wieder ausgeben
<< " = " // " = " ausgeben
<< summand1 + summand2 // Ergebnis berechnen und ausgeben
<< std::endl; // Zeilenumbruch
}
Benutzereingabe: 774
Benutzereingabe: 123
774 + 123 = 897
Das Ergebnis lässt sich natürlich auch in einer Variablen zwischenspeichern. Folgendes Beispiel demonstriert diese Möglichkeit:
#include <iostream>
int main(){
int summand1, summand2, ergebnis; // Anlegen von drei Variablen
std::cin >> summand1 >> summand2; // Zwei Zahlen eingeben
ergebnis = summand1 + summand2; // Ergebnis berechnen
std::cout << summand1 << " + " << summand2 // beide durch " + " getrennt wieder ausgeben
<< " = " // " = " ausgeben
<< ergebnis // Ergebnis ausgeben
<< std::endl; // Zeilenumbruch
}
Benutzereingabe: 400
Benutzereingabe: 300
400 + 300 = 700
Die großen Vier
[Bearbeiten]C++ beherrscht die vier Grundrechenarten: Addition (+
), Subtraktion (-
), Multiplikation (*
) und Division (/
). Genau wie in der Mathematik gilt auch in C++ die Regel: Punktrechnung geht vor Strichrechnung und Klammern gehen über alles. Das folgende Beispiel soll eine komplexere Rechnung demonstrieren:
#include <iostream>
int main(){
int ergebnis; // Anlegen einer Variable
ergebnis = ((3 + 3*4)/5 - 1)*512 - 768; // Ergebnis berechnen
std::cout << "((3 + 3*4)/5 - 1)*512 - 768 = " // Aufgabe ausgeben
<< ergebnis // Ergebnis ausgeben
<< std::endl; // Zeilenumbruch
}
((3 + 3*4)/5 - 1)*512 - 768 = 256
Gerechnet wird in dieser Reihenfolge:
3 * 4 = 12 3 + 12 = 15 15 / 5 = 3 3 - 1 = 2 2 * 512 = 1024 1024 - 768 = 256
Sie sollten darauf achten, immer die gleiche Anzahl öffnende und schließende Klammern zu haben, denn dies ist ein beliebter Fehler, der von Anfängern meist nicht so schnell gefunden wird. Compiler bringen in solchen Fällen nicht selten Meldungen, die einige Zeilen unter dem eigentlichen Fehler liegen.
Zusammengesetzte Operatoren
[Bearbeiten]C++ ist eine Sprache für schreibfaule Menschen. Daher gibt es die Möglichkeit, die Rechenoperatoren mit dem Zuweisungsoperator zu kombinieren. Dies sieht dann folgendermaßen aus:
zahl = 22;
zahl += 5; // zahl = zahl + 5;
zahl -= 7; // zahl = zahl - 7;
zahl *= 2; // zahl = zahl * 2;
zahl /= 4; // zahl = zahl / 4;
Als Kommentar sehen Sie die Langfassung geschrieben. Diese Kurzschreibweise bedeutet nicht mehr, als dass die vor (!) dem Zuweisungsoperator stehende Rechenoperation mit der Variablen auf der linken Seite und dem Wert auf der rechten Seite ausgeführt und das Ergebnis der Variablen auf der linken Seite zugewiesen wird. Sie sollten diese Kurzschreibweise der ausführlichen vorziehen, da sie nicht nur die Finger schont, sondern auch noch ein wenig schneller ist.
Am besten werden Sie dies wahrscheinlich verstehen, wenn Sie es einfach ausprobieren. Stehen auf der rechten Seite noch weitere Rechenoperationen, so werden diese zuerst ausgeführt. Das Ganze stellt sich dann also folgendermaßen dar:
Inkrement und Dekrement
[Bearbeiten]Inkrementieren bedeutet, den Wert einer Variablen um 1 zu erhöhen, entsprechend bedeutet Dekrementieren 1 herunterzuzählen. Dem Inkrementoperator schuldet C++ übrigens seinen Namen. Die beiden Operatoren gibt es jeweils in der Präfix- und der Postfix-Variante. Insgesamt ergeben sich also vier Operatoren:
zahl = 5;
zahl++; // Inkrement Postfix (zahl == 6)
++zahl; // Inkrement Präfix (zahl == 7)
zahl--; // Dekrement Postfix (zahl == 6)
--zahl; // Dekrement Präfix (zahl == 5)
Der Unterschied zwischen Inkrement (++
) und Dekrement (--
) ist ohne größeres Nachdenken erkennbar. Der Sinn von Präfix und Postfix ergibt sich hingegen nicht sofort von selbst. C++ schuldet seinen Namen der Postfix-Variante.
Der Unterschied zwischen Präfix und Postfix besteht im Rückgabewert. Die Präfix-Variante erhöht den Wert einer Zahl um 1 und gibt diesen neuen Wert zurück. Die Postfix-Variante erhöht den Wert der Variablen ebenfalls um 1, gibt jedoch den Wert zurück, den die Variable vor der Erhöhung hatte.
Das folgende kleine Programm zeigt den Unterschied:
#include <iostream>
int main(){
int zahl; // Anlegen einer Variable
std::cout << "zahl direkt ausgeben:\n";
zahl = 5; // zahl den Wert 5 zuweisen
std::cout << zahl << ' '; // zahl ausgeben
zahl++; // Inkrement Postfix (zahl == 6)
std::cout << zahl << ' '; // zahl ausgeben
++zahl; // Inkrement Präfix (zahl == 7)
std::cout << zahl << ' '; // zahl ausgeben
zahl--; // Dekrement Postfix (zahl == 6)
std::cout << zahl << ' '; // zahl ausgeben
--zahl; // Dekrement Präfix (zahl == 5)
std::cout << zahl << ' '; // zahl ausgeben
std::cout << "\nRückgabewert des Operators ausgeben:\n";
zahl = 5; // zahl den Wert 5 zuweisen
std::cout << zahl << ' '; // zahl ausgeben
std::cout << zahl++ << ' '; // Inkrement Postfix (zahl == 6)
std::cout << ++zahl << ' '; // Inkrement Präfix (zahl == 7)
std::cout << zahl-- << ' '; // Dekrement Postfix (zahl == 6)
std::cout << --zahl << ' '; // Dekrement Präfix (zahl == 5)
std::cout << "\nEndwert von zahl: " << zahl << std::endl;
}
zahl direkt ausgeben:
5 6 7 6 5
Rückgabewert des Operators ausgeben:
5 5 7 7 5
Endwert von zahl: 5
In einem späteren Kapitel werden Sie noch ein paar zusätzliche Informationen erhalten, was beim Rechnen schief gehen kann und wie Sie es vermeiden. Wenn Sie beim Herumexperimentieren mit Rechenoperationen plötzlich scheinbar unerklärliche Ergebnisse erhalten, dann ist es an der Zeit einen Blick auf dieses Kapitel zu werfen.
Im Anhang zu diesem Kapitel finden Sie:
- Aufgaben und zugehörige Musterlösungen.
Variablen, Konstanten und ihre Datentypen
[Bearbeiten]Variablen sind Behälter für Werte, sie stellen gewissermaßen das Gedächtnis eines Programms bereit. Konstanten sind spezielle Variablen, sie ändern ihren Wert nie. Der Datentyp einer Variablen oder Konstanten beschreibt, wie der Inhalt zu verstehen ist.
Ein Rechner kennt nur zwei Grundzustände: 0 und 1. Durch eine Aneinanderreihung solcher Zustände lassen sich mehr verschiedene Werte darstellen. Mit acht aufgereihten Zuständen (= 8 Bit = 1 Byte) lassen sich bereits 256 verschiedene Werte darstellen. Diese Werte kann man beispielsweise als Ganzzahl, (Schrift-)Zeichen, Wahrheitswert oder Gleitkommazahl interpretieren. Der Datentyp gibt daher Auskunft darüber, um was es sich handelt.
Der C++-Standard schreibt nicht vor, dass ein Byte aus genau 8 Bit bestehen muss – diese Anzahl ist jedoch weitverbreitet. Es ist also möglich, dass auf einer speziellen Prozessorarchitektur z. B. eine Anzahl von 10 Bit als „ein Byte“ festgelegt ist. Dies ist jedoch äußerst selten der Fall, daher werden wir im Folgenden annehmen, dass ein Byte aus 8 Bit besteht.
Datentypen
[Bearbeiten]Zunächst sollen die Datentypen von C++ beschrieben werden, denn sie sind grundlegend für eine Variable oder Konstante. Die vier wichtigsten (Gruppen von) Datentypen sind: Wahrheitswerte, Zeichen, Ganzzahlen und Gleitkommazahlen.
Wahrheitswerte
[Bearbeiten]Der Datentyp für Wahrheitswerte heißt in C++ bool
, was eine Abkürzung für boolean ist. Er kann nur zwei Zustände annehmen: true
(wahr) oder false
(falsch). Obwohl eigentlich 1 Bit ausreichen würde, hat bool
mindestens eine Größe von einem Byte (also 8 Bit), denn 1 Byte ist die kleinste adressierbare Einheit und somit die Minimalgröße für jeden Datentyp. Es ist auch durchaus möglich, dass ein bool
beispielsweise 4 Byte belegt, da dies auf einigen Prozessorarchitekturen die Zugriffsgeschwindigkeit erhöht.
Zeichen
[Bearbeiten]Zeichen sind eigentlich Ganzzahlen. Sie unterscheiden sich von diesen nur bezüglich der Ein- und Ausgabe. Jeder Zahl ist ein Zeichen zugeordnet. Mit den Zahlen lässt sich ganz normal rechnen aber bei der Ausgabe erscheint das zugeordnete Zeichen auf dem Bildschirm. Welches Zeichen welcher Zahl entspricht, wird durch den verwendeten Zeichensatz festgelegt.
Die meisten Zeichensätze beinhalten den sogenannten ASCII-Code (American Standard Code for Information Interchange), welcher die Zeichen 0 – 127 belegt. Er enthält 32 Steuerzeichen (0 – 31) und 96 druckbare Zeichen (32 – 127).
char
ist der Standard-Datentyp für Zeichen. Er ist in der Regel 1 Byte groß und kann somit 256 verschiedene Zeichen darstellen. Diese genügen für einen erweiterten ASCII-Code, welcher zum Beispiel auch deutsche Umlaute definiert. Für Unicode-Zeichen gibt es die Datentypen char16_t
mit einer Größe von 2 Byte und char32_t
mit einer Größe von 4 Byte. Früher nutzte man für Unicode auch den Datentyp wchar_t
, welcher je nach System 2 oder 4 Byte groß war. Dieser Datentyp sollte jedoch nicht mehr eingesetzt werden. Die folgende Liste enthält einige nützliche Links zu Artikeln der Wikipedia:
- ASCII
- Unicode (UTF-8, UTF-16, UTF-32)
- ISO 8859-1 (Latin-1)
- Codepage 437 (DOS / DOS-Box)
Es gibt in C++ 4 eingebaute Datentypen für Zeichen:
char
char16_t
char32_t
wchar_t
(veraltet)
Ganzzahlen
[Bearbeiten]C++ definiert folgende eingebaute Datentypen für Ganzzahlen:
Schreibweise | Typ | Anzahl Bits nach data model | ||||
---|---|---|---|---|---|---|
C++ standard | LP32 | ILP32 | LLP64 | LP64 | ||
signed char
|
signed char
|
mindestens 8 |
8 | 8 | 8 | 8 |
unsigned char
|
unsigned char
| |||||
short
|
short
|
mindestens 16 |
16 | 16 | 16 | 16 |
short int
| ||||||
signed short
| ||||||
signed short int
| ||||||
unsigned short
|
unsigned short
| |||||
unsigned short int
| ||||||
int
|
int
|
mindestens 16 |
16 | 32 | 32 | 32 |
signed
| ||||||
signed int
| ||||||
unsigned
|
unsigned
| |||||
unsigned int
| ||||||
long
|
long
|
mindestens 32 |
32 | 32 | 32 | 64 |
long int
| ||||||
signed long
| ||||||
signed long int
| ||||||
unsigned long
|
unsigned long
| |||||
unsigned long int
| ||||||
long long
|
long long
|
mindestens 64 |
64 | 64 | 64 | 64 |
long long int
| ||||||
signed long long
| ||||||
signed long long int
| ||||||
unsigned long long
|
unsigned long long
| |||||
unsigned long long int
|
Alle Schreibweisen sind identisch zu dem jeweils zugeordneten Typ. Die Schreibweise, die in der Typ-Spalte verwendet wird, ist die in diesem Buch genutzte Schreibweise. In der Praxis finden sie aber, je nachdem wer den Code geschrieben hat, auch die äquivalenten Schreibweisen der ersten Spalte.
Die genaue Anzahl der Bits hängt von der Implementierung ab und wird gemeinhin als data model bezeichnet. Vier data models sind weit verbreitet:
- 32 Bit Systeme
- LP32 oder 2/4/4 (
int
hat 16 Bits,long
und Zeiger haben 32 Bits)- Win16 API
- ILP32 oder 4/4/4 (
int
,long
und Zeiger haben 32 Bits)- Win32 API
- Unix und Unixoide Systeme (Linux, Mac OS X)
- LP32 oder 2/4/4 (
- 64 Bit Systeme
- LLP64 oder 4/4/8 (
int
undlong
haben 32 Bits, Zeiger haben 64 Bits)- Win64 API
- LP64 oder 4/8/8 (
int
hat 32 Bits,long
und Zeiger haben 64 Bits)- Unix und Unixoide Systeme (Linux, Mac OS X)
- LLP64 oder 4/4/8 (
Der Wertebereich von vorzeichenbehafteten Typen (»signed
«) berechnet sich durch:
Für vorzeichenlose Typen (»unsigned
«) berechnet er sich durch:
Auffällig sind in der Tabelle die beiden char
-Datentypen. Im Gegensatz zu den anderen Datentypen gibt es hier keine Schreibweise, in der am Ende ein int
steht. Und was noch wichtiger ist, signed char
darf nicht mit char
abgekürzt werden! Das liegt daran, dass char
ein Datentyp für Zeichen ist, während signed char
und unsigned char
üblicherweise Zahlen repräsentieren. Historisch bedingt ist die Trennung zwischen Zeichen und Zahlen in C++ leider sehr unsauber, was sich vor allem bei den char
-Datentypen zeigt.
Sowohl signed char
, als auch unsigned char
werden bei der Ein- und Ausgabe als Zeichen behandelt, weshalb hier immer zuvor nach int
gecasted (umgewandelt, Näheres im Kapitel Casts) werden muss. Der Datentyp char
kann vom Wertebereich her je nach Compiler entweder zu signed char
oder zu unsigned char
identisch sein. Zu beachten ist jedoch, dass char
dennoch ein eigenständiger Typ ist! Für den Augenblick ist diese Unterscheidung nicht so wichtig, wir werden jedoch noch einmal auf dieses (im Kontext von Templates wichtige) Detail zu sprechen kommen.
Für den Augenblick sollten Sie sich merken: Wenn Sie eine Zahl mit einer 1-Byte-Variablen repräsentieren wollen, dann nutzen Sie signed char
oder unsigned char
, wenn Sie ein Zeichen repräsentieren wollen, verwenden Sie char
.
Wählen Sie ein int
, wenn dieser Typ alle Zahlen des nötigen Wertebereichs aufnehmen kann, bei vorzeichenlosen Zahlen verwenden Sie unsigned
. Reicht dieser Wertebereich nicht aus und ist long
größer, dann nehmen Sie long
(bzw. unsigned long
). short
und unsigned short
sollte nur Verwendung finden, wenn Speicherplatz knapp ist, etwa bei Verwendung großer Arrays, oder wenn Low-Level-Datenstrukturen festgelegter Größe benutzt werden müssen. Achten Sie darauf, dass der theoretisch größte Wert, welcher für Ihre Variable auftreten könnte, den größten möglichen Wert nicht überschreitet. Selbiges gilt natürlich auch für die Unterschreitung des kleinstmöglichen Wertes.
Ein Unter- oder Überlauf ist übrigens durchaus möglich. Die meisten Compiler bieten zwar eine Option an, um in einem solchen Fall einen Fehler zu erzeugen, aber diese Option ist standardmäßig nicht aktiv. Im folgenden kleinen Beispiel werden die Datentypen short
(min: -32768, max: 32767) und unsigned short
benutzt und bei beiden wird je ein Unter- und ein Überlauf ausgeführt:
Davon ausgehend, dass short
eine Größe von 2 Byte hat, finden je zwei Über- bzw. Unterläufe statt.
#include <iostream>
int main(){
short variable1 = 15000;
unsigned short variable2 = 15000;
std::cout << "short Variable: " << variable1 << std::endl
<< "unsigned short Variable: " << variable2 << std::endl
<< "+30000\n\n";
variable1 += 30000;
variable2 += 30000;
std::cout << "short Variable: " << variable1 << " (Überlauf)" << std::endl
<< "unsigned short Variable: " << variable2 << std::endl
<< "+30000\n\n";
variable1 += 30000;
variable2 += 30000;
std::cout << "short Variable: " << variable1 << std::endl
<< "unsigned short Variable: " << variable2 << " (Überlauf)" << std::endl
<< "-30000\n\n";
variable1 -= 30000;
variable2 -= 30000;
std::cout << "short Variable: " << variable1 << std::endl
<< "unsigned short Variable: " << variable2 << " (Unterlauf)" << std::endl
<< "-30000\n\n";
variable1 -= 30000;
variable2 -= 30000;
std::cout << "short Variable: " << variable1 << " (Unterlauf)" << std::endl
<< "unsigned short Variable: " << variable2 << std::endl;
}
short Variable: 15000
unsigned short Variable: 15000
+30000
short Variable: -20536 (Überlauf)
unsigned short Variable: 45000
+30000
short Variable: 9464
unsigned short Variable: 9464 (Überlauf)
-30000
short Variable: -20536
unsigned short Variable: 45000 (Unterlauf)
-30000
short Variable: 15000 (Unterlauf)
unsigned short Variable: 15000
Verständlicher wird dieses Phänomen, wenn man die Zahlen binär (Duales Zahlensystem) darstellt. Der Einfachheit halber beginnen wir mit der Darstellung der unsigned short
Variablen, die ersten beiden Additionen von jeweils 30000:
Addition im Dualsystem mit unsigned short
als Datentyp
Rechnung 1
|0011101010011000| 15000
+ |0111010100110000| 30000
--------------------------------
Merker |111 11 |
--------------------------------
= |1010111111001000| 45000
Rechnung 2
|1010111111001000| 45000
+ |0111010100110000| 30000
--------------------------------
Merker 1|1111111 |
--------------------------------
= 1|0010010011111000| 9464
Die beiden Rechnungen weisen keinerlei Besonderheiten auf. Da nur die letzten 16 Ziffern beachtet werden (2 Byte = 16 Bit), entfällt in der zweiten Rechnung die 1 vor dem Vertikalstrich, wodurch das Ergebnis (in dezimaler Schreibweise) 9464 und nicht 75000 lautet.
Anschließend werden von der unsigned short
-Ganzzahl zwei Mal jeweils 30000 subtrahiert:
Subtraktion im Dualsystem mit unsigned short
als Datentyp
Rechnung 3
|0010010011111000| 9464
- |0111010100110000| 30000
----------------------------------
Merker...1|1111111 |
----------------------------------
=...1|1010111111001000| 45000
Rechnung 4
|1010111111001000| 45000
- |0111010100110000| 30000
----------------------------------
Merker |111 11 |
----------------------------------
= |0011101010011000| 15000
In diesem Fall ist die zweite Rechnung unauffällig. In der ersten Rechnung wird hingegen eine große Zahl von einer kleineren abgezogen, was zu einem negativen Ergebnis führt – oder besser – führen würde, denn die Untergrenze ist in diesem Fall 0. Dort wo die 3 Punkte stehen, folgt eine unendliche Anzahl von Einsen. Dies ist keineswegs nur bei Dualzahlen der Fall. Wenn Sie im dezimalen System eine große Zahl von einer kleineren nach den üblichen Regeln der schriftlichen Subtraktion abziehen, so erhalten Sie ein ähnliches Ergebnis:
24 - 31 ------------ Merker...1 ------------ =...993
Im Programm werden mit der Vorzeichen-behafteten Ganzzahl, der short
-Variablen, ebenfalls vier Rechnungen durchgeführt. Deren duale Darstellung wird Ihnen sehr bekannt vorkommen:
Addition im Dualsystem mitshort
als Datentyp Rechnung 1 |0|011101010011000| 15000 + |0|111010100110000| 30000 -------------------------------- Merker |1|11 11 | -------------------------------- = |1|010111111001000| -20536 Rechnung 2 |1|010111111001000| -20536 + |0|111010100110000| 30000 -------------------------------- Merker 1|1|111111 | -------------------------------- = 1|0|010010011111000| 9464 Subtraktion im Dualsystem mitshort
als Datentyp Rechnung 3 |0|010010011111000| 9464 - |0|111010100110000| 30000 ---------------------------------- Merker...1|1|111111 | ---------------------------------- =...1|1|010111111001000| -20536 Rechnung 4 |1|010111111001000| -20536 - |0|111010100110000| 30000 ---------------------------------- Merker |1|11 11 | ---------------------------------- = |0|011101010011000| 15000
Die dualen Ziffern sind die gesamte Zeit über exakt die gleichen, der einzige Unterschied besteht darin, dass negative Zahlen in Zweierkomplement-Darstellung repräsentiert werden. Dies führt zu einer veränderten Darstellung im Dezimalsystem.
Meist ist es besser, wenn man die genaue Größe der Datentypen festlegt. Hierfür muss der Header cstdint
eingebunden werden. Darin sind Alias-Namen für die eingebauten Datentypen definiert:
std::int8_t
optionalstd::int16_t
optionalstd::int32_t
optionalstd::int64_t
optionalstd::int_fast8_t
std::int_fast16_t
std::int_fast32_t
std::int_fast64_t
std::int_least8_t
std::int_least16_t
std::int_least32_t
std::int_least64_t
std::intmax_t
std::intptr_t
optionalstd::uint8_t
optionalstd::uint16_t
optionalstd::uint32_t
optionalstd::uint64_t
optionalstd::uint_fast8_t
std::uint_fast16_t
std::uint_fast32_t
std::uint_fast64_t
std::uint_least8_t
std::uint_least16_t
std::uint_least32_t
std::uint_least64_t
std::uintmax_t
std::uintptr_t
optional
Alle Datentypen, die mit int
beginnen, stehen für signed
Datentypen, alle, die mit uint
beginnen, für unsigned
Datentypen. Die mit »optional« gekennzeichneten Datentypen sind nur definiert, wenn sie durch die Plattform auch nativ unterstützt werden. Dies ist jedoch auf den schon oben referenzierten Systemen immer der Fall. Lediglich auf Mikrocontrollern oder ähnlichem kann es hier zu Problemen kommen.
Die least
-Datentypen entsprechen dem kleinsten Typ, der mindestens so viele Bits hat, die fast
-Datentypen dem schnellsten Datentyp, der mindestens so viele Bits hat. Die max
-Datentypen entsprechen dem jeweils größten verfügbaren Datentyp. Die ptr
-Datentypen haben exakt so viele Bit wie ein Zeiger (siehe Kapitel Zeiger) und sind daher ebenfalls nur verfügbar, wenn die Hardware einen entsprechenden Integer-Datentyp nativ unterstützt.
In der Regel ist es am sinnvollsten die exakten Datentypen zu verwenden, also etwa std::uint16_t
, wenn man einen Datentyp möchte, der 16 Bit hat und vorzeichenlos ist. Die fast
-Datentypen können nötigenfalls zur Geschwindigkeitsoptimierung innerhalb von Funktionen eingesetzt werden.
Gleitkommazahlen
[Bearbeiten]Eine Gleitkommavariable kann sich eine bestimmte Anzahl Ziffern merken und dazu die Position des Kommas. Das Wissen über den internen Aufbau einer solchen Zahl werden Sie wahrscheinlich eher selten bis nie brauchen, daher sei an dieser Stelle auf den Wikipediaartikel über Gleitkommazahlen verwiesen. In C++ werden Sie Gleitkommazahlen/-variablen für das Rechnen mit Kommazahlen verwenden. Es gibt drei Datentypen für Gleitkommazahlen, die in der folgenden Tabelle mit ihren üblichen Werten aufgelistet sind:
Typ | Speicherplatz | Wertebereich | kleinste positive Zahl | Genauigkeit |
---|---|---|---|---|
float |
4 Byte | 6 Stellen | ||
double |
8 Byte | 12 Stellen | ||
long double |
10 Byte | 18 Stellen |
Die Auswahl eines Gleitkommadatentyps ist weniger einfach als die einer Ganzzahl. Wenn Sie nicht genau wissen, was Sie nehmen sollen, ist double
in der Regel eine gute Wahl. Sobald Sie erst einmal ausreichend Erfahrung haben, wird es Ihnen leichter fallen abzuschätzen, ob float
oder long double
für Ihr Problem vielleicht eine bessere Wahl ist.
Variablen
[Bearbeiten]Bevor eine Variable verwendet werden kann, muss sie dem Compiler bekannt gegeben werden. Dies bezeichnet man als Deklaration der Variablen. Das eigentliche Anlegen einer Variablen, so dass der Compiler Speicherplatz für sie reserviert, wird Definition genannt. Eine Definition ist immer auch eine Deklaration und bei Variablen ist der Unterschied zwischen Deklaration und Definition etwas zu kompliziert, um ihn an dieser Stelle bereits zu erklären. Sie werden die Begriffe zunächst in Zusammenhang mit Funktionen kennenlernen und später auch für Variablen. Sie sollten sich jedoch jetzt bereits merken, dass es sich beim Anlegen der Variablen, die wir verwenden, immer um Definitionen handelt. Dies ist insofern wichtig, als dass eine Definition immer nur einmal geschrieben werden darf, während eine Deklaration beliebig oft vorgenommen werden kann. Um einen Vergleich zur realen Welt zu ziehen, wollen wir das Entstehen neuen Lebens betrachten. Sie können beliebig oft erzählen, dass ein bestimmtes Kind geboren wird. Tatsächlich geschehen kann dies aber nur einmal. Im vorherigen Kapitel haben wir bereits mit Variablen vom Typ int
gerechnet. Nun sollen Sie lernen, wie Variablen in C++ angelegt werden. Die allgemeine Syntax lautet:
Außerdem ist es möglich, mehrere Variablen des gleichen Typs hintereinander anzulegen:
Auch kann man einer Variablen einen Anfangswert geben, dies bezeichnet man als Initialisierung. Es gibt zwei syntaktische Möglichkeiten (Schreibweisen) für Initialisierungen, welche anhand einer int
Variable gezeigt werden soll:
Die erste Variante ist weit verbreitet aber nicht zwingend besser. Bei den fundamentalen Datentypen von C++ spielt es keine Rolle, welche Variante Sie verwenden, aber bei komplexeren Datentypen (Klassen) kann es zu Verwechslungen mit dem Zuweisungsoperator kommen, wenn Sie Möglichkeit 1 benutzen. Den genauen Unterschied zwischen einer Initialisierung und einer Zuweisung werden Sie kennenlernen, sobald es um Klassen geht. Für den Moment sollten Sie sich für eine der beiden Varianten entscheiden. Für Möglichkeit 1 spricht die große Verbreitung und die damit verbundene intuitive Nutzung. In diesem Buch werden wir diese erste Methode verwenden und in Zusammenhang mit Klassen auch eine Empfehlung geben, wann diese Methode zur besseren Übersicht im Quellcode beitragen kann. Für Möglichkeit 2 spricht hingegen, dass diese Syntax für Initialisierungen immer gültig ist. Nebenbei wird bei dieser Methode deutlich, dass es sich um eine Initialisierung handelt.
Variablen mit Anfangswerten können natürlich auch hintereinander angelegt werden, sofern sie den gleichen Datentyp besitzen, allerdings ist davon aus Gründen der Übersichtlichkeit abzuraten.
Wenn Sie einer Variablen keinen Anfangswert geben, müssen Sie ihr später im Programm noch einen Wert zuweisen, bevor Sie mit ihr arbeiten (also damit rechnen oder den Inhalt ausgeben lassen). Weisen Sie einer solchen Variablen keinen Wert zu und benutzen sie, so kann der Inhalt zufällig sein. Genaugenommen handelt es sich dann um die Bitfolge, die an der Stelle im Speicher stand, an der Ihre Variable angelegt wurde. Es gibt in C++ Regeln, in welchen Fällen der Compiler eine Variable ohne explizite Initialisierung implizit mit 0 initialisiert und wann stattdessen einfach der aktuelle Speicherinhalt stehen bleibt. Allerdings sind diese Regeln so kompliziert, dass es sich nicht lohnt, sie sich zu merken. Denn sollte je ein anderer Programmierer Ihren Code lesen, so muss auch dieser die Regeln kennen, um den Code sofort verstehen zu können. Das nachfolgende kleine Programm zeigt einen Fall, in dem C++ besagt, dass keine implizite Initialisierung mit 0 stattfindet.
#include <iostream> // Ein-/Ausgabe
int main(){
int zahl; // Ganzzahlige Variable
double kommazahl1, kommazahl2; // Gleitkommavariablen
char zeichen; // Zeichenvariable
std::cout << "zahl: " << zahl << std::endl // Ausgabe der Werte
<< "kommazahl1: " << kommazahl1 << std::endl // welche jedoch
<< "kommazahl2: " << kommazahl2 << std::endl // nicht festgelegt
<< "zeichen: " << zeichen << std::endl; // wurden
}
zahl: -1211024315
kommazahl1: 4.85875e-270
kommazahl2: -3.32394e-39
zeichen: f
Die Ausgabe kann bei jedem Ausführen des Programms anders lauten. Sollte dies bei Ihnen nicht der Fall sein, so stehen nur zufällig die gleichen Werte an der Stelle im Speicher, welchen die jeweilige Variable belegt. Spätestens nach einem Neustart Ihres Rechners haben Sie höchstwahrscheinlich eine andere Ausgabe. Variablen keinen Anfangswert zu geben, ist beispielsweise sinnvoll, wenn Sie vorhaben, über std::cin
einen Wert in die Variable einzulesen. Dennoch würde es auch in diesem Fall keinen Schaden anrichten, wenn Sie die Variablen explizit mit 0 initialisieren.
#include <iostream> // Ein-/Ausgabe
int main(){
int zahl; // Ganzzahlige Variable
double kommazahl1, kommazahl2; // Gleitkommavariablen
char zeichen; // Zeichenvariable
std::cout << "Geben Sie bitte durch Leerzeichen getrennt eine Ganzzahl, zwei Kommazahlen "
"und ein Zeichen ein:\n";
std::cin >> zahl // Eingabe von Werten
>> kommazahl1 // mit denen die vier
>> kommazahl2 // Variablen gefüllt
>> zeichen; // werden
std::cout << "Zahl: " << zahl << std::endl // Ausgabe der Werte
<< "Kommazahl1: " << kommazahl1 << std::endl // welche zuvor
<< "Kommazahl2: " << kommazahl2 << std::endl // eingegeben
<< "Zeichen: " << zeichen << std::endl; // wurden
}
Geben Sie bitte durch Leerzeichen getrennt eine Ganzzahl, zwei Kommazahlen und ein Zeichen ein:
Benutzereingabe: 6 8.4 6.0 g
Zahl: 6
Kommazahl1: 8.4
Kommazahl2: 6
Zeichen: g
Konstanten
[Bearbeiten]Konstanten sind, wie schon oben beschrieben, Variablen, welche ihren Wert nicht verändern. Daraus folgt, dass einer Konstanten nur genau ein Mal ein Wert zugewiesen werden kann; in C++ muss dies das Initialisieren mit einem Anfangswert sein, andernfalls hätten Sie eine Konstante mit einem zufälligen Wert und das ergibt kaum einen Sinn. Das Schlüsselwort, um eine Variable zu einer Konstanten zu machen, ist const
. Es gehört immer zu dem, was links davon steht, es sei denn, links von ihm steht nichts mehr, dann gehört 'const' zu dem Begriff auf dessen rechter Seite. Dies klingt zwar kompliziert, ist es aber eigentlich gar nicht. Für uns bedeutet es im Moment nur, dass Sie zwei Möglichkeiten haben, eine Variable zu einer Konstanten zu machen:
const int zahl(400); // Alternativ: const int zahl=400;
// oder
int const zahl(400); // Alternativ: int const zahl=400;
Beides hat die gleiche Wirkung, wieder ist die erste Variante weit verbreitet und wieder ist die zweite Variante der besseren Lesbarkeit bei komplexeren Datentypen (Arrays von Zeigern Konstante auf Memberfunktionen…) vorzuziehen. Entscheiden Sie sich für die Variante, die Ihnen besser gefällt und verwenden Sie diese. Wichtig ist, dass Sie der Variante, für die Sie sich entscheiden, treu bleiben, wenigstens für die Dauer eines Projekts. Denn Code, in dem sich der Schreibstil ständig ändert, ist schwieriger zu lesen.
Literale und ihre Datentypen
[Bearbeiten]Ein Literal ist eine Angabe im Quellcode, die einen konkreten Wert angibt und einen der oben beschriebenen Datentypen besitzt.
Bei der Ausgabe ist zu beachten, dass Boolean-Werte als 0 (false) bzw. 1 (true) ausgegeben werden.
Automatische Bestimmung des Datentyps
[Bearbeiten]Da die Bestimmung des Typs für einen ganzzahligen Wert etwas schwieriger ist als bei den übrigen, werden wir diese zuletzt behandeln. Bei Gleitkommaliteralen ist festgelegt, dass es sich um double
-Werte handelt. Um einen Gleitkommaliteral mit einem anderen Typ zu erhalten, ist ein so genanntes Suffix nötig.
Eine Zahl mit Komma (.
) ist also ein double
-Wert. Folgt der Zahl ein f
oder ein F
wird sie zu einem float
-Wert und folgt ihr ein l
oder ein L
wird sie zu einem long double
-Wert. Gleitpunktzahlen können auch in der wissenschaftlichen Schreibweise dargestellt werden.
Wie die letzten beiden Beispiele zeigen, können auch hierbei die Suffixe für den Datentyp genutzt werden.
Um ein Zeichen beziehungsweise eine Zeichenkette als char16_t
bzw. char32_t
zu kennzeichnen, stellt man ein kleines u
bzw. großes U
voran. Für wchar_t
nutzt man ein großes L
.
'a' // char
u'b' // char16_t
U'b' // char32_t
L'b' // wchar_t
"Ich bin ein Text" // char const*
u"Ich bin ein Text" // char16_t const*
U"Ich bin ein Text" // char32_t const*
L"Ich bin ein Text" // wchar_t const*
Was das Sternchen (*
) hinter dem Datentyp im Kommentar bedeutet, werden Sie in einem späteren Kapitel erfahren. bool
kann nur zwei Zustände annehmen, entsprechend gibt es auch nur zwei bool
-Literale: true
und false
.
Nun zu den Ganzzahlen. Neben der dezimalen Darstellung von Zahlen gibt es in C++ auch die Möglichkeit der Binären, Oktalen und Hexadezimalen Darstellung. Um eine Zahl als Binär zu kennzeichnen, wird ein 0b
oder 0B
vorangestellt, für Oktal wird nur eine 0
(Null) vorangestellt und für eine Hexadezimalzahl wird 0x
oder 0X
vorangestellt. Die Groß-/Kleinschreibung der hexadezimalen Ziffern a
bis f
spielt keine Rolle.
756 // Dezimal, Dezimal: 756
0b10 // Binär, Dezimal: 2
0B111 // Binär, Dezimal: 7
046 // Oktal, Dezimal: 38
0757 // Oktal, Dezimal: 495
0xffff // Hexadezimal, Dezimal: 65535
0X1234ABcd // Hexadezimal, Dezimal: 305441741
Der Datentyp wird durch die Größe des Wertes bestimmt, wobei die folgende Reihenfolge gilt: int
, unsigned int
, long
, unsigned long
, long long
, unsigned long long
. Weiterhin kann jeder Ganzzahl auch explizit das Suffix u
oder U
für unsigned
und ein l
oder L
für long
bzw. ein ll
oder LL
für long long
angehängt werden. Die Reihenfolge ändert sich entsprechend den durch die Suffixe festgelegten Kriterien.
übersichtlichere int-Schreibweise
[Bearbeiten]Es ist außerdem möglich, Integer-Literale an beliebigen Stellen durch das Zeichen '
zu trennen, um die Übersicht zu verbessern. Dies kann insbesondere bei binären Literalen nützlich sein, da diese oft sehr lang sind.
9'756'432'108 // ' als Tausender-Trennzeichen
978'3'446'43981'8 // ' als Trennzeichen für eine ISBN-Nummer
0B1111'0000'1010'0101 // ' als Trennzeichen für einen 16 Bit Wert mit 4 Gruppen
Das Wissen über die Datentypen von Literalen werden Sie wahrscheinlich eher selten benötigen, daher reicht es „mal etwas davon gehört zu haben“ und es, wenn nötig, nachzuschlagen.
Rechnen mit unterschiedlichen Datentypen
[Bearbeiten]Sie kennen nun die Datentypen in C++ und haben auch schon mit int
-Variablen gerechnet. In diesem Kapitel erfahren Sie, wie man mit Variablen unterschiedlichen Typs rechnet. Es geht also weniger um das Ergebnis selbst, als viel mehr darum, wie der Ergebnisdatentyp lautet.
Ganzzahlen
[Bearbeiten]Das Rechnen mit Ganzzahlen ist leicht zu begreifen. Die „kleinen“ Datentypen werden als int
behandelt. Bei den größeren entscheidet der größte Datentyp über den Ergebnistyp. Die folgende Liste zeigt die Zusammenhänge:
char + char => int | wchar_t + char => int char + wchar_t => int | wchar_t + wchar_t => int char + signed char => int | wchar_t + signed char => int char + unsigned char => int | wchar_t + unsigned char => int char + short => int | wchar_t + short => int char + unsigned short => int | wchar_t + unsigned short => int char + int => int | wchar_t + int => int char + unsigned int => unsigned int | wchar_t + unsigned int => unsigned int char + long => long | wchar_t + long => long char + unsigned long => unsigned long | wchar_t + unsigned long => unsigned long signed char + char => int | unsigned char + char => int signed char + wchar_t => int | unsigned char + wchar_t => int signed char + signed char => int | unsigned char + signed char => int signed char + unsigned char => int | unsigned char + unsigned char => int signed char + short => int | unsigned char + short => int signed char + unsigned short => int | unsigned char + unsigned short => int signed char + int => int | unsigned char + int => int signed char + unsigned int => unsigned int | unsigned char + unsigned int => unsigned int signed char + long => long | unsigned char + long => long signed char + unsigned long => unsigned long | unsigned char + unsigned long => unsigned long short + char => int | unsigned short + char => int short + wchar_t => int | unsigned short + wchar_t => int short + signed char => int | unsigned short + signed char => int short + unsigned char => int | unsigned short + unsigned char => int short + short => int | unsigned short + short => int short + unsigned short => int | unsigned short + unsigned short => int short + int => int | unsigned short + int => int short + unsigned int => unsigned int | unsigned short + unsigned int => unsigned int short + long => long | unsigned short + long => long short + unsigned long => unsigned long | unsigned short + unsigned long => unsigned long int + char => int | unsigned int + char => unsigned int int + wchar_t => int | unsigned int + wchar_t => unsigned int int + signed char => int | unsigned int + signed char => unsigned int int + unsigned char => int | unsigned int + unsigned char => unsigned int int + short => int | unsigned int + short => unsigned int int + unsigned short => int | unsigned int + unsigned short => unsigned int int + int => int | unsigned int + int => unsigned int int + unsigned int => unsigned int | unsigned int + unsigned int => unsigned int int + long => long | unsigned int + long => long oder unsigned long int + unsigned long => unsigned long | unsigned int + unsigned long => unsigned long long + char => long | unsigned long + char => unsigned long long + wchar_t => long | unsigned long + wchar_t => unsigned long long + signed char => long | unsigned long + signed char => unsigned long long + unsigned char => long | unsigned long + unsigned char => unsigned long long + short => long | unsigned long + short => unsigned long long + unsigned short => long | unsigned long + unsigned short => unsigned long long + int => long | unsigned long + int => unsigned long long + unsigned int => unsigned long | unsigned long + unsigned int => unsigned long long + long => long | unsigned long + long => unsigned long long + unsigned long => unsigned long | unsigned long + unsigned long => unsigned long
Zugegebenermaßen wirkt dies erst einmal erschlagend aber es ist eigentlich nicht schwierig zu begreifen. Bei jeder Rechenoperation hat jeder der 2 Operanden, sowie das Ergebnis der Rechnung, einen Datentyp:
#include <iostream>
int main(){
char zahl1=22;
short zahl2=40;
std::cout << zahl1 * zahl2 << std::endl; // 22 * 40 = 880
// char + short => int
}
Gleitkommarechnen
[Bearbeiten]Beim Rechnen mit Gleitkommazahlen gelten im Grunde die gleichen Regeln wie bei Ganzzahlen. Der Ergebnistyp entspricht auch hier dem des Operanden mit dem „größeren“ Typ. Die aufsteigende Reihenfolge lautet: float
, double
, long double
. Es gilt also:
float + float => float float + double => double float + long double => long double double + float => double double + double => double double + long double => long double long double + float => long double long double + double => long double long double + long double => long double
Casting
[Bearbeiten]Casting bedeutet in diesem Zusammenhang die Umwandlung eines Datentyps in einen anderen. Diese Typumwandlung kann sowohl automatisch (implizit) stattfinden, als auch vom Programmierer angegeben (explizit) werden.
Implizite Typumwandlung
[Bearbeiten]Mit impliziter Typumwandlung hatten Sie bereits reichlich zu tun, denn es kann ausschließlich mit Zahlen gerechnet werden, die den gleichen Typ besitzen.
Beispiele:
char + int => int | int + int => int short + unsigned int => unsigned int | unsigned int + unsigned int => unsigned int float + double => double | double + double => double
Umformungsregeln
[Bearbeiten]Viele binäre Operatoren, die arithmetische oder Aufzählungsoperanden erwarten, verursachen Umwandlungen und ergeben Ergebnistypen auf ähnliche Weise. Der Zweck ist, einen gemeinsamen Ergebnistyp zu finden. Dieses Muster wird "die üblichen arithmetischen Umwandlungen" genannt, die folgendermaßen definiert sind:
„Gleitkomma geht vor“:
- Wenn ein Operand vom Typ
long double
ist, dann wird der andere zulong double
konvertiert. - Andernfalls, wenn ein Operand vom Typ
double
ist, dann wird der andere zudouble
konvertiert. - Andernfalls, wenn ein Operand vom Typ
float
ist, dann wird der andere zufloat
konvertiert.
Ist kein Gleitkommatyp beteiligt, dann werden folgende Ganzzahl-Umwandlungen auf beide Operanden angewendet:
- Wenn ein Operand vom Typ
unsigned long
ist, dann wird der andere zuunsigned long
konvertiert. - Andernfalls, wenn ein Operand vom Typ
long
und der andere vom Typunsigned int
, dann wird, falls einlong
alle Werte einesunsigned int
darstellen kann, derunsigned int
-Operand zulong
konvertiert; andernfalls werden beide Operanden zuunsigned long
konvertiert. - Andernfalls, wenn ein Operand vom Typ
long
ist, dann wird der andere zulong
konvertiert. - Andernfalls, wenn ein Operand vom Typ
unsigned int
ist, dann wird der andere zuunsigned int
konvertiert.
Hinweis: Der einzig verbleibende Fall ist, dass beide Operanden vom Typ int sind.
Diese Regeln wurden so aufgestellt, dass dabei stets ein Datentyp in einen anderen Datentyp mit "größerem" Wertebereich umgewandelt wird. Das stellt sicher, dass bei der Typumwandlung keine Wertverluste durch Überläufe entstehen. Es können allerdings bei der Umwandlung von Ganzzahlen in float
-Werte Rundungsfehler auftreten:
Für die Berechnung werden zunächst beide Operanden in den Datentyp float
konvertiert und anschließend addiert. Das Ergebnis ist wiederum ein float
und somit aber nicht in der Lage, Zahlen in der Größenordnung von 17 Millionen mit der nötigen Genauigkeit zu speichern, um zwischen 17000000 und 17000001 zu unterscheiden. Das Ergebnis der Addition ist daher wieder 17000000.
Explizite Typumwandlung
[Bearbeiten]In C++ gibt es dafür zwei Möglichkeiten. Zum einen den aus C übernommenen Cast (Typ)Wert
und zum anderen die vier (neuen) C++ Casts.
static_cast< Zieltyp >(Variable)
const_cast< Zieltyp >(Variable)
dynamic_cast< Zieltyp >(Variable)
reinterpret_cast< Zieltyp >(Variable)
Die Leerzeichen zwischen dem Zieltyp und den spitzen Klammern sind nicht zwingend erforderlich, Sie sollten sich diese Notation jedoch angewöhnen. Speziell wenn Sie später mit Templates oder Namensräumen arbeiten, ist es nützlich, Datentypen ein wenig von ihrer Umgebung zu isolieren. Sie werden an den entsprechenden Stellen noch auf die ansonsten möglichen Doppeldeutigkeiten hingewiesen.
Im Moment benötigen Sie nur den static_cast
. Was genau die Unterschiede zwischen diesen Casts sind und wann man welchen einsetzt, erfahren Sie im Kapitel Casts. Auf C-Casts wird in diesem Kapitel ebenfalls eingegangen, merken Sie sich jedoch schon jetzt, dass Sie diese nicht einsetzen sollten. Natürlich müssen Sie sie als C++-Programmierer dennoch kennen, falls Sie einmal auf einen solchen stoßen sollten.
Ganzzahlen und Gleitkommazahlen
[Bearbeiten]Wird mit einer Ganzzahl und einer Gleitkommazahl gerechnet, so ist das Ergebnis vom gleichen Typ wie die Gleitkommazahl.
Rechnen mit Zeichen
[Bearbeiten]Mit Zeichen zu rechnen, ist besonders praktisch. Um beispielsweise das gesamte Alphabet auszugeben, zählen Sie einfach vom Buchstaben 'A'
bis einschließlich 'Z'
:
Für eine Erklärung des obigen Quellcodes lesen Sie bitte das Kapitel Schleifen.
Wenn Sie binäre Operatoren auf Zeichen anwenden, ist das Ergebnis (mindestens) vom Typ int
. Im folgenden Beispiel wird statt eines Buchstabens der dazugehörige ASCII-Wert ausgegeben. Um also wieder ein Zeichen auszugeben, müssen Sie das Ergebnis wieder in den Zeichentyp casten. (Beachten Sie im folgenden Beispiel, dass die Variable i
– im Gegensatz zum vorherigen Beispiel – nicht vom Typ char
ist):
#include <iostream>
int main(){
char zeichen = 'A';
for(int i = 0; i < 26; ++i){
std::cout << zeichen + i << ' '; // Ergebnis int
}
std::cout << std::endl;
for(int i = 0; i < 26; ++i){
std::cout << static_cast< char >(zeichen + i); // Ergebnis char
}
}
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
ABCDEFGHIJKLMNOPQRSTUVWXYZ
Verzweigungen
[Bearbeiten]Eine Verzweigung (bedingte Anweisung, conditional statement) dient dazu, ein Programm in mehrere Pfade aufzuteilen. Beispielsweise kann so auf Eingaben des Benutzers reagiert werden. Je nachdem, was der Benutzer eingibt, ändert sich der Programmablauf.
Falls
[Bearbeiten]Verzweigungen werden mit dem Schlüsselwort if
begonnen. In der einfachsten Form sieht das so aus:
Wenn die Bedingung
erfüllt ist, wird die Anweisung
ausgeführt, ansonsten wird sie übersprungen. Sollen nicht nur eine, sondern mehrere Anweisungen ausgeführt werden, fassen Sie diese mit {...}
zu einer Blockanweisung zusammen:
Als Bedingung
darf jeder Ausdruck verwendet werden, der einen bool
zurückgibt oder dessen Ergebnis sich in einen bool
umwandeln lässt. Ganzzahlige und Gleitkommadatentypen lassen sich nach bool
umwandeln, die Regel lautet: Ist eine Zahl (exakt) gleich 0, so wird sie als false
ausgewertet, andernfalls als true
.
Andernfalls
[Bearbeiten]Das Schlüsselwort else
erweitert die Einsatzmöglichkeiten der Verzweigung. Während ein normales (also einzelnes) if
einen bestimmten Teil des Codes ausführt, falls eine Bedingung erfüllt ist, stellt else
eine Erweiterung dar, anderen Code auszuführen, falls die Bedingung nicht erfüllt ist.
int i;
cin >> i;
if (i)
cout << "Sie haben einen Wert ungleich 0 eingegeben!\n";
else
cout << "Sie haben 0 eingegeben!\n";
Natürlich könnten auch hier, sowohl für die if
-Anweisung, als auch für die else
-Anweisung, ein Anweisungsblock stehen. Wenn Sie Pascal oder eine ähnliche Programmiersprache kennen, wird Ihnen auffallen, dass auch die Anweisung vor dem else
mit einem Semikolon abgeschlossen wird. Da auf eine if
- oder else
-Anweisung immer nur eine Anweisung oder ein Anweisungsblock stehen kann, muss zwangsläufig direkt danach ein else
stehen, um dem if
zugeordnet zu werden.
Sie können in einer Verzweigungsanweisung auch mehr als zwei Alternativen angeben:
int i;
cin >> i;
if (i == 10)
cout << "Sie haben zehn eingegeben\n";
else
if (i == 11)
cout << "Sie haben elf eingegeben\n";
else
cout << "Sie haben weder zehn noch elf eingegeben\n";
Es können beliebig viele Zweige mit else if
vorkommen. Allerdings ist es üblich, eine andere Einrückung zu wählen, wenn solche „if
-else
-Bäume“ ausgebaut werden:
int i;
cin >> i;
if (i == 10)
cout << "Sie haben zehn eingegeben\n";
else if (i == 11)
cout << "Sie haben elf eingegeben\n";
else
cout << "Sie haben weder zehn noch elf eingegeben\n";
Außerdem ist es zu empfehlen, auch bei einer Anweisung einen Anweisungsblock zu benutzen. Letztlich ist die Funktionalität immer die gleiche, aber solche Blöcke erhöhen die Übersichtlichkeit und wenn Sie später mehrere Anweisungen, statt nur einer angeben möchten, brauchen Sie sich um etwaige Klammern keine Gedanken zu machen, weil sie sowieso schon vorhanden sind.
int i;
cin >> i;
if (i == 10) {
cout << "Sie haben zehn eingegeben\n";
} else if(i == 11) {
cout << "Sie haben elf eingegeben\n";
} else {
cout << "Sie haben weder zehn noch elf eingegeben\n";
}
Sie werden für die Positionierung der Klammern übrigens auch oft auf eine andere Variante treffen:
int i;
cin >> i;
if (i == 10)
{
cout << "Sie haben zehn eingegeben\n";
}
else
{
if (i == 11)
{
cout << "Sie haben elf eingegeben\n";
}
else
{
cout << "Sie haben weder zehn noch elf eingegeben\n";
}
}
Einige Programmierer finden dies übersichtlicher, für dieses Buch wurde jedoch die Variante mit den öffnenden Klammern ohne Extrazeile verwendet. Das hat den Vorteil, dass weniger Platz benötigt wird und da die Einrückung ohnehin die Zugehörigkeit andeutet, ist eine zusätzliche Kennzeichnung nicht unbedingt nötig.
Die Einrückung von Quelltextzeilen hat für den Compiler übrigens keine Bedeutung. Sie ist lediglich eine grafische Darstellungshilfe für den Programmierer. Auch die in diesem Buch gewählte Einrückungstiefe von vier Leerzeichen ist optional, viele Programmierer verwenden etwa nur zwei Leerzeichen. Andere hingegen sind davon überzeugt, dass acht die ideale Wahl ist. Aber egal, wofür Sie sich entscheiden, wichtig ist, dass Sie Ihren Stil einhalten und nicht ständig ihren Stil wechseln. Das verwirrt nicht nur, sondern sieht auch nicht schön aus.
Wenn es Sie nicht stört, in Ihrem Texteditor Tabulatorzeichen und Leerzeichen anzeigen zu lassen, dann sollten Sie für die Einrückung Tabulatorzeichen verwenden und für alles hinter der normalen Einrückung (etwa den Abstand bis zum Kommentar) Leerzeichen. Das hat den Vorteil, dass Sie die Einrückungstiefe jederzeit ändern können, indem Sie angeben wie viele Leerzeichen einem Tabulatorzeichen entsprechen.
Nachteil: Wenn Sie den Quelltext in verschiedenen Editoren bearbeiten oder weitergeben, muss in jedem Editor eingestellt werden, was die Tabulator-Breite sein soll. Beim Verwenden von Leerzeichen bleibt die Einrückung immer gleich, auch wenn die Tabulatorbreite verschieden eingestellt sein sollte.
Vergleichsoperatoren
[Bearbeiten]Im obigen Beispiel kam schon der Vergleichsoperator ==
zum Einsatz. In C++ gibt es insgesamt sechs Vergleichsoperatoren. Sie liefern jeweils den Wert true
, wenn die beiden Operanden (die links und rechts des Operators stehen) dem Vergleichskriterium genügen, ansonsten den Wert false
.
==
|
identisch |
<=
|
ist kleiner (oder) gleich |
>=
|
ist größer (oder) gleich |
<
|
ist kleiner |
>
|
ist größer |
!=
|
ist ungleich |
Der Vergleichsoperator ==
wird von Anfängern oft mit dem Zuweisungsoperator =
verwechselt. Da es absolut legal ist, eine Zuweisung innerhalb einer if
-Bedingung zu machen, führt das oft zu schwer zu findenden Fehlern. Eine Zuweisung wird ausgewertet zum zugewiesenen Wert.
Problem-Beispiel:
Die „Bedingung“ weist den Wert von b
an die Variable a
zu (a = 8
). Für das if
wird der Gesamtausdruck ausgewertet (also 8
), was true
bedeutet.
Prüfen Sie bei seltsamen Verhalten also immer, ob vielleicht der Zuweisungsoperator =
statt des Gleichheitsoperators ==
verwendet wurde.
Eine weitere Falle ist der Ungleichheitsoperator !=
, wenn er falsch herum geschrieben wird (=!
). Letzteres sind in Wahrheit zwei Operatoren, nämlich die Zuweisung =
und die logische Negierung !
, die Sie gleich kennen lernen werden. Um das zu unterscheiden, machen Sie sich einfach klar, was das in Worten heißt:
!=
– nicht gleich=!
– gleich nicht
Logische Operatoren
[Bearbeiten]Mit logischen Operatoren können Sie mehrere Bedingungen zu einem Ausdruck verknüpfen. C++ bietet folgende Möglichkeiten:
!
|
Logisches Nicht | Resultat wahr, wenn der Operand falsch ist |
&&
|
Logisches Und | Resultat wahr, wenn beide Operanden wahr sind |
||
|
Logisches Oder | Resultat wahr, wenn mindestens ein Operand wahr ist (inclusive-or) |
Die Operatoren lassen sich übersichtlich mit Wahrheitstafeln beschreiben (bei der hier gewählten Darstellung ist jede Spalte für sich zu lesen):
Logisches Und (&& )
| ||||
---|---|---|---|---|
a |
true |
true |
false |
false
|
b |
true |
false |
true |
false
|
a && b |
true |
false |
false |
false
|
Beispiel für die dritte Spalte: Mit a = false
und b = true
gilt a && b -> false
.
Logisches Oder (|| )
| ||||
---|---|---|---|---|
a |
true |
true |
false |
false
|
b |
true |
false |
true |
false
|
a || b |
true |
true |
true |
false
|
Logisches Nicht (! )
| ||
---|---|---|
a |
true |
false
|
!a |
false |
true
|
Beispiel:
Aus Gründen der Lesbarkeit sollten Vergleichsausdrücke grundsätzlich von Klammern umgeben sein. Der obige Code würde folglich so aussehen:
Sowohl beim &&
-Operatoren (Logik-und) als auch beim ||
-Operator (Logik-oder) werden die Teilausdrücke von links nach rechts bewertet, und zwar nur so lange, bis das Resultat feststeht. Wenn z. B. bei einer &&
-Verknüpfung A && B && C
schon die erste Bedingung 'A' falsch ist, werden 'B' und 'C' gar nicht mehr untersucht, da bei &&
ja alle Bedingungen true
sein müssen.
Der Rückgabewert der beiden Operatoren ist vom Typ bool
.
Gelegentlich ist daher anzutreffen:
if ( (variable_ist_gueltig) && (variable_erfuellt_zusaetzliche_detailbedingung) ) { /* mache etwas */ }
Ob die Variable die Detailbedingung erfüllt, kann nur geprüft werden, wenn sie einen gültigen Wert enthält. Die erste Bedingung auf Gültigkeit schützt somit die nachfolgende davor, mit ungültigen Werten arbeiten zu müssen.
Beachten Sie bitte, dass der UND-Operator (&&
) eine höhere Priorität als der ODER-Operator (||
) hat. Das heißt, Sie müssen bei Ausdrücken wie dem Folgenden vorsichtig sein.
int i = 10, j = 20;
// Erwartete Reihenfolge ((((i == 10) || (j == 20)) && (j == 20)) && (i == 5))
// Tatsächliche Reihenfolge ((i == 10) || (((j == 20) && (j == 20)) && (i == 5)))
if (i == 10 || j == 20 && j == 20 && i == 5) {
cout << "i ist Zehn und fünf oder (j ist Zwanzig und i fünf)!\n";
} else {
cout << "i ist nicht Zehn oder (j ist Zwanzig oder i nicht fünf)!\n";
}
i ist Zehn und fünf oder (j ist Zwanzig und i fünf)!
Die Ausgabe ist von der Logik her falsch, weil der Ausdruck in der Reihenfolge ((i == 10) || (((j == 20) && (j == 20)) && (i == 5)))
ausgewertet wird. Solche Fehler sind sehr schwer zu finden, also sollten Sie sie auch nicht machen. Daher der Tipp: Verwenden Sie bei solch komplexen Bedingungen immer Klammern, um klar zu machen, in welcher Reihenfolge Sie die Ausdrücke auswerten wollen. Das ist unmissverständlich, und der menschliche Leser liest den Ausdruck genauso wie der Compiler. Um zu erkennen, an welcher Stelle eine Klammer wieder geschlossen wird, beherrschen die meisten Editoren das sogenannte Bracket Matching. Dabei hebt der Editor (automatisch oder über einen bestimmten Hotkey) die schließende Klammer hervor.
Wenn Sie mit mehreren &&
und ||
arbeiten, dann schreiben Sie den Ausdruck, der am wahrscheinlichsten zutrifft, auch am weitesten links, also vor den anderen Ausdrücken. Das ist eine (zugegebenermaßen) sehr geringfügige Optimierung, aber es gibt Situationen in denen sie trotzdem sinnvoll ist. Beispielsweise wenn die Bedingung innerhalb einer Schleife sehr oft ausgeführt wird.
Hinweis für fortgeschrittene Leser: Beachten Sie bitte, dass für überladende Operatoren andere Regeln gelten. Alle diejenigen, die noch nicht wissen, was überladende Operatoren sind, brauchen sich um diesen Hinweis (noch) nicht zu kümmern.
Bedingter Ausdruck
[Bearbeiten]Häufig werden Verzweigungen eingesetzt, um abhängig vom Wert eines Ausdrucks eine Zuweisung vorzunehmen. Das können Sie mit dem Auswahloperator ? ... : ...
auch einfacher formulieren:
Grafisch sieht das so aus:
Der Variablen min
wird der kleinere, der beiden Werte a
und b
zugewiesen. Analog zum Verhalten der logischen Operatoren wird nur derjenige „Zweig“ bewertet, der nach Auswertung der Bedingung (a < b
) tatsächlich ausgeführt wird.
Der Bedingungsoperator kann auch wie folgt verwendet werden:
Im Gegensatz zur if...else
-Anweisung ist es hier aber nicht möglich, in Abhängigkeit zu einer Bedingung,
nur eine Anweisung auszugeben, indem man die else
-Anweisung einfach weg lässt:
Das liegt daran, dass der Bedingungsoperator ?:
keine Kontrollstruktur im eigentlichen Sinne ist.
Vielmehr wird mit (a) ? make(b) : make(c);
ein Ausdruck „berechnet“, der einen Wert aufweisen muss,
während sich die if
-Anweisung logisch in einen "Tu-nichts-Pfad" auflösen kann.
Schleifen
[Bearbeiten]Mit dem, was Sie bis jetzt gelernt haben, sollte es für Sie eine leichte Übung sein, die Zahlen von eins bis zehn ausgeben zu lassen. So könnte ein Programm aussehen, das dies tut:
Dieses Programm ist einfach – aber was wäre, wenn die Zahlen eins bis einer Million ausgegeben werden sollen? Oder schlimmer noch – ja, es geht noch schlimmer: die Ausgabe hängt von der Benutzereingabe ab. Dann müssten Sie von der größtmöglichen Zahl ausgehen (bei unsigned int
üblicherweise 4.294.967.295) und zusätzlich noch nach jeder Ausgabe überprüfen, ob die vom Benutzer eingegebene Zahl erreicht ist.
#include <iostream>
int main() {
unsigned i = 1; // 2 Variablen anlegen
unsigned benutzer = 0;
std::cin >> benutzer; // Benutzer gibt Zahl ein
if (i <= benutzer) { // Benutzereingabe erreicht?
std::cout << "1\n"; // Ausgabe der Zahl 1
++i; // Ausgegebene Zahlen mitzählen
} else {
return 0; // Anwendung beenden
}
if (i <= benutzer) { // Benutzereingabe erreicht?
std::cout << "2\n"; // Ausgabe der Zahl 2
++i; // Ausgegebene Zahlen mitzählen
} else {
return 0; // Anwendung beenden
}
// ...
if (i <= benutzer) { // Benutzereingabe erreicht?
std::cout << "4294967295\n"; // Ausgabe der Zahl 4294967295
++i; // Ausgegebene Zahlen mitzählen
} else {
return 0; // Anwendung beenden
}
// Wenn Ihr Compiler diese Stelle erreichen soll, brauchen Sie einen leistungsstarken
// Rechner und ein robustes Betriebssystem.
// Um dieses Problem zu lösen, setzen Sie sich einfach in die nächste Zeitmaschine
// und bestellen Sie sich einen Rechner aus dem Jahr 2030!
}
Insgesamt würden sich über 17,1 Milliarden Zeilen Code ergeben.
An diesem Punkt kommen Schleifen ins Spiel. Bis jetzt wurden alle Programme einfach der Reihe nach abgearbeitet und zwischendurch wurde eventuell mal verschiedenen Zweigen gefolgt. Mit einer Schleife können Sie erreichen, dass ein Programmteil mehrfach abgearbeitet wird. C++ stellt drei Schleifenkonstrukte zur Verfügung. Die kopfgesteuerte while
-Schleife, die fußgesteuerte do-while
-Schleife und die (ebenfalls kopfgesteuerte) for
-Schleife. Was kopf- und fußgesteuerte Schleife bedeutet erfahren Sie in Kürze. Vielleicht wissen Sie es aber auch schon aus dem Kapitel „Grundlegende Elemente“, aus dem Abschnitt „Für Programmieranfänger“.
Die while
-Schleife
[Bearbeiten]Eine while
-Schleife hat die folgende allgemeine Form:
Natürlich können Sie, wie bei den Verzweigungen auch, hier wieder mehrere Anweisungen zu einem Anweisungsblock zusammenfassen, da diese als eine Anweisung gilt:
Solange die Bedingung erfüllt ist, wird die Anweisung oder der Anweisungsblock ausgeführt. Da es sich hier um eine kopfgesteuerte Schleife handelt, wird erst die Bedingung ausgewertet. Ist diese erfüllt, so wird dann die Anweisung ausgeführt. Ist die nächste Überprüfung der Bedingung positiv, so wird der Schleifenrumpf erneut ausgeführt und so fort. Ist die Bedingung nicht erfüllt, wird der Schleifeninhalt übersprungen und mit dem Quelltext nach der Schleife fortgesetzt. Es kann daher vorkommen, dass der Schleifenrumpf gar nicht ausgeführt wird - wenn die Bedingung schon zu Beginn nicht erfüllt wird.
Was eine fußgesteuerte Schleife macht, erfahren Sie unter der Überschrift do-while
. Eine Gegenüberstellung der beiden Schleifen gibt es in der Zusammenfassung dieses Kapitels.
Wie Bedingungen ausgewertet werden, können Sie im Kapitel „Verzweigungen“ nachlesen - das vorherige Kapitel.
Beachten Sie, dass Schleifen so lange ausgeführt werden, bis die Bedingung nicht mehr erfüllt ist. Wenn Sie also nicht innerhalb der Schleife dafür sorgen, dass die Bedingung irgendwann nicht mehr erfüllt ist, dann haben Sie eine sogenannte Endlosschleife. Das heißt, der Schleifenrumpf (so nennt man die Anweisung oder den Anweisungsblock einer Schleife) wird immer wieder ausgeführt. Das Programm kann nur durch Abbrechen beendet werden. In Kombination mit einem Schlüsselwort, das Sie in Kürze kennen lernen werden, kann eine solche Endlosschleife durchaus gewollt und sinnvoll sein (der Befehl kann die Schleife abbrechen), aber in der Regel entsteht so etwas versehentlich. Wenn Ihr Programm also mal „abgestürzt“ ist, im Sinne von „Es reagiert nicht mehr! Wie schrecklich!“, dann haben Sie vermutlich irgendwo eine Endlosschleife eingebaut.
Nun aber zurück zu unserer Schleifenaufgabe mit 17,1 Milliarden Zeilen Code. Da Ihr Compiler immer noch nicht damit fertig ist, die oben vorgestellte Lösung zu übersetzen, versuchen wir jetzt mal das ganze mit einer while
-Schleife zu lösen.
#include <iostream>
int main() {
unsigned i = 1; // 2 Variablen anlegen
unsigned benutzer = 0;
std::cin >> benutzer; // Benutzer gibt Zahl ein
while (i <= benutzer) { // Benutzereingabe erreicht?
std::cout << i << std::endl; // Ausgabe von i und einem Zeilenumbruch
i++; // i um eins erhöhen (-> ausgegebene Zahlen mitzählen)
}
}
Nun ja, das sieht dem Programm von oben doch irgendwie ähnlich, nur die knapp 4,3 Milliarden if
-Anweisungen sind weggefallen und haben einer vom Aufbau fast identischen while
-Schleife Platz gemacht. Nun haben wir natürlich ein Problem: Ihr Superrechner aus dem Jahr 2020 ist immer noch mit der Übersetzung der ersten Programmversion beschäftigt und wird es wohl auch noch bis zu seiner Erfindung bleiben. Aber zum Glück haben Sie ja noch einen alten Rechner von 1980. Also versuchen Sie das Programm auf diesem zu übersetzen und auszuführen. Tatsächlich. Es funktioniert. Erstaunlich, dass so eine alte Kiste einen Rechner überholt, den Sie sich extra aus der Zukunft haben liefern lassen, um die maximale Leistungsstärke zu bekommen.
Wenn Sie Schwierigkeiten haben das Beispiel nachzuvollziehen, dann sehen Sie sich noch mal die Schleifen-Version für die Zahlen von 1 bis 10 an.
So sieht das Ganze schon viel kompakter und vielleicht auch übersichtlicher aus als die Version von ganz oben. Die Ausgabe dagegen ist völlig identisch. Hier wird i
am Anfang auf eins gesetzt. Somit ist die Bedingung (eins ist kleiner oder gleich zehn) erfüllt. Damit wird der Schleifenrumpf ausgeführt. „1“ wird ausgegeben. Es folgt ein Zeilenumbruch. Danach wird der Wert von i
um eins erhöht. Danach wird wieder geprüft, ob die Bedingung erfüllt ist. Da i
jetzt zwei ist, lautet die Bedingung „zwei ist kleiner oder gleich zehn“, da dies eine wahre Aussage ist, wird wieder der Schleifenrumpf ausgeführt. Das wiederholt sich bis i
Schließlich den Wert elf hat. Die Bedingung lautet dann „elf ist kleiner oder gleich zehn“, diese Aussage ist zweifellos falsch. Daher wird der Schleifenrumpf nun übersprungen und mit dem Code dahinter weitergemacht. Da in unserem Beispiel dort aber kein Code mehr folgt, wird das Programm beendet.
Zusammengefasst:
- Quelltext vor der Schleife
- Schleifen Bedingung
- Erfüllt:
- Schleifenrumpf
- weiter mit 2
- Nicht erfüllt:
- weiter mit 3
- Erfüllt:
- Quelltext nach der Schleife
Die do-while
-Schleife
[Bearbeiten]Wie versprochen lüften wir nun das Geheimnis um die fußgesteuerten Schleifen. do-while
ist eine fußgesteuerte Schleife, das heißt, als erstes wird der Schleifenrumpf ausgeführt, danach die Bedingung überprüft und dann abhängig von der Bedingung, wieder der Rumpf ausgeführt (Bedingung erfüllt) oder mit dem Quelltext nach der Schleife fortgesetzt (Bedingung nicht erfüllt). Eine fußgesteuerte Schleife zeichnet sich also dadurch aus, dass der Schleifenrumpf mindestens ein mal ausgeführt wird.
Bei dieser Schleife finden wir die obige Syntax nur selten, da der Schleifenrumpf hier zwischen den beiden Schlüsselwörtern do
und while
steht, wird fast immer ein Anweisungsblock benutzt, auch wenn nur eine Anweisung vorhanden ist. Für Ihren Compiler spielt das natürlich keine Rolle, aber für einen Menschen der den Quelltext liest, ist es übersichtlicher.
Unser anfängliches Riesenprogramm sieht mit einer do-while
Schleife so aus:
#include <iostream>
int main() {
unsigned i = 1; // 2 Variablen anlegen
unsigned benutzer = 0;
std::cin >> benutzer; // Benutzer gibt Zahl ein
do { // Schleifenanfang
std::cout << i << std::endl; // Ausgabe von i
++i; // Ausgegebene Zahlen mitzählen
} while (i <= benutzer); // Benutzereingabe erreicht?
}
Sie werden feststellen, dass die Ausgabe dieses Programms mit der Ausgabe in der while
-Schleifen Version übereinstimmt. Den Unterschied bemerken Sie, wenn Sie 0 eingeben: Während die while
-Version keine Ausgabe macht, gibt diese do-while
-Version „1“ und einen Zeilenumbruch aus, denn der Schleifenrumpf wird immer erst einmal ausgeführt, erst danach wird die Bedingung überprüft und entschieden ob er noch einmal ausgeführt werden muss.
Zusammengefasst:
- Quelltext vor der Schleife
- Schleifenrumpf
- Schleifen Bedingung
- Erfüllt: weiter mit 2
- Nicht erfüllt: weiter mit 4
- Quelltext nach der Schleife
Die for
-Schleife
[Bearbeiten]Die for
-Schleife ist etwas komplexer als die vorherigen beiden Schleifen. Sie gliedert sich in Teile:
for(«Initialisierungsteil»; «Bedingungsteil»; «Anweisungsteil») «Schleifenrumpf»
Der Schleifenrumpf kann wie immer eine einzelne Anweisung oder ein {}-Anweisungsblock sein. Der Bedingungsteil verhält sich genau wie bei der while
- und der do-while
-Schleife oder sagen wir fast genau so, denn einen kleinen aber feinen Unterschied gibt es doch. Während while
und do-while
immer eine Bedingung erwarten, muss bei einer for
-Schleife nicht unbedingt eine Bedingung angegeben werden. Wenn keine Bedingung angegeben ist, wird einfach angenommen, dass die Bedingung immer erfüllt ist, Sie erhalten eine Endlosschleife. Wie das sinnvoll eingesetzt wird erfahren Sie in Kürze.
Im Anweisungsteil können Sie eine beliebige Anweisung ausführen, dieser Teil wird oft verwendet, um Variablen bei jedem Schleifendurchlauf hoch oder runter zu zählen. Im nächsten Beispiel wird dies auch demonstriert. Es ist auch möglich, mehrere solcher „hoch- oder runter-Zähl“-Anweisungen durch Komma getrennt anzugeben, das wird im nächsten Beispiel aber nicht gemacht. Der Anweisungsteil wird übrigens direkt nach dem Schleifenrumpf und vor dem nächsten Bedingungstest ausgeführt. Der Initialisierungsteil ist dem Anweisungsteil dahingehend ähnlich, als dass auch hier eine beliebige Anweisung ausgeführt werden kann. Zusätzlich ist es hier aber noch möglich Variablen eines Datentyps anzulegen. Sie können also problemlos 2 int
-Variablen anlegen, aber nicht eine int
- und eine char
-Variable. Der Initialisierungsteil wird nur einmal am Beginn der Schleife ausgeführt.
Im Kapitel Lebensdauer und Sichtbarkeit von Objekten werden Sie noch etwas genaueres darüber erfahren wo Sie die im Initialisierungsteil der Schleife angelegten Variablen verwenden können. Dort werden Sie auch erfahren wie eine for
-Schleife mit Hilfe einer while
-Schleife „nachgebaut“ werden kann.
Jetzt sollten Sie sich jedoch merken, dass Sie eine solche Variable nur innerhalb der Schleife verwenden können, also nicht mehr nach ihrem Verlassen.
#include <iostream>
int main() {
unsigned benutzer = 0; // Variablen für Benutzereingabe
std::cin >> benutzer; // Benutzer gibt Zahl ein
for(unsigned i = 1; i <= benutzer; ++i) // for-Schleife
std::cout << i << std::endl; // Ausgabe von i
}
Zusammengefasst:
- Quelltext vor der Schleife
- Initialisierungsteil der Schleife
- Schleifen Bedingung
- Erfüllt:
a) Schleifenrumpf
b) Anweisungsteil
c) weiter mit 3 - Nicht erfüllt: weiter mit 4
- Erfüllt:
- Quelltext nach der Schleife
Seit C++11 gibt noch eine weitere Form der for
-Schleife. In vielen anderen Programmiersprachen nennt man diese Form die »foreach«-Schleife:
An dieser Stelle des Buches lässt sich die Funktionsweise noch schwer erklären, da die Datenstrukturen (welche hier mit «Container» angedeutet sind) noch nicht behandelt wurden. Daher wird erst später genauer auf diese Form der for
-Schleife eingegangen.
Zu erkennen ist sie daran, das es keine zwei Semikolons zwischen den Klammern gibt, sondern einen Doppelpunkt.
Die break
-Anweisung
[Bearbeiten]Jetzt ist es an der Zeit, das Geheimnis um die sinnvolle Verwendung von Endlosschleifen zu enthüllen. Die break
-Anweisung wird innerhalb von Schleifen verwendet, um die Schleife sofort zu beenden. Der Quelltext wird dann ganz normal nach der Schleife fortgesetzt. break
können Sie in jeder der drei Schleifen verwenden. Das folgende Beispiel demonstriert den Einsatz von break
, anhand unseres Lieblingsprogramms in diesem Kapitel.
#include <iostream>
int main() {
unsigned i = 1; // 2 Variablen anlegen
unsigned benutzer = 0;
std::cin >> benutzer; // Benutzer gibt Zahl ein
for(;;){ // Endlos-for-Schleife
std::cout << i << "\n"; // Ausgabe von i
++i; // Variable erhöhen
if(i > benutzer) break; // Abbrechen, wenn Bedingung erfüllt
}
}
Sie können aus jeder Schleife eine Endlosschleife machen, hier wurde die for
-Schleife gewählt um zu zeigen wie Sie ohne Bedingung aussieht. Bei einer while
- oder do-while
-Schleife könnten Sie beispielsweise true
als Bedingung angeben. Die for-Schleife legt jetzt übrigens das gleiche Verhalten an den Tag, wie eine do-while
-Schleife. Sie können auch problemlos nur einen oder zwei Teile des Schleifenkopfes bei for
-Schleife übergeben, wichtig ist nur, dass Sie die beiden Semikolons immer angeben, da Sie dem Compiler mitteilen, was welcher Teil des Schleifenkopfes ist.
Nun wissen Sie wieder etwas mehr über for
, dabei sollte es unter dieser Überschrift doch eigentlich um break
gehen. Wie Sie aber sehen, hängt letztlich alles mit allem zusammen und so ist es oft schwer eine richtige Abgrenzung zu schaffen.
Die continue
-Anweisung
[Bearbeiten]Das zweite wichtige Schlüsselwort für Schleifen ist continue
. Es wird genau so verwendet wie break
, bricht die Schleife allerdings nicht völlig ab, sondern setzt die Codeausführung am Ende des Schleifenrumpfes fort. Für while
und do-while
bedeutet das beim Bedingungstest, für die for
-Schleife beim Anweisungsteil. Mit continue
können Sie also den Rest des aktuellen Schleifendurchlaufs überspringen. Wir sehen uns das wieder anhand des Beispiels an.
#include <iostream>
int main() {
unsigned benutzer = 0; // Variablen für Benutzereingabe
std::cin >> benutzer; // Benutzer gibt Zahl ein
for(unsigned i = 1; i <= benutzer; ++i) { // for-Schleife
if (i % 2 == 0) continue; // alle geraden Zahlen überspringen
std::cout << i << std::endl; // Ausgabe von i
}
}
Hier werden nur ungrade Zahlen ausgegeben, da der %
-Operator (liefert den Rest einer Ganzzahligen Division) bei allen geraden Zahlen, (teilt man durch 2 ist als Rest ja nur 0 oder 1 möglich,) 0 zurückliefert und somit continue
ausgeführt wird.
Natürlich könnten Sie das auch noch auf eine andere Weise realisieren. Aber es gibt ja beim Programmieren viele Wege, die nach Rom führen, wie in diesem Kapitel anhand der verschieden Schleifen schon bewiesen wurde. Leider gibt es aber noch mehr Wege, die auf direktem Wege an Rom vorbeiführen... Aber hier ist für das Beispiel von eben noch ein Pfad, der sicher nach Rom führt:
#include <iostream>
int main() {
unsigned int benutzer = 0; // Variablen für Benutzereingabe
std::cin >> benutzer; // Benutzer gibt Zahl ein
for(unsigned i = 1; i <= benutzer; i += 2) // for-Schleife mit 2er Schritten
std::cout << i << std::endl; // Ausgabe von i
}
Strukturierte Programmierung
[Bearbeiten]Die Anweisungen 'break' und 'continue' gelten als „unsauber“, sie erschweren fast immer das Verständnis des Programms. Die Verwendung von 'break' ist oft ein Zeichen dafür, dass die Schleifenbedingung unvollständig ist - und man daher nicht einzig aus der Schleifenbedingung ersehen kann, wie lange die Schleife läuft. Die 'continue'-Anweisung kann meist durch eine 'if'-Anweisung ersetzt werden - oder ebenfalls durch eine exaktere Schleifenbedingung. In als „gut“ erachteten Programmen kommen 'break' und 'continue' möglichst gar nicht vor.
Kapitelanhang
[Bearbeiten]Im Anhang zu diesem Kapitel finden Sie:
- Aufgaben und zugehörige Musterlösungen.
- Am Anfang gibt der Spieler einen Zahlenbereich ein. (Zum Beispiel: 1-100)
- Der Spieler muss sich innerhalb dieses Bereiches eine Zahl merken (eingegebene Grenzzahlen sind nicht zulässig).
- Das Programm soll dann die Zahl erraten. Der Benutzer teilt dem Programm mit, ob die Zahl an die er denkt kleiner, größer oder gleich der vom Programm geratenen Zahl ist. Die kann zum Beispiel über die Eingabe von
<
,>
und=
erfolgen.
Es gibt natürlich viele Wege dieses Problem zu lösen und Sie sehen ja ob Ihr Programm funktioniert oder nicht. Hier wird nur eine Musterlösung vorgestellt, falls Sie überhaupt nicht zurechtkommen oder sich einfach dafür interessieren wie der Autor an das Problem herangegangen ist.
#include <iostream>
int main() {
int min, max; // Variablen für den möglichen Zahlenbereich
int zahl; // Zahl die der Rechner vermutet
std::cout << "Wo fängt die Zahlenreihe an?: "; // Zahlenbereich abfragen
std::cin >> min; // Benutzereingabe einlesen
std::cout << "Wo hört die Zahlenreihe auf?: "; // Zahlenbereich abfragen
std::cin >> max; // Benutzereingabe einlesen
for (char eingabe = '0'; eingabe != '=';) { // Abbrechen wenn eingabe '=' ist
zahl = min + (max - min) / 2; // Mittlere Zahl berechnen
std::cout << "Denken Sie an " << zahl << "? "; // Vermutung ausgeben
std::cin >> eingabe; // Antwort einlesen
if (eingabe == '<') // Ist die Zahl kleiner?
max = zahl; // Setzte max auf den zu großen Wert zahl
else if (eingabe == '>') // Ist die Zahl größer?
min = zahl; // Setzte min auf den zu kleinen Wert zahl
else if (eingabe != '=') // Ist Eingabe auch kein Gleichheitszeichen
std::cout << "Sie haben ein unzulässiges Zeichen eingegeben!\n"; // Fehlerhafte Eingabe melden
if (min+1 >= max) { // Keine Zahl mehr im gültigen Bereich
std::cout << "Sie sind ein Lügner!\n"; // Das Programm ist äußert entsetzt
break; // Schleife wird abgebrochen
}
}
std::cout << "Die von Ihnen gemerkte Zahl ist " << zahl << "!" << std::endl; // Ausgabe der erratenen Zahl
}
Wo fängt die Zahlenreihe an?: 0
Wo hört die Zahlenreihe auf?: 100
Denken Sie an 50? <
Denken Sie an 25? >
Denken Sie an 37? <
Denken Sie an 31? >
Denken Sie an 34? <
Denken Sie an 32? >
Denken Sie an 33? =
Die von Ihnen gemerkte Zahl ist 33!
Dies ist eine Beispielausgabe, alles nach einem Fragezeichen ist eine Benutzereingabe.
Auswahl
[Bearbeiten]Verzweigungen kennen Sie schon, daher sollte es Ihnen nicht schwer fallen, ein Programm zu schreiben, welches abfragt, welche der verschiedenen, in der Überschrift benannten Leckereien, Sie am liebsten mögen. Das ganze könnte etwa so aussehen:
#include <iostream>
using namespace std;
int main(){
int auswahl;
cout << "Wählen Sie Ihre Lieblingsleckerei:\n"
"1 - Käsesahnetorte\n"
"2 - Streuselkuchen\n"
"3 - Windbeutel\n";
cin >> auswahl;
if(auswahl==1)
cout << "Sie mögen also Käsesahnetorte!\n";
else if(auswahl==2)
cout << "Streuselkuchen ist ja auch lecker...\n";
else if(auswahl==3)
cout << "Windbeutel sind so flüchtig wie ihr Name, das können Sie sicher bestätigen?\n";
else
cout << "Wollen Sie wirklich behaupten, dass Ihnen nichts davon zusagt?\n";
return 0;
}
Wählen Sie Ihre Lieblingsleckerei:
1 - Käsesahnetorte
2 - Streuselkuchen
3 - Windbeutel
<Eingabe>2</Eingabe>
Streuselkuchen ist ja auch lecker...
Das funktioniert natürlich, aber finden Sie nicht auch, dass der (Schreib-)Aufwand ein wenig zu groß ist? Außerdem ist diese Schreibweise nicht gerade sofort einleuchtend. Für unseren Fall, wäre es schöner, wenn wir die Variable auswahl
als zu untersuchendes Objekt festlegen könnten und dann einfach alle Fälle die uns interessieren durchgehen könnten. Praktischerweise bietet C++ ein Schlüsselwort, das genau dies ermöglicht.
switch
und break
[Bearbeiten]Eine switch
-Anweisung hat die folgende Form:
switch(«Ganzzahlige Variable»){
case «Ganzzahl»: «Anweisungsblock»
case «Ganzzahl»: «Anweisungsblock»
«...»
default: «Anweisungsblock»
}
Auf unser Beispiel angewandt, würde das dann so aussehen:
#include <iostream>
using namespace std;
int main(){
int auswahl;
cout << "Wählen Sie Ihre Lieblingsleckerei:\n"
"1 - Käsesahnetorte\n"
"2 - Streuselkuchen\n"
"3 - Windbeutel\n";
cin >> auswahl;
switch(auswahl){
case 1: cout << "Sie mögen also Käsesahnetorte!";
case 2: cout << "Streuselkuchen ist ja auch lecker...";
case 3: cout << "Windbeutel sind so flüchtig wie ihr Name, das können Sie sicher bestätigen?";
default: cout << "Wollen Sie wirklich behaupten, dass Ihnen nichts davon zusagt?";
}
return 0;
}
Wählen Sie Ihre Lieblingsleckerei:
1 - Käsesahnetorte
2 - Streuselkuchen
3 - Windbeutel
<Eingabe>2</Eingabe>
Streuselkuchen ist ja auch lecker...
Windbeutel sind so flüchtig wie ihr Name, das können Sie sicher bestätigen?
Wollen Sie wirklich behaupten, dass Ihnen nichts davon zusagt?
Nun ja, Sie haben ein neues Schlüsselwort kennen gelernt, setzen es ein und kommen damit auch prompt zum falschen Ergebnis. Sieht so aus, als würde jetzt jeder der Sätze ab dem Ausgewählten ausgegeben. In der Tat handelt switch
genau so. Es führt alle Anweisungen, ab dem Punkt aus, an dem das case
-Argument mit der übergebenen Variable (in unserem Fall auswahl
) übereinstimmt. Um das Ausführen nachfolgender Anweisungen innerhalb von switch
zu verhindern, benutzen Sie das Schlüsselwort break
. Unser Beispiel sieht dann also folgendermaßen aus:
#include <iostream>
using namespace std;
int main(){
int auswahl;
cout << "Wählen Sie Ihre Lieblingsleckerei:\n"
"1 - Käsesahnetorte\n"
"2 - Streuselkuchen\n"
"3 - Windbeutel\n";
cin >> auswahl;
switch(auswahl){
case 1:
cout << "Sie mögen also Käsesahnetorte!";
break;
case 2:
cout << "Streuselkuchen ist ja auch lecker...";
break;
case 3:
cout << "Windbeutel sind so flüchtig wie ihr Name, das können Sie sicher bestätigen?";
break;
default:
cout << "Wollen Sie wirklich behaupten, dass Ihnen nichts davon zusagt?";
}
return 0;
}
Wählen Sie Ihre Lieblingsleckerei:
1 - Käsesahnetorte
2 - Streuselkuchen
3 - Windbeutel
<Eingabe>2</Eingabe>
Streuselkuchen ist ja auch lecker...
Nun haben Sie wieder das ursprüngliche, gewünschte Verhalten. Was Sie noch nicht haben, ist eine Erklärung, warum extra ein break
aufgerufen werden muss, um das Ausführen folgender case
-Zweige zu unterbinden. Aber das werden Sie gleich erfahren.
Nur Ganzzahlen
[Bearbeiten]Wie bereits aus der oben stehenden Syntaxangabe hervorgeht, kann switch
nur mit Ganzzahlen benutzt werden. Dies hängt damit zusammen, dass switch
ausschließlich auf Gleichheit mit einem der case
-Zweige vergleicht. Bei int
-Werten (mit der üblichen Größe von 4 Byte) ist das noch eine „überschaubare“ Menge von maximal 4.294.967.296 () case
-Zweigen. Ein float
kann die gleiche Menge unterschiedlicher Zahlen darstellen, sofern er ebenfalls 4 Byte groß ist. Aber versuchen Sie mal einen genauen float
-Wert aufzuschreiben. Sie arbeiten also immer mit Näherungen, wenn Sie Gleitkommazahlen in Ihrem Code benutzen. Ihr Compiler regelt das für Sie, er weist der Gleitkommazahl den zu Ihrem Wert am nächsten liegenden, darstellbaren Wert zu. Deshalb ist es so gut wie nie sinnvoll (aber natürlich trotzdem möglich), einen Test auf Gleichheit für Gleitkommazahlen durchzuführen. Bei switch
wurde das jedoch unterbunden.
Zeichen und mehrere case
-Zweige
[Bearbeiten]Eine if
-Anweisung trifft Ihre Entscheidung hingegen, anhand der Tatsache ob eine Bedingung erfüllt ist, oder nicht. Für die if
-Anweisung gibt es also nur zwei Möglichkeiten. Allerdings gibt es eine ganze Menge von Möglichkeiten, wie der Zustand true
, oder false
erreicht werden kann. Die Rede ist von Vergleichen und Verknüpfungen ( wie &&
(= und) oder ||
(= oder) ) und der Umwandlung von anderen Datentypen nach true
, oder false
. Für ganzzahlige Datentypen gilt etwa, dass jeder Wert ungleich 0 true
ergibt, aber 0 hingegen wird als false
ausgewertet. Für eine genaue Beschreibung dessen, was hier noch mal kurz umrissen wurde, sehen Sie sich das Kapitel über Verzweigungen an.
Also gut, switch
kann nur auf Gleichheit testen und erlaubt auch keine Verknüpfungen. Oder sagen wir besser: fast keine. Denn genau deshalb gibt es diese scheinbar aufwendige Regelung mit dem break
. Das folgende Beispiel demonstriert die Verwendung von Zeichen und die Anwendung von mehreren case
-Zweigen, die den gleichen Code ausführen (was einer Verknüpfung mit ||
zwischen mehreren Vergleichen mittels ==
gleichkommt):
#include <iostream>
using namespace std;
int main(){
char auswahl;
cout << "Wählen Sie Ihre Lieblingsleckerei:\n"
"K - Käsesahnetorte\n"
"S - Streuselkuchen\n"
"W - Windbeutel\n";
cin >> auswahl;
switch(auswahl){
case 'k':
case 'K':
cout << "Sie mögen also Käsesahnetorte!";
break;
case 's':
case 'S':
cout << "Streuselkuchen ist ja auch lecker...";
break;
case 'w':
case 'W':
cout << "Windbeutel sind so flüchtig wie ihr Name, das können Sie sicher bestätigen?";
break;
default:
cout << "Wollen Sie wirklich behaupten, dass Ihnen nichts davon zusagt?";
}
return 0;
}
Wählen Sie Ihre Lieblingsleckerei:
K - Käsesahnetorte
S - Streuselkuchen
W - Windbeutel
<Eingabe>s</Eingabe>
Streuselkuchen ist ja auch lecker...
Dieses Programm benutzt die Anfangsbuchstaben anstatt der bisherigen Durchnummerierung für die Auswahl. Außerdem ist es möglich sowohl den großen, als auch den kleinen Buchstaben einzugeben. Beides führt den gleichen Code aus.
Missing 'break'
[Bearbeiten]Da switch
„von oben nach unten“ abgearbeitet wird, benötigt der letzte Fall kein 'break' - dort endet die Switch-Struktur sowieso. Es ist jedoch erlaubt, den 'default'-Fall auch vor einem oder mehreren 'case'-Fällen aufzuführen - dann sollte er ebenso mit einer 'break'-Anweisung enden, wie jeder andere Fall.
Der letzte Fall darf ebenso mit 'break' enden, wie alle anderen - es schadet nicht ;-)
Ein Taschenrechner wird geboren
[Bearbeiten]In diesem Kapitel wollen wir einen kleinen Taschenrechner für die Kommandozeile schreiben. Dieser wird in späteren Kapiteln noch verbessert, aber fürs Erste lernt er nur die vier Grundrechenarten und kann auch nur mit je 2 Zahlen rechnen.
Die Eingabe
[Bearbeiten]Eine Aufgabe besteht aus zwei Zahlen, die durch ein Rechenzeichen getrennt sind. Für die Zahlen verwenden wir den Typ double
. Das Rechenzeichen lesen wir als char
ein. Außerdem brauchen wir noch eine Variable, in der wir das Ergebnis speichern. Diese soll ebenfalls vom Typ double
sein.
Die 4 Grundrechenarten
[Bearbeiten]Wie sicher jeder weiß, sind die 4 Grundrechenarten Addition (+
), Subtraktion (-
), Multiplikation (*
) und Division (/
). Da die Variable rechenzeichen
vom Typ char
und char
ein ganzzahliger Typ ist, bietet es sich an, die switch
-Anweisung zu verwenden, um die richtige Rechnung zu ermitteln und durchzuführen. Wenn ein ungültiges Rechenzeichen eingegeben wird, geben wir eine Fehlermeldung aus und beenden das Programm.
switch(rechenzeichen){
case '+': ergebnis = zahl1+zahl2; break;
case '-': ergebnis = zahl1-zahl2; break;
case '*': ergebnis = zahl1*zahl2; break;
case '/': ergebnis = zahl1/zahl2; break;
default: cout << "unbekanntes Rechenzeichen...\n"; return 1;
}
Der Rückgabewert 1
- ausgelöst von return 1;
- beendet das Programm, die 1
(und jeder andere Wert ungleich 0
auch) sagt dabei aus, dass im Programm ein Fehler auftrat.
Das ganze Programm
[Bearbeiten]Hier ist noch einmal eine Zusammenfassung unseres Taschenrechners:
#include <iostream>
using namespace std;
int main(){
double zahl1, zahl2, ergebnis; // Variablen für Zahlen
char rechenzeichen; // Variable fürs Rechenzeichen
cout << "Geben Sie eine Rechenaufgabe ein: "; // Eingabeaufforderung ausgeben
cin >> zahl1 >> rechenzeichen >> zahl2; // Aufgabe einlesen
switch(rechenzeichen){ // Wert von rechenzeichen ermitteln
case '+': ergebnis = zahl1+zahl2; break; // entsprechend dem
case '-': ergebnis = zahl1-zahl2; break; // Rechenzeichen
case '*': ergebnis = zahl1*zahl2; break; // das Ergebnis
case '/': ergebnis = zahl1/zahl2; break; // berechnen
// Fehlerausgabe und Programm beenden, falls falsches Rechenzeichen eingegeben wurde
default: cout << "unbekanntes Rechenzeichen...\n"; return 1;
}
// Aufgabe noch mal komplett ausgeben
cout << zahl1 << ' ' << rechenzeichen << ' ' << zahl2 << " = " << ergebnis << '\n';
}
Geben Sie eine Rechenaufgabe ein: <Eingabe>99 / 3</Eingabe>
99 / 3 = 33
Zusammenfassung
[Bearbeiten]Ein C++-Programm beginnt mit der Hauptfunktion main()
.
Ein- und Ausgabe
[Bearbeiten]Die Ein- und Ausgabe erfolgt in C++ über Streams, mehr dazu erfahren Sie in einem der folgenden Abschnitte. Um die Ein- und Ausgabe zu nutzen müssen Sie die Headerdatei iostream
einbinden. Die Streamobjekte cin
und cout
liegen im Namensraum std
. Auf Elemente innerhalb eines Namensraums wird mit dem Bereichsoperator ::
zugegriffen.
#include <iostream>
int main(){
std::cout << "Das wird ausgegeben.\n";
int wert;
std::cin >> wert;
std::cout << "Wert ist: " << wert << std::endl;
}
endl
fügt einen Zeilenumbruch ein und leert anschließend den Ausgabepuffer.
Mit der using
-Direktive können auch alle Elemente eines Namensraums verfügbar gemacht werden.
#include <iostream>
using namespace std;
int main(){
cout << "Das wird Ausgegeben.\n";
int wert;
cin >> wert;
cout << "Wert ist: " << wert << endl;
}
Kommentare
[Bearbeiten]Es gibt in C++ zwei Arten von Kommentaren:
Rechnen
[Bearbeiten]C++ beherrscht die 4 Grundrechenarten, die Operatoren lauten +
, -
, *
und /
. Es gilt Punkt- vor Strichrechnung und Klammern ändern die Auswertungsreihenfolge. Weiterhin bietet C++ mit dem Zuweisungsoperator kombinierte Rechenoperatoren:
Für Ganzzahlige Datentypen stehen weiterhin der Inkrement- und der Dekrementoperator, jeweils in einer Prä- und einer Postfixvariante zur Verfügung:
++lvalue; // Erhöht den Wert und gibt ihn zurück
lvalue++; // Erhöht den Wert und gibt den alten Wert zurück
--lvalue; // Erniedriegt den Wert und gibt ihn zurück
lvalue--; // Erniedriegt den Wert und gibt den alten Wert zurück
Die Rückgabe der Präfixvariante ist ein lvalue, die Postfixvariante gibt ein rvalue zurück.
Variablen
[Bearbeiten]Die Syntax zur Deklaration einer Variable lautet:
Datentypen
[Bearbeiten]Die C++-Basisdatentypen sind:
- Typlos
void
(wird später erläutert)
- Logisch
bool
- Zeichen (auch Ganzzahlig)
char
(signed char
undunsigned char
)wchar_t
- Ganzzahlig
short
(signed short
undunsigned short
)int
(signed int
undunsigned int
)long
(signed long
undunsigned long
)
- Gleitkommazahlen
float
double
long double
Die Typmodifizierer signed
und unsigned
geben an, ob es sich um einen vorzeichenbehafteten oder vorzeichenlosen Typ handelt. Ohne diese Modifizierer wird ein vorzeichenbehafteter Typ angenommen, außer bei char
. Hier ist es Implementierungsabhängig. signed
und unsigned
können ohne Datentyp verwendet werden, dann wird int
als Typ angenommen.
Das Schlüsselwort const
zeichnet einen Datentyp als konstant aus. Es gehört immer zu dem, was links davon steht, es sei denn, links steht nichts mehr, dann gehört es zu dem Teil auf der rechten Seite. Es ist somit egal, ob Sie const
vor oder hinter einen Basisdatentypen schreiben:
const «Datentyp» «Variablenname»;
«Datentyp» const «Variablenname»;
C++ macht keine Angaben über die Größen der Datentypen, es ist lediglich folgendes festgelegt:
char
<=short
<=int
<=long
float
<=double
<=long double
Initialisierung
[Bearbeiten]In C++ gibt es 2 Möglichkeiten für eine Initialisierung:
Datentyp «Variablenname» = «Wert»;
Datentyp «Variablenname»(«Wert»);
Die erste Variante ist verbreiteter, aber nur möglich, wenn zur Initialisierung nur 1 Wert benötigt wird. Alle Basisdatentypen erwarten genau einen Wert.
Typaufwertung
[Bearbeiten]Wird ein Operator auf verschiedene Datentypen angewendet, müssen vor der Operation beide in den gleichen Typ konvertiert werden. Der Zieltyp ist üblicherweise der kleinste, der beide Werte darstellen kann. Für genauere Informationen lesen Sie bitte im Kapitel Rechnen mit unterschiedlichen Datentypen.
Kontrollstrukturen
[Bearbeiten]Anweisungsblöcke
[Bearbeiten]Mit einem Anweisungsblock können mehrere Anweisungen zusammengefasst werden. Er kann überall stehen, wo auch eine Anweisung stehen könnte. Die Syntax sieht folgendermaßen aus:
Verzweigung
[Bearbeiten]Eine Verzweigung hat in C++ folgende Syntax:
Die Bedingung ist immer ein logischer Wert. Zahlenwerte werden implizit in logische Werte konvertiert. 0
entspricht dabei immer false
, alle anderen Werte werden zu true
umgewandelt.
Schleifen
[Bearbeiten]Es gibt 3 Arten von Schleifen. Prinzipiell lässt sich in C++ jede der 3 Arten durch jede der jeweils andere simulieren. Allerdings kann je nach Anwendungsfall, rücksichtlich der Tipparbeit, einer der drei Arten am praktischsten sein.
// while-Schleife (Kopfgesteuert)
while(«Bedingung»)
«Anweisung»
// do-while-Schleife: Alle Anweisungen werden mindestens einmal ausgeführt. (Fußgesteuert)
do
«Anweisung»
while(«Bedingung»);
// for-Schleife: Ermöglicht die Initialisierung, die Bedingung (Kopfgesteuert) und eine Aktion,
// die nach jedem Schleifendurchlauf stattfindet, im Schleifenkopf zu platzieren
for(«Deklarationen (z.B. Zählvariablen)»; «Bedingung»; «Aktion nach einem Durchlauf (z.B. hochzählen)»)
«Anweisung»
Auswahl
[Bearbeiten]Mit der switch
-Anweisung kann eine Variable auf mehrere (konstante) Werte verglichen werden. Jedem dieser Werte können eine oder mehrere Anweisungen folgen, welche im Falle einer Übereinstimmung ausgeführt werden, bis das Ende des switch
-Blocks erreicht wird.
switch(«Variable»){
case «Wert»:
«Anweisung»
«...»
»break;«
«...»
default: // Wird ausgeführt, falls die Variable keinem der Werte entsprach
«Anweisung»
«...»
}
Wird das break;
am Ende eines Anweisungsblockes weggelassen, werden auch die darauf folgenden Blöcke ausgeführt, bis ein break;
gefunden oder das Ende der switch
-Anweisung erreicht ist.
Prozeduren und Funktionen
[Bearbeiten]Unter einer Funktion (function, in anderen Programmiersprachen auch Prozedur oder Subroutine genannt) versteht man ein Unterprogramm, das eine bestimmte Aufgabe erfüllt. Funktionen sind unter anderem sinnvoll, um sich wiederholende Aufgaben zu kapseln, so dass die Befehle nicht jedesmal neu geschrieben werden müssen. Zudem verbessert es die Übersichtlichkeit der Quellcode-Struktur erheblich, wenn der Programmtext logisch in Abschnitte unterteilt wird.
Parameter und Rückgabewert
[Bearbeiten]Die spezielle Funktion main()
ist uns schon mehrfach begegnet. In C++ lassen sich Funktionen nach folgenden Kriterien unterscheiden:
- Eine Funktion kann Parameter besitzen, oder nicht.
- Eine Funktion kann einen Wert zurückgeben, oder nicht.
Dem Funktionsbegriff der Mathematik entsprechen diejenigen C++-Funktionen, die sowohl Parameter haben als auch einen Wert zurückgeben. Dieser Wert kann im Programm weiter genutzt werden, um ihn z.B. einer Variablen zuzuweisen.
Damit diese Anweisung fehlerfrei kompiliert wird, muss vorher die Funktion f()
deklariert worden sein. Bei einer Funktion bedeutet Deklaration die Angabe des Funktionsprototyps. Das heißt, der Typ von Parametern und Rückgabewert muss angegeben werden. Das folgende Beispiel deklariert bspw. eine Funktion, die einen Parameter vom Typ char
besitzt und einen int
-Wert zurückgibt.
Soll eine Funktion keinen Wert zurückliefern, lautet der Rückgabetyp formal void
.
Nach dem Compilieren ist das Linken der entstandenen Objektdateien zu einem ausführbaren Programm nötig. Der Linker benötigt die Definition der aufzurufenden Funktion. Eine Funktionsdefinition umfasst auch die Implementation der Funktion, d.h. den Code, der beim Aufruf der Funktion ausgeführt werden soll. In unserem Fall wäre das:
int f(int x); // Funktionsdeklaration
int main(){
int a = f(3); // Funktionsaufruf
// a hat jetzt den Wert 9
}
int f(int x){ // Funktionsdefinition
return x * x;
}
Innerhalb eines Programms dürfen Sie eine Funktion beliebig oft (übereinstimmend!) deklarieren, aber nur einmal definieren.
Der Compiler muss die Deklaration kennen, um eventuelle Typ-Unverträglichkeiten abzufangen. Würden Sie die obige Funktion z.B. als int a = f(2.5);
aufrufen, käme die Warnung, dass f()
ein ganzzahliges Argument erwartet und keine Fließkommazahl. Eine Definition ist für den Compiler auch immer eine Deklaration, das heißt Sie müssen nicht explizit eine Deklaration einfügen, um eine Funktion aufzurufen, die zuvor definiert wurde.
Die Trennung von Deklaration und Definition kann zu übersichtlicher Code-Strukturierung bei größeren Projekten genutzt werden. Insbesondere ist es sinnvoll, Deklarationen und Definitionen in verschiedene Dateien zu schreiben. Oft will man, wenn man fremden oder alten Code benutzt, nicht die Details der Implementierung einer Funktion sehen, sondern nur das Format der Parameter o.ä. und kann so in der Deklarationsdatei (header file, üblicherweise mit der Endung .hpp, z.T. auch .h oder .hh) nachsehen, ohne durch die Funktionsrümpfe abgelenkt zu werden. (Bei proprietärem Fremdcode bekommt man die Implementation in der Regel gar nicht zu Gesicht!)
Bei einer Deklaration ist es nicht nötig, die Parameternamen mit anzugeben, denn diese sind für den Aufruf der Funktion nicht relevant. Es ist allerdings üblich, die Namen dennoch mit anzugeben, um zu verdeutlichen was der Parameter darstellt. Der Compiler ignoriert die Namen in diesem Fall einfach, weshalb es auch möglich ist den Parametern in der Deklaration und der Definition unterschiedliche Namen zu geben. Allerdings wird davon aus Gründen der Übersichtlichkeit abgeraten.
Mit der Anweisung return
gibt die Funktion einen Wert zurück, in unserem Beispiel x * x
, wobei die Variable x
als Parameter bezeichnet wird. Als Argument bezeichnet man eine Variable oder einen Wert, mit denen eine Funktion aufgerufen wird. Bei Funktionen mit dem Rückgabetyp void
schreiben Sie einfach return;
oder lassen die return
-Anweisung ganz weg. Nach einem return
wird die Funktion sofort verlassen, d.h. alle nachfolgenden Anweisungen des Funktionsrumpfs werden ignoriert.
Erwartet eine Funktion mehrere Argumente, so werden die Parameter durch Kommas getrennt. Eine mit
deklarierte Funktion könnte z.B. so aufgerufen werden:
Für eine leere Parameterliste schreiben Sie hinter den Funktionsnamen einfach ()
.
Vergessen Sie beim Aufruf einer Funktion ohne Parameter nicht die leeren Klammern. Andernfalls erhalten Sie nicht den von der Funktion zurückgelieferten Wert, sondern die Adresse der Funktion. Dies ist ein „beliebter“ Anfängerfehler, daher sollten Sie im Falle eines Fehlers erst einmal überprüfen, ob Sie nicht vielleicht irgendwo eine Funktion ohne Parameter falsch aufgerufen haben.
Übergabe der Argumente
[Bearbeiten]C++ kennt zwei Varianten, wie einer Funktion die Argumente übergeben werden können: call-by-value und call-by-reference.
call-by-value
[Bearbeiten]Bei call-by-value (Wertübergabe) wird der Wert des Arguments in einen Speicherbereich kopiert, auf den die Funktion mittels Parametername zugreifen kann. Ein Werteparameter verhält sich wie eine lokale Variable, die „automatisch“ mit dem richtigen Wert initialisiert wird. Der Kopiervorgang kann bei Klassen (Thema eines späteren Kapitels) einen erheblichen Zeit- und Speicheraufwand bedeuten!
#include <iostream>
void f1(int const x) {
x = 3 * x; // ungültig, weil Konstanten nicht überschrieben werden dürfen
std::cout << x << std::endl;
}
void f2(int x) {
x = 3 * x;
std::cout << x << std::endl;
}
int main() {
int a = 7;
f1(a); // Kompiler-Fehler, da Übergabeparameter der Funktion const ist.
f2(5); // Ausgabe: 15
std::cout << x; // Fehler! x ist hier nicht definiert
std::cout << a; // a hat immer noch den Wert 7
return 0;
}
Wird der Parameter als const
deklariert, so darf ihn die Funktion nicht verändern (siehe erstes Beispiel). Im zweiten Beispiel kann die Variable x
verändert werden. Die Änderungen betreffen aber nur die lokale Kopie und sind für die aufrufende Funktion nicht sichtbar.
call-by-reference
[Bearbeiten]Sollen die von einer Funktion vorgenommen Änderungen auch für das Hauptprogramm sichtbar sein, müssen in C sogenannte Zeiger verwendet werden. C++ stellt ebenfalls Zeiger zur Verfügung. C++ gibt Ihnen aber auch die Möglichkeit, diese Zeiger mittels Referenzen zu umgehen, was im alten C nicht möglich war. Beide sind jedoch noch Thema eines späteren Kapitels.
Im Gegensatz zu call-by-value wird bei call-by-reference die Speicheradresse des Arguments übergeben, also der Wert nicht kopiert. Änderungen der (Referenz-)Variable betreffen zwangsläufig auch die übergebene Variable selbst und bleiben nach dem Funktionsaufruf erhalten. Um call-by-reference anzuzeigen, wird der Operator &
verwendet, wie Sie gleich im Beispiel sehen werden. Wird keine Änderung des Inhalts gewünscht, sollten Sie den Referenzparameter als const
deklarieren, um so den Speicherbereich vor Änderungen zu schützen. Fehler, die sich aus der ungewollten Änderung des Inhaltes einer übergebenen Referenz ergeben, sind in der Regel schwer zu finden.
Die im folgenden Beispiel definierte Funktion swap()
vertauscht ihre beiden Argumente. Weil diese als Referenzen übergeben werden, überträgt sich das auf die Variablen, mit denen die Funktion aufgerufen wurde:
#include <iostream>
void swap(int &a, int &b) {
int tmp = a; // "temporärer" Variable den Wert von Variable a zuweisen
a = b; // a mit dem Wert von Variable b überschreiben
b = tmp; // b den Wert der "temporären" Variable zuweisen (Anfangswert von Variable a)
}
int main() {
int x = 5, y = 10;
swap(x, y);
std::cout << "x=" << x << " y=" << y << std::endl;
return 0;
}
x=10 y=5
Ob das kaufmännische Und (&) genau nach int
(int& a, ...)
, oder genau vor der Variablen a
(int &a, ...)
, oder dazwischen (int & a, ...)
steht, ist für den Compiler nicht von Bedeutung. Auch möglich wäre (int&a, ...)
.
Nicht-konstante Referenzen können natürlich nur dann übergeben werden, wenn das Argument tatsächlich eine Speicheradresse hat, sprich eine Variable bezeichnet. Ein Literal z.B. 123
oder gar ein Ausdruck 1 + 2
wäre hier nicht erlaubt. Bei const
-Referenzen wäre das möglich, der Compiler würde dann eine temporäre Variable anlegen.
Array
[Bearbeiten]Wir werden die oben angesprochenen Zeiger in einem späteren Kapitel ausführlich kennen lernen. Sie ermöglichen z.B. das Arbeiten mit Arrays. Soll eine Funktion mit einem Array umgehen, werden ihr die Startadresse des Arrays (ein Zeiger) und seine Größe als Parameter übergeben. Wenn das Array nur gelesen werden soll, deklariert man die Werte, auf die der Zeiger zeigt, als const
. Das Zeichen *
signalisiert, dass es sich um einen Zeiger handelt.
#include <iostream>
void print(int const* array, int const arrayGroesse) {
std::cout << "Array:" << std::endl;
for (int i = 0; i < arrayGroesse; ++i) {
std::cout << array[i] << std::endl;
}
}
int main() {
int array[] = { 3, 13, 113 };
int n = sizeof(array) / sizeof(array[0]);
print(array, n);
}
Array:
3
13
113
Default-Parameter
[Bearbeiten]Default-Parameter dienen dazu, beim Aufruf einer Funktion nicht alle Parameter explizit angeben zu müssen. Die nicht angegebenen Parameter werden mit einer Voreinstellung (default) belegt. Parameter, die bei einem Aufruf einer Funktion nicht angegeben werden müssen, werden auch als „fakultative Parameter“ bezeichnet.
int summe(int a, int b, int c = 0, int d = 0) {
return a + b + c + d;
}
int main() {
int x = summe(2, 3, 4, 5); //x == 14
x = summe(2, 3, 4); //x == 9, es wird d=0 gesetzt
x = summe(2, 3); //x == 5, es wird c=0, d=0 gesetzt
}
Standardargumente werden in der Deklaration einer Funktion angegeben, da der Compiler sie beim Aufruf der Funktion kennen muss. Im obigen Beispiel wurde die Deklaration durch die Definition gemacht, daher sind die Parameter hier in der Definition angegeben. Bei einer getrennten Schreibung von Deklaration und Definition könnte das Beispiel so aussehen:
int summe(int a, int b, int c = 0, int d = 0); // Deklaration
int main() {
int x = summe(2, 3, 4, 5); //x == 14
x = summe(2, 3, 4); //x == 9, es wird d=0 gesetzt
x = summe(2, 3); //x == 5, es wird c=0, d=0 gesetzt
}
int summe(int a, int b, int c, int d) { // Definition
return a + b + c + d;
}
Funktionen überladen
[Bearbeiten]Überladen (overloading) von Funktionen bedeutet, dass verschiedene Funktionen unter dem gleichen Namen angesprochen werden können. Damit der Compiler die Funktionen richtig zuordnen kann, müssen die Funktionen sich in ihrer Funktionssignatur unterscheiden. In C++ besteht die Signatur aus dem Funktionsnamen und ihren Parametern, der Typ des Rückgabewerts gehört nicht dazu. So ist es nicht zulässig, eine Funktion zu überladen, die den gleichen Namen und die gleiche Parameterliste wie eine bereits existierende Funktion besitzt und sich nur im Typ des Rückgabewerts unterscheidet. Das obige Beispiel lässt sich ohne Default-Parameter so formulieren:
int summe(int a, int b, int c, int d) {
return a + b + c + d;
}
int summe(int a, int b, int c) {
return a + b + c;
}
int summe(int a, int b) {
return a + b;
}
int main() {
// ...
}
Funktionen mit beliebig vielen Argumenten
[Bearbeiten]Wenn die Zahl der Argumente nicht von vornherein begrenzt ist, wird als Parameterliste die sog. Ellipse ...
angegeben. Der Funktion werden die Argumente dann in Form einer Liste übergeben, auf die mit Hilfe der (in der Headerdatei cstdarg
definierten) va-Makros zugegriffen werden kann.
#include <cstdarg>
#include <iostream>
int summe(int a, ...) {
int summe = 0;
int i = a;
va_list Parameter; // Zeiger auf Argumentliste
va_start(Parameter, a); // gehe zur ersten Position der Liste
while (i != 0){ // Schleife, solange Zahl nicht 0 (0 ist Abbruchbedingung)
summe += i; // Zahl zur Summe addieren
i = va_arg(Parameter, int); // nächstes Argument an i zuweisen, Typ int
}
va_end(Parameter); // Liste löschen
return summe;
}
int main() {
std::cout << summe(2, 3, 4, 0) << std::endl; // 9
std::cout << summe(2, 3, 0) << std::endl; // 5
std::cout << summe(1,1,0,1,0) << std::endl; // 2 (da die erste 0 in der while-Schleife für Abbruch sorgt)
}
9
5
2
Obwohl unspezifizierte Argumente manchmal verlockend aussehen, sollten Sie sie nach Möglichkeit vermeiden. Das hat zwei Gründe:
- Die va-Befehle können nicht erkennen, wann die Argumentliste zu Ende ist. Es muss immer mit einer expliziten Abbruchbedingung gearbeitet werden. In unserem Beispiel muss als letztes Argument eine 0 stehen, ansonsten gibt es, je nach Compiler unterschiedliche, „interessante“ Ergebnisse.
- Die va-Befehle sind Makros, d.h. eine strenge Typüberprüfung findet nicht statt. Fehler werden - wenn überhaupt - erst zur Laufzeit bemerkt.
Mit dem kommenden C++ Standard C++11 wird mit den „variadic templates“ eine Technik eingeführt, welche diese Art von variabler Parameterliste überflüssig macht. Über Templates wie Sie im aktuellen Standard stehen, werden Sie später mehr erfahren. Sobald der neue Standard endgültig verabschiedet ist, wird es auch zu den „variadic templates“ ein Kapitel geben. Die meisten Compiler beherrschen diese Technik bereits in weiten Teilen, sofern man den neuen Standard explizit aktiviert:
#include <iostream>
template < typename First >
First summe(First first){
return first;
}
template < typename First, typename ... Liste >
First summe(First first, Liste ... rest){
return first + summe(rest ...);
}
int main() {
std::cout << summe(2, 3, 4) << std::endl;
std::cout << summe(2, 3) << std::endl;
std::cout << summe(1, 1, 0, 1) << std::endl;
}
9
5
3
Diese Implementierung ist typsicher und benötigt keine Tricks um den letzten Parameter zu kennzeichnen. Hierbei entspricht der Rückgabetyp immer dem Typ des ersten Parameters, was nicht sonderlich sinnvoll ist, aber eine Implementierung, die sich auch bezüglich des Rückgabetyps sinnvoll verhält, würde an dieser Stelle endgültig zu weit führen. „Variadic templates“ sind eine extrem nützliche Technik für Fortgeschrittene. Für Sie genügt es zum gegenwärtigen Zeitpunkt zu wissen, dass sie existieren und dass man derartige Funktionen wie jede andere Funktion aufrufen kann.
Inline-Funktionen
[Bearbeiten]Um den Aufruf einer Funktion zu beschleunigen, kann in die Funktionsdeklaration das Schlüsselwort inline
eingefügt werden. Dies ist eine Empfehlung (keine Anweisung) an den Compiler, beim Aufruf dieser Funktion keine neue Schicht auf dem Stack anzulegen, sondern den Code direkt auszuführen - den Aufruf sozusagen durch den Funktionsrumpf zu ersetzen.
Da dies – wie eben schon erwähnt – nur eine Empfehlung an den Compiler ist, wird der Compiler eine Funktion nur dann tatsächlich inline einbauen, wenn es sich um eine kurze Funktion handelt. Ein typisches Beispiel:
Das Schlüsselwort inline
wird bei der Deklaration angegeben. Allerdings muss der Compiler beim Aufruf den Funktionsrumpf kennen, wenn er den Code direkt einfügen soll. Für den Aufruf einer inline
-Funktion genügt also wie immer die Deklaration, um dem Compiler jedoch tatsächlich das Ersetzen des Funktionsaufrufs zu ermöglichen, muss auch die Definition bekannt sein. Über diese Eigenheit von inline
-Funktionen erfahren Sie im Kapitel „Headerdateien“ mehr. Auch die Bedeutung von Deklaration und Definition wird Ihnen nach diesem Kapitel klarer sein.
Lebensdauer und Sichtbarkeit von Objekten
[Bearbeiten]Die Lebensdauer einer Variable beschreibt die Zeit, in der die Variable im Speicher existiert. Die Sichtbarkeit einer Variable ist von ihrer Lebensdauer zu unterscheiden. Sie beschreibt, wie der Name schon vermuten lässt, wann man auf eine Variable über ihren Namen zugreifen kann, beziehungsweise wann dies nicht möglich ist. Wir werden in diesem Kapitel Variablen nach ihrer Lebensdauer sortiert betrachten und dabei auch immer auf ihren Sichtbarkeitsbereich eingehen.
Statische Variablen
[Bearbeiten]Als statische Variablen werden alle Variablen bezeichnet, deren Lebensdauer mit der Laufzeit des Programms übereinstimmt. Sie belegen somit während der gesamten Ausführungszeit Speicherplatz, können andererseits aber auch während der gesamten Laufzeit zugegriffen werden.
Lokale statische Variablen
[Bearbeiten]Variablen, die innerhalb einer Funktion als static
deklariert wurden, werden als lokale statische Variablen bezeichnet, da sie bezüglich der Funktion lokal sind. Solche Variablen sind nur innerhalb der jeweiligen Funktion sichtbar. Da sie jedoch permanent existieren, können Sie über eine entsprechende Referenz oder einen Zeiger auch außerhalb der Funktion auf sie zugreifen. Die Initialisierung solcher Variablen erfolgt beim ersten Aufruf der Funktion.
Nicht-lokale statische Variablen
[Bearbeiten]Alle statischen Variablen, die nicht in die Kategorie „lokal“ fallen, werden entsprechend als „nicht-lokal“ bezeichnet. Alle Variablen dieser Art sind überall sichtbar, mit Ausnahme von Variablen, die in anonymen Namensräumen deklariert wurden. Diese sind nur innerhalb der aktuellen Übersetzungseinheit sichtbar. Auch diese können natürlich dennoch mittels einer entsprechenden Referenz oder eines Zeigers auch aus anderen Übersetzungseinheiten zugegriffen werden. Als Übersetzungseinheit bezeichnet man das Kompilat einer cpp-Datei, welches der Compiler erstellt, bevor es vom Linker zum endgültigen Programm gebunden wird. Aus Quelltextsicht entspricht sie somit einer cpp-Datei inklusive aller mit #include
eingebundenen Header. Um eine Variable in einer anderen Übersetzungseinheit sichtbar zu machen, muss sie dort deklariert werden. Beachten Sie, dass die Variable dort nicht ein zweites Mal definiert werden darf.
Globale Variablen
[Bearbeiten]Variablen, die direkt im globalen Namensraum deklariert wurden, heißen globale Variablen. Im globalen Namensraum heißt, außerhalb von jeder Funktion, Klasse und jedem Namensraum. Ein geeignetes Indiz zum Überprüfen, ob eine Variable global deklariert ist, ist den Zugriffsoperator (::
) ohne Namensraum zu benutzen. Folgendes Beispiel zeigt diesen einfachen Test.
extern int globale; // explizite Deklaration
int global; // Definition
int main(){
int lokal;
::global; // geht -> globale Variable
::lokal; // Fehler -> nicht globale Variable
}
Zum erfolgreichen Kompilieren muss die Fehlerzeile auskommentiert werden
Auch hierbei gibt es natürlich wieder eine Ausnahme, denn auch Variablen aus einem anonymen Namensraum lassen sich auf diese Weise zugreifen. Da sich die Nicht-lokalen, statischen Variablen aus interner Sicht jedoch ohnehin alle sehr ähnlich verhalten, spielt ihre Unterscheidung in der Praxis ohnehin eine untergeordnete Rolle. Allgemein sollten Sie globale Variablen komplett vermeiden, da sie in größeren Projekten mit mehreren Programmierern sehr schnell zu Namenskonflikten führen.
Während sich eine globale Variable jedoch über eine explizite Deklaration aus einer anderen Übersetzungseinheit heraus sichtbar machen lässt, ist dies für eine Variable in einem anonymen Namensraum nicht möglich.
Globaler Namensraum
[Bearbeiten]Variablen, die innerhalb eines Namensraumes deklariert werden, vermeiden Namenskonflikte und die Zuordnung zu einzelnen Programmteilen ist schneller ersichtlich. Wenn Sie eine globale Variable benötigen, dann verpacken Sie diese in einen geeigneten Namensraum. Auch hier ist natürlich eine explizite Deklaration in einer anderen Übersetzungseinheit möglich, wobei die Deklaration natürlich auch dort im selben Namensraum erfolgen muss.
Anonymer Namensraum
[Bearbeiten]Der anonyme Namensraum wurde nun schon einige Male angesprochen. Variablen, die in diesem Namensraum definiert wurden, können in der aktuellen Übersetzungseinheit wie globale Variablen behandelt werden. Da ein anonymer Namensraum nur in der aktuellen Übersetzungseinheit sichtbar ist, gibt es keine Möglichkeit, seinen Inhalt aus einer anderen Übersetzungseinheit aus sichtbar zu machen. Der anonyme Namensraum einer anderen Übersetzungseinheit ist entsprechend wieder ein anderer als der in der aktuellen Datei.
Statische Klassenvariablen
[Bearbeiten]Statische Klassenvariablen verhalten sich im wesentlichen wie Variablen innerhalb eines benannten Namensraumes. Um sie in einer anderen Übersetzungseinheit zu sehen, ist eine Definition der Klasse nötig, in der sie deklariert ist. Dies erfolgt üblicherweise durch Einbinden der entsprechenden Headerdatei der Klasse.
Probleme mit der Initialisierung
[Bearbeiten]Das große Problem, das sich in Zusammenhang mit statischen, nicht-lokalen Variablen ergibt, ist ihre Initialisierungsreihenfolge bezüglich unterschiedlicher Übersetzungseinheiten. Diese ist nicht definiert, da es für den Compiler beziehungsweise Linker im allgemeinen unmöglich ist, herauszufinden, welche Reihenfolge die richtige ist. Betrachten Sie zur Veranschaulichung das folgende Beispiel mit zwei Übersetzungseinheiten.
//*******************************************************//
//******************** Datei "B.hpp" ********************//
// Definition von B
struct B{
B(int value):value_(value){}
int value_;
};
//*******************************************************//
//****************** Datei "file1.cpp" ******************//
#include "B.hpp"
B b(5); // Definition und Initialisierung der globalen Variable b
//*******************************************************//
//****************** Datei "file2.cpp" ******************//
#include <iostream>
#include "B.hpp"
extern B b; // Deklaration der Variable b
struct A{ // Definition von A
A(B const& value):b_(value){}
B b_;
};
A a(b); // Globale Variable a, die mit b initialisiert wird
int main(){
std::cout << a.b_.value_ << std::endl;
}
Ausgabe ist vom Compiler abhängig
Falls die Variable B zuerst initialisiert wird, liefert das Programm wie gewünscht die Ausgabe 5. Sie können ja mal versuchen Ihrem Compiler die beiden cpp-Dateien auf der Kommandozeile in unterschiedlicher Reihenfolge zu übergeben. Wahrscheinlich wird das Programm je nachdem welche Datei zuerst gebunden wird, ein anderes Ergebnis liefern. Sie können sich jedoch keinesfalls darauf verlassen, dass Sie Ihre Wunschinitialisierungsreihenfolge immer mit dieser Try-and-Error-Methode herstellen können. Es ist nicht immer möglich, weshalb es vom C++-Standard auch nicht definiert wurde.
Glücklicherweise gibt es einen einfachen Ausweg aus diesem Dilemma. Eine kleine Designänderung wird Ihr Problem sofort beheben. Wie Ihnen inzwischen bekannt ist, werden lokale, statische Variablen erst beim ersten Funktionsaufruf initialisiert. Wenn Sie also derartige Initialisierungsprobleme befürchten müssen, dann können Sie statt einer nicht-lokalen, statischen Variable eine Funktion verwenden, die eine Referenz auf eine lokale statische Variable zurückgibt. Die Initialisierungsreihenfolge ergibt sich dann automatisch und ohne Zutun von Compiler und Linker zur Laufzeit des Programms.
//*******************************************************//
//******************** Datei "B.hpp" ********************//
// Definition von B
struct B{
B(int value):value_(value){}
int value_;
};
//*******************************************************//
//****************** Datei "file1.cpp" ******************//
#include "B.hpp"
// Definition und Initialisierung einer statischen
// lokalen Variable die durch einen Funktionsaufruf
// zugegriffen werden kann
B& b(){
static B _b(5); // Initialisierung beim ersten Aufruf
return _b;
}
//*******************************************************//
//****************** Datei "file2.cpp" ******************//
#include <iostream>
#include "B.hpp"
B& b(); // Deklaration der Funktion b
struct A{ // Definition von A
A(B const& value):b_(value){}
B b_;
};
// Kapselungsfunktion
A& a(){
static A _a(b()); // Initialisierung beim ersten Aufruf
return _a;
}
int main(){
std::cout << a().b_.value_ << std::endl;
}
5
Diese Variante erfordert zwar einen minimal höheren Schreibaufwand, im Gegenzug erhalten Sie aber ein klar definiertes und sinnvolles Verhalten und als kleinen Bonus bekommen Sie noch einen möglicherweise hilfreichen Effekt dazu. Da die Initialisierung der Variablen nun erst dann stattfindet, wenn Sie diese tatsächlich verwenden, startet ihr Programm etwas schneller und Variablen, die während der Laufzeit nicht genutzt werden, verschwenden keine Zeit mehr mit einer unnötigen Initialisierung. Natürlich belegen sie trotzdem immer noch Speicherplatz, also gehen Sie trotz der gewonnen Laufzeitvorteile mit bedacht vor, wenn Sie statische Variablen verwenden.
Alter Text
[Bearbeiten]Der Rest des Kapitels muss noch einmal überarbeitet werden, die aktuelle Version bietet jedoch schon einen grundlegenden Überblick. Ich möchte die Überarbeitung selbst vornehmen, da ich eine etwas andere Struktur vorgesehen habe. Der aktuelle Text wird natürlich in meine Version mit einfließen. ;) --Prog 16:19, 6. Dez. 2010 (CET)
Lokale Variablen
[Bearbeiten]Im Gegensatz zu globalen, werden lokale Variablen in einem bestimmten Anweisungsblock (z.B. Schleifen, if
-Abfragen oder Funktionen) deklariert. Ihre Existenz endet, wenn dieser Block wieder verlassen wird.
void foo(int a) {
int lok1 = 0; // lok1 ist eine Variable im Anweisungsblock von void foo(int a)
if (a < 0) {
int lok2 = 1; // lok2 ist eine Variable im if-Block
} // hier wird lok2 aus dem Speicher gelöscht...
} // ...und hier lok1
Statische Variablen
[Bearbeiten]Statische Variablen (auch statische Klassenmember) werden wie globale zu Beginn des Programms im Speicher angelegt und bei seinem Ende wieder daraus entfernt. Der Unterschied zu einer globalen Variable wird weiter unten auf dieser Seite im Teil über Sichtbarkeit geklärt.
Dynamisch erzeugte Variablen
[Bearbeiten]Eine Variable, die mittels dem new
Operator angefordert wird, ist dynamisch. Sie existiert so lange, bis sie durch einen Aufruf von delete
wieder gelöscht wird.
Der Aufruf von delete
ist die einzige Möglichkeit, den von einer dynamisch erzeugten Variable belegten Speicher wieder frei zu geben. Geschieht dies nicht, so kann es leicht zu einem Speicherleck kommen.
Lesen Sie bitte auch das Kapitel über den new und delete um Informationen über deren Verwendung zu erhalten.
Objekte und Membervariablen
[Bearbeiten]Objekte werden wie normale Variablen gehandhabt, d. h. sie können global, lokal, statisch oder dynamisch erzeugt sein. Ihre Member haben die gleiche Lebensdauer wie sie selbst. Eine Ausnahme bilden statische Klassenvariablen, die von Anfang bis Ende des Programmablaufes im Speicher vorhanden sind.
Sichtbarkeit
[Bearbeiten]Allgemein
[Bearbeiten]Um überhaupt die Chance zu haben mit einer Variablen zu arbeiten, muss diese im Quelltext bereits deklariert worden sein. Folgendes Codestück ist also falsch und führt zu einem Fehler.
Das Prinzip der Datenkapselung in der objektorientierten Programmierung finden Sie im Abschnitt über Klassen.
Gültigkeitsbereiche und deren Schachtelung
[Bearbeiten]Jede Variable gehört zu einem bestimmten Gültigkeitsbereich (engl. scope). Dieser legt fest, wann eine Variable von uns „gesehen“ und damit benutzt werden kann. Vereinfacht gesagt bildet jedes Paar aus geschweiften Klammern ({}
) einen eigenen Definitionsbereich. Dazu gehören beispielsweise if
, else
, Schleifen und Funktionen. Diese unterschiedlichen Bereiche sind nun ineinander geschachtelt ähnlich wie die berühmten Matrjoschka-Puppen mit dem Unterschied, dass die Definitionsbereiche nicht „kleiner“ werden und dass es mehrere „nebeneinander“ geben kann.
// das hier gehört zum globalen Bereich
int func() { // hier beginnt der Bereich der Funktion func...
return 0;
// ... und ist hier auch schon wieder beendet
}
int bar(int val) { // val gehört zum Definitionsbereich "bar"
if (val == 7) { // diese if-Anweisung hat auch ihren eigenen Gültigkeitsbereich...
int ich_gehoer_zum_if;
} // ... der hier zu Ende ist
} // bar ende
Welche Variablen sind sichtbar?
[Bearbeiten]Jetzt ist es leicht zu bestimmen mit welchen Variablen wir an einer bestimmten Stelle im Programmcode arbeiten können: Es sind diejenigen, die entweder dem derzeitigen oder einem Gültigkeitsbereich auf einer höheren Ebene angehören. Wenn wir uns erneut das Beispiel der Puppen vor Augen halten, so wird klar was hiermit gemeint ist.
Beispiel:
int out;
{
int inner_1;
}
{
int inner_2;
// an dieser Stelle könnten wir sowohl auf out als auch auf inner_2 zugreifen, nicht jedoch auf inner_1;
}
Zusätzlich gilt noch, dass von zwei Variablen gleichen Namens nur auf die weiter innen liegende zugegriffen werden kann. Außerdem wird die gleichnamige innere Variable im selben Speicherbereich agieren.
Rekursion
[Bearbeiten]Jede Funktion kann sowohl andere Funktionen als auch sich selbst aufrufen. Ein solcher Selbstaufruf wird auch rekursiver Aufruf genannt. Das dahinter stehende Konzept bezeichnet man entsprechend als Rekursion.
Eine Ausnahme von dieser Regel bildet wiedereinmal die Funktion main()
. Sie darf ausschließlich vom Betriebssystem aufgerufen werden, also weder von einer anderen Funktion, noch aus sich selbst heraus.
Eine rekursive Problemlösung ist etwas langsamer und speicheraufwendiger als eine iterative Variante (also mit Schleifen). Dafür ist der Code allerdings auch kompakter und ein „intelligenter“ Compiler ist meist in der Lage, eine Rekursion in eine Iteration umzuwandeln um somit die Nachteile aufzuheben. Sie sollten also keine Scheu haben ein Problem mit Rekursion zu lösen, insbesondere wenn die Lösung leichter zu verstehen ist als eine iterative Variante. Sollten dadurch im Laufe der Entwicklung eines Programms Geschwindigkeits- oder Speichernachteile auftreten, so können Sie die Funktion immer noch durch eine iterativ arbeitende ersetzen.
Beispiele
[Bearbeiten]Fakultät
[Bearbeiten]Als erstes einfaches Beispiel einer rekursiven Problemlösung nehmen wir die Berechnung der Fakultät. Da die Fakultät für negative und nicht ganze Zahlen nicht definiert ist, benutzen wir als Datentyp unsigned int
:
#include <iostream> // Für std::cin und std::cout
unsigned int fakultaet(unsigned int zahl) {
if (zahl <= 1) {
return 1; // Die Fakultät von 0 und 1 ist als 1 definiert.
} else {
return zahl * fakultaet(zahl - 1);
}
}
int main() {
unsigned int zahl;
std::cout << "Bitte Zahl eingeben: ";
std::cin >> zahl; // Zahl einlesen
std::cout << "Die Fakultät von " << zahl << // Antwort ausgeben
" ist " << fakultaet(zahl) << "." << std::endl;
}
Bitte Zahl eingeben: <eingabe>4</eingabe>
Die Fakultät von 4 ist 24.
Genau wie bei einer Schleife, ist auch bei einer Rekursion eine Abbruchbedingung definiert (also erforderlich) und genau wie bei einer Schleife würde ohne Abbruchbedingung eine Endlosrekursion auftreten, analog zur Endlosschleife. So eine Endlosschleife bezeichnet man auch als infiniten Regress. Wenn der Wert der Variablen zahl
kleiner oder gleich eins ist, so wird eins zurückgegeben, andernfalls wird weiter rekursiv aufgerufen. Eine iterative Variante für das gleiche Problem könnte folgendermaßen aussehen:
unsigned int fakultaet(unsigned int zahl) {
unsigned int wert = 1;
for (unsigned int i = 2; i <= zahl; ++i) {
wert *= i;
}
return wert;
}
Fibonacci-Zahlen
[Bearbeiten]Als zweites Beispiel wollen wir Fibonacci-Zahlen ausrechnen.
#include <iostream>
unsigned int fibonacci(unsigned int zahl) {
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
} // else
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
} // else
// Ansonsten wird die Summe der zwei vorherigen Fibonacci-Zahlen zurückgegeben.
return fibonacci(zahl - 1) + fibonacci(zahl - 2);
}
int main() {
unsigned int zahl;
std::cout << "Bitte Zahl eingeben: ";
std::cin >> zahl; // Zahl einlesen
std::cout << "Die Fibonacci-Zahl von " << zahl << // Antwort ausgeben
" ist " << fibonacci(zahl) << "." << std::endl;
}
Bitte Zahl eingeben: <eingabe>12</eingabe>
Die Fibonacci-Zahl von 12 ist 144.
Die iterative Entsprechung sieht folgendermaßen aus:
unsigned int fibonacci(unsigned int zahl) {
if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
return 0;
} // else
if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
return 1;
} // else
unsigned int ret;
unsigned int h1 = 0;
unsigned int h2 = 1;
for (unsigned int i = 1; i < zahl; ++i) {
// (Zwischen-)Ergebnis ist die Summe der zwei vorhergehenden Fibonacci-Zahlen.
ret = h1 + h2;
// "vorherige zwei F.-Zahlen" um 1 "Stelle" der Reihe "weiter ruecken":
h1 = h2;
h2 = ret;
}
return ret;
}
Bei vielen komplexen Problemen eignet sich Rekursion oft besser zur Beschreibung, als eine iterative Entsprechung. Aus diesem Grund trifft man das Konzept der Rekursion in der Programmierung recht häufig an. Bei der Fibonacci-Funktion ist allerdings die iterative Lösung wesentlich effizienter, da ansonsten bei jedem Aufruf dieselbe Methode wieder zweimal neu aufgerufen wird. So ergeben sich bei fibonacci(40) schon 240-1 Aufrufe.
Merge sort
[Bearbeiten]Merge sort ist ein Beispiel für eine Funktion, bei der Rekursion sinnvoll eingesetzt wird. Die Idee ist: Um ein Array zu sortieren, sortiere erst die erste Hälfte, dann die zweite Hälfte, und dann füge die beiden Teile zusammen (merge). Der folgende Code implementiert Merge sort für int
-Arrays. Sie erwartet ein Array, den ersten Index des zu sortierenden Bereichs, und den Index auf das erste Element nach dem zu sortierenden Bereich. Da die genaue Implementierung des Merge-Schritts hier nicht von Interesse ist, wird einfach angenommen, dass dafür bereits eine Funktion merge
existiert.
void mergesort(int array[], int begin, int end)
{
int mid = begin + (end-begin)/2; // Mitte des Feldes bestimmen
mergesort(array, begin, mid); // Linke Hälfte
mergesort(array, mid, end); // Rechte Hälfte
merge(array, begin, mid, end);
}
Lösung: Es fehlt eine Abbruchbedingung. Eine mögliche Abbruchbedingung wäre: Weil eine Liste mit nur einem oder gar keinem Element darin nicht sortiert werden braucht, kann die Funktion 'nichts tun', wenn der Unterschied von begin
und end
kleinergleich 1 ist.
Bei komplexeren Problemen, die rekursiv gelöst werden sollen, ist es wichtig darauf zu achten, dass das „jeweils zu lösende Problem“ bei jedem tieferen Rekursionsschritt kleiner wird, einfacher wird, näher an die Abbruchbedingung herankommt. Damit ist recht gut sichergestellt, dass die Rekursion nicht (in ungünstigen Fällen) „unendlich tief“ verzweigt.
Jeder (rekursive) Aufruf der Funktion sollte das ihr übergebene (Teil-)Problem zumindest ein wenig vereinfachen, aufteilen oder anderweitig an eine Lösung heranbringen, bevor sich die Funktion für (Unter-Teil-)Probleme rekursiv erneut aufruft - und das Vereinfachen sollte in jedem möglichen Fall (if
-Zweig) geschehen.
Zeiger
[Bearbeiten]Grundlagen zu Zeigern
[Bearbeiten]Zeiger (engl. pointers) sind Variablen, die als Wert die Speicheradresse einer anderen Variable (oder eines anderen Speicherobjekts) enthalten.
Jede Variable wird in C++ an einer bestimmten Position im Hauptspeicher abgelegt. Diese Position nennt man Speicheradresse (engl. memory address). C++ bietet die Möglichkeit, die Adresse jeder Variable zu ermitteln. Solange eine Variable gültig ist, bleibt sie an ein und derselben Stelle im Speicher.
Am einfachsten vergegenwärtigt man sich dieses Konzept anhand der globalen Variablen. Diese werden außerhalb aller Funktionen und Klassen deklariert und sind überall gültig. Auf sie kann man von jeder Klasse und jeder Funktion aus zugreifen. Über globale Variablen ist bereits zur Kompilierzeit bekannt, wo sie sich innerhalb des Speichers befinden (also kennt das Programm ihre Adresse).
Eine Adresse ist nichts anderes als eine Ganzzahl, die den Ort, die Nummer des ersten Bytes eines Objekts, angibt. Um eine solche Adressen-Ganzzahl zu speichern, ist ein Zeiger im Wesentlichen eine normale (Ganzzahl-)Variable. Zeiger(variablen) werden deklariert (und definiert), besitzen einen Gültigkeitsbereich, selbst wiederum eine Adresse und einen Wert. Dieser Wert, der Inhalt der Zeigervariable, ist zwar zunächst einmal eine Zahl, aber auch die Adresse einer anderen Variable oder eines Speicherbereichs. Bei der Deklaration einer Zeigervariable wird auch der Typ der Variable angegeben, auf die der Zeiger verweisen soll - also der Typ, den das Objekt besitzt, auf das der Zeiger zeigt.
#include <iostream>
int main() {
int Wert; // eine int-Variable
int *pWert; // eine Zeigervariable für eine Variable vom Typ int
int *pZahl; // ein weiterer "Zeiger auf int"
Wert = 10; // Zuweisung eines Wertes an eine int-Variable
pWert = &Wert; // Adressoperator '&' liefert die Adresse der Variable "Wert"
pZahl = pWert; // pZahl und pWert zeigen jetzt auf dieselbe Variable
…
Der Adressoperator &
kann auf jede Variable angewandt werden und liefert deren Adresse, die man einer (dem Variablentyp entsprechenden) Zeigervariablen zuweisen kann. Wie im Beispiel gezeigt, können Zeiger gleichen Typs einander zugewiesen werden. Zeiger verschiedenen Typs bedürfen einer Typumwandlung. Die Zeigervariablen pWert
und pZahl
sind an verschiedenen Stellen im Speicher abgelegt, nur die Inhalte sind gleich.
Wollen Sie auf den Wert zugreifen, der sich hinter der im Zeiger gespeicherten Adresse verbirgt, so verwenden Sie den Dereferenzierungsoperator *
.
Man nennt das den Zeiger dereferenzieren. Im Beispiel erhalten Sie die Ausgabe Wert = 23
, denn pWert
und pZahl
verweisen ja beide auf die Variable Wert
.
Um es noch einmal hervorzuheben: Zeiger auf Integer (int
) sind selbst keine Integer. Den Versuch, einer Zeigervariablen eine Zahl zuzuweisen, beantwortet der Compiler mit einer Fehlermeldung oder mindestens einer Warnung. Hier gibt es nur eine Ausnahme: die Zahl 0 darf jedem beliebigen Zeiger zugewiesen werden. Ein solcher Nullzeiger zeigt nirgendwohin. Der Versuch, ihn zu dereferenzieren, führt zu einem Laufzeitfehler.
Der Sinn von Zeigern erschließt sich vor allem Anfängern nicht unmittelbar. Das ändert sich allerdings schnell, sobald der dynamische Speicher besprochen wird.
Zeiger und const
[Bearbeiten]Das Schlüsselwort const
kann auf zweierlei Arten in Verbindung mit Zeigern genutzt werden:
- Um den Wert, auf den der Zeiger zeigt, konstant zu machen,
- Um den Zeiger selbst konstant zu machen.
Im ersten Fall kann der Zeiger im Laufe seines Lebens auf verschiedene Objekte zeigen, diese Werte können dann allerdings (über diesen Zeiger) nicht geändert werden. Im zweiten Fall kann der Zeiger nicht auf eine andere Adresse "umgebogen" werden. Der Wert an jener Stelle kann allerdings verändert werden. Natürlich sind auch beide Varianten in Kombination möglich.
int Wert1; // eine int-Variable
int Wert2; // noch eine int-Variable
int const * p1Wert = &Wert1; // Zeiger auf konstanten int
int * const p2Wert = &Wert1; // konstanter Zeiger auf int
int const * const p3Wert = &Wert1; // konstanter Zeiger auf konstanten int
p1Wert = &Wert2; // geht
*p1Wert = Wert2; // geht nicht, int konstant
p2Wert = &Wert2; // geht nicht, Zeiger konstant
*p2Wert = Wert2; // geht
p3Wert = &Wert2; // geht nicht, Zeiger konstant
*p3Wert = Wert2; // geht nicht, int konstant
Wie Sie sich sicher noch erinnern, gehört const
immer zu dem was links von ihm steht. Es sei denn links steht nichts mehr, dann gehört es zu dem was rechts davon steht.
Zeiger und Funktionen
[Bearbeiten]Wenn Sie einen Zeiger als Parameter an eine Funktion übergeben, können Sie den Wert an der übergebenen Adresse ändern. Eine Funktion, welche die Werte zweier Variablen vertauscht, könnte folgendermaßen implementiert werden:
Diese Funktion hat natürlich einige Schwachstellen. Beispielsweise stürzt sie ab, wenn ihr ein Nullzeiger übergeben wird. Aber sie zeigt, dass es mit Zeigern möglich ist, den Wert einer Variable außerhalb der Funktion zu verändern. In Kürze werden Sie sehen, dass sich dieses Beispiel besser mit Referenzen lösen lässt.
Funktionen, die einen Zeiger auf einen konstanten Datentyp erwarten, können auch mit einem Zeiger auf einen nicht-konstanten Datentyp aufgerufen werden. Das folgende Minimalbeispiel soll dies zeigen:
// Funktion, die einen Zeiger auf einen konstanten int erwartet
void function(int const* parameter){}
int main() {
int* zeiger; // Zeiger auf nicht-konstanten int
function(zeiger); // Funktioniert
}
Probleme mit Zeigern auf Zeiger auf konstante Daten
[Bearbeiten]Ein Problem, über das die meisten Programmierer irgendwann stolpern, ist die Übergabe eines Zeigers auf einen Zeiger auf nicht-konstante Daten an eine Funktion, die einen Zeiger auf einen Zeiger auf konstante Daten erwartet. Da sich das const
hier nicht direkt auf die Daten des Zeigers bezieht, sondern erst auf die Daten des Zeigers, auf den der Zeiger zeigt, erlaubt der Compiler die Übergabe nicht. Die Lösung des Problems ist relativ einfach: teilen Sie dem Compiler mittels const_cast
explizit mit, dass Sie die Umwandlung vornehmen möchten.
// Funktion, die einen Zeiger auf einen Zeiger einen konstanten int erwartet
void function(int const** parameter){}
int main() {
int** zeiger; // Zeiger auf Zeiger auf nicht-konstanten int
function(zeiger); // Fehler: Typen sind inkompatibel
// Lösung: explizites hinzucasten der Konstantheit
function(const_cast< int const** >(zeiger));
}
Wenn Sie das Beispiel ausprobieren möchten, kommentieren Sie die fehlerhafte Zeile aus
In den meisten Fällen werden Sie einen Parametertyp haben, der etwa die Form Typ const*const*
hat und Daten übergeben will, deren Typ Typ**
lautet. Die Umwandlung der Konstantheit der Daten unmittelbar auf die der Zeiger zeigt, dass der Compiler dies automatisch vornehmen kann. Alle anderen Umwandlungen müssen Sie explizit mittels const_cast
erledigen. Es ist somit egal, ob Sie const_cast< int const*const* >
oder nur const_cast< int const** >
für die explizite Umwandlung angeben.
Zeigerarithmetik
[Bearbeiten]Zeiger sind keine Zahlen. Deshalb sind einige arithmetischen Operationen auf Zeiger nicht anwendbar, und für die übrigen gelten andere Rechenregeln als in der Zahlenarithmetik. C++ kennt die Größe des Speicherbereichs, auf den ein Zeiger verweist. Inkrementieren (oder Dekrementieren) verändert die referenzierte Adresse unter Berücksichtigung dieser Speichergröße. Das folgende Beispiel soll den Unterschied zwischen Zahlen- und Zeigerarithmetik verdeutlichen:
#include <iostream>
int main() {
std::cout << "Zahlenarithmetik" << std::endl;
int a = 1; // a wird 1
std::cout << "a: " << a << std::endl;
a++; // a wird 2
std::cout << "a: " << a << std::endl;
std::cout << "Zeigerarithmetik" << std::endl;
int *p = &a; // Adresse von a an p zuweisen
std::cout << "p verweist auf: " << p << std::endl;
std::cout << " Größe von int: " << sizeof(int) << std::endl;
p++;
std::cout << "p verweist auf: " << p << std::endl;
}
Zahlenarithmetik
a: 1
a: 2
Zeigerarithmetik
p verweist auf: 0x7fff3aa60090
Größe von int: 4
p verweist auf: 0x7fff3aa60094
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.
Wie Sie sehen, erhöht sich der Wert des Zeigers nicht um eins, sondern um vier, was genau der Größe des Typs entspricht, auf den er zeigt: int
. Auf einer Platform, auf der int
eine andere Größe hat, würde natürlich entsprechend dieser Größe gezählt werden. Im nächsten Beispiel sehen Sie, wie ein Zeiger auf eine weitere Zeigervariable verweist, welche ihrerseits auf einen int
-Wert zeigt.
#include <iostream>
int main() {
int a = 1;
int *p = &a; // Adresse von a an p zuweisen
int **pp = &p; // Adresse von p an pp zuweisen
std::cout << "pp verweist auf: " << pp << std::endl;
std::cout << " Größe von int*: " << sizeof(int*) << std::endl;
++pp;
std::cout << "pp verweist auf: " << pp << std::endl;
}
pp verweist auf: 0x7fff940cb6f0
Größe von int*: 8
pp verweist auf: 0x7fff940cb6f8
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.
Wie Sie sehen hat ein Zeiger auf int
im Beispiel eine Größe von 8 Byte. Die Größe von Datentypen ist allerdings architektur-, compiler- und systembedingt. pp
ist vom Typ „Zeiger auf Zeiger auf int
“, was sich dann in C++ als int**
schreibt. Um also auf die Variable "hinter" diesen beiden Zeigern zuzugreifen, muss man **pp
schreiben.
Es spielt keine Rolle, ob man in Deklarationen int* p;
oder int *p;
schreibt. Einige Programmierer schreiben den Stern direkt hinter den Datentyp (int* p
), andere schreiben ihn direkt vor Variablennamen (int *p
), und wieder andere lassen zu beidem ein Leerzeichen (int * p
). In diesem Buch wird die Konvention verfolgt, den Stern direkt vor Variablennamen zu schreiben, wenn einer vorhanden ist (int *p
), andernfalls wird er direkt nach dem Datentyp geschrieben (int*
).
Negativbeispiele
[Bearbeiten]Zur Verdeutlichung zwei Beispiele, die nicht funktionieren, weil sie vom Compiler nicht akzeptiert werden:
int *pWert;
int Wert;
pWert = Wert; // dem Zeiger kann kein int zugewiesen werden
Wert = pWert; // umgekehrt natürlich auch nicht
Zeigervariablen erlauben als Wert nur Adressen auf Variablen. Daher kann einer Zeigervariable wie in diesem Beispiel kein Integer-Wert zugewiesen werden.
Im Folgenden wollen wir den Wert, auf den pWert
zeigt, inkrementieren. Einige der Beispiele bearbeiten die Adresse, die pWert
enthält. Erst das letzte Beispiel verändert tatsächlich den Wert, auf den pWert
zeigt. Beachten Sie, dass jede Codezeile ein Einzelbeispiel ist.
int Wert = 0;
int *pWert = &Wert; // pWert zeigt auf Wert
pWert += 5; // ohne Dereferenzierung (*pWert) verändert man die Adresse, auf die
// der Zeiger verweist, und nicht deren Inhalt
std::cout << pWert; // es wird nicht die Zahl ausgegeben, auf die pWert
// zeigt, sondern deren (veränderte) Adresse
std::cout << "Wert enthält: " << pWert; // gleiche Ausgabe
pWert++; // Diese Operation verändert wiederum die Adresse, da nicht dereferenziert wird.
*pWert++; // Auf diese Idee kommt man als Nächstes. Doch auch das hat nicht den
// gewünschten Effekt. Da der (Post-)Inkrement-Operator vor dem Dereferenzierungs-
// operator ausgewertet wird, verändert sich wieder die Adresse.
(*pWert)++; // Da der Ausdruck in der Klammer zuerst ausgewertet wird, erreichen wir
// diesmal den gewünschten Effekt: Eine Änderung des Wertes.
void
-Zeiger (anonyme Zeiger)
[Bearbeiten]Eine besondere Rolle spielen die „Zeiger auf void
“, die so genannten generischen Zeiger. Einer Zeigervariable vom Typ void*
kann jeder beliebige Zeiger zugewiesen werden. void
-Zeiger werden in der Sprache C beispielsweise bei der dynamischen Speicherverwaltung verwendet. In C++ kommt man weitgehend ohne sie aus. Vermeiden Sie Zeiger auf void
, wenn Sie eine andere Möglichkeit haben. Ein Objekt kann nicht vom Typ void
sein. Entsprechend ist es auch nicht möglich, einen void
-Zeiger zu dereferenzieren. Das folgende kleine Codebeispiel ist praxisfern. Aber wie schon erwähnt gibt es in C++ nur noch sehr selten eine Notwendigkeit für void
-Zeiger, und keine dieser seltenen Situationen könnte an dieser Stelle im Buch bereits erklärt werden. Die meisten C++-Programmierer werden in Ihrem gesamten Leben nicht in die Verlegenheit kommen, einen void
-Zeiger wirklich verwenden zu müssen.
#include <iostream>
int main() {
int value = 1;
int* pointer = &value; // zeigt auf value
void* void_pointer;
void_pointer = pointer; // void_pointer zeigt wie pointer auf value
…
Sie können jetzt nicht ohne Weiteres auf *void_pointer
zugreifen, um an den Wert von value
zu kommen. Da es sich um einen Zeiger vom Typ void
handelt, muss man diesen erst umwandeln (engl. to cast). In diesem Fall nach int*
.
Der Ablauf dieser letzten Zeile noch einmal im Detail:
- Zeiger
void_pointer
ist vom Typvoid*
und zeigt aufvalue
reinterpret_cast< int* >
, Zeiger ist vom Typint*
- Zeiger dereferenzieren (
*
), um Wert (vom Typint
) zu erhalten - Wert ausgeben
Funktionszeiger
[Bearbeiten]Zeiger können nicht nur auf Variablen, sondern auch auf Funktionen verweisen. Die Deklaration eines solchen Funktionszeigers sieht im ersten Augenblick etwas schwierig aus, aber sie ist dennoch leicht zu merken. Sie schreiben einfach den Prototypen der Funktion, auf die ihr Zeiger verweisen soll und geben statt des Funktionsnamens den Variablennamen an. Selbigen stellen Sie einen Stern voran, um klar zu machen, dass es sich um einen Zeiger handelt, und um dem Compiler zu vermitteln, dass der Stern nicht zum Rückgabetyp gehört, fassen Sie ihn und den Variablennamen in Klammern ein. Das folgende kleine Beispiel zeigt die Verwendung:
#include <iostream>
int multiplication(int a, int b){
return a*b;
}
int division(int a, int b){
return a/b;
}
int main(){
int (*rechenoperation)(int, int) = 0; // Anlegen eines Funktionszeigers, Initialisierung mit 0
rechenoperation = &multiplication;
std::cout << (*rechenoperation)(40, 8) << std::endl;
rechenoperation = &division;
std::cout << (*rechenoperation)(40, 8) << std::endl;
}
320
5
Man liest: rechenoperation
ist ein Zeiger auf eine Funktion, die zwei int
s übernimmt und einen int
zurückgibt. Im Kapitel über Felder wird eine allgemein gültige Regel für das Lesen komplexer Datentypen vorgestellt. Wie Sie sehen wird der Zeigervariable nacheinander die Adresse zweier Funktionen zugewiesen, die dem Typ der Zeigervariable entsprechen. Eine für den Zeiger gültige Funktion muss also zwei int
s übernehmen und einen int
zurückgeben.
Um die Adresse der Funktion zu erhalten, müssen Sie den Adressoperator auf den Funktionsnamen anwenden. Beachten Sie, dass die Klammern, die Sie zum Aufruf einer Funktion immer setzen müssen, hier keinesfalls gesetzt werden dürfen. &multiplication()
würde Ihnen die Adresse des von multiplication()
zurückgelieferten Objekts beschaffen. Es sei auch darauf hingewiesen, dass der Adressoperator nicht zwingend zum Ermitteln der Funktionsadresse notwendig ist. Sie sollten Ihn aus Gründen der Übersicht allerdings immer mitschreiben.
Gleiches gilt bei der Dereferenzierung: ein expliziter Stern vor dem Funktionszeiger macht deutlich, dass es sich um eine Zeigervariable handelt. Während der Fehlersuche kann dies beim Lesen des Codes erheblich helfen. Die Klammern sind, wie schon bei der Deklaration, nötig, um dem Compiler mitzuteilen, dass sich der Stern nicht auf den Rückgabewert, sondern auf die Funktion bezieht.
int x;
x = (*rechenoperation)(40, 8); // ruft multiplication() bzw. division() auf und weist x den Rückgabewert zu
x = rechenoperation(40, 8); // alternative (nicht empfohlene) Syntax
In C werden Funktionszeiger oft für generische Funktionen verwendet, wofür es in C++ mit den Templates (auf Deutsch etwa „Vorlagen“) eine bessere Lösung gibt. Insbesondere werden in C++ statt Funktionszeigern auch oft Funktoren verwendet, welche aber erst später vorgestellt werden. Vorweggenommen sei an dieser Stelle bereits, dass ein Funktor etwas mehr Schreibaufwand benötigt als ein Funktionszeiger, dafür aber auch einiges kann, was mit einfachen Funktionszeigern nicht möglich ist.
Im Zusammenhang mit Klassen werden uns weitere Arten von Zeigern begegnen:
- Zeiger auf statische Datenelemente
- Zeiger auf Elementfunktionen.
Beide werden Sie zu einem späteren Zeitpunkt noch kennen lernen. Auch im Abschnitt „Speicherverwaltung“ werden Ihnen Zeiger noch einmal begegnen.
Referenzen
[Bearbeiten]Grundlagen zu Referenzen
[Bearbeiten]Referenzen sind interne Zeiger auf Variablen. Sie werden also genau so verwendet wie gewöhnliche Variablen, verweisen jedoch auf das Objekt, mit dem sie initialisiert wurden. Die Zeigerverwendung wird vor dem Programmierer verborgen.
Wie Sie im Beispiel sehen, sind a
und r
identisch. Gleiches können Sie natürlich auch mit einem Zeiger erreichen, auch wenn bei einem Zeiger die Syntax etwas anders ist als bei einer Referenz.
Im Beispiel wurde die Referenz auf int r
, mit dem int a
initialisiert. Beachten Sie, dass die Initialisierung einer Referenzvariablen nur beim Anlegen erfolgen kann, danach kann ihr Wert nur noch durch eine Zuweisung geändert werden. Daraus folgt, dass eine Referenz immer initialisiert werden muss und es nicht möglich ist, eine Referenzvariable auf ein neues Objekt verweisen zu lassen:
int a = 10; // eine Variable
int b = 20; // noch eine Variable
int &r = a; // Referenz auf die Variable a
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
++a;
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
r = b; // r zeigt weiterhin auf a, r (und somit a) wird 20 zugewiesen
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
a: 10 b: 20 r: 10
a: 11 b: 20 r: 11
a: 20 b: 20 r: 20
Wie Sie sehen, ist es nicht möglich, r
als Alias für b
zu definieren, nachdem es einmal mit a
initialisiert wurde. Die Zuweisung bewirkt genau das, was auch eine Zuweisung von b
an a
bewirkt hätte. Dass eine Referenz wirklich nichts weiter ist als ein Aliasname wird umso deutlicher, wenn man sich die Adressen der Variablen aus dem ersten Beispiel ansieht:
Wie Sie sehen, sind die Adressen identisch.
Anwendung von Referenzen
[Bearbeiten]Vielleicht haben Sie sich bereits gefragt, wofür Referenzen nun eigentlich gut sind, schließlich könnte man ja auch einfach die Originalvariable benutzen.
Selbstdefinierte Referenzen
[Bearbeiten]Referenzen bieten in einigen Anwendungsfällen eine Beschleunigung und bessere Lesbarkeit der Quelltexte. Sie müssen initialisiert werden.
#include <iostream>
int main(){
unsigned int const x = 2, y = 3, z = 4;
unsigned int zahlen_array[x][y][z] = {
{ { 0, 1, 2, 3}, { 4, 5, 6, 7}, { 8, 9, 10, 11} },
{ {12, 13, 14, 15}, {16, 17, 18, 19}, {20, 21, 22, 23} }
};
for(unsigned int a = 0; a < x; ++a){
for(unsigned int b = 0; b < y; ++b){
for(unsigned int c = 0; c < z; ++c){
// ref wird als Referenz auf zahlen_array[a][b][c] initialisiert
unsigned int& ref = zahlen_array[a][b][c];
// entspricht 'zahlen_array[a][b][c] *= 2;'
ref *= 2;
// entspricht 'zahlen_array[a][b][c] += a * b * c;'
ref += a * b * c;
// entspricht 'std::cout << zahlen_array[a][b][c] << std::endl;'
std::cout << ref << ", ";
}
std::cout << std::endl;
}
std::cout << std::endl;
}
}
0, 2, 4, 6,
8, 10, 12, 14,
16, 18, 20, 22,
24, 26, 28, 30,
32, 35, 38, 41,
40, 44, 48, 52,
Bei mehrfacher Verwendung, kann eine Referenz Ihnen viel Tipparbeit ersparen und vor allem erhöht sie die Übersichtlichkeit des Quellcodes. Außerdem kann diese Vorgehensweise die Performance verbessern, da der Zugriff auf Daten in Klassen oder Feldern durch eine Referenzdefinition vereinfacht wird. Bei obigem Beispiel wird zur Laufzeit, im Arbeitsspeicher, bei jeder Verwendung von zahlen_array[a][b][c]
zuerst der Speicherort der einzelnen Zahl berechnet, dabei müssen die Inhalts-, Feldgrößen und Offsets innerhalb des Felds berücksichtigt werden. An anderen Stellen mag dies alles mit STL-Containerklassen und verschachtelten Methoden- und Operatoraufrufen erfolgen.
Dies alles können Sie dem Prozessor nicht ersparen. Sie können aber dafür sorgen, dass es, pro Schritt, nur einmal vorkommt. Die Verwendung einer Referenz ergibt daher Sinn, sobald Sie zahlen_array[a][b][c]
mehr als einmal verwenden. Eine Referenz ist intern meist mit einem Zeiger implementiert. Es sei allerdings darauf hingewiesen, dass der Compiler in vielen Fällen diese Optimierung auch selbst vornehmen kann, daher ist ein Performancegewinn nicht zwingend vorhanden.
Call-By-Reference
[Bearbeiten]Möglicherweise erinnern Sie sich aber auch noch, dass im Kapitel „Prozeduren und Funktionen“ die Wertübergabe als Referenz (call-by-reference) vorgestellt wurde. Darauf wird nun genauer eingegangen.
Referenzen bieten genau wie Zeiger die Möglichkeit, den Wert einer Variable außerhalb der Funktion zu ändern. Im Folgenden sehen Sie die im Kapitel über Zeiger vorgestellte Funktion swap()
mit Referenzen:
Diese Funktion bietet gegenüber der Zeigervariante zwei Vorteile. Die Syntax ist einfacher und es ist nicht möglich, so etwas wie einen Nullzeiger zu übergeben. Um diese Funktion zum Absturz zu bewegen, ist schon einige Mühe nötig.
const
-Referenzen
[Bearbeiten]Referenzen auf konstante Variablen spielen in C++ eine besondere Rolle. Eine Funktion, die eine Variable übernimmt, kann genauso gut auch eine Referenz auf eine konstante Variable übernehmen. Folgendes Beispiel soll dies demonstrieren:
Die beiden Ausgabefunktionen sind an sich identisch, lediglich die Art der Parameterübergabe unterscheidet sich. ausgabe1()
übernimmt einen int
, ausgabe2()
eine Referenz auf einen konstanten int
. Beide Funktionen lassen sich auch vollkommen identisch aufrufen. Würde ausgabe2()
eine Referenz auf einen nicht-konstanten int
übernehmen, wäre ein Aufruf mit einer Konstanten, wie dem int
-Literal 5
nicht möglich.
In Verbindung mit Klassenobjekten ist die Übergabe als Referenz auf ein konstantes Objekt sehr viel schneller, dazu erfahren Sie aber zu gegebener Zeit mehr. Für die Ihnen bereits bekannten Basisdatentypen ist tatsächlich die Übergabe als Wert effizienter.
Referenzen als Rückgabetyp
[Bearbeiten]Referenzen haben als Rückgabewert die gleichen Vorteile wie bei der Wertübergabe. Allerdings sind sie in diesem Zusammenhang wesentlich gefährlicher. Es kann schnell passieren, dass Sie versehentlich eine Referenz auf eine lokale Variable zurückgeben. Diese Variable ist außerhalb der Funktion allerdings nicht mehr gültig, daher ist das Resultat, wenn Sie außerhalb der Funktion darauf zugreifen, undefiniert. Aus diesem Grund sollten Sie Referenzen als Rückgabewert nur verwenden wenn Sie wirklich wissen, was Sie tun.
Felder
[Bearbeiten]In C++ lassen sich mehrere Variablen desselben Typs zu einem Array (im Deutschen bisweilen auch Datenfeld oder Vektor genannt, selten auch Matrix, Tabelle, Liste) zusammenfassen. Auf die Elemente des Arrays wird über einen Index zugegriffen. Bei der Definition sind der Typ der Elemente und die Größe des Arrays (=Anzahl der Elemente) anzugeben. Folgende Möglichkeiten stehen zum Anlegen eines Array zur Verfügung:
// Array mit 10 Elementen vom Typ 'int'; Array-Name ist 'feld'.
int feld[10]; // Anlegen ohne Initialisierung
int feld[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // Mit Initialisierung (automatisch 10 Elemente)
int feld[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // 10 Elemente, mit Initialisierung
Soll das Array initialisiert werden, verwenden Sie eine Aufzählung in geschweiften Klammern, wobei der Compiler die Größe des Arrays selbst ermitteln kann. Es wird empfohlen, diese automatische Größenerkennung nicht zu nutzen. Wenn die Größenangabe explizit gemacht wurde, gibt der Compiler einen Fehler aus, falls die Anzahl der Intitiallisierungselemente nicht mit der Größenangabe übereinstimmt.
Wie bereits erwähnt, kann auf die einzelnen Elemente eines Arrays mit dem Indexoperator []
zugegriffen werden. Beim Zugriff auf Arrayelemente beginnt die Zählung bei 0. Das heißt, ein Array mit 10 Elementen enthält die Elemente 0 bis 9. Ein Arrayindex ist immer ganzzahlig.
Mit Arrayelementen können alle Operationen wie gewohnt ausgeführt werden.
feld[4] = 88;
feld[3] = 2;
feld[2] = feld[3] - 5 * feld[7 + feld[3]];
if(feld[0] < 1){
++feld[9];
}
for(int n = 0; n < 10; ++n){
std::cout << feld[n] << std::endl;
}
Beachten Sie, dass der Compiler keine Indexprüfung durchführt. Wenn Sie ein nicht vorhandenes Element, z.B. feld[297]
im Programm verwenden, kann Ihr Programm bei einigen Durchläufen unerwartet abstürzen. Zugriffe über Arraygrenzen erzeugen undefiniertes Verhalten. In modernen Desktop-Betriebssystemen kann das Betriebssystem einige dieser Bereichsüberschreitungen abfangen und das Programm abbrechen („segmentation fault“). Manchmal überschreibt man auch einfach nur den eigenen Speicherbereich in anderen Variablen, was zu schwer zu findenden Bugs führt.
Zeiger und Arrays
[Bearbeiten]Der Name eines Arrays wird vom Compiler (ähnlich wie bei Funktionen) als Adresse des Arrays interpretiert. In Folge dessen haben Sie neben der Möglichkeit über den Indexoperator auch die Möglichkeit, mittels Zeigerarithmetik auf die einzelnen Arrayelemente zuzugreifen.
Im Normalfall werden Sie mit der ersten Syntax arbeiten, es ist jedoch nützlich zu wissen, wie Sie ein Array mittels Zeigern manipulieren können. Es ist übrigens nicht möglich, die Adresse von feld
zu ändern. feld
verhält sich also in vielerlei Hinsicht wie ein konstanter Zeiger auf das erste Element des Arrays. Einen deutlichen Unterschied werden Sie allerdings bemerken, wenn Sie den Operator sizeof()
auf die Arrayvariable anwenden. Als Rückgabe bekommen Sie die Größe des gesamten Arrays. Teilen Sie diesen Wert durch die Größe eines beliebigen Arrayelements, erhalten Sie die Anzahl der Elemente im Array.
Mehrere Dimensionen
[Bearbeiten]Auch mehrdimensionale Arrays sind möglich. Hierfür werden einfach Größenangaben für die benötigte Anzahl von Dimensionen gemacht. Das folgende Beispiel legt ein zweidimensionales Array der Größe 4 × 8 an, das 32 Elemente enthält. Theoretisch ist die Anzahl der Dimensionen unbegrenzt.
int feld[4][8]; // Ohne Initialisierung
int feld[4][8] = { // Mit Initialisierung
{ 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 }
};
Wie Sie sehen, können auch mehrdimensionale Arrays initialisiert werden. Die äußeren geschweiften Klammern beschreiben die erste Dimension mit 4 Elementen. Die inneren geschweiften Klammern beschreiben dementsprechend die zweite Dimension mit 8 Elementen. Beachten Sie, dass die inneren geschweiften Klammern lediglich der Übersicht dienen, sie sind nicht zwingend erforderlich. Dementsprechend ist es für mehrdimensionale Arrays bei der Initialisierung nötig, die Größe aller Dimensionen anzugeben.
Genaugenommen wird eigentlich ein (1-dimensionales) Array von (1-dimensionalen) Arrays erzeugt. In unserem Beispiel ist feld
ein Array mit 4 Elementen vom Typ „Array mit 8 Elementen vom Typ int
“. Dementsprechend sieht auch der Aufruf mittels Zeigerarithmetik auf einzelne Elemente aus.
Es sind ebenso viele Dereferenzierungen wie Dimensionen nötig. Um sich vor Augen zu führen, wie die Zeigerarithmetik für diese mehrdimensionalen Arrays funktioniert, ist es nützlich, sich einfach die Adressen bei Berechnungen anzusehen:
#include <iostream>
int main(){
int feld[4][8];
std::cout << "Größen\n";
std::cout << "int[4][8]: " << sizeof(feld) << "\n";
std::cout << " int[8]: " << sizeof(*feld) << "\n";
std::cout << " int: " << sizeof(**feld) << "\n";
std::cout << "Adressen\n";
std::cout << " feld: " << feld << "\n";
std::cout << " feld + 1: " << feld + 1 << "\n";
std::cout << "(*feld) + 1: " << (*feld) + 1 << "\n";
}
Größen
int[4][8]: 128
int[8]: 32
int: 4
Adressen
feld: 0x7fff2be5d400
feld + 1: 0x7fff2be5d420
(*feld) + 1: 0x7fff2be5d404
Wie Sie sehen, erhöht feld + 1
die Adresse um den Wert 32 (Hexadezimal 20), was sizeof(int[8])
entspricht. Also der Größe aller verbleibenden Dimensionen. Die erste Dereferenzierung liefert wiederum eine Adresse zurück. Wird diese um 1 erhöht, so steigt der Wert lediglich um 4 (sizeof(int)
).
Beachten Sie, dass auch für mehrdimensionale Arrays keine Indexprüfung erfolgt. Greifen Sie nicht auf ein Element zu, dessen Grenzen außerhalb des Arrays liegen. Beim Array int[12][7][9]
können Sie auf die Elemente [0..11][0..6][0..8] zugreifen. Die Zählung beginnt also auch hier immer bei 0 und endet dementsprechend 1 unterhalb der Dimensionsgröße.
Arrays und Funktionen
[Bearbeiten]Arrays und Funktionen arbeiten in C++ nicht besonders gut zusammen. Sie können keine Arrays als Parameter übergeben und auch keine zurückgeben lassen. Da ein Array allerdings eine Adresse hat (und der Arrayname diese zurückliefert), kann man einfach einen Zeiger übergeben. C++ bietet (ob nun zum besseren oder schlechteren) eine alternative Syntax für Zeiger bei Funktionsparametern an.
void funktion(int *parameter);
void funktion(int parameter[]);
void funktion(int parameter[5]);
void funktion(int parameter[76]);
Jeder dieser Prototypen ist gleichwertig. Die Größenangaben beim dritten und vierten Beispiel werden vom Compiler ignoriert. Innerhalb der Funktion können Sie wie gewohnt mit dem Indexoperator auf die Elemente zugreifen. Beachten Sie, dass Sie die Arraygröße innerhalb der Funktion nicht mit sizeof(arrayname)
feststellen können. Bei diesem Versuch würden Sie stattdessen die Größe eines Zeigers auf ein Array-Element erhalten.
Aufgrund dieses Verhaltens könnte man die Schreibweise des ersten Prototypen auswählen. Andere Programmierer argumentieren, dass bei der zweiten Schreibweise deutlich wird, dass der Parameter ein Array repräsentiert. Eine Größenangabe bei Arrayparametern ist manchmal anzutreffen, wenn die Funktion nur Arrays dieser Größe bearbeiten kann. Um es noch einmal zu betonen: Diese Größenangaben sind nur ein Hinweis für den Programmierer; der Compiler wird ohne Fehler und Warnung Ihr Array als einfachen Zeiger übernehmen. Eine eventuelle Angabe der Elementanzahl beim Funktionsparameter wird vom Compiler komplett ignoriert.
Bei mehrdimensionalen Arrays sehen die Regeln ein wenig anders aus, da diese Arrays vom Typ Array sind. Wie Sie wissen, ist es zulässig, Zeiger als Parameter zu übergeben. Entsprechend ist natürlich auch ein Zeiger auf ein Array zulässig. Die folgenden Prototypen zeigen, wie die Syntax bei mehrdimensionalen Arrays aussieht.
void funktion(int (*parameter)[8]);
void funktion(int parameter[][8]);
void funktion(int parameter[4][8]);
Alle diese Prototypen haben einen Parameter vom Typ „Zeiger auf Array mit acht Elementen vom Typ int
“. Ab der zweiten Dimension geben Sie also tatsächlich Arrays an, somit müssen Sie natürlich auch die Anzahl der Elemente zwingend angeben. Daher können Sie sizeof()
in der Funktion verwenden, um die Größe zu ermitteln. Dies ist allerdings nicht notwendig, da Sie bereits im Vorfeld wissen, wie groß der Array ist und vom welchem Typ er ist. Die Größe berechnet sich wie folgt:
sizeof(Typ)*Anzahl der Elemente
. In unserem Beispiel entspricht dies 4*8 = 32
. Auf ein zweidimensionales Array können Sie innerhalb der Funktion mit dem normalen Indexoperator zugreifen.
Beachten Sie, dass beim ersten Prototypen die Klammern zwingend notwendig sind, andernfalls hätten die eckigen Klammern auf der rechten Seite des Parameternamen Vorrang. Somit würde der Compiler dies wie oben gezeigt als einen Zeiger behandeln, natürlich unabhängig von der Anzahl der angegebenen Elemente. Ohne diese Klammern würden Sie also einen Zeiger auf einen Zeiger auf int
deklarieren.
Seit C++11 gibt es den Header <array>
in welchem eine gleichnamige Datenstruktur definiert ist. Man könnte in diesem Zusammenhang vielleicht von C++-Arrays sprechen. Sie werden verwendet wie gewöhnliche C-Arrays, haben aber eine andere Deklaration. Ein Array mit 8 Elemente vom Typ int
wird wie folgt definiert:
Für mehrdimensionale Arrays kann man statt int
als Datentyp wieder ein Array angeben. Die Deklaration ist also etwas aufwendiger als bei C-Arrays, dafür kann ein solches C++-Array aber ganz normal an Funktionen übergeben werden.
void funktion(std::array< int, 8 > parameter);
void funktion(std::array< int, 8 > const& parameter);
void funktion(std::array< std::array< int, 8 >, 4 > const& parameter);
Im ersten Fall wird eine Kopie übergeben, im zweiten eine Referenz auf eine konstantes Array. In beiden Fällen können nur C++-Arrays mit genau 8 Elementen übergeben werden. Die dritte Zeile zeigt die Übergabe eines zweidimensionalen Arrays, auch hier müssen die beiden Dimensionen (4 und 8) natürlich exakt übereinstimmen.
Ein weiterer Vorteil ist, dass C++-Array-Objekte eine Funktion names size()
haben, welche die Anzahl der Elemente zurückgibt.
Lesen komplexer Datentypen
[Bearbeiten]Sie kennen nun Zeiger, Referenzen und Arrays, sowie natürlich die grundlegenden Datentypen. Es kann Ihnen passieren, dass Sie auf Datentypen treffen, die all das in Kombination nutzen. Im Folgenden werden Sie lernen, solche komplexen Datentypen zu lesen und zu verstehen, wie man sie schreibt.
Als einfache Regel zum Lesen von solchen komplexeren Datentypen können Sie sich merken:
- Es wird ausgehend vom Namen gelesen.
- Steht etwas rechts vom Namen, wird es ausgewertet.
- Steht rechts nichts mehr, wird der Teil auf der linken Seite ausgewertet.
- Mit Klammern kann die Reihenfolge geändert werden.
Die folgenden Beispiele werden zeigen, dass diese Regeln immer gelten:
int i; // i ist ein int
int *j; // j ist ein Zeiger auf int
int k[6]; // k ist ein Array von sechs Elementen des Typs int
int *l[6]; // l ist ein Array von sechs Elementen des Typs Zeiger auf int
int (*m)[6]; // m ist ein Zeiger auf ein Array von sechs Elementen des Typs int
int *(*&n)[6]; // n ist eine Referenz auf einen Zeiger auf ein Array von
// sechs Elementen des Typs Zeiger auf int
int *(*o[6])[5]; // o ist ein Array von sechs Elementen des Typs Zeiger auf ein
// Array von fünf Elementen des Typs Zeiger auf int
int **(*p[6])[5]; // p ist Array von sechs Elementen des Typs Zeiger auf ein Array
// von fünf Elementen des Typs Zeiger auf Zeiger auf int
Nehmen Sie sich die Zeit, die Beispiele nachzuvollziehen. Wenn Sie keine Probleme damit haben, sehen Sie sich das nächste sehr komplexe Beispiel an. Es soll die allgemeine Gültigkeit dieser Regel noch einmal demonstrieren:
pFunc
ist ein Array mit fünf Elementen, das Zeiger auf Zeiger auf Funktionen enthält, die einen Zeiger auf int
und eine Referenz auf double
übernehmen und einen Zeiger auf Arrays mit sechs Elementen vom Typ Zeiger auf Funktionen, ohne Parameter, mit einem int
als Rückgabewert zurückgeben.
Einem solchen Monstrum werden Sie beim Programmieren wahrscheinlich selten bis nie begegnen, aber falls doch, können Sie es mit den obengenannten Regeln entschlüsseln. Wenn Sie nicht in der Lage sind, dem Beispiel noch zu folgen, brauchen Sie sich keine Gedanken zu machen: Nur wenige Menschen sind in der Lage, sich ein solches Konstrukt überhaupt noch vorzustellen. Wenn Sie es nachvollziehen können, kommen Sie sehr wahrscheinlich mit jeder Datentypdeklaration klar!
Wenn Sie in Ihren Programmen solche Strukturen deklarieren/anlegen, denken Sie daran, dass ein kurzes Kommentar es jedermann viel einfacher macht, die Struktur zu verstehen!
Zeichenketten
[Bearbeiten]Einleitung
[Bearbeiten]In C gibt es keinen eingebauten Datentyp für Zeichenketten, lediglich einen für einzelne Zeichen. Da es in C noch keine Klassen gab, bediente man sich dort der einfachsten Möglichkeit, aus Zeichen Zeichenketten zu bilden: Man legte einfach einen Array von Zeichen an.
C++ bietet eine komfortablere Lösung an: Die C++-Standardbibliothek enthält eine Klasse namens string
. Um diese Klasse nutzen zu können, müssen Sie die gleichnamige Headerdatei string
einbinden.
Wir werden uns in diesem Kapitel mit der C++-Klasse string
auseinandersetzen. Am Ende des Kapitels beleuchten wir den Umgang mit C-Strings (also char
-Arrays) etwas genauer. Natürlich liegt auch string
, wie alle Teile der Standardbibliothek, im Namensraum std
.
Wie entsteht ein string
-Objekt?
[Bearbeiten]Zunächst sind einige Worte zur Notation von Zeichenketten in doppelten Anführungszeichen nötig. Wie Ihnen bereits bekannt ist, werden einzelne Zeichen in einfachen Anführungszeichen geschrieben. Dieser Zeichenliteral ist dann vom Typ char
. Die doppelten Anführungszeichen erzeugen hingegen eine Instanz eines char
-Arrays. "Hallo Welt!"
ist zum Beispiel vom Typ char[12]
.
Es handelt sich also um eine Kurzschreibweise, zum Erstellen von char
-Arrays, damit Sie nicht {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'
} schreiben müssen, um eine einfache Zeichenkette zu erstellen. Was ist das '\0'
und warum ist das Array 12 char
s lang, obwohl es nur 11 Zeichen enthält?
Wie bereits erwähnt, ist ein C-String ein Array von Zeichen. Da ein solcher C-String natürlich im Programmablauf Zeichenketten unterschiedlicher Längen enthalten konnte, beendete man die Zeichenkette durch ein Endzeichen: '\0'
(Zahlenwert 0). Somit musste ein Array von Zeichen in C immer ein Zeichen länger sein, als die längste Zeichenkette, die im Programmverlauf darin gespeichert wurde.
Diese Kurzschreibweise kann aber noch mehr, als man auf den ersten Blick vermuten würde. Die eben genannte lange Notation zur Initialisierung eines Arrays funktioniert im Quelltext nur, wenn der Compiler auch weiß, von welchem Datentyp die Elemente des Arrays sein sollen. Da Zeichenliterale jedoch implizit in größere integrale Typen umgewandelt werden können, kann er den Datentyp nicht vom Typ der Elemente, die für die Initialisierung genutzt wurden ableiten:
#include <string>
int main() {
// char-Array mit 12 Elementen
char a[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
// int-Array mit 12 Elementen
int b[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
// char-Array mit 12 Elementen
std::string z = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
}
Bei der Notation mit Anführungszeichen ist dagegen immer bekannt, dass es sich um ein char
-Array handelt. Entsprechend ist die Initialisierung eines int
-Arrays damit nicht möglich. Folgendes dagegen schon:
#include <string>
int main() {
// char-Array mit 12 Elementen
char a[] = "Hallo Welt!";
// char-Array mit 12 Elementen
std::string z = "Hallo Welt!";
}
Bei der Erzeugung eines string
-Objekts wird eine Funktion aufgerufen, die sich Konstruktor nennt. Was genau ein Konstruktor ist, erfahren Sie im Kapitel über Klassen. In unserem Fall wird also der Konstruktor für das string
-Objekt z
aufgerufen. Als Parameter erhält er das char
-Array "Hallo Welt!"
. Wie Ihnen bereits bekannt ist, können an Funktionen keine Arrays übergeben werden. Stattdessen wird natürlich ein Zeiger vom Arrayelementtyp (also char
) übergeben. Dabei geht aber die Information verloren, wie viele Elemente dieses Array enthält und an dieser Stelle kommt das '\0'
-Zeichen (Nullzeichen) ins Spiel. Anhand dieses Zeichens kann auch innerhalb des Konstruktors erkannt werden, wie lang die übergebene Zeichenkette ist.
Damit wissen Sie nun, wie aus dem einfachen char
-Array das fertige string
-Objekt wird. Jetzt ist es an der Zeit zu erfahren, was Sie mit diesem Objekt alles machen können.
string
und andere Datentypen
[Bearbeiten]Wie Sie bereits im Beispiel von eben gesehen haben, lässt sich die string
-Klasse problemlos mit anderen Datentypen und Klassen kombinieren. Im ersten Beispiel dieses Kapitels wurde zunächst eine Zuweisung eines char
-Arrays vorgenommen. Anschließend wurde das string
-Objekt über cout
ausgegeben. Auch die Eingabe einer Zeichenkette über cin
ist mit einem string
-Objekt problemlos möglich:
#include <iostream>
#include <string>
int main() {
std::string zeichenkette;
std::cin >> zeichenkette;
std::cout << zeichenkette;
}
Diese Art der Eingabe erlaubt es lediglich, bis zum nächsten Whitespace einzulesen. Es kommt jedoch häufig vor, dass man eine Zeichenkette bis zum Zeilenende oder einem bestimmten Endzeichen einlesen möchte. In diesem Fall ist die Funktion getline
hilfreich. Sie erwartet als ersten Parameter einen Eingabestream und als zweiten ein string
-Objekt.
#include <iostream>
#include <string>
int main() {
std::string zeichenkette;
// Liest bis zum Zeilenende
std::getline(std::cin, zeichenkette);
std::cout << zeichenkette;
}
Als optionalen dritten Parameter kann man das Zeichen angeben, bis zu dem man einlesen möchte. Im Fall von eben wurde als der Default-Parameter '\n'
(Newline-Zeichen) benutzt. Im folgenden Beispiel wird stattdessen bis zum ersten kleinen y eingelesen.
#include <iostream>
#include <string>
int main() {
std::string zeichenkette;
// Liest bis zum nächsten y
std::getline(std::cin, zeichenkette, 'y');
std::cout << zeichenkette;
}
Zuweisen und Verketten
[Bearbeiten]Genau wie die Basisdatentypen, lassen sich auch string
s einander zuweisen. Für die Verkettung von string
s wird der +
-Operator benutzt und das Anhängen einer Zeichenkette ist mit +=
möglich.
#include <iostream>
#include <string>
int main() {
std::string string1, string2, string3;
string1 = "ich bin ";
string2 = "klug";
string3 = string1 + string2;
std::cout << string3 << std::endl;
string3 += " - " + string1 + "schön";
std::cout << string3 << std::endl;
std::cout << string1 + "schön und" + string2 << std::endl;
}
ich bin klug
ich bin klug - ich bin schön
ich bin schön und klug
Spielen Sie einfach ein wenig mit den Operatoren, um den Umgang mit ihnen zu lernen.
Nützliche Methoden
[Bearbeiten]Die string
-Klasse stellt einige nützliche Methoden bereit. Etwa um den String mit etwas zu füllen, ihn zu leeren oder über verschiedene Eigenschaften Auskunft zu bekommen. Eine Methode wird mit folgender Syntax aufgerufen:
Die Methoden size()
und length()
erwarten keine Parameter und geben beide die aktuelle Länge der gespeicherten Zeichenkette zurück. Diese Doppelung in der Funktionalität existiert, da string
in Analogie zu den anderen Containerklassen der C++-Standardbibliothek size()
anbieten muss, der Name length()
für die Bestimmung der Länge eines Strings aber natürlicher und auch allgemein üblich ist. empty()
gibt true
zurück falls der String leer ist, andernfalls false
.
Mit clear()
lässt sich der String leeren. Die resize()
-Methode erwartet ein oder zwei Parameter. Der erste ist die neue Größe des Strings, der zweite das Zeichen, mit dem der String aufgefüllt wird, falls die angegebene Länge größer ist, als die aktuelle. Wird der zweite Parameter nicht angegeben, wird der String mit '\0'
(Nullzeichen) aufgefüllt. In der Regel werden Sie dieses Verhalten nicht wollen, geben Sie also ein Füllzeichen an, falls Sie sich nicht sicher sind, was Sie tun. Ist die angegebene Länge geringer, als die des aktuellen Strings, wird am Ende abgeschnitten.
Um den Inhalt zweier Strings auszutauschen existiert die swap()
-Methode. Sie erwartet als Parameter den String mit dem ihr Inhalt getauscht werden soll. Dies ist effizienter, als das Vertauschen über eine dritte, temporäre string-Variable.
#include <iostream>
#include <string>
int main() {
std::string zeichenkette1 = "Ich bin ganz lang!";
std::string zeichenkette2 = "Ich kurz!";
std::cout << zeichenkette1 << std::endl;
std::cout << zeichenkette2 << std::endl;
zeichenkette1.swap(zeichenkette2);
std::cout << zeichenkette1 << std::endl;
std::cout << zeichenkette2 << std::endl;
}
Ich bin ganz lang!
Ich kurz!
Ich kurz!
Ich bin ganz lang!
Zeichenzugriff
[Bearbeiten]Genau wie bei einem Array können Sie den []
-Operator (Zugriffsoperator) verwenden, um auf einzelne Zeichen im String zuzugreifen. Allerdings wird, ebenfalls genau wie beim Array, nicht überprüft, ob der angegebene Wert noch innerhalb der enthaltenen Zeichenkette liegt.
Alternativ existiert die Methode at()
, die den Index als Parameter erwartet und eine Grenzprüfung ausführt. Im Fehlerfall löst sie eine out_of_range
-Exception aus. Da Sie den Umgang mit Exceptions wahrscheinlich noch nicht beherrschen, sollten Sie diese Methode vorerst nicht einsetzen und stattdessen genau darauf achten, dass Sie nicht versehentlich über die Stringlänge hinaus zugreifen.
#include <iostream>
#include <string>
int main() {
std::string zeichenkette = "Ich bin ganz lang!";
std::cout << zeichenkette[4] << std::endl;
std::cout << zeichenkette.at(4) << std::endl;
std::cout << zeichenkette[20] << std::endl; // Ausgabe von Datenmüll
std::cout << zeichenkette.at(20) << std::endl; // Laufzeitfehler
}
b
b
terminate called after throwing an instance of 'std::out_of_range'
what(): basic_string::at
Abgebrochen
Die Fehlerausgabe kann bei Ihrem Compiler anders aussehen.
Beachten Sie beim Zugriff, dass das erste Zeichen den Index 0 hat. Das letzte Zeichen hat demzufolge den Index zeichenkette.length() - 1
.
Manipulation
[Bearbeiten]Suchen
[Bearbeiten]Die Methode find()
sucht das erste Vorkommen eines Strings und gibt die Startposition (Index) zurück. Der zweite Parameter gibt an, ab welcher Position des Strings gesucht werden soll.
Wird ein Substring nicht gefunden, gibt find()
den Wert std::string::npos
zurück.
Das Gegenstück zu find()
ist rfind()
. Es ermittelt das letzte Vorkommen eines Strings. Die Parameter sind die gleichen wie bei find()
.
Löschen
[Bearbeiten]Mit der Methode erase()
können Zeichen im String gelöscht werden. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter die Anzahl der Zeichen festlegen. Wird die Methode nur mit dem Startwert aufgerufen, löscht sie alle Zeichen ab dieser Position.
Ersetzen
[Bearbeiten]Sie können replace()
verwenden, um Strings zu ersetzen. Dafür benötigen Sie die Anfangsposition und die Anzahl der Zeichen, die anschließend ersetzt werden sollen.
Wie Sie sehen, verwenden wir find()
, um die Startposition zu ermitteln. Der zweite Parameter gibt die Länge an. Hier soll die alternative Schreibweise verdeutlicht werden; Sie müssen nicht eine zusätzliche Variable deklarieren, sondern können die std::string
-Klasse wie eine Funktion verwenden und über Rückgabewert auf die Methode length()
zugreifen. Im dritten Parameter spezifizieren Sie den String, welcher den ursprünglichen String zwischen der angegebenen Startposition
und Startposition + Laenge
ersetzt.
Einfügen
[Bearbeiten]Die Methode insert()
erlaubt es Ihnen, einen String an einer bestimmten Stelle einzufügen.
Kopieren
[Bearbeiten]Mit der Methode substr()
kann man sich einen Zeichenketten-Teil zurückgeben lassen. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter noch die Anzahl der Zeichen festlegen. Wird die Methode nur mit dem Startwert aufgerufen, gibt sie alle Zeichen ab dieser Position zurück.
Das -0
soll verdeutlichen, dass der Startwert abgezogen werden muss, an dieser Stelle ist es natürlich überflüssig.
Vergleiche
[Bearbeiten]C++-Strings können Sie, genau wie Zahlen, miteinander vergleichen. Was Gleichheit und Ungleichheit bei einem String bedeutet, wird Ihnen sofort klar sein. Sind alle Zeichen zweier Strings identisch, so sind beide gleich, andernfalls nicht. Die Operatoren <
, >
, <=
und >=
geben da schon einige Rätsel mehr auf.
Im Grunde kennen Sie die Antwort bereits. Zeichen sind in C++ eigentlich Zahlen. Sie werden zu Zeichen, indem den Zahlen entsprechende Symbole zugeordnet werden. Der Vergleich erfolgt also einfach mit den Zahlen, welche die Zeichen kodieren. Das erste Zeichen der Strings, das sich unterscheidet, entscheidet darüber, welcher der Strings größer bzw. kleiner ist.
Die meisten Zeichenkodierungen beinhalten in den ersten 7 Bit den ASCII-Code, welchen die nachfolgende Tabelle zeigt.
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
#include <string>
int main(){
std::string gross = "Ich bin ganz groß!";
std::string klein = "Ich bin ganz klein!";
gross == klein; // ergibt false ('g' != 'k')
gross != klein; // ergibt true ('g' != 'k')
gross < klein; // ergibt true ('g' < 'k')
gross > klein; // ergibt false ('g' < 'k')
gross <= klein; // ergibt true ('g' < 'k')
gross >= klein; // ergibt false ('g' < 'k')
}
Zahl zu string
und umgekehrt
[Bearbeiten]In C++ gibt es, im Gegensatz zu vielen anderen Programmiersprachen, keine Funktion, um direkt Zahlen in Strings oder umgekehrt umzuwandeln. Es ist allerdings nicht besonders schwierig, eine solche Funktion zu schreiben. Wir haben für die Umwandlung zwei Möglichkeiten:
- Die C-Funktionen
atof()
,atoi()
,atol()
undsprintf()
- C++-String-Streams
Die C-Variante wird in Kürze im Zusammenhang mit C-Strings besprochen. Für den Moment wollen wir uns der C++-Variante widmen. Stringstreams funktionieren im Grunde genau wie die Ihnen bereits bekannten Ein-/Ausgabestreams cin
und cout
mit dem Unterschied, dass sie ein string
-Objekt als Ziel benutzen.
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream> // String-Ein-/Ausgabe
int main() {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
int var = 10; // Eine ganzzahlige Variable
strout << var; // ganzzahlige Variable auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
std::cout << str << std::endl; // String ausgeben
}
Der vorliegende Code wandelt eine Ganzzahl in einen String um, indem die Ganzzahl auf dem Ausgabe-Stringstream ausgegeben und dann der Inhalt des Streams an den String zugewiesen wird. Die umgekehrte Umwandlung funktioniert ähnlich. Natürlich verwenden wir hierfür einen Eingabe-Stringstream (istringstream
statt ostringstream
) und übergeben den Inhalt des Strings an den Stream, bevor wir ihn von diesem auslesen.
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream> // String-Ein-/Ausgabe
int main() {
std::istringstream strin; // Unser Eingabe-Stream
std::string str = "17"; // Ein String-Objekt
int var; // Eine ganzzahlige Variable
strin.str(str); // Streaminhalt mit String-Variable füllen
strin >> var; // ganzzahlige Variable von Eingabe-Stream einlesen
std::cout << var << std::endl; // Zahl ausgeben
}
Statt istringstream
und ostringstream
können Sie übrigens auch ein stringstream
-Objekt verwenden, welches sowohl Ein-, als auch Ausgabe erlaubt, allerdings sollte man immer so präzise wie möglich angeben, was der Code machen soll. Daher ist die Verwendung eines spezialisierten Streams zu empfehlen, wenn Sie nur die speziellen Fähigkeiten (Ein- oder Ausgabe) benötigen.
Sicher sind Sie jetzt bereits in der Lage, zwei Funktionen zu schreiben, welche diese Umwandlung durchführt. Allerdings stehen wir in dem Moment, wo wir andere Datentypen als int
in Strings umwandeln wollen vor einem Problem. Wir können der folgenden Funktion zwar ohne weiteres eine double
-Variable übergeben, allerdings wird dann der Nachkommateil einfach abgeschnitten. Als Lösung kommt Ihnen nun eventuell in den Sinn, einfach eine double
-Variable von der Funktion übernehmen zu lassen.
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream> // String-Ein-/Ausgabe
std::string zahlZuString(double wert) {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
strout << wert; // Zahl auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
return str; // String zurückgeben
}
int main() {
std::string str;
int ganzzahl = 19;
double kommazahl = 5.55;
str = zahlZuString(ganzzahl);
std::cout << str << std::endl;
str = zahlZuString(kommazahl);
std::cout << str << std::endl;
}
19
5.55
Nun, so weit so gut. Das funktioniert. Leider gibt es da aber auch noch die umgekehrte Umwandlung und obgleich es möglich ist, sie auf ähnliche Weise zu lösen, wird Ihr Compiler sich dann ständig mit einer Warnung beschweren, wenn das Ergebnis Ihrer Umwandlung an eine ganzzahlige Variable zugewiesen wird.
Besser wäre es, eine ganze Reihe von Funktionen zu erzeugen, von denen jede für einen Zahlentyp verantwortlich ist. Tatsächlich können Sie in C++ mehrere Funktionen gleichen Namens erzeugen, die unterschiedliche Parameter(typen) übernehmen. Diese Vorgang nennt sich Überladen von Funktionen. Der Compiler entscheidet dann beim Aufruf der Funktion anhand der übergebenen Parameter, welche Version gemeint war, während der Programmierer immer den gleichen Namen verwendet.
Im Moment haben wir obendrein einen Sonderfall der Überladung. Alle unsere Funktionen besitzen exakt den gleichen Code. Lediglich der Parametertyp ist unterschiedlich. Es wäre ziemlich zeitaufwendig und umständlich, den Code immer wieder zu kopieren, um dann nur den Datentyp in der Parameterliste zu ändern. Noch schlimmer wird es, wenn wir eines Tages eine Änderung am Funktionsinhalt vornehmen und diese dann auf alle Kopien übertragen müssen.
Glücklicherweise bietet C++ für solche Fälle so genannte Templates, die es uns erlauben, den Datentyp vom Compiler ermitteln zu lassen. Wir teilen dem Compiler also mit, was er tun soll, womit muss er dann selbst herausfinden. Die Funktion zahlZuString()
(umbenannt in toString()
) sieht als Template folgendermaßen aus:
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream> // String-Ein-/Ausgabe
template <typename Typ>
std::string toString(Typ wert) {
std::ostringstream strout; // Unser Ausgabe-Stream
std::string str; // Ein String-Objekt
strout << wert; // Zahl auf Ausgabe-Stream ausgeben
str = strout.str(); // Streaminhalt an String-Variable zuweisen
return str; // String zurückgeben
}
int main() {
std::string str;
int ganzzahl = 19;
double kommazahl = 5.55;
std::string nochnString = "Blödsinn";
str = toString(ganzzahl);
std::cout << str << std::endl;
str = toString(kommazahl);
std::cout << str << std::endl;
str = toString(nochnString);
std::cout << str << std::endl;
}
19
5.55
Blödsinn
Die letzte Ausgabe zeigt deutlich warum die Funktion in toString
umbenannt wurde, denn sie ist nun in der Lage, jeden Datentyp, der sich auf einem ostringstream
ausgeben lässt, zu verarbeiten und dazu zählen eben auch string
-Objekte und nicht nur Zahlen. Sie werden später noch lernen, welches enorme Potenzial diese Technik in Zusammenhang mit eigenen Datentypen hat. An dieser Stelle sei Ihnen noch die Funktion zur Umwandlung von Strings in Zahlen (oder besser: alles was sich von einem istringstream
einlesen lässt) mit auf den Weg gegeben:
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream> // String-Ein-/Ausgabe
template <typename Typ>
void stringTo(std::string str, Typ &wert) {
std::istringstream strin; // Unser Eingabe-Stream
strin.str(str); // Streaminhalt mit String-Variable füllen
strin >> wert; // Variable von Eingabe-Stream einlesen
}
int main() {
std::string str = "7.65Blödsinn";
int ganzzahl;
double kommazahl;
std::string nochnString;
stringTo(str, ganzzahl);
std::cout << ganzzahl << std::endl;
stringTo(str, kommazahl);
std::cout << kommazahl << std::endl;
stringTo(str, nochnString);
std::cout << nochnString << std::endl;
}
7
7.65
7.65Blödsinn
Die Variable, die mit dem Wert des Strings belegt werden soll, wird als Referenz an die Funktion übergeben, damit der Compiler ihren Typ feststellen kann. Die Ausgabe zeigt, dass immer nur so viel eingelesen wird, wie der jeweilige Datentyp (zweiter Funktionsparameter) fassen kann. Für eine ganzzahlige Variable wird nur die Zahl Sieben eingelesen, die Gleitkommavariable erhält den Wert 7.65
und das string
-Objekt kann die gesamte Zeichenkette übernehmen.
C-Strings
[Bearbeiten]Wie bereits erwähnt, handelt es sich bei einem C-String um ein Array von char
s. Das Ende eines C-Strings wird durch ein Nullzeichen (Escape-Sequenz '\0'
) angegeben. Das Arbeiten mit C-Strings ist mühsam, denn es muss immer sichergestellt sein, dass das Array auch groß genug ist, um den String zu beinhalten. Da in C/C++ jedoch auch keine Bereichsüberprüfung durchgeführt wird, macht sich ein Pufferüberlauf (also eine Zeichenkette die größer ist als das Array, das sie beinhaltet) erst durch einen eventuellen Programmabsturz bemerkbar. Allein um dies zu vermeiden sollten Sie, wann immer es Ihnen möglich ist, die C++-string-Klasse verwenden.
Ein weiteres Problem beim Umgang mit C-Strings ist der geringe Komfort beim Arbeiten. Ob Sie einen String mit einem anderen vergleichen wollen, oder ihn an ein anderes Array „zuweisen“ möchten, in jedem Fall benötigen Sie unintuitive Zusatzfunktionen. Diese Funktionen finden Sie in der Standardheaderdatei „cstring“. Wie diese Funktionen heißen und wie man mit ihnen umgeht können Sie im C++-Referenz-Buch nachlesen, falls Sie sie einmal benötigen sollten.
Wenn Sie sich eingehender mit der Thematik auseinandersetzen möchten, sei Ihnen das Buch C-Programmierung ans Herz gelegt. Wenn Sie in C++ mit der C-Standard-Bibliothek arbeiten möchten, müssen Sie den Headerdateien ein „c“ voranstellen und das „.h“ weglassen. So wird beispielsweise aus dem C-Header „string.h“ der C++-Header „cstring“.
Vorarbeiter des Compilers
[Bearbeiten]Bevor der Compiler eine C++-Datei zu sehen kriegt, läuft noch der Präprozessor durch. Er überarbeitet den Quellcode, sodass der Compiler daraus eine Objektdatei erstellen kann. Diese werden dann wiederum vom Linker zu einem Programm gebunden. In diesem Kapitel soll es um den Präprozessor gehen, wenn Sie allgemeine Informationen über Präprozessor, Compiler und Linker brauchen, dann lesen Sie das Kapitel „Compiler“.
#include
[Bearbeiten]Die Präprozessordirektive #include
haben Sie schon häufig benutzt. Sie fügt den Inhalt der angegebenen Datei ein. Dies ist nötig, da der Compiler immer nur eine Datei übersetzen kann. Viele Funktionen werden aber in verschiedenen Dateien benutzt. Daher definiert man die Prototypen der Funktionen (und einige andere Dinge, die Sie noch kennenlernen werden) in so genannten Headerdateien. Diese Headerdateien werden dann über #include
eingebunden, wodurch Sie die Funktionen usw. aufrufen können.
Ausführlich Informationen über Headerdateien erhalten Sie im gleichnamigen Kapitel.
#include
bietet zwei Möglichkeiten, Headerdateien einzubinden:
#include "name" // Sucht im aktuellen Verzeichnis und dann in den Standardpfaden des Compilers
#include <name> // Sucht gleich in den Standardpfaden des Compilers
„Aktuelles Verzeichnis“ bezieht sich immer auf das Verzeichnis, in welchem die Datei liegt.
Die erste Syntax funktioniert immer, hat aber den Nachteil, dass dem Compiler nicht mitgeteilt wird, dass es sich um eine Standardheaderdatei handelt. Wenn sich im aktuellen Verzeichnis beispielsweise eine Datei namens iostream
befände und Sie versuchten über die erste Syntax, die Standardheaderdatei iostream
einzubinden, bänden Sie stattdessen die Datei im aktuellen Verzeichnis ein, was sehr unwahrscheinlich sein sollte, da Sie hoffentlich immer Dateiendungen wie .hpp oder .h für Ihre eigenen Header verwenden. Außerdem verlängert das Einbinden von Standardheadern in Anführungszeichen den Präprozessordurchlauf, je nach Anzahl der Dateien im aktuellen Quellpfad.
Aus diesem Grund ist es wichtig zu wissen, ob der eingebundene Header für eine Bibliotheksfunktionalität in den vordefinierten Pfaden des verwendeten Compilers steht, oder ob es eigener Inhalt ist, oder es sich um Zusatzbibliotheken handelt, deren Include-Verzeichnisse an anderen Stellen zu finden sind. Es sollte die Variante gewählt werden, bei der sich die Headerdatei vom Präprozessor am schnellsten finden lässt.
Verwenden Sie für Verweise auf Ihre eigenen Includes immer eine relative Pfadangabe mit normalen Slashes '/' als Verzeichnisseparator, damit Sie Ihre Quellcodeverzeichnisse auch an anderen Stellen und in anderen Entwicklungsumgebungen schneller kompiliert bekommen. Eine Verzeichnisangabe wie "/home/ichuser/code/cpp/projekt/zusatzlib/bla.h" oder "c:\users\manni\Eigene Dateien\code\cpp\projekt\zusatzlib\bla.h" ist sehr unangenehm und sorgt für große Verwirrung. Schreiben Sie es nun in "../zusatzlib/bla.h" um, können Sie später Ihr gesamtes Projekt leichter in anderen Pfaden kompilieren und sparen sich selbst und Ihrem Präprozessor einiges an Ärger und Verwirrung.
#define
und #undef
[Bearbeiten]#define
belegt eine Textsubstitution mit dem angegebenen Wert, z.B.:
Makros sind auch möglich, z.B.:
#define ADD_DREI(x, y, z) x + y + z + 3
int d(1), e(20), f(4), p(0);
p = ADD_DREI(d, e, f); // p = d + e + f + 3;
Diese sind allerdings mit Vorsicht zu genießen, da durch die strikte Textsubstitution des Präprozessors ohne jegliche Logikprüfungen des Compilers fatale Fehler einfließen können, und sollten eher durch (inline
)-Funktionen, Konstanten oder sonstige Konzepte realisiert werden. Manchmal lässt es sich allerdings nicht umgehen, ein Makro (weiter) zu verwenden. Beachten Sie bitte immer, dass es sich hierbei um Anweisungen an eine Rohquelltextvorbearbeitungsstufe handelt und nicht um Programmcode.
Da anstelle von einfachen Werten auch komplexere Terme als Parameter für das Makro verwendet werden können und das Makro selbst auch in einen Term eingebettet werden kann, sollten immer Klammern verwendet werden. Bsp.:
#define MUL_NOK(x, y) x*y
#define MUL_OK(x, y) ((x)*(y))
resultNok = a*MUL_NOK(b, c + d); // a*b*c + d = a*b*c + d <= falsch
resultOk = a*MUL_OK(b, c + d); // a*((b)*(c + d)) = a*b*c + a*b*d <= richtig
#undef
löscht die Belegung einer Textsubstitution/Makro, z.B.:
Bitte denken Sie bei allen Ideen, auf die Sie nun kommen, dass Sie fast immer eine Alternative zur Makroprogrammierung haben. Verwenden Sie diese Makros vor allem als Zustandsspeicher für den Präprozessordurchlauf an sich und nicht um Funktionalität Ihres Programms zu erweitern.
Einer statischen, konstanten Definition oder Templatefunktionen ist immer der Vorzug zu geben.
#
[Bearbeiten]Der #
-Ausdruck erlaubt es, den einem Makro übergebenen Parameter als Zeichenkette zu interpretieren:
Das obige Beispiel gibt also den Text "Test" auf die Standard-Ausgabe aus.
##
[Bearbeiten]Der ##
-Ausdruck erlaubt es, in Makros definierte Strings innerhalb des Präprozessorlaufs miteinander zu kombinieren, z.B.:
Als Resultat beinhaltet die Konstante A_UND_B die Zeichenkette "HalloWelt". Der ##
-Ausdruck verbindet die Namen der symbolischen Konstanten, nicht deren Werte. Ein weiteres Anwendungsbeispiel:
#define MAKE_CLASS( NAME ) \
class NAME \
{ \
public: \
static void Init##NAME() {}; \
};
...
MAKE_CLASS( MyClass )
...
MyClass::InitMyClass();
#if
, #ifdef
, #ifndef
, #else
, #elif
und #endif
[Bearbeiten]Direktiven zur bedingten Übersetzung, d.h. Programmteile werden entweder übersetzt oder ignoriert.
#if ''Ausdruck1''
// Programmteil 1
#elif ''Ausdruck2''
// Programmteil 2
/*
...
*/
#else
// Programmteil sonst
#endif
Die Ausdrücke hinter #if
bzw. #elif
werden der Reihe nach bewertet, bis einer von ihnen einen von 0 verschiedenen Wert liefert. Dann wird der zugehörige Programmteil wie üblich verarbeitet, die restlichen werden ignoriert. Ergeben alle Ausdrücke 0, wird der Programmteil nach #else
verarbeitet, sofern vorhanden.
Als Bedingungen sind nur konstante Ausdrücke erlaubt, d.h. solche, die der Präprozessor tatsächlich auswerten kann. Definierte Makros werden dabei expandiert, verbleibende Namen durch 0L ersetzt. Insbesondere dürfen keine Zuweisungen, Funktionsaufrufe, Inkrement- und Dekrementoperatoren vorkommen. Das spezielle Konstrukt
wird durch 1L bzw. 0L ersetzt, je nachdem, ob das Makro Name definiert ist oder nicht.
#ifdef
ist eine Abkürzung für #if defined
.
#ifndef
ist eine Abkürzung für #if ! defined
.
Beispiel:
Nehmen wir an, dass Sie Daten in zusammengesetzten Strukturen immer an 4-Byte Grenzen ausrichten wollen. Verschiedene Compiler bieten hierfür verschiedene compilerspezifische Pragma-Direktiven. Da diese Konfiguration nicht im C++-Standard definiert ist, kann nicht sichergestellt werden, dass es eine allgemeingültige Anweisung hierfür gibt. Sie definieren daher mit #define
in einer Headerdatei, die überall eingebunden ist, welchen Compiler Sie verwenden. Z.B. mit #define FLAG_XY_COMPILER_SUITE
in einer Datei namens "compiler-config-all.hpp". Das gibt Ihnen die Möglichkeit, an Stellen, an denen Sie compilerspezifisches Verhalten verwenden wollen, dieses auch auszuwählen.
#include "compiler-config-all.hpp"
#ifdef WIN32
#pragma pack(4)
#elif USING_GCC
#pragma align=4
#elif FLAG_XY_COMPILER_SUITE
#pragma ausrichtung(byte4)
#endif
#error
und #warning
[Bearbeiten]#error
gibt eine Fehlermeldung während des Compilerlaufs aus und bricht den Übersetzungsvorgang ab, z.B.:
#warning
ist ähnlich wie #error
, mit dem Unterschied, dass das Kompilieren nicht abgebrochen wird. Es ist allerdings nicht Teil von ISO-C++, auch wenn die meisten Compiler es unterstützen. Meistens wird es zum Debuggen eingesetzt:
#line
[Bearbeiten]Setzt den Compiler-internen Zeilenzähler auf den angegebenen Wert, z.B.:
#pragma
[Bearbeiten]Das #pragma
-Kommando ist vorgesehen, um eine Reihe von Compiler-spezifischen Anweisungen zu implementieren, z.B.:
Kennt ein bestimmter Compiler eine Pragma-Anweisung nicht, so gibt er üblicherweise eine Warnung aus, ignoriert diese nicht für ihn vorgesehene Anweisung aber ansonsten.
Um z.B. bei MS VisualC++ eine "störende" Warnung zu unterdrücken, gibt man folgendes an:
Vordefinierte Präprozessor-Variablen
[Bearbeiten]__LINE__
: Zeilennummer__FILE__
: Dateiname__DATE__
: Datum des Präprozessoraufrufs im Format Monat/Tag/Jahr__TIME__
: Zeit des Präprozessoraufrufs im Format Stunden:Minuten:Sekunden__cplusplus
: Ist nur definiert, wenn ein C++-Programm verarbeitet wird
Headerdateien
[Bearbeiten]Sie haben bereits mit 2 Headerdateien Bekanntschaft gemacht: iostream
und string
. Beide sind so genannte Standardheader, das heißt, sie sind in der Standardbibliothek jedes Compilers enthalten.
Was ist eine Headerdatei?
[Bearbeiten]Headerdateien sind gewöhnliche C++-Dateien, die im Normalfall Funktionsdeklarationen und Ähnliches enthalten. Sicher werden Sie sich erinnern: Deklarationen machen dem Compiler bekannt, wie etwas benutzt wird. Wenn Sie also eine Headerdatei einbinden und darin eine Funktionsdeklaration steht, dann weiß der Compiler wie die Funktion aufgerufen wird. Der Compiler weiß zu diesem Zeitpunkt nicht, was die Funktion tut, aber das ist auch nicht nötig um sie aufrufen zu können.
Das Einbinden einer Headerdatei erfolgt über die Präprozessordirektive #include
. Der Code in der Datei die mit include
referenziert wird, wird vom Präprozessor einfach an der Stelle eingefügt, an der das include
stand.
In der nebenstehenden Darstellung wird die Headerdatei mal2.hpp
von den Quelldateien main.cpp
und mal2.cpp
eingebunden. Um dieses Beispiel zu übersetzen, müssen die Dateien alle im gleichen Verzeichnis liegen. Sie übergeben die Quelldateien an einen Compiler und rufen anschließend den Linker auf, um die entstandenen Objektdateien zu einem Programm zu binden.
Falls Sie mit der GCC arbeiten, beachten Sie bitte, dass g++ sowohl Compiler als auch Linker ist. Wenn Sie nur die beiden Quelldateien ohne weitere Parameter angeben, wird nach dem Kompilieren automatisch gelinkt. Der zweite Aufruf entfällt somit.
Einige Informationen zu Compilern und Linkern finden Sie übrigens im Kapitel Compiler.
Namenskonventionen
[Bearbeiten]Übliche Dateiendungen für C++-Quelltexte sind „.cpp“ oder „.cc“. Headerdateien haben oft die Endungen „.hpp“, „.hh“ und „.h“. Letztere ist allerdings auch die gebräuchlichste Dateiendung für C-Header, weshalb zugunsten besserer Differenzierung empfohlen wird, diese nicht zu benutzen.
Die Standardheader von C++ haben überhaupt keine Dateiendung, wie Sie vielleicht schon anhand der beiden Headerdateien iostream
und string
erraten haben. In der Anfangszeit von C++ endeten Sie noch auf „.h“, inzwischen ist diese Notation aber nicht mehr gültig, obwohl sie immer noch von vielen Compilern unterstützt wird. Der Unterschied zwischen „iostream“ und „iostream.h“ besteht darin, dass in ersterer Headerdatei alle Deklarationen im Standardnamespace std
vorgenommen werden.
Prinzipiell kommt es nicht darauf an, welche Dateiendungen Sie Ihren Dateien geben, dennoch ist es sinnvoll, eine übliche Endung zu verwenden, wenn auch nur, um einer möglichen Verwechslungsgefahr vorzubeugen. Wenn Sie sich einmal für eine Dateiendung entschieden haben, ist es vorteilhaft, darin eine gewisse Konstanz zu bewahren, nicht nur vielleicht aus Gemütlichkeit und Zeiteinsparnis, sondern auch im Sinne des schnellen Wiederfindens. Einige Beispiele für Headerdateiendungen finden Sie, wenn Sie sich einfach mal einige weitverbreite C++-Bibliotheken ansehen. Boost verwendet „.hpp“, wxWidgets nutzt „.h“ und Qt orientiert sich an der Standardbibliothek, hat also gar keine Dateiendung.
Schutz vor Mehrfacheinbindung
[Bearbeiten]Da Headerdateien oft andere Headerdateien einbinden, kann es leicht passieren, dass eine Headerdatei mehrfach eingebunden wird. Da viele Header nicht nur Deklarationen, sondern auch Definitionen enthalten, führt dies zu Compiler-Fehlermeldungen, da innerhalb einer Übersetzungseinheit ein Name stets nur genau einmal definiert werden darf (mehrfache Deklarationen, die keine Definitionen sind, sind jedoch erlaubt). Um dies zu vermeiden, wird der Präprozessor verwendet. Am Anfang der Headerdatei wird ein Präprozessor-ifndef ausgeführt, das prüft, ob ein Symbol nicht definiert wurde, ist dies der Fall, wird das Symbol definiert. Am Ende der Headerdatei wird die Abfrage mit einem Präprozessor-endif wieder beendet.
Das Symbol wird üblicherweise aus dem Dateinamen des Headers abgeleitet. In diesem Fall wurde der Punkt durch einen Unterstrich ersetzt und ein weiterer Unterstrich vor und nach dem Dateinamen eingefügt. Wenn der Präprozessor nun die Anweisung bekommt, diese Datei in eine andere einzubinden, wird er das anstandslos tun. Findet er eine zweite Anweisung, die Datei einzubinden, wird er alles von #ifndef
bis #endif
überspringen, da das Symbol _mal2_hpp_
ja nun bereits definiert wurde.
Sie können sich eine beliebige Methode zum Generieren von Symbolen ausdenken. Da es schnell passieren kann, dass Sie eine Bibliothek verwenden, in der Headerdateien enthalten sind, welche Sie auch in ihrem eigenen Projekt bereits verwenden, ist es jedoch empfehlenswert an dieser Stelle nicht mit Zeichen zu geizen. Eine mögliche Variante ist beispielsweise neben dem Dateinamen auch noch den Projektnamen zu verwenden und eventuell auch noch eine feste Zeichenfolge, um das Symbol unmissverständlich als Mehrfacheinbindungsschutz zu kennzeichnen. Das könnte dann etwa so aussehen:
_Projektname_Dateiname_Dateiendung_INCLUDED_
Mehr Informationen über Präprozessor-Anweisungen finden Sie im Kapitel Vorarbeiter des Compilers.
Inline-Funktionen
[Bearbeiten]Im Kapitel „Prozeduren und Funktionen“ haben Sie bereits erfahren was eine Inline-Funktion ist. Das Schlüsselwort inline
empfiehlt dem Compiler, beim Aufruf einer Funktion den Funktionsaufruf direkt durch den Funktionsrumpf zu ersetzen, wodurch bei kurzen Funktionen die Ausführungsgeschwindigkeit gesteigert werden kann. Es bewirkt aber noch mehr. Normalerweise darf eine Definition immer nur einmal gemacht werden. Da für das Inlinen einer Funktion aber die Definition bekannt sein muss, gibt es für Inline-Funktionen eine Ausnahme: Sie dürfen beliebig oft definiert werden, solange alle Definitionen identisch sind. Deshalb dürfen (und sollten) Inline-Funktionen in den Headerdateien definiert werden, ohne dass sich der Linker später über eine mehrfache Definition in verschiedenen Objektdateien beschweren wird.
Werden Klassenmethoden bereits bei der Deklaration definiert (also mit einem Körper versehen), so werden sie automatisch als inline
verwendet, auch ohne daß der Bezeichner angegeben wird. Dies verbessert die Ausführung von einfachen Datenzugriffen erheblich. Alternativ zur Definition bei der Deklaration können die Methoden auch explizit als "inline" deklariert und an einem anderen Ort (z.B. weiter unten in der Deklarationsdatei) ausgeführt werden. Das kann die Übersichtlichkeit verbessern.
Inline-Funktionen dürfen auch in cpp-Dateien deklariert und definiert werden. Allerdings können sie dann auch nur innerhalb der Objektdatei, die aus der cpp-Datei erzeugt wird, ge-inlinet werden und das ist in aller Regel nicht beabsichtigt.
Das Klassenkonzept
[Bearbeiten]In einem späteren Abschnitt werden Sie etwas über objektorientierte Programmierung erfahren. Die notwendige Basis dafür werden wir in diesem Abschnitt erarbeiten. Sie haben inzwischen einiges über die verschiedenen Basisdatentypen gelernt und auch erfahren, wie Sie diese Daten mithilfe von Funktionen manipulieren können. Im Prinzip könnten Sie mit diesen Mitteln bereits jedes beliebige Programm schreiben. Allerdings werden Programme, die mit diesen Mitteln geschrieben wurden oft unübersichtlich, sobald eine gewisse Größe überschritten wird. Aus diesem Grund wurde ein neues Konzept erdacht, das für mehr Übersicht sorgt. Anstatt einfach Daten (Variablen) zu haben, die von Funktionen manipuliert werden, fasst man logisch zusammengehörende Daten und die darauf arbeitenden Funktionen zu einem neuen Konstrukt zusammen. Dieses Konstrukt nennt sich „Klasse“.
Eine Klasse hat üblicherweise einen Namen und besteht aus Variablen und Funktionen, welche als Klassenmember (zu deutsch etwa Mitglieder) bezeichnet werden. Ein Funktionsmember bezeichnet man auch als Methode der Klasse. Ein wesentlicher Vorteil der Verwendung von Klassen besteht darin, dass ein Nutzer der Klasse üblicherweise nicht wissen muss, wie die Klasse intern arbeitet. Andererseits kann man Klassen so gestalten, dass man von außen nur auf bestimmte klasseneigene Variablen und Funktionen zugreifen kann. Dann können Sie die interne konkrete Implementierung (abgesehen von diesen Variablen- und Funktionsnamen) der Klasse jederzeit ändern, ohne dass der Code, in dem die Klasse verwendet wird, geändert werden muss. Der Code außerhalb der Klasse muss nur dann geändert werden, wenn die Variablen- und Funktionennamen der Klasse, auf die man von außen zugreifen kann, geändert wurden. Meistens werden übrigens die Variablen einer Klasse nicht sichtbar gemacht, und man verwendet stattdessen Funktionen, um die Variablen zu verändern. Dabei kommt es durchaus häufig vor, dass eine Funktion nichts anderes tut, als eine Variable zu verändern oder sie auszulesen. Dabei spricht man auch von "Getter"- und "Setter"-Funktionen.
Eine Klasse ist ein benutzerdefinierter Datentyp. Somit können Sie also, genau wie bei einem Basisdatentyp, Variablen vom Typ der Klasse (= deren Name) erstellen. Eine Variable vom Typ einer Klasse beinhaltet alle in der Klasse deklarierten Variablen. Somit verbraucht eine Klassenvariable soviel Speicherplatz wie die Variablen, die in ihr deklariert wurden. Natürlich hat der Übersetzer hier einige Freiräume, sodass Sie nicht einfach davon ausgehen können, dass eine Klassenvariable genau soviel Speicherplatz belegt, wie die Summe ihrer Variablenmember. Wenn der Übersetzer es aus Optimierungsgründen für sinnvoll hält, etwas mehr Speicherplatz zu nutzen, um etwa die internen Variablen so im Speicher anzuordnen, dass auf sie schnell zugegriffen werden kann, darf er dies tun. Wenn Sie die genaue Größe wissen möchten, die eine Variable einer Klasse im Speicher belegt, können Sie den sizeof
-Operator auf die Klasse oder eine Variable vom Typ der Klasse anwenden.
Der Inhalt einer solchen Variable ist ein „Objekt“ der Klasse. Ebenso wie es vom Typ 'int' viele Variablen mit unterschiedlichen Werten in einem Programm geben kann, so kann es viele Objekte mit unterschiedlichen Werten vom selben Klassen-Typ in einem Programm geben.
Eine Klasse ist wie ein Bauplan für Objekte. Klassen haben zwei spezielle Methoden, die beim Erstellen bzw. Zerstören eines Objektes vom Typ der Klasse aufgerufen werden. Erstere bezeichnet man als Konstruktor und die zweite als Destruktor der Klasse.
Ähnlich wie Funktionen ihre lokalen Variablen haben können, auf die „von außen“ nicht zugegriffen werden kann, gibt es innerhalb einer Klasse verschiedene Sichtbarkeitsarten für Variablen, die darüber entscheiden, ob eine Membervariable nur innerhalb der Klasse oder auch von außerhalb verwendet werden kann. Später werden wir sehen, dass man diese Sichtbarkeitsart durch spezielle Ausdrücke wie etwa public
oder private
festlegt.
Es gibt einige allgemeine Operatoren für Klassen und Objekte. Es ist möglich, einige dieser Operatoren für eine Klasse zu überladen, sodass diese dann auf Klassenobjekte angewendet werden können.
Das waren jetzt wieder sehr viele Informationen auf wenig Raum, aber machen Sie sich keine Sorgen, wenn Sie sich nicht alles merken konnten: In den nächsten Kapiteln wird auf die verschiedenen Eigenschaften von Klassen noch näher eingegangen, sodass Sie die Gedanken dahinter nachvollziehen können.
Ein eigener Datentyp
[Bearbeiten]Nun wird es aber Zeit, dass wir auch mal eine eigene Klasse schreiben. Das folgende Beispiel soll die prinzipielle Arbeitsweise einer Klasse demonstrieren.
#include <iostream>
class Auto{
public:
Auto(int tankgroesse, float tankinhalt, float verbrauch);
void info()const;
void fahren(int km);
void tanken(float liter);
private:
int tankgroesse_;
float tankinhalt_;
float verbrauch_;
};
Auto::Auto(int tankgroesse, float tankinhalt, float verbrauch):
tankgroesse_(tankgroesse),
tankinhalt_(tankinhalt),
verbrauch_(verbrauch)
{}
void Auto::info()const{
std::cout << "In den Tank passen " << tankgroesse_ << " Liter Treibstoff.\n";
std::cout << "Aktuell sind noch " << tankinhalt_ << " Liter im Tank.\n";
std::cout << "Der Wagen verbraucht " << verbrauch_ << " Liter pro 100 km.\n";
std::cout << std::endl;
}
void Auto::fahren(int km){
std::cout << "Fahre " << km << " km.\n";
tankinhalt_ -= verbrauch_*km/100;
if(tankinhalt_ < 0.0f){
tankinhalt_ = 0.0f;
std::cout << "Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.\n";
std::cout << "Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!\n";
}
std::cout << std::endl;
}
void Auto::tanken( float liter ) {
std::cout << "Tanke " << liter << " Liter.\n";
tankinhalt_ += liter;
if( tankinhalt_ > tankgroesse_ ) {
tankinhalt_ = tankgroesse_;
std::cout << "Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.\n";
std::cout << "Sie haben aber einiges daneben gegossen!\n";
}
std::cout << std::endl;
}
…
Diese Klasse nutzt vieles, was Sie im Laufe dieses Abschnittes noch kennenlernen werden. Für den Moment sollten Sie wissen, dass diese Klasse drei verschiedene Daten beinhaltet. Diese Daten sind die drei Variablen, deren Namen auf einen Unterstrich (_
) enden. Vier Funktionen arbeiten auf diesen Daten.
Sie haben nun gesehen, wie die Klasse aufgebaut ist, und in den folgenden Kapiteln wird dieser Aufbau genauer erläutert. Jetzt sollen Sie jedoch erst einmal den Vorteil einer Klasse verstehen, denn um eine Klasse zu benutzen, müssen Sie keine Ahnung haben, wie diese Klasse intern funktioniert.
…
int main(){
Auto wagen(80, 60.0f, 5.7f);
wagen.info();
wagen.tanken(12.4f);
wagen.info();
wagen.fahren(230);
wagen.info();
wagen.fahren(12200);
wagen.info();
wagen.tanken(99.0f);
wagen.info();
}
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 60 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.
Tanke 12.4 Liter.
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 72.4 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.
Fahre 230 km.
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 59.29 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.
Fahre 12200 km.
Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.
Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 0 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.
Tanke 99 Liter.
Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.
Sie haben aber einiges daneben gegossen!
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 80 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.
In der ersten Zeile von main()
wird ein Auto
-Objekt mit dem Namen wagen
erstellt. Anschließend werden Methoden dieses Objekts aufgerufen, um die Daten darin zu verwalten. Von den Daten innerhalb des Objekts kriegen Sie beim Arbeiten mit dem Objekt überhaupt nichts mit. Lediglich die Ausgabe verrät, dass die drei Methoden untereinander über diese Daten „kommunizieren“.
Die vierte Methode (jene, die mit dem Klassennamen identisch ist) wird übrigens auch aufgerufen. Gleich in der ersten Zeile von main()
wird diese Methode genutzt, um das Objekt zu erstellen. Es handelt sich also um den Konstruktor der Klasse.
Erstellen und Zerstören
[Bearbeiten]In C++-Klassen gibt es zwei besondere Arten von Methoden: Konstruktoren und den Destruktor. Ein Konstruktor wird beim Anlegen eines Objektes ausgeführt, der Destruktor vor der „Zerstörung“ desselben. Der Name des Konstruktors ist immer gleich dem Klassennamen, der Destruktor entspricht ebenfalls dem Klassennamen, jedoch mit einer führenden Tilde (~
).
Konstruktoren und Destruktoren haben keinen Rückgabetyp, auch nicht void
. Der Konstruktor kann nicht als Methode aufgerufen werden, beim Destruktor ist dies hingegen möglich (aber nur selten nötig, dazu später mehr).
Konstruktor
[Bearbeiten]Jede Klasse hat einen oder mehrere Konstruktoren. Ein solcher Konstruktor dient zur Initialisierung eines Objektes. Im Folgenden wird eine Klasse Bruch
angelegt, die über den Konstruktor die Membervariablen zaehler_
und nenner_
initialisiert.
class Bruch{
public:
Bruch(int z, int n):
zaehler_(z), // Initialisierung von zaehler_
nenner_(n) // Initialisierung von nenner_
{}
private:
int zaehler_;
int nenner_;
};
int main(){
Bruch objekt(7, 10); // Initialisierung von objekt
}
Wie Sie sehen, ist der Methodenrumpf von Bruch::Bruch
leer. Die Initialisierung findet in den beiden Zeilen über dem Rumpf statt. Nach dem Prototyp wird ein Doppelpunkt geschrieben, darauf folgt eine Liste der Werte, die initialisiert werden sollen. Vielleicht erinnern Sie sich noch, dass es zur Initialisierung zwei syntaktische Varianten gibt:
Innerhalb der Initialisierungsliste ist nur die erste Variante zulässig. Auch in der Hauptfunktion main()
findet eine Initialisierung statt. Hier könnte die zweite Variante verwendet werden, jedoch besitzt der Konstruktor zwei Parameter, daher ist die Variante mit dem Gleichheitszeichen nicht sofort offensichtlich. Dieses Thema wird am Ende des Kapitels genauer besprochen, da zum Verständnis dessen ein wenig Hintergrundwissen nötig ist, das im Verlauf des Kapitels vermittelt wird.
Beachten Sie, dass die Initialisierung der Variablen einer Klasse in der Reihenfolge erfolgt, in der sie in der Klasse deklariert wurden. Die Initialisierungsreihenfolge ist unabhängig von der Reihenfolge, in der sie in der Initialisierungsliste angegeben wurden. Viele Compiler warnen, wenn man so etwas macht, da derartiger Code möglicherweise nicht das tut, was man erwartet.
Natürlich ist es wie bei Funktionen möglich und in der Regel zu empfehlen, die Methodendeklaration von der Methodendefinition zu trennen. Die Definition sieht genau so aus, wie bei einer normalen Funktion. Der einzige auffällige Unterschied besteht darin, dass dem Methodennamen der Klassenname vorangestellt wird, getrennt durch den Bereichsoperator (::
).
class Bruch{
public:
Bruch(int z, int n); // Deklaration
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(int z, int n): // Definition
zaehler_(z), // Initialisierung von zaehler_
nenner_(n) // Initialisierung von nenner_
{}
int main(){
Bruch objekt(7, 10); // Der Bruch 7/10 oder auch 0.7
}
Wie bereits erwähnt, hat der Konstruktor keinen Rückgabetyp, daher wird auch in der Definition keiner angegeben. Bei den Basisdatentypen ist es bezüglich der Performance übrigens egal, ob Sie diese initialisieren oder zuweisen. Sie könnten also den gleichen Effekt erzielen, wenn Sie statt der Initialisierungsliste eine Zuweisung im Funktionsrumpf benutzen. Allerdings gilt dies wirklich nur für die Basisdatentypen, bei komplexen Datentypen ist die Initialisierung oft deutlich schneller. Außerdem können konstante Variablen ausschließlich über die Initialisierungsliste einen Wert erhalten. Nun aber noch mal ein einfaches Beispiel, in dem der Funktionsrumpf des Konstruktors zur Anfangswertzuweisung dient:
class Bruch{
public:
Bruch(int z, int n); // Deklaration
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(int z, int n){ // Definition
zaehler_ = z; // Zuweisung
nenner_ = n; // Zuweisung
}
Wie Sie sehen, entfällt der Doppelpunkt, wenn Sie die Initialisierungsliste nicht nutzen. Natürlich können Sie auch Initialisierungsliste und Funktionsrumpf parallel benutzen.
Irgendwann werden Sie sicher einmal in die Verlegenheit kommen, ein Array als Membervariable innerhalb Ihrer Klasse zu deklarieren. Leider gibt es keine Möglichkeit, Arrays zu initialisieren, sie müssen immer im Konstruktorrumpf mittels Zuweisung ihren Anfangswert erhalten. Entsprechend ist es auch nicht möglich, ein Memberarray mit konstanten Daten zu erstellen. Wenn Sie also zwingend eine Initialisierung benötigen, müssen Sie wohl oder übel Einzelvariablen erstellen oder auf eine array-ähnliche Klasse zurückgreifen. std::vector
aus der Standard-Headerdatei vector
ist meist eine gute Alternative. Um Performanceeinbußen zu verhindern, müssten Sie das std::vector
-Objekt zunächst mittels der Methode reserve(n)
anweisen, für n
Elemente Speicher zu alloziieren und anschließend die Elemente mittels der Methode push_back(element)
hinzufügen. Beides passiert natürlich im Konstruktorrumpf, in der Initialisierungsliste müssen Sie keine Angaben machen.
Defaultparameter
[Bearbeiten]Sie können einem Konstruktor ebenso Defaultparameter vorgeben, wie einer gewöhnlichen Funktion. Die syntaktischen Regeln sind identisch.
class Bruch{
public:
Bruch(int z, int n = 1); // Deklaration mit Defaultwert
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(int z, int n): // Definition
zaehler_(z),
nenner_(n)
{}
Mehrere Konstruktoren
[Bearbeiten]Auch das Überladen des Konstruktors funktioniert wie das Überladen einer Funktion. Deklarieren Sie mehrere Konstruktoren innerhalb der Klasse und schreiben für jeden eine Definition:
class Bruch{
public:
Bruch(int z); // Deklaration
Bruch(int z, int n); // Deklaration
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(int z): // Definition
zaehler_(z),
nenner_(1)
{}
Bruch::Bruch(int z, int n): // Definition
zaehler_(z),
nenner_(n)
{}
Wenn mehrere Konstruktoren das gleiche tun, ist es oft sinnvoll, diese gleichen Teile in eine eigene Methode (üblicherweise mit dem Namen init()
) zu schreiben. Das ist kürzer, übersichtlicher, meist schneller und hilft auch noch bei der Vermeidung von Fehlern. Denn wenn Sie den Code nachträglich ändern, dann müssten Sie diese Änderungen für jeden Konstruktor vornehmen. Nutzen Sie eine init()
-Methode, müssen Sie den Code nur in dieser ändern.
class A{
public:
A(double x);
A(int x);
private:
void init();
int x_;
int y_;
};
A::A(double x):
x_(x) {
init();
}
A::A(int x):
x_(x) {
init();
}
void A::init(){
y_ = 7000;
}
Dieses Beispiel ist zugegebenermaßen ziemlich sinnfrei, aber das Prinzip der init()
-Funktion wird deutlich. Wenn Sie sich nun irgendwann entscheiden, y_
den Anfangswert 3333
zuzuweisen, dann müssen Sie dies nur in der init()
-Funktion ändern. Die Konstruktoren bleiben unverändert.
Standardkonstruktor
[Bearbeiten]Als Standardkonstruktor bezeichnet man einen Konstruktor, der keine Parameter erwartet. Ein Standardkonstruktor für unsere Bruch
-Klasse könnte folgendermaßen aussehen:
class Bruch{
public:
Bruch(); // Deklaration Standardkonstruktor
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(): // Definition Standardkonstruktor
zaehler_(0),
nenner_(1)
{}
Natürlich könnten wir in diesem Beispiel den Konstruktor überladen, um neben dem Standardkonstruktor auch die Möglichkeiten zur Initialisierung mit beliebigen Werten zu haben. Es bietet sich hier jedoch an, dafür Defaultparameter zu benutzen. Das folgende kleine Beispiel erlaubt die Initialisierung mit einer ganzen Zahl und mit einer gebrochenen Zahl (also zwei Ganzzahlen: Zähler und Nenner). Außerdem wird auch gleich noch der Standardkonstruktor bereitgestellt, welcher den Bruch mit 0 initialisiert, also den Zähler auf 0 und den Nenner auf 1 setzt.
class Bruch{
public:
// Erlaubt 3 verschiedene Aufrufmöglichkeiten, darunter die des Standardkonstruktors
Bruch(int z = 0, int n = 1);
private:
int zaehler_;
int nenner_;
};
Bruch::Bruch(int z, int n): // Definition des Konstruktors
zaehler_(z),
nenner_(n)
{}
Im Kapitel „Leere Klassen?“ werden Sie noch einiges mehr über den Standardkonstruktor erfahren.
Kopierkonstruktor
[Bearbeiten]Neben dem Standardkonstruktor gibt es einen weiteren speziellen Konstruktor, den Kopierkonstruktor: er erstellt ein Objekt anhand eines bereits vorhandenen Objektes. Der Parameter des Kopierkonstruktors ist also immer eine Referenz auf ein konstantes Objekt derselben Klasse. Der Kopierkonstruktor unserer Bruch
-Klasse hat folgende Deklaration:
Beachten Sie, dass ein Kopierkonstruktor immer eine Referenz auf ein Objekt (meist ein konstantes Objekt) übernimmt. Würde an dieser Stelle eine „call-by-value“-Übergabe stehen, müsste für die Objektübergabe an den Kopierkonstruktor ja selbst das Objekt zunächst (mittels Kopierkonstruktor) kopiert werden. Dies führt zu einer Endlosrekursion beim Kompilieren und ist somit verboten.
Wenn wir keinen eigenen Kopierkonstruktor schreiben, erstellt der Compiler einen für uns. Dieser implizite Kopierkonstruktor initialisiert alle Membervariablen mit den entsprechenden Werten der Membervariablen im übergebenen Objekt. Für den Moment ist diese vom Compiler erzeugte Variante ausreichend. Später werden Sie Gründe kennenlernen, die es notwendig machen, einen eigenen Kopierkonstruktor zu schreiben. Wenn Sie die Nutzung des Kopierkonstruktors verbieten wollen, dann schreiben Sie seine Deklaration in den private
-Bereich der Klassendeklaration. Das bewirkt einen Kompilierfehler, wenn eine nicht-Klassen-Funktion versucht den Kopierkonstruktor aufzurufen.
Neben dem Kopierkonstruktor erzeugt der Compiler übrigens auch noch eine Kopierzuweisung, auch zu dieser werden Sie später noch mehr erfahren. Auch diese können Sie verbieten, indem Sie sie im private
deklarieren. Wie dies geht, erfahren Sie im Kapitel zu Operatorüberladung.
Destruktor
[Bearbeiten]Im Gegensatz zum Konstruktor, gibt es beim Destruktor immer nur einen pro Klasse. Das liegt daran, dass ein Destruktor keine Parameter übergeben bekommt. Sein Aufruf erfolgt in der Regel implizit durch den Compiler bei der Zerstörung eines Objektes. Für den Anfang werden Sie mit dem Destruktor wahrscheinlich nicht viel anfangen können, da es mit den Mitteln, die Sie bis jetzt kennen, kaum nötig werden kann, dass bei der Zerstörung eines Objektes Aufräumarbeiten ausgeführt werden. Das folgende kleine Beispiel enthält einen Destruktor, der einfach gar nichts tut:
class A{
public:
~A(); // Deklaration Destruktor
};
A::~A(){ // Definition Destruktor
// Hier könnten "Aufräumarbeiten" ausgeführt werden
}
Wenn Sie den Abschnitt über Speicherverwaltung gelesen haben, werden Sie wissen, wie nützlich der Destruktor ist. Im Moment reicht es, wenn Sie mal von ihm gehört haben. Auch über den Destruktor werden Sie im Kapitel „Leere Klassen?“ weitere Informationen erhalten.
Beispiel mit Ausgabe
[Bearbeiten]Um noch einmal deutlich zu machen, an welchen Stellen Konstruktor und Destruktor aufgerufen werden, geben wir einfach innerhalb der Methoden eine Nachricht aus:
#include <iostream>
class A{
public:
A(); // Deklaration Konstruktor
~A(); // Deklaration Destruktor
void print(); // Deklaration einer Methode
};
A::A(){ // Definition Konstruktor
std::cout << "Innerhalb des Konstruktors" << std::endl;
}
A::~A(){ // Definition Destruktor
std::cout << "Innerhalb des Destruktors" << std::endl;
}
void A::print(){ // Definition der print()-Methode
std::cout << "Innerhalb der Methode print()" << std::endl;
}
int main(){
A objekt; // Anlegen des Objekts == Konstruktoraufruf
objekt.print(); // Aufruf der Methode print()
return 0;
} // Ende von main(), objekt wird zerstört == Destruktoraufruf
Innerhalb des Konstruktors
Innerhalb der Methode print()
Innerhalb des Destruktors
Explizite Konstruktoren
[Bearbeiten]Bevor wir den Begriff des expliziten Konstruktors klären, soll kurz auf die oben angesprochene zweite syntaktische Möglichkeit zum Konstruktoraufruf eingegangen werden.