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.
class Bruch{
public:
Bruch(int z = 0, int n = 1);
// ...
};
int main(){
Bruch objekt1 = Bruch(7, 10); // Bruch objekt1(7, 10);
Bruch objekt2 = Bruch(7); // Bruch objekt2(7);
Bruch objekt3 = Bruch(); // Bruch objekt3;
}
Diese Variante, einen Konstruktor aufzurufen, ist absolut gleichwertig zu der, die im Kommentar steht. Beachten Sie an dieser Stelle, dass hier keineswegs ein Kopierkonstruktor aufgerufen wird, es handelt sich tatsächlich nur um eine andere syntaktische Möglichkeit. Im folgenden Beispiel wird eine Klasse String definiert, welche über 2 Konstruktoren verfügt:
#include <iostream>
class String{
public:
String(char const* zeichenkette);
String(int anzahl, char zeichen = '*');
char feld[80];
};
String::String(char const* zeichenkette){
char* i = feld;
while(*zeichenkette != '\0'){
*i++ = *zeichenkette++;
}
*i = '\0';
}
String::String(int anzahl, char zeichen){
for(int i = 0; i < anzahl; ++i)
feld[i] = zeichen;
feld[anzahl] = '\0';
}
int main(){
String str1 = "Ein Text";
String str2 = String(5, 'Z');
String str3 = 'A';
std::cout << str1.feld << std::endl;
std::cout << str2.feld << std::endl;
std::cout << str3.feld << std::endl;
}
Ein Text
ZZZZZ
*****************************************************************
Auf die Implementierung der Konstruktoren soll an dieser Stelle nicht eingegangen werden und natürlich wäre feld
in einer echten Klasse privat. Der erste Konstruktor erzeugt ein Objekt anhand einer Zeichenkette. Der zweite Konstruktor kann mit einem oder mit zwei Parametern aufgerufen werden. Er erzeugt einen String, der aus anzahl
zeichen
besteht.
Sicher wird Ihnen auffallen, dass für die Initialisierung von str1
und str3
zwar das Gleichheitszeichen verwendet wird, dahinter jedoch kein Objekt der Klasse String folgt. Für diese syntaktische Möglichkeit muss der Compiler eine sogenannte implizite Konvertierung durchführen. Falls die Klasse über einen Konstruktor mit einem Parameter verfügt, der zu dem des übergebenen Objekts kompatibel ist, wird dieser Konstruktor verwendet. Kompatibel bedeutet, dass sich das übergebene Objekt in maximal einem Schritt in ein Objekt mit dem Typ des Parameters umwandeln lässt. Bei str3
wird ein Objekt vom Typ char const
übergeben. Der Compiler findet in der Klasse einen Konstruktor, der mit einem int
aufgerufen werden kann. Ein char
lässt sich direkt in einen int
umwandeln. Daher wählt der Compiler diesen Konstruktor aus. Analog wird für str1
ein Objekt vom Typ char const[9]
übergeben und dieses ist zu char const*
kompatibel.
Es ist im Falle von str3
jedoch sehr wahrscheinlich, dass eigentlich ein String definiert werden sollte, der aus dem einzelnen Zeichen 'A'
besteht und der Programmierer nur übersehen hat, dass die Klasse gar nicht über einen entsprechenden Konstruktor verfügt. Das Problem wäre gelöst, wenn die implizite Konvertierung nicht stattfinden würde und genau das ist bei expliziten Konstruktoren der Fall. Das Schlüsselwort für die Deklaration lautet explicit
.
#include <iostream>
class String{
public:
String(char const* zeichenkette);
explicit String(int anzahl, char zeichen = '*');
char feld[80];
};
int main(){
String str1 = "Ein Text"; // implizite Konvertierung
String str3 = 'A'; // Fehler, keine implizite Konvertierung möglich
}
In vielen Fällen ist, wie bei str1
, eine implizite Konvertierung gewünscht, da sie zur besseren Lesbarkeit des Quellcodes beitragen kann. Beachten Sie auch, dass die folgenden beiden Zeilen explizite Konstruktoraufrufe sind und entsprechend durchgeführt werden:
Die Möglichkeit, einen Konstruktor mittels impliziter Konvertierung aufzurufen, sollte immer dann genutzt werden, wenn das Objekt, das Sie definieren wollen, den übergebenen Wert repräsentiert. In unserem String-Beispiel ist dies bei str1
der Fall. Das erzeugte Objekt repräsentiert den übergebenen String. Im Falle von str2
wird hingegen ein Objekt nach den Vorgaben der übergebenen Parameter erstellt. Der Unterschied ist rein sprachlicher Natur, im ersten Fall wird eben bereits ein String (in Form eines C-Strings) übergeben. Technisch gesehen wird natürlich in beiden Fällen ein Objekt anhand der Parameter konstruiert.
Wenn Sie nun beabsichtigen, einen String zu erstellen, der aus dem Zeichen 'A'
besteht, würde das erstellte Objekt das Zeichen 'A'
repräsentieren. Wenn Sie sich infolgedessen für die Anwendung einer impliziten Konvertierung wie bei str3
entscheiden, wird der Compiler dies dank explizitem Konstruktor mit einer Fehlermeldung quittieren. Wenn Sie beabsichtigen einen String mit 'A'
vielen Zeichen zu konstruieren, dann wählen Sie die explizite Syntax wie bei str4
oder str5
. Dies wird Ihr Compiler anstandslos übersetzen.
Privat und öffentlich
[Bearbeiten]Mit den Schlüsselwörtern public
und private
wird festgelegt, von wo aus auf eine Eigenschaft oder Methode einer Klasse zugegriffen werden kann. Auf jene, die als public
deklariert werden, kann von überall aus zugegriffen werden, sie sind also öffentlich verfügbar. private
-deklarierte Eigenschaften oder Methoden lassen sich nur innerhalb der Klasse ansprechen, also nur innerhalb von Methoden derselben Klasse. Typischerweise werden Variablen private
deklariert, während Methoden public
sind. Eine Ausnahme bilden Hilfsmethoden, die gewöhnlich im private
-Bereich deklariert sind, wie etwa die init()
-Methode, die von verschiedenen Konstruktoren aufgerufen wird, um Codeverdopplung zu vermeiden.
Standardmäßig sind die Member einer Klasse private
. Geändert wird der Sichtbarkeitsbereich durch die beiden Schlüsselwörter gefolgt von einem Doppelpunkt. Alle Member, die darauffolgend deklariert werden, fallen in den neuen Sichtbarkeitsbereich.
Zwischen den Sichtbarkeitsbereichen kann somit beliebig oft gewechselt werden. Der implizit private Bereich sollte aus Gründen der Übersichtlichkeit nicht genutzt werden. In diesem Buch folgen wir der Regel, als erstes die öffentlichen Member zu deklarieren und erst danach die privaten. Die umgekehrte Variante findet jedoch ebenfalls eine gewisse Verbreitung. Entscheiden Sie sich für eine Variante und wenden Sie diese konsequent an.
In Zusammenhang mit Vererbung werden Sie noch einen weiteren Sichtbarkeitsbereich kennen lernen. Dieser ist für unsere Zwecke aber noch nicht nötig und wird daher auch erst später erläutert.
Strukturen
[Bearbeiten]Neben den Klassen gibt es in C++ auch noch die aus C stammenden „Strukturen“. Eine Struktur, auch Datenverbund genannt, fasst mehrere Variablen zu einem Typ zusammen. Im Gegensatz zu den C-Strukturen sind C++-Strukturen allerdings vollständig kompatibel zu Klassen. Sie können in einer Struktur also problemlos Methoden deklarieren oder die Zugriffsrechte über die Schlüsselworte public
, protected
(wird später erläutert) und private
festlegen.
Der einzige wirkliche Unterschied zwischen Strukturen und Klassen ist in C++, dass in Klassen implizit private
gilt, während in Strukturen implizit public
gilt. Da dieser implizite Zugriffsrechtebereich aber für gewöhnlich nicht genutzt werden sollte, ist dieser Unterschied fast unbedeutend. Das folgende kleine Beispiel zeigt die Definition einer Struktur im Vergleich zu der einer Klasse.
struct A{ | class B{
// öffentliche Member | // private Member
|
public: | public:
// öffentliche Member | // öffentliche Member
|
private: | private:
// private Member | // private Member
}; | };
Im Kapitel über Vererbung werden Sie noch sehen, dass Sie eine Klasse problemlos von einer Struktur ableiten können und umgekehrt.
Operatoren überladen
[Bearbeiten]Im Kapitel über Zeichenketten haben Sie gelernt, dass es sich bei std::string
um eine Klasse handelt. Dennoch war es Ihnen möglich, mehrere std::string
-Objekte über den +
-Operator zu verknüpfen oder einen String mittels =
bzw. +=
an einen std::string
zuzuweisen bzw. anzuhängen. Der Grund hierfür ist, dass diese Operatoren für die std::string
-Klasse überladen wurden. Das heißt, ihnen wurde in Zusammenhang mit der Klasse std::string
eine neue Bedeutung zugewiesen.
Vorweg sollten Sie auf jeden Fall eines beherzigen. Überladen Sie Operatoren nur, wenn beim Operatoraufruf intuitiv klar ist, was passiert. Ist dies nicht der Fall, dann schreiben Sie besser eine einfache Methode mit einem aussagekräftigen Namen. Der Sinn der Operatorüberladung ist die Erhöhung der Übersichtlichkeit des Quellcodes. Wenn man erst in der Dokumentation nachschlagen muss, um herauszufinden, was ein überladener Operator tut, dann haben Sie dieses Ziel verfehlt. Es muss natürlich nicht im Detail klar sein, welchen Effekt der Operator hat, aber man sollte intuitiv eine ungefähre Vorstellung haben, wenn man Code liest, in dem ein überladener Operator verwendet wurde. Beispielweise kann für eine Klasse, die eine komplexe Zahl repräsentiert, nicht sofort eindeutig klar sein, wie diese als Text ausgegeben wird, aber man sollte immer am Methodennamen oder eben am Operator erkennen, dass hier eine Ausgabe stattfindet.
Um Operatoren überladen zu können, müssen Sie zunächst einmal wissen, dass diese in C++ im Prinzip nur eine spezielle Schreibweise für Funktionen sind. Das folgende kleine Beispiel demonstriert den Aufruf der überladenen std::string
in ihrer Funktionsform:
Wie Ihnen beim Lesen des Beispiels vielleicht schon aufgefallen ist, wurde der Zuweisungsoperator als Methode von std::string
überladen. Der Verkettungsoperator ist hingegen als gewöhnliche Funktion überladen. Dieses Vorgehen ist üblich, aber nicht zwingend. Die Deklarationen der beiden Operatoren sehen folgendermaßen aus:
class std::string{
// Zuweisungsoperator als Member von std::string
std::string& operator=(std::string const& assign_string);
};
// Verkettungsoperator als globale Funktion
std::string operator+(std::string const& lhs, std::string const& rhs);
Da es sich bei std::string
um ein typedef
von einer Templateklasse handelt, sehen die Deklarationen in der C++-Standardbibliothek noch etwas anders aus, das ist für unser Beispiel aber nicht relevant. Auf die Rückgabetypen der verschiedenen Operatoren wird später in diesem Kapitel noch näher eingegangen.
Wenn Sie den Zuweisungsoperator als globale Funktion überladen wollten, müssten Sie ihm zusätzlich als ersten Parameter eine Referenz auf ein std::string
-Objekt übergeben. Würden Sie den Verkettungsoperator als Methode der Klasse deklarieren wollen, entfiele der erste Parameter. Er würde durch das Objekt ersetzt, für das der Operator aufgerufen wird. Da die Operatorfunktion nichts an dem Objekt ändert, für das sie aufgerufen wird, ist sie außerdem konstant. Allerdings hätten Sie an dieser Stelle ein Problem, sobald Sie einen std::string
zum Beispiel mit einem C-String verknüpfen wollen.
Bei einer Operatorüberladung als Methode ist der erste Operand immer ein Objekt der Klasse. Für den Zuweisungsoperator ist das in Ordnung, da ja immer etwas an einen std::string
zugewiesen werden soll. Wenn Sie also folgende Operatorüberladungen in der Klasse vornahmen, werden Sie bei der Nutzung der Klasse schnell auf Probleme stoßen.
class std::string{
// Zuweisung eines anderen std::string's
std::string& operator=(std::string const& assign_string);
// Zuweisung eines C-Strings
std::string& operator=(char const assign_string[]);
// Verkettung mit anderen std::string's
std::string operator+(std::string const& rhs)const;
// Verkettung mit C-Strings
std::string operator+(char const rhs[])const;
};
int main(){
std::string std_str_1 = "std_1";
std::string std_str_2 = "std_2";
char c_str_1[80] = "c_str";
std_str_1 = std_str_2; // Geht
std_str_1 = c_str_1; // Geht
// c_str_1 = std_str_1; // Geht nicht (soll es auch nicht!)
std_str_1 + std_str_2; // Geht
std_str_1 + c_str_1; // Geht
// c_str_1 + std_str_2; // Geht nicht (sollte es aber)
}
Die in der Klasse deklarierten Formen lassen sich problemlos nutzen. Wenn wir aber wollen, dass man auch „C-String + std::string
“ schreiben kann, müssen wir noch eine weitere Operatorüberladung global deklarieren. Da dies jedoch uneinheitlich aussieht und obendrein die Klassendeklaration unnötig aufbläht, deklariert man alle Überladungen des Verkettungsoperators global. Das Ganze sieht dann folgendermaßen aus.
std::string operator+(std::string const& lhs, std::string const& rhs);
std::string operator+(std::string const& lhs, char const rhs[]);
std::string operator+(char const lhs[], std::string const& rhs);
Definition der überladenen Operatoren
[Bearbeiten]Im Folgenden werden Sie einige beispielhafte Implementierungen für verschiedene Operatoren kennenlernen, wobei wir uns weiterhin an der std::string
-Klasse orientieren möchten. Vergegenwärtigen wir uns zunächst, welche Operatoren für diese Klasse überladen sind. Bereits erwähnt wurden der Zuweisungsoperator (=
) und der Verknüpfungsoperator (+
), infolge dessen ist auch die verknüpfende Zuweisung (+=
) überladen. Weiterhin stehen die Vergleichsoperatoren (==
, !=
, <
, <=
, >
und >=
) und ebenso wie der Ein- (>>
) und Ausgabeoperator (<<
) bereit. Außerdem steht der Zugriffsoperator in einer konstanten und einer nicht-konstanten Version zur Verfügung.
Für die folgenden Beispiele nehmen wir an, dass unser String in einem Array von char
s mit 1024 Elementen verwaltet wird. Dementsprechend werden wir als Klassenname ab sofort nicht mehr std::string
, sondern MyString
verwenden. Nachdem Sie die dynamische Speicherverwaltung kennengelernt haben, können Sie auch mal versuchen, eine eigene Stringklasse zu schreiben, welche nicht auf eine festgelegte Zeichenmenge begrenzt ist. Der Einfachheit halber werden wir im Folgenden keine Grenzprüfung für unser char
-Array vornehmen. Wenn Sie also vorhaben, Ihre Objekte mit langen Strings zu füllen, dann achten Sie darauf, dass Ihr Array hinreichend groß ist.
Als Erstes müssen wir uns überlegen, ob wir die Operatoren als Klassenmember oder extern deklarieren. Allgemein gilt für Zuweisungsoperatoren, ob nun zusammengesetzt oder nicht, dass Sie als Klassenmember deklariert sind. Vergleichs- und Verknüpfungsoperatoren werden als Klassenmember deklariert, wenn Sie ausnahmslos mit Objekten der gleichen Klasse benutzt werden, andernfalls werden Sie extern deklariert. Ein Zugriffsoperator liefert üblicherweise einen Wert aus der Klasse oder erlaubt sogar direkten Zugriff, somit ist er natürlich ein Klassenmember. Ein- und Ausgabeoperatoren sind extern, aber darauf wird später, wie schon erwähnt, noch explizit eingegangen. Im Folgenden werden einmal alle Deklarationen aufgeführt, die wir für unser Beispiel implementieren möchten, inklusive der (sehr kurz gefassten) Klassendefinition von MyString
.
#include <cstddef>
class MyString{
public:
static std::size_t const max_length = 1024;
MyString();
MyString(MyString const& string);
MyString(char const* c_string);
MyString& operator=(MyString const& string);
MyString& operator=(char const* c_string);
MyString& operator+=(MyString const& string);
MyString& operator+=(char const* c_string);
char const& operator[](std::size_t index)const;
char& operator[](std::size_t index);
std::size_t length()const{ return length_; }
private:
char data_[max_length];
std::size_t length_;
void selfcopy(MyString const& string);
void selfcopy(char const* c_string);
};
MyString operator+(MyString const& lhs, MyString const& rhs);
MyString operator+(MyString const& lhs, char const rhs[]);
MyString operator+(char const lhs[], MyString const& rhs);
bool operator==(MyString const& lhs, MyString const& rhs);
bool operator==(MyString const& lhs, char const rhs[]);
bool operator==(char const lhs[], MyString const& rhs);
bool operator!=(MyString const& lhs, MyString const& rhs);
bool operator!=(MyString const& lhs, char const rhs[]);
bool operator!=(char const lhs[], MyString const& rhs);
bool operator<(MyString const& lhs, MyString const& rhs);
bool operator<(MyString const& lhs, char const rhs[]);
bool operator<(char const lhs[], MyString const& rhs);
bool operator<=(MyString const& lhs, MyString const& rhs);
bool operator<=(MyString const& lhs, char const rhs[]);
bool operator<=(char const lhs[], MyString const& rhs);
bool operator>(MyString const& lhs, MyString const& rhs);
bool operator>(MyString const& lhs, char const rhs[]);
bool operator>(char const lhs[], MyString const& rhs);
bool operator>=(MyString const& lhs, MyString const& rhs);
bool operator>=(MyString const& lhs, char const rhs[]);
bool operator>=(char const lhs[], MyString const& rhs);
Als Erstes werden wir uns kurz Gedanken um unsere Datenstruktur machen. Die Zeichen werden, wie gesagt, in der Membervariable data_
gespeichert. length_
speichert die Anzahl der momentan in data_
gültigen Zeichen. Der Standardkonstruktor setzt einfach length_
auf 0 und erstellt somit einen leeren String. Der Kopierkonstruktor übernimmt die Stringlänge von dem an ihn übergebenen Argument und nutzt std::memcpy
, um die benötigten Arrayelemente zu kopieren. Natürlich könnte man die Arrays auch in einer for
-Schleife durchlaufen und die Elemente einzeln kopieren, aber std::memcpy
ist um einiges schneller, da hier gleich ganze Blöcke von Elementen kopiert werden. Der verbleibende Konstruktor erhält einen C-String (nullterminierter String, wird also durch ein Nullzeichen abgeschlossen) und verfährt somit fast analog, mit dem Unterschied, dass ganz am Anfang erst einmal die Stringlänge ermittelt werden muss.
#include <cstring>
MyString::MyString():length_(0){}
MyString::MyString(MyString const& string):
length_(string.length_)
{
std::memcpy(data_, string.data_, length_*sizeof(data_[0]));
}
MyString::MyString(char const* c_string){
length_ = max_length - 1;
for(std::size_t i = 0; i < max_length; ++i){
if(c_string[i] != '\0'){
continue;
}
length_ = i;
break;
}
std::memcpy(data_, c_string, length_*sizeof(data_[0]));
}
Die Funktion std::memcpy()
erwartet 3 Argumente, als Erstes die Zielspeicheradresse, auf die geschrieben werden soll, als Zweites die Quelladresse, von der gelesen werden soll und schließlich die Anzahl der Bytes, die kopiert werden sollen. Die Zeigertypen werden hierbei implizit nach void*
umgewandelt, daher ist die Funktion mit einiger Umsicht zu verwenden. Die Multiplikation von length_
mit sizeof(data_[0])
ist eigentlich nicht nötig, da ein char
immer die Größe 1 Byte hat. Es wird empfohlen in Zusammenhang mit der Funktion memcpy
dennoch immer die Datentypgröße durch Multiplikation von sizeof(Variablenname)
zu berücksichtigen, da sich auf diese Weise der Typ von Variable ändern kann, ohne dass eine Änderung des folgenden Codes nötig ist. So könnte in diesem Beispiel problemlos der Typ von char
auf wchar_t
geändert werden. Beachten Sie, dass wirklich die Variable und nicht deren Typ an sizeof
übergeben wird.
Möglicherweise wundern Sie sich ein wenig über den Schleifenaufbau im letzten Konstruktor. Den Rest der Schleife zu überspringen, sobald eine Bedingung erfüllt ist, ist eine übliche Designvariante um Quellcode übersichtlicher zu gestalten. Es vermeidet eine zusätzlich Codeeinrückung und drückt deutlich aus, dass dies eine Abbruchbedingung ist und sofort mit dem nächsten Schleifendurchlauf fortgefahren wird.
Da die Konstruktoren in diesem Beispiel das gleiche tun wie die Zuweisungsoperatoren und in den Konstruktoren weder etwas besonderes zu beachten, noch großartige Optimierungsmöglichkeiten bestehen, werden wir den Code für beide zusammenfassen. Dies geschieht in einer privaten Hilfsfunktion. Der Rückgabewert der Zuweisungsoperatoren ist immer eine Referenz auf das aktuelle Objekt, was wir durch ein return *this
realisieren.
#include <cstring>
MyString::MyString(MyString const& string){
selfcopy(string);
}
MyString::MyString(char const* c_string){
selfcopy(c_string);
}
MyString& MyString::operator=(MyString const& string){
selfcopy(string);
return *this;
}
MyString& MyString::operator=(char const* c_string){
selfcopy(c_string);
return *this;
}
void MyString::selfcopy(MyString const& string){
length_ = string.length_;
std::memcpy(data_, string.data_, length_*sizeof(data_[0]));
}
void MyString::selfcopy(char const* c_string){
length_ = max_length - 1;
for(std::size_t i = 0; i < max_length; ++i){
if(c_string[i] != '\0'){
continue;
}
length_ = i;
break;
}
std::memcpy(data_, c_string, length_*sizeof(data_[0]));
}
Beachten Sie, dass Sie nicht versuchen sollten, einen Konstruktor durch den Aufruf des Zuweisungsoperators oder umgekehrt zu implementieren. Speziell für unser Beispiel würde zwar beides funktionieren, wobei Sie die Syntax für letzteres noch nicht kennen, aber sobald die Klasse in irgendeiner Weise erweitert wird, könnte sich dadurch undefiniertes Verhalten ergeben. Die Auslagerung der gemeinsamen Arbeit erhöht außerdem die Lesbarkeit des Quellcodes und ist ein allgemein übliches Vorgehen um Codeverdopplung zu vermeiden.
Für die verknüpfende Zuweisung implementieren wir zunächst die Version, die wieder ein Objekt unserer Klasse übernimmt. Wir kopieren den Inhalt des übergebenen Strings an das derzeitige Ende (also die durch length_
gekennzeichnete Stelle) des aktuellen Strings und addieren anschließend die Länge des übergeben zu der des aktuellen Strings.
Die andere verknüpfende Zuweisung übernimmt einen C-String als Argument. Hier können und sollten wir uns die Implementierung wieder leicht machen. Wir wandeln den C-String in einen MyString
um, indem wir ein temporäres Objekt durch einen Aufruf des entsprechenden Konstruktors erstellen. Anschließend rufen wir mit diesem temporären Objekt die eben erstellte verknüpfende Zuweisung auf. Wir nutzen also den vorhandenen Code zweier anderer Methoden der Klasse und vermeiden so Codeverdopplung. Gleichzeitig hat das noch den angenehmen Nebeneffekt, dass wir weniger Schreibarbeit haben. Der Rückgabewert ist bei beiden Versionen wieder eine Referenz auf das aktuelle Objekt.
#include <cstring>
MyString& MyString::operator+=(MyString const& string){
std::memcpy(
data_ + length_, // Adresse des Datenfeldes + Anzahl vorhandener Zeichen
string.data_, // Adresse des Datenfeldes des Arguments
string.length_*sizeof(data_[0]) // Anzahl der Zeichen im Datenfeld des Arguments
);
length_ += string.length_; // Längen addieren
return *this;
}
MyString& MyString::operator+=(char const* c_string){
return *this += MyString(c_string);
}
Wie Sie sehen ist die Implementierung der zweiten Operatorvariante mit einer Zeile wirklich sehr kompakt. Da die Rückgabe der anderen verknüpfenden Zuweisung mit jener Version identisch ist, kann diese durch ein return
vor dem Aufruf einfach weitergegeben werden.
Da wir nun die verknüpfende Zuweisung zur Verfügung haben, wenden wir uns als Nächstes dem Verknüpfungsoperator an sich zu. Da bei diesem der erste Operand nicht zwingendermaßen ein Objekt von MyString
sein muss, ist dieser Operator außerhalb der Klasse deklariert. Daraus ergibt sich für uns leider auch ein Nachteil, denn wir haben in den Operatorimplementierungen keinen Zugriff auf private
- und protected
-Member der Klasse. Allerdings benötigen wir diesen letztlich auch gar nicht, denn wir werden wiederum vorhandenen Code für die Implementierung benutzen.
Der Rückgabewert dieses Operators ist das aus der Verknüpfung resultierende Objekt der Klasse MyString
. Die Strategie, die wir für die Implementierung benutzen, sieht so aus, dass wir den Verknüpfungsaufruf durch einen Aufruf der verknüpfenden Zuweisung ausdrücken. Da bei dieser aber immer das „erste Argument“ (also das Objekt, für das die Operatormethode aufgerufen wird) modifiziert wird, müssen wir zunächst eine Kopie unseres ersten Arguments erstellen. Für diese Kopie können wir dann die verknüpfende Zuweisung mit unserem zweiten Argument als Argument aufrufen. Da dieser Aufruf wiederum eine Referenz auf unsere Kopie zurück gibt, können wir diese sofort weitergeben, denn die Kopie ist nun das resultierende Objekt unserer Verknüpfung.
MyString operator+(MyString const& lhs, MyString const& rhs){
return MyString(lhs) += rhs;
}
MyString operator+(MyString const& lhs, char const rhs[]){
return MyString(lhs) += rhs;
}
MyString operator+(char const lhs[], MyString const& rhs){
return MyString(lhs) += rhs;
}
Wie Sie sehen ist die Implementierung wesentlich einfacher, als die langatmige Erklärung eben vielleicht vermuten ließ. Die Aufrufhierarchie sieht nun folgendermaßen aus:
Zugriffsoperator
[Bearbeiten]Den Zugriffsoperator gibt es in einer konstanten und einer nicht-konstanten Version. In Abhängigkeit davon, ob das konkrete Objekt für das er aufgerufen wird konstant ist oder nicht, entscheidet der Compiler, welche Version aufgerufen wird. Die beiden Versionen unterscheiden sich ausschließlich im Rückgabetyp. Die konstante Version liefert eine Referenz auf ein konstantes Zeichen, während die nicht-konstante Version eine Referenz auf ein Zeichen liefert und somit schreibenden Zugriff auf das gewählte Zeichen bietet. Wir werden uns zunächst um die konstante Version kümmern.
Der Zugriffsoperator übernimmt üblicherweise einen Index. In unserem Fall ist dies eine positive ganze Zahl, über welche Zugriff auf das entsprechende Zeichen unserer Zeichenkette gewährt wird. Wir überprüfen ob der Index innerhalb unserer Zeichenkette liegt. Da der Index positiv sein soll, brauchen wir den negativen Bereich nicht zu prüfen, lediglich wenn der Index größer ist, als der MyString
lang, müssen wir etwas unternehmen. Üblicherweise würde man in einem solchen Fall eine Exception werfen, da dies aber noch nicht behandelt wurde, werden wir stattdessen das letzte Zeichen unseres MyString
s zurückliefern. Ist der MyString
leer, so liefern wir das Zeichen an der Position 0, obgleich dieses eigentlich nicht gültig ist.
char const& MyString::operator[](std::size_t index)const{
if(length_ == 0)
return data_[0];
if(index >= length_)
return data_[length_ - 1];
return data_[index];
}
Wie bereits erwähnt, ist die Implementierung für die nicht-konstante Version identisch. Daher wäre es sinnvoll, wenn wir diese konstante Version aufrufen und anschließend den Rückgabetyp modifizieren lassen könnten. Tatsächlich ist dies möglich, da dies jedoch ein wenig Wissen über die verschiedenen Möglichkeiten zur Typumwandlung in C++ erfordert, wird die Technik hier lediglich verwendet und nicht erläutert. Wenn Sie mehr darüber erfahren möchten, lesen Sie das Kapitel „Casts“.
char& MyString::operator[](std::size_t index){
return const_cast< char& >(
static_cast< MyString const& >(*this)[index]
);
}
Vergleichsoperatoren
[Bearbeiten]Die 6 Vergleichsoperatoren sollen MyString
-Objekte untereinander, sowie in Kombination mit C-Strings vergleichen. Somit werden Sie außerhalb der Klasse deklariert, da der erste Parameter eines Vergleichs ein C-String sein kann. Es sind insgesamt 18 Überladungen (6 Operatoren, mit je 3 Prototypen) erforderlich um alle möglichen Vergleiche abzudecken. Wie eben schon gezeigt, werden wir auch in diesem Fall Codeverdopplung soweit es geht vermeiden, indem sich die Operatoren gegenseitig aufrufen. Alle Vergleichsoperatoren liefern einen logischen Wert (true
oder false
) zurück.
Als Erstes beschäftigen wir uns mit dem Gleichheitsoperator ==
. Zwei MyString
s sollen gleich sein, wenn Sie die gleiche Länge haben und alle enthaltenen Zeichen gleich sind. Die Implementierung überprüft entsprechend diese beiden Bedingungen, wobei ein Vergleich der einzelnen Zeichen natürlich nur nötig (und möglich) ist, wenn die Länge übereinstimmt. Dementsprechend ist die Überprüfung dieser Bedingung natürlich auch der erste Schritt.
Die Versionen die einen C-String übernehmen, erstellen aus dem C-String ein temporäres MyString
-Objekt und rufen anschließend die Überladung auf, die zwei MyString
-Objekte übernimmt. Nur für diese eine Version werden wir den vergleichenden Code schreiben.
bool operator==(MyString const& lhs, MyString const& rhs){
if(lhs.length() != rhs.length()){
return false;
}
for(std::size_t i = 0; i < lhs.length(); ++i){
if(lhs[i] != rhs[i]){
return false;
}
}
return true;
}
bool operator==(MyString const& lhs, char const rhs[]){
return lhs == MyString(rhs);
}
bool operator==(char const lhs[], MyString const& rhs){
return MyString(lhs) == rhs;
}
Der Ungleichheitsoperator !=
liefert als Umkehrfunktion immer den genau den gegenteiligen Wert des Gleichheitsoperators. Entsprechend werden wir einfach immer den Gleichheitsoperator aufrufen und das Ergebnis negieren.
bool operator!=(MyString const& lhs, MyString const& rhs){
return !(lhs == rhs);
}
bool operator!=(MyString const& lhs, char const rhs[]){
return !(lhs == rhs);
}
bool operator!=(char const lhs[], MyString const& rhs){
return !(lhs == rhs);
}
Als Nächstes müssen wir uns überlegen, wann ein MyString
kleiner als ein anderer sein soll. Die Länge ist dafür zunächst einmal uninteressant, wir schauen uns stattdessen die einzelnen Zeichen an. Wenn das erste Zeichen des ersten MyString
s kleiner als das erste Zeichen des zweiten MyString
s ist, so ist das gesamte erste MyString
-Objekt kleiner als das Zweite. Ist das erste Zeichen größer, so ist entsprechend das gesamte erste MyString
-Objekt größer. Sind beide Zeichen gleichgroß, so sehen wir uns entsprechend das jeweils zweite Zeichen der beiden Objekte an und vollführen den gleichen Vergleich. Dies setzen wir solange fort, bis in einem der beiden MyString
s keine Zeichen mehr zur Verfügung stehen. Sind bis zu dieser Position alle Zeichen gleich, so ist der kürzere String der kleinere. Sind beide MyString
s gleich lang, so sind sie insgesamt gleich.
Ist der erste MyString
nicht kleiner, so ist er entsprechend größer oder gleich dem zweiten MyString
. Die Umkehroperation zum Kleiner-Operator <
ist dementsprechend der Größer-Gleich-Operator >=
. Äquivalent dazu sind auch der Größer-Operator >
und der Kleiner-Gleich-Operator <=
entgegengesetzte Funktionen.
bool operator<(MyString const& lhs, MyString const& rhs){
std::size_t min_length = std::min(lhs.length(), rhs.length());
for(std::size_t i = 0; i < min_length; ++i){
if(lhs[i] < rhs[i]){
return true;
}
if(lhs[i] > rhs[i]){
return false;
}
}
if(lhs.length() >= rhs.length()){
return false;
}
return true;
}
bool operator<(MyString const& lhs, char const rhs[]){
return lhs < MyString(rhs);
}
bool operator<(char const lhs[], MyString const& rhs){
return MyString(lhs) < rhs;
}
bool operator>=(MyString const& lhs, MyString const& rhs){
return !(lhs < rhs);
}
bool operator>=(MyString const& lhs, char const rhs[]){
return !(lhs < rhs);
}
bool operator>=(char const lhs[], MyString const& rhs){
return !(lhs < rhs);
}
bool operator<=(MyString const& lhs, MyString const& rhs){
return !(lhs > rhs);
}
bool operator<=(MyString const& lhs, char const rhs[]){
return !(lhs > rhs);
}
bool operator<=(char const lhs[], MyString const& rhs){
return !(lhs > rhs);
}
Was jetzt noch fehlt, ist die Implementierung für den Größer-Operator, auf den wir eben bereits in der Implementierung für den Kleiner-Gleich-Operator zurückgegriffen haben. Es gibt für die Implementierung zwei Möglichkeiten, zum einen könnten wir ihn als Kombination aus den anderen Operatoren implementieren, zum anderen könnten wir aber auch eine Implementierung schreiben, welche dem Kleiner-Operator ähnlich ist. Ersteres vermeidet Codeverdopplung, letzteres ist dafür deutlich performanter.
#include <algorithm> // Für std::min()
// Performante Implementierung
bool operator>(MyString const& lhs, MyString const& rhs){
std::size_t min_length = std::min(lhs.length(), rhs.length());
for(std::size_t i = 0; i < min_length; ++i){
if(lhs[i] > rhs[i]){
return true;
}
if(lhs[i] < rhs[i]){
return false;
}
}
if(lhs.length() <= rhs.length()){
return false;
}
return true;
}
// Codeverdopplung vermeidenede Implementierung
bool operator>(MyString const& lhs, MyString const& rhs){
return !(lhs < rhs || lhs == rhs);
}
Die Implementierungen für die C-String-Varianten rufen in jedem Fall wieder die eben implementierte auf, um so Codeverdopplung zu vermeiden.
bool operator>(MyString const& lhs, char const rhs[]){
return lhs > MyString(rhs);
}
bool operator>(char const lhs[], MyString const& rhs){
return MyString(lhs) > rhs;
}
Ein-/Ausgabeoperator
[Bearbeiten]Für den Ein- bzw. Ausgabeoperator müssen wir uns zunächst darüber im Klaren sein, wie die Ein- und Ausgabe in C++ typischerweise funktioniert. Alle Ein- und Ausgaben werden über Streams vorgenommen, für die Standardein-/ausgabe gibt es die Objekte std::cin
(Klasse std::istream
), std::cout
, std::cerr
und std::clog
(alle Klasse std::ostream
). Wie genau diese Objekte die Ein-/Ausgabe letztlich verarbeiten, muss uns an dieser Stelle nicht kümmern. Objekte der Klassen std::ifstream
bzw. std::ofstream
kümmern sich um die Ein-/Ausgabe bezüglich Dateien und mit std::istringstream
und std::ostringstream
stehen uns zwei Klassen für temporäre Speicherbereiche zur Verfügung. Die 4 letztgenannten Klassen sind von std::istream
oder std::ostream
abgeleitet, daher müssen wir uns nicht explizit um sie kümmern.
Als Nächstes muss uns klar sein, wie die entsprechenden Prototypen für die Operatoren aussehen müssen. Was wir erreichen wollen, ist eine Ein-/Ausgabe mit folgender Syntax:
MyString string_1, string_2;
std::cin >> string_1 >> string_2; // Eingabe zweier MyStrings
std::cout << string_1 << string_2 << std::endl; // Ausgabe zweier MyStrings + Zeilenumbruch
Es werden gleich 2 Objekte gelesen bzw. geschrieben, um auch eine sinnvolle Aussage über den Rückgabedatentyp treffen zu können. Wenn wir Klammern für die beiden Ausdrücke setzen, wird offensichtlich, welche Typen die binären Operatoren als Parameter erhalten müssen.
MyString string_1, string_2;
((std::cin >> string_1) >> string_2);
(((std::cout << string_1) << string_2) << std::endl);
Wie in der Mathematik wird die innerste Klammer zuerst aufgelöst. Der operator>>
erhält als Parametertypen also einen std::istream
und einen MyString
. Da wir etwas von std::istream
lesen wollen und es dabei aus dem Eingabepuffer entfernt werden muss, damit es später nicht erneut eingelesen wird, müssen wir eine Referenz auf std::istream
übergeben. MyString
muss bei der Eingabe ebenfalls verändert werden, somit ist auch das MyString
-Objekt als Referenz zu übergeben. Wenn wir uns nun der zweiten Klammer zuwenden, kennen wir bereits die beiden Parametertypen. Da der erste Parameter die Rückgabe der ersten Klammer ist, folgt logisch, dass unser Ausgabeoperator als Rückgabetyp den Typ des ersten Parameters (Referenz auf std::istream
) haben muss.
Für die Ausgabe gilt ähnliches, allerdings soll MyString
hierbei nicht verändert werden. Wir können somit wahlweise eine Kopie oder eine Referenz auf const MyString
übergeben. Da Letzteres effizienter ist, wählen wir dies. In unserem Beispiel oben gibt es für die Ausgabe noch eine dritte Klammer. Diese erhält als zweiten Parameter kein MyString
-Objekt sondern std::endl
. Folglich muss es auch mit diesen Prototypen eine Überladung geben, sie befindet sich in der Headerdatei ostream
welche von iostream
eingebunden wird.
Allgemein lässt sich somit also für die Ein-/Ausgabe sagen, der erste Parameter ist ebenso wie der Rückgabetyp immer eine Referenz auf std::istream
bzw. std::ostream
und der zweite Parameter hat den Typ, für den die Operatoren überladen werden sollen. Für die Eingabe wird als zweiter Parameter eine Referenz übergeben, für die Ausgabe eine Kopie oder typischerweise eine Referenz auf const
. In unserem konkreten Fall sehen die Prototypen somit so aus:
#include <iostream> // Am Beginn der Headerdatei einbinden
std::istream& operator>>(std::istream& is, MyString& rhs);
std::ostream& operator<<(std::ostream& os, MyString const& rhs);
Wenn Sie die Deklarationen in einer Headerdatei und die Funktionsdefinitionen in einer Quelldatei machen, so genügt es im Header die Datei iosfwd
einzubinden und erst in der Quelldatei iostream
. Erstere enthält nur die (Forward-)Deklarationen von std::istream
und std::ostream
, was die Compilierzeit beim Einbinden ihrer Headerdatei verringert.
Für die konkrete Ein-/Ausgabe nutzen wir nun die Member unserer MyString
-Klasse. Da die Ausgabe sich etwas einfacher gestaltet, wird sie zuerst erläutert. Die Methode length()
liefert uns die Anzahl der gültigen Zeichen in unserem String und operator[]
liefert uns per Index ein Zeichen vom Datentyp char
. Für diesen ist ein Ausgabeoperator deklariert, also geben wir einfach in einer Zählschleife length()
viele Zeichen aus. Am Ende geben wir noch unseren std::ostream
zurück.
std::ostream& operator<<(std::ostream& os, MyString const& rhs){
for(std::size_t i = 0; i < rhs.length(); ++i){
os << rhs[i];
}
return os;
}
Für die Eingabe haben wir keine Information wie lang der zu lesende String werden soll. Der std::string
verhält sich so, dass bis zum nächsten Leerraumzeichen eingelesen wird. Wir werden immer bis zum nächsten Zeilenumbruch lesen, maximal jedoch MyString::max_length - 1
viele Zeichen. Praktischerweise bietet uns std::istream
mit der Methode getline()
genau das, was wir brauchen. getline()
bekommt als ersten Parameter einen Zeiger auf char
, was als Zeiger auf die Anfangsadresse eines Arrays von char
s zu verstehen ist. Als zweiter Parameter wird die Größe des Arrays übergeben. Die Funktion getline()
setzt das letzte geschriebene Zeichen dabei immer auf '\0'
. Falls im Eingabestream also MyString::max_length
oder mehr Zeichen enthalten sind, werden nur MyString::max_length - 1
dieser Zeichen in den Puffer geschrieben und das letzte Zeichen wird auf '\0'
gesetzt. Die Methode gcount()
liefert uns im Anschluss die Information, wie viele Zeichen nun wirklich aus dem Eingabestream gelesen wurden. Danach geben wir eine Referenz auf den Eingabestream zurück.
std::istream& operator>>(std::istream& is, MyString& rhs){
is.getline(rhs.data_, MyString::max_length);
rhs.length_ = is.gcount();
return is;
}
Wie Ihnen zweifellos auffällt, wird hier auf private Klassenmember zugegriffen. Dies ist für Ein- und Ausgabeoperatoren oft sinnvoll, aber nur möglich wenn sie zuvor auch innerhalb der Klasse als friend
deklariert wurden. Ergänzen Sie daher die Klassendefinition folgendermaßen:
Dass die Zeile nicht eingerückt wurde, soll andeuten, dass es für eine friend
-Deklaration unerheblich ist, in welchem Sichtbarkeitsbereich sie stattfindet.
Bei den Ein-/Ausgabeoperatoren handelt es sich eigentlich um den Rechtsshift-operator>>
und den Linksshift-operator
. Eine solche Entfremdung bezüglich der Funktionalität eines Operators sollte eigentlich nicht stattfinden. Wenn es allerdings konsequent umgesetzt wird, wie es beim Ein-/Ausgabesystem von C++ der Fall ist, kann es nach einer kurzen Einlernphase für den Nutzer (Sie als Programmierer) die Übersichtlichkeit erheblich steigern.
Ein weiteres gutes Beispiel für eine Bibliothek die Operatoren sinnvollerweise zweckentfremdet ist Boost::Spirit. Mit den Boost Bibliotheken sollten Sie sich übrigens früher oder später vertraut machen. Spirit ist beispielsweise speziell für die Ein-/Ausgabe extrem nützlich. Sie finden die Boost-Bibliotheken unter boost.org.
Cast-Operatoren
[Bearbeiten]Wie Ihnen bereits bekannt ist, kann ein Datentyp in einen anderen konvertiert werden, wenn letzter einen Konstruktor bereitstellt der ein Objekt des ersten übernimmt. Oft kommt es jedoch vor, dass Sie auf den Datentyp, in den umgewandelt werden soll, keinen Zugriff haben, sprich keinen zusätzlichen Konstruktor einführen können. Bei den elementaren Datentypen ist dies beispielsweise grundsätzlich der Fall.
Aus diesem Grund gibt es die Cast-Operatoren. Wenn Sie nun, etwa mittels static_cast
, eine Umwandlung vornehmen, wird entweder der passende Konstruktor aufgerufen oder ein passender Cast-Operator, wobei nur eines von beiden deklariert sein darf, weil sich der Compiler sonst über Mehrdeutigkeit beschwert. Ein Cast-Operator wird immer in der Klasse deklariert, die man umwandeln möchte. Die Syntax lautet wie folgt:
Auffällig ist, dass für diese „Funktionen“ kein Rückgabetyp angegeben wird. Diese wurde (analog zum Konstruktor) weggelassen, da der Rückgabetyp offensichtlich ist. Ein Konstruktor liefert ein Objekt seiner Klasse und die Typumwandungsoperator gibt natürlich ein Objekt des Zieldatentyps zurück.
Im folgenden möchten wir unserer MyString
-Klasse noch einen Cast-Operator nach double
spendieren. Die Deklaration sieht folgendermaßen aus:
Wie Sie sehen, wurde der Operator als konstanter Member deklariert. Dies ist für die meisten Typumwandlungsoperatoren sinnvoll, denn typischerweise wollen wir das Originalobjekt ja nicht verändern. Analog dazu werden einem Konstruktor typischerweise Referenzen auf konstante Objekte übergeben. Die Definition kann beispielsweise folgendermaßen aussehen:
#include <sstream>
MyString::operator double()const{
std::stringstream stream;
stream << *this; // Text temporär ausgeben
double result;
stream >> result; // Als Zahl wieder einlesen
return result;
}
Nun können Sie MyString
-Objekt implizit und explizit in double
s umwandeln.
In diesem Fall wäre die explizite Typumwandlung nicht zwingend nötig gewesen, der Compiler hätte auch implizit (ohne static_cast< double >
) das richtige getan, es gibt jedoch reichlich Fälle in denen eine explizite Umwandlung nötig ist.
Spaß mit „falschen“ Überladungen
[Bearbeiten]Mit der Operatorüberladung lässt sich auch einiger Unsinn anstellen. Die folgende kleine Klasse verhält sich wie ein normaler int
, allerdings rechnet der Plusoperator minus, und umgekehrt. Gleiches gilt für die Punktoperationen. Natürlich sollten Sie solch unintuitives Verhalten in ernst gemeinten Klassen unbedingt vermeiden, aber an dieser Stelle demonstriert es noch einmal schön die Syntax zur Überladung.
// Zum Überladen von Ein-/Ausgabe
#include <iostream>
class Int{
public:
// Konstruktor (durch Standardparameter gleichzeitig Standardkonstruktor)
Int(int value = 0): m_value(value) {}
// Überladene kombinierte Rechen-/Zuweisungsoperatoren
Int const& operator+=(Int const& rhs) { m_value -= rhs.m_value; return *this; }
Int const& operator-=(Int const& rhs) { m_value += rhs.m_value; return *this; }
Int const& operator*=(Int const& rhs) { m_value /= rhs.m_value; return *this; }
Int const& operator/=(Int const& rhs) { m_value *= rhs.m_value; return *this; }
private:
int m_value;
// Friend-Deklarationen für Ein-/Ausgabe
friend std::istream& operator>>(std::istream& is, Int& rhs);
friend std::ostream& operator<<(std::ostream& os, Int const& rhs);
};
// Überladene Rechenoperatoren rufen kombinierte Versionen auf
Int operator+(Int const& lhs, Int const& rhs) { return Int(lhs) += rhs; }
Int operator-(Int const& lhs, Int const& rhs) { return Int(lhs) -= rhs; }
Int operator*(Int const& lhs, Int const& rhs) { return Int(lhs) *= rhs; }
Int operator/(Int const& lhs, Int const& rhs) { return Int(lhs) /= rhs; }
// Definition Ein-/Ausgabeoperator
std::istream& operator>>(std::istream& is, Int& rhs) { return is >> rhs.m_value; }
std::ostream& operator<<(std::ostream& os, Int const& rhs) { return os << rhs.m_value; }
Präfix und Postfix
[Bearbeiten]Für einige Klassen kann es sinnvoll sein, den Increment- und den Decrement-Operator zu überladen. Da es sich um unäre Operatoren handelt, sie also nur einen Parameter haben, werden sie als Klassenmember überladen. Entsprechend übernehmen sie überhaupt keine Parameter mehr, da dieser ja durch das Klassenobjekt, für das sie aufgerufen werden ersetzt wird. Als Beispiel nutzen wir noch einmal unsere Int-Klasse, diesmal aber ohne die Operatoren mit der falschen Funktionalität zu überladen.
class Int{
public:
// ...
Int& operator++() { ++m_value; return *this; }
Int& operator--() { --m_value; return *this; }
// ...
};
// ...
int main(){
Int value(5);
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5
}
So weit, so gut, aber wir können nicht value++
oder value--
schreiben. Diese Variante überlädt also nur die Präfix-Version der Operatoren, aber wie schreibt man nun die Postfix-Überladung? Wie Sie wissen, kann eine Funktion nur anhand ihrer Parameter oder, im Fall einer Methode, auch ihres const
-Qualifizieres überladen werden. Aber weder die Präfix- noch die Postfixvariante übernehmen einen Parameter und auch der const
-Qualifizier ist in keinem Fall gegeben, weil die Operatoren ja immer das Objekt verändern.
Aus diesem Grund hat man sich eine Kompromisslösung ausdenken müssen. Die Postfix-Operatoren übernehmen einen int
, der jedoch innerhalb der Funktionsdefinition nicht verwendet wird. Er dient einzig, um dem Compiler mitzuteilen, dass es sich um eine Postfix-Operatorüberladung handelt.
class Int{
public:
// ...
// Präfix (also ++Variable)
Int& operator++() { ++m_value; return *this; }
Int& operator--() { --m_value; return *this; }
// Postfix (also Variable++)
Int const operator++(int) { Int tmp = *this; ++m_value; return tmp; }
Int const operator--(int) { Int tmp = *this; --m_value; return tmp; }
// ...
};
// ...
int main(){
Int value(5);
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << value++ << std::endl; // value ist 6, Ausgabe ist 5
std::cout << value-- << std::endl; // value ist 5, Ausgabe ist 6
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
}
Im folgenden wird auf den Inkrementoperator (++) Bezug genommen, für den Dekrementoperator (--) gilt natürlich das Gleiche, mit dem Unterschied, dass die Werte erniedrigt werden.
In diesem Beispiel sehen Sie auch schön den Unterschied zwischen Präfix- und Postfix-Variante. Bei der Präfix-Variante wird der Wert um 1 erhöht und anschließend zurückgegeben. Der Postfix-Operator erhöht den Wert zwar um 1, gibt anschließend aber den Wert zurück, den das Objekt vor der Erhöhung hatte. Entsprechend sind auch die Rückgabewerte der Operatoren. Bei Präfix wird eine Referenz auf sich selbst zurückgeliefert, bei Postfix hingeben ein konstantes temporäres Objekt, das den alten Wert des Objektes enthält, für das der Operator aufgerufen wurde. Die Implementierung ist entsprechend. Die Präfix-Version erhöht den Wert und liefert sich dann selbst zurück, während die Postfix-Variante zunächst eine Kopie der aktuellen Objektes erstellt, dann den Wert erhöht und schließlich den zwischengespeicherten, alten Wert zurückgibt.
Wie früher schon einmal erwähnt, ist es effizienter den Präfix-Operator zu benutzen, wenn prinzipiell beide Varianten möglich wären. Bei den Basisdatentypen, kann der Compiler einen Performance-Nachteil wegoptimieren. Bei Klassen für die diese Operatoren überladen wurden, kann sich der Compiler jedoch nicht darauf verlassen, dass die Präfix-Variante das gleiche macht, wie die Postfix-Variante. Daher ist er bei Klassen nicht oder zumindest nur sehr eingeschränkt in der Lage an dieser Stelle zu optimieren. Aus Gründen der Einheitlichkeit sollte daher, auch bei Basisdatentypen, die Präfix-Variante bevorzugt werden.
Der Rückgabewert der Postfix-Variante ist übrigens aus gutem Grund konstant. Den Präfix-Operator können Sie beliebig oft hintereinander für das gleiche Objekt aufrufen. Auch wenn in den meisten Fällen der Aufruf der Plus-Zuweisung (+=) möglich und effizienter ist. Für die Postfix-Variante ist ein mehrfacher Aufruf nicht sinnvoll, wie im folgenden demonstriert.
Int i = 5;
++++++i; // i ist 8
// Effizienter wäre "i += 3;"
i++++++; // i ist 9
// Lässt sich nur übersetzen, wenn Rückgabewert nicht const
Beim mehrfachen Aufruf der Postfix-Variante werden bis auf den ersten, alle Aufrufe für temporäre Objekte aufgerufen. Hinzu kommt noch, dass der Rückgabewert ja immer der alte Wert ist. Somit ist die Operation für jeden Folgeaufruf die Gleiche, während der berechnete Wert, als temporäres Objekt, sogleich verworfen wird. Ist der Rückgabetyp konstant, kann kein Nicht-Konstanter-Operator für das Objekt aufgerufen werden und eine solch unsinnige Postfix-Operatorkette resultiert in einem Compilierfehler.
Klassen als Datenelemente einer Klasse
[Bearbeiten]Analog zu Basistyp-Variablen, können Sie auch Klassenobjekte als Member einer Klasse deklarieren. Dies funktioniert genau so, wie Sie es schon kennen.
Nun enthält die Klasse B
ein Objekt der Klasse A
.
Verweise untereinander
[Bearbeiten]Manchmal ist es nötig, dass zwei Klassen einen Verweis auf die jeweils andere enthalten. In diesem Fall muss die Klassendeklaration von der Klassendefinition getrennt werden. Wie auch bei Funktionen gilt, dass jede Definition immer auch eine Deklaration ist und dass Deklarationen beliebig oft gemacht werden können, während Definition nur einmal erfolgen dürfen.
Um ein Klassenobjekt zu deklarieren, muss die gesamte Klassendefinition bekannt sein. Wenn Sie hingegen nur einen Zeiger oder eine Referenz auf ein Klassenobjekt deklarieren möchten, genügt es, wenn die Klasse deklariert wurde.
class A; // Deklaration von A
class B{
// B enthält eine Referenz auf ein A-Objekt
A& a_objekt;
};
class A{
// A enthält ein B-Objekt
B b_objekt;
};
Falls Ihnen jetzt die Frage im Hinterkopf herumschwirrt, wieso B nur eine Referenz und kein Objekt von A enthält: Die Größe eines Objektes der Klasse A wäre dann
[Größe von B]+[Größe von restlichen Membern]
Sagen wir mal, die restlichen Member sind 1 groß: Die Größe von B wäre:
[Größe von A] (B enthält keine weiteren Member)
Wenn Sie jetzt ein wenig herumrechnen, werden Sie feststellen, dass A und B unendlich groß wären, da sie sich selbst unendlich oft enthalten. Dasselbe Problem tritt auf, wenn eine Klasse direkt ein Objekt von sich selbst enthält.
Rechnen mit Brüchen
[Bearbeiten]Das Rechnen mit Ganzzahlen und Gleitkommazahlen haben Sie inzwischen gelernt. In diesem Kapitel kehren wir das Prinzip mal um. Jetzt werden Sie Ihrem Rechner beibringen mit Brüchen zu rechnen! Davon ausgehend, dass Sie wissen was Brüche sind, sollte das kein allzu großes Problem darstellen. Die Klasse, die am Ende dieses Abschnittes entstanden ist, wird noch nicht perfekt sein, aber sie wird zum normalen Rechnen ausreichen. Sie dürfen sie natürlich jederzeit weiterentwickeln und verbessern.
Was diese Klasse bieten soll
[Bearbeiten]Bevor wir nun Hals über Kopf anfangen, Quellcode zu schreiben, sollten wir uns erst klar machen, was genau wir überhaupt erreichen wollen.
Brüche bestehen aus einem Zähler und einem Nenner. Diese sind Ganzzahlen, allerdings ist der Nenner eine natürliche Zahl (ohne Vorzeichen) und der Zähler eine ganze Zahl (mit Vorzeichen). Daher werden wir den entsprechenden Variablen passende Datentypen zuordnen.
Da es beim Rechnen leichter ist, die Brüche gleich in ihrer kleinsten (nicht mehr zu kürzenden) Form zu sehen, werden wir den Bruch nach jeder Rechenoperation kürzen. Weiterhin wollen wir mit den Brüchen rechnen können wie mit den eingebauten Datentypen für Zahlen. Natürlich brauchen wir eine Ein- und Ausgabe für unsere Brüche. Schließlich und endlich wollen wir sie beim Rechnen mit den eingebauten Datentypen mischen.
Was diese Klasse nicht kann
[Bearbeiten]Die logische Folge des automatischen Kürzens ist natürlich, dass sich die Brüche nicht mit beliebigen Zahlen erweitern und kürzen lassen. Es ist natürlich möglich, beides gleichzeitig zu realisieren, aber es ist nicht sinnvoll, also machen wir das auch nicht. Eine Umwandlung von Gleitkommazahlen in gebrochene Zahlen ist ebenfalls nicht vorgesehen.
Ein erster Blick
[Bearbeiten]Gleich werden Sie zum ersten Mal einen Blick auf die Bruch-Klasse werfen, also machen Sie sich bereit:
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1);
private:
int zaehler_;
unsigned int nenner_;
};
Das war nicht besonders aufregend, die Klasse hat bisher nur einen Konstruktor und die Variablen zaehler_
und nenner_
. Beachten Sie aber, dass zaehler_
vom Datentyp int
(vorzeichenbehaftet) ist, während nenner_
den Datentyp unsigned int
(vorzeichenlos) hat.
Auf die Standardparameter des Konstruktors werden wir in einem späteren Kapitel noch einmal zu sprechen kommen. Für den Moment soll es reichen, seine Deklaration zu zeigen.
Die Methoden
[Bearbeiten]In diesem Kapitel werden Sie mit den Methoden bekannt gemacht, die unsere Bruch-Klasse zur Verfügung stellt. Grundlegend tauchen im Zusammenhang mit Brüchen zwei Begriffe auf: „Erweitern“ und „Kürzen“. Im Zusammenhang mit diesen beiden finden sich wiederum die Begriffe „kleinstes gemeinsames Vielfaches“ und „größter gemeinsamer Teiler“. Wir haben uns bei den Vorüberlegungen dafür entschieden, das Erweitern wegzulassen, bei der Addition und Subtraktion brauchen wir es aber. Im folgenden werden das „kleinste gemeinsame Vielfache“ und der „größte gemeinsame Teiler“ mit kgV und ggT abgekürzt.
Wir haben also die folgenden Methoden:
- ggT
- kgV
- kuerzen
Dazu kommen noch zwei Zugriffsfunktionen, da sich der Benutzer unserer Klasse vielleicht für Zähler und Nenner des Bruchs interessiert:
- zaehler
- nenner
Zwei dieser fünf Methoden müssen aber gleich wieder gestrichen werden. ggT und kgV sind zwar in Zusammenhang mit Brüchen hilfreich, aber sie beschreiben allgemeine mathematische Funktionen, die nicht ausschließlich in Zusammenhang mit Brüchen eingesetzt werden. Daher sind diese beiden Funktionen unter der Überschrift Methoden etwas fehlpositioniert, da sie keine Member der Klasse Bruch sind.
Zugriff
[Bearbeiten]Die beiden Zugriffsmethoden tun nichts weiter, als Zähler und Nenner des Bruchs zurückzugeben, daher schreiben wir sie direkt in die Klassendeklaration.
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1);
int zaehler()const {return zaehler_;}
unsigned int nenner()const {return nenner_;}
private:
int zaehler_;
unsigned int nenner_;
};
Wie Sie sehen, haben die beiden Methoden den gleichen Rückgabetyp wie die Variablen, die sie repräsentieren. Achten Sie bitte auch darauf, dass beide als const
deklariert sind, da sie keine Variable innerhalb der Klasse verändern.
ggT()
[Bearbeiten]In der Schule haben Sie sicher gelernt, dass sich der ggT (größte gemeinsame Teiler) durch Primfaktorzerlegung ermitteln lässt. Dieses Verfahren ist allerdings denkbar ungünstig, um es auf einem Rechner umzusetzen. Sie müssten zunächst einmal die Primzahlen berechnen und das dauert, zumal Sie ja gar nicht wissen, wie viele Primzahlen Sie überhaupt benötigen.
Deshalb bedienen wir uns eines anderen Verfahrens, des euklidischen Algorithmus. Eine Verbesserung dieses Verfahrens ist der steinsche Algorithmus, aber für unsere Zwecke reicht ersterer. Sie dürfen die Klasse natürlich gerne dahingehend verbessern, dass Sie den steinschen Algorithmus einsetzen.
Der euklidische Algorithmus beruht auf zwei Eigenschaften des größten gemeinsamen Teilers:
Wenn Sie eine genaue Beschreibung wünschen, dann schauen Sie sich doch mal den entsprechenden Wikipediaartikel an.
Beachten Sie, dass diese Funktion rekursiv funktioniert. Die Implementierung als einfache Schleife wäre ebenfalls möglich gewesen, aber sie ist etwas unübersichtlicher und dank Optimierung ist die rekursive Variante nur unwesentlich langsamer. Wenn Sie nicht mehr wissen, was rekursive Funktionen sind, dann werfen Sie noch mal einen Blick auf das Kapitel Rekursion im Abschnitt „Weitere Grundelemente“.
kgV()
[Bearbeiten]Das kgV lässt sich ganz einfach berechnen, wenn Sie den ggT kennen. Daher schreiben wir die Funktion mit Hilfe der eben erstellten ggT-Funktion.
Damit kgV()
auf ggT()
zugreifen kann, muss dieses natürlich bekannt sein. Sie müssen die Deklaration immer geschrieben haben, bevor Sie das entsprechende Element verwenden.
kuerzen()
[Bearbeiten]kuerzen()
ist nun endlich mal wirklich eine Memberfunktion (oder auch Methode) von Bruch. Da sie somit direkten Zugriff auf die Variablen hat, die sie verändern soll, braucht sie keine Argumente und auch keinen Rückgabewert.
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1);
int zaehler()const {return zaehler_;}
unsigned int nenner()const {return nenner_;}
private:
void kuerzen();
int zaehler_;
unsigned int nenner_;
};
void Bruch::kuerzen(){
unsigned int tmp(ggT(zaehler_, nenner_));
zaehler_ /= tmp;
nenner_ /= tmp;
}
Da kuerzen()
eine Methode ist, die nur innerhalb der Klasse nach einer Rechenoperation aufgerufen wird, deklarieren wir sie im privaten Bereich der Klasse.
Die Rechenoperationen
[Bearbeiten]Addition, Subtraktion, Multiplikation und Division, das sind 4 Rechenoperationen. C++ bietet aber die mit der Zuweisung kombinierten Kurzschreibweisen, womit wir insgesamt auf 8 kommen.
Addition
[Bearbeiten]Um zwei Brüche zu addieren, müssen die Nenner gleich sein. Wenn wir bereits Brüche haben, die sich nicht weiter kürzen lassen, (und dafür sorgt unsere Klasse,) dann erhalten wir wiederum einen unkürzbaren Bruch, wenn wir mit dem kgV erweitern. Das Ganze sieht also so aus:
Pseudocode
ErgebnisNenner = kgV(Bruch1Nenner, Bruch2Nenner);
ErgebnisZaehler = Bruch1Zaehler * (ErgebnisNenner / Bruch1Nenner) +
Bruch2Zaehler * (ErgebnisNenner / Bruch2Nenner);
Subtraktion
[Bearbeiten]Für die Subtraktion gelten die gleichen Regeln wie bei der Addition.
Pseudocode
ErgebnisNenner = kgV(Bruch1Nenner, Bruch2Nenner);
ErgebnisZaehler = Bruch1Zaehler * (ErgebnisNenner / Bruch1Nenner) -
Bruch2Zaehler * (ErgebnisNenner / Bruch2Nenner);
Multiplikation
[Bearbeiten]Bei der Multiplikation werden einfach die Zähler und die Nenner multipliziert. Danach muss der Bruch wieder gekürzt werden.
Pseudocode
ErgebnisZaehler = Bruch1Zaehler * Bruch2Zaehler;
ErgebnisNenner = Bruch1Nenner * Bruch2Nenner;
kuerzen();
Division
[Bearbeiten]Die Division stimmt mit der Multiplikation fast überein, aber statt die Zähler und die Nenner miteinander zu multiplizieren, werden sie gegeneinander multipliziert.
Pseudocode
ErgebnisZaehler = Bruch1Zaehler * Bruch2Nenner;
ErgebnisNenner = Bruch1Nenner * Bruch2Zaehler;
kuerzen();
Kombination
[Bearbeiten]Da C++ wie schon gesagt neben den normalen Rechenoperatoren noch die mit der Zuweisung kombinierten zur Verfügung stellt, werden wir einen kleinen Trick anwenden, um uns doppelte Arbeit zu ersparen. Wir werden die eigentlichen Rechenoperationen in den Zuweisungskombioperatoren implementieren und dann innerhalb der normalen Rechenoperatoren temporäre Objekte anlegen, für welche wir die Kombinationsoperatoren aufrufen. Das ist ein übliches und viel angewandtes Verfahren, welches einige Vorteile zu bieten hat. Sie sparen sich die doppelte Schreibarbeit und müssen sich bei Veränderungen nur um die Kombioperatoren kümmern, da sich die anderen ja genauso verhalten.
Die umgekehrte Variante, also von den Kombioperatoren die normalen aufrufen zu lassen, ist übrigens nicht zu empfehlen, da die Kombinationsoperatoren immer schneller sind, sie benötigen schließlich keine temporären Objekte. Außerdem ist es in vielen Klassen nötig, die normalen Rechenoperatoren außerhalb der Klasse zu deklarieren. Wenn sie nicht als friend
deklariert sind, haben sie keinen Zugriff auf die privaten Member der Klasse, rufen Sie dagegen die Kombioperatoren auf, brauchen Sie gar keinen Zugriff.
Die normalen Rechenoperatoren werden als Nicht-Member implementiert. Dies ist nötig, damit für beide Parameter eine implizite Typumwandlung erfolgen kann. Andernfalls könnten Sie zwar Bruch(1, 2)+3
schreiben, aber nicht 3+Bruch(1, 2)
und das ist natürlich nicht gewollt. Die Deklaration als Nicht-Member hat zur Folge, das wir zwei Parameter übergeben müssen. Der erste wird als Kopie („Call by value“) übergeben, da wir ja sowieso eine temporäre Kopie benötigen. Den Zweiten übergeben wir wie üblich als Referenz auf const
. Da die Implementierung so kurz ist, sind diese Funktionen natürlich inline
. Der Rückgabetyp lautet immer Bruch const
. Dies verhindert, dass Sie so etwas wie a + b = 4
schreiben können, obwohl doch a + b == 4
gemeint war.
Abschluss
[Bearbeiten]So, nun haben Sie wirklich genug Theorie gehört, es wird Zeit zu zeigen, wie das Ganze im Quelltext aussieht. Der Code lässt sich zwar noch nicht ausführen, weil der Konstruktor noch nicht definiert ist, aber es lohnt sich trotzdem, schon mal einen Blick darauf zu werfen.
unsigned int ggT(unsigned int a, unsigned int b){
if(b == 0) // Wenn b gleich 0
return a; // ggT gefunden
else return ggT(b, a % b); // andernfalls weitersuchen
}
unsigned int kgV(unsigned int a, unsigned int b){
// Das kgV zweier Zahlen, ist ihr Produkt geteilt durch ihren ggT
return a * b / ggT(a, b);
}
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1); // noch nicht definiert
int zaehler()const { return zaehler_; } // Gibt Zähler zurück
unsigned int nenner()const { return nenner_; } // Gibt Nenner zurück
Bruch& operator+=(Bruch const& lvalue);
Bruch& operator-=(Bruch const& lvalue);
Bruch& operator*=(Bruch const& lvalue);
Bruch& operator/=(Bruch const& lvalue);
private:
void kuerzen(); // kürzt weitestmöglich
int zaehler_;
unsigned int nenner_;
};
// Diese Methoden erstellen eine temporäre Kopie (lhs) ihres Objekts, führen
// die Rechenoperation auf ihr aus und geben sie dann zurück
inline Bruch const operator+(Bruch lhs, Bruch const &rhs){ return lhs += rhs; }
inline Bruch const operator-(Bruch lhs, Bruch const &rhs){ return lhs -= rhs; }
inline Bruch const operator*(Bruch lhs, Bruch const &rhs){ return lhs *= rhs; }
inline Bruch const operator/(Bruch lhs, Bruch const &rhs){ return lhs /= rhs; }
void Bruch::kuerzen(){
const unsigned int tmp = ggT(zaehler_, nenner_); // ggT in tmp speichern
zaehler_ /= tmp; // Zähler durch ggT teilen
nenner_ /= tmp; // Nenner durch ggT teilen
}
Bruch& Bruch::operator+=(Bruch const &lvalue){
const unsigned int tmp = kgV(nenner_, lvalue.nenner_);
zaehler_ = zaehler_ * (tmp / nenner_) + lvalue.zaehler_ * (tmp / lvalue.nenner_);
nenner_ = tmp;
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator-=(Bruch const &lvalue){
const unsigned int tmp = kgV(nenner_, lvalue.nenner_);
zaehler_ = zaehler_ * (tmp / nenner_) - lvalue.zaehler_ * (tmp / lvalue.nenner_);
nenner_ = tmp;
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator*=(Bruch const &lvalue){
zaehler_ *= lvalue.zaehler_;
nenner_ *= lvalue.nenner_;
kuerzen(); // Bruch wieder kürzen
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator/=(Bruch const &lvalue){
zaehler_ *= lvalue.nenner_;
nenner_ *= lvalue.zaehler_;
kuerzen(); // Bruch wieder kürzen
return *this; // Referenz auf sich selbst zurückgeben
}
Umwandlung aus anderen Datentypen
[Bearbeiten]Um aus einer Variable eines anderen Datentyps in einen Bruch umzuwandeln, übergibt man einem Konstruktor diese Variable und lässt ihn die Umwandlung durchführen. Sie erinnern sich bestimmt noch daran, dass im ersten Kapitel dieses Abschnittes stand, die Standardparameter des Konstruktors würden später besprochen. Dieser Zeitpunkt ist nun gekommen. Zur Erinnerung, seine Deklaration innerhalb der Klasse lautete:
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1);
// ...
};
Bruch::Bruch(int zaehler, unsigned int nenner):
zaehler_(zaehler),
nenner_(nenner){
kuerzen();
}
Die Definition steht, wie Sie oben sehen, außerhalb der Klasse. Innerhalb der Initialisierungsliste, welche durch einen Doppelpunkt eingeleitet wird, werden die Membervariablen mit den übergebenen Parametern initialisiert. Innerhalb des Funktionsrumpfes wird die Methode kuerzen()
aufgerufen, um den Bruch falls nötig zu kürzen.
Beachten Sie, dass die Standardparameter nur bei der Deklaration, nicht aber bei der Definition einer Funktion angegeben werden. Unser Konstruktor übernimmt 2 int
-Werte und beide besitzen einen Standardwert, daher kann er in 3 Formen aufgerufen werden:
Die erste Form entspricht einem Defaultkonstruktor und setzt den Wert des Bruches auf (0/1)
, was dem ganzzahligen Wert 0 entspricht. In der zweiten Form wird 1 int
-Wert übergeben, der resultierende Bruch entspricht diesem Wert, da der Nenner dank des Standardparameters auf 1 gesetzt wird. Die dritte Form übernimmt schließlich 2 int
-Werte, der erste gibt den Zähler und der zweite den Nenner des Bruches an.
Auch an dieser Stelle soll noch einmal darauf hingewiesen werden, dass der Nenner nicht negativ sein kann, daher ist dieser Parameter auch vom Typ unsigned int
welcher nur positive Werte zulässt.
Bitte beachten Sie, dass der Nenner eines Bruches normalerweise nicht 0 sein kann. Normalerweise würde man dem in C++ mit einer Ausnahme begegnen. Da wir Ausnahmen aber bisher nicht besprochen haben und es auch noch eine Weile dauern wird, bis wir dieses Thema behandeln, werden wir dem Umstand, dass es möglich ist dem Konstruktor von Bruch eine 0 als Nenner zu übergeben, erst einmal ignorieren. Später kommen wir auf diesen Punkt aber noch einmal zurück.
Gleitkommazahl wird Bruch
[Bearbeiten]Wir haben jetzt die Umwandlung von ganzen Zahlen in Brüche besprochen. Als nächstes wollen wir eine Gleitkommazahl in einen Bruch umwandeln. Hierfür benötigen wir einen zweiten Konstruktor, welcher eine Gleitkommazahl übernimmt und sie in einen Bruch umwandelt:
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1);
Bruch(double wert);
// ...
};
Bruch::Bruch(double wert):
zaehler_(static_cast<int>(wert*1000000.0+0.5)),
nenner_(1000000){
kuerzen();
}
Es ist kaum möglich, einen double
-Wert zuverlässig in einen Bruch umzuwandeln, da die Datentypen, die wir für Zähler (int
) und Nenner (unsigned int
) verwenden, den Wertebereich unserer Brüche gewaltig einschränken. Dieser Konstruktor ist daher kaum praxistauglich, aber als Beispiel sollte er genügen. Einmal vorausgesetzt, dass ein int
4 Byte groß ist, beachtet er drei Vorkomma- und sechs Nachkommastellen.
Wir setzen den Nenner des Bruches auf 1000000
, dann multiplizieren wir den übergebenen Wert mit 1 000 000. Würde man jetzt Zähler durch Nenner teilen, hätte man wieder exakt den Wert, der übergeben wurde. Da der Zähler aber ein int
- und kein double
-Wert sein muss, müssen wir ihn noch umwandeln. Da eine solche Umwandlung aber alle Nachkommastellen abschneidet, anstatt kaufmännisch korrekt zu runden, addieren wir den double
-Wert vorher mit 0.5
, was dazu führt, dass die Zahl nach dem Abschneiden der Nachkommastellen kaufmännisch gerundet wurde.
Alles was über 6 Nachkommastellen hinausgeht ist also für uns nicht relevant, da es korrekt gerundet wird. Das Problem besteht darin, dass der Bruch keinen Zähler aufnehmen kann, der größer als der größtmögliche int
-Wert (bei 4 Byte 2147483647
) ist. Leider lässt sich dieses Problem nicht ohne weiteres lösen, sodass wir uns an dieser Stelle damit zufrieden geben, dass dies nur eine beispielhafte Implementierung für eine Umwandlung einer Gleitkommazahl in einen Bruch und keine perfekte Implementierung ist.
Anschließend wird im Funktionsrumpf wieder einmal die Funktion kuerzen()
aufgerufen. Sie können nun also auch schreiben:
Bruch bruch1(0.25) // erzeugt (1/4)
Bruch bruch2(7.0) // erzeugt (7/1)
Bruch bruch3(999.999999) // erzeugt (999999999/1000000)
(K)ein Kopierkonstruktor
[Bearbeiten]Für unsere Bruch-Klasse werden wir keinen Kopierkonstruktor anlegen, da unser Compiler uns diese Arbeit mit einem zufriedenstellenden Ergebnis abnimmt. Oder anders ausgedrückt, Sie können, ohne eine einzige Zeile Code zur Klasse hinzuzufügen, schreiben:
Ein- und Ausgabe
[Bearbeiten]Als nächstes wollen wir dafür sorgen, dass unsere Brüche ebenso einfach ein- und ausgegeben werden können, wie die elementaren Datentypen. Sie wissen ja bereits, dass Sie hierfür nur den Ein- und Ausgabeoperator überladen müssen. Wir wollen die Brüche in der folgenden Form schreiben und lesen:
(Zähler/Nenner)
Die beiden folgenden Operatorfunktionen können Sie nach der Deklaration der Bruch
-Klasse einfügen. Include-Dateien gehören natürlich an die übliche Stelle am Dateianfang.
Ausgabe
[Bearbeiten]Die Ausgabe lässt sich ziemlich simpel realisieren, wir geben einfach das von uns gewünschte Format auf dem Ausgabestream aus, den wir mittels Parameter erhalten. Dann geben wir den Stream zurück. Beachten Sie allerdings, dass Sie die Headerdatei „iostream“ inkludieren müssen, damit Sie die Standardstreams überladen können.
#include <iostream>
std::ostream& operator<<(std::ostream &os, Bruch const &bruch){
return os << '(' << bruch.zaehler() << '/' << bruch.nenner() << ')';
}
Eingabe
[Bearbeiten]Die Eingabe ist etwas schwieriger. Wir müssen sicherstellen, dass das von uns vorgegebene Format eingehalten wird und falls nötig, den Eingabestream auf einen Fehlerstatus zu setzen.
#include <iostream>
std::istream& operator>>(std::istream &is, Bruch &bruch){
char tmp;
int zaehler;
unsigned int nenner;
is >> tmp;
if(tmp == '('){
is >> zaehler;
is >> tmp;
if(tmp == '/'){
is >> nenner;
is >> tmp;
if(tmp == ')'){
bruch = Bruch(zaehler, nenner);
return is;
}
}
}
is.setstate(std::ios_base::failbit);
return is;
}
Wie Sie sehen können, wird diesmal eine nicht-konstante Referenz auf einen Bruch übergeben, da dieser ja geändert wird, wenn man ihm einen neuen Wert zuweist. Da die Klasse Bruch
keine Möglichkeit bereitstellt, zaehler
oder nenner
einzeln zu ändern, erstellen wir nach dem Einlesen beider einen entsprechenden Bruch
und weisen ihn an unseren zu. Auf diese Weise stellen wir auch sicher, dass sich an dem Bruch nichts verändert, wenn während des Einlesens irgendetwas schief geht. Falls etwas daneben geht, wird das failbit
gesetzt und anschließend der Stream zurückgegeben.
Umwandlung in andere Datentypen
[Bearbeiten]In diesem Kapitel werden wir unseren Bruch in Gleitkommazahlen umwandeln. Hierfür müssen wir einfach den Zähler durch den Nenner teilen. Da jedoch beide einen integralen Typ haben, müssen wir vor dem Teilen einen der Werte in eine Gleitkommazahl umwandeln, damit keine Ganzzahldivision durchgeführt wird. Bei Ganzzahldivisionen würden natürlich die Nachkommastellen abgeschnitten.
class Bruch{
public:
// ...
operator float() {return static_cast<float>(zaehler_) / nenner_;}
operator double() {return static_cast<double>(zaehler_) / nenner_;}
operator long double(){return static_cast<long double>(zaehler_) / nenner_;}
// ...
};
Sie können den Bruch jetzt auf die folgende Weise in eine Gleitkommazahl umwandeln:
Das Kapitel Casts enthält weitergehende Informationen zur Umwandlung von Datentypen.
Der Taschenrechner geht zur Schule
[Bearbeiten]Im Abschnitt „Einführung in C++“ gab es ein Kapitel über einen einfachen Taschenrechner. In diesem Kapitel werden wir ihn ganz leicht modifizieren, so dass er mit Brüchen statt mit Gleitkommazahlen rechnet und die Ausgabe als Brüche und als Gleitkommazahlen gemacht wird.
#include <iostream>
// Alles was zur Bruch-Klasse gehört
int main(){
Bruch zahl1, zahl2, ergebnis; // Variablen für Zahlen vom Typ Bruch
char rechenzeichen; // Variable fürs Rechenzeichen
std::cout << "Geben Sie eine Rechenaufgabe ein: "; // Eingabeaufforderung ausgeben
std::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
default: std::cout << "unbekanntes Rechenzeichen...\n"; return 1;
}
// Aufgabe noch mal komplett ausgeben
std::cout << zahl1 << ' ' << rechenzeichen << ' ' << zahl2 << " = " << ergebnis << '\n';
std::cout << static_cast<double>(zahl1) << ' ' // Ausgabe als
<< rechenzeichen << ' ' // Gleitkommawerte
<< static_cast<double>(zahl2) << " = "
<< static_cast<double>(ergebnis) << '\n';
}
Geben Sie eine Rechenaufgabe ein: <Eingabe>(1/4)*(1/2)</Eingabe>
(1/4) * (1/2) = (1/8)
0.25 * 0.5 = 0.125
In der Zusammenfassung finden Sie noch einmal das gesamte Programm.
Zusammenfassung
[Bearbeiten]Hier finden Sie noch einmal das komplette Programm, inklusive der Ausgabe eines Durchlaufs.
#include <iostream>
unsigned int ggT(unsigned int a, unsigned int b){
if(b == 0)
return a;
else return ggT(b, a % b);
}
unsigned int kgV(unsigned int a, unsigned int b){
return a/ggT(a,b) * b;
}
class Bruch{
public:
Bruch(int zaehler = 0, unsigned int nenner = 1); // Konstruktoren
Bruch(double wert); // dieser ist nicht perfekt
int zaehler()const {return zaehler_;} // Gibt Zähler zurück
unsigned int nenner()const {return nenner_;} // Gibt Nenner zurück
Bruch& operator+=(Bruch const &lvalue);
Bruch& operator-=(Bruch const &lvalue);
Bruch& operator*=(Bruch const &lvalue);
Bruch& operator/=(Bruch const &lvalue);
// Umwandlung in Gleitkommatypen
operator float() {return static_cast<float>(zaehler_)/nenner_;}
operator double() {return static_cast<double>(zaehler_)/nenner_;}
operator long double(){return static_cast<long double>(zaehler_)/nenner_;}
private:
void kuerzen(); // kürzt weitestmöglich
int zaehler_;
unsigned int nenner_;
};
// Diese Methoden erstellen eine temporäre Kopie (lhs) ihres Objekts, führen
// die Rechenoperation auf ihr aus und geben sie dann zurück
inline Bruch const operator+(Bruch lhs, Bruch const &rhs){ return lhs += rhs; }
inline Bruch const operator-(Bruch lhs, Bruch const &rhs){ return lhs -= rhs; }
inline Bruch const operator*(Bruch lhs, Bruch const &rhs){ return lhs *= rhs; }
inline Bruch const operator/(Bruch lhs, Bruch const &rhs){ return lhs /= rhs; }
Bruch::Bruch(int zaehler, unsigned int nenner):
zaehler_(zaehler),
nenner_(nenner){
kuerzen();
}
Bruch::Bruch(double wert):
zaehler_(static_cast<int>(wert*1000000.0+0.5)),
nenner_(1000000){
kuerzen();
}
void Bruch::kuerzen(){
unsigned int tmp = ggT(zaehler_, nenner_); // ggT in tmp speichern
zaehler_ /= tmp; // Zähler durch ggT teilen
nenner_ /= tmp; // Nenner durch ggT teilen
}
Bruch& Bruch::operator+=(Bruch const &lvalue){
unsigned int tmp = kgV(nenner_, lvalue.nenner_);
zaehler_ = zaehler_ * (tmp / nenner_) + lvalue.zaehler_ * (tmp / lvalue.nenner_);
nenner_ = tmp;
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator-=(Bruch const &lvalue){
unsigned int tmp = kgV(nenner_, lvalue.nenner_);
zaehler_ = zaehler_ * (tmp / nenner_) - lvalue.zaehler_ * (tmp / lvalue.nenner_);
nenner_ = tmp;
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator*=(Bruch const &lvalue){
zaehler_ *= lvalue.zaehler_;
nenner_ *= lvalue.nenner_;
kuerzen(); // Bruch wieder kürzen
return *this; // Referenz auf sich selbst zurückgeben
}
Bruch& Bruch::operator/=(Bruch const &lvalue){
zaehler_ *= lvalue.nenner_;
nenner_ *= lvalue.zaehler_;
kuerzen(); // Bruch wieder kürzen
return *this; // Referenz auf sich selbst zurückgeben
}
std::ostream& operator<<(std::ostream &os, Bruch const &bruch){
return os << '(' << bruch.zaehler() << '/' << bruch.nenner() << ')';
}
std::istream& operator>>(std::istream &is, Bruch &bruch){
char tmp;
int zaehler, nenner;
is >> tmp;
if(tmp=='('){
is >> zaehler;
is >> tmp;
if(tmp=='/'){
is >> nenner;
is >> tmp;
if(tmp==')'){
bruch=Bruch(zaehler, nenner); // Bruch erzeugen und Wert übernehmen
return is;
}
}
}
is.setstate(std::ios_base::failbit); // Fehlerstatus setzen
return is;
}
int main(){
Bruch zahl1, zahl2, ergebnis; // Variablen für Zahlen vom Typ Bruch
char rechenzeichen; // Variable fürs Rechenzeichen
std::cout << "Geben Sie eine Rechenaufgabe ein: "; // Eingabeaufforderung ausgeben
std::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
default: std::cout << "unbekanntes Rechenzeichen...\n"; return 1;
}
// Aufgabe noch mal komplett ausgeben
std::cout << zahl1 << ' ' << rechenzeichen << ' ' << zahl2 << " = " << ergebnis << '\n';
std::cout << static_cast<double>(zahl1) << ' ' // Ausgabe als
<< rechenzeichen << ' ' // Gleitkommawerte
<< static_cast<double>(zahl2) << " = "
<< static_cast<double>(ergebnis) << '\n';
}
Geben Sie eine Rechenaufgabe ein: <Eingabe>(1/5)-(3/4)</Eingabe>
(1/5) - (3/4) = (-11/20)
0.2 - 0.75 = -0.55
Methoden
[Bearbeiten]Auch für Klassen gilt üblicherweise: Verwenden Sie const
, wann immer es möglich ist. Wie Sie bereits wissen, sollte const
immer verwendet werden, wenn eine Variable nach der Initialisierung nicht mehr verändert werden soll.
Da Klassen Datentypen sind, von denen Instanzen (also Variablen) erstellt werden können, ist es natürlich möglich ein Objekt zu erstellen, das konstant ist. Da der Compiler jedoch davon ausgehen muss, dass jede Methode der Klasse die Daten (und somit das Objekt) verändert, sind Methodenaufrufe für konstante Objekte nicht möglich. Eine Ausnahme bilden jene Methoden, die ebenfalls als const
gekennzeichnet sind. Eine solche Methode kann zwar problemlos Daten aus dem Objekt lesen, aber niemals darauf schreiben und auch für Dritte keine Möglichkeit bereitstellen, objektinterne Daten zu verändern.
Konstante Methoden
[Bearbeiten]Unsere Beispielklasse Auto
enthält die konstante Methode info()
, sie greift nur lesend auf die Membervariablen zu, um ihre Werte auszugeben. Wenn Sie ein konstantes Auto
Objekt erstellen, können Sie info()
problemlos aufrufen. Versuchen Sie jedoch fahren()
oder tanken()
aufzurufen, wird Ihr Compiler dies mit einer Fehlermeldung quittieren.
Wie Sie an diesem Beispiel sehen, lässt sich eine Methode als konstant auszeichnen, indem nach der Parameterliste das Schlüsselwort const
angegeben wird. Diese Auszeichnung folgt also auch der einfachen Regel: const
steht immer rechts von dem, was konstant sein soll, in diesem Fall die Methode. Da const
zum Methodenprototyp zählt, muss es natürlich auch bei der Definition der Methode angegeben werden.
Es sei noch einmal explizit darauf hingewiesen, dass sich die Konstantheit einer Methode lediglich auf die Membervariablen der zugehörigen Klasse auswirkt. Es ist problemlos möglich, eine als Parameter übergebene Variable zu modifizieren.
Eine konstante Methode kann ausschließlich andere konstante Methoden des eigenen Objektes aufrufen, denn der Aufruf einer nicht-konstanten Methode könnte ja Daten innerhalb des Objektes ändern.
Sinn und Zweck konstanter Objekte
[Bearbeiten]Vielleicht haben Sie sich bereits gefragt, wofür es gut sein soll, ein Objekt als konstant auszuzeichnen, wenn der Zweck eines Objektes doch darin besteht, mit den enthaltenen Daten zu arbeiten. Beim Erstellen eines konstanten Objektes können Sie es einmalig über den Konstruktor mit Werten belegen. In diesem Fall haben Sie von einem konstanten Objekt das gleiche, wie bei konstanten Variablen von Basisdatentypen.
Oft ist es jedoch nicht möglich, alle Einstellungen zu einem Objekt über den Konstruktoraufruf festzulegen. Es fördert die Übersichtlichkeit schließlich nicht, wenn man etwa 20 verschiedene Konstruktoren mit je etwa 50 Parametern hat. Der Ansatz, Klassen so zu gestalten, dass man immer alle Werte über den Konstruktor festlegen kann, hat also seine Grenzen. In diesem Fall hat es einfach keinen Sinn, ein Objekt bei der Erstellung konstant zu machen, denn die Einstellungen werden erst nach dem Erstellen des Objektes vorgenommen.
Wenn Sie ein so erstelltes Objekt nun allerdings an eine Funktion übergeben und diese Funktion keine Veränderungen an dem Objekt vornimmt, ist die Wahrscheinlichkeit groß, dass der Parameter ein konstantes Objekt ist. Innerhalb einer solchen Funktion wäre das Objekt also konstant.
Zugriffsmethoden
[Bearbeiten]Zugriffsmethoden sollten eigentlich vermieden werden, aber manchmal sind sie nützlich. Eine Zugriffsmethode macht nichts anderes, als eine Membervariable lesend oder schreibend zugreifbar zu machen:
class A{
public:
void SetWert(int wert) { m_wert = wert; }
int GetWert()const { return m_wert; }
private:
int m_wert;
};
Get-Methoden sind immer konstant, da sie den Wert ja nur lesend zugreifbar machen sollen. Eine Set-Methode kann dagegen nie mit einem konstanten Objekt benutzt werden. Im Normalfall sollten Sie jedoch keine „Getter“ oder „Setter“ benötigen, wenn doch, müssen Sie sich Gedanken darüber machen, ob das Objekt die Logik möglicherweise nicht ausreichend kapselt.
Solche Einzeiler werden normalerweise einfach direkt in die Funktionsdeklaration geschrieben, dadurch sind sie auch gleich automatisch als inline
ausgezeichnet. Dazu müssen Sie nur das Semikolon durch den Funktionsrumpf ersetzen. Sollten Sie dennoch lieber eine eigene Definition für solche Methoden machen wollen, dann achten Sie darauf, diese bei der Definition als inline
zu kennzeichnen. Falls Sie mit Headerdateien arbeiten, dann beachten Sie, dass der Funktionsrumpf bei inline
-Methoden während des Kompilierens bekannt sein muss. Die Definition muss also mit in die Headerdatei, nicht wie gewöhnlich in die Quelldatei.
Überladen
[Bearbeiten]Sie haben bereits gelernt, dass Funktionen überladen werden können, indem für den gleichen Funktionsnamen mehrere Deklarationen mit verschiedenen Parametern gemacht werden. Auch bei Klassenkonstruktoren haben Sie Überladung bereits kennengelernt. Für gewöhnliche Memberfunktionen ist eine Überladung ebenfalls nach den Ihnen bereits bekannten Kriterien möglich. Zusätzlich können Sie Memberfunktionen aber anhand des eben vorgestellten const
-Modifizierers überladen.
class A{
public:
void methode(); // Eine Methode
void methode()const; // Die überladene Version der Methode für konstante Objekte
};
Natürlich können auch Methoden mit Parametern auf diese Weise überladen werden. Die nicht-konstante Version wird immer dann aufgerufen, wenn Sie mit einem nicht-konstanten Objekt der Klasse arbeiten. Analog dazu wird die konstante Version aufgerufen, wenn Sie mit einem konstanten Objekt arbeiten. Wenn Sie nur eine konstante Version deklarieren, wird immer diese aufgerufen.
Sinnvoll ist diese Art der Überladung vor allem dann, wenn Sie einen Zeiger oder eine Referenz auf etwas innerhalb des Objekts zurückgeben. Wie Sie sich sicher erinnern, kann eine Überladung nicht anhand des Rückgabetyps einer Funktion (oder Methode) gemacht werden. Das folgende Beispiel wird Ihnen zeigen, wie Sie eine const
-Überladung nutzen können, um direkte Manipulation von Daten innerhalb eines Objekts nur für nicht-konstante Objekte zuzulassen.
#include <iostream>
class A{
public:
A():m_b(7) {} // m_b mit 7 initialisieren
int& B() { return m_b; } // Zugriff lesend und schreibend
int const& B()const { return m_b; } // Zugriff nur lesend
private:
int m_b; // Daten
};
int main(){
A objekt; // Ein Objekt von A
A const objekt_const; // Ein konstantes Objekt von A
std::cout << objekt.B() << std::endl; // Gibt 7 aus
std::cout << objekt_const.B() << std::endl; // Gibt 7 aus
objekt.B() = 9; // setzt den Wert von m_b auf 9
// objekt_const.B() = 9; // Produziert einen Kompilierfehler
std::cout << objekt.B() << std::endl; // Gibt 9 aus
std::cout << objekt_const.B() << std::endl; // Gibt 7 aus
}
Im Kapitel über Operatoren überladen werden Sie noch ein Beispiel zu dieser Technik kennenlernen, welches in der Praxis oft zu sehen ist.
Code-Verdopplung vermeiden
[Bearbeiten]Im Beispiel von eben geben die beiden Funktionen lediglich eine Referenz auf eine Membervariable innerhalb des Objekts zurück. In der Regel wird eine solche Funktion natürlich noch etwas mehr tun, zum Beispiel den Zugriff protokollieren und den aktuellen Wert auf dem Bildschirm ausgeben. Dann wäre es nötig, zwei Funktionen zu schreiben, die den gleichen Code enthalten. Das wiederum ist ausgesprochen schlechter Stil. Stellen Sie sich nur vor, Sie möchten die Funktion später aus irgendwelchen Gründen ändern, dann müssten Sie alle Änderungen an zwei Stellen im Code vornehmen.
Daher ist es sinnvoll, wenn eine Variante die andere aufruft. Hierfür sind einige Tricks nötig, da Sie einer der beiden Varianten beibringen müssen, eine Methode aufzurufen, die eigentlich nicht zum const
-Modifizierer des aktuellen Objekts passt. Die konstante Variante verspricht, niemals eine Änderung am Objekt vorzunehmen, sie ist in ihren Möglichkeiten also stärker eingeschränkt. Die nicht konstante Version darf hingegen alles, was auch die konstante Version darf. Somit ist es sinnvoll, die konstante Version von der nicht-konstanten aufrufen zu lassen; so kann die Methode, die mehr darf, (später) erweitert werden um Aktivitäten, die nur sie darf, ohne die Aufrufstruktur umstellen zu müssen.
// nicht-konstante Version von B()
int& A::B(){
// Konstantheit hinzucasten
A const* obj_myself_but_const = static_cast< A const* >(this);
// konstante Methodenversion aufrufen
int const& result = obj_myself_but_const->B();
// Konstantheit vom Rückgabewert wegcasten
return const_cast< int& >(result);
Erläuterung:
Um nun der nicht-konstanten Version beizubringen, dass sie ihr konstantes Äquivalent aufrufen soll, müssen wir zunächst einmal aus dem aktuellen Objekt ein konstantes Objekt machen. Jede Klasse enthält eine spezielle Variable, den sogenannten this
-Zeiger, der innerhalb einer Membervariable einen Zeiger auf das aktuelle Objekt repräsentiert. Diesen this
-Zeiger casten wir in einen Zeiger auf ein konstantes Objekt.
Nun haben wir einen Zeiger auf das Objekt, über den wir nur konstante Methoden aufrufen können. Das Problem ist nun, dass die aufgerufene Methode natürlich auch eine Referenz auf eine konstante Variable aufruft.
Da wir ja wissen, dass das aktuelle Objekt eigentlich gar nicht konstant ist, können wir die Konstantheit für die zurückgegebene Referenz guten Gewissens entfernen. Allerdings ist der static_cast
, den Sie bereits kennen, nicht dazu in der Lage. Um Konstantheit zu entfernen, benötigen Sie den const_cast
. Beachten Sie jedoch, dass dieser Cast wirklich nur auf Variablen angewendet werden darf, die eigentlich nicht konstant sind!
Wenn Sie diese Anweisungen in einer zusammenfassen, sieht Ihre Klasse nun folgendermaßen aus.
class A{
public:
// m_b mit 7 initialisieren
A():m_b(7) {}
// Ruft B()const auf
int& B() { return const_cast< int& >( (static_cast< A const* >(this)) -> B() ); }
int const& B()const { return m_b; }
private:
int m_b; // Daten
};
Wie schon gesagt, sieht diese Technik in unserem Beispiel überdimensioniert aus. Aber auch an dieser Stelle möchte ich Sie auf das Beispiel im Kapitel zur Operatorüberladung verweisen, wo sie, aufgrund der umfangreicheren konstanten Version, bereits deutlich angenehmer erscheint. Mit Performanceeinbußen haben Sie an dieser Stelle übrigens nicht zu rechnen. Ihr Compiler wird die casting-Operationen vermutlich wegoptimieren.
Call by reference
[Bearbeiten]Im Gegensatz zu den vorangegangenen Kapiteln dieses Abschnitts geht es diesmal nicht darum, wie man Objekte aufbaut, sondern wie man mit ihnen arbeitet. Im Kapitel über Funktionen hatten Sie schon ersten Kontakt mit der Wertübergabe als Referenz. Wie dort bereits erwähnt, ist es für Klassenobjekte effizienter, sie als Referenz an eine Funktion zu übergeben.
Bei der Übergabe einer Variablen als Wert muss von dieser Variable erst eine Kopie angefertigt werden. Zusätzlich führt der Kopierkonstruktor an dieser Stelle möglicherweise noch irgendwelche zusätzlichen Operationen aus, die Zeit kosten. Bei einer Übergabe als Referenz muss hingegen nur die Speicheradresse des Objekts kopiert werden. Wenn wir dann noch dafür sorgen, dass die Referenz auf ein konstantes Objekt verweist, haben wir eine fast kostenlose Übergabe und gleichzeitig die Sicherheit, dass die übergebene Variable innerhalb der Funktion nicht verändert wird.
class A{
public:
int a, b, c, d;
};
class B{
public:
// Das übergebene A-Objekt ist innerhalb der Methode konstant
void methode(A const& parameter_name);
};
Die Methode von B
kann auf die 4 Variablen von A
lesend zugreifen, sie aber nicht verändern. Die Übergabe als Wert ist dann sinnvoll, wenn innerhalb einer Methode ohnehin eine Kopie der Variablen benötigt wird.
Nun können auf das A-Objekt beliebige lesende oder schreibende Operationen angewendet werden. Da sie auf einer Kopie ausgeführt werden, bleibt auch hier das Originalobjekt unverändert, aber eben zu dem Preis, dass zusätzlich Zeit und Speicherplatz benötigt werden, um eine Kopie des Objekts zu erstellen. In den meisten Fällen ist es nicht nötig, innerhalb einer Methode Schreiboperationen auszuführen. Verwenden Sie daher nach Möglichkeit die „Call by reference“-Variante.
Bei Rückgabewerten sollten Sie natürlich auf Referenzen verzichten, es sei denn, Sie wissen wirklich, was Sie tun. Andernfalls kann es schnell passieren, dass Sie eine Referenz auf eine Variable zurückgeben, die außerhalb der Methode gar nicht mehr existiert. Das wiederum führt zufallsbedingt zu Laufzeitfehlern und Programmabstürzen.
Wir empfehlen inline
[Bearbeiten]In den Kapiteln über Funktionen und Headerdateien haben Sie bereits einiges über inline
-Funktionen erfahren. An dieser Stelle soll dieses Wissen nun auf den Gebrauch von inline
-Klassenmethoden übertragen werden. Das folgende kleine Beispiel zeigt eine Klasse mit einer inline
-Methode.
Das Schlüsselwort inline
steht vor der Methodendefinition, es kann auch in der Deklaration innerhalb der Klasse angegeben werden. Letzteres ist jedoch unübersichtlich und daher nicht zu empfehlen. Damit der Compiler den Methodenaufruf auch durch den Methodenrumpf ersetzen kann, steht die Methodendefinition ebenso wie die Klassendefinition in der Headerdatei und nicht in einer, eventuell zugehörigen, Implementierungsdatei. Wenn Sie eine Funktion direkt innerhalb einer Klassendefinition definieren, ist sie implizit inline
, da solche Funktionen nahezu immer sehr kurz sind und sich somit gut zur Ersetzung eignen.
class A{
public:
// methode ist implizit inline
int methode()const { return a_; }
private:
int a_;
};
Einige moderne Compilersuiten sorgen, je nach Einstellungen, dafür, dass Methoden inline
verwendet werden, auch wenn sie nicht als inline
deklariert wurden! In vielen Fällen können die Compiler ohnehin am besten selbst entscheiden, welche Funktionen gute inline
-Kandidaten sind und welche nicht.
Vererbung
[Bearbeiten]Einleitung
[Bearbeiten]Wenn von Objektorientierung gesprochen wird, fällt früher oder später auch das Stichwort Vererbung. Auf dieser Seite lernen Sie anhand eines Beispiels die Grundprinzipien der Vererbung kennen.
Die Ausgangslage
[Bearbeiten]Stellen wir uns vor, dass wir in einer Firma arbeiten und in einem Programm sämtliche Personen verwalten, die mit dieser Firma in einer Beziehung stehen.
Über jede Person sind folgende Daten auf jeden Fall bekannt: Name, Adresse, Telefonnummer.
Um alles zusammenzufassen, haben wir uns eine Klasse geschrieben, mit deren Hilfe wir eine einzelne Person verwalten können:
(Aus Gründen der Einfachheit benutzen wir string
s)
#include<iostream>
#include<string>
using namespace std;
class Person{
public:
Person(string Name, string Adresse, string Telefon) :m_name(Name), m_adr(Adresse), m_tel(Telefon){}
string getName(){ return m_name; }
string getAdr(){ return m_adr; }
string getTel(){ return m_tel; }
void info(){ cout << "Name: " << m_name << " Adresse: " << m_adr << " Telefon: " << m_tel << endl; }
private:
string m_name;
string m_adr;
string m_tel;
};
Dies ist natürlich eine sehr minimale Klasse, aber schließlich geht es hier ja auch um die Vererbung.
Gemeinsam und doch getrennt
[Bearbeiten]Die obige Klasse funktioniert ja eigentlich ganz gut, nur gibt es ein kleines Problem. In unserer Firma gibt es Mitarbeiter, Zulieferer, Kunden, Chefs, ... . Diese sind zwar alle Personen, sie haben jedoch jeweils noch zusätzliche Attribute (z. B. ein Mitarbeiter hat einen Lohn, während ein Kunde eine KundenNr. hat). Jetzt könnten wir natürlich für jeden unterschiedlichen Typ eine eigene Klasse schreiben, den bereits vorhanden Code der Klasse Person
hineinkopieren und schließlich die entsprechenden Erweiterungen vornehmen.
Dieser Ansatz hat jedoch einige Probleme:
- Er ist unübersichtlich.
- Es kann leicht zu Kopierfehlern kommen.
- Soll
Person
geändert werden, so muss jede Klasse einzeln bearbeitet werden.
Zum Glück bietet uns C++ aber ein mächtiges Hilfsmittel in Form der Vererbung. Anstatt alles zu kopieren, können wir den Compiler anweisen, die Klasse Person
als Grundlage zu verwenden. Dies wird durch ein Beispiel klarer.
Beispiel
[Bearbeiten]class Mitarbeiter : public Person
{
public:
Mitarbeiter(string Name, string Adresse, string Telefon, int Gehalt, int MANr):Person(Name,Adresse,Telefon),
m_gehalt(Gehalt), m_nummer(MANr) {}
int getGehalt(){ return m_gehalt; }
int getNummer(){ return m_nummer; }
private:
int m_gehalt;
int m_nummer;
};
Erläuterung des Beispiels
[Bearbeiten]Nun wollen wir uns der Analyse des Beispiels zuwenden, um genau zu sehen, was passiert ist.
Das wichtigste im Code steht ganz am Anfang: class Mitarbeiter : public Person
. Damit weisen wir den Compiler an, alle Elemente aus Person
auch in Mitarbeiter
zu übernehmen (z.B. hat Mitarbeiter
jetzt auch eine info-Funktion und eine Membervariable m_name
). Eine solche Klasse nennen wir abgeleitet/Kindklasse von Person
.
Des Weiteren rufen wir im Konstruktor auch den Konstruktor von Person
auf. Wir sparen uns also sogar diesen Code.
Benutzen können wir die Klasse wie gewohnt, mit der Ausnahme, dass wir jetzt auch alle Methoden von Person
aufrufen können:
Mitarbeiter Mit("Erika Mustermann", "Heidestraße 17, Köln", "123/454", 4523, 12344209);
Mit.info();
cout << Mit.getGehalt() << endl;
cout << Mit.getName() << endl;
Warum wir das Schlüsselwort public
verwenden, wird im Kapitel „Private und geschützte Vererbung“ erklärt.
protected
[Bearbeiten]Zum Schluss werden wir noch das Prinzip der Datenkapselung auf die Vererbung erweitern. Bis jetzt kennen wir die Schlüsselwörter public
und private
. Machen wir folgendes Experiment: Schreiben Sie eine neue Membermethode von Mitarbeiter
und versuchen Sie auf m_name
aus der Person
-Klasse zuzugreifen. Der Compiler wird einen Fehler ausgeben. Warum?
m_name
wurde in der Person
-Klasse als private
deklariert, das heißt, es kann nur von Objekten dieser Klasse angesprochen werden, nicht aber von abgeleiteten Klassen wie z. B. Mitarbeiter
. Um zu vermeiden, dass wir m_name
als public
deklarieren müssen, gibt es protected
. Es verhält sich ähnlich wie private
, mit dem Unterschied, dass auch Objekte von Kindklassen auf mit diesem Schlüsselwort versehene Member zugreifen können.
Methoden (nicht) überschreiben
[Bearbeiten]Im letzten Kapitel haben Sie die Vererbung kennengelernt. Auch ist Ihnen bereits bekannt, dass eine Funktion oder Methode überladen werden kann, indem man für einen Funktionsnamen mehrere unterschiedliche Deklarationen tätigt. Es stellt sich nun also die Frage, wie der Compiler reagiert, wenn wir in einer abgeleiteten Klasse eine Methode deklarieren, die in der Basisklasse unter dem gleichen Namen bereits existiert.
Dieser Vorgang wird als Überschreiben einer Methode bezeichnet. Man könnte nun natürlich annehmen, dass das Überschreiben nur für die entsprechende Signatur gilt, dies ist jedoch nicht der Fall. Eine Methode gleichen Namens, verdeckt alle Überladungen in der Basisklasse. Folgendes Beispiel soll das Verhalten klären:
#include <iostream>
struct Base{
void f(){ std::cout << "void Base::f()" << std::endl; }
void f(int){ std::cout << "void Base::f(int)" << std::endl; }
};
struct A: Base{};
struct B: Base{
void f(double){ std::cout << "void B::f(double)" << std::endl; }
};
int main(){
A a; // Nutzt f() aus Base, da keine eigene Methode f() existiert
B b; // Überschreibt alle Methoden f()
a.f(); // void Base::f();
a.f(5); // void Base::f(int);
// b.f(); // Compilierfehler: kein passendes f() in B; Base::f() ist verdeckt
b.f(5.4); // void B::f(double);
b.f(5); // void B::f(double); (implizite Konvertierung nach double)
// expliziter Aufruf der Basisklassenmethoden
b.Base::f(); // void Base::f();
b.Base::f(5.4); // void Base::f(int); (implizite Konvertierung nach int)
b.Base::f(5); // void Base::f(int);
}
Wie sie sehen, können die Methoden der Basisklasse durch explizite Angabe der selben aufgerufen werden. Alternativ wäre auch ein static_cast
von b
möglich, dies führt jedoch zu schlecht lesbarem und fehleranfälligen Code und sollte daher vermieden werden. Fehleranfällig ist er, weil ein static_cast
natürlich eine willkürliche Konvertierung bewirken kann, also in einen Typen von dem b
gar nicht abgeleitet ist, aber auch wenn das Konvertierungsziel eine Basisklasse ist, können sich unerwartete Effekte einstellen:
Schlechter Stil! Bitte nicht verwenden!
int main(){
B b; // Überschreibt alle Methoden f()
// expliziter Aufruf der Basisklassenmethoden
static_cast< Base& >(b).Base::f(); // Gleichwertig zu "b.Base::f()"
static_cast< Base >(b).Base::f(); // Erzeugt eine temporäre Kopie von a und ruft für diese Base::f() auf
}
Um zu sehen, dass tatsächlich eine Kopie erzeugt wird, können sie Base
einen entsprechenden Kopierkonstruktor hinzufügen. Natürlich benötigt Base
dann auch einen Standardkonstruktor.
Im Kapitel Virtuelle Methoden werden Sie eine scheinbar ähnliche Technik kennenlernen, diese hat jedoch im Gegensatz zum Überschreiben von Methoden nichts mit Funktionsüberladung zu tun und sollte keinesfalls damit verwechselt werden!
Private und geschützte Vererbung
[Bearbeiten]Einleitung
[Bearbeiten]Bis jetzt haben wir alle Vererbungen mit dem Schlüsselwort public
vorgenommen (dies wird „öffentliche Vererbung“ genannt). Nun werden wir lernen, was passiert, wenn statt public
private
bzw. protected
verwendet wird.
Private Vererbung
[Bearbeiten]Verwenden wir private
, so bedeutet dies, dass alle geschützten und öffentlichen Membervariablen bzw. Memberfunktion aus der Basisklasse privat werden, von außen also nicht sichtbar sind.
Wenn kein Schlüsselwort zur Ableitung angegeben wird, handelt es sich automatisch um private Vererbung.
Geschützte Vererbung
[Bearbeiten]Geschützte Vererbung verläuft analog zur privaten Vererbung und sagt aus, dass alle geschützten und öffentlichen Member der Elternklasse im Bereich protected
stehen.
Wann wird was benutzt?
[Bearbeiten]Um festzustellen wann welche Art von Vererbung eingesetzt wird, gibt es zwei unterschiedliche Arten wie eine abgeleitete Klasse im Verhältnis zu ihrer Basisklasse stehen kann.
- ist-Beziehung: "Klasse B ist eine Klasse A" → Klasse B erbt
public
von A. (Beispiel: Ein Arbeiter ist eine Person) - hat-Beziehung: "Klasse B hat ein Klasse A Objekt" → Klasse B erbt
private
von A. (Beispiel: Ein Mensch hat ein Herz)
Meistens wird mithilfe von public
vererbt. Andere Typen von Vererbung werden nur selten bis gar nicht benutzt. Oft ist es sinnvoll statt einer privaten Vererbung ein Memberobjekt der entsprechenden Klasse zu verwenden.
Funktionstemplates
[Bearbeiten]Ein Funktionstemplate ist eine „Mustervorlage“ oder „Schablone“, die dem Compiler mitteilt, wie eine Funktion erzeugt werden soll. Aus einem Template können semantisch gleichartige Funktionen mit verschiedenen Parametertypen erzeugt werden. Für alle, die eben in Panik zu Wiktionary oder einem noch schwereren Wörterbuch gegriffen haben: semantisch heißt hier so viel, wie allgemein gehalten in Bezug auf den Datentyp. Die Funktion hat also immer die gleiche Parameteranzahl, die Parameter haben aber unterschiedliche Datentypen. Das ist zwar nicht ganz korrekt ausgedrückt, aber eine genaue Definition würde das Wort wieder so unverständlich machen, dass es ohne Erklärung weniger Schrecken verbreitet hätte. Lesen Sie einfach das Kapitel, dann werden Sie auf einige der „fehlenden Klauseln“ selbst aufmerksam werden.
Das nachfolgende Template kann Funktionen generieren, welche die größere von zwei Zahlen zurückliefern. Der Datentyp spielt dabei erst einmal keine Rolle. Wichtig ist nur, dass es zwei Variablen gleichen Typs sind. Die Funktion liefert dann einen Wert zurück, der ebenfalls dem Datentyp der Parameter entspricht.
Das Template wird mit dem gleichnamigen, aber kleingeschriebenen Schlüsselwort template
eingeleitet. Danach folgt eine spitze Klammer und ein weiteres Schlüsselwort namens typename
. Früher hat man an dieser Stelle auch das gleichbedeutende Schlüsselwort class
benutzt. Das funktioniert heute immer noch, ist aber nicht mehr üblich. Erstens symbolisiert typename
besser was folgt, nämlich einen Platzhalter, der für einen Datentyp steht, und zweitens ist es einfach übersichtlicher. Dies gilt insbesondere bei Klassentemplates, die aber Thema des nächsten Kapitels sind.
Als Nächstes folgt wie schon gesagt der Platzhalter. Er kann die gleichen Namen haben wie eine Variable sie haben könnte. Es ist aber allgemein üblich, den Platzhalter als T
(für Typ) zu bezeichnen. Das soll natürlich nur ein Vorschlag sein – viele Programmierer haben da auch ihren eigenen Stil. Wichtig ist nur, dass Sie sich nicht zu viele Bezeichner suchen, es sei denn Sie haben ein ausgesprochenes Talent dafür, aussagekräftige Namen zu finden. Am Ende dieser Einführung wird die spitze Klammer geschlossen. Dann wird ganz normal die Funktion geschrieben mit der einen Ausnahme, dass anstatt eines genauen Datentypes einfach der Platzhalter angegeben wird.
Sie können diese Funktion dann z.B. mit int
- oder double
-Werten aufrufen:
Bei jedem ersten Aufruf einer solchen Funktion erstellt der Compiler aus dem Template eine echte Funktion, wobei er den Platzhalter durch den tatsächlichen Datentyp ersetzt. Welcher Datentyp das ist, entscheidet der Compiler anhand der übergebenen Argumente. Bei diesem Aufruf findet keine Typumwandlung statt. Es ist also nicht möglich, einen int
- und einen double
-Wert an die Templatefunktion zu übergeben. Das nächste Beispiel wird das verdeutlichen:
int a = 3;
double b = 5.4;
double m; // Ergebnis
m = maxi(a, b); // funktioniert nicht
m = maxi(4.5, 3); // funktioniert nicht
m = maxi(4.5, 3.0); // funktioniert
m = maxi(a, 4.8); // funktioniert nicht
m = maxi(7, b); // funktioniert nicht
m = maxi(a, 5); // funktioniert
m = maxi(7.0, b); // funktioniert
Möchten Sie eine andere Variante als jene, die anhand der Argumente aufgerufen wird benutzen, müssen Sie die gewünschte Variante explizit angeben:
Diese Templatefunktion kann nun mit allen Datentypen aufgerufen werden, die sich nach der Ersetzung des Platzhalters durch den konkreten Datentyp auch übersetzen lassen. In unserem Fall wäre die einzige Voraussetzung, dass auf den Datentyp der Operator >
angewandt werden kann und ein Kopierkonstruktor vorhanden ist, da die Werte mit „call by value“, also als Kopie übergeben werden.
Das ist beispielsweise auch bei C++-Zeichenketten, also Objekte der Klasse std::string
mit Headerdatei: string
, der Fall. Daher ist folgendes problemlos möglich:
Spezialisierung
[Bearbeiten]Im Gegensatz zu normalen Funktionen können Templatefunktionen nicht einfach überladen werden, da das Template ja eine Schablone für Funktionen ist und somit bereits für jeden Datentyp eine Überladung bereitstellt. Manchmal ist es aber so, dass das allgemein gehaltene Template für bestimmte Datentypen keine sinnvolle Funktion erzeugt. In unserem Beispiel würde dies für C-Strings zutreffen:
const char* maxi(const char* str1, const char* str2){
if(str1 > str2)
return str1;
else
return str2;
}
Beim Aufruf von maxi("Ich bin ein String!", "Ich auch!")
würde die oben stehende Funktion aus dem Template erzeugt. Das Ergebnis wäre aber nicht wie gewünscht die dem ASCII-Code nach größere Zeichenkette, sondern einfach die, dessen Adresse im Arbeitsspeicher größer ist. Um C-Strings zu vergleichen gibt es eine Funktion in der Headerdatei cstring
(oder in der veralteten Fassung string.h
).
Um nun dem Compiler beizubringen, wie er maxi()
für C-Strings zu implementieren hat, muss diese Variante spezialisiert werden. Das heißt, es wird für einen bestimmten Datentyp (in diesem Fall const char*
) eine von der Mustervorlage (also dem Template) abweichende Version definiert. Für das nächste Beispiel muss die Headerdatei cstring
inkludiert werden.
template <>
const char* maxi(const char* str1, const char* str2){
if(strcmp(str1, str2) > 0) // strcmp vergleicht zwei C-Strings
return str1;
else
return str2;
}
Eine Spezialisierung leitet man immer mit dem Schlüsselwort template
gefolgt von einer öffnenden und einer schließenden spitzen Klammer ein. Der Name der Funktion ist identisch mit dem des Templates (maxi
), und alle T
werden durch den konkreten Typ const char*
ersetzt. Der Rumpf der Funktion kann dann völlig beliebig gestaltet werden. Natürlich ist es Sinn und Zweck der Sache, ihn so zu schreiben, dass die Spezialisierung das gewünschte sinnvolle Ergebnis liefert.
Übrigens wäre es auch möglich, den als Beispiel dienenden Aufruf von oben als maxi<std::string>("Ich bin ein String!", "Ich auch!")
zu schreiben. Dann würden die beiden C-Strings vor dem Aufruf in C++-Strings umgewandelt, und die können ja problemlos mit >
verglichen werden. Diese Methode hat aber drei entscheidende Nachteile gegenüber einer Spezialisierung für C-Strings:
- Die Headerdatei
string
muss jedes Mal inkludiert werden - Der Aufruf von
maxi("Ich bin ein String!", "Ich auch!")
ist weiterhin problemlos möglich und liefert auch weiterhin ein dem Zufall überlassenes Ergebnis - Diese Art der Lösung ist viel langsamer als die Variante mit der Spezialisierung
Zwischen Spezialisierung und Überladung
[Bearbeiten]Die folgende Überladung der Templatefunktion maxi()
ist ebenfalls problemlos möglich und scheint auf den ersten Blick prima zu funktionieren:
// template <> - ohne das ist es keine Spezialisierung, sondern eine Überladung
const char* maxi(const char* str1, const char* str2){
if(strcmp(str1, str2) > 0)
return str1;
else
return str2;
}
Um es gleich vorwegzunehmen: das hier beschriebene Problem lässt sich leicht herbeiführen, ist aber nur sehr schwer zu finden. Daher achten Sie darauf, es zu vermeiden. Vergessen Sie auf keinen Fall template <>
voranzustellen, wenn Sie ein Template spezialisieren.
Das Ausführen der nächsten beiden Codezeilen führt zum Aufruf verschiedener Funktionen. Die Funktionen haben identische Parametertypen und Namen sowie die gleiche Parameteranzahl, aber eben nicht den gleichen Code.
// Überladene Version - Ergebnis korrekt
cout << maxi("Ich bin ein String!", "Ich auch!");
// Vom Template erzeugte Version - Ergebnis zufällig (Speicheradressen)
cout << maxi<const char*>("Ich bin ein String!", "Ich auch!");
So etwas sollten Sie um jeden Preis vermeiden! Selbst ein erfahrener Programmierer dürfte über einen Fehler dieser Art erst einmal sehr erstaunt sein. Sie können die beiden Codezeilen ja jemandem zeigen, der sich gut auskennt und ihn fragen, was der Grund dafür sein könnte, dass die Varianten zwei verschiedene Ergebnisse liefern. Ohne die Implementierung von maxi()
zu sehen wird er höchst wahrscheinlich nicht darauf kommen. Ein solcher Fehler ist nur schwer zu finden. Es ist bereits eine Katastrophe, ihn überhaupt erst einmal zu machen. Merken Sie sich also unbedingt, dass man template <>
nur vor eine Funktion schreibt, wenn man für einen bestimmten Datentyp die Funktionsweise ändern will, weil die Standardvorlage kein sinnvolles Ergebnis liefert.
Überladen von Template-Funktionen
[Bearbeiten]Ein Template ist in der Regel eine Vorlage für Funktionen, deren Parameter sich lediglich im Typ unterscheiden. Das Überladen von Funktionen ist aber auch durch eine andere Parameterzahl oder eine andere Anordnung der Parametertypen möglich. Unter dieser Überschrift wird die maxi()
-Template um einen optionalen dritten Parameter erweitert. Was bisher vorgestellt wurde, wird in diesem Beispielprogramm zusammengefasst:
#include <iostream> // Ein und Ausgabe
#include <string> // C++-Strings
#include <cstring> // Vergleiche von C-Strings
using namespace std;
// Unsere Standardvorlage für maxi()
template <typename T>
T maxi(T obj1, T obj2){
if(obj1 > obj2)
return obj1;
else
return obj2;
}
// Die Spezialisierungen für C-Strings
template <> // Spezialisierung
const char* maxi(const char* str1, const char* str2){ // statt T, const char*
if(strcmp(str1, str2) > 0)
return str1;
else
return str2;
}
int main(){
int a = 3; // Ganzzahlige Variable
double b = 5.4; // Gleitkommazahl-Variable
int m; // Ganzzahl-Ergebnis
double n; // Kommazahl-Ergebnis
// Je nachdem, wo die Typumwandlungen stattfinden,
// sind die Ergebnisse unterschiedlich
m = maxi<int>(a, b); // m == 5
n = maxi<int>(a, b); // n == 5.0
m = maxi<double>(a, b); // m == 5
n = maxi<double>(a, b); // n == 5.4
// Aufgerufen wird unabhänngig vom Ergebnistyp m:
m = maxi(3, 6); // maxi<int>()
m = maxi(3.0, 6.0); // maxi<double>()
m = maxi<int>(3.0, 6); // maxi<int>()
m = maxi<double>(3, 6.0); // maxi<double>()
// Aufruf von maxi<std::string>()
string s1("alpha");
string s2("omega");
string smax = maxi(s1,s2); // smax == omega
// Aufruf von maxi<const char*>()
smax = maxi("alpha", "omega"); // Spezialisierung wird aufgerufen
return 0; // Programm erfolgreich durchlaufen
}
Eine Ausgabe enthält das Programm noch nicht, aber das sollten Sie ja problemlos schaffen. Die Datei iostream
ist auch schon inkludiert.
Nun aber zur Überladung der Templatefunktion: um von maxi()
zu verlangen, den größten von drei Werten zurückzugeben, muss der Parameterliste ein weiterer Platzhalter, also noch ein T obj3
hinzugefügt werden. Gleiches gilt für unsere C-String-Spezialisierung. Da wir ja aber auch die Möglichkeit haben wollen, nur den größeren von zwei Werten zu bestimmen, müssen wir die bisherige Variante stehen lassen und die mit drei Argumenten hinzufügen. Das Ganze sieht dann so aus:
#include <iostream> // Ein und Ausgabe
#include <string> // C++-Strings
#include <cstring> // Vergleiche von C-Strings
using namespace std;
// Unsere Standardvorlage für maxi() mit 2 Argumenten
template <typename T>
T maxi(T obj1, T obj2){
if (obj1 > obj2)
return obj1;
else
return obj2;
}
// Die Spezialisierungen für C-Strings mit zwei Argumenten
template <> // Spezialisierung
const char* maxi(const char* str1, const char* str2){
if(strcmp(str1, str2) > 0)
return str1;
else
return str2;
}
// Unsere Standardvorlage für maxi() mit drei Argumenten
template <typename T>
T maxi(T obj1, T obj2, T obj3){
if(obj1 > obj2)
if(obj1 > obj3)
return obj1;
else
return obj3;
else if(obj2 > obj3)
return obj2;
else
return obj3;
}
// Die Spezialisierungen für C-Strings mit drei Argumenten
template <> // Spezialisierung
const char* maxi(const char* str1, const char* str2, const char* str3){
if(strcmp(str1, str2) > 0){
if(strcmp(str1, str3) > 0)
return str1;
else
return str3;
}
else if(strcmp(str2, str3) > 0)
return str2;
else
return str3;
}
int main(){
cout << "Beispiele für Ganzzahlen:\n";
cout << " (2, 7, 4) -> " << maxi(2, 7, 4) << endl;
cout << " (2, 7) -> " << maxi(2, 7) << endl;
cout << " (7, 4) -> " << maxi(7, 4) << endl;
cout << "\nBeispiele für Kommazahlen:\n";
cout << "(5.7, 3.3, 8.1) -> " << maxi(5.7, 3.3, 8.1) << endl;
cout << " (7.7, 7.6) -> " << maxi(7.7, 7.6) << endl;
cout << " (1.9, 0.4) -> " << maxi(1.9, 0.4) << endl;
cout << "\nBeispiele für C-Strings:\n";
cout << "(ghi, abc, def) -> " << maxi("ghi", "abc", "def") << endl;
cout << " (ABC, abc) -> " << maxi("ABC", "abc") << endl;
cout << " (def, abc) -> " << maxi("def", "abc") << endl;
return 0; // Programm erfolgreich durchlaufen
}
Beispiele für Ganzzahlen:
(2, 7, 4) -> 7
(2, 7) -> 7
(7, 4) -> 7
Beispiele für Kommazahlen:
(5.7, 3.3, 8.1) -> 8.1
(7.7, 7.6) -> 7.7
(1.9, 0.4) -> 1.9
Beispiele für C-Strings:
(ghi, abc, def) -> ghi
(ABC, abc) -> abc
(def, abc) -> def
So Sie verstanden haben, wie aus einer Template eine konkrete Funktion erzeugt wird, fragen Sie sich vielleicht, ob das wirklich so einfach ist. Glücklicherweise kann diese Frage bejaht werden. Man stellt sich einfach das Funktionstemplate als mehrere separate Funktionen vor, die durch unterschiedliche Parametertypen überladen sind und wendet dann die üblichen Regeln zur Überladung von Funktionen an. Wenn man, wie in diesem Fall getan, ein weiteres Funktionstemplate anlegt, werden dadurch natürlich auch gleich wieder eine ganze Reihe einzelner Funktionen hinzugefügt. Im Endeffekt überlädt man also nicht das Template, sondern immer noch die aus ihm erzeugten Funktionen. Was Spezialisierungen angeht, gehören die natürlich immer zu dem Template, das eine Verbindung zu seiner semantischen Parameterliste besitzt.
Templates mit mehreren Parametern
[Bearbeiten]Natürlich kann ein Template auch mehr als nur einen Templateparameter übernehmen. Nehmen Sie an, Sie benötigen eine kleine Funktion, welche die Summe zweier Argumente bildet. Über den Typ der Argumente wissen Sie nichts, aber der Rückgabetyp ist Ihnen bekannt. Eine solche Funktion könnte folgendermaßen implementiert werden:
#include <iostream> // Ein und Ausgabe
using namespace std;
// Unsere Vorlage für die Summenberechnung
template <typename R, typename Arg1, typename Arg2>
R summe(Arg1 obj1, Arg2 obj2){
return obj1 + obj2;
}
int main(){
double a = 5.4; // Gleitkommazahl-Variable
int b = -3; // Ganzzahlige Variable
cout << summe<int>(a, b) << endl; // int summe(double obj1, int obj2);
cout << summe<double>(a, b) << endl; // double summe(double obj1, int obj2);
cout << summe<double>(a, b) << endl; // double summe(double obj1, int obj2);
cout << summe<unsigned long>(a, b) << endl; // unsigned long summe(double obj1, int obj2);
cout << summe<double, int, int>(a, b) << endl; // double summe(int obj1, int obj2);
cout << summe<double, long>(a, b) << endl; // double summe(long obj1, int obj2);
return 0; // Programm erfolgreich durchlaufen
}
2
2.4
2.4
2
2
2
Beim fünften Aufruf sind alle Argumente explizit angegeben. Wie Sie unschwer erkennen werden, kann der Compiler bei dieser Funktion den Rückgabetyp nicht anhand der übergebenen Funktionsparameter ermitteln. Daher müssen Sie ihn immer explizit angeben, wie die ersten vier Aufrufe es zeigen.
Die Templateargumente werden beim Funktionsaufruf in der gleichen Reihenfolge angegeben, wie sie bei der Deklaraton des Templates in der Templateargumentliste stehen. Wenn dem Compiler weniger Argumente übergeben werden als bei der Deklaration angegeben, versucht er die übrigen anhand der Funktionsargumente zu bestimmen.
Das bedeutet für Sie, dass Argumente, die man immer explizit angeben muss, auch bei der Deklaration an vorderster Stelle der Templateargumentliste stehen müssen. Das folgende Beispiel verdeutlicht die Folgen bei Nichteinhaltung dieser Regel:
#include <iostream> // Ein und Ausgabe
using namespace std;
// Unsere Vorlage für die Summenberechnung
template <typename Arg1, typename Arg2, typename R> // Rückgabetyp als letztes angegeben
R summe(Arg1 obj1, Arg2 obj2){
return obj1 + obj2;
}
int main(){
double a = 5.4; // Gleitkommazahl-Variable
int b = -3; // Ganzzahlige Variable
cout << summe<int>(a, b) << endl; // ??? summe(int obj1, int obj2);
cout << summe<double>(a, b) << endl; // ??? summe(double obj1, int obj2);
cout << summe<double>(a, b) << endl; // ??? summe(double obj1, int obj2);
cout << summe<unsigned long>(a, b) << endl; // ??? summe(unsigned long obj1, int obj2);
cout << summe<double, int, int>(a, b) << endl; // int summe(double obj1, int obj2);
cout << summe<double, long>(a, b) << endl; // ??? summe(double obj1, long obj2);
return 0; // Programm erfolgreich durchlaufen
}
Nur der Aufruf (Nr. 5), bei dem auch das letzte Templateargument, also der Rückgabetyp explizit angegeben wurde, ließe sich übersetzen. Alle anderen bemängelt der Compiler. Sie müssten also alle Templateargumente angeben, um den Rückgabetyp an den Compiler mitzuteilen. Das schmeißt diese wundervolle Gabe des Compilers, selbständig anhand der Funktionsparameter die richtige Templatefunktion zu erkennen, völlig über den Haufen. Hinzu kommt in diesem Beispiel noch, dass die Reihenfolge bei einer expliziten Templateargumentangabe alles andere als intuitiv ist.
Nichttypargumente
[Bearbeiten]Neben Typargumenten können Templates auch ganzzahlige Konstanten als Argument übernehmen. Die folgende kleine Templatefunktion soll alle Elemente eines Arrays (Datenfeldes) beliebiger Größe mit einem Startwert initialisieren.
#include <cstddef> // Für den Typ size_t
template <std::size_t N, typename T>
void array_init(T (&array)[N], T const &startwert){
for(std::size_t i=0; i!=N; ++i)
array[i]=startwert;
}
Die Funktion übernimmt eine Referenz auf ein Array vom Typ T
mit N
Elementen. Jedem Element wird der Wert zugewiesen, welcher der Funktion als Zweites übergeben wird. Der Typ std::size_t
wird übrigens von der C++-Standardbibliothek zur Verfügung gestellt. Er ist in der Regel als typedef
auf unsigned long
deklariert.
Ihr Compiler sollte in der Lage sein, sowohl den Typ T
als auch die Größe des Arrays N
selbst zu ermitteln. In einigen Fällen kann es jedoch vorkommen, dass der Compiler nicht in der Lage ist, die Größe N
zu ermitteln. Da sie als erstes Templateargument angegeben wurde, reicht es aus, nur sie explizit anzugeben und den Compiler in diesen Fällen zumindest den Typ T
selbst ermitteln zu lassen.
inline
[Bearbeiten]Obwohl Funktionstemplates im Header definiert werden müssen, sind sie nicht automatisch auch inline
. Wenn Sie eine Funktionstemplate als inline
deklarieren möchten, geben Sie das Schlüsselwort zwischen Templateparameterliste und dem Prototypen an.
Container
[Bearbeiten]Containerklassen
[Bearbeiten]In diesem Kapitel soll das Konzept generischer Container vorgestellt werden. Als Container bezeichnet man einen Datentyp, der Daten gleichen Typs in einer bestimmten Struktur zusammenfasst. Generisch bedeutet natürlich, dass die Container als Klassentemplates realisiert sind und der Typ der gespeicherten Daten so frei gewählt werden kann.
Ein einfacher Container trägt den Namen array
, es bekommt zwei Templateparameter. Der Erste ist der Datentyp der Array-Elemente, der Zweite ist die Anzahl der Array-Elemente. Ein std::array
ist im Grunde nichts anderes, als ein gewöhnliches C-Array, mit den zusätzlichen Eigenschaften eines STL-Containers. Welche das sind, werden wir gleich klären.
Der wohl wichtigste Container ist std::vector
. Er ist im Prinzip ein Array, dessen Größe sich zur Laufzeit anpassen lässt. Er bekommt als Templateparameter also nur den Datentyp, die Anzahl der Elemente ist dynamisch. Im folgenden sollen die STL-Container und ihre Verwendung kurz erläutert werden. Folgende Typen stehen zur Verfügung:
Sequentielle Containerklassen
array
vector
list
forward_list
deque
Assoziative Containerklassen
unordered_map
unordered_multimap
unordered_multiset
unordered_set
map
multimap
multiset
set
Containeradapterklassen (keine C++ Standardbibliothek-Iteratoren)
priority_queue
queue
stack
Container sind Behälter für Objekte. Dabei wird immer eine Kopie des Objektes in dem Container gespeichert. Das hat den Vorteil, dass sich die Speicherverwaltung vereinfacht, denn das Objekt verliert seine Gültigkeit, wenn das Containerobjekt aufgelöst wird. Der Nachteil liegt auf der Hand - durch das Erstellen einer Kopie geht Zeit und Speicherplatz verloren. Es gibt aber auch Wege, Zeiger in Containern zu speichern, was aber an späterer Stelle erläutert werden soll. Ein weiterer Vorteil bei der Verwendung von Containern ist ihre einheitliche Schnittstelle zu Elementfunktionen. Das vereinfacht den Einsatz von bestimmten Algorithmen, wie sie ebenfalls in der STL zu finden sind.
Containereigenschaften
[Bearbeiten]Ein vector ist im Wesentlichen ein dynamisches Feld, das je nach Bedarf seine Größe dynamisch verändern kann. Allerdings sind dabei einige Dinge zu beachten. Auf ein beliebiges Objekt innerhalb des Feldes kann sehr effizient unter direkter Adressierung zugegriffen werden - man spricht daher auch von konstantem Aufwand, da sich die Zugriffszeit nicht ändert, wenn das Feldobjekt wächst. Ebenso ist das Anhängen und Entfernen von Objekten am Ende effizient, nicht so hingegen am Anfang oder in der Mitte (linearer Aufwand). Der Container deque beherrscht ebenso wie vector das schnelle Einfügen von Objekten am Ende und dazu noch am Anfang des Feldes. Hingegen ist auch hier das Einfügen von Objekten in der Mitte sehr aufwendig. Auch dieser Containertyp unterstützt Random-Access-Operatoren, d.h. der Zugriff auf ein beliebiges Element ist sehr effizient. Der dritte sequenzielle Container ist der list-Container. Dieser Typ unterstützt nur sogenannte Bidirectional-Iteratoren. Dadurch ist ein direkter Indexzugriff wie bei den anderen Containern nicht möglich. Der Vorteil dieses Containers ist allerdings das effiziente Einfügen und Entfernen von Objekten an beliebigen Positionen des Feldes. Durch diese deutlichen Unterschiede sollte sich der Programmierer schon bei der Planung des Programms darüber im Klaren sein, welcher Containertyp am besten zu seinen Anforderungen passt.
Grundlegende Mitgliedsfunktionen der sequentiellen Containerklassen
[Bearbeiten]Sequenzielle Container besitzen Funktionen wie front()
oder auch back()
. Damit kann auf das erste bzw. letzte Element des Containers zugegriffen werden. Darüber hinaus gibt es Funktionen, die das Anhängen und Entfernen von Elementen am Ende eines Feldes erlauben (push_back()
, pop_back()
). Das Einfügen/Entfernen von Objekten am Anfang wird nur von den Typen deque und list beherrscht (mittels: push_front()
und pop_front()
). Das direkte Indizieren beherrschen vector und deque (mittels at()
bzw. mit operator[]
). Die folgende Tabelle soll dies vergleichend veranschaulichen.
vector
|
deque
|
list
| |
front()
|
x | x | x |
back()
|
x | x | x |
push_back()
|
x | x | x |
pop_back()
|
x | x | x |
push_front()
|
/ | x | x |
pop_front()
|
/ | x | x |
at()
|
x | x | / |
operator[]
|
x | x | / |
Darüber hinaus sind weitere Funktionen definiert, deren konkrete Implementierung vom jeweiligen Containertyp abhängig sind. Als Beispiel sei hier der vector-Container gewählt.
Funktionsname | Beschreibung |
assign
|
Zuweisen von Elementen zum vector |
begin
|
gibt einen Iterator auf das erste Element zurück |
capacity
|
gibt Anzahl an Elementen zurück die vom vector aufgenommen werden können |
clear
|
löscht alle Elemente |
empty
|
gibt wahr zurück wenn Container leer ist |
end
|
gibt einen Iterator auf das letzte Element zurück |
erase
|
löscht das Element oder eine Reihe von Elementen |
insert
|
fügt Elemente in den Container ein |
max_size
|
gibt die maximal mögliche Zahl von Elementen des Containers an |
rbegin
|
gibt einen reverse Iterator auf den Anfang zurück |
rend
|
gibt einen reverse Iterator auf das Ende zurück |
reserve
|
setzt eine minimale Kapazität des Containers |
resize
|
ändert die Größe des Containers |
size
|
gibt aktuelle Größe des Feldes zurück |
swap
|
tauscht den Inhalt des Containers mit einem anderen |
Adaptive Container
[Bearbeiten]Dies sind spezielle Container, die auch Containeradapter genannt werden. Insbesondere lassen sich auf diese Container keine Iteratoren definieren. Der Zugriff erfolgt ausschließlich über die Elementfunktionen dieser Typen. In der STL finden sich folgende Typen:
stack
queue
priority_queue
Interne Zahlendarstellung
[Bearbeiten]Wie bereits in der Einführung der Variablen erwähnt, sind Variablen nichts weiter als eine bequeme Schreibweise für eine bestimmte Speicherzelle bzw. eine Block von Speicherzellen. Die kleinste Speicherzelle, welche direkt vom Prozessor "adressiert" werden kann, ist ein Byte. Ein Byte besteht (heutzutage) aus 8 Bit. So stellen Sie ein Byte grafisch dar:
Das unterste Bit, Bit 0, wird als "niederwertigstes Bit" bezeichnet (englisch: "least significant bit: LSB"), das oberste Bit, Bit 7, als "höchstwertiges Bit" (englisch: "most significant bit: MSB").
Jedes dieser acht Bits kann den Wert 0 oder 1 annehmen. Damit kann ein Byte 28=256 verschiedene Werte annehmen: Wie diese 256 verschiedenen Werte "interpretiert" werden, ist abhängig vom Datentyp. Der C++ Datentyp, der genau ein Byte repräsentiert, ist char, also zu Deutsch: "Zeichen". Jeder Buchstabe, jede Ziffer, jedes der so genannten "Sonderzeichen" kann in einem solchen char gespeichert werden. (Zumindestens sofern Sie in Westeuropa bleiben. Für die vielen verschiedenen Zeichen z.B. der asiatischen Sprachen genügt ein char nicht mehr, doch dafür gibt es den Datentyp wchar_t, der größer als ein Byte ist und somit mehr Zeichen darstellen kann.)
Das Zeichen 'A' wird z.B. durch die Bitkombination 01000001 dargestellt, das Zeichen '&' durch 00100110. Die Zuordnung von Bitwerten zu Zeichen ist im (ASCII-Code) festgeschrieben, den heute eigentlich alle Computer verwenden. Im Anhang D ist der ASCII-Code aufgelistet.
Die Bitkombinationen eines Bytes können Sie natürlich auch als Zahlen auffassen. Die Bitkombinationen 00000000 bis 01111111 entsprechen dann den Zahlen 0 bis 127. Leider ist nun die Frage, welche Zahlen den Bitfolgen 10000000 bis 11111111 entspricht, nicht mehr so eindeutig. Naheliegend wären ja die Zahlen 128 bis 255. Es könnten aber auch die Zahlen -128 bis -1 sein. Um die zugrunde liegenden Zusammenhänge zu erläutern muss etwas weiter ausgeholt werden
Wie auch bei den Ganzzahltypen (und char gehört schon ein wenig mit dazu), wird zwischen "vorzeichenbehafteten" (englisch: "signed") und "vorzeichenlosen" (englisch: "unsigned") Zahlen unterschieden. Diese Unterscheidung existiert jedoch nur im "Kopf des Programmierers". Der Prozessor weiß nicht, ob der Wert einer Speicherzelle vorzeichenbehaftet ist oder nicht. Zum Addieren und Subtrahieren muss er das auch nicht wissen. Die gewählte "Zweierkomplement-Darstellung" gestattet es, mit vorzeichenbehafteten Zahlen genauso zu rechnen wie mit vorzeichenlosen. Nur beim Multiplizieren und Dividieren müssen Sie dem Prozessor mitteilen, ob er eine normale (vorzeichenlose) oder eine vorzeichenbehaftete Operation ausführen soll.
Dezimal | Bit-Stellen | ||||||||
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | ||
… | … | ||||||||
252 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | |
253 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | |
254 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | |
255 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | |
256 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
257 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
258 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
259 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
… | … |
Zur besseren Veranschaulichung die beispielhafte Binärdarstellung der Zahlen um 255 herum:
Sie sehen, dass die Zahlen ab 256 nicht mehr in die 8 Bits eines Bytes passen, das Byte "läuft über". Wenn Sie nun z.B. zu 255 eine 1 addieren, und das Ergebnis in einem Byte speichern wollen, wird das oberste Bit abgeschnitten und übrigt bleibt eine 0, die dann gespeichert wird.
Da bei dem Übergang von 255 zu 0 die Zahlen wieder von vorne anfangen, können Sie sich den Zahlenbereich auch als Kreis vorstellen. Entgegen dem Uhrzeigersinn (in Pfeilrichtung) "wachsen" die Zahlen, in die andere Richtung fallen sie. An irgendeiner Stelle erfolgt dann ein "Sprung". Wenn diese Stelle bei einer Berechnung überschritten wird, wird dies einen "Überlauf" genannt. Bei "unsigned" Zahlen unterhalb der 0, bei den "signed" Zahlen genau gegenüber:
Diese Darstellung negativer Zahlen hat einige Vorteile:
- Der Prozessor muss beim Addieren und Subtrahieren nicht darauf achten, ob eine Zahl vom Programmierer als vorzeichenbehaftet oder vorzeichenlos interpretiert wird. Beim Addieren zählt er entsprechend viele Zahlen "links herum", beim Subtrahieren entsprechend andersherum. Falls dabei ein Überaluf auftritt, ist das dem Prozessor "egal".
- Die "Sprungstelle", also der Überlauf, ist möglichst weit weg von der Null und den kleinen Zahlen, die am meisten benutzt werden, so dass Sie beim Rechnen (hoffentlich) nicht in den Bereich dieses Überlaufes kommen.
Diese Darstellung negativer Zahlen wird auch bei den anderen Ganzzahltypen angewendet, nur ist der Kreis dort wesentlich größer, und enthält mehr darstellbare Zahlen.
Während nun bei den Ganzzahltypen int, short und long festgelegt ist, dass sie standardmäßig immer "signed" sind (so können Sie in C++ auch signed int statt int usw. schreiben, es ist der gleiche Datentyp), konnte man sich bei char nicht einigen, ob mit oder ohne Vorzeichen praktischer ist. Folglich kann jeder Compilerhersteller dies nach eigenem Gusto entscheiden. Ob auf einem System der char-Datentyp nun vorzeichenbehaftet ist oder nicht, ist jedoch in der Praxis nicht wichtig, sofern Sie nicht mit ihnen rechnen wollen. (Zum Rechnen werden char-Variablen und -Literale stets erst in ein int umgewandelt. Erst dann wird z.B. aus einem Byte mit den Bits 11111111 der Wert 255 oder -1, je nach dem.)
Sie können jedoch explizit signed char oder unsigned char schreiben, wenn Sie sicher gehen wollen, dass Sie ein vorzeichenbehaftetes bzw. vorzeichenloses char bekommen. (Kurioserweise ist ein char ein eigener Datentyp, auch wenn er stets entweder einem signed char oder einem unsigned char gleicht. Es gibt also in C++ drei verschiedene char-Datentypen!)
Die übrigen Ganzzahltypen belegen mehrere Bytes, wie viele genau, können Sie herausbekommen, in dem Sie ein kleines Programm schreiben, das folgendes ausgibt:
std::cout << "Größe eines short: " << sizeof(short) << std::end;
std::cout << "Größe eines int : " << sizeof(int) << std::end;
std::cout << "Größe eines long : " << sizeof(long) << std::end;
sizeof( Typ_oder_Ausdruck ) gibt die Größe eines Typs oder Ausdrucks in Byte zurück. (Genauer: sizeof() gibt an, wie viele Bytes der Datentyp im Vergleich zu einem 'char' benötigt. sizeof(char) ist definitionsgemäß gleich 1, da keine kleinere Speichergröße direkt angesprochen werden kann.)
Die Größe der Datentypen ist jedoch nicht genau festgelegt und variiert von Compiler zu Compiler und von Prozessor zu Prozessor! Sie dürfen sich also nicht darauf verlassen, dass ein int immer soundsoviel Bytes groß ist! Das einzige, das im C++ Standard festgelegt ist, ist folgende Größenrelation:
sizeof(char) := 1 sizeof(long) ≥ sizeof(int) ≥ sizeof(short) ≥ 1 sizeof( X ) = sizeof( signed X ) = sizeof( unsigned X ) (für X = int, short, long, char)
Wissen Sie jedoch die Größe eines Ganzzahltyps, wüssten Sie auch, wie groß der Wertebereich ist, den er aufnehmen kann:
Größe in Byte | Wertebereich | |
unsigned |
signed
| |
1 (8bit) | 0 … 255 | -128 … +127 |
2 (16bit) | 0 … 65.536 | -32.768 … +32.767 |
4 (32bit) | 0 … 4.294.967.296 | -2.147.483.648 … +2.147.483.647 |
8 (64bit) | 0 … 18.446.744.073.709.551.616 | -9.223.372.036.854.775.808 … +9.223.372.036.854.775.807 |
Warum hat man die Größe der Datentypen nicht festgelegt?
Ganz einfach. Es existieren sehr verschiedene Prozessortypen. Es gibt so genannte 16-Bit-Prozessoren, das heißt, diese können 16-bittige Zahlen "in einem Rutsch" verarbeiten. Die nächste Generation waren die 32-Bit-Prozessoren, sie können 32-Bit-Zahlen besonders schnell verarbeiten. Heute sind 64-Bit-Prozessoren Standard, sie haben die 32-Bit-Prozessoren zumindest im Customer-Bereich weitesgehend verdrängt.
Wäre jetzt festgelegt, dass ein int immer 32-bit groß ist, wären C++ Programme auf 16-Bit-Prozessoren sehr langsam, da sie nur "häppchenweise" mit 32-bit-Werten rechnen können. Darum wurde festgelegt, dass ein int immer so groß sein soll, wie die so genannte "Wortbreite" des Prozessors. Das garantiert, dass Berechnungen mit int's immer am schnellsten ausgeführt werden können.
Mal als Überblick, welche Größen die Ganzzahl-Datentypen auf verschiedenen Plattformen haben:
Plattform | Größe des Datentyps (in Byte) | ||
short |
int |
long
| |
DOS (16 bit "Real Mode") | 2 | 2 | 4 |
Linux (auf 32bit-Plattformen wie x86, PowerPC u.a.) OS/2 (ab 3.0) MS-Windows (ab 9x/NT/2k) |
2 | 4 | 4 |
Linux (auf 64bit-Plattformen wie Alpha, amd64 u.a.) | 2 | 4 | 8 |
Sie sehen, dass unter Linux auch auf einem 64bit-System ein int nur 32 Bit groß ist. Vermutlich wurde sich für 32-bittige int's entschieden, weil es zu viele C- und C++-Programme gibt, die darauf vertrauen, dass ein int immer 32 bit groß ist. :-( Es ist aber nicht sicher, dass das auch in Zukunft so bleiben wird.
Wenn Sie low-level Code schreiben wollen/müssen, und auf die genaue Größe seiner Ganzzahltypen angewiesen sind, gibt es spezielle typedef's in dem Standard-Header <stdint.h>, auf welche später genauer eingegangen wird.
Zusammenfassend lässt sich also aussagen, dass ein int mehrere aufeinander folgende Bytes im Speicher belegt. Wie bereits erläutert, werden die Bits in einem Byte von 0 bis 7 gezählt, das Bit 0 ist dabei die "Einer-Stelle", das Bit 7 hat die Wertigkeit 128 (bzw. -128 bei vorzeichenbehafteten Bytes). Bei einem 2-Byte-short int ergeben sich also 16 Bits, und das Ganze sieht dann wie folgt aus:
Das MSB ist in diesem Falle das Bit 15. Die Bits 0..7 bilden das so genannte "Low Byte", die Bits 8..15 das "High Byte".
So weit so gut. Das Problem ist jetzt die Byte-Reihenfolge, also wie dieses "Doppelbyte" abgespeichert wird. Die einen sagen: Low-Byte zuerst, also das "untere Byte" an die "kleinere Adresse", das High-Byte in die darauf folgende, dies wird "Little Endian" genannt. Andere finden es natürlicher, wenn das High-Byte "zuerst" gespeichert wird, also an der kleinere Adresse im Speicher liegt. Dies nennt sich "Big Endian"-Speicherung. Diese beiden Methoden der Speicherung von Mehrbyte-Werten existieren auch bei 32- und 64-Bit-Werten.
Da sich in dieser Frage die verschiedenen Prozessor-Hersteller nicht einig wurden, existieren bis heute beide Varianten. Der eine Prozessor macht es so herum, ein anderer andersherum:
Prozessor-Plattform | "Endianness" |
---|---|
Intel x86 und kompatible | Little Endian |
Intel Itanium (64-Bit-CPU) | Little Endian |
Motorola 68k | Big Endian |
PowerPC | Vom Betriebssystem einstellbar |
Sun SPARC | Big Endian |
DEC Alpha | Vom Betriebssystem einstellbar |
Wichtig wird diese so genannte "Byte order" immer dann, wenn Daten in eine Datei gespeichert oder über ein Netzwerk auf andere Computer geschickt werden sollen. Denn falls die Datei später von einem Computer mit anderer Byte Order gelesen wird, versteht er die gespeicherten Daten nicht mehr richtig. Sie müssen also bei der Speicherung oder Übermittlung von Daten stets vorher festlegen, welche Byte Order man benutzt. Im Internet wurde dafür der Begriff "network byte order" geprägt. Dieser bedeutet: Big Endian.
Gleitkommazahlen
[Bearbeiten]Gleitkommazahlen werden heutzutage meist nach dem so genannten IEEE 754 Standard abgespeichert. Nach diesem Standard ist ein 32bit- (einfache Genauigkeit) und ein 64bit- (doppelte Genauigkeit) Gleitkommadatentyp definiert. Der 32-bit Gleitkommadatentyp ist dabei wie folgt aufgebaut:
Das Vorzeichen-Bit gibt dabei an, ob die Zahl positiv (Vorzeichen=0) oder negativ (Vorzeichen=1) ist. In der Mantisse werden nur die Binärstellen nach dem Komma gespeichert. Vor dem Komma steht implizit stets eine 1. Der gespeicherte Wert für den Exponenten ist um 127 größer als der echte Exponent. Die gespeicherten Werte von 0..255 für den Exponenten entsprechen also einem realen Wertebereich von -127...+128. Dabei sind die Werte -127 und +128 "reserviert" für spezielle Gleitkommawerte wie "null" oder "unendlich" (und noch einige andere, so genannte NaN's und 'denormalisierte Zahlen', worauf hier aber nicht weiter eingegangen wird). Der Wert einer 32-bit-Gleitkommazahl errechnet sich dabei wie folgt:
Der Wert "null" wird dabei mit Exponent 0 und Mantisse 0 gespeichert. Dabei kann das Vorzeichen 0 oder 1 sein, es wird also zwischen +0.0 und -0.0 unterschieden. Damit kann man z.B. erkennen, ob das Ergebnis einer Berechnung ein "sehr kleiner positiver Wert" war, der auf 0 gerundet wurde, oder ein "sehr kleiner negativer Wert". Ansonsten sind beide Werte identisch. Ein Vergleich (+0.0 == -0.0) ergibt also den Wert "true", also "wahr".
Der Wert "unendlich" wird mit Exponent 255 und Mantisse 0 gespeichert. "Unendlich" bedeutet dabei "größer als die größte darzustellende Zahl". Auch hier wird wieder zwischen "+unendlich" und "-unendlich" unterschieden. "Unendlich" kann dabei durchaus sinnvoll sein. So liefert die mathematische Funktion atan() (arcus tanges) für "unendlich" genau den Wert Pi/2.