Beruflich Dokumente
Kultur Dokumente
C/C++
Das umfassende Handbuch
An den Leser
Es gibt dabei keine Vorgriffe auf den Stoff späterer Kapitel, so dass sich Anfänger pro-
blemlos von den Grundbegriffen zu den fortgeschrittenen Themen vorarbeiten kön-
nen. Sie können die nötige Theorie nicht nur leicht nachvollziehen, sondern lernen
ihren Nutzen auch im großen Zusammenhang kennen.
Dieses Buch wurde mit großer Sorgfalt geschrieben, geprüft und produziert. Sollten
Sie dennoch etwas nicht so vorfinden, wie Sie es erwarten, so zögern Sie nicht, mit
uns Kontakt aufzunehmen. Ihre Anmerkungen, Ihr Lob oder Ihre konstruktive Kritik
sind mir herzlich willkommen!
almut.poll@galileo-press.de
www.galileocomputing.de
Galileo Press · Rheinwerkallee 4 · 53227 Bonn
Auf einen Blick
4 Arithmetik ................................................................................................................ 83
Wir hoffen sehr, dass Ihnen dieses Buch gefallen hat. Bitte teilen Sie uns doch Ihre Meinung
mit. Eine E-Mail mit Ihrem Lob oder Tadel senden Sie direkt an die Lektorin des Buches:
almut.poll@galileo-press.de. Im Falle einer Reklamation steht Ihnen gerne unser Leserservice zur
Verfügung: service@galileo-press.de. Informationen über Rezensions- und
Schulungsexemplare erhalten Sie von: britta.behrens@galileo-press.de.
Informationen zum Verlag und weitere Kontaktmöglichkeiten finden Sie auf unserer Verlags-
website www.galileo-press.de. Dort können Sie sich auch umfassend und aus erster Hand
über unser aktuelles Verlagsprogramm informieren und alle unsere Bücher versandkostenfrei
bestellen.
Dieses Buch wurde gesetzt aus der TheAntiqua (9,35/13,7 pt) in FrameMaker.
Gedruckt wurde es auf chlorfrei gebleichtem Offsetpapier (70 g/m2).
Der Name Galileo Press geht auf den italienischen Mathematiker und Philosophen Galileo
Galilei (1564–1642) zurück. Er gilt als Gründungsfigur der neuzeitlichen Wissenschaft und
wurde berühmt als Verfechter des modernen, heliozentrischen Weltbilds. Legendär ist sein
Ausspruch Eppur si muove (Und sie bewegt sich doch). Das Emblem von Galileo Press ist der
Jupiter, umkreist von den vier Galileischen Monden. Galilei entdeckte die nach ihm benannten
Monde 1610.
ISBN 978-3-8362-2757-5
© Galileo Press, Bonn 2014
5., aktualisierte und überarbeitete Auflage 2014
Das vorliegende Werk ist in all seinen Teilen urheberrechtlich geschützt. Alle Rechte
vorbehalten, insbesondere das Recht der Übersetzung, des Vortrags, der Reproduktion,
der Vervielfältigung auf fotomechanischem oder anderen Wegen und der Speicherung in
elektronischen Medien.
Ungeachtet der Sorgfalt, die auf die Erstellung von Text, Abbildungen und Programmen
verwendet wurde, können weder Verlag noch Autor, Herausgeber oder Übersetzer für mögliche
Fehler und deren Folgen eine juristische Verantwortung oder irgendeine Haftung übernehmen.
Inhalt
Vorwort .................................................................................................................................................. 19
1 Einige Grundbegriffe 21
5
Inhalt
4 Arithmetik 83
5 Aussagenlogik 107
6
Inhalt
7 Modularisierung 181
7
Inhalt
9 Programmgrobstruktur 241
8
Inhalt
11 Kombinatorik 273
13 Sortieren 347
9
Inhalt
14 Datenstrukturen 393
10
Inhalt
11
Inhalt
12
Inhalt
13
Inhalt
22 Vererbung 805
14
Inhalt
15
Inhalt
16
Inhalt
17
Vorwort
Als die erste Auflage dieses Buches erschien, war Roman Herzog Präsident der Bun-
desrepublik Deutschland. Auf Herzog folgten Rau, Köhler, Wulff und Gauck und
jeweils eine neue Auflage dieses Buches. Jetzt liegt die fünfte, vollständig überarbei-
tete Auflage vor. Der Leitgedanke des Buches ist aber über all die Jahre gleich geblie-
ben. Dazu möchte ich aus dem Vorwort der ersten Auflage zitieren:
Ziel des Buches ist es, Leser ohne Vorkenntnisse auf ein professionelles Niveau
der C- und C++-Programmierung zu führen. Unter »Programmierung« wird dabei
weitaus mehr verstanden als die Beherrschung einer Programmiersprache. So
wie »Schreiben« mehr ist, als Wörter unter Beachtung der Regeln von Recht-
schreibung, Zeichensetzung und Grammatik zu Sätzen zusammenzufügen, ist
Programmieren mehr als die Erstellung formal korrekter Programme. Zum Pro-
grammieren gehört ein Überblick über die Grundlagen und die Anwendungen
der Programmierung. Der Leitgedanke dieses Buches ist es, wichtige Grundlagen
und Konzepte der Informatik darzustellen und unmittelbar mit der Programmie-
rung zu verknüpfen. Die Grundlagen liefern dann die Ideen zur Programmierung,
und die Programmierung liefert die Motivation für die Beschäftigung mit den
Grundlagen.
Dem ist auch heute nichts hinzuzufügen.
Es freut mich, dass ich mit Martin Guddat einen Kollegen gefunden habe, der die
Arbeit am Buch für die Amtsperioden der nächsten fünf Bundespräsidenten fort-
setzen wird. Dazu wünsche ich ihm viel Erfolg.
Ich verwende das Buch des Kollegen Kaiser seit mehreren Jahren in meinen eigenen
Vorlesungen und empfehle es immer wieder gerne als umfassendes und konsisten-
tes Werk, das eine breite Basis für die Programmierung legt. Umso mehr freut es
mich, dass ich die Gelegenheit bekomme, das Buch in den kommenden Jahren wei-
terzuführen, zu pflegen und an neue Entwicklungen anzupassen.
19
Vorwort
Dafür möchte ich Ulrich Kaiser meinen besonderen Dank aussprechen und hoffe,
dass ich seiner riesigen Vorarbeit gerecht werde.
Für die Durchsicht des gesamten Manuskripts, für seine Anmerkungen und seine
Änderungsvorschläge danken wir beide besonders Herrn Daniel Hacirisoglu!
20
Kapitel 1
1
Einige Grundbegriffe
Computer Science is no more about computers than astronomy is
about telescopes.
– Edsger W. Dijkstra
Womit beschäftigen wir uns in diesem Buch? Mit Informatik? Mit Programmierung?
Mit der Programmiersprache C/C++? Mit Computern? Alles scheint miteinander ver-
woben. Und dann sagt auch noch einer der bedeutendsten Informatiker und Pioniere
der Programmierung:
Sie sind vielleicht über das Interesse an Technik zu Computern und über das Inter-
esse an Computern zu Programmiersprachen gekommen. Wir möchten mit Ihnen
diesen Weg weitergehen und Sie über das Interesse an Programmiersprachen zur
Programmierung und über das Interesse an der Programmierung zur Informatik
führen. Ein weiter Weg, der merkwürdigerweise mit einem Kochrezept beginnt.
Im Internet habe ich das folgende Rezept zur Herstellung eines Pfannkuchens ge-
funden:
Zutaten:
50 g Butter oder Magarine
100 g Zucker
1 Pck. Vanillezucker
4 Eier
200 ml Milch
200 g Mehl
1 TL Backpulver
etwas Butter zum Ausbacken
Zubereitung:
Butter mit Zucker und Vanillezucker vermengen (dazu evtl. in der
Mikrowelle weich werden lassen). Die Eigelbe hinzufügen und
schaumig rühren, dann die Milch zugeben und unterrühren. Mehl
mit Backpulver über die Masse sieben und glatt rühren. Eiweiße steif
schlagen und zum Schluss unterheben.
21
1 Einige Grundbegriffe
Das Rezept gliedert sich in zwei Teile. Im ersten Teil werden die erforderlichen Zutaten
genannt, und im zweiten Teil wird die Zubereitung beschrieben. Die beiden Teile sind
wesentlich verschieden und gehören doch untrennbar zusammen. Ohne Zutaten ist
die Zubereitung nicht möglich, und ohne Zubereitung bleiben die Zutaten ungenieß-
bar. Außerdem sehen Sie, dass sich der Autor bei der Formulierung des Rezepts einer
bestimmten Fachsprache (schaumig rühren, steif schlagen, unterheben) bedient. Ohne
diese Fachsprache wäre die Anleitung wahrscheinlich weitschweifiger, umständlicher
und vielleicht sogar missverständlich. Die Verwendung einer Fachsprache setzt aller-
dings voraus, dass sich Autor und Leser des Rezepts zuvor (ausgesprochen oder unaus-
gesprochen) auf eine gemeinsame Terminologie verständigt haben.
Wir übertragen dieses Beispiel in unsere Welt – die Welt der Datenverarbeitung:
왘 Die Zutaten für das Rezept sind die Daten bzw. Datenstrukturen, die wir verarbei-
ten wollen.
왘 Die Zubereitungsvorschrift ist ein Algorithmus1, der festlegt, wie die Daten verar-
beitet werden sollen.
왘 Das Rezept insgesamt ist ein Programm, das alle Datenstrukturen (Zutaten) und
Algorithmen (Zubereitungsvorschriften) zum Lösen der gestellten Aufgabe ent-
hält.
왘 Die gemeinsame Terminologie, in der sich Autor und Leser des Rezepts verständi-
gen, ist die Programmiersprache, in der das Programm geschrieben ist. Die Pro-
grammiersprache muss dabei alle im Hinblick auf die Zutaten und die
Zubereitung bedeutsamen Informationen zweifelsfrei zu übermitteln.
왘 Die Küche ist die technische Infrastruktur zur Umsetzung von Rezepten in
schmackhafte Gerichte und ist vergleichbar mit einem Computer, seinem
Betriebssystem und den benötigten Entwicklungswerkzeugen.
왘 Der Koch übersetzt das Rezept in einzelne Arbeitsschritte in der Küche. Üblicher-
weise geht ein Koch in zwei Schritten vor. Im ersten Schritt bereitet er die Zutaten
einzeln und unabhängig voneinander vor (z. B. Kartoffeln kochen), um die Einzel-
teile dann in einem zweiten Schritt zusammenzufügen und abzuschmecken. In
der Datenverarbeitung sprechen wir in diesem Zusammenhang von Compiler und
Linker.
왘 Das fertige Gericht ist das lauffähige Programm, das vom Anwender (Esser)
genutzt (verzehrt) werden kann.
Nur, welche Rolle spielen wir in diesem Szenario? Sollte für uns kein Platz vorgesehen
sein? Nein, wir suchen uns die interessanteste Aufgabe aus:
1 Dieser Begriff geht zurück auf Abu Jafar Muhammad Ibn Musa Al-Khwarizmi, der als Bibliothekar
des Kalifen von Bagdad um 825 ein Rechenbuch verfasste und dessen Name in der lateinischen
Übersetzung von 1200 als »Algorithmus« angegeben wurde.
22
왘 Wir sind Autoren, die sich neue, schmackhafte Gerichte für unterschiedliche
Anlässe ausdenken und Rezepte bzw. Kochbücher mit den besten Kreationen ver-
1
öffentlichen.
Zurück zu den Grundbegriffen der Informatik. Wir haben informell folgende Begriffe
eingeführt:
왘 Datenstruktur
왘 Algorithmus
왘 Programm
Dabei haben Sie bereits erkannt, dass diese Begriffe untrennbar zusammengehören
und eigentlich nur unterschiedliche Facetten ein und desselben Themenkomplexes
sind.
23
1 Einige Grundbegriffe
In einem ersten Wurf versuchen wir, die Begriffe Algorithmus, Datenstruktur und
Programm einigermaßen exakt zu erfassen.
1.1 Algorithmus
Um unsere noch sehr vage Vorstellung von einem Algorithmus zu präzisieren, star-
ten wir mit einer Definition:
Bei dem Begriff »Algorithmus« denkt man heute sofort an »Programmierung«. Das
war nicht immer so. In der Tat gab es Algorithmen schon lange, bevor man auch nur
entfernt an Programmierung dachte. Bereits im antiken Griechenland wurden Algo-
rithmen zur Lösung mathematischer Probleme formuliert, so z. B. der euklidische
Algorithmus zur Bestimmung des größten gemeinsamen Teilers zweier Zahlen oder
das sogenannte Sieb des Eratosthenes zur Bestimmung aller Primzahlen unterhalb
einer vorgegebenen Schranke.2
Sie kennen den Algorithmus zur schrittweisen Berechnung des Quotienten zweier
Zahlen. Um etwa 84 durch 16 zu dividieren, gehen Sie nach einem Schema vor, das Sie
bereits in der Schule gelernt haben:
84 ÷ 16 = 5.25
80
40
32
80
80
0
2 Euklid von Alexandria (um 300 v. Chr.) und Eratosthenes von Kyrene (um 200 v. Chr.)
24
1.1 Algorithmus
Dieses Schema ist aber keine ausreichend präzise Verfahrensbeschreibung. Das Ver-
fahren sollte so beschrieben werden, dass es jemand quasi mechanisch ohne fremde
1
Hilfe anwenden kann. Sie erinnern sich noch an die Definition von Algorithmus.
Dort hatten wir im Zusammenhang mit einem Algorithmus die Begriffe Problem,
Anfangsdaten und Anweisungen verwendet. Als Erstes müssen Sie das Problem und
die Anfangsdaten identifizieren. Danach können Sie sich Gedanken über Anweisun-
gen zur Lösung des Problems machen:
Problem:
Anfangsdaten:
z = Zähler (z ⱖ 0)
n = Nenner (n > 0)
Anweisungen:
1. Bestimme die größte ganze Zahl x mit nx ⱕ z! Dies ist der Vorkomma-Anteil der
gesuchten Zahl.
2. Zur Bestimmung der Nachkommastellen fahre wie folgt fort:
2.1 Sind noch Nachkommastellen zu berechnen (d. h. a > 0)? Wenn nein, dann
beende das Verfahren!
2.2 Setze z = 10(z-nx)!
2.3 Ist z = 0, beende das Verfahren!
2.4 Bestimme die größte ganze Zahl x mit nx ⱕ z! Dies ist die nächste Ziffer.
2.5 Jetzt ist eine Ziffer weniger zu bestimmen. Vermindere also den Wert von a
um 1, und fahre anschließend bei 2.1 fort!
Führen Sie diese Anweisungen an dem Beispiel z = 84, n = 16 und a = 5 Schritt für
Schritt durch, und Sie werden sehen, dass sich das Ergebnis 5.25 ergibt.
Die einzelnen Anweisungen und ihre Abfolge können Sie sich durch ein sogenanntes
Flussdiagramm veranschaulichen. In einem solchen Diagramm werden alle beim
Ablauf des Algorithmus möglicherweise vorkommenden Wege unter Verwendung
bestimmter Symbole grafisch beschrieben. Die dabei zulässigen Symbole sind in
einer Norm (DIN 66001) festgelegt. Von den zahlreichen in dieser Norm festgelegten
3 Zunächst ist a die Anzahl der zu berechnenden Nachkommastellen. Im Verfahren verwenden wir
a als die Anzahl der noch zu berechnenden Nachkommastellen. Wir werden den Wert von a in
jedem Verfahrensschritt herunterzählen, bis a = 0 ist und keine Nachkommastellen mehr zu
berechnen sind.
25
1 Einige Grundbegriffe
Symbolen möchten wir Ihnen an dieser Stelle nur einige wenige vorstellen und sie
verwenden:
Allgemeine Operation
Verzweigung
Mit diesen Symbolen können Sie den zuvor nur sprachlich beschriebenen Algorith-
mus grafisch darstellen, wenn Sie zusätzlich die Abfolge der einzelnen Operationen
durch Richtungspfeile kennzeichnen:
Start
Eingabe: z, n, a
2.1 nein
a>0 Ende
ja
ja
2.3 z=0
nein
2.5 a=a–1
26
1.1 Algorithmus
In Abbildung 1.4 können Sie den Ablauf des Algorithmus für konkrete Anfangswerte
»mit dem Finger« nachfahren und erhalten so eine recht gute Vorstellung von der
1
Dynamik des Verfahrens.
Wir möchten Ihnen den Divisionsalgorithmus anhand des Flussdiagramms für kon-
krete Daten (z=84, n=16, a=4) Schritt für Schritt erläutern. Mehrfach durchlaufene
Teile zeichnen wir dabei entsprechend oft, nicht durchlaufene Pfade lassen wir weg:
Start
2.4 Ende
Ausgabe: »2« Ausgabe: »5«
Als Ergebnis erhalten wir die Ausgabe "5.25". Sie sehen, dass der Algorithmus
gewisse Verfahrensschritte (z. B. 2.1) mehrfach – allerdings mit unterschiedlichen
Daten – durchläuft. Die Daten steuern letztlich den konkreten Ablauf des Algorith-
mus. Das Verfahren zeigt im Ablauf eine gewisse Regelmäßigkeit – um nicht zu sagen
Monotonie. Gerade solche monotonen Aufgaben würde man sich gern von einer
Maschine abnehmen lassen. Eine Maschine müsste natürlich jeden einzelnen Ver-
fahrensschritt »verstehen«, um das Verfahren als Ganzes durchführen zu können.
Einige unserer Schritte (z. B. 2.2) erscheinen unmittelbar verständlich, während
andere (z. B. 2.4) ein gewisses mathematisches Vorverständnis voraussetzen. Je nach-
dem, welche Intelligenz man bei demjenigen (Mensch oder Maschine) voraussetzt,
der den Algorithmus durchführen soll, wird man an manchen Stellen noch präziser
formulieren und einen Verfahrensschritt gegebenenfalls in einfachere Teilschritte
zerlegen müssen.
27
1 Einige Grundbegriffe
Festgehalten werden sollte noch, dass wir von einem Algorithmus gefordert haben,
dass er nach endlich vielen Schritten zu einem Ergebnis kommt (terminiert). Dies ist
bei unserem Divisionsalgorithmus durch die Vorgabe der Anzahl der zu berechnen-
den Nachkommastellen sichergestellt, auch wenn in unserem konkreten Beispiel ein
vorzeitiger Abbruch eintritt. Würden wir das Abbruchkriterium fallenlassen, würde
unser Verfahren unter Umständen (z. B. bei der Berechnung von 10:3) nicht abbre-
chen, und eine mit der Berechnung beauftragte Maschine würde endlos rechnen. Es
ist zu befürchten, dass die Eigenschaft des Terminierens für manche Verfahren
schwer oder vielleicht auch gar nicht nachzuweisen ist.
1.2 Datenstruktur
Wir starten wieder mit einer Definition:
Als Beispiel betrachten wir ein Versandhaus, das seine Geschäftsvorfälle durch drei
Karteien organisiert: Eine Kundenkartei mit den personenbezogenen Daten aller
Kunden, eine Artikelkartei für die Stammdaten und den Lagerbestand aller lieferba-
ren Artikel und eine Bestellkartei für alle eingehenden Bestellungen (siehe Abbil-
dung 1.6).
Kunde Artikel
Kunde: 1234
Datum: 13.06.2013
Artikel: 12-3456
Anzahl: 1
Artikel: …
Anzahl: …
…
28
1.2 Datenstruktur
Ein einzelner Datensatz entspricht einer ausgefüllten Karteikarte. Auf jeder Kartei-
karte sind zwei Bereiche erkennbar. Links steht jeweils die Struktur der Daten, wäh-
1
rend rechts die konkreten Datenwerte stehen. Die Datensätze für Kunden, Artikel
und Bestellungen sind dabei strukturell verschieden. Neben der Struktur der Kartei-
karten ist natürlich auch noch die Organisation der einzelnen Karteikästen von
Bedeutung. Stellen Sie sich vor, dass die Kundendatei nach Kundennummern, die
Artikeldatei nach Artikelnummern und die Bestelldatei nach Bestelldatum sortiert
ist. Darüber hinaus gibt es noch Querverweise zwischen den Datensätzen der ver-
schiedenen Karteikästen. In der Bestelldatei finden Sie auf jeder Karteikarte z. B. Arti-
kelnummern und eine Kundennummer.
Die drei Karteikästen mit ihrer Sortierung, der Struktur ihrer Karteikarten und der
Querverweisstruktur bilden insgesamt die Datenstruktur. Beachten Sie, dass die kon-
kreten Daten – also das, was auf den ausgefüllten Karteikarten steht – nicht zur
Datenstruktur gehören. Die Datenstruktur legt nur die Organisationsform der Daten
fest, nicht jedoch die konkreten Datenwerte.
Das Problem, eine »bestmögliche« Organisationsform für Daten zu finden, ist im All-
gemeinen unlösbar, weil Sie dazu in der Regel gegenläufige Optimierungsaspekte in
Einklang bringen müssten. Sie könnten z. B. bei der oben dargestellten Datenstruktur
den Verbesserungsvorschlag machen, alle Kundendaten mit auf der Bestellkartei zu
vermerken, um die Rechnungsstellung zu erleichtern. Dadurch erhöht sich dann
aber der Aufwand, den Sie bei der Adressänderung eines Kunden in Kauf zu nehmen
hätten. Die Erstellung von Datenstrukturen, die alle Algorithmen eines bestimmten
Problemfeldes wirkungsvoll unterstützen, ist eine ausgesprochen schwierige Auf-
gabe, zumal man häufig zum Zeitpunkt der Festlegung einer Datenstruktur noch gar
nicht absehen kann, welche Algorithmen in Zukunft mit den Daten dieser Struktur
arbeiten werden.
29
1 Einige Grundbegriffe
Bei der Fülle der in der Praxis vorkommenden Probleme können Sie natürlich nicht
erwarten, dass Sie für alle Probleme passende Datenstrukturen bereitstellen können.
Sie müssen lernen, typische, immer wiederkehrende Bausteine zu identifizieren und
zu beherrschen. Aus diesen Bausteinen können Sie dann komplexere, jeweils an ein
bestimmtes Problem angepasste Strukturen aufbauen.
1.3 Programm
Ein Programm ist, im Gegensatz zu einer Datenstruktur oder einem Algorithmus,
etwas sehr Konkretes – zumindest dann, wenn Sie schon einmal ein Programm
erstellt oder benutzt haben.
Im Gegensatz zu einem Algorithmus fordern wir von einem Programm nicht expli-
zit, dass es terminiert. Viele Programme (z. B. ein Betriebssystem oder Programme
zur Überwachung und Steuerung technischer Anlagen) sind auch so konzipiert, dass
sie im Prinzip endlos laufen könnten.
30
1.4 Programmiersprachen
1.4 Programmiersprachen
Sie kennen das sicherlich aus dem einen oder anderen Internetforum zur Program- 1
mierung. Da fragt ein Newbie um Rat, und es entwickelt sich folgender Dialog:
Newbie: Hallo, ich bin neu hier und habe da eine Frage. Wie kann man in der Pro-
grammiersprache abc ...
Experte1: Hallo Newbie, ich kenne abc nicht. Ich programmiere aber schon seit Jah-
ren in xyz. In xyz kann man dein Problem ganz einfach lösen ...
Experte2: Also Experte1, du lebst ja völlig hinter dem Mond. Kein Mensch program-
miert heute mehr in xyz. So etwas macht man in uvw ...
Der Expertenstreit, ob nun xyz oder uvw die bessere Programmiersprache sei, wird
dann mit wachsender Schärfe über mehrere Wochen ausgefochten, bis beide Kontra-
henten ermüdet aufgeben, nicht ohne vorher noch einmal deutlich klarzustellen,
dass der jeweils andere keine Ahnung habe und jedes weitere Wort Zeitverschwen-
dung sei. Vielleicht kommt auch der Newbie noch mal zu Wort:
Newbie: Hallo, ich habe inzwischen eine Lösung gefunden. Es war eigentlich ganz
einfach ...
Lassen Sie sich auf solche zwecklosen ideologischen Grabenkriege, die seit Jahren mit
erstarrten Fronten geführt werden, nicht ein. Sicherlich gibt es Sprachen, die für den
einen oder anderen Anwendungszweck besser geeignet sind als andere, aber aus
Sicht der Informatik sind alle Sprachen gleich gut (oder eher schlecht). Wichtig ist,
dass es verschiedene Programmiersprachen gibt, denn nur diese Vielfalt und der
damit verbundene Wettbewerb sorgen für die stetige Weiterentwicklung aller Pro-
grammiersprachen.
Vielleicht hilft Ihnen ein bisschen Statistik weiter. Der Tiobe-Index (tiobe.com) listet
225 verschiedene Programmiersprachen, die in einer monatlichen Statistik auf ihre
Relevanz untersucht werden. Aktuell ergibt sich dabei das folgende Ranking:
1 C 17,8 %
2 Java 16,6 %
3 Objective-C 10,3 %
4 C++ 8,8 %
5 PHP 5,9 %
31
1 Einige Grundbegriffe
Betrachtet man innerhalb dieser Tabelle die Sprachen, die sich explizit auf C als
»Muttersprache« berufen, machen diese einen Anteil von über 40 % aus. Auch Pro-
grammiersprachen wie Java oder PHP sind sprachlich eng mit C verwandt, auch
wenn sie auf anderen Laufzeitkonzepten beruhen.
Diese Unterscheidung ist eigentlich viel wichtiger als die Unterscheidung in einzelne
Programmiersprachen, denn wer eine Sprache eines bestimmten Paradigmas
beherrscht, dem fällt es in der Regel leicht, auf eine andere Sprache des gleichen Para-
digmas zu wechseln. Sie lernen hier mit C das prozedurale und mit C++ das objekto-
rientierte Paradigma und sind damit für über 90 % aller Fälle bestens gerüstet.
Wenn Sie Ihre Programmierkenntnisse beruflich nutzen wollen, können Sie in der
Regel die Programmiersprache, die in einem Softwareprojekt verwendet wird, nicht
frei wählen. Die Sprache ist meistens durch innere oder äußere Randbedingungen
festgelegt. In dieser Situation ist es wichtig, dass Sie »programmieren« können, und
darunter verstehe ich weitaus mehr als die Beherrschung einer Programmiersprache.
Wenn ein Verlag einen Autor sucht, dann wird jemand gesucht, der »schreiben« kann.
Dabei bedeutet »schreiben« mehr als die bloße Beherrschung von Rechtschreibung
und Grammatik. In diesem Sinne versteht sich dieses Buch als ein Lehrbuch zum Pro-
grammieren, wobei programmieren weitaus mehr ist als die Beherrschung einer kon-
kreten Programmiersprache. Eines der bedeutendsten Bücher der Informatik heißt:
In diesem mehrbändigen Werk finden Sie nicht eine einzige Zeile Code in einer kon-
kreten Programmiersprache.
4 Unter dem Paradigma einer Programmiersprache versteht man, locker gesprochen, die »Denke«,
die hinter einer Programmiersprache steckt.
5 Donald E. Knuth, The Art of Computer Programming
32
1.5 Aufgaben
Natürlich macht Programmieren erst richtig Spaß, wenn das Ergebnis (z. B. ein Com-
puterspiel) am Ende über den Bildschirm eines Computers flimmert. Darum nehmen
1
konkrete Programmierbeispiele in C und C++ in diesem Buch breiten Raum ein.
1.5 Aufgaben
A 1.1 Formulieren Sie Ihr morgendliches Aufsteh-Ritual vom Klingeln des Weckers
bis zum Verlassen des Hauses als Algorithmus. Berücksichtigen Sie dabei auch
verschiedene Wochentagsvarianten! Zeichnen Sie ein Flussdiagramm!
A 1.2 Verfeinern Sie den Algorithmus zur Division zweier Zahlen aus Abschnitt 1.1
so, dass er von jemandem, der nur Zahlen addieren, subtrahieren und der
Größe nach vergleichen kann, durchgeführt werden kann! Zeichnen Sie ein
Flussdiagramm!
A 1.3 In unserem Kalender sind zum Ausgleich der astronomischen und der kalen-
darischen Jahreslänge in regelmäßigen Abständen Schaltjahre eingebaut. Zur
exakten Festlegung der Schaltjahre dienen die folgenden Regeln:
1. Ist die Jahreszahl durch 4 teilbar, ist das Jahr ein Schaltjahr.
Diese Regel hat allerdings eine Ausnahme:
2. Ist die Jahreszahl durch 100 teilbar, ist das Jahr kein Schaltjahr.
Diese Ausnahme hat wiederum eine Ausnahme:
3. Ist die Jahreszahl durch 400 teilbar, ist das Jahr doch ein Schaltjahr.
Formulieren Sie einen Algorithmus, mit dessen Hilfe man feststellen kann, ob
ein bestimmtes Jahr ein Schaltjahr ist oder nicht!
A 1.4 Sie sollen eine unbekannte Zahl x (a ⱕ x ⱕ b) erraten und haben beliebig viele
Versuche dazu. Bei jedem Versuch erhalten Sie die Rückmeldung, ob die
gesuchte Zahl größer, kleiner oder gleich der von Ihnen geratenen Zahl ist.
Entwickeln Sie einen Algorithmus, um die gesuchte Zahl möglichst schnell zu
ermitteln! Wie viele Versuche benötigen Sie bei Ihrem Verfahren maximal?
A 1.5 Formulieren Sie einen Algorithmus, der prüft, ob eine gegebene Zahl eine
Primzahl ist oder nicht!
A 1.6 Ihr CD-Ständer hat 100 Fächer, die fortlaufend von 1–100 nummeriert sind. In
jedem Fach befindet sich eine CD. Formulieren Sie einen Algorithmus, mit
dessen Hilfe Sie die CDs alphabetisch nach Interpreten sortieren können! Das
Verfahren soll dabei auf den beiden folgenden Grundfunktionen basieren:
vergleiche(n,m)
33
1 Einige Grundbegriffe
Vergleichen Sie CDs in den Fächern n und m. Das Ergebnis ist »richtig« oder
»falsch« – je nachdem, ob die beiden CDs in der richtigen oder falschen Rei-
henfolge im Ständer stehen.
tausche(n,m)
A 1.7 Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die CDs in Ihrem CD-
Ständer jeweils um ein Fach aufwärts verschieben können! Die dabei am Ende
herausgeschobene CD kommt in das erste Fach. Das Verfahren soll nur auf der
Grundfunktion tausche aus Aufgabe 1.6 beruhen.
A 1.8 Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die Reihenfolge der
CDs in Ihrem CD-Ständer umkehren können! Das Verfahren soll nur auf der
Grundfunktion tausche aus Aufgabe 1.6 beruhen.
A 1.9 In einem Hochhaus mit 20 Stockwerken gibt es einen Aufzug. Im Aufzug sind
20 Knöpfe, mit denen man sein Fahrziel wählen kann, und auf jeder Etage ist
ein Knopf, mit dem man den Aufzug rufen kann. Entwickeln Sie einen Algo-
rithmus, der den Aufzug so steuert, dass alle Aufzugbenutzer gerecht bedient
werden!
A 1.10 Beim Schach gibt es ein einfaches Endspiel, wenn die eine Seite den König und
einen Turm, die andere Seite dagegen nur noch den König auf dem Spielfeld
hat:
Versuchen Sie, den Algorithmus für das Endspiel so zu formulieren, dass auch
ein Nicht-Schachspieler die Spielstrategie versteht!
34
Kapitel 2
Einführung in die Programmierung 2
Bevor wir in den Mikrokosmos der C-Programmierung abtauchen, wollen wir Soft-
waresysteme und ihre Erstellung von einer höheren Warte aus betrachten. Dieser
Abschnitt dient der Einordnung dessen, was Sie später im Detail kennenlernen wer-
den, in einen Gesamtzusammenhang. Auch wenn Ihnen noch nicht alle Begriffe, die
hier fallen werden, unmittelbar klar sind, ist es doch hilfreich, wenn Sie bei den vielen
Details, die später wichtig werden, den Blick für das Ganze nicht verlieren.
2.1 Softwareentwicklung
Damit ein Problem durch ein Softwaresystem gelöst werden kann, muss es zunächst
einmal erkannt, abgegrenzt und adäquat beschrieben werden. Der Softwareinge-
nieur spricht in diesem Zusammenhang von Systemanalyse. In einem weiteren
Schritt wird das Ergebnis der Systemanalyse in den Systementwurf überführt, der
dann Grundlage für die nachfolgende Realisierung oder Implementierung ist. Der
Softwareentwicklungszyklus beginnt also nicht mit der Programmierung, sondern
es gibt wesentliche, der Programmierung vorgelagerte, aber auch nachgelagerte Akti-
vitäten.
Obwohl wir in diesem Buch nur die »Softwareentwicklung im Kleinen« und hier auch
nur Realisierungsaspekte behandeln werden, möchten wir Sie doch zumindest auf
einige Aktivitäten und Werkzeuge der »Softwareentwicklung im Großen« hinweisen.
Für die Realisierung großer Softwaresysteme muss zunächst einmal ein sogenanntes
Vorgehensmodell zugrunde gelegt werden. Ausgangspunkt sind dabei Standardvor-
gehensmodelle wie etwa das V-Modell:
35
2 Einführung in die Programmierung
System
Anforderungs- System
analyse Integration
DV
DV
Anforderungs-
Integration
analyse
Software
Anforderungs-
analyse
Software
Integration
Software
Grobentwurf
Software
Feinentwurf
Implementierung
Große Unternehmen verfügen in der Regel über eigene Vorgehensmodelle zur Soft-
wareentwicklung. Ein solches allgemeines Modell muss auf die Anforderungen eines
konkreten Entwicklungsvorhabens zugeschnitten werden. Man spricht in diesem
Zusammenhang von Tailoring. Das auf ein konkretes Projekt zugeschnittene Vorge-
hensmodell nennt dann alle prinzipiell anfallenden Projektaktivitäten mit den zuge-
ordneten Eingangs- und Ausgangsprodukten (Dokumente, Code ...) sowie deren
mögliche Zustände (geplant, in Bearbeitung, vorgelegt, akzeptiert) im Laufe der Ent-
wicklung. Durch Erstellung einer Aktivitätenliste, Aufwandsschätzungen, Reihenfol-
geplanung und Ressourcenzuordnung1 entsteht ein Projektplan. Wesentliche
Querschnittsaktivitäten eines Projektplans sind:
36
2.1 Softwareentwicklung
왘 Implementierung
왘 Test und Integration
왘 Qualitätssicherung 2
Diese übergeordneten Tätigkeiten werden dabei oft noch in viele (hundert) Einzelak-
tivitäten zerlegt. Der Projektplan wird durch regelmäßige Reviews überprüft (Soll-Ist-
Vergleich) und dem wirklichen Projektstand angepasst. Ziel ist es, Entwicklungseng-
pässe, Entwicklungsverzögerungen und Konfliktsituationen rechtzeitig zu erkennen,
um wirkungsvoll gegensteuern zu können.
Für alle genannten Aktivitäten gibt es Methoden und Werkzeuge, die den Softwarein-
genieur bei seiner Arbeit unterstützen. Einige davon seien im Folgenden aufgezählt:
Für die Projektplanung gibt es Werkzeuge, die Aktivitäten und deren Abhängigkeiten
sowie Aufwände und Ressourcen erfassen und verwalten können. Solche Werkzeuge
können dann konkrete Zeitplanungen auf Basis von Aufwandsschätzungen und Res-
sourcenverfügbarkeit erstellen. Mithilfe der Werkzeuge erstellt man dann Aktivitä-
ten-Abhängigkeitsdiagramme (Pert-Charts) und Aktivitäten-Zeit-Diagramme (Gantt-
Charts) sowie Berichte über den Projektfortschritt, aufgelaufene Projektkosten, Soll-
Ist-Vergleiche, Auslastung der Mitarbeiter etc.
37
2 Einführung in die Programmierung
Werkzeuge zur Generierung bzw. Durchführung von Testfällen und zur Leistungs-
messung runden den Softwareentwicklungsprozess in Richtung Test und Qualitäts-
sicherung ab.
Von den oben angesprochenen Themen interessiert uns hier nur die konkrete Imple-
mentierung von Softwaresystemen. Betrachtet man komplexe, aber gut konzipierte
Softwaresysteme, findet man häufig eine Aufteilung (Modularisierung) des Systems
in verschiedene Ebenen oder Schichten. Die Aufteilung erfolgt so, dass jede Schicht
die Dienstleistungen der darunterliegenden Schicht nutzt, ohne deren konkrete Im-
plementierung zu kennen. Typische Schichten eines Grobdesigns sehen Sie in Abbil-
dung 2.2.
Visualisierung
Interaktion
Kommunikation
Synchronisation
Funktion
Datenzugriff
Auf der Ebene der Visualisierung werden die Elemente der Benutzerschnittstelle (Mas-
ken, Dialoge, Menüs, Buttons ...), aber auch Grafikfunktionen bereitgestellt. Früher
wurde auf dieser Ebene mit Maskengeneratoren gearbeitet. Heute findet man hier
objektorientierte Klassenbibliotheken und Werkzeuge zur interaktiven Erstellung von
Benutzeroberflächen. Angestrebt wird eine konsequente Trennung von Form und
Inhalt. Das heißt, das Layout der Elemente der Benutzerschnittstelle wird getrennt von
den Funktionen des Systems. Unter Interaktion sind die Funktionen zusammenge-
fasst, die die anwendungsspezifische Steuerung der Benutzerschnittstelle ausmachen.
Einfache, nicht anwendungsbezogene Steuerungen, wie z. B. das Aufklappen eines
38
2.1 Softwareentwicklung
Von den zuvor genannten Aspekten betrachten wir, wie durch eine Lupe, nur einen
kleinen Ausschnitt, und zwar die Realisierung einzelner Anwendungsfunktionen:
Visualisierung
Synchronisa
Interaktion
Interakt
ak
kttiio
o
Kommunikation
Synchronisation
Funktion
Fu
un
Datenzugriff
Datenzugrif
gri
g
grif
rriiiff
ffff
39
2 Einführung in die Programmierung
In den Schichten Visualisierung und Interaktion werden wir uns auf das absolute
Minimum beschränken, das wir benötigen, um lauffähige Programme zu erhalten,
die Benutzereingaben entgegennehmen und Ergebnisse auf dem Bildschirm ausge-
ben können. Auch den Datenzugriff werden wir nur an sehr spartanischen Dateikon-
zepten praktizieren. Kommunikation und Synchronisation behandeln wir hier gar
nicht. Diese Themen werden in Büchern über Betriebssysteme oder verteilte Sys-
teme thematisiert.
Programmtext
erstellen bzw.
Editor modifizieren
Programmtext
Compiler übersetzen
Ausführbares
Linker Programm
erzeugen
Programm
Debugger ausführen und
testen
Programm
Profiler analysieren und
optimieren
40
2.2 Die Programmierumgebung
Der Programmierer wird bei jedem dieser Schritte von folgenden Werkzeugen unter-
stützt:
왘 Editor 2
왘 Compiler
왘 Linker
왘 Debugger
왘 Profiler
Sie werden diese Werkzeuge hier nur grundsätzlich kennenlernen. Es ist absolut not-
wendig, dass Sie, parallel zur Arbeit mit diesem Buch, eine Entwicklungsumgebung
zur Verfügung haben, mit der Sie Ihre C/C++-Programme erstellen. Um welche Ent-
wicklungsumgebung es sich dabei handelt, ist relativ unwichtig, da wir uns mit unse-
ren Programmen nur in einem Bereich bewegen werden, der von allen Entwicklungs-
umgebungen unterstützt wird. Alle konkreten Details über Editor, Compiler, Linker,
Debugger und Profiler entnehmen Sie bitte den Handbüchern Ihrer Entwicklungs-
umgebung!
Üben Sie gezielt den Umgang mit den Funktionen Ihres Editors, denn auch die
»handwerklichen« Aspekte der Programmierung sind wichtig!
Mit dem Editor als Werkzeug erstellen wir unsere Programme, die wir in Dateien
ablegen. Im Zusammenhang mit der C-Programmierung sind dies:
왘 Header-Dateien
왘 Quellcodedateien
41
2 Einführung in die Programmierung
Den Typ (Header oder Source) einer Datei können Sie bereits am Namen der Datei
erkennen. Header-Dateien sind an der Dateinamenserweiterung .h, Quellcodeda-
teien an der Erweiterung .c in C bzw. .cpp und .cc in C++ zu erkennen.
Der Compiler übersetzt den Quellcode (die C- oder CPP-Dateien) in den sogenannten
Objectcode und nimmt dabei verschiedene Prüfungen zur Korrektheit des übergebe-
nen Quellcodes vor. Alle Verstöße gegen die Regeln der Programmiersprache5 wer-
den durch gezielte Fehlermeldungen unter Angabe der Zeile angezeigt. Nur ein
vollständig fehlerfreies Programm kann in Objectcode übersetzt werden. Viele Com-
piler mahnen auch formal zwar korrekte, aber möglicherweise problematische
Anweisungen durch Warnungen an. Bei der Fehlerbeseitigung sollten Sie strikt in der
Reihenfolge, in der der Compiler die Fehler gemeldet hat, vorgehen. Denn häufig fin-
det der Compiler nach einem Fehler nicht den richtigen Wiederaufsetzpunkt und
meldet Folgefehler in Ihrem Programmcode, die sich bei genauem Hinsehen als gar
nicht vorhanden erweisen.
Der Compiler erzeugt zu jedem Sourcefile genau ein Objectfile, wobei nur die innere
Korrektheit des Sourcefiles überprüft wird. Übergreifende Prüfungen können hier
noch nicht durchgeführt werden. Der vom Compiler erzeugte Objectcode ist daher
auch noch nicht lauffähig, denn ein Programm besteht in der Regel aus mehreren
Sourcefiles, deren Objectfiles noch in geeigneter Weise kombiniert werden müssen.
42
2.2 Die Programmierumgebung
Letztlich erstellt der Linker das ausführbare Programm, zu dem auch weitere Funkti-
ons- oder Klassenbibliotheken hinzugebunden werden können. Bibliotheken enthal-
ten kompilierte Funktionen, zu denen zumeist kein Quellcode verfügbar ist, und
werden z. B. vom Betriebssystem oder dem C-Laufzeitsystem zur Verfügung gestellt.
Im Internet finden Sie viele nützliche, freie oder kommerzielle Bibliotheken, die
Ihnen die Programmierarbeit sehr erleichtern können.
Bei der Fehlersuche in Ihren Programmen bedenken Sie stets, was Brian Kernighan,
neben Dennis Ritchie und Ken Thomson einer der Väter der Programmiersprache C,
in dem eingangs bereits erwähnten Zitat sagt, das frei übersetzt lautet:
Fehlersuche ist doppelt so schwer wie das Schreiben von Code. Wenn man also
versucht, den Code so intelligent wie möglich zu schreiben, ist man prinzipiell
nicht in der Lage, seine Fehler zu finden.
43
2 Einführung in die Programmierung
verbrauchs optimieren. Ein besseres Zeitverhalten erkauft man oft mit einem höhe-
ren Speicherbedarf und einen geringeren Speicherbedarf mit einer längeren Laufzeit.
Sie kennen das von der Kaufentscheidung für ein Auto. Wenn Sie mehr transportie-
ren wollen, müssen Sie Einschränkungen bei der Höchstgeschwindigkeit hinneh-
men. Wenn Sie umgekehrt ein schnelles Auto wollen, haben Sie in der Regel weniger
Raum. Im Extremfall müssen Sie sich zwischen einem Lkw und einem Sportwagen
entscheiden.
Die Analyse der Speicher- und Laufzeitkomplexität von Programmen gehört zur pro-
fessionellen Softwareentwicklung wie die Analyse der Effizienz eines Motors zu einer
professionellen Motorenentwicklung. Ein ineffizientes Programm ist wie ein Motor,
der die zugeführte Energie überwiegend in Abwärme umsetzt.
44
Kapitel 3
Ausgewählte Sprachelemente von C
3
Hello, World
– Sprichwörtlich gewordene Ausgabe eines C-Programms von Brian
Kernighan
Dieses Kapitel führt im Vorgriff auf spätere Kapitel einige grundlegende Programm-
konstrukte sowie Funktionen zur Tastatureingabe bzw. Bildschirmausgabe ein. Ziel
dieses Kapitels ist es, Ihnen das minimal notwendige Rüstzeug zur Erstellung kleiner,
interaktiver Beispielprogramme bereitzustellen. Es geht in den Beispielen dieses
Kapitels noch nicht darum, komplizierte Algorithmen zu entwickeln, sondern sich
anhand einfacher, überschaubarer Beispiele mit Editor, Compiler und gegebenenfalls
Debugger vertraut zu machen. Es ist daher wichtig, dass Sie die Beispiele – so banal
sie Ihnen anfänglich auch erscheinen mögen – in Ihrer Entwicklungsumgebung edi-
tieren, kompilieren, linken und testen.
3.1 Programmrahmen
Der minimale Rahmen für unsere Beispielprogramme sieht wie folgt aus:
A # include <stdio.h>
B # include <stdlib.h>
void main()
{
C ...
...
...
D ...
...
...
}
45
3 Ausgewählte Sprachelemente von C
Die beiden ersten mit # beginnenden Zeilen (mit A und B am Rand gekennzeichnet)
übernehmen Sie einfach in Ihren Programmcode. Ich werde später etwas dazu sagen.
Das eigentliche Programm besteht aus einem Hauptprogramm, das in C mit main
bezeichnet werden muss. Den Zusatz void und die hinter main stehenden runden
Klammern werde ich ebenfalls später erklären.
Die auf main folgenden geschweiften Klammern umschließen den Inhalt des Haupt-
programms, der aus Variablendefinitionen (im mit C markierten Bereich) und Pro-
grammcode (im folgenden Bereich D) besteht. Geschweifte Klammern kommen in
der Programmiersprache C immer vor, wenn etwas zusammengefasst werden soll.
Geschweifte Klammern treten immer paarig auf. Sie sollten die Klammern so einrü-
cken, dass man sofort erkennen kann, welche schließende Klammer zu welcher öff-
nenden Klammer gehört. Das erhöht die Lesbarkeit Ihres Codes.
Der hier gezeigte Rahmen stellt bereits ein vollständiges Programm dar, das Sie kom-
pilieren, linken und starten können. Sie können natürlich nicht erwarten, dass dieses
Programm irgendetwas macht. Damit das Programm etwas macht, müssen wir den
Bereich zwischen den geschweiften Klammern mit Variablendefinitionen und Pro-
grammcode füllen.
3.2 Zahlen
Natürlich benötigen wir in unseren Programmen gelegentlich konkrete Zahlenwerte.
Man unterscheidet dabei zwischen ganzen Zahlen, z. B.:
1234
–4711
1.234
–47.11
Diese Schreibweisen sind Ihnen bekannt. Wichtig ist, dass bei Gleitkommazahlen,
den angelsächsischen Konventionen folgend, ein Dezimalpunkt verwendet wird.
3.3 Variablen
Variablen bilden das »Gedächtnis« eines Computerprogramms. Sie dienen dazu,
Datenwerte eines bestimmten Typs zu speichern, die wir für unser Programm benö-
tigen. Bei den Typen denken wir vorerst nur an Zahlen, also ganze Zahlen oder Gleit-
kommazahlen. Später werden auch andere Datentypen hinzukommen.
46
3.3 Variablen
Den Namen vergibt der Programmierer. Der Name dient dazu, die Variable im Pro-
gramm eindeutig ansprechen zu können. Denkbare Typen sind derzeit »ganze Zahl«
oder »Gleitkommazahl«. Der Speicherbereich, in dem eine Variable angelegt ist, wird
durch den Compiler/Linker festgelegt und soll uns im Moment nicht interessieren.
Zunächst möchten wir Ihnen erläutern, wie Sie Variablen in einem Programm anle-
gen und wie Sie sie dann mit Werten versehen.
Variablen müssen vor ihrer erstmaligen Verwendung angelegt (definiert) werden.
Dazu wird im Programm der Typ der Variablen, gefolgt vom Variablennamen, ange-
geben (A). Die Variablendefinition wird durch ein Semikolon abgeschlossen. Mehrere
solcher Definitionen können aufeinanderfolgen, und mehrere Variablen gleichen
Typs können in einem Zug definiert werden (B):
# include <stdio.h>
# include <stdlib.h>
void main()
{
A int summe;
float hoehe;
B int a, b, c;
}
Sie sehen hier zwei verschiedene Typen: int und float. Der Typ int1 steht für eine
ganze Zahl, float2 für eine Gleitkommazahl. Für numerische Berechnungen würde
47
3 Ausgewählte Sprachelemente von C
eigentlich der Typ float ausreichen, da eine ganze Zahl immer als Gleitkommazahl
dargestellt werden kann. Es ist aber sinnvoll, diese Unterscheidung zu treffen, da ein
Computer mit ganzen Zahlen sehr viel effizienter umgehen kann als mit Gleitkom-
mazahlen. Das Rechnen mit ganzen Zahlen ist darüber hinaus exakt, während das
Rechnen mit Gleitkommazahlen immer mit Ungenauigkeiten verbunden ist. Auf der
anderen Seite haben Gleitkommazahlen einen erheblich größeren Rechenbereich als
ganze Zahlen und werden dringend benötigt, wenn man sehr kleine oder sehr große
Zahlen verarbeiten will. Grundsätzlich sollten Sie aber, wann immer möglich, den
Datentyp int gegenüber float bevorzugen.
Der Variablenname kann vom Programmierer relativ frei vergeben werden und
besteht aus einer Folge von Buchstaben (keine Umlaute oder ß) und Ziffern. Zusätz-
lich erlaubt ist das Zeichen »_«. Das erste Zeichen eines Variablennamens muss ein
Buchstabe (oder »_«) sein. Grundsätzlich sollten Sie sinnvolle Variablennamen ver-
geben. Darunter verstehe ich Namen, die auf die beabsichtigte Verwendung der Vari-
ablen hinweisen. Variablennamen wie summe oder maximum helfen unter Umständen,
ein Programm besser zu verstehen. C unterscheidet im Gegensatz zu manchen ande-
ren Programmiersprachen zwischen Buchstaben in Groß- bzw. Kleinschreibung. Das
bedeutet, dass es sich bei summe, Summe und SUMME um drei verschiedene Variablen han-
delt. Vermeiden Sie mögliche Fehler oder Missverständnisse, indem Sie Variablenna-
men immer kleinschreiben.
3.4 Operatoren
Variablen und Zahlen an sich sind wertlos, wenn man nicht sinnvolle Operationen
mit ihnen ausführen kann. Spontan denkt man dabei sofort an die folgenden Opera-
tionen:
3.4.1 Zuweisungsoperator
Variablen können direkt bei ihrer Definition oder später im Programm Werte zuge-
wiesen werden. Die Notation dafür ist naheliegend:
48
3.4 Operatoren
# include <stdio.h>
# include <stdlib.h>
void main()
{ 3
A int summe = 1;
B float hoehe = 3.7;
C int a, b = 0, c;
D a = 1;
E hoehe = a;
F a = 2;
}
Bei einer Zuweisung steht links vom Gleichheitszeichen der Name einer zuvor defi-
nierten Variablen (A–F). Dieser Variablen wird durch die Zuweisung ein Wert gege-
ben. Als Wert kommen dabei konkrete Zahlen, aber auch Variablenwerte oder
allgemeinere Ausdrücke (Berechnungen, Formeln etc.) infrage. Variablen können
auch direkt bei der Definition initialisiert werden (A–C). Die Wertzuweisungen erfol-
gen in der angegebenen Reihenfolge, sodass wir im oben genannten Beispiel davon
ausgehen können, dass a bereits den Wert 1 hat, wenn die Zuweisung an hoehe erfolgt
(E). Zuweisungen sind nicht endgültig. Sie können den Wert einer Variablen jederzeit
durch eine erneute Zuweisung ändern. Nicht initialisierte Variablen wie a und c in
der Zeile (C) haben einen »Zufallswert«.
Wichtig ist, dass der zugewiesene Wert zum Typ der Variablen passt. Das bedeutet,
dass Sie einer Variablen vom Typ int nur einen int-Wert zuweisen können. Einer
float-Variablen können Sie dagegen einen int- oder einen float-Wert zuweisen, da
ja eine ganze Zahl problemlos auch als Gleitkommazahl aufgefasst werden kann.
Eine Zuweisungsoperation hat übrigens den zugewiesenen Wert wiederum als eige-
nen Wert, sodass Zuweisungen, wie im folgenden Beispiel gezeigt, kaskadiert werden
können:
a = b = c = 1;
49
3 Ausgewählte Sprachelemente von C
# include <stdio.h>
# include <stdlib.h>
void main()
{
int summe = 1;
float hoehe;
int a, b, c = 0;
Besondere Vorsicht ist bei der Verwendung nicht initialisierter Variablen geboten, da
das Ergebnis einer Operation auf nicht initialisierten Variablen undefiniert ist (B).
Die gleiche Variable kann auch auf beiden Seiten einer Zuweisung vorkommen (C).
In den Formelausdrücken auf der rechten Seite der Zuweisung können dabei die fol-
genden Operatoren verwendet werden:
50
3.4 Operatoren
Im Zweifel sollten Sie Klammern setzen, denn Klammern machen Formeln besser
lesbar und haben keinen Einfluss auf die Verarbeitungsgeschwindigkeit des Pro-
gramms.
3
Einige Beispiele:
int a;
float b;
float c;
a = 1;
b = (a+1)*(a+2);
c = (3.14*a – 2.7)/5;
Die Variable auf der linken Seite einer Zuweisung kann auch auf der rechten Seite
derselben Zuweisung vorkommen. Zuweisungen dieser Art sind nicht nur möglich,
sie kommen sogar ausgesprochen häufig vor. Zunächst wird der rechts vom Zuwei-
sungsoperator stehende Ausdruck vollständig ausgewertet, dann wird das Ergebnis
der Variablen links vom Gleichheitszeichen zugewiesen. Die Anweisung
a = a+1;
enthält also keinen mathematischen Widerspruch, sondern erhöht den Wert der
Variablen a um 1. Treffender wäre daher eigentlich die Notation:
a ← a+1;
+= x += y x=x+y
-= x -= y x=x–y
*= x *= y x=x*y
51
3 Ausgewählte Sprachelemente von C
/= x /= y x=x/y
%= x %= y x=x%y
In dem noch häufiger vorkommenden Fall einer Addition oder Subtraktion von 1
kann man noch einfacher formulieren:
Diese Operatoren gibt es in Präfix- und Postfixnotation. Das heißt, diese Operatoren
können ihrem Operanden voran- oder nachgestellt werden. Im ersten Fall wird der
Operator angewandt, bevor der Operand in einen Ausdruck eingeht, im zweiten Fall
erst danach. Das kann ein kleiner, aber bedeutsamer Unterschied sein. Betrachten Sie
dazu das folgende Beispiel:
int i, k;
i = 0;
A k = i++;
i = 0;
B k = ++i;
In der Postfix-Notation (A) wird der Wert von i erst nach der Zuweisung an k erhöht.
Also: k = 0. In der Präfix-Variante hingegen (B) wird der Wert von i vor der Zuweisung
an k erhöht. Also: k = 1.
Die Variable i hat in beiden Fällen im Anschluss an die Zuweisung den Wert 1.
Auf eine Besonderheit möchten wir Sie an dieser Stelle unbedingt hinweisen:
Im Falle einer Division wird in dieser Situation eine Division ohne Rest (Integer-Divi-
sion) durchgeführt.
52
3.4 Operatoren
a = (100*10)/100;
b = 100*(10/100);
3
Rein mathematisch müsste eigentlich in beiden Fällen 10 als Ergebnis herauskom-
men. Im Programm ergibt sich aber a = 10 und b = 0. Dabei handelt es sich nicht um
einen Rechen- oder Designfehler, das ist ein ganz wichtiges und gewünschtes Verhal-
ten. Die Integer-Division ist für die Programmierung mindestens genauso wichtig
wie die »richtige« Division.
Wenn Sie sich bei einer Integer-Division für den unter den Tisch fallenden Rest inte-
ressieren, können Sie diesen mit dem Modulo-Operator (%) ermitteln. Der Ausdruck
a = 20 %7;
berechnet den Rest, der bei einer Division von 20 durch 7 bleibt, und weist diesen der
Variablen a zu. Die Variable a hat also anschließend den Wert 6. Im Gegensatz zu den
anderen hier besprochenen Operatoren müssen bei einer Modulo-Operation beide
Operanden ganzzahlig und sollten sogar positiv sein.
Die Integer-Division bildet zusammen mit dem Modulo-Operator ein in der Pro-
grammierung unverzichtbares Operatorengespann. Ich möchte Ihnen das an einem
Beispiel erläutern. Stellen Sie sich vor, dass Sie im Rechner eine zweidimensionale
Struktur (z. B. ein Foto) mit einer gewissen Höhe (hoehe) und Breite (breite) ver-
walten:
spalte
0 1 2 3 4 5 6 7
0 00 01 02 03 04 05 06 07
zeile 1 10 11 12 13 14 15 16 17 hoehe
2 20 21 22 23 24 25 26 27
breite
53
3 Ausgewählte Sprachelemente von C
Dieses Bild werden Sie nun in eine eindimensionale Struktur (z. B. eine Datei) um-
speichern:
position
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
00 01 02 03 04 05 06 07 10 11 12 13 14 15 16 17 20 21 22 23 24 25 26 27
Wenn Sie aus Zeile und Spalte in der zweidimensionalen Struktur die Position in der
eindimensionalen Struktur berechnen möchten, geht das mit der Formel:
Um umgekehrt aus der Position die Zeile und die Spalte für einen Bildpunkt zu
berechnen, benötigen Sie die Integer-Division und den Modulo-Operator. Es ist
nämlich:
zeile = position/breite;
spalte = position%breite;
Beachten Sie dabei, dass alle Positionsangaben hier beginnend mit der Startposition
0 festgelegt sind. Das werden wir auch zukünftig immer so halten, da diese Festle-
gung zu einfacheren Positionsberechnungen führt, als wenn man mit der Position 1
beginnen würde. Also: Das 1. Element befindet sich an der Position 0, das 2. an der
Position 1 etc.
Das Beispiel zeigt, dass in der Integer-Welt das Tandem aus Integer-Division und
Modulo-Operation in gewisser Weise die Umkehrung der Multiplikation darstellt
und somit an die Stelle der »richtigen« Division tritt. Auf dieses Tandem werden Sie
immer wieder bei der Programmierung stoßen. Wenn Sie z. B. die drittletzte Ziffer
einer bestimmten Zahl im Dezimalsystem bestimmen wollen, erhalten Sie diese mit
der Formel:
ziffer = (zahl/100)%10;
Bedenken Sie aber immer, dass bei der Integer-Division eine Berechnung der Form
(a/b)*b nicht den Wert a als Ergebnis haben muss. Das Ergebnis ist im Vergleich zur
exakten Rechnung die nächstkleinere Zahl, die durch b teilbar ist.
54
3.4 Operatoren
3.4.3 Typkonvertierungen
Manchmal möchte man, obwohl man es nur mit Integer-Werten zu tun hat, eine
»richtige« Division durchführen und das Ergebnis einer Gleitkommazahl zuwei-
sen. Die bloße Zuweisung an eine Gleitkommazahl konvertiert das Ergebnis zwar
3
automatisch in eine Gleitkommazahl, aber erst nachdem die Division durchge-
führt wurde:
void main()
{
int a = 1, b = 2;
float x;
A x = a/b;
}
Bevor Sie nun künstlich eine Gleitkommazahl in die Division einbringen, können Sie
in der Formel eine Typkonvertierung durchführen. Sie ändern z. B. für die Berech-
nung (und nur für die Berechnung) den Datentyp von a in float, indem Sie der Vari-
ablen den gewünschten Datentyp in Klammern voranstellen:
void main()
{
int a = 1, b = 2;
float x;
A x = ((float)a)/b;
}
Durch die explizite Typumwandlung (A) wird a vor der Division in float konvertiert.
Das Ergebnis der Division ist dann 0.5.
Bei der Typumwandlung handelt es sich um einen einstelligen Operator – den soge-
nannten Cast-Operator. Eine Typumwandlung bezeichnet man auch als Typecast.
3.4.4 Vergleichsoperationen
Zahlen und Variablen können untereinander verglichen werden. Tabelle 3.4 zeigt die
in C verwendeten Vergleichsoperatoren:
55
3 Ausgewählte Sprachelemente von C
== x == y gleich
!= x != y ungleich
Auf der linken bzw. rechten Seite eines Vergleichsausdrucks können beliebige Aus-
drücke (üblicherweise handelt es sich um arithmetische Ausdrücke) mit Variablen
oder Zahlen stehen:
a < 7
a <= 2*(b+1)
a+1 == a*a
Das Ergebnis eines Vergleichs ist ein logischer Wert (»wahr« oder »falsch«), der in C
durch 1 (wahr) oder 0 (falsch) dargestellt wird. Mit diesem Wert können Sie dann, wie
mit einem durch einen arithmetischen Ausdruck gewonnenen Wert, weiterarbeiten.
Vergleiche stellt man allerdings üblicherweise nicht an, um mit dem Vergleichser-
gebnis zu rechnen, sondern um anschließend im Programm zu verzweigen. Man
möchte erreichen, dass das Programm in Abhängigkeit vom Ergebnis des Vergleichs
unterschiedlich fortfährt. Wie Sie das erreichen, erfahren Sie im nächsten Abschnitt
über den »Kontrollfluss«.
3.5 Kontrollfluss
Bei einem Programm kommt es ganz entscheidend darauf an, in welcher Reihenfolge
die einzelnen Anweisungen ausgeführt werden. Üblicherweise werden Anweisungen
in der Reihenfolge ihres Vorkommens im Programm ausgeführt. Sie haben aber im
Eingangsbeispiel (Divisionsalgorithmus aus der Schule) bereits gesehen, dass es
erforderlich ist, Fallunterscheidungen und gezielte Wiederholungen von Anwei-
sungsfolgen zu ermöglichen.
56
3.5 Kontrollfluss
3
Hier steht eine Bedingung
(zumeist ein Vergleichsausdruck).
if( a < 0)
a = -a;
Wenn der Wert von a kleiner als der Wert von b ist, dann tausche die Werte von a
und b:
if( a < b)
{
c = a;
a = b;
b = c;
}
Weise der Variablen max den größeren der Werte von a und b zu:
max = a;
if( a < b)
max = b;
Mit else können wir einem if-Ausdruck Anweisungen hinzufügen, die ausgeführt
werden sollen, wenn die if-Bedingung nicht zutrifft. Von der Struktur her sieht das
vollständige if-Statement dann wie folgt aus:
57
3 Ausgewählte Sprachelemente von C
if ( ... )
Die hier stehenden Anweisungen
{
Handelt es sich hier um eine einzelne werden ausgeführt, wenn die
...
Anweisung, können die geschweiften Bedingung erfüllt ist.
...
Klammern weggelassen werden. ...
}
else Die hier stehenden Anweisungen
{
werden ausgeführt, wenn die
Dieser Teil kann vollständig fehlen. ...
Bedingung nicht erfüllt ist.
...
...
Handelt es sich hier um eine einzelne }
Anweisung, können die geschweiften
Klammern weggelassen werden.
if( a < b)
max = b;
else
max = a;
if( a < b)
abst = b – a;
else
abst = a – b;
Die Prüfung, ob eine Bedingung erfüllt ist, ist letztlich eine Prüfung auf gleich oder
ungleich 0. Das heißt, wenn Sie eine Variable auf gleich oder ungleich 0 testen möch-
ten, können Sie die Bedingung vereinfachen:
if ( a != 0)
{
...
}
58
3.5 Kontrollfluss
if ( a)
{
...
}
3
Andersherum kann
if ( a == 0)
{
...
}
if ( !a)
{
...
}
Bei der Verwendung von if sollten Sie beachten, dass ein Vergleich auf Gleichheit
mit dem doppelten Gleichheitszeichen durchgeführt wird. Das einfache Gleichheits-
zeichen bedeutet eine Zuweisung. Die Verwechslung des Vergleichs auf Gleichheit
mit der Zuweisungsoperation ist einer der »beliebtesten« Anfängerfehler in C. Im fol-
genden Codefragment
if( a = 1)
b = 5;
wird zunächst der Variablen a der Wert 1 zugewiesen. Das Ergebnis dieser Zuweisung
ist 1, sodass die nachfolgende Zeile (b = 5) immer ausgeführt wird. Sagen Sie daher im
Beispiel oben nicht »if a gleich 1«, sondern »if a ist gleich 1«, dann kann Ihnen der Feh-
ler nicht so leicht unterlaufen.
59
3 Ausgewählte Sprachelemente von C
왘 Es gibt eine Reihe von Dingen, die zu tun sind, bevor man mit der Durchführung
der Schleife beginnen kann. Wir nennen dies die Initialisierung der Schleife.
왘 Zunächst muss eine Prüfung durchgeführt werden, ob die Bearbeitung der
Schleife abgebrochen oder fortgesetzt werden soll. Wir nennen dies den Test auf
Fortsetzung der Schleife.
왘 Bei jedem Schleifendurchlauf werden die eigentlichen Tätigkeiten durchgeführt.
Wir nennen dies den Schleifenkörper.
왘 Nach dem Ende eines einzelnen Schleifendurchlaufs müssen gewisse Operationen
durchgeführt werden, um den nächsten Schleifendurchlauf vorzubereiten. Wir
nennen dies das Inkrement der Schleife.
Machen Sie sich dies am Beispiel einer Routineaufgabe klar:
Sie haben Ihre Post erledigt und wollen die Briefe frankieren, bevor Sie sie zum Brief-
kasten bringen. Dazu stellen Sie zunächst die erforderlichen Hilfsmittel bereit. Sie
besorgen sich einen Bogen mit Briefmarken und legen den Stapel der unfrankierten
Briefe vor sich auf den Schreibtisch. Das ist die Initialisierung. Bevor Sie nun fortfahren,
prüfen Sie, ob der Stapel der Ausgangspost noch nicht abgearbeitet ist. Das ist der Test
auf Fortsetzung. Liegen noch Briefe vor Ihnen, treten Sie in den eigentlichen Arbeits-
prozess ein. Sie trennen eine Briefmarke ab, befeuchten sie auf der Rückseite und kle-
ben sie auf den obersten Brief des Stapels. Das ist der Schleifenkörper. Nachdem Sie
einen Brief frankiert haben, nehmen Sie ihn und legen ihn in den Postausgangskorb.
Das ist das Inkrement, mit dem Sie den nächsten Frankiervorgang vorbereiten. Danach
setzen Sie die Arbeit mit dem Test fort. Wir fassen Initialisierung, Test und Inkrement
unter dem Begriff Schleifenkopf zusammen und zeichnen ein Flussdiagramm:
Bearbeiteten Brief in
Postausgang legen Dies ist der Test.
Er wird immer vor dem nächsten
Dies ist das Inkrement. Arbeitsschritt durchgeführt.
Es wird nach jedem Sind
Arbeitsschritt durchgeführt. noch Briefe
nein
unbearbeitet?
ja
60
3.5 Kontrollfluss
In C gibt es ein Sprachelement, das das hier diskutierte Schleifenmuster exakt abbil-
det. Es handelt sich um die for-Anweisung, die sich aus Schleifenkopf und Schleifen-
körper zusammensetzt. Der Schleifenkopf enthält drei durch Semikolon getrennte
Ausdrücke, die die Abarbeitung der Schleife steuern:
3
Der Test wird vor jedem möglichen Eintritt in den
Die Initialisierung wird vor dem Schleifenkörper ausgewertet. Ergibt sich dabei ein Wert
ersten Eintritt in die Schleife ≠ 0, wird der Schleifenkörper ausgeführt. Andernfalls
einmal ausgeführt. wird die Bearbeitung der Schleife abgebrochen.
Der Test dient letztlich dazu, die Schleife abzubrechen. Deshalb spricht man oft etwas
oberflächlich von einer »Abbruchbedingung«. Dies suggeriert, dass die Schleife abge-
brochen wird, wenn der Test positiv ausfällt. Es ist aber genau umgekehrt: Die
Schleife wird abgebrochen, wenn der Test negativ ausfällt, bzw. fortgesetzt, wenn der
Test positiv ausfällt. In diesem Sinne handelt es sich also bei dem Test um eine »Wei-
termachbedingung«. Prägen Sie sich daher den irreführenden Begriff »Abbruchbe-
dingung« und das damit verbundene Bild erst gar nicht ein.
Eine der häufigsten Anwendungen von Schleifen ist die sogenannte Zählschleife.
Hier wird die Anzahl der Schleifendurchläufe über eine Zählvariable gesteuert. Kon-
kret kann das etwa so aussehen:
int i, summe;
summe = 0;
for( i = 1; i <= 100; i = i + 1)
summe = summe + i;
In dieser Schleife werden die Zahlen von 1 bis 100 aufsummiert. Das kann man natür-
lich auch rückwärts machen:
61
3 Ausgewählte Sprachelemente von C
summe = 0;
for( i = 100; i > 0; i = i – 1)
summe = summe + i;
Man kann auch mehrere Anweisungen durch Komma getrennt in die Initialisierung
oder das Inkrement der Schleife aufnehmen. In unserem Beispiel nehmen wir die Ini-
tialisierung der Summe mit in den Schleifenkopf auf:
In der folgenden Schleife wird eine Variable a von 1 ausgehend hoch- und eine Vari-
able b von 100 ausgehend heruntergezählt, solange a dabei kleiner als b bleibt:
Einzelne Felder im Schleifenkopf können auch leer gelassen werden. Ist die Initiali-
sierung oder das Inkrement leer, wird dort nichts gemacht. Ein leer gelassenes Feld
für den Test führt dazu, dass der Test immer positiv ausfällt. Achtung, beim folgen-
den Beispiel handelt es sich um eine Endlosschleife:
for( ; ; )
;
1. Sie stellen fest, dass oben auf dem Stapel ein Brief liegt, der an jemanden in der
Nachbarschaft gerichtet ist. Sie beschließen, das Porto zu sparen und den Brief
selbst vorbeizubringen. Dazu überspringen Sie die weitere Bearbeitung dieses
Briefes und legen den Brief unfrankiert in den Postausgang. Sie fahren dann mit
der Abarbeitung des Stapels fort.
2. Sie stellen fest, dass Ihnen die Briefmarken ausgegangen sind. Es bleibt Ihnen
nichts anderes übrig, als die Bearbeitung der Schleife vorzeitig abzubrechen.
62
3.5 Kontrollfluss
Bearbeiteten Brief in 3
Postausgang legen
Sind
noch Briefe
unbearbeitet? nein
ja
Brief nehmen
ja Brief an
Nachbarn?
Hier wird nur ein einzelner
Arbeitsschritt abgebrochen. nein
ja
Keine Marken
mehr?
Hier wird die Bearbeitung
nein komplett abgebrochen.
Marke abtrennen
Marke befeuchten
Marke aufkleben
Um auf Sonderfälle sinnvoll zu reagieren, müssen wir aus dem Schleifenkörper her-
aus in die Schleifensteuerung eingreifen. Das ist in C durch eine break- oder eine con-
tinue-Anweisung möglich:
63
3 Ausgewählte Sprachelemente von C
Beachten Sie noch einmal den Unterschied! Durch continue wird nur der aktuelle
Schleifendurchlauf abgebrochen, die Schleife insgesamt jedoch über Inkrement und
Test fortgesetzt. Durch break wird dagegen die Schleife sofort abgebrochen. Natürlich
kann es mehrere break- oder continue-Anweisungen in beliebiger Reihenfolge in
einer Schleife geben. Solche Anweisungen werden aber immer unter einer Bedin-
gung stehen. Ein unbedingtes break oder continue ist nicht sinnvoll, da der nachfol-
gende Code nie erreicht würde.
In die oben konstruierte Schleife zum Aufsummieren aller Zahlen zwischen 1 und
100 bauen wir jetzt zusätzlich eine continue-Anweisung ein, die dafür sorgt, dass alle
durch 7 teilbaren Zahlen bei der Summation übersprungen werden:
Beachten Sie, dass wir jetzt die geschweiften Klammern benötigen, da wir mehr als
eine Anweisung im Schleifenkörper haben. Was berechnet das Programm, wenn Sie
die geschweiften Klammern weglassen? Wenn Sie nicht sicher sind, probieren Sie es
aus.
Wir wollen zusätzlich noch einen harten Schleifenabbruch in unser Programm ein-
bauen:
Jetzt wird die Schleife sofort verlassen, wenn sich in summe ein Wert größer als 1000
ergibt. Wissen Sie, bei welchem Wert von i die Schleife jetzt abgebrochen wird und
welchen Wert die Variable summe dann hat? Implementieren Sie das Programm, um es
herauszufinden.
64
3.5 Kontrollfluss
Für die Testbedingung im Schleifenkopf gilt das bereits zur if-Bedingung Gesagte.
Bei einer Prüfung auf gleich oder ungleich 0 kann man vereinfachen:
3
Insbesondere muss auch hier wieder auf den Unterschied zwischen Test auf Gleich-
heit (==) und Zuweisung (=) hingewiesen werden.
Wenn eine Schleife keine Initialisierung und kein Inkrement benötigt, können Sie
anstelle einer for- auch eine while-Anweisung verwenden:
Die Anweisungen break und continue können bei while genauso wie bei for verwen-
det werden. Im Grunde genommen ist while entbehrlich, da es ein Spezialfall von for
ist. Umgekehrt könnte auch for vollständig durch while nachgebildet werden. Das ist
aber im Sinne einer guten Lesbarkeit der Programme nicht immer sinnvoll, da Initia-
lisierung und Inkrement der Schleife nicht mehr explizit ausgewiesen und im rest-
lichen Programm »versteckt« sind. Das kann bei Programmänderungen oder
-erweiterungen zu Problemen führen.
왘 ein if in einem if
왘 ein if in einem for
왘 ein for in einem for
65
3 Ausgewählte Sprachelemente von C
Als Beispiel betrachten wir ein Programm, das das »kleine Einmaleins« durch zwei
ineinander geschachtelte Zählschleifen berechnet:
Die Variable i durchläuft in der äußeren Schleife die Werte von 1 bis 10. Für jeden
Wert von i durchläuft dann die Variable k in der inneren Schleife ebenfalls die Werte
von 1 bis 10. Insgesamt wird damit die Berechnung in der inneren Schleife 100-mal
für alle möglichen Kombinationen von i und k ausgeführt.
Das folgende Programm berechnet das Produkt nur, wenn beide Faktoren gerade
sind:
Nur wenn i gerade ist, wird jetzt in die innere Schleife über k eingetreten, und dort
wird dann das Produkt nur dann berechnet, wenn k ebenfalls gerade ist.
Beachten Sie, dass in diesem Beispiel alle geschweiften Klammern und auch die Ein-
rückungen eigentlich überflüssig sind. Zusätzlich gesetzte Klammern und einheitli-
che Einrückungen verbessern aber die Lesbarkeit des Programms.
Das oben dargestellte Programm berechnet zwar das kleine Einmaleins, aber die
Ergebnisse verfliegen im luftleeren Raum. Sinnvoll wäre es, immer dann, wenn man
ein neues Ergebnis ermittelt hat, dieses auf dem Bildschirm auszugeben. Damit wer-
den wir uns im nächsten Abschnitt beschäftigen.
66
3.6 Elementare Ein- und Ausgabe
C hat keine Sprachelemente für Ein- oder Ausgabe. Ein- und Ausgabe werden nicht
durch die Sprache selbst, sondern durch sogenannte Funktionen erledigt. Die hinter
den Funktionen stehenden Konzepte werde ich Ihnen später vorstellen. Sie können
Funktionen aber auch verwenden, ohne genau verstanden zu haben, was bei der Ver-
wendung »unter der Haube« passiert. Sollten bei den folgenden Erklärungen noch
Fragen offen bleiben, versichere ich Ihnen, dass ich diese Fragen später ausführlich
beantworten werde.
3.6.1 Bildschirmausgabe
Um einen Text auf dem Bildschirm auszugeben, verwenden wir die Funktion printf
und schreiben:
In den auszugebenden Text können wir Zahlenwerte einstreuen, indem wir als Platz-
halter für die fehlenden Zahlenwerte eine sogenannte Formatanweisung in den Text
einfügen. Eine solche Formatanweisung besteht aus einem Prozentzeichen, gefolgt
von dem Buchstaben d (für Dezimalwert) oder f (für Gleitkommawert) – also %d oder
%f. Die zugehörigen Werte werden dann als Konstanten oder Variablen durch Kom-
mata getrennt hinter dem Text angefügt.
67
3 Ausgewählte Sprachelemente von C
Das Programmfragment
int wert = 1;
wert = 2;
Der auszugebende Wert kann auch ohne Verwendung einer Variablen direkt dort
berechnet werden, wo er benötigt wird:
Der Ausdruck 3*a+b wird zunächst vollständig ausgewertet, und das Ergebnis wird an
der durch %d markierten Stelle in die Ausgabe eingefügt.
float preis;
preis = 10.99;
printf( "Die Ware kostet %f EURO\n", preis);
Wichtig ist, dass die Formatanweisung exakt zum Typ des auszugebenden Werts
passt – also %d bei ganzen Zahlen und %f bei Gleitkommazahlen.
Wir wollen unser Beispiel zur Berechnung des kleinen Einmaleins jetzt mit einer Aus-
gabe ausstatten:
68
3.6 Elementare Ein- und Ausgabe
produkt = i*k;
printf( "%d mal %d ist %d\n", i, k, produkt);
}
printf( "\n");
} 3
Das Programm gibt jetzt das kleine Einmaleins auf dem Bildschirm aus und erzeugt
nach jedem Zehnerpäckchen einen zusätzlichen Zeilenvorschub. Das sieht so aus:
2 mal 6 ist 12
2 mal 7 ist 14
2 mal 8 ist 16
2 mal 9 ist 18
2 mal 10 ist 20
3 mal 1 ist 3
3 mal 2 ist 6
3 mal 3 ist 9
3 mal 4 ist 12
3 mal 5 ist 15
3 mal 6 ist 18
3 mal 7 ist 21
3 mal 8 ist 24
3 mal 9 ist 27
3 mal 10 ist 30
4 mal 1 ist 4
4 mal 2 ist 8
4 mal 3 ist 12
3.6.2 Tastatureingabe
Eine oder mehrere ganze Zahlen lesen wir mit der Funktion scanf von der Tastatur ein.
69
3 Ausgewählte Sprachelemente von C
Beim Einlesen müssen Variablen angegeben werden, denen die Werte zugewiesen
werden sollen. Wir stellen dazu dem Variablennamen ein & voran. Die exakte Bedeu-
tung des &-Zeichens können Sie im Moment noch nicht verstehen, sie wird später
erklärt. Lassen Sie das & jedoch nicht weg, auch wenn es Ihnen an dieser Stelle unmo-
tiviert erscheint.
Der zum oben dargestellten Programm gehörende Bildschirmdialog sieht bei ent-
sprechenden Benutzereingaben wie folgt aus:
Für die Eingabe von Gleitkommazahlen verwenden Sie dann natürlich Gleitkomma-
variablen und die Formatanweisung %f.
Wir können das Programm zur Ausgabe des kleinen Einmaleins jetzt so erweitern,
dass die Bereiche, in denen das kleine Einmaleins berechnet werden soll, durch den
Benutzer festgelegt werden. Hier sehen Sie das vollständige Programm dazu:
void main()
{
int i, k;
int maxi, maxk;
int produkt;
70
3.6 Elementare Ein- und Ausgabe
Der Benutzer wird aufgefordert, Maximalwerte für i und k einzugeben. Die eingege-
benen Werte werden dann in den Schleifen verwendet, um die zulässigen Werte für i
und k nach oben zu begrenzen. Das folgende Bild zeigt einen möglichen Programm-
lauf:
3
Bitte maxi eingeben: 3
Bitte maxk eingeben: 5
1 mal 1 ist 1
1 mal 2 ist 2
1 mal 3 ist 3
1 mal 4 ist 4
1 mal 5 ist 5
2 mal 1 ist 2
2 mal 2 ist 4
2 mal 3 ist 6
2 mal 4 ist 8
2 mal 5 ist 10
3 mal 1 ist 3
3 mal 2 ist 6
3 mal 3 ist 9
3 mal 4 ist 12
3 mal 5 ist 15
int zahl;
scanf( "ABC%dxyz", &zahl);
In diesem Fall erwartet scanf genau das in der Formatanweisung angegebene Muster
in der Eingabe, also ABC, gefolgt von einer Zahl, die der Variablen zahl zugewiesen
wird, und dann wiederum gefolgt von xyz. Auf diese Weise können Sie Ihre Eingaben
aus einem komplexeren Kontext »herauspicken« oder den Eingabetext in seine Ein-
zelbestandteile zerlegen. Diese strenge Auslegung der Eingabe verlangt allerdings
vom Benutzer, dass er die Zeichen genauso eingibt, wie im Formatstring vorgegeben.
Eine Abweichung führt zu Fehleingaben oder zu scheinbar unmotiviertem Warten
auf weitere Eingaben. Wir wollen das hier nicht weiter diskutieren, da diese Art der
Eingabe bei modernen Softwaresystemen mit grafischer Benutzeroberfläche nicht
verwendet wird.
71
3 Ausgewählte Sprachelemente von C
왘 Einzeilige Kommentare beginnen mit // und erstrecken sich dann bis zum Ende
der Zeile.
왘 Mehrzeilige Kommentare beginnen mit /* und enden mit */.
/*
** Variablendefinitionen
*/
int zahl1; // Dies ist eine Zahl
/*
** Programmcode
*/
zahl1 = 123; // Der Wert ist jetzt 123
Setzen Sie Kommentare nur dort ein, wo sie wirklich etwas zum Programmverständ-
nis beitragen! Vermeiden Sie Plattitüden wie im Beispiel oben!
Die in diesem Buch als Beispiele vorgestellten Programme enthalten in der Regel
keine Kommentare. Das liegt daran, dass alle Beispielprogramme im umgebenden
Text ausführlich besprochen werden. Lassen Sie sich durch das Fehlen von Kommen-
taren nicht zu der irrigen Annahme verleiten, dass Kommentare in C-Programmen
überflüssig sind.
Das Layout des Programmtextes können Sie, von den #-Anweisungen, die immer am
Anfang einer Zeile stehen müssen, einmal abgesehen, mit Leerzeichen, Zeilenumbrü-
chen, Seitenvorschüben und Tabulatorzeichen relativ frei gestalten. Ein einheitli-
ches, klar gegliedertes Layout erhöht die Lesbarkeit und damit auch die Pflegbarkeit
eines Programms. Die Frage nach einer einheitlichen und verbindlichen Gestaltung
des Programmcodes gewinnt insbesondere dann an Bedeutung, wenn Software von
mehreren Programmierern im Team erstellt wird und die Notwendigkeit besteht,
dass ein und derselbe Code von verschiedenen Entwicklern bearbeitet wird. Viele
Unternehmen haben daher Codier-Richtlinien aufgestellt, und die Entwickler sind
gehalten, sich an diesen Vorgaben zu orientieren. Ich werde Ihnen an einigen Stellen
Empfehlungen über einen »guten« Gebrauch der durch C bzw. C++ zur Verfügung
gestellten Sprachmittel geben. Eine vollständige Bereitstellung von Codier-Richtli-
nien finden Sie in diesem Buch jedoch nicht.
72
3.7 Beispiele
void main() { int i; int k; int maxi; int maxk; int produkt; printf(
"Bitte maxi eingeben: "); scanf( "%d", &maxi); printf(
"Bitte maxk eingeben: "); scanf( "%d", &maxk); for( i = 1; i <= maxi; i =
3
i + 1) { for( k = 1; k <= maxk; k = k + 1) { produkt = i*k; printf(
"%d mal %d ist %d\n", i, k, i*k); } printf( "\n"); } }
3.7 Beispiele
Es wird Sie vielleicht überraschen, aber mit dem, was Sie bisher gelernt haben, kön-
nen Sie bereits alles programmieren, was man überhaupt nur programmieren kann.
Im Grunde genommen könnten Sie dieses Buch jetzt zuklappen und den Rest verges-
sen. Ich hoffe natürlich, dass Sie weiterlesen, denn wenn Sie jetzt aufhören, wäre das
so, als würde ich Sie mit einem Teelöffel vor ein riesiges Schwimmbecken stellen und
sagen, dass Sie jetzt alles haben, was Sie benötigen, um das Becken zu leeren.
Es ist noch ein weiter Weg zum Ziel, das ja professionelle Programmierung heißt.
Aber ein wichtiges Etappenziel haben Sie erreicht. Um zu sehen, was Sie bereits kön-
nen, finden Sie hier einige Beispiele.
73
3 Ausgewählte Sprachelemente von C
void main()
Start
{
int z, n, a, x;
z = 10 * ( z - n*x); z = 10 (z – nx)
if ( z == 0 ) ja
z=0
break;
nein
x = z/n; x = größte ganze Zahl mit nx ≤ z
}
a=a–1
}
Ende
Die Schleife wird so lange ausgeführt, wie Ziffern zu berechnen sind – es sei denn,
dass der Divisionsrest 0 wird. Dann wird die Schleife vorzeitig durch die break-Anwei-
sung beendet.
Zu teilende Zahl: 84
Teiler: 16
Anzahl Nachkommastellen: 4
Ergebnis = 5.25
und mit einem Testfall, bei dem das Abbruchkriterium über die Stellenzahl zum Zuge
kommt (100:7):
74
3.7 Beispiele
weiche1 0 1
1 2
weiche2 0 1 0 1 weiche3
0 1
weiche4
4 5
Die möglichen Positionen der Kugel auf dem Weg zu einem der Ausgänge sind in
Abbildung 3.14 fortlaufend von 1 bis 5 nummeriert. Die Weichen sind so konstruiert,
dass sie beim Passieren einer Kugel umschlagen und auf diese Weise die nächste
Kugel in die entgegengesetzte Richtung lenken. Die Frage, an welchem Ausgang die
Kugel das System verlässt, können wir über eine Reihe geschachtelter Verzweigun-
gen beantworten, wenn wir den Weg einer Kugel durch das System nachvollziehen.
75
3 Ausgewählte Sprachelemente von C
Weichenstellungen
eingeben
ja weiche nein
1
==1
position = 1 position = 2
weiche1 umschlagen
ja position nein
==1
position nein
==3
ja
ja weiche4 nein
==1
position = 4 position = 5
weiche4 umschlagen
Position
ausgeben
Dieses Flussdiagramm setzen wir dann in C-Code um. Zum Programmstart lassen wir
den Benutzer die Anfangsstellung der vier Weichen eingeben, dann läuft der Algo-
rithmus so ab, wie im Flussdiagramm vorgegeben:
76
3.7 Beispiele
void main()
{
int weiche1, weiche2, weiche3, weiche4;
int position;
3
printf( "Bitte geben Sie die Weichenstellungen ein: ");
scanf( "%d %d %d %d", &weiche1, &weiche2, &weiche3, &weiche4);
if( weiche1 == 1)
position = 1;
else
position = 2;
weiche1 = 1 – weiche1;
if( position == 1)
{
if( weiche2 == 1)
position = 4;
else
position = 3;
weiche2 = 1 – weiche2;
}
else
{
if( weiche3 == 1)
position = 3;
else
position = 5;
weiche3 = 1 – weiche3;
}
if( position == 3)
{
if( weiche4 == 1)
position = 4;
else
position = 5;
weiche4 = 1 – weiche4;
}
printf( "Auslauf: %d, ", position);
printf( "neue Weichenstellung %d %d %d %d\n",
weiche1, weiche2, weiche3, weiche4);
}
77
3 Ausgewählte Sprachelemente von C
Das Umschlagen der Weichen realisieren wir durch weiche = 1 – weiche. Diese Anwei-
sung bewirkt, dass der Wert von weiche immer zwischen 0 und 1 hin- und herschaltet.
Um mehrere Kugeln durch das System laufen zu lassen, müssen wir die Anzahl der
gewünschten Kugeln erfragen und den einzelnen Durchlauf in eine Schleife einpa-
cken. Dazu dienen die folgenden Erweiterungen:
void main()
{
int weiche1, weiche2, weiche3, weiche4;
int position;
int kugeln;
78
3.7 Beispiele
Der Benutzer soll eine von ihm vorab festgelegte Anzahl von Zahlen eingeben. Das
Programm summiert unabhängig voneinander die positiven und die negativen Ein- 3
gaben und gibt am Ende die Summe der negativen Eingaben, die Summe der positi-
ven Eingaben und die Gesamtsumme aus.
In einem konkreten Beispiel soll das Programm so ablaufen, dass zunächst im Dialog
mit dem Benutzer alle erforderlichen Eingaben erfragt werden:
# include <stdio.h>
# include <stdlib.h>
void main()
{
A int anzahl;
int z;
int summand;
int psum;
int nsum;
B printf( "Wie viele Zahlen sollen eingegeben werden: ");
scanf( "%d", &anzahl);
fflush( stdin);
79
3 Ausgewählte Sprachelemente von C
C psum = 0;
nsum = 0;
Weil dies eines unserer ersten Programme ist, wollen wir alle Teile noch einmal
intensiv betrachten und diskutieren:
Bereich A: Hier werden die benötigten Variablen definiert. Alle Variablen sind ganz-
zahlig und werden in der folgenden Bedeutung verwendet:
Bereich B: Hier wird der Benutzer zunächst aufgefordert, die gewünschte Anzahl ein-
zugeben. Dann wird die Benutzereingabe in die Variable anzahl übertragen. Verges-
sen Sie nicht das &-Zeichen vor der einzulesenden Variablen!
Bereich C: Die zur Summenbildung verwendeten Variablen (psum, nsum) werden mit 0
initialisiert.
Zeile D: In einer Schleife werden für z = 1,2,...,anzahl jeweils die Unterpunkte E–G aus-
geführt.
80
3.8 Aufgaben
Bereich E: Der Benutzer wird aufgefordert, die nächste Zahl einzugeben, und diese
Zahl wird der Variablen summand zugewiesen.
Bereich F: Wenn die vom Benutzer eingegebene Zahl (summand) größer als 0 ist, wird
psum entsprechend erhöht, andernfalls wird nsum entsprechend verkleinert.
3
Bereich G: Die gewünschten Ergebnisse psum, nsum und psum+nsum werden ausge-
geben.
3.8 Aufgaben
A 3.1 Machen Sie sich mit Editor, Compiler und Linker Ihrer Entwicklungsumge-
bung vertraut, indem Sie die Programme dieses Kapitels eingeben und zum
Laufen bringen!
A 3.2 Schreiben Sie ein Programm, das zwei ganze Zahlen von der Tastatur einliest
und anschließend deren Summe, Differenz, Produkt, den Quotienten und den
Divisionsrest auf dem Bildschirm ausgibt!
1. Zahl: 10
2. Zahl: 4
Summe 10 + 4 = 14
Differenz 10 – 4 = 6
Produkt 10*4 = 40
Quotient 10/4 = 2 Rest 2
A 3.3 Erstellen Sie ein Programm, das unter Verwendung der in Aufgabe 1.3 formu-
lierten Regeln berechnet, ob eine vom Benutzer eingegebene Jahreszahl ein
Schaltjahr bezeichnet oder nicht!
A 3.4 Erstellen Sie ein Programm, das zu einem eingegebenen Datum (Tag, Monat
und Jahr) berechnet, um den wievielten Tag des Jahres es sich handelt! Berück-
sichtigen Sie dabei die Schaltjahrregel!
A 3.5 Schreiben Sie ein Programm, das alle durch 7 teilbaren Zahlen zwischen zwei
zuvor eingegebenen Grenzen ausgibt!
A 3.6 Schreiben Sie ein Programm, das berechnet, wie viele Legosteine zum Bau der
folgenden Treppe mit der zuvor eingegebenen Höhe h erforderlich sind:
81
3 Ausgewählte Sprachelemente von C
A 3.7 Schreiben Sie ein Programm, das eine vom Benutzer festgelegte Anzahl von
Zahlen einliest und anschließend die größte und die kleinste der eingegebe-
nen Zahlen auf dem Bildschirm ausgibt!
A 3.8 Implementieren Sie das Ratespiel aus Aufgabe 1.4 entsprechend dem von
Ihnen gewählten Algorithmus!
A 3.9 Implementieren Sie Ihren Algorithmus aus Aufgabe 1.5 zur Feststellung, ob
eine Zahl eine Primzahl ist!
A 3.10 Schreiben Sie ein Programm, das das kleine Einmaleins berechnet und in
Tabellenform auf dem Bildschirm ausgibt! Die Darstellung auf dem Bild-
schirm sollte wie folgt sein:
1 2 3 4 5 6 7 8 9 10
---------------------------------------------
1 | 1 2 3 4 5 6 7 8 9 10
2 | 2 4 6 8 10 12 14 16 18 20
3 | 3 6 9 12 15 18 21 24 27 30
4 | 4 8 12 16 20 24 28 32 36 40
5 | 5 10 15 20 25 30 35 40 45 50
6 | 6 12 18 24 30 36 42 48 54 60
7 | 7 14 21 28 35 42 49 56 63 70
8 | 8 16 24 32 40 48 56 64 72 80
9 | 9 18 27 36 45 54 63 72 81 90
10 | 10 20 30 40 50 60 70 80 90 100
---------------------------------------------
Die Ausgabe einer ganzen Zahl in einer bestimmten Feldbreite erreichen Sie
übrigens dadurch, dass Sie in der Formatanweisung zwischen dem Prozentzei-
chen und dem Buchstaben für den Datentyp die gewünschte Feldbreite, z. B. in
der Form "%3d", angeben.
82
Kapitel 4
Arithmetik
Der Mangel an mathematischer Bildung gibt sich durch nichts so auf-
fallend zu erkennen wie durch maßlose Schärfe im Zahlenrechnen. 4
– Carl Friedrich Gauß
Computer bedeutet im Wortsinn Rechner. Einen Computer für eine einmalig vor-
kommende Berechnung zu verwenden ist nicht besonders sinnvoll. In einer solchen
Situation nimmt man besser einen Taschenrechner. Eine besondere Hilfe sind Com-
puterprogramme aber bei sich stereotyp wiederholenden Rechenoperationen. Sol-
che Operationen werden Sie in diesem Abschnitt kennenlernen.
Es gibt aber noch einen weiteren Unterschied zwischen der Arithmetik der Mathema-
tik und der Arithmetik der Informatik, der mir hier viel wichtiger ist. In der Mathema-
tik versucht man, arithmetische Zusammenhänge durch möglichst einfache und
elegante Formeln auszudrücken. In der Programmierung schaut man aus einem
anderen Blickwinkel auf diese Formeln, da man sich fragt, wie man einen Formelaus-
druck möglichst effizient berechnen kann. Naiv würde man vielleicht vermuten, dass
eine elegante mathematische Formulierung auch eine effiziente Berechnung nach
sich zieht. Das ist aber nicht so. Wir betrachten den folgenden mathematischen Aus-
druck:
a · x5 + b · x4 + c · x3 + d · x2 + e · x + f
Mit den arithmetischen Grundoperationen können wir den Ausdruck wie folgt
berechnen:
83
4 Arithmetik
Zunächst indizieren wir die Koeffizienten. Anstelle von a, b, c, d, e und f schreiben wir
a0, a1, a2, a3, a4 und a5. Wir erhalten eine Folge von Zwischenergebnissen z1 ... z6, wobei
z6 zugleich das Endergebnis ist:
z1 = a0
z 2 = z1 · x + a 1
z 3 = z2 · x + a2
z 4 = z3 · x + a 3
z 5 = z 4 · x + a4
z6 = z5 · x + a5
Jetzt erkennen Sie ein wiederkehrendes Muster, das sich auch wie folgt beschreiben
lässt:
z1 = a0
zn+1 = zn· x + an für n= 1,2, ... 5
Damit haben wir eine ganz präzise Vorschrift gefunden, die sich leicht in ein Pro-
gramm umsetzen lässt1. Diesen Ansatz werden wir jetzt weiterverfolgen und mit der
Programmierung verbinden.
1 Sie wissen noch nicht, wie Sie »indizierte« Variablen erzeugen können. Dazu später mehr.
84
4.1 Folgen
4.1 Folgen
In konkreten Problemstellungen stoßen Sie häufig auf Folgen von Zahlen, die einem
bestimmten Bildungsgesetz unterliegen. Zum Beispiel:
1 1 1 1
1, --, ---, ---, -----, ...
2 4 8 16 4
Das allgemeine Bildungsgesetz ist in dieser Schreibweise zwar zu erkennen, aber
nicht exakt festgelegt. Sie präzisieren dies, indem Sie das Bildungsgesetz für die k-te
Zahl exakt aufschreiben:
1
a k = ----- k = 0, 1, ...
k
2
Jetzt können Sie genau sagen, welchen Wert eine bestimmte Zahl in der Folge hat,
indem Sie den entsprechenden Wert für k einsetzen und ausrechnen.
1
a 0 = ------ = 1
0
2
1 1
a 1 = ---- = --
1 2
2
1 1
a 2 = ----- = ---
2 4
2
1 1
a 3 = ----- = ---
3 8
2
Wir sprechen in diesem Zusammenhang von einer expliziten Definition der Folge ak.
Sie können die Folge ak aber auch unter einem anderen Blickwinkel betrachten:
Das erste Glied der Folge hat den Wert 1, alle weiteren Glieder erhalten Sie
jeweils durch Halbieren des vorangegangenen Werts.
⎧ 1 falls k = 0
⎪
ak = ⎨ ak – 1
⎪ ------------ falls k = 1, 2, 3, ...
⎩ 2
Dies bezeichnen wir als eine induktive Definition der Folge ak. Sie erkennen intuitiv,
dass durch die induktive und die explizite Definition die gleiche Zahlenfolge
beschrieben ist. An dieser Stelle sollten Sie sich klarmachen, dass induktiv definierte
Folgen in der Programmierpraxis häufig vorkommen und sich in besonderer Weise
für eine Berechnung durch Computerprogramme eignen.
85
4 Arithmetik
Wir betrachten dazu ein konkretes Problem. Dieses Problem wollen wir in drei Schrit-
ten lösen.
1. Analyse
2. Modellierung
3. Programmierung
Wir beginnen mit der Analyse. Dazu müssen wir das Problem zunächst einmal for-
mulieren:
Ein Student möchte bei seiner Bank ein Darlehen in einer bestimmten Höhe
aufnehmen. Er vereinbart eine feste monatliche Ratenzahlung. Diese Rate
dient dazu, die monatlich anfallenden Zinsen zu bezahlen, und enthält darüber
hinaus einen Tilgungsbetrag, mit dem das Darlehen abbezahlt wird. In dem
Maße, in dem die Restschuld abgetragen wird, sinkt der Anteil der Zinsen an
der monatlichen Ratenzahlung, und der Tilgungsbetrag wächst entsprechend.
Daraus ergibt sich ein ganz bestimmter Tilgungsplan, den wir im Folgenden
aufstellen wollen. Darüber hinaus werden wir noch einige durchaus bankenüb-
liche Zusatzregelungen wie etwa Zinsbindung und Sondertilgungen in die
Berechnung einfließen lassen.
Wir stellen noch einmal alle relevanten Begriffe zusammen und präzisieren die Auf-
gabenstellung:
Damit ist das Problem noch nicht gelöst, sondern nur abgegrenzt. Der wesentliche
Schritt zur Lösung ist die jetzt folgende Modellierung:
Den Kreditnehmer interessiert, wie hoch nach einer gewissen Anzahl von Monaten
seine Restschuld ist. Wir bezeichnen die Restschuld nach Ablauf von Monaten mit
restn. In diesem Sinne ist rest0 der volle Darlehensbetrag, aber über die weitere Ent-
86
4.1 Folgen
wicklung der Folge restn wissen Sie noch nicht sehr viel. Sie wissen aber, dass die Zin-
sen einen großen Einfluss auf die Entwicklung dieser Folge haben. Nun ist der
Zinssatz ebenfalls abhängig von der Zeit, da Sie ja einen Zinssatz (zins1) für den Zeit-
raum innerhalb der Zinsbindung und einen weiteren Zinssatz (zins2) außerhalb der
Zinsbindung zu betrachten haben. Wenn Sie die Anzahl der Jahre, für die die Zinsbin-
dung besteht, mit bindung bezeichnen, erhalten Sie die folgende Formel für den gül- 4
tigen Zinssatz (zins) im n-ten Monat:
Mit diesem Zinssatz können Sie dann die monatliche Zinslast (zinsen) auf der Rest-
schuld berechnen:
rest n ⋅ zins n
zinsen n = --------------------------------
1200
Was von der monatlichen Rate nach Abzug der Zinsen (rate – zinsenn) noch übrig
bleibt, dient zur Tilgung des Darlehens. Ist dieser mögliche Tilgungsbetrag größer als
die Restschuld, wird nur in Höhe der Restschuld getilgt, denn der Kreditnehmer will
natürlich nicht mehr Geld zurückzahlen, als er bekommen hat. Damit ergibt sich für
die Tilgung im n-ten Monat:
Die Restschuld mindert sich dann um diesen Tilgungsbetrag. Sie haben aber noch die
jährlich vereinbarten Sonderzahlungen zu berücksichtigen. Diese dürfen natürlich
ebenfalls nicht den nach Abzug der Tilgung verbleibenden Darlehensrest überstei-
gen, und es gilt:
Insgesamt ergibt sich dann nach Abzug aller Zahlungen der neue Darlehensrest:
Sie haben damit alle für unser Problem relevanten Formeln hergeleitet, und unser
Modell ist fertig. Jetzt können Sie mit der Programmierung beginnen.
87
4 Arithmetik
Schritt für Schritt erstellen Sie das Programm. Zunächst legen Sie die erforderlichen
Variablen an. Verwenden Sie dabei die oben eingeführten Namen, sodass der Ver-
wendungszweck der Variablen klar sein sollte:
void main()
{
float rest, rate, zins1, zins2, sondertilgung;
int bindung;
int monat;
float zins, zinsen, tilgung, sonderz;
}
Für die ersten 6 Variablen muss der Benutzer Werte eingeben, während die restlichen
nur zur internen Verarbeitung dienen. Den Dialog mit dem Benutzer führen Sie in
der folgenden Weise aus:
void main()
{
float rest, rate, zins1, zins2, sondertilgung;
int bindung;
int monat;
float zins, zinsen, tilgung, sonderz;
88
4.1 Folgen
Jetzt sind alle Daten zur Erstellung des Tilgungsplans eingegeben, und Sie können
mit der Berechnung des Plans beginnen. Zunächst wird eine Überschrift ausgegeben.
Dann gehen Sie in einer Schleife Monat für Monat vor. Die Schleife endet, wenn das
Darlehen vollständig abgetragen ist, also kein Rest mehr bleibt.
void main()
4
{
... Variablendefinition und Eingaben wie oben ...
printf( "\nTilgungsplan:\n\n");
printf( "Monat Zinssatz Zinsen Tilgung Sondertilg Rest\n");
G sonderz = 0;
if( (monat % 12) == 0)
{
sonderz = sondertilgung;
if( sonderz > rest)
sonderz = rest;
}
printf( " %10.2f", sonderz);
H rest = rest – sonderz;
printf( " %10.2f", rest);
I printf( "\n");
}
}
89
4 Arithmetik
(A) In einer Schleife wird Monat für Monat bearbeitet. Für jeden Monat werden die
Anweisungen (B–I) ausgeführt. Die Schleife endet, wenn keine Restschuld mehr
besteht, das Darlehen also vollständig getilgt ist.
(B) Zunächst wird die laufende Nummer des Monats ausgegeben. Die Feldbreite für
die Ausgabe wird durch die Zahl 5 in der Formatanweisung festgelegt. Es erfolgt kein
Zeilenvorschub. Alle Ausgaben für einen Monat erscheinen in der gleichen Zeile.
(C) Jetzt wird der zur Anwendung kommende zins ermittelt. Vor Ablauf der Zinsbin-
dung ist dies zins1, danach zins2. Bei der Ausgabe des Zinssatzes wird eine spezielle
Formatanweisung für Gleitkommazahlen verwendet, die die Feldbreite (10) und die
Anzahl der Nachkommastellen (2) festlegt.
(D) Hier werden die auf die Restschuld fälligen Zinsen berechnet.
(E) Dann wird die Tilgung nach der oben hergeleiteten Formel berechnet und ausge-
geben.
(G) Hier wird festgestellt, ob eine Sondertilgung fällig ist. Eine Sondertilgung ist fällig,
wenn die Monatszahl ohne Rest durch 12 teilbar ist. Wir verwenden hier den Opera-
tor %, der den Rest einer Division ermittelt. Das Ergebnis von monat % 12 ist 0, wenn ein
komplettes Jahr abgelaufen ist und eine Sonderzahlung geleistet wird. Vor der Aus-
gabe wird noch dafür gesorgt, dass die Sondertilgung nicht höher als der Darlehens-
rest ausfällt. Zur formatierten Ausgabe der Sondertilgung siehe Punkt C.
(H) Jetzt wird auch noch die Sondertilgung vom Darlehensrest abgezogen. Der jetzt
noch verbleibende Betrag wird entsprechend formatiert ausgegeben.
(I) Ein Zeilenvorschub schließt die Ausgabezeile für einen Monat ab.
In einem konkreten Lauf erfragt das Programm zunächst alle für das Darlehen rele-
vanten Daten.
Darlehen: 100000
Nominalzins: 6.5
Monatsrate: 3000
Zinsbindung (Jahre): 1
Zinssatz nach Bindung: 8.0
Jaehrliche Sondertilgung: 10000
90
4.1 Folgen
Tilgungsplan:
Das Beispiel zeigt, wie einfach Sie in einer Programmschleife eine iterativ definierte
Folge berechnen können, ohne sich Gedanken über eine explizite Darstellung der
Folge machen zu müssen. Das Beispiel zeigt auch, dass Sie eine Aufgabenstellung
zunächst mit Papier und Bleistift analysieren sollten, bevor Sie mit der Programmie-
rung beginnen.
Im Prinzip handelt es sich bei dem oben dargestellten Programm um die Simulation
eines endlichen Prozesses. Sie wissen ja, dass die Schuld irgendwann vollständig
getilgt ist, wenn jeden Monat ein gewisser Mindestbetrag getilgt wird. Manchmal
91
4 Arithmetik
haben Sie es aber auch mit Prozessen zu tun, bei denen es nicht von vornherein klar
ist, dass sie enden oder dass sich ein stabiles Ergebnis einstellt. Einem solchen Pro-
zess wollen wir uns jetzt zuwenden.
Wenn Sie bei einem einfachen Problem auf eine Gleichung wie x ·x = 10 stoßen, kön-
nen Sie diese Gleichung mit den arithmetischen Grundoperationen nicht lösen, da
Sie zur Lösung ja die Wurzel ziehen müssen. Bei einem Taschenrechner drücken Sie
einfach auf die » «-Taste und erhalten:
10 = 3.162...
Das ist natürlich nur ein Näherungswert, und Sie müssen davon ausgehen, dass der
exakte Wert im endlichen Zahlenmodell des Computers nicht vorkommt. Um eine
Näherungslösung zu finden, folgen Sie einer uralten Idee des griechischen Mathema-
tikers Heron2. Für die alten Griechen war Mathematik im Wesentlichen Geometrie3,
und auch das »Ziehen der Wurzel« war für sie ein geometrisches Problem:
Gesucht ist die Kantenlänge eines Quadrats, das eine vorgegebene Fläche a (z. B.
a = 10) hat.
Wir nennen die gesuchte Lösung w und starten mit einer mehr oder weniger willkür-
lichen ersten Näherung:
w0 = a
Wenn wir w0 als eine Seitenlänge eines Rechtecks auffassen, das die Fläche a haben
a
soll, müssen wir -----
w0- als Länge der anderen Seite wählen. Das ist sicher noch eine unge-
nügende Annäherung an ein Quadrat, aber wenn wir im nächsten Schritt den Mittel-
wert aus den beiden Kantenlängen wählen, wird unser Rechteck schon deutlich
quadratischer:
w 1 = -- ⎛ w 0 + --------⎞
1 a
2⎝ w ⎠ 0
w 2 = -- ⎛ w 1 + -------⎞
1 a
2⎝ w1 ⎠
Abbildung 4.1 zeigt die Entwicklung unserer Folge, die sich offensichtlich längs des
10
Funktionsgraphen f ( x ) = ------ an das Ziel 10 herantastet:
x
2 Heron von Alexandria lebte und lehrte vermutlich im 1. Jahrhundert n. Chr. in Alexandria.
3 Die Algebra stammt zwar auch aus Griechenland, wurde aber erst ca. 300 Jahre nach Heron entdeckt.
92
4.1 Folgen
10
10
ƒ( x) =
x
9
8
4
7
10 = 3,162
6
3 w
²
2 w1
w0
1
0
0 1 2 3 4 5 6 7 8 9 10
Die Folge
⎧ a falls n = 0
⎪
w n = ⎨ --1 ⎛
--------------⎞
a
⎪ 2 ⎝ wn – 1 + w ⎠ falls n = 1, 2, ...
⎩ n–1
scheint eine gute Annäherung an den Zielwert w = a zu liefern. Sie probieren das
mit einem Programm aus, wobei Sie den Wert a für die zu berechnende Wurzel vom
Benutzer eingeben lassen. Sie wissen allerdings noch nicht, wie oft Sie die Iteration
durchführen müssen, bis das Ergebnis genau genug ist. Versuchen Sie es zunächst
mit zehn Durchläufen:
93
4 Arithmetik
void main()
{
float a, w;
int i;
w = a;
for( i = 0; i < 10; i++)
{
w = (w + a/w)/2;
printf( "%f\n", w);
}
}
Das ist eine sehr gute Näherung, offensichtlich hätten sogar weniger Schleifendurch-
läufe ausgereicht. Aber das Programm selbst kann Ihnen nicht sagen, ob es allgemein
(d. h. nicht nur für 10) funktioniert. Selbst weitere Tests könnten Sie nicht zufrieden-
stellen, da immer ein Restzweifel bestehen bleibt. Eine befriedigende Antwort kann
Ihnen nur die Mathematik geben. Sie muss Ihnen zwei Fragen beantworten, bevor Sie
diesem Programm trauen können:
Konvergiert dieses Verfahren allgemein – und wenn ja, gegen welchen Wert?
w n ≥ w n + 1 ≥ a ( für n = 1, 2, ... )
94
4.1 Folgen
Die Mathematik sagt auch, dass solche Folgen, die monoton fallen und nach unten
beschränkt sind, konvergieren. Wenn Sie sicher sind, dass das Verfahren konvergiert,
können Sie sehr einfach den Grenzwert ermitteln. Wir nennen den Grenzwert w und
machen in der Formel
w n = -- ⎛ w n – 1 + --------------⎞
1 a
2⎝ wn – 1 ⎠ 4
auf beiden Seiten den »Grenzübergang ins Unendliche« und erhalten für w die fol-
gende Gleichung:
w = -- ⎛ w + ----⎞
1 a
2⎝ w⎠
w2 = a
Also:
w= a
Nach diesen Überlegungen sind Sie sicher, dass Sie das Verfahren nach einem Schritt
mit der Bedingung
wn · wn – a < fehlerschranke
void main()
{
float a, w;
w = a;
for( ; ; )
{
w = (w + a/w)/2;
printf( " %f\n", w);
4 Wenn Sie der mathematische Beweis interessiert, schauen Sie im Internet unter dem Stichwort
»Heron-Verfahren« nach.
95
4 Arithmetik
Jetzt bricht das Programm ab, sobald die geforderte Genauigkeit erreicht ist:
Das Programm zur Berechnung der Wurzel ist ein einfaches Beispiel für ein soge-
nanntes numerisches Verfahren. Numerische Verfahren sind sehr eng mit der
Mathematik verknüpft und werden eingesetzt, wenn Probleme aus der »analogen«
Welt in der diskreten Welt eines Computers simuliert und gelöst werden sollen. Den-
ken Sie dabei an Wetter- oder Klimasimulationen, an die Simulation eines Tsunamis
oder des dynamischen Fahrverhaltens eines Autos. Allein das Gebiet der numeri-
schen Verfahren ist so umfangreich, dass die Literatur dazu ganze Bibliotheken füllt.
1
2
3
4
96
4.2 Summen und Produkte
Das iterative Bildungsgesetz für die Anzahl der Steine ist schnell gefunden:
⎧ 0 für h = 0
sh = ⎨
s
⎩ h–1+h für h > 0
Das bedeutet:
4
s0 = 0
s1 = 1
s2 = 1 + 2 = 3
s3 = 1 + 2 + 3 = 6
s4 = 1 + 2 + 3 + 4 = 10
s5 = 1 + 2 + 3 + 4 + 5 = 15
...
sh = 1 + 2 + 3 + 4 + 5 + ··· + h = ?
Sie können den gesuchten Wert iterativ durch ein C-Programm berechnen:
void main()
{
int max =9;
int steine = 0;
int h;
Hoehe: 1, Steine = 1
Hoehe: 2, Steine = 3
Hoehe: 3, Steine = 6
Hoehe: 4, Steine = 10
Hoehe: 5, Steine = 15
Hoehe: 6, Steine = 21
Hoehe: 7, Steine = 28
Hoehe: 8, Steine = 36
Hoehe: 9, Steine = 45
97
4 Arithmetik
Aber Sie sind in diesem Fall auch in der Lage, eine explizite Formel anzugeben. Dazu
bauen Sie die gleiche Treppe noch einmal – auf dem Kopf stehend – neben die zu
untersuchende Treppe:
h+1
h
Sie sehen, dass jeweils h+1 Steine in h Schichten übereinander vorhanden sind und
dass das doppelt so viele Steine sind, wie in einer Treppe benötigt werden. Es gilt also:
( h + 1 )h
sh = 0 + 1 + 2 + 3 + 4 + 5 + ··· + h = --------------------
2
Wir können die Addition in der gaußschen Summenformel durch eine Multiplika-
tion ersetzen und uns das folgende Bildungsgesetz anschauen:
⎧ 1 für n = 0
fn = ⎨
⎩ n–1⋅n
f für n > 0
f0 = 1
f1 = 1
f2 = 1 · 2 = 2
f3 = 1 · 2 · 3 = 6
98
4.2 Summen und Produkte
f4 = 1 · 2 · 3 · 4 = 24
f5 = 1 · 2 · 3 · 4 · 5 = 120
...
fn = 1 · 2 · 3 · 4 · 5 · … · n
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
6! = 720
7! = 5040
8! = 40320
9! = 362880
Diese Folge ist so wichtig, dass man ihr einen eigenen Namen gegeben hat. Es ist die
Folge der Fakultäten. Das einzelne Folgenglied zum Index n nennen wir n-Fakultät
und schreiben dafür »n!«. Also:
0! = 1 0-Fakultät
1! = 1 1-Fakultät
2! = 1 · 2 = 2 2-Fakultät
3! = 1 · 2 · 3 = 6 3-Fakultät
4! = 1 · 2 · 3 · 4 = 24 4-Fakultät
99
4 Arithmetik
5! = 1 · 2 · 3 · 4 · 5 = 120 5-Fakultät
...
n! = 1 · 2 · 3 · 4 · 5 · ... · n n-Fakultät
Auch diese Folge wird Ihnen bei der Programmierung häufig begegnen.
4.3 Aufgaben
A 4.1 Schreiben Sie ein Programm, das zu einem gegebenen Anfangskapital und
einem jährlichen Zinssatz berechnet, wie viele Jahre benötigt werden, damit
das Kapital eine bestimmte Zielsumme überschreitet!
A 4.2 Den größten gemeinsamen Teiler (ggT) von zwei natürlichen Zahlen können
Sie berechnen, indem Sie so lange die kleinere Zahl von der größeren Zahl
abziehen, bis beide Zahlen gleich sind. Sie möchten z. B. den ggT von 152 und
56 berechnen. Dann gehen Sie wie folgt vor:
152 – 56 = 96
96 – 56 = 40
56 – 40 = 16
40 – 16 = 22
22 – 16 = 8
16 – 8 = 8 = ggT
Erstellen Sie ein Programm, das mit diesem Algorithmus den ggT berechnet!
A 4.3 Sie haben zwei ausreichend große Eimer. Im ersten befinden sich x, im zwei-
ten y Liter Wasser. Sie füllen nun immer a Prozent des Wassers aus dem ersten
in den zweiten und anschließend b Prozent des Wassers aus dem zweiten in
den ersten Eimer. Diesen Umfüllprozess führen Sie n-mal durch. Erstellen Sie
ein Programm, das nach Eingabe der Startwerte (x, y, a, b und n) die Füllstände
der Eimer nach jedem Umfüllen ermittelt und auf dem Bildschirm ausgibt!
Welche Aufteilung des Wassers ergibt sich auf lange Sicht für unterschiedliche
Startwerte?
A 4.4 In einem Schulbezirk gibt es 1200 Planstellen für Lehrer. Diese unterteilen sich
derzeit in 40 Studiendirektoren, 160 Oberstudienräte und 1000 Studienräte.
Alle drei Jahre ist eine Beförderung möglich, dabei steigen jeweils 10 % der
Oberstudienräte und 20 % der Studienräte in die nächsthöhere Gruppe auf.
Darüber hinaus gehen 20 % einer jeden Gruppe innerhalb von drei Jahren in
den Ruhestand. Die dadurch frei werdenden Planstellen werden mit Studien-
räten besetzt. Schreiben Sie ein Programm, das die bestehende Situation in
Dreijahreszyklen fortschreibt! Welche Verteilung von Direktoren, Oberräten
und Räten ergibt sich auf lange Sicht? Drehen Sie an der »Beförderungs-
100
4.3 Aufgaben
A 4.5 Epidemien (z. B. Grippewellen) breiten sich in der Bevölkerung nach gewissen
Gesetzmäßigkeiten aus. Die Bevölkerung zerfällt im Verlauf einer Epidemie in
drei Gruppen. Als Gesunde bezeichnen wir Menschen, die mit dem Krank-
heitserreger noch nicht in Berührung gekommen sind und deshalb anste- 4
ckungsgefährdet sind. Kranke sind Menschen, die akut infiziert und
ansteckend sind. Immunisierte schließlich sind Menschen, die die Krankheit
überstanden haben und weder ansteckend noch ansteckungsgefährdet sind.
gesund0 = x – y
krank0 = y
immun0 = 0
Ausgehend von diesen Daten, wollen wir die Ausbreitung der Krankheit in
Zeitsprüngen von einem Tag berechnen. Wir überlegen uns dazu, welche Ver-
änderungen von Tag zu Tag auftreten. Es gibt zwei Arten von Übergängen zwi-
schen den Gruppen. Aus Gesunden werden Kranke (Infektion), und aus
Kranken werden Immune (Immunisierung).
Die Zahl der Infektionen ist proportional zur Zahl der Gesunden und proporti-
onal zum Anteil der Kranken in der Gesamtbevölkerung. Denn je mehr
Gesunde es gibt, desto mehr Menschen können sich anstecken, und je mehr
Ansteckende es gibt, desto mehr Menschen können angesteckt werden. Mit
einem geeigneten Proportionalitätsfaktor (Infektionsrate) nimmt daher die
Zahl der Gesunden ständig ab:
gesund n krank n
gesund n + 1 = gesund n – infektionsrate -------------------------------------------
x
Die Zahl der Immunisierungen ist proportional zur Zahl der Kranken, denn je
mehr Menschen erkrankt sind, desto mehr Menschen erlangen Immunität. Mit
einem geeigneten Proportionalitätsfaktor (Immunisierungsrate) gilt daher:
101
4 Arithmetik
können daher nur empirisch ermittelt werden. Sind Ihnen diese Faktoren aber
aus der Kenntnis früherer Epidemien her bekannt, können Sie mit einem ein-
fachen Programm den Verlauf der Krankheitswelle vorausberechnen. Erstel-
len Sie das Programm, und ermitteln Sie den Verlauf einer Epidemie mit den
folgenden Basisdaten:
Infektionsrate: 0.6
Immunisierungsrate: 0.06
Gesamtpopulation: 2000
Akut Kranke: 10
Anzahl Tage: 25
Abbildung 4.4 zeigt für die oben genannten Basisdaten das epidemische
Anwachsen des Krankenstandes, bis dem Virus der Nährboden entzogen wird
und der Krankenstand langsam wieder abfällt:
2000
1800
1600
1400
1200
1000
800
600
400
200
0
0
4
8
12
16
20
24
28
32
36
40
44
48
52
56
60
64
68
72
76
Immun
80
84
Krank
88
92
96
Gesund
100
A 4.6 Der belgische Mathematiker Viktor d’Hondt entwickelte 1882 ein Verfahren,
um zu einem Wahlergebnis die zugehörige Sitzverteilung für ein Parlament
zu berechnen. Dieses Verfahren (d’Hondtsches Höchstzahlverfahren) wurde
bis 1983 verwendet, um die Sitzverteilung für den Deutschen Bundestag fest-
zulegen.
102
4.3 Aufgaben
Sitze 5 4 1
Schreiben Sie ein Programm, das für eine beliebige Wahl mit drei Parteien die
Sitzverteilung berechnet! Die Anzahl der zu vergebenden Sitze und die Stim-
men für die drei Parteien sollen dabei vom Benutzer eingegeben werden.
A 4.7 Im folgenden Zahlenkreis stehen die Buchstaben jeweils für eine Ziffer.
B C
A b c D
a d
H h e
g f E
G F
103
4 Arithmetik
Bestimmen Sie diese Ziffern (1 bis 9) so, dass folgende Bedingungen erfüllt
werden:
Zeigen Sie durch ein Programm, dass es genau eine mögliche Ziffernzuord-
nung gibt, und bestimmen Sie diese!
A 4.8 Erstellen Sie ein Programm, das zu einer vom Benutzer eingegebenen Zahl die
Primzahlzerlegung ermittelt
Zahl: 13230
13230 = 2*3*3*3*5*7*7
Zum Beispiel:
3 5 7
x x x
sin ( x ) = x – ----- + ----- – ----- + …
3! 5! 7!
2 4 6
x x x
cos ( x ) = 1 – ----- + ------ – ------ + ...
2! 4! 6!
2 3
x x x
e = 1 + x + ----- + ----- + …
2! 3!
104
4.3 Aufgaben
A 4.10 Erstellen Sie Programme, um den Steinverbrauch für die in Abbildung 4.6
abgebildete Treppe und die beiden Pyramiden zu berechnen. Die Pyramiden
sind dabei innen nicht hohl.
105
Kapitel 5
Aussagenlogik
Logiker: Alle Katzen sind sterblich. Sokrates ist gestorben. Also ist
Sokrates eine Katze.
Älterer Herr: Ich habe eine Katze, die heißt Sokrates. 5
Logiker: Sehen Sie ...
Älterer Herr: Sokrates war also eine Katze!
Logiker: Die Logik hat es uns eben bewiesen.
– Aus »Die Nashörner« von Eugène Ionesco
Eine ganze Nacht habe ich mich mit der Frage gequält, wie ich in die Thematik dieses
Abschnitts einsteigen soll. Als ich heute beim Frühstück saß, war plötzlich alles ganz
einfach, denn in meiner Morgenlektüre fand ich den folgenden Artikel:
Natürlich handelt es sich bei dieser Frage um eine Scherzfrage, aber wie geht der Logi-
ker mit solchen Sätzen um? Etwa mit dem folgenden Satz:
Wenn fünf Ochsen in fünf Minuten fünf Liter Milch geben, dann gibt es den
Osterhasen.
107
5 Aussagenlogik
Ist dieser Satz falsch, weil Ochsen keine Milch geben? Oder ist er falsch, weil es den
Osterhasen nicht gibt? Oder ist er vielleicht sogar richtig? Und wenn er richtig ist, ist
dann die Existenz des Osterhasen bewiesen? Mit so wichtigen Fragen werden wir uns
in diesem Abschnitt beschäftigen, und Sie werden auch wieder einiges an Program-
mierung lernen.
5.1 Aussagen
Die Aussagenlogik beschäftigt sich, wie nicht anders zu erwarten, mit Aussagen.
Unter einer Aussage verstehen wir einen Satz, der entweder wahr oder falsch ist. Wir
müssen nicht wissen, ob der Satz wahr oder falsch ist, wir müssen ihm nur prinzipiell
zugestehen, dass er wahr oder falsch ist. Genau genommen, interessieren wir uns
nicht einmal dafür, ob der Satz wahr oder falsch ist. Und ganz genau genommen,
interessieren wir uns nicht einmal dafür, was »wahr« und »falsch« inhaltlich bedeu-
tet. Wir können jederzeit 0 oder 1 anstelle von »falsch« oder »wahr« sagen. Insofern
betreiben wir Logik als ein rein formales System ohne Bezug zur Realität.
»Köln liegt in Deutschland und Köln hat mehr als 1 Mio. Einwohner.«
108
5.2 Aussagenlogische Operatoren
109
5 Aussagenlogik
A nicht A
0 1
1 0
A B A und B
0 0 0
0 1 0
1 0 0
1 1 1
Auch für diesen Operator verwenden wir spezielle Formelsymbole. Anstelle von A
und B schreiben wir auch A ∧ B oder A && B.
Bleibt noch das »oder«, für das wir die Notationen A oder B, A ∨ B und A || B verwenden:
A B A oder B
0 0 0
0 1 1
1 0 1
1 1 1
110
5.2 Aussagenlogische Operatoren
Eine Aussage, die aus zwei mit »oder« verbundenen Teilaussagen besteht, ist also
genau dann wahr, wenn mindestens eine der beiden Teilaussagen wahr ist.
An dieser Definition erhitzen sich gelegentlich die Gemüter. Vielfach wird gefordert,
dass die Aussage »A oder B« falsch zu sein habe, wenn A und B beide wahr sind. Dies
entspricht dem Operator »entweder ... oder ...«. Die deutsche Sprache1 trennt leider
nicht sauber zwischen »oder« und »entweder ... oder«. Vielfach wird dort, wo eigent-
lich »entweder ... oder ...« gemeint ist, einfach nur »oder« verwendet. In aller Regel ist 5
das unproblematisch, weil zumeist aus dem Zusammenhang klar ist, welcher der bei-
den Operatoren gemeint ist, oder weil sich die Alternativen sowieso gegenseitig aus-
schließen.
in aller Regel:
Der Fall, dass beide Alternativen gewählt werden, wird dabei von vornherein ausge-
schlossen. Im strengen Sinne unseres Gebrauchs des Operators »oder« schließt die
erste Formulierung der Frage aber nicht aus, sowohl um 8 als auch um 10 ins Kino zu
gehen. Seien Sie also immer vorsichtig! Wenn Sie ein Logiker auf der Straße mit den
Worten »Geld oder Leben!« überfällt, und Sie geben Ihm das Geld, kann er immer
noch Ihr Leben nehmen, ohne wortbrüchig zu werden. Bestehen Sie also in dieser
Situation auf der Formulierung »Entweder Geld oder Leben«, und geben Sie erst
dann das Geld. Zum Schluss aber noch ein Beispiel dazu, dass wir auch in unserer
Umgangssprache das nicht ausschließende »oder« ganz selbstverständlich benut-
zen. Wenn Sie etwa an der Grenze gefragt werden
würden Sie dann mit Nein antworten, wenn Sie zufällig beide Dokumente einge-
steckt haben?
Mit den Bausteinen »nicht«, »und« und »oder« können wir jetzt beliebig komplexe
logische Ausdrücke zusammensetzen. Aber noch immer lässt die Umgangssprache
zu viel Interpretationsspielraum. Wenn etwa die Zollvorschriften besagen, dass man
entweder 1 Liter Spirituosen oder 5 Liter Bier und eine Stange Zigaretten
1 Die lateinische Sprache kennt z. B. »vel« für das nicht ausschließende und »aut« für das aus-
schließende »oder«.
111
5 Aussagenlogik
(entweder 1 Liter Spirituosen oder 5 Liter Bier) und eine Stange Zigaretten
oder
entweder 1 Liter Spirituosen oder (5 Liter Bier und eine Stange Zigaretten)
gemeint ist. Vermutlich das Erstere. Diese Vermutung leitet sich aber nicht aus dem
logischen Gerüst der Aussage, sondern aus der Tatsache ab, dass es sich bei Spirituo-
sen und Bier um ähnliche und daher vielleicht austauschbare Dinge handelt. Soll ein
Logiker es aufgrund dieser vagen Annahme riskieren, mit 1 Liter Spirituosen und
einer Stange Zigaretten die Grenze zu überqueren? Dies wäre zwar bei der ersten
Interpretation erlaubt, bei der zweiten aber verboten.
Wir müssen präziser sein und Operatoren so definieren, dass immer eine eindeutige
Auswertungsreihenfolge gegeben ist. Dazu geben wir in gemischten Ausdrücken
»nicht« eine höhere Priorität als »und« und »und« eine höhere Priorität als »oder«.
Wollen wir eine andere Auswertungsreihenfolge erzwingen, setzen wir Klammern.
Das Ergebnis zusammengesetzter Ausdrücke kann mit diesen Zusatzregeln einfach
ermittelt werden, indem zunächst die Wahrheitswerte der Teilausdrücke und dann
sukzessive die Wahrheitswerte zusammengesetzter Ausdrücke ermittelt werden. Als
Beispiel wählen wir den Ausdruck (A ∨ B) ∧ (C ∨ A):
A B C A A ˅B C˅A ( A˅ B ) ˄ (C ˅ A)
0 0 0 1 1 0 0
0 0 1 1 1 1 1
0 1 0 1 1 0 0
0 1 1 1 1 1 1
1 0 0 0 0 1 0
1 0 1 0 0 1 0
1 1 0 0 1 1 1
1 1 1 0 1 1 1
oder
und
oder
nicht
112
5.2 Aussagenlogische Operatoren
Wenn Sie die Skizze unter der Tabelle an eine elektrische Schaltung erinnert, ist die-
ser Eindruck durchaus gewollt. Große Teile des Innenlebens eines Computers setzen
sich aus Schaltungen zusammen, die nach den Prinzipien der Aussagenlogik ar-
beiten.
Von besonderem Interesse sind für uns verschiedene Ausdrücke, die die gleichen
Werte in ihrer Wahrheitstabelle haben, denn solche Ausdrücke können wir in einer
Formel austauschen, ohne den logischen Gehalt der Formel zu ändern. Als Beispiel 5
betrachten wir die Ausdrücke A ∧ B bzw. A ∨ B.
A B A∧B A∨B
0 0 1 1
0 1 1 1
1 0 1 1
1 1 0 0
Beide Ausdrücke beschreiben also die gleiche logische Funktion. Das war auch zu
erwarten, denn der Satz »Nicht beide Autos sind rot« ist logisch gleichwertig mit
»Eines der beiden Autos ist nicht rot«. Die Sätze sind nicht gleich, aber gleichwertig.
Das kennen Sie ja auch schon aus der Arithmetik: Die Formeln (a + b)2 und a2 + 2ab +
b2 sind auch nicht gleich (im Sinne von identisch), aber in jeder algebraischen Formel
können Sie den einen Ausdruck durch den anderen ersetzen. Ebenso können Sie jetzt
in jeder logischen Formel den Ausdruck A ∧ B durch A ∨ B ersetzen. Wir sprechen in
diesem Zusammenhang auch von logischer Äquivalenz oder Gleichheit. Um dies in
Formeln ausdrücken zu können, führen wir einen neuen Operator – den Äquivalenz-
operator – ein:
A B A⇔B
0 0 1
0 1 0
1 0 0
1 1 1
113
5 Aussagenlogik
Um Klammern zu sparen, wollen wir vereinbaren, dass ⇔ schwächer bindet als die
zuvor eingeführten Operatoren. Dann können wir die Äquivalenz von A ∧ B und
A ∨ B auch durch eine Formel ausdrücken:
A∧B⇔A∨B
Dieser Ausdruck ist unabhängig von den Wahrheitswerten von A und B immer wahr.
Formeln, die unabhängig vom Wahrheitsgehalt der Elementaraussagen immer wahr
sind, bezeichnen wir als Tautologien. Tautologien haben in der Aussagenlogik die
gleiche Bedeutung wie wichtige Identitäten (z. B. binomische Formeln) in der Alge-
bra. Einige wichtige Tautologien sind im Folgenden zusammengestellt:
Logische Äquivalenzen
A ˄ ( B ˄ C ) ⟺ ( A˄ B ) ˄ C A ˅ ( B ˅ C ) ⟺ ( A˅ B ) ˅ C Assoziativgesetz
A˄ B ⟺ B˄ A A˅ B ⟺ B˅ A Kommutativgesetz
( A˅ B ) ˄ A ⟺ A ( A˄ B ) ˅ A ⟺ A Verschmelzungsgesetz
A ˄ ( B ˅ C ) ⟺ ( A˄ B ) ˅ ( A˄ C ) A ˅ ( B ˄ C ) ⟺ ( A˅ B ) ˄ ( A˅ C ) Distributivgesetz
A ˄ (B ˅ B ) ⟺ A A ˅ (B ˄ B ) ⟺ A Komplementgesetz
A˄ A ⟺ A A˅ A ⟺ A Idempotenzgesetz
A˄ B ⟺ A˅B A˅ B ⟺ A˄B De Morgansches Gesetz
A˄ A ⟺ 0 A˅ A ⟺ 1
A⟺A
Diese Formeln eröffnen Ihnen die Möglichkeit, mit logischen Ausdrücken wie mit
algebraischen Formeln zu rechnen. Teilweise ähneln diese Formeln sehr stark For-
meln, die Sie aus der Algebra kennen.
Einen letzten Operator, den Implikationsoperator, möchten wir Ihnen noch vorstel-
len und dafür das Symbol ⇒ verwenden:
A B A⇒B
0 0 1
0 1 1
1 0 0
1 1 1
114
5.2 Aussagenlogische Operatoren
Dieser Operator ist sehr eng mit unserer logischen Schlussfolgerungsweise (wenn A
gilt, dann gilt auch B) verwandt. Trotzdem sollten Sie die Implikation nicht mit einem
logischen Schluss verwechseln. Wenn Sie sagen:
ist diese Aussage im Sinne zweier mit dem Implikationsoperator verbundener Teil-
aussagen gemäß obiger Wahrheitstafel wahr. Keinesfalls ist damit aber gemeint, dass
es eine Kausalität gibt, die besagt, dass Städte mit mehr als 1 Mio. Einwohnern immer 5
in Deutschland liegen. Wenn Köln mehr als 1 Mio. Einwohner hätte, könnte man der
Formulierung
Köln hat mehr als 1 Mio. Einwohner, also liegt Köln in Deutschland
sicherlich nicht zustimmen, da eine derartige Kausalität nicht besteht. Besteht aller-
dings eine kausale Beziehung, wie etwa in
wenn eine Zahl kleiner als 5 ist, dann ist sie auch kleiner als 10,
dann gilt auch die Implikation für alle konkret eingesetzten Zahlen, da der Fall einer
wahren Aussage links und einer falschen Aussage rechts vom Implikationspfeil
durch den Kausalzusammenhang ausgeschlossen ist.
Beachten Sie, dass wir eine Implikation als wahr definiert haben, wenn die Prämisse
– das ist die Aussage links vom Implikationspfeil – falsch ist; und zwar völlig unab-
hängig davon, was auf der rechten Seite folgt. Auch hier gibt es oft Widerspruch, weil
die Implikation unausgesprochen als Äquivalenz verstanden wird. Ein in diesem
Zusammenhang häufig zu beobachtender Fehler ist es, dass die Aussagen A ⇒ B und
A ⇒ B als gleichwertig angesehen werden. Eine Betrachtung der Wahrheitstafeln
zeigt aber, dass eine solche Gleichsetzung falsch ist. Wenn ich z. B. sage:
Wenn morgen die Sonne scheint, dann gehe ich ins Schwimmbad,
dann heißt das nicht, dass ich bei Regen nicht ins Schwimmbad gehe. Ich habe mich
für diesen Fall nicht festgelegt. Hätte ich mich auch in diesem Fall festlegen wollen,
hätte ich eine Äquivalenzaussage formulieren müssen:
Ich gehe morgen genau dann ins Schwimmbad, wenn die Sonne scheint.
Auch für die Implikation gilt eine Reihe von Rechenregeln. Die drei vielleicht wich-
tigsten zeigt die folgende Tabelle:
( A ⟹ B ) ⟺ ( A˅ B)
(A ⟹ B ) ⟺ ( B ⟹ A)
( A ⟺ B ) ⟺ ( A ⟹ B ) ˄ (B ⟹ A )
Abbildung 5.4 Rechenregeln für die Implikation
115
5 Aussagenlogik
Die erste Regel zeigt, wie wir eine Implikation durch »nicht« und »oder« ausdrücken
können. Die zweite ermöglicht, eine Implikation »rückwärts« zu lesen. Die dritte for-
muliert einen naheliegenden Zusammenhang zwischen Implikation und Äquiva-
lenz.
Jetzt können Sie übrigens die Eingangsfrage dieses Kapitels beantworten: »Wenn
fünf Ochsen in fünf Minuten fünf Liter Milch geben, dann gibt es den Osterhasen«.
Dieser Satz ist aussagenlogisch wahr, was aber keine Auswirkungen auf die Milchpro-
duktion von Ochsen oder die Existenz des Osterhasen hat.
z = f(x,y) = x ∧ y
anschauen, dann handelt es sich um eine Funktion, die zwei logische Werte (x und y)
übergeben bekommt und daraus einen logischen Wert (z) berechnet. So eine Funk-
tion nennen wir eine zweiststellige boolesche Funktion.
116
5.3 Boolesche Funktionen
Allgemein können wir n-stellige boolesche Funktionen betrachten. Letztlich ist eine
n-stellige boolesche Funktion durch eine Tabelle mit n Eingabespalten und einer
Ausgabespalte gegeben. In der Tabelle stehen nur 0 und 1 (siehe Abbildung 5.5).
Die Anzahl der Zeilen einer solchen Tabelle ist abhängig von der Anzahl der Eingabe-
spalten. Bei vier Eingabespalten haben wir 16 Zeilen. Die Zahl der Zeilen verdoppelt
sich mit jeder hinzukommenden Spalte, sodass wir bei n Spalten 2n Zeilen in der
Tabelle haben. In jeder Zeile können wir dann einen Funktionswert angeben, sodass 5
n
wir insgesamt 2 ( 2 ) n-stellige boolesche Funktionen aufstellen können. Wenn Sie
noch kein Gefühl für das Wachstum dieser Zahl haben, dann betrachten Sie die
Tabelle in Abbildung 5.6.
n
n 2 (2 )
0 2
1 4
2 16
So viele fünfstellige
3 256 boolesche
4 65536 Funktionen gibt es.
5 4294967296
6 1,84467E+19
7 3,40282E+38
8 1,15792E+77
9 1,3408E+154
Trotz dieser schieren Menge sind wir in der Lage, alle booleschen Funktionen mit
unseren drei logischen Grundoperatoren »nicht«, »und« und »oder« zu berechnen.
Wie das geht, zeige ich Ihnen an einem Beispiel. Dabei sollten Sie darauf achten, dass
sich das Vorgehen problemlos auf jedes beliebige andere Beispiel übertragen lässt.
117
5 Aussagenlogik
A B C D z = f(A,B,C,D)
0 1
0 0 0 0 0
A
0 0 0 1 0
0 0 1 0 0
0 1 0 1
0 0 1 1 1 A ˄ B˄C ˄ D
B C 0 1 0 0 0
0 1 0 1 0
0 1
0 1 1 0 0
D 0 1 1 1 1 A ˄ B˄C ˄ D
1 0 1 0 0 0 0
1 0 0 1 1 A ˄ B ˄ C˄ D
1 0 1 0 0
1 0 1 1 1 A ˄ B ˄ C˄ D
1 1 0 0 1 A ˄ B˄ C ˄D
1 1 0 1 1 A ˄ B˄ C ˄D
1 1 1 0 1 A ˄ B˄ C ˄D
1 1 1 1 1 A ˄ B ˄ C˄ D
z = A ˄ B ˄ C ˄ D ˅ A ˄ B ˄ C ˄ D ˅ A ˄ B ˄ C˄ D ˅ A ˄ B ˄ C˄ D
˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D ˅ A ˄ B˄ C ˄D
Für das Spiel erstellen Sie eine Wahrheitstafel, indem Sie alle 16 möglichen Weichen-
stelllungen in Gedanken durchspielen und den Auslauf notieren. Dann betrachten
Sie in der Tabelle die Zeilen, in denen Sie eine 1 als Ergebnis erhalten haben. Für diese
Zeilen gibt es eine einfache Darstellung ausschließlich mit »und« und »nicht«. Am
Ende sammeln Sie diese Terme durch eine Oder-Verknüpfung ein. Jede 1 in der Wer-
tespalte der Funktionstabelle triggert damit genau einen Term, der eine 1 erzeugt.
Diese 1 sorgt dann dafür, dass sich in dieser Situation insgesamt eine 1 als Funktions-
ergebnis ergibt.
Um die Lesbarkeit unserer Formeln zu verbessern, lassen wir das ∧-Zeichen in den
Formeln einfach weg und erhalten:
z = A BCD ∨ ABCD ∨ AB CD ∨ ABCD ∨ ABC D ∨ ABCD ∨ ABCD ∨ ABCD
Diese Formel ist relativ komplex, und Sie können versuchen, sie zu vereinfachen.
Dazu gibt es Techniken, wie z. B. die sogenannten Karnaugh-Diagramme, die wir hier
aber nicht behandeln werden. Letztlich werden boolesche Funktionen mit zuneh-
mender Stellenzahl so komplex, dass sie sich nur noch mit Computerunterstützung
optimieren lassen. Die Optimierung komplexer boolescher Funktionen ist ein ganz
118
5.4 Logische Operatoren in C
0 1 0 1
B C
0 1
D
1 0
Wir müssen untersuchen, wann die Kugel den Ausgang 1 nimmt. Sie sehen, dass,
wenn A = 1 und B = 1 ist, alle Kugeln zum Ausgang 1 gelenkt werden, egal, wie die bei-
den anderen Weichen stehen. Wenn A = 1 und B = 0 ist oder wenn A = 0 und C = 1 ist,
geht die Kugel durch die Mitte, und Weiche D entscheidet, wo sie letztlich hingeht. In
allen anderen Fällen ist der Ausgang 0.
z = A B ∨ (A B ∨ A C)D
2 Wenn Sie sich schon einmal mit digitalen Schaltungen beschäftigt haben, wissen Sie, dass es
sogar einen einzigen Operator gibt, mit dem man alle Schaltungen aufbauen kann; wenn nicht,
dann versuchen Sie, diesen Operator zu finden.
119
5 Aussagenlogik
schon, dass Sie mehr nicht brauchen. C verwendet die folgenden Zeichen für die logi-
schen Operatoren:
Operator Darstellung in C
nicht !
und &&
oder ||
In der Auswertung boolescher Ausdrücke folgt C den Regeln, die wir oben bereits auf-
gestellt haben: ! vor && vor ||. Im Zweifel setzen Sie Klammern.
Das ist eigentlich schon alles, was Sie wissen müssen, um mit logischen Ausdrücken
zu programmieren.
5.5 Beispiele
Unsere Kenntnisse über die boolesche Algebra und die logischen Operatoren in C fas-
sen wir jetzt zusammen, um zwei kleine Programmieraufgaben zu lösen.
5.5.1 Kugelspiel
Für das Kugelspiel hatten wir zwei Lösungsformeln hergeleitet:
Und
z = A B ∨ (A B ∨ A C)D
Beide lassen sich einfach in C-Code umsetzen. Wir wollen beide Lösungen verglei-
chen und geben dazu die gesamte Funktionstabelle mit den beiden berechneten
Werten aus:
void main()
{
int A, B, C, D;
int z1, z2;
120
5.5 Beispiele
Neu ist für Sie vielleicht die Technik, mit der hier durch vier ineinander geschachtelte
Schleifen die Tabelle erzeugt wird. Aber das ist ganz einfach:
Bei diesem Prozess bewegt sich A am trägsten und D am hektischsten. Auf diese Weise
entstehen die Kombinationen genau in der Reihenfolge, in der wir sie bisher auch
immer notiert haben, und wir sehen, dass die in der innersten Schleife berechneten
logischen Werte exakt den Erwartungen entsprechen (siehe Abbildung 5.9).
Das Umschlagen der Weichen können wir hier übrigens nicht mehr simulieren, da
dieses Verhalten in diesem booleschen Modell nicht mehr abgebildet ist.
121
5 Aussagenlogik
A B C D z = f(A,B,C,D) 0 0 0 0 : 0 0
0 1
0 0 0 0 0 0 0 0 1 : 0 0
A
0 0 1 0 : 0 0
0 0 0 1 0 0 0 1 1 : 1 1
0 0 1 0 0
0 1 0 0 : 0 0
0 1 0 1 0 1 0 1 : 0 0
0 0 1 1 1 0 1 1 0 : 0 0
0 1 1 1 : 1 1
B C 0 1 0 0 0 1 0 0 0 : 0 0
0 1 0 1 0 1 0 0 1 : 1 1
1
1 0 1 0 : 0 0
0 0 1 1 0 0 1 0 1 1 : 1 1
1 1 0 0 : 1 1
0 1 1 1 1
D 1 1 0 1 : 1 1
1 0 1 0 0 0 0 1 1 1 0 : 1 1
1 1 1 1 : 1 1
1 0 0 1 1
1 0 1 0 0
1 0 1 1 1
1 1 0 0 1
1 1 0 1 1
1 1 1 0 1
1 1 1 1 1
5.5.2 Schaltung
Als eine weitere Anwendung der Aussagenlogik wollen wir ein Programm schreiben,
das alle Schalterstellungen, bei denen in der folgenden Schaltung die Lampe leuchtet,
tabellarisch ausgibt.
s3 s4
s1
s5
s2
s7
s6
Wir verwenden für jeden der Schalter S1–7 eine Variable, die jeweils die Werte 0 oder
1 annehmen kann. Dabei bedeutet:
122
5.5 Beispiele
s3 und s4
s1 5
oder und oder
s5
s2
oder und s7
s6
Damit können wir den Zustand der Lampe (1 = an, 0 = aus) als eine boolesche Funk-
tion der Schalterstellungen darstellen. In C-Notation erhalten wir also:
lampe = (s1 || s2) && ((s3 && s4) || ((s5 || s6) && s7))
Jetzt müssen wir alle möglichen Schalterstellungen generieren und dann jeweils prü-
fen, ob die Lampe brennt. Alle 128 möglichen Schalterstellungen erzeugen wir, indem
wir in sieben ineinander geschachtelten Zählschleifen alle Schalter jeweils auf 0 bzw.
1 setzen. Diese Methode kennen Sie bereits aus der letzten Aufgabe.
void main()
{
int s1, s2, s3, s4, s5, s6, s7;
int lampe;
printf( "s1 s2 s3 s4 s5 s6 s7\n");
for( s1 = 0; s1 <= 1; s1 = s1 + 1)
{
for( s2 = 0; s2 <= 1; s2 = s2 + 1)
{
for( s3 = 0; s3 <= 1; s3 = s3 + 1)
{
for( s4 = 0; s4 <= 1; s4 = s4 + 1)
{
for( s5 = 0; s5 <= 1; s5 = s5 + 1)
{
123
5 Aussagenlogik
for( s6 = 0; s6 <= 1; s6 = s6 + 1)
{
for( s7 = 0; s7 <= 1; s7 = s7 + 1)
{
lampe = (s1||s2)&&((s3&&s4)||((s5||s6)&&s7));
if( lampe == 1)
printf( " %d %d %d %d %d %d %d\n",
s1, s2, s3, s4, s5, s6, s7);
}
}
}
}
}
}
}
}
Wenn Sie in der innersten Schleife erkennen, dass die Lampe leuchtet (lampe == 1),
geben Sie die zugehörigen Schalterstellungen aus. Sie erhalten eine Liste mit insge-
samt 51 gültigen Schalterstellungen, von denen ein Teil hier dargestellt ist (siehe
Abbildung 5.12).
s1 s2 s3 s4 s5 s6 s7
0 1 0 0 0 1 1
0 1 0 0 1 0 1
0 1 0 0 1 1 1
0 1 0 1 0 1 1
0 1 0 1 1 0 1
0 1 0 1 1 1 1
0 1 1 0 0 1 1
0 1 1 0 1 0 1
0 1 1 0 1 1 1
0 1 1 1 0 0 0
0 1 1 1 0 0 1
0 1 1 1 0 1 0
0 1 1 1 0 1 1
… … … … … … …
Die Methode zur Generierung der Schalterkombinationen lässt sich durch eine Ver-
zweigungsstruktur, die einem auf den Kopf gestellten Baum ähnelt, veranschauli-
chen (siehe Abbildung 5.13).
An jedem Verzweigungspunkt (Schalter) gibt es die Möglichkeit, nach links (0) oder
nach rechts (1) zu gehen. Jeder Weg durch den Baum entspricht genau einer Schalter-
kombination. Unser Programm sucht also in einer vollständigen Baumsuche unter
124
5.5 Beispiele
allen möglichen Wegen diejenigen heraus, die die gewünschte Eigenschaft haben. Eine
spezielle Lösung ist in Abbildung 5.13 hervorgehoben.
0 1
s1 0
s2 1 5
s3 1
s4 0
s5 0
s6 1
s7 1
Viele der Programme, die wir hier betrachten, verwenden die Lösungsstrategie einer
vollständigen Baumsuche. Das liegt daran, dass sich bei abstrakter Betrachtung von
Problemen häufig Bäume als natürliche Modelle zur Beschreibung des Problem- oder
Lösungsraums anbieten. Die Diskussion von Bäumen wird daher im Laufe dieses
Buches noch breiten Raum einnehmen.
Ein Phänomen, das uns sehr zu schaffen machen wird, lässt sich an diesem Beispiel
bereits erahnen. Schauen Sie sich die Anzahl der zu untersuchenden Schalterkombi-
nationen an, werden Sie feststellen, dass sich deren Zahl mit Hinzunahme eines
neuen Schalters jeweils verdoppelt, obwohl im Programmcode nur eine Schleife, die
zwei Werte durchläuft, hinzukommt. Verantwortlich dafür ist die Tiefe der Schachte-
lung, die mit jedem Schalter um 1 zunimmt. Bei Hinzunahme eines Schalters ist dann
aber auch mit einer Verdopplung der Laufzeit des Programms zu rechnen. Wenn Sie
sich vorstellen, dass Ihr Rechner zur Untersuchung einer Schalterkombination eine
bestimmte Zeiteinheit benötigt, werden zur Analyse einer Schaltung mit 20 Schal-
tern 1048576, bei 50 Schaltern bereits 1.26 · 1015 Zeiteinheiten benötigt. Wenn Sie
zusätzlich annehmen, dass die Analyse einer Schalterkombination 1/1000 sec dauert,
würde für die Analyse einer Schaltung mit 50 Schaltern ein Zeitraum von mehr als
35000 Jahren benötigt. Für wirklich große Schaltungen wird kein noch so schneller
Rechner der Welt diese Art der Schaltungsanalyse in akzeptabler Zeit durchführen
können. Wir sind mit diesem einfachen Beispiel bereits auf das Problem der »kombi-
natorischen Explosion« gestoßen, mit dem wir uns noch eingehend beschäftigen
werden.
125
5 Aussagenlogik
5.6 Aufgaben
A 5.1 Wir definieren einen neuen logischen Operator nand durch folgende Wahr-
heitstafel:
A B A nand B
0 0 1
0 1 1
1 0 1
1 1 0
Zeigen Sie, dass man beliebige boolesche Funktionen unter alleiniger Verwen-
dung des nand-Operators darstellen kann!
Hinweis: Es reicht, wenn Sie zeigen, dass man »nicht«, »und« und »oder« dar-
stellen kann.
A 5.2 Erstellen Sie ein Programm, das Wahrheitstafeln für die folgenden booleschen
Ausdrücke auf dem Bildschirm ausgibt:
1. ( A ∧ B ) ⇒ ( C ∨ D )
2. A ∧ B ∨ C ∧ D
3. A ⇒ B ⇒ ( C ∨ D )
4. ( A ∨ B ) ∧ ( A ∨ C ) ∧ D
Logische Äquivalenzen
A ˄ ( B ˄ C ) ⟺ ( A˄ B ) ˄ C A ˅ ( B ˅ C ) ⟺ ( A˅ B ) ˅ C Assoziativgesetz
A˄ B ⟺ B˄ A A˅ B ⟺ B˅ A Kommutativgesetz
( A˅ B ) ˄ A ⟺ A ( A˄ B ) ˅ A ⟺ A Verschmelzungsgesetz
A ˄ ( B ˅ C ) ⟺ ( A˄ B ) ˅ ( A˄ C ) A ˅ ( B ˄ C ) ⟺ ( A˅ B ) ˄ ( A˅ C ) Distributivgesetz
A ˄ (B ˅ B ) ⟺ A A ˅ (B ˄ B ) ⟺ A Komplementgesetz
A˄ A ⟺ A A˅ A ⟺ A Idempotenzgesetz
A˄ B ⟺ A˅B A˅ B ⟺ A˄B De Morgansches Gesetz
A˄ A ⟺ 0 A˅ A ⟺ 1
A⟺A
126
5.6 Aufgaben
A 5.4 Die Schaltung aus Aufgabe 5.2 wird dahingehend abgeändert, dass zwei Schal-
ter miteinander gekoppelt werden und eine neue Leitung gelegt wird.
Finden Sie eine möglichst einfache boolesche Funktion für diese Schaltung,
und erstellen Sie ein Programm, das alle Schalterstellungen ausgibt, in denen
die Lampe leuchtet!
5
s3 s4
s1
s5
s2
s7
s6
A 5.5 Familie Müller ist zu einer Geburtstagsfeier eingeladen. Leider können sich
die Familienmitglieder (Anton, Berta, Claus und Doris) nicht einigen, wer hin-
geht und wer nicht. In einer gemeinsamen Diskussion kann man sich jedoch
auf die folgenden fünf Grundsätze verständigen:
Helfen Sie Familie Müller, indem Sie ein Programm erstellen, das alle Konstel-
lationen ermittelt, in denen Familie Müller zur Feier gehen könnte.
A 5.6 Bankdirektor Schulze hat den Tresor seiner Bank durch ein elektronisches
Schloss sichern lassen. Dieses Schloss kann über neun Kippschalter geöffnet
werden, wenn man diese in die richtige Stellung (»unten« oder »oben«)
bringt. Da sich der Bankdirektor die richtige Schalterkombination nicht mer-
ken kann und bereits mehrfach einen Fehlalarm ausgelöst hat, hat er sich den
folgenden Merkzettel erstellt:
1. Wenn Schalter 3 auf »oben« gestellt wird, dann müssen sowohl Schalter 7
als auch Schalter 8 auf »unten« gestellt werden.
2. Wenn Schalter 1 auf »unten« gestellt wird, dann muss von den Schaltern 2
und 4 mindestens einer auf »unten« gestellt werden.
127
5 Aussagenlogik
3. Von den beiden Schaltern 1 und 6 muss mindestens einer auf »unten« ste-
hen.
4. Wenn Schalter 6 auf »unten« gestellt wird, dann müssen 7 auf »unten« und
5 auf »oben« stehen.
5. Falls sowohl Schalter 9 auf »unten« als auch Schalter 1 auf »oben« gestellt
werden, dann muss 3 auf »unten« stehen.
6. Von den Schaltern 8 und 2 muss mindestens einer auf »oben« stehen.
7. Wenn Schalter 3 auf »unten« oder Schalter 6 auf »oben« steht oder beides
der Fall ist, dann müssen Schalter 8 auf »unten« und Schalter 4 auf »oben«
stehen.
8. Falls Schalter 9 auf »oben« steht, dann müssen Schalter 5 auf »unten« und
Schalter 6 auf »oben« stehen.
9. Wenn Schalter 4 auf »unten« steht, dann müssen Schalter 3 auf »unten«
und Schalter 9 auf »oben« stehen.
A 5.7 Der Wikipedia habe ich das folgende Beispiel einer sogenannten Entschei-
dungstabelle entnommen:
Tabellenbezeichnungen R1 R2 R3 R4 R5 R6 R7 R8
Bedingungen
Lieferfähig? j j j j n n n n
Angaben vollständig? j j n n j j n n
Bonität in Ordnung? j n j n j n j n
Aktionen
Lieferung mit Rechnung x x
Lieferung als Nachnahme x x
Angaben vervollständigen x x
Mitteilen: nicht lieferbar x x x x
Erstellen Sie ein C-Programm, das die in der Tabelle genannten Bedingungen
abfragt und dann die erforderlichen Aktionen ausgibt.
128
Kapitel 6
Elementare Datentypen und ihre
Darstellung
Das Buch der Natur ist mit mathematischen Symbolen geschrieben.
– Galileo Galilei
6
Jemand bittet Sie, sich eine geheime Zahl zwischen 0 und 31 zu denken, und legt
Ihnen dann nacheinander die folgenden fünf Karten vor:
2 3 6 7
10 11 14 15 11
C D 10
18 19 22 23 9 15
8 14
26 27 30 31 4
13 27
5
6 12 26
12 7 25 31
7 13
14 24 30
5 20 15 29
15 21
A 3 28
22 28
13 23 23
E
29
1 30
11 21 31
16
31
9
19 29
20
17
17
24
27
21
18
28
25
25
22
19
29
26
23
30
27
31
Er fordert Sie auf, jeweils zu sagen, ob die gedachte Zahl auf der Karte steht oder nicht.
Nachdem Sie die Fragen beantwortet haben, nennt er Ihnen, ohne lange zu zögern,
Ihre Geheimzahl.
129
6 Elementare Datentypen und ihre Darstellung
Versuchen Sie, hinter diesen Trick zu kommen. Wenn es Ihnen nicht gelingt, dann
lesen Sie aufmerksam das folgende Kapitel, denn Sie erfahren dort mehr über den
Hintergrund dieses Tricks. Im Laufe dieses Kapitels werden wir den Trick auflösen
und Ihnen zeigen, wie Sie ihn programmieren.
6.1 Zahlendarstellungen
Zahlen sind abstrakte mathematische Objekte. Damit man sie konkret benutzen kann,
brauchen sie eine »Benutzerschnittstelle«, mittels derer man sie addieren, multipli-
zieren oder vergleichen kann. Die denkbar einfachste Benutzerschnittstelle erhält
man, wenn man für die Eins einen Strich und für jede folgende Zahl jeweils einen wei-
teren Strich macht. Solche Darstellungen werden allerdings sehr schnell unübersicht-
lich, sodass man zur besseren Lesbarkeit Gruppierungen einführen muss:
Dieses Strichsystem kennt im Prinzip nur eine Operation (Addition von 1) und hat in
dieser Beschränkung durchaus seine Vorteile, sodass wir es heute noch – z. B. auf
Bierdeckeln – verwenden. Wirklich große Zahlen lassen sich dadurch allerdings nicht
darstellen, sodass man gezwungen ist, zur Abkürzung zusätzliche Symbole einzufüh-
ren. So ist es z. B. im römischen Zahlensystem, aber rechnen Sie bitte mal CCCLXXVII
+ DCXXIII. Das römische Zahlensystem verwendet man heute nur noch aus nostalgi-
schen Gründen – etwa auf den Zifferblättern von Uhren oder für Jahreszahlen in
Kalendern.
Ziffernwerte
Stellenwerte
Diese Darstellung beruht auf der Zahl 10 als Basis. Im Grunde genommen müssten
Sie die Basiszahl an der Ziffernfolge vermerken (z. B. 471110), denn nur mithilfe der
Basis können Sie aus der Ziffernfolge den Zahlenwert rekonstruieren.
130
6.1 Zahlendarstellungen
Sie können jede andere natürliche Zahl größer als 1 als Basis verwenden – z. B. die Zahl
7. Sie erhalten dann nur eine andere Ziffernfolge:
Ziffernwerte
Basis 7
471110 = 1 · 74 + 6 · 73 + 5 · 72 + 1 · 71 + 0 · 70 = 165107
Basis 10 Stellenwerte
6
Wie kommen Sie zu dieser neuen Ziffernfolge? Wir formulieren den oben genannten
Ausdruck durch Ausklammern um:
471110 = (((1 · 7 + 6) · 7 + 5) · 7 + 1) · 7 + 0
Jetzt sehen Sie, dass Sie die Ziffernwerte erhalten, indem Sie die Reste bei Division
durch 7 betrachten. Also dividieren Sie 4711 fortlaufend durch die Basiszahl 7 und
notieren sich die Reste. Das ergibt die gesuchte Ziffernfolge – allerdings in umgekehr-
ter Reihenfolge, da die niederwertigste Ziffer zuerst berechnet wird:
4711 = 673 · 7 + 0
673 = 96 · 7 + 1
96 = 13 · 7 + 5
13 = 1·7 + 6
7 = 0·7 + 1
16510
Abbildung 6.5 Umrechnung auf eine andere Basis
Eigentlich ist im 7er-System alles genauso wie im 10er-System, außer dass es nur die
Ziffern 0–61 gibt und dass beim Zählen immer bei 6 ein Übertrag erfolgt.
10er-System 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
7er-System 0 1 2 3 4 5 6 10 11 12 13 14 15 16 20 21 22
Auch rechnen können Sie im 7er-System genauso gut wie im 10er-System. Die ver-
meintliche Überlegenheit des Dezimalsystems ergibt sich daraus, dass wir durch jah-
relange Übung eine große Vertrautheit mit diesem System erworben haben und alle
1 Divisionsreste größer als 6 können bei einer Division durch 7 ja nicht vorkommen.
131
6 Elementare Datentypen und ihre Darstellung
Um uns die Arbeit des Umrechnens zu erleichtern, schreiben wir ein kleines Pro-
gramm, das die oben beschriebene fortlaufende Division durch die Basis vornimmt:
void main()
{
A int basis = 7;
int zahl = 4711;
int z;
Die Basis, auf die umgerechnet werden soll, ist 7, kann aber geändert werden (A). Im
Verlauf des Programms wird die umzurechnende Zahl so lange durch die Basis
geteilt, bis nichts mehr übrig bleibt (B), und in der Schleife wird jeweils eine Zeile aus-
gegeben (C), sodass wir folgendes Ergebnis erhalten:
Basis: 7
-------------------
4711 = 673*7 + 0
673 = 96*7 + 1
96 = 13*7 + 5
13 = 1*7 + 6
1 = 0*7 + 1
Eigentlich besteht dieses Programm fast nur aus Ausgaben. Die wesentlichen Berech-
nungen verstecken sich in den beiden Ausdrücken z/basis und z%basis, in denen der
ganzzahlige Quotient und der Rest ermittelt werden. Wir spielen alle Basen von 2 bis
9 durch und erhalten die folgenden Ergebnisse:
132
6.1 Zahlendarstellungen
Grundsätzlich ist es kein Problem, eine Basis größer als 10 zu verwenden. Es werden
dann aber unter Umständen Ziffernwerte größer als 10 auftauchen. Wir testen dies
mit der Basis 13:
Basis: 13
------------------
4711 = 362*13 + 5
362 = 27*13 + 11
27 = 2*13 + 1
2 = 0*13 + 2
------------------
Es ergibt sich die Ziffernfolge 2, 1, 11, 5. Diese Ziffernfolge können Sie jedoch nicht naht-
los aneinanderreihen (21115), da dann die eindeutige Zuordnung der Ziffern zu den
Stellenwerten verloren gehen würde. Wir behelfen uns dadurch, dass wir die zusätz-
lichen Ziffernsymbole a, b und c (oder A, B und C) für die Ziffernwerte 10, 11 und 12 ein-
führen. Damit lautet die Darstellung der Zahl 4711 im 13er-System 21b5 oder 21B5.
Eine zentrale Frage steht aber noch im Raum. Warum machen wir das eigentlich?
Sind wir nicht mit dem Dezimalsystem glücklich und zufrieden? Die Antwort auf
diese Frage erhalten Sie im nächsten Abschnitt.
133
6 Elementare Datentypen und ihre Darstellung
6.1.1 Dualdarstellung
Sie wissen, dass ein Digitalrechner intern mit zwei Zuständen – nennen wir sie 0 und
1 – arbeitet. Alle Daten und auch Programme im Rechner bestehen in diesem Sinne
aus Folgen von 0 und 1. Der Rechner braucht eine Organisation, über die er effizient
auf die Daten und Programme zugreifen kann. Dazu wird der Speicher des Rechners
in kleine Speicherzellen unterteilt, und die Speicherzellen werden fortlaufend num-
meriert. Da alles nur mit 0 und 1 dargestellt wird, können Sie sich das wie folgt vor-
stellen:
Speicherzelle Wert
0 0 0 0 0 0 1 1 1 0 0 1 0
1 0 0 0 1 1 0 0 1 0 1 0 0
2 0 0 1 0
3 0 0 1 1
4 0 1 0 0
5 0 1 0 1
6 0 1 1 0
7 0 1 1 1
8 1 0 0 0 weitere
9 1 0 0 1 Werte
10 1 0 1 0
11 1 0 1 1
12 1 1 0 0
13 1 1 0 1
14 1 1 1 0
15 1 1 1 1
Wenn Sie dem Rechner die Anweisung geben, Ihnen den Wert aus der Speicherzelle
13 zu geben, wird der Rechner intern die Zellennummer im 2er-System erwarten und
Ihnen auch den Wert der Speicherzelle im 2er-System zurückgeben. Wenn Sie sich
also mit den Interna des Rechners beschäftigen wollen, kommen Sie um das 2er-Sys-
tem – auch Dualsystem genannt – nicht herum. Für uns Menschen hat dieses System
aber erhebliche Nachteile. Wegen der kleinen Basis sind die Zahlen viel zu lang und
nur umständlich zu handhaben. Außerdem fehlt uns jegliche Größenvorstellung für
134
6.1 Zahlendarstellungen
Dualzahlen. Wenn etwa in der Zeitung ein Auto zu einem Kaufpreis von 23456 Euro
annonciert wäre, würde bei Verwendung des Dualsystems dort ein Kaufpreis von
101101110100000 Euro stehen. Bei einer 0 mehr am Ende der Ziffernfolge wäre es der
doppelte Kaufpreis. Das könnte man nur schwer erkennen.
Als Ergänzung zum Dualsystem benötigen wir dringend Zahlensysteme, die einfache
Umrechnungen ins Dualsystem erlauben, dabei aber »menschenfreundlicher« sind
als das Dualsystem.
6
6.1.2 Oktaldarstellung
Wir betrachten noch einmal unsere Lieblingszahl 4711, für die wir bereits die Dualdar-
stellung 1001001100111 kennen. Ich setze zwei führende Nullen hinzu und gruppiere
die Ziffern in Dreierpäckchen: 001 001 001 100 111
Jetzt klammere ich in jeder Zeile die höchste vorkommende Zweierpotenz aus:
In den Klammern stehen jetzt Ziffernwerte zwischen 0 und 7. Die rechnen wir aus:
135
6 Elementare Datentypen und ihre Darstellung
Damit haben wir eine einfache Umrechnung zwischen dem 8er-System (Oktalsys-
tem) und dem 2er-System gefunden. Sie müssen einfach nur vom Ende der Zahl her
Dreiergruppen bilden und diese Dreiergruppen mit folgender Tabelle ziffernweise in
das Oktalsystem übersetzen:
Oktal 0 1 2 3 4 5 6 7
6.1.3 Hexadezimaldarstellung
Die Hexadezimaldarstellung verwendet die Basis 16 und die zusätzlichen Ziffern-
symbole a, b, c, d, e und f (oder A, B, C, D, E und F), sodass wir die folgende
Übersetzungstabelle für die Ziffern des Hexadezimalsystems nutzen können.
Hexadezimal 0 1 2 3 4 5 6 7 8 9 a b c d e f
Dual 0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111
Da 16 = 24 genauso eine Potenz von 2 ist wie 8 = 23, gilt das zur Umrechnung zwischen
der Dual- und Oktaldarstellung Gesagte auch für die Umrechnung zwischen Dual-
und Hexadezimaldarstellung. Der einzige Unterschied ist, dass anstelle der Dreier-
päckchen jetzt Viererpäckchen gebildet werden müssen. Abbildung 6.12 zeigt Ihnen
die Umrechnung an einem Beispiel:
Oktal 5 6 4 3
Dual 1 0 1 1 1 0 1 0 0 0 1 1
Hexadezimal b a 3
136
6.2 Bits und Bytes
Als Dualzahl interpretiert, kann ein Byte also Zahlen von 0–255 (hex. 00–ff) darstellen.
Bit 7 bezeichnen wir dabei als das höchstwertige (most significant), Bit 0 als das nie-
derwertigste (least significant) Bit. Im Sinne der Interpretation als Dualzahl hat das
höchstwertige Bit den Stellenwert 27 = 128, das niederwertigste den Stellenwert 20 = 1.
Jedes Byte im Speicher bekommt eine fortlaufende Nummer, seine Adresse. Über
diese Adresse kann es vom Prozessor angesprochen (adressiert) werden (siehe Abbil-
dung 6.14).
Der Prozessor wählt über den Adressbus eine Speicherzelle an und kann dann über
den Datenbus den Inhalt der Speicherzelle laden, um die Daten zu verarbeiten. Auf
dem gleichen Weg kann er dann Daten in den Speicher zurückschreiben.
Wir können die Informationen auf dem Adress- und Datenbus als Dualzahlen auffas-
sen. In unserem Beispiel ist die Speicherstelle mit der Adresse 10112 = b16 angewählt,
und in dieser Speicherstelle steht der Wert 110110012 = d916 = 217.
2 Binary Digit, gleichzeitig engl. bit = ein bisschen, kein Bier aus der Eifel
137
6 Elementare Datentypen und ihre Darstellung
Prozessor
1 0 1 1 1 1 0 1 1 0 0 1
Adressbus Datenbus
1 0 1 1 1 1 0 1 1 0 0 1
0 0 0 0 0 1 1 0 1 1 0 1
0 0 0 1 1 1 0 1 0 1 0 1
0 0 1 0 0 1 1 1 1 0 1 0
0 0 1 1 1 0 1 1 0 0 0 1
0 1 0 0 0 1 1 0 1 1 0 1
0 1 0 1 1 0 0 1 1 0 0 0
0 1 1 0 0 0 0 1 0 1 1 1
0 1 1 1 1 0 0 1 1 1 0 0
Adressen
Daten
1 0 0 0 0 1 1 0 1 0 0 0
1 0 0 1 1 1 0 1 0 0 0 0
1 0 1 0 1 0 1 1 0 1 0 1
1 0 1 1 1 1 0 1 1 0 0 1
1 1 0 0 0 1 0 1 0 1 0 1
1 1 0 1 1 1 1 1 1 0 0 0
1 1 1 0 1 0 1 0 1 1 0 1
1 1 1 1 0 1 0 1 0 0 1 0
Speicher
Aus der »Breite« von Adress- und Datenbus ergeben sich grundlegende Leistungsda-
ten für einen Computer. Der hier schematisch gezeichnete Rechner kann über den
vierstelligen Adressbus insgesamt 16 Speicherzellen anwählen, in denen er jeweils
Werte im Bereich von 0–255 findet. Das ist natürlich nichts im Vergleich zu den Kapa-
zitäten heutiger Rechner, die in der Regel über ein 64-Bit-Bussystem verfügen. Da
sich die Kapazität mit jedem zusätzlichen Bit verdoppelt, ergeben sich Werte in ganz
anderen Größenordnungen:
138
6.3 Skalare Datentypen in C
4 Bit 24 16
8 Bit 28 256
Wenn man in diese Größenordnungen vorstößt, braucht man Begriffe für große
Datenmengen. Man orientiert sich dabei an den Maßeinheitenpräfixen (der Physik,
Kilo, Mega, ...). Anders als in der Physik, in der diese Präfixe immer eine Vervielfa-
chung um den Faktor 103 = 1000 bedeuten, verwendet man in der Informatik den
Faktor 210 = 1024.
Wegen der zunehmenden Abweichungen für große Werte sollte man in der Informa-
tik eigentlich immer die Binärpräfixe verwenden. In der Praxis hat sich das jedoch
bisher nicht durchgesetzt. In der Regel verwendet man die SI-Präfixe und meint
damit die Werte der Binärpräfixe.
139
6 Elementare Datentypen und ihre Darstellung
nannte skalare Datentypen hinzu. Unter einem skalaren Datentyp verstehen wir
einen Datentyp zur Darstellung eindimensionaler numerischer Werte.
Syntaxgraph char
signed short
int
Egal, wie Sie das Diagramm durchlaufen, Sie erhalten immer eine gültige Typverein-
barung, auf die dann ein Variablenname folgen muss. Einige Beispiele:
int a;
signed char b;
unsigned short int c;
long d;
unsigned long long int e;
140
6.3 Skalare Datentypen in C
C legt sich bezüglich einer Anzahl der Bytes bei den Datentypen nur auf eine Min-
destgröße fest:
Zusätzlich ist festgelegt, dass die Datentypen in der oben genannten Reihenfolge
ineinander enthalten sind. Auf unterschiedlichen Zielsystemen können Größen der
Datentypen durchaus unterschiedlich sein. Sie können die Werte auf Ihrem Rechner
mit einem kleinen Programm ermitteln:
char: 1
short: 2
int: 4
long: 4
long long: 8
Wir verwenden hier den C-Operator sizeof, der uns die Größe eines Datentyps in
Bytes liefert. Abhängig von der Anzahl der Bytes ergibt sich dann ein unterschiedlich
großer Rechenbereich4:
4 Über die interne Darstellung vorzeichenbehafteter Zahlen haben wir nicht gesprochen, aber
anschaulich sollte klar sein, dass bei gleicher Bytezahl die größte vorzeichenbehaftete Zahl etwa
halb so groß ist wie die größte vorzeichenlose Zahl.
141
6 Elementare Datentypen und ihre Darstellung
signed unsigned
Allgemein
Das Rechnen mit ganzen Zahlen ist exakt, solange Sie den vorgegebenen Rechenbe-
reich nicht verlassen.
C unterstützt das Dezimal-, das Oktal- und das Hexadezimalsystem. Das Dualsystem
ist wegen seiner Nähe zu Oktal- bzw. Hexadezimalsystem dadurch mit abgedeckt.
Wenn wir mit Zahlen in verschiedenen Zahlensystemen in einem Programm arbei-
ten möchten, stellen sich drei Fragen:
Im Quellcode setzen wir einer Zahl ein Präfix voran, an dem man erkennt, ob es sich
um eine Zahl im Oktalsystem (Präfix 0) oder Hexadezimalsystem (Präfix 0x) handelt.
Bei der Eingabe mit scanf bzw. der Ausgabe mit printf verwenden wir spezielle For-
matanweisungen ("%..."):
Beachten Sie, dass 1234 und 01234 in einem C-Programm verschiedene Werte darstel-
len, da es sich im ersten Fall um eine Dezimal- und im zweiten Fall um eine Oktaldar-
stellung handelt.
142
6.3 Skalare Datentypen in C
Die folgenden Beispiele zeigen, wie Sie mit den verschiedenen Zahlendarstellungen
in einem Programm arbeiten können. Dabei haben wir uns hier auf den in diesem
Zusammenhang wichtigsten Datentyp (unsigned int) beschränkt. Im ersten Beispiel
werden Zahlkonstanten in verschiedenen Systemen einer Variablen zugewiesen.
zahl = 123456;
printf( "Dezimalausgabe: %d\n", zahl); 6
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
zahl = 0123456;
printf( "Dezimalausgabe: %d\n", zahl);
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
zahl = 0x123abc;
printf( "Dezimalausgabe: %d\n", zahl);
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n", zahl);
Dezimalausgabe: 123456
Oktalausgabe: 361100
Hexadezimalausgabe: 1e240
Dezimalausgabe: 42798
Oktalausgabe: 123456
Hexadezimalausgabe: a72e
Dezimalausgabe: 1194684
Oktalausgabe: 4435274
Hexadezimalausgabe: 123abc
Im zweiten Beispiel wird der Zahlenwert in unterschiedlichen Formaten von der Tas-
tatur eingelesen:
143
6 Elementare Datentypen und ihre Darstellung
Dezimaleingabe: 123456
Dezimalausgabe: 123456
Oktalausgabe: 361100
Hexadezimalausgabe: 1e240
Oktaleingabe: 123456
Dezimalausgabe: 42798
Oktalausgabe: 123456
Hexadezimalausgabe: a72e
Hexadezimaleingabe: 123abc
Dezimalausgabe: 1194684
Oktalausgabe: 4435274
Hexadezimalausgabe: 123abc
6.3.2 Gleitkommazahlen
Für Gleitkommazahlen gibt es nicht so viele Typvarianten wie für ganze Zahlen:
144
6.3 Skalare Datentypen in C
float 4
double 8
long double 8
Die Ergebnisse dieses Programms sind aber, wie schon bei den Ganzzahltypen,
maschinenabhängig.
–12.345 · 10–12
145
6 Elementare Datentypen und ihre Darstellung
Beispiele:
float a = –1;
float b = 1E2;
double c = 1.234;
long double d = –123.456E-345;
Achtung, während das Rechnen mit ganzen Zahlen exakt ist, solange man im zulässi-
gen Rechenbereich bleibt, ist das Rechnen mit Gleitkommazahlen fehleranfällig. Es
gibt einen Mindestabstand zwischen zwei Zahlen, unterhalb dessen der Rechner
nicht genauer auflösen kann. Durch häufige Rechenoperationen können sich die
Rechenfehler dann aufschaukeln. Dies bei numerischen Berechnungen zu vermei-
den ist es eine anspruchsvolle Aufgabe, mit der wir uns hier aber nicht beschäftigen
werden.
6.4 Bitoperationen
Bisher haben wir Zahlen immer als Material für arithmetische Operationen gesehen,
aber man kann Zahlen auch viel elementarer als ein Bitmuster betrachten – also ein-
fach als eine Folge von 0 und 1. Es ist hilfreich, wenn Sie im Folgenden gar nicht daran
denken, dass Zahlen einen Wert haben. Wir wollen uns überlegen, wie wir im Bitmus-
ter einer ganzen Zahl gezielt Manipulationen durchführen können. Solche Manipula-
tionen sind z. B.:
Natürlich haben solche Operationen auch eine arithmetische Bedeutung, aber uns
interessiert hier in erster Linie das Bitmuster. In C gibt es sechs Operationen auf Bit-
146
6.4 Bitoperationen
Das bitweise Komplement (~) invertiert das Bitmuster einer Zahl. Aus einer 0 wird
eine 1 und aus einer 1 eine 0:
Bitweises Komplement
6
x 1 0 0 1 1 0 1 1
~x 0 1 1 0 0 1 0 0
Das bitweise Und (&) benötigt zwei Operanden und verknüpft deren Muster Bit für Bit
mit einer logischen Und-Operation:
Bitweises Und
x 1 1 0 0 0 0 1 0
y 1 0 0 1 1 0 1 1
x&y 1 0 0 0 0 0 1 0
Ganz analog arbeitet das bitweise Oder (|) mit einer logischen Oder-Operation:
Bitweises Oder
x 1 1 0 0 0 0 1 0
y 1 0 0 1 1 0 1 1
x|y 1 1 0 1 1 0 1 1
Bitweises Entweder-Oder
x 1 1 0 0 0 0 1 0
y 1 0 0 1 1 0 1 1
x^y 0 1 0 1 1 0 0 1
147
6 Elementare Datentypen und ihre Darstellung
Schließlich gibt es noch zwei Schiebeoperationen. Bei einem Bitshift nach rechts wird
das Bitmuster um eine gewisse Anzahl von Stellen nach rechts geschoben, und die
frei werdenden Stellen werden mit Nullen aufgefüllt:
Bitshift rechts
x 1 0 0 1 1 0 1 1
x>>2 0 0 1 0 0 1 1 0
Der Bitshift nach links schiebt in die andere Richtung. Auch hier werden Nullen nach-
geschoben:
Bitshift links
x 1 0 0 1 1 0 1 1
x<<2 0 1 1 0 1 1 0 0
Ein Schieben um eine Stelle nach links entspricht übrigens einer Multiplikation mit
2, ein Schieben um eine Stelle nach rechts entspricht einer Division durch 2 ohne
Rest. Dementsprechend ist 1 << n = 2n.
Mit den jetzt bereitgestellten Grundoperationen können Sie die oben beschriebenen
Bitmanipulationen durchführen. Starten Sie mit der Aufgabe, in einer Zahl x ein
bestimmtes Bit – etwa das dritte von rechts5 – zu setzen. Dazu gehen Sie wie folgt vor:
5 Wenn ich vom »dritten Bit von rechts« spreche, meine ich das Bit mit dem Stellenwert 23. Ich
fange also, wie so oft in der Programmierung, bei 0 an zu zählen.
148
6.4 Bitoperationen
int n = 3;
unsigned int x = 0xaffe;
Das Bitmuster 1<<n, in dem ja genau ein Bit gesetzt ist, bezeichnet man auch als Maske,
weil über dieses Muster genau ein Bit aus der Zahl x herausgefiltert (maskiert) wird.
149
6 Elementare Datentypen und ihre Darstellung
Häufig will man ein Bitmuster nicht verändern, sondern einfach nur testen, ob ein
bestimmtes Bit in dem Bitmuster gesetzt ist. Das geht so:
int n = 3;
unsigned int x = 0xaffe;
Sie werden sich vielleicht fragen, was solche »Bitfummeleien« sollen. Darauf will ich
Ihnen zwei Antworten geben. Erstens, wenn Sie einmal maschinennah, etwa auf
einem Microcontroller, programmieren, werden Ihre Programme zu einem großen
Teil aus solchen Bitoperationen bestehen, und zweitens können Sie mit diesen Tech-
niken den zu Beginn des Kapitels gezeigten Kartentrick programmieren. Das machen
wir als erstes Beispiel im nächsten Abschnitt.
6.5 Programmierbeispiele
Unsere Kenntnisse über die Darstellung von Zahlen in verschiedenen Zahlensyste-
men setzen wir jetzt in einigen Beispielen praktisch ein. Dabei kommen wir auch
noch einmal auf den Kartentrick vom Anfang des Kapitels zurück, den Sie mittler-
weile schon durchschaut haben dürften.
6.5.1 Kartentrick
Ich weiß nicht, ob es Ihnen gelungen ist, hinter den am Anfang des Kapitels beschrie-
benen Kartentrick zu kommen. Wenn nicht, dann versuchen Sie es, bevor Sie weiter-
lesen, vielleicht noch einmal mit den inzwischen erworbenen Kenntnissen über
Dualzahlen und Bitmuster.
Wir schauen uns an, welche Dualdarstellung die Zahlen auf den Karten haben, und
kommen zu folgendem Ergebnis (siehe Abbildung 6.26).
Auf der Karte A stehen alle Zahlen, die das nullte Bit gesetzt haben. Auf der Karte B
stehen alle Zahlen, die das erste Bit gesetzt haben, und so geht das weiter. Das heißt,
jedes Mal, wenn der Besitzer der Geheimzahl sagt, dass die Zahl auf einer Karte steht
oder nicht, gibt er ein Bit seiner Zahl preis. Diese Bits müssen Sie nur noch zu einer
Zahl kombinieren. Dazu betrachten Sie die erste Zahl auf jeder Karte. In dieser Zahl ist
immer nur genau das für diese Karte charakteristische Bit gesetzt.
150
6.5 Programmierbeispiele
E D C B A
0
1 B
2
3
2 3 6 7
4
5 10 11 14 15 11
6 C D 10
7 18 19 22 23 9 15
8 8 14
9 26 27 30 31 4
13 27
5
6 12 26
10 12 7 25 31
7 13 6
11
14 24 30
12
5 20 15 29
15 21
13 A 3 28
22 28
13 23 23
E
14
29
1 30
11
15
21 31
16
16 31
9
19 29
20
17
17
17
24
18
27
21
18
19
28
25
25
22
20
19
29
26
21 1 0 1 0 1
23
22
30
27
23
31
24
25
26
27
28
29 1 + 4 + 16 = 21
30
31
Sie müssen also nur noch ein logisches Oder zwischen den ersten Zahlen aller ausge-
wählten Karten bilden, um die Geheimzahl zu erhalten. Da die Bitmuster dieser Zah-
len sich aber nicht überschneiden, entspricht diese Oder-Verknüpfung einer
Addition. Wenn Sie also die jeweils ersten Zahlen der Karten, auf denen die Geheim-
zahl stehen, addieren, erhalten Sie die Geheimzahl.
void main()
{
int bit, z, zahl, antwort;
151
6 Elementare Datentypen und ihre Darstellung
1 = 000012
2 = 000102
4 = 001002
8 = 010002
16 = 100002
In der folgenden Schleife (B) werden alle 32 Zahlen durchlaufen, aber ausgegeben
werden nur die, die das bit gesetzt haben (C). Wenn die gesuchte Zahl auf der ausge-
gebenen Karte steht, wird das bit gesetzt (D).
6.5.2 Zahlenraten
Wenn Sie das letzte Beispiel abwandeln, kommen Sie zu einer anderen Form des Zah-
lenratens. Sie können das höchste Bit setzen und fragen, ob die Geheimzahl größer
oder kleiner ist. Wenn sie größer ist, bleibt das Bit gesetzt, ansonsten löschen wir es
wieder. Dann setzen wir das zweithöchste Bit und fragen wieder. Auf diese Weise
152
6.5 Programmierbeispiele
können wir mit jeder Frage die Anzahl der noch möglichen Zahlen halbieren, bis am
Ende nur noch eine Zahl übrig bleibt. Hier ist das Programm dazu:
void main()
{
int n, antwort;
unsigned int zahl, bit;
6
A printf( "Anzahl Stellen: ");
scanf( "%d", &n);
printf( "Denk dir eine Zahl zwischen 0 und %d\n", (1<<n)-1);
In dem Programm werden n-stellige Dualzahlen betrachtet (A). Die größte n-stellige
Dualzahl ist:
n 111 ... 1
( 1 << n ) – 1 = 2 – 1 =
⎧
⎨
⎪⎩
n-mal
In der Schleife (B) wird das Bit zunächst gesetzt. Wenn die Zahl dann zu groß ist, wird
das Bit wieder gelöscht (D).
Innerhalb der Schleife durchläuft die Variable bit Potenzen von 2 (C):
153
6 Elementare Datentypen und ihre Darstellung
Als Geheimzahl wählen wir natürlich 4711 und lassen den Computer raten:
Anzahl Stellen: 16
Denk dir eine Zahl zwischen 0 und 65535
Ist die Zahl kleiner als 32768: 1
Ist die Zahl kleiner als 16384: 1
Ist die Zahl kleiner als 8192: 1
Ist die Zahl kleiner als 4096: 0
Ist die Zahl kleiner als 6144: 1
Ist die Zahl kleiner als 5120: 1
Ist die Zahl kleiner als 4608: 0
Ist die Zahl kleiner als 4864: 1
Ist die Zahl kleiner als 4736: 1
Ist die Zahl kleiner als 4672: 0
Ist die Zahl kleiner als 4704: 0
Ist die Zahl kleiner als 4720: 1
Ist die Zahl kleiner als 4712: 1
Ist die Zahl kleiner als 4708: 0
Ist die Zahl kleiner als 4710: 0
Ist die Zahl kleiner als 4711: 0
Die Zahl ist 4711
6.5.3 Addierwerk
Im nächsten Beispiel werden Sie ein Programm schreiben, das zwei Zahlen addieren
soll. Nichts leichter als das, werden Sie sagen, aber wir wollen es so machen, wie der
Rechner es intern macht. Das heißt, wir stellen uns vor, dass es noch gar keine Addi-
tion gibt und dass unser Programm nur elementare Operationen auf Bitmustern
durchführen kann. Sie werden also ein Programm schreiben, das zwei Zahlen addiert,
ohne dass irgendwo im Programm ein +-Zeichen auftaucht. Versuchen Sie es
zunächst einmal allein, bevor Sie sich meine Lösung ansehen.
Eine Addition läuft im Dualsystem genauso ab, wie Sie es in der Schule im 10er-Sys-
tem gelernt haben. Man schreibt beide Zahlen untereinander und addiert ziffern-
weise von rechts nach links, wobei gegebenenfalls ein Übertrag entsteht. Den
Übertrag verarbeitet man immer im nächsten Rechenschritt. Im Dualsystem müssen
Sie nur darauf achten, dass ein Übertrag schon bei 1 (1+1 = 10)6 und nicht erst bei 9 ein-
tritt. Außerdem müssen Sie im Hinterkopf behalten, dass Sie einen endlichen
Rechenbereich haben und irgendwann ein Überlauf erfolgt. Die Berechnung der
Summe wird nach folgendem Schema durchgeführt:
6 Vielleicht kennen Sie den Spruch: »There are 10 types of people in the world: Those who under-
stand binary and those who don’t.« Ich hoffe, Sie gehören inzwischen zur ersten Art.
154
6.5 Programmierbeispiele
0 1 1 0 1 0 1 1
1 0 1 0 1 0 1 1
Überlauf 1
+ 1
+ 1
+ 0
+ 1
+ 0
+ 1
+ 1
+ 0 Übertrag
0 0 0 1 0 1 1 0
Bezeichnen Sie die eingehenden Bits mit s1 und s2 und den Übertrag mit c. Wenn Sie
jetzt beachten, dass der Entweder-Oder-Operator einer Addition von Bits ohne Über-
trag entspricht, ergibt sich die Summe (ohne Übertrag) der drei Werte durch diesen
C-Ausdruck:
summe = s1 ^ s2 ^c;
Einen Übertrag erhalten Sie, wenn mindestens zwei der drei Werte 1 sind:
void main()
{
A unsigned int z1, z2;
B unsigned int s, s1, s2, sum, c;
155
6 Elementare Datentypen und ihre Darstellung
z1 und z2 sind die zu addierenden Zahlen (A), s ist die Maske, die über die Zahlen
geschoben wird, s1 und s2 sind die aus den Zahlen z1 und z2 maskierten Bits, sum ist
die zu berechnende Summe, und c ist der Übertrag (Carry) (B).
Im Programm werden Maske und Carry über die Zahlen geschoben (C) und in der
Schleife die Bits aus den Zahlen gefiltert (D), danach werden Summe und Übertrag für
die betrachtete Stelle berechnet (E).
Das Programm kann addieren, obwohl nirgendwo im Programm, außer in der Zähl-
schleife, ein +-Zeichen steht.
6.6 Zeichen
Ein Computer soll nicht nur Zahlen, sondern auch Buchstaben und Text verarbeiten
können. Da der Computer intern aber nur Dualzahlen – besser gesagt: Bitmuster –
kennt, muss es eine Zuordnung von Buchstaben zu Bitmustern geben. Eine solche
Zuordnung nennt man einen Code.
Ein klassischer Code für die gebräuchlichsten Zeichen ist der ASCII-Code. Die meisten
Rechner benutzen diesen Code, haben jedoch oft individuelle Erweiterungen (natio-
nale Zeichensätze, grafische Symbole etc.).
Quelle:
Q ll Wiki
Wikipedia
di
Das Zeichen Z hat den ASCII-Code 5a16 = 9010.
Der numerische Wert ist dabei relativ unwichtig.
Wichtig ist das Bitmuster:
5a = 0101 1010
156
6.6 Zeichen
A a = 'x';
b = '\n';
Beachten Sie, dass beim Lesen mit %c nur das eine Zeichen eingelesen wird und dass
zusätzlich eigegebene Zeichen, wie das unvermeidliche Linefeed zum Abschluss der
Eingabe, im Eingabepuffer verbleiben und dann gegebenenfalls bei der nächsten
Leseoperation gelesen werden.
Dass Zeichen (char) zugleich als Zahlen interpretiert werden können, hat den positi-
ven Seiteneffekt, dass mit Zeichen wie mit Zahlen gerechnet werden kann. Für das
folgende Programm
char x;
157
6 Elementare Datentypen und ihre Darstellung
65: A
66: B
67: C
68: D
69: E
70: F
71: G
72: H
73: I
74: J
75: K
Zum Abschluss dieses Abschnitts wollen wir uns vergewissern, dass unser Rechner
den ASCII-Zeichencode verwendet. Dazu erstellen wir folgendes Programm:
void main()
{
A unsigned char zeile, spalte, z;
printf( " ");
B for( spalte = 0; spalte < 0x10; spalte ++)
C printf( " .%x", spalte);
printf( "\n");
D for( zeile = 0; zeile < 0x08; zeile++)
{
E printf( " %x.", zeile);
F for( spalte = 0; spalte < 0x10; spalte ++)
{
G z = (zeile << 4)|spalte;
H if( (z > 0x20) && ( z < 0x7f))
I printf( " %c", z);
else
printf( " .");
}
printf( "\n");
}
}
158
6.7 Arrays
Der hier verwendete Datentyp für Zeichen ist unsigned char (A). Das Programm
erzeugt in einer Schleife die Überschrift (B) mit insgesamt 0x10 = 16 Spalten. Dabei
werden die Spaltenüberschriften mit der Formatanweisung %x hexadezimal ausgege-
ben. Im Anschluss an die Überschrift folgt eine Doppelschleife zur Erzeugung der
Tabelle (D) und (F), auch hier wieder mit einer hexadezimalen Ausgabe (E). Der Index
des aktuellen Zeichens wird arithmetisch ermittelt, z = zeile · 24 + spalte (G).
Damit nur die druckbaren Zeichen in der Tabelle ausgegeben werden, erfolgt eine
Unterscheidung in druckbare bzw. nicht druckbare Zeichen (H). Die druckbaren Zei- 6
chen liegen dabei in dem Bereich 2016 < z < 7f16 und werden mit %c ausgegeben (I).
Mit unserem Programm erhalten wir eine eigene Tabelle des ASCII-Zeichensatzes:
.0 .1 .2 .3 .4 .5 .6 .7 .8 .9 .a .b .c .d .e .f
0. . . . . . . . . . . . . . . . .
1. . . . . . . . . . . . . . . . .
2. . ! " # $ % & ' ( ) * + , – . /
3. 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4. @ A B C D E F G H I J K L M N O
5. P Q R S T U V W X Y Z [ \ ] ^ _
6. ` a b c d e f g h i j k l m n o
7. p q r s t u v w x y z { | } ~ .
Den Zeichencode für die auszugebenden Zeichen setzen wir im Programm als Bit-
muster aus dem Zeilen- und dem Spaltenindex zusammen.
Der Zeilenindex wird um 4 Bit nach links geschoben. In die 4 unteren Bytes wird dann
der Spaltenindex montiert, damit ergibt sich in z der Zeichencode in der betrachte-
ten Zeile und Spalte.
6.7 Arrays
Stellen Sie sich vor, dass Sie ein Programm erstellen sollen, das 100 Zahlen einliest
und die Zahlen in umgekehrter Reihenfolge wieder ausgibt. Mit Ihren derzeitigen
Programmierkenntnissen wären Sie tatsächlich gezwungen, 100 Variablen anzule-
gen, einzeln einzulesen und anschließend einzeln wieder auszugeben. Sie könnten
für die erforderlichen Ein- und Ausgaben nicht einmal eine Schleife verwenden, da
Sie keinen Datentyp kennen, der 100 Zahlen aufnehmen kann und dessen Inhalt fle-
xibel über eine Schleife bearbeitet werden kann. Zum Glück handelt es sich bei dem
angesprochenen Problem nicht um einen Mangel der Programmiersprache C, son-
159
6 Elementare Datentypen und ihre Darstellung
Wenn wir ein Array benötigen, müssen wir uns drei Fragen stellen:
Wenn wir ein Array für fünf ganze Zahlen (int) benötigen, das meinezahlen heißen
soll, schreiben wir im Programm:
Natürlich können Sie einem Array auch jeden anderen Datentyp (char, unsigned
short, float, ...) zugrunde legen.
Auf die einzelnen Elemente des Arrays greifen wir mit einem sogenannten Index zu.
Verwendet werden die indizierten Elemente eines Arrays wie eine Variable des ent-
sprechenden Datentyps. Der Index selbst ist eine ganze Zahl und kann über eine Kon-
160
6.7 Arrays
stante, eine Variable oder einen Formelausdruck gegeben sein. Das erste Element des
Arrays hat den Index 0, das zweite den Index 1 etc.:
Wenn das Array n Elemente hat, sind die Elemente von 0 bis n-1 nummeriert.
Achten Sie darauf, dass Sie nur gültige Indizes aus diesem Bereich verwenden. Der
Compiler überprüft das nicht. In der Regel stürzt Ihr Programm ab, wenn Sie einen
ungültigen Index verwenden.
Arrays können direkt bei ihrer Definition mit Werten gefüllt werden. Man gibt dazu
6
die gewünschten Werte in geschweiften Klammern und durch Kommata getrennt
an:
Dass dabei u. U. nicht alle Felder besetzt werden, ist unproblematisch. Der Compiler
füllt das Array von vorn beginnend. Nicht angesprochene Felder bleiben uninitiali-
siert.
void main()
{
A int daten[100];
int i;
Zuerst wird ein Array für 100 Zahlen erstellt (A). Dieses Array wird zunächst in auf-
steigender Richtung durchlaufen, um Zahlen einzugeben (B). Nach erfolgter Eingabe
wird das Array in absteigender Richtung durchlaufen, um die Zahlen auszugeben (C).
161
6 Elementare Datentypen und ihre Darstellung
Eine Entfernungstabelle ist eine zweidimensionale Reihung von Daten gleichen Typs
– also ein zweidimensionales Array:
void main()
{
int start, ziel, distanz;
A int entfernung[5][5] = {
{ 0, 2, 5, 9,14},
{ 2, 0, 7,15,27},
{ 5, 7, 0, 9,23},
{ 9,15, 9, 0,12},
{14,27,23,12, 0}
};
C distanz = entfernung[start][ziel];
162
6.7 Arrays
In dem Programm wird in (A) ein zweidimensionales Array für 5 × 5 int-Werte ange-
legt und initialisiert. Anschließend werden Zeilen- und Spaltenindex eingelesen (B).
Mit den vorgegebenen Orten wird dann die Entfernung aus der Tabelle gelesen (C)
und ausgegeben (D).
Beim Anlegen und beim Zugriff werden jetzt zwei Indizes (Zeilenindex und Spalten- 6
index) verwendet. Natürlich können Zeilen- und Spaltenzahl in einem Array unter-
schiedlich sein:
double tabelle[100][200];
Wichtig ist auch hier wieder, dass die Indizierung der Elemente beim Index 0
beginnt. Im oben dargestellten Beispiel sind also die Zeilen von 0 bis 99 und die Spal-
ten von 0 bis 199 nummeriert.
float temperatur[100][24][60][60];
24 Stunden am Tag
163
6 Elementare Datentypen und ihre Darstellung
t = temperatur[4][14][0][10]
Beachten Sie auch hier wieder, dass die Zählung der Tage, Stunden, Minuten und
Sekunden im Array mit 0 beginnt. Am Beispiel der Uhrzeit sehen Sie auch, dass das
eine ganz natürliche Zählweise ist, da der Tag um 0 Uhr 0 beginnt.
6.8 Zeichenketten
Ein Wort der deutschen Sprache hat es bis in die englischen Zeitungen gebracht:
In einem Computer wollen wir auch Worte, Sätze und Texte variabler Länge verarbei-
ten können. Wir sprechen in diesem Zusammenhang allgemein von Zeichenketten
oder Strings. Bisher kennen Sie nur Stringkonstanten wie "Hallo Welt\n" und ein-
zelne Zeichen wie 'A' oder '\n'. Beachten Sie hier den Unterschied:
Da Zeichenketten Reihungen von Zeichen (char) sind, ist es naheliegend, zur Speiche-
rung von Zeichenketten ein Array zu verwenden. Da Sie Zeichenketten im Rechner
verändern möchten, ist es sinnvoll, eine Zeichenkette in einem ausreichend großen
Array abzulegen, das noch Platz, z. B. für das Einfügen von Zeichen, lässt. Im Array ste-
hen dann die Zeichencodes der einzelnen Zeichen:
164
6.8 Zeichenketten
D i e s i s t e i n T e x t ∅
Zeichenkette
Terminator
6
Abbildung 6.33 Aufbau einer Zeichenkette
Da die Zeichenkette unter Umständen nicht das ganze Array ausfüllt, benötigen Sie
einen Code, der das Ende der Zeichenkette markiert (Terminator). Dieser Code darf
natürlich nicht innerhalb der Zeichenkette als »normales« Zeichen vorkommen.
Deshalb wählen Sie die 0 als Terminator. Bitte beachten Sie, dass es sich hier nicht um
das Zeichen '0' (ASCII-Code 3016), sondern um eine »richtige« 0 (0016) handelt. Dieser
Code (ASCII-Zeichen NUL) steht ja nicht für einen sinnvollen Buchstaben.
Bevor Sie mit einer Zeichenkette arbeiten können, müssen Sie ein Array ausreichen-
der Größe bereitstellen. Zum Beispiel:
char wort[100];
In ein solches Array können Sie eine Zeichenkette mit scanf einlesen. Die Eingabe
von Zeichenketten erfolgt mit der Formatanweisung "%s":
Achtung
왘 Beim Einlesen von Zeichenketten in ein Array wird dem Variablennamen kein &
vorangestellt7. Wenn Sie hier ein & setzen, wird Ihr Programm abstürzen.
왘 Bei der Eingabe wird nicht geprüft, ob das Array groß genug ist, um den String
aufzunehmen. Werden mehr Zeichen eingegeben, als das Array aufnehmen kann,
stürzt das Programm ab.
Mit scanf können Sie nur einzelne Wörter jeweils bis zum nächsten Trennzeichen
(Leerzeichen, Tabulator oder Zeilenumbruch) einlesen. Möchten Sie eine komplette
Eingabezeile, gegebenenfalls mit Leerzeichen und einschließlich des abschließenden
Zeilenvorschubs, in ein Array einlesen, verwenden Sie die folgende Anweisung:
7 Diese scheinbare Abweichung von der Norm werde ich Ihnen später erklären. Nehmen Sie das für
den Moment bitte zunächst ohne weitere Erklärung hin.
165
6 Elementare Datentypen und ihre Darstellung
char zeile[100];
Dabei übergeben Sie die Länge des Eingabe-Arrays (im Beispiel 100), um zu verhin-
dern, dass die Grenzen des Arrays überschritten werden.
Da die Zeichenkette nach dem Einlesen in einem Array zur Verfügung steht, können
Sie über den Index auf jedes Zeichen zugreifen und es bei Bedarf verändern.
Achtung: Bei der Veränderung von Zeichenketten müssen Sie Folgendes unbedingt
beachten:
왘 Die Nummerierung der Zeichen beginnt beim Index 0. Wenn die Zeichenkette n
Zeichen hat, sind diese von 0 bis n-1 nummeriert. Der Terminator hat den Index n.
왘 Die Zeichenkette befindet sich in einem Array fester Länge. Sie müssen darauf ach-
ten, dass bei Veränderungen (z. B. durch Anfügen von Buchstaben) die Grenzen
des zugrunde liegenden Arrays nicht überschritten werden.
왘 Wegen des Terminators muss das Array, das den String aufnimmt, mindestens ein
Element mehr haben, als der String Zeichen enthält.
왘 Die Zeichenkette muss nach eventuellen Manipulationen immer konsistent sein.
Insbesondere bedeutet das, dass das Terminator-Zeichen korrekt positioniert wer-
den muss.
Auf die Rahmenbedingungen muss der Programmierer achten. Verletzt er eine die-
ser Bedingungen, stürzt das Programm in der Regel ab.
Die Ausgabe eines Strings, auch mit Leerzeichen oder Zeilenumbrüchen, kennen Sie
schon von Stringkonstanten. Man verwendet printf mit der Formatanweisung "%s":
Jetzt können Sie ein erstes zusammenhängendes Beispiel programmieren. Sie wer-
den ein Wort einlesen, um dann seine Länge festzustellen:
void main()
{
A char wort[100];
int i;
166
6.8 Zeichenketten
Um eine Zeichenkette zu verwenden, wird das Array für die Zeichenkette bereitge-
stellt (A). In das Array kann dann das Wort eingelesen werden (B).
In einer Schleife werden die Zeichen im Array gezählt, solange nicht der Terminator
auftaucht (C). Die Zählung findet komplett im Schleifenkopf statt, beachten Sie, dass
der Schleifenkörper leer ist (D).
Wort: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz hat 63 Zeichen
Im nächsten Beispiel werden Sie ein Wort einlesen und prüfen, ob es sich bei diesem
Wort um ein Palindrom handelt. Unter einem Palindrom verstehen wir ein Wort
oder eine Wortfolge, die vorwärts und rückwärts gelesen gleich ist, wobei es auf Leer-
zeichen, Satzzeichen oder Groß- bzw. Kleinschreibung nicht ankommt. Palindrome
sind z. B. »otto«, »lagerregal« oder »rentner«. Schreiben Sie bei der Eingabe alles klein
und zusammen, damit Sie keinen Zusatzaufwand bei der Überprüfung haben:
void main()
{
char wort[100];
int vorn, hinten;
167
6 Elementare Datentypen und ihre Darstellung
Zuerst müssen Sie das Wort, das Sie prüfen wollen, einlesen (A). In diesem Wort wird
dann das Wortende gesucht (B). Nach Ablauf der Schleife ist in hinten der Index des
Terminators. Dieser wird im folgenden Schleifenkopf (C) daher um 1 zurückgesetzt. In
dieser Schleife wird das Wort von vorn vorwärts und von hinten rückwärts durchlau-
fen, solange vorn noch vor hinten liegt. Innerhalb der Schleife erfolgt die Prüfung.
Wenn vorn ein anderes Zeichen steht als hinten, wird die Schleife abgebrochen (D).
Außerhalb der Schleife erfolgt die Prüfung des Ergebnisses, wenn die Schleife vorzei-
tig abgebrochen wurde (E), handelt es sich um kein Palindrom (F).
Wort: einnegermitgazellezagtimregennie
Palindrom
Bis jetzt haben Sie noch keine Zeichenketten verändert oder sogar neue Zeichenket-
ten erstellt. Das machen Sie im nächsten Beispiel. Sie kennen sicherlich das Spiel
»Galgenmännchen«, bei dem man versucht, durch Raten von Buchstaben in mög-
lichst wenig Versuchen ein unbekanntes Wort zu ermitteln. Im Folgenden sehen Sie
meine Lösung, die insofern etwas merkwürdig ist, als der Rater selbst das Geheim-
wort eingibt, es angezeigt bekommt und dann aufgefordert wird, das Wort zu raten.
Aber im Vordergrund steht ja nicht das Spiel, sondern das Programm:
void main()
{
A char wort[100], anzeige[100];
char versuch;
int nochzuraten, i, anzahl;
168
6.8 Zeichenketten
Das Programm startet mit der Erstellung von Puffern für das Ratewort und den
Anzeigestring (A), das zu ratende Wort wird in (B) eingelesen.
In (C) wird der Anzeigestring aufbereitet, indem für alle Zeichen ein '-' gesetzt wird.
Gleichzeitig wird gezählt, wie viele Zeichen zu raten sind.
Nach Ablauf dieser Schleife wird der Anzeigestring terminiert (D). Jetzt beginnt das
eigentliche Spiel mit einer Schleife über alle Rateversuche (E).
In jedem Schleifendurchlauf erfolgt die Eingabe eines neuen Zeichens (F). Vor dem
Lesen wird mit \n der noch in der Eingabe stehende Zeilenvorschub aus der letzten
Eingabe konsumiert. Nach der Eingabe des zu ratenden Zeichens läuft eine Schleife
über das zu ratende Wort (G).
169
6 Elementare Datentypen und ihre Darstellung
Wenn der geratene Buchstabe mit dem Zeichen im Wort übereinstimmt und in der
Anzeige noch ein '-' steht, wird das Zeichen in die Anzeige übernommen, und es ist
nur noch ein Buchstabe weniger zu erraten (H) bis (I). Im folgenden Bildschirmdialog
habe ich versucht, das Wort »mississippi« zu erraten:
Wort: mississippi
-----------
1-ter Versuch: i
-i--i--i--i
2-ter Versuch: a
-i--i--i--i
3-ter Versuch: s
-ississi--i
4-ter Versuch: p
-ississippi
5-ter Versuch: m
mississippi
Du hast 5 Versuche benoetigt
An dieser Stelle sind einige ergänzende Informationen zur Eingabe mit scanf ange-
bracht. Alle Eingaben des Benutzers landen in einem Zwischenpuffer, aus dem scanf
nach und nach die geforderten Eingaben abruft. Mit %c wird nur ein einzelnes Zei-
chen abgerufen. Alles, was der Benutzer zusätzlich eingegeben hat (z. B. Leerzeichen
oder Zeilenumbrüche), bleibt in der Eingabe stehen und muss gegebenenfalls durch
gezielte Leseoperationen beseitigt werden. Im oben dargestellten Beispiel wird durch
die Anweisung \n%c vor dem Lesen des Eingabezeichens (%c) der von der letzten Ein-
gabe noch anstehende Zeilenumbruch (\n) beseitigt.
Häufig will man Zeichenketten kopieren oder miteinander vergleichen. Da ist es ver-
lockend, es genauso wie bei Zahlen zu machen:
int a = 1, b;
b = a;
if( a == b)
...
Zeichenketten können nicht mit = kopiert und nicht mit == oder != miteinan-
der verglichen werden. Bei einer Kopie müssen die Zeichenketten Zeichen für
Zeichen kopiert und bei einem Vergleich Zeichen für Zeichen verglichen
werden.
170
6.8 Zeichenketten
Nach Ihrem derzeitigen Kenntnisstand bedeutet das, dass Sie das Kopieren und das Ver-
gleichen von Strings selbst implementieren müssen. Fangen Sie mit dem Kopieren an:
In dem Programm wird Zeichen für Zeichen von original nach kopie kopiert (A),
abschließend wird die Kopie in (B) terminiert.
Eingabe: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Original: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Kopie: Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
171
6 Elementare Datentypen und ihre Darstellung
Am zuletzt geprüften Zeichen können Sie erkennen, ob die beiden Worte gleich
waren (B).
Wort1:
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz
Wort2:
Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesezz
Die Worte sind verschieden
Die Steuerung dieser Schleife ist durchaus trickreich, sodass wir uns die Testbedin-
gung noch einmal genauer anschauen wollen:
ja ja ja abbrechen gleich
Sie sehen, dass in jedem vorkommenden Fall die Schleife korrekt fortgesetzt oder
abgebrochen wird.
Sie werden an dieser Stelle mit Recht einwenden, dass es nicht sein darf, dass man für
Grundaufgaben wie Kopieren oder Vergleichen immer und immer wieder den glei-
chen Code schreiben muss. Es muss in einer Programmiersprache Möglichkeiten
geben, solche Aufgaben einmal und dann auch endgültig zu lösen. Mit den dazu
erforderlichen Sprachmitteln werden wir uns im nächsten Kapitel beschäftigen.
Zunächst aber schließen wir dieses Kapitel mit einigen Programmierbeispielen ab.
172
6.9 Programmierbeispiele
6.9 Programmierbeispiele
Beispielprogramme mit Verwendung von Zeichenketten und Arrays schließen die-
ses Kapitel ab.
6.9.1 Buchstabenstatistik
Sie werden ein Programm erstellen, das eine komplette Textzeile einliest und dann
eine Statistik über die vorkommenden Buchstaben (a–z) erzeugt: 6
void main()
{
char text[100];
A int statistik[26];
int i;
printf( "\nAuswertung:\n");
F for( i = 0; i < 26; i++)
printf( "%c: %d\n", 'a' + i, statistik[i]);
}
Auf das Zählen der Buchstaben möchte ich noch etwas genauer eingehen. Im Array
statistik haben wir 26 Zähler für das Vorkommen der Buchstaben (A). Das Vorkom-
men von a wollen wir in statistik[0], das Vorkommen von b in statistik[1] etc.
zählen. Wie kommen Sie nun von einem Zeichen zum Index seines Zählers? Ganz
einfach, Sie ziehen den Code von a als Zahlenwert vom Code des Zeichens ab. So
ergibt sich die Formel
statistik[zeichen[i]-'a']
173
6 Elementare Datentypen und ihre Darstellung
Und wie gelangen Sie umgekehrt von einem Index zu dem Zeichen, das zu diesem
Index gehört? Ganz einfach: Sie addieren den Zeichencode von a zum Index hinzu. So
erhalten Sie die Formel
'a' + i
Mit dieser Erläuterung ist der Rest des Programmes schnell erklärt. In (B) werden alle
26 Buchstabenzähler auf 0 gesetzt. Dann wird in (C) der Text eingelesen und mit (D)
über die gesamte Eingabe iteriert. Dabei werden im Text nur Zeichen betrachtet, die
zwischen a und z liegen (E). Abschließend erfolgt die Ausgabe der Statistik (F).
Sie werden nun das Programm testen und dazu Eingaben verwenden, die möglichst
viele verschiedene Buchstaben enthalten. In der Wikipedia finden Sie eine Liste von
Pangrammen8, die als Testfälle geeignet sind:
Eines der bekanntesten Pangramme der englischen Sprache ist übrigens »The quick
brown fox jumps over the lazy dog«. Dieses Pangramm wurde in der Steinzeit der
Datenverarbeitung benutzt, um Fernschreibverbindungen mit einem vollständigen
Satz an Zeichen zu testen. Noch viele Modems können heute diesen Satz auf Knopf-
druck automatisch erzeugen. Sie testen Ihr Programm allerdings mit einem anderen
Pangramm (ohne Leerzeichen):
Text: franzjagtimkomplettverwahrlostentaxiquerdurchbayern
Auswertung:
a: 5
b: 1
c: 1
d: 1
e: 5
f: 1
174
6.9 Programmierbeispiele
g: 1
h: 2
i: 2
j: 1
k: 1
l: 2
m: 2
n: 3
o: 2 6
p: 1
q: 1
r: 6
s: 1
t: 5
u: 2
v: 1
w: 1
x: 1
y: 1
z: 1
6.9.2 Sudoku
Sicher haben Sie sich schon einmal an einem Sudoku-Rätsel versucht. Man muss ein
9 × 9-Zahlenschema, in dem gewisse Zahlen vorgegeben sind, so ausfüllen, dass in
jeder Zeile und in jeder Spalte und in jedem der neun Teilquadrate immer alle Zahlen
von 1 bis 9 stehen. Sie werden ein kleines Programm schreiben, das Sie bei der Lösung
unterstützt. Das Programm enthält aber nur die Ein- und Ausgabe. Weitergehende
Funktionen sind zunächst einmal nicht vorgesehen.
Das 9 × 9-Zahlenfeld bilden Sie natürlich auf einem zweidimensionalen Array ab:
int sudoku[9][9];
Der Einfachheit halber nummerieren Sie die Zeilen und Spalten des Sudokus von 0
bis 9. Dann kann es auch schon losgehen:
void main()
{
A int sudoku[9][9] = {
{5,0,9,7,0,2,0,4,0},
{7,6,0,3,4,8,9,1,5},
{1,3,4,5,0,9,0,7,0},
175
6 Elementare Datentypen und ihre Darstellung
{6,7,1,0,0,0,0,0,0},
{8,0,5,6,2,1,7,3,4},
{0,0,3,0,5,0,1,6,9},
{0,5,8,1,0,0,3,2,7},
{3,0,0,2,0,0,0,9,6},
{9,0,0,0,7,3,8,5,0},
};
int zeile, spalte, zahl;
In dem Programm wird in (A) eine Sudoku-Aufgabe festgelegt. Der Wert 0 steht für
ein nicht ausgefülltes Feld.
Innerhalb einer Schleife wird so lange fortgefahren, wie keine 0 eingegeben wurde (B).
Das Sudoku wird zeilen- und spaltenweise ausgegeben (C) und (E), jedes dritte Mal
kommt dabei eine Trennlinie (D) bzw. ein Trennstrich (F). Zahlen größer als 0 werden
ausgegeben (G), ansonsten werden Leerzeichen dargestellt. Die Ausgaben werden
176
6.9 Programmierbeispiele
mit einem Zeilenabschluss und einer Abschlusszeile beendet (H) und (I). Am Ende
eines jeden Durchlaufs erfolgen dann die Eingabe (J) und der Eintrag in das Sudoku-
Feld (K).
+-------+-------+-------+
| 5 9 | 7 2 | 4 |
| 7 6 | 3 4 8 | 9 1 5 | 6
| 1 3 4 | 5 9 | 7 |
+-------+-------+-------+
| 6 7 1 | | |
| 8 5 | 6 2 1 | 7 3 4 |
| 3 | 5 | 1 6 9 |
+-------+-------+-------+
| 5 8 | 1 | 3 2 7 |
| 3 | 2 | 9 6 |
| 9 | 7 3 | 8 5 |
+-------+-------+-------+
Zeile Spalte Zahl: 0 1 8
+-------+-------+-------+
| 5 8 9 | 7 2 | 4 |
| 7 6 | 3 4 8 | 9 1 5 |
| 1 3 4 | 5 9 | 7 |
+-------+-------+-------+
| 6 7 1 | | |
| 8 5 | 6 2 1 | 7 3 4 |
| 3 | 5 | 1 6 9 |
+-------+-------+-------+
| 5 8 | 1 | 3 2 7 |
| 3 | 2 | 9 6 |
| 9 | 7 3 | 8 5 |
+-------+-------+-------+
Zeile Spalte Zahl:
Wenn Sie es sich zutrauen, können Sie zu diesem Programm noch Eingabeprüfun-
gen, Lösungshinweise und das Erzeugen von Aufgaben hinzufügen. Das sind jedoch
anspruchsvolle Aufgaben, die Ihre derzeitigen Programmierkenntnisse noch über-
steigen.
177
6 Elementare Datentypen und ihre Darstellung
6.10 Aufgaben
A 6.1 Erstellen Sie ein Programm, das einen String und einen Buchstaben überge-
ben bekommt und dann ausgibt, wie oft der Buchstabe in dem String vor-
kommt.
A 6.2 Schreiben Sie ein Programm, das die Reihenfolge der Zeichen in einem String
umkehrt.
A 6.3 Schreiben Sie ein Programm, das alle 'e' aus einem String entfernt.
A 6.4 Erweitern Sie das Programm zur Palindromerkennung so, dass nicht zwischen
Groß- und Kleinbuchstaben unterschieden wird. Es sollen also Worte wie
»Retsinakanister« korrekt als Palindrom erkannt werden.
A 6.5 Schreiben Sie ein Programm, das eine nur aus Ziffern bestehende Zeichen-
kette einliest und aus dem Eingabestring eine int-Zahl berechnet.
A 6.6 Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend auf
Wunsch bestimmte Zahlen wieder ausgibt. Das Programm soll wie folgt ar-
beiten:
A 6.7 Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend der
Größe nach sortiert wieder ausgibt.
178
6.10 Aufgaben
A 6.8 Unter einem magischen Quadrat der Kantenlänge 5 verstehen wir eine Anord-
nung der Zahlen 1 bis 25 in einem quadratischen Schema auf eine Weise, dass
die Summen in allen Zeilen, Spalten und den beiden Hauptdiagonalen gleich
sind. Das folgende Beispiel zeigt ein solches Quadrat:
19 3 12 21 10
11 25 9 18 2
8 17 1 15 24 6
5 14 23 7 16
22 6 20 4 13
Erstellen Sie ein Programm, das überprüft, ob es sich bei einem 5 × 5-Quadrat
um ein magisches Quadrat handelt.
A 6.9 Magische Quadrate ungerader Kantenlänge lassen sich nach folgendem Ver-
fahren konstruieren:
1. Positioniere die 1 in dem Feld unmittelbar unter der Mitte des Quadrats!
2. Wenn die Zahl x in der Zeile i und der Spalte k positioniert wurde, dann ver-
suche, die Zahl x+1 in der Zeile i+1 und der Spalte k+1 abzulegen! Handelt es
sich bei diesen Angaben um ungültige Zeilen- oder Spaltennummern,
wende Regel 4 an! Ist das Zielfeld bereits besetzt, wende Regel 3 an!
3. Wird versucht, eine Zahl in einem bereits besetzten Feld in der Zeile i und
der Spalte k zu positionieren, versuche stattdessen die Zeile i+1 und die
Spalte k-1. Handelt es sich bei diesen Angaben um ungültige Zeilen- oder
Spaltennummern, wende Regel 4 an. Ist das Zielfeld bereits besetzt, wende
Regel 3 erneut an!
4. Die Zeilen- und Spaltennummern laufen von 0 bis n-1. Ergibt sich im Laufe
des Verfahrens eine zu kleine Zeilen- oder Spaltennummer, setze die Num-
mer auf den Maximalwert n-1! Ergibt sich eine zu große Spalten- oder Zei-
lennummer, setze die Nummer auf den Minimalwert 0!
Erstellen Sie nach diesen Angaben ein Programm, das für ungerade Kanten-
längen von 3 bis 9 ein magisches Quadrat erzeugen kann.
A 6.10 Informieren Sie sich im Internet, was unter einem Vigenère-Schlüssel zu ver-
stehen ist. Erstellen Sie dann ein Programm, das einen eingegebenen String
mit einem Passwort verschlüsselt und wieder entschlüsselt.
179
Kapitel 7
Modularisierung
Divide et impera!
– Niccolò Machiavelli
Sie sind an einem Punkt angelangt, an dem Sie im Prinzip jede Programmieraufgabe
lösen können. Dabei haben Sie allerdings die Erfahrung gemacht, dass Ihre Pro-
gramme mit wachsender Komplexität der Aufgabenstellung unübersichtlich und
unverständlich zu werden drohen. Es stellt sich daher die Frage:
Die Methodik des »Teilens und Herrschens« bezeichnet man in der Programmierung
als Modularisierung. Modularisierung wird in C durch sogenannte Funktionen unter-
stützt. Funktionen und Funktionsaufrufe haben wir übrigens, ohne besonders darauf
hinzuweisen, am Beispiel von printf und scanf bereits verwendet.
7.1 Funktionen
Wir nehmen noch einmal ein Kochbuch in die Hand und finden ein Rezept für Apfel-
kuchen, das ich stark gekürzt habe:
181
7 Modularisierung
Zubereitung:
Bereiten Sie den Hefeteig nach Rezept zu, und rollen Sie diesen dann auf einer
bemehlten Arbeitsfläche quadratisch aus. Geben Sie den Teig dann auf ein mit
Backpapier ausgelegtes Blech, und ziehen Sie den Rand an jeder Seite hoch. Der Teig
kann dann – mit einem Küchentuch abgedeckt – noch ein wenig stehenbleiben. In
der Zwischenzeit schneiden und entkernen Sie die Äpfel und schneiden sie in
schmale Spalten. ...
Wir finden wieder die übliche Aufteilung in Zutaten und Zubereitung. Bei der Zube-
reitung fällt eine Teilaufgabe (Hefeteig zubereiten) an, die in diesem Rezept nicht
weiter erklärt ist. Dazu wird auf ein anderes Rezept verwiesen. Dieses andere Rezept
beschreibt kein vollständiges Gericht, da man einen Hefeteig ohne weitere Zuberei-
tung nicht essen sollte, aber es beschreibt eine klar abgegrenzte Teilaufgabe, die nicht
nur bei der Herstellung von Apfelkuchen anfällt. Daher ist es sinnvoll, die Zuberei-
tung von Hefeteig in dem Kochbuch nur einmal zu beschreiben und dann aus ande-
ren Rezepten darauf zu verweisen. Mit dem Hinweis »Hefeteig zubereiten« ist es im
Allgemeinen aber nicht getan. In der Regel müssen mit dem Hinweis Zusatzinforma-
tionen, etwa über die zu erstellende Menge oder spezielle Zutaten, gegeben werden.
Wir übertragen die Begriffe aus der Backstube in die Terminologie der Datenverarbei-
tung:
왘 Die Herstellung von Apfelkuchen ist unsere eigentliche Aufgabe. Das ist das
Hauptprogramm.
왘 Die Herstellung von Hefeteig ist eine Teilaufgabe im Rahmen der Herstellung
eines Apfelkuchens. Das ist eine Funktion oder ein Unterprogramm.
왘 Das Starten der Aktivität »Hefeteig erstellen« aus der Zubereitungsvorschrift von
Apfelkuchen bezeichnen wir als einen Aufruf des Unterprogramms aus dem
Hauptprogramm. Wir sprechen von einem Unterprogrammaufruf oder einem
Funktionsaufruf.
왘 Zwischen Haupt- und Unterprogramm müssen beim Aufruf ganz bestimmte
Informationen fließen, z. B. darüber, wie viel Hefeteig hergestellt und ob dem Teig
Zucker zugesetzt werden soll. Über den Austausch dieser Informationen muss
zwischen Haupt- und Unterprogramm eine präzise Vereinbarung bestehen. Das
Hauptprogramm muss wissen, welche Informationen das Unterprogramm benö-
tigt und welche Ergebnisse es produziert. Eine solche Vereinbarung nennen wir
eine Schnittstelle.
왘 Eine im Rahmen der Schnittstelle vereinbarte Einzelinformation, wie z. B »Zucker-
zugabe in Gramm«, nennen wir einen Parameter. Alle Parameter zusammen
beschreiben die Schnittstelle. Ein Parameter, durch den Informationen vom
Hauptprogramm zum Unterprogramm fließen, bezeichnen wir als Eingabepara-
182
7.1 Funktionen
Stellen Sie sich vor, dass der Apfelkuchen von zwei Personen unabhängig voneinan-
der hergestellt wird. Der Apfelkuchenbäcker ruft dem Hefeteigbäcker nur zu: »Ich
brauche 500 Gramm gesüßten Hefeteig«. Der Apfelkuchenbäcker muss nicht wissen, 7
wie man einen Hefeteig macht, und der Hefeteigbäcker muss nicht wissen, warum
oder wozu der Hefeteig benötigt wird. Auf diese Trennung von WIE und WARUM
kommt es uns an.
Durch die Aufteilung zwischen Haupt- und Unterprogramm erhalten wir also eine
Trennung zwischen WIE und WARUM. Das Unterprogramm weiß, WIE etwas
gemacht wird, aber nicht WARUM. Umgekehrt weiß das Hauptprogramm, WARUM
etwas gemacht wird, aber nicht WIE. Im Haupt- wie im Unterprogramm kann man
sich dann ganz auf die jeweilige Aufgabe konzentrieren und ist nicht mit überflüssi-
gem Wissen über die jeweils andere Seite belastet.
Erst diese Technik ermöglicht es, größere Programme noch beherrschbar zu halten.
Große Softwaresysteme zu modularisieren, d. h. in kleinere, überschaubare funktio-
nale Einheiten aufzuteilen und mit geeigneten Schnittstellen zu versehen, ist eine
zentrale Aufgabe des Programmdesigns. Der sichere Umgang mit dieser Technik ist
eine der wichtigsten Fähigkeiten, die einen guten Softwareentwickler auszeichnen.
1. eine Schnittstelle, die alle zwischen Haupt- und Unterprogramm fließenden Infor-
mationen festlegt
2. die Implementierung, in der die Funktion konkret ausprogrammiert wird
Stellen Sie sich vor, dass Sie im Rahmen einer Programmieraufgabe an verschiede-
nen Stellen Ihres Programms das Maximum zweier Zahlen bestimmen müssen.
Diese Berechnung möchten Sie an eine Funktion delegieren. Auch bezüglich der dazu
erforderlichen Schnittstelle haben Sie schon eine konkrete Vorstellung:
In die Funktion gehen zwei Gleitkommazahlen – nennen wir sie x und y – hinein, und
aus der Funktion kommt die größere der beiden Zahlen, also wieder eine Gleitkom-
mazahl, als Ergebnis heraus. Einen Namen soll die Funktion auch haben – sie soll
maximum heißen. Damit ergibt sich die folgende Schnittstelle:
183
7 Modularisierung
Gleitkommazahlen x und y
gehen in die Funktion hinein
Implementieren Sie die Funktion, indem Sie an die Schnittstelle (A) den Funktions-
körper als Block anhängen (B–F). In diesem Block können die Parameter (hier x und y)
wie gewöhnliche Variablen des entsprechenden Typs verwendet werden:
Innerhalb der Funktion wird geprüft, ob der Wert des Parameters x größer als der
Wert des Parameters y ist (C). Ist das der Fall, soll der Wert von x zurückgegeben wer-
den (D), andernfalls der Wert von y (E).
Neu ist hier die return-Anweisung (D und E). Diese Anweisung bewirkt, dass der fol-
gende Ausdruck ausgewertet und als Funktionsergebnis (Rückgabe- oder Return-
wert) an das rufende Programm zurückgegeben wird. Das Unterprogramm ist damit
beendet, auch wenn die Anweisung nicht am Ende des Unterprogramms steht. Der
Typ des Rückgabewerts muss natürlich dem in der Schnittstelle vereinbarten Typ
entsprechen. Unsere Funktion hat zwei »Ausstiege«. Ist x>y, wird die Funktion mit
der Anweisung return x beendet. Die folgende Anweisung wird in diesem Fall nicht
mehr erreicht. Ist die Bedingung x>y nicht erfüllt, endet die Funktion mit der Anwei-
sung return y. Letztlich wird also der größere der beiden Zahlenwerte zurückge-
geben.
184
7.1 Funktionen
void main()
{
float a = 1, b = 2.3, c;
A c = maximum( a, b);
B c = maximum( 12.3, a*b + 1);
c = b + maximum( 1, 2);
c = maximum( 1, maximum( a, b) + 1);
}
Dem Funktionsaufruf maximum folgen in Klammern und durch Kommata getrennt die
Eingangsparameter, die an die Funktion übergeben werden (A).
Dabei können Konstanten, Variablen und Formelausdrücke übergeben werden (A, B).
Das Funktionsergebnis kann in Formeln oder Funktionsaufrufen benutzt werden (C,
D), und das Funktionsergebnis kann Variablen zugewiesen werden (D).
Eine Funktion kann Parameter unterschiedlicher Typen haben oder auch parameter-
los sein. Ebenso kann der Rückgabewert einer Funktion einen beliebigen Datentyp
haben oder auch fehlen.
Wenn eine Funktion einen Returntyp hat, darf es keine Möglichkeit geben, die Funk-
tion ohne eine explizite Returnanweisung mit Returnwert zu verlassen. Das rufende
Programm muss den Returnwert allerdings nicht verwenden.
Eine Funktion ohne Parameter wird mit einer leeren Parameterliste definiert.
int ausgabe()
{
printf( "Hallo Welt");
return 1;
}
185
7 Modularisierung
Eine Funktion ohne Returntyp erkennen Sie am Pseudo-Returntyp void. Eine solche
Funktion kann jederzeit durch return ohne Wertangabe verlassen werden, es muss
eine solche Anweisung allerdings nicht geben.
A void test()
{
int v;
Im angegebenen Beispiel wird eine Funktion ohne Returntyp erstellt (A). Die Funk-
tion wird in (B) ohne Rückgabe eines Returnwertes verlassen. In (C) erfolgt der Aufruf
einer anderen Funktion ohne Verwendung des zurückgegebenen Returnwertes. Die
Funktion mit dem Rückgabetyp void kann dabei auch ohne return enden (D).
Natürlich kann es in einem Programm viele Funktionen geben, und Funktionen kön-
nen ihrerseits wieder Funktionen rufen. Wichtig ist dabei immer, dass die an der
Schnittstelle getroffenen Typvereinbarungen eingehalten werden. Das heißt, dass
die in die Funktion eingehenden Parameterwerte den an der Schnittstelle festgeleg-
ten Typen entsprechen müssen und dass der Funktionswert nur dort verwendet wer-
den kann, wo auch ein Ausdruck des gleichen Typs stehen könnte. Ein Unterpro-
gramm erhält nur die Parameterwerte und hat daher keine Möglichkeit, die
Originaldaten des Hauptprogramms zu verändern.
Insgesamt ergibt sich ein C-Programm als eine Sammlung vieler Einzelfunktionen, die
alle einem gemeinsamen Zweck dienen und zusammen das Programm bilden. Das
Hauptprogramm main ist dabei nur der Einstiegspunkt, an dem der Kontrollfluss startet.
1 Was genau bei der Übergabe eines Arrays an eine Funktion passiert, erkläre ich Ihnen im
Abschnitt über Zeiger.
186
7.2 Arrays als Funktionsparameter
void main()
{ void ausgeben( int anz, int dat[])
int daten[10]; {
int i; 7
init( 10, daten);
ausgeben( 10, daten); for( i = 0; i < anz; i++)
umkehren( 10, daten); printf( "%d ", dat[i]);
ausgeben( 10, daten); printf( "\n");
} }
Eine Rückgabe von Arrays aus einem Unterprogramm an das Hauptprogramm ist
nicht möglich2, aber auch nicht nötig, da das Hauptprogramm ja ein Array bereitstel-
len kann, das dann vom Unterprogramm entsprechend bearbeitet wird.
Das hier zu Arrays Gesagte gilt natürlich auch für Strings. Bei Strings wird jedoch in
der Regel keine Information über die Größe des zugrunde liegenden Arrays übertra-
gen. Das Ende des Strings ist ja durch den Terminator eindeutig bestimmt. Als Bei-
187
7 Modularisierung
spiel erstellen wir Funktionen zur Ermittlung der Länge eines Strings und zum
Vergleich zweier Strings:
Der Text und das anzuhängende Zeichen werden an die Funktion übergeben (A). In
der Schleife (B) wird der Text komplett durchlaufen. Abschließend wird an der Posi-
tion am Ende des Textes das anzuhängende Zeichen angefügt (C) und schließlich die
Zeichenkette mit einer terminierenden 0 beendet (D).
188
7.2 Arrays als Funktionsparameter
void main()
{
A char txt[20];
char b;
txt[0] = 0;
B for( b = 'a'; b <= 'k'; b++)
{
C append( txt, b);
D printf( "%s\n", txt);
} 7
}
Im Hauptprogramm wird ein leerer String erzeugt (A), und eine Schleife mit den Zei-
chen 'a' bis 'k' wird durchlaufen (B). In jedem Schleifendurchlauf wird das aktuelle Zei-
chen angehängt (C), und der entstandene String wird ausgegeben (D), was dann zur
folgenden Ausgabe führt:
a
ab
abc
abcd
abcde
abcdef
abcdefg
abcdefgh
abcdefghi
abcdefghi
abcdefghijk
Im Hauptprogramm wird ein Puffer für 20 Zeichen angelegt. Wenn Sie die Funktion
append zu oft rufen, wird im Unterprogramm ohne Kontrollen außerhalb des Puffers
geschrieben, und es kommt zu einem Buffer Overflow. Das kann zu schwerwiegen-
den Fehlern bis hin zu Programmabstürzen führen. Darum muss das rufende Pro-
gramm dafür sorgen, dass der Puffer für die im Unterprogramm ausgeführten
Operationen groß genug ist.
Ein Buffer Overflow ist übrigens eine der Hauptangriffsstellen für Hacker. Hacker
versuchen, in einem Programm gezielt einen Buffer Overflow herbeizuführen und
dadurch schädlichen Code in das Programm zu injizieren.
189
7 Modularisierung
void main()
{
float a = 1, b = 2.3, c;
c = maximum( a, b);
} 2.3
2.3 1
Blackbox
}
Der Informationsaustausch zwischen der Blackbox und ihrer Umwelt erfolgt allein
über die Funktionsschnittstelle.
Es gibt allerdings die Möglichkeit, sich Nebeneingänge in die Blackbox eines Funkti-
onsaufrufs zu schaffen. Dazu dienen die sogenannten globalen Variablen. Globale
Variablen werden außerhalb jeglicher Funktion, auch außerhalb von main, angelegt.
Sie können dann in jeder Funktion benutzt werden.
190
7.3 Lokale und globale Variablen
A int zaehler = 0;
D zaehler++;
E return x+y;
}
7
void main()
{
F int i, x;
In dem angegebenen Code wird in (A) eine globale Variable zaehler angelegt. In (C)
und (F) werden lokale Variablen von funktion und main definiert. Die Variablen x in
funktion (B und E) und in main (F) haben nichts miteinander zu tun, sie sind unabhän-
gig. Auf die globale Variable zaehler kann aber sowohl in funktion als auch in main
zugegriffen werden (D und G). Das Programm liefert damit das folgende Ergebnis:
1. Aufruf: 124
2. Aufruf: 125
3. Aufruf: 126
4. Aufruf: 127
5. Aufruf: 128
Globale Variablen umgehen das konsequente Information Hiding und können über-
raschende Seiteneffekte auslösen. Sie sind daher eine potenzielle Fehlerquelle in
Ihren Programmen. Vor der Verwendung solcher Variablen sollten Sie daher immer
prüfen, ob der Seiteneffekt sinnvoll und notwendig ist. Auf keinen Fall sollten Sie aus
Bequemlichkeit globale Variablen anstelle einer sauberen Funktionsschnittstelle
verwenden, und es sollten keine globalen und lokalen Variablen gleichen Namens
vorkommen. Geben Sie globalen Variablen immer einen ausreichend langen, pro-
grammweit eindeutigen Namen, um solche Konflikte zu vermeiden.
191
7 Modularisierung
7.4 Rekursion
Mit einem Funktionsaufruf verbindet man gemeinhin die Vorstellung, dass eine
Funktion eine andere Funktion aufruft. Es gibt aber keinen Grund, auszuschließen,
dass eine Funktion sich mittelbar (d. h. auf dem Umweg über eine andere Funktion)
oder unmittelbar selbst aufruft. Man bezeichnet dies als Rekursion. Das bedeutet,
dass eine Funktion ihre Berechnungen unter Rückgriff auf sich selbst durchführt. Das
erscheint zunächst paradox, ist aber eine sehr sinnvolle Programmiertechnik.
Als Beispiel betrachten wir die Folge der Fakultäten, die Sie bereits aus dem Kapitel
über Arithmetik kennen. Sie erinnern sich vielleicht, dass n! (sprich »n-Fakultät«) das
Produkt der ersten n natürlichen Zahlen bezeichnet. Also:
n! =1 · 2 · 3 · ... · n
Diese Zahl lässt sich in einer Funktion recht einfach durch eine Schleife iterativ
berechnen:
void main()
{
int n;
for( n = 0; n < 10; n++)
D printf( "%d! = %d\n", n, fakultaet_iter( n));
}
In der Funktion wird der in (A) übergebene Parameter n immer wieder mit fak multi-
pliziert und dabei heruntergezählt.
0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
192
7.4 Rekursion
5! = 120
6! = 720
7! = 5040
8! = 40320
9! = 362880
Sie haben aber auch eine andere, eine rekursive Definition der Fakultät kennenge-
lernt:
⎧ 1 falls n ≤ 1
n! = ⎨ 7
⎩ n ⋅ ( n – 1 )! falls n > 1
In dieser Formel wird die Berechnung der Fakultät auf die Berechnung der nächstklei-
neren Fakultät zurückgespielt. Genau das können wir auch in einem C-Programm
machen:
void main()
{
int n;
for( n = 0; n < 10; n++)
printf( "%d! = %d\n", n, fakultaet_rek( n));
}
Die rekursive Funktion gibt für einen Aufruf mit einem Parameter n <=1 eine 1 als
Rückgabewert (A). Für n>1 erfolgt der rekursive Aufruf der Funktion (B).
Beachten Sie, dass der Parameter n bei jedem Rekursionsschritt um 1 vermindert wird
und dann für n==1 (A) kein weiterer Selbstaufruf mehr erfolgt. So, wie Sie sich bei
einer Schleife immer Gedanken über eine geeignete Abbruchbedingung machen
müssen, müssen Sie sich auch bei Rekursion immer Gedanken über einen Ausstieg
machen, damit sich Ihr Programm nicht in einem endlosen rekursiven Abstieg ver-
liert. Ein Absturz mit einem sogenannten Stack Overflow wäre die unausweichliche
Folge.
Von ihrem äußeren Verhalten her sind die iterative und die rekursive Implementie-
rung der Fakultätsfunktion gleich. Beide haben die gleiche Schnittstelle und liefern
193
7 Modularisierung
Sie kennen vielleicht das Spiel »Türme von Hanoi«, bei dem ein Spieler die Aufgabe
hat, einen Stapel unterschiedlich großer Ringe von einem Ständer (Start) auf einen
anderen Ständer (Ziel) zu transportieren:
1
2
3
4
5
Start Tmp Ziel
3 An dieser Stelle möchte ich keine konkreten Messungen durchführen. Wir werden uns später
noch intensiv mit dem Laufzeitverhalten von Funktionen auseinandersetzen.
194
7.4 Rekursion
Die letzte Bedingung besagt, dass die Stapel immer der Größe nach sortiert bleiben
müssen – egal, auf welchem Ständer sie sich befinden. Wir wollen versuchen, fünf
Ringe unter Beachtung der Bedingungen zu transportieren. Dazu legen wir eine
mutige Annahme zugrunde. Wir stellen uns vor, dass wir vier Ringe regelkonform
bewegen können4. Dann könnten wir wie folgt vorgehen:
1
2
3
5 4
Start Tmp Ziel
Wir haben ja angenommen, dass wir das können. Der nächste Schritt ist dann klar.
Wir legen den 5. Ring an seine endgültige Position:
1
2
3
4 5
Start Tmp Ziel
195
7 Modularisierung
1
2
3
4
5
Start Tmp Ziel
Und damit sind wir fertig. Das Verfahren hängt natürlich noch völlig in der Luft, denn
wir haben nur gezeigt, dass wir fünf Ringe schaffen, sofern wir vier Ringe schaffen.
Aber mit der gleichen Argumentation wie oben sehen Sie, dass man vier Ringe
schafft, sofern man drei Ringe schafft. Und man schafft zwei, sofern man einen
schafft. Und einen Ring schafft man locker, indem man ihn einfach umlegt. Jetzt
zieht der Schluss durch: Man schafft einen, also auch zwei. Man schafft zwei, also
auch drei ... Letztlich schafft man also beliebig große Stapel5.
Hinter dieser Vorüberlegung verbirgt sich auch schon das komplette Verfahren. Wir
müssen es nur noch programmieren:
5 Streng mathematisch müsste man hier einen Beweis durch vollständige Induktion führen, aber
anschaulich ist klar, dass der Schluss »durchläuft«.
196
7.4 Rekursion
void main()
{
G hanoi( 5, 'S', 'T', 'Z');
}
Die Funktion hanoi bewegt n Ringe von start über tmp nach ziel (A). Wenn mehr als
ein Ring zu bewegen ist (B), dann führe die folgenden drei Aktionen aus:
왘 Bewege n-1 Ringe von start über ziel nach tmp (C). 7
왘 Bewege den n-ten Ring von start nach ziel (D).
왘 Bewege n-1 Ringe von tmp über start nach ziel (E).
Wenn nur ein Ring zu bewegen ist, ist, dann bewege ihn direkt von start nach ziel (F).
Der Aufruf der rekursiven hanoi-Funktion kann nun aus dem Hauptprogramm erfol-
gen, z. B. um fünf Ringe von S über T nach Z zu bewegen (G). Dieses Programm
erzeugt die erforderlichen Handlungsanweisungen:
Ring 4
Ring 3
Ring 2
Ring 1
Ring 5
Ring 1: S -> Z
Ring 2: S -> T
Ring 1: Z -> T
Ring 3: S -> Z
Ring 1: T -> S
Ring 2: T -> Z
Ring 1: S -> Z
Ring 4: S -> T
Ring 1: Z -> T
Ring 2: Z -> S
Ring 1: T -> S
Ring 3: Z -> T
Ring 1: S -> Z
Ring 2: S -> T
Ring 1: Z -> T
Ring 5: S -> Z
Ring 1: T -> S
Ring 2: T -> Z
Ring 1: S -> Z
Ring 3: T -> S
Ring 1: Z -> T
Ring 2: Z -> S
Ring 1: T -> S
Ring 4: T -> Z
Ring 1: S -> Z
Ring 2: S -> T
Ring 1: Z -> T
Ring 3: S -> Z
Ring 1: T -> S
Ring 2: T -> Z
Ring 1: S -> Z
197
7 Modularisierung
Links neben die Ausgabe habe ich eine »Baumstruktur« gezeichnet, die Ihnen helfen
soll zu verstehen, wie es zu dieser Ausgabe kommt. Jeder Knoten des Baums steht für
einen Aufruf der Funktion hanoi. Die durchgezogenen Linien stehen für einen Funk-
tionsaufruf, die gestrichelten für das direkte Bewegen eines einzelnen Rings. Sie
sehen, dass auf den höheren Aufrufebenen (Ring = n > 1) immer ein Unterprogramm-
aufruf, gefolgt von einer Bewegung, gefolgt von einem erneuten Unterprogramm-
aufruf erfolgt. Auf den tiefsten Ebenen (Ring = n = 1) gibt es dann keine
Unterprogrammaufrufe mehr, sondern nur jeweils eine Bewegung des Rings. Exakt
so ist das durch das Programm vorgegeben. Der Baum wird so abgearbeitet, dass es
immer zuerst in die Tiefe geht, dann eine Ebene zurück, wenn es nicht mehr weiter-
geht. Die von einem Knoten ausgehenden Linien werden von oben nach unten abge-
fahren. Dadurch ergibt sich genau die rechts stehende Ausgabe.
Die Rekursion ist eine durchaus anspruchsvolle Technik, da man bei der Konzeption
rekursiver Funktionen leicht einen »Knoten im Gehirn« bekommen kann. Bewegen
Sie das oben erläuterte Programm so lange in Ihrem Kopf, bis der Knoten entwirrt ist.
Was hier zur Verwirrung beiträgt, ist, dass die Ständer in der Rekursion ihre Rollen
wechseln. Ein Ständer, der eben noch Startpunkt war, ist auf der nächsten Rekursi-
onsebene vielleicht Zwischenablage oder Ziel. Nehmen Sie sich Zeit. Manchmal dau-
ert es etwas länger, bis ein Groschen fällt. Ich kann Ihnen aber eines mit Sicherheit
versprechen: Wenn dieser Groschen fällt, werden auch alle weiteren Groschen in die-
sem Buch fallen.
198
7.5 Der Stack
Funktion – hat damit einen eigenen, dynamisch wachsenden Bereich auf dem Stack,
in dem es seine lokalen Daten ablegt. Wenn die Funktion beendet wird, wird der Stack
wieder abgebaut, und die lokalen Daten der Funktionsinstanz verschwinden. Das
rufende Programm sieht dann wieder seine lokalen Daten auf dem Stack, als hätte
der Unterprogrammaufruf nie stattgefunden.
Das hier beschriebene Prinzip gilt insbesondere für einen rekursiven Funktionsauf-
ruf, bei dem ja mehrere Aufrufinstanzen ein und derselben Funktion ineinander
geschachtelt existieren können. Jede Instanz hat dann für ihren Lebenszyklus einen
eigenen Satz lokaler Variablen, der unabhängig von den lokalen Variablen anderer
7
Aufrufinstanzen der gleichen Funktion ist. Wenn es also in einer Funktion eine lokale
Variable x gibt, existiert diese Variable in allen Aufrufinstanzen dieser Funktion als
eigenständige Variable. Wenn dann in verschiedenen Aufrufinstanzen auf die Vari-
able x zugegriffen wird, haben diese Zugriffe nichts miteinander zu tun, weil auf ver-
schiedene Bereiche des Stacks zugegriffen wird.
Stack
void main()
{
Jede Aufrufinstanz hat ihre
int k; k:6
eigenen lokalen Variablen
k = fakultaet( 3);
auf dem Stack.
}
6
if( n <= 1)
übergeben Parameter als
return 1;
lokale Variablen auf den
f = n*fakultaet(n-1);
Stack gelegt.
return f;
2
}
if( n <= 1)
die Aufrufinstanz weitere
return 1;
lokale Variablen anlegt.
f = n*fakultaet(n-1);
return f; 1
}
199
7 Modularisierung
7.6 Beispiele
Die Beispiele dieses Abschnitts werden etwas umfangreicher werden als alle vorange-
gangenen Beispiele. Ich will Ihnen ja zeigen, dass Sie durch Modularisierung auch
Probleme in Angriff nehmen können, an die Sie sich bisher nicht herangetraut hät-
ten. Sie werden dabei auch sehen, dass Sie auch schon Beiträge zur Lösung eines Pro-
blems programmieren können, ohne bereits zu wissen, wie die endgültige Lösung
des Problems einmal aussehen könnte.
7.6.1 Bruchrechnung
Im ersten Beispiel geht es um Bruchrechnung. Sie werden positive Brüche addieren.
Dabei werden Sie nicht mit Gleitkommazahlen rechnen, sondern immer Zähler und
Nenner explizit berechnen, wie Sie das in der Schule gelernt haben. Also:
1 1 5
-- + -- = ---
2 3 6
a c ad + bc
--- + --- = -------------------
b d bd
Dabei sollte das Ergebnis immer gekürzt sein. Kürzen bedeutet, dass Zähler und Nen-
ner durch den größten gemeinsamen Teiler (ggT) dividiert werden. Sie wissen also,
dass Sie, egal, wie Sie später die Bruchrechnung programmieren werden, eine Funk-
tion zur Berechnung des ggT benötigen. Mit dieser Funktion fangen Sie an.
Den ggT von zwei Zahlen berechnen Sie, indem Sie so lange die kleinere Zahl von der
größeren abziehen, bis beide Zahlen gleich sind:
200
7.6 Beispiele
Solange a und b verschieden sind (A), wird die kleinere von der größeren Zahl abgezo-
gen (B). Am Ende ist der ggT in a und wird zurückgegeben (C). Da a=b gilt, hätte aber
auch b zurückgegeben werden können.
Bei einem Bruch müssen Sie Zähler und Nenner speichern. Dafür bietet sich ein Array
mit zwei int-Werten an. Damit ist aber auch schon klar, wie Sie das Kürzen eines
Bruchs implementieren können. Sie müssen nur Zähler und Nenner durch den ggT
dividieren:
In der Funktion wird zuerst der ggT von Zähler und Nenner berechnet (A), danach
werden Zähler und Nenner durch den ggT dividiert (B) und (C).
Beachten Sie, dass diese Funktion wie auch die nächste keinen Returnwert benötigt,
da Zähler und Nenner im Array direkt verändert werden.
Das Addieren der Brüche wird nun gemäß der Vorschrift ausgeführt und das Ergeb-
nis dann gekürzt. Im Hauptprogramm erstellen Sie einen Testrahmen:
void main()
{
A int bruch1[2], bruch2[2], ergebnis[2];
printf( "Bruch1: ");
scanf( "%d/%d", &bruch1[0], &bruch1[1]);
printf( "Bruch2: ");
scanf( "%d/%d", &bruch2[0], &bruch2[1]);
201
7 Modularisierung
B addieren(bruch1,bruch2,ergebnis);
printf( "Ergebnis: %d/%d\n", ergebnis[0], ergebnis[1]);
}
In dem Testrahmen werden drei Brüche angelegt (A). Das Ergebnis der Addition von
bruch1 und bruch2 wird in ergebnis abgelegt. Der Testrahmen erzeugt damit die fol-
gende Ausgabe:
Bruch1: 1/3
Bruch2: 1/6
Ergebnis: 1/2
Bei der Erstellung des Programms sind wir konsequent »bottom up« vorgegangen.
Das heißt, wir haben gerufene Funktionen vor rufenden Funktionen implementiert.
Das muss nicht so sein. Häufig geht man auch »top down« vor. Bottom up bietet den
Vorteil, dass Sie das Programm auf jeder Entwicklungsstufe testen können. Zum Bei-
spiel können Sie die ggT-Funktion testen, ohne das Kürzen oder die Addition imple-
mentiert zu haben. Umgekehrt können Sie das Kürzen nicht ohne die ggT-Funktion
testen. Bei großen Softwaresystemen können Sie jedoch in der Regel nicht bottom up
vorgehen, da Sie die Detailinformationen, die Sie dazu benötigen, nicht oder noch
nicht haben. Typischerweise kommen in Softwareprojekten immer beide Vorge-
hensweisen vor.
Die Aufgabe des Damenproblems lautet, n Damen auf einem quadratischen Schach-
brett der Breite n so zu positionieren, dass keine Dame eine andere schlagen kann.
Das bedeutet:
202
7.6 Beispiele
D
7
1 2 3 4 5 6 7 8
damen[0] D
damen[1] D
damen[2] D
damen[3] D
damen[4] D
damen[5] D
damen[6] D
damen[7] D
Abbildung 7.12 Eine mögliche Lösung des Damenproblems
Eine naheliegende Datenstruktur zur Lösung des Problems ist, wie die Zeichnung
bereits andeutet, ein Array mit acht Integer-Zahlen. Die Anweisung: damen[3] = 6;
bedeutet dann, dass die Dame mit dem Index 3 in die Spalte 6 gesetzt wird. Wenn es
203
7 Modularisierung
uns gelingt, in dem Array alle möglichen Stellungen zu erzeugen und dann die kor-
rekten Stellungen herauszufiltern, hätten wir alle Lösungen gefunden.
Über die Erzeugung der Stellungen werden wir uns zunächst noch keine Gedanken
machen, aber wir wissen, dass wir Stellungen und einzelne Damen gegeneinander
prüfen müssen. Wir überlegen uns also, wann sich zwei Damen in unserem Modell
schlagen können. Dass zwei Damen in der gleichen Zeile stehen, ist in unserem
Modell ausgeschlossen. Es bleiben also die Fälle, dass zwei Damen in der gleichen
Spalte oder in der gleichen Diagonalen stehen:
1 2 3 4 5 6 7 8
damen[0]
Die Damen 1 und 6 können sich schlagen wegen:
damen[1] D damen[1] = damen[6]
damen[2]
damen[3] D
damen[4]
damen[5]
damen[6] D
damen[7] D
Die Damen 3 und 7 können sich schlagen wegen:
|damen[3] – damen[7]| = |3 – 7|
Im ersten Fall ist der Horizontalabstand 0, im zweiten Fall ist der Horizontalabstand
gleich dem Vertikalabstand. Offensichtlich spielt der Abstand (= Absolutbetrag der
Differenz) eine Rolle bei der Prüfung einer Stellung. Also implementieren Sie die
Abstandsberechnung als eigenständige Funktion:
204
7.6 Beispiele
Mit dieser Hilfsfunktion können Sie prüfen, ob sich zwei Damen schlagen können:
Die Funktion erhält die Indizes der beiden zu prüfenden Damen sowie ein Array der
zu prüfenden Stellungen als Eingangsparameter (A) und berechnet den Vertikalab-
stand dv (B) und den Horizontalabstand dh (C). Im Folgenden wird geprüft, ob der
Horizontalabstand == 0 oder der Vertikalabstand == Horizontalabstand (D). Ist das
der Fall, können sich die Damen schlagen. Andernfalls können sich die Damen nicht
schlagen, und es wird 0 zurückgegeben.
Sie werden eine Stellung Schritt für Schritt aufbauen, indem Sie zunächst die erste,
dann die zweite, dann die dritte Dame etc. zu positionieren versuchen. Eine neue
Dame zu positionieren ist natürlich nur sinnvoll, wenn diese Dame keine der zuvor
positionierten Damen schlagen kann. Sie erstellen daher eine Funktion, die die dazu
erforderlichen Prüfungen vornimmt:
In der Funktion wird eine Dame x gegen alle vorher gesetzten Damen i geprüft (A).
Wenn die Dame x eine dieser Damen schlagen kann, ist die Stellung nicht okay (B).
Ansonsten ist die Stellung okay (C).
205
7 Modularisierung
Schließlich brauchen Sie auch noch eine Funktion zur Ausgabe einer Lösung:
A int laufendenummer = 0;
void print_loesung( int anz, int damen[])
{
int i;
B laufendenummer++;
printf( "%2d. Loesung: ", laufendenummer);
for( i = 0; i < anz; i = i + 1)
C printf( " %d", damen[i]);
printf( "\n");
}
Die Lösungen werden in einer globalen Variablen gezählt (A). Diese globale Variable
wird in der Funktion fortlaufend hochgezählt (B). Die Lösungsausgabe erfolgt dann
jeweils mit der laufenden Nummer.
Wenn Ihnen eine feste Anzahl von Damen vorgegeben ist, können Sie alle möglichen
Stellungen durch ineinander geschachtelte Schleifen erzeugen. Das bedeutet aller-
dings, dass Sie so viele ineinander geschachtelte Schleifen wie Damen haben. Abgese-
hen davon, dass das sehr mühselig zu programmieren ist, ist diese Lösung auch sehr
unflexibel, da sie nur für diese eine feste Zahl von Damen gilt und für mehr oder
weniger Damen neu programmiert werden müsste. Trotzdem werden Sie diese
Lösung einmal für vier Damen erstellen:
void damen4()
{
A int damen[4];
B for( damen[0] = 1; damen[0] <= 4; damen[0]++)
{
for( damen[1] = 1; damen[1] <= 4; damen[1]++)
{
C if( !stellung_ok( 1, damen))
continue;
for( damen[2] = 1; damen[2] <= 4; damen[2]++)
{
D if( !stellung_ok( 2, damen))
continue;
for(damen[3] = 1; damen[3] <= 4; damen[3]++)
{
206
7.6 Beispiele
7
In der Funktion wird zuerst ein Array für vier Damen erstellt (A). Danach werden
beginnend mit der ersten Schleife (A) in vier Schleifen alle möglichen Stellungen
erzeugt. Innerhalb der Schleifen wird geprüft, ob eine Dame eine zuvor gesetzte
Dame schlagen kann. Falls das der Fall ist, wird die Stellung nicht weiter untersucht
(C) und (D). Haben Sie eine Stellung gefunden, bei der das auf keiner Ebene der Fall ist,
haben Sie damit auch eine Lösung gefunden (E).
Bis auf die mangelnde Flexibilität ist das eine akzeptable Lösung. Aber wie kommen
Sie zu einer allgemeineren und flexibleren Lösung? Ganz einfach, indem Sie die erste
Dame bewegen und für die Bewegung der zweiten und jeder weiteren Dame das Pro-
gramm in die Rekursion schicken. Dazu müssen Sie zunächst einmal eine »rekursi-
onsfähige« Schnittstelle festlegen. Das Array mit den Damen darf jetzt nicht mehr in
der Funktion angelegt werden, da es dann ja bei jedem rekursiven Aufruf neu erzeugt
würde. Das Array müssen Sie also außerhalb der Rekursion anlegen und dann an der
Schnittstelle durchreichen. Zusätzlich müssen Sie dann auch die Größe des Arrays (=
Anzahl Damen) an der Schnittstelle übertragen. Die rekursiven Funktionsausrufe
arbeiten immer mit einer speziellen Dame. Auf Rekursionstiefe 0 arbeiten Sie mit
der Dame 0, auf Rekursionstiefe 1 mit der Dame 1 etc. Auch diese Information müs-
sen Sie an der Schnittstelle bereitstellen. Dies setzen Sie in Programmcode um:
Bei der jetzt noch anstehenden Implementierung der Rekursion müssen Sie zwei
Aspekte beachten:
207
7 Modularisierung
왘 Wenn Sie das maximale Level (= anz) erreichen, haben Sie eine Lösung gefunden.
In diesem Fall müssen Sie die Lösung ausgeben und dürfen nicht erneut in die
Rekursion absteigen.
왘 In allen anderen Fällen steigen Sie nur dann weiter in die Rekursion ab, wenn die
bisher gefundene Stellung in Ordnung ist.
Wenn in der rekursiven Funktion eine Lösung gefunden wird (A), erfolgt eine Aus-
gabe, es wird nicht weiter abgestiegen. Wenn eine Stellung okay ist (D), wird rekursiv
auf das nächste Level abgestiegen (E).
Wichtig ist jetzt noch der Aufruf der rekursiven Funktion. Sie müssen das Damen-
Array außerhalb der Funktion anlegen und die Funktion mit dem richtigen Startlevel
(0) rufen. Die Anzahl der Damen können Sie relativ frei wählen:
void main()
{
int damen[20];
int anz;
printf( "Anzahl: ");
scanf( "%d", &anz);
damenproblem( anz, damen, 0);
}
208
7.6 Beispiele
Für das 8-Damen-Problem gibt es 92 Lösungen, von denen die ersten zehn folgender-
maßen aussehen:
1. Loesung: 1 5 8 6 3 7 2 4
2. Loesung: 1 6 8 3 7 4 2 5
3. Loesung: 1 7 4 6 8 2 5 3
4. Loesung: 1 7 5 8 2 4 6 3
5. Loesung: 2 4 6 8 3 1 7 5
6. Loesung: 2 5 7 1 3 8 6 4
7. Loesung: 2 5 7 4 1 8 6 3
7
8. Loesung: 2 6 1 7 4 8 3 5
9. Loesung: 1 6 8 3 1 4 7 5
10. Loesung: 2 7 3 6 8 5 1 4
Wenn ich gesagt habe, dass Sie die Zahl der Damen nur »relativ« frei wählen können,
hat das damit zu tun, dass ich das Damen-Array auf 20 Einträge limitiert habe. Diese
Einschränkung mögen Sie als unglücklich empfinden, und wir werden uns später
darüber Gedanken machen, wie wir uns von solchen Beschränkungen lösen können.
An dieser Stelle möchte ich Ihren Blick darauf lenken, dass die Zahl 20 keine wirkliche
Beschränkung darstellt, da es hier eine ganz andere Beschränkung gibt – die Laufzeit
des Programms. Ich habe das Programm so geändert, dass nicht mehr die einzelnen
Lösungen ausgegeben werden, sondern nur deren Gesamtzahl. Zusätzlich habe ich
ausgegeben, wie viele Stellungen bei der Lösungssuche untersucht wurden. Sie erhal-
ten das folgende bemerkenswerte Ergebnis:
209
7 Modularisierung
Bei zunehmender Damenzahl sehen Sie einen extremen Anstieg der zu untersuchen-
den Stellungen. Das ist auch nicht verwunderlich, da insgesamt bei n Damen bis zu
n · n … n = nn
⎧
⎪
⎨
⎪
⎩
n mal
7.6.3 Permutationen
Stellen Sie sich vor, dass beim Scrabble ein Haufen von zehn Buchstabenklötzchen
vor Ihnen liegt und Sie herausfinden wollen, ob man mit den Buchstaben ein sinn-
volles Wort legen kann. Eine Lösung könnte darin bestehen, die Buchstaben systema-
tisch in allen möglichen Reihenfolgen – man nennt das Permutationen – auf den
Tisch zu legen und zu prüfen, ob dabei ein gültiges Wort vorkommt. Für das gesuchte
Verfahren drängt sich ein rekursives Vorgehen förmlich auf. Sie legen jeden der zehn
Buchstaben einmal an die erste Position. Dann müssen Sie nur noch die restlichen
neun Buchstaben in allen möglichen Reihenfolgen dahinterlegen. Das können Sie
sofort so programmieren:
210
7.6 Beispiele
Die Funktion perm bekommt die Anzahl der Zeichen und das Array mit den Zeichen
übergeben. In dem Parameter start ist die Stelle enthalten, ab der noch weitere Ver-
tauschungen durchzuführen sind (A).
Solange noch Vertauschungen vorgenommen werden müssen (B), werden die fol-
genden Schritte ausgeführt: 7
In (J) ist eine neue Permutation (start == anz) fertig und wird ausgegeben.
In einem Hauptprogramm testen wir das mit den Buchstaben »TEWR« und stellen
fest, dass »WERT« das einzige Wort ist, das wir mit diesen Buchstaben legen können:
void main()
{
char haufen[5] = "TEWR";
printf( "Vorher: %s\n", haufen);
perm( 4, haufen, 0);
printf( "Nachher: %s\n", haufen);
}
Vorher: TEWR
TEWR
TERW
TWER
TWRE
TRWE
TREW
ETWR
ETRW
EWTR
EWRT
211
7 Modularisierung
ERWT
ERTW
WETR
WERT
WTER
WTRE
WRTE
WRET
REWT
RETW
RWET
RWTE
RTWE
RTEW
Nachher: TEWR
Die Ausgaben am Anfang und am Ende zeigen, dass die Reihenfolge der Elemente
nach allen zwischenzeitlich durchgeführten Vertauschungen am Ende wieder der
Ausgangsreihenfolge entspricht. Abbildung 7.14 zeigt, wie der Algorithmus vorgeht:
212
7.6 Beispiele
Um die Fragezeichen zu beseitigen, werden der Reihe nach alle noch verfügbaren
Buchstaben eingesetzt, und das Programm wird rekursiv zur Beseitigung der restli-
chen Fragezeichen aufgerufen. Das gibt auf der höchsten Ebene 4, dann jeweils 3,
dann 2 und dann einen Unterprogrammaufruf.
7
7.6.4 Labyrinth
In diesem Beispiel versetzen Sie sich in ein Labyrinth, das unfairerweise keinen Aus-
gang hat.
Ihre Aufgabe besteht nun darin, für beliebige Start- und Zielpunkte einen Weg durch
das Labyrinth zu finden, sofern es einen solchen Weg überhaupt gibt.
Das Wegenetz des Irrgartens werden Sie in einem zweidimensionalen Array ablegen,
in dem Sie Mauern mit '#' und begehbare Bereiche mit ' ' markieren:
213
7 Modularisierung
char labyrinth[22][22] =
{
"#####################",
"# # # #",
"# # ############# # #",
"# # # # #",
"# # ###### ###### # #",
"# # # # # # # #",
"# # # # ##### # # # #",
"# # # # # # # # #",
"# # # # ## ## # # # #",
"# ### ### # # # # #",
"# # # # # # #",
"# # # # # ### ### #",
"# # # # ## ## # # # #",
"# # # # # # # # #",
"# # # # ##### # # # #",
"# # # # # # # #",
"# # ###### ###### # #",
"# # # # #",
"# # ############# # #",
"# # # #",
"#####################",
0
};
In jeder Zeile des Arrays steht ein 0-terminierter String. Beachten Sie, dass der Com-
piler jede Zeile des Arrays mit 0 (nicht '0') abschließt. In das erste Feld der letzten
Zeile schreiben Sie explizit eine 0, um das Ende des Arrays zu markieren. Das so auf-
gebaute Array können Sie auf dem Bildschirm darstellen, indem Sie Zeile für Zeile
mit printf ausgeben:
void ausgabe()
{
int zeile;
for( zeile = 0; labyrinth[zeile][0] != 0; zeile++)
printf( "%s\n", labyrinth[zeile]);
}
214
7.6 Beispiele
Die Daten werden Zeile für Zeile ausgegeben, die Ausgabe wird beendet, wenn in der
ersten Spalte der betrachteten Zeile eine 0 steht. Für das Beispiel ergibt sich dann fol-
gende Ausgabe:
#####################
# # # #
# # ############# # #
# # # # #
# # ###### ###### # #
# # # # # # # #
# # # # ##### # # # # 7
# # # # # # # # #
# # # # ## ## # # # #
# ### ### # # # # #
# # # # # # #
# # # # # ### ### #
# # # # ## ## # # # #
# # # # # # # # #
# # # # ##### # # # #
# # # # # # # #
# # ###### ###### # #
# # # # #
# # ############# # #
# # # #
#####################
Wenn ein Start- und einen Zielpunkt jeweils durch Zeilen- und Spaltenindex vorgege-
ben ist, können Sie versuchen, einen Weg zwischen den beiden Punkten zu finden. Es
geht dabei nicht darum, einen möglichst kurzen Weg zu finden, Hauptsache, Sie fin-
den überhaupt einen Weg. Wichtig ist, dass Start- und Zielpunkt dabei auf gültigen,
begehbaren Feldern liegen. Das wird vom Programm nicht geprüft und muss bei der
Eingabe der Daten beachtet werden. Zeilen- und Spaltenindex beginnen, wie üblich,
bei 0. Rekursiv ist die Wegesuche verblüffend einfach zu programmieren:
A int weg( int start_z, int start_s, int ziel_z, int ziel_s)
{
if( (start_z == ziel_z) && (start_s == ziel_s))
{
B labyrinth[start_z][start_s] = '+';
return 1;
}
C if( labyrinth[start_z-1][start_s] == ' ')
{
D labyrinth[start_z][start_s] = '^';
215
7 Modularisierung
Die Funktion weg erhält als Eingangsparameter Startzeile und Startspalte sowie Ziel-
zeile und Zielspalte (A). In der Funktion wird zuerst geprüft, ob Sie am Ziel sind (B). In
diesem Fall schreiben Sie an Ihrer Position ein '+' und geben 1 (= Erfolg) zurück.
In (C) sind Sie nicht am Ziel, stellen aber fest, dass Sie nach oben gehen können. Sie
schreiben an Ihrer Position ein '^' (D). Wenn Sie rekursiv von der Position oberhalb
einen Weg zum Ziel finden (E), geben Sie 1 zurück (F). Andernfalls machen Sie mit den
folgenden Fällen (unten, links, rechts) weiter.
Wenn Sie die Position (G) erreichen, bedeutet das, dass Sie von hier aus keinen Weg
zum Ziel gefunden haben. Markieren Sie das Feld mit '-', und geben Sie 0 (= Misser-
folg) zurück (H).
Felder auf Ihrem aktuellen Weg und Felder, die bereits erfolglos besucht wurden, wer-
den im Array markiert, um zu verhindern, dass Sie in Ihrer eigenen Spur zurücklau-
fen oder bereits als erfolglos erkannte Wege erneut einschlagen. Im Hauptprogramm
fragen Sie Start- und Zielpunkt ab und starten die Wegsuche.
216
7.6 Beispiele
void main()
{
int start_z, start_s, ziel_z, ziel_s;
ausgabe();
Im Hauptprogramm werden die Start- und Zielpositionen eingegeben, und die Weg-
suche wird gestartet. Mit konkreten Eingaben erhalten Sie ein etwas sprödes Bild-
schirmprotokoll, das ich noch grafisch aufbereitet habe:
217
7 Modularisierung
Anhand der Markierungen, die das Programm im Array zurückgelassen hat, können
Sie genau erkennen, wo der Weg entlangführt, welche Felder als erfolglos ausge-
schlossen und welche Felder nicht getestet wurden. Sie können den Weg auch selbst
finden, wenn Sie an jedem Punkt vorrangig nach oben, dann nach unten, links und
rechts gehen. Wenn Sie dabei in eine Sackgasse geraten, gehen Sie so weit zurück, bis
es wieder eine Alternative gibt. Das folgende Beispiel deutet dieses Vorgehen an:
7.7 Aufgaben
A 7.1 Erstellen Sie eine Funktion, die einen String und einen Buchstaben übergeben
bekommt und zurückgibt, wie oft der Buchstabe in dem String vorkommt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.1 als Vorlage.
A 7.2 Erstellen Sie eine Funktion, die die Reihenfolge der Zeichen in einem String
umkehrt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.2 als Vorlage.
A 7.3 Erstellen Sie eine Funktion, die alle 'e' aus einem String entfernt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.3 als Vorlage.
218
7.7 Aufgaben
A 7.4 Lösen Sie das Damenproblem mithilfe des Beispielprogramms zur Erzeugung
von Permutationen.
A 7.5 Betrachten Sie das folgende Schema, in dessen Felder die Zahlen von 1 bis 8 so
eingetragen werden müssen, dass sich die Zahlen in den durch eine Linie ver-
bundenen Feldern um mehr als 1 unterscheiden:
Finden Sie alle Lösungen des Problems, indem Sie das Zahlenschema auf ein
Array abbilden und dann alle möglichen Anordnungen der Zahlen erzeugen
und jeweils prüfen, ob die geforderten Bedingungen erfüllt sind!
A 7.6 Erstellen Sie eine Funktion, die die Zahlen in einem Array sortiert.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor,
um ein Array mit n Elementen zu sortieren?
A 7.7 Erstellen Sie eine Funktion, die die Zahlen in einem Array so umordnet, dass
anschließend alle negativen Zahlen vor allen nicht negativen Zahlen stehen.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor,
um ein Array mit n Elementen umzuordnen? Versuchen Sie, mit deutlich
weniger Vertauschungen auszukommen als in Aufgabe 7.7.6.
A 7.8 Erstellen Sie eine rekursive Funktion, die die Reihenfolge der Zahlen in einem
Array umkehrt.
219
7 Modularisierung
A 7.9 Der Springer ist eine leichte und bewegliche Figur beim Schach, die von ihrer
aktuellen Position aus bis zu acht Felder im sogenannten Rösselsprung (zwei
vorwärts, eins seitwärts) mit einem Zug erreichen kann:
Erstellen Sie ein Programm, das einen Springer von einem beliebigen Start-
punkt zu einem beliebigen Zielpunkt auf einem Schachbrett ziehen kann! Die
vom Programm gewählte Zugfolge muss nicht optimal sein und soll bei der
Ausgabe durch fortlaufende Nummern angezeigt werden:
+--+--+--+--+--+--+--+--+
| 0|39| |33| 2|35|18|21|
+--+--+--+--+--+--+--+--+
| | | 1|36|19|22| 3|16|
+--+--+--+--+--+--+--+--+
|38| |32| |34|17|20| 9|
+--+--+--+--+--+--+--+--+
| | |37| |23|10|15| 4|
+--+--+--+--+--+--+--+--+
| |31| | | |25| 8|11|
+--+--+--+--+--+--+--+--+
| | | |24| |14| 5|26|
+--+--+--+--+--+--+--+--+
|30| | | |28| 7|12| |
+--+--+--+--+--+--+--+--+
| | |29| |13| |27| 6|
+--+--+--+--+--+--+--+--+
220
7.7 Aufgaben
A 7.10 Erweitern Sie das Programm der vorangegangenen Aufgabe so, dass eine opti-
male, d. h. möglichst kurze, Zugfolge ermittelt wird:
+--+--+--+--+--+--+--+--+
| 0| 3| | | | | | |
+--+--+--+--+--+--+--+--+
| | | 1| | | | | |
7
+--+--+--+--+--+--+--+--+
| 2| | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
221
Kapitel 8
Zeiger und Adressen
Wissen heißt wissen, wo es geschrieben steht.
– Albert Einstein
8
Zu Beginn dieses Kapitels stellen wir uns eine eigentlich ganz einfach erscheinende
Aufgabe. Wir wollen eine Funktion erstellen, die die Werte von zwei Integer-Variab-
len im rufenden Programm vertauscht. Wir legen ganz unbekümmert los:
B t = a;
a = b;
b = t;
}
void main()
{
int x = 1;
int y = 2;
Das Programm enthält eine Funktion tausche (A). Diese Funktion erhält zwei Para-
meter, a und b, und tauscht deren Werte (B). Im Hauptprogramm rufen wir die Funk-
tion tausche, um die Werte von x und y zu tauschen.
Vorher: 1 2
Nachher: 1 2
223
8 Zeiger und Adressen
Das Ergebnis ist enttäuschend. Es passiert nichts. Das war aber auch zu erwarten. Sie
wissen ja bereits, dass beim Aufruf des Unterprogramms die Werte von x und y in
eigenständige, nur dem Unterprogramm gehörende, Variablen umkopiert werden.
Ein Vertauschen der Werte dieser Variablen im Unterprogramm hat keinerlei Aus-
wirkungen auf die ursprünglichen Variablen im Hauptprogramm. Auch ein Umbe-
nennen von x in a und y in b würde an dieser Situation nichts ändern.
Sie könnten die Variablen x und y des Hauptprogramms global anlegen und dann im
Unterprogramm auf diese globalen Variablen zugreifen. Bei genauer Betrachtung
erweist sich diese Idee aber als keine echte Lösung der gestellten Aufgabe, da das
Unterprogramm dann ja nur genau diese beiden Variablen vertauschen könnte und
nicht allgemein zur Vertauschung von Variablen eingesetzt werden könnte.
Um das Problem wirklich zu lösen, müssen Sie dem Unterprogramm den gezielten
Zugriff auf ausgewählte Variablen des Hauptprogramms ermöglichen. Dazu müssen
Sie dem Unterprogramm mitteilen, wo diese Variablen im Speicher stehen. Eine Vari-
able steht im Speicher an einer bestimmten Adresse, die der Compiler kennt, weil er
die Variable dort angelegt hat. Sie können den Compiler auffordern, Ihnen diese
Adresse zu geben:
Die Speicheradresse einer Variablen erhalten Sie, indem Sie dem Variablenna-
men den Adress-Operator & voranstellen.
Der konkrete Wert einer Adresse interessiert uns in der Regel nicht. Eine Adresse ist
für uns nur eine eindeutige Zugriffsinformation auf das, was an dieser Adresse im
Speicher hinterlegt ist.
Ändern Sie das Hauptprogramm ab, indem Sie jetzt nicht mehr die Werte der Variab-
len, sondern die Adressen der Variablen übergeben:
void main()
{
int x = 1;
int y = 2;
printf( "Vorher: %d %d\n", x, y);
A tausche( &x, &y);
printf( "Nachher: %d %d\n", x, y);
}
In (A) wird die Funktion mit den Adressen der Variablen aufgerufen.
224
왘 Eine Variable, in der die Adresse einer anderen Variablen gespeichert ist, nennen
wir eine Zeigervariable oder kurz Zeiger bzw. Pointer.
왘 Die Variable, deren Adresse im Zeiger gespeichert ist, bezeichnen wir als die durch
den Zeiger referenzierte oder adressierte Variable.
왘 Über einen Zeiger kann auf die Daten der referenzierten Variablen zugegriffen
werden. Wir nennen dies Indirektzugriff oder auch Dereferenzierung.
왘 Zum Zugriff auf die referenzierte Variable verwendet man den Dereferenzierungs-
operator *.
왘 Ist p ein Zeiger, ist *p der Wert der referenzierten Variablen.
A int x;
B float y;
C int *pi;
D float *pf;
E pi = &x;
F pf = &y;
G *pi = 1234;
H *pf = *pi + 0.5;
Das Beispielprogramm startet mit zwei »gewöhnlichen« Variablen in (A) und (B). Es
folgen ein Zeiger auf int (C) und ein Zeiger auf float (D). In (E) und (F) finden dann
Adresszuweisungen statt, pi referenziert jetzt x, und pf referenziert y. Über einen
Indirektzugriff erfolgt in (G) die Zuweisung x = 1234 und in (H) die Zuweisung y = x +
0.5 = 1234.5. Die Ausgabe sieht dann folgendermaßen aus:
x: 1234
y: 1234.5000000
225
8 Zeiger und Adressen
Wichtig ist, dass wir nicht allgemein von »Zeigern«, sondern immer konkret von
»Zeigern auf ...« sprechen. Im oben dargestellten Beispiel haben wir es mit einem
»Zeiger auf int« und einem »Zeiger auf float« zu tun. Dementsprechend können wir
dem ersten nur die Adresse einer int-Variablen und dem zweiten nur die Adresse
einer float-Variablen zuweisen. Mit anderen Worten:
Der dereferenzierte Zeiger muss den gleichen Typ haben wie die Variable, auf
die er zeigt.
Wir haben hier als Beispiel nur Zeiger auf int bzw. float betrachtet. Natürlich gibt es
auch Zeiger auf char oder Zeiger auf double. Wir können Variablen aller Datentypen
referenzieren.
Zurück zu dem Tauschprogramm, das nicht funktioniert hat. Wir ändern dieses Pro-
gramm an einigen wenigen Stellen ab:
B t = *a;
C *a = *b;
D *b = t;
}
void main()
{
int x = 1;
int y = 2;
printf( "Vorher: %d %d\n", x, y);
E tausche( &x, &y);
printf( "Nachher: %d %d\n", x, y);
}
Die Parameter der Schnittstelle haben wir geändert (A), a und b sind Zeiger auf int.
Der Zugriff auf die durch a und b referenzierten Variablen erfolgt nun mit dem Ope-
rator * (A, B, C). Durch die geänderte Schnittstelle erfolgt der Aufruf der Funktion nun
passend durch die Übergabe der Adressen der Variablen x und y an die Funktion tau-
sche in (E).
Vorher: 1 2
Nachher: 2 1
226
Jetzt macht das Programm genau das, was es machen soll. Es greift über die Zeiger a
und b auf die Variablen x und y des Hauptprogramms zu und ändert gegebenenfalls
deren Werte. Das ändert übrigens nichts daran, dass bei einem Unterprogrammauf-
ruf Kopien der übergebenen Parameter erzeugt werden. Es handelt sich jetzt aller-
dings um Kopien der übergebenen Adressen, die im Unterprogramm zum Zugriff auf
die Daten des übergeordneten Programms genutzt werden.
Eigentlich ist das schon fast alles, was ich Ihnen in diesem Kapitel erzählen wollte,
aber Sie erhalten noch weitere Beispiele, da ich aus Erfahrung weiß, dass viele Leser
hier anfänglich Verständnisschwierigkeiten haben, die sich aber mit wachsender
Vertrautheit mit Zeigern legen werden. Das Verständnisproblem liegt in der Indirek-
tion, da ein Zeiger sozusagen »einmal um die Ecke geht«. Aber im Grunde genom- 8
men verwenden wir im täglichen Leben häufig Referenzen oder Zeiger. Wenn wir
jemandem indirekten Zugriff auf uns selbst verschaffen wollen, geben wir ihm
unsere Handynummer. Das ist die Adresse. Diese Adresse speichert er in seinem
Handy ab. Das ist der Zeiger. Wenn er uns dann erreichen will, wählt er die Nummer
aus dem Adressbuch seines Handys. Das ist der Indirektzugriff über den Zeiger. Die
konkrete Telefonnummer ist dabei ein notwendiges, aber eigentlich nebensächli-
ches Detail.
Häufig nutzt man Zeiger, wenn man von einer Funktion mehr als einen Rückgabe-
wert erwartet. Über return kann eine Funktion ja nur einen Wert zurückgeben. Über
Zeiger kann eine Funktion dagegen beliebig viele Werte zurückgeben. Als Beispiel
erstellen wir eine Funktion, die ein Array von Integer-Zahlen übergeben bekommt
und den größten und kleinsten Wert zurückgibt.
A void minmax( int anz, int daten[], int *pmin, int *pmax)
{
int i, min, max;
B min = daten[0];
max = daten[0];
for( i = 1; i < anz; i++)
{
if( daten[i] < min)
min = daten[i];
if( daten[i] > max)
C max = daten[i];
}
D *pmin = min;
*pmax = max;
}
227
8 Zeiger und Adressen
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
In der Schnittstelle der Funktion (A) werden neben der Anzahl und dem Daten-Array
die Zeiger auf die Variablen für die Rückgabe von Minimum und Maximum überge-
ben. Die Berechnung von Minimum und Maximum in min bzw. max erfolgt in dem
Bereich (B–C), in (D) werden Minimum und Maximum in den Variablen des rufenden
Programms gespeichert. Zum Aufruf der Funktion werden die Adressen von min und
max an die Funktion minimax übergeben (E). Wir erhalten die erwartete Ausgabe:
Minimum: –12
Maximum: 32
Achtung, eine Funktion kann keinen Zeiger auf eine eigene lokale Variable zurückge-
ben, da diese Variable nach dem Rücksprung der Funktion nicht mehr existiert. Der
Zeiger würde ins Leere zeigen1. Dies heißt natürlich nicht, dass Funktionen grund-
sätzlich keine Zeiger zurückgeben können. Wir stellen die Maximumfunktion, die
wir schon öfter betrachtet haben, konsequent auf die Verwendung von Zeigern um:
1 Ins Leere zeigende Zeiger sind übrigens das größte Problem der C-Programmierung. Darüber
erfahren Sie später noch mehr.
228
void main()
{
int a = 1;
int b = 2;
int c;
a = 1, b = 1, c = 2
Der hier von der Maximumfunktion zurückgegebene Zeiger »überlebt« den Funkti-
onsaufruf, weil die Adresse ja ursprünglich aus dem Hauptprogramm kommt und
daher die Lebenserwartung des Hauptprogramms hat. Die Zeigerversion der Maxi-
mumfunktion wirkt auf den ersten Blick wie eine umständliche Variante der
ursprünglichen Lösung, aber man kann diese Implementierung zusätzlich in einer
ganz anderen Weise verwenden:
void main()
{
int a = 1;
int b = 2;
Hier wird über den zurückgegeben Zeiger zugegriffen (A). Das heißt, der Variablen
mit dem größeren Wert wird 3 zugewiesen. Also b = 3.
a = 1, b = 3
Sie sehen, dass das Funktionsergebnis jetzt auch auf der linken Seite einer Zuweisung
verwendet werden kann, was vorher nicht möglich war. Auf der linken Seite einer
229
8 Zeiger und Adressen
Zuweisung können Sie nur etwas verwenden, das einen Speicherplatz referenziert.
Man nennt dies einen L-Value. Was auf der rechten Seite einer Zuweisung verwendet
werden kann, nennt man einen R-Value. Jeder L-Value ist ein R-Value, aber nicht
jeder R-Value ist ein L-Value, was sofort einsichtig ist, da Sie a = 1, aber nicht 1 = a
schreiben können. Der wesentliche Unterschied zwischen den beiden Varianten der
Maximumfunktion ist also, dass die erste einen R-Value und die zweite einen L-Value
zurückgibt. Die zweite Variante kann damit viel flexibler verwendet werden.
Sie könnten aus den bisherigen Beispielen den Eindruck gewinnen, dass Zeiger nur
an Funktionsschnittstellen von Bedeutung sind. Das ist aber nicht der Fall. Zeiger
sind ein ganz wichtiges, vielleicht sogar das wichtigste Programmiermittel in C. In
vollem Umfang können Sie das erst erkennen, wenn wir uns mit dynamischen
Datenstrukturen beschäftigen.
8.1 Zeigerarithmetik
Bisher haben wir Zeiger nur verwendet, um über den Zeiger auf die referenzierte
Variable zuzugreifen. Wir haben dem Zeiger dazu einen Initialwert (die Adresse sei-
ner referenzierten Variablen) gegeben und diesen Wert danach nicht mehr verän-
dert. Man kann den Wert eines Zeigers aber auch ändern, da es sich bei dem Zeiger
um eine ganz normale Variable handelt. Insbesondere kann man auch mit Zeigern
rechnen. Was bedeutet es aber, wenn man zu einem Zeiger etwa 1 hinzuaddiert? Man
könnte vermuten, dass der Adresswert des Zeigers um 1 erhöht wird. Wir testen diese
Vermutung durch ein kleines Programm:
void main()
{
A int *p = 0;
Der Zeiger wird mit 0 initialisiert (A). Dann werden 0, 1, 2 und 3 zum Zeigerwert
addiert und der Adresswert ausgegeben (B–E).
230
8.1 Zeigerarithmetik
p + 0 = 0
p + 1 = 4
p + 2 = 8
p + 3 = 12
Der Adresswert des Zeigers erhöht sich offensichtlich jedes Mal um 4. Dies hat damit
zu tun, dass es sich um einen Zeiger auf int handelt und eine int-Zahl 4 Bytes im
Speicher belegt. Allgemein gilt der folgende Zusammenhang:
Wenn man zu einem Zeiger 1 addiert, erhöht sich der Adresswert um die Größe
des Datentyps, auf den der Zeiger zeigt. Bei Addition oder Subtraktion beliebi-
ger ganzer Zahlen ändert sich der Adresswert um entsprechende Vielfache der 8
Größe des referenzierten Datentyps.
Würde man das oben gezeigte Beispiel mit einem Zeiger auf char anstelle eines Zei-
gers auf int umsetzen, wäre das Ergebnis entsprechend anders, weil der Datentyp
char auf unserem Rechner 1 Byte im Speicher belegt:
void main()
{
A char *p = 0;
In (A) haben wir nun einen im Vergleich zum letzten Beispiel geänderten Datentyp.
Damit erhalten wir auch ein geändertes Ergebnis:
p + 0 = 0
p + 1 = 1
p + 2 = 2
p + 3 = 3
Zwei Zeiger zu addieren macht keinen Sinn, aber die Differenz zwischen zwei Zeigern,
die auf den gleichen Typ zeigen, liefert durchaus einen sinnvoll zu interpretierenden
Abstand.
Das Rechnen mit Zeigern ist besonders nützlich, wenn wir mit Arrays arbeiten. Damit
beschäftigen wir uns im nächsten Abschnitt.
231
8 Zeiger und Adressen
a + 0 a[0]
+ 1 a[1]
+ 2 a[2]
+ 3 a[3]
+ 4 a[4]
+ 5 a[5]
+ 6 a[6]
+ 7 a[7]
Ist a ein Array, ist a zugleich ein Zeiger auf das erste Element des Arrays.
Es gilt also: *a = a[0]
Wegen der oben beschriebenen Gesetze der Zeigerarithmetik folgt dann für einen
Index i:
Ist a ein Array, ist a+i ein Zeiger auf das i-te Element des Arrays.
Es gilt also: *(a+i) = a[i]
a + 0 a[0] = *(a+0)
+ 1 a[1] = *(a+1)
+ 2 a[2] = *(a+2)
+ 3 a[3] = *(a+3)
+ 4 a[4] = *(a+4)
+ 5 a[5] = *(a+5)
+ 6 a[6] = *(a+6)
+ 7 a[7] = *(a+7)
232
8.2 Zeiger und Arrays
Die Array- und die Zeigernotation können also synonym verwendet werden. Als Bei-
spiel erstellen wir eine Funktion, die berechnet, wie viele 'a' in einem String vorkom-
men, einmal in Array- und einmal in Zeigernotation. Zur Array-Notation muss nichts
gesagt werden, so haben wir es ja schon immer gemacht:
void main()
{
int anz;
anz = zaehle( "Panamakanalaal");
printf( "%d a gefunden\n", anz);
}
233
8 Zeiger und Adressen
In der Schnittstelle der Funktion finden wir string als Zeiger auf char (A). Das erste
Zeichen im String ist *string (B), und string++ rückt den Zeiger auf dasnächste Zei-
chen vor (B). Als Ergebnis erhalten wir:
7 a gefunden
Sie sehen, dass der Index, den wir bei der Array-Notation verwendet hatten, hier
überflüssig ist, weil wir den an der Schnittstelle übergebenen Zeiger Zeichen für Zei-
chen durch den zu untersuchenden Text schieben können, bis wir auf den Termina-
tor stoßen. Um noch einmal zu demonstrieren, wie elegant man mit Zeigern in
einem String operieren kann, erstellen wir eine Funktion zur Palindromerkennung:
void main()
{
int ok;
ok = palindrom( "retsinakanister");
if( ok == 1)
printf( "Palindrom erkannt\n");
}
Die Funktion erhält den Zeiger auf den zu untersuchenden Text (A). Zuerst werden
Hilfszeiger deklariert, die auf den Anfang des Textes gesetzt werden (B).
Der Zeiger hinten wird bis zum Terminator vorgeschoben und dann wieder ein Zei-
chen zurückgesetzt (C und D). Damit zeigt er auf das letzte Zeichen.
234
8.3 Funktionszeiger
Die Zeiger laufen vorwärts bzw. rückwärts durch den Text, bis sie sich in der Mitte
treffen. Werden dabei Unterschiede festgestellt, ist es kein Palindrom (E bis F).
Ansonsten handelt es sich um ein Palindrom, und es erfolgt eine entsprechende
Rückgabe (G). Das Hauptprogramm erzeugt dann die folgende Ausgabe:
Palindrom erkannt
Wenn Sie diesen Abschnitt aufmerksam gelesen haben, dann werden Sie jetzt wissen,
warum wir den skalaren Variablen beim Einlesen mit scanf ein &-Zeichen vorange-
stellt haben und warum wir das bei Arrays nicht gemacht haben. Wir haben die
Adresse von skalaren Variablen übergeben, damit die Funktion scanf den Eingabe-
wert in die Variable eintragen konnte. Bei Arrays war das nicht erforderlich, weil der 8
Name des Arrays bereits als Zeiger behandelt wird.
8.3 Funktionszeiger
Nicht nur Variablen, sondern auch Funktionen haben eine Adresse im Speicher. Kon-
sequenterweise kann man die Adresse einer Funktion in einer Variablen speichern
und die Funktion dann aus der Variablen heraus aufrufen. Dies führt zu einer außer-
ordentlich wichtigen und zugleich eleganten Programmiertechnik, die wir uns
anhand eines einfachen Beispiels Schritt für Schritt erarbeiten möchten. Wir werden
eine Funktion erstellen, die wahlweise das Minimum oder das Maximum einer Reihe
von Zahlen berechnet. Ausgangspunkt sind Grundfunktionen zum Berechnen von
Minimum und Maximum zweier Zahlen:
235
8 Zeiger und Adressen
Jetzt erstellen wir eine Funktion, die wahlweise das Minimum oder das Maximum
einer Zahlenreihe berechnet. Dazu übergeben wir an der Schnittstelle einen zusätzli-
chen Parameter, über den gesteuert wird, ob das Minimum oder das Maximum
bestimmt werden soll. Die Zahlenreihe selbst befindet sich in einem Array:
m = daten[0];
for( i = 1; i < anz; i++)
{
if( modus == 1)
B m = minimum( m, daten[i]);
else
C m = maximum( m, daten[i]);
}
return m;
}
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
In der Funktion wird über den Parameter modus gesteuert, ob das Minimum oder das
Maximum berechnet werden soll (A). Die Unterscheidung erfolgt dann in (B) und (C).
Als Test rufen wir die Funktion suche mit unterschiedlichem modus auf und erhalten
das Ergebnis:
Minimum: –12
Maximum: 32
236
8.3 Funktionszeiger
Jetzt kommt der entscheidende Schritt. Anstatt einen Modus zu übergeben und in
der Schleife entsprechend dem Modus zu verzweigen, können wir auch direkt die zu
verwendende Funktion übergeben. Dabei stellt sich die Frage, wie ein Parameter
deklariert werden muss, der eine Funktion (besser Funktionsadresse) transportieren
soll. Damit eine Typüberprüfung durch den Compiler durchgeführt werden kann,
müssen eingehende Parameter und der Returntyp der übergebenen Funktion in der
Parameterdeklaration festgelegt werden. Die Funktionen minimum und maximum haben
eine identische Schnittstelle, wobei es auf die Benennung der Schnittstellenvariablen
nicht ankommt. Hier geht es nur um die Struktur der Schnittstelle.
fkt ist eine Funktion, die zwei int-Werte übergeben bekommt und einen int-
Wert zurückgibt.
Diese allgemeine Beschreibung passt jetzt sowohl auf die Funktion minimum als auch
auf die Funktion maximum. Auf die gleiche Weise können wir auch andere Funktionen
mit andersartiger Parameterstruktur beschreiben. Wir stellen unser aktuelles Bei-
spiel auf die Verwendung von Funktionszeigern um:
A int suche( int anz, int *daten, int fkt( int, int))
{
int i, m;
m = daten[0];
for( i = 1; i < anz; i++)
B m = fkt( m, daten[i]);
return m;
}
237
8 Zeiger und Adressen
void main()
{
int zahlen[10] = {1, –12, 31, 17, –11, 0, 22, 9, 4, –7};
int min, max;
Im Parameter fkt wird eine Funktion übergeben, die zwei int-Werte übergeben
bekommt und einen int-Wert zurückgibt (A). Diese im Parameter fkt übergebene
Funktion wird in (B) aufgerufen.
Beim Aufruf der Funktion suche wird dann im dritten Parameter die bei der Suche zu
verwendende Hilfsfunktion übergeben (C und D). Wir erhalten wieder das bekannte
Ergebnis:
Minimum: –12
Maximum: 32
Welche Funktion in der suche-Funktion gerufen wird, entscheidet sich erst zur Lauf-
zeit anhand der übergebenen Funktionsadresse, der Compiler überwacht dabei nur,
dass an der Schnittstelle ausschließlich Funktionen mit der passenden Parameter-
struktur verwendet werden. Das ist ein grundsätzlicher Unterschied zur bisherigen
Verwendung von Funktionen. Bisher war immer schon zur Compile-Zeit erkennbar,
welche Funktion gerufen wird. Immer, wenn Sie Entscheidungen vom Compiler in
die Laufzeit verlegen, gewinnen Sie an Flexibilität und verlieren dafür an Ausfüh-
rungsgeschwindigkeit. Durch Bereitstellung einer geeigneten Funktion konnten wir
die Suchfunktion auf Minimumsuche oder Maximumsuche einstellen. Man nennt
diese Art zu programmieren auch Callback und die übergebenen Funktionen Call-
back-Funktionen. In diesem Bild sagt man zur Funktion suche:
Such bitte eine Zahl in diesem Array mit zehn Zahlen, und wenn du bei zwei
Zahlen entscheiden musst, welche zu nehmen ist, dann frag doch bitte bei mir
über meine Callback-Funktion (Rückruf-Funktion) nach.
Callback-Funktionen sind ein wichtiges Prinzip bei der Erstellung von Softwaresyste-
men. Zum Beispiel werden Callbacks bei grafischen Benutzeroberflächen häufig ver-
wendet, um an die Eingaben des Benutzers spezielle Aktionen (Funktionen) des
238
8.4 Aufgaben
Programms zu binden. Wenn das System dann zur Laufzeit feststellt, dass der Benut-
zer z. B. mit der Maus geklickt hat, ruft es eine bestimmte, vom Programmierer für
diesen Fall bereitgestellte Callback-Funktion.
8.4 Aufgaben
A 8.1 Schreiben Sie eine Funktion, die ein Array von Gleitkommazahlen übergeben
bekommt und die größte Zahl, die kleinste Zahl und den Mittelwert aller Zah-
len zurückgibt.
A 8.2 Schreiben Sie eine Funktion, die ein Array von Integer-Zahlen übergeben 8
bekommt und auf allen Zahlen des Arrays eine konfigurierbare Operation aus-
führt. Die auszuführende Operation soll der Funktion über einen Funktions-
zeiger mitgeteilt werden. Die übergebene Funktion soll einen int-Wert als
Parameter erhalten und einen int-Wert zurückgeben. Testen Sie Ihr Pro-
gramm mit folgenden Operationen:
왘 Integer-Division durch 2
왘 Rest bei Division durch 2
왘 Ausgabe auf dem Bildschirm
A 8.3 Erstellen Sie eine rekursive Funktion zur Berechnung der Länge eines Strings,
die konsequent auf die Verwendung von Zeigern setzt.
A 8.4 Erstellen Sie eine rekursive Funktion zum Vergleich zweier Strings, die konse-
quent auf die Verwendung von Zeigern setzt.
A 8.5 Erstellen Sie eine rekursive Lösung der Aufgabe 7.8, die konsequent auf die
Verwendung von Zeigern setzt.
a = add( 5, 1, 2, 3, 4, 5);
a = add( 6, 2,-2, 7,-1, 4, 0);
a = add( 7, 0,-4,-2, 8, 1, 5, 6);
239
8 Zeiger und Adressen
Wir wollen die Funktion add immer so rufen, dass im ersten Parameter, der ja
auf jeden Fall vorhanden ist, die Anzahl der noch folgenden Parameter steht.
Versuchen Sie jetzt, die Funktion add so zu programmieren, dass sie die
Summe der auf den ersten Parameter folgenden Parameterwerte berechnet
und zurückgibt. In unseren Beispielen sollen also für a die folgenden Werte
berechnet werden:
A 8.7 Ganze Zahlen werden im Rechner als Folge von Bytes abgelegt. Damit ist aber
noch nicht festgelegt, in welcher Reihenfolge die Bytes einer Zahl gespeichert
werden. Eine 2-Byte-Zahl wird an zwei aufeinanderfolgenden Speicheradres-
sen abgelegt. Aber steht das höherwertige Byte an der kleineren oder der grö-
ßeren Adresse?
왘 Wenn das niederwertige Byte an der kleineren Adresse und das höherwer-
tige Byte an der größeren Adresse steht, spricht man vom Little-Endian-
Format.
왘 Wenn das höherwertige Byte an der kleineren Adresse und das niederwer-
tige Byte an der größeren Adresse steht, spricht man vom Big-Endian-
Format.
Schreiben Sie ein Programm, das feststellt, ob Ihre Hardware eine Big- oder
Little-Endian-Darstellung verwendet.
240
Kapitel 9
Programmgrobstruktur
Ich bin von je der Ordnung Freund gewesen.
– Johann Wolfgang von Goethe
Bisher besteht für uns ein Programm aus einer einzigen Quellcodedatei, in der wir das
Hauptprogramm und alle Unterprogramme finden. Spätestens aber dann, wenn wir 9
ein Programm mit zwei oder mehr Personen parallel entwickeln wollen, sind wir ge-
zwungen, den Quellcode auf mehrere Dateien aufzuteilen. Das wird dann aber zwangs-
läufig dazu führen, dass z. B. eine Funktion in einer anderen Quellcodedatei steht als
die Aufrufe dieser Funktion. Wie erfährt der Compiler, der ja jede Quellcodedatei ein-
zeln übersetzt, welche Funktionen es anderweitig gibt und welche Schnittstellen sie
haben? Im Grunde genommen waren wir von Anfang an mit diesem Problem kon-
frontiert – wir haben es bisher nur ignoriert. Funktionen wie scanf oder printf stehen
ja auch nicht in unserem Quellcode, sondern »irgendwo anders«. Das Geheimnis liegt
in den Include-Anweisungen am Anfang unseres Programms (A und B):
A # include <stdio.h>
B # include <stdlib.h>
void main()
{
...
...
...
}
Diese und weitere Anweisungen dieser Art werden Sie jetzt kennenlernen.
241
9 Programmgrobstruktur
9.1.1 Includes
Mit einer Include-Direktive können komplette Dateien vor der Übersetzung in den
Quellcode eingefügt (inkludiert) werden. Üblicherweise handelt es sich dabei um
sogenannte Header-Dateien. Diese Dateien erkennen Sie an der Dateinamenserwei-
terung .h. Grundsätzlich müssen Sie zwischen System-Header-Dateien und Projekt-
Header-Dateien unterscheiden. System-Header-Dateien sind Dateien, die mit dem
Compiler oder mit speziellen System- oder Entwicklungskomponenten geliefert wer-
den und auf Ihrem Entwicklungsrechner bereits installiert sind. Diese Dateien liegen
in speziellen Systemverzeichnissen, die Ihrer Entwicklungsumgebung bekannt sind1.
Projekt-Header-Dateien sind Header-Dateien, die Sie als Programmierer in Ihrem
Projekt selbst erstellen. Diese Dateien liegen zusammen mit den von Ihnen ebenfalls
erstellten Quellcodedateien im Projektordner Ihres Projekts.
stdio.h
Syste
m
# include <stdio.h>
# include "header.h"
heade
r.h
void main()
{
…
…
Proje …
kt
}
1 Wie man eine Entwicklungsumgebung konfiguriert, damit diese Dateien gefunden werden, wer-
den wir hier nicht behandeln.
242
9.1 Der Präprozessor
Wenn ich gesagt habe, dass eine Header-Datei durch den Compiler in den Quellcode
eingefügt wird, ist das nicht ganz richtig, da eine Quellcodedatei durch den Compiler
nie verändert wird. Der Compiler nimmt beim Lesen der Quellcodedatei nur einen
»Umweg« durch die inkludierte Datei. Sie können sich das aber so vorstellen, als
würde die Datei anstelle der Include-Direktive stehen:
// Quellcodedatei
…
…
# include "header1.h
…
…
… // header1.h
… …
… …
… # include "header2.h
… …
…
…
// header2.h
…
…
…
Sie werden sich vielleicht fragen, warum man dann nicht einfach anstelle der
Include-Direktive direkt den Inhalt der Header-Datei hinschreibt. Das hat einen ein-
fachen Grund. Die Header-Dateien sind so etwas wie die »allgemeinen Geschäftsbe-
dingungen (AGB)« Ihres Programms. Stellen Sie sich vor, dass Sie einen Webshop
programmieren, bei dem auf hunderten von Seiten auf die allgemeinen Geschäftsbe-
dingungen hingewiesen werden muss. Wenn Sie an diesen Stellen immer die voll-
ständigen AGB einfügen würden, wäre das zwar prinzipiell möglich, aber höchst
problematisch. Das Problem würde evident, sobald Sie die AGB ändern müssten.
Dann müssten Sie auf hunderten von Seiten die AGB aktualisieren, was am Ende
garantiert zu inkonsistenten Geschäftsgrundlagen führen würde.
243
9 Programmgrobstruktur
Symbolische Konstanten
# define PI 3.14
# define MAX 10
# define MIN MAX/2
Im oben dargestellten Beispiel wird durch die symbolische Konstante MAX sicherge-
stellt, dass die Größe und die Initialisierung des Arrays a immer aufeinander abge-
stimmt sind. Es wird verhindert, dass bei einer Vergrößerung oder Verkleinerung des
Arrays vergessen wird, die Initialisierungsschleife entsprechend anzupassen. Die
symbolische Konstante MIN greift auf den zuvor festgelegten Wert der symbolischen
Konstanten MAX zurück.
# define PLUS +
# define MAL *
int x; int x;
Präprozessor Compiler
x = 2 MAL (5 PLUS 1); x = 2 * (5 + 1);
244
9.1 Der Präprozessor
Wichtig ist nur, dass am Ende gültiger C-Code entsteht, da Sie ja noch kompilieren
wollen.
int x; int x;
Präprozessor Compiler
x = ZWEI*ZWEI; x = 1+1*1+1;
int x; int x;
Präprozessor Compiler
x = ZWEI*ZWEI; x = (1+1)*(1+1);
Wählen Sie die Namen für symbolische Konstanten so, dass Sie sie von Variablenna-
men unterscheiden können! Ein brauchbarer Ansatz ist es, für symbolische Konstan-
ten nur Großbuchstaben und für Variablennamen nur Kleinbuchstaben zu
verwenden. Symbolische Konstanten, die übergreifend in mehreren Programmda-
teien benötigt werden, gehören natürlich in eine Header-Datei, damit sie dort zentral
gepflegt werden können.
9.1.3 Makros
Häufig benötigt man in einem Programm »Minifunktionen«. Das Dilemma mit sol-
chen Funktionen ist, dass man sich die einheitliche Verarbeitung durch eine Funk-
tion wünscht, ohne die zusätzlichen Laufzeitkosten für einen Funktionsaufruf in
245
9 Programmgrobstruktur
Kauf nehmen zu wollen. Eine gewisse Abhilfe schaffen hier die sogenannten Makros.
Makros stellen eine Verallgemeinerung symbolischer Konstanten dar und können
zusätzliche Parameter enthalten:
Macro
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*(r)*(r))
double x;
double x; Präprozessor Compiler
x = KREIS_FLAECHE( 5); x = (3.14*(5)*(5));
Makros können auch mehrere Parameter haben. Setzen Sie bei arithmetischen Aus-
drücken um die Parameter immer Klammern, da Sie nicht wissen, was dort als Para-
meter einmal eingesetzt wird, und weil durch den Präprozessor keine Ausdrücke
ausgewertet werden. Das Weglassen der Klammern im oben dargestellten Beispiel
kann zu einer sicherlich nicht gewünschten Auflösung führen:
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*r*r)
double x;
double x; Präprozessor Compiler
x = KREIS_FLAECHE( 1+1); x = (3.14*1+1*1+1);
Setzen Sie, wie schon bei den symbolischen Konstanten, immer Klammern um das
gesamte Makro, da Sie nicht wissen, wo das Makro überall eingesetzt wird.
# define PI 3.14
# define KREIS_FLAECHE( r) (PI*(r)*(r))
double x;
double x; Präprozessor Compiler
int a = 1; x = (3.14*(a++)*(a++));
x = KREIS_FLAECHE( a++);
Die zweimalige Erhöhung des Werts von a ist in diesem Beispiel sicher nicht gewollt.
246
9.1 Der Präprozessor
Verschleiern Sie dies nicht, indem Sie gleiche Namenskonventionen für Makros und
Funktionen verwenden! Gehen Sie ähnlich vor wie bei Variablen und symbolischen
Konstanten, und schreiben Sie Funktionsnamen immer klein und Makronamen
immer groß.
# undef TEST
int a = 1; int a = 1;
Präprozessor Compiler
# ifdef TEST a = a + 1;
printf( "a = %d\n", a);
# endif
Compileschalter TEST gesetzt
a = a + 1; # define TEST
int a = 1;
int a = 1;
# ifdef TEST Präprozessor Compiler
printf( "a = %d\n", a); printf( "a = %d\n", a);
# endif
a = a + 1;
a = a + 1;
Über den Compile-Schalter können Sie wahlweise eine Version mit Testausgabe
(define TEST) oder ohne Testausgabe (undef TEST) erzeugen. Dies nennt man bedingte
Kompilierung. Beachten Sie, dass, im Gegensatz zu einer Fallunterscheidung, der
nicht auszuführende Code vollständig aus dem Quellcode entfernt wird, bevor der
Compiler das Programm übersetzt. Weitere mögliche Direktiven für Compile-Schal-
ter sind:
247
9 Programmgrobstruktur
Mit Compile-Schaltern können Sie das oben bereits angesprochene Problem mehrfa-
cher oder rekursiver Includes ein und derselben Header-Datei lösen. Dazu wählen Sie
zu jeder Header-Datei einen projektweit eindeutigen Namen (im Beispiel HEADER_H)
und versehen jede Header-Datei in der folgenden Weise mit einem Rahmen aus
Direktiven:
HEADER_H
ist nicht definiert.
248
9.2 Ein kleines Projekt
Beim ersten Include wird die Header-Datei, da HEADER_H noch undefiniert ist, ganz
normal durchlaufen. Dabei wird allerdings HEADER_H definiert. Beim nächsten Ver-
such eines Includes derselben Datei ist dann HEADER_H definiert, und der Inhalt der
Datei wird ausgeblendet.
왘 Direktiven
왘 Deklarationen
왘 Definitionen
Direktiven richten sich an den Präprozessor. Es handelt sich dabei um die oben disku-
tierten #-Anweisungen.
Deklarationen sind Vereinbarungen, die nur über die Definition gewisser Objekte
informieren. Zum Beispiel handelt es sich bei einem Funktionsprototyp oder einem
Externverweis auf eine globale Variable um Deklarationen.
Definitionen dagegen sind Vereinbarungen, die konkrete Objekte und damit Code
erzeugen. Zum Beispiel handelt es sich bei einer Funktionsimplementierung oder
dem Anlegen einer Variablen um Definitionen. Definitionen sind immer zugleich
auch Deklarationen.
Jetzt werfen wir einen Blick auf das Miniprojekt, in dem es drei Dateien (maximum.h,
maximum.c und main.c) gibt (siehe Abbildung 9.12).
249
9 Programmgrobstruktur
maximum.h
Hier werden die globale Variable
# ifndef MAXIMUM_H absolutes_maximum und die
# define MAXIMUM_H Funktion maximum deklariert.
Das Hauptprogramm unseres Projekts befindet sich in der Datei main.c. Hier wird
maximum.h inkludiert, und es wird auf die Funktion und die globale Variable des
Maximum-Moduls zugegriffen. Die Header-Datei limits.h wird hier nicht benötigt
und muss nicht inkludiert werden. Würde man limits.h aus irgendeinem Grund hier
250
9.2 Ein kleines Projekt
ebenfalls benötigen, hätte man das Include von limits.h in die Header-Datei maxi-
mum.h gelegt. Dann würde jeder, der maximum.h inkludiert, automatisch limits.h
mit inkludieren.
Ich hoffe, dass Sie jetzt verstehen, wie Header- und Source-Dateien zusammenspie-
len und sich voneinander abgrenzen. In einer Header-Datei stehen nur Direktiven
und Deklarationen. Definitionen sollten in einer Header-Datei nicht stehen, da die
dort definierten Objekte, sobald die Header-Datei von mehreren Quellcodedateien
inkludiert würde, doppelt angelegt würden. Das würde der Linker nicht akzeptieren.
In einer Quellcodedatei können Direktiven, Deklarationen und Definitionen stehen.
Eine Quellcodedatei ohne eine Definition ist sinnlos. In einer Quellcodedatei sollten
aber nur Direktiven und Deklarationen stehen, die ausschließlich in dieser Datei
benötigt werden. Alle Deklarationen und Direktiven, die in mehr als einer Quellcode- 9
datei benötigt werden, gehören in eine Header-Datei.
Einige der hier diskutierten Konzepte mögen Ihnen im Moment unmotiviert, viel-
leicht sogar überflüssig erscheinen. Das ist verständlich, da wir bisher nur kleine Pro-
gramme erstellt und den Programmcode immer in einer Datei zusammengehalten
haben. Eine Aufteilung wie in unserem Mikroprojekt wirkt künstlich und aufgesetzt.
Die Programmerstellung wird sich aber nicht auf Dauer in einem so kleinen und
überschaubaren Rahmen bewegen. Spätestens, wenn mehrere Programmierer an
einem Programm arbeiten oder wenn Programmteile entstehen, die in unterschied-
lichem Zusammenhang Verwendung finden sollen, ist es unumgänglich, eine Auftei-
lung auf mehrere Dateien vorzunehmen. Sie sollten sich daher bereits »im Kleinen«
an die später »im Großen« zwingend notwendigen Maßnahmen gewöhnen.
251
Kapitel 10
Die Standard C Library
Ich habe mir das Paradies immer als eine Art Bibliothek vorgestellt.
– Jorge Luis Borges
Eine der Entwurfsideen bei der Entwicklung von C war, den Sprachumfang so klein
wie möglich zu halten. C enthält darum im Gegensatz zu vielen anderen Program-
miersprachen keine Sprachelemente zur Dateibearbeitung, zur Bildschirmausgabe 10
oder zur Bearbeitung von Zeichenketten. Dies und vieles mehr wird in C durch Funk-
tionsbibliotheken erledigt. Wir wollen uns hier nur mit der sogenannten Standard C
Library (auch C Runtime Library) beschäftigen. Diese Funktionsbibliothek enthält
einige hundert Funktionen und ist ebenso wie die Sprache C selbst durch die ANSI1
normiert. Sie können also davon ausgehen, dass die Funktionen dieser Bibliothek in
jeder C-Programmierumgebung dem Standard entsprechend verfügbar sind. Ich
kann hier natürlich nicht jede Funktion dieser Library besprechen und gebe Ihnen
daher nur einen groben Überblick über eine Auswahl von Funktionen. Wenn Sie den
Sinus oder die Wurzelfunktion, die aktuelle Uhrzeit oder das Datum in einem Ihrer
Programme benötigen, schauen Sie zuerst immer in der Runtime Library nach, ob
geeignete Funktionen nicht bereits vorhanden sind. Alle Details über diese Funktio-
nen entnehmen Sie dann Ihren Compiler-Handbüchern, dem Hilfesystem Ihrer Ent-
wicklungsumgebung oder einer der zahlreichen Informationsseiten im Internet.
Dort finden Sie auch Informationen darüber, welche Headerfiles Sie in Ihrem Quell-
code inkludieren müssen, um die jeweiligen Funktionen, ihren Prototypen entspre-
chend, korrekt verwenden zu können.
Im Folgenden werden wir einige wichtige Funktionen der Runtime Library heraus-
greifen und Ihnen anhand von Beispielen vorstellen. Viele dieser Funktionen sind
erst von Interesse, wenn sie für konkrete Probleme benötigt werden. Wenn Sie z. B.
nicht unmittelbar planen, ein Programm mit trigonometrischen Berechnungen zu
erstellen, müssen Sie sich jetzt nicht mit Sinus und Cosinus beschäftigen. In der
C-Programmierung würde Sie das nicht weiterbringen. Auf keinen Fall überspringen
sollten Sie aber die Abschnitte:
왘 Stringoperationen
왘 Freispeicherverwaltung
253
10 Die Standard C Library
Diese Abschnitte sind für das weitere Verständnis von zentraler Bedeutung.
A # include <math.h>
void main()
{
double x, y, z;
x = 1.2;
y = 3.4;
B z = sqrt(x*x + y*y);
printf( "z = %f\n", z);
C z = sqrt(exp(x) + y);
printf( "z = %f\n", z);
D z = fabs( pow(sin(x)+cos(y*y),5));
printf( "z = %f\n", z);
}
Durch die Einbindung von math.h (A) werden die mathematischen Funktionen zur
Verwendung eingebunden. Dies darf nicht vergessen werden, da die verwendeten
Funktionen sonst nicht verfügbar sind.
Im Programm werden dann die Werte für die folgenden Formeln berechnet und aus-
gegeben:
2 2
왘 z = x + y (B)
x
왘 z = e + y (C)
2 5
왘 z = ( sin ( x ) + cos ( y ) ) (D)
254
10.1 Mathematische Funktionen
Z = 3.605551
Z = 2.592319
Z = 6.793692
A # include <stdlib.h>
void main()
{
int seed, wurf, i; 10
B seed = 4711;
C srand( seed);
Zuerst erfolgt die Einbindung der Bibliothek zur Verwendung der Funktionen srand
und rand (A). Die Funktion srand initialisiert den Zufallszahlengenerator mit einem
Startwert, den Sie frei wählen können (B und C). Danach können mit der Funktion
rand Zufallszahlen2 abgerufen werden. Die Funktion rand liefert eine ganze Zahl, die
Sie noch in den Bereich von 1–6 bringen müssen, damit sie einem gültigen Wurf eines
Würfels entspricht (D). Dazu bilden Sie den Rest bei Division durch 6 und erhalten
eine Zufallszahl zwischen 0 und 5. Wenn Sie jetzt noch 1 addieren, bekommen Sie
eine Zufallszahl im Bereich zwischen 1 und 6. Wenn Sie also eine Zufallszahl zwischen
a und b benötigen, erreichen Sie dies mit dem Formelausdruck rand()%(b-a+1)+a. Das
Programm erzeugt die folgende Ausgabe:
2 Diese Zahlen werden natürlich durch einen Algorithmus berechnet und sind daher nicht wirklich
zufällig. Man spricht deshalb auch von Pseudozufallszahlen.
255
10 Die Standard C Library
1. Wurf: 3
2. Wurf: 1
3. Wurf: 5
4. Wurf: 1
5. Wurf: 4
Wenn Sie mit dem gleichen Startwert starten, erhalten Sie immer die gleiche Folge
von Zufallszahlen. Das ist durchaus wünschenswert, da man Programme häufig mit
Zufallswerten testet und nach einer Fehlerkorrektur mit der Testsequenz, die den
Fehler aufgedeckt hat, noch einmal testen will, um festzustellen, ob der Fehler nicht
mehr auftritt3.
A # include <ctype.h>
void main()
{
char text[100];
int u, l;
char *p;
256
10.3 Stringoperationen
Initial erfolgt ein Include zur Verwendung der Funktionen zur Zeichenkonvertierung
(A). Innerhalb des Programms erfolgen über die ganze eingegebene Zeichenkette das
Zählen der Großbuchstaben (B) und das Zählen der Kleinbuchstaben (C). Abschlie- 10
ßend wird die zeichenweise Konvertierung in Großbuchstaben (D) und in Kleinbuch-
staben (E) durchgeführt. Es ergibt sich z. B. die folgende Ausgabe:
Eingabe: AbCdEfGhIjKlMnOpQrStUvWxYz
13 Gross-, 13 Kleinbuchstaben
Gross: ABCDEFGHIJKLMNOPQRSTUVWXYZ
Klein: abcdefghijklmnopqrstuvwxyz
10.3 Stringoperationen
In vorangegangenen Abschnitten haben wir mehr oder weniger umständlich Funkti-
onen zur Feststellung der Stringlänge und zum Vergleichen von Strings erstellt.
Natürlich hält die Standard Library auch für diese Aufgaben fertige Funktionen
bereit.
257
10 Die Standard C Library
A # include <string.h>
void main()
{
B char eingabe[100];
char text[500];
C for( text[0] = 0; ; )
{
printf( "Eingabe: ");
scanf( "%s", eingabe);
Für die verwendeten Stringfunktionen der Standardbibliothek wird zuerst die ent-
sprechende Datei inkludiert (A). Innerhalb des Hauptprogramms werden die Puffer
für die Eingabe und den kumulierten Text angelegt (B). Beim Start der Schleife ist der
kumulierte Text leer und wird entsprechend gesetzt (C). Es erfolgt jeweils die Eingabe
neuer Textstücke, die Schleife endet, wenn »ende« eingegeben wird (D).
Nach jeder Eingabe wird geprüft, ob ausreichend Platz vorhanden ist (E). Ist dies der
Fall, wird der eingegebene Text an den kumulierten Text angefügt. Das Programm
erzeugt z. B. die folgende Ausgabe:
Eingabe: the
Eingabe: quick
Eingabe: brown
Eingabe: fox
Eingabe: jumps
Eingabe: over
Eingabe: the
Eingabe:lazy
Eingabe:dog
Eingabe: ende
thequickbrownfoxjumpsoverthelazydog
258
10.3 Stringoperationen
Zur Funktion strcmp (Stringvergleich) muss noch erwähnt werden, dass die Funktion
0 zurückgibt, wenn die beiden übergebenen Strings übereinstimmen, und dass bei
der Überprüfung zwischen Groß- und Kleinbuchstaben unterschieden wird. Genau
genommen, ist der Funktionswert die Differenz der beiden ersten Zeichen, in denen
sich die beiden Strings unterscheiden, oder 0, wenn sie sich nicht unterscheiden.
Dadurch liefert die Funktion strcmp nicht nur eine Information über die Gleichheit,
sondern auch über die lexikographische Ordnung4 der beiden Strings. Diese Infor-
mation können Sie z. B. zum Sortieren von Strings verwenden.
Ich möchte Sie an dieser Stelle auch noch einmal eindringlich daran erinnern, dass
bei der Verwendung von Operationen, die Strings verändern, immer die Gefahr des
Pufferüberlaufs besteht. Das rufende Programm muss dafür sorgen, dass die überge-
benen Puffer groß genug sind, damit es nicht zu einem Pufferüberlauf kommt. Im
Falle eines Pufferüberlaufs stürzt Ihr Programm in der Regel ab. Es könnte aber auch
10
in einem inkonsistenten Zustand weiterlaufen und später einen Fehler verursachen,
den Sie dann keiner Ursache mehr zuordnen könnten. Die beste Art, mit Fehlern die-
ser Art umzugehen, ist, sie erst gar nicht zu machen.
Beachten Sie auch, dass Sie im oben dargestellten Beispiel effizienter programmieren
können, wenn Sie bereits ermittelte Längen oder Zeiger auf Stringende nicht immer
wieder neu berechnen. Sie sollten daher immer im Blick behalten, dass bei einer
Stringoperation der zu untersuchende String Zeichen für Zeichen abgearbeitet wird.
Bei langen Strings kann das spürbar Laufzeit kosten, insbesondere, wenn die Opera-
tion in Schleifen vielfach aufgerufen wird. Betrachten Sie dazu das folgende Code-
fragment:
259
10 Die Standard C Library
Die Schleifenkonstruktion in (A) ist effizient, da nur einmal über den String iteriert
wird. Die Schleifenkonstruktion in (B) und (C) ist bereits weniger effizient, da zweimal
über den String iteriert wird. Die Schleife in (D) ist ineffizient, da so oft über den
String iteriert wird, wie der String Zeichen hat.
Ein- und Ausgabe müssen sich aber nicht unbedingt auf Tastatur oder Bildschirm
beziehen. Wir können Daten ja auch aus einer Datei einlesen und in eine Datei ausge-
ben. Für ein C-Programm macht das keinen großen Unterschied. Eingabequellen wie
Tastatur oder Datei und Ausgabeziele wie Bildschirm oder Datei sind aus Sicht eines
C-Programms sogenannte Streams. Obwohl es offensichtliche Unterschiede zwi-
schen Datei, Bildschirm und Tastatur gibt5, versucht das Laufzeitsystem, auf der ab-
straktesten Ebene die unterschiedlichen Streams einheitlich zu behandeln. Erst in
tieferen, systemnäheren Schichten, die wir aber nicht betrachten werden, werden die
Unterschiede sichtbar.
Die Funktion scanf liest ihre Eingaben vom Standard-Inputstream. Die Funktion
printf schreibt ihre Ausgaben auf den Standard-Outputstream. Diese Streams sind
dann mit der Tastatur bzw. dem Bildschirm verknüpft. Die Funktionen printf und
scanf nutzen diese Streams implizit. Wir können die Streams aber auch explizit nut-
zen. Dazu verwenden wir die Funktionen fscanf und fprintf. Diese Funktionen las-
sen sich in der folgenden Weise verwenden:
5 Zum Beispiel endet die Eingabe aus einer Datei, wenn die Datei vollständig gelesen ist. Die Einga-
bequelle »Tastatur« versiegt jedoch nie.
6 Genau genommen wird noch ein dritter Stream (stderr) geöffnet, aber der soll uns hier nicht
interessieren.
260
10.4 Ein- und Ausgabe
void main()
{
char name[100];
int alter;
A fprintf( stdout, "Bitte gib deinen Namen und dein Alter an: " );
B fscanf( stdin, "%s %d", name, &alter);
fprintf( stdout, "Du heisst %s und bist %d Jahre alt.\n", name, alter);
}
In (A) und (B) werden Funktionen wie printf und scanf verwendet, wobei im ersten
Parameter der Stream steht. 10
Das ist natürlich noch kein Gewinn, da es printf und scanf auch getan hätten, aber
Sie können eigene Streams öffnen und mit fscanf Daten aus diesen Streams lesen
und mit fprintf in diese Streams schreiben. Dazu müssen Sie nur zwei weitere Funk-
tionen kennenlernen. Mit fopen können Sie einen Stream aus einer Datei öffnen, und
mit fclose können Sie den Stream wieder schließen. Das sieht dann so aus:
# include <stdio.h>
# include <stdlib.h>
void main()
{
char token[100];
int counter = 0;
A FILE *pf;
if( pf == 0)
C return;
for( ; ; )
{
D fscanf( pf, "%s", token);
E if( feof( pf))
261
10 Die Standard C Library
E break;
counter++;
F printf( "Token %3d: %s \n", counter, token);
}
G fclose(pf);
}
Mit dem Datentyp für einen Stream legen wir die Variable pf an (A). fopen versucht,
die Datei zu öffnen, und gibt den Stream zurück (B). Dabei öffnet, in unserem Bei-
spiel, das Programm seine eigene Quellcodedatei (Test.c) zum Lesen ("r" steht für
read).
Falls die Datei nicht geöffnet werden konnte, wird das Programm beendet (D).
Ansonsten wird jeweils ein Wort aus der Datei gelesen (D). Erst wenn nichts mehr
gelesen werden konnte, wird die Schleife beendet (E). Im anderen Fall wird das Wort
auf dem Bildschirm ausgegeben (F). Nach Verlassen der Schleife wird die Datei wieder
geschlossen (G). Wir erhalten damit diese Ausgabe:
Token 1: #
Token 2: include
Token 3: <stdio.h>
Token 4: #
Token 5: include
Token 6: <stdlib.h>
Token 7: void
Token 8: main()
Token 9: {
...
Beim Aufruf von fopen übergeben wir zwei Parameter. Bei dem ersten handelt es sich
um den Dateinamen, dem auch ein Dateipfad vorangestellt sein könnte, und beim
zweiten Parameter können wir festlegen, ob die Datei zum Lesen ("r") oder zum
Schreiben ("w") geöffnet werden soll. Weitere Öffnungsmodi sind möglich, werden
hier aber nicht diskutiert. Die Funktion gibt einen Zeiger (einen sogenannten File-
pointer) zurück, der, wenn er nicht 0 ist, auf den geöffneten Stream zeigt. Über diesen
Zeiger greifen dann alle nachfolgenden Funktionen auf die Datei zu. Die Funktion
feof7 testet, ob ein Leseversuch hinter dem Dateiende gemacht wurde. Ist das der
Fall, wird die Leseschleife abgebrochen. Am Ende des Hauptprogramms wird die
Datei mit fclose geschlossen. Auch wenn das Laufzeitsystem zum Programmende
262
10.5 Variable Anzahl von Argumenten
alle offenen Streams schließt, ist es guter Stil, Streams zu schließen, sobald man sie
nicht mehr benötigt, da dann die durch den Stream belegten Systemressourcen frei-
gegeben werden können.
In unserem Beispiel haben wir nur Daten gelesen, aber völlig analog können Sie
natürlich auch in eine zum Schreiben geöffnete Datei mit fprintf Daten schreiben.
Mehrere Dateien gleichzeitig zum Lesen und/oder Schreiben geöffnet zu halten, ist
selbstverständlich auch möglich; Sie müssen nur für jede Datei eine eigene Zeigerva-
riable anlegen.
Das war ein kurzer Einblick in die Dateioperationen der Standardbibliothek. Es gibt
viele weitere Funktionen, die auch den Zugriff auf systemnäheren Ebenen ermögli-
chen. Auch auf die vielfältigen Möglichkeiten, die Ein- bzw. Ausgabe über den For-
matstring zu gestalten, bin ich nicht eingegangen. Ich habe mich bewusst sehr kurz
gefasst, weil diese Dateioperationen nicht so wichtig sind, wie man auf den ersten 10
Blick vermuten könnte. Bei großen Datenmengen, die Sie flexibel in einem Pro-
gramm verwalten möchten, verwenden Sie Datenbanken, und auch bei einfachen
Dateien finden Sie heute in der Regel Strukturen wie z. B. XML. Versuchen Sie erst gar
nicht, eine XML-Datei mit diesen Dateioperationen einzulesen. Dafür gibt es soge-
nannte XML-Parser, die in eigenen Bibliotheken frei verfügbar sind und diese Auf-
gabe viel eleganter und effizienter erledigen.
# include <stdio.h>
# include <stdlib.h>
A # include <stdarg.h>
263
10 Die Standard C Library
Sie wissen sicher noch, dass die einer Funktion übergebenen Parameter auf dem
Stack liegen. Wenn wir von dem zwingend notwendigen ersten Parameter die
Adresse nehmen, haben wir einen Zeiger in den Stack. Wenn wir zusätzlich den
Datentyp des ersten Parameters kennen und wissen, wie der Stack auf unserem Sys-
tem organisiert ist, können wir daraus die Adresse des ersten unspezifizierten Para-
meters ermitteln. Genau das macht das Makro va_start (D) mit dem in (C)
angelegten Stackpointer für den Parameterzugriff. In der Schleife über alle unspezifi-
zierten Parameter (E) rückt das Makro va_arg dann den Zeiger entsprechend dem
zuletzt betrachteten Datentyp weiter (F). Nach dem Durchlaufen der Schleife wird die
Stack-Operation beendet (G).
Wenn ich Ihnen nicht genau sage, wie diese Berechnungen im Einzelnen aussehen,
liegt das daran, dass hier auf verschiedenen Rechnerarchitekturen unter Umständen
unterschiedliche Berechnungen ausgeführt werden müssen. Wenn es Sie interes-
siert, wie das auf Ihrem System gemacht wird, schauen Sie in die Header-Datei
stdarg.h. Im Hauptprogramm können wir jetzt die Funktion summe mit unterschiedli-
cher Parameterzahl rufen:
void main()
{
int a=1, b=2, c=3, d=4;
int x;
x = summe( 2, a, b);
printf( "%d\n", x);
x = summe( 3, a, b, c);
printf( "%d\n", x);
x = summe( 4, a, b, c, d);
printf( "%d\n", x);
}
264
10.6 Freispeicherverwaltung
Die Funktion summe wird im Programm mit unterschiedlich vielen Parametern aufge-
rufen:
3
6
10
Wichtig ist, dass wir im ersten Parameter mitteilen, wie viele unspezifizierte Parame-
ter folgen. Eine Funktion mit variabler Argumentzahl muss irgendwo die Informa-
tion erhalten, mit wie vielen Parametern sie aufgerufen wurde. In unserem Fall
übergeben wir diese Zahl explizit als ersten Parameter. Bei printf ist das z. B. nicht so.
Die Funktion printf wertet den Formatstring aus, und immer wenn sie aufgrund
eines %-Zeichens einen neuen Parameter eines bestimmten Typs benötigt, holt sie
sich ihn vom Stack. Das Beispiel von printf zeigt auch, dass eine Funktion mit variab- 10
ler Argumentliste eine heterogene Parameterstruktur haben kann. Die Funktion
muss aber erkennen können, mit wie vielen Parametern welchen Typs sie gerufen
wurde, um den Stack richtig zu interpretieren.
10.6 Freispeicherverwaltung
Die größte Beschränkung, die unsere Programme derzeit noch haben, ist, dass wir
zur Compilezeit bereits wissen und festlegen müssen, welches Datenvolumen wir im
Speicher verarbeiten wollen. Besonders störend ist uns das bei Arrays aufgefallen.
Wir müssen zur Compilezeit entscheiden, wie viele Elemente ein Array haben soll
und können zur Laufzeit nichts mehr an dieser Entscheidung ändern. Von diesen
Fesseln kann man sich durch die Funktionen malloc, calloc, realloc und free
befreien. Mit malloc und calloc kann man sich zur Laufzeit dynamisch Speicher
holen (allokieren), mit realloc kann man diesen Speicher vergrößern und mit free
wieder freigeben.
Als erstes Beispiel zur Verwendung dieser Funktionen schreiben wir ein früher
bereits erstelltes Programm, das vom Benutzer eingegebene Zahlen in umgekehrter
Reihenfolge wieder ausgibt. Diesmal wollen wir aber den Benutzer vor der Eingabe
entscheiden lassen, wie viele Zahlen er eingeben will:
void main()
{
A int *p;
int anz, i;
printf( "Wie viele Zahlen: ");
scanf( "%d", &anz);
B p = (int *)malloc( anz*sizeof(int));
265
10 Die Standard C Library
Wir starten mit einem Zeiger auf Integer, da wir ja Integer-Zahlen in einem Array ver-
walten wollen8 (A). Mit der Funktion malloc wird dann Speicher allokiert und dem
Zeiger zugewiesen (B). Dazu teilen wir der Funktion mit, wie viel Bytes Speicher
(anz*sizeof(int)) wir benötigen. Der sizeof-Operator sagt uns, wie viele Bytes eine
Integer-Zahl belegt. Die Funktion malloc kann nicht wissen, wofür wir den Speicher
benötigen und gibt daher einen unspezifizierten Zeiger (void *) zurück. Diesen müs-
sen wir daher noch durch eine Typumwandlung auf den richtigen Typ (int *) kon-
vertieren. Jetzt ist der Speicher allokiert und wir können ihn über den Zeiger wie
einen »normalen« Array verwenden (C). (p+i) entspricht dabei &p[i]. Am Ende wird
der nicht mehr benötigte Speicher wieder freigegeben. Mit malloc allokierter Spei-
cher liegt nicht auf dem Stack und wird daher beim Verlassen der Funktion, in der er
angelegt wurde nicht automatisch wieder beseitigt. Das passiert erst, wenn wir es
explizit durch Aufruf der free-Funktion veranlassen (D). Immer, wenn Sie in einem
Programm malloc (oder realloc s.u.) verwenden, müssen Sie sich Gedanken darüber
machen, wann, wo und wie der Speicher wieder freizugeben ist. Wenn Sie das verges-
sen, hat Ihr Programm ein »Speicherleck« aus dem sozusagen Speicher entweicht,
8 Sie erinnern sich noch an den Zusammenhang zwischen Zeigern und Arrays.
266
10.6 Freispeicherverwaltung
der dann für Ihr Programm nicht mehr nutzbar ist. Mit malloc und free kann man
bizarre Programmierfehler erzeugen. Das soll uns im Moment aber nicht interessie-
ren. Im Moment wollen wir uns über die neue Programmierfreiheit freuen, die uns
diese Funktionen liefern.
So groß ist die Freiheit allerdings nun auch wieder nicht, denn noch immer muss der
Benutzer die Größe des Arrays vorgeben. Das heißt, dass er vor Beginn der Eingabe
schon wissen muss, wie viele Zahlen er eingeben will. Von dieser Beschränkung wol-
len wir uns jetzt lösen. Der Benutzer soll so lange Zahlen eingeben können, bis er die
Eingabe durch die Zahl –1 beendet. Wir könnten das mit malloc realisieren, indem wir
mit einer bestimmten Arraygröße starten und immer, wenn der Array überzulaufen
droht, einen größeren Array allokieren, die Daten aus dem alten in den neuen Array
umkopieren und den alten Array wieder freigeben. Mit der Funktion realloc lässt
sich das aber auch einfacher realisieren: 10
void main()
{
A int size = 0, increment = 2;
int anz, i, z;
B int *p = 0;
267
10 Die Standard C Library
1. Zahl: 1
Array auf 2 Elemente vergroessert2
2. Zahl: 2
3. Zahl: 3
Array auf 4 Elemente vergroessert
4. Zahl: 4
5. Zahl: 5
Array auf 6 Elemente vergroessert
6. Zahl: 6
7. Zahl: –1
6
5
4
3
2
1
Die Funktion realloc erhält neben der Größe des zu allokierenden Speichers im ers-
ten Parameter einen Zeiger. Ist dieser Zeiger 0, so verhält sich realloc wie malloc und
allokiert neuen Speicher in der erforderlichen Größe. Ist der Zeiger ungleich 0, so
geht realloc davon aus, dass dort bereits Speicher allokiert ist und prüft, ob dieser
Speicher für die neue Anforderung bereits ausreicht. Ist das nicht der Fall, so allokiert
realloc neuen Speicher, kopiert den Speicherinhalt vom alten in den neuen Speicher
um, gibt den alten Speicher frei und gibt einen Zeiger auf den neuen Speicher an das
rufende Programm zurück.
Mit dieser Funktion können wir unseren Array bedarfsgerecht wachsen lassen. Wir
starten mit Arraygröße 0 (size) und vergrößern den Array immer um eine
bestimmte Anzahl von Elementen (increment) (A). Am Anfang ist noch kein Speicher
allokiert (B). Wenn die Größe nicht mehr ausreicht (C) wird die neue Kapazität
berechnet (D) und Speicher allokiert (E). Nach Abbruch der Schleife erfolgt die Aus-
gabe rückwärts (F) und die Freigabe des Speichers (G).
Sollte uns einmal der Speicher ausgehen, so geben Funktionen wie malloc und free
den Wert 0 zurück. Das ist eine schwierige Situation. Da aber bei unseren Program-
men diese Situation sicher nicht eintreten wird, ignorieren wir dieses Problem.
Der effiziente und korrekte Umgang mit Speicher ist eine der anspruchsvollsten Auf-
gaben der C-Programmierung. Leider werden hier immer wieder Fehler gemacht, die
zu Abstürzen oder dramatischen Sicherheitslücken in Programmen führen können.
Traurige Berühmtheit erlangte dabei im Jahr 2014 der sogenannte Heartbleed-Bug,
268
10.6 Freispeicherverwaltung
der einen Großteil der Server im Internet betraf. Ich will versuchen, Ihnen diesen
Fehler auf einfache Weise zu erklären. Um eine gesicherte Internetverbindung zu
einem Server auch bei längerer Inaktivität des Benutzers aufrecht zu erhalten, kann
der Client spontan ein beliebiges Datentelegramm schicken und den Server bitten, es
zurückzuschicken. Man nennt dies einen Heartbeat. Der Client sendet dabei auch die
Information, wie lang sein Datentelegramm ist. Der Server war nun so program-
miert, dass er, entsprechend der vom Client mitgeteilten Telegrammlänge, Speicher
allokierte, das Telegramm entsprechend der Anzahl der effektiv gesendeten Bytes in
den Speicher kopierte und dann den Speicherinhalt entsprechend der mitgeteilten
Telegrammlänge zurückschickte. Beachten Sie, dass hier zwei verschiedene Längen
im Spiel sind. Einerseits die vom Client behauptete Telegrammlänge und anderer-
seits die Länge des vom Client effektiv gesendeten Telegramms.
Wenn man jetzt den Client so programmiert, dass er bei jedem Heartbeat behauptet, 10
dass sein Telegramm 512 Bytes groß ist, er aber nur 1 Byte effektiv sendet, führt das
dazu, dass der Server mit jedem Heartbeat 511 Bytes aus seinem Hauptspeicher
schickt, die unter Umständen noch mit Daten von einer früheren Nutzung belegt
sind. Auf diese Weise erhält der Client kleine Puzzlestücke des Serverspeichers, die er
versuchen kann zu einem größeren Bild zusammenzusetzen. Im übertragenen Spei-
cher findet der Client dann gegebenenfalls Passwörter oder andere sicherheitsrele-
vante Informationen. Diese Sicherheitslücke war besonders heimtückisch, weil man
nicht feststellen konnte, welche Informationen abgegriffen wurden. Der Fehler lässt
sich natürlich ganz einfach vermeiden, indem man ein Telegramm in der effektiv
empfangenen Länge zurückschickt. Dann erhält der Client nur seine gesendeten
Daten zurück.
Bevor wir uns den Aufgaben dieses Kapitels zuwenden, möchte ich Ihnen zeigen, wie
Sie mit dem Zufallszahlengenerator sehr einfach Testdaten erzeugen können. Als
Erstes erstellen wir eine Funktion, die eine Zufallszahl in einem vorgegebenen
Bereich berechnet:
Aufbauend auf dieser Funktion, erstellen wir jetzt eine Funktion, die Zeichenketten
zufällig erzeugt:
269
10 Die Standard C Library
void zfstring (char *s, int len, char von, char bis)
{
int i;
Als Parameter erhält die Funktion einen ausreichend großen Stringpuffer, die Länge
des zu erzeugenden Strings und den gewünschten Zeichenbereich.
void main()
{
int seed = 12345;
int i;
char str[100];
srand( seed );
for( i = 0; i < 5; i++ )
{
zfstring( str, zfzahl( 1, 10 ), 'a', 'z' );
printf( "%2d: %s\n", i, str );
}
printf( "\n" );
for( i = 0; i < 5; i++ )
{
zfstring( str, zfzahl( 1, 10 ), '0', '9' );
printf( "%2d: %s\n", i, str );
}
}
0: cdzef
1: feejghws
2: wyxxafq
3: khdac
4: aghycwulci
270
10.7 Aufgaben
0: 99467
1: 78070
2: 0877
3: 4
4: 01
Sie sehen, dass wir jetzt beliebige Zeichenketten wahlweise mit Buchstaben oder mit
Ziffern erzeugen können. So haben wir die Möglichkeit, Testdaten für Funktionen,
die Zeichenketten verarbeiten, zu erzeugen und damit Massentests durchzuführen.
10.7 Aufgaben
A 10.1 Erstellen Sie eine Funktion, die 1000 Zufallsstrings erzeugt und diese in eine 10
Datei schreibt, deren Name als Parameter an die Funktion übergeben wird.
A 10.2 Erstellen Sie eine Funktion, die die in Aufgabe 10.1 erstellten Strings aus der
Datei einliest und eine Statistik über die Buchstabenhäufigkeit erstellt.
271
10 Die Standard C Library
Lesen Sie die Testdaten wahlweise auch aus einer Textdatei ein, die Sie zuvor
erzeugt haben.
272
Kapitel 11
Kombinatorik
La multitude qui ne se réduit pas à l'unité est confusion
(Vielfalt, die nicht auf Einheit zurückgeht, ist Wirrwarr)
– Blaise Pascal
Häufig haben wir es bei der Programmierung mit Problemen zu tun, bei denen in
einem einfach strukturieren, aber sehr großen Suchraum eine kleine, durch kom-
plexe Bedingungen gegebene Menge von Lösungen zu finden ist. Oft kann man die 11
gesuchten Lösungen nicht direkt konstruieren, sondern muss den gesamten Such-
raum durchlaufen und die gültigen Lösungen herausfiltern. Man nennt das eine
Brute-Force-Attacke, weil man mit brachialer Gewalt versucht, die Lösungen unter
allen nur denkbaren Kandidaten zu finden. Durch eine Brute-Force-Attacke haben
wir z. B. alle Lösungen des Damenproblems gefunden. Sie kennen vielleicht das Bei-
spiel, wie man auf diese Weise einen Tiger fängt. Man baut einen Zaun um sich
herum und kann sicher sein, dass in dem durch den Zaun begrenzten Außengebiet
ein Tiger ist. Jetzt kommt es nur noch darauf an, das Innengebiet systematisch zu ver-
größern und dadurch das Außengebiet systematisch zu verkleinern. Man muss nur
aufpassen, dass dabei kein Tiger entwischt. Sie sehen, dass man auf diese Weise nicht
nur einen Tiger, sondern alle Tiger fangen kann. Besser wäre es natürlich, wenn man
in der Lage wäre, einen Tiger gezielt aufzuspüren.
Kombinatorik ist ein Teilgebiet der Mathematik, das sich, vereinfacht gesprochen,
mit den Möglichkeiten beschäftigt, Elemente aus einer Menge in verschiedenartiger
Weise auszuwählen und zusammenzustellen. Wir wollen hier keine Mathematik
betreiben, aber wir interessieren uns für die Kombinatorik insoweit, wie sie uns bei
der Programmierung hilft. Für die Programmierung gibt es im Wesentlichen zwei
kombinatorische Fragestellungen:
왘 Wie viele verschiedene, einem bestimmten Schema folgende Auswahlen gibt es?
왘 Wie können alle einem bestimmten Schema folgenden Auswahlen erzeugt
werden?
Die Antwort auf die erste Frage sagt uns, wie groß der Suchraum unseres Problems
ist. Durch die Beantwortung dieser Frage hoffen wir, Formeln zu finden, die uns hel-
fen, die zu erwartende Rechenzeit zur Lösungssuche vorab zu bestimmen. Durch die
273
11 Kombinatorik
Beantwortung der zweiten Frage hoffen wir, konkrete Algorithmen zu finden, die uns
bei der »erschöpfenden Lösungssuche« helfen.
In diesem Kapitel formulieren wir vier kombinatorische Grundaufgaben, für die wir
dann die beiden oben gestellten Fragen beantworten werden.
왘 Man könnte zulassen, dass eine Zahl mehrfach gezogen wird. Man würde dazu die
gezogene Kugel immer wieder in das Ziehungsgerät zurücklegen. Sechsmal die 1
wäre dann ein gültiger Tipp und eine mögliche Ziehung.
왘 Man könnte verlangen, dass man die gezogenen Zahlen in der korrekten Zie-
hungsreihenfolge getippt haben muss, um zu gewinnen. 1, 2, 3, 4, 5, 6 wäre dann ein
anderes Ziehungsergebnis als 6, 5, 4, 3, 2, 1. Das Ziehungsverfahren müsste man
dazu nicht ändern. Man müsste nur das Ziehungsergebnis in der Reihenfolge der
Ziehung bekannt geben.
Wir führen also eine Ziehung von k Kugeln aus einer Grundgesamtheit mit n Kugeln
mit Zurücklegen und mit Beachtung der Reihenfolge durch.
274
11.3 Permutationen ohne Wiederholungen
Wir fassen unsere Ergebnisse zusammen und führen dabei einen neuen Begriff ein:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es auf
die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Permutation
11
mit Wiederholungen, wenn in der Auswahl Wiederholungen von Elementen
vorkommen dürfen.
Es gibt nk solcher Permutationen.
275
11 Kombinatorik
Setzt man diese Überlegung auf alle k zu ziehenden Kugeln fort, ergibt sich,
dass es insgesamt n · (n – 1) · ... · (n – k + 1) mögliche Ziehungsergebnisse gibt.
Mathematisch kann man dieses Ergebnis etwas kompakter formulieren, wenn man
geschickt erweitert und sich erinnert, dass das Produkt der ersten x natürlichen Zah-
len mit x! (x-Fakultät) bezeichnet wird:
n( n – 1) ⋅ … ⋅ (n – k + 1)(n – k) ⋅ … ⋅ 1 n!
n ( n – 1 ) ⋅ … ⋅ ( n – k + 1 ) = ------------------------------------------------------------------------------------------------ = -------------------
(n – k) ⋅ … ⋅ 1 ( n – k )!
Die letzte Formel kann auch über eine andere Argumentation anschaulich hergelei-
tet werden. Wir nehmen die n Kugeln und legen sie in allen denkbaren Reihenfolgen
auf den Tisch. Dazu gibt es n! Möglichkeiten.
n Kugeln
n! Vertauschungen
Die ersten k Kugeln sollen die ausgewählten Kugeln sein. Jede Auswahl von k Kugeln
kommt aber so oft vor, wie es Vertauschungsmöglichkeiten im hinteren Teil gibt. Um
das Ergebnis zu erhalten, müssen wir also die Gesamtzahl der Möglichkeiten (n!)
durch die Anzahl der Vertauschungsmöglichkeiten im hinteren Teil ((n – k)!) dividie-
ren. Als Ergebnis erhalten wir die Formel:
n!
-------------------
( n – k )!
8! 8⋅7⋅6⋅5⋅4⋅3⋅2⋅1
----- = ------------------------------------------------------ = 336
3! 3⋅2⋅1
Wir fassen unser Ergebnis wieder unter einem neuen Begriff zusammen:
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es auf
die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Permutation
ohne Wiederholungen, wenn in der Auswahl keine Wiederholungen von Ele-
menten vorkommen dürfen.
n!
Es gibt ------------------- solcher Permutationen.
( n – k )!
276
11.3 Permutationen ohne Wiederholungen
Auf der Suche nach einer Formel legen wir wieder alle n Kugeln in allen möglichen
Reihenfolgen auf den Tisch. Die vorderen k Kugeln werden ausgewählt, die hinteren
n-k Kugeln sind nicht gewählt: 11
n Kugeln
n! Vertauschungen
Vertauschungen der Kugeln im vorderen wie im hinteren Teil vervielfachen jetzt die
Lösungsgesamtheit und müssen durch Divisionen entfernt werden. Damit ergibt
sich als Ergebnis:
n!
------------------------
k! ( n – k )!
Dieser Ausdruck ist so bedeutsam für die Mathematik, dass man ihm einen eigenen
Namen gegeben hat. Man nennt diesen Ausdruck Binomialkoeffizient und hat eine
abkürzende Schreibweise dafür eingeführt, bei der man die Zahlen n und k in einer
Klammer untereinanderschreibt:
⎛ n⎞ = ------------------------
n!
⎝ k⎠ k! ( n – k )!
Man liest diesen Ausdruck dann »n über k«1. In gekürzter Form ist dies:
1 Die Bedeutung der Binomialkoeffizienten reicht weit über die Kombinatorik hinaus.
277
11 Kombinatorik
k-Faktoren
⎫
⎪
⎪
⎪
⎪
⎪
⎬
⎪
⎪
⎪
⎪
⎪
⎪⎭
n ( n – 1 ) ( n – 2 )… ( n – k + 1 )
⎛ n⎞ = -----------------------------------------------------------------------------
-
⎝ k⎠ k ( k – 1 ) ( k – 2 )…1
Man schreibt also einen Bruch mit k Faktoren im Zähler und im Nenner – im Zähler
von n absteigend und im Nenner von k absteigend. Im Falle unserer Büchersamm-
lung (n = 100, k = 5) ergeben sich
⎛ 100⎞ = 100 ⋅ 99 ⋅ 98 ⋅ 97 ⋅ 96
------------------------------------------------------ = 75287520
⎝ 5 ⎠ 5⋅4⋅3⋅2⋅1
verschiedene Buchpakete für den Urlaub. Hätten Sie gedacht, dass die Auswahl so
groß ist?
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es nicht
auf die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Kombina-
tion ohne Wiederholungen, wenn in der Auswahl keine Wiederholungen von
Elementen vorkommen dürfen.
Zur Veranschaulichung des allgemeinen Falls wählen wir wieder das Bild der Los-
trommel:
Zur Herleitung einer Formel stellen wir uns vor, dass wir eine Kugel aus der Menge
der n Kugeln herausnehmen und auf den Tisch legen. Zu den restlichen Kugeln fügen
wir k neue, von den anderen Kugeln unterscheidbare Kugeln hinzu. Die n+k-1 Kugeln
2 Achtung, wir teilen den Bonbons Kinder zu und nicht den Kindern Bonbons!
278
11.3 Permutationen ohne Wiederholungen
legen wir jetzt in allen möglichen Reihenfolgen hinter die am Anfang herausgelegte
Kugel auf den Tisch. Damit ergibt sich z. B. folgendes Bild:
n+k–1
n–1
Ausgewählt sind jetzt diejenigen grauen Kugeln, denen eine oder mehrere schwarze
Kugeln folgen, und zwar so oft, wie schwarze Kugeln folgen. Auf diese Weise sind k
der n grauen Kugeln ausgewählt, da es ja k schwarze Kugeln gibt. Natürlich kommen
auch hier die gesuchten Auswahlen entsprechend vielfach vor. Um die Vielfachen 11
auszuscheiden, müssen wir noch durch die Anzahl der möglichen Vertauschungen
der schwarzen Kugeln untereinander (das sind k!) und durch die Anzahl der mögli-
chen Vertauschungen der grauen Kugeln inklusive ihrer schwarzen Nachfolger
untereinander (das sind (n – 1)!) dividieren. Insgesamt ergibt sich also die Formel:
( n + k – 1 )!
----------------------------
k! ( n – 1 )!
⎛ n + k – 1⎞
⎝ k ⎠
⎛ 104 ⎞ = ----------------
104! 104 ⋅ 103 ⋅ 102 ⋅ 101
- = ------------------------------------------------- = 4598126
⎝ 100⎠ 100!4! 4⋅3⋅2⋅1
mögliche Bonbonverteilungen. Auch hier erstaunt Sie sicher die große Zahl3.
Eine Auswahl von k Elementen aus einer n-elementigen Menge, bei der es nicht
auf die Reihenfolge der Auswahl ankommt, bezeichnen wir als n-k-Kombina-
tion mit Wiederholungen, wenn in der Auswahl Wiederholungen von Elemen-
ten vorkommen dürfen.
3 Beachten Sie, dass ich eingangs von »verschiedenfarbigen«, also individuell unterscheidbaren,
Bonbons gesprochen habe. Das heißt, es ist ein Unterschied, ob ein Kind ein rotes oder ein hell-
rotes Bonbon bekommt.
279
11 Kombinatorik
( n + k – 1 )!
Es gibt ⎛ n + k – 1⎞ = ---------------------------- solcher Kombinationen.
⎝ k ⎠ k! ( n – 1 )!
11.3.3 Zusammenfassung
Wir haben vier kombinatorische Grundaufgaben betrachtet und sind jeweils zu For-
meln über die Anzahl der möglichen Auswahlen gekommen:
Beachten Sie, dass, wenn Wiederholungen zugelassen sind, der Wert von k durchaus
größer als der Wert von n sein kann, was bei Auswahlen ohne Wiederholungen natür-
lich ausgeschlossen ist.
⎛ 100⎞ ≈ 10 29
⎝ 50 ⎠
280
11.3 Permutationen ohne Wiederholungen
200000
180000
n
160000
k
140000
120000
100000
80000
60000
40000 18
15
20000 12
9 11
0
0 1 2 3
6 n
4 5 6 7 3
8 9 10
11 12 1314 15 16 0
k 17 18 19
20
4 Wenn Sie die Frage beantworten können, können Sie sich Ihre Prämie hier abholen:
http://www.claymath.org/millennium/P_vs_NP/.
281
11 Kombinatorik
Stellen Sie sich vor, dass wir acht Gleitkommazahlen in einem Array haben. Das sind
die eigentlichen Daten, aus denen wir eine Auswahl treffen wollen:
double daten[8] = { 1.234, 3.14, 0.815, 47.11, 11.11, 0.1, 1.14, 2.718}
Ausgewählt sind in diesem Fall die drei Elemente mit den Indizes 2, 0 und 7:
4-2-Permutationen 4-2-Permutationen
mit Wiederholungen ohne Wiederholungen
1: < 0, 0> 1: < 0, 1>
2: < 0, 1> 2: < 0, 2>
3: < 0, 2> 3: < 0, 3>
ohne Reihenfolge 4: < 0, 3> 4: < 1, 2>
5: < 1, 1>
6: < 1, 2>
5: < 1, 3>
6: < 2, 3>
4
2 ()
=6
7: < 1, 3>
8: < 2, 2>
9: < 2, 3>
10: < 3, 3>
(
4+2–1
2 )
= 10
282
11.4 Kombinatorische Algorithmen
In diesem Abschnitt greifen wir das Thema der Erzeugung von Permutationen und
Kombinationen noch einmal auf – diesmal allerdings in Kenntnis der mathemati-
schen Grundlagen und mit etwas mehr Systematik. Zur Implementierung werden
wir durchweg rekursive Algorithmen verwenden.
Alle Algorithmen dieses Abschnitts werden Arrays mit Permutationen bzw. Kombi-
nationen der Zahlen von 0 bis n-1 als Ergebnis erzeugen. Es ist daher sinnvoll, vorweg
eine zentrale Funktion zur Ausgabe solcher Arrays zu erstellen:
int count = 0;
void print_array( int k, int array[])
{
int i;
Der Funktion wird ein Array mit ganzen Zahlen und die Anzahl der Zahlen im Array
übergeben. Außerhalb der Funktion wird ein globaler Zähler angelegt und verwen-
det, um mitzuzählen, wie viele Permutationen bzw. Kombinationen bisher ausgege-
ben wurden.
283
11 Kombinatorik
Dieser Zähler wird jeder Ausgabe vorangestellt. Die Ausgaben, die diese Funktion
erzeugt, haben Sie ja bereits im letzten Abschnitt kennengelernt.
1
0 x k-1
1 2 0 0
0 2 0 1 1
0 3 1 2 2
1 4 2 3 3
2 5 3 4 4
3 6 4 5 5
4 7 5 6 6
5 8 6 7 7
6 . 7 8 8
7 . 8 . .
8 n-1 . . .
. . n-1 n-1
. n-1
n-1
Wie beim Zahlenschloss eines Fahrrads liefert jede Einstellung der Stangen eine Per-
mutation von Zahlen zwischen 0 und n-1, wobei sich Zahlen wiederholen können. Bei
fest vorgegebener Anzahl von Stangen könnte man diesen Mechanismus mit ent-
sprechend vielen ineinander geschachtelten Schleifen simulieren. Da wir aber die
Zahl der Stangen variabel halten wollen, müssen wir es anders machen und entschei-
den uns für Rekursion.
284
11.4 Kombinatorische Algorithmen
B if( x < k)
{
C for( i = 0; i < n; i++)
{
D array[x] = i;
E perm_mw( n, k, array, x+1);
}
}
else
F print_array( k, array);
} 11
Listing 11.2 Erzeugen von Permutationen mit Wiederholungen
Als Parameter erhält die Funktion die Anzahl der Werte auf einer Stange n, die Anzahl
der Stangen k, das Array array mit den Stellungen der k Stangen und den Index x der
Stange, die in dieser Funktionsinstanz gesetzt werden soll (A). Solange wir noch nicht
am Ende angekommen sind (B), wird die Stange x in alle möglichen Stellungen
gebracht (C) und (D), und die restlichen Stangen ab Index x+1 werden positioniert (E).
Ansonsten ist eine neue Permutation erzeugt und wird ausgegeben (F).
void main()
{
A int array[3];
Die Parameter n und k können Sie dabei frei wählen. Das Array muss nur groß genug
sein, um die k ausgewählten Zahlen aufzunehmen (A). Beachten Sie jedoch, dass das
Programm für n = k = 10 insgesamt 10 Milliarden Zeilen ausgeben würde.
285
11 Kombinatorik
2-3-Permutationen
mit Wiederholungen
1: ( 0, 0, 0)
2: ( 0, 0, 1)
3: ( 0, 1, 0)
4: ( 0, 1, 1)
5: ( 1, 0, 0)
6: ( 1, 0, 1)
7: ( 1, 1, 0)
8: ( 1, 1, 1)
k-1
x 0
2 0 1
0 1 2
0 1 1 2 3
0 0 2 3 4
1 1 3 4 5
2 2 4 5 6
3 3 5 6 7
4 4 6 7 .
5 5 7 . .
6 6 . . .
7 7 . . n-1
. . . n-1
. . n-1
. .
n-1 n-1
286
11.4 Kombinatorische Algorithmen
Diese Winkel müssen wir jetzt in die Funktion zur Generierung der Kombinationen
einbauen. Das ist aber nicht schwer. Wir machen das durch einen zusätzlichen Para-
meter, der den Startwert oder Minimalwert für jede Stange transportiert:
if( x < k)
{
B for( i = min; i < n; i++)
{
array[x] = i;
C komb_mw( n, k, array, x+1, i);
}
} 11
else
print_array( k, array);
}
Dieser Wert wird als min zusätzlich für die aktuelle Stange übergeben (A). Im weiteren
Verlauf werden nur Werte ab dem Minimum eingestellt (B).
Beim rekursiven Aufruf wird dieser Parameter auf den Wert der aktuellen Stange
gesetzt, um dann auf der nächsten Ebene wieder als Startwert verwendet zu werden (C).
Diese Funktion komb_mw können wir nun wieder in einem passenden Hauptpro-
gramm nutzen:
void main()
{
int array[4];
Im Hauptprogramm müssen Sie darauf achten, den Parameter für das Minimum mit
0 zu belegen, damit die erste Stange beim Wert 0 starten kann (A):
287
11 Kombinatorik
3-4-Kombinationen
mit Wiederholungen
1: ( 0, 0, 0, 0)
2: ( 0, 0, 0, 1)
3: ( 0, 0, 0, 2)
4: ( 0, 0, 1, 1)
5: ( 0, 0, 1, 2)
6: ( 0, 0, 2, 2)
7: ( 0, 1, 1, 1)
8: ( 0, 1, 1, 2)
9: ( 0, 1, 2, 2)
10: ( 0, 2, 2, 2)
11: ( 1, 1, 1, 1)
12: ( 1, 1, 1, 2)
13: ( 1, 1, 2, 2)
14: ( 1, 2, 2, 2)
15: ( 2, 2, 2, 2)
k-1
0
x 1
2 0 2
1 0 1 3
0 1 2 4
0 1 2 3 5
0 2 3 4 6
1 3 4 5 7
2 4 5 6 8
3 5 6 7 .
4 6 7 8 .
5 7 8 . n-1
6 8 . .
7 . . n-1
8 . n-1
. n-1
.
n-1
288
11.4 Kombinatorische Algorithmen
In dieser Konstruktion kann man eine Stange nicht mehr beliebig weit nach oben
schieben, da noch ausreichend Platz für die nachfolgenden Stangen bleiben muss.
Wenn wir die Stange x positionieren, müssen wir berücksichtigen, dass noch k-1-x
Stangen folgen werden, für die die Zahlen größer als (n-1)-(k-1-x) = n-k+x reserviert
bleiben müssen. Das müssen wir bei der Programmierung berücksichtigen:
if( x < k)
{
A for( i = min; i <= n-k+x; i++)
{
array[x] = i;
B komb_ow( n, k, array, x+1, i+1); 11
}
}
else
print_array( k, array);
}
Die Schleife wird so angepasst, dass nach oben Platz für die noch folgenden Stangen
bleibt (A), und die nächste Stange muss mindestens 1 höher als die aktuelle Stange
sein (B).
void main()
{
int array[3];
289
11 Kombinatorik
5-3-Kombinationen
ohne Wiederholungen
1: ( 0, 1, 2)
2: ( 0, 1, 3)
3: ( 0, 1, 4)
4: ( 0, 2, 3)
5: ( 0, 2, 4)
6: ( 0, 3, 4)
7: ( 1, 2, 3)
8: ( 1, 2, 4)
9: ( 1, 3, 4)
10: ( 2, 3, 4)
k-1
1 0
0 x 1
1 2 0 2
0 2 0 1 3
0 3 1 2 4
1 4 2 3 5
2 5 3 4 6
3 6 4 5 7
4 7 5 6 8
5 8 6 7 .
6 . 7 8 .
7 . 8 . n-1
8 n-1 . .
. . n-1
. n-1
n-1
290
11.4 Kombinatorische Algorithmen
Wir wollen zweistufig vorgehen, indem wir zunächst, mit der Funktion des letzten
Abschnitts, n-k-Kombinationen ohne Wiederholungen erzeugen und dann für jede
dieser Auswahlen alle möglichen Reihenfolgen, d. h. alle k-k-Permutationen ohne
Wiederholungen, berechnen. Als Ergebnis erhalten wir dann alle n-k-Permutationen
ohne Wiederholungen.
Jetzt modifizieren wir die Funktion komb_ow so, dass an der Stelle, an der eine n-k-
Kombination erzeugt wurde, anstelle einer Ausgabe die Generierung aller k-k-Per-
mutationen dieser Kombination angestoßen wird. Den Prozedurnamen ändern wir
gleichzeitig in perm_ow:
291
11 Kombinatorik
if( x < k)
{
for( i = min; i <= n-k+x; i++)
{
array[x] = i;
perm_ow( n, k, array, x+1, i+1);
}
}
else
B perm( k, array, 0);
}
In dem Programm hat sich, im Vergleich zu komb_ow, nichts geändert, außer, dass die
Funktion von komb_ow in perm_ow umbenannt wurde (A). Sobald eine neue Kombina-
tion erzeugt wurde, wird diese permutiert (B).
void main()
{
int array[2];
4-2-Permutationen
ohne Wiederholungen
1: ( 0, 1)
2: ( 1, 0)
3: ( 0, 2)
4: ( 2, 0)
292
11.5 Beispiele
5: ( 0, 3)
6: ( 3, 0)
7: ( 1, 2)
8: ( 2, 1)
9: ( 1, 3)
10: ( 3, 1)
11: ( 2, 3)
12: ( 3, 2)
11.5 Beispiele
Da in unseren Programmierbeispielen bisher nur Permutationen vorgekommen
sind, wollen wir zum Abschluss dieses Kapitels zwei Beispiele mit Kombinationen
erstellen. Im ersten Beispiel betrachten wir Kombinationen ohne Wiederholungen
11
und im zweiten Beispiel Kombinationen mit Wiederholungen. In beiden Beispielen
wollen wie vorab abschätzen, wie viele Fälle untersucht werden müssen, um einen
Eindruck von der Laufzeit unserer Programme zu erhalten. Im zweiten Beispiel wer-
den Sie dabei sehen, dass es nicht immer empfehlenswert ist, mit kombinatorischen
Algorithmen zu arbeiten.
11.5.1 Juwelenraub
Zwei Ganoven haben die Scheibe eines Juwelierladens eingeschlagen und in aller Eile
zehn Schmuckstücke zusammengerafft. Wieder zu Hause angekommen, streiten sie
sich um eine gerechte Verteilung der Beute. Zum Glück sind alle Beutestücke mit
einem Preisschild versehen, aber wie soll man eine Verteilung vornehmen, bei der
beide einen annähernd gleichen Anteil erhalten? Wir werden alle denkbaren Teilaus-
wahlen mit einem, zwei, drei, vier oder fünf Beutestücken betrachten und jeweils den
Wert der Teilauswahl berechnen. Man entscheidet sich dann für die Teilauswahl,
deren Wert der halben Gesamtsumme am nächsten liegt. Teilauswahlen sind Kombi-
nationen ohne Wiederholungen, da die Reihenfolge der Zuteilung keine Rolle spielt
und jedes Schmuckstück nur einmal zugeteilt werden kann. Teilauswahlen mit mehr
als fünf Beutestücken müssen nicht betrachtet werden, da dann ja die gegenteilige
Auswahl weniger als fünf Beutestücke hat und bereits in der Betrachtung enthalten
ist.
Bei einer n-k-Auswahl ohne Beachtung der Reihenfolge und ohne Wiederholungen
haben wir n über k mögliche Ergebnisse. Wir machen eine Aufstellung der Binomial-
koeffizienten 10 über k für k zwischen 1 und 5:
293
11 Kombinatorik
10
( )k k Auswahlen
1
2
10
45
3 120
4 210
5 252
Summe: 637
Insgesamt müssen also 637 Fälle betrachtet werden, und es ist nicht erkennbar, dass
man Fälle davon außer Betracht lassen kann.
Wir kommen zur Implementierung des Programms. Dazu legen wir einige globale
Variablen an:
B double summe;
int anzahl;
int auswahl[10];
double teilsumme;
double abweichung;
Hier sind die Preise der zehn Beutestücke (A) sowie eine Variable für den Gesamtwert
der Beute angelegt (B).
Der Gesamtwert der Beute muss noch berechnet werden. Das machen wir in der
Funktion vorbereitung, in der wir auch die abweichung initialisieren und eine Über-
sicht über die Beute ausgeben:
294
11.5 Beispiele
void vorbereitung()
{
int i;
11
Die Funktion berechnet den Gesamtwert der Beute (A) und setzt die Abweichung auf
einen großen Anfangswert, damit beliebige Auswahlen diesen Wert später unterbie-
ten (B).
Wenn wir die Funktion aus einem Hauptprogramm aufrufen, erhalten wir die fol-
gende Ausgabe:
Für abweichung versuchen wir, ein Minimum zu finden. Das geht besonders einfach,
wenn Sie anfangs einen Wert nehmen, der größer als alle im Weiteren vorkommen-
den Werte ist. Sie ersparen sich dann die Abfrage, ob Sie schon einen gültigen Ver-
gleichswert in der Variablen haben.
Wir wollen die Funktion komb_ow verwenden. Dort haben wir immer, wenn eine neue
Kombination erzeugt wurde, die Funktion print_array gerufen. Das interessiert uns
hier nicht. Wir wollen ja nicht jede mögliche Auswahl ausgeben, sondern verglei-
chen, bewerten und am Ende nur die beste Auswahl ausgeben. Dazu erstellen wir
eine Funktion mit der gleichen Schnittstelle wie print_array, die die Aufgabe hat,
295
11 Kombinatorik
eine Kombination zu bewerten und, wenn sie besser ist als die bisher beste, in den
bereitgestellten globalen Variablen zu sichern, damit sie am Ende des Programms für
die Ausgabe zur Verfügung steht.
An der Schnittstelle von aufteilung wird eine Auswahl von k Beutestücken überge-
ben (A). Für die übergebene Auswahl der Beute wird dann der Wert berechnet (B). Die
Abweichung vom Optimum wird mit der Funktion für den Absolutbetrag fabs
berechnet (C). Falls sich das Ergebnis verbessert hat (D), wird die Auswahl gesichert.
Dazu wird u. a. das Array mit den Indizes umkopiert (D).
Bis auf den geänderten Unterprogrammaufruf können wir die Funktion komb_ow
unverändert übernehmen:
if( x < k)
{
for( i = min; i <= n-k+x; i++)
{
array[x] = i;
komb_ow( n, k, array, x+1, i+1);
296
11.5 Beispiele
}
}
else
A aufteilung( k, array);
}
Hier wird jetzt aufteilung anstelle von bisher print_array gerufen (A).
void main( )
{
int array[5];
11
int i;
vorbereitung();
A for( i = 1; i <= 5; i++)
komb_ow( 10, i, array, 0, 0);
auswertung();
}
Dazu erzeugen wir in einer Schleife 10-1-, 10-2-, 10-3-, 10-4- und 10-5-Kombinatio-
nen (A).
Die beste Auswahl liegt nach dem Verlassen der Schleife in der globalen Datensiche-
rung, aus der sie mit der Funktion auswertung ausgegeben wird. Diese Funktion muss
ich Ihnen der Vollständigkeit halber noch nachreichen:
void auswertung()
{
int i;
297
11 Kombinatorik
Bei Aufruf unseres kompletten Programms erhalten wir das folgende Ergebnis für
die Aufteilung:
Es bleibt eine unvermeidliche Differenz, die aber durch Zahlung von 16,95 Euro aus-
geglichen werden kann.
Die 637 Fälle, die hier zu betrachten waren, stellen keine besondere Herausforderung
für einen Computer dar. Aber beachten Sie, dass bei 100 Beutestücken allein für eine
50:50-Aufteilung
⎛ 100⎞ ≈ 10 29
⎝ 50 ⎠
11.5.2 Geldautomat
Wir wollen einen Geldautomaten, der intern unbegrenzt viele 5-, 10-, 20-, 50-, 100-,
200- und 500-Euro-Scheine vorhält, so programmieren, dass er einen Geldbetrag mit
bis zu 20, aber möglichst wenig Geldscheinen auszahlt. Wenn eine solche Auszah-
lung nicht möglich ist, soll eine entsprechende Meldung ausgegeben werden.
Eine Auszahlung ist eine Kombination der oben genannten sieben Banknoten mit
Wiederholungen. Wir können also, ähnlich wie im letzten Beispiel, der Reihe nach
alle 7-1-, 7-2-, 7-3-, ... 7-20-Kombinationen mit Wiederholungen erzeugen, bis wir eine
erste Lösung gefunden haben. Sobald wir die erste Lösung gefunden haben, können
wir das Verfahren abbrechen, da wir an weiteren Lösungen nicht interessiert sind.
Die Lösung, die wir als erste finden, kommt ja auch mit den wenigsten Geldscheinen
298
11.5 Beispiele
aus. Trotzdem kann es natürlich sein, dass alle Auswahlen durchsucht werden müs-
sen. Was das bedeutet, sehen Sie in der Tabelle in Abbildung 11.12:
( 7+k–1
k ) k
1
2
Auswahlen
7
28
3 84
4 210
5 462
6 924
7 1716
8 3003
9 5005
10 8008
11 12376
12 18564
11
13 27132
14 38760
15 54264
16 74613
17 100947
18 134596
19 177100
20 230230
Summe: 888029
Trotz dieser beeindruckenden Zahl wollen wir das Problem mit einem kombinatori-
schen Algorithmus lösen. Wir stellen dazu ein globales Array mit den sieben verfüg-
baren Banknoten und eine globale Variable für den auszuzahlenden Betrag bereit:
Diese Daten sind global und können von allen Unterprogrammen genutzt werden.
Der Zähler pruefungen wird für die eigentliche Aufgabe des Programms nicht benö-
tigt. Mithilfe dieses Zählers werden wir messen, wie aufwendig die Lösungssuche ist.
Als Nächstes erstellen wir eine Funktion, die prüft, ob eine an der Schnittstelle über-
gebene Auswahl von Notenwerten dem angeforderten Betrag entspricht:
299
11 Kombinatorik
B pruefungen++;
An der Schnittstelle der Funktion wird eine Auswahl von k Banknoten übergeben (A).
In dieser Funktion zählen wir, wie oft die Funktion aufgerufen wurde, um Informati-
onen über das Laufzeitverhalten des Algorithmus zu gewinnen (B).
Zur eigentlichen Prüfung wird das Geld in der Auswahl gezählt (C), dazu erfolgt vom
Index in der Auswahl der Zugriff auf den Notenwert (D).
Falls die ermittelte Summe dem geforderten Betrag entspricht, gibt die Funktion eine
1 zurück, sonst 0 (E).
if( x < k)
{
for( i = min; i < n; i++)
{
array[x] = i;
300
11.5 Beispiele
Die modifizierte Version bricht mit einer Erfolgsmeldung ab, wenn in der Rekursion
eine Lösung gefunden wird (A), ansonsten wird weitergesucht.
Wenn sich die Auswahl auf dieser Aufrufebene nicht zu einer Lösung fortsetzen lässt,
wird gegebenenfalls auf höheren Aufrufebenen weitergesucht (B). 11
Wenn eine vollständige Auswahl erzeugt wurde, dann wird das Ergebnis der Prüfung
zurückgegeben (C).
Im Hauptprogramm fügen wir alle Puzzlesteine zusammen und erhalten einen funk-
tionierenden Geldautomaten:
void main()
{
int array[20];
int k, i;
for( ; ;)
{
printf( "Betrag: ");
scanf( "%d", &betrag);
B pruefungen = 0;
301
11 Kombinatorik
Im Hauptprogramm erfragen wir den abzuhebenden Betrag und brechen ab, wenn
ein Betrag ≤ 0 eingegeben wurde (A). Mit jedem neuen Betrag wird der Zähler der Prü-
fungen zurückgesetzt (B). Danach wird die Suche nach Lösungen mit 1, 2, 3, ... 20 Geld-
scheinen gestartet (C). Falls die Suche erfolgreich abgeschlossen werden konnte, wird
in (D) die Notenstückelung ausgegeben und die Suche für den Betrag beendet (E).
Wenn keine Lösung gefunden wurde, erfolgt ebenfalls eine Ausgabe. Wir testen die-
ses Programm für einige Fälle:
Betrag: 885
Auszahlung: 500 200 100 50 20 10 5
Es wurden 2353 Pruefungen durchgefuehrt
Betrag: 1235
Auszahlung: 500 500 200 20 10 5
Es wurden 926 Pruefungen durchgefuehrt
Betrag: 500
Auszahlung: 500
Es wurden 1 Pruefungen durchgefuehrt
Betrag: 1
Keine Auszahlung moeglich
Es wurden 888029 Pruefungen durchgefuehrt
Dass eine so einfache Aufgabe einen so großen Rechenaufwand verursacht, lässt uns
natürlich keine Ruhe, und wir denken über Alternativen nach. Wie würden wir denn
302
11.5 Beispiele
eine Auszahlung vornehmen, wenn wir am Kassenschalter sitzen würden? Wir wür-
den so lange 500-Euro-Scheine ausgeben, wie der Auszahlungsbetrag nicht über-
schritten würde. Dann würden wir so lange 200-Euro-Scheine auszahlen, wie der
noch fehlende Betrag nicht überschritten würde. Dann ginge es mit 100-Euro-Schei-
nen weiter. Das würden wir so lange machen, bis entweder der Betrag vollständig
ausgezahlt wäre oder ein Rest (1, 2, 3 oder 4 Euro) bleiben würde, den wir nicht zahlen
könnten.
303
11 Kombinatorik
Diese Funktion findet die gleichen Auszahlungen wie der kombinatorische Algorith-
mus und macht dabei nur einige wenige Schleifendurchläufe.
304
Kapitel 12
Leistungsanalyse und
Leistungsmessung
If people do not believe that mathematics is simple, it is only because
they do not realize how complicated life is.
– John von Neumann
Bisher haben wir zur Lösung spezieller Programmieraufgaben immer den erstbesten
Algorithmus genommen und implementiert. Dabei haben wir gesehen, dass es Algo-
rithmen sehr unterschiedlicher Leistungsfähigkeit geben kann. Um dies noch einmal zu 12
vertiefen, wollen wir drei verschiedene Algorithmen für dieselbe Aufgabe formulieren
und bewerten. Wir wollen alle ganzzahligen, nicht-negativen Lösungen der Gleichung
x+y+z=n
Beim ersten Lösungsansatz lassen wir die Variablen x, y und z im gesamten Such-
raum (0 bis n) variieren und prüfen für jede Variablenkombination, ob eine Lösung
vorliegt. Wir erzeugen also durch drei ineinander geschachtelte Schleifen alle theore-
tisch denkbaren Möglichkeiten und filtern die korrekten Lösungen durch eine
Abfrage aus. Dann fragen wir uns, wie oft einzelne Zeilen in diesem Programm durch-
laufen werden:
305
12 Leistungsanalyse und Leistungsmessung
}
}
}
}
Für n = 50 ergeben sich konkrete Zahlenwerte, die angeben, wie oft eine bestimmte
Codezeile ausgeführt wurde. Diese habe ich in der linken Spalte dem Code vorange-
stellt. Für (A) gilt n + 1 = 51, für (B) gilt (n + 1)2 = 2601 und für (C) gilt (n + 1)3 = 132651.
Mehr als diese konkreten Zahlenwerte interessiert uns aber eine Formel, die aussagt,
wie viele Fälle allgemein betrachtet werden. Da sich mit jeder Schleife die Zahl der
untersuchten Fälle um den Faktor n + 1 vervielfacht, haben wir insgesamt (n + 1)3 Fälle
zu untersuchen, und unser Programm wird mit einem unbekannten Proportionali-
tätsfaktor c die Laufzeit
haben. Der konkrete Wert des Proportionalitätsfaktors interessiert uns nicht, zumal
dieser Faktor auf unterschiedlich schnellen Rechnern unterschiedlich ausfallen wird
und damit keine Kenngröße des Algorithmus ist.
Es ist Ihnen natürlich längst aufgefallen, dass in dem oben dargestellten Algorithmus
unnötige Fälle untersucht werden, da der Wert für z feststeht, sobald konkrete Werte
für x und y vorgegeben sind. Es kommt dann nur z = n-x-y infrage, um die geforderte
Gleichung zu erfüllen. Damit erweist sich die innere Schleife als überflüssig, und wir
können das Programm wie folgt vereinfachen:
306
Die Anzahl der betrachteten Fälle reduziert sich deutlich auf (n + 1)2 (B), und es ist
davon auszugehen, dass dieses Programm mit einer Laufzeit von t(n) = c(n + 1)2 bei
gleicher Funktionalität entsprechend schneller am Ziel ist.
Wenn Sie jetzt noch einmal genau hinschauen, werden Sie feststellen, dass es sinnlos
ist, y immer durch den gesamten Bereich von 0 bis n zu variieren, weil oberhalb von
y = n-x keine Lösungen für z mehr gefunden werden können. Wir können die Schleife
über y also bei Überschreitung des Werts n-x abbrechen. Da z = n-x-y in dieser Situa-
tion stets größer oder gleich 0 ist, ist dann die Abfrage z ≥ 0 nicht mehr erforderlich,
und wir können das Programm noch einmal vereinfachen:
(n + 1)(n + 2)
Jetzt sind es in (A) n + 1 = 51 und in (B) ----------------------------------- = 1326 Durchläufe.
2
Bei gegebenem x gibt es für y jetzt nur noch n-x+1 verschiedene Möglichkeiten. Ins-
gesamt ergibt sich damit1:
Möglichkeiten
x
für y
0 n+1
1 n
… …
n-1 2
n 1
(n + 2) (n + 1)
Summe:
2
307
12 Leistungsanalyse und Leistungsmessung
Die Anzahl der zu betrachtenden Fälle halbiert sich etwa bei Verwendung dieses Pro-
gramms, sodass wir nochmals eine deutliche Verringerung der Laufzeit erwarten
können. Die vergleichende Grafik in Abbildung 12.2 zeigt ein sehr unterschiedliches
Laufzeitverhalten der drei Programme:
2500
2000
(n + 1)3
1500
1000
500
0
1 2 (n + 1)2
3
4 5 6
7
8 9
10 (n + 2)(n + 1)
11 12 2
Auffallend ist, dass das erste Programm für »große« Werte von n deutlich aus dem
Rahmen fällt. Hier scheinen wir es mit verschiedenen »Leistungsklassen« zu tun zu
haben, während sich die beiden letzten Programme trotz des Leistungsunterschieds
in etwa gleich zu entwickeln scheinen. Diese Beobachtung wollen wir im Rahmen
dieses Kapitels auf eine saubere Grundlage stellen.
12.1 Leistungsanalyse
Die theoretische Analyse von Algorithmen ist ein anspruchsvolles Feld. Nur in ein-
fach gelagerten Fällen können Sie einen Algorithmus vollständig rechnerisch in den
Griff bekommen. Im Regelfall werden Sie unbedeutende Beiträge zur Laufzeit eines
Programms unter den Tisch fallenlassen und sich mit den Teilen beschäftigen, die
einen substanziellen Beitrag zur Gesamtlaufzeit des Programms leisten. Dazu müs-
sen Sie zunächst einmal lernen, die wesentlichen Teile, die die Laufzeit prägen, zu
identifizieren. Sinnvollerweise orientieren Sie sich dabei an den Bausteinen von Pro-
grammen. Diese sind:
308
12.1 Leistungsanalyse
왘 Blöcke
왘 Fallunterscheidungen
왘 Schleifen
왘 Unterprogramme
Wir betrachten ein einfaches Beispiel, an dem wir eine komplette Analyse durchfüh-
ren wollen.
void upr1()
{
int i;
void upr3()
{
machwas();
}
void machwas()
{
int i;
int a, b, c;
a = b = c = 0;
for( i = 0; i < 300000; i++)
{
a = b;
b = c; Dieses Programm hat keinen Sinn, es
c = a; soll nur Rechenzeit verbrauchen.
}
}
309
12 Leistungsanalyse und Leistungsmessung
Die drei Unterprogramme haben einzig und allein die Aufgabe, Laufzeit zu produzie-
ren. Wir vermuten, dass upr2 die 50-fache und upr1 die 500-fache Laufzeit von upr3
hat. Die effektiven Laufzeiten werden wir später messen, sie sind uns nicht bekannt.
Der eigentliche Algorithmus, für dessen Laufzeitverhalten wir uns interessieren, ist
durch das Unterprogramm test gegeben.
Dieses Programm macht nichts Sinnvolles. Wir interessieren uns nur für die Laufzeit
des Programms, die von den Parametern n und m abhängt. Um die Laufzeit in den Griff
zu bekommen, zerlegen wir den Algorithmus in seine Bestandteile. Wir verwenden
dazu eine grafische Notation, die unmittelbar einsichtig ist.
test
for
{}
upr1 for
if
upr2 for
upr3
310
12.1 Leistungsanalyse
An den Blättern des Baums finden Sie die Unterprogramme upr1 bis upr3, die wir
nicht weiter zerlegt haben, da sie eine konstante Laufzeit haben. Diese Laufzeiten
werden jetzt im Baum über die Knoten nach oben propagiert, bis wir am Ende an der
Wurzel die Laufzeit des gesamten Programms erhalten. Je nach Sprachkonstrukt
erfolgt an den Knoten natürlich eine andere Art der Propagierung von Laufzeiten.
Das werden wir jetzt im Detail diskutieren.
Wir erstellen zunächst eine detailreichere Grafik (siehe Abbildung 12.5), in der auch
schon die Laufzeiten der drei Unterprogramme als unbekannte Konstanten t1, t2 und
t3 eingetragen sind.
12
{ … }
if( i2%2)
upr3()
t3
311
12 Leistungsanalyse und Leistungsmessung
Initialisierung
Inkrement Test
nein
ja
Schleifenkörper
Alle Teile tragen zur Gesamtlaufzeit einer Schleife bei. Im Allgemeinen kann man
daher nicht einfach etwas weglassen. Es ist durchaus denkbar, dass etwa in der Initia-
lisierung einer Schleife eine sehr rechenintensive Prozedur gerufen wird und der
Aufwand zur Initialisierung der Schleife alle anderen Aufwände deutlich übersteigt.
Wir versuchen daher eine vollständige Bilanz:
Außerdem sei:
ttest (k) die Laufzeit des Tests vor dem k-ten Schleifendurchlauf
tincr (k) die Laufzeit des Inkrements am Ende des k-ten Durchlaufs
Dann berechnet sich die Laufzeit der Schleife nach n Durchläufen wie folgt:
...
+ tbody (n) + tincr (n) + ttest (n + 1)
312
12.1 Leistungsanalyse
Diese Formel ist sehr unhandlich und führt, wenn sie in dieser Form im Struktur-
baum eines Programms propagiert wird, zu nicht mehr handhabbaren Ausdrücken.
Unter gewissen zusätzlichen Annahmen lässt sich die Formel erheblich verein-
fachen.
Gibt es zusätzlich eine gemeinsame obere Schranke tmax für die Laufzeit des Schlei-
fenkörpers, ist:
t(n) ≤ ntmax
Ist die Laufzeit des Schleifenkörpers sogar unabhängig vom einzelnen Schleifen-
12
durchlauf, ergibt sich:
t(n) = ntbody
In unserem Beispiel sind in der inneren Schleife die Bedingungen zur Vereinfachung
gegeben. Wir können daher wie folgt propagieren:
upr3()
t3 Die Laufzeit des Schleifenkörpers hängt
nicht vom Schleifendurchlauf ab.
Dieses Ergebnis fließt nun zusammen mit der Laufzeit von upr2 in eine Fallunter-
scheidung ein.
Zur vollständigen Bilanzierung einer Fallunterscheidung müssen der Test und die
beiden Alternativen berücksichtigt werden:
313
12 Leistungsanalyse und Leistungsmessung
if( bedingung)
alternative1
else
alternative2
Außerdem sei:
Kann die Laufzeit zur Überprüfung der Bedingung im Vergleich zur Laufzeit der
Alternativen vernachlässigt werden, vereinfacht sich die Formel zu:
314
12.1 Leistungsanalyse
if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2
upr3()
t3
Wenn es eine gemeinsame obere Schranke tmax für die Laufzeiten der beiden Alterna-
tiven gibt, kann man die Laufzeit der Fallunterscheidung abschätzen:
t ≤ tbed + tmax
Als obere Schranke ist die Laufzeitsumme der beiden Alternativen geeignet:
Auch hier kann tbed weggelassen werden, wenn die Laufzeit zur Prüfung der Bedin-
gung im Vergleich zu den anderen Laufzeiten klein ist.
t ≤ t 2 + i 2t 3
315
12 Leistungsanalyse und Leistungsmessung
den, die (möglichst knapp) oberhalb der Funktionen für die Alternativen verläuft
und möglichst einfach ist. Eine solche Funktion ist nicht immer leicht zu finden.
2. Unter Umständen kommen Sie bei einer Abschätzung durch die Einbeziehung sel-
tener, aber rechenintensiver Sonderfälle zu sehr ungünstigen Werten, die die
wirkliche Leistungsfähigkeit des Algorithmus nicht mehr wiedergeben.
Im zweiten Fall kann eine Betrachtung der Wahrscheinlichkeit, mit der die Alternati-
ven eintreten, hilfreich sein.
Wie üblich kann die Laufzeit zur Prüfung der Bedingung weggelassen werden, wenn
sie durch die anderen Terme dominiert wird.
In diesem Fall liefert die Formel allerdings keine Aussage mehr über die maximal zu
erwartende Laufzeit, sondern über die durchschnittlich zu erwartende Laufzeit. An
diesem Ergebnis ist man aber häufig genauso stark interessiert wie an der maxima-
len Laufzeit, weil es etwas über das Verhalten eines Programms in typischen Lastsitu-
ationen aussagt.
Da in unserem Beispiel die Fallunterscheidung gleich häufig mit geraden und unge-
raden Werten für i2 gerufen wird, können wir t wie folgt berechnen:
1 1
t = -- t 2 + -- i 2 t 3
2 2
weiterrechnen, da wir in der Lage sind, die Fallunterscheidung auf der nächsten
Ebene wieder zu eliminieren, wenn wir in der übergeordneten Schleife zwischen
geraden und ungeraden Werten der Schleifenvariablen unterscheiden:
316
12.1 Leistungsanalyse
Die Laufzeit der Schleife ergibt sich als Summe der Laufzeiten für die geraden und
ungeraden Werte der Schleifenvariablen. Diesen Wert tragen Sie in den Struktur-
baum des Programms ein:
if( i2%2)
upr3()
t3
Auf der nächsten Ebene finden Sie einen Block. Die Berechnungsvorschrift für einen
Block ist ganz einfach. Die Laufzeit in einem Block ist gleich der Summe aller Laufzei-
ten der einzelnen Anweisungen:
317
12 Leistungsanalyse und Leistungsmessung
Außerdem sei:
Dann berechnet sich die Gesamtlaufzeit des Blocks nach der Formel:
t = t1 + t2 + ... + tn
Blöcke können auch einen Eigenanteil am Berechnungsaufwand (z. B. für das Anle-
gen lokaler Variablen) haben. Diesen Aufwand können Sie jedoch in der Regel ver-
nachlässigen.
In unserem Beispiel enthält der Block zwei Anweisungen, und wir propagieren mit
der Summe:
{ … }
t1 + mt2 + m(m – 1)t3
if( i2%2)
upr3()
t3
318
12.1 Leistungsanalyse
Der Block gehört zu einer Schleife. Die Laufzeit des Blocks hängt aber nicht vom Schlei-
fendurchlauf ab. Wir können unser bisheriges Ergebnis daher einfach mit der Anzahl
der Schleifendurchläufe multiplizieren. Schließlich werden alle Anweisungen des Algo-
rithmus zu einem Unterprogramm zusammengefasst. Auch hier fällt durch die Lauf-
zeitkosten für den Unterprogrammaufruf noch einmal ein Eigenanteil an. Aber auch
diese Kosten können Sie vernachlässigen. Wir erhalten letztlich an der Wurzel unseres
Strukturbaums die Gesamtkosten für den Algorithmus (siehe Abbildung 12.13).
{ … } 12
t1 + mt2 + m(m – 1)t3
if( i2%2)
upr3()
t3
Da wir zusätzlich wissen, dass t1 = 500t3 und t2 = 50t3 ist, haben wir die Laufzeit unse-
res Programms bis auf einen Proportionalitätsfaktor c vollständig im Griff:
319
12 Leistungsanalyse und Leistungsmessung
Viel interessanter ist aber die Frage, welchen Einfluss die drei Unterprogramme2 auf
die Gesamtlaufzeit des Programms haben. Dazu betrachten wir noch einmal die
ursprüngliche Formel:
Wie Sie sehen, haben bezüglich n alle drei Programme das gleiche Gewicht. Bezüglich
m spielt das dritte Unterprogramm, obwohl es nur einen Bruchteil der Laufzeit des
ersten hat, eine bedeutend gewichtigere Rolle. Wenn m z. B. den Wert 1000 hat, geht
das erste Unterprogramm einfach, das zweite tausendfach und das dritte nahezu mil-
lionenfach in die Laufzeitbilanz ein. Dies zeigt, dass man sich bei einer Optimierung
des Algorithmus in erster Linie auf das dritte Unterprogramm konzentrieren sollte.
Sie sehen, dass uns die Laufzeitformel viel über das Programm verrät, was wir bei blo-
ßer Betrachtung des Codes vielleicht nicht erkannt hätten. Wenn Sie lernen wollen,
effizient zu programmieren, ist es daher unerlässlich, sich mit der Mathematik hinter
den Programmen zu beschäftigen.
12.2 Leistungsmessung
Eine Messung oder eine Messreihe ist in der Regel viel einfacher durchzuführen als die
mathematische Analyse eines Programms. Man muss sich allerdings fragen, was eine
Messung oder auch viele Messungen über die Laufzeitfunktion eines Programms aus-
sagen. Ohne zusätzliche Informationen sagen einzelne Messwerte so viel – oder besser
gesagt: so wenig – aus wie einzelne Punkte über den Verlauf einer Kurve:
Messwerte
2 Stellen Sie sich an dieser Stelle vor, dass die drei Programme nichts miteinander zu tun hätten.
320
12.2 Leistungsmessung
Aus Abbildung 12.14 geht hervor, dass einzelne Messungen eigentlich nichts über den
weiteren Verlauf einer Laufzeitfunktion jenseits der Messpunkte aussagen. Auch die
Hinzunahme weiterer Messpunkte führt nicht zu einer endgültigen Sicherheit, wie
sie eine theoretische Analyse liefert. Da aber eine vollständige theoretische Analyse
von Algorithmen oft unmöglich ist, muss man zur Beurteilung der Leistungsfähig-
keit von Algorithmen letztlich doch auf praktische Messungen zurückgreifen. Paral-
lel zu den Messungen sollte man sich aber stets anhand theoretischer Überlegungen
darüber klar werden, inwieweit die Messergebnisse plausibel und verallgemeine-
rungsfähig sind.
Für die folgenden Messungen legen wir das bereits ausführlich diskutierte Testpro-
gramm mit
zugrunde:
Bei der Messung eines Programms interessieren uns vorrangig zwei Gesichtspunkte:
321
12 Leistungsanalyse und Leistungsmessung
왘 die Überdeckungsanalyse
왘 die Performance-Analyse
Im ersten Fall wird ermittelt, wie oft gewisse Programmteile durchlaufen werden,
während im zweiten Fall die Laufzeit gewisser Programmteile gemessen wird.
12.2.1 Überdeckungsanalyse
Für konkrete Messungen müssen wir mit konkreten Werten für die Parameter n und
m unseres Programms arbeiten. Wir setzen mehr oder weniger willkürlich n = 17 und
m = 13. Entsprechend unseren Vorüberlegungen erwarten wir in dieser Situation:
3 Ich möchte Ihnen hier kein konkretes Werkzeug vorstellen, da Sie je nach Entwicklungsumge-
bung andere Werkzeuge vorfinden.
322
12.2 Leistungsmessung
12.2.2 Performance-Analyse
In diesem Abschnitt wollen wir konkrete Laufzeitmessungen an unserem Programm
durchführen. Dazu analysieren wir das Programm mit einem Werkzeug zur Perfor-
mance-Analyse, um sogenannte Laufzeitprofile zu erstellen. Die Tabelle4 in Abbil-
dung 12.17 zeigt die Messergebnisse für zehn Messungen mit unterschiedlichen
Werten für n und m.
Die Messungen bestätigen unsere Annahmen über das Verhältnis der Laufzeiten der
Unterprogramme und zeigen, dass wir die Gesamtlaufzeit des Programms sehr prä-
zise vorhersagen können, sofern uns die Laufzeiten der drei Unterprogramme
bekannt sind. Die Vorhersagen sind für große Werte von m weniger präzise, was aber
zu erwarten war, da dann ja Ungenauigkeiten bei der Messung von upr3 mit einem
relativ großen Faktor multipliziert werden.
323
12 Leistungsanalyse und Leistungsmessung
noch einmal betrachten, stellen wir fest, dass m quadratisch in die Formel eingeht,
während n nur linear vorkommt. Dies bedeutet, dass sich große Werte von m erheb-
lich stärker in der Laufzeit des Algorithmus niederschlagen als entsprechende Werte
von n. Die Laufzeiten der Unterprogramme haben nur einen untergeordneten Ein-
fluss auf diesen Effekt. Egal, wie klein man t3 auch wählt, um den Einfluss von m zu
verringern, für hinreichend große Werte wird sich m immer als die die Laufzeit domi-
nierende Einflussgröße durchsetzen. Solche Überlegungen zum sogenannten asym-
ptotischen Laufzeitverhalten werden wir im Folgenden vertiefen.
12.3 Laufzeitklassen
Im letzten Abschnitt war es uns gelungen, eine vollständige Laufzeitanalyse eines
Programms durchzuführen. Wir haben aber erkennen müssen, dass eine vollstän-
dige theoretische Durchdringung eines komplexen Algorithmus mit den bisher
bereitgestellten Mitteln wohl kaum möglich ist. Für viele Zwecke ist eine solche For-
mel auch zu konkret und enthält noch zu viele unnötige Detailinformationen über
den Aufbau des Algorithmus. Wir möchten Algorithmen auf einer abstrakteren
Ebene miteinander vergleichen. Dazu benötigen wir ein Maß für die Leistungsfähig-
keit eines Algorithmus, das uns einfache Klassifizierungen ermöglicht. Ein solches
Maß wollen wir jetzt entwickeln.
Für die weiteren Überlegungen dieses Abschnitts setze ich voraus, dass Sie einige
wichtige mathematische Grundfunktionen und Formeln beherrschen. Im Einzelnen
handelt es sich um:
324
12.3 Laufzeitklassen
Außerdem benötigen wir im Laufe des Abschnitts folgende Formeln, die Sie in jeder
mathematischen Formelsammlung finden:
n(n + 1)
왘 1 + 2 + 3 + … + n = -------------------- (Summe der ersten n Zahlen)
2
n ( n + 1 ) ( 2n + 1 )
왘 1 + 22 + 32 + … + n2 = ----------------------------------------- (Summe der ersten n Quadratzahlen)
6
n+1
q –1
왘 q0 + q1 + q2 + … + qn = --------------------- für q ≠ 1 (Summenformel der geometrischen Reihe)
q–1
Die Formeln sind wichtig, weil sie in ganz natürlicher Weise bei der Ermittlung von
12
Laufzeitfunktionen immer wieder benötigt werden, und die Funktionen sind wich-
tig, weil sie häufig als Laufzeitfunktionen von Algorithmen auftreten. Dass Potenz-
funktionen und Exponentialfunktionen als Laufzeitfunktionen vorkommen, haben
Sie bereits an vielen Beispielen gesehen. Logarithmen und Wurzelfunktionen kön-
nen aber ebenfalls auftreten. Das zeigen die nächsten beiden Beispiele.
Betrachten Sie das folgende Programm, und versuchen Sie herauszufinden, welche
Werte die Funktion funktion1 allgemein zurückgibt. Als Hilfe habe ich Ihnen schon
einmal die Werte für n = 1-20 angegeben:
325
12 Leistungsanalyse und Leistungsmessung
Wenn Sie genau hinsehen, werden Sie feststellen, dass die Funktion immer bei einer
Zweierpotenz ihren Wert um 1 erhöht. Das liegt daran, dass die Variable x immer
ihren Wert verdoppelt, bis sie die Schranke n erreicht.
Die Frage, wie oft man einen Wert verdoppeln kann, bis eine bestimmte Schranke
erreicht ist, beantwortet uns der Logarithmus, der ja die Umkehrung der Exponenti-
alfunktion ist. Durch die Verdopplung ergibt sich in der Funktion: x = 2k. Damit folgt:
k k
x ≤ n ⇔ 2 ≤ n ⇔ log 2 ( 2 ) ≤ log 2( n ) ⇔ k ⋅ log 2 ( 2 ) ≤ log 2( n ) ⇔ k ≤ log 2( n )
Somit läuft k immer bis zum nächstliegenden ganzzahligen Wert des Zweierlogarith-
mus von n. Die Laufzeitfunktion unseres Beispiels ist also eine »diskrete Abtastung«
des Logarithmus zur Basis 2:
6
log2(n)
2 Laufzeitfunktion
0
0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50
Da man in der Regel nur an einer (möglichst guten) Abschätzung der Laufzeitfunk-
tion nach oben interessiert ist, kann man also sagen:
t(n) ≤ log2(n)
Wir betrachten ein weiteres Beispiel. Auch hier sollten Sie es zunächst einmal wieder
selbst versuchen. Analysieren Sie den Code und die Ausgabe in Abbildung 12.20. Ver-
suchen Sie so, die Laufzeitfunktion zu bestimmen. Erst dann lesen Sie unterhalb der
Abbildung weiter.
326
12.3 Laufzeitklassen
In diesem Beispiel durchläuft y die ungeraden Zahlen. Die Variable x berechnet also
Summen ungerader Zahlen. Die Formel zur Berechnung dieser Summe habe ich
Ihnen am Anfang dieses Abschnitts vorgestellt. Die Summe der ersten k ungeraden
2
Zahlen ist k2. Hier ergibt sich also: x ≤ n ⇔ k ≤ n ⇔ k ≤ 2 n . Das heißt: t ( n ) ≤ n
Die Laufzeitfunktion dieses Beispiels ist also eine Diskretisierung der Wurzel-
funktion:
7
2
6 x
5
2
Laufzeitfunktion
1
0
0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50
327
12 Leistungsanalyse und Leistungsmessung
Laufzeitfunktion t(n)
Laufzeit
vor Optimierung
topt(n)
Laufzeitfunktion
nach Optimierung
Datenvolumen
n0
5 Es sei denn, dass die Verbesserung erst bei einem Datenvolumen eintritt, das in unserem Pro-
gramm gar nicht vorkommt.
328
12.3 Laufzeitklassen
»immer« besser sein muss als eine andere, sondern nur »fast immer« – also ab einem
bestimmten Wert n0, der beliebig, aber fest ist. Diese Art des Vergleichs hat eine ganz
neue Qualität. Wir haben es jetzt mit einer infinitesimalen Begriffsbildung zu tun.
Das bedeutet, dass man endlich viele Werte der Funktion abändern kann, ohne dass
die Vergleichsaussage an Wert verliert. Die Entscheidung über den besseren Algorith-
mus fällt sozusagen erst »im Unendlichen«. Dies unterstreicht noch einmal die frü-
her bereits getroffene Feststellung, dass endlich viele Messwerte eigentlich nichts
über die Qualität eines Algorithmus aussagen.
Bevor wir das in einer Definition festhalten, wollen wir noch einen anderen Aspekt
bei der Beurteilung von Laufzeitfunktionen diskutieren. Stellen Sie sich vor, dass Sie
ein Programm geschrieben haben, das Integer-Zahlen sortiert. Dieses Programm
stellen Sie auf die Sortierung von Gleitkommazahlen um. Da ein Rechner Gleitkom-
mazahlen nicht so effizient verarbeiten kann wie Integer-Zahlen, wird sich die Lauf-
zeit des Programms durch diese Änderung um einen konstanten Faktor c
verschlechtern:
12
c · t(n)
Laufzeit
t(n)
Datenvolumen
Trotzdem sind wir weit davon entfernt zu behaupten, dass der Algorithmus jetzt
schlechter geworden ist. Es handelt sich nach wie vor um den gleichen Algorithmus
mit dem gleichen Laufzeitverhalten. Daher interessiert uns eine solche multiplikative
Konstante bei der Beurteilung von Laufzeiten erst in zweiter Linie. Zur Beurteilung der
Leistungsfähigkeit von Algorithmen benötigen wir ein Klassifizierungsschema für
Laufzeitfunktionen, das von der konkreten Formel der Laufzeitfunktion abstrahiert,
trotzdem aber die wesentlichen Informationen über das qualitative Verhalten der
Funktion »im Unendlichen« enthält. Sehr hilfreich sind dafür die folgenden Begriffs-
bildungen:
329
12 Leistungsanalyse und Leistungsmessung
678
Laufzeitfunktion
Unter einer Laufzeitfunktion wollen wir im Folgenden stets eine nicht negative
Funktion von den natürlichen Zahlen in die natürlichen Zahlen verstehen.
Für zwei Laufzeitfunktionen f und g schreiben wir f Ɐ g, wenn es eine Konstante c > 0
und eine natürliche Zahl n0 so gibt, dass f(n) ≤ c · g(n) für alle natürlichen Zahlen n > n0 gilt.
Gilt sowohl f Ɐ g als auch g Ɐ f, schreiben wir f ≈ g.
Gilt f Ɐ g, aber nicht f ≈ g, schreiben wir auch f Ɱ g.
Ich füge noch zwei in der Mathematik und Informatik üblicherweise verwendete
Begriffe hinzu:
Mit O(g) bezeichnen wir die Menge aller Funktionen f, für die f Ɐ g gilt6. In diesem
Sinne kann man anstelle von f Ɐ g auch f ∈ O(g) schreiben. Weit verbreitet ist auch
die Notation7 f = O(g).
Mit Θ(g) bezeichnen wir die Menge aller Funktionen f, für die f ≈ g gilt8. In diesem
Sinne kann man anstelle von f ≈ g auch f ∈ Θ(g) schreiben. Weit verbreitet ist auch
die Notation f = Θ(g).
c · g(n)
Laufzeit
f(n)
g(n)
n0 Datenvolumen
330
12.3 Laufzeitklassen
Das Wachstum von f könnte aber durchaus geringer als das von g sein, da es nach
unten keine durch g definierte Auffanglinie gibt. Eine solche Linie gibt es zusätzlich,
falls f ≈ g ist. Dann gibt es einen durch zwei multiplikative Konstanten definierten
»Kanal«, in dem sich f fast immer bewegt (siehe Abbildung 12.25).
c1 · g(n)
Laufzeit
f(n)
g(n)
c2 · g(n)
12
n0 Datenvolumen
Da f weder nach unten noch nach oben ausbrechen darf, hat f das gleiche Wachstum
wie g. Der Kanal muss nicht, wie in der Skizze gezeigt, um g herum liegen. Wichtig ist
nur, dass g durch die beiden Konstanten das Wachstum des Kanals vorgibt. Auch der
Wert von n0 ist mehr oder weniger willkürlich. Wichtig ist nur, dass f ab n0 den vorge-
gebenen Kanal nicht mehr verlässt. Jeder größere Wert wäre auch geeignet. Diese
Willkür in der Wahl des »Kanals« führt oft zu Verwirrung:
c1 · g(n)
Laufzeit
f(n)
c2 · g(n)
g(n)
n0 Datenvolumen
331
12 Leistungsanalyse und Leistungsmessung
Wenn Sie sich auf das Wachstum von Laufzeitfunktionen konzentrieren, können Sie
die Funktionen oft erheblich vereinfachen, indem Sie Funktionsterme eliminieren,
die keinen wesentlichen Beitrag zum Wachstum der Funktion liefern. Wir betrachten
dazu zunächst einmal Polynome und stellen uns vor, dass wir die folgende Laufzeit-
funktion zu einem Algorithmus ermittelt haben:
Wir wollen diese Funktion nach oben abschätzen. Dazu lassen wir negative Terme
einfach weg, da sie das Wachstum bremsen. Wir erhalten:
t(n) ≤ n4 + 3n3 + n
t(n) Ɐ n4
Da wir eine analoge Abschätzung für ein beliebiges Polynom durchführen können,
erkennen wir, dass das Wachstum eines polynomialen Ausdrucks durch die höchste
Potenz dominiert wird. Wir versuchen jetzt noch eine Abschätzung in die umge-
kehrte Richtung. Dazu lassen wir zunächst die positiven Terme niedriger Potenz weg:
t(n) ≥ n4 – 2n2 – 3
Die negativen Terme vergrößern wir noch, indem wir zur dritten Potenz übergehen:
Jetzt opfern wir die Hälfte unserer höchsten Potenz, um damit die niederen Potenzen
zu eliminieren:
1 4 1 4 3 1 4 1 3
t ( n ) ≥ -- n + -- n – 5n = -- n + -- n ( n – 10 )
2 2 2 2
Für n ≥ 10 ist der letzte Term nicht mehr negativ und kann weggelassen werden.
Damit haben wir:
1 4
t ( n ) ≥ -- n für n ≥ 10
2
9 Abschätzungen enthalten immer eine gewisse Willkür. Man könnte hier durchaus filigraner
abschätzen. Aber das ist nicht nötig. Stellen Sie sich vor, dass Sie sich beim Bäcker ein Brötchen
kaufen wollen. Durch einen flüchtigen Blick ins Portemonnaie sehen Sie, dass Sie noch Geld-
scheine haben. Dann würden Sie doch auch nicht anfangen, Ihr Kleingeld zu zählen, um festzu-
stellen, ob es für ein Brötchen reicht.
332
12.3 Laufzeitklassen
Also:
t(n) Ɒ n4
t(n) ≈ n4
200000 5n4
180000
160000
140000
120000
100000 12
80000
60000
n0 n4 + 3n3 – 2n2 + n – 3
40000
20000 1 n4
2
0
1 2 3 4 5 6 7 8 9 10 11 1213 14 1516 17 1819202122232425
Viel wichtiger als diese konkrete Abschätzung ist aber das durch Verallgemeinerung
gewonnene Ergebnis:
Wir müssen bei polynomialen Laufzeitfunktionen also immer nur auf die höchste
Potenz achten. Wegen des zusätzlichen Faktors n, den man durch keine Konstante
einfangen kann, haben höhere Potenzen ein echt größeres Wachstum. Die Laufzeit-
funktionen im polynomialen Bereich sind also nach Potenzen geordnet:
1 Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...
Das Gleiche gilt auch für nicht ganzzahlige Potenzen – also auch für die Wurzeln
1⁄k
(k n = n ) . Auch hier »zählt« immer nur die höchste Potenz, und es ist insgesamt:
333
12 Leistungsanalyse und Leistungsmessung
Bei den Logarithmen müssen Sie nur wissen, dass sich Logarithmen unterschiedli-
cher Basis nur durch einen konstanten Faktor unterscheiden. Damit haben alle Loga-
rithmen ungeachtet ihrer Basis das gleiche Wachstumsverhalten, das schwächer als
jede Potenz ist. Wir können also unsere Kette wie folgt erweitern:
Am oberen Ende der Kette stehen die Exponentialfunktionen, die je nach Größe ihrer
Basis unterschiedlich schnell wachsen und jede Potenz in ihrem Wachstum übertref-
fen. Damit erhalten wir:
Diese Kette zeigt nur einige wichtige Vertreter von Laufzeitfunktionen. Beliebige
3 n
Funktionen mit gebrochener Basis (z. B. ⎛ --⎞ ) oder gebrochenem Exponenten (z. B.
3⁄ ⎝ 2⎠
n 2 ) oder Kombinationen dieser Grundtypen (z. B. n · log(n)) können vorkommen.
Diese Funktionen bilden nur ein Gerüst, anhand dessen man weitere Laufzeitfunkti-
onen einordnen kann (siehe Abbildung 12.28).
1 konstant
log(n)
logarithmisch
log2(n)
log3(n)
…
logk(n)
…
k
n
…
3
n
n · log(n)
n
n · log2(n)
n linear
n · log3(n)
polynomial
…
n2 quadratisch
n · logk(n)
n3 kubisch
…
…
nk
…
exponentiell
2n
3n
…
kn
…
334
12.3 Laufzeitklassen
Bei der Zuordnung einer Laufzeitfunktion zu einer Laufzeitklasse kommt es jetzt dar-
auf an, möglichst früh unwesentliche Terme unter den Tisch fallenzulassen, damit
man möglichst elegant zu einem aussagekräftigen Ergebnis kommt. Wir betrachten
dazu einige Beispielprogramme, denen wir jeweils versuchen, eine Laufzeitklasse
zuzuordnen, ohne uns zu tief in Detailberechnungen zu verlieren.
12.3.1 Programm 1
1 1
2 3
3 6
4 10
5 15
6 21
7 28
8 36
9 45
10 50
11 56
12 62
13 69
14 76
15 84
335
12 Leistungsanalyse und Leistungsmessung
Die äußere Schleife dieses Programms wird linear (1-n) durchlaufen, die innere dage-
gen höchstens 11-mal. Daraus folgt: n Ɐ t(n) Ɐ 11n. Das Programm ist also linear:
t(n) ≈ n.
12.3.2 Programm 2
for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k++)
z++;
}
return z;
}
1 1
2 3
3 3
4 7
5 7
6 7
7 7
8 15
9 15
10 15
11 15
12 15
13 15
14 15
15 15
Wenn Sie sich bei diesem Programm die äußere Schleife wegdenken und stattdessen
einfach den Maximalwert i = n annehmen, sehen Sie, dass das Programm mindestens
linear ist. Auf der anderen Seite verdoppelt i mit jedem Schritt in der äußeren
336
12.3 Laufzeitklassen
Schleife seinen Wert, erreicht also das Schleifenende nach log2(n) Schritten. Stellen
Sie sich jetzt vor, dass wir im s-ten dieser Schritte sind. Dann hat i den Wert 2s. Dann
wurden in der inneren Schleife bisher 1 + 2 + 22 + ... + 2s Schritte durchgeführt. Nach
der eingangs erwähnten Summenformel der geometrischen Reihe ist:
s+1
2 s 2 –1 s+1 s
1 + 2 + 2 + … + 2 = -------------------- ≤ 2 = 2⋅2
2–1
log 2 ( n )
Da es maximal log2(n) Schritte gibt, ist t(n) Y 2 · 2 = 2n
Insgesamt ist also n Ɐ t(n) Ɐ 2n. Daher ist auch dieses Programm linear: t(n) ≈ n.
An dieser Stelle erkennen Sie deutlich den Nutzen dieser Überlegungen. Die beiden
ersten Programme haben, obwohl sie grundverschieden sind, die gleichen Wachs-
tumseigenschaften und sind darum in der gleichen Leistungsklasse – so, wie man
etwa zwei grundverschiedene Autos bezüglich ihrer Motorleistung vergleichen kann.
12.3.3 Programm 3 12
Das dritte Programm ist dem zweiten oberflächlich durchaus ähnlich. Ich habe nur
die beiden Schleifen getauscht. Die Vervielfachung findet jetzt in der inneren Schleife
statt, während der Index der äußeren Schleife linear wächst. Diesmal werden Sie aber
kein lineares Verhalten erkennen. Doch zunächst werfen wir einen Blick auf das Pro-
gramm:
337
12 Leistungsanalyse und Leistungsmessung
1 1
2 3
3 5
4 8
5 11
6 14
7 17
8 21
9 25
10 29
11 33
12 37
13 41
14 45
15 49
In der inneren Schleife gibt es log2(i) Durchläufe. Das bedeutet, wenn man die Formel
log(a) + log(b) = log(a · b) iteriert anwendet:
und andererseits
n⁄ n
t ( n ) ≈ log 2 ( n! ) ≥ log 2 ( n 2 ) = --- ⋅ log 2 ( n )
2
Insgesamt ist daher: t ( n ) ≈ n ⋅ log ( n )
Das ist übrigens eine ganz wichtige Laufzeitklasse. Wenn wir uns in einem späteren
Abschnitt mit Sortierung beschäftigen, werden wir erneut auf diese Laufzeitklasse
stoßen.
Es ist übrigens ganz interessant, dieses Programm mit dem ersten Programm zu ver-
gleichen. Obwohl dieses Programm wegen n Ɱ n · log(n) in einer schlechteren Lauf-
zeitklasse ist als das erste, hat man bei Betrachtung der Bildschirmausgaben den
gegenteiligen Eindruck:
1 1 1
2 3 3
338
12.3 Laufzeitklassen
3 6 5
4 10 8
5 15 11
6 21 14
7 28 17
8 36 21
9 45 25
10 50 29
11 56 33
12
12 62 37
13 69 41
14 76 45
15 84 49
Das liegt daran, dass die Entscheidung erst »im Unendlichen« fällt. Erst bei n = 1919
entscheidet sich, welche Funktion die größere ist.
339
12 Leistungsanalyse und Leistungsmessung
12.3.4 Programm 4
Zur Entspannung analysieren wir jetzt ein ganz einfaches Programm, bei dem die
innere Schleife immer bis zum Quadrat des Schleifenindex der äußeren Schleife
läuft. Die resultierende Laufzeitkomplexität können Sie schon erahnen:
1 1
2 5
3 14
4 30
5 55
6 91
7 140
340
12.3 Laufzeitklassen
8 204
9 285
10 385
11 506
12 650
13 819
14 1015
15 1240
Es ist hier so, dass es in der inneren Schleife immer i2 Durchläufe gibt, sodass es ins-
gesamt
n ( n + 1 ) ( 2n + 1 )
t ( n ) = 1 + 2 2 + 3 2 + … + n 2 = -----------------------------------------
6
Durchläufe gibt. Hier haben wir sogar eine explizite Laufzeitformel, die Sie mit der
oben dargestellten Bildschirmausgabe vergleichen können. Da wir aber nur an der
12
Komplexitätsklasse interessiert sind, stellen wir fest, dass das asymptotische Verhal-
ten durch die höchste vorkommende Potenz bestimmt wird. Es ist also: t(n) ≈ n3.
12.3.5 Programm 5
Im nächsten Beispiel wachsen die Schleifenindizes in beiden Schleifen exponentiell:
for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k *= 2)
z++;
}
return z;
}
341
12 Leistungsanalyse und Leistungsmessung
1 1
2 3
3 3
4 6
5 6
6 6
7 6
8 10
9 10
10 10
11 10
12 10
13 10
14 10
15 10
Dementsprechend moderat ist das Wachstum der Funktion, weil beide Schleifenzäh-
ler durch die Verdopplung sehr schnell ihr Ziel erreichen. Die äußere Schleife benö-
tigt, wegen der Verdopplung log2(n) Schritte, wobei beim s-ten Schritt i den Wert i = 2s
hat. In der inneren Schleife benötigt die Variable k dann aber, ebenfalls wegen der
Verdopplung, s Schritte, um diesen Wert von i zu erreichen. Das heißt, im s-ten
Schleifendurchlauf der äußeren Schleife macht die innere Schleife genau s Durch-
läufe. Da die äußere Schleife log2(n) Durchläufe macht, ergibt das:
log 2 ( n ) ( log 2 ( n ) + 1 ) 1
t(n) = 1 + 2 + 3 + ... + log2(n) = ----------------------------------------------------- = -- ⎛ log 22 ( n ) + log 2 ( n )⎞
2 2⎝ ⎠
Unter log2(n) wird hier wieder der nächstpassende ganzzahlige Wert verstanden, und
ich habe zur Auswertung der Summe die gaußsche Summenformel angewandt. Da
das Quadrat des Logarithmus den nicht quadrierten Logarithmus dominiert und der
Faktor ½ keine Rolle spielt, erhalten wir:
t(n) ≈ log2(n)
Dieses Programm hat die niedrigste Laufzeitkomplexität unter unseren sechs Bei-
spielen.
12.3.6 Programm 6
In unserem letzten Beispiel werden wir es mit exponentieller Laufzeit zu tun haben:
342
12.3 Laufzeitklassen
Hier muss der Zähler k einem exponentiell wachsenden m hinterherlaufen. Die Vari-
able m hat immer den Wert m = 2i–1. In der inneren Schleife werden also immer m = 2i–1
Durchläufe ausgeführt. Damit ist:
2n – 1
t ( n ) = 1 + 2 + 2 2 + 2 3 + … + 2 n – 1 = -------------- = 2 n – 1 ≈ 2 n
2–1
Dieses Programm ist also das mit der höchsten Laufzeitkomplexität unter unseren
sechs Beispielen.
343
12 Leistungsanalyse und Leistungsmessung
Nun könnte man argumentieren, dass es eigentlich egal ist, welcher Leistungsklasse
ein Algorithmus angehört, da unsere Rechner immer schneller werden und irgend-
wann so schnell sein werden, dass die Frage nach der Effizienz von Algorithmen zu
den Akten gelegt werden kann. Dem kann man zweierlei entgegenhalten. Zum einen
ist Effizienz ein grundsätzlicher Wert, den man immer anstreben sollte, denn auch
auf einem schnelleren Rechner bleibt ein »guter« Algorithmus besser als ein
»schlechter«. Schnelle Rechner machen aus schlechten Programmen keine guten
Programme. Ein zweites Argument ist aber noch gewichtiger. Schauen Sie sich die
Tabelle in Abbildung 12.29 an. Sie zeigt, welche Gewinne man für Algorithmen unter-
schiedlicher Leistungsklassen aus einer Vervielfachung der Rechnerleistung zieht:
Die Tabelle zeigt, dass selbst eine Vertausendfachung der Rechnerleistung nur
geringe Gewinne im Bereich der exponentiell wachsenden Algorithmen bringt.
Selbst ein 1000-mal schnellerer Rechner schafft es nur, ein Problem der Leistungs-
klasse 2n für knapp zehn Elemente mehr in der gleichen Zeitvorgabe zu lösen. Für uns
bedeutet dies, dass die Suche nach Algorithmen niedriger Zeitkomplexität immer
344
12.3 Laufzeitklassen
ein wichtiges Anliegen der Programmierung sein wird und es keinen Sinn macht, auf
zukünftige Rechner zu warten. Unser Ziel muss immer sein, einen Algorithmus in
eine möglichst optimale Leistungsklasse zu bringen.
Grundsätzlich sollte allerdings auch gesagt werden, dass Algorithmen einer höheren
Laufzeitkomplexität nicht in jeder Situation schlechter sind als solche mit einer nied-
rigeren Laufzeitkomplexität. Sie erinnern sich, dass die entsprechende Ungleichung
erst ab einer bestimmten, unter Umständen sehr großen Zahl gelten muss. Es gibt
Fälle, in denen asymptotisch schlechtere Verfahren, z. B. aufgrund einer einfacheren
Implementierung, eingesetzt werden, weil entsprechend große Datenmengen nicht
zu verarbeiten sind, und es gibt auch Fälle, in denen die asymptotisch besten bekann-
ten Verfahren nicht eingesetzt werden, weil ihre Vorzüge erst in Bereichen zum Tra-
gen kommen, die nicht mehr praxisrelevant sind. Und es gibt leider auch Fälle, in
denen exponentiell wachsende Verfahren eingesetzt werden müssen, weil keine effi-
zienteren Verfahren bekannt sind.
Nur ein Fall sollte auf keinen Fall eintreten. Es sollte nicht vorkommen, dass ineffizi-
12
ente Verfahren aus Unkenntnis effizienterer Algorithmen oder aus dem Unvermö-
gen heraus, eine Laufzeitanalyse durchzuführen, eingesetzt werden. Das ist so, als
würde ein Maschinenbauer, ohne sich um den Wirkungsgrad zu kümmern, einen
Motor konstruieren, der im Ergebnis überwiegend Verlustwärme produziert. Ineffizi-
ente Algorithmen verursachen ja im wahrsten Sinne des Wortes Verlustwärme, da sie
die CPU des Rechners über das notwendige Maß hinaus beanspruchen.
345
Kapitel 13
Sortieren
Ordnung lehrt Euch Zeit gewinnen
– Johann Wolfgang von Goethe
Eine klassische Aufgabe der Datenverarbeitung ist das Sortieren von Datensätzen
nach einem bestimmten Kriterium. Wir wollen die Rahmenbedingungen stark ver-
einfachen, um uns auf den eigentlichen algorithmischen Kern von Sortierverfahren
konzentrieren zu können. Wir stellen uns die Aufgabe, ein Array von ganzen Zahlen
in aufsteigender Reihenfolge zu sortieren. Gesucht wird der effizienteste Algorith-
mus für dieses Problem.
13
13.1 Sortierverfahren
Das Thema der Sortierung ist so wichtig und zugleich so ergiebig, dass wir verschie-
dene Verfahren formulieren und als C-Programme realisieren werden. Konkret wer-
den wir die folgenden Verfahren betrachten:
왘 Bubblesort
왘 Selectionsort
왘 Insertionsort
왘 Shellsort
왘 Quicksort
왘 Heapsort
Die verschiedenen Verfahren werden wir als Funktionen implementieren und mit
einer einheitlichen Schnittstelle ausstatten, an der wir die Anzahl der Daten (int n)
und das Array mit den Daten (int *daten) übergeben.
Damit sind wir in der Lage, vorab einen einheitlichen Testrahmen für alle Sortierpro-
gramme dieses Abschnitts zu erstellen:
347
13 Sortieren
Das Hauptprogramm enthält das zu sortierende Array (daten). Dieses Array kann, je
nach Anforderung, mehrere Tausend oder sogar Millionen von Zahlen enthalten. Das
wird über die symbolische Konstante ANZAHL gesteuert. Nachdem der Zufallszahlen-
generator mit dem Startwert SEED gestartet wurde, wird das Array in der Funktion
testdaten mit Zufallszahlen gefüllt. Danach wird das Array mit dem zu testenden
Verfahren, hier vorläufig XXXsort genannt, sortiert. Eine Funktion zu schreiben, die
das Array vor und nach der Sortierung ausgibt, um eine Sichtkontrolle des Ergebnis-
ses vorzunehmen, macht angesichts der möglichen Datenflut keinen Sinn. Wir kom-
plettieren den Testrahmen daher durch eine Hilfsfunktion (pruefen), die prüft, ob das
Array korrekt sortiert ist:
In diesem Testrahmen werden wir mit entsprechend großen Arrays für alle hier
betrachteten Verfahren vergleichende Laufzeitbetrachtungen und Laufzeitmessun-
gen anstellen, in der Hoffnung, das Beste aller Sortierverfahren zu finden. Später wer-
den wir noch weitere Funktionen zur Testdatengenerierung hinzufügen.
348
13.1 Sortierverfahren
13.1.1 Bubblesort
Das erste Sortierverfahren, das wir untersuchen wollen, wird allgemein als Bubble-
sort bezeichnet. Der Name rührt vielleicht daher, dass die zu sortierenden Elemente
im Array wie Luftblasen im Wasser aufsteigen.
Array i k
0 1 2 3 4 5
Vor dem 1. Durchlauf 3 5 2 6 4 1 5
0
1
2
3
4
Nach dem 1. Durchlauf 3 2 5 4 1 6 4
0
1
2
3
Nach dem 2. Durchlauf 2 3 4 1 5 6 3
0
1
2
Nach dem 3. Durchlauf 2 3 1 4 5 6 2
0
1
Nach dem 4. Durchlauf 2 1 3 4 5 6 1
0
Nach dem 5. Durchlauf 1 2 3 4 5 6 0
349
13 Sortieren
Die in den beiden rechten Spalten stehenden Zahlen stellen bereits einen Bezug zu
den Schleifenzählern i und k des nachfolgenden Programms her. Startend mit n-1,
steht in der Zählvariablen i, wie viele Durchläufe noch durchzuführen sind. Inner-
halb eines Durchlaufs zählt die Variable k dann die Anzahl der durchgeführten Ver-
gleichsschritte.
Die Sortierfunktion startet mit der äußeren Schleife. Am Anfang müssen alle Ele-
mente betrachtet werden, dann immer eins weniger (A). Die zweite Schleife durch-
läuft den noch zu betrachtenden Bereich (B). Innerhalb dieser zweiten Schleife
werden zwei benachbarte Elemente verglichen (C). Wenn sie in der falschen Reihen-
folge sind, werden sie getauscht.
Abbildung 13.3 zeigt Bubblesort bei der Arbeit auf einem Array mit 100 Zufallszahlen.
Am Anfang, zweimal zwischendurch und am Ende wurden dabei Schnappschüsse
des Arrays gemacht:
350
13.1 Sortierverfahren
Das Bild zeigt, wie große Werte nach rechts wandern, bis sie ihre Position gefunden
haben, und so, von rechts nach links, Ordnung in die anfangs chaotische Punktwolke
einkehrt.
13.1.2 Selectionsort
Eine weitere Möglichkeit zur Sortierung besteht darin, immer das kleinste oder
größte Element im Array zu suchen und dieses dann durch Tausch direkt an die rich-
tige Stelle zu bringen. Dieses Verfahren nennen wir Selectionsort:
Durchlaufe das Array in aufsteigender Richtung, und suche das kleinste Ele-
ment! Vertausche das kleinste Element mit dem ersten Element! Das neue
erste Element ist jetzt an der korrekten Position und muss im Weiteren nicht
mehr betrachtet werden.
Durchlaufe das Array jetzt ab dem zweiten Element aufwärts, und suche wieder
das kleinste Element! Vertausche das gefundene Element mit dem zweiten Ele-
ment! Jetzt sind die beiden ersten Elemente im Array in der richtigen Reihen-
folge und müssen im Weiteren nicht mehr betrachtet werden. 13
Setze dieses Verfahren fort, bis das gesamte Array sortiert ist!
Auch hier veranschaulichen wir die einzelnen Verfahrensschritte durch eine Grafik:
Array i k
0 1 2 3 4 5
Vor dem 1. Durchlauf 3 5 2 6 4 1 0
1
2
3
4
5
351
13 Sortieren
Die dunklen Felder zeigen dabei das aktuell im Verfahren ausgewählte, minimale Ele-
ment. Am Ende eines Verfahrensschritts erfolgt dann der Tausch des jeweils kleins-
ten mit dem zuerst betrachteten Element.
In der äußeren Schleife werden die n-1 Verfahrensschritte durchgeführt (A). Zunächst
ist das erste zu betrachtende Element das kleinste (B), dann wird im Rest des Arrays
ein kleineres gesucht (C). In (D) wird dann das kleinste Element mit dem zuerst
betrachteten getauscht.
Beachten Sie, dass wir uns bei der Minimumsuche nicht den Wert des kleinsten Ele-
ments merken, sondern den Index, also die Stelle, an der das kleinste Element steht.
Dadurch haben wir die Möglichkeit, am Ende die Elemente zu tauschen.
352
13.1 Sortierverfahren
Aus der ungeordneten Punktwolke im rechten Teil wird jeweils das kleinste Element
entfernt und an die geordnete Kette im linken Teil angefügt. Dadurch wird die Sortie-
rung systematisch von links nach rechts aufgebaut. Rechts verbleiben die noch
unsortierten Elemente, die aber alle größer als die Elemente im bereits sortierten Teil
sind.
13.1.3 Insertionsort
13
Insertionsort ist ein Sortierverfahren, das so arbeitet, wie wir Spielkarten auf der
Hand sortieren.
3
7 8
4 5 10
2 6
1
9
Die erste Karte ganz links ist sortiert. Wir nehmen die zweite Karte und stecken
sie, je nach Größe, vor oder hinter die erste Karte. Damit sind die beiden ersten
Karten relativ zueinander sortiert.
Wir nehmen die dritte, vierte, fünfte ... Karte und schieben sie so lange nach
links, bis wir an die Stelle kommen, an der sie hineinpasst. Dort stecken wir sie
hinein.
353
13 Sortieren
In einem Array geht das Verschieben von Daten nicht so leicht wie bei einem Karten-
spiel auf der Hand. Wir können im Array nicht einfach ein Element »dazwischen-
schieben«. Dazu müssen zunächst alle übersprungenen Elemente nach rechts
aufrücken, um für das einzusetzende Element einen Platz frei zu machen.
Array i k
0 1 2 3 4 5
Vor dem 1. Durchlauf 3 5 2 6 4 1 1
1
Nach dem 1. Durchlauf 3 5 2 6 4 1 2
2
1
Nach dem 2. Durchlauf 2 3 5 6 4 1 3
3
Nach dem 3. Durchlauf 2 3 5 6 4 1 4
4
3
2
Nach dem 4. Durchlauf 2 3 4 5 6 1 5
5
4
3
2
1
Nach dem 5. Durchlauf 1 2 3 4 5 6 6
354
13.1 Sortierverfahren
E daten[k] = v;
}
}
Die äußere Schleife führt die n-1 Verfahrensschritte durch (A). Innerhalb dieser 13
Schleife wird das betrachtete Element außerhalb des Arrays gesichert (B). Im Array
rücken größere Elemente dann auf (C) und (D). Abschließend erhält das gesicherte
Element seine korrekte Position (E).
Wie bei Bubblesort und Selectionsort haben wir es im Programm mit einer Doppel-
schleife zu tun. Anstelle von Elementvertauschungen wird jetzt jedoch mit Element-
verschiebungen gearbeitet. Wie viele Elementverschiebungen in der inneren Schleife
durchgeführt werden, ist auf Anhieb nicht erkennbar.
Sie sehen, wie die Punktwolke von links nach rechts abgearbeitet wird. Im Gegensatz
zu den bisher diskutierten Verfahren werden die noch unsortierten Daten nicht
umgeordnet, und im sortierten Bereich können immer noch Elemente eingeschoben
werden.
355
13 Sortieren
13.1.4 Shellsort
Zur Einführung des nächsten Sortierverfahrens schwächen wir das Verfahren aus
dem vorangegangenen Abschnitt zunächst ab. Wir modifizieren Insertionsort so,
dass das Array nicht in Einerschritten, sondern in Schritten mit der Schrittweite h
durchlaufen wird. Dazu ersetzen wir die in Insertionsort vorkommende Konstante 1
durch eine Variable h (A, B und C), die wir zusätzlich an der Schnittstelle der Funktion
übergeben:
Für h = 1 ist dies unser altbekanntes Programm Insertionsort. Aber was macht dieses
Programm für h > 1? Nach wie vor das Gleiche wie Insertionsort, allerdings mit dem
Unterschied, dass sich der Algorithmus bei einem Durchlauf immer nur für Ele-
mente mit Abstand h interessiert.
Betrachten wir dies am Beispiel eines Arrays mit 17 Elementen und wählen dazu die
Schrittweite h = 3:
3 12 5 2 14 9 8 11 4 1 10 16 7 6 17 15 13
3 2 8 1 7 15
12 14 11 10 6 13
5 9 4 16 17
356
13.1 Sortierverfahren
Der Algorithmus betrachtet jetzt immer Elemente mit Abstand 3. Dadurch ergeben
sich drei ineinander verzahnte Teil-Arrays, die durch den Algorithmus sortiert wer-
den. Damit ergibt sich das folgende Ergebnis:
1 2 3 7 8 15
6 10 11 12 13 14
4 5 9 16 17
1 6 4 2 10 5 3 11 9 7 12 16 8 13 17 15 14
Das Ergebnis ist also ein Array, in dem alle Teilauswahlen von Elementen mit
Abstand h korrekt sortiert sind. Wir nennen diese »schwache« Form von Sortierung
eine h-Sortierung. 13
Betrachten Sie jetzt das folgende Programm:
B for( ; h > 0; h /= 3)
C insertion_h_sort( n, daten, h);
}
Am Anfang wird die Schrittweite h immer mit 3 multipliziert und dann noch um 1
vergrößert. Dadurch ergibt sich eine Folge von h-Werten1. Wenn die Schleife abgebro-
n
chen wird, ist h ≈ --- (A).
3
In der zweiten Schleife wird dann, wegen der Division ohne Rest, exakt die gleiche
Folge wieder rückwärts durchlaufen (B). Für n = 10000 ergibt sich dadurch z. B. die
Folge:
n+1
– 1 , aber das soll uns hier nicht interessieren.
1 Exakt ist das die Folge h n = 3----------------------
-
2
357
13 Sortieren
1
4
13
40
121
364
1093
3280
3280
1093
364
121
40
13
4
1
Da für jeden h-Wert der Folge die Funktion insertion_h_sort gerufen (C) wird, wird
mit fallender Schrittweite h immer wieder eine h-Sortierung durchgeführt. Das führt
am Ende dazu, dass das Array sortiert ist, da h = 1 als letzter Wert der Folge vorkommt.
Wir haben also ein neues Sortierverfahren gefunden, das wir noch optimieren kön-
nen, wenn wir die Funktion insertion_h_sort direkt im übergeordneten Programm
implementieren:
358
13.1 Sortierverfahren
Dass die Daten durch Shellsort sortiert werden, steht außer Frage, da ja für h = 1 Inser-
tionsort durchgeführt wird. Es drängt sich natürlich die Frage auf, warum man den
Algorithmus derart verkompliziert und nicht sofort mit Insertionsort eine Sortie-
rung durchführt.
Eine plausible Antwort auf diese Frage ist nicht einfach. Der Vorteil liegt, grob gespro-
chen, darin, dass Shellsort zunächst weiträumige Vertauschungen im Array durch-
führt, während Insertionsort mit vielen (zu vielen) Nachbarvergleichen und -ver-
tauschungen arbeitet. Wenn Shellsort schließlich mit h = 1 Insertionsort durchführt,
ist das Array schon so geschickt vorsortiert, dass Insertionsort hier viel effizienter
abläuft als auf einem nicht vorsortierten Array. Sie werden später sehen, dass Shell-
sort in der Praxis viel effizienter arbeitet als Insertionsort und den scheinbaren
Mehraufwand geradezu spielend kompensiert.
Auch in den Schnappschüssen zeigt sich für Shellsort ein ganz anderes Bild als bei
den bisherigen Verfahren:
13
13.1.5 Quicksort
Wir werden jetzt ein Sortierverfahren konstruieren, das auf dem Prinzip »Teile und
herrsche« beruht und rekursiv arbeitet. Das Prinzip ist einfach:
Zerlege das Array in zwei Teile, wobei alle Elemente des ersten Teils kleiner oder
gleich allen Elementen des zweiten Teils sind. Die beiden Teile können jetzt
unabhängig voneinander betrachtet werden, da beim Sortieren keine Elemente
mehr von dem einen Teil in den andern bewegt werden müssen.
Zerlege jedes der beiden Teile aus dem vorherigen Schritt in gleicher Weise wie-
der in zwei Teile.
Setze den Prozess des Zerlegens fort, bis die Zerlegungsprodukte nur noch ein
Element haben und damit sortiert sind.
359
13 Sortieren
Besonders effizient ist dieses Verfahren, wenn es gelingt, die beiden Teile, in die wir
zerlegen, immer in etwa gleich groß zu halten.
Zur Zerlegung des Arrays konstruieren wir einen sogenannten Pivot2. Dabei handelt
es sich um ein von der Größe her möglichst in der Mitte liegendes Element mit der
Eigenschaft, dass alle Elemente links vom Pivot kleiner (im Sinne von ≤) und alle Ele-
mente rechts vom Pivot größer (im Sinne von ≥) als der Pivot sind. Der Pivot selbst ist
unter diesen Voraussetzungen bereits richtig platziert und muss bei der weiteren
Verarbeitung nicht mehr betrachtet werden.
links rechts
Pivot
Die Rekursion bricht ab, wenn wir durch Zerlegung auf Arrays mit einem oder gar
keinem Element stoßen, bei denen ja nichts mehr zu sortieren ist.
verfügen, die die gewünschte Aufteilung vornimmt und uns für die weitere Verarbei-
tung den Index des Pivots, also die Stelle, an der aufgeteilt wurde, zurückgibt, können
wir das Hauptprogramm wie folgt realisieren:
360
13.1 Sortierverfahren
In der Schnittstelle wird mit links und rechts der Bereich der Daten übergeben, die
sortiert werden sollen (A). Wenn hier links < rechts ist, dann muss weiter aufgeteilt
werden (B). In diesem Fall wird der Index des Pivots bestimmt und das Array dadurch
in zwei Teile geteilt (C). Von diesem geteilten Bereich wird nun links vom Pivot sor- 13
tiert (D) und danach rechts vom Pivot (E).
Unklar bleibt dabei zunächst noch, wie wir die Aufteilung konstruieren können.
Natürlich sollte der Pivot vom Wert her möglichst mittig liegen, um eine gleichmä-
ßige Aufteilung zu gewährleisten. Aber, um das wertmäßig in der Mitte liegende Ele-
ment zu finden, müsste man schon sortiert haben. Als Pivot wählen wir einen mehr
oder weniger zufälligen Wert, von dem wir hoffen, dass er zentral in den Daten liegt.
Wir wählen einfach das letzte Element des Arrays, ernennen es zum Pivot und versu-
chen es dann, durch geschickte Umordnung, an seine exakte Position bringen. Sie
können sich das Verfahren an einem Beispiel mit der folgenden Ausgangslage klar-
machen:
3 12 5 2 14 9 8 11 4 1 10 16 7 6 17 15 13
Jetzt müssen wir eine Ordnung herstellen, in der alle Elemente links vom Pivot klei-
ner als der Pivot und rechts vom Pivot größer als der Pivot sind. Wir arbeiten uns
dazu von den Ecken des Arrays zur Mitte hin vor und überspringen alle Elemente, die
im Sinne der angestrebten Aufteilung bereits korrekt platziert sind.
361
13 Sortieren
Hier ist alles in Ordnung (≤ 13). Hier ist alles in Ordnung (≥ 13).
3 12 5 2 14 9 8 11 4 1 10 16 7 6 17 15 13
3 12 5 2 6 9 8 11 4 1 10 16 7 14 17 15 13
Wenn es nicht mehr weitergeht, vertauschen wir die beiden Elemente, die unser wei-
teres Vorgehen blockiert haben, und können uns danach weiter zur Mitte vorar-
beiten.
Hier ist alles in Ordnung (≤ 13). Hier ist alles in Ordnung (≥ 13).
3 12 5 2 6 9 8 11 4 1 10 16 7 14 17 15 13
3 12 5 2 6 9 8 11 4 1 10 7 16 14 17 15 13
Treffpunkt
Nachdem wir die nächste Blockade beseitigt haben, stoßen wir bei unserem Vorge-
hen von links und rechts aufeinander. Links vom Treffpunkt ist jetzt alles kleiner und
rechts vom Treffpunkt alles größer als der Pivot (13). Abschließend tauschen wir noch
den Pivot mit dem Element rechts vom Treffpunkt:
362
13.1 Sortierverfahren
3 12 5 2 6 9 8 11 4 1 10 7 16 14 17 15 13
3 12 5 2 6 9 8 11 4 1 10 7 13 14 17 15 16
Die gewünschte Aufteilung ist damit hergestellt. Der Pivot ist irgendwo – hoffentlich
halbwegs in der Mitte – gelandet, und links vom Pivot ist alles kleiner, rechts vom Pivot
alles größer. Der linke und der rechte Teil des Arrays können jetzt unabhängig vonein-
ander sortiert werden. Der Pivot hat bereits seine endgültige Position gefunden.
A pivot = daten[rechts];
B i = links-1;
C j = rechts;
for(;;)
{
D while( daten[++i] < pivot)
;
E while( (j > i) && (daten[--j] > pivot))
;
F if( i >= j)
break;
G t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
H daten[rechts] = daten[i];
daten[i] = pivot;
I return i;
}
363
13 Sortieren
Zu Beginn ist der Wert ganz rechts der Pivot (A). Die Aufteilung startet nun mit zwei
Fingern links (B) und rechts (C) vom aufzuteilenden Bereich. Solange links alles in
Ordnung ist, wird nach rechts gegangen (D), danach wird, solange rechts alles in Ord-
nung ist, nach links gegangen (E).
Wenn die Bedingung i >= j zutrifft (F), haben sich die Finger getroffen, und die Aus-
führung geht nach dem break bei (H) weiter. Andernfalls werden die blockierenden
Elemente getauscht (G). Die Schleife läuft so lange, bis es zu dem oben beschriebenen
Abbruch kommt, wenn sich die Finger getroffen haben. Nach diesem Abbruch wird
der Pivot mit dem Element rechts vom Treffpunkt getauscht (H), abschließend wird
der Index des Pivots zurückgegeben, und die Aufteilung ist abgeschlossen (I).
Beachten Sie, dass ich in diesem Programm die Operatoren ++ und -- in Präfixnota-
tion verwende. Dies bedeutet, dass in daten[++i] und daten[--i] das Inkrement bzw.
Dekrement von i bzw. j durchgeführt wird, bevor der Zugriff in das Array erfolgt.
Eine gewisse Effizienzsteigerung erreichen wir dadurch, dass wir auf den Funktions-
aufruf von aufteilung verzichten und die Funktion innerhalb von qcksort reali-
sieren.
364
13.1 Sortierverfahren
Die Sortierung des gesamten Arrays wird dann durch den Aufruf
Auch hier habe ich wieder ein paar Schnappschüsse gemacht. Da immer zuerst ein
rekursiver Abstieg in die linke Hälfte des Arrays erfolgt, ergibt sich ein Aufbau des
sortierten Arrays von links nach rechts. Das heißt, zunächst wird der linke Teil voll-
ständig sortiert, bevor der rechte Teil in Angriff genommen wird. Für alle weiteren
Unterteilungen gilt das in gleicher Weise:
Sie sehen, wie immer kleinere Pakete entstehen, die unabhängig voneinander bear-
beitet werden können, da alle Werte in einem Paket größer als die Werte in den lin-
ken Nachbarpaketen bzw. kleiner als die Werte in den rechten Nachbarpaketen sind.
Unter dem Vergrößerungsglas erkennen Sie, wie sich die beiden ersten Pakete gebil-
det haben:
365
13 Sortieren
Pivot
366
13.1 Sortierverfahren
Rekursion arbeitet so, dass die lokalen Variablen einer Funktion auf den Stack gelegt
werden und damit für jede Aufrufinstanz der Funktion separat zur Verfügung ste-
hen. Ist das Unterprogramm beendet, werden die Variablen des rufenden Pro-
gramms wiederhergestellt, und es kann weiterarbeiten, als wäre nichts geschehen.
Wenn wir einen kleinen Stack nachbilden und dort die Werte für links und rechts zwi-
schenspeichern, können wir die Rekursion vermeiden. Ein Stack ist nichts anderes als
ein Stapel, auf den oben etwas gelegt und dem von oben wieder etwas entnommen
werden kann3. Was zuletzt auf den Stapel gelegt wurde, kommt als Erstes wieder her-
unter. Man spricht deswegen auch von einem Last-In-First-Out- oder kurz LIFO-
Speicher.
Zur Implementierung eines Stacks benötigen Sie ein Array und einen Zeiger (Stack-
pointer) auf das oberste Element des Stapels (Stacktop). Das kann z. B. so aussehen:
A int stack[100];
B int pos = 0;
int i;
13
printf( "Push: ");
for( i = 0; i < 8; i++)
{
printf( "%d " , i);
C stack[pos++] = i;
}
printf( "\nPop: ");
D while( pos)
{
E i = stack[--pos];
printf( "%d ", i);
}
Zuerst wird ein Stack für 100 Integer-Zahlen angelegt (A). Der zugehörige Stackpoin-
ter pos zeigt immer auf die nächste freie Position (B). Als Nächstes wird in der Schleife
jeweils eine Zahl auf den Stack gelegt, und der Stackpointer wird inkrementiert (C).
Diese Stack-Operation heißt Push (Teller auf Stapel legen). Nachdem wir den Stack
gefüllt haben, entnehmen wir nun Elemente vom Stack. Dazu testen wir jeweils, ob
noch etwas auf dem Stack liegt (D). Ist das der Fall, wird der Stackpointer dekremen-
tiert, und eine Zahl wird vom Stack entfernt (E). Diese Stack-Operation heißt Pop (Tel-
ler vom Stapel nehmen).
367
13 Sortieren
Push: 0 1 2 3 4 5 6 7
Pop: 7 6 5 4 3 2 1 0
Achtung: Die Implementierung dieses Stacks ist insofern unvollständig, als ein Spei-
cherüberlauf (Stack Overflow) nicht abgefangen wird, aber das soll uns hier nicht
stören.
In der Funktion qcksort legen wir jetzt einen Stack an. Dieser Stack übernimmt die
Rolle einer Warteschlange für Sortieraufträge. Die Funktion liest ihre Sortieraufträge
vom Stack und erzeugt, wenn es erforderlich ist, neue Sortieraufträge auf dem Stack.
Ein Sortierauftrag bezieht sich dabei immer auf einen Teilbereich des Arrays – also
auf den linken und rechten Randpunkt des zu sortierenden Teilbereichs:
stack[pos++] = links;
der erste Auftrag
nicht rekursive stack[pos++] = rechts;
Version
solange noch Aufträge in der
while( pos) Warteschlange sind
{
rechts = stack[--pos];
Lies den nächsten
links = stack[--pos];
Auftrag vom Stack!
if( links < rechts)
{
Bearbeite den Auftrag! i = aufteilung( links, rechts, daten);
stack[pos++] = links;
stack[pos++] = i-1;
Erzeuge zwei neue stack[pos++] = i+1;
Aufträge auf dem stack[pos++] = rechts;
Stack! }
}
}
Die Reihenfolge der Abarbeitung der Pakete ist hier übrigens genau umgekehrt wie
beim ursprünglichen Quicksort. Wir legen jetzt das linke Paket zuerst auf den Stack
368
13.1 Sortierverfahren
und dann das rechte. Dadurch wird das rechte Paket zuerst heruntergeholt und bear-
beitet. Aber das hat weder Einfluss auf das Ergebnis noch auf die Effizienz des Ver-
fahrens.
Beachten Sie auch, dass der Stack eine begrenzte Größe hat. Das schränkt das Daten-
volumen ein, das diese Funktion bearbeiten kann. Wie stark diese Einschränkung ist,
hängt von der Verteilung der Daten ab. Die benötigte Größe des Stacks entspricht der
doppelten Rekursionstiefe der alten Version, da in der neuen Version immer zwei
Zahlen auf den Stack kommen, wenn die alte Version in die Rekursion gegangen ist.
Bei optimaler Verteilung der Daten kann die Größe des Arrays mit jedem Rekursions-
schritt halbiert werden. Somit könnte das Array in diesem Fall 2128 ≈ 3.4 · 1028 Ele-
mente haben. Das ist mehr als ausreichend. Falls das Array aber bereits sortiert ist,
kann der zu betrachtende Bereich in jedem Verfahrensschritt nur um ein Element
verkleinert werden, und das Array könnte daher maximal 128 Elemente haben. Hier
ist also Vorsicht geboten.
Im oben dargestellten Programm lässt sich der Aufruf der Funktion aufteilung, wie
schon bei der rekursiven Variante gezeigt, eliminieren. Bei dieser Gelegenheit stellen
13
wir das Programm auch auf unsere Standardschnittstelle um, da jetzt ja keine rekur-
sionsfähige Schnittstelle mehr erforderlich ist: Damit erhalten wir die endgültige
Implementierung von Quicksort:
stack[pos++] = 0;
stack[pos++] = n-1;
while( pos)
{
rechts = stack[--pos];
links = stack[--pos];
369
13 Sortieren
A {
A while( daten[++i] < pivot)
A ;
A while((j > i) && (daten[--j] > pivot))
A ;
A if( i >= j)
A break;
A t = daten[i];
A daten[i] = daten[j];
A daten[j] = t;
A }
A daten[ rechts] = daten[i];
A daten[ i] = pivot;
stack[pos++] = links;
stack[pos++] = i-1;
stack[pos++] = i+1;
stack[pos++] = rechts;
}
}
}
Der mit (A) gekennzeichnete Bereich enthält die Aufteilung. Es gibt noch zahlreiche
weitere Möglichkeiten, dieses Programm weiter zu optimieren, aber das würde auf
Kosten der Lesbarkeit und Verständlichkeit des Programms gehen. Wir wollen es
daher bei dieser Version belassen. Im Moment wissen wir noch nicht, welchen Lauf-
zeitgewinn wir durch diese Umstellung erzielt haben. Später werden wir die rekur-
sive und die nicht rekursive Variante gegeneinander antreten lassen.
13.1.6 Heapsort
Bevor Sie das letzte Sortierverfahren kennenlernen, machen wir uns ein paar Gedan-
ken über sogenannte Heaps4.
Sie können sich die Elemente eines Arrays wie in einem Baum angeordnet vorstellen.
370
13.1 Sortierverfahren
0 1 2 3 4 5 6 7 8 9 10 11
Die Verweise von Knoten auf Folgeknoten bzw. Blätter sind dabei natürlich nicht
explizit, sondern nur gedanklich vorhanden. In etwas übersichtlicherer Darstellung
haben wir:
1 2
3 4 5 6
7 8 9 10 11
Damit wird zumindest gedanklich aus dem Array eine baumartige Struktur. An dem
Bild erkennen Sie auch, dass es einfache Rechenvorschriften gibt, um aus dem Index
eines Knotens den Index seines linken bzw. rechten Nachfolgers zu berechnen. Auf
diese Formeln werden wir noch zurückkommen.
Heap-Bedingung
Wir sagen, dass ein Baum die sogenannte Heap-Bedingung erfüllt, wenn für jeden
Knoten des Baums gilt, dass der Wert des Knotens größer oder gleich den Werten
seiner Nachfolgerknoten ist.
371
13 Sortieren
Einen Baum, der die Heap-Bedingung erfüllt, nennen wir einen Heap. Abbildung
13.22 zeigt einen Heap:
10
9 8
1 2
7 5 2 3
3 4 5 6
1 5 2 3 1
7 8 9 10 11
Ein Heap stellt eine Vorstufe zur Sortierung dar, denn im Heap ist der Wert an einem
Knoten immer größer (im Sinne von ≥) als die Werte an allen nachfolgenden Knoten.
Insbesondere steht das größte Element ganz oben in der Wurzel. Dadurch können Sie
einem Heap sehr einfach das größte Element entnehmen.
Stellen Sie sich jetzt vor, dass die Heap-Bedingung an einer Stelle, etwa durch Wertän-
derung eines einzelnen Elements, verletzt ist. Zum Beispiel haben wir im obigen
Heap den Wert 10 an der Wurzel durch 4 ersetzt:
9 8
7 5 2 3
1 5 2 3 1
372
13.1 Sortierverfahren
In dieser Situation gibt es eine sehr einfache und elegante Strategie, um die Heap-
Bedingung wiederherzustellen. Sie müssen das störende Element nur mit seinem
größten Nachfolger tauschen und dann diesen Tauschprozess, im Baum absteigend,
so lange fortsetzen, bis alles wieder in Ordnung ist. Sie verlagern das Problem so suk-
zessive weiter nach unten, bis es sozusagen unten aus dem Baum herauswächst. In
unserem Beispiel tauschen wir 4 mit 9, dann 4 mit 7 und letztlich 4 mit 5. Dann haben
wir wieder einen intakten Heap:
9
9
4
7 8
7
4
13
5 5 2 3
4
5
1 4 2 3 1
Auch wenn Ihnen sicherlich noch nicht klar ist, was diese Überlegungen konkret mit
unserer Sortieraufgabe zu tun haben, können wir diesen Reparaturalgorithmus für
Heaps ja mal programmieren:
B v = daten[k];
C while( k < n/2)
{
D j = 2*k+1;
E if( (j < n-1) && (daten[j] < daten[j+1]))
j++;
F if( v >= daten[j])
G break;
H daten[k] = daten[j];
373
13 Sortieren
H daten[k] = daten[j];
I k = j;
}
J daten[k] = v;
}
Unsere Funktion zur Reparatur erhält als Parameter die Größe des Heaps n, den Heap
selbst und den Index k des Störenfrieds (A).
Zuerst wird in (B) der Störenfried aus dem Heap genommen. Im Anschluss startet
eine Schleife, die so lange fortfährt, wie der betrachtete Knoten noch mindestens
einen Nachfolger hat (C). In dieser Schleife wird der Index j zunächst auf den linken
Nachfolger gesetzt (D). Falls es aber einen rechten Nachfolger gibt und dieser größer
ist als der linke (E), dann gehe zum rechten Nachfolger (j++). Falls der Störenfried grö-
ßer ist als sein größter Nachfolger (F), muss nicht weiter abgestiegen werden (G).
Andernfalls wird der größte Nachfolger eine Ebene hochgezogen (H) und an dessen
Knoten weitergemacht (I). Abschließend wird in (J) der Störenfried in den frei gewor-
denen Knoten gelegt, der Heap ist repariert.
374
13.1 Sortierverfahren
In (A) wird von hinten nach vorn im Array ein Heap aufgebaut, die Erklärung dazu
erhalten Sie im Anschluss. Solange sich der Heap durch Abtrennen des letzten Ele-
ments verkleinern lässt (B), tausche das erste (größte) Element mit dem letzten (C),
und repariere den um ein Element verkleinerten Heap (D).
Zum Aufbau des Heaps im Array möchte ich Ihnen nun noch ein paar Erklärungen
geben. Wir bekommen ja ein beliebiges unsortiertes Array vorgelegt. Die hintere
Hälfte des Arrays ist dabei ungeachtet der Datenwerte immer für einen Heap geeig-
net, da diese Knoten keine Nachfolgerknoten haben und die Heap-Bedingung für
diese Knoten irrelevant ist.
13
0 1 2 3 4 5 6 7 8 9 10 11
In der vorderen Hälfte gehen wir Schritt für Schritt zurück und integrieren das jeweils
neue Element an der Wurzel durch adjustheap in den im Aufbau befindlichen Heap.
Wenn wir vorn angekommen sind, ist der Heap (noch nicht die Sortierung) fertig.
Aus diesem Heap erzeugen wir dann die Sortierung, indem wir immer das größte Ele-
ment ganz vorn mit dem letzten Element tauschen und den dadurch an der Wurzel
gestörten, um ein Element verkleinerten Heap wieder reparieren.
Dieser Algorithmus ist sicher nicht leicht zu verstehen, aber er ist einer der faszinie-
rendsten Algorithmen der Informatik. Wenn Sie diesen Algorithmus verstehen, wer-
den Sie jeden Algorithmus verstehen.
375
13 Sortieren
Diesmal habe ich aber den ersten Schnappschuss nicht ganz am Anfang genommen,
sondern gewartet, bis der Heap aufgebaut war. Anschließend (Bilder 2, 3, 4)
schrumpfte der Heap, und die Sortierung baute sich von rechts nach links auf.
13.2.1 Bubblesort
Algorithmen wie Bubblesort haben wir schon häufig betrachtet. Sie wissen, dass der
Code in der inneren Schleife
n(n – 1)
( n – 1 ) + ( n – 2 ) + … + 2 + 1 = --------------------
2
mal ausgeführt wird. Eine Überdeckungsanalyse mit 1000 zufällig gewählten Zahlen
bestätigt dies (siehe Abbildung 13.27).
Die Überdeckung zeigt, dass bei zufälliger Anfangsverteilung der Daten etwa in der
Hälfte der Fälle in der inneren Schleife getauscht werden muss. Wenn wir davon aus-
gehen, dass die mittlere Laufzeit in der inneren Schleife cbub ist, erhalten wir für Bub-
blesort die folgende Laufzeitformel:
n(n – 1)
t bub ( n ) = c bub -------------------- ≈ n 2
2
Bubblesort hat also eine quadratische Laufzeit. Was das im Vergleich zu den anderen
Algorithmen wert ist, werden Sie im Weiteren sehen.
376
13.2 Leistungsanalyse der Sortierverfahren
n = 1000
13.2.2 Selectionsort
Ähnliche Überlegungen wie bei Bubblesort wenden wir jetzt auf Selectionsort an.
n = 1000
377
13 Sortieren
Daraus folgt:
n(n – 1)
t sel ( n ) = c sel1 -------------------- + c sel2 ( n – 1 ) ≈ n 2
2
Selectionsort ist also, wie Bubblesort, von quadratischer Laufzeit. Für kleine Werte
von n mag Selectionsort wegen des Terms csel2(n – 1) schlechter sein als Bubblesort.
Für große Werte von n ist Selectionsort aber wegen der offensichtlich besseren Lauf-
zeit im Kern (csel1 < cbub) sicherlich schneller als Bubblesort – vielleicht drei- bis fünf-
mal so schnell.
13.2.3 Insertionsort
Auch bei Insertionsort haben wir zwei ineinander geschachtelte Schleifen zu analy-
sieren. Hier wird jedoch die innere Schleife über eine zusätzliche Bedingung kontrol-
liert (daten[k-1] > v) und gegebenenfalls vorzeitig abgebrochen. Bei zufällig
verteilten Daten können wir davon ausgehen, dass diese Bedingung im Mittel bei der
Hälfte des zu durchlaufenden Indexbereichs erfüllt ist, die Schleife also im Durch-
schnitt auf halber Strecke abgebrochen werden kann. Bei einer ungünstigen Vertei-
lung der Daten muss jedoch der gesamte Bereich durchlaufen werden. Diese
Überlegung lässt vermuten, dass Insertionsort sehr sensibel auf eine Vorsortierung
in den Daten reagieren wird. Je besser die Vorsortierung ist, desto schneller wird
Insertionsort sein. Im Moment bleiben wir aber bei zufällig sortierten Daten und
sehen unsere Vermutung, dass die innere Schleife zur Hälfte durchlaufen wird, be-
stätigt:
n = 1000
void insertionsort( int n, int *daten)
{
1 int i, k, v;
n–1
999 for( i = 1; i < n; i++)
{
v = daten[i]; cins2
for( k = i; (k>=1)&& (daten[k-1] > v); k--)
239518 daten[k] = daten[k-1]; cins1
daten[k] = v;
}
n(n – 1)
≈ }
4
n( n – 1)
t ins ( n ) = c ins1 -------------------- + c ins2 ( n – 1 ) ≈ n 2
4
378
13.2 Leistungsanalyse der Sortierverfahren
1
Das ist wieder ein quadratisches Verfahren, aber wegen cins1 ≈ -- csel1 könnte Insertion-
2
sort doppelt so schnell wie Selectionsort sein.
13.2.4 Shellsort
Die bisher betrachteten Sortierverfahren hatten ein quadratisches Laufzeitverhalten,
weil sie aus zwei verschachtelten Schleifen bestanden, die beide linear durchlaufen
wurden. Die Unterschiede lagen im Wesentlichen im Berechnungsaufwand inner-
halb der Schleifen. Bei Shellsort finden wir sogar drei ineinander verschachtelte
Schleifen. Die Befürchtung, dass daraus ein kubisches Laufzeitverhalten resultieren
könnte, kann jedoch schon durch den Überdeckungstest zerstreut werden. Im Über-
deckungstest ergeben sich bei gleichem Datenvolumen deutlich kleinere Anzahlen
von Schleifendurchläufen als bei den zuvor betrachteten Sortierverfahren:
n = 1000
void shellsort( int n, int *daten)
1 { 13
int i, k, h, v;
Wie oft die inneren Schleifen aber wirklich durchlaufen werden (hier 4821 bzw. 9690),
konnte bisher nicht allgemein berechnet werden, zumal hier ja auch noch die spezi-
elle Wahl der Distanzenfolge (hier 1, 4, 7, ...) eine wichtige Rolle spielt. Man kennt relativ
schlechte und relativ gute Distanzenfolgen. Die hier gewählte Folge ist z. B. als relativ
gut bekannt. Aber man kennt nicht »die beste« Distanzenfolge. Für das oben darge-
5⁄
stellte Programm wird ein asymptotisches Verhalten wie n(log(n))2 oder n 4 vermu-
tet. Bewiesen ist aber keine der beiden Vermutungen.
379
13 Sortieren
Eine Abschätzung, die wir hier nicht beweisen wollen, besagt, dass Shellsort für die
3⁄
hier gewählte Distanzenfolge asymptotisch nicht schlechter als n 2 und damit
zumindest für entsprechend große Arrays besser als Bubblesort, Insertionsort und
Selectionsort ist. Auch in der Praxis zeigt Shellsort eine deutlich bessere Performance
als die zuvor diskutierten Verfahren.
13.2.5 Quicksort
Im Gegensatz zu Shellsort gibt es über das Laufzeitverhalten von Quicksort reichhal-
tige Untersuchungen mit konkreten Ergebnissen.
Der Überdeckungstest zeigt bei Quicksort erfreulich niedrige Zahlen, noch niedriger
als bei Shellsort:
Um qualitative Aussagen zur Laufzeit zu gewinnen, betrachten wir noch einmal die
Aufrufstruktur von Quicksort:
380
13.2 Leistungsanalyse der Sortierverfahren
links rechts
Pivot
Wenn wir eine in etwa zentrierte Lage des Pivots unterstellen, hat Quicksort wegen
der fortlaufenden Halbierung der zu betrachtenden Teilbereiche eine Rekursions- 13
tiefe von log(n). Auf jedem Teilbereich arbeitet dann das Unterprogramm auftei-
lung. Sie erinnern sich, dass in diesem Unterprogramm linear von den Ecken des
aufzuteilenden Bereichs zu einem Treffpunkt vorgerückt wurde, wobei gelegentlich
Vertauschungen durchgeführt werden mussten. Selbst wenn bei jedem Schritt eine
Vertauschung erforderlich wäre, käme dabei nicht mehr als eine linear wachsende
Laufzeit heraus. Da auf jeder Rekursionsebene über alle Teilbereiche hinweg (maxi-
mal) n Elemente zu betrachten sind und das Unterprogramm aufteilung in jedem
dieser Teilbereiche mit linearer Zeitkomplexität arbeitet, ergibt sich für Quicksort
das folgende Laufzeitverhalten:
tqck(n) = n · log(n)
13.2.6 Heapsort
n
In der Funktion Heapsort wird n – 1 + --- -mal adjustheap gerufen. Das zeigt auch die
2
Überdeckungsanalyse:
381
13 Sortieren
n = 1000
Die Funktion adjustheap war aber eine Funktion, die einen Baum in der Tiefe durch-
lief, und wir wissen bereits, dass ein gleichmäßig aufgefüllter Baum mit n Elementen
die Tiefe log(n) hat:
9
9
4
7 8
7
log(n)
5 5 2 3
4
5
1 4 2 3 1
n
Abbildung 13.34 Tiefe des gleichmäßig gefüllten Baums
theap(n) = n · log(n)
382
13.3 Leistungsmessung der Sortierverfahren
Als Erstes testen wir mit zufällig verteilten Daten. Dazu verwenden wir den folgen-
den Testdatengenerator:
383
13 Sortieren
Für Arrays mit bis zu 10 Millionen Elementen erhalten wir dann die folgenden Mess-
ergebnisse:
Wie erwartet, ist die iterative Implementierung von Quicksort das schnellste Pro-
gramm. Quicksort benötigt weniger als eine Sekunde, wenn Bubblesort bereits meh-
rere Tage rechnet.
Bevor Sie jetzt aber glauben, dass wir den besten Sortieralgorithmus bereits gefun-
den haben, testen wir noch mit anderen Datenverteilungen. Wir erstellen einen
Generator, der aufsteigend sortierte Daten mit »leichten Störungen« erzeugt:
Messungen führen wir jetzt nur noch für Insertionsort, Quicksort (iterativ) und
Heapsort durch, da die anderen Programme aufgrund der ersten Messung unwichtig
384
13.3 Leistungsmessung der Sortierverfahren
geworden sind und wir Insertionsort noch eine Chance geben wollen, sich bei vorsor-
tierten Daten zu verbessern. Spannend ist die Frage, wie Quicksort jetzt abschneidet:
Abbildung 13.38 Vergleich der Laufzeiten (in Millisekunden) für leicht gestörte Testdaten
Sie sehen, dass sich Insertionsort deutlich verbessert, ohne jedoch Quicksort oder
Heapsort zu erreichen. Quicksort fällt für große Datenmengen hinter Heapsort
zurück. 13
Wir verschärfen die Situation dahingehend, dass das Array im vorderen Teil sortiert
ist und nur im hinteren Teil unsortierte Daten angefügt sind. Diese Verteilung
erzeugt uns der folgende Generator:
385
13 Sortieren
Das ist übrigens ein durchaus realistisches Testszenario, wenn Sie sortierte Daten
haben und neue Daten hinzugefügt wurden, die einsortiert werden müssen:
Quicksort verschlechtert sich noch einmal und ist Heapsort jetzt deutlich unterle-
gen. Auch Insertionsort verschlechtert sich, bleibt aber besser als bei Zufallsdaten.
Das liegt daran, dass hier großräumigere Verschiebungen möglich sind als im vorhe-
rigen Testszenario.
In unserem letzten Testszenario gehen wir davon aus, dass die Daten im Wesentli-
chen korrekt sortiert sind und nur 10 % der Daten aufgrund von Schlüsseländerun-
gen an der falschen Stelle stehen. Solche Daten erzeugen wir mit dem folgenden
Generator:
386
13.3 Leistungsmessung der Sortierverfahren
Man könnte versuchen, Quicksort durch eine bessere Wahl des Pivots robuster zu
machen. Dazu gibt es verschiedene Ansätze. Man könnte den Pivot zufällig wählen
oder drei zufällige Werte aus dem Array betrachten und den mittleren der drei aus-
wählen. Aber keiner der Ansätze verbessert Quicksort in allen denkbaren Situatio-
nen, zumal solche Erweiterungen auch zusätzliche Rechenzeit verbrauchen.
Für welches Verfahren soll man sich entscheiden, wenn man keine Informationen
über die Verteilung der zu sortierenden Daten hat? Die Antwort lautet Introsort.
Introsort ist ein hybrides Verfahren, das die Vorteile von Quicksort, Heapsort und
Insertionsort zu kombinieren versucht. Introsort startet als Quicksort und beobach-
tet dabei die Tiefe des Abstiegs. Wenn dabei ein bestimmter Wert (z. B. 2 · log(n)) über-
schritten wird, schaltet das Verfahren auf Heapsort um (Stop Loss). Wenn am Ende
nur noch kleine Teilbereiche zu sortieren sind, wird die Feinarbeit mit Insertionsort
erledigt. Inzwischen hat Introsort Quicksort in den meisten Funktionsbibliotheken
abgelöst.
387
13 Sortieren
왘 Mit einem Vergleich können maximal zwei Permutationen erzeugt werden. Man
kann aufgrund des Vergleichs alles so lassen, wie es ist, oder eine ganz bestimmte
Vertauschung vornehmen.
왘 Mit k Vergleichen können maximal doppelt so viele Permutationen erzeugt wer-
den wie mit k-1 Vergleichen, da man auch hier wieder zwei Möglichkeiten hat. Man
kann alles so lassen oder eine möglicherweise neue Permutation5 erzeugen.
Insgesamt kann man also sagen, dass man mit k Vergleichen maximal 2k Permutati-
onen erzeugen kann. Die Anzahl k der Vergleiche muss also mindestens so groß sein,
dass 2k ≥ n! ist. Es muss also gelten:
k n⁄ n
k = log ( 2 ) ≥ log ( n! ) ≥ log ( n 2 ) = --- log ( n )
2
In dem Umfeld, in dem wir bisher Lösungen gesucht haben, gibt es also keine »wirk-
lich« besseren Verfahren als Quicksort oder Heapsort. Das heißt aber nicht, dass es
keine besseren Sortierverfahren als Quicksort und Heapsort gibt. Man kann durch-
aus Verfahren konstruieren, die nicht auf Einzelvergleichen beruhen und dann effizi-
enter als Quicksort sind. Wir betrachten dazu ein Verfahren, mit dem die Post Briefe
nach Postleitzahlen sortiert.
5 Gewisse Permutationen können dabei mehrfach erzeugt werden, aber das soll uns hier nicht
kümmern, da wir nur an einer Maximalzahl erzeugter Permutationen interessiert sind.
388
13.4 Grenzen der Optimierung von Sortierverfahren
Zur Vereinfachung stellen wir uns vor, dass es vierstellige Postleitzahlen gibt, die nur
die Ziffern 1, 2 und 3 enthalten dürfen. Im ersten Verfahrensschritt nehmen wir die
Briefe und sortieren sie entsprechend der letzten Ziffer der Postleitzahl in drei ver-
schiedene Fächer:
1 3 2 3
2 3 1 2
3 2 2 1
2 1 3 3
2 3 1 2
2 2 3 3
3 2 1 1
1 1 3 2
2 3 1 2 1 3 2 3
3 2 2 1 2 3 1 2 2 1 3 3
3 2 1 1 1 1 3 2 2 2 3 3
1 2 3 13
Dann entnehmen wir die Stapel den drei Fächern und legen sie aufeinander, den Sta-
pel aus Fach 1 zuoberst, dann den Stapel aus Fach 2, zuunterst den Stapel aus Fach 3.
Anschließend sortieren wir die Briefe wieder in die drei Fächer ein. Diesmal sortieren
wir aber nach der vorletzten Stelle und legen Wert darauf, dass die im ersten Schritt
hergestellte Vorsortierung dabei nicht zerstört wird:
3 2 2 1
3 2 1 1
2 3 1 2
2 3 1 2
1 1 3 2
1 3 2 3
2 1 3 3
2 2 3 3
3 2 1 1 1 1 3 2
2 3 1 2 3 2 2 1 2 1 3 3
2 3 1 2 1 3 2 3 2 2 3 3
1 2 3
Abbildung 13.44 Zweiter Schritt der Postleitzahlensortierung
389
13 Sortieren
Die Briefe in den drei Kästen sind jetzt nach den beiden letzten Ziffern korrekt sor-
tiert. Wir wiederholen den gleichen Schritt jetzt noch zweimal. Also: zusammenlegen
und nach der zweiten Ziffer verteilen und dann noch einmal zusammenlegen und
nach der ersten Ziffer verteilen:
1 1 3 2 3 2 1 1
2 1 3 3 2 3 1 2
3 2 1 1 2 3 1 2
3 2 2 1 3 2 2 1
2 2 3 3 1 3 2 3
2 3 1 2 1 1 3 2
2 3 1 2 2 1 3 3
1 3 2 3 2 2 3 3
2 1 3 3
2 2 3 3 3 2 1 1 2 3 1 2
1 1 3 2 2 3 1 2 3 2 1 1 1 1 3 2 3 2 2 1 2 3 1 2
1 3 2 3 2 3 1 2 3 2 2 1 2 1 3 3 2 2 3 3 1 3 2 3
1 2 3 1 2 3
Abbildung 13.45 Dritter und vierter Schritt der Postleitzahlensortierung
Ein letztes Mal legen wir die Briefe aufeinander, und der dabei entstehende Stapel ist
korrekt sortiert:
1 1 3 2
1 3 2 3
2 1 3 3
2 2 3 3
2 3 1 2
2 3 1 2
3 2 1 1
3 2 2 1
Dieses Verfahren nennt man Distributionsort. Bei einer genauen Betrachtung des
Verfahrens können wir Folgendes feststellen:
Dieses Verfahren basiert also nicht auf Einzelvergleichen. Und noch etwas ist frappie-
rend: Wir haben vier (= Anzahl der Stellen der Postleitzahl) Sortierläufe gemacht und
in jedem Durchlauf jeden Brief genau einmal in die Hand genommen. Das heißt,
390
13.4 Grenzen der Optimierung von Sortierverfahren
unser Verfahren ist asymptotisch linear und damit allen bisher vorgestellten Sortier-
verfahren für große Datenmengen weit überlegen.
Die Nachteile dieses Verfahrens liegen natürlich auch auf der Hand. Das Verfahren
lässt sich nicht allgemein implementieren, da zur Konstruktion konkrete Informati-
onen über den Schlüssel (Anzahl Stellen, vorkommende Ziffern) benötigt werden,
und es wird zusätzlicher Platz zur Ablage der Briefe in den Fächern benötigt. Bei den
auf Elementvertauschung basierenden Verfahren war das nicht erforderlich. Hier
fanden alle Operationen innerhalb des zu sortierenden Arrays (in place) statt.
Sie sehen hier also, dass Laufzeit und Speicherplatz zwei Ressourcen sind, die in
gewisser Weise gegeneinander aufgerechnet werden können oder müssen. Häufig
kann man Rechenzeit auf Kosten zusätzlichen Speichers oder Speicherplatz auf Kos-
ten zusätzlicher Rechenzeit sparen. Das Ziel, sowohl Speicherplatz als auch Rechen-
zeit zu sparen, lässt sich in der Regel nicht erreichen.
13
391
Kapitel 14
Datenstrukturen
Jetzt wächst zusammen,
was zusammengehört.
– Willy Brandt
Im Prinzip können Sie mit den bisher bereitgestellten Programmiermitteln jede nur
erdenkliche Programmieraufgabe lösen. Trotzdem erkennen Sie schnell die Grenzen
Ihrer derzeitigen Möglichkeiten, wenn Sie z. B. nur versuchen, ein einfaches Adress-
buch zu programmieren. Im Adressbuch gibt es heterogene Daten, die zusammen-
gehören. Zum Beispiel gehören Name, Telefonnummer und Geburtsdatum einer
Person zusammen und sollten daher auch immer gemeinsam betrachtet werden.
Wollten Sie das auf Ihrem derzeitigen Kenntnisstand zu modellieren versuchen, 14
müssten Sie für alle Daten unterschiedliche Arrays anlegen, da wir in einem Array ja
nur homogene Daten zusammenfassen können. Sie hätten also ein Array für alle
Namen, ein Array für alle Telefonnummern und ein Array für alle Geburtsdaten.
Schlimmer noch – da Geburtsdaten aus Tag, Monat und Jahr bestehen, hätten Sie
weitere Arrays für alle Datumsfelder eines Kalenderdatums. In dieser Situation wäre
es wünschenswert, alles, was zu einer Person gehört, in einer »Datenstruktur«
zusammenzufassen und dann ein Array aus dieser Datenstruktur aufzubauen.
Streng genommen, sind solche Datenstrukturen überflüssig. Sie bringen aber deutli-
che Verbesserungen in Richtung Komfort, Verständlichkeit, Erweiterbarkeit, Wieder-
verwertbarkeit – kurz: Qualität des Programmcodes – und sind daher für die
Programmierung zwar entbehrlich, aber für die Softwareentwicklung unentbehrlich.
393
14 Datenstrukturen
gramm relativ einfach durch einen anderen Algorithmus ersetzt werden, ohne dass
Auswirkungen auf andere Teile des Programms zu befürchten sind. Änderungen an
einer Datenstruktur erfordern dagegen in der Regel Änderungen in allen Algorith-
men, die auf dieser Datenstruktur arbeiten, und haben somit Auswirkungen in
unterschiedlichen Teilen eines Programms.
Die Wahl einer Datenstruktur ist also in aller Regel eine wesentlich »härtere« Design-
Entscheidung als die Wahl eines Algorithmus. Daraus folgt, dass die Festlegung von
Datenstrukturen mit großer Sorgfalt getroffen werden muss, um den Aufwand für
zukünftige Änderungen so gering wie möglich zu halten. Dies ist besonders schwie-
rig, da man häufig zu dem Zeitpunkt, zu dem die Datenstruktur festgelegt werden
muss, noch nicht weiß, welche Algorithmen auf der Datenstruktur arbeiten werden.
Auf den Internetseiten des Deutschen Fußballbundes habe ich eine Bilanz aller Fuß-
ballspiele der deutschen Nationalmannschaft gefunden. In diese Bilanz sind alle
Spielergebnisse von 1908 bis 2013 eingegangen und nach Nationen verdichtet aufge-
führt. Diese Daten habe geringfügig aufbereitet1 und in der Textdatei Laender-
spiele.txt abgespeichert:
Diese kleine Datensammlung soll als Grundlage für ein Programm dienen, das sich
wie ein roter Faden durch dieses Kapitel ziehen wird. Von einfachen Strukturen bis
hin zu komplexeren Modellierungstechniken, wie Listen, Bäumen oder Hash-Tabel-
len, werden wir immer wieder auf diese Daten zurückgreifen.
1 Ich habe Umlaute entfernt und Leerzeichen in Ländernamen durch Bindestriche ersetzt.
394
14.1 Strukturdeklarationen
14.1 Strukturdeklarationen
In den beiden letzten Spalten unserer Datentabelle finden Sie die Kalenderdaten für
das erste und das letzte Spiel gegen die anderen Nationen. Sie könnten ein Datum als
Text einlesen und in einem String speichern, aber ein Datum soll nicht einfach nur
ein Text sein. Es soll zwar zusammengehören wie ein Text, aber wir wollen einzeln
auf Tag, Monat und Jahr zugreifen können. Dazu benötigen wir eine Datenstruktur.
Bevor Sie eine Datenstruktur verwenden können, müssen Sie sie deklarieren:
Deklaration der
Datenstruktur datum
struct datum
tag {
datum
Im Grunde genommen, ist durch solch eine Deklaration noch nichts passiert –
zumindest ist noch kein ausführbarer Code entstanden. Wir haben nur eine Schab-
lone bereitgestellt, durch die wir auf unsere Daten blicken wollen. Konkrete Daten
sind noch nicht entstanden.
Alle elementaren Datentypen (char, int, float, ...) sind der Rohstoff, aus dem Daten-
strukturen zusammengesetzt werden können.
395
14 Datenstrukturen
str
uct
far
kt { be
t pun cha
uc r
str { x; cha rot;
ble r
dou le y; cha gruen
r b
b lau ; nis
dou }; ergeb
};
;
uct spiel
st r
{
struct beispiel ore;
int t entore;
{ g eg
int
unsigned char c; stru } ;
short s; ct a
rtik
int i; { el
float f; int
arti
int keln
double d; a
floa nzahl; ummer;
}; t ei
floa n
t ve kaufsp
}; rkau re
fspr is;
eis;
datum
unent jahr
verl
struct tore
{
int dfb;
struct spiele int gegner;
{ };
int gesamt; struct datum
int gew; {
int unent; int tag;
int verl; int monat;
}; int jahr;
};
396
14.1 Strukturdeklarationen
Strukturen können ihrerseits wieder Strukturen und Arrays fester Länge – auch
Arrays von Strukturen – enthalten. Unter dem Strukturnamen bilanz wollen wir eine
Zeile unserer Datentabelle modellieren. Dazu greifen wir auf die bereits deklarierten
Teilstrukturen (spiele, tore, datum) zurück und fügen noch ein Array von 30 Zeichen
für den Namen des Landes hinzu.
};
unent
verl struct tore
{
treffer struct bilanz int dfb;
{ int gegner;
dfb char name[30]; };
tore
bilanz
int monat;
monat int jahr;
};
jahr
Alles in dieser Datenstruktur haben wir mit einem Namen versehen. Grundsätzlich
gibt es zwei verschiedene Arten von Namen:
왘 Strukturnamen (in der Grafik senkrecht geschrieben) wie bilanz oder datum. Mit
diesen Namen werden neue Strukturen eindeutig benannt.
왘 Feldnamen (in der Grafik waagerecht geschrieben) wie monat oder treffer. Diese
Namen dienen zum Zugriff auf die Felder einer Datenstruktur.
Die Struktur bilanz enthält z. B. unter dem Namen ergebnisse eine Struktur spiele.
Insbesondere ist die Struktur datum zweimal in der Struktur bilanz vorhanden. Auf
das eine Datum kann unter dem Namen erstes, auf das zweite unter dem Namen
letztes zugegriffen werden. Wie ein Zugriff auf die Daten konkret aussieht, werden
Sie später sehen. Noch gibt es ja gar keine Daten, sondern nur Schablonen mit Struk-
tur- und Zugriffsinformationen.
397
14 Datenstrukturen
Um die Datentabelle als Ganzes zu modellieren, werden wir jetzt noch ein Array von
ausreichend vielen Bilanzen erstellen und zusätzlich speichern, wie viele Einträge in
diesem Array gültig sind.
struct bilanz
anzahl {
…
land };
bilanz
… struct bilanz
{
…
struct daten };
bilanz
… {
daten
…
… };
… …
struct bilanz
{
bilanz
… …
};
In der Modellierung steckt immer ein gewisses Maß an Willkür. Man hätte es auch
ganz anders machen können. Das ist wie bei dem Entwurf eines Architekten für eine
Wohnung. Im Grundriss steckt Willkür, aber es gibt funktionierende und nicht funk-
tionierende Grundrisse. Es ist die Erfahrung des Architekten, aus der Vielzahl der
Möglichkeiten, den Rahmenbedingungen der Bauphysik und den Anforderungen
des Kunden eine geeignete Synthese zu finden. Genauso ist es die Erfahrung des Soft-
wareentwicklers, aus der kombinatorischen Vielfalt der Möglichkeiten, den Rahmen-
bedingungen der Programmiersprache und den Anforderungen an das Programm
geeignete Strukturen zu entwickeln.
Beachten Sie, dass wir die Arrays in diesem Beispiel auf die zu erwartende Maximal-
last (maximal 29 Buchstaben im Ländernamen, maximal 100 verschiedene Länder)
ausgelegt haben. Das war in diesem Fall möglich, ist aber eine grundsätzliche, stö-
rende Beschränkung, von der wir uns später befreien werden. Vorher wollen wir aber
konkret Datenstrukturen anlegen und mit diesen Datenstrukturen arbeiten.
14.1.1 Variablendefinitionen
Durch die Deklaration einer Datenstruktur wird im Prinzip ein neuer Datentyp ein-
geführt. Üblicherweise findet man Datenstruktur-Deklarationen in Header-Dateien,
die dann von allen Quelldateien, die diese Datenstrukturen verwenden wollen, inklu-
398
14.1 Strukturdeklarationen
Jetzt ist ein konkretes Datum entstanden, das auch schon bei der Definition mit Wer-
ten gefüllt werden kann:
Auch komplexe, verschachtelte Strukturen können auf diese Weise angelegt und ini-
tialisiert werden. Folgen Sie dazu einfach der durch die Schablone vorgegebenen
Struktur.
399
14 Datenstrukturen
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
struct tore
{
struct bilanz int dfb;
struct bilanz beispiel = { int gegner;
{ "Lummerland", charname [30]; };
{ 6, 1, 2, 3}, struct spiele ergebnisse;
{ 13, 17}, struct tore treffer;
{ 2, 3, 1975}, struct datum erstes; struct datum
{ 24, 12, 2000} struct datum letztes; {
}; }; int tag;
int monat;
int jahr;
};
struct datum
{
int tag;
int monat;
int jahr;
};
400
14.2 Zugriff auf Strukturen
Natürlich können Sie auch gezielt auf die einzelnen Felder einer Datenstruktur
zugreifen, um diese zu lesen oder zu verändern. Damit werden wir uns im Folgenden
beschäftigen. Wir unterscheiden dabei:
왘 Direktzugriff
왘 Indirektzugriff
14.2.1 Direktzugriff
Zum direkten Zugriff auf die Felder einer Datenstruktur dient der Punkt-Operator (.):
Datum: 1.9.2014
struct datum heute = {31, 8, 2014};
struct datum morgen;
14
Ein komplettes Datum wird zugewiesen.
morgen = heute;
morgen.tag= morgen.tag+1;
if( morgen.tag > 31)
{
morgen.tag= 1; Zugriff auf einzelne Felder
morgen.monat++; eines Datums
}
Sie können Schritt für Schritt mit dem Punkt-Operator in die Datenstruktur hinein-
zoomen, bis Sie auf dem Level angekommen sind, auf dem Sie arbeiten möchten –
egal, wie tief die Strukturen verschachtelt sind.
Abbildung 14.14 zeigt zwei unterschiedlich tiefe Zugriffe in die Datenstruktur mit bei-
spielhaften Zuweisungen.
Wichtig ist, immer im Blick zu behalten, welchen Datentyp Sie auf welcher Zugriffs-
stufe jeweils erhalten, damit Sie wissen, welche Operationen Sie auf dem jeweiligen
Level ausführen können.
2 Dazu müssen Sie sich noch bis zur objektorientierten Programmierung gedulden.
401
14 Datenstrukturen
struct spiele
{
int gesamt;
int gew;
int unent;
int verl;
};
struct tore
{
struct bilanz int dfb;
struct bilanz beispiel;
struct spiele sp = {6,1,2,3}; { int gegner;
char name[30]; };
beispiel.ergebnisse = sp; struct spiele ergebnisse;
struct tore treffer;
struct datum erstes; struct datum
struct datum letztes; {
beispiel.erstes.jahr = 1950; int tag;
};
int monat;
int jahr;
};
struct datum
{
int tag;
int monat;
int jahr;
};
dat.land[3].name[7] = 'a';
char
struct bilanz
struct daten
Auf der tiefsten Ebene haben Sie in diesem Beispiel den Datentyp char, sodass Sie
einen Buchstaben zuweisen können.
402
14.2 Zugriff auf Strukturen
dat.land[2].ergebnisse.verl = 123;
int
struct spiele
struct bilanz
struct daten
14.2.2 Indirektzugriff
Sie können auch Zeiger auf Datenstrukturen anlegen, wie Sie bereits Zeiger auf die 14
Grunddatentypen angelegt haben:
Beachten Sie, dass pointer keine Datenstruktur mit Feldern tag, monat und jahr ist,
sondern nur die Adresse einer solchen Datenstruktur, also einen Verweis auf eine
solche Datenstruktur, enthalten kann. Der Zeiger ist unbrauchbar, solange ihm nicht
die Adresse einer konkreten Datenstruktur zugewiesen wird. Sie wissen schon, dass
Sie mit dem Adress-Operator (&) die Adresse eine Variablen ermitteln und mit dem
Dereferenzierungsoperator (*) über eine Adresse zugreifen können. Das funktioniert
unabhängig davon, ob die Variable als Typ einen der Grunddatentypen oder eine
selbst angelegte Struktur hat.
403
14 Datenstrukturen
Erst dadurch, dass in dem oben dargestellten Beispiel der Zeiger pointer die Adresse
von geburtsdatum erhält, kann über den Zeiger sinnvoll zugegriffen werden. Der
Zugriff erfolgt mit dem Dereferenzierungsoperator, den Sie ja bereits in Kapitel 8,
»Zeiger und Adressen«, kennengelernt haben. Da diese Art des Zugriffs sehr häufig
vorkommt und in der hier gezeigten Notation etwas sperrig ist, gibt es einen eigenen
Operator, der die Dereferenzierung mit einem sofortigen Zugriff auf ein Feld der refe-
renzierten Datenstruktur verbindet:
Damit können wir den Strukturzugriff im Beispiel oben etwas eleganter formulieren:
404
14.3 Datenstrukturen und Funktionen
Wir könnten alle Beispiele aus dem vorangegangenen Abschnitt von Direktzugriff
auf Indirektzugriff umstellen, aber dann würden Sie sich zu Recht fragen, warum
man einen Zeiger anlegt und mit einem Adresswert versieht, nur um anschließend
indirekt anstatt direkt zugreifen zu können. Den wahren Wert von Zeigern erkennt
man erst im Zusammenhang mit Funktionen und dynamischen Datenstrukturen.
Auf diese Themen wollen wir jetzt zielstrebig zusteuern.
Als Beispiel erstellen wir eine Funktion, die ein Kalenderdatum als lokale Variable
anlegt, den Benutzer nach Tag, Monat und Jahr fragt und das komplette Datum als
Returnwert zurückgibt:
Abgesehen davon, dass der Rückgabetyp hier eine Datenstruktur ist (A), kennen Sie
das von Funktionen, die einen Basistyp wie int oder float als Rückgabetyp hatten.
Innerhalb der Funktion werden Tag, Monat und Jahr vom Benutzer eingegeben (B),
und eine in der Funktion erzeugte Struktur wird zurückgegeben (C).
Wir erstellen jetzt noch eine Funktion, die die Aufgabe hat, zwei Kalenderdaten mit-
einander zu vergleichen, um festzustellen, welches der beiden Daten das frühere ist.
Dieser Funktion müssen wir zwei Datenstrukturen übergeben:
405
14 Datenstrukturen
Wenn die Jahre in den als Strukturen übergebenen Parametern (A) unterschiedlich
sind (B), wird die Jahresdifferenz zurückgegeben.
Wenn die Jahre gleich und die Monate unterschiedlich sind (C), wird die Monatsdiffe-
renz zurückgegeben.
Wenn die Jahre und Monate gleich sind, wird die Tagesdifferenz zurückgegeben (D).
void main()
{
A struct datum datum1, datum2;
B datum1 = datumseingabe();
C datum2 = datumseingabe();
Datum: 3.7.2001
Datum: 4.7.2001
Das erste Datum liegt vor dem zweiten.
Machen Sie sich noch einmal klar, was bei einem Funktionsaufruf an der Schnittstelle
passiert. Es werden Kopien der übergebenen Daten auf dem Stack erzeugt, die Funk-
tion arbeitet mit diesen Kopien, und beim Rücksprung werden die Daten auf dem
Stack wieder beseitigt. Datenstrukturen können sehr groß sein, sodass bei einem
Funktionsaufruf gegebenenfalls große Datenmengen, zumeist überflüssigerweise,
406
14.3 Datenstrukturen und Funktionen
In der umgestellten Version werden nun zwei Zeiger auf Strukturen als Parameter
übergeben (A). Der Zugriff auf die Daten erfolgt jetzt mit dem Pfeil-Operator, ansons-
ten hat sich nichts geändert. 14
In der Schnittstelle ist eine explizite Rückgabe jetzt nicht mehr erforderlich (A), da die
Daten über den Zeiger direkt in die Datenstruktur des aufrufenden Programms ein-
getragen werden. Innerhalb der Funktion erfolgt der Zugriff auf die Daten mit dem
Pfeil-Operator (B).
Funktionen wie scanf haben ja immer schon nach diesem Prinzip der Rückgabe über
Zeiger gearbeitet.
Im Hauptprogramm müssen Sie jetzt darauf achten, Zeiger anstelle von Datenstruk-
turen zu übergeben:
407
14 Datenstrukturen
void main()
{
struct datum datum1, datum2;
A datumseingabe( &datum1);
B datumseingabe( &datum2);
Verwenden Sie daher, wo immer es sinnvoll möglich ist, Zeiger bei der Übergabe von
Datenstrukturen an Funktionen, um den damit verbundenen Laufzeit-und Speicher-
gewinn mitzunehmen3.
408
14.4 Ein vollständiges Beispiel (Teil 1)
Unsere Datenstruktur ist in zweierlei Hinsicht limitiert. Der Name eines Landes darf
nicht mehr als 29 Buchstaben4 umfassen, und es darf maximal 100 Länder geben.
Diese beiden Grenzen stellen kein ernsthaftes Problem dar, aber Sie werden später
sehen, wie Sie sich von diesen Beschränkungen befreien können. Zunächst aber
arbeiten wir mit dieser Beschränkung und lesen die Daten aus der Datei Laender-
spiele.txt in diese Datenstruktur ein. Wir starten im Hauptprogramm:
void main()
{
A struct daten dat;
B lies_datei( &dat, "Laenderspiele.txt");
}
409
14 Datenstrukturen
In dem Programm soll die Datenstruktur dat den gesamten Inhalt der Datei aufneh-
men (A).
Die Funktion lies_datei liest die Daten aus der Datei Laenderspiele.txt und speichert
sie in der Datenstruktur dat (B).
Jetzt geht es ans Einlesen der Daten aus der Datei Laenderspiele.txt. Dazu müssen Sie
sich zunächst einmal an einige wichtige Dateioperationen erinnern:
Wichtig ist auch der Dateihandle (Datentyp FILE), den wir beim Öffnen der Datei
erhalten und bei allen Zugriffsfunktionen auf die Datei als Parameter verwenden
müssen. Wenn Ihnen das nichts mehr sagt, schlagen Sie es in Abschnitt 10.4, »Ein-
und Ausgabe«, noch einmal nach. Mit diesen Dateioperationen können wir die Funk-
tion lies_datei erstellen, wobei wir das Lesen einer einzelnen Länderspielbilanz
noch einmal in ein Unterprogramm (lies_bilanz) auslagern:
Die Funktion erhält als Parameter einen Zeiger auf die Datenstruktur, die die Daten
aufnehmen soll, und den Namen der Datei, in der die Daten stehen (A).
Anfangs enthält die Datenstruktur noch keine Daten (B), die Anzahl wird auf 0
gesetzt. In (C) wird die Datei zum Lesen geöffnet. Wenn beim Öffnen der Datei ein
Fehler festgestellt wird, erfolgt ein Abbruch der Funktion (D).
410
14.4 Ein vollständiges Beispiel (Teil 1)
Wenn die Datei erfolgreich geöffnet wurde, startet die Leseschleife, deren Notation
weiter unten noch erläutert wird (E): Mit der Funktion lies_bilanz (siehe unten) wird
jeweils der nächste Datensatz (struct bilanz) gelesen. In anz wird die Anzahl der gele-
senen Datensätze gezählt. Am Rückgabewert der Funktion lies_bilanz wird erkannt,
ob noch ein Datensatz gelesen werden konnte.
Nach Abschluss der Leseschleife wird die Datei geschlossen (F). Die Anzahl der erfolg-
reich gelesenen Datensätze wird in die Datenstruktur geschrieben und zusätzlich
von der Funktion zurückgegeben (G).
Die oben bereits verwendete Funktion lies_bilanz sieht dann folgendermaßen aus:
Die Funktion erhält als Übergabeparameter die zum Lesen geöffnete Datei pf und
einen Zeiger auf die Datenstruktur pb, in die die Daten eingetragen werden sollen (A).
Die Funktion startet mit einem versuchsweisen Lesen des Ländernamens (B). Wenn
hier kein Land mehr gefunden wurde (End of File), dann wird die Funktion beendet (C).
Ansonsten wird angenommen, dass auch die restlichen Elemente nach dem Länder-
namen gelesen werden können, und es werden die Ergebnisse (D), Treffer (E) sowie das
Datum des ersten (F) und letzten Spiels (G) eingelesen. Nach dem erfolgreichen Lesen
des Datensatzes wird ein entsprechender Rückgabewert geliefert.
411
14 Datenstrukturen
Beim Aufruf der Funktion lies_bilanz in Listing 14.8 (E) bedarf der Parameter
pd->land+anz sicher noch einer weiteren Erklärung. In Abschnitt 8.2, »Zeiger und
Arrays«, habe ich auf die Analogie zwischen Arrays und Zeigern hingewiesen. Ich
hatte dort gesagt:
Ist a ein Array, ist a+i ein Zeiger auf das i-te Element des Arrays.
Das gilt natürlich auch, wenn die Elemente des Arrays Datenstrukturen (hier struct
bilanz) sind. Damit ergibt sich die folgende Leseanleitung für den Aufruf der
Funktion:
lies_bilanz( pf, pd->land+anz) Anstelle von &(pd->land[anz]) können wir daher auch
pd->land+anz schreiben.
Mit der Funktion lies_datei sind wir in der Lage, die Daten aus unserer Textdatei
einzulesen. Vorher erstellen wir aber noch eine Funktion zur Ausgabe der komplet-
ten Datenstruktur, damit wir uns nach dem Einlesen davon überzeugen können,
dass alles richtig angekommen ist. Zu dieser Funktion muss ich nicht viel erklären, da
wir im Prinzip nur das Lesen in vereinfachter Form umkehren.
412
14.4 Ein vollständiges Beispiel (Teil 1)
pb->ergebnisse.gew,
pb->ergebnisse.unent,
pb->ergebnisse.verl);
printf( " %4d:%-4d", pb->treffer.dfb,
pb->treffer.gegner);
printf( " %02d.%02d.%4d", pb->erstes.tag,
pb->erstes.monat,
pb->erstes.jahr);
printf( " %02d.%02d.%4d", pb->letztes.tag,
pb->letztes.monat,
pb->letztes.jahr);
printf( "\n");
}
Im Hauptprogramm lesen wir jetzt die Daten aus der Datei in die Datenstruktur ein
und geben sie sofort danach zum Test auf dem Bildschirm aus:
14
void main()
{
struct daten dat;
Jetzt haben wir die komplette Länderspielbilanz im Speicher und können beliebige
Anfragen beantworten und Auswertungen durchführen. Ich möchte Ihnen dafür nur
ein Beispiel geben. Wir fragen uns, wer denn der Lieblingsgegner der deutschen
Mannschaft ist. Das heißt, wir suchen das Land, gegen das Deutschland bisher die
meisten Länderspiele absolviert hat, und wollen die Bilanz gegen diesen Gegner aus-
413
14 Datenstrukturen
geben. Wir erstellen dazu eine Funktion, die einen Zeiger auf die gesamten Daten
erhält, diese durchsucht und den Index des Lieblingsgegners zurückgibt:
Wir suchen den Index des Lieblingsgegners (A) und arbeiten dazu mit einer Schleife
über alle Länderbilanzen (B). Wenn ein Gegner mit mehr Spielen gefunden wird (C),
dann speichere das neue Maximum (D) und den Index des Gegners (E). Abschließend
wird der Index des Lieblingsgegners zurückgegeben (F).
void main()
{
struct daten dat;
int i;
lies_datei( &dat, "Laenderspiele.txt");
A i = lieblingsgegner( &dat);
printf( "\nLieblingsgegner\n");
B print_bilanz( dat.land+i);
}
Wir suchen nach dem Einlesen der Daten den Lieblingsgegner (A), geben dessen
Bilanz auf dem Bildschirm aus (B) und erhalten das folgende Ergebnis:
414
14.5 Dynamische Datenstrukturen
Lieblingsgegner
Schweiz 51 36 6 9 138:65 05.04.1908 26.05.2012
Auf weitere Auswertungen möchte ich an dieser Stelle verzichten, zumal wir die
Datenstruktur noch einmal grundlegend überarbeiten werden, um die Beschrän-
kung auf eine bestimmte Anzahl von Datensätzen endgültig zu beseitigen.
Die Adresse wird dem Zeiger zugewiesen. Der Datentyp wird angepasst.
Lassen Sie sich durch die Typanpassung (Cast-Operator) nicht verwirren. Die Typan-
passung ist formal erforderlich, damit der Returnwert der Funktion malloc zum
Datentyp unseres Zeigers passt und zugewiesen werden kann. Die Funktion malloc
kann ja, weil sie den benötigten Datentyp nicht kennt, nur einen strukturlosen Zei-
gertyp (void *) liefern, der nicht zu dem hier benötigten Zeigertyp (char *) passt. Dem
strukturlosen Zeiger wird durch die Typumwandlung die benötigte Struktur aufge-
prägt. Im Grunde genommen passiert dabei gar nichts, und wenn Sie die Typum-
wandlung weglassen, erhalten Sie allenfalls einen Warnhinweis auf nicht kompatible
Zeigertypen. Entscheidend ist, dass unser Zeiger nach der Zuweisung den korrekten
Adresswert hat und wir über den Zeiger auf unserem Speicher arbeiten können:
415
14 Datenstrukturen
zeiger[0] = 'A';
zeiger[1] = 'B'; AB
zeiger[2] = 0;
printf( "%s\n",zeiger);
Wichtig ist auch, dass der Speicher, wenn wir ihn nicht mehr benötigen, wieder frei-
gegeben werden muss.
free ( zeiger );
Der Zeiger hat nach der Freigabe immer noch den alten Adresswert, dieser darf aber
nicht mehr zum Zugriff verwendet werden. Der Zeiger selbst kann natürlich weiter-
verwendet werden, wenn er einen neuen, gültigen Adresswert erhält.
Genauso können Sie vorgehen, wenn Sie es mit einer Datenstruktur zu tun haben.
Hier stellt sich allerdings die Frage, wie viel Speicher Sie für eine Datenstruktur anfor-
dern müssen, da Sie in der Regel nicht genau wissen, wie viele Bytes eine Datenstruk-
tur konkret im Speicher belegt. Sie sollten jetzt auch nicht anfangen, die Bytes in der
Struktur zu zählen. Dazu gibt es den sizeof-Operator, der uns die vom Compiler fest-
gelegte, »amtliche« Größe mitteilt. Im folgenden Beispiel wird der Speicher für ein
Kalenderdatum allokiert, kurz verwendet und wieder freigegeben:
A struct datum
{
int tag;
int monat;
int jahr;
};
B struct datum *pdat;
C pdat = (struct datum *)malloc( sizeof( struct datum));
416
14.5 Dynamische Datenstrukturen
D pdat->tag = 31;
pdat->monat = 12;
pdat->jahr = 2000;
E free( pdat);
In dem Programm wird die Datenstruktur für ein Kalenderdatum angelegt (A) und
ein Zeiger auf so eine Struktur bereitgestellt (B). Danach wird die Größe der Daten-
struktur ermittelt und entsprechend Speicher angefordert (C). Innerhalb der Zeile
wird mit (struct datum *) der Datentyp angepasst und die Adresse schließlich dem
Zeiger zugewiesen. Die dynamisch allokierte Datenstruktur kann nun verwendet
werden (D), bevor sie zum Programmende wieder freigegeben wird (E).
In der Regel erfolgt die Freigabe des Speichers natürlich nicht unmittelbar nach einer
einmaligen Verwendung, sondern dann, wenn die Datenstruktur nicht mehr benö-
tigt wird. Das kann an einer ganz anderen Stelle im Programm, unter Umständen
auch ganz am Ende des Programms, sein. Das Laufzeitsystem gibt übrigens bei Pro-
grammende alle Ressourcen, die Ihr Programm belegt hat, wieder frei. Dazu gehört
auch der von Ihnen allokierte Speicher. Trotzdem ist es guter Programmierstil, nicht 14
mehr benötigten Speicher zeitnah an das Laufzeitsystem zurückzugeben. Immer
wenn Sie in einem Ihrer Programme eine der Funktionen malloc oder calloc rufen,
sollten Sie sich Gedanken darüber machen, wo das zugehörige free gerufen wird.
Unser eigentliches Problem haben wir aber noch nicht gelöst, sondern nur verlagert.
Denn wenn wir eine unbekannte Zahl an Datenstrukturen verarbeiten wollen, können
wir diese zwar bedarfsgerecht allokieren, benötigen dazu aber eine unbekannte Zahl
an Zeigern. Zur Lösung des eigentlichen Problems verwenden wir jetzt einen ganz ein-
fachen Trick. Immer wenn wir den Speicher für eine neue Datenstruktur holen, holen
wir uns auch den Speicher für einen Zeiger auf eine weitere Datenstruktur. Das heißt,
wir bauen in die Datenstruktur einen Zeiger auf die nächste Datenstruktur ein. Dieser
Gedanke führt uns zum Konzept der Liste (siehe Abbildung 14.26).
struct datum
{
A struct datum *next;
int tag;
int monat;
int jahr;
};
417
14 Datenstrukturen
datum
tag
datum
datum
tag tag
Wir haben nun innerhalb der Struktur einen Zeiger auf ein Folgeelement eingeführt
(A). Mit dieser Datenstruktur können wir eine gegebenenfalls sehr lange Kette von
Daten aufbauen. Dazu muss es allerdings eine Möglichkeit geben, das Ende der Kette
zu erkennen. Als Endemarkierung eignet sich der Adresswert 0, da 0 keine gültige
Speicheradresse ist. Um deutlich zu machen, dass Sie eigentlich nicht den Adresswert
0, sondern den Nullzeiger meinen, können Sie auch NULL schreiben. Bei NULL handelt
es sich um ein Makro, das durch (void *)0 definiert ist. Bei NULL handelt es sich also
um einen unspezifizierten Zeiger mit dem Adresswert 0.
Wir erstellen jetzt das Hauptprogramm, in dem wir eine Liste anlegen:
void main()
{
A struct datum *liste = NULL;
B liste = eingabe();
C ausgabe( liste);
D freigabe( liste);
}
In der Funktion ist in (A) der Listenanker definiert. Zu Beginn ist die Liste noch leer.
Die Arbeit mit der Liste habe ich in drei Unterprogramme ausgelagert (B, C und D),
die wir einzeln betrachten wollen. In der ersten Funktion (eingabe) wird die Liste auf-
gebaut, und ein Zeiger auf den Listenanker wird an das aufrufende Programm
zurückgegeben.
418
14.5 Dynamische Datenstrukturen
for( ; ;)
{ Der Benutzer will noch ein weiteres Datum
printf( "Noch ein Datum? "); eingeben, also wird eine neue Datenstruktur
scanf( "%d", &weiter); allokiert und mit Werten gefüllt.
if( !weiter)
break;
pneu = (struct datum *)malloc( sizeof( struct datum));
printf( "Datum: ");
scanf( "%d.%d.%d", &pneu->tag, &pneu->monat, &pneu->jahr);
pneu->next = anker;
1 Das neue Element wird vorn in die Liste eingekettet:
anker = pneu; 2
} anker
return anker; 2 1
} neu
Der Listenanker wird zurückgegeben.
14
Abbildung 14.27 Die Funktion eingabe
Das Hauptprogramm speichert sich den Rückgabewert dieser Funktion als Listenan-
ker und kann dann nach Belieben weitere Operationen auf der Liste ausführen. Dazu
iteriert man in der Regel über die Elemente der Liste, um dann auf einzelnen Elemen-
ten die gewünschten Operationen auszuführen. Als Beispiel geben wir die Liste auf
dem Bildschirm aus:
419
14 Datenstrukturen
Dieses Beispiel zeigt, wie einfach und elegant Sie mit Zeigern auf dynamischen
Datenstrukturen arbeiten können. Sie hätten sogar auf den Hilfszeiger verzichten
können und direkt mit der Kopie des Listenankers durch die Liste iterieren können.
Sie können, auch wenn ich das hier nicht zeige, die Liste jederzeit verändern, indem
Sie Elemente einfügen oder entfernen. Die Liste bleibt im Speicher erhalten, bis sie
explizit durch Aufruf der Funktion freigabe wieder beseitigt wird.
Jetzt kann der Benutzer so viele Kalenderdaten eingeben, wie er will. Die Eingabe ist
nur durch den verfügbaren Speicher begrenzt. Würde man diese Grenze erreichen,
sodass kein Speicher mehr zugeteilt werden könnte, würde die Funktion malloc
einen Nullzeiger zurückgeben. Ich überprüfe das hier allerdings nicht, da diese
Grenze durch manuelle Eingaben nicht erreicht werden kann. Alle Daten werden in
der Liste gesammelt und nach Abschluss der Eingabe in umgekehrter Reihenfolge
wieder ausgegeben.
void main()
{
A struct datum *liste = NULL;
B liste = eingabe();
C ausgabe( liste);
D freigabe( liste);
}
420
14.6 Ein vollständiges Beispiel (Teil 2)
Dass die Ausgabe in umgekehrter Reihenfolge erfolgt, liegt daran, dass wir neue Ele-
mente immer am Anfang der Liste einfügen. Wollte man die Eingabereihenfolge
erhalten, müsste man neue Elemente am Ende der Liste anfügen. Dazu erhalten Sie
später noch ein Beispiel. 14
Als Erstes ändern wir die Datenstruktur. Wir arbeiten jetzt mit einer Liste von Bilan-
zen. Die umfassende Datenstruktur (struct daten) mit dem Array und der Anzahl der
Array-Elemente benötigen wir nicht mehr, dafür müssen wir aber zur Bilanz ein Ver-
kettungsfeld hinzufügen:
struct bilanz
{
A struct bilanz *next;
B char *name;
struct spiele ergebnisse;
struct tore treffer;
struct datum erstes;
struct datum letztes;
};
421
14 Datenstrukturen
Neben diesem hinzugefügten Feld next (A) habe ich zusätzlich das Array für den Län-
dernamen, das fest auf 30 Zeichen ausgelegt war, durch einen Zeiger ersetzt (B). Das
bedeutet, dass der Ländername jetzt nicht mehr innerhalb der Datenstruktur bilanz
steht, sondern außerhalb der Struktur allokiert werden muss. Innerhalb der Struktur
steht, jetzt nur noch ein Zeiger, der auf den Namen verweist. Vorher wurden 30 Bytes
für jeden Ländernamen verbraucht – egal, wie lang der Ländername wirklich war.
Ländernamen mit mehr als 29 Zeichen (+ Terminator) waren nicht möglich. Jetzt
wird für jeden Namen nur noch so viel Speicher allokiert, wie er wirklich belegt, und
Ländernamen können mehr als 30 Zeichen enthalten. Beim Zugriff auf den Länder-
namen besteht übrigens kein Unterschied, da ein Array ja immer schon wie ein Zei-
ger behandelt wurde. Die Änderungen müssen wir beim Lesen einer Bilanz
berücksichtigen:
422
14.6 Ein vollständiges Beispiel (Teil 2)
Zunächst wird versucht, den Ländernamen in einen 100 Zeichen großen Puffer zu
lesen (A, B). Dabei wird festgestellt, ob es überhaupt noch einen Datensatz gibt. Gibt
es noch einen Datensatz, wird zuerst der erforderliche Speicher für eine Bilanz allo-
kiert (C). Danach wird der Speicher für den Ländernamen in der erforderlichen Länge
(Stringlänge + Terminatorzeichen) geholt und direkt mit der Bilanz verknüpft (D).
Jetzt ist ausreichend Speicher bereitgestellt, der Ländername kann kopiert (E) wer-
den, und die restlichen Daten können aus der Datei nachgeladen werden (F). Die
Funktion gibt am Ende einen Zeiger auf den neuen Datensatz zurück (G).
Auf der übergeordneten Aufrufebene öffnen wir die Datei und verketten die einzel-
nen Bilanzen zu einer Liste:
Die Rückgabe der Funktion ist eine Liste von Bilanzen, dazu erhält sie als Parameter
den Namen der Datei, in der die Daten stehen (A). Die Funktion liest in einer Schleife
jeweils eine Bilanz aus der geöffneten Datei und gibt einen Zeiger auf den Datensatz
zurück (B). Der neue Datensatz wird dann am Anfang der Liste eingekettet (C).
Das rufende Programm erhält einen Zeiger auf das erste Listenelement und kann
damit iterativ auf die gesamte Liste zugreifen. Da immer am Anfang der Liste einge-
kettet wird, ergibt sich eine Liste, in der die Länder alphabetisch rückwärts sortiert
sind. Das ist nicht weiter problematisch, aber wenn es Sie stört, können Sie die Kette
auch umgekehrt aufbauen. Wenn Sie dabei die doppelte Indirektion verwenden, geht
das Einketten am Ende sogar einfacher als das Einketten am Anfang:
423
14 Datenstrukturen
In der abgewandelten Version der Funktion wird der Zeiger auf den Listenanfang (A)
verwendet sowie ein Zeiger auf einen Zeiger auf eine Bilanz (B) – eine doppelte Indi-
rektion.
Wenn Sie gerade erst gelernt haben, mit Indirektion (Zeigern) umzugehen, ist es
sicherlich nicht ganz einfach, mit doppelter Indirektion konfrontiert zu werden. Aber
wir wollen es einmal versuchen. Zunächst ist da die Variable ppb. Vor dieser Variablen
steht ein doppelter Stern. Diese Variable ist damit ein Zeiger – und zwar ein Zeiger, der
auf einen anderen Zeiger zeigt. Die Variable ppb enthält also die Adresse eines Zeigers
auf eine Bilanz. Wir wollen diese Variable in diesem Programm so verwenden, dass sie
immer auf die Adresse im Speicher zeigt, an der die nächste Bilanz eingetragen werden
muss. Am Anfang ist das die Adresse des Listenankers – also ppb = &liste. Mit der
Anweisung *ppb = lies_bilanz( pf) tragen wir dann an dieser Stelle einen neuen Zei-
ger auf eine Bilanz ein. Mit der Anweisung (*ppb)->next gehen wir danach zum Ver-
kettungsfeld dieser frisch eingetragenen Bilanz und ermitteln mit dem Adress-
Operator die Speicheradresse dieses Verkettungsfeldes – also &(*ppb)->next). Das ist
die Adresse, an der der Zeiger auf die nächste Bilanz eingetragen werden muss.
ppb
bilanz
bilanz
424
14.6 Ein vollständiges Beispiel (Teil 2)
Wird im Unterprogramm keine Bilanz mehr gefunden, kommt eine 0 zurück und
wird ebenfalls eingetragen. Damit ist die Liste automatisch terminiert und in der Rei-
henfolge aufgebaut, in der die Datensätze in der Datei stehen.
Wir dürfen natürlich die Freigabe der Liste nicht vergessen. Dazu erstellen wir eine
Funktion, die wir am Ende unseres Hauptprogramms rufen werden:
B while( liste)
{
C pb = liste;
D liste = liste->next;
E free( pb->name);
F free( pb);
}
}
14
Listing 14.19 Die Funktion freigabe
In der Funktion wird die komplette Liste freigegeben (A). Die Freigabe wird fortge-
setzt, solange die Liste nicht leer ist (B). Zur Freigabe wird das erste Element der Liste
genommen und ausgekettet (C) und (D). Bevor die Bilanz in (F) freigegeben wird,
muss der Speicher für den Ländernamen freigegeben werden (E).
Wenn wir jetzt noch eine Funktion zur Ausgabe einer Bilanz erstellen,
425
14 Datenstrukturen
printf( "\n");
}
können wir testen, ob der Auf- und Abbau der Liste klappen.
void main()
{
struct bilanz *liste;
struct bilanz *pb;
C freigabe( liste);
}
Das entsprechende Hauptprogramm ist übersichtlich, es besteht nur aus dem Einle-
sen der Liste (A), ihrer Ausgabe (B) und der Freigabe aller Daten (C).
Das Programm liefert die vollständige Liste als Ausgabe, von der wir hier nur die ers-
ten Zeilen darstellen:
Nachdem wir die Länderspielbilanz erfolgreich eingelesen haben, haben wir eine
kleine »Datenbank« im Speicher unseres Rechners, die wir befragen können. Typi-
scherweise iteriert man dazu über die Daten und wählt die Datensätze aus, die
bestimmten Kriterien entsprechen. Wir wollen Ihnen dazu einige Beispiele geben.
426
14.6 Ein vollständiges Beispiel (Teil 2)
Als Erstes wollen wir die Bilanz eines Landes anhand des Ländernamens finden. Dazu
erstellen wir die folgende Funktion:
Die Funktion sucht in der übergebenen Liste pb das Land land (A). Dazu wird über die
Liste iteriert (B). Falls in der Schleife die Namen übereinstimmen, wird ein Zeiger auf
den gefundenen Datensatz zurückgegeben (C). Wenn das Land nicht gefunden
wurde, erfolgt die Rückgabe von NULL (D).
14
Im Hauptprogramm suchen wir »Italien« und geben die Bilanz gegen Italien auf dem
Bildschirm aus:
void main()
{
struct bilanz *liste;
struct bilanz *pb;
freigabe( liste);
}
Das Hauptprogramm sucht die Daten für Italien (A), gibt den gefundenen Datensatz
aus (B) und erzielt damit dieses Ergebnis:
427
14 Datenstrukturen
Auch Suchen mit mehreren Treffern stellen kein Problem dar. Wir suchen z. B. alle
Länder mit dem Anfangsbuchstaben 'B' und erstellen dazu die folgende Funktion:
Die Funktion sucht in der Liste der Bilanzen pb das erste Land, dessen Name mit die-
sem Buchstaben beginnt (A). Dazu wird innerhalb der Funktion über die Liste iteriert
(B). Falls die Buchstaben übereinstimmen, wird ein Zeiger auf den gefundenen
Datensatz zurückgegeben (B). Wenn die Funktion keinen Treffer findet, gibt sie den
Wert NULL zurück (D).
void main()
{
struct bilanz *liste;
struct bilanz *pb;
freigabe( liste);
}
Die Schleife (A) startet am Listenanfang, läuft so lange, bis ein weiteres Land gefun-
den wird, und geht nach jedem Durchlauf zum nächsten Land. Innerhalb des Schlei-
fenkörpers wird dann nur noch der Treffer ausgegeben (B).
428
14.6 Ein vollständiges Beispiel (Teil 2)
Wir verwenden die Funktion jetzt iterativ, indem wir uns in einer Schleife immer den
nächsten Treffer geben lassen. Beachten Sie, dass im Test der Schleife kein Vergleich,
sondern eine Zuweisung steht. Das Ergebnis des Funktionsaufrufs wird dem Zeiger
pb zugewiesen, und solange das Ergebnis nicht 0 ist, also ein weiterer Treffer gefun-
den wurde, wird weitergemacht.
Ich möchte Ihnen an diesem Beispiel noch einmal das Prinzip der Callback-Funktio-
nen demonstrieren. Sie haben dieses wichtige Prinzip im Zusammenhang mit Funk-
tionszeigern ja bereits kennengelernt. Wir haben hier zwei select-Funktionen, die im
Prinzip identisch sind, nur in ihrem Inneren jeweils eine andere Testfunktion ver-
wenden. Viele weitere Tests sind denkbar. Das kann man vereinheitlichen, wenn
man der select-Funktion die Testfunktion als Parameter übergibt. Die Situation ist
14
hier aber insofern etwas komplizierter, als die Testfunktionen ihrerseits Ver-
gleichsparameter (Ländername, Anfangsbuchstabe) benötigen, die der select-Funk-
tion unbekannt und darüber hinaus strukturell verschieden sind. Wir machen uns
zunächst einmal klar, wie es ablaufen soll:
왘 Das Hauptprogramm ruft die select-Funktion und übergibt dieser die Liste, die
Testfunktion und den Vergleichsparameter.
왘 Die select-Funktion iteriert über die Liste und ruft für jedes Element in der Liste
die Testfunktion. Sie übergibt der Testfunktion dabei das Element und den Ver-
gleichsparameter.
왘 Die Testfunktion prüft anhand des Vergleichsparameters, ob das Element das Ver-
gleichskriterium erfüllt, und meldet an die select-Funktion zurück, ob das Ele-
ment ausgewählt werden soll.
왘 Die select-Funktion gibt das nächste ausgewählte Element an das Hauptpro-
gramm zurück.
Problematisch ist, dass die select-Funktion den Typ des Vergleichsparameters nicht
kennt und auch nicht kennen darf, weil dies die Allgemeinheit des Ansatzes verlet-
zen würde. Wir übergeben der select-Funktion daher einen unspezifizierten Zeiger
(void *), den wir dort, wo wir den Typ kennen, wieder in einen spezifischen Zeiger
zurückverwandeln. Wir fangen mit der Implementierung bei der Testfunktion an.
Um ein anderes Testszenario zu haben, wollen wir einen Datumsvergleich vor-
nehmen:
429
14 Datenstrukturen
Die Schnittstelle der Funktion beinhaltet einen Zeiger pb auf die zu untersuchende
Bilanz und p als Vergleichsparameter. Hier handelt es sich eigentlich um einen Zeiger
auf ein Kalenderdatum (struct datum *), der als Erstes mit dem Cast-Operator auf den
korrekten Datentyp umgesetzt wird (B).
Innerhalb der Funktion erfolgt dann die Berechnung der Tage in den Kalenderdaten
(C und D). Wenn das Spiel nach dem übergebenen Datum (pd) stattgefunden hat, wird
eine 1 zurückgegeben, ansonsten eine 0.
Zum Datumsvergleich berechne ich hier ein Tagesäquivalent, das auf zwölf Monaten
mit 31 Tagen beruht. Das ist aber nicht das Wesentliche in diesem Programm. Das
Wesentliche ist die Schnittstelle, an der ein Zeiger auf eine Bilanz und ein weiterer,
unspezifizierter Zeiger übergeben werden. Das Programm geht davon aus, dass sich
hinter dem unspezifizierten Zeiger in Wirklichkeit ein Zeiger auf ein Kalenderdatum
verbirgt, und interpretiert den Zeiger entsprechend. Dies zeigt noch einmal deutlich,
dass bei einer Datenstruktur nur eine Schablone über die eigentlichen Daten gelegt
wird. Und dieses Programm betrachtet die Daten jetzt durch die Schablone eines
Kalenderdatums. Die Schnittstelle zum rufenden Programm ist:
Das übergeordnete Programm, das wir jetzt erstellen werden, weiß gar nicht, was es
in dem unspezifizierten Zeiger transportiert, da es nur diese Schnittstelle kennt:
A struct bilanz *select( struct bilanz *pb, int testfkt( struct bilanz *
, void *data), void *p)
{
B for( ; pb; pb = pb->next)
{
C if( testfkt( pb, p))
430
14.6 Ein vollständiges Beispiel (Teil 2)
D return pb;
}
E return NULL;
}
Die Schnittstelle der Funktion (A) enthält als ersten Parameter pb als Zeiger auf eine
Bilanz, fkt als Zeiger auf eine Funktion, die einen Zeiger auf eine Bilanz und einen Zeiger
auf einen unspezifizierten Zeiger erhält und einen int-Wert zurückgibt, und schließlich
als dritten Parameter einen unspezifizierten Zeiger als Vergleichsparameter.
Die Funktion iteriert über die komplette Liste (B) und ruft für jeden Listeneintrag die
als Parameter übergebene Funktion mit der aktuell betrachteten Bilanz und dem
Vergleichsparameter auf (C). Wenn der Vergleich erfolgreich war, wird die entspre-
chende Bilanz ausgewählt und zurückgegeben (D). Wenn keine passende Bilanz
gefunden wird, gibt die Funktion NULL zurück (E).
Die Funktion select iteriert also über die Bilanzen und fragt bei jeder Bilanz bei der
Callback-Funktion nach, ob die Bilanz gewählt werden soll. Dabei übergibt sie trans- 14
parent den Vergleichsparameter an die Callback-Funktion, ohne dessen Typ oder
Bedeutung zu kennen.
Im Hauptprogramm müssen wir jetzt nur noch diese Funktion geeignet aufrufen
und erhalten eine Liste aller Länder, gegen die es nach dem 1.1.2013 noch ein Länder-
spiel gegeben hat:
void main()
{
struct bilanz *liste;
struct bilanz *pb;
A struct datum dat = {1,1,2013};
freigabe( liste);
}
Das Programm definiert zuerst ein Vergleichsdatum (A), liest die Daten aus der Datei
und ruft dann die select-Funktion mit drei Parametern auf, und zwar pb als die Liste
431
14 Datenstrukturen
der Bilanzen, test als Callback-Funktion und das Vergleichsdatum dat als unspezifi-
zierten Zeiger (B).
Für eine einzelne Verwendung der select-Funktion würde man diesen Program-
mieraufwand sicherlich nicht in Kauf nehmen, aber bei jeder weiteren Verwendung
würde man von der einmal geleisteten Arbeit profitieren, da man jetzt nur noch eine
Callback-Funktion erstellen muss, die prüft, ob eine Bilanz ausgewählt werden soll.
Solche Programmiertechniken verwendet man daher immer dann, wenn man ein
allgemeines Vorgehen sehr häufig in speziellen unterschiedlichen Situationen
anwenden will – typischerweise in Funktionsbibliotheken.
왘 vom Compiler nicht entdeckt werden können und erst zur Laufzeit auftreten
왘 sich wie Zeitbomben verhalten, da die Auswirkungen eines Fehlers nicht unmittel-
bar, sondern unter Umständen erst lange, nachdem der Fehler gemacht worden
ist, sichtbar werden
왘 Fernwirkung haben können, da die Fehlersymptome an ganz anderen Stellen des
Programms auftreten als die Fehler und keine ursächliche Verknüpfung zwischen
Fehlerursache und Fehlerwirkung zu erkennen ist
왘 schwer zu reproduzieren sind, da sie stark vom dynamischen Programmkontext
abhängen und manchmal nur in selten vorkommenden Konstellationen auftre-
ten
왘 sich manchmal gar nicht zeigen, sondern das Programm nur verschlechtern oder
bei Langzeitbetrieb das System zunehmend beanspruchen oder gar blockieren
432
14.7 Die Freispeicherverwaltung
433
14 Datenstrukturen
Im Folgenden zeigen wir Ihnen einige typische Fehler im Umgang mit dem Freispei-
chersystem.
Ursache Auswirkung
Speicher wird nach der Dies kann noch gut gehen, bis das Betriebssystem diesen
Freigabe noch benutzt. Speicherbereich wieder ausliefert. Spätestens dann aber
treten unabsehbare Fehler auf.
Das Programm über- Die Freispeicherverwaltung ist korrupt. Der Fehler muss
schreibt die Verwal- nicht sofort sichtbar werden, sondern erst, wenn das
tungsinformation des System eine Operation auf dem korrupten Block durch-
Freispeichersystems. führen will. Auch hier ist eine Zuordnung von Ursache
und Wirkung nicht möglich.
434
14.8 Aufgaben
Darüber hinaus gibt es Fehler, die vielleicht gar nicht bemerkt werden, da sie nur in
einer schleichenden Verschlechterung des Laufzeitverhaltens des Programms sicht-
bar werden.
Ursache Auswirkung
14.8 Aufgaben
A 14.1 In Abschnitt 7.6.1, »Bruchrechnung«, haben wir Brüche durch zwei-elementige
Arrays dargestellt und die Addition und das Kürzen von Brüchen program-
miert. Erstellen Sie die gleichen Funktionen erneut, verwenden Sie diesmal
zur Speicherung von Brüchen jedoch eine geeignete Datenstruktur.
A 14.2 Erstellen Sie eine Datenstruktur, die den Namen und das Alter einer Person
aufnehmen kann. Erstellen Sie dann ein Programm, das diese Daten für zehn
Personen einliest und nach Alter sortiert wieder ausgibt.
A 14.3 Erstellen Sie eine Funktion, die ein Array von ganzen Zahlen übergeben
bekommt und den größten, den kleinsten und den Mittelwert der übergebe-
nen Werte in einer Datenstruktur zurückgibt.
A 14.4 Erstellen Sie eine Datenstruktur für ein Fußballturnier mit einer festen Anzahl
von Mannschaften. Bei dem Turnier spielt jede Mannschaft gegen jede Mann-
435
14 Datenstrukturen
schaft in einem Hinspiel und einem Rückspiel. Die Datenstruktur sollte fol-
gende Informationen aufnehmen können:
Darüber hinaus sollte eine Tabelle berechnet werden können und ebenfalls in
der Datenstruktur abgelegt werden. Zur Bearbeitung der Datenstruktur erstel-
len Sie folgende Funktionen:
A 14.5 Erstellen Sie ein Programm, das eine Liste von Zahlen verwaltet. Das Pro-
gramm soll folgende Funktionen enthalten:
A 14.6 Erstellen Sie eine doppelt verkettete Liste mit fortlaufend nummerierten Zah-
len als Nutzlast. Erstellen Sie dann eine Ausgabefunktion, mit der man inter-
aktiv durch die Liste iterieren kann. Die Ausgabefunktion soll dabei durch
folgende Kommandos gesteuert werden:
왘 + Vorwärtsschritt
왘 - Rückwärtsschritt
왘 0 Abbruch
In der Ausgabefunktion wird immer der Wert des aktuell betrachteten Listen-
elements ausgegeben.
436
Kapitel 15
Ausgewählte Datenstrukturen
Trees sprout up just about everywhere in Computer Science.
(Bäume schlagen in der Informatik praktisch überall aus.)
– Donald E. Knuth
Stellen Sie sich vor, dass Sie in einem Programm das Telefonbuch einer großen Stadt 15
mit Hundertausenden von Einträgen verwalten wollen. Jeder Eintrag besteht aus
einer Datenstruktur, die den Namen, den Vornamen und die Telefonnummer des
Teilnehmers enthält. Sie wissen vorab nicht, wie viele Einträge es geben wird, und es
können jederzeit Einträge hinzukommen oder entfernt werden. Darüber hinaus soll
es möglich sein, über den Namen auf einen Eintrag zuzugreifen. Für dieses Szenario
wollen wir möglichst allgemeingütige Datenstrukturen modellieren und uns Gedan-
ken über Speicher- und Zugriffseffizienz machen. Die einzige Datenstruktur, die Sie
bisher kennen und mit der Sie dieses Problem lösen könnten, ist eine Liste. Vielleicht
finden wir aber noch etwas Besseres.
...
Bolivien 1
Oesterreich 38
Paraguay 2
Marokko 4
Belgien 25
...
437
15 Ausgewählte Datenstrukturen
Die Reihenfolge der Länder ist dabei nicht alphabetisch, sondern zufällig gewählt. Für
die Datensätze in der Datei haben wir auch bereits eine Datenstruktur gegner ange-
legt:
struct gegner
{
char *name;
int spiele;
};
Wir wollen jetzt einen »Container« programmieren, in dem wir eine unbeschränkte
Anzahl von Gegnern speichern können.
Bolivien
Oesterreich
Container
Paraguay
Marokko
Wir wollen verschiedene Speichertechniken für den Container entwickeln und diese
bezüglich ihrer Laufzeit- und Speichereffizienz miteinander vergleichen.
왘 Liste
왘 Binärbaum
왘 Treap
왘 Hash-Tabelle
438
15.1 Listen
15.1 Listen
Eine Liste ist eine endliche Menge von (Daten-)Elementen, die durch eine Nachfolge-
operation miteinander verbunden oder verkettet sind. Über die Nachfolgeoperation
sind dann in naheliegender Weise die Begriffe Nachfolger und Vorgänger eines Ele-
ments definiert. Es gibt genau ein Element, das keinen Vorgänger hat. Dieses Element
heißt Listenanfang. Außerdem gibt es genau ein Element, das keinen Nachfolger hat.
Dieses Element heißt Listenende. Jedes Element der Liste ist vom Listenanfang aus
durch eine genau bestimmte Anzahl von Nachfolgeoperationen erreichbar.
Als grafische Notation für ein Element wählen wir ein Rechteck. Die Nachfolgerope-
ration visualisieren wir durch Pfeile:
Listen sind eine häufig anzutreffende und sehr flexible Form der Speicherung vor-
rangig sequenziell zu verarbeitender Daten. Die Daten können dabei durchaus inho-
mogen sein, müssen also untereinander weder die gleiche Struktur noch die gleiche
Größe haben. Jedes Datenelement enthält einen Zeiger auf das nächstfolgende Ele-
ment. Das heißt, in jeder Datenstruktur ist die Adresse der nachfolgenden Daten-
struktur eingetragen. Die Liste wird durch den Null-Zeiger abgeschlossen. Der Null-
Zeiger ist ein Zeiger mit dem Wert 0. Da 0 nicht als normaler Adresswert vorkommt,
kann man mit diesem Wert das Listenende markieren.
Enthält jedes Element der Liste auch einen Rückverweis auf seinen Vorgänger, spre-
chen wir von einer doppelt verketteten Liste.
439
15 Ausgewählte Datenstrukturen
Eine doppelt verkettete Liste kann man vorwärts wie rückwärts durchlaufen.
Häufig gehören zu einer Liste noch zwei weitere Zeiger: ein Zeiger auf das erste Ele-
ment (Anker), um für eine sequenzielle Verarbeitung in die Liste einsteigen zu kön-
nen, und ein weiterer Zeiger auf das letzte Element, um am Ende anfügen zu können,
ohne die ganze Liste sequenziell durchlaufen zu müssen:
Listenende
Listenanker
Dies ist eine logische Sicht. Im Speicher können die einzelnen Listenelemente in
beliebiger Reihenfolge verstreut liegen.
Die Frage, ob Sie in einem Programm ein Array, eine einfach verkettete oder eine
doppelt verkette Liste verwenden sollten, können Sie anhand der folgenden Ver-
gleichstabelle zu entscheiden versuchen:
440
15.1 Listen
Operationen auf Listen können häufig durch Ändern von Zeigerwerten ausgeführt
werden, ohne dass große Datenmengen im Speicher bewegt werden müssen. Das
macht Listen flexibler als Arrays.
Bei homogenen Datenbeständen fester Anzahl, die einen effizienten und wahl-
freien Zugriff erfordern, sind Arrays die erste Wahl.
441
15 Ausgewählte Datenstrukturen
struct listentry
struct liste {
{ struct listentry *nxt;
struct listentry *first; struct gegner *geg;
}; };
Paraguay
Oesterreich
Marokko
Bolivien
Der Container besteht aus einem Header (struct liste), der nur einen Zeiger auf das
erste Listenelement (first) enthält. Die eigentliche Liste ist eine Verkettung von Lis-
tenelementen (struct listentry), die jeweils einen Zeiger auf den durch sie verwalte-
ten Gegner (geg) und einen Zeiger auf das nächste Listenelement (nxt) enthalten. Die
Liste implementieren wir außerhalb der eigentlichen Nutzdaten, sodass in die Struk-
tur dieser Daten (struct gegner) nicht eingegriffen werden muss.
Die erforderliche Struktur wird allokiert (A), initialisiert (B) und an das rufende Pro-
gramm zurückgegeben (C). Bei jedem Aufruf der list_create-Funktion wird ein
442
15.1 Listen
container1 = list_create();
container2 = list_create();
Stellen Sie sich vor, dass der Container jetzt bereits mit Daten gefüllt ist und Sie ein
Element mit einem bestimmten Namen im Container suchen. Dazu müssen Sie über
die Liste im Container iterieren und dabei berücksichtigen, dass die Elemente nach
dem Namen sortiert sind. Für jedes betrachtete Element müssen Sie drei Fälle unter-
scheiden:
1. Der Name des betrachteten Elements entspricht dem gesuchten Namen. Dann ist
das Objekt gefunden, und der Zeiger auf das Objekt kann zurückgegeben werden.
2. Der Name des betrachteten Elements ist alphabetisch größer als der gesuchte
Name. Dann kann der Name in der restlichen Liste nicht mehr vorkommen, da ja
nur noch größere Elemente folgen. Die Suche muss erfolglos abgebrochen wer-
den. 15
3. Der Name des betrachteten Elements ist alphabetisch kleiner als der gesuchte
Name. Dann muss noch weitergesucht werden.
443
15 Ausgewählte Datenstrukturen
Die Funktion erhält an ihrer Schnittstelle den Parameter l als Liste, in der das Objekt
mit dem Namen name zu suchen ist (A). Innerhalb der Funktion wird über die Liste ite-
riert (B), und die Namen der Listenelemente werden mit dem gesuchten Namen ver-
glichen (C). Bei einem Ergebnis von cmp == 0 ist der passende Eintrag gefunden und
wird zurückgegeben (E). Ein Ergebnis von cmp <0 bedeutet, dass die Suche in der Liste
erfolglos war und abgebrochen wird (E). Bei einem Ergebnis von cmp > 0 wird weiter-
gesucht, bis alle Einträge betrachtet worden sind. Wenn kein Ergebnis gefunden
wurde, gibt die Funktion eine 0 zurück (F).
Soll ein Element in den Container eingefügt werden, muss zunächst die Einfügeposi-
tion gesucht werden. Gibt es schon ein Element gleichen Namens, kann das Element
nicht eingefügt werden. Wenn das Element eingefügt werden kann, wird der Speicher
für einen weiteren Listeneintrag (struct listentry) allokiert, und die erforderlichen
Verkettungen werden hergestellt:
Das Suchen der Einfügeposition startet in (A). Wieder findet für jedes Element ein
Namensvergleich statt (B). Wenn ein Element gleichen Namens schon vorhanden ist,
wird das Einfügen abgebrochen (C). Ist die Einfügeposition gefunden, wird die
Suchschleife beendet (D). Zum Einfügen wird der benötigte Speicher allokiert (E) und
das einzufügende Element eingekettet (F und G). Nach Eintragen des Gegners (H)
wird der Erfolg der Funktion an den Aufrufer zurückgemeldet (I).
444
15.1 Listen
Das Einfügen entspricht, von der Vorgehensweise her, der Suche. Allerdings wird hier
wieder mit doppelter Indirektion gearbeitet, um das neue Element direkt an der
gefundenen Position einsetzen zu können. Iteriert wird also nicht von Element zu
Element, sondern von Einfügeposition zu Einfügeposition.
Es fehlt noch die Funktion, um einen Container vollständig – also einschließlich der
im Container gespeicherten Nutzdaten – freizugeben:
A while( e = l->first)
{
B l->first = e->nxt;
C free( e->geg->name);
D free( e->geg);
E free( e);
}
F free( l);
}
15
Listing 15.4 Freigeben des Containers
In der Funktion ist e der Zeiger auf das nächste zu löschende Element (A). Innerhalb
der while-Schleife wird e ausgekettet (B), der Name des Gegners wird freigegeben (C),
der Gegner (D) und der Listeneintrag selbst (E) werden ebenfalls freigegeben. Wenn
alle Elemente gelöscht worden sind, wird der Container selbst freigegeben (F).
Beachten Sie, dass bei while( e = l->first) eine Zuweisung an den Zeiger e erfolgt.
Sollte dabei der Null-Zeiger zugewiesen worden sein, wird die Schleife abgebrochen.
Wir testen jetzt den Container mit konkreten Daten. Insbesondere interessiert uns
der Aufbau des Containers mit den Funktionen list_create und list_insert. Das
Öffnen der Datei, das Einlesen der Daten aus der Datei und das Befüllen der Nutzda-
tenstruktur kennen Sie bereits aus Kapitel 14, »Datenstrukturen«.
445
15 Ausgewählte Datenstrukturen
A l = list_create();
for( ; ;)
{
fscanf( pf, "%s", land);
if( feof( pf))
break;
g = (struct gegner *)malloc( sizeof( struct gegner));
g->name = (char *)malloc( strlen( land)+1);
strcpy( g->name, land);
fscanf( pf, "%d", &g->spiele);
B list_insert( l, g);
}
fclose( pf);
C return l;
}
Die nötigen Anpassungen gegenüber dem bereits bekannten Vorgehen sind gering.
Geändert wurden hier das Erzeugen eines leeren Containers (A), das Einfügen eines
neuen Elements in den Container (B) und die Rückgaben des gefüllten Containers (C).
void main()
{
struct liste *l;
char land[100];
struct gegner *g;
int i;
A l = list_load( "Laenderspiele.txt");
446
15.1 Listen
if( g)
printf( "Gegen %s gab es bisher %d Spiele\n", g->name
, g->spiele);
else
printf( "%s nicht gefunden\n", land);
}
C list_free( l);
}
Um den Container zu verwenden, laden wir in (A) die Daten. Innerhalb des Contai-
ners suchen wir dann mit (B). Wenn alle Arbeiten erledigt sind, wird der erzeugte
Container in (C) wieder freigegeben. Wir erhalten bei einem Durchlauf z. B. folgendes
Ergebnis:
Land Bolivien:
Gegen Bolivien gab es bisher 1 Spiele
Land: Lummerland
Lummerland nicht gefunden
15
Für die Laufzeiteigenschaften des Containers ist die Suchtiefe beim Einsetzen bzw.
Finden von Elementen entscheidend. Die maximale Suchtiefe entspricht der Anzahl
der Elemente in der Liste. Bei zufälliger Sortierung der Elemente in der Liste können
wir erwarten, dass die mittlere Suchtiefe der Hälfte der Anzahl der Listeneinträge ent-
spricht. Wir testen dies mit 50 ausgewählten Ländern, indem wir nach jedem Gegner
einmal suchen und den Mittelwert berechnen (siehe Abbildung 15.7).
447
15 Ausgewählte Datenstrukturen
Aegypten
Maximale Suchtiefe: 50
+-Bosnien-Herzegowina
+-Bulgarien
Vielleicht gelingt es uns ja, eine Speicherstruktur zu finden, die so flexibel wie eine
Liste ist, aber nur logarithmische Suchtiefe hat.
15.2 Bäume
Ein Baum verallgemeinert den Begriff der Liste dahingehend, dass jedes Element eine
endliche Folge von Nachfolgern haben kann. Wir sprechen dann vom 1., 2., 3. Nachfol-
ger etc. Die Elemente im Baum bezeichnen wir als Knoten. Einen Baum zeichnen wir
in der folgenden Weise (siehe Abbildung 15.8).
In einem Baum gibt es genau einen Knoten, der keinen Vorgänger hat. Diesen
bezeichnen wir als den Wurzelknoten oder die Wurzel. Alle Knoten sind von der Wur-
zel aus durch endlich viele Nachfolgeroperationen auf genau einem Weg erreichbar
(es gibt keine Schleifen oder Zyklen). Knoten, die keine Nachfolger haben, bezeichnen
wir als Blätter.
448
15.2 Bäume
Wurzel
Blätter
Binärbaume sind Bäume, bei denen ein Knoten maximal zwei Nachfolger hat. Bei
einem Binärbaum sprechen wir dann vom linken und vom rechten Nachfolger,
obwohl die Begriffe »links« und »rechts« softwaretechnisch keinen Sinn haben. 15
Binärbaum
Hinweis: Wenn wir in diesem Kapitel von einem Baum sprechen, meinen wir immer
einen Binärbaum.
Betrachten wir einen Baum, stellen wir eine starke Selbstähnlichkeit fest. Wenn wir
uns auf einen beliebigen Knoten K des Baums positionieren und alle von diesem
Knoten aus erreichbaren Knoten betrachten, bildet diese Unterstruktur wieder einen
449
15 Ausgewählte Datenstrukturen
Baum. Diese Unterstruktur wird als Teilbaum mit der Wurzel K bezeichnet. Bei einem
Binärbaum bezeichnen wir den mit dem linken Nachfolger eines Knotens K als Wur-
zel beginnenden Teilbaum als den linken Teilbaum des Knotens K. Entsprechend
definieren wir den rechten Teilbaum.
Durch die Anzahl von Nachfolgeroperationen, die man benötigt, um von der Wurzel
aus einen bestimmten Knoten zu erreichen, sind in einem Baum Levels definiert.
Jeder Knoten ist genau einem Level zugeordnet. Das maximale Level eines Baums + 1
bezeichnen wir als die Tiefe des Baums.
Level 0
Level 1
Tiefe 4
Level 2
Level 3
Insbesondere kann man an dieser Stelle bereits feststellen, dass die Tiefe eines »voll-
ständig gefüllten« Binärbaums proportional zum Logarithmus der Anzahl seiner
Knoten ist. Wenn man also jetzt noch geeignete Suchstrategien hätte, könnte man
eine mit sortierten Arrays vergleichbare Suchtiefe erreichen.
In der Knotenstruktur für einen Binärbaum müssen wir Zeiger für den linken und
den rechten Nachfolger anlegen. Eine einfache Knotenstruktur könnte dann so aus-
sehen:
450
15.2 Bäume
struct node
{
struct node *left;
struct node *right;
int value;
};
Als erstes Beispiel legen wir einen Baum mit 14 Knoten an. Jeder Knoten (struct node)
hat neben seinen Nutzdaten (hier nur ein Zahlenwert, value) Zeiger auf einen mögli-
chen linken oder rechten Nachfolger (left, right). Diese Zeiger haben den Wert 0,
wenn der Nachfolger nicht existiert. Ausgehend von dieser einfachen Knotenstruk-
tur, werden die Knoten statisch angelegt1 und miteinander verkettet.
/* Wurzel */
8 12 24 28 struct node n16 = { &n06, &n18, 16};
Dieser Baum wird als ein Beispiel für weitere Überlegungen dieses Abschnitts dienen.
1 Später werden unsere Bäume natürlich dynamisch aufgebaut werden, aber für eine erste
Betrachtung reicht dieser Baum erst einmal aus.
451
15 Ausgewählte Datenstrukturen
Unter der Traversierung eines Baums verstehen wir das systematische Aufsu-
chen aller Knoten des Baums, um an den Knoten gewisse Operationen durch-
führen zu können.
Um alle Knoten eines Baums zu besuchen, kann man die Selbstähnlichkeit des
Baums ausnutzen und rekursiv vorgehen.
왘 Preorder-Traversierung
왘 Inorder-Traversierung
왘 Postorder-Traversierung
왘 Levelorder-Traversierung
Preorder-Traversierung
Wir starten an einem Knoten (initial die Wurzel) und bearbeiten den Knoten, gehen
danach zum linken Nachfolger und fahren dort rekursiv mit der Bearbeitung fort.
Wenn wir vom linken Knoten und allen darunterliegenden Knoten zurückkommen,
starten wir rekursiv mit der Bearbeitung des rechten Nachfolgers:
452
15.2 Bäume
preorder( n->right);
}
}
void main()
{
printf( "Preorder:\n");
preorder( &n16);
}
Preorder:
16 6 4 2 14 10 8 12 18 22 20 26 24 28
Grafisch lässt sich der Weg durch den Baum wie folgt darstellen:
15
16
6 18
4 14 22
2 10 20 26
8 12 24 28
453
15 Ausgewählte Datenstrukturen
Inorder-Traversierung
Tauschen Sie bei der Preorder-Traversierung nur zwei Zeilen im Quellcode, erhalten
Sie die Inorder-Traversierung:
Wir tauchen in die Behandlung des linken Teilbaums eines Knotens ab, bevor wir den
Knoten selbst behandeln. Dadurch ergibt sich das folgende Programm:
void main()
{
printf( "Inorder:\n");
inorder( &n16);
}
Inorder:
2 4 6 8 10 12 14 16 18 20 22 24 26 28
Zur grafischen Darstellung müssen wir nur den Besuchspfad leicht verschieben
(siehe Abbildung 15.14).
454
15.2 Bäume
16
6 18
4 14 22
2 10 20 26
15
8 12 24 28
Postorder-Traversierung
Bei der Postorder-Traversierung erfolgt die Behandlung eines Knotens, nachdem
beide am Knoten hängenden Teilbäume bearbeitet wurden:
455
15 Ausgewählte Datenstrukturen
void main()
{
printf( "Postorder:\n");
postorder( &n16);
}
Postorder:
2 4 8 12 10 14 6 20 24 28 26 22 18 16
In der Grafik in Abbildung 15.15 zeigt sich wieder eine Verschiebung des Besuchs-
pfads:
16
6 18
4 14 22
2 10 20 26
8 12 24 28
456
15.2 Bäume
Stacks sind unfaire Warteschlangen, weil der, der zuletzt kommt, zuerst bedient wird
(LIFO, Last In First Out). Bei einem Stack wird am gleichen Ende der Warteschlange
geschrieben und gelesen:
Eingang (schreiben)
Ausgang (lesen)
Ein Stack kann als Array mit einem Schreib-Lesezeiger implementiert werden:
int i; 15
A int stack[100];
B int pos = 0;
Das Array für den Stack wird in (A) angelegt, der Schreib-Lesezeiger in (B) für den lee-
ren Stack initialisiert. Das Schreiben (C) legt den Wert auf dem Stack ab und erhöht
457
15 Ausgewählte Datenstrukturen
den Schreib-Lesezeiger, beim Lesen wird der oberste Wert vom Stack geholt und der
Schreib-Lesezeiger dekrementiert (D). Wir erhalten das folgende Ergebnis:
0 1 2 3 4 5 6 7 8 9
9 8 7 6 5 4 3 2 1 0
Queues sind faire Warteschlangen, weil der, der zuerst kommt, auch zuerst bedient
wird (FIFO, First In First Out). Bei einer Queue wird an verschiedenen Enden geschrie-
ben und gelesen.
Ausgang Eingang
(lesen) (schreiben)
Eine Queue kann als Array mit einem Schreib- und einem Lesezeiger implementiert
werden:
int i;
A int queue[100];
B int first = 0;
C int last = 0;
Die Implementierung legt zuerst eine Queue als Array an (A) und initialisiert den
Lesezeiger (B) und den Schreibzeiger (C). Beim Schreiben wird der Schreibzeiger
inkrementiert (D) und beim Lesen der Lesezeiger (E). Die Queue liefert die folgende
Ausgabe:
458
15.2 Bäume
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
Sie sehen, dass die Daten jetzt in der gleichen Abfolge abgerufen werden, in der sie
eingespeichert wurden. Das unterscheidet die Queue vom Stack. Die Daten der
Queue »wandern« bei dieser Implementierung übrigens durch das Array, da Schreib-
und Lesezeiger immer nur nach rechts verschoben werden. Das bedeutet, dass man
bei häufigem Schreiben und Lesen irgendwann ein Speicherproblem bekommt,
obwohl unter Umständen nur sehr wenige Elemente in der Queue sind. Im Kapitel 16,
»Abstrakte Datentypen«, werden wir dieses Problem lösen. Hier soll es uns nicht stö-
ren, wir machen das Array einfach groß genug.
Levelorder-Traversierung
Wie bereits angekündigt, erstellen wir zunächst eine rekursionsfreie Variante der
Preorder-Traversierung:
stack[pos++] = n;
while( pos)
{
n = stack[--pos];
if( n)
{
printf( " %2d", n->value);
stack[pos++] = n->right;
stack[pos++] = n->left;
}
}
}
459
15 Ausgewählte Datenstrukturen
Anstatt in die Rekursion zu gehen, legen wir Zeiger auf die anstehenden Knoten auf
den Stack, um sie in nachfolgenden Schleifenläufen wieder vom Stack zu holen und
zu bearbeiten. Die Reihenfolge, in der wir die Knoten (left, right) auf den Stack
legen, unterscheidet sich dabei von der Reihenfolge der rekursiven Aufrufe, weil der
Stack die Reihenfolge dreht.
Dieses Programm führt natürlich nach wie vor eine Preorder-Traversierung durch.
Jetzt aber tauschen wir den Stack durch eine Queue aus:
queue[last++] = n;
while( first < last)
{
n = queue[first++];
if( n)
{
printf( " %2d", n->value);
queue[last++] = n->left;
queue[last++] = n->right;
}
}
}
Dies bedeutet, dass »Geschwister« jetzt vor »Kindern« bearbeitet werden, da sie ja
früher in die Queue kommen und dort fair behandelt werden. Im Hauptprogramm
testen wir die neue Traversierungsstrategie
void main()
{
printf( "Levelorder:\n");
levelorder( &n16);
}
Levelorder:
16 6 18 4 14 22 2 10 20 26 8 12 24 28
460
15.2 Bäume
Dies ist die sogenannte Levelorder-Traversierung, bei der der Baum Level für Level
abgearbeitet wird:
16
6 18
4 14 22
2 10 20 26
15
8 12 24 28
Wir betrachten im Folgenden Bäume, deren Knoten der Größe nach verglichen wer-
den können, wobei mit »Größe« nicht unbedingt eine numerische Größe gemeint
ist. Es können z. B. auch den Knoten zugeordnete Namen bezüglich ihrer lexikogra-
phischen Ordnung verglichen werden.
461
15 Ausgewählte Datenstrukturen
Sortierter Binärbäum
Ein Binärbaum, bei dem die Knoten mittels einer Ordnungsrelation (<) verglichen
werden können, heißt aufsteigend sortiert, wenn an jedem Knoten K die Bedin-
gungen
X < K für alle Knoten X des linken Teilbaums von K
und
X > K für alle Knoten X des rechten Teilbaums von K
gelten.
Beachten Sie, dass die Bedingungen X < K und X > K nicht nur für den rechten bzw.
linken Nachfolger von K, sondern für alle Knoten im linken bzw. rechten Teilbaum
von K gelten müssen.
16
6 18
4 14 22
2 10 20 26
8 12 24 28
Hier ist alles größer als 6.
Durch Vertauschen von »links« und »rechts« nach der oben genannten Definition
erhält man den Begriff des absteigend sortierten Baums. Wenn wir im Folgenden von
sortierten Bäumen sprechen, meinen wir immer aufsteigend sortierte Bäume.
462
15.2 Bäume
Sortierte Bäume sind für uns von besonderem Interesse, weil man in diesen Bäumen
sehr effizient von der Wurzel zu einem gesuchten Knoten absteigen kann. Wir
machen uns das an einem konkreten Beispiel im oben dargestellten, aufsteigend sor-
tierten Baum klar. Wir starten an der Wurzel und suchen den Knoten mit dem Wert 8:
Suche 8!
16
8 < 16,
also gehe nach links!
6 18
8 > 6,
also gehe nach rechts!
4 14 22
8 < 14,
also gehe nach links!
8 < 10, 2 10 20 26
also gehe nach links! 15
8 gefunden 8 12 24 28
Um ein Element zu finden, wird in einer Schleife gezielt nach links bzw. rechts im
Baum abgestiegen, bis das gesuchte Element gefunden oder das Ende des Baums
erreicht wurde. Die maximale Suchtiefe entspricht dabei der maximalen Tiefe des
Baums.
463
15 Ausgewählte Datenstrukturen
if( n->value == v)
A {
printf( " %d gefunden\n", v);
return;
}
if( v < n->value)
B {
printf( " %2d->li", n->value);
n = n->left;
}
else
C {
printf( " %sd->re", n->value);
n = n->right;
}
}
D printf( " %d nicht gefunden\n", v);
}
Wenn der betrachtete und der gesuchte Wert übereinstimmen, ist das Element
gefunden (A). Ist das gesuchte Element kleiner, erfolgt ein Abstieg nach links (B). Ist
das gesuchte Element größer, geht der Abstieg nach rechts (C). Wenn das Element
nicht gefunden wird, erfolgt eine entsprechende Ausgabe (D).
Diese Funktion ist so geschrieben, dass der Abstieg ausführlich protokolliert wird.
Hier sehen Sie den Suchweg und das Bildschirmprotokoll bei der Suche nach Knoten
mit den Werten 1–10:
void main()
{
int i;
464
15.2 Bäume
16
6 18
15
4 14 22
2 10 20 26
8 12 24 28
Jetzt haben wir das nötige Rüstzeug, um den Container als aufsteigend sortierten
Baum zu realisieren (siehe Abbildung 15.23).
Der Container besteht aus einem Header (struct tree), der nur einen Zeiger auf die
Wurzel des Baums (root) enthält. Der eigentliche Baum ist eine Verkettung von Kno-
ten (struct treenode), die jeweils einen Zeiger auf den durch sie verwalteten Gegner
(geg) und einen »linken« (left) sowie »rechten« (right) Nachfolger enthalten.
465
15 Ausgewählte Datenstrukturen
struct treenode
{
struct tree struct treenode *left;
{ struct treenode *right;
struct treenode *root; struct gegner *geg;
}; };
Paraguay
Marokko
Oesterreich
Bolivien
Dazu wird der erforderliche Speicher allokiert (A), der noch leere Container wird ini-
tialisiert (B) und an das rufende Programm zurückgegeben (C).
container1 = tree_create();
container2 = tree_create();
466
15.2 Bäume
Wie bei der Listenimplementierung können beliebig viele Container erzeugt und
unabhängig voneinander genutzt werden. Ein nicht mehr benötigter Container wird
mit tree_free wieder beseitigt:
Hier werden zunächst alle Knoten freigegeben (A), bevor dann auch die Header-
Struktur freigegeben wird (B).
Die einzelnen Knoten des Baums werden dabei mit tree_freenode freigegeben. Die
Funktion zur Freigabe der Knoten arbeitet rekursiv, um zunächst die an einem Kno-
ten hängenden linken und rechten Teilbäume freizugeben, bevor der Knoten selbst
einschließlich des referenzierten Gegners freigegeben wird:
Wenn ein tn mit dem Wert NULL übergeben wurde, ist die Funktion am Ende dieses
Zweigs angekommen, dann gibt es nichts mehr zu tun (A). Ansonsten werden der
linke und der rechte Teilbaum (B und C), der Gegner (D und E) und der betrachtete
Knoten selbst (F) freigegeben.
Das Finden und Löschen von Knoten unterscheidet sich nicht wesentlich von den
entsprechenden Verfahren des Listencontainers. Der Unterschied besteht darin, dass
beim Abstieg zu der zu bearbeitenden Position im Baum mal nach links und mal
nach rechts verzweigt wird. Diese Verzweigungsmöglichkeiten gab es ja bei Listen
nicht.
467
15 Ausgewählte Datenstrukturen
Die Funktion erhält als Parameter für die Suche im Baum t ein Objekt mit Namen name
(A). Die Suche startet an der Wurzel des Baums und macht weiter, solange das Ende des
Baums noch nicht erreicht ist (tn != 0) (B). Das Vergleichsergebnis (C) bestimmt das
weitere Vorgehen. Bei cmp==0: ist das Objekt gefunden und wird zurückgegeben (D).
Bei cmp < 0: ist das Objekt kleiner, und es wird nach links im Baum abgestiegen (E), und
bei cmp > 0: ist das Objekt größer, und der Abstieg im Baum erfolgt nach rechts (F).
Wenn die Suche erfolglos war, gibt die Funktion eine 0 zurück (G).
Soll ein Element eingefügt werden, muss zunächst die Einfügeposition gesucht wer-
den. Gibt es schon ein Element gleichen Namens, kann das neue Element nicht ein-
gefügt werden. Wenn das Element eingefügt werden kann, wird der Speicher für
einen weiteren Knoten (struct treenode) allokiert, und die erforderlichen Verkettun-
gen werden hergestellt: Beim Einsetzen arbeiten wir wieder mit der inzwischen ver-
trauten doppelten Indirektion:
468
15.2 Bäume
Diese Funktion sollten Sie inzwischen ohne weiteren Kommentar verstehen können. 15
Das Laden der Daten in den Container und der Testrahmen für den Container unter-
scheiden sich bis auf die Benennung der Containerfunktionen nicht von den ent-
sprechenden Funktionen für den Listencontainer. Diese Funktionen müssen hier
nicht noch einmal eigens gezeigt werden. Man hätte sogar eine abstraktere, für beide
Containertypen identische Schnittstelle verwenden können, sodass der Anwender
gar nicht hätte erkennen können, welche Datenstruktur (Liste oder Baum) der Imple-
mentierung des Containers zugrunde liegt.
Entscheidend ist, welche Suchtiefe sich ergibt, wenn man zufällig angeordnete
Datensätze aus einer Datei einliest. Dies zeigt Abbildung 15.24.
Im Vergleich zur Liste sinkt die maximale Suchtiefe von 50 auf 9 und die mittlere
Suchtiefe von 25.5 auf 5.96. Mit einer maximalen Suchtiefe von 9 liegt der Baum nicht
weit vom theoretischen Optimum für Binärbäume entfernt, das für 50 Elemente bei
6 (log2(50) = 5.64) liegt. Beachten Sie, dass das im allgemeinen Fall eine Reduktion von
n auf log(n) bedeutet, was für große Datenmengen eine noch viel dramatischere Ein-
sparung ist, als das konkrete Zahlen für n = 50 zum Ausdruck bringen.
Ein Problem darf natürlich nicht verschwiegen werden. Die Suchtiefen können nicht
garantiert werden. Sie schwanken mit der Reihenfolge, in der die Daten eingelesen
werden. Sollten die Daten in der Datei in aufsteigend sortierter Reihenfolge vorliegen,
werden neue Elemente immer nur rechts im Baum angefügt, und der Baum wird zu
469
15 Ausgewählte Datenstrukturen
einer Liste. Der Baum ist in dieser Situation sogar schlechter als eine Liste, da er für die
gleiche Suchqualität mehr Speicher verbraucht und aufwendigere Algorithmen hat.
Maximale Suchtiefe: 9
Thailand
Mittlere Suchtiefe: 5.96
\--Japan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/--Ukraine
\--Irland
|
|
|
|
|
|
|
|
|
|
|
|
|
/--Oesterreich
\--Tuerkei
|
|
|
/--Wales
\--Bolivien
| /--Bosnien-Herzegowina
|
|
|
|
|
|
|
|
|
|
|
|
/--Italien
\--Marokko
|
|
|
|
|
|
/--Paraguay
|
/--USA
\--V-A-Emirate
\--Belgien
\--Israel
\--Kolumbien
|
|
|
|
|
/--Norwegen
\--Oman
|
/--Polen
\--Tunesien
\--Argentinien
|
|
|
|
|
|
|
/--Finnland
\--Island
\--Kasachstan
/--Luxemburg
\--Niederlande
|
\--Peru
|
|
|
|
|
/--Suedkorea
\--Albanien
|
\--Daenemark
|
|
|
/--Frankreich
\--Jugoslawien
\--Neuseeland
/--Nordirland
\--Russland
|
|
\--Aegypten
/--Algerien
\--Bulgarien
|
|
/--Ecuador
/--GUS
\--Mexiko
\--Portugal
|
/--San-Marino
/--Chile
/--Estland
/--Rumaenien
/--Serbien-Montenegro
/--China
/--Faeroeer
Mit der Frage, wie man die Degeneration des Baums vermeiden kann, werden wir uns
bei unserem nächsten Containertyp – dem Treap – beschäftigen.
15.3 Treaps
Die Struktur des Baums hat sich als Alternative zu Listen erwiesen. Es müssen jetzt
noch Algorithmen gefunden werden, die verhindern, dass ein Baum beim Einsetzen
und Löschen von Elementen aus der Balance gerät. Diese Algorithmen sollten eine
sich beim Einsetzen oder Löschen aufbauende Schieflage sofort wieder ausgleichen.
Es gibt zahlreiche Ansätze, dieses Problem in den Griff zu bekommen. Allerdings
steht man hier vor dem üblichen Dilemma. Je besser der Baum balanciert wird, desto
aufwendiger sind die Algorithmen zur Balancierung. Das bedeutet, dass ein Teil des
Gewinns, den man durch kürzere Suchwege erzielt, durch aufwendigere Algorith-
men wieder verloren geht.
Aus den vielen sich anbietenden Alternativen (z. B. AVL-Bäume oder Rot-Schwarz-
Bäume) habe ich hier die sogenannten Treaps ausgewählt. Zum einen sind die Algo-
470
15.3 Treaps
rithmen für Treaps recht einfach, und zum anderen zeigen Treaps, wie wirkungsvoll
man den Zufall zur effizienten Lösung eines Problems einsetzen kann. Bei den in die-
sem Abschnitt vorgestellten Algorithmen handelt es sich um sogenannte probabilis-
tische oder randomisierte Algorithmen. Dies ist eine Klasse von Algorithmen, bei
denen der Zufall eine Rolle spielt. Das exakte Ergebnis eines solchen Algorithmus, in
diesem Fall der konkrete Aufbau des Baums, ist nicht vorhersagbar. Entscheidend ist,
dass das Ergebnis unter statistischen Gesichtspunkten gut ist.
Bei dem Begriff Treap handelt es sich um ein Kunstwort, das aus der Verschmelzung
von Tree (Baum) mit Heap (Haufen) entstanden ist. Im Deutschen sagt man daher
manchmal auch »Baufen«.
Mit Heaps hatten wir uns bereits im Zusammenhang mit dem Sortierverfahren
Heapsort befasst. Dies bedarf aber sicher noch einer Auffrischung, zumal wir hier den
Heap nicht in einem Array, sondern in einem Baum3 realisieren werden.
15.3.1 Heaps
Stack, Queue und Heap sind Warteschlangen, die man vereinfacht wie folgt charakte-
risieren kann:
Bei einem Heap spricht man deshalb auch von einer Prioritätswarteschlange. Priori-
tätswarteschlangen spielen überall dort eine wichtige Rolle, wo Aufgaben prioritäts-
gesteuert abgearbeitet werden müssen.
Ein Heap ist ein Baum, in dem jeder Knoten eine Priorität hat und jeder Knoten
eine höhere Priorität hat als seine Nachfolgerknoten.
3 Stacks, Queues und Heaps sind keine konkreten Datenstrukturen, sondern abstrakte Speicher-
und Zugriffskonzepte, die man konkret z. B. durch Arrays oder Bäume implementieren kann.
Im folgenden Kapitel 16, »Abstrakte Datentypen«, werde ich diesen Gedanken noch einmal ver-
tiefen.
471
15 Ausgewählte Datenstrukturen
10
9 8
7 5 2 3
1 5 2 3 1
Bei einem Heap steht an der Wurzel des Baums das Element mit der höchsten Priori-
tät im Baum. Das Gleiche gilt für jeden Teilbaum des Baums.
Wenn die Heap-Bedingung an einer (und nur einer) Stelle im Baum gestört ist, kann
man sie sehr einfach wiederherstellen.
9 8
7 5 2 3
1 5 2 3 1
Man tauscht den Störenfried so lange mit seinem größten Nachfolger, bis die Stö-
rung nach unten aus dem Baum herausgewachsen ist:
472
15.3 Treaps
4
7 8
7
5 5 2 3
4
5
1 4 2 3 1
Es gibt einfache Algorithmen, um ein Element in einen Heap einzufügen und das Ele-
ment mit der höchsten Priorität aus einem Heap zu entnehmen.
Beide Operationen erzeugen, wenn sie auf einem intakten Heap ausgeführt werden,
am Ende wieder einen intakten Heap. Die Laufzeitkomplexität ist bei beiden Operati-
onen proportional zur Tiefe des Baums.
Ein Baum mit den beiden Ordnungskriterien Schlüssel und Priorität heißt
Treap, (Tree + Heap) wenn er bezüglich des Schlüssels ein aufsteigend sortierter
Baum und bezüglich der Priorität ein Heap ist.
473
15 Ausgewählte Datenstrukturen
Abbildung 15.28 zeigt einen Treap, wobei der Schlüssel an jedem Knoten links oben
und die Priorität rechts unten notiert ist:
16
50
6 18
45 40
4 14 22
30 42 36
2 10 20 26
10 33 31 25
8 24 28
15 16 22
In diesen Treap wollen wir ein neues Element (z. B. mit Schlüssel 13 und Priorität 48)
einfügen. Dabei interessieren wir uns zunächst nur für den Schlüssel und setzen das
Element mit dem aus dem letzten Kapitel bekannten Verfahren in den aufsteigend
sortierten Baum ein (siehe Abbildung 15.29).
13 16
48 50
6 18
45 40
4 14 22
30 42 36
2 10 20 26
10 33 31 25
8 13 24 28
15 48 16 22
474
15.3 Treaps
Dabei ist allerdings die Heap-Eigenschaft verloren gegangen. Ein einfaches Wieder-
herstellen der Heap-Eigenschaft, wie Sie es im Exkurs über Heaps gelernt haben, wäre
nicht zielführend, da dabei die aufsteigende Ordnung zerstört würde. Es kommt also
darauf an, Algorithmen zu finden, die die Heap-Eigenschaft wiederherstellen, ohne
die aufsteigende Ordnung zu zerstören. An dieser Stelle kommen die Rotationen ins
Spiel. Da wir vom Knoten 10 zum Knoten 13 nach rechts abgestiegen sind und diese
Knoten die Heap-Bedingung verletzen, korrigieren wir den Baum durch eine Linksro-
tation (siehe Abbildung 15.30).
d b
b e a d
16 Linksrotation
16
50 a c c e 50
6 18 6 18
45 40 45 40
4 14 22 4 14 22
30 42 36 30 42 36
15
2 10 20 26 2 13 20 26
10 33 31 25 10 48 31 25
8 24 28 10 24 28
13 33
15 48 16 22 16 22
8
15
Jetzt haben wir das Problem um eine Ebene nach oben zur Wurzel hin verlagert. Das
Problem ist aber immer noch nicht gelöst, da die Knoten 14 und 13 jetzt in der fal-
schen Reihenfolge sind. Da es von 14 nach 13 nach links geht, korrigieren wir durch
Rechtsrotation:
475
15 Ausgewählte Datenstrukturen
d b
b e a d
Rechtsrotation
16 16
50 a c c e
50
6 18 6 18
45 40 45 40
4 14 22 4 13 22
30 42 36 30 48 36
2 13 20 26 2 10 14 20 26
10 48 31 25 10 33 42 31 25
10 24 28 8 24 28
33 16 22 15 16 22
8
15
Das Problem wurde dadurch wieder nach oben verlagert, besteht jetzt aber zwischen
den Knoten 6 und 13. Hier muss jetzt wieder eine Linksrotation durchgeführt werden
(siehe Abbildung 15.30).
Nach diesem Rotationsschritt ist die Heap-Bedingung wiederhergestellt, und die auf-
steigende Sortierung besteht nach wie vor. Wir haben also wieder einen Treap.
Beachten Sie, dass der leere Baum ein Treap ist. Da wir beim Einsetzen eines Elements
immer wieder einen Treap herstellen können, sind wir in der Lage, einen Treap mit
beliebig vielen Knoten aufzubauen.
Ich hoffe, dass Ihnen durch diese Erklärungen auch klar geworden ist, welche Rolle
der Schlüssel und die Priorität anschaulich beim Aufbau des Baums spielen:
왘 Der Schlüssel bestimmt die aufsteigende Sortierung und sorgt damit für die Links-
rechts-Ausrichtung der Knoten im Baum.
왘 Die Priorität bestimmt die Heap-Ordnung und sorgt damit für die Oben-unten-
Ausrichtung der Knoten im Baum.
476
15.3 Treaps
d b
b e a d
16 Linksrotation
16
50 a c c e 50
6 18 13 18
45 40 48 40
4 13 22 6 14 22
30 48 36 45 42 36
2 10 14 20 26 4 10 20 26
10 33 42 31 25 30 33 31 25
8 24 28 2 8 24 28
15 16 22 10 15 16 22
Die Implementierung des Containers als Treap ist viel einfacher, als es die umfangrei-
chen Erklärungen dieses Abschnitts vermuten lassen.
1. In der Knotenstruktur muss nur ein Feld für die Priorität hinzugenommen
werden.
2. Konstruktor und Destruktor für den Container sind identisch mit den entspre-
chenden Funktionen für unbalancierte Bäume, da sich ja nur die Knotenstruktur
geändert hat.
3. Die Find-Funktion ist für Treaps ebenfalls identisch mit der entsprechenden Funk-
tion für aufsteigend sortierte Bäume, da der Treap ein aufsteigend sortierter Baum
ist.
4. Die Insert-Funktion mit den beiden Rotationen muss neu implementiert werden.
477
15 Ausgewählte Datenstrukturen
In der Datenstruktur besteht der einzige Unterschied zum Baum in dem zusätzlichen
Feld für die Priorität (prio) in der Knotenstruktur treapnode:
struct treapnode
{
struct treapnode *left;
struct treap struct treapnode *right;
{ struct gegner *geg;
struct treapnode *root; unsigned int prio;
}; };
Paraguay
Marokko
Oesterreich
Bolivien
Mit Blick auf das Einsetzen neuer Knoten implementieren wir jetzt die beiden Rotati-
onen. Wir starten mit der Rechtsrotation:
b
d
a d
b e
Rechtsrotation
c e
a c
478
15.3 Treaps
d b
b e a d
Linksrotation
a c c e
A if( *node)
{
B cmp = strcmp( g->name, (*node)->geg->name);
if( cmp > 0)
C {
if( !treap_insert_rek(&((*node)->right), g))
return 0;
if ((*node)->prio < (*node)->right->prio)
treap_rotate_left( node);
479
15 Ausgewählte Datenstrukturen
return 1;
}
if( cmp < 0)
D {
if( !treap_insert_rek(&((*node)->left), g))
return 0;
if ((*node)->prio < (*node)->left->prio)
treap_rotate_right( node);
return 1;
}
E return 0;
}
Zuerst wird geprüft, ob der Platz besetzt ist (A). Ist das der Fall, folgt ein Namensver-
gleich (B), anhand dessen Ergebnis entweder der Abstieg nach rechts und anschlie-
ßend gegebenenfalls eine Rotation nach links erfolgt (C) oder der Abstieg nach links
und anschließend gegebenenfalls eine Rotation nach rechts (D).
Ist das Element schon vorhanden, springt die Funktion zurück (E).
Ist der Platz frei, ist der Abstieg beendet, und der Knoten wird eingesetzt (F). Das Ele-
ment bekommt dabei seine Priorität (G).
Um die rekursive Einsetzprozedur wird noch eine Aufrufschale gesetzt, um die vor-
gegebene Schnittstelle zu erhalten:
480
15.3 Treaps
Die Funktion stellt dabei den passenden Namen und die vereinbarten Parameter (A)
und ruft intern die Rekursion auf (B).
Wir wollen jetzt noch überprüfen, ob der Treap die in ihn gesetzten Erwartungen
erfüllt. Bei zufällig gewählten Daten wird sich zwar ein anderer Aufbau des Baums
ergeben, aber bezüglich der Tiefe sind keine Änderungen zu erwarten. Was aber pas-
siert, wenn wir 50 alphabetisch sortierte Länderspielgegner in den Treap-Container
laden?
15
Japan
\--England
|
|
|
|
|
|
|
|
|
|
|
|
|
/--Kamerun
\--Chile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/--Island
\--Jugoslawien
|
|
|
|
|
|
|
/--Litauen
\--Bosnien-Herzegowina
|
|
|
|
/--Daenemark
\--Faeroeer
|
|
|
|
|
|
|
/--Israel
\--Kolumbien
|
|
|
|
|
|
|
|
|
|
/--Niederlande
\--Armenien
|
|
|
|
|
|
/--Bulgarien
\--Costa-Rica
|
/--Elfenbeinkueste
\--Estland
|
|
|
|
|
|
/--Irland
/--Italien
\--Kasachstan
|
|
/--Lettland
\--Marokko
|
|
|
\--Algerien
|
|
/--Australien
\--Brasilien
\--China
\--Ecuador
\--Iran
\--Kanada
\--Kroatien
|
/--Liechtenstein
\--Malta
|
|
/--Neuseeland
\--Albanien
/--Argentinien
\--Aserbaidschan
|
/--Boehmen-Maehren
\--Frankreich
|
|
|
/--Kuwait
\--Luxemburg
\--Mexiko
|
\--Aegypten
\--Belgien
/--Bolivien
\--Finnland
|
|
/--Griechenland
/--Moldawien
\--Georgien
| /--Ghana
Maximale Suchtiefe: 9
Mittlere Suchtiefe: 5.60
481
15 Ausgewählte Datenstrukturen
Es ergeben sich Werte, die nahezu identisch mit den Resultaten des Baums für
Zufallsdaten sind. Durch Randomisierung ist es uns also gelungen, einen Container
zu entwickeln, der sehr robust gegenüber vorsortierten Daten ist und in jeder Situa-
tion deutlich kürzere Suchwege als eine Liste hat.
15.4 Hash-Tabellen
Stellen Sie sich vor, dass Sie für ein Übersetzungsprogramm alle Wörter eines Wörter-
buchs (ca. 500000 Stichwörter) mit ihrer Übersetzung in einem Programm spei-
chern wollen. Ein balancierter Binärbaum hätte in dieser Situation eine Suchtiefe von
ca. 20. Damit sind Sie nicht zufrieden. Sie haben das ehrgeizige Ziel, die Suchtiefe
unter 2 zu drücken.
Ideal wäre ein Array, das für jedes Wort genau einen Eintrag hätte. Dazu müssten Sie
aus dem Wort einen eindeutigen Index berechnen, der dann die Position im Array
festlegt. Wenn Sie sich auf Worte der Länge 20 und die 26 Kleinbuchstaben a–z (gege-
ben durch die Werte 0–25) beschränken, können Sie eine einfache Funktion zur
Indexberechnung angeben.
h(b0, b1, ..., b19) = b0 · 260 + b1 · 261 + b2 · 262 + ... + b19 · 2619
Das dazu benötigte Array müsste allerdings 2620 Felder haben, da theoretisch so viele
verschiedene Wörter vorkommen können. Das ist nicht möglich.
Sie könnten die Streuung der Funktion h reduzieren, indem Sie z. B. am Ende der
Berechnung eine Modulo-Operation mit der gewünschten Tabellengröße vorneh-
men:
h(b0, b1, ..., b19) = (b0 · 260 + b1 · 261 + b2 · 262 + ... + b19 · 2619)%500000
Eine solche Funktion bezeichnet man als Hash-Funktion. Jetzt wäre allerdings nicht
mehr gewährleistet, dass jedes Wort genau einen Index bekommt. Es kann jetzt vor-
kommen, dass verschiedene Wörter auf den gleichen Index abgebildet werden. Wir
nennen dies eine Kollision. Im Fall einer Kollision könnten Sie die kollidierenden Ein-
träge in Form einer Liste (Synonymkette) an das Array anhängen.
Die auf diese Weise entstehende Datenstruktur nennt man ein Hash-Tabelle.
Hash-Tabellen kombinieren die Geschwindigkeit von Arrays mit der Flexibilität von
Listen. Durch eine breite Vorselektion über ein Array erhalten Sie eine hoffentlich
kurze Liste, die dann durchsucht wird:
482
15.4 Hash-Tabellen
Wörterbuch
… Hash-
white Tabelle
gray
gray
red
yellow
orange
pink
red white
green
blue yellow pink blue black
brown
orange green brown violet
violet
black Kollision Synonymkette
… Hash-
Funktion
Die Hash-Funktion hat entscheidenden Einfluss auf die Performance der Hash-
Tabelle. Die Hash-Funktion sollte möglichst zufällig und breit streuen, um wenig Kol-
lisionen zu erzeugen, und sehr effizient zu berechnen sein, damit durch die bei
15
jedem Zugriff erfolgende Vorselektion möglichst wenig Rechenzeit verloren geht.
Im Container implementieren Sie ein dynamisch allokiertes Array, an das die Syno-
nymketten angehängt werden.
Paraguay
Oesterreich
Marokko
Bolivien
483
15 Ausgewählte Datenstrukturen
Der Container besteht aus einem Header (struct hashtable), der neben der Größe der
Tabelle einen Zeiger auf die eigentliche Hash-Tabelle (struct hashentry **) enthält. In
der Hash-Tabelle stehen Zeiger auf die Synonymkette, die aus Verkettungselemen-
ten (struct hashentry) besteht, die jeweils einen Zeiger auf den durch sie verwalteten
Gegner (geg) und einen Zeiger auf das nächste Listenelement (nxt) enthalten. Die
Synonymketten sind strukturell genauso aufgebaut wie die Listen im Listencon-
tainer.
Ein leerer Container besteht aus einem Header (struct hashtable), an den bereits
eine Tabelle angehängt ist. In der Funktion hash_create wird ein leerer Container
erzeugt:
Die gewünschte Tabellengröße (siz) wird als Parameter übergeben und in die Hea-
der-Struktur eingetragen (h->size). Danach wird die Tabelle allokiert. Die Tabelle ent-
hält initial nur Null-Zeiger (calloc), da noch keine Daten verlinkt sind.
Bei jedem Aufruf der hash_create-Funktion wird ein neuer Container erzeugt. Ein
Anwendungsprogramm kann daher mehrere Container erzeugen und unabhängig
voneinander verwenden:
container1 = hash_create();
container2 = hash_create();
484
15.4 Hash-Tabellen
gleiche Fach und müssen dort sequenziell gesucht werden. Die zugehörige Hash-
Funktion ist:
Diese Hash-Funktion ist sehr einfach, aber für große Registraturen unbrauchbar, da
sie nur sehr gering streut. Die mathematische Analyse von Hash-Funktionen ist sehr
komplex und soll hier nicht betrieben werden. Wir verwenden in unseren Beispielen
die folgende Funktion:
Die Hash-Funktion enthält eine komplexe Berechnung, in die alle Zeichen des
Namens »gleichberechtigt« eingehen (A).
Durch die Modulo-Operation am Ende der Berechnung wird erzwungen, dass der
berechnete Wert ein gültiger Tabellenindex ist.
Hash-Funktionen haben auch in anderen Bereichen der Informatik (z. B. in der Kryp-
tologie) eine große Bedeutung. Mit Hash-Funktionen (z. B. MD5, Message-Digest
Algorithm 5) versucht man, »Fingerabdrücke« von Daten zu erhalten, aus denen man
keine Rückschlüsse auf die Ausgangsdaten gewinnen kann. Solche Hash-Funktionen
sind naturgemäß weitaus komplexer als die hier verwendete Funktion.
Um ein Element zu finden, wird zunächst mit der Hash-Funktion der Einstieg in die
Hash-Tabelle berechnet. In der Tabelle steht dann der Anker der Synonymkette, oder
0, wenn zu dem Hash-Wert noch nichts gespeichert wurde. In der Synonymkette
wird das Element dann gesucht. Die Suche in der Synonymkette ist die Listensuche,
die Sie ja bereits kennen.
485
15 Ausgewählte Datenstrukturen
Die Funktion erhält als Parameter die Hash-Tabelle h, in der das Element mit dem
Namen name gefunden werden soll (A). Für die Suche wird zuerst der Hash-Index zum
gesuchten Namen berechnet (B), um über den Hash-Index den Anker der Synonym-
kette zu finden, über die dann iteriert wird (C).
Wenn das Element gefunden wird, wird es entsprechend zurückgegeben (D), ansons-
ten ist die Rückgabe 0 (E).
Das Einsetzen in die Hash-Tabelle verläuft analog zur Suche. Mit der Hash-Funktion
wird der Einstieg in die Synonymkette berechnet. Das dann folgende Einsetzen in die
Synonymkette mittels doppelter Indirektion kennen Sie bereits als Listenoperation:
486
15.4 Hash-Tabellen
neu->geg = g;
*e = neu;
return 1;
}
In der Funktion wird wieder zuerst der Hash-Index berechnet (A). Danach erfolgt eine
Iteration über die Synonymkette (B). Ist ein Element gleichen Namens schon vorhan-
den, kann es nicht eingesetzt werden (C). Ansonsten wird das neue Element in die
Synonymkette eingefügt (D), und der Erfolg wird zurückgemeldet (E).
Im Gegensatz zum Listencontainer werden die Listen hier nicht alphabetisch sortiert
aufgebaut. Die Listen werden kurz sein, sodass sich der Zusatzaufwand für das Sortie-
ren wahrscheinlich nicht auszahlt.
Wird eine Hash-Tabelle nicht mehr benötigt, wird der belegte Speicher freigegeben.
Bevor die eigentliche Hash-Tabelle und der Header freigegeben werden können,
muss über die Tabelle iteriert werden, um alle Synonymketten mit allen anhängen-
den Datensätzen freizugeben:
Die Funktion startet mit der Iteration über die Tabelle (A). Innerhalb der Iterations-
schleife erfolgt die Iteration über eine Synonymkette (B). Hier wird mit dem Ausket-
487
15 Ausgewählte Datenstrukturen
ten eines Elements gestartet (C), bevor die Freigabe der Nutzdaten (D) und der
Verkettungsstruktur (E) erfolgt. Erst danach kann dann die Freigabe der Tabelle (F)
und des Headers (G) vorgenommen werden.
eine Zuweisung an den Zeiger e erfolgt. Sollte dabei der Null-Zeiger zugewiesen wor-
den sein, wird die Schleife abgebrochen.
Das Einlesen der Daten und das Anwendungsprogramm enthalten nur minimale
Abweichungen von den zuvor betrachteten Containertypen und müssen daher nicht
erneut betrachtet werden. Viel interessanter sind die Ergebnisse für unterschiedliche
Tabellengrößen.
Die Hash-Tabelle zeigt sehr geringe Suchtiefen, selbst dann, wenn die Tabelle nur so
groß ist wie die Anzahl der zu erwartenden Nutzdaten.
Anders als die zuvor diskutierten Containertypen reflektiert die Hash-Tabelle nicht
die Ordnung der Daten. Hashing ist ja geradezu der Versuch, jede Ordnungsstruktur
in den Daten zu zerschlagen (to hash = zerhacken). Insofern ist eine Hash-Tabelle
auch invariant gegenüber jeglicher Vorsortierung der Daten.
Abbildung 15.40 zeigt den Aufbau der Hash-Tabelle für 50 Gegner der deutschen Nati-
onalmannschaft.
Möchten Sie die vorgestellten Container miteinander vergleichen, müssen Sie die
Speicher- und die Laufzeitkomplexität berücksichtigen.
488
15.4 Hash-Tabellen
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
0:
1:
2:
3:
4:
5:
6:
7:
8:
9:
Irland.
Thailand, Aegypten.
Paraguay, Mexiko.
Russland.
Marokko.
Albanien.
Ukraine, V-A-Emirate.
Peru.
Neuseeland.
Polen, Israel.
Wales.
Norwegen.
Algerien, Island.
Frankreich.
Niederlande.
Portugal, Estland.
China, Tuerkei.
Bulgarien, Nordirland.
Finnland, Jugoslawien.
Daenemark, Serbien-Montenegro.
Kolumbien.
Japan, Tunesien.
Bosnien-Herzegowina, Suedkorea.
Belgien.
Luxemburg.
USA.
GUS.
Italien.
Bolivien, Oesterreich, Oman, Faeroeer, Kasachstan.
San-Marino.
Argentinien.
Chile.
Rumaenien.
Hash-Tabelle für 50 Gegner
Tabellengröße 50
Maximale Suchtiefe: 5
Mittlere Suchtiefe: 1.44
Tabellengröße 100
Maximale Suchtiefe: 4
Mittlere Suchtiefe: 1.24
Tabellengröße 200
Maximale Suchtiefe: 3
Mittlere Suchtiefe: 1.16
15.4.1 Speicherkomplexität
Alle Verfahren benötigen über die Nutzdaten hinaus zusätzlichen Speicher zum Auf-
bau der internen Datenstrukturen. Wir bezeichnen den Speicherbedarf für einen
Pointer/Integer mit p. Dann ergibt sich, abhängig von der Zahl der zu speichernden
Daten n, der zusätzliche Speicherbedarf s(n):
Bei Listen haben wir für jedes Element zwei Zeiger, einen auf das Element und einen
auf den nächsten Listeneintrag:
s(n) = 2pn
Bei Bäumen haben wir neben dem Zeiger auf das Element jeweils Zeiger auf den lin-
ken und den rechten Nachfolger:
s(n) = 3pn
s(n) = 4pn
Bei einer Hash-Tabelle, die dreimal so groß angelegt ist, wie die zu erwartende Anzahl
von Einträgen, ist:
s(n) = 5pn
489
15 Ausgewählte Datenstrukturen
15.4.2 Laufzeitkomplexität
Bei der Laufzeitkomplexität muss man eigentlich alle Containeroperationen einzeln
betrachten. Es ist ja so, dass etwa Treaps im Vergleich zu Bäumen zusätzliche Laufzeit
beim Einsetzen von Elementen verbrauchen. Diese Investition zahlt sich aber beim
Suchen von Elementen durch die kürzeren Suchwege wieder aus. Streng genommen,
kommt es auf das Verhältnis von Einsetz-, Such- und Löschoperationen an. Da aber
auch Einsetz- und Löschoperationen von kürzeren Suchwegen profitieren,
beschränke ich mich beim Vergleich auf die Suchtiefe.
Wie zu erwarten ist, wachsen die Suchtiefen bei Listen linear, bei Bäumen und Treaps
logarithmisch, und die Suchtiefe beim Hashing ist konstant. Letzteres gilt allerdings
nur, wenn die Tabellengröße proportional zum Datenvolumen ist.
Besonders interessant ist noch der Vergleich zwischen Treap und Baum bei vorsor-
tierten Daten. Hier ergeben sich dramatische Vorteile des Treaps:
Baum Treap
490
15.4 Hash-Tabellen
15
491
Kapitel 16
Abstrakte Datentypen
Controlling complexity is the essence of computer programming.
– Brian Kernighan
In diesem Kapitel werden Sie eigentlich nichts Neues über die Programmiersprache
C erfahren, sondern einen Programmierstil kennenlernen, der von vielen Program-
mierern als ungeschriebene Regel der C-Programmierung akzeptiert und verwendet
wird. Gleichzeitig ist dieses Kapitel bereits ein kleiner Schritt in Richtung der objekt-
orientierten Programmierung.
Mit einem Datentyp sind immer gewisse für diesen Datentyp zulässige Operationen
verbunden. Sie können Zahlen etwa addieren, multiplizieren oder der Größe nach
vergleichen. In der Definition einer Programmiersprache ist genau festgelegt, welche
Operationen auf welchen Grunddatentypen durchgeführt werden können. Unzuläs-
sige Operationen, wie etwa die Division von zwei Arrays, werden vom Compiler abge- 16
lehnt. Wenn man nun einen neuen Datentyp anlegt, stellt man sich sinnvollerweise
die Frage, welche Operationen denn auf diesem Typ zulässig sein sollen.
Als Beispiel betrachten wir ein Kalenderdatum, bestehend aus Tag, Monat und Jahr.
Eine Datenstruktur dazu ist einfach erstellt:
struct datum
{
int tag;
int monat;
int jahr;
};
Grundsätzlich kann in dieser Struktur aber alles gespeichert werden, was sich aus
drei ganzen Zahlen zusammensetzt – z. B. die Abmessungen einer Kiste in Millime-
tern. Damit man wirklich von einem Kalenderdatum sprechen kann, müssen unter
anderem die folgenden einschränkenden Bedingungen erfüllt sein:
493
16 Abstrakte Datentypen
Bei all diesen Operationen muss davon ausgegangen werden, dass die eingehenden
Daten korrekte Kalenderdaten sind und als Ergebnis wieder korrekte Kalenderdaten
erzeugt werden. Es ist daher sinnvoll, die Datenstruktur zusammen mit ihren Opera-
tionen als eine Einheit zu begreifen.
Abstrakter Datentyp
funktion1
funktion2
funktion3
funktion4
Interne Datenstruktur
Der abstrakte Datentyp verbirgt alle Implementierungsdetails (z. B. den Aufbau der
internen Datenstruktur) vor dem Benutzer. Unter den Funktionen zur Bedienung
des abstrakten Datentyps gibt es in der Regel zwei wichtige Funktionen, die eine
besondere Bedeutung haben. Der Konstruktor hat die Aufgabe, den abstrakten
Datentyp in einen konsistenten Anfangszustand zu bringen, und wird einmal zur
494
16.1 Der Stack als abstrakter Datentyp
Initialisierung des abstrakten Datentyps ausgeführt. Der Destruktor hat die Aufgabe,
einen abstrakten Datentyp rückstandslos zu beseitigen, und wird einmal, ganz am
Ende des Lebenszyklus eines abstrakten Datentyps, aufgerufen.
Anhand zweier Beispiele (Stack und Queue) werden Sie die Denkweise kennenlernen,
die hinter dem Konzept des abstrakten Datentyps steht. In C ist ein abstrakter Daten-
typ eine rein gedankliche Abstraktion, die von der Programmiersprache nicht unter-
stützt wird, sodass es hier mehr darum geht, Ihnen einen gewissen Programmierstil
vorzustellen, der dem Konzept des abstrakten Datentyps nahekommt. Trotzdem ist
die Vorstellung, es bei der Implementierung von Datenstrukturen mit abstrakten
Datentypen zu tun zu haben, sehr hilfreich für den Entwurf und die Realisierung von
Programmen, da dieser Ansatz über eine konsequente Modularisierung zu qualitativ
besseren Programmen führt. Erst mit dem Klassenkonzept in C++ wird dieser Ansatz
eine befriedigende Abrundung erfahren.
Wir wollen jetzt einen Stack implementieren, der einen ihm unbekannten Datentyp
16
verwaltet, von dem er nur die Größe (in Bytes) kennt. Neben Konstruktor und Des-
truktor gibt es die Operationen push und pop und eine Funktion isempty, die testet, ob
der Stack leer ist.
construct Stack
isempty
push
pop
destruct
Damit ergibt sich die folgende Schnittstelle für einen abstrakten Datentyp:
495
16 Abstrakte Datentypen
push Stack und OK oder OVERFLOW Lege ein Element auf den
Element Stack.
# define OK 1
# define OVERFLOW –1
# define EMPTY 0
struct stack
{
char *stck;
int ssize;
int esize;
int pos;
};
496
16.1 Der Stack als abstrakter Datentyp
Bei der Konstruktion (construct) wird festgelegt, wie viele Elemente maximal auf
dem Stack liegen können (ssiz) und wie groß die einzelnen Elemente (esiz) sind.
Der Stack kennt nur die Größe der zu verwaltenden Datenpakete und erhält daher
einen unspezifizierten Zeiger (void *), wenn er die Daten auf den Stack legen oder
vom Stack nehmen soll.
Die Operationen push und pop sind einfach zu implementieren, allerdings müssen Sie
darauf achten, dass bei push kein Overflow und bei pop kein Underflow auftritt:
497
16 Abstrakte Datentypen
if( !s->pos)
return EMPTY;
s->pos--;
memcpy( v, s->stck + s->pos*s->esize, s->esize);
return OK;
}
Listing 16.3 Implementierung von push und pop für den Stack
Der Stack ist leer, wenn der Stack-Zeiger den Wert 0 hat. Damit kann die Anfrage, ob
der Stack leer ist, sehr einfach beantwortet werden:
Durch den Destruktor wird ein Stack vollständig beseitigt, indem die allokierten Spei-
cherressourcen wieder freigegeben werden:
498
16.1 Der Stack als abstrakter Datentyp
A struct test
{
int i1;
int i2;
};
void main()
{
B struct stack *mystack;
struct test t;
int i;
srand( 12345);
C mystack = stack_construct( 100, sizeof( struct test));
for( i = 0; i < 5; i++)
{
t.i1 = rand( )%1000;
t.i2 = rand()%1000;
printf( "(%3d, %3d) ", t.i1, t.i2);
D stack_push( mystack, &t);
}
printf( "\n"); 16
E while( !stack_isempty( mystack))
{
F stack_pop( mystack, &t);
printf( "(%3d, %3d) ", t.i1, t.i2);
}
printf( "\n");
G stack_destruct( mystack);
}
Im Testprogramm wird zuerst die Struktur angelegt, die auf den Stack soll (A). Nach
der Deklaration eines Zeigers auf den abstrakten Datentyp (B) wird der Stack für 100
Datenstrukturen der entsprechenden Größe konstruiert (C). Auf den konstruierten
Stack erfolgt dann ein Push von Zufallsdaten (D). Nach dem Befüllen des Stacks
erfolgt über den Test auf einen leeren Stack (E) die Entnahme aller Testdaten über pop
(F), bevor der Stack wieder zerstört wird (G).
499
16 Abstrakte Datentypen
(584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404)
( 72, 404) (477, 413) (828, 405) (795, 125) (584, 164)
Nimmt man den Stack als Vorbild, kann man eine Queue mit wenigen Veränderun-
gen implementieren. Auch die Queue soll einen ihr unbekannten Datentyp verwal-
ten, von dem sie nur die Größe (in Bytes) kennt. Neben Konstruktor (construct),
Destruktor (destruct) und dem Test auf Leere (isempty) haben wir jetzt die Operatio-
nen put und get, um Daten in die Queue einzustellen bzw. aus der Queue zu lesen:
construct Queue
isempty
put
get
destruct
500
16.2 Die Queue als abstrakter Datentyp
put Queue und Ele- OK oder OVERFLOW Lege ein Element in die
ment Queue.
put Queue und Ele- OK oder OVERFLOW Lege ein Element in die
ment Queue.
# define OK 1
# define OVERFLOW –1
# define EMPTY 0
struct queue
{
char *que;
int qsize;
501
16 Abstrakte Datentypen
int esize;
int first;
int anz;
};
In der Datenstruktur für eine Queue speichern wir den Index des ersten Elements
(first) und die Anzahl (anz) der Elemente, die aktuell vorhanden sind. Um ein unnö-
tiges Umkopieren von Daten innerhalb des Nutzdaten-Arrays zu vermeiden, wollen
wir die Daten als Ringpuffer anlegen.
Ein Ringpuffer ist ein Array, das gedanklich zu einem Ring geschlossen ist, sodass
man, wenn man hinten herausläuft, vorn wieder hineinkommt. In einem Ringpuffer
können Sie eine Queue mit Schreib- und Lesezeiger anlegen, die nicht aus dem
zugrunde liegenden Array hinausläuft. Sie müssen nur darauf achten, dass der
Schreibzeiger den Lesezeiger nicht überrundet. Das kann dann so aussehen:
0
0 Lesezeiger
Lesezeiger Schreibzeiger
Schreibzeiger
Der Schreibzeiger ist physikalisch
und logisch vor dem Lesezeiger.
Aber der Schreibzeiger kann in einem Ringpuffer auch hinter dem Lesezeiger sein1. Genau
genommen, gibt es die Begriffe »vorn« und »hinten« in einem Ringpuffer nicht mehr
(siehe Abbildung 16.5).
1 Sebastian Vettel kann hinter Fernando Alonso herfahren und trotzdem in Führung liegen, weil
die Rennstrecke ein Ringpuffer ist.
502
16.2 Die Queue als abstrakter Datentyp
0 0
Lesezeiger Schreibzeiger
Schreibzeiger Lesezeiger
Mit diesen Vorüberlegungen können wir alle Funktionen der Queue implementie-
ren. Wir starten dazu mit dem Konstruktor
Beim Test, ob eine Queue leer ist, muss nur das Datenfeld anz befragt werden:
503
16 Abstrakte Datentypen
Der Schreibzeiger kann sich physikalisch hinter dem Lesezeiger befinden, über-
rundet ihn aber nicht, da immer q->anz < q->qsize ist.
Das Testprogramm kennen Sie bereits vom Testen des Stacks. Hier wird allerdings
eine Queue konstruiert. Dementsprechend ergibt sich auch eine andere Reihenfolge
der Daten beim Datenabruf mit get:
A struct test
{
int i1;
int i2;
};
void main()
{
504
16.2 Die Queue als abstrakter Datentyp
srand( 12345);
Das Vorgehen ist analog zum Test des Stacks, es wird zuerst die Struktur deklariert,
die in die Queue soll (A). Es folgt die Deklaration eines Zeigers auf den abstrakten
Datentyp (B) und die Konstruktion einer Queue für 100 Datenstrukturen der entspre-
chenden Größe (C). Nach dem Put von Zufallsdaten (D) werden die Daten über den
Test auf eine leere Queue (E) per get entnommen (F). Abschließend wird die Queue
beseitigt (G). Wir erhalten z. B. die folgende Ausgabe:
(584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404)
(584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404)
Durch die abstrakten Datentypen »Stack« und »Queue« haben wir eine saubere Tren-
nung zwischen WAS und WIE vollzogen. Das Anwendungsprogramm weiß, WAS
gespeichert wird, aber nicht WIE. Stack und Queue wissen, WIE gespeichert wird, aber
nicht WAS. Diese Trennung ermöglicht eine vollständige Entkopplung der eigentli-
chen Funktionalität des Anwendungsprogramms von seiner Datenhaltung. Dieser
Gedanke wird durch die objektorientierte Programmierung konsequent fortgesetzt.
505
Kapitel 17
Elemente der Graphentheorie
Man versteht etwas nicht wirklich, wenn man nicht versucht, es zu
implementieren.
– Donald E. Knuth
Die geografische Lage von Königsberg am Pregel ist gekennzeichnet durch vier Land-
gebiete (Festland oder Inseln), die durch sieben Brücken miteinander verbunden
sind:
17
Die Königsberger Bürger stellten sich die Frage, ob es einen Spazierweg gäbe, bei dem
sie jede Brücke genau einmal überqueren und am Ende zum Ausgangspunkt zurück-
kehren könnten. Als der berühmte Mathematiker Leonhard Euler1 mit diesem Pro-
blem konfrontiert wurde, abstrahierte er von der konkreten geografischen Situation
und stellte die Struktur des Problems durch einen »Graphen« dar, in dem Kreise
(sogenannte Knoten, A–D) die Landgebiete und Linien (sogenannte Kanten, a–g) die
Brücken repräsentierten (siehe Abbildung 17.2).
1 Leonhard Euler (1707–1783) gilt als einer der Väter der modernen Analysis. Nach ihm ist die euler-
sche Konstante e = 2,1718... benannt.
507
17 Elemente der Graphentheorie
C C
g
c d g
c d
e
e
A D A D
a b f
a b f
B B
Beim Versuch, das Problem zu lösen, findet man drei einfache Kriterien, die erfüllt
sein müssen, damit es einen eulerschen Weg gibt:
1. Der Graph muss zusammenhängend sein. Das heißt, man muss jeden Knoten von
jedem anderen Knoten aus über einen Weg erreichen können.
2. Zu dem Startknoten muss es neben der Kante, über die man ihn verlässt, eine wei-
tere Kante geben, über die man ihn am Ende des Weges wieder erreicht.
3. Wenn man einen Knoten auf dem gesuchten Rundweg über eine Kante erreicht
und der Weg noch nicht beendet ist, muss es eine weitere, noch nicht benutzte
Kante geben, über die man ihn wieder verlassen kann.
Die Bedingungen 2 und 3 besagen, dass die Kanten an jedem Knoten »paarig« auftre-
ten müssen, damit ein eulerscher Weg überhaupt existieren kann. Der Königsberger
Brückengraph erfüllt diese Bedingungen nicht. Er ist zwar zusammenhängend, aber
es gibt sogar an keinem Knoten eine gerade Anzahl von Kanten. Es kann den gesuch-
ten Rundweg nicht geben. Jeder Versuch wird zwangsläufig scheitern, da man irgend-
wann an einem Knoten landet, von dem keine unbenutzte Kante mehr wegführt:
Um das Problem der Existenz eines eulerschen Weges allgemein zu lösen, denken wir
uns jetzt einen zusammenhängenden Graphen, bei dem es an jedem Knoten eine
gerade Anzahl Kanten gibt.
508
Wir starten an einem beliebigen Knoten zu einer Wanderung. Die dabei benutzten
Kanten markieren wir, damit wir sie nicht noch einmal verwenden. Wenn wir zu
einem Knoten kommen, versuchen wir, den Knoten über eine beliebige, noch nicht
benutzte Kante wieder zu verlassen. Irgendwann wird die Wanderung an einem Kno-
ten enden, den wir nicht mehr verlassen können, da alle Kanten an dem Knoten mar-
kiert sind. Dieser Knoten kann nur unser Startknoten sein, da wir beim
Durchwandern eines Knotens immer zwei Kanten streichen und immer keine oder
eine gerade Anzahl von Kanten übrig bleibt. Das heißt, entweder kommen wir zu
dem Knoten nicht mehr hin, oder wenn wir hinkommen, können wir ihn auch wie-
der verlassen.
Wir haben also eine Rundwanderung gemacht, haben unter Umständen allerdings
noch nicht alle Kanten verwendet. Wir laufen daher unseren Weg noch einmal ab, bis
wir auf einen Knoten kommen, an dem es eine noch nicht verwendete Kante gibt.
Dort starten wir wieder eine Rundwanderung über noch ungenutzte Kanten, die uns
zwangsläufig wieder zu diesem Knoten zurückführt. Die so gelaufene »Schleife«
fügen wir zu unserem Weg hinzu.
Diesen Prozess setzen wir fort, bis es an unserem Weg keine unbenutzten Kanten
mehr gibt.
Wir haben jetzt aber einen eulerschen Weg gefunden, denn gäbe es noch irgendwo
eine ungenutzte Kante, dann gäbe es ja einen Weg von dieser Kante zum Startknoten
unseres Weges. Irgendwo würde dieser Weg auf unseren Rundwanderweg treffen. 17
Dort gäbe es dann aber eine noch ungenutzte Brücke an unserem Weg.
509
17 Elemente der Graphentheorie
1. Einen eulerschen Weg kann es in einem Graphen nur geben, wenn der Graph
zusammenhängend ist und alle Knoten eine gerade Anzahl von Kanten haben.
2. Wenn ein Graph zusammenhängend ist und alle Knoten eine gerade Anzahl von
Kanten haben, dann gibt es einen eulerschen Weg.
In einem Graphen gibt es genau dann einen eulerschen Weg, wenn der Graph
zusammenhängend ist und alle Knoten eine gerade Anzahl von Kanten haben.
Leonard Euler hat mit diesem Satz 1736 den Grundstein für die Graphentheorie
gelegt. Heute ist die Graphentheorie eine unerschöpfliche Quelle für Datenstruktu-
ren und Algorithmen mit großer Bedeutung für die Lösung wichtiger Probleme.
Unter einem Graphen verstehen wir eine Struktur, die aus endlich vielen Kno-
ten und Kanten besteht. Einer Kante ist jeweils ein Anfangsknoten und ein End-
knoten zugeordnet.
Typischerweise bezeichnen wir Knoten mit Großbuchstaben (A, B, C, ...) und Kanten
mit Kleinbuchstaben (a, b, c, ...). Wenn eine Kante k den Anfangsknoten A und den End-
knoten E hat, sagen wir, dass die Kante von A nach E führt und schreiben k = (A, E). Es
ist nicht ausgeschlossen, dass Anfangs- und Endknoten einer Kante gleich sind. Es ist
auch nicht ausgeschlossen, dass es zu einem Knoten keine Kante gibt.
Wir visualisieren einen Graphen, indem wir die Knoten als Kreise und die Kanten als
Pfeile von ihrem Anfangsknoten zu ihrem Endknoten zeichnen.
A E D
a = (B,A)
d b = (A,B)
f c = (B,D)
a b d = (C,A)
c
e = (B,C)
e f = (D,C)
B C
g = (C,C)
g
510
17.2 Die Adjazenzmatrix
Was Knoten und Kanten konkret sind oder sein könnten (z. B. Landgebiete und Brü-
cken), interessiert uns nicht. Diese Abstraktion ermöglicht die universelle Verwend-
barkeit von Graphen für unterschiedlichste Aufgaben.
Grundsätzlich ist nicht ausgeschlossen, dass es in einem Graphen verschiedene Kan-
ten mit gleichem Anfangs- und gleichem Endknoten (Parallelkanten) gibt.
a
A B
b
A D A D
17
B C B C
511
17 Elemente der Graphentheorie
mit
nach
A D A B C D
d A 0 1 0 0 Es gibt eine Kante von B nach D.
B 1 0 1 1
von
a b f
c C 1 0 1 0
e
D 0 0 1 0
B C
Es gibt keine Kante von D nach B.
Streng genommen, kann man gar nicht von der Adjazenzmatrix eines Graphen
reden, da die Matrix ja von der betrachteten Reihenfolge der Knoten abhängt. Da wir
aber nur Eigenschaften betrachten, die unabhängig von der gewählten Reihenfolge
sind, ist es egal, welche Knotenreihenfolge wir betrachten.
# define ANZAHL 12
Für jede Stadt haben wir eine Nummer und einen Klartextnamen:
512
17.3 Beispielgraph (Autobahnnetz)
# define BERLIN 0
# define BREMEN 1
# define DORTMUND 2
# define DRESDEN 3
# define DUESSELDORF 4
# define FRANKFURT 5
# define HAMBURG 6
# define HANNOVER 7
# define KOELN 8
# define LEIPZIG 9
# define MUENCHEN 10
# define STUTTGART 11
char *stadt[ANZAHL] =
{
"Berlin",
"Bremen",
"Dortmund",
"Dresden",
"Duesseldorf",
"Frankfurt",
"Hamburg",
"Hannover", 17
"Koeln",
"Leipzig",
"Muenchen",
"Stuttgart"
};
Damit können wir den Autobahngraphen dieser zwölf Städte durch eine Adjazenz-
matrix einführen (siehe Abbildung 17.9).
513
17 Elemente der Graphentheorie
Hamburg
Bremen
Berlin
Hannover
Dortmund
Leipzig
Düsseldorf
unsigned int adjazenz[ ANZAHL][ ANZAHL] =
{
Köln Dresden {0,0,0,1,0,0,1,1,0,1,0,0},
{0,0,1,0,0,0,1,1,0,0,0,0},
{0,1,0,0,1,1,0,1,1,0,0,0},
Frankfurt {1,0,0,0,0,0,0,0,0,1,0,0},
{0,0,1,0,0,0,0,0,1,0,0,0},
{0,0,1,0,0,0,0,1,1,1,1,1},
{1,1,0,0,0,0,0,1,0,0,0,0},
{1,1,1,0,0,1,1,0,0,1,0,0},
Stuttgart {0,0,1,0,1,1,0,0,0,0,0,0},
{1,0,0,1,0,1,0,1,0,0,1,0},
{0,0,0,0,0,1,0,0,0,1,0,1},
München {0,0,0,0,0,1,0,0,0,0,1,0},
};
Wir legen daher ein Array (war_da) an, in dem wir festhalten, ob wir einen bestimm-
ten Knoten schon einmal besucht haben. Vor der Traversierung markieren wir alle
Knoten mit dem Wert 0 als »noch nicht besucht«:
void main()
{
int i;
int war_da[ANZAHL];
514
17.4 Traversierung von Graphen
Wir starten mit der Markierung aller Knoten als »noch nicht besucht« (A), bevor wir
die Traversierung von Berlin aus beginnen (B).
Die Schnittstelle der Funktion enthält neben dem knoten, der besucht wird, die Infor-
17
mation über die bereits besuchten Knoten und den Rekursionslevel (A). Der Rekursi-
onslevel wird nur für das Einrücken der Ausgabe verwendet. Die Funktion gibt zuerst
den besuchten Knoten aus (B) und markiert diesen dann als besucht (C). In der fol-
genden Schleife über alle Knoten (D) werden die Knoten, die erreichbar sind und
noch nicht als besucht markiert worden sind (E), besucht (F).
In der machwas-Funktion geben wir nur den Knoten in der entsprechenden Einrü-
ckungstiefe level aus:
515
17 Elemente der Graphentheorie
Berlin
Dresden
Leipzig
Frankfurt
Dortmund
Bremen
Hamburg
Hannover
Duesseldorf
Koeln
Muenchen
Stuttgart
Der Algorithmus geht, in Berlin startend, immer zu der (alphabetisch) ersten Stadt,
die direkt erreichbar ist und in der er noch nicht war. Gibt es keine solche Stadt mehr,
erfolgt der Rücksprung auf die nächsthöhere Aufrufebene. Der Algorithmus geht also
in seiner eigenen Spur zurück, bis er eine noch nicht besuchte Stadt findet. Auf diese
Weise wird in dem Graphen ein Baum aller von Berlin aus erreichbaren Städte kon-
struiert.
Berlin
Hamburg
Bremen
Dresden
Berlin
Hannover
Leipzig
Dortmund
Leipzig Frankfurt
Düsseldorf
Dortmund München
Köln Dresden
Hamburg Köln
Stuttgart Hannover
München
516
17.5 Wege in Graphen
einem Graphen verstehen. Bei dieser Gelegenheit führen wir noch eine Reihe weite-
rer Begriffe ein:
왘 Eine endliche Folge A1, A2, ... An von Knoten eines Graphen heißt Weg, wenn je zwei
aufeinanderfolgende Knoten durch eine Kante miteinander verbunden sind.
왘 A1 wird als der Anfangs-, An als der Endknoten des Weges bezeichnet, und man
spricht von einem Weg von A1 nach An.
왘 Sind Anfangs- und Endknoten eines Weges gleich, sprechen wir von einem
geschlossenen Weg oder einer Schleife.
왘 Ein Weg heißt schleifenfrei, wenn alle vorkommenden Knoten voneinander ver-
schieden sind.
왘 Ein Weg heißt Kantenzug, wenn alle im Weg vorkommenden Kanten voneinander
verschieden sind.
왘 Ein geschlossener Kantenzug heißt Kreis.
왘 Ein Graph heißt kreisfrei, wenn er keine Kreise enthält.
왘 Die Anzahl der Kanten in einem Weg wird auch als die Länge des Weges bezeichnet.
A D
d
a b f 17
c
e
B C
g
Die Adjazenzmatrix eines Graphen liefert nur die Information, welche Knoten durch
eine Kante, also durch einen Weg der Länge 1, miteinander verbunden sind. Wir wol-
len jetzt die allgemeinere Frage, welche Knoten durch einen beliebigen Weg mitein-
ander verbunden werden können, beantworten. Dazu definieren wir die Wegematrix
eines Graphen:
517
17 Elemente der Graphentheorie
Die Wegematrix eines Graphen ist in der Regel nicht bekannt. Um sie aus der Adja-
zenzmatrix zu berechnen, verwenden wir das Verfahren von Warshall.
Wir betrachten einen beliebigen Graphen mit Knoten E1, E2, E3, ... En und der Adjazenz-
matrix A.
Für diesen Graphen bilden wir eine Folge von Mengen, die am Anfang leer ist und
nach und nach alle Knoten aufnimmt:
M0 = Ø
M1 = {E1}
M2 = {E1, E2}
_
Mn = {E1, E2, …, En}
Dazu berechnen wir eine Folge von Matrizen W0, W1, ... Wn, die wir aus der Adjazenz-
matrix ableiten:
M0 M1 M2 M3 Mn
↓ ↓ ↓ ↓ ↓
A = W0 → W1 → W 2 → W 3 … → W n
518
17.6 Der Algorithmus von Warshall
Die Matrix Wk hat in Zeile i und Spalte j genau dann den Wert 1, wenn es einen
Weg von Ei nach Ej gibt, dessen Zwischenpunkte sämtlich in Mk liegen.
Die Matrix W0 hat diese Eigenschaft, weil W0 die Adjazenzmatrix ist, die ja die Verbin-
dungen ohne Zwischenpunkte enthält.
Wenn es jetzt gelingt, die Eigenschaft durch ein Konstruktionsverfahren (das wir
noch nicht kennen) von Matrix zu Matrix (Wk → Wk+1) zu übertragen, haben wir am
Ende in Wn die gesuchte Wegematrix, da die Eigenschaft für k = n die Wegematrix
charakterisiert.
Wir gehen davon aus, dass wir die Matrix Wk erfolgreich konstruiert haben. Das
heißt: Es gilt die obige Eigenschaft. Jetzt wollen wir die Matrix Wk+1 konstruieren.
Dazu bilden wir die Menge Mk+1, indem wir zur Menge Mk den Knoten Ek+1 hinzu-
nehmen.
Wir betrachten jetzt zwei beliebige Knoten Ei und Ej. Dabei geht es um zwei unter-
schiedliche Fälle:
왘 Wenn die beiden Knoten bereits durch einen Weg in Mk verbunden sind, dann
steht in Wk in der entsprechenden Zeile und Spalte bereits eine 1, und diese 1 wird
dann in Wk+1 übernommen.
17
Ei
Mk Mk+1
Ek+1
Ej
왘 Wenn die betrachteten Knoten in Mk noch nicht verbunden sind, können sie in
Mk+1 nur über den Zwischenpunkt Ek+1 verbunden werden. Dazu muss es in Mk
aber bereits Wege von Ei nach Ek+1 und von Ek+1 nach Ej geben. Das können wir in
den entsprechenden Zeilen und Spalten der Matrix Wk überprüfen. Wenn beide
Prüfungen positiv ausfallen, können wir Ei und Ej in Wk+1 als verbindbar mar-
kieren.
519
17 Elemente der Graphentheorie
Ei
Mk Mk+1
Ek+1
Ej
Wenn wir dieses Verfahren für alle Knotenpaare Ei, Ej durchgeführt haben, hat Wk+1
die gewünschte Eigenschaft und zeigt die Verbindbarkeit von Knoten über Mk+1 an.
Bei der Implementierung des Verfahrens arbeiten wir »in place«. Das heißt, wir
erzeugen nicht ständig neue Matrizen, sondern modifizieren die Adjazenzmatrix
Schritt für Schritt, bis aus ihr die Wegematrix entstanden ist. Der Algorithmus ist ein-
facher zu implementieren, als die Herleitung des Verfahrens es vermuten lässt:
void warshall()
{
int von, nach, zpkt;
Der Algorithmus startet mit einer Schleife über Zwischenpunkte (A). Dies ist der Zwi-
schenpunkt, der jeweils neu zur Menge der Zwischenpunkte hinzugenommen wird.
520
17.6 Der Algorithmus von Warshall
Die Schleife erzeugt also gedanklich die Mengenfolge M1, M2, ..., Mn. Anschließend
werden in der Doppelschleife (B und C) alle Knotenpaare betrachtet, und es wird
untersucht, ob eine Verbindung über den Zwischenpunkt möglich ist.
Der Fall einer Verbindbarkeit ohne Verwendung des Zwischenpunkts muss nicht
geprüft werden, da diese Information bereits aus der vorherigen Iteration in der
Matrix vorhanden ist und durch die »In-place«-Strategie übernommen wird.
Hamburg
Bremen
Berlin
Hannover
Dortmund
Leipzig # define ANZAHL 12
Düsseldorf 17
unsigned int weg[ ANZAHL][ ANZAHL] =
{
Köln Dresden { 0,0,0,1,0,0,1,1,0,1,0,0},
{0,0,1,0,0,0,1,1,0,0,0,0},
{0,0,0,0,1,1,0,1,1,0,0,0},
Frankfurt { 0,0,0,0,0,0,0,0,0,1,0,0},
{0,0,0,0,0,0,0,0,1,0,0,0},
{0,0,0,0,0,0,0,1,1,1,1,1},
{0,0,0,0,0,0,0,1,0,0,0,0},
{0,0,0,0,0,0,0,0,0,1,0,0},
{0,0,0,0,0,0,0,0,0,0,0,0},
Stuttgart {0,0,0,0,0,0,0,0,0,0,1,0},
{0,0,0,0,0,0,0,0,0,0,0,1},
München {0,0,0,0,0,0,0,0,0,0,0,0},
};
Angewandt auf diese Ausgangsmatrix, erzeugt der Algorithmus die folgende Ergeb-
nismatrix:
521
17 Elemente der Graphentheorie
Ber Bre Dor Dre Due Fra Ham Han Koe Lei Mue Stu
Ber 0 0 0 1 0 0 1 1 0 1 1 1
Bre 0 0 1 0 1 1 1 1 1 1 1 1
Dor 0 0 0 0 1 1 0 1 1 1 1 1
Dre 0 0 0 0 0 0 0 0 0 1 1 1
Due 0 0 0 0 0 0 0 0 1 0 0 0
Fra 0 0 0 0 0 0 0 1 1 1 1 1
Ham 0 0 0 0 0 0 0 1 0 1 1 1
Han 0 0 0 0 0 0 0 0 0 1 1 1
Koe 0 0 0 0 0 0 0 0 0 0 0 0
Lei 0 0 0 0 0 0 0 0 0 0 1 1
Mue 0 0 0 0 0 0 0 0 0 0 0 1
Stu 0 0 0 0 0 0 0 0 0 0 0 0
Die Ergebnismatrix zeigt, von welcher Stadt aus welche Städte erreichbar sind. Das
Erreichbarkeitsproblem ist damit vollständig gelöst. Die Matrix zeigt allerdings
nicht, welchen Weg man im Falle der Erreichbarkeit einschlagen sollte. Mit dieser
Frage werden wir uns später beschäftigen.
17.7 Kantentabellen
Eine Adjazenzmatrix ist eine sinnvolle Repräsentation für einen Graphen, wenn man
eine knotenorientierte Verarbeitung des Graphen plant. Die Algorithmen, die Sie bis-
her kennengelernt haben, waren knotenorientiert. Manchmal ist es aber sinnvoll, in
einem Algorithmus kantenorientiert vorzugehen. Das heißt, man möchte der Reihe
nach alle Kanten eines Graphen betrachten, um gewisse Berechnungen durchführen
zu können.
In dieser Situation bietet es sich an, eine Kantentabelle anstelle einer Adjazenzmatrix
zu verwenden. Eine Kantentabelle ist ein Array (oder eine Liste), in der alle Kanten
des Graphen mit Anfangs- und Endpunkt aufgeführt sind.
A D
Kante
a b c d e f g
d
f
von B A B C B D C
a b nach A B
c D A C C C
e
B C
Die erste Kante geht von B nach A.
g
522
17.8 Zusammenhang und Zusammenhangskomponenten
Ein Graph mit n Knoten kann n2 Kanten haben, wenn alle Knoten paarweise mitein-
ander verbunden sind. In der Regel werden es aber deutlich weniger Kanten sein. Ver-
wenden Sie bei einem kantenorientierten Verfahren eine Adjazenzmatrix, müssen
Sie alle n2 Knotenpaare betrachten und werden quadratische Laufzeit haben. Bei Ver-
wendung einer Kantentabelle können Sie die Laufzeit reduzieren, wenn es relativ
wenig Kanten im Vergleich zum Quadrat der Knotenzahl gibt.
A D A D A D
B C B C B C
In einem ungerichteten Graphen ergeben sich immer »Cluster« von paarweise unter-
einander zusammenhängenden Knoten. Diese Cluster heißen Zusammenhangs-
komponenten. Im folgenden Beispiel sehen Sie vier Zusammenhangskomponenten:
523
17 Elemente der Graphentheorie
524
17.8 Zusammenhang und Zusammenhangskomponenten
Betrachten Sie z. B. die Menge aller Autos und auf dieser Menge die Relation »vom
gleichen Hersteller sein«. Diese Relation ist eine Äquivalenzrelation (die Bedingun-
gen 1–3 sind erfüllt) und zerlegt die Menge der Autos in elementfremde Klassen von
Autos, die jeweils vom gleichen Hersteller kommen. Diese Klassen heißen dann Audi,
BMW, Mercedes oder VW. In diesem Sinne bilden Äquivalenzrelationen auch das the-
oretische Fundament der objektorientierten Programmierung (siehe ab Kapitel 20).
struct kante
{
Hamburg int von;
Bremen
# define ANZ_KNOTEN 12 int nach;
# define ANZ_KANTEN 17 };
Berlin
Hannover
struct kante kanten tabelle[ANZ_KANTEN] =
{
Dortmund {0,3},
Leipzig {1,6},
Düsseldorf
{0,7},
# define BERLIN 0
{1,7},
# define BREMEN 1
{6,7},
Köln Dresden # define DORTMUND 2
{2,8},
# define DRESDEN 3
{4,8},
# define DUESSELDORF 4
{5,8},
# define FRANKFURT 5
Frankfurt {0,9},
# define HAMBURG 6
{3,9},
# define HANNOVER 7
{2,4},
# define KOELN 8
{2,5},
# define LEIPZIG 9
{0,6},
# define MUENCHEN 10
Stuttgart {7,9},
# define STUTTGART 11
{5,10},
{5,11},
München {10,11}
};
525
17 Elemente der Graphentheorie
Wir haben jetzt die Cluster »Südwest« und »Nordost«. Diese beiden Cluster wollen wir
aus der Kantentabelle berechnen. Dabei lassen wir uns von der folgenden Idee leiten:
Es bleibt die Frage: Wie kann man möglichst einfach eine Datenstruktur für eine
Menge von Zahlen (Knotenindizes) implementieren, die die folgenden Operationen
unterstützt:
Die benötigten Mengen werden als logische Baumstruktur in einem Array ge-
speichert.
int vorgaenger[ANZ_KNOTEN];
Bisher haben wir Bäume immer so implementiert, dass wir Knotenstrukturen hat-
ten, in denen jeweils die Nachfolgerknoten referenziert wurden. Wenn wir dies als
eine Vorwärtsverkettung auffassen, gehen wir jetzt genau umgekehrt vor. Wir spei-
chern in dem Array zu jedem Knoten den Index seines Vaterknotens. Durch diese
Rückwärtsindizierung können wir auf einfache Weise zu einem Knoten seine Wurzel
finden. Abbildung 17.20 veranschaulicht dieses Konzept:
2 2
4 0 5 0 4 5
3 6 3 6
v orgaenger 2 3 -1 5 2 2 5
index 0 1 2 3 4 5 6
526
17.8 Zusammenhang und Zusammenhangskomponenten
Im Array können sogar mehrere elementfremde Bäume liegen. Die Wurzel eines
Baums erkennen Sie am Index –1. Im Grunde genommen interessiert uns der genaue
Aufbau des Baums aber nicht. Wichtig ist nur, dass jeder Baum im Array eine Menge
beschreibt. Alles, was im selben Baum ist, ist in derselben Menge.
void init()
{
int i;
Die folgende Funktion join dient dazu, zwei Mengen zu vereinigen. Sie erhält zwei
Knotenindizes und geht im Baum zu den zu diesen Knoten gehörenden Wurzeln.
Sind die Wurzeln gleich, sind die beiden Knoten bereits im selben Baum. Sind die
Wurzeln verschieden, werden die beiden Mengen vereinigt, indem Sie nur die Wurzel 17
der einen Menge (egal, welche von beiden) unter die Wurzel der anderen bringen:
Dazu arbeitet sich die Funktion zur Wurzel von a (A) und zur Wurzel von b (B). Haben
die beiden Knoten unterschiedliche Wurzeln, dann wird die Wurzel b unter die Wur-
zel a gebracht (C).
Nach dem Aufruf dieser Funktion sind die Menge, die den Knoten a enthält, und die
Menge, die den Knoten b enthält, miteinander verschmolzen.
527
17 Elemente der Graphentheorie
Der Rest des Algorithmus ist genauso einfach zu implementieren. Um die Zusam-
menhangskomponenten zu berechnen, wird nach der Initialisierung über die Kan-
ten der Kantentabelle iteriert. Für jede Kante wird die Menge, in der der
Anfangspunkt liegt, mit der Menge, in der der Endpunkt liegt, verschmolzen:
void bilde_komponenten()
{
int k;
init();
for( k = 0; k < ANZ_KANTEN; k++)
join( kantentabelle[k].von, kantentabelle[k].nach);
}
void ausgabe()
{
int i, k, z;
for( i = 0, z = 0; i < ANZ_KNOTEN; i++)
{
if( vorgaenger[i] == –1)
{
printf( "%d-te Zusammenhangskomponente:\n", ++z);
for( k = 0; k < ANZ_KNOTEN; k++)
{
if( wurzel( k) == i)
printf( " %2d %s\n", k, stadt[k]);
}
printf( "\n");
}
}
}
In der Ausgabefunktion werden alle Knoten gesucht, die Wurzeln eines Baums sind.
Jeder dieser Knoten repräsentiert eine Zusammenhangskomponente. In der inneren
Schleife werden dann alle Knoten gesucht, die den in der äußeren Schleife gefunde-
nen Knoten als Wurzel haben, und ausgegeben.
Die Ausgabe verwendet noch eine Hilfsfunktion, um zu einem Knoten den Index sei-
ner Wurzel zu ermitteln:
528
17.8 Zusammenhang und Zusammenhangskomponenten
void main()
{
bilde_komponenten();
ausgabe();
}
Listing 17.10 Das Programm zur Erzeugung und Ausgabe der Komponenten
Das Hauptprogramm berechnet die Komponenten und gibt sie auf dem Bildschirm aus.
Abbildung 17.21 zeigt zusammenfassend die Ausgangssituation, die durch den Algo-
rithmus erzeugten Bäume und die abschließende Bildschirmausgabe:
17
Hamburg
Bremen
1 5
Berlin
Hannover 0 6 9 4 10 11
3 7 2
Dortmund
Leipzig
Düsseldorf
8
Köln Dresden
1-te Zusammenhangskomponente:
0 Berlin
1 Bremen
Frankfurt 3 Dresden
6 Hamburg
7 Hannover
9 Leipzig
2-te Zusammenhangskomponente:
2 Dortmund
Stuttgart 4 Duesseldorf
5 Frankfurt
8 Koeln
München 10 Muenchen
11 Stuttgart
529
17 Elemente der Graphentheorie
Wenn in einem Graphen jeder Kante ein Zahlenwert zugeordnet ist, sprechen
wir von einem gewichteten oder bewerteten Graphen. Den Zahlenwert einer
Kante bezeichnen wir als das Kantengewicht.
A D
b/-1
f/0
a/1 c/4 d/2
B e/-5 C
g/-3
Wenn einzelne Kanten eines Graphen bewertet sind, können Sie auch ganze Wege
bewerten:
In einem gewichteten Graphen wird die Summe der Kantengewichte aller Kan-
ten eines Weges als das Gewicht oder die Bewertung des Weges bezeichnet.
A D
530
17.9 Gewichtete Graphen
Was ist der kürzeste/schnellste/kostengünstigste Weg, also der Weg mit dem
niedrigsten Gewicht, von einem Knoten zu einem anderen?
Auf diese Frage gibt es nur dann eine Antwort, wenn es keine negativ bewerteten
Schleifen in einem Graphen gibt. Wir wollen im Folgenden nur Graphen mit nicht
negativen Kantengewichten betrachten, dann gibt es keine negativ bewerteten
Schleifen, und wir sind sicher, dass es immer Wege mit minimalem Gewicht gibt,
sofern es überhaupt Wege gibt. Ausgangspunkt der folgenden Betrachtungen ist eine
»Adjazenzmatrix«, in die wir, anstelle von 0 oder 1 für die Existenz einer Kante, das
Kantengewicht eintragen. In unserem Beispiel (Autobahnnetz) sprechen wir dann
auch von einer Distanzenmatrix.
# define ANZAHL 12
# define xxx 10000
Frankfurt
217
400 425
Stuttgart 220
München
In der Distanzenmatrix stehen die Entfernungen zwischen Städten, die durch eine
Kante verbunden sind. Bei Städten, die nicht direkt durch eine Kante verbunden
sind, steht dort ein »großer« Wert (xxx, 10000), der erkennbar keine gültige Entfer-
nungsangabe darstellt.
531
17 Elemente der Graphentheorie
Die Folge der unter Spannung stehenden Drähte bildet dann den gesuchten Weg. In
einem digitalen Modell, etwa unter Verwendung der Distanzenmatrix, wird dieser
Weg nicht so einfach zu finden sein.
Wir betrachten einen Graphen mit nicht negativen Kantengewichten. Die Kantenge-
wichte werden dabei als Entfernungen interpretiert. Dann gibt es, was die Wegesuche
betrifft, drei verschiedene Aufgabenstellungen mit offensichtlich wachsendem
Lösungsaufwand:
Wenn Sie die erste Aufgabe für zwei Knoten A und B lösen, fallen alle kürzesten Ver-
bindungen zwischen Knoten längs des Wegs von A nach B als Nebenergebnis mit ab,
da ja Teilstrecken optimaler Wege ebenfalls optimal sind. Mehr noch, es fallen alle
optimalen Strecken von A nach B über einen beliebigen Zwischenpunkt C mit ab, da
532
17.11 Der Algorithmus von Floyd
ja geprüft werden muss, ob ein Weg über C die kürzeste Verbindung von A nach B
ermöglicht. Das bedeutet, dass Sie die Aufgabe 1 nicht lösen können, ohne zugleich
die Aufgabe 2 zu lösen. Sie haben es also de facto nur mit zwei Aufgaben zu tun:
Aufgabe I: Finde die kürzesten Wege zwischen allen Knoten des Graphen.
Aufgabe II: Finde die kürzesten Wege von einem Knoten A zu allen anderen Knoten
des Graphen.
Wir werden im Folgenden drei Algorithmen betrachten:
1. Algorithmus von Floyd (Aufgabe I)
2. Algorithmus von Dijkstra (Aufgabe II)
3. Algorithmus von Ford (Aufgabe II)
4
A B – 0 2 – – B 14 0 2 5 9
D
C – – 0 3 – C 12 13 0 3 7
1 D – – – 0 4 D 9 10 12 0 4
3
E 5 – – – 0 E 5 6 8 11 0
B 2 C A B C D E A B C D E
Zwischenpunktmatrix
A B C D E
A – – B C D
B E – – C D
C E E – – D
D E E E – –
E – A B C –
533
17 Elemente der Graphentheorie
Da alle Teilstrecken optimaler Wege ihrerseits optimal sind, reicht es aus, für je zwei
Knoten X und Y einen Zwischenpunkt Z in einer Zwischenpunktmatrix zu speichern.
Die weiteren Zwischenpunkte findet man dann, indem man in der Matrix Zwischen-
punkte zu X und Z bzw. Z und Y sucht und dieses Verfahren (rekursiv) fortsetzt, bis
keine Zwischenpunkte mehr gefunden werden. Im folgenden Beispiel wird der kür-
zeste Weg von D nach C aus der Zwischenpunktmatrix in Abbildung 17.26 gelesen:
12
D C
4 8
E
6 2
B
5 1
A
Durch eine kleine Datenstruktur könnte man die beiden Matrizen noch miteinander
verschmelzen. Das wollen wir hier aber nicht machen. Wir arbeiten mit zwei getrenn-
ten Matrizen, die wie folgt angelegt werden:
int distanz[ANZAHL][ANZAHL];
int zwischenpunkt[ANZAHL][ANZAHL];
int zwischenpunkt[ANZAHL][ANZAHL];
void print_zwischenpunkte()
{
int z, s;
printf( "Zwischenpunkte:\n");
for( z = 0; z < ANZAHL; z++)
{
for( s = 0; s < ANZAHL; s++)
printf( "%3d ", zwischenpunkt[z][s]);
printf( "\n");
}
}
534
17.11 Der Algorithmus von Floyd
int distanz[ANZAHL][ANZAHL];
void print_distanzen()
{
int z, s;
printf( "Distanzen:\n");
for( z = 0; z < ANZAHL; z++)
{
for( s = 0; s < ANZAHL; s++)
printf( "%3d ", distanz[z][s]);
printf( "\n");
}
}
Um aus den Matrizen einen optimalen Weg auszugeben, verwenden wir die Funktio-
nen print_path und print_nodes:
Die Funktion print_path erhält Start- und Zielpunkt und gibt diese samt Entfernung
aus. Alle Zwischenpunkte auf dem Weg vom Start- zum Zielpunkt werden dabei mit
der rekursiven Funktion print_nodes aus der Zwischenpunktmatrix gelesen und aus-
gegeben.
zpkt = zwischenpunkt[von][nach];
if( zpkt == –1)
return;
535
17 Elemente der Graphentheorie
Bevor wir uns auf die Suche nach kürzesten Wegen machen, müssen wir noch die
Zwischenpunktmatrix initialisieren. Der Wert –1 in einem Feld der Zwischenpunkt-
matrix zeigt an, dass für den zugehörigen Weg noch kein Zwischenpunkt berechnet
wurde. Die Zwischenpunktmatrix wird dementsprechend initialisiert:
void init()
{
int von, nach;
Von der Idee her ist der Algorithmus von Floyd identisch mit dem Algorithmus von
Warshall (siehe dort). Auch hier wird Schritt für Schritt eine Menge bereits bearbeite-
ter Knoten aufgebaut. Hier wird jedoch nicht nur nach der Existenz eines Weges über
den jeweils neu hinzugekommenen Zwischenpunkt gefragt, sondern es wird auch
geprüft, ob der Weg über den Zwischenpunkt kürzer ist als der bisher kürzeste Weg.
Ist das der Fall, werden die neue Distanz in der Distanzenmatrix und der Zwischen-
punkt in der Zwischenpunktmatrix gespeichert.
void floyd()
{
int von, nach, zpkt;
unsigned int d;
536
17.11 Der Algorithmus von Floyd
In der Funktion wird geprüft, ob man über den Zwischenpunkt zpkt den Weg vom
Knoten von zum Knoten nach verkürzen kann (A, B und C).
Ist eine Verkürzung möglich, hat man eine neue Distanz (D) und einen neuen Zwi-
schenpunkt (E und F).
Angewandt auf unseren Standardgraphen mit dem deutschen Autobahnnetz,
erzeugt der Algorithmus von Floyd die Distanzen- und die Zwischenpunktmatrix.
17
Hamburg
Bremen
119
284
154 Berlin
125
Hannover
282
233 # define BERLIN 0
# define BREMEN 1
Dortmund 208 256 179 205
Leipzig # define DORTMUND 2
Düsseldorf 63
352 # define DRESDEN 3
47 83 108
264 # define DUESSELDORF 4
Köln 395 Dresden # define FRANKFURT 5
189
# define HAMBURG 6
# define HANNOVER 7
Frankfurt # define KOELN 8
217 # define LEIPZIG 9
400 425 # define MUENCHEN 10
# define STUTTGART 11
Stuttgart 220
München
537
17 Elemente der Graphentheorie
Aus diesen Matrizen können konkrete Fahrtrouten mit Entfernungsangaben (im Bei-
spiel Berlin-Stuttgart und München-Hamburg) ausgegeben werden.
void main()
{
init();
floyd();
print_distanzen();
print_zwischenpunkte();
Distanzen:
0 403 490 205 553 574 284 282 573 179 604 791
403 0 233 489 296 477 119 125 316 381 806 694
490 233 0 572 63 264 352 208 83 464 664 481
205 489 572 0 635 503 489 364 655 108 533 720
553 296 63 635 0 236 415 271 47 527 636 453
574 477 264 503 236 0 506 352 189 395 400 217
284 119 352 489 415 506 0 154 435 410 835 723
282 125 208 364 271 352 154 0 291 256 681 569
573 316 83 655 47 189 435 291 0 547 589 406
179 381 464 108 527 395 410 256 547 0 425 612
604 806 664 533 636 400 835 681 589 425 0 220
791 694 481 720 453 217 723 569 406 612 220 0
Zwischenpunkte:
–1 6 7 –1 7 9 –1 –1 7 –1 9 9
6 –1 –1 9 2 7 –1 –1 2 7 9 7
7 –1 –1 9 –1 –1 1 –1 –1 7 5 5
–1 9 9 –1 9 9 0 9 9 –1 9 9
7 2 –1 9 –1 8 2 2 –1 7 8 8
9 7 –1 9 8 –1 7 –1 –1 –1 –1 –1
–1 –1 1 0 2 7 –1 –1 2 7 9 7
538
17.12 Der Algorithmus von Dijkstra
–1 –1 –1 9 2 –1 –1 –1 2 –1 9 5
7 2 –1 9 –1 –1 2 2 –1 7 5 5
–1 7 7 –1 7 –1 7 –1 7 –1 –1 5
9 9 5 9 8 –1 9 9 5 –1 –1 –1
9 7 5 9 8 –1 7 5 5 5 –1 –1
Und für die Strecken Berlin-Stuttgart und München-Hamburg erhalten wir die fol-
genden Pfade:
Die Distanzenmatrix ist symmetrisch, weil hier ein symmetrischer Graph vorliegt.
Das Verfahren setzt aber nicht voraus, dass der Graph symmetrisch ist. Bei einem
asymmetrischen Graphen (Einbahnstraßen) könnte sich ein asymmetrischer Distan-
zengraph ergeben. Dies bedeutet, dass Hin- und Rückfahrt gegebenenfalls unter-
schiedliche Streckenführungen und unterschiedliche Distanzen hätten.
Die Aufgabe, alle kürzesten Verbindungen in einem Graphen zu finden, ist damit
befriedigend gelöst. Gelöst ist damit natürlich auch die Aufgabe, die kürzesten Wege
von einem festen Startpunkt zu allen möglichen Zielpunkten zu finden. Wir hoffen
aber, dass wir, wenn wir uns auf diese Teilaufgabe beschränken, effizientere Algorith-
men finden können. Sie werden für diese Aufgabe zwei verschiedene Verfahren ken-
nenlernen: eines (Dijkstra), das knotenorientiert arbeitet, und ein anderes (Ford), das 17
kantenorientiert vorgeht.
A
9 5
B 3 C
6 2 4
D 3 E
539
17 Elemente der Graphentheorie
1. Starte am Knoten A, und bewerte die Knoten, die von dort aus direkt erreichbar
sind, entsprechend der Entfernung.
2. Wähle den am günstigsten bewerteten Knoten (das ist C), und markiere den Weg,
der zu dieser Bewertung geführt hat. Danach bewerte alle von A oder C aus direkt
erreichbaren Knoten. Dabei ergeben sich gegebenenfalls neue Bewertungen oder
Verbesserungen bisheriger Bewertungen.
3. Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (B), und
markiere den Weg, der zu dieser Bewertung geführt hat. Danach bewerte alle von
A, C oder B direkt erreichbaren Knoten.
4. Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (E), und mar-
kiere den Weg, der zu dieser Bewertung geführt hat. Danach bewerte alle von A, C,
B oder E direkt erreichbaren Knoten.
Wähle den am günstigsten bewerteten, noch nicht erledigten Knoten (D), und
markiere den Weg, der zu diesem Knoten geführt hat. Beende das Verfahren, da
keine Knoten mehr zu bewerten sind.
6 2 4 6 2 4 6 2 4 6 2 4 6
9 14 9 12 9 12 9
D 3 E D 3 E D 3 E D 3 E D 3 E D E
Das Verfahren konstruiert einen Baum (den Baum der günstigsten von A ausgehen-
den Wege) in den Graphen hinein. Beachten Sie übrigens, dass die insgesamt kosten-
günstigste Kante (B–E) nicht ausgewählt wurde. Ein Greedy-Verfahren, das sich
zuerst auf günstigste Kanten stürzen würde, würde also nicht zum Ziel führen.
Es war kein Zufall, dass sich in unserem Beispiel ein Baum als Lösungsstruktur erge-
ben hat. Das liegt daran, dass Teilstrecken kürzester Wege ebenfalls kürzeste Wege
sind und daher einmal eingetretene Pfade nicht mehr verlassen. Zur Speicherung
aller kürzesten Wege von einem festen Ausgangspunkt bietet sich daher eine Baum-
struktur an. Für diese Baumstruktur verwenden wir wieder das Prinzip der Rückver-
weise zum Vaterknoten. Zusätzlich zum Rückverweis benötigen wir für jeden Knoten
noch die Distanz zum Startknoten und aus verfahrenstechnischen Gründen noch
eine Information, ob ein Knoten bereits bearbeitet wurde. Daher verwenden wir im
Verfahren die folgende Datenstruktur:
540
17.12 Der Algorithmus von Dijkstra
# define ANZAHL 12
struct knoteninfo
{
unsigned int distanz;
int vorgaenger;
char erledigt;
};
Im Array info stehen also für jeden Knoten die Information über den Vorgänger, die
Distanz zum Ausgangspunkt und der Bearbeitungsvermerk.
In unserem Standardbeispiel wird sich zum Startpunkt Berlin der folgende Baum
ergeben:
# define BERLIN 0
# define BREMEN 1
# define DORTMUND 2
Hamburg # define DRESDEN 3
Bremen 284
# define DUESSELDORF 4
# define FRANKFURT 5
403 Berlin # define HAMBURG 6
Hannover 0 # define HANNOVER 7
# define KOELN 8
282 # define LEIPZIG 9 17
Dortmund # define MUENCHEN 10
Leipzig # define STUTTGART 11
Düsseldorf
553 490
179 205
Köln 573 Dresden Von Berlin nach Dortmund
sind es 490 km.
574
Frankfurt 0 1 2 3 4 5 6 7 8 9 10 11
Distanz 0 403 490 205 553 574 284 282 573 179 604 791
Vorgänger –1 6 7 0 2 9 0 0 2 0 9 5
791 Erledigt 1 1 1 1 1 1 1 1 1 1 1 1
Stuttgart
604 Der Knoten Dortmund Der Vorgänger des Knotens 2
ist bearbeitet. (Dortmund) ist der Knoten 7 (Hannover).
München
Sie sehen, dass wir aus dieser Struktur alle benötigten Informationen herauslesen
können. Wir müssen sie jetzt nur noch erzeugen.
Zur Initialisierung des info-Arrays werden die Entfernungen aus der zum Startkno-
ten gehörenden Zeile der Distanzenmatrix übernommen.
541
17 Elemente der Graphentheorie
Wenn es keine direkte Verbindung durch eine Kante gibt, ist dieser Wert zunächst
noch »sehr« groß (xxx = 10000). Der Vorgänger aller Knoten ist zunächst der Startkno-
ten, nur der Startknoten selbst hat als Wurzel natürlich keinen Vorgänger:
Nur der Ausgangspunkt wird als »erledigt« markiert. Alle anderen Knoten müssen
noch bearbeitet werden.
542
17.12 Der Algorithmus von Dijkstra
In der Hilfsfunktion knoten_auswahl wird unter allen noch nicht erledigten Knoten
derjenige ermittelt, der momentan den geringsten Abstand zum Startknoten hat.
int knoten_auswahl()
{
int i, minpos;
unsigned int min;
min = xxx;
minpos = –1;
for( i = 0; i< ANZAHL; i++)
{
if( info[i].distanz < min && !info[i].erledigt)
{
min = info[i].distanz;
minpos = i;
}
}
return minpos;
}
Die Funktion gibt den Index des gesuchten Knotens (oder –1, falls alle Knoten bereits 17
erledigt sind) zurück.
Sie können die Effizienz der Knotensuche steigern, wenn Sie eine Datenstruktur zur
Zwischenspeicherung von Knoten verwenden, die eine effiziente Entnahme des
jeweils am nächsten liegenden Knotens ermöglicht, wobei die Struktur nach Einbau
eines neuen Knotens in die Menge der erledigten Knoten reorganisiert werden
müsste, da sich die Abstände vermindern. Eine geeignete Struktur wäre ein soge-
nannter Fibonacci-Heap, den wir hier aber nicht behandeln.
Wir kommen jetzt zum algorithmischen Kern des Dijkstra-Verfahrens. Diesen Kern
haben wir Ihnen ja bereits oben vorgestellt, sodass wir hier direkt in den Code einstei-
gen können:
init( ausgangspkt);
543
17 Elemente der Graphentheorie
Der Ausgangsknoten ist bereits erledigt, und der letzte, am Ende übrig bleibende
Knoten muss nicht mehr eigens behandelt werden. Also wird die Schleife ANZAHL-2
mal durchlaufen (A). In der Schleife wird der nächste (= nächstliegende) Knoten
gewählt (B). Der Knoten ist dann erledigt (C). Jetzt wird über alle noch nicht erledigten
Knoten k iteriert (D und E).
Wenn der Weg zum Knoten k über den Knoten knoten verkürzt werden kann, dann
ergeben sich eine kürzere Distanz (F und G) und ein neuer Vorgänger. Ansonsten
bleibt alles beim Alten.
Dieser Algorithmus erzeugt den Kürzeste-Wege-Baum, den wir dann nur noch ausge-
ben müssen. Da der Baum allerdings rückwärtsverkettet aufgebaut ist, drehen wir die
Ausgabereihenfolge der Knoten durch Rekursion um:
void print_all()
{
int i;
544
17.12 Der Algorithmus von Dijkstra
Die Funktion print_all ruft die print_path-Funktion, die sich rekursiv selbst ruft:
void main()
{
dijkstra( BERLIN);
print_all();
}
Hamburg
Bremen 284
17
403 Berlin
Hannover 0
282
Dortmund
Leipzig
Düsseldorf
553 490
179 205
Köln 573 Dresden
574
Frankfurt
791
Stuttgart
604
München
545
17 Elemente der Graphentheorie
Berlin-0 km
Berlin-Hamburg-Bremen-403 km
Berlin-Hannover-Dortmund-490 km
Berlin-Dresden-205 km
Berlin-Hannover-Dortmund-Duesseldorf-553 km
Berlin-Leipzig-Frankfurt-574 km
Berlin-Hamburg-284 km
Berlin-Hannover-282 km
Berlin-Hannover-Dortmund-Koeln-573 km
Berlin-Leipzig-179 km
Berlin-Leipzig-Muenchen-604 km
Berlin-Leipzig-Frankfurt-Stuttgart-791 km
Wir wollen aus der Distanzenmatrix eines Graphen eine Kantentabelle, die für jede
Kante deren Anfangs- und Endpunkt sowie das Kantengewicht enthält, erzeugen:
Kantentabelle
A
9 5 Kante 1: A→B 9 Kante 8: C →B 3
B 3 C Kante 2: A→ C 5 Kante 9: C →E 4
Kante 3: B →A 9 Kante 10: D→B 6
6 2 4 Kante 4: B→C 3 Kante 11: D→E 3
Kante 5: B→D 6 Kante 12: E →B 2
D 3 E Kante 6: B→E 2 Kante 13: E →C 4
Kante 7: C →A 5 Kante 14: E →D 3
Abbildung 17.33 Beispielgraph und die zugehörige Kantentabelle
Ein Graph mit n Knoten hat maximal, wenn jeder Knoten mit jedem verbunden ist,
n2 Kanten. Wir erzeugen daher ein Array, das auf diese Maximallast ausgelegt ist und
für jede Kante den Anfangs- und Endknoten sowie das Kantengewicht bereitstellt:
546
17.13 Erzeugung von Kantentabellen
# define ANZAHL 5
# define xxx 10000
struct kante
{
int von;
int nach;
int distanz;
};
int anzahl_kanten;
struct kante kantentabelle[ANZAHL*ANZAHL];
Die Kantentabelle (kantentabelle) befüllen wir jetzt mit Daten, indem wir die Distan-
zenmatrix auswerten. Dabei ergibt sich auch die Anzahl der effektiv vorhandenen
Kanten (anzahl_kanten):
void setup_kantentabelle()
{
int i, j, k, d;
17
for( i = k = 0; i < ANZAHL; i++)
{
for( j = 0; j < ANZAHL; j++)
{
d = distanz[i][j];
if((d > 0) && (d < xxx))
{
kantentabelle[k].distanz = d;
kantentabelle[k].von = i;
kantentabelle[k].nach = j;
k++;
}
}
anzahl_kanten = k;
}
}
547
17 Elemente der Graphentheorie
Auf diese Weise lässt sich einfach eine Kantentabelle aus der Distanzenmatrix erzeu-
gen, und wir gehen im Folgenden davon aus, dass für unseren Graphen eine Kanten-
tabelle vorliegt.
Kantentabelle
A
9 5 Kante 1: A→B 9 Kante 8: C →B 3
B 3 C Kante 2: A→ C 5 Kante 9: C →E 4
Kante 3: B →A 9 Kante 10: D→B 6
6 2 4 Kante 4: B→C 3 Kante 11: D→E 3
Kante 5: B→D 6 Kante 12: E →B 2
D 3 E Kante 6: B→E 2 Kante 13: E →C 4
Kante 7: C →A 5 Kante 14: E →D 3
Abbildung 17.34 Ausgangsgraph für den Algorithmus von Ford
Wir wollen alle kürzesten, vom Knoten D ausgehenden Wege ermitteln. Das Verfah-
ren besteht aus mehreren Durchläufen. In jedem Durchlauf werden der Reihe nach
alle Kanten betrachtet und, sofern sie eine Verkürzung zu einem Zielknoten ermögli-
chen, in den Ergebnisbaum eingebaut.
6 6 5 5 7
B C B C B C B C B C
3 3 3
D E D E D E D E D E
Kante 1–Kante 9 Kante 10 Kante 11 Kante 12 wird anstelle Kante 13 wird eingebaut,
bringen nichts. wird eingebaut. wird eingebaut. von Kante 11 eingebaut. Kante 14 bringt nichts.
Interessant ist hier die Betrachtung der Kante 12 von E nach B. Bei Betrachtung dieser
Kante zeigt sich, dass man den Knoten B über diese Kante günstiger (5 statt bisher 6)
erreichen kann als über Kante 11. Darum wird Kante 11 wieder ausgebaut und statt-
dessen Kante 12 genommen.
548
17.14 Der Algorithmus von Ford
Nach dem ersten Durchlauf ist bereits ein Teilbaum entstanden, der aber weder voll-
ständig noch endgültig sein muss. Es können sowohl weitere Kanten hinzukommen
als auch Kanten wieder entfernt werden, wenn neue oder bessere Wege gefunden
werden. Darum startet man einen zweiten Durchlauf mit genau der gleichen Stra-
tegie:
5 7 5 7 5 7 5 7
B C B C B C B C
3 3 3 3
D E D E D E D E
Kante 1 und Kante 2 Kante 3 Kante 4 – Kante 6 Kante 7 wird anstelle von
bringen nichts. wird eingebaut. bringen nichts. Kante 3 eingebaut.
Kanten 8 – 14 bringen
nichts.
Auch in diesem Durchlauf haben sich Verbesserungen ergeben. Das Verfahren wird
so lange durchgeführt, wie innerhalb eines Durchlaufs noch Verbesserungen mög-
lich sind. Es gibt daher noch ein weiteren Durchlauf, in dem es aber nicht mehr zu
Verbesserungen kommt. Das Verfahren ist damit abgeschlossen, und der Kürzeste-
Wege-Baum ist berechnet.
17
Die im Algorithmus von Ford zur Speicherung des Ergebnisbaums verwendete
Datenstruktur ist bis auf eine Kleinigkeit (das Feld erledigt in der Datenstruktur kno-
teninfo wird nicht benötigt) identisch mit der beim Algorithmus von Dijkstra ver-
wendeten Struktur:
struct knoteninfo
{
unsigned int distanz;
int vorgaenger;
};
Dementsprechend gleichen sich auch die Funktionen zur Initialisierung und zur
Ausgabe dieser Struktur und müssen hier nicht noch einmal gesondert aufgeführt
werden. Wir können uns also direkt um den Kernalgorithmus kümmern, dessen Ver-
fahrensidee uns ja bereits bekannt ist:
549
17 Elemente der Graphentheorie
A init( ausgangspkt);
In der Funktion wird zuerst die Ergebnisstruktur initialisiert (A). Solange das Stop-Kenn-
zeichen nicht gesetzt ist, wird in einer Schleife die Kantentabelle durchlaufen (B). Inner-
halb der Schleife wird jeweils versuchsweise das Stop-Kennzeichen gesetzt (C). In der
nachfolgenden Iteration über alle Kanten (D) wird jeweils der Anfangs- und Endpunkt
der betrachteten Kante abgerufen (E und F) und die Distanz zum Endpunkt bei Verwen-
dung der aktuellen Kante ermittelt (G). Wenn diese Distanz kürzer ist als die bisher
ermittelte Distanz (H), wird die Kante in den Ergebnisbaum eingebaut. Eine gegebenen-
falls vorher genutzte Kante wird dabei automatisch überschrieben (I).
Das Ergebnis des Algorithmus von Ford ist natürlich identisch mit dem Ergebnis des
Dijkstra-Algorithmus:
550
17.15 Minimale Spannbäume
Unter einem Spannbaum verstehen wir einen Teilgraphen eines Graphen, der
ein Baum (zusammenhängend und kreisfrei) ist und alle Knoten des Graphen
enthält.
Ein Graph hat in der Regel viele Spannbäume. Einen Spannbaum erhält man, wenn
man aus dem Graphen so lange wie möglich Kanten entfernt, ohne den Zusammen-
hang zu zerstören. Ich habe das einmal mehr oder weniger willkürlich beim Stan-
dardbeispiel des Autobahnnetzes durchgeführt (siehe Abbildung 17.38).
Das Beispiel zeigt einen Spannbaum, der eine Kantengewichtssumme von 2466 hat.
Wir suchen jetzt unter allen möglichen Spannbäumen eines Graphen denjenigen mit
der geringsten Kantengewichtssumme:
551
17 Elemente der Graphentheorie
Hamburg
Bremen
284
Berlin
125
Hannover
282
284
282
Frankfurt
179
217 205
425 125
208
63
Stuttgart 83
395
München 217
425
2466
Gesucht ist ein Algorithmus, der den minimalen Spannbaum eines Graphen be-
rechnet.
552
17.16 Der Algorithmus von Kruskal
Ausgangspunkt für den Algorithmus ist eine Kantentabelle, in der die Kanten nach
Kantenlänge sortiert sind. Wenn eine solche Tabelle nicht vorliegt, können Sie sie aus
der Distanzenmatrix erzeugen und mit einem der bekannten Sortierverfahren sor-
tieren. Aus dieser Tabelle berechnet der Algorithmus von Kruskal dann den minima-
len Spannbaum:
Minimaler Spannbaum
Graph Sortierte Kantentabelle
A
A Kante 1: A ↔ C 1
6 1 Kante 2: B ↔ E 2 1
B 5 C Kante 3: C ↔ E 3
Kante 4: B ↔ C 5 B C
9 2 3 Kante 5: A ↔ B 6
Kante 6: D ↔ E 8 3
D 8 E Kante 7: B ↔ D 9 2
D 8 E
Bilde für jeden Knoten eine Menge, die nur diesen einzelnen Knoten enthält.
Betrachte dann der Länge nach alle Kanten. Wenn Anfangs- und Endpunkt der
Kante in verschiedenen Mengen liegen, dann nimm die Kante hinzu, und ver- 17
einige die beiden Mengen. Wenn alle Kanten betrachtet sind, ist der minimale
Spannbaum fertig.
Abbildung 17.40 zeigt das Verfahren anhand des oben dargestellten Graphen:
A A A A A
6 1 6 1 6 1 6 1 6 1
B 5 C B 5 C B 5 C B 5 C B 5 C
3 9 2 3 2 3 2 3 2 3
9 2 9 9 9
D 8 E D 8 E D 8 E D 8 E D 8 E
Jeder Knoten liegt in Betrachte Kante 1, und Betrachte Kante 2, und Betrachte Kante 3, und Kanten 4 und 5 bringen
einer eigenen Menge. vereinige die Mengen. vereinige die Mengen. vereinige die Mengen. nichts. Betrachte Kante 6,
Betrachte jetzt der Reihe Kante 1 gehört zum Kante 2 gehört zum Kante 3 gehört zum und vereinige die Mengen.
nach alle Kanten. Spannbaum. Spannbaum. Spannbaum. Kante 7 bringt nichts mehr.
553
17 Elemente der Graphentheorie
Die Implementierung des Verfahrens besteht eigentlich nur aus einer geschickten
Assemblierung von Teilen, die wir anderweitig bereits erstellt haben. Zunächst brau-
chen wir aber wieder eine geeignete Datenstruktur.
# define ANZAHL 12
int vorgaenger[ANZAHL];
# define ANZ_KANTEN 22
int ausgewaehlt[ANZ_KANTEN];
Als Datenstruktur für die Kantentabelle wird die folgende struct verwendet:
struct kante
{
int distanz;
int von;
int nach;
};
Eigentlich benötigt man für die Kantenauswahl die Kantenlänge (distanz) nicht.
Wichtig ist nur, dass die Kanten, nach Länge sortiert, in einem Array (kantentabelle)
vorliegen. In unserem konkreten Beispiel ist dieses Array wie folgt definiert (siehe
Abbildung 17.41).
Zur Initialisierung erhält jeder Knoten eine eigene Menge, indem er zur Wurzel (-1)
eines rückwärts verketteten Baums gemacht wird.
void init()
{
int i;
554
17.16 Der Algorithmus von Kruskal
Zur Vereinigung der zu den Knoten a und b gehörenden Mengen werden zunächst 17
die Wurzeln zu a und b gesucht. Sind die Wurzeln gleich, dann sind die beiden Knoten
schon in der gleichen Menge, und es muss nichts gemacht werden (return 0). Sind die
Knoten ungleich, werden die Mengen vereinigt, indem die eine Wurzel (b) unter die
andere (a) gebracht wird. In diesem Fall wird Erfolg zurückgemeldet (return 1).
555
17 Elemente der Graphentheorie
In der Funktion kruskal werden die Kanten der Reihe nach betrachtet, und Kanten,
die zur Vereinigung von zwei Mengen führen, werden im Array ausgewaehlt
markiert:
void kruskal()
{
int kante;
init();
for( kante = 0; kante < ANZ_KANTEN; kante++)
ausgewaehlt[kante] = join( kantentabelle[kante].von
, kantentabelle[kante].nach);
}
void ausgabe()
{
int kante;
unsigned int summe;
556
17.17 Hamiltonsche Wege
Hamburg
Bremen
119
284
154 Berlin
125 47 Duesseldorf-Koeln
Hannover 63 Dortmund-Duesseldorf
282 108 Dresden-Leipzig
233
119 Bremen-Hamburg
Dortmund 208 125 Bremen-Hannover
256 179 205 179 Berlin-Leipzig
Leipzig
Düsseldorf 63 189 Frankfurt-Koeln
352 208 Dortmund-Hannover
47 83 108 217 Frankfurt-Stuttgart
264
220 Muenchen-Stuttgart
Köln 395 Dresden 256 Hannover-Leipzig
189 ----
void main() 1731
{
Frankfurt kruskal();
ausgabe();
217
}
400 425
Stuttgart 220
München
2 Ein Dodekaeder ist ein Körper, dessen Oberfläche aus zwölf regelmäßigen Fünfecken besteht.
557
17 Elemente der Graphentheorie
Ausgehend von einem beliebigen Eckpunkt des Dodekaeders, sollte man, immer an
den Kanten entlangfahrend, alle anderen Eckpunkte besuchen, um schließlich zum
Ausgangspunkt zurückzukehren, ohne einen Eckpunkt zweimal besucht zu haben.
Auf den ersten Blick ähnelt dieses Problem dem Königsberger Brückenproblem. Bei
genauerem Hinsehen sind die beiden Probleme jedoch grundverschieden. Bei dem
hamiltonschen Problem geht es darum, alle Knoten eines Graphen genau einmal zu
besuchen, während es bei dem eulerschen Problem darum geht, alle Kanten eines Gra-
phen genau einmal zu benutzen. Dieser Unterschied wirkt unbedeutend, doch
erstaunlicherweise sind die Probleme von extrem verschiedener Berechnungskomp-
lexität. Während sich das Problem des eulerschen Weges in einem Graphen in poly-
nomialer Zeitkomplexität lösen lässt, sind für das Problem, den kürzesten hamilton-
schen Weg zu finden, nur Algorithmen exponentieller Laufzeit bekannt.
Wir definieren, was wir unter einem hamiltonschen Weg verstehen wollen:
Ein Weg in einem ungerichteten Graphen heißt hamiltonscher Weg, wenn die
folgenden drei Bedingungen erfüllt sind:
Wenn wir einen hamiltonschen Weg in einem Graphen haben, dann muss der Weg
genau so viele Kanten haben, wie der Graph Knoten hat, und in jedem Knoten des
Graphen muss genau eine Kante des hamiltonschen Weges einlaufen und genau eine
Kante auslaufen. Mit diesen Kriterien können wir erkennen, dass es im Allgemeinen
keinen hamiltonschen Weg geben muss. In dem in Abbildung 17.44 dargestellten
Graphen müsste man, um einen hamiltonschen Weg zu erhalten, genau eine Kante
außer Betracht lassen. In jedem Fall gäbe es dann aber immer einen Knoten mit nur
einer Kante.
558
17.17 Hamiltonsche Wege
Im Falle des Dodekaeders gibt es aber viele hamiltonsche Wege. Um das zu erkennen,
abstrahieren wir von der räumlichen Gestalt des Dodekaeders und modellieren ihn
durch einen Graphen:
10
9 11
17 18
1 3
8 12
16 19
7 13
15
6 14
5
0 4
Ein hamiltonscher Weg ist eine Permutation der Knotenmenge, die zusätzlich die fol-
genden Bedingungen erfüllt:
1. Jeder Knoten, außer dem letzten, der Permutation muss mit seinem Nachfolger
durch eine Kante verbunden sein.
2. Der letzte Knoten der Permutation muss mit dem ersten durch eine Kante ver- 17
bunden sein.
Um einen hamiltonschen Weg zu finden, können Sie alle Permutationen der Knoten-
menge erzeugen und für jede Permutation anhand der oben genannten Bedingun-
gen prüfen, ob sie einen hamiltonschen Weg beschreibt. Auf diese Weise erhalten Sie
nicht nur einen, sondern alle hamiltonschen Wege.
Permutationen können wir bereits erzeugen. Sie erinnern sich hoffentlich an das
Programm perm aus Abschnitt 7.4, »Rekursion«. Dieses Programm können wir so
modifizieren, dass es hamiltonsche Wege findet.
Wir starten wieder mit der Adjazenzmatrix, die für den Dodekaeder recht verwirrend
ist (siehe Abbildung 17.46).
Wie schon angekündigt, werden die Permutationen mit einer Abwandlung des Pro-
gramms perm erzeugt. Die Abwandlung besteht darin, dass beim Einfügen eines
neuen Knotens in die im Aufbau befindliche Permutation immer geprüft wird, ob
der Knoten mit seinem Vorgängerknoten verbunden werden kann. Nur wenn eine
solche Verbindungsmöglichkeit besteht, wird mit der Erzeugung der Permutation
fortgefahren.
559
17 Elemente der Graphentheorie
# define ANZAHL 20
Ist eine Permutation vollständig erzeugt, wird abschließend noch geprüft, ob es eine
Kante vom letzten wieder zum ersten Knoten der Permutation gibt. Dies ist natürlich
ein Brute-Force-Ansatz, bei dem mehr als 1017 Fälle überprüft werden müssen.
560
17.17 Hamiltonsche Wege
array[start] = array[i];
array[i] = sav;
if( dodekaeder[array[start-1]][array[start]])
F hamilton( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
}
Wenn eine neue Permutation erzeugt wurde (A), wird geprüft, ob der Endpunkt mit
dem Anfangspunkt durch eine Kante verbunden ist. Wenn das der Fall ist, liegt ein
hamiltonscher Weg vor (B). In diesem Fall wird der gefundene Weg ausgegeben (C
und D). Andernfalls ist die Permutation noch nicht vollständig (E) und wird fortge-
setzt. Nur wenn der betrachtete Knoten mit seinem Vorgänger verbunden werden
kann, lohnt es sich, mit der Erzeugung der Permutation fortzufahren (F).
void main()
{
A int pfad[ANZAHL]; 17
int i;
gerufen, das ein Array für die Permutationen definiert (A) und initialisiert (B), findet
es 60 verschiedene hamiltonsche Wege,
1: 0-1-2-3-4-14-13-12-11-10-9-8-7-16-17-18-19-15-5-6-0
2: 0-1-2-3-4-14-5-15-16-17-18-19-13-12-11-10-9-8-7-6-0
...
59: 0-6-7-16-17-18-11-12-13-19-15-5-14-4-3-2-10-9-8-1-0
60: 0-6-7-16-17-18-19-15-5-14-13-12-11-10-9-8-1-2-3-4-0
von denen ich den ersten und den letzten hier grafisch dargestellt habe:
561
17 Elemente der Graphentheorie
2 2
10 10
9 11 9 11
17 18 17 18
1 3 1 3
8 12 8 12
16 19 16 19
7 13 7 13
15 15
6 14 6 14
5 5
0 4 0 4
Das Problem, einen möglichst kurzen hamiltonschen Weg in einem nicht nega-
tiv bewerteten Graphen zu finden, wird auch als das Problem des Handlungs-
reisenden (engl. Travelling Salesman Problem, kurz TSP) bezeichnet.
Hinter der Bezeichnung Problem des Handlungsreisenden steht die folgende Veran-
schaulichung:
Ein Handlungsreisender will alle seine Kunden besuchen. Er startet mit der
Rundreise von seinem Büro und möchte am Ende der Rundreise wieder an sei-
nem Schreibtisch sitzen. Unter allen möglichen Reiserouten möchte er natür-
lich die mit der kürzesten Gesamtstrecke wählen.
Mit der Lösungsstrategie der »Reise um die Welt« können wir dieses Problem lösen,
wenn wir zusätzlich die Weglängen berechnen und uns den jeweils kürzesten Weg
speichern. Zusätzlich zur Distanzenmatrix (distanz) benötigen wir globale Variablen
für die Länge der kürzesten Rundreise (mindist) und für ein Array (minpfad), in dem
wir den Pfad der kürzesten Rundreise ablegen.
Wir erzeugen, wie in der »Reise um die Welt«, alle möglichen Rundreisen im deut-
schen Autobahnnetz. Immer, wenn eine neue Rundreise erzeugt wurde, berechnen
wir deren Länge. Wenn die Rundreise kürzer als die bisher kürzeste Rundreise ist,
kopieren wir den Pfad der Rundreise in das Array minpfad um und erhalten eine neue
minimale Distanz (mindist).
562
17.18 Das Travelling-Salesman-Problem
# define ANZAHL 12
# define xxx 10000
Hamburg
Bremen int distanz[ ANZAHL][ ANZAHL] =
119
284 {
154 Berlin
125 { 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx},
Hannover
233
282 {xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx},
{xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx},
Dortmund 208 256 179 205
Leipzig {205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx},
Düsseldorf 63
352 {xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx},
47 83 108
264 {xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217},
Köln 395 Dresden {284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx},
189
{282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx},
Frankfurt
{xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx},
{179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx},
217 {xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220},
400 425
{xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0},
Stuttgart 220 };
563
17 Elemente der Graphentheorie
In der geänderten Funktion hamilton wird jedes Mal, wenn eine neue Rundreise
gefunden wurde (A), die Länge der entsprechenden Reise berechnet (B). Ist die neue
Rundreise kürzer als die bisherige minimale Reise (C), wird der entsprechende Pfad
als neuer minpfad gesichert (D).
Das Programm findet sechs hamiltonsche Wege, von denen der im Folgenden darge-
stellte der kürzeste ist:
205 Berlin-Dresden
108 Dresden-Leipzig
Hamburg 425 Leipzig-Muenchen
Bremen 220 Muenchen-Stuttgart
119 217 Stuttgart-Frankfurt
284 189 Frankfurt-Koeln
154 Berlin 47 Koeln-Duesseldorf
125 63 Duesseldorf-Dortmund
Hannover 208 Dortmund-Hannover char *stadt[ANZAHL] =
282 125 Hannover-Bremen
233 {
119 Bremen-Hamburg
284 Hamburg-Berlin "Berlin",
Dortmund 208 256 179 205 2210 "Bremen",
Leipzig
Düsseldorf 63 "Dortmund",
83 352 "Dresden",
47 264 108 "Duesseldorf",
Köln 395 Dresden "Frankfurt",
189 void main() "Hamburg",
{ "Hannover",
int pfad[ANZAHL]; "Koeln",
Frankfurt int i; "Leipzig",
"Muenchen",
217
for( i = 0; i < ANZAHL; i++) "Stuttgart"
400 425
pfad[i] = i; };
hamilton(12, pfad, 1);
Stuttgart 220 for( i = 1; i <= ANZAHL; i++)
printf( "%5d %s-%s\n",
distanz[minpfad[i-1]] [minpfad[i%ANZAHL]],
München stadt[minpfad[i-1]],
stadt[minpfad[i%ANZAHL]]);
printf( "%5d\n", mindist);
}
564
17.18 Das Travelling-Salesman-Problem
Bei der vollständigen Untersuchung werden Sie feststellen, dass ein kürzerer Weg als
der von uns bereits gefundene trotz des erheblich größeren Suchraums nicht gefun-
den werden kann. Irgendwie ist es uns mit Intuition oder Glück gelungen, durch eine
geschickte Vorauswahl von Kanten den Umfang der Aufgabe drastisch zu verklei-
nern, ohne die optimale Lösung zu verlieren. Das liegt natürlich an der geometri-
schen Anschaulichkeit des Problems. Bei vielen Optimierungsaufgaben fehlt diese
Anschauung, und die kombinatorische Zahl der zu betrachtenden Permutationen ist
noch um ein Vielfaches größer.
Ein Computer hat nicht die Intuition, eine geeignete Vorauswahl zu treffen, und es ist
bisher nicht gelungen, eine allgemeine Lösung des Travelling-Salesman-Problems zu
finden, die die kombinatorische Explosion vermeidet. Beim Versuch, die Explosion
zu vermeiden, hat man aber Überraschendes und Tiefliegendes entdeckt.
Die Menge aller Probleme, die man algorithmisch lösen kann und für die man eine
Lösung mit polynomialer Komplexität überprüfen kann, nennen wir NP. P ist eine
Teilmenge von NP.
NP-vollständig
NP
TSP
565
17 Elemente der Graphentheorie
nung von 1 Million Dollar ausgesetzt. Sollten Sie die Antwort auf diese Frage finden,
können Sie sich hier Ihr Preisgeld abholen: http://www.claymath.org.
Für die allgemeine Lösung des TSP sind nur Algorithmen exponentieller Laufzeit ver-
fügbar. Das bedeutet, dass man das TSP für »große« Graphen nicht in akzeptabler Zeit
lösen kann. Da man aber für viele technische und betriebswirtschaftliche Fragestel-
lungen an einer Lösung des TSP interessiert ist, muss man einen Kompromiss zwi-
schen zwei gegensätzlichen Anforderungen suchen:
1. Der Suchraum sollte so klein sein, dass man zu einem Algorithmus von polynomi-
aler Laufzeit kommt.
2. Der Suchraum sollte so groß sein, dass sich die optimale Lösung oder zumindest
eine halbwegs optimale Lösung noch darin befindet.
566
17.18 Das Travelling-Salesman-Problem
Hamburg
Bremen
Berlin
Hannover
Dortmund
Düsseldorf
Leipzig
Köln
Dresden
Frankfurt
17
Stuttgart
München
Überspringen Sie beim Rückzug aus der Tiefensuche die Knoten, an denen Sie schon
waren, erhalten Sie die folgende Besuchsfolge:
Start: Berlin
Vor: Leipzig-Dresden
Zurück: Leipzig
Vor: Hannover-Bremen-Hamburg
Zurück: Bremen-Hannover
Vor: Dortmund-Düsseldorf-Köln-Frankfurt-Stuttgart-München
Zurück: Stuttgart-Frankfurt-Köln-Düsseldorf-Dortmund-Hannover-Leipzig
Ziel: Berlin
567
17 Elemente der Graphentheorie
Hamburg
Bremen
Berlin
Hannover
Dortmund
Düsseldorf
Leipzig
Köln
2558 km Dresden
Stuttgart
minimaler
Spannbaum München
Einen minimalen Spannbaum haben wir zuvor bereits berechnet. Wir übernehmen
das Ergebnis in Form einer Adjazenzmatrix:
568
17.18 Das Travelling-Salesman-Problem
Düsseldorf
Dortmund
Hannover
München
Hamburg
Frankfurt
Stuttgart
Dresden
Bremen
Leipzig
Berlin
Köln
0 412 488 205 284 572 555 282 569 179 584 634 Berlin
0 233 470 119 317 466 125 312 362 753 640 Bremen
0 607 343 63 264 208 83 532 653 451 Dortmund
0 502 629 469 364 589 108 484 524 Dresden
427 0 232 292 47 558 621 419 Düsseldorf
int distanz [ ANZAHL][ ANZAHL] = 495 0 352 189 395 400 217 Frankfurt
{ 0 154 422 391 782 668 Hamburg
{ 0,412,488,205,572,555,284,282,569,179,584,634}, 0 287 256 639 526 Hannover
{412, 0,233,470,317,466,119,125,312,362,753,640}, 0 515 578 376 Köln
{488,233, 0,607, 63,264,343,208, 83,532,653,451}, 0 425 465 Leipzig
{205,470,607, 0,629,469,502,364,589,108,484,524},
0 220 Stuttgart
{572,317, 63,629, 0,232,427,292, 47,558,621,419},
{555,466,264,469,232, 0,495,352,189,395,400,217}, 0 München
{284,119,343,502,427,495, 0,154,422,391,782,668},
{282,125,208,364,292,352,154, 0,287,256,639,526},
{569,312, 83,589, 47,189,422,287, 0,515,578,376},
{179,362,532,108,558,395,391,256,515, 0,425,465},
{584,753,653,484,621,400,782,639,578,425, 0,220},
{634,640,451,524,419,217,668,526,376,465,220, 0},
};
17
Hamburg
Bremen
119
284
int spannbaum[ ANZAHL][ ANZAHL] = 154 Berlin
{ 125
Hannover
{0,0,0,0,0,0,0,0,0,1,0,0}, 282
{0,0,0,0,0,0,1,1,0,0,0,0}, 233
{0,0,0,0,1,0,0,1,0,0,0,0}, Dortmund 208 256 179 205
{0,0,0,0,0,0,0,0,0,1,0,0}, Leipzig
Düsseldorf 63
{0,0,1,0,0,0,0,0,1,0,0,0},
{0,0,0,0,0,0,0,0,1,0,0,1}, 83 352
47 264 108
{0,1,0,0,0,0,0,0,0,0,0,0},
{0,1,1,0,0,0,0,0,0,1,0,0}, Köln 395 Dresden
189
{0,0,0,0,1,1,0,0,0,0,0,0},
{1,0,0,1,0,0,0,1,0,0,0,0},
BERLIN 0
{0,0,0,0,0,0,0,0,0,0,0,1}, Frankfurt BREMEN 1
{0,0,0,0,0,1,0,0,0,0,1,0}, DORTMUND 2
}; 217 DRESDEN 3
400 425 DUESSELDORF 4
FRANKFURT 5
HAMBURG 6
Stuttgart 220 HANNOVER 7
KOELN 8
LEIPZIG 9
München MUENCHEN 10
STUTTGART 11
569
17 Elemente der Graphentheorie
Wir erstellen ein Array (pfad), um den bei der Tiefensuche konstruierten Weg aufzu-
nehmen:
Die globale Variable position legt dabei die aktuelle Schreibposition in diesem Array
fest.
A pfad[position++] = knoten;
for( i = 0; i < ANZAHL; i++)
{
B if( spannbaum[knoten][i] && (herkunft != i))
tiefensuche( i, knoten);
}
}
Als Parameter werden der aktuelle Knoten (knoten) und der Knoten, über den man zu
diesem Knoten gelangt ist (herkunft), mitgegeben. Der aktuelle Knoten wird an der
nächsten Schreibposition in den Pfad geschrieben (A). Danach wird zu allen Folge-
knoten im Baum gegangen (B). Über den Parameter herkunft wird dabei verhindert,
dass man dabei zu dem Knoten zurückgeht, von dem man gekommen ist.
Da immer nur neu erreichte Knoten in den Pfad eingetragen werden, entstehen
keine Dubletten in dem Pfad.
Die Funktion ausgabe gibt den aktuell im Array pfad vorliegenden Weg auf dem Bild-
schirm aus.
Bei der Ausgabe wird die Länge des Weges berechnet und abschließend ebenfalls aus-
gegeben.
570
17.18 Das Travelling-Salesman-Problem
void ausgabe()
{
int i;
int d;
void main()
{
int start;
Für jeden Knoten wird, nachdem die Schreibposition zurückgesetzt wurde, die Tie-
fensuche gestartet und das Ergebnis ausgegeben.
Das Programm berechnet so viele hamiltonsche Wege, wie der Graph Knoten hat.
Insgesamt werden also zwölf hamiltonsche Wege erzeugt und ausgegeben, von
denen der kürzeste mit 2376 km eine gute Approximation des optimalen Weges (2210
km) ist.
Zum Abschluss dieses Kapitels wollen wir uns fragen, wie gut denn die Näherungslö-
sung ist, die wir aus dem Spannbaum erzeugt haben. Im Allgemeinen werden Sie die
optimale Lösung nicht kennen und müssen daher versuchen, abzuschätzen, wie weit
Sie im »Worst Case« vom Optimum entfernt sind.
571
17 Elemente der Graphentheorie
2210
Im Folgenden sei:
Wenn man aus dem minimalen hamiltonschen Weg eine Kante entfernt, erhält man
einen Spannbaum, der kürzer ist als der minimale hamiltonsche Weg. Daraus folgt,
dass der minimale Spannbaum kürzer ist als der minimale hamiltonsche Weg. Es ist
also: s ≤ m.
Wenn man den minimalen Spannbaum in Tiefensuche durchläuft, wird jede Kante
des Spannbaums maximal zweimal abgefahren. Beim Entfernen der doppelt vor-
kommenden Knoten wird diese Länge nicht vergrößert, da wir ja vorausgesetzt
haben, dass direkte Verbindungen nie länger als Umwege sind. Es gilt also für den mit
unserem Verfahren ermittelten hamiltonschen Weg: h ≤ 2s.
Insgesamt folgt: h ≤ 2s ≤ 2m
Der aus dem Spannbaum gewonnene hamiltonsche Weg ist also maximal doppelt so
lang wie der kürzeste hamiltonsche Weg. Damit haben wir eine Route für den Hand-
lungsreisenden gefunden, die maximal doppelt so lang ist wie die optimale Route.
572
17.18 Das Travelling-Salesman-Problem
Für das TSP gibt es hunderte von Verfahren, die versuchen, die Lösungssuche unter
speziellen Randbedingungen zu verbessern oder zu beschleunigen, aber keines die-
ser Verfahren löst das allgemeine Problem in polynomialer Laufzeit.
Das TSP ist vielleicht das am intensivsten untersuchte und am meisten diskutierte
Problem der Informatik. Ein Ende dieser Diskussion ist nicht in Sicht.
17
573
Kapitel 18
Zusammenfassung und Ergänzung
Denn was man schwarz auf weiß besitzt, kann man getrost nach
Hause tragen.
– Johann Wolfgang von Goethe
In diesem Kapitel finden Sie ein Kompendium der wichtigsten Fakten zur C-Pro-
grammierung. Die Informationen sind alphabetisch in Stichworten gegliedert. Hier
können Sie Ihr Wissen über die C-Programmierung auffrischen oder vertiefen.
Adressen
Variablen und Funktionen liegen zur Laufzeit an konkreten Stellen im Speicher des
Rechners und haben daher eine Speicheradresse. Diese Adresse kann mit dem
Adress-Operator (&) ermittelt werden.
Die Variable v wird angelegt (A), und ihr Adresswert wird ausgegeben (B):
4061360
A void f()
{
}
Die Funktion f wird definiert (A), und die Adresse der Funktion f wird z. B. mit diesem
Adresswert ausgegeben (B):
575
18 Zusammenfassung und Ergänzung
10752470
Bei einer Funktion kann die explizite Angabe des Adress-Operators weggelassen wer-
den, da aus dem Zusammenhang klar ist, dass es sich nur um eine Funktionsadresse
handeln kann.
Adressen werden dazu verwendet, die Zugriffsinformation auf eine Variable oder
Funktion in einem Programm zu verwalten. Zum Beispiel kann die Adresse einer
Variablen in eine Datenstruktur geschrieben werden. Die Datenstruktur kann an eine
Funktion übergeben werden. Diese Funktion kann dann über die in der Datenstruk-
tur gespeicherte Adresse auf die Variable zugreifen.
Mit Adressen von Variablen können Sie rechnen. Was passiert, wenn Sie zu einer
Adresse eine Zahl (ein sogenanntes Offset) addieren, zeigt das folgende Beispiel:
int v;
2293136
2293140
2293144
2293148
Der Adresswert erhöht sich bei einer Addition von 1 um die Größe des Objekts (hier
int, Größe 4), dessen Adresse genommen wurde. Hätte man also eine Reihung von
Objekten gleichen Typs im Speicher (siehe Abschnitt »Arrays«), würde eine Addition
von 1 die Adresse des nächsten Objekts im Speicher liefern. Adressen sind also beson-
ders geeignet, um sich in homogenen Datenbeständen wahlfrei zu bewegen. Mit
Adressen von Funktionen kann man nicht rechnen.
576
Alignment
anderen Struktur eintragen. Verkettete Strukturen haben den Vorteil, dass sie zur
Laufzeit dynamisch aufgebaut werden können, sodass der Umfang der zu verarbei-
tenden Daten zur Compile-Zeit noch nicht bekannt sein muss. Weitere Informatio-
nen zu dynamischen Datenstrukturen finden Sie in Abschnitt 18.75, »Speicherallo-
kation«, und mehr über den Zugriff mittels Adressen erfahren Sie in Kapitel 8,
»Zeiger und Adressen«.
Alignment
Bei der Definition von Datenstrukturen gibt es gewisse Möglichkeiten, den benötig-
ten Speicherplatz zu optimieren. Um uns das klarzumachen, erstellen wir eine Daten-
struktur mit vier Zahlen (long) und vier Zeichen (char). Wir erstellen zwei Varianten
dieser Datenstruktur und vertauschen nur die Reihenfolge der Felder:
struct test1
{
char c1;
long l1;
char c2;
long l2;
char c3;
long l3;
char c4;
long l4; 18
};
struct test2
{
long l1;
long l2;
long l3;
long l4;
char c1;
char c2;
char c3;
char c4;
};
577
18 Zusammenfassung und Ergänzung
dass daher auch der Speicherplatzbedarf dieser Datenstrukturen gleich ist und sich
leicht aus den Grunddatentypen berechnen lässt. Wenn man annimmt, dass eine
long-Zahl vier und ein Zeichen ein Byte belegt, sollten das in der Summe 20 Bytes
sein. Wenn wir die Größe der beiden Datenstrukturen mit dem sizeof-Operator
bestimmen, erleben wir eine Bestätigung und eine Überraschung:
void main()
{
printf ("test1: %d\n", sizeof(test1));
printf ("test2: %d\n", sizeof(test2));
}
test1: 32
test2: 20
Die zweite Datenstruktur hat die erwartete Größe, während die erste um mehr als
50 % größer ist. Der Grund dafür ist eine unterschiedliche Ausrichtung der Daten im
Speicher. Man spricht in diesem Zusammenhang auch von Alignment. Die unter-
schiedliche Ausrichtung hat mit der Hardwarearchitektur des Zielsystems zu tun.
Der Compiler legt die Felder der Datenstruktur so an, dass der Prozessor des Zielsys-
tems möglichst effizient mit den Daten arbeiten kann, ohne dabei die Reihenfolge
der Felder zu verändern. Stellen Sie sich vor, dass der Rechner einen vier Bytes brei-
ten Datenbus hat und mit einem Speicherzugriff immer vier Bytes gleichzeitig lesen
kann. Dann wird er den Speicher in 4-Byte-Blöcken lesen und schreiben. Das bedeu-
tet, dass er eine 4-Byte-Integer-Zahl mit einem Zugriff lesen kann, wenn sie auf einer
durch 4 teilbaren Speicheradresse beginnt. Ist das nicht der Fall, muss der Prozessor
mit zwei Lesezugriffen insgesamt acht Bytes lesen und aus diesen die vier relevanten
Bytes zusammenstellen:
Optimal ausgerichtet.
Die Zahl kann in einem Zug gelesen werden.
578
Arithmetische Operatoren (+, –, *, /, %)
Der Rechner kann also mit 4-Byte-Zahlen besonders effizient umgehen, wenn sie im
Speicher ein 4-Byte-Alignment haben, d. h., wenn sie auf einer durch 4 teilbaren
Adresse beginnen. Aus diesem Grund hat der Compiler in die erste Datenstruktur
Füllfelder eingefügt, um ein günstiges Alignment zu erzwingen:
c1 l1 c2 l2 c3 l3 c4 l4
Bei der zweiten Datenstruktur war das nicht erforderlich, da ohne Füllfelder bereits
ein optimales Alignment vorliegt:
l1 l2 l3 l4 c1 c2 c3 c4
Dementsprechend kleiner ist die Datenstruktur. Ein Rechner kann auch mit nicht
optimal ausgerichteten Daten arbeiten, und man kann einen Compiler so einstellen,
dass er die Datenstrukturen speicheroptimiert und nicht zugriffsoptimiert ablegt.
Darauf werde ich hier jedoch nicht eingehen. Ein optimales (speicher- und zugriffs-
optimales) Alignment erhalten Sie, wenn Sie die Datenfelder in Ihren Datenstruktu-
ren, ohne Rücksicht auf die Bedeutung, der Größe nach sortieren – also etwa alle long
vor allen int vor allen short vor allen char, so wie ich es bei der zweiten Datenstruk- 18
tur gemacht habe.
+ +x plus x arithmetischer R 14
Operator
- -x minus x
579
18 Zusammenfassung und Ergänzung
Die Operatorzeichen + und – kommen in doppelter Bedeutung vor, aber das ist kein
Problem, da man anhand der Verwendung (einstellig, zweistellig) erkennen kann,
welche Bedeutung innerhalb einer Formel gemeint ist. Die Prioritäten sind so
gewählt, dass sie der vertrauten Sicht der Schulmathematik entsprechen.
Sind die Operanden eines arithmetischen Operators ganzzahlig, ist auch das Ergeb-
nis ganzzahlig. Für die Division bedeutet dies, dass eine Division ohne Rest durchge-
führt wird, wenn beide an der Division beteiligten Operanden ganzzahlig sind. In
Formeln wird bei der Auswertung immer so lange wie möglich ganzzahlig gerechnet,
auch wenn am Ende unter Umständen eine Gleitkommazahl herauskommt. Manch-
mal ergeben sich dadurch Ergebnisse, die in einem scheinbaren Widerspruch zur
Schulmathematik stehen. Dies ist insbesondere mit Blick auf die Division wichtig.
Schulmathematisch ist
x = 10 · 10 = 1 und y = 10 · 10 = 1
100 100
Die Division mit Rest und den Rest bei Division (Modulo-Operation) betrachtet man
üblicherweise nur bei positiven Zahlen. Dort ist alles eindeutig geregelt:
580
Arrays
–5 = –2*3 + 1
oder
–5 = –1*3 – 2
Am besten verwenden Sie diese Operationen bei negativen Zahlen nicht, da unter-
schiedliche Compiler unterschiedliche Ergebnisse liefern können.
Arrays
Große, homogene Datenbestände, auf die man zur Laufzeit flexibel zugreifen muss,
kann man in einem sogenannten Array ablegen. Arrays sind Reihungen von Daten
des gleichen Typs.
Index
581
18 Zusammenfassung und Ergänzung
Datentyp
Name
int daten[3][7][5];
Arrays können für alle verfügbaren Datentypen gebildet werden. Es gibt also:
Ein Array ist homogen, d. h., alle Felder haben den gleichen Datentyp. Die Anzahl der
Dimensionen sowie die Anzahl der Elemente in den einzelnen Dimensionen sind
beliebig, müssen aber bei der Definition des Arrays festgelegt werden und können
danach nicht mehr geändert werden.
Das folgende Beispiel zeigt ein Array, das insgesamt 3 · 5 · 7 = 105 Gleitkommazahlen
aufnehmen kann, die in drei Dimensionen zu 3, 7 bzw. 5 Elementen gruppiert sind:
float daten[3][7][5];
582
Arrays
int matrix[3][2] = {
{ 11, 12},
{ 21, 22},
{ 31, 32}
};
Die Anzahl der Elemente in der ersten Dimension kann auch implizit durch die Initi-
alisierung des Arrays festgelegt werden. Die beiden folgenden Arrays haben jeweils
drei Elemente in der ersten Dimension:
int matrix[][2] = {
{ 11, 12},
{ 21, 22},
{ 31, 32}
};
Die Felder eines Arrays sind in jeder Dimension, beginnend mit 0, fortlaufend num-
meriert.
Hat ein Array in einer Dimension n Elemente, sind diese von 0 bis n-1 num-
meriert.
18
Zugegriffen wird auf die Felder eines Arrays, indem Sie in jeder Dimension einen gül-
tigen Index angeben. Der Index kann durch eine Konstante, eine Variable oder einen
beliebigen Ausdruck gegeben sein, der zur Laufzeit zu einem gültigen ganzzahligen
Index ausgewertet werden kann:
int daten[3][7][5];
int x;
int y;
x = 1;
y = 2;
daten[1][x][2*x+y-1]= 15;
daten[1][2][1] = daten[2][x+1][3]+4;
Das Ergebnis eines indizierten Zugriffs ist von dem Datentyp, der durch den Feldtyp
des Arrays festgelegt ist, und kann wie eine Variable dieses Typs verwendet werden.
583
18 Zusammenfassung und Ergänzung
Es gibt keine Prüfungen, ob der Programmierer korrekte Indizes benutzt. Das fol-
gende Programm wird gnadenlos abstürzen:
int a[100];
a[100] = 1;
Da ein Unterprogramm nicht ermitteln kann, wie viele Elemente ein Array hat, wird
in der Regel zusätzlich zu dem Array eine Integer-Variable übergeben, in der die
Anzahl der Elemente, die im Unterprogramm zu bearbeiten sind, festgelegt ist:
Bei der Übergabe eines mehrdimensionalen Arrays kann nur die Anzahl der Ele-
mente in der ersten Dimension unbestimmt bleiben. Alle anderen Angaben sind
unverzichtbar, da sie für die korrekte »Serialisierung« des Arrays im Speicher not-
wendig sind.
584
Arrays und Zeiger
Wenn a ein Array ist, dann kann a wie ein Zeiger auf das erste Element im Array
18
verwendet werden.
Grundsätzlich ist ein Array aber kein Zeiger, weil das Array die Elemente physikalisch
enthält, während der Zeiger die Elemente nur referenziert. In der Verwendung kann
man aber Array und Zeiger nicht unterscheiden.
Wir demonstrieren dies, indem wir in einem Programm ein Array mit Feldtyp int
und einen Zeiger auf int anlegen:
int i;
A int zahlen[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
B int *z;
C z = zahlen;
585
18 Zusammenfassung und Ergänzung
Das Programm legt ein Array mit zehn Integer-Zahlen (A) sowie einen Zeiger auf Inte-
ger (B) an. Diesem Zeiger kann das Array zugewiesen werden, da das Array auch als
Zeiger verstanden werden kann (C). Im weiteren Verlauf werden Array und Zeiger
dann synonym verwendet (D und E), und wir erhalten diese Ausgabe:
1 2 3 4 5 6 7 8 9 10
1 2 3 4 5 6 7 8 9 10
Bei der Zuweisung (z = zahlen) wird nur die Startadresse des Arrays in den Zeiger
übertragen. Die Elemente im Array werden nicht kopiert – wohin auch? Die umge-
kehrte Zuweisung (zahlen = z) ist nicht möglich, da ein Zeiger kein Array ist und keine
Elemente enthält, die in das Array kopiert werden könnten.
Das zu den eindimensionalen Arrays Gesagte gilt auch für mehrdimensionale Arrays.
Sie müssen sich nur klarmachen, dass ein zweidimensionales Array ein »Array von
Arrays« ist. Am besten schauen wir uns das direkt im Code an:
int i,k;
B int (*z)[3];
C z = zahlen;
586
Arrays und Zeiger
printf( "\n");
}
printf( "\n");
Hier wird ein zweidimensionales Array angelegt (A) und ein Zeiger auf ein eindimen-
sionales Array mit drei Integer-Werten erstellt (B). Diesem Zeiger wird das Array
zugewiesen (C). Array und Zeiger werden nun synonym verwendet (D und E), und wir
erhalten diese Ausgabe:
1 2 3
4 5 6
1 2 3
4 5 6
Mit den Regeln der Zeigerarithmetik folgt, dass wir ein Element des Arrays sowohl
über seinen Index als auch sein Offset ansprechen können.
int i = 1;
float x;
x = i[a];
587
18 Zusammenfassung und Ergänzung
ASCII-Zeichencode
Zur rechnerinternen Darstellung von Zeichen wird häufig der ASCII-Zeichencode ver-
wendet. Durch den ASCII-Zeichencode wird jedem Zeichen eine Zahl zugeordnet:
Quelle: Wikipedia
Der ASCII-Zeichencode stammt aus der Frühzeit der Datenverarbeitung und umfasst
nur die Buchstaben des englischen Alphabets und einige Sonderzeichen. Zur Darstel-
lung umfassenderer Zeichensätze werden erweiterte Zeichencodes benötigt, die
unter Umständen auch eine Darstellung in mehreren Bytes erfordern. Solche Zei-
chencodes werden hier nicht behandelt.
Ausgabe
In der C Runtime Library gibt es eine Reihe von Funktionen zur Bildschirmausgabe.
Die wichtigste dieser Funktionen ist printf, die zur formatierten Ausgabe von Zei-
chen, Zahlen und Text dient. Die printf-Funktion hat eine variable Anzahl von Para-
metern. Festgelegt ist dabei nur der erste Parameter, der einen Formatstring enthält.
Dieser String enthält, neben dem auszugebenden Text, sogenannte Formatanwei-
sungen. Jede Formatanweisung korrespondiert mit einem Parameter der Funktion,
der den auszugebenden Wert enthält. Die Formatanweisung legt fest, wie der Para-
meterwert ausgegeben werden soll.
588
Ausgabe
%[flags][width][.precision][length]specifier
Die in eckigen Klammern aufgeführten Bestandteile sind optional; sie können also
fehlen. Im einfachsten Fall hat eine Formatanweisung also die Form:
%specifier
Der hier genannte specifier ist ein einzelnes Zeichen wie s, d oder f und steht für den
auszugebenden Datentyp und dessen grundlegendes Ausgabeformat. Gültige For-
matanweisungen sind etwa %d, %s oder %f.
Tabelle 18.2 zeigt alle gültigen Format-Specifier mit ihrem Datentyp und dem zuge-
hörigen Ausgabeformat:
c Zeichen
s String
589
18 Zusammenfassung und Ergänzung
Zwischen dem %-Zeichen und dem Format-Specifier können die folgenden Kennzei-
chen angegeben werden:
Kennzeichen Bedeutung
Durch die Angabe von width können Sie eine Feldbreite für die Ausgabe festlegen:
width Bedeutung
zahl Die Zahl gibt die verwendete Feldbreite an, sofern die Ausgabe kür-
zer als die angegebene Feldbreite ist. Ist die Ausgabe länger, wird
die Feldbreite ignoriert. Die Ausgabe wird also nie abgeschnitten.
590
Ausgabe
Durch die Angabe einer precision kann die Genauigkeit der Ausgabe beeinflusst
werden (Tabelle 18.5):
precision Bedeutung
zahl Bei Ganzahlen gibt zahl die Anzahl der mindestens auszugebenden Zif-
fern an, wobei gegebenenfalls mit führenden Nullen aufgefüllt wird.
Für Gleitkommazahlen, die mit a, A, e, E und f, F ausgegeben werden,
legt zahl die Anzahl der auszugebenden Nachkommastellen fest.
Für Gleitkommazahlen, die mit g oder G ausgegeben werden, ist zahl
die Gesamtzahl signifikanter Ziffern, die ausgegeben werden sollen.
Bei Strings ist zahl die maximale Anzahl von Zeichen, die ausgegeben
werden sollen.
* Der Wert für die Präzision wird aus dem nächsten Parameter der Para-
meterliste gelesen. Als Parameter wird ein Ganzzahlwert erwartet.
591
18 Zusammenfassung und Ergänzung
auto
Mit dem Zusatz auto wird eine Variable als »automatische Variable« klassifiziert. Man
spricht in diesem Zusammenhang auch von einer Speicherklasse, die der Variablen
zugeordnet wird:
Die Speicherklasse auto kann nur bei Variablendefinitionen innerhalb eines Blocks
(also innerhalb geschweifter Klammern) verwendet werden und legt fest, dass es sich
um eine lokale Variable handelt, deren Lebensdauer automatisch bestimmt wird. Die
Variable wird erzeugt, wenn der Kontrollfluss in den Block eintritt. Beim Verlassen des
Blocks wird die Variable wieder beseitigt. Automatische Variablen liegen auf dem Stack.
Da Variablen innerhalb von Blöcken ohne explizite Zuordnung einer Speicherklasse
immer als automatische Variablen angelegt werden, findet man eine auto-Anwei-
sung in C-Programmen relativ selten. Sie sollten auto in diesem Sinn auch nicht
mehr verwenden, da auto in neueren Standards – seit C++11 – eine geänderte Bedeu-
tung hat. Dort bedeutet auto, dass der Compiler eine automatische Typerkennung
für diese Variable durchführt.
Bedingte Auswertung
Einfache Berechnungsalternativen können Sie durch bedingte Auswertung sehr ein-
fach formulieren. Sie verwenden dazu den dreistelligen ? :-Operator.
Wenn Sie z. B. den größeren von zwei Werten (a, b) ermitteln und zuweisen möchten,
können Sie alternativ zu einer Fallunterscheidung
if( a > b)
max = a;
else
max = b;
max = a > b ? a : b;
592
Bitfelder
rechten Seite ja nach Ausgang des Tests nur einer ausgewertet wird, was bei Seiten-
effekten unter Umständen zu schwer verständlichem Code führen kann.
In der Zuweisung
wird nur der größere der beiden Werte (bei Gleichheit b) nach der Zuweisung noch
um 1 erhöht.
Bitfelder
Bitfelder sind durch den Programmierer größenoptimierte Datenstrukturen.
Manchmal legt man innerhalb von Datenstrukturen Felder an, die man in der vom
System bereitgestellten Größe nicht benötigt. Wenn Sie z. B. nur eine Ja-/Nein-Infor-
mation speichern möchten und dafür ein int-Feld anlegen, verbrauchen Sie 32 oder
64 Bit Speicher, obwohl Sie nur 1 Bit benötigen. Durch Verwendung von Bitfeldern
können Sie Datenstrukturen mit Integer-Feldern auf eine geeignete Größe kompri-
mieren.
Als Beispiel betrachten wir die Datenstruktur für ein Kalenderdatum auf einem
32-Bit-System:
struct datum
{ 18
unsigned int tag;
unsigned int monat;
unsigned int jahr;
};
Hier werden jeweils 32 Bit (= 4 Bytes) für Tag, Monat und Jahr reserviert. Das sind ins-
gesamt 12 Bytes. Sie wissen aber, dass zur Speicherung der Tageszahl (1–31) 5 Bit aus-
reichend sind. Für den Monat (1–12) reichen sogar 4 Bit, und für das Jahr benötigen Sie
maximal 11 Bit. Insgesamt wären also nur 20 Bit erforderlich, und Sie könnten die
gesamte Information in einer 4-Byte-Integer-Zahl ablegen.
Wenn Sie dem C-Compiler mitteilen, wie viele Bits Sie für die einzelnen Felder benö-
tigen, kann er die Datenstruktur optimieren:
struct datum
{
unsigned int tag : 5;
unsigned int monat : 4;
593
18 Zusammenfassung und Ergänzung
Auf diese Weise können Sie bis auf ein einzelnes Bit heruntergehen. Sie könnten in
der struct datum z. B. noch die Information, ob es sich um ein Schaltjahr handelt, hin-
zufügen, ohne dass sich der Speicherbedarf vergrößert, da Sie in der Datenstruktur
(siehe Alignment) noch 12 Bit Reserve haben:
struct datum
{
unsigned int tag : 5;
unsigned int monat : 4;
unsigned int jahr : 11;
unsigned int schaltjahr : 1;
};
594
Bitoperatoren (~, <<, >>, &, ^, |)
Die Verknüpfungsoperationen führen eine Operation auf allen Bitstellen ihrer Ope-
randen durch. Abbildung 18.13 zeigt dies am Beispiel des bitweisen Und auf einem
8-Bit-Datenwort:
Bitweises Und
x 1 1 0 0 0 0 1 0
y 1 0 0 1 1 0 1 1
x&y 1 0 0 0 0 0 1 0 0 und 1 ist 0.
x 1 0 0 1 1 0 1 1 x 1 1 0 0 0 0 1 0
~x 0 1 1 0 0 1 0 0 y 1 0 0 1 1 0 1 1
x&y 1 0 0 0 0 0 1 0
18
595
18 Zusammenfassung und Ergänzung
int n = 3;
unsigned int x = 0xaffe;
Bitoperationen wendet man üblicherweise nur auf vorzeichenlose ganze Zahlen an.
Bei vorzeichenlosen Zahlen werden beim Schieben alle frei werdenden Bitstellen mit
0 besetzt. Der Standard legt aber nicht fest, was bei einem Bitshift nach rechts in die
frei werdenden Bitstellen einer vorzeichenbehafteten Zahl geschoben wird. Auf man-
chen Systemen ist es eine 0, auf manchen das Vorzeichenbit (Carry). Das Programm-
fragment in Abbildung 18.16 zeigt, dass sich die Schiebeoperationen auf meinem
System für signed- und unsigned-Operanden unterschiedlich verhalten:
Auf einem anderen System könnte das anders sein. Seien Sie daher sehr vorsichtig,
wenn Sie Schiebeoperationen mit vorzeichenbehafteten Zahlen verwenden.
596
Blöcke
Blöcke
Anweisungen können Sie durch geschweifte Klammern zu Blöcken zusammenfas-
sen. Solche Blöcke können Sie strukturell als eine einzelne Anweisung auffassen und
wieder mit anderen Anweisungen und Blöcken zu neuen Blöcken zusammenfassen.
Auf diese Weise ergibt sich eine Hierarchie ineinander geschachtelter Blöcke und Ein-
zelanweisungen.
Nur am Anfang eines Blocks können Sie Variablen definieren1. Diese Variablen sind
dann nur innerhalb des Blocks, in dem sie angelegt wurden, und darin eingeschlosse-
nen Blöcken sichtbar und können auch nur dort verwendet werden. Sie können sich
das so vorstellen, dass jeder eingeschlossene Block auf einer neuen, höheren Ebene
liegt, von der aus man auf die darunterliegenden Ebenen blicken kann:
{
int
x;
...
{
int
a;
...
...
...
... }
}
18
Von den unteren Ebenen kann man aber nicht die Informationen auf den darüberlie-
genden Ebenen erkennen. Im Falle von Namenskonflikten, also gleich benannten
Variablen auf verschiedenen Ebenen, haben die Variablen, die einem »näher« sind,
Vorrang vor »entfernteren« Variablen. Sie sollten solche Konflikte aber prinzipiell
vermeiden.
Beachten Sie auch den Unterschied zwischen automatischen und statischen Variab-
len. Automatische Variablen werden immer wieder neu erzeugt, wenn der Kontroll-
fluss in den Block eintritt, und wieder beseitigt, wenn der Kontrollfluss den Block
verlässt. Statische Variablen haben die Lebensdauer des Hauptprogramms. Näheres
dazu finden Sie unter den Stichworten auto bzw static.
597
18 Zusammenfassung und Ergänzung
break
Eine break-Anweisung dient zum Unterbrechen des Kontrollflusses innerhalb von
Schleifen (for, while, do) oder Sprungleisten (switch) und tritt nie als alleinstehende
Anweisung auf. Schauen Sie sich daher die Abschnitte »for«, »while«, »do« und
»switch« in diesem Kapitel an.
case
Mit case werden Einsprungstellen innerhalb einer switch-Sprungleiste festgelegt. Da
case immer nur in Verbindung mit switch auftritt, erhalten Sie alle weiteren Infor-
mationen im Abschnitt über switch.
Cast-Operator
In vielen Fällen werden in C automatisch Typkonvertierungen durchgeführt. Wenn
z. B. eine Integer-Variable einer Float-Variaben zugewiesen wird, haben wir es streng
genommen mit zwei verschiedenen Typen zu tun. Trotzdem ist die Zuweisung mög-
lich, weil eine Integer-Zahl ohne Informationsverlust in eine Float-Variable hinein-
passt.
int i = 100;
float f;
f = i;
float f = 1.23;
int i;
i = (int)f;
Sinnvoll ist eine Typumwandlung z. B., wenn Sie innerhalb einer Berechnung von
Integer- auf Gleitkommarechnung umsteigen möchten:
598
char
f ist 0.0,
float f; da eine Integer-Division durchgeführt wird.
f = ((float)10)/100;
f ist 0.1,
da der Operand 10 vor der Division
in float konvertiert wird.
int *pointer;
char
Der Datentyp char bezeichnet eine »sehr kleine« ganze Zahl oder ein einzelnes Zei-
chen. Es handelt sich um einen vorzeichenbehafteten Datentyp, üblicherweise im
Bereich von –128 bis +127.
599
18 Zusammenfassung und Ergänzung
Mehr darüber erfahren Sie in den Abschnitten über Datentypen für ganze Zahlen
bzw. Datentypen für Gleitkommazahlen.
Compile-Schalter
Oft ist es erforderlich, von einem Softwaresystem unterschiedliche Varianten (z. B.
für verschiedene Betriebssysteme) zu erstellen. Die konsistente Pflege der verschie-
denen Varianten stellt ein erhebliches Problem dar, wenn man das Single-Source-
Prinzip verletzt. Dieses Prinzip besagt, dass es auch bei unterschiedlichen Zielvarian-
ten immer nur eine Variante des Quellcodes geben darf. Compile-Schalter ermögli-
chen es, aus einer Quelle verschiedene Varianten eines Programms zu erzeugen.
Wir verdeutlichen dies am Beispiel von Prüfdrucken. Stellen Sie sich vor, dass in der
Testvariante eines Programms an vielen unterschiedlichen Stellen Prüfdrucke einge-
baut sind. Diese Prüfdrucke protokollieren den Programmablauf und unterstützen
damit die Fehlersuche. In der Variante, die an einen Kunden ausgeliefert wird, sollen
natürlich keine Prüfdrucke mehr vorhanden sein. Mehr noch, es soll nicht einmal
mehr der Code für Prüfdrucke in der Kundenvariante vorhanden sein, da unnützer
Code das Programm nur unnötig aufblähen würde. Möchten Sie in dieser Situation
vermeiden, dass der Programmcode in zwei Varianten zerfällt, können Sie Compile-
Schalter verwenden:
void main()
Preprozessorlauf {
mit gesetztem Compileschalter int i, s;
# define TESTVARIANTE
for( i = 1, s = 0; i < 10; i++)
{
s = s + i;
void main()
printf( "Zwischenergebnis: i = %d, s = %d\n", i, s);
{
}
int i, s; printf( "Endergebnis: %d\n", s); Zwischenergebnis: i = 1, s = 1
Zwischenergebnis: i = 2, s = 3
} Zwischenergebnis: i = 3, s = 6
for( i = 1, s = 0; i < 10; i++) Zwischenergebnis: i = 4, s = 10
{ Zwischenergebnis: i = 5, s = 15
s = s + i; Zwischenergebnis: i = 6, s = 21
# ifdef TESTVARIANTE Zwischenergebnis: i = 7, s = 28
Zwischenergebnis: i = 8, s = 36
printf( "Zwischenergebnis: i = %d, s = %d\n", i, s); Zwischenergebnis: i = 9, s = 45
# endif Endergebnis: 45
}
printf( "Endergebnis: %d\n", s); void main()
} {
int i, s;
600
Compile-Schalter
Für das Verständnis von Compile-Schaltern ist wichtig, dass die jeweils nicht aktivier-
ten Codeteile nicht durch Abfragen zur Laufzeit umsprungen werden, sondern
bereits vor der Kompilation durch den Präprozessor ausgefiltert werden und von
daher im Code des laufenden Programms gar nicht mehr vorkommen. Dies ermög-
licht es, in den verschiedenen Varianten systemspezifischen Code zu implementie-
ren, der für gewisse Zielsysteme nicht kompilierbar wäre.
Anweisung Bedeutung
Tabelle 18.7
Steueranweisungen für die bedingte Kompilierung
Beachten Sie, dass es hier keine Gruppierungen mit Klammern gibt und dass eine
vollständige Fallunterscheidung wie folgt aussehen könnte:
# define VERSION 2
...
# if VERSION < 1
601
18 Zusammenfassung und Ergänzung
...
# elif VERSION == 2
...
# else
...
# endif
Anweisung Bedeutung
const
Mit dem Zusatz const werden Daten als konstant, also unveränderlich, definiert:
Konstanten haben wie Variablen einen Datentyp. Anders als Variablen müssen sie
aber bei der Definition mit einem Wert versehen werden. Dieser Wert kann dann
nicht mehr geändert werden. Konstanten können daher nur dort verwendet werden,
wo auch ein konkreter Wert des gleichen Typs verwendet werden könnte.
Konstanten haben natürlich keine Adresse, sodass der Adress-Operator nicht auf
Konstanten angewandt werden kann.
Ansonsten unterscheidet sich der Umgang mit Konstanten nicht vom Umgang mit
Variablen. Überall, wo eine Variable verwendet wird, ohne deren Wert zu verändern,
kann auch eine Konstante benutzt werden.
602
Dateioperationen
continue
Eine continue-Anweisung dient zum Fortsetzen des Kontrollflusses innerhalb von
Schleifen und tritt nie als alleinstehende Anweisung auf. Mehr darüber erfahren Sie
in den Abschnitten »for«, »while« und »do«.
C Standard Library
Die C Standard Library (auch C Runtime Library) ist eine standardisierte Sammlung
von Funktionen, symbolischen Konstanten, Makros und Datentypen. Die Elemente
der Library sind keine Elemente der Sprache C, aber sie sind mit C standardisiert und
in jeder C-Entwicklungsumgebung identisch verfügbar.
Dateioperationen
Ein C-Programm kann Text oder Binärdaten aus einer Datei einlesen oder solche 18
Daten in einer Datei speichern. Dazu muss eine Datei zunächst mit der Funktion
fopen geöffnet werden. Beim Öffnen der Datei geben Sie den Dateinamen und den
Modus an, in dem Sie die Datei öffnen möchten.
603
18 Zusammenfassung und Ergänzung
Der Modus wird der Funktion fopen als String (z. B. "r+") übergeben. Beim Öffnen
einer Datei wird, je nach Modus, ein Schreib-/Lesezeiger positioniert. Dieser Zeiger
legt fest, an welcher Position der nächste Schreib-/Lesezugriff erfolgt. Bei einer
Schreib-/Leseoperation rückt der Zeiger dann entsprechend voran. Die Position die-
ses Zeigers kann aber auch abgefragt (ftell) und explizit gesetzt (fseek) werden.
Beim Schreiben werden die Daten nicht eingefügt, sondern gegebenenfalls beste-
hende Daten werden überschrieben. Überschreiten Sie mit einer Schreiboperation
das Dateiende, wird die Datei automatisch vergrößert. Versuchen Sie, mit einer Lese-
operation das Dateiende zu überschreiten, erhalten Sie die Information EOF (End of
File). Im Anfügemodus wird immer am Dateiende gearbeitet, egal, wie der Schreib-/
Lesezeiger positioniert wird. Unter Windows gibt es zusätzlich die Möglichkeit, eine
Datei im Binärmodus (Zusatz b im Modus) zu öffnen. Dies bewirkt, dass man die Ori-
ginaldaten in der Datei liest und schreibt und keine automatische Übersetzung von
CR-LF in LF2 und umgekehrt erfolgt.
Beim erfolgreichen Öffnen einer Datei erhalten Sie einen Zeiger vom Typ FILE. Über
diesen Zeiger können Sie dann mit einer Reihe von Funktionen auf die Datei zugrei-
fen und z. B. Daten lesen oder schreiben. Wird der Zugriff auf eine Datei nicht weiter
2 Windows hat etwa im Vergleich zu Unix andere Konventionen zur Markierung des Zeilenendes
in Textdateien.
604
Dateioperationen
benötigt, sollte sie mit fclose geschlossen werden. Im folgenden Beispiel werden
zehn Zeilen in eine Datei geschrieben, anschließend wieder ausgelesen und auf dem
Bildschirm angezeigt:
FILE *meinedatei;
int i;
char c;
Die formatierte Eingabe und Ausgabe für Dateien entspricht den Funktionen scanf
18
und printf für die Bildschirmein- und -ausgabe. Sie müssen lediglich beachten, dass
der Dateizeiger bei allen Funktionen als zusätzlicher Parameter übergeben werden
muss.
Funktionsname Beschreibung
605
18 Zusammenfassung und Ergänzung
Funktionsname Beschreibung
fread Lesen einer bestimmten Anzahl von Bytes aus einer Datei
Geöffnete Dateien sind, wie auch Tastatur und Bildschirm, sogenannte Streams
(Ein-/Ausgabeströme). Weitere Informationen dazu erhalten Sie im Abschnitt über
Streams. C kennt nur den Dateityp Stream, der im Prinzip einer nicht weiter struktu-
rierten Zeichenkette entspricht. Komplexere Dateitypen (z. B. indexsequenzielle
Dateien) sind im Standardumfang von C nicht verfügbar.
Syntaxgraph char
signed short
int
Der Datentyp char (bzw. unsigned char) wird auch für Zeichen (besser Zeichencodes)
verwendet.
606
Datentypen für Gleitkommazahlen
signed unsigned
Der am häufigsten verwendete Ganzahldatentyp ist int bzw. unsigned int. Dies ist
der Datentyp, mit dem das Zielsystem am effizientesten rechnen kann. Dieser Daten-
typ wird immer dort verwendet, wo es einfach nur darum geht, ganzzahlig zu
rechnen.
607
18 Zusammenfassung und Ergänzung
Grundsätzlich kann man sagen, dass double einen größeren Bereich präziser abdeckt
als float und long double einen größeren Bereich präziser abdeckt als double.
Speicherbedarf, Abdeckung und Präzision sind aber maschinenabhängig, sodass ich
hier keine allgemeingültigen Angaben machen kann. Auch die interne Darstellung
von Gleitkommazahlen wird hier nicht diskutiert.
Mehr darüber erfahren Sie in den Abschnitten über Datentypen für ganze Zahlen
bzw. ASCII-Zeichencode.
Datentypen (allgemein)
Die einfachsten Datentypen sind die Datentypen für:
왘 ganze Zahlen
왘 Gleitkommazahlen
왘 Zeichen
Aus diesen Grundtypen kann man durch Aggregation komplexere Typen zusam-
mensetzen. Aggregationen sind:
왘 Array
왘 Struct
왘 Union
Zu allen kursiv gedruckten Begriffen dieses Kapitels finden Sie weiterführende Infor-
mationen in den entsprechenden Abschnitten dieser Zusammenfassung.
608
Datenzugriff
Datenzugriff
Wir unterscheiden drei Arten des Datenzugriffs:
Den Direktzugriff verwenden Sie, wenn ein Datum über eine Variable unmittelbar
gegeben ist.
Handelt es sich um einen der Grunddatentypen (int, float, ...), verwenden Sie den
Variablennamen zum Zugriff:
int var1;
float var2;
var1 = 4711;
A var2 = 1.234 + 5*(var1 + 3);
Im angegebenen Beispiel erfolgt ein Zugriff auf die durch var1 bzw. var2 gegebenen
Daten (A).
Auch bei zusammengesetzten Datentypen können Sie auf diese Weise auf die Daten-
struktur als Ganzes zugreifen. Zusätzlich verwenden Sie den Punkt-Operator, um
innerhalb der Struktur gezielt Teilinformationen anzusprechen:
18
struct typ1
{
int a;
float b;
};
struct typ2
{
int c;
float d;
struct typ1 e;
};
A var1.a = 123;
A var1.b = var1.a + 3.14;
609
18 Zusammenfassung und Ergänzung
A var2.c = 2*var1.a;
A var2.d = var1.b + var2.c;
A var2.e = var1;
A var2.e.b = var2.e.b + 1;
Auch hier erfolgt der Zugriff auf die durch var1 bzw. var2 gegebenen Daten (A).
Auf die einzelnen Elemente eines ein- oder mehrdimensionalen Arrays wird indiziert
mit dem []-Operator zugegriffen:
int var1[10];
float var2[5][7];
var1[8] = 1.23;
var2[1][2] = var1[8]+7;
var2[2][1] = 2*(var2[1][2] + var1[8]);
Haben Sie einen Zeiger auf ein Datum, können Sie durch Dereferenzierung mit dem
*-Operator auf das Datum zugreifen (Indirektzugriff):
int var;
int *ptr;
ptr = &var;
*ptr = 123;
Über den Zeiger ptr wird der Wert der Variablen var verändert.
Handelt es sich bei dem referenzierten Datum um eine Struktur (struct oder union),
können Sie mit dem Pfeil-Operator (->) auf die einzelnen Felder zugreifen, ohne
zuvor explizit dereferenzieren zu müssen:
struct typ
{
int a;
float b;
};
ptr = &var;
610
Deklarationen und Definitionen
ptr->a = 123;
ptr->b = ptr->a + 1.234;
Auch hier wird über den Zeiger ptr der Inhalt der Variablen var verändert.
struct typ1
{
int a;
float b[20];
};
struct typ2
{
struct typ1 daten[10][5];
};
ptr = &var;
18
ptr->daten[1][2].a = 123;
ptr->daten[2][3].b[8] = 2*ptr->daten[1][2].a + 123;
default
Eine default-Anweisung dient zur Behandlung von Standardfällen in Sprungleisten.
Weitere Informationen dazu finden Sie im Abschnitt über switch.
611
18 Zusammenfassung und Ergänzung
int x;
Wenn ein Objekt definiert wird, entsteht durch den Compiler »raumgreifender«
Code im lauffähigen Programm. Definitionen erzeugen also Codesubstanz, die zur
Laufzeit im Code lokalisiert werden kann.
Wenn dagegen über die Existenz eines Objekts (Variable oder Funktion) informiert
wird, spricht man von einer Deklaration. Eine Definition ist in diesem Sinne immer
auch eine Deklaration, da mit der Definition auch immer eine Information über die
Existenz verbunden ist. Es gibt aber auch Deklarationen, die nicht zugleich Defini-
tion sind. Diese beginnen mit dem Schlüsselwort extern.
extern int x;
Wenn der Quellcode eines Programms auf mehrere Dateien verteilt ist, passiert es
zwangsläufig, dass man in einer Datei eine Variable oder Funktion nutzen will, die in
einer anderen Datei definiert ist. Wenn eine Datei kompiliert wird, benötigt der Com-
piler Informationen über die korrekte Verwendung auch anderweitig definierter
Objekte. Er benötigt den Typ anderweitig definierter Variablen und die Schnittstelle
anderweitig definierter Funktionen. Genau diese Informationen stellt eine Variab-
len- oder Funktionsdeklaration zur Verfügung.
Die Deklaration extern int x bedeutet also, dass es irgendwo eine int-Variable mit
dem Namen x gibt. Analog bedeutet die Deklaration extern void test(int a), dass es
irgendwo eine void-Funktion mit dem Namen test gibt, die einen int-Parameter hat.
Der Parametername (hier a) dient ja zum Zugriff auf den Parameter aus der Funktion
und kann bei der Deklaration auch weggelassen werden:
extern void test( int);
Üblicherweise verwendet man aber die Parameternamen auch in Deklarationen, da
sie oft hilfreich zum Verständnis einer Funktionsschnittstelle sind.
extern void copy( char *destination, char *source);
Deklarationen bezeichnet man auch als Vorwärtsverweise Funktionsdeklarationen
auch als Funktionsprototypen.
612
do
Dezimaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
do
Bei do ... while handelt es sich um ein Schleifenkonstrukt, das im Gegensatz zu for
keine Initialisierung und kein Inkrement hat und bei dem der Test auf Fortsetzung
am Ende eines jeden Durchlaufs durchgeführt wird:
do 1
{ 2
3
printf( "%d\n", i); 4
i++; 5
} while( i < 10); 6
7
8
9
18
Prüfung auf Fortsetzung am
Ende des Schleifenkörpers
Beachten Sie den wesentlichen Unterschied zwischen for und do. Bei for wird der
Test vor jedem Eintritt, also auch vor dem ersten Eintritt in den Schleifenkörper,
durchgeführt. Bei do hingegen wird der Test nach jedem Verlassen des Schleifenkör-
pers und vor dem möglichen Wiedereintritt in den Schleifenkörper ausgeführt. Das
führt dazu, dass eine do-Schleife auf jeden Fall mindestens einmal durchlaufen wird
(siehe Abbildung 18.23).
Bei for spricht man auch von einer kopfgesteuerten, bei do von einer fußgesteuerten
Schleife.
Aus dem Schleifenkörper kann die do...while Schleife genauso wie die for-Schleife
durch break bzw. continue gesteuert werden. Mit break wird die Schleife sofort abge-
brochen, während mit continue nur ein einzelner Schleifendurchlauf abgebrochen
und die Schleife über den Test fortgesetzt wird.
613
18 Zusammenfassung und Ergänzung
do for( ; i < 0; )
{ {
printf( "%d\n", i); printf( "%d\n", i);
i++; i++;
} while( i < 0); }
1
double
Bei double handelt es sich um einen Datentyp für Gleitkommazahlen »doppelter«
Genauigkeit. Wie viele Bytes der Datentyp double im Speicher belegt und welchen
Zahlenbereich er genau abdeckt, kann systemspezifisch unterschiedlich sein.
Eingabe
In der C Runtime Library gibt es eine Reihe von Funktionen zur Tastatureingabe. Die
wichtigste dieser Funktionen ist scanf, die zur formatierten Eingabe von Zeichen,
Zahlen und Text dient. Die scanf-Funktion hat eine variable Anzahl von Parametern.
Festgelegt ist dabei nur der erste Parameter, der einen Formatstring enthält. Dieser
String enthält wiederum sogenannte Formatanweisungen. Jede Formatanweisung
korrespondiert mit einem Parameter, der die Variable referenziert, in die der einzule-
sende Wert kopiert werden soll. Die Parameter der scanf-Funktion sind also Zeiger.
Die Formatanweisung legt fest, in welchem Format der einzugebende Wert erwartet
wird. Die Formatanweisungen sind genauso aufgebaut wie bei der Ausgabe mit
printf (siehe Abschnitt »Ausgabe«).
Wichtig ist, dass die Eingabe exakt so erfolgen muss, wie es in den Formatanweisun-
gen festgelegt ist. Die Anweisung
int a, b;
614
enum (Aufzählungstypen)
Die Funktion scanf stellt ein potenzielles Sicherheitsrisiko dar. Sie erhält einen Zeiger
als Parameter und überschreibt dann den durch den Zeiger referenzierten Speicher-
bereich mit den Eingaben, ohne zu prüfen, ob der bereitgestellte Speicherbereich
ausreichend groß für die Eingabe ist. Schadprogramme versuchen, durch spezielle
Eingaben einen Überlauf (Buffer Overflow) zu provozieren, bei dem dann schädlicher
Code im Hauptspeicher platziert wird.
18
else
Mit else formulieren Sie eine Alternative innerhalb einer Fallunterscheidung mit if.
Die Anweisung else tritt nie als eigenständige Anweisung auf. Schauen Sie sich daher
auch den Abschnitt zu if an.
enum (Aufzählungstypen)
Wenn Sie »sprechende« Namen für Zahlenwerte benutzen möchten, verwenden Sie
den Aufzählungstyp enum. Ein Aufzählungstyp ist ein Datentyp, bei dem der Program-
mierer selbst geeignete Bezeichnungen für einzelne Werte vergeben kann.
615
18 Zusammenfassung und Ergänzung
Einmal in dieser Weise deklariert, ist wochentag ein Datentyp wie int, der die symbo-
lischen Werte Montag bis Sonntag annehmen kann. Verwendet wird ein solcher Auf-
zählungstyp dann wie folgt:
Intern wird ein Aufzählungstyp auf int abgebildet. Welche Zahlenwerte dabei zuge-
ordnet werden, ist nicht festgelegt. Man kann jedoch eine bestimmte Festlegung
durch Angabe konkreter Werte erzwingen:
Es wird nicht geprüft, ob Variablen von einem Aufzählungstyp wirklich nur die für
den Aufzählungstyp festgelegten Werte enthalten. Man kann solchen Variablen eine
beliebige ganze Zahl zuweisen und mit den Variablenwerten wie mit ganzen Zahlen
rechnen. Aufzählungstypen bieten insofern keine umfassenden Funktionen, son-
dern dienen nur einer besseren Lesbarkeit des Programmcodes.
Escape-Sequenzen
Escape-Sequenzen werden als Ersatzdarstellung für nicht druckbare Zeichen wie Tabu-
lator oder Seitenvorschub verwendet. In C gibt es die folgenden Escape-Sequenzen:
Sequenz Bedeutung
\a Alarmton
\b Rückschritt (Backspace)
\t horizontaler Tabulator
616
extern
Sequenz Bedeutung
\v vertikaler Tabulator
\" Anführungszeichen
\? Fragezeichen
\\ Backslash
Bei einem Oktalcode schreibt man einfach eine ein- bis dreistellige Oktalzahl in die
Escape-Sequenz:
\101 ein A
\12 ein Zeilenvorschub
\134 ein Backslash
\x41 ein A
\xa ein Zeilenvorschub 18
\x5c ein Backslash
extern
Mit dem Schlüsselwort extern wird in C eine Deklaration eingeleitet. Mehr zu Dekla-
rationen erfahren Sie unter dem Stichwort »Deklarationen und Definitionen«.
617
18 Zusammenfassung und Ergänzung
float
Bei float handelt es sich um einen Datentyp für Gleitkommazahlen einfacher
Genauigkeit.
Wie viele Bytes der Datentyp float im Speicher belegt und welchen Zahlenbereich er
genau abdeckt, kann systemspezifisch unterschiedlich sein.
for
Bei einer for-Anweisung handelt es sich um die wesentliche Kontrollstruktur zur
Implementierung von Schleifen. Wir unterscheiden:
왘 Schleifenkopf
왘 Schleifenkörper
왘 Initialisierung
왘 Test
왘 Inkrement
618
Funktionsaufruf
Funktionen
Funktionen sind das wesentliche Modularisierungskonzept in jeder Programmier-
sprache.
Eine Funktion hat eine Schnittstelle und eine Implementierung. Durch die Schnitt-
stelle wird festgelegt, wie die Funktion heißt, welche Daten in die Funktion hineinge-
hen und welche Daten aus ihr herauskommen. In der Implementierung wird
festgelegt, wie die Daten verarbeitet werden.
Funktionsdefinition
Funktionsschnittstelle
char meinefunktion( int x, float y)
{
// Funktionscode
} Funktionsimplementierung
Funktionsaufruf 18
Um eine Funktion aufrufen zu können, muss man ihre Schnittstelle kennen. Beim
Aufruf muss man die Daten übergeben, die an der Schnittstelle verlangt sind. Eine
Funktion mit der Schnittstelle
char meinefunktion( int, float);
erwartet beim Aufruf einen int- und einen float-Wert und gibt nach Erledigung
ihrer Aufgabe einen char-Wert zurück. Sie kann also in der folgenden Weise aufgeru-
fen werden:
int a = 1;
float b = 2.3;
char c;
c = meinefunktion( a, b);
Wichtig ist, dass die beim Aufruf verwendeten Datentypen den in der Schnittstelle
geforderten Datentypen entsprechen oder automatisch in diese konvertiert werden
können.
619
18 Zusammenfassung und Ergänzung
Beim Aufruf werden Kopien der übergebenen Parameter erzeugt und an die Funk-
tion übergeben. Nach Übergabe der Daten besteht keine Kopplung mehr zwischen
den Daten des rufenden Programms und den Daten, auf denen die Funktion arbeitet.
Möchten Sie einer Funktion die Möglichkeit geben, auf ausgewählten Daten des
rufenden Programms zu arbeiten, muss müssen Sie mit Zeigern (siehe Abschnitt
»Zeiger«) arbeiten.
Funktionsimplementierung
Die Funktionsimplementierung folgt in der Funktionsdefinition direkt auf die Funk-
tionsschnittstelle und ist in geschweifte Klammern eingeschlossen. In der Funkti-
onsimplementierung wird ausprogrammiert, wie die an der Schnittstelle übergebe-
nen Daten zu verarbeiten sind, um die vereinbarten Rückgabewerte zu berechnen.
Funktionsdefinition
Funktionsschnittstelle
char meinefunktion( int x, float y)
{
// Funktionscode
} Funktionsimplementierung
Die Funktion greift über die Parameternamen auf die übergebenen Daten zu. Dabei
handelt es sich um Kopien der vom rufenden Programm übergebenen Daten, sodass
eine Änderung der Werte keine Auswirkungen auf die Daten im Hauptprogramm
hat. Auch eine zufällige Namensgleichheit von Funktionsparametern und Variablen
im Hauptprogramm ändert daran nichts.
620
Funktionsschnittstelle
Funktionen können andere Funktionen, aber auch sich selbst mittelbar oder unmit-
telbar aufrufen. Letzteres bezeichnet man als Rekursion. Rekursion ist ein wichtiges
Programmiermittel, das ebenfalls unter einem eigenen Stichwort behandelt wird.
Funktionsprototyp
Ein Funktionsprototyp ist die »Bekanntgabe« einer Funktionsschnittstelle. Möchten
Sie etwa die Existenz einer Funktion mit dem Namen meinefunktion, die einen int-
und einen float-Wert erhält und ein einzelnes Zeichen (char) zurückgibt, bekannt
geben, schreiben Sie:
extern char meinefunktion( int, float);
Häufig fügt man hier noch die Namen hinzu, über die die Funktion auf die Parameter
zugreift:
extern char meinefunktion( int anzahl, float wert);
Die Namen dienen der Beschreibung der Funktion. Sie sind in einem Funktionspro-
totyp aber weder notwendig, noch müssen sie mit den Namen übereinstimmen, die
in der Implementierung der Funktion tatsächlich zum Zugriff verwendet werden.
Einen Funktionsprototyp finden Sie vorrangig in einer Header-Datei, die dann von
allen Quellcodedateien inkludiert werden sollte, in denen die Funktion verwendet
wird. Der Compiler kann dann beim Übersetzen der Quellcodedatei prüfen, ob der
Aufruf der Funktion in der Quellcodedatei konform zum Funktionsprototyp in der 18
Header-Datei ist.
Funktionsschnittstelle
Eine Funktionsschnittstelle ist die formale Beschreibung aller in eine Funktion ein-
gehenden und aus der Funktion herauskommenden Datentypen. Die Schnittstelle
umfasst den Namen der Funktion, den Typ und die Reihenfolge der Parameter und
den Rückgabetyp. Die Namen, über die auf die Funktion bzw. auf die Parameter der
Funktion zugegriffen wird, gehören im engeren Sinn nicht zur Schnittstelle.
Eine Funktion, die einen String und ein Zeichen übergeben bekommt und berechnet,
wie oft das Zeichen in dem String vorkommt, hat die folgende Schnittstelle:
621
18 Zusammenfassung und Ergänzung
Wenn man die Funktion zaehlezeichen, den eingehenden String s und das einge-
hende Zeichen c nennt, ergibt sich die folgende Funktionsdeklaration, die insbeson-
dere die Schnittstelle festlegt:
int zaehlezeichen( char *s, char c);
Als Datentypen sind alle verfügbaren Grunddatentypen (int, float, ...), aber auch
Zeiger, Arrays und Datenstrukturen möglich.
Funktionszeiger
Funktionen haben eine Adresse. Diese Adresse kann einer Variablen zugewiesen wer-
den. Eine Variable, die die Adresse einer Funktion enthält, wird als Funktionszeiger
bezeichnet.
Damit ein Funktionszeiger konsistent verwendet werden kann, wird bei der Defini-
tion des Zeigers die Schnittstelle der zu referenzierenden Funktion angegeben:
A int (*fz1)();
B void (*fz2)(int);
fz1 ist ein Zeiger auf eine parameterlose Funktion, die einen int-Wert zurückgibt (A).
fz2 ist ein Zeiger auf eine Funktion, die einen int-Wert erhält und keinen Wert
zurückgibt (B).
fz3 ist ein Zeiger auf eine Funktion, die einen Pointer auf char und einen int-Wert
erhält und einen float-Wert zurückgibt (C).
Einem Funktionszeiger kann die Adresse einer beliebigen Funktion zugewiesen wer-
den, deren Schnittstelle mit der bei der Definition des Zeigers angegebenen Schnitt-
stelle übereinstimmt. Über den Funktionszeiger kann die referenzierte Funktion
aufgerufen werden (siehe Abbildung 18.27).
und
z = (*funktionszeiger)( 1.2, 2)
622
Funktionszeiger
void berechne( float *dat, int anz, float x, float fkt( float, int))
{
int i; eine Funktion, die zu einer Funktion
fkt eine Funktionstabelle erstellt
for( i = 0; i < anz; i++)
dat[i] = fkt( x, i); Es werden die Werte
} fkt(x,0)
fkt(x, 1)
void main() …
{ fkt( x, anz-1)
float daten[10]; in den Array dat eingetragen.
int i;
Berechnen der Funktionstabelle für die
berechne( daten, 10, 1.2, potenz); Funktion potenz.
0: 1.000000
1: 1.200000
for( i = 0; i < 10; i++) 2: 1.440000
printf( "%d: %f\n", i, daten[i]); 3: 1.728000
4: 2.073600
} 5: 2.488320
6: 2.985985
7: 3.583182
8: 4.299818
9: 5.159782
623
18 Zusammenfassung und Ergänzung
Ganze Zahlen
Bei der Darstellung ganzer Zahlen unterscheiden wir die Dezimal-, Hexadezimal- und
Oktaldarstellung.
왘 Die Dezimaldarstellung beginnt mit einer Ziffer von 1 bis 9, optional gefolgt von
weiteren Ziffern zwischen 0 und 9.
왘 Die Oktaldarstellung beginnt mit 0, optional gefolgt von weiteren Ziffern zwi-
schen 0 und 7.
왘 Die Hexadezimaldarstellung beginnt mit 0x, gefolgt von weiteren Ziffern zwi-
schen 0 und 9 sowie zwischen a und f bzw. A und F.
In allen drei Systemen kann als Vorzeichen + oder – vorangestellt werden, obwohl ein
negatives Vorzeichen in der Hexadezimal- und Oktaldarstellung unüblich ist.
An die Zahlen kann ein Suffix (l, L, ll, LL, Ll und lL) angefügt werden. Dieses Suffix
zeigt an, dass es sich um long- bzw. long long-Zahlen handelt.
An die Zahlen kann ein weiteres Suffix (u, U) angefügt werden. Dieses Suffix zeigt an,
dass es sich um eine vorzeichenlose Zahl (unsigned) handelt. Dieses Suffix ist natür-
lich, obwohl von vielen Compilern akzeptiert, mit dem negativen Vorzeichen unver-
einbar.
Damit ergibt sich ein breites Spektrum möglicher Darstellungen. Zum Beispiel:
123
–123
0123
0xAffe
123L
–123UL
0123lu
0x1ae7LL
0xEdel
624
Gleitkommazahlen
Ganze Zahl
Okt
0-7
Suffix
Vorzeichen Dez
0-9 l,ll,lL,Ll,LL u,U
+
1-9
-
u,U l,ll,lL,Ll,LL
0-9,a-f,A-f
0x
Hex
Gleitkommazahlen
Gleitkommazahlen gibt es nur in Dezimaldarstellung. Diese Darstellung entspricht
der bekannten Darstellung für wissenschaftliche Taschenrechner. Deshalb nur ein
paar Beispiele:
1.23 18
0.123
.123
–1.234
-.123
1.2345e6
–12.34E-12
1e23
Gleitkommazahlen können ein Suffix haben. Möglich sind hier f bzw. F und l bzw. L.
Damit zeigen Sie an, dass es sich um eine float- (f, F) bzw. long double-Zahl (l, L) han-
delt. Geben Sie kein Suffix an, ist die Zahl vom Typ double:
1.23f
0.123l
–12.34E-12F
1e23L
625
18 Zusammenfassung und Ergänzung
goto
Mit goto können Sprünge innerhalb eines Programms realisiert werden. Dazu defi-
nieren Sie zunächst ein Label, das später angesprungen werden kann:
Anfang
1
2
3
4
int i = 1; 5
6
7
printf( "Anfang\n"); 8
9
weiter: Ende
Hier wird ein Label definiert. Ein Label
dient als mögliches Sprungziel für eine
printf( "%d\n", i);
goto-Anweisung
i++;
Das Beispiel zeigt, dass man mit Sprunganweisungen problemlos eine Schleife nach-
bilden kann. Das geht allerdings zu Lasten der Lesbarkeit des Programms. Sprungan-
weisungen werden daher in höheren Programmiersprachen meist nur unter großen
Vorbehalten verwendet, da sie einen unübersichtlichen Kontrollfluss (sogenannten
Spaghetti-Code) erzeugen können3 und die Gefahr, besteht, dass man im Gestrüpp
des Kontrollflusses die Orientierung verliert. Darum wird allgemein davon abgera-
ten, goto zu verwenden.
Sprünge können vorwärts und rückwärts gerichtet sein, und ein Label kann von
unterschiedlichen Stellen aus angesprungen werden. Sprünge können aber immer
nur auf einer Funktionsebene durchgeführt werden. Das heißt, es ist nicht möglich,
aus einer Funktion zu einem Label in einer anderen Funktion zu springen, auch nicht
aus einer gerufenen Funktion zurück in die aufrufende Funktion.
3 Googlen Sie dazu den Artikel »Go-to statement considered harmful« von E. W. Dijkstra.
626
Header-Datei
Bei der Verwendung von goto sollten Sie sich strenge Selbstkontrollen auferlegen.
Versuchen Sie zunächst immer, goto zu vermeiden! Benutzen Sie goto nur, wenn es
keine sinnvolle Variante ohne goto gibt! Vermeiden Sie es auf jeden Fall, mit goto in
einen undefinierten Kontext (z. B. in einen Schleifenkörper) zu springen!
Hauptprogramm
Das Hauptprogramm ist der Einstiegspunkt in ein C-Programm. Hier startet der Kon-
trollfluss. Das Hauptprogramm wird mit main bezeichnet.
Hexadezimaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
Header-Datei
Ein C-Programm besteht aus Quellcodedateien und Header-Dateien. Header-Dateien
erkennen Sie an der Namenserweiterung .h. Eine Header-Datei enthält nur Elemente,
die nicht »raumgreifend« sind. Darunter werden Elemente verstanden, die vom
Compiler zur Erzeugung des Codes benötigt werden, aber nicht in konkreten Code 18
übersetzt werden. Ein typisches Beispiel für solche Elemente sind Funktionsprototy-
pen, die vom Compiler benötigt werden, um zu überprüfen, ob Funktionsschnittstel-
len korrekt implementiert und verwendet werden.
왘 Präprozessor-Direktiven
– Includes
– Compile-Schalter
– symbolische Konstanten
– Makros
왘 Deklarationen
– Externverweise auf statische Variablen und Konstanten
– Funktionsprototypen
– Datenstrukturen und Typvereinbarungen
627
18 Zusammenfassung und Ergänzung
Typischerweise enthält eine Header-Datei nur Elemente, die in mehr als einer Quell-
codedatei benötigt werden. Elemente, die nur in einer Quellcodedatei benötigt wer-
den, stehen üblicherweise in dieser einen Quellcodedatei.
Kontrollstrukturen
Zur Steuerung des Kontrollflusses gibt es in C:
왘 Fallunterscheidungen
왘 Schleifen
왘 die goto-Anweisung
Es gibt drei verschiedene Schleifenkonstrukte (for, while und do-while), von denen
for sicherlich das wichtigste ist (siehe Abbildung 18.32).
Darüber hinaus gibt es die goto-Anweisung, mit der man einen beliebig komplexen
Kontrollfluss modellieren kann, die aber zu Recht in der strukturierten Programmie-
rung nur in Ausnahmefällen verwendet wird (siehe Abbildung 18.33).
628
Identifier
label:
...
...
...
if(...)
goto label;
Zu den Kontrollstrukturen finden Sie weitere Hinweise unter den Stichworten if,
switch, for, do, while und goto.
18
Identifier
Identifier verwendet der Programmierer, um etwas eindeutig zu benennen, damit er
später, gegebenenfalls in einem anderen Zusammenhang, darauf Bezug nehmen
kann. Im Einzelnen handelt es sich um:
왘 Variablennamen
왘 Namen für Funktionen und Funktionsparameter
왘 Namen für Datenstrukturen (struct und union) oder Felder in Datenstrukturen
왘 Namen für eigendefinierte Datentypen (typedef)
왘 Namen für Aufzählungstypen und deren mögliche Werte (enum)
왘 Namen für symbolische Konstanten
왘 Namen für Makros und Makroparameter
왘 Namen für Sprungziele (goto)
629
18 Zusammenfassung und Ergänzung
C ist case-sensitiv. Das bedeutet, dass in C immer zwischen Groß- und Kleinschrei-
bung unterschieden wird.
if
Fallunterscheidungen werden durch eine if-Anweisung programmiert.
Als Bedingung kann ein beliebiger Ausdruck, der zu einem Wert ausgewertet werden
kann, verwendet werden. Ergibt sich bei der Auswertung ein von 0 verschiedener
Wert, gilt der Ausdruck als »wahr«, und die Anweisungen unter dem if werden aus-
geführt. Ergibt sich der Wert 0, gilt der Ausdruck als »falsch«, und die Anweisungen
unter einem gegebenenfalls vorhandenen else kommen zur Ausführung.
Bei der Verwendung von if-else gibt es einen möglichen Zuordnungskonflikt. Die
Frage ist, welchem if ein else zugeordnet werden soll, wenn dies nicht aufgrund von
Klammersetzungen eindeutig erkennbar ist (vgl. Abbildung 18.35):
630
Include-Anweisung
if( ...)
{
Zu welchem if gehört dieses else? if( ...)
...;
}
else
if( ...) ...;
if( ...)
...;
else
...; if( ...)
{
if( ...)
...;
else
...;
}
Die Regel besagt, dass ein else in dieser Situation (dangling else) dem nächsten darü-
berstehenden if zuzuordnen ist, das noch kein else zugeordnet hat. In unserem Bei-
spiel ist also die zweite Interpretation korrekt. Vermeiden Sie solche Situationen und
die damit verbundenen Verständnisschwierigkeiten dadurch, dass Sie geschweifte
Klammern setzten.
18
Include-Anweisung
Bei einer Include-Anweisung handelt es sich um eine Präprozessor-Direktive der
Form
# include <dateiname>
oder
# include "dateiname"
Diese Anweisung veranlasst den Präprozessor, die angegebene Datei anstelle der
Include-Anweisung in den Programmtext einzuschleusen. Um die Auswirkung die-
ser Anweisung zu verstehen, müssen Sie sich nur vorstellen, dass der Inhalt der ange-
sprochenen Datei anstelle der Include-Anweisung stehen würde. Abbildung 18.36
verdeutlicht den Lesefluss des Compilers für zwei eingebundene Header-Dateien.
631
18 Zusammenfassung und Ergänzung
// Quellcodedatei
…
…
# include "header1.h
…
…
… // header1.h
… …
… …
… # include "header2.h
… …
…
…
// header2.h
…
…
…
# include ".../test/include/abc.h"
# include "C:/uvw/xyz/abc.h"
Inkludierte Dateien können ihrerseits wieder Dateien inkludieren. Dabei müssen Sie
darauf achten, dass keine Zyklen entstehen, die zu einem endlosen Einlagerungspro-
zess führen würden. Zyklen können durch Compile-Schalter (siehe Abschnitt
»Compile-Schalter«) verhindert werden. Eine Header-Datei, z. B. abc.h, versehen Sie
632
Logische Operatoren ( !, &&, ||)
dazu mit einem eindeutigen Compile-Schalter, den Sie z. B. aus dem Dateinamen
erzeugen:
ifndef ABC_H
# define ABC_H
...
# endif
Wird diese Datei dann innerhalb eines Compiler-Laufs erstmalig inkludiert, wird der
Compile-Schalter gesetzt. Bei weiteren Includes dieser Datei innerhalb desselben
Compiler-Laufs ist der Compile-Schalter dann gesetzt, und die Datei wird ausge-
blendet.
int
Der Datentyp int bezeichnet eine »normale« vorzeichenbehaftete ganze Zahl. Siehe
Abschnitt »Datentypen für ganze Zahlen«.
In klassischem C gibt es keine Wahrheitswerte wie »true« und »false«. An die Stelle
von »true« und »false« treten numerische Werte. Alles, was ungleich 0 ist, wird als
wahr und alles, was 0 ist, als falsch verstanden.
633
18 Zusammenfassung und Ergänzung
a !a a b a&&b a b a||b
0 1 0 0 0 0 0 0
≠0 0 0 ≠0 0 0 ≠0 1
≠0 0 0 ≠0 0 1
≠0 ≠0 1 ≠0 ≠0 1
Abbildung 18.37 Die Wahrheitstafeln der drei logischen Operatoren
Mit diesen Operatoren können beliebige logische Ausdrücke gebildet werden, die
dann z. B. als Bedingung oder Test in Fallunterscheidungen bzw. Schleifen Verwen-
dung finden.
Bei der Auswertung logischer Ausdrücke wird die sogenannte Shortcut Evaluation
durchgeführt. Das bedeutet, dass Terme, die als irrelevant für das Endergebnis
erkannt werden, nicht weiter ausgewertet werden. Wenn z. B. ein Teilausdruck
bereits als wahr erkannt wurde, ist es nicht notwendig, weitere mit »oder« ver-
knüpfte Ausdrücke zu betrachten, da der Gesamtausdruck nicht »wahrer als wahr«
werden kann. Wenn man einen Teilausdruck als falsch erkannt hat, ist es nicht not-
wendig, weitere mit »und« verknüpfte Ausdrücke zu betrachten, da der Gesamtaus-
druck nicht »falscher als falsch« werden kann. Diese Art der Auswertung verbessert
das Laufzeitverhalten und ist unproblematisch, solange in den nicht ausgewerteten
Formelbestandteilen keine Seiteneffekte verborgen sind. In Abbildung 18.38 finden
Sie ein Beispiel dazu:
634
main
long
Der Datentyp long bezeichnet eine »große« vorzeichenbehaftete ganze Zahl.
Lesen Sie dazu auch den Abschnitt »Datentypen für ganze Zahlen«.
long double
Bei long double handelt es sich um einen Datentyp für Gleitkommazahlen besonders
großer Genauigkeit.
main
Das Hauptprogramm trägt in C den Namen main. In der einfachsten Form sieht das
Hauptprogramm wie folgt aus:
void main()
{
... 18
}
Ein C-Compiler akzeptiert ein Hauptprogramm in der oben dargestellten Form, aber
der Standard sieht für das Hauptprogramm eine andere Schnittstelle vor, da das
Hauptprogramm über Aufrufparameter und einen Rückgabewert mit dem Laufzeit-
system verzahnt werden kann (siehe Abbildung 18.39).
Das Hauptprogramm hat zwei Eingabeparameter und einen Rückgabewert. Der erste
Eingabeparameter sagt, mit wie vielen Parametern das Hauptprogramm gerufen
wurde, wobei der Programmname (hier meinprogramm) als nullter Parameter mit zu
den Aufrufparametern zählt. Danach folgen die eigentlichen Parameter (hier eins,
zwei, drei), die der Benutzer beim Aufruf hinzugefügt hat. Das Programm erhält diese
Parameter als ein Array von Strings. Der normale Rückgabewert im Falle eines erfolg-
reichen Programmlaufs ist 0. Andere Rückgabewerte signalisieren spezielle Fehler
und sind von System zu System verschieden.
635
18 Zusammenfassung und Ergänzung
Programmaufruf:
Standardschnittstelle des
meinprogramm eins zwei drei
Hauptprogramms
Makros
Makros definieren – wie symbolische Konstanten – Textersetzungen, die durchge-
führt werden, bevor der Compiler den Quellcode übersetzt. Die Ersetzungen können
zusätzlich durch Parameter gesteuert werden.
Bei der Auflösung von Makros findet eine reine Textersetzung statt. Es werden keine
Ausdrücke ausgewertet oder vereinfacht. Das kann zu ineffizientem Code oder sogar
zu unerwünschten Berechnungen führen.
636
Makros
Setzen Sie daher immer Klammern um die Parameter, da Sie nicht wissen, was als
Parameter übergeben wird. Setzen Sie ebenfalls Klammern um den gesamten Aus-
druck, da Sie nicht wissen, in welchem Kontext der Ausdruck aufgelöst wird:
Bei Seiteneffekten (wie im Beispiel ++a) schützt auch die Klammersetzung nicht vor
Fehlberechnungen. Vermeiden Sie daher Seiteneffekte in Makros.
Als Parameter können beliebige Texte, also nicht nur Zahlen, übergeben werden.
Wichtig ist nur, dass nach der Übersetzung durch den Präprozessor gültiger C-Quell-
code entstanden ist.
637
18 Zusammenfassung und Ergänzung
void main()
{
char *s, *q;
Ein Makro kann eine variable Anzahl von Parametern haben, auf die dann kumulativ
mit __VA_ARGS__ Bezug genommen werden kann. Zum Beispiel kann das folgende
Makro PRINTF wie eine Bildschirmausgabe mit printf verwendet werden, stellt aber
jeder Ausgabe einen Zeilenvorschub und den Text »Ausgabe: « voran:
Makrodefinitionen können sich über mehrere Zeilen erstrecken, wenn Sie mit Back-
slash (\) am Ende einer Zeile eine Folgezeile anfügen. Makrodefinitionen können mit
"# undef <makroname>" zurückgenommen werden.
Modulo-Operation
Die Modulo-Operation liefert den Rest bei der Division zweier ganzer Zahlen und
wird in C durch den Modulo-Operator (%) durchgeführt. Die Modulo-Operation
gehört zu den arithmetischen Operationen und ist eine Rechenoperation, die in der
Informatik genauso wichtig wie Addition, Subtraktion, Multiplikation und Division
ist. Insbesondere in der Kryptologie, also bei der Ver- und Entschlüsselung von
Daten, ist diese Operation unverzichtbar.
638
Operatoren
Oktaldarstellung
Siehe Abschnitt »Ganze Zahlen«.
Operatoren
Operationen, wie z. B. die Addition, sind eigentlich nur spezielle Funktionen. Die
naheliegende Schreibweise dafür ist die Funktionsschreibweise:
z = pus( x, y)
Dabei ist plus der Operator, x und y sind seine Operanden, und z ist das Ergebnis der
Operation. Anstelle der Funktionsschreibweise verwendet man die Operatorschreib-
weise
z = x plus y
Bei Operatoren gleicher Priorität sind Sie jedoch bei vielen Operatoren nach wie vor
auf Klammern angewiesen.
639
18 Zusammenfassung und Ergänzung
Möchten Sie hier Klammern sparen, müssen Sie eine Auswertungsreihenfolge (von
links nach rechts oder von rechts nach links) festlegen. Legen Sie für den Divisionso-
perator eine Auswertung von links nach rechts fest, können Sie anstelle von (a/b)/c
auch a/b/c schreiben. Bei a/(b/c) sind die Klammern nach wie vor erforderlich. Bei
einer Auflösung von links nach rechts sprechen wir von Linksassoziativität, im
umgekehrten Fall von Rechtsassoziativität.
Wenn Sie ein formales Gebäude an Operatoren für eine Programmiersprache errich-
ten möchten, benötigen Sie also für jeden Operator die folgenden Informationen:
왘 Operatorzeichen (z. B. +, *, /)
왘 Stelligkeit (1, 2 oder 3)
왘 Notation (Infix, Präfix oder Postfix)
왘 Priorität im Vergleich zu anderen Operatoren
왘 Assoziativität (Links- oder Rechtsassoziativität)5
5 Beachten Sie, dass durch Assoziativität und Priorität nicht festgelegt ist, zu welchem Zeitpunkt
ein Teil eines Ausdrucks auf dem Rechner wirklich ausgewertet wird. Im Ausdruck 1*2-3*4-5*6
ist zwar festgelegt, dass die Subtraktionen von links nach rechts ausgeführt werden und die Mul-
tiplikationsergebnisse vorliegen müssen, bevor sie in einer Subtraktion verwendet werden. Es ist
aber nicht festgelegt, dass 1*2 vor 5*6 ausgerechnet werden muss. Das ist unkritisch, solange
keine »Seiteneffekte« in den Formeln vorkommen. Ein Seiteneffekt ist z. B. gegeben, wenn die
Auswertung eines Teils eines Ausdrucks Einfluss auf die Werte anderer Teile des Ausdrucks hat.
Mit einigen der im Folgenden diskutierten Operatoren (z. B. ++-Operator) können Sie leicht sol-
che Seiteneffekte erzeugen. Dann ist höchste Vorsicht geboten.
640
Operatoren
Bevor wir Ihnen die verfügbaren Operatoren im Einzelnen vorstellen, erhalten Sie
zunächst einen Überblick über das Gesamtgebäude:
. a.x Strukturzugriff
~ ~x bitweises Bitoperator
Komplement
+ +x plus x arithmetischer
Operator
- -x minus x 18
* *p Dereferenzierung Zugriffsoperator
641
18 Zusammenfassung und Ergänzung
/= x/=y
%= x%=y
&= x&=y
^= x^=y
|= x|=y
<<= x<<=y
>>= x>>=y
642
Operatoren
Wichtig zur Arbeit mit der Tabelle ist noch die folgende Leseanleitung:
왘 Die Operatoren werden hier mit Werten von 1 bis 15 priorisiert. Operatoren mit
höherer Priorität binden dabei stärker als Operatoren niedriger Priorität und wer-
den in Formelausdrücken vorrangig ausgewertet.
a && b == c && d
18
als
a && (b == c) && d
zu lesen ist. Dies ist sicher gewöhnungsbedürftig und entspricht nicht der Auswer-
tungsreihenfolge, die Sie von der Aussagenlogik her kennen und hier nur durch ent-
sprechende Klammersetzung
(a && b) == (c && d)
643
18 Zusammenfassung und Ergänzung
왘 arithmetische Operatoren
왘 Bitoperatoren
왘 logische Operatoren
왘 Vergleichsoperatoren
왘 Zugriffsoperatoren
왘 Zuweisungsoperatoren
왘 Funktionsaufruf
왘 Bedingte Auswertung
왘 Sequenzielle Auswertung (Komma-Operator)
왘 Cast-Operator
왘 sizeof
Präprozessor
Der Präprozessor stellt eine Vorverarbeitungsstufe zur eigentlichen Quellcodeüber-
setzung durch Compiler und Linker dar. Auf dieser Vorverarbeitungsstufe werden
elementare Texteinblendungen, -ausblendungen oder -ersetzungen durchgeführt.
Der Präprozessor wird durch sogenannte Präprozessor-Direktiven gesteuert. Solche
Direktiven befinden sich typischerweise in Header-Dateien oder am Anfang von
Quellcodedateien und wirken dann auf den nachfolgenden Text.
왘 Include-Anweisungen
왘 symbolische Konstanten
왘 Makros
왘 Compile-Schalter
Zu jedem dieser Begriffe finden Sie einen eigenen Abschnitt in dieser Zusammen-
fassung.
644
register
Quellcodedatei
Ein C-Programm besteht aus Quellcodedateien und Header-Dateien. Quellcodeda-
teien erkennen Sie an der Namenserweiterung .c (oder .cpp, falls es sich um C++-
Quellcodedateien handelt). Quellcodedateien können alle Elemente aus C enthalten.
Diese sind:
왘 Präprozessor-Direktiven
– Includes
– Compile-Schalter
– symbolische Konstanten
– Makros
왘 Deklarationen
– Externverweise auf statische Variablen und Konstanten
– Funktionsprototypen
– Datenstrukturen und Typvereinbarungen
왘 Definitionen
– statische Variablen und Konstanten
– Funktionen
Zu einem vollständigen Programm gehört immer eine Quellcodedatei, die eine Funk-
tion mit dem Namen main enthält. In dieser Funktion startet der Kontrollfluss des
Programms.
register
Der Zusatz register kann vor der Definition automatischer Variablen stehen.
register int a;
Diese Anweisung weist der Variablen die Speicherklasse register zu. Es handelt es
sich dabei um eine Empfehlung an den Compiler, diese Variable in ein internes Regis-
ter des Prozessors zu legen, damit mit dem Wert sehr effizient umgegangen werden
kann. Der Compiler folgt dieser Empfehlung allerdings nur, wenn es ihm möglich ist.
645
18 Zusammenfassung und Ergänzung
Wenn überhaupt, dann sollten Sie register nur mit Integer-Variablen verwenden, da
das die typischen Registerinhalte sind. Die Speicherklasse register ist unvereinbar
mit statischen oder globalen Variablen, da Variablen immer nur kurzfristig in Prozes-
sorregistern gespeichert werden sollten. Eine Registervariable hat auch keine
Adresse, da sie ja nicht im adressierbaren Speicher liegt. Wenn Sie im Code die
Adresse einer Variablen verwenden, wird der Compiler eine register-Anweisung für
diese Variable ignorieren.
Rekursion
Rekursion ist eine Programmiertechnik, bei der sich eine Funktion unmittelbar oder
mittelbar selbst aufruft. C unterstützt, wie die meisten höheren Programmierspra-
chen, diese Technik.
Rekursion kann immer dann verwendet werden, wenn man ein Problem auf ein oder
mehrere kleinere Probleme der gleichen Art zurückführen kann.
In der in Abbildung 18.46 dargestellten Funktion reverse wird die Reihenfolge der
Elemente eines Arrays umgekehrt:
Kehre die Reihenfolge der Zahlen im Array um. void reverse( int von, int bis, int *daten)
{
int t;
void main()
{
int zahlen[10] = {0,1,2,3,4,5,6,7,8,9};
reverse( 0, 9, zahlen);
}
Es ist wichtig, ein Abbruchkriterium für die Rekursion zu finden. Im Beispiel oben ist
das sehr einfach. Es muss weitergemacht werden, solange von kleiner als bis ist.
646
return
Mit Rekursion lassen sich komplexe Probleme manchmal sehr einfach und elegant
lösen. Dazu sollten Sie aber zwei grundsätzliche Hinweise im Hinterkopf behalten:
왘 Rekursion ist niemals zwingend erforderlich, da man Rekursion immer durch ein
iteratives Vorgehen ersetzen kann.
왘 Rekursive Lösungen eines Problems sind langsamer als gut optimierte iterative
Lösungen des gleichen Problems.
return
Mit einer return-Anweisung kann unmittelbar aus einem Unterprogramm in das
aufrufende Programm zurückgesprungen werden. Dabei kann ein Rückgabewert an
das rufende Programm übergeben werden.
Wenn eine Funktion einen Returntyp hat, muss jeder mögliche Ausführungspfad der
Funktion mit einer return-Anweisung mit Rückgabe eines Returnwerts enden:
int funktion()
{ 18
int x; Rücksprung mit Wert 0
...
if( ...)
return 0;
...
return x+1;
}
Rücksprung mit Ausdruck
Der Returnwert kann eine Konstante, eine Variable oder ein Ausdruck sein. Wichtig
ist, dass sich nach Auswertung des Ausdrucks ein Wert ergibt, dessen Datentyp
»kompatibel« mit dem geforderten Rückgabetyp ist. So kann in einer float-Funktion
durchaus ein int-Wert zurückgegeben werden, da eine implizite Konvertierung von
int in float möglich ist. Umgekehrt ist das nicht ohne Datenverlust möglich, daher
kann in einer int-Funktion nicht ein float-Wert zurückgegeben werden. Auch wenn
nicht jeder Compiler das als Fehler ansieht, wird zumindest auf den möglichen
Datenverlust hingewiesen.
647
18 Zusammenfassung und Ergänzung
void funktion()
{
... Expliziter Rücksprung
if( ...)
return;
...
...
}
Impliziter Rücksprung
In jedem Fall erfolgt bei einer return-Anweisung der sofortige Rücksprung in das
rufende Programm. return-Anweisungen im Inneren einer Funktion stehen daher
immer unter einer Bedingung, da ansonsten der nachfolgende Code niemals erreicht
würde. Einzig am Ende einer Funktion kann ein unbedingtes return stehen.
Schlüsselwörter
In jeder Programmiersprache gibt es eine Reihe reservierter Wörter. Diese auch als
Schlüsselwörter oder Keywords bezeichneten Wörter haben eine genau definierte
Bedeutung und dürfen nur in dieser Bedeutung verwendet werden. Schlüsselwörter
dürfen Sie z. B. nicht als Variablennamen oder Funktionsnamen verwenden.
648
Sequenzielle Auswertung (Komma-Operator)
do if static while
Man kann aber auch das Ergebnis einer Sequenz zuweisen und weiterverarbeiten: 18
int ergebnis;
In diesem Beispiel wird der Variablen ergebnis der Wert des zuletzt ausgewerteten
Ausdrucks zugewiesen. Der Wert von ergebnis ist also 3.
ergebnis = 1, 2, 3;
ist wegen der höheren Priorität des Zuweisungsoperators gegenüber dem Komma-
Operator das Ergebnis der Zuweisung 1.
649
18 Zusammenfassung und Ergänzung
short
Der Datentyp short bezeichnet eine »kleine« vorzeichenbehaftete ganze Zahl, die
von der Größe zwischen char und int einzuordnen ist.
Bezüglich der Anzahl der Bytes und des Rechenbereichs legt sich der Standard nicht
fest. Typischerweise belegt der Datentyp 2 Bytes und kann damit Zahlen zwischen
–215 = –32768 und 215 – 1 = 32767 darstellen.
signed
Das Schlüsselwort signed kann Integer-Datentypen vorangestellt werden, um festzu-
legen, dass es sich um einen vorzeichenbehafteten Datentyp handelt.
Schauen Sie sich dazu ebenfalls den Abschnitt »Datentypen für ganze Zahlen« an.
sizeof
Der sizeof-Operator berechnet die Größe eines Datentyps, wobei unter der Größe
die Anzahl der Bytes zu verstehen ist, die der Datentyp im Speicher belegt. Der
sizeof-Operator kann auf einen Datentyp oder eine Variable eines Datentyps ange-
wandt werden:
struct b
{
int x;
double d;
};
Dieses Beispiel zeigt die interne Ausrichtung der Daten im Speicher (Alignment). Da
das double-Feld in der Datenstruktur auf eine durch 8 teilbare Adresse ausgerichtet
650
Speicherallokation
wird, entsteht zwischen x und d eine Lücke von 4 Bytes, sodass die Struktur insge-
samt 16 Bytes groß ist. Das zeigt, dass Sie die Größe von Datenstrukturen nicht selbst
berechnen sollten, da die Größe der Grunddatentypen (int, float) maschinenabhän-
gig ist und sich auch die Größe zusammengesetzter Typen nicht ohne Weiteres aus
der Größe der Grunddatentypen errechnen lässt. Überlassen Sie es immer dem Com-
piler, zu berechnen, wie groß die von ihm erzeugten Datenstrukturen sind.
Angewandt auf ein Array, liefert der sizeof-Operator die Anzahl der Bytes in einem
Array. Damit können Sie die Anzahl der Elemente in einem Array berechnen:
Verwechseln Sie den sizeof-Operator nicht mit der strlen-Funktion für Strings.
Betrachten Sie dazu das folgende Beispiel:
char *p = "Programmierung";
Im ersten Fall wird der Speicherplatz, den der String "Programmierung" belegt, berech-
net. Das sind einschließlich des Terminatorzeichens 15 Bytes. Im zweiten Fall wird der 18
Speicherplatz berechnet, den der Zeiger p belegt. Das sind 4 Bytes. Die strlen-Funk-
tion berechnet in jedem Fall die Länge des gegebenen Strings ohne das Termina-
torzeichen, und das sind 14 Bytes.
Speicherallokation
Wenn ein Programm zur Laufzeit Speicher benötigt, kann es diesen mit Funktionen
wie malloc und calloc vom Laufzeitsystem anfordern. Von diesen Funktionen erhält
das Programm die Adresse des reservierten (allokierten) Speichers, die dann übli-
cherweise einem Zeiger zugewiesen wird, damit über den Zeiger auf den Speicher
zugegriffen werden kann. Nicht mehr benötigter Speicher sollte mit der Funktion
free freigegeben (deallokiert) werden, damit er vom Laufzeitsystem erneut dispo-
niert werden kann.
Im Zusammenhang mit dem Allokieren und Deallokieren von Speicher gibt es die
folgenden Funktionen:
651
18 Zusammenfassung und Ergänzung
Funktion Beschreibung
free Gibt den mit malloc, calloc oder realloc allokierten Speicher frei.
Der Rückgabewert von malloc, calloc und realloc ist NULL, wenn die Anforderung
mangels Speichers nicht ausgeführt werden kann.
In Abbildung 18.49 sehen Sie ein Beispiel für die Allokation einer Datenstruktur mit
abschließender Freigabe:
ptr->wert1 = 123;
ptr->wert2 = 4.56; Allokieren des Speichers.
ptr->txt[0] = 'A';
ptr->txt[1] = 'B'; Verwenden des Speichers.
ptr->txt[2] = 0;
free( ptr);
Freigabe des Speichers. Daten: 123, 4.560000, AB
652
Speicherallokation
int *ptr;
int i; Speicher für 10 int-Werte allokieren
Typkonvertierung
Beispiel für die systematische Vergrößerung eines Puffers mit abschließender Frei-
gabe:
char *ptr = 0;
Aktuelle Puffergröße
int size = 0;
char c = 0; Eingabezeichen
653
18 Zusammenfassung und Ergänzung
왘 stdin
왘 stdout
왘 stderr
Der Stream stdin ist zum Lesen geöffnet und mit dem Eingabemedium (Tastatur) des
Computers verbunden. Alle Tastatureingaben des Benutzers landen in diesem
Stream und können vom Programm von dort mit entsprechenden Dateioperationen
gelesen werden.
Der Stream stdout ist zum Schreiben geöffnet und mit dem Ausgabemedium (Bild-
schirm) des Computers verbunden. Alle Bildschirmausgaben des Programms landen
in diesem Stream und werden anschließend auf dem Bildschirm dargestellt.
Der Stream stderr ist wie stdout zum Schreiben geöffnet und mit dem Bildschirm
verbunden. Alle Fehlerausgaben des Programms landen in diesem Stream.
Nach der Umlenkung von stdout in die Datei ausgabe.txt erscheinen die Ausgaben
nicht mehr auf dem Bildschirm, sondern werden in die festgelegte Datei geschrieben.
654
static-Funktion
printf("Test...Test...Test\n");
Auf die Standardstreams können Sie im Prinzip die gleichen Funktionen anwenden
wie auf Dateien. Dateien sind aus Sicht eines C-Programms letztlich auch Streams. So
macht es keinen Unterschied, ob Sie
printf( "Ausgabe\n");
oder
18
schreiben. Es ergibt sich die gleiche Ausgabe.
static-Funktion
Vor einer Funktion bedeutet der Zusatz static, dass diese Funktion nur in der Com-
pilationseinheit (= Quellcodedatei), in der sie steht, bekannt ist und verwendet wer-
den kann.
Durch den Zusatz static kann man vermeiden, dass es Namenskonflikte zwischen
zufällig gleich benannten Funktionen in verschiedenen Quellcodedateien gibt. Funk-
tionen, die ausschließlich als Hilfsfunktionen innerhalb eines Moduls verwendet
werden, die also nicht aus anderen Modulen heraus gerufen werden, sollten static
sein.
655
18 Zusammenfassung und Ergänzung
static-Variable
Vor einer Variablen außerhalb von Funktionen bedeutet static, dass die Variable wie
eine globale Variable bei Programmstart angelegt und gegebenenfalls initialisiert
wird und dann über die gesamte Programmlaufzeit verfügbar ist. Im Gegensatz zu
einer globalen Variablen ist die Variable aber nur in der Compilationseinheit
(= Quellcodedatei), in der sie steht, bekannt und kann auch nur dort verwendet wer-
den.
Vor einer Variablen innerhalb einer Funktion oder eines Blocks bedeutet static, dass
die Variable beim erstmaligen Eintritt in die Funktion/den Block erzeugt und gegebe-
nenfalls initialisiert wird und dann bei allen weiteren Eintritten in die Funktion/den
Block mit dem zuletzt gesetzten Wert verfügbar ist. Die Variable ist dabei nur inner-
halb der Funktion/des Blocks bekannt und kann auch nur dort verwendet werden.
void function()
{
static int zahl1;
static double zahl2 = 1.234;
...
}
Eine solche Variable ist also wie eine globale Variable, deren Sichtbarkeit und Ver-
wendbarkeit auf eine einzelne Funktion bzw. einen einzelnen Block beschränkt ist.
struct
Mit struct definierte Datenstrukturen sind benutzerdefinierte Datentypen, die
durch Aggregation bestehender Datentypen erzeugt werden. Die einzelnen Bestand-
teile können dabei ihrerseits wieder folgende Elemente sein:
왘 Ganzzahlen
왘 Gleitkommazahlen
왘 Arrays
왘 Strukturen
왘 Unions
왘 Aufzählungstypen
왘 Bitfelder
왘 Zeiger
656
switch
왘 ein Name
왘 ein Datentyp und ein Name für jedes Element der Datenstruktur
Beispiel:
struct datum
{
int tag;
int monat;
int jahr;
};
struct person
{
char name[100];
char vorname[100];
struct datum geburtstag;
char familienstand;
float groesse;
};
struct verein
{
18
char name[100];
int anzahl_mitglieder;
struct person mitglieder[1000];
};
Eine Struktur ist nur eine Schablone für Daten. Die eigentlichen Daten werden durch
Definition von Variablen (siehe Abschnitt »Variablen«) angelegt. Auf die Felder einer
Datenstruktur wird dann mit speziellen Operatoren (siehe Abschnitt »Zugriffsopera-
toren ([], ->., ., *, &)«) zugegriffen.
switch
Bei switch handelt es sich um ein Kontrollkonstrukt, mit dem sogenannte
Sprungleisten realisiert werden. switch kann eingesetzt werden, wenn bei einer Ver-
zweigung mehrere, insbesondere mehr als zwei Fälle, zu betrachten sind:
657
18 Zusammenfassung und Ergänzung
switch( i)
{ Ist der Wert des obigen Ausdrucks 1
case-Label müssen case 1: wird hier hin gesprungen.
ganzahlige Konstante printf( "Eins\n");
Ausdrücke sein. break; break bricht die Behandlung ab.
case 2:
printf( "Gerade Primzahl\n");
break;
case 3: Ist der Wert 3, 5 oder 7 wird hier hin gesprungen.
case 5:
Label können case 7:
kaskadiert werden, printf( "Primzahl\n");
um mehrere Fälle break;
zusammenzufassen. case 4:
case 6: Wenn break fehlt, läuft
case 8: der Kontrollfluss in den
printf( "Gerade Zahl\n"); nächsten Fall, auch wenn
das Label dort nicht passt.
case 9:
printf( "Keine Primzahl\n");
break;
Ein default-Label
default:
erfasst alle Fälle, die
nicht durch andere printf( "Unbehandelte Zahl\n");
Label abgedeckt sind. break;
}
Die Verzweigung erfolgt bezüglich eines Ausdrucks mit ganzzahligem Wert. Dabei
kann es sich durchaus um einen komplexen Formelausdruck handeln. Je nach Wert
werden dann sogenannte case-Label angesprungen. Als case-Label sind beliebige
ganzzahlige konstante Ausdrücke zugelassen, also nicht nur Zahlen wie 1 oder 2, son-
dern auch Ausdrücke wie 'a' oder 1<<4. Natürlich darf kein Label mehrfach vor-
kommen.
Ein default-Label sammelt alle Fälle, die nicht durch andere Label abgedeckt sind. Ein
solches Label muss es nicht geben, und es muss auch nicht am Ende der Sprungleiste
stehen. Es ist aber guter Programmierstil, ein default-Label am Ende einer
Sprungleiste zu haben.
Beachten Sie die Bedeutung der break-Anweisung, die die Behandlung nicht nur
eines Falles, sondern der gesamten Fallunterscheidung abbricht. Fehlt die break-
Anweisung bei einem Fall, läuft der Kontrollfluss automatisch in den nächsten Fall
hinein. In der Regel ist dieses Verhalten nicht erwünscht, und Sie sollten daher
immer prüfen, ob Sie kein break vergessen haben. In speziellen Fällen ist das »feh-
658
Symbolische Konstanten
lende« break aber durchaus sinnvoll. Das break beim letzten Fall kann natürlich
immer weggelassen werden.
Wenn Sie das Beispiel oben fortlaufend mit i = 0, 1, 2, ... 10 durchlaufen, erhalten Sie
die folgende Ausgabe:
0:
Unbehandelte Zahl
1:
Eins
2:
Gerade Primzahl
3:
Primzahl
4:
Gerade Zahl
Keine Primzahl
5:
Primzahl
6:
Gerade Zahl
Keine Primzahl
7:
Primzahl
8:
Gerade Zahl
Keine Primzahl
9: 18
Keine Primzahl
10:
Unbehandelte Zahl
Beachten Sie, dass hier bei den Zahlen 4, 6 und 8 wegen des fehlenden break zwei Fälle
durchlaufen werden, was aber so gewollt ist, da diese Zahlen sowohl gerade als auch
keine Primzahlen sind.
Symbolische Konstanten
Symbolische Konstanten werden durch Präprozessor-Anweisungen der Form
# define <Name> <Wert>
659
18 Zusammenfassung und Ergänzung
ganzzahl a = 1;
int a = 1;
Nach dem Preprocessing entsteht gültiger C-Code, der problemlos übersetzt werden
kann.
Solche Spielereien werden Sie in seriösen C-Programmen nicht finden, aber das
Bespiel zeigt, dass nur eine Textersetzung durchgeführt wird. Einzig wichtig ist, dass
der Compiler nach der Textersetzung gültigen C-Code erhält.
# define ANZAHL 10
Die Größe des Arrays ist durch
int i; die symbolische Konstante
int daten[ANZAHL]; ANZAHL gegeben.
660
typedef
Es gibt fünf vordefinierte symbolische Konstanten, die jeweils mit einem doppelten
Unterstrich beginnen und enden:
Konstante Ersetzung
Mit der Konstanten __TIME__ kann ein Programm z. B. seine Compile-Zeit in den Code
einbrennen und ausgeben:
Compile-Zeit: 09:29:56
typedef
18
Durch typedef können neue Typbezeichner definiert werden.
wird z. B. ein neuer Typbezeichner (zahl) für den bestehenden Datentyp int einge-
führt. Dieser neue Bezeichner kann dann als Alias anstelle von int verwendet wer-
den:
zahl z;
z = 1;
661
18 Zusammenfassung und Ergänzung
struct pkt
{
int x;
int y;
};
punkt p;
p.x = 1;
p.y = 2;
punkt p;
p.x = 1;
p.y = 2;
typedef struct
{
int x;
int y;
} punkt;
punkt p;
p.x = 1;
p.y = 2;
Die Einführung eines neuen Typbezeichners ist mehr eine kosmetische Operation als
eine wirklich notwendige Funktion.
662
union
union
Eine Union (union) ist wie eine Struktur (struct) eine Datenstruktur. Der formale Auf-
bau einer Union entspricht exakt dem einer Struktur, anstelle des Schlüsselworts
struct wird jedoch das Schlüsselwort union verwendet. Mehr darüber erfahren Sie im
Abschnitt über das Schlüsselwort »struct«.
Der inhaltliche Unterschied zwischen Struktur und Union besteht darin, dass bei
einer Union der Compiler angewiesen wird, die einzelnen Felder im Speicher nicht
hintereinander, sondern platzsparend übereinander anzulegen. Die bedeutet natür-
lich, dass Sie zu einem Zeitpunkt immer nur ein Feld einer Union nutzen können und
auch jederzeit wissen sollten, welches der Felder Sie genutzt haben. Unions eignen
sich daher nur für Datenstrukturen, deren Felder alternativ (im Sinne eines Entwe-
der-Oder) genutzt werden.
struct datum
{
int tag;
int monat;
int jahr;
};
18
struct zeitraum1
{
struct datum von;
struct datum bis;
};
struct zeitraum2
{
struct datum anfang;
int anzahl tage;
};
663
18 Zusammenfassung und Ergänzung
union zeitraum
{
struct zeitraum1 z1;
struct zeitraum2 z2;
};
Da man einer Variablen vom Typ union zeitraum nicht ansehen kann, ob in ihr ein
Zeitraum vom Typ zeitraum1 oder zeitraum2 gespeichert ist, fügt man häufig einer
Union noch eine sogenannte Diskriminante hinzu. Dazu bettet man die Union in
eine Struktur ein, die zusätzlich die Diskriminante (hier typ) enthält:
struct termin
{
int typ;
union zeitraum z;
};
Wenn man jetzt die Diskriminante konsequent mitführt (z. B. typ = 1 bedeutet
zeitraum1, typ = 2 bedeutet zeitraum2), kann man die Union konsistent verwenden.
In der Verwendung (Definition, Zugriff) unterscheidet sich die Union nicht von einer
Struktur, Sie müssen lediglich darauf achten, dass immer nur eine der Varianten gül-
tig ist und verwendet werden kann.
unsigned
Das Schlüsselwort unsigned kann allen Integer-Datentypen vorangestellt werden, um
festzulegen, dass es sich um einen vorzeichenlosen Datentyp handelt:
unsigned char c;
unsigned int i;
Beachten Sie dazu auch den Abschnitt »Datentypen für ganze Zahlen«.
664
Variablen
Unterprogramme
Ein C-Programm besteht aus einem Hauptprogramm (main) und vielen Unterpro-
grammen, die mittelbar oder unmittelbar vom Hauptprogramm gerufen werden.
Unterprogramme werden in C durch Funktionen (siehe Abschnitt »Funktionen«)
realisiert. Unterprogramme in dem Sinne, dass eine Funktion nur lokal innerhalb
einer anderen Funktion existiert, gibt es in C nicht.
Variablen
Mit Variablen modelliert man die Daten eines Programms. Zu einer Variablen ge-
hören:
왘 eine Speicherklasse
왘 ein Datentyp
왘 ein Name
왘 ein Wert
왘 eine Adresse
Speicherklasse
Datentyp
18
Name
Wert
Die Speicherklasse legt den Speicherort (Prozessorregister, Stack, Heap) und die
Lebensdauer einer Variablen fest. Es gibt die Speicherklassen:
왘 auto
왘 register
왘 static
왘 extern
665
18 Zusammenfassung und Ergänzung
Als Datentyp kommen alle vordefinierten Datentypen (int, float, ...), alle zusam-
mengesetzten Datentypen (struct, union), Arrays und Zeiger infrage. Siehe auch die
Abschnitte »int«, »float«, »struct« und »union«.
Der Name einer Variablen wird vom Programmierer relativ frei festgelegt und folgt
den Bezeichnungsregeln für Identifier (siehe Abschnitt »Variablen«).
Einen Wert erhält eine Variable durch Initialisierung oder Zuweisung über eine Kon-
stante oder über eine andere Variable. Mehr über Wertzuweisungen erfahren Sie bei
den einzelnen Datentypen und im Abschnitt über Zuweisungsoperatoren.
Die Adresse einer Variablen wird nicht vom Programmierer, sondern vom Compiler
vergeben, siehe Abschnitt »Adressen«.
== x == y gleich Vergleichsoperator L 9
!= x != y ungleich
Beachten Sie, dass ein Vergleich auf Gleichheit mit dem doppelten Gleichheitszei-
chen (==) durchgeführt wird. Ein einfaches Gleichheitszeichen bedeutet eine Zuwei-
sungsoperation.
Vergleichen Sie ganze Zahlen nach Möglichkeit »sortenrein«, also signed mit signed
und unsigned mit unsigned, ansonsten könnten Sie unangenehme Überraschungen
erleben:
666
volatile
unsigned int a = 1;
signed int b = –1;
if( a < b)
printf( "a < b");
a < b
Der Compiler warnt Sie vor solchen Vergleichen. Nehmen Sie die Warnungen des
Compilers nicht auf die leichte Schulter.
void
Das Schlüsselwort void ist ein Surrogat, das überall dort auftaucht, wo eigentlich ein
Datentyp erwartet wird, es aber keinen Datentyp gibt – z. B. bei einer Funktion, die
nichts zurückgibt:
oder bei einem unspezifizierten Zeiger, bei dem nicht festgelegt ist, auf welchen
Datentyp er zeigt:
18
void *zeiger;
volatile
Variablen kann bei der Definition das Schlüsselwort volatile6 vorangestellt werden.
volatile int a;
Dies ist ein Hinweis des Programmierers an den Compiler, dass diese Variable unter
Umständen von außerhalb des Programms geändert wird. Das bedeutet, dass man
sich nicht darauf verlassen kann, dass die Variable ihren Wert zwischen zwei Lesezu-
griffen beibehält, weil noch ein unbekannter Dritter seine Hände im Spiel hat. Der
Compiler unterlässt bei solchen Variablen Optimierungen und fordert den Wert bei
jedem Lesezugriff erneut an.
667
18 Zusammenfassung und Ergänzung
while
Mit while können einfache Schleifen erstellt werden. Bei while handelt es sich um
eine vereinfachte Variante von for, da Initialisierung und Inkrement fehlen:
for( ; ...; )
{
...
while( ...) ...
{ ...
... }
...
...
}
Abbildung 18.58 Struktur der while-Schleife
Schleifensteuerung mit break und continue ist, wie bei for, möglich.
Die while-Anweisung wird häufig verwendet, ist aber im Grunde genommen über-
flüssig, da sie jederzeit durch ein for, bei dem Initialisierung und Inkrement leer
gelassen werden, ersetzt werden kann.
Zeichen
Zeichen werden in einfache Hochkommata eingeschlossen. Handelt es sich um ein
druckbares Zeichen, können Sie das Zeichen direkt verwenden:
'a'
'Z'
'A' ein A
\101 ein A
\x41 ein A
668
Zeichenketten
Sprachlich unterscheidet man nicht immer sauber zwischen einem Zeichen, seinem
Literal und seinem Code. Das Zeichen ist das Schriftsymbol, um das es eigentlich
geht. Das Literal ist die Darstellung des Zeichens im Quelltext, und der Zeichencode
ist die Darstellung des Zeichens im Rechner. Im Rechner gibt es daher keine Zeichen,
sondern nur Zeichencodes, also Bitmuster, die wir als Zahlen interpretieren können.
Auch wenn ein Zeichenliteral, wie etwa '\x5c', im Quellcode aus mehreren Buchsta-
ben besteht, steht es für ein einzelnes Zeichen (hier Backslash) und belegt im aus-
führbaren Programm nur ein Byte.
Zeichenketten
Zeichenketten werden in doppelte Hochkommata eingeschlossen und können
Escape-Sequenzen enthalten:
"ABCD\n"
"\x41\x42\x43\x0a" ebenfalls "ABCD\n"
Typischerweise steht eine Zeichenkette in einem Puffer, der mindestens ein Byte
mehr hat als die Zeichenkette Zeichen hat, da das Terminatorzeichen mitgespeichert
werden muss. Der Puffer kann statisch oder dynamisch allokiert sein, und auf die ein-
zelnen Zeichen der Zeichenkette kann mit einem Index zugegriffen werden. Als Pro-
grammierer müssen Sie darauf achten, dass bei Operationen auf Zeichenketten die
zugrunde liegende Pufferlänge nicht überschritten wird. Dies gilt insbesondere für
Operationen, die eine Zeichenkette verlängern.
669
18 Zusammenfassung und Ergänzung
Zeiger
Zeiger dienen zum Zugriff auf Objekte über deren Adresse. Zu einem Zeiger gehören
immer drei Dinge:
Bei der Definition eines Zeigers muss der Typ des Objekts bekannt sein, dessen
Adresse der Zeiger aufnehmen soll. Das folgende Codefragment zeigt die Definition
einiger Zeiger:
Bevor ein Zeiger verwendet werden kann, muss ihm die Adresse eines Objekts (Funk-
tion oder Datum) zugewiesen werden. Mehr darüber erfahren Sie im Abschnitt
»Adressen«. Das folgende Codefragment zeigt die Zuweisung von Adressen:
struct abc
{
int x;
int y;
};
int variable1;
struct abc variable2;
int *pointer;
struct abc *p;
int (*f)(int, float);
670
Zeiger
pointer = &variable1;
p = &variable2;
f = meinefunktion;
Sobald ein Zeiger einen gültigen Adresswert hat, kann über diese Adresse auf die Ori-
ginaldaten zugegriffen werden. Zum Zugriff wird der *-Operator verwendet:
*pointer = 1;
(*p).x = 2;
18
*pointer = (*f)( 3, 4.5);
Beim Zugriff über einen Zeiger in eine Datenstruktur kann der Pfeil-Operator (->) ver-
wendet werden:
Beim Aufruf einer Funktion über einen Zeiger kann der *-Operator weggelassen
werden:
int x;
x = f( 5, 7.8);
Mit den Adresswerten in den Zeigern kann gerechnet werden. Näheres dazu erfahren
Sie im Abschnitt über Adressen.
671
18 Zusammenfassung und Ergänzung
Zeiger haben eine wichtige Bedeutung als Funktionsparameter, für den Zugriff auf
Arrays und den Aufbau dynamischer Datenstrukturen wie Listen oder Bäume. Siehe
die Kapitel 8, »Zeiger und Adressen«, Kapitel 14, »Datenstrukturen« und Kapitel 15,
»Ausgewählte Datenstrukturen«.
. a.x Strukturzugriff
* *p Dereferenzierung Zugriffsoperator R 14
Zugriffsoperatoren sind eng verknüpft mit den Datentypen, auf die zugegriffen wer-
den soll.
Der Operator [] wird zum indizierten Zugriff in Arrays verwendet. Die Operatoren &
und * werden im Zusammenhang mit Adressen und Zeigern verwendet. Die Operato-
ren . und -> dienen zum direkten bzw. indirekten Zugriff auf einzelne Teile innerhalb
zusammengesetzter Datentypen (struct, union).
Weitere Informationen erhalten Sie in den Abschnitten über Adressen, Zeiger, Arrays
und Datenzugriff.
672
Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=, ^=, |= <<=, >>=)
Es besteht eine gewisse Asymmetrie zwischen dem Objekt auf der linken Seite und
dem Wert auf der rechten Seite einer Zuweisung. Eine Zuweisung der Form
a = 1;
ist möglich, sofern a für eine numerische Variable steht, während eine Formulierung
wie
1 = a;
Alles, was auf der linken Seite einer Zuweisungsoperation stehen kann, bezeichnet
man als L-Value. Alles, was auf der rechten Seite stehen kann, wird als R-Value
bezeichnet. Ein L-Value ist stets auch ein R-Value.
L-Values sind Variablen, aber nicht nur Variablen. L-Values können auch mittels der
Zugriffsoperatoren ([], ., ->, *) aus Variablen gewonnen werden. Beispiele:
struct x
{
int x1;
int x2;
};
int a;
int *b;
18
int c[100];
struct x d;
struct x *e;
a = 12;
b = &a;
c[a+1] = 17;
d.x1 = 123;
e = &d;
e->x2 = 456;
Der Zugriffsoperator & (Adress-Operator) liefert nur einen R-Value, da man die
Adresse eines Objekts nicht ändern kann.
Im Grunde genommen braucht man nicht mehr als die einfache Zuweisung mit dem
Gleichheitszeichen. Die weiteren Zuweisungsoperatoren sind prinzipiell vermeidbar
und dienen nur dem Programmierkomfort.
673
18 Zusammenfassung und Ergänzung
Operation Bedeutung
Seien Sie sehr vorsichtig bei der Verwendung dieser Operatoren in komplexen For-
meln. Sie können mit diesen Operatoren sehr kurz und knapp formulieren, aber auch
schwer zu erkennende Seiteneffekte erzeugen. Häufig verwendet man solche Opera-
toren in einfachen Formeln, z. B. in Schleifen, zum Herauf- oder Herunterzählen
eines Schleifenzählers:
int i;
In diesem Fall ist es egal, ob man in der Form i++ oder ++i zählt.
Schon bei einer einfachen Zuweisung ist es allerdings ein Unterschied, ob man
x = i++;
oder
x = ++i;
schreibt.
674
Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=, ^=, |= <<=, >>=)
Darüber hinaus gibt es eine Reihe von Operatoren, die eine Operation mit gleichzei-
tiger Wertzuweisung verbinden. Diese sind:
Operation Bedeutung
x += y x=x+y
x -= y x=x–y
x *= y x=x*y
x /= y x=x/y
x %= y x=x%y
x &= y x=x&y
x ^= y x=x^y
x |= y x=x|y
x <<= y x = x << y
x <<= y x = x >> y
Tabelle 18.23 zeigt zusammenfassend alle Operatoren dieses Abschnitts mit ihrer
Assoziativität und Priorität: 18
-- x-- Post-Dekrement
-- --x Pre-Dekrement
675
18 Zusammenfassung und Ergänzung
+= x += y Operation mit
anschließender
-= x -= y
Wertzuweisung
*= x *= y
/= x /= y
%= x %= y
&= x &= y
^= x ^= y
|= x |= y
<<= x <<= y
>>= x >>= y
676
Kapitel 19
Einführung in C++
Der oft zitierte »Paradigmenwechsel« ist meist leeres Gerede, in C++
gibt es ihn jedoch wirklich. Dieses Kapitel bereitet Sie darauf vor.
Wenn Sie das Buch bis zu diesem Punkt durchgearbeitet haben, beherrschen Sie die
Grundlagen der Programmiersprache C; Sie kennen verschiedene Algorithmen, kön-
nen neue Algorithmen umsetzen und eigene Programme schreiben.
Bereits im ersten Kapitel dieses Buches haben Sie erfahren, dass Programmierspra-
chen bestimmte Paradigmen unterstützen. Die Programmiersprache C basiert auf
dem prozeduralen Paradigma. Das prozedurale Programmieren haben Sie mittler-
weile kennengelernt. C++ unterstützt dazu auch die Objektorientierung. Im verblei-
benden Teil des Buches werde ich Ihnen das objektorientierten Paradigma und die
damit verbundene »Denke« vorstellen.
C++ bietet aber auch wichtige Erweiterungen gegenüber C, die gar nichts mit objekt-
orientierter Programmierung zu tun haben. Dennoch bringen auch diese Erweite-
rungen eine deutliche Erleichterung bei der prozeduralen Programmierung. Als
Einstieg in C++ zeige ich Ihnen zuerst diese Veränderungen, bevor ich im folgenden
19
Kapitel die eigentliche objektorientierte Entwicklung erläutere.
Bei der Entwicklung von C++ ist viel Wert auf die Kompatibilität gelegt worden,
sodass sich die meisten C-Programme auch mit einem C++-Compiler übersetzen las-
sen. Lassen Sie sich aber von diesem Abschnitt nicht dazu verleiten, C++ auf ein leicht
erweitertes C zu reduzieren. Sie werden in den folgenden Kapiteln noch völlig neue
Möglichkeiten der Modellierung entdecken.
19.1 Schlüsselwörter
In C++ bleiben die bereits in C eingeführten Schlüsselwörter in ihrer Bedeutung
erhalten:
677
19 Einführung in C++
do if static while
Zusätzlich wurde in C++ eine Reihe weiterer Schlüsselwörter eingeführt. Die meisten
dieser zusätzlichen Schlüsselwörter und deren Verwendung lernen Sie im Laufe des
Buches kennen:
Auch die neuen Schlüsselwörter sind reservierte Wörter, die z. B. nicht zur Benen-
nung von Variablen verwendet werden können. Wenn C-Programme diese Schlüssel-
wörter verwendet haben, lassen sie sich mit einem C++-Compiler nicht mehr
übersetzen und müssen vorher angepasst werden.
19.2 Kommentare
C++ bietet zusätzliche Kommentierungsmöglichkeiten, die die Dokumentation von
Code deutlich erleichtern. In C musste ein Kommentar ähnlich der Klammersetzung
immer gestartet /* und beendet */ werden. Mit dieser Methode können leicht meh-
rere Zeilen Kommentar ergänzt oder »Codebereiche« auskommentiert werden. Die
Syntax macht es aber umständlich, eine einzelne Zeile zu kommentieren. In C++ kön-
nen mit // Kommentare erstellt werden, die bis zum Ende der Zeile reichen:
678
19.3 Datentypen, Datenstrukturen und Variablen
y = 2; /* C-Style-Kommentar */
// C++-Kommentar ab dem Start der Zeile
x = 1; // Hier steht ein C++-Kommentar für den Rest der Zeile
Solche Kommentare können überall in der Zeile starten. Prinzipiell ist es sogar mög-
lich, solche einzeiligen Kommentare über das Zeilenende hinaus zu verlängern. Das
geht mit einem Backslash \ am Ende der Zeile:
Von dieser Möglichkeit (A) sollten Sie aber keinen Gebrauch machen. Die Verwen-
dung dieser Variante ist sehr unüblich, da die Weiterführung des Kommentars
extrem leicht übersehen wird, wie Sie hier vielleicht schon selbst erleben. In Zeile (B)
startet kein neuer Code, stattdessen handelt es sich auch hier immer noch um Kom-
mentartext.
Wenn Sie eine Variable des neuen Typs in C anlegen, müssen Sie so vorgehen:
Das Schlüsselwort enum muss hier vor dem Namen des Datentyps erneut angegeben
werden. In C++ kann die explizite Verwendung von enum entfallen. Mit der oben
erfolgten Anlage des enum ist durch die automatische Typisierung implizit ein neuer
Datentyp eingeführt worden, der im Weiteren direkt verwendet werden kann:
679
19 Einführung in C++
struct punkt
{
int x;
int y;
};
In C++ kann auch für eine struct wie für ein enum das zusätzliche Schlüsselwort an
dieser Stelle entfallen:
oder
hier nicht mehr notwendig und auch nicht mehr üblich. Aufgrund der Kompatibili-
tät mit C können die Konstruktionen aber weiterverwendet werden.
struct student
{
char name[50];
680
19.3 Datentypen, Datenstrukturen und Variablen
struct bachelorarbeit
{
char thema[200];
float note;
student* stud; // Verweis auf den studenten
};
In dem Beispiel finden sich zuerst die Vorwärtsverweise auf die Strukturen student
und bachelorarbeit (A). Diese Angaben sagen nur, dass entsprechende Strukturen
noch bereitgestellt werden. In der Deklaration der Struktur student erfolgt dann ein
Verweis auf die Struktur bachelorarbeit (B) (hier noch nicht vollständig deklariert).
Beachten Sie dabei, dass in den Datenstrukturen nur Zeiger auf noch nicht dekla-
rierte Strukturen vorkommen dürfen. Durch den Vorwärtsverweis wird es nicht
möglich, die Struktur selbst zu verwenden, da die einzelnen Felder und die Größe der
einzubindenden Struktur an dieser Stelle noch nicht bekannt sind. Das folgende Bei-
spiel führt daher zu einem Fehler bei der Übersetzung:
681
19 Einführung in C++
Die in C verwendete Sichtweise hat dabei durchaus Vorteile, weil man mit logischen
Ergebnissen wie mit Zahlen rechnen kann und z. B. die Ergebnisse einer logischen
Operation zur Gesamtzahl der Ergebnisse mit dem Wert wahr aufaddieren kann.
In C++ hat man den Mittelweg beschritten und einen Datentyp bool eingeführt, der
die beschriebenen Eigenschaften vereinigt und mit int kompatibel ist.
Der Datentyp bool kann die Werte true und false annehmen . Gleichzeitig gilt aber
auch true = 1 und false = 0. Damit können Sie logische Ausdrücke, die Sie bisher mit
int gebildet haben, jetzt auch mit bool bilden, z. B. so:
b1 = true;
b2 = !b1;
b3 = 3 > 2;
if( b2 != false)
{
b4 = b1 || b2;
}
Der Datentyp bool ist zwar kompatibel mit dem Datentyp int, aber nicht identisch. Er
verhält sich wie ein int, der nur die Werte 0 und 1 aufnehmen kann. Eine Wertzuwei-
sung ist damit in beide Richtungen fehlerfrei möglich. Das Codefragment demonst-
riert dies, und
bool b = 7;
int ausgabe = b;
printf( "Der Wert von ausgabe ist '%d'\n", ausgabe);
Viele Entwickler setzen den Datentyp bool mit true und false kaum ein und verwen-
den weiterhin die Ihnen bereits bekannten Mechanismen aus C.
682
19.3 Datentypen, Datenstrukturen und Variablen
#define ANZAHL 10
Dies hatte den Grund, dass die folgende Definition in C nicht möglich war:
Der definierte konstante Wert anzahl kann bereits zur Übersetzungszeit verwendet
werden.
Der Präprozessor, der in C noch von entscheidender Bedeutung ist, verliert in C++
durch den Ersatz symbolischer Konstanten an Relevanz. Auch die Makros, die Sie
auch schon kennen, werden ersetzt – und zwar durch die Inline-Funktionen, die wir
noch in diesem Kapitel behandeln.
int x = 0;
x = 99;
A int a = 0;
683
19 Einführung in C++
a += i;
C }
In dem Beispiel wird die Variable a nach der Verwendung von x definiert (A), dies ist
in C nicht möglich. Ebenso können in C++ die Schleifenvariablen im Schleifenkopf
definiert werden, wie hier die Variable i (B). Die Schleifenvariable verliert ihre Gültig-
keit mit dem Ende des zugehörigen Blocks in (C) und kann danach nicht mehr ver-
wendet werden.
Im Moment mag Ihnen diese Erweiterung der Variablendefinition wie eine (nützli-
che) Spielerei erscheinen. Sie werden aber im folgenden Kapitel sehen, wie in C++ bei
der Definition von Variablen automatisch spezieller Code ablaufen kann. Die Stelle
der Definition bestimmt den Zeitpunkt der Codeausführung und gewinnt damit
extrem an Bedeutung.
Ich zeige Ihnen die Vorgehensweise in C noch einmal anhand einer swap-Funktion
zum Vertauschen zweier Variablenwerte:
B tmp = *a;
C *a = *b;
D *b = tmp;
}
int main()
{
int x, y;
...
684
19.3 Datentypen, Datenstrukturen und Variablen
In der Schnittstelle der Funktion erfolgt die Übergabe der Parameter als Zeiger auf
int (A). Der eigentliche Tausch der Werte erfolgt mithilfe der Hilfsvariablen tmp (B)
und der dereferenzierten Zeiger (B–D). Bei Aufruf der Funktion werden die Adressen
der zu tauschenden Variablen übergeben (E).
C++ bietet Ihnen hier eine elegante und effiziente Alternative: die sogenannten Refe-
renzen. Über Referenzen können Funktionen übergebene Variablen ändern!
Referenzen verhalten sich wie ein konstanter Zeiger, der bei jeder Verwendung auto-
matisch referenziert wird. Eine Referenz ist ein L-Value. Sie kann also auf der rechten
und auf der linken Seite einer Zuweisung verwendet werden.
Um an der Schnittstelle einer Funktion eine Referenz zu übergeben, wird bei der Ver-
einbarung der Parameter dem Datentyp ein & hintenangestellt. Gelesen wird dies als:
»x« vom Typ Referenz Datentyp.
Die Schnittstelle der Funktion zum Tauschen der Werte sieht mit Referenzen so aus:
Die Funktion swap liefert weiter keinen Rückgabewert und erhält jetzt aber zwei Para-
meter, a und b, vom Typ Referenz auf int.
Innerhalb der Funktion werden die Variablen dann wie »normale« int-Werte ver-
wendet. Die gesamte Funktion zum Tauschen der Werte und ihr Aufruf sehen damit
so aus:
B tmp = a;
C a = b;
D b = tmp;
}
685
19 Einführung in C++
int main()
{
int x = 1, y = 2;
// ...
printf( "Vorher; %d %d\n", x, y);
E swap( x, y);
In (A) wird die Funktion, wie bereits beschrieben, deklariert. Der Tausch der Werte
über direkte Zuweisung erfolgt in (B–D). Hier sind durch die Verwendung der Refe-
renzen keine Indirektionen mehr notwendig. Im Grunde handelt es sich bei Referen-
zen um Zeiger, die bei jeder Übergabe implizit dereferenziert werden. Der Aufruf der
Funktion erfolgt in (E) direkt mit den Variablen ohne einen Adress-Operator. Es
kommt zur erwarteten Ausgabe:
Vorher: 1 2
Nachher: 2 1
Referenzen sind nicht nur nützlich, wenn Sie übergebene Werte ändern wollen. Refe-
renzen sind bei der Übergabe auch sehr effizient, da nur der Verweis anstelle des gan-
zen Elements über den Stack übergeben wird. Bei einer großen Struktur kann dies ein
deutlicher Vorteil sein.
Achtung!
Bei der Übergabe eines Wertes per Zeiger sieht man an der Schnittstelle sofort, dass
vom Zeiger referenzierte Werte in der Funktion geändert werden könnten.
Bei der Übergabe per Referenz ist dies für den Aufrufer nicht mehr so offensichtlich!
Das ist auch der Grund, warum viele C-Programmierer die Verwendung von Refe-
renzen mit gemischten Gefühlen betrachten. Bei der Verwendung von Zeigern
muss der Aufrufer einer Funktion explizit die Adresse eines Wertes angeben und ist
sich damit dessen bewusst, dass die Funktion die Werte möglicherweise ändert.
Dies ist bei Referenzen nicht der Fall. Ich werde Ihnen in diesem Abschnitt aber noch
eine Möglichkeit zeigen, das Problem abzumildern.
Zuerst werden wir aber noch weitere Details der Übergabe per Referenz betrachten.
Dazu erstellen wir zuerst eine einfache Maximumfunktion und danach verschiedene
Varianten. Die erste Variante ist wenig überraschend:
686
19.3 Datentypen, Datenstrukturen und Variablen
Die Funktion gibt das Maximum der beiden als Kopie übergebenen Werte als Ergeb-
nis zurück. Auch das Ergebnis wird als Kopie an den Aufrufer übergeben.
erhalten Sie für den Aufruf von max2 eine Fehlermeldung. Der Datentyp der Konstan-
ten kann nicht in eine Referenz auf ein int verwandelt werden.
In der Schnittstelle deklarierte Referenzen können und dürfen in einer Funktion ver-
ändert werden. Daher können solchen Referenzen keine konstanten Werte überge-
ben werden, da hier die Veränderung unmöglich wäre. Wenn Sie konstante Werte
übergeben wollen, müssen Sie die Referenzen in der Schnittstelle ebenfalls als kon-
stant deklarieren.
687
19 Einführung in C++
Wenn Sie dem Compiler damit angeben, dass die Referenzen innerhalb der Funktion
nicht verändert werden, dann können Sie eine entsprechende Funktion auch mit
Konstanten aufrufen:
Durch die Angabe von const int& in der Schnittstelle werden in der Funktion kon-
stante Referenzen verwendet. Der Compiler erstellt hier bei Bedarf Zwischenvariab-
len, die für den Zugriff verwendet werden, und der folgende Aufruf wird möglich:
Die Verwendung einer konstanten Referenz in der Schnittstelle sorgt nicht nur dafür,
dass die Funktion mit konstanten Werten aufgerufen werden kann. Sie zeigt dem
Benutzer einer Funktion auch an, dass die Funktion die übergebenen Referenzen
nicht verändern wird.
Funktionen, die die Übergabe per Referenz aus Performance-Gründen nutzen und
die übergebenen Werte nicht verändern, sollten die Referenzen als konstant dekla-
rieren. Dadurch können sie den Performance-Vorteil der Übergabe per Referenz
gegenüber der Kopie nutzen. Anhand der Schnittstelle sieht der Benutzer aber, dass
die Funktion die Werte nicht ändert.
688
19.3 Datentypen, Datenstrukturen und Variablen
return b;
}
Die Funktion max4 erhält Referenzen auf int und gibt eine Referenz auf int zurück.
Damit wird die die folgende Verwendung möglich:
void main()
{
int x = 1, y = 2;
A max4( x, y ) = 4711;
Hier ist auch der Rückgabewert der Funktion eine Referenz und damit ein L-Value,
der auch auf der linken Seite einer Zuweisung stehen darf (A).
Aus der Funktion wird die Variable, die den größeren Wert enthält, als Referenz
zurückgegeben. In diesem Fall ist das y. Dieser Variablen wird dann als L-Value der
Wert 4711 zugewiesen.
int i;
int& ref = i;
i = 1;
printf(" %d %d\n", i, ref);
689
19 Einführung in C++
i++;
printf(" %d %d\n", i, ref);
ref++;
printf(" %d %d\n", i, ref);
Im Beispiel ist ref eine Referenz auf i, und die beiden Variablen können völlig syno-
nym verwendet werden. Das Programm liefert die folgende Ausgabe:
1 1
2 2
3 3
Dabei haben ref und i nicht nur immer den gleichen Wert, sie bezeichnen auch den
gleichen Speicherbereich. Der Adress-Operator auf ref und i angewandt, liefert das
gleiche Ergebnis. Damit produziert diese Zeile
Generell müssen Sie beachten, dass die Referenz initialisiert werden muss:
int& ref = i;
Dabei handelt es sich um einen einmaligen Vorgang. Wie Sie schon gesehen haben,
steht ref danach synonym für i. Spätere Wertzuweisungen
ref = x;
ändern nichts an der Initialisierung und der erfolgten Zuordnung, sondern setzen
nur neue Werte.
19.4 Funktionen
Gerade im Bereich der Funktionen gibt es einige wichtige Erweiterungen in C++, die
ich Ihnen noch zeigen werde, bevor wir in die objektorientierte Programmierung ein-
steigen.
690
19.4 Funktionen
Wie andere Warnungen und Fehlermeldungen des Compilers sollten Sie diese Anfor-
derungen nicht als Behinderung bei der Arbeit begreifen. Stattdessen sollten Sie die
Warnung des Compilers als Hilfe auffassen, guten Code zu generieren. Der Compiler
gibt Ihnen eine Unterstützung bei der Fehlervermeidung, indem er Sie zwingt, Ihre
Absichten möglichst präzise zu formulieren. Dann kann er Sie auch frühzeitig darauf
hinweisen, wenn es Abweichungen gibt.
Für das Hauptprogramm main sind im C++-Standard zwei Varianten vorgesehen. Für
ein Hauptprogramm, das eine unbekannte Anzahl von Parametern erhält, typischer-
weise von der Kommandozeile:
int main()
{
...
return 0;
}
return 0;
Die allgemeine Regel, dass Funktionen mit einem Rückgabetyp auch einen Wert
zurückgeben müssen, gilt weiter1.
1 In den folgenden Kapiteln haben einige main-Funktionen nur eine Handvoll Zeilen. Dort habe ich
die return-Anweisung weggelassen, um das Beispiel in den Vordergrund zu rücken. Generell bin
ich aber dafür, sie zu verwenden.
691
19 Einführung in C++
0
2
4
6
8
10
Im Laufe der weiteren Programmierung und Verwendung der Funktion stellen Sie
vielleicht fest, dass die Funktion überwiegend mit einem Inkrement von 1 eingesetzt
wird. Bei jedem Aufruf müssen aber alle Parameter mit angegeben werden. Sie wür-
den Ihre Funktion hier gerne um eine sinnvolle Vorgabe ergänzen.
C++ bietet mit den sogenannten Default-Werten eine solche Möglichkeit. Dies sind
vorgegebene Argumentwerte, die an einer Funktionsschnittstelle verwendet wer-
den, wenn vom rufenden Programm keine Werte übergeben wurden. Das rufende
Programm kann also bestimmte Werte im Aufruf einfach auslassen, die fehlenden
Werte werden in der Funktion ersetzt. Die Default-Werte werden auch Standard-
werte genannt.
692
19.4 Funktionen
In C++ erhalten wir diese Vorgaben, indem die gewünschten Default-Werte wie eine
Zuweisung oder Initialisierung an den entsprechenden Parameter angefügt werden
(hier die 1 für inkrement):
Jetzt können wir die Funktion mit zwei oder drei Parametern verwenden:
hochzaehlen( 0, 6, 2);
hochzaehlen( 0, 3);
0
2
4
6
0
1
2
3
Bei Aufruf ohne den dritten Parameter wird automatisch der Standardwert ver-
wendet.
Wenn auch der Endwert unseres Inkrements ende einen typischen Wert hat, kann
19
natürlich auch hier ein entsprechender Default-Wert festgelegt werden:
Jetzt könnte die Funktion auch mit einem Parameter aufgerufen werden. Sogar für
den Startwert start können wir einen Default-Wert festlegen, sodass die Funktion
dann so aussieht
hochzaehlen();
왘 Default-Werte können immer nur für die »letzten« Argumente einer Funktion
(d. h. ab einer bestimmten Position, dann aber für alle folgenden Argumente)
angegeben werden.
693
19 Einführung in C++
왘 Beim Aufruf einer Funktion mit Default-Argumenten können immer nur Argu-
mente vom Ende der Parameterliste weggelassen werden. Benötigen Sie einen
bestimmten Parameter, müssen alle davorliegenden Parameter mit angegeben
werden.
So werden alle Nutzer der Funktion über die Schnittstelle informiert. Für Bibliothe-
ken kennt der Benutzer meist nur die Header-Dateien und nicht den Quellcode.
Durch die Default-Werte im Prototyp ist auch von außen sichtbar, welche Standard-
werte angewandt werden. Naheliegenderweise dürfen die Defaults dann bei der Im-
plementierung der Funktion nicht erneut definiert werden, da sonst Standardwerte
an zwei Stellen festgelegt würden.
19.4.3 Inline-Funktionen
Insbesondere bei der objektorientierten Programmierung entstehen häufig sehr
kleine Funktionen, die einen Aufruf als Funktion eigentlich »nicht lohnen«. In C wer-
den solche Funktionen vielfach als Präprozessor-Makros realisiert, um zu verhin-
dern, dass Parameter aufwendig über den Stack übergeben werden müssen. Die
Risiken, die dabei auch existieren, haben Sie in Kapitel 9 zur Programmgrobstruktur
schon kennengelernt.
Für solche Aufgaben bietet C++ als Lösung Inline-Funktionen an. Inline-Funktionen
haben die Effizienz von Makros, verbunden mit der Konsistenz und Schnittstellensi-
cherheit von Funktionen.
694
19.4 Funktionen
void main()
{
int x = 10, y = 100
Überall dort, wo die Inline-Funktion verwendet wird, ersetzt der Compiler den Funk-
tionsaufruf durch den entsprechenden Code der Funktion. Die Übergabe der Parame-
ter über den Stack entfällt. Aus dem oben dargestellten Beispiel wird praktisch der
folgende Quelltext:
void main()
{
int x = 10, y = 100
int m = x > y ? x : y;
}
Die Inline-Anweisung kann vom Compiler nicht immer ausgeführt werden, etwa
dann, wenn es sich um eine rekursive Funktion handelt. Sie ist daher als eine Emp-
fehlung an den Compiler zu verstehen.
19
Sinnvoll ist das Inlining besonders für kleine Funktionen, die sehr oft gerufen wer-
den und bei denen der Aufwand der Parameterübergabe über den Stack im Verhält-
nis zum Ausführungsaufwand hoch ist. Bei kleinen und einfachen Funktionen
bedeutet Inlining oft sowohl kompakteren Programmcode als auch schnellere Aus-
führung und ist auf jeden Fall zu empfehlen2.
Da der Compiler den Code der Inlining-Funktion an der Stelle ihres Aufrufs einsetzt,
muss die Definition einer solchen Funktion bereits vorliegen, wenn sie verwendet
wird. Der Prototyp reicht hier nicht aus. Falls eine Inline-Funktion in mehreren
Modulen benutzt werden soll, muss ihre Definition daher in einer Header-Datei
2 Inlining kann zu erheblichen Performance-Gewinnen führen, z. B. bei einer Funktion, die in einer
Schleife oft gerufen wird. Sie können z. B. Funktionen in den Sortieralgorithmen als Inline-Funk-
tionen deklarieren und damit erhebliche Performance-Gewinne erzielen – etwa bei den Funktio-
nen insertion_h_sort oder adjustheap in Kapitel 13 zum Sortieren.
695
19 Einführung in C++
abgelegt sein, die dann von den Modulen inkludiert wird, die die Funktion ver-
wenden3.
struct punkt
{
int x;
int y;
};
struct vektor
{
int x;
int y;
int z;
};
Typische Funktionen für deren Ausgabe sind in der Programmiersprache C dann die
folgenden:
void main ()
{
struct punkt p = {5, 4};
3 Dies widerspricht nicht der Anforderung, dass in einer Header-Datei kein Code stehen soll. Wie
bei einer Datenstruktur steht in einer Header-Datei nur eine formale Definition, die erst dann
Code wird, wenn sie in einem Programm verwendet wird.
696
19.4 Funktionen
In (A) und (B) erfolgt die Definition einer Ausgabefunktion je Datentyp mit unter-
schiedlichen Namen, da gleichnamige Funktionen zum Fehler führen würden. Die
Ausgabe erfolgt dann unter Verwendung der passenden Funktionen zum jeweiligen
Datentyp in (C) und (D).
In C++ wird die Verwendung der Datentypen durch das Überladen von Funktionen
deutlich vereinfacht. Überladen bedeutet dabei, dass Funktionen nicht nur anhand
ihres Namens, sondern auch anhand ihrer Parametersignatur unterschieden wer-
den. Damit kann es in C++ verschiedene Funktionen gleichen Namens geben, sofern
sich die Art und/oder Anzahl der Parameter unterscheiden.
Mit diesem Wissen erstellen wir nun zwei gleichnamige Ausgabefunktionen mit
unterschiedlicher Schnittstelle, die wir gleich verwenden:
void main ()
{
punkt p = {5, 4};
vektor v = {1, 2, 3};
C print( p);
D print( v);
}
In (A) definieren wir die Ausgabe für den Datentyp punkt, in (B) für den vektor. Beide
Funktionen haben den Namen print.
697
19 Einführung in C++
In (C) wird die Funktion print für einen punkt gerufen, in (D) die Variante für einen
vektor.
Die passenden Funktionen werden vom Compiler anhand der Signatur ausgewählt,
und wir erhalten die erwartete Ausgabe:
Punkt (5, 4)
Vektor (1, 2, 3)
Verschiedene Funktionen gleichen Namens stellen in C++ kein Problem dar, sofern
sie anhand ihrer Parametersignatur unterschieden werden können.
Der Typ des Rückgabewertes geht nicht mit in die Parametersignatur ein. Ebenso
werden Default-Parameter nicht berücksichtigt.
Bei den folgenden Beispielen unterscheiden sich weder der Name noch die Parame-
tersignatur der Funktionen:
int main()
{
fkt1( 1);
}
Das Programm kann nicht übersetzt werden, da die Funktionen fkt1 für den Compi-
ler nicht unterscheidbar sind. Eine Funktion kann generell ohne Information zum
erwarteten Rückgabetyp aufgerufen werden, daher kann der Rückgabetyp kein
Unterscheidungskriterium sein. Ebenso wie der Rückgabetyp gehen Default-Werte
nicht mit in die Signatur ein:
698
19.4 Funktionen
int main()
{
fkt2( 1);
}
Auch dieses Beispiel kann nicht übersetzt werden. Aus der Sicht des Compilers sind
die beiden Varianten von fkt2 nicht unterscheidbar.
xxx_Ficf
19
und beinhaltet die Parameter int, char und float. Über diesen Namen greift der Lin-
ker dann auf die Funktion im Objektcode zu. Sie müssen den Prozess nicht im Detail
verstehen. Sie müssen nur wissen, dass es ihn gibt und dass er als Teil des Sprachstan-
dards normiert ist. Die Normierung ist notwendig, denn nur so kann die Interopera-
bilität der verschiedenen C++-Compiler sichergestellt werden. So ist es möglich, dass
Sie nicht nur die Bibliotheken verwenden können, die Ihr Compiler mitbringt, son-
dern auch bereits übersetzte Bibliotheken aus anderen Quellen nutzen können.
699
19 Einführung in C++
dazu, dass der Linker eine Funktion mit dem Namen xxx_Ficf sucht.
Wenn diese Funktion mit einem C-Compiler übersetzt worden ist, wird es diese Funk-
tion nicht geben, da der C-Compiler die Regeln der C++-Namensgenerierung natür-
lich nicht kennt und nicht anwendet.
Es muss also eine Möglichkeit geben, innerhalb von C++ für eine bestimmte Funk-
tion, wie hier die Funktion xxx, das Function Name Encoding abzuschalten. Dazu
deklarieren wir die Funktionen als extern "C". Das kann für eine einzelne Funktion
passieren:
Es kann aber auch ein ganzer Block als extern "C" ausgezeichnet werden:
extern "C"
{
void abc(int, char b, float c);
int aaa();
}
Mit dieser zusätzlichen Auszeichnung gibt es allerdings ein neues Problem, wenn sie
in einer Header-Datei verwendet wird, die parallel in C- und C++-Programme inklu-
diert werden soll. Innerhalb eines C-Compilers ist die Anweisung extern "C" unbe-
kannt und soll es auch sein. Ein C-Compiler soll unabhängig von den C++-Standards
sein. Genau genommen, soll er nicht einmal wissen müssen, dass C++ existiert. Bin-
den wir daher einen solchen Header in ein C-Programm ein, bedeutet dies dort einen
Fehler.
Wir müssen daher verhindern, dass die entsprechende Zeile für den C-Compiler
sichtbar wird. Es gibt innerhalb der C++-Compiler die vorbelegte symbolische Kon-
stante __cplusplus. Wenn diese Konstante definiert ist, kann davon ausgegangen
werden, dass gerade ein C++-Compiler am Werk ist und die zusätzlichen extern "C"-
Anweisungen benötigt werden. Mit diesem Wissen können wir eine entsprechende
Header-Datei nun für die Bearbeitung durch den Präprozessor folgendermaßen
gestalten:
700
19.5 Operatoren
#ifdef __cplusplus
extern "C"
{
#endif
#ifdef __cplusplus
}
#endif
Dabei verlassen wir uns darauf, dass die symbolische Konstante in C nicht existiert.
Aufgrund des nicht definierten __cplusplus sieht der C-Compiler nach dem Durch-
lauf des Präprozessors nur noch diesen Code:
Für den C++-Compiler sind die extern "C"-Anweisungen aber weiterhin sichtbar:
extern "C"
{
void abc(int, char b, float c);
int aaa();
}
19
Entsprechend ausgestattet, können wir Header-Dateien erstellen, die sowohl von C
als auch von C++ verwendet werden können. Dies geht zwar auf Kosten der Lesbar-
keit, hat aber dennoch große Vorteile. Wenn Sie sich die Header-Dateien von Biblio-
theken – auch die Ihres Compilers – ansehen, werden Sie entsprechende Konstrukte
(und weitere dieser Art) finden.
19.5 Operatoren
Auch bei den Operatoren gibt es Erweiterungen und Ergänzungen. Insbesondere
werden in C++ einige ganz neue Operatoren eingeführt:
Operator Bezeichnung
:: Globalzugriff
701
19 Einführung in C++
Operator Bezeichnung
:: Class-Member-Zugriff
.* Pointer-to-Member-Zugriff (direkt)
Den Operator für den Globalzugriff werde ich Ihnen im Folgenden direkt vorstellen,
die anderen Operatoren kommen erst mit der objektorientierten Programmierung
zum Einsatz.
void main()
{
B int a = 2; // lokale Variable a
Im Programm wird die globale Variable a definiert (A). In der main-Funktion über-
deckt die lokale Variable a (B) die globale Variable gleichen Namens. In der Program-
miersprache C wäre der globale Wert damit nicht mehr adressierbar, und es wäre nur
ein Zugriff auf den lokalen Wert möglich (C). In C++ können Sie über den Globalzu-
griff das globale a mit der Notation ::a ansprechen (D), und es ergibt sich die fol-
gende Ausgabe:
702
19.5 Operatoren
lokal: 2
global: 1
Über den Globalzugriff lässt sich der Konflikt an dieser Stelle entschärfen. Generell
sollten Sie Namensüberschneidungen natürlich dennoch vermeiden – im Allgemei-
nen indem Sie die Namen der lokalen Funktion ändern, da hier die Auswirkungen
leichter zu überblicken sind. Dass Sie Überschneidungen vermeiden sollten, liegt
auch daran, dass Sie mit der hier gezeigten Methode nur auf globale Variablen zugrei-
fen können. Nicht-globale Überlagerungen können auch mit dieser Methode nicht
aufgelöst werden.
:: cl::mem Class-Member- 19
Zugriff
. a.x Strukturzugriff
-- x-- Post-Dekrement
703
19 Einführung in C++
-- --x Pre-Dekrement
+ +x plus x arithmetischer
Operator
- -x minus x
* *p Dereferenzierung Zugriffsoperator
.* Pointer-to-Mem- Zugriffsoperator L 14
ber-Zugriff
->* Pointer-to-Mem-
ber-Zugriff
704
19.5 Operatoren
!= x!=y ungleich
/= x/=y
%= x%=y
&= x&=y
^= x^=y
|= x|=y
<<= x<<=y
>>= x>>=y
705
19 Einführung in C++
Eine Leseanleitung zu dieser Tabelle (noch in der Version für die Programmierspra-
che C) finden Sie in Kapitel 18, »Zusammenfassung und Ergänzung«.
Einige der Operatoren und deren Operatorzeichen liegen außerhalb des Minimalzei-
chensatzes, der weltweit gleich interpretiert wird und überall vollständig zur Verfü-
gung steht. Daher wurden für einige Operatoren, die Sie bereits kennen, neue
Schreibweisen hinzugefügt, die ohne diese entsprechenden Sonderzeichen aus-
kommen.
Operator Alternative
! not
&& and
& bitand
|| or
| bitor
^ xor
~ compl
!= not_eq
|= or_eq
^= xor_eq
&= and_eq
if ( !( a&b))
{
...
}
706
19.5 Operatoren
Auch wenn es sich um einen Standard handelt, benötigen manche Compiler zusätz-
liche Einstellungen oder Inkludierungen, um entsprechenden Code zu übersetzen.
Sie sollten diese Varianten daher nicht aktiv verwenden, sie können Ihnen aber in
fremden Programmen begegnen.
In C++ gibt es darüber hinaus noch eine weitere Änderung, die das gesamte System
der Operatoren betrifft. Genau genommen, haben die meisten Operatoren gar keine
feste Bedeutung mehr. Es handelt sich jetzt stattdessen eher um ein System, das die
Stelligkeit (unär oder binär), die Assoziativität (links oder rechts) und die Priorität der
Operatoren vorgibt. Die Funktionalität der Operatoren steht nur noch für die inte-
gralen Datentypen des Compilers fest. Für selbst definierte Datentypen hängt die
konkrete Bedeutung eines Operators in C++ von den Datentypen ab, auf die er ange-
wandt wird. Der Programmierer kann die Funktionsweise von Operatoren verän-
dern. Was das bedeutet und wie das geht, erfahren Sie jetzt.
a = b + c;
a = operator+( b, c);
Damit ist es naheliegend, dass wir diese Funktion wie andere Funktionen überladen 19
können. Das bedeutet, dass Sie die Operationen, die ein Operator ausführt, abhängig
von den verwendeten Datentypen, selbst bestimmen können. Damit stehen Ihnen
ganz neue Möglichkeiten offen.
Das gilt natürlich nicht nur für den +-Operator, sondern auch für fast alle anderen
Operatoren wie etwa:
++ – * / % []
왘 Strukturzugriff .
왘 Pointer-to-Member .*
왘 Class-Member-Zugriff ::
4 Beachten Sie, dass die explizite Verwendung dieser Darstellung für nicht selbst definierte Daten-
typen untersagt ist. Das soll uns aber nicht weiter stören, da der besondere Wert dieser Technik
gerade bei eigenen Datentypen zum Tragen kommt, wie ich Ihnen gleich zeigen werde.
707
19 Einführung in C++
왘 bedingte Auswertung ? :
왘 Typspeichergröße sizeof
Ich werde Ihnen die Möglichkeiten dieser Überladung wieder mit der Datenstruktur
punkt demonstrieren5.
struct punkt
{
int x;
int y;
};
Für diese Struktur wollen wir eine Addition einführen. Aus der Mathematik kennen
Sie die Addition von Vektoren. Die Summe zweier Punkte ist dort definiert als
(x, y) + (u, v) = (x + u, y + v)
Der neue einzuführende Operator + für Punkte ist eine Funktion, die zwei Punkte p1
und p2 als Übergabeparameter erhält und einen Punkt als Ergebnis zurückliefert. Die
Schnittstelle der zugehörigen Operatorfunktion ergibt sich damit zu:
Die Funktion erhält die beiden Punkte p1 und p2 als Argumente (A). Innerhalb der
Funktion wird eine zusätzlich Variable vom Typ punkt für das Ergebnis erzeugt (B),
und die eigentliche Addition wird durchgeführt (C und D). Als Resultat wird der neue
Punkt zurückgegeben (E).
Die Addition von Punkten in einem Programm wird damit sehr einfach und gut les-
bar dargestellt:
5 Das Überladen der eingebauten Operatoren (z. B. zur Addition von int-Werten) ist nicht möglich.
708
19.5 Operatoren
void main()
{
punkt x = {1, 2};
punkt y = {3, 4};
punkt u,v ;
A u = x + y;
print( u);
B v = operator+( y, y);
print( v);
}
In dem Programm werden beide möglichen Schreibweisen verwendet. In (A) wird die
Addition über die Operatorschreibweise aufgerufen, in (B) wird die Funktions-
schreibweise verwendet.
Die Ausgabe der Ergebnisse erfolgt über die passende print-Funktion aus dem voran-
gegangenen Abschnitt, die für den Datentyp punkt überladen worden ist:
Punkt: (4, 6)
Punkt: (6, 8)
In diesem Sinne können wir auch weitere Operatoren anpassen und z. B. einen Ope-
rator für die skalare Multiplikation erstellen. Hier soll der Operator einen int-Wert
als Faktor sowie eine Struktur punkt erwarten und einen punkt zurückliefern. Die 19
Berechnungsvorschrift dafür lautet:
a · (x, y) = ( a · x, a · y)
709
19 Einführung in C++
void main()
{
punkt x = { 1, 2 };
punkt y = { 3, 4 };
punkt u, v;
u = x + y;
print ( u );
v = operator+( y, y );
print( v );
A print( operator*( 3, v ));
}
und geben in (A) das Ergebnis der Skalarmuliplikation ohne Erstellung einer Zwi-
schenvariablen mit der überladenen print-Funktion aus:
Punkt: (4, 6)
Punkt: (6, 8)
Punkt: (18, 24)
Mit überladenen Operatoren lassen sich viele Operationen auf Datenstrukturen gut
erfassbar darstellen. Die umfassende Implementierung eines kompletten und kon-
sistenten Operatorenmodells ist allerdings oft aufwendig.
Im Beispiel oben sollte etwa auch die folgende Skalarmultiplikation explizit imple-
mentiert werden:
Wie Sie sehen, kann hier aber schon der bestehende Operator zur Implementierung
verwendet werden. Bei der Verwendung überladener Operatoren muss darauf geach-
tet werden, dass die bereitgestellten Operatoren stimmig und intuitiv sind. Zum Bei-
spiel sollte beim Überladen des operator== typischerweise auch operator!=
überladen werden etc. Sind die Operatoren nicht entsprechend angelegt, kann besser
eine normale Funktion verwendet werden, die den Benutzer nicht in die Irre führt.
Durch die leichtere Verwendung und Lesbarkeit des entstehenden Codes lohnt sich
der Aufwand zur Implementierung der Operatoren aber durchaus. In den Beispielen
des nächsten Abschnitts werde ich Ihnen die Umsetzung eines entsprechenden Ope-
ratormodells demonstrieren.
710
19.6 Auflösung von Namenskonflikten
Dies kommt umso öfter vor, je größer die Programme werden, und je mehr Bibliothe-
ken genutzt werden – seien es fremde oder selbst erstellte. So ist z. B. der Funktions-
name print zur Ausgabe wenig originell, taucht aber gerade daher häufig auf. Das
Gleiche gilt für bestimmte Variablennamen.
In eigenen Programmen lassen sich solche Konflikte durch die Einführung von
Namenkonventionen meist vermeiden. Dazu erhalten z. B. alle eigenen Funktionen
ein bestimmtes Präfix.
Das hier diskutierte Problem und eine möglich Lösung gibt es auch im realen Leben
– und zwar mit Straßennamen. Innerhalb einer Stadt kann die Verwaltung dafür sor-
gen, dass alle Straßennamen innerhalb der Stadt einmalig sind. Werden aber zwei
Städte zusammengelegt, kann es natürlich vorkommen, dass es plötzlich zwei Bahn-
hofstraßen oder Marktstraßen gibt. Wenn nun keine der Straßen umbenannt werden
soll, muss für die Straßen eine anderweitige Unterscheidung festgelegt werden, ein
sogenannter Namensraum, z. B. in Form von Stadtteilen. Wenn wir in der Stadt einen
Stadtteil A und einen Stadtteil B mit jeweils einer Bahnhofstraße haben, wird man für
die genaue Bezeichnung den Stadtteilnamen mit angeben, also »Bahnhofstraße in
19
Stadtteil B«. Alternativ kann die Verwaltung einen neuen Stadtteil benennen. Einen
ähnlichen Mechanismus gibt es mit den sogenannten Namensräumen auch in C++.
Die Namensräume übernehmen hier die Funktion von Stadtteilen. In C++ bevorzugt
man dazu allerdings eine etwas kompaktere Notation in der Art von
Stadtteil_B::Bahnhofstrasse
Ich werde jetzt einen Fall von Namensüberschneidungen in C++ künstlich erzeugen
und einige Dateien erstellen, die uns sicher einen Namenskonflikt einbringen wer-
den. An diesem Beispiel werde ich Ihnen dann zeigen, wie Sie den Konflikt mit
Namensräumen auflösen können.
711
19 Einführung in C++
eins.cpp zwei.cpp
#include "eins.h" #include "zwei.h"
void funktion() void funktion()
{ {
// Funktionscode // Funktionscode
} }
eins.h zwei.h
extern void funktion(); extern void funktion();
beispiel.cpp
#include "eins.h"
#include "zwei.h"
void main()
{
funktion(); // aus Modul eins
funktion(); // aus Modul zwei
}
namespace eins
{
}
Der Name des Namensraums ist im Rahmen der üblichen Namensregeln frei wähl-
bar. Insbesondere muss er nicht dem Namen der Header-Datei entsprechen. Die
geschweiften Klammern, die den Block nach dem Schlüsselwort markieren, enthal-
ten all das, was in den erzeugten Namensraum gehören soll. In unserem einfachen
Beispiel ist das jeweils die einzelne Funktion:
712
19.6 Auflösung von Namenskonflikten
namespace eins
{
extern void funktion();
}
Damit ist funktion aus der Datei eins.cpp im Namensraum eins platziert. Das bedeu-
tet, dass sie zukünftig mit ihrem »vollen« Namen angesprochen werden muss. Die
Notation dafür lautet:
eins::funktion()
Der Funktionsname in dieser Notation wird auch als voll qualifiziert bezeichnet.
Konsequenterweise werde ich jetzt auch das zweite Modul mit einem entsprechen-
den Namespace versehen. Das Gesamtergebnis sieht dann so aus:
eins.cpp zwei.cpp
#include "eins.h" #include "zwei.h"
void eins::funktion() void zwei::funktion()
{ {
// Funktionscode // Funktionscode
} }
eins.h zwei.h
namespace eins namespace zwei
{ {
extern void funktion(); extern void funktion();
} }
19
beispiel.cpp
Angabe des #include "eins.h"
Namespaces #include "zwei.h"
void main() Nutzung der
{ Funktionen
Definition der Funktion eins::funktion();
mit qualifiziertem zwei::funktion();
Namen }
Mit der Angabe der voll qualifizierten Namen im Hauptprogramm konnte ich das
gesamte Beispiel damit übersetzungsfähig machen.
713
19 Einführung in C++
#include "eins.h"
#include "zwei.h"
A using eins::funktion;
void main()
{
B funktion();
C zwei::funktion();
}
In dem Beispiel wird eins::funktion mit using als die präferierte Version von funk-
tion eingeführt (A). Damit ist es nun im Weiteren möglich, funktion aus dem
Namensraum eins auch ohne weitere explizite Angabe des Namensraums (B) aufzu-
rufen. Der explizite Aufruf von zwei::funktion ist dabei weiter möglich (C).
A namespace allgemein
{
void f1();
void f2();
}
714
19.6 Auflösung von Namenskonflikten
B namespace speziell
{
void f1();
}
void main()
{
C using speziell::f1;
D using namespace allgemein;
E f1(); // speziell::f1
F f2(); // allgemein::f2
}
In dem Codefragment werden die beiden Namensräume allgemein (A) und speziell
(B) erzeugt. Beide Namensräume enthalten die Funktion f1 und bergen damit einen
potenziellen Namenskonflikt. Über das bereits bekannte using wird nun die Funk-
tion speziell::f1 importiert (C). Anschließend wird mit using namespace allgemein
der vollständige Namensraum allgemein eingebunden.
Der Aufruf von f1 in (E) richtet sich an die Funktion speziell::f1, da die spezifischere
Einbindung aus (C) die höhere Priorität besitzt. Der Aufruf von f2 richtet sich durch
die Einbindung des gesamten Namensraums allgemein an allgemein::f2.
Es gibt noch einige weitere Möglichkeiten und Feinheiten bei der Definition und Nut- 19
zung von Namensräumen wie etwa anonyme und geschachtelte Namensräume,
diese wollen wir hier aber nicht weiter behandeln.
Um die Funktion printf zur Ausgabe einzubinden, haben Sie bisher den folgenden
Eintrag verwendet:
#include <stdio.h>
715
19 Einführung in C++
In C++ ist den C-Laufzeitbibliotheken jeweils der Buchstabe c vorangestellt, und das
Suffix ».h« fehlt. In C++ erfolgt die Einbindung daher folgendermaßen:
#include <cstdio>
Analog sind die Namen der anderen Bibliotheken geändert worden6. Die wichtigere
Änderung dabei ist aber, dass die Funktionen der Standardbibliothek jetzt einem
Namensraum mit dem Namen std zugeordnet sind. Die Ansprache der Funktionen
in ihrem Namensraum std kann jetzt so erfolgen:
A #include <cstdio>
void main()
{
B std::printf( "Hello World!\n");
}
In dem Beispiel wird die C++-Version als Äquivalent zur Header-Datei stdio.h für die
Standardausgabe eingebunden (A) und die Ausgabe mit dem voll qualifizierten
Namen aufgerufen (B).
In den meisten Fällen ist es dabei sinnvoll, gleich den kompletten std-Namensraum
einzubinden, sodass sich folgendes Vorgehen ergibt:
A #include <cstdlib>
void main()
{
char *str = "123456";
C int n = atoi( str);
}
Hier wird in (A) die Header-Datei cstdlib als C++-Äquivalent zu stdlib.h eingebun-
den. Danach wird der komplette Namensraum std importiert (B). Damit können die
Funktionen der Laufzeitbibliothek ohne die voll qualifizierte Angabe in (C) aufgeru-
fen werden.
Weitere Informationen und Details für Ihren Compiler sollten Sie in der detaillierten
Dokumentation des Compilers finden.
6 Die alten Header sind in den meisten Compilern weiter verfügbar, Sie werden aber den neuen
Headern zunehmend öfter begegnen.
716
Kapitel 20
Objektorientierte Programmierung
Der Prolog zu C++ ist geschafft, in diesem Kapitel erkläre ich Ihnen die
Grundlagen der objektorientierten Programmierung.
Wenn Sie sich an die Datenstruktur Liste zurückerinnern, wissen Sie, dass die Struk-
tur die Datenelemente einer Liste speichern kann, z. B. Anker, Vorgänger und Zeiger
auf ein Listenelement. Sie wissen aber auch noch, dass die Datenstruktur allein dem
Programmierer wenig nutzt.
Erst in Kombination mit den Operationen, die auf dieser Datenstruktur arbeiten,
wird die Liste wirklich anwendbar. Ohne ihre separat definierten Operationen wie
create, insert, remove und find ist die Datenstruktur wenig wert.
Die Operationen benötigen die Datenstruktur aber gleichermaßen. Der Nutzen bei-
der Elemente entsteht aus der Kombination von Daten und Verhalten.
Diese Kombination der beiden Elemente ist also notwendig, sie bedeutet aber auch
einen erhöhten Aufwand bei der Wartung und Pflege des Codes. Dies gilt besonders,
717
20 Objektorientierte Programmierung
weil der Code für die Datenstruktur und die Operationen typischerweise über meh-
rere Dateien hinweg verteilt ist.
Eine Änderung muss meist parallel an beiden Elementen durchgeführt werden. Wenn
neue Operationen hinzugefügt werden, muss oft eine Anpassung der Datenstruktur
erfolgen, damit die Operationen die Struktur verwenden können und umgekehrt.
Durch die Trennung von Daten und Verhalten wird damit die Wartbarkeit und Erwei-
terbarkeit eines Systems erschwert. Dies ergibt sich durch die Aufteilung in die
genannten Bereiche und die daraus resultierende Aufteilung auf mehrere Dateien.
Ein Bestreben der objektorientierten Programmierung ist es nun, die Daten und Ope-
rationen zu kombinieren, um die Wartung und Weiterentwicklung zu vereinfachen.
Wie diese Kombination erreicht wird, werden Sie in diesem Kapitel erfahren.
Moderne Softwareentwicklung ist dadurch geprägt, dass die Systeme immer umfang-
reicher und komplexer werden. Während der Umfang von Projekten in den 70er-Jahren
des vergangenen Jahrhunderts noch in der Größenordnung von Zehn- und Hundert-
tausenden Zeilen Programmcode gelegen hat, sind heute mehrere Millionen Zeilen von
Code, sogenannten Source Lines of Code (SLOC) in Projekten durchaus üblich:
2001 Windows XP 40
718
20.2 Objektorientiertes Design
Die Angabe der Codezeilen eines Projekts durch SLOC ist für einen Vergleich der
Komplexität von Projekten mit Sicherheit nicht geeignet, gibt Ihnen aber eine Vor-
stellung davon, welche Mengen an Code in großen Projekten verwaltet werden müs-
sen. Es ist einleuchtend, dass bei solchen Projektgrößen diese Themen zunehmend
an Bedeutung gewinnen:
Allen drei Punkten ist gemeinsam, dass sie den Aufwand in der Entwicklung verrin-
gern und Komplexität reduzieren sollen. Bei Erstellung und Betrieb von Software
übersteigen die Kosten für die Menschen, die diese Software erstellen, die Kosten, die
für den technischen Betrieb der Maschinen aufgewendet werden, in vielen Fällen.
Performance und Effizienz von Programmen sind weiter wichtig, in vielen Bereichen 20
bestimmen aber die Kosten und die Geschwindigkeit der Erstellung, Wartung und
Pflege eines Systems den Erfolg im Wettbewerb.
Generell ist alles, was im Anwendungsgebiet unseres Programms als Substantiv ver-
wendet wird, ein Anwärter dafür, als Objekt repräsentiert zu werden. Beispiele dafür
719
20 Objektorientierte Programmierung
sind eine Person, eine Datei, ein Bankkonto, ein Datum oder Termin oder auch eine
Prüfung. Aber auch abstraktere Konzepte wie Listen oder Warteschlangen sind geeig-
net, als Objekt repräsentiert zu werden.
Für mein Beispiel wähle ich mit einem Auto und einem Datum einen Gegenstand
und ein Konzept, die ich als Objekte repräsentieren möchte.
31
Abbildung 20.1 Beispiel für zu repräsentierende Objekte
In der UML werden Objekte durch ein Rechteck mit drei Feldern repräsentiert. Im
oberen Feld steht der Name des Objekts:
720
20.2 Objektorientiertes Design
UML-Darstellung
31
Abbildung 20.2 Darstellung von Objekten in der UML
Ein Objekt befindet sich zu jedem Zeitpunkt in einem genau definierten Zustand.
Dieser Zustand wird durch die Attribute des Objekts beschrieben. Die Attribute ent-
halten alle Daten, um den Zustand des Objekts als Modell vollständig und konsistent
zu beschreiben. Die Attribute eines Objekts werden in der UML im mittleren Feld der
Objektbeschreibung dargestellt.
leistung
20
geschwindigkeit
verdeck_offen
Datum
Konzept eines Datums
tag
monat
jahr
31
Abbildung 20.3 Darstellung der Attribute von Objekten
721
20 Objektorientierte Programmierung
Unser Datumsobjekt soll ein Datum speichern können. Dazu möchten wir den Tag,
den Monat und das Jahr festhalten. Wir sehen daher die entsprechenden Attribute
vor. Die Attribute, die ich für die Modellierung des Autos vorgesehen habe, können
Sie dem Diagramm entnehmen. Sie sehen hier auch schon zwei unterschiedliche
Arten von Attributen. Die Leistung des modellierten Autos gehört zu den beständi-
gen Stammdaten, die oft nur bei der Erstellung eines Objekts erzeugt oder geän-
dert werden. Der Zustand des Verdecks gehört zu den Bewegungsdaten, die sich
öfter verändern. Für die weitere Darstellung werde ich aber nicht zwischen beiden
unterscheiden.
Eine Warteschlange als Objekt hätte als Attribute z. B. die Größe der Warteschlange
sowie die enthaltenen Elemente und deren Reihenfolge.
An dieser Stelle könnten Sie einwenden, dass die Attribute, die ich zur Beschreibung
verwendet habe, nicht ausreichend sind oder schon zu viele Informationen enthal-
ten. Beides kann richtig sein. Für einen Automobilhersteller wäre »mein« Auto mit
den gegebenen Attributen sicherlich nicht ausreichend beschrieben. Auch eine Soft-
ware für Probleme der Astronomie oder zur Speicherung geologischer Daten würde
aufgrund der auftretenden Zeiträume die eher in Millionen von Jahren gemessen
werden, sicherlich eine andere Datumsrepräsentation verwenden.
Die hier angegebenen Beispiele stellen natürlich nur eine exemplarische und noch
unvollständige Beschreibung dar und müssten weiter detailliert werden – je nach-
dem, für welchen Zweck das Objekt verwendet werden soll. Die Modellierung eines
Objekts muss für die Problemstellung angemessen sein und kann sich von Anwen-
dungsfall zu Anwendungsfall unterscheiden. Vorerst soll die oben beschriebene
Modellierung aber ausreichen.
Den hier gezeigten Zustand konnten Sie auch mit Datenstrukturen schon gut abbil-
den. Objekte haben zu ihrem Zustand aber noch ein dynamisches Verhalten. Die
Methoden eines Objekts beschreiben alle Operationen, die das Objekt ausführen
kann.
In der UML werden die Methoden eines Objekts im unteren Feld der Objektbeschrei-
bung geführt (siehe Abbildung 20.4).
In dem Beispiel sehe ich hier nur die Möglichkeit zum Setzen eines Datums und zum
Lesen eines Datums sowie eine Abfrage vor, die darüber Auskunft gibt, ob ein Datum
in einem Schaltjahr liegt.
Um den Einstieg in das Thema zu finden, sind die Begriffe in der oben stehenden
Erläuterung noch etwas unscharf.
722
20.2 Objektorientiertes Design
leistung
geschwindigkeit
verdeck_offen
beschleunigen()
verdeck_bedienen()
hupen()
Datum
Konzept eines Datums
tag
monat
jahr
31 datum_setzen()
datum_lesen()
ist_schaltjahr()
Dies will ich jetzt präzisieren. Bisher habe ich nicht unterschieden zwischen einem
Datum in seiner allgemeinen Form und einem konkreten Datum. Diese Unterschei-
dung ist aber ausgesprochen wichtig. So hat ein Datum im Allgemeinen Tag, Monat
und Jahr. Der letzte Heiligabend im 20. Jahrhundert hatte aber genau das konkrete
Datum 24.12.2000.
Betrachtet man gleichartige Objekte, findet man eine Reihe von Gemeinsamkeiten,
aus denen man Klassen bilden kann, wie wir es für Autos und Daten schon getan
haben. 20
Jedes betrachtete Datum hat einen Tag, einen Monat und ein Jahr. Es gibt also eine
Vorlage, die ein Datum im Sinne unseres Anwendungsfalls allgemein beschreibt.
Diese Vorlage existiert aus sich heraus, sie ist eine generelle Beschreibung. Auch
wenn noch kein einziges konkretes Datum im System erfasst ist, können wir eine
generelle Klasse zu seiner Beschreibung erstellen.
Unter einer Klasse verstehen wir die softwaretechnische Beschreibung einer Vorlage
(Attribute und Methoden) für ein Objekt.
Wenn wir ein Objekt als eine spezielle Ausprägung einer solchen Klasse mit einem
konkreten Zustand erstellen, wird dieser Prozess Instanziierung genannt. Daher wer-
den die aus einer Klasse erzeugten Objekte auch als Instanzen bezeichnet.
So, wie Sie mit einer Plätzchenform konkrete Plätzchen ausstechen können, können
Sie mit einer Klasse konkrete Instanzen erstellen.
723
20 Objektorientierte Programmierung
Eine Instanz ist eine konkrete Ausprägung einer Klasse mit spezifischen Attributen,
die ihren Zustand bestimmen.
Wenn der Begriff Objekt verwendet wird, ist damit nicht klar, ob eine Klasse oder eine
Instanz gemeint ist. Im Folgenden werde ich daher vorrangig die Begriffe Klasse und
Instanz nutzen. Von Objekten werde ich nur sprechen, wenn die Unterscheidung
unerheblich ist. Wie Sie vielleicht jetzt schon erkennen können, befasst sich das
objektorientierte Design eher mit Objekten im Sinne von Klassen als im Sinne von
Instanzen.
Die Unterscheidung zwischen der Klasse und der Instanz findet sich auch in der UML.
Dort wird eine Instanz dadurch deutlich gemacht, dass der Name der Klasse unter-
strichen und der Name der Instanz durch einen Doppelpunkt getrennt hinten ange-
fügt wird. Die konkreten Attributwerte einer Instanz notieren wir mit einem
Gleichheitszeichen hinter dem Attributnamen.
Auto MeinCabrio:Auto
leistung leistung = 37
geschwindigkeit geschwindigkeit = 50
verdeck_offen verdeck_offen = true
beschleunigen() beschleunigen()
verdeck_bedienen() verdeck_bedienen()
hupen() hupen()
Datum Silvester:Datum
tag tag = 31
monat monat = 12
jahr jahr = 2014
datum_setzen() datum_setzen()
datum_lesen() datum_lesen()
ist_schaltjahr() ist_schaltjahr()
Weitere Konzepte der Objektorientierung wie die Vererbung und die sogenannte
Polymorphie oder auch Vielgestaltigkeit werden wir vorerst zurückstellen und
betrachten, wenn wir sie direkt praktisch umsetzen können.
724
20.4 Aufbau von Klassen
Als Beispiel werden wir in den folgenden Kapiteln die Elemente eines Logbuchs ver-
wenden und anfangs auch das oben erwähnte Datum wieder kurz aufgreifen. In dem
Logbuch sollen bestimmte Ereignisse protokolliert werden. Zu einem Logbuchein-
trag gehört neben der Bezeichnung des eingetretenen Ereignisses das Datum, an
dem es stattgefunden hat. Wir werden das Beispiel im weiteren Verlauf ausbauen, es
steht damit aber bereits fest, dass wir eine Klasse zur Repräsentation des Datums
benötigen werden. Mit dieser Klasse werden wir starten und wollen dazu die Klasse
datum erstellen, mit der wir ein Kalenderdatum repräsentieren. Anhand dieses Bei-
spiels werden wir die ersten Schritte in der objektorientierten Programmierung
gehen und später auch wesentliche Aspekte der Objektorientierung wie die Ver-
erbung umsetzen.
C };
Auf das Schlüsselwort class und den Namen der Klasse (A) folgt eine geöffnete
geschweifte Klammer, die den Anfang des Blocks mit dem Inhalt der Klasse markiert
(B). Wie eine Struktur hat die Klasse eine geschweifte Klammer als schließendes Ele-
ment, gefolgt von einem Semikolon (C).
725
20 Objektorientierte Programmierung
Auch der Zugriff auf meinen Kühlschrank ist privat. Auf meinen Kühlschrank kann
allerdings auch meine Familie zugreifen. Diese Form eines geschützten Zugriffs fin-
den wir auch in der objektorientierten Entwicklung bei Objekten, die miteinander
verwandt sind. Diese Form der Verbindung werden Sie später als Vererbung kennen-
lernen.
Im täglichen Leben kann ich z. B. auch Freunden (friend) einen Zugriff auf meinen
Kühlschrank erlauben, sie können sich dann an meinem privaten Kühlschrank
bedienen, als wäre es ihr eigener. Die sonst geltende Einschränkung ist für sie aufge-
hoben.
Anders als bei Datenstrukturen gibt es bei Klassen also einen Schutz gegen die miss-
bräuchliche Verwendung der Member der Klasse. Um diesen Schutz wirksam werden
zu lassen, werden die Member einer Klasse in entsprechenden Bereichen deklariert.
Die entsprechenden Bereiche werden innerhalb der Klassendefinition durch die
jeweiligen Schlüsselwörter gekennzeichnet:
726
20.4 Aufbau von Klassen
class datum
{
A private:
B protected:
C public:
};
Auf Elemente im privaten Bereich private (A) besteht besonderer Zugriffsschutz. Auf
Elemente im öffentlichen Bereich public (C) kann jeder zugreifen. Die Bedeutung des
Bereichs protected (B) werden Sie später kennenlernen.
Alle Elemente, die keinem Bereich zugeordnet sind, sind privat. Die Bereiche können
auch mehrfach und in beliebiger Reihenfolge vorkommen. Die hier angegebene Rei-
henfolge ist allerdings üblich und bietet sich aus Gründen der Übersichtlichkeit an.
20.4.2 Datenmember
Die Daten, die wir in Klassen speichern, werden Attribute oder Datenmember
genannt. Zur Umsetzung unserer Datumsklasse können wir die notwendigen Daten
im öffentlichen Bereich der Klasse speichern. Damit ist ein lesender und schreiben-
der Zugriff von überall her möglich.
class datum 20
{
A public:
B int tag;
int monat;
int jahr;
};
Nach dem Beginn des öffentlichen Bereichs (A) werden die Attribute zur Speicherung
eines Datums in der Klasse deklariert (B).
Der Zugriff auf Attribute erfolgt wie bei Strukturen mit dem Punkt-Operator:
727
20 Objektorientierte Programmierung
int main()
{
datum d;
d.tag = 24;
d.monat = 12;
d.jahr = 2014;
}
In unserem Beispiel werden bisher nur Integer verwendet, generell können in einer
Klasse aber alle elementaren Datentypen und deren Arrays als Attribute verwendet
werden3. Dies umfasst auch Datenstrukturen:
class klasse
{
A private:
int i;
float f;
char c;
B char string[20];
public:
int* pi;
C struktur str;
//...
D public:
short s;
};
Im Beispiel ist eine Auswahl verschiedener Datentypen als private-Attribute (A) der
Klasse deklariert, unter anderem ein Array (B), eine Struktur (C) und in einem zweiten
öffentlichen Bereich (D) ein weiterer elementarer Datentyp. Natürlich können auch
Klassen in Klassen vorkommen, dies wird im nächsten Kapitel detailliert behandelt.
Bis zu dieser Stelle ist unsere Klasse mit einer Datenstruktur identisch. Faktisch ent-
spricht eine Klasse mit ausschließlich öffentlichen Datenmembern einer Daten-
struktur.
3 Ebenso wie bei Datenstrukturen müssen die Werte der Attribute korrekt initialisiert werden.
728
20.4 Aufbau von Klassen
20.4.3 Funktionsmember
Wir haben in der Vergangenheit bereits ausgiebig mit Datenstrukturen und separa-
ten Funktionen gearbeitet. Dabei waren die Datenstrukturen und Funktionen strikt
getrennt. Formal waren sie damit zwar unabhängig, wenn Sie sich deren Zusammen-
wirken anschauen, sind Daten und Funktionen aber durchaus stark miteinander ver-
flochten.
Werfen Sie z. B. einen Blick auf das behandelte Listen-Modul, dann ist offensichtlich,
dass die Datenstruktur erst mit den Listenoperationen (create, insert, remove, find)
sinnvoll verwendet werden kann. Andererseits können die Operationen nur mit der
jeweiligen Datenstruktur arbeiten und sind ohne diese Strukturen nutzlos.
Wir wollen nun unserer Datumsklasse eine erste Methode und damit ein Verhalten
hinzufügen. Dabei beginnen wir mit einer Methode, die übergebene Parameter für
tag, monat und jahr in den entsprechenden Datenmembern speichert.
20
Dieses Verhalten erscheint im Moment etwas überflüssig, da der Benutzer der Klasse
die Werte ja direkt schreiben kann, wie Sie bereits gesehen haben. Der Nutzen diese
Methode wird sich aber sehr schnell zeigen.
Wir werden unsere Methode set nennen. Methoden, die die Werte von Datenmem-
bern setzen, werden oft auch als Setter-Methoden bezeichnet. Unsere Methode soll
die folgende Schnittstelle besitzen:
Die Methode erhält die Parameter für die zu setzenden Werte von tag, monat und jahr
und liefert keinen Rückgabewert.
Wir platzieren unsere Setter-Methode mit ihren drei Parametern ebenfalls im öffent-
lichen Bereich der Klasse. So kann jeder auf sie zugreifen.
729
20 Objektorientierte Programmierung
class datum
{
public:
int tag;
int monat;
int jahr;
Die Methode wird in der Klasse mit einer Inline-Implementierung angelegt (A). Dies
sieht auf den ersten Blick etwas ungewöhnlich aus, wenn Sie die Formatierung anpas-
sen, erkennen Sie aber schnell die gewohnte Form:
Die Attribute sind für die Klasse definiert, zu der die Methode gehört. Innerhalb der
Methode erfolgt der Zugriff auf die Attribute der Klasse selbst ohne Punkt-Operator.
Einen Zugriff auf Attribute und Methoden einer Klasse durch Methoden der Klasse
selbst bezeichnen wir auch als einen Zugriff von »innen«. Alle anderen Zugriffe wer-
den auch als Zugriff von »außen« bezeichnet.
Wir haben mit set eine öffentliche Methode zum Schreiben der Attribute implemen-
tiert. Der Aufruf einer solchen Methode von außen erfolgt wie der Zugriff auf die
Attribute einer Klasse über den Punkt-Operator:
int main()
{
datum ha; //Heiligabend
A ha.set( 24, 12, 2014);
B int t = ha.tag;
printf( "Der Tag des Datums ist: %d\n", t);
}
730
20.4 Aufbau von Klassen
Aus dem Hauptprogramm erfolgen ein Aufruf der Methode set zum Setzen der Attri-
bute für das Datum ha (A) und ein lesender Zugriff auf das Attribut tag (B), und wir
erhalten damit folgende Ausgabe:
class datum
{
private:
A int tag;
int monat;
int jahr;
public
B void set( int t, int m, int j)
{ tag = t; monat = m; jahr = j;}
};
20
Wir erklären daher die Datenmember für private (A) und belassen nur die set-
Methode im öffentlichen Bereich (B). Die set-Methode bleibt damit auch weiter aus
unserem Programm aufrufbar. Die Methode selbst gehört mit zur Klasse, sie befindet
sich »innen«, daher darf sie ohne Einschränkungen auf private Elemente der Klasse
zugreifen. Hier zeigt sich nun der Nutzen dieser Methode, die jetzt weiter den Zugriff
auf die nun privaten Daten ermöglicht.
int main()
{
datum ha; //Heiligabend
A ha.set( 24, 12, 2014);
B int t = ha.tag; // FEHLER
}
731
20 Objektorientierte Programmierung
Der Zugriff auf die set-Methode im öffentlichen Bereich (A) arbeitet wie erwartet. Der
Zugriff von außen auf das private Attribut tag der Klasse (B) schlägt fehl.
Durch die Verschiebung in den privaten Bereich können die Attribute nun allerdings
auch nicht mehr gelesen werden. Wir wollen unsere Attribute aber natürlich nicht
nur setzen, sondern sie auch auslesen können. Die Lösung für dieses Problem liegt
nahe. Wir erstellen zu jedem zu lesenden Attribut eine Methode, die das Attribut aus-
liest und den entsprechenden Wert als Resultat zurückgibt. Solche lesenden Metho-
den werden auch Getter-Methoden genannt. Auch unsere Getter legen wir als Inline-
Methoden an und platzieren sie im öffentlichen Bereich:
class datum
{
private:
int tag;
int monat;
int jahr;
public:
A int getTag() { return tag;}
B int getMonat() { return monat;}
C int getJahr() { return jahr;}
void set( int t, int m, int j)
{ tag = t; monat = m; jahr = j;}
};
Nun können wir auch direkten lesenden Zugriff auf die Attribute durch unsere Get-
ter-Methoden (A, B und C) ersetzen und erhalten den gewünschten Wert. Damit kön-
nen wir über die Methoden nun mittelbar wieder auf die privaten Attribute
zugreifen:
int main()
{
datum ha; //Heiligabend
ha.set( 24, 12, 2014);
int t = ha.getTag();
}
Die Datenmember der Klasse liegen jetzt im privaten Bereich und sind nur noch über
öffentliche Getter- und Setter-Methoden zugänglich, die das Verhalten der Klasse
darstellen.
732
20.4 Aufbau von Klassen
Auf den ersten Blick mag es so erscheinen, als wären wir praktisch nicht weiter als am
Anfang. Aber wir haben einen großen Fortschritt gemacht. Die Klasse hat ihre Daten
nun gekapselt. Zugriffe auf die internen Daten, die den Zustand beschreiben, sind
nur noch über die bereitgestellten Methoden möglich. Dies eröffnet uns verschie-
dene Optionen:
왘 Wir haben nun die Möglichkeit, schreibenden Zugriff so zu gestalten, dass fehler-
hafte Eingaben korrigiert oder abgelehnt werden, ohne die Instanz der Klasse in
einen inkonsistenten Zustand zu bringen.
왘 Wenn die Anwender der Klasse nur noch die Getter- und Setter-Methoden ver-
wenden, kann der Entwickler der Klasse z. B. Namen der Attribute oder auch deren
Datentyp einfach ändern. Nur die Methoden müssten dann aktualisiert werden.
Da alle Zugriffe ausschließlich durch diese entsprechenden Methoden erfolgen
(können), müsste ein Nutzer dieser Klasse seinen eigenen Programmcode nicht
ändern. Auf diese Weise haben wir in Sachen Wartbarkeit einen großen Fortschritt
erzielt, da Anpassungen in der Klasse erfolgen können, ohne dass der Nutzer der
Klasse zu Änderungen gezwungen wird.
Auch dieses Konzept kennen Sie aus dem Alltag. So kann der Tageskilometerzähler
meines Autos einfach abgelesen werden, das Auslesen des aktuellen Standes ist also
öffentlich. Das Verändern des Kilometerstandes ist nur von innen heraus durch die
Steuergeräte möglich. Das Fahrzeug bietet mir allerdings einen zusätzlichen öffentli-
chen Zugriff von außen – mit der eingeschränkten Funktionalität, den Zählerstand
auf null zurückzusetzen.
Wir wollen in diesem Sinne unsere Setter-Methode so erweitern, dass sie vor dem Set-
zen der Attribute eine einfache Prüfung vornimmt und ungültige Werte korrigiert. Die
20
Prüfungen und die Korrektur halten wir hier bewusst einfach. Wir wollen für unser
Datum annehmen, dass es gültige Daten zwischen dem 01.01.1970 und dem 30.12.2099
aufnehmen soll. Wir gehen davon aus, dass alle Monate 30 Tage lang sind. Selbst mit
dieser Einschränkung ist bereits jetzt abzusehen, dass die Implementierung der
Methode mehrere Zeilen in Anspruch nehmen wird. Eine Inline-Implementierung
wird hier schnell unübersichtlich. Wir wollen daher dieses Funktionsmember außer-
halb der Klasse implementieren. Dazu ersetzen wir zuerst unsere bisherige Implemen-
tierung durch eine Deklaration der Methode:
class datum
{
private:
int tag;
int monat;
int jahr;
733
20 Objektorientierte Programmierung
public:
int getTag() { return tag;}
int getMonat() { return monat;}
int getJahr() { return jahr;}
A void set( int t, int m, int j);
};
Innerhalb der Klasse datum wird die Methode set nun lediglich deklariert und nicht
implementiert (A).
Für die innerhalb der Klasse deklarierte Methode erfolgt nun die Implementierung
außerhalb der Klasse4:
Zur Implementierung der Methode wird der voll qualifizierte Name der Klasse und
Methode als Name der zu implementierenden Funktion verwendet (A). Dieser bildet
sich aus dem Klassennamen, gefolgt vom Class-Member-Operator :: und dem
Namen der Methode. Innerhalb der Funktion erfolgt die Implementierung in
gewohnter Art und Weise. Den Code zur Korrektur der Daten selbst werden wir hier
nicht weiter vertiefen. Ungültige Werte werden einfach immer auf einen vorgegebe-
nen Wert korrigiert.
Mit unserer neuen Setter-Methode werden falsche Datumswerte nun bei der Eingabe
entsprechend korrigiert, sodass unser Objekt nach Aufruf der set-Methode immer
ein gültiges Datum nach den vorgegebenen Regeln enthält:
4 Die Datei, in der die Implementierung erfolgt, benötigt natürlich Kenntnis der Klassendeklara-
tion. Typischerweise erfolgt die Implementierung in einer Datei datum.cpp, die die Deklaration
in datum.h inkludiert.
734
20.4 Aufbau von Klassen
int main()
{
datum dat(
A dat.set(0, 1 1066);
printf( "Datum: %d.%d.%d\n",
dat.getTag(), dat.getMonat(), dat.getJahr());
}
Wenn wir der set-Methode nun ungültige Parameter übergeben (A), werden die Ein-
gaben auf gültige Werte korrigiert. Damit erhalten wir die folgende Ausgabe:
Datum: 1.1.1970
20.4.5 Konstruktoren
Wir können nun Instanzen unserer Klasse datum anlegen und haben mit der set-
Methode dafür gesorgt, dass nur noch im Rahmen der Anforderungen gültige
Datumswerte in die Attribute eingetragen werden können.
Damit ist aber immer noch nicht garantiert, dass die Attribute des Objekts immer
mit konsistenten Werten gefüllt sind. Dies liegt daran, dass wir bisher nicht kontrol-
lieren können, in welchem Zustand das Objekt erstellt wird. Direkt nach seiner
Instanziierung ist der Zustand des Objekts noch undefiniert.
Wir benötigen eine Möglichkeit, den Zustand bereits während der Erstellung der
Instanz zu kontrollieren. Diese Möglichkeit gibt es mit dem sogenannten Konstruk- 20
tor. Der Konstruktor steuert den Instanziierungsprozess eines Objekts und ist dafür
verantwortlich, die Instanz bei der Erstellung direkt in einen konsistenten Zustand
zu bringen.
Das Gegenstück zum Konstruktor ist der Destruktor, der den Abbau einer Instanz
steuert. Diesen werden wir abschließend behandeln.
Der Konstruktor einer Klasse ist eine spezielle Methode. Sie ist so eng mit der Klasse
verknüpft, dass sie den gleichen Namen trägt wie die Klasse selbst. Ein Konstruktor
hat keinen Rückgabetyp, nicht einmal void.
Eine Klasse kann keinen, einen oder mehrere Konstruktoren haben. Ebenso wie
andere Methoden kann der Konstruktor parameterlos sein. Wenn eine Klasse meh-
rere Konstruktoren hat, wird wie bei überladenen Funktionen der passende Kon-
struktor ausgewählt. Dazu werden die Parameter verwendet, die bei der
Instanziierung angegeben worden sind. Entsprechend müssen sich auch die Parame-
tersignaturen der Konstruktoren unterscheiden.
735
20 Objektorientierte Programmierung
Wir wollen nun unserer Klasse einen Konstruktor hinzufügen. Der Konstruktor soll
von überall aufrufbar sein, daher platzieren wir ihn im öffentlichen Bereich.
class datum
{
private:
int tag;
int monat;
int jahr;
public:
int getTag() { return tag;}
int getMonat() { return monat;}
int getJahr() { return jahr;}
void set( int t, int m, int j);
A datum( int t, int m, int j);
};
Innerhalb der Klasse deklarieren wir einen Konstruktor (A) und implementieren ihn
wie unseren Setter außerhalb der Klassendeklaration:
Die Implementierung selbst greift auf den bestehenden Setter zurück und korrigiert
damit eventuell übergebene falsche Werte. Wir verwenden unseren Konstruktor nun
bei der Instanziierung eines Datums:
int main()
A {
datum em( 1, 5, 2014 ); //1. Mai
printf( "1. Mai: %d.%d.%d\n",
em.getTag(), em.getMonat(), em.getJahr());
}
Wir bedienen in unserem Programm die Schnittstelle des Konstruktors (A) und
erstellen damit eine Instanz der Klasse. Wenn es mehrere Konstruktoren gibt, wird
der passende anhand der Parametersignatur ausgewählt.
736
20.4 Aufbau von Klassen
1. Mai: 1.5.2014
Sobald eine Klasse einen Konstruktor mit Parametern enthält, ist der parameterlose
Konstruktor, den wir im Beispiel bisher verwendet haben, nicht mehr verfügbar.
Solange wir keinen Konstruktor explizit bereitgestellt hatten, war er vom System
automatisch erstellt worden. Dies ist jetzt nicht mehr der Fall. Die folgende Erstel-
lung eines Objekts ist damit jetzt nicht mehr möglich:
int main()
{
datum ha; // FEHLER
}
Wenn wir nach der Erstellung des neuen Konstruktors ein Datumsobjekt ohne Para-
meter erzeugen wollen, erhalten wir einen Compiler-Fehler mit dem Hinweis, der
Konstruktor sei nicht verfügbar.
Wenn Sie für eine Klasse keinen Konstruktor definieren, erstellt C++ für diese Klasse
automatisch einen Konstruktor ohne Parameter. Bisher haben Sie diesen automa-
tisch erstellten Konstruktor verwendet, ohne es zu wissen. Nachdem Sie aber den ers-
ten eigenen Konstruktor erstellt haben, wird dieser parameterlose Konstruktor nicht
mehr vom System bereitgestellt. Wenn Sie weiter einen Konstruktor ohne Parameter
verwenden wollen, müssen Sie ihn nun selbst implementieren:
20
class datum
{
private:
int tag;
int monat;
int jahr;
public:
int getTag() { return tag;}
int getMonat() { return monat;}
int getJahr() { return jahr;}
void set( int t, int m, int j);
datum( int t, int m, int j);
A datum() { set( 1, 1, 1970); }
};
737
20 Objektorientierte Programmierung
int main()
{
A datum em( 1, 5, 2014); // 1. Mai
printf( "1. Mai: %d.%d.%d\n",
si.getTag(), si.getMonat(), si.getJahr());
B datum ha; // Heiligabend
ha.set( 24, 12, 2014);
}
Ich möchte die generellen Anforderungen an einen Konstruktor noch einmal zusam-
menfassen:
738
20.4 Aufbau von Klassen
Zum Aufruf von Konstruktoren wollen wir noch einen Blick auf die Notation werfen,
da es hier leicht zu einem ganz bestimmten Fehler kommt. Bei den folgenden Notati-
onen handelt es sich um gültige Aufrufe unserer Konstruktoren:
A datum d1;
B datum d2( 1, 4, 2015);
Zuerst wird ein parameterloser Konstruktor aufgerufen (A), anschließend wird ein
Konstruktor mit drei int-Parametern verwendet (B).
Die jetzt folgende Konstruktion sieht nur auf den ersten Blick wie der Aufruf eines para-
meterlosen Konstruktors aus, ist aber kein solcher und führt zu einer Warnung. Es han-
delt sich formal um die Deklaration einer parameterlosen Funktion mit dem Namen
d und dem Ergebnistyp datum, die der Compiler mit einer Fehlermeldung quittiert.
datum d();
20.4.6 Destruktoren
Der Destruktor ist das Gegenstück zum Konstruktor. Er wird aufgerufen, wenn ein
Objekt zerstört wird. Im Destruktor können Aufräumarbeiten wie die Freigabe der
vom Objekt belegten Ressourcen vorgenommen werden. Dies betrifft insbesondere
die Freigabe von allokiertem Speicher.
Jede Klasse kann maximal einen Destruktor haben. Der Destruktor hat wie der Kon-
struktor keinen Rückgabewert, und er ist immer parameterlos. Der Destruktor hat
20
den Namen der Klasse, angeführt von einer vorangestellten Tilde. Der Destruktor
kann nicht explizit aufgerufen werden, er wird vom System aufgerufen, wenn eine
Instanz abgebaut wird.
class datum
{
private:
int tag;
int monat;
int jahr;
public:
int getTag() { return tag;}
int getMonat() { return monat;}
739
20 Objektorientierte Programmierung
Der Destruktor (A) hat in dieser Klasse keine Aufräumarbeiten zu erledigen, er bleibt
leer. In diesem Fall könnte der Destruktor komplett entfallen, Sie werden aber noch
Klassen kennenlernen, bei denen der Destruktor wichtig ist.
In C++ existieren die genannten Methoden weiter. Sie haben aber schon gesehen,
dass mit den Konstruktoren und Destruktoren bei Erzeugung und Abbau von Instan-
zen zusätzlicher Code ausgeführt wird. Die einzelnen Abläufe im Lebenszyklus sind
damit in C++ etwas komplexer als in C.
void funktion()
{
A int auto1;
{
B int auto2;
C }
D }
740
20.5 Instanziierung von Klassen
Zuerst wird die Variable auto1 definiert (A), es folgt die Definition von auto2 (A). Mit
dem Schließen des umgebenden Blocks endet die Lebensdauer von auto2 (C), die
Lebensdauer von auto1 endet in (D).
void funktion()
{
A datum d1;
B datum d2( 1, 1, 2015);
C }
In dem Beispiel wird zuerst der parameterlose Konstruktor von datum aufgerufen (A),
danach wird eine Instanz von datum mit dem Konstruktor für drei int-Werte auf-
gerufen (B). Der automatische Aufruf der Destruktoren erfolgt zuerst für d2 (C),
20
danach für d1.
A int statisch1;
B static int statisch2;
void funktion()
{
C static int statisch3;
//...
}
741
20 Objektorientierte Programmierung
Außerhalb eines Blocks angelegte Variablen sind statisch (A), das Schlüsselwort
static in (B) ist damit redundant. Innerhalb des Blocks werden statische Variablen
mit dem Schlüsselwort static definiert (C).
int main()
{
C static datum d2;
fkt();
5 Ein Arbeiten mit Seiteneffekt wäre z. B. der gemeinsame Zugriff von Objekten auf globale
Variablen.
742
20.5 Instanziierung von Klassen
fkt();
}
Die statische Variable d1 wird zum Programmstart initialisiert (A), d2 wird in main ini-
tialisiert (C). Die Variable d3 wird beim ersten Aufruf von fkt() initialisiert (B) und
bleibt danach weiter bestehen. Alle statischen Variablen werden zum Program-
mende destruiert .
void funktion()
{
int *pi;
A pi = (int*)malloc(sizeof(int));
//...
B free( pi );
}
Der Speicher wird dynamisch allokiert (A) und nach abgeschlossener Verwendung
wieder freigegeben (B). Wird der dynamisch reservierte Speicher nicht freigegeben, 20
ist er bis zum Programmende belegt.
Um ein Objekt dynamisch zu allokieren, reicht es nicht aus, nur den entsprechenden
Speicher bereitzustellen. Es ist unbedingt notwendig, dass auch ein geeigneter Kon-
struktor der Klasse ausgeführt wird. Um dies sicherzustellen, wird in C++ der Opera-
tor new verwendet, um Klassen dynamisch zu instanziieren, also den zugehörigen
Speicher bereitzustellen und den geeigneten Konstruktor aufzurufen. Die Parameter
für einen geeigneten Konstruktor werden dem new-Operator mitgegeben.
Der new-Operator gibt als Ergebnis einen Zeiger auf den erzeugten Objekttyp zurück:
743
20 Objektorientierte Programmierung
A datum* d1;
int main()
{
B datum* d2;
C d1 = new datum;
d2 = new datum( 30, 12, 2014);
D delete d1;
delete d2;
}
Nach der Anlage von Zeigern auf entsprechende Objekte entweder global (A) oder
lokal (B) erfolgen jeweils die dynamische Allokation ab (C) und die Freigabe des Spei-
chers durch Aufruf des delete-Operators (D).
Mit dem Operator delete wird für dynamisch angelegte Klassen deren Destruktor
ausgeführt.
Objekte, die mit new instanziiert worden sind, dürfen auf keinen Fall mit free freige-
geben werden, mit malloc allokierter Speicher nicht mit delete. Beides führt zum
Programmabsturz.
datum ds_array[10];
Eine Übergabe von Parametern an den Konstruktor und eine individuelle Instanziie-
rung sind nicht möglich. Der Aufruf der Konstruktoren erfolgt mit wachsendem
Index.
Ein Array von Objekten kann auch dynamisch allokiert werden. Die Anzahl der
gewünschten Objekte wird auch hier in den eckigen Klammern angegeben. Ein Array
von Objekten wird mit dem delete[]-Operator freigegeben. Es handelt sich dabei um
einen eigenen Operator, der für die Freigabe eines dynamischen Arrays verwendet
werden muss. Falls der delete-Operator zur Freigabe des Arrays verwendet wird, wird
nur das erste Element des Arrays beseitigt, die anderen bestehen weiter.
744
20.6 Operatoren auf Klassen
datum *dd_array;
A dd_array = new datum[10];
// Nutzung des Arrays
B delete[] dd_array;
Nach der Allokation des dynamischen Arrays mit zehn Elementen in (A) erfolgt die
abschließende Freigabe des Arrays in (B).
Den Operator, den Sie dafür brauchen, implementieren Sie mit den Ihnen bereits
bekannten Mitteln:
Wir implementieren die Methode operator-, die als Eingangsparameter die Referen-
zen auf zwei Datumsobjekte erhält (A). Für Datumsoperationen bietet es sich oft an,
745
20 Objektorientierte Programmierung
einen Fixpunkt zu wählen. Dazu ermitteln wir für jedes der beiden Daten die Anzahl
der vergangenen Tage, die von einem imaginären »nullten Januar im Jahr null« bereits
vergangen sind (B, C). Aus diesen Werten berechnen wir dann die Differenz (D).
20.6.1 Friends
Im vorangegangenen Beispiel haben wir den Operator unter Verwendung der Getter-
Methoden implementiert. Dies ist unvermeidlich, da die Funktion operator- nicht
zur Klasse datum gehört und daher keinen Zugriff auf die privaten Datenmember hat.
Nicht nur hier kann sich der umfassende Zugriffsschutz auch als lästig erweisen. In
Bibliotheken von Klassen wollen wir thematisch zusammengehörigen Klassen
untereinander weitergehende Zugriffsrechte einräumen. Um das zu erreichen, kön-
nen wir hier bisher nur nach dem Motto »alles oder nichts« Member im öffentlichen
Bereich der Klasse platzieren.
Um hier differenzierter vorzugehen, kann eine Klasse anderen Funktionen oder Klas-
sen einen besonderen Status zuerkennen und sie zu einem »Freund« erklären. Diese
Freunde (friends) erhalten dann den gleichen Status wie Memberfunktionen der
entsprechenden Klassen und können damit auf deren private Member zugreifen.
Funktionen, die auf unterschiedliche Klassen zugreifen, aber keiner der Klassen
zugeordnet werden sollen, werden oft als Friend-Funktionen erstellt.
Die Freundschaftserklärung gilt nur in eine Richtung. Ich kann jemand anderen zu mei-
nem Freund erklären und ihm damit besondere Zugriffsrechte an meinen privaten
Daten einräumen. Ich kann mich aber nicht selbst zu einem Freund von jemand ande-
rem erklären und mir dadurch besondere Zugriffsrechte an dessen Daten einräumen.
Mit der Erklärung einer Klasse oder Funktion zum Freund sollten Sie sparsam umge-
hen. Es gibt Situationen, in denen diese Möglichkeit sinnvoll eingesetzt werden kann,
ein freigiebiger Umgang mit Freundschaften deutet aber oft auch auf eine ungüns-
tige Modellierung hin. Wir wollen uns ansehen, wie unser Operator als Friend-Funk-
tion implementiert wird.
Wenn unsere Klasse datum die Funktion operator- zu einem Freund erklärt, erweitert
sich die Deklaration der Klasse folgendermaßen:
class datum
{
A friend int operator-( datum& l, datum& r);
private:
// ...
public:
// ...
};
746
20.6 Operatoren auf Klassen
Innerhalb der Klasse wird der operator- mit der gewünschten Signatur durch das
vorangestellte Schlüsselwort zum friend der Klasse erklärt. Damit lässt sich der Ope-
rator etwas knapper implementieren, auch wenn die Funktionalität gleich bleibt:
Durch die Freundschaft zur Klasse hat der implementierte Operator nun direkten
Zugriff auf die privaten Attribute (A, B) und muss nicht mehr die Getter verwenden.
class datum
{
//...
public:
//...
A int operator-( datum& r);
}; 20
Die Deklaration des Operators erfolgt hier als öffentliche Methode (A). In diesem Fall
benötigt ein zweistelliger Operator nur ein Argument. Der erste Operand ist implizit
durch das Objekt gegeben, auf dem der Operator als Memberfunktion ausgeführt
wird. Der zweite Operand ist als Parameter der Methode übergeben:
747
20 Objektorientierte Programmierung
Die Implementierung ähnelt den vorherigen Fällen. Der linke Operand ist das Objekt
selbst, hier erfolgt direkter Zugriff (A). Der zweite Operand, der als Parameter überge-
ben wurde, ist ein Attribut der aktuellen Klasse, wir haben daher Zugriff auf dieses
private Attribut (B).
Wie Sie am Beispiel der printf-Funktion schon gesehen haben, arbeiten diese Funk-
tionen auch unter C++ weiter.
Allerdings gibt es für C++ auch ein System zur Ein- und Ausgabe, das das Klassenkon-
zept und die Möglichkeit zur Überladung von Operatoren ausnutzt. Dieses System
werden wir Ihnen im Folgenden kurz vorstellen, ohne in die Details zu gehen.
Um Text auf dem Bildschirm auszugeben, stellt eine Bibliothek in C++ das Objekt
cout zur Verfügung. Das Objekt ist eine Instanz der Klasse ostream (für Output-
Stream), die vom System zum Programmstart instanziiert wird. Um cout nutzen zu
können, muss zuvor die Bibliothek iostream inkludiert worden sein, so wie für printf
die Bibliothek stdio eingebunden werden muss.
Die Ausgabe wird dann über die Methoden und überladenen Operatoren des cout-
Objekts angesprochen. Der wesentliche Operator für die Ausgabe ist der Operator <<.
Mit diesem Operator können elementare Datentypen wie int oder float an den Aus-
gabestrom geleitet werden. Die Ausgabe erfolgt dann folgendermaßen:
#include <iostream>
A int a = 42;
B std::cout << a;
Nach der Einbindung der Bibliothek, die u. a. die Klasse ostream enthält (A), kann die
Variable a über den überladenen Operator einfach ausgegeben werden (B):
42
Die Ausgaben nach cout können auch »verkettet« werden, indem mehrere Ausgaben
hintereinandergestellt werden:
748
20.7 Ein- und Ausgabe in C++
#include <iostream>
using std::cout; // Alternativ: using namespace std;
int a = 1;
char c = 'X';
char* s = "Text";
cout << "Der Wert von a ist: " << a << '\n';
cout << "Der Wert von c ist: " << c << '\n';
cout << "Der Wert von s ist: " << s << '\n';
In den einzelnen Zeilen wird jeweils die Ausgabe eines Strings und einer Variablen
mit '\n' zum Zeilenumbruch verkettet, und wir erhalten die folgende Ausgabe:
Oft wird die Ausgabe in einen Stream auch mit dem endl-Objekt beendet:
Dies bewirkt nicht nur einen Zeilenumbruch, sondern auch die sofortige Ausgabe des
Streambuffers. In großer Menge verwendet, kann sich dies nachteilig auf die Perfor-
mance der Ausgabe auswirken:
20
class datum
{
A friend ostream& operator<<( ostream& os, const datum& d);
private:
//...
};
749
20 Objektorientierte Programmierung
Rückgabewert des deklarierten <<-Operators (A) ist wieder eine Referenz auf einen
ostream zur Verkettung der Ausgaben. Der erste Eingangsparameter der Methode ist
eine Referenz auf den ostream, an den ausgegeben wird. Die übergebenen Datumsob-
jekte sollen in der Ausgabe nicht verändert werden und werden als konstante Refe-
renz übergeben.
20.7.2 Tastatureingabe
Wie bei der Ausgabe nutzt auch die Eingabe in C++ überladene Operatoren. Hier steht
das Objekt cin im Zentrum, eine Instanz der Klasse istream. Bei der Eingabe wird der
Operator >> verwendet. Wie das Zeichen für den Operator schon andeutet, ist der
Ablauf der Eingabe wie bei der Ausgabe, lediglich die Flussrichtung dreht sich um.
Wollen wir z. B. einen Wert in eine int-Variable einlesen, verwenden wir die folgende
Implementierung:
float f;
A cout << "Bitte geben Sie 'f' ein: "
B cin >> f;
750
20.7 Ein- und Ausgabe in C++
Die Ausgabe des Textes erfolgt ohne Zeilenumbruch (A), sodass der gesamte Ablauf
mit dem Einlesen des Wertes von der Tastatur in die Variable (B) auf dem Bildschirm
folgendermaßen aussieht:
Auch für den >>-Operator gilt, dass die Überladungen für die elementaren Datenty-
pen bereits existieren, z. B. ist damit folgende Eingabe möglich:
char name[100];
int alter;
cout << "Bitte geben Sie Ihren Namen ein: ";
cin >> name;
cout << "\nBitte geben Sie Ihr Alter ein: ";
cin >> alter;
Wir können auch den >>-Operator für unsere eigene Klasse überladen. Als Beispiel
wollen wir einen einfachen Eingabe-Operator für unsere Datumsklasse erstellen. Für
den Zugriff auf die privaten Attribute unserer Klasse erklären wir auch diesen Opera-
tor zum friend:
Die Übergabe der Klasse datum erfolgt als Referenz, sodass die Variable innerhalb der
Methode verändert werden kann. Die Implementierung des Operators sieht dann
folgendermaßen aus:
751
20 Objektorientierte Programmierung
C d.set(tag,monat, jahr );
D return is;
}
Die einzelnen Werte werden zunächst in temporäre Variablen (A) eingelesen (B) und
dann mittels des Setters übertragen (C). Abschließend erfolgt die Rückgabe des über-
gebenen istreams zur Verkettung (D).
datum d;
cout << "Geben Sie Tag, Monat und Jahr ein: " << '\n';
cin >> d;
cout << "Das Datum ist: " << d << '\n';
20.7.3 Dateioperationen
Auch für Dateioperationen verlässt sich C++ auf Objekte, Methoden und Operatoren.
Diese ersetzen die Datenstrukturen und Funktionen, die Sie von C kennen.
Die Operatoren, die Sie gerade zur Ein- und Ausgabe kennengelernt haben, werden
für Operationen auf Dateien ebenso angewandt wie für die Standardein- und -aus-
gabe. Dazu werde ich Ihnen im Folgenden ein Beispiel zeigen.
Bei der Ausgabe auf den Bildschirm hat uns das System das Objekt cout bereitgestellt.
Mittels des überladenen Operators << haben wir dann die Daten zur Ausgabe an cout
weitergegeben.
Um Text in eine Datei statt auf den Bildschirm auszugeben, benötigen wir zuerst ein
Objekt, das die entsprechende Datei repräsentiert. Der Datentyp für ein solches
Dateiobjekt ist ofstream (eine Abkürzung für Output File Stream).
Ein solches Objekt können wir nach Einbinden der entsprechenden Header-Datei
instanziieren:
752
20.7 Ein- und Ausgabe in C++
A #include <fstream>
using namespace std;
int main()
{
D datei.close();
}
In dem Beispiel wird der passende Header inkludiert (A) und ein Objekt datei vom
Typ ofstream erzeugt. Der Name der Datei wird dem Konstruktor übergeben. Die
Datei wird im aktuellen Verzeichnis angelegt und zum Schreiben geöffnet (B).
Das Datumsobjekt, das ausgegeben werden soll, wird angelegt und mit seinem ope-
rator<< an die Datei weitergegeben (C). Nachdem alle Ausgaben im Programm abge-
schlossen sind, wird die Datei wieder geschlossen (D).
Wenn Sie das Programm ablaufen lassen und das Programmverzeichnis öffnen, wer-
den Sie dort die Datei datum.txt mit dem erwarteten Inhalt finden:
1.1.2015
Der Operator, den wir für die Bildschirmausgabe erstellt hatten, ist jetzt auch für die
Dateiausgabe genutzt worden.
20
Die Objekte der Klasse ofstream schließen geöffnete Dateien selbstständig in ihrem
Destruktor. Es ist allerdings gute Praxis, die Datei zu schließen, nachdem alle Ausga-
ben erfolgt sind. Insbesondere verhindert es Datenverlust, wenn es zu einem uner-
warteten Abbruch des Programms kommt und gepufferte Daten noch nicht
geschrieben worden sind. Das Pendant zu ofstream zum Einlesen von Dateien ist der
ifstream (Input File Stream). Um eine Datei einzulesen und den Inhalt auf dem Bild-
schirm auszugeben, gehen Sie folgendermaßen vor:
A #include <iostream>
#include <fstream>
using namespace std;
int main()
{
char c;
753
20 Objektorientierte Programmierung
C if( !datei )
{
cout << " Fehler!\n";
exit( 1 );
}
D while( 1 )
{
E datei.get( c );
F if( datei.eof() )
break;
G cout.put( c );
}
H datei.close();
}
Sie inkludieren die Dateien für die File Streams und für die Standardausgabe (A).
Danach kann die gewünschte Datei zum Lesen geöffnet werden, hier z. B. datei.txt (B).
Vor der Verwendung einer Datei sollten Sie prüfen, ob sie erfolgreich geöffnet wor-
den ist6. Das Öffnen schlägt z. B. fehl, wenn die Datei im aktuellen Verzeichnis nicht
existiert (C).
Auf den ersten Blick sieht die Prüfung einer Datei der in C sehr ähnlich. Es besteht
aber ein grundsätzlicher Unterschied.
In C wird an dieser Stelle mit dem Negationsoperator ! geprüft, ob der Zeiger auf die
Datenstruktur einen Wert ungleich 0 hat. Bei der Prüfung in C++ handelt es sich um
einen eigens für den ifstream überladenen !-Operator, der den Erfolg der Dateiope-
ration anzeigt.
Die Datei wird danach in einer Endlosschleife (D) Zeichen für Zeichen eingelesen (E).
Dazu bietet der ifstream eine get-Methode. Das Ergebnis der Leseoperation wird in
den Parameter c geschrieben. Der Parameter wird als Referenz übergeben, daher ist
keine Übergabe der Adresse erforderlich.
Ist das Dateiende erreicht, gibt die Methode eof (End of File) des ifstream als Ergebnis
true zurück, und die Schleife wird beendet (F). Die Ausgabe auf dem Bildschirm
erfolgt mit cout (G). Da cout wie eine Datei behandelt wird, hat sie eine put-Methode,
6 Diese Prüfung habe ich oben nicht vorgenommen, um das Beispiel kompakt zu halten.
754
20.8 Der this-Pointer
die in die »Datei« schreibt. Alternativ hätte auch die Ausgabe verwendet werden kön-
nen, die Sie schon kennen. Zum Ende der Verwendung wird auch hier die Datei
geschlossen (H).
Für diese Operation müssen wir allerdings einen expliziten Zugriff auf das Objekt
haben, im oben angegebenen Beispiel die Instanz in der Variablen d.
Innerhalb der Memberfunktion eines Objekts befinden wir uns aber praktisch im
»Inneren« des Objekts und haben keinen expliziten Namen, auf den wir zugreifen
können.
Um von hier einen Zugriff auf die Adresse der »zugehörigen« Instanz der Klasse zu
bekommen, bietet C++ den sogenannten this-Pointer.
755
20 Objektorientierte Programmierung
void datum::ausgabe()
{
cout << *this;
}
Hier erfolgt der Aufruf des Ausgabe-Operators aus der eigenen Klasse mit einem
dereferenzierten this-Pointer, der als Referenz an den Operator übergeben wird.
20.9 Beispiele
Sie haben nun die Grundlagen der objektorientierten Entwicklung kennengelernt.
Die besonderen Vorteile der Objektorientierung kommen zum Tragen, wenn grö-
ßere Programme entstehen und Objekte wiederverwendet werden können. Wir bear-
beiten ein Beispiel, in dem Sie die gelernten Vorgehensweisen anwenden müssen.
Das Beispiel wird dann im folgenden Kapitel wieder aufgegriffen, sodass Sie dort
bereits davon profitieren können.
20.9.1 Menge
Mit dem Datentyp Menge wollen wir einen Datentyp implementieren, den es in vie-
len Programmiersprachen bereits als Basisdatentyp gibt. Um eine sinnvolle Imple-
mentierung zu ermöglichen, werde ich Ihnen zuerst die Anforderungen an diesen
Datentyp vorstellen.
Allgemein ist eine Menge eine ungeordnete Sammlung von Elementen eines
bestimmten Datentyps. Jedes Element der Menge kann maximal einmal vorkom-
men, ist also entweder nicht oder einmal enthalten.
Für unser Beispiel wollen wir eine Menge implementieren, die Zahlen von 0 bis 255
aufnehmen kann. Unser Datentyp soll dabei die wesentlichen aus der Mengenlehre
bekannten Operationen ermöglichen:
Eine Menge A, die die Zahlen 1, 3, 5 und 7 enthält, wollen wir folgendermaßen dar-
stellen:
A = { 1, 3, 5, 7 }
756
20.9 Beispiele
Die Ausgabe erfolgt typischerweise sortiert, die Menge selbst ist aber unsortiert.
Vereinigung
Durchschnitt
Differenzmenge
Komplement
Die naheliegendste Operation ist die Vereinigung von zwei Mengen, also die Zusam-
menfassung der Elemente beider Mengen. Wir wollen von den beiden Mengen A und
B als Beispiel ausgehen:
A = { 1, 2, 4 }
B = { 1, 4 , 6, 7 }
Wir bestimmen den Operator + als Operator für die Vereinigung. Wenn wir die bei-
den Mengen zur Menge C vereinigen wollen, erhalten wir also:
C = A + B = { 1, 2, 4, 6, 7 }
Die nächste Operation soll die Ermittlung des Durchschnitts sein. Zum Durchschnitt 20
zweier Mengen gehören alle Elemente, die in beiden Mengen vorhanden sind. Der
Durchschnitt wird Schnittmenge genannt. Mit dem Operator * für den Durchschnitt
erhalten wir:
C = A * B = { 1, 4 }
Die Differenzmenge A – B ist die Menge aller Elemente, die zu A, nicht aber zu B gehö-
ren. Mit dem Operator – ergibt das als Ergebnis:
C = A – B = { 1, 2 }
Das Komplement ~B ist die Menge aller Elemente, die in der Grundmenge, aber nicht
in B sind:
757
20 Objektorientierte Programmierung
Wir wollen in unserer Klasse die folgenden Operatoren implementieren, die jeweils
als Ergebnis eine neue Menge erzeugen:
Operation Beschreibung
A+e Erzeugt die Menge, die alle Elemente aus A und zusätzlich das Element
e enthält.
A–e Erzeugt die Menge, die alle Elemente aus A, aber nicht das Element e
enthält.
Operation Beschreibung
Zusätzlich gibt es Operatoren, die eine bestehende Menge prüfen und ein entspre-
chendes Ergebnis zurückliefern:
Operation Beschreibung
A <= B Prüft, ob A Teilmenge von B ist. Das Ergebnis ist 1, wenn die Teilmen-
genbeziehung besteht, ansonsten 0.
!A Prüft, ob die Menge A leer ist. Bei einer leeren Menge ist das Ergebnis 1,
ansonsten 0.
758
20.9 Beispiele
Operation Beschreibung
e<A Prüft, ob die Menge A das Element e enthält. Kommt e in A vor, ist das
Ergebnis 1, andernfalls 0.
Abschließend gibt es einen Operator, mit dessen Hilfe wir eine Menge in einen Out-
put-Stream wie cout ausgeben können:
Operation Beschreibung
Wir werden unsere Klasse für die Menge set nennen. Da wir die Klasse später wieder-
verwenden wollen, achten wir auf eine saubere Aufteilung unseres Codes und erstel-
len drei Dateien:
class set
{
// Deklaration der Klasse
//
};
Die Deklaration der Klasse set erfolgt in einer separaten Datei set.h. 20
#include "set.h"
set::set()
{
//...
}
// Implementierung der weiteren Methoden
759
20 Objektorientierte Programmierung
#include "set.h"
int main()
{
// Hauptprogramm
}
In test.cpp verwalten wir das Hauptprogramm und den Testrahmen für die Klasse.
Auch diese Datei inkludiert die Klassendeklaration in set.h.
Nachdem die Aufteilung der Dateien feststeht, müssen wir die interne Repräsenta-
tion der Daten festlegen. Wir wollen unsere Menge intern als ein Array vorzeichenlo-
ser Zeichen (unsigned char) repräsentieren. Jeweils ein einzelnes Bit an der
entsprechenden Bitposition soll anzeigen, ob eine bestimmte Zahl Element der
Menge ist oder nicht. Um die Zahlen von 0 bis 255 als Elemente der Menge zu verwal-
ten, genügt damit ein Array mit 32 Zeichen:
set
data[0] data[1] data[31]
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 248 249 250 251 252 253 254 255
0 0 1 0 1 0 0 0 0 0 0 1 0 0 1 0 ... 0 1 0 0 0 0 0 0
In Abbildung 20.7 sind für die fünf Elemente der Menge die fünf korrespondierenden
Bits in dem Array gesetzt.
class set
{
private:
unsigned char data[32];
};
760
20.9 Beispiele
In der Klassendeklaration sind nun die Operatoren enthalten, die ein neues set
erzeugen:
class set
{
friend set operator+( const set& s1, const set& s2 );
friend set operator-( const set& s1, const set& s2 );
friend set operator*( const set& s1, const set& s2 );
friend set operator~( const set& s );
friend set operator+( const set& s, const int e );
friend set operator-( const set& s, const int e );
//...
};
Die Operatoren, die eine neue Menge erzeugen, haben als Rückgabetyp ein Objekt
des Datentyps set. Wir übergeben die Parameter an die Operatoren als konstante
Referenzen. So kann der Nutzer der Klasse mit einem Blick in die Klassendeklaration
erkennen, dass die per Referenz übergebenen Operanden nicht manipuliert werden.
Die Operatoren wie der Operator +=, die eine Operation mit einer Zuweisung kop-
peln, geben keine neue Menge zurück, sondern eine Referenz auf den veränderten
Operanden. Bei diesen Operatoren wird nur der zweite Operand als konstante Refe-
renz übergeben, da wir den ersten Operanden ja ausdrücklich verändern wollen.
20
Die prüfenden Operatoren geben jeweils einen Wert vom Typ int als Prüfergebnis
zurück. Auch hier sollen die Operanden nicht verändert werden und werden entwe-
der als Kopie oder als konstante Referenz übergeben.
class set
{
A // .. Erster Teil der Klassendeklaration
friend set& operator+=( set& s1, const set& s2 );
friend set& operator-=( set& s1, const set& s2 );
friend set& operator*=( set& s1, const set& s2 );
friend set& operator+=( set& s, const int e );
friend set& operator-=( set& s, const int e );
B friend int operator<= ( const set& s1, const set& s2 );
friend int operator< ( int e, const set& s2 );
friend int operator! ( const set& s );
761
20 Objektorientierte Programmierung
Die vollständige Klasse enthält damit die Deklaration für die Operatoren, die den lin-
ken Operanden verändern (A), die prüfenden Operatoren (B), den Ausgabe-Operator
(C), das Array zur Speicherung der Daten (D) sowie den Konstruktor (E).
Implementierung
Die eigentliche Implementierung startet mit dem Konstruktor. Im Konstruktor müs-
sen wir dafür sorgen, dass eine neu instanziierte Menge der leeren Menge entspricht,
also alle Bits des zur Datenspeicherung verwendeten Arrays auf 0 gesetzt sind:
set::set()
{
int i;
for( i = 0; i < 32; i++ )
data[i] = 0;
}
0 1 2 3 4 5 6 7
0 1 1 0 1 0 0 0 A = { 1, 2, 4 }
0 1 0 0 1 0 1 1 B = { 1, 4, 6, 7 }
0 1 1 0 1 0 1 1 A += B // { 1, 2, 4, 6, 7 }
762
20.9 Beispiele
Es handelt sich um eine simple Oder-Verknüpfung der Array-Elemente, die wir nun
implementieren:
Zur Verknüpfung muss unser Operator in dem Array von s1 zusätzlich zu den dort
bereits gesetzten Bits diejenigen setzen, die im Array von s2 gesetzt sind. Dies errei-
chen wir mit einem bitweisen Oder-Operator (A):
Wir haben nun als ersten Operator den Operator += implementiert. Oft kann man
weitere Operatoren auf der Basis bereits implementierter leicht umsetzen. So wer-
den wir hier mit dem Operator + verfahren. Wir werden den Operator += jetzt verwen-
den, um die Vereinigung von Mengen mit operator+ einfach zu implementieren.
Die Vereinigung zweier Mengen erzeugt als Ergebnis eine neue Menge. Innerhalb
unseres Operators instanziieren wir daher zuerst die neue Menge, die wir nachher als
Ergebnis zurückgeben werden (A), und weisen ihr die Werte des linken Operanden zu
(B). Danach besteht der Rest der Implementierung nur noch aus der Anwendung des
bereits umgesetzten Operators += (C) und der Rückgabe des Ergebnisses (D).
Die Operation A – B entfernt aus A alle Elemente, die zu B gehören. Die Implementie-
rung der Operationen -= und – läuft damit prinzipiell genauso ab wie bei den beiden
vorherigen Operatoren – mit dem Unterschied, dass bei der Differenzbildung die
Bits, die im Operanden s2 gesetzt sind, in dem linken Operanden s1 gelöscht werden
müssen. Schematisch sieht die Operation so aus:
763
20 Objektorientierte Programmierung
0 1 2 3 4 5 6 7
0 1 1 0 1 0 0 0 A = { 1, 2, 4 }
0 1 0 0 1 0 1 1 B = { 1, 4, 6, 7 }
0 1 1 0 0 0 0 0 A -= B // { 1, 2 }
Als nächsten Operator haben wir den Durchschnitt deklariert. Der Durchschnitt
zweier Mengen wird gebildet, indem eine bitweise Und-Verknüpfung der beiden
Daten-Arrays durchgeführt wird, sodass nur die Bits gesetzt bleiben, die in beiden
Mengen gesetzt sind:
764
20.9 Beispiele
}
set operator* ( const set& s1, const set& s2 )
{
set r;
r = s1;
r *= s2;
return r;
}
Um das Komplement einer Menge zu bilden, müssen wir alle Bits in dem Daten-
Array invertieren. Auch dies ist mit den uns bereits aus C bekannten Bitoperationen
kein Problem:
Dazu erstellen wir zunächst ein neues set (A) und übertragen dann das bitweise
Komplement aller Array-Elemente in dieses set (B). 20
765
20 Objektorientierte Programmierung
Um ein einzelnes Elements mit operator+= hinzuzufügen, wird in (A) eine bitweise
Oder-Verknüpfung des betroffenen Array-Elements mit einem einzeln gesetzten Bit
vorgenommen.
Wir implementieren die Methode für den operator+ zum Hinzufügen einzelner Ele-
mente analog zu den anderen Fällen mithilfe von operator+=.
Analog dem Hinzufügen wird hier in (A) das entsprechende Element des Arrays mit
dem Komplement des betroffenen Bits mit einer bitweisen Und-Verknüpfung mani-
puliert.
766
20.9 Beispiele
Die manipulierenden Operatoren sind damit abgeschlossen, wir können nun die ver-
gleichenden Operatoren umsetzen.
Die Operation A <= B prüft, ob A Teilmenge von B ist. Für diese Prüfung muss getestet
werden, ob mindestens die Bits aus der einen Menge auch in der anderen gesetzt
sind:
Dazu werden alle Array-Elemente durchlaufen (A). Werden Bits im Array von s1
gefunden, die im Array von s2 nicht gesetzt sind (B), besteht keine Teilmengenbezie-
hung, die Prüfung wird abgebrochen (C). Wenn kein vorzeitiger Abbruch erfolgt ist,
besteht eine Teilmengenbeziehung (D).
Als Nächstes wird die Prüfung auf das Vorhandensein eines einzelnen Elements
umgesetzt.
Um mit dem operator< zu prüfen, ob ein bestimmtes Element vorhanden ist, müssen 20
wir ermitteln, ob das entsprechende Bit gesetzt ist. Die Prüfung hat starke Ähnlich-
keit mit dem Hinzufügen oder Entfernen eines Elements:
Auch hier wird ein einzeln gesetztes Bit mit einer Und-Verknüpfung mit dem Array-
Element kombiniert (A). Das Ergebnis des bitweisen Vergleichs ist bereits das Ergeb-
nis der Prüfung.
767
20 Objektorientierte Programmierung
Schließlich bleibt die Prüfung auf die leere Menge. Die leere Menge erkennen Sie
daran, dass alle Felder des Arrays den Wert 0 haben müssen.
Es erfolgt eine Prüfung aller Array-Elemente (A). Wenn ein Element ungleich 0 ist, ist
die Menge nicht leer, und die Prüfung ist beendet (B). Wenn kein vorzeitiger Abbruch
erfolgt ist, ist die Menge leer (C).
Jetzt fehlt nur noch der Ausgabe-Operator, um unsere Menge in einen ostream über
cout auszugeben. Den Ausgabe-Operator implementieren wir mit einem unserer
überladenen Prüfoperatoren:
768
20.9 Beispiele
Die Ausgabe startet mit einer offenen, geschweiften Klammer (A). Im Kopf der
Schleife über alle Elemente wird mit append ein Kennzeichen gesetzt, um das tren-
nende Komma vor dem ersten Element zu unterdrücken (B). Mit dem überladenen
operator< wird geprüft, ob i im set s enthalten ist (C). Ist das der Fall, erfolgt eine Aus-
gabe (D). Eine geschlossene, geschweifte Klammer markiert das Ende der Ausgabe (E).
{ 2, 4, 6, 8, 10, 12}
Wir erstellen nun ein kleines Testprogramm für unsere neue Klasse:
int main()
{
set A;
A += 2;
A += 4;
A += 6;
A += 8;
A += 10;
A += 12;
set B;
B += 2;
B += 4;
B += 6;
B += 7;
B += 9;
20
B += 11;
769
20 Objektorientierte Programmierung
A = { 2, 4, 6, 8, 10, 12}
B = { 2, 4, 6, 7, 9, 11}
int main()
{
// Erster Teil von main
A if( ! ( A*B ) )
cout << "Der Durchschnitt von A und B ist leer\n\n";
else
cout << "Der Durchschnitt von A und B ist nicht leer\n\n";
if( !( A <= B ) )
cout << "A ist keine Teilmenge von B\n\n";
cout << "Berechnung einiger Formeln\n";
cout << "(A +1) * ~(B + 8) = "
<< ( A + 1 ) * ~( B + 8 );
cout << "((A + 1) * ~(B + 8)) – 10 = "
<< ( ( A + 1 ) * ~( B + 8 ) ) – 10;
cout << "((A + 1) * ~(B + 8) – 10) + B = \n"
<< ( ( A + 1 ) * ~( B + 8 ) – 10 ) + B;
cout << "\n"
A += ( B – 11 );
cout << "A = " << A;
A *= ( B -= 2 );
cout << "A = " << A;
cout << "B = " << B;
return 0;
}
770
20.10 Aufgaben
Wir verwenden in dem Programm auch den überladenen operator! (A), der feststellt,
ob eine Menge leer ist. Dieser Operator ist in der C-Programmierung so allgegenwär-
tig, dass eine eventuell vorgenommene Überladung oft übersehen wird.
Mit diesen weiteren Prüfungen erhalten wir dann das folgende Ergebnis:
A = { 2, 4, 6, 7, 8, 9, 10, 12}
A = { 4, 6, 7, 9}
B = { 4, 6, 7, 9, 11}
Damit ist die Menge vollständig implementiert. Die Menge werden wir in der Übung
des folgenden Kapitels wiederverwenden.
20.10 Aufgaben
A 20.1 Komplexe Zahlen sind eine für viele mathematische Anwendungen erforder- 20
liche Erweiterung der reellen Zahlen. Eine komplexe Zahl z besteht aus einem
Real- und Imaginärteil, bei dem es sich jeweils um eine reelle Zahl handelt.
Wir notieren sie in der Form z = (Re(z), Im(z)). Reelle Zahlen sind spezielle
komplexe Zahlen, deren Imaginärteil den Wert 0 hat.
Für die Addition und Multiplikation komplexer Zahlen (bzw. komplexer mit
reellen Zahlen) bestehen die folgenden Rechenregeln:
Für die Multiplikation einer reellen Zahl mit einer komplexen Zahl z gilt damit
insbesondere:
a · z = (a · Re(z), a · Im(z))
Den Betrag einer komplexen Zahl berechnen Sie mit der Formel
771
20 Objektorientierte Programmierung
z = Re ( z ) 2 + Im ( z ) 2
Modellieren Sie den Datentyp der komplexen Zahl als Klasse in C++. Stellen Sie
für alle geläufigen Operatoren mit komplexen Zahlen bzw. mit komplexen
und reellen Zahlen sinnvolle Operatoren zur Verfügung.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv
testet!
A 20.2 Entwerfen und implementieren Sie eine erweiterte Klasse datum mit mehreren
Operatoren. Die Klasse soll ein Kalenderdatum, bestehend aus Tag, Monat und
Jahr, verwalten und an einer von Ihnen festgelegten Schnittstelle die folgen-
den Funktionen bieten:
Die Klasse soll Schaltjahre und die korrekte Anzahl von Tagen pro Monat
berücksichtigen.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv
testet!
A 20.3 Die Enigma ist eine Verschlüsselungsmaschine, die im 2. Weltkrieg auf deut-
scher Seite zur Chiffrierung und Dechiffrierung von Nachrichten und insbe-
sondere zur Lenkung der U-Boot-Flotte eingesetzt wurde. Äußerlich ähnelt die
Enigma einer Kofferschreibmaschine.
Intern besteht sie aus einem Steckbrett, drei Rotoren und einem Reflektor. Die
Verschlüsselung wird durch die Verdrahtung dieser drei Grundelemente
erreicht. Abbildung 20.11 zeigt den schematischen Aufbau der Enigma mit
einer konkreten Verdrahtung der Bauteile.
772
20.10 Aufgaben
Anfangsstellung 4 7 11
A 0 0 4 4 7 7 11 11 0
B 1 1 5 5 8 8 12 12 1
C 2 2 6 6 9 9 13 13 2
D 3 3 7 7 10 10 14 14 3
E 4 4 8 8 11 11 15 15 4
F 5 5 9 9 12 12 16 16 5
G 6 6 10 10 13 13 17 17 6
H 7 7 11 11 14 14 18 18 7
I 8 8 12 12 15 15 19 19 8
J 9 9 13 13 16 16 20 20 9
K 10 10 14 14 17 17 21 21 10
L 11 11 15 15 18 18 22 22 11
M 12 12 16 16 19 19 23 23 12
N 13 13 17 17 20 20 24 24 13
O 14 14 18 18 21 21 25 25 14
P 15 15 19 19 22 22 0 0 15
Q 16 16 20 20 23 23 1 1 16
R 17 17 21 21 24 24 2 2 17
S 18 18 22 22 25 25 3 3 18
T 19 19 23 23 0 0 4 4 19
U 20 20 24 24 1 1 5 5 20
V 21 21 25 25 2 2 6 6 21
W 22 22 0 0 3 3 7 7 22
X 23 23 1 1 4 4 8 8 23
Y 24 24 2 2 5 5 9 9 24
Z 25 25 3 3 6 6 10 10 25
Steckbrett Rotor1 Rotor2 Rotor3 Reflektor
Bei einer bestimmten Stellung der Rotoren kann dann ein Buchstabe chiffriert 20
bzw. dechiffriert werden, indem durch das Drücken des Buchstabens auf der
Tastatur ein Stromkreis durch die Bauteile geschlossen wird. Dieser bringt
dann eine Lampe mit dem Ergebnisbuchstaben zum Aufleuchten.
In der oben dargestellten Stellung wird etwa der Buchstabe A durch den Buch-
staben B verschlüsselt. Um dies abzulesen, starten Sie ganz links auf der Buch-
stabenreihe mit einem Buchstaben, hier z. B. dem A. Von dort verfolgen Sie die
die Linie über die drei Rotoren, den Reflektor und zurück. Der Buchstabe, bei
dem Sie landen, ist der, auf den der Startbuchstabe verschlüsselt wird. Hier ist
es das B. Umgekehrt kann B wieder zu A entschlüsselt werden. Das ist einsich-
tig, hier geht es ja nur auf dem gleichen Weg zurück, man landet also wieder
am Ausgangspunkt.
773
20 Objektorientierte Programmierung
Eine konkrete Verschlüsselung hängt also von der Verdrahtung der Bauteile
und der Anfangsstellung der Rotoren ab. Wie schon beschrieben, ist die
Enigma »selbstinvers«. Ein verschlüsselter Text kann also mit exakt der glei-
chen Prozedur, mit der er verschlüsselt wurde, auch wieder entschlüsselt wer-
den.
Entschlüsseln Sie die folgende Botschaft, die von einer Enigma mit der oben
genannten Konfiguration verschlüsselt wurde:
774
Kapitel 21
Das Zusammenspiel von Objekten
In diesem Kapitel werden Sie sehen, dass auch in C++ das Ganze mehr
ist als die Summe seiner Teile.
Wir haben mit Datenstrukturen bereits in Kapitel 14 mit der Länderspielbilanz, die
einzelne Spielergebnisse hatte, eine Hat-ein- bzw. Ist-Teil-von-Beziehung modelliert.
Diese Modellierungsmöglichkeit haben Sie in der objektorientierten Modellierung
natürlich weiterhin. Auch hier erweisen sich Objekte als Erweiterungen von Daten-
strukturen und ersetzen sie streng genommen sogar. Im Folgenden werden Sie
einige Modellierungsmöglichkeiten und deren Umsetzung in C++ kennenlernen.
In UML wird für die Komposition die nachfolgende Notation verwendet. Als Beispiel
wollen wir ein Auto modellieren, wie es z. B. für die Software eines Autovermieters
verwendet werden könnte, um den Zustand des Fahrzeugs (Luftdruck, Beschädigun-
gen, Tankinhalt) nach jeder Vermietung zu verwalten. Das Auto ist selbst ein Objekt
21
und hat mehrere andere Objekte, hier Räder und einen Tank, aber auch Beschädigun-
gen, als Modellierung eines abstrakteren Konzepts. Es ist damit eine Komposition.
Zu den komponierten Objekten kann die Angabe einer Kardinalität hinzugefügt wer-
den. Die Kardinalität gibt an, wie viele Teile einer bestimmten Klasse jeweils zur
Gesamtheit gehören.
1 Auf die feine Unterscheidung von Aggregation und Komposition wollen wir nicht eingehen und
im Weiteren von Komposition sprechen. In der Regel gehören die durch Komposition verbunde-
nen Objekte der gleichen Begriffswelt an und haben die gleiche Lebensdauer.
775
21 Das Zusammenspiel von Objekten
Räder
4..5
...
Auto ...
...
... Beschädigungen
0..*
...
keine bis unbegrenzt viele ...
Tank
1
genau ein Tank ...
...
Abbildung 21.1 Beispielhafte Komposition eines Autos für die Software eines Fahrzeug-
vermieters
In der angegebenen Komposition können für das Auto die Daten von vier bis maxi-
mal fünf Rädern verwaltet werden. Jedes Rad könnte dabei z. B. Attribute für Profil-
tiefe und Luftdruck verwalten. Mit bis zu fünf Reifen sieht die Klasse auch ein
eventuell vorhandenes Ersatzrad vor. Zu jedem vermieteten Fahrzeug können
zusätzlich Beschädigungen verwaltet werden, die z. B. die Daten einer Schadensbe-
schreibung und einer geschätzten Schadenshöhe enthalten. Die Kardinalität der
Beschädigung liegt zwischen null im Auslieferungszustand und beliebig vielen. Als
letztes Element der Komposition hat ein Auto noch einen Tank, der z. B. ein Attribut
für den Füllzustand besitzt. Aus einer entsprechenden Darstellung der Objektstruk-
tur lässt sich damit schon viel über das entsprechende Modell ablesen. Sie werden
nun sehen, wie eine entsprechende Struktur in C++ umgesetzt wird.
776
21.2 Komposition eigener Objekte
zur Speicherung der Zeit werden wir noch erstellen. Im weiteren Verlauf wird unsere
Klasse timestamp Teil eines Logbucheintrags werden und dort den Zeitstempel ent-
sprechender Logeinträge speichern.
Datum
1
...
Timestamp ...
...
... Zeit
1
...
...
Abbildung 21.2 Die Komposition von Timestamp hat ein Datum und eine Zeit.
Um die komponierte Klasse timestamp zu implementieren, fehlt uns noch die Klasse
zeit. Diese können Sie nach dem Beispiel der Klasse datum leicht erstellen:
class zeit
{
A private:
int stunde;
int minute;
public:
B zeit( int st, int mi ) { set( st, mi ); }
C zeit() { set( 0, 0 ); } 21
int getStunde(){ return stunde; }
int getMinute() { return minute; }
void set( int st, int mi );
};
Die Klasse zeit besitzt private Attribute zur Speicherung von Stunde und Minute (A).
Im Konstruktor werden die Attribute über die set-Methode der Klasse gesetzt (B). Der
parameterlose Konstruktor der Klasse verwendet die set-Methode ebenfalls (C).
Die beiden Konstruktoren und die get-Methode sind als Inline-Methoden implemen-
tiert, die set-Methode ist wie bei der Klasse datum aufgrund der Länge separat umge-
setzt:
777
21 Das Zusammenspiel von Objekten
minute = mi;
stunde = st;
}
Bevor wir die Klasse timestamp aus den beiden Elementen zusammensetzen, erwei-
tern wir datum und zeit noch jeweils um eine print-Methode, die den Inhalt der
Klasse formatiert ausgibt und die wir jeweils in der Klassendefinition als Inline-
Methode umsetzen:
class zeit
{
private:
int stunde;
int minute;
public:
zeit( int st, int mi ) { set( st, mi ); }
zeit() { set( 0, 0 ); }
int getStunde() { return stunde; }
int getMinute() { return minute; }
void set( int st, int mi );
A void print(){ printf( "%0.2d:%0.2d", stunde, minute ); }
};
Die Ausgabe für die Klasse zeit gibt Stunde und Minute aus (A).
class datum
{
private:
int tag;
int monat;
int jahr;
778
21.2 Komposition eigener Objekte
public:
datum( int t, int m, int j ) { set( t, m, j ); }
datum() { set( 1, 1, 1970 ); }
void set( int t, int m, int j );
A void print() { printf( "%0.2d.%0.2d.%4d", tag, monat, jahr ); };
In der Klasse datum wird die print-Methode für die Ausgabe von Tag, Monat und Jahr
hinzugefügt (A). Die vorher bereits implementierten Operatoren sind hier aus Grün-
den der Übersichtlichkeit nicht mehr weiter dargestellt.
Damit sind alle Klassen vorhanden, die in timestamp zusammengeführt werden sol-
len. Sie können die neue Klasse nun zusammensetzen.
class timestamp
{ 21
A private:
datum dat;
zeit zt;
public:
B datum getDatum() { return dat; }
C zeit getZeit() { return zt; }
};
Die Implementierung ist sehr übersichtlich. Die Klasse hat zwei private Attribute, die
beide Objekte enthalten, aus denen der Timestamp komponiert ist und die er »hat«
(A). Ansonsten beschreibt die Klassendefinition nur die beiden Getter-Methoden, die
779
21 Das Zusammenspiel von Objekten
nicht mehr zu tun haben, als eine Kopie der Attribute an den Aufrufer zurückzuge-
ben (B und C).
void main()
{
timestamp ts1;
A ts1.getDatum().print();
printf( " " );
B ts1.getZeit().print();
}
Zur Ausgabe von Datum und Zeit holen wir uns das eingebettete Objekt und rufen
jeweils dessen print-Funktion (A und B). Der Zugriff erfolgt wie bei Datenstrukturen
über den Punkt-Operator. Es ergibt sich die erwartete Ausgabe:
01.01.1970 00:00
Daher erweitern wir die Klasse timestamp um eine eigene print-Methode. Hier wer-
den wir bereits von der Funktionalität profitieren, die datum und zeit zur Verfügung
stellen:
class timestamp
{
private:
datum dat;
zeit zt;
public:
datum getDatum() { return dat; }
zeit getZeit() { return zt; }
A void print();
};
780
21.2 Komposition eigener Objekte
Aus Gründen der Übersichtlichkeit habe ich die print-Methode nicht inline imple-
mentiert, sondern in der Klasse nur deklariert (A) und dann separat umgesetzt. Gene-
rell wäre natürlich auch eine Inline-Implementierung möglich gewesen:
void timestamp::print( )
{
A dat.print();
printf( " " );
B zt.print();
}
Die Implementierung ist einfach, da die eingebetteten Objekte bereits die passende
Funktionalität zur Verfügung stellen. Daher muss nur die Ausgabe von Datum (A)
und Zeit (B) entsprechend kombiniert werden. Die Ausgabe des Zeitstempels kann
jetzt aus dem Hauptprogramm direkt über die print-Methode des Timestamps
erfolgen:
void main()
{
timestamp ts1;
ts1.print();
}
Das Aufrufen von Methoden durch Objekte untereinander, wie ich es in der Ausgabe
von timestamp verwendet habe, wird oft auch als Senden von Nachrichten zwischen
Objekten bezeichnet. Gemeint ist damit in C++ typischerweise der Methodenaufruf
zwischen Objekten.
781
21 Das Zusammenspiel von Objekten
class timestamp
{
private:
datum dat;
zeit zt;
public:
datum getDatum() { return dat; }
zeit getZeit() { return zt; }
void print();
};
Ohne eine besondere Initialisierung erhalten wir für eine neu erstellte Instanz der
Klasse timestamp die bereits bekannte Ausgabe:
01.01.1970 00:00
Die eingebetteten Objekte dat und zt werden bei der Erstellung des timestamp-
Objekts jeweils mit ihrem eigenen parameterlosen Konstruktor instanziiert. Diese
sind ohne unser Zutun implizit für uns aufgerufen worden. Die eingebetteten
Objekte wurden damit korrekt initialisiert. Bisher hat dieses Verhalten für unsere
Zwecke auch ausgereicht. Alle beteiligten Objekte sind in einem konsistenten
Zustand. Zur Erinnerung noch einmal die Anforderung an einen Konstruktor:
Wie Sie bei der Klasse timestamp sehen, kann ein Objekt auch andere Objekte enthal-
ten. Das Objekt wird damit Benutzer anderer Objekte. Unsere Klasse timestamp ist
Benutzer der Klassen zeit und datum. Es muss sich damit auch an die vorhandenen
Konstruktoren und deren Konstruktionsvorgaben halten und die Konstruktoren
von zeit und datum mit den passenden Parametern rufen.
782
21.2 Komposition eigener Objekte
Der Konstruktor von timestamp ohne Parameter erfüllt diese Bedingungen auch. Wir
wollen dem Benutzer der Klasse jetzt aber auch einen parametrierten Konstruktor
anbieten, mit dem er ein neues Objekt direkt mit festgelegtem Datum und Zeit initi-
alisieren kann. Dazu müssen Konstruktionsparameter aus der Klasse timestamp an
die eingelagerten Klassen zeit und datum weitergeleitet werden. Wie Sie dazu vorge-
hen, sehen Sie im folgenden Abschnitt.
class timestamp
{
private:
datum dat;
zeit zt;
public:
A timestamp( int ta, int mo, int ja, int st, int mi );
//...
};
Im Konstruktor (A) werden der zu erzeugenden Instanz die Werte für Tag, Monat,
Jahr, Stunde und Minute übergeben. Von diesen Werten sollen die jeweils passenden
an die eingebetteten Objekte weitergegeben werden. Der Datenfluss dazu ist in Abbil-
21
dung 21.3 dargestellt:
Datum
timestamp ts( 1, 5, 2014, 20, 15 )
tag = 1
monat = 5
jahr = 2014
Timestamp dat( 1, 5, 2014 )
...
dat
zt
Zeit
... zt( 20, 15 )
stunde = 20
minute = 15
...
783
21 Das Zusammenspiel von Objekten
Die Parameter des Konstruktors von timestamp können direkt an die Konstruktoren
der eingelagerten Objekte weitergeleitet werden. Der entsprechende Aufruf ist von
der Schnittstelle des timestamp-Konstruktors durch einen Doppelpunkt getrennt wie
in Listing 21.12 dargestellt.
A timestamp::timestamp( int ta, int mo, int ja, int st, int mi)
B : dat( ta, mo, ja),
C zt( st, mi)
{
D
}
Im eigentlichen Konstruktor von timestamp (D) stehen die Objekte dat und zt dann
bereits initialisiert zur Verfügung.
Der durch den Doppelpunkt von der Schnittstelle getrennte Aufruf der Konstrukto-
ren wird auch Initialisiererliste genannt.
Die Initialisiererliste muss nicht separat implementiert werden, sondern kann auch
in der Inline-Implementierung eines Konstruktors hinzugefügt werden. Dies sehen
Sie hier für einen zweiten, anders parametrierten Konstruktor von timestamp.
class timestamp
{
private:
datum dat;
zeit zt;
2 Die auf (A, B und C) verteilten Anweisungen können in einer Zeile stehen und werden hier nur
aus Platzgründen umbrochen.
784
21.2 Komposition eigener Objekte
public:
A timestamp( int ta, int mo, int ja) : dat( ta, mo, ja) {}
// ...
};
Listing 21.13 Die Klasse »timestamp« mit einem zweiten parametrierten Konstruktor
In dem angegebenen Beispiel ist ein zweiter Konstruktor mit Übergabe der
Datumsparameter und Aufruf des Konstruktors für dat in der Klassendeklaration
eingefügt worden (A). Der Konstruktor selbst hat einen leeren Rumpf, die Parameter
für Tag, Monat und Jahr werden direkt an das Objekt dat weitergeleitet, der Konstruk-
tor für das Objekt zt wird nicht weiter spezifiziert, hier wird wieder automatisch der
parameterlose Konstruktor gerufen.
class timestamp
{
private:
datum dat;
zeit zt;
public:
timestamp() {}
timestamp( int ta, int mo, int ja) : dat( ta, mo, ja) {}
A timestamp( int ta, int mo, int ja, int st, int mi);
1. parameterlos
2. unter Angabe von Tag, Monat und Jahr
3. unter Angabe von Tag, Monat, Jahr, Stunde und Minute
785
21 Das Zusammenspiel von Objekten
void main()
{
A timestamp ts1;
B timestamp ts2( 1, 5, 2014 );
C timestamp ts3( 1, 5, 2014, 20, 15 );
ts1.print(); printf( "\n" );
ts2.print(); printf( "\n" );
ts3.print(); printf( "\n" );
}
왘 parameterlos (A): Hier werden für datum und zeit die parameterlosen Konstrukto-
ren aufgerufen, die das Datum auf den 1.1.1970 und die Zeit auf 0:00 Uhr setzen.
왘 unter Angabe von Tag, Monat und Jahr (B): Hier werden die Konstruktorparameter
an datum übergeben, zeit wird parameterlos auf 0:00 Uhr instanziiert.
왘 unter Angabe von Tag, Monat, Jahr, Stunde und Minute (C): Hier werden datum und
zeit mit den übergebenen Parametern konstruiert.
01.01.1970 00:00
01.05.2014 00:00
01.05.2014 20:15
Die Klasse timestamp ist damit so weit fertiggestellt, dass sie für das Logbuch verwen-
det werden kann.
class text
{
786
21.3 Eine Klasse text
A private:
int len;
char* txt;
B public:
text( char* t );
~text();
void print(){ printf( "%s", txt ); }
};
Die Klasse enthält die genannten privaten Attribute (A) und die öffentlichen Schnitt-
stellen für einen Konstruktor, Destruktor und eine Ausgabemethode (B). Zur Umset-
zung definieren wir einen Konstruktor, der eine Zeichenkette übergeben bekommt,
sowie einen Destruktor, der den allokierten Speicher wieder freigibt.
text::text( char* t)
{
A len = strlen( t );
B txt = ( char* ) malloc( len + 1 );
C strcpy( txt, t );
}
text::~text()
{
D free( txt );
}
Im Konstruktor werden die Länge der übergebenen Zeichenkette ermittelt (A) und
der entsprechende Speicher allokiert (B), danach wird der Inhalt der Zeichenkette in
den allokierten Speicher kopiert (C).
Der Destruktor macht nicht mehr, als den im Konstruktor allokierten Speicher frei-
zugeben (D), wenn ein Objekt der Klasse zerstört wird.
Bevor wir unsere Klasse an anderer Stelle verwenden, wollen wir einen kleinen Test-
rahmen aufbauen. Dazu erstellen wir in einem Programm neben unserer Funktion
main eine weitere Funktion text_test, die bisher noch leer ist.
787
21 Das Zusammenspiel von Objekten
void main( )
{
text t( "test1" );
B text_test ( t );
}
Der Programmcode lässt sich übersetzen und starten, das Programm führt allerdings
zum Absturz!
Ursache des Absturzes ist ein weiterer Automatismus von C++. Sie wissen bereits aus
C, dass bei Aufruf einer Funktion die Parameter als Kopie an die Funktion übergeben
werden:
Bei Aufruf einer Funktion werden die Parameter als Kopie an die Funktion über-
geben.
In dem Programm entsteht bei Aufruf der Funktion in (A) implizit eine neue Instanz
der Klasse text als Kopie. Wenn eine Kopie benötigt wird, erzeugt das Laufzeitsystem
an der Schnittstelle das benötigte Duplikat. Es erzeugt dabei eine identische, bitweise
Kopie aller Attribute der Instanz. Wenn das Ende der Funktion erreicht ist (B), wird
diese Kopie mit Aufruf ihres Destruktors wieder beseitigt.
Wenn Sie den Code für Konstruktor und Destruktor um eine Ausgabe erweitern, kön-
nen Sie dieses Verhalten auch bei der Arbeit betrachten:
text::text( char* t)
{
len = strlen( t );
txt = ( char* ) malloc( len + 1 );
strcpy( txt, t );
cout << "Konstruktor fuer: " << txt << "\n";
}
788
21.3 Eine Klasse text
text::~text()
{
A cout << "Destruktor fuer: " << txt << "\n";
B cout << "Freigeben von: " << &(txt) <<"\n";
free( txt );
}
Die Erweiterung gibt im Destruktor den Text des zu destruierenden Objekts (A) und
dessen Adresse (B) aus. Mit dieser Ergänzung erhalten Sie z. B. die folgende Ausgabe:
Hier ist zu sehen, dass der Destruktor der Klasse zweimal durchlaufen wird und beide
Male den gleichen Speicher freigibt. Abbildung 21.4 zeigt den Ablauf, der zu diesem
Problem führt:
21
text x
len = 5
txt
789
21 Das Zusammenspiel von Objekten
왘 Die Instanz x wird bei Verlassen der Funktion wieder beseitigt, dazu wird der De-
struktor aufgerufen, und der referenzierte Speicher wird freigegeben.
왘 Das Programm läuft weiter, und die main-Funktion ist nun ebenfalls beendet.
Damit wird der Destruktor von t aufgerufen. Der Speicher, auf den t verweist,
wurde allerdings schon freigegeben, und mit der zweiten Freigabe desselben Spei-
chers kommt es zum Absturz.
Der Copy-Konstruktor ist ein Konstruktor, trägt damit den Namen der Klasse und hat
keinen Returnwert. Als Parameter erhält er eine konstante Referenz auf den Typ der
Klasse, zu der er gehört.
Wir erweitern unsere Klasse um den Copy-Konstruktor, der für die neue Instanz eige-
nen Speicher allokiert und den Inhalt aus der kopierten Instanz überträgt. Dazu wird
die Deklaration der Klasse um den Copy-Konstruktor ergänzt:
class text
{
private:
int len;
char* txt;
public:
text( char* t );
~text();
A text( const text& s );
790
21.3 Eine Klasse text
A len = s.len;
B txt = ( char* ) malloc( len + 1 );
C strcpy( txt, s.txt );
}
Die Implementierung ähnelt der des eigentlichen Konstruktors. Zuerst wird die Text-
länge der zu kopierenden Instanz in die Kopie übertragen (A), und nach Allokation
eigenen Speichers in der entsprechenden Größe (B) wird die Zeichenkette in diesen
Speicher kopiert (C).
Jede Instanz
verwendet
Instanz t verweist weiter
eigenen
auf gültigen Speicher,
text x Speicher.
wird korrekt beseitigt.
void main ()
{
A text u ( "test2" );
791
21 Das Zusammenspiel von Objekten
B text v ( "test3" );
C v = u;
D text w = u;
}
In dem Beispiel werden zuerst zwei unabhängige Objekte u und v erstellt (A und B). In
(C) wird dem bereits bestehenden Objekt v das Objekt u zugewiesen. Hier wird der
Zuweisungsoperator operator= aufgerufen.
In (D) handelt es sich nicht um eine Zuweisung, da das Objekt w hier erstmals erstellt
wird, auch wenn die Schreibweise das zuerst vermuten lässt. Da das w hier instanzi-
iert wird, wird hier der Copy-Konstruktor für w aufgerufen.
text v text v
len = 5 len = 5
test3
792
21.3 Eine Klasse text
왘 Damit entsteht das gleiche Problem, wie beim Copy-Konstruktor bereits beobach-
tet. Es existieren jetzt zwei Objekte, die Zeiger auf den gleichen Speicher verwalten
und bei der Destruktion freigeben. Damit kommt es auch hier zu einem Absturz
des Programms.
왘 Etwas versteckt gibt es sogar noch ein weiteres Problem, denn durch das Über-
schreiben von v.txt ist ein Speicherleck entstanden. Der hier abgelegte Zeiger ist
überschrieben worden. Damit ist der Verweis auf den reservierten Speicher verlo-
ren gegangen. Da eine Freigabe des Speichers nur unter Angabe seiner Adresse
möglich ist, kann dieser Speicher nicht mehr an das System zurückgegeben
werden.
Er erhält eine konstante Referenz auf das zuzuweisende Objekt. Das Ergebnis ist wie-
der eine Referenz. Damit wird die Verkettung von Zuweisungen ermöglicht. Die
angepasste Klassendeklaration sieht damit so aus:
class text
{
private:
int len; 21
char* txt;
public:
text( char* t );
~text();
text( const text& s);
text& operator=( const text& s );
void print(){ printf( "%s", txt ); }
};
Die Implementierung des Operators ähnelt der des Copy-Konstruktors, arbeitet aber
auf einer existierenden Instanz der Klasse:
793
21 Das Zusammenspiel von Objekten
Zu Beginn der Zuweisung wird der von der Instanz bereits allokierte »alte« Speicher
freigegeben (A). Danach wird die Länge len mit der Länge des zu kopierenden Wertes
überschrieben (B). Nach Allokierung des »neuen« Speichers (C) wird der Text aus der
zugewiesenen Instanz kopiert und die Referenz auf die Instanz selbst zur Verkettung
mehrerer Zuweisungen zurückgegeben (E).
void main ()
{
text u ( "test2" );
text v ( "test3" );
v = u;
}
Bisher hat unsere Klasse allerdings noch keine echte Funktionalität, außer die, ihren
Text auszugeben. Ich werde deshalb zum Abschluss noch eine Methode hinzufügen,
die mit dem Text in der Klasse arbeitet. Dazu soll innerhalb des gespeicherten Textes
gesucht werden. Die verwendete Methode find bekommt eine Zeichenkette als Para-
meter übergeben und gibt die Position des ersten Auftretens dieser Zeichenkette im
Text der Klasse zurück. Ich füge in (A) zuerst die Deklaration der Methode zur Klasse
hinzu:
794
21.3 Eine Klasse text
class text
{
private:
int len;
protected:
char* txt;
public:
text( char* t );
~text();
text( const text& s );
text& operator=( const text& s );
void print(){ printf( "%s", txt ); }
A int find( char* f );
};
Listing 21.26 Deklaration der Klasse »tex« mit der Methode »find«
Es fehlt noch die Implementierung. Dabei soll der Rückgabewert der Funktion der
Position der ersten gefundenen Übereinstimmung mit der gesuchten Zeichenkette
entsprechen. Wird die Zeichenkette nicht gefunden, dann liefert die find-Methode
–1 als Ergebnis:
B if( !pos)
return –1;
else 21
C return( pos – txt);
}
Innerhalb der Methode wird die Funktion strstr der C-Laufzeitbibliothek verwendet
(A). Sie gibt einen Zeiger auf den Anfang des gefundenen Textes zurück. Wenn der
gesuchte Text nicht in txt gefunden wurde, ist das Ergebnis ein Nullzeiger. Ist das der
Fall, liefert die Methode die –1 als Ergebnis (B). Ansonsten wird über die Zeigerarith-
metik die Position des Treffers berechnet und zurückgeliefert (C).
Die Klasse text ist damit implementiert und kann verwendet werden:
795
21 Das Zusammenspiel von Objekten
void main ()
{
A text t1( "Ein Auto" );
B text t2( "Ein Cabrio" );
C text t3( t2 );
D t3 = t1;
t3.print();
printf( "\n" );
Das Testprogramm erstellt zuerst die beiden Instanzen t1 und t2 (A und B). Die
Instanz t3 wird als Kopie von t2 erzeugt (C), hier handelt es sich um eine weitere
mögliche Schreibweise des Copy-Konstruktors. In (D) erfolgt die Zuweisung von t1
an das bestehende Objekt t3 über den erstellten Zuweisungsoperator. Mit der
Anwendung der find-Methode erhalten wir dann das folgende Ergebnis:
Ein Auto
'Auto' an Pos: 4
Daher werden
왘 Destruktor
왘 Copy-Konstruktor und
왘 Zuweisungsoperator
oft auch als die Drei bezeichnet, von denen es in der Rule of Three heißt, dass man alle
benötige, wenn man einen von ihnen brauche.
Dies gilt auch, wenn bei der aktuellen Verwendung der Klasse (noch) nicht kopiert
oder zugewiesen wird und die gezeigten Probleme nicht auftreten. Nur wenn eine
796
21.4 Übungen/Beispiel
solche Klasse vollständig implementiert ist, kann sie später in einer anderen Umge-
bung ohne Probleme wiederverwendet werden.
21.4 Übungen/Beispiel
Ich hatte schon erwähnt, dass der Nutzen der Objektorientierung besonders in der
Wiederverwendung liegt. Dies will ich Ihnen jetzt demonstrieren, indem wir die
Klasse set aus dem Beispiel des vorangegangenen Kapitels jetzt nutzen, um die
nächste Übung umzusetzen.
21.4.1 Bingo
Im vorangegangenen Kapitel haben wir eine Klasse zur Verwaltung von Mengen
implementiert. Wir wollen diese nun verwenden und ein Bingospiel programmieren.
Bingo ist ein Glücksspiel, an dem eine beliebige Anzahl von Spielern teilnehmen
kann.
Jeder Spieler hat vor sich eine Karte, auf der zehn Zahlen von 1 bis 50 notiert sind. Der
Spielleiter zieht aus einer Lostrommel nacheinander Zahlen (0–50) und ruft diese
öffentlich aus.
Immer, wenn ein Spieler die gezogene Zahl auf seiner Karte findet, streicht er die Zahl
durch. Wer als Erster alle Zahlen durchgestrichen hat, hat gewonnen – Bingo!
Wir werden das Spiel Schritt für Schritt implementieren und beginnen mit der Los-
trommel. Die zugehörige Klasse deklariert nur wenige Member.
class lostrommel
{
21
private:
A int anzahl;
B set trommel;
C public:
lostrommel( int max );
int ziehen();
};
Die Klasse enthält als Attribute die Anzahl der Kugeln in der Lostrommel (A) sowie
eine Menge von Kugeln (B). Die Attribute sind privat, die Methoden der Klasse sind
öffentlich (C).
797
21 Das Zusammenspiel von Objekten
Der Konstruktor der Lostrommel erwartet als Parameter die Anzahl der Kugeln in der
Lostrommel und füllt die Trommel dann entsprechend:
Der Konstruktor erhält als Parameter die höchste vorkommende Nummer (A). Die
Anzahl der Kugeln in der Trommel (0 bis max) wird ermittelt (B), und in einer Schleife
über alle Kugeln (C) erfolgt das Auffüllen der Lostrommel. Über den Operator += wird
jeweils ein Element i der Menge trommel hinzugefügt (D).
int lostrommel::ziehen()
{
int z, i, x;
A if( !anzahl )
return –1;
B z = rand() % anzahl;
C for( x = 0, i = 0; i <= z; x++ )
{
D if( x < trommel )
i++;
}
x--;
E trommel -= x;
F anzahl--;
G return x;
}
Zuerst wird geprüft, ob noch Kugeln in der Lostrommel sind (A). Ist dies der Fall, wird
eine Zufallszahl z ermittelt (B). In einer Schleife gehen wir durch z Kugeln (C) und prü-
fen jeweils, ob die Kugel x noch in der Trommel enthalten ist (D). Am Ende der
Schleife entnehmen wir die z-te Kugel mit dem Wert x (E) und dekrementieren die
Anzahl der Kugeln in der Trommel (F). Die Methode schließt mit der Rückgabe des
Wertes der entnommenen Kugel (G).
798
21.4 Übungen/Beispiel
Neben der Lostrommel benötigen wir auch einen Spieler. In die Klasse spieler neh-
men wir den Namen des Spielers und seine Spielkarte auf:
class spieler
{
friend ostream& operator <<( ostream& os, spieler& sp );
private:
char name[20];
A set karte;
public:
void init( int anz, int max );
B void streiche( int z ) { karte -= z; }
C int bingo() { return !karte; }
D char* get_name() { return name; }
};
Die Spielkarte ist wie die Lostrommel als set implementiert (A). Drei der Methoden
werden inline in der Klasse realisiert. In streiche wird die Zahl z aus der Karte gestri-
chen (B). Wenn die Karte leer ist, hat der Spieler ein Bingo, dies wird in bingo geprüft
(C). Schließlich hat die Klasse noch eine Methode, um den Namen des Spielers
zurückzugeben (D).
Die Methode init wird außerhalb der Klasse implementiert. Die Initialisierung des
Spielers erfordert die Eingabe des Spielernamens. Danach wird über eine eigens für
den Spieler temporär erstellte Lostrommel eine Karte für den Spieler erstellt:
799
21 Das Zusammenspiel von Objekten
Innerhalb der Initialisierung wird der Name des Spielers eingegeben (A), danach wird
eine temporäre Lostrommel erstellt (B), aus der dann k-mal gezogen wird, um die
Karte zufällig zu füllen (C). Abschließend werden Name und Karte des Spielers über
den noch zu implementierenden Ausgabe-Operator ausgegeben (D).
Dieser Ausgabe-Operator für die Klasse spieler ist unter Verwendung der Ausgabe
der Menge schnell erstellt:
Das Bingospiel wird von einem Spielleiter moderiert. Der Spielleiter verfügt über
eine Lostrommel und verwaltet die Mitspieler.
class leiter
{
private:
A int anzahl;
B spieler* teilnehmer;
C lostrommel trommel;
public:
D leiter( int anz, int karte, int max );
E ~leiter();
void spiel();
};
Die Klasse verwaltet in ihrem privaten Bereich die Anzahl der Spieler (A), einen Zeiger
auf ein Array der Teilnehmer (B) und eine Lostrommel als Menge (C). Im öffentlichen
Bereich befindet sich neben Konstruktor und Destruktor (D, E) noch die Methode zur
Durchführung des Spiels.
In seinem Konstruktor erhält der Spielleiter drei Parameter, die die Rahmendaten
des zu leitenden Spieles bestimmen. Den Parameter max mit der größtmöglichen
Zahl auf den Karten verwendet er zur Konstruktion seiner Lostrommel.
800
21.4 Übungen/Beispiel
Mit anz für die Anzahl der Spieler erstellt er das Array der Spieler, die er alle über ihre
init-Methode initialisiert und ihnen die Anzahl der Zahlen auf der Karte über karte
mitgibt.
Der Konstruktor gibt den Parameter max über die Initialisiererliste an den Konstruk-
tor der Menge weiter (A). Innerhalb des Konstruktors wird das dynamische Array der
Teilnehmer erstellt (B) und mit den entsprechenden Teilnehmern initialisiert (C).
leiter::~leiter()
{
delete[] teilnehmer;
}
Die einzelnen Elemente stehen nun bereit, es fehlt nur die letzte Methode. Mit der
Methode spiel startet der Spielleiter das Spiel:
21
void leiter::spiel()
{
int fertig, sp, z;
A for( fertig = 0; !fertig; )
{
B z = trommel.ziehen();
cout << "Gezogen: " << z << '\n';
C for( sp = 0; sp < anzahl; sp++ )
{
D teilnehmer[sp].streiche( z );
cout << teilnehmer[sp];
}
801
21 Das Zusammenspiel von Objekten
In einer Endlosschleife (A) zieht er jeweils eine Zahl aus der Lostrommel (B) und for-
dert alle Spieler (C) auf, die gezogene Zahl von ihrer Karte zu streichen (D). Nachdem
alle Spieler Gelegenheit hatten, ihre Karte zu aktualisieren, erfolgt die Nachfrage an
alle, ob das Spiel mit einem Bingo gewonnen ist (E). Meldet sich hier ein Spieler, wird
sein Name als Sieger ausgegeben (F) und das Abbruchkriterium für die Schleife
gesetzt (G). Durch die Verteilung der Aufgaben auf die verschiedenen Objekte hat das
Hauptprogramm nur noch verhältnismäßig wenig zu erledigen:
int main()
{
int seed, anzahl, karte, maximum;
cout << "Startwert fuer Z-Generator: ";
cin >> seed;
A srand( seed );
cout << "Anzahl Teilnehmer: ";
B cin >> anzahl;
cout << "Kartengroesse: ";
cin >> karte;
cout << "Maximum: ";
cin >> maximum;
if( maximum > 63 )
maximum = 63;
if( karte > maximum + 1 )
karte = maximum + 1;
C leiter ltr( anzahl, karte, maximum );
D ltr.spiel();
return 0;
}
802
21.5 Aufgabe
21.5 Aufgabe
Mittlerweile sollten Ihre Programmierkenntnisse einen Stand erreicht haben, an
dem Sie sich leicht selbst Aufgaben suchen können, die Sie selbständig lösen und bei
denen Sie am besten wissen, ob Ihre Lösung die richtige ist. Dabei sollten Sie auch zu
weiterführender Literatur greifen, die sich mit den Feinheiten von C++ beschäftigt.
Sie finden hier nur noch eine letzte Aufgabe, um eine Stringklasse in erweiterter
Form selbst zu erstellen.
803
21 Das Zusammenspiel von Objekten
A 21.1 Implementieren Sie einen neuen Datentyp string. Der Datentyp soll den Spei-
cher für die Zeichenkette intern in einem Puffer verwalten und ausschließlich
durch Methoden und Operatoren bedient werden.
Sorgen Sie dabei dafür, dass der interne Zeichenpuffer immer entsprechend
den Anforderungen schrumpft und wächst. Dabei soll der Speicher für den
Zeichenpuffer nur vergrößert werden, wenn es notwendig ist.
Der Benutzer eines Strings sollte in keiner Weise mit dem Allokieren und
Beseitigen von Speicher in Berührung kommen.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv
testet!
804
Kapitel 22
Vererbung
Der Apfel fällt nicht weit vom Stamm – oder warum das Vererben von
Eigenschaften bei der Programmierung eine gute Sache ist.
Bei der Generalisierung entstehen neue Klassen durch Abstraktion aus bestehenden
Klassen.
Kindklassen erben die Attribute und Methoden ihrer Elternklassen und können diese
verwenden. Dazu können sie bei Bedarf weitere Attribute und Methoden ausprägen
oder bestehende Methoden modifizieren.
Auto Generalisierung
...
...
Cabriolet
...
... Spezialisierung
805
22 Vererbung
In dem Beispiel ist das Cabriolet eine Kindklasse von der Elternklasse Auto. Das Cab-
riolet ist eine Spezialisierung des Autos, es ist aber auch weiter ein Auto. Sie kennen
dieses Prinzip der generalisierenden und spezialisierenden Betrachtung aus Ihrem
Alltag, wo Sie es ständig verwenden. Sie spezialisieren, indem Sie einen Begriff mit
zusätzlichen Details anreichern. Sie generalisieren, indem Sie störende Details weg-
lassen und sich auf das Wesentliche konzentrieren.
Sie haben z. B. aus der Beobachtung der Sie umgebenden Welt durch Generalisierung
den Begriff Auto entwickelt. Dazu haben Sie keine genaue Checkliste bekommen,
anhand derer Sie feststellen könnten, wann ein Objekt ein Auto ist. Trotzdem sind Sie
in der Lage, so verschieden aussehende Autos wie ein Cabriolet, einen Kombi oder
einen Minivan als Auto zu identifizieren, auch wenn Sie das betreffende Modell noch
nie zuvor gesehen haben. Sie wissen:
Auto
Basisklasse, Elternklasse,
... Vaterklasse, Parent
...
Abgeleitete Klasse,
Unterklasse, Kind-
Klasse, Child
806
22.1 Darstellung der Vererbung
Auto
...
...
Bei allen Vererbungen handelt es sich um eine Ist-ein-Beziehung, ein Touran ist ein
Minivan ist ein Auto. Die hier erfolgte Generalisierung und Spezialisierung erleich-
tern auch bei der Programmierung den Umgang mit einem behandelten Objekt.
Auch das kennen Sie aus dem richtigen Leben. Zum Beispiel wissen Sie, dass eine
Autowaschanlage mit ganz unterschiedlichen Autos umgehen kann. Die Autowasch-
anlage hat eine »Schnittstelle« für Autos.
Sie können alle Objekte vom Typ Auto an die Waschanlage übergeben. Sie benötigen
also keine speziellen Autowaschanlagen für Kombis, denn ein Kombi ist ein Auto.
22
Auch ein SUV kann in eine Autowaschanlage einfahren, ebenso wie ein Minivan.
Wir werden uns dieses Prinzip, dass abgeleitete Objekte eine Ist-ein-Beziehung zu
ihrer Elternklasse haben, später noch zunutze machen.
22.1.3 Mehrfachvererbung
Eine abgeleitete Klasse kann auch mehrere Basisklassen haben. Wir sprechen dann
von Mehrfachvererbung. Die UML-Notation dafür sieht folgendermaßen aus:
807
22 Vererbung
Auto Boot
v_max v_max
fahren() schwimmen()
Amphibienfahrzeug
Wir haben hier weiter eine Ist-ein-Beziehung. Ein Amphibienfahrzeug ist ein Auto,
und es ist auch gleichzeitig ein Boot. Es erbt die Methoden und Attribute seiner bei-
den Elternklassen und kann fahren und schwimmen.
class text
{
private:
int len;
char* txt;
public:
text( char* t );
808
22.2 Vererbung in C++
A text();
~text();
text( const text& s );
text& operator=( const text& s );
void print(){ printf( "%s\n", txt ); }
int find( char* f );
};
Listing 22.1 Erweiterung der Klasse »text «um einen parameterlosen Konstruktor
text::text()
{
len = strlen( "leer" );
txt = ( char* ) malloc( len + 1 );
strcpy( txt, "leer" );
}
Der parameterlose Konstruktor initialisiert die Klasse mit dem festen Text »leer«.
Dies ist für die Anwendung der Klasse text selbst wenig sinnvoll, vereinfacht aber
unsere nächsten Schritte.
Mit der Ableitung stellen wir eine Ist-ein-Beziehung her. Um eine Klasse abzuleiten,
gehen wir wie folgt vor:
809
22 Vererbung
Wir fügen hinter dem Namen der abgeleiteten Klasse den Namen ihrer Basisklasse
an. Dazwischen stehen noch ein Doppelpunkt : als Trennzeichen und eine Zugriffs-
spezifikation, in diesem Fall public. Mit dieser überwiegend verwendeten Zugriffs-
spezifikation werden wir uns vorerst ausschließlich beschäftigen.
Nach dieser Deklaration ist die Stringklasse str als neue von text abgeleitete Klasse
eingeführt und kann verwendet werden. Die eigentliche Klassendeklaration von str
bleibt leer. Die Klasse kann so direkt instanziiert werden und erbt die Attribute und
Methoden der Elternklasse:
void main()
{
A str string;
B string.print();
}
Da wir für die Klasse keinen Konstruktor definiert haben, erstellt das System automa-
tisch einen parameterlosen Konstruktor, den wir verwenden (A). Die Basisklasse text
wird automatisch mit ihrem parameterlosen Konstruktor initialisiert.
Über den Aufruf der print-Methode (B) greift die abgeleitete Klasse direkt auf eine
öffentliche Methode der Basisklasse zu und nutzt damit eine Funktionalität, die sie
von der Elternklasse geerbt hat. Das Programm erzeugt das erwartete Ergebnis:
leer
Wenn Sie eine abgeleitete Klasse instanziieren, müssen Sie dafür sorgen, dass alle
deren Basisklassen korrekt instanziiert werden.
810
22.2 Vererbung in C++
Ich werde einen Konstruktor für die Kindklasse erstellen, der einen passenden Kon-
struktor der Basisklasse explizit aufruft. Der Ablauf ist dabei so, dass die Basisklasse
immer vor ihrer abgeleiteten Klasse instanziiert wird.
Dieser Konstruktor macht nichts weiter, als den Konstruktor der Basisklasse text mit
dem Parameter t aufzurufen. Im aktuellen Fall hat der Konstruktor einen leeren
Codeblock {}, generell kann er natürlich auch speziellen Code zur Initialisierung der
Kindklasse enthalten.
Mit dem neuen Konstruktor kann die Klasse jetzt auch mit Parametern initiiert
werden:
void main()
{
str string( "Abgeleitete Klasse" );
string.print();
}
22
Das Ergebnis ist wie erwartet:
Abgeleitete Klasse
Das Vorgehen ist Ihnen schon aus der Konstruktion eingebetteter Objekte bekannt,
allerdings wird der Konstruktor der Basisklasse mit dem Namen der Basisklasse und
seiner entsprechenden Parametersignatur aufgerufen. Bei eingebetteten Objekten
wird der Konstruktor über den Namen des Attributs aufgerufen.
811
22 Vererbung
Es ist allerdings sinnvoll, dass eine Klasse ihren Nachkommen gewisse Sonderrechte
beim Zugriff einräumt. Dazu bietet C++ als passendes Konzept die geschützten Mem-
ber. Den geschützten Bereich protected hatte ich bisher ausgeklammert. Mit diesem
dritten Zugriffsschutz haben wir die folgenden Bereiche in einer Klasse:
class beispiel
{
A private:
B protected:
C public:
};
Der private-Bereich (A) erlaubt nur Zugriffe aus der Klasse selbst. Im Bereich protec-
ted werden die Methoden und Attribute abgelegt, auf die die abgeleiteten Klassen,
also die Kindklassen, Zugriff haben dürfen (B). Im öffentlichen Bereich public (C)
kann, wie Sie ja wissen, der Zugriff von außerhalb erfolgen.
class text
{
A protected:
int len;
char* txt;
812
22.2 Vererbung in C++
B public:
text( char* t );
text();
~text();
text( const text& s );
text& operator=( const text& s );
void print() { printf( "%s\n", txt ); }
int find( char* f );
};
und verschiebe die Elemente len und txt aus dem Bereich private in diesen protec-
ted-Bereich (A), der Bereich private entfällt damit. Der Teil der Klassendeklaration im
öffentlichen Bereich (B) bleibt unverändert.
Außenstehende, die die Klasse text verwenden, haben weiter nur über die öffentli-
chen Konstruktoren und Methoden Zugriff auf den in der Klasse enthaltenen Text,
während die Kinder der Klasse jetzt direkt auf die Attribute zugreifen können. Ein pri-
vater Bereich ist nicht mehr erforderlich, könnte aber zusätzlich vorkommen.
Die Klasse text hat bisher nur festen Text, den sie bei ihrer Erstellung zugewiesen
bekommt. Die Kindklasse soll so erweitert werden, dass dieser Text jederzeit über
eine setText-Methode geändert werden kann.
813
22 Vererbung
Dazu ergänze ich die Deklaration von str um die neue Methode setText (A) und im-
plementiere sie:
void main()
{
A str string( "Abgeleitete Klasse" );
string.print();
B string.setText( "ist erweitert" );
string.print( );
}
indem der Text der instanziierten Klasse str (A) nachträglich geändert (B) und die
Ergebnisse vorher und nachher ausgedruckt werden. Auch dazu wird wieder die
geerbte print-Funktion der Basisklasse verwendet:
Abgeleitete Klasse
ist erweitert
814
22.2 Vererbung in C++
klasse abweichende Funktionalität umgesetzt werden. Für die Klasse str soll print
den ausgegebenen Text mit spitzen Doppelklammern einfassen. Die modifizierte
Funktionalität verfolgt keinen besonderen Zweck, ich will lediglich ein abweichendes
Verhalten erzeugen, das man von außen leicht beobachten kann.
};
Um die Methode hinzuzufügen, wird sie nur in der Klasse deklariert (A) und danach
implementiert:
void str::print()
{
A printf( "<<%s>>\n", txt );
}
Die Methode unterscheidet sich von der print-Methode der Basisklasse dadurch,
dass die spitzen Klammern <<>> den Text einfassen (A). Der Aufruf der Methode über
eine Instanz von str
22
void main()
{
str s1( "Ueberdeckt" );
s1.print();
}
bringt nun das erwartetet Ergebnis. Die print-Methode der Kindklasse wird gerufen,
die Funktion der Basisklasse ist überdeckt:
<<Ueberdeckt>>
815
22 Vererbung
Es ist weiter möglich, die Methode der Basisklasse in der abgeleiteten Klasse zu ver-
wenden. Dazu muss in der Basisklasse ein qualifizierter Zugriff mit dem Scope-Reso-
lution-Operator erfolgen:
In diesem Beispiel wird in der neuen Methode basisPrint die print-Methode der
Basisklasse explizit aufgerufen, indem der qualifizierte Name text::print verwendet
wird.
Beachten Sie, dass die print-Methode der Klasse str die print-Methode der Basis-
klasse überschreibt und damit verdeckt. Wenn in der Kindklasse eine print-Methode
vorhanden ist, wird in der Basisklasse nicht mehr nach der gleichnamigen Methode
gesucht, auch wenn hier vielleicht eine Methode mit (besser) passender Signatur zur
Verfügung steht. Ich zeige Ihnen dazu noch ein Beispiel: Wenn text eine überladene
Version von print hat, die der Ausgabe ein Präfix voranstellt,
class text
{
// ...
void print() { printf( "%s\n", txt ); }
void print(char pre) { printf( "%c%s\n", pre, txt ); }
};
ist diese überladene Version jetzt über die Kindklasse str nicht mehr erreichbar, da
str.print() alle print-Methoden der Elternklasse verdeckt.
void main()
{
str s1( "Ueberdeckt" );
text t1( "Elternklasse" );
s1.print(); // OK
t1.print('_'); // OK
s1.print('_'); // Fehler, da die Methode verdeckt ist
}
816
22.2 Vererbung in C++
Die Suche nach einer print-Methode in der Basisklasse startet nur, wenn in der Kind-
klasse überhaupt keine print-Methode vorhanden ist.
void main()
{
A text t1( "Text1" );
B text* t2 = new text( "Text2" );
C static text t3( "Text3" );
G t1.print();
t2->print();
t3.print();
H s1.print();
s2->print();
s3.print();
delete t2;
delete s2;
}
22
Listing 22.13 Die unterschiedlichen Instanziierungsmöglichkeiten der Klassen
In dem Programm werden Instanzen der Klasse text automatisch, dynamisch und sta-
tisch instanziiert (A–C), das Gleiche passiert mit den Instanzen der Klasse str (D–F).
Die Ausgabe der jeweiligen Objekte erfolgt dann in (G), und (H) und liefert das erwar-
tete Ergebnis:
Text1
Text2
Text3
<<String1>>
<<String2>>
<<String3>>
817
22 Vererbung
void main()
{
A text *t1, *t2;
B t1 = new text( "Text t1" );
C t2 = new str( "String t2" );
D t1->print();
E t2->print();
delete t1;
delete t2;
}
Listing 22.14 Instanziierung von »text« und »str« als Objekt vom Typ »text«
Das Programm deklariert mit t1 und t2 zwei Zeiger auf text (A). Dem Zeiger t1 wird
durch dynamische Instanziierung ein Objekt vom Typ text zugewiesen (B). Der Zei-
ger t2 erhält einen Zeiger auf ein Objekt vom Typ str zugewiesen (C). Das ist problem-
los möglich, denn laut der Vererbung gilt: str ist ein text.
Interessant wird es bei der Ausgabe der instanziierten Objekte (D, E), denn die Aus-
gabe erfolgt in beiden Fällen im Format der print-Methode von text, obwohl die
Typen der instanziierten Objekte sich unterscheiden:
Text t1
String t2
Diese Ausgabe war auch zu erwarten. Wir instanziieren für die Variable t2 zwar ein
Objekt der Klasse str, wir verwenden diese jedoch als text. Wenn die print-Methode
eines Objekts der Klasse text aufgerufen wird, dann erfolgt die Ausgabe auch mit des-
sen Methode und ohne die spitzen Klammern der überladenen Ausgabe von str.
Dynamische Instanziierung
Zeiger auf dynamisch instanziierte Objekte können während der Ausführung auf
Objekte unterschiedlichen Typs zeigen. Welcher Typ es letztlich sein wird, ist zum
818
22.2 Vererbung in C++
Übersetzungszeitpunkt unter Umständen noch gar nicht bekannt. Das folgende Bei-
spiel illustriert diese Möglichkeit:
void main()
{
A text *t = 0;
B char typ, init[] = "INIT";
while( 1 )
{
cout << "text oder str [t|s]: ";
C cin >> typ;
if( typ == 't' )
D t = new text( init );
else
E t = new str( init );
F t->print();
}
}
Im Programm wird ein Zeiger t auf text deklariert (A). In einer Schleife wird der
Benutzer nach dem Typ der Klasse gefragt, die mit vorgegebenem Text (B) initialisiert
werden soll (C). Je nach Benutzereingabe wird das Objekt, auf das t zeigt, dann als
text (D) oder als str (E) dynamisch erstellt. Bei der Ausgabe wird aber immer die
print-Methode von text verwendet, da t vom Typ Zeiger auf text ist.
Es gibt viele Situationen, in denen die tatsächliche Klasse des erstellten Objekts erst
zur Laufzeit ausgewählt wird – etwa wie oben nach Benutzereingaben oder aufgrund
anderer Randbedingungen.
Daher wollen wir das System auffordern können, die Klasse bei Aufruf der print-
Methode zur Laufzeit zu prüfen. Je nachdem, welcher konkreten Klasse das Objekt
dann angehört (text oder str), soll dann die print-Methode dieser Klasse gerufen
werden.
819
22 Vererbung
class text
{
protected:
int len;
char* txt;
public:
text( char* t );
// ...
A virtual void print(){ printf( "%s\n", txt ); }
};
Im Beispiel hier wurde die print-Methode der Klasse text als virtual gekennzeichnet
(A). Eine weitere Änderung ist weder in der Basisklasse noch in der abgeleiteten
Klasse notwendig:
Die Kindklasse bleibt unverändert. Nur in der Basisklasse wurde das Schlüsselwort
virtual hinzugefügt. Wenn das unveränderte Testprogramm noch einmal gestartet
wird,
820
22.2 Vererbung in C++
void main()
{
text *t = 0;
char typ, init[] = "INIT";
while( 1 )
{
cout << "text oder str [t|s]: ";
cin >> typ;
if( typ == 't' )
t = new text( init );
else
t = new str( init );
t->print();
}
}
wird zur Laufzeit die Klasse des instanziierten Objekts ermittelt und die korrekte
print-Methode verwendet:
Die korrekte Auswahl der Klasse erkennen Sie leicht an den spitzen Klammern der
Ausgabe der Klasse str.
22
22.2.8 Verwendung des Schlüsselwortes virtual
Das Schlüsselwort virtual aktiviert für die gekennzeichnete Methode die dynami-
sche Prüfung des Typs zur Laufzeit, auch dynamisches Binden genannt.
821
22 Vererbung
Text Text
print
gerufen print() ausgeführt gerufen print()
Methode der
konkreten
print Methode der Instanz
Str konkreten Instanz Str
22.2.9 Mehrfachvererbung
Wir haben die einfache Vererbung am Beispiel der Klassen text und str implemen-
tiert. Ich werde jetzt noch einen Schritt weiter gehen und eine Klasse erstellen, die
von zwei unterschiedlichen Klassen erbt.
Dazu erstelle ich die Klasse logeintrag, die einen Eintrag in einem Systemprotokoll
oder Logbuch verwaltet. In der UML soll die Klassenhierarchie folgendermaßen aus-
sehen:
822
22.2 Vererbung in C++
Text
...
...
Datum
1
...
Str Timestamp ...
... ...
... ... Zeit
1
...
...
Logeintrag
...
...
Die bereits implementierten Klassen bleiben unverändert. Die neue Klasse logein-
trag erbt von den beiden bestehenden Klassen str und timestamp. In C++ wird dies
folgendermaßen implementiert:
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ), 22
str ( tx ) {}
};
Wie bei der Einfachvererbung werden bei der Mehrfachvererbung erst der Name der
Kindklasse und ein Doppelpunkt angegeben. Es folgen Zugriffsspezifikation und
Name der Basisklasse, jeweils durch Kommata voneinander getrennt (A), in diesem
Fall für zwei Elternklassen.
Für die Bereitstellung entsprechender Konstruktoren gilt das Gleiche wie für die ein-
fache Vererbung. Der Nutzer einer Klasse ist für die korrekte Initialisierung der Basis-
klassen verantwortlich.
823
22 Vererbung
Zur Initialisierung der Basisklassen habe ich der Klasse logeintrag einen parameter-
losen Konstruktor erstellt, der timestamp mit seinem parameterlosen Konstruktor
und str mit einem leeren String initialisiert (B).
Zusätzlich habe ich einen weiteren Konstruktor mit insgesamt sechs Parametern
erstellt. Dieser versorgt die beiden Basisklassen und damit auch deren eingebettete
Klassen mit Initialisierungswerten. In Abbildung 22.8 ist der Ablauf noch einmal gra-
fisch dargestellt:
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ), str ( tx ) {}
Text
len = 8
text = Neujahr
Datum
tag = 1
dat( 1, 1, 2015 ) monat = 1
txt("Neujahr") jahr = 2015
Timestamp
Str dat
zt
Zeit
stunde = 0
zt( 0, 1 ) minute = 1
str("Neujahr") timestamp( 1, 1, 2015, 0, 1 )
Logeintrag
Abbildung 22.8 Instanziierung der Klasse »timestamp« und Weitergabe der Parameter
Das Diagramm zeigt, wie die Parameter des Konstruktors von logeintrag verteilt
werden. Die Klasse str wird instanziiert und konstruiert zuerst ihre Basisklasse text.
Im Konstruktor von timestamp werden die Daten an die Konstruktoren der eingebet-
teten Objekte dat und zt weitergeleitet.
Damit erbt die Klasse zwei Methoden mit dem Namen print, die Methoden
str.print und timestamp.print.
824
22.2 Vererbung in C++
Eine Instanziierung der Klasse logeintrag und Zugriff auf die print-Methode quit-
tiert der Compiler mit einer Fehlermeldung bei der Übersetzung.
Timestamp
Str
dat
zt
print() print()
Logeintrag
void main()
{
logeintrag le( 1, 1, 2015, 0, 1, "Neujahr" );
A le.print();
}
Die print-Methode in (A) kann nicht eindeutig identifiziert werden. Das System kann
die Mehrdeutigkeit nicht auflösen. Wenn ich für die Klasse logeintrag eine der geerb-
ten print-Methoden verwenden will, muss ich zuerst wieder Eindeutigkeit herstel-
len. Dazu deklariere ich in der Klasse logeintrag eine neue print-Methode:
Mit der Deklaration ist für den Compiler wieder unzweifelhaft geklärt, welche
Methode aufgerufen werden soll. Es fehlt nur noch eine passende Implementierung,
die natürlich die bereits vorhandene Funktionalität der Basisklassen verwenden soll:
825
22 Vererbung
A void logeintrag::print()
{
B timestamp::print();
printf( " " );
C str::print();
printf( "\n" );
}
Innerhalb von logeintrag.print (A) wird direkt auf die Methode print der Klasse
timestamp zugegriffen (B) und in der Ausgabe mit dem Ergebnis der Methode
str.print verknüpft (C).
In diesem Beispiel sind alle Methoden parameterlos. Hier kann aber auch ein Aufruf
mit Parametern erfolgen. Der Compiler wählt in dem Fall aus passenden überlade-
nen Funktionen anhand der Parametersignatur aus. Die print-Methode des Logein-
trags kann nun verwendet werden
void main()
{
logeintrag le( 1, 1, 2015, 0, 1, "Neujahr" );
le.print();
}
Dazu benötige ich ein weiteres Attribut in der Klasse, das die Anzahl der aktuell exis-
tierenden Instanzen festhält. Der Zähler soll im privaten Bereich der Klasse liegen. Es
handelt sich dabei um eine interne Information, Außenstehende sollen den Zähler
826
22.2 Vererbung in C++
nicht sehen oder gar verändern können. Der Zähler soll nicht für jede Instanz der
Klasse existieren, sondern nur einmal für die ganze Klasse. Daher kann dieser Zähler
kein gewöhnliches Datenmember von logeintrag sein.
Für solch eine Art von Daten können in einer Klasse sogenannte statische Daten-
member angelegt werden. Man stellt der Deklaration eines Attributs dazu das Schlüs-
selwort static voran1:
public:
logeintrag() : timestamp(), str( "" ) {}
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ),
str ( tx ) {}
void print();
};
Das Member aktiv (A) ist jetzt ein Attribut, das sich im Namensraum der Klasse
befindet und deren Zugriffsschutz genießt. Es ist aber nicht für jede Instanz der
Klasse vorhanden, sondern nur einmal. Dieses statische Datenmember ist von den
Instanzen unabhängig und wird nicht über einen Konstruktor initialisiert. Es wird
definiert wie eine globale Variable, der der Klassenname vorangestellt wurde. Dabei
initialisiere ich den Startwert auch gleich auf 0:
int logeintrag::aktiv = 0; 22
Den Konstruktor der Klasse erweitere ich jetzt so, dass ein neues Element den Zähler
inkrementiert. Zusätzlich erstelle ich einen Destruktor, der den Zähler dekremen-
tiert, wenn eine Instanz abgebaut wird:
1 Beachten Sie, dass das Schlüsselwort static innerhalb einer Klasse eine ganz eigene Bedeutung
hat.
827
22 Vererbung
private:
static int aktiv;
public:
A logeintrag() : timestamp(), str( "" ) { aktiv++;}
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ), str ( tx )
B { aktiv++;}
C ~logeintrag() { aktiv--; }
void print();
};
Da der Konstruktor beim Anlegen eines neuen Elements immer durchlaufen wird, ist
sichergestellt, dass der Zähler mit jeder neuen Instanz erhöht wird. Sie müssen natür-
lich darauf achten, dass alle Konstruktoren entsprechend berücksichtigt werden (A
und B). Analog wird durch den Destruktor der Zähler der aktiven Elemente bei Abbau
einer Instanz heruntergesetzt (C).
Es stellt sich noch die Frage, wie Sie die ermittelten Werte abfragen können. Da der
Zähler im privaten Bereich der Klasse abgelegt ist, ist Zugriff von außen nicht mög-
lich. Eine »normale« Getter-Funktion als Lösung ist nicht möglich. Der abgefragte
Wert »gehört« ja keinem einzelnen Logeintrag. Insbesondere wäre ein solcher Getter
ja erst erreichbar, wenn mindestens eine Instanz der Klasse existiert. Wie das Attribut
muss auch die zugehörige Getter-Funktion für das Attribut aktiv der ganzen Klasse
gehören. Auch sie wird als static deklariert2.
public:
logeintrag() : timestamp(), str( "" ) { aktiv++;}
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ), str ( tx )
{ aktiv++;}
~logeintrag() { aktiv--; }
2 Generell ist zu beachten, dass statische Funktionsmember nur auf statische Datenmember
zugreifen können, da sie zur Klasse und nicht zur Instanz gehören. Andersherum gibt es keine
Beschränkung.
828
22.2 Vererbung in C++
Statische Member – egal, ob Daten- (A) oder Funktionsmember (B) – gehören der gan-
zen Klasse. Sie werden daher auch nicht wie die anderen Attribute und Methoden
über eine Instanz aufgelöst, sondern eben über die Klasse. Eine Abfrage des aktuellen
Zählerstands erfolgt damit in dieser Form:
void main()
{
A printf( "Anzahl Logeintraege: %d\n", logeintrag::getAktiv());
{
B logeintrag log;
C printf( "Anzahl Logeintraege: %d\n", logeintrag::getAktiv());
D }
E printf( "Anzahl Logeintraege: %d\n", logeintrag::getAktiv());
}
Das Programm gibt die Anzahl der Logeinträge aus, bevor eine Instanz erstellt wor-
den ist (A), erstellt dann eine Instanz log (B) und macht eine erneute Ausgabe (C). Am
Ende des Blocks (D) wird der Destruktor der automatisch erzeugten Variablen log
gerufen und die einzige Instanz von logeintrag zerstört, und es erfolgt in (E) eine
abschließende Ausgabe zu diesem Gesamtergebnis:
Anzahl Logeintraege: 0
Anzahl Logeintraege: 1 22
Anzahl Logeintraege: 0
Gelegentlich benötigt man auch Klassen, die virtuelle Methoden enthalten, bei denen
es (noch) nicht sinnvoll ist, eine Implementierung vorzunehmen. In einem solchen
Fall deklarieren wir die Methode in der Basisklasse als rein virtuelle Methode und
erzwingen damit die Implementierung in den Kindklassen. Rein virtuelle Funktionen
haben anstelle einer Implementierung den Zusatz = 0 in der Klassendeklaration:
829
22 Vererbung
class basis
{
public:
virtual void print() = 0;
};
Die Deklaration einer rein virtuellen Methode hat zur Folge, dass die zugehörige
Klasse nicht instanziiert werden kann, solange die notwendige Implementierung
noch »fehlt«. Eine Klasse mit mindestens einer rein virtuellen Methode wird ab-
strakte Klasse genannt.
Die Kindklasse einer abstrakten Klasse muss für alle rein virtuellen Methoden der
Basisklasse eine Implementierung bereitstellen, erst dann kann sie instanziiert wer-
den. Ich zeige Ihnen das notwendige Vorgehen anhand der vorgestellten abstrakten
Basisklasse mit der rein virtuellen Methode print (A). Der Versuch, die Klasse zu
instanziieren, endet mit einer Fehlermeldung. Auch eine von basis abgeleitete
Klasse kann erst dann instanziiert werden, wenn sie für alle rein virtuellen Methoden
eine Implementierung liefert.
Mit abgeleitet (A) habe ich jetzt eine instanziierbare Kindklasse gebildet, da ich für
die bis dahin rein virtuelle print-Methode eine Implementierung gestellt habe.
Durch die Deklaration der Klasse basis mit der rein virtuellen Methode print wird
aber die Schnittstelle für alle zukünftigen Kindklassen bereits festgeschrieben.
Eine rein virtuelle Methode ist sinnvoll, wenn für eine bestimmte Methode noch
keine sinnvolle Implementierung existiert. In diesem Fall kann z. B. der Ersteller der
Klasse basis seinen Code darauf aufbauen, dass Klassen mit dieser Basisklasse immer
eine print-Methode bereitstellen werden.
830
22.3 Beispiele
22.3 Beispiele
Die Prinzipien der objektorientierten Programmierung und die Stärken der Verer-
bung werde ich Ihnen nun anhand zwei größerer Beispiele erläutern. In beiden Bei-
spielen wird die dynamische Bindung als zentrales Element eingesetzt.
22.3.1 Würfelspiel
Bei dem ersten Beispiel handelt sich um ein einfaches Würfelspiel, bei dem die Spie-
ler von einem Startfeld aus ein bestimmtes Ziel erreichen müssen.
Auf dem Weg zum Zielfeld gibt es Hindernisse, die einen Spieler aufhalten oder
zurückwerfen, aber auch Felder, die einen Spieler weiter voranbringen. Ein beispiel-
hafter Spielplan könnte folgendermaßen aussehen:
3 Felder
Ziel zurück
22
2-mal aus-
setzen
Wir wollen in unserem Spiel den gerade gezeigten Spielplan umsetzen. Das Pro-
gramm soll aber so gestaltet sein, dass beliebige Spielpläne dieser Art erstellt werden
können. Durch einen Blick in den Spielkarton kann man bereits die folgenden Ele-
mente identifizieren:
831
22 Vererbung
Damit haben Sie die wesentlichen Klassen für das Design bereits kennengelernt:
Würfel
Spiel
Figur Feld
Wenn Sie den Spielplan noch einmal genauer betrachten, dann erkennen Sie schnell,
dass unser Spielplan aus einzelnen Feldern besteht, die sich in unterschiedliche
Typen einteilen lassen. Neben den normalen Feldern gibt es ein Start- und ein Ziel-
feld sowie besondere »Ereignisfelder«.
Insgesamt finden wir auf dem Spielplan die folgenden Felder wieder:
왘 ein Startfeld
왘 ein Zielfeld
왘 Sprungfelder
왘 Wartefelder
Jedes dieser Felder ist auch ein Feld, stellt aber eine Spezialisierung des gewöhnlichen
Feldes dar. Wir werden diese Spezialfelder von dem gewöhnlichen Feld durch Ver-
erbung ableiten:
832
22.3 Beispiele
Würfel
Spiel
Figur Feld
왘 Das Spiel kann von einem bis vier Spielern gespielt werden.
왘 Jeder Spieler bekommt eine Spielfigur und startet mit dieser vom Startfeld.
왘 Es wird reihum gewürfelt, und ein Spieler rückt immer entsprechend seiner
Augenzahl vor.
왘 Es können mehrere Figuren auf einem Feld stehen, und Figuren können nicht
geschlagen werden. 22
왘 Wenn eine Figur auf ein Sprungfeld kommt, muss sie die dort angegebene Zahl an
Schritten vor- oder zurückgehen.
왘 Wenn eine Figur auf ein Wartefeld kommt, muss der Spieler die dort angegebene
Anzahl von Runden aussetzen.
왘 Wenn man mit einem Wurf über das Ende des Spielplans hinauskommt, muss
man die überzähligen Punkte wieder zurücksetzen.
왘 Wer als Erster das Zielfeld exakt erreicht hat, hat gewonnen.
Aus der Spielanleitung können wir auch die Kardinalitäten ermitteln, die wir in unser
Design eintragen, ebenso eine weitere wichtige Komponente, den Spieler:
833
22 Vererbung
1
Würfel
n
nimmt teil an
Spieler 1..4 1
Spiel
1
4 n
steht auf
Figur Feld
1 4 1
왘 Am Anfang melden sich die Spieler als Teilnehmer am Spiel an. Das Spiel hat dazu
eine Methode anmeldung, die vom Spieler aufgerufen wird.
왘 Bei der Anmeldung erhält der Spieler eine Figur, die mit figurAufstellen auf dem
Startfeld platziert wird. Wenn sich genügend Spieler angemeldet haben, wird das
Spiel gestartet, um eine Partie zu spielen.
왘 Dies wird von der Methode partie kontrolliert. In der Partie übernimmt das Spiel
die Kontrolle und fordert die Spieler nacheinander auf, einen Zug zu machen. Es
sendet eine Botschaft an den betroffenen Spieler, der daraufhin seine Methode zug
ausführt.
왘 Bevor der Spieler aufgefordert wird, einen Zug zu machen, fragt das Spiel das Feld,
auf dem die Figur des Spielers steht, ob die Figur das Feld verlassen darf oder ob sie
blockiert ist.
왘 Dazu dient die Methode blockiert des Feldes. Ist die Figur blockiert, wird der Spie-
ler übersprungen.
왘 Ist ein Spieler aufgefordert zu ziehen, nimmt er den Würfel und würfelt mit dessen
Methode wurf.
834
22.3 Beispiele
왘 Mit der gewürfelten Zahl wendet er sich an seine Figur und fordert sie auf, die ent-
sprechende Anzahl von Feldern weiterzuziehen. Die Figur wendet sich dazu an ihr
Feld und dessen Methode figurSetzen mit dem Auftrag, entsprechend weiterzu-
ziehen.
왘 Die Felder reichen die Figur dann mit der Methode step von Feld zu Feld weiter.
Das Spiel setzt den Prozess fort, bis eine Figur das Zielfeld erreicht hat.
1
Würfel
würfeln wurf()
n
nimmt teil an
Spieler 1..4 1
Spiel
an-
melden Figur aufstellen
Zug
anmeldung()
zug() partie()
machen
Figur blockiert?
1
4 n
steht auf
ziehen
Figur Feld
1 4 1
Die Implementierung
22
Damit sind die Vorüberlegungen abgeschlossen, und die Implementierung des Spiels
kann starten. Als Erstes soll der Würfel implementiert werden:
class wuerfel
{
public:
A wuerfel( int seed ) { srand( seed ); }
B int wurf() { return rand() % 6 + 1; }
};
Zur Realisierung des Würfels verwenden wir die Funktionen zur Generierung von
Zufallszahlen aus der C Runtime Library. Im Konstruktor der Klasse wird der Zufalls-
835
22 Vererbung
zahlengenerator initialisiert (A). Die Methode wurf liefert die gewürfelte Augenzahl
als Rückgabewert (B).
Als Nächstes wird die Klasse feld als Basisklasse aller Elemente des gesamten Spielfel-
des umgesetzt. Die einzelnen Felder werden in Form einer verketteten Liste mitein-
ander verknüpft.
class feld
{
private:
A feld *nxt;
B feld *prv;
protected:
C int besetzt[4];
D virtual feld *step( int fig, int steps );
public:
E feld( feld *ende );
F feld* getNext() { return nxt; }
G virtual char getTyp() { return '.'; }
H feld * figurSetzen( int fi, int wurf );
I virtual int blockiert( int fig ) { return 0; }
};
Für die Verkettung in der Liste enthält die Klasse im privaten Bereich die Zeiger auf
Nachfolger (A) und Vorgänger (B). Die Spielfelder werden in der Reihenfolge ihres
Vorkommens in einer doppelt verketteten Liste gespeichert. Der Zeiger nxt zeigt
jeweils auf den Nachfolger, prev auf den Vorgänger.
Auf die Daten im privaten Bereich kann nur die Klasse selbst zugreifen. Nicht einmal
die abgeleiteten Klassen (Startfeld, Wartefeld, Sprungfeld und Zielfeld) sollen in die
Verkettung eingreifen können.
Im geschützten Bereich werden die Attribute und Methoden geführt, die auch für die
Kinder, also die abgeleiteten Feldtypen, zugreifbar sein sollen.
Im Array besetzt (C) wird festgehalten, welche der vier Figuren (Index 0–3) dieses
Feld aktuell besetzen. Die hier deklarierte Methode steps dient dazu, die Figur mit
dem Index fig um die Anzahl von steps Feldern weiterzuziehen (D). Die step-
Methode ist virtuell, da bestimmte Felder wie das Sprungfeld sie anders implemen-
tieren werden als das allgemeine Feld. Durch die Kennzeichnung als virtual wird
sichergestellt, dass die richtige Methode aufgerufen wird, auch wenn ein spezielles
Feld als allgemeines Feld verwendet wird.
836
22.3 Beispiele
Die drei Funktionen, für die in den spezialisierten, abgeleiteten Klassen ein besonde-
res Verhalten implementiert wird (step, getTyp und blockiert), sind als virtual
deklariert, damit zur Laufzeit die richtige Funktion aufgerufen wird.
Die Funktionen, die später vollständig auf der Abstraktionsebene der Klasse feld
abgehandelt werden, sind nicht als virtuell gekennzeichnet.
Die Methoden getNext, getTyp und blockiert sind bereits inline in der Klassendekla-
22
ration implementiert. Die noch fehlenden Methoden step und figurSetzen sowie
den Konstruktor gehen wir jetzt an.
Der Konstruktor von feld hat im Wesentlichen die Aufgabe, die Felder des Spielfeldes
zu verketten. Dazu wird das neu instanziierte Objekt an das Ende der aktuellen Liste
angehängt. Das Listenende wird dem Konstruktor als Parameter ende übergeben. Das
Einfügen des Elements in die Liste ist in Abbildung 22.16 dargestellt.
837
22 Vererbung
if( ende )
C ende->nxt = this;
ende
Abbildung 22.15 Einfügen des neuen Feldes an das Ende der Liste
Das neu erstellte Feld ist das letzte Feld der Liste, es hat daher keinen Nachfolger nxt
(A). Das alte ende der Liste ist jetzt der Vorgänger prv (B) des neuen Feldes. Damit ist
das neue Feld dann auch der Nachfolger des alten Listenendes (C). Nachdem das neue
Feld verkettet ist, werden die Felder des Arrays besetzt noch auf 0 gesetzt (D). Das
bedeutet, dass sich keine der Figuren auf dem neu angelegten Feld befindet.
Auch die Methode zum Setzen einer Figur ist Bestandteil der Klasse feld:
838
22.3 Beispiele
Wenn eine Figur mit dem Index fig gesetzt werden soll, wird zuerst geprüft, ob die
Figur auf diesem Feld steht und nicht blockiert ist (A). Sonst gibt die Methode eine 0
als Fehler zurück. Wenn die Figur gezogen werden kann, wird das Feld als nicht mehr
von dieser Figur besetzt markiert (B). Das eigentliche Weiterziehen der Figur wird
durch die Methode step realisiert. Die step-Methode liegt im geschützten Bereich
und kann auch nur aus der Klasse selbst aufgerufen werden.
Als Ergebnis liefert die step-Methode einen Zeiger auf das Feld, auf dem die Figur zu
stehen kommt. Diesen Zeiger reicht figurSetzen gleich als eigenes Resultat weiter (C).
Wenn vorwärts gelaufen wird (E), wird die step-Methode für das Nachfolgefeld aufge-
rufen, allerdings mit einem Schritt weniger (F), ansonsten entsprechend für das Vor-
gängerfeld, auch mit einem Schritt weniger (G).
839
22 Vererbung
Die Funktionen, die in der Klasse feld selbst abgewickelt werden, sind damit voll-
ständig implementiert. Als Nächstes kann die Spezialisierung in den abgeleiteten
Klassen Zielfeld (ziel), Startfeld (start), Sprungfeld (sprung) und Wartefeld (warte)
umgesetzt werden.
Das Zielfeld unterscheidet sich vom normalen Feld nur durch die Antwort auf getTyp:
Die Klasse benötigt keinen besonderen Konstruktor, sie ruft nur den Konstruktor der
Basisklasse auf und reicht den Parameter ende weiter (A).
Die Rückgabe auf getTyp ist hier ein 'Z' für Zielfeld (B).
Auch das Startfeld leitet sich von dem gewöhnlichen Feld ab, hat diesem gegenüber
aber ein erweitertes Verhalten:
Das Startfeld ist das erste zu erzeugende Feld und kann (nur) ohne ein bestehendes
Listenende konstruiert werden. Es hat daher einen parameterlosen Konstruktor, der
ein vorgängerloses Feld (ende = 0) instanziiert (A).
Als Anfrage zum Typ gibt das Startfeld 'S' zurück (B). Außerdem hat das Startfeld
eine zusätzliche Methode, um eine Figur aufzustellen (C). Es ist das einzige Feld, auf
dem man eine Figur direkt aufstellen kann. Alle anderen Felder kann man nur durch
Ziehen erreichen.
Das Sprungfeld hat ein eigenes Verhalten und ein zusätzliches Attribut:
840
22.3 Beispiele
public:
B sprung( feld * ende, int off ) : feld( ende ) { offset = off; }
C char getTyp() { return offset > 0 ? '+' : '-'; }
};
Die Klasse sprung speichert in der privaten Variablen offset, wie weit eine Figur
springen muss, die hier landet (A). Die Richtung des Sprungs wird über das Vorzei-
chen codiert. Diese Variable offset muss bei Instanziierung des Objekts gesetzt wer-
den, dazu enthält der Konstruktor den Parameter off (B). Wie schon beim Zielfeld
wird die Information über das Listenende zum Einketten des Feldes an den Konstruk-
tor der Basisklasse weitergeleitet. Die Information, die ein Sprungfeld als Typ zurück-
gibt, ist – abhängig von der Sprungrichtung – entweder '+' für Sprungfelder
vorwärts, ansonsten '-' (C).
Wenn eine Figur auf dem Feld zum Stehen kommt (steps == 0) (A), wird der Zug um
den offset in positive oder negative Richtung verlängert (B).
Die weitere Abwicklung des Zuges wird dann der step-Methode der Basisklasse über-
lassen (C), die auch zum Tragen kommt, wenn der Zug nicht auf dem Feld selbst endet.
Als letztes spezialisiertes Feld muss jetzt nur noch das Wartefeld umgesetzt werden:
841
22 Vererbung
public:
C warte( feld* ende, int t ) : feld( ende ) { timeout = t; }
D char getTyp() { return 'W'; }
Das Wartefeld verwaltet in der privaten Variablen timeout die generelle Wartezeit, die
eine Figur auf dem Feld verbringen muss (A), und im Array wait einen individuellen
Zähler für jede wartende Figur (B).
Der Konstruktor erhält neben dem Listenende zum Einketten einen Parameter t, mit
dem timeout initialisiert wird (C). Das Listenende wird wie bei den anderen Feldern
an den Konstruktor der Basisklasse weitergereicht. Als Typ gibt das Wartefeld 'W'
zurück (D).
Das Wartefeld verfügt über eine spezifische Methode blockiert, die zusammen mit
einer eigenen step-Methode die besondere Funktionalität des Wartefeldes imple-
mentiert.
Bei jeder Anfrage, ob eine Figur fig blockiert ist, wird die eventuell noch vorhandene
Wartezeit reduziert (A), die Figur bleibt weiter blockiert (B).
Steht keine Wartezeit für die Figur an, ist sie nicht blockiert und kann ziehen (C).
842
22.3 Beispiele
In der step-Methode von warte wird geprüft, ob der Zug der Figur auf dem Feld endet
(A). Ist das der Fall, wird die Figur für die eingestellte Anzahl von Zügen blockiert (B).
Die weitere Behandlung des Zuges erfolgt über die step-Methode der Basisklasse (C).
Dies ist der Fall, wenn der Zug auf dem Feld geendet hat, aber auch, wenn über das
Feld hinweggezogen wird.
Durch das Design unseres Spiels könnten weitere Ereignisfelder leicht umgesetzt
und integriert werden:
왘 ein Feld, von dem man nur nach Wurf einer bestimmten Augenzahl weiterziehen
darf
왘 ein Feld, bei dem der nächste Wurf rückwärts zählt
왘 ein Feld, das man nur überspringen kann, wenn man mindestens drei Augen über
das Feld hinaus gewürfelt hat. Andernfalls bleibt man auf dem Feld stehen oder
muss den Rest des Wurfes aussetzen
Bei der Implementierung werden Sie feststellen, dass es mit der objektorientierten
Entwicklung sehr einfach ist, neue Klassen hinzuzufügen und diese konsistent in die
bestehende Programmstruktur einzubauen.
22
An dieser Stelle sollen aber die noch fehlenden Klassen figur, spiel und spieler im
Vordergrund stehen. Dabei soll zuerst die Klasse figur fertiggestellt werden. Der
Code, der für diese Klasse noch benötigt wird, ist übersichtlich:
class figur
{
A friend class spiel;
private:
B feld * pos;
C int nummer;
public:
D figur() { pos = 0; }
843
22 Vererbung
Die erst noch zu implementierende Klasse spiel wird als Freund von figur deklariert
(A). Dies ist notwendig, damit das Spiel einer Figur später beim Aufstellen seine Num-
mer direkt zuweisen kann. Als private Attribute verwaltet die Figur einen Zeiger auf
das Feld, auf dem sie steht (B), und die Nummer, die ihr bei der Erstellung durch das
Spiel zugewiesen wird (C). Als friend ist dem Spiel der Zugriff auf das private Attribut
möglich. Bei der Erstellung durch ihren Konstruktor (D) steht die Figur auf gar kei-
nem Feld, deshalb wird die Position auf 0 gesetzt.
Um zu ziehen, verwendet eine Figur die figurSetzen-Methode des Feldes pos, auf
dem sie steht. Sie übergibt als Parameter ihre eigene Nummer und die Augenzahl des
Wurfes als die zu ziehenden Felder (E).
Als nächstes Element ist die Klasse für den Spieler an der Reihe. Der Spieler muss
selbst nur noch wenig tun, da das notwendige Verhalten in den anderen Klassen im-
plementiert ist. Daher ist auch hier die Klassenbeschreibung knapp, insbesondere da
ich eine Methode und den Konstruktor auch erst später implementieren werde:
class spieler
{
private:
A char name[20];
B spiel * game;
C figur *fig;
public:
D spieler( char *n, spiel *s );
E char * getName() { return name; }
F void zug();
};
Im privaten Bereich werden der Name des Spielers (A), ein Zeiger auf das Spiel, in dem
der Spieler aktiv ist (B), und auf seine Figur abgelegt (C).
Der Konstruktor für einen Spieler erhält dessen Namen und einen Zeiger auf das
Spiel (D), wird aber erst später implementiert.
844
22.3 Beispiele
Ansonsten kann der Spieler auf Anfrage noch seinen Namen mitteilen (E) und einen
Zug ausführen (F). Der Konstruktor und die Methode zug werden Methoden der noch
zu erstellenden Klasse spiel verwenden. Daher ziehe ich diese Klasse jetzt vor, bevor
diese Methoden implementiert werden.
class spiel
{
private:
A int anzSpieler;
B spieler *player[4];
C start *startfeld;
D int spielrunde();
public:
E wuerfel w;
F figur fig[4];
G spiel( int seed );
H figur *anmeldung( spieler *sp );
I void spielstand();
J void partie();
K ~spiel();
};
Die Klasse verwaltet in ihrem privaten Bereich die Anzahl der Mitspieler (A) und ein
Array mit Zeigern auf die teilnehmenden Spieler (B). Über das erste Feld startfeld (C)
ist das gesamte Spielfeld ansprechbar, da über dieses Feld die komplette verkettete
Liste zugreifbar ist. Die Methode spielrunde (D) ist privat, da sie nur über die (öffent- 22
lich verfügbare) Methode partie verwendet wird. Diese wickelt die gesamte Partie
rundenweise ab.
Der Würfel (E) und die Spielfiguren (F) des Spiels sollen für alle zugreifbar sein und befin-
den sich daher im öffentlichen Bereich der Klasse – ebenso wie der Konstruktor (G) und
die weiteren Methoden (H–K), die ich Ihnen der Reihe nach vorstellen werde. Ich starte
mit dem Konstruktor, der das komplette Spiel inklusive des Spielplans erzeugt:
845
22 Vererbung
Im Konstruktor der Klasse spiel wird das Spielfeld aufgebaut, indem die einzelnen
Felder des Spielplans, beginnend mit dem Startfeld (A), instanziiert werden. Die Ver-
kettung der Felder erfolgt jeweils über deren Konstruktor. Dazu wird immer das aktu-
ell letzte Feld (last) im Spielplan an den Konstruktor übergeben (B). Im Konstruktor
wird das Feld in die Liste eingekettet. Das neue Feld ist nach der Einkettung dann das
aktuell letzte Feld, der Rückgabewert wird entsprechend als last gespeichert. Bei Auf-
bau des Spielplans gibt es noch keine Spieler (C). Die Anzahl der Spieler wird später in
der Methode anmeldung für jeden neu angemeldeten Spieler inkrementiert.
846
22.3 Beispiele
Im Aufbau des Spielplans wird jeweils nur eine einheitliche Sprungweite (hier +3 und 22
–3) für die Sprungfelder vorwärts und rückwärts verwendet. Dies ist so umgesetzt, um
die Ausgabe des Spielplans übersichtlich zu halten. Für die Sprungfelder wird dort
jeweils nur das Vorzeichen der Sprungrichtung ohne Sprungweite dargestellt. Die
Sprungfelder sind aber so implementiert, dass jedes Feld einen individuellen Wert
verwalten kann. Wollten Sie dies nutzen, müsste allerdings die Ausgabe angepasst
werden. Das Gleiche gilt für die Wartefelder, die im hier implementierten Spielplan
alle eine einheitliche Wartezeit haben und dort immer als 'W' dargestellt werden.
In einer Erweiterung wäre es auch problemlos möglich, die Konfiguration des Spiel-
plans aus einer entsprechenden Datei zu laden und den Plan mit diesen Daten zu
erstellen.
847
22 Vererbung
spiel::~spiel()
{
feld *f, *t;
A for( f = startfeld; t = f; )
{
B f = f->getNext();
C delete t;
}
}
Um alle Felder abzufahren, starten wir eine Schleife mit dem Startfeld des Spielplans
(A), holen jeweils das Folgefeld aus der verketteten Liste (B) und löschen das aktuelle
Feld (C) so lange, bis kein Nachfolgefeld mehr vorhanden ist. Die Zuweisung von t = f
als Schleifenbedingung in (A) bricht ab, wenn ein Feld keinen Nachfolger mehr hatte.
D fig[anzSpieler].nummer = anzSpieler;
E startfeld->figurAufstellen( anzSpieler );
F fig[anzSpieler].pos = startfeld;
cout << anzSpieler + 1 << ".ter Spieler ist " << sp->getName() << '\
n';
G
H anzSpieler++;
return fig+anzSpieler-1;
}
Der Spieler, der sich anmeldet, übergibt der anmeldung einen Zeiger auf sich selbst (A).
Das Spiel nimmt Anmeldungen nur so lange entgegen, bis die maximale Spieleran-
zahl erreicht ist (B). Bei erfolgreicher Anmeldung wird der Zeiger auf den neu ange-
meldeten Spieler zur späteren Verwendung gespeichert (C), und die Nummer, die der
Spieler vom Spiel zugeteilt bekommen hat, wird in der Klasse spieler gesetzt (spiel
ist ja ein friend von spieler, daher ist dieser Zugriff möglich) (D). Danach wird die
848
22.3 Beispiele
Figur des neuen Spielers auf dem Startfeld aufgestellt (E), und das Startfeld wird als
aktuelles Feld seiner Figur gesetzt (F). Schließlich wird nur noch die Zahl der angemel-
deten Spieler erhöht (G) und ein Zeiger auf die Figur des neuen Spielers zurückgege-
ben (H). Aufgrund der Zeigerarithmetik entspricht der dort verwendete Code dieser
Darstellung:
Damit sind jetzt alle Vorbereitungen getroffen, um eine Spielrunde auch durchfüh-
ren zu können:
int spiel::spielrunde()
{
int sp;
A for( sp = 0; sp < anzSpieler; sp++ )
{
B if( !fig[sp].pos->blockiert( sp ) )
{
C player[sp]->zug();
D if( fig[sp].pos->getTyp() == 'Z' )
{
cout << '\n';
E return 0;
}
}
}
cout << '\n';
return 1;
}
Da das Verhalten in den einzelnen Klassen implementiert ist, muss in einer Spiel-
runde für jede Figur (A) lediglich überprüft werden, ob die Figur blockiert ist (B) oder
ziehen darf. Darf sie ziehen, wird der Zug ausgeführt (C) und geprüft, ob das erreichte
Feld das Zielfeld ist (D). Ist das der Fall, wird die aktuelle Spielrunde abgebrochen (E).
Zur Durchführung einer ganzen Partie müssen in der entsprechenden Methode nur
noch die Spielrunden abgearbeitet werden:
void spiel::partie()
{
do {
849
22 Vererbung
A spielstand();
B } while( spielrunde() );
C spielstand();
}
Die Partie besteht aus einer Abfolge von Spielrunden. Vor der Ausführung der Spiel-
runde wird mit spielstand der aktuelle Spielplan mit den Positionen der Figuren aus-
gegeben (A). Die Schleife läuft so lange, bis eine Spielrunde einen Sieger ermittelt hat
(B). Abschließend wird noch einmal der Spielplan ausgegeben, um den Endstand dar-
zustellen (C).
Die Ausgabe des Spielstands erfolgt mit einer einfachen Repräsentation des Spielfel-
des der Positionen der jeweiligen Spielfigur:
A C B D
2-mal aus- 3 Felder
Start setzen zurück
S..W..-..+....W......W......-Z
-A----------------------------
2-mal aus-
setzen
3 Felder
vor ----B-------------------------
3 Felder
---C--------------------------
Ziel zurück
-----D------------------------
2-mal aus-
setzen
void spiel::spielstand()
{
feld *fld;
int fg;
A for( fld = startfeld; fld; fld = fld->getNext() )
B cout << fld->getTyp();
cout << '\n';
850
22.3 Beispiele
Dazu wird zuerst der Spielplan mit dem Typ aller Spielfelder ausgegeben, indem das
gesamte Spielfeld in einer Schleife abgefahren und ausgegeben wird (A).
S..W..-..+....W......W......-Z
Nun können nacheinander für alle Spieler (C) alle Felder durchgegangen werden (D).
Befindet sich der Spieler auf dem aktuell betrachteten Feld, wird sein Initial ausgege-
ben (E), ansonsten wird '-' als Platzhalter gedruckt (F).
Die Bereitstellung des Konstruktors der Klasse spieler hatte ich bis nach Erstellung
der Anmeldung vertagt. Da diese nun vorhanden ist, erfolgt jetzt die Implementie-
rung:
Der Konstruktor kopiert den Namen in das private Array (A). Der Zeiger auf das Spiel,
an dem der Spieler teilnimmt, wird als Verbindung zum Spiel gespeichert (B) und der
Spieler damit am Spiel angemeldet (C). Die Anmeldung liefert den Zeiger auf seine
Spielfigur zurück. Der Zeiger wird ebenfalls bei den privaten Attributen gespeichert.
Abschließend fehlt nun nur noch die Methode zug, um einen einzelnen Zug für den
Spieler auszuführen:
851
22 Vererbung
void spieler::zug()
{
int wurf;
A wurf = game->w.wurf();
B cout << *name << '=' << wurf << ' ';
C fig->ziehen( wurf );
}
Auch diese Methode profitiert von dem bereits vorhandenen Umfeld. Zum Würfeln
wird der Würfel vom Spiel genommen und geworfen (A). Die ermittelte Augenzahl
wird ausgegeben (B) und der Figur dann der Auftrag erteilt, sich die gewürfelte
Anzahl von Feldern weiterzubewegen (C).
Das Hauptprogramm instanziiert die vier Spieler und startet die Partie:
int main()
{
A spiel sp( 1234 );
return 0;
}
Das Programm instanziiert das Spiel mit Übergabe eines Wertes zur Initialisierung
des Zufallszahlengenerators (A). Das Spielfeld steht damit bereits komplett zur Verfü-
gung. Im Folgenden werden dann die einzelnen Spieler hinzugefügt. Dabei geben Sie
ihren Namen und die Adresse der Instanz des Spiels, an dem Sie teilnehmen wollen,
an (B). Wenn alle Spieler angemeldet sind, bleibt nur noch, die Partie zu starten und
den Ablauf zu betrachten.
852
22.3 Beispiele
S..W..-..+....W......W......-Z
Ausgabe aller Spieler A-----------------------------
B-----------------------------
C-----------------------------
Alle Spieler auf dem Startfeld D-----------------------------
class feld
{
private:
feld *nxt;
feld *prv;
853
22 Vererbung
protected:
int besetzt[4];
A virtual feld *step( int fig, int steps );
public:
feld( feld *ende );
feld* getNext() { return nxt; }
B virtual char getTyp() { return '.'; }
feld * figurSetzen( int fi, int wurf );
C virtual int blockiert( int fig ) { return 0; }
};
Um das Resultat ohne diese Kennzeichnung zu betrachten, entferne ich jetzt das
Schlüsselwort virtual bei diesen drei Methoden. Auf den ersten Blick hat das keiner-
lei Auswirkungen. Das Programm lässt sich auch danach weiterhin problemlos über-
setzen. Wenn ich das Programm nun aber starte, erhalte ich eine deutlich geänderte
Ausgabe:
..............................
A-----------------------------
B-----------------------------
C-----------------------------
D-----------------------------
Die Ausgabe für das Spielfeld verwendet die Rückgabewerte von getTyp. Hier taucht
jetzt nur noch der Punkt '.' des gewöhnlichen Feldes auf. Auch die weiteren Ausga-
ben zeigen, dass jetzt alle Felder wie gewöhnliche Felder behandelt werden. Die Fel-
der sind weiter korrekt als Start-, Warte- oder Sprungfelder instanziiert worden. Im
Laufe des Spiels werden sie dann aber als gewöhnliche Felder angesprochen, da der
Spielplan als verkettete Liste die Zeiger gewöhnlicher Felder verwaltet. Die Speziali-
sierung der anderen Felder wird jetzt zur Laufzeit nicht mehr erkannt.
854
22.3 Beispiele
step()
dynamisches Binden
Ohne die virtuellen Methoden verhalten sich jetzt alle Felder hier wie gewöhnliche
Felder, insbesondere wird das Zielfeld nicht mehr als solches erkannt, und es kann
kein Gewinner ermittelt werden. Nur mit der Verwendung virtueller Funktionen
wird zur Laufzeit geprüft, um welchen konkreten Feldtyp es sich im Einzelnen han-
delt und welche Methoden tatsächlich aufgerufen werden sollen.
22.3.2 Partnervermittlung
Als weiteres Beispiel wollen wir uns dem delikaten Problem der Partnervermittlung
zuwenden und einen entsprechenden Dienst betrachten, der seinen Kunden eine
Vermittlung anbietet.
Auch hier werden wir zunächst die beteiligten Klassen identifizieren und ein Klas-
sendiagramm erstellen. Im Zentrum unserer Betrachtung stehen die Partnervermitt-
lung und deren Kunden. Die Kunden suchen Partner, die Agentur ist bestrebt, Paare
zusammenzubringen.
22
Das Design
Es ergeben sich direkt die beiden ersten Klassen:
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() antrag()
855
22 Vererbung
In der gezeigten UML-Darstellung hat die Klasse Kunde einen Bezug auf sich selbst.
Dieser Bezug ergibt sich aus der späteren Verwendung zur Laufzeit. Dort ist der Part-
ner eines Kunden eine andere Instanz der gleichen Klasse Kunde.
Wie sieht das Zusammenspiel zwischen der Partnervermittlung und den Kunden
nun genauer aus?
Die Partnervermittlung hat eine Kartei mit Kunden. Diese Kunden werden in der
Agentur in Männer und Frauen unterschieden. Ein Kunde sucht eine Partnerschaft
zu einem anderen Partnersuchenden und hat gegebenenfalls bereits einen anderen
Partner gefunden.
Die Dynamik im Verhältnis von Partnervermittlung und Kunde und auch der Kun-
den untereinander beschreiben wir durch Methoden und Botschaften.
Der Ablauf der Vermittlung und der Partnersuche wird über Botschaften zwischen
den Objekten gesteuert und läuft wie folgt ab:
Die Agentur schickt einem Kunden ein Angebot, das Informationen über einen ande-
ren Kunden enthält. Der Empfänger des Angebots prüft, ob der angebotene Partner
seinen Vorstellungen entspricht und gegebenenfalls seinem derzeitigen Partner vor-
zuziehen ist.
Ist das der Fall, macht der Empfänger dem von der Agentur angebotenen Partner
einen Antrag.
856
22.3 Beispiele
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung
Hier zeigt die Klasse Kunde wieder zweimal einen Selbstbezug. Die versendeten Bot-
schaften richten sich dabei jeweils an eine andere Instanz derselben Klasse Kunde.
Ein Kunde hat bisher keine besonderen Merkmale, außer dass er gegebenenfalls
einen anderen Kunden als Partner hat. Wir geben ihm jetzt weitere Eigenschaften,
indem wir ihn zu einer Person machen:
Person
name
geschlecht
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot
22
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung
Eine Person hat einen Namen und ein Geschlecht. Letzteres ist bei der Partnersuche
natürlich wichtig. Der Kunde ist eine Person und erbt damit diese Eigenschaften.
Die beiden Eigenschaften Name und Geschlecht reichen natürlich noch nicht aus,
damit sich ein Kunde ein Bild von seinem möglichen Partner machen kann. Er
857
22 Vererbung
benötigt dazu weitere Informationen, nämlich ein Profil des Partners. Wir
beschränken uns für das Profil auf die Merkmale Größe, Alter und Vermögen.
Person
name
geschlecht
Profil
alter
groesse
vermoegen
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung
Abbildung 22.23 Die Verwendung des Profils bei »Person« und »Kunde«
Das Profil kommt in unserem Modell in zweierlei Bedeutung vor. Zum einen hat jede
person ein Eigenprofil, und zum anderen hat jeder kunde ein Wunschprofil (Partner-
profil) seines zukünftigen Partners. Das Modell habe ich bereits in diesem Sinne
ergänzt.
Die Partnerwahl besteht damit im Wesentlichen aus einem Abgleich des Wunschpro-
fils mit dem Eigenprofil eines möglichen Partners.
Nun gibt es sicherlich unterschiedliche Typen von Kunden. Wir werden im Weiteren
drei Spezialisierungen implementieren:
Die drei Kundentypen werden bei der Partnersuche verschiedene Kriterien anlegen,
die Sie später noch kennenlernen.
Wir übernehmen diese Spezialisierungen der Klasse kunde in unser Design und erhal-
ten damit folgendes Diagramm (siehe Abbildung 22.24).
858
22.3 Beispiele
Person
name
geschlecht
Profil
alter
groesse
vermoegen
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung
class profil {
A public:
int alter;
double groesse;
double vermoegen; 22
B profil( int a = 0, double gr = 0, double v = 0 )
C { set( a, gr, v ); }
D void set( int a, double gr, double v )
{ alter = a; groesse = gr; vermoegen = v; }
};
In der Klasse profil sind alle Attribute und Methoden öffentlich deklariert worden
(A), um das Beispiel übersichtlicher zu halten3.
3 Generell sollten Klassen nicht so offen gestaltet werden, falls es nicht notwendig ist. Wenn ein
Lesezugriff von außen erfolgen muss, sollte das durch entsprechende Getter auf private Attri-
bute realisiert werden.
859
22 Vererbung
Zur Instanziierung hat profil einen Konstruktor mit Default-Werten für die einzel-
nen Parameter (B). Das erlaubt später die Konstruktion mit fehlenden Werten. Inner-
halb des inline implementierten Konstruktors werden die Daten über eine set-
Methode gesetzt. Die Methode werden wir auch noch an anderer Stelle verwenden,
um die Werte eines Profils zu modifizieren (C).
Die bereits erwähnte Methode set dient dazu, Profile bequem ändern zu können (D).
Jetzt ist es möglich, Profile zu erstellen und zu aktualisieren. Die Kunden sollen
anhand ihres Partnerprofils und des Eigenprofils eines anderen Kunden ermitteln
können, wie groß die gegenseitige Übereinstimmung ist. Dazu muss die »Abwei-
chung« zweier Profile voneinander messbar gemacht werden.
Aus der Mathematik wissen Sie, dass man die relative Abweichung einer Zahl b von
einer Zahl a durch die Formel
a–b
------------
a
messen kann, zumindest solange a nicht 0 ist.
Diese Abweichung lassen wir durch eine kleine Hilfsfunktion berechnen:
Der Betrag wird über die Funktion fabs berechnet, eine Funktion zur Ermittlung des
Absolutwertes aus der Standardbibliothek (A).
Aufbauend auf dieser Hilfsfunktion, lässt sich jetzt die Abweichung zweier Profile
berechnen:
860
22.3 Beispiele
Die Funktion hat zwar den gleichen Funktionsnamen wie die Hilfsfunktion, durch
die unterschiedliche Parametersignatur (hier zwei Referenzen auf Profile) ist dies in
C++ aber problemlos möglich.
Innerhalb der Funktion wird die relative Abweichung von Alter und Größe bestimmt
und addiert. Beim Vermögen wird die relative Abweichung zwischen gewünschtem
und tatsächlichem Profil nur berücksichtigt, wenn das Vermögen kleiner ist, als
gewünscht. Damit wird abgebildet, dass sich vermutlich alle damit arrangieren kön-
nen, wenn der potenzielle Partner vermögender ist, als erhofft.
Als Nächstes erstelle ich die Klasse person. Sie ist die Basisklasse für alle Kunden,
seien es nun bescheidene, anspruchsvolle oder auch betrügerische:
Person
name Profil
geschlecht
alter
groesse
vermoegen
class person
{
private:
A char name[20];
B char geschlecht;
public:
C profil eigenprofil;
person( char * n, char g, int a, double gr, double v ); 22
char *getName() { return name; }
char getGeschlecht() { return geschlecht; }
};
Die Person speichert die Daten zu ihrem Namen und Geschlecht im privaten Bereich
(A und B). Die Speicherung des Geschlechts erfolgt als char entweder als 'm' oder 'w'.
Das Eigenprofil wird dagegen im öffentlichen Bereich abgelegt und ist von überall
zugreifbar (C), damit potenzielle Interessenten es mit ihrem Wunschprofil abglei-
chen können.
861
22 Vererbung
Im Konstruktor werden die Parameter für Name und Geschlecht sowie Alter, Größe
und Vermögen übergeben. Name und Geschlecht werden in die Attribute der Person
kopiert (B, C), die Daten zu Alter, Größe und Vermögen werden in der Initialisierer-
liste an das Eigenprofil weitergereicht (A).
Bisher hat eine Person noch kein bestimmtes Verhalten, außer, dass sie über Namen
und Geschlecht informieren kann. Das konkrete Verhalten ergibt sich erst, wenn wir
die Person zu einem Kunden verfeinern. In der Klasse kunde finden wir einen Großteil
der Funktionalität unseres Programms. Das Klassen-Design gibt dazu bereits Hinweise:
Person
name
geschlecht
1 Partner
angebot() 1
trennung()
antrag()
Trennung
Wir werden die Verfeinerungen nun in der von person abgeleiteten Klasse kunde
umsetzen:
862
22.3 Beispiele
C double abw;
void neuerPartner( kunde *k );
virtual int akzeptiert( kunde *k );
virtual int verbesserung( kunde *k );
public:
D profil partnerprofil;
kunde( char * n, char g, int a, double gr, double v );
void trennung();
int antrag( kunde *k );
virtual int angebot( kunde *k );
};
Die Klasse kunde deklariert einen später noch umzusetzenden Ausgabe-Operator als
friend der Klasse (A).
Im protected-Bereich der Klasse sind die Elemente umgesetzt, die von außen unzu-
gänglich bleiben, für Kinder der Klasse aber erreichbar sein sollen. Insbesondere ist
das ein Zeiger auf einen anderen Kunden als Partner (B). Verpartnerte Kunden ver-
weisen gegenseitig aufeinander. Ein Wert 0 für diesen Zeiger bedeutet, dass es keinen
Partner gibt. Zusätzlich speichert der Kunde die Abweichung des aktuellen Partners
zum gewünschten Partnerprofil (C). Über die Methoden im geschützten Bereich
erfolgt die interne Steuerung der Klasse.
Alle Methoden, die in abgeleiteten Klassen angepasst werden, sind in der Basisklasse
kunde bereits als virtual gekennzeichnet. Dabei handelt es sich um die Methoden 22
akzeptiert, verbesserung und angebot.
Ein Kunde ist eine Person und enthält ein Profil. Bei der Instanziierung müssen wir
daher die Basisklasse person und das enthaltene Objekt partnerprofil korrekt initia-
lisieren. Der Ablauf ist in Abbildung 22.27 dargestellt. Im Code sieht das so aus:
863
22 Vererbung
Aufrufparameter des n =
Konstruktors der "Hans" Von person an
An person
Klasse kunde mit g = 'm' eigenprofil über-
a = 36 durchgereichte
allen Parametern gebene Parameter
gr = 1.73 Parameter
v = 50000
Bei Aufruf des Konstruktors der Klasse kunde wird ein Teil der Daten an den Kon-
struktor der Klasse person weitergeleitet (A). Aus der Klasse person wird der Konstruk-
tor des Eigenprofils aufgerufen. Das Partnerprofil wird mit dem parameterlosen
Konstruktor initiiert (B) und bleibt ohne Werte. Bei seiner Erstellung hat der Kunde
noch keinen Partner, der Partnerverweis wird daher auf 0 gesetzt (C).
Damit ist der Kunde vollständig implementiert, und die eigentliche Partnerwahl
kann umgesetzt werden. Dabei prüft ein Kunde immer zuerst, ob ein vorgeschlage-
ner Partner prinzipiell infrage kommt und akzeptiert werden kann:
Mit der Methode verbesserung prüft ein Kunde, ob die Entscheidung zur Bindung mit
einem angebotenen Kunden zu einer Verbesserung seiner Situation führen würde.
Auch diese Methode ist in kunde bereits als virtual deklariert.
864
22.3 Beispiele
Die Art der Prüfung führt dazu, dass ein Kunde eine Vermittlung ablehnt, wenn das
Profil des angebotenen Partners zu stark vom Wunschprofil abweicht. Dies gilt selbst
dann, wenn er noch gar keinen Partner hat.
Wenn ein Kunde eine mögliche Verbesserung feststellt, will er natürlich auch eine
(neue) Bindung eingehen. Die Klasse kunde erstellt mit der Methode neuerPartner die
Verbindung. Das beinhaltet auch die Trennung von einem eventuell bereits vorhan-
denen Partner:
C partner = k;
abw = abweichung( partnerprofil, k->eigenprofil );
}
Wenn ein Kunde einen neuen Partner annehmen möchte und bereits einen Partner
hat (A), schickt er diesem die Botschaft, dass er eine Trennung vollzieht (B). Die
865
22 Vererbung
Methode hat keinen Rückgabewert. Der Entpartnerte hat keine Möglichkeit zur Reak-
tion, er kann die Trennung nur zur Kenntnis nehmen.
In der Methode wird der neue Partner gespeichert (C), und die jetzt vorhandene
Abweichung zum Wunschprofil wird festgehalten (D).
void kunde::trennung()
{
A cout << "Trennung: " << getName() << " >< " << partner->getName()
<< '\n';
B partner = 0;
}
Wie schon beschrieben, hat der von einer Trennung Betroffene keine Möglichkeit zu
reagieren, außer die Trennung bekannt zu geben (A) und die Verbindung zum bishe-
rigen Partner auf seiner Seite ebenfalls zu löschen (B).
Über die öffentliche Methode antrag nimmt ein Kunde den Antrag eines anderen
partnersuchenden Kunden entgegen:
Auch der Antragsempfänger prüft zuerst, ob sich seine Situation mit dem neuen Part-
ner verbessern würde (A). Ist das nicht der Fall, lehnt er den Antrag ab (B). Ansonsten
hält er den neuen Partner direkt fest (C), womit er sich von einem gegebenenfalls vor-
handenen Partner trennt, und nimmt den Antrag an (D). Er vertraut dabei darauf, dass
der Antragssteller ihn auch nimmt. Dieser hat vor seinem Antrag ja auch bereits fest-
gestellt, dass der potenzielle neue Partner eine Verbesserung darstellt.
866
22.3 Beispiele
Nachrichten sendet. Um solche Nachrichten zu erhalten, hat die Klasse kunde die
Methode angebot:
B if( !verbesserung( k ) )
return 0;
D neuerPartner( k );
Wird einem Kunden ein Angebot unterbreitet, erfolgt zuerst einmal dessen Ausgabe
(A), damit wir das Werben später besser verfolgen können.
Wenn mit dem neuen Partner keine Verbesserung erzielt wird (B), wird das Angebot
abgewiesen. Lehnt der neue Partner den Antrag ab, kommt das Angebot ebenfalls
nicht infrage (C). Ansonsten wird der angebotene Kunde zum neuen Partner
gemacht (D), und die Nachricht wird verkündet (E).
Die Klasse kunde ist nun fast vollständig implementiert, wir müssen nun nur noch
den Ausgabe-Operator erstellen, den wir in dieser Methode schon verwendet haben.
22
Um einen Kunden möglichst einfach auf dem Bildschirm auszugeben, implementie-
ren wir noch die Überladung des Ausgabe-Operators, die wir in der Methode angebot
schon verwendet haben. Der Operator ist in der Klasse kunde als friend deklariert,
daher kann er auf alle Elemente der Klasse zugreifen:
867
22 Vererbung
C else
os << '-';
os << '\n';
return os;
}
Die Ausgabe ist unkompliziert. In allen Fällen werden zuerst der Namen des Kunden
und ein " == " ausgegeben (A). Hat der Kunde einen Partner, wird im Anschluss des-
sen Name angedruckt (B), ansonsten ein '-' (C).
Die Ausgabe für den Kunden Anton mit Partner Berta sieht dann so aus:
Anton == Berta
Anton == -
Ein Blick auf das Klassen-Design zeigt uns, dass die Implementierung der Partnerver-
mittlung selbst noch offen ist:
Person
name
geschlecht
anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung
868
22.3 Beispiele
class partnervermittlung
{
private:
A kunde *frauen[100];
B int anzahlF;
kunde *maenner[100];
int anzahlM;
public:
partnervermittlung();
void anmeldung( kunde *k );
void vermittlung();
void ergebnis();
};
Dafür hat sie im privaten Bereich den Karteikasten für die weiblichen Kunden (A) und
deren verwaltete Anzahl (B) und das Gleiche für die Männer.
Neben dem Konstruktor sind nur die öffentlichen Methoden für die Neuanmeldung
eines Kunden, den Start der Vermittlung selbst und die Ausgabe des aktuellen Ver-
mittlungsstands für alle Kunden enthalten.
Der Konstruktor und die Neuanmeldung eines Kunden gestalten sich einfach:
partnervermittlung::partnervermittlung()
{
anzahlF = 0;
anzahlM = 0;
}
22
Listing 22.69 Konstruktor der »partnervermittlung«
Im Konstruktor wird die Anzahl der vorhandenen Karteikarten für Frauen und Män-
ner auf 0 gesetzt.
869
22 Vererbung
Bei der Neuanmeldung eines Kunden wird ein männlicher Kunde der Kartei der
Männer hinzugefügt (A), ein weiblicher Kunde wird der Kartei der Frauen hinzuge-
fügt (B). In beiden Fällen erfolgt das Inkrementieren des Zählers mit dem Postfix-
Operator in Verbindung mit der Zuweisung.
Wir verzichten in unserem Beispiel auf die Prüfung der Anzahl der bereits vorhande-
nen Kunden im jeweiligen Karteikasten, die sonst eventuell zu einer Ablehnung
eines neuen Kunden oder zur Erweiterung eines vollen Karteikastens führen müsste.
void partnervermittlung::ergebnis()
{
int i;
A cout << "\nFrauen:\n";
Zur Ausgabe geht die Partnervermittlung nur ihre Kartei durch. Für jedes Geschlecht
startet die Ausgabe mit einer Überschrift (A, C), gefolgt von der Ausgabe aller Perso-
nen in der jeweiligen Kartei mit ihrem zugehörigen Partner, über den Ausgabe-Ope-
rator der Klasse kunde (B, D).
Bei fünf registrierten Kunden sieht die Ausgabe vor Beginn der Vermittlung dann
z. B. so aus:
Frauen:
Berta == -
Doris == -
870
22.3 Beispiele
Maenner:
Anton == -
Claus == -
Ernst == -
Auch bei der der Durchführung der eigentlichen Partnervermittlung macht es sich
die Agentur sehr einfach:
void partnervermittlung::vermittlung()
{
int i, j;
Die Agentur prüft die Profile und Wünsche der Kunden gar nicht, sondern bietet ein-
fach der Reihe nach allen Männern alle Frauen an (A) und umgekehrt (B). Die eigent-
liche Arbeit erledigen dann die Kunden mit ihren implementierten Methoden selbst. 22
Generell ist die Partnervermittlung nun arbeitsfähig. Nach Erstellung eines Haupt-
programms, das Kunden instanziiert, könnte die Vermittlung starten. Allerdings
haben aktuell alle Kunden die gleiche Strategie bei der Auswahl ihres Partners. Bevor
wir die Vermittlung beginnen, wollen wir daher noch besondere Kundentypen wie
den anspruchsvollen Kunden, den bescheidenen Kunden und den Heiratsschwindler
hinzufügen.
871
22 Vererbung
Partnervermittlung 1 n
Kunde
frauen
maenner Angebot
anmeldung() angebot()
vermittlung() trennung()
ergebnis() antrag() Hinzugefügte Klassen
Der anspruchsvolle Kunde verhält sich im Konstruktor nicht anders als ein gewöhn-
licher Kunde:
Der anspruchsvolle Kunde ist ein Kunde, seine Klasse leitet sich von der Basisklasse
kunde ab (A). Die Parameter seines Konstruktors werden komplett an die Basisklasse
weitergeleitet (B), die die Initialisierung vornimmt. Die Klasse hat keine eigenen
Daten zur Initialisierung, der eigentliche Konstruktor enthält keinen Code.
Auch wenn der anspruchsvolle Kunde keine eigenen Attribute hat, ist die Methode
angepasst, die über die Akzeptanz eines vorgeschlagenen Partners entscheidet. Diese
Methode ist in der Basisklasse bereits als virtual deklariert und sieht nun so aus:
872
22.3 Beispiele
Bei der Akzeptanz eines vorgeschlagenen Partners ist der anspruchsvolle Kunde
wählerischer. Während ein gewöhnlicher Kunde bei einem Vorschlag eine Abwei-
chung von weniger als 25 % zu seinem Wunschprofil akzeptiert, muss für den
anspruchsvollen Kunden die Abweichung unter 10 % liegen (A).
Der bescheidene Kunde akzeptiert jedes Angebot – egal, welches Profil der Partner
hat, er schaut sich das Angebot nicht einmal an:
Die Methode akzeptiert gibt ohne Prüfung eine 1 zurück (A). Der Konstruktor gibt
auch hier nur die Parameter weiter und hat keinen eigenen Code.
Anders als die anderen Kunden hat der Heiratsschwindler ein ganz eigenes Vorgehen
bei der Akzeptanz, der Beurteilung einer Verbesserung und dem Umgang mit den
Angeboten der Agentur:
Auch diese Klasse ist eine Kindklasse von kunde (A), und der Konstruktor ist wie bei
den anderen Kindklassen implementiert (D). Der Heiratsschwindler hat allerdings
eigene Methoden akzeptiert, verbesserung und angebot (B, C, E). Die Methoden sind
auch in der Basisklasse als virtual deklariert.
873
22 Vererbung
Die Strategie des Heiratsschwindlers zur Akzeptanz eines Angebots ist sehr einfach,
er schaut nur auf das Geld und akzeptiert ausschließlich Partner mit einem Vermö-
gen von mindestens 50000 EUR (A). Alle anderen Profileigenschaften sind ihm egal.
Da der Heiratsschwindler nur am Geld interessiert ist, stellt für ihn jeder Partner mit
einem höheren Vermögen als sein bisheriger Partner eine Verbesserung dar. Die
Methode zur Ermittlung einer Verbesserung hat er daher entsprechend angepasst:
Die ersten Prüfungen (A und B) sind wie bei anderen Kunden auch. Danach verfolgt
er aber seine einfache Strategie. Hat der vorgeschlagene Partner ein höheres Vermö-
gen als der aktuelle, ist das für ihn immer eine Verbesserung (C).
Bis hierhin kann man das Verhalten des Heiratsschwindlers noch akzeptabel nen-
nen. Auf das Vermögen des zukünftigen Partners zu schielen ist ja kein Verbrechen,
wenn auch etwas eindimensional. Die kriminellen Absichten zeigen sich aber darin,
wie der Heiratsschwindler mit den Angeboten an ihn selbst umgeht.
Wenn der Heiratsschwindler ein Angebot erhält, dann verstellt er sich und passt
seine Angaben zu Alter und Vermögen an die Wünsche des suchenden Partners an
und erhofft sich damit bessere Chancen:
874
22.3 Beispiele
C return kunde::angebot(k );
}
Der Heiratsschwindler liest Alter (A) und Vermögen (B) aus dem Wunschprofil des
suchenden Partners aus und übernimmt die Werte in das eigene Profil. Der Heirats-
schwindler lügt nur bei Alter und Vermögen, bei der Größe sagt er die Wahrheit, ver-
mutlich aus Sorge, dass ein Betrug hier zu offensichtlich wäre.
Nach der Verstellung verläuft die weitere Prüfung des Angebots wie bei anderen Kun-
den auch. Er kann die Methode angebot der Basisklasse verwenden wie die anderen
Kunden auch (C). Durch seine eigene und modifizierte Methode akzeptiert ist dafür
gesorgt, dass nur die wohlhabenden Kunden berücksichtigt werden.
Das Hauptprogramm
Jetzt sind alle Elemente beisammen, im Hauptprogramm müssen nun nur noch die
notwendigen Objekte instanziiert und der Vermittlungsprozess gestartet werden:
void main()
{
A partnervermittlung pv;
875
22 Vererbung
H pv.ergebnis();
I pv.vermittlung();
J pv.ergebnis();
}
Zu Beginn wird die Partnervermittlung selbst instanziiert (A). Der erste gewöhnliche
Kunden »Anton« wird erstellt (B), und sein Partnerprofil wird gesetzt (C). Direkt dar-
auf erfolgt seine Anmeldung bei der Vermittlung (D). Als Nächstes meldet sich
»Berta« als gewöhnlicher Kunde an. Jetzt tritt der Heiratsschwindler »Claus« auf den
Plan. Er wird mit seinem Partnerprofil erstellt und angemeldet (F).
Anschließend werden noch weitere Kunden erstellt und hinzugefügt (G). Bevor nun
die eigentliche Vermittlung beginnt, wird der initiale Vermittlungsstand ausgegeben
(H), und die eigentliche Vermittlung startet und wird protokolliert (I). Nach deren
Abschluss muss nur noch der endgültige Stand nach der Vermittlung ausgegeben
werden (J).
Frauen:
Berta == -
Doris == -
Maenner:
Anton == -
Claus == -
Ernst == -
Die Ausgabe beginnt mit der Darstellung der Vermittlungssituation. In unserem Sys-
tem sind zwei Frauen und drei Männer registriert. Alle sind zum Start der Vermitt-
lung ohne Partner.
Nun beginnt die eigentliche Vermittlung, indem allen Männern alle Frauen als Part-
ner angeboten werden:
Die Vermittlung beginnt mit Anton und Berta, die sich auch prompt verpartnern.
Eine Verbindung zwischen Anton und Doris kommt nicht zustande, vermutlich ist
Doris für Anton zu alt.
876
22.3 Beispiele
Der Heiratsschwindler Claus greift ins Geschehen ein. Er ist an der vermögenden
Berta interessiert, denn 60000 EUR sind für ihn ein gutes Argument. Er hat Erfolg
und spannt sie Anton aus.
Wenn Heiratsschwindler Claus jetzt aber auf die 100000 Euro schwere Doris trifft,
verbindet er sich unter falschen Angaben mit ihr und lässt Berta fallen.
Jetzt ist der bescheidene Ernst an der Reihe. Er liiert sich mit Berta, die von Claus
abserviert worden war. Eine Verbindung zwischen Ernst und Doris kommt nicht
zustande, Doris lässt ihren »Traummann« Claus auch im Folgenden nicht mehr los.
Die erste Runde der Vermittlung ist abgeschlossen, nun werden allen Frauen alle
Männer angeboten:
Berta wird Anton vorgestellt und gibt für ihn dem bescheidenen Ernst den Laufpass.
Vermutlich ist Ernst für Berta nicht vermögend genug. Die weiteren Angebote
nimmt Berta nicht wahr.
Weitere Vermittlungsversuche bei Doris ergeben keine Änderungen mehr, sie bleibt
bei Claus.
Mit diesen letzten Angeboten ist die Vermittlung beendet, und es hat sich folgendes
Ergebnis eingestellt, das nun ausgegeben wird:
877
22 Vererbung
Frauen:
Berta == Anton
Doris == Claus
Maenner:
Anton == Berta
Claus == Doris
Ernst == -
Der Heiratsschwindler hat sich die reiche Dame geangelt, die als anspruchsvolle Kun-
din auf den Betrüger hereingefallen ist. Anton und Berta haben nach Wirrungen
zueinandergefunden, und der arme, aber anspruchslose Ernst geht leer aus. Ob hier
ein lebensnahes System entstanden ist, müssen Sie nun selbst entscheiden.
878
Kapitel 23
Zusammenfassung und Überblick
Das eben geschieht den Menschen, die in einem Irrgarten hastig
werden: Eben die Eile führt immer tiefer in die Irre.
– Seneca der Jüngere
Ich habe Ihnen in den bisherigen Kapiteln zu C++ die Eigenschaften von C++ an eini-
gen möglichst kontinuierlichen Beispielen demonstriert. Das führt dazu, dass wich-
tige Aspekte zu einzelnen Themen dort nicht erläutert werden, da sie an dieser Stelle
den weiteren Fortgang des Beispiels stören würden. In diesem Kapitel greife ich
daher die Inhalte der letzten vier Kapitel noch einmal auf und ergänze hier noch feh-
lende Aspekte der C++-Programmierung. In diesem Kapitel können Sie Ihr Wissen
über die C++-Programmierung auffrischen oder vertiefen. Ich habe versucht, die ein-
zelnen Abschnitte dieses Kapitels weiter unabhängig voneinander lesbar zu gestal-
ten. Generell wird hier aber vorausgesetzt, dass Sie die Kapitel zur C++-
Programmierung bereits durchgearbeitet haben.
Anders als die Zusammenfassung für die C-Programmierung ist dieses Kapitel the-
matisch sortiert.
class punkt
{
//
// Inhalt der Klassendeklaration ...
//
};
1 Für die Namen von Klassen gelten die bereits bekannten Regeln für Identifier, siehe Kapitel 18,
Abschnitt »Identifier«.
879
23 Zusammenfassung und Überblick
Wie bei einer Datenstruktur handelt es sich hier zunächst nur um die Deklaration
eines neuen Datentyps. Konkrete Ausprägungen dieses neuen Datentyps werden
erst gebildet, wenn eine Instanz der Klasse erzeugt wird. Dazu wird eine Variable des
entsprechenden Datentyps erzeugt, z. B. so:
punkt p;
Hier wird die Instanz p der Klasse punkt erzeugt. Weitere Beispiele zur sogenannten
Instanziierung finden Sie in Abschnitt 23.5.4, »Instanziierung von Objekten«.
Anders als bei Datenstrukturen kann eine Klasse den Zugriff auf ihre Datenelemente
durch sogenannte Zugriffsrechte beeinflussen. Nimmt man die Zugriffsrechte mit in
die Betrachtung, hat eine Klasse den folgenden Aufbau:
class punkt
{
private:
// Privater Bereich mit den privaten Membern
protected:
// Geschuetzter Bereich mit geschuetzten Membern
public:
// Oeffentlicher Bereich mit den oeffentlichen, also
// frei zugreifbaren Membern
};
Die Unterteilung in die drei Bereiche (private, protected und public) entspricht die-
sen Zugriffsrechten. Wie im ersten Beispiel, das keine der drei Zugriffsspezifikationen
enthält, müssen nicht alle der drei Bereiche auftreten. Jeder Bereich kann beliebig oft
auftauchen. Die Reihenfolge ist nicht relevant.
In jedem Bereich können Daten und Funktionen als sogenannte Member angelegt
sein. Member, die in keinem der Bereiche angelegt sind, werden automatisch dem
Bereich private zugeschlagen.
Die große Freiheit, die Elemente anzuordnen, sollten Sie nicht nutzen. Stattdessen
sollten Sie das oben verwendete Schema mit der Reihenfolge private, protected und
public verwenden und alle Member bewusst einem Bereich zuordnen. Das erleich-
tert die Lesbarkeit Ihres Codes.
880
23.2 Member
23.2 Member
Eine Klasse kann Daten und Funktionen enthalten. Die Daten repräsentieren den
Zustand, die Funktionen das Verhalten der Klasse. Wir fassen die Daten und Funktio-
nen unter der Bezeichnung Member zusammen. Die Datenmember werden auch
Attribute genannt, die Funktionsmember Methoden.
23.2.1 Datenmember
Innerhalb einer Klasse angelegte Daten werden als Datenmember oder Attribute
bezeichnet. Wie Datenfelder in Datenstrukturen haben sie einen Namen2 und einen
Datentyp. Sie repräsentieren den Zustand eines Objekts:
class punkt
{
private:
int x;
int y;
// ... weitere private Attribute
protected:
// ... weitere geschuetzte Attribute
public:
// ... weitere oeffentliche Attribute
};
Als Datenmember können alle Datentypen verwendet werden. Dies sind die elemen-
taren Datentypen (char, short, int, float etc.), aber auch die selbst definierten. Zu den
selbst definierten Datentypen gehören z. B. Datenstrukturen (struct), andere Klassen
23
(class) oder Aufzählungsdatentypen (enum). Zeiger und Arrays können ebenfalls
genutzt werden:
struct knoteninfo
{
// ...
};
2 Für die Namen von Datenmembern gelten die bereits bekannten Regeln für Identifier, siehe
Abschnitt 18.53.
881
23 Zusammenfassung und Überblick
class weg
{
};
class graphen
{
private:
knoteninfo member1; // eine eingelagerte Struktur
weg member2; // eine eingelagerte Klasse
weg * member3; // ein Zeiger auf eine Klasse
float member4[10]; // ein Array von Gleitkommazahlen
weg member5[20]; // ein Array von eingelagerten Klassen
};
Es ist möglich, innerhalb einer Klasse weitere Klassen und Datenstrukturen zu defi-
nieren. Solche Konstruktionen sind aber meist unnötig und stehen einem klaren
Klassen-Design oft entgegen. Sie werden daher auch selten verwendet, und ich werde
sie hier nicht weiter vertiefen.
Die Deklaration von Aufzählungstypen (enum) innerhalb einer Klasse wird allerdings
öfter genutzt:
class bachelorarbeit
{
// ...
public:
enum note { sehr_gut, gut, befriedigend, ausreichend, nicht_bestanden };
note gesamtnote;
};
23.2.2 Funktionsmember
Innerhalb einer Klasse angelegte Funktionen werden als Funktionsmember oder
auch Methoden bezeichnet. Durch die Funktionsmember unterscheiden sich Klas-
sen grundlegend von Datenstrukturen. Funktionsmember haben einen Namen und
eine exakt festgeschriebene Schnittstelle:
882
23.2 Member
class punkt
{
private:
int x;
int y;
// ... private Funktionsmember
protected:
// ... geschuetzte Funktionsmember
public:
int getX();
int getY();
void set( int xx, int yy );
// ... weitere oeffentliche Funktionsmember
};
Bei Implementierung innerhalb der Klasse wird der Funktionskörper direkt an die
Deklaration der Schnittstelle angefügt:
class punkt
{
private:
int x;
int y;
public:
A int getX() { return x; }
B int getY() { return y; } 23
void set( int xx, int yy );
};
Wird ein Funktionsmember außerhalb der Klasse definiert, sind die Vereinbarung
der Schnittstelle und die Implementierung der Funktion aufgeteilt. Die Schnittstelle
wird in der Klassendeklaration beschrieben. Die Implementierung erfolgt außerhalb.
883
23 Zusammenfassung und Überblick
Dazu wird der qualifizierte Name der zu implementierenden Funktion aus dem Klas-
sennamen vor »::« und dem Funktionsnamen gebildet. Die Schnittstelle selbst
bleibt unverändert:
class punkt
{
private:
int x;
int y;
public:
int getX() { return x; }
int getY() { return y; }
void set( int xx, int yy );
};
class punkt
{
private:
int x;
int y;
public:
int getX() { return x; }
int getY() { return y; }
int set( int xx = 0, int yy = 0 ) { x = xx; y = yy; }
};
884
23.2 Member
class punkt {
private:
int x;
int y;
public:
void set( int xx, int yy ) { x = xx; y = yy; }
void set() { x = 0; y = 0; }
};
class ultimate
{
public:
const int answer;
};
Das Datenmember answer in der Klasse ultimate ist jetzt konstant. Es kann, nachdem
es initial einen Wert erhalten hat, nicht mehr verändert werden. Die Initialisierung
erfolgt über den Konstruktor, der die Instanz erstellt. Die Initialisierung erfolgt über
die Initialisiererliste, mit der auch eingelagerte Objekte und Basisklassen initialisiert
werden. In unserem Beispiel könnte das so aussehen:
class ultimate
{
public:
const int answer;
ultimate (); // parameterloser Konstruktor 23
};
ultimate::ultimate() : answer( 42 )
{
}
Durch den Konstruktor wird jetzt das konstante Datenmember answer der Klasse
ultimate auf den Wert 42 initialisiert6.
6 Für die Schreibweise der Initialisierung siehe auch Abschnitt 23.5.5, »Explizite und implizite Ver-
wendung von Konstruktoren«.
885
23 Zusammenfassung und Überblick
Wenn die Klasse konstante Datenmember enthält, muss jeder Konstruktor diese
Member initialisieren, allerdings nicht notwendigerweise auf den gleichen Wert.
Funktionsmember können ebenfalls als konstant deklariert werden. Ihnen wird dazu
das Schlüsselwort const angefügt.
class ultimate
{
public:
int test;
void member()const;
};
Eine konstante Funktion kann nur lesend auf die Datenmember ihrer Klasse zugrei-
fen. Aus Sicht einer konstanten Funktion sind alle Datenmember konstant.
Um einer konstanten Funktion dennoch den schreibenden Zugriff auf ein Daten-
member zu erlauben, können Sie dieses Member als mutable (veränderlich) dekla-
rieren:
class ultimate
{
public:
mutable int test;
void member( ) const;
};
Mit dieser Ergänzung ist der schreibende Zugriff auf das als mutable deklarierte test
jetzt auch aus der konstanten Funktion möglich.
886
23.2 Member
class punkt
{
private:
int x;
int y;
public:
int getX() const { return x; }
int getY() const { return y; }
void set( int xx, int yy ) { x = xx; y = yy; }
void set() { x = 0; y = 0; }
punkt() { set(); }
};
void fkt()
{
const punkt p; // Instanziieren eines konstanten Objekts
können für die konstante Instanz p jetzt nur noch die als const deklarierten Metho-
den der Klasse aufgerufen werden. Bei der Verwendung nicht konstanter Methoden
verweigert der Compiler den Aufruf. Klassen sollten von Anfang an darauf ausgelegt
werden, dass sie mit Konstantheit korrekt umgehen. Der nachträgliche Einbau der
»Const Correctness« ist meist aufwendig, da sich Änderungen dann oft durch viele
Klassen fortpflanzen.
Wenn wir das Beispiel der Klasse punkt auf eine Darstellung am Bildschirm beziehen,
wären Informationen zur Bildschirmauflösung eine Eigenschaft, die für alle Punkte
gelten sollte und nicht für eine einzelne Instanz. Diese Daten sollen in diesem Bei-
spiel daher als statisch deklariert werden:
7 Die Bedeutung des Schlüsselwortes static unterscheidet sich hier deutlich von der in C, siehe
dazu die Abschnitte »static-Funktion« und »static-Variable« in Kapitel 18.
887
23 Zusammenfassung und Überblick
class punkt {
private:
int x;
int y;
public:
static int maxx;
static int maxy;
// ...
};
Statische Member nutzen den Namensraum und den Zugriffsschutz der Klasse, lie-
gen aber außerhalb der Instanzen der Klasse. Die Initialisierung muss daher auch von
außerhalb erfolgen:
Wenn ein statisches Datenmember zusätzlich auch als konstant deklariert ist, kann
es auch direkt in der Klasse initialisiert werden:
class punkt
{
private:
int x;
int y;
public:
static const int maxx = 1920; // Festlegung der Werte
static const int maxy = 1080; // in der Klasse
};
Die Möglichkeit, den Wert außerhalb der Klasse zu initialisieren, bleibt hier bestehen.
Der Wert der Konstanten kann aber nur an einer Stelle festgelegt werden.
Im folgenden Abschnitt erfahren Sie mehr über den Zugriff auf statische Daten-
member.
class punkt {
private:
int x;
int y;
888
23.2 Member
class punkt {
private:
int x;
int y;
static int maxx;
static int maxy;
public:
static void setMax( int mx, int my );
};
23.2.5 Operatoren
In C++ können Operatoren wie andere Funktionen auch überladen werden8. Eine
Möglichkeit besteht darin, den Operator als Methode der Klasse zu deklarieren. Wir
betrachten dazu den Operator + für die Klasse punkt:
23
class punkt
{
private:
int x;
int y;
public:
punkt( int xx = 0, int yy = 0 ) { x = xx; y = yy; }
punkt operator+( const punkt& p );
};
889
23 Zusammenfassung und Überblick
Bei dem Operator handelt es sich letztlich um ein Funktionsmember. Der Operator
kommt also immer mit einer konkreten Instanz zur Ausführung und kann folgen-
dermaßen implementiert werden:
res.x = x + p.x;
res.y = y + p.y;
return res;
}
punkt a, b, c;
a = b.operator+ ( c );
Die Methode wird für die Instanz b aufgerufen und erhält die Instanz c als Parameter
übergeben. Innerhalb des Operators wird ein neues Objekt res erzeugt, das als Ergeb-
nis zurückgegeben und a zugewiesen wird.
Die definierte Operator-Funktion hat noch die Besonderheit, dass sie auch in der
Operator-Schreibweise aufgerufen werden kann:
punkt a, b, c;
a = b + c;
Dieser Aufruf ist mit dem vorher dargestellten identisch. Auch hier wird das entspre-
chende Funktionsmember auf die Instanz b ausgeführt.
Die auf diese Weise überladenen Operatoren werden an alle abgeleiteten Klassen ver-
erbt. Die einzige Ausnahme bildet dabei der Zuweisungsoperator operator=.
Operatoren können als überladene Methoden auch komplett unabhängig von einer
Klasse definiert werden9. Da für Operatoren meist der Zugriff auf geschützte oder pri-
vate Datenmember der Klasse notwendig ist, werden entsprechende Operatoren
meist mit dem Schlüsselwort friend zum Freund der beteiligten Klassen erklärt10.
9 Die Operatoren für Zuweisung =, Funktionsaufruf (), Indizierung [] und Indirektzugriff -> kön-
nen nur, wie hier beschrieben, innerhalb der Klasse implementiert werden.
10 siehe auch Kapitel 23, Abschnitt »Member«, Stichwort, »Zugriff durchs friends«
890
23.2 Member
class punkt
{
};
punkt p;
Für instanziierte Klassen will man typischerweise auf deren Attribute und Methoden
zugreifen. Dazu wird wie bei Datenstrukturen der Operator für den Direktzugriff ».«
verwendet. Bei der Nutzung von Zeigern kommt der Operator für den Indirektzugriff
»->« zum Einsatz. Anders als bei Datenstrukturen kann der Zugriff bei Klassen aber
durch den zu vergebenden Zugriffsschutz eingeschränkt sein.
Für Betrachtungen zum Zugriff kann zwischen einem Zugriff von »innen« und von
»außen« unterschieden werden. Der Zugriff von »innen« findet z. B. statt, wenn aus
einer Memberfunktion der Klasse selbst auf ein Datenmember oder eine andere
Methode der Klasse zugegriffen wird. Ein Zugriff von außen liegt vor, wenn man die
Instanz aus der Perspektive einer Funktion betrachtet, in der die Instanz der Klasse
erzeugt worden ist. Dies entspricht der Verwendung, die Sie von Datenstrukturen her
kennen.
Die beiden Zugriffsarten unterscheiden sich dabei erheblich, daher werden wir sie
auch in zwei eigenen Abschnitten betrachten.
class fragment
{
// ...
public:
11 Das kennen Sie vom Verhalten einer Datenstruktur, die in C++ einer Klasse entspricht, die nur
Datenmember im öffentlichen Bereich hat.
891
23 Zusammenfassung und Überblick
int data;
void funktion( int param );
};
Wir können von außen auf Member einer Instanz von fragment zugreifen, die im
öffentlichen Bereich liegen. Dazu verwenden wir den ».«-Operator. Damit können
wir gleichermaßen Datenmember lesen und schreiben sowie Funktionsmember auf-
rufen:
fragment instanz;
Haben wir stattdessen einen Zeiger auf die Instanz, wird mit dem Indirektionsopera-
tor »->« zugegriffen:
fragment instanz;
fragment* p;
p = &instanz;
Die Zeiger können vor dem Zugriff natürlich auch dereferenziert werden, damit dann
wieder ein Direktzugriff möglich ist, auch wenn die Methode etwas umständlich ist:
fragment instanz;
fragment* p;
p = &instanz;
892
23.2 Member
class fragment
{
// ...
public:
static int sData;
static void sFunktion( int param );
};
werden verwendet, indem man den Namen der Klasse bei der Verwendung voran-
stellt:
fragment::sData = 0;
fragment::sFunktion( 1 );
Hier zeigt sich auch in der Notation, dass die statischen Member an die Klasse und
nicht an eine bestimmte Instanz gekoppelt sind. Das ist auch sinnvoll so, da statische
Elemente so auch verwendet werden können, wenn keine Instanz der Klasse exis-
tiert.
Damit sind öffentliche statische Member globale, allgemein zugängliche Daten oder
Funktionen, die im Namensraum der Klasse existieren und über diesen Namens-
raum angesprochen werden.
class bachelorarbeit
{
public:
enum note { sehr_gut, gut, befriedigend, ausreichend, nicht_bestanden };
note gesamtnote;
23
};
Hier wird ebenfalls über den Klassennamen und den Membernamen mit einem
dazwischengestellten »::« zugegriffen:
bachelorarbeit ba;
ba.gesamtnote = meineNote;
Klassen können auch andere Klassen als Datentypen für Datenmember verwenden.
Auf diese Art werden Klassen ineinander eingelagert:
893
23 Zusammenfassung und Überblick
class aaa
{
public:
int x;
};
class bbb
{
public:
aaa eingelagert;
aaa* zeiger;
};
Wie schon von Datenstrukturen bekannt, können hier die Zugriffe kaskadierend aus-
geführt werden:
bbb instanz;
bbb* p;
p = &instanz;
instanz.eingelagert.x = 123;
instanz.zeiger->x = 123;
p->eingelagert.x = 123;
p->zeiger->x = 123;
Abschließend sei noch einmal betont, dass Zugriff von außen ausschließlich auf
Member im mit public markierten öffentlichen Bereich der Klasse möglich ist.
Zugriffe von außen auf Member im Bereich protected und private weist der Compi-
ler mit einer Fehlermeldung ab.
Wenn wir uns im Innern eines Objekts befinden, können wir auf alle Member dieses
Objekts zugreifen – unabhängig davon, ob sie als public, protected oder private
deklariert sind.
Sie haben gesehen, dass Sie beim Zugriff von innen direkt auf alle Member zugreifen
können. Dazu muss nichts weiter spezifiziert werden, es müssen keine Instanz- oder
Klassennamen vorangestellt werden:
894
23.2 Member
class punkt
{
private:
int x;
int y;
public:
void set( int xx, int yy );
void set() { x = 0; y = 0; }
};
Es ist wichtig, dass die Zugriffsberechtigungen nicht nur für die Instanz gelten, für die
die Methode aufgerufen worden ist, sondern für alle Instanzen dieser Klasse.
Wenn ein Objekt Zugriff auf eine andere Instanz gleichen Typs erhält, kann es dort
ebenfalls uneingeschränkt zugreifen. Ich werde das illustrieren und das Beispiel pas-
send erweitern:
class punkt
{
private:
int x; 23
int y;
public:
void set( int xx, int yy );
void set(){ x = 0; y = 0; }
void set( punkt * p );
};
Dazu ergänze ich die Klasse um eine set-Funktion, die einen Zeiger auf einen ande-
ren Punkt erhält, um dessen Koordinaten zu kopieren. In der Implementierung wird
dabei ganz problemlos in den privaten Bereich der anderen Instanz, nämlich des
Objekts p, hineingegriffen:
895
23 Zusammenfassung und Überblick
Daran können Sie erkennen, dass der Zugriffsschutz auf Klassenebene arbeitet und
nicht auf Instanzebene. Das entspricht auch seiner Absicht. Der Zugriffsschutz soll
sicherstellen, dass alle Zugriffe auf eine Klasse die Konsistenz der Daten wahren. Es
darf davon ausgegangen werden, dass alle Punkte wissen, wie sie miteinander umzu-
gehen haben. Ein Zugriffsschutz auf Instanzebene wäre hier eher kontraproduktiv.
Wenn ein Klasse eine andere Klasse einlagert, ist sie ein gewöhnlicher Nutzer der
Klasse. Sie erhält dadurch keine besonderen Zugriffsrechte. Der Zugriff entspricht
weiter dem Zugriff von außen.
class aaa
{
private:
int x;
};
class bbb
{
private:
aaa eingelagert; // eingelagerte Klasse
void funktion();
};
void bbb::funktion()
{
//...
eingelagert.x = 1; // unzulaessiger Zugriff – Compiler-Fehler
}
Dieses Verhalten ist auch sinnvoll, ansonsten könnte der Schutz einfach dadurch
umgangen werden, dass man eine Klasse in eine andere einpackt.
Der statische Zugriff auf Member, Aufzählungstypen (enum) und interne Datenstruk-
turen der Klasse ist aus Memberfunktionen der Klasse ohne Einschränkungen mög-
896
23.2 Member
lich. Der Klassenname muss hier nicht mehr vorangestellt werden, da die Member-
funktion ja bereits aus dem Namensraum der Klasse zugreift:
class bachelorarbeit
{
private:
enum note { sehr_gut, gut, befriedigend, ausreichend, nicht_bestanden };
note gesamtnote;
public:
void berechneNote();
};
void bachelorarbeit::berechneNote()
{
//...
gesamtnote = gut;
}
Da ein statisches Funktionsmember zur ganzen Klasse gehört, aber nicht zu einer
konkreten Instanz, kann eine statische Memberfunktion nicht auf die nicht-stati-
schen Member der Klasse zugreifen, die ja an eine Instanz gebunden sind:
class klasse
{
private:
int nichtStatisch;
static void funktion();
};
void klasse::funktion()
{
nichtStatisch = 0; // Fehler, Datenmember nicht zugreifbar 23
}
Es handelt sich hier nicht um eine Einschränkung der Zugriffsrechte, sondern nur
um ein Problem der Verfügbarkeit. Wenn ein statisches Funktionsmember einer pas-
senden Instanz habhaft wird, hat es den fehlenden »Griff«, um auf die nicht-stati-
schen Elemente zuzugreifen. Das geht z. B., wenn eine Instanz als Funktionsparame-
ter übergeben wird:
897
23 Zusammenfassung und Überblick
class klasse
{
private:
int nichtStatisch;
static void funktion( klasse *pk );
};
class person
{
private:
person* partner;
public:
void partnerFestlegen( person* pp );
};
Der this-Pointer liefert innerhalb einer Methode die Adresse der Instanz, für die die
Methode aufgerufen worden ist. Statische Funktionsmember werden ohne eine kon-
krete Instanz ausgeführt, daher gibt es dort natürlich auch keinen this-Pointer.
898
23.2 Member
class person
{
// Die Funktion funktion1 ist mein Freund
friend int funktion1( person* pp, int a );
Mit der friend-Deklaration erhalten die Freunde die gleichen Zugriffsrechte wie die
eigenen Funktionsmember.
Nur die Klasse selbst kann andere zu ihren Freunden erklären. Könnte eine Klasse
von außen sich selbst zu einem Freund erklären, wäre der Zugriffsschutz ja auch
praktisch nutzlos. 23
Der Zugriffsschutz in Klassen dient als Hilfe, die Konsistenz von Klassendaten sicher-
zustellen und die Schnittstellen und Zuständigkeiten zwischen Objekten klar zu defi-
nieren. Wie an anderer Stelle auch, legen wir uns mit dem Zugriffsschutz Beschrän-
899
23 Zusammenfassung und Überblick
Die Möglichkeit, Freunde zu erklären, kann dazu verführen, die Regeln der objektori-
entierten Programmierung zu durchbrechen.
Von dieser Möglichkeit sollten Sie daher nur sehr sparsam Gebrauch machen. Bei
jeder Deklaration eines Freundes sollten Sie prüfen, ob Sie an dieser Stelle nicht
anfangen, Ihr ursprüngliches Design infrage zu stellen oder Design-Fehler kaschie-
ren. Oft ist ein verbessertes Klassen-Design langfristig die bessere Lösung als ein
schneller Durchgriff mit der friend-Deklaration. Ein Mittel für ein verbessertes Klas-
sen-Design kann die Vererbung sein, die wir im nächsten Abschnitt noch einmal
näher betrachten.
23.3 Vererbung
In objektorientierten Sprachen wie C++ können durch Vererbung aus bestehenden
Klassen neue Klassen gewonnen werden. Die Kindklassen erben die Attribute und
Methoden ihrer Eltern und können zusätzliche Attribute ausprägen sowie ererbtes
Verhalten aus Funktionsmembern anpassen und erweitern.
23.3.1 Einfachvererbung
Von einer bestehenden Klasse (Basisklasse oder Elternklasse genannt) kann eine
neue Klasse (Kindklasse oder abgeleitete Klasse) abgeleitet werden. Dazu gehen Sie
folgendermaßen vor:
Auf das Schlüsselwort class folgt der Name der neuen Klasse. Darauf folgt, getrennt
durch einen Doppelpunkt und die Angabe einer Zugriffsspezifikation, der Name der
Basisklasse, von der die neue Klasse erbt.
900
23.3 Vererbung
Die abgeleitete Klasse erbt alle Daten und Funktionsmember ihrer Basisklasse und
kann unmittelbar verwendet werden:
class basis
{
public:
char data;
void print() { cout << "basis: " << data << '\n'; }
};
int main()
{
basis b; // Instanz der Basisklasse
b.data = 'b';
b.print();
a.data = 'a';
a.print();
}
In dem Beispielprogramm wird mit der Instanziierung von a ein voll funktionsfähi-
ges Objekt erstellt. Als Instanz einer von basis abgeleiteten und ansonsten unverän-
derten Klasse verhält sich a wie eine Instanz der Basisklasse. Das führt dann zu der
folgenden Ausgabe: 23
basis: b
basis: a
Die Kindklasse kann, wie andere Klassen auch, neue Daten- und Funktionsmember
deklarieren. Dabei kann sie bestehende Daten- und Funktionsmember der Basis-
klasse überschreiben. Unter Überschreiben versteht man das Anlegen von Daten-
oder Funktionsmembern gleichen Namens wie in der Basisklasse. Im oben darge-
stellten Beispiel kann in der abgeleiteten Klasse das Funktionsmember print über-
schrieben werden:
901
23 Zusammenfassung und Überblick
class basis
{
public:
char data;
void print() { cout << "basis: " << data << '\n'; }
};
basis: b
abgeleitet: a
Trotz der Überschreibung in der abgeleiteten Klasse ist die print-Methode der Basis-
klasse weiterhin vorhanden. Sie kann auch genutzt werden, muss allerdings jetzt spe-
ziell aufgerufen werden. Dazu müssen Sie angeben, dass speziell auf die Methode der
Basisklasse zugegriffen werden soll. Wenn Sie im Hauptprogramm den zweiten Teil
entsprechend modifizieren:
basis: a
abgeleitet: a
Durch den Zugriff auf basis::print wird die eigentlich überdeckte print-Methode
der Basisklasse explizit aufgerufen.
Auch nach dem Überdecken ist es möglich, in der Klasse das Verhalten der Basis-
klasse zu verwenden, um es in der abgeleiteten Klasse zu verfeinern oder zu konkre-
tisieren. Dazu überschreiben Sie in der abgeleiteten Klasse eine geeignete Methode
der Basisklasse. Innerhalb der Neuimplementierung wir die Methode der Basisklasse
wieder aufgerufen, typischerweise flankiert durch den veränderten Code. Für den
Nutzer der Klasse bleibt es bei dem Methodenaufruf der abgeleiteten Klasse, der
intern die Funktion der Basisklasse nutzt.
902
23.3 Vererbung
void abgeleitet::print()
{
// spezieller Code der abgeleiteten Klasse
basis::print();
// weiterer spezieller Code der abgeleiteten Klasse
}
class basis
{
public:
char data;
void print() { cout << "basis: " << data << '\n'; }
};
23
a.basis::data = 'b';
a.data = 'a';
a.basis::print();
a.print();
basis: b
abgeleitet: a
An diesem Beispiel können Sie sehen, dass die Klasse die Member zuerst in ihrem
Namensraum sucht. Findet sie sie dort nicht, wird die Suche bei der Basisklasse fort-
903
23 Zusammenfassung und Überblick
gesetzt. Wurden Member überschrieben, kann zum Zugriff mit dem Scope-Resolu-
tion-Operator »::« direkt auf die Member der Basisklasse verwiesen werden.
Für die Funktionsmember ist es ein geeignetes Verfahren, das das Verhalten von
Klassen bestimmt. Das Überschreiben von Datenmembern sollten Sie allerdings ver-
meiden. Sie haben vielleicht schon bei dem kleinen Beispiel den berechtigten Ein-
druck gewonnen, dass es schnell unübersichtlich wird und nicht zu einer sauberen
Modellbildung beiträgt.
Betrachtet man die Analogie zum richtigen Leben, dann ist es wenig überraschend,
dass die Vererbung ein mehrstufiger Prozess ist. Von einer abgeleiteten Klasse kann
wieder eine neue Klasse abgeleitet werden. Der Zugriff auf die Member durch die
Klassenhierarchie, also auf Eltern, Großeltern etc., erfolgt auch hier durch Voranstel-
len des Klassennamens:
class grossmutter
{
public:
void funktion() {}
};
int main()
{
kind k;
904
23.3 Vererbung
23.3.2 Mehrfachvererbung
Wir haben bisher die einfache Vererbung betrachtet, bei der eine Klasse von einer
Basisklasse erbt. Eine Klasse kann aber auch mehr als eine Basisklasse haben. In die-
sem Fall sprechen wir von Mehrfachvererbung. Anstelle einer einzelnen Klasse als
Basisklasse wird dann in der Klassendeklaration eine Liste von Basisklassen angege-
ben, jeweils mit ihrer Zugriffsspezifikation:
Auch hier wird zuerst der Name der abgeleiteten Klasse angegeben, gefolgt von
einem Doppelpunkt und der Liste der Basisklassen mit ihrer Zugriffsspezifikation.
Solange die beteiligten Basisklassen sauber voneinander getrennt sind, ist die Mehr-
fachvererbung unkritisch und eine einfache Fortsetzung der Einfachvererbung.
Einige Fälle, bei denen eben keine solch saubere Trennung vorliegt, und die daraus
entstehenden Konfliktfälle werden wir jetzt diskutieren.
Mehrdeutige Vererbung
Wenn ein Klasse ein gleichartiges Member (Daten- oder Funktionsmember) von
unterschiedlichen Basisklassen erbt, kann ein Namenskonflikt zu Mehrdeutigkeiten
führen, die manuell aufgelöst werden müssen. Im folgenden Beispiel wurde ein sol-
cher Konflikt erzeugt:
class basis1
{
public:
int data;
}; 23
class basis2
{
public:
int data;
};
905
23 Zusammenfassung und Überblick
In der abgeleiteten Klasse sind die beiden Member data aus den Klassen basis1 und
basis2 trotz ihres gleichen Typs und Namens unabhängig voneinander vorhanden.
Zur Auflösung dieser Mehrdeutigkeit stellen Sie beim Zugriff auf das Element den
Namen der Klasse voran, auf dessen Member Sie zugreifen möchten. Damit wird für
den Compiler festgelegt, in welchem Namensraum er suchen soll:
int main()
{
abgeleitet a;
a.basis1::data = 1;
a.basis2::data = 2;
}
Wenn möglich, sollten Namenskollisionen dieser Art vermieden werden. Wenn Sie
die gesamte Vererbungshierarchie kontrollieren, sollten Sie solche Fälle durch
Umbenennen von Membern beseitigen. Es kann jedoch auch vorkommen, dass Sie
von Klassen ableiten, über die Sie nicht die volle Kontrolle haben. Das ist z. B. der Fall,
wenn Sie Bibliotheken in eigene Klassen zusammenführen. In einem solchen Fall
wird mit der oben angegebenen Vorgehensweise gearbeitet.
Wiederholte Vererbung
Es kann vorkommen, dass dasselbe »Erbgut« auf unterschiedlichen Wegen zu einer
abgeleiteten Klasse vererbt wird. In diesem Fall sprechen wir von wiederholter Verer-
bung. Ich werde Ihnen auch das in einem kurzen Beispiel demonstrieren:
class usbdevice
{
public:
int data;
};
906
23.3 Vererbung
USB-Device
Scanner Drucker
Kopierer
Das geerbte Datenmember data aus der Klasse usbdevice ist jetzt in der Klasse kopie-
rer doppelt vorhanden, wie wir durch einen einfachen Test auch herausfinden kön-
nen. Wir sind dabei erfolgreich, es über den Namensraum der Klasse anzusprechen,
von der es unmittelbar geerbt wurde:
kopierer k;
k.scanner::data = 4711;
k.drucker::data = 42;
23
Für konkrete Instanzen stellt sich die Situation damit folgendermaßen dar (siehe
Abbildung 23.2).
Ob dieses Verhalten dem Programm angemessen ist, hängt von der modellierten
Situation ab. Häufig will man aber, dass das Erbgut einer wiederholt vorkommenden
Basisklasse nur einmal bei der abgeleiteten Klasse auftritt. In diesen Fall kommen die
sogenannten virtuellen Basisklassen zum Einsatz, die Sie im folgenden Abschnitt
kennenlernen.
907
23 Zusammenfassung und Überblick
USB-Device
data
Scanner Drucker
Kopierer
Virtuelle Basisklassen
Bei der Vererbung können Sie bei der Bestimmung der Basisklassen das Schlüssel-
wort virtual einsetzen. Eine solche Basisklasse wird dann als virtuelle Basisklasse
bezeichnet. Auch bei wiederholter Vererbung kommt eine virtuelle Basisklasse unter
den Vorfahren einer Klasse dann nur einmal vor.
Wir deklarieren die Klasse usbdevice jeweils als virtuelle Basisklasse von scanner und
drucker, bevor wir die Klasse kopierer von den beiden ableiten:
class usbdevice
{
public:
int data;
};
908
23.3 Vererbung
Alle virtuellen Vorkommen der Basisklasse usbdevice werden jetzt zu einer Instanz
zusammengefasst. Da die Klasse usbdevice damit in scanner und drucker nur noch
einmal vorkommt, kommt auch bei der Klasse kopierer das Erbgut von usbdevice
nur noch einmal vor. Jetzt ist es möglich, eine Instanz von kopierer anzulegen und
dem nur noch einmal vorkommenden Datenmember data einen Wert zu geben,
ohne spezifisch adressieren zu müssen:
kopierer k;
k.data = 123;
Der Zugriff über die unterschiedlichen Zugriffswege ist generell weiter möglich:
Wie erwartet, greifen alle drei Notationen jetzt natürlich auf dasselbe Datenmember
zu, und die Ausgabe ist damit:
123
123
123
class usbdevice
{
public:
909
23 Zusammenfassung und Überblick
int data;
};
};
class multifunktion: public scanner, public drucker, public memorystick
{
};
Als Ergebnis gibt es jetzt zwei Vorkommnisse von usbdevice in multifunktion. Das ist
einmal die von drucker und scanner geerbte virtuelle Basisklasse und zum anderen
die von memorystick geerbte nicht-virtuelle Basisklasse. Damit besteht an dieser
Stelle wieder eine Mehrdeutigkeit, und für den Zugriff auf die Elemente der Basis-
klasse müssen erneut Klassennamen vorangestellt werden:
multifunktion m;
Sie werden anhand der Beispiele ahnen, dass sich hier praktisch beliebig komplexe
Klassenhierarchien modellieren lassen. Das hier angeführte Beispiel ist vielleicht
etwas künstlich, aber ich hoffe, es hat Ihren Blick für die vielfältigen Situationen
geschärft, die bei der Vererbung auftreten können.
Wir werden die besonders komplexen Fälle jetzt nicht weiter verfolgen. Stattdessen
wenden wir uns mit den virtuellen Funktionen einem Thema zu, dass für die objekt-
orientierte Programmierung bei allen Arten von Vererbungshierarchien extrem
wichtig ist.
910
23.3 Vererbung
class basis
{
public:
void print() { cout << "Klasse basis\n"; }
};
Zunächst ist dort eine Klasse basis, die lediglich über eine Memberfunktion print
ihren Namen auf dem Bildschirm ausgeben kann. Zusätzlich wurde die Funktion
ausgabe erstellt, die eine Referenz auf eine Instanz der Klasse basis übergeben
bekommt und dann die Ausgabe des Klassennamens über die print-Funktion veran-
lasst.
Wenn wir eine Instanz der Klasse basis anlegen und diese Instanz an die Funktion 23
ausgabe übergeben,
int main()
{
basis instanzBasis;
ausgabe( instanzBasis );
}
911
23 Zusammenfassung und Überblick
Klasse basis
Jetzt wird das Programm erweitert, indem die Klasse abgeleitet von basis erbt.
Zusätzlich erstellen wir dabei für die abgeleitete Klasse eine eigene print-Funktion,
in der der Klassenname abgeleitet ausgegeben wird.
Wenn wir jetzt im Programm eine Instanz der Klasse abgeleitet erzeugen und an die
Funktion ausgabe übergeben,
main( )
{
abgeleitet instanzAbgeleitet;
ausgabe( instanzAbgeleitet );
}
Klasse basis
Ein Objekt der Klasse abgeleitet ist ein Objekt der Klasse basis, sodass es problemlos
an die Funktion ausgabe übergeben werden kann. Es erfüllt die Typbedingung an der
Schnittstelle. Innerhalb der Funktion ausgabe ist allerdings nur bekannt, dass es sich
um ein Objekt der Klasse basis handelt. Die Klasse abgeleitet hat noch gar nicht
existiert, als die Funktion ausgabe erstellt und kompiliert worden ist. Wir wollen aber
erreichen, dass hier der Name der Klasse, um die es sich wirklich handelt, auf dem
Bildschirm erscheint. Die tatsächliche Klassenzugehörigkeit des Parameters b der
Funktion ausgabe kann aber erst zur Laufzeit festgestellt werden. Dadurch, dass wir
die Memberfunktion print der Klasse basis als virtuell deklarieren, aktivieren wir die
dynamische Zuordnung der richtigen Funktion zum Funktionsaufruf in der ausgabe-
Funktion. Wir betrachten noch einmal das jetzt vollständige Programm:
class basis
{
public:
virtual void print() { cout << "Klasse basis\n"; }
};
912
23.3 Vererbung
int main()
{
basis instanzBasis;
ausgabe( instanzBasis );
abgeleitet instanzAbgeleitet;
ausgabe( instanzAbgeleitet );
}
Klasse basis
Klasse abgeleitet
Hier handelt es sich um ein ganz wichtiges Prinzip der objektorientierten Modellbil-
dung in C++.
Bei der Modellierung einer möglichen Basisklasse, also einer Klasse, von der später
einmal weitere Klassen abgeleitet werden, erklärt man die Methoden zu virtuellen
Methoden:
23
왘 die später einmal von den abgeleiteten Klassen überschrieben werden
왘 die auch dann in ihrer konkreten Ausprägung zur Ausführung kommen sollen,
wenn sie abstrakt über die Basisklasse aufgerufen werden
Alle Funktionsmember einer Klasse von vorneherein als virtuell zu deklarieren ist
nicht sinnvoll. Zum einen will man den Effekt der dynamischen Zuordnung nicht
immer haben. Zum anderen hat das Verfahren einen zwar geringen, aber dennoch
vorhandenen zusätzlichen Laufzeitbedarf. Aus Effizienzgründen versucht man
daher, nur die Funktionen als virtuell zu deklarieren, deren Virtualität auch wirklich
benötigt wird.
913
23 Zusammenfassung und Überblick
class basis
{
public:
~basis() { cout << " Destruktor von basis\n"; }
};
int main()
{
basis *b = new abgeleitet;
delete b;
}
Offensichtlich ist bei der Beseitigung des Objekts b durch die Anweisung delete b der
Destruktor der Klasse abgeleitet nicht aufgerufen worden, obwohl das zu beseiti-
gende Objekt von dieser Klasse ist. Erst wenn Sie den Destruktor der Klasse basis als
virtuell deklarieren,
class basis
{
public:
virtual ~basis() { cout << " Destruktor von basis \n"; }
};
wird auch in dieser Situation die Beseitigung des Objekts korrekt durchgeführt, und
Sie erhalten die Ausgabe:
914
23.3 Vererbung
Beachten Sie dabei, dass durch den virtuellen Destruktor nach der Ausführung des
Destruktors der abgeleiteten Klasse auch der Destruktor der Basisklasse ausgeführt
wird. Hier unterscheidet sich das Verhalten von virtuellen Funktionen!
Durch den virtuellen Destruktor der Basisklasse wird sichergestellt, dass der Destruk-
tor der abgeleiteten Klasse auch dann zur Ausführung kommt, wenn die abgeleitete
Klasse bei ihrer Beseitigung nur als Basisklasse angesprochen wird.
Basisklassen sollten immer dann einen virtuellen Destruktor haben, wenn zu erwar-
ten ist, dass abgeleitete Klassen einen Destruktor benötigen.
Der Hinweis auf virtuelle Destruktoren ist insofern besonders wichtig, weil durch
den nicht-virtuellen Konstruktor oft kein direkt beobachtbarer Fehler entsteht, son-
dern sich das Programm zur Laufzeit graduell verschlechtert. Dies passiert, wenn z. B.
dynamisch allokierter Speicher durch den fehlenden Destruktoraufruf nicht wieder
freigegeben wird und damit ein sogenanntes Speicherleck entsteht.
Das ist sinnvoll, wenn die Klasse noch nicht »reif« zur Instanziierung ist. Eine solche
Klasse wird abstrakte Klasse genannt. Die Kinder und Kindeskinder der abstrakten
Klasse basisAbstrakt müssen die an der Basisklasse vorhandenen rein virtuellen
Funktionen überschreiben, sofern sie instanziiert werden wollen. Solange eine
Klasse noch mindestens eine rein virtuelle Methode enthält, kann sie nicht instanzi-
iert werden.
915
23 Zusammenfassung und Überblick
int maint()
{
basisAbstrakt instanzBa; // Fehler: basisAbstrakt
// kann nicht instanziiert werden
konkret instanzKonkret; // ok
ausgabe( instanzKonkret );
}
Durch diese Technik kann sichergestellt werden, dass eine entsprechende Methode
bei abgeleiteten Klassen auf jeden Fall eigenständig implementiert wird. Die Alterna-
tive, nämlich die Implementierung eines »Platzhalters« in der Basisklasse, den abge-
leitete Klassen überschreiben sollen, trägt das Risiko, dass diese Anforderung
übersehen wird. Rein virtuelle Funktionen erzwingen die Implementierung in einem
solchen Fall.
916
23.4 Zugriffsschutz und Vererbung
Durch Vererbung kommt nun zur Innen- und Außenansicht noch eine dritte Sicht
hinzu, die Sicht der abgeleiteten Klasse auf ihre Basisklasse(n) und deren Vorfahren,
also die Sicht entlang der Vererbungshierarchie.
Der Zugriff auf die Member einer Klasse wird an zwei Stellen reguliert. Das sind zum
einen die als private, protected und public gekennzeichneten Bereiche in der Klas-
sendeklaration. Für die Klassen entlang der Vererbungshierarchie ist das zusätzlich
die Zugriffsspezifikation bei der Vererbung. Diese legt den Zugriff der abgeleiteten
Klassen auf die Basisklasse fest.
Wie bei den Zugriffsspezifikationen in der Klassendeklaration ist auch hier private
die restriktivste Variante. Und auch bei der Vererbung wird private als Standardwert
angenommen, wenn keine explizite Angabe gemacht worden ist. Bisher habe ich aus-
schließlich die Zugriffsspezifikation public verwendet. Das ist auch die mit Abstand
am häufigsten genutzte der drei.
917
23 Zusammenfassung und Überblick
class pkw
{
private:
Interner Zugriff auf die
// ...
Elemente der Klasse
protected:
(public, protected
Externer Zugriff auf // ...
und private)
die public-Elemente public:
der Klasse // ...
};
};
Öffentliche Vererbung
Wenn wir als Zugriffsspezifikation public verwenden, sprechen wir auch von öffent-
licher Vererbung.
Bei der öffentlichen Vererbung werden der public- und der protected-Bereich der
Basisklasse in die entsprechenden Bereiche der abgeleiteten Klasse übernommen
und sind dort in gleicher Weise wie an der Basisklasse verfügbar. Für einen Fall aus
der folgenden Vererbungshierarchie werden wir uns das genauer ansehen (siehe
Abbildung 23.4).
In dem Beispiel ist pkw die Basisklasse von kombi und diese die Basisklasse von vari-
ant. Die Klasse kombi hat damit internen Zugriff auf alle Member der Klasse selbst und
die nicht-privaten Member der Klasse pkw. Die Klasse variant hat durch die öffentli-
che Vererbung noch Durchgriff auf den protected- und public-Bereich von kombi und
pkw, also die Eltern- und Großelternklasse.
918
23.4 Zugriffsschutz und Vererbung
PKW
...
...
Die Klasse pkw als Basisklasse der Vererbungshierarchie findet sich damit aus allen
drei Blickwinkeln (von innen, von außen und in der Vererbungshierarchie) vollstän-
dig in der abgeleiteten Klasse kombi und auch in deren abgeleiteter Klasse variant
wieder.
Insofern kann jedermann eine Instanz der Klasse kombi oder variant wie eine Instanz
der Klasse pkw – die sie ja auch ist – verwenden. Insbesondere kann man explizit
(durch Cast-Operator) oder implizit (z. B. durch Zuweisung oder Parameterübergabe
an eine Funktion) ein Objekt der Klasse kombi oder variant in ein Objekt der Klasse
pkw konvertieren und dann als solches verwenden. Hier liegt auch die hauptsächliche
Verwendung der öffentlichen Vererbung. Wir wollen uns das konkret in Code an-
sehen:
class pkw
{ 23
};
919
23 Zusammenfassung und Überblick
int main()
{
pkw *p;
kombi k;
variant v;
C p = &k; // ok
p = &v; // ok
}
Wir erzwingen hier implizite Typumwandlungen, indem wir die Adresse von einem
kombi oder einem variant nehmen und einem Zeiger auf einen pkw zuweisen (A, B, C).
Alle Konvertierungen funktionieren einwandfrei, weil der pkw ein öffentlicher Teil von
kombi und variant ist.
Öffentliche Vererbung
Die öffentliche Vererbung ist die Variante, die Ihnen am häufigsten begegnen wird.
Nutzen Sie die öffentliche Vererbung, um eine Ist-ein-Beziehung zu modellieren: Ein
Variant ist ein Kombi ist ein Pkw.
Wenn Sie eine Hat-ein- oder Besteht-aus-Beziehung modellieren wollen, greifen Sie
nicht auf die öffentliche Vererbung zurück, sondern verwenden Sie die Aggregation
oder Komposition.
Geschützte Vererbung
Die geschützte Vererbung wird mit der Zugriffsspezifikation protected umgesetzt.
Sie ist restriktiver als die öffentliche Vererbung. Hier werden die öffentlichen public-
und die geschützten protected-Member der Basisklasse zu geschützten Membern
der abgeleiteten Klasse. Was an der Basisklasse noch öffentlich zugänglich war, ist an
der abgeleiteten Klasse der abgeleiteten Klasse selbst und ihren Kindern vorbehalten.
920
23.4 Zugriffsschutz und Vererbung
Private Vererbung
Die restriktivste Form der Vererbung ist durch die Zugriffsspezifikation private
gegeben. Durch diese Zugriffsspezifikation werden die öffentlichen und geschützten
Member der Basisklasse in den privaten Bereich der abgeleiteten Klasse gelegt.
Dadurch wird jetzt auch den Kindern und Kindeskindern der abgeleiteten Klasse der
Zugriff auf die Basisklasse untersagt. Für die private Vererbung gibt es Anwendungs-
fälle bei der Implementierung. Insbesondere kann sie eine »ist implementiert in
Form von«-Beziehung darstellen, im Softwaredesign findet sie keine direkte Verwen-
dung.
class basis
{
public:
int a;
int b;
int c;
};
In der Basisklasse sind alle Elemente öffentlich. Durch private Vererbung werden sie
in der abgeleiteten Klasse privat:
921
23 Zusammenfassung und Überblick
Es ist auf diese Weise nicht möglich, ein privates Element der Basisklasse nachträg-
lich zu veröffentlichen. Das kann nur die Basisklasse, indem sie ein Element in den
entsprechenden Bereich legt oder eine Funktion oder Klasse zu ihrem Freund erklärt.
왘 automatische Objekte
왘 statische Objekte
왘 dynamische Objekte
Automatische Objekte sind Objekte, die innerhalb von Blöcken ohne den Zusatz
static angelegt werden:
void funktion()
{
punkt p1; // ein automatisches Objekt
Automatische Objekte werden jedes Mal erneut instanziiert, wenn der Kontrollfluss
ihre Definition erreicht, und beseitigt, wenn der Kontrollfluss den Block verlässt, in
dem sie definiert wurden.
Statische Objekte sind Objekte, die außerhalb von Blöcken mit oder ohne den Zusatz
static oder innerhalb von Blöcken mit dem Zusatz static definiert werden:
14 Beachten Sie, dass es sich bei basis::b nicht um die Deklaration eines Datenmembers handelt,
sondern nur um eine Änderung der Sichtbarkeitsregeln für das Datenmember b der Basisklasse
basis. Deshalb steht hier auch nur der Zugriffspfad und nicht auch der Datentyp.
922
23.5 Der Lebenszyklus von Objekten
void funktion ()
{
static punkt p3; // ein lokales statisches Objekt
Statische Objekte werden einmalig, und zwar vor ihrer erstmaligen Verwendung,
instanziiert und erst bei Programmende wieder beseitigt. Insbesondere behalten sol-
che Objekte ihren Zustand (der durch die Werte ihrer Datenmember repräsentiert
wird) blockübergreifend bei.
왘 Innerhalb von Blöcken definierte statische Objekt heißen lokale statische Objekte
und sind auch nur in dem Block bekannt, in dem sie definiert worden sind.
왘 Außerhalb von Blöcken mit dem Zusatz static definierte Objekte sind innerhalb
des Moduls (= Kompilationseinheit = Quellcodedatei) bekannt, in dem sie defi-
niert worden sind.
왘 Außerhalb von Blöcken ohne den Zusatz static definierte Objekte sind überall
bekannt. Vor ihrer Verwendung in anderen Modulen müssen sie dort allerdings
durch einen extern-Verweis bekannt gemacht werden:
Dynamische Objekte sind Objekte, die der Programmierer anlegt, wenn er sie benö-
tigt, und wieder beseitigt, wenn er sie nicht mehr benötigt. Zum Anlegen der Objekte
dient der new-, zum Beseitigen der delete-Operator. Der new-Operator liefert einen
Zeiger auf das angelegte Objekt. Zum Beseitigen eines Objekts wird der delete-Opera-
tor auf den von new gelieferten Objektzeiger angewandt:
923
23 Zusammenfassung und Überblick
void funktion ()
{
punkt* p; // Zeiger fuer ein dynamisches Objekt
p = new punkt; // Anlegen des dynamischen Objekts
Arrays von Objekten werden instanziiert, indem man bei automatischen und stati-
schen Objekten die gewünschte Array-Größe (also die Anzahl der Objekte im Array)
in eckigen Klammern angibt:
void funktion ()
{
punkt a2[10]; // ein lokales Array mit 10-Punkt-Objekten
// ...
}
Möchten Sie ein Array von Objekten dynamisch instanziieren, verwenden Sie new
mit einer zusätzlichen Angabe zur gewünschten Array-Größe:
punkt *array;
Beseitigt werden dynamisch angelegte Arrays von Objekten mit dem delete[]-Ope-
rator:
delete[] array;
Einleitend habe ich gesagt, dass das Instanziieren von Objekten oberflächlich dem
Anlegen von Variablen in C entspräche. Der wesentliche Unterschied zwischen der
Variablendefinition in C und der Objektinstanziierung in C++ besteht darin, dass der
Entwickler durch spezielle Funktionen dafür sorgen kann, dass Objekte immer kon-
sistent aufgebaut werden und auch konsistenzwahrend wieder beseitigt werden.
Diese Funktionen heißen Konstruktoren und Destruktoren und werden bei der
Instanziierung bzw. der Beseitigung eines Objekts automatisch verwendet.
924
23.5 Der Lebenszyklus von Objekten
Ein Konstruktor ist eine Funktion der Klasse, die den Namen der Klasse trägt und im
Gegensatz zu Funktionsmembern keinen Rückgabetyp hat, auch nicht void.
Im folgenden Beispiel wird eine Klasse implementiert, die einen String aufnehmen
soll. Die Klasse verwaltet intern ein Längenfeld (len) und einen Zeiger auf einen dyna-
misch zu allokierenden Zeichenpuffer (txt). In dem Zeichenpuffer wird der zu ver-
waltende Text stehen:
class string
{
private:
int len;
char* txt;
};
Solange die Klasse keinen explizit erstellten Konstruktor hat, kann sie in dieser Form
instanziiert werden:
string s;
Das kann zu Problemen führen, da weder das Längenfeld noch der Zeiger bei dieser
Form der Instanziierung sinnvoll initialisiert werden. Wir erstellen daher einen Kon-
struktor, um eine Instanz der Klasse string aus einer als Parameter übergebenen Zei-
chenkette zu initialisieren:
class string
{
23
private:
int len;
char* txt;
public:
string( char* t ); // 1. Konstruktor
};
925
23 Zusammenfassung und Überblick
string::string( char* t )
{
len = strlen( t );
txt = new char[len + 1];
strcpy( txt, t );
}
Um ein Objekt der Klasse string zu instanziieren, muss jetzt eine Zeichenkette über-
geben werden:
Eine Klasse kann mehrere Konstruktoren haben. Wie andere Methoden auch kann
der Konstruktor überladen werden. Wir erstellen einen weiteren Konstruktor, der
eine Zahl in eine Zeichenkette umwandelt und mit dieser Zeichenkette die Klasse
string initialisiert:
class string
{
private:
int len;
char* txt;
public:
string( char* t ); // 1. Konstruktor
string( int x ); // 2. Konstruktor
};
string::string( int x )
{
txt = new char[10];
sprintf(txt, "%d", x );
len = strlen( txt );
}
926
23.5 Der Lebenszyklus von Objekten
Jetzt kann ein String mit einem Text oder einer Zahl initialisiert werden. Anhand der
Parametersignatur wird der richtige Konstruktor ausgewählt:
class string
{
private:
int len;
char* txt;
public:
string( char* t ); // 1. Konstruktor
string( int x ); // 2. Konstruktor
string(); // 3. Konstruktor
};
string::string()
{
len = 0;
txt = new char[len + 1];
*txt = 0; // *txt entspricht txt[0]
}
Der parameterlose Konstruktor erstellt praktisch einen leeren String und setzt ledig-
lich die terminierende 0.
Damit kann der String jetzt wieder ohne Parameter instanziiert werden:
In allen Fällen ist jetzt sichergestellt, dass das Längenfeld (len) und der Textzeiger
(txt) korrekte Initialwerte haben.
927
23 Zusammenfassung und Überblick
Konstruktoren können auch Default-Argumente haben. Diese gehen wie üblich nicht
in die Parametersignatur ein. Aus naheliegenden Gründen können Konstruktoren
nicht virtuell sein15.
class string
{
private:
int len;
char* txt;
public:
// ... // Konstruktoren
~string(); // Destruktor
};
string::~string()
{
delete[] txt;
}
Sie sehen, dass sich der Destruktor darauf verlässt, dass das Objekt durch den Kon-
struktor korrekt initialisiert wurde und txt korrekt initialisiert und später auch nicht
korrumpiert wurde.
Der Destruktor liegt praktisch immer im öffentlichen Bereich der Klasse. Technisch
ist es aber auch möglich, den Destruktor im geschützten oder privaten Bereich der
Klasse abzulegen.
928
23.5 Der Lebenszyklus von Objekten
Ich greife hier noch einmal auf das in diesem Kapitel bereits verwendete Beispiel der
Klasse string zurück und betrachte hier nur den ersten implementierten Konstruk-
tor sowie den Destruktor:
class string
{
private:
int len;
char* txt;
public:
string( char* t );
~string();
};
string::string( char* t )
23
{
len = strlen( t );
txt = new char[len + 1];
strcpy( txt, t );
}
string::~string()
{
delete[] txt;
}
929
23 Zusammenfassung und Überblick
Die Klasse ist damit prinzipiell vollständig und kann verwendet werden:
int main()
{
string s1( "Test" );
}
Sobald wir aber eine implizite Kopie des Objekts erzeugen, z. B. durch die Übergabe
des Objekts als Parameter, stürzt das Programm ab:
Betrachtet man den Ablauf Schritt für Schritt, wird die Ursache des Problems schnell
sichtbar. Mit dem Konstruktor
string s1
len = 4
txt Test
Durch die Parameterübergabe per Kopie wird implizit eine identische Kopie dieses
Objekts erzeugt und an die Funktion übergeben (siehe Abbildung 23.6).
Durch das System wird nur das eigentliche Objekt dupliziert, also der Inhalt der
Datenmember len und txt, wobei txt auch in der Kopie des Objekts auf den vom
kopierten Objekt allokierten Speicherbereich zeigt.
930
23.5 Der Lebenszyklus von Objekten
string s1
len = 4
txt Test
string par
len = 4
txt
Was im weiteren Verlauf passiert, ist durch die schon bekannten Regeln vorgegeben.
Bei Verlassen der Funktion tuwas wird das Parameterobjekt par wieder beseitigt.
Dazu wird sein Destruktor aufgerufen. Dieser beseitigt den anhängenden Textbuffer.
Nach der Rückkehr aus der Funktion ergibt sich damit folgendes Bild:
string s1
len = 4
txt
Damit ist das Ursprungsobjekt korrupt. Der interne Zeiger verweist auf Speicher, der
bereits freigegeben ist. Zum Programmende, wenn das Objekt s1 ebenfalls wieder frei-
gegeben wird, kommt es zum Programmabsturz16. Sie können den Absturz vermei-
den, indem Sie festlegen, wie beim Kopieren eines Objekts vom Typ string verfahren
werden soll und dass dort der anhängende Textbuffer mitkopiert werden muss.
16 Der Fehler entsteht, weil das übergebene Objekt kopiert wird. Er würde nicht auftreten, wenn
wir das Objekt als Zeiger oder als Referenz übergeben hätten.
931
23 Zusammenfassung und Überblick
Der Copy-Konstruktor hat dann die Aufgabe, eine korrekte Kopie des Originals zu
erstellen. Wir erweitern daher unsere Klasse auf folgende Weise:
class string
{
private:
int len;
char* txt;
public:
string( char* t ); // 1. Konstruktor
string( const string& s );
~string();
};
Jetzt wird an der Funktionsschnittstelle eine korrekte Kopie erzeugt, und das Pro-
gramm stürzt nicht mehr ab:
string s1
len = 4
txt Test
string par
len = 4
txt Test
932
23.5 Der Lebenszyklus von Objekten
Mit dem gleichen Problem sind wir auch konfrontiert, wenn wir versuchen, einen
String einem anderen zuzuweisen. Auch das folgende Programm stürzt trotz des so-
eben implementierten Copy-Konstruktors ab:
int main()
{
string s1( "Test1" );
string s2( "Test2" );
s2 = s1;
}
Das liegt daran, dass bei einer Zuweisung, wie sie hier passiert, nicht der Copy-Kon-
struktor verwendet wird. Konstruktoren sind dazu da, Objekte zu instanziieren. Das
Objekt s2 in unserem Beispiel existiert aber schon.
Um das Problem auch in dieser Situation zu lösen, müssen wir eine überladene Ver-
sion des Zuweisungsoperators operator= bereitstellen:
class string
{
private:
int len;
char* txt;
public:
string( char* t );
string( const string& s );
string& operator=( const string& s );
~string();
};
len = s.len;
txt = new char[len + 1];
strcpy( txt, s.txt );
C return *this ;
}
933
23 Zusammenfassung und Überblick
Dieser überladene Operator macht im Prinzip das Gleiche wie der Copy-Konstruktor.
Er sorgt dafür, dass eine saubere Kopie mit eigenem Textbuffer entsteht.
string s3;
s3 = s3;
In diesem Fall gibt es für den Operator nichts zu tun, und er gibt nur eine Referenz auf
sich selbst zurück, um eine Verkettung zu ermöglichen.
Andernfalls wird zuerst der alte Textbuffer des bereits instanziierten Objekts freige-
geben (B), bevor der Zeiger txt mit der Adresse des neu allokierten Buffers über-
schrieben wird.
Abschließend gibt der Operator eine Referenz auf sich selbst zurück (C), um die Ver-
kettung von Zuweisungen zu ermöglichen:
s1 = s2 = s3;
class string
{
private:
// ...
934
23.5 Der Lebenszyklus von Objekten
public:
string(); // 1. Konstruktor
string( char* t ); // 2. Konstruktor
string( int x ); // 3. Konstruktor
string( const string& s ); // Copy-Konstruktor
};
Der erste Konstruktor erzeugt einen leeren String, der zweite Konstruktor erzeugt
einen String aus einer 0-terminierten Zeichenkette, indem er einen Buffer allokiert
und die Zeichenkette in den Buffer kopiert. Der dritte Konstruktor erzeugt einen
String aus einer Zahl, indem er einen Buffer allokiert und die Zahl mit sprintf als Zei-
chenkette in den Buffer schreibt. Der Copy-Konstruktor wurde im vorangegangenen
Abschnitt 23.5.3, »Kopieren von Objekten«, noch einmal vorgestellt.
Zunächst einmal können Sie mit den Konstruktoren in folgender Weise Objekte
erstellen:
Der Compiler erkennt, dass er hier den zweiten bzw. dritten Konstruktor verwenden
kann, um aus dem Datentyp der rechten Seite (char * bzw. int) den Typ der linken
Seite (string) zu erzeugen.
Man kann ein Objekt auch durch den expliziten Aufruf eines Konstruktors
Schließlich besteht noch die Möglichkeit der Instanziierung durch den Aufruf einer
Funktion, die ein Objekt der entsprechenden Klasse als Returnwert hat:
935
23 Zusammenfassung und Überblick
string funktion()
{
return string( "Test" );
}
int main()
{
string s10 = funktion();
}
Um Objekte dynamisch zu instanziieren, müssen Sie bei der Verwendung des new-
Operators zu einem Konstruktor passende Parameter übergeben:
delete s11;
delete s12;
delete s13;
Für diese Art der Instanziierung muss ein parameterloser Konstruktor existieren. Die
Objekte im Array werden dann einheitlich über diesen Konstruktor in aufsteigender
Reihenfolge instanziiert.
Die Objekte eines Arrays können auch durch explizite Konstruktoraufrufe instanzi-
iert werden:
936
23.5 Der Lebenszyklus von Objekten
Auch hier wird der erste Konstruktor für alle Objekte im Array in aufsteigender Rei-
henfolge gerufen. Eine Initialisierung wie bei statischen Arrays ist in diesem Fall
nicht möglich.
Üblicherweise initialisiert man alle Elemente in gleicher Weise durch einen parame-
terlosen Konstruktor und nimmt anschließend in einer Schleife eine individuelle Ini-
tialisierung vor.
delete[] s17;
Wir erstellen dazu eine Klasse, die intern einen int-Wert verwaltet und einen Kon-
struktor hat, der den Wert initialisiert:
class klasse
{
private:
int wert;
public: 23
klasse( int w ) { wert = w; }
};
Sie wissen aus dem letzten Abschnitt, dass wir jetzt in der folgenden Weise Instanzen
der Klasse erzeugen können:
klasse k1(5);
klasse k2 = 7;
Die erste Art der Instanziierung nennen wir explizite Instanziierung, da wir hier den
Konstruktor explizit aufrufen. Die zweite Art nennen wir implizite Instanziierung.
Hier vertrauen wir darauf, dass der Compiler einen geeigneten Konstruktor findet,
937
23 Zusammenfassung und Überblick
an den er den übergebenen Wert implizit weiterreichen kann. Diese Art der Instanzi-
ierung funktioniert natürlich nur für Konstruktoren mit genau einem Pflicht-Para-
meter. Der Typ des Parameters muss dabei nicht unbedingt einer der
Grunddatentypen sein, es kann sich z. B. auch um eine selbst erstellte Klasse handeln.
Der Typ muss auch nicht exakt passen, der Compiler muss nur eine implizite Konver-
tierung durchführen können.
Wenn Sie nicht wünschen, dass ein Konstruktor implizit verwendet werden kann,
stellen Sie ihm das Schlüsselwort explicit voran:
class klasse
{
private:
int wert;
public:
explicit klasse( int w ){ wert = w; }
};
Die explizite Verwendung des Konstruktors ist danach weiterhin möglich, die impli-
zite Verwendung wird dann aber vom Compiler unterbunden:
klasse k1(5);
klasse k2 = 7; // Fehler, kein geeigneter Konstruktor vorhanden
In diesem Buch taucht diese Notation praktisch nicht auf, weil für die Grunddatenty-
pen die implizite Notation kürzer und besser verständlich ist. Gelegentlich wird eine
solche Notation aber in Initialisiererlisten verwendet18.
class klasse
{
private:
int wert;
public:
klasse( int w ): wert(w) {}
};
938
23.5 Der Lebenszyklus von Objekten
class aaa
{
public:
aaa( int x, int y ); // 1. Konstruktor von aaa
aaa( double d ); // 2. Konstruktor von aaa
};
class bbb
{
public:
bbb( char c ); // 1. Konstruktor von bbb
bbb(); // 2. Konstruktor von bbb
};
Die Klassen aaa und bbb dienen nur dazu, in einer weiteren Klasse lager eingelagert
zu werden. Sie haben jeweils zwei Konstruktoren, deren Implementierung uns hier
nicht interessiert.
Die Klasse lager enthält Objekte der Klassen aaa und bbb als eingelagerte Datenmem-
ber und darüber hinaus mehrere Konstruktoren, an denen wir unterschiedliche Sze-
narien zur Initialisierung studieren wollen: 23
class lager
{
private:
aaa eingelagertA;
bbb eingelagertB;
public:
lager( double dd, char cc );
lager( int xx, int yy );
lager( int zz );
lager( double dd, char cc, char *t, int zz = 0 );
};
939
23 Zusammenfassung und Überblick
Da es in unserem Beispiel für die Klasse bbb einen parameterlosen Konstruktor gibt,
kann auf eine explizite Initialisierung von eingelagertB verzichtet werden:
Nicht alle Parameter des Konstruktors müssen zur Initialisierung der eingelagerten
Klasse dienen. Einige der Parameter oder auch alle können auch im Konstruktor
selbst verwendet werden. Darüber hinaus ist auch die Verwendung von Default-
Argumenten (hier in der Klassendeklaration oben angegeben) möglich:
940
23.5 Der Lebenszyklus von Objekten
Die Initialisierung der eingelagerten Objekte erfolgt unabhängig von der Reihenfolge
der Aufrufe im Konstruktor in der Reihenfolge, in der die Objekte in der Klasse ange-
legt sind.
Eingelagerte Arrays von Klassen spielen insofern eine Sonderrolle, da sie nur ange-
legt werden können, wenn sie keinen Konstruktor oder einen explizit erstellten para-
meterlosen Konstruktor haben. Die Array-Elemente werden dann in aufsteigender
Reihenfolge mit dem parameterlosen Konstruktor initialisiert.
Ein gesonderter Aufruf eines speziellen Konstruktors für die einzelnen Array-Ele-
mente ist nicht möglich. Alle Elemente werden einheitlich durch den parameterlo-
sen Konstruktor initialisiert. Individuelle Initialisierungen müssen später
durchgeführt werden:
class lager
{
private:
bbb arrayB[100];
aaa eingelagertA;
bbb eingelagertB;
public:
lager( double dd, char cc );
};
941
23 Zusammenfassung und Überblick
Ich greife dazu das Beispiel der virtuellen Basisklassen aus Abschnitt 23.3.2, »Mehr-
fachvererbung«, noch einmal auf. Zunächst lege ich die Klasse ubsdevice an, die als
virtuelle Basisklasse aller folgenden Klassen dient:
class usbdevice{
public:
usbdevice( char * s ) { cout << "usbdevice initialisiert
durch " << s << '\n'; }
};
Die Klasse hat lediglich einen Konstruktor, in dem sie ausgibt, von wem sie instanzi-
iert wurde.
Im nächsten Schritt erstellen wir zwei Klassen scanner und printer, die usbdevice als
virtuelle Basisklasse verwenden:
Beide Klassen initialisieren ihre virtuelle Basisklasse usbdevice unter Nennung ihres
eigenen Namens.
Abschließend wird noch die Klasse kopierer von scanner und printer abgeleitet:
942
23.5 Der Lebenszyklus von Objekten
Diese Klasse muss im Konstruktor noch einmal usbdevice initialisieren. Die Basis-
klassen scanner und printer müssen nicht explizit initialisiert werden, da diese Klas-
sen ja über einen parameterlosen Konstruktor verfügen.
Wenn wir jetzt im Hauptprogramm die Klassen scanner, printer und kopierer
instanziieren,
int main()
{
scanner s;
cout << '\n';
printer p;
cout << '\n';
kopierer k;
cout << '\n';
}
Sie sehen, dass die virtuelle Basisklasse immer nur einmal durch die letzte abgelei-
tete Klasse initialisiert wird. Insbesondere findet bei der Instanziierung der Klasse
23
kopierer keine Initialisierung der Klasse usbdevice durch scanner oder printer statt,
obwohl die Konstruktoren von scanner und printer aktiviert werden.
Virtuelle Basisklassen werden von allen nicht virtuellen Basisklassen initialisiert. Die
Initialisierung erfolgt dabei ausgehend von der zu instanziierenden Klasse, wobei die
Verbindungen zu den Basisklassen in der Reihenfolge ihrer Nennung in der jeweili-
gen Klassendeklaration abgesucht werden.
23.5.8 Instanziierungsregeln
Mit der Instanziierung eines Objekts tritt man unter Umständen eine Lawine von Ini-
tialisierungen los, die in einer ganz bestimmten Reihenfolge durchgeführt werden,
943
23 Zusammenfassung und Überblick
um alle mit diesem Objekt durch Einlagerung oder Vererbung in Beziehung stehen-
den Objekte ebenfalls korrekt zu instanziieren.
Die Reihenfolge, in der die Konstruktoren der Objekte aufgerufen werden, ergibt sich
dabei aus ganz bestimmten Regeln. Diese Regeln wollen wir in diesem Abschnitt
noch einmal zusammenstellen:
Regel 1
왘 Wer ein Objekt instanziiert, ist für dessen korrekte Initialisierung zuständig. Zur
Initialisierung eines Objekts wird ein Konstruktor der zugehörigen Klasse gerufen.
Für Objekte, die keinen explizit erstellten Konstruktor haben, wird automatisch
ein parameterloser Default-Konstruktor erstellt.
왘 Bei der Beseitigung des Objekts wird der Destruktor der zugehörigen Klasse geru-
fen, sofern ein solcher explizit erstellt wurde.
Regel 2
왘 Ein Objekt wird in der Regel mit konkreten Parameterwerten initialisiert. Anhand
der zur Initialisierung verwendeten Parameter wird ein Konstruktor mit passen-
der Parametersignatur ausgewählt. In die Parametersignatur gehen nur Parame-
ter ohne Default-Werte ein. Ein Objekt kann nur dann ohne Angabe von
Parametern initialisiert werden, wenn es keinen oder einen explizit erstellten
parameterlosen Konstruktor hat.
왘 Destruktoren haben keine Parameter.
Regel 3
왘 Eine abgeleitete Klasse ist für die Initialisierung ihrer Basisklasse zuständig. Der
Konstruktor der abgeleiteten Klasse reicht dazu alle erforderlichen Parameter an
den Konstruktor der Basisklasse weiter. Auf eine explizite Initialisierung der Basis-
klasse kann nur verzichtet werden, wenn die Basisklasse keinen oder einen expli-
zit erstellten parameterlosen Konstruktor hat.
Regel 4
왘 Bei der Instanziierung eines Objekts einer abgeleiteten Klasse kommt der Konstruk-
tor der Basisklasse vor dem Konstruktor der abgeleiteten Klasse zur Ausführung.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
Regel 5
왘 Bei Mehrfachvererbung werden die Konstruktoren für die Basisklassen in der Rei-
henfolge ihres Vorkommens in der Deklaration der abgeleiteten Klasse aufgerufen.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
944
23.5 Der Lebenszyklus von Objekten
Regel 6
왘 Bei wiederholter Vererbung wird eine mehrfach vorhandene Basisklasse entspre-
chend der Häufigkeit ihres Vorkommens über ihre abgeleiteten Basisklassen initi-
alisiert.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
Regel 7
왘 Virtuelle Basisklassen werden, sofern eine explizite Initialisierung erforderlich ist,
von der letzten abgeleiteten Klasse einmalig initialisiert. Weitere bei den Vorfah-
ren gegebenenfalls vorhandene Initialisierungen werden nicht ausgeführt.
Regel 8
왘 Virtuelle Basisklassen werden vor allen nicht-virtuellen Basisklassen initialisiert.
Die Initialisierung erfolgt dabei ausgehend von der zu instanziierenden Klasse in
einer Tiefensuche im »Netzwerk der Vorfahren«, wobei die Verbindungen zu den
Basisklassen in der Reihenfolge ihrer Nennung in der jeweiligen Klassendeklara-
tion abgesucht werden.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
Regel 9
왘 Objekte sind für die Initialisierung ihrer eingelagerten Objekte zuständig. Sie rei-
chen dazu im Konstruktor entsprechende Initialisierungsparameter an das einge-
lagerte Objekt weiter. Auf die explizite Initialisierung eines eingelagerten Objekts
kann nur verzichtet werden, wenn die Klasse des eingelagerten Objekts keinen
oder einen explizit erstellten parameterlosen Konstruktor hat.
Regel 10
왘 Eingelagerte Objekte werden nach den Objekten in der Vererbungshierarchie 23
(Basisklassen, virtuelle Basisklassen), aber vor ihrer umschließenden Klasse initia-
lisiert.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
Regel 11
왘 Verschiedene eingelagerte Objekte einer Klasse werden in der Reihenfolge ihres
Vorkommens in der Klassendeklaration initialisiert.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
945
23 Zusammenfassung und Überblick
Regel 12
왘 Arrays von Objekten können nur erstellt werden, wenn die Objekte nicht explizit
initialisiert werden müssen. Arrays von eingelagerten Objekten werden wie
gewöhnliche eingelagerte Objekte initialisiert. Innerhalb des Arrays werden die
Objekte nach aufsteigenden Indizes initialisiert.
왘 Bei der Destruktion ist der Vorgang genau umgekehrt.
Wie Sie an den Regeln sehen können, bedarf es bei einer komplexen Klassenhierar-
chie einer eingehenden Analyse, um zu erkennen, in welcher Reihenfolge die Initiali-
sierung von Objekten erfolgt. Umso wichtiger ist die Forderung nach überschauba-
ren Konstruktoren und Destruktoren, die frei von Seiteneffekten sind.
class basis
{
virtual void virtFunk() {}
};
946
23.6 Typüberprüfung und Typumwandlung
Die virtuelle Funktion virtFunk in der Klasse basis wird nicht aktiv verwendet. Sie ist
dazu da, den Compiler dazu zu bringen, den Code zu generieren, der bei virtuellen
Funktionen für die Laufzeitüberprüfung verwendet wird. Eine Klasse mit mindestens
einer virtuellen Funktion nennt man auch polymorphe Klasse. Die hier folgenden
Überlegungen sind auch nur für polymorphe Klassen sinnvoll.
Wir wollen jetzt eine Funktion schreiben, die überprüft, ob zwei ihr übergebene
Objekte vom gleichen Typ sind. Dazu verwenden wir den typeid-Operator, der uns
eine Referenz auf den Laufzeittyp (eine Instanz der Klasse type_info, deklariert in der
Header-Datei typeinfo) eines Objekts liefert:
An der Schnittstelle der Funktion wird für beide Parameter formal der gleiche Typ
entgegengenommen. Zur Laufzeit können dort, neben Instanzen vom Typ basis, ver-
schiedene von basis abgeleitete Typen ankommen. In den Beispielen zu virtuellen
Funktionen haben wir damit schon gearbeitet.
Innerhalb der Funktion wird jetzt anhand der Laufzeitinformation erkannt, ob die
beiden Datentypen gleich sind, und das Ergebnis wird zurückgeliefert. Die Informa-
tion könnte dann wie folgt verwendet werden:
int main()
{
basis instanzBasis;
abgeleitet instanzAbgeleitet;
Über den Operator typeid können Sie mit der übergebenen Instanz von type_info
noch weitere Informationen erhalten. Die Klasse type_info hat z. B. auch ein Funkti-
onsmember name, das den Namen des Datentyps zurückgibt. Damit wäre z. B. der fol-
gende Aufruf möglich:
947
23 Zusammenfassung und Überblick
Die Methode gibt eine Zeichenkette zurück, die wir hier direkt ausgeben. Der Inhalt
der Zeichenkette für einen bestimmten Datentyp ist von der Implementierung des
Compilers abhängig. Bei mir erscheint als Ausgabe der oben stehenden Zeile Fol-
gendes:
class abgeleitet
Die genaue Ausgabe kann aber bei Ihrem Compiler auch anders sein.
Sie haben die Möglichkeit zur Typumwandlung schon an einem früheren Beispiel
kennengelernt, um die Rechnung mit Gleitkommazahlen zu erzwingen:
float x;
int a, b;
a = 10;
b = 3;
A x = (float) a/b;
In diesem Code wird in (A) der Datentyp der Variablen a von int in float umgewan-
delt – und zwar bevor a in die Berechnung eingeht. Dadurch wird erzwungen, dass
das Ergebnis aus einer Gleitkommadivision berechnet wird und nicht durch die Inte-
ger-Division.
Die Umwandlung von int in float wird von Compiler häufig selbständig durchge-
führt (implizit), sie ist auch problemlos möglich. Wird eine Funktion, die einen float-
Parameter erwartet, mit einem int-Wert aufgerufen, erfolgt die erforderliche Kon-
vertierung vom Compiler automatisch (implizit).
948
23.7 Typumwandlung in C++
Eine Umwandlung zwischen zwei feststehenden Typen, für die der Compiler eine
Regel kennt, nennt man Static Cast. Sie können einen solchen Static Cast explizit mit
der static_cast-Typumwandlung auslösen:
Die Schreibweise mit der Typangabe in spitzen Klammern <float> wird Ihnen später
an anderer Stelle in Kapitel 24, »Ergänzung und die C++-Standardbibliothek«, auch
noch einmal begegnen.
Funktionell besteht aber kein Unterschied zum allgemeinen Cast, den Sie bereits
kennen:
x = ( float ) a / b;
Der eigentliche Unterschied besteht darin, dass der Compiler bei einem static_cast
die Umwandlung ablehnt, wenn er selbst keine passende Umwandlungsregel hat.
Der Compiler kann daher bei einem static_cast noch warnen, wenn die Umwand-
lung aus seiner Sicht »ungewöhnlich« ist.
Manchmal ist es aber auch erforderlich, eine Typumwandlung zu erzwingen, die dem
Compiler implizit nicht möglich ist. Wir haben das auch schon gemacht, als wir
unterschiedliche Zeigertypen ineinander konvertiert haben. Wenn wir z. B. eine
Gleitkommazahl in ihrer internen Dualdarstellung byteweise ausgeben wollen, müs-
sen wir der Umwandlung etwas Nachdruck verleihen:
Der Compiler kann die gewünschte Umettikettierung eines Zeigers auf float zu
23
einem Zeiger auf unsigned char (also byte) implizit nicht durchführen.
Hier hilft auch kein static_cast, da der Compiler diese Umwandlung nicht in seinem
Repertoire hat. Hier müssen wir einen sogenannten Reinterpret Cast verwenden.
Dieser ermöglicht eine beliebige Neuinterpretation eines Datentyps. Damit können
wir die Gleitkommazahl auch auf dem Bildschirm ausgeben:
949
23 Zusammenfassung und Überblick
Der Reinterpret Cast entspricht dem klassischen Cast. Wir könnten also auch
schreiben:
Mit dem Reinterpret Cast haben wir aber die Möglichkeit, dem Compiler im Pro-
grammcode explizit unsere Absicht mitzuteilen: »Ja, ich weiß, dass dies keine stati-
sche Umwandlung ist, ich will diesen Datentyp explizit reinterpretieren«.
Eine weitere Möglichkeit zur feineren Beschreibung der eigenen Absichten bietet der
Const Cast. Mit dem Const Cast wird nur die Konstantheit eines Datentyps manipu-
liert, der Datentyp selbst bleibt unverändert. Auch dazu erhalten Sie ein Beispiel.
Funktionen greifen häufig nur lesend auf die Parameter zu, auch wenn Sie die Para-
meter an der Schnittstelle nicht als const deklariert haben. Soll eine solche Funktion
mit einem konstanten Parameter aufgerufen werden, führt das zu Typunverträglich-
keiten:
int main()
{
const char* s = "Test";
funktion( s );
}
Der Compiler akzeptiert dieses Programm nicht, da er sich weigert, einen Zeiger auf
eine konstante Zeichenkette an eine Funktion zu schicken, die den Parameter nicht
explizit als const qualifiziert. Die Funktion gibt damit ja zu verstehen, dass sie den
Inhalt des Strings möglicherweise ändert. In diesem speziellen Fall wäre der Aufruf
aber auch mit einer konstanten Zeichenkette sinnvoll und unproblematisch. Hier
kann man die strenge Typüberprüfung durch einen Const Cast beim Funktionsauf-
ruf aufweichen:
Auch hier wäre die Verwendung eines klassischen Casts möglich gewesen:
funktion( (char*) ( s ) );
950
23.7 Typumwandlung in C++
Der Const Cast hat aber auch hier wieder den Vorteil der größeren Genauigkeit. Er
sagt aus, dass Sie nur die Konstantheit ändern wollen, der Datentyp selbst aber
unverändert bleiben soll.
Beachten Sie aber, dass der Cast immer nur die Typüberprüfung abschwächt und Sie
dem Compiler damit mitteilen, dass er den Aufruf trotz seiner Bedenken ausführen
soll. Er macht damit nicht aus einer unveränderlichen Größe eine veränderliche. Der
Compiler kann z. B. konstante Werte auch in einem Speicherbereich abgelegt haben,
in dem das Programm keine Schreibrechte hat. Erfolgte dann doch ein schreibender
Zugriff aus der Funktion, hätte das typischerweise einen Absturz zur Folge.
23
951
Kapitel 24
Die C++-Standardbibliothek und
Ergänzung
Wenn du einen Garten und dazu noch eine Bibliothek hast, wird es dir
an nichts fehlen.
– Cicero
Wenn Sie das Buch bis zu dieser Stelle durchgearbeitet haben und vielleicht auch
schon mit anderen Programmiersprachen gearbeitet haben, werden Sie in C und C++
vielleicht einige Punkte vermisst haben. Auch wenn Sie keine anderen Sprachen zum
Vergleich heranziehen können, hatten Sie möglicherweise an manchen Stellen die
Vorstellung, dass Ihnen die Sprache mehr Unterstützung bei der Implementierung
bieten sollte.
Zu den Funktionen, die die meisten Entwickler in C++ vermissen, gehört Folgendes:
953
24 Die C++-Standardbibliothek und Ergänzung
Einige Programmiersprachen, wie z. B. Ruby oder Python, stellen diese Funktionen voll-
ständig oder zumindest teilweise aus dem normalen Sprachumfang zur Verfügung.
Zwei Themen der C++-Programmierung habe ich bisher ausgespart, ohne deren
Kenntnis die C++-Standardbibliothek nicht verwendet werden kann. Diese beiden
Themen werde ich Ihnen zuerst präsentieren, bevor wir die Standardbibliothek aktiv
nutzen werden.
Wie Sie bei der Diskussion von Datenstrukturen, abstrakten Datenstrukturen und
auch Klassen gesehen haben, dreht sich ein großer Teil der Softwareentwicklung
darum, Daten und Algorithmen einmal zu erstellen und dann für ähnliche Situatio-
nen wiederverwendbar zu machen.
Sie wissen mittlerweile auch, wie Sie mit Klassenhierarchien und dem dynamischen
Binden nachträglich erweiterbare Systeme schaffen können.
Hier handelt es sich um mächtige Techniken. Das Problem, dass wir gleichartige
Funktionen und Datenstrukturen für unterschiedliche Datentypen bisher immer
wieder neu implementieren müssen, werden wir jetzt aber auf andere Weise an-
gehen.
Ich werde Ihnen das zu lösende Problem zuerst noch einmal anhand eines Beispiels
verdeutlichen. Dazu werde ich eine Funktion zum Tauschen von Werten verwenden.
Im Verlauf des Buches sind Ihnen schon mehrere Versionen der Funktion tausche
begegnet.
954
24.1 Generische Klassen (Templates)
Die Funktionen unterscheiden sich dabei jeweils nur durch den verwendeten Daten-
typ (A, B und C). Das implementierte Verhalten ist in allen drei Varianten identisch,
sowohl für die elementaren Datentypen int und double als auch für eine selbst
erstellte Datenstruktur wie punkt. Obwohl in allen Versionen der Quellcode bis auf
die Namen der verwendeten Typen identisch ist, müssen wir die Funktion jedes Mal
neu implementieren.
Sie könnten auf eine Lösung aus der C-Programmierung zurückgreifen und Makros
für den Präprozessor schreiben. Wegen der beschriebenen Schwierigkeiten und Risi-
ken der Makroprogrammierung ist das allerdings ein aufwendiges und fehlerträchti-
ges Unterfangen.
In C++ gibt es zur Lösung dieses Problems sogenannte Schablonen oder Templates,
die Funktionen und Klassen nach Vorgaben generieren können.
Eine Schablone ist keine Klasse oder Funktion, sondern eine leere Hülle, aus der spä-
ter im Generierungsprozess ein entsprechendes Element entsteht. Um dem Compi-
ler eine Schablone anzuzeigen, wird das Schlüsselwort template verwendet.
Auf das Schlüsselwort template folgt in spitzen Klammern das Schlüsselwort type-
name1, gefolgt von einem Platzhalter für den Datentyp, der in der Schablone verwen-
det werden soll. Der Name des Platzhalters unterliegt den üblichen Namensregeln,
typischerweise wird hier aber T für Typ verwendet.
Ich demonstriere Ihnen die Erstellung einer Schablone direkt am Beispiel der Funk-
tion tausche:
1 Ursprünglich ist hier anstelle von typename das Schlüsselwort class verwendet worden. Das ist
auch weiter möglich.
955
24 Die C++-Standardbibliothek und Ergänzung
Zur Erstellung einer Schablone wird dem Compiler zuerst angezeigt, dass die fol-
gende Funktion als Schablone verwendet werden soll (A). Das Schlüsselwort template
weist darauf hin, dass es sich hier nicht um fertigen Code handelt. Die Schablone soll
über den Typ mit dem Namen T aufgebaut werden. T steht hier als Platzhalter für den
noch unbekannten, später verwendeten Datentyp. Es folgt die eigentliche Schablone
der Funktion tausche (B). Die Funktion soll als Parameter zwei Referenzen auf Variab-
len des Datentyps T erhalten. Innerhalb des Funktionsblocks wird die Funktion defi-
niert, ebenfalls unter Zuhilfenahme des Platzhalters T.
Mit der Erstellung der Schablone wird noch kein Code kompiliert. Erst wenn eine
Template-Funktion erstmalig für einen bestimmten Datentyp aufgerufen wird,
generiert der Compiler aus dem Template eine echte Funktion.
void main()
{
double wert1 = 1.0, wert2 = 2.0;
A tausche<double>( wert1, wert2 );
}
Zur Verwendung des Templates geben wir an (A), für welchen Datentyp die Funktion
generiert werden soll. Vor der Kompilierung wird der Platzhalter dann durch den tat-
sächlichen Datentyp ersetzt.
956
24.1 Generische Klassen (Templates)
Wir können die Funktion jetzt auch für andere Datentypen verwenden:
Hier haben wir jeweils explizit angegeben, für welchen Typ die Schablone verwendet
werden soll.
Welcher Datentyp verwendet werden soll, kann der Compiler in vielen Fällen auch
implizit anhand der übergebenen Argumente entscheiden:
int x1 = 2, x2 = 2;
tausche( x1, x2 );
Wir haben nun eine universell einsetzbare Funktion tausche, die wir für alle Datenty-
pen verwenden können.
Ich werde das Beispiel der Funktion tausche jetzt erweitern und eine komplette
Klasse generieren, die einen Stack für beliebige Datentypen erstellt. Dazu sehen wir
uns zuerst noch einmal einen Stack für float-Werte an:
class floatStack
{
private:
float stck[100];
int top;
public:
floatStack() { top = 0; }
int push( int element );
float pop();
int isEmpty(){ return top == 0; }
};
957
24 Die C++-Standardbibliothek und Ergänzung
float floatStack::pop()
{
if( top > 0 )
top--;
return stck[top];
}
Die Klasse implementiert einen Container, auf dem wir mit push- und pop-Operatio-
nen bis zu 100 float-Elemente verwalten können. Diese Klasse ist jetzt zwar hilfreich,
aber immer noch nur recht eingeschränkt nutzbar. Wünschenswert wäre es, wenn
wir den Datentyp und die Maximalzahl der Elemente im Stack beliebig konfigurieren
könnten. Dies erreichen wir durch die Verwendung eines Templates:
958
24.1 Generische Klassen (Templates)
Vielleicht müssen Sie bei der jetzt entstandenen Klasse zweimal hinschauen. Aller-
dings ist nicht mehr passiert, als dass ich den Namen des verwendeten Datentyps
float und die Zahl 100 durch die Parameter T und SIZE ersetzt habe. Zusätzlich habe
ich diese Werte über eine »Schnittstelle« nach außen bekannt gemacht, wie hier bei
der Methode pop:
Das Schlüsselwort template zeigt wie schon oben an, dass es sich hier um eine Schab-
lone handelt. Templates sind in der Anwendung vergleichbar mit Makros und wer-
den wie Makros üblicherweise in Header-Dateien deklariert. Wir speichern das
Template also in der Datei stack.h, um es einzusetzen. Dazu erstellen wir eine Datei
test.cpp, in der wir die Header-Datei mit Template-Beschreibung inkludieren
#include "stack.h"
datum dat;
A stack<datum, 10 >dstack;
D while( !dstack.isEmpty() )
{
E dat = dstack.pop();
cout << "pop: " << dat << '\n';
}
959
24 Die C++-Standardbibliothek und Ergänzung
Mit der Anweisung in (A) wird der Stack für die Klasse datum2 mit einer Kapazität von
zehn Elementen instanziiert und mit dem Namen dstack versehen. Innerhalb einer
Schleife (B) werden nun fünf Daten auf den Stack gelegt (C). Im Anschluss werden – so
lange, bis der Stack leer ist (D) – die Elemente geholt (E) und ausgegeben. Das führt zu
folgender Ausgabe:
push: 1.1.2015
push: 2.1.2015
push: 3.1.2015
push: 4.1.2015
push: 5.1.2015
pop: 5.1.2015
pop: 4.1.2015
pop: 3.1.2015
pop: 2.1.2015
pop: 1.1.2015
Der Stack arbeitet einwandfrei und kann nun für beliebige Datentypen universell
eingesetzt werden. Sie können z. B. nun einen Stack für int-Werte einrichten, die
Kapazität auf drei Einträge begrenzen und folgenden Code ausführen:
stack<int, 3 >istack;
while( !istack.isEmpty() )
{
int i = istack.pop();
cout << "pop: " << i << '\n';
}
2 Ich nehme dabei an, dass Ihnen die Implementierung von datum aus den früheren Kapiteln
bekannt ist.
960
24.1 Generische Klassen (Templates)
push: 1
push: 2
push: 3
push: 4
push: 5
pop: 3
pop: 2
pop: 1
Die Template-Funktion lässt sich nun mit allen Datentypen aufrufen, mit denen sie
sich nach Ersetzung des Platzhalters auch übersetzen lässt. In diesem Fall ist die Vor-
aussetzung nur das Vorhandensein eines korrekten Copy-Konstruktors, da die Werte
per Kopie übergeben werden.
Bei der Definition der Templates muss mit besonderer Sorgfalt vorgegangen werden.
Zum Zeitpunkt der Erstellung ist ja noch unbekannt, für welche Klassen der Generie-
rungsprozess zukünftig verwendet werden soll. Insbesondere sollten Templates
immer »geschlossen« arbeiten und keine Seiteneffekte haben (Verwendung globaler
Variablen etc.). Über solche Seiteneffekte können ansonsten nicht zusammenhän-
gende Klassen ungewollt voneinander abhängig werden.
Verwendet ein Template globale Variablen, können sich verschiedene, aus dem glei-
chen Template generierte Klassen gegenseitig beeinflussen, ohne dass dieser Zusam-
menhang unmittelbar sichtbar ist. Sie haben inzwischen vermutlich selbst erfahren,
wie schwierig die Fehlersuche in ähnlichen Fällen sein kann.
Beachten Sie auch, dass bei jeder Verwendung des Templates für unterschiedliche
Klassen neuer, eigenständiger Code erzeugt wird. Insofern besteht natürlich weiter-
hin das Problem der Codevervielfachung. Dieses Problem ist allerdings ein rein tech-
nisches Mengenproblem. Das viel gravierendere Konsistenzproblem tritt hier nicht
mehr auf3.
Auch wenn diese Hinweise jetzt vielleicht abschreckend wirken, generell erhöhen
Templates die Qualität. Die Parameter sind zur Kompilierungszeit bekannt, und es 24
kann bei der Kompilierung eine Typüberprüfung stattfinden.
3 Bei der manuellen Vervielfältigung von Code werden alle Fehler und Unzulänglichkeiten z. B. in
der Performance ebenfalls vervielfältigt. Bei der Korrektur und Wartung des entsprechenden
Codes müssen dann alle Duplikate konsistent gehalten werden. Wenn Sie ein solches Problem in
der Praxis erleben, werden Sie Templates besonders zu schätzen wissen.
961
24 Die C++-Standardbibliothek und Ergänzung
Bei realen Programmsystemen gilt das natürlich auch, nur kann die Fehlerbehand-
lung hier nicht einfach ignoriert werden, wie wir es hier getan haben.
Einer Funktion fällt es meist leicht, eine Ausnahme oder Fehlersituation zu erken-
nen. Typische Ausnahmesituationen sind:
왘 Division durch 0
왘 Bereichsüberschreitung bei einem Zugriff auf ein Array
왘 Versuch, aus einer nicht vorhandenen Datei zu lesen
왘 Fehlschlag bei der Speicherallokation mit malloc oder new
Eine Funktion, die Daten aus einer Datei einlesen soll, deren Name ihr übergeben
wird, stellt leicht fest, falls z. B. die Datei nicht gelesen werden kann.
Die Funktion, in der der Fehler auftritt, hat aber in den meisten Fällen nicht genü-
gend Informationen, um zu entscheiden, wie mit dem erkannten Fehler umgegan-
gen werden soll.
Soll das Einlesen noch einmal mit einer anderen Datei versucht werden, deren Name
vom Benutzer erfragt wird? Muss das Programm sofort abgebrochen werden, oder
kann das Problem vielleicht sogar ignoriert werden?
Dies sind Fragen, die typischerweise an einer Stelle im Programm beantwortet wer-
den müssen, die den Kontext kennt und diese Frage entscheiden kann.
Dabei können zwischen der Funktion, die eine solche Frage entscheiden kann, und
der Funktion, in der das Problem auftritt, durchaus weitere Funktionsaufrufe liegen,
besonders wenn eine Bibliothek verwendet wird.
962
24.2 Ausnahmebehandlung (Exceptions)
Für die Frage, wie mit dem Problem umgegangen werden muss, kann es keine allge-
meine Lösung geben, diese Frage muss in jedem Programm anhand der Anforderun-
gen neu beantwortet werden. Die Anforderungen an Hilfsprogramme sind anders als
an ein Computerspiel, eine Anwendungssoftware oder die Software eines Linienflug-
zeugs.
Das, was aber vereinheitlicht werden kann, ist der Weg, auf dem die Information über
das Auftreten eines Problems transportiert wird – von der Stelle der Erkennung zu
der Stelle, an der das Problem behandelt werden kann.
C++ bietet mit der Ausnahmefallbehandlung bzw. dem Exception Handling die Mög-
lichkeit, Fehlersituationen über Aufrufhierarchien hinweg zu behandeln.
Die Funktion, die einen Ausnahmefall meldet, beendet damit die reguläre Ausfüh-
rung. Das Melden wird auch als Werfen einer Ausnahme bezeichnet. Die gemeldete
Ausnahme wird dann über die Aufrufhierarchie nach »oben« weitergereicht, bis sich
eine Funktion für zuständig erklärt und angemessene Maßnahmen ergreift. Die
Funktion, die die geworfene Ausnahme aufgreift, kann die sein, die den Werfer aufge-
rufen hat, sie kann aber auch mehrere Funktionsaufrufe entfernt liegen.
Ein solches Verfahren wird dadurch erschwert, dass in C++ mit seinen Destruktoren
und Konstruktoren an verschiedenen Stellen Code ausgeführt wird, der bei Abbruch
einer Funktion aufgrund einer Ausnahmebehandlung auch korrekt ausgeführt wer-
den muss, wenn das Programm später wieder erfolgreich weitergeführt werden soll.
963
24 Die C++-Standardbibliothek und Ergänzung
try
{
funktion(); // wirft Ausnahme im Fehlerfall mit throw
// evtl. weitere Funktionen bei fehlerfreier Ausfuehrung
}
catch (Datentyp)
{
// Fehlerbehandlung
}
int eingabe()
{
A int i;
B cout << "Bitte eine positive Zahl eingeben: ";
cin >> i;
C
D if( i < 0 )
throw 'N';
E return i;
}
Die Funktion fordert vom Benutzer die Eingabe einer ganzen Zahl (A) und liest eine
Zahl von der Standardeingabe (B). Ist keine positive Zahl eingegeben worden (C),
dann wird mit throw eine Ausnahme geworfen und die Ausführung der Funktion
beendet (D). Ansonsten wird das Ergebnis der Eingabe zurückgeliefert (E), die Funk-
tion war erfolgreich.
Wir sehen uns auch gleich die Verwendung der Funktion innerhalb eines try-catch-
Blocks an:
int main_()
{
A try {
B int i = eingabe();
964
24.2 Ausnahmebehandlung (Exceptions)
In dem Programm wird innerhalb des try-Blocks (A) die Funktion eingabe aufgerufen
(B). Gibt der Benutzer einen positiven Wert ein, liefert die Funktion das Ergebnis
zurück, und der Erfolg wird angezeigt (C). In diesem Fall wird der try-Block bei (D)
erfolgreich beendet. Der gesamte catch-Block (E) wird übersprungen, und das Pro-
gramm endet mit (G) und liefert folgendes Ergebnis:
Anders ist der Verlauf, wenn der Benutzer eine negative Zahl eingibt. In diesem Fall
wirft die Funktion eingabe eine Ausnahme:
throw 'N';
Die Ausnahme, die hier geworfen wird, ist vom Typ char. Sie beendet die Ausführung
der Funktion auf der Stelle. Der Funktionscode nach dem throw-Befehl wird nicht
mehr ausgeführt. In der main-Funktion wird diese Ausnahme dann mit der catch-
Anweisung gefangen (D). Innerhalb des catch-Blocks wird die gefangene Ausnahme
ausgewertet (E) und behandelt (F).
In diesem Beispiel war die geworfene Ausgabe vom Typ char. Die Ausnahme ist von
einem entsprechenden catch-Block für den Datentyp char gefangen worden. In
einem try-catch-Block können mehrere catch-Anweisungen stehen. Wird eine Aus-
nahme geworfen, werden alle catch-Anweisungen nacheinander daraufhin geprüft,
ob der zu fangende Datentyp zum geworfenen Datentyp passt. Ist das der Fall, wird
der catch-Block behandelt. Wenn kein passender catch-Block vorhanden ist, wird die
965
24 Die C++-Standardbibliothek und Ergänzung
Generell können zum Werfen von Ausnahmen alle Datentypen herangezogen wer-
den. Es bietet sich allerdings an, dazu Instanzen geeigneter Klassen zu verwenden.
#include <exception>
using namespace std;
Die für Ausnahmen verwendete Klasse exception hat die folgende Deklaration:
class exception {
public:
exception();
exception( const exception& );
explicit exception(const char * const &);
exception& operator= ( const exception& );
virtual ~exception() throw( );
virtual const char* what() const throw( );
}
Insbesondere hat die Klasse einen Konstruktor, dem eine Zeichenkette mit einer
Beschreibung der Ausnahme übergeben werden kann. Diese Beschreibung kann bei
einer Instanz der Klasse mit der what-Funktion auch wieder abgefragt werden.
Für die Klasse exception ist auch bereits eine ganze Hierarchie definiert (siehe Abbil-
dung 24.1).
In der Hierarchie finden sich passende Typen für einen breiten Bereich möglicher
Ausnahmezustände. Die Klasse runtime_error spezifiziert dabei z. B. eine Ausnahme,
die erst zur Laufzeit erkannt werden kann. Wir können die Ausnahme in unserem
Beispiel der Klasse range_error zuordnen, die für Bereichsüberschreitungen zur Lauf-
zeit verwendet wird.
Für eine Erläuterung der Bedeutung aller Ausnahmeklassen verweise ich Sie auf die
Dokumentation Ihres Compilers.
Wenn Sie weitere Ausnahmefälle spezifizieren wollen, können Sie natürlich auch an
geeigneter Stelle der Hierarchie von diesen Klassen ableiten und eigene Ausnahme-
typen definieren oder auch eine komplett eigene Klassenhierarchie zu diesem Zweck
erstellen. Die Klasse exception bietet aber einen guten Ausgangspunkt.
966
24.2 Ausnahmebehandlung (Exceptions)
exception
domain_error
Ich werde das gerade vorgestellte Beispiel unter Verwendung der exception-Klasse
noch einmal neu bauen. Mit Verwendung von exception-Klassen aus der Standardbi-
bliothek sieht das Beispiel dann folgendermaßen aus:
#include <iostream>
#include <exception>
using namespace std;
int eingabe() {
int i;
cout << "Bitte eine positive Zahl eingeben: ";
cin >> i;
if( i < 0 )
A throw out_of_range( "Negativer Wert eingegeben" ); 24
return i;
}
int main()
{
try {
int i = eingabe();
967
24 Die C++-Standardbibliothek und Ergänzung
Die Funktion eingabe wirft nun eine Ausnahme vom Typ out_of_range. Beim Werfen
der Ausnahme wird dem Konstruktor zusätzlich eine Zeichenkette mitgegeben (A).
Die geworfene Ausnahme wird in (C) gefangen. Bei ihrer Verarbeitung wird auch die
Zeichenkette wieder ausgegeben, die bei der Instanziierung der Ausnahme überge-
ben worden war (C). Sie sehen in dem erweiterten Beispiel auch, dass auf einen try-
block auch mehrere Handler folgen können, in denen unterschiedliche Typen von
Ausnahmen gefangen werden (B, D). Wir wissen in unserem Beispiel, das keine Aus-
nahmen von Typ exception geworfen werden, daher ist der zweite catch-Block hier
ohne Funktion, soll aber das Prinzip demonstrieren. Bei der Anordnung der catch-
Blöcke ist wichtig, dass die spezialisierten Klassen zuerst aufgeführt werden (B). Gibt
es mehrere Handler zu einem try-Block, wird die geworfene Ausnahme dem ersten
passenden Block zugeordnet.
Ich werde Ihnen jetzt ein etwas komplexeres Beispiel der Ausnahmebehandlung zei-
gen, das den Verlauf des Kontrollflusses bei der Verwendung von Ausnahmen noch
einmal demonstriert. Das Beispiel hat keinen tieferen Sinn, sondern soll lediglich
eine zweistufige Ausnahmebehandlung demonstrieren. In dem Beispiel baue ich
eine kleine Funktionshierarchie auf, in der eine Funktion test1 eine Funktion test2
und diese wiederum eine Funktion test3 ruft. Der Parameter i der Funktionen dient
nur zum Zählen der Funktionsaufrufe und wird innerhalb der Funktionshierarchie
von Funktion zu Funktion weitergereicht:
968
24.2 Ausnahmebehandlung (Exceptions)
test3( i );
}
Die Funktion test3 wirft nun eine Ausnahme. Sie verwendet dazu zwei unterschied-
liche Klassen aus der C++-exception-Klassenhierarchie:
Das dazugehörige Hauptprogramm ruft die Funktion test1 in einer Schleife auf und
fängt die Ausnahmen, die aus der Hierarchie geworfen werden:
int main()
{
int i;
for( i = 0; i < 4; i++ )
{
try{
cout << "Aufruf von test1(" << i << ")\n"; 24
test1( i );
cout << "Kein Ausnahmefall aufgetreten\n";
}
catch( exception e )
{
cout << "main faengt: " << e.what() << '\n';
}
}
}
969
24 Die C++-Standardbibliothek und Ergänzung
In der vierfachen Schleife (A) wird der jeweils reguläre Code ausgeführt. Der reguläre
Code ist der Code im try-Block. Tritt dabei kein Ausnahmefall ein, wird der Code im
try-Block vollständig ausgeführt und der Code im catch-Block ignoriert. Tritt jedoch
in der Ausführung der test-Funktionen ein Ausnahmefall ein, kommt die Funktion
test nicht zurück. Stattdessen geht es in dem catch-Block weiter. Der catch-Block
fängt beide Arten von Ausnahmen, die in der Funktion test3 geworfen werden, da es
sich auch bei der Klasse runtime_error (durch Vererbung) um eine Klasse vom Typ
exception handelt.
In Abbildung 24.2 sehen Sie, was wir von dem Programm zu erwarten haben:
class exception
{
};
Im Hauptprogramm wird über die Funktion test1 mittelbar die Funktion test3 geru-
fen. Die von der Funktion geworfenen Ausnahmen führen den Kontrollfluss unter
Umgehung der dazwischenliegenden Funktionen direkt in den catch-Block des
Hauptprogramms zurück. Diesem catch-Block wird die Exception in Form eines
Objekts vom Typ exception übergeben.
970
24.2 Ausnahmebehandlung (Exceptions)
Der hier verwendete Handler (catch-Block) fängt alle Ausnahmen vom Typ exception
und deren abgeleitete Klassen.
Wichtig ist hier zu erwähnen, dass die auf dem Stack liegenden in den Funktionen
test1, test2 und test3 angelegten Objekte beim Zurückfahren des Stacks ordnungs-
gemäß durch Aufruf ihrer Destruktoren beseitigt werden.
Sie sollten die Ausnahmebehandlung immer nur zur gezielten Behandlung von Aus-
nahmefällen verwenden und nicht versuchen, hier weiteren Programmablauf unter-
zubringen.
971
24 Die C++-Standardbibliothek und Ergänzung
catch( runtime_error e )
{
A cout << "test2 faengt und wirft weiter: " << e.what() << '\n';
throw;
B }
}
Sie können die modifizierte Version der Funktion ohne weitere Änderungen in das
Programm einbauen. Die geänderte Version der Funktion macht dabei nicht mehr,
als die gefangene Ausnahme zu betrachten (A). Sie behandelt die Ausnahme nicht,
sondern entscheidet sich dazu, sie zur Behandlung einfach weiterzugeben (B). Mit
dem Befehl throw ohne Parameter wird die gefangene Ausnahme erneut weiterge-
worfen, sodass die darüberliegende Hauptfunktion sie fangen und behandeln kann.
Den Kontrollfluss stelle ich hier nicht noch einmal dar, sondern zeige nur noch das
Ergebnis:
Selbst wenn Ihre eigenen Programme keine Ausnahmen werfen, kommen Sie in der
Regel nicht darum herum, Exceptions zu fangen, da die C++-Standardbibliothek in
definierten Ausnahmefällen Exceptions erzeugt, die Sie dann behandeln müssen.
972
24.4 Iteratoren
24.4 Iteratoren
Sie haben schon gelernt, dass es je nach Art der zur Speicherung verwendeten Daten-
struktur unterschiedliche Möglichkeiten gibt, sich in einer Datenstruktur »fortzube-
wegen«. In einem Array können Sie wahlfrei auf jeden Index direkt zugreifen. In
einer einfach verketteten Liste können Sie sich nur vorwärtsbewegen. In einer dop-
pelt verketteten Liste können Sie sich vor- und zurückbewegen.
int i;
In einem Array können Sie sich aber auch mit einem Zeiger bewegen:
int *p;
listenelement *p; 24
In den Beispielen fungieren i und p als Mittel, um sich durch die Datenstruktur zu
bewegen.
973
24 Die C++-Standardbibliothek und Ergänzung
Auch wenn sich Iteratoren für alle Datenstrukturen ähnlich verhalten sollen, können
sie nicht überall gleich sein. Sie wissen, dass der Zugriff auf ein Array flexibler ist als
der auf eine Liste. In einem Array können Sie frei zugreifen, in einer vorwärts verket-
teten Liste können Sie sich nur vom Start zum Ende bewegen. Die vorwärts verket-
tete Liste ist also der kleinste gemeinsame Nenner.
Um optimal mit den jeweiligen Datenstrukturen (Arrays, Listen und andere) arbeiten
zu können, gibt es daher unterschiedliche Iteratoren, die in der Verwendung aber alle
sehr ähnlich sind.
Uns interessieren vorerst zwei Iteratoren besonders:
Bidirektionale Iteratoren (p, q) können wie Zeiger in einer doppelt verketteten Liste
verwendet werden. Man kann:
왘 einen Iterator auf das erste oder letzte Element der Liste setzen
왘 einen Iterator inkrementieren oder dekrementieren (p++, p--)
왘 über einen Iterator auf ein Objekt zugreifen (*p)
왘 über einen Iterator wie mit einem Zeiger auf ein Daten- und Funktionsmember
zugreifen (p->...)
왘 Iteratoren bezüglich Gleichheit und Ungleichheit vergleichen
974
24.4 Iteratoren
Die letzten vier Punkte können Sie für bidirektionale Zugriffe natürlich auch nachbil-
den, wie ein wahlfreier Zugriff in einer Liste generell ja auch über sequenziellen
Zugriff nachgebildet werden kann. Effizient ist das aber nicht.
Iteratoren dienen auch dazu, fortlaufende Bereiche innerhalb eines Containers fest-
zulegen. Bereiche werden definiert, indem man zwei Iteratoren als Bereichsgrenzen
festlegt, einen an der Anfangsposition, den anderen an der Endposition.
Der gewählte Teilbereich versteht sich dann immer einschließlich des Anfangs und
ohne die Endposition:
iterator1 iterator2
Ausgewählter Bereich
Für einen so festgelegten Bereich wird dann die Notation (iterator1, iterator2) ver-
wendet.
begin end
Container
rend rbegin
975
24 Die C++-Standardbibliothek und Ergänzung
Wichtig ist in diesem Zusammenhang, dass die Iteratoren end und rend außerhalb
des Gültigkeitsbereichs liegen. Ein Zugriffsversuch über diese Iteratoren führt zu
einem Fehler!
Um die Klasse string verwenden zu können, binden Sie die entsprechende Header-
Datei sowie den Namespace std ein:
#include <string>
using namespace std;
Die einfachste Form, einen string zu erzeugen, ist der parameterlose Konstruktor:
string s
Dieser String ist, wie zu erwarten, leer. Die Klasse string hat aber auch eine Vielzahl
von Konstruktoren, die verwendet werden können, um die Instanzen direkt bei der
Erzeugung mit Inhalt zu versehen:
976
24.5 Strings (string)
Wie bei den schon bekannten Zeichenketten erfolgt die Zählung der Positionen
immer ab 0.
Beachten Sie dabei, dass sich die Funktionalität leicht unterscheidet. Während die
Konstruktion aus einem Buffer bei s4 in dieser Notation die ersten drei Zeichen über-
nimmt, erfolgt die Konstruktion in dieser Form bei einem übergebenen String wie in
s8 ab Position 3.
string s;
cout << "Eingabe: ";
cin >> s;
cout << "Ausgabe: " << s << '\n';
977
24 Die C++-Standardbibliothek und Ergänzung
Eingabe: 42
Ausgabe: 42
24.5.2 Zugriff
Auf die Attribute eines Strings kann auf verschiedene Arten zugegriffen werden. Um
die aktuelle Länge eines Strings zu erhalten, können Sie seine Memberfunktion size
aufrufen:
string s("C++-Standardbibliothek");
string::size_type size;
size = s.size();
cout << "Laenge von: \"" << s << "\" ist " << size << endl;
Sie können den Datentyp bei Bedarf auf ein int oder unsigned int casten.
string s = "12345";
int l = (int) s.size();
int len = s.size();
Ob ein String leer ist, kann anhand seiner empty-Methode geprüft werden.
string s;
if (s.empty())
cout << " String ist leer" << '\n';
Auch auf die einzelnen Zeichen eines Strings können Sie wie in einem Zeichen-Array
zugreifen:
A string s ("abcdefg");
978
24.5 Strings (string)
Nach der Initialisierung des Strings (A) wird dem char c der Wert des Zeichens an
Position 7 zugewiesen (B) und die Länge des Strings in l abgelegt (C). In der folgenden
Schleife (D) werden dann alle Zeichen im String durch das ermittelte char 'g' ersetzt
(E). Die Ausgabe ergibt dann wie erwartet:
ggggggg
Die Funktionalität wird durch die Überladung des operator[] bereitgestellt, mit dem
dieser wahlfreie Zugriff ermöglicht wird. Wie auch von Zeichenketten gewohnt, ist
mit dieser Art des Zugriffs keine Bereichsprüfung verbunden. Ein Zugriff außerhalb
des gültigen Bereichs kann daher die gewohnten unabsehbaren Folgen haben, in der
Regel einen Absturz des Programms, wie Sie ihn von den bereits bekannten Zeichen-
ketten kennen.
Strings stellen allerdings auch einen Zugriff mit Bereichsprüfung zur Verfügung –
und zwar mit der Memberfunktion at:
string s ("abcdefg");
char c = s.at(6);
int l = s.size();
for (int i = 0; i< l; i++)
s.at(i) = c;
cout << s << '\n';
Die Funktionalität ist die gleiche wie bei der Verwendung des operator[], allerdings
erfolgt jeweils eine Bereichsprüfung. Erfolgt ein Zugriff außerhalb des gültigen
Bereichs, wird eine out_of_range-Exception geworfen. Die Klasse exception und die
zugehörige Hierarchie kennen Sie aus Abschnitt 24.2, »Ausnahmebehandlung
(Exceptions)«.
Auf Strings kann auch über Iteratoren zugegriffen werden. Als Beispiel legen wir
einen String s und einen Iterator it an:
979
24 Die C++-Standardbibliothek und Ergänzung
string s( "abcdefg" );
string::iterator it;
Den Iterator können wir nun so initialisieren, dass er auf den Anfang des Strings
zeigt:
it = s.begin();
Natürlich können wir ihn auch hinter das letzte Zeichen setzen:
it = s.end();
Denken Sie aber daran, dass dieser Iterator bereits außerhalb des gültigen Bereichs
zeigt! Ein Zugriff ist hier nicht möglich.
Mit einem Iterator können Sie wie mit einem Zeiger Zeichen für Zeichen durch den
String gehen:
Der Stringiterator ist ein Random-Access-Iterator. Sie können mit ihm wahlfrei auf
alle Zeichen des Strings zugreifen. Auch dazu einige Beispiele:
Die Iteratoren können auch mit <, <=, > und >= verglichen werden. Damit lässt sich
feststellen, welcher der Iteratoren auf eine Position weiter vorne oder hinten im
String zeigt.
Generell können Sie mit einem Iterator, wie wir ihn oben angelegt haben, auch rück-
wärts durch einen String iterieren. Als elegantere Lösung gibt es dafür aber den Rück-
wärtsiterator:
980
24.5 Strings (string)
Start und Ende werden hier mit rbegin und rend ermittelt.
Obwohl mit ++ hochgezählt wird, läuft der Iterator rückwärts durch den String.
Beachten Sie dabei, dass rbegin den Iterator auf das letzte Zeichen setzt und nicht wie
end dahinter. Entsprechendes gilt für rend und begin.
begin end
String
rend rbegin
An dieser Stelle noch einmal die Erinnerung, dass end und rend bereits außerhalb des
gültigen Bereichs zeigen und ein Zugriff auf diese Positionen zu einem Fehler führt.
24.5.3 Manipulation
Die Möglichkeiten, Strings zu verändern, sind äußerst umfangreich. Strings können
zusammengefügt, ineinander eingefügt und ganz oder in Teilen ersetzt werden.
Die einfachste Möglichkeit, einen String zu verändern, ist, ihm einen neuen Inhalt zu
geben:
string s1;
string s2; 24
s1 = "abcdefgh";
s2 = s1;
string s1 = "abcdefgh";
string s2;
981
24 Die C++-Standardbibliothek und Ergänzung
Bei assign kann ein Stringbereich auch über Iteratoren gewählt werden:
string s1 = "abcdefgh";
string s2;
Wie anfänglich schon einmal bei den Iteratoren erwähnt, ist ein durch Iteratoren
begrenzter Bereich immer einschließlich des Startiterators (hier s1.begin()) und aus-
schließlich des Enditerators (hier s1.end()). Beachten Sie, dass der Enditerator bereits
auf einen nicht mehr gültigen Bereich zeigt.
Das Anfügen eines Strings an einen anderen wird naheliegenderweise mit dem ope-
rator+= ausgeführt:
string s1 = "abcdefgh";
string s2;
s2 += s1; // s2 = "abcdefgh"
s2 += "uvw"; // s2 = "abcdefghuvw"
s2 += 'z'; // s2 = "abcdefghuvwz"
string s1 = "abcdefgh";
string s2 = "ijklmno";
s2.append( s1 ); // wie s1 += s2
s2.append( s1, 1, 3 ); // fuege ab Pos 1 drei Zeichen an
982
24.5 Strings (string)
Es ist leicht zu sehen, dass die Schnittstelle mit der von assign identisch ist – mit dem
Unterschied, dass der Teilstring nicht zugewiesen, sondern angehängt wird.
Mit insert kann ein String oder Teilstring in einen anderen eingesetzt werden. Die
Auswahl erfolgt wie bei assign und append. Bei insert muss nur zusätzlich die Posi-
tion angegeben werden, an der eingesetzt werden soll. Die Positionsangabe ist der
erste Parameter der Funktion:
string s1 = "abcdefgh";
string s2 = "ijklmno";
s2.insert( 3, s1 );
s2.insert( 3, s1, 1, 3 );
s2.insert( 3, "xyz" );
s2.insert( 3, "xyz", 2 );
s2.insert( 3, "xyz", 0, 2 );
s2.insert( 3, 5, '*' );
In den hier angegebenen Beispielen wird der String jeweils an der Position 3 des
Strings s2 eingebaut, nicht wie in append an das Ende angehängt.
Mit der Funktion replace können Teile in einem String ersetzt werden. Hier muss
neben der Startposition angegeben werden, wie viele Zeichen ersetzt werden sollen.
Die Startposition ist wieder der erste Parameter, die zu ersetzende Länge folgt als
zweiter Parameter:
24
string s1 = "abcdefgh";
string s2 = "ijklmno";
s2.replace( 3, 2, s1 );
s2.replace( 3, 2, s1, 1, 3 );
s2.replace( 3, 2, "xyz" );
s2.replace( 3, 2, "xyz", 2 );
983
24 Die C++-Standardbibliothek und Ergänzung
s2.replace( 3, 2, "xyz", 0, 2 );
s2.replace( 3, 2, 5, '*' );
In der letzten Anweisung werden im String s2 von der Position 3 an zwei Zeichen
durch fünf Sterne ersetzt. Mit den ersten beiden Parametern wird ein Bereich festge-
legt, dessen Inhalt durch den in den restlichen Parametern festgesetzten (Teil-)String
ersetzt wird. Ansonsten haben die Parameter die gleiche Bedeutung wie bei insert,
append und assign. Der zu ersetzende Bereich kann statt durch Position und Länge
auch durch zwei Iteratoren gegeben sein:
Um nur einen Teilstring (Substring) aus einem bestehenden String zu erzeugen, wird
substr aufgerufen:
string s1 = "abcdefgh";
string s2 = "ijklmno";
s2 = s1.substr( 1, 5 ); // s2 = bcdef
Auch hier legt der erste Parameter die Startposition fest und der zweite die Länge.
string s1 = "abcdefgh";
string s2 = "ijklmno";
s1.swap( s2 );
string s = "abcdefghi";
984
24.5 Strings (string)
Fehlerhafte Parameter
In der Übersicht über die Manipulation der Strings habe ich nichts dazu gesagt, was
passiert, wenn Sie falsche Parameter an die Funktionen übergeben. Wenn es pro-
blemlos möglich ist, sollten die Funktionen in diesen Situationen »vernünftig« rea-
gieren. Möchten Sie sechs Zeichen löschen und der String hat nur drei Zeichen,
werden nur die drei Zeichen gelöscht. Wird allerdings eine ungültige Bereichsangabe
aufgerufen, dann erzeugt die Funktion eine out_of_range-Exception, die von der auf-
rufenden Routine gefangen und behandelt werden sollte. Im obigen Beispiel zur
erase-Funktion ist das der Fall, wenn Sie alle Aufrufe nacheinander ausführen, da der
String s beim zweiten Aufruf mit s.erase(7) gar keine Position 7 mehr hat.
Als Rückgabewert haben die meisten Funktionen eine Referenz auf den Ergebnis-
string, sodass das Ergebnis der einen Funktion sofort im nächsten Funktionsaufruf
verwendet werden kann:
string s1 = "abcdef";
string s2 = "ghijkl";
string s3 = "123456";
s1.insert( 3, s2.insert( 3, s3 ) );
1. In dem oben stehenden Beispiel wird zunächst s3 in die Mitte von s2 eingesetzt,
das Zwischenergebnis lautet s2 = "ghi123456jkl".
2. Das Ergebnis des inneren Funktionsaufrufs ist eine Referenz auf s2. Diese Referenz
wird jetzt im äußeren Funktionsaufruf als Parameter verwendet. 24
3. Damit wird jetzt das veränderte s2 in die Mitte von s1 eingesetzt, und wir erhalten
als Ergebnis s1 = "abcghi123456jkldef".
In einem zweiten Beispiel werden wir auf das Ergebnis einer Memberfunktion wieder
eine Memberfunktion aufrufen:
string s1 = "abcdef";
string s2 = "ghijkl";
string s3 = "123456";
985
24 Die C++-Standardbibliothek und Ergänzung
24.5.4 Vergleich
Die Klasse string überlädt die Vergleichsoperatoren, sodass die Vergleichsoperato-
ren (==, !=, <, <= >, >=) für inhaltliche Vergleiche der Strings verwendet werden kön-
nen. Insbesondere können Sie einen Vergleich auf Gleichheit oder Ungleichheit von
Strings mit den Operatoren == und != vornehmen:
if( s1 == s2 )
// Aktion bei Gleichheit;
if( s1 != s2 )
// Aktion bei Ungleichheit;
Durch die überladenen Operatoren ist das bei Strings nicht der Fall. Verglichen wer-
den die Strings dabei auf Basis der Zeichencodes.
Neben den überladenen Operatoren gibt es noch die Funktion compare, die wie die
Funktion strcmp die Differenz der Zeichen an der Stelle berechnet, an der die Ver-
gleichsobjekte sich erstmalig unterscheiden. Die Funktion compare kann paramet-
riert werden. Die Angaben erfolgen durch die Angabe einer Länge oder einer Position
und einer Länge:
986
24.5 Strings (string)
24.5.5 Suchen
Zum Suchen in Strings gibt es zahlreiche Funktionen in unterschiedlichen Para-
metrierungen. Es kann nach Zeichen und Zeichenketten gesucht werden.
Bei einem Treffer geben die Funktionen eine Positionsangabe zurück. In der Regel
handelt es sich dabei um die Position, an der der Treffer erstmalig aufgetreten ist. Mit
der Angabe kann dann direkt auf die Fundstelle zugegriffen werden. Wie bei der
Funktion size ist der Rückgabetyp string::size_type, aber auch hier kann der Typ
auf int oder unsigned int gecastet werden.
Ergibt die Suche keinen Treffer, geben die Funktionen den konstanten Wert
string::npos zurück. Über diesen Wert kann explizit geprüft werden, ob die Suche
erfolgreich gewesen ist. Häufig wird der Rückgabewert der Funktion aber auch ohne
Prüfung direkt wieder in den Stringfunktionen verwendet, um direkt an der Fund-
stelle zu arbeiten, also z. B. einzufügen oder zu löschen. War die Suche vorher nicht
erfolgreich, dann wird der Wert string::npos an die Funktion übergeben. Ist der Wert
dort unzulässig, wirft die Funktion eine out_of_range_exception, die dann behandelt
werden muss.
string s1 = "abcdefg";
string s2 = "de";
s1.find( s2 ); // suche s2 in s1
s1.find( s2, 2 ); // suche s2 in s1 ab Pos. 2
Die Funktion find durchsucht den String von vorne nach hinten und meldet den ers-
ten Treffer. Um den String von hinten nach vorne zu durchsuchen, wird rfind ver-
wendet, parametriert wie find. Es gibt noch weitere Suchfunktionen, die z. B. das
987
24 Die C++-Standardbibliothek und Ergänzung
24.5.6 Speichermanagement
Sie werden vermutlich schon beim Lesen der Beispiele zu schätzen wissen, dass die
Klasse string das Speichermanagement komplett übernimmt. Der Nutzer muss dazu
nichts weiter tun. Der Benutzer kann das Speichermanagement unterstützen und
Hinweise geben, wenn er weiß, was zukünftig mit dem String passieren wird.
Ein konkreter String hat bezüglich des Speichermanagements drei wichtige Kenn-
größen:
왘 Länge: die Länge des Strings, also die Anzahl der Zeichen des Strings
왘 allokierter Buffer: Der allokierte Buffer ist stets größer als der String, da er ja min-
destens die Zeichen und den Terminator enthalten muss.
왘 Kapazität: die maximale Länge einer Zeichenkette, die man speichern könnte,
ohne neuen Speicher zu allokieren
Alle drei Größen stehen natürlich in enger Beziehung zueinander. Das Speicherma-
nagement sorgt dabei dafür, dass die Kapazität immer groß genug ist; meist ist die
Kapazität größer als die aktuelle Länge. Wenn sich die Länge des Strings vergrößert,
reicht die Kapazität dann möglicherweise aus, ohne neuen Speicher zu allokieren.
Wenn die Länge die verfügbare Kapazität erreicht, wird die Kapazität erhöht und
neuer Speicher allokiert. Das ist meist mehr, als gerade zwingend benötigt wird. Es
entsteht also eine Überkapazität. Diese Überkapazität wird auch nicht unbedingt
abgebaut, wenn der String verkleinert wird4.
Ich habe hier den Typ string::size_type gleich auf int gecastet. Mit dieser Funktion
werden wir uns den Verlauf der Kapazität im Betrieb ansehen5:
4 Es gibt auch Methoden, mit resize die Kapazitätsreduzierung eines Strings zu erzwingen, aber
auf die gehe ich hier nicht ein.
5 Der Verlauf der Kapazität ist implementierungsabhängig und kann bei Ihnen daher im Detail
anders aussehen.
988
24.5 Strings (string)
string s;
Dazu erstelle ich ein Programm, das einen anfangs leeren String zehnmal um jeweils
20 Zeichen verlängert, und beobachte dabei die Kapazität:
Laenge: 20 Kapazitaet: 31
Laenge: 40 Kapazitaet: 47
Laenge: 60 Kapazitaet: 70
Laenge: 80 Kapazitaet: 105
Laenge: 100 Kapazitaet: 105
Laenge: 120 Kapazitaet: 157
Laenge: 140 Kapazitaet: 157
Laenge: 160 Kapazitaet: 235
Laenge: 180 Kapazitaet: 235
Laenge: 200 Kapazitaet: 235
Sie sehen, dass insgesamt sechsmal neuer Speicher allokiert wird. Bei jeder Verände-
rung der Kapazität muss der Speicherinhalt intern umkopiert werden. Dies können
Sie vermeiden, wenn Sie dem String bereits am Anfang die Information über die
benötigte Größe mitgeben:
s.reserve( 200 );
Da der String jetzt von Anfang an die richtige Größe hat, muss innerhalb der Schleife
kein neuer Speicher mehr allokiert werden.
989
24 Die C++-Standardbibliothek und Ergänzung
Das dynamische Array der C++-Standardbibliothek passt seine Größe zur Laufzeit an
den geforderten Platzbedarf an, ohne dass die Speicherverwaltung explizit durch den
Nutzer des Arrays übernommen werden muss (z. B. mit new und delete). Ebenso wie
statische Arrays bieten dynamische Arrays einen wahlfreien Zugriff auf die Datenele-
mente. Sie ähneln in vielen Aspekten den Strings, die Sie in C ja auch als (statische)
Arrays von char kennengelernt haben. Die dynamischen Arrays sollen dabei aber
über einen beliebigen Datentyp aufgebaut werden können. Um für dieses und die
weiteren Datenstrukturen einen geeigneten Datentyp als Beispiel zu haben, erstelle
ich zuerst die Klasse klasse.
class klasse
{
private:
int x;
public:
klasse( int xx = 0 ) { x = xx; }
void setX( int xx ) { x = xx; }
int getX() const{ return x; };
};
Die Klasse deklariere ich in der Datei klasse.h, die ich im weiteren Verlauf inkludiere,
wenn klasse verwendet wird, ohne darauf dort jeweils noch einmal explizit einzu-
gehen.
Versuchen Sie nicht, in der Klasse eine besondere Bedeutung zu erkennen. Sie ist ein-
fach eine Beispielklasse, die im Verlauf des Kapitels für die unterschiedlichen Daten-
strukturen der Standardbibliothek zum Einsatz kommen wird. Sie wird auch später
erhöhten Anforderungen entsprechend noch etwas angepasst.
Die Klasse macht aber erst einmal nicht mehr, als intern eine ganze Zahl als ihre
»Daten« zu verwalten und über Funktionsmember mit getX lesenden und mit setX
schreibenden Zugriff zu ermöglichen. Mit dem Konstruktor kann sie mit einem int-
Wert implizit oder explizit konstruiert werden. Durch den Standardwert in der
Schnittstelle kann der Konstruktor auch parameterlos verwendet werden.
990
24.6 Dynamische Arrays (vector)
#include <vector>
using namespace std;
24.6.3 Konstruktion
Sie haben am Anfang des Kapitels die Verwendung von Templates kennengelernt.
Die Standardbibliothek macht ausgiebig Gebrauch von dieser Technik. Das gilt für
Arrays genauso wie für die meisten anderen Datenstrukturen, die Sie hier noch ken-
nenlernen werden. Nur so ist es möglich, dass diese Datenstrukturen generisch über
alle Datentypen erzeugt werden können und eine so flexible Verwendung erlauben.
Alternativ können Sie das Array auch mit einer bestimmten Anzahl von Elementen
initialisieren (hier 100).
Wie Sie es auch schon von statischen Arrays kennen, funktioniert diese Initialisie-
rung natürlich nur, wenn der verwendete Datentyp parameterlos konstruiert werden
kann – entweder durch einen explizit dafür bereitgestellten Konstruktor oder durch 24
einen automatisch erzeugten Standardkonstruktor.
Andernfalls müssen Sie für die Konstruktion durch einen geeigneten parametrierten
Konstruktor sorgen. Dazu verwenden Sie üblicherweise explizite Konstruktorauf-
rufe:
Die verwendete Beispielklasse kann sowohl explizit konstruiert werden als auch im-
plizit, also durch einen Zahlenwert:
991
24 Die C++-Standardbibliothek und Ergänzung
klasse kExplizit( 17 );
klasse kImplizit = 17;
Der Konstruktor von klasse erlaubt diese Variante6. Unsere Beispielklasse erlaubt die
implizite Konstruktion, daher ist auch die Initialisierung des Vektors mit implizitem
Konstruktor möglich:
Bisher haben wir die Beispielklasse zur Konstruktion des dynamischen Arrays ver-
wendet. Um das Array zu verwenden, benötigen Sie aber gar keine eigens erstellte
Klasse. Sie können einen vector auch direkt mit Grunddatenypen erzeugen:
24.6.4 Zugriff
Ebenso wie bei Strings wird die Anzahl der Elemente im dynamischen Array mit der
size-Funktion ermittelt:
int l;
l = ( int ) v.size();
Ich habe das Ergebnis hier direkt vom Datentyp vector::size_type in einen int-Wert
gewandelt.
Ebenfalls wie beim String wird mit der empty-Funktion getestet, ob der Vektor leer ist:
if( v.empty() )
// Das Array ist leer ...
Das dynamische Arrays erlaubt wie das statische Array einen wahlfreien Zugriff auf
seine Elemente über einen Index mit dem operator[] oder über die at-Funktion.
Bei beiden Varianten ist das zurückgegebene Ergebnis vom Datentyp Referenz auf
den Basisdatentyp. Da es sich bei diesem Ergebnis um einen L-Value handelt, kann
992
24.6 Dynamische Arrays (vector)
der Rückgabewert auch manipuliert werden. Wir rufen im Beispiel die setX-Funktion
auf das zurückgegebene Ergebnis auf, die auch direkt ausgeführt wird.
Wie schon beim String ist der Zugriff mit dem operator[] ohne Bereichskontrolle.
Eine Bereichsverletzung führt hier zu einem Programmabsturz. Die at-Funktion kon-
trolliert den Zugriffsbereich und wirft bei einer Bereichsverletzung eine out_of_
range-Exception, die gefangen und behandelt werden kann. Der Preis dieser Prüfung
ist allerdings ein (leicht) erhöhter Laufzeitaufwand.
Abweichend vom String bietet der Vektor noch einen direkten Zugriff auf das erste
(front) und das letzte (back) Element des dynamischen Arrays:
Wie schon von statischen Arrays bekannt, sind auch hier die Elemente von 0 ausge-
hend nummeriert. In einem dynamischen Array mit 100 Elementen hat das erste Ele-
ment den Index 0 und das letztes Element den Index 99. Aber diese Zählweise ist
Ihnen ja schon vertraut.
24.6.5 Iteratoren
Iteratoren werden in einem dynamischen Array verwendet, um auf bestimmte Posi-
tionen zuzugreifen oder das Array zu durchlaufen. Sie ergänzen den wahlfreien
Zugriff über den operator[].
Wie bei Strings gibt es Iteratoren, um sich vorwärts oder rückwärts durch die Daten
zu bewegen. Wenig überraschend werden daher auch hier begin, end sowie rbegin
und rend verwendet, um die Iteratoren an den Anfang oder das Ende eines Vektors zu
positionieren. In der Verwendung sind die Iteratoren dann wie Zeiger:
vector<klasse> v( 100 ); 24
993
24 Die C++-Standardbibliothek und Ergänzung
24.6.6 Manipulation
Ich zeige Ihnen in diesem Abschnitt Funktionen zur Manipulation dynamischer
Arrays:
Sie werden erkannt haben, dass sich hier einige der Funktionen wiederfinden, die ich
Ihnen auch schon für Strings gezeigt habe. Hier zeigt sich bereits der Vorteil der von
der Standardbibliothek verwendeten Schnittstellen, die die gleichen Funktionen für
alle Datentypen bereitstellt, soweit der Datentyp dies erlaubt.
Die Inhalte eines Arrays können einem anderen Array zugewiesen werden. Dazu
wird der =-Operator oder die assign-Funktion verwendet. Die zugewiesenen Arrays
müssen dabei nicht gleich groß sein:
vector<klasse> v1( 10 );
vector<klasse> v2( 50 );
v1 = v2;
v1.assign( v2.begin() + 5, v2.end() – 1 );
Im ersten Fall wird das gesamte Array v2 dem Array v1 zugewiesen. Im zweiten Fall
wird mit Iteratoren ein Teilbereich aus dem Array v2 selektiert und zugewiesen.
Eventuell notwendige Anpassungen der Array-Größe werden automatisch vorge-
nommen.
In dem Beispiel haben beide Arrays den gleichen Datentyp. Das ist für eine Zuwei-
sung nicht zwingend notwendig, die Datentypen müssen aber »passen«, d. h., es
muss eine elementweise Zuweisung möglich sein. Sie können z. B. einem Element
994
24.6 Dynamische Arrays (vector)
des Datentyps Klasse einen int-Wert zuweisen, daher ist auch eine Zuweisung von
Teilen eines dynamischen int-Arrays ohne Umwege möglich:
vector<klasse> v1( 10 );
vector<int> v3( 100 );
Sofern eine Zuweisung einzelner Elemente erlaubt ist, ist sogar die Zuweisung aus
einem »normalen« Array möglich, aus dem die Bereiche über Zeiger selektiert
werden:
vector<klasse> v1( 10 );
klasse a[50]; // statisches Array mit Datentyp klasse
v1.assign( a + 1, a + 20 );
Der Inhalt, der in dem empfangenden Array enthalten war, wird beim Zuweisen
ersetzt.
Ist das nicht gewünscht, kann alternativ mit der insert-Funktion in ein bestehendes
Array eingesetzt werden. Ich zeige Ihnen zuerst, wie Sie ein einzelnes Element einfü-
gen. Hier wird die Einfügeposition angegeben und optional, wie oft das Element ein-
gefügt werden soll:
vector<klasse> v1( 10 );
klasse k = 123;
Auch hier kann die Verwendung von int-Werten erfolgen, da eine elementweise
Zuweisung möglich ist:
vector<klasse> v1( 10 );
995
24 Die C++-Standardbibliothek und Ergänzung
Es können aber auch ganze Bereiche aus anderen Arrays eingefügt werden. Hier
muss neben der Einfügeposition auch der Bereich bestimmt werden, der aus dem
anderen Array eingesetzt wird:
vector<klasse> v1( 10 );
vector<klasse> v2( 10 );
Daten, die sich im empfangenden Array hinter der Einfügeposition befanden, wer-
den nach hinten geschoben. Das Array vergrößert sich bei Bedarf entsprechend.
Sie sehen in dem Beispiel bereits, dass sowohl aus dynamischen als auch aus stati-
schen Arrays eingefügt werden kann. Bei dynamischen Arrays als Quelle wird der
einzufügende Bereich über Iteratoren selektiert, bei statischen Arrays über Zeiger.
Wenn aus einem dynamischen Array Elemente entfernt werden sollen, wird die
erase-Funktion verwendet. Ihr wird eine einzelne Position zur Löschung angegeben:
vector<klasse> v1( 10 );
v1.erase( v1.begin() + 3 );
Bei Löschung rücken die nachfolgenden Elemente auf, und das Array verkleinert sich
entsprechend.
vector<klasse> v1( 10 );
Bei der Bereichsangabe arbeiten die Iteratoren wie gewohnt, das Ende des Bereichs ist
nicht einschließlich. Das heißt, entfernt werden alle Elemente ab einschließlich der
erstgenannten Position, aber ausschließlich der zweitgenannten (End-) Position. Im
oben dargestellten Beispiel werden also die Positionen 0, 1 und 2 im Array gelöscht.
Um das gesamte Array zu löschen, könnte als Bereich natürlich begin und end ver-
wendet werden, aber es gibt mit clear auch eine Funktion, um den gesamten Inhalt
des Arrays direkt zu löschen:
996
24.6 Dynamische Arrays (vector)
vector<klasse> v1( 10 );
v1.clear();
vector<klasse> v1( 10 );
vector<klasse> v2( 100 );
v1.swap( v2 );
Aus den vorangegangen Kapiteln wissen Sie bereits, dass man mit Arrays mit wenig
Aufwand einen Stack implementieren kann.
Das dynamische Array der Standardbibliothek enthält hier bereits die passenden
Funktionen, damit eine Verwendung eines vector als Stack ohne weiteren Aufwand
erfolgen kann.
vector<klasse> v( 10 );
klasse t;
v.push_back( k );
v.pop_back();
Mit push_back wird ein Element am Ende des Arrays angefügt, und mit pop_back wird
das letzte Element eines Arrays entfernt und beseitigt. Beide Funktionen haben kei-
nen Returnwert.
Um einen einfachen Stack zu realisieren und zu verwenden, sind damit nur noch
wenige Zeilen notwendig:
int main()
{
vector<int> stack;
int summe = 0;
stack.push_back( 10 ); 24
stack.push_back( 20 );
stack.push_back( 30 );
while( !stack.empty() )
{
summe += stack.back();
stack.pop_back();
}
}
997
24 Die C++-Standardbibliothek und Ergänzung
24.6.7 Speichermanagement
Das Speichermanagement von dynamischen Arrays entspricht dem der Strings.
Auch die Funktionen zur Unterstützung des Speichermanagements sind dieselben:
왘 resize
왘 capacity
왘 reserve
Die C++-Standardbibliothek stellt für Listen den Datentyp list bereit, der als doppelt
verkettete Liste implementiert ist.
Ich werden Ihnen in diesem Abschnitt den Datentyp list vorstellen. Vor allem werde
ich Ihnen hier aber auch noch Techniken zeigen, mit denen Sie noch effizienter mit
diesem Datentyp arbeiten können.
#include <list>
using namespace std;
Eine Liste verwaltet Elemente eines bestimmten Datentyps und muss daher über den
entsprechenden Datentyp konstruiert werden. Wir verwenden wieder den Datentyp
klasse, den Sie schon aus dem vorangegangenen Abschnitt kennen und dessen Hea-
der-Datei Sie natürlich auch einbinden sollten.
24.7.1 Konstruktion
Die einfachste Form, eine Liste zu konstruieren, ist:
list<klasse> l;
Die Zeile erzeugt eine leere Liste l, in der Instanzen der Klasse klasse verwaltet wer-
den können. Eine Liste wächst dynamisch, aber auch hier kann bei der Konstruktion
eine initiale Größe mitgegeben werden. Die Anweisung
998
24.7 Listen (list)
list<klasse> l( 1000 );
erzeugt eine Liste von 1000 Elementen vom Typ klasse. Wie schon bei den Vektoren
muss für diese Variante ein parameterloser Konstruktor bereitstehen. Und auch hier
können wie bei dynamischen Arrays die Listenelemente explizit oder implizit kon-
struiert werden:
Die Liste kann ebenfalls aus einer bereits bestehenden Liste konstruiert werden:
list<klasse> l2 = l1;
list<klasse> l3( l1 );
list<klasse> l4( l1.begin(), l1.end() );
Hier werden die Listen l2, l3 und l4 jeweils als Kopie der Liste l1 erzeugt. Bei der im
letzten Fall verwendeten Variante ist auch eine Zuweisung von einer Liste eines
anderen Datentyps möglich:
Notwendige Voraussetzung ist natürlich auch hier wieder, dass der verwendete
Datentyp eine elementweise Zuweisung ermöglicht.
24.7.2 Zugriff
Die Ermittlung der Größe verläuft wie bei den anderen vorgestellten Datentypen:
list<klasse> l( 1000 );
24
int s;
s = ( int ) l.size();
Auch hier wird das Ergebnis der Größenermittlung meist durch einen int-Wert abge-
bildet. Der Test auf eine leere Liste ist ebenfalls vorhanden:
list<klasse> l;
if( l.empty() )
// ...Liste ist leer
999
24 Die C++-Standardbibliothek und Ergänzung
Listen ermöglichen keinen wahlfreien Zugriff. Bei doppelt verketteten Listen gibt es
direkten Zugriff nur auf das erste und das letzte Element der Liste. Dazu dienen die
Funktionen front und back, die jeweils eine Referenz auf das erste und letzte Element
liefern:
list<klasse> l( 1000 );
klasse& k1 = l.front();
klasse& k2 = l.back();
k1.setX( k2.getX() );
l.back().setX( 1239 );
24.7.3 Iteratoren
Die wichtigste Operation auf Listen ist die Iteration, also das sequenzielle Durchlau-
fen der Elemente. Iteratoren sind damit bei Listen nicht so mächtig wie bei Vektoren.
Ein wahlfreier Zugriff ist nicht möglich, Listeniteratoren sind bidirektional und erlau-
ben die Aktionen, die Sie auch im Rahmen der C-Programmierung für doppelt ver-
kettete Listen kennengelernt haben.
= Zuweisung
++ nächstes Listenelement
-- vorheriges Listenelement
1000
24.7 Listen (list)
Die Operatoren ++ und -- der Iteratoren können sowohl in der Präfix-Notation (++p,
--p) als auch in der Postfix-Notation (p++, p--) verwendet werden.
Ein expliziter Zugriff auf die Verkettungsfelder der Liste ist nicht möglich und im
Sinne der Kapselung auch gar nicht erwünscht.
list<klasse> l( 1000 );
list<klasse>::iterator it;
Um den Iterator auf das erste Element der Liste zu setzen, verwenden Sie auch hier
die Funktion begin:
it = l.begin();
Von diesem Startpunkt aus können Sie dann z. B. mit dem ++-Operator durch die
gesamte Liste iterieren:
list<klasse> l( 1000 );
list<klasse>::iterator it;
Um sich rückwärts durch die Daten zu bewegen, verwenden Sie den Rückwärtsitera-
tor, dessen Start und Ende mit rbegin und rend bestimmt werden:
list<klasse> l( 1000 );
list<klasse>::reverse_iterator rit;
Da die Listen keinen wahlfreien Zugriff ermöglichen, werden hier Teilbereiche typi-
scherweise mit Iteratoren festgelegt. Dabei markiert der erste Parameter jeweils die
Anfangsposition, der zweite die Endposition. Der Teilbereich versteht sich dann
immer einschließlich der Anfangsposition, aber ausschließlich der Endposition.
Achtung, die Iteratoren end und rend zeigen jeweils bereits außerhalb des gültigen
Listenbereichs. Ein Zugriffsversuch über einen solchen Iterator führt daher zu einem
Fehler.
1001
24 Die C++-Standardbibliothek und Ergänzung
24.7.4 Manipulation
Die Funktionen zur Manipulation bieten Ihnen einen umfangreichen Werkzeugkas-
ten für viele Aufgaben:
splice Einfügen von Elementen aus einer Liste in eine andere Liste
remove Entfernen von Elementen, die mit einem bestimmten Objekt überein-
stimmen
Die Zuweisung von Listen mit dem operator= oder der assign-Funktion bietet wenig
Neues:
1002
24.7 Listen (list)
list<klasse> l( 1000 );
list<klasse> l1, l2, l3, l4;
klasse k( 123 );
A l2 = l1;
B l2.assign( ++l.begin(), --l.end() );
C l3.assign( 20, k );
D l4.assign( 20, 123 );
Bei der ersten Zuweisung (A) wird die komplette Liste l2 in l1 kopiert.
In der zweiten Zuweisung (B) wird der kopierte Bereich durch Iteratoren eingegrenzt,
l1 erhält den Inhalt von l2 allerdings ohne das erste und letzte Element. Im dritten
Fall (C) wird eine Liste mit 20 Elementen erzeugt, die alle mit k initialisiert werden. Im
vierten und letzten Fall wird ebenfalls eine Liste mit 20 Elementen erzeugt, die dort
direkt durch den Konstruktor initialisiert werden.
Die Liste hat die »Stack-Operationen« push_front, push_back, pop_front und pop_back.
Mit diesen Operationen kann man Elemente am Anfang (front) oder am Ende (back)
in die Liste einfügen (push) oder entfernen (pop):
list<klasse> l( 1000 );
klasse k( 123 );
Ich werde diese Funktionen gleich noch verwenden, um einen Stack mit einer Liste
aufzubauen.
24
Auch für die Liste kann die insert-Funktion zum Einfügen verwendet werden:
list<klasse> l( 1000 );
list<klasse> l1( 10 );
klasse k( 123 );
A l.insert( l.begin(), k );
B l.insert( l.begin(), 7, k );
C l.insert( l.begin(), l1.begin(), l1.end() );
1003
24 Die C++-Standardbibliothek und Ergänzung
Allen drei verwendeten Aufrufen ist gemeinsam, dass zuerst die Einfügeposition mit
einem Iterator bestimmt wird. In (A) wird das Element k an den Anfang der Liste ein-
gefügt, in (B) wird k 7-mal eingefügt, und in (C) wird ein bestimmter Bereich aus der
Liste l1 eingefügt.
Entfernt werden Elemente aus der Liste mit den Funktionen erase für einzelne Ele-
mente oder Bereiche und clear für den gesamten Listeninhalt:
list<klasse> l( 10 );
l.erase( l.begin() ); // loesche das erste Element
l.erase( ++l.begin(), --l.end() ); // loesche alles bis auf Anfang
// und Ende
l.clear(); // loesche alles
Bei der erase-Funktion kann ein einzelnes Element angegeben werden, aber auch ein
Bereich. Sowohl Position als auch Bereich werden durch Iteratoren festgelegt. Die
Funktionen erase und clear rufen jeweils die Destruktoren der entsorgten Elemente
auf.
Bei Listen ist das Umkopieren von Elementen sehr effizient zu realisieren. Hier reicht
es aus, die Zeiger auf die Datenstrukturen zu kopieren. Bei Arrays hingegen müssen
die Datenstrukturen selbst übertragen werden.
Die Funktion swap zum Vertauschen von Listen haben Sie bereits für andere Daten-
strukturen kennengelernt, ebenso die reverse-Funktion:
list<klasse> l1( 10 );
list<klasse> l2( 1000 );
Die Funktion splice ist hingegen neu. Mit ihr werden Elemente listenübergreifend
getauscht oder verschoben. Die Elemente werden aus der Quellliste entfernt und in
die Zielliste eingefügt:
list<klasse> l1( 10 );
list<klasse> l2( 1000 );
list<klasse> l3( 1000 );
A l1.splice( l1.end(), l2 );
B l1.splice( l1.end(), l3, l3.begin() );
C l1.splice( l1.end(), l3, ++l3.begin(), --l3.end() );
1004
24.7 Listen (list)
Mit dem ersten Aufruf der splice-Funktion (A) werden alle Elemente der Liste l2 an
das Ende der Liste l1 angefügt. Die Liste l2 ist danach leer.
Der zweite Aufruf (B) fügt das erste Element der Liste l3 an das Ende von l1 an (ver-
schiebt). Der dritte Aufruf (C) von splice wählt in l3 alles außer dem ersten und dem
letzten Element und verschiebt diesen Bereich nach l1.
Die Funktionen, die ich Ihnen bisher für die Liste vorgestellt habe, greifen die Funkti-
onalität auf, die schon die anderen Datentypen der Standardbibliothek bereitgestellt
haben. Ich werde Ihnen jetzt eine Technik vorstellen, mit der Sie die Anwendungs-
möglichkeiten der Bibliothek noch einmal deutlich steigern können.
Ich habe Ihnen in Abschnitt 8.3 bereits die Arbeit mit Funktionszeigern vorgestellt.
Die Funktionszeiger greifen wir jetzt noch einmal auf. Die Standardbibliothek bietet
mit ihren Schnittstellen dabei ein Umfeld, das die Nutzung von Funktionszeigern
verhältnismäßig einfach macht.
Um die Verwendung in den kommenden Beispielen zu erleichtern, werde ich die Bei-
spielklasse klasse leicht erweitern. Ich werde klasse weiter durch alle Beispiele als
Datentyp verwenden.
class klasse
{
private:
int x;
public:
klasse( int xx = 0 ) { x = xx; }
void setX( int xx ) { x = xx; }
int getX() const{ return x; }; 24
bool operator== ( const klasse& cmp ) const
{ return x == cmp.x; }
bool operator< ( const klasse& cmp ) const
{ return x < cmp.x; }
bool operator> ( const klasse& cmp ) const
{ return x > cmp.x; }
};
1005
24 Die C++-Standardbibliothek und Ergänzung
Die Operatoren melden nur zurück, ob eine entsprechende Ist-kleiner-als- oder Ist-
gleich-Beziehung zwischen den x-Membern der Objekte besteht.
Zusätzlich ergänze ich die Klasse noch um einen Ausgabe-Operator operator <<, den
ich außerhalb der Klasse implementiere:
Damit ist es möglich, die Objekte direkt an einen Ausgabestrom umzuleiten und zwi-
schen zwei Objekten vom Typ klasse Vergleiche auszuführen:
klasse k1 = 9;
klasse k2 = 42;
if( k1 == k2 )
{
// ... x-Member der Objekte sind gleich
}
if( k1 < k2 )
{
// ... x-Member von k1 ist kleiner als von k2
}
k1: 9
und könnten nun explizit Objekte vergleichen. Mit diesem Vergleich könnten wir
auch eine Ordnung zwischen den Objekten herstellen und sie z. B. sortieren.
Allerdings wollen wir die Vergleiche gar nicht selbst ausführen. Stattdessen nutzen
wir Funktionen des Datentyps Liste, die diese Vergleiche implizit für ihre Arbeit ver-
wenden.
Die entsprechenden Funktionen sind remove, unique, sort und merge, die ich Ihnen
nun im Einzelnen zeigen werde.
1006
24.7 Listen (list)
list<klasse> l;
list<klasse>::iterator it;
Wir erstellen die Liste l, befüllen Sie mit Werten und geben die Liste im Originalzu-
stand aus. Für die Ausgabe verwenden wir den gerade eingeführten operator<<.
Danach rufen wir die noch zu untersuchenden Listenfunktionen auf und geben das
Resultat aus. Da wir bisher noch keine Aktion ausführen, erhalten wir zweimal die
gleiche Ausgabe:
24
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
4 4 3 3 2 2 1 1 0 0
Als erste Funktion werden wir jetzt die remove-Funktion in den Testrahmen einsetzen.
Die Funktion dient dazu, alle Elemente aus der Liste zu entfernen, die mit einem über-
gebenen Objekt übereinstimmen. Um die Übereinstimmung festzustellen, wird dabei
der operator== des verwendeten Datentyps aufgerufen. Die ebenfalls hinzugefügten
Operatoren für den Vergleich auf größer oder kleiner verwenden wir erst später.
1007
24 Die C++-Standardbibliothek und Ergänzung
l.remove(3);
oder
l.remove( klasse(3) );
als zu testende Funktion in unseren Testrahmen ein, dann erhalten wir das folgende
Ergebnis:
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
4 4 2 2 1 1 0 0
Die Objekte, die dem übergebenen Objekt entsprochen haben, sind aus der Liste ent-
fernt worden. Die entfernten Objekte sind dabei beseitigt, und ihr Destruktor ist auf-
gerufen worden.
Um Dubletten aus einer Liste zu entfernen, können wir die unique-Funktion ver-
wenden:
l.unique();
Ihr Aufruf sorgt dafür, dass Folgen eines in der Liste mehrfach hintereinander vor-
kommenden gleichen Elements auf ein Vorkommen dieses Elements reduziert wer-
den. Auf das vorangegangene Ergebnis aufgerufen, ist das Resultat damit:
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
4 2 1 0
Auch hier werden die entfernten Objekte ordnungsgemäß mit einem Destruktorauf-
ruf beseitigt.
Sie sollten beachten, dass unique nur auf unmittelbar aufeinanderfolgende Objekte
ausgeführt wird. Eine Liste mit der Folge »1 2 3 1 2 3« bleibt also unverändert. Aller-
dings werden auch mehr als zwei aufeinanderfolgende Elemente bereinigt. Die Folge
»1 2 2 2 2 3« wird damit zu »1 2 3«.
Der Aufruf der sort-Funktion sortiert eine Liste. Fügen wir den Befehl
l.sort();
1008
24.7 Listen (list)
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
0 0 1 1 2 2 3 3 4 4
Die Funktion verwendet dabei den operator< des Datentyps, über den die Liste kon-
struiert wurde.
list<klasse> l2;
Wenn wir die Listen getrennt voneinander sortieren und dann mischen
l.sort(); // l = 0 0 1 1 2 2 3 3 4 4
l2.sort(); // l2 = 0 1 2 3 4 5 6 7 8 9
l.merge( l2 ); // l = 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 6 7 8 9
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 6 7 8 9
Es ist wichtig, dass die zu mischenden Listen vorab sortiert sind, sonst erhalten wir
kein sinnvolles Ergebnis.
24
Wir gehen aber noch einmal zur remove-Funktion zurück. Mit ihr kann man zwar ein
bestimmtes Objekt entfernen. Oft will man aber ein Objekt entfernen, das eine
bestimmte Bedingung erfüllt. In unserem Beispiel wollen wir aus der Liste l alle Ele-
mente entfernen, deren x-Member einen Wert größer als 2 haben. In solchen Fällen
wird die remove_if-Funktion verwendet. Um zu entscheiden, ob ein Element entfernt
werden soll, verwendet die Funktion ein sogenanntes Prädikat. Darunter verstehen
wir eine boolesche Funktion, anhand deren Ergebnis entschieden wird, ob ein Ele-
ment gelöscht wird oder nicht. Eine solche Funktion als Prädikat können Sie leicht
erstellen:
1009
24 Die C++-Standardbibliothek und Ergänzung
Jetzt müssen wir die Funktion nur noch der Funktion remove_if übergeben. Sie wen-
det die greater2-Funktion auf jedes Element an und löscht es dann gegebenenfalls.
Wir fügen den Aufruf in unseren Testrahmen ein,
l.remove_if( greater2 );
und alle Elemente werden gelöscht, für die das Prädikat true zurückliefert:
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
0 0 1 1 2 2
In das Prädikat, das wir jetzt erstellt haben, ist der Vergleichswert fest eincodiert.
Wenn Sie mehr Flexibilität haben möchten, könnten Sie auf die Idee kommen, mit
einer Funktion zu arbeiten, die auf eine globale Variable zurückgreift:
int min = 2;
bool greater2( klasse& k )
{
return k.getX() > min;
}
Über das Verändern des Variablenwertes könnten Sie dann die Funktion konfigurie-
ren. Das verstößt allerdings gegen die Grundsätze der objektorientierten Program-
mierung. Wir werden unser Prädikat stattdessen in ein Funktionsobjekt integrieren:
class greater_min{
private:
int min;
public:
greater_min( int m ) { min = m; }
bool operator() ( klasse & k ) { return k.getX() > min; }
};
Hier ist aus der globalen Variablen ein privates Memberdatum geworden, das im
Konstruktor der Klasse initialisiert wird.
1010
24.7 Listen (list)
Das Wichtige am Funktionsobjekt ist allerdings der überladene operator(), der jetzt
das Prädikat repräsentiert.
Wir können von der Klasse eine Instanz erzeugen und den überladenen Operator so
verwenden wie vorher unsere greater2-Funktion:
klasse k( 42 );
greater_min cmp( 2 );
Wir wollen das Prädikat aber gar nicht explizit selbst aufrufen, sondern von der
remove_if-Funktion aufrufen lassen. Dazu übergeben wir nicht wie vorher die Funk-
tion greater2, sondern eine Instanz der Klasse greater_min an die Funktion:
list<klasse> l;
l.remove_if( greater_min( 2 ) );
Als Ergebnis erhalten wir wieder die Liste, die nur die Elemente kleiner gleich 2 ent-
hält:
Vorher:
4 4 3 3 2 2 1 1 0 0
Nachher:
0 0 1 1 2 2
Prädikate können auch mit den Funktionen unique, sort und merge verwendet wer-
24
den, Klassen mit überladenem operator() funktionieren mit unique und sort. Diese
Klassen benötigen allerdings ein Prädikat bzw. eine Überladung des operator() mit
zwei Parametern, da hier ja immer zwei Objekte miteinander verglichen werden
müssen.
Die Parametrierung ist bei sort besonders nützlich, da man mit dieser Technik nach
beliebigen Kriterien sortieren kann. Wir betrachten daher noch einmal ein Beispiel
zum Sortieren über eine Klasse mit überladenem operator():
1011
24 Die C++-Standardbibliothek und Ergänzung
class cmpClass
{
private:
int modus;
public:
cmpClass( int m ) { modus = m; }
bool operator() ( klasse& k1, klasse& k2 )
{
switch( modus )
{
case 0:
return k1.getX() < k2.getX();
case 1:
return k1.getX() % 2 < k2.getX() % 2;
case 2:
return k1.getX() % 3 < k2.getX() % 3;
default:
return k1.getX( ) < k2.getX( );
}
}
};
Wir testen unsere Klasse, indem wir zuerst mit setup eine Liste mit Werten füllen:
1012
24.7 Listen (list)
Nun können wir eine Liste erstellen und mit den jeweiligen Kriterien mithilfe unse-
res Funktionsobjekts sortieren:
list<klasse>l;
setup( l );
l.sort( cmpClass( 0 ) );
ausgabe( l, "Mode 0 () : " );
l.sort( cmpClass( 1 ) );
ausgabe( l, "Mode 1 (%2) : " );
l.sort( cmpClass( 2 ) );
ausgabe( l, "Mode 2 (%3) : " );
Vorher : 9 8 7 6 5 4 3 2 1 0
Mode 0 () : 0 1 2 3 4 5 6 7 8 9
Mode 1 (%2) : 0 2 4 6 8 1 3 5 7 9
Mode 2 (%3) : 0 6 3 9 4 1 7 2 8 5
Damit haben wir ein Mittel, um Listen sehr flexibel zu erstellen und nach eigenen,
konfigurierbaren Parametern zu sortieren.
1013
24 Die C++-Standardbibliothek und Ergänzung
24.7.5 Speichermanagement
Abschließend noch einige Hinweise zum Speichermanagement. In der Liste werden
Elemente dynamisch angefügt. Eine Kapazität wie bei Strings oder Vektoren gibt es
daher in diesem Sinne nicht. Mit der Funktion resize können Listen vergrößert oder
auch verkleinert werden. Zum Vergrößern kann ein Initialwert oder ein Initialobjekt
für die neuen Listenelemente mitgegeben werden:
Wenn die Liste verkleinert wird, werden die Elemente, die aus der Liste fallen, mit
Aufruf des Destruktors korrekt beseitigt.
Wie Sie schon wissen, hat ein Stack einen sehr eingeschränkten Funktionsumfang. Er
muss nur die folgenden Grundfunktionen bereitstellen:
Dabei wird die Funktionalität von pop und top gelegentlich auch zusammengefasst.
In diesem Kapitel habe ich Ihnen mit dem Vektor und der Liste bereits Datenstruktu-
ren gezeigt, die Funktionalität eines Stacks als Teilmenge eines viel größeren Funkti-
onsumfangs enthalten. Sie werden sich daher vielleicht wundern, dass dem Stack
jetzt noch einmal ein eigener Abschnitt gewidmet ist.
Wie bei den Zugriffsrechten in Klassen liegt hier der Schlüssel in der Beschränkung
der Funktionalität. Die einfache Datenstruktur Stack soll nicht mehr bieten, als für
ihre Verwendung unbedingt notwendig ist. Die Verwendung wird durch die
bewusste Einschränkung vereinfacht.
Daher bietet die Standardbibliothek eine eigene Datenstruktur Stack ohne umfang-
reiche weitere Funktionen.
1014
24.8 Stacks (stack)
Da die Datenstrukturen mit der Funktionalität ja bereits vorhanden ist, wird der
Stack in der Standardbibliothek als Adapter umgesetzt, der auf eine geeignete Daten-
struktur aufgesetzt wird und deren Funktionalität verwendet. Dabei stellt der Stack
dem Benutzer nur seine eigene einfache Schnittstelle zur Verfügung, unabhängig
von der adaptierten Datenstruktur.
Um die Datenstruktur Stack zu verwenden, müssen Sie wie üblich die passende Hea-
der-Datei einbinden:
#include <stack>
using namespace std;
Der Stack wirkt als Adapter auf eine bestehende Struktur. Zu seiner Einrichtung sind
daher die folgenden Schritte notwendig:
왘 Festlegung des Datentyps der Basisklasse, in der die eigentlichen Daten liegen
왘 Festlegung des verwendeten Containers, der die Elemente der Basisklasse verwal-
tet und für den der Stack als Adapter agiert
Wir arbeiten auch hier als Datentyp für die Basisklasse wieder mit dem Datentyp
klasse aus Abschnitt 24.6.1 und dessen Erweiterung aus 24.7.4. Ich führe sie hier nicht
noch einmal separat auf.
Wir erzeugen zuerst einen Stack, der als Basisklasse die Klasse klasse über einen Vek-
tor als Container verwendet:
Völlig analog können wir auch einen Stack über eine Liste als verwendeten Container
erstellen:
wird ein Stack auf Basis der Datenstruktur Dequeue erzeugt. Diese Datenstruktur ist
der Default-Wert für die Erzeugung eines Stacks. Ich habe diese Struktur hier zwar
nicht behandelt, da die Schnittstelle des Stacks aber immer gleich bleibt, ist das für
Sie belanglos.
Oft ist es egal, welche unterliegende Datenstruktur für den Stack verwendet wird. In
diesem Fall verwendet man einfach die zuletzt aufgezeigte Form. Der Sinn des Adap-
ters ist ja gerade, dass er die Containerklasse vor dem Anwender verbirgt.
1015
24 Die C++-Standardbibliothek und Ergänzung
Wie bei den zugrunde liegenden Containern muss auch hier nicht extra eine Basis-
klasse eingerichtet werden. Sie können einen Stack auch über einem der Grundda-
tentypen erzeugen:
stack<int> istack;
stack<float, vector<float>> fstack;
Das ist eine einfache Art, einen LIFO-Datenspeicher für Grunddatentypen zu erhal-
ten, und wird auch oft so gemacht.
Der Stack kann auch mit einem bereits bestehenden Container initialisiert werden.
Hier wird dann eine Kopie der Elemente im Stack erzeugt. Der Container wird nicht
in den Stack »eingeschoben«:
Sobald Sie einen Stack eingerichtet haben, können Sie ihn über die folgenden Mem-
berfunktionen bedienen:
push Legt ein Element des Basistyps oben auf dem Stack ab.
top Erzeugt eine Referenz auf das oberste Element auf dem Stack.
size Gibt die Anzahl der Elemente auf dem Stack zurück.
Über diese sehr einfache Schnittstelle kann der Stack dann bedient werden. Ich zeige
Ihnen dazu ein Beispiel, bei dem ich int als Basisdatentyp verwende:
A stack<int> stck;
D while( !stck.empty() )
{
E cout << stck.top( ) << ' ';
1016
24.9 Warteschlangen (queue)
F stck.pop();
}
Das Programm legt einen Stack an (A) und legt die Zahlen von 111 bis 119 als Elemente
auf dem Stack ab (B). Nach der Ausgabe der Stackgröße (C) wird in einer Schleife (D) so
lange jeweils das oberste Element vom Stack ausgegeben und vom Stack entfernt, bis
der Stack wieder leer (empty) ist.
stck.size(): 9
119 118 117 116 115 114 113 112 111
Ich gehe hier noch einmal darauf ein, weil die Standardbibliothek für diesen oft ver-
wendeten Datentyp ebenfalls eine unkompliziert einzusetzende Implementierung
als Adapter bereithält.
Die Container, mit denen wir die Queue implementieren können, sind die Liste und
die hier nicht separat behandelte Dequeue. Vektoren können für die Warteschlange
nicht als Container genutzt werden, da sie das Einfügen am Anfang nicht effizient 24
beherrschen. Um eine Queue zu verwenden, wird die entsprechende Header-Datei
inkludiert:
#include <queue>
using namespace std;
Die Instanziierung wird dann wie beim Stack durchgeführt. Es folgen einige Beispiele,
die auf der Verwendung der Basisklasse klasse und des Grunddatentyps int ba-
sieren:
1017
24 Die C++-Standardbibliothek und Ergänzung
Auch hier können zur Initialisierung wieder Kopien bereits existierender Container
verwendet werden:
list <klasse> l;
queue<klasse, list <klasse>> lq( l );
Die Queue hat ebenso wie der Stack ihren sehr übersichtlichen Befehlssatz als Vorteil:
front Erzeugt eine Referenz auf das Element am Anfang der Warteschlange.
back Erzeugt eine Referenz auf das Element am Ende der Warteschlange.
Um die Bedienung der Warteschlange zu demonstrieren, setze ich das Beispiel zur
Bedienung des Stacks analog noch einmal als Warteschlange um:
queue<int> que;
while( !que.empty( ) )
{
cout << que.front( ) << ' ';
que.pop( );
}
Wie in dem Beispiel für den Stack werden auch hier die Zahlen von 111 bis 119 in die
Datenstruktur gegeben und dann so lange ausgegeben und danach mit pop entnom-
men, bis die Struktur leer ist. Hier wird nun das Element, das am längsten wartet,
zuerst ausgegeben (FIFO):
1018
24.10 Prioritätswarteschlangen (priority_queue)
queue.size(): 9
111 112 113 114 115 116 117 118 119
Damit erhalten Sie gleich eine erste Idee von den Anforderungen, die die Prioritäts-
warteschlange an die verwalteten Objekte stellt. Es muss ein Maß geben, um deren
Wichtigkeit ermitteln zu können. Praktisch bedeutet das, dass die verwalteten
Objekte einen Größenvergleich ermöglichen müssen. Wir nehmen dabei zuerst ein-
mal an, dass das größte Element auch das wichtigste ist.
Intern ist die Prioritätswarteschlange als Heap organisiert. Wir haben den Heap als
Datenstruktur schon unter verschiedenen Aspekten diskutiert. Über einen Heap
konnte man elegant und effizient das jeweils größte Element nach vorne bringen
und bei Bedarf entnehmen. Mit diesen Details müssen wir uns an dieser Stelle nicht
mehr beschäftigen, da wir die Datenstruktur hier nur verwenden wollen. Dazu bin-
den wir die Header-Datei der Queues ein:
#include <queue>
using namespace std;
24
Der Einfachheit halber starten wir unser erstes Beispiel mit dem Basisdatentyp int
und richten damit eine Prioritätswarteschlange ein, die ganze Zahlen ihrer Größe
nach verwaltet.
priority_queue<int> meineQueue;
Standardmäßig wird die Queue über einem vector eingerichtet. Man kann das auch
explizit verlangen:
1019
24 Die C++-Standardbibliothek und Ergänzung
Die damit eingerichtete Prioritätswarteschlange arbeitet so, dass sie zum Vergleich
von Objekten und zum Ermitteln des größten und damit wichtigsten Objekts den
operator< (less) für ganze Zahlen verwendet. Auch dies können Sie explizit festlegen:
Diese Art des Vergleichs führt dazu, dass die größten Objekte (hier Zahlen) zuerst ent-
nommen werden. Möchten Sie, dass zuerst die kleinen Zahlen entnommen werden,
legen Sie als Vergleichsoperator den operator> (greater) fest und geben das bei der
Erstellung der Queue an:
Zur Verwendung von greater muss zusätzlich noch eine weitere Header-Datei einge-
bunden werden:
#include <functional>
push Legt ein Element des Basistyps in der Warteschlange ab. Das Element
reiht sich entsprechend seiner Priorität in die Warteschlange ein.
pop Entfernt das Element am Anfang der Warteschlange. Dies ist das Ele-
ment mit der höchsten Priorität. Haben mehrere Elemente die höchste
Priorität, dann ist es eines davon.
top Erzeugt eine Referenz auf das Element am Anfang der Warteschlange.
Dies ist das Element mit der höchsten Priorität. Haben mehrere Ele-
mente die höchste Priorität, dann ist es eines davon.
Mit diesen Informationen können wir nun ein vollständiges Beispiel erstellen:
priority_queue<int> meineQueue;
srand( 123 );
for( int i = 0; i < 10; i++ )
meineQueue.push( rand() % 100 );
1020
24.10 Prioritätswarteschlangen (priority_queue)
while( !meineQueue.empty() )
{
cout << meineQueue.top() << ' ';
meineQueue.pop();
}
Das Programm erzeugt zehn Zufallszahlen zwischen 0 und 99 und stellt sie in eine
Prioritätswarteschlange ein. Anschließend entnimmt es die Zahlen nacheinander
aus der Warteschlange und stellt das Ergebnis auf dem Bildschirm dar. Die Ausgabe
zeigt, dass die Zahlen ihrer Größe nach entnommen werden7:
78 75 65 64 63 60 53 49 40 4
Im Allgemeinen wollen wir anstelle der Zahlen allerdings auch Objekte in der Priori-
tätswarteschlange verwenden. Damit das möglich ist, muss die verwendete Basis-
klasse die Vergleichsoperatoren operator< und /oder operator> so überladen, dass sie
die gewünschte Rangfolge oder Priorität abbilden. Ich habe eine entsprechende
Erweiterung der Klasse klasse in Abschnitt 24.7.4 bereits vorgenommen, sodass wir
klasse direkt als Datentypen verwenden können. Wir können dabei wählen, ob die
Ordnung absteigend (less) oder aufsteigend (greater) sein soll. Wir betrachten ein
Beispiel mit aufsteigender Ordnung:
srand( 123 );
for( int i = 0; i < 10; i++ )
meineQueue.push( rand() % 100 );
while( !meineQueue.empty() )
{
cout << meineQueue.top() << ' ';
24
meineQueue.pop();
}
Beachten Sie, dass das Beispiel bis auf die Konstruktion der Prioritätswarteschlange
vollkommen identisch ist. Das liegt daran, dass die Klasse klasse, was die Wertzuwei-
sung und Ausgabe betrifft, wie ganze Zahlen behandelt werden kann. Die Ausgabe ist
die gleiche wie zuvor. Da wir allerdings das Sortierkriterium gedreht haben, erhalten
wir jetzt die umgekehrte Reihenfolge:
7 Dies sind die Werte, die auf meinem System ausgegeben worden sind. Auf Ihrem System kann
der Zufallszahlengenerator andere Werte liefern.
1021
24 Die C++-Standardbibliothek und Ergänzung
4 40 49 53 60 63 64 65 75 78
Ich hatte Ihnen bereits bei der Verwendung der Listen die Arbeit mit Prädikaten und
Funktionsobjekten vorgestellt. Beide Vorgehensweisen wollen wir auch jetzt nutzen,
um die Funktionalität der Prioritätswarteschlange auch mit einer Vergleichsfunk-
tion zu nutzen, die außerhalb der Klasse des Basisdatentyps implementiert ist.
In diesem Fall wird der Vergleich nicht mit dem operator> und operator< in der
Klasse durchgeführt, sondern mit einem externen Vergleich. Das ist z. B. notwendig,
wenn man keinen Zugriff auf den Quellcode der Basisdatenklasse hat, die in der Prio-
ritätswarteschlange verwaltet wird. Wenn unsere Klasse klasse keine entsprechen-
den Vergleichsoperatoren hätte und der Quellcode uns nicht zugänglich wäre,
müssten wir diesen Weg beschreiten. Diese Situation liegt bei Verwendung von Bibli-
otheken durchaus häufiger vor.
Als Basis für das weitere Vorgehen erstellen wir erst einmal eine Vergleichsfunktion,
die in der Lage ist, zwei Elemente unseres Basisdatentyps miteinander zu ver-
gleichen:
Bei der Instanziierung der Prioritätswarteschlange können wir diese Funktion jetzt
zusätzlich an das Template übergeben:
srand( 123 );
for( int i = 0; i < 10; i++ )
meineQueue.push( rand() % 100 );
while( !meineQueue.empty() )
{
cout << meineQueue.top() << ' ';
meineQueue.pop();
}
Immer wenn jetzt innerhalb der Warteschlange ein Vergleich zweier Objekte des
Basisdatentyps erforderlich ist, wird die Funktion klasseCmp verwendet. Sie können
das Testprogramm unverändert mit diesem Beispiel starten und erhalten die zufällig
erzeugten Werte dann in absteigender Reihenfolge.
1022
24.10 Prioritätswarteschlangen (priority_queue)
Im Abschnitt über Listen habe ich Ihnen die Arbeit mit Funktionsobjekten gezeigt,
die einen überladenen operator() haben und konfigurierbar sind. Ein solches Funk-
tionsobjekt werden wir auch hier noch einmal erstellen und verwenden. Dazu dekla-
rieren wir eine Klasse, die Objekte vom Basisdatentyp klasse mit einem überladenen
operator() vergleichen kann:
class meinCmp
{
private:
bool richtung;
public:
meinCmp( bool r = true ) { richtung = r; }
Die Klasse enthält die Membervariable richtung. Mit ihr wird die Vergleichsrichtung
(aufsteigend oder absteigend) gesteuert. Konfiguriert wird richtung bei der Instanzi-
ierung im Konstruktor. Über den überladenen operator() können wir die Klasse
explizit nutzen, um Objekte vom Typ klasse zu vergleichen:
klasse k1 = 3;
klasse k2 = 99;
meinCmp cmp;
if( cmp(k1, k2))
// Aktion
24
Im Beispiel oben findet der Vergleich auf < statt, da der Default für richtung verwen-
det wird. Wollen wir auf > vergleichen, muss die Klasse entsprechend instanziiert
werden:
meinCmp( false );
Aber wie bei den Listen wollen wir die Klasse gar nicht explizit verwenden, sondern
dem Container zur Verfügung stellen, der sie dann implizit aufruft. Dazu ändern wir
das Standardbeispiel dieses Abschnitts wie folgt ab:
1023
24 Die C++-Standardbibliothek und Ergänzung
srand( 123 );
for( int i = 0; i < 10; i++ )
meineQueue.push( rand() % 100 );
while( !meineQueue.empty() )
{
cout << meineQueue.top() << ' ';
meineQueue.pop();
}
Im Unterschied zur Vorgängerversion übergeben wir jetzt bei der Konstruktion der
Warteschlange die Vergleichsklasse meinCmp als Parameter an das Template.
Damit werden die Objektvergleiche innerhalb der Warteschlange nun mit dem über-
ladenen operator() der Klasse meinCmp ausgeführt. Für eine andere Vergleichsrich-
tung muss die Klasse dann nur anders konfiguriert werden, und die erste Zeile ändert
sich zu:
Mehr müssen wir mithilfe des Funktionsobjekts nicht tun, um die Reihenfolge der
Ausgabe umzukehren.
Der Zugriff auf die beiden Elemente erfolgt über die öffentlichen Datenmember
first und second:
p1.first = p2.first – 1;
p1.second = "zwei";
1024
24.12 Mengen (set und multiset)
Auch für Paare ist eine Funktion swap definiert, die zwei gleichartige Paare ver-
tauscht:
if( p1 >= p2 )
p1.swap( p2 ); // Tausche p1 und p2
Unterstützen bedeutet in diesem Fall, dass die verwendeten Datentypen beide den
operator= und den operator< unterstützen müssen. Durch die Art, wie der Vergleich
für Paare intern implementiert ist, reicht das aus, um alle Vergleiche daraus zu erstel-
len. Bei einem Vergleich auf <, <=, > und >= hat das erste Element des Paares ein grö-
ßeres Gewicht. Sie kennen das vom Vergleich eines Datums aus Monat und Tag.
Dabei kommt es zuerst auf den Monat an, er hat das höhere Gewicht. Wenn der
Monat gleich ist, entscheidet als Kriterium der Tag den Vergleich.
Paare allein werden selten verwendet. Sie werden aber als Schlüssel-Wert-Kombinati-
onen bei den Containern genutzt, die ich Ihnen jetzt vorstellen werde.
Dem Container wird die Art der internen Organisation überlassen. Dadurch lassen
sich Suchen hier besonders effizient implementieren, etwa durch geordnete Binär-
bäume. Bei der Liste und dem Vektor hingegen muss für eine Suche viel aufwendiger
sequenziell durch die Objekte gegangen werden.
Damit eine solche effiziente interne Anordnung für die Objekte in der Menge erstellt
werden kann, müssen diese eine Funktion oder einen Operator implementieren,
über die ein »Kleiner-als-Vergleich« möglich ist. Das reicht nicht nur aus, um eine
Ordnung zwischen den Objekten herzustellen, damit kann auch die Gleichheit von
1025
24 Die C++-Standardbibliothek und Ergänzung
Objekten bestimmt werden. Die ist gegeben, wenn weder das eine Objekt kleiner ist
als das andere noch das andere kleiner als das eine.
Zur Verwendung von Mengen mit den Templates set und multiset wird die Header-
Datei set inkludiert:
#include <set>
using namespace std;
Objekte, die in einer Menge verwaltet werden sollen, müssen miteinander vergleich-
bar sein. Der notwendige <-Vergleich kann über einen überladenen operator< erfol-
gen, aber auch über eine externe Vergleichsfunktion oder Vergleichsklasse. Die zum
Objektvergleich verwendeten Attribute nennen wir Schlüssel des Objekts.
24.12.1 Konstruktion
Mengen werden wie die anderen Containerklassen auch über den Basisdatentyp kon-
struiert, den sie verwalten sollen. Wir verwenden auch hier wieder die Klasse klasse:
Die Konstruktion ist so möglich, da die Klasse eine Implementierung für einen über-
ladenen operator< besitzt.
Hat die Klasse keinen überladenen Operator oder soll mit einer externen Funktion
verglichen werden, die die folgende Schnittstelle hat
Hat man eine Vergleichsklasse meinCmp mit einem überladenen operator(), die para-
meterlos konstruiert werden kann, dann reicht die folgende Konstruktion:
set<klasse, meinCmp> s;
Ebenso wie bei anderen Containern können Sie eine Menge auch unter Verwendung
einer anderen Menge oder von Teilen einer anderen Menge konstruieren:
1026
24.12 Mengen (set und multiset)
Multisets unterscheiden sich von Sets dadurch, dass sie mehrere Objekte mit glei-
chem Schlüsselwert aufnehmen und auch verwalten können. Bei der Konstruktion
verwenden Sie dann natürlich das Template multiset anstelle von set, um ein Multi-
set zu erhalten. Die Verwendung ist dann weitestgehend gleich, ich weise nur noch
auf die Unterschiede hin.
24.12.2 Zugriff
Auch für Mengen gibt es die Funktionen size und empty, auf die ich jetzt aber nicht
noch einmal eingehe. Auch das Konzept der Iteratoren kennen Sie bereits von ande-
ren Containern. Da die Daten intern in einem aufsteigend sortierten Baum abgelegt
werden, führt ein solches Abfahren des Containers:
set<klasse>::iterator it;
for( it = s.begin(); it != s.end(); it++ )
cout << *it << ' ';
Bei Mengen ist die wesentliche Funktion zum Zugriff die find-Funktion. Die Funk-
tion erhält als Parameter ein Objekt. Wenn in der Menge ein gleiches Objekt vorhan-
den ist (im Sinne von weder größer noch kleiner), erhalten Sie einen Iterator zurück,
der auf das Element zeigt. Wird das Element nicht in der Menge gefunden, ist der
Rückgabewert der Funktion ein Iterator auf das Ende der Menge.
set<klasse> s;
set<klasse>::iterator it;
it = s.find( 123 );
24
if( it == s.end() )
cout << "Nicht gefunden\n";
Ein Multiset kann ein Objekt zwar mehrfach enthalten, aber auch bei einem Multiset
liefert find maximal einen Treffer. In einem Multiset verwendet man deshalb häufig
die Funktion equal_range. Diese liefert nicht einen einzelnen Treffer, sondern einen
Trefferbereich. Der Trefferbereich wird durch zwei Iteratoren beschrieben, die den
Anfang und das Ende des Bereichs markieren. Die beiden Iteratoren werden als
geordnetes Paar (siehe dazu Abschnitt 24.11, »Geordnete Paare (pair)«) zurückgege-
ben. Die Verwendung von equal_range könnte damit so aussehen:
1027
24 Die C++-Standardbibliothek und Ergänzung
multiset<klasse> m;
Beachten Sie, dass auch hier der Ende-Iterator des Trefferbereichs wie üblich bereits
außerhalb des Bereichs liegt.
Häufig interessiert man sich für Elemente, deren Schlüsselwerte in einem bestimmten
Wertebereich liegen. Um solche Elemente in einer Menge zu finden, verwenden Sie die
Funktion lower_bound und upper_bound. Beide Funktionen geben Iteratoren zurück
und können dazu verwendet werden, den gesamten Trefferbereich zu durchlaufen:
set<klasse> s;
// Iteratoren berechnen
lb = s.lower_bound( 123 );
ub = s.upper_bound( 123 );
1028
24.12 Mengen (set und multiset)
Das mit lower_bound gefundene Objekt ist das erste Objekt innerhalb des Trefferbe-
reichs, also das kleinste Objekt, das größer oder gleich der angegebenen Schranke ist.
Das mit upper_bound gefundene Objekt ist das erste, das außerhalb des Trefferbe-
reichs liegt, also das kleinste Objekt, das größer als die angegebene Schranke ist.
Achtung: Die Objekte werden in der Menge als konstante Objekte gespeichert und
können dort auch nicht geändert werden. Wenn Sie den Schlüssel eines Objekts
ändern möchten, müssen Sie das Objekt aus dem Container entnehmen und mit
geändertem Schlüssel wieder speichern.
24.12.3 Manipulation
Zum Aufbau, Abbau und Umbau von Mengen dienen die folgenden Funktionen:
왘 insert
왘 erase
왘 clear
왘 swap
Mit insert kann ein Element in ein Set oder Multiset eingefügt werden. Generell
kann insert in beiden Fällen gleich verwendet werden:
set<klasse> s;
s.insert( 123 );
multiset<klasse> m;
m.insert( 123 );
In dem oben gezeigten Beispiel wird allerdings der Rückgabewert der Funktion igno-
riert, der bei Einfügen in set und multiset jeweils unterschiedlich arbeitet.
In einem Multiset kann ein Schlüssel mehrfach vorkommen. Der Rückgabewert von
insert in einem Multiset ist daher einfach immer ein Iterator auf das neu eingefügte
Objekt: 24
multiset<klasse> m;
multiset<klasse>::iterator it;
it = m.insert( 123 );
Bei einem Set wird das Objekt nicht eingefügt, wenn der Schlüssel in der Menge
bereits vorhanden ist. Um dazu alle Informationen zu übertragen, ist der Rückgabe-
typ von insert für ein Set daher ein geordnetes Paar mit einem Iterator und einem
booleschen Wert.
1029
24 Die C++-Standardbibliothek und Ergänzung
Abhängig vom Erfolg der Operation, der über den booleschen Wert signalisiert wird,
zeigt der Iterator dann entweder auf das neu eingesetzte oder auf das bereits vorhan-
dene Element mit dem gleichen Schlüssel. Im Beispiel sieht das dann so aus:
set<klasse> s;
pair< set<klasse>::iterator, bool> ret;
ret = s.insert( 5 );
#include <map>
using namespace std;
24.13.1 Konstruktion
Zur Konstruktion einer Map benötigen Sie einen Datentyp als Schlüssel und einen
Datentyp für die Werte. Wie bei den Mengen muss der Schlüsseldatentyp einen
»Kleiner-als-Vergleich« unterstützen, um eine effiziente Schlüsselsuche zu ermögli-
chen. Auch hier kann der Vergleich mit einem überladenen Operator, einer Funktion
oder einem Funktionsobjekt erfolgen.
1030
24.13 Relationen (map und multimap)
In dem Beispiel werde ich die Klasse string als Schlüssel verwenden und klasse als
Datentyp für den Wert. Eine entsprechende Relation wird dann folgendermaßen auf-
gebaut:
Dies ist mit Sicherheit die am häufigsten vorkommende Verwendung. Man legt
Objekte unter einem Namen ab und verwendet für den Namen die Klasse string. Die
Klasse string trägt alles, was es für einen effektiven Zugriff über den Namen braucht,
bereits in sich.
Würde man an dieser Stelle eine eigene Klasse verwenden, müsste diese einen »Klei-
ner-als-Vergleich« unterstützen. Die verschiedenen Varianten mit Verwendung
eines überladenen Operators, einer externen Funktion oder einem Funktionsobjekt
haben Sie in anderen Beispielen bereits gesehen, sodass ich sie nicht noch einmal
aufführe.
Ebenso wie die anderen Container können Maps auch durch Zuweisung einer beste-
henden Map oder durch Auswahl eines bestimmten Bereichs einer Map initialisiert
werden:
Der Unterschied zwischen Maps und Multimaps besteht darin, dass eine Multimap
mehrere Elemente mit gleichem Schlüsselwert aufnehmen und verwalten kann. Bei
der Konstruktion einer Multimap wird lediglich multimap anstelle von map verwendet.
Ansonsten gibt es bei der Konstruktion keine Unterschiede.
24.13.2 Zugriff
Der Zugriff bei Maps verhält sich wie der Zugriff auf Sets, Multimaps verhalten sich 24
wie Multisets. Auch hier gibt es die Funktionen size und empty sowie den Zugriff über
Iteratoren. Ebenso sind die Funktionen find, equal_range sowie lower_bound und
upper_bound vorhanden. Die Suchfunktionen erhalten als Argument ein passendes
Schlüsselobjekt.
Eine Besonderheit im Zugriff auf Maps soll hier allerdings noch herausgestellt wer-
den. Maps bieten einen Zugriff über den operator[]. Mit dem Operator kann der
Schlüssel der Map direkt wie ein Index in einem Array verwendet werden. Diese Nut-
zung, auch assoziativer Zugriff genannt, vereinfacht die Nutzung von Maps deutlich,
wie das Beispiel zeigt:
1031
24 Die C++-Standardbibliothek und Ergänzung
meineMap["eins"] = 1;
meineMap["zwei"] = 2;
klasse k = meineMap["eins"];
Der assoziative Zugriff macht die Map vielleicht sogar zum wichtigsten Container der
Standardbibliothek. Er ist allerdings ausschließlich für die Map verfügbar, für die
Multimap existiert der assoziative Zugriff nicht.
24.13.3 Manipulation
Zur Manipulation von Maps und Multimaps dienen die folgenden Funktionen:
왘 insert
왘 erase
왘 clear
왘 swap
Die Funktionen sind bedeutungsgleich mit den gleichnamigen Funktionen für Sets
und Multisets. Sie können deren Verwendung bei Bedarf im vorangegangenen
Abschnitt nachschlagen.
list<klasse>::iterator it;
Mit dieser Möglichkeit, alle Elemente eines Containers auf eine standardisierte
Schnittstelle ansprechen zu können, liegt die Idee nahe, die Iteratoren auch zu ver-
wenden, um wichtige Aufgaben wie das Suchen oder Sortieren in Containern umzu-
setzen.
1032
24.14 Algorithmen der Standardbibliothek
Gemessen daran, wie oft Sie dieses Stück Code allein in diesem Kapitel gesehen
haben, sind das Durchlaufen aller Elemente eines Containers und die Verwendung
jedes Elements ganz offensichtlich Standardaufgaben. In einigen anderen Program-
miersprachen gibt es zu diesem Zweck eigens einen Befehl foreach, mit dem man z. B.
durch jedes Element eines Arrays laufen kann, ohne dabei auf Anfang und Ende zu
achten. Im Sprachumfang von C++ ist ein solcher Befehl nicht enthalten. Wie Sie
schon gesehen haben, bieten die Container der Standardbibliothek mit der Informa-
tion zu ihrer Größe und den Iteratoren alle Möglichkeiten, die für eine Nachbildung
des foreach-Befehls notwendig sind.
Daher können wir einen solchen Befehl hier als generische Funktion nachbilden, als
sogenannten Algorithmus der Standardbibliothek.
Dazu benötigen wir einen Start- und Enditerator, um den zu bearbeitenden Bereich
zu begrenzen, und eine Funktion, die auf jedes Element des Containers ausgeführt
werden soll. Dabei ist es egal, um welche Art des Containers (Vektor, Liste, Menge etc.)
es sich handelt. Der Zugriff auf die Objekte soll über die Iteratoren erfolgen, die das
Objekt dann an die Bearbeitungsfunktion weitergeben. Auch das will ich Ihnen an
einem Beispiel zeigen.
#include <algorithm>
using namespace std; 24
Ausgangspunkt für die zu bearbeitenden Instanzen ist auch hier wieder die Klasse
klasse, die Sie bereits aus Abschnitt 24.6.1 und Abschnitt 24.7.4 kennen (hier noch
einmal ohne die Vergleichsoperatoren):
class klasse
{
private:
int x;
1033
24 Die C++-Standardbibliothek und Ergänzung
public:
klasse( int xx = 0 ) { x = xx; }
void setX( int xx ) { x = xx; }
int getX() const { return x; }
};
Das foreach, das wir verwenden wollen, soll eine Funktion auf alle Elemente eines
Containers ausführen. Die Elemente sind in unserem Beispiel Instanzen der Klasse
klasse. Ich verwende hier noch einmal die Funktion, die die im Objekt gekapselten
int-Werte ausgibt8:
Jetzt können wir ein dynamisches Array (vector) einrichten und drei willkürliche
Werte im Array ablegen und dann die Funktion for_each aufrufen:
meinVector.push_back( 1 );
meinVector.push_back( 2 );
meinVector.push_back( 3 );
Wird die Funktion meineFkt für jedes Element zwischen den beiden Iteratoren
meinVector.begin() und meinVector.end() ausgeführt, erhalten wir folgende Aus-
gabe:
1 2 3
Das Verfahren ist unabhängig vom Container, sodass wir es z. B. auch mit einem set
ausführen könnten:
set<klasse> meinSet;
meinSet.insert( 1 );
meinSet.insert( 2 );
meinSet.insert( 3 );
8 Je nach Art des Containers können die Instanzen aus dem Container auch vom Typ const klasse&
sein, das sollte bei den Funktionen entsprechend berücksichtigt werden.
1034
24.14 Algorithmen der Standardbibliothek
class fktKlasse
{
private:
int faktor;
public:
fktKlasse( int f = 1 ) { faktor = f; }
void operator() ( klasse& k ) { cout << k.getX() * faktor << ' '; }
};
Mit dem Funktionsobjekt können wir das Vielfache eines Wertes für jedes Element
im Container ausgeben. Den Faktor, mit dem vervielfacht wird, können wir dabei bei
der Konstruktion bestimmen. Dazu geben wir ihn im Konstruktor mit.
Damit können wir dem generischen for_each anstelle der Funktion für jedes Element
das Funktionsobjekt mitgeben
meinVector.push_back( 1 );
meinVector.push_back( 2 );
meinVector.push_back( 3 );
for_each( meinVector.begin(), meinVector.end(), fktKlasse( 3 ) );
3 6 9
Auch hier kann das passende Funktionsobjekt natürlich auch vorab instanziiert wer-
den und dann übergeben werden. Die Funktionalität ist die gleiche:
fktKlasse fK( 5 ); 24
for_each( meinVector.begin(), meinVector.end(), fK );
Nur das Ergebnis unterscheidet sich aufgrund des hier gewählten Faktors 5 natürlich:
5 10 15
Der Algorithmus for_each ist nur einer der vielen von der C++-Standardbibliothek
bereitgestellten Algorithmen. Als nur ein weiteres Beispiel unter vielen kann mit den
Algorithmen gezählt werden:
1035
24 Die C++-Standardbibliothek und Ergänzung
meinVector.clear();
for( int i = 0; i < 10; i++ )
meinVector.push_back( i % 3 );
0 1 2 0 1 2 0 1 2 0
2 kommt 3 mal vor
Es gibt Algorithmen zum Löschen und Tauschen von Elemente, zum Mischen und
zum Kopieren etc.
Die Standardbibliothek enthält eine Vielzahl von Algorithmen, die ich Ihnen hier
nicht alle vorstellen werde. Die Algorithmen decken u. a. folgende Bereiche ab:
왘 Iterieren
왘 Suchen und Finden
왘 Vergleichen
왘 Zählen
왘 Kopieren
왘 Tauschen
왘 Ersetzen
왘 Wertzuweisung
왘 Entfernen von Elementen
왘 Reorganisation
1036
24.14 Algorithmen der Standardbibliothek
왘 Sortieren
왘 binäre Suche
왘 Mischen
왘 Mengenoperationen
왘 Heap-Algorithmen
왘 Minima und Maxima
왘 lexikografische Ordnung und Permutation
Sie können durch die Verwendung der Algorithmen der Standardbibliothek viele
Aufgabe elegant und effizient lösen. Durch die Nutzung des Bibliothekscodes erspa-
ren Sie sich nicht nur den Implementierungaufwand, Sie greifen auch direkt auf aus-
giebig getestete Funktionen zurück. Sie sollten sich daher die Zeit nehmen, sich mit
der Standardbibliothek vertraut zu machen.
Ich zeige Ihnen dieses Verhalten am Beispiel eines dynamischen Arrays (vector). Was
ich hier demonstriere, gilt aber auch für die anderen Container.
Um das Verhalten des Containers zu testen, erstelle ich zuerst zwei Klassen basis und
abgeleitet mit einer einfachen Vererbungsbeziehung:
class basis
{
public:
virtual void printClass() { cout << "basis" << '\n'; }
};
Mit einem Aufruf der virtuellen Funktion printClass können die Instanzen der Klas-
sen ausgeben, zu welcher Klasse sie gehören. Entsprechende Beispiele zum Verhalten
virtueller Funktionen kennen Sie auch schon aus Kapitel 23, »Zusammenfassung und
Ergänzung«.
1037
24 Die C++-Standardbibliothek und Ergänzung
Um das Verhalten der Klassen im Container zu testen, erstellen wir ein dynamisches
Array:
vector<basis> v;
Der Vektor ist über den Datentyp basis angelegt. Eine Instanz der Klasse abgeleitet
ist aufgrund der öffentlichen Vererbung eine Instanz der Klasse basis und kann
daher natürlich auch im Container abgelegt werden:
abgeleitet a;
v.push_back( a );
Beim Einfügen in den Container wird das eingefügte Objekt allerdings in den Contai-
ner kopiert. Da der Container über den Datentyp basis angelegt worden ist, ist auch
die Kopie im Container von diesem Datentyp. Sie können das leicht prüfen, indem
Sie auf das Objekt und seine Kopie im Container die Funktion printClass aufrufen
a.printClass();
v[0].printClass();
abgeleitet
basis
Das Objekt a ist unzweifelhaft vom Typ abgeleitet, die Kopie im Container gibt sich
als vom Typ basis zu erkennen. Die Verfeinerung auf die Klasse abgeleitet geht im
Container verloren, da der Container nur Klassen vom Typ basis speichert.
vector<basis*> v;
Hier können wir eine Instanz der Klasse abgeleitet erzeugen und deren Adresse im
Container ablegen:
abgeleitet a;
v.push_back( &a );
1038
24.14 Algorithmen der Standardbibliothek
a.printClass( );
v[0]->printClass( );
abgeleitet
abgeleitet
Da der Container nur noch einen Zeiger verwaltet, war das auch nicht anders zu
erwarten.
Der Nachteil dieses Verfahrens ist allerdings auch offensichtlich. Da wir nur den Zei-
ger auf das Objekt in den Container kopieren, kann der Container nicht mehr das
Speichermanagement für die zugrunde liegenden Instanzen übernehmen. Das müs-
sen wir nun in beide Richtungen selbst verantworten. Die Objekte müssen so lange
existieren, wie ein Verweis auf sie im Container existiert. Bei einer Löschoperation
im Container muss gegebenenfalls die zum Zeiger gehörige Instanz außerhalb des
Containers gelöscht werden. Darum muss sich der Entwickler dann entsprechend
den Anforderungen seines Programms wieder selbst kümmern.
24
1039
Anhang A
Aufgaben und Lösungen
Es werden mehr Menschen durch Übung tüchtig als durch Natur-
anlage.
– Demokrit
Aufgaben sind ein ganz wesentlicher Bestandteil dieses Buches. Ob Sie die Inhalte
eines Abschnitts wirklich verstanden haben, können Sie erst erkennen, wenn Sie in
der Lage sind, das Erlernte selbstständig auf konkrete Aufgabenstellungen anzu-
wenden.
Wenn man Aufgaben stellt, wird natürlich auch sofort nach Musterlösungen gerufen,
weil man ein »amtliches« Ergebnis haben will, mit dem man sein eigenes Ergebnis
vergleichen kann. Dieser Gedanke ist stark von schulischen Aufgaben geprägt, bei
denen ein Lehrer oft ein ganz bestimmtes konkretes Ergebnis erwartet. Zu den Aufga-
ben dieses Buches gibt es in der Regel nicht »die richtige Lösung«, sondern es gibt
eine ganze Reihe von Lösungsmöglichkeiten, unter denen es vielleicht nicht einmal
die »beste Lösung« gibt. Bei der Beurteilung Ihrer eigenen Lösung sind Sie daher
immer auf Ihren eigenen Sachverstand angewiesen. Eine hier angebotene Lösung ist
nicht von sich aus besser als eine von Ihnen selbst erstellte, sie ist vielleicht nur
anders. Oft ist sie auch aus Gründen der Verständlichkeit nicht bis ins Letzte opti-
miert. Für Ihren persönlichen Fortschritt ist eine selbst erstellte Lösung ungleich
wertvoller als eine Musterlösung – egal, wie sie im Vergleich mit der Musterlösung
abschneidet. Musterlösungen können sogar kontraproduktiv sein, wenn sie zu häu-
fig oder zu früh in Anspruch genommen werden, weil Sie dann nicht lernen, eigene
Wege zu beschreiten und eigenständige Methoden zur Problemlösung zu ent-
wickeln.
Lösungen
Dieser Lösungsteil ist also ein Giftschrank oder eine Notapotheke, und mir wäre es
am liebsten, wenn Sie diese Seiten niemals aufschlagen müssten. Die Mutigen unter
Ihnen sollten diesen Teil herausreißen und verbrennen.
Um mit diesem Lösungsteil produktiv zu arbeiten, sollten Sie sich strenge Regeln
auferlegen. Wenn Sie eine Aufgabe nicht lösen können, dann arbeiten Sie zunächst
noch einmal das zugehörige Kapitel durch. Vielleicht haben Sie wichtige Informatio-
1041
A Aufgaben und Lösungen
nen übersehen, die Sie zur Lösung der Aufgabe benötigen. Schauen Sie erst dann in
den Lösungsteil, wenn Sie alle eigenen Ansätze erschöpft haben. Seien Sie hartnäckig,
und geben Sie bei der Lösungssuche nicht vorzeitig auf, auch wenn die Suche einmal
mehrere Stunden dauert. Gerade diese – scheinbar vergeudete – Zeit ist für den Lern-
prozess von größter Wichtigkeit. Gehen Sie erst zum nächsten Kapitel, wenn Sie das
vorherige Kapitel einschließlich der Aufgaben vollständig verstanden haben. Wenn
Sie dann eine Aufgabe gelöst haben, können Sie hier ganz entspannt nachschlagen,
wie Sie es auch hätten machen können.
Sollten Sie trotzdem einmal auf der Suche nach Lösungsansätzen hier landen, lesen
Sie die Musterlösung immer nur so weit, bis Sie eine neue Anregung zur Lösung
gefunden haben. Gehen Sie dann sofort zur Aufgabe zurück, und versuchen Sie, die
Aufgabe mit diesem Ansatz zu lösen. Um Sie bei diesem iterativen Prozess zu unter-
stützen, sind die Lösungen häufig durch »Spoiler« gegliedert:
Spoiler
Wenn Sie auf einen solchen Spoiler treffen, dann sollten Sie genug Anregungen
bekommen haben, um selbstständig mit der Bearbeitung der Aufgabe fortfahren zu
können. Bringen Sie sich dann nicht um ein Erfolgserlebnis, indem Sie sofort hinter
dem Spoiler weiterlesen.
Kapitel 1
A 1.1 Aufgabe
Formulieren Sie Ihr morgendliches Aufsteh-Ritual vom Klingeln des Weckers bis
zum Verlassen des Hauses als Algorithmus. Berücksichtigen Sie dabei auch verschie-
dene Wochentagsvarianten! Zeichnen Sie ein Flussdiagramm!
Lösung
Zu dieser Aufgabe benötigen Sie keine Musterlösung, da Ihr Aufsteh-Ritual einzigar-
Lösungen
tig ist. Wichtig ist, dass Sie versuchen, knapp und präzise zu formulieren.
Zum Beispiel:
1042
Kapitel 1
nein tag = ja
Waschen Mittwoch Duschen
Zähneputzen
A 1.2 Aufgabe
Verfeinern Sie den Algorithmus zur Division zweier Zahlen aus Abschnitt 1.1, »Algo-
rithmus«, so, dass er von jemandem, der nur Zahlen addieren, subtrahieren und der
Größe nach vergleichen kann, durchgeführt werden kann! Zeichnen Sie ein Flussdia-
gramm!
Lösung
Im Flussdiagramm gibt es zwei Formulierungen, die über das Addieren, Subtrahieren
und Vergleichen von Zahlen hinausgehen. In diesen Formulierungen wird jeweils
eine Multiplikation verwendet:
und
z = 10(z – n · x)
Sie müssen die Multiplikationen durch eine Kette von Additionen ersetzen. Um etwa
x zu ermitteln, können Sie so lange n+n+n+... addieren, bis die Summe z übertrifft.
Sobald Sie z übertroffen haben, haben Sie einmal zu viel addiert und müssen den
Wert n wieder abziehen. In x zählen Sie die Anzahl der Additionen und vermindern x
1043
A Aufgaben und Lösungen
Spoiler
Wir setzen die Idee vor dem Spoiler in ein Flussdiagramm um. In der Variablen x zäh-
len wir die Additionen:
x=0
nx = 0
nein x=x–1
nx ≤ z
nx = nx – n
ja
x=x+1
nx = nx + n
In der Variablen nx steht im Laufe des Verfahrens immer das Produkt n·x. Dieses Pro-
dukt wird am Ende um n vermindert, um es in dem folgenden Algorithmus mit kor-
rektem Wert benutzen zu können.
Diese Fragmente müssen Sie nur noch in das ursprüngliche Flussdiagramm »ein-
bauen« und erhalten dadurch das Ergebnis.
Sie sehen, dass die Darstellung von Algorithmen durch Flussdiagramme sehr schnell
Grenzen erreicht, in denen die Lesbarkeit und die Verständlichkeit nicht mehr
gewährleistet sind.
1044
Kapitel 1
z = 10(z – n · x)
w = z – nx
z=0
i=1
nein
i ≤ 10
ja
z=z+w
i=i+1
A 1.3 Aufgabe
In unserem Kalender sind zum Ausgleich der astronomischen und der kalendari-
schen Jahreslänge in regelmäßigen Abständen Schaltjahre eingebaut. Zur exakten
Festlegung der Schaltjahre dienen die folgenden Regeln:
1. Ist die Jahreszahl durch 4 teilbar, ist das Jahr ein Schaltjahr.
Diese Regel hat allerdings eine Ausnahme:
2. Ist die Jahreszahl durch 100 teilbar, ist das Jahr kein Schaltjahr.
Diese Ausnahme hat wiederum eine Ausnahme:
3. Ist die Jahreszahl durch 400 teilbar, ist das Jahr doch ein Schaltjahr.
Formulieren Sie einen Algorithmus, mit dessen Hilfe man feststellen kann, ob ein
Lösungen
Lösung
Prüfen Sie die »selektivste« Bedingung (3) zuerst. Ist diese Bedingung erfüllt, haben
Sie ein Ergebnis. Ist die Bedingung nicht erfüllt, dann prüfen Sie die Bedingung (2). Ist
diese erfüllt, haben Sie wieder ein Ergebnis. Ist Bedingung (2) nicht erfüllt, müssen Sie
abschließend noch Bedingung (1) prüfen.
1045
A Aufgaben und Lösungen
Spoiler
Jahreszahl: z
z durch ja
400 Schaltjahr
teilbar
nein
kein ja z durch
100
Schaltjahr teilbar
nein
nein z durch 4 ja
teilbar
A 1.4 Aufgabe
Sie sollen eine unbekannte Zahl x (a ≤ x ≤ b) erraten und haben beliebig viele Versu-
che dazu. Bei jedem Versuch erhalten Sie die Rückmeldung, ob die gesuchte Zahl grö-
ßer, kleiner oder gleich der von Ihnen geratenen Zahl ist. Entwickeln Sie einen
Algorithmus, um die gesuchte Zahl möglichst schnell zu ermitteln! Wie viele Versu-
che benötigen Sie bei Ihrem Verfahren maximal?
Lösung
Lösungen
Man könnte fortlaufend die Zahlen 1, 2, 3, ... raten. Dabei würde sich der Suchraum im
Falle einer negativen Antwort um ein Element verkleinern. Effizienter ist es, immer
die Zahl in der Mitte des Suchraums zu raten. Im Falle einer negativen Antwort
könnte man dann den Suchraum halbieren, da anschließend nur noch die Zahlen
rechts oder die Zahlen links von der geratenen Zahl infrage kämen.
Spoiler
1046
Kapitel 1
a+b
Die Mitte zwischen zwei ganzen Zahlen a und b wird durch die Formel ------------- berech-
2
net. Dabei muss aber nicht unbedingt eine ganze Zahl herauskommen. In diesem Fall
wollen wir zur nächstkleineren ganzen Zahl übergehen und dafür die Notation
a + b verwenden.
-------------
2
Das Verfahren ist nun denkbar einfach. Man rät die Zahl in der Mitte zwischen a und
b (Ratezahl = q). Ist die Ratezahl gleich der Geheimzahl, ist man fertig. Ist die Geheim-
zahl größer als die Ratezahl, können wir Ratezahl + 1 als Untergrenze im nächsten
Rateschritt verwenden. Ist die Geheimzahl kleiner, ist Ratezahl –1 die neue Ober-
grenze. Dieses Verfahren setzen wir fort, bis die Zahl geraten ist.
Start
Eingabe a,
b
a+b
q=
2
geheimzahl ja
Ende
=q
nein
ja geheimzahl
b=q–1
<q
nein
Lösungen
a=q+1
1047
A Aufgaben und Lösungen
Spoiler
Wenn man 1 verdoppelt, hat man 2. Wenn man das wieder und wieder verdoppelt,
hat man 4, 8, 16 etc. Allgemein hat man nach k Verdopplungen s = 2k. Wir fragen uns:
Für welchen Wert von k ist s > x = b-a+1?
Das können Sie einfach ausrechnen:
s > x ⇔ 2k > x
⇔ log2(2k) > log2(x)
⇔ k · log2(2) > log2(x)
⇔ k > log2(x)
⇔ k > log2(b – a + 1)
Die maximale Anzahl der Rateschritte k ist also durch den Zweierlogarithmus der
anfänglichen Suchraumgröße gegeben k = log2(b – a +1). Das ist in der Regel keine
ganze Zahl, da sich der Suchraum nicht immer exakt halbieren lässt. Das soll uns aber
egal sein. Streng formal müssten wir zur nächstgrößeren ganzen Zahl übergehen.
Sie werden noch häufiger sehen, dass der Logarithmus eine wichtige Bedeutung für
die Beurteilung von Algorithmen hat. Sollten Sie daher Verständnisprobleme mit
den oben genannten Umformungen haben, empfehle ich Ihnen dringend, Ihre
Kenntnisse über mathematische Funktionen, insbesondere über Potenz- und Loga-
rithmusfunktionen, aufzupolieren.
A 1.5 Aufgabe
Formulieren Sie einen Algorithmus, der prüft, ob eine gegebene Zahl eine Primzahl
ist oder nicht!
Lösung
Eine Primzahl ist eine natürliche Zahl, größer als 1, die nur durch 1 und durch sich
selbst teilbar ist. Um zu testen, ob eine Zahl p eine Primzahl ist, müssen Sie nur fort-
Lösungen
laufend für alle Zahlen zwischen 2 und p-1 testen, ob sie die Zahl p teilen.
Spoiler
Mit dem Hinweis vor dem Spoiler können Sie direkt ein Flussdiagramm zeichnen.
Dieser Algorithmus lässt sich noch optimieren. Damit wollen wir uns aber nicht
beschäftigen.
1048
Kapitel 1
Start
Eingabe p
t=2
nein
t<p Primzahl Ende
ja
nein ja keine
t=t+1 t teilt p
Primzahl
A 1.6 Aufgabe
Ihr CD-Ständer hat 100 Fächer, die fortlaufend von 1–100 nummeriert sind. In jedem
Fach befindet sich eine CD. Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie
die CDs alphabetisch nach Interpreten sortieren können! Das Verfahren soll dabei
auf den beiden folgenden Grundfunktionen basieren:
vergleiche(n,m)
Vergleichen Sie CDs in den Fächern n und m. Das Ergebnis ist »richtig« oder »falsch« –
je nachdem, ob die beiden CDs in der richtigen oder falschen Reihenfolge im Ständer
stehen.
Lösungen
tausche(n,m)
Lösung
Es gibt zahlreiche verschiedene Sortierverfahren. Eins der einfachsten heißt Bubble-
sort und läuft wie folgt ab:
1049
A Aufgaben und Lösungen
dann tausche sie! Nach einem Durchlauf ist auf jeden Fall die CD mit dem alphabe-
tisch letzten Interpreten am Ende des Ständers eingestellt.
왘 Wiederhole diesen Verfahrensschritt so lange, bis die CDs vollständig sortiert
sind! Dabei muss jeweils das letzte Element des vorherigen Durchlaufs nicht mehr
betrachtet werden, da es schon seine endgültige Position gefunden hat!
Spoiler
Die Zahl der im Verfahren noch zu sortierenden CDs bezeichnen wir mit n. Anfäng-
lich ist also n = 100 und wird nach jedem Durchlauf um 1 vermindert. In jedem Durch-
lauf vergleichen wir dann die k-te CD mit ihrer Nachbarin (Index k+1), wobei der
Index k von 1 bis n-1 läuft. Gegebenenfalls wird dann getauscht:
Start
n = 100
n=n–1
nein
n >1 Ende
ja
k=1
k=k+1
nein
k <n
Lösungen
ja
richtig
vergleiche(k, k + 1)
falsch
tausche(k, k + 1)
1050
Kapitel 1
Ob es bessere Verfahren gibt, bleibt offen, da wir ja noch nicht wissen, was »besser«
eigentlich bedeutet. Mit »besser« ist aber sicherlich nicht die Einfachheit des Flussdi-
agramms gemeint, sondern eher die Anzahl der Verfahrensschritte. Machen Sie sich
an dieser Stelle klar, dass ein komplizierteres Flussdiagramm durchaus weniger Ver-
fahrensschritte haben könnte. In einem späteren Kapitel werden wir verschiedene
Sortieralgorithmen betrachten und, um es vorwegzunehmen, der hier vorgestellte
Algorithmus wird der schlechteste von allen sein.
A 1.7 Aufgabe
Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die CDs in Ihrem CD-Ständer
jeweils um ein Fach aufwärts verschieben können! Die dabei am Ende herausgescho-
bene CD kommt in das erste Fach. Das Verfahren soll nur auf der Grundfunktion tau-
sche aus Aufgabe 1.6 beruhen.
Lösung
Zur Lösung der Aufgabe durchlaufen wir den CD-Ständer rückwärts und tauschen
jeweils zwei benachbarte CDs. Alle CDs rücken dabei um einen Platz im Ständer auf,
und die letzte CD wird bis nach vorn durchgereicht.
Spoiler
Wir starten jetzt bei n = 100 und tauschen jeweils die n-te CD mit ihrer Vorgängerin
(Index n-1). Danach zählen wir n um 1 herunter. Dieser Prozess wird fortgesetzt,
solange n > 1 ist:
Start
n = 100
Lösungen
n=n–1
nein
n>1 Ende
ja
tausche(n, n – 1)
1051
A Aufgaben und Lösungen
A 1.8 Aufgabe
Formulieren Sie einen Algorithmus, mit dessen Hilfe Sie die Reihenfolge der CDs in
Ihrem CD-Ständer umkehren können! Das Verfahren soll nur auf der Grundfunktion
tausche aus Aufgabe 1.6 beruhen.
Lösung
Wir tauschen die erste CD mit der letzten, dann die zweite mit der vorletzten etc. Die-
ses Verfahren setzen wir, zur Mitte hin vorrückend, fort, bis unsere Hände in der
Mitte zusammenstoßen.
Spoiler
Wir nehmen die linke Hand l und die rechte Hand r und arbeiten uns mit ständigen
Vertauschungen zur Mitte vor:
Start
l=1
r = 100
l=l+1
r=r –1
nein
l<r Ende
ja
tausche( l, r)
Lösungen
Das Verfahren wird fortgesetzt, solange die linke Hand noch vor der rechten Hand ist
(l < r).
A 1.9 Aufgabe
In einem Hochhaus mit 20 Stockwerken fährt ein Aufzug. Im Aufzug sind 20 Knöpfe,
mit denen man sein Fahrziel wählen kann, und auf jeder Etage ist ein Knopf, mit dem
1052
Kapitel 1
man den Aufzug rufen kann. Entwickeln Sie einen Algorithmus, der den Aufzug so
steuert, dass alle Aufzugbenutzer gerecht bedient werden!
Lösung
Sammeln Sie zunächst alle Informationen über den Status des Aufzugs. Abstrahieren
Sie dabei vollständig von der Fahrphysik, da es uns um eine rein logische Steuerung
geht. Es geht nicht um Beschleunigung und Bremswege oder Zeiten für das Ein- oder
Ausladen von Passagieren. Versuchen Sie in einem ersten Schritt, den logischen Sta-
tus des Aufzugs mit möglichst wenigen Daten vollständig zu beschreiben.
Spoiler
Den logischen Status des Aufzugs können Sie mit folgenden Daten beschreiben:
왘 e ist die Etage, auf der sich der Aufzug gerade befindet (1 ≤ e ≤ 20).
왘 r ist die Richtung, in die der Aufzug fährt oder fahren will (r = 0 abwärts, r = 1 auf-
wärts).
왘 k1, k2, ... k20 sind die Knöpfe zur Anforderung des Aufzugs (1 = gedrückt, 0 = nicht
gedrückt).
Denken Sie jetzt darüber nach, wie eine gerechte Bedienung aller Fahrgäste aussehen
könnte, und entwerfen Sie einen Algorithmus.
Spoiler
Lösungen
Immer, wenn vor mir eine Ampel auf Rot umschaltet, fühle ich mich subjektiv unge-
recht behandelt. Wenn dann noch aus den anderen Richtungen niemand kommt,
wächst mein Unmut noch einmal deutlich. Aber die Ampel kann ja nur im Rahmen
ihrer Informationen versuchen, das Beste zu machen. So ist es auch mit dem Aufzug.
Ich schlage folgende Strategie vor: Der Aufzug entscheidet sich anfänglich für eine
Richtung – z. B. »oben«. Dann fährt er so lange nach oben, wie es oberhalb seiner
aktuellen Position noch Anforderungen, also gedrückte Knöpfe, gibt. Auf diesem
Weg hält er auf allen Etagen an, für die ein Knopf gedrückt wurde, um Passagiere ein-
1053
A Aufgaben und Lösungen
und aussteigen zu lassen. Gibt es oberhalb seiner Position keine Anforderung mehr,
kehrt der Aufzug die Richtung um und fährt so lange nach unten, wie es unterhalb
seiner Position noch Anforderungen gibt.
Versuchen Sie, mit dieser Strategie den Aufzug zum Fahren zu bringen.
Spoiler
Abbildung A.10 zeigt eine mögliche Lösung:
e=1
r=1
nein
ke = 1
ja
Passagiere laden
ke = 0
nein nein
nein nein
ja
min < e i≥1 i ≤ 20 max > e
ja i=i–1 i=i+1 ja
ki = 1 ki = 1
nein nein
ja ja
min = i max = i
Der Aufzug startet in der ersten Etage mit Fahrtrichtung nach oben. Zunächst werden
auf jeder Etage, falls erforderlich, Passagiere geladen Nach dem Laden von Passgieren
wird auch der Etagenknopf zurückgesetzt. Abhängig von der eingestellten Richtung
Lösungen
wird dann in zwei symmetrische Teile des Flussdiagramms verzweigt. Wir betrachten
hier nur den rechten Teil (Fahrt nach oben). Zunächst wird dort in max die maximale
Etage bestimmt, zu der es eine Anforderung gibt. Ist dieses Maximum größer als die
momentane Etage, wird um eine Etage nach oben gefahren, und es geht mit dem
optionalen Laden der Passagiere weiter. Ist das Maximum nicht größer als die aktu-
elle Etage, erfolgt eine Richtungsumkehr, und es geht über die Richtungsabfrage in
den linken Teil des Flussdiagramms.
1054
Kapitel 3
A 1.10 Aufgabe
Beim Schach gibt es ein einfaches Endspiel, wenn die eine Seite den König und einen
Turm, die andere Seite dagegen nur noch den König auf dem Spielfeld hat:
Versuchen Sie, den Algorithmus für das Endspiel so zu formulieren, dass auch ein
Nicht-Schachspieler die Spielstrategie versteht!
Lösung
Geben Sie in einer Internetsuchmaschine den Suchtext »Mattsetzen mit einem
Turm« ein, und Sie erhalten genügend auch algorithmennah formulierte Strategien
für dieses Endspiel.
Lösungen
Kapitel 3
A 3.1 Aufgabe
Machen Sie sich mit Editor, Compiler und Linker Ihrer Entwicklungsumgebung ver-
traut, indem Sie die Programme dieses Kapitels eingeben und zum Laufen bringen!
Lösung
Zu dieser Aufgabe benötigen Sie keine Musterlösung.
1055
A Aufgaben und Lösungen
A 3.2 Aufgabe
Schreiben Sie ein Programm, das zwei ganze Zahlen von der Tastatur einliest und
anschließend deren Summe, Differenz, Produkt, den Quotienten und den Divisions-
rest auf dem Bildschirm ausgibt!
1. Zahl: 10
2. Zahl: 4
Summe 10 + 4 = 14
Differenz 10 – 4 = 6
Produkt 10*4 = 40
Quotient 10/4 = 2 Rest 2
Lösung
Diese Aufgabe kann geradlinig implementiert werden.
Sollten Sie Probleme mit dieser Aufgabe haben, sollten Sie sich noch einmal mit Ein-
gabe (scanf), Ausgabe (printf) und den arithmetischen Operatoren (+, -, *, / und %)
beschäftigen.
Spoiler
Hier ist mein Quellcode der Lösung:
void main()
{
int zahl1, zahl2;
int summe, differenz, produkt, quotient, rest;
1056
Kapitel 3
Bei Division durch 0 bricht mein Programm mit folgender Meldung ab:
Den Fall zahl2 == 0 sollten Sie daher durch eine Prüfung abfangen, bevor Sie mit die-
sem Operanden in eine Division oder Modulo-Operation gehen. Bauen Sie diese
Erweiterung in Ihr Programm ein.
A 3.3 Aufgabe
Erstellen Sie ein Programm, das unter Verwendung der in Aufgabe 1.3 formulierten
Regeln berechnet, ob eine vom Benutzer eingegebene Jahreszahl ein Schaltjahr
bezeichnet oder nicht!
Lösung
Wir wollen das folgende Flussdiagramm implementieren:
Jahreszahl: z
z durch ja
400 Schaltjahr
teilbar
Lösungen
nein
kein ja z durch
100
Schaltjahr
teilbar
nein
nein z durch 4 ja
teilbar
1057
A Aufgaben und Lösungen
Spoiler
Das Flussdiagramm kann in den folgenden »Pseudocode« übersetzt werden:
Eingabe: Jahr
if( Jahr durch 400 teilbar)
Ausgabe: Schaltjahr;
else
{
if( Jahr durch 100 teilbar)
Ausgabe: kein Schaltjahr
else
{
if( Jahr durch 4 teilbar)
Ausgabe: Schaltjahr
else
Ausgabe: kein Schaltjahr
}
}
Spoiler
Im Pseudocode müssen Sie jetzt nur noch die fehlenden Details ergänzen:
void main()
{
int jahr;
1058
Kapitel 3
if( jahr % 4 == 0)
printf( "%d ist ein Schaltjahr\n", jahr);
else
printf( "%d ist kein Schaltjahr\n", jahr);
}
}
}
A 3.4 Aufgabe
Erstellen Sie ein Programm, das zu einem eingegebenen Datum (Tag, Monat und
Jahr) berechnet, um den wievielten Tag des Jahres es sich handelt! Berücksichtigen
Sie dabei die Schaltjahrregel!
Lösung
Zunächst müssen Tag, Monat und Jahr eingegeben werden. Dann berechnen wir mit
der Lösung der vorherigen Aufgabe, ob das Jahr einen Schalttag hat.
Wir summieren dann die abgelaufenen Tage in einer Variablen. Es sind mindestens
so viele Tage vergangen, wie die Tageszahl im Datum angibt. Danach kommt es auf
den Monat an. Wenn der Monat größer als 1 ist, kommen für den abgelaufenen
Januar 31 Tage hinzu. Wenn der Monat größer als 2 ist, haben wir mindestens 28 Tage
zusätzlich und müssen gegebenenfalls noch den Schalttag addieren, Wenn der
Monat größer als 3 ist, kommen weitere 31 Tage hinzu. Das machen wir für alle
Monate bis Dezember.
Spoiler
void main()
{
int tag, monat, jahr;
int schalttag;
Lösungen
int laufender_tag;
1059
A Aufgaben und Lösungen
Die Lösung ist nicht sehr elegant. Insbesondere wäre es schön, wenn man die Lösung
zur Schaltjahresberechnung hier »wiederverwenden« könnte, ohne den Code neu
1060
Kapitel 3
schreiben zu müssen. Mit diesem Thema werden wir uns später im Zusammenhang
mit Funktionen intensiv auseinandersetzen.
A 3.5 Aufgabe
Schreiben Sie ein Programm, das alle durch 7 teilbaren Zahlen zwischen zwei zuvor
eingegebenen Grenzen ausgibt!
Lösung
Zunächst müssen die Grenzen eingegeben werden. Dann erstellen Sie eine Schleife,
in der eine Variable zwischen den eingegebenen Grenzen hochgezählt wird. Im Inne-
ren der Schleife prüfen Sie, ob der jeweilige Variablenwert durch 7 teilbar ist. Ist das
der Fall, geben Sie den Variablenwert aus.
Spoiler
Ob eine Zahl a durch eine Zahl b teilbar ist, testen Sie mit dem Modulo-Operator. Im
Falle der Teilbarkeit ist a%b ==0.
Spoiler
Der vollständige Programmcode:
void main()
{
int min, max;
int i;
1061
A Aufgaben und Lösungen
A 3.6 Aufgabe
Schreiben Sie ein Programm, das berechnet, wie viele Legosteine zum Bau der folgen-
den Treppe mit der zuvor eingegebenen Höhe h erforderlich sind:
Lösung
Bei gegebener Höhe h muss die Summe 1 + 2 + 3 + ... + h berechnet werden. Das geht
ganz einfach durch fortlaufende Addition in einer Schleife.
Spoiler
Programmcode der Lösung:
void main()
{
int hoehe;
int steine, i;
1062
Kapitel 3
In der Variablen steine wird die Anzahl der benötigten Steine gezählt. Wichtig ist,
dass diese Variable am Anfang auf 0 gesetzt wird, da im Weiteren immer nur etwas
hinzuaddiert wird. Würde man zu einer nicht initialisierten Variablen etwas hinzuad-
dieren, wäre das Ergebnis nach wie vor undefiniert.
A 3.7 Aufgabe
Schreiben Sie ein Programm, das eine vom Benutzer festgelegte Anzahl von Zahlen
einliest und anschließend die größte und die kleinste der eingegebenen Zahlen auf
dem Bildschirm ausgibt!
Lösung
Das Programm muss keinen Überblick über alle eingegebenen Zahlen haben. Es
genügt, wenn sich das Programm immer nur die bisher kleinste und die bisher
größte vorgekommene Zahl merkt und diese mit der jeweils nächsten Eingabe ver-
gleicht. Wird die bisher größte Zahl durch die Eingabe überboten (oder die bisher
kleinste Zahl unterboten), haben wir eine neue größte (kleinste) Zahl.
Spoiler
Auch bei dieser Aufgabe haben wir das »Initialisierungsproblem«, das wir immer
haben, wenn wir einen Wert eigentlich nur verändern wollen. In dieser Situation
müssen Sie sich Gedanken über einen sinnvollen Anfangswert machen. Sie könnten
das Minimum anfänglich auf den größten theoretisch vorkommenden und das
Maximum auf den kleinsten theoretisch vorkommenden Wert setzen. Dann wären
Sie sicher, dass diese Werte durch die folgenden Eingaben unter- bzw. überboten
würden. Es gibt aber nicht immer solche theoretischen Extremwerte. Sie können das
Problem hier so lösen, dass Sie bei der ersten Eingabe sowohl das Minimum als auch
das Maximum auf den eingegebenen Wert setzen.
Spoiler
Lösungen
void main()
{
int anzahl;
int max, min, i, z;
1063
A Aufgaben und Lösungen
A 3.8 Aufgabe
Implementieren Sie das Ratespiel aus Aufgabe 1.4 entsprechend dem von Ihnen
gewählten Algorithmus!
Lösung
Zur Lösung von Aufgabe 1.4 habe ich ein Flussdiagramm gezeichnet, das wir hier imp-
lementieren können (siehe Abbildung A.15).
a+b a+b
Lösungen
Unter ------------- haben wir dabei die größte ganze Zahl z mit z ≤ ------------- verstanden.
2 2
Wenn wir es mit int-Zahlen a und b zu tun haben, ist dies aber (a+b)/2, da eine Divi-
sion ohne Rest genau das gewünschte Ergebnis liefert.
1064
Kapitel 3
Start
Eingabe a,
b
a+b
q=
2
geheimzahl ja
Ende
=q
nein
ja geheimzahl
b=q–1
<q
nein
a=q+1
Spoiler
Vom Flussdiagramm zum Programm ist es nur ein kleiner Schritt, den man quasi
automatisch durchführen kann.
Lösungen
void main()
{
int a, b;
int q, v;
1065
A Aufgaben und Lösungen
for( ; ; )
{
q = (a+b)/2;
printf( "Ist es %d? ( 0 gleich, –1 kleiner, 1 groesser): ", q);
scanf( "%d", &v);
if( v == 0)
break;
if( v < 0)
b = q-1;
else
a = q+1;
}
}
Sobald die Zahl geraten ist, wird die Schleife über break abgebrochen.
A 3.9 Aufgabe
Implementieren Sie Ihren Algorithmus aus Aufgabe 1.5 zur Feststellung, ob eine Zahl
eine Primzahl ist!
Lösung
Auch hier haben wir schon ein Flussdiagramm gezeichnet:
Start
Eingabe p
t=2
Lösungen
nein
t<p Primzahl Ende
ja
nein ja keine
t=t+1 t teilt p
Primzahl
1066
Kapitel 3
In einer Schleife prüfen wir für alle Zahlen von 2 bis p–1, ob sie als Teiler infrage kom-
men. Wenn wir einen Teiler finden, müssen wir nicht weitersuchen, da die Zahl dann
keine Primzahl ist. Erst wenn alle möglichen Teiler ausgeschlossen sind, wissen wir,
dass es sich um eine Primzahl handelt.
Spoiler
Der vollständige Quellcode:
void main()
{
int p;
int t;
Wird ein Teiler gefunden, wird die Schleife abgebrochen. Außerhalb der Schleife
erkennt man dann am Stand des Schleifenzählers, ob die Schleife vollständig durch-
laufen wurde und somit eine Primzahl vorliegt.
Lösungen
A 3.10 Aufgabe
Schreiben Sie ein Programm, das das kleine Einmaleins berechnet und in Tabellen-
form auf dem Bildschirm ausgibt! Die Darstellung auf dem Bildschirm sollte wie folgt
sein:
1 2 3 4 5 6 7 8 9 10
---------------------------------------------
1 | 1 2 3 4 5 6 7 8 9 10
2 | 2 4 6 8 10 12 14 16 18 20
1067
A Aufgaben und Lösungen
3 | 3 6 9 12 15 18 21 24 27 30
4 | 4 8 12 16 20 24 28 32 36 40
5 | 5 10 15 20 25 30 35 40 45 50
6 | 6 12 18 24 30 36 42 48 54 60
7 | 7 14 21 28 35 42 49 56 63 70
8 | 8 16 24 32 40 48 56 64 72 80
9 | 9 18 27 36 45 54 63 72 81 90
10 | 10 20 30 40 50 60 70 80 90 100
---------------------------------------------
Die Ausgabe einer ganzen Zahl in einer bestimmten Feldbreite erreichen Sie übrigens
dadurch, dass Sie in der Formatanweisung zwischen dem Prozentzeichen und dem
Buchstaben für den Datentyp die gewünschte Feldbreite, z. B. in der Form "%3d",
angeben.
Lösung
Kopf und Fußzeile der Tabelle erhalten wir durch einfache Ausgaben. In einer Dop-
pelschleife können wir dann alle infrage kommenden Kombinationen der beiden
Operanden z und s (z wie Zeile und s wie Spalte) erzeugen und dann jeweils z*s
berechnen und ausgeben.
Wichtig ist, dass die Ergebnisse innerhalb einer Zeile immer ohne Zeilenvorschub
ausgegeben werden und der Zeilenvorschub erst erfolgt, wenn eine komplette Zeile
abgearbeitet ist. Dazu iterieren wir in der äußeren Schleife über alle Zeilen und in der
inneren Schleife über alle Spalten der Zeile. Der Zeilenvorschub wird dann in der
äußeren Schleife ausgegeben, wenn in die nächste Zeile gewechselt wird.
Spoiler
Hier der vollständige Quellcode:
void main()
Lösungen
{
int z, s;
1068
Kapitel 4
printf( "\n");
}
printf( "---------------------------------------------\n");
}
Kapitel 4
A 4.1 Aufgabe
Schreiben Sie ein Programm, das zu einem gegebenen Anfangskapital und einem
jährlichen Zinssatz berechnet, wie viele Jahre benötigt werden, damit das Kapital eine
bestimmte Zielsumme überschreitet!
Lösung
Zu Programmstart werden folgende Eingangsdaten vom Benutzer erfragt:
왘 Anfangskapital
왘 Zinssatz
왘 Zielbetrag
In einer Schleife werden dann dem Kapital so lange die jährlich anfallenden Zinsen
zugeschlagen, bis erstmalig der Zielbetrag übertroffen wird. Die Zinsberechnung
könnte etwa so aussehen:
kapital += kapital*zins/100.0;
Gleichzeitig werden in der Schleife die Jahre gezählt und abschließend ausgegeben.
Spoiler
Hier sehen Sie die vollständige Lösung:
void main()
Lösungen
{
float kapital, zins, ziel;
int jahre;
1069
A Aufgaben und Lösungen
A 4.2 Aufgabe
Den größten gemeinsamen Teiler (ggT) von zwei natürlichen Zahlen können Sie
berechnen, indem Sie so lange die kleinere Zahl von der größeren Zahl abziehen, bis
beide Zahlen gleich sind. Sie wollen z. B. den ggT von 152 und 56 berechnen. Dann
gehen Sie wie folgt vor:
152 – 56 = 96
96 – 56 = 40
56 – 40 = 16
40 – 16 = 22
22 – 16 = 8
16 – 8 = 8 = ggT
Erstellen Sie ein Programm, das mit diesem Algorithmus den ggT berechnet!
Lösung
Es werden zwei Zahlen a und b eingegeben. Wenn a die größere der beiden Zahlen ist,
wird a – b als neuer Wert für a berechnet. Ist b die größere Zahl, wird b – a als neuer
Wert für b berechnet. Dieser Prozess wird in einer Schleife so lange fortgesetzt, wie
a ≠ b ist. Die Zahl a (oder b) ist dann der ggT und wird ausgegeben.
Spoiler
Hier die vollständige Lösung:
Lösungen
void main()
{
int a, b;
1070
Kapitel 4
while( a != b)
{
if( a >b)
a -= b;
else
b -= a;
}
printf( "%d\n", a);
}
A 4.3 Aufgabe
Sie haben zwei ausreichend große Eimer. Im ersten befinden sich x, im zweiten y Liter
Wasser. Sie füllen nun immer a Prozent des Wassers aus dem ersten in den zweiten
und anschließend b Prozent des Wassers aus dem zweiten in den ersten Eimer. Die-
sen Umfüllprozess führen Sie n-mal durch. Erstellen Sie ein Programm, das nach Ein-
gabe der Startwerte (x, y, a, b und n) die Füllstände der Eimer nach jedem Umfüllen
ermittelt und auf dem Bildschirm ausgibt! Welche Aufteilung des Wassers ergibt sich
auf lange Sicht für unterschiedliche Startwerte?
Lösung
In jedem Schritt bestimmen wir zunächst die Umfüllmenge von x nach y. Diese ist:
p = a*x/100.0
Nachdem diese Menge von x abgezogen und zu y hinzugefügt wurde, wird die
Umfüllmenge von y nach x berechnet, von y abgezogen und zu x hinzugefügt. Dieser
Prozess wird in einer Schleife n-mal durchgeführt.
Spoiler
Lösungen
void main()
{
float x, y, a, b, p;
int n, i;
1071
A Aufgaben und Lösungen
A 4.4 Aufgabe
In einem Schulbezirk gibt es 1200 Planstellen für Lehrer. Diese unterteilen sich der-
zeit in 40 Studiendirektoren, 160 Oberstudienräte und 1000 Studienräte. Alle drei
Jahre ist eine Beförderung möglich, dabei steigen jeweils 10 % der Oberstudienräte
und 20 % der Studienräte in die nächsthöhere Gruppe auf. Darüber hinaus gehen
20 % einer jeden Gruppe innerhalb von drei Jahren in den Ruhestand. Die dadurch
frei werdenden Planstellen werden mit Studienräten besetzt. Schreiben Sie ein Pro-
gramm, das die bestehende Situation in Dreijahreszyklen fortschreibt! Welche Ver-
teilung von Direktoren, Oberräten und Räten ergibt sich auf lange Sicht? Drehen Sie
an der »Beförderungsschraube« für Oberstudienräte und Studienräte, um andere
Lösungen
Verteilungen zu erreichen!
Lösung
Die Aufgabenstellung ist unpräzise, da unklar ist, ob zuerst befördert und dann pen-
sioniert oder zuerst pensioniert und dann erst befördert wird. Wir entscheiden uns,
zuerst zu befördern. Dazu werden den Studiendirektoren 10 % der Oberstudienräte
zugeschlagen. Die gleiche Zahl muss natürlich von den Oberstudienräten abgezogen
werden. Anschließend werden 20 % der Studienräte zu den Oberstudienräten addiert
und von den Studienräten abgezogen. Damit ist die Beförderungswelle abgeschlos-
1072
Kapitel 4
sen. Zur Pensionierung werden dann 20 % von den Studiendirektoren und ebenfalls
20 % von den Oberstudienräten abgezogen. Bei den Studienräten muss man rechne-
risch keine Pensionierungen durchführen, da ja immer auf 1200 aufgefüllt wird. Die-
ser Prozess wird in einer Schleife 20-mal durchgeführt und protokolliert.
Spoiler
Hier sehen Sie die vollständige Lösung:
void main()
{
int i, b;
int StD = 40;
int OStR = 160;
int StR = 1000;
A 4.5 Aufgabe
Epidemien (z. B. Grippewellen) breiten sich in der Bevölkerung nach gewissen Gesetz-
mäßigkeiten aus. Die Bevölkerung zerfällt im Verlauf einer Epidemie in drei Grup-
pen. Als Gesunde bezeichnen wir Menschen, die mit dem Krankheitserreger noch
nicht in Berührung gekommen sind und deshalb ansteckungsgefährdet sind. Kranke
sind Menschen, die akut infiziert und ansteckend sind. Immunisierte schließlich sind
Menschen, die die Krankheit überstanden haben und weder ansteckend noch anste-
ckungsgefährdet sind.
1073
A Aufgaben und Lösungen
Als Ausgangssituation betrachten wir eine feste Population von x Menschen, unter
denen sich bereits eine gewisse Anzahl y von Kranken befindet:
gesund0 = x – y
krank0 = y
immun0 = 0
Ausgehend von diesen Daten, wollen wir die Ausbreitung der Krankheit in Zeitsprün-
gen von einem Tag berechnen. Wir überlegen uns dazu, welche Veränderungen von
Tag zu Tag auftreten. Es gibt zwei Arten von Übergängen zwischen den Gruppen. Aus
Gesunden werden Kranke (Infektion), und aus Kranken werden Immune (Immuni-
sierung).
Die Zahl der Infektionen ist proportional zur Zahl der Gesunden und proportional
zum Anteil der Kranken in der Gesamtbevölkerung. Denn je mehr Gesunde es gibt,
desto mehr Menschen können sich anstecken, und je mehr Ansteckende es gibt,
desto mehr Menschen können angesteckt werden. Mit einem geeigneten Proportio-
nalitätsfaktor (Infektionsrate) nimmt daher die Zahl der Gesunden ständig ab:
gesund n krank n
gesund n + 1 = gesund n – infektionsrate ------------------------------------------
x
Die Zahl der Immunisierungen ist proportional zur Zahl der Kranken, denn je mehr
Menschen erkrankt sind, desto mehr Menschen erlangen Immunität. Mit einem
geeigneten Proportionalitätsfaktor (Immunisierungsrate) gilt daher:
bekannt, können Sie mit einem einfachen Programm den Verlauf der Krankheits-
welle vorausberechnen. Erstellen Sie das Programm, und ermitteln Sie den Verlauf
einer Epidemie mit den folgenden Basisdaten:
Infektionsrate: 0.6
Immunisierungsrate: 0.06
Gesamtpopulation: 2000
Akut Kranke: 10
Anzahl Tage: 25
1074
Kapitel 4
Abbildung A.17 zeigt für die oben genannten Basisdaten das epidemische Anwachsen
des Krankenstandes, bis dem Virus der Nährboden entzogen wird und der Kranken-
stand langsam wieder abfällt:
2000
1800
1600
1400
1200
1000
800
600
400
200
0
0
5
10
15
20
25
30
35
40
45
50
55
60
65
70
75
Immun
80
85
Krank
90
95
Gesund
100
Lösung
Die zur Lösung benötigten Formeln sind in der Aufgabenstellung vollständig angege-
ben, sie müssen nur noch implementiert werden. Zuvor werden alle relevanten
Daten eingelesen:
Spoiler
Lösungen
void main()
{
float infrate;
float immrate;
float gesamt;
float gesund;
float krank;
float immun;
1075
A Aufgaben und Lösungen
int tage;
int i;
printf("Infektionsrate: ");
scanf("%f", &infrate);
printf("Immunisierungsrate: ");
scanf("%f", &immrate);
printf("Gesamtpopulation: ");
scanf("%f", &gesamt);
printf("Akut Kranke: ");
scanf("%f", &krank);
printf("Anzahl Tage: ");
scanf("%d", &tage);
A 4.6 Aufgabe
Lösungen
Zur Durchführung des Verfahrens werden die Stimmergebnisse der Parteien fortlau-
fend durch die Zahlen 1, 2, 3, 4, ... dividiert. Sind n Sitze im Parlament zu vergeben,
werden die n größten Divisionsergebnisse ausgewählt, und die zugehörigen Parteien
erhalten für jede ausgewählte Zahl einen Sitz. Das folgende Beispiel zeigt das Ergeb-
1076
Kapitel 4
nis einer Wahl mit drei Parteien und 200000 abgegebenen Stimmen, bei der zehn
Sitze zu vergeben waren:
Sitze 5 4 1
Schreiben Sie ein Programm, das für eine beliebige Wahl mit drei Parteien die Sitzver-
teilung berechnet! Die Anzahl der zu vergebenden Sitze und die Stimmen für die drei
Parteien sollen dabei vom Benutzer eingegeben werden.
Lösung
Die Aufgabenstellung suggeriert, dass zunächst alle Werte in Tabelle A.1 berechnet
werden müssen, bevor die Sitzverteilung durchgeführt werden kann. Das ist aber
Lösungen
nicht nötig. Sie müssen in jeder Spalte immer nur einen Wert berechnen. Anfänglich
ist das die Stimmenzahl. Die Partei mit dem höchsten Wert bekommt dann einen
Sitz. Danach dividieren Sie die Stimmenzahl dieser Partei durch den nächsten Teiler.
Dazu führen Sie für jede Partei einen Teiler ein, der anfänglich 1 ist und bei jeder Sitz-
vergabe an diese Partei um 1 erhöht wird.
Spoiler
1077
A Aufgaben und Lösungen
Wir haben drei Parteien A, B und C mit ihren Stimmen (StimmenA, StimmenB und Stim-
menC). Diese Daten und die Anzahl der Sitze (sitze) müssen am Anfang eingelesen
werden.
Jede Partei erhält einen Sitzzähler (SitzeA, SitzeB und SitzeC). Da am Anfang noch
keine Partei einen Sitz hat, werden diese Zähler auf 0 gesetzt.
Jede Partei bekommt einen Teiler (TeilerA, TeilerB und TeilerC), der anfänglich 1 ist
und immer um 1 erhöht wird, wenn die Partei einen Sitz zugeteilt bekommen hat.
Mithilfe des Teilers lässt sich für jede Partei ein Quotient (QuotientA, QuotientB und
QuotientC) berechnen (QuotientX = StimmenX/TeilerX).
Die Partei mit dem größten Quotienten bekommt den nächsten Sitz. Dazu wird der
Sitzezähler der Partei hochgezählt. Nach Zuteilung eines Sitzes wird der Teiler der
Partei um 1 erhöht und der Quotient der Partei neu berechnet.
Dieses Verfahren wird so lange durchgeführt, bis alle Sitze verteilt sind. Danach wird
die Sitzverteilung (SitzeA, SitzeB und SitzeC) ausgegeben.
Spoiler
Vielleicht haben Sie ein Problem damit, die größte von drei Zahlen zu berechnen. Das
ist auch etwas fummelig, da Ihnen für eine elegante Lösung dieser Aufgabe noch ein
wichtiges Sprachelement (Arrays) fehlt. Aber Sie können die Aufgabe mit Ihrem der-
zeitigen Kenntnisstand lösen.
Im Pseudocode können Sie die größte von drei Zahlen wie folgt ermitteln:
else
{
if(Zahl2 > Zahl3)
Zahl2 ist das Maximum;
else
Zahl3 ist das Maximum;
}
1078
Kapitel 4
Spoiler
Sie haben alle zur Lösung der Aufgabe erforderlichen Informationen und können
programmieren.
Am Anfang legen Sie die Variablen an und lesen die erforderlichen Daten ein:
Dann initialisieren Sie die Sitze, Teiler und Quotienten für jede Partei:
Dann folgt die Schleife für die Sitzvergabe, die wir zunächst ohne ihren Inhalt
betrachten:
Lösungen
In dieser Schleife muss zunächst der größte Quotient ermittelt werden. Sie machen
das nach dem Muster aus dem zweiten Hinweis:
1079
A Aufgaben und Lösungen
Der Wert der Variablen max (1, 2 oder 3) identifiziert die Partei, die den nächsten Sitz
bekommt. Sie geben dieser Partei den Sitz und passen ihren Teiler und Quotienten
an:
if(max == 1)
{
SitzeA = SitzeA+1;
QuotientA = StimmenA / teilerA;
teilerA = teilerA + 1;
}
if(max == 2)
{
SitzeB = SitzeB+1;
QuotientB = StimmenB / teilerB;
teilerB = teilerB + 1;
}
if(max == 3)
{
Lösungen
SitzeC = SitzeC+1;
QuotientC = StimmenC / teilerC;
teilerC = teilerC + 1;
}
1080
Kapitel 4
printf("\nSitzverteilung:\n\n");
printf("Partei A | Partei B | Partei C\n");
printf("---------+----------+---------\n");
printf(" %3d | %3d | %3d\n\n", SitzeA, SitzeB, SitzeC);
Die Lösung dieser Aufgabe wirkt umständlich, weil fast identischer Code mehrfach
hingeschrieben werden musste. Für vier oder noch mehr Parteien würde der Code
zur Berechnung des Maximums förmlich explodieren. Sie würden sich wünschen,
dass Sie anstelle von A, B und C einfach eine laufende Nummer verwenden könnten,
über die Sie auf die Daten einer Partei zugreifen könnten. Sobald wir uns mit Arrays
beschäftigt haben, können Sie diese Aufgabe sehr viel eleganter lösen und die »Re-
dundanz« im Code vermeiden.
A 4.7 Aufgabe
Im folgenden Zahlenkreis stehen die Buchstaben jeweils für eine Ziffer.
B C
A b c D
a d
H h e
g f E
G F
Abbildung A.18 Zahlenkreis
Bestimmen Sie diese Ziffern (1 bis 9) so, dass folgende Bedingungen erfüllt werden:
1081
A Aufgaben und Lösungen
Zeigen Sie durch ein Programm, dass es genau eine mögliche Ziffernzuordnung gibt,
und bestimmen Sie diese!
Lösung
Wir wählen einen Brute-Force-Ansatz und erzeugen alle möglichen Belegungen der
16 Buchstaben (A bis H, a bis h) mit den neun Ziffernwerten (1 bis 9). Für jede Belegung
testen wir dann, ob die neun Bedingungen erfüllt sind. Belegungen, die alle Bedin-
gungen erfüllen, werden ausgegeben.
Spoiler
Legen Sie 16 Variablen A, a, B, b, C, c ... für alle im Diagramm vorkommenden Unbe-
kannten an, und erstellen Sie dann 16 ineinander geschachtelte Schleifen, in denen
diese Variablen jeweils von 1 bis 9 hochgezählt werden.
void main()
{
int A,B,C,D,E,F,G,H;
int a,b,c,d,e,f,g,h;
for ( A = 1; A <= 9; A = A + 1 )
{
for ( a = 1; a <= 9; a = a + 1 )
{
for ( B = 1; B <= 9; B = B + 1 )
{
for ( b = 1; b <= 9; b = b + 1 )
{
// Weitere Schleifen
}
}
}
Lösungen
}
}
Auf diese Weise können Sie alle möglichen Belegungen im oben dargestellten Dia-
gramm erzeugen und dann jeweils prüfen, ob die Bedingungen erfüllt sind.
Aber Vorsicht: Das Programm erzeugt 916 = 1853020188851841 verschiedene Fälle und
wird dazu eine erhebliche (ich habe es nicht ausprobiert) Rechenzeit benötigen. Um
die Zahl der zu untersuchenden Fälle zu reduzieren, sollten Sie die jeweiligen Prüfun-
gen so früh wie möglich, d. h. in der äußersten Schleife, in der alle in die Prüfung ein-
1082
Kapitel 4
gehenden Werte bekannt sind, durchführen. Fällt die Prüfung negativ aus, sollten Sie
nicht in die tieferen Schleifen einsteigen, da dort keine Lösung mehr gefunden wer-
den kann.
Zum Beispiel können Sie die Bedingung »Aa ist Primzahl« bereits am Anfang der
zweiten Schleife durchführen, da dort A und a festgelegt sind. Das könnte dann wie
folgt aussehen:
void main()
{
int A,B,C,D,E,F,G,H;
int a,b,c,d,e,f,g,h;
for ( A = 1; A <= 9; A = A + 1 )
{
for ( a = 1; a <= 9; a = a + 1 )
{
if( Aa ist keine Primzahl) // Pseudocode
continue;
for ( B = 1; B <= 9; B = B + 1 )
{
for ( b = 1; b <= 9; b = b + 1 )
{
// Weitere Schleifen
}
}
}
}
}
Spoiler
Ich will Ihnen noch ein paar Hinweise zur Umsetzung der Prüfungen geben. Durch
Lösungen
die Variablen A, a, ... sind die einzelnen Ziffern der in den Prüfungen vorkommenden
Zahlen gegeben. Die zugehörigen numerischen Werte lassen sich durch einfache
Multiplikationen mit 10 bzw. 100 erhalten.
Die Prüfung »abc ist gleich cba« bedeutet dann numerisch: a*100+b*10+c ==
c*100+b*10+a.
Die Prüfung »CDE ist Produkt von Cc mit der Quersumme von CDE« kann wie folgt
umgesetzt werden: C*100+D*10+E == (C*10+c) * (C+D+E).
1083
A Aufgaben und Lösungen
Auch eine Prüfung wie »Aa ist Primzahl« lässt sich leicht realisieren, da als mögliche
Teiler ja nur 2, 3, 5 und 7 untersucht werden müssen. Das heißt, sobald z = A*10+a
durch eine der Zahlen 2, 3, 5 oder 7 teilbar ist, müssen Sie nicht weitersuchen und
können mit continue zum nächsten Fall übergehen. Im Code könnte das wie folgt
aussehen:
for ( A = 1; A <= 9; A = A + 1 )
{
for ( a = 1; a <= 9; a = a + 1 )
{
z = A*10+a;
if(z%2 == 0)
continue;
if(z%3 == 0)
continue;
if(z%5 == 0)
continue;
if(z%7 == 0)
continue;
for ( B = 1; B <= 9; B = B + 1 )
...
}
}
Sie sehen, dass dort einiges an Fleiß- und Schreibarbeit auf Sie zukommt, aber am
Ende werden Sie mit der Lösung der Aufgabe belohnt, die ich Ihnen hier nicht ver-
rate.
Eine vollständige Lösung dieser Aufgabe, die allerdings schon von den »logischen
Operatoren« des nächsten Kapitels Gebrauch macht, finden Sie in den Materialien
zum Buch unter http://www.galileo-press.de/3536. Vielleicht sollten Sie sich diese
Lösung erst anschauen, nachdem Sie das nächste Kapitel durchgearbeitet haben.
Lösungen
A 4.8 Aufgabe
Erstellen Sie ein Programm, das zu einer vom Benutzer eingegebenen Zahl die Prim-
zahlzerlegung ermittelt
Zahl: 13230
13230 = 2*3*3*3*5*7*7
1084
Kapitel 4
Lösung
Nennen wir die eingegebene Zahl n. Betrachten Sie in einer Schleife alle Zahlen i =
2,3,4,...n, und prüfen Sie, ob i die zu untersuchende Zahl teilt. Wenn ja, dann teilen
Sie die Zahl durch i (n=n/i), und geben Sie i als Primfaktor aus. Testen Sie dann i
erneut, da ein Faktor ja auch mehrfach vorkommen könnte. Erst wenn i kein Teiler
der verbliebenen Zahl mehr ist, erhöhen Sie i um 1. Am Ende haben Sie alle Primfak-
toren gefunden.
Spoiler
Ob eine Zahl n durch eine Zahl i teilbar ist, können Sie mit Ausdruckt n%i == 0 (d. h. bei
Division von n durch i bleibt der Rest 0) testen.
Die Prüfung auf mehrfaches Vorkommen eines Teilers können Sie auf verschiedene
Arten realisieren:
왘 Sie können in der äußeren Zählschleife eine innere Schleife erstellen, die erst ver-
lassen wird, wenn der Faktor in der Zahl nicht mehr vorkommt.
왘 Sie können das Inkrement aus dem Schleifenkopf entfernen und die Variable i nur
hochzählen, wenn der Faktor i in der Zahl nicht mehr vorkommt.
Spoiler
Ich zeige Ihnen hier die vollständige Lösung in der ersten Variante (Schleife in
Schleife):
void main()
{
int n, i;
printf("Zahl: ");
scanf("%d", &n);
Lösungen
printf("\n");
1085
A Aufgaben und Lösungen
if(n > 1)
printf("*");
}
}
printf("\n");
}
Die Lösung ist sicherlich nicht optimal. Die Primfaktoren einer Zahl möglichst effizi-
ent zu berechnen ist eine komplizierte mathematische Aufgabe. Viele Verschlüsse-
lungen sind nur deshalb schwer zu knacken, weil die Faktorisierung großer Zahlen
eine sehr rechenintensive Aufgabe ist. Eine naheliegende Vereinfachung des oben
dargestellten Programms könnte darin bestehen, dass man, abgesehen von 2, nur
ungerade Teilerkandidaten und Teilerkandidaten, deren Quadrat kleiner als n ist,
betrachtet. Bauen Sie diese Verbesserungen in Ihren Code ein!
A 4.9 Aufgabe
Wichtige mathematische Funktionen können näherungsweise durch Summen (man
nennt dies Potenzreihenentwicklung) berechnet werden.
Zum Beispiel:
3 5 7
x x x
sin ( x ) = x – ----- + ----- – ----- + ...
3! 5! 7!
2 4 6
x x x
cos ( x ) = 1 – ----- + ------ – ------ + ...
2! 4! 6!
2 3
x x x
e = 1 + x + ----- + ----- + ...
2! 3!
Die im Nenner der Brüche vorkommenden Fakultäten kennen Sie ja aus dem
Abschnitt über Summen und Produkte.
Erstellen Sie auf diesen Formeln basierende Berechnungsprogramme für Sinus, Cosi-
Lösungen
nus und e-Funktion! Überprüfen Sie die Ergebnisse Ihrer Programme mit einem
Taschenrechner!
Lösung
Wie man fortlaufend in einer Schleife summiert, wissen Sie schon. Wie berechnet
man aber hier am effizientesten die einzelnen Summanden? Wenn Sie in jedem
Schleifendurchlauf den Zähler und den Nenner des Summanden neu berechnen, ist
das sicher nicht sehr effizient. Überlegen Sie sich zuerst, wie Sie einen Summanden
1086
Kapitel 4
aus dem vorherigen berechnen können. Auf diese Weise finden Sie eine sehr einfa-
che iterative Berechnung.
Spoiler
Bei der Potenzreihe der e-Funktion entsteht der nächste Summand immer, indem
man den vorherigen Summanden mit x multipliziert und durch eine Zahl dividiert,
die anfänglich 1 ist und für jeden Summanden um 1 erhöht wird. Mit dieser Überle-
gung sollten Sie in der Lage sein, eine iterative Berechnung der e-Funktion zu pro-
grammieren.
Für Sinus- und Cosinusfunktion gilt Ähnliches. Beachten Sie dabei, dass beim Sinus
nur für ungerade und beim Cosinus nur für gerade n-Werte Terme vorkommen und
dass immer ein Vorzeichenwechsel stattfindet.
Spoiler
Im Falle der Exponentialfunktion ergibt sich nach den Vorüberlegungen die folgende
Berechnung:
void main()
{
float x;
float ex;
float summand;
float n;
printf("x: ");
scanf("%f", &x);
ex = summand = 1;
for(n = 1; n <= 20; n = n + 1)
Lösungen
{
summand = summand * x/n;
ex = ex + summand;
}
printf("\ne^x = %f\n\n", ex);
}
Das Abbruchkriterium (n <= 20) ist hier mehr oder weniger willkürlich gewählt. Man
sollte abrechen, wenn sich »nur noch wenig« ändert. Den dabei verbleibenden Rest-
1087
A Aufgaben und Lösungen
fehler abzuschätzen ist eine Aufgabe der numerischen Mathematik, die wir hier nicht
betrachten wollen.
Spoiler
Beim Cosinus starten wir mit n = 1 und zählen immer um 2 hoch. Dadurch durchläuft
n nur die geraden Zahlen. Ein Summand berechnet sich aus dem vorherigen dann
durch die Formel:
cosx = summand = 1;
for(n = 1; n <= 20; n = n + 2)
{
summand = -summand * (x/n)*(x/(n+1));
cosx = cosx + summand;
}
printf("\ncos(x) = %f\n\n", cosx);
Falls noch nicht geschehen, erstellen Sie jetzt auch das Programm für den Sinus.
Spoiler
sinx = summand = x;
for(n = 2; n <= 20; n = n + 2)
{
summand = -summand * (x/n)*(x/(n+1));
sinx = sinx + summand;
}
printf("\nsin(x) = %f\n\n", sinx);
Lösungen
A 4.10 Aufgabe
Erstellen Sie Programme, um den Steinverbrauch für die in Abbildung A.19 abgebil-
dete Treppe und die beiden Pyramiden zu berechnen. Die Pyramiden sind dabei
innen nicht hohl.
1088
Kapitel 4
Versuchen Sie, auch explizite Formeln für den Steinverbrauch herzuleiten. Verglei-
chen Sie die iterativ berechneten Ergebnisse mit den durch die expliziten Formeln
gegebenen Zahlen.
Lösung
Bei der Treppe werden alle ungeraden Zahlen (1, 3, 5, ...) aufsummiert. Die i-te unge-
rade Zahl erhält man durch die Formel 2 · i – 1.
Bei der quadratischen Pyramide werden Quadratzahlen (1, 4, 9, 16, ...) summiert.
Bei der Dreieckspyramide ist auf der ersten Ebene ein Stein, auf der zweiten Ebene
sind 1+2 Steine, auf der dritten dann 1+2+3 Steine.
Explizite Formeln finden Sie im Internet, wenn Sie den Suchbegriff Summenformeln
in eine Suchmaschine eingeben.
Lösungen
Spoiler
Das folgende Listing zeigt das vollständige Programm:
void main()
{
int hoehe;
int i, k, s;
1089
A Aufgaben und Lösungen
Treppe:
s(h) = h2
Quadratische Pyramide:
h ( h + 1 ) ( 2h + 1 )
s ( h ) = -----------------------------------------
6
Dreieckspyramide
h(h + 1)(h + 2)
s ( h ) = --------------------------------------
6
Lösungen
Kapitel 5
A 5.1 Aufgabe
Wir definieren einen neuen logischen Operator nand durch folgende Wahrheitstafel:
1090
Kapitel 5
A B A nand B
0 0 1
0 1 1
1 0 1
1 1 0
Zeigen Sie, dass man beliebige boolesche Funktionen unter alleiniger Verwendung
des Nand-Operators darstellen kann!
Hinweis: Es reicht, wenn Sie zeigen, dass man »nicht«, »und« und »oder« darstellen
kann.
Lösung
Der in der Aufgabenstellung definierte Operator »nand« ist entsprechend der oben
dargestellten Wahrheitstabelle ein invertiertes logisches Und. Es gilt also:
A nand B ⇔ ( A ∧ B )
A ⇔ ( A ∧ A ) ⇔ A nand A
Mit den de Morganschen Regeln kann man dann auch ein logisches Oder durch
»nand« ausdrücken:
Der Nand-Operator kommt zwar in der umgangssprachlichen Logik nicht vor, hat
dafür aber eine umso größere Bedeutung in der Schaltungslogik. Das Ergebnis dieser
Aufgabe bedeutet ja, dass man jede logische Schaltung aus einem Grundelement
(einem sogenannten Nand-Gatter) aufbauen kann.
Es gibt einen zweiten Operator (nor), der das Gleiche wie nand leistet. Sie können sich
sicher denken, wie dieser Operator definiert ist.
1091
A Aufgaben und Lösungen
A 5.2 Aufgabe
Erstellen Sie ein Programm, das Wahrheitstafeln für die folgenden booleschen Aus-
drücke auf dem Bildschirm ausgibt:
1. (A ∧ B) ⇒ (C ∨ D)
2. A ∧ B ∨ C ∧ D
3. A ⇒ B ⇒ C ∧ D( )
4. ( A ∨ B) ∧ ( A ∨ C ) ∧ D
Beachten Sie, dass Sie eine Implikation X ⇒ Y durch X ∨ Y ausdrücken können!
Lösung
Erzeugen Sie alle möglichen Kombinationen von Wahrheitswerten durch entspre-
chende Schleifenkonstrukte, berechnen Sie in der innersten Schleife dann jeweils
den Wahrheitswert, und geben Sie den Wert aus.
Spoiler
Sie müssen alle Ausdrücke, die eine Implikation enthalten, zunächst so umformen,
dass nur noch C-Operatoren vorkommen.
( ) ( ) ( ) (
A ⇒ B ⇒ C ∨ D ⇔ ( A ⇒ B) ∨ C ∨ D ⇔ A ∨ B ∨ C ∨ D )
Der entsprechende C-Ausdruck ist also:
!A || B || C || !D
Spoiler
Hier sehen Sie den vollständige Quellcode des Programms für den ersten Formelaus-
Lösungen
druck:
void main()
{
int A, B, C, D, wert;
1092
Kapitel 5
Die Lösung für die restlichen Aufgabenteile erhalten Sie, indem Sie bei der Berech-
nung von wert jeweils die entsprechenden C-Ausdrücke einsetzen.
A 5.3 Aufgabe
Überprüfen Sie die folgenden Tautologien aus Abschnitt 5.2, »Aussagenlogische Ope-
ratoren«, durch C-Programme:
Logische Äquivalenzen
A ˄ ( B ˄ C ) ⟺ ( A˄ B ) ˄ C A ˅ ( B ˅ C ) ⟺ ( A˅ B ) ˅ C Assoziativgesetz
A˄ B ⟺ B˄ A A˅ B ⟺ B˅ A Kommutativgesetz
( A˄ B ) ˄ A ⟺ A ( A˄ B ) ˅ A ⟺ A Verschmelzungsgesetz
A ˄ ( B ˅ C ) ⟺ ( A˄ B ) ˅ ( A˄ C ) A ˅ ( B ˄ C ) ⟺ ( A˅ B ) ˄ ( A˅ C ) Distributivgesetz
A ˄( B ˅ B ) ⟺ A A ˅( B ˄ B ) ⟺ A Komplementgesetz
A˄ A ⟺ A A˅ A ⟺ A Idempotenzgesetz
Lösungen
A˄ A ⟺ 0 A˅ A ⟺ 1
A⟺A
1093
A Aufgaben und Lösungen
Lösung
Hier sollten Sie prüfen, ob sich für alle möglichen Belegungen der Eingangswerte
rechts und links vom Äquivalenzzeichen immer die gleichen Wahrheitswerte er-
geben.
Spoiler
Ich habe die Aufgabe am Beispiel des Distributivgesetzes gelöst.
void main()
{
int A, B, C;
int links, rechts;
int ok;
ok = 1;
for( A = 0; A <= 1; A++)
{
for( B = 0; B <= 1; B++)
{
for( C = 0; C <= 1; C++)
{
links = A && (B || C);
rechts = (A && B) || (A && C);
printf( "%d %d %d | %d %d\n", A, B, C,
links, rechts);
if( links != rechts)
ok = 0;
}
}
Lösungen
}
if( ok)
printf( "Tautologie\n");
else
printf( "keine Tautologie\n");
}
1094
Kapitel 5
Würde es nur um die Frage gehen, ob die linke und die rechte Seite äquivalent sind,
könnte man die Untersuchung abbrechen, sobald eine Belegung gefunden wird, für
die sich ein Unterschied ergibt. Hier wird aber die gesamte Tabelle ausgegeben.
Die anderen Teilaufgaben lösen Sie völlig analog. Auch wenn Ihnen die anderen
Lösungen unmittelbar klar sind, sollten Sie sie dennoch programmieren, um Pro-
grammierroutine zu erwerben.
A 5.4 Aufgabe
Die Schaltung aus dem Beispiel in Abschnitt 5.5.5 wird dahingehend abgeändert, dass
zwei Schalter miteinander gekoppelt werden und eine neue Leitung gelegt wird.
s3 s4
s1
s5
s2
s7
s6
Finden Sie eine möglichst einfache boolesche Funktion für diese Schaltung, und
erstellen Sie ein Programm, das alle Schalterstellungen ausgibt, in denen die Lampe
leuchtet!
Lösung
Lösungen
Wir haben eine sehr ähnliche Aufgabe ja schon gelöst und dort das folgende Ergebnis
erhalten:
lampe = (s1||s2)&&((s3&&s4)||((s5||s6)&&s7))
Wenn Sie damit Probleme haben, dann schauen Sie sich zunächst diese Aufgabe
noch einmal an, und versuchen Sie dann, die Formel für diese Aufgabenstellung
anzupassen.
1095
A Aufgaben und Lösungen
Spoiler
Wenn Sie von der Lösungsformel
lampe = (s1||s2)&&((s3&&s4)||((s5||s6)&&s7))
ausgehen, sehen Sie, dass in der veränderten Schaltung immer s3 = s5 ist. Sie können
also s5 aus der Formel eliminieren, indem Sie s3 anstelle von s5 schreiben:
lampe = (s1||s2)&&((s3&&s4)||((s3||s6)&&s7))
Zusätzlich gibt es jetzt aber noch eine »Abkürzung«, mit der Sie den zweiten Formel-
teil teilweise »umgehen« können. Bauen Sie dazu diese Abkürzung ein:
Mit dieser Formel können Sie die Aufgabe jetzt programmieren, wobei die Schleife
über s5 natürlich wegfallen kann.
Die oben angegebene Formel ist allerdings nicht die einfachste Darstellung. Viel-
leicht finden Sie durch »genaues Hinsehen« noch eine einfachere Version.
Spoiler
Durch »genaues Hinsehen« erkennen wir, dass, wenn Schalter s7 geschlossen ist, die
Stellungen der Schalter s3, s4 und s6 irrelevant sind. Ist umgekehrt der Schalter s7
geöffnet, entscheiden nur die Schalter s3 und s4 darüber, ob Strom fließt.
Da s6 jetzt ebenfalls eliminiert ist, erkennen wir, dass Schalter s6 in der Schaltung ohne
Bedeutung ist. Ist er offen, kann er nicht verhindern, dass über s7 der Stromkreis
Lösungen
geschlossen ist. Ist er geschlossen, kann er nicht ohne s7 den Stromkreis schließen. Die
Kopplung von s3 und s5 hat im Übrigen keinen Einfluss auf die Schaltung. Würde man
die Kopplung wieder aufheben, wäre Schalter s5 genauso überflüssig wie s6.
1096
Kapitel 5
Spoiler
Die optimierte Lösung im Quellcode sieht so aus:
void main()
{
int s1, s2, s3, s4, s7;
int lampe;
A 5.5 Aufgabe
Familie Müller ist zu einer Geburtstagsfeier eingeladen. Leider können sich die Fami-
lienmitglieder (Anton, Berta, Claus und Doris) nicht einigen, wer hingeht und wer
nicht. In einer gemeinsamen Diskussion kann man sich jedoch auf die folgenden
fünf Grundsätze verständigen:
1097
A Aufgaben und Lösungen
Helfen Sie Familie Müller, indem Sie ein Programm erstellen, das alle Konstellatio-
nen ermittelt, in denen Familie Müller zur Feier gehen könnte.
Lösung
Wie bei den vorangegangenen Aufgaben dieses Abschnitts sollten Sie auch hier alle
möglichen Fälle erzeugen und aus diesen die gültigen Fälle herausfiltern. Bei dieser
Aufgabe müssen alle fünf Bedingungen erfüllt sein. Die Bedingungen müssen also
mit einem logischen Und verknüpft werden.
Spoiler
Sollten Sie noch Schwierigkeiten haben, die Bedingungen in die C-Notation zu brin-
gen, dann finden Sie hier die Lösungen:
Der unter Punkt 5 verwendete Ungleich-Operator (!=) ist eigentlich kein logischer,
sondern ein arithmetischer Operator. Aber wenn die Variablenwerte nur 0 oder 1
sind, kann dieser Operator ein logisches Entweder-Oder »simulieren«. Logisch sau-
Lösungen
Mit diesem Hinweis können Sie jetzt das vollständige Programm erstellen.
Spoiler
Hier ist der vollständige Quellcode:
1098
Kapitel 5
void main()
{
int A, B, C, D;
A 5.6 Aufgabe
Bankdirektor Schulze hat den Tresor seiner Bank durch ein elektronisches Schloss
sichern lassen. Dieses Schloss kann über neun Kippschalter geöffnet werden, wenn
man diese in die richtige Stellung (»unten« oder »oben«) bringt. Da sich der Bankdi-
rektor die richtige Schalterkombination nicht merken kann und bereits mehrfach
einen Fehlalarm ausgelöst hat, hat er sich den folgenden Merkzettel erstellt:
1. Wenn Schalter 3 auf »oben« gestellt wird, dann müssen sowohl Schalter 7 als auch
Schalter 8 auf »unten« gestellt werden.
2. Wenn Schalter 1 auf »unten« gestellt wird, dann muss von den Schaltern 2 und 4
mindestens einer auf »unten« gestellt werden.
Lösungen
3. Von den beiden Schaltern 1 und 6 muss mindestens einer auf »unten« stehen.
4. Wenn Schalter 6 auf »unten« gestellt wird, dann müssen 7 auf »unten« und 5 auf
»oben« stehen.
5. Falls sowohl Schalter 9 auf »unten« als auch Schalter 1 auf »oben« gestellt werden,
dann muss 3 auf »unten« stehen.
6. Von den Schaltern 8 und 2 muss mindestens einer auf »oben« stehen.
7. Wenn Schalter 3 auf »unten« oder Schalter 6 auf »oben« steht oder beides der Fall
ist, dann müssen Schalter 8 auf »unten« und Schalter 4 auf »oben« stehen.
1099
A Aufgaben und Lösungen
8. Falls Schalter 9 auf »oben« steht, dann müssen Schalter 5 auf »unten« und Schalter
6 auf »oben« stehen.
9. Wenn Schalter 4 auf »unten« steht, dann müssen Schalter 3 auf »unten« und
Schalter 9 auf »oben« stehen.
Lösung
Übersetzen Sie zunächst die Bedingungen in C-Notation. Erzeugen Sie dann in einem
verschachtelten Schleifenkonstrukt alle kombinatorisch möglichen Schalterstellun-
gen, und prüfen Sie die Bedingungen.
Spoiler
In meiner Implementierung habe ich die Bedingungen einzeln und negiert (also als
Abbruchbedingungen) formuliert.
void main()
{
int s1, s2, s3, s4, s5, s6, s7, s8, s9;
{
for( s7 = 0; s7 <= 1; s7++)
{
for( s8 = 0; s8 <= 1; s8++)
{
for( s9 = 0; s9 <= 1; s9++)
{
if( s3 && (s7 || s8)) // Bedingung 1
continue;
1100
Kapitel 5
A⇒B
A⇒ B ⇔ A∨ B ⇔ A∧ B
ergibt.
In dieser Form können Sie das Programm einfach optimieren, indem Sie jede
Abbruchbedingung so weit wie möglich nach vorn ziehen. Wie weit Sie eine Bedin-
gung nach vorn ziehen können, entscheidet der Schalter mit der größten in der
Bedingung vorkommenden Schalternummer, da alle Schalter einschließlich dieses
Schalters bei der Überprüfung der Bedingung gesetzt sein müssen. Die Bedingung 2
1101
A Aufgaben und Lösungen
können Sie z. B. bis in die Schleife über s4 vorziehen. Dadurch erfolgt hier bereits ein
Abbruch, wenn in den tieferen Schleifen keine Lösung mehr gefunden werden kann.
A 5.7 Aufgabe
Der Wikipedia habe ich das folgende Beispiel einer sogenannten Entscheidungs-
tabelle entnommen:
Tabellenbezeichnungen R1 R2 R3 R4 R5 R6 R7 R8
Bedingungen
Lieferfähig? j j j j n n n n
Angaben vollständig? j j n n j j n n
Bonität in Ordnung? j n j n j n j n
Aktionen
Lieferung mit Rechnung x x
Lieferung als Nachnahme x x
Angaben vervollständigen x x
Mitteilen: nicht lieferbar x x x x
Erstellen Sie ein C-Programm, das die in der Tabelle genannten Bedingungen abfragt
und dann die erforderlichen Aktionen ausgibt.
Lösungen
Lösung
Jede Aktion ist eine boolesche Funktion der drei Bedingungen. Wir müssen also vier
dreistellige boolesche Funktionen implementieren.
Spoiler
Betrachten wir die erste boolesche Funktion »Lieferung mit Rechnung«. Diese hängt
nur von der Lieferfähigkeit und der Bonität ab. Wenn beides positiv (j) ist, kann auf
Rechnung geliefert werden. Es ist also:
1102
Kapitel 6
Spoiler
Hier ist der vollständige Quelltext zur Lösung der Aufgabe, wobei folgende Variab-
lennamen verwendet werden:
왘 lf – Lieferfähig
왘 vs – Angaben vollständig
왘 io – Bonität in Ordnung
void main()
{
int lf, vs, io;
Kapitel 6
A 6.1 Aufgabe
Erstellen Sie ein Programm, das einen String und einen Buchstaben übergeben
bekommt und dann ausgibt, wie oft der Buchstabe in dem String vorkommt.
1103
A Aufgaben und Lösungen
Lösung
Zu dieser Aufgabe ist nicht viel zu sagen. Bevor Sie anfangen, sollten Sie sich noch
einmal klarmachen, dass der String in ein Array eingelesen wird und dort durch das
Terminatorzeichen abgeschlossen wird. Achten Sie darauf, dass das Array ausrei-
chend groß ist, sonst könnte Ihr Programm abstürzen. Einlesen können Sie den
String mit scanf, wobei Sie die Formatanweisung %s verwenden sollten. Den Buchsta-
ben (Datentyp char) lesen Sie ebenfalls mit printf, allerdings mit der Formatanwei-
sung %c, ein. Denken Sie daran, dass Sie beim Einlesen des Buchstabens ein & vor den
Variablennamen setzen müssen, beim Einlesen des Strings jedoch nicht. Den Grund
für diesen wesentlichen Unterschied erfahren Sie erst später.
Spoiler
Nachdem Sie den String eingelesen haben, können Sie mit einer einfachen Schleife
über alle Zeichen des Strings iterieren. Das könnte so aussehen:
char string[100];
int i;
Spoiler
Dies ist der Quellcode der vollständigen Lösung:
Lösungen
void main()
{
char string[100];
char buchstabe;
int z, i;
1104
Kapitel 6
A 6.2 Aufgabe
Schreiben Sie ein Programm, das die Reihenfolge der Zeichen in einem String
umkehrt.
Lösung
Zur Lösung dieser Aufgabe sollten Sie »in place« arbeiten. Das heißt, es wird kein
neuer String erstellt, sondern die erforderlichen Vertauschungen werden im Original
vorgenommen.
Dazu arbeiten Sie mit zwei Indizes (vorn und hinten). Den einen positionieren Sie auf
dem ersten, den anderen auf dem letzten Zeichen des Strings. Dann vertauschen Sie
immer die Zeichen an den beiden indizierten Positionen und arbeiten sich mit den
Indizes zur Mitte des Strings vor.
Spoiler
Da Sie die Länge der Zeichenkette nicht kennen, müssen Sie mit dem Index hinten
über den gesamten String iterieren, um das Ende der Kette zu finden. Das könnte im
Code so aussehen:
Lösungen
Nach dieser Schleife steht der Index hinten auf dem Terminatorzeichen des Strings.
Sie müssen ihn also um ein Zeichen zurücksetzen, damit er auf dem letzten Buchsta-
ben der Zeichenkette steht.
Wenn Sie jetzt noch den Index vorn an den Anfang der Zeichenkette setzen, können
Sie mit dem Vertauschen beginnen. Nach jeder Vertauschung schieben Sie den Index
1105
A Aufgaben und Lösungen
vorn um ein Zeichen nach hinten und den Index hinten um ein Zeichen nach vorn,
um danach erneut zu tauschen. Das machen Sie so lange, wie vorn < hinten ist.
Spoiler
Dies ist meine Lösung:
void main()
{
char string[100];
int vorn, hinten;
char tmp;
vorn = 0;
for( hinten = 0; string[hinten]; hinten++)
;
hinten--;
for( ; vorn < hinten; vorn++, hinten--)
{
tmp = string[vorn];
string[vorn] = string[hinten];
string[hinten] = tmp;
}
printf( "Ergebnis: %s\n", string);
}
A 6.3 Aufgabe
Schreiben Sie ein Programm, das alle »e« aus einem String entfernt.
Lösungen
Lösung
Wir wollen hier wieder »in place« arbeiten. Das Entfernen eines Zeichens bedeutet
aber nicht, dass das das Zeichen einfach, z. B. mit einem Leerzeichen, überschrieben
wird, sondern, dass alle nachfolgenden Zeichen aufrücken müssen. Überlegen Sie
sich eine Strategie, mit der das Aufrücken möglichst effizient durchgeführt werden
kann. Bei der Entfernung eines Zeichens immer sofort alle nachfolgenden Zeichen
um eine Position aufrücken zu lassen ist nicht die optimale Lösung – weder vom Pro-
grammieraufwand noch vom Laufzeitverhalten her.
1106
Kapitel 6
Spoiler
Arbeiten Sie mit einem Lese- und einem Schreibindex. Zu Beginn stehen beide Indi-
zes am Wortanfang. Der Leseindex rückt systematisch durch die Zeichenkette vor.
Aber nur, wenn das Zeichen am Leseindex kein »e« ist, wird das Zeichen auf die
Schreibposition kopiert, und der Schreibindex rückt ebenfalls vor.
Achtung: Der String wird durch das Entfernen von Zeichen gegebenenfalls verkürzt.
Vom Speicher her ist das kein Problem, aber Sie müssen das Terminatorzeichen neu
positionieren.
Spoiler
Ich denke, dass das Problem ausreichend diskutiert ist, um sofort in den Quellcode
einzusteigen:
void main()
{
char string[100];
int lesen, schreiben;
string[schreiben] = 0;
Hier wird nach dem Entfernen der »e« das Terminatorzeichen an der aktuellen
Schreibposition, also unmittelbar hinter der verkürzten Zeichenkette, angefügt.
1107
A Aufgaben und Lösungen
A 6.4 Aufgabe
Erweitern Sie das Programm zur Palindromerkennung so, dass nicht zwischen Groß-
und Kleinbuchstaben unterschieden wird. Es sollen also Worte wie »Retsinakanister«
korrekt als Palindrom erkannt werden.
Lösung
Man könnte das zu untersuchende Wort nach der Eingabe komplett in Kleinbuchsta-
ben (oder Großbuchstaben) konvertieren, um dann anschließend mit der Palind-
romuntersuchung zu beginnen. Dies würde das Wort aber verändern, was sicherlich
ein unerwünschter Nebeneffekt wäre. Besser ist, wenn man nur beim konkreten Ver-
gleich von zwei Buchstaben eine kurzfristige Konvertierung außerhalb der Zeichen-
kette durchführt.
Spoiler
Vielleicht ist Ihnen noch nicht klar, wie Sie die erforderliche Zeichenkonvertierung
vornehmen. Wenn Sie in die ASCII-Zeichentabelle schauen, fällt auf, dass zwischen
Groß- und Kleinbuchstaben immer eine feste Differenz von 32 ist. Sie könnten daher
vor dem Vergleich bei Großbuchstaben immer 32 addieren, um den entsprechenden
Kleinbuchstaben zu erhalten. Dazu müssten Sie aber zunächst immer prüfen, ob Sie
es mit einem Großbuchstaben zu tun haben. Überlegen Sie, ob Sie die Konvertierung
nicht auch ohne eine solche Prüfung hinbekommen.
Spoiler
Die Differenz von 32 ist nur das Ergebnis eines bestimmten Design-Prinzips, das man
bei der Konstruktion des ASCII-Zeichensatzes angewandt hat. Kleinbuchstaben
unterscheiden sich von Großbuchstaben dadurch, dass das Bit 0x20 im Zeichencode
gesetzt ist. Dieses Bit erzeugt dann eine Differenz von 32. Vor dem Vergleich müssen
wir also nur dieses Bit setzen, unabhängig davon, ob wir es mit einem Klein- oder
Lösungen
Großbuchstaben zu tun haben. Wenn buchstabe ein beliebiger Buchstabe (a–z, A–Z)
ist, dann ist
buchstabe | 0x20
Spoiler
1108
Kapitel 6
Hier ist der vollständige Quellcode. Das Vorgehen zur Palindromerkennung kennen
Sie ja bereits. Hier ist nur die Konvertierung in den Zeichenvergleich eingebaut.
Beachten Sie, dass die Zeichenkette selbst durch den Algorithmus nicht verändert
wird:
void main()
{
char wort[100];
int vorn, hinten;
A 6.5 Aufgabe
Schreiben Sie ein Programm, das eine nur aus Ziffern bestehende Zeichenkette ein-
liest und aus dem Eingabestring eine int-Zahl berechnet.
Lösung
Lösungen
Sie werden vielleicht sagen: Das mache ich doch mit der Formatanweisung %d.
Das ist grundsätzlich richtig, wäre hier aber gemogelt, weil Sie die Konvertierung bei
dieser Aufgabe selbst durchführen sollen. Überlegen Sie sich also einen Konvertie-
rungsalgorithmus.
Spoiler
1109
A Aufgaben und Lösungen
Wir führen die Konvertierung am Beispiel der Zahl 4711 durch. Wenn wir die einzel-
nen Ziffernwerte haben, müssen wir ja nur noch in der richtigen Reihenfolge addie-
ren und multiplizieren:
4711 = ((4*10+7)*10+1)*10+1
Mit 0 startend, müssen wir also immer die bisher berechnete Zahl mit 10 multiplizie-
ren und den nächsten Ziffernwert addieren.
Es bleibt die Frage, wie wir vom ASCII-Code eines Zeichens zum Ziffernwert des Zei-
chens kommen. Dazu müssen wir nur den »Abstand« vom Zeichen '0' berechnen.
Also:
Spoiler
Die vollständige Lösung im Quelltext sieht so aus:
void main()
{
char string[20];
int zahl, i;
A 6.6 Aufgabe
Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend auf Wunsch
bestimmte Zahlen wieder ausgibt. Das Programm soll wie folgt arbeiten:
1110
Kapitel 6
Lösung
Legen Sie ein Array für zehn int-Zahlen an, und lesen Sie in einer Schleife zehn Zah-
lenwerte in das Array ein.
Erstellen Sie dann eine Schleife, in der Sie jeweils den gewünschten Index erfragen
und die Zahl mit dem eingegebenen Index aus dem Array ausgeben. Beenden Sie die
Schleife, sobald ein ungültiger Index eingegeben wird.
Das Programm kann geradlinig erstellt werden. Sie müssen darauf achten, dass Sie
nur gültige Indizes (0 bis 9) zum Zugriff verwenden und dass der Benutzer eine
gegenüber der Indizierung um 1 verschobene Nummerierung (1 bis 10) im Kopf hat.
Spoiler
Das fertige Programm sieht so aus:
void main()
Lösungen
{
int zahlen[10];
int i;
1111
A Aufgaben und Lösungen
for( ; ;)
{
printf( "\nWelche Zahl soll ich ausgeben: ");
scanf( "%d", &i);
if( (i < 1) || (i>10))
break;
printf( "Die %d-te Zahl ist %d\n", i, zahlen[i-1]);
}
}
A 6.7 Aufgabe
Schreiben Sie ein Programm, das zehn Zahlen einliest und anschließend der Größe
nach sortiert wieder ausgibt.
Lösung
Es gibt zahlreiche Algorithmen, um Daten zu sortieren. Wenn Ihnen kein eigener
Algorithmus einfällt, dann nehmen Sie doch das Verfahren Bubblesort aus der
Lösung zu Aufgabe 1.6.
Spoiler
Der Algorithmus Bubblesort wurde in der Lösung zu Aufgabe 1.6 vorgestellt und dort
ausgiebig diskutiert. Es reicht daher, wenn ich Ihnen hier nur das Programmierergeb-
nis präsentiere:
void main()
{
int zahlen[10];
int i, k, t;
{
printf( "%d-te Zahl: ", i+1);
scanf( "%d", &zahlen[i]);
}
1112
Kapitel 6
A 6.8 Aufgabe
Unter einem magischen Quadrat der Kantenlänge 5 verstehen wir eine Anordnung
der Zahlen 1–25 in einem quadratischen Schema auf eine Weise, dass die Summen in
allen Zeilen, Spalten und den beiden Hauptdiagonalen gleich sind. Das folgende Bei-
spiel zeigt ein solches Quadrat:
19 3 12 21 10
11 25 9 18 2
8 17 1 15 24
5 14 23 7 16
22 6 20 4 13
Erstellen Sie ein Programm, das überprüft, ob es sich bei einem 5 × 5-Quadrat um ein
Lösungen
Lösung
Dies ist eine sehr »technische« Aufgabe. Wenn Sie bereits sattelfest in der Arbeit mit
mehrdimensionalen Arrays sind, können Sie diese Aufgabe überspringen. Wenn Sie
aber noch Probleme mit Array-Zugriffen haben, lernen Sie hier, auf verschiedene
Arten systematisch durch zweidimensionale Arrays zu iterieren:
1113
A Aufgaben und Lösungen
왘 zeilenweise
왘 spaltenweise
왘 diagonal
Spoiler
Bei einem magischen 5 × 5-Quadrat muss die Zeilen-/Spalten-/Diagonalensumme
immer 65 sein.
Da ein magisches n × n-Quadrat die Zahlen 1, 2, 3 ..., n2 enthält, ergibt sich für die
Summe aller Zahlen im magischen Quadrat nach der Summenformel von Gauß:
2 2
n (n + 1)
gesamtsumme = --------------------------
2
2
n( n + 1)
zeilensumme = -----------------------
2
Um eine Zeilensumme zu berechnen, iteriert man mit festem Zeilenindex durch alle
Spalten.
Um die Summe in den Diagonalen zu berechnen, arbeitet man mit gekoppelten Indi-
zes. Bei der Diagonalen von links oben nach rechts unten ist der Zeilenindex immer
gleich dem Spaltenindex. Bei der Diagonalen von links unten nach rechts oben ist der
Zeilenindex stets n-1-Spaltenindex. In unserem Beispiel (n = 5) also Zeilenindex = 4 –
Spaltenindex.
Spoiler
Lösungen
Sie können sich überlegen, wie Sie reagieren, wenn Sie bei der Analyse des Quadrats
auf einen Fehler stoßen. Ich habe mich entschlossen, die Analyse auch bei einem
gefundenen Fehler vollständig durchzuführen und am Ende die Anzahl der Fehler
auszugeben. Dazu gibt es eine Variable fehler, in der die gefundenen Fehler gezählt
werden.
1114
Kapitel 6
void main()
{
int mquadrat[5][5];
int z, s, summe;
int fehler = 0;
summe += mquadrat[s][s];
printf( "Pruefung der 1-ten Diagonalen: %d\n", summe);
if( summe != 65)
fehler++;
1115
A Aufgaben und Lösungen
A 6.9 Aufgabe
Magische Quadrate ungerader Kantenlänge lassen sich nach folgendem Verfahren
konstruieren:
1. Positioniere die 1 in dem Feld unmittelbar unter der Mitte des Quadrats!
2. Wenn die Zahl x in der Zeile i und der Spalte k positioniert wurde, dann versuche,
die Zahl x+1 in der Zeile i+1 und der Spalte k+1 abzulegen! Handelt es sich bei die-
sen Angaben um ungültige Zeilen- oder Spaltennummern, wende Regel 4 an! Ist
das Zielfeld bereits besetzt, wende Regel 3 an!
3. Wird versucht, eine Zahl in einem bereits besetzten Feld in der Zeile i und der
Spalte k zu positionieren, versuche stattdessen die Zeile i+1 und die Spalte k-1. Han-
delt es sich bei diesen Angaben um ungültige Zeilen- oder Spaltennummern,
wende Regel 4 an. Ist das Zielfeld bereits besetzt, wende Regel 3 erneut an!
4. Die Zeilen- und Spaltennummern laufen von 0 bis n-1. Ergibt sich im Laufe des
Verfahrens eine zu kleine Zeilen- oder Spaltennummer, setze die Nummer auf den
Maximalwert n-1! Ergibt sich eine zu große Spalten- oder Zeilennummer, setze die
Nummer auf den Minimalwert 0!
Erstellen Sie nach diesen Angaben ein Programm, das für ungerade Kantenlängen
von 3 bis 9 ein magisches Quadrat erzeugen kann.
Lösung
Zunächst einmal sollten Sie ein zweidimensionales Array der Größe 9 × 9 anlegen.
Dieses können Sie auch für kleinere magische Quadrate verwenden, indem Sie nur
den »linken oberen Teil« nutzen.
Lösungen
Das Array initialisieren Sie mit Werten (z. B. 0), an denen Sie später erkennen können,
ob Sie schon einen Wert eingetragen haben oder nicht.
Dann müssen Sie für die vier Bedingungen »arithmetische Übersetzungen« finden.
Wie das geht, verrate ich aber erst hinter dem Spoiler.
Spoiler
1116
Kapitel 6
Sie kennen die Größe des magischen Quadrats, da der Benutzer die gewünschte
anzahl von Zeilen bzw. Spalten vorab eingegeben hat.
Regel 1 besagt:
Beachten Sie, dass es sich um eine Integer-Division handelt und dass die Zeilennum-
mer nach unten ansteigt.
왘 zeile = (zeile+1)%anzahl
왘 spalte = (spalte+1)%anzahl
Beachten Sie dabei, dass die Modulo-Operation genau die Korrekturen durchführt,
die Regel 4 vorschreibt.
왘 zeile = (zeile+1)%anzahl
왘 spalte = (spalte+anzahl-1)%anzahl
Beachten Sie, dass hier wegen der Möglichkeit, dass spalte-1 negativ werden könnte,
vor der Modulo-Operation anzahl addiert wird. Auf diese Weise wird verhindert, dass
eine unklare Modulo-Operation auf einer negativen Zahl ausgeführt wird. Bei positi-
ven Zahlen verfälscht eine Addition von anzahl nicht das Ergebnis, weil ja anschlie-
ßend noch der Rest bei Division durch anzahl gebildet wird.
Tragen Sie jetzt fortlaufend die Zahlen von 1 bis anzahl*anzahl in das Quadrat ein,
und beachten Sie dabei die oben genannten Regeln.
Spoiler
Der vollständige Quellcode sieht so aus:
Lösungen
void main()
{
int mquadrat[9][9];
int anzahl, zeile, spalte, zahl;
1117
A Aufgaben und Lösungen
spalte = anzahl/2;
zeile = anzahl/2+1;
A 6.10 Aufgabe
Informieren Sie sich im Internet, was unter einem Vigenère-Schlüssel zu verstehen
ist. Erstellen Sie dann ein Programm, das einen eingegebenen String mit einem Pass-
Lösungen
Lösung
Wir gehen davon aus, dass sowohl der zu verschlüsselnde Text als auch das Passwort
nur Kleinbuchstaben des englischen Alphabets (a–z) enthält.
Wenn Sie sich über die Vigenère-Verschlüsselung informiert haben, dann wissen Sie,
dass es sich um einen Verschiebeschlüssel handelt. Das heißt, dass jeder Buchstabe
des zu verschlüsselnden Textes um ein gewisses Maß im Alphabet verschoben wird.
1118
Kapitel 6
Sollte er dabei hinten aus dem Alphabet herausgeschoben werden, wird er vorn wie-
der hineingeschoben. Um wie viel ein Buchstabe verschoben wird, wird durch das
Passwort festgelegt. Dazu wird das Passwort fortlaufend über den zu verschlüsseln-
den Text gelegt. Wenn dabei z. B. der Passwortbuchstabe ›k‹ auf das Zeichen ›u‹ trifft,
bedeutet das, dass der Buchstabe ›u‹ um 10 zu verschieben ist, da ›k‹ der Buchstabe
mit dem Index 10 im Alphabet ist. Als Verschlüsselung von ›u‹ ergibt sich dann ›e‹.
Beachten Sie dabei, dass das ›u‹ über ›z‹ hinausgeschoben wird und es dann mit ›a‹
vorn wieder losgeht.
Spoiler
Nachdem der zu verschlüsselnde Text und das Passwort eingelesen sind, sollten Sie
zunächst die Passwortlänge berechnen.
Wenn die Passwortlänge plen ist, wird das Textzeichen mit dem Index i durch den
Passwortbuchstaben mit dem Index i%plen verschlüsselt.
Verschlüsseln bedeutet, dass Sie den Index des Textzeichens und des Passwortzei-
chens im Alphabet kennen müssen. Diese Indizes erhalten Sie, wenn Sie den ASCII-
Code von 'a' von dem jeweiligen Zeichen abziehen.
Spoiler
Die konkrete Formel für die Verschlüsselung des Zeichens mit dem Index i ist:
Lösungen
Zur Entschlüsselung müssen Sie subtrahieren. Dabei müssen Sie darauf achten, dass
kein negatives Zwischenergebnis entsteht, weil dann die Modulo-Operation proble-
matisch sein könnte. Sie verhindern dies, indem Sie vor der Modulo-Operation die
Alphabetgröße (26) addieren:
1119
A Aufgaben und Lösungen
Spoiler
Hier sehen Sie das vollständige Programm:
void main()
{
char text[100];
char passwort[20];
int i, plen;
Kapitel 7
A 7.1 Aufgabe
Erstellen Sie eine Funktion, die einen String und einen Buchstaben übergeben
bekommt und zurückgibt, wie oft der Buchstabe in dem String vorkommt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.1 als Vorlage.
1120
Kapitel 7
Lösung
Bei der Erstellung einer Funktion kommt es ganz entscheidend auf die Festlegung
der Schnittstelle an. Darum sollten Sie bei dieser Aufgabe mit der Schnittstelle begin-
nen. Überlegen Sie sich dazu, welche Daten – oder besser: welche Datentypen – in
Ihre Funktion hineinfließen und welche aus ihr herauskommen sollen.
Spoiler
In Ihre Funktion gehen ein String (char s[]) und ein Buchstabe (char b) ein. Heraus
kommt am Ende eine Zahl (int). Zusätzlich braucht die Funktion noch einen Namen.
Wenn Sie die Funktion vorkommen nennen, ergibt sich die folgende Schnittstelle:
Jetzt können Sie die Funktion mit dem Code aus Aufgabe 6.1 implementieren.
Spoiler
Wir kopieren den relevanten Code aus Aufgabe 6.1 in den Funktionskörper und er-
halten:
Das ergibt aber noch keine funktionierende Funktion. Was ist falsch, und was fehlt?
Spoiler
Wir haben die Schnittstellenparameter der Funktion s und b genannt. Im ursprüngli-
chen Programm hießen sie string und buchstabe. Das müssen wir noch anpassen,
wobei es egal ist, für welche Variante wir uns entscheiden. Die Variablen in der Funk-
tion sind, auch wenn sie zufällig so heißen wie Variablen im Hauptprogramm oder in
anderen Funktionen, eigenständig und nur innerhalb der Funktion bekannt.
1121
A Aufgaben und Lösungen
Die Funktion ist damit fertig, sie sollte aber noch getestet werden. Schreiben Sie
daher noch ein Hauptprogramm, in dem die Funktion aufgerufen und getestet wird.
Spoiler
Im Hauptprogramm können wir genauso vorgehen wie in der Lösung zu Aufgabe 6.1.
Anstelle des Codes zur Berechnung der Vorkommnisse des Buchstabens platzieren
wir jetzt allerdings einen Funktionsaufruf und nehmen das Ergebnis in der Variablen
z entgegen:
void main()
{
char string[100];
char buchstabe;
int z;
Lösungen
1122
Kapitel 7
Wir haben ein »großes« Programm in zwei kleine Teile aufgeteilt. Beide Teile haben
genau abgegrenzte Teilaufgaben. Im Hauptprogramm werden die Daten eingelesen,
und das Ergebnis der Berechnung wird ausgegeben. In der Funktion wird die Berech-
nung durchgeführt.
Die Teile sind durch eine einfache Schnittstelle miteinander verbunden. Es gibt keine
Information, die an der Schnittstelle vorbei zwischen den beiden Teilen fließt. Insge-
samt hat das Programm durch unsere Änderungen an Klarheit und Verständlichkeit
gewonnen, und die Funktion kann gegebenenfalls in einem anderen Zusammen-
hang wiederverwendet werden.
A 7.2 Aufgabe
Erstellen Sie eine Funktion, die die Reihenfolge der Zeichen in einem String umkehrt.
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.2 als Vorlage.
Lösung
Starten Sie wieder mit der Schnittstelle.
Spoiler
Wir nennen die Funktion umkehren. Die Funktion bekommt einen String übergeben,
den sie umdrehen muss. Das geschieht in dem Array, in dem der String steht. Eine
explizite Rückgabe zusätzlicher Ergebnisse ist nicht erforderlich, darum ist der Rück-
gabetyp der Funktion void.
Sie können die Funktion jetzt implementieren. Verwenden Sie dazu den Code aus der
Lösung von Aufgabe 6.2.
Spoiler
Das Einbauen des bekannten Codes aus Aufgabe 6.2 sollte Ihnen keine Schwierigkei-
ten bereiten, deshalb zeige ich Ihnen hier nur noch das Ergebnis:
1123
A Aufgaben und Lösungen
vorn = 0;
for( hinten = 0; s[hinten]; hinten++)
;
hinten--;
for( ; vorn < hinten; vorn++, hinten--)
{
tmp = s[vorn];
s[vorn] = s[hinten];
s[hinten] = tmp;
}
}
void main()
{
char string[100];
umkehren( string);
A 7.3 Aufgabe
Erstellen Sie eine Funktion, die alle »e« aus einem String entfernt.
Lösungen
Hinweis: Verwenden Sie die Lösung von Aufgabe 6.3 als Vorlage.
Lösung
Beginnen Sie wieder mit der Schnittstelle.
Spoiler
Die von mir gewählte Schnittstelle ist:
1124
Kapitel 7
Spoiler
Hier ist der Quellcode zur Lösung:
void main()
{
char string[100];
erase( string);
Falls Sie Schwierigkeiten beim Verständnis des Funktionscodes haben, schlagen Sie
noch einmal die Lösung der Aufgabe 6.3 nach.
A 7.4 Aufgabe
Lösen Sie das Damenproblem mithilfe des Beispielprogramms zur Erzeugung von
Permutationen.
1125
A Aufgaben und Lösungen
Lösung
Funktionen zur Analyse einer gegebenen Stellung haben wir uns bereits erarbeitet.
Wir fassen das noch einmal zusammen.
Mit der Funktion abstand berechnen wir den Abstand von zwei Zahlen:
Mit der Funktion schlagen testen wir, ob sich zwei Damen x und y gegenseitig be-
drohen:
dv = abstand( x, y);
dh = abstand( damen[x], damen[y]);
if( (dh == 0) || (dv == dh))
return 1;
return 0;
}
In der Funktion stellung_ok prüfen wir, ob keine der Damen auf dem Schachbrett
eine andere Dame schlagen kann.
1126
Kapitel 7
Für jede Dame k wird dazu geprüft, ob sie von einer Dame i < k geschlagen werden
kann. Bei einen 8 × 8-Schachbrett sind das insgesamt 28 Prüfungen. Nur wenn keine
dieser Prüfungen fehlschlägt, ist die Prüfung okay.
Jetzt müssen noch mit dem Programm perm alle möglichen Stellungen erzeugt, getes-
tet und gegebenenfalls ausgegeben1 werden.
Spoiler
Das Programm perm können wir weitestgehend übernehmen. An der Schnittstelle
habe ich das char-Array in ein int-Array geändert, da wir die Damen in einem int-
Array speichern wollen. Das ist aber mehr eine kosmetische Operation, da wir die
Damen auch in einem char-Array hätten speichern können.
Immer wenn eine neue Permutation (= Stellung der Damen auf dem Brett) erzeugt
ist, prüfen wir die Stellung mit der Funktion stellung_ok und stoßen gegebenenfalls
die Ausgabe an.
1127
A Aufgaben und Lösungen
Erstellen Sie jetzt noch das Hauptprogramm mit der Initialisierung des Schachbretts
und dem Aufruf der perm-Funktion.
Spoiler
Im Hauptprogramm legen wir ein Array für maximal zwölf Damen an und initialisie-
ren es entsprechend. Wenn der Benutzer das Problem für weniger als zwölf Damen
lösen will, wird einfach nur der vordere Teil dieses Arrays genutzt:
void main()
{
int damen[12] = {1,2,3,4,5,6,7,8, 9, 10, 11, 12};
int anz;
A 7.5 Aufgabe
Betrachten Sie das folgende Schema, in dessen Felder die Zahlen von 1 bis 8 so einge-
tragen werden müssen, dass sich die Zahlen in den durch eine Linie verbundenen Fel-
dern um mehr als 1 unterscheiden:
Lösungen
Finden Sie alle Lösungen des Problems, indem Sie das Zahlenschema auf ein Array
abbilden und dann alle möglichen Anordnungen der Zahlen erzeugen und jeweils
prüfen, ob die geforderten Bedingungen erfüllt sind!
1128
Kapitel 7
Lösung
Um das Programm perm benutzen zu können, müssen Sie die vorgegebene Struktur
in ein Array »serialisieren«. In welcher Reihenfolge Sie das machen, ist relativ egal. Je
nach gewählter Reihenfolge wird dann in den Bedingungen auf andere Felder des
Arrays zugegriffen. Ich schlage folgende Serialisierung vor:
0 1
2 3 4 5
6 7
Nachdem Sie die Serialisierung durchgeführt haben, können Sie alle Permutationen
des Arrays vornehmen.
Spoiler
Ich habe zunächst eine Ausgabefunktion geschrieben, die mir das Array wieder in
dem vorgegebenen Schema anzeigt:
Danach habe ich eine Testfunktion erstellt, die später einmal prüfen soll, ob eine
Belegung des Arrays die gestellten Bedingungen erfüllt. Momentan gibt diese Funk-
tion aber immer 1 zurück. Das bedeutet, dass jede Belegung akzeptiert wird:
1129
A Aufgaben und Lösungen
Im Programm perm erzeuge ich alle Permutationen und gebe diejenigen aus, die den
Test bestehen:
Mit dem Hauptprogramm ist dann eine erste Version fertig, die allerdings noch jede
Permutation akzeptiert und daher 8! = 40320 »Lösungen« ausgibt.
void main()
{
int schema[8] = {1,2,3,4,5,6,7,8};
Lösungen
Bauen Sie jetzt in die Funktion test einen Filter ein, der nur noch die korrekten Bele-
gungen akzeptiert.
Spoiler
1130
Kapitel 7
Jede Verbindungslinie in dem Diagramm steht für eine Filterbedingung. Wir müssen
also 17 Bedingungen implementieren. Alle Bedingungen sind aber strukturell gleich.
Ein zu untersuchendes Zahlenpaar wird abgelehnt, wenn der Abstand kleiner oder
gleich 1 ist. Implementieren Sie daher eine allgemeine Vergleichsfunktion und dann
auf Basis dieser Vergleichsfunktion die 17 Filterbedingungen.
Spoiler
Meine Vergleichsfunktion testet, ob zwei Zahlen zu nah beieinander sind:
abst = a – b;
if( abst < 0)
abst = – abst;
return abst <= 1;
}
return 1;
}
1131
A Aufgaben und Lösungen
A 7.6 Aufgabe
Erstellen Sie eine Funktion, die die Zahlen in einem Array sortiert.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor, um ein
Array mit n Elementen zu sortieren?
Lösung
Sie könnten eine Funktion tauschen implementieren und die Werte paarweise ver-
gleichen und sortieren. Ich möchte Ihnen aber ein anderes Verfahren vorschlagen:
Durchlaufen Sie das Array in aufsteigender Reihenfolge, und suchen Sie dabei das
kleinste Element. Nach dem Durchlauf tauschen Sie das kleinste Element mit dem
ersten Element im Array. Das erste Element müssen Sie jetzt nicht mehr betrachten,
da es bereits seine endgültige Position gefunden hat. Durchlaufen Sie dann das Array
erneut in aufsteigender Richtung ab dem zweiten Element, und suchen Sie dabei wie-
der das kleinste Element. Nach diesem Durchlauf tauschen Sie das kleinste gefun-
dene Element mit dem zweiten Element. Ich denke, Sie wissen jetzt, wie es
weitergeht.
Spoiler
Das oben beschriebene Verfahren können Sie in einer Funktion wie folgt implemen-
tieren:
1132
Kapitel 7
In der äußeren Schleife werden jeweils die Durchläufe organisiert, und in der inneren
Schleife wird das Minimum im verbliebenen Bereich gesucht. Am Ende der äußeren
Schleife findet die Vertauschung statt.
Entscheidend für die Laufzeit des Verfahrens ist, wie oft der Vergleich in der inneren
Schleife durchgeführt wird, wenn n Zahlen zu sortieren sind. Versuchen Sie, eine For-
mel für die erforderliche Anzahl der Vergleiche zu finden.
Spoiler
Bei n zu sortierenden Zahlen müssen im ersten Durchlauf n-1 Vergleiche durchge-
führt werden. Bei jedem Durchlauf ist es dann ein Vergleich weniger, bis es am Ende
nur noch ein Vergleich ist. Nach der Summenformel von Gauß sind das insgesamt
( n – 1 )n
v = 1 + 2 + 3 + ... + n – 1 = --------------------
2
Vergleiche. Die Zahl der Vergleiche wächst also mit dem Quadrat der Array-Größe.
Was das im Vergleich zu anderen Sortierverfahren bedeutet, werden Sie später sehen.
A 7.7 Aufgabe
Erstellen Sie eine Funktion, die die Zahlen in einem Array so umordnet, dass
anschließend alle negativen Zahlen vor allen nicht negativen Zahlen stehen.
Wie viele Vergleiche und Vertauschungen nimmt Ihre Funktion maximal vor, um ein
Array mit n Elementen umzuordnen? Versuchen Sie, mit deutlich weniger Vertau-
schungen auszukommen als in Aufgabe 7.6.
Lösung
Man könnte das Array mit dem in Aufgabe 7.6 beschriebenen Verfahren oder auch
mit Bubblesort sortieren. In beiden Fällen wäre der Aufwand aber proportional zum
Quadrat der Array-Größe. Die Aufgabe verlangt aber keine vollständige Sortierung
des Arrays, sondern nur eine bestimmte Anordnung. Das könnte effizienter gehen.
Lösungen
Denken Sie noch einmal in Ruhe über das Problem nach, bevor Sie die Lösung hinter
dem Spoiler lesen.
Spoiler
Durchlaufen Sie das Array vom Anfang in aufsteigender Richtung, und betrachten
Sie die Zahlen. Alle Zahlen, die negativ sind, sind bereits in einer korrekten Position.
Sobald Sie auf nicht negative Zahl stoßen, bleiben Sie stehen und merken sich diese
1133
A Aufgaben und Lösungen
Position. Jetzt beginnen Sie, das Array vom Ende her in absteigender Richtung zu
durchlaufen. Alle Zahlen, die nicht negativ sind, sind bereits korrekt positioniert.
Sobald Sie auf die erste negative Zahl stoßen, bleiben Sie stehen und merken sich
auch diese Position. Jetzt tauschen Sie die beiden Störenfriede an den gemerkten
Positionen. Danach können Sie sich, wie zuvor beschrieben, weiter zur Mitte vorar-
beiten.
Spoiler
Hier ist der Funktionscode:
links = –1;
rechts = anz;
for(;;)
{
while( (links < rechts) && (daten[++links] < 0))
;
while( (links < rechts) && (daten[--rechts] >= 0))
;
if( links >= rechts)
break;
tmp = daten[links];
daten[links] = daten[rechts];
daten[rechts] = tmp;
}
}
Lösungen
In der äußeren Schleife arbeiten wir uns mit zwei »Fingern«, links und rechts, durch
das Array. Der eine (links) geht vom Anfang aufwärts, der andere (rechts) vom Ende
abwärts. Dies passiert in den beiden while-Schleifen der nachfolgenden Funktion.
Stößt man von links kommend auf eine nicht negative Zahl oder von rechts kom-
mend auf eine negative Zahl, stoppt der Fortschritt. Hat dann der linke Finger den
rechten erreicht, wird die Schleife abgebrochen. Ansonsten werden die beiden Zah-
len, an denen gestoppt wurde, getauscht, und der Prozess wird in der Hauptschleife
fortgesetzt.
1134
Kapitel 7
Versuchen Sie, jetzt zu ermitteln, wie viele Schritte diese Funktion benötigt, um die
Anordnung durchzuführen. Vergleichen Sie das Ergebnis mit dem Ergebnis von Auf-
gabe 7.6.
Spoiler
Die beiden Finger arbeiten sich linear von den Rändern aufeinander zu, bis sie sich
irgendwo treffen. Dabei werden bei einem Array mit n Elementen maximal n Schritte
durchgeführt. Der Aufwand ist also proportional zur Größe des Arrays.
Setzen wir das ins Verhältnis zum Ergebnis aus Aufgabe 7.6, erhalten wir:
( n – 1 )n
--------------------
Schritte in 7.6 2 n–1
----------------------------------- = -------------------- = -----------
Schritte in 7.7 n 2
Das heißt, bei einem Array der Größe 1000 ist der in Aufgabe 7.6 erzeugte Aufwand
etwa 500-mal so groß wie der Aufwand dieses Verfahrens. Je größer das Array ist,
umso deutlicher zeigt sich der Unterschied.
A 7.8 Aufgabe
Erstellen Sie eine rekursive Funktion, die die Reihenfolge der Zahlen in einem Array
umkehrt.
Lösung
Rekursion ist immer hilfreich, wenn man ein größeres Problem auf ein oder mehrere
kleinere Probleme der gleichen Art zurückführen kann.
Tauschen Sie das erste und das letzte Element des Arrays, dann haben Sie das Pro-
blem auf das gleiche Problem in einem um zwei Zahlen verkleinerten Array zurück-
geführt.
die für das große und jedes der kleineren Teilprobleme geeignet ist. Finden Sie
zunächst eine geeignete Schnittstelle.
Spoiler
Einer rekursionsfähigen Schnittstelle sollte man das Daten-Array und den Teilbe-
reich des Arrays, der zu bearbeiten ist, übergeben. Der Teilbereich kann durch die
Indizes des linken und des rechten Randpunktes festgelegt werden. Dadurch ergibt
sich die folgende Schnittstelle:
1135
A Aufgaben und Lösungen
Spoiler
Über die Schnittstellenparameter links und rechts können Sie gezielt den Bereich
spezifizieren, der auf der nächsten Rekursionsstufe zu bearbeiten ist. Die Rekursion
endet, wenn links größer oder gleich rechts ist. Damit ergibt sich der folgende Quell-
code:
Bei der Verwendung der Funktion müssen Sie darauf achten, dass beim Erstaufruf
das gesamte Array, also links = kleinster vorkommender Index = 0 und rechts = größ-
ter vorkommender Index, als Arbeitsbereich festgelegt wird. In einem konkreten Fall
könnte das wie folgt aussehen:
umkehren( 0, 9, daten);
Lösungen
A 7.9 Aufgabe
Der Springer ist eine leichte und bewegliche Figur beim Schach, die von ihrer aktuel-
len Position aus bis zu acht Felder im sogenannten Rösselsprung (zwei vorwärts, eins
seitwärts) mit einem Zug erreichen kann:
1136
Kapitel 7
Erstellen Sie ein Programm, das einen Springer von einem beliebigen Startpunkt zu
einem beliebigen Zielpunkt auf einem Schachbrett ziehen kann! Die vom Programm
gewählte Zugfolge muss nicht optimal sein und soll bei der Ausgabe durch fortlau-
fende Nummern angezeigt werden:
+--+--+--+--+--+--+--+--+
| 0|39| |33| 2|35|18|21|
+--+--+--+--+--+--+--+--+
| | | 1|36|19|22| 3|16|
+--+--+--+--+--+--+--+--+
|38| |32| |34|17|20| 9|
+--+--+--+--+--+--+--+--+
| | |37| |23|10|15| 4|
+--+--+--+--+--+--+--+--+
Lösungen
1137
A Aufgaben und Lösungen
Lösung
Überlegen Sie zunächst, wie die Datenstruktur für das Schachbrett aussehen sollte,
und erstellen Sie dann einfache Hilfsfunktionen, z. B. für die Initialisierung oder die
Ausgabe des Schachbretts.
Spoiler
Als Struktur für das Schachbrett wählen Sie ein zweidimensionales Array:
int schachbrett[8][8];
Da dieses Array im Programm nur einmal und immer als »DAS Schachbrett« vor-
kommt, können Sie das Array global, also außerhalb Ihrer Funktionen, anlegen. Alle
Funktionen können dann auf die Information des Schachbretts zugreifen, und Sie
müssen das Schachbrett nicht an den Funktionsschnittstellen übergeben.
Als Information schreiben Sie die laufende Nummer des Zugs, die zu einem Feld
geführt hat, in das Feld. Da das Startfeld die Zugnummer 0 bekommt, verwenden Sie
den Wert –1, um anzuzeigen, dass ein Feld in der aktuellen Zugfolge nicht oder noch
nicht betreten wurde.
void initialisieren()
{
int z, s;
void ausgabe()
{
int z, s;
printf( "+--+--+--+--+--+--+--+--+\n");
for( z = 0; z < 8; z++)
{
1138
Kapitel 7
Ausgegeben werden nur Zugnummern >= 0. Ist die Zugnummer –1, bleibt das ent-
sprechende Feld des Schachbretts leer.
Die Initialisierung und die Ausgabe des Schachbretts liefern allerdings keinen Beitrag
zur Lösung des eigentlichen Problems. Das ist jetzt Ihre Aufgabe.
Bevor wir uns aber Gedanken über den eigentlichen Algorithmus zur Lösungssuche
machen, fragen wir uns, wie man alle möglichen Züge von einem Startpunkt aus
generieren kann. Man könnte zwar alle solchen Züge individuell programmieren,
aber eleganter wäre, wenn man sie systematisch erzeugen könnte.
Spoiler
Von einem festen Feld aus gibt es immer acht mögliche Züge eines Springers, wenn
wir einmal außer Acht lassen, dass ein Sprung das Brett verlässt. Wir schreiben alle
gültigen Bewegungsinkremente für die acht Sprünge in eine Datenstruktur:
Jeder der acht Züge besteht aus zwei Inkrementen, dem Zeileninkrement und dem
Spalteninkrement. Wenn wir das Array abfahren und jeweils das Zeileninkrement
Lösungen
zur aktuellen Zeile und das Spalteninkrement zur aktuellen Spalte addieren, erhalten
wir alle möglichen Zielfelder. Das könnte im Code dann wie folgt aussehen:
1139
A Aufgaben und Lösungen
Mit diesem Kniff können wir alle in einer bestimmten Situation möglichen Züge des
Springers generieren.
Suchen Sie jetzt einen rekursiven Algorithmus, um eine zielführende Zugfolge zu fin-
den. Definieren Sie dazu zunächst wieder eine rekursionsfähige Schnittstelle.
Spoiler
Als Erstes brauchen wir wieder eine rekursionsfähige Schnittstelle. In die Funktion
gehen folgende Werte ein:
Zurückgeben soll die Funktion die Information, ob mit der momentan untersuchten
Zugfolge das Ziel erreicht wurde. Dazu verwenden wir einen int-Wert. Insgesamt
ergibt sich die folgende Schnittstelle:
int springer( int zug, int pz, int ps, int zz, int zs)
Spoiler
Ich versuche, den Algorithmus sprachlich zu beschreiben:
1. Wenn wir im Wege der Rekursion auf ein bestimmtes Feld geschickt werden, prü-
fen wir als Erstes, ob es sich überhaupt um ein gültiges Feld des Schachbretts han-
delt und ob das Feld in dieser Zugfolge noch nicht benutzt wurde.
2. Sind die unter 1 genannten Bedingungen erfüllt, können wir den Zug ausführen,
Lösungen
indem wir die Zugnummer auf das Feld schreiben. Andernfalls melden wir Misser-
folg zurück, damit alternative Wege untersucht werden.
3. Ist das Ziel durch den aktuellen Zug erreicht, können wir Erfolg zurückmelden.
Alternativen müssen dann nicht mehr gesucht werden.
4. Ist das Ziel noch nicht erreicht, können wir rekursiv alle von der aktuellen Position
ausgehenden Sprünge mit einer um 1 erhöhten Zugnummer ausführen. Führt
einer dieser Züge zum Ziel, können wir wieder Erfolg melden und die Untersu-
chung weiterer Fälle einstellen.
1140
Kapitel 7
5. Führt keiner der unter 4 untersuchten Fälle zum Ziel, löschen wir die Zugnummer
wieder vom aktuellen Feld und ziehen uns mit einer Misserfolgsmeldung aus der
Rekursion zurück.
Implementieren Sie diese Strategie in einer Funktion mit der oben definierten
Schnittstelle.
Spoiler
Der Quellcode der Lösung:
int springer( int zug, int pz, int ps, int zz, int zs)
{
int z;
Initial rufen Sie diese Funktion mit der Zugnummer 0 und den Zeilen und Spalten-
indizes des Start- und Zielpunktes.
Lösungen
void main()
{
int startz, starts, zielz, ziels;
1141
A Aufgaben und Lösungen
initialisieren();
springer(0,startz-1,starts-1,zielz-1,ziels-1);
ausgabe();
}
Wenn Ihr Programm übrigens eine andere Zugfolge liefert als meins, muss das kein
Fehler sein. Vielleicht hat Ihr Programm bei der Lösungssuche nur einen anderen
Weg eingeschlagen als meins, weil Sie die Zugmöglichkeiten in einer anderen Rei-
henfolge betrachtet haben.
A 7.10 Aufgabe
Erweitern Sie das Programm der vorangegangenen Aufgabe so, dass eine optimale,
d. h. möglichst kurze, Zugfolge ermittelt wird:
+--+--+--+--+--+--+--+--+
| 0| 3| | | | | | |
+--+--+--+--+--+--+--+--+
| | | 1| | | | | |
+--+--+--+--+--+--+--+--+
| 2| | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
+--+--+--+--+--+--+--+--+
Lösungen
| | | | | | | | |
+--+--+--+--+--+--+--+--+
Lösung
Das zur Lösung der Aufgabe 7.9 verwendete Programm geht immer zuerst in die
Tiefe, um eine Lösung zu finden. Dabei findet es gegebenenfalls eine sehr lange, ziel-
führende Zugfolge, bevor eine weniger lange, ebenfalls zielführende Zugfolge in
einem noch nicht untersuchten Parallelzweig gefunden wird. Man könnte eine voll-
1142
Kapitel 7
Spoiler
Lassen Sie dazu die Rekursion immer nur bis zu einer vorgegebenen Tiefe laufen,
auch wenn noch keine Lösung ermittelt wurde! Starten Sie dann mit Rekursionstiefe
1, und steigern Sie schrittweise die Tiefe, bis Sie die erste Lösung gefunden haben!
Erweitern Sie dafür die Schnittstelle der Funktion um einen zusätzlichen Parameter
für die Maximaltiefe.
Spoiler
int springer( int zug, int pz, int ps, int zz, int zs, int maxt)
{
int z;
void main()
{
int tiefe;
int startz, starts, zielz, ziels;
1143
A Aufgaben und Lösungen
initialisieren();
for( tiefe = 0; !springer(0,startz-1,starts-1,zielz-1,ziels-1,tiefe);
tiefe++)
;
ausgabe();
}
Kapitel 8
A 8.1 Aufgabe
Schreiben Sie eine Funktion, die ein Array von Gleitkommazahlen übergeben
bekommt und die größte Zahl, die kleinste Zahl und den Mittelwert aller Zahlen
zurückgibt.
Lösung
Diese Funktion soll drei Werte zurückgeben. Das geht natürlich nicht über einen
möglichen Returnwert. Definieren Sie daher eine Schnittstelle, bei der diese Werte
über Zeiger zurückgegeben werden. Insgesamt benötigt die Funktion dann fünf Para-
meter:
왘 einen Parameter, über den die Größe des Arrays übergeben wird
왘 einen Parameter, über den das Array mit den Daten übergeben wird
왘 drei Parameter für die drei Gleitkommazahlen, die zurückgegeben werden
Spoiler
Lösungen
void minmaxmid( int anz, float *daten, float *pmin, float *pmax,
float *pmid)
Die letzten drei Parameter sind die Rückgabeparameter. Es handelt sich um Zeiger
auf Gleitkommazahlen. Das rufende Programm stellt die von diesen Parametern
referenzierten Gleitkommavariablen bereit, damit über die Zeiger Werte in die Vari-
ablen eingetragen werden können.
1144
Kapitel 8
Spoiler
Das Minimum, das Maximum und den Mittelwert zu berechnen sollte keine beson-
dere Herausforderung für Sie sein. Entscheidend sind die drei letzten Zeilen des fol-
genden Programms. Hier werden die Ergebnisse in die vom rufenden Programm
bereitgestellten Variablen geschrieben. Zum Zugriff über die Zeiger wird der Derefe-
renzierungsoperator (*) verwendet.
void minmaxmid( int anz, float *daten, float *pmin, float *pmax,
float *pmid)
{
float mx, mn, md;
int i;
mx = mn = md = daten[0];
for( i = 1; i< anz; i++)
{
if( daten[i] > mx)
mx = daten[i];
if( daten[i] < mn)
mn = daten[i];
md += daten[i];
}
*pmax = mx;
*pmin = mn;
*pmid = md/anz;
}
Erstellen Sie jetzt noch ein Hauptprogramm, in dem diese Funktion gerufen wird.
Spoiler
Lösungen
Sie benötigen drei Variablen (min, max, mid), in denen Sie die Ergebnisse entgegenneh-
men wollen. Übergeben werden an die Funktion jedoch nicht die Variablenwerte,
sondern die Adressen der Variablen (&min, &max, &mid):
1145
A Aufgaben und Lösungen
void main()
{
float daten[10] = { –1.2, 8.5, 3.1, –4.7, 5.5, –7.1, 2.0, 1.4,
–4.9, 0.3};
float min, max, mid;
In der Funktion minmaxmid werden den Variablen über die Zeiger Werte zugewiesen,
die anschließend ausgegeben werden können.
Wichtig ist, dass im Hauptprogramm die Speichersubstanz der Variablen min, max und
mid liegt. Würde man nur Zeiger anlegen und diese Zeiger an das Unterprogramm
übergeben, wäre dies zwar formal richtig, aber das Unterprogramm würde abstürzen.
Der folgende Programmcode kann daher nicht funktionieren:
A 8.2 Aufgabe
Schreiben Sie eine Funktion, die ein Array von Integer-Zahlen übergeben bekommt
und auf allen Zahlen des Arrays eine konfigurierbare Operation ausführt. Die auszu-
führende Operation soll der Funktion über einen Funktionszeiger mitgeteilt werden.
Die übergebene Funktion soll einen int-Wert als Parameter erhalten und einen int-
Wert zurückgeben. Testen Sie Ihr Programm mit folgenden Operationen:
왘 Integer-Division durch 2
Lösungen
Lösung
Zur Lösung dieser Aufgabe benötigen Sie Funktionszeiger, damit Sie die gewünsch-
ten Funktionen als Parameter an die Funktion übergeben können. Die als Parameter
übergebenen Funktionen müssen dazu eine einheitliche Schnittstelle haben.
1146
Kapitel 8
Zur Lösung dieser Aufgabe benötigen Sie also zwei Schnittstellen – die Schnittstelle
der Funktionen, die als Parameter übergeben werden, und die Schnittstelle der
eigentlich zu erstellenden Funktion.
Spoiler
Wir erstellen die drei Funktionen2, die wir später als Parameter übergeben wollen.
Die Funktion zur Ausgabe (out) benötigt eigentlich keinen Rückgabewert. Da wir aber
eine einheitliche Schnittstelle benötigen, lassen wir diese Funktion den eingegebe-
nen Wert wie ein Echo wieder ausgeben. Damit haben wir für alle drei Funktionen die
gleiche Schnittstelle. Sie erhalten einen int-Wert und geben einen int-Wert zurück.
Also:
Der Identifier fkt ist dabei nur ein Platzhalter, der für div2, rest2, out oder jede
andere Funktion mit identischer Schnittstelle stehen könnte. Der Datentyp (Funk-
tion, die einen int-Wert bekommt und einen int-Wert zurückgibt) wird als Parame-
ter an der Schnittstelle der eigentlich zu erstellenden Funktion verwendet:
1147
A Aufgaben und Lösungen
Spoiler
In der Funktion wird über das Daten-Array iteriert, und für jedes Feld im Array wird
die gewünschte Funktion ausgeführt:
In der Funktion selbst ist nicht bekannt, welche Operation auf den Feldern des Arrays
ausgeführt wird. Das entscheidet das Hauptprogramm durch entsprechende Para-
metrierung.
Erstellen Sie noch ein Hauptprogramm, das die Funktion doit testet.
Spoiler
Im Hauptprogramm wird ein Daten-Array angelegt. Danach wird eine Reihe von
Operationen auf dem Array ausgeführt:
void main()
Lösungen
{
int daten[10] = { 10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
1148
Kapitel 8
A 8.3 Aufgabe
Erstellen Sie eine rekursive Funktion zur Berechnung der Länge eines Strings, die
konsequent auf die Verwendung von Zeigern setzt.
Lösung
Unter einer konsequenten Verwendung von Zeigern verstehe ich, dass nirgendwo im
Programm ein Zugriff über einen Integer-Index3 erfolgt. Insbesondere sollten keine
Integer-Indizes an der Funktionsschnittstelle verwendet werden. Etwas salopper aus-
gedrückt: In Ihrer Funktion dürfen nirgendwo eckige Klammern auftauchen – weder
zur Deklaration eines Arrays noch zum Zugriff.
Spoiler
An der Schnittstelle bekommt die Funktion einen Zeiger (auf char) übergeben. Beim
Erstaufruf ist das ein Zeiger auf den kompletten String, im Laufe des Verfahrens dann
ein Zeiger auf den Reststring:
Ist der Reststring leer (*str == 0 oder !*str), ist die Länge 0. Ansonsten ist die Länge
mindestens 1. Dazu kommt dann noch die Länge des Reststrings (str+1). Um den
Reststring zu referenzieren, wird einfach der Zeiger (str) um 1 weitergeschoben.
A 8.4 Aufgabe
Erstellen Sie eine rekursive Funktion zum Vergleich zweier Strings, die konsequent
auf die Verwendung von Zeigern setzt.
Lösungen
Lösung
Gehen Sie ähnlich vor wie in Aufgabe 8.3, wobei Sie es hier mit zwei Zeigern zu tun
haben, die synchron durch die beiden Strings laufen.
3 Zeiger sind natürlich auch Indizes (Index heißt auf Deutsch schließlich Zeiger, und der Zeigefin-
ger heißt im Englischen Index Finger). Deshalb betone ich hier: Integer-Index.
1149
A Aufgaben und Lösungen
Spoiler
Man betrachtet zwei Zeichen in den beiden Strings, die an der gleichen Position ste-
hen. Dann gibt es im Prinzip drei Fälle:
1. Wenn ein Unterschied erkannt wird, sind die Strings verschieden (return 0).
2. Wenn kein Unterschied besteht und der erste String zu Ende ist, dann ist auch der
zweite zu Ende, und die Strings sind gleich (return 1).
3. Wenn kein Unterschied besteht und die Strings nicht zu Ende sind, kann noch
keine Entscheidung getroffen werden. Durch synchrones Vorrücken der Zeiger
(s1+1, s2+1) wird die Entscheidung auf die nächste Rekursionsebene gelegt.
Beim Erstaufruf der Funktion müssen die Zeiger natürlich am Anfang der zu verglei-
chenden Strings stehen.
A 8.5 Aufgabe
Erstellen Sie eine rekursive Lösung der Aufgabe 7.8, die konsequent auf die Verwen-
dung von Zeigern setzt.
Lösung
Die Aufgabe 7.8 hatten wir so gelöst, dass wir den noch zu bearbeitenden Bereich des
Lösungen
Arrays durch zwei Integer-Indizes beschrieben haben, die dann in der Rekursion auf-
einander zu rückten. Verwenden Sie jetzt, anstelle der Integer-Indizes, Zeiger auf den
Wertetyp des Arrays. Das Array selbst müssen Sie dann nicht mehr übergeben, da es
durch die Zeiger referenziert ist.
Spoiler
1150
Kapitel 8
Bei der Implementierung müssen Sie nur wissen, dass man Zeigerwerte, also Spei-
cheradressen, auch bezüglich ihrer Größe miteinander vergleichen können und dass
der Zugriff über den *-Operator erfolgen muss.
Algorithmisch ist die Lösung identisch mit der Lösung zu Aufgabe 7.8 und muss
daher nicht besonders erklärt werden.
A 8.6 Aufgabe
In C können Sie Funktionen mit einer unbestimmten Anzahl an Parametern definie-
ren. Anstelle der fehlenden Funktionsparameter können Sie einfach drei Punkte set-
zen. Zum Beispiel können Sie die folgende Funktion
erstellen, die Sie dann mit unterschiedlicher Parameterzahl rufen können. Zum Bei-
spiel:
a = add( 5, 1, 2, 3, 4, 5);
Lösungen
Wir wollen die Funktion add immer so rufen, dass im ersten Parameter, der ja auf
jeden Fall vorhanden ist, die Anzahl der noch folgenden Parameter steht. Versuchen
Sie jetzt, die Funktion add so zu programmieren, dass sie die Summe der auf den ers-
ten Parameter folgenden Parameterwerte berechnet und zurückgibt. In unseren Bei-
spielen sollen also für a die folgenden Werte berechnet werden:
1151
A Aufgaben und Lösungen
Lösung
Die Funktionsparameter werden auf dem Stack übergeben. Sie müssen versuchen,
Zugriff auf den Stack zu bekommen.
Spoiler
Der erste Parameter, der ja immer vorhanden ist, liegt auch auf dem Stack. Wenn Sie
die Adresse dieses Parameters nehmen, haben Sie eine gültige Adresse aus Ihrem
Stack-Bereich. Suchen Sie im Umfeld dieser Adresse die anderen Parameter.
Spoiler
Die gesuchten Parameter können eigentlich nur vor oder hinter dem ersten Parame-
ter liegen. Wenn Sie daher in der Funktion einen Zeiger anlegen
int *p;
p = &anz;
dann finden Sie hinter diesem Parameter tatsächlich den zweiten Parameter. Prüfen
Sie dies durch eine Testausgabe:
Spoiler
Sie können jetzt durch den Stack iterieren und alle dort gefundenen Werte addieren:
1152
Kapitel 8
p = &anz;
for( sum = 0, i = 1; i <= anz; i++)
sum += *(p+i);
return sum;
}
void main()
{
int a;
a = add( 1, 1);
printf( "Summe = %d\n", a);
a = add( 2, 1, 2);
printf( "Summe = %d\n", a);
a = add( 3, 1, 2, 3);
printf( "Summe = %d\n", a);
a = add( 4, 1, 2, 3, 4);
printf( "Summe = %d\n", a);
a = add( 5, 1, 2, 3, 4, 5);
printf( "Summe = %d\n", a);
}
Das hier vorgestellte Verfahren funktioniert immer, wenn Sie mindestens einen
Parameter haben und die Anzahl und die Typen der Zusatzparameter kennen. Aber
Vorsicht: Das Verfahren ist maschinenabhängig, da in unterschiedlichen Architektu-
ren der Stack unterschiedlich organisiert sein kann. In der Runtime Library (siehe
später in Kapitel 10, »Die Standard C Library«) gibt es eine Möglichkeit, Funktionen
mit einer variablen Parameterzahl portabel, also maschinenunabhängig, zu pro-
grammieren.
Lösungen
A 8.7 Aufgabe
Ganze Zahlen werden im Rechner als Folge von Bytes abgelegt. Damit ist aber noch
nicht festgelegt, in welcher Reihenfolge die Bytes einer Zahl gespeichert werden. Eine
2-Byte-Zahl wird an zwei aufeinanderfolgenden Speicheradressen abgelegt. Aber
steht das höherwertige Byte an der kleineren oder der größeren Adresse?
왘 Wenn das niederwertige Byte an der kleineren Adresse und das höherwertige Byte
an der größeren Adresse steht, spricht man vom Little-Endian-Format.
왘 Wenn das höherwertige Byte an der kleineren Adresse und das niederwertige Byte
an der größeren Adresse steht, spricht man vom Big-Endian-Format.
1153
A Aufgaben und Lösungen
Schreiben Sie ein Programm, das feststellt, ob Ihre Hardware eine Big- oder Little-
Endian-Darstellung verwendet.
Lösung
Zur Beantwortung der Frage sollten Sie eine Zahl4 nehmen und diese Zahl byteweise
betrachten.
Rein arithmetische Operationen oder Bitoperationen (z. B. zahl % 0xff oder zahl >> 8)
sind bei der Analyse nicht zielführend, da diese Operationen die Stellenwerte korrekt
berücksichtigen. Hier geht es ja darum, die Reihenfolge der Bytes im Speicher festzu-
stellen.
Spoiler
Wenn Sie die Adresse der Zahl als einen Zeiger auf Bytes (unsigned char *) uminter-
pretieren, können Sie die Zahl byteweise aus dem Speicher lesen.
Zur »Uminterpretation« können Sie den Cast-Operator verwenden, den Sie bereits in
Kapitel 3, »Ausgewählte Sprachelemente von C«, kennengelernt haben.
Spoiler
Lösungen
Die Lösung ist ganz einfach und bedarf nur weniger Zeilen Code:
void main()
{
unsigned short testzahl = 0x0102;
unsigned char *p;
4 Es kann im Prinzip irgendeine Zahl sein, die Bytes der Zahl müssen sich nur unterscheiden.
1154
Kapitel 10
if( *p == 0x01)
printf( "Big-Endian\n");
else
printf( "Little-Endian\n");
}
Der Cast-Operator nimmt die Adresse der Testzahl und führt eine Typkonvertierung
auf den Datentyp »Zeiger auf unsigned char« durch. Wenn das Ergebnis dann einem
passenden Zeiger zugewiesen wird, können wir über den Zeiger byteweise auf den
Speicher zugreifen, in dem die Zahl abgelegt ist. Beachten Sie, dass durch den Cast-
Operator nur der Zeiger uminterpretiert wird. Die Zahl selbst wird nicht verändert.
Auf einem System mit Intel-Architektur (PC) sollte das Little-Endian-Format erkannt
werden.
Kapitel 10
Beachten Sie bei der Bearbeitung der Aufgaben auch die Hinweise, die ich Ihnen in
Kapitel 10, »Die Standard C Library«, am Beginn des Aufgabenabschnitts gegeben
habe. Der Generator für Zufallsstrings ist hier ein wichtiges Testwerkzeug!
A 10.1 Aufgabe
Erstellen Sie eine Funktion, die 1000 Zufallsstrings erzeugt und diese in eine Datei
Lösungen
Lösung
Zum Erzeugen der Zufallsstrings können Sie den Generator aus dem zugehörigen
Kapitel verwenden.
Nach dem Einlesen des Dateinamens müssen Sie die Datei zum Schreiben öffnen
(fopen). Dann können Sie die Strings in die Datei schreiben (fprintf). Am Ende sollten
Sie die Datei wieder schließen (fclose).
1155
A Aufgaben und Lösungen
Wenn Sie keinen Dateipfad angeben, wird die Datei in Ihrem Projektverzeichnis
erzeugt. Eine gegebenenfalls vorhandene Datei gleichen Namens wird dabei über-
schrieben.
Spoiler
Wichtig für Dateioperationen ist der sogenannte Filepointer (Dateizeiger). Dieser
wird durch die Anweisung
FILE *pf;
angelegt. Beim Filepointer handelt es sich um einen Zeiger, dem ich hier den Namen
pf gegeben habe. Dieser Zeiger wird beim Schreiben der Daten
fclose( pf);
verwendet, ohne dass wir wissen müssen, worauf der Zeiger eigentlich zeigt. Man
nennt so etwas einen magischen Zeiger (Magic Pointer).
An der Schnittstelle der Funktion habe ich den Datenamen (dname), die Anzahl der zu
erzeugenden Daten (anz) und einen Startwert für den Zufallszahlengenerator (seed)
als Parameter festgelegt. Damit ergibt sich folgende Schnittstelle:
char str[100];
int i;
FILE *pf;
srand( seed);
pf = fopen( dname, "w");
1156
Kapitel 10
void main()
{
int anzahl, seed;
char datei[80];
A 10.2 Aufgabe
Erstellen Sie eine Funktion, die die in Aufgabe 10.1 erstellten Strings aus der Datei ein-
liest und eine Statistik über die Buchstabenhäufigkeit erstellt.
Lösung
Eine Buchstabenhäufigkeitsanalyse haben wir bereits in Abschnitt 6.9.1, »Buchsta-
benstatistik«, durchgeführt, sodass wir uns hier ganz auf die Dateioperationen kon-
Lösungen
zentrieren können.
Für die drei Operationen benötigen Sie wieder den magischen Dateizeiger.
1157
A Aufgaben und Lösungen
Spoiler
Ich habe den Dateinamen an der Schnittstelle übergeben und zunächst nur das Lesen
der Zeichenketten implementiert. Jede Zeichenkette wird, nachdem sie gelesen
wurde, auf dem Bildschirm ausgegeben:
Im Hauptprogramm wird nur der Dateiname abgefragt, um dann die Funktion ana-
lyse aufzurufen.
void main()
{
int anzahl, seed;
char datei[80];
analyse( datei);
}
Implementieren Sie daher jetzt die noch fehlende Buchstabenanalyse. Falls Sie Pro-
bleme damit haben, holen Sie sich Anregungen aus Abschnitt 6.9.1, »Buchstabensta-
tistik«.
Spoiler
1158
Kapitel 10
Hier sehen Sie jetzt die vollständige Funktion einschließlich der Buchstabenanalyse.
Am Ende werden sowohl die absolute als auch die relative Häufigkeit des Vorkom-
mens aller Buchstaben ausgegeben.
A 10.3 Aufgabe
Betrachten Sie folgenden Funktionen der Standard Library:
왘 atoi
Lösungen
왘 strcat
왘 strchr
왘 strcmp
왘 strcpy
왘 strcspn
왘 strlen
왘 strncat
왘 strncmp
1159
A Aufgaben und Lösungen
왘 strncpy
왘 strpbrk
왘 strrchr
왘 strspn
왘 strstr
왘 strtok
왘 strtol
Besorgen Sie sich Informationen über die Schnittstelle dieser Funktionen. Imple-
mentieren Sie dann die Funktionen myatoi, mystrcat, ... mit gleicher Funktionalität
und Schnittstelle. Erstellen Sie einen Testrahmen, in dem Sie Massentestdaten gene-
rieren und die Ergebnisse Ihrer Funktionen mit denen der Originalfunktionen ver-
gleichen.
Lesen Sie die Testdaten wahlweise auch aus einer Textdatei ein, die Sie zuvor erzeugt
haben.
Lösung
Eine vollständige Lösung dieser Aufgabe ist sehr umfangreich. In der einen oder
anderen Weise haben wir auch Funktionen aus der Liste bereits implementiert.
Exemplarisch möchte ich deshalb hier nur die erste Funktion (atoi) implementieren.
Informieren Sie sich zunächst über die Aufgabe und die Schnittstelle dieser Funktion.
Spoiler
Die Funktion atoi (ASCII to Integer) konvertiert eine Zeichenkette in eine Integer-
Zahl und hat die Schnittstelle:
Der Zusatz const bedeutet, dass die Funktion den übergebenen String nicht verän-
dern kann. Das haben wir bisher nicht besprochen; es ist aber in diesem Zusammen-
Lösungen
hang auch nicht wichtig. Wenn es Sie stört, lassen Sie das const einfach weg.
1160
Kapitel 10
왘 Der Prozess stoppt, sobald der String beendet ist oder ein Zeichen auftritt, das
nicht in das oben dargestellte Schema passt. Zeichen, die nach dem Abbruch noch
im String stehen, werden ignoriert.
왘 Die Zahl wird aus dem Vorzeichen und den Dezimalziffern berechnet. Konnten
keine gültigen Ziffern gelesen werden, ist das Ergebnis der Konvertierung 0.
Spoiler
Bei der Implementierung folgen wir den Anforderungen:
In einem Testrahmen überprüfen wir die Funktion anhand eines einzelnen Testfalls:
void main()
Lösungen
{
int i1, i2;
char *test = "\r\f \v-12345abcdefg";
i1 = atoi( test);
i2 = myatoi( test);
if( i1 != i2)
printf( "Fehler\n");
1161
A Aufgaben und Lösungen
else
printf( "ok\n");
}
Um Ihre Funktion auf Herz und Nieren zu testen, sollten Sie Massentests mithilfe des
Zeichenkettengenerators durchführen, den ich Ihnen in Kapitel 10, »Die Standard C
Library«, zu Beginn des Aufgabenteils vorgestellt habe.
Kapitel 14
A 14.1 Aufgabe
In Abschnitt 7.6.1, »Bruchrechnung«, haben wir Brüche durch zwei-elementige Arrays
dargestellt und die Addition und das Kürzen von Brüchen programmiert. Erstellen
Sie die gleichen Funktionen erneut, verwenden Sie diesmal zur Speicherung von Brü-
chen jedoch eine geeignete Datenstruktur.
Lösung
Erstellen Sie zuerst eine Datenstruktur, die Zähler und Nenner eines Bruchs aufneh-
men kann.
Spoiler
So sollte Ihre Datenstruktur im Prinzip aussehen:
struct bruch
{
int zaehler;
int nenner;
};
Wie man die Datenstruktur selbst und ihre Felder benennt, ist im Prinzip egal, aber
Lösungen
hier bieten sich die Begriffe bruch, zaehler und nenner an.
Spoiler
Die Funktion ggt zur Berechnung des größten gemeinsamen Teilers kann ohne Ver-
änderung übernommen werden und wird hier nicht eigens noch mal gezeigt oder
erklärt.
1162
Kapitel 14
Von der Umstellung betroffen sind aber die Funktionen kuerzen und addieren, bei
denen sich nicht nur die Implementierung, sondern auch die Schnittstelle ändert.
Die Funktion kuerzen sollte einen Bruch übergeben bekommen und diesen selbst
ändern. Der Bruch sollte daher mittels Zeiger übergeben werden, damit die Funktion
auf dem Original arbeiten kann.
Die Funktion addieren bekommt an der Schnittstelle zwei Brüche als Werte überge-
ben, erzeugt das Ergebnis als neuen Bruch und gibt diesen zurück. Sie könnten hier
auch Zeiger verwenden, aber ich schlage vor, mit der Übergabe von Werten zu ar-
beiten.
Spoiler
Bis auf die Benennung der Parameter sollten Ihre Schnittstellen wie folgt definiert
sein:
Spoiler
Bei der Implementierung müssen Sie darauf achten, dass Sie beim Direktzugriff den
Punkt und beim Indirektzugriff den Pfeil zum Zugriff auf die Felder Ihrer Datenstruk-
tur verwenden müssen. Wenn Sie es falsch machen, weist der Compiler Sie auf den
Fehler hin.
1163
A Aufgaben und Lösungen
Die Funktion addieren verwendet bereits die Funktion kuerzen und übergibt dazu die
Adresse des intern verwendeten Bruchs erg:
return erg;
}
Die Funktion addieren zeigt, dass Datenstrukturen als Werte an Funktionen überge-
ben und als Werte von Funktionen zurückgegeben werden können. Da Datenstruktu-
ren sehr groß sein können, kann ein unerwünschter Rechenaufwand an der
Schnittstelle entstehen, da die Daten kopiert werden müssen. Sie vermeiden diesen
Rechenaufwand, indem Sie Zeiger verwenden, da dann ja nur die Zeigerwerte dupli-
ziert werden. In C ist es üblich, größere Datenstrukturen immer mittels Zeigern zu
übergeben.
Spoiler
Hier ist ein einfaches Testprogramm:
void main()
{
struct bruch bruch1, bruch2, ergebnis;
1164
Kapitel 14
A 14.2 Aufgabe
Erstellen Sie eine Datenstruktur, die den Namen und das Alter einer Person aufneh-
men kann. Erstellen Sie dann ein Programm, das diese Daten für zehn Personen ein-
liest und nach Alter sortiert wieder ausgibt.
Lösung
Am Anfang steht immer die Datenstruktur. Erstellen Sie daher die Datenstruktur,
und überlegen Sie sich, welche Funktionen Sie benötigen, um das Problem gut
modularisiert zu lösen.
Spoiler
Hier ist meine Datenstruktur:
struct person
{
char name[20];
int alter;
};
Beachten Sie, dass der Name in der Datenstruktur steht und inklusive des Termina-
torzeichens auf 20 Zeichen beschränkt ist.
Im Hauptprogramm werden wir ein Array für eine festgelegte Maximalzahl von Per-
sonen anlegen. Das könnte dann so aussehen:
# define ANZAHL 10
1. Einlesen
Lösungen
2. Sortieren
3. Ausgeben
Für jede dieser Aufgaben erstellen wir eine Funktion, die jeweils einen Zeiger auf das
Daten-Array und die Anzahl der Daten im Array übergeben bekommen. Das fertige
Hauptprogramm könnte damit wie folgt aussehen:
1165
A Aufgaben und Lösungen
# define ANZAHL 10
void main()
{
struct person daten[ANZAHL];
Zum Sortieren werde ich Bubblesort verwenden. Das ist zwar nicht der effizienteste
Algorithmus, aber der Sortieralgorithmus steht hier nicht im Vordergrund. Wenn Sie
wollen, können Sie natürlich auch ein anderes Verfahren verwenden.
Spoiler
Bei der Implementierung der Funktionen einlesen und ausgeben sollten Sie keine
Probleme haben. Sie iterieren jeweils durch das Array, lesen die Daten für die Felder
ein oder geben sie aus:
1166
Kapitel 14
Zur Implementierung der Sortierung kopieren Sie die Funktion bubblesort, bei der
Sie anschließend zwei kleine chirurgische Eingriffe vornehmen müssen:
1. An der Schnittstelle wird nicht ein int-Array (int *daten), sondern ein Array von
Personen (struct person *daten) übergeben.
2. Die Variable t, die zum Tauschen zweier Felder im Array dient, hat nicht mehr den
Datentyp int, sondern struct person.
A 14.3 Aufgabe
Erstellen Sie eine Funktion, die ein Array von ganzen Zahlen übergeben bekommt
und den größten, den kleinsten und den Mittelwert der übergebenen Werte in einer
Datenstruktur zurückgibt.
Lösung
Eine Funktion kann nur einen Wert per return-Anweisung zurückgeben. Zusätzliche
Werte können nur über Zeigerparameter zurückgegeben werden. Mit einer Daten-
1167
A Aufgaben und Lösungen
struktur haben Sie die Möglichkeit, mehrere Werte zu einem neuen Typ zusammen-
zufassen und diesen per Zeiger oder per return-Anweisung zurückzugeben.
Erstellen Sie jetzt eine Datenstruktur für das Funktionsergebnis. Erstellen Sie dann
Funktionsschnittstellen für beide Varianten:
Spoiler
Hier ist die Datenstruktur:
struct auswertung
{
int min;
int max;
float mid;
};
Zu beachten ist eigentlich nur, dass für den Mittelwert (mid) ein Gleitkommawert zu
erwarten ist. Minimum (min) und Maximum (max) haben natürlich den Datentyp int.
Zur Verwendung der ersten Variante muss ein Zeiger auf die Ergebnisstruktur über-
geben werden:
Lösungen
1168
Kapitel 14
Spoiler
Bei Variante 1 wird durch Indirektzugriff auf der vom rufenden Programm übergebe-
nen Datenstruktur gearbeitet:
Eine explizite Rückgabe von Ergebnissen ist dann nicht mehr erforderlich.
Bei Variante 2 wird eine lokale Datenstruktur (a) erzeugt, auf der dann mit Direktzu-
griff gearbeitet wird:
struct auswertung a;
int i;
1169
A Aufgaben und Lösungen
A 14.4 Aufgabe
Erstellen Sie eine Datenstruktur für ein Fußballturnier mit einer festen Anzahl von
Mannschaften. Bei dem Turnier spielt jede Mannschaft gegen jede Mannschaft in
einem Hinspiel und einem Rückspiel. Die Datenstruktur sollte folgende Informatio-
nen aufnehmen können:
Darüber hinaus sollte eine Tabelle berechnet werden können und ebenfalls in der
Datenstruktur abgelegt werden. Zur Bearbeitung der Datenstruktur erstellen Sie fol-
gende Funktionen:
Lösung
Zur Lösung dieser Aufgabe werden Sie sicherlich mehrere Datenstrukturen anlegen
müssen. Zum Beispiel benötigen Sie Datenstrukturen zum Speichern eines einzel-
nen Ergebnisses oder zum Speichern einer Tabellenzeile. Fassen Sie die einzelnen
Lösungen
Erstellen Sie Ihre Datenstrukturen und später auch Ihre Funktionen so, dass die
Anzahl der am Turnier beteiligten Vereine über eine symbolische Konstante festge-
legt und damit zur Compile-Zeit geändert werden kann.
Spoiler
1170
Kapitel 14
Mit einer symbolischen Konstanten legen wir die Anzahl der am Turnier beteiligten
Mannschaften fest:
# define ANZAHL 4
Immer, wenn in einer Datenstruktur oder einer Funktion auf die Anzahl der Vereine
Bezug genommen wird, werde ich diese Konstante verwenden. Dadurch kann die
Anzahl der Vereine zur Compile-Zeit geändert werden.
Die erste Datenstruktur steht für ein einzelnes Spielergebnis und muss nicht weiter
erklärt werden:
struct ergebnis
{
int tore;
int gegentore;
};
Da jede Mannschaft gegen jede andere ein Heim- und ein Auswärtsspiel austrägt,
ergeben sich insgesamt ANZAHL(ANZAHL-1) Spiele bzw. Spielergebnisse, die man in
einem zweidimensionalen Array speichern kann:
Durch den Index der Heimmannschaft und den Index der Auswärtsmannschaft
kann man wahlfrei auf jedes Spielergebnis zugreifen. Zum Beispiel
resultat[1][2].tore = 3;
resultat[1][2].gegentore = 0;
Da keine Mannschaft gegen sich selbst spielt, bleibt die »Diagonale« in dem Array
ungenutzt.
Für jeden Mannschaftsnamen sehen wir einen Puffer der Länge 20 vor, sodass wir die
Mannschaftsnamen wie folgt speichern können:
Lösungen
char mannschaft[ANZAHL][20];
왘 der Vereinsname
왘 die Anzahl der Spiele, die der Verein gespielt hat
왘 die Anzahl der Punkte, die der Verein erreicht hat
왘 die Anzahl der Tore, die der Verein geschossen hat
왘 die Anzahl der Gegentore, die der Verein kassiert hat
1171
A Aufgaben und Lösungen
struct auswertung
{
int team;
int spiele;
int punkte;
int tore;
int gegentore;
};
Anstelle des Vereinsamens speichern wir in der Tabelle nur den Index des Vereins
(team). Über diesen Index finden wir aber den Vereinsnamen im Array mannschaft.
Eine Tabelle enthält für jeden Verein eine Auswertung. Die Tabelle ist damit ein
Array, dessen Felder vom Typ struct auswertung sind:
Jetzt haben wir alle Bausteine, die wir noch zu einer Gesamtheit zusammenfügen
müssen.
struct turnier
{
char mannschaft[ANZAHL][20];
struct ergebnis resultat[ANZAHL][ANZAHL];
struct auswertung tabelle[ANZAHL];
};
dadurch nicht inkonsistent, und auch Funktionen wie die Tabellenberechnung könn-
ten mit solchen Ergebnissen durchgeführt werden.
왘 Der Name einer Mannschaft darf inklusive des Terminatorzeichens nicht mehr als
20 Zeichen umfassen.
왘 Der Index team in der Datenstruktur auswertung muss immer einen Wert zwischen
0 und ANZAHL-1 haben, um einen gültigen Verein zu referenzieren.
1172
Kapitel 14
왘 Dem Feld tore in der Struktur ergebnis geben wir eine zusätzliche Bedeutung. Ein
negativer Wert in diesem Feld soll anzeigen, dass das Spiel noch nicht stattgefun-
den hat.
Jetzt können Sie Funktionen auf dieser Datenstruktur implementieren. Formal sind
die Funktionen sehr einheitlich. Sie bekommen einen Zeiger auf das Turnier, das Sie
bearbeiten sollen, und benötigen keinen Returntyp:
Spoiler
Wir starten mit der Funktion daten_einlesen. Ich habe die Funktion so implemen-
tiert, dass die Daten von der Tastatur eingelesen werden. Für vier Vereine ist das noch
handhabbar. Bei größeren Datenmengen wird das Einlesen nicht komplizierter, es
wird nur ermüdend für den Benutzer, und Sie sollten vielleicht Funktionen erstellen,
um die Daten in einer Datei zu speichern und aus einer Datei wieder einzulesen. Das
können Sie ja als Zusatzaufgabe implementieren.
}
for( z = 0; z < ANZAHL; z++)
{
for( s = 0; s < ANZAHL; s++)
{
if( z == s)
continue;
printf( "%s : %s: ", t->mannschaft[z], t->mannschaft[s]);
1173
A Aufgaben und Lösungen
Wichtig ist hier der Zugriff in die Datenstruktur, den ich Ihnen an einem Beispiel
noch einmal Schritt für Schritt erklären möchte. Betrachten Sie den Ausdruck:
&(t->resultat[z][s].tore)
Zunächst einmal ist t ein Zeiger auf ein Turnier (struct turnier *t). Über diesen Zei-
ger können wir auf das Feld resultat zugreifen. Da t ein Zeiger ist, wird zum Zugriff
der Pfeil-Operator verwendet.
t->resultat
Resultat ist ein zweidimensionales Array. Wir können also mit Zeilen- und Spaltenin-
dex ein Feld auswählen:
t->resultat[z][s]
Wichtig ist, dass Zeilen- und Spaltenindex dabei im gültigen Bereich (0-ANZAHL-1) lie-
gen. Die einzelnen Felder des Arrays sind vom Typ struct ergebnis. Das heißt, wir
können mit dem Punkt-Operator auf das Feld tore zugreifen:
t->resultat[z][s].tore
Jetzt sind wir auf einem int-Feld angekommen. Von diesem Feld können wir die
Adresse nehmen:
Lösungen
&(t->resultat[z][s].tore)
Mit dieser Adresse gehen wir letztlich in die scanf-Funktion, um einen Wert einzu-
lesen.
Erstellen Sie als Nächstes eine Funktion, um die Daten aus der Datenstruktur auszu-
geben.
Spoiler
1174
Kapitel 14
Die Ausgabe ist einfacher als die Eingabe und sollte Sie nicht vor Probleme stellen.
Beachten Sie, dass ich in meiner Implementierung auch die Tabelle ausgebe, obwohl
es noch keine Funktion gibt, um die Tabelle zu berechnen. Die Tabellendaten sind
daher noch uninitialisiert, und der Versuch, diese Funktion zu rufen, bevor die Tabelle
berechnet ist, könnte, wegen der uninitialisierten Vereinsindizes in der Tabelle, zu
einem Absturz führen. In einem »seriösen« Programm würde man das noch absichern.
printf( "\nMannschaften\n");
for( z = 0 ; z < ANZAHL; z++)
printf( "%d. %s\n", z, t->mannschaft[z]);
printf( "\nSpiele\n");
for( z = 0; z < ANZAHL; z++)
{
for( s = 0; s < ANZAHL; s++)
{
if( z == s)
continue;
if( t->resultat[z][s].tore > 0)
printf( "%s : %s %d:%d\n", t->mannschaft[z],
t->mannschaft[s], t->resultat[z][s].
tore, t->resultat[z][s].gegentore);
}
}
printf( "\nTabelle\n");
for( z = 0; z < ANZAHL; z++)
{
printf( "%-10s", t->mannschaft[t->tabelle[z].team]);
printf( "%2d %2d %2d:%2d\n", t->tabelle[z].spiele,
t->tabelle[z].punkte, t->tabelle[z].tore,
t->tabelle[z].gegentore);
Lösungen
}
}
Spoiler
Um eine Tabelle zu berechnen, müssen wir zunächst einmal die Spielergebnisse ag-
gregieren. Das heißt, wir müssen berechnen, wie viele Spiele ein Verein absolviert
hat, wie viele Punkte er dabei erreicht hat, wie viele Tore er geschossen und kassiert
1175
A Aufgaben und Lösungen
hat. Das machen wir in der Funktion tabelle_berechnen. Wenn Sie mit der Punktver-
gabe bei Fußballturnieren vertraut sind, sollten Sie keine Probleme mit dem folgen-
den Code haben.
else
t->tabelle[s].punkte +=3;
}
t->tabelle[z].tore += t->resultat[z][s].tore;
t->tabelle[z].gegentore += t->resultat[z][s].gegentore;
t->tabelle[s].tore += t->resultat[z][s].gegentore;
t->tabelle[s].gegentore += t->resultat[z][s].tore;
}
}
bubblesort( ANZAHL, t->tabelle);
}
1176
Kapitel 14
Am Ende dieser Funktion wird die Tabelle mit Bubblesort sortiert. Dazu muss Bubble-
sort wieder einmal angepasst werden. Insbesondere muss zunächst eine Funktion
zum Vergleich verschiedener Auswertungen erstellt werden, da es sich nicht um
einen einfachen numerischen Vergleich handelt. Typischerweise bekommt eine
Mannschaft für einen Sieg drei Punkte und für ein Unentschieden einen Punkt. Beim
Vergleich spielt auch die Tordifferenz, also die Differenz zwischen geschossenen und
kassierten Toren, eine große Rolle. Es gibt bei unterschiedlichen Turnieren manch-
mal unterschiedliche Bewertungskriterien. Um zu entscheiden, ob ein Verein A vor
einem Verein B in der Tabelle liegt, habe ich die folgenden Kriterien5 zugrunde
gelegt:
Diese Kriterien müssen der Reihe nach prüft werden, wobei nachfolgende Kriterien
nur herangezogen werden, wenn die vorangegangenen nicht zu einer Entscheidung
geführt haben. Damit ergibt sich folgende Vergleichsfunktion:
5 Üblicherweise gibt es noch weitere Kriterien, wie z. B. das Ergebnis des direkten Vergleichs. Sol-
che Kriterien habe ich hier aber nicht implementiert.
1177
A Aufgaben und Lösungen
Zum Sortieren nehme ich wieder Bubblesort. Sortiert werden jetzt Strukturen des
Typs struct auswertung, und zum Vergleich zweier Auswertungen wird die oben dar-
gestellte Funktion vergleich herangezogen. Der eigentliche Algorithmus von Bub-
blesort wird aber nicht verändert:
Mit dem Sortieren der Tabelle ist die Aufgabe komplett gelöst.
A 14.5 Aufgabe
Erstellen Sie ein Programm, das eine Liste von Zahlen verwaltet. Das Programm soll
folgende Funktionen enthalten:
Lösung
Definieren Sie zunächst die Datenstruktur und die Schnittstellen der benötigten
Funktionen.
1178
Kapitel 14
Spoiler
Um eine Liste aufbauen zu können, muss die Datenstruktur einen Verkettungszei-
ger, also einen Zeiger auf ihren eigenen Typ, enthalten. Häufig nennt man diesen Zei-
ger next, jeder andere Name ist aber auch möglich:
struct element
{
struct element *next;
int wert;
};
Neben dem Verkettungszeiger gibt es noch ein Datenfeld (wert), in dem die »Nutz-
last« der Datenstruktur steht.
Zur Erzeugung der Liste übergeben wir die gewünschte Anzahl und erhalten die Liste,
genauer: einen Zeiger auf das erste Element der Liste, zurück:
Die Funktionen zum Ausgeben, umgekehrten Ausgeben und Freigeben der Liste
haben die gleiche Schnittstelle. Sie erhalten jeweils einen Zeiger auf das erste Ele-
ment der Liste und haben keinen Returnwert:
Die Funktion zum Addieren der Listenwerte gibt zusätzlich die berechnete Summe
Lösungen
zurück:
Die Funktion zum Umkehren der Liste gibt einen Zeiger auf das erste Element der
neu arrangierten Liste zurück:
1179
A Aufgaben und Lösungen
So könnte dann ein Testprogramm, in dem die sechs Funktionen verwendet werden,
aussehen:
void main()
{
struct element *liste;
int summe;
Spoiler
Wie Sie eine Liste auf- und wieder abbauen, wurde im Lehrbuchteil ausführlich
erklärt. Wenn Sie den folgenden Code nicht nachvollziehen können, gehen Sie noch
einmal zurück in Kapitel 14, »Datenstrukturen«.
1180
Kapitel 14
while( e)
{
tmp = e;
e = e->next;
free( tmp);
}
}
Implementieren Sie jetzt die Funktionen zum Ausgeben und Addieren der Listen-
werte. Dazu müssen einfache Iterationen über die Liste durchgeführt werden.
Spoiler
Die Funktion zum Ausgeben aller Listenwerte:
sum += e->wert;
return sum;
}
1181
A Aufgaben und Lösungen
Spoiler
Mit Rekursion ist die Rückwärtsausgabe sehr einfach. Bevor man einen Listenwert
ausgibt, behandelt man rekursiv den Rest der Liste:
Bei der Rückwärtsausgabe wird die Liste nicht umgeordnet. Im letzten Teil der Auf-
gabe erstellen Sie eine Funktion, die die Liste physikalisch umordnet.
Spoiler
Zum Invertieren entnehmen wir Element für Element aus der Liste und ketten es in
eine neue Liste ein. Dabei wird automatisch die Reihenfolge der Elemente umge-
kehrt. Lassen Sie sich bei der Implementierung von Abbildung A.26 inspirieren:
liste
inverse
Spoiler
Wir benötigen einen Hilfszeiger tmp. Dann entnehmen wir das erste Element der
Liste, referenzieren es vorher aber über den Hilfszeiger:
tmp = liste;
liste = liste->next;
1182
Kapitel 14
Danach hängen wir die bisher erzeugte inverse Liste an das ausgekettete Element an
und machen das ausgekettete Element zum ersten Element der inversen Liste:
tmp->next = inverse;
inverse = tmp;
Diesen Prozess des »Umschaufelns« führen wir in einer Schleife so lange durch, bis
die ursprüngliche Liste leer ist und alle Elemente in der invertierten Liste sind. Damit
ergibt sich die folgende Funktion:
Am Ende wird der Zeiger auf das erste Element der neu arrangierten Liste (inverse)
zurückgegeben. Die ursprüngliche Verkettung ist verloren gegangen.
A 14.6 Aufgabe
Erstellen Sie eine doppelt verkettete Liste mit fortlaufend nummerierten Zahlen als
Nutzlast. Erstellen Sie dann eine Ausgabefunktion, mit der man interaktiv durch die
Liste iterieren kann. Die Ausgabefunktion soll dabei durch folgende Kommandos
gesteuert werden:
Lösungen
왘 + – Vorwärtsschritt
왘 - – Rückwärtsschritt
왘 0 – Abbruch
In der Ausgabefunktion wird immer der Wert des aktuell betrachteten Listenele-
ments ausgegeben.
1183
A Aufgaben und Lösungen
Lösung
Beginnen Sie wieder mit der Datenstruktur und den Schnittstellen der benötigen
Funktionen.
Spoiler
Neben einem Zeiger für die Vorwärtsverkettung (next) benötigen wir in der Daten-
struktur jetzt auch einen Zeiger für die Rückwärtsverkettung (prev):
struct element
{
struct element *next;
struct element *prev;
int wert;
};
Wir benötigen Funktionen zum Erzeugen, Ausgeben und Freigeben der Liste. Schnitt-
stellen für diese Aufgaben haben Sie bereits in der letzten Aufgabe erstellt:
Mit diesen Funktionen könnte dann das Hauptprogramm wie folgt realisiert werden:
void main()
{
struct element *liste;
liste_freigeben( liste);
}
Erstellen Sie als Nächstes die Funktionen zum Erzeugen und Freigeben der Liste.
Spoiler
Beim Erzeugen der Liste können Sie vorgehen wie in Aufgabe 14.5. Sie müssen hier
allerdings zusätzlich die Rückwärtsverkettung aufbauen. Das ist aber ganz einfach:
1184
Kapitel 14
Neu gegenüber der Lösung von Aufgabe 14.4 sind die Zeilen:
if( elm)
elm->prev = tmp;
tmp->prev = 0;
In der if-Anweisung wird geprüft, ob schon ein (erstes) Element in der Liste ist. Ist
das der Fall, wird das neue Element zum Vorgänger dieses Elements gemacht. Das
neue Element soll das erste Element der Liste werden und hat daher noch keinen Vor-
gänger. Dies wird durch die Anweisung tmp->prev = 0 realisiert. Wichtig ist, dass die
Liste in beiden Verkettungsrichtungen durch 0-Zeiger terminiert wird.
Die Freigabe der doppelt verketteten Liste ist identisch mit der Freigabe einer einfach
verketteten Liste, da bei der Freigabe nur anhand der Vorwärtsverkettung vorgegan-
gen wird. Die Rückwärtsverkettung ist dabei nicht mehr als eine Nutzlast. Den Code
zur Freigabe finden Sie in Aufgabe 14.5.
Lösungen
Erstellen Sie zum Abschluss noch die Funktion zur interaktiven Ausgabe.
Spoiler
In einer Schleife geben Sie zunächst immer den Wert des aktuell betrachteten Listen-
elements aus. Dann fragen Sie den Benutzer, in welche Richtung er sich bewegen will.
Entsprechend dessen Eingabe bewegen Sie sich in der Liste vorwärts oder rückwärts
– natürlich nur, wenn in der entsprechenden Richtung ein weiteres Element ist. Um
1185
A Aufgaben und Lösungen
das Ende der Liste erkennen zu können, ist es wichtig, dass die Liste in beiden Rich-
tungen terminiert ist.
for( ; ; )
{
printf( "Element %d\n", e->wert);
printf( "- 0 + :");
scanf( "%s", eingabe);
if( *eingabe == '0')
break;
if( (*eingabe == '+') && e->next)
e = e->next;
if( (*eingabe == '-') && e->prev)
e = e->prev;
}
}
Der Benutzer kann zur Steuerung beliebige Zeichenketten eingeben, wobei sich das
Programm immer nur für das erste Zeichen der Eingabe interessiert.
Kapitel 20
A 20.1 Aufgabe
Komplexe Zahlen sind eine für viele mathematische Anwendungen erforderliche
Erweiterung der reellen Zahlen. Eine komplexe Zahl z besteht aus einem Real- und
Imaginärteil, bei dem es sich jeweils um eine reelle Zahl handelt.
Lösungen
Wir notieren sie in der Form y = (Re(z), Im(z)). Reelle Zahlen sind spezielle komplexe
Zahlen, deren Imaginärteil den Wert 0 hat.
Für die Addition und Multiplikation komplexer Zahlen (bzw. komplexer mit reellen
Zahlen) bestehen die folgenden Rechenregeln:
1186
Kapitel 20
Für die Multiplikation einer reellen Zahl mit einer komplexen Zahl z gilt damit insbe-
sondere:
a · z = (a · Re(z), a · Im(z))
Den Betrag einer komplexen Zahl berechnet man mit der Formel
2 2
z = Re ( z ) + Im ( z )
Modellieren Sie den Datentyp der komplexen Zahl als Klasse in C++. Stellen Sie für
alle geläufigen Operatoren mit komplexen Zahlen bzw. mit komplexen und reellen
Zahlen sinnvolle Operatoren zur Verfügung.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv testet!
Lösung
Sie können sich komplexe Zahlen sehr gut mithilfe der gaußschen Zahlenebene ver-
deutlichen. Auf der x-Achse tragen Sie dabei den Realteil ab, auf der y-Achse den Ima-
ginärteil der komplexen Zahl z:
Im(z)
7 (4,7)
5 (6,5)
3 (1,3)
0 1 2 3 4 5 6 7 Re(z)
Lösungen
Die eingezeichneten Punkte entsprechen dann jeweils einer Imaginärzahl. Wir notie-
ren diese in folgender Form:
z=1+3·i
Sie können den Buchstaben i für unsere Zwecke einfach als Einheit für den Imaginär-
teil ansehen.
1187
A Aufgaben und Lösungen
Die uns bekannten reellen Zahlen finden wir in der gaußschen Ebene vollständig auf
der x-Achse.
Alternativ können Sie natürlich auch komplett auf eine Veranschaulichung verzich-
ten und Ihre Klasse gemäß den Anforderungen, also den gestellten Rechenregeln,
implementieren.
Bevor Sie die Implementierung starten, sollten Sie die Klassendeklaration mit den
Attributen und Methoden komplett erstellen. Überlegen Sie, welche Attribute Ihre
Klasse haben muss, welche Konstruktoren, welche Operatoren Sie benötigen und
welche Schnittstellen diese jeweils besitzen müssen.
Spoiler
Eine mögliche Deklaration der Klasse komplex könnte folgendermaßen aussehen:
class komplex
{
friend ostream& operator<<( ostream& os, komplex& Z );
friend float abs(komplex& Z);
private:
float re;
float im;
public:
komplex();
komplex(float x, float y);
1188
Kapitel 20
Spoiler
Der Konstruktor der Klasse muss nicht mehr tun, als die beiden privaten Attribute zu
initialisieren:
Der Operator ist nach der oben angegebenen Rechenvorschrift ebenfalls einfach
implementiert:
Z.re = re + Z2.re;
Z.im = im + Z2.im;
return Z;
}
Damit sollten Sie genug Material haben, um den Rest der Aufgabe allein zu lösen.
Spoiler
In dem Material zum Buch finden Sie die komplette Lösung und ein Beispielpro-
gramm, mit dem sich die Operatoren testen lassen:
1189
A Aufgaben und Lösungen
x = 1 + 3i
y = 2 + 4i
a = 2
Befehl: x+y
x + y = 3 + 7i
Befehl: x*y
x * y = –10 + 10i
Befehl: a*x
a * x = 2 + 6i
Befehl: |x|
|x| = 3.16228
A 20.2 Aufgabe
Entwerfen und implementieren Sie eine erweiterte Klasse datum mit mehreren Ope-
ratoren. Die Klasse soll ein Kalenderdatum, bestehend aus Tag, Monat und Jahr, ver-
walten und an einer von Ihnen festgelegten Schnittstelle die folgende Funktionalität
bieten:
1190
Kapitel 20
Die Klasse soll Schaltjahre und die korrekte Anzahl von Tagen pro Monat berücksich-
tigen.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv testet!
Lösung
Eine eingeschränkte Implementierung für eine Datumsklasse haben Sie in Kapitel
20, »Objektorientierte Programmierung«, ja bereits kennengelernt. Hier geht es nun
darum, eine ähnliche Klasse mit einem erweiterten Funktionsumfang noch einmal
neu zu implementieren.
Bei der Implementierung der geforderten Funktionalität ist es sinnvoll, ein Startda-
tum als eine Art »Nullpunkt« zu wählen. In diesem Fall entscheiden wir uns für den
1.1.1900. Um mit diesem Nullpunkt möglichst einfach arbeiten zu können, ist eine
Funktion sinnvoll, die die vergangenen Tage seit diesem Datum berechnet. Außer-
dem ist die Inverse dieser Funktion sinnvoll, mit der nach einer Angabe von Tagen
seit dem Nullpunkt das Datum berechnet wird. Diese Funktion unterstützt uns dann
bei der Implementierung der Operatoren und auch bei der Ermittlung des Wochen-
tags. Ein Hinweis an dieser Stelle, der 1.1.1900 war ein Montag. Bei der Feststellung der
Feiertage müssen nur die festen Feiertage berücksichtigt werden.
Spoiler
Anhand dieser Hinweise sollten Sie die Klassendeklaration der Datumsklasse erstel-
len können. Meine sieht z. B. so aus:
class datum
{
friend ostream& operator<<(ostream& os, const datum& dat);
private:
Lösungen
int tag;
int mon;
int jahr;
short modus;
1191
A Aufgaben und Lösungen
public:
datum();
datum(int t, int m, int j);
datum(int t, int m, int j, int mod);
int wochentag( );
int feiertag();
};
Dabei sind die folgenden Funktionen zentral für die Implementierung im privaten
Bereich der Klasse angesiedelt, sodass Sie Ihre Umsetzung hier beginnen sollten:
Das Attribut modus wird verwendet, um das Format zu bestimmen, mit dem der Ope-
rator << ein Datum ausgibt. Es kann ein Konstruktor gesetzt und mit der Methode
setModus verändert werden. In meiner Implementierung hat der Modus Werte zwi-
schen 0 und 2.
Versuchen Sie, diese privaten Methoden zuerst umzusetzen, bevor Sie weiterlesen.
Spoiler
1192
Kapitel 20
Wie schon bei den Vorüberlegungen dargestellt, sind die beiden Methoden dat2Tage
und tage2Dat entscheidend für die Implementierung. Meine Implementierung von
dat2Tage sieht dabei so aus:
A j = jahr – 1900;
anz = j*365 + j/4 – j/100 + j/400 + 1;
C anz+=tag;
return anz;
}
Die Tage der vergangenen ganzen Jahre werden unter Berücksichtigung der Schalt-
jahre berechnet (A). Die Tage der vergangenen Monate vom Jahresanfang ab werden
aufaddiert (B). Dazu wird die unten noch gezeigte Funktion tageDesMonats verwen-
det. Schließlich werden nur noch die vergangenen Tage vom Anfang des aktuellen
Monats hinzugezählt.
case 6:
case 9:
case 11:
return 30;
break;
case 2:
if (schaltjahr(jahr))
return 29;
1193
A Aufgaben und Lösungen
return 28;
break;
default:
return 31;
}
}
Mit diesen Funktionen sollten Sie nun die restlichen privaten Funktionen und Kon-
struktoren und Operatoren ergänzen können.
Spoiler
Mit der bereits gezeigten Funktion dat2Tage und der von Ihnen erstellten tage2Dat
kann dann z. B. die Addition von Tagen zu einem Datum als operator+ sehr einfach
erfolgen:
A datum datum::operator+(int t)
{
datum temp;
unsigned int tage;
B tage=dat2Tage() + t;
C temp.tage2Dat(tage);
D temp.setModus(modus);
return temp;
}
Der Operator gibt ein neues Datumsobjekt zurück (A). Dazu wird zuerst ein temporä-
res Objekt angelegt. Danach wird für das Datum, auf das der Operator angewandt
worden ist, die Anzahl der Tage seit dem »Nullpunkt« ermittelt. Die zu addierenden
Tage werden hinzugezählt (B). Damit muss nun nur noch das Datum berechnet wer-
Lösungen
den, das dieser neuen Anzahl von Tagen entspricht (C). Das Ergebnisdatum über-
nimmt dabei den Modus modus des Datums, auf das der Operator angewandt worden
ist (D).
Durch die Hilfsfunktionen können Sie nun einfach mit den Daten »rechnen«.
1194
Kapitel 20
Die vollständige Lösung ist für den Lösungsteil zu umfangreich, aber natürlich in den
Materialien zum Buch enthalten.
Lösungen
A 20.3 Aufgabe
Die Enigma ist eine Verschlüsselungsmaschine, die im 2. Weltkrieg auf deutscher
Seite zur Chiffrierung und Dechiffrierung von Nachrichten und insbesondere zur
Lenkung der U-Boot-Flotte eingesetzt wurde. Äußerlich ähnelt die Enigma einer Kof-
ferschreibmaschine.
Intern besteht sie aus einem Steckbrett, drei Rotoren und einem Reflektor. Die Ver-
schlüsselung wird durch die Verdrahtung dieser drei Grundelemente erreicht.
1195
A Aufgaben und Lösungen
Abbildung A.28 zeigt den schematischen Aufbau der Enigma mit einer konkreten
Verdrahtung der Bauteile:
Anfangsstellung 4 7 11
A 0 0 4 4 7 7 11 11 0
B 1 1 5 5 8 8 12 12 1
C 2 2 6 6 9 9 13 13 2
D 3 3 7 7 10 10 14 14 3
E 4 4 8 8 11 11 15 15 4
F 5 5 9 9 12 12 16 16 5
G 6 6 10 10 13 13 17 17 6
H 7 7 11 11 14 14 18 18 7
I 8 8 12 12 15 15 19 19 8
J 9 9 13 13 16 16 20 20 9
K 10 10 14 14 17 17 21 21 10
L 11 11 15 15 18 18 22 22 11
M 12 12 16 16 19 19 23 23 12
N 13 13 17 17 20 20 24 24 13
O 14 14 18 18 21 21 25 25 14
P 15 15 19 19 22 22 0 0 15
Q 16 16 20 20 23 23 1 1 16
R 17 17 21 21 24 24 2 2 17
S 18 18 22 22 25 25 3 3 18
T 19 19 23 23 0 0 4 4 19
U 20 20 24 24 1 1 5 5 20
V 21 21 25 25 2 2 6 6 21
W 22 22 0 0 3 3 7 7 22
X 23 23 1 1 4 4 8 8 23
Y 24 24 2 2 5 5 9 9 24
Z 25 25 3 3 6 6 10 10 25
Steckbrett Rotor1 Rotor2 Rotor3 Reflektor
zusammengeklebt denken. Das Steckbrett und der Reflektor sind fest, die Rotoren
dagegen drehbar montiert.
Bei einer bestimmten Stellung der Rotoren kann dann ein Buchstabe chiffriert bzw.
dechiffriert werden, indem durch das Drücken des Buchstabens auf der Tastatur ein
Stromkreis durch die Bauteile geschlossen wird. Dieser bringt dann eine Lampe mit
dem Ergebnisbuchstaben zum Aufleuchten.
1196
Kapitel 20
In der oben dargestellten Stellung wird etwa der Buchstabe »A« durch den Buchsta-
ben »B« verschlüsselt. Um dies abzulesen, starten Sie ganz links auf der Buchstaben-
reihe mit einem Buchstaben, hier z. B. dem »A«. Von dort verfolgen Sie die die Linie
über die drei Rotoren, den Reflektor und zurück. Der Buchstabe, bei dem Sie landen,
ist der, auf den der Startbuchstabe verschlüsselt wird. Hier ist es das »B«. Umgekehrt
kann »B« wieder zu »A« entschlüsselt werden. Das ist einsichtig, hier geht es ja nur
auf dem gleichen Weg zurück, man landet also wieder am Ausgangspunkt.
Der Verschlüsselung- und Entschlüsselungsprozess lief nun so ab, dass sich der linke
Rotor nach jedem Drücken eines Buchstabens auf der Tastatur um eine Position wei-
terdrehte. In unserem Beispiel dreht sich der Rotor von 4 auf 5. Dadurch ergibt sich
für den nächsten Buchstaben ein geändertes Codierungsschema. Wenn nun der erste
Rotor von 25 auf 0 vorrückt, dreht auch der zweite Rotor um einen Schritt weiter.
Ebenso verhält es sich mit dem dritten Rotor, der einen Schritt vorrückt, wenn der
zweite Rotor wieder auf 0 springt. Dieses Prinzip kennen Sie vielleicht von mechani-
schen Zählwerken, wie sie früher und auch teilweise noch heute in Autos als Kilome-
terzähler zum Einsatz kommen.
Eine konkrete Verschlüsselung hängt also von der Verdrahtung der Bauteile und der
Anfangsstellung der Rotoren ab. Wie schon beschrieben, ist die Enigma »selbstin-
vers«. Ein verschlüsselter Text kann also mit exakt der gleichen Prozedur, mit der er
verschlüsselt wurde, auch wieder entschlüsselt werden.
Entschlüsseln Sie die folgende Botschaft, die von einer Engima mit der oben genann-
Lösungen
1197
A Aufgaben und Lösungen
Lösung
Zunächst müssen Sie die Struktur der Konfigurationsdateien für die Enigma festle-
gen. Um die Enigma ausgiebig testen zu können und auch schnell zu konfigurieren,
entscheiden wir uns für eine Konfiguration mittels dreier Dateien:
Dabei ist das Format dieser Dateien von entscheidender Bedeutung. Durch die Fest-
legung des Formats nehmen wir bereits zu einem großen Teil vorweg, auf welche Art
und Weise wir die Verschlüsselungsmaschine simulieren werden.
Wir entscheiden uns für die Simulation des Steckbretts, der Rotoren und des Reflek-
tors durch Zahlen-Arrays. Dadurch können wir die Drahtverbindungen der Enigma
direkt nachbilden. Wenn z. B. beim Steckbrett die Zahl 0 mit der 2 verbunden ist, wird
das Array-Element mit dem Index 0 den Wert 2 erhalten, z. B.:
steckbrett[0] = 2.
Damit ist auch einsichtig, dass wir in den Dateien nur die Werte der Array-Elemente
speichern und nicht ihre Indizes. Der erste Wert soll einfach dem 0-ten Element ent-
sprechen, der zweite dem 1-ten etc.
Damit sind die Dateien für das Steckbrett und den Reflektor festgelegt:
2
0
1
3
4
...
6
4
18
12
1
...
1198
Kapitel 20
Im Reflektor soll damit das Element mit dem Index 0 mit dem Element mit dem
Index 6 verbunden werden, 1 mit 4 etc.
Jetzt fehlt uns nur noch das genaue Format für die Rotoren-Datei. Wir einigen uns
darauf, dass in einer Zeile die Werte für alle drei Rotoren stehen:
2 6 3
25 2 4
24 0 25
22 22 0
10 5 1
...
Hier ist also bei Rotor 1 die 0 mit der 2 verbunden, bei Rotor 2 die 0 mit der 6 und bei
Rotor 3 die 0 mit der 2.
Die nächste Frage, die beantwortet werden muss, ist die, wie wir den Durchlauf durch
die Drähte der Enigma nachbilden. Dazu schauen wir uns ein einfaches Beispiel an:
Anfangsstellung 4 7 11
A 0 0 4 4 7 7 11 11 00
B 1 1 5 5 8 8 12 12 11
C 2 2 6 6 9 9 13 13 22
D 3 3 7 7 10 10 14 14 33
E 4 4 8 8 11 11 15 15 44
F 5 5 9 9 12 12 16 16 55
G 6 6 10 10 13 13 17 17 66
H 7 7 11 11 14 14 18 18 77
I 8 8 12 12 15 15 19 19 88
Nun können Sie sich die einzelnen Schritte der Verschlüsselung klarmachen. Um im
Lösungen
ersten Schritt im Steckbrett von »A« zu »C« zu kommen, müssen Sie zu »A« 2 addie-
ren. Im zweiten Schritt gelangen Sie zu »E«, indem Sie wieder 2 addieren, und zwar
mithilfe der Berechnung –6+8.
Wie Sie jetzt sicherlich bereits erkennen, können Sie die Verschlüsselung nachbilden,
indem Sie beim Durchlauf durch die Enigma die vorkommenden Indizes und Werte
abwechselnd subtrahieren und addieren.
neu = 'A' –0+2 –6+8 –11+7 –11+12 –1+4 –15+16 –12+13 –10+4 –0+1
1199
A Aufgaben und Lösungen
neu = 'A' +1
und damit:
neu = 'B'
Spoiler
Ich möchte an dieser Stelle nur die Klassendefinitionen anführen. Die Gesamtfunkti-
onalität der Enigma dürfte nach der Vorüberlegung nun einfach zu implementieren
sein.
Wir wollen bei dieser Aufgabe konsequent objektorientiert vorgehen und zerlegen
deshalb unsere Softwareenigma in einzelne Objekte: Steckbrett, Rotoren und Re-
flektor:
class steckbrett
{
private:
int s[26];
public:
steckbrett( int *steck );
Das Steckbrett stellt Funktionen bereit, um der inneren Verdrahtung folgen zu kön-
Lösungen
class rotor
{
private:
int r[26];
int r_akt;
A rotor *next;
1200
Kapitel 20
public:
rotor( int *rr, rotor *n, int r_a );
C void mov();
};
Ein Rotor hat eine Verbindung zu einem anderen Rotor über den Zeiger next (A).
Damit ist es möglich, mit einem Nachfolger zu kommunizieren, z. B. muss sich der
nächste Rotor nach einer vollen Umdrehung um eine Stelle weiterdrehen.
Über die Funktion diff (B) wird die Differenz zwischen der aktuellen Position des
Rotors und der übergebenen Position bestimmt. Über mov wird der Rotor weiterge-
dreht (C).
class reflektor
{
private:
int r[26];
public:
reflektor( int *ref );
Abschließend werfen wir noch einen Blick auf die Deklaration der Enigma:
class enigma
{
friend ostream& operator<<( ostream& os, enigma& e );
A private:
steckbrett *steckb;
rotor *r1;
1201
A Aufgaben und Lösungen
rotor *r2;
rotor *r3;
reflektor *refl;
B char *text;
public:
enigma( int *steck,
int *r_1, int *r_2, int *r_3,
int r1_a, int r2_a, int r3_a,
int *ref );
~enigma();
Die Enigma besteht aus einem Steckbrett, drei Rotoren und einem Reflektor, die alle
im privaten Bereich der Klasse definiert werden (A). Dies gilt auch für den text, in
dem sich der ver- und entschlüsselte Text befinden wird. Die tatsächliche Arbeit wird
von der Funktion work erledigt werden, die die eigentliche Ent- und Verschlüsselung
vornimmt (C).
Beachten Sie, dass es nur einen Konstruktor und die Arbeitsfunktion gibt. Es sind
keine get-Methoden vorhanden. Aber eine Verschlüsselungsmaschine, die ihre Kon-
figuration in die Welt hinausposaunt, wäre ja auch nicht sinnvoll.
Spoiler
1202
Kapitel 21
Kapitel 21
Mittlerweile sollten Ihre Programmierkenntnisse einen Stand erreicht haben, an
dem Sie sich leicht selbst Aufgaben suchen können, die Sie selbständig lösen und bei
denen Sie am besten wissen, ob Ihre Lösung die richtige ist. Dabei sollten Sie auch zu
weiterführender Literatur greifen, die sich mit den Feinheiten von C++ beschäftigt.
Sie finden hier nur noch eine letzte Aufgabe, um eine Stringklasse in erweiterter
Form selbst zu erstellen.
A 21.1 Aufgabe
Implementieren Sie einen neuen Datentyp string. Der Datentyp soll den Speicher
für die Zeichenkette intern in einem Puffer verwalten und ausschließlich durch
Methoden und Operatoren bedient werden.
Sorgen Sie dabei dafür, dass der interne Zeichenpuffer immer entsprechend den
Anforderungen schrumpft und wächst. Dabei soll der Speicher für den Zeichenpuffer
nur vergrößert werden, wenn es notwendig ist.
Der Benutzer eines Strings sollte in keiner Weise mit dem Allokieren und Beseitigen
von Speicher in Berührung kommen.
Schreiben Sie ein Testprogramm, das alle Funktionen dieser Klasse intensiv testet!
Lösungen
Lösung
Den Datentyp werden wir als Klasse string realisieren. Sie ist der Klasse str in diesem
Kapitel sehr ähnlich. Durch die Anforderung, die Größe des Speichers nur bei Bedarf
anzupassen, hat die Klasse auch ein etwas anderes Innenleben. Da die hier geforderte
Klasse einen höheren Funktionsumfang haben soll als str, bleibt auch genug für Sie
zu entwickeln.
Überlegen Sie zuerst, welche Attribute und Methoden Ihre Klasse haben soll, und
erstellen Sie eine passende Klassendeklaration, bevor Sie weiterlesen.
1203
A Aufgaben und Lösungen
Spoiler
Die Attribute der Klasse sollten im privaten Bereich gespeichert werden. Damit wer-
den sie entsprechend gekapselt. Die Kommunikation nach außen erfolgt nur über
die öffentlichen Konstruktoren, Operatoren und Methoden der Klasse. Die Klasse
verwaltet ein dynamisches Array von char für den zu speichernden Text (text), die
Länge des aktuellen Strings (len) und die Größe des aktuell allokierten Speichers
(alen). Die Anforderung, dass der reservierte Speicher nur angepasst werden soll,
wenn es notwendig ist, erfordert im Gegensatz zur bekannten Klasse str hier dieses
weitere Attribut.
Ihre Klasse sollte auf jeden Fall auch einen Copy-Konstruktor und einen Zuweisungs-
operator aufweisen. Wenn diese noch nicht in Ihrer Deklaration enthalten sind, soll-
ten Sie sie jetzt einfügen, bevor Sie sich meinen Vorschlag für die Deklaration
ansehen.
Spoiler
Meine Deklaration für die Stringklasse string sieht folgendermaßen aus:
class string
{
private:
int len;
int alen;
char *text;
public:
~string();
1204
Kapitel 21
Implementieren Sie am besten zuerst die Konstruktoren (A) und (B), den operator+
(C) sowie den Zuweisungsoperator operator= (D). Die Funktionen compareCs und com-
pareCis habe ich für den Vergleich mit Unterscheidung von Groß- und Kleinschrei-
bung (case sensitive) und ohne Unterscheidung (case insensitive) vorgesehen. Für
einen der Fälle (das ist typischerweise der erste) können Sie natürlich auch den
operator== überladen.
Spoiler
Die Implementierung des Konstruktors überspringe ich hier und starte gleich mit
dem Copy-Konstruktor, den wir uns näher ansehen wollen:
Im Copy-Konstruktor müssen wir die Werte aus dem kopierten Objekt übertragen.
Für das neue Objekt ignorieren wir die allokierte Speichermenge in der Vorlage. Der
dort allokierte Speicher kann ja größer sein als der tatsächlich benötigte. Wir allokie-
Lösungen
ren im neuen Objekt nur so viel Speicher, wie wir jetzt benötigen, und kopieren dafür
vorab den len-Wert (A, B). Im Anschluss allokieren wir den Speicher für text (C) und
kopieren die Zeichenkette aus dem anderen Objekt in diesen Speicher (D).
Der operator= muss dafür sorgen, dass der zugewiesene String korrekt in einen
bereits bestehenden String kopiert wird. Aufgrund der Anforderung aus der Aufga-
benstellung bezüglich der Speicherverwendung müssen wir nun etwas genauer
unterscheiden als in dem Beispiel der Klasse str:
1205
A Aufgaben und Lösungen
E return *this;
}
Wir prüfen daher zuerst, ob der zugewiesene Inhalt die Größe überschreitet, die im
Objekt bereits allokiert ist (A). Falls das der Fall ist, wird der Speicher in der geforder-
ten Größe reallokiert (B). Danach werden die Attribute len und alen angepasst, und
der Text wird kopiert.
Als Rückgabewert hat der Zuweisungsoperator eine Referenz auf string. In (E) gibt
sich die gerade aktive Klasse selbst zurück, damit die Verkettung von Zuweisungen
möglich ist (a=b=c).
{
B string neu( text );
1206
Kapitel 21
neu.len += t.len;
neu.alen = neu.len + t.len;
}
else
{
E strcat( neu.text, t.text );
neu.len += t.len;
}
F return neu;
}
Als Übergabeparameter erhält der Operator eine konstante Referenz auf den überge-
benen String. Der operator+ gibt ein neues Objekt als Ergebnis zurück. Innerhalb des
Operators wird daher zuerst ein neues Objekt mit dem Inhalt des aktuellen Strings
angelegt. Dazu kommt bereits der Copy-Konstruktor zum Einsatz (B). Nach Berech-
nung der neuen Gesamtlänge des zusammengesetzten Strings muss gegebenenfalls
Speicher nachallokiert werden (C), bevor die beiden Zeichenketten zusammengefügt
werden (D). Wenn der Speicher ausreicht, werden die beiden Teilstrings ohne diesen
Umweg zusammengefügt (E). Abschließend wird der temporäre String als Ergebnis
des Operators zurückgegeben (F). Beachten Sie, dass auch hier wieder der Copy-Kon-
struktor zum Einsatz kommt.
Auf dieser Basis sollten Sie die restlichen Funktionen jetzt implementieren können.
Spoiler
Der gesamte Quellcode zu dieser Aufgabe ist wieder recht umfangreich. Sie finden
ihn in den Materialien zum Buch. Dazu gehört auch ein Testprogramm:
1207
A Aufgaben und Lösungen
Command: w 0 C/
Command: w 1 C++
Command: a 0 1
Command: s 0
Command:
Bevor Sie sich mit Ihrem eigenen Programm zufriedengeben, seien Sie aber sicher,
dass Sie wirklich umfangreich getestet haben. Gehen Sie dabei nicht nur die nahelie-
genden Fälle durch, sondern überlegen Sie auch, was ein Benutzer der Klasse alles tun
könnte!
string a( "AAA" );
string b( "BBB" );
string leer( "" );
a.insert( b, 0 );
a.insert( a, 0 );
a.insert( a, 1 );
a.searchFor( leer );
und betrachten Sie besonders die Randfälle, also Operationen an Position 0, an den
Positionen len und len+1 oder mit leeren Strings. Wenn Sie dabei auf Fehler stoßen
und in Ihrer Klasse Bibliotheksfunktionen verwendet haben, ist das eine gute Gele-
genheit, deren Dokumentation zu prüfen. Viele Funktionen der Laufzeitbibliothek
für Zeichenketten haben ein Problem, wenn die Speicherbereiche der beteiligten Zei-
chenketten sich überlappen. Der Benutzer Ihrer Klasse string kennt deren Innenle-
ben natürlich nicht. Zur Aufgabe einer vollständigen Klasse würde es damit auch
gehören, solche Fälle für den Benutzer abzufangen.
Lösungen
1208
Index
Index
!=-Operator ........................................................ 56, 666 ?:-Operator (bedingte Auswertung) ............... 592
!-Operator ............................................... 119, 633, 754 []-Operator ................................................................ 672
# define ...................................................................... 601 ^=-Operator.............................................................. 672
# elif............................................................................. 601 ^-Operator ....................................................... 147, 594
# else ........................................................................... 601 __cplusplus .............................................................. 701
# endif......................................................................... 601 __DATE__.................................................................. 661
# error ......................................................................... 602 __FILE__ .................................................................... 661
# if................................................................................. 601 __LINE__ ................................................................... 661
# ifdef.......................................................................... 601 __STDC__ .................................................................. 661
# ifndef ....................................................................... 601 __TIME__ .................................................................. 661
# include .................................................................... 631 |=-Operator ............................................................... 672
# line............................................................................ 602 ||-Operator ....................................................... 119, 633
# pragma.................................................................... 602 |-Operator......................................................... 147, 594
# undef ....................................................................... 601 ~..................................................................................... 739
%=-Operator ...................................................... 51, 672 ~-Operator ....................................................... 147, 594
%-Operator.................................................. 50, 53, 579
&&-Operator .................................................. 119, 633 A
&=-Operator ............................................................. 672
&-Operator (Adress-Operator)............... 224, 403, Ableiten einer Klasse ............................................ 809
575, 672, 755 Absolutbetrag.......................................................... 254
&-Operator (Bit-Operator) .................................. 147 Abstrakte Klasse ..................................................... 830
&-Operator (bitweises Und)............................... 594 Abstrakter Datentyp ............................................. 493
&-Operator (Referenz).......................................... 685 Destruktor ............................................................ 495
*=-Operator ........................................................ 51, 672 Konstruktor ......................................................... 494
*-Operator..................................................................... 50 Queue ..................................................................... 500
-*-Operator ................................................................ 702 Stack ....................................................................... 495
*-Operator (Dereferenzierung) ...... 225, 403, 672 Abweichung ............................................................. 860
*-Operator (Multiplikation)................................ 579 Addition........................................................................ 50
++-Operator ....................................................... 52, 672 Adjazenzmatrix ...................................................... 511
+=-Operator ....................................................... 51, 672 Adressbus.................................................................. 137
+-Operator................................................ 50, 579, 707 Adresse .................................................... 137, 223, 575
,-Operator .................................................................. 649 Rechnen mit Adressen ..................................... 576
.-Operator ............................................... 401, 672, 727 Adress-Operator (&)........................... 224, 403, 575
/=-Operator........................................................ 51, 672 algorithm ................................................................ 1033
/-Operator .......................................................... 50, 579 Algorithmen
<<=-Operator............................................................ 672 Standardbibliothek ........................................ 1033
<<-Operator ........................................... 148, 594, 748 Algorithmus......................................................... 22, 24
<=-Operator ....................................................... 56, 666 Algorithmus von Dijkstra................................... 539
<-Operator.......................................................... 56, 666 Algorithmus von Floyd ....................................... 533
==-Operator ....................................................... 56, 666 Algorithmus von Ford ......................................... 548
-=-Operator ........................................................ 51, 672 Algorithmus von Kruskal ................................... 552
=-Operator.......................................................... 48, 672 Algorithmus von Warshall................................. 518
>=-Operator ....................................................... 56, 666 Alignment................................................................. 577
>>=-Operator............................................................ 672 and ............................................................................... 706
>>-Operator ........................................... 148, 594, 750 and_eq........................................................................ 706
->-Operator ............................................ 404, 672, 702 Anweisung
>-Operator.......................................................... 56, 666 break....................................................... 64, 598, 618
1209
Index
Anweisung (Forts.) B
case.......................................................................... 598
continue ......................................................... 64, 618 Basisklasse
do ... while ............................................................. 613 virtuell.................................................................... 941
else .................................................................... 57, 615 Zugriff .................................................................... 824
extern ..................................................................... 700 Basisklasse initialisieren ..................................... 941
for...................................................................... 61, 618 Baum .................................................................. 448, 523
goto ......................................................................... 626 aufsteigend sortierteter Baum..................... 461
if................................................................................ 630 Blatt ........................................................................ 448
return............................................................ 184, 647 Breitensuche........................................................ 451
switch ..................................................................... 657 Inorder-Traversierung..................................... 454
using ....................................................................... 714 Knoten ................................................................... 448
while........................................................................ 668 Level........................................................................ 450
Äquivalenzoperator .............................................. 113 Levelorder-Traversierung............................... 459
Äquivalenzrelation................................................ 525 Postorder-Traversierung ................................ 455
Reflexivität ........................................................... 525 Preorder-Traversierung .................................. 452
Symmetrie ............................................................ 525 Teilbaum............................................................... 450
Transitivität......................................................... 525 Tiefe ........................................................................ 450
Arithmetik.................................................................... 83 Tiefensuche .......................................................... 451
Arithmetische Operatoren................................. 579 Traversierung...................................................... 451
Arithmetischer Operator ....................................... 50 Wurzel.................................................................... 448
Array ......................................................... 159, 232, 581 Bedingte Auswertung .......................................... 592
als Funktionsparameter ................................. 186 Bedingte Befehlsausführung ............................... 57
Arrays als Funktionsparameter................... 584 Bedingte Kompilierung....................................... 247
Arrays und Zeiger .................................... 232, 585 begin............................................................................ 975
dynamisch .................................................. 745, 990 Beispiel
eindimensionales Array........................ 160, 581 Addierwerk........................................................... 154
Index .................................................... 160, 163, 583 ASCII-Zeichensatz ............................................. 158
Initialisierung...................................................... 161 Bingo ...................................................................... 797
mehrdimensionales Array ................... 162, 581 Bruchrechnung................................................... 200
Array von Objekten ..................................... 924, 936 Buchstabenstatistik ......................................... 173
Arrays .......................................................................... 744 Container als Baum ......................................... 465
ASCII-Code....................................................... 156, 588 Container als Hash-Tabelle........................... 483
Attribut....................................................................... 881 Container als Liste ............................................ 441
Attribute .................................................................... 721 Container als Treap ................................. 473, 477
Aufsteigend sortierteter Baum......................... 461 Damenproblem.................................................. 202
Aufzählungstyp....................................................... 615 Division ganzer Zahlen ...................................... 73
Ausgabe ..................................................... 67, 260, 588 Galgenmännchen.............................................. 168
Formatanweisung............................................. 588 Geldautomat....................................................... 298
printf ....................................................................... 588 ggT berechnen .................................................... 200
Ausnahmefallbehandlung.................................. 963 Heron-Verfahren .................................................. 92
Aussage....................................................................... 108 Juwelenraub ........................................................ 293
Aussagenlogik.......................................................... 107 Kartentrick........................................................... 150
auto .............................................................................. 592 Kugelspiel ............................................. 75, 117, 120
Automatisch erstellter Konstruktor............... 788 Labyrith................................................................. 213
Automatisch erstellter Zuweisungso- Länge einer Zeichenkette ............................... 166
perator ................................................................... 791 Lostrommel ......................................................... 798
Automatische Instanziierung ........................... 741 Menge ........................................................ 756, 1025
Automatische Typisierung....................... 679, 680 Palindromerkennung ...................................... 167
Automatische Variable ........................................ 190 Permutationen ................................................... 210
Automatisches Objekt.......................................... 922 Schaltung.............................................................. 122
1210
Index
1211
Index
1212
Index
1213
Index
1214
Index
Laufzeitklassen........................................................ 324
N
Laufzeitkomplexität................................................. 44
Laufzeitprofil............................................................... 43 Namenskonflikte ................................................... 711
Laufzeitprofile ......................................................... 323 Namensraum........................................................... 711
Layout ............................................................................ 72 erstellen................................................................. 712
Lebenszyklus............................................................ 922 importieren.......................................................... 714
Leistungsanalyse .......................................... 305, 308 std ............................................................................ 715
Leistungsmessung ....................................... 305, 320 namespace................................................................ 712
Levelorder-Traversierung ................................... 459 Negationsoperator ................................................ 754
Linefeed...................................................................... 157 new...................................................................... 743, 923
Linker ............................................................ 22, 43, 699 new-Operator ................................................. 702, 743
list ................................................................................. 998 Nicht-Operator........................................................ 110
Liste........................................................... 417, 439, 998 not ................................................................................ 706
doppelt verkettete Liste................................... 439 not_eq ........................................................................ 706
Listenanfang............................................................. 439 NP-Vollständig ........................................................ 565
Listenende................................................................. 439 NULL............................................................................ 418
logischer Operator ....................................... 108, 633 Null-Zeiger ................................................................ 439
lokale Variable ......................................................... 190 Numerische Verfahren ........................................... 96
long .................................................................... 140, 606
long double..................................................... 144, 607 O
L-Value ..................................................... 230, 685, 689
Objectcode ................................................................... 42
Objectfile ...................................................................... 42
M
Objekt ......................................................................... 721
main................................................... 46, 186, 635, 691 Array....................................................................... 924
Aufrufparameter ............................................... 635 automatisch ........................................................ 922
Makro ................................................................ 245, 636 beseitigen ............................................................. 928
malloc....................................................... 265, 415, 651 destruieren........................................................... 928
Mantisse..................................................................... 145 dynamisch............................................................ 923
map ............................................................................ 1030 eingelagert ........................................................... 939
Mathematische Funktionen .............................. 254 instanziieren .............................................. 934, 943
Mehrfachvererbung .................................... 807, 822 konstruieren ........................................................ 925
Member kopieren ................................................................ 929
konstante .............................................................. 885 Lebenszyklus ....................................................... 922
statisch......................................................... 826, 893 statisch .................................................................. 922
statische ................................................................ 887 Objektorientierte Programmierung .............. 717
überschreiben...................................................... 901 Objektorientiertes Design.................................. 720
Zugriff..................................................................... 891 Objektorientierung ...................................... 717, 797
1215
Index
1216
Index
Parametersignatur................................................. 698 R
Partnervermittlung............................................... 855
Performance-Analyse........................................... 323 rand ............................................................................. 255
Permutationen range ........................................................................... 968
mit Wiederholungen .............................. 274, 284 range_error .............................................................. 966
ohne Wiederholungen ........................... 275, 290 rbegin.......................................................................... 975
Pointer ........................................................................ 225 Realisierung ................................................................ 35
Postfixnotation.......................................................... 52 realloc ................................................................ 265, 651
Postorder-Traversierung..................................... 455 Referenz ..................................................................... 684
Potenzfunktion....................................................... 254 konstante.............................................................. 688
Prädikat .................................................................... 1010 Rückgabe .............................................................. 689
Präfixnotation ............................................................ 52 register ....................................................................... 645
Präprozessor................................................... 241, 644 Rein virtuelle Funktion .............................. 829, 915
bedingte Kompilierung ................................... 247 reinterpret_cast...................................................... 949
Compile-Schalter ............................................... 247 Rekursion ......................................................... 192, 646
Makro ..................................................................... 245 Relation.................................................................... 1030
symbolische Konstante ................................... 244 rend ............................................................................. 975
Preorder-Traversierung ....................................... 452 Rest bei Division........................................................ 50
printf..................................................................... 67, 260 return................................................................. 184, 647
Prioritätswarteschlange .......................... 471, 1019 Review ........................................................................... 37
private............................................ 726, 731, 880, 917 rewind......................................................................... 606
Private Vererbung.................................................. 921 Ringpuffer................................................................. 502
Produkte ....................................................................... 96 runtime_error......................................................... 966
Profiler ........................................................................... 43 R-Value ....................................................................... 230
Programm ............................................................. 22, 30
Programmcode .......................................................... 46 S
Programmgrobstruktur....................................... 241
Programmierparadigma ........................................ 32 scanf ............................................................ 69, 260, 614
Programmiersprache................................ 22, 30, 31 Formatanweisung............................................. 614
Programmierumgebung ........................................ 40 Schleife ................................................................ 59, 618
Programmrahmen.................................................... 45 break.......................................................................... 64
Programmschleife .................................................... 59 continue ................................................................... 64
Projekt Initialisierung ........................................................ 60
Header-Datei ....................................................... 249 Inkrement................................................................ 60
Quellcodedatei.................................................... 249 Körper ....................................................................... 60
Projektplan................................................................... 36 Test............................................................................. 60
protected....................................... 726, 812, 880, 917 Schlüsselwörter ............................................. 648, 678
Prozessor ................................................................... 137 catch ....................................................................... 963
public................................................................. 880, 917 class ............................................................... 725, 879
Punkt-Operator....................................................... 727 const ....................................................................... 885
const_cast ............................................................ 950
friend ...................................................................... 899
Q namespace ........................................................... 712
Quadratwurzel......................................................... 254 private........................................................... 880, 917
Qualitätssicherung ................................................... 38 protected...................................................... 880, 917
Quellcodedatei ................................................. 42, 645 public............................................................. 880, 917
Queue.................................................... 458, 500, 1017 reinterpret_cast ................................................. 949
Quicksort (nicht rekursiv)................................... 366 static........................................... 743, 827, 887, 922
Quicksort (rekursiv) .............................................. 359 static_cast............................................................ 949
template................................................................ 955
throw ...................................................................... 963
1217
Index
1218
Index
1219
Index
1220