Lade Inhalt...

Professional Programmer Series: C/C++

Einführung in die objektorientierte Programmierung

Skript 2009 274 Seiten

Informatik - Programmierung

Leseprobe

Inhalt

Vorwort

1 Prêt-á-porter oder Haute Couture?
1.1 Warum objektorientierte Betriebssysteme?
1.2 Warum objektorientierte Datenbanken?
1.3 Warum objektorientierte Programmierung?
1.3.1 Rapid Prototyping und schrittweise Verfeinerung
1.3.2 Designschwerpunkt liegt auf der Architektur
1.3.3 Produktivität und Sicherheit nehmen zu
1.3.4 Bessere Wartbarkeit und Erweiterbarkeit
1.4 Ojektorientierte Analyse und objektorientiertes Design?

2 Take off: Die Sprache C++
2.1 Für wen ist C++?
2.2 C und C++ sind sich sehr ähnlich
2.3 C und C++ sind sehr verschieden voneinander
2.4 Historie von C++

3 Die Basis von C++ - das klassische C
3.1 Die C++-Fibel
3.1.1 Wie C-Programme aufgebaut sind
3.1.2 Blockkonzept
3.1.3 Datentypen
3.1.4 Kontrollstrukturen
3.1.5 Operatoren
3.1.6 Der Präprozessor
3.1.7 Moderne Architekturen mit traditionellen Bausteinen?
3.2 Basiserweiterungen von C++ gegenüber dem klassischen C
3.2.1 Kommentare
3.2.2 Aufzählungs-Namen und Struktur-Namen
3.2.3 Definitionen im Blockinneren
3.2.4 Sichtbarkeitsoperator
3.2.5 Das Schlüsselwort const
3.2.6 Explizite Typkonvertierung
3.2.7 Funktions-Prototypen
3.2.8 Überladung von Funktionsnamen
3.2.9 Default-Parameter
3.2.10 Prototypen zu Funktionen mit Default-Parameter
3.2.11 Funktionen mit variablen Argumentlisten
3.2.12 Der Datentyp va_list
3.2.13 Die Makros va_start(), va_arg() und va_end()
3.2.14 Der Referenz-Operator
3.2.15 Funktionen mit Referenzparameter
3.2.16 Referenzen als Ergebnistyp von Funktionen
3.2.17 Das Schlüsselwort inline: Inline-Funktionen
3.2.18 Die Operatoren new und delete
3.2.19 Wenn der Heap-Speicher verbraucht ist
3.2.20 new, delete und Klassen
3.2.21 void-Pointer und void-Funktionen

4 Klasse, Objekt und Botschaft
4.1 Kapselung und abstrakter Datentyp
4.1.1 Klassen und Objekte
4.1.2 Abstrakte Klassen (abstract, deferred, pure classes)
4.1.3 static-Attribute, static-Elementfunktionen
4.2 Der Botschaftenmechanismus
4.3 Klassen und Objekte inneinander geschachtelt („Aggregation“)

5 Einfache Vererbung
5.1 Die Vererbung als zentrales Strukturierungsprinzip
5.1.1 Ähnlichkeiten zwischen Klassen
5.1.2 Vererbung: Strukturierung und Produktivitätssteigerung
5.2 Verschiedene Arten der Vererbung
5.3 Beispiel: die Klasse vektor erbt von der Klasse array
5.4 Der Destruktor
5.5 Der this-Zeiger
5.6 Aufruf des Basisklassenkonstruktors
5.7 Typische Modulstruktur, wenn Vererbung genutzt wird

6 Fortgeschrittene Möglichkeiten der Vererbung
6.1 Die "Erbschaft" verkleinern
6.2 Die Mehrfachvererbung
6.3 Die wiederholte Vererbung
6.4 Virtuelle Basisklassen und virtuelle Vererbung
6.5 Abstract, deferred, pure class und pure virtual functions

7 Polymorphismus
7.1 Early-Binding-Polymorphismus
7.2 Funktionsüberladung
7.3 Operatorüberladung
7.3.1 Operatorfunktionen als Elementfunktionen von Klassen
7.4 Freund-Funktionen (friend functions)
7.5 Late-Binding-Polymorphismus

8 Ausnahmebehandlung (Exceptionhandling)
8.1 Das Prinzip der Ausnahmebehandlung
8.1.1 Ausnahmeklassen
8.1.2 Der throw-Ausdruck löst den Ausnahmezustand aus
8.1.3 Der try Block
8.1.4 Mit catch wird der Ausnahmezustand abgefangen
8.1.5 Lebensdauer von Ausnahmeobjekten
8.1.6 Explizite Deklaration
8.1.7 terminate(), set_terminate(), unexpected() und set_unexpected()

9 Templates
9.1 Klassen-Templates
9.1.1 Ein einfaches Klassen-Template
9.1.2 Ein Klassen-Template mit mehreren Parametern
9.2 Funktions-Templates
9.3 Container-Klassen
9.4 Generische Container

10 iostream Ein-/Ausgabe
10.1 Die Ausgabe
10.1.1 Ausgabe-Manipulatoren
10.1.2 Ausgabe-Formatflags
10.2 Die Eingabe
10.2.1 Eingabe-Manipulatoren
10.2.2 Eingabe-Formatflags
10.2.3 istream-Elementfunktionen für die unformatierte Eingabe
10.3 Die Datei-Ein-/Ausgabe
10.3.1 Statusabfragen

11 Glossar

12 Register

13 Über den Autor

Vorwort

Mit C++ steht dem professionellen Programmierer eine faszinierende Sprache zur Verfügung: ob low-level oder high-level-Programme, also: ob sehr nahe an der Hardware oder sehr weit davon entfernt, ob technisch oder kommerzielle Applikation, die Sprache ist so flexibel, dass Sie in jedem Applikationsgebiet effizient eingesetzt werden kann. Im low-level-Bereich erübrigt sich der Abstieg auf die Assemblerebene und im high-level-Ensatz läßt sich die Idee der Software-ICs mit Hilfe der objektorientierten Features nahezu perfekt umsetzen.

Dass C++ eine hybride Sprache ist, mag dem puristischen Anhänger der Objektorientierung ein Dorn im Auge sein - wir empfinden diese hybride Natur der Sprache als Vorteil. Die Evolution lehrt uns, dass Teilnehmer an komplexen Systemen mit der Fähigkeit zu Kompromissen in aller Regel die bessere Durchsetztungsfähigkeit besitzen. In der hybriden Natur der Sprache sehen wir einen, der realen Welt sehr gerecht werdenden, Kompromiss: objektorientiert programmieren zu können, wo immer es möglich ist und beim prozeduralen Paradigma bleiben zu können, wo immer es notwendig ist.

C++ ist eine Sprache für den professionellen Programmierer, der in jeder Situation genau weiß, was er schreibt! Mit halbseidenem Wissen wird kein Programmierer mit C++ glücklich: kein Laufzeitsystem bügelt mangelndes Know-How aus, kein „väterlicher“ Compiler egalisiert in leichtsinniger Laune schlampig programmiertes. „What you get is what you write“ ist die Devise! Hier unterscheidet sich aber C++ nicht von anderen Profi-Werkzeugen: in der Hand von Laien entfalten sie ihr Potential nicht, ja richten vielleicht sogar Schaden an.

Dieser Leitfaden soll den Programmierer in die Sprache C++ und in die objektorientierte Programmierung einführen. Wir werden uns dabei auf den Sprachkern von C++ konzentrieren. Da weder betriebssystem- noch compilerspezifische Features genutzt werden, laufen die Beispiele auf allen Betriebssystemen und Prozessorplattformen.

Viel Erfolg!

1 Prêt-á-porter oder Haute Couture?

Kaum ein anderes Schlagwort beschäftigt die Softwerker mehr: Objektorientiertheit zieht sich wie ein roter Faden durch die Informatikwelt. Objektorientierte Betriebssysteme sind die Basis: darauf laufen objektorientierte Datenbanken, natürlich mit einer objektorientierten Programmiersprache geschrieben, nachdem vorher eine objektorientierte Analyse und ein objektorientiertes Design durchgezogen wurden. Zum Benutzer hin lacht eine objektorientierte, graphische Oberfläche. Und die Gestalt vor dem Computer denkt selbstverständlich objektorientiert! So mag es einem vorschweben - Objektorientiertheit als prêt-á-porter für alle. Die Realität sieht noch anders aus. Objektorientiertheit ist wohl zur Zeit eines der faszinierendsten und gleichzeitig am wenigsten verstandenen Informatik-Paradigmen: Objektorientiertheit ist heute noch eher in der Haute Couture der Softwareschneidereien zu finden.

Diese Serie soll mithelfen, diesen Zustand zu ändern. In der Objektorientiertheit liegt die Chance, der Softwareherstellung einen ungeahnten Produktivitätsschub zu geben. Die Objektorientiertheit darf schon aus diesem Grund kein Privileg der Großmeister bleiben, sie muss Allgemeingut in den Köpfen aller Informatiker werden, egal ob Analytiker, Designer oder Programmierer.

Bevor wir uns dem objektorientierten Programmieren zuwenden, wollen wir zuerst die strategische und tiefere Bedeutung der Objektorientiertheit verdeutlichen. Dieser veränderte Denkansatz, der Daten und Funktionen als untrennbare Einheit, eben als Objekt (siehe Bild), in den Mittelpunkt der Betrachtung stellt, ist jedoch keineswegs nur um seiner selbst willen interessant! Die damit erreichbaren Ziele versprechen einen enormen Fortschritt im Softwarebau. Dies wollen wir anhand der Fragestellung ausloten, warum die weiter unten erwähnten objektorientierten Betriebssysteme und Datenbanken so wünschenswert sind.

Abbildung in dieser Leseprobe nicht enthalten

Bild 1: Ein Objekt besteht aus Methoden und Daten.

1.1 Warum objektorientierte Betriebssysteme?

Mehrere Gründe lassen objektorientierte Betriebssysteme wünschenswert erscheinen: ein wesentlicher Aspekt ist die effiziente Unterstützung moderner Client-Server-Applikationen. Auf der anderen Seite lassen sich mit der Objektorientiertheit die seit langem, vor allem von den Anwendern angestrebten Eigenschaften wie Portabilität, Flexibilität, Skalierbarkeit und vieles andere mehr erreichen.

Ein Betriebssystem ist in erster Linie ein Ressourcenmanager, der die vom System für die Applikationssoftware zur Verfügung gestellten Hardware- und Software-Ressourcen sicher und schnell verwalten muss. Anspruchsvolle Applikationen lassen sich nur dann unternehmensweit verteilt einsetzen und wirtschaftlich herstellen, wenn sie von einer tragfähigen Plattform, also dem Betriebssystem, entsprechend unterstützt werden. Die effiziente Ausnutzung einer verteilten, heterogenen[1] Hardware- und Software-Infrastruktur verlangt auch betriebssystemseitig eine Client-Server-Architektur, die innerhalb eines Systems - aber auch über Systemgrenzen hinweg - verteilt ist. Diese verteilten Betriebssystemkomponenten, die sich innerhalb eines Computersystems idealerweise in einen Mikro-Kern und darum herum angeordnete Server gliedern (siehe Bild), müssen miteinander kommunizieren. Das aber genau ist die Stärke der Objektorientiertheit: unabhängige Objekte interagieren miteinander durch den Austausch von Botschaften (siehe Bild). Die Clients und Server werden also am besten als kooperierende Objekte entworfen und implementiert. Bei diesem Ansatz ergibt sich fast von selbst die Transparenz der Verteilung, d. h. die Kommunikation ist unabhängig vom Ort der Kommunikationspartner, sodass es für einen Client unerheblich ist, ob der zuständige Server lokal vorhanden ist oder von einem entfernten Rechner aus antwortet.

Abbildung in dieser Leseprobe nicht enthalten

Bild 2: Ein objektorientiertes Mikrokernsystem.

Ein Objekt ist eine autonome Kapsel (siehe Bild), die im Sinne eines abstrakten Datentyps sowohl Datenstrukturen wie auch Operationen (auch Methoden genannt) kapselt. Diese Operationen der Objekte manipulieren die objektinternen Daten. Angestoßen werden die Operationen von aussen durch eine Nachricht (auch Botschaft genannt), die von einem anderen Objekt kommt. Derartige Softwarearchitekturen auf der Applikationsebene müssen durch ein entsprechendes Leistungsangebot (Message Passing, Kapselung, Objektverwaltung, usw. ) auf der Betriebssystemseite unterstützt werden, um die maximale Effizienz der Applikationen zu gewährleisten.

Abbildung in dieser Leseprobe nicht enthalten

Bild 3: Ein Gesamtsystem wird in kooperierende Objekte gegliedert.

Ein nach diesem Paradigma konstruiertes Betriebssystem ermöglicht am ehesten auch eine flexible Adaptionsfähigkeit. Die Vielfalt der Betriebssysteme, vor allen in den Bereichen Automation und Telekommunikation, erfordert einen immensen Aufwand an Pflege, Wartung und Weiterentwicklung. Anwendungen für diese Spezialbetriebssysteme sind nicht portabel und der Programmierer dieser Applikationen braucht i. d. R. Spezialkenntnisse, die kaum auf andere Systeme übertragbar sind. Auf der Basis eines möglichst objektorientierten und hardwareunabhängigen Mikrokerns ließe sich eine weitgehende Vereinheitlichung erreichen: je nach Bedarf wird für einen speziellen Einsatzzweck durch das Hinzufügen von modularen Servern ein dediziertes Gesamtbetriebssystem konfiguriert. Diese systeminhärente, flexible Adaptionsfähigkeit stellt auch sicher, dass sich neuartige Applikationen rasch auf das System stellen lassen: überfordern etwa die kooperativen Arbeitsabläufe und multimedialen Datenströme zukunftsweisender Applikationen, die im gegebenen Betriebssystem vorhandenen Gegebenheiten, so sollten nur einzelne Server auf der Betriebssystemebene auszutauschen sein.

1.2 Warum objektorientierte Datenbanken?

Der Ruf nach objektorientierten Datenbankmanagementsystemen (OODBMS) hat im wesentlichen drei Gründe: zum einen brauchen die objektorientierten Anwendungsprogramme ein Objektarchiv. Zum zweiten versprechen OODBM-Systeme die notwendige Effizienz für die Archivierung sehr komplexer Informationsstrukturen. Und zuletzt: in einer objektorientierten Datenbank stecken prinzipiell mehr Möglichkeiten wie bspw. in einer relationalen Datenbank.

In vielen Fällen macht es Sinn, dass Prozesse ihre Objekte bei Programmende archivieren, um sie beim nächsten Programmlauf wieder zu animieren. Solche Objekte werden auch persistente Objekte genannt, weil sie von einem Programmlauf (Prozess) zum nächsten erhalten bleiben. Die Frage ist, wo sollen diese persistenten Objekte archiviert werden? Heute werden diese Objekte in der Regel in Datenfiles untergebracht. Manche objektorientierten Programmiersprachen bieten hierfür eine Standardlösung, andere Sprachen unterstützen persistente Objekte (noch) nicht und der Programmierer muss hierfür eine eigene Lösung entwickeln. Wie auch immer - dieser Ansatz funktioniert ganz gut, solange nur ein einziger Prozess mit dem Objektarchiv arbeitet. Sind mehrere Prozesse an den Objekten im Archiv interessiert, so muss dieser Zugriff synchronisiert[2], d. h. aufeinander abgestimmt werden, sonst werden inkonsistente Informationen genutzt oder festgeschrieben. Darüberhinaus ist ein entsprechender Zugriffsschutz zu implementieren: nicht jeder Prozess darf unbedingt mit allen Objekten nach Gutdünken verfahren. Außerdem muss das Objektarchiv garantieren, dass keine Objekte verloren gehen bei Systemabstürzen, Platten- und anderen Hardware-Defekten. Schon diese hier angesprochenen Anforderungen zeigen: eigentlich wäre ein Datenbanksystem das geeignete Objektarchiv, denn Datenbanksysteme verfügen im allgemeinen über die oben gewünschte Funktionalität. Die Sache hat jedoch einen Haken. Konventionelle Datenbanken sind nur für die klassischen Datentypen aufnahmebereit (zahlen- und zeichenartige Datentypen und Kompositionen daraus). Die Typenvielfalt[3] der Objekte überfordert diese konventionellen Systeme. Lösungen, die hier angeboten werden, sind Objektkonverter für relationale Datenbanken: ein Objekt wird vor der Ablage in entsprechende Einzelteile zerlegt. Beim Herausholen aus der relationalen Datenbank wird das Objekt vom Objektkonverter wieder zusammengebaut. Dieses Zerlegen und Zusammenbauen kostet jedoch erheblich Zeit! Besser ist da schon eine objektorientierte Datenbank, in die Objekte in ihrer Ganzheit eingebracht und wieder ausgegeben werden können. Dabei ist es nicht nur der Vorteil, dass die Zeit des Auseinander- und Zusammenbaus gespart wird! Objektorientierte Datenbanken sind prinzipiell leistungsfähiger als relationale Datenbanken. Komplexe, mächtige Strukturen darstellen zu können, liegt in der Natur der Objekte und was man mit ihnen machen kann: Ableitungen und Zusammensetzungen bilden, sie Botschaften austauschen lassen, u. v. a. m. Und das vielleicht Erstaunlichste dabei: dies alles läßt sich pflegeleicht, redundanzfrei und effiezient realisieren Auf diese Weise können die objektorientierten Datenbanken das Erbe des netzwerkorientierten DB-Modells (ohne dessen Nachteile) antreten, wenn es um die Darstellung komplexer Strukturen geht. Auf natürliche Weise können über ein- und dieselbe Datenmenge (genauer Objektmenge) mehrere Strukturen gelegt werden, die jeweils für sich bedeutsam und vollkommen unabhängig voneinander sind (siehe Bild).

Abbildung in dieser Leseprobe nicht enthalten

Bild 4: Mehrfachstrukturen in der OODB

Ein weiterer Pluspunkt: die Flexibilität. Mit Hilfe bestimmter Objekttechniken (vor allem der Vererbung) lassen sich die eingebauten Datentypen bedarfsweise um applikationsspezifische Klassen (die u. a. von den eingebauten Klassen abgeleitet sind) ergänzen. Damit lassen sich OODB-Systeme für Aufnahme jeglicher Art von Informationen präparieren (durch den Datenbankanwender wohlgemerkt). Damit noch nicht genug! Die Objekte in der Datenbank müssen darin nicht nur passiv gelagert sein, die Objekte können durchaus aktiv sein! Damit lassen sich auf elegante Art Trigger[4] und sich selbst überwachende Daten (pardon: Objekte) implementieren.

All diese phantastisch anmutenden Möglichkeiten auf dem Datenbanksektor hier detaillierter auszuführen, würde den Rahmen dieser Arbeit sprengen. Der Autor will damit sein Plädoyer für die Objektorientiertheit untermauern und verdeutlichen, warum die Objektorientiertheit zur Mainstream-Technologie der Informatik geworden ist.

Nach den beiden Ausflügen in die Welt der Betriebssysteme und Datenbanken wollen wir uns wieder unserem eigentlichen Thema, der Softwarekonstruktion zuwenden.

1.3 Warum objektorientierte Programmierung?

Mit den Beispielen vom OOOS[5] und dem OODBMS[6] mag OOP[7] schon mehr als hinreichend begründet sein. Wir wollen aber noch genauer darauf eingehen, welche Vorteile sich schon während der Implementierungsphase durch die Anwendung der Objekttechnik ergibt.

1.3.1 Rapid Prototyping und schrittweise Verfeinerung

Der Programmierer kann rasch für neue Applikationen und Projekte Objekte entweder von bestehenden ableiten, oder neu entwickeln, ohne befürchten zu müssen, Details festzulegen, die eine spätere Änderung erschweren.

Hierzu ein Beispiel: Nehmen wir einmal an, unsere Applikation benötigt sortierte Daten. Nun kann ein Programmierer des Teams eine geeignete Klasse mit entsprechenden Schnittstellen definieren und dem Projektteam zur Verfügung stellen. Existiert im Projekt noch keine für eine Ableitung taugliche Basisklasse, so kann zunächst ein einfacher Algorithmus auf einem Array implementiert werden. Der verwendete Algorithmus und die implementierte Datenstruktur sind vielleicht ineffizient, können aber durchaus hinreichend für einen Prototyp sein. Im Laufe des Projektfortschritts läßt sich im Rahmen der Verfeinerung, wenn es angezeigt ist, das Array durch eine dynamische Liste ersetzen und der ursprüngliche Sortieralgorithmus wird ebenfalls ausgetauscht und arbeitet nun optimal mit der neuen Datenstruktur zusammen. Auf Grund der Kapselung haben all diese Änderungen nur minimalen oder gar keinen Einfluß auf den Rest des Systems!

1.3.2 Designschwerpunkt liegt auf der Architektur

Der Designschwerpunkt wird auf die Architektur gesetzt statt auf die Implementierungsdetails. Objektorientiertes Programmieren zwingt den Programmierer, den Schwerpunkt seiner Überlegungen zunächst auf das Design guter Klassen zu konzentrieren und sich weniger von funktionalen low-level Implementierungsdetails während des Designs leiten zu lassen. In den Fällen, wo von Basisklassen abgeleitet werden kann, entfällt die Implementierung sogar weitgehend!

Im Rahmen der schrittweisen Verfeinerung wird die Lösung also zunächst mit einer minimalen Implementierung skizziert und dann laufend - Stück für Stück - verfeinert, vervollständigt und verbessert. Auf diese Art und Weise lassen sich Fehler so frühzeitig feststellen, dass sie nicht erst später zum großen Problem werden können. Durch den Einsatz des arbeitsfähigen Prototyps lassen sich außerdem Designentscheidungen validieren, bevor sie festgeschrieben werden.

1.3.3 Produktivität und Sicherheit nehmen zu

Wie sieht die Arbeit zahlreicher Programmierer heute aus? Zeichen für Zeichen, Zeile für Zeile, Seite für Seite werden heute noch Programme in der gleichen Art geschrieben, wie in der Steinzeit der Programmierung Anfang der fünfziger Jahre unseres Jahrhunderts. Für den Hardwareingenieur hat sich die Welt seit damals dramatisch verändert: verwendete die damalige Ingenieurgeneration zum Aufbau der Computerelektronik diskrete elektronische Bauteile um die logischen Gatter zu implementieren, so ist der heutige Elektroniker und Systemdesigner meilenweit davon entfernt - er verwendet standardisierte Bausteine aus den Bausteinkatalogen der großen Halbleiterhersteller. Ein solcher Baustein, Hardware-IC[8] genannt, ist bezüglich seiner Schnittstellen, seinen elektronischen und logischen Eigenschaften genau spezifiziert und kann vom Benutzer als "black box" gesehen werden. Diese Bausteine sind ausgetestet, bewährt und zuverlässig; dafür sorgt der IC-Hersteller.

Im gleichen Maße wie der Hardware-IC von den Details der Gatterimplementierung abstrahiert, steigt die Produktivität des Hardwaredesigners. Genau dieser Effekt ist auch für den Softwareentwickler anzustreben.

Der Softwareentwickler muss auf Software-ICs[9] zugreifen können, um eine vergleichbare Erhöhung der Effizienz und Produktivität zu erleben. Um solche Software-ICs zu verwirklichen, sind in den vergangenen Jahren zahlreiche Anstrengungen unternommen worden. Der Durchbruch ist aber erst der objektorientierten Softwaretechnik gelungen: durch die strenge Isolation von Eigenschaften hinter den definierten Schnittstellen der Objekte kommt man zu Software-Moduln, die sich in verschiedenen Projekten und Produkten immer wieder einsetzen lassen.

Zum Quantensprung im Bereich der Effizienz bekommt man als Morgengabe obendrein die Sicherheit der Software verbessert und deren Komplexität reduziert. Die gesteigerte Sicherheit ergibt sich aus der nur begrenzten äußeren Beeinflußbarkeit der Objekte. Objekte sind gegen das pathologische Verhalten anderer Objekte weitgehend immun, da sie ja als Botschaftenempfänger für die Ausfühung der korrespondierenden Methoden selbst zuständig sind. Durch die strenge Isolation von Eigenschaften in den Objekten ist obendrein der Übeltäter leichter als bisher zu identifizieren. Die Reduktion der Komplexität der Software ergibt sich durch das Verbergen der Details in den Objekten. Die Objekte sind als Bausteine auf einem bestimmten Abstraktionsniveau definiert und der Umgang mit diesen Bausteinen setzt keine Detailkenntnisse voraus.

Abbildung in dieser Leseprobe nicht enthalten

Bild 5: Fehlbehandlung ausgeschlossen.

1.3.4 Bessere Wartbarkeit und Erweiterbarkeit

Überraschenderweise unterstützen die gleichen objektorientierten Konzepte, die das Rapid Prototyping unterstützen, auch die Software-Wartung und Pflege. Wenn Schnittstellen zwischen den abstrakten Datentypen sorgfältig entworfen sind, läßt sich die Fehlerbehebung oder die Erweiterung um zusätzliche Funktionalität mit nur einem minimalen Einfluß auf andere Systemteile durchführen, weil der Ort des Eingriffs genau definierbar ist und die Abhängigkeiten überschaubar sind. Dieser Sachverhalt hat zwei wesentliche Nebeneffekte: Erstens läßt sich mit hoher Sicherheit ausschließen, dass sich durch Fehlerkorrekturen neue Fehler einschleichen und zweitens unterstützt damit die objektorientierte Programmierung das Entwerfen und Schreiben änderungsfreundlicher Software.

1.4 Ojektorientierte Analyse und objektorientiertes Design?

Warum sollen vor der Programmierung, während der Analyse- und der Designphase, die Objekttechniken von Vorteil sein? Muss der Architekt die gleichen Methoden für die Planung verwenden wie der Maurer für den Bau? Der Hausarchitekt kann das auch überhaupt nicht. Mittels der Objekttechnik sind Informatiker allerdings in der glücklichen Lage, dass der Designer die gleiche Methode verwenden kann wie der Programmierer, und das bringt schon substantielle Vorteile. Eine konventionelle Spezifikation entsprechend einer strukturierten Methode (z. B. SADT oder SA/SD) ist für die objektorientierte Programmierung nur sehr eingeschränkt brauchbar. Zum einen unterscheiden sich Terminologie und Darstellung zwischen dem strukturierten Design und der objektorientierten Implementierung, mit der Konsequenz, dass sich Designer und Implementierer auf Anhieb nicht verstehen. Zum zweiten müssen die strukturierten Darstellungen der Spezifikation vom Implementierer in die Objektorientiertheit übersetzt werden, was praktisch einem Redesign gleichkommt und mit allen Problemen von Abbildungen einer Methode auf eine andere verbunden ist (Fehlinterpretation, Informationsverlust, ...).

Erst wenn auch in der Analyse- und Design-Phase objektorientiert gedacht, notiert und gesprochen wird, haben wir es mit einer bruchlosen Technologie über den gesamten Lebenszyklus eines Softwaresystems zu tun, die uns außerdem den Vorteil einer einheitlichen Terminologie über alle Phasen hinweg beschert und dadurch letztlich auch die Design-Phase auf eine geringere Distanz zur Implementierungsphase bringt.

Es hat sich bisher noch keine einheitliche Vorgehensweise für die OOA[10] und die OOD[11] herausgebildet. Es gibt aber verschiedene Vorschläge, die einen guten Eindruck machen und durch Werkzeuge unterstützt werden. Wir werden auf das Thema OOA/OOD zurückkommen, wenn wir mit OOP vertraut sind. Wenn wir die Paradigmen der Objektorientiertheit kennen, werden wir konkret die durch sie gegebenen Vorteile in den frühen Phasen Analyse und Design benennen können.

2 Take off: Die Sprache C++

Nachdem wir bisher über die Tragweite der Objektorientiertheit philosophiert haben, wollen wir uns mit dieser Technik selbst auseinandersetzen. Für den praktischen Teil benutzen wir hierfür C++.

Ausgehend vom prozeduralen Paradigma will die Serie in die objektorientierte Denkweise und Programmierung einführen. Zu diesem Zweck wird auch der Darstellung der Terminologie ein breiter Raum eingeräumt. Es wird bewußt darauf verzichtet, englische Begriffe einzudeutschen. Wir benutzen also die Terminologie der amerikanischen Kollegen und erleichtern so dem Leser die breite Orientierung. Als Programmiersprache nutzen wir C++. C++ wird, davon sind wir überzeugt, den Stellenwert von C einnehmen. Dafür sprechen mehrere technische Gründe, die im Verlaufe der Serie mehrfach herausgearbeitet werden und an dieser Stelle noch beiseite gelassen werden können. Ein ganz anderer Grund ist die Tatsache, dass C++ ein technologisch hochwertiges Werkzeug ist, das dem heutigen C-Programmierer vermutlich auf Jahre hinaus eine stabile Plattform bietet, unter Einbeziehung seines heutigen Know Hows!

Wo soll eine Darstellung der Sprache C++ beginnen? Sollen wir annehmen, dass der Leser die Untermenge C bereits kennt und er sich deswegen auf die Darstellung der objektorientierten Paradigmen und deren konkrete Umsetzung in C++ konzentrieren kann? Oder soll eine Einführung in C++ bei "Adam und Eva" beginnen, sprich bei der Sprache C? Nun, wir werden hier einen Kompromiss eingehen! Wir nehmen an, dass die geneigte Leserschaft nicht nur aus C-Programmierern besteht. Auch Cobol-, Fortran-, Pascal-, Modula- und Ada-Programmierer werden unter den Lesern[12] sein. Da die prozedurale, imperative Programmierart ja all diesen Programmierern vertraut ist, genügt hier eine knappe Darstellung von C. Ich denke, auch Programmierer aus dem Smalltalk-, Prolog- und Lisp-Lager dürften sich mittels der knappen, informalen und mit Beispielen durchsetzten Darstellung von C in der prozeduralen Welt rasch zurecht finden.

2.1 Für wen ist C++?

C++ kommt in erster Linie für alle C-Programmierer in Frage, ist C++ doch die Sprache, die für diese Programmierer Vertrautes beinhaltet und zusätzlich neue, leistungsfähige Konzepte bietet. Durch seine objektorientierten Konzepte ist C++ ein mächtigeres Werkzeug als C. Doch schon alleine die nicht objektorientierten Erweiterungen von C++ gegenüber C rechtfertigen den Vorzug von C++ gegenüber dem klassischen C. Da C++ ein Hybrid[13] (aus objektorientierter und traditioneller Sprache) ist, kann der C-Programmierer sich Stück für Stück von den neuen Konzepten und Denkweisen aneignen. Ein Vorteil, der von keiner anderen objektorientierten Sprache in dieser Form angeboten wird. Jenseits dieser Zielgruppe ist die Sprache aber grundsätzlich für alle geeignet, die die Vorteile der abstrakten Datentypen und der objektorientierten Programmierung nutzen wollen und gleichzeitig auch die Laufeffizienz von C brauchen. Ob sich die C++-Interessenten auf PCs, Workstations, Midrange-Rechnern oder Mainframes tummeln, spielt keine Rolle: C++-Compiler gibt es für Computer aller Größenordnungen.

Da C++ eine Erweiterung von C ist, wird der C++-Compiler auf gleiche Weise eingesetzt wie der bisherige C-Compiler: der Vorgang des Compilierens und Bindens bleibt wie gewohnt erhalten. Um den maximalen Vorteil von C++ zu nutzen, bedarf es jedoch eines anderen Programmieransatzes. Insbesondere betrifft das auch die frühen Phasen der Softwareentwicklung: Der objektorientierte Gedanke muss bereits bei der Analyse und dem Design gegenwärtig sein. Noch eine Beobachtung: C++ macht in der Regel aus guten C-Programmierern noch bessere, aber aus schlechten C-Programmierern werden nicht notwendigerweise gute C++-Programmierer.

2.2 C und C++ sind sich sehr ähnlich

Der prozedurale Teil von C++ ist weitestgehend abwärtskompatibel zu C. Ein bestehendes C-Programm bedarf in der Regel nur weniger oder keiner Änderungen, um ein C++ Programm zu werden, dies ist dann selbstverständlich noch nicht objektorientiert, aber vom C++-Compiler übersetzbar. C++ steht in der Tradition von C: selbst die fortschrittlichen Konzepte sind effizient implementiert und ein teures Laufzeitsystem ist nicht notwendig. C++-Programme werden in der gleichen Weise übersetzt und gebunden wie C-Programme. Separate Compilierun g, die Verwendung von Standardbibliotheken und die Einbindung von Fremdsprachenmoduln sind in gleicher Weise möglich.

2.3 C und C++ sind sehr verschieden voneinander

Bei genauer Betrachtung ergeben sich jedoch gravierende Unterschiede zwischen C und C++. Im allgemeinen sind gut geschriebene C++ Programme[14] auf Quellcode-Ebene zwischen 20% und 50% kleiner als entsprechende C-Quellen. C++ unterstützt ein Modul-Design auf höherer Ebene als C. Hier verhält sich C++ zu C so wie sich C zu Assembler verhält. In C++ stehen die Klassen im Mittelpunkt der Überlegungen. In dem Maße wie Datentypen entworfen werden, gilt es auch ihre Attribute und Operatoren zu definieren. Damit wird ein hastiges Design erschwert. C++ unterstützt mittlere und große Programme besser als C. Um diesen Vorteil auszuschöpfen, bedarf es aber eines anderen Design- und Programmieransatzes.

2.4 Historie von C++

C++ ist das Resultat von Forschungsanstrengungen zur Erweiterung der Sprache C, um Datenabstraktionen und objektorientierte Programmierung zu unterstützen. Im wesentlichen wurde diese Arbeit von Bjarne Stroustrup in den AT&T Bell Laboratories geleistet.

Abbildung in dieser Leseprobe nicht enthalten

Bild 6: Der Sprachenbaum

Bjarne Stroustrup motivierte[15] die Entwicklung von C++ so: "The Language was originally invented because the author wanted to write event-driven simulations for which Simula67 would have been ideal, except for efficiency considerations". Auslöser der Sprachentwicklung war also der Wunsch, Programme zu entwickeln, die bei maximaler Ausführungsgeschwindigkeit mit nur minimaler Codegröße aufwarten.

Hier einige wichtige Entwicklungsschritte: Im August 1981 veröffentlichte Stroustrup den Artikel "Classes: An Abstract Data Type Facility for the C Language". Dezember 1984: C++ ist offiziell außerhalb von AT&T verfügbar. Hierbei handelt es sich um Release E, das im Rahmen einer "educational license" vor allem an US-Universitäten vergeben wurde. November 1985: Ab nun ist C++ kommerziell verfügbar. Dieses Release 1.0 kostete ca. 2000$. Dieses Release implementierte die Sprache C++, so wie sie in Stroustrups Buch "The C++ Programming Language" beschrieben wird. Juli 1986: Version 1.1 von C++ wird ausgeliefert. Der Compiler hat weniger Fehler, ist schneller und produziert besseren Code. Diese Version enthält auch einige Erweiterungen gegenüber dem Stroustrup-Buch: Zeiger auf Klassenelemente und geschützte Klassenelemente werden eingeführt. September 1987: Version 1.2 von C++ wird ausgeliefert. Einige interne Verbesserungen (vor allem die Benamung generierter Variablen) gestatteten nun, dass der erzeugte C-Code von mehreren C-Compilern übersetzt werden kann. Außerdem ist ab nun die Überladung von unsigned int und unsigned long Funktionen möglich. Mitte 1989: Version 2.0 von C++ wird ausgeliefert. Diese Version enthält einige wesentliche Verbesserungen: Mehrfachvererbung, inklusive virtueller Basisklassen; typsicheres Binden überladener Funktionen; reihenfolgeunabhängiges Overload-Matching; Default-Zuweisung geht jetzt elementweise statt bitweise. Die Operatoren new und delete können für jede Klasse überladen werden. 1990: Version 2.1 ist da. Im konventionellen Teil ist die Sprache jetzt fast 100% kompatibel zu ANSI-C (auch als C90 bekannt).

Im ANSI-Kommitee (X3J16) zur Normierung von C++ sind mehr als 40 Firmen vertreten. Ausgangsbasis für die Normierung war die Version 2 von C++ und die von Bjarne Stroustrup vorgeschlagenen Erweiterungen[16]. Wir halten uns an den Standard ISO/IEC 14882 „Standard for the C++ Programming Language“. ISO/IEC-Standardisierungen wurden dann in den Jahren 1998 und 2003 zum Abschluß gebracht.

3 Die Basis von C++ - das klassische C

Haben wir in der ersten Folge die Vorteile der Objektorientiertheit dargestellt, so wenden wir uns in diesem Beitrag der Programmiersprache C++ zu. In der C++-Fibel werden wir uns zunächst mit dem konventionellen, also nicht objektorientierten Teil von C++ auseinandersetzen. Diesen Part kann der C-Kenner sehr rasch überfliegen oder auch ganz auslassen: im nicht objektorientierten Teil ist C++ weitestgehend identisch mit dem klassischen C. Wir richten uns mit der C++-Fibel und dem darin dargestellten Subset C an die Cobol-, Fortran- Pascal-, Modula-, Ada-, Smalltalk-, Prolog- Lisp- und Assembler-Programmierer, die in C++ einsteigen wollen und über noch keine C-Kenntnisse verfügen. Die nicht objektorientierten Erweiterungen und Neuigkeiten von C++ stellen wir zusammengefaßt nach der C++-Fibel vor. Hier kann dann der C-Profi einsteigen.

3.1 Die C++-Fibel

Das Skelett der Sprache ist im wesentlichen gegeben durch die Datentypen, die Kontrollstukturen und die Operatoren. Die Grundzüge dieser Konzepte sollen zunächst dargestellt werden.. Wir werden sehen, wie Programme aufgebaut sind, was es mit dem Blockkonzept auf sich hat und uns dann mit den Datentypen auseinandersetzen. Wir lassen die skalaren und zusammengesetzten Datentypen Revue passieren und schließen dieses Thema mit einer Betrachtung der Typkonvertierung ab. Nachdem wir mit dem Datenkonzept der Sprache in den Grundzügen vertraut sind, wenden wir uns den Konstrollstrukturen zu: wir werden alle Schleifen- und Selektionsanweisungen sehen. Was jetzt noch fehlt sind die Operatoren. C++ bietet sehr viele Operatoren an. Die werden wir nicht alle im Einzelnen vorstellen. Auf einige wenige werden wir aber doch detallierter eingehen, weil sie doch maßgeblich für das typische C-Feeling verantwortlich sind. Am Ende der C++-Fibel werden wir noch kurz den Präprozessor behandeln. Insgesamt haben wir damit die Grundlage gelegt, um darauf mit den objektorientierten Konzepten aufzubauen.

3.1.1 Wie C-Programme aufgebaut sind

Vereinfacht ausgedrückt bestehen C-Programme aus einer Sammlung von Funktionen. Bei kleinen Programmen stehen sämtliche Funktionen in einem einzigen Modul[17]. Bei mittleren und großen Programmen werden die Funktionen zweckmäßigerweise in mehreren Moduln untergebracht. Die konstruktiven Einheiten, mit denen die Modulbildung betrieben wird, sind also die Funktionen. Prozeduren, als selbständige syntakische Elemente, sind als solche nicht in der Sprache enthalten. Das Prozedurkonzept wird vielmehr durch die Funktionen abgedeckt.

Funktionen können so geschrieben weden, dass sie ein Ergebnis zurückliefern, sonst aber keine Datenobjekte der aufrufenden Funktion manipulieren: die aufgerufene Funktion bekommt ihre Parameter mittels call by value und gibt einen Returnwert zurück. C-Funktionen dieser Art entsprechen den Pascal-Funktionen. Andererseits können aber C-Funktionen auch so gestaltet werden, dass sie keinen Returnwert zurückgeben und ihre Wirkung somit ausschließlich in der Manipulation von Datenobjekten des Aufrufers besteht. Diese C-Funktionen werden als void-Funktionen bezeichnet und entsprechen den Pascal-Prozeduren. Wird in einer non-void Funktion kein expliziter Return-Wert vereinbart, so gilt der Return-Wert der Funktion als undefiniert. Im übrigen bestimmt der Datentyp des zurückgereichten Wertes den Typ der Funktion.

Funktionen sind Einheiten, die parametriert werden können. Hier entspricht C ganz den gängigen Hochsprachkonzepten. In den Funktionsköpfen sind die formalen Parameter als Positionsparameter aufzulisten und der Typ jedes einzelnen formalen Parameters ist zu spezifizieren. Diese Funktionsschnittstelle wird in aller Regel vor ihrer Verwendung mittels eines Prototyps bekannt gemacht. Damit hat der Compiler die Möglichkeit, an der Aufrufstelle zu prüfen, ob formale Parameter hinsichtlich Typ und Anzahl, sowie der formale und der aktuelle Returnwerttyp zusammenpassen. Dieser Prototyp darf aber auch fehlen: dann werden für Funktionsaufrufe keinerlei Prüfungen durchgeführt. Es wird nicht geprüft, ob im Funktionsaufruf die Anzahl der aktuellen Parameter und deren Typen mit der Anzahl und den Typen der formalen Parameter in der Funktionsdefinition übereinstimmt. Dies ist eine Reminiszenz an die Kernighan-Ritchie-Version von C und alte C-Programme bleiben mit unveränderter Semantik übersetzbar.

Übergeben werden die Parameter standardmäßig "by value", das heißt, die aufgerufene Funktion bekommt Kopien von den aktuellen Parametern. Ist der zu übergebende Parameter jedoch ein Array, so erfolgt die Parameterübergabe "by reference": die aufgerufene Funktion bekommt die Adresse der aktuellen Parameter und kann nun diese direkt bearbeiten. Funktionen können in C außer über Parameter und Returnwerte auch über globale Variablen und Betriebssystem-Dienste kommunizieren. Insgesamt ist das Funktionskonzept durchaus griffig. Leider ist noch eine Fußangel enthalten, die C-Novizen gelegentlich zu schaffen macht: Beim Funktionsaufruf finden nämlich implizite Typkonvertierungen statt, die man kennen muss, um erfolgreich programmieren zu können. Mit diesen Konvertierungen setzten wir uns weiter unten auseinander.

Alles in einer Datei ...

Abbildung in dieser Leseprobe nicht enthalten

... oder in mehreren Dateien

In der einen Datei: .

Abbildung in dieser Leseprobe nicht enthalten

Bild 7: Funktionen als Modulinhalte

In der Aufteilung eines komplexen Programms in mehrere kleine Funktionen sollten sich die verschiedenen Ebenen der Modellbildung (Abstraktionsstufen) wiederspiegeln. Bei einem so gestalteten Systemdesign ist die Menge aller Funktionen dann hierarchisch strukturiert, sodass die "niederen" Funktionen (die "Mechanismusroutinen") die Werkzeuge der "höheren" Funktionen (der "Strategieroutinen") sind ("Strategie-Mechanismus-Prinzip"). Da C-Compiler separate Compilierung erlauben und Unix über ein hierarchisches Dateisystem verfügt, lassen sich auf Unix-Systemen die Zerlegungsstrukturen auch anschaulich darstellen, indem einzelne Funktionen in entsprechend im Dateissystem angeordneten Dateien untergebracht werden.

3.1.2 Blockkonzept

Die nächstkleinere Strukturierungseinheit sind Blöcke innerhalb der Funktionen. Die Blockbildung erfolgt mit geschweiften Klammern und spielt insbesondere bei den Kontrollstrukturen eine Rolle. Das Blockkonzept ist von Algol 60 übernommen und dient wie bei allen von Algol abstammenden, blockorientierten Sprachen dazu, um den Geltungsbereich und die Lebensdauer von Programmgrößen, wie z. B. Variablen und Funktionen zu regeln. Zusätzlich zum Blockkonzept stellt C noch einen weiteren Mechanismus zur Steuerung des Geltungsbereichs zur Verfügung: das Speicherklassenkonzept. Wir gehen darauf an anderer Stelle ein.

Blöcke können in C geschachtelt werden, nicht jedoch Funktionen. Also abweichend von der Pascal-Manier sind sämtliche Funktionen innerhalb einer Quelldatei sequentiell anzuordnen, das Ineinanderschachteln von Funktionen ist nicht möglich. (Siehe Beispiel oben.) Werden Funktionen vor ihrer Definition verwendet, so sollte der Programmierer, wie bereits erwähnt, diese Funktion tunlichst in einer Vorwärtsdeklaration oder besser mittels eines Prototyps deklarieren. (Im Beispiel oben ist der Prototyp zu sehen.) Der Compiler erzwingt Prototypen und Vorwärtsdeklarationen zwar nicht, aber im Sinne einer typsicheren Syntaxprüfung der Aufrufstelle sollte ein Protoyp nie fehlen.

3.1.3 Datentypen

Von der Qualität der angebotenen Datentypen hängt ganz wesentlich ab, wie natürlich sich eine Anwendung mit Hilfe einer Sprache modellieren läßt. Diesbezüglich ist schon das Angebot des klassichen C als gut zu bezeichnen; um die Daten technischer, kommerzieller oder wissenschaftlicher Applikationen zu modellieren, stehen genügend Standarddatentypen zur Verfügung. Zusätzliche, eigene Datentypen lassen sich leicht zusammensetzen. C++ bietet vor allem im Bereich der zusammengesetzten Datentypen einige, von der Objektorientierheit geprägte, neue Datentypen. Diese wollen wir zunächst noch nicht betrachten, da wir hier zunächst die Grundlagen für diese leistungsfähigeren, objektorientierten Datentypen kennenlernen wollen. In der folgenden Tabelle werden die Schlüsselworte für die skalaren und zusammengesetzten Datentypen vorgestellt.

Abbildung in dieser Leseprobe nicht enthalten[18]

Bild 8: Datentyp-Schlüsselworte[19]

3.1.3.1 Skalare Datentypen

An skalaren Datentypen bietet C hinsichtlich Umfang und Qualität das gleiche wie etwa Pascal. Skalare Datentypen gibt es für ganze Zahlen (Typ int), für reelle Zahlen (Typ float) und für Zeichen (Typ char). Von den ganzzahligen Objekten gibt es implementierungsabhängig bis zu drei verschiedene Größen: zusätzlich zum Typ int die Typen long und short. Auch vorzeichenlose ganze Zahlen lassen sich definieren (Typ unsigned). Neben den einfachen Gleitkommazahlen können auch doppeltgenaue Gleitkommazahlen definiert werden (Typ double).

Zusätzlich zu diesen gemeinhin bekannten Basisdatentypen kennt C noch einen weiteren basialen Datentyp: den Zeiger (engl. Pointer), auch Referenz genannt, der grundsätzlich an einen Datentpy gebunden ist. Implementierungstechnisch ist ein Pointer die Adresse des Objekts, auf das der Pointer zeigt. Verwendet werden Pointer typischerweise zum Aufbau dynamischer Datenstrukturen, wie z. B. Geflechte und Bäume, und in der hardwarenahen Programmierung, um beispielsweise Memory-mapped-Register zu bedienen. Auf Pointer sind die arithmetischen Operationen Addition (einer ganzen Zahl) und Subtraktion (einer ganzen Zahl oder eines anderen Pointers, der in dasselbe Konstrukt zeigen muss) zugelassen ("Pointerarithmetik"). Das Pointer-Konzept trägt einerseits erheblich zur Flexibilität der Sprache bei (das geht bei keiner anderen Sprache so elegant und leicht...), ist aber andererseits auch Ursache so mancher Probleme, insbesondere dann, wenn dem Programmierer diese Denkweise noch neu ist.

Abbildung in dieser Leseprobe nicht enthalten

Bild 9: Definition skalarer Datentypen

Abbildung in dieser Leseprobe nicht enthalten

Bild 10: Skalare Datentypen

3.1.3.2 Zusammengesetzte Datentypen

Einfach strukturierte Datentypen lassen sich aus allen skalaren Datentypen zusammensetzen. Daraus lassen sich dann wiederum komplex strukturierte Datentypen konstruieren. Betrachten wir zunächst den einfachsten zusammengesetzten Datentyp: das Array.

- Arrays

Das Array besteht aus einer Anzahl von Einzelementen (Arraykomponenten), von denen jedes für sich eine Variable ist. Bezeichnenderweise müssen die Arraykomponenten alle vom gleichen Datentyp sein, d. h. alle Elemente sind z. B. ausschließlich Variablen für ganze Zahlen oder ausschließlich Zeichenvariablen, usw.

C läßt aber auch, wie oben schon erwähnt, Schachtelungen zu: eine Arraykomponente kann selbst wiederein Array sein. Auf diese Weise lassen sich (mehrdimensionale) Matrizen bilden. In den meisten Anwendungen sind die Arrayelemente jedoch skalare Größen.

Ein Array wird als Einheit durch einen Namen angesprochen. Die einzelnen Elemente innerhalb des Arrays werden durch einen Index, der dem Arraynamen folgt, in eindeutiger Weise bestimmt. Damit läßt sich jedes Arrayelement ansprechen und auch verändern, ohne dass Nachbarelemente von diesen Maßnahmen betroffen werden.

int vector[3];

...

vector[0] = 111;

vector[1] = 471;

vector[2] = 313;

Bild 11: Arraydefinition und Initialisierung

Das Array vector umfaßt 3 int-Elemente. Wie im Beispiel ersichtlich ist, sind unterer und oberer Arrayindex nicht frei wählbar: der kleinste Index ist stets 0 und der höchste Index ist um 1 kleiner wie die Arraylänge.

Aus der Verwandtschaft zwischen Pointer und Arrays resultiert die Möglichkeit, bei der Ansprache einzelner Arrayelemente die üblicherweise angewandte Indizierung durch eine Pointer-Adressierung zu ersetzen. Diese Möglichkeiten verwirren in der Regel den C-Neuling und machen Programme nicht gerade leichter lesbar, wenn beide Zugriffsformalismen auf Arrayelemente in ein- und derselben Funktion verwendet werden. Sinnvolle Programmierkonventionen untersagen jedoch das Mischen dieser beiden Techniken.

Strings (Zeichenketten) sind in C übrigens als Arrays vom Typ char zu implementieren.

Neben dem Array ist die Struktur (structure) der nächste bedeutende zusammengesetzte Datentyp. Strukturen bestehen aus einer endlichen Anzahl von Elementen aus skalaren oder zusammengesetzten Datentypen. Im Gegensatz zu Arrays müssen allerdings hier die einzelnen Elemente nicht typidentisch sein!

· Strukturen

Eine Struktur besteht aus mehreren Einzelelementen (Strukturkomponenten). Im Gegensatz zum Arraymüssen jedoch die Strukturkomponenten nicht alle vom gleichen Datentyp sein.

Die einzelnen Strukturkomponenten können entweder aus skalaren Werten oder wiederum aus zusammen-gesetzten Objekten, also Arrays und/oder Strukturen, bestehen. Auf diese Weise lassen sich beliebig komplexe anwendungsspezifische Strukturen modellieren. Wie schon vom Array her bekannt ist, wird auch die Struktur als Einheit mit einem Namen angesprochen. Jede einzelne Strukturkomponente hat wiederum einen eigenen Namen (member name), der sich von den Namen anderer Strukturkomponenten unterscheiden muss. Mit einer Folge von Namen (durch einen Punkt getrennt) läßt sich damit jede Strukturkomponente ansprechen und auch verändern, ohne dass davon andere Komponenten der Struktur betroffen sind.

Abbildung in dieser Leseprobe nicht enthalten

Bild 12: Deklaration und Benutzung eines Strukturtyps

Von dem Datentyp structure gibt es in C zwei Derivate: den Datentyp union und den Datentyp bitfield. In beiden Fällen handelt es sich um Datenstrukturen, deren Elemente nicht typidentisch sein müssen.

· Union

In gewisser Weise erinnern die Unions an den varianten Record von Pascal. Sinn und Zweck der Unions ist die Abbildung all ihrer Elemente auf ein und denselben Speicherplatz. Der Programmierer muss deshalb bei Verwendung von Unions auch einen Protokoll-Mechanismus implementieren, der über den augenblicklichen Typ der Union Auskunft gibt, denn es kann (von bestimmten Tricks abgesehen) sinnvollerweise nur der Typ aus der Union extrahiert werden, der zuletzt hineingesteckt wurde.

union all_type

Abbildung in dieser Leseprobe nicht enthalten

Bild 13: Deklaration und Benutzung einer Union

Durch diese Definition wird für die Union token soviel Speicherplatz allokiert, dass sowohl zeichenartige Objekte wie auch ganzzahlige Objekte und Gleitkommazahlen darin abgelegt werden können. Eingesetzt werden Unions vor allem beim Aufbau inhomogener, platzsparender Tabellen (und zugegebenermaßen auch zum "Zaubern").

· Bitfeld

Die Bitfelder (bitfields) bestehen aus einer Anzahl aufeinanderfolgender Einzelbits innerhalb eines Objekts vom Typ int. In einem int-Objekt können allerdings mehrere Bitfelder untergebracht werden. Dabei muss beachtet werden, dass ein Bitfeld die Grenzen zwischen zwei int-Objekten nicht überschreiten kann.

Abbildung in dieser Leseprobe nicht enthalten

Bild 14: Deklaration und Benutzung eines Bitfelds

Der Name des Bitfeldes ist durch einen Doppelpunkt von der Längenangabe des Bitfeldes getrennt. Im obigen Fall sind alle Bitfelder zwei Bit lang. Eingesetzt werden Bitfelder typischerweise immer dann, wenn eine explizite Zuordnung von Informationen eines höheren Abstraktionsniveaus an Einzelbits angezeigt ist; dies ist häufig im Bereich der hardwarenahen Programmierung, wie z. B. im Betriebssystem- und Compilerbau der Fall.

· Aufzählungstyp

Bei den Aufzählungstypen handelt es sich um Neudefinitionen von Datentypen, deren diskreter Wertebereich durch die Aufzählung aller Elemente , den sogenannten Aufzählungsliteralen, festgelegt wird.

Abbildung in dieser Leseprobe nicht enthalten

Bild 15:Deklaration und Benutzung einer Aufzählung

Intern werden die einzelnen Werte aus der Wertemenge eines Aufzählungstyps durch ganze Zahlen dargestellt. Dabei wird der erste Aufzählungswert durch die Null repräsentiert. Die anderen Werte folgen entsprechend ihrer Definitionsreihenfolge mit einem jeweiligen Inkrement von 1. Aus diesem Grund ist die Wertemenge geordnet und die Relationsoperatoren <, >, usw. sind anwendbar. Außerdem dürfen Aufzählungswerte überall dort vorkommen , wo int-Konstanten stehen können.

Die interne Repräsentation der Aufzählungswerte kann allerdings bei der Typdefinition beeinflußt werden; dies wollen wir hier jedoch nicht weiter verfolgen.

3.1.3.3 Speicherklassen

Speicherklassen haben nichts mit dem Klassenkonzept der Objektorientiertheit zu tun! Das Speicherklassenkonzept dient dazu, die Lebensdauer von Namen zu regeln. Variablen der Speicherklasse auto leben nur innerhalb eines Blocks. Größen der Speicherklasse extern sind in allen Blöcken sämtlicher Funktionen eines Moduls bekannt ("globale Variablen"), außerdem sind Größen der Speicherklasse extern in anderen Moduln importierbar. Bei den Größen der Speicherklasse static muss man unterscheiden, ob es sich um Variablen- oder um Funktionsnamen handelt. Blocklokale Variablen der Speicherklasse static überleben jetzt das Blockende (z. B. Ende einer Funktion). Wird der Block irgendwann während der Programmlaufzeit wieder einmal betreten (z. B. erneuter Aufruf der Funktion), dann kann sich die static-Variable noch an ihren ehemaligen Wert erinnern. Eine etwas andere Bedeutung hat die Speicherklasse static im Zusammenhang mit den globalen Variablen eines Moduls, die bleiben sowieso während der gesamten Programmlaufzeit am Leben. Globale Variablen der Speicherklasse static sind nur innerhalb des Moduls in dem sie definiert wurden gültig und können nicht in andere Moduln exportiert werden. Auch Funktionen können von der Speicherklasse static sein. Hier verhält es sich ganz analog zu den globalen Variablen: eine static-Funktion ist nicht aus anderen Moduln aufrufbar, sie kann nur von Funktionen aus dem eigenen Modul gerufen werden. Wir sehen, die Speicherklasse static spielt eine wichtige Rolle, wenn es darum geht die Innereien eines Moduls zu schützen und vor anderen Moduln zu verbergen ("information hiding"). Bei der Darstellung des objektorientierten Teils der Sprache werden wir noch zusätzliche und weitergehende Mittel zur Kapselung kennenlernen.

Abbildung in dieser Leseprobe nicht enthalten

Bild 16: Speicherklassen-Schlüsselworte

Abbildung in dieser Leseprobe nicht enthalten

Bild 17: Speicherklassen

3.1.3.4 Typkonvertierung

C verfügt über einen umfangreichen Satz von (internen) Konvertierungsregeln für Datentypen, die implizit ablaufen. Abgesehen von der expliziten cast-Operation registriert der Programmierer von diesen Typkonvertierungen nichts, er sollte sie aber tunlichst kennen. Am besten dokumentiert der C-Programmierer seinen Kenntnisstand, indem er den impliziten Konvertierungen durch eine explizite Konvertierung mit dem cast-Operator vorgreift. Die Programme werden außerdem dadurch lesbarer und sind leichter zu pflegen.

Typkonvertierungen treten in folgenden Fällen auf:

- Bei der Zuweisung an eine Variable anderen Typs (assignment conversion). Der Typ der Zielvariablen bestimmt den Typ.
- Bei einer expliziten cast-Operation (type-cast conversion)
- Bei der Abarbeitung von Ausdrücken (operator conversion). Hier bestimmen die Regeln der arithmetischen Konvertierung den Typ des Ausdrucksergebnisses.
- Beim Aufruf von Funktionen (function-call conversion). Zu welchem Typ die aktuellen Funktionsparameter konvertiert werden, hängt von der Existen z eines Prototys / einer Vorwärtsdeklaration ab. Fehlt ein solcher Prototyp oder existiert nur eine old-style-Vorwärtsdeklaration, so kommen die Regeln der arithmetischen Konvertierung zur Anwendung (also: float nach double; char nach int; unsigned char oder unsigned short nach unsigned int). Existiert ein Prototyp, und passen die aktuellen und formalen Parameter nicht zusammen (type checking), so erfolgt, falls möglich, eine Konvertierung (assignment conversion; Anpassung der aktuellen Parameter an die im Prototyp genannten), andernfalls eine Fehlermeldung (etwa: Übergabe einer Struktur an ein Skalar).

Im folgenden sind die arithmetischen Konvertierungen zusammengefasst, wie sie z.B. bei der operator conversion ablaufen:

1. Sämtliche Operanden vom Typ float werden nach double konvertiert.
2. Ist einer der Operanden vom Typ double, so wird auch der andere nach double konvertiert.
3. Sämtliche Operanden vom Typ char oder short werden nach int konvertiert.
4. Sämtliche Operanden vom Typ unsigned char oder unsigned short werden nach unsigned int konvertiert.
5. Ist einer der Operanden vom Typ unsigned long, so wird auch der andere nach unsigned long konvertiert.
6. Ist einer der Operanden vom Typ long, so wird auch der andere nach long konvertiert.
7. Ist einer der Operanden vom Typ unsigned int, so wird auch der andere nach unsigned int konvertiert.

Achtung: In welcher Reihenfolge diese Konvertierungsregeln auf die einzelnen Operanden eines komplexeren Ausdrucks angewendet werden, hängt von der Operatorpriorität ab.

Abbildung in dieser Leseprobe nicht enthalten

Bild 18: Eine explizite Typkonvertierung nach float

3.1.4 Kontrollstrukturen

Mit den Kontrollstrukturen stehen die Sprachelemente zur Verfügung, um komplexe Ablaufstrukturen zu formulieren. Die Sprache C stellt eine Reihe von Kontrollstrukturen zur Verfügung. Im folgenden werden wir die Schemata von einigen Kontrollstrukturen betrachten.

Abbildung in dieser Leseprobe nicht enthalten

Bild 19: Befehls-Schlüsselworte und Kontrollstrukturen

3.1.4.1 Wiederholungen

Es existiert eine Schleifenkonstruktion, die das Schleifenkriterium vor dem Eintritt in den Schleifenrumpf prüft. Daneben gibt es eine weitere Schleife, die das Schleifenkriterium erst nach dem Durchlauf des Schleifenrumpfes testet. Die Wiederholungsanweisung nach dem erstgenannten Muster ist die while-Anweisung:

· while-Schleife

Betrachten wir die while-Anweisung, die das Schleifenkriterium vor den einzelnen Wiederholungen prüft.

Abbildung in dieser Leseprobe nicht enthalten

Programm 20: Die while-Schleife

Die Wiederholungsanweisung, die nach dem zweiten Muster funktioniert, also das Schleifenkriterium erst im Schleifenfuß abprüft, ist in der do-Anweisung implementiert:

· do-Schleife

Bei der bisher vorgestellten while-Schleife kann es bei entsprechend gestalteten Bedingungen vorkommen, dass der Schleifenrumpf kein einziges Mal durchlaufen wird. Anders verhält es sich bei der do-Schleife: hier kann das Schleifenkriterium zum ersten Mal ausgewertet werden, nachdem der Schleifenrumpf mindestens einmal durchlaufen wurde. Die Wiederholungsanweisung mit dem Test am Schleifenende hat die Form:

Abbildung in dieser Leseprobe nicht enthalten

Eine notationelle Variante der while-Schleife ist die for-Schleife. In der for-Schleife werden im Schleifenkopf die Ausdrücke für die Initialisierung, das Schleifenkriterium und die Reinitialisierung der Schleife, jeweils getrennt durch einen Strichpunkt getrennt, übersichtlich aufgeführt.

for (Initopt; Kriteriumopt; Re-Initopt)

Abbildung in dieser Leseprobe nicht enthalten

Im folgenden Programm wird in einer for-Schleife das kleine 2er-Einmaleins berechnet und ausgegeben. Inhaltlich wird also das gleiche getan, wie in der oben dargestellten while-Schleife. Bezüglich der Syntax gibt es jedoch einen erheblichen Unterschied: die for-Schleife bietet uns die Möglichkeit, alles was eine Schleife beeinflußt, möglichst nahe beieinander zu notieren ("Lokalitätsprinzip")! Vor dem ersten Strichpunkt innerhalb der runden Klammern des Schleifenkopfes werden die beiden maßgeblichen Variablen initialisiert. Vor dem zweiten Strichpunkt ist das Schleifenkriterum sichtbar. Der letzte Teil des Schleifenkopfes zeigt uns, wie die Schleifenvariable modifiziert wird, bevor sie erneut im Schleifenkriterum Verwendung findet.

Abbildung in dieser Leseprobe nicht enthalten

Programm 21: Die for-Schleife

Neben den Wiederholungsanweisungen gibt es eine Reihe weiterer interessanter Kontrollstrukturen. Wir wollen hier noch kurz auf die Selektionsanweisungen und die Kontrolltransferanweisungen eingehen.

3.1.4.2 Selektionsanweisungen

C++ kennt ein Sprachkonstrukt für die Selektion und eines für deren Spezialfall, die Fallunterscheidung (bedingte Anweisung).

· if-Anweisung

Ist die Bedingung nach dem Schlüsselwort if erfüllt, so wird der erste Ausdruck abgearbeitet (der true-Zweig) andernfalls der zweite Ausdruck (false-Zweig). Auffälligerweise entbehrt C im true-Zweig das Schlüsselwort then. Der false-Zweig ist optional, kann also gegebenenfalls fehlen.

Abbildung in dieser Leseprobe nicht enthalten

Um mehrstufige Alternativen zu implementieren, können if-Anweisungen geschachtelt werden zu if-if-Kaskaden oder else-if-Kaskaden.

Abbildung in dieser Leseprobe nicht enthalten

Programm 22: Eine if-if-Kaskade

Alternativen mit mehreren Zweigen werden in C typischerweise mit der Auswahlanweisung (switch-Anweisung) programmiert:

· switch-Anweisung

Die Auswahlanweisung wird für Mehrfachverzweigungen auf der Basis eines ganzzahligen Ausdrucks verwendet.

Abbildung in dieser Leseprobe nicht enthalten

Das erarbeitete Ergebnis des Ausdrucks nach dem Schlüsselwort switch definiert die Einstiegsstelle im Rumpf der Auswahl. Sämtliche Ausdrücke hinter den Schlüsselwörtern case müssen ganzzahlige Konstantenausdrücke sein. Die case-Ausdrücke vor den Doppelpunkten haben die Qualität von Marken, denn nach dem Ausführen eines case-Zweiges wird nicht etwa der Rumpf der Auswahlanweisung verlassen, sondern es wird der nächste case-Zweig abgearbeitet, sofern nicht mit der break-Anweisung das Verlassen des Auswahl-Rumpfes erzwungen wird. Stimmt keiner der ganzzahligen Konstantenausdrücke der case-Anweisungen mit dem Ergebnis des switch-Ausdrucks überein, so wird der default-Zweig abgearbeitet (sofern vorhanden).

#include <stdio.h>

main(void)

{

char antwort;

printf("%s\n%s\n",

"Gefaellt Ihnen die Programmiersprache C++?",

"Tippen Sie j fuer ja und n fuer nein.");

scanf("%c",&antwort);

switch (antwort)

{

case 'j':

{

printf("\nSchoen. Weiterhin viel Spass!\n");

break;

}

case 'n':

{

printf("\nIch bin ueberzeugt, das aendert sich!\n");

break;

}

default :

{

printf("\nTut mir leid. %c verstehe ich nicht!\n",

antwort);

}

}

}

Programm 23: Die switch-Anweisung

Neben der nicht salonfähigen goto-Anweisung enthält C noch drei weitere Kontrolltransferanweisungen: die break-, die continue- und die return-Anweisung.

3.1.4.3 Kontrolltransferanweisungen

· break

Eine Verwendungsmöglichkeit für die break-Anweisung haben wir bereits im letzten Beispiel gesehen. Dort wurde die break-Anweisung benutzt, um den Rumpf der Auswahlanweisung zu verlassen. Andere Kontrollstrukturen, die mittels der break-Anweisung verlassen werden können, sind die Wiederholungsanweisungen. Eine break-Anweisung innerhalb einer while-, do- oder for-Anweisung bewirkt das Verlassen des innersten Schleifenrumpfes

· continue

Die continue-Anweisung ist ebenfalls im Zusammenhang mit Schleifen zu gebrauchen: damit kann ein Schleifenrumpf vorzeitig verlassen und - nach vorheriger Prüfung des Schleifenkriterums - eine erneute Iteration angestoßen werden.

· return

Die return-Anweisung ist im Zusammenhang mit Funktionen zu sehen und hat zwei Aufgaben: erstens den Steuerfluß von der aufgerufenen Funktion in die aufrufende Funktion zurückzugeben und zweitens einen Returnwert hochzureichen.

3.1.4.4 Befehle zur Heapverwaltung

Abbildung in dieser Leseprobe nicht enthalten

Bild 24: Befehle zur Heapverwaltung

Mit den Schlüsselwort-Operatoren new und delete wird Speicher allokiert und freigegeben. Die Möglichkeit, dem Operator new einen Initialisierer mitzugeben (gilt nur für non-Arrays), ist ein Vorteil gegenüber dem traditionellen malloc(). Wird kein Initialisierer angeben, so hat das von new erzeugte Ojekt einen undefinierten Inhalt.

Die Operatoren new und delete sind nicht kompatibel mit den Funktionen malloc() und free() (und verwandten Funktionen wie calloc() usw.). Speicherplatz, der mit new allokiert wurde darf nicht mit free() freigegeben werden. Analog darf Speicherplatz der mit alloc() allokiert wurde nicht mit delete freigegeben werden. Bei vielen Compilern funktioniert zwar das Mischen, was aber u.U. zu schwer lokalisierbaren Speicherproblemen führen kann!

/* new mit Initialisierer: */

int *p_int = new int(3); /* p_int zeigt auf 3 */

/* new mit Groessenangabe */

iarray = new int [10]; /* int iarray[10]; */

...

/* die dynamisch allokierten Größen werden wieder gelöscht: */

delete p_int;

delete [] iarray;

Bild 25: Speicherallokation und Freigabe

Die Standard-C-Bibliotheksfunktionen malloc() und calloc() liefern einen NULL-Pointer, wenn auf der Heap[20] nicht mehr genug Platz zum Allokieren der gewünschten Anzahl von Bytes vorhanden ist.

Der Operator new liefert ebenfalls einen NULL-Pointer, wenn die Heap-Allokation wegen Speichermangels nicht ausgeführt werden kann. In C++ kann also in der gleichen Weise auf den Speichermangel reagiert werden wie in C. C++ bietet darüber hinaus noch eine bessere Möglichkeit!

In C++ ist der Funktionszeiger _new_handler als Systemvariable definiert, die mit dem NULL-Pointer initialisiert ist und vom Programmierer mit einem Zeiger auf eine Problembehandlungsfunktion besetzt werden kann. Geht der Aufruf von new schief, so ruft das System die von diesem Zeiger referenzierte Funktion auf. Von der Idee her sollte diese Problembehandlungsfunktion dafür sorgen, dass (vielleicht aus Performancegründen) zu großzügig verbrauchter Heap-Platz wieder freigegeben wird.

Nach dem Ausführen der mittels _new_handler referenzierten Funktion versucht new erneut den gewünschten Speicher auf der Heap zu allokieren.

Auf Unix-Systemen gilt die Heap als ausgeschöpft, wenn der Systemspeicher belegt ist: d.h. der Hauptspeicher ist voll und der Page-/Swap-Space ist ebenfalls komplett belegt.

Besetzt wird der Funktionszeiger _new_handler nicht durch eine direkte Zuweisung sondern mit Hilfe der Funktion set_new_handler().

Der Einsatzzweck von new und delete geht über die hier dargestellte Verwendung hinaus: wenn wir den objektorientierten Teil der Sprache betrachten, werden wir kennenlernen, wie sich mit new auch Objekte kreieren lassen.

3.1.5 Operatoren

Hinsichtlich der angebotenen Kontrollstrukturen unterscheidet sich C nicht sonderlich von anderen gängigen Hochsprachen. Ganz anders verhält es sich jedoch bei den zur Verfügung gestellten Operatoren. Ein Vergleich: Wirth's Pascal enthält 16 Operatoren für die Zuweisung, für Arithmetik, für Vergleiche, für Logik und für Mengenoperationen, C++ enthält über 50 Operatoren, von denen alleine 11 Zuweisungsoperatoren sind. Der Operatorensatz kennzeichnet die Sprache C auch als Sprache für die Systemprogrammierung: es stehen nämlich mehrere Operatoren für Bit-Verarbeitung zur Verfügung.

Jeder Operator ist einer von 16 Prioritätsstufen zugeordnet, um mit Klammern sparsamer umgehen zu können. Trotzdem empfiehlt es sich, der Lesbarkeit wegen, die Operatorpriorität durch eine entsprechende (redundante) Klammerung zu verdeutlichen.

Anfangs macht die Operatorenvielfalt dem C-Neuling sicherlich zu schaffen, aber schon während der Einarbeitungszeit bekommt man einen Blick für die Systematik und wird feststellen, dass man seine Ideen und Spezifikationen auf nahezu natürliche Weise in ein Programm umsetzen kann.

Abbildung in dieser Leseprobe nicht enthalten

Bild 26: Arithmetische Operatoren

(Anmerkung zu den eckigen Klammern: In den eckigen Klammern [ ] stehen Hinweise, nicht etwa Operanden.)

Abbildung in dieser Leseprobe nicht enthalten

Bild 27: Vergleichs- und Bitoperatoren

(Anmerkung zu den logischen Operatoren: Für die logische Verknüpfung gilt: Werte (von Variablen) gelten als FALSE bei einem Wert gleich 0; alle anderen Werte werden als TRUE interpretiert.)

Abbildung in dieser Leseprobe nicht enthalten

Bild 28: Zuweisungsoperatoren

Abbildung in dieser Leseprobe nicht enthalten

Bild 29: ...noch einige Operatoren

Abbildung in dieser Leseprobe nicht enthalten

Bild 30: Prioritäten und Assoziativitäten von Operatoren

3.1.6 Der Präprozessor

Der Compiler hat die Aufgabe ein Quellprogramm in ein vom Computer ausführbares Qbjektprogramm zu übersetzen. Bei der Erzeugung einer ausführbaren Objektdatei sind im Allgemeinen mehrere Übersetzungs-läufe (Compiler Pässe) beteiligt. Im ersten Paß ist der Präprozessor aktiv. Ihm wenden wir uns im nächsten Abschnitt noch genauer zu, da verschiedene Präprozessorleistungen vom Programmierer intensiv genutzt werden. Im zweiten Paß wird aus der vom C-Präprozessor expandierten Quelldatei i. d. R. Assembler-Quell-Code erzeugt. Im dritten Paß ist dann der Assembler an der Reihe. Er erzeugt relokierbaren Objekt-Code. Vor dem Assembler kann auf Wunsch noch der Optimierer laufen. Im vierten Paß ist der Linker aktiv. Jetzt werden offene Referenzen zwischen den Bindeobjekten aufgelößt und das gebundene Objekt wird logisch im Adreßraum angeordnet ("Fixierung im Adreßraum"). Bei der separaten Compilierung einzelner Moduln eines Multi-Datei-Programms wird dieser letzte Paß durch die Compiler-Option ausgeklammert.

Von den verschiedenen Compiler-Pässen interessiert uns der erste Paß am meisten. Vom C-Präprozessor wissen wir bereits, dass er im Quellprogramm die mit der #define-Anweisung definierten symbolischen Konstanten gegen ihre Werte austauscht.

Der Präprozessor leistet jedoch erheblich mehr. Im wesentlichen[21] bietet er folgende Dienste: er...

- wertet Konstanten-Ausdrücken aus,
- entfernt Kommentare aus dem Quellprogramm,
- substituiert symbolische Konstanten,
- expandiert Makros,
- zieht Include-Dateien in die Quelle,
- steuert die bedingte Compilierung.

Alle Dienste des Präprozessors werden mit den sogenannten Präprozessoranweisungen angefordert. Die Präprozessoranweisungen beginnen[22] alle mit dem Zeichen #. Diese Präprozessoranweisungen gelten zwar nicht als C-Schlüsselworte, sind aber integraler Bestandteil von C.

3.1.6.1 Das Arbeiten mit symbolischen Konstanten.

Symbolische Konstanten werden mit den Präprozessor-Anweisungen

- #define
- #undef

behandelt.

Eingeführt werden symbolische Konstanten mit der #define-Anweisung. Zum Beispiel

#define PI 3.14159

Die so eingeführte symbolische Konstante lebt solange, bis sie mittels

#undef PI

wieder undefiniert wird.

Wird in der #define-Anweisung nur ein Name, aber kein Wert angegeben, so hat diese symbolische Konstante den Wert 1.

An dieser Stelle sei erwähnt, dass symbolische Konstanten vom blockorientierten Geltungsbereich und der speicherklassenabhängigen Lebensdauer keine "Ahnung" haben. Für sie gelten "statische" Gesetze. Symbolische Konstanten leben und gelten zwischen den sie betreffenden #define- und #undef-Anweisungen. Diese beiden Präprozessor-Anweisungen dürfen in einem (modularisierten) Programm auch in verschiedenen Quell-Dateien stehen. Innerhalb dieses so gestalteten Definitionsbereichs tauscht der Präcompiler vor der Übersetzung die Namen der symbolischen Konstanten gegen ihren Wert aus.

Obwohl es syntaktisch nicht vorgeschrieben ist, so empfiehlt es sich doch, alle #define-Anweisungen am Anfang eines Programms oder Moduls noch vor den Funktionen, den extern-Deklarationen und den extern-Definitionen unterzubringen. Außerdem sollte auf die #undef-Anweisung verzichtet werden. Erstens gibt es kaum eine Situation, in der sie echt gebraucht wird und zweitens stiften die Namen symbolischer Konstanten, die durch #undef freigegeben und durch ein nachfolgendes #define mit anderem Inhalt neu definiert wurden, erfahrungsgemäß Verwirrung.

Um per #define ins Leben gerufene Namen leicht als solche identifizieren zu können, empfiehlt es sich, für sie nur Großbuchstaben zu verwenden.

Anstatt die symbolische Konstante PI mit #define PI 3.14159 in der Datei programm.c zu definieren, kann sie auch beim Compileraufruf in der Kommandozeile mit der Option -D definiert werden:

$cc programm.c -DPI=3.14159

3.1.6.2 Das Arbeiten mit Makros

Auch Makros werden mit den Präprozessor-Anweisungen

#define

#undef

behandelt. Die Anweisung #undef wollen wir hier nicht weiter verfolgen, da sie in diesem Zusammenhang absolut ungebräuchlich ist.

Makros setzen sich zusammen aus einem Namen, formalen Parametern (argument lis t) und der Ersetzungsliste (replacement list)

0000

0001 #define SQU(x) x * x

0002 #define ABS(x) (((x)<0)?-(x):(x))

0000 A A \ /

. | | \------ ------/

. | | V

. | | replacement list

. | +---- argument list

. +------- Makro-Name

Bild 31: Makrodefinitionen

Aus dem Makro-Aufruf

... = SQU(a[i]);

wird nach der Makro-Expansion durch den C-Präprozessor

... = a[i] * a[i];

Analoges gilt für das Makro aus Zeile 0002.

Makros vollständig klammern !

Vergleicht man die beiden Makro-Definitionen, so fällt einem auf, dass die Makro-Definition in Zeile 2 exten siver geklammert ist als die andere. Vergegenwärtigen wir uns die Wirkung des Makro-Aufrufs

... = SQU(y+1);

so stellen wir fest, dass die Makro-Definition in Zeile 2 unzureichend ist, denn die Makro-Expansion ergibt

... = y+1 * y+1; /* = 2y+1 */

Eine sichere Definition des Makros SQU lautet:

#define SQU(x) ((x) * (x))

Dass die äußersten Klammmern notwendig sind, läßt sich leicht einsehen, wenn man überlegt, wie der folgende Makroaufruf expandiert wird:

... = SQU(a+b) / SQU(a+b);

Keine Seiteneffekte in Makros !

Das Beispiel des Makros SQU zeigt, dass das Definieren von Makros durchaus nicht trivial ist. Selbst wenn Makros sicher definiert wurden, birgt ihre Anwendung eine weitere Gefahr in sich: es muss streng darauf geachtet werden, dass in Makro-Aufrufen keine Seiteneffekte vorkommen! Aus dem Makro-Aufruf

... = SQU(a++);

erzeugt die Makro-Expansion

... = (a++) * (a++);

In diesem Fall beschert uns der Seiteneffekt im Makroaufruf eine Inkrementierung zuviel!

Vorteile und Nachteile

Dieses letzte Beispiel macht vor allem deutlich, dass Funktionen erheblich sicherer sind als Makros. Anderer seits haben Makros auch ihre Vorteile:

· Sie sind effizienter als Funktionen. Aus Makros wird In-line-Code, für den weder Aufruf, noch Parameter übergabe, noch Rücksprung notwendig ist.

· Makros sind "generische" Einheiten, da sie typunabhängig definiert werden. Unser Makro SQU funktioniert für alle skalaren Datentypen gleichermaßen.

3.1.6.3 Der String-Generator (string creation operator) #

Manche C-Compiler (dazu gehören auch die Unix C-Compiler) substituieren Makroparameter auch in String-Konstanten. So kann man beispielsweise folgendes schreiben

#define PRINT(x) printf("x = %d\n",x)

um nach der Makroexpansion den Makroparameter im Formatstring der printf()-Anweisung zu haben, d. h. der Makroaufruf

PRINT(alpha);

wird expandiert zu

printf("alpha = %d\n",alpha);

Die Makroexpansion auch innherhalb von Stringkonstanten ist nicht in allen C-Compilern realisiert. Im Sinne einer strengen und exakten Sprachdefinition ist diese Möglichkeit, bzw. die Syntax, mit der die Möglichkeit realisiert wird, auch nicht ganz einwandfrei.

Um eine syntaktisch saubere Lösung zu haben, hat ANSI-C für solche Fälle den Operator # als String-Generator (string creation operator) eingeführt. Dieser Operator darf ausschließlich in der Ersetzungsliste (replacement list) einer Makrodefinition vorkommen. Der Präprozessor ersetzt diesen Operator und den unmittelbar angehängten Namen durch eine Stringliteral, das aus dem Makroparameter des Makroaufrufs gebildet wird. Beispiel: das oben gezeigte Makro kann mit ANSI-C wie folgt geschrieben werden:

#define PRINT(x) printf(#x " = %d\n",x)

Der Makroaufruf

PRINT(chx[EOF]);

wird expandiert zu

printf("chx[EOF] = %d\n",chx[(-1)]);

Dieses Beispiel macht auch deutlich, dass ein Makro (hier EOF) auf der Parameterposition eine Makros nicht vor der String-Generierung expandiert wird!

Soll jedoch ein Stringliteral aus einem expandierten Makroargument gebildet werden, so muss ein zusätzliches Makro eingeführt werden:

#define STR(x) #x

#define PRINT(x) printf(str(x) " = %d\n",x)

Jetzt wird der Makroaufruf

PRINT(chx[EOF]);

expandiert zu

printf("chx[(-1)] = %d\n",chx[(-1)]);

ANSI-C hat noch einen weiteren Operator eingeführt, der auch nur im Zusammenhang mit Makros in der Ersetzungsliste vorkommen darf: der Token-Verknüpfer.

3.1.6.4 Der Token-Verknüpfer (token concatenation operator; token pasting) ##

Manche C-Compiler (dazu gehören wieder die Unix C-Compiler) gestatten folgende Stringkonkatenationen mit Makros:

#define VAR(x) VAR/* konkateniert mit */x

Ein Makroaufruf

VAR(Y);

wird expandiert zur Konkatenation von VAR und Y, also zu

VARY;

Hier ist offensichtlich der Kommentar als Stringverknüpfer mißbraucht worden. Da auch dies nicht im Sinne einer exakten Sprachdefinition ist, bietet ANSI-C hierfür den Operator ## an. Das oben gezeigte Makro läßt sich mit ANSI-C wie folgt definieren:

#define VAR(x) VAR ## x

Der Makroaufruf

... = VAR(I) + 25;

wird wiederum expandiert zum Ausdruck

... = VARI + 25;

3.1.6.5 Das Arbeiten mit Include-Dateien

Durch die #include-Anweisung lassen sich einzelne Zeilen einer Quelldatei durch komplette Inhalte anderer Dateien, sogenannter Include-Dateien, ersetzen. In welchen Verzeichnissen der Präprozessor die Include-Dateien sucht, hängt davon ab, ob der Dateiname mit spitzen Klammern oder mit Gänsefüßchen eingefasst ist.

0000

0001 #include <stdio.h>

0002 #include "globdef.h"

...

Bild 32: Includeanweisungen

Die Include-Datei stdio.h wird vom Präprozessor in Systemverzeichnissen gesucht. (In den meisten Unix-Systemen im Verzeichnis /usr/include ). Die Include-Datei globdef.h wird dagegen im selben Verzeichnis gesucht, in dem auch die Quelldatei steht. Sollte es dort nicht zu finden sein, so wird in bestimmten anderen Verzeichnissen gesucht. Für die Suche in anderen Verzeichnissen kann man beim Compileraufruf in der Kommandozeile mit der Option -I dem Präprozessor Such-Verzeichnisse angeben. Fehlt ein solcher Vorschlag, so versucht der Präprozessor auch diese Include-Datei in einem Systemverzeichnis zu finden.

In beiden Fällen oben wurde die Include-Datei mit einem sogenannten relativen Pfadnamen genannt. Man hat aber auch die Möglichkeit einen absoluten Pfadnamen anzugeben. Dann alllerdings wird die Include-Datei nur am so definierten Platz gesucht.

In Unix-Systemen werden Include-Dateien gerne als "Header-Files" bezeichnet. Von dieser Bezeichnung leitet sich die Konvention ab, Include-Dateinamen mit dem Suffix ".h" zu versehen.

3.1.6.6 Die bedingte Compilierung

Unter bedingter Compilierung verstehen wir das gezielte Aussparen von Programmteilen während der Übersetzung. Welche Programmteile von der Compilation ausgeklammert bleiben, wird durch verschiedene Bedingungs-Anweisungen gesteuert.

Für den Test von Bedingungen stehen uns drei Anweisungen zur Verfügung:

- #ifdef NAME
- #ifndef NAME
- #if KONSTANTEN-AUSDRUCK

NAME und KONSTANTEN-AUSDRUCK verstehen sich hier natürlich nur als Platzhalter, die durch beliebige Namen, bzw. Konstanten-Ausdrücke zu ersetzen sind.

Die Abfrage #ifdef NAME wird positiv bestätigt, wenn der verwendete Name vorher mittels einer #define-Anweisung definiert wurde.

Dagegen wird die Abfrage #ifndef NAME dann positiv bestätigt, wenn der verwendete Name nicht vorher in einer #define-Anweisung definiert wurde.

Die Bedingung #if KONSTANTEN-AUSDRUCK gilt als erfüllt, wenn das Ergebnis des Konstanten-Ausdrucks ungleich Null ist.

Alle drei Präprozessor-Anweisungen können in einem Quellprogramm einen Abschnitt einleiten, der nur compiliert werden soll, wenn die jeweilige Bedingung erfüllt ist. Beendet wird ein bedingt zu compilierender Abschnitt mit der Anweisung

#endif

Will man eine echte Alternative für die bedingte Compilierung formulieren, so darf der erste Abschnitt mit der Anweisung

#else

abgeschlossen werden. Die #else-Anweisung markiert den alternativen Abschnitt, der dann seinerseits mit der #endif-Anweisung abgeschlossen werden muss.

Ist die in einer #ifdef-, #ifndef- oder #if-Anweisung formulierte Bedingung erfüllt, so werden alle Zeilen des Quellprogramms zwischen der #else-Anweisung (sofern eine solche existiert) und der #endif-Anweisung von der Compilation ausgeklammert. Ist die Bedingung dagegen nicht erfüllt, so werden umgekehrt alle Programmzeilen ignoriert, die zwischen der Bedingung und der #else-Anweisung, oder, falls keine #else-Anweisung existiert, zwischen der Bedingung und der #endif-Anweisung stehen.

[...]


[1] Verschiedene Hardware-Hersteller liefern Komponenten zu einem Gesamtsystem

[2] Objekt-Locking analog zum Record-Locking beim gemeinsamen Dateizugriff.

[3] Neben Zahlen und Zeichen: Bilder, Diagramme, Spreadsheets, Sound, Videos, CAD-Pläne, usw. Neben diesen Datenelementen verfügen Objekte ja auch noch über Methoden (Elementfunktionen), die es zu archivieren gilt.

[4] Ein Objekt, das ein oder mehrere Datenelemente hinsichtlich Werteüber- oder -unterschreitung überwacht und diese Wertebereichsverletzungen meldet oder auch behandelt. Objektorientierten Triggern stehen umfangreichere Möglichkeiten zur Verfügung als den in konventionellen Datenbanken möglichen Triggern.

[5] Object Oriented Operating System.

[6] Object Oriented Data Base Management System.

[7] Object Oriented Programming.

[8] Integrated Circuit

[9] Der Begriff wurde von Brad J. Cox in "System-building with Software-ICs" geprägt.

[10] Objektorientierte Analyse

[11] Objektorientiertes Design

[12] Wenn Sie wollen, teilen Sie mir doch mit, in welcher Sprachlandschaft Sie zuhause sind! Sie erreichen mich entweder via eMail: illik@ambit.de oder per Fax: 07723.50267.

[13] Von manchen Sprachtheoretikern wird dieser hybride Charakter der Sprache C++ schwer angekreidet. Vom pragmatischen Standpunkt aus betrachtet bietet eine hybride Sprache aber durchaus gewichtige Vorteile: ein prozedurales Team kann sukzessive in die Objektorientiertheit hineinwachsen oder auch ein gemischtes Team mit prozeduralen und objektorientierten Mitgliedern kann in einem Projekt erfolgreich zusammenarbeiten. Außerdem ist der prozedurale Anteil auch an einer objektorientierten Lösung häufig in größerem Umfang sinnvoll, wie mancher zunächst annimmt.

[14] Das gilt für Programme mittleren und großen Umfangs.

[15] Bjarne Stroustrup: "The C++ Programming Language"; Addison-Wesley Puplishing Company; Reading, Massachusetts, USA, 1986

[16] M. Ellis & B. Stroustrup "The Annotated C++ Reference Manual", Addison-Wesley, 1989

[17] In unserem Kontext entspricht ein Modul einer Datei.

[18] Zum Beispiel: Memory-Mapped-Register, Shared-Memory. Für Ausdrücke, die als volatile gekennzeichnete Variablen oder Konstanten enthalten besteht für den Compiler Optimierungsverbot.

[19] Um die Erweiterung gegenüber dem klassischen C von Brian W. K ernighan und Dennis M. R itchie zu kennzeichnen, sind diese mit dem Hinweis "(nicht K&R)" gekennzeichnet.

[20] Auf UNIX-Systemen gilt die Heap als ausgeschöpft, wenn der Systemspeicher belegt ist: d.h. der Hauptspeicher ist vollständig belegt und der Page-/Swap-Space ist ebenfalls komplett belegt.

[21] Hier noch einige Präprozessoranweisungen, auf die wir nicht eingehen: #pragma, #error und #line.

[22] Manche C++-Compiler erwarten, dass ds Zeichen # gleich auf der ersten Spalte einer Zeile steht.

Autor

Zurück

Titel: Professional Programmer Series: C/C++