Sie sind auf Seite 1von 1221

Ulrich Kaiser, Martin Guddat

C/C++
Das umfassende Handbuch
An den Leser

Liebe Leserin, lieber Leser,


wir freuen uns, Ihnen die fünfte Auflage dieses Lehrwerkes zu C und C++ vorzustel-
len. Entstanden aus einem Kurs von Prof. Dr. Kaiser über Grundlagen der Informatik
und Programmiersprachen, ist dieses Buch seinem Anliegen immer treu geblieben:
Es lehrt Programmieren auf professionellem Niveau, ohne konkrete Kenntnisse vor-
auszusetzen. Die Syntax der Sprachen ist dabei ein notwendiges Hilfsmittel, das Pro-
grammieren selbst darf vielleicht als Kunst verstanden werden; in jedem Fall aber als
eine Praxis, für die Talent, Neugierde und ein Verständnis der Grundlagen der Infor-
matik von Bedeutung sind. Letzteres erarbeiten Sie sich mit diesem Buch, das Theo-
rie und Praxis lebendig verbindet.

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.

Alles wird anhand anschaulicher Beispiele erläutert – wo es um die Genauigkeit


einer mathematischen Abschätzung geht, denken Sie etwa an den prüfenden Blick
ins Portemonnaie, ob Ihr Bargeld für ein Brötchen reicht. Im Laufe der Zeit konnten
viele Leserwünsche und Lehrerfahrungen einfließen – so haben die Behandlung der
Standardbibliotheken, der Abbau bestimmter Hürden bei mathematischen Inhalten
und die ausführlichen, vollständigen Musterlösungen das Buch verbessert. Neu in
dieser Auflage: Falls Sie einmal nicht weiterkommen, schauen Sie erst nach Lösungs-
hinweisen, bevor Sie sich die vollständige Lösung ansehen. Die Codebeispiele und
Lösungen finden Sie außerdem zum Download bei den Materialien zum Buch unter
http://www.galileo-press.de/3536.

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!

Ihre Almut Poll


Lektorat Galileo Computing

almut.poll@galileo-press.de
www.galileocomputing.de
Galileo Press · Rheinwerkallee 4 · 53227 Bonn
Auf einen Blick

Auf einen Blick

1 Einige Grundbegriffe ............................................................................................ 21

2 Einführung in die Programmierung ................................................................ 35

3 Ausgewählte Sprachelemente von C .............................................................. 45

4 Arithmetik ................................................................................................................ 83

5 Aussagenlogik ......................................................................................................... 107

6 Elementare Datentypen und ihre Darstellung ............................................ 129

7 Modularisierung ..................................................................................................... 181

8 Zeiger und Adressen ............................................................................................. 223

9 Programmgrobstruktur ....................................................................................... 241

10 Die Standard C Library .......................................................................................... 253

11 Kombinatorik .......................................................................................................... 273

12 Leistungsanalyse und Leistungsmessung ..................................................... 305

13 Sortieren ................................................................................................................... 347

14 Datenstrukturen .................................................................................................... 393

15 Ausgewählte Datenstrukturen ......................................................................... 437

16 Abstrakte Datentypen .......................................................................................... 493

17 Elemente der Graphentheorie ........................................................................... 507

18 Zusammenfassung und Ergänzung ................................................................ 575

19 Einführung in C++ .................................................................................................. 677

20 Objektorientierte Programmierung ................................................................ 717

21 Das Zusammenspiel von Objekten .................................................................. 775

22 Vererbung ................................................................................................................. 805

23 Zusammenfassung und Überblick ................................................................... 879

24 Die C++-Standardbibliothek und Ergänzung ............................................... 953

A Aufgaben und Lösungen ..................................................................................... 1041


Impressum

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.

An diesem Buch haben viele mitgewirkt, insbesondere:

Lektorat Almut Poll, Erik Lipperts


Korrektorat Friederike Daenecke
Herstellung Martin Pätzold
Einbandgestaltung Janina Conrady
Typografie und Layout Vera Brauner
Satz Typographie & Computer, Krefeld
Druck und Bindung C. H. Beck, Nördlingen

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.

Bibliografische Information der Deutschen Nationalbibliothek


Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen National-
bibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar.

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.

Die in diesem Werk wiedergegebenen Gebrauchsnamen, Handelsnamen, Warenbezeichnungen


usw. können auch ohne besondere Kennzeichnung Marken sein und als solche den gesetzlichen
Bestimmungen unterliegen.
Inhalt

Inhalt
Vorwort .................................................................................................................................................. 19

1 Einige Grundbegriffe 21

1.1 Algorithmus ........................................................................................................... 24


1.2 Datenstruktur ........................................................................................................ 28
1.3 Programm ............................................................................................................... 30
1.4 Programmiersprachen ......................................................................................... 31
1.5 Aufgaben ................................................................................................................ 33

2 Einführung in die Programmierung 35

2.1 Softwareentwicklung .......................................................................................... 35


2.2 Die Programmierumgebung ............................................................................... 40
2.2.1 Der Editor ................................................................................................. 41
2.2.2 Der Compiler ............................................................................................ 42
2.2.3 Der Linker ................................................................................................. 43
2.2.4 Der Debugger ........................................................................................... 43
2.2.5 Der Profiler ............................................................................................... 43

3 Ausgewählte Sprachelemente von C 45

3.1 Programmrahmen ................................................................................................ 45


3.2 Zahlen ..................................................................................................................... 46
3.3 Variablen ................................................................................................................ 46
3.4 Operatoren ............................................................................................................. 48
3.4.1 Zuweisungsoperator ............................................................................... 48
3.4.2 Arithmetische Operatoren ..................................................................... 49
3.4.3 Typkonvertierungen ................................................................................ 55
3.4.4 Vergleichsoperationen ............................................................................ 55

5
Inhalt

3.5 Kontrollfluss .......................................................................................................... 56


3.5.1 Bedingte Befehlsausführung ................................................................. 57
3.5.2 Wiederholte Befehlsausführung ........................................................... 59
3.5.3 Verschachtelung von Kontrollstrukturen ............................................. 65
3.6 Elementare Ein- und Ausgabe ............................................................................ 67
3.6.1 Bildschirmausgabe .................................................................................. 67
3.6.2 Tastatureingabe ...................................................................................... 69
3.6.3 Kommentare und Layout ........................................................................ 72
3.7 Beispiele .................................................................................................................. 73
3.7.1 Das erste Programm ............................................................................... 73
3.7.2 Das zweite Programm ............................................................................. 75
3.7.3 Das dritte Programm .............................................................................. 79
3.8 Aufgaben ................................................................................................................ 81

4 Arithmetik 83

4.1 Folgen ...................................................................................................................... 85


4.2 Summen und Produkte ........................................................................................ 96
4.3 Aufgaben ................................................................................................................ 100

5 Aussagenlogik 107

5.1 Aussagen ................................................................................................................ 108


5.2 Aussagenlogische Operatoren ........................................................................... 108
5.3 Boolesche Funktionen ......................................................................................... 116
5.4 Logische Operatoren in C .................................................................................... 119
5.5 Beispiele ..................................................................................................................
120
5.5.1 Kugelspiel ................................................................................................. 120
5.5.2 Schaltung ................................................................................................. 122
5.6 Aufgaben ................................................................................................................ 126

6
Inhalt

6 Elementare Datentypen und ihre Darstellung 129

6.1 Zahlendarstellungen ............................................................................................ 130


6.1.1 Dualdarstellung ....................................................................................... 134
6.1.2 Oktaldarstellung ...................................................................................... 135
6.1.3 Hexadezimaldarstellung ........................................................................ 136
6.2 Bits und Bytes ........................................................................................................ 137
6.3 Skalare Datentypen in C ...................................................................................... 139
6.3.1 Ganze Zahlen ........................................................................................... 140
6.3.2 Gleitkommazahlen .................................................................................. 144
6.4 Bitoperationen ...................................................................................................... 146
6.5 Programmierbeispiele ......................................................................................... 150
6.5.1 Kartentrick ................................................................................................ 150
6.5.2 Zahlenraten .............................................................................................. 152
6.5.3 Addierwerk ............................................................................................... 154
6.6 Zeichen .................................................................................................................... 156
6.7 Arrays ......................................................................................................................
159
6.7.1 Eindimensionale Arrays .......................................................................... 160
6.7.2 Mehrdimensionale Arrays ...................................................................... 162
6.8 Zeichenketten ....................................................................................................... 164
6.9 Programmierbeispiele ......................................................................................... 173
6.9.1 Buchstabenstatistik ................................................................................ 173
6.9.2 Sudoku ...................................................................................................... 175
6.10 Aufgaben ................................................................................................................ 178

7 Modularisierung 181

7.1 Funktionen ............................................................................................................. 181


7.2 Arrays als Funktionsparameter ......................................................................... 186
7.3 Lokale und globale Variablen ............................................................................. 190
7.4 Rekursion ................................................................................................................ 192
7.5 Der Stack ................................................................................................................. 198
7.6 Beispiele .................................................................................................................. 200
7.6.1 Bruchrechnung ........................................................................................ 200
7.6.2 Das Damenproblem ................................................................................ 202

7
Inhalt

7.6.3 Permutationen ......................................................................................... 210


7.6.4 Labyrinth .................................................................................................. 213
7.7 Aufgaben ................................................................................................................ 218

8 Zeiger und Adressen 223

8.1 Zeigerarithmetik ................................................................................................... 230


8.2 Zeiger und Arrays .................................................................................................. 232
8.3 Funktionszeiger ..................................................................................................... 235
8.4 Aufgaben ................................................................................................................ 239

9 Programmgrobstruktur 241

9.1 Der Präprozessor ................................................................................................... 241


9.1.1 Includes .................................................................................................... 242
9.1.2 Symbolische Konstanten ........................................................................ 244
9.1.3 Makros ...................................................................................................... 245
9.1.4 Bedingte Kompilierung ........................................................................... 247
9.2 Ein kleines Projekt ................................................................................................ 249

10 Die Standard C Library 253

10.1 Mathematische Funktionen ............................................................................... 254


10.2 Zeichenklassifizierung und -konvertierung .................................................... 256
10.3 Stringoperationen ................................................................................................ 257
10.4 Ein- und Ausgabe .................................................................................................. 260
10.5 Variable Anzahl von Argumenten ..................................................................... 263
10.6 Freispeicherverwaltung ....................................................................................... 265
10.7 Aufgaben ................................................................................................................ 271

8
Inhalt

11 Kombinatorik 273

11.1 Kombinatorische Grundaufgaben .................................................................... 274


11.2 Permutationen mit Wiederholungen ............................................................... 274
11.3 Permutationen ohne Wiederholungen ............................................................ 275
11.3.1 Kombinationen ohne Wiederholungen ................................................ 277
11.3.2 Kombinationen mit Wiederholungen ................................................... 278
11.3.3 Zusammenfassung ................................................................................. 280
11.4 Kombinatorische Algorithmen ..........................................................................
283
11.4.1 Permutationen mit Wiederholungen ...................................................284
11.4.2 Kombinationen mit Wiederholungen ...................................................286
11.4.3 Kombinationen ohne Wiederholungen ................................................ 288
11.4.4 Permutationen ohne Wiederholungen ................................................. 290
11.5 Beispiele .................................................................................................................. 293
11.5.1 Juwelenraub ............................................................................................. 293
11.5.2 Geldautomat ............................................................................................ 298

12 Leistungsanalyse und Leistungsmessung 305

12.1 Leistungsanalyse ................................................................................................... 308


12.2 Leistungsmessung ................................................................................................ 320
12.2.1 Überdeckungsanalyse ............................................................................. 322
12.2.2 Performance-Analyse .............................................................................. 323
12.3 Laufzeitklassen ...................................................................................................... 324

13 Sortieren 347

13.1 Sortierverfahren .................................................................................................... 347


13.1.1 Bubblesort ................................................................................................ 349
13.1.2 Selectionsort ............................................................................................ 351
13.1.3 Insertionsort ............................................................................................. 353
13.1.4 Shellsort .................................................................................................... 356
13.1.5 Quicksort .................................................................................................. 359
13.1.6 Heapsort ................................................................................................... 370

9
Inhalt

13.2 Leistungsanalyse der Sortierverfahren ............................................................ 376


13.2.1 Bubblesort ................................................................................................ 376
13.2.2 Selectionsort ............................................................................................ 377
13.2.3 Insertionsort ............................................................................................. 378
13.2.4 Shellsort .................................................................................................... 379
13.2.5 Quicksort .................................................................................................. 380
13.2.6 Heapsort ................................................................................................... 381
13.3 Leistungsmessung der Sortierverfahren .......................................................... 383
13.4 Grenzen der Optimierung von Sortierverfahren ............................................ 388

14 Datenstrukturen 393

14.1 Strukturdeklarationen ......................................................................................... 395


14.1.1 Variablendefinitionen ............................................................................. 398
14.2 Zugriff auf Strukturen ......................................................................................... 400
14.2.1 Direktzugriff ............................................................................................. 401
14.2.2 Indirektzugriff .......................................................................................... 403
14.3 Datenstrukturen und Funktionen ..................................................................... 405
14.4 Ein vollständiges Beispiel (Teil 1) ....................................................................... 409
14.5 Dynamische Datenstrukturen ............................................................................ 415
14.6 Ein vollständiges Beispiel (Teil 2) ...................................................................... 421
14.7 Die Freispeicherverwaltung ............................................................................... 432
14.8 Aufgaben ................................................................................................................ 435

15 Ausgewählte Datenstrukturen 437

15.1 Listen ....................................................................................................................... 439


15.2 Bäume ..................................................................................................................... 448
15.2.1 Traversierung von Bäumen .................................................................... 451
15.2.2 Aufsteigend sortierte Bäume ................................................................. 461
15.3 Treaps ......................................................................................................................470
15.3.1 Heaps ........................................................................................................ 471
15.3.2 Der Container als Treap .......................................................................... 473

10
Inhalt

15.4 Hash-Tabellen ........................................................................................................


482
15.4.1 Speicherkomplexität ............................................................................... 489
15.4.2 Laufzeitkomplexität ................................................................................ 490

16 Abstrakte Datentypen 493

16.1 Der Stack als abstrakter Datentyp .................................................................... 495


16.2 Die Queue als abstrakter Datentyp .................................................................. 500

17 Elemente der Graphentheorie 507

17.1 Graphentheoretische Grundbegriffe ................................................................ 510


17.2 Die Adjazenzmatrix .............................................................................................. 511
17.3 Beispielgraph (Autobahnnetz) ........................................................................... 512
17.4 Traversierung von Graphen ................................................................................ 514
17.5 Wege in Graphen .................................................................................................. 516
17.6 Der Algorithmus von Warshall .......................................................................... 518
17.7 Kantentabellen ..................................................................................................... 522
17.8 Zusammenhang und Zusammenhangskomponenten ................................. 523
17.9 Gewichtete Graphen ............................................................................................ 530
17.10 Kürzeste Wege ...................................................................................................... 532
17.11 Der Algorithmus von Floyd ................................................................................. 533
17.12 Der Algorithmus von Dijkstra ............................................................................ 539
17.13 Erzeugung von Kantentabellen ......................................................................... 546
17.14 Der Algorithmus von Ford ................................................................................... 548
17.15 Minimale Spannbäume ....................................................................................... 551
17.16 Der Algorithmus von Kruskal ............................................................................. 552
17.17 Hamiltonsche Wege ............................................................................................. 557
17.18 Das Travelling-Salesman-Problem .................................................................... 562

11
Inhalt

18 Zusammenfassung und Ergänzung 575

19 Einführung in C++ 677

19.1 Schlüsselwörter ..................................................................................................... 677


19.2 Kommentare .......................................................................................................... 678
19.3 Datentypen, Datenstrukturen und Variablen ................................................ 679
19.3.1 Automatische Typisierung von Aufzählungstypen ............................. 679
19.3.2 Automatische Typisierung von Strukturen .......................................... 680
19.3.3 Vorwärtsverweise auf Strukturen ......................................................... 680
19.3.4 Der Datentyp bool ................................................................................... 681
19.3.5 Verwendung von Konstanten ................................................................ 682
19.3.6 Definition von Variablen ........................................................................ 683
19.3.7 Verwendung von Referenzen ................................................................. 684
19.3.8 Referenzen als Rückgabewerte .............................................................. 688
19.3.9 Referenzen außerhalb von Schnittstellen ............................................ 689
19.4 Funktionen .............................................................................................................
690
19.4.1 Funktionsdeklarationen und Prototypen ............................................. 691
19.4.2 Vorgegebene Werte in der Funktionsschnittstelle
(Default-Werte) ....................................................................................... 692
19.4.3 Inline-Funktionen .................................................................................... 694
19.4.4 Überladen von Funktionen ..................................................................... 696
19.4.5 Parametersignatur von Funktionen ...................................................... 698
19.4.6 Zuordnung der Parametersignaturen und der passenden
Funktion ................................................................................................... 699
19.4.7 Verwendung von C-Funktionen in C++-Programmen ......................... 700
19.5 Operatoren .............................................................................................................
701
19.5.1 Der Globalzugriff ..................................................................................... 702
19.5.2 Alle Operatoren in C++ ............................................................................ 703
19.5.3 Überladen von Operatoren .................................................................... 707
19.6 Auflösung von Namenskonflikten .................................................................... 711
19.6.1 Der Standardnamensraum std .............................................................. 715

12
Inhalt

20 Objektorientierte Programmierung 717

20.1 Ziele der Objektorientierung .............................................................................. 717


20.2 Objektorientiertes Design .................................................................................. 719
20.3 Klassen in C++ ........................................................................................................ 725
20.4 Aufbau von Klassen .............................................................................................. 725
20.4.1 Zugriffsschutz von Klassen .................................................................... 726
20.4.2 Datenmember ......................................................................................... 727
20.4.3 Funktionsmember ................................................................................... 729
20.4.4 Verwendung des Zugriffsschutzes ........................................................ 731
20.4.5 Konstruktoren .......................................................................................... 735
20.4.6 Destruktoren ............................................................................................ 739
20.5 Instanziierung von Klassen ................................................................................. 740
20.5.1 Automatische Variablen in C ................................................................. 740
20.5.2 Automatische Instanziierung in C++ ..................................................... 741
20.5.3 Statische Variablen in C .......................................................................... 741
20.5.4 Statische Instanziierung in C++ ............................................................. 742
20.5.5 Dynamische Variablen in C .................................................................... 743
20.5.6 Dynamische Instanziierung in C++ ........................................................ 743
20.5.7 Instanziierung von Arrays in C++ ........................................................... 744
20.6 Operatoren auf Klassen ....................................................................................... 745
20.6.1 Friends ...................................................................................................... 746
20.6.2 Operator als Methode der Klasse .......................................................... 747
20.7 Ein- und Ausgabe in C++ ..................................................................................... 748
20.7.1 Überladen des <<-Operators .................................................................. 749
20.7.2 Tastatureingabe ...................................................................................... 750
20.7.3 Dateioperationen .................................................................................... 752
20.8 Der this-Pointer ..................................................................................................... 755
20.9 Beispiele .................................................................................................................. 756
20.9.1 Menge ....................................................................................................... 756
20.10 Aufgaben ................................................................................................................ 771

21 Das Zusammenspiel von Objekten 775

21.1 Modellierung von Beziehungen ........................................................................ 775


21.2 Komposition eigener Objekte ............................................................................ 776

13
Inhalt

21.2.1 Komposition in C++ ................................................................................. 779


21.2.2 Implementierung der print-Methode für timestamp ......................... 780
21.2.3 Der Konstruktor von timestamp ............................................................ 781
21.2.4 Parametrierter Konstruktor einer komponierten Klasse .................... 783
21.2.5 Konstruktionsoptionen der Klasse timestamp .................................... 785
21.3 Eine Klasse text ..................................................................................................... 786
21.3.1 Der Copy-Konstruktor ............................................................................. 788
21.3.2 Implementierung eines Copy-Konstruktors ......................................... 790
21.3.3 Zuweisung von Objekten ........................................................................ 791
21.3.4 Implementierung des Zuweisungsoperators ....................................... 793
21.3.5 Erweiterung der Klasse text ................................................................... 794
21.3.6 Vorgehen für eigene Objekte ................................................................. 796
21.4 Übungen/Beispiel ................................................................................................. 797
21.4.1 Bingo ......................................................................................................... 797
21.5 Aufgabe .................................................................................................................. 803

22 Vererbung 805

22.1 Darstellung der Vererbung ................................................................................. 805


22.1.1 Mehrere abgeleitete Klassen ................................................................. 806
22.1.2 Wiederholte Vererbung .......................................................................... 807
22.1.3 Mehrfachvererbung ................................................................................ 807
22.2 Vererbung in C++ .................................................................................................. 808
22.2.1 Ableiten einer Klasse ............................................................................... 809
22.2.2 Gezieltes Aufrufen des Konstruktors der Basisklasse ......................... 810
22.2.3 Der geschützte Zugriffsbereich einer Klasse ........................................ 812
22.2.4 Erweiterung abgeleiteter Klassen ......................................................... 813
22.2.5 Überschreiben von Funktionen der Basisklasse ................................... 814
22.2.6 Unterschiedliche Instanziierungen und deren Verwendung ............. 817
22.2.7 Virtuelle Memberfunktionen ................................................................. 820
22.2.8 Verwendung des Schlüsselwortes virtual ............................................ 821
22.2.9 Mehrfachvererbung ................................................................................ 822
22.2.10 Zugriff auf die Methoden der Basisklassen .......................................... 824
22.2.11 Statische Member ................................................................................... 826
22.2.12 Rein virtuelle Funktionen ....................................................................... 829
22.3 Beispiele .................................................................................................................. 831
22.3.1 Würfelspiel ............................................................................................... 831
22.3.2 Partnervermittlung ................................................................................. 855

14
Inhalt

23 Zusammenfassung und Überblick 879

23.1 Klassen und Instanzen ......................................................................................... 879


23.2 Member .................................................................................................................. 881
23.2.1 Datenmember ......................................................................................... 881
23.2.2 Funktionsmember ................................................................................... 882
23.2.3 Konstante Member ................................................................................. 885
23.2.4 Statische Member ................................................................................... 887
23.2.5 Operatoren ............................................................................................... 889
23.2.6 Zugriff auf Member ................................................................................. 891
23.2.7 Zugriff von außen .................................................................................... 891
23.2.8 Zugriff von innen ..................................................................................... 894
23.2.9 Der this-Pointer ....................................................................................... 898
23.2.10 Zugriff durch friends ............................................................................... 899
23.3 Vererbung ............................................................................................................... 900
23.3.1 Einfachvererbung .................................................................................... 900
23.3.2 Mehrfachvererbung ................................................................................ 905
23.3.3 Virtuelle Funktionen ............................................................................... 911
23.3.4 Virtuelle Destruktoren ............................................................................ 914
23.3.5 Rein virtuelle Funktionen ....................................................................... 915
23.4 Zugriffsschutz und Vererbung ........................................................................... 916
23.4.1 Geschützte Member ................................................................................ 917
23.4.2 Zugriff auf die Basisklasse ...................................................................... 917
23.4.3 Modifikation von Zugriffsrechten ......................................................... 921
23.5 Der Lebenszyklus von Objekten ........................................................................ 922
23.5.1 Konstruktion von Objekten .................................................................... 925
23.5.2 Destruktion von Objekten ...................................................................... 928
23.5.3 Kopieren von Objekten ........................................................................... 929
23.5.4 Instanziierung von Objekten .................................................................. 934
23.5.5 Explizite und implizite Verwendung von Konstruktoren .................... 937
23.5.6 Initialisierung eingelagerter Objekte .................................................... 939
23.5.7 Initialisierung von Basisklassen ............................................................. 941
23.5.8 Instanziierungsregeln ............................................................................. 943
23.6 Typüberprüfung und Typumwandlung ........................................................... 946
23.6.1 Dynamische Typüberprüfungen ............................................................ 946
23.7 Typumwandlung in C++ ...................................................................................... 948

15
Inhalt

24 Die C++-Standardbibliothek und Ergänzung 953

24.1 Generische Klassen (Templates) ........................................................................ 954


24.2 Ausnahmebehandlung (Exceptions) ................................................................ 962
24.3 Die C++-Standardbibliothek ............................................................................... 973
24.4 Iteratoren ............................................................................................................... 973
24.5 Strings (string) .......................................................................................................
976
24.5.1 Ein- und Ausgabe .....................................................................................
977
24.5.2 Zugriff .......................................................................................................
978
24.5.3 Manipulation ...........................................................................................
981
24.5.4 Vergleich ...................................................................................................
986
24.5.5 Suchen ......................................................................................................
987
24.5.6 Speichermanagement ............................................................................ 988
24.6 Dynamische Arrays (vector) ................................................................................ 990
24.6.1 Die Beispielklasse klasse ......................................................................... 990
24.6.2 Einbinden dynamischer Arrays .............................................................. 991
24.6.3 Konstruktion ............................................................................................ 991
24.6.4 Zugriff ....................................................................................................... 992
24.6.5 Iteratoren ................................................................................................. 993
24.6.6 Manipulation ........................................................................................... 994
24.6.7 Speichermanagement ............................................................................ 998
24.7 Listen (list) .............................................................................................................. 998
24.7.1 Konstruktion ............................................................................................ 998
24.7.2 Zugriff ....................................................................................................... 999
24.7.3 Iteratoren ................................................................................................. 1000
24.7.4 Manipulation ........................................................................................... 1002
24.7.5 Speichermanagement ............................................................................ 1014
24.8 Stacks (stack) ......................................................................................................... 1014
24.9 Warteschlangen (queue) ..................................................................................... 1017
24.10 Prioritätswarteschlangen (priority_queue) .................................................... 1019
24.11 Geordnete Paare (pair) ........................................................................................ 1024
24.12 Mengen (set und multiset) ................................................................................. 1025
24.12.1 Konstruktion ............................................................................................ 1026
24.12.2 Zugriff ....................................................................................................... 1027
24.12.3 Manipulation ........................................................................................... 1029
24.13 Relationen (map und multimap) ....................................................................... 1030
24.13.1 Konstruktion ............................................................................................ 1030

16
Inhalt

24.13.2 Zugriff ....................................................................................................... 1031


24.13.3 Manipulation ........................................................................................... 1032
24.14 Algorithmen der Standardbibliothek ............................................................... 1032
24.14.1 Vererbung und virtuelle Funktionen in Containern ............................ 1037

A Aufgaben und Lösungen 1041

Kapitel 1 .................................................................................................................. 1042


Kapitel 3 .................................................................................................................. 1055
Kapitel 4 .................................................................................................................. 1069
Kapitel 5 .................................................................................................................. 1090
Kapitel 6 .................................................................................................................. 1103
Kapitel 7 .................................................................................................................. 1120
Kapitel 8 .................................................................................................................. 1144
Kapitel 10 ................................................................................................................ 1155
Kapitel 14 ................................................................................................................ 1162
Kapitel 20 ............................................................................................................... 1186
Kapitel 21 ................................................................................................................ 1203

Index ........................................................................................................................................................ 1209

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.

Bocholt, im September 2014


Ulrich Kaiser

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!

Bocholt, im September 2014


Martin Guddat

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:

Computerwissenschaft hat mit Computern genauso viel zu tun wie Astrono-


mie mit Teleskopen.

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.

Eine Pfanne bei mittlerer Hitze heiß werden lassen. Portionsweise


aus dem Teig nun Pfannkuchen in wenig Butter von beiden Seiten
braten, bis sie goldgelb sind.

Abbildung 1.1 Ein Pfannkuchenrezept

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.

Was müssen wir lernen, um unsere Rolle ausfüllen zu können?

왘 Wir müssen die Sprache beherrschen, in der Rezepte formuliert werden.


왘 Wir müssen einen Überblick über die üblicherweise verwendeten Zutaten, deren
Eigenschaften und Zubereitungsmöglichkeiten haben.
왘 Wir müssen einen Vorrat an Zubereitungsverfahren bzw. kompletten Rezepten
abrufbereit im Kopf haben.
왘 Wir müssen wissen, welche Zutaten oder Verfahren miteinander harmonieren
und welche nicht.
왘 Wir müssen wissen, was in einer Küche üblicherweise an Hilfsmitteln vorhanden
ist und wie bzw. wozu diese Hilfsmittel verwendet werden.
왘 Bei anspruchsvolleren Gerichten müssen wir wissen, in welcher Reihenfolge und
mit welchem Timing die Einzelteile zuzubereiten sind und wie die einzelnen Auf-
gaben verteilt werden müssen, damit alles zeitgleich serviert werden kann.
왘 Wir müssen auch wissen, worauf ein potenzieller, späterer Esser Wert legt und
worauf nicht. Dies ist besonders wichtig, wenn wir Rezepte für einen ganz beson-
deren Anlass erstellen.

Letztlich möchten wir komplette Festmenüs und deren Speisefolge komponieren


und benötigen dazu eine Mischung aus Phantasie, Kreativität, logischer Strenge, Aus-
dauer und Fleiß, wie sie auch ein guter Koch, Komponist oder Architekt benötigt.

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.

왘 Algorithmen arbeiten auf Datenstrukturen. Algorithmen ohne Datenstrukturen


sind leere Formalismen.
왘 Datenstrukturen benötigen Algorithmen, die auf ihnen operieren und sie damit
zum »Leben« erwecken.
왘 Programme realisieren Datenstrukturen und Algorithmen. Datenstrukturen und
Algorithmen sind zwar ohne Programme denkbar, aber viele Datenstrukturen

23
1 Einige Grundbegriffe

und Algorithmen wären ohne Programmierung allenfalls von akademischem


Interesse.

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:

Was ist ein Algorithmus?


Ein Algorithmus ist eine endliche Menge genau beschriebener Anweisungen, die
unter Verwendung vorgegebener Anfangsdaten in einer genau festgelegten Rei-
henfolge ausgeführt werden müssen, um die Lösung eines Problems in endlich vie-
len Schritten zu ermitteln.

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

Abbildung 1.2 Die schriftliche Division

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:

Berechne den Quotienten zweier natürlicher Zahlen!

Anfangsdaten:

z = Zähler (z ⱖ 0)

n = Nenner (n > 0)

a = Anzahl der zu berechnenden Nachkommastellen3

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:

Start oder Ende des Algorithmus

Ein- oder Ausgabe

Allgemeine Operation

Verzweigung

Abbildung 1.3 Symbole im Flussdiagramm

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

1 x = größte ganze Zahl mit nx ≤ z

Ausgabe: »Ergebnis = x.«

2.1 nein
a>0 Ende
ja

2.2 z = 10(z – nx)

ja
2.3 z=0
nein

x = größte ganze Zahl mit nx ≤ z


2.4
Ausgabe: »x«

2.5 a=a–1

Abbildung 1.4 Flussdiagramm des Algorithmus

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

Eingabe: z = 84, n = 16,


a=4

1 x = größte ganze Zahl mit 16 x ≤ 84 = 5

Ausgabe: »Ergebnis = 5.«

2.1 a>0 a>0 a>0


ja ja ja

2.2 z = 10 (84 – 16 · 5) = 40 z = 10 (40 – 16 · 2) = 80 z = 10 (80 – 16 · 5)

2.3 z=0 z=0 z=0


nein nein
ja
x = größte ganze Zahl mit 16 x ≤ 40 = 2 x = größte ganze Zahl mit 16 x ≤ 80 = 5

2.4 Ende
Ausgabe: »2« Ausgabe: »5«

2.5 a=4–1=3 a=3–1=2

Abbildung 1.5 Das Flussdiagramm für einen konkreten Fall

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:

Was ist eine Datenstruktur?


Eine Datenstruktur ist ein Modell, das die zur Lösung eines Problems benötigten
Informationen (Ausgangsdaten, Zwischenergebnisse, Endergebnisse) enthält und
für alle Informationen genau festgelegte Zugriffswege bereitstellt.

Auch Datenstrukturen hat es bereits lange vor der Programmierung gegeben,


obwohl man hier mit einigem Recht sagen kann, dass die Theorie der Datenstruktu-
ren erst mit der maschinellen Datenverarbeitung zur Blüte gekommen ist.

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

Kundennummer: 1234 Bezeichnung: Kamera


Name: Meier Art.Nr.: 12-3456
Vorname: Otto Lagerbestand: 11
Adresse: … EK-Preis: 123,45…
Bestellung VK-Preis: 345,67

Kunde: 1234
Datum: 13.06.2013
Artikel: 12-3456
Anzahl: 1
Artikel: …
Anzahl: …

Abbildung 1.6 Verbundene Karteikästen

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.

Auf der Datenstruktur arbeiten Algorithmen (z. B. Kundenadresse ändern, Rechnung


stellen, Artikel nachbestellen, Lieferung zusammenstellen etc.). Die Effizienz dieser
Algorithmen hängt dabei ganz entscheidend von der Organisation der Datenstruk-
tur ab. Zum Beispiel ist die Frage: »Was hat der Kunde Müller dem Unternehmen bis-
her an Umsatz eingebracht?« ausgesprochen schwer zu beantworten. Dazu müssten
Sie zunächst in der Kundendatei die Kundennummer des Kunden Müller finden. Als
Nächstes müssten Sie alle Bestellungen durchsuchen, um festzustellen, ob die Kun-
dennummer von Müller dort vorkommt, und schließlich müssten Sie dann auch
noch die Preise der in den betroffenen Bestellungen vorkommenden Artikel in der
Artikeldatei suchen und aufsummieren. Die Frage: »Welche Artikel in welcher Menge
sind im letzten Monat bestellt worden?« lässt sich mit dieser Datenstruktur erheb-
lich einfacher beantworten.

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.

Was ist ein Programm?


Ein Programm ist eine eindeutige, formalisierte Beschreibung von Algorithmen und
Datenstrukturen, die durch einen automatischen Übersetzungsprozess auf einem
Computer ablauffähig ist.
Den zur Formulierung eines Programms verwendeten Beschreibungsformalismus
bezeichnen wir als Programmiersprache.

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.

Eine Programmiersprache muss nach dieser Definition Elemente zur exakten


Beschreibung von Datenstrukturen und Algorithmen enthalten. Programmierspra-
chen dienen daher nicht nur zur Erstellung lauffähiger Programme, sondern auch
zur präzisen Festlegung von Datenstrukturen und Algorithmen. Dazu müssen Sie
lernen, in einer Programmiersprache so selbstverständlich zu »reden« wie in einer
natürlichen Sprache.

Eigentlich stellen wir gegensätzliche Forderungen an eine Programmiersprache. Sie


sollte automatisch übersetzbar, d. h. maschinenlesbar, und möglichst verständlich
und leicht erlernbar, d. h. menschenlesbar, sein, und sie sollte darüber hinaus die
maschinellen Berechnungs- und Verarbeitungsmöglichkeiten eines Computers
möglichst vollständig ausschöpfen. Maschinenlesbarkeit und Menschenlesbarkeit
sind bei den heutigen Maschinenkonzepten unvereinbare Begriffe. Da die Maschi-
nenlesbarkeit jedoch unverzichtbar ist, müssen zwangsläufig bei der Menschenles-
barkeit Kompromisse gemacht werden; Kompromisse, von denen Berufsgruppen
wie Systemanalytiker oder Programmierer leben.

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:

Rang Name Anteil

1 C 17,8 %

2 Java 16,6 %

3 Objective-C 10,3 %

4 C++ 8,8 %

5 PHP 5,9 %

Tabelle 1.1 Ranking der Programmiersprachen

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.

Der Tiobe-Index unterscheidet auch verschiedene Programmierparadigmen4 und


kommt hier zu folgendem Ergebnis:

Rang Name Anteil

1 Objektorientiertes Paradigma 58,5 %

2 Prozedurales Paradigma 36,6 %

3 Funktionales Paradigma 3,2 %

4 Logisches Paradigma 1,8 %

Tabelle 1.2 Ranking der Programmierparadigmen

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:

The Art of Computer Programming5 (Die Kunst der Computerprogrammie-


rung)

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)

Tauschen Sie die CDs in den Fächern n und 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:

Abbildung 1.7 Darstellung des Endspiels

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

Debugging is twice as hard as writing the code in the first place.


Therefore, if you write the code as cleverly as possible, you are, by
definition, not smart enough to debug it.
– Brian Kernighan

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

Abbildung 2.1 Das V-Modell

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:

왘 Projektplanung und Projektmanagement


왘 Konfigurations- und Change Management
왘 Systemanalyse
왘 Systementwurf

1 Ressourcen sind Mitarbeiter, aber auch technisches Gerät oder Rechenzeit.

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.

Das Konfigurationsmanagement wird von Werkzeugen, die alle Quellen (Pro-


gramme und Dokumentation) eines Projekts in ein Archiv aufnehmen und jedem
Mitarbeiter aktuelle Versionen mit Sperr- und Ausleihmechanismen zum Schutz vor
konkurrierender Bearbeitung zur Verfügung stellen, unterstützt. Die Werkzeuge hal-
ten die Historie aller Quellen nach und können jederzeit frühere, konsistente Versio-
nen der Software oder der Dokumentation restaurieren.

Bei der Systemanalyse werden objektorientierte Analysemethoden und Beschrei-


bungsformalismen, insbesondere UML (Unified Modeling Language), eingesetzt. Für
die Analyse der Datenstrukturen verwendet man häufig sogenannte Entity-Relation-
ship-Methoden. Alle genannten Methoden werden durch Werkzeuge (sogenannte
CASE2-Tools) unterstützt. In der Regel handelt es sich dabei um Werkzeuge zur inter-
aktiven, grafischen Eingabe des jeweiligen Modells. Alle Eingaben werden über ein
zentrales Data Dictionary (Datenwörterbuch oder Datenkatalog) abgeglichen und
konsistent gehalten. Durch einen Transformationsschritt erfolgt bei vielen Werkzeu-
gen der Übergang von der Analyse zum Design, d. h. zum Systementwurf. Auch hier
stehen wieder computerunterstützte Verfahren vom Klassen-, Schnittstellen- und
Datendesign bis hin zur Codegenerierung oder zur Generierung eines Datenbank-
schemas oder von Teilen der Benutzeroberfläche (Masken, Menüs) zur Verfügung. Je
nach Entwicklungsumgebung gibt es eine Vielzahl von Werkzeugen, die den Pro-
grammierer bei der Implementierung unterstützen. Verwiesen sei hier besonders auf

2 Computer Aided Software Engineering

37
2 Einführung in die Programmierung

die heute sehr kompletten Datenbank-Entwicklungsumgebungen sowie die vielen


interaktiven Werkzeuge zur Erstellung grafischer Benutzeroberflächen. Sogenannte
Make-Utilities verwalten die Abhängigkeiten aller Systembausteine und automati-
sieren den Prozess der Systemgenerierung aus den aktuellen Quellen.

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

Abbildung 2.2 Schichten eines Softwaresystems

Jede Schicht hat ihre spezifischen Aufgaben.

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

Menüs, liegen bereits in der Visualisierungskomponente. In der Regel werden die


Funktionen zur Interaktion über den Benutzer (Mausklick auf einen Button etc.) ange-
stoßen und vermitteln dann zwischen den Benutzerwünschen und den eigentlichen
Funktionen des Anwendungssystems, die hier unter dem Begriff Funktion zusammen- 2
gefasst sind. Auf den Ebenen Interaktion und Funktion zerfällt ein System häufig in
unabhängige, vielleicht sogar parallel laufende Module, die auf einem gemeinsamen
Datenbestand arbeiten. Die Datenhaltung und der Datenzugriff werden häufig in einer
übergreifenden Schicht vorgenommen, denn hier muss sichergestellt werden, dass
unterschiedliche Funktionen trotz konkurrierenden Zugriffs einen konsistenten Blick
auf die Daten haben. Bei großen Softwaresystemen kommen Datenbanken mit ihren
Management-Systemen zum Einsatz. Diese verfügen über spezielle Sprachen zur Defi-
nition, Abfrage, Manipulation und Integritätssicherung von Daten. Unterschiedliche
Teile eines Systems können auf einem Rechner, aber auch verteilt in einem lokalen
oder weltweiten Netz laufen. Wir sprechen dann von einem »verteilten System«. Unter
dem Begriff Kommunikation werden Funktionen zum Datenaustausch zwischen ver-
schiedenen Komponenten eines verteilten Systems zusammengefasst. Über Funktio-
nen zur Synchronisation schließlich werden parallel arbeitende Systemfunktionen,
etwa bei konkurrierendem Zugriff auf Betriebsmittel, wieder koordiniert. Die Schich-
ten Kommunikation und Synchronisation stützen sich stark auf die vom jeweiligen
Betriebssystem bereitgestellten Funktionen und sind von daher häufig an ein
bestimmtes Betriebssystem gebunden. In allen anderen Bereichen versucht man, nach
Möglichkeit portable Funktionen, d. h. Funktionen, die nicht an ein bestimmtes Sys-
tem gebunden sind, zu erstellen. Man erreicht dies, indem man allgemein verbindliche
Standards, wie z. B. die Programmiersprache C, verwendet.

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

Abbildung 2.3 Realisierung von Anwendungsfunktionen

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.

2.2 Die Programmierumgebung


Bei der Realisierung von Softwaresystemen ist die Programmierung natürlich eine
der zentralen Aufgaben. Abbildung 2.4 zeigt die Programmierung als eine Abfolge
von Arbeitsschritten:

Programmtext
erstellen bzw.
Editor modifizieren

Programmtext
Compiler übersetzen

Ausführbares
Linker Programm
erzeugen

Programm
Debugger ausführen und
testen

Programm
Profiler analysieren und
optimieren

Abbildung 2.4 Arbeitsschritte bei der Programmierung

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!

2.2.1 Der Editor


Ein Programm wird wie ein Brief in einer Textdatei erstellt und abgespeichert. Der
Programmtext (Quelltext) wird mit einem sogenannten Editor3 erstellt. Es kann
nicht Sinn und Zweck dieses Buches sein, Ihnen einen bestimmten Editor mit all sei-
nen Möglichkeiten vorzustellen. Die Editoren der meisten Entwicklungsumgebun-
gen orientieren sich an den Möglichkeiten moderner Textverarbeitungssysteme,
sodass Sie, sofern Sie mit einem Textverarbeitungssystem vertraut sind, keine
Schwierigkeiten mit der Bedienung des Editors Ihrer Entwicklungsumgebung haben
sollten. Über die reinen Textverarbeitungsfunktionen hinaus hat der Editor in der
Regel Funktionen, die Sie bei der Programmerstellung gezielt unterstützen. Art und
Umfang dieser Funktionen sind allerdings auch von Entwicklungsumgebung zu Ent-
wicklungsumgebung verschieden, sodass wir hier nicht darauf eingehen können.

Ü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

3 engl. to edit = einen Text erstellen oder überarbeiten

41
2 Einführung in die Programmierung

Header-Dateien (engl. Headerfiles) sind Dateien, die Informationen zu Datentypen


und -strukturen, Schnittstellen von Funktionen etc. enthalten. Es handelt sich dabei
um allgemeine Vereinbarungen, die an verschiedenen Stellen (d. h. in verschiedenen
Source- und Headerfiles) einheitlich und konsistent benötigt werden. Headerfiles
stehen im Moment noch nicht im Mittelpunkt unseres Interesses. Spätestens mit der
Einführung von Datenstrukturen werden wir Ihnen jedoch die große Bedeutung die-
ser Dateien erläutern.

Die Quellcodedateien (engl. Sourcefiles) enthalten den eigentlichen Programmtext


und stehen für uns zunächst im Vordergrund.

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.

2.2.2 Der Compiler


Ein Programm in einer höheren Programmiersprache ist auf einem Rechner nicht
unmittelbar ablauffähig. Es muss durch einen Compiler4 in die Maschinensprache
des Trägersystems übersetzt werden.

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.

4 engl. to compile = zusammenstellen


5 Man nennt so etwas einen Syntaxfehler.

42
2.2 Die Programmierumgebung

2.2.3 Der Linker


Die noch fehlende Montage der einzelnen Objectfiles zu einem fertigen Programm
übernimmt der Linker6. Der Linker nimmt dabei die noch ausstehenden übergreifen-
2
den Prüfungen vor. Auch dabei kann noch eine Reihe von Fehlern aufgedeckt wer-
den. Zum Beispiel kann der Linker in der Zusammenschau aller Objectfiles
feststellen, dass versucht wird, eine Funktion zu verwenden, die es nirgendwo gibt.

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.

2.2.4 Der Debugger


Der Debugger7 dient zum Testen von Programmen. Mit dem Debugger können die
erstellten Programme bei ihrer Ausführung beobachtet werden. Darüber hinaus kön-
nen Sie in das laufende Programm durch manuelles Ändern von Variablenwerten
etc. eingreifen. Ein Debugger ist nicht nur zur Lokalisierung von Programmierfeh-
lern, sondern auch zur Analyse eines Programms durch Nachvollzug des Programm-
ablaufs oder zum interaktiven Erlernen einer Programmiersprache ausgesprochen
hilfreich. Arbeiten Sie sich daher frühzeitig in die Bedienung des Debuggers Ihrer
Entwicklungsumgebung ein und nicht erst, wenn Sie ihn zur Fehlersuche benötigen.

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.

2.2.5 Der Profiler


Wenn Sie die Performance Ihrer Programme analysieren und optimieren wollen,
sollten Sie einen Profiler verwenden. Ein Profiler überwacht Ihr Programm zur Lauf-
zeit und erstellt sogenannte Laufzeitprofile, die Informationen über die verbrauchte
Rechenzeit und den in Anspruch genommenen Speicher enthalten. Häufig können
Sie ein Programm nicht gleichzeitig bezüglich seiner Laufzeit und seines Speicher-

6 engl. to link = verbinden


7 engl. to debug = entwanzen

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 ...
...
...
}

Listing 3.1 Ein minimaler Programmrahmen

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

und Gleitkommazahlen, z. B.:

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

Was ist eine Variable?


Unter einer Variablen verstehen wir einen mit einem Namen versehenen Speicher-
bereich, in dem Daten eines bestimmten Typs hinterlegt werden können.
Das im Speicherbereich der Variablen hinterlegte Datum bezeichnen wir als den 3
Wert der Variablen.

Zu einer Variablen gehören also:


왘 ein Name
왘 ein Typ
왘 ein Speicherbereich
왘 ein Wert

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;
}

Listing 3.2 Unterschiedliche Variablendefinitionen

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

1 engl. Integer = ganze Zahl


2 engl. Floatingpoint Number = Gleitkommazahl

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:

왘 Variablen Zahlenwerte zuweisen


왘 mit Variablen und Zahlen rechnen
왘 Variablen und Zahlen miteinander vergleichen

Diese Möglichkeiten gibt es natürlich auch in der Programmiersprache C.

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;
}

Listing 3.3 Wertzuweisung an Variablen

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;

3.4.2 Arithmetische Operatoren


Mit Variablen und Zahlen können Sie rechnen, wie Sie es von der Schulmathematik
her gewohnt sind:

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;

A hoehe = 1.2 + 2*c;


B a = b + c;
C summe = summe + 1;
}

Listing 3.4 Verwendung arithmetischer Operatoren

Variablenwerte können durch Formeln berechnet werden, und in Formeln können


dabei wieder Variablen vorkommen (A).

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:

Operator Verwendung Bedeutung

+ x+y Addition von x und y

- x–y Subtraktion von x und y

* x*y Multiplikation von x und y

/ x/y Division von x durch y (y ≠ 0)

% x%y Rest bei ganzzahliger Division von x durch y (Modulo-


Operator, y ≠ 0)

Tabelle 3.1 Grundlegende Operatoren in C

Sie können in Formelausdrücken Klammern setzen, um eine bestimmte Auswer-


tungsreihenfolge zu erzwingen. In Fällen, die nicht durch Klammern eindeutig gere-
gelt sind, greift dann die aus der Schule bekannte Regel:

50
3.4 Operatoren

Punktrechnung (*, /, %) geht vor Strichrechnung (+, -).

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;

Ganze Zahlen und Gleitkommazahlen können in Formeln durchaus gemischt vor-


kommen. Es wird immer so lange wie möglich im Bereich der ganzen Zahlen gerech-
net. Sobald aber die erste Gleitkommazahl ins Spiel kommt, wird die weitere
Berechnung im Bereich der Gleitkommazahlen durchgeführt.

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;

Anweisungen wie a = a + 5 oder b = b – a werden in Programmen sogar recht häufig


verwendet. Sie können dann vereinfachend a += 5 oder b -= a schreiben. Insgesamt
gibt es folgende Vereinfachungsmöglichkeiten:

Operator Verwendung Entsprechung

+= x += y x=x+y

-= x -= y x=x–y

*= x *= y x=x*y

Tabelle 3.2 Vereinfachende Operatoren

51
3 Ausgewählte Sprachelemente von C

Operator Verwendung Entsprechung

/= x /= y x=x/y

%= x %= y x=x%y

Tabelle 3.2 Vereinfachende Operatoren (Forts.)

In dem noch häufiger vorkommenden Fall einer Addition oder Subtraktion von 1
kann man noch einfacher formulieren:

Operator Verwendung Entsprechung

++ x++ bzw. ++x x=x+1

-- x-- bzw. --x x=x–1

Tabelle 3.3 Operatoren für die Addition und Subtraktion von 1

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:

Das Ergebnis einer arithmetischen Operation, an der nur ganzzahlige Operanden


beteiligt sind, ist immer eine ganze Zahl.

Im Falle einer Division wird in dieser Situation eine Division ohne Rest (Integer-Divi-
sion) durchgeführt.

52
3.4 Operatoren

Betrachten Sie dazu das folgende Codefragment:

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

Abbildung 3.1 Beispiel einer zweidimensionalen Struktur

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

Abbildung 3.2 Beispiel einer eindimensionalen Struktur

Wenn Sie aus Zeile und Spalte in der zweidimensionalen Struktur die Position in der
eindimensionalen Struktur berechnen möchten, geht das mit der Formel:

position = zeile*breite + spalte;

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;
}

Listing 3.5 Beispiel der Integer-Division

Das Ergebnis der Division in der Zeile (A) ist 0.

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;
}

Listing 3.6 Typumwandlung vor der Division

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

Operator Verwendung Entsprechung

< x<y kleiner

<= x <= y kleiner oder gleich

> x>y größer

>= x >= y größer oder gleich

== x == y gleich

!= x != y ungleich

Tabelle 3.4 Vergleichsoperatoren

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.5.1 Bedingte Befehlsausführung


Die bedingte Ausführung einer Anweisungsfolge realisieren wir in C durch eine if-
Anweisung, die die folgende Struktur hat:

3
Hier steht eine Bedingung
(zumeist ein Vergleichsausdruck).

if ( ... ) Die hier stehenden Anweisungen


{ werden ausgeführt, wenn die
Handelt es sich hier um eine ... Auswertung der Bedingung
einzelne Anweisung, können ... einen Wert ≠ 0 ergibt.
die geschweiften Klammern ...
weggelassen werden. }

Abbildung 3.3 Bedingte Befehlsausführung

Zum besseren Verständnis betrachten wir einige einfache Beispiele.

Das folgende Codefragment berechnet den Absolutbetrag einer Variablen a:

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

Hier steht eine Bedingung


(zumeist ein Vergleichsausdruck).

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.

Abbildung 3.4 Die vollständige if-Anweisung

Auch dazu betrachten wir einige einfache Beispiele.

Berechne das Maximum zweier Zahlen a und b:

if( a < b)
max = b;
else
max = a;

Berechne den Abstand von a und b:

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)
{
...
}

kann auch so ausgedrückt werden:

58
3.5 Kontrollfluss

if ( a)
{
...
}
3
Andersherum kann

if ( a == 0)
{
...
}

auch so dargestellt werden:

if ( !a)
{
...
}

Da der C-Programmierer mit Buchstaben im Quellcode geizt, favorisiert er zumeist


die kürzere Formulierung, aber bleiben Sie zunächst ruhig bei der längeren, wenn Sie
den Code dann besser lesen können.

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.

3.5.2 Wiederholte Befehlsausführung


Am Beispiel der Division aus dem ersten Kapitel haben Sie gesehen, dass es erforder-
lich sein kann, in einem Algorithmus eine bestimmte Folge von Anweisungen wie-
derholt zu durchlaufen, bis eine bestimmte Situation eingetreten ist. Wir nennen
dies eine Programmschleife. Versucht man, die Anatomie von Schleifen allgemein zu
beschreiben, stößt man auf ein immer wiederkehrendes Muster:

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:

Dies ist die Initialisierug.


Initialisierung, Test und Inkrement Sie wird nur einmal ausgeführt.
nennen wir den Schleifenkopf.
Briefe und Marken
bereitstellen

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

Dies ist der Schleifenkörper, Brief nehmen


in dem immer ein Arbeitsschritt
durchgeführt wird.
Marke abtrennen
Marke befeuchten
Marke aufkleben

Abbildung 3.5 Flussdiagramm »Briefe frankieren«

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.

for ( ... ; ... ; ... )


{ Das Inkrement wird immer
Initialisierung, Test und ... nach dem Verlassen und vor
Inkrement bezeichnen wir einem möglichen Wieder-
als den Schleifenkopf.
...
eintritt in den Schleifenkörper
... ausgeführt.
...
Der Schleifenkörper wird bei
jedem Schleifendurchlauf aus-
...
geführt. Besteht der Schleifen- ...
körper nur aus einer einzelnen }
Anweisung, können die
geschweiften Klammern weg-
gelassen werden.

Abbildung 3.6 Die for-Anweisung

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;

Der Endwert in summe ist jeweils der gleiche.

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:

for(summe = 0, i = 1; i <= 100; i++)


summe = summe + i;

Anstelle von i = i + 1 habe ich hier die Kurzform i++ verwendet.

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:

for(a = 1, b = 100; a < b; a++, b--)


...;

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( ; ; )
;

Eine solche Endlosschleife zu programmieren ist natürlich Unsinn. Trotzdem gibt es


Situationen, in denen man den Test weglässt, da man den Ablauf der Schleife auch
aus dem Schleifenkörper heraus steuern kann. Um das zu verstehen, kehren wir noch
einmal zu dem einführenden Beispiel zurück und diskutieren zwei Sonderfälle, die
beim Frankieren der Briefe auftreten können:

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.

Wir nehmen diese beiden Fälle in das Flussdiagramm auf:

62
3.5 Kontrollfluss

Briefe und Marken


bereitstellen

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

Abbildung 3.7 Das erweiterte Flussdiagramm

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:

for ( ... ; ... ; ... )


{
...
...
if ( ... )
Bei einer continue-Anweisung
continue ;
wird der derzeitige Schleifen-
durchlauf abgebrochen, die ...
Schleifenbearbeitung insgesamt ...
Bei einer break-Anweisung
aber über Inkrement und Test if ( ... )
wird die Bearbeitung der
fortgesetzt. break;
Schleife abgebrochen.
...
...
}
...

Abbildung 3.8 Schleifensteuerung innerhalb der for-Anweisung

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:

for(summe = 0, i = 1; i <= 100; i = i + 1)


{
if( i%7 == 0)
continue;
summe = summe + i;
}

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:

for(summe = 0, i = 1; i <= 100; i = i + 1)


{
if( i%7 == 0)
continue;
summe = summe + i;
if( summe > 1000)
break;
}

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.

Ganz selbstverständlich haben wir in unserem Beispiel eine Bedingung in eine


Schleife eingebaut. Das zeigt, dass man offensichtlich die verschiedenen Kontroll-

64
3.5 Kontrollfluss

strukturen ineinander schachteln kann. Diese Beobachtung wollen wir im folgenden


Abschnitt noch etwas vertiefen.

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

for ( …; a; …) for ( …; !a; …)


{ {
for ( …; a != 0; …)
… for ( …; a == 0; …)…
{ } { }
… …
} }

Abbildung 3.9 Vereinfachung der Testbedingung

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:

for ( ; …Test…; ) while ( …Test… )


{ {
… …
} }

Abbildung 3.10 Ersatz der for- durch eine while-Anweisung

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.

3.5.3 Verschachtelung von Kontrollstrukturen


Kontrollstrukturen können beliebig ineinander eingesetzt werden. Möglich sind z. B.:

왘 ein if in einem if
왘 ein if in einem for
왘 ein for in einem for

65
3 Ausgewählte Sprachelemente von C

왘 ein for in einem if


왘 ein if in einem for in einem for
왘 ein for in einem if in einem for in einem if

Als Beispiel betrachten wir ein Programm, das das »kleine Einmaleins« durch zwei
ineinander geschachtelte Zählschleifen berechnet:

for( i = 1; i <= 10; i = i + 1)


{
for( k = 1; k <= 10; k = k + 1)
produkt = i*k;
}

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:

for( i = 1; i <= 10; i = i + 1)


{
if( i%2 == 0)
{
for( k = 1; k <= 10; k = k + 1)
{
if( k%2 == 0)
produkt = i*k;
}
}
}

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

3.6 Elementare Ein- und Ausgabe


Um erste einfache Programme schreiben zu können, müssen Sie Werte von der Tas-
tatur in Variablen einlesen und Werte von Variablen auf dem Bildschirm ausgeben
können. Es geht dabei nicht darum, komplexe Interaktionen mit dem Benutzer abzu- 3
wickeln. Es reicht, wenn Sie einige wenige Benutzereingaben in Ihre Programme hin-
ein- und einige wenige Ergebnisse aus Ihren Programmen herausbekommen.
Dementsprechend spartanisch sind die Methoden, die ich Ihnen hier vorstellen
werde. Da Computernutzer heutzutage von opulenten Bedienoberflächen verwöhnt
sind, wird das vielleicht enttäuschend für Sie sein. Aber vielleicht lenkt gerade diese
Genügsamkeit Ihren Blick auf das Wesentliche.

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:

printf( "Dieser Text wird ausgegeben\n");

Der auszugebende Text wird in doppelte Hochkommata eingeschlossen. Die am


Ende des Textes stehende Zeichenfolge \n erzeugt einen Zeilenvorschub. Vergessen
Sie nicht das Semikolon am Ende der Zeile!

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;

printf ( "Die %d. Zeile hat %d Buchstaben!\n", wert, 26);

wert = 2;

printf ( "Dies ist die %d. Zeile!\n", wert);

Abbildung 3.11 Programmfragment zur Ausgabe

führt zu der Ausgabe:

Die 1. Zeile hat 26 Buchstaben!


Dies ist die 2. Zeile!

Der auszugebende Wert kann auch ohne Verwendung einer Variablen direkt dort
berechnet werden, wo er benötigt wird:

printf( "Ergebnis = %d\n", 3*a + b);

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.

Zur Ausgabe von Gleitkommazahlen verwendet man die Formatanweisung %f. Im


folgenden Beispiel

float preis;

preis = 10.99;
printf( "Die Ware kostet %f EURO\n", preis);

erhalten wir die Ausgabe:

Die Ware kostet 10.99 EURO

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:

for( i = 1; i <= 10; i = i + 1)


{
for( k = 1; k <= 10; k = k + 1)
{

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.

int zahl1, zahl2;

printf ( "Bitte geben Sie zwei Zahlen ein: ");

scanf ( "%d %d", &zahl1, &zahl2);


printf ( "Sie haben %d und %d eingegeben\n", zahl1, zahl2);

Abbildung 3.12 Einlesen von Werten

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:

Bitte geben Sie zwei Zahlen ein: 123 456


Sie haben 123 und 456 eingegeben!

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;

printf( "Bitte maxi eingeben: ");


scanf( "%d", &maxi);
printf( "Bitte maxk eingeben: ");
scanf( "%d", &maxk);

for( i = 1; i <= maxi; i = i + 1)


{
for( k = 1; k <= maxk; k = k + 1)
{
produkt = i*k;
printf( "%d mal %d ist %d\n", i, k, produkt);
}
printf( "\n");
}
}

Listing 3.7 Das kleine Einmaleins

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

Die Formatanweisung in scanf kann neben den %-Anweisungen auch zusätzliche


Zeichen enthalten. Zum Beispiel

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

3.6.3 Kommentare und Layout


C-Programme können durch Kommentare verständlicher gestaltet werden. Es gibt
zwei Arten, ein Programm in C zu kommentieren:

왘 Einzeilige Kommentare beginnen mit // und erstrecken sich dann bis zum Ende
der Zeile.
왘 Mehrzeilige Kommentare beginnen mit /* und enden mit */.

Kommentare werden beim Übersetzen des Programms einfach ignoriert.

/*
** 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

So sollten Sie es jedenfalls nicht machen:

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"); } }

Es gibt übrigens einen hochinteressanten Wettbewerb (International Obfuscated C


Code Contest) im Internet, bei dem es darum geht, möglichst kreativ C-Programme
zu erstellen, die ihre wahre Funktion verschleiern. Das ist sozusagen das genaue
Gegenteil dessen, was von einem seriösen Programmierer erwartet wird. Im Rahmen
dieses Wettbewerbs sind im Laufe der Jahre kleine Kunstwerke entstanden, die nur
mit perfekten C-Kenntnissen analysiert und verstanden werden können. Im
Moment ist es vielleicht noch etwas zu früh für Sie, sich an solchen Programmen zu
versuchen, aber wenn Sie später einmal testen wollen, ob Sie C wirklich verstanden
haben, finden Sie dort echte Herausforderungen.

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.

3.7.1 Das erste Programm


Was liegt näher, als mit Ihren frisch erworbenen Programmierkenntnissen zu versu-
chen, den Algorithmus zur Division aus dem ersten Kapitel zu realisieren? Erinnern
Sie sich an das zugehörige Flussdiagramm, das Sie jetzt in ein C-Programm umsetzen
können.

73
3 Ausgewählte Sprachelemente von C

void main()
Start
{
int z, n, a, x;

printf ( "Zu teilende Zahl: ");


scanf ( "%d", &z);
printf ( "Teiler: "); Eingabe: z, n, a
scanf ( "%d", &n);
printf ( "Anzahl Nachkommastellen: ");
scanf ( "%d", &a);

x = z/n; x = größte ganze Zahl mit nx ≤ z

printf ( "Ergebnis = %d.", x); Ausgabe: »Ergebnis = x.«

for ( ; a > 0; a = a – 1) nein


{ a>0
ja

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

printf ( "%d", x); Ausgabe: »x«

}
a=a–1
}
Ende

Abbildung 3.13 Flussdiagramm der schriftlichen Division

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.

Wir testen das Programm mit unserem Standardfall (84:16)

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

Zu teilende Zahl: 100


Teiler: 7
Anzahl Nachkommastellen: 6
Ergebnis = 14.285714
3
Das Programm arbeitet einwandfrei.

3.7.2 Das zweite Programm


Wir betrachten ein einfaches Spiel, bei dem eine Kugel durch eine Reihe von Weichen
(weiche1 bis weiche4) zu einem von zwei möglichen Ausgängen fällt:

weiche1 0 1

1 2

weiche2 0 1 0 1 weiche3

0 1

weiche4
4 5

Abbildung 3.14 Das Kugelspiel

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

Wir modellieren die Problemlösung zunächst durch ein Flussdiagramm, in dessen


Struktur man das Spiel direkt wiedererkennt:

Weichenstellungen
eingeben

ja weiche nein
1
==1

position = 1 position = 2

weiche1 umschlagen

ja position nein
==1

ja weiche2 nein ja weiche3 nein


==1 ==1

position = 4 position = 3 position = 3 position = 5

weiche2 umschlagen weiche3 umschlagen

position nein
==3

ja

ja weiche4 nein
==1

position = 4 position = 5

weiche4 umschlagen

Position
ausgeben

Abbildung 3.15 Flussdiagramm des Kugelspiels

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);
}

Listing 3.8 Implementierung des Kugelspiels

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.

Und so läuft das Programm aus Benutzersicht ab:

Bitte geben Sie die Weichenstellungen ein: 1 0 1 0


Auslauf: 5, neue Weichenstellung 0 1 1 1

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;

printf( "Bitte geben Sie die Weichenstellungen ein: ");


scanf( "%d %d %d %d", &weiche1, &weiche2, &weiche3, &weiche4);
printf( "Bitte geben Sie die Anzahl der Kugeln ein: ");
scanf( "%d", &kugeln);

for( ; kugeln > 0; kugeln = kugeln – 1)


{
... wie bisher ...
}
}

Listing 3.9 Erweiterung des Kugelspiels

Und so läuft das erweiterte Programm ab:

Bitte geben Sie die Weichenstellungen ein: 0 1 0 1


Bitte geben Sie die Anzahl der Kugeln ein: 5
Auslauf: 5, neue Weichenstellung 1 1 1 1
Auslauf: 4, neue Weichenstellung 0 0 1 1
Auslauf: 4, neue Weichenstellung 1 0 0 0
Auslauf: 5, neue Weichenstellung 0 1 0 1
Auslauf: 5, neue Weichenstellung 1 1 1 1

78
3.7 Beispiele

3.7.3 Das dritte Programm


Für unser drittes Programm stellen wir uns die folgende Programmieraufgabe:

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:

Wie viele Zahlen sollen eingegeben werden: 8


1. Zahl: 1
2. Zahl: 2
3. Zahl: –5
4. Zahl: 4
5. Zahl: 5
6. Zahl: –8
7. Zahl: 3
8. Zahl: –7

Anschließend werden die gewünschten Berechnungsergebnisse ausgegeben:

Die Summe aller positiven Eingaben ist: 15


Die Summe aller negtiven Eingaben ist: –20
Die Gesamtsumme ist: –5

Zur Realisierung nehmen wir unseren Standardprogrammrahmen und ergänzen die


gewünschte Funktionalität:

# 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;

D for( z = 1; z <= anzahl; z = z + 1)


{
E printf( "%d. Zahl: ", z);
scanf( "%d", &summand);

F if( summand > 0)


psum = psum + summand;
else
nsum = nsum + summand;
}
G printf( "Summe aller positiven Eingaben: %d\n", psum);
printf( "Summe aller negativen Eingaben: %d\n", nsum);
printf( "Gesamtsumme: %d\n", psum + nsum);
}

Listing 3.10 Das dritte Programm

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:

왘 anzahl ist die vom Benutzer gewählte Zahl der Eingaben.


왘 z ist die Kontrollvariable für die Zählschleife.
왘 summand ist die vom Benutzer aktuell eingegebene Zahl.
왘 psum ist die jeweils aufgelaufene Summe der positiven Eingaben.
왘 nsum ist die jeweils aufgelaufene Summe der negativen Eingaben.

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

Was passiert, wenn man versucht, durch 0 zu dividieren?

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

Abbildung 3.16 Treppe aus Legosteinen

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 einige fundamentale Unterschiede zwischen Berechnungen in der Mathema-


tik und in der Programmierung. Ein wichtiger Unterschied ist, dass die Mathematik
mit unendlich vielen Zahlen arbeitet, während ein Computer nur endlich viele Zah-
len kennt. In der Mathematik ist es so, dass es zwischen zwei verschiedenen Zahlen
immer eine weitere Zahl gibt. Auf einem Computer ist das nicht immer so. Ein Com-
puter muss das mathematische Modell von unendlich vielen Zahlen in ein endliches
Zahlenmodell pressen, wobei es dann immer eine größte und eine kleinste Zahl und
auch einen Mindestabstand zwischen Zahlen gibt. Bei dieser »Diskretisierung« erge-
ben sich zwangsläufig Probleme (z. B. Rechenungenauigkeit), mit denen man umzu-
gehen lernen muss.

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

a·x·x·x·x·x + b·x·x·x·x + c·x·x·x + d·x·x +e·x +f


Aus Sicht der Mathematik ist hier nichts einzuwenden. Der erfahrene Programmie-
rer aber formt den Ausdruck durch systematisches Ausklammern um:

((((a·x + b)·x + c)·x + d)·x + e)·x + f


Das ist jetzt nicht mehr so gut lesbar, aber es gibt einen frappierenden Unterschied
zur ersten Formulierung. Während die erste Formel 15 Multiplikationen enthält,
kommt die zweite mit fünf Multiplikationen aus. Additionen gibt es in beiden For-
meln gleich viele. Die zusätzlichen Klammern in der zweiten Formel steigern den
Berechnungsaufwand nicht. Sie legen ja nur fest, in welcher Reihenfolge die einzel-
nen Operationen durchzuführen sind. Das bedeutet, dass man bei der Programmie-
rung die zweite Formulierung bevorzugen wird, zumal diese Formulierung ein sehr
einfaches, leicht zu programmierendes Bildungsgesetz aufweist. In dieser Formel
klingt der Rhythmus der Informatik: »a mal x + b mal x + c mal x + d ...«

Wir haben die Ausgangsformel in eine einfache Abfolge gleichartiger Rechenschritte


zerlegt und wollen diesen Prozess im Folgenden noch präziser beschreiben:

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.

In einer etwas formaleren Notation schreiben wir das wie folgt:

⎧ 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:

Ausgangspunkt für den Tilgungsplan ist die anfängliche Darlehenssumme


bzw. die Restschuld, die jeweils noch zu Buche steht. Mit der Bank wird ein soge-
nannter Nominalzins vereinbart. Die Restschuld wird monatlich mit 1/12 dieses
Nominalzinses verzinst. Die monatlich zu zahlende Rate wird ebenfalls festge-
legt und muss natürlich größer als die anfallenden Zinsen sein, damit noch ein
Tilgungsbetrag übrig bleibt. Der Tilgungsbetrag ergibt sich dann aus der
Monatsrate nach Abzug der monatlichen Zinsen. Wegen des Risikos von Zins-
schwankungen garantiert die Bank den obigen Nominalzins allerdings nur
über einen gewissen Zeitraum. In diesem Zeitraum besteht dann eine Zinsbin-
dung. Nach Ablauf dieser Zeit gelten die dann marktüblichen Zinsen, die im
Vorhinein natürlich nur geschätzt werden können und ein gewisses Risiko im
Tilgungsplan darstellen. Letztlich wird mit der Bank noch vereinbart, dass jähr-
liche Sondertilgungen in einer bestimmten Höhe vorgenommen werden kön-
nen.

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:

⎧ zins1 falls n ≤ bindung ⋅ 12


zins n = ⎨
⎩ zins2 falls n > bindung ⋅ 12

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:

⎧ rate – zinsen n falls rate – zinsen n ≤ rest n


tilgung n = ⎨
⎩ rest n falls rate – zinsen n > rest n

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:

⎧ falls n durch 12 teilbar


⎪ sondertilgung
⎪ und sondertilgung < rest n – tilgung n

sonderz n = ⎨ falls n durch 12 teilbar
⎪ rest n – tilgung n
⎪ und sondertilgung ≥ rest n – tilgung n

⎩ 0 falls n nicht durch 12 teilbar

Insgesamt ergibt sich dann nach Abzug aller Zahlungen der neue Darlehensrest:

restn+1 = restn – tilgungn – sonderzn

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;
}

Listing 4.1 Deklaration der verwendeten Variablen

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;

printf( "Darlehen: ");


scanf( "%f", &rest);
printf( "Nominalzins: ");
scanf( "%f", &zins1);
printf( "Monatsrate: ");
scanf( "%f", &rate);
printf( "Zinsbindung (Jahre): ");
scanf( "%d", &bindung);
printf( "Zinssatz nach Bindung: ");
scanf( "%f", &zins2);
printf( "Jaehrliche Sondertilgung: ");
scanf( "%f", &sondertilgung);
}

Listing 4.2 Dialog mit dem Benutzer

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");

A for( monat = 1; rest > 0; monat = monat + 1)


{
B printf( "%5d", monat);
C if( monat <= bindung * 12)
zins = zins1;
else
zins = zins2;
printf( " %10.2f", zins);
D zinsen = rest * zins / 1200;
printf( " %10.2f", zinsen);

E tilgung = rate – zinsen;


if( tilgung > rest)
tilgung = rest;
printf( " %10.2f", tilgung);
F rest = rest – tilgung;

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");
}
}

Listing 4.3 Vollständige Berechnung des Tilgungsplans

89
4 Arithmetik

Dazu einige Erklärungen:

(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.

(F) Der Darlehensrest wird um die Tilgung gemindert.

(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

Im Anschluss wird dann der zugehörige Tilgungsplan erzeugt:

90
4.1 Folgen

Tilgungsplan:

Monat Zinssatz Zinsen Tilgung Sondertilg Rest


1 6.50 541.67 2458.33 0.00 97541.66
2 6.50 528.35 2471.65 0.00 95070.02
3 6.50 514.96 2485.04 0.00 92584.98 4
4 6.50 501.50 2498.50 0.00 90086.48
5 6.50 487.97 2512.03 0.00 87574.45
6 6.50 474.36 2525.64 0.00 85048.80
7 6.50 460.68 2539.32 0.00 82509.48
8 6.50 446.93 2553.07 0.00 79956.41
9 6.50 433.10 2566.90 0.00 77389.51
10 6.50 419.19 2580.81 0.00 74808.70
11 6.50 405.21 2594.79 0.00 72213.91
12 6.50 391.16 2608.84 10000.00 59605.07
13 8.00 397.37 2602.63 0.00 57002.44
14 8.00 380.02 2619.98 0.00 54382.45
15 8.00 362.55 2637.45 0.00 51745.00
16 8.00 344.97 2655.03 0.00 49089.97
17 8.00 327.27 2672.73 0.00 46417.23
18 8.00 309.45 2690.55 0.00 43726.68
19 8.00 291.51 2708.49 0.00 41018.20
20 8.00 273.45 2726.55 0.00 38291.65
21 8.00 255.28 2744.72 0.00 35546.93
22 8.00 236.98 2763.02 0.00 32783.91
23 8.00 218.56 2781.44 0.00 30002.46
24 8.00 200.02 2799.98 10000.00 17202.48
25 8.00 114.68 2885.32 0.00 14317.16
26 8.00 95.45 2904.55 0.00 11412.61
27 8.00 76.08 2923.92 0.00 8488.70
28 8.00 56.59 2943.41 0.00 5545.29
29 8.00 36.97 2963.03 0.00 2582.26
30 8.00 17.22 2582.26 0.00 0.00

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

Das setzen wir jetzt einfach so fort:

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

Abbildung 4.1 Entwicklung der Folge

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;

printf( "Bitte Zahl eingeben: ");


scanf( "%f", &a);

w = a;
for( i = 0; i < 10; i++)
{
w = (w + a/w)/2;
printf( "%f\n", w);
}
}

Listing 4.4 Berechnung der Wurzel durch Iteration

Dieses Programm arbeitet dann wie folgt:

Bitte Zahl eingeben: 10


5.500000
3.659091
3.196005
3.162456
3.162278
3.162278
3.162278
3.162278
3.162278
3.162278

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?

Die Mathematik sagt4:

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⎠

Aus dieser Gleichung folgt unmittelbar:

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

abbrechen können. Der Schleifenzähler wird nicht mehr benötigt:

void main()
{
float a, w;

printf( " Bitte Zahl eingeben: ");


scanf( "%f", &a);

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

if( w*w – a < 0.001)


break;
}
}

Listing 4.5 Iteration mit Abbruchkriterium

Jetzt bricht das Programm ab, sobald die geforderte Genauigkeit erreicht ist:

Bitte Zahl eingeben: 10


5.500000
3.659091
3.196005
3.162456

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.

4.2 Summen und Produkte


Am Ende des vorangegangenen Kapitels hatte ich Ihnen die Aufgabe gestellt zu
berechnen, aus wie vielen Steinen die folgende Legotreppe besteht, wenn Sie von
einer beliebigen Höhe h ausgehen:

1
2
3
4

Abbildung 4.2 Legotreppe

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;

for ( h=1; h<= max; h= h + 1)


{
steine = steine + h;
printf(" Hoehe: %d, Steine = %d\n", h, steine);
}
}

Listing 4.6 Berechnung der Treppenelemente

Mit dem Programm erhalten Sie das folgende Ergebnis:

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

Abbildung 4.3 Zwei angeordnete Legotreppen

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

Dieser Formel, der sogenannten gaußschen Summenformel, werden Sie häufiger


begegnen, da sie eine wichtige Rolle bei der Beurteilung von Algorithmen spielt.

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

Auch diese Folge

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

können wir durch ein C-Programm berechnen:


4
void main()
{
int max = 9;
int f = 1;
int n;

for ( n=1; n<= max; n= n + 1)


{
f = f * n;
printf(" %d! = %d\n", n,f);
}
}

Listing 4.7 Berechnung der Fakultäten

Das Programm erzeugt diese Ausgabe:

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

schraube« für Oberstudienräte und Studienräte, um andere Verteilungen zu


erreichen!

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.

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ü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:

immunn+1 = immunn + immunisierungsrate · krankn

Der Rest der Population ist krank.

krankn+1 = x – gesundn+1 – immunn+1

Die Proportionalitätsfaktoren (Infektionsrate und Immunisierungsrate) hän-


gen dabei von medizinisch-sozialen Faktoren wie Art der Krankheit, hygieni-
sche Bedingungen, Bevölkerungsdichte, medizinische Versorgung etc. ab und

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

Abbildung 4.4 Epidemieverlauf

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.

Zur Durchführung des Verfahrens werden die Stimmergebnisse der Parteien


fortlaufend durch die Zahlen 1, 2, 3, 4, ... dividiert. Sind n Sitze im Parlament zu

102
4.3 Aufgaben

vergeben, werden die n größten Divisionsergebnisse ausgewählt, und die


zugehörigen Parteien erhalten für jede ausgewählte Zahl einen Sitz. Das fol-
gende Beispiel zeigt das Ergebnis einer Wahl mit drei Parteien und 200000
abgegebenen Stimmen, bei der zehn Sitze zu vergeben waren:

Partei A Partei B Partei C 4


Stimmen 100000 80000 20000

1 100000 80000 20000

2 50000 40000 10000

3 33333 26666 6666

4 25000 20000 5000

5 20000 16000 4000

6 16666 13333 3333

7 14285 11429 2857

8 12500 10000 2500

Sitze 5 4 1

Tabelle 4.1 Stimmen und Sitzverteilung nach d’Hondt

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

Abbildung 4.5 Zahlenkreis

103
4 Arithmetik

Bestimmen Sie diese Ziffern (1 bis 9) so, dass folgende Bedingungen erfüllt
werden:

왘 Aa, Bb, Cc, Dd, Ee, Ff, Gg und Hh sind Primzahlen.


왘 ABC ist ein Vielfaches von Aa.
왘 abc ist gleich cba.
왘 CDE ist Produkt von Cc mit der Quersumme von CDE.
왘 Bb ist gleich der Quersumme von cde.
왘 EFG ist ein Vielfaches von Aa.
왘 efg ist Produkt von Aa mit der Quersumme von efg.
왘 GHA ist Produkt von eE mit der Quersumme von ABC.
왘 Die Quersumme von gha ist Cc.

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

und auf dem Bildschirm ausgibt!

A 4.9 Wichtige mathematische Funktionen können näherungsweise durch Sum-


men (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


Abschnitt 4.2, »Summen und Produkte«.

Erstellen Sie auf diesen Formeln basierende Berechnungsprogramme für


Sinus, Cosinus und e-Funktion! Überprüfen Sie die Ergebnisse Ihrer Pro-
gramme mit einem Taschenrechner!

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.

Abbildung 4.6 Pyramiden

Versuchen Sie, auch explizite Formeln für den Steinverbrauch herzuleiten.


Vergleichen Sie die iterativ berechneten Ergebnisse mit den durch die explizi-
ten Formeln gegebenen Zahlen.

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:

Abbildung 5.1 Anzeige wegen 40 Cent (aus der NRZ)

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.

Konkrete Aussagen sind z. B.:

»Köln liegt in Deutschland.«


»Köln hat mehr als 1 Mio. Einwohner.«

Keine Aussagen im Sinne unserer Begriffsbildung sind dagegen:

»Guten Tag, meine Damen und Herren!«


»Wie spät ist es?«

Bei der Programmierung haben wir es nicht mit umgangssprachlichen Aussagen,


sondern mit präzise formulierten Aussagen in einer Programmiersprache zu tun wie
»wert < 10« oder »a + b < c«. Wir wollen uns deshalb auch nicht zu weit auf das glatte
Eis umgangssprachlicher Aussagen hinausbegeben.

5.2 Aussagenlogische Operatoren


Durch die Aussagenlogik möchten wir nicht ergründen, ob eine Aussage wirklich
wahr oder falsch ist. Im Falle der Aussage über die Einwohnerzahl Kölns wäre dafür
auch eher das Einwohnermeldeamt als die Logik zuständig. Wir möchten aus ele-
mentaren Aussagen, deren Wahrheitswert wir als gegeben annehmen, komplexere
Aussagen zusammensetzen und uns Gedanken über den Wahrheitswert dieser
neuen Aussagen machen.

Eine zusammengesetzte Aussage ist etwa:

»Köln liegt in Deutschland und Köln hat mehr als 1 Mio. Einwohner.«

108
5.2 Aussagenlogische Operatoren

Der Wahrheitswert dieser zusammengesetzten Aussage hängt von den Wahrheits-


werten der Einzelaussagen ab. Unser Sprachgefühl sagt uns, dass die Gesamtaussage
richtig ist, wenn beide Teilaussagen richtig sind. Kennen wir also den Wahrheitswert
der Teilaussagen, kennen wir auch den Wahrheitswert der Gesamtaussage.
Das Wort »und«, mit dem wir die Teilaussagen verbunden haben, ist ein sogenannter
logischer Operator. Unsere Sprache kennt viele weitere solcher Operatoren, die wir
täglich benutzen, ohne uns vielleicht jemals deren genaue Bedeutung klargemacht zu 5
haben. Als ein Beispiel betrachten wir den Operator »während«. Diesen Operator
benutzen wir in verschiedenen Bedeutungen, zum einen, um einen schwachen
Gegensatz, zum anderen, um einen gleichzeitigen Verlauf auszudrücken. Die Aussage
»Mein Auto ist rot, während dein Auto grün ist.«
heißt eigentlich nichts anderes als: »Mein Auto ist rot und dein Auto ist grün«. Dies
allerdings mit dem deutlichen Zusatz: »Man beachte den feinen Unterschied«.
Die Aussage
»Es regnete, während ich im Kino war.«
dagegen beschreibt den zeitlichen Verlauf zweier Ereignisse. Im ersten Fall macht der
Operator »während« einen subtilen Zusatz, der oft nur aus dem Zusammenhang und
für eingeweihte Zuhörer zu verstehen ist. Im zweiten Fall kann der Wahrheitswert
der Gesamtaussage nicht aus den Wahrheitswerten der einzelnen Aussagen abgelei-
tet werden, weil der Operator eine Zusatzaussage über die zeitliche Parallelität der
Einzelaussagen macht. Beide Varianten des Operators »während« sind für unsere
Zwecke ungeeignet, denn wir wollen hier nur Operatoren behandeln, bei denen sich
der resultierende Wahrheitswert zweifelsfrei aus den Wahrheitswerten der beteilig-
ten Einzelaussagen herleiten lässt.
Weitere Beispiele für umgangssprachliche Operatoren sind:
왘 nicht ...
왘 ... oder ...
왘 weder ... noch ...
왘 wenn ... dann ...
왘 zwar ... aber ...
왘 entweder ... oder ...
왘 sowohl ... als auch ...

Mit umgangssprachlichen Formulierungen sind wir wegen der häufig auftretenden


Fehlinterpretationen nicht zufrieden. Wir werden daher im Folgenden einige Präzi-
sierungen vornehmen müssen.
Zunächst benutzen wir für die Wahrheitswerte »wahr« bzw. »falsch« die Symbole 1
bzw. 0. Für Aussagen setzen wir Großbuchstaben A, B, C etc. oder A1, A2. »Die Aussage

109
5 Aussagenlogik

A ist wahr« heißt dann in Formelschreibweise A = 1. Umgekehrt heißt A = 0: »Die


Aussage A ist falsch«.
Die drei wichtigsten Operatoren sind sicher: nicht, und und oder. Da die Operanden
eines logischen Operators nur die Werte 0 oder 1 annehmen, können wir einen logi-
schen Operator durch eine Tabelle vollständig beschreiben: Eine solche Tabelle nen-
nen wir Wahrheitstafel. Über solche Wahrheitstafeln werden wir jetzt die drei
wichtigsten Operatoren einführen. Dabei handelt es sich um: nicht, und und oder.
Die Aussage »nicht A« ist genau dann wahr, wenn die ursprüngliche Aussage A falsch
ist. Damit ergibt sich für den Nicht-Operator folgende Wahrheitstafel:

A nicht A
0 1
1 0

Tabelle 5.1 Wahrheitstafel für den Nicht-Operator

Anstelle von »nicht A« schreiben wir in Formeln auch A oder !A.


Wir hatten bereits festgestellt, dass eine Und-Aussage genau dann wahr ist, wenn
beide Teilaussagen wahr sind. Den Operator »und« definieren wir also über die fol-
gende Wahrheitstafel:

A B A und B
0 0 0
0 1 0
1 0 0
1 1 1

Tabelle 5.2 Wahrheitstafel für den Und-Operator

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

Tabelle 5.3 Wahrheitstafel für den Oder-Operator

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.

So bedeutet die Frage

»Sollen wir um 8 Uhr oder um 10 Uhr ins Kino gehen?«

in aller Regel:

»Sollen wir entweder um 8 Uhr oder um 10 Uhr ins Kino gehen?«

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

»Haben Sie Ihren Pass oder Ihren Personalausweis dabei?«,

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

importieren darf, ist die Frage, ob

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

Abbildung 5.2 Wahrheitstafel eines zusammengesetzten Ausdrucks

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

Tabelle 5.4 Wahrheitstafel gleichwertiger Ausdrücke

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

Tabelle 5.5 Wahrheitstafel für den Äquivalenzoperator

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

Abbildung 5.3 Wichtige Tautologien

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

Tabelle 5.6 Wahrheitstafel des Implikationsoperators

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:

Wenn Köln 1 Mio. Einwohner hat, dann liegt Köln in Deutschland,

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.

Aber wer formuliert schon so gestelzt?

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.

5.3 Boolesche Funktionen


Bevor wir uns wieder der Programmierung zuwenden, wollen wir uns Gedanken dar-
über machen, wie weit die logischen Operatoren uns denn tragen. Ein logischer Ope-
rator realisiert eine Funktion, und die Wahrheitstafel ist eigentlich nur eine
vollständige Wertetabelle dieser Funktion. Wenn Sie sich z. B. die Funktion

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.

a1 a2 a3 a4 z = f(a1, a2, a3, a4 )


0 0 0 0 1 Dies ist eine vierstellige
boolesche Funktion.
0 0 0 1 0
0 0 1 0 1
Hier stehen alle
möglichen Wert- 0 0 1 1 1
kombinationen für 0 1 0 0 0
die Eingabeparameter. Dies ist ein
0 1 0 1 0 Funktionsergebnis
0 1 1 0 0 z = f(0, 1, 1, 0).
0 1 1 1 1
1 0 0 0 1
1 0 0 1 0
1 0 1 0 1
1 0 1 1 1
1 1 0 0 0
1 1 0 1 0
1 1 1 0 0
1 1 1 1 1

Abbildung 5.5 Eine vierstellige 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

Abbildung 5.6 Anzahl n-stelliger boolescher Funktionen

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.

Wir betrachten das Kugelspiel aus dem letzten Kapitel.

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

Abbildung 5.7 Darstellung des Kugelspiels

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

wichtiger Aspekt beim Entwurf digitaler Schaltungen – ohne Computerunterstüt-


zung könnte man solche Schaltungen heute nicht mehr entwickeln. Die wichtige
Erkenntnis für uns ist ja, dass wir jede boolesche Funktion – und sei sie noch so kom-
plex – mit unseren drei Grundoperatoren2 realisieren können.
Vielleicht finden wir ja eine Ad-hoc-Vereinfachung, wenn wir noch einmal einen
Blick auf das Spiel werfen:
5
0 1

0 1 0 1

B C

0 1

D
1 0

Abbildung 5.8 Suche nach Vereinfachungen

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

Im nächsten Abschnitt erfahren Sie, wie Sie in C boolesche Funktionen erstellen,


danach werden wir dieses Spiel programmieren.

5.4 Logische Operatoren in C


Als Variable für boolesche Werte können Sie in C einfach int verwenden. Als logische
Operatoren gibt es nur das »nicht«, das »und« und das »oder«, aber Sie wissen ja

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 ||

Tabelle 5.7 Logische Operatoren in C

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:

z = A BCD ∨ ABCD ∨ AB CD ∨ ABCD ∨ ABC D ∨ ABCD ∨ ABCD ∨ ABCD

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

for( A = 0; A <= 1 ; A++)


{
for( B = 0; B <= 1 ; B++)
{
for( C = 0; C <= 1 ; C++)
{
for( D = 0; D <= 1 ; D++)
5
{
z1 = A&&B || (A&&!B || !A&&C) && D;
z2 = !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;
printf( "%d %d %d %d | %d %d\n", A, B, C, D, z1, z2);
}
}
}
}
}

Listing 5.1 Erstellung aller Kombinationen

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:

왘 A durchläuft die Werte 0 und 1.


왘 Für jeden Wert von A durchläuft dann B die Werte 0 und 1. Damit ergeben sich alle
Wertekombinationen von A und B.
왘 Für jede Wertekombination von A und B durchläuft dann C die Werte 0 und 1.
Damit ergeben sich alle Dreierkombinationen.
왘 Für jede Dreierkombination durchläuft dann D die Werte 0 und 1. Damit ergeben
sich alle Viererkombinationen.

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

Abbildung 5.9 Berechnete Werte für das Kugelspiel

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

Abbildung 5.10 Beispiel für eine Lampenschaltung

Wir verwenden für jeden der Schalter S1–7 eine Variable, die jeweils die Werte 0 oder
1 annehmen kann. Dabei bedeutet:

1 – Der Schalter ist geschlossen.


0 – Der Schalter ist geöffnet.

122
5.5 Beispiele

Zusätzlich wissen wir:

왘 Hintereinanderliegende Schalter realisieren eine Und-Verbindung.


왘 Parallel liegende Schalter realisieren eine Oder-Verbindung.

Für unsere Schaltung bedeutet dies:

s3 und s4
s1 5
oder und oder
s5
s2
oder und s7
s6

Abbildung 5.11 Umsetzung der Schaltung

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);
}
}
}
}
}
}
}
}

Listing 5.2 Durchlaufen aller Schalterstellungen

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
… … … … … … …

Abbildung 5.12 Ausschnitt aus dem Ergebnis

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

Abbildung 5.13 Veranschaulichung der Lösungsstruktur

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

Tabelle 5.8 Wahrheitstafel des nand-Operators

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

Beachten Sie, dass Sie eine Implikation X ⇒ Y durch X ∨ Y ausdrücken können!


A 5.3 Überprüfen Sie die folgenden Tautologien aus Abschnitt 5.2, »Aussagenlogi-
sche Operatoren« 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
A˄ B ⟺ A˅B A˅ B ⟺ A˄B De Morgansches Gesetz

A˄ A ⟺ 0 A˅ A ⟺ 1

A⟺A

Abbildung 5.14 Übersicht der Tautologien

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

Abbildung 5.15 Schaltung mit gekoppelten Schaltern

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:

1. Mindestens ein Familienmitglied geht zu der Feier.


2. Anton geht auf keinen Fall zusammen mit Doris.
3. Wenn Berta geht, dann geht Claus mit.
4. Wenn Anton und Claus gehen, dann bleibt Berta zu Hause.
5. Wenn Anton zu Hause bleibt, dann geht entweder Doris oder Claus.

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.

Schreiben Sie ein C-Programm, das den Tresor knackt!

A 5.7 Der Wikipedia habe ich das folgende Beispiel einer sogenannten Entschei-
dungstabelle entnommen:

Eine Entscheidungstabelle besteht aus vier Teilbereichen:

E einer Auflistung der zu berücksichtigenden Bedingungen


E einer Auflistung der möglichen Aktionen
E einem Bereich, in dem die möglichen Bedingungskombinationen zusammengestellt sind
E einem Bereich, in dem jeder Bedingungskombination die jeweils durchzuführenden
Aktivitäten zugeordnet sind

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

Abbildung 5.16 Entscheidungstabelle zur Umsetzung in C

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

Abbildung 6.1 Zahlenraten

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:

Abbildung 6.2 Zahlendarstellung und Gruppierung

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.

Heute werden zur Darstellung von Zahlen sogenannte Stellenwertsysteme verwen-


det. Im Alltag nutzen wir das Dezimalsystem:

Ziffernwerte

4711 = 4 · 103 + 7 · 102 + 1 · 101 + 1 · 100

Stellenwerte

Abbildung 6.3 Darstellung im Dezimalsystem

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

Abbildung 6.4 Darstellung im 7er-System

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

Abbildung 6.6 Vergleich des Dezimalsystems und des 7er-Systems

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

konkreten Rechenprozesse des Alltags (z. B. Münzen und Geldscheine im Zahlungs-


verkehr) auf dieses System ausgerichtet sind. Sie müssen in den verschiedenen Stel-
lenwertsystemen nicht perfekt rechnen können, sollten aber zumindest von einem
Stellenwertsystem in ein anderes umrechnen können.

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;

printf( "Basis: %d\n", basis);


printf( "-------------------\n");

B for( z = zahl ; z != 0; z = z/basis)


C printf( "%4d = %4d*%d + %2d\n", z, z/basis, basis, z%basis);

printf( " -------------------\n\n");


}

Listing 6.1 Programm zur Umrechnung auf andere Basen

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

Basis: 2 Basis: 3 Basis: 4 Basis: 5


----------------- ----------------- ----------------- -----------------
4711 = 2355*2 + 1 4711 = 1570*3 + 1 4711 = 1177*4 + 3 4711 = 942*5 + 1
2355 = 1177*2 + 1 1570 = 523*3 + 1 1177 = 294*4 + 1 942 = 188*5 + 2
1177 = 588*2 + 1 523 = 174*3 + 1 294 = 73*4 + 2 188 = 37*5 + 3
588 = 294*2 + 1 174 = 58*3 + 0 73 = 18*4 + 1 37 = 7*5 + 2
294 = 147*2 + 0 58 = 19*3 + 1 18 = 4*4 + 2 7 = 1*5 + 2
147 = 73*2 + 1 19 = 6*3 + 1 4 = 1*4 + 0 1 = 0*5 + 1
73 = 36*2 + 1 6 = 2*3 + 0 1 = 0*4 + 1
36 = 18*2 + 0 2 = 0*3 + 2
18 = 9*2 + 0
9 = 4*2 + 1 6
4 = 2*2 + 0
2 = 1*2 + 0
1 = 0*2 + 1

Basis: 6 Basis: 7 Basis: 8 Basis: 9


----------------- ----------------- ----------------- -----------------
4711 = 785*6 + 1 4711 = 673*7 + 0 4711 = 588*8 + 7 4711 = 523*9 + 4
785 = 130*6 + 5 673 = 96*7 + 1 588 = 73*8 + 4 523 = 58*9 + 1
130 = 21*6 + 4 96 = 13*7 + 5 73 = 9*8 + 1 58 = 6*9 + 4
21 = 3*6 + 3 13 = 1*7 + 6 9 = 1*8 + 1 6 = 0*9 + 6
3 = 0*6 + 3 1 = 0*7 + 1 1 = 0*8 + 1

Abbildung 6.7 Ausgabe für die Basen 2 bis 9

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
------------------

Abbildung 6.8 Ausgabe für die Basis 13

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

Abbildung 6.9 Organisation der Speicherzellen

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

001 001 001 100 1112 = 0 · 214 + 0 · 213 + 1 · 212 +


0 · 211 + 0 · 210 + 1 · 29 +
0 · 28 + 0 · 27 + 1 · 26 +
1 · 25 + 0 · 24 + 0 · 23 +
1 · 22 + 1 · 21 + 1 · 20

Jetzt klammere ich in jeder Zeile die höchste vorkommende Zweierpotenz aus:

001 001 001 100 1112 = (0 · 22 + 0 · 21 + 1) · 212 +


(0 · 22 + 0 · 21 + 1) · 29 +
(0 · 22 + 0 · 21 + 1) · 26 +
(1 · 22 + 0 · 21 + 0) · 23 +
(1 · 22 + 1 · 21 + 1) · 20

In den Klammern stehen jetzt Ziffernwerte zwischen 0 und 7. Die rechnen wir aus:

001 001 001 100 1112 = 1 · 212 +


1 · 29 +
1 · 26 +
4 · 23 +
7 · 20

Da aber 23 = 8 ist, können wir auch schreiben:

001 001 001 100 1112 = 1 · 84 +


1 · 83 +
1 · 82 +
4 · 81 +
7 · 80 = 111478

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

Dual 000 001 010 011 100 101 110 111

Abbildung 6.10 Darstellung im Oktalsystem

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

Abbildung 6.11 Darstellung im Hexadezimalsystem

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

Abbildung 6.12 Oktal-, Dual- und Hexadezimalsystem in der Gegenüberstellung

Mit dem Hexadezimalsystem und dem Oktalsystem haben wir Zahlendarstellungen


gefunden, die einerseits sehr nah an der internen Zahlendarstellung des Computers
sind und andererseits der menschlichen Auffassung von Zahlen näher kommen als
das Dualsystem. Das liegt daran, dass 8 und 16 die beiden der 10 am nächsten liegen-
den Zweierpotenzen sind.

136
6.2 Bits und Bytes

6.2 Bits und Bytes


Die kleinste Informationseinheit auf einem Digitalrechner bezeichnen wir als Bit2.
Ein Bit kann die logischen Werte 0 (Bit gelöscht) und 1 (Bit gesetzt) annehmen. Alle
Informationen auf einem Rechner, seien es nun Programme oder Daten, sind als Fol-
gen von Bits gespeichert. Den Speicherinhalt eines Rechners können wir zu einem
Zeitpunkt als eine (sehr) lange Folge von Bits betrachten. Um Teile dieser Informatio-
nen gezielt ansprechen und manipulieren zu können, muss der Bitfolge eine Struk-
tur gegeben werden. Zunächst fassen wir jeweils acht Bits zusammen und nennen 6
diese Informationsgröße ein Byte.

Höchstwertigstes oder Niederwertigstes oder


most significant Bit least significant Bit
Byte
Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0
0 1 1 0 1 0 0 1
7 6 5 4 3 2 1
2 2 2 2 2 2 2 20
Stellenwerte

Abbildung 6.13 Darstellung eines 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

Abbildung 6.14 Adress- und Datenbus

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

Breite Adressierbare Zellen

4 Bit 24 16

8 Bit 28 256

16 Bit 216 65536

32 Bit 232 4294967296

64 Bit 264 1,84467 · 1019 6

Tabelle 6.1 Busbreite und adressierbare Zellen

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.

Physik (SI-Präfixe) Informatik (Binärpräfixe) rel. Abweichung

Kilo 103 210 Kibi 2,40 %

Mega 106 220 Mebi 4,86 %

Giga 109 230 Gibi 7,37 %

Tera 1012 240 Tebi 9,95 %

Peta 1015 250 Pebi 12,59 %

Exa 1018 260 Exbi 15,29 %

Tabelle 6.2 Vergleich von SI- und Binärpräfixen

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.

6.3 Skalare Datentypen in C


Sie wissen bereits, dass jede Variable in C einen Typ haben muss. Bisher haben Sie die
Typen int und float kennengelernt. In diesem Abschnitt kommen weitere soge-

139
6 Elementare Datentypen und ihre Darstellung

nannte skalare Datentypen hinzu. Unter einem skalaren Datentyp verstehen wir
einen Datentyp zur Darstellung eindimensionaler numerischer Werte.

6.3.1 Ganze Zahlen


Es gibt eine Reihe von Datentypen für ganze Zahlen. Betrachten Sie dazu das folgende
Diagramm3:

Syntaxgraph char

signed short

int

unsigned long long

Abbildung 6.15 Syntaxgraph für Datentypen

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;

Zu Beginn der Typvereinbarung können Sie festlegen, ob es sich um einen vorzei-


chenbehafteten (signed) oder vorzeichenlosen (unsigned) Typ handelt. Wenn Sie an
dieser Stelle nichts angeben, wird ein vorzeichenbehafteter Typ angelegt. Aus diesem
Grund ist die explizite Angabe von signed überflüssig und kommt in C-Programmen
nur sehr selten vor. Danach folgt der eigentliche Datentyp, wobei die verschiedenen
Varianten (char, short, int, long, long long) sich dadurch unterscheiden, wie viele
Bytes der Datentyp im Speicher belegt. Das gegebenenfalls noch hinter short oder
long zusätzlich vorkommende int ist ohne Bedeutung und wird daher auch nur sehr
selten verwendet. Viele der theoretisch möglichen Kombinationen werden Sie nie in
einem C-Programm finden. Die wichtigsten Typen sind sicherlich char und int
zusammen mit ihren unsigned-Varianten. Das liegt daran, dass char der Typ ist, der
den wenigsten Platz belegt, und int der Typ ist, mit dem der Rechner am schnellsten

3 So etwas nennt man einen Syntaxgraphen.

140
6.3 Skalare Datentypen in C

rechnen kann. Speicherverbrauch und Rechengeschwindigkeit sind eben die wich-


tigsten Kriterien bei der Programmoptimierung.

C legt sich bezüglich einer Anzahl der Bytes bei den Datentypen nur auf eine Min-
destgröße fest:

Datentyp Mindestgröße typische Größe

char 1 Byte 1 Byte


6
short 2 Bytes 2 Bytes

int 2 Bytes 4 Bytes

long 4 Bytes 4 Bytes

long long 8 Bytes 8 Bytes

Tabelle 6.3 Datentypen und deren Größen

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:

printf( "char: %d\n", sizeof( char));


printf( "short: %d\n", sizeof( short));
printf( "int: %d\n", sizeof( int));
printf( "long: %d\n", sizeof( long));
printf( "long long: %d\n", sizeof( long long));

Listing 6.2 Größe unterschiedlicher ganzzahliger Datentypen

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

Größe min max min max

1 Byte –128 127 0 255

2 Bytes –32768 32767 0 65535

4 Bytes –2147483648 2147483647 0 4294967295

8 Bytes –9,2234E+18 9,2234E+18 0 1,84467E+19

Allgemein

n Bit –2n–1 2n–1 – 1 0 2n – 1

Tabelle 6.4 Rechenbereiche der Datentypen

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:

왘 Wie schreiben wir eine Zahlkonstante in einem bestimmten Format im Quellcode?


왘 Wie lesen wir eine Zahl in einem bestimmten Format von der Tastatur ein?
왘 Wie schreiben wir eine Zahl in einem bestimmten Format auf dem Bildschirm?

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 ("%..."):

Zahlenformat Eingabe Ausgabe Präfix

Dezimalsystem "%d" "%d" (kein Präfix)

Oktalsystem "%o" "%o" 0

Hexadezimalsystem "%x" "%x" 0x

Tabelle 6.5 Zahlenformate bei Ein- und Ausgabe

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.

unsigned int zahl;

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);

Listing 6.3 Ausgabe von Zahlen in unterschiedlicher Darstellung

Diese wird dann in verschiedenen Darstellungen ausgegeben:

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:

unsigned int zahl;

printf( "Dezimaleingabe: ", &zahl);

143
6 Elementare Datentypen und ihre Darstellung

scanf( "%d", &zahl);


printf( "Dezimalausgabe: %d\n", zahl);
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n\n\n", zahl);

printf( "Oktaleingabe: ", &zahl);


scanf( "%o", &zahl);
printf( "Dezimalausgabe: %d\n", zahl);
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n\n\n", zahl);

printf( "Hexadezimaleingabe: ", &zahl);


scanf( "%x", &zahl);
printf( "Dezimalausgabe: %d\n", zahl);
printf( "Oktalausgabe: %o\n", zahl);
printf( "Hexadezimalausgabe: %x\n\n\n", zahl);

Listing 6.4 Einlesen von Werten in unterschiedlichen Formaten

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:

Datentyp typische Größe

float 1 Byte einfache Genauigkeit

Tabelle 6.6 Typvarianten der Gleitkommazahlen

144
6.3 Skalare Datentypen in C

Datentyp typische Größe

double 2 Bytes doppelte Genauigkeit

long double 4 Bytes besonders hohe Genauigkeit

Tabelle 6.6 Typvarianten der Gleitkommazahlen (Forts.)

Wertebereich und Genauigkeit der verschiedenen Gleitkommatypen sind im Stan-


dard nicht festgelegt. Der Speicherplatzbedarf auf einem konkreten Zielsystem lässt 6
sich wieder über ein kleines Programm ermitteln:

printf( "float: %d\n", sizeof( float));


printf( "double: %d\n", sizeof( double));
printf( "long double %d\n", sizeof( long double));

Listing 6.5 Größe unterschiedlicher Gleitkommadatentypen

Hier erhalten wir die folgende Ausgabe:

float 4
double 8
long double 8

Die Ergebnisse dieses Programms sind aber, wie schon bei den Ganzzahltypen,
maschinenabhängig.

Zur Eingabe konkreter Gleitkommazahlenwerte verwenden Sie die vom Taschen-


rechner her bekannte technisch-wissenschaftliche Notation mit Vorzeichen, Man-
tisse und Exponent:

Vorzeichen Mantisse E oder e Vorzeichen Exponent

–12.345 · 10–12

Abbildung 6.16 Bestandteile von Gleitkommazahlen

145
6 Elementare Datentypen und ihre Darstellung

Beispiele:

float a = –1;
float b = 1E2;
double c = 1.234;
long double d = –123.456E-345;

Gleitkommazahlen gibt es nur in Dezimalschreibweise, aber es gibt wieder verschie-


dene Formatanweisungen für die Ein- bzw. Ausgabe.

Typ Eingabe Ausgabe

float "%f" "%f"

double "%lf" "%lf"

long double "%Lf" "%LF"

Tabelle 6.7 Formatanweisungen für Gleitkommazahlen

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.:

왘 Setze gezielt ein bestimmtes Bit.


왘 Lösche gezielt ein bestimmtes Bit.
왘 Invertiere gezielt ein bestimmtes Bit.

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

mustern, die Sie im Folgenden kennenlernen werden. Diese Operationen werden


vorrangig auf vorzeichenlosen Zahlen (unsigned char, unsigned short, unsigned int,
unsigned long und unsigned long long) durchgeführt, und in diesem Kontext werden
wir diese Operationen auch ausschließlich betrachten.

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

Abbildung 6.17 Bitweises Komplement

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

Abbildung 6.18 Bitweises Und

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

Abbildung 6.19 Bitweises Oder

Zusätzlich gibt es das bitweise Entweder-Oder, das eine ausschließende Oder-Opera-


tion durchführt:

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

Abbildung 6.20 Bitweises Entweder-Oder

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

Abbildung 6.21 Bitshift rechts

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

Abbildung 6.22 Bitshift links

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:

Eine 1 wird um drei Positionen nach


links geschoben und über ein bitweises
Oder mit x verknüpft. Dadurch wird das
dritte Bit in x gesetzt.
Setzen des dritten Bits in x
1 0 0 0 0 0 0 0 1
1<<3 0 0 0 0 1 0 0 0
x 1 0 1 1 0 0 1 1
x|(1<<3) 1 0 1 1 1 0 1 1

Abbildung 6.23 Setzen des dritten Bits in x

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

Zum Löschen eines Bits verwenden Sie das bitweise Und:

Eine 1 wird um vier Positionen nach links


geschoben, komplementiert und dann
über ein bitweises Und mit x verknüpft.
Dadurch wird das vierte Bit in x gelöscht.
Löschen des vierten Bits in x
1 0 0 0 0 0 0 0 1
6
1<<4 0 0 0 1 0 0 0 0
~(1<<4) 1 1 1 0 1 1 1 1
x 1 0 1 1 0 0 1 1
x&~(1<<4) 1 0 1 0 0 0 1 1

Abbildung 6.24 Löschen des vierten Bits in x

Um ein Bit zu invertieren, verwenden Sie das bitweise Entweder-Oder:

Eine 1 wird um fünf Positionen nach links


geschoben und über ein bitweises
Entweder-Oder mit x verknüpft. Dadurch
wird das fünfte Bit in x invertiert.
Invertieren des fünften Bits in x
1 0 0 0 0 0 0 0 1
1<<5 0 0 1 0 0 0 0 0
x 1 0 1 1 0 0 1 1
x^(1<<5) 1 0 0 1 0 0 1 1

Abbildung 6.25 Invertieren des fünften Bits in x

Wir fassen das noch einmal zusammen:

int n = 3;
unsigned int x = 0xaffe;

x = x | (1<<n); // Setzen des n-ten Bits in x


x = x & ~(1<<n); // Loeschen des n-ten Bits in x
x = x ^ (1<<n); // Invertieren des n-ten Bits in x

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;

if( x & (1<<n)) // Test, ob das n-te Bit in x gesetzt ist


{
...
}

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

Abbildung 6.26 Die Auflösung des Kartentricks

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.

Das können wir in einem Programm umsetzen:

void main()
{
int bit, z, zahl, antwort;

printf( "Denk dir eine Zahl zwischen 0 und 31\n");

A for( bit = 1, zahl = 0; bit < 32; bit = bit << 1)


{
printf( "Ist die Zahl in dieser Liste");
B for( z = 0; z < 32; z++)
{

151
6 Elementare Datentypen und ihre Darstellung

C if( z & bit)


printf( " %d", z);
}
printf( ":");
scanf( "%d", &antwort);
if( antwort == 1)
D zahl = zahl | bit;
}

printf( "Die Zahl ist %d\n", zahl);


}

Listing 6.6 Das Kartenraten als Programm

(A) Die Variable bit durchläuft in der Schleife die Werte:

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).

Am Beispiel der Zahl 21 ergibt sich dann folgendes Ablaufprotokoll:

Denk dir eine Zahl zwischen 0 und 31


Ist die Zahl in dieser Liste 1 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31: 1
Ist die Zahl in dieser Liste 2 3 6 7 10 11 14 15 18 19 22 23 26 27 30 31: 0
Ist die Zahl in dieser Liste 4 5 6 7 12 13 14 15 20 21 22 23 28 29 30 31: 1
Ist die Zahl in dieser Liste 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31: 0
Ist die Zahl in dieser Liste 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31:
1

Die Zahl ist 21

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);

B for( zahl = 0, bit = (1<<n-1); bit > 0; bit = (bit>>1))


{
C zahl = zahl | bit;
printf( "Ist die Zahl kleiner als %d: ", zahl);
scanf( "%d", &antwort);
if( antwort == 1)
D zahl = zahl & ~bit;
}

printf( "Die Zahl ist %d\n", zahl);


}

Listing 6.7 Ein abgewandeltes Zahlenraten

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):

2n–1 = 1000 ... 0002


2n–2 = 0100 ... 0002
2n–3 = 0010 ... 0002
...
21 = 0000 ... 0102
20 = 0000 ... 0012

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

Abbildung 6.27 Schema des Addierwerkes 6

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:

c = (s1 & s2) | (s1 & c) | (s2 & c)

Jetzt können Sie das Addierwerk programmieren:

void main()
{
A unsigned int z1, z2;
B unsigned int s, s1, s2, sum, c;

printf( "Gib bitte zwei Zahlen ein: ");


scanf( "%d %d", &z1, &z2);

C for( sum = 0, s = 1, c = 0; s != 0; s = s << 1, c = c << 1)


{
D s1 = z1 & s;
s2 = z2 & s;
E sum = sum | (s1 ^ s2 ^ c);
c = (s1 & s2) | (s1 & c) | (s2 & c);
}

printf( "Summe: %d\n", sum);


}

Listing 6.8 Implementierung des Addierwerkes

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).

Zum Abschluss können Sie das Programm testen:

Gib bitte zwei Zahlen ein: 12345 67890


Summe: 80235

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

Abbildung 6.28 Der ASCII-Zeichensatz

156
6.6 Zeichen

Grundsätzlich unterscheiden wir im ASCII-Code druckbare und nicht druckbare Zei-


chen. Die druckbaren Zeichen (A, B, C, ...) sprechen für sich. Von den nicht druckbaren
Zeichen interessiert uns hier nur das Linefeed-Zeichen (LF), das, wenn man es auf
dem Bildschirm ausgibt, einen Zeilenvorschub erzeugt.
Zeichenkonstanten werden in einfache Hochkommata gesetzt ('a', 'Z'). Für das
nicht druckbare Linefeed-Zeichen verwenden wir die Ersatzdarstellung '\n'. Als Typ
für Zeichenvariablen wird char (oder unsigned char) verwendet. Bei der Ein- bzw. Aus-
gabe einzelner Zeichen wird "%c" als Formatanweisung verwendet:
6
char a, b, c;

A a = 'x';
b = '\n';

printf( "Bitte gib einen Buchstaben ein: ");


scanf( "%c", &c);

C printf( "Ausgabe: %c%c%c\n", a, b, c);

Listing 6.9 Verwendung von char

In dem Programm werden zuerst Zeichenkonstanten in Variablen gespeichert (A),


bevor in einem weiteren Schritt ein Zeichen von der Tastatur eingelesen wird. Zei-
chen werden mit der Formatanweisung %c eingelesen (B) bzw. ausgegeben (C). Dabei
erzeugt die Ausgabe von b einen Zeilenvorschub:

Bitte gib einen Buchstaben ein: Q


Ausgabe: x
Q

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;

for( x = 'A'; x <= 'K'; x = x + 1)


printf( "%d %c\n", x, x);

Listing 6.10 Interpretation von char als Zahlen

157
6 Elementare Datentypen und ihre Darstellung

erhalten wir diese Ausgabe:

65: A
66: B
67: C
68: D
69: E
70: F
71: G
72: H
73: I
74: J
75: K

Da die Nummerierung der Zeichen der alphabetischen Reihenfolge entspricht, kann


dies z. B. genutzt werden, um Zeichen alphabetisch zu sortieren.

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");
}
}

Listing 6.11 Ausgabe des ASCII-Zeichensatzes

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.

z = (zeile << 4) | spalte;

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

dern um einen Mangel an Programmierkenntnissen in C, den wir umgehend beseiti-


gen werden.

6.7.1 Eindimensionale Arrays


Ein Array ist eine Aneinanderreihung von Datenelementen gleichen Typs. Über
einen Index kann auf jedes Datenelement unmittelbar zugegriffen werden.

Wenn wir ein Array benötigen, müssen wir uns drei Fragen stellen:

1. Wie soll das Array heißen?


2. Wie viele Datenelemente soll das Array haben?
3. Welchen Datentyp sollen die Elemente des Arrays haben?

Wenn wir ein Array für fünf ganze Zahlen (int) benötigen, das meinezahlen heißen
soll, schreiben wir im Programm:

Typ der Elemente


meinezahlen
Name des Arrays
Index Wert
Anzahl der Elemente
0 123
int meinezahlen[5];
1 123 + 5 = 128
int i;
2 128 + 2 = 130
meinezahlen[0] = 123;
3 130 + 3 = 133
meinezahlen[1] = meinezahlen[0]+5;
4 133 + 4 = 137
for( i = 2; i < 5; i = i+1)
meinezahlen[i] = meinezahlen[i-1]+i;

for( i = 0; i < 5; i = i+1)


printf( "%d: %d\n", i, meinezahlen[i]); 0: 123
1: 128
2: 130
3: 133
4: 137

Abbildung 6.29 Verwendung eines eindimensionalen Arrays

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:

int meinezahlen[5] = { 1, 2, 3, 4, 5};

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.

In Verbindung mit Zählschleifen ergeben sich vielfältige Verarbeitungsmöglichkei-


ten für Arrays. Insbesondere können Sie das in der Einleitung gestellte Problem (100
Zahlen einlesen und in umgekehrter Reihenfolge wieder ausgeben) elegant lösen:

void main()
{
A int daten[100];
int i;

B for( i = 0; i < 100; i = i+1)


{
printf( "Gib die %d-te Zahl ein: ", i);
scanf( "%d", &daten[i]);
}

C for( i = 99; i >= 0; i = i-1)


printf( "Die %d-te Zahl ist: %d\n", i, daten[i]);
}

Listing 6.12 Einlesen und Ausgeben von 100 Zahlen

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

6.7.2 Mehrdimensionale Arrays


Der Wikipedia habe ich den folgenden Auszug entnommen:

Abbildung 6.30 Entfernungstabelle

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}
};

printf( "Gib zwei Orte (0-4) ein: ");


B scanf( "%d %d", &start, &ziel);

C distanz = entfernung[start][ziel];

D printf( "Entfernung zwischen %d und %d: %d km\n", start,


ziel, distanz);
}

Listing 6.13 Implementierung eines mehrdimensionalen Arrays

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).

Gib zwei Orte (0-4) ein: 0 3


Entfernung zwischen 0 und 3: 9 km

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.

»Zeilen« und »Spalten« sind anschauliche, aber im Grunde genommen irreführende


Begriffe, denn ein zweidimensionales Array ist im Rechner nicht »zweidimensional«
organisiert. Insbesondere werden diese Begriffe dann unbrauchbar, wenn man
Arrays noch höherer Dimension betrachtet, was problemlos möglich ist. Wenn Sie
z. B. über einen Zeitraum von 100 Tagen sekündlich die Temperatur aufzeichnen
wollten, könnten Sie ein vierdimensionales Array verwenden:

Temperaturen sind Gleitkommazahlen.

Temperaturaufzeichnung für 100 Tage

float temperatur[100][24][60][60];

24 Stunden am Tag

60 Minuten pro Stunde

60 Sekunden pro Minute

Abbildung 6.31 Beispielhaftes Array zur Aufzeichnung von Temperaturen

163
6 Elementare Datentypen und ihre Darstellung

Die Temperatur am 5. Tag der Aufzeichnungen um 10 Sekunden nach 14:00 Uhr


erhalten Sie dann mit dem Zugriff:

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:

Abbildung 6.32 Zeitungsmeldung zur deutschen Sprache

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:

왘 'A' ist der Buchstabe A.


왘 "A" ist eine Zeichenkette, die nur den Buchstaben A enthält.

Diese Unterscheidung ist keine Spitzfindigkeit, da es sich um grundsätzlich verschie-


dene Datentypen handelt. Den Datentyp für Zeichenketten werden Sie jetzt kennen-
lernen.

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

zugrunde liegendes Array

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":

scanf( "%s", wort);

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];

fgets( zeile, 100, stdin);

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.

wort[0] = 'A'; // Erster Buchstabe A


wort[1] = wort[0]; // Zweiter Buchstabe auch A
wort[2] = 0; // Terminator, Zeichenkette ist "AA"

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":

printf( "%s", wort);

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

printf( "Wort: ");


B scanf( "%s", wort);

C for( i = 0; wort[i] != 0; i++)


D ;

E printf( "%s hat %d Zeichen\n", wort, i);


}
6
Listing 6.14 Verwendung einer Zeichenkette

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).

Beachten Sie, dass kein & vor dem Variablennamen steht!

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).

Abschließend werden die Zeichenkette und ihre Länge ausgegeben (E):

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;

printf( "Wort: ");


A scanf( "%s", wort);

B for( hinten = 0; wort[hinten] != 0; hinten++)


;

167
6 Elementare Datentypen und ihre Darstellung

C for( vorn = 0, hinten--; vorn < hinten; vorn++, hinten--)


{
D if( wort[vorn] != wort[hinten])
break;
}

E if( vorn < hinten)


F printf( "Kein Palindrom\n");
else
printf( "Palindrom\n");
}

Listing 6.15 Palindromerkennung

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

printf( "Wort: ");


B scanf( "%s", wort);

C for( nochzuraten = 0; wort[nochzuraten] != 0; nochzuraten++)


anzeige[nochzuraten] = '-';
D anzeige[nochzuraten] = 0;

E for( anzahl = 1; nochzuraten != 0; anzahl++)


{ 6
printf( "%s\n", anzeige);
printf( "%d-ter Versuch: ", anzahl);

F scanf( "\n%c", &versuch);

G for( i = 0; wort[i] != 0; i++)


{
H if( (wort[i] == versuch) && (anzeige[i] == '-'))
{
anzeige[i] = versuch;
I nochzuraten--;
}
}
}
printf( "%s\n", anzeige);
printf( "Du hast %d Versuche benoetigt\n", anzahl-1);
}

Listing 6.16 Galgenmännchen

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)
...

Das geht bei Zeichenketten so nicht:

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:

char original[100], kopie[100];


int i;

printf( "Eingabe: ");


scanf( "%s", original);
6
A for( i = 0; original[i] != 0; i++)
kopie[i] = original[i];
B kopie[i] = 0;

printf( "\nOriginal: %s", original);


printf( "\nKopie: %s", kopie);

Listing 6.17 Kopieren von Zeichenketten

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

Im nächsten Programmfragment werden zwei Strings eingelesen und miteinander


verglichen. Das Vergleichsergebnis wird auf dem Bildschirm ausgegeben:

char wort1[100], wort2[100];


int i;

printf( "Wort1: ");


scanf( "%s", wort1);
printf( "Wort2: ");
scanf( "%s", wort2);

A for( i = 0; (wort1[i] != 0) && (wort1[i] == wort2[i]); i++)


;
B if( wort1[i] == wort2[i])
printf( "Die Worte sind gleich\n");
else
printf( "Die Worte sind verschieden");

Listing 6.18 Vergleich von Zeichenketten

171
6 Elementare Datentypen und ihre Darstellung

In dem Programm werden die eingegebenen Zeichenketten Zeichen für Zeichen


geprüft (A). Solange das erste Wort noch nicht beendet ist und die Zeichen im ersten
und zweiten Wort gleich sind, wird weitergeprüft.

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:

Wort1 beendet Wort2 beendet Zeichen gleich Aktion Ergebnis

nein nein nein abbrechen verschieden

nein nein ja weitermachen offen

nein ja nein abbrechen verschieden

nein ja ja kann nicht vorkommen

ja nein nein abbrechen verschieden

ja nein ja kann nicht vorkommen

ja ja nein kann nicht vorkommen

ja ja ja abbrechen gleich

nein nein nein abbrechen verschieden

Tabelle 6.8 Die Testbedingungen im Detail

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;

B for( i = 0; i < 26; i++)


statistik[i] = 0;

printf( "Text: ");


C scanf( "%s", text);

D for( i = 0; text[i] != 0; i++)


{
E if( (text[i] >= 'a') && ( text[i] <= 'z'))
statistik[text[i]-'a']++;
}

printf( "\nAuswertung:\n");
F for( i = 0; i < 26; i++)
printf( "%c: %d\n", 'a' + i, statistik[i]);
}

Listing 6.19 Erstellen einer Buchstabenstatistik

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

in der Ausgabe der Statistik.

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:

Abbildung 6.34 Beispiele von Pangrammen

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

8 Das sind Texte, die alle Zeichen des Alphabets enthalten.

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;

B for( zahl = 1; zahl !=0; )


{
printf( "\n");
C for( zeile = 0; zeile < 9; zeile++)
{
D if( zeile %3 == 0)
printf( "+-------+-------+-------+\n");
E for( spalte = 0; spalte < 9; spalte++)
{
F if( spalte % 3 == 0)
printf( "| ");
if(sudoku[zeile][spalte] > 0)
G printf( "%d ", sudoku[zeile][spalte]);
else
printf( " ");
}
H printf( "|\n");
}
I printf( "+-------+-------+-------+\n");
printf( "Zeile Spalte Zahl: ");
J scanf( "%d %d %d", &zeile, &spalte, &zahl);
K sudoku[zeile][spalte] = zahl;
}
}

Listing 6.20 Sudoku

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).

Der folgende Screenshot zeigt das Programm bei der Arbeit:

+-------+-------+-------+
| 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:

Gib die 1. Zahl ein: 23


Gib die 2. Zahl ein: 17
Gib die 3. Zahl ein: 234
Gib die 4. Zahl ein: 875
Gib die 5. Zahl ein: 328
Gib die 6. Zahl ein: 0
Gib die 7. Zahl ein: 519
Gib die 8. Zahl ein: 712
Gib die 9. Zahl ein: 1000
Gib die 10. Zahl ein: 14

Welche Zahl soll ich ausgeben: 3


Die 3. Zahl ist 234

Welche Zahl soll ich ausgeben: 9


Die 9. Zahl ist 1000

Welche Zahl soll ich ausgeben: 2


Die 2. Zahl ist 17

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:

Wie können Sie umfangreiche Programme noch handhabbar halten?

Die Antwort auf diese Frage ist naheliegend:

Sie müssen ein umfangreiches Programm in kleinere, jeweils noch überschaubare


Einzelteile zerlegen, diese Einzelteile möglichst unabhängig voneinander entwickeln
und dann zusammenfügen. Alle komplexen technischen Produkte – und große Soft-
waresysteme sind vielleicht die komplexesten technischen Produkte, die wir kennen
– entstehen heute auf diese Art.

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:

Ein Rezept für Apfelkuchen


Zutaten:
600 g Hefeteig
1,5 Kilo Äpfel
...

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

meter. Einen Parameter, durch den Informationen vom Unterprogramm zum


Hauptprogramm zurückfließen, bezeichnen wir als Rückgabeparameter.
왘 Konkrete, durch die Parameter der Schnittstelle fließende Daten (z. B. 100 Gramm
Zuckerzugabe) bezeichnen wir als Parameterwerte. Entsprechend der Flussrich-
tung bezeichnen wir die Parameterwerte auch als Eingabewerte oder Rückgabe-
werte.

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.

Betrachten wir konkret die Programmiersprache C. Zu einer Funktion gehören zwei


Dinge:

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

float maximum( float x, float y)

Die Funktion heißt »maximum«

Die Funktion gibt eine Gleitkommazahl zurück

Abbildung 7.1 Die Schnittstelle einer Funktion

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:

A float maximum( float x, float y)


B {
C if( x > y)
D return x;
E return y;
F }

Listing 7.1 Funktion mit Funktionskörper

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.

Die fertige Funktion maximum können Sie jetzt im Hauptprogramm verwenden:

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);
}

Listing 7.2 Verwendung der Funktion maximum im Hauptprogramm 7

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.

int vergleich( float x, int y)


{
if( x == y)
return 1;
return 0;
}

Listing 7.3 Funktion mit verschiedenen Parametern und Rückgabetypen

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;
}

Listing 7.4 Parameterlose Funktion

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;

v = vergleich( 1.0, 17);


if( v == 0)
B return;
C ausgabe();
D }

Listing 7.5 Funktion ohne Rückgabe

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.

7.2 Arrays als Funktionsparameter


Arrays spielen eine Sonderrolle bei der Parameterübergabe an Funktionen. Das hat
damit zu tun, dass Arrays häufig sehr groß sind und eine Übergabe durch Kopieren
des kompletten Arrays sehr ineffizient wäre. Wenn also ein Array an eine Funktion
übergeben wird1, erhält die Funktion Zugriff auf die Originaldaten. Ich zeige Ihnen
das an einem Beispiel:

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 init( int anz, int dat[])


{
int i;
Das Array wird im Hauptprogramm
angelegt. for( i = 0; i < anz; i++)
dat[i] = 2*i;
}

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");
} }

void umkehren( int anz, int dat[])


Die Unterprogramme erhalten die {
Anzahl der Daten und den Zugriff auf int v, h, t;
die Originaldaten.
Die Daten werden im Originalarray for(v = 0,h = 9; v < h; v++,h--)
initialisiert, umgekehrt und ausge- {
t = dat[v];
geben.
dat[v] = dat[h];
dat[h] = t;
}
0 2 4 6 8 10 12 14 16 18
}
18 16 14 12 10 8 6 4 2 0

Abbildung 7.2 Arrays als Funktionsparameter

Die Funktionen sollten Sie mit Ihren bisher erworbenen Programmierkenntnissen


problemlos verstehen können. Wichtig ist, dass den Funktionen die Anzahl der
Datensätze im Array mitgeteilt wird, da diese Information im Array selbst nicht ent-
halten ist. Das Array wird dann mit unbestimmter Größe (dat[]) übergeben. Der
Zugriff erfolgt so, als wäre das Array in der Funktion angelegt, wobei der Durchgriff
auf die Daten im Hauptprogramm erfolgt.

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-

2 Die Begründung dazu folgt ebenfalls später im Zusammenhang mit Zeigern.

187
7 Modularisierung

spiel erstellen wir Funktionen zur Ermittlung der Länge eines Strings und zum
Vergleich zweier Strings:

int stringlaenge( char s[])


{
int i;
void main()
{ for( i = 0; s[i] != 0; i++)
int l, v; ;
return i;
l = stringlaenge( "qwert"); }
printf( "Laenge: %d\n", l);
int stringvergleich( char s1[], char s2[])
v = stringvergleich( "qwert", "qwerz");
{
if ( v == 1)
int i;
printf( "gleich\n");
else
for( i = 0; (s1[i]!=0)&&(s1[i]==s2[i]); i++)
printf( "ungleich\n");
;
}
Laenge: 5 return s1[i] == s2[i];
ungleich }

Abbildung 7.3 Strings als Funktionsparameter

Diese beiden Funktionen sind unkritisch, da die Strings in den Unterprogrammen


nur gelesen und nicht verändert werden. Wenn Sie aber Funktionen schreiben, die
Strings etwa verlängern, stoßen Sie auf eines der schwerwiegendsten Probleme im
Umgang mit C. In Listing 7.6 sehen Sie eine Funktion append, die ein Zeichen an einen
Text anhängt:

A void append( char s[], char c)


{
int i;
B for( i = 0; s[i] != 0; i++)
;
C s[i] = c;
D s[i+1] = 0;
}

Listing 7.6 Die Funktion append

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
}

Listing 7.7 Verwendung von append im Hauptprogramm

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

7.3 Lokale und globale Variablen


Innerhalb einer Funktion können nach Bedarf sogenannte lokale Variablen angelegt
werden. Diese Variablen gehören dann ausschließlich der Funktion. Das rufende Pro-
gramm hat keinen Zugriff auf diese Variablen – genauso wie die Funktion keinen
Zugriff auf die Variablen des rufenden Programms hat. Auch eine zufällige Namens-
gleichheit von Variablen im rufenden und im gerufenen Programm ändert daran
nichts. Lokale Variablen werden automatisch erzeugt, wenn der Kontrollfluss in die
Funktion eintritt, und automatisch wieder beseitigt, wenn der Kontrollfluss die
Funktion verlässt. Solche Variablen heißen deshalb auch automatische Variablen.
Bei jedem Neueintritt in die Funktion werden die Variablen wieder neu erzeugt. Das
heißt, dass auch die Variablen verschiedener Aufrufe der gleichen Funktion nichts
miteinander zu tun haben. Auch die Funktionsparameter sind solche automatischen
Variablen. Sie werden beim Funktionsaufruf mit Kopien der übergebenen Werte
gefüllt und haben dann keine Beziehung mehr zu irgendwelchen Variablen des
Hauptprogramms. Dieses wichtige, auch als Information Hiding bezeichnete Prinzip
ermöglicht erst eine konsequente Modularisierung eines Programms. Eine Funktion
erzeugt sozusagen bei jedem Aufruf eine Blackbox, in die niemand hineinsehen, aus
der aber auch niemand heraussehen kann.

void main()
{
float a = 1, b = 2.3, c;

c = maximum( a, b);
} 2.3
2.3 1

float maximum( float x, float y)


{

Blackbox
}

Abbildung 7.4 Die Funktion als 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;

B int funktion( int x)


{
C int y = 123;

D zaehler++;
E return x+y;
}
7
void main()
{
F int i, x;

for( i = 1; i <= 5; i++)


{
x = funktion( i);
G printf( "%d. Aufruf: %d\n", zaehler, x);
}
}

Listing 7.8 Globale und lokale Variablen

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:

A int fakultaet_iter( int n)


{
int fak;
B for( fak = 1; n > 1; n--)
fak = fak * n;
C return fak;
}

void main()
{
int n;
for( n = 0; n < 10; n++)
D printf( "%d! = %d\n", n, fakultaet_iter( n));
}

Listing 7.9 Iterative Berechnung der Fakultät

In der Funktion wird der in (A) übergebene Parameter n immer wieder mit fak multi-
pliziert und dabei heruntergezählt.

Im Hauptprogramm werden die Fakultäten 1! Bis 9! berechnet und ausgegeben (D),


dabei wird die folgende Ausgabe erzeugt:

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:

int fakultaet_rek( int n)


{
A if( n <= 1)
return 1;
B return n*fakultaet_rek( n-1);
}

void main()
{
int n;
for( n = 0; n < 10; n++)
printf( "%d! = %d\n", n, fakultaet_rek( n));
}

Listing 7.10 Rekursive Berechnung der Fakultät

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

für gleiche Argumente gleiche Ergebnisse. Auch der Implementierungsaufwand ist


identisch. Und doch gibt es einen wesentlichen Unterschied. Wenn Sie Laufzeitpro-
file3 machen, werden Sie feststellen, dass die iterative Implementierung weniger
Laufzeit benötigt als die rekursive. Das rekursive Verfahren stellt sich, insbesondere
für große Werte von n, als deutlich langsamer heraus. Der Grund dafür liegt in den
bei rekursiver Programmierung zusätzlich anfallenden Zeiten für die vielen Unter-
programmaufrufe. Angesichts dieses Ergebnisses kann man sich fragen, wofür denn
Rekursion überhaupt sinnvoll ist, zumal theoretische Untersuchungen zeigen, dass
Rekursion immer vermieden werden kann und gut optimierte iterative Algorithmen
grundsätzlich effizienter arbeiten als ihre rekursiven Gegenstücke. Trotzdem sind
rekursive Techniken von großem Nutzen in der Programmierung. Sie erlauben es oft,
komplizierte Operationen verblüffend einfach zu implementieren. Rekursive Algo-
rithmen werden gern verwendet, wenn man ein Problem durch einen geschickten
Ansatz auf ein »kleineres« Problem der gleichen Struktur zurückführen kann. Dazu
wollen wir ein Beispiel betrachten.

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:

Bringe 5 Ringe von Start


über Tmp nach Ziel

1
2
3
4
5
Start Tmp Ziel

Abbildung 7.5 Aufgabenstellung der Türme von Hanoi

Dabei sind folgende Regeln zu beachten:

왘 Der Spieler darf einen Hilfsständer (Tmp) zur Zwischenablage benutzen.


왘 Es darf in einem Schritt immer nur ein Ring bewegt werden.
왘 Es darf nie ein kleinerer Ring unter einem größeren Ring liegen.

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:

Bringe zunächst 4 Ringe


von Start über Ziel nach Tmp

1
2
3
5 4
Start Tmp Ziel

Abbildung 7.6 Zwischenstand auf dem Weg zur Lösung

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:

Bringe den 5. Ring von


Start nach Ziel

1
2
3
4 5
Start Tmp Ziel

Abbildung 7.7 Ring 5 an seiner Zielposition

Danach können wir noch mal vier Ringe transportieren:

4 Wie das geht, interessiert uns nicht.

195
7 Modularisierung

Dann bringe 4 Ringe von


Tmp über Start nach Ziel

1
2
3
4
5
Start Tmp Ziel

Abbildung 7.8 Die gelöste Aufgabe

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:

A void hanoi( int n, char start, char tmp, char ziel)


{
B if (n > 1)
{
C hanoi( n – 1, start, ziel, tmp);
D printf( "Ring %d: %c -> %c\n", n, start, ziel);
E hanoi( n – 1, tmp, start, ziel);
}
else
F printf( "Ring %d: %c -> %c\n", n, start, ziel);
}

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');
}

Listing 7.11 Die implementierte Funktion hanoi

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

Abbildung 7.9 Alle Handlungsanweisungen zur Lösung als Baum

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.

7.5 Der Stack


Ein tieferes Verständnis für Rekursion gewinnen Sie, wenn Sie sich klarmachen, was
beim Aufruf einer Funktion »unter der Haube« abläuft. Beim Unterprogrammaufruf
spielt der sogenannte Stack eine entscheidende Rolle. Ein Stack ist ein »Stapelspei-
cher«, den Sie sich wie einen Tellerstapel in einem Restaurant vorstellen können.
Neue Informationen (Teller) werden auf den Stapel gelegt und können wieder vom
Stapel entfernt werden. Wichtig ist, dass die Information, die als letzte auf den Stapel
gekommen ist, auch als erste wieder heruntergenommen werden muss. Man
bezeichnet dies auch als Last-In-First-Out- oder kurz LIFO-Prinzip. Stacks haben in
der Informatik große Bedeutung und werden als allgemeine Warteschlangenstruktur
später noch ausführlich behandelt. Hier interessiert uns im Moment nur die Rolle
des Stacks beim Unterprogrammaufruf.
Wenn eine Funktion eine andere ruft, werden zunächst die Aufrufparameter, die ja
durch Formelausdrücke gegeben sein können, ausgewertet. Die Ergebnisse werden
auf den Stack gelegt. Beim Aufruf wird dem Unterprogramm mitgeteilt, wo es seine
Parameter auf dem Stack findet. Das Unterprogramm legt dann seine lokalen Variab-
len zusätzlich auf dem Stack ab. Eine Funktion – oder besser: jede Aufrufinstanz einer

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.

Abbildung 7.10 zeigt dieses Vorgehen am Beispiel der Fakultätsberechnung:

Stack

void main()
{
Jede Aufrufinstanz hat ihre
int k; k:6
eigenen lokalen Variablen
k = fakultaet( 3);
auf dem Stack.
}
6

int fakultaet(int n) n:3


{
int f; f:6 Beim Aufruf werden die
3·2

if( n <= 1)
übergeben Parameter als
return 1;
lokale Variablen auf den
f = n*fakultaet(n-1);
Stack gelegt.
return f;
2
}

int fakultaet(int n) n:2


{
int f; f:2
Der Stack »wächst«, wenn
2·1

if( n <= 1)
die Aufrufinstanz weitere
return 1;
lokale Variablen anlegt.
f = n*fakultaet(n-1);
return f; 1
}

int fakultaet(int n) n:1


{ Beim Verlassen des
int f; f:? Unterprogramms wird der
1

if( n <= 1) Stack »zurückgefahren« und


return 1; alle lokalen Variablen der
f = n*fakultaet(n-1); Aufrufinstanz verschwinden.
return f;
}

Abbildung 7.10 Der Stack

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

Sie wissen, dass allgemein gilt:

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:

int ggt( int a, int b)


{
A for( ; a != b; )
{
B if( a > b)
a = a – b;
else
b = b – a;
}
C return a;
}

Listing 7.12 Berechnung des ggT

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:

void kuerzen( int b[])


{ 7
int gt;
A gt = ggt( b[0], b[1]);
B b[0] = b[0]/gt;
C b[1] = b[1]/gt;
}

Listing 7.13 Funktion zum Kürzen eines Bruchs

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.

void addieren( int b1[], int b2[], int erg[])


{
erg[0] = b1[0]*b2[1] + b2[0]*b1[1];
erg[1] = b1[1]*b2[1];
kuerzen( erg);
}

Listing 7.14 Funktion zum Addieren von Brüchen

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]);
}

Listing 7.15 Der Testrahmen für die Addition

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.

7.6.2 Das Damenproblem


Unser nächstes Beispiel kommt aus dem Bereich des Schachspiels, daher zunächst
einige Vorbemerkungen für Nicht-Schachspieler. Die Dame ist die schlagkräftigste
und spielstärkste Figur im Schach. Von dem Feld, auf dem sie steht, beherrscht sie
alle waagerecht, senkrecht oder diagonal erreichbaren Felder.

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:

왘 höchstens (genau) eine Dame in jeder Zeile


왘 höchstens (genau) eine Dame in jeder Spalte
왘 höchstens eine Dame in jeder Diagonalen

202
7.6 Beispiele

D
7

Abbildung 7.11 Die Zugmöglichkeiten der Dame

Im klassischen Fall des 8×8-Schachbretts gibt es 92 verschiedene Lösungen, von


denen eine in Abbildung 7.12 dargestellt ist:

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|

Abbildung 7.13 Überprüfung einer Stellung

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:

int abstand( int x, int y)


{
if( x >= y)
return x – y;
return y – x;
}

Listing 7.16 Berechnung des Abstands

204
7.6 Beispiele

Mit dieser Hilfsfunktion können Sie prüfen, ob sich zwei Damen schlagen können:

A int schlagen( int x, int y, int damen[])


{
int dh, dv;
B dv = abstand( x, y);
C dh = abstand( damen[x], damen[y]);
D if( (dh == 0) || (dv == dh))
return 1;
E return 0;
}
7

Listing 7.17 Prüfung auf Schlagmöglichkeit

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:

int stellung_ok( int x, int damen[])


{
int i;
A for( i = 0; i < x; i = i+1)
{
B if( schlagen( i, x, damen))
return 0;
}
C return 1;
}

Listing 7.18 Prüfen auf gültige Stellung

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");
}

Listing 7.19 Ausgabe einer Lösung

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

E if( stellung_ok( 3, damen))


print_loesung( 4, damen);
}
}
}
}
}

Listing 7.20 Lösung des Damenproblems für vier Damen

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:

void damenproblem( int anz, int damen[], int lvl)


{
for( damen[lvl] = 1; damen[lvl] <= anz; damen[lvl]++)
{
A /* Rekursion auf dem naechsten */
/* Level (lvl +1) */
}
}

Listing 7.21 Schema der Funktion für die rekursive Lösung

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.

Damit haben Sie eine allgemeine Lösung für das Damenproblem:

void damenproblem( int anz, int damen[], int lvl)


{
A if( lvl == anz)
{
B print_loesung( anz, damen);
C return;
}
for( damen[lvl] = 1; damen[lvl] <= anz; damen[lvl]++)
{
D if( stellung_ok( lvl, damen))
E damenproblem( anz, damen, lvl+1);
}
}

Listing 7.22 Rekursive Lösung des Damenproblems

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);
}

Listing 7.23 Damenproblem mit wählbarer Anzahl von Damen

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:

Damen Loesungen Stellungen


1 1 1
2 0 6
3 0 18
4 2 60
5 10 220
6 4 894
7 40 3584
8 92 15720
9 352 72378
10 724 348150
11 2680 1806706
12 14200 10103868
13 73712 59815314
14 365596 377901398
15 2279184 532748320

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

unterschiedliche Aufstellungen der Damen in der Rekursion erzeugt werden kön-


nen. Viele davon fallen weg, da zu einer unfertigen, aber bereits als ungültig erkann-
ten Stellung keine weiteren Damen mehr gesetzt werden, aber es bleiben genug
Stellungen übrig, um auch den schnellsten Rechner lahmzulegen. Das ist übrigens
kein Problem, das der Rekursion geschuldet ist. Ein iteratives Programm wäre sicher
schneller, würde aber auch an der kombinatorischen Explosion der zu untersuchen-
den Stellungen scheitern.

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:

A void perm( int anz, char array[], int start)


{
int i;
char sav;
B if( start < anz)
{
C sav = array[ start];
D for( i = start; i < anz; i = i+1)
{
E array[start] = array[i];
F array[i] = sav;
G perm( anz, array, start + 1);
H array[i] = array[start];
}
I array[start] = sav;
}
else

210
7.6 Beispiele

J printf( "%s\n", array);


}

Listing 7.24 Die Funktion perm

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

왘 Das Element an der Startposition wird gesichert (C).


왘 Danach werden alle Elemente ab der Startposition durchlaufen (D). Jedes Element
wird im Austausch mit sav einmal an die Startposition gebracht und am Ende wie-
der in seine Ausgangsposition gesetzt.

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);
}

Listing 7.25 Berechnung der Permutationen

Das Programm erzeugt dabei die folgende Ausgabe:

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:

start = 0 start = 1 start = 2 start = 3 start = 4


TEW? TEWR
TE??
TER? TERW
TWE? TWER
T??? TW??
TWR? TWRE
TRW? TRWE
TR??
TRE? TREW
ETW? ETWR
ET??
ETR? ETRW
EWT? EWTR
E??? EW??
EWR? EWRT
ERW? ERWT
ER??
ERT? ERTW
????
WET? WETR
WE??
WER? WERT
WTE? WTER
W??? WT??
WTR? WTRE
WRT? WRTE
WR??
WRE? WRET
REW? REWT
RE??
RET? RETW
RWE? RWET
R??? RW??
RWT? RWTE
RTW? RTWE
RT??
RTE? RTEW

Abbildung 7.14 Arbeitsweise des Algorithmus

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.

Insgesamt ergeben sich damit bei n Elementen n! (n-Fakultät) Vertauschungen. Das


heißt, dass wir für die eingangs angesprochenen zehn Buchstabenklötzchen insge-
samt 10! = 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · 9 · 10 = 3628800 Vertauschungen erzeugen würden.
Eine wirkliche Hilfe beim Scrabblen wäre das nicht.

7
7.6.4 Labyrinth
In diesem Beispiel versetzen Sie sich in ein Labyrinth, das unfairerweise keinen Aus-
gang hat.

Abbildung 7.15 Das Labyrinth

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
};

Listing 7.26 Die Umsetzung des Labyrinths im Code

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]);
}

Listing 7.27 Ausgabe einer Labyrinthzeile

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

E if( weg( start_z-1, start_s, ziel_z, ziel_s))


F return 1;
}
if( labyrinth[start_z+1][start_s] == ' ')
{
labyrinth[start_z][start_s] = 'v';
if( weg( start_z+1, start_s, ziel_z, ziel_s))
return 1;
}
if( labyrinth[start_z][start_s-1] == ' ')
{
labyrinth[start_z][start_s] = '<';
if( weg( start_z, start_s-1, ziel_z, ziel_s))
return 1;
}
if( labyrinth[start_z][start_s+1] == ' ')
{
labyrinth[start_z][start_s] = '>';
if( weg( start_z, start_s+1, ziel_z, ziel_s))
return 1;
}
G labyrinth[start_z][start_s] = '-';
H return 0;
}

Listing 7.28 Die rekursive Wegsuche

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();

printf( "\nStart (Zeile Spalte): ");


scanf( "%d %d", &start_z, &start_s);
printf( "Ziel (Zeile Spalte): ");
scanf( "%d %d", &ziel_z, &ziel_s); 7

if( weg( start_z, start_s, ziel_z, ziel_s))


ausgabe();
else
printf( "Kein Weg gefunden!\n");
}

Listing 7.29 Das Hauptprogramm zur Wegsuche

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:

nicht zum Ziel führende Bereiche Start (Zeile Spalte): 1 1


Ziel (Zeile Spalte): 19 19
#####################
#v#-------------#>>v#
#v#-#############^#v#
#v#--------# ^#v#
#v#-######-######^#v#
#v#-#-#-----#>>v#^#v#
#v#-#-#-#####^#v#^#v#
#v#-#-#----# ^#v#^#v#
#v#-#-#-##-##^#v#^#v#
#v###-###>>v#^#v#^#v#
#v#>>v#>>^#>>^#>>^#v#
#v#^#v#^#---###-###v#
#v#^#v#^##-##-#-#-#v#
#v#^#v#^ #----#-#-#v#
#v#^#v#^#####-#-#-#v#
#v#^#>>^#-----#-#-#v#
#v#^######-######-#v#
#v#^ #--------#v#
#v#^#############-#v#
nicht untersuchte Bereiche #v#>>^#-----------#+#
#####################

Abbildung 7.16 Die Suche und ihr Protokoll

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:

Abbildung 7.17 Darstellung des Suchvorgehens

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:

Abbildung 7.18 Das Schema

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:

Abbildung 7.19 Bewegungen des Springers

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:

Startpunkt (Zeile Spalte): 1 1


Zielpunkt (Zeile Spalte): 1 2

+--+--+--+--+--+--+--+--+
| 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:

Startpunkt (Zeile Spalte): 1 1


Zielpunkt (Zeile Spalte): 1 2

+--+--+--+--+--+--+--+--+
| 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:

A void tausche( int a, int b)


{
int t;

B t = a;
a = b;
b = t;
}

void main()
{
int x = 1;
int y = 2;

printf( "Vorher: %d %d\n", x, y);


tausche( x, y);
printf( "Nachher: %d %d\n", x, y);
}

Listing 8.1 Tauschen von Werten

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);
}

Listing 8.2 Funktion tausche mit Übergabe von Adressen

In (A) wird die Funktion mit den Adressen der Variablen aufgerufen.

Jetzt passen allerdings der Funktionsaufruf und die Funktionsschnittstelle nicht


mehr zueinander. In den Parametervariablen stehen jetzt nicht mehr Integer-Werte,
sondern Adressen von Integer-Variablen. So etwas bezeichnet man als Zeiger:

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.

Der Dereferenzierungsoperator ist das Gegenstück zum Adress-Operator. Mit dem 8


Adress-Operator kommen wir von einer Variablen zu ihrer Adresse, mit dem Derefe-
renzierungsoperator kommen wir von der Adresse wieder zu der Variablen bzw.
ihrem Wert:

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;

printf( "x: %d\n", x);


printf( "y: %f\n", y);

Listing 8.3 Die Dereferenzierung von Adressen

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:

A void tausche( int *a, int *b)


{
int t;

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);
}

Listing 8.4 Die geänderte Funktion tausche

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;

E minmax( 10, zahlen, &min, &max);

printf( "Minimum: %d\n", min);


printf( "Maximum: %d\n", max);
}

Listing 8.5 Rückgabe von Werten über Zeiger

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:

A int *maximum( int *x, int *y)


{
B if( *x > *y)
C return x;
else
D return y;
}

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;

E c = *maximum( &a, &b);


printf( "a = %d, b = %d, c = %d\n", a, b, c);
}

Listing 8.6 Die Funktion maximum mit Zeigern


8
Die umgestellte Funktion gibt einen Zeiger auf int zurück (A). In (B) werden die Werte
der Variablen verglichen, und die Adresse der Variablen mit dem größeren Wert wird
zurückgegeben (C oder D). Über die zurückgegebene Adresse wird dann auf den Wert
zugegriffen (E), und wir erhalten die Ausgabe:

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;

A *maximum( &a, &b) = 3;


printf( "a = %d, b = %d\n", a, b);
}

Listing 8.7 Zugriff auf den zurückgegebenen Wert

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;

B printf( "p + 0 = %d\n", p + 0);


C printf( "p + 1 = %d\n", p + 1);
D printf( "p + 2 = %d\n", p + 2);
E printf( "p + 3 = %d\n", p + 3);
}

Listing 8.8 Adresswert des Zeigers

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;

B printf( "p + 0 = %d\n", p + 0);


C printf( "p + 1 = %d\n", p + 1);
D printf( "p + 2 = %d\n", p + 2);
E printf( "p + 3 = %d\n", p + 3);
}

Listing 8.9 Adresswert des Zeigers für char

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

8.2 Zeiger und Arrays


Der vorangegangene Abschnitt lässt bereits erahnen, dass Zeiger und Arrays in C eng
miteinander verwandt sind. Wenn wir etwa ein Array von acht Integer-Zahlen anle-
gen, verwendet C den Namen dieses Arrays wie einen Zeiger auf das erste Element
des Arrays. Da die restlichen Elemente des Arrays in festem Abstand entsprechend
der Größe des Datentyps folgen, besteht dann der folgende Zusammenhang:

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]

Abbildung 8.1 Zusammenhang zwischen Index und Zeiger

Allgemein kann man also sagen:

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]

Wir haben also die folgende Beziehung:

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)

Abbildung 8.2 Vergleich von Array- und Zeigernotation

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:

int zaehle( char string[])


{
int a;

for( i = 0, a = 0; *string[i] != 0; i++)


{
if( string[i] == 'a') 8
a++;
}
return a;
}

Listing 8.10 Zählen von Zeichen in Array-Notation

In Zeigernotation sieht die Funktion dann so aus:

A int zaehle( char *string)


{
int a;

B for( a = 0; *string != 0; string++)


{
if( *string == 'a')
a++;
}
return a;
}

void main()
{
int anz;
anz = zaehle( "Panamakanalaal");
printf( "%d a gefunden\n", anz);
}

Listing 8.11 Zählen von Zeichen in Zeigernotation

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:

A int palindrom( char *string)


{
B char *vorn = string;
char *hinten = string;
C for(; *hinten != 0; hinten++)
;
D hinten--;

E for( ; vorn < hinten; vorn++, hinten--)


{
if( *vorn != *hinten)
F return 0;
}
G return 1;
}

void main()
{
int ok;
ok = palindrom( "retsinakanister");
if( ok == 1)
printf( "Palindrom erkannt\n");
}

Listing 8.12 Palindromerkennung

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:

int minimum( int a, int b)


{
if( a < b)
return a;
return b;
}

int maximum( int a, int b)


{
if( a > b)
return a;
return b;
}

Listing 8.13 Getrennte Funktionen für Minimum und Maximum

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:

A int suche( int anz, int *daten, int modus)


{
int i, m;

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;

D min = suche( 10, zahlen, 1);


printf( "Minimum: %d\n", min);

E max = suche( 10, zahlen, 2);


printf( "Maximum: %d\n", max);
}

Listing 8.14 Minimum und Maximum in einer Funktion

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.

Für die Funktion minimum:


8
int minimum( int, int)

Für die Funktion maximum:

int maximum( int, int)

Allgemein sieht die Schnittstelle damit so aus:

int fkt( int, int)

Wir lesen das so:

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:

int minimum( int a, int b) {...}


int maximum( int a, int b) {...}

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;

C min = suche( 10, zahlen, minimum);


printf( "Minimum: %d\n", min);

D max = suche( 10, zahlen, maximum);


printf( "Maximum: %d\n", max);
}

Listing 8.15 Verwendung von Funktionszeigern

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 8.6 In C können Sie Funktionen mit einer unbestimmten Anzahl an Parametern


definieren. Anstelle der fehlenden Funktionsparameter können Sie einfach
drei Punkte setzen. Zum Beispiel können Sie die folgende Funktion

int add( int anz, ...)


{
// Funktionscode
}

erstellen, die Sie dann mit unterschiedlicher Parameterzahl rufen können.


Zum Beispiel:

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 = add( 5, 1, 2, 3, 4, 5); // a = 1+2+3+4+5 = 15


a = add( 6, 2,-2, 7,-1, 4, 0); // a = 2-2+7-1+4+0 = 10
a = add( 7, 0,-4,-2, 8, 1, 5, 6); // a = 0-4-2+8+1+5+6 = 14

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.

Beide Formate kommen in unterschiedlichen Hardwarearchitekturen vor. Der


Unterschied zwischen Little- und Big-Endian ist auch im Zusammenhang mit
Netzwerkkommunikation von zentraler Bedeutung, da festgelegt werden
muss, in welcher Reihenfolge die einzelnen Bytes einer Integer-Zahl im Netz-
werk übertragen werden müssen. Das Internetprotokoll gibt eine Big-Endian-
Reihenfolge vor. Alle Systeme müssen bei der Übertragung von Integer-Zah-
len diese sogenannte Network Byte Order einhalten. Umgangssprachlich ist
Ihnen dieses Problem bekannt. Wenn wir zweistellige Zahlen sprachlich über-
mitteln, verwenden wir im Deutschen das Little-Endian-Format (»einund-
zwanzig«), während in Großbritannien das Big-Endian-Format (»twenty one«)
verwendet wird.

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()
{
...
...
...
}

Listing 9.1 Die include-Anweisung

Diese und weitere Anweisungen dieser Art werden Sie jetzt kennenlernen.

9.1 Der Präprozessor


Der C-Präprozessor ist ein Werkzeug, das relativ unabhängig von der Sprache C
betrachtet werden kann. Es handelt sich um einen Vorübersetzer, der, bevor der
Compiler den Quellcode zu sehen bekommt, Textersetzungen durchführt. Dieser

241
9 Programmgrobstruktur

Ersetzungsprozess wird durch gewisse, in den Programmcode eingelagerte Anwei-


sungen, sogenannte Präprozessor-Direktiven, gesteuert. Diese Direktiven beginnen,
damit sie klar vom C-Code unterschieden werden können, immer mit einem »#« am
Zeilenanfang. In der Regel erstrecken sich Präprozessor-Direktiven auf nur eine Zeile,
bei Bedarf kann jedoch mit »\« eine Fortsetzungszeile angefügt werden.

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
}

Abbildung 9.1 Projekt- und Systemdateien

1 Wie man eine Entwicklungsumgebung konfiguriert, damit diese Dateien gefunden werden, wer-
den wir hier nicht behandeln.

242
9.1 Der Präprozessor

Der Compiler erkennt an den spitzen Klammern bzw. den Anführungszeichen, ob er


die Datei in Systemverzeichnissen oder im Projektverzeichnis suchen soll. Was
genau in den Header-Dateien steht, werden wir später betrachten, im Moment sind
es für uns einfach nur Dateien.

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:

Lesefluss des Compilers 9

// Quellcodedatei


# include "header1.h


… // header1.h
… …
… …
… # include "header2.h
… …


// header2.h


Abbildung 9.2 Der Lesefluss des Compilers

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.

Header-Dateien können, wie oben in Abbildung 9.2 bereits angedeutet, ihrerseits


wieder Header-Dateien inkludieren. Auf diese Weise kann eine komplexe Hierarchie

243
9 Programmgrobstruktur

von ineinander geschachtelten Dateien entstehen, die unter Umständen schwer zu


durchschauen ist. Insbesondere besteht die Gefahr, dass ein und dieselbe Datei
mehrfach oder sogar rekursiv inkludiert wird. Wie Sie sich mit einem einfachen Trick
vor solchen Problemen schützen können, erfahren Sie weiter unten, wenn ich
erkläre, wie Header-Dateien üblicherweise aufgebaut sind.

9.1.2 Symbolische Konstanten


Durch symbolische Konstanten können Werte, die an unterschiedlichen Stellen im
Quellcode einheitlich verwendet werden sollen, an zentraler Stelle festgelegt und
gepflegt werden.

Symbolische Konstanten
# define PI 3.14
# define MAX 10
# define MIN MAX/2

float array[MAX]; float array[10];


int i; int i;
Präprozessor Compiler
for( i = MIN; i < MAX; i++) for( i = 10/2; i < 10; i++)
array[i] = PI; array[i] = 3.14;

Abbildung 9.3 Verwendung symbolischer Konstanten

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.

Beachten Sie, dass symbolische Konstanten vom Präprozessor vollständig aufgelöst


werden, sodass der folgende Compiler sie nicht mehr zu sehen bekommt. Es handelt
sich insbesondere nicht um Variablen, denen Sie z. B. einen Wert zuweisen könnten.
Es geht auch nicht ausschließlich um numerische Werte. Im Prinzip können Sie belie-
bige Ersetzungen durch den Präprozessor vornehmen:

# define PLUS +
# define MAL *

int x; int x;
Präprozessor Compiler
x = 2 MAL (5 PLUS 1); x = 2 * (5 + 1);

Abbildung 9.4 Arbeitsweise des Präprozessors

244
9.1 Der Präprozessor

Wichtig ist nur, dass am Ende gültiger C-Code entsteht, da Sie ja noch kompilieren
wollen.

Bei symbolischen Konstanten werden keine Ausdrücke ausgewertet. Betrachten Sie


dazu das folgende Beispiel:

# define ZWEI 1+1

int x; int x;
Präprozessor Compiler
x = ZWEI*ZWEI; x = 1+1*1+1;

Abbildung 9.5 Probleme bei der Verwendung des Präprozessors


9
Die Variable x erhält hier nicht, wie man auf den ersten Blick vielleicht vermuten
könnte, den Wert 4, sondern 3. Setzen Sie daher Klammern, wenn Sie sichergehen
wollen, dass ein arithmetischer Ausdruck in einem beliebigen Kontext eingesetzt
werden kann:

# define ZWEI (1+1)

int x; int x;
Präprozessor Compiler
x = ZWEI*ZWEI; x = (1+1)*(1+1);

Abbildung 9.6 Korrekte Klammerung im Präprozessor

Bedenken Sie also immer:

Symbolische Konstanten sind keine Variablen, sondern nur Platzhalter für


einen Ersatztext!

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));

Abbildung 9.7 Parameter im Präprozessor

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);

Abbildung 9.8 Klammerung bei der Verwendung von Parametern

Setzen Sie, wie schon bei den symbolischen Konstanten, immer Klammern um das
gesamte Makro, da Sie nicht wissen, wo das Makro überall eingesetzt wird.

Problematisch können Makros werden, wenn durch ungeschickte Verwendung


unwissentlich Seiteneffekte ausgelöst werden:

# 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++);

Abbildung 9.9 Seiteneffekte bei der Verwendung des Präprozessors

Die zweimalige Erhöhung des Werts von a ist in diesem Beispiel sicher nicht gewollt.

Beachten Sie also:

246
9.1 Der Präprozessor

Makros sind keine Funktionen, sondern nur parametrierte Platzhalter für


einen Ersatztext!

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ß.

9.1.4 Bedingte Kompilierung


Oft ist es erforderlich, von einem Softwaresystem unterschiedliche Varianten (z. B.
für verschiedene Betriebssysteme) zu erstellen. Die Varianten unterscheiden sich
vielleicht nur minimal, aber der erforderliche Code ist auf einem System für das 9
jeweils andere System nicht kompilierbar. In diesem Fall kann man mit sogenannten
Compile-Schaltern aus einer Quelle die verschiedenen Versionen erzeugen. Im fol-
genden Beispiel wollen wir zwei Versionen erzeugen. Die eine soll einen Testaus-
druck enthalten, die andere nicht. Dazu habe ich einen Compile-Schalter TEST
eingeführt:

Compileschalter TEST nicht gesetzt

# 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;

Abbildung 9.10 Die Verwendung von Compile-Schaltern

Ü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

# define Setzen eines Schalters

# undef Rücksetzen eines Schalters

# if Fallunterscheidung aufgrund eines konstanten Ausdrucks (0 oder != 0)

# ifdef Fallunterscheidung aufgrund eines gesetzten Compile-Schalters

# ifndef Fallunterscheidung aufgrund eines nicht gesetzten Compile-Schalters

# else Alternative zu if, ifdef oder ifndef

# elif Alternative wie else, allerdings mit erneuter if-Bedingung

# endif Ende einer Fallunterscheidung

Tabelle 9.1 Liste der Direktiven

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.

Lesefluss des Compilers # ifndef HEADER_H


# define HEADER_H

… HEADER_H
… wird definiert.



# endif

# include "header.h"

Headerdatei header.h

… HEADER_H wird zweimal inkludiert.
# include "header.h" ist definiert.

… # ifndef HEADER_H
… # define HEADER_H

… Inhalt der Datei
… wird ausgeblendet.

# endif

Abbildung 9.11 Vermeidung rekursiver Includes

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.

9.2 Ein kleines Projekt


Wir wollen jetzt auf engstem Raum ein kleines Projekt mit Header- und Quellcodeda-
teien erstellen. An diesem Miniprojekt möchten wir Ihnen das Zusammenspiel von
Header-Dateien und Quellcodedateien erläutern. Dazu benötigen wir vorab noch
einige Begriffe.
9
Grundsätzlich unterscheiden wir in einem C-Programm zwischen:

왘 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).

In der Header-Datei (maximum.h) verhindert ein Compile-Schalter (MAXIMUM_H), dass


die Header-Datei mehrfach oder gar rekursiv inkludiert wird. Statten Sie jede Header-
Datei mit einem solchen Schutz aus! Sorgen Sie dafür, dass für jede Header-Datei ein
projektweit eindeutiger Schalter verwendet wird! Erzeugen Sie den Schalter z. B. aus
dem Dateinamen! Innerhalb des durch den Compile-Schalter geschützten Bereichs
werden in unserem Beispiel eine globale Variable (absolutes_maximum) und eine
Funktion (maximum) deklariert. Beachten Sie, dass weder die Variable noch die Funk-
tion hier wirklich erzeugt werden, sondern hier wird nur mitgeteilt, dass es die Vari-
able und die Funktion irgendwo im Projekt gibt.

249
9 Programmgrobstruktur

maximum.h
Hier werden die globale Variable
# ifndef MAXIMUM_H absolutes_maximum und die
# define MAXIMUM_H Funktion maximum deklariert.

extern int absolutes_maximum;


extern int maximum( int a, int b);
main.c
# endif # include <stdio.h>
# include <stdlib.h>
# include "maximum.h"
maximum.c void main()
# include <limits.h> {
# include "maximum.h" int a, b, c;

int absolutes_maximum = INT_MIN; a = maximum( 1, -5);


b = maximum( -10, 17);
int maximum( int a, int b) c = absolutes_maximum;
{ printf( "%d\n", c);
int max; }

if( a > b) Hier werden die globale


max = a; Variable absolutes_maximum
else und die Funktion maximum
max = b; verwendet.
if( max > absolutes_maximum)
absolutes_maximum = max;
INT_MIN ist eine symbolische
}
Konstante, die in limits.h als
der kleinstmöglichte int-Wert
Hier werden die globale Variable festgelegt ist.
absolutes_maximum und die
Funktion maximum definiert.

Abbildung 9.12 Ein Projekt mit drei Dateien

In der Implementierungsdatei (maximum.c) wird die Header-Datei (maximum.h)


inkludiert, und die globale Variable und die Funktion werden definiert. Der Compiler
kann dann prüfen, ob die Schnittstelle so implementiert wird, wie es in der Deklara-
tion vorgegeben ist. Zusätzlich wird in der Datei eine System-Header-Datei (limits.h)
inkludiert. In dieser Datei werden grundlegende Zahlenbereichsgrenzen durch sym-
bolische Konstanten beschrieben. Die symbolische Konstante INT_MIN nennt den
Wert der kleinstmöglichen int-Zahl auf unserem System. Mit diesem Wert initialisie-
ren wir die globale Variable absolutes_maximum. Die beiden Dateien maximum.h und
maximum.c bilden zusammen ein funktionierendes Modul, das natürlich noch
mehr Funktionen als nur die Maximumfunktion enthalten könnte. Wer die Funktio-
nen dieses Moduls nutzen will, muss die Header-Datei maximum.h inkludieren.

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

1 American National Standards Institute

253
10 Die Standard C Library

Diese Abschnitte sind für das weitere Verständnis von zentraler Bedeutung.

10.1 Mathematische Funktionen


Die Standard Library enthält viele mathematische Funktionen vom Sinus bis zur
Exponentialfunktion. Im folgenden Beispiel werden die Funktionen sqrt (Quadrat-
wurzel), exp (e-Funktion), fabs (Absolutbetrag), pow (Potenzfunktion) sowie sin
(Sinus) und cos (Cosinus) in Formeln verwendet:

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);
}

Listing 10.1 Beispiele für die Verwendung mathematischer Funktionen

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

Wir erhalten die folgende Ausgabe:

Z = 3.605551
Z = 2.592319
Z = 6.793692

Manchmal benötigen Sie in einem Programm Zufallswerte, um z. B. den Wurf eines


Würfels zu simulieren. Das folgende Programm zeigt, wie Sie das mithilfe der Funkti-
onen srand und rand realisieren können:

A # include <stdlib.h>

void main()
{
int seed, wurf, i; 10

B seed = 4711;

C srand( seed);

for( i = 1; i <= 5; i++)


{
D wurf = rand()%6 + 1;
printf( "%d. Wurf: %d\n", i, wurf);
}
}

Listing 10.2 Verwendung von Zufallszahlen

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.

10.2 Zeichenklassifizierung und -konvertierung


Es gibt zahlreiche Funktionen zur Klassifizierung und Konvertierung von Zeichen.
Zur Klassifizierung gehören Fragen wie: Ist das Zeichen ein druckbares Zeichen? Han-
delt es sich um eine Ziffer, einen Buchstaben oder ein Satzzeichen? Eine typische
Konvertierungsaufgabe ist die Konvertierung von Großbuchstaben in Kleinbuchsta-
ben oder umgekehrt. Wir zeigen Ihnen auch hier wieder nur ein kleines Beispiel:

A # include <ctype.h>
void main()
{
char text[100];
int u, l;
char *p;

printf( "Eingabe: ");


scanf( "%s", text);

for( p = text, u = l = 0; *p; p++)


{
B if( isupper( *p))
u++;
C if( islower( *p))
l++;
}
printf( "%d Gross-, %d Kleinbuchstaben\n", u, l);

3 Man nennt dies einen Regressionstest.

256
10.3 Stringoperationen

for( p = text; *p; p++)


D *p = toupper( *p);
printf( "Gross: %s\n", text);

for( p = text; *p; p++)


E *p = tolower( *p);
printf( "Klein: %s\n", text);
}

Listing 10.3 Zeichenklassifizierung und -konvertierung

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

Die in diesem Beispiel verwendeten Klassifizierungs- und Konvertierungsroutinen


sind übrigens Makros. Beachten Sie die Unterschiede zwischen Funktionen und
Makros!

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.

In einem Beispielprogramm wollen wir folgende Funktionen verwenden, um einen


Text aus mehreren Eingaben zusammenzusetzen:

왘 strlen – Stringlänge ermitteln


왘 strcmp – Strings vergleichen
왘 strcat – String an String anhängen

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);

D if( strcmp( eingabe, "ende") == 0)


break;

E if( strlen(text) + strlen(eingabe) < 500)


F strcat( text, eingabe);
}
printf( "%s\n", text);
}

Listing 10.4 Beispiel für Stringoperationen

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:

A for( i= 0; str[i] != 0; i++)


{
...
}

B len = strlen( str);


C for( i = 0; i < len; i++)
{
...
}
D
for( i = 0; i < strlen(str); i++)
{
...
}

Listing 10.5 Laufzeitkosten von Funktionsaufrufen

4 Wenn beide Strings nur groß- oder nur kleingeschrieben sind.

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.

Zusätzlich zu den hier betrachteten Funktionen gibt es zahlreiche weitere Funktio-


nen, etwa um einen String zu kopieren (strcpy), ein bestimmtes Zeichen in einem
String zu finden (strchr) oder zu prüfen, ob ein String in einem anderen String ent-
halten ist (strstr).

10.4 Ein- und Ausgabe


Für die Ein- bzw. Ausgabe haben Sie die Funktionen scanf und printf kennengelernt.
Dabei soll es im Prinzip auch bleiben, obwohl es noch viele weitere Funktionen zur
Tastatureingabe und Bildschirmausgabe gibt.

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.

Wenn ein C-Programm startet, öffnet das Laufzeitsystem zwei6 Streams:

왘 stdin (Standard Input)


왘 stdout (Standard Output)

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);
}

Listing 10.6 Schreiben in Streams

In (A) und (B) werden Funktionen wie printf und scanf verwendet, wobei im ersten
Parameter der Stream steht. 10

Bitte gib deinen Namen und dein Alter an: Otto 42


Du heisst Otto und bist 42 Jahre alt.

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;

B pf = fopen( "Test.c", "r");

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);
}

Listing 10.7 Anwendung von Dateioperationen

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

7 EOF = End of File

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.

10.5 Variable Anzahl von Argumenten


Stellen Sie sich vor, dass Sie eine Funktion wie printf selbst schreiben wollten. Sie
würden schon daran scheitern, dass Sie keine Funktion erstellen könnten, die eine
variable Anzahl von Parametern hat. Alle unsere Funktionen haben eine Schnittstelle
mit einer festgelegten Anzahl von Parametern. Von dieser Restriktion wollen wir uns
befreien, indem wir eine Funktion erstellen, die beliebig viele als Parameter überge-
bene Zahlen addiert und das Ergebnis zurückgibt. Wir schauen direkt auf den Code:

# include <stdio.h>
# include <stdlib.h>

A # include <stdarg.h>

B int summe( int anz,...)


{
C va_list ap;
int sum;
int summand;

263
10 Die Standard C Library

D va_start( ap, anz);


E for( sum = 0; anz; anz--)
{
F summand = va_arg( ap, int);
sum += summand;
}
G va_end( ap);
return sum;
}

Listing 10.8 Variable Anzahl von Argumenten

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);
}

Listing 10.9 Verwendung der Funktion summe

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

for( i = 0; i < anz; i++)


{
printf( "%d. Zahl: ", i+1);
C scanf( "%d", p+i);
}
for( i = anz-1; i >= 0; i--)
printf( "%d\n", p[i]);
D free( p);
}

Listing 10.10 Verwendung der Freispeicherverwaltung

Wie viele Zahlen: 5


1. Zahl: 1
2. Zahl: 2
3. Zahl: 3
4. Zahl: 4
5. Zahl: 5
5
4
3
2
1

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;

for( anz = 0; ; anz++)


{
printf( "%d. Zahl: ", anz+1);
scanf( "%d", &z);
if( z == –1)
break;
C if( anz >= size)
{
D size = size + increment;
E p = (int *)realloc( p, size*sizeof( int));
printf( "Array auf %d Elemente vergroessert\n", size);
}
p[anz] = z;
}
for( i = anz-1; i >= 0; i--)
F printf( "%d\n", p[i]);
G free( p);
}

Listing 10.11 Reallokieren von Speicher

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).

So kleinschrittig wie in dem obigen Beispiel wird man allerdings normalerweise


nicht vorgehen.

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:

int zfzahl (int min, int max)


{
return rand()%(max-min+1) + min;
}

Listing 10.12 Berechnung von Zufallszahlen

Die Formel habe ich Ihnen ja im Abschnitt 10.1, »Mathematische Funktionen«,


erklärt.

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;

for ( i = 0; i < len; i++)


s[i] = zfzahl( von, bis);
s[i] = 0;
}

Listing 10.13 Erzeugen eines Zufallsstrings

Als Parameter erhält die Funktion einen ausreichend großen Stringpuffer, die Länge
des zu erzeugenden Strings und den gewünschten Zeichenbereich.

Diese Funktionen testen wir in einem Hauptprogramm:

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 );
}
}

Listing 10.14 Test der Zufallsstringerzeugung

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.

A 10.3 Betrachten Sie folgende Funktionen der Standard Library:


왘 atoi
왘 strcat
왘 strchr
왘 strcmp
왘 strcpy
왘 strcspn
왘 strlen
왘 strncat
왘 strncmp
왘 strncpy
왘 strpbrk
왘 strrchr
왘 strspn
왘 strstr
왘 strtok
왘 strtol

Besorgen Sie sich Informationen über die Schnittstelle dieser Funktionen.


Implementieren Sie dann die Funktionen myatoi, mystrcat, ... mit gleicher
Funktionalität und Schnittstelle. Erstellen Sie einen Testrahmen, in dem Sie

271
10 Die Standard C Library

Massentestdaten generieren und die Ergebnisse Ihrer Funktionen mit denen


der Originalfunktionen vergleichen.

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.

11.1 Kombinatorische Grundaufgaben


Man könnte die Ziehung der Lottozahlen (6 aus 49) in zweierlei Hinsicht ändern:

왘 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.

In beiden Fällen müssten natürlich die Tippscheine neu gestaltet werden.


Insgesamt ergeben sich durch die Kombination der beiden Varianten vier verschie-
dene Ziehungsmodalitäten. Man könnte mit/ohne Wiederholungen und mit/ohne
Berücksichtigung der Ziehungsreihenfolge arbeiten. Diese vier Fälle wollen wir im
Folgenden diskutieren.

11.2 Permutationen mit Wiederholungen


An Ihrem Fahrrad haben Sie ein Zahlenschloss. Ein solches Schloss besteht in der
Regel aus vier Zahlenringen, die unabhängig voneinander auf Zahlen zwischen 1 und
9 eingestellt werden können. Zum Öffnen des Schlosses kommt es darauf an, die rich-
tige Zahl an der korrekten Position einzustellen.

Was hat das mit der Ziehung der Lottozahlen zu tun?

In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal


eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel wieder in die Trommel zurück. Gewonnen hat, wer die gezoge-
nen Kugeln in der richtigen Reihenfolge getippt hat.

Wir führen also eine Ziehung von k Kugeln aus einer Grundgesamtheit mit n Kugeln
mit Zurücklegen und mit Beachtung der Reihenfolge durch.

Wie viele verschiedene Ziehungsergebnisse gibt es?

274
11.3 Permutationen ohne Wiederholungen

Es gibt n Möglichkeiten, die erste Kugel zu ziehen.


In jedem dieser n Fälle gibt es n Möglichkeiten, die zweite Kugel zu ziehen.
Das sind insgesamt n2 Fälle.
In jedem dieser n2 Fälle gibt es n Möglichkeiten, die dritte Kugel zu ziehen. Das
macht insgesamt n3 Fälle.
Setzt man diese Überlegung auf alle k zu ziehenden Kugeln fort, ergibt sich,
dass es insgesamt nk mögliche Ziehungsergebnisse gibt.

Im Falle des Fahrradschlosses bedeutet dies, dass es 94 = 6561 verschiedene Einstel-


lungsmöglichkeiten gibt und dass die Chance, die richtige Einstellung auf Anhieb zu
erraten, 1:6561 ist.

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.

11.3 Permutationen ohne Wiederholungen


Sie sind beim Pferderennen. Acht Pferde sind am Start, und Sie wollen einen Tipp auf
den korrekten Einlauf der drei ersten Pferde abgeben. Wie groß sind Ihre Gewinn-
chancen, wenn man annimmt, dass alle acht Pferde gleich stark sind, also jeder mög-
liche Einlauf gleich wahrscheinlich ist? Eigentlich ist das die gleiche Fragestellung wie
im vorangegangenen Abschnitt – nur, dass hier Wiederholungen ausgeschlossen
sind, denn ein Pferd kann ja nicht gleichzeitig als Erster und als Zweiter oder Dritter
über die Ziellinie gehen. Das oben gewählte Modell können wir in abgewandelter
Form wiederverwenden:

In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal


eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel nicht wieder in die Trommel zurück. Gewonnen hat, wer die
gezogenen Kugeln in der richtigen Reihenfolge geraten hat.

Wie viele verschiedene Ziehungsergebnisse gibt es?

Es gibt n Möglichkeiten, die erste Kugel zu ziehen.


In jedem dieser n Fälle gibt es n-1 Möglichkeiten, die zweite Kugel zu ziehen.
Das sind insgesamt n · (n – 1) Fälle.
In jedem dieser n(n – 1) Fälle gibt es n-2 Möglichkeiten, die dritte Kugel zu zie-
hen. Das macht insgesamt n(n – 1)(n – 2) Fälle.

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

k ausgewählte Kugeln n-k nicht ausgewählte Kugeln


(n-k)! Vertauschungen

Abbildung 11.1 Darstellung der Permutation ohne Wiederholungen

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 )!

Im Falle des Pferderennens (n = 8, k = 3) erhalten wir also

8! 8⋅7⋅6⋅5⋅4⋅3⋅2⋅1
----- = ------------------------------------------------------ = 336
3! 3⋅2⋅1

verschiedene Zieleinläufe. Die Chance zu gewinnen, ist also 1:336.

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

11.3.1 Kombinationen ohne Wiederholungen


In Ihrem Bücherschrank stehen 100 Bücher. Sie wollen fünf Bücher auswählen, um
sie mit in den Urlaub zu nehmen. Wie viele verschiedene Buchpakete können Sie für
den Urlaub zusammenstellen? Diese Frage ähnelt den Fragestellungen des vorange-
gangenen Abschnitts. Der Unterschied besteht darin, dass es diesmal nicht auf die
Reihenfolge ankommt, in der die Elemente ausgewählt werden:

In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal


eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel nicht wieder in die Trommel zurück. Gewonnen hat, wer die
gezogenen Kugeln ohne Berücksichtigung der Ziehungsreihenfolge richtig
getippt hat.

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

k ausgewählte Kugeln n-k nicht ausgewählte Kugeln


k! Vertauschungen (n-k)! Vertauschungen

Abbildung 11.2 Darstellung der Kombinationen ohne Wiederholungen

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?

Zusammenfassung des Ergebnisses:

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.

Es gibt ⎛ n⎞ = ------------------------ solcher Kombinationen.


n!
⎝ k⎠ k! ( n – k )!

11.3.2 Kombinationen mit Wiederholungen


Sie haben eine Tüte mit 100 verschiedenfarbigen Bonbons und wollen diese Bonbons
an fünf Kinder verteilen. Die Frage ist: Wie viele verschiedene Verteilungen gibt es?
Zur Verteilung wählen wir ein Kind aus und geben ihm das erste Bonbon. Dann wäh-
len wir wieder ein Kind und geben ihm das nächste Bonbon. Dieses Verfahren setzen
wir fort, bis alle Bonbons verteilt sind2. Auch hier kommt es uns auf die Reihenfolge,
in der wir die Bonbons verteilen, nicht an. Im Gegensatz zum letzten Abschnitt kön-
nen hier aber Wiederholungen auftreten, da ein Kind mehrfach beschenkt werden
kann.

Zur Veranschaulichung des allgemeinen Falls wählen wir wieder das Bild der Los-
trommel:

In einer Lostrommel befinden sich n unterscheidbare Kugeln. Wir ziehen k-mal


eine Kugel aus dieser Lostrommel, notieren uns das Ziehungsergebnis und
legen die Kugel wieder in die Trommel zurück. Gewonnen hat, wer die gezoge-
nen Kugeln ohne Berücksichtigung der Ziehungsreihenfolge getippt hat.

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

Abbildung 11.3 Darstellung der Kombinationen mit Wiederholungen

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 )!

Dies ist aber der Binomialkoeffizient:

⎛ n + k – 1⎞
⎝ k ⎠

Im Falle der Bonbonverteilung (n = 5, k = 100) gibt es

⎛ 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.

Wir haben in diesem Abschnitt Kombinationen mit Wiederholungen betrachtet und


wieder eine Formel gewonnen:

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:

Zahlenschloss k = 4 Ringe mit Finale mit n = 8 Läufern und k = 3 Medaillen


n = 9 Einstellungsmöglichkeiten: n! = 8 · 7 · 6 = 336
4
n = 9 = 6561
k (n – k)!
verschiedene Zahlencodes. verschiedene Medaillenvergaben.

mit Wiederholungen ohne Wiederholungen


n-k-Auswahl
k≤n
Permutation mit Permutation ohne
Wiederholungen Wiederholungen
mit Reihenfolge n!
nk
(n – k)!

Kombination mit Kombination ohne


Wiederholungen Wiederholungen
ohne Reihenfolge n+k–1
( k ) ()n
k

Verteilung von k = 100 Bonbons an n = 5 Kinder: Ziehung der Lottozahlen k = 6 aus n = 49


49
( n+k–1
k
=
104
) ( )( )
100
=
104
4
= 104 · 103 · 102 · 101 = 4598126
4·3·2·1
() ( )
n
k
=
6
= 49 · 48 · 47 · 46 · 45 · 44 = 13983816
6·5·4·3·2·1
verschiedene Verteilungen. verschiedene Ziehungsergebnisse.

Abbildung 11.4 Übersicht über die kombinatorischen Grundaufgaben

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.

Es lohnt sich, die Entwicklung des Binomialkoeffizienten für unterschiedliche Werte


von n und k etwas genauer zu betrachten. Der Binomialkoeffizient liefert, bei glei-
chem n und k, jeweils die kleinste Zahl in der oben dargestellten Tabelle, da es ja weni-
ger Kombinationen als Permutationen gibt und sich ohne Wiederholung weniger
Anordnungen ergeben als mit Wiederholungen. Den größten Wert hat der Binomial-
koeffizient ⎛ ⎞ bei festem n, wenn k etwa oder genau halb so groß ist wie n. Für n =
n
⎝ k⎠
100 ist

⎛ 100⎞ ≈ 10 29
⎝ 50 ⎠

Abbildung 11.5 zeigt das rasante Wachstum des Binomialkoeffizienten:

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

Abbildung 11.5 Wachstum des Binomialkoeffizienten

Die anderen kombinatorischen Größen dieses Abschnitts wachsen noch weitaus


schneller als der Binomialkoeffizient. Wenn Sie also Algorithmen für kombinatori-
sche Auswahlen entwickeln, sollten Sie sich darüber im Klaren sein, dass Ihre Pro-
gramme für große Suchräume schnell an praktische Grenzen stoßen werden. Wer
will schon mehrere Tage oder Wochen auf das Ergebnis einer Berechnung warten? So
einfach eine Lösungssuche mit kombinatorischen Algorithmen auch ist, muss es
immer Ihr Bestreben sein, effizientere Algorithmen zu finden. Ob das prinzipiell
immer gelingen kann, ist eine der spannendsten und bis heute unbeantworteten Fra-
gen der theoretischen Informatik, auf die ein Kopfgeld von 1 Million Dollar4 ausge-
setzt ist.

Im Folgenden wollen wir Programme entwickeln, die Kombinationen bzw. Permuta-


tionen mit und ohne Wiederholungen erzeugen. Als Grundmenge betrachten wir
dabei stets n Zahlen zwischen 0 und n-1. Das ist keine Einschränkung, weil wir diese
Zahlen als Indizes für den Zugriff auf die eigentlich auszuwählenden Elemente ver-
wenden können. Mit anderen Worten: Wir permutieren und kombinieren nicht die
eigentliche Menge, sondern deren Indexmenge.

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}

Eine Auswahl ist durch ein Array von Indizes gegeben:

int auswahl[3] = {2, 0, 7}

Ausgewählt sind in diesem Fall die drei Elemente mit den Indizes 2, 0 und 7:

daten[auswahl[0]] = daten[2] = 0.815


daten[auswahl[1]] = daten[0] = 1.234
daten[auswahl[2]] = daten[7] = 2.718

Abbildung 11.6 zeigt am Beispiel von 4-2-Auswahlen, welche Indexmengen unsere


Programme jeweils erzeugen werden:

mit Wiederholungen ohne Wiederholungen


n-k-Auswahl
k≤n
4-2-Permutationen 4-2-Permutationen
mit Wiederholungen ohne Wiederholungen
1: < 0, 0> 1: < 0, 0>
2: < 0, 1> 2: < 1, 0>
3: < 0, 2> 3: < 0, 2>
4: < 0, 3> 4: < 2, 0>
5: < 1, 0> 5: < 0, 3>
6: < 1, 1> 6: < 3, 0>
mit Reihenfolge 7: < 1, 2> 7: < 1, 2>
8: < 1, 3> 8: < 2, 1>
9: < 2, 0> 9: < 1, 3>
10: < 2, 1> 10: < 3, 1> 4!
= 12
11: < 2, 2> 11: < 2, 3> (4 – 2)!
12: < 2, 3> 12: < 3, 2>
13: < 3, 0>
14: < 3, 1> 42 = 16
15: < 3, 2>
16: < 3, 3>

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

Abbildung 11.6 Übersicht der erzeugten Indexmengen

282
11.4 Kombinatorische Algorithmen

11.4 Kombinatorische Algorithmen


Im Rahmen unserer Programmierübungen sind wir bereits mehrfach auf Probleme
gestoßen, die wir durch Erzeugung von Permutationen gelöst haben:

왘 In Kapitel 5, »Aussagenlogik«, haben wir in einem Programm alle möglichen


Schalterstellungen von sieben Schaltern erzeugt, um festzustellen, ob eine Lampe
leuchtet. Letztlich handelte es sich bei den Schalterstellungen um 2-7-Permutatio-
nen mit Wiederholungen.
왘 In Abschnitt 7.4, »Rekursion«, haben wir ein Programm perm erstellt, das n-n-Per-
mutationen ohne Wiederholungen erzeugte. Das ist ein Spezialfall der hier zu
betrachtenden n-k-Permutationen.
왘 Das 8-Damen-Problem haben wir nun durch die Erzeugung von 8-8-Permutatio-
nen zu lösen versucht. Wir hatten hier zunächst Permutationen mit Wiederholun-
gen erzeugt und dann die Wiederholungen (= zwei Damen in gleicher Spalte)
11
herausgefiltert.

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;

printf( "%3d: (", ++count);


for( i = 0; i < k-1; i++)
printf( "%2d,", array[i]);
printf( "%2d)\n", array[k-1]);
}

Listing 11.1 Hilfsfunktion zur Ausgabe von Arrays

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.

Jetzt kommen wir zu unseren eigentlichen Algorithmen.

11.4.1 Permutationen mit Wiederholungen


Wir behandeln zunächst n-k-Permutationen mit Wiederholungen, da dies der ein-
fachste der hier zu untersuchenden Fälle ist. Veranschaulichen können wir uns das
Problem durch k Stangen, die von 0 bis k-1 nummeriert, jeweils mit Zahlen von 0 bis
n-1 beschriftet und verschiebbar auf einer Querleiste montiert sind.

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

Abbildung 11.7 Veranschaulichung der Permutationen mit Wiederholungen

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

A void perm_mw(int n, int k, int array[], int x)


{
int i;

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).

Mit einem entsprechenden Hauptprogramm können Sie jetzt Permutationen mit


Wiederholungen generieren:

void main()
{
A int array[3];

B printf( "2-3-Permutationen\nmit Wiederholungen\n");


perm_mw( 2, 3, array, 0);
}

Listing 11.3 Test und Ausgabe der Permutationen

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.

In (B) erzeugen wir 2-3-Permutationen mit Wiederholung im Array, starten mit


Stange 0 und erhalten die folgende Ausgabe:

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)

11.4.2 Kombinationen mit Wiederholungen


Bei Kombinationen ist die Reihenfolge, in der die Auswahlen getroffen werden, nicht
von Bedeutung. Wir können uns daher auf eine spezielle Reihenfolge, die dann stell-
vertretend für alle möglichen Reihenfolgen steht, beschränken. Naheliegenderweise
wählen wir die Reihenfolge, bei der alle Zahlen der Größe nach geordnet sind. Um das
in unserem Stangenmodell zu erzwingen, wird der Bewegungsspielraum der Stangen
so eingeschränkt, dass eine Stange niemals weiter nach unten geschoben werden
kann als die vorangegangene. Mechanisch würden Sie das Problem lösen, indem Sie
sich im Baumarkt ein paar Winkeleisen besorgen und diese unten an den Stangen als
Anschlag anbringen würden.

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

Abbildung 11.8 Veranschaulichung der Kombinationen mit Wiederholungen

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:

A void komb_mw(int n, int k, int array[], int x, int min)


{
int i;

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);
}

Listing 11.4 Kombination mit Wiederholungen

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];

printf( "3-4-Kombinationen\nmit Wiederholungen\n");


A komb_mw( 3, 4, array, 0, 0);
}

Listing 11.5 3-4-Kombinationen mit Wiederholungen

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)

11.4.3 Kombinationen ohne Wiederholungen


Mit geringfügigen Änderungen können wir die Funktion zur Generierung von Kom-
binationen mit Wiederholungen auf Kombinationen ohne Wiederholungen umstel-
len. Wir müssen nur die Winkel eine Stelle höher anschrauben, damit verhindert
wird, dass gleiche Werte erzeugt werden können:

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

Abbildung 11.9 Veranschaulichung der Kombinationen ohne Wiederholungen

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:

void komb_ow(int n, int k, int array[], int x, int min)


{
int i;

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);
}

Listing 11.6 Kombination ohne Wiederholungen

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).

Im Hauptprogramm erzeugen wir 5-3-Kombinationen ohne Wiederholungen:

void main()
{
int array[3];

printf( "5-3-Kombinationen\nohne Wiederholungen\n");


komb_ow( 5, 3, array, 0, 0);
}

Listing 11.7 5-3-Kombinationen ohne Wiederholungen

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)

11.4.4 Permutationen ohne Wiederholungen


Es bleiben die Permutationen ohne Wiederholungen, die allerdings nicht so einfach
zu behandeln sind wie die bisher implementierten Auswahlen. Auch hier werden die
Bewegungsmöglichkeiten der Stangen eingeschränkt, aber nicht durch einen so ein-
fachen Mechanismus wie zuvor. Hier wird gefordert, dass eine Stange auf keinen der
zuvor verwendeten Werte eingestellt wird.

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

Abbildung 11.10 Veranschaulichung der Permutationen ohne Wiederholungen

290
11.4 Kombinatorische Algorithmen

Die zur mechanischen Umsetzung dieser Vorschrift erforderlichen Bauteile gibt es


leider in keinem Baumarkt.

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.

Eine Funktion zur Berechnung von k-k-Permutationen ohne Wiederholungen haben


Sie in Abschnitt 7.4, »Rekursion«, unter dem Namen perm bereits kennengelernt. Wir
nehmen also diese Funktion und ändern sie so ab, dass, sobald eine Permutation
erzeugt wurde, das Programm print_array aufgerufen wird:

void perm( int anz, int array[], int start)


{
11
int i, sav;

if( start < anz)


{
sav = array[ start];
for( i = start; i < anz; i++)
{
array[start] = array[i];
array[i] = sav;
perm( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
else
A print_array( anz, array);
}

Listing 11.8 Abgeänderte Funktion perm

Diese Funktion wurde bereits in Abschnitt 7.4, »Rekursion«, ausführlich erklärt. An


der Position (A) wurde eine neue Permutation erzeugt.

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

A void perm_ow(int n, int k, int array[], int x, int min)


{
int i;

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);
}

Listing 11.9 Permutation ohne Wiederholung

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).

Im Hauptprogramm rufen wir diese Funktion, um 4-2-Permutationen ohne Wieder-


holungen zu erzeugen:

void main()
{
int array[2];

printf( "4-2-Permutationen\nohne Wiederholungen\n");


perm_ow( 4, 2, array, 0, 0);
}

Listing 11.10 4-2-Permutationen ohne Wiederholungen

Wir erhalten das folgende Ergebnis:

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

Abbildung 11.11 Binomialkoeffizienten


»10 über k« für k zwischen 1 und 5

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:

A double beute[10] = { 333.33, 655.99, 387.50, 1420.10, 4583.17,


7500.00, 215.12, 3230.17, 599.00, 3775.11};

B double summe;

int anzahl;
int auswahl[10];
double teilsumme;
double abweichung;

Listing 11.11 Globale Variablen des Programms Juwelenraub

Hier sind die Preise der zehn Beutestücke (A) sowie eine Variable für den Gesamtwert
der Beute angelegt (B).

Die darauffolgenden vier Variablen beschreiben eine Teilauswahl der Beute:

왘 anzahl ist die Anzahl der ausgewählten Beutestücke.


왘 auswahl ist das Array mit den Indizes der ausgewählten Beutestücke.
왘 teilsumme ist der Wert der Teilauswahl.
왘 abweichung ist die Abweichung der Teilsumme von der Hälfte des Gesamtwerts.

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;

for( i = 0, summe = 0.0; i < 10; i++)


{
printf( "Beutestueck %2d: %10.2f Euro\n", i+1, beute[i]);
A summe += beute[i];
}
printf( "Gesamtbeute: %10.2f Euro\n\n", summe);
B abweichung = summe + 1;
}

Listing 11.12 Die Hilfsfunktion vorbereitung

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:

Beutestueck 1: 333.33 Euro


Beutestueck 2: 655.99 Euro
Beutestueck 3: 387.50 Euro
Beutestueck 4: 1420.10 Euro
Beutestueck 5: 4583.17 Euro
Beutestueck 6: 7500.00 Euro
Beutestueck 7: 215.12 Euro
Beutestueck 8: 3230.17 Euro
Beutestueck 9: 599.00 Euro
Beutestueck 10: 3775.11 Euro
Gesamtbeute: 22699.49 Euro

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.

A void aufteilung( int k, int array[])


{
int i;
double teil;
double abw;
B for( i = 0, teil = 0.0; i < k; i++)
teil += beute[array[i]];

C abw = fabs( summe/2 – teil);


D if( abw < abweichung)
{
abweichung = abw;
teilsumme = teil;
anzahl = k;
for( i = 0; i < k; i++)
auswahl[i] = array[i];
}
}

Listing 11.13 Die Funktion aufteilung

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:

void komb_ow(int n, int k, int array[], int x, int min)


{
int i;

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);
}

Listing 11.14 Kombination ohne Wiederholungen (leicht modifiziert)

Hier wird jetzt aufteilung anstelle von bisher print_array gerufen (A).

Im Hauptprogramm werden nach der Vorbereitung alle relevanten Kombinationen


erzeugt und getestet:

void main( )
{
int array[5];
11
int i;

vorbereitung();
A for( i = 1; i <= 5; i++)
komb_ow( 10, i, array, 0, 0);
auswertung();
}

Listing 11.15 Hauptprogramm für den Juwelenraub

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;

printf( "Der Komplize erhaelt:\n\n");


for( i = 0; i < anzahl; i++)
{
printf( " Beutestueck %2d %10.2f Euro\n", auswahl[i]+1,
beute[auswahl[i]]);
}

297
11 Kombinatorik

printf( "\nTeilsumme %10.2f Euro\n", teilsumme);


printf( "\nAbweichung %10.2f Euro\n", abweichung);
}

Listing 11.16 Auswertung des gefundenen Ergebnisses

Bei Aufruf unseres kompletten Programms erhalten wir das folgende Ergebnis für
die Aufteilung:

Der Komplize erhaelt:

Beutestueck 3 387.50 Euro


Beutestueck 6 7500.00 Euro
Beutestueck 7 215.12 Euro
Beutestueck 8 3230.17 Euro

Teilsumme 11332.79 Euro

Abweichung 16.95 Euro

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 ⎠

Fälle zu betrachten wären.

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

Abbildung 11.12 Übersicht über alle Auswahlmöglichkeiten

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:

int noten[7] = {500, 200, 100, 50, 20, 10, 5};


int betrag;
int pruefungen;

Listing 11.17 Globale Variablen für den Geldautomaten

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

A int pruefe( int k, int array[])


{
int i;
int summe;

B pruefungen++;

C for( i = 0, summe = 0; i < k; i++)


D summe += noten[array[i]];

E return summe == betrag;


}

Listing 11.18 Prüfung auf den passenden Betrag

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).

Zur Lösungssuche verwenden wir komp_mw in einer modifizierten Fassung. Ein


wesentlicher Unterschied zu den vorangegangenen kombinatorischen Programmen
ist nämlich, dass wir diesmal gar nicht an einer erschöpfenden Suche interessiert
sind, sondern mit der Suche aufhören wollen, sobald die erste Lösung gefunden ist.
Das heißt, dass wir den Rückgabewert des Unterprogramms pruefe nutzen wollen,
um die Suche abzubrechen:

int komb_mw(int n, int k, int array[], int x, int min)


{
int i;

if( x < k)
{
for( i = min; i < n; i++)
{
array[x] = i;

300
11.5 Beispiele

A if( komb_mw( n, k, array, x+1, i))


return 1;
}
B return 0;
}
else
C return pruefe( k, array);
}

Listing 11.19 Kombination mit Wiederholungen (leicht modifiziert)

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);

A if( betrag <= 0)


break;

B pruefungen = 0;

C for( k = 1; k <= 20; k++)


{
if( komb_mw( 7, k, array, 0, 0))
{
printf( "Auszahlung: ");

301
11 Kombinatorik

D for( i = 0; i < k; i++)


printf( "%d ", noten[array[i]]);
printf( "\n");
E break;
}
}
F if( k > 20)
printf( "Keine Auszahlung moeglich\n");
printf( "Es wurden %d Pruefungen durchgefuehrt\n\n", pruefungen);
}
}

Listing 11.20 Hauptprogramm für den Geldautomaten

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

Das Ausführungsprotokoll zeigt, dass beim Versuch, 1 Euro auszuzahlen, tatsächlich


888029 Prüfungen durchgeführt werden.

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.

Das können Sie sofort programmieren:

A void auszahlung( int betrag)


{
B int noten[7] = { 500, 200, 100, 50, 20, 10, 5};
int n;
int rest;
11
printf( "Auszahlung: ");

C for( n = 0, rest = betrag; rest >= 5; )


{
D if( noten[n] <= rest)
{
printf( "%d ", noten[n]);
E rest -= noten[n];
}
else
F n++;
}
printf( "\n");
if( rest)
printf( "Rest %d Euro kann nicht ausgezahlt werden\n", rest);
}

Listing 11.21 Die Funktion auszahlung

Die Funktion erhält an ihrer Schnittstelle den Auszahlungsbetrag übergeben (A).


Innerhalb der Funktion sind die Notenwerte definiert (B). Die Auszahlung startet mit
dem mit dem ersten (größten) Notenwert und fährt fort, solange Teile des Restbe-
trags noch ausgezahlt werden können (C). Innerhalb der Schleife wird geprüft, ob der
betrachtete Notenwert kleiner als der Restbetrag ist (D). Ist das der Fall, wird der Wert
ausgezahlt (E), ansonsten wird der nächstkleinere Notenwert betrachtet (F).

303
11 Kombinatorik

Diese Funktion findet die gleichen Auszahlungen wie der kombinatorische Algorith-
mus und macht dabei nur einige wenige Schleifendurchläufe.

Den hier verwendeten Algorithmus bezeichnet man als Greedy5-Algorithmus, weil er


sich immer den größten Happen schnappt und so versucht, das Problem dadurch
möglichst schnell zu lösen. Gier kann allerdings blind machen. Wenn Sie sich vorstel-
len, dass der Automat nur mit Noten ab 20 Euro bestückt wäre und 60 Euro auszu-
zahlen wären, dann würde der Algorithmus zunächst 50 Euro auszahlen und wäre
mit seiner Gier in eine Sackgasse gelaufen, obwohl er mit drei 20-Euro-Noten den
Betrag hätte auszahlen können.

5 engl. greedy = gierig, gefräßig

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

bestimmen. Die Zahl n ist dabei beliebig, aber fest vorgegeben.

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:

1 void gleichung1( int n)


{
int x, y, z;

1 for( x = 0; x <= n; x++)


{
A 51 for( y = 0; y <= n; y++)
{
B 2601 for( z = 0; z <= n; z++)
{
C 132651 if( x + y + z == n)
printf( "%d + %d + %d = %d\n", x, y, z, n);

305
12 Leistungsanalyse und Leistungsmessung

}
}
}
}

Listing 12.1 Durchläufe der einzelnen Zeilen

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

t(n) = c(n + 1)3

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:

1 void gleichung2( int n)


{
int x, y, z;

1 for( x = 0; x <= n; x++)


{
A 51 for( y = 0; y <= n; y++)
{
B 2601 z = n – x – y;
2601 if( z >= 0)
1326 printf( "%d + %d + %d = %d\n", x, y, z, n);
}
}
}

Listing 12.2 Durchläufe im vereinfachten Programm

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:

11 void gleichung3( int n)


{
int x, y, z;

1 for( x = 0; x <= n; x++)


{
12
A 51 for( y = 0; y <= n-x; y++)
{
B 1326 z = n – x – y;
1326 printf( "%d + %d + %d = %d\n", x, y, z, n);
}
}
}

Listing 12.3 Durchläufe im weiter vereinfachten Programm

(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

Abbildung 12.1 Anzahl der Möglichkeiten


k(k + 1)
1 Sie erinnern sich: 1 + 2 + ... k = -------------------- .
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

Abbildung 12.2 Laufzeitverhalten der drei Programme

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.

Das Beispiel basiert auf drei Unterprogrammen konstanter Laufzeit:

void upr1()
{
int i;

for( i = 0; i < 500; i++)


machwas();
}
12
void upr2()
{
int i;

for( i = 0; i < 50; i++)


machwas();
}

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.
}
}

Abbildung 12.3 Die drei Unterprogramme konstanter Laufzeit

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.

void test(int n, int m)


{
int i1, i2, i3;

for( i1 = 0; i1 < n; i1++)


{
upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
upr2();
else
for( i3 = 0; i3 < i2; i3++)
upr3();
}
}
}

Listing 12.4 Das eigentliche Unterprogramm test

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

Abbildung 12.4 Der Strukturbaum des Programms

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.

void test( int m, int n)

for( i1 = 0; i1 < n; i1++)

12

{ … }

upr1() for( i2 = 0; i2 < 2*m; i2++)


t1

if( i2%2)

upr2() for( i3 = 0; i3 < i2; i3++)


t2

upr3()
t3

Abbildung 12.5 Strukturbaum mit Laufzeiten der Unterprogramme

Wir beginnen mit der Bottom-up-Analyse unseres Programms. Zunächst propagie-


ren wir die Laufzeit des Unterprogramms upr3 in die übergeordnete Schleife. Dazu
erinnern wir uns an die Kontrollflusssteuerung innerhalb einer Schleife:

311
12 Leistungsanalyse und Leistungsmessung

Initialisierung

Inkrement Test
nein
ja

Schleifenkörper

Abbildung 12.6 Kontrollflusssteuerung innerhalb der Schleife

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:

Gegeben sei eine Schleife der Form:

for( init; test; incr)


body

Außerdem sei:

tinit die Laufzeit der Initialisierung

ttest (k) die Laufzeit des Tests vor dem k-ten Schleifendurchlauf

tbody (k) die Laufzeit des Schleifenkörpers im k-ten Durchlauf

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:

t(n) = tinit + ttest (1)


+ tbody (1) + tincr (1) + ttest (2)
+ tbody (2) + tincr (2) + ttest (3)

...
+ 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.

Wenn die Laufzeiten zur Schleifensteuerung eine im Vergleich zum Schleifenkörper


zu vernachlässigende Größenordnung haben, kann die Formel wie folgt vereinfacht
werden:

t(n) = tbody (1) + tbody (2) + ... + tbody (n)

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:

Die Laufzeiten zur Schleifensteuerung


können vernachlässigt werden.

for( i3 = 0; i3 < i2; i3++)


i2t3
Es finden i2 Schleifendurchläufe statt. Die Laufzeit pro Schleifendurchlauf ist t3.

upr3()
t3 Die Laufzeit des Schleifenkörpers hängt
nicht vom Schleifendurchlauf ab.

Abbildung 12.7 Propagierung der Ergebnisse

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

Alternative1 Bedingung Alternative2


ja nein

Abbildung 12.8 Fallunterscheidung

Gegeben sei eine Fallunterscheidung der Form:

if( bedingung)
alternative1
else
alternative2

Außerdem sei:

tbed die Laufzeit zur Überprüfung der Bedingung

talt1 die Laufzeit der Alternative1

talt2 die Laufzeit der Alternative2

Dann berechnet sich die Laufzeit der Fallunterscheidung wie folgt:

⎧ t alt1 falls die Bedingung erfüllt ist


t = t bed + ⎨
⎩ t alt2 falls die Bedingung nicht erfüllt ist

Kann die Laufzeit zur Überprüfung der Bedingung im Vergleich zur Laufzeit der
Alternativen vernachlässigt werden, vereinfacht sich die Formel zu:

⎧ t alt1 falls die Bedingung erfüllt ist


t = ⎨
⎩ t alt2 falls die Bedingung nicht erfüllt ist

In unserem konkreten Beispiel ergibt sich:

314
12.1 Leistungsanalyse

if( i2%2)
t2 falls i2 ungerade ist
i2t3 falls i gerade ist
2

upr2() for( i3 = 0; i3 < i2; i3++)


t2 i2t3

upr3()
t3

Abbildung 12.9 Aktualisiertes Ergebnis


12
Die in der Formel vorkommende Fallunterscheidung macht die Formel allerdings
unhandlich, da bei der weiteren Berechnung jetzt immer zwei Fälle zu betrachten
sind. In der Regel versucht man daher, eine solche Variantenbildung so früh wie
möglich zu unterbinden. Dazu betrachten wir zwei Möglichkeiten. Zunächst versu-
chen wir es mit einer gemeinsamen Abschätzung beider Alternativen:

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:

t ≤ tbed + talt1 + talt2

Auch hier kann tbed weggelassen werden, wenn die Laufzeit zur Prüfung der Bedin-
gung im Vergleich zu den anderen Laufzeiten klein ist.

In unserem Beispiel können wir wie folgt abschätzen:

t ≤ t 2 + i 2t 3

Beachten Sie hier aber zweierlei:

1. Bei den einzelnen Laufzeiten handelt es sich im Allgemeinen um Funktionen, die


noch von außen liegenden Variablen abhängen. Es geht hier also nicht darum, ein-
fach nur den größeren zweier Werte zu bestimmen, sondern eine Funktion zu fin-

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.

Gibt es Informationen darüber, mit welcher Wahrscheinlichkeit p (0 ≤ p ≤ 1) die


Bedingung in der Fallunterscheidung wahr wird, lässt sich die mittlere Laufzeit wie
folgt ermitteln:

t = tbed + ptalt1 + (1 – p) talt2

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

Wir wollen in unserem Beispiel aber mit der exakten Formel

⎧ t2 falls i 2 ungerade ist



⎩ i2 t3 falls i 2 gerade ist

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

for( i2 = 0; i2 < 2*m; i2++)


{
if( i2 % 2) i2 = 1, 3, 5, … , 2m – 1
m
Laufzeit: t2 Laufzeit: mt2
else

Laufzeit: i2t3 i2 = 0, 2, 4, … , 2(m – 1)


} Laufzeit:
m(m – 1)
(2 + 4 + … + 2(m – 1))t3 = 2(1 + 2 + … + m – 1)t3 = 2 t3 = m(m – 1)t3
2

Abbildung 12.10 Unterscheidung zwischen den Schleifenwerten

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:

for( i2 = 0; i2 < 2*m; i2++) 12


mt2 + m(m – 1)t3

if( i2%2)

t2 falls i2 ungerade ist


i2t3 falls i gerade ist
2

upr2() for( i3 = 0; i3 < i2; i3++)


t2 i2t3

upr3()
t3

Abbildung 12.11 Weiter aktualisierter Strukturbaum

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

Gegeben sei ein Block der Form:


{
anweisung_1
anweisung_2
...
anweisung_n
}

Außerdem sei:

tk die Laufzeit der k-ten Anweisung im Block.

Dann berechnet sich die Gesamtlaufzeit des Blocks nach der Formel:

t = t1 + t2 + ... + tn

Dominierte Terme können weggelassen werden.

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

upr1() for( i2 = 0; i2 < 2*m; i2++)


t1 mt2 + m(m – 1)t3

if( i2%2)

t2 falls i2 ungerade ist


i2t3 falls i gerade ist
2

upr2() for( i3 = 0; i3 < i2; i3++)


t2 i2 t3

upr3()
t3

Abbildung 12.12 Weitere Propagierung der Summen

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).

void test( int m, int n)


t(n,m) = n(t1 + mt2 + m(m – 1)t3)

for( i1 = 0; i1 < n; i1++)


n(t1 + mt2 + m(m – 1)t3)

{ … } 12
t1 + mt2 + m(m – 1)t3

upr1() for( i2 = 0; i2 < 2*m; i2++)


t1 mt2 + m(m – 1)t3

if( i2%2)

t2 falls i2 ungerade ist


i2t3 falls i gerade ist
2

upr2() for( i3 = 0; i3 < i2; i3++)


t2 i2 t 3

upr3()
t3

Abbildung 12.13 Vollständiger Strukturbaum

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:

t(n,m) = cn(m2 + 49m + 500)

Den Proportionalitätsfaktor können wir nur durch konkrete Messungen ermitteln.


Er gilt dann aber auch nur für den Rechner, auf dem die Messung durchgeführt
wurde.

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:

t(n,m) = n(t1 + mt2 + m(m – 1)t3)

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.

Im nächsten Abschnitt wollen wir feststellen, ob unsere theoretischen Vorüberle-


gungen auch einer praktischen Überprüfung standhalten. Danach werden wir die
mathematischen Aspekte der Programmierung noch etwas vertiefen.

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

Abbildung 12.14 Einzelne Messwerte passen auf unterschiedliche Kurven.

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

t(n,m) = n(t1 + mt2 + m(m – 1)t3)

zugrunde:

void test(int n, int m) 12


{
int i1, i2, i3;

for( i1 = 0; i1 < n; i1++)


{
upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
if( i2 % 2)
upr2();
else
for( i3 = 0; i3 < i2; i3++)
upr3();
}
}
}
t(n,m) = n(t1 + mt2 + m(m – 1)t3)
Abbildung 12.15 Das bereits bekannte Testprogramm

Bei der Messung eines Programms interessieren uns vorrangig zwei Gesichtspunkte:

왘 Wie oft werden Teile des Programms durchlaufen?


왘 Wie viel Rechenzeit wird für Teile des Programms benötigt?

321
12 Leistungsanalyse und Leistungsmessung

Um Antworten auf diese Fragen zu erhalten, müssen Messungen am laufenden Pro-


gramm durchgeführt werden. Dazu besteht natürlich die Möglichkeit, die Pro-
gramme durch einen Testrahmen zu erweitern. Durch spezielle, geschickt platzierte
Zähler kann man ermitteln, wie oft ein bestimmter Messpunkt angelaufen wird.
Durch Aufruf von Timerfunktionen des C-Laufzeitsystems kann die abgelaufene Zeit
an bestimmten Kontrollpunkten gemessen und kumuliert werden. Dies bedeutet
aber, dass der Programmcode für Test- und Messzwecke verändert werden muss. Die-
ser Eingriff in das Programm verfälscht die eigentlichen Messergebnisse. Wir messen
ja nicht das Programm, sondern wir messen das Programm im Messrahmen, also das
in Messung befindliche Programm. Für spezielle Tests müsste der Messrahmen ver-
ändert werden. Es stellt sich die Frage, inwieweit dann verschiedene Messungen noch
vergleichbar sind. Grundsätzlich sind solche Messungen unbefriedigend und sollten
nur dann genutzt werden, wenn keine anderen Möglichkeiten zur Verfügung stehen.

In der Regel finden Sie heute in jeder Softwareentwicklungsumgebung Werkzeuge


zur Programmanalyse3 – sogenannte Profiler. Diese Werkzeuge erfordern keinen Ein-
griff in den Programmcode und sind daher »handgestrickten« Testrahmen vorzuzie-
hen. Prinzipiell unterscheiden wir zwei Arten der Analyse:

왘 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:

왘 n = 17 Aufrufe von upr1


왘 n · m = 17 ·13 = 221 Aufrufe von upr2
왘 n · m · (m – 1) = 17 · 13 · 12 = 2652 Aufrufe von upr3

Die Überdeckungsanalyse bestätigt das zuvor theoretisch hergeleitete Ergebnis:

3 Ich möchte Ihnen hier kein konkretes Werkzeug vorstellen, da Sie je nach Entwicklungsumge-
bung andere Werkzeuge vorfinden.

322
12.2 Leistungsmessung

1 void test(int n, int m)


{
int i1, i2, i3;

for( i1 = 0; i1 < n; i1++)


{
17 upr1();
for( i2 = 0; i2 < 2*m; i2++)
{
442 if( i2 % 2)
221 upr2();
else
221 for( i3 = 0; i3 < i2; i3++)
2652 upr3();
}
}
}
Abbildung 12.16 Ergebnisse der Überdeckungsanalyse 12

Überdeckungsanalysen sind übrigens nicht nur für Laufzeituntersuchungen von


Bedeutung. Solche Analysen sind wichtige Hilfsmittel für Tests und die Qualitätssi-
cherung von Programmen. Beim Test von Programmen geht es ja darum, Testdaten
so zu wählen, dass alle Teile eines Programms auch wirklich getestet werden. Zur
Feststellung des Überdeckungsgrades eines Tests werden dann die hier diskutierten
Werkzeuge eingesetzt.

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.

4 Alle Zeitangaben sind in Millisekunden.

323
12 Leistungsanalyse und Leistungsmessung

n m upr1 upr2 upr3 Laufzeit Laufzeit Abweichung


Aufrufe Zeit Aufrufe Zeit Aufrufe Zeit gerechnet gemessen
5 12 5 511,70 60 51,15 660 1,01 6294,10 6293,26 0,01%
17 13 17 509,89 221 50,98 2652 1,00 22586,71 22598,55 0,05%
7 33 7 512,04 231 50,93 7392 1,01 22815,03 22780,88 0,15%
9 9 9 511,23 81 50,90 648 1,00 9371,97 9373,75 0,02%
12 21 12 511,01 252 51,04 5040 1,01 24084,60 24066,44 0,08%
7 2 7 511,74 14 51,40 14 1,02 4316,06 4316,07 0,00%
14 10 14 510,31 140 51,13 1260 1,01 15575,14 15571,09 0,03%
19 3 19 511,04 57 51,17 114 1,01 12741,59 12741,02 0,00%
6 13 6 509,72 78 50,91 936 1,00 7965,30 7969,45 0,05%
5 18 5 509,85 90 51,00 1530 1,01 8684,55 8679,61 0,06%

Abbildung 12.17 Ergebnisse der Performance-Analyse

Wenn wir die Laufzeitformel

t(n, m) = n(t1 + mt2 + m(m – 1)t3)

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

왘 Potenzfunktionen (1, n, n2, n3, ...)


왘 Wurzelfunktionen ( 2 n , 3 n , 4 n , …)
왘 Exponentialfunktionen (2n, 3n, 4n, …)
왘 Logarithmen (log2(n), log3(n), log4(n), ...)

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

왘 1 + 3 + 5 + 7 + … + 2n – 1 = n2 (Summe der ersten n ungeraden Zahlen)

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:

int funktion1( int n)


{
int x; 1 0
2 1
int k = 0; 3 1
4 2
for( x = 1; x <= n; x *= 2) 5 2
6 2
k++; 7 2
8 3
return k-1; 9 3
10 3
} 11 3
12 3
void main() 13 3
14 3
{ 15 3
int n; 16 4
17 4
18 4
for( n = 1; n <= 20; n++) 19 4
printf( "%2d %2d\n", n, funktion1( n)); 20 4
}

Abbildung 12.18 Die Rückgabewerte von funktion1

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

Abbildung 12.19 Logarithmus zur Basis 2

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

int funktion2( int n)


{
int x, y;
int k = 0; 1 1
2 1
3 1
for( x = 0, y = 1; x <= n; x += y, y += 2) 4 2
k++; 5 2
6 2
7 2
return k-1; 8 2
} 9 3
10 3
11 3
void main() 12 3
{ 13 3
14 3
int n; 15 3
16 4
for( n = 1; n <= 20; n++) 17 4
18 4
printf( "%2d %2d\n", n, funktion2( n)); 19 4
} 20 4
12
Abbildung 12.20 Die Rückgabewerte von funktion2

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

Abbildung 12.21 Wurzelfunktion

Sie sehen an diesen Beispielen bereits, dass Laufzeitfunktionen unangenehme Eigen-


schaften haben können. Sie können z. B. Sprünge machen, sodass es oft unmöglich

327
12 Leistungsanalyse und Leistungsmessung

ist, solche Funktionen durch geschlossene mathematische Terme auszudrücken. Sie


haben aber auch gesehen, dass eine geschickte Abschätzung für unsere Zwecke in der
Regel ausreichend ist. Diesen Weg wollen wir weiter beschreiten.

Wollen wir Algorithmen bezüglich ihrer Laufzeit miteinander vergleichen, müssen


wir die zugehörigen Laufzeitfunktionen betrachten. Der Vergleich zweier Funktio-
nen ist allerdings nicht so einfach wie der Vergleich zweier Zahlenwerte, da beim Ver-
gleich von Funktionen unendlich viele Funktionswerte betrachtet werden müssen.
Die Idealvorstellung, dass eine Laufzeitfunktion immer (d. h. für alle Funktionswerte)
besser ist als eine andere, wird sich im Allgemeinen nicht ergeben. Stellen Sie sich
vor, dass Sie ein Programm geschrieben haben, bei dem in einer Schleife eine kom-
plexe Berechnung durchgeführt wird. Sie optimieren dieses Programm, und es
gelingt Ihnen, die Laufzeit in der Schleife deutlich zu verkürzen. Leider müssen Sie
dabei einen größeren Aufwand zur Initialisierung der Schleife in Kauf nehmen. Dies
bedeutet nun, dass der neue Algorithmus unter Umständen für kleine Datenmengen
schlechter ist als der alte und erst für große Datenmengen seine Überlegenheit
beweist, da die Initialisierung der Schleife ja bei wenigen Schleifendurchläufen stär-
ker ins Gewicht fällt. Die konkreten Laufzeitfunktionen könnten qualitativ etwa wie
folgt aussehen:

Laufzeitfunktion t(n)
Laufzeit

vor Optimierung

topt(n)

Laufzeitfunktion
nach Optimierung

Datenvolumen
n0

topt(n) ≤ t(n) für n ≥ n0

Abbildung 12.22 Mögliche konkrete Laufzeitfunktion

Welchen Algorithmus würden Sie in dieser Situation bevorzugen? Sicherlich den


zweiten5. Sinnvollerweise fordern wir daher nicht, dass eine Laufzeitfunktion

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

Abbildung 12.23 Laufzeitveränderungen mit konstantem Faktor

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).

Anschaulich bedeutet f Ɐ g, dass f »nicht wesentlich schneller wächst« als g, f Ɱ g


bedeutet, dass f »wesentlich langsamer wächst« als g, und f ≈ g bedeutet, dass f und g
»im Wesentlichen gleich schnell« wachsen. Beachten Sie, dass f trotz f Ɐ g stets grö-
ßer als g sein kann. Es muss nur eine durch c · g(n) gegebene Schranke geben, unter-
halb derer sich f fast immer bewegt. Da f dabei nicht nach oben ausbrechen darf, hat
f maximal das Wachstum von g (siehe Abbildung 12.24).

c · g(n)
Laufzeit

f(n)

g(n)

n0 Datenvolumen

Abbildung 12.24 Obere Schranke im Laufzeitverhalten

6 Sprich: »groß O von g«


7 Streng genommen, ist diese Notation falsch, da auf der linken Seite des Gleichheitszeichens eine
Funktion und auf der rechten Seite eine Menge von Funktionen steht.
8 Sprich: »Theta von g«

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

Abbildung 12.25 Obere und untere Schranke im Laufzeitverhalten

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

Abbildung 12.26 Vorgegebener Kanal für die Laufzeit

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:

t(n) = n4 + 3n3 – 2n2 + n – 3

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

Das können wir, wegen n ≥ 1, weiter abschätzen9:

t(n) ≤ n4 + 3n4 + n4 = 5n4

Insgesamt haben wir damit erhalten:

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:

t(n) ≥ n4 – 2n3 – 3n3 = n4 – 5n3

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

Insgesamt ergibt sich:

t(n) ≈ n4

Abbildung 12.27 zeigt das durch die Abschätzung erhaltene Ergebnis:

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

Abbildung 12.27 Ergebnis der Abschätzung

Viel wichtiger als diese konkrete Abschätzung ist aber das durch Verallgemeinerung
gewonnene Ergebnis:

Für eine polynomiale Laufzeitfunktion


t(n) = aknk + ak – 1nk – 1 + ... + a2n2 + a1n + a0 mit ak > 0 gilt: t(n) ≈ nk

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:

1 Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...

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:

1 Ɱ log(n) Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ...

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:

1 Ɱ log(n) Ɱ ... Ɱ k n Ɱ ... Ɱ 3 n Ɱ n Ɱ n Ɱ n2 Ɱ n3 Ɱ ... Ɱ nk Ɱ ... Ɱ 2n Ɱ 3n Ɱ ... Ɱ kn Ɱ ...

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

Abbildung 12.28 Typische Laufzeitklassen

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

int programm1( int n)


{
int i, k;
int z = 0;

for( i = 1; i <= n; i++)


{
for( k = 1; k <= i; k += (i/10 + 1)) 12
z++;
}
return z;
}

Listing 12.5 Das Programm 1

Das Programm 1 ergibt die folgende Ausgabe:

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

int programm2( int n)


{
int i, k;
int z = 0;

for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k++)
z++;
}
return z;
}

Listing 12.6 Das Programm 2

Das Programm 2 ergibt die folgende Ausgabe:

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:

int programm3( int n)


{
int i, k;
int z = 0;

for( i = 1; i <= n; i++)


{
for( k = 1; k <= i; k *= 2)
z++;
}
return z;
}

Listing 12.7 Das Programm 3

Das Programm 3 ergibt die folgende Ausgabe:

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:

t(n) ≈ log2(1) + log2(2) + ... log2(n) = log2(n!)


n⁄ n
Mit den Abschätzungen n 2 ≤ n! ≤ n folgt dann einerseits:

t(n) ≈ log2(n!) ≤ log2(nn) = n · log2(n)

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:

Durchlauf Programm 1 Laufzeitklasse: n · log(n)


Laufzeitklasse: n

1 1 1

2 3 3

Tabelle 12.1 Die ersten 15 Durchläufe

338
12.3 Laufzeitklassen

Durchlauf Programm 1 Laufzeitklasse: n · log(n)


Laufzeitklasse: n

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

Tabelle 12.1 Die ersten 15 Durchläufe (Forts.)

Das liegt daran, dass die Entscheidung erst »im Unendlichen« fällt. Erst bei n = 1919
entscheidet sich, welche Funktion die größere ist.

Durchlauf Programm 1 Laufzeitklasse: n · log(n)


Laufzeitklasse: n

1915 19033 19029

1916 19043 19040

1917 19053 19051

1918 19063 19062

1919 19073 19073

1920 19083 19084

1921 19093 19095

Tabelle 12.2 Die Durchläufe 1915–1925

339
12 Leistungsanalyse und Leistungsmessung

Durchlauf Programm 1 Laufzeitklasse: n · log(n)


Laufzeitklasse: n

1922 19103 19106

1923 19113 19117

1924 19123 19128

1925 19133 19139

Tabelle 12.2 Die Durchläufe 1915–1925 (Forts.)

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:

int programm4( int n)


{
int i, k;
int z = 0;

for( i = 1; i <= n; i++)


{
for( k = 1; k <= i*i; k++)
z++;
}
return z;
}

Listing 12.8 Das Programm 4

Das Programm 4 ergibt die folgende Ausgabe:

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:

int programm5( int n)


{
int i, k;
int z = 0;

for( i = 1; i <= n; i *= 2)
{
for( k = 1; k <= i; k *= 2)
z++;
}
return z;
}

Listing 12.9 Das Programm 5

Das Programm 5 ergibt die folgende Ausgabe:

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

int programm6( int n)


{
int i, k, m;
int z = 0;

for( i = 1, m = 1; i <= n; i++, m *= 2)


{
for( k = 1; k <= m; k++)
z++;
}
return z;
}

Listing 12.10 Das Programm 6

Das Programm 6 ergibt die folgende Ausgabe:


12
1 1
2 3
3 7
4 15
5 31
6 63
7 127
8 255
9 511
10 1023
11 2047
12 4095
13 8191
14 16383
15 32767

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.

Im Prinzip haben wir vier große Leistungsbereiche für Algorithmen ermittelt:

343
12 Leistungsanalyse und Leistungsmessung

왘 Algorithmen exponentieller Laufzeitkomplexität (untereinander abgestuft nach


der Größe der Basis). Dies sind die Algorithmen mit inakzeptabel wachsendem
Zeitbedarf. Der Programmierer sollte diese Algorithmen meiden, wo immer es
möglich ist.
왘 Algorithmen polynomialer Laufzeitkomplexität (untereinander abgestuft nach
der höchsten vorkommenden Potenz). Dies sind Algorithmen mit einem akzepta-
bel wachsenden Zeitbedarf. Natürlich ist man hier immer bemüht, die höchste
vorkommende Potenz so niedrig wie möglich zu halten.
왘 Algorithmen logarithmischer Laufzeitkomplexität (untereinander gleichwertig,
unabhängig von der Basis). Dies sind Algorithmen mit einem sehr moderaten
Wachstum, die sich jeder Programmierer wünscht.
왘 Algorithmen konstanter Laufzeit. Dies sind natürlich die besten Algorithmen.
Nur kommen sie bei ernsthaften Problemen in der Regel nicht vor.

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:

10-mal 100-mal 1000-mal


Heutiger
Laufzeitklasse schnellerer schnellerer schnellerer
Rechner
Rechner Rechner Rechner
log(n) x x10 x100 x1000
n x 10 · x 100 · x 1000 · x
2
n x 3.16 · x 10 · x 31.6 · x
2 n
x x + 3.32 x + 6.64 x + 9.96
10 n
x x+1 x+2 x+3

Abbildung 12.29 Laufzeitklassen und Rechnerleistung

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.

void XXXsort( int n, int *daten)

Damit sind wir in der Lage, vorab einen einheitlichen Testrahmen für alle Sortierpro-
gramme dieses Abschnitts zu erstellen:

347
13 Sortieren

Testobjekt void XXXsort( int n, int *daten)


{
...
}

Testrahmen void testdaten( int n, int *daten)


{
# define ANZAHL 100 int i;
# define SEED 4711
for ( i = 0; i < n; i++)
void main() daten[i] = rand() % n;
{ }
int daten[ANZAHL];
int pruefen( int n, int *daten)
srand( SEED); {
testdaten( ANZAHL, daten); int i;

XXXsort( ANZAHL, daten); for( i = 0; i < n-1; i++)


{
if( pruefen( ANZAHL, daten)) if( daten[i] > daten[i+1])
printf( "ok\n"); return 0;
else }
printf( "nicht ok\n"); return 1;
} }

Abbildung 13.1 Einheitlicher Testrahmen für alle Sortierprogramme

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.

Das Verfahren läuft wie folgt ab:

Durchlaufe die Daten in aufsteigender Richtung! Betrachte dabei immer zwei


benachbarte Elemente. Wenn zwei benachbarte Elemente in falscher Ordnung
sind, dann vertausche sie! Nach einem Durchlauf ist auf jeden Fall das größte
Element am Ende der Daten.

Wiederhole diesen Verfahrensschritt so lange, bis die Daten 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!

Abbildung 13.2 veranschaulicht die einzelnen Durchläufe des Verfahrens am Beispiel


eines Arrays mit sechs Elementen. In dieser Grafik werden ein Vergleichsschritt durch
ein graues Rechteck und eine Vertauschung durch gekreuzte Linien dargestellt:
13

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

Abbildung 13.2 Veranschaulichung von Bubblesort

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.

void bubblesort( int n, int *daten)


{
int i, k, t;

A for( i = n-1; i > 0; i--)


{
B for( k = 0; k < i; k++)
{
C if( daten[k] > daten[k+1])
{
t = daten[k];
daten[k] = daten[k+1];
daten[k+1] = t;
}
}
}
}

Listing 13.1 Die Bubblesort-Funktion

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:

Abbildung 13.3 Der Sortierverlauf bei Bubblesort

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

Nach dem 1. Durchlauf 1 5 2 6 4 3 1


2
3
4
5

Nach dem 2. Durchlauf 1 2 5 6 4 3 2


3
4
5

Nach dem 3. Durchlauf 1 2 3 6 4 5 3


4
5

Nach dem 4. Durchlauf 1 2 3 4 6 5 4

Nach dem 5. Durchlauf 1 2 3 4 5 6 5

Abbildung 13.4 Veranschaulichung von Selectionsort

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.

Als Programm realisieren wir das wie folgt:

void selectionsort( int n, int *daten)


{
int i, k, t, min;

A for( i = 0; i < n-1; i++)


{
B min = i;
for( k = i+1; k < n; k++)
{
C if( daten[k] < daten[min])
min = k;
}
t = daten[min];
D daten[min] = daten[i];
daten[i] = t;
}
}

Listing 13.2 Die Selectionsort-Implementierung

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.

Im Vergleich zu Bubblesort finden deutlich weniger Elementvertauschungen statt,


denn die Vertauschung wird hier im Gegensatz zu Bubblesort in der äußeren der bei-
den Schleifen durchgeführt. Dafür befindet sich in der inneren Schleife die Suche
nach dem Minimum, die es bei Bubblesort nicht gibt. Wie sich das in der Laufzeitbi-
lanz auswirkt, werden wir später untersuchen.

Auch hier beobachten wir den Algorithmus bei der Arbeit:

352
13.1 Sortierverfahren

Abbildung 13.5 Der Sortierverlauf bei Selectionsort

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

Abbildung 13.6 Die Arbeitsweise von Insertionsort

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.

Zur Veranschaulichung des Verfahrens dient Abbildung 13.7:

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

Abbildung 13.7 Veranschaulichung von Insertionsort

Zu Beginn jedes Verfahrensschritts wird das einzusortierende Element aus dem


Array entnommen. Solange das einzusortierende Element seinen Platz noch nicht
gefunden hat, rücken die Vergleichselemente von links her auf. Am Ende wird das
zuvor entnommene Element an der frei gewordenen Position abgelegt.

Mit diesen Informationen ist Insertionsort einfach zu realisieren:

354
13.1 Sortierverfahren

void insertionsort( int n, int *daten)


{
int i, k, v;

A for( i = 1; i < n; i++)


{
B v = daten[i];

C for( k = i; (k >= 1)&&(daten[k-1] > v); k--)


D daten[k] = daten[k-1];

E daten[k] = v;
}
}

Listing 13.3 Die Insertionsort-Implementierung

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.

Auch hier betrachten wir einige Schnappschüsse:

Abbildung 13.8 Der Sortierverlauf bei Insertionsort

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:

void insertion_h_sort( int n, int *daten, int h)


{
int i, k, v;

A for( i = h; i < n; i++)


{
v = daten[i];
B for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
C daten[k] = daten[k-h];
daten[k] = v;
}
}

Listing 13.4 Ersetzen der Schrittweite im Insertionsort

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

Abbildung 13.9 Sortierung mit der Schrittweite h = 3

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

Abbildung 13.10 Beispiel einer h-Sortierung

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:

shellsort( int n, int *daten)


{
int h;

A for( h = 1; h <= n/9; h = 3*h+1)


;

B for( ; h > 0; h /= 3)
C insertion_h_sort( n, daten, h);
}

Listing 13.5 Die Shellsort-Implementierung

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:

void shellsort( int n, int *daten)


{
int i, k, h, v;

for( h = 1; h <= n/9; h = 3*h+1)


;
for( ; h > 0; h /= 3)
{
A for( i = h; i < n; i++)
A {
A v = daten[i];
A for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
A daten[k] = daten[k-h];
A daten[k] = v;
A }
}
}

Listing 13.6 Die optimierte Shellsort-Implementierung

358
13.1 Sortierverfahren

Im Bereich (A) ist jetzt insertion_h_sort direkt in die Implementierung integriert.

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

Abbildung 13.11 Der Sortierverlauf bei Shellsort

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.

Abbildung 13.12 zeigt die Verfahrensidee:

links rechts

Pivot

alle Werte ≤ x x alle Werte ≥ x

Abbildung 13.12 Die Verfahrensidee bei Quicksort

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.

Wenn wir annehmen, dass wir bereits über eine Funktion

int aufteilung( int links, int rechts, int *daten)

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:

2 frz. Pivot = Dreh- und Angelpunkt

360
13.1 Sortierverfahren

A void qcksort( int links, int rechts, int *daten)


{
int i;

B if( links < rechts)


{
C i = aufteilung( links, rechts, daten);
D qcksort( links, i-1, daten);
E qcksort( i+1, rechts, daten);
}
}

Listing 13.7 Die Quicksort-Implementierung

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:

Dies ist der gewählte Pivot.

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

Abbildung 13.13 Die Ausgangslage für die Aufteilung

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

Diese beiden Werte sind falsch,


also werden sie getauscht.

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

Abbildung 13.14 Vorgehensweise bei der Aufteilung

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

Diese beiden Werte sind falsch,


also werden sie getauscht.

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

Treffpunkt

Abbildung 13.15 Die Vertauschung bei einer Blockade

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

Tausche den Pivot mit der


Zahl rechts vom Treffpunkt.

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

Abbildung 13.16 Abschließender Tausch des Pivots

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.

Die Aufteilung muss noch implementiert werden:


13
int aufteilung( int links, int rechts, int *daten)
{
int pivot, i, j, t;

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;
}

Listing 13.8 Die Implementierung der Aufteilung

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.

void qcksort( int links, int rechts, int *daten)


{
int pivot, i, j, t;

if( rechts > links)


{
A pivot = daten[rechts];
A i = links-1;
A j = rechts;
A for( ; ;)
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;

364
13.1 Sortierverfahren

qcksort( links, i-1, daten);


qcksort( i+1, rechts, daten);
}
}

Listing 13.9 Die Implementierung von Quicksort mit integrierter Aufteilung

Die Sortierung des gesamten Arrays wird dann durch den Aufruf

qcksort( 0, n-1, daten)

veranlasst. Die Standardschnittstelle unserer Sortierverfahren konnten wir nicht


implementieren, da die Funktion qcksort rekursionsfähig sein musste. Aus Gründen
der Einheitlichkeit versehen wir jetzt aber noch Quicksort mit der gleichen Schnitt-
stelle wie die anderen Sortierverfahren:

void quicksort( int n, int *daten)


{
qcksort( 0, n-1, daten); 13
}

Listing 13.10 Implementierung von Quicksort mit unserer Standardschnittstelle

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:

Abbildung 13.17 Der Sortierverlauf bei Quicksort

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

Abbildung 13.18 Der Sortierverlauf im Detail

Die Implementierung von Quicksort verwendet Rekursion. Im Abschnitt über Rekur-


sion haben Sie erfahren, dass Rekursion immer vermeidbar ist und dass gute nicht
rekursive Alternativen laufzeiteffizienter sind, weil die Laufzeitkosten für die Unter-
programmaufrufe vermieden werden. Ich möchte Ihnen daher zeigen, wie Sie die
Rekursion bei Quicksort vermeiden können. Wir betrachten dazu noch einmal die
»Urversion« unserer Quicksort-Implementierung, bei der die Aufteilungsfunktion
noch nicht integriert war:

void qcksort( int links, int rechts, int *daten)


{
int i;

if( links < rechts)


{
i = aufteilung( links, rechts, daten);
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}

Listing 13.11 Unsere ursprüngliche Implementierung von Quicksort

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);
}

Listing 13.12 Implementierung eines einfachen Stacks

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).

3 Denken Sie dabei an den Tellerstapel im China-Restaurant.

367
13 Sortieren

Wir erhalten für unsere Stack-Operationen das folgende Ergebnis:

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:

void qcksort( int links, int rechts, int *daten)


{
int i;
rekursive Version
if( links < rechts)
{
i = aufteilung( links, rechts, daten);
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);void qcksortiter(int links, int rechts, int *daten)
} {
} int i;
int stack[256];
int pos = 0; Stack und Stackpointer

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! }
}
}

Abbildung 13.19 Gegenüberstellung der rekursiven und rekursionsfreien Quicksort-


Implementierung

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:

void quicksort(int n, int *daten)


{
int pivot, i, j, t;
int links, rechts;
int stack[256];
int pos = 0;

stack[pos++] = 0;
stack[pos++] = n-1;

while( pos)
{
rechts = stack[--pos];
links = stack[--pos];

if( links < rechts)


{
A i = links-1;
A j = rechts;
A pivot = daten[rechts];
A for(;;)

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;
}
}
}

Listing 13.13 Rekursionsfreie Implementierung und Eliminierung der Funktion aufteilung

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.

4 engl. Heap = Haufen

370
13.1 Sortierverfahren

0 1 2 3 4 5 6 7 8 9 10 11

Abbildung 13.20 Array-Elemente als Baum angeordnet

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:

Linker Nachfolger Rechter Nachfolger


des Knotens i ist des Knotens i ist
der Knoten 2i + 1. der Knoten 2i + 2.
0
13

1 2

3 4 5 6

7 8 9 10 11

Abbildung 13.21 Übersichtlichere Baumdarstellung

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.

Im Array, d. h. in den Knoten des Baums, können beliebige Zahlenwerte stehen.

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

Abbildung 13.22 Darstellung eines Heaps

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:

Hier ist die Heap-


4
Bedingung gestört.

9 8

7 5 2 3

1 5 2 3 1

Abbildung 13.23 Eine gestörte Heap-Bedingung

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:

Die Heap-Bedingung ist


wiederhergestellt.

9
9

4
7 8
7

4
13
5 5 2 3
4
5

1 4 2 3 1

Abbildung 13.24 Wiederherstellung der Heap-Bedingung

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:

A void adjustheap( int n, int *daten, int k)


{
int j, v;

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;
}

Listing 13.14 Funktion zur Wiederherstellung der Heap-Bedingung

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.

Mit diesem Hilfsprogramm können wir unser letztes Sortierverfahren realisieren.


Wir bauen dazu zunächst vom rechten Rand des Arrays her einen Heap im Array auf.
Dann nehmen wir das größte Element vom Heap, vertauschen es mit dem letzten
Element im Array und stellen die Heap-Bedingung in dem um ein Element verklei-
nerten Baum wieder her. Jetzt steht das größte Element am Ende des Arrays, und die
n-1 Elemente davor bilden wieder einen Heap. Damit steht das zweitgrößte Element
am Anfang. Wir tauschen es jetzt durch das vorletzte Element im Array aus und
adjustieren wieder den Heap, der jetzt nur noch n-2 Elemente hat. Dadurch wird das
drittgrößte Element nach vorn geholt. Im Code liest sich das wie folgt:

void heapsort( int n, int *daten)


{
int k, t;

for( k = n/2; k;)


A adjustheap( n, daten, --k);
B while( --n)
{
t = daten[0];
C daten[0] = daten[n];
daten[n] = t;

374
13.1 Sortierverfahren

D adjustheap( n, daten, 0);


}
}

Listing 13.15 Die Implementierung von Heapsort

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

Hier wird der Heap aufgebaut. Hier ist alles in Ordnung.

Abbildung 13.25 Der Aufbau des Heaps

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.

Ich habe auch von diesem Algorithmus Schnappschüsse gemacht:

375
13 Sortieren

Abbildung 13.26 Der Sortierverlauf bei Heapsort

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 Leistungsanalyse der Sortierverfahren


In diesem Abschnitt möchten wir Laufzeitformeln für unsere Sortierverfahren her-
leiten, da wir ja noch immer auf der Suche nach dem besten aller Verfahren sind.

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

void bubblesort( int n, int *daten)


1 {
n–1 int i, k, t;

999 for( i = n-1; i > 0; i--)


{
499500 for( k = 0; k < i; k++)
{
n(n) – 1
if( daten[k] > daten[k+1])
2 {
239518 t = daten[k]; cbub
daten[k] = daten[k+1];
n(n – 1) daten[k+1] = t;

4 }
}
}
}

Abbildung 13.27 Überdeckungsanalyse für Bubblesort 13

13.2.2 Selectionsort
Ähnliche Überlegungen wie bei Bubblesort wenden wir jetzt auf Selectionsort an.

Zunächst zeigt der Überdeckungstest bei identischer Ausgangssituation das folgende


Bild:

n = 1000

void selectionsort( int n, int *daten)


1 {
n–1 int i, k, t, min;

999 for( i = 0; i < n-1; i++)


n(n – 1)
{
2 min = i; csel2
499500 for( k = i+1; k < n; k++)
{ csel1
if( daten[k] < daten[min])
5419 min = k;
}
999 t = daten[min];
daten[min] = daten[i];
daten[i] = t;
}
}

Abbildung 13.28 Überdeckungsanalyse für Selectionsort

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

Abbildung 13.29 Überdeckungsanalyse für Insertionsort

Bei zufällig verteilten Daten ergibt sich damit:

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;

5 for( h = 1; h <= n/9; h = 3*h+1)


;
for( ; h > 0; h /= 3)
{
4821 for( i = h; i < n; i++)
{
v = daten[i];
for( k = i; (k >= h) && (daten[k-h] > v); k -= h)
9690 daten[k] = daten[k-h];
daten[k] = v;
}
}
}

Abbildung 13.30 Überdeckungsanalyse für Shellsort

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:

void qcksort(int links, int rechts, int *daten)


1333 {
int pivot, i, j, t;

666 if( links < rechts)


{
i = links-1;
j = rechts;
pivot = daten[rechts];
for(;;)
{
2407 while( daten[++i] < pivot)
;
2407 while( (j > i) && (daten[--j] > pivot))
;
2407 if( i >= j)
666 break;
1741 t = daten[i];
daten[i] = daten[j];
daten[j] = t;
}
666 daten[ rechts] = daten[i];
daten[ i] = pivot;
qcksort( links, i-1, daten);
qcksort( i+1, rechts, daten);
}
}
Abbildung 13.31 Überdeckungsanalyse für Quicksort

Um qualitative Aussagen zur Laufzeit zu gewinnen, betrachten wir noch einmal die
Aufrufstruktur von Quicksort:

380
13.2 Leistungsanalyse der Sortierverfahren

links rechts
Pivot

alle Werte ≤ x x alle Werte ≥ x


log(n)

Abbildung 13.32 Aufrufstruktur von Quicksort

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)

Damit ist Quicksort das effizienteste der bisher betrachteten Sortierverfahren.


Zumindest bei der Sortierung großer Arrays mit Zufallsdaten wird Quicksort die Nase
vorn haben. Sie sehen aber auch, dass die Wahl des Pivots die Schwachstelle von
Quicksort ist. Liegt der Pivot ungünstig, wird ein Teilbereich immer nur um 1 verklei-
nert, was eine Rekursionstiefe von n und damit (Worst Case) eine quadratische Lauf-
zeit zur Folge hätte.

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

void heapsort( int n, int *daten)


1 {
int k, t;
n
for( k = n/2; k;)
2 500 adjustheap( n, daten, --k);
while( --n)
{
t = daten[0];
daten[0] = daten[n];
n–1 daten[n] = t;
999 adjustheap( n, daten, 0);
}
}

Abbildung 13.33 Überdeckungsanalyse für Heapsort

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

Damit ergibt sich für Heapsort wie für Quicksort:

theap(n) = n · log(n)

382
13.3 Leistungsmessung der Sortierverfahren

Obwohl Heapsort im asymptotischen Verhalten Quicksort entspricht, erwarten wir


aufgrund der aufwendigeren inneren Schleife ein schlechteres Laufzeitverhalten als
Quicksort. Allen anderen Verfahren dürfte Heapsort aber für hinreichend große
Datenmengen überlegen sein.

13.3 Leistungsmessung der Sortierverfahren


Bevor wir konkrete Messungen durchführen, fassen wir zusammen, was wir als
Ergebnis erwarten:

Es gibt drei Leistungsklassen:

1. Quicksort und Heapsort


2. Shellsort
3. Bubblesort, Selectionsort und Insertionsort

Für große Datenmengen müssten die Leistungsunterschiede zwischen den verschie-


denen Klassen deutlich sichtbar werden. Von Quicksort erwarten wir die beste Per- 13
formance. In der niedrigsten Leistungsklasse dürfte Insertionsort am besten und
Bubblesort am schlechtesten abschneiden. Shellsort ist schwer einzuschätzen.
Quicksort könnte problematisch sein, wenn spezielle Vorsortierungen im Array vor-
liegen, während Insertionsort von speziellen Vorsortierungen profitieren könnte.

Als Erstes testen wir mit zufällig verteilten Daten. Dazu verwenden wir den folgen-
den Testdatengenerator:

void testdaten1( int n, int *daten)


{
int i;

for( i = 0; i < n; i++)


daten[i] = rand()%n;
}

Zufallszahlen zwischen 0 und n-1

Abbildung 13.35 Testdatengenerator für die Sortierfunktionen

383
13 Sortieren

Für Arrays mit bis zu 10 Millionen Elementen erhalten wir dann die folgenden Mess-
ergebnisse:

testdaten1 Bubblesort Selectionsort Insertionsort Shellsort Quicksort Quicksort Heapsort


rekursiv iterativ
100 0,01 0,01 0,00 0,00 0,00 0,00 0,00
1000 0,86 0,50 0,22 0,07 0,07 0,05 0,07
10000 150,26 39,72 18,90 1,05 0,86 0,66 0,88
100000 18159,78 3898,27 1938,95 14,60 9,10 7,59 11,29
1000000 ca. 30 min ca. 6 min 3 min 456,57 85,17 150,36
10000000 ca. 2 Tage ca. 12 std ca. 6 std 4442,62 863,08 2507,95

Abbildung 13.36 Messergebnisse (in Millisekunden) für die Sortierfunktionen

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:

void testdaten2( int n, int *daten)


{
int i;

for( i = 0; i < n; i++)


daten[i] = (9*i)/10 + rand()%(n/10);
}

10% Abweichung von aufsteigender Ordnung

Abbildung 13.37 Erstellung aufsteigend sortierter Testdaten mit leichten Störungen

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:

testdaten2 Insertionsort Quicksort Heapsort


iterativ
100 0,00 0,00 0,00
1000 0,03 0,05 0,07
10000 1,48 0,60 0,81
100000 142,68 7,34 9,84
1000000 4632,99 97,25 120,40
10000000 3796,46 1279,30

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:

void testdaten3( int n, int *daten)


{
int i;

for( i = 0; i < (9*n)/10; i++)


daten[i] = i;
for( ; i < n; i++)
daten[i] = rand()%n;
}

Die letzten 10% der Daten sind


unsortiert.

Abbildung 13.39 Erstellung aufsteigend sortierter Testdaten mit unsortierten Daten am


Ende

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:

testdaten3 Insertionsort Quicksort Heapsort


iterativ
100 0,00 0,00 0,00
1000 0,03 0,04 0,06
10000 3,64 0,49 0,68
100000 591,22 99,74 7,04
1000000 71322,23 15651,39 76,21
10000000 884,36

Abbildung 13.40 Vergleich der Laufzeiten (in Millisekunden) für Testdaten


mit unsortiertem Ende

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:

void testdaten4( int n, int *daten)


{
int i, k;

for( i = 0; i < n; i++)


daten[i] = i;
for( i = 0; i < n/10; i++)
{
k = rand()%n;
daten[k] = rand()%n;
}
}
10% zufällig gewählte Daten sind
unsortiert.

Abbildung 13.41 Erstellung von Testdaten mit teilweise unsortierten Daten

386
13.3 Leistungsmessung der Sortierverfahren

Quicksort verschlechtert sich noch einmal, während Heapsort unverändert bleibt.


Das schnellste Programm ist jetzt aber Insertionsort:

testdaten4 Insertionsort Quicksort Heapsort


iterativ
100 0,00 0,00 0,00
1000 0,03 0,06 0,06
10000 2,60 0,63 0,68
100000 75,01 1751,50 6,67
1000000 203,31 70,88
10000000 221,15 795,65

Abbildung 13.42 Vergleich der Laufzeiten (in Millisekunden) für teilweise


unsortierte Testdaten

Wir haben also folgendes Ergebnis:


13
왘 Bei zufällig verteilten Daten ist Quicksort das beste Sortierprogramm. Quicksort
kann aber empfindlich einbrechen, wenn die Daten teilsortiert sind.
왘 Insertionsort arbeitet am schnellsten, wenn zur Sortierung nur wenige Daten über
kleine Stecken bewegt werden müssen.
왘 Heapsort ist sehr robust gegenüber verschiedenen Vorsortierungen und erreicht
fast die Performance von Quicksort.

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

13.4 Grenzen der Optimierung von Sortierverfahren


Wir haben eine Reihe von Sortierverfahren unterschiedlicher Leistungsfähigkeit
gefunden, aber keinem der Verfahren ist es gelungen, die magische Grenze n · log(n)
zu unterbieten. Ist das unser Unvermögen, oder sind wir auf eine natürliche
Schranke – also quasi auf ein Naturgesetz – gestoßen? Diese Frage wollen wir jetzt dis-
kutieren.

Ein Sortierverfahren erzeugt eine ganz bestimmte Permutation des zu sortierenden


Arrays. Wir wissen, dass es n! solcher Permutationen gibt. Ein Sortierverfahren, das
ein beliebiges Array mit n Elementen sortieren kann, muss also prinzipiell in der Lage
sein, alle möglichen Permutationen zu erzeugen, da es ja eine beliebig vorgegebene
Permutation rückgängig machen muss. Wenn das Verfahren dabei seine Informatio-
nen aus Einzelvergleichen zweier Elemente zieht, kann man Folgendes feststellen:

왘 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

Damit hat das Verfahren mindestens die Laufzeitkomplexität n · log(n).

Wir fassen dieses wichtige theoretische Ergebnis noch einmal zusammen:

Sortierverfahren, die auf Einzelvergleichen basieren, haben mindestens die Laufzeit-


komplexität n · log(n).

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

Abbildung 13.43 Erster Schritt der Postleitzahlensortierung

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

Abbildung 13.46 Ergebnis der Postleitzahlensortierung

Dieses Verfahren nennt man Distributionsort. Bei einer genauen Betrachtung des
Verfahrens können wir Folgendes feststellen:

Zu keinem Zeitpunkt haben wir die Postleitzahlen zweier Briefe miteinander


verglichen.

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.

Für die Softwareentwicklung spielen Datenstrukturen häufig sogar eine weitaus


wichtigere Rolle als Algorithmen, da Datenstrukturen in der Regel langlebiger sind
als Algorithmen und daher eine zentrale Rolle im Design von Softwaresystemen
spielen. Denken Sie z. B. an ein Programm, das die Studenten einer Hochschule ver-
waltet. Die einem Studenten zugehörigen Daten wären hier »Name«, »Vorname«,
»Matrikelnummer« sowie weitere Informationen über belegte Vorlesungen, Noten
etc. Die erforderlichen Datenstrukturen sind ein unmittelbares Abbild der in der Rea-
lität vorkommenden Daten und ihrer Beziehungen untereinander und als solches
weitaus stabiler als ein bestimmter Algorithmus, der etwa aus Einzelnoten eine
Gesamtnote berechnet oder die Teilnehmer einer Klausur nach aufsteigenden Matri-
kelnummern ordnet. Ein Algorithmus kann in einem gut modularisierten Pro-

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:

Abbildung 14.1 Daten der Länderspiele der deutschen Fußballnationalmannschaft

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

int tag; Die Felder haben


monat int monat; jeweils einen Typ
jahr int jahr; und einen Namen.
};
14
Abbildung 14.2 Deklaration der Datenstruktur 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.

Abbildung 14.3 Interpretation der Datenstruktur als Schablone

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;

Abbildung 14.4 Verschiedene Beispiele von Datenstrukturen

Nach Bedarf können aus allen Grunddatentypen Datenstrukturen zusammengestellt


werden, auch wenn wir es in unserem Beispiel nur mit ganzen Zahlen zu tun haben.

gesamt dfb tag


tore

datum

gew gegner monat


spiele

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;
};

Abbildung 14.5 Interpretation der Länderspieldaten als Datenstrukturen

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.

Damit ergibt sich die folgende Strukturdeklaration:

name [30] struct spiele


{
ergebnisse int gesamt;
gesamt int gew;
int unent;
gew int verl;
spiele

};
unent
verl struct tore
{
treffer struct bilanz int dfb;
{ int gegner;
dfb char name[30]; };
tore
bilanz

struct spiele ergebnisse;


gegner struct tore treffer;
struct datum
erstes struct datum erstes;
{
struct datum letztes;
tag };
int tag; 14
int monat;
datum

monat int jahr;


};
jahr

letztes struct datum


{
tag int tag;
datum

int monat;
monat int jahr;
};
jahr

Abbildung 14.6 Erweiterte Strukturdeklaration für die Länderspieldaten

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

int anzahl; struct bilanz


struct bilanz land[100]; {
};
bilanz


… };

… …
struct bilanz
{
bilanz

… …
};

Abbildung 14.7 Vollständige Strukturdeklaration

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

diert werden. Werden Datenstruktur-Deklarationen nur in einer einzigen Quelldatei


benötigt, können sie auch dort, typischerweise am Anfang der Datei, stehen. Kon-
krete Daten einer bestimmten Struktur erhalten Sie, indem Sie eine Variable anlegen.
Wenn Sie z. B. ein Kalenderdatum benötigen, können Sie schreiben:

Typ der Variablen

struct datum geburtstag;

Name der Variablen

Abbildung 14.8 Beispiel der Struktur für ein Kalenderdatum

Jetzt ist ein konkretes Datum entstanden, das auch schon bei der Definition mit Wer-
ten gefüllt werden kann:

Tag Monat Jahr 14

struct datum geburtstag = {17, 11, 2013};

Abbildung 14.9 Befüllung mit Werten bei der Definition

Im Speicher werden jetzt die konkreten Werte hinterlegt:

Abbildung 14.10 Ablage der Werte im Speicher

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;
};

Abbildung 14.11 Anlage komplexer Strukturen bei der Definition

14.2 Zugriff auf Strukturen


Die Werte einer Variablen können einer anderen Variablen zugewiesen werden, egal,
ob die Variablen nur auf einem einfachen Datentyp oder einer komplexen Struktur
basieren. Wichtig ist, dass bei einer Zuweisung auf der linken und rechten Seite des
Gleichheitszeichens der gleiche Datentyp steht.

struct datum heute = {31, 8, 2014};


struct datum morgen;
Ein komplettes Datum wird zugewiesen.
morgen = heute;

Abbildung 14.12 Zuweisung einer Datenstruktur

400
14.2 Zugriff auf Strukturen

Operationen wie z. B. Größenvergleich (<, >) oder arithmetische Operationen können


Sie auf Datenstrukturen nicht ausführen2. Wie sollte der Compiler auch wissen, wie
etwa der Vergleich zweier Kalenderdaten, im Sinne eines Vorher-nachher-Vergleichs,
durchgeführt werden sollte? Dazu müsste er ja die Bedeutung dieser Datenstruktur
kennen.

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
}

printf( "Datum: %d.%d.%d\n", morgen.tag, morgen.monat, morgen.jahr);

Abbildung 14.13 Zugriff auf die Elemente der Datumsstruktur

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;
};

Abbildung 14.14 Zugriffe unterschiedlicher Tiefe

struct daten dat;

dat.land[3].name[7] = 'a';
char

Array von char

struct bilanz

Array von struct bilanz

struct daten

Abbildung 14.15 Unterschiedliche Datentypen je Ebene

Auf der tiefsten Ebene haben Sie in diesem Beispiel den Datentyp char, sodass Sie
einen Buchstaben zuweisen können.

Im nächsten Beispiel endet der Zugriff auf einer Integer-Zahl:

402
14.2 Zugriff auf Strukturen

struct daten dat;

dat.land[2].ergebnisse.verl = 123;

int

struct spiele

struct bilanz

Array von struct bilanz

struct daten

Abbildung 14.16 Weiterer Zugriff mit anderem Datentyp

14.2.2 Indirektzugriff
Sie können auch Zeiger auf Datenstrukturen anlegen, wie Sie bereits Zeiger auf die 14
Grunddatentypen angelegt haben:

Zeiger auf ein Datum

struct datum *pointer;

Abbildung 14.17 Zeiger auf ein Datum

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

Noch haben geburtsdatum und


struct datum geburtsdatum; pointer nichts miteinander zu tun.
struct datum *pointer;
Der Zeiger erhält einen Adresswert.
pointer = &geburtsdatum;

(*pointer).tag = 17; Über den Zeiger werden Werte in


(*pointer).monat = 11; die referenzierte Datenstruktur
(*pointer).jahr = 2013; eingetragen.

Abbildung 14.18 Zeiger auf 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:

Der Zugriffsoperator für Indirektzugriff


Ist p ein Zeiger auf eine Datenstruktur und x ein Feld dieser Datenstruktur, lässt sich
auf das Feld mit den beiden geichwertigen Ausdrücken
(*p).x bzw. p->x
zugreifen. Beide Ausdrücke sind dabei als R-Value und L-Value – also sowohl auf der
rechten als auch auf der linken Seite einer Zuweisung – geeignet.
Den Ausdruck p->x lesen wir als »p points x«.

Damit können wir den Strukturzugriff im Beispiel oben etwas eleganter formulieren:

Zugriff mit *-Operator


struct datum geburtsdatum; Zugriff mit Points-Operator
struct datum *pointer; struct datum geburtsdatum;
struct datum *pointer;
pointer = &geburtsdatum;
pointer = &geburtsdatum;
(*pointer).tag = 17;
(*pointer).monat = 11; pointer->tag = 17;
(*pointer).jahr = 2013; pointer->monat = 11;
pointer->jahr = 2013;

Abbildung 14.19 Direkt- und Indirektzugriff im Vergleich

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.

14.3 Datenstrukturen und Funktionen


Datenstrukturen können als Parameter an Funktionen übergeben werden, und
Datenstrukturen können auch von Funktionen zurückgegeben werden.

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:

A struct datum datumseingabe()


{
struct datum dat; 14

printf( "Datum: ");


B scanf( "%d.%d.%d", &dat.tag, &dat.monat, &dat.jahr);
C return dat;
}

Listing 14.1 Strukturen als Rückgabetyp

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:

A int datumsvergleich( struct datum d1, struct datum d2)


{
B if( d1.jahr != d2.jahr)
return d1.jahr – d2.jahr;

405
14 Datenstrukturen

C if( d1.monat != d2.monat)


return d1.monat – d2.monat;

D return d1.tag – d2.tag;


}

Listing 14.2 Übergabe von Strukturen als Parameter

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).

Im Hauptprogramm testen wir die beiden Funktionen:

void main()
{
A struct datum datum1, datum2;

B datum1 = datumseingabe();
C datum2 = datumseingabe();

D if( datumsvergleich( datum1, datum2) < 0)


printf( "Das erste Datum liegt vor dem zweiten.\n");
else
printf( "Das zweite Datum liegt vor dem ersten\n");
}

Listing 14.3 Test des Datumsvergleichs

In unserem Testprogramm werden zwei Kalenderdaten (A) eingegeben, zugewiesen


und miteinander verglichen (D). Wir erhalten das erwartete Ergebnis.

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

dupliziert werden. Die damit verbundenen Speicher- und Laufzeitkosten entfallen,


wenn man anstelle der Datenstrukturen nur Zeiger auf die Datenstrukturen übergibt
und die Funktion auf den Originaldaten des aufrufenden Programms arbeiten lässt.
Wir stellen die Funktion datumsvergleich auf die konsequente Verwendung von Zei-
gern um:

A int datumsvergleich( struct datum *pd1, struct datum *pd2)


{
if( pd1->jahr != pd2->jahr)
return pd1->jahr – pd2->jahr;
if( pd1->monat != pd2->monat)
return pd1->monat – pd2->monat;
return pd1->tag – pd2->tag;
}

Listing 14.4 Umgestellte Version von datumsvergleich

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

Auch die Rückgabe großer Datenstrukturen mit anschließendem Kopieren in die


Datenstruktur des aufrufenden Programms können Sie vermeiden, wenn Sie einen
Zeiger auf die im Hauptprogramm bereitstehende Datenstruktur übergeben. Wir
praktizieren das am Beispiel der Funktion datumseingabe:

A void datumseingabe( struct datum *pd)


{
printf( "Datum: ");
B scanf( "%d.%d.%d", &pd->tag, &pd->monat, &pd->jahr);
}

Listing 14.5 datumseingabe mit veränderter Übergabe

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);

C if( datumsvergleich( &datum1, &datum2) < 0)


printf( "Das erste Datum liegt vor dem zweiten.\n");
else
printf( "Das zweite Datum liegt vor dem ersten\n");
}

Listing 14.6 Angepasstes Testprogramm

In dem angepassten Testprogramm werden jetzt Adressen übergeben (A, B, C),


anstatt die Daten zu kopieren.

Wir haben zwei funktionell gleichwertige Varianten unseres Beispielprogramms


erstellt. Der Unterschied zwischen den beiden Varianten liegt im Speicherverbrauch
und im Laufzeitverhalten. Der zusätzliche Speicherverbrauch auf dem Stack ist in
unseren Beispielen nicht von Bedeutung, zumal der Speicher immer nur sehr kurz
während eines Funktionsaufrufs benötigt wird. Bei sehr großen Datenstrukturen in
Verbindung mit Rekursion könnte das anders aussehen. Was die Laufzeit betrifft,
können Sie damit rechnen, dass die auf Zeiger umgestellten Funktionen ca. zwei- bis
dreimal so schnell wie die ursprünglichen Funktionen sind. Das liegt daran, dass es
sich um Funktionen handelt, bei denen die Behandlung der Schnittstellenparameter
einen substanziellen Anteil der Gesamtlaufzeit ausmacht. Bei häufigem Funktions-
aufruf, z. B. beim Sortieren einer großen Zahl von Kalenderdaten, kann sich das deut-
lich bemerkbar machen.

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.

3 Im klassischen Kernighan-Ritchie-C mussten übrigens Datenstrukturen immer per Zeiger über-


geben werden.

408
14.4 Ein vollständiges Beispiel (Teil 1)

14.4 Ein vollständiges Beispiel (Teil 1)


Zurück zur Aufgabe dieses Abschnitts. Wir wollen die Daten aus der Länderspielbi-
lanz des DFB einlesen und im Speicher zur Weiterverarbeitung zur Verfügung stellen.
Eine Datenstruktur dafür haben wir bereits erstellt. Abbildung 14.20 zeigt den aktuel-
len Enwicklungsstand:

struct daten struct spiele


{ {
int anzahl; int gesamt;
struct bilanz land[100]; int gew;
}; int unent;
struct bilanz int verl; struct datum
{ }; {
charname[30]; int tag;
struct spiele ergebnisse; int monat;
struct tore treffer; int jahr;
struct datum erstes; struct tore };
struct datum letztes; { 14
}; int dfb;
int gegner;
};

Abbildung 14.20 Die Daten in unterschiedlichen Strukturen

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");
}

Listing 14.7 Hauptprogramm zum Einlesen der Daten

4 Vergessen Sie die terminierende 0 hinter jedem String nicht!

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:

왘 fopen – Öffnen einer Datei


왘 feof – Test, ob hinter dem Dateiende gelesen wurde
왘 fscanf – Einlesen von Daten aus der Datei ähnlich scanf
왘 fclose – Schließen der Datei

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:

A int lies_datei( struct daten *pd, char *dateiname)


{
FILE *pf;
int anz;
B pd->anzahl = 0;
C pf = fopen( dateiname, "r");
if( !pf)
D return 0;

E for( anz = 0; lies_bilanz( pf, pd->land+anz); anz++)


;
F fclose( pf);
pd->anzahl = anz;
G return anz;
}

Listing 14.8 Die Funktion lies_datei

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:

A int lies_bilanz( FILE *pf, struct bilanz *pb)


{
B fscanf( pf, "%s", pb->name);
if( feof( pf))
C return 0;
D fscanf( pf, "%d %d %d %d", &pb->ergebnisse.gesamt,
&pb->ergebnisse.gew,
&pb->ergebnisse.unent,
14
&pb->ergebnisse.verl);
E fscanf( pf, "%d:%d", &pb->treffer.dfb,
&pb->treffer.gegner);
F fscanf( pf, "%d.%d.%d", &pb->erstes.tag,
&pb->erstes.monat,
&pb->erstes.jahr);
G fscanf( pf, "%d.%d.%d", &pb->letztes.tag,
&pb->letztes.monat,
&pb->letztes.jahr);
H return 1;
}

Listing 14.9 Die Funktion lies_bilanz

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.

Es gilt also: *(a+i) = a[i]

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:

pd ist ein Zeiger auf eine Datenstruktur daten


pd->land ist das Array mit den Länderbilanzen
pd->land[anz] ist die Bilanz mit dem Index anz
&(pd->land[anz]) ist die Adresse an der Bilanz mit dem Index anz

Wegen array[i] = *(array+i) ist &array[i] = array+i.

lies_bilanz( pf, pd->land+anz) Anstelle von &(pd->land[anz]) können wir daher auch
pd->land+anz schreiben.

int lies_bilanz( FILE *pf, struct bilanz *pb)


{

}

Abbildung 14.21 Leseanleitung für den Funktionsaufruf

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.

void print_daten( struct daten *pd)


{
int i;
for( i = 0; i < pd->anzahl; i++)
print_bilanz( pd->land + i);
}

Listing 14.10 Die Funktion print_daten

void print_bilanz( struct bilanz *pb)


{
printf( "%-25s", pb->name);
printf( " %3d %3d %3d %3d", pb->ergebnisse.gesamt,

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");
}

Listing 14.11 Die Funktion print_bilanz

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;

lies_datei( &dat, "Laenderspiele.txt");


print_daten( &dat);
}

Abbildung 14.22 Ergebnis unseres Ausgabetests

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:

int lieblingsgegner( struct daten *pd)


{
int i;
A int index;
int max = –1;
B for( i = 0; i < pd->anzahl; i++)
{
C if( pd->land[i].ergebnisse.gesamt > max)
{
D max = pd->land[i].ergebnisse.gesamt;
E index = i;
}
}
F return index;
}

Listing 14.12 Finden des Lieblingsgegners

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).

Im Hauptprogramm ist dann nur noch wenig zu tun:

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);
}

Listing 14.13 Das zugehörige Hauptprogramm

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

Der Lieblingsgegner der deutschen Fußballnationalmannschaft ist also die Schweiz


mit 51 Länderspielen.

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.

14.5 Dynamische Datenstrukturen


In diesem Abschnitt wollen wir uns von der Beschränkung lösen, dass wir zum Kom-
pilationszeitpunkt bereits festlegen müssen, wie groß die zu verarbeitenden Daten-
strukturen maximal sind. Eine zentrale Rolle spielen dabei die Funktionen malloc
und free, die Sie in Abschnitt 10.6, »Freisprecherverwaltung«, bereits kennengelernt
haben. Stellen Sie sich vor, dass wir an irgendeiner Stelle unseres Programms 1000
Bytes brauchen, um dort etwa einen Text zu speichern. Dann können wir über die
14
Funktion malloc genau die benötigten 1000 Bytes vom Laufzeitsystem anfordern.
Als Ergebnis des Funktionsaufrufs erhalten wir die Adresse des für uns reservierten
Speichers. Diese Adresse müssen wir in einem Zeiger speichern, damit wir über die-
sen Zeiger auf den Speicher zugreifen können. Im Code könnte das so aussehen:

1000 Bytes werden


char *zeiger; angefordert.

zeiger = (char *)malloc( 1000);

Die Adresse wird dem Zeiger zugewiesen. Der Datentyp wird angepasst.

Abbildung 14.23 Allokieren von Speicher

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

Über den Zeiger wird


auf den Speicher
zugegriffen.

zeiger[0] = 'A';
zeiger[1] = 'B'; AB
zeiger[2] = 0;
printf( "%s\n",zeiger);

Abbildung 14.24 Zugriff auf den allokierten Speicher

Wichtig ist auch, dass der Speicher, wenn wir ihn nicht mehr benötigen, wieder frei-
gegeben werden muss.

free ( zeiger );

Der Speicher wird Die Speicheradresse


wieder freigegeben. darf danach nicht mehr
verwendet werden.

Abbildung 14.25 Freigabe des allokierten Speichers

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);

Listing 14.14 Allokieren einer Datenstruktur

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).

Wir erweitern die Datenstruktur für ein Kalenderdatum in diesem Sinne:

struct datum
{
A struct datum *next;
int tag;
int monat;
int jahr;
};

Listing 14.15 Erweiterte Datenstruktur

417
14 Datenstrukturen

Das ist der Listenanker. Das ist der Verkettungszeiger.


Der Datentyp ist »Zeiger auf Der Datentyp ist »Zeiger auf
datum«, also struct datum *. datum«, also struct datum *.

liste next next next

datum
tag
datum

datum
tag tag

monat monat monat

jahr jahr jahr

Das sind die Listenelemente vom


Typ struct datum.

Abbildung 14.26 Das Konzept einer Liste

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

struct datum *eingabe()


{ Dies ist der Listenanker. Die Liste ist noch leer.
struct datum *anker = NULL;
struct datum *pneu; Hilfszeiger für neue Listenelemente
int weiter;

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:

Diese Liste soll ausgegeben werden.

void ausgabe( struct datum *liste)


{
struct datum *pd; Hilfszeiger

printf( "Daten in der Liste:\n");

Starte am Solange die Liste … gehe zum nächsten


Listenanfang. nicht zu Ende ist …, Listenelement.

for( pd = liste; pd; pd = pd->next)


printf( " %d.%d.%d\n", pd->tag, pd->monat, pd->jahr);
}
Gib ein Listenelement aus.

Abbildung 14.28 Die Funktion ausgabe

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.

Diese Liste soll vollständig freigegeben werden, …

static void freigabe( struct datum *liste)


{
struct datum *pd; … solange die Liste nicht leer ist.
for( ; liste;)
{ Das erste Element wird im Hilfszeiger
pd= liste; 1 gemerkt, dann ausgekettet
liste = liste->next; 2
free( pd);
} 2 liste
}
pd 1

und abschließend freigegeben.

Abbildung 14.29 Die Funktion freigabe

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);
}

Wir erhalten z. B. das folgende Ergebnis:

420
14.6 Ein vollständiges Beispiel (Teil 2)

Noch ein Datum? 1


Datum 29.12.1999
Noch ein Datum? 1
Datum 30.12.1999
Noch ein Datum? 1
Datum 31.12.1999
Noch ein Datum? 1
Datum 1.1.2000
Noch ein Datum? 0
Daten in der Liste:
1.1.2000
31.12.1999
30.12.1999
29.12.1999

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

14.6 Ein vollständiges Beispiel (Teil 2)


Zum Abschluss dieses Kapitels werden wir das Beispiel mit der Länderspielbilanz
vollständig auf dynamische Datenstrukturen umstellen. Große Teile des Codes aus
der nicht dynamischen Anfangsversion können wir dabei übernehmen, einiges müs-
sen wir aber ändern.

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:

struct bilanz *lies_bilanz( FILE *pf)


{
A char land[100];
struct bilanz *pb;
B fscanf( pf, "%s", land);
if( feof( pf))
return NULL;
C pb = (struct bilanz *)malloc( sizeof( struct bilanz));

D pb->name = (char *)malloc( strlen( land)+1);


E strcpy( pb->name, land);

F fscanf( pf, "%d %d %d %d", &pb->ergebnisse.gesamt,


&pb->ergebnisse.gew,
&pb->ergebnisse.unent,
&pb->ergebnisse.verl);
fscanf( pf, "%d:%d", &pb->treffer.dfb,
&pb->treffer.gegner);
fscanf( pf, "%d.%d.%d", &pb->erstes.tag,
&pb->erstes.monat,
&pb->erstes.jahr);
fscanf( pf, "%d.%d.%d", &pb->letztes.tag,
&pb->letztes.monat,
&pb->letztes.jahr);
G return pb;
}

Listing 14.16 Geändertes Einlesen einer Bilanz

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:

A struct bilanz *lies_datei( char *dateiname)


{
FILE *pf;
struct bilanz *liste, *pb;

pf = fopen( dateiname, "r");


if( !pf)
return NULL; 14
B for( liste = NULL; pb = lies_bilanz( pf); )
{
C pb->next = liste;
liste = pb;
}
fclose( pf);
return liste;
}

Listing 14.17 Geändertes Einlesen einer Datei

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

struct bilanz *lies_datei2( char *dateiname)


{
FILE *pf;
A struct bilanz *liste;
B struct bilanz **ppb;

pf = fopen( dateiname, "r");


if( !pf)
return NULL;
for( ppb = &liste; *ppb = lies_bilanz( pf); ppb = &((*ppb)->next))
;
fclose( pf);
return liste;
}

Listing 14.18 Einlesen der Datei mit Einketten am Ende

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

liste next next next


bilanz

bilanz

bilanz

Abbildung 14.30 Darstellung der doppelten Indirektion

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:

A void freigabe( struct bilanz *liste)


{
struct bilanz *pb;

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,

void print_bilanz( struct bilanz *pb)


{
printf( "%-25s", pb->name);
printf( " %3d %3d %3d %3d", pb->ergebnisse.gesamt,
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);

425
14 Datenstrukturen

printf( "\n");
}

Listing 14.20 Ausgeben eine Bilanz

können wir testen, ob der Auf- und Abbau der Liste klappen.

void main()
{
struct bilanz *liste;
struct bilanz *pb;

A liste = lies_datei2( "Laenderspiele.txt");

for( pb = liste ; pb; pb = pb->next)


B print_bilanz( pb);

C freigabe( liste);
}

Listing 14.21 Auf- und Abbau der 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:

Aegypten 1 0 0 1 1:2 28.12.1958 28.12.1958


Albanien 14 13 1 0 38:10 08.04.1967 06.06.2001
Algerien 2 0 0 2 1:4 01.01.1964 16.06.1982
Argentinien 20 6 5 9 28:28 08.06.1958 15.08.2012
Armenien 2 2 0 0 9:1 09.10.1996 10.09.1997
Aserbaidschan 4 4 0 0 15:2 12.08.2009 07.06.2011
Australien 4 3 0 1 12:5 18.06.1974 29.03.2011
Belgien 25 20 1 4 58:26 16.05.1910 11.10.2011
Boehmen-Maehren 1 0 1 0 4:4 12.11.1939 12.11.1939
Bolivien 1 1 0 0 1:0 17.06.1994 17.06.1994

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:

A struct bilanz *select_land( struct bilanz *pb, char *land)


{
B for( ; pb; pb = pb->next)
{
if( !strcmp( land, pb->name))
C return pb;
}
D return NULL;
}

Listing 14.22 Suchen einer Bilanz anhand des Ländernamens

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;

liste = lies_datei2( "Laenderspiele.txt");

A pb = select_land( liste, "Italien");


if( pb)
B print_bilanz( pb);

freigabe( liste);
}

Listing 14.23 Testen der Bilanzsuche

Das Hauptprogramm sucht die Daten für Italien (A), gibt den gefundenen Datensatz
aus (B) und erzielt damit dieses Ergebnis:

Italien 31 7 9 15 35:47 01.01.1923 28.06.2012

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:

A struct bilanz *select_buchstabe( struct bilanz *pb, char buchstabe)


{
B for( ; pb; pb = pb->next)
{
if( pb->name[0] == buchstabe)
C return pb;
}
D return NULL;
}

Listing 14.24 Selektieren eines Landes anhand des Anfangsbuchstabens

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).

Abgesehen von dem unterschiedlichen Vergleichskriterium, ist diese Funktion iden-


tisch mit der Funktion zur Ländersuche. Wir werden diese Funktion nur etwas anders
verwenden:

void main()
{
struct bilanz *liste;
struct bilanz *pb;

liste = lies_datei2( "Laenderspiele.txt");


A for( pb = liste; pb = select_buchstabe( pb, 'B'); pb = pb->next)
B print_bilanz( pb);

freigabe( liste);
}

Listing 14.25 Ausgabe der Länderspiele gegen Länder mit ›B‹

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)

Belgien 25 20 1 4 58:26 16.05.1910 11.10.2011


Boehmen-Maehren 1 0 1 0 4:4 12.11.1939 12.11.1939
Bolivien 1 1 0 0 1:0 17.06.1994 17.06.1994
Bosnien-Herzegowina 2 1 1 0 4:2 11.10.2002 03.06.2010
Brasilien 21 4 5 12 24:39 05.05.1963 10.08.2011
Bulgarien 21 16 2 3 56:24 20.10.1935 21.08.2002

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

A int test( struct bilanz *pb, void *p)


{
B struct datum *pd = (struct datum *)p;
int d1, d2;

C d1 = ((pd->jahr * 12) + pd->monat)*31 + pd->tag;


D d2 = ((pb->letztes.jahr * 12) + pb->letztes.monat)*31
+ pb->letztes.tag;

E return d2 >= d1;


}

Listing 14.26 Datumsvergleich

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:

int test (struct bilanz *, void *);

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;
}

Listing 14.27 Die Funktion select

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};

liste = lies_datei2( "Laenderspiele.txt");


B for( pb = liste; pb = select( pb, test, (void *)&dat); pb = pb->next)
print_bilanz( pb);

freigabe( liste);
}

Listing 14.28 Hauptprogramm zur Datumssuche

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).

Als Ergebnis wird dargestellt:

Ecuador 2 2 0 0 7:2 20.06.2006 29.05.2013


Frankreich 25 8 6 11 42:41 15.03.1931 06.02.2013
Kasachstan 4 4 0 0 14:1 12.10.2010 26.03.2013
Paraguay 2 1 1 0 4:3 15.06.2002 14.08.2013
USA 9 6 0 3 21:15 13.06.1993 02.06.2013

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.

14.7 Die Freispeicherverwaltung


Mit den Funktionen zur Freispeicherverwaltung (malloc, calloc, realloc und free)
können wir uns – im Rahmen des verfügbaren Speichers – von den Fesseln der stati-
schen Datenstrukturen lösen. Doch zusätzliche Freiheiten bringen auch zusätzliche
Gefahren, und es ist eine besondere Sorgfalt im Umgang mit den erweiterten Mög-
lichkeiten geboten. Durch die neuen Funktionen ergibt sich eine ganz neue Art von
schwer zu entdeckenden, ja geradezu heimtückischen Fehlerquellen. Die neue Quali-
tät dieser Fehler liegt darin, dass sie:

왘 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

Um diese neuen Fehlerquellen zu verstehen und um Fehler im Umgang mit dem


Freispeichersystem zu vermeiden, müssen wir uns mit der dynamischen Speicher-
verwaltung des Laufzeitsystems beschäftigen. Es handelt sich dabei natürlich um
Dienste, die vom jeweiligen Betriebssystem bereitgestellt werden und daher auf ver-
schiedenen Systemen unterschiedlich implementiert sein können. Trotzdem gibt es
einige gemeinsame Prinzipien, die Sie hier kennenlernen sollen.
Das Betriebssystem verwaltet den Speicher in Form von Seiten (Pages). Typische
Page-Größen sind 1 oder 4 Kilobyte. Bei einem virtuellen Speichersystem sieht der C-
Programmierer gar nicht die wirklichen (physikalischen) Adressen, sondern das Sys-
tem stellt einem Programm einen virtuellen Adressraum zur Verfügung, der unter
Umständen viel größer sein kann als der vorhandene, physikalische Adressraum.
Das Betriebssystem muss dann natürlich sicherstellen, dass immer dann, wenn ein
Programm über eine virtuelle Adresse zugreift, die physikalische Adresse hinterlegt
wird. Diesen Vorgang des bedarfsgerechten Seitenwechsels nennt man »Paging«.
Da ein Programm aber seinen Speicher in unterschiedlichen Größenordnungen
anfordern möchte, muss es über dem Paging-System noch eine Organisation zur
Speicherzuteilung geben. Das System verwaltet dazu eine Liste freier Speicherblöcke.
Diese Liste ist z. B. nach aufsteigenden Speicheradressen geordnet. Jeder Speicher- 14
block enthält am Anfang eine gewisse Verwaltungsinformation (Länge, nächster
freier Block, ...). Fordert nun ein Programm Speicher einer gewissen Größe an, durch-
läuft das System diese Kette auf der Suche nach einem Speicherblock geeigneter
Größe. Es gibt dafür verschiedene Strategien. Das System kann sich für den ersten
Block, der groß genug ist (First Fit), oder für den kleinsten freien Block, der groß
genug ist (Best Fit), entscheiden. Wird kein Block gefunden, werden neue Pages vom
Betriebssystem angefordert. Geht das auch nicht, kann die Nachfrage nicht befriedigt
werden. Wurde ein Block gefunden, kann das System den Block noch optimal anpas-
sen, indem der nicht benötigte Teil abgespalten und in die Freikette aufgenommen
wird. Das System kann aber auch einfach einen zu großen Block ausliefern, etwa
wenn der Rest zu klein ist, um noch nach Abzug des Verwaltungsanteils genutzt zu
werden. In jedem Fall liefert das System als Ergebnis der Speicherallokierung einen
Zeiger auf den Nutzbereich des allokierten Blocks an das rufende Programm.
Wird ein Speicherblock wieder zurückgegeben, wird der Block wieder in die Freikette
eingefügt. Beachten Sie, dass das System die Größe des Blocks an der transparent
mittransportierten Verwaltungsinformation erkennen kann. Um eine unnötige
Fragmentierung des Speichers zu vermeiden, werden dann benachbarte Blöcke in
der Freikette wieder zu größeren Einheiten zusammengefasst.
Der Speicher ist letztlich ein großer Flickenteppich, bestehend aus Verwaltungsdaten
und nutzbaren Bereichen. Erstere sind entweder unter Kontrolle des Speicherverwal-
tungssystems oder transparent an ein Programm ausgeliehen. Letztere liegen entwe-
der in der Freikette brach oder sind zur Nutzung an ein Programm ausgeliehen.

433
14 Datenstrukturen

Da im C-Laufzeitsystem Effizienz den höchsten Vorrang hat, gibt es keine Vorkehrun-


gen, diese verschiedenen Speicherbereiche vor Missbrauch zu schützen, und auch
keine Vorkehrungen, ungenutzten Speicher automatisch der Freispeicherverwal-
tung wieder zuzuführen (Garbage Collection).

Im Folgenden zeigen wir Ihnen einige typische Fehler im Umgang mit dem Freispei-
chersystem.

Die hier zunächst aufgeführten Fehler führen in der Regel zu Programmabstürzen.


Sie sind jedoch schwer zu lokalisieren, da eine Beziehung zwischen Ursache und Wir-
kung kaum auszumachen ist.

Ursache Auswirkung

Es wird vergessen, Spei- Über »wilde«, d. h. nicht sinnvoll initialisierte, Zeiger


cher zu allokieren. wird irgendwo im Speicher geschrieben. Die Folge sind
korrupte Daten und/oder Programmabstürze.

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.

Allokierter Speicher wird Die Daten der Freispeicherverwaltung werden zerstört.


mehrfach freigegeben. Früher oder später treten interessante Fehler oder
scheinbar unmotivierte Programmabstürze auf.

Ein Programmteil In den anderen Programmteilen treten unerklärliche


schreibt in Datenberei- Fehler auf, die ihrerseits wieder unabsehbare Folgefehler
chen anderer Programm- haben können. Oft treten unerklärliche Fehler in Pro-
teile. grammen auf, die seit langer Zeit stabil laufen. Oft sind
sogar beide Programmteile lange stabil gelaufen, und
der Fehler ist erst zu beobachten, nachdem an ganz
anderer Stelle etwas geändert und dadurch etwa nur die
Reihenfolge, in der die beiden Programmteile ablaufen,
vertauscht wurde. Eine Zuordnung von Ursache und Wir-
kung ist nicht möglich.

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.

Tabelle 14.1 Ursache und Wirkung bei Fehlern in der Speicherverwaltung

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

Allokierter Speicher wird Geschieht dies etwa in einer häufig durchlaufenen


nicht freigegeben. Schleife, kann das Programm krebsartig wachsen und
letztlich so viel Ressourcen beanspruchen, dass das Sys-
tem überlastet ist.

Speicher wird in zu gro- Das System arbeitet ineffizient, da große Speicherberei-


ßen Portionen allokiert. che ungenutzt sind.

Speicher wird in zu klei- Das Programm arbeitet ineffizient. Gemeinsam zu verar-


nen Portionen allokiert. beitende Datenbereiche sind über viele Pages verstreut.
Das System wird durch übermäßiges Paging verlang-
samt oder blockiert. Das Verhältnis von Nutzdaten zu
Verwaltungsdaten in der Freispeicherverwaltung ist sehr
schlecht.
14
Tabelle 14.2 Ursache und Wirkung bei Fehlern in der Speicherverwaltung

Scheinbar geringfügige Fehler dieser Art können insbesondere in großen Systemen,


bei denen in der Regel verschiedene Programmierer an den betroffenen Programm-
teilen arbeiten, einen erheblichen Aufwand bei der Fehlersuche verursachen.

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:

왘 die Namen aller beteiligten Mannschaften


왘 die Ergebnisse aller durchgeführten Spiele

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:

왘 eine Funktion, die alle Daten einliest


왘 eine Funktion, die die Tabelle berechnet
왘 eine Funktion, die alle Daten und die aktuelle Tabelle ausgibt

A 14.5 Erstellen Sie ein Programm, das eine Liste von Zahlen verwaltet. Das Pro-
gramm soll folgende Funktionen enthalten:

왘 Erzeugen der Liste mit einer wählbaren Anzahl fortlaufender Zahlen


왘 Ausgeben der Liste
왘 Ausgeben der Liste in umgekehrter Reihenfolge
왘 Addieren aller Zahlenwerte in der Liste
왘 Umkehren der Liste
왘 Freigeben der Liste

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

Natürlich können wir nicht für alle möglichen Aufgabenstellungen angepasste


Datenstrukturen bereitstellen. Ähnlich wie bei Algorithmen müssen wir hier eine
Auswahl treffen. Die wichtigste Aufgabe von Datenstrukturen ist, eine große Menge
von Daten so zu speichern, dass konkrete Daten in der Datenstruktur effizient
gesucht und natürlich auch gefunden werden können. Dazu gibt es verschiedene
Ansätze, die wir in diesem Abschnitt verfolgen werden. Zunächst aber präzisieren wir
die Aufgabenstellung.

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.

Als Ausgangspunkt unserer Überlegungen betrachten wir eine vereinfachte Länder-


spieldatei, die für jeden Gegner der Nationalmannschaft nur den Ländernamen und
die Anzahl der Länderspiele enthält.

...
Bolivien 1
Oesterreich 38
Paraguay 2
Marokko 4
Belgien 25
...

Abbildung 15.1 Vereinfachte Länderspieldatei

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

Abbildung 15.2 Container zur Speicherung von Spielgegnern

Der Container soll dabei folgende Funktionen haben:

왘 Erzeuge einen leeren Container.


왘 Füge einen Gegner zum Container hinzu.
왘 Suche einen Gegner im Container.
왘 Lösche den Container mit seinem Inhalt.

Wir wollen verschiedene Speichertechniken für den Container entwickeln und diese
bezüglich ihrer Laufzeit- und Speichereffizienz miteinander vergleichen.

Konkret implementiert wird der Container als:

왘 Liste
왘 Binärbaum
왘 Treap
왘 Hash-Tabelle

Mit den Listen fangen wir an.

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:

Abbildung 15.3 Visualisierung einer Liste 15

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.

Abbildung 15.4 Visualisierung 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

Abbildung 15.5 Doppelt verkettete Liste mit Anker und Ende

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:

Operation Array Einfach verkettete Doppelt verkettete


Liste Liste

wahlfreier sehr gut durch schlecht, da die Liste sequenziell durchlaufen


Zugriff Zugriff über Index werden muss

Einfügen schlecht, da alle sehr einfach durch Einketten des neuen


hinter einem nachfolgenden Elements
Element Elemente ver-
schoben werden
müssen; wenn
kein Platz im
Array ist, sogar
sehr aufwendig

Einfügen aufwendig, da der einfach durch Einket-


vor einem Vorgänger gesucht ten des neuen
Element werden muss Elements

Tabelle 15.1 Vergleich von Arrays und Listen

440
15.1 Listen

Operation Array Einfach verkettete Doppelt verkettete


Liste Liste

Entfernen schlecht, da alle sehr einfach durch Ausketten des Elements


hinter einem nachfolgenden
Element Elemente ver-
schoben werden
müssen

Entfernen aufwendig, da der einfach durch Aus-


vor einem Vorgänger gesucht ketten des Elements
Element werden muss

Gehe zum einfach durch einfach durch Ausnutzung der Vorwärtsver-


Nachfolger! Erhöhung des kettung
Index

Gehe zum einfach durch aufwendig, da der einfach durch Aus-


Vorgänger! Heruntersetzen Vorgänger gesucht nutzung der Rück-
des Index werden muss wärtsverkettung

Vertauschen einfach aufwendig, da zur etwas verzwickt,


zweier Aktualisierung der aber nicht so auf- 15
Elemente Verkettung eine Vor- wendig wie bei ein-
gängersuche erfor- fach verketteten
derlich ist Listen

Tabelle 15.1 Vergleich von Arrays und Listen (Forts.)

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.

Grundsätzlich kann man sagen:

Bei dynamisch wachsenden und schrumpfenden, vielleicht sogar inhomoge-


nen Datenbeständen stark variierender Anzahl mit häufigen Einsetz- und
Löschoperationen und vorrangig sequenziellem Zugriff sollten Sie Listen
bevorzugen.

Bei homogenen Datenbeständen fester Anzahl, die einen effizienten und wahl-
freien Zugriff erfordern, sind Arrays die erste Wahl.

Zurück zum Container für die Länderspieldaten. Im Container implementieren wir


eine einfach verkettete Liste. Die Listeneinträge werden dabei alphabetisch sortiert.

441
15 Ausgewählte Datenstrukturen

struct listentry
struct liste {
{ struct listentry *nxt;
struct listentry *first; struct gegner *geg;
}; };

Paraguay

Oesterreich

Marokko

Bolivien

Abbildung 15.6 Darstellung des Containers

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.

Alle Datenstrukturen im Container werden dynamisch erzeugt. Ein leerer Container


besteht aus einem Header (struct liste), dessen first-Zeiger den Wert 0 hat, da es
noch keine Listeneinträge gibt. Im Konstruktor (list_create) wird ein solcher leerer
Container angelegt:

struct liste *list_create()


{
struct liste *l;

A l = (struct liste *)malloc( sizeof( struct liste));


B l->first = 0;
C return l;
}

Listing 15.1 Anlegen eines leeren Containers

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

neuer Container erzeugt. Ein Anwendungsprogramm kann daher mehrere Container


erzeugen und unabhängig voneinander verwenden:

struct liste *container1;


struct liste *container2;

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.

Wir implementieren diese Suchstrategie:

A struct gegner *list_find( struct liste *l, char *name)


{
struct listentry *e;
int cmp;

B for( e = l->first; e; e = e->nxt)


{
C cmp = strcmp( name, e->geg->name);
if( !cmp)
D return e->geg;
else if( cmp < 0)
E break;
}
F return 0;
}

Listing 15.2 Implementierung der Suchstrategie

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:

int list_insert( struct liste *l, struct gegner *g)


{
struct listentry **e, *neu;
int cmp;

A for( e = &(l->first); *e; e = &((*e)->nxt))


{
B cmp = strcmp( g->name, (*e)->geg->name);
if( !cmp)
C return 0;
else if( cmp < 0)
D break;
}
E neu = (struct listentry *)malloc( sizeof( struct listentry));
F neu->nxt = *e;
G *e = neu;
H neu->geg = g;
I return 1;
}

Listing 15.3 Einfügen in die Liste

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:

void list_free( struct liste *l)


{
struct listentry *e;

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«.

struct liste *list_load( char *datei)


{
FILE *pf;
char land[100];
struct liste *l;
struct gegner *g;

445
15 Ausgewählte Datenstrukturen

pf = fopen( datei, "r");

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;
}

Listing 15.5 Öffnen und Einlesen der Datei

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).

Im Hauptprogramm kann der Container jetzt verwendet werden:

void main()
{
struct liste *l;
char land[100];
struct gegner *g;
int i;

A l = list_load( "Laenderspiele.txt");

for( i = 0; i < 3; i++)


{
printf( "Land: ");
scanf( "%s", land);
B g = list_find( l, land);

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);
}

Listing 15.6 Verwenden des Containers im Hauptprogramm

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).

Listen sind einfach zu implementieren und können durch kleine, überschaubare


Funktionen gepflegt werden. Aber je größer der Datenbestand in einer Liste wird,
desto mehr zeigen sich die Nachteile von Listen gegenüber Arrays. Der Suchaufwand
in einer Liste ist proportional zur Anzahl der gespeicherten Objekte. Da Sie in einem
Array, durch eine einfache Indexberechnung, wahlfrei auf jedes Element zugreifen
können, können Sie in einem sortierten Array eine Suchstrategie mit logarithmi-
schem Aufwand implementieren. Dazu betrachten Sie immer das Element in der
Mitte des Suchbereichs. Entweder haben Sie dann das gesuchte Element gefunden,
oder Sie können den Suchbereich halbieren. Durch fortlaufende Halbierung des
Suchbereichs erhalten Sie eine maximal logarithmische Suchtiefe. Für große Such-
räume bedeutet das, wie Sie aus unseren Überlegungen zur Laufzeitkomplexität
bereits wissen, einen erheblichen Unterschied.

447
15 Ausgewählte Datenstrukturen
Aegypten

Liste für 50 zufällig gewählte Gegner


+-Albanien
+-Algerien
+-Argentinien
+-Belgien
+-Bolivien

Maximale Suchtiefe: 50
+-Bosnien-Herzegowina
+-Bulgarien

Mittlere Suchtiefe: 25.5


+-Chile
+-China
+-Daenemark
+-Ecuador
+-Estland
+-Faeroeer
+-Finnland
+-Frankreich
+-GUS
+-Irland
+-Island
+-Israel
+-Italien
+-Japan
+-Jugoslawien
+-Kasachstan
+-Kolumbien
+-Luxemburg
+-Marokko
+-Mexiko
+-Neuseeland
+-Niederlande
+-Nordirland
+-Norwegen
+-Oesterreich
+-Oman
+-Paraguay
+-Peru
+-Polen
+-Portugal
+-Rumaenien
+-Russland
+-San-Marino
+-Serbien-Montenegro
+-Suedkorea
+-Thailand
+-Tuerkei
+-Tunesien
+-USA
+-Ukraine
+-V-A-Emirate
+-Wales
Abbildung 15.7 Suche und Suchtiefe im Container (Liste)

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

Abbildung 15.8 Darstellung eines Baums

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

Abbildung 15.9 Darstellung eines Binärbaums

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

Abbildung 15.10 Die Ebenen eines Baums

Damit ergeben sich die folgenden, wichtigen mathematischen Formeln:

왘 Auf dem Level i eines Binärbaums sind maximal 2i Knoten.


2t – 1
왘 Ein Binärbaum der Tiefe t hat maximal 2 0 + 2 1 + 2 2 + … + 2 t – 1 = ------------- = 2 t – 1
2–1
Knoten.
왘 Ein Binärbaum mit n Knoten hat mindestens die Tiefe log2(n).

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.

struct node /* Knoten des Levels 4 */


{ struct node n08 = { 0, 0, 8};
struct node *left; struct node n12 = { 0, 0, 12};
16 struct node *right; struct node n24 = { 0, 0, 24};
int value; struct node n28 = { 0, 0, 28};
};
/* Knoten des Levels 3 */
Der Knoten n22 hat den struct node n02 = { 0, 0, 2};
6 18 struct node n10 = { &n08, &n12, 10};
Wert 22, den Knoten n20
als linken und den Knoten struct node n20 = { 0, 0, 20};
n26 als rechten Nachfolger. struct node n26 = { &n24, &n28, 26}; 15
4 14 22 /* Knoten des Levels 2 */
struct node n04 = { &n02, 0, 4};
struct node n14 = { &n10, 0, 14};
struct node n22 = { &n20, &n26, 22};

2 10 20 26 /* Knoten des Levels 1 */


struct node n06 = { &n04, &n14, 6};
struct node n18 = { 0, &n22, 18};

/* Wurzel */
8 12 24 28 struct node n16 = { &n06, &n18, 16};

Abbildung 15.11 Statisches Anlegen eines Baums

Dieser Baum wird als ein Beispiel für weitere Überlegungen dieses Abschnitts dienen.

15.2.1 Traversierung von Bäumen


Bei Listen gab es eine natürliche Weise, vorwärts oder rückwärts durch alle Elemente
zu iterieren. Bei Bäumen gibt es dagegen keine natürliche Besuchsreihenfolge. Man
kann unterschiedliche Besuchsstrategien entwickeln. Zum Beispiel kann man »Kin-
der« vor »Geschwistern« (Tiefensuche, Depth-First) oder »Geschwister« vor »Kin-
dern« (Breitensuche, Breadth-First) besuchen:

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

Abbildung 15.12 Traversierungsmöglichkeiten

Solche Besuchsstrategien führen zum Begriff der Traversierung:

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.

Wir werden Ihnen im Folgenden vier Traversierungsstrategien vorstellen:

왘 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:

void preorder( struct node *n)


{
if( n)
{
printf( " %2d", n->value);
preorder( n->left);

452
15.2 Bäume

preorder( n->right);
}
}

Listing 15.7 Preorder-Traversierung des Baums

Aufgerufen an der Wurzel

void main()
{
printf( "Preorder:\n");
preorder( &n16);
}

ergibt sich die folgende Besuchsreihenfolge:

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

Abbildung 15.13 Grafische Darstellung der Preorder-Traversierung

453
15 Ausgewählte Datenstrukturen

Inorder-Traversierung
Tauschen Sie bei der Preorder-Traversierung nur zwei Zeilen im Quellcode, erhalten
Sie die Inorder-Traversierung:

void inorder( struct node *n)


{
if( n)
{
inorder( n->left);
printf( " %2d", n->value);
inorder( n->right);
}
}

Listing 15.8 Inorder-Traversierung des Baums

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);
}

Hier sehen Sie die geänderte Besuchsreihenfolge:

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).

Die Besuchsreihenfolge entspricht in diesem Fall der Knotennummerierung. Das


liegt daran, dass ich bei der Knotennummerierung ein ganz bestimmtes Schema
gewählt habe, das ich Ihnen später noch erläutern werde. Sie können sich ja schon
einmal Gedanken darüber machen, wie dieses Schema aufgebaut ist.

454
15.2 Bäume

16

6 18

4 14 22

2 10 20 26

15
8 12 24 28

Abbildung 15.14 Grafische Darstellung der Inorder-Traversierung

Postorder-Traversierung
Bei der Postorder-Traversierung erfolgt die Behandlung eines Knotens, nachdem
beide am Knoten hängenden Teilbäume bearbeitet wurden:

void postorder( struct node *n)


{
if( n)
{
postorder( n->left);
postorder( n->right);
printf( " %2d", n->value);
}
}

Listing 15.9 Postorder-Traversierung des Baums

Dementsprechend ergibt sich mit dem geänderten Hauptprogramm

455
15 Ausgewählte Datenstrukturen

void main()
{
printf( "Postorder:\n");
postorder( &n16);
}

wieder eine andere Besuchsreihenfolge:

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

Abbildung 15.15 Grafische Darstellung der Postorder-Traversierung

Die bisher vorgestellten Traversierungsverfahren arbeiten rekursiv. Sie wissen ja


bereits, dass sich Rekursion des Hardware-Stacks bedient und dass Sie Rekursion ver-
meiden können, wenn Sie in Ihrem Programm einen eigenen Stack mitführen.

456
15.2 Bäume

Wir wollen jetzt die Inorder-Traversierung mit dieser Technik rekursionsfrei


machen. Das ist nicht besonders spannend, da wir etwas Ähnliches z. B. schon beim
Sortierverfahren Quicksort gemacht haben. Anschließend werden wir dann aber den
Stack durch eine sogenannte Queue ersetzen und ein neues Traversierungsverfahren
erhalten. Zum besseren Verständnis dieser Operation finden Sie hier aber zunächst
einen kleinen Exkurs über Stacks und Queues. Stacks haben Sie ja bereits in Abschnitt
7.5, »Der Stack«, kennengelernt2.

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)

Abbildung 15.16 Schreiben und Lesen auf dem Stack

Ein Stack kann als Array mit einem Schreib-Lesezeiger implementiert werden:

int i; 15
A int stack[100];
B int pos = 0;

for( i = 0; i < 10; i++)


{
printf( " %d", i);
C stack[pos++] = i;
}
printf( "\n");
while( pos)
{
D i = stack[--pos];
printf( " %d", i);
}

Listing 15.10 Implementierung des Stacks

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

2 Stacks und Queues kommen, in unterschiedlichen Zusammenhängen, mehrfach in diesem Buch


vor. Das ist dadurch gerechtfertigt, weil dies die wichtigsten Datenstrukturen der Informatik
sind.

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)

Abbildung 15.17 Schreiben und Lesen aus der Queue

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;

for( i = 0; i < 10; i++)


{
printf( " %d", i);
D queue[last++] = i;
}
printf( "\n");
while( first < last)
{
E i = queue[first++];
printf( " %d", i);
}

Listing 15.11 Implementierung der Queue

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.

Nach diesen Vorüberlegungen kommen wir zur letzten Traversierungsstrategie:

Levelorder-Traversierung
Wie bereits angekündigt, erstellen wir zunächst eine rekursionsfreie Variante der
Preorder-Traversierung:

void preorder( struct node *n)


{
15
if( n)
{
printf( " %2d", n->value);
preorder( n->left);
preorder( n->right);
}
} void preorder( struct node *n)
{
struct node *stack[100];
int pos = 0;

stack[pos++] = n;
while( pos)
{
n = stack[--pos];
if( n)
{
printf( " %2d", n->value);
stack[pos++] = n->right;
stack[pos++] = n->left;
}
}
}

Abbildung 15.18 Rekursionsfreie Preorder-Traversierung

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:

void levelorder( struct node *n)


{
struct node *queue[100];
int first = 0, last = 0;

queue[last++] = n;
while( first < last)
{
n = queue[first++];
if( n)
{
printf( " %2d", n->value);
queue[last++] = n->left;
queue[last++] = n->right;
}
}
}

Listing 15.12 Implementierung der Levelorder-Traversierung

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);
}

und erhalten folgendes Ergebnis:

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

Abbildung 15.19 Grafische Darstellung der Levelorder-Traversierung

15.2.2 Aufsteigend sortierte Bäume


Wir kehren jetzt zu unserem eigentlichen Anliegen, einen Container auf Basis eines
Binärbaums zu erstellen, zurück. Würden wir den Baum »ungeordnet« aufbauen,
müssten wir den Baum immer vollständig traversieren, um ein bestimmtes Element
zu finden. Wir benötigen daher eine »geordnete« Baumstruktur, die es uns ermög-
licht, effizient in einem Baum zu suchen. Dazu definieren wir zunächst den Begriff
des aufsteigend sortierten Baums.

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.

Abbildung 15.20 zeigt einen aufsteigend sortierten Baum.

16

6 18

4 14 22

2 10 20 26

Hier ist alles kleiner als 6.

8 12 24 28
Hier ist alles größer als 6.

Abbildung 15.20 Aufsteigend sortierter Baum

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

Abbildung 15.21 Suche im aufsteigend sortierten Baum

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.

Wir implementieren dieses Verfahren in unserem Beispielbaum:

void find( struct node *n, int v)


{
while( n)
{

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);
}

Listing 15.13 Implementierung der Suche im Baum

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;

for( i = 1; i <= 10; i++)


{
printf( "Suche %2d ", i);
find( &n16, i);
}
}

464
15.2 Bäume

Suche 1 16->li 6->li 4->li 2->li 1 nicht gefunden


Suche 2 16->li 6->li 4->li 2 gefunden
Suche 3 16->li 6->li 4->li 2->re 3 nicht gefunden
Suche 4 16->li 6->li 4 gefunden
Suche 5 16->li 6->li 4->re 5 nicht gefunden
Suche 6 16->li 6 gefunden
Suche 7 16->li 6->re 14->li 10->li 8->li 7 nicht gefunden
Suche 8 16->li 6->re 14->li 10->li 8 gefunden
Suche 9 16->li 6->re 14->li 10->li 8->re 9 nicht gefunden
Suche 10 16->li 6->re 14->li 10 gefunden

16

6 18

15

4 14 22

2 10 20 26

8 12 24 28

Abbildung 15.22 Suche im Baum

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

Abbildung 15.23 Sortierter Baum als Container

Im Konstruktor (tree_create) wird eine leere, aber konsistent initialisierte Daten-


struktur erzeugt und an das aufrufende Programm zurückgegeben:

struct tree *tree_create()


{
struct tree *t;

A t = (struct tree *)malloc( sizeof( struct tree));


B t->root = 0;
C return t;
}

Listing 15.14 Erzeugung eines Baums als Container

Dazu wird der erforderliche Speicher allokiert (A), der noch leere Container wird ini-
tialisiert (B) und an das rufende Programm zurückgegeben (C).

Die Verwendung der neuen Funktion sieht wie folgt aus:

struct tree *container1;


struct tree *container2;

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:

void tree_free( struct tree *t)


{
A tree_freenode( t->root);
B free( t);
}

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:

void tree_freenode( struct treenode *tn)


{
if( !tn)
A return; 15
B tree_freenode( tn->left);
C tree_freenode( tn->right);
D free( tn->geg->name);
E free( tn->geg);
F free( tn);
}

Listing 15.15 Freigabe eines Knotens

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.

Dieses Vorgehen entspricht der Postorder-Traversierung, wobei der Baum natürlich


nach der Traversierung nicht mehr vorhanden ist.

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

Wir betrachten zunächst die Find-Funktion:

A struct gegner *tree_find( struct tree *t, char *name)


{
struct treenode *tn;
int cmp;

B for( tn = t->root; tn; )


{
C cmp = strcmp( name, tn->geg->name);
if( cmp == 0)
D return tn->geg;
if( cmp < 0)
E tn = tn->left;
else
F tn = tn->right;
}
G return 0;
}

Listing 15.16 Die Implementierung von tree_find

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:

int tree_insert( struct tree *t, struct gegner *g)


{
struct treenode **node, *neu;
int cmp;

468
15.2 Bäume

for( node = &(t->root); *node; )


{
cmp = strcmp( g->name, (*node)->geg->name);
if( !cmp)
return 0;
if( cmp < 0)
node = &((*node)->left);
else
node = &((*node)->right);
}
neu = (struct treenode *)malloc( sizeof( struct treenode));
neu->left = 0;
neu->right = 0;
neu->geg = g;
*node = neu;
return 1;
}

Listing 15.17 Implementierung des Einfügens in den Baum

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.

Baum für 50 zufällig gewählte Gegner

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

Abbildung 15.24 Suche und Suchtiefe im Container (Baum)

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:

Stack: Wer zuletzt kommt, wird zuerst bedient. 15


Queue: Wer zuerst kommt, wird zuerst bedient.
Heap: Wer am wichtigsten ist, wird zuerst bedient.

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 wird durch die folgende Heap-Bedingung definiert:

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.

Abbildung 15.25 zeigt einen Heap:

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

Abbildung 15.25 Darstellung eines Heaps

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.

Hier ist die Heap-


4
Bedingung gestört.

9 8

7 5 2 3

1 5 2 3 1

Abbildung 15.26 Heap mit einer gestörten Heap-Bedingung

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

Die Heap-Bedingung ist


9
wiederhergestellt.
9

4
7 8
7

5 5 2 3
4
5

1 4 2 3 1

Abbildung 15.27 Wiederherstellung der Heap-Bedingung

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.

Entnehmen des Elements mit der höchsten Priorität:


1. Entferne das Element an der Wurzel. Dies ist das gesuchte Element mit der höchs- 15
ten Priorität.
2. Bringe irgendein Blatt des Baums an die Wurzel.
3. Stelle die an der Wurzel gestörte Heap-Bedingung wieder her.

Einfügen eines neuen Elements:


1. Füge das Element als Blatt im Baum ein.
2. Gehe von dem Element zurück zur Wurzel, und führe dabei jeweils einen Repara-
turschritt (Tausch mit größtem Nachfolger) durch.

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.

15.3.2 Der Container als Treap


Ausgangspunkt für die folgenden Überlegungen ist ein Baum, bei dem jeder Knoten
zwei Ordnungskriterien trägt. Das erste Ordnungskriterium nennen wir Schlüssel,
das zweite Priorität.

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

Abbildung 15.28 Darstellung eines Treaps

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

Abbildung 15.29 Einfügen eines Elements in den Treap

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

Abbildung 15.30 Linksrotation im Treap

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

Abbildung 15.31 Rechtsrotation im Treap

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

Abbildung 15.32 Erneute Linksrotation

Da diese beiden Sortierrichtungen »orthogonal« zueinander sind, können sie offen-


sichtlich in einem Baum koexistieren. 15
Es fehlt noch die entscheidende Idee, warum wir mithilfe eines Treaps die Entartung
des Baums zur Liste vermeiden können. Die Knoten, die wir in den Baum einsetzen,
enthalten zunächst nur einen Schlüssel – im konkreten Beispiel den Ländernamen.
Wenn wir jetzt noch allen Knoten beim Einsetzen eine Zufallszahl als Priorität geben,
sorgt diese Priorität dafür, dass der Baum nicht in Vertikalrichtung degeneriert. Wir
gewinnen sozusagen die Zufälligkeit, die wir bei einer geordneten Eingabe verlieren,
auf diese Weise zurück.

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.

Wir betrachten hier nur die Punkte 1 und 4.

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

Abbildung 15.33 Treap als Container

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

Feld, in dem der Knoten d im Vaterknoten eingehängt ist

void treap_rotate_right( struct treapnode **node)


{
tn ist der linke struct treapnode *tn; Der rechte Nachfolger von b (also c) wird zum
Nachfolger von d, linken Nachfolger von d.
also tn = b. tn = (*node)->left;
(*node)->left = tn->right;
tn->right = *node; d wird der neue rechte Nachfolger von b.
*node = tn;
}
b wird im Vaterknoten eingehängt.

Abbildung 15.34 Implementierung der Rechtsrotation

478
15.3 Treaps

Die Linksrotation wird analog zur Rechtsrotation implementiert:

d b

b e a d
Linksrotation

a c c e

Abbildung 15.35 Die Linksrotation

void treap_rotate_left( struct treapnode **node)


{
struct treapnode *tn;
15
tn = (*node)->right;
(*node)->right = tn->left;
tn->left = *node;
*node = tn;
}

Listing 15.18 Implementierung der Linksrotation

Zum Einsetzen eines Elements gehen Sie rekursiv vor.

int treap_insert_rek( struct treapnode **node, struct gegner *g)


{
int cmp;

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;
}

F *node = (struct treapnode *) malloc( sizeof( struct treapnode));


(*node)->left = 0;
(*node)->right = 0;
(*node)->geg = g;
G (*node)->prio = rand();
return 1;
}

Listing 15.19 Rekursives Einfügen in den Treap

In der Rekursion wird die Einfügeposition im aufsteigend sortierten Baum gesucht.


Wenn noch nicht vorhanden, wird das Element eingefügt. Das Element erhält beim
Einfügen eine zufällige Priorität. Beim Rückzug aus der Rekursion wird durch Rotati-
onen die Heap-Bedingung hergestellt, sofern sie verletzt ist. Wurde beim Abstieg
nach links gegangen, erfolgt beim Rückzug eine Rechtsrotation. Wurde beim Abstieg
nach rechts gegangen, erfolgt beim Rückzug eine Linksrotation.

Im Ablauf der Funktion sieht dies folgendermaßen aus:

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

A int treap_insert( struct treap *t, struct gegner *g)


{
B return treap_insert_rek( &(t->root), g);
}

Die Funktion stellt dabei den passenden Namen und die vereinbarten Parameter (A)
und ruft intern die Rekursion auf (B).

Um Performance zu gewinnen, können Sie die Rekursion eliminieren, indem Sie


einen Stack mitführen, auf dem Sie Aufträge für die beim Rückzug zu bearbeitenden
Knoten ablegen. Sie kennen diese Technik bereits aus anderem Zusammenhang,
darum möchte ich Sie an dieser Stelle nur auf das beigefügte Programm aus dem
Download-Bereich verweisen (unter http://www.galileo-press.de/3536, »Materialien
zum Buch«).

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

Treap für 50 sortierte Gegner

Maximale Suchtiefe: 9
Mittlere Suchtiefe: 5.60

Abbildung 15.36 Suche und Suchtiefe im Container (Treap)

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

Abbildung 15.37 Schema einer Hash-Tabelle

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.

struct hashtable struct hashentry


{ {
int size; struct hashentry *nxt;
struct hashentry **table; struct gegner *geg;
}; };

Paraguay

Oesterreich

Marokko

Bolivien

Abbildung 15.38 Hash-Tabelle als Container

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:

struct hashtable *hash_create( int siz)


{
struct hashtable *h;

h = (struct hashtable *)malloc( sizeof( struct hashtable));


h->size = siz;
h->table = (struct hashentry **)calloc( siz, sizeof( struct hashentry *));
return h;
}

Listing 15.20 Erzeugen der Hashtable

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:

struct hashtable *container1;


struct hashtable *container2;

container1 = hash_create();
container2 = hash_create();

Hash-Tabellen und Hash-Funktionen (man spricht auch von Streuwertfunktionen)


sind keine Erfindung der Informatik, es gibt sie schon seit ewigen Zeiten. Zum Bei-
spiel ist eine Registratur, in der Akten nach dem ersten Buchstaben eines Stichworts
abgelegt werden, eine Hash-Tabelle. Kollidierende Akten kommen dann in das

484
15.4 Hash-Tabellen

gleiche Fach und müssen dort sequenziell gesucht werden. Die zugehörige Hash-
Funktion ist:

unsigned int hashfunktion( char *name)


{
return *name;
}

Listing 15.21 Eine Hash-Funktion

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:

unsigned int hashfunktion( char *name, unsigned int size)


{
unsigned int h;

A for( h = 0; *name; name++)


h = ((h << 6) | (*name – '@')) % size;
15
return h;
}

Listing 15.22 Eine geeignetere Hash-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

A struct gegner *hash_find( struct hashtable *h, char *name)


{
unsigned int index;
struct hashentry *e;

B index = hashfunktion( name, h->size);

C for( e = h->table[index]; e; e = e->nxt)


{
if( !strcmp( name, e->geg->name))
D return e->geg;
}
E return 0;
}

Listing 15.23 Die Suche im Hash

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:

int hash_insert( struct hashtable *h, struct gegner *g)


{
unsigned int ix;
struct hashentry **e, *neu;

A ix = hashfunktion( g->name, h->size);

B for( e = h->table + ix; *e; e = &((*e)->nxt))


{
C if( !strcmp( g->name, (*e)->geg->name))
return 0;
}
D neu = (struct hashentry *)malloc( sizeof( struct hashentry));
neu->nxt = *e;

486
15.4 Hash-Tabellen

neu->geg = g;
*e = neu;
return 1;
}

Listing 15.24 Einfügen in den Hash

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:

void hash_free( struct hashtable *h) 15


{
unsigned int ix;
struct hashentry *e;

A for( ix = 0; ix < h->size; ix++)


{
B while( e = h->table[ix])
{
C h->table[ix] = e->nxt;
D free( e->geg->name);
free( e->geg);
E free( e);
}
}
F free( h->table);
G free( h);
}

Listing 15.25 Freigeben des Hash

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.

Beachten Sie, dass im Schleifenkopf der while-Anweisung


while( e = h->table[ix])

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.

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

Abbildung 15.39 Suchtiefen der Hash-Tabelle für unterschiedliche Größen

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

Abbildung 15.40 Suche und Suchtiefe im Container (Hash-Tabelle)


15

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

Bei Treaps kommt die Priorität hinzu:

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.

Tabelle 15.2 zeigt gemessene Suchtiefen für zufällig generierte Daten:

Liste Baum Treap Hash-Tabelle

Anzahl Maxi- Durch- Maxi- Durch- Maxi- Durch- Maxi- Durch-


mum schnitt mum schnitt mum schnitt mum schnitt

1000 1000 500 20 11 23 13 2 1,16

10000 10000 5000 30 16 29 16 2 1,03

100000 100000 50000 40 21 41 21 2 1,07

1000000 100000 500000 52 25 49 25 4 1,34

Tabelle 15.2 Suchtiefen für zufällig generierte Daten

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

Anzahl Maximum Durchschnitt Maximum Durchschnitt

1000 1000 500 20 11

10000 10000 5000 29 17

100000 100000 50000 39 21

1000000 1000000 500000 49 25

Tabelle 15.3 Suchtiefen für vorsortierte Daten

490
15.4 Hash-Tabellen

Bei kleinen Datenmengen ist es unerheblich, welche Speichertechnik Sie verwenden.


Bei großen Datenmengen gibt es jedoch signifikante Unterschiede. Listen sind dann
nicht mehr empfehlenswert und unbalancierte Bäume nur dann, wenn die Daten
zufällig eingetragen werden. Sind Sie in der Anwendung an der Sortierordnung inte-
ressiert, sollten Sie balancierte Bäume verwenden. Interessiert Sie die Ordnung dage-
gen nicht, ist Hashing unschlagbar.

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:

왘 Der Monat muss immer eine Zahl zwischen 1 und 12 sein.


왘 Die Anzahl der Tage eines Monats variiert nach vorgegebenen Gesetzmäßigkeiten
zwischen 28 und 31.

493
16 Abstrakte Datentypen

왘 Die Schaltjahresregelung ist zu beachten.

Darüber hinaus gibt es eine Vielzahl wünschenswerter Operationen. Zum Beispiel:

왘 Berechne den Wochentag zu einem gegeben Datum.


왘 Berechne die Anzahl der Tage zwischen zwei Kalenderdaten.
왘 Addiere eine bestimmte Zahl von Tagen zu einem Kalenderdatum.
왘 Vergleiche zwei Kalenderdaten im Sinne von früher/später.

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.

Was ist ein abstrakter Datentyp?


Ein abstrakter Datentyp ist eine Datenstruktur zusammen mit einer Reihe von
Funktionen, die auf dieser Datenstruktur arbeiten.
Der abstrakte Datentyp verbirgt nach außen seine Implementierung und wird aus-
schließlich über die Schnittstelle seiner Funktionen bedient.

Wir veranschaulichen dies durch die Skizze in Abbildung 16.1:

Abstrakter Datentyp

funktion1

funktion2

funktion3

funktion4
Interne Datenstruktur

Abbildung 16.1 Trennung von Schnittstelle und Implementierung

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.

16.1 Der Stack als abstrakter Datentyp


Wir haben bereits häufiger einen Stack betrachtet und dabei den Vergleich zu einem
Tellerstapel gezogen, auf dem oben Teller abgelegt und von oben wieder Teller ent-
nommen werden können.

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

Abbildung 16.2 Der Stack als abstrakter Datentyp

Damit ergibt sich die folgende Schnittstelle für einen abstrakten Datentyp:

495
16 Abstrakte Datentypen

Operation Eingehende Ausgehende Parameter Beschreibung


Parameter

construct Stack-Größe Stack Erzeuge einen leeren Stack


und Element- der gewünschten Stack-
größe Größe für Elemente der
gewünschten Element-
größe.

isempty Stack 0 oder 1 Teste, ob der Stack leer ist.

push Stack und OK oder OVERFLOW Lege ein Element auf den
Element Stack.

pop Stack EMPTY oder OK Hole ein Element vom


und sofern OK, das Stack.
oberste Element vom
Stack

destruct Stack Beseitige den mit


construct erzeugten
Stack.

Tabelle 16.1 Schnittstelle des Stacks

Diese Schnittstelle legen wir in einer Header-Datei fest:

# define OK 1
# define OVERFLOW –1
# define EMPTY 0

struct stack
{
char *stck;
int ssize;
int esize;
int pos;
};

struct stack *stack_construct( int ssiz, int esiz);


void stack_destruct( struct stack *s);
int stack_isempty( struct stack *s);
int stack_push( struct stack *s, void *v);
int stack_pop( struct stack *s, void *v);

Listing 16.1 Header-Datei der Schnittstelle für den Stack

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.

Im Konstruktor muss im Wesentlichen der erforderliche Speicher allokiert werden.


Es handelt sich dabei um die Datenstruktur für die Verwaltung des Stacks (struct
stack) selbst und das Array zur Aufnahme der Nutzdaten (stck):

struct stack *stack_construct( int ssiz, int esiz)


{
struct stack *s;
s = (struct stack *)malloc( sizeof( struct stack));
s->stck = (char *)malloc( ssiz*esiz);
s->ssize = ssiz;
s->esize = esiz;
s->pos = 0;
return s;
}

Listing 16.2 Erzeugung des Stacks


16
Neben der Speicherung der Kenngrößen (ssiz, esiz) wird insbesondere der Stack-
Zeiger (pos) auf 0 gesetzt. Dieser Zeiger indiziert immer den Platz, an dem das nächste
Element gespeichert werden muss. Am Ende der Funktion wird ein Zeiger auf den ini-
tialisierten, aber noch leeren Stack zurückgegeben.

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:

int stack_push( struct stack *s, void *v)


{
if( s->pos >= s->ssize)
return OVERFLOW;
memcpy( s->stck + s->pos*s->esize, v, s->esize);
s->pos++;
return OK;
}

int stack_pop( struct stack *s, void *v)


{

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 Datenaustausch zwischen Anwendungsprogramm und Stack erfolgt über einen


unspezifizierten Zeiger (void *v). Nur das Anwendungsprogramm kennt die genaue
Bedeutung dieses Zeigers. Der Stack weiß nur, wie viele Bytes (esize) die durch den
Zeiger referenzierten Elemente haben. Diese Information benötigt er, um die Daten
zu kopieren (memcpy). Die Funktion memcpy(dst,src,size) kopiert eine gewisse
Anzahl (size) Bytes von einer Quelladresse (src) zu einer Zieladresse (dst). Da der
Stack-Zeiger immer hinter dem zuletzt gespeicherten Element des Stacks steht, wird
er vor dem Lesen dekrementiert (s->pos--) und nach dem Schreiben inkrementiert
(s->pos++). Der Returnwert der Funktionen informiert über Erfolg oder Misserfolg
der gewünschten Operation.

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:

int stack_isempty( struct stack *s)


{
return s->pos == 0;
}

Listing 16.4 Prüfung des Stacks

Durch den Destruktor wird ein Stack vollständig beseitigt, indem die allokierten Spei-
cherressourcen wieder freigegeben werden:

void stack_destruct( struct stack *s)


{
free( s->stck);
free( s);
}

Listing 16.5 Beseitigung des Stacks

Im Anwendungsprogramm wird eine Testdatenstruktur (test) erstellt. Für diese


Datenstruktur wird dann ein Stack erzeugt, und es werden Daten mit push und pop
auf dem Stack abgelegt bzw. vom Stack zurückgeholt:

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);
}

Listing 16.6 Test des Stacks

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

Wir erhalten vom Testprogramm z. B. folgende Ausgabe:

(584, 164) (795, 125) (828, 405) (477, 413) ( 72, 404)
( 72, 404) (477, 413) (828, 405) (795, 125) (584, 164)

16.2 Die Queue als abstrakter Datentyp


Wenn man bei einem Tellerstapel die Teller immer oben hinzufügen, aber unten wie-
der entnehmen würde, würde man nicht von einem Stack, sondern einer Queue spre-
chen. Eine Warteschlange vor der Kasse eines Supermarkts wäre vielleicht ein
treffenderes Beispiel.

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

Abbildung 16.3 Die Queue als abstrakter Datentyp

Damit ergibt sich die folgende Schnittstelle:

Operation Eingehende Ausgehende Parameter Beschreibung


Parameter

construct Queue-Größe Queue Erzeuge eine leere Queue


und Element- der gewünschten Queue-
größe Größe für Elemente der
gewünschten Element-
größe.

Tabelle 16.2 Schnittstelle der Queue

500
16.2 Die Queue als abstrakter Datentyp

Operation Eingehende Ausgehende Parameter Beschreibung


Parameter

isempty Queue 0 oder 1 Teste, ob die Queue leer


ist.

put Queue und Ele- OK oder OVERFLOW Lege ein Element in die
ment Queue.

get Queue EMPTY oder OK Hole ein Element aus der


und sofern OK, das Queue.
nächste Element aus
der Queue

destruct Queue Beseitige die mit


construct erzeugte
Queue.

construct Queue-Größe Queue Erzeuge eine leere Queue


und Element- der gewünschten Queue-
größe Größe für Elemente der
gewünschten Element-
größe.

isempty Queue 0 oder 1 Teste, ob die Queue leer 16


ist.

put Queue und Ele- OK oder OVERFLOW Lege ein Element in die
ment Queue.

get Queue EMPTY oder OK Hole ein Element aus der


und sofern OK, das Queue.
nächste Element aus
der Queue

Tabelle 16.2 Schnittstelle der Queue (Forts.)

Aus dieser Tabelle können wir unmittelbar die erforderlichen Funktionsprototypen


für die Header-Datei ableiten:

# 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;
};

struct queue *queue_construct( int qsiz, int esiz);


void queue_destruct( struct queue *q);
int queue_isempty( struct queue *q);
int queue_put( struct queue *q, void *v);
int queue_get( struct queue *q, void *v);

Listing 16.7 Die Header-Datei der Queue

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.

Abbildung 16.4 Ringpuffer mit Schreibzeiger vor 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).

Die Zeigerbewegungen in einem Ringpuffer können mit einfachen Modulo-Operati-


onen implementiert werden:

zeiger = (zeiger + offset)%pufferlänge

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

Der Schreibzeiger ist physikalisch hinter,


aber logisch vor dem Lesezeiger.

Abbildung 16.5 Ringpuffer mit Schreibzeiger »hinter« Lesezeiger

Mit diesen Vorüberlegungen können wir alle Funktionen der Queue implementie-
ren. Wir starten dazu mit dem Konstruktor

struct queue *queue_construct( int qsiz, int esiz)


{
struct queue *q;
q = (struct queue *)malloc( sizeof( struct queue));
q->que = (char *)malloc( qsiz*esiz);
q->qsize = qsiz;
q->esize = esiz;
q->first = 0;
q->anz = 0; 16
return q;
}

Listing 16.8 Erzeugen der Queue

und dem Destruktor:

void queue_destruct( struct queue *q)


{
free( q->que);
free( q);
}

Listing 16.9 Zerstören der Queue

Beim Test, ob eine Queue leer ist, muss nur das Datenfeld anz befragt werden:

int queue_isempty( struct queue *q)


{
return q->anz == 0;

Listing 16.10 Prüfung der Queue

503
16 Abstrakte Datentypen

Bei der Implementierung der Schreib-/Lesezugriffe müssen Sie Folgendes beachten:

Im Ringpuffer läuft der Schreibzeiger dem Lesezeiger immer um q->anz Elemente


logisch voraus, wobei physikalisch im Array Modulo q->qsize gerechnet wird.

Der Schreibzeiger kann sich physikalisch hinter dem Lesezeiger befinden, über-
rundet ihn aber nicht, da immer q->anz < q->qsize ist.

Das setzen wir in den Funktionen put und get um:

int queue_put( struct queue *q, void *v)


{
if( q->anz >= q->qsize)
return OVERFLOW;
memcpy( q->que + ((q->first+q->anz)%q->qsize)*q->esize, v, q->esize);
q->anz++;
return OK;
}

Listing 16.11 Ablegen in der Queue

int queue_get( struct queue *q, void *v)


{
if( !q->anz)
return EMPTY;
memcpy( v, q->que + q->first*q->esize, q->esize);
q->first = (q->first+1)%q->qsize;
q->anz--;
return OK;
}

Listing 16.12 Entnahme aus der Queue

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

B struct queue *myqueue;


int i;
struct test t;

srand( 12345);

C myqueue = queue_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 queue_put( myqueue, &t);
}
printf( "\n");
E while( !queue_isempty( myqueue))
{
F queue_get( myqueue, &t);
printf( "(%3d, %3d) ", t.i1, t.i2);
}
printf( "\n");
G queue_destruct( myqueue); 16
}

Listing 16.13 Test der Queue

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

Abbildung 17.1 Die sieben Brücken von Königsberg

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).

Das Königsberger Brückenproblem ist ein klassisches Problem der »Graphentheo-


rie«, dessen Lösung auf den berühmten Mathematiker Leonhard Euler (1707–1783)
zurückgeht. Den gesuchten Rundweg bezeichnet man daher auch als eulerschen
Weg.

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

Abbildung 17.2 Die sieben Brücken als Graph

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:

Abbildung 17.3 Die Knoten und Kanten des Graphen

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.

Wir haben damit ein Verfahren beschrieben, um in einem zusammenhängenden


Graphen, mit gerader Kantenzahl an jedem Knoten, einen eulerschen Weg zu finden.

Finden Sie in diesem Graphen einen


eulerschen Weg, indem Sie das oben
beschriebene Verfahren durchführen.

Abbildung 17.4 Beispiel eines Graphen für einen eulerschen Weg

509
17 Elemente der Graphentheorie

Wir fassen unsere Ergebnisse zusammen:

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.

Damit können wir den folgenden Satz formulieren:

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.

17.1 Graphentheoretische Grundbegriffe


Ein Graph ist eine grundlegende Struktur, die Strukturen wie Baum oder Liste verall-
gemeinert:

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

Abbildung 17.5 Darstellung und Notation eines Graphen

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

Abbildung 17.6 Graph mit Parallelkanten

Wir wollen hier aber nur Graphen ohne Parallelkanten betrachten.


Wenn in einem Graphen zu jeder Kante k = (A,B) auch die Kante k' = (B,A) vorkommt,
bezeichnen wir den Graphen als ungerichtet oder symmetrisch.
Da in einem symmetrischen Graphen zu jeder Kante auch die in umgekehrter Rich-
tung verlaufende Kante vorhanden ist, identifizieren wir die beiden Kanten mitein-
ander und zeichnen für Kante und Umkehrkante jeweils nur eine Linie. Die Pfeile
lassen wir in solchen Graphen weg:

A D A D
17

B C B C

Abbildung 17.7 Darstellung eines ungerichteten (symmetrischen) Graphen

17.2 Die Adjazenzmatrix


Zur Speicherung eines Graphen in einem Programm dient häufig die sogenannte
Adjazenzmatrix:

Die Adjazenzmatrix eines Graphen


Gegeben sei ein Graph mit fortlaufend nummerierten Knoten (E1, E2, E3, ... En). Die Matrix

⎛ a1,1 " a1,n ⎞


⎜ ⎟
( )
A = ai , j =⎜ # % # ⎟
⎜a ⎟
⎝ n,1 " an,n ⎠

511
17 Elemente der Graphentheorie

mit

⎧1 Es gibt eine Kante von E i nach E j


a i, j = ⎨
⎩0 Es gibt keine Kante von E i nach E j

heißt die Adjazenzmatrix des Graphen.

In Abbildung 17.8 sehen Sie einen Graphen mit seiner Adjazenzmatrix:

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.

Abbildung 17.8 Ein Graph und seine Adjazenzmatrix

Symmetrische Graphen haben eine symmetrische Adjazenzmatrix (ai,j = aj,i). Das


heißt, die Matrix ist spiegelsymmetrisch zur Hauptdiagonalen (von links oben nach
rechts unten).

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.

17.3 Beispielgraph (Autobahnnetz)


Wie ein roter Faden wird sich ein Beispiel durch diesen Abschnitt ziehen. Es handelt
sich um eine Auswahl deutscher Städte mit Autobahnverbindungen. Die Städte sind
die Knoten, die Autobahnen die Kanten eines Graphen. Für dieses Bespiel definieren
wir zunächst einige grundsätzliche Konstanten.

Es handelt sich um eine Auswahl von zwölf Städten:

# 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).

Anhand dieses Graphen werden wir wichtige graphentheoretische Problemstellun-


gen diskutieren. Zum Beispiel werden wir uns fragen, ob und wie man von Stuttgart
nach Berlin kommt. An diesem Beispiel erkennen Sie bereits, dass man Fragen, die
man durch einen einfachen Blick auf die Karte beantworten kann, nicht so einfach
aus der Adjazenzmatrix herauslesen kann.

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},
};

Abbildung 17.9 Der Autobahngraph und seine Adjazenzmatrix

17.4 Traversierung von Graphen


Einen Graphen, in dem alle Knoten von allen Knoten aus erreichbar sind, können Sie,
von einem beliebigen Knoten startend, wie einen Baum rekursiv traversieren. Sie
müssen nur darauf achten, dass Sie Knoten, die Sie bereits besucht haben, nicht
erneut besuchen, weil Sie sonst in einer endlosen Rekursion gefangen sind.

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];

for( i = 0; i < ANZAHL; i++)


A war_da[i] = 0;
B traverse( BERLIN, war_da, 0);
}

Listing 17.1 Traversieren eines Graphen

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 eigentliche Traversierungsstrategie orientiert sich an der Preorder-Traversierung


für Bäume. Wenn wir auf einem bisher unbesuchten Knoten ankommen, führen wir
zunächst die gewünschte Knotenoperation aus (machwas), um danach alle vom Stand-
ort aus erreichbaren Knoten zu besuchen, an denen wir noch nicht waren:

A void traverse( int knoten, int war_schon_da[], int level)


{
int i;

B machwas( knoten, level);


C war_schon_da[knoten] = 1;
D for( i = 0; i < ANZAHL; i++)
{
E if( adjazenz[knoten][i] && !war_schon_da[i])
F traverse( i, war_schon_da, level+1);
}
}

Listing 17.2 Implementierung von traverse

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:

void machwas( int knoten, int level)


{
int i;

for( i = 0; i < level; i++)


printf( " ");
printf( "%s\n", stadt[knoten]);
}

Listing 17.3 Funktion machwas zur Ausgabe der besuchten Knoten

Dieser Algorithmus erzeugt die folgende Ausgabe:

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

Bremen Düsseldorf Stuttgart


Frankfurt

Hamburg Köln

Stuttgart Hannover

München

Abbildung 17.10 Graph aller erreichbaren Städte

17.5 Wege in Graphen


Wie schon angekündigt, wollen wir uns mit der »Wegesuche« in Graphen beschäfti-
gen. Dazu müssen wir zunächst einmal definieren, was wir unter einem Weg in

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.

Wir veranschaulichen diese Begriffe an einem einfachen Beispiel:

A D
d

a b f 17
c

e
B C
g

Abbildung 17.11 Beispielgraph

In diesem Graphen gilt:

왘 Die Folge (B, A, B, D, C) ist ein Weg der Länge 4.


왘 Die Folge (A, B, C, C, A) ist ein geschlossener Weg.
왘 Der Weg (A, B, D, C) ist schleifenfrei.
왘 Der Weg (B, A, B, D) ist ein Kantenzug, aber nicht schleifenfrei.
왘 Der Weg (A, B, A) ist ein Kreis.

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 Wegmatrix eines Graphen


Gegeben sei ein Graph mit fortlaufend nummerierten Knoten (E1, E2, E3, ... En). Die
Matrix

⎛ w 1,1 " w 1,n ⎞


⎜ ⎟
( )
W = wi , j = ⎜ # % # ⎟
⎜w ⎟
⎝ n,1 " wn,n ⎠
mit

⎧1 Es gibt einen Weg von E i nach E j


w i, j = ⎨
⎩0 Es gibt keinen Weg von E i nach E j

heißt die Wegematrix des Graphen.

Die Wegematrix eines Graphen ist in der Regel nicht bekannt. Um sie aus der Adja-
zenzmatrix zu berechnen, verwenden wir das Verfahren von Warshall.

17.6 Der Algorithmus von Warshall


Wir versuchen jetzt, ein Verfahren zu konstruieren, das, ausgehend von der Adja-
zenzmatrix, die Wegematrix in einem Graphen konstruiert. Wenn uns das gelingt,
können wir die Frage der Verbindbarkeit von Knoten vollständig beantworten.

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

Wir versuchen dabei, die folgende Eigenschaft zu realisieren:

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

Abbildung 17.12 Es besteht bereits ein Weg zwischen den Knoten.

왘 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

Abbildung 17.13 Weg über einen Zwischenpunkt

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;

A for( zpkt = 0; zpkt < ANZAHL; zpkt++)


{
B for( von = 0; von < ANZAHL; von++)
{
if( weg[von][zpkt])
{
C for( nach = 0; nach < ANZAHL; nach++)
{
if( weg[zpkt][nach])
weg[von][nach] = 1;
}
}
}
}
}

Listing 17.4 Algorithmus von Warshall

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.

Die Wegematrix im deutschen Autobahnnetz zu berechnen ist wenig ergiebig, da das


Autobahnnetz zusammenhängend ist und die Wegematrix in allen Feldern den Wert
1 enthalten wird. Wir machen daher die Autobahnen zu Einbahnstraßen. Jetzt ist
nicht mehr jede Stadt von jeder anderen aus erreichbar. Es ergibt sich folgende Adja-
zenzmatrix, die ich bereits weg genannt habe, weil sie in die Wegematrix umgerech-
net werden soll:

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},
};

Abbildung 17.14 Einbahnstraßen-Autobahngraph und seine Adjazenzmatrix

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

Abbildung 17.15 Kantenmatrix eines Graphen

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.

Eine konkrete Implementierung einer Kantentabelle werden Sie im nächsten


Abschnitt kennenlernen.

17.8 Zusammenhang und Zusammenhangskomponenten


Zur Anwendung von Kantentabellen werden wir Ihnen jetzt die sogenannten Zusam-
menhangskomponenten eines symmetrischen Graphen vorstellen. Dazu erläutern
wir Ihnen zunächst den Begriff des Zusammenhangs in beliebigen Graphen:

Ein Graph heißt schwach zusammenhängend, wenn es für je zwei Knoten A


und B einen Weg von A nach B oder einen Weg von B nach A gibt.

Ein Graph heißt stark zusammenhängend oder einfach zusammenhängend,


wenn es für je zwei Knoten A und B einen Weg von A nach B gibt.

Ein zusammenhängender Graph ist immer schwach zusammenhängend. In symme-


trischen Graphen fallen die beiden Begriffe zusammen. 17
In Abbildung 17.16 sehen Sie dazu einige einfache Beispiele:

nicht schwach zusammenhängend, zusammenhängend


schwach zusammenhängend aber nicht zusammenhängend

A D A D A D

B C B C B C

Abbildung 17.16 Beispiele für zusammenhängende Graphen

Ein ungerichteter, zusammenhängender kreisfreier Graph wird auch als Baum


bezeichnet.

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

Abbildung 17.17 Vier Zusammenhangskomponenten

Die Zusammenhangskomponenten bilden immer eine »disjunkte Zerlegung« der


Knotenmenge. Das bedeutet, dass jeder Knoten genau einer Zusammenhangskomp-
onente zugeordnet ist. Würde man im oben dargestellten Beispiel eine zusätzliche
Kante von einem Knoten eines Clusters zu einem Knoten eines anderen Clusters zie-
hen, würden die beiden Cluster sofort verschmelzen.

Abbildung 17.18 Verschmelzung von Zusammenhangskomponenten

Ist der Graph zusammenhängend, dann gibt es nur eine Zusammenhangskompo-


nente.

Die Cluster bilden sich, weil die Verbindungsbeziehung in symmetrischen Graphen


die folgenden drei Eigenschaften hat:

1. Jeder Knoten kann mit sich selbst verbunden werden.


2. Wenn A mit B verbunden werden kann, dann kann auch B mit A verbunden
werden.
3. Wenn A mit B und B mit C verbunden werden kann, dann kann auch A mit C ver-
bunden werden.

524
17.8 Zusammenhang und Zusammenhangskomponenten

Diese drei Eigenschaften heißen Reflexivität, Symmetrie und Transitivität. Eine


Beziehung, die diese drei Eigenschaften hat, nennt sich Äquivalenzrelation.

Äquivalenzrelationen haben immer die Eigenschaft, die Grundmenge vollständig in


paarweise elementfremde Teilmengen (sogenannte Äquivalenzklassen) zu zerlegen.

Äquivalenzrelationen sind eine ganz wesentliche Grundlage unseres Denkens.


Immer wenn wir abstrahieren, verwenden wir (bewusst oder unbewusst) eine Äqui-
valenzrelation.

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).

Die Zusammenhangskomponenten sind die Äquivalenzklassen bezüglich der Äquiva-


lenzrelation »durch einen Weg verbindbar« über der Knotenmenge eines Graphen. Wir
wollen einen Algorithmus entwickeln, der die Zusammenhangskomponenten für einen
Graphen berechnet, und folgen dabei der Idee von der Verschmelzung der Cluster.

Zunächst modifizieren wir unser Standardbeispiel, damit überhaupt verschiedene


Zusammenhangskomponenten entstehen, und erstellen für den modifizierten Gra-
phen eine Kantentabelle.
17

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}
};

Abbildung 17.19 Kantentabelle des modifizierten Graphen

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:

Um die Zusammenhangskomponenten zu bestimmen, bilden wir Mengen von


Knoten. Am Anfang liegt jeder Knoten für sich allein in einer eigenen Menge.
Dann betrachten wir der Reihe nach alle Kanten des Graphen. Wenn Anfangs-
und Endpunkt der Kante bereits in der gleichen Menge liegen, ist nichts zu tun.
Wenn aber der Anfangs- und der Endpunkt in verschiedenen Mengen liegen,
müssen die beiden Mengen verschmolzen werden. Die Mengen, die nach
Betrachtung aller Kanten noch übrig sind, sind die Zusammenhangskompo-
nenten.

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:

왘 Einfügen eines Elements (Knotenindex) in eine Menge


왘 Vereinigen von zwei Mengen

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

vorgaenger[3] = 5 bedeutet, dass der


Knoten mit dem Index 5 der Vorgänger
1 1 des Knotens mit dem Index 3 ist.

v orgaenger 2 3 -1 5 2 2 5
index 0 1 2 3 4 5 6

Abbildung 17.20 Datenstruktur zur Speicherung des Baums

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.

Jetzt geht es darum, die Zusammenhangskomponenten aufzubauen. Am Anfang


liegt jeder Knoten allein in einer Menge. Jeder Knoten ist also die Wurzel in einem
ansonsten leeren Baum. Um dies zu erreichen, müssen Sie alle Werte im Rückver-
weis-Array (vorgaenger) auf –1 setzen:

void init()
{
int i;

for( i= 0; i < ANZ_KNOTEN; i++)


vorgaenger[i] = –1;
}

Listing 17.5 Initialisierung der Datenstruktur

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:

void join( int a, int b)


{
A while( vorgaenger[a] != –1)
a = vorgaenger[a];
B while( vorgaenger[b] != –1)
b = vorgaenger[b];
if( a != b)
C vorgaenger[b] = a;
}

Listing 17.6 Vereinigung der Mengen mit join

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);
}

Listing 17.7 Bilden der Zusammenhangskomponente

Nach Aufruf dieser Funktion liegen die Zusammenhangskomponenten im Vorgän-


ger-Array vor. Sie müssen nur noch ausgegeben werden.

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");
}
}
}

Listing 17.8 Die Ausgabefunktion

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

int wurzel( int a)


{
while( vorgaenger[a] != –1)
a = vorgaenger[a];
return a;
}

Listing 17.9 Indexermittlung für die Wurzel

Es fehlt noch das Hauptprogramm, in dem alle Fäden zusammengeknüpft werden:

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

Abbildung 17.21 Ergebnis aus der Ermittlung der Zusammenhangskomponenten

529
17 Elemente der Graphentheorie

17.9 Gewichtete Graphen


Bisher haben wir in unseren Graphen nur Informationen über die prinzipielle Ver-
bindbarkeit von Knoten gespeichert. Wenn Sie an wichtige Anwendungen, wie etwa
ein Navigationssystem im Auto, denken, dann kommt es aber nicht nur auf die Exis-
tenz einer Verbindung, sondern auch auf die Entfernung und die optimale Route
zum Ziel an. Ein System, das nach der Zieleingabe nur »ja, Ihr Ziel ist erreichbar«
sagen würde, wäre als Navigationssystem unbrauchbar.

Wir wollen unsere Graphen daher um »Entfernungsangaben« erweitern:

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.

Kantengewichte können in konkreten Beispielen unter anderem Entfernungskilo-


meter, Reise- oder Produktionskosten, Reise- oder Produktionszeiten, Gewinne oder
Verluste bzw. Leitungs- oder Transportkapazitäten bedeuten.

In der Darstellung schreiben wir die Kantengewichte zusätzlich an die einzelnen


Kanten:

A D

b/-1
f/0
a/1 c/4 d/2

B e/-5 C
g/-3

Abbildung 17.22 Darstellung eines gewichteten Graphen

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

b/-1 Der Weg (a, b, a, b, c) hat das Gewicht 4.


f/0 Der Weg (a, b, c, f, d, b) hat das Gewicht 5.
a/1 d/2
c/4 Der Weg (f, g, g, d) hat das Gewicht –4.
B e/-5 C
g/-3

Abbildung 17.23 Gewicht eines Weges im gewichteten Graphen

530
17.9 Gewichtete Graphen

Je nach Bedeutung (Entfernung/Dauer/Preis) der Kantengewichte können wir uns


dann z. B. fragen:

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.

Abbildung 17.24 zeigt die Distanzenmatrix für unser Standardbeispiel:

# define ANZAHL 12
# define xxx 10000

unsigned int distanz[ANZAHL][ANZAHL] =


Hamburg {
Bremen { 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx},
119 {xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx},
284 {xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx},
154 Berlin
125 {205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx},
Hannover {xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx},
282
233 {xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217},

Dortmund 208 256


{284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx}, 17
179 205 {282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx},
Leipzig
Düsseldorf 63 {xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx},
352 {179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx},
47 83 108 {xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220},
264
{xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0},
Köln 395 Dresden };
189

Frankfurt

217
400 425

Stuttgart 220

München

Abbildung 17.24 Darstellung der Distanzenmatrix

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

17.10 Kürzeste Wege


Sobald wir einen Graphen mit Distanzangaben haben, können wir uns die Frage nach
kürzesten Wegen zwischen zwei Knoten stellen. In einem analogen Modell ist das
Problem, einen kürzesten Weg zu finden, einfach zu lösen. Man baut den Graphen als
Drahtmodell, wobei die Länge der Drähte dem Kantengewicht entspricht, fasst an
den beiden Knoten an und zieht sie so weit, wie es geht, auseinander.

Abbildung 17.25 Ermittlung des kürzesten Weges

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:

1. Finde den kürzesten Weg von einem Knoten A zu einem Knoten B.


2. Finde die kürzesten Wege von einem Knoten A zu allen anderen Knoten des
Graphen.
3. Finde die kürzesten Wege zwischen allen Knoten des Graphen.

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)

17.11 Der Algorithmus von Floyd


Bevor wir einen Algorithmus zur Suche aller optimalen Wege erstellen können, müs-
sen wir uns überlegen, wie eine Datenstruktur aussehen könnte, in der wir das Ergeb-
nis speichern können. Spontan würde man sagen, dass wir eine Liste aller
Knotenpaare erstellen, die zu jedem Knotenpaar eine Liste mit den Knoten des
jeweils optimalen Weges enthält. Es geht aber viel einfacher und eleganter. Wir benö-
tigen zwei Matrizen. Die eine ist die Distanzenmatrix, die zu jedem Knotenpaar die
Entfernung aufnimmt. Die zweite Matrix ist eine Zwischenpunktmatrix, die zu 17
jedem Knotenpaar A, B einen Zwischenpunkt auf dem optimalen Weg von A nach B
enthält. Abbildung 17.26 zeigt dies an einem einfachen Beispiel:

Die Distanz von D nach C


beträgt zwölf Einheiten.

Graph Distanzenmatrix Distanzmatrix


E
5 A 0 1 – – – A 0 1 3 6 10
Algorithmus von Floyd

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 –

Der kürzeste Weg von D nach C


geht über den Zwischenpunkt E.

Abbildung 17.26 Der Algorithmus von Floyd

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

Abbildung 17.27 Kürzester Weg mit Zwischenpunkten

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];

Wir erstellen Hilfsfunktionen, um diese Matrizen auszugeben:

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");
}
}

Listing 17.11 Hilfsfunktion zur Ausgabe von Zwischenpunkten

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");
}
}

Listing 17.12 Hilfsfunktion zur Ausgabe von Distanzen

Um aus den Matrizen einen optimalen Weg auszugeben, verwenden wir die Funktio-
nen print_path und print_nodes:

void print_path( int von, int nach)


{
printf( "%s", stadt[von]);
17
print_nodes( von, nach);
printf( "->%s", stadt[nach]);
printf( " (%d km)\n", distanz[von][nach]);
}

Listing 17.13 Die Funktion print_path

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.

void print_nodes( int von, int nach)


{
int zpkt;

zpkt = zwischenpunkt[von][nach];
if( zpkt == –1)
return;

535
17 Elemente der Graphentheorie

print_nodes( von, zpkt);


printf( "->%s", stadt[zpkt]);
print_nodes( zpkt, nach);
}

Listing 17.14 Die Funktion print_nodes

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;

for( von = 0; von < ANZAHL; von++)


{
for( nach = 0; nach < ANZAHL; nach++)
zwischenpunkt[von][nach] = –1;
}
}

Listing 17.15 Initialisierung der Zwischenpunktmatrix

Als Distanzenmatrix wird initial die Adjazenzmatrix verwendet.

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;

A for( zpkt = 0; zpkt < ANZAHL; zpkt++)


{
B for( von = 0; von < ANZAHL; von++)
{

536
17.11 Der Algorithmus von Floyd

C for( nach = 0; nach < ANZAHL; nach++)


{
d = distanz[von][zpkt] + distanz[zpkt][nach];
D if( d < distanz[von][nach])
{
E distanz[von][nach] = d;
F zwischenpunkt[von][nach] = zpkt;
}
}
}
}
}

Listing 17.16 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

Abbildung 17.28 Graph mit dem Autobahnnetz

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();

print_path( BERLIN, STUTTGART);


print_path( MUENCHEN, HAMBURG);
}

Listing 17.17 Anwendung des Algorithmus von Floyd

Wir erhalten die folgenden Distanzen aus print_distanzen:

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

Mit diesen Zwischenpunkten aus print_zwischenpunkte:

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:

Berlin->Leipzig->Frankfurt->Stuttgart (791 km)


Muenchen->Leipzig->Hannover->Hamburg (835 km)

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.

17.12 Der Algorithmus von Dijkstra


Die Verfahrensidee des Algorithmus von Dijkstra möchten wir Ihnen an einem einfa-
chen Beispiel vorstellen. Wir betrachten dazu den folgenden Graphen, in dem wir alle
kürzesten Wege vom Startpunkt A aus suchen:

A
9 5
B 3 C

6 2 4

D 3 E

Abbildung 17.29 Beispielgraph für den Algorithmus von Dijkstra

539
17 Elemente der Graphentheorie

Dazu bietet sich die folgende Vorgehensweise an:

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.

Abbildung 17.30 zeigt dieses Vorgehen Schritt für Schritt:

(1) (2) (3) (4) (5)


A A A A A A
9 5 9 5 9
9 5 8 5 8 5 8 5 8 5
B 3 C B 3 C B 3 C B C B C B C

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

Abbildung 17.30 Das Vorgehen bei Dijkstra Schritt für Schritt

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;
};

struct knoteninfo info[ANZAHL];

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

Abbildung 17.31 Entstehender Baum für den Startpunkt Berlin

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

# define xxx 10000

unsigned int distanz[ ANZAHL][ ANZAHL] =


{
{ 0,xxx,xxx,205,xxx,xxx,284,282,xxx,179,xxx,xxx},
{xxx, 0,233,xxx,xxx,xxx,119,125,xxx,xxx,xxx,xxx},
{xxx,233, 0,xxx, 63,264,xxx,208, 83,xxx,xxx,xxx},
{205,xxx,xxx, 0,xxx,xxx,xxx,xxx,xxx,108,xxx,xxx},
{xxx,xxx, 63,xxx, 0,xxx,xxx,xxx, 47,xxx,xxx,xxx},
{xxx,xxx,264,xxx,xxx, 0,xxx,352,189,395,400,217},
{284,119,xxx,xxx,xxx,xxx, 0,154,xxx,xxx,xxx,xxx},
{282,125,208,xxx,xxx,352,154, 0,xxx,256,xxx,xxx},
{xxx,xxx, 83,xxx, 47,189,xxx,xxx, 0,xxx,xxx,xxx},
{179,xxx,xxx,108,xxx,395,xxx,256,xxx, 0,425,xxx},
{xxx,xxx,xxx,xxx,xxx,400,xxx,xxx,xxx,425, 0,220},
{xxx,xxx,xxx,xxx,xxx,217,xxx,xxx,xxx,xxx,220, 0},
};

Listing 17.18 Distanzenmatrix als Basis für den Dijkstra-Algorithmus

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:

void init( int ausgangspkt)


{
int i;

for( i = 0; i < ANZAHL; i++)


{
info[i].erledigt = 0;
info[i].distanz = distanz[ausgangspkt][i];
info[i].vorgaenger = ausgangspkt;
}
info[ausgangspkt].erledigt = 1;
info[ausgangspkt].vorgaenger = –1;
}

Listing 17.19 Initialisierung des info-Arrays

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;
}

Listing 17.20 Knotenauswahl

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:

void dijkstra( int ausgangspkt)


{
int i, knoten, k;
unsigned int d;

init( ausgangspkt);

543
17 Elemente der Graphentheorie

A for( i = 0; i < ANZAHL-2; i++)


{
B knoten = knoten_auswahl();
C info[knoten].erledigt = 1;
D for( k = 0; k < ANZAHL; k++)
{
E if( info[k].erledigt)
continue;
F d = info[knoten].distanz + distanz[knoten][k];
G if( d < info[k].distanz)
{
info[k].distanz = d;
info[k].vorgaenger = knoten;
}
}
}
}

Listing 17.21 Implementierung des Dijkstra-Verfahrens

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;

for( i = 0; i < ANZAHL; i++)


{
print_path( i);
printf( "%d km\n", info[i].distanz);
}
}

Listing 17.22 Ausgabe der Knoten durch Rekursion

544
17.12 Der Algorithmus von Dijkstra

Die Funktion print_all ruft die print_path-Funktion, die sich rekursiv selbst ruft:

void print_path( int i)


{
if( info[i].vorgaenger != –1)
print_path( info[i].vorgaenger);
printf( "%s-", stadt[i]);
}

Im Hauptprogramm wird der Kürzeste-Wege-Baum durch den Dijkstra-Algorithmus


erzeugt und anschließend ausgegeben:

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

Abbildung 17.32 Alle kürzesten Wege vom Startpunkt Berlin

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

17.13 Erzeugung von Kantentabellen


Wie angekündigt, lernen Sie noch ein zweites Verfahren kennen, um den Kürzeste-
Wege-Baum zu erzeugen, das, im Gegensatz zum Dijkstra-Algorithmus, kantenorien-
tiert vorgehen wird. Natürlich können Sie alle Kanten in der Adjazenzmatrix eines
Graphen finden. Wenn Sie aber von vornherein ein kantenorientiertes Vorgehen pla-
nen, ist es sinnvoll, anstelle einer Adjazenzmatrix eine Kantentabelle zu verwenden.

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

int distanz[ ANZAHL][ ANZAHL];

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;
}
}

Listing 17.23 Befüllen der Kantentabelle

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.

17.14 Der Algorithmus von Ford


Der Algorithmus von Ford ist ein kantenorientiertes Verfahren, mit dem alle kürzes-
ten Wege von einem festen Startpunkt aus ermittelt werden können. Ausgangspunkt
ist die Kantentabelle eines Graphen. Wir betrachten als Beispiel den bei den Kanten-
tabellen besprochenen Graphen:

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.

1. Durchlauf Durchlauf beendet


A A A A A

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.

Abbildung 17.35 1. Durchlauf des Algorithmus von Ford

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:

2. Durchlauf Durchlauf beendet


14 14 12
A A A A

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.

Abbildung 17.36 2. Durchlauf des Algorithmus von Ford

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;
};

struct knoteninfo info[ANZAHL];

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

void ford( int ausgangspkt)


{
int von, nach;
unsigned int d;
int stop;
int kante;

A init( ausgangspkt);

B for( stop = 0; !stop; )


{
C stop = 1;
D for( kante = 0; kante < anzahl_kanten; kante++)
{
E von = kantentabelle[kante].von;
F nach = kantentabelle[kante].nach;
G d = info[von].distanz + kantentabelle[kante].distanz;
H if( d < info[nach].distanz)
{
info[nach].distanz = d;
I info[nach].vorgaenger = von;
stop = 0;
}
}
}
}

Listing 17.24 Implementierung des Algorithmus von Ford

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

Kante 1: Berlin->Dresden 205


Kante 2: Berlin->Hamburg 284
void main() Kante 3: Berlin->Hannover 282
Kante 4: Berlin->Leipzig 179
{ Kante 5: Bremen->Dortmund 233
setup_kantentabelle(); Kante 6: Bremen->Hamburg 119
print_kantentabelle(); Kante 7: Bremen->Hannover 125
Kante 8: Dortmund->Bremen 233
ford( BERLIN); Kante 9: Dortmund->Duesseldorf 63
Hamburg print_all(); Kante 10: Dortmund->Frankfurt 264
Bremen 284 }
Kante 11: Dortmund->Hannover 208
Kante 12: Dortmund->Koeln 83
Kante 13: Dresden->Berlin 205
403 Berlin
Kante 14: Dresden->Leipzig 108
Kante 15: Duesseldorf->Dortmund 63
Hannover 0 Kante 16: Duesseldorf->Koeln 47
Kante 17: Frankfurt->Dortmund 264
Kante 18: Frankfurt->Hannover 352
282 Kante 19: Frankfurt->Koeln 189
Dortmund Kante 20: Frankfurt->Leipzig 395
Leipzig Kante 21: Frankfurt->Muenchen 400
Düsseldorf Kante 22: Frankfurt->Stuttgart 217
553 490 Kante 23: Hamburg->Berlin 284
179 205 Kante 24: Hamburg->Bremen 119
Kante 25: Hamburg->Hannover 154
Köln 573 Dresden
Kante 26: Hannover->Berlin 282
Kante 27: Hannover->Bremen 125
Kante 28: Hannover->Dortmund 208
574 Kante 29: Hannover->Frankfurt 352
Kante 30: Hannover->Hamburg 154
Frankfurt Kante 31: Hannover->Leipzig 256
Kante 32: Koeln->Dortmund 83
Kante 33: Koeln->Duesseldorf 47
Kante 34: Koeln->Frankfurt 189
Berlin-0 km Kante 35: Leipzig->Berlin 179
Berlin-Hamburg-Bremen-403 km Kante 36: Leipzig->Dresden 108
791 Berlin-Hannover-Dortmund-490 km Kante 37: Leipzig->Frankfurt 395
Berlin-Dresden-205 km Kante 38: Leipzig->Hannover 256
Stuttgart Kante 39: Leipzig->Muenchen 425
Berlin-Hannover-Dortmund-Duesseldorf-553 km
604 Berlin-Leipzig-Frankfurt-574 km Kante 40: Muenchen->Frankfurt 400
Berlin-Hamburg-284 km Kante 41: Muenchen->Leipzig 425
München Berlin-Hannover-282 km Kante 42: Muenchen->Stuttgart 220
Berlin-Hannover-Dortmund-Koeln-573 km Kante 43: Stuttgart->Frankfurt 217
Berlin-Leipzig-179 km Kante 44: Stuttgart->Muenchen 220
Berlin-Leipzig-Muenchen-604 km
Berlin-Leipzig-Frankfurt-Stuttgart-791 km

Abbildung 17.37 Ergebnis des Algorithmus von Ford


17

17.15 Minimale Spannbäume


Im Folgenden betrachten wir ungerichtete, zusammenhängende, gewichtete Gra-
phen mit nicht-negativen Kantengewichten.

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:

Als minimalen Spannbaum eines Graphen bezeichnen wir den Spannbaum,


der unter allen Spannbäumen die niedrigste Kantengewichtssumme hat.

551
17 Elemente der Graphentheorie

Hamburg
Bremen
284
Berlin
125
Hannover
282

Dortmund 208 179 205


Leipzig
Düsseldorf 63
83

Köln 395 Dresden

284
282
Frankfurt
179
217 205
425 125
208
63
Stuttgart 83
395
München 217
425
2466

Abbildung 17.38 Beispielhafte Spannbäume im Autobahnnetz

Es gibt zahlreiche Optimierungsfragen, zu deren Lösung ein minimaler Spannbaum


konstruiert werden muss. Zum Beispiel könnte das kürzeste Glasfasernetz längs der
Autobahn gesucht sein, das alle Großstädte Deutschlands miteinander verbindet.

Gesucht ist ein Algorithmus, der den minimalen Spannbaum eines Graphen be-
rechnet.

17.16 Der Algorithmus von Kruskal


Der Algorithmus von Kruskal dient dazu, den minimalen Spannbaum in einem Gra-
phen zu ermitteln, und basiert auf der im Folgenden dargestellten Verfahrensidee.

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

Abbildung 17.39 Minimaler Spannbaum nach dem Algorithmus von Kruskal

Das Verfahren läuft dann wie folgt ab:

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.

Abbildung 17.40 Schema des Verfahrens nach Kruskal

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.

Zur Speicherung der Mengen verwenden wir wieder rückwärtsverkettete Baumstruk-


turen in einem Array.

# define ANZAHL 12
int vorgaenger[ANZAHL];

Die im Laufe des Verfahrens ausgewählten Kantenindizes werden ebenfalls in einem


Array festgehalten:

# 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;

for( i= 0; i < ANZAHL; i++)


vorgaenger[i] = –1;
}

Listing 17.25 Initialisierung der Knoten

554
17.16 Der Algorithmus von Kruskal

struct kante kantentabelle[ANZ_KANTEN] =


{
{ 47, 4, 8},
{ 63, 2, 4}, Hamburg
Bremen
{ 83, 2, 8}, 119
{ 108, 3, 9}, 284
154 Berlin
{ 119, 1, 6}, 125
Hannover
{ 125, 1, 7}, 282
233
{ 154, 6, 7},
{ 179, 0, 9}, Dortmund 208 256 179 205
Leipzig
{ 189, 5, 8}, Düsseldorf 63
{ 205, 0, 3}, 83 352
47 264 108
{ 208, 2, 7},
Köln 395 Dresden
{ 217, 5,11}, 189
{ 220,10,11},
{ 233, 1, 2},
Frankfurt
{ 256, 7, 9},
{ 264, 2, 5}, 217
{ 282, 0, 7}, 400 425
{ 284, 0, 6},
{ 352, 5, 7}, Stuttgart 220
{ 395, 5, 9},
{ 400, 5,10}, München
{ 425, 9,10},
};

Abbildung 17.41 Das Array kantentabelle im Beispiel

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).

int join( int a, int b)


{
while( vorgaenger[a] != –1)
a = vorgaenger[a];
while( vorgaenger[b] != –1)
b = vorgaenger[b];
if( a == b)
return 0;
vorgaenger[b] = a;
return 1;
}

Listing 17.26 Vereinigung der Knoten

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);
}

Listing 17.27 Implementierung des Algorithmus von Kruskal

Es fehlt noch eine Funktion, um die gewählten Kanten auszugeben:

void ausgabe()
{
int kante;
unsigned int summe;

for( kante = 0, summe = 0; kante < ANZ_KANTEN; kante++)


{
if( ausgewaehlt[kante])
{
summe += kantentabelle[kante].distanz;
printf( "%4d %s-%s\n", kantentabelle[kante].distanz,
stadt[kantentabelle[kante].von],
stadt[kantentabelle[kante].nach]);
}
}
printf( "----\n%4d\n", summe);
}

Listing 17.28 Ausgabe der Kanten

In dieser Funktion werden gleichzeitig die Gewichte der ausgewählten Kanten


addiert, und die Kantengewichtssumme wird am Ende ausgegeben.

In Abbildung 17.42 sehen Sie den berechneten minimalen Spannbaum:

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

Abbildung 17.42 Ergebnis des Algorithmus von Ford


17

17.17 Hamiltonsche Wege


Im Jahre 1859 stellte der irische Mathematiker W. R. Hamilton eine Knobelaufgabe
vor, bei der es darum ging, auf einem Dodekaeder2 eine »Reise um die Welt« zu
machen.

Abbildung 17.43 Dodekaeder für die Reise um die Welt

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:

1. Der Weg ist geschlossen.


2. Alle Knoten des Weges, außer Anfangs- und Endpunkt, sind voneinander ver-
schieden.
3. Jeder Knoten des Graphen kommt in dem Weg vor.

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.

Abbildung 17.44 Graph ohne hamiltonschen Weg

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

Abbildung 17.45 Der Dodekaeder als Graph

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

unsigned int dodekaeder[ ANZAHL][ ANZAHL] =


{
{0,1,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0},
{1,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0},
2 {0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0},
{0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0},
{1,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0},
10
{0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0},
9 11
{1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0},
17 18 {0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0},
1 3 {0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0},
8 12
{0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0},
{0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0},
16 19 {0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0},
7 13 {0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0},
15 {0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1},
{0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0},
6 14 {0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,1},
5 {0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0},
{0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0},
0 4
{0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1},
{0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0}
};

Abbildung 17.46 Adjazenzmatrix des Dodekaeders

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.

Hier sehen Sie das Knotenpermutationsprogramm mit den zusätzlichen Prüfungen:

void hamilton( int anz, int array[], int start)


{
int i, sav;

A if( start == anz)


{
if( dodekaeder[array[anz-1]][array[0]])
B {
C for( i = 0; i < anz; i++)
printf( "%d-", array[i]);
D printf( "%d\n", array[0]);
}
}
E else
{
sav = array[ start];
for( i = start; i < anz; i++)
{

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;
}
}

Listing 17.29 Permutation der Knoten

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).

Wird dieses Programm aus einem entsprechenden Hauptprogramm

void main()
{
A int pfad[ANZAHL]; 17
int i;

B for( i = 0; i < ANZAHL; i++)


pfad[i] = i;

C hamilton(ANZAHL, pfad, 1);


}

Listing 17.30 Hauptprogramm zum Aufruf von hamilton

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

Abbildung 17.47 Erster und letzter gefundener hamiltonscher Weg

17.18 Das Travelling-Salesman-Problem


Zum Abschluss dieses Kapitels wollen wir eines der am meisten untersuchten Pro-
bleme der Informatik diskutieren.

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 };

München int mindist = xxx;


int minpfad[ANZAHL];

Abbildung 17.48 Erweiterungen zur Speicherung des minimalen Pfades

Listing 17.31 zeigt die notwendigen Erweiterungen:

void hamilton( int anz, int array[], int start)


{
int i, sav;
unsigned int d;
17
if( start == anz)
{
if( distanz[array[anz-1]][array[0]] < xxx)
A {
B for( i = 0, d = 0; i < anz; i++)
d += distanz[array[i]][array[(i+1)%anz]];
C if( d < mindist)
{
D mindist = d;
E for( i = 0, d = 0; i < anz; i++)
minpfad[i] = array[i];
}
}
}
else
{
...
}
}

Listing 17.31 Erweiterungen bei hamilton für das TSP

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);
}

Abbildung 17.49 Kürzester gefundener hamiltonscher Weg

Ob das Rundreiseprogramm wirklich die absolut kürzeste Rundreise durch Deutsch-


land geliefert hat, wissen wir nicht. Das Programm hat nur die kürzeste Rundreise
innerhalb des ihm zur Verfügung stehenden Graphen berechnet. Der Graph enthielt
aber nur ausgewählte Städteverbindungen. Zum Beispiel gab es keine Direktverbin-
dung von Berlin nach Frankfurt, und diese Direktverbindung wird kürzer sein als der
Weg über Hannover oder Leipzig. Die kürzeste Reiseroute könnte mit einer Direkt-
fahrt von Berlin nach Frankfurt beginnen. Leipzig und Hannover würden dann später
angefahren. Wenn wir wirklich die kürzeste Reiseroute zwischen unseren zwölf Städ-
ten finden wollen, müssen wir einen Graphen betrachten, der alle paarweisen Städte-
verbindungen enthält. In solch einem Graphen steht jede Permutation der Knoten
für einen hamiltonschen Weg. Da der erste Knoten fest gewählt werden kann, müs-
sen noch 11! = 39916800, also knapp 40 Millionen hamiltonsche Wege, untersucht
werden. In unserem Programm wurden dagegen nur sechs Wege verglichen.

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.

Für die Menge aller mit polynomialer Berechnungskomplexität lösbaren Probleme


verwenden wir die Bezeichnung P.

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.

Das Travelling-Salesmann-Problem ist z. B. ein Problem aus NP.

Es gibt in NP – und das kann man beweisen – sogenannte NP-vollständige Probleme.


Dies sind Probleme, auf die man alle Probleme in NP mit einem maximal polynomi-
17
alen Zusatzaufwand reduzieren kann. Das TSP ist ein solches Problem. Hätten Sie also
einen Algorithmus polynomialer Laufzeit für ein NP-vollständiges Problem (z. B. für
das TSP), könnten Sie alle Probleme aus NP mit polynomialem Aufwand lösen. Es wäre
NP = P. In diesem Fall müssten alle Bücher über Algorithmen neu geschrieben werden.

NP-vollständig

NP
TSP

Abbildung 17.50 Zusammenhang von P und NP

Die Frage, ob es für ein NP-vollständiges Problem einen polynomialen Lösungsalgo-


rithmus gibt, ist vielleicht die bedeutendste Frage der theoretischen Informatik, und
sie ist bis heute unbeantwortet. Auf die Beantwortung dieser Frage ist eine Beloh-

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.

Gesucht ist ein Algorithmus polynomialer Laufzeitkomplexität, der eine Näherungs-


lösung für das Problem des Handlungsreisenden findet, die eine garantierte Maxi-
malabweichung von der unbekannten optimalen Lösung hat.
Wir treffen zwei zusätzliche Annahmen über den Graphen:
1. Für je zwei Knoten gibt es eine direkte Verbindung (Kante) im Graphen.
2. Direkte Verbindungen sind nie länger als Umwegstrecken über einen dritten Ort.

Die allgemeinen Rahmenbedingungen Symmetrie, Zusammenhang und keine nega-


tiv bewerteten Kanten bleiben natürlich weiterhin bestehen.
Unter diesen Annahmen, die in wichtigen Anwendungsfällen zutreffen, ist es mög-
lich, eine Näherungslösung für das TSP zu finden. Der Algorithmus dazu basiert auf
minimalen Spannbäumen, die wir ja in polynomialer Laufzeit berechnen können.
Wenn Sie aus einem hamiltonschen Weg eine Kante entfernen, erhalten Sie einen
Spannbaum. Umgekehrt können Sie versuchen, aus einem Spannbaum einen hamil-
tonschen Weg zu konstruieren.
Wir betrachten den Spannbaum des deutschen Autobahnnetzes und durchlaufen die-
sen Baum, von Berlin aus startend, in Tiefensuche, wobei wir die Nachfolger eines
Knotens in alphabetisch aufsteigender Reihenfolge besuchen (siehe Abbildung 17.51).
Dann ergibt sich die folgende Besuchsreihenfolge:
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

566
17.18 Das Travelling-Salesman-Problem

Hamburg
Bremen

Berlin
Hannover

Dortmund
Düsseldorf
Leipzig
Köln
Dresden

Frankfurt

17
Stuttgart

München

Abbildung 17.51 Hamiltonscher Weg und Spannbaum

Ü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

Das ist ein hamiltonscher Weg der Länge 2558 km.

567
17 Elemente der Graphentheorie

Hamburg
Bremen

Berlin
Hannover

Dortmund
Düsseldorf
Leipzig
Köln
2558 km Dresden

Frankfurt aus dem minimalen


Spannbaum
konstruierter
hamiltonscher Weg

Stuttgart
minimaler
Spannbaum München

Abbildung 17.52 Ermittelter hamiltonscher Weg im Autobahnnetz

Je nach Startknoten erhalten Sie unter Umständen einen anderen hamiltonschen


Weg. Sie könnten für jeden Startknoten diesen hamiltonschen Weg berechnen und
den kürzesten dieser Wege als Näherungslösung für das TSP verwenden.

Wir wollen dieses Verfahren implementieren und verwenden eine Distanzenmatrix,


die die Entfernungen zwischen allen Städtepaaren enthält (siehe Abbildung 17.53).

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},
};

Abbildung 17.53 Distanzenmatrix zwischen den Städtepaaren

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

Abbildung 17.54 Adjazenzmatrix des Spannbaums

569
17 Elemente der Graphentheorie

Wir erstellen ein Array (pfad), um den bei der Tiefensuche konstruierten Weg aufzu-
nehmen:

static int pfad[ANZAHL];


int position;

Die globale Variable position legt dabei die aktuelle Schreibposition in diesem Array
fest.

Die Tiefensuche wird rekursiv implementiert:

void tiefensuche( int knoten, int herkunft)


{
int i;

A pfad[position++] = knoten;
for( i = 0; i < ANZAHL; i++)
{
B if( spannbaum[knoten][i] && (herkunft != i))
tiefensuche( i, knoten);
}
}

Listing 17.32 Implementierung der Tiefensuche

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.

Im Hauptprogramm werden in einer Schleife alle Knoten einmal als Startpunkt


gewählt.

570
17.18 Das Travelling-Salesman-Problem

void ausgabe()
{
int i;
int d;

for( i = 0, d = 0; i < ANZAHL; i++)


{
printf( "%d-", pfad[i]);
d += distanz[pfad[i]][pfad[(i+1)%ANZAHL]];
}
printf( "%d (%d km)\n", pfad[0], d);

Listing 17.33 Ausgabe des vorliegenden Pfades

void main()
{
int start;

for( start = 0; start < ANZAHL; start++)


{
position = 0;
tiefensuche( start, –1);
ausgabe(); 17
}
}

Listing 17.34 Hauptprogramm für die Näherungslösung des TSP

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

0-9-3-7-1-6-2-4-8-5-11-10-0 (2558 km)


1-6-7-2-4-8-5-11-10-9-0-3-1 (2496 km)
2-4-8-5-11-10-7-1-6-9-0-3-2 (3001 km)
3-9-0-7-1-6-2-4-8-5-11-10-3 (2376 km)
4-2-7-1-6-9-0-3-8-5-11-10-4 (3126 km) Leipzig
5-8-4-2-7-1-6-9-0-3-11-10-5 (2670 km)
6-1-7-2-4-8-5-11-10-9-0-3-6 (2499 km)
7-1-6-2-4-8-5-11-10-9-0-3-7 (2496 km)
8-4-2-7-1-6-9-0-3-5-11-10-8 (2821 km) 2376
9-0-3-7-1-6-2-4-8-5-11-10-9 (2496 km)
10-11-5-8-4-2-7-1-6-9-0-3-10 (2447 km)
11-5-8-4-2-7-1-6-9-0-3-10-11 (2447 km)
BERLIN 0
BREMEN 1
DORTMUND 2
DRESDEN 3
DUESSELDORF 4
FRANKFURT 5
HAMBURG 6 Stuttgart
HANNOVER 7
KOELN 8
LEIPZIG 9 München
MUENCHEN 10
STUTTGART 11

Abbildung 17.55 Approximation des optimalen Weges

Im Folgenden sei:

m die Länge des minimalen hamiltonschen Weges


s die Länge des minimalen Spannbaums
h die Länge des durch dieses Verfahren ermittelten hamiltonschen Weges

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

Das mag unbefriedigend sein, aber das Approximationsverfahren hat polynomiale


Laufzeit, während die vollständige Lösungssuche exponentielle Laufzeit hat.

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.

Beispiel für eine Variable:


18
A int v;

B printf( "%d\n", &v);

Die Variable v wird angelegt (A), und ihr Adresswert wird ausgegeben (B):

4061360

Beispiel für eine Funktion:

A void f()
{
}

B printf( "%d\n", &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.

Im Grunde genommen interessieren uns konkrete Adresswerte von Variablen oder


Funktionen nicht; wichtig ist, dass wir über die Adresse auf das ursprüngliche Objekt
zugreifen können. Das ist ja auch bei alltäglichen Adressen so. Wir speichern eine E-
Mail-Adresse im Adressbuch unseres E-Mail-Programms und verwenden sie, wenn
wir dem Kontakt eine E-Mail schicken wollen. Der konkrete Adresswert (yxz@abc.de)
interessiert uns dabei eigentlich nicht.

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;

printf( "%d\n", &v);


printf( "%d\n", &v+1);
printf( "%d\n", &v+2);
printf( "%d\n", &v+3);

Es wird z. B. folgende Ausgabe erzeugt:

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.

Adressen sind aber auch geeignet, um verkettete Datenstrukturen (Listen, Bäume) zu


erstellen. Sie erstellen eine Verkettung, indem Sie in eine Struktur die Adresse einer

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
};

Entsprechend vertauscht, sieht die Struktur dann so aus:

struct test2
{
long l1;
long l2;
long l3;
long l4;
char c1;
char c2;
char c3;
char c4;
};

Beide Datenstrukturen enthalten exakt die gleichen Informationen, und beide


Datenstrukturen sind in der Verwendung identisch. Sie werden vielleicht vermuten,

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));
}

Wir erhalten die folgende Ausgabe:

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.

Nicht optimal ausgerichtet.


Es müssen zwei Lesevorgänge gemacht und das Ergebnis
muss aus den beiden Vorgängen montiert werden.

Abbildung 18.1 Optimale Ausrichtung der Bytes

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

Abbildung 18.2 Anordnung durch den Compiler, um ein Alignment zu erzwingen

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

Abbildung 18.3 Alignment ohne zusätzliche Füllfelder

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.

Arithmetische Operatoren (+, –, *, /, %)


Für beliebige Zahlenwerte gibt es die zweistelligen arithmetischen Operatoren für
Addition (+), Subtraktion (–), Multiplikation (*) und Division (/).

Hinzu kommen einstellige Operatoren (+, –) für das Vorzeichen.

Für ganzzahlige Werte gibt es den »Rest bei Division« (Modulo-Operation).

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

+ +x plus x arithmetischer R 14
Operator
- -x minus x

Tabelle 18.1 Arithmetische Operatoren

579
18 Zusammenfassung und Ergänzung

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

* x*y Multiplikation arithmetischer L 13


Operator
/ x/y Division

% x%y Rest bei Division

+ x+y Addition arithmetischer L 12


Operator
– x–y Subtraktion

Tabelle 18.1 Arithmetische Operatoren (Forts.)

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

Im Programm ist aber


x = 10*(10/100) = 10*0 = 0
y = (10*10)/100 = 100/100 = 1

Abbildung 18.4 Division mit ganzen Zahlen

Die Division mit Rest und den Rest bei Division (Modulo-Operation) betrachtet man
üblicherweise nur bei positiven Zahlen. Dort ist alles eindeutig geregelt:

Da 7 = 2 · 3 + 1 ist, ergibt sich:


a = 2 und b = 1
a = 7/3;
b = 7%3;

Abbildung 18.5 Ganzzahlige Division mit Rest

580
Arrays

Bei negativen Zahlen gibt es verschiedene Möglichkeiten, die Modulo-Operation zu


definieren, je nachdem, ob Sie hier negative oder positive Divisionsreste festlegen:

–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.

Im einfachsten Fall handelt es sich um eine eindimensionale Anordnung, wobei die


einzelnen Elemente über einen Index angesprochen werden können:

Index

a[0] a[1] a[2] a[3]

Abbildung 18.6 Indexierung im eindimensionalen Array 18

Die Reihungen können dabei in mehreren Dimensionen gebildet werden, wobei es


dann in jeder Dimension einen Index gibt:

a[4][0][0] a[4][0][1] a[4][0][2] a[4][0][3]


4 a[4][1][0] a[4][1][1] a[4][1][2] a[4][1][3]
0–
n a[3][0][0] a[3][0][1] a[3][0][2] a[3][0][3]
s io a[4][2][0] a[4][2][1] a[4][2][2] a[4][2][3]
en a[3][1][0] a[3][1][1] a[3][1][2] a[3][1][3]
im
D a[2][0][0] a[2][0][1] a[2][0][2] a[2][0][3]
1. a[3][2][0] a[3][2][1] a[3][2][2] a[3][2][3]
a[2][1][0] a[2][1][1] a[2][1][2] a[2][1][3]
2. Dimension 0 – 2

a[1][0][0] a[1][0][1] a[1][0][2] a[1][0][3]


a[2][2][0] a[2][2][1] a[2][2][2] a[2][2][3] 3. Dimension 0–3
a[1][1][0] a[1][1][1] a[1][1][2] a[1][1][3]
a[0][0][0] a[0][0][1] a[0][0][2] a[0][0][3]
a[1][2][0] a[1][2][1] a[1][2][2] a[1][2][3]
a[0][1][0] a[0][1][1] a[0][1][2] a[0][1][3]
a[0][2][0] a[0][2][1] a[0][2][2] a[0][2][3]

Abbildung 18.7 Indexierung im mehrdimensionalen Array

581
18 Zusammenfassung und Ergänzung

Zu einem Array gehören:

왘 ein Datentyp für die Felder (Feldtyp)


왘 ein Name, über den das Array angesprochen werden kann
왘 eine feste Anzahl von Dimensionen
왘 eine feste Anzahl von Elementen in jeder Dimension

Datentyp

Name

Anzahl Elemente für jede Dimensionen

int daten[3][7][5];

Anzahl der Dimensionen = 3

Abbildung 18.8 Anlegen eines Arrays

Arrays können für alle verfügbaren Datentypen gebildet werden. Es gibt also:

왘 Arrays von Ganzzahlen


왘 Arrays von Gleitkommazahlen
왘 Arrays von Datenstrukturen
왘 Arrays von Aufzählungstypen
왘 Arrays von Bitfeldern
왘 Arrays von Zeigern

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];

Arrays können bei der Definition mit Initialwerten versehen werden:

582
Arrays

int matrix[3][2] = {
{ 11, 12},
{ 21, 22},
{ 31, 32}
};

Diese Werte können natürlich im Laufe des Programms geändert werden.

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}
};

float zahlen[] = {1.1, 2.2, 3.3};

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;

Falsch berechnete Array-Indizes sind eine der häufigsten Fehlerursachen in C-Pro-


grammen. Bei iterierter Verarbeitung von Arrays durch Schleifen sollten Sie daher
immer den Minimal- und den Maximalwert überprüfen, um sicherzustellen, dass der
gültige Indexbereich nicht verlassen wird.

Arrays als Funktionsparameter


Wenn ein Array an eine Funktion übergeben wird, dann wird das Array als Zeiger
(siehe Abschnitt »Arrays und Zeiger«) übergeben. Damit erhält das Unterprogramm
eine Referenz auf die Originaldaten und kann diese gegebenenfalls verändern.

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:

void initialisierung( int anzahl, int *daten)


{
int i;

for( i = 0; i < anzahl; i++)


void main()
daten[i] = i+1;
{
}
int zahlen[10];

initialisierung ( 10, zahlen); void ausgabe( int anzahl, int *daten)


ausgabe ( 10, zahlen); {
} int i;
1 2 3 4 5 6 7 8 9 10
for( i = 0; i < anzahl; i++)
printf( "%d ", daten[i]);
printf( " \n");
}

Abbildung 18.9 Arrays als Funktionsparameter

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

void initialisierung( int anzahl, int (*daten)[5])


{
int i, k;

for( i = 0; i < anzahl; i++)


{
for( k = 0; k < 5; k++)
daten[i][k] = i+k;
void main()
}
{
}
int zahlen[3][5];

initialisierung( 3, zahlen); void ausgabe( int anzahl, int (*daten)[5])


ausgabe( 3, zahlen); {
} int i, k;
0 1 2 3 4
1 2 3 4 5 for( i = 0; i < anzahl; i++)
2 3 4 5 6 {
for( k = 0; k < 5; k++)
printf( "%d ", daten[i][k]);
printf( "\n");
}
}

Abbildung 18.10 Mehrdimensionale Arrays als Funktionsparameter

Arrays und Zeiger


Arrays und Zeiger werden in C synonym verwendet. Mit anderen Worten:

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;

for( i = 0; i < 10; i++)


D printf( "%d ", zahlen[i]);
printf( "\n");

585
18 Zusammenfassung und Ergänzung

for( i = 0; i < 10; i++)


E printf( "%d ", z[i]);
printf( "\n");

Listing 18.1 Arrays und Zeiger

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;

A int zahlen[2][3] = {{1,2,3},{4,5,6}};

B int (*z)[3];

C z = zahlen;

for( i = 0; i < 2; i++)


{
for( k = 0; k < 3; k++)
D printf( "%d ", zahlen[i][k]);
printf( "\n");
}
printf( "\n");

for( i = 0; i < 2; i++)


{
for( k = 0; k < 3; k++)
E printf( "%d ", z[i][k]);

586
Arrays und Zeiger

printf( "\n");
}
printf( "\n");

Listing 18.2 Mehrdimensionale Arrays und Zeiger

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

Für höhere Dimensionen gilt Entsprechendes, da ein n-dimensionales Array immer


ein eindimensionales Array über einem n-1-dimensionalen Array ist.

Mit den Regeln der Zeigerarithmetik folgt, dass wir ein Element des Arrays sowohl
über seinen Index als auch sein Offset ansprechen können.

In einem Array a sind die Zugriffe a[i] und *(a+i) identisch.

Im Zweidimensionalen heißt dies, dass die Zugriffe a[i][k], (*(a+i))[k] und


*(*(z+i)+k) identisch sind.
18

Das kann zu unleserlichen Ausdrücken und ausgesprochen bizarren Identitäten füh-


ren. Zum Beispiel:

a[i] = *(a+i) = *(i+a) = i[a]

Bei dem folgenden Beispiel handelt es sich konsequenterweise um korrekten, aber


unleserlichen C-Code:

float a[3] = {1.1, 2.2, 3.3};

int i = 1;
float x;

x = i[a];

Probieren Sie es aus, aber verwenden Sie es nicht in Ihren Programmen.

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

Das Zeichen \ hat den ASCII-Code 5c16 = 1348.

Abbildung 18.11 Auszug aus der ASCII-Zeichentabelle

Im ASCII-Zeichencode wird jedes Zeichen durch eine 7-Bit-Codierung dargestellt und


kann daher in einem 8-Bit-Datenwort (unsigned char) abgelegt werden.

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.

Eine Formatanweisung hat den folgenden formalen Aufbau:

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:

Specifier Datentyp Ausgabeformat

d oder i ganze Zahl dezimal

u vorzeichenlose ganze dezimal


Zahl

o vorzeichenlose ganze oktal


Zahl

x vorzeichenlose ganze hexadezimal mit a, b, s, d, e und f


Zahl

X vorzeichenlose ganze hexadezimal mit A, B, C, D, E und F 18


Zahl

f Gleitkommazahl keine Exponentenschreibweise

F Gleitkommazahl keine Exponentenschreibweise

e Gleitkommazahl Exponentenschreibweise mit e

E Gleitkommazahl Exponentenschreibweise mit E

g Gleitkommazahl kürzeste Variante von f oder e

G Gleitkommazahl kürzeste Variante von F oder E

a Gleitkommazahl hexadezimal mit Kleinbuchstaben

A Gleitkommazahl hexadezimal mit Großbuchstaben

c Zeichen

s String

Tabelle 18.2 Specifier für die Ausgabe

589
18 Zusammenfassung und Ergänzung

Specifier Datentyp Ausgabeformat

p Zeiger (Adresswert) hexadezimal

n int * Keine Ausgabe. Die Länge der bisherigen


Ausgabe wird in der referenzierten Varia-
blen gespeichert.

% Ausgabe eines %-Zeichens

Tabelle 18.2 Specifier für die Ausgabe (Forts.)

Zwischen dem %-Zeichen und dem Format-Specifier können die folgenden Kennzei-
chen angegeben werden:

Kennzeichen Bedeutung

- Die Ausgabe wird links ausgerichtet.


Bei fehlendem Kennzeichen wird rechts ausgerichtet.

+ Ausgabe mit Vorzeichen.


Bei fehlendem Kennzeichen wird nur bei negativen Werten ein
Vorzeichen ausgegeben.

Leerzeichen Ausgabe eines Leerzeichens anstelle des +-Zeichens

# Bei Oktal- und Hexadezimalausgaben wird 0, 0x bzw. 0X vorange-


stellt.
Bei Gleitkommaausgaben wir auch dann ein Dezimalpunkt ausge-
geben, wenn keine Ziffern hinter dem Punkt folgen.

0 Zahlen werden von links mit führenden Nullen aufgefüllt.

Tabelle 18.3 Kennzeichen für die Ausgabe

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.

* Die Feldbreite wird aus dem nächsten Parameter der Parameter-


liste gelesen. Als Parameter wird ein Ganzzahlwert erwartet.

Tabelle 18.4 Feldbreite für die Ausgabe

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.

Tabelle 18.5 Präzisionsangabe für die Ausgabe

Die optionale Abgabe von length ermöglicht Konvertierungen zwischen verschiede-


nen Typen von Eingabeparametern, wenn der übergebene Parameter einmal nicht
den Datentyp hat, der üblicherweise erwartet wird. Dieser Teil der Formatanweisung
ist sehr technisch und wird hier nicht umfassender diskutiert.

Mit den Formatanweisungen können Sie sehr flexibel Ausgabeformatierungen


18
erzeugen. Die Ausgabe mit printf bezieht sich aber auf Ausgaben im Konsolenfens-
ter. Im Zeitalter grafischer Benutzeroberflächen verliert diese Art der Ausgabe mehr
und mehr an Bedeutung und wird eigentlich nur noch für Prüfdrucke verwendet.

Abbildung 18.12 zeigt einige Beispiele:

double d = 3.14; Ohne besondere Formatierung

Feldbreite 10, 2 Nachkommastellen, rechtsbündig


printf( "%f\n", d);
printf( "%10.2f\n", d); Feldbreite 10, 3 Nachkommastellen, linksbündig
printf( "%-10.3f\n", d);
printf( "%-10.*f\n", 4, d); Feldbreite 10, 4 Nachkommastellen, linksbündig
printf( "%E\n", d);
Exponentialschreibweise
3.140000
3.14
3.140
3.1400
3.140000E+000

Abbildung 18.12 Verschiedene Formatanweisungen und ihre Wirkung

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:

auto int summe;


auto double wert;

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;

den Operator für die bedingte Auswertung verwenden:

max = a > b ? a : b;

Die allgemeine Form des Operators ist: test ? ausdruck1 : ausdruck2


Zur Berechnung des Ergebnisses wird zunächst der Ausdruck test ausgewertet. Ist
dieser Ausdruck wahr (≠ 0), wird ausdruck1 ausgewertet und geht als Ergebnis in die
weitere Verarbeitung ein. Ist der Ausdruck test falsch (= 0), wird der ausdruck2 ausge-
wertet und ist das Ergebnis. Beachten Sie, dass von den beiden Ausdrücken auf der

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

max = a > b ? a++ : b++;

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

unsigned int jahr : 11;


};

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;
};

Bitfelder können mit allen ganzzahligen Datentypen (vorrangig unsigned) genutzt


werden. Bitfelder werden allerdings nicht sehr häufig verwendet, da der Hauptan-
wendungsbereich in der maschinennahen Programmierung liegt und man es dort
bevorzugt, durch Verwendung von Bitoperationen die vollständige Kontrolle über
die erzeugten Bitmuster zu haben.

Bitoperatoren (~, <<, >>, &, ^, |)


Bitoperationen dienen dazu, auf einzelne Bits eines Datums lesend oder schreibend
zuzugreifen. Man kann Bits invertieren, mit »und«, »oder« bzw. »entweder oder«
verknüpfen und nach links oder rechts schieben:

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

~ ~x bitweises Komplement Bitoperator R 14

<< x << y Bitshift links Bitoperator L 11

>> x >> y Bitshift rechts

& x&y bitweises Und Bitoperator L 8

^ x^y bitweises Entweder-Oder Bitoperator L 7

| x|y bitweises Oder Bitoperator L 6

Tabelle 18.6 Bitoperatoren

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.

Für jede Bitstelle wird eine eigene


Und-Verknüpfung durchgeführt.

Abbildung 18.13 Bitweises Und auf ein Datenwort

Insgesamt gibt es folgende Verknüpfungen (Abbildung 18.4):

Bitweises Komplement Bitweises Und

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

Bitweises Oder Bitweises Entweder-Oder


x 1 1 0 0 0 0 1 0 x 1 1 0 0 0 0 1 0
y 1 0 0 1 1 0 1 1 y 1 0 0 1 1 0 1 1
x|y 1 1 0 1 1 0 1 1 x^y 0 1 0 1 1 0 0 1

Abbildung 18.14 Bitweise Verknüpfungen in der Übersicht

Neben diesen bitweisen Verknüpfungen gibt es noch Schiebeoperationen. Bei diesen


Operationen werden die Bits eines Datenworts nach links oder rechts verschoben:

Bitshift rechts Bitshift links


x 1 0 0 1 1 0 1 1 x 1 0 0 1 1 0 1 1
x>>2 0 0 1 0 0 1 1 0 x<<2 0 1 1 0 1 1 0 0

Abbildung 18.15 Bitweises Verschieben

595
18 Zusammenfassung und Ergänzung

Mit Bitoperationen können gezielt einzelne Bits in einem Datenwort gesetzt,


gelöscht, invertiert oder abgefragt werden:

int n = 3;
unsigned int x = 0xaffe;

x = x | (1<<n); // Setzen des n-ten Bits in x


x = x & ~(1<<n); // Loeschen des n-ten Bits in x
x = x ^ (1<<n); // Invertieren des n-ten Bits in x

if( x & (1<<n)) // Test, ob das n-te Bit in x gesetzt ist


{
...
}

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:

unsigned char a = 0xff;


signed char b = 0xff; 255
int i; 127
63
31
for( i = 0; i <= 8; i++) 15
{ 7
3
printf( "%d\n", a); 1
a = a >> 1; 0 Hier (unsigned) wird
} 0 nachgeschoben.
-1
printf( "\n"); -1
for( i = 0; i <= 8; i++) -1
{ -1
-1
printf( "%d\n", b); -1
b = b >> 1; -1
} -1
-1
Hier (signed) wird 1
nachgeschoben.

Abbildung 18.16 Unterschiedliches Verhalten des Verschiebens abhängig vom Datentyp

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

Abbildung 18.17 Blöcke als Ebenen

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.

1 Diese Einschränkung wird später, in C++, aufgehoben.

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;

Wenn man umgekehrt eine Float-Variable einer Integer-Variablen zuweist, besteht


die Gefahr eines Informationsverlustes, und der Compiler erzeugt zumindest einen
Warnhinweis. Diesen Hinweis können Sie durch eine explizite Typumwandlung
beseitigen und nehmen dann den Informationsverlust in Kauf:

float f = 1.23;
int i;

i = (int)f;

Zur Typumwandlung schreiben Sie den gewünschten Ergebnistyp in runden Klam-


mern vor den zu konvertierenden Ausdruck.

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 = 10/100; f ist 0.0,


da die Konvertierung in float erst
f = (float)(10/100); nach der Division durchgeführt wird.

f = ((float)10)/100;
f ist 0.1,
da der Operand 10 vor der Division
in float konvertiert wird.

Abbildung 18.18 Beispiele für den sinnvollen Einsatz der Typumwandlung

Gern verwendet werden Typkonvertierungen auch in Verbindung mit Zeigern an


Funktionsschnittstellen. Eine Funktion wie malloc allokiert Speicher, ohne wissen zu
können, wofür der Speicher benötigt wird. Die Funktion gibt daher einen unspezifi-
zierten Zeiger (void *) zurück. Damit ein solcher Zeiger sinnvoll verwendet werden
kann, muss eine Konvertierung auf den tatsächlich benötigten Zeigertyp stattfinden:

int *pointer;

pointer = (int *)malloc( ...);

Verschiedene Zeigertypen können immer ohne Informationsverlust konvertiert 18


werden, da mit einem anderen Zeigertyp nur eine andere Interpretation des referen-
zierten Objekts einhergeht. Der Programmierer ist allerdings dafür verantwortlich,
dass diese Interpretation auch stimmt. Durch einfaches Casting wird z. B. aus einer
Zahl kein String und aus einem String keine Zahl.

Im Zusammenhang mit Callback-Funktionen werden häufig Parameterkonvertie-


rungen benötigt, wenn man Informationen transparent durch eine Funktion an eine
Callback-Funktion weiterleiten will. Dazwischenliegende Funktionsinstanzen rei-
chen dann die Daten nur durch, ohne deren Typ zu kennen. Erst in der Zielfunktion
wird dann der wirkliche Datentyp durch eine Cast-Operation wiederhergestellt. Dazu
muss die Zielfunktion die richtige Interpretation der Daten kennen.

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;

for( i = 1, s = 0; i < 10; i++)


Preprozessorlauf
{
mit ungesetztem Compileschalter
s = s + i;
# undef TESTVARIANTE }
printf( "Endergebnis: %d\n", s);
}
Endergebnis: 45

Abbildung 18.19 Die Verwendung von Compile-Schaltern

Je nachdem, ob der Compile-Schalter TESTVARIANTE bei der Erzeugung des Pro-


gramms gesetzt ist oder nicht, werden die Prüfdrucke dann übernommen oder her-

600
Compile-Schalter

ausgefiltert. Typischerweise wird der Compile-Schalter in einer zentralen Header-


Datei mit #define gesetzt oder #undef zurückgenommen. Diese Header-Datei wird
dann von allen betroffenen Quellcodedateien inkludiert. Der Compile-Schalter kann
aber auch über die Kommandozeile des Compilers gesetzt werden, sodass zum
Erzeugen unterschiedlicher Varianten nicht einmal der Inhalt einer Datei geändert
werden muss.

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.

Im Zusammenhang mit bedingter Kompilierung gibt es die folgenden Steueran-


weisungen:

Anweisung Bedeutung

# define Setzen eines Schalters

# undef Rücksetzen eines Schalters

# if Fallunterscheidung aufgrund eines konstanten Ausdrucks (0 oder ‡0)

# ifdef Fallunterscheidung aufgrund eines gesetzten Compile-Schalters


18
# ifndef Fallunterscheidung aufgrund eines nicht gesetzten Compile-
Schalters

# else Alternative bei if, ifdef oder ifndef

# elif Alternative mit erneuter if-Bedingung

# endif Ende der Fallunterscheidung

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

Es gibt weitere Möglichkeiten, den Compiler über spezielle Präprozessoranweisun-


gen zu steuern, die hier aber nicht im Detail diskutiert werden:

Anweisung Bedeutung

# line Festlegung einer Zeilennummer

# error Abbruch des Compiler-Laufs mit einer Fehlermeldung

# pragma spezifische Anweisung an den Compiler

Tabelle 18.8 Weitere Präprozessoranweisungen

const
Mit dem Zusatz const werden Daten als konstant, also unveränderlich, definiert:

const int x = 4711;


const double d = 1.234;

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

Konstanten bieten keine zusätzliche Programmierfunktionalität, richtig eingesetzt,


schützen sie aber vor missbräuchlicher Verwendung von Daten.

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.

Die C Standard Library wird in Kapitel 10 auszugsweise diskutiert. Eine vollständige


Diskussion dieser Library würde den Rahmen dieses Buches sprengen.

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.

Es gibt die folgenden Öffnungsmodi:

Modus Bedeutung Wenn die Datei Wenn die Datei Schreib-/


existiert nicht existiert Lesezeiger

r öffnet die Datei Öffnen Fehler Dateianfang


zum Lesen

w öffnet die Datei leere Datei leere Datei Dateianfang


zum Schreiben erzeugen erzeugen

a öffnet die Datei Datei öffnen leere Datei Dateiende


zum Anfügen erzeugen

Tabelle 18.9 Öffnungsmodi für Dateien

603
18 Zusammenfassung und Ergänzung

Modus Bedeutung Wenn die Datei Wenn die Datei Schreib-/


existiert nicht existiert Lesezeiger

r+ öffnet die Datei Datei öffnen Fehler Dateianfang


zum Lesen und
Schreiben

w+ öffnet die Datei leere Datei leere Datei Dateianfang


zum Lesen und erzeugen erzeugen
Schreiben

a+ öffnet die Datei Datei öffnen leere Datei Dateiende


zum Anfügen erzeugen
mit Lese- und
Schreibzugriff

wx öffnet die Datei leere Datei Fehler Dateianfang


zum Schreiben erzeugen

w+x öffnet die Datei leere Datei Fehler Dateianfang


zum Lesen und erzeugen
Schreiben

Tabelle 18.9 Öffnungsmodi für Dateien (Forts.)

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;

meinedatei = fopen( "test.txt", "w");


Datei erstellen
E Öffnen (Modus w)
if( !meinedatei)
E Test auf Erfolg
return;
E Daten schreiben
for( i = 0; i < 10; i++)
E Schließen
fprintf( meinedatei, "%c: %d\n", 'a'+ i, i);
fclose( meinedatei);

meinedatei = fopen( "test.txt", "r");


Datei einlesen if( !meinedatei)
E Öffnen (Modus r)
return;
E Test auf Erfolg
for( ; ; )
E Daten lesen
{
E Test auf Dateiende
fscanf( meinedatei, "%c: %d\n", &c, &i);
E Schließen
if( feof( meinedatei))
break; a: 0
printf( "%c: %d\n", c, i); b: 1
} c: 2
d: 3
fclose( meinedatei); e: 4
f: 5
g: 6
h: 7
i: 8

Abbildung 18.20 Öffnen, Auslesen und Ausgeben einer Datei

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.

Hier finden Sie eine Auswahl wichtiger Funktionen für Dateioperationen:

Funktionsname Beschreibung

fclose Schließen einer zuvor mit fopen geöffneten Datei

feof Test auf Dateiende (EOF)

fgetc Lesen eines Zeichens aus einer Datei

fgets Lesen eines Strings (ohne Leerzeichen) aus einer Datei

fopen Öffnen einer Datei

fprintf formatierte Ausgabe in eine Datei

Tabelle 18.10 Einige wichtige Funktionen für Dateioperationen

605
18 Zusammenfassung und Ergänzung

Funktionsname Beschreibung

fputc Ausgabe eines einzelnen Zeichens in eine Datei

fputs Ausgabe eines Strings in eine Datei

fread Lesen einer bestimmten Anzahl von Bytes aus einer Datei

fscanf formatiertes Einlesen aus einer Datei

fseek Position des Schreib-/Lesezeigers ermitteln

ftell Position des Schreib-/Lesezeigers setzen

fwrite Schreiben einer bestimmten Anzahl von Bytes in eine Datei

rewind Rücksetzen des Schreib-/Lesezeigers auf den Dateianfang

Tabelle 18.10 Einige wichtige Funktionen für Dateioperationen (Forts.)

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.

Datentypen für ganze Zahlen


Für ganze Zahlen gibt es die Typen char, short, int, long und long long. Diese Typen
können jeweils vorzeichenlos (unsigned) oder vorzeichenbehaftet (signed) sein.
Damit ergeben sich folgende Möglichkeiten zur Festlegung eines Datentyps für
ganze Zahlen:

Syntaxgraph char

signed short

int

unsigned long long

Abbildung 18.21 Datentypdiagramm für ganze Zahlen

Der Datentyp char (bzw. unsigned char) wird auch für Zeichen (besser Zeichencodes)
verwendet.

606
Datentypen für Gleitkommazahlen

Die verschiedenen Typen unterscheiden sich hinsichtlich ihres Platzbedarfs im


Speicher:

Datentyp Mindestgröße Typische Größe

char 1 Byte 1 Byte

short 2 Bytes 2 Bytes

int 2 Bytes 4 Bytes

long 4 Bytes 4 Bytes

long long 8 Bytes 8 Bytes

Tabelle 18.11 Speicherbedarf der Datentypen

und folglich auch hinsichtlich des Zahlenbereichs, den sie abdecken:

signed unsigned

Größe min max min max

1 Byte –128 127 0 255

2 Bytes –32768 32767 0 65535

4 Bytes –2147483648 2147483647 0 4294967295


18
8 Bytes –9,2234E+18 9,2234E+18 0 1,84467E+19

Tabelle 18.12 Zahlenbereiche der Datentypen

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.

Datentypen für Gleitkommazahlen


Für Gleitkommazahlen gibt es die Datentypen float, double und long double.

Diese Typen unterscheiden sich hinsichtlich ihres Speicherplatzbedarfs, hinsichtlich


des Zahlenbereichs, den sie abdecken, und hinsichtlich der Genauigkeit, mit der sie
ihren Zahlenbereich abdecken.

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.

Datentypen für Zeichen


Für einzelne Zeichen wird der ASCII-Code des Zeichens in einem Datum des Typs char
bzw. unsigned char gespeichert. Da es sich bei dem Zeichencode um eine Zahl han-
delt, kann mit Zeichen gerechnet werden wie mit kleinen ganzen Zahlen. Rechner-
intern besteht kein Unterschied zwischen einem Zeichen und einer Zahl.

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

Bei der Aggregation sind beliebige Schachtelungen möglich, z. B.:

왘 Array in Array, Struct oder Union


왘 Struct in Array, Struct oder Union
왘 Union in Array, Struct oder Union

Durch Zeiger können beliebige Querverweise zwischen Datenstrukturen hergestellt


werden. Über solche Querverweise können dann »verkettete« Datenstrukturen wie
Listen oder Bäume aufgebaut werden.

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 direkten Zugriff


왘 den indizierten Zugriff
왘 den indirekten Zugriff

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;
};

struct typ1 var1;


struct typ2 var2;

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;
};

struct typ var;


struct typ *ptr;

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.

Die hier einzeln dargestellten Zugriffstechniken können natürlich auch in Kombina-


tion auftreten:

struct typ1
{
int a;
float b[20];
};

struct typ2
{
struct typ1 daten[10][5];
};

struct typ2 var;


struct typ2 *ptr;

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.

Deklarationen und Definitionen


Wenn in C ein Objekt (Datum oder Funktion) angelegt wird, sprechen wir von einer
Definition.

Im folgenden Codefragment werden eine Variable x und eine Funktion test


definiert:

611
18 Zusammenfassung und Ergänzung

int x;

void test( int a)


{
...
}

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.

Im folgenden Codefragment werden eine Variable x und eine Funktion test


deklariert:

extern int x;

extern void test( int a);

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

Obwohl Deklarationen überall im Code stehen können, stehen sie typischerweise in


Header-Dateien, da sie dann einheitlich von einer zentralen Stelle aus überall genutzt
werden können.

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:

Schleife ohne Initialisierung


int i = 1; und Inkrement

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

Abbildung 18.22 Schleifenablauf bei der do-Schleife

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

Schleife wird keinmal durchlaufen


int i = 1; int i = 1;

do for( ; i < 0; )
{ {
printf( "%d\n", i); printf( "%d\n", i);
i++; i++;
} while( i < 0); }
1

Schleife wird einmal durchlaufen

Abbildung 18.23 Gegenüberstellung von fuß- und kopfgesteuerter Schleife

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.

Mehr darüber erfahren Sie im Abschnitt »Datentypen für Gleitkommazahlen«.

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;

scanf( "(%x,%x)", &a, &b);

614
enum (Aufzählungstypen)

erwartet z. B. die Eingabe von zwei Ganzzahlen in Hexadezimalschreibweise durch


ein Komma getrennt und in Klammern eingeschlossen. Zusätzliche Leerzeichen sind
dabei möglich. Eine gültige Eingabe wäre in diesem Fall:(1a, b2). Solche Eingabeauf-
forderungen findet man allerdings sehr selten, da sie sehr restriktiv sind. In der Regel
stehen im Formatstring nur die Formatanweisungen und kein zusätzlicher Text. Bei
einer Eingabe aus einer Datei mit fscanf kann es aber sehr sinnvoll sein, in der For-
matanweisung zusätzlichen Text zu verwenden, um strukturierende Zeichen wie
Komma, Semikolon oder Doppelpunkt bei der Eingabe zu filtern. Wichtig ist, dass die
Eingabefunktion aus dem Eingabestrom immer so viele Zeichen liest, wie sie benö-
tigt, um alle Parameter entsprechend der Formatanweisungen zu sättigen. Fehlen
noch Zeichen, wird die Eingabe noch nicht abgeschlossen. Bleibt Text, den der Benut-
zer zu viel eingegeben hat, übrig, wird dieser bei der nächsten Eingabe gelesen. Dies
führt manchmal zu einem unerwarteten Verhalten, das dann als Programmfehler
interpretiert wird, obwohl die Eingabefunktion nur konsequent der Formatvorgabe
folgt.

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.

Einen Aufzählungstyp führen Sie z. B. wie folgt ein:

enum wochentag { Montag, Dienstag, Mittwoch, Donnerstag, Freitag,


Samstag, Sonntag};

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:

enum wochentag geburtstag;


geburtstag = Freitag;

if( geburtstag == Sonntag)


{
// Ausschlafen
}

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:

enum wochentag { Montag=1, Dienstag=2, Mittwoch=3, Donnerstag=4,


Freitag=5, Samstag=6, Sonntag=7};

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)

\f Seitenvorschub (Form Feed)

\n Zeilenvorschub (Line Feed)

\r Wagenrücklauf (Carriage Return)

\t horizontaler Tabulator

Tabelle 18.13 Escape-Sequenzen

616
extern

Sequenz Bedeutung

\v vertikaler Tabulator

\' einfaches Hochkomma

\" Anführungszeichen

\? Fragezeichen

\\ Backslash

Tabelle 18.13 Escape-Sequenzen (Forts.)

Beliebige Zeichen können auch durch Angabe ihres ASCII-Zeichencodes in Oktal-


oder Hexadezimaldarstellung beschrieben werden.

Bei einem Oktalcode schreibt man einfach eine ein- bis dreistellige Oktalzahl in die
Escape-Sequenz:

\101 ein A
\12 ein Zeilenvorschub
\134 ein Backslash

Einem ein- oder zweistelligem Hexadezimalcode wird ein x vorangestellt:

\x41 ein A
\xa ein Zeilenvorschub 18
\x5c ein Backslash

Escape-Sequenzen gibt es nur im Quellcode und nicht im ausführbaren Programm.


Sie werden durch den Compiler aufgelöst und durch den entsprechenden Binärcode
ersetzt. Insofern belegen sie im Rechner auch nur ein Byte und nicht die Anzahl an
Bytes, die die Sequenz im Quellcode hat.

In diesem Sinne liefert der Aufruf von


x = strlen( "\n");
den Wert x = 1, da die Zeichenkette "\n" nur ein einzelnes Zeichen, nämlich ein Line-
feed-Zeichen, enthält.

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.

Lesen Sie dazu auch den Abschnitt »Datentypen für Gleitkommazahlen«.

for
Bei einer for-Anweisung handelt es sich um die wesentliche Kontrollstruktur zur
Implementierung von Schleifen. Wir unterscheiden:

왘 Schleifenkopf
왘 Schleifenkörper

Der Schleifenkopf ist untergliedert in:

왘 Initialisierung
왘 Test
왘 Inkrement

Der Schleifenkörper wird durch geschweifte Klammern eingefasst, allerdings können


diese Klammern auch fehlen, wenn der Schleifenkörper nur aus einer einzigen
Anweisung besteht. Die Anweisungen im Schleifenkopf steuern die Schleife in der
folgenden Weise:

Der Test wird vor jedem möglichen Eintritt in den Schleifenkörper


ausgewertet. Ergibt sich dabei ein Wert ≠ 0, so wird der Schleifenkörper
ausgeführt. Andernfalls wird die Bearbeitung der Schleife abgebrochen.

Das Inkrement wird immer nach dem Verlassen


Die Initialisierung wird vor dem Eintritt
und vor einem möglichen Wiedereintritt in den
in die Schleife einmal ausgeführt.
Schleifenkörper ausgeführt.

for( ... ; ... ; ... )


{
Initialisierung, Test und Inkrement ...
... Bei einer continue-Anweisung wird
bezeichnen wir als den Schleifenkopf.
der derzeitige Schleifendurchlauf
if( ... ) abgebrochen, die Schleifenbearbeitung
continue; insgesamt aber fortgesetzt.
...
Der Schleifenkörper wird bei jedem ...
Schleifendurchlauf ausgeführt. Besteht Bei einer break-Anweisung wird die
if( ... )
der Schleifenkörper nur aus einer Bearbeitung der Schleife abgebrochen.
einzelnen Anweisung, so können die break;
geschweiften Klammern weggelassen ...
werden. ...
}

Abbildung 18.24 Elemente der for-Schleife

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

Abbildung 18.25 Schnittstelle und Implementierung einer Funktion

Weitere Informationen zu Funktionen finden Sie unter den Begriffen Funktionsauf-


ruf, Funktionsprototyp, Funktionsschnittstelle und 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.

Das Funktionsergebnis muss nicht entgegengenommen und zugewiesen werden.


Wenn Sie am zurückgegebenen Funktionswert nicht interessiert sind, können Sie die
oben dargestellte Funktion auch in der folgenden Weise aufrufen:
meinefunktion( a, b);
Sie können den zurückgegebenen Funktionswert aber auch direkt in einer Formel
oder als Parameter für einen weiteren Funktionsaufruf verwenden:
printf( "%c", meinefunktion( a, b)+1);

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

Abbildung 18.26 Schnittstelle und Implementierung einer Funktion

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.

In der Funktionsimplementierung können alle Daten- und Kontrollstrukturen ver-


wendet werden. Eine besondere Bedeutung hat dabei die return-Anweisung, die
unter einem eigenen Stichwort behandelt wird.

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:

erster Parametertyp: char * (der String)


zweiter Parametertyp: char (das Zeichen)

Rückgabetyp: int (die Anzahl der Vorkommnisse)

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);

C float (*fz3)( char*, 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).

Streng formal müsste es in dem in der Abbildung dargestellten Beispiel


funktionszeiger = &potenz

und
z = (*funktionszeiger)( 1.2, 2)

heißen, aber Adress- und Dereferenzierungsoperator können weggelassen werden,


da für den Compiler aus dem Zusammenhang eindeutig klar ist, welche Operationen
hier gemeint sind.

622
Funktionszeiger

float potenz( float x, int n)


{ eine Funktion, die y = xn berechnet
float y = 1;
Schnittstelle:
for( ; n; n--) float potenz( float, int)
y *= x;
return y; ein Zeiger auf eine beliebige
} Funktion mit der Schnittstelle:
float...( float, int)
void main()
{
float (*funktionszeiger)( float, int);
float z;
Dem Zeiger wird die
funktionszeiger = potenz; Funktionsadresse zugewiesen.

z = funktionszeiger( 1.2, 2); Die Funktion wird über den


} Funktionszeiger aufgerufen.
Funktionsergebnis: z = 1.44

Abbildung 18.27 Verwendung eines Funktionszeigers

Besonders häufig werden Funktionszeiger als Funktionsparameter verwendet, um


einer Funktion mitzuteilen, dass sie eine andere Funktion rufen soll. Man nennt dies
einen Callback, und die als Parameter übergebene Funktion bezeichnet man als Call-
back-Funktion.

float potenz( float x, int n)


{
float y = 1;
eine Funktion, die y = xn berechnet
for( ; n; n--) 18
y *= x; Schnittstelle:
return y; float potenz( float, int)
}

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

Abbildung 18.28 Verwendung eines Funktionszeigers für eine Callback-Funktion

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.

Die Suffixe für Größe und Vorzeichenlosigkeit können in beliebiger Reihenfolge


angehängt werden.

Damit ergibt sich ein breites Spektrum möglicher Darstellungen. Zum Beispiel:

123
–123
0123
0xAffe

123L
–123UL
0123lu
0x1ae7LL
0xEdel

Abbildung 18.29 zeigt einige Möglichkeiten, ganze Zahlen zu bilden.

Die hier vorgestellten Darstellungen sind rein äußerliche Darstellungen im Quell-


code. Im Rechner selbst gibt es nur die Binärdarstellung.

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

Abbildung 18.29 Alle Möglichkeiten, ganze Zahlen zu bilden

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

Die hier vorgestellten Darstellungen sind rein äußerliche Darstellungen im Quell-


code. Im Rechner selbst gibt es nur die Binärdarstellung.

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++;

if( i < 10) Kommt die goto-Anweisung zur


goto weiter; Ausführung, wird zum angegebenen
Label gesprungen.
printf( "Ende\n");

Abbildung 18.30 Verwendung der goto-Anweisung

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.

Normalerweise sollten Sprunganweisungen in einem C-Programm nicht vorkom-


men, zumal sie prinzipiell vermeidbar sind. Ein Sprung kann sinnvoll sein, um bei
einer massiven Abbruchbedingung aus einer tief verschachtelten Schleifenstruktur
auszusteigen, da ein break in einer solchen Situation immer nur die innerste Schleife
beendet. Dabei besteht aber schon die Gefahr, dass wichtige Aufräumarbeiten am
jeweiligen Schleifenende übergangen werden.

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.

Mehr dazu erfahren Sie unter dem Stichwort »main«.

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.

Nicht »raumgreifende« Elemente sind:

왘 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.

Bei der Verwendung von Funktionsbibliotheken spielen Header-Dateien eine ent-


scheidende Rolle. Möchten Sie eine Funktion aus einer Funktionsbibliothek aufru-
fen, müssen Sie in der zugehörigen Dokumentation nachschlagen, welche Header-
Dateien inkludiert werden müssen. Diese Includes stellen Sie dann an den Anfang
jeder Quellcodedatei, in der die Funktion aufgerufen wird.

Kontrollstrukturen
Zur Steuerung des Kontrollflusses gibt es in C:

왘 Fallunterscheidungen
왘 Schleifen
왘 die goto-Anweisung

Fallunterscheidungen können mit if, if-else oder switch realisiert werden:

if(...) if(...) switch(...)


{ { {
... ... case ...:
... ... ...
} } break;
else case ...:
{ case ...:
... ...
... break;
} default:
...
break;
}

Abbildung 18.31 Die Struktur von if, if-else und switch

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

for (...;...;...) while (...) do


{ { {
... ... ...
if (...) if(...) if(...)
break; break; break;
... ... ...
if (...) if(...) if(...)
continue; continue; continue;
... ... ...
} } } while(...);

Abbildung 18.32 Die Struktur von for, while und do-while

label:
...
...
...
if(...)
goto label;

Abbildung 18.33 Die Struktur von goto

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)

Identifier müssen von Zahlen, Zeichen, Zeichenketten oder Operatoren unterscheid-


bar sein und beginnen daher immer mit einem Buchstaben (a–z, A–Z) oder einem
Unterstrich (_). Danach können beliebige Buchstaben, Ziffern und Unterstriche fol-

629
18 Zusammenfassung und Ergänzung

gen. Insbesondere enthalten Identifier keine Leerzeichen oder Sonderzeichen wie +,


– oder =. Schlüsselwörter wie if oder for können ebenfalls nicht als Identifier ver-
wendet werden.

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.

Hier steht eine Bedingung


(zumeist ein Vergleichsausdruck).

if ( ... ) Die hier stehenden Anweisungen


{ werden ausgeführt, wenn die
Handelt es sich hier um eine einzelne ... Bedingung erfüllt ist.
Anweisung, können die geschweiften
...
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.

Abbildung 18.34 Die Struktur der if-else-Anweisung im Detail

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
...;
}

Abbildung 18.35 Zuordnung der else-Anweisung

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

Lesefluss des Compilers

// Quellcodedatei


# include "header1.h


… // header1.h
… …
… …
… # include "header2.h
… …


// header2.h


Abbildung 18.36 Verwendung der include-Anweisung

Ob der Dateiname in spitze Klammern (<...>) oder Anführungszeichen ("...") gesetzt


wird, beeinflusst die Strategie, mit der die Datei gesucht wird. Im ersten Fall wird die
Datei in bestimmten, vordefinierten Systemverzeichnissen, im zweiten Fall in Ihrem
Projektverzeichnis gesucht. Eine Include-Anweisung verwendet man nur für
Dateien, die keinen »raumgreifenden« Code enthalten. Dies sind die mit der Dateina-
menserweiterung .h versehenen Header-Dateien (siehe Abschnitt »Header-Datei)«.
Der Dateiname kann einen relativen

# include ".../test/include/abc.h"

oder einen absoluten

# include "C:/uvw/xyz/abc.h"

Zugriffspfad enthalten. Dabei müssen Sie die betriebsystemspezifischen Konventio-


nen beachten. Gegebenenfalls müssen Escape-Sequenzen (siehe Abschnitt »Escape-
Sequenzen«) verwendet werden, um spezielle Zeichen (etwa \) im Dateipfad angeben
zu können.

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«.

Logische Operatoren ( !, &&, ||)


Logische Operatoren berechnen aussagenlogische Verknüpfungen mit nicht (!) und 18
(&&) und oder (||):

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

! !x logische logischer Operator R 14


Verneinung

&& x && y logisches Und logischer Operator L 5

|| x || y logisches Oder logischer Operator L 4

Tabelle 18.14 Logische Operatoren

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.

Die drei logischen Operatoren können durch Wahrheitstafeln definiert werden:

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:

Der Formelteil rechts von || wird nicht


ausgewertet, da die Formel wegen a = 1
bereits als »wahr« erkannt ist.
int a = 1;
int b = 0; Der Formelteil rechts von && wird nicht
ausgewertet, da die Formel wegen !a = 0
a || b++; bereits als »falsch« erkannt ist.
!a && b++;
b ist unverändert.
printf( "b = %d\n", b);

b++ || a; Hier wird dagegen b


b++ && !a; zweimal inkrementiert

printf( "b = %d\n", b); b = 0


b = 2

Abbildung 18.38 Auswertung logischer Operatoren

634
main

Also: Vorsicht bei Seiteneffekten in logischen Ausdrücken!

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.

Mehr darüber erfahren Sie im Abschnitt »Datentypen für Gleitkommazahlen«.

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

int main(int argc, char* argv[])


{
int i;
Anzahl der Parameter Array mit Parameterstrings

printf( "Anzahl Argumente %d\n", argc);


for( i = 0; i < argc; i++)
printf( "%s\n", argv[i]); Anzahl Argumente 4
meinprogramm
return 0; eins
} zwei
drei

Rückgabewert 0 bedeutet Erfolg, andere


Rückgabewerte sind systemspezifisch.

Abbildung 18.39 Die Standardschnittstelle von main

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.

Makros zur Berechnung von Quadrat


und Summe.
# define QUADRAT( a) a*a
# define SUMME( a, b) a+b x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
void main() x = 1+2;
{ Also: x = 3
int x, y, z;
y = QUADRAT( x);
x = SUMME( 1, 2); wird vom Präprozessor übersetzt zu
y = QUADRAT( x); y = x*x;
z = SUMME( QUADRAT(2), QUADRAT(3)); Also: y = 9
}
z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = 2*2+3*3;
Also: z = 13

Abbildung 18.40 Textersetzung durch Makros

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

Makros zur Berechnung von Quadrat


und Summe.
# define QUADRAT( a) a*a
# define SUMME( a, b) a+b x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
void main() x = 1+1*1+1;
{ Also: x = 3
int x, y, z;
int a = 1; y = QUADRAT( x);
wird vom Präprozessor übersetzt zu
x = QUADRAT( 1+1); y = 2*1+1;
y = 2*SUMME( 1, 1); Also: y = 3
z = QUADRAT( ++a);
} z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = ++a*++a;
Also: a = 3 und z = 9

Abbildung 18.41 Unerwünschte Nebeneffekte bei der Textersetzung

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:

Makros zur Berechnung von Quadrat und


Summe mit vollständiger Klammerung
# define QUADRAT( a) ((a)*(a)) 18
# define SUMME( a, b) ((a)+(b)) x = SUMME( 1, 2);
wird vom Präprozessor übersetzt zu
void main() x = ((1+1)*(1+1));
{ Also: x = 4
int x, y, z;
int a = 1; y = QUADRAT( x);
wird vom Präprozessor übersetzt zu
x = QUADRAT( 1+1); y = 2*((1)+(1));
y = 2*SUMME( 1, 1); Also: y = 4
z = QUADRAT( ++a);
} z = SUMME( QUADRAT(2), QUADRAT(3));
wird vom Präprozessor übersetzt zu
z = ((++a)*(++a));
Also: a = 3 und z = 9
Das Ergebnis ist nach wie vor unerwünscht.

Abbildung 18.42 Weitere Nebeneffekte bei der Verwendung von Makros

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

In Verbindung mit Makroparametern können spezielle Operatoren verwendet wer-


den. Der Operator # setzt Anführungszeichen um einen Ausdruck, und der Operator
## verschmilzt zwei Ausdrücke zu einem. Mit diesen Operatoren können Sie erstaun-
liche Effekte erzeugen, die Sie aber in der Regel nicht benötigen.

Makros mit Anführungszeichen


und Verschmelzung.
# define STRING( a) #a
# define VERSCHMELZUNG( a, b) #a ## #b

void main()
{
char *s, *q;

s = STRING( Dies ist ein Beispiel-String);


q = VERSCHMELZUNG( Dies ist ein Beispiel, -String);
}
In beiden Fällen ergibt sich:
"Dies ist ein Beispiel-String"

Abbildung 18.43 Verwendung von Makroparametern und Verschmelzung

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:

# define PRINTF( format, ...) printf( "\nAusgabe: "##format, __VA_ARGS__)

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.

Weitere Informationen dazu finden Sie im Abschnitt »Arithmetische Operatoren (+,


–, *, /, %)«.

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

und anstelle eines Funktionsnamens ein Operatorzeichen:


z = x + y

Wie Funktionen können Operatoren eine unterschiedliche Zahl an Argumenten


(Operanden) haben. Üblich sind einstellige (z. B. –x) und zweistellige Operatoren (z. B.
x+y). Die Zahl der Operanden, die ein Operator benötigt, bezeichnet man als die Stel-
ligkeit des Operators. Im Wesentlichen haben wir es mit ein- und zweistelligen Ope-
ratoren zu tun4. Bei mehrstelligen Operatoren schreiben Sie das Operatorzeichen
zwischen die Operanden (Infixnotation). Bei einstelligen Operatoren können Sie es 18
voranstellen (Präfixnotation) oder hinten anfügen (Postfixnotation).

Komplexere Formeln entsprechen ineinander geschachtelten Funktionsaufrufen.

mal( a, plus(b,c)) a*(b+c)


plus( mal(a,b), c) (a*b)+c
Abbildung 18.44 Ersatz von Operatoren durch ineinander geschachtelte Funktionsaufrufe

Bei der Auflösung der Funktionsaufrufe in die Operatorschreibweise entstehen


Klammern, die Sie nicht ohne Weiteres weglassen können. Um Klammern zu sparen,
arbeiten Sie mit Prioritäten. Wenn Sie festlegen, dass * eine höhere Priorität hat als +,
können Sie anstelle von (a*b) + c vereinfachend a*b+c schreiben. Bei a*(b+c) sind die
Klammern aber nach wie vor erforderlich.

Bei Operatoren gleicher Priorität sind Sie jedoch bei vielen Operatoren nach wie vor
auf Klammern angewiesen.

4 Mit der bedingten Auswertung gibt es in C auch einen dreistelligen Operator.

639
18 Zusammenfassung und Ergänzung

div( a, div(b,c)) a/(b/c)


div( div(a,b), c) (a/b)/c
Abbildung 18.45 Ersatz von Operatoren gleicher Priorität

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

Wenn Sie die – trotz dieser Rahmenbedingungen – noch verbleibenden Freiheiten


ausnutzen, kommen Sie zu einer reichhaltigen Formelsprache und zu Formelausdrü-
cken, die nicht immer leicht zu verstehen sind. Immerhin hat C fast 50 Operatoren
unterschiedlicher Stelligkeit, Priorität und Assoziativität. Manche Programmierer
treiben es auf die Spitze und machen sich einen Sport daraus, möglichst umfassende,
klammerfreie, bis zur Unleserlichkeit optimierte Formeln zu programmieren.
Gerade als Anfänger sollten Sie diese Optimierung aber lieber dem Compiler überlas-
sen. Berechnen Sie komplexe Formeln in Teilschritten mit Speicherung von Zwi-
schenergebnissen, und setzen Sie, um die beabsichtigte Auswertung zu verdeutli-
chen gelegentlich auch überflüssige Klammern.

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:

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

() f (x, y) Funktionsaufruf Auswertungs- L 15


operator

[] a [i] Array-Zugriff Zugriffsoperator

-> p->x Indirektzugriff

. a.x Strukturzugriff

++ x++ Post-Inkrement Zuweisungs


operator
-- x-- Post-Dekrement

! !x logische logischer Operator R 14


Verneinung

~ ~x bitweises Bitoperator
Komplement

++ ++x Pre-Inkrement Zuweisungs-


operator
-- --x Pre-Dekrement

+ +x plus x arithmetischer
Operator
- -x minus x 18

* *p Dereferenzierung Zugriffsoperator

& &x Adress-Operator

() (type) Typkonvertierung Datentyp-


Operator
sizeof sizeof (x) Typspeichergröße

new new class Objekt allokieren

delete delete a Objekt deallokieren

* x*y Multiplikation arithmetischer L 13


Operator
/ x/y Division

% x%y Rest bei Division

+ x+y Addition arithmetischer L 12


Operator
- x-y Subtraktion

Tabelle 18.15 Operatoren in C

641
18 Zusammenfassung und Ergänzung

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

<< x<<y Bitshift links Bitoperator L 11

>> x>>y Bitshift rechts

< x<y kleiner als Vergleichs- L 10


operator
<= x<=y kleiner oder gleich

> x>y größer als

>= x>=y größer oder gleich

== x==y gleich Vergleichs- L 9


operator
!= x!=y ungleich

& x&y bitweises Und Bitoperator L 8

^ x^y bitweises Bitoperator L 7


Entweder-Oder

| x|y bitweises Oder Bitoperator L 6

&& x && y logisches Und logischer Operator L 5

|| x || y logisches Oder logischer Operator L 4

?: x?y:z bedingte Auswertung Auswertungs- L 3


operator

= x=y Wertzuweisung Zuweisungs- R 2


operator
+= x+=y Operation mit
anschließender
-= x-=y
Zuweisung
*= x*=y

/= x/=y

%= x%=y

&= x&=y

^= x^=y

|= x|=y

<<= x<<=y

>>= x>>=y

Tabelle 18.15 Operatoren in C (Forts.)

642
Operatoren

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

, x,y sequenzielle Auswertungs- L 1


Auswertung operator

Tabelle 18.15 Operatoren in C (Forts.)

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.

Bezüglich der Assoziativität gibt es zwei Möglichkeiten:

왘 Linksassoziativität (L in der Tabelle) bedeutet, dass die Auswertung bei gleicher


Priorität von links nach rechts erfolgt.
왘 Rechtsassoziativität (R in der Tabelle) bedeutet, dass die Auswertung bei gleicher
Priorität von rechts nach links erfolgt.

Ein so umfangreiches Modell an Operatoren sinnvoll zu balancieren ist nicht einfach.


Und in der Tat sind nicht alle Design-Entscheidungen plausibel. Zum Beispiel hat der
Vergleichsoperator == eine höhere Priorität als der logische Operator &&. Das bedeu-
tet, dass der Ausdruck

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)

erzwingen können. Um vor unangenehmen Überraschungen geschützt zu sein, soll-


ten Sie deshalb immer, wenn Sie sich Ihrer Sache nicht ganz sicher sind, Klammern
setzen. Setzen Sie lieber ein überflüssiges Klammernpaar, als ein wesentliches irr-
tümlich zu vergessen.

Weitere Informationen zur Bedeutung und Verwendung einzelner Operatoren fin-


den Sie in dieser Kurzreferenz zu den in der Tabelle in der Spalte »Klassifizierung«
genannten Sammelbegriffen:

643
18 Zusammenfassung und Ergänzung

왘 arithmetische Operatoren
왘 Bitoperatoren
왘 logische Operatoren
왘 Vergleichsoperatoren
왘 Zugriffsoperatoren
왘 Zuweisungsoperatoren

Die hier unter den Begriffen Auswertungsoperator und Datentyp-Operator zusam-


mengefassten Operatoren sind sehr uneinheitlich und haben daher jeweils einen
eigenen Stichworteintrag. Dabei handelt es sich um folgende Stichwörter:

왘 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.

Präprozessor-Direktiven beginnen mit dem Zeichen # am Zeilenanfang. Sie sind in


der Regel einzeilig und können an beliebiger Stelle im Quellcode eingestreut werden.
Soll sich eine Präprozessor-Direktive über mehrere Zeilen erstrecken, können durch
\ am Zeilenende Fortsetzungszeilen angefügt werden.

Inhaltlich unterscheiden wir vier verschiedene Arten von Direktiven:

왘 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

Typischerweise enthält eine Quellcodedatei alle Variablen und Funktionen eines


bestimmten Themenkomplexes und darüber hinaus alle erforderlichen Deklaratio-
nen, die aber nicht außerhalb der Datei bekannt sein müssen. Deklarationen, die 18
auch außerhalb der Quellcodedatei bekannt sein müssen, stehen in einer Header-
Datei (siehe Abschnitt »Header-Datei«).

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

Gut optimierende Compiler brauchen solche Hilfestellungen durch den Program-


mierer heute nicht mehr.

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;

Ist noch etwas zu tun? if( von < bis)


{
Tausche den ersten und den letzten Wert im Array. t = daten[von];
daten[von] = daten[bis];
daten[bis] = t;
Kehre die Reihenfolge der Zahlen im kleineren Array um. reverse( von+1, bis-1, daten);
}
}

void main()
{
int zahlen[10] = {0,1,2,3,4,5,6,7,8,9};

reverse( 0, 9, zahlen);
}

Abbildung 18.46 Verwendung von Rekursion

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.

Bevor Funktionen in einer Laufzeitbibliothek allgemein zur Verfügung gestellt wer-


den, wird daher häufig die Rekursion entfernt.

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:

Diese Funktion muss int zurückgeben.

int funktion()
{ 18
int x; Rücksprung mit Wert 0
...
if( ...)
return 0;
...
return x+1;
}
Rücksprung mit Ausdruck

Abbildung 18.47 Funktion mit Returntyp

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

Wenn ein Unterprogramm keinen Returnwert hat (void-Funktion), muss es nicht


unbedingt eine explizite return-Anweisung geben. Wenn es allerdings eine solche
Anweisung gibt, darf sie keinen Returnwert haben:

Diese Funktion hat keinen Returnwert.

void funktion()
{
... Expliziter Rücksprung
if( ...)
return;
...
...
}
Impliziter Rücksprung

Abbildung 18.48 Funktion ohne Returnwert

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.

In C sind folgende Wörter reserviert:

auto double int struct

break else long switch

case enum register typedef

char extern return union

const float short unsigned

continue for signed void

Tabelle 18.16 Reservierte Schlüsselwörter in C

648
Sequenzielle Auswertung (Komma-Operator)

default goto sizeof volatile

do if static while

Tabelle 18.16 Reservierte Schlüsselwörter in C (Forts.)

Erläuterungen zu jedem dieser Schlüsselwörter finden Sie unter dem entsprechen-


den Stichwort in diesem Kapitel.

Sequenzielle Auswertung (Komma-Operator)


Der Komma-Operator für die sequenzielle Auswertung erlaubt es, mehrere Ausdrü-
cke durch Komma getrennt hintereinanderzuschreiben. Die Ausdrücke werden von
links nach rechts ausgewertet, und das Ergebnis ist der Wert des zuletzt ausgewerte-
ten Ausdrucks. In der Regel wird das Ergebnis nicht zugewiesen oder verwendet. Zum
Beispiel verwendet man den Komma-Operator häufig bei der Initialisierung oder im
Inkrement von Schleifen:

for( i = 0, k= 1; i < 100; i++, k *= 2)


{
...
}

Man kann aber auch das Ergebnis einer Sequenz zuweisen und weiterverarbeiten: 18

int ergebnis;

ergebnis = (1, 2, 3);

In diesem Beispiel wird der Variablen ergebnis der Wert des zuletzt ausgewerteten
Ausdrucks zugewiesen. Der Wert von ergebnis ist also 3.

Lässt man übrigens die Klammern weg,

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.

Mehr dazu erfahren Sie im Abschnitt »Datentypen für ganze Zahlen«.

signed
Das Schlüsselwort signed kann Integer-Datentypen vorangestellt werden, um festzu-
legen, dass es sich um einen vorzeichenbehafteten Datentyp handelt.

Da die Integer-Typen auch ohne explizite Angabe von signed vorzeichenbehaftet


sind, ist der Zusatz signed streng genommen überflüssig und wird auch nur sehr sel-
ten verwendet.

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:

int s1, s2, s2;

struct b
{
int x;
double d;
};

s1 = sizeof( int); // maschinenabhängig, aber typischerweise 4


s2 = sizeof( double) // maschinenabhängig, aber typischerweise 8
s3 = sizeof( b); // maschinenabhängig, aber typischerweise 16

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:

int daten[] = {13,21,7,4};


int anzahl = sizeof( daten)/sizeof(int); // Anzahl ist 4

Verwechseln Sie den sizeof-Operator nicht mit der strlen-Funktion für Strings.
Betrachten Sie dazu das folgende Beispiel:

char *p = "Programmierung";

printf( "%d\n", sizeof( "Programmierung"));


printf( "%d\n", sizeof( p));

printf( "%d\n", strlen( "Programmierung"));


printf( "%d\n", strlen( p));

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

malloc Allokiert eine gewünschte Anzahl von Bytes.

calloc Allokiert einen zusammenhängenden Bereich aus einer bestimmten


Anzahl von Blöcken einer bestimmten Größe. Zusätzlich wird der allo-
kierte Speicher mit 0 initialisiert.

realloc Reallokiert (vergrößert, verkleinert) einen zuvor allokierten Bereich.


Wird dabei ein neuer Bereich angelegt, werden die Daten aus dem
alten Bereich in den neuen Bereich kopiert, und der alte Bereich wird
freigegeben. Beim Kopieren in einen kleineren Bereich werden über-
schüssige Daten nicht kopiert. Beim Kopieren in einen größeren
Bereich bleiben überschüssige Daten uninitialisiert.

free Gibt den mit malloc, calloc oder realloc allokierten Speicher frei.

Tabelle 18.17 Funktionen zum Allokieren und Deallokieren von Speicher

Der Rückgabewert von malloc, calloc und realloc ist NULL, wenn die Anforderung
mangels Speichers nicht ausgeführt werden kann.

Funktionen zur Speicherallokation werden verwendet, um den Speicher für Daten-


strukturen und Arrays (insbesondere Strings) zu allokieren.

In Abbildung 18.49 sehen Sie ein Beispiel für die Allokation einer Datenstruktur mit
abschließender Freigabe:

Datenstruktur, für die Speicher


allokiert werden soll.
struct test
{
int wert1;
float wert2;
char txt[3]; Zeiger für den allokierten Speicher.
};
Typkonvertierung Benötigte Speichermenge.
struct test *ptr;

ptr = (struct test *)malloc( sizeof( struct test));

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;

printf( "Daten: %d, %f, %s\n", ptr->wert1, ptr->wert2, ptr->txt);

free( ptr);
Freigabe des Speichers. Daten: 123, 4.560000, AB

Abbildung 18.49 Allokieren und Freigeben einer Datenstruktur

652
Speicherallokation

Beispiel für die Allokation eines Arrays mit abschließender Freigabe:

Zeiger für den zu allokierten Array

int *ptr;
int i; Speicher für 10 int-Werte allokieren

Typkonvertierung

ptr = (int *)calloc(10, sizeof(int));

for( i = 0; i < 10; i++) Verwenden des Speichers


ptr[i] = i;
for( i = 0; i < 10; i++) 0
printf( "%d\n", ptr[i]); 1
2
3
free( ptr); 4
Freigabe des Speichers 5
6
7
8
9

Abbildung 18.50 Allokieren und Freigeben eines Arrays

Beispiel für die systematische Vergrößerung eines Puffers mit abschließender Frei-
gabe:

Zeiger für den zu allokierten Puffer 18

char *ptr = 0;
Aktuelle Puffergröße
int size = 0;
char c = 0; Eingabezeichen

printf( "Text: ");


for( ; c != '\n'; size++)
{
scanf( "%c", &c); Ein Zeichen einlesen
ptr = (char *)realloc( ptr, size+1); Puffer vergrößern
ptr[size] = c; Zeichen im Puffer speichern
}
ptr[size-1] = 0; Zeichenkette terminieren
printf( "Ergebnis: %s\n", ptr);
Freigabe des Speichers
free( ptr);
Text: the quick brown fox jumps over the lazy dog
Ergebnis: the quick brown fox jumps over the lazy dog

Abbildung 18.51 Allokieren und dynamisches Vergrößern des Speichers

653
18 Zusammenfassung und Ergänzung

Die Vergrößerung von Speicher erfolgt üblicherweise nicht so kleinschrittig, wie im


letzten Beispiel gezeigt, und die Freigabe von Speicher erfolgt in der Regel nicht, wie
in den Beispielen suggeriert, unmittelbar nach der ersten Verwendung. Normaler-
weise wird allokierter Speicher über einen längeren Zeitraum verwendet und erst
dann freigegeben, wenn er nicht mehr benötigt wird.

Speicherallokation in Verbindung mit Zeigern ermöglicht es, dynamisch große


Datenbestände (z. B. in Form von Listen oder Bäumen) aufzubauen und zu verwalten.
Ohne diese Technik vollständig verstanden zu haben, können Sie keine professionel-
len Programme schreiben.

Streams (stdin, stdout, stderr)


In der Laufzeitumgebung eines C-Programms gibt es drei vordefinierte Streams:

왘 stdin
왘 stdout
왘 stderr

Diese Streams werden vom Laufzeitsystem bereitgestellt, wenn das Programm


startet.

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.

Die Zuordnungen der Streams zu konkreten Ein-/Ausgabegeräten sind flexibel und


können vom Programmierer im Rahmen des technisch Möglichen geändert werden.
Zum Beispiel kann der Stream stdout auf eine zum Schreiben geöffnete Datei umge-
lenkt werden. Dazu verwendet man die Funktion freopen aus der Standardbibliothek.

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

Umlenken der Standardausgabe


in die Datei ausgabe.txt.

freopen("ausgabe.txt", "w", stdout);

printf("Test...Test...Test\n");

Abbildung 18.52 Umlenken der Standardausgabe in die Datei ausgabe.txt

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

fprintf( stdout, "Ausgabe\n");

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.

static int funktion( int x, float y)


{
...
}

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.

static int zahl1;


static double zahl2 = 1.234;

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

Zu einer Datenstruktur gehören:

왘 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

Hier kann ein Ausdruck mit ganzahligem Wert stehen.


Entsprechend dem Wert wird ein Label angesprungen.

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;
}

Abbildung 18.53 Mehrere Verzweigungen mit der switch-Anweisung

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

Abbildung 18.54 Ausgabe der switch-Anweisung

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>

festgelegt. Typischerweise stehen solche symbolischen Konstanten in Header-


Dateien und bewirken, dass der Präprozessor, nachdem er die Anweisung gelesen
hat, alle Vorkommnisse von <Name> im Quellcode durch <Wert> ersetzt.

659
18 Zusammenfassung und Ergänzung

# define ganzzahl int


# define wenn if
# define ist ==
# define plus + Symbolische
# define anfang { Konstanten.
# define ende }

ganzzahl a = 1;

wenn( a ist 1) Quellcode vor


anfang Preprocessing
a = a plus 1;
ende

int a = 1;

if( a == 1) Quellcode nach


{ Preprocessing
a = a + 1;
}

Abbildung 18.55 Verwendung symbolischer Konstanten

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.

Häufig verwendet man symbolische Konstanten, um Array-Grenzen einheitlich im


Quellcode ansprechen zu können:

# define ANZAHL 10
Die Größe des Arrays ist durch
int i; die symbolische Konstante
int daten[ANZAHL]; ANZAHL gegeben.

for( i = 0; i < ANZAHL; i++) 0


daten[i] = 2*i; 2
4
6
for( i = 0; i < ANZAHL; i++) 8
printf( "%d\n", daten[i]); 10
12
14
16
18

Abbildung 18.56 Symbolische Konstanten für Arrays

660
typedef

Es gibt fünf vordefinierte symbolische Konstanten, die jeweils mit einem doppelten
Unterstrich beginnen und enden:

Konstante Ersetzung

__FILE__ Dateiname der Quelldatei als Zeichenkette

__LINE__ aktuelle Zeilennummer als numerischer Wert

__DATE__ aktuelles Datum als Zeichenkette

__TIME__ aktuelle Zeit als Zeichenkette

__STDC__ 1, falls der Compiler ANSI-C-konform ist

Tabelle 18.18 Vordefinierte symbolische Konstanten

Mit der Konstanten __TIME__ kann ein Programm z. B. seine Compile-Zeit in den Code
einbrennen und ausgeben:

printf( "Compile-Zeit: %s\n", __TIME__);

Compile-Zeit: 09:29:56

typedef
18
Durch typedef können neue Typbezeichner definiert werden.

Durch die Anweisung

typedef int zahl;

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;

Hauptsächlich werden benutzerspezifische Typbezeichner in Verbindung mit Daten-


strukturen (struct, union) verwendet.

661
18 Zusammenfassung und Ergänzung

struct pkt
{
int x;
int y;
};

typedef struct pkt punkt;

punkt p;

p.x = 1;
p.y = 2;

Man kann Struktur und Typbezeichner auch in einem Zug einführen:

typedef struct pkt


{
int x;
int y;
} punkt;

punkt p;

p.x = 1;
p.y = 2;

Der Strukturname ist in dieser Situation überflüssig:

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.

Im folgenden Beispiel wird ein Zeitraum durch zwei verschiedene Strukturdefinitio-


nen modelliert, zum einen über Anfangs- und Enddatum (zeitraum1) und zum ande-
ren über ein Anfangsdatum und die Länge in Tagen (zeitraum2):

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;
};

Bis hier wurden nur struct-Vereinbarungen verwendet. Beide Strukturen (zeitraum1


und zeitraum2) sollen jetzt aber in einem Programm verwendet werden, wobei zu
einem Zeitpunkt immer nur eine der beiden Varianten benötigt wird. Dies können
Sie durch eine Union modellieren:

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;

unsigned int funktion( unsigned char x)


{
...
}

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

static int meinezahl = 17;

Abbildung 18.57 Elemente einer Variablen

Die Speicherklasse legt den Speicherort (Prozessorregister, Stack, Heap) und die
Lebensdauer einer Variablen fest. Es gibt die Speicherklassen:
왘 auto
왘 register
왘 static
왘 extern

Mehr zu Speicherklassen erfahren Sie unter den entsprechenden Stichwörtern.

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«.

Vergleichsoperator (<, <=, >, >=, ==, !=)


Vergleichsoperatoren dienen dazu, Zahlen – egal, ob ganze Zahlen oder Gleitkomma-
zahlen – untereinander auf gleich, ungleich, größer oder kleiner zu testen:

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

< x<y kleiner als Vergleichsoperator L 10

<= x <= y kleiner oder gleich

> x>y größer als

>= x >= y größer oder gleich

== x == y gleich Vergleichsoperator L 9

!= x != y ungleich

Tabelle 18.19 Vergleichsoperatoren

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");

Sie erhalten folgende Ausgabe:

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:

void funktion( int x);

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.

In maschinenfernen Anwendungsprogrammen kommen solche Variablen nicht vor.

6 engl. volatile = flüchtig, unbeständig, unberechenbar

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'

Bei nicht druckbaren Zeichen verwenden Sie die zugehörige Escape-Sequenz:

'A' ein A
\101 ein A
\x41 ein A

'\n' ein Zeilenvorschub


'\12' ein Zeilenvorschub
'\xa' ein Zeilenvorschub

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"

Bei Zeichenketten gibt es verschiedene Abstraktionsebenen, die sprachlich nicht


immer sauber getrennt werden. Wenn man »Zeichenkette« oder »String« sagt, meint
man in der Regel die Zeichenfolge (Wort oder Satz), um die es eigentlich geht. Manch-
mal meint man aber auch das Literal, das das Wort im Quellcode darstellt (z. B.
»Auto«). Manchmal ist aber auch die interne Darstellung im Rechner gemeint.

Im Rechner besteht eine Zeichenkette aus einer ununterbrochenen Reihung (Array) 18


von Zeichencodes, die durch den Zeichencode 0 (Terminatorzeichen) abgeschlossen
wird. Beachten Sie, dass es sich bei dem Terminatorzeichen nicht um das Zeichen ›0‹
mit Zeichencode 0x30, sondern das Zeichen NULL mit dem Zeichencode 0 handelt.

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.

Zur Verarbeitung von Zeichenketten (z. B. Vergleich, Kopieren) gibt es zahlreiche


Runtime-Library-Funktionen.

669
18 Zusammenfassung und Ergänzung

Zeiger
Zeiger dienen zum Zugriff auf Objekte über deren Adresse. Zu einem Zeiger gehören
immer drei Dinge:

왘 ein Name, über den der Zeiger angesprochen wird


왘 der Datentyp des Objekts, auf das der Zeiger zeigt
왘 die Adresse des Objekts, auf das der Zeiger zeigt

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:

int *pointer; // pointer ist ein Zeiger auf einen int-Typ


struct abc *p; // p ist ein Zeiger auf eine Datenstruktur abc
int (*f)(int, float); // f ist ein Zeiger auf eine Funktion mit der
// Schnittstelle int xxx( int, float)

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:

// Benoetigte Strukturen, Variablen und Funktionen

struct abc
{
int x;
int y;
};

int variable1;
struct abc variable2;

int meinefunktion( int a, float b)


{
...
}

// Definition der Zeiger

int *pointer;
struct abc *p;
int (*f)(int, float);

670
Zeiger

// Zuweisung der Adressen

pointer = &variable1;
p = &variable2;
f = meinefunktion;

Es gibt in C keine automatischen Laufzeitprüfungen, die feststellen, ob ein Zeiger


einen gültigen Adresswert enthält. Arbeitet man in einem Programm mit nicht oder
nicht korrekt initialisierten Zeigern, stürzt das Programm ab. Es ist daher sehr sinn-
voll, Zeiger, die noch keine gültige Adresse enthalten, mit dem Wert 0 (oder NULL) zu
initialisieren. Da 0 kein gültiger Adresswert ist, kann so geprüft werden, ob ein Zeiger
bereits eine Adresse enthält:

int *pointer = NULL;

if( pointer != NULL)


*pointer = *pointer + 1;

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:

p->x = 2; /* wie (*p).x = 2; */


p->y = p->x + 3;

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«.

Zugriffsoperatoren ([], ->, ., *, &)


Zugriffsoperatoren dienen zum Zugriff auf Daten. Im Einzelnen unterscheiden wir:

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

[] a[i] Array-Zugriff Zugriffsoperator L 15

-> p->x Indirektzugriff

. a.x Strukturzugriff

* *p Dereferenzierung Zugriffsoperator R 14

& &x Adress-Operator

Tabelle 18.20 Zugriffsoperatoren

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.

Zuweisungsoperatoren (++, --, =, +=, -=, *=, /=, %=, &=,


^=, |= <<=, >>=)
Die übliche Wertzuweisung an eine Variable erfolgt über das Gleichheitszeichen. Auf
der linken Seite des Gleichheitszeichens steht das Objekt, das einen Wert bekommen
soll, auf der rechten Seite steht der Wert, der zugewiesen werden soll. Objekt und
Wert müssen dabei den gleichen Datentyp haben, oder es muss eine implizite Kon-
vertierung – ohne Datenverlust – vom Typ des Werts in den Typ des Objekts möglich
sein.

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;

sinnlos ist, da der Konstanten 1 kein Wert zugewiesen werden kann.

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

Zunächst gibt es die Inkrement- und Dekrement-Operatoren, um den Wert eines


L-Values um 1 zu erhöhen oder zu reduzieren. Diese Operatoren können ihrem Ope-
randen vorangestellt oder angeschlossen werden. Im ersten Fall wird die Operation
ausgeführt, bevor der Wert in die weitere Berechnung eingeht. Im zweiten Fall wird
die Operation ausgeführt, nachdem der Wert in die weitere Berechnung eingegangen
ist.

Operation Bedeutung

x++ x = x + 1 nach Verwendung von x in einem Ausdruck

x-- x = x – 1 nach Verwendung von x in einem Ausdruck

++x x = x + 1 vor Verwendung von x in einem Ausdruck

--x x = x – 1 vor Verwendung von x in einem Ausdruck

Tabelle 18.21 Zuweisungsoperatoren

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;

for( i = 0; i < 100; 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.22 Operation und Wertzuweisung in einem Operator

Tabelle 18.23 zeigt zusammenfassend alle Operatoren dieses Abschnitts mit ihrer
Assoziativität und Priorität: 18

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

++ x++ Post-Inkrement Zuweisungsoperator L 15

-- x-- Post-Dekrement

++ ++x Pre-Inkrement Zuweisungsoperator R 14

-- --x Pre-Dekrement

Tabelle 18.23 Alle Operatoren dieses Abschnitts im Überblick

675
18 Zusammenfassung und Ergänzung

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

= x=y Wertzuweisung Zuweisungsoperator R 2

+= 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

Tabelle 18.23 Alle Operatoren dieses Abschnitts im Überblick (Forts.)

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:

auto double int struct

break else long switch

Tabelle 19.1 Schlüsselwörter in C

677
19 Einführung in C++

case enum register typedef

char extern return union

const float short unsigned

continue for signed void

default goto sizeof volatile

do if static while

Tabelle 19.1 Schlüsselwörter in C (Forts.)

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:

asm dynamic_cast namespace reinterpret_cast try

bool explicit new static_cast typeid

catch false operator template typename

class friend private this using

const_cast inline public throw virtual

delete mutable protected true wchar_t

Tabelle 19.2 Neue Schlüsselwörter in C++

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:

A z = 3; // Ein C++-Kommentar kann (unüblich!) auch so -> \


B z = x * y; fortgesetzt werden

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.

19.3 Datentypen, Datenstrukturen und Variablen


Auch beim Thema Datentypen, -strukturen und Variablen gibt es einige Neuigkeiten.
Die meisten Änderungen entfalten erst mit der Objektorientierung ihre volle Wir-
kung, einige Punkte möchte ich Ihnen aber vorab vorstellen.

19.3.1 Automatische Typisierung von Aufzählungstypen 19


Die Aufzählungstypen, die Sie aus C kennen, gibt es auch in C++:

enum wochentag {Mo, Di, Mi, Do, Fr, Sa, So};

Wenn Sie eine Variable des neuen Typs in C anlegen, müssen Sie so vorgehen:

enum wochentag wt_c; /* C-Stil */

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:

wochentag wt_cpp; // C++-Stil

679
19 Einführung in C++

19.3.2 Automatische Typisierung von Strukturen


So wie für Aufzählungstypen enum wird in C++ auch für Datenstrukturen struct im-
plizit ein Datentyp erstellt. Die Deklaration erfolgt weiter, wie Sie es bereits kennen:

struct punkt
{
int x;
int y;
};

In C erfolgt dagegen die Verwendung bekanntlich so:

struct punkt p_c; /* C-Stil */

In C++ kann auch für eine struct wie für ein enum das zusätzliche Schlüsselwort an
dieser Stelle entfallen:

punkt p_cpp; // C++-Stil

Damit sind die in C oft verwendeten Konstruktionen

typedef struct punkt PUNKT

oder

# define PUNKT struct punkt

hier nicht mehr notwendig und auch nicht mehr üblich. Aufgrund der Kompatibili-
tät mit C können die Konstruktionen aber weiterverwendet werden.

19.3.3 Vorwärtsverweise auf Strukturen


C++ erlaubt Vorwärtsverweise innerhalb von Strukturen. Damit kann später auf
Strukturen verwiesen werden, die an dieser Stelle noch nicht definiert sind. Dies ist
insbesondere notwendig, wenn es sich um Zirkelverweise handelt.

A struct student; // Vorwaertsverweis auf student


struct bachelorarbeit; // Vorwaertsverweis auf bachelorarbeit

struct student
{
char name[50];

680
19.3 Datentypen, Datenstrukturen und Variablen

B bachelorarbeit* ba; // Verweis auf bachelorarbeit


};

struct bachelorarbeit
{
char thema[200];
float note;
student* stud; // Verweis auf den studenten
};

Listing 19.1 Verwendung des Vorwärtsverweises

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:

struct student; // Vorwaertsverweis auf student


struct bachelorarbeit; // Vorwaertsverweis auf bachelorarbeit
19
struct student
{
char name[50];
bachelorarbeit ba; // Fehler, falls bachelorarbeit
// noch nicht vollständig deklariert wurde
};

Listing 19.2 Fehlerhafte Verwendung des Vorwärtsverweises

19.3.4 Der Datentyp bool


Sie haben im Kapitel über Logik erfahren, wie C den Datentyp int verwendet, um die
logischen Aussagewerte wahr und falsch mit den Zahlenwerten 1 und 0 abzubilden.
Genau genommen, ist das Ergebnis einer logischen Operation aber keine Zahl, son-
dern ein Wahrheitswert – eben wahr oder falsch. Viele andere Programmiersprachen
haben daher einen eigenen Datentyp für Wahrheitswerte.

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:

bool b1, b2, b3, b4;

b1 = true;
b2 = !b1;
b3 = 3 > 2;

if( b2 != false)
{
b4 = b1 || b2;
}

Listing 19.3 Verwendung des Datentyps bool

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);

erzeugt die folgende Ausgabe:

Der Wert von ausgabe ist '1'

Viele Entwickler setzen den Datentyp bool mit true und false kaum ein und verwen-
den weiterhin die Ihnen bereits bekannten Mechanismen aus C.

19.3.5 Verwendung von Konstanten


Sie haben die Verwendung symbolischer Konstanten in C kennengelernt, etwa um
Arrays zu definieren:

682
19.3 Datentypen, Datenstrukturen und Variablen

#define ANZAHL 10

int array[ANZAHL]; /* Vorgehen in ANSI-C */

Dies hatte den Grund, dass die folgende Definition in C nicht möglich war:

const int anzahl = 10;


int array[anzahl]; /* In ANSI-C nicht moeglich */

Genau dieses Konstrukt ist in C++ möglich und erwünscht.

const int anzahl = 10;


int array[anzahl]; // Typische Vorgehensweise in C++

Der definierte konstante Wert anzahl kann bereits zur Übersetzungszeit verwendet
werden.

Die Verwendung von Konstanten anstelle symbolischer Konstanten sollte in C++


bevorzugt werden. Symbolische Konstanten resultieren nur in einer Textersetzung
durch den Präprozessor. Echte Konstanten ermöglichen dem Compiler eine Typprü-
fung und erhöhen damit die Typsicherheit.

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.

Die Include-Anweisungen #include und die Compile-Schalter wie #ifdef behalten 19


allerdings auch in C++ ihre Bedeutung.

19.3.6 Definition von Variablen


Während in C Variablen am Anfang eines Blocks definiert werden müssen, können
sie in C++ frei eingeführt werden. Voraussetzung ist nur, dass sie vor der erstmaligen
Verwendung definiert werden:

int x = 0;
x = 99;

A int a = 0;

B for( int i = 0; i<100; i++)


{

683
19 Einführung in C++

a += i;
C }

Listing 19.4 Definition von Variablen in 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.

19.3.7 Verwendung von Referenzen


In C erfolgt die Übergabe von Parametern an eine Funktion immer als Kopie. Eine
Änderung dieser Kopie innerhalb der Funktion ist für den Aufrufer ohne Wirkung.
Sollen Werte einer Variablen des Hauptprogramms innerhalb einer Funktion geän-
dert werden, muss deren Adresse übergeben werden. Die Funktion erhält dann einen
Zeiger auf den entsprechenden Wert. Über den Zeiger wird der Wert dann aus der
Funktion heraus manipuliert.

Ich zeige Ihnen die Vorgehensweise in C noch einmal anhand einer swap-Funktion
zum Vertauschen zweier Variablenwerte:

A void swap( int* a, int* b)


{
int tmp;

B tmp = *a;
C *a = *b;
D *b = tmp;
}

int main()
{
int x, y;
...

684
19.3 Datentypen, Datenstrukturen und Variablen

E swap ( &x, &y);


return 0;
}

Listing 19.5 Tauschen von Variablen in C

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).

Innerhalb der Funktion werden die Adressen nur dereferenziert verwendet, um an


die dahinterliegenden Werte zu gelangen. An den eigentlichen Adressen sind wir ja
gar nicht interessiert.

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:

void swap( int& a, int& b) 19

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:

A void swap( int& a, int& b)


{
int tmp;

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);

printf( "Nachher; %d %d\n", x, y);


}

Listing 19.6 Tauschen von Variablen in C++ mit Referenzen

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

int max1( int a, int b )


{
if( a >= b )
return a;
else
return b;
}

Listing 19.7 Eine bereits bekannte max-Funktion

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.

In der zweiten Variante werden Referenzen als Parameter übergeben:

int max2( int& a, int& b )


{
if( a >= b )
return a;
else
return b;
}

Listing 19.8 Alternative max-Funktion mit Referenzen

In der üblichen Verwendung


19
z = max1( x, y );
z = max2( x, y );

zeigt sich kein Unterschied.

Wenn Sie nun aber konstante Werte an die Funktion übergeben:

const int u =99, v= 100;


z = max2( 99, 100 ); // Compiler-Fehler
z = max2( u, v ); // Compiler-Fehler

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:

int max3( const int& a, const int& b )


{
if( a >= b )
return a;
else
return b;
}

Listing 19.9 Maximumfunktion mit konstanten Referenzen

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:

z = max3( 99, 100 ); // ok


z = max3( u, v ); // ok

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.

19.3.8 Referenzen als Rückgabewerte


Sie können Referenzen auch als Rückgabewerte verwenden. Ein Beispiel für eine ent-
sprechende Nutzung ist die folgende Variante der max-Funktion:

int& max4( int& a, int& b )


{
if( a >= b )
return a;
else

688
19.3 Datentypen, Datenstrukturen und Variablen

return b;
}

Listing 19.10 Maximumfunktion mit Rückgabe einer Referenz auf int

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;

printf( "y: %d, x: %d \n", y, x );


}

Listing 19.11 Verwendung der Referenz als Rückgabewert (L-Value)

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.

Die Ausgabe ist damit:


19
y: 4711, x: 1

19.3.9 Referenzen außerhalb von Schnittstellen


Die bisherigen Beispiele legen zu Recht nahe, dass Referenzen hauptsächlich bei
Funktionsschnittstellen zum Einsatz kommen. Referenzen können aber auch als
Variablen in einem Programm verwendet werden. Hier werden sie oft auch als Alias-
namen für andere Variablen bezeichnet und müssen bei der Deklaration initialisiert
werden:

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);

Listing 19.12 Verwendung einer Referenz als Aliasname

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

printf(" %d \n", (&ref – &i));

die erwartete Ausgabe 0.

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

19.4.1 Funktionsdeklarationen und Prototypen


Sie haben es im Verlauf des Buches bereits als guten Programmierstil kennengelernt,
für alle Funktionen auch Funktionsprototypen zur Verfügung zu stellen. In C++ ist es
erforderlich, für jede Funktion, die gerufen wird, bevor sie definiert worden ist, einen
Funktionsprototyp bereitzustellen. Dies gibt dem Compiler die Möglichkeit, auch
über Modulgrenzen hinweg eine konsequente Typüberprüfung vorzunehmen.
Wenn Sie sich bereits an diesen guten Programmierstil gehalten haben, gibt es für Sie
keine Änderung, ansonsten ist hier die richtige Gelegenheit, damit zu beginnen.

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( int argc, char** argv )


{
//...
return 0;
}
19
Alternativ der parameterlose Aufruf:

int main()
{
...
return 0;
}

Die return-Anweisung in der main-Funktion darf in C++ weggelassen werden. Der


Compiler ergänzt dann automatisch ein:

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++

19.4.2 Vorgegebene Werte in der Funktionsschnittstelle (Default-Werte)


Häufig haben Sie es in der Programmierung mit Funktionen zu tun, bei denen Sie
nicht immer alle Parameter benötigen. Wir sehen uns das am Beispiel einer Funktion
zum Hochzählen (und Ausgeben) von Werten an:

void hochzaehlen( int start, int ende, int inkrement)


{
int zaehler = start;
while ( zaehler <= ende)
{
printf("%d\n", zaehler);
zaehler += inkrement;
}
printf("\n");
}

Die Funktion wird z. B. mit den folgenden Parametern aufgerufen:

hochzaehlen ( 0, 10, 2);

und liefert das erwartete Ergebnis:

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):

void hochzaehlen( int start, int ende , int inkrement = 1 )

Jetzt können wir die Funktion mit zwei oder drei Parametern verwenden:

hochzaehlen( 0, 6, 2);
hochzaehlen( 0, 3);

und erhalten folgendes Ergebnis:

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:

void hochzaehlen( int start, int ende = 5 , int inkrement = 1 )

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

void hochzaehlen( int start = 0, int ende = 5 , int inkrement = 1 )

und ganz ohne Parameter aufgerufen werden kann:

hochzaehlen();

Für die Default-Werte gibt es einige naheliegende Einschränkungen:

왘 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.

Hat eine Funktion mit Default-Argumenten einen Funktionsprototyp, gehört die


Festlegung der Standardwerte dorthinein:

void hochzaehlen( int start = 0, int ende = 5 , int inkrement = 1);

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.

void hochzaehlen( int start, int ende, int inkrement)


{
int zaehler = start;
//...
}

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.

Um eine Funktion zu einer Inline-Funktion zu machen, stellen Sie der Funktionsdefi-


nition das Schlüsselwort inline voran:

A inline int max( int a, int b)


{
return a > b ? a : b;
}

694
19.4 Funktionen

void main()
{
int x = 10, y = 100

int m = max( x, y);


}

Listing 19.13 Verwendung von Inline-Funktionen

Ü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;
}

Listing 19.14 Praktisch entstandener Code nach Ersetzung der Inline-Funktion

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.

Bei größeren Funktionen kann Inlining zu umfangreicherem Code führen, ergibt


aber trotzdem oft eine bessere Performance.

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.

Es gibt noch eine weitere Methode, um Inline-Funktionen zu erstellen. Diese stelle


ich Ihnen im folgenden Kapitel im Rahmen der Objektorientierung vor.

19.4.4 Überladen von Funktionen


Die wichtigste nicht-objektorientierte Funktion von C++ ist das Überladen von Funk-
tionen. Eine typische Situation ohne Überladung zeige ich Ihnen hier noch einmal.
Ich werde dabei von folgender Datenstruktur ausgehen:

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:

A void print_punkt( punkt p)


{
printf( "Punkt: (%d, %d)\n", p.x, p.y);
}
B void print_vektor( vektor v)
{
printf( "Vektor: (%d, %d, %d)\n", v.x, v.y, v.z);
}

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

struct vektor v = {1, 2, 3};


C print_punkt( p);
D print_vektor( v);
}

Listing 19.15 Ausgabe unterschiedlicher Strukturen

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:

A void print( punkt p)


{
printf( "Punkt: (%d, %d)\n", p.x, p.y);
}
B void print( vektor v)
{ 19
printf( "Vektor: (%d, %d, %d)\n", v.x, v.y, v.z);
}

void main ()
{
punkt p = {5, 4};
vektor v = {1, 2, 3};
C print( p);
D print( v);
}

Listing 19.16 Ausgabe mit überladenen Funktionen

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 der Programmiersprache C würde der Compiler diesen Namenskonflikt nicht


akzeptieren. In C++ werden die Funktionen jedoch anhand ihrer verschiedenen Auf-
rufparameter differenziert.

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.

19.4.5 Parametersignatur von Funktionen


Zur Unterscheidung und Auswahl (überladener) Funktionen dient neben dem Funk-
tionsnamen die Parametersignatur. Zu der Signatur gehören Anzahl, Reihenfolge
und Typ der übergebenen Parameter.

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 fkt1( int a) {return 0;}


void fkt1( int a) {return;}

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 fkt2( int a) {return a;}


int fkt2( int a, int b=10) {return a + b;}

int main()
{
fkt2( 1);
}

Auch dieses Beispiel kann nicht übersetzt werden. Aus der Sicht des Compilers sind
die beiden Varianten von fkt2 nicht unterscheidbar.

19.4.6 Zuordnung der Parametersignaturen und der passenden Funktion


Um die Zuordnung der überladenen Funktionen zu ermöglichen, benutzt der Compi-
ler ein einfaches Verfahren. Er verändert die Funktionsnamen intern so, dass Typ
und Anzahl der Parameter mit in den Namen der Funktion eingehen. Diese Modifika-
tion wird auch als das Dekorieren von Namen oder auch Function Name Encoding
bezeichnet. Am folgenden Beispiel können Sie sehen, wie die Namensdekoration
konkret abläuft. Wir nehmen die Funktion mit dem Namen xxx als Ausgangspunkt:

void xxx( int a, char b, float c);

Der vom Compiler generierte Funktionsname im erzeugten Objektcode lautet:

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.

Im gesamten Prozess der Funktionsauswahl und Namensgenerierung ist insbeson-


dere die Auswahl der Funktionen nicht immer leicht nachzuvollziehen. Zuerst wird
natürlich nach Funktionen gesucht, die eine exakt passende Signatur besitzen. Aber
durch die verschiedenen Möglichkeiten der Konvertierung wird die Auswahl dann
trickreich. So kann eine Funktion, die einen Parameter vom Typ float erwartetet,
auch durchaus mit einem int aufgerufen werden, eine Konvertierung von int nach
float ist ja verlustfrei möglich. Wir werden das Thema daher nicht näher betrachten
und lassen es bei dieser kurzen Übersicht bewenden.

699
19 Einführung in C++

19.4.7 Verwendung von C-Funktionen in C++-Programmen


Wenn Sie C-Funktionen in ein C++-Programm einbinden möchten, wird der gerade
besprochene Weg zur Namensermittlung für überladene Funktionen zum Problem.

Wie Sie gesehen haben, führt der Aufruf der Funktion

void xxx( int a, char b, float 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:

extern "C" void abc(int, char b, float c);

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

void abc(int, char b, float c);


int aaa();

#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:

void abc(int, char b, float c);


int aaa();

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

Tabelle 19.3 Neue Operatoren in C++

701
19 Einführung in C++

Operator Bezeichnung

:: Class-Member-Zugriff

.* Pointer-to-Member-Zugriff (direkt)

->* Pointer-to-Member-Zugriff (indirekt)

new Objekt Allokator

delete Objekt Deallokator

Tabelle 19.3 Neue Operatoren in C++ (Forts.)

Den Operator für den Globalzugriff werde ich Ihnen im Folgenden direkt vorstellen,
die anderen Operatoren kommen erst mit der objektorientierten Programmierung
zum Einsatz.

19.5.1 Der Globalzugriff


Anders als in C können in C++ auch »verdeckte« globale Elemente sichtbar gemacht
werden. Dazu wird der Operator :: für den Globalzugriff verwendet. Ich zeige Ihnen
im folgendem Beispiel direkt seine Funktion:

A int a = 1; // globale Variable a

void main()
{
B int a = 2; // lokale Variable a

C printf( "lokal: %d\n", a); // ohne Scope Resolution

D printf( "global: %d\n", ::a); // mit Scope Resolution


}

Listing 19.17 Der Globalzugriff

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.

Der Operator :: für den Globalzugriff wird auch als Scope-Resolution-Operator


bezeichnet und kommt auch als sogenannter Class-Member-Operator zum Einsatz.
Diese Funktion zeige ich Ihnen bei der objektorientierten Entwicklung und der Nut-
zung von Klassen.

19.5.2 Alle Operatoren in C++


Die neuen Operatoren in C++ kommen zu den bereits bekannten Operatoren aus C
hinzu und ordnen sich dort in das Gesamtsystem ein. Dadurch erweitert sich die
Liste der Operatoren. Die vollständige Liste für C++ sieht damit folgendermaßen aus:

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

:: ::var Globalzugriff Zugriffsoperator 17

:: cl::mem Class-Member- 19
Zugriff

() f (x, y) Funktionsaufruf Auswertungsoperator L 16

[] a [i] Array-Zugriff Zugriffsoperator

-> p->x Indirektzugriff

. a.x Strukturzugriff

++ x++ Post-Inkrement Zuweisungsoperator

-- x-- Post-Dekrement

Tabelle 19.4 Liste aller Operatoren in C++

703
19 Einführung in C++

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

! !x logische Vernei- logischer Operator R 15


nung

~ ~x bitweises Komple- Bitoperator


ment

++ ++x Pre-Inkrement Zuweisungsoperator

-- --x Pre-Dekrement

+ +x plus x arithmetischer
Operator
- -x minus x

* *p Dereferenzierung Zugriffsoperator

& &x Adress-Operator

() (type) Typkonvertierung Datentyp-Operator

sizeof sizeof (x) Typspeichergröße

new new class Objekt allokieren

delete delete a Objekt deallokie-


ren

.* Pointer-to-Mem- Zugriffsoperator L 14
ber-Zugriff

->* Pointer-to-Mem-
ber-Zugriff

* x*y Multiplikation arithmetischer L 13


Operator
/ x/y Division

% x%y Rest bei Division

+ x+y Addition arithmetischer L 12


Operator
- x-y Subtraktion

<< x<<y Bitshift links Bitoperator L 11

>> x>>y Bitshift rechts

Tabelle 19.4 Liste aller Operatoren in C++ (Forts.)

704
19.5 Operatoren

Zeichen Verwendung Bezeichnung Klassifizierung Ass Prio

< x<y kleiner als Vergleichsoperator L 10

<= x<=y kleiner oder gleich

> x>y größer als

>= x>=y größer oder gleich

== x==y gleich Vergleichsoperator L 9

!= x!=y ungleich

& x & y bitweises Und Bitoperator L 8

^ x^y bitweises Entwe- Bitoperator L 7


der-Oder

| x|y bitweises Oder Bitoperator L 6

&& x && y logisches Und logischer Operator L 5

|| x || y logisches Oder logischer Operator L 4

?: y?y:z bedingte Auswer- Auswertungsoperator L 3


tung

= x=y Wertzuweisung Zuweisungsoperator R 2

+= x+=y Operation mit


anschließender 19
-= x-=y
Zuweisung
*= x*=y

/= x/=y

%= x%=y

&= x&=y

^= x^=y

|= x|=y

<<= x<<=y

>>= x>>=y

, x,y sequenzielle Aus- Auswertungsoperator L 1


wertung

Tabelle 19.4 Liste aller Operatoren in C++ (Forts.)

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

Tabelle 19.5 Neue Schlüsselwörter für bekannte Operatoren

So kann eine Prüfung jetzt z. B. ihr Aussehen folgendermaßen ändern:

if ( not ( a and b))


{
...
}

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.

19.5.3 Überladen von Operatoren


In C++ können Operatoren auch als Funktion dargestellt werden. Anstelle der übli-
chen Schreibweise

a = b + c;

kann prinzipiell auch folgende Notation verwendet werden4:

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:

++ – * / % []

Überladen werden können alle Operatoren, außer:

왘 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:

punkt operator+( punkt p1, punkt p2)

Die Implementierung der Funktion können Sie leicht vornehmen:

A punkt operator+( punkt p1, punkt p2)


{
B punkt ergebnis;
C ergebnis.x = p1.x + p2.x;
D ergebnis.y = p1.y + p2.y;
E return ergebnis;
}

Listing 19.18 Überladener operator+ für zwei Punkte

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);
}

Listing 19.19 Verwendung des überladenen Operators

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)

punkt operator*( int faktor, punkt p)


{
punkt ergebnis;
ergebnis.x = faktor * p.x;
ergebnis.y = faktor * p.y;
return ergebnis;
}

Listing 19.20 Definition eines weiteren überladenen Operators

Zur Nutzung dieses Operators erweitern wir unser Hauptprogramm leicht

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 ));
}

Listing 19.21 Verwendung des zusätzlichen neuen Operators

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:

punkt operator*( punkt p, int faktor)


{
return faktor * p;
}

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

19.6 Auflösung von Namenskonflikten


Gerade bei großen Projekten kann es leicht vorkommen, dass Funktionen in unter-
schiedlichen Teilen des Projekts die gleichen Namen tragen.

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.

Wenn verschiedene Teile eines Programms unabhängig voneinander entwickelt wer-


den, ist es oft aber gar nicht möglich, entsprechende Konventionen zu vereinbaren
oder durchzusetzen.

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

um die Bahnhofstraße in Stadtteil B anzusprechen. Auch hier kommt wieder der


Scope-Resolution-Operator zum Einsatz.

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.

Unsere Dateien für diesen Fall sehen folgendermaßen aus:

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
}

Abbildung 19.1 Namenskonflikte durch gleiche Funktionsnamen

Wegen des Namenskonflikts der mehrfach vorkommenden Funktion mit dem


Namen funktion kann das Beispiel erwartungsgemäß nicht übersetzt werden. Im Fol-
genden werde ich diesen Konflikt durch die Verwendung von Namensräumen auf-
lösen.

Erstellen von Namensräumen


Dazu werde ich für jedes Modul einen eigenen Namensraum schaffen. Ein Namens-
raum wird mit dem Schlüsselwort namespace neu eingerichtet:

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 }

Abbildung 19.2 Auflösung der Namenskonflikte durch Namespaces

Mit der Angabe der voll qualifizierten Namen im Hauptprogramm konnte ich das
gesamte Beispiel damit übersetzungsfähig machen.

In C++ können Funktionen in eigenen Namensräumen deklariert werden. Definition


und Verwendung der Funktionen erfolgen dann unter Angabe des vollständigen
Namens (voll qualifiziert).

713
19 Einführung in C++

Ansprechen von Funktionen in Namensräumen


Generell können Sie mit dem oben geschilderten Vorgehen bereits arbeiten. Die
Angabe eines voll qualifizierten Namens wird bei langen Namen aber schnell un-
übersichtlich. Um sich hier die Arbeit zu erleichtern, verwenden Sie die using-Anwei-
sung. Mit ihrer Hilfe kann eine Funktion mit ihrem voll qualifiziertem Namen einge-
bunden werden und danach mit ihrem »kurzen« Namen genutzt werden:

#include "eins.h"
#include "zwei.h"

A using eins::funktion;

void main()
{
B funktion();

C zwei::funktion();
}

Listing 19.22 Ansprechen von Namensräumen

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).

In einer Datei können mehrere using-Anweisungen angegeben werden, auf diese


Weise kann ein aufgelöster Namenskonflikt allerdings auch wieder eingeführt
werden.

Importieren eines kompletten Namensraums


Die Angabe einzelner using-Anweisungen ist zwar eine Verbesserung, aber weiterhin
aufwendig und kleinteilig. Über die using namespace-Anweisung ist es daher nicht nur
möglich, einzelne Funktionen, sondern einen vollständigen Namensraum als Ganzes
zu importieren:

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
}

Listing 19.23 Import vollständiger Namensräume

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.

19.6.1 Der Standardnamensraum std


C++ verwendet die Definition von Namensräumen auch für die Standardbibliothek.
Die C-Laufzeitbibliotheken stehen Ihnen aber weiterhin zur Verfügung. Allerdings
hat sich deren Einbindung verändert, da die C-Laufzeitbibliotheken für C++-Compiler
mit angepassten Header-Dateien versehen worden sind, bei denen auch die Namen
angepasst wurden.

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>

B using namespace std;

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.

Im letzten Kapitel haben Sie die nicht-objektorientierten Erweiterungen von C++


kennengelernt.

In diesem Kapitel geht es um Theorie und Praxis der objektorientierten Programmie-


rung. Zu Beginn erkläre ich Ihnen, was die Objektorientierung ausmacht und welche
Vorteile sie bieten soll. Danach stelle ich Ihnen die wichtigsten Konzepte und Begriffe
der Objektorientierung vor. Das ist erst einmal völlig unabhängig von einer konkreten
Programmiersprache, führt uns aber dann recht schnell zur konkreten Umsetzung der
Konzepte in C++, womit wir uns dann schon mitten in der Praxis befinden werden.

20.1 Ziele der Objektorientierung


Bisher haben wir uns ausschließlich mit der prozeduralen Programmierung beschäf-
tigt. Hier sind Datenstrukturen und Funktionen zumindest im Programmcode strikt
getrennt. Diese Trennung besteht aber nur im Code selbst. Im Kapitel über Daten-
20
strukturen haben Sie gesehen, dass die Datenstrukturen und die Funktionen, die mit
diesen Strukturen arbeiten, in der Verwendung eine Einheit bilden. Das gilt trotz
ihrer Trennung im Programmcode.

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:

Jahr System SLOC [Mio.]

1993 Windows NT 3.1 4–5

1996 Windows NT 4.0 11–12

2000 Windows 2000 >29

2001 Windows XP 40

2003 Windows Server 2003 50

2000 Debian 2.2 55–59

2002 Debian 3.0 104

2007 Debian 4.0 283

2005 Mac OS X 10.4 86

2003 Linux-Kernel 2.6.0 5,2

2009 Linux-Kernel 2.6.32 12,6

2012 Linux-Kernel 3.6 15,9

2007 SAP NetWeaver 238

Tabelle 20.1 Anzahl der Codezeilen in verschiedenen Softwareprojekten


(Quelle: Wikipedia)

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:

왘 Wartbarkeit: Existierender Programmcode muss möglichst einfach gepflegt und


erweitert werden können. Dies umfasst Programmcode und Programmstruktu-
ren, die auch dann einfach zu verstehen, zu korrigieren und zu erweitern sein
müssen, wenn der Entwickler den Code nicht selbst erstellt hat.
왘 Wiederverwendbarkeit: Einzelne Elemente aus einem Programmsystem sollen
möglichst einfach in einem anderen System verwendet werden können. Dazu
muss es möglich sein, eine Kopie des Elements möglichst einfach in ein anderes
Programm integrieren zu können. Die Abhängigkeiten, die sogenannte Kopplung,
müssen dazu möglichst gering gehalten werden. Das Element muss außerdem
einfach für die Anforderungen weiterer Systeme konfiguriert werden können.
왘 Einfache Modellierung: Die Elemente sollen möglichst einfach modelliert werden
können. Dies bedeutet, dass Konzepte aus der realen Welt leicht in Software umge-
setzt werden können. Der Bruch, der zwischen den Konzepten in der realen Welt
und der Abstraktion in der Software erfolgt, soll klein gehalten werden. Die Ele-
mente der Software sollen am Ende einfach zu verstehen und zu nutzen sein.

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.

20.2 Objektorientiertes Design


Um die objektorientierte Entwicklung praktisch zu diskutieren, müssen wir uns
zuerst den Begriffen widmen, die in diesem Bereich verwendet werden. Sie ahnen
vielleicht schon, dass der erste Begriff hier der des Objekts ist. Als ein Objekt bezeich-
net man die softwaretechnische Repräsentation eines Gegenstands oder eines Kon-
zepts aus dem Anwendungsgebiet. Der Gegenstand kann dabei real existieren oder
auch gedacht sein. Objekte leiten sich typischerweise aus Substantiven ab.

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.

Um diese Konzept in eine Software zu überführen, erfolgt eine sogenannte Modellie-


rung, bei der man die Eigenschaften der modellierten Konzepte vorab erfassen und
darstellen möchte. Für die Modellierung von Objekten und Systemen, das soge-
nannte objektorientierte Design, hat sich eine Methodik entwickelt, die so eng mit
der objektorientierten Programmierung verbunden ist, dass sie in der Beschreibung
objektorientierter Systeme allgegenwärtig ist. Ich werde die Grundbegriffe der soge-
nannten UML1-Methodik für die sogenannten Klassendiagramme einführen und für
einige Beispiele verwenden. Für eine detaillierte Darstellung von UML und der
Modellierung objektorientierter Systeme verweise ich Sie auf weiterführende Lite-
ratur.

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.

Reales Objekt Auto

Konzept eines Datums

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:

1 UML = Unified Modeling Language

720
20.2 Objektorientiertes Design

Reales Objekt Auto


Auto

UML-Darstellung

Konzept eines Datums Datum

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.

Reales Objekt Auto Auto

leistung
20
geschwindigkeit
verdeck_offen

Attribute der jeweiligen Objekte

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

Reales Objekt Auto Auto

leistung
geschwindigkeit
verdeck_offen
beschleunigen()
verdeck_bedienen()
hupen()

Methoden der jeweiligen Objekte

Datum
Konzept eines Datums
tag
monat
jahr

31 datum_setzen()
datum_lesen()
ist_schaltjahr()

Abbildung 20.4 Darstellung der Methoden von Objekten

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.

Klasse Instanziierung Instanz

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()

Abbildung 20.5 Darstellung instanziierter Klassen

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

20.3 Klassen in C++


Ich habe bisher allgemein über Klassen und Instanzen gesprochen und will Ihnen
nun zeigen, wie Klassen in C++ umgesetzt werden. Klassen ermöglichen es Ihnen,
Datentypen in einer eleganteren und leichter wiederverwendbaren Art zu imple-
mentieren, als Sie das bisher mit den Datenstrukturen struct konnten.

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.

20.4 Aufbau von Klassen


Die grundlegende Einheit unseres Beispiels ist die Klasse datum zur Verwaltung eines
Kalenderdatums. Von ihrem äußeren Aufbau ist eine Klasse einer Datenstruktur sehr
ähnlich. Es wird lediglich das Schlüsselwort struct durch das neue Schlüsselwort
class ersetzt:
20
A class datum
B {

C };

Listing 20.1 Der Aufbau einer Klasse

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

Datenstrukturen können ausschließlich Daten enthalten. Klassen können stattdes-


sen Daten und Funktionen als Elemente enthalten. Beide werden auch allgemein als
Member2 bezeichnet.

Dabei unterscheiden wir

왘 Datenmember, auch Attribute genannt


왘 Funktionsmember, auch als Methoden bezeichnet

Der weitere grundlegende Unterschied zwischen Strukturen und Klassen liegt in


dem Zugriffsschutz, der für die Member einer Klasse vorgesehen werden kann.

20.4.1 Zugriffsschutz von Klassen


Ein Zugriffsschutz auf bestimmte Objekte ist Ihnen aus dem Alltag vertraut. Dort ist
es normal, dass nicht jeder mit allen Objekten alles tun kann. So darf z. B. jeder an
meine Haustür gehen und dort klingeln. Der Zugriff darauf ist öffentlich. Jeder kann
Post in meinen Briefkasten werfen, Briefe herausnehmen darf aber nur ich. Hier ist
offensichtlich der »schreibende« Zugriff (Post einwerfen) öffentlich, der »lesende«
Zugriff (Briefe entnehmen) ist privat.

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.

Diese unterschiedlichen Formen des Zugriffs und deren Aufteilung in verschiedene


Bereiche gibt es auch in der objektorientierten Programmierung. Die Unterschei-
dung zwischen öffentlich (public), privat (private) und geschützt (protected) sowie
die Erklärung von Freundschaften (friend) finden wir auch dort wieder.

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:

2 engl. Member = Mitglied

726
20.4 Aufbau von Klassen

class datum
{
A private:

B protected:

C public:

};

Listing 20.2 Der Zugriffsschutz in einer Klasse

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;
};

Listing 20.3 Die Klasse »datum«

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;
}

Listing 20.4 Zugriff auf die Daten mit dem Punkt-Operator

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;
};

Listing 20.5 Zugriff auf die Attribute der Klasse datum

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.

Bei Anpassungen der Datenstruktur folgen typischerweise auch Anpassungen der


Operationen. Andersherum erfordern Änderungen an der Funktionalität der Opera-
tionen vielfach auch eine Änderung der Datenstrukturen. Es besteht also ein starker
Zusammenhang.

Eine unkoordinierte Änderung oder Erweiterung an Datenstrukturen oder Operatio-


nen kann dabei schnell zu einem nicht mehr überschaubaren und damit nicht mehr
wartbaren System führen.

Es liegt also nahe, Datenstruktur und Operation zu kombinieren. Dies passiert in


einem Objekt, indem zusammengehörige Daten und Funktionen als Member einer
Klasse zusammengeführt werden.

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:

void set( int t, int m, int j )

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.

Methoden können direkt in der Klassendeklaration als sogenannte Inline-Methoden


implementiert werden. Sie werden dann direkt in die Klassendeklaration geschrieben:

729
20 Objektorientierte Programmierung

class datum
{
public:
int tag;
int monat;
int jahr;

A void set( int t, int m, int j) { tag = t; monat = m; jahr = j;}


};

Listing 20.6 Inline-Implementierung der Methode »set«

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:

void set( int t, int m, int j)


{
tag = t;
monat = m;
jahr = j;
}

Listing 20.7 Umgestellter Code der Methode set

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);
}

Listing 20.8 Die Verwendung der Methode »set«

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:

Der Tag des Datums ist: 24

20.4.4 Verwendung des Zugriffsschutzes


Wir haben zuerst alle Attribute in den öffentlichen Bereich gelegt, dort besteht aber
keinerlei Schutz gegen ungewollte Änderungen von außen. Die Setter-Methode ist
eine Vorbereitung auf dem Weg, die Attribute vor unkontrolliertem Zugriff zu schüt-
zen. Im nächsten Schritt werden wir mit der Deklaration der Attribute als privat
genau diesen Schutz einrichten.

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;}
};

Listing 20.9 Platzierung der Attribute im privaten Bereich

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.

Wir testen unsere veränderte Klasse:

int main()
{
datum ha; //Heiligabend
A ha.set( 24, 12, 2014);
B int t = ha.tag; // FEHLER
}

Listing 20.10 Fehler bei lesendem Zugriff

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;}
};

Listing 20.11 Klasse datum mit Getter-Methoden (inline)

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();
}

Listing 20.12 Zugriff auf private Attribute über öffentliche Getter

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);
};

Listing 20.13 Deklaration einer Methode in der Klasse

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:

A void datum::set( int t, int m, int j )


{
if( j<1970 || j>2099 )
j = 1970;
if( m<1 || m>12 )
m = 1;
if( t<1 || t>30 )
t = 1;
tag = t; monat = m; jahr = j;
}

Listing 20.14 Implementierung der Methode der Klasse

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());
}

Listing 20.15 Verwendung der korrigierenden set-Methode

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);
};

Listing 20.16 Klasse mit der Deklaration eines Konstruktors

Innerhalb der Klasse deklarieren wir einen Konstruktor (A) und implementieren ihn
wie unseren Setter außerhalb der Klassendeklaration:

datum::datum( int t, int m, int j)


{
set(t, m, j);
}

Listing 20.17 Die Implementierung des Konstruktors

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());
}

Listing 20.18 Verwendung des Konstruktors

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

Bei der Ausgabe erhalten wir nun das erwartete Ergebnis:

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
}

Listing 20.19 Fehlender parameterloser Konstruktor

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); }
};

Listing 20.20 Ergänzung eines parameterlosen Konstruktors

737
20 Objektorientierte Programmierung

Nachdem wir einen eigenen parameterlosen Konstruktor als Inline-Implementie-


rung zu der Klasse hinzugefügt haben (A), können wir auch wieder Instanzen in der
bisher verwendeten parameterlosen Form erstellen:

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);
}

Listing 20.21 Anwendung des neuen parameterlosen Konstruktors

Ausführung des Konstruktor-Codes bei Instanziierung eines Objektes


Ich hatte Sie im vorangegangenen Kapitel darauf hingewiesen, dass es einen Grund
dafür gibt, dass in C++ Variablen nicht nur am Anfang eines Codeblocks definiert
werden können. An dieser Stelle sollten Sie sich noch einmal klarmachen, dass mit
dem Aufruf des Konstruktors (A, B) implizit der Code ausgeführt wurde, der im Kon-
struktor definiert worden ist. Damit bestimmt die Abfolge der Variablendefinition
also auch die Abfolge des Codeablaufs mit.

Ich möchte die generellen Anforderungen an einen Konstruktor noch einmal zusam-
menfassen:

Anforderungen an einen Konstruktor


Objekte müssen immer in einem konsistenten Zustand sein. Ein Konstruktor eines
Objekts instanziiert das Objekt in einem konsistenten Anfangszustand, der dann im
weiteren Lebenszyklus der Instanz durch die Methoden konsistent verändert wer-
den kann.
Durch die Konstruktoren werden dem Benutzer genau vorgegebene Optionen zur
Verfügung gestellt, wie ein Objekt erstellt werden kann.
Wenn ein Objekt zur Instanziierung Zusatzinformationen von außen benötigt, dann
stellt es entsprechend parametrierte Konstruktoren zur Verfügung. Wenn keine sol-
chen Informationen notwendig sind, dann genügt ein Konstruktor mit der Vorgabe
»keine Parameter«.

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);

Listing 20.22 Aufruf von Konstruktoren

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.

Die Klasse datum benötigt keine besonderen Aufräumarbeiten, aber zu Demonstrati-


onszwecken erstellen wir hier einen funktionslosen Destruktor:

class datum
{
private:
int tag;
int monat;
int jahr;
public:
int getTag() { return tag;}
int getMonat() { return monat;}

739
20 Objektorientierte Programmierung

int getJahr() { return jahr;}


void set( int t, int m, int j);
datum( int t, int m, int j);
A ~datum() {}
};

Listing 20.23 Erweiterung der Klasse um einen Destruktor

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.

20.5 Instanziierung von Klassen


In den vorangegangenen Abschnitten haben Sie die Konstruktion und Destruktion
von Objekten kennengelernt. Ich möchte in diesem Zusammenhang die Instanziie-
rung in C++ mit Ihnen noch einmal genauer betrachten. Dazu werden wir die ver-
schiedenen Formen der Variablenanlage in C durchgehen und parallel dazu die
Situation bei der Instanziierung von Objekten in C++ vergleichen.
In C können Variablen auf drei Arten angelegt werden:
왘 automatisch
왘 statisch
왘 dynamisch

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.

20.5.1 Automatische Variablen in C


Automatische Variablen werden innerhalb von Blöcken angelegt. Sie haben die
Lebensdauer des umschließenden Blocks:

void funktion()
{
A int auto1;
{
B int auto2;
C }
D }

Listing 20.24 Automatische Variablen in C

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).

20.5.2 Automatische Instanziierung in C++


Objekte einer Klasse werden automatisch instanziiert, wenn eine Variable dieser
Klasse in einem Block angelegt wird. Dabei wird der Konstruktor aufgerufen, dessen
Signatur zu den mitgegebenen Parametern passt. Jedes Mal, wenn der Programmab-
lauf die Definition passiert, wird das Objekt neu instanziiert.

Automatische Objekte werden durch einen gegebenenfalls vorhandenen Destruktor


beseitigt, sobald der Block verlassen wird, in dem sie definiert wurden.

Die Destruktoren laufen immer in der umgekehrten Reihenfolge der Konstruktoren


ab. Was zuletzt konstruiert wurde, wird zuerst beseitigt.

void funktion()
{
A datum d1;
B datum d2( 1, 1, 2015);
C }

Listing 20.25 Automatische Instanziierung in 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.

20.5.3 Statische Variablen in C


Statische Variablen werden außerhalb von Blöcken angelegt oder mithilfe des
Schlüsselwortes static gekennzeichnet:

A int statisch1;
B static int statisch2;
void funktion()
{
C static int statisch3;
//...
}

Listing 20.26 Statische Variablen in C

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).

20.5.4 Statische Instanziierung in C++


Da in C++ mit den Konstruktoren und Destruktoren Code zur Laufzeit implizit aufge-
rufen wird, müssen Sie hier unterscheiden, ob statische Objekte innerhalb oder
außerhalb von Funktionen angelegt werden:

왘 Anlage eines statischen Objekts außerhalb von Funktionen


Die Objekte werden vor dem eigentlichen Programmstart, also noch vor der Aus-
führung des in main enthaltenen Codes, instanziiert, entsprechende Konstrukto-
ren werden ausgeführt.
왘 Anlage eines statischen Objekts innerhalb von Funktionen
Das Objekt wird einmalig instanziiert, wenn der Programmablauf erstmalig des-
sen Deklaration passiert. Wenn ein statisches Objekt innerhalb einer Funktion ein-
mal angelegt ist, bleibt es bestehen und wird auch bei erneutem Passieren des
Codes nicht neu initialisiert. Es wird nicht bei Verlassen der Funktion, sondern erst
bei Programmende beseitigt.
왘 Beseitigung von statistischen Objekten
Statische Objekte werden nach dem Ende des Programms in der umgekehrten Rei-
henfolge der Instanziierung beseitigt. Für die Angabe zur Reihenfolge der Destruk-
tion gelten die gleichen Regeln wie bei automatischen Objekten. Wenn die Objekte
ohne Seiteneffekte arbeiten5, sollte die Reihenfolge allerdings keine Rolle spielen.

Die Unterscheidung zeigt sich in diesem Beispiel:

A static datum d1;


void fkt()
{
B static datum d3;
}

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();
}

Listing 20.27 Statische Instanziierung in C++

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 .

20.5.5 Dynamische Variablen in C


Dynamische Variablen werden zur Laufzeit angelegt und existieren bis zu ihrer expli-
ziten Beseitigung:

void funktion()
{
int *pi;
A pi = (int*)malloc(sizeof(int));
//...

B free( pi );
}

Listing 20.28 Dynamische Variablen in C

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.

20.5.6 Dynamische Instanziierung in C++


In C haben wir benötigten Speicher mit den Funktionen malloc und calloc der Lauf-
zeitbibliothek allokiert. Zum Allokieren von Klassen können diese Funktionen nicht
verwendet werden.

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;
}

Listing 20.29 Dynamische Instanziierung in C++

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.

20.5.7 Instanziierung von Arrays in C++


Arrays können auch in C++ auf die bereits bekannte Art und Weise angelegt werden.
Um ein Array von Objekten einer Klasse zu erzeugen, muss die entsprechende Klasse
einen parameterlosen Konstruktor besitzen. Ob es sich dabei um einen explizit
erstellten oder einen automatisch vom System bereitgestellten Konstruktor handelt,
ist egal.

datum ds_array[10];

Listing 20.30 Anlage eines statischen Arrays mit zehn Elementen

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;

Listing 20.31 Dynamische Instanziierung eines Arrays in C++

Nach der Allokation des dynamischen Arrays mit zehn Elementen in (A) erfolgt die
abschließende Freigabe des Arrays in (B).

20.6 Operatoren auf Klassen


Sie können auch für Klassen überladene Operatoren definieren. Das eröffnet Ihnen
die Möglichkeit, viele Operationen elegant und übersichtlich zu notieren. Mit einem
überladenen Operator können Sie z. B. die Anzahl von Tagen zwischen zwei Daten
einfach als deren Differenz betrachten und einen entsprechenden Operator erstel-
len. Die Verwendung sieht folgendermaßen aus:

datum ha( 24, 12, 2014 );


datum zf( 26, 12, 2014 );
int diff = zf – ha;
printf( "Heiligabend und zweiter Feiertag liegen %d Tage auseinander\n"
, diff );

Listing 20.32 Anwendung eines Operators auf die Klasse »datum«

Wir erhalten damit die folgende Ausgabe: 20

Heiligabend und zweiter Feiertag liegen 2 Tage auseinander

Den Operator, den Sie dafür brauchen, implementieren Sie mit den Ihnen bereits
bekannten Mitteln:

A int operator-( datum& l, datum& r)


{
B int tage_l = 360*l.getJahr() + 30*l.getMonat() + l.getTag();
C int tage_r = 360*r.getJahr() + 30*r.getMonat() + r.getTag();
D return tage_l – tage_r;
}

Listing 20.33 Implementierung der Methode »operator-«

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:
// ...
};

Listing 20.34 Friend-Deklaration in der Klasse

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:

int operator-( datum& l, datum& r)


{
A int tage_l = 360*l.jahr + 30*l.monat + l.tag;
B int tage_r = 360*r.jahr + 30*r.monat + r.tag;
return tage_l – tage_r;
}

Listing 20.35 Implementierung des »operator-« als friend

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.

20.6.2 Operator als Methode der Klasse


Es gibt noch eine weitere Möglichkeit, einer Klasse einen Operator hinzuzufügen. In
diesem Fall wird der Operator als Methode der Klasse deklariert und implementiert:

class datum
{
//...
public:
//...
A int operator-( datum& r);
}; 20

Listing 20.36 Operator als Methode der Klasse

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:

int datum::operator-( datum& r)


{
A int tage_l = jahr*360 + 30*monat + tag;
B int tage_r = r.jahr*360 + 30*r.monat + r.tag;
return tage_l – tage_r;
}

Listing 20.37 Implementierung des Operators als Methode der Klasse

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).

20.7 Ein- und Ausgabe in C++


Beim Einstieg in C haben wir zu Beginn einfache Ein- und Ausgabeoperationen mit
printf und scanf eingeführt und verwendet.

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;

Listing 20.38 Verwendung des <<-Operators

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';

Listing 20.39 Verkettung von Ausgaben

In den einzelnen Zeilen wird jeweils die Ausgabe eines Strings und einer Variablen
mit '\n' zum Zeilenumbruch verkettet, und wir erhalten die folgende Ausgabe:

Der Wert von a ist: 1


Der Wert von c ist: X
Der Wert von s ist: Text

Oft wird die Ausgabe in einen Stream auch mit dem endl-Objekt beendet:

cout << 'Ausgabe mit Buffer-Leerung' << endl;

Listing 20.40 Zeilenumbruch und Buffer-Leerung

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

20.7.1 Überladen des <<-Operators


Zusätzlich zu den vom System bereitgestellten <<-Operatoren können wir auch ent-
sprechende Operatoren für unsere Klasse überladen. Damit haben wir eine sehr ele-
gante Ausgabemöglichkeit für unsere Objekte. Ausgabe-Operatoren werden häufig
außerhalb der Klasse und als friend-Funktionen definiert.

class datum
{
A friend ostream& operator<<( ostream& os, const datum& d);
private:
//...
};

Listing 20.41 Deklaration des Ausgabe-Operators als friend

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.

So implementieren Sie den entsprechenden Operator:

ostream& operator<<( ostream& os, const datum& d)


{
A os << d.tag << '.' << d.monat << '.' << d.jahr;
B return os;
}

Listing 20.42 Implementierung des Ausgabe-Operators

Die eigentliche Ausgabe erfolgt über die bereits vorhandenen Ausgabe-Operatoren


für die Basisdatentypen (A). Die Rückgabe des ostream (B) ist notwendig für die Ver-
kettung von Ausgaben. Die Ausgabe eines Datumsobjekts wird damit sehr einfach:

datum einheit( 3, 10, 1990);


cout << "Tag der Einheit: " << einheit<< '\n';

Listing 20.43 Ausgabe eines Datumsobjekts

Wir erhalten dieses Ergebnis:

Tag der Einheit: 3.10.1990

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;

Listing 20.44 Einlesen von Werten mit dem Operator <<

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:

Bitte geben Sie 'f' ein: 1.7

Der <<-Operator verwendet Referenzen, um den Wert in die angegebene Variable


einzutragen. Daher ist der in C verwendete Adress-Operator für die Eingabe hier
nicht notwendig.

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;

Listing 20.45 Einlesen unterschiedlicher Datentypen

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:

friend istream& operator>>( istream& is, datum& d);


20
Listing 20.46 Signatur des >>-Operator zur Überladung

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:

istream& operator>>( istream& is, datum& d)


{
A int tag, monat, jahr;
is >> tag;
is >> monat;
B is >> jahr;

751
20 Objektorientierte Programmierung

C d.set(tag,monat, jahr );
D return is;
}

Listing 20.47 Implementierung des >>-Operators

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).

Nach der Implementierung kann der Operator verwendet werden:

datum d;
cout << "Geben Sie Tag, Monat und Jahr ein: " << '\n';
cin >> d;
cout << "Das Datum ist: " << d << '\n';

Listing 20.48 Verwendung des >>-Operators

Wir erhalten damit z. B. diese Ausgabe:

Geben Sie Tag, Monat und Jahr ein:


15
8
2015
Das Datum ist: 15.8.2015

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()
{

B ofstream datei( "datum.txt" );

datum d1( 1, 1, 2015 );


C datei << d1 << endl;

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

B ifstream datei( "datei.txt" );

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).

Generell sind die Dateibehandlung und das darunterliegende Betriebssystem natür-


lich von der verwendeten Programmiersprache unabhängig. Daher bleiben die
wesentlichen Mechanismen der Dateioperationen auch in C++, wie Sie es bereits ken-
nen. Sie sollten die Elemente wiedererkennen, die Sie bereits im ersten Teil des
Buches gesehen haben.

Sie finden weitere Informationen zu ifstream und ofstream in der Dokumentation


Ihres Compilers. Insbesondere habe ich im ganzen Kapitel nicht davon gesprochen,
wie man die Ausgaben in C++ formatieren kann. Dieses Thema wird auch im Buch
nicht behandelt. Diese Art von Ausgabe hat heute auch nur noch geringe Bedeutung.
Wenn Sie sie benötigen, werden Sie dazu umfangreiche Dokumentation bei Ihrem
Compiler oder im Internet finden. Investieren Sie nicht zu viel Zeit in ausgefeilte Ein-
und Ausgaben mit den hier vorgestellten Mitteln. Wenden Sie sich lieber den eigent-
lichen Algorithmen und den Benutzerschnittstellen grafischer Systeme zu.

20.8 Der this-Pointer


Bei unserer bisherigen Arbeit mit Objekten hat es ausgereicht, dass innerhalb eines
Objekts auf Attribute zugegriffen werden konnte. Adressen von Objekten haben wir
nur verwendet, wenn wir Objekte dynamisch erstellt oder mit Arrays gearbeitet
haben. Wir können auch weiter wie in C die Adresse eines Objekts mit dem Adress-
Operator & ermitteln:
20
datum d;
datum *zeiger;
zeiger = &d;

Listing 20.49 Ermittlung der Adresse eines Elements in C und C++

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

Der this-Pointer bekommt eine besondere Bedeutung, wenn Objekte untereinander


ihre Adressen übermitteln müssen, z. B. weil sie Querverweise untereinander ein-
richten, um Datenstrukturen wie Listen aufzubauen. Eine weitere Verwendung ist
das folgende Konstrukt:

void datum::ausgabe()
{
cout << *this;
}

Listing 20.50 Verwendung des this-Pointers

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

Abbildung 20.6 Darstellung der Basisoperationen

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:

C = ~B = { 0, 2, 3, 5, 8, 9, 10, ..., 255 }

757
20 Objektorientierte Programmierung

Wir wollen in unserer Klasse die folgenden Operatoren implementieren, die jeweils
als Ergebnis eine neue Menge erzeugen:

Operation Beschreibung

A+B Erzeugt die Vereinigung der Mengen A und B.

A*B Erzeugt den Durchschnitt der Mengen A und B.

A–B Erzeugt die mengentheoretische Differenz »A ohne B«.

~A Erzeugt das Komplement der Menge A.

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.

Tabelle 20.2 Operatoren, die eine neue Menge erzeugen

Es wird auch Operatoren geben, die eine bestehende Menge verändern:

Operation Beschreibung

A += B Fügt die Elemente aus B zur Menge A hinzu.

A *= B Entfernt aus A alle Elemente, die nicht zu B gehören.

A -= B Entfernt aus A alle Elemente, die zu B gehören.

A += e Fügt das Element e der Menge A hinzu.

A -= e Entfernt das Element e aus der Menge A.

Tabelle 20.3 Operatoren, die eine bestehende Menge verändern

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.

Tabelle 20.4 Prüfende Operatoren

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.

Tabelle 20.4 Prüfende Operatoren (Forts.)

Abschließend gibt es einen Operator, mit dessen Hilfe wir eine Menge in einen Out-
put-Stream wie cout ausgeben können:

Operation Beschreibung

os << A Gibt die Menge A auf dem ostream os aus.

Tabelle 20.5 Ausgabe-Operator

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
//
};

Listing 20.51 Die Datei »set.h«

Die Deklaration der Klasse set erfolgt in einer separaten Datei set.h. 20

#include "set.h"

set::set()
{
//...
}
// Implementierung der weiteren Methoden

Listing 20.52 Die Datei »set.cpp«

Die Implementierung der inkludierten Klassendeklaration in set.h liegt in der Datei


set.cpp.

759
20 Objektorientierte Programmierung

#include "set.h"

int main()
{
// Hauptprogramm
}

Listing 20.53 Die Datei »test.cpp«

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

{ 2, 4, 11, 14, 249 }

Abbildung 20.7 Interne Repräsentation der Daten

In Abbildung 20.7 sind für die fünf Elemente der Menge die fünf korrespondierenden
Bits in dem Array gesetzt.

Die Klassendeklaration für die Menge lautet damit folgendermaßen:

class set
{
private:
unsigned char data[32];
};

Listing 20.54 Deklaration der Klasse »set«

760
20.9 Beispiele

Wir werden alle Operatoren als friend-Funktionen implementieren. Die entspre-


chenden Deklarationen nehmen wir in die Klasse mit auf.

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 );
//...
};

Listing 20.55 Erweiterte Klassendeklaration

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

C friend ostream& operator<< ( ostream& os, set s );


private:
D unsigned char data[32];
public:
E set();
};

Listing 20.56 Vollständige Deklaration der Klasse »set«

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;
}

Listing 20.57 Implementierung des Konstruktors von »set«

Dies lässt sich mit einem einfachen Schleifendurchlauf erledigen. Im Anschluss an


den Konstruktor wollen wir unseren ersten Operator implementieren. Dazu wählen
wir den Operator +=, der die Elemente aus B zur Menge A hinzufügt. Mit der gewähl-
ten Datenrepräsentation sieht die Operation schematisch folgendermaßen aus:

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 }

Abbildung 20.8 Verknüpfung zweier Mengen

762
20.9 Beispiele

Es handelt sich um eine simple Oder-Verknüpfung der Array-Elemente, die wir nun
implementieren:

set& operator+= ( set& s1, const set& s2 )


{
int i;
for( i = 0; i < 32; i++ )
A s1.data[i] |= s2.data[i];
return s1;
}

Listing 20.58 Implementierung des Operators »+=«

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.

set operator+ ( const set& s1, const set& s2 )


{
A set r;
B r = s1;
C r += s2;
D return r;
20
}

Listing 20.59 Implementierung des Operators »+«

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 }

Abbildung 20.9 Differenzbildung zweier Mengen

Die Ähnlichkeit zur Vereinigung ist leicht erkennbar.

set& operator-= ( set& s1, const set& s2 )


{
int i;
for( i = 0; i < 32; i++ )
A s1.data[i] &= ~s2.data[i];
return s1;
}
set operator- ( const set& s1, const set& s2 )
{
set r;
r = s1;
B r -= s2;
return r;
}

Listing 20.60 Implementierung von operator-= und operator-

Die Implementierung des operator-= erfolgt durch eine bitweise Und-Verknüpfung


(A) von s1 mit dem Komplement von s2, der operator- wendet den operator-= ent-
sprechend an (B).

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:

set& operator*= ( set& s1, const set& s2 )


{
int i;
for( i = 0; i < 32; i++ )
s1.data[i] &= s2.data[i];
return s1;

764
20.9 Beispiele

}
set operator* ( const set& s1, const set& s2 )
{
set r;
r = s1;
r *= s2;
return r;
}

Listing 20.61 Implementierung von »operator*=« und »operator*«

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:

set operator~ ( const set& s )


{
int i;
A set r;
for( i = 0; i < 32; i++ )
B r.data[i] = ~s.data[i];
return r;
}

Listing 20.62 Implementierung von »operator~«

Dazu erstellen wir zunächst ein neues set (A) und übertragen dann das bitweise
Komplement aller Array-Elemente in dieses set (B). 20

Die Vorgehensweise, um ein einzelnes Element zu einer Menge hinzuzufügen, unter-


scheidet sich etwas von den bisherigen Verfahren. Dazu müssen wir das entspre-
chende Bit im Array lokalisieren und dieses Bit gezielt setzen. Das Bit für die Position
e steht im Array-Element e/8 und dort an der Position e%8. Mit diesen Informationen
können wir das Bit gezielt manipulieren. Am Beispiel von e = 13 ergibt sich:

data[0] data[1] element = 13/8


0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 = 1
0 0 1 0 1 0 0 0 0 0 0 1 0 0 1 0
position =
13%8 = 5

Abbildung 20.10 Berechnung der Position eines Elements

Dieses Vorgehen können wir nun algorithmisch umsetzen:

765
20 Objektorientierte Programmierung

set& operator+= ( set& s1, const int e )


{
if( ( e >= 0 ) && ( e < 256 ) )
A s1.data[e/8] |= ( 1 << ( e%8 ) );
return s1;
}
set operator+ ( const set& s1, const int e )
{
set r;
r = s1;
r += e;
return r;
}

Listing 20.63 Hinzufügen einzelner Elemente

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+=.

Nachdem Sie das Vorgehen kennengelernt haben, um ein Element hinzuzufügen,


können Sie auch leicht Elemente entfernen:

set& operator-= ( set& s1, const int e )


{
if( ( e >= 0 ) && ( e < 256 ) )
A s1.data[e/8] &= ~( 1 << ( e%8 ) );
return s1;
}
set operator- ( const set& s1, const int e )
{
set r;
r = s1;
r -= e;
return

Listing 20.64 Entfernen einzelner Elemente

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:

int operator<= ( const set& s1, const set& s2 )


{
int i;
A for( i = 0; i < 32; i++ )
{
B if( ( s1.data[i] & s2.data[i] ) != s1.data[i] )
C return 0;
}
D return 1;
}

Listing 20.65 Prüfung der Teilmengenbeziehung

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:

int operator<( int e, const set& s )


{
if( ( e >= 0 ) && ( e < 256 ) )
A return s.data[e/8] & ( 1 << ( e%8 ) );
return 0;
}

Listing 20.66 Prüfung eines enthaltenen 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.

int operator! ( const set& s )


{
int i;
A for( i = 0; i < 32; i++ )
{
if( s.data[i] )
B return 0;
}
C return 1;
}

Listing 20.67 Prüfung auf die leere Menge

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:

ostream& operator<< ( ostream& os, const set& s ) {


int i;
int append;
A os << '{';

B for( i = 0, append = 0; i < 256; i++ )


{
C if( i < s )
{
if( append )
os << ',';
append = 1;
D os << ' ' << i;
}
}
E os << '}' << '\n';
return os;
}

Listing 20.68 Der Ausgabe-Operator

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).

Der Ausgabe-Operator erzeugt dann z. B. die folgende Ausgabe:

{ 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;

cout << " A = " << A;


cout << " B = " << B << '\n';

cout << " A + B = " << A + B;


cout << " A * B = " << A * B;
cout << " A – B = " << A – B;
}

Listing 20.69 Start des Testprogramms für die Mengenklasse

Mit diesem Code erhalten wir die folgende Ausgabe:

769
20 Objektorientierte Programmierung

A = { 2, 4, 6, 8, 10, 12}
B = { 2, 4, 6, 7, 9, 11}

A + B = { 2, 4, 6, 7, 8, 9, 10, 11, 12}


A * B = { 2, 4, 6}
A – B = { 8, 10, 12}

Wir erweitern unser Testprogramm nun um einige zusätzliche Prüfungen:

int main()
{
// Erster Teil von main

cout << "\n"

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"

if( ( A*B + 15 ) <= ( B + 15 ) )


cout << " A*B+15 ist Teilmenge von B+15\n\n";

A += ( B – 11 );
cout << "A = " << A;
A *= ( B -= 2 );
cout << "A = " << A;
cout << "B = " << B;

return 0;
}

Listing 20.70 Vollständiges Testprogramm

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:

Der Durchschnitt von A und B ist nicht leer

A ist keine Teilmenge von B

Berechnung einiger Formeln


(A +1) * ~(B + 8) = { 1, 10, 12}
((A + 1) * ~(B + 8)) – 10 = { 1, 12}
((A + 1) * ~(B + 8) – 10) + B = { 1, 2, 4, 6, 7, 9, 11, 12}

A*B+15 ist Teilmenge von B+15

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:

x + y = (Re(x) + Re(y), Im(x) + Im(y))


x · y = (Re(x) Re(y) – Im(x) Im(y), Re(x) · Im(y) + Im(x) · Re(y))

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:

왘 Setzen eines bestimmten Datums


왘 Vergleich zweier Daten (gleich, vorher, nachher)
왘 Addition einer bestimmten Anzahl von Tagen zu einem Datum
왘 Bestimmung der Differenz (= Anzahl Tage) zwischen zwei Daten
왘 Berechnen des Wochentags eines Datums
왘 Feststellung, ob es sich um einen Feiertag handelt
왘 Erzeugen von Textstrings in unterschiedlichen Formaten

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.

Um eine Vorstellung vom mechanischen Aufbau der Enigma zu bekommen,


müssen Sie sich die einzelnen Bauteile ausgeschnitten und an den Schmalsei-
ten zu Walzen zusammengeklebt denken. Das Steckbrett und der Reflektor
sind fest, die Rotoren dagegen drehbar montiert.

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

Abbildung 20.11 Schematischer Aufbau der Enigma

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.

Die linke Buchstabenreihe in der Abbildung repräsentiert hier die Schreibma-


schinentastatur und die leuchtenden Lämpchen gleichzeitig.

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

Der Verschlüsselungs- und Entschlüsselungsprozess lief nun so ab, dass sich


der linke Rotor nach jedem Drücken eines Buchstabens auf der Tastatur um
eine Position weiterdrehte. In unserem Beispiel dreht sich der Rotor von 4 auf
5. Dadurch ergibt sich für den nächsten Buchstaben ein geändertes Codie-
rungsschema. 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 mechanischen Zählwerken, wie sie
früher und auch teilweise noch heute in Autos als Kilometerzähler zum Ein-
satz 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 »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.

Erstellen Sie eine Soft-Enigma mit folgenden Leistungsmerkmalen:

왘 Konfiguration der Rotoren und des Reflektors über separate Konfigurati-


onsdateien
왘 interaktive Auswahl der Konfigurationsdateien für die Rotoren und den
Reflektor
왘 interaktive Konfiguration des Steckbretts
왘 interaktive Eingabe der Anfangsstellung für die drei Rotoren
왘 Verschlüsseln und Entschlüsseln von Tastatureingaben
왘 Verschlüsseln und Entschlüsseln von Dateien

Entschlüsseln Sie die folgende Botschaft, die von einer Enigma mit der oben
genannten Konfiguration verschlüsselt wurde:

PJS UGGQFP LTV


PHAZBRXE VKE YYWLBTBORYC
MGU QA BNQXXK FZL GJ TWGNQKJWR
PQV DXQWINKGGLXKP
ZTMMM GZVCAIVNCQI AGSHRY

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.

21.1 Modellierung von Beziehungen


Wir modellieren die Hat-ein- oder Ist-Teil-von-Beziehung zwischen Objekten als
Komposition1.

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

mindestens vier, maximal fünf

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.

21.2 Komposition eigener Objekte


Wir wollen nun eigene Objekte mit einer Hat-ein-Beziehung zusammenführen. Dazu
werden wir einen Zeitstempel als neue Klasse timestamp modellieren. Ein Zeitstempel
fasst in einem Objekt Datum und Zeit zusammen. Er hat genau ein Datum und genau
eine Uhrzeit. Für das Datum haben wir bereits eine passende Klasse datum, die Klasse

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.

Wir modellieren unsere Klasse timestamp so, wie oben geplant:

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 );
};

Listing 21.1 Implementierung der Klasse zeit

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

void zeit::set( int st, int mi )


{
if(st < 0 || st > 23)
st = 0;
if(mi < 0 || mi > 59)
mi = 0;

minute = mi;
stunde = st;
}

Listing 21.2 Außerhalb der Klasse implementierte set-Funktion

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 ); }
};

Listing 21.3 Die erweiterte Klasse zeit mit print-Methode

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 ); };

int getTag() { return tag; }


int getMonat() { return monat; }
int getJahr() { return jahr; }
};

Listing 21.4 Die erweiterte Klasse datum mit »print-Methode«

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.

21.2.1 Komposition in C++


Für die Modellierung der Klasse timestamp stellen wir nur wenige weitere Vorüberle-
gungen an. Wie bei Datum und Zeit sehen wir für den Timestamp öffentliche Getter-
Methoden vor, die Kopien der Attribute als Ergebnis an den Aufrufer zurückliefern.
Damit können Sie die Klasse jetzt bereits implementieren:

class timestamp
{ 21
A private:
datum dat;
zeit zt;
public:
B datum getDatum() { return dat; }
C zeit getZeit() { return zt; }
};

Listing 21.5 Die Implementierung der Klasse »timestamp«

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).

Wir können die Klasse jetzt bereits instanziieren und verwenden:

void main()
{
timestamp ts1;
A ts1.getDatum().print();
printf( " " );
B ts1.getZeit().print();
}

Listing 21.6 Verwendung der Klasse »timestamp«

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

21.2.2 Implementierung der print-Methode für timestamp


Bei der Verwendung, die ich Ihnen gerade gezeigt habe, wird die Ausgabe des Time-
stamps von außen über das Hauptprogramm gesteuert. Das Objekt soll als Verhalten
aber selbst die Möglichkeit haben, die Ausgabe im gewünschten Format vorzu-
nehmen.

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();
};

Listing 21.7 Deklaration der print-Methode von »timestamp«

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();
}

Listing 21.8 Implementierung der print-Methode von »timestamp«

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();
}

Listing 21.9 Ausgabe des Zeitstempels über die neue Methode

Der Aufruf der print-Methode gibt das gewünschte Ergebnis:


21
01.01.1970 00:00

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.

21.2.3 Der Konstruktor von timestamp


Bisher haben wir den vom System bereitgestellten parameterlosen Konstruktor für
die Klasse genutzt. Wir wollen uns das Ergebnis noch einmal ansehen:

781
21 Das Zusammenspiel von Objekten

class timestamp
{
private:
datum dat;
zeit zt;
public:
datum getDatum() { return dat; }
zeit getZeit() { return zt; }
void print();
};

Listing 21.10 Bisherige Implementierung von »timestamp«

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:

Anforderungen an einen Konstruktor


Objekte müssen immer in einem konsistenten Zustand sein. Ein Konstruktor eines
Objekts instanziiert das Objekt in einem konsistenten Anfangszustand, der dann im
weiteren Lebenszyklus der Instanz durch die Methoden konsistent verändert wer-
den kann.
Durch die Konstruktoren werden dem Benutzer genau vorgegebene Optionen zur
Verfügung gestellt, wie ein Objekt erstellt werden kann.
Wenn ein Objekt zur Instanziierung Zusatzinformationen von außen benötigt, dann
stellt es entsprechend parametrierte Konstruktoren zur Verfügung. Wenn keine sol-
chen Informationen notwendig sind, dann genügt ein Konstruktor mit der Vorgabe
»keine Parameter«.

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.

21.2.4 Parametrierter Konstruktor einer komponierten Klasse


Die Schnittstelle für einen Konstruktor von timestamp, der das Objekt vollständig ini-
tialisiert, können wir uns leicht vorstellen. Wenn wir die Deklaration der Klasse ent-
sprechend erweitern, erhalten wir das folgende Ergebnis:

class timestamp
{
private:
datum dat;
zeit zt;
public:
A timestamp( int ta, int mo, int ja, int st, int mi );
//...
};

Listing 21.11 Die Klasse »timestamp« mit parametriertem Konstruktor

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
...

Abbildung 21.3 Weiterleitung der Konstruktor-Parameter

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.

Die Identifikation des eingebetteten Objekts, dessen Konstruktor aufgerufen werden


soll, erfolgt dabei durch seinen Membernamen. Entsprechend der Parametersigna-
tur wird dann ein geeigneter Konstruktor dieser Klasse gerufen. Der Aufruf erfolgt
dabei vor dem Aufruf des Konstruktors der einlagernden Klasse. Innerhalb dieses
Konstruktors steht das genutzte Objekt dann bereits instanziiert zur Verfügung.

Für das Beispiel sieht das dann so aus:

A timestamp::timestamp( int ta, int mo, int ja, int st, int mi)
B : dat( ta, mo, ja),
C zt( st, mi)
{
D
}

Listing 21.12 Implementierung des Konstruktors von »timestamp«

Direkt im Anschluss an die Schnittstellen (A) erfolgt durch den Doppelpunkt


getrennt der Aufruf des Konstruktors für das Objekt dat mit den Parametern ta, mo
und ja (B) und durch ein Komma getrennt der Konstruktor für das Objekt zt mit den
Parametern st und mi (C)2.

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.

21.2.5 Konstruktionsoptionen der Klasse timestamp


Die vollständige Deklaration der Klasse timestamp sieht jetzt folgendermaßen aus:

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);

datum getDatum() { return dat; } 21


zeit getZeit() { return zt; }
void print();
};

Listing 21.14 Die Klasse »timestamp« mit allen Konstruktoren

Es gibt jetzt drei Möglichkeiten, die Klasse timestamp zu instanziieren:

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

Unter Verwendung der unterschiedlichen Konstruktoren können Sie timestamp-


Objekte jetzt auf diese Arten erstellen:

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" );
}

Listing 21.15 Optionen zur Konstruktion von »timestamp«

왘 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.

Es ergibt sich die folgende Ausgabe:

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.

21.3 Eine Klasse text


Ich werde nun für das Logbuch eine Klasse zur Verwaltung von Texten erstellen, an
der ich Ihnen sehr wichtige Konzepte zu Verwendung dynamischen Speichers in
Klassen vorstellen kann. Vorerst halte ich diese Klasse so einfach wie möglich. In der
Deklaration sehe ich den Zeiger txt vor, über den der dynamisch allokierte Speicher
für eine Zeichenkette verwaltet wird. Zusätzlich werde ich die Länge der Zeichenkette
in len speichern. Damit ergibt sich die folgende Klassendeklaration:

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 ); }
};

Listing 21.16 Deklaration der Klasse »text«

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 );
}

Listing 21.17 Konstruktor und Destruktor der Klasse »text« 21

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 text_test( text x )


{
A }

void main( )
{
text t( "test1" );
B text_test ( t );
}

Listing 21.18 Hauptprogramm und Testfunktion

Der Programmcode lässt sich übersetzen und starten, das Programm führt allerdings
zum Absturz!

21.3.1 Der Copy-Konstruktor


Die Klasse besteht bisher praktisch nur aus Konstruktor und Destruktor sowie einem
Hauptprogramm mit einer zusätzlichen Funktion. Nach den Ihnen bisher bekannten
Regeln werden Sie keinen Fehler in dem Programm finden.

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 );
}

Listing 21.19 Konstruktor und Destruktor mit Ausgabe

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:

Konstruktor fuer: test1


Destruktor fuer: test1
Freigeben von: 001BF878
Destruktor fuer: test1
Freigeben von: 001BF878

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:

text t text t text t


len = 5 int len = 5 len = 5

txt test1 txt test1 txt ungültig

21
text x
len = 5

txt

Abbildung 21.4 Ablauf des Aufrufs bei einer bitweisen Kopie

왘 In der main-Funktion wird mit dem explizit implementierten Konstruktor die


Instanz t erstellt, die für den abzulegenden Text Speicher allokiert und verwendet.
왘 Mit dem Aufruf der Funktion text_test wird eine bitweise Kopie von t als Instanz
x zur Verwendung in der Funktion erstellt. Die Kopie verweist auf denselben Spei-
cher wie t, da die Speicheradresse von txt einfach kopiert wurde.

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.

21.3.2 Implementierung eines Copy-Konstruktors


Um das beschriebene Problem zu vermeiden, müssen wir der Klasse einen Copy-
Konstruktor implementieren. Dieser muss dafür sorgen, dass bei Bedarf eine kor-
rekte Kopie unseres Objekts erzeugt wird, die auf »eigenen« Speicher verweist.

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.

text( const text& s );

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 );

void print(){ printf( "%s", txt ); }


};

Listing 21.20 Erweiterung der Klassendeklaration um einen Copy-Konstruktor

Die Implementierung des Copy-Konstruktors sieht dann so aus:

text::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 );
}

Listing 21.21 Implementierung des Copy-Konstruktors

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).

Mit dem implementierten Copy-Konstruktor wird eine unabhängige Kopie erzeugt,


die eigenen Speicher verwaltet und freigibt. Damit ergibt sich nun der korrekte
Ablauf unseres Testprogramms. Mit der Erzeugung einer Kopie des Objekts wird
eigener Speicher für die Kopie reserviert, der dann ohne Beeinträchtigung des Origi-
nals freigegeben werden kann.

text t text t text t


len = 5 int len = 5 len = 5

txt test1 txt test1 txt test1

Jede Instanz
verwendet
Instanz t verweist weiter
eigenen
auf gültigen Speicher,
text x Speicher.
wird korrekt beseitigt.

Im Copy-Konstruktor wird für len = 5


die neue Instanz eigener Instanz x wird bei Verlassen
Speicher allokiert und der Text txt test1
der Funktion beseitigt,
kopiert. Speicher wird freigegeben. 21

Abbildung 21.5 Ablauf des Aufrufs mit Copy-Konstruktor

21.3.3 Zuweisung von Objekten


Es gibt noch eine weitere Situation, bei der durch automatisch bereitgestellte Metho-
den von C++ ein ähnliches Problem entstehen kann. Dies ist bei der Zuweisung an ein
Objekt der Fall, also wenn ein Objekt einem bereits bestehenden Objekt zugewiesen
wird.

void main ()
{
A text u ( "test2" );

791
21 Das Zusammenspiel von Objekten

B text v ( "test3" );
C v = u;

D text w = u;
}

Listing 21.22 Zuweisung von Objekten

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.

Der Zuweisungsoperator wird wie der Copy-Konstruktor automatisch erstellt, falls er


benötigt wird und keiner vorhanden ist. Der automatisch erstellte Zuweisungsopera-
tor erzeugt ebenfalls eine bitweise Kopie des zugewiesenen Objekts. Die Situation bei
einer solchen Zuweisung für ein Objekt mit dynamisch allokiertem Speicher wollen
wir ebenfalls im Detail betrachten.

text u text u text u


len = 5 int len = 5 len = 5

txt test2 txt test2 txt ungültig

text v text v
len = 5 len = 5

txt test3 txt

test3

Abbildung 21.6 Ablauf des Aufrufs bei einer bitweisen Zuweisung

Im Einzelnen sieht der Ablauf folgendermaßen aus:

왘 In der main-Funktion werden die Instanzen u und v unabhängig voneinander


erstellt. Innerhalb der Instanzen verweist der Zeiger txt jeweils auf eigenen Spei-
cher.
왘 Bei der Zuweisung von u an v wird eine bitweise Kopie von v in u gespeichert. Der
Zeiger der bisher in v.txt gespeichert war, wird mit dem Inhalt von u.txt über-
schrieben.

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.

Im Ergebnis gibt es also durch den automatisch erstellten Zuweisungsoperator zwei


gravierende Probleme, die wir jetzt abstellen werden.

21.3.4 Implementierung des Zuweisungsoperators


Um Abhilfe zu schaffen, können wir den Zuweisungsoperator passend überladen.
Der Zuweisungsoperator hat die folgende Schnittstelle:

text& operator=( const text& s );

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 ); }
};

Listing 21.23 Ergänzung der Klassendeklaration um den Zuweisungsoperator

Die Implementierung des Operators ähnelt der des Copy-Konstruktors, arbeitet aber
auf einer existierenden Instanz der Klasse:

793
21 Das Zusammenspiel von Objekten

text& text::operator=( const text& s )


{
A free( txt );
B len = s.len;
C txt = ( char * ) malloc( len + 1 );
D strcpy( txt, s.txt );
E return *this;
}

Listing 21.24 Implementierung des Zuweisungsoperators für »text«

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).

Damit ist nun auch die Zuweisung korrekt ausführbar:

void main ()
{
text u ( "test2" );
text v ( "test3" );
v = u;
}

Listing 21.25 Zuweisung an bestehende Objekte über den erstellten Zuweisungsoperator

21.3.5 Erweiterung der Klasse text


Unsere Klasse text ist mittlerweile voll funktionsfähig. Sie hat Konstruktoren und
einen Destruktor, die die Speicherverwaltung übernehmen. Mit dem explizit erstell-
ten Copy-Konstruktor und dem passenden Zuweisungsoperator ist die Klasse auch
für die übliche Verwendung in C++ gerüstet.

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:

int text::find( char* f )


{
A char* pos = strstr( txt, f );

B if( !pos)
return –1;
else 21
C return( pos – txt);
}

Listing 21.27 Implementierung von »text::find«

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" );

int pos = t3.find( "Auto" );

printf( "'Auto' an Pos: %d\n", pos );


}

Listing 21.28 Verwendung der Klasse »text«

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

21.3.6 Vorgehen für eigene Objekte


Wenn wir Klassen implementieren, für die durch eine einfache bitweise Kopie kein in
sich konsistentes Objekt erzeugt wird, sollten von Anfang an ein passender Copy-
Konstruktor und ein entsprechender Zuweisungsoperator implementiert werden.
Dies ist fast immer der Fall, wenn eine Klasse einen Destruktor benötigt.

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();
};

Listing 21.29 Die Klasse »lostrommel«

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:

A lostrommel::lostrommel( int max )


{
int i;
B anzahl = max + 1;
C for( i = 0; i < anzahl; i++ )
D trommel += i;
}

Listing 21.30 Konstruktor der Klasse lostrommel

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).

Um eine Kugel zu ziehen, gehen wir folgendermaßen vor:

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;
}

Listing 21.31 Ziehen einer Kugel aus der Lostrommel

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; }
};

Listing 21.32 Die Klasse spieler

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:

void spieler::init( int k, int max )


{
21
char inter;
int z;
cout << "Name: ";
A cin >> name;

B lostrommel ltr( max );


for( ; k; k-- )
C karte += ltr.ziehen();

D cout << *this;


}

Listing 21.33 Die Initialisierung des Spielers

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:

ostream& operator<< ( ostream& os, spieler& sp )


{
return os << sp.name << ": " << sp.karte;
}

Listing 21.34 Ausgabe-Operator der Klasse spieler

Durch die Verwendung bereits existierender Ausgabe-Operatoren erfolgen hier


lediglich eine verkettete Ausgabe des Spielernamens und die Ausgabe der verbliebe-
nen Zahlen auf der Karte.

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();
};

Listing 21.35 Die Klasse leiter

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.

A leiter::leiter( int anz, int karte, int max ) : trommel( max )


{
B teilnehmer = new spieler[anz];
for( anzahl = 0; anzahl < anz; anzahl++ )
C teilnehmer[anzahl].init( karte, max );
}

Listing 21.36 Konstruktor der Klasse leiter

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).

Im Destruktor werden dann die dynamisch allokierten Ressourcen wieder freige-


geben:

leiter::~leiter()
{
delete[] teilnehmer;
}

Listing 21.37 Destruktor der Klasse leiter

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

for( sp = 0; sp < anzahl; sp++ )


{
E if( teilnehmer[sp].bingo() )
{
F cout << "BINGO – " << teilnehmer[sp].get_name() << '\n';
G fertig = 1;
}
}
}
}

Listing 21.38 Das eigentliche Spiel

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;
}

Listing 21.39 Das Hauptprogramm

802
21.5 Aufgabe

Das Hauptprogramm initialisiert den Zufallsgenerator mit einem ermittelten Wert


(A) und erfragt vom Nutzer die erforderlichen Spielparameter (B). Danach wird der
Spielleiter instanziiert (C), der dann das Spiel durchführen muss (D). Ein Beispiel-
durchlauf könnte dann folgendermaßen aussehen:

Startwert fuer Z-Generator: 1


Anzahl Teilnehmer: 3
Kartengroesse: 4
Maximum: 8
Spielparameter werden eingegeben. Name: Anton
Anton: { 3, 5, 6, 8}
Spieler Anton, Berta und Claus werden Name: Berta
initialisiert. Berta: { 0, 4, 6, 8}
Name: Claus
Claus: { 0, 1, 7, 8}
Gezogen: 7
Anton: { 3, 5, 6, 8}
Berta: { 0, 4, 6, 8}
Die erste Kugel wird gezogen (7). Claus: { 0, 1, 8}
Gezogen: 3
Anton: { 5, 6, 8}
Die 7 bei Claus von der Karte gestrichen,
Berta: { 0, 4, 6, 8}
das Spiel geht weiter.
Claus: { 0, 1, 8}
Gezogen: 0
Anton: { 5, 6, 8}
Berta: { 4, 6, 8}
Claus: { 1, 8}
Gezogen: 8
Anton: { 5, 6}
Berta: { 4, 6}
Claus hat nur noch eine Zahl auf der Claus: { 1}
Karte. Gezogen: 1
Anton: { 5, 6} 21
Berta: { 4, 6}
Claus hat gewonnen – Bingo! Claus: {}
BINGO - Claus

Abbildung 21.7 Ein Beispieldurchlauf für das Bingospiel

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.

Stellen Sie die folgenden Methoden und Operatoren zur Verfügung:

왘 Erstellen eines Strings aus einer Zeichenkette


왘 Erstellen eines Strings aus einem anderen String
왘 Verketten zweier Strings miteinander
왘 Anfügen eines Buchstabens an einen String
왘 Einfügen eines Strings in einen anderen String
왘 Suchen im String
왘 Vergleich der Strings

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.

Vererbung ist ein Strukturierungsprinzip, durch das eine Ist-ein-Beziehung zwischen


Klassen modelliert wird. Wir sprechen in diesem Zusammenhang auch von Genera-
lisierung und Spezialisierung. Die beiden Begriffe unterscheiden sich dabei eigent-
lich nicht, sie betrachten nur denselben Prozess von unterschiedlichen Standpunk-
ten aus.

Bei der Spezialisierung entstehen neue Klassen (abgeleitete Klassen, Unterklassen,


Kindklassen oder Children) durch die Detaillierung und Konkretisierung bestehen-
der Klassen (Basisklassen, Oberklassen, Elternklassen oder Parents).

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.

22.1 Darstellung der Vererbung


22
In der UML kennzeichnen wir Vererbungsbeziehungen durch einen Pfeil:

Auto Generalisierung
...
...

Cabriolet
...
... Spezialisierung

Abbildung 22.1 Darstellung der Vererbung in der UML

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:

왘 Ein Cabriolet ist ein Auto.


왘 Ein Kombi ist ein Auto.
왘 Ein Minivan ist ein Auto.

22.1.1 Mehrere abgeleitete Klassen


Dieses Beispiel hat schon angedeutet, dass eine Basisklasse (hier das Auto) mehrere
abgeleitete Klassen haben kann. In der UML-Beschreibung werden dann die verschie-
denen Pfeile zusammengefasst. Ein erweitertes Beispiel sieht dann folgendermaßen
aus:

Auto
Basisklasse, Elternklasse,
... Vaterklasse, Parent
...

Cabriolet Kombi SUV Minivan


... ... ... ...
... ... ... ...

Abgeleitete Klasse,
Unterklasse, Kind-
Klasse, Child

Abbildung 22.2 Mehrere abgeleitete Klassen in der UML

806
22.1 Darstellung der Vererbung

22.1.2 Wiederholte Vererbung


Ebenso wie in der realen Welt kann Vererbung auch über mehrere Generationen
erfolgen. Es gibt Eltern, Kinder, Kindeskinder etc.:

Auto
...
...

Cabriolet Kombi SUV Minivan


... ... ... ...
... ... ... ...

Variant T-Modell Touran


... ... ...
... ... ...

Abbildung 22.3 Wiederholte Vererbung

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

Abbildung 22.4 Mehrfachvererbung in der UML

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.

Die Mehrfachvererbung kann im Detail durchaus verzwickt sein. So hat unser


Amphibienfahrzeug z. B. zwei Attribute für die Maximalgeschwindigkeit v_max von
seinen Elternklassen geerbt, eine Maximalgeschwindigkeit zu Wasser und die andere
zu Land. Solche Konfliktsituationen müssen wir auflösen können. C++ unterstützt
die Mehrfachvererbung und erlaubt die Auflösung solcher Konflikte. Die meisten
derzeit populären objektorientierten Sprachen unterstützen die Mehrfachvererbung
nicht.

22.2 Vererbung in C++


Die gezeigten Prinzipien der Abstraktion und Spezialisierung werden wir konkret für
unsere bisherigen Klassen anwenden.

Im vorangegangenen Kapitel haben wir die Textklasse text implementiert. Bisher


können Sie in dieser Klasse einen Text ablegen, den Text ausgeben und in dem Text
nach einem Subtext suchen. Ich werde die Klasse jetzt als Basis für Vererbung nutzen.
Um uns die gleich folgenden Schritte zu erleichtern, ergänzen wir die Klasse text
zuerst noch um einen parameterlosen Konstruktor:

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

Die Klassendeklaration wird um einen parameterlosen Konstruktor erweitertet (A),


der im Folgenden eine einfache Implementierung erhält:

text::text()
{
len = strlen( "leer" );
txt = ( char* ) malloc( len + 1 );
strcpy( txt, "leer" );
}

Listing 22.2 Implementierung des parameterlosen Konstruktors

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.

22.2.1 Ableiten einer Klasse


Wir können in C++ jederzeit eine bestehende Klasse nehmen und eine weitere Klasse
von ihr ableiten. Dies ist möglich, ohne in die Implementierung der bestehenden 22
Klasse einzugreifen.

Mit der Ableitung stellen wir eine Ist-ein-Beziehung her. Um eine Klasse abzuleiten,
gehen wir wie folgt vor:

Trennender Doppelpunkt Name der Klasse von der abgeleitet


Klassendeklaration wird (Elternklasse)

class str : public text


{
//... Zugriffsspezifikation der Vererbung
};

Abbildung 22.5 Ableiten einer Klasse von der Basisklasse

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.

class str: public text


{
};

Listing 22.3 Die abgeleitete Klasse »str«

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

22.2.2 Gezieltes Aufrufen des Konstruktors der Basisklasse


Ich hatte Ihnen gezeigt, wie bei der Aggregation die einbettende Klasse als Benutzer
für die Instanziierung der eingebetteten Klasse verantwortlich wird. Bei der Verer-
bung ist die abgeleitete Klasse Benutzer der Basisklasse und damit nun verpflichtet,
diese zu initialisieren.

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++

Im letzten Beispiel wurde diese Initialisierung über den parameterlosen Konstruktor


der Basisklasse erledigt. Dieser wird automatisch aufgerufen, um die Elternklasse zu
initialisieren, wenn die Kindklasse instanziiert wird. In dem Beispiel, dass ich Ihnen
gerade gezeigt habe, wäre die Instanziierung von str gescheitert, wenn die Basis-
klasse text keinen parameterlosen Konstruktor gehabt hätte.

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.

class str: public text


{
public:
A str( char* t ) : text( t ) {}
};

Listing 22.4 Erweiterung der Deklaration von »str« um einen Konstruktor

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

22.2.3 Der geschützte Zugriffsbereich einer Klasse


Ich habe nun eine Klasse von der Basisklasse abgeleitet. Die abgeleitete Klasse hat
allerdings keine besonderen Zugriffsrechte auf die Methoden und Attribute ihrer
Basisklasse. Auch die Kindklasse kann nur den öffentlichen Teil ihrer Basisklasse nut-
zen. Diese Beschränkung ist notwendig, um den Zugriffsschutz zu erhalten, der ja
nicht von außen umgangen werden soll.

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:

};

Listing 22.5 Die unterschiedlichen Bereiche des Zugriffsschutzes

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.

Verlagerung von Member in den geschützten Bereich


In unserem Beispiel werde ich Ihnen zeigen, wie Sie die Funktionalität der Klasse str
erweitern und ihr Zugriff auf die Attribute ihrer Basisklasse text ermöglichen. Dazu
lege ich innerhalb der Klasse text einen geschützten Bereich an

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 );
};

Listing 22.6 Die Klasse »text« mit geschütztem Bereich

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.

22.2.4 Erweiterung abgeleiteter Klassen


Bisher entspricht die Klasse str praktisch der Klasse text, sie hat die gleichen Daten
und Methoden. Die Vererbung bezieht ihren Reiz aber daraus, dass zwischen Eltern
und Kindern neben aller Ähnlichkeit auch Unterschiede bestehen.

Um abgeleitete Klassen zu spezialisieren oder zu generalisieren, können Sie eigene


Attribute und Methoden hinzufügen. Die Attribute und Methoden der Basisklasse
können dabei verwendet werden, sofern sie zugänglich sind. Bei den geschützten
22
Attributen von text ist das der Fall.

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.

class str: public text


{
public:
str( char* t ) : text( t ){}
A void setText( char* t );
};

Listing 22.7 Erweiterte Deklaration von »str«

813
22 Vererbung

Dazu ergänze ich die Deklaration von str um die neue Methode setText (A) und im-
plementiere sie:

void str::setText( char * t )


{
A free( txt );
B len = strlen( t );
C txt = ( char * ) malloc( len + 1 );
D strcpy( txt, t );
}

Listing 22.8 Die Methode »str::setText«

Die Funktionsweise entspricht dem Zuweisungsoperator. Zuerst wird der bereits


allokierte Speicher freigegeben (A). Die Länge des übergebenen Textes wird ermittelt
und len damit entsprechend gesetzt (B). Mit der bekannten Länge wird Speicher allo-
kiert (C) und der übergebene Text in den Speicher übertragen (D). Dabei greift die
Methode auf die geschützten Elemente len und txt der Basisklasse zu (B und C).

Die neue Funktion wird getestet,

void main()
{
A str string( "Abgeleitete Klasse" );
string.print();
B string.setText( "ist erweitert" );
string.print( );
}

Listing 22.9 Text der Funktion »setText«

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

22.2.5 Überschreiben von Funktionen der Basisklasse


Bisher haben wir Funktionen der Basisklasse verwendet oder die abgeleitete Klasse
gegenüber der Basisklasse mit erweiterter Funktionalität ausgestattet. Ich werde jetzt
eine Funktion modifizieren, die in der Basisklasse bereits vorhanden ist. Dazu ver-
wende ich die bekannte print-Methode. In der Kindklasse soll eine von der Basis-

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.

class str: public text


{
public:
str( char* t ) : text( t ){}
void setText( char* t );
A void print();
void print() { printf( "%s\n", txt ); }

};

Listing 22.10 Erweiterung der Deklaration von »str« um die print-Methode

Um die Methode hinzuzufügen, wird sie nur in der Klasse deklariert (A) und danach
implementiert:

void str::print()
{
A printf( "<<%s>>\n", txt );
}

Listing 22.11 Die Methode »str::print«

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:

class str: public text


{
public:
str( char* t ) : text( t ){}
void setText( char* t );
void print();
A void basisPrint() { text::print(); }
};

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 ); }
};

Listing 22.12 Erweiterung von »text« mit überladenem »print«

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.

22.2.6 Unterschiedliche Instanziierungen und deren Verwendung


Sie können mittlerweile eine Klasse von einer Basisklasse ableiten, die abgeleitete
Klasse instanziieren und deren Funktionalität erweitern, ergänzen und verwenden.
Die Instanziierung kann auch hier automatisch, dynamisch und statisch erfolgen:

void main()
{
A text t1( "Text1" );
B text* t2 = new text( "Text2" );
C static text t3( "Text3" );

D str s1( "String1" );


E str* s2 = new str( "String2" );
F static str s3( "String3" );

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

Aufruf von Methoden über Zeiger auf Objekte


Ziel der Objektorientierung ist es, Objekte auch auf einer abstrakteren Ebene verwen-
den zu können und deren Ist-ein-Beziehung zu nutzen. Dies ist z. B. der Fall, wenn Sie
ein Objekt der Klasse str als Objekt des Typs text verwenden. Ich zeige Ihnen dazu
ein Beispiel, in dem ich Objekte vom Typ text und str dynamisch erstelle und beide
jeweils einem Zeiger auf die Basisklasse text zuweise:

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();
}
}

Listing 22.15 Festlegung eines dynamischen Objekts zur Laufzeit

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.

Eine vom Programm erstellte Ausgabe sieht dann z. B. so aus:


22
text oder str [t|s]: t
INIT
text oder str [t|s]: s
INIT

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

Um eine Typprüfung zur Laufzeit anzufordern, wird die entsprechende Methode in


der Basisklasse als »virtuell« deklariert. Dies ist für das Laufzeitsystem der Hinweis,
bei Methodenaufruf zuerst die tatsächliche Klasse des Objekts festzustellen und
deren Version dieser Methode zu rufen:

22.2.7 Virtuelle Memberfunktionen


Eine Methode wird durch das vorangestellte Schlüsselwort virtual als virtuell dekla-
riert:

class text
{
protected:
int len;
char* txt;
public:
text( char* t );
// ...
A virtual void print(){ printf( "%s\n", txt ); }
};

Listing 22.16 Deklaration der print-Methode als »virtual«

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:

class str: public text


{
public:
str( char* t ) : text( t ){}
void setText( char* t );
void print();
};

Listing 22.17 Unveränderte Implementierung der Kindklasse

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();
}
}

Listing 22.18 Festlegung eines dynamischen Objekts zur Laufzeit

wird zur Laufzeit die Klasse des instanziierten Objekts ermittelt und die korrekte
print-Methode verwendet:

text oder str [t|s]: t


INIT
text oder str [t|s]: s
<<INIT>>

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

Zeiger zeigt auf Objekt Zeiger zeigt auf Objekt


der Klasse text der Klasse str
text *t; text *t;
t = new text( "Init"); t = new str( "Init");
t->print(); t->print();

Text Text

print
gerufen print() ausgeführt gerufen print()
Methode der
konkreten
print Methode der Instanz
Str konkreten Instanz Str

print() print() ausgeführt

Abbildung 22.6 Typprüfung zur Laufzeit

Diese Prüfung erfordert natürlich einen erhöhten Berechnungs- und Speicherauf-


wand, da bei jedem Aufruf der Methode der Typ der zugehörigen Instanz ermittelt
werden muss. Die Kennzeichnung sollte daher nur dort erfolgen, wo sie wirklich
benötigt wird. Wenn eine Klasse als Basisklasse erstellt wird und die Kindklassen die
Methode mit der gleichen Signatur anders implementieren werden, ist das der Fall.
Markieren Sie nicht alle Methoden auf Verdacht als virtuell, sondern treffen Sie eine
bewusste Entscheidung.

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
...
...

Abbildung 22.7 Klassenhierarchie des Timestamps

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:

A class logeintrag: public str, public timestamp


{
public:
B logeintrag() : timestamp(), str( "" ) {}

logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ), 22
str ( tx ) {}
};

Listing 22.19 Implementierung der Mehrfachvererbung

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

logeintrag le( 1, 1, 2015, 0, 1, "Neujahr" );

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.

22.2.10 Zugriff auf die Methoden der Basisklassen


Die Klasse logeintrag hat zwei Basisklassen und erbt die Methoden und Attribute
jeder ihrer Basisklassen (siehe Abbildung 22.9).

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()

print Methoden von str print Methode von timestamp

Logeintrag

Erbt zwei print Methoden

Abbildung 22.9 Mehrfach geerbte Methode »print«

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:

class logeintrag: public str, public timestamp


{
public:
logeintrag() : timestamp(), str( "" ) {} 22
logeintrag( int ta, int mo, int ja, int st, int mi, char* tx ) :
timestamp( ta, mo, ja, st, mi ),
str ( tx ) {}
A void print();
};

Listing 22.20 Überschreiben der geerbten print-Methoden

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" );
}

Listing 22.21 Nutzung der geerbten Methoden durch qualifizierten Zugriff

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).

Über den Operator :: für den »Class-Member-Zugriff« können die gewünschten


print-Methoden in ihren Basisklassen eindeutig adressiert werden. Dazu wird der
Name der Klasse, gefolgt vom operator:: und dem Namen der aufzurufenden
Methode, verwendet.

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();
}

und liefert das erwartete Ergebnis:

01.01.2015 00:01 <<Neujahr>>

22.2.11 Statische Member


Ich möchte die Klasse des Logeintrags jetzt noch so erweitern, dass im System jeder-
zeit festgestellt werden kann, wie viele Instanzen der Klasse logeintrag existieren.

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:

class logeintrag: public str, public timestamp


{
private:
A static int aktiv;

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();
};

Listing 22.22 Erweiterung der Klassendeklaration um das statische Datenmember

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

Listing 22.23 Anlage und Initialisierung des statischen Datenmembers

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:

class logeintrag: public str, public timestamp


{

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();
};

Listing 22.24 Anpassung von Konstruktor und Destruktor

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.

class logeintrag: public str, public timestamp


{
private:
A static int aktiv;

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++

B static int getAktiv() {return aktiv;}


void print();
};

Listing 22.25 Klasse mit statischer Getter-Methode

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());
}

Listing 22.26 Abfrage der aktiven Logeinträge

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

22.2.12 Rein virtuelle Funktionen


Mit dem Schlüsselwort virtual haben Sie eine Möglichkeit kennengelernt, mit der
eine Methode in einer abgeleiteten Klasse dynamisch gebunden werden kann. Zur
Laufzeit wird dann die passende Methode für die tatsächliche Klasse ermittelt.

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;
};

Listing 22.27 Deklaration einer rein virtuellen Methode

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.

Eine Einsatzmöglichkeit liegt vor, wenn Erbinformationen der Klasse weitergegeben


werden sollen, die Klasse so aber noch nicht verwendbar ist. Das ist der Fall, wenn Sie
wissen, dass alle Kinder einer Klasse eine bestimmte Methode erhalten sollen, deren
Implementierung aber erst mit der Konkretisierung der abgeleiteten Klasse sinnvoll
erfolgen kann.

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.

A class abgeleitet : public basis


{
public:
B void print() { cout << "Klasse abgeleitet\n"; }
};

Listing 22.28 Implementierung der rein virtuellen Funktion in der Kindklasse

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:

2-mal aus- 3 Felder


Start setzen zurück

2-mal aus- 3 Felder


setzen vor

3 Felder
Ziel zurück

22

2-mal aus-
setzen

Abbildung 22.10 Spielplan des Würfelspiels

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

왘 den Spielplan mit einer Reihe nacheinander angeordneter Spielfelder


왘 vier unterschiedliche Spielfiguren
왘 einen Würfel

Damit haben Sie die wesentlichen Klassen für das Design bereits kennengelernt:

Würfel

Spiel

Figur Feld

Abbildung 22.11 Klassen des Würfelspiels

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

Ziel Sprung Warte Start

Abbildung 22.12 Vererbungshierarchie der Klassen

Der Spielanleitung entnehmen wir jetzt noch die folgenden »Anforderungen«:

왘 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

Ziel Sprung Warte Start

Abbildung 22.13 Erweiterte Klassenstruktur mit den Kardinalitäten

Das dynamische Verhalten des Spiels


Aus den vorhandenen Informationen leiten wir nun das dynamische Verhalten des
Spiels ab:

왘 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.

In Abbildung 22.14 sind die entsprechenden Botschaften noch einmal dargestellt:

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

ziehen() setzen figurSetzen()


blockiert()
step() Schritt machen

Ziel Sprung Warte Start

figurAufstellen() step() step() figurAufstellen()


blockiert()

Abbildung 22.14 Klassendiagramm mit dem dynamischen Verhalten

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; }
};

Listing 22.29 Implementierung des Würfels

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; }
};

Listing 22.30 Implementierung der Klasse »feld«

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

Im öffentlichen Bereich der Klasse befinden sich die folgenden Methoden:

왘 der Konstruktor (E)


왘 die Methode getNext (F)
Mit dieser Methode bekommt man von einem Feld den Zeiger auf seinen Nachfol-
ger und kann damit vom Start aus das gesamte Spielfeld abfahren. Für die eigent-
liche Spielfunktion wird die Methode nicht verwendet.
왘 die Methode getTyp (G), die den Typ des aktuellen Feldes abfragt
Diese Information wird nur zu Ausgabezwecken verwendet. Der Typ ist ein einzelner
Buchstabe, der das Feld charakterisiert. Insgesamt wird es die folgenden Typen geben:
'.' – normales Feld
'S' – Startfeld
'Z' – Zielfeld
'+' – Vorwärts-Sprungfeld
'-' – Rückwärts-Sprungfeld
'W' – Wartefeld
왘 die Methode figurSetzen (H), um die Figur mit dem Index fig um die Anzahl von
wurf Feldern weiterzubewegen
왘 die Methode blockiert (I), mit der getestet wird, ob die Figur mit dem Index fig
das Feld verlassen darf oder ob sie aktuell blockiert ist

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.

Die Umsetzung in C++ sieht dann so aus:

837
22 Vererbung

feld::feld( feld *ende )


{
A nxt = 0;
B prv = ende;

if( ende )
C ende->nxt = this;

D besetzt[0] = besetzt[1] = besetzt[2] = besetzt[3] = 0;


}

Listing 22.31 Konstruktor der Klasse »feld«

ende

feld feld feld C feld


nxt nxt nxt nxt = 0 A
prv prv prv prv
... ... ... B ...

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:

feld *feld::figurSetzen( int fig, int wurf )


{
A if( !besetzt[fig] || blockiert( fig ) )
return 0;
B besetzt[fig] = 0;

C return step( fig, wurf );


}

Listing 22.32 Die Methode »figurSetzen«

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).

Die step-Methode selbst ist folgendermaßen implementiert:

feld* feld::step( int fig, int steps)


{
A if( steps == 0 )
{
B besetzt[fig] = 1;
C return this;
}

D if( ( ( steps > 0 ) && !nxt ) || ( ( steps < 0 ) && !prv ) )


steps = -steps;

E if( steps > 0 )


F return nxt->step( fig, steps – 1 );
else
G return prv->step( fig, steps + 1 );
}

Listing 22.33 Implementierung der Methode »step«


22
Die Methode kann mit einem positiven oder negativen Wert von steps für die Anzahl
der Schritte aufgerufen werden. Die Figur läuft dann entweder vorwärts oder rück-
wärts. Wenn keine Schritte (mehr) zu gehen sind (A), ist der Zug beendet. Das aktuelle
Feld ist dann das, auf dem die Figur zum Stehen gekommen ist. Es wird mit der gezo-
genen Figur fig als besetzt markiert (B), und die Adresse des Feldes wird als Ergebnis
zurückgeliefert (C). Sind noch Schritte zu gehen, erfolgt die Prüfung, ob ein Ende des
Spielfeldes erreicht ist und die Laufrichtung durch einen Vorzeichenwechsel umge-
kehrt werden muss (D). Bei einer Vorwärtsbewegung ist das der Fall, wenn es kein Nach-
folgerfeld mehr gibt, und bei der Rückwärtsbewegung, wenn kein Vorgänger existiert.

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:

class ziel: public feld


{
public:
A ziel( feld* ende ) : feld( ende ) {};
B char getTyp() { return 'Z'; }
};

Listing 22.34 Die Klasse »ziel«

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:

class start: public feld


{
public:
A start() : feld( 0 ) {};

B char getTyp() { return 'S'; }

C void figurAufstellen( int fig ) { besetzt[fig] = 1; }


};

Listing 22.35 Die Klasse »start«

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

class sprung: public feld


{
private:
A int offset;
feld *step( int fig, int steps );

public:
B sprung( feld * ende, int off ) : feld( ende ) { offset = off; }
C char getTyp() { return offset > 0 ? '+' : '-'; }
};

Listing 22.36 Die Klasse »sprung«

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).

Die eigentliche Funktionalität des Sprungfeldes liegt in der geänderten Methode


step:

feld* sprung::step( int fig, int steps )


{
A if( steps == 0 )
B steps = offset;
22
C return feld::step( fig, steps );
}

Listing 22.37 Die Methode »sprung::step«

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

class warte: public feld


{
protected:
A int timeout;
B int wait[4];
feld *step( int fig, int steps );

public:
C warte( feld* ende, int t ) : feld( ende ) { timeout = t; }
D char getTyp() { return 'W'; }

int blockiert( int fig );


};

Listing 22.38 Die Klasse »warte«

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.

int warte::blockiert( int fig )


{
if( wait[fig] )
{
A wait[fig]--;
B return 1;
}
C return 0;
}

Listing 22.39 Die Methode »warte::blockiert«

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

feld * warte::step( int fig, int steps )


{
A if( steps == 0 )
B wait[fig] = timeout;
C return feld::step( fig, steps );
}

Listing 22.40 Die Methode »warte::step«

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.

Damit sind einige der vorgegebenen Ereignisfelder beispielhaft implementiert.

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

void ziehen( int wurf )


E { pos = pos->figurSetzen( nummer, wurf ); }
};

Listing 22.41 Die Klasse »figur«

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).

Die aufgerufene figurSetzen-Methode ruft fortlaufend die step-Methoden der ver-


ketteten Felder des Spielplans auf, bis die Figur ihr endgültiges Ziel erreicht hat. Das
erreichte Feld wird als Rückgabewert an die figur übergeben und dort gespeichert.

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();
};

Listing 22.42 Die Klasse »spieler«

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.

Die Deklaration der Klasse spiel ist folgendermaßen:

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();
};

Listing 22.43 Die Klasse »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:

spiel::spiel( int seed ) : w( seed )


{
feld *last;
last = startfeld = new start();
A last = new feld( last );

845
22 Vererbung

B last = new feld( last );


last = new warte( last, 2 );
last = new feld( last );
last = new feld( last );
last = new sprung( last, –3 );
last = new feld( last );
last = new feld( last );
last = new sprung( last, +3 );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new warte( last, 2 );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new warte( last, 2 );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new sprung( last, –3 );
last = new ziel( last );
C anzSpieler = 0;
}

Listing 22.44 Der Konstruktor von »spiel«

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

spiel::spiel( int seed) : w( seed )


{
feld *last; Aktuelle Spielerzahl
anzSpieler = 0;
last = startfeld = new start();
last = new feld( last );
last = new feld( last );
last = new warte( last, 2 );
last = new feld( last );
last = new feld( last );
last = new sprung( last, -3 );
last = new feld( last );
last = new feld( last );
last = new sprung( last, +3 );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new warte( last, 2 );
2-mal aus- 3 Felder
Start setzen zurück last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new warte( last, 2 );
2-mal aus- 3 Felder
setzen vor last = new feld( last );
last = new feld( last );
Ziel
3 Felder last = new feld( last );
zurück
last = new feld( last );
last = new feld( last );
last = new feld( last );
last = new sprung( last, -3 );
2-mal aus-
setzen
last = new ziel( last );
}

Abbildung 22.16 Zusammenhang von Spielplan und Konstruktor

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.

Als Gegenstück zum Konstruktor werden im Destruktor die notwendigen Aufräum-


arbeiten erledigt:

847
22 Vererbung

spiel::~spiel()
{
feld *f, *t;
A for( f = startfeld; t = f; )
{
B f = f->getNext();
C delete t;
}
}

Listing 22.45 Der Destruktor von »spiel«

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.

Damit ein Spieler mitspielen kann, muss er sich am Spiel anmelden:

A figur * spiel::anmeldung( spieler *sp )


{
B if( anzSpieler >= 4 )
return 0;
C player[anzSpieler] = sp;

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;
}

Listing 22.46 Die Methode »spiel::anmeldung«

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:

return &fig[anzSpieler – 1].

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;
}

Listing 22.47 Die Methode »spiel::spielrunde« 22

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();
}

Listing 22.48 Die Methode »spiel::partie«

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

Abbildung 22.17 Zusammenhang zwischen Spielfeld und Ausgabe

void spiel::spielstand()
{
feld *fld;
int fg;
A for( fld = startfeld; fld; fld = fld->getNext() )
B cout << fld->getTyp();
cout << '\n';

C for( fg = 0; fg < 4; fg++ )


{
D for( fld = startfeld; fld; fld = fld->getNext() )
{
if( fld == fig[fg].pos )

850
22.3 Beispiele

cout << *( player[fg]->getName() );


else
E cout << '-';
}
F cout << '\n';
}
cout << '\n';
}

Listing 22.49 Ausgabe des Spielstands

Dazu wird zuerst der Spielplan mit dem Typ aller Spielfelder ausgegeben, indem das
gesamte Spielfeld in einer Schleife abgefahren und ausgegeben wird (A).

Damit ist die Repräsentation des Spielplans gegeben:

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:

spieler::spieler( char *n, spiel *s )


{
A strcpy( name, n );
B game = s;
C fig = game->anmeldung( this );
22
}

Listing 22.50 Konstruktor der Klasse »spieler«

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 );
}

Listing 22.51 Die Methode »spieler::zug«

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 );

B spieler anton( "Anton", &sp );


spieler berta( "Berta", &sp );
spieler claus( "Claus", &sp );
spieler doris( "Doris", &sp );
cout << '\n';
D sp.partie();

return 0;
}

Listing 22.52 Das Hauptprogramm

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.

Eine beispielhafte Partie könnte so aussehen:

852
22.3 Beispiele

1.ter Spieler ist Anton


2.ter Spieler ist Berta
3.ter Spieler ist Claus
4.ter Spieler ist Doris

S..W..-..+....W......W......-Z
Ausgabe aller Spieler A-----------------------------
B-----------------------------
C-----------------------------
Alle Spieler auf dem Startfeld D-----------------------------

A=1 B=4 C=6 D=5


Augenzahlen des S..W..-..+....W......W......-Z
jeweiligen Wurfes -A----------------------------
----B-------------------------
---C--------------------------
Figurpositionen nach -----D------------------------
dem Zug
A=5 B=2 D=5
S..W..-..+....W......W......-Z
---A--------------------------
---B--------------------------
---C--------------------------
A, B und C auf Wartefeld, ----------D-------------------
nur D darf ziehen
D=5
S..W..-..+....W......W......-Z
---A--------------------------
---B--------------------------
---C--------------------------
Mehrere Züge nicht ---------------D--------------
dargestellt
A=2 B=3
S..W..-..+....W......W......-Z
Spieler B hat das Zielfeld --------------------A---------
-----------------------------B
erreicht und hat gewonnen ---------------------C--------
---------------------------D--

Abbildung 22.18 Eine beispielhafte Partie des Würfelspiels 22

Nachbetrachtung der virtuellen Methoden


Die Implementierung des Spiels ist damit abgeschlossen. Ich möchte abschließend
aber noch einmal die Bedeutung der virtuellen Methoden für die Implementierung
unterstreichen. In der Deklaration von feld haben wir drei Methoden als virtuell
gekennzeichnet:

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; }
};

Listing 22.53 Die virtuellen Methoden in »feld«

Das sind step (A), getTyp (B) und blockiert (C).

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:

1. Spieler ist Anton


2. Spieler ist Berta
3. Spieler ist Claus
4. Spieler ist Doris

..............................
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.

Dies ist in Abbildung 22.19 noch einmal dargestellt.

854
22.3 Beispiele

Figur steht auf Start

ziehen ziehen() setzen figurAufstellen()

step()
dynamisches Binden

Ziel Sprung Warte Start

step() step() figurAufstellen()


blockiert()

Abbildung 22.19 Dynamisches Binden zur Laufzeit

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()

Abbildung 22.20 Die beiden ersten Klassen der Partnervermittlung

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.

Die Agentur kann:

왘 eine Anmeldung neuer Kunden entgegennehmen


왘 Vermittlungen durchführen
왘 Vermittlungsergebnisse bekannt geben

Ein Kunde kann:

왘 sich mit einem Angebot der Agentur auseinandersetzen


왘 einem anderen Kunden einen Antrag machen bzw. einen solchen Antrag von
einem anderen Kunden entgegennehmen
왘 sich von seinem derzeitigen Partner trennen, um sich mit einem anderen Partner
zu verbinden

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.

Der so angesprochene Wunschpartner prüft natürlich auch, ob er sich durch den


Antrag verbessern kann. Ist das der Fall, trennt er sich von seinem bisherigen Partner
(Trennung) und nimmt den Antrag an. Andernfalls lehnt er den Antrag ab. Im Falle
eines positiven Bescheids trennt sich dann auch der Antragsteller von seinem bishe-
rigen Partner (sofern er einen hatte), und die neue Partnerschaft ist besiegelt. Im Dia-
gramm sieht das so aus:

856
22.3 Beispiele

Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot

anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung

Abbildung 22.21 Botschaften zwischen den Instanzen von »Partnervermittlung« und


»Kunde«

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

Abbildung 22.22 Die Ableitung des »Kunden« von der »Person«

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:

왘 den bescheidenen Kunden


왘 den anspruchsvollen Kunden
왘 den Heiratsschwindler

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).

Damit ist unser Klassendiagramm vollständig, und die Implementierung kann


starten.

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

BescheidenerKunde AnspruchsvollerKunde Heiratsschwindler

Abbildung 22.24 Das Klassendesign mit den Spezialisierungen

Die Implementierung der Klassen


Ich beginne die Implementierung mit der Klasse profil. Das Design gibt uns dabei
die grobe Struktur bereits vor:

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; }
};

Listing 22.54 Die Klasse »profil«

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:

double abweichung( double a, double b )


{
if( !a )
return 0;
A return ( double ) fabs( a – b ) / a;
}

Listing 22.55 Hilfsfunktion zur Berechnung der Abweichung

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:

double abweichung( profil &wunsch, profil &real )


{
double sum = 0;

sum = abweichung( wunsch.alter, real.alter );


sum += abweichung( wunsch.groesse, real.groesse );
if( wunsch.vermoegen > real.vermoegen )
sum += abweichung( wunsch.vermoegen, real.vermoegen );
return sum / 3;
}

Listing 22.56 Berechnung der Abweichung zweier Profile

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

Abbildung 22.25 Design der Klasse »person«

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; }
};

Listing 22.57 Deklaration der Klasse »person«

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.

Der Konstruktor der Klasse hat nicht viele Aufgaben:

861
22 Vererbung

person::person( char * n, char g, int a, double gr, double v )


A : eigenprofil( a, gr, v )
{
strcpy( name, n );
B geschlecht = g;
C }

Listing 22.58 Konstruktor der Klasse »person«

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

Klasse kunde zur Profil


Erweiterung der
Klasse person groesse
vermoegen
Kunde

1 Partner

angebot() 1
trennung()
antrag()
Trennung

Abbildung 22.26 Das Klassen-Design in der Übersicht

Wir werden die Verfeinerungen nun in der von person abgeleiteten Klasse kunde
umsetzen:

class kunde: public person


{
A friend ostream& operator<<( ostream & os, kunde &k);
protected:
B kunde* partner;

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 );
};

Listing 22.59 Die Klasse »kunde«

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.

Im öffentlichen Bereich liegt neben den weiteren Methoden auchdas Wunschprofil


des gesuchten Partners (D).

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:

kunde::kunde( char *n, char g, int a, double gr, double v )


A : person( n, g, a, gr, v ),
B partnerprofil()
{
C partner = 0;
}

Listing 22.60 Der Konstruktor von »kunde«

863
22 Vererbung

Person eigenprofil Profil


name = "Hans" a = 36 alter = 36
geschlecht = 'm' gr = 1.73 groesse = 1.73
v = 50000 vermoegen = 50000

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

Kunde partnerprofil Profil


n =
"Hans" alter = 0
g = 'm' groesse = 0
a = 36 vermoegen = 0
gr = 1.73
v = 50000

Aufruf parameterloser Konstruktor

Abbildung 22.27 Ablauf der Konstruktion von »kunde«

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:

int kunde::akzeptiert( kunde *k )


{
A return abweichung( partnerprofil, k->eigenprofil ) < 0.25;
}

Listing 22.61 Die Methode »kunde::akzeptiert«

Die zugehörige Methode ist in der Klassendeklaration als virtual gekennzeichnet,


damit in den abgeleiteten Klassen eine dynamische Bindung erfolgen kann. In der
Implementierung von kunde wird ein vorgeschlagener Partner akzeptiert, wenn sein
Profil weniger als 25 % vom Wunschprofil abweicht (A).

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

int kunde::verbesserung( kunde *k )


{
A if( !akzeptiert( k ) )
B return 0;
C if( !partner )
D return 1;
E if( abweichung( partnerprofil, k->eigenprofil ) < abw )
F return 1;
G return 0;
}

Listing 22.62 Die Methode »kunde::verbesserung«

Um festzustellen, ob es sich um eine Verbesserung handelt, wird zuerst geprüft, ob


der Partner akzeptabel ist (A). Ist das nicht der Fall, wird der Partner abgelehnt, und es
erfolgt keine Bindung (B). Ist der Partner prinzipiell akzeptabel und der Kunde selbst
hat noch keinen Partner (C), liegt immer eine Verbesserung vor (D). Sollte es bereits
einen Partner geben, wird geprüft, ob die Profilabweichung mit dem neuen Partner
geringer wäre als vorher (E). In diesem Fall läge ebenfalls eine Verbesserung vor (F).
Ansonsten stellt der angebotene Kunde keine Verbesserung dar (G).

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:

void kunde::neuerPartner( kunde *k ) 22


{
A if( partner )
B partner->trennung();

C partner = k;
abw = abweichung( partnerprofil, k->eigenprofil );
}

Listing 22.63 Die Methode »kunde::neuerPartner«

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).

Die Methoden akzeptiert, verbesserung und neuerPartner liegen im geschützten


Bereich der Klasse. Die Methode trennung ist öffentlich und kann auch von außen
aufgerufen werden.

void kunde::trennung()
{
A cout << "Trennung: " << getName() << " >< " << partner->getName()
<< '\n';
B partner = 0;
}

Listing 22.64 Die Methode »kunde::trennung«

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:

int kunde::antrag( kunde *k )


{
A if( !verbesserung( k ) )
B return 0;
C neuerPartner( k );
D return 1;
}

Listing 22.65 Die Methode »kunde::antrag«

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.

Die noch zu implementierende Partnervermittlung wird den Kunden über deren


öffentliche Schnittstelle andere Partnersuchende anbieten, indem sie entsprechende

866
22.3 Beispiele

Nachrichten sendet. Um solche Nachrichten zu erhalten, hat die Klasse kunde die
Methode angebot:

int kunde::angebot( kunde *k )


{
A cout << "Angebot: " << getName() << " ?? " << k->getName() << '\n';

B if( !verbesserung( k ) )
return 0;

C if( !k->antrag( this ) )


return 0;

D neuerPartner( k );

E cout << "Partner: " << *this;


return 1;
}

Listing 22.66 Die Methode »kunde::angebot«

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:

ostream& operator << ( ostream &os, kunde &k )


{
A os << k.getName();
os << " == ";
if( k.partner )
B os << k.partner->getName();

867
22 Vererbung

C else
os << '-';
os << '\n';
return os;
}

Listing 22.67 Der Ausgabe-Operator für die Klasse »kunde«

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

Wenn Anton keinen Partner hat, erfolgt diese Ausgabe:

Anton == -

Ein Blick auf das Klassen-Design zeigt uns, dass die Implementierung der Partnerver-
mittlung selbst noch offen ist:

Person
name
geschlecht

Noch zu implementierende Profil


Klasse partnervermittlung alter
groesse
vermoegen
Partnervermittlung 1 n
Kunde
frauen 1 Partner
maenner Angebot

anmeldung() angebot() 1
vermittlung() trennung()
ergebnis() Antrag
antrag() Trennung

Abbildung 22.28 Aktueller Stand der Implementierung

Die Implementierung der Partnervermittlung


Die Partnervermittlung selbst macht es sich relativ einfach. Sie verwaltet jeweils
einen Karteikasten für die weiblichen und männlichen Kunden und hält die Anzahl
der verwalteten Karteikarten nach:

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();
};

Listing 22.68 Die Klasse »partnervermittlung«

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.

void partnervermittlung::anmeldung( kunde *k )


{
if( k->getGeschlecht() == 'm' )
A maenner[anzahlM++] = k;
else
B frauen[anzahlF++] = k;
}

Listing 22.70 Die Methode »partnervermittlung::anmeldung«

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.

Die Ausgabe des aktuellen Vermittlungsstands der Partnervermittlung erfolgt mit


einer entsprechenden Methode unter Verwendung des bereits implementierten
Ausgabe-Operators der Klasse kunde:

void partnervermittlung::ergebnis()
{
int i;
A cout << "\nFrauen:\n";

B for( i = 0; i < anzahlF; i++ )


cout << " " << *frauen[i];

C cout << "\nMaenner:\n";

D for( i = 0; i < anzahlM; i++ )


cout << " " << *maenner[i];

cout << '\n';


}

Listing 22.71 Die Methode »partnervermittlung::ergebnis«

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;

for( i = 0; i < anzahlM; i++ )


{
for( j = 0; j < anzahlF; j++ )
A maenner[i]->angebot( frauen[j] );
}

for( i = 0; i < anzahlF; i++ )


{
for( j = 0; j < anzahlM; j++ )
B frauen[i]->angebot( maenner[j] );
}
}

Listing 22.72 Die Methode »partnervermittlung::vermittlung«

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.

Die unterschiedlichen Kundentypen als Kindklassen


Dabei ist es wichtig, dass wir die neuen Kundentypen und deren Klassen hinzufügen
können, ohne die restliche Implementierung ändern zu müssen. Die Partnervermitt-
lung kennt die noch zu erstellenden Kundentypen gar nicht. Sie behandelt nur Kun-
den und weiß gar nichts von der gleich stattfindenden Vererbung.

871
22 Vererbung

Partnervermittlung 1 n
Kunde
frauen
maenner Angebot

anmeldung() angebot()
vermittlung() trennung()
ergebnis() antrag() Hinzugefügte Klassen

BescheidenerKunde AnspruchsvollerKunde Heiratsschwindler

Abbildung 22.29 Die besonderen Kundentypen und ihre Klassen

Der anspruchsvolle Kunde verhält sich im Konstruktor nicht anders als ein gewöhn-
licher Kunde:

A class anspruchsvollerKunde: public kunde


{
private:
int akzeptiert( kunde *k );
public:
B anspruchsvollerKunde( char *n, char g, int a, double gr,
double v ) : kunde( n, g, a, gr, v ) {}
};

Listing 22.73 Klasse des »anspruchsvollen Kunden«

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:

int anspruchsvollerKunde::akzeptiert( kunde *k )


{
A return abweichung( partnerprofil, k->eigenprofil ) < 0.10;
}

Listing 22.74 Methode akzeptiert des »anspruchsvollen Kunden«

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:

A class bescheidenerKunde: public kunde


{
private:
B int akzeptiert( kunde *k ) { return 1; }
public:
bescheidenerKunde( char *n, char g, int a, double gr, double v )
: kunde( n, g, a, gr, v ) {}
};

Listing 22.75 Die Klasse des »bescheidenen Kunden«

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:

A class heiratsschwindler: public kunde


{
private:
B int akzeptiert( kunde *k );
C int verbesserung( kunde *k );
22
public:
D heiratsschwindler( char *n, char g, int a, double gr, double v )
: kunde( n, g, a, gr, v ) {}
E int angebot( kunde *k );
};

Listing 22.76 Klasse des »Heiratsschwindlers«

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

int heiratsschwindler::akzeptiert( kunde *k )


{
A return k->eigenprofil.vermoegen >= 50000.00;
}

Listing 22.77 Die Methode »heiratsschwindler::akzeptiert«

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:

int heiratsschwindler::verbesserung( kunde *k )


{
A if( !akzeptiert( k ) )
return 0;
B if( !partner )
return 1;
C return k->eigenprofil.vermoegen > partner->eigenprofil.vermoegen;
}

Listing 22.78 Die Methode »heiratsschwindler::verbesserung«

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:

int heiratsschwindler::angebot( kunde *k )


{
A eigenprofil.set( k->partnerprofil.alter,
eigenprofil.groesse,
B k->partnerprofil.vermoegen );

874
22.3 Beispiele

C return kunde::angebot(k );
}

Listing 22.79 Die Methode »heiratsschwindler::angebot«

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;

B kunde anton( "Anton", 'm', 55, 1.75, 100000 );


C anton.partnerprofil.set( 50, 1.70, 0 );
D pv.anmeldung( &anton );

E kunde berta( "Berta", 'w', 50, 1.70, 60000 );


berta.partnerprofil.set( 50, 1.80, 10000 );
pv.anmeldung( &berta ); 22

F heiratsschwindler claus( "Claus", 'm', 30, 1.80, 10000 );


claus.partnerprofil.set( 25, 1.70, 0 );
pv.anmeldung( &claus );

G anspruchsvollerKunde doris( "Doris", 'w', 60, 1.65, 100000 );


doris.partnerprofil.set( 65, 1.80, 10000 );
pv.anmeldung( &doris );

bescheidenerKunde ernst( "Ernst", 'm', 50, 1.80, 8000 );


ernst.partnerprofil.set( 50, 1.80, 20000 );
pv.anmeldung( &ernst );

875
22 Vererbung

H pv.ergebnis();
I pv.vermittlung();
J pv.ergebnis();
}

Listing 22.80 Das Hauptprogramm

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).

Wir starten das Hauptprogramm und verfolgen dessen Ausgabe:

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:

Angebot: Anton ?? Berta


Partner: Anton == Berta
Angebot: Anton ?? Doris

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

Angebot: Claus ?? Berta


Trennung: Anton >< Berta
Partner: Claus == Berta

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.

Angebot: Claus ?? Doris


Trennung: Berta >< Claus
Partner: Claus == Doris

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.

Angebot: Ernst ?? Berta


Partner: Ernst == Berta
Angebot: Ernst ?? Doris

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:

Angebot: Berta ?? Anton


Trennung: Ernst >< Berta
Partner: Berta == Anton
Angebot: Berta ?? Claus
Angebot: Berta ?? Ernst 22

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.

Angebot: Doris ?? Anton


Angebot: Doris ?? Claus
Angebot: Doris ?? Ernst

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.

23.1 Klassen und Instanzen


Die Deklaration einer Klasse ähnelt vom äußeren Aufbau der Deklaration einer
Datenstruktur struct. Die Klasse wird festgelegt durch das Schlüsselwort class und 23
die Angabe des Klassennamens1, gefolgt von der eigentlichen Klassendeklaration:

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.

Die Zugriffsrechte werden in Abschnitt 23.4, »Zugriffsschutz und Vererbung«, noch


einmal aufgegriffen. Jetzt betrachten wir zuerst die Daten und Funktionen einer
Klasse, die auch Member genannt werden.

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;
};

Die generelle Notation und Verwendung von Aufzählungstypen ist in Abschnitt


18.35, »enum (Aufzählungstypen)«, zusammengefasst. Die Verwendung des oben
deklarierten enum greife ich in Abschnitt 23.2.7, »Zugriff von außen«, noch einmal auf.

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
};

Die Funktionsmember können innerhalb oder außerhalb der Klasse implementiert


werden.

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 );
};

Innerhalb einer Klasse angelegte Funktionsmember sind automatisch als inline3


deklariert. Im Code eines Funktionsmembers kann auf alle Datenmember der Klasse
zugegriffen werden (A, B).

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.

3 siehe Abschnitt 19.4.3, »Inline-Funktionen«

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 );
};

void punkt::set( int xx, int yy )


{
x = xx; y = yy;
}

In C++ können die Schnittstellen von Funktionen allgemein vorab festgelegte


Default-Werte4 enthalten. Das gilt natürlich auch für Funktionsmember:

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; }
};

Die Funktionsmember können, wie andere Funktionen in C++, überladen werden5.


Das bedeutet, dass es innerhalb einer Klasse mehrere Funktionen gleichen Namens
geben kann, die sich nur in der Parametersignatur unterscheiden müssen. Der Com-
piler wählt die passende Funktion bei Aufruf anhand des Namens und der Parame-
tersignatur aus.

4 siehe Abschnitt 19.4.2, »Vorgegebene Werte in der Funktionsschnittstelle (Default-Werte)«


5 siehe Abschnitt 19.4.4, »Überladen von Funktionen«

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; }
};

23.2.3 Konstante Member


Daten und Funktionsmember können auch als konstant deklariert werden. Bei
einem konstanten Datenmember wird das Schlüsselwort const vorangestellt:

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;
};

void ultimate::member() const


{
test = 1; // Fehler, da Funktionsmember const ist
}

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;
};

void ultimate::member( ) const


{
test = 1; // Zugriff moeglich, da test als mutable deklariert
}

Mit dieser Ergänzung ist der schreibende Zugriff auf das als mutable deklarierte test
jetzt auch aus der konstanten Funktion möglich.

Konstante Methoden werden hauptsächlich zusammen mit konstanten Klassen ver-


wendet. Wenn eine Klasse als konstante Variable instanziiert worden ist,

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

int x = p.getX(); // OK, da konstante Methode

p.set( 1, 2 ); // Fehler, Methode nicht const


}

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.

23.2.4 Statische Member 23


Funktions- und Datenmembern kann bei der Deklaration das Schlüsselwort static
vorangestellt werden7. Die so deklarierten statischen Member existieren danach
nicht für jede Instanz der Klasse, sondern einmal für die gesamte Klasse.

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:

int punkt::maxx = 1920;


int punkt::maxy = 1080;

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.

Statische Funktionsmember werden ebenfalls durch das Schlüsselwort static dekla-


riert. Die Implementierung kann innerhalb der Klasse erfolgen:

class punkt {
private:
int x;
int y;

888
23.2 Member

static int maxx;


static int maxy;
public:
static void setMax( int mx, int my ) { maxx = mx; maxy = my; }
};

Alternativ kann die Implementierung aber auch außerhalb erfolgen:

class punkt {
private:
int x;
int y;
static int maxx;
static int maxy;
public:
static void setMax( int mx, int my );
};

void punkt::setMax( int mx, int my )


{
maxx = mx;
maxy = my;
}

Die Kennzeichnung als static erfolgt dabei nur in der Klassendeklaration.

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 );
};

8 siehe Abschnitt 19.4.4, »Überladen von Funktionen«

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:

punkt punkt::operator+( const punkt& p )


{
punkt res;

res.x = x + p.x;
res.y = y + p.y;

return res;
}

Die Operator-Funktion kann wie andere Funktionsmember verwendet werden:

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

23.2.6 Zugriff auf Member


Klassen, die deklariert worden sind,

class punkt
{
};

werden z. B. durch Anlegen einer Klassenvariablen vom entsprechenden Datentyp


instanziiert:

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.

23.2.7 Zugriff von außen


Für den Zugriff von außen sind nur die Daten zugänglich, die sich im public-Bereich 23
einer Klasse befinden11. Auf Daten im Bereich protected und private kann von außen
nicht zugegriffen werden, weder lesend noch schreibend. Wir werden uns das bei-
spielhaft an diesem Fragment einer Klasse ansehen:

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;

instanz.data = 0; // Zugriff auf oeffentliches Datenmember


instanz.funktion( 1 ); // Zugriff auf oeffentliches Funktionsmember

Haben wir stattdessen einen Zeiger auf die Instanz, wird mit dem Indirektionsopera-
tor »->« zugegriffen:

fragment instanz;
fragment* p;

p = &instanz;

p->data = 0; // Zugriff auf oeffentliches Datenmember


p->funktion( 1 ); // Zugriff auf oeffentliches Funktionsmember

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;

( *p ).data = 0; // Zugriff auf oeffentliches Datenmember


( *p ).funktion( 1 ); // Zugriff auf oeffentliches Funktionsmember

Statische Member haben den gleichen Zugriffsschutz wie ihre nicht-statischen


Gegenstücke. Da die statischen Member aber zur Klasse insgesamt und nicht zu einer
bestimmten Instanz gehören, ist der Zugriff etwas anders. Öffentliche statische
Member einer Klasse

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.

Ähnlich verhält es sich mit Aufzählungstypen, die im öffentlichen Bereich einer


Klasse deklariert worden sind. Ein Beispiel dafür hatte ich in Abschnitt 23.2.1, »Daten-
member«, verwendet:

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::note meineNote = bachelorarbeit::befriedigend;

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.

23.2.8 Zugriff von innen


Der Zugriff von innen ist bei der Implementierung der Funktionsmember bereits
verwendet worden. Ich möchte ein paar Details dieser Zugriffsart noch einmal ver-
tiefen.

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; }
};

void punkt::set( int xx, int yy )


{
x = xx;
y = yy;
}

Dabei ist es unerheblich, ob die Memberfunktion innerhalb oder außerhalb der


Klasse implementiert ist. In beiden Fällen erfolgt der Zugriff aus der Memberfunk-
tion direkt auf die Datenmember x und y. Dabei spielen andere Faktoren wie der
Name der Instanz oder die Art der Instanziierung keine Rolle.

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

void punkt::set( punkt * p )


{
x = p->x;
y = p->y;
}

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 );
};

void klasse::funktion( klasse *pk )


{
pk->nichtStatisch = 0;
}

23.2.9 Der this-Pointer


Innerhalb eines Objekts, also in seinen Memberfunktionen, haben wir ohne Umwege
Zugriff auf die anderen Member der Instanz. Wenn wir aber die Adresse des Objekts
selbst ermitteln wollen, in dem wir uns befinden, haben wir kein Element, auf das wir
einen Adress-Operator anwenden können, es fehlt der »Griff«. Hier greift man auf
den this-Pointer zurück. Der this-Pointer gibt die Adresse der aktuellen Instanz an.
Er wird typischerweise verwendet, wenn sich Objekte gegenseitig ihre Adresse mit-
teilen, um Querverweise untereinander zu erstellen:

class person
{
private:
person* partner;
public:
void partnerFestlegen( person* pp );
};

void person::partnerFestlegen( person* pp )


{
partner = pp; // Verweis von mir auf meinen Partner
pp->partner = this; // Rueckverweis des Partners auf mich
}

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

23.2.10 Zugriff durch friends


Eine Klasse kann die Zugriffsbeschränkungen auch gezielt aufheben, die sie durch die
Erklärung von protected- und private-Bereichen aufbaut. Dazu erklärt sie eine Funk-
tion oder Klasse mit dem Schlüsselwort friend zu ihrem Freund. Als Freunde kom-
men infrage:

왘 Funktionen und Operatoren, die nicht zu einer Klasse gehören


왘 spezielle Funktionsmember einer bestimmten Klasse
왘 alle Funktionsmember einer bestimmten Klasse

Um Freunde zu erklären, fügen Sie, üblicherweise am Anfang, in der Deklaration der


Klasse eine friend-Deklaration ein:

class person
{
// Die Funktion funktion1 ist mein Freund
friend int funktion1( person* pp, int a );

// Das Funktionsmember member1 der Klasse andere ist mein Freund


friend andere::member1( int x );

//Alle Funktionsmember der Klasse ganzAndere sind meine Freunde


friend class ganzAndere;
// ...
};

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

Ganz generell verlangen die Freundschaftsbeziehungen eine explizite Erklärung des-


sen, der damit Zugriff ermöglicht. Freundschaftsbeziehungen vererben sich nicht12.
Auch ein Freund meines Freundes ist nicht automatisch auch mein Freund. Ich muss
ihn erst selbst dazu erklären13.

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-

12 Die Vererbung behandeln wir im folgenden Abschnitt.


13 Man sagt dazu auch, dass die Freundschaftsbeziehung nicht transitiv ist.

899
23 Zusammenfassung und Überblick

kungen auf, die die Aufgaben und Zuständigkeitsbereiche klar voneinander


abgrenzen sollen.

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:

class abgeleitet : public basis


{
// ...
};

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.

Die bei der Vererbung verwendete Zugriffsspezifikation (public, protected, private)


schränkt dabei gegebenenfalls den Zugriff auf die Daten- und Funktionsmember der
Basisklasse ein. Die Details dazu werden in Abschnitt 23.4.3, »Modifikation von
Zugriffsrechten«, ausführlicher diskutiert. Vorerst betrachten wird nur die Verer-
bung mit der gebräuchlichsten Zugriffsspezifikation public.

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'; }
};

class abgeleitet: public basis // abgeleitete Klasse


{
};

int main()
{
basis b; // Instanz der Basisklasse

b.data = 'b';
b.print();

abgeleitet a; // Instanz der abgeleiteten Kindklasse

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'; }
};

class abgeleitet: public basis // abgeleitete Klasse


{
public:
void print() { cout << "abgeleitet: " << data << '\n'; }
};

Bei unverändertem Hauptprogramm ergibt sich jetzt folgende Ausgabe:

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:

abgeleitet a; // Instanz der abgeleiteten Kindklasse


a.data = 'a';
a.basis::print();
a.print();

erhalten Sie das folgende Ergebnis:

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
}

Konsequenterweise können auch die Datenmember der Basisklasse überschrieben


werden:

class basis
{
public:
char data;
void print() { cout << "basis: " << data << '\n'; }
};

class abgeleitet: public basis // abgeleitete Klasse


{
public:
char data;
void print() { cout << "abgeleitet: " << data << '\n'; }
};

Nach dem Überschreiben von Methoden existieren verschiedene Varianten, die


separat aufgerufen werden können. Genauso verhält es sich jetzt für die Datenmem-
ber. Das Attribut data ist jetzt zweimal vorhanden und kann auch unterschiedliche
Werte haben, wie das Beispiel demonstriert:

abgeleitet a; // Instanz der abgeleiteten Kindklasse

23
a.basis::data = 'b';
a.data = 'a';
a.basis::print();
a.print();

Die erwartete Ausgabe, die Sie erhalten, ist folgendermaßen:

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.

Das Überschreiben von Funktionsmembern ist ein typisches Vorgehen, um abgelei-


tete Klassen zu spezialisieren und zu konkretisieren.

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() {}
};

class mutter: public grossmutter


{
public:
void funktion() {}
};

class kind: public mutter


{
public:
void funktion() {}
};

int main()
{
kind k;

k.funktion(); // Methode des Kindes


k.mutter::funktion(); // Methode der Mutter
k.grossmutter::funktion(); // Methode der Grossmutter
}

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:

class abgeleitet: public basis1, public basis2


{
// ...
};

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;
};

class abgeleitet: public basis1, public basis2


{
// ...
};

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;
};

class scanner: public usbdevice


{
};

class drucker: public usbdevice


{
};

class kopierer: public scanner, public drucker


{
};

906
23.3 Vererbung

In der UML-Darstellung sähe die Vererbungshierarchie so aus:

USB-Device

Scanner Drucker

Kopierer

Abbildung 23.1 UML-Darstellung der wiederholten Vererbung

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

Abbildung 23.2 Konkrete Situation bei der wiederholten Vererbung

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;
};

class scanner: virtual public usbdevice


{
};

908
23.3 Vererbung

class drucker: virtual public usbdevice


{
};

class kopierer: public scanner, public drucker


{
};

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:

cout << k.data << '\n';


cout << k.scanner::data << '\n';
cout << k.scanner::data << '\n';

Wie erwartet, greifen alle drei Notationen jetzt natürlich auf dasselbe Datenmember
zu, und die Ausgabe ist damit:

123
123
123

Um die Möglichkeiten der wiederholten Vererbung abschließend noch etwas weiter 23


zu treiben, werden wir nun annehmen, dass parallel zum virtuellen Auftreten der
Basisklasse in der Vererbungshierarchie auch noch gewöhnliche Vererbung auftritt.
Im Beispiel füge ich jetzt noch die Klasse memorystick hinzu, die die Klasse usbdevice
als gewöhnliche Basisklasse hat. Die Klasse multifunktion wird dann zusätzlich von
memorystick abgeleitet und repräsentiert jetzt einen Multifunktionsdrucker, der
kopieren und gescannte Bilder speichern kann. Im Code sieht das so aus:

class usbdevice
{
public:

909
23 Zusammenfassung und Überblick

int data;
};

class scanner: virtual public usbdevice


{
};

class drucker: virtual public usbdevice


{
};

class memorystick:public usbdevice


{

};
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;

m.scanner::data = 1; // Zugriff auf data der virtuellen Basis


m.drucker::data = 1; // wie oben
m.memorystick::data = 2; // Zugriff auf data nicht-virtuelle Basis

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

23.3.3 Virtuelle Funktionen


Objektorientierten Systeme werden durch Wiederverwendung und Verfeinerung
bestehender Klassen weiterentwickelt. Bei der Erstellung einer Klasse hat der Ent-
wickler vielleicht bereits eine Klassenhierarchie im Kopf. Er weiß aber in der Regel
noch nicht, welche Klasse er selbst oder andere Entwickler letztlich von seiner Klasse
ableiten werden.

Um hier im Laufe der Weiterentwicklung alle Möglichkeiten zu haben, müssen Sys-


teme erstellt werden können, die auch mit noch nicht bekannten Erweiterungen
nahtlos zusammenarbeiten. Dies wird durch den Einsatz virtueller Funktionen
ermöglicht.

Um das Vorgehen zu illustrieren, starte ich mit einem kleinen Beispiel:

class basis
{
public:
void print() { cout << "Klasse basis\n"; }
};

void ausgabe( basis & b )


{
b.print();
}

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

erhalten wir erwartungsgemäß die Ausgabe:

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.

class abgeleitet: public basis


{
public:
void print() { cout << "Klasse abgeleitet\n"; }
};

Wenn wir jetzt im Programm eine Instanz der Klasse abgeleitet erzeugen und an die
Funktion ausgabe übergeben,

main( )
{
abgeleitet instanzAbgeleitet;
ausgabe( instanzAbgeleitet );
}

ändert sich gegenüber vorher die Ausgabe nicht:

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

void ausgabe( basis & b )


{
b.print();
}

class abgeleitet: public basis


{
public:
void print( ) { cout << "Klasse abgeleitet\n"; }
};

int main()
{
basis instanzBasis;
ausgabe( instanzBasis );

abgeleitet instanzAbgeleitet;
ausgabe( instanzAbgeleitet );
}

Nach der Ergänzung von virtual ist die Ausgabe jetzt:

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

23.3.4 Virtuelle Destruktoren


Das Problem, dass abgeleitete Klassen bei Aufruf von Methoden als ihre Basisklassen
angesprochen werden, tritt auch bei Aufruf des Destruktors auf. Auch dazu ein Bei-
spiel:

class basis
{
public:
~basis() { cout << " Destruktor von basis\n"; }
};

class abgeleitet: public basis


{
public:
~abgeleitet() { cout << " Destruktor von abgeleitet\n"; }
};

int main()
{
basis *b = new abgeleitet;
delete b;
}

Das Programm erzeugt die folgende Ausgabe:

Destruktor von basis

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:

Destruktor von abgeleitet


Destruktor von basis

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.

23.3.5 Rein virtuelle Funktionen


Bei der Modellierung von Klassen und der Implementierung von Klassen, die als
Basisklassen dienen sollen, werden gelegentlich virtuelle Funktionen erstellt, bei
denen es nicht sinnvoll ist, die eigentliche Methode bereits zu implementieren. Erst
bei weiterer Konkretisierung der Klasse wird es sinnvolle Implementierungen der
virtuellen Methode geben. In solchen Fällen erstellt man die entsprechenden Funkti-
onen als rein virtuelle Funktionen. Rein virtuelle Funktionen haben anstelle einer
Implementierung den Zusatz »=0« in der Klassendeklaration:

class basisAbstrakt // abstrakte Klasse


{
public:
virtual void print() = 0; // rein virtuelle Methode
};
23
Eine solche Deklaration hat zur Folge, dass die Klasse basisAbstrakt nicht instanziiert
werden kann.

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

class basisAbstrakt // abstrakte Klasse


{
public:
virtual void print() = 0; // rein virtuelle Methode
};

void ausgabe( basisAbstrakt &ba )


{
ba.print();
}

class konkret: public basisAbstrakt


{
public:
void print() { cout << "Klasse inst\n"; }
};

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.

23.4 Zugriffsschutz und Vererbung


Bei den Zugriffsmöglichkeiten auf eine Klasse sind bisher nur zwei Varianten disku-
tiert worden: der Zugriff von außen und der Zugriff von innen. Die beiden Zugriffsar-
ten und die Möglichkeit des Zugriffs sind eng mit den Zugriffsspezifikationen public
und private verbunden.

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.

23.4.1 Geschützte Member


So, wie Kinder ein besonderes Verhältnis zu ihren Eltern haben, haben auch abgelei-
tete Klassen ein besonderes Verhältnis zu ihren Basisklassen. Im Vergleich zu nicht-
verwandten Klassen haben sie weitere Zugriffsrechte. Sie haben jedoch nicht die glei-
chen Zugriffsrechte wie die Elternklasse selbst.

Daten- und Funktionsmember, die im protected-Bereich einer Klasse deklariert sind,


bezeichnen wir als geschützte Member. Eine abgeleitete Klasse hat uneingeschränk-
ten Zugriff auf die geschützten Member ihrer Basisklasse. Auf deren private Daten
hat sie jedoch trotz der Ableitungsbeziehung keinen Zugriff. Auf die Daten im öffent-
lichen Bereich kann sie, wie alle anderen Klassen auch, ebenfalls uneingeschränkt
zugreifen. Damit ergibt sich allgemein dieses abgestufte System von Zugriffsrechten:

Bereich Zugriff aus der Zugriff aus einer Zugriff von


Klasse selbst abgeleiteten Klasse außerhalb

private zulässig unzulässig unzulässig

protected zulässig zulässig unzulässig

public zulässig zulässig zulässig


23
Tabelle 23.1 Zugriffsrechte für Klassen

23.4.2 Zugriff auf die Basisklasse


Bei der Vererbung wird für jede geerbte Basisklasse eine Zugriffsspezifikation ange-
geben (public, protected oder private).

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 // ...

};

class kombi : public pkw


{

Durchgriff auf protected und


public von pkw

};

Abbildung 23.3 Darstellung des Zugriffs einer Kindklasse

Generell bestimmt die Zugriffsspezifikation bei der Vererbung, in welche Bereiche


der abgeleiteten Klasse die drei Bereiche der Basisklasse übernommen werden.
Damit bestimmt sich der Zugriff durch die abgeleitete Klasse und insbesondere
deren Verhalten bei weiterer Vererbung. Ich werde jetzt die Varianten kurz betrach-
ten und gegeneinander absetzen.

Ö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
...
...

Cabriolet Kombi SUV Minivan


... ... ... ...
... ... ... ...

Variant T-Modell Touran


... ... ...
... ... ...

Abbildung 23.4 Vererbungshierarchie des Beispiels

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
};

class kombi: public pkw


{
public:
A void test() { pkw *p; p = this; } // ok
};

919
23 Zusammenfassung und Überblick

class variant: public kombi


{
public:
B void test() { pkw *p; p = this; } // ok
};

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.

Die für die öffentliche Vererbung geschilderten Möglichkeiten der Konvertierung


bestehen hier nicht mehr. Für die geschützte Vererbung gibt es auch keine einfache
Beschreibung der modellierten Beziehung. Sie werden der geschützten Vererbung
insgesamt sehr selten begegnen.

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.

23.4.3 Modifikation von Zugriffsrechten


Sind Sie mit dem pauschalen Zugriff auf die Basisklasse nicht zufrieden, haben Sie die
Möglichkeit, zunächst restriktiv (private) zu erben und dann selektiv den Zugriff auf
die geerbten Member zu lockern.

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:

class abgeleitet: private basis


{
};
23
In der abgeleiteten Klasse kann der Zugriff auf die Member dann aber selektiv gelo-
ckert werden:

class abgeleitet: private basis


{
protected:
basis::b;
public:
basis::c;
};

921
23 Zusammenfassung und Überblick

In der abgeleiteten Klasse ist jetzt a privat, b geschützt und c öffentlich14.

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.

23.5 Der Lebenszyklus von Objekten


Objekte werden vor ihrer ersten Verwendung instanziiert und wieder beseitigt, wenn
sie nicht mehr benötigt werden. Die Instanziierung eines Objekts entspricht bei ober-
flächlicher Betrachtung dem Anlegen von Variablen in C. Wie dort gibt es auch für
Objekte drei verschiedene Speicherklassen. Es gibt:

왘 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

for( ...; ...; )


{
punkt p2; // noch 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

punkt p1; // ein globales statisches Objekt

static punkt p2; // ein modulweit bekanntes statisches Objekt

void funktion ()
{
static punkt p3; // ein lokales statisches Objekt

for( /**/; /**/; )


{
static punkt p4; // noch 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:

extern punkt p1;


23
Üblicherweise steht ein solcher extern-Verweis in einer Header-Datei, die von allen
Modulen inkludiert wird, die das Objekt verwenden wollen.

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

// .. // Verwenden des Objekts

delete p; // Beseitigen 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:

punkt a1[10]; // ein globales Array mit 10-Punkt-Objekten

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;

array = new punkt[10];

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

23.5.1 Konstruktion von Objekten


Um sicherzustellen, dass Klassen bei der Instanziierung in einen konsistenten Initial-
zustand gebracht werden, kann eine Klasse mit einem oder mehreren Konstruktoren
ausgestattet werden.

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
};

Den Konstruktor implementieren wir außerhalb der Klasse:

925
23 Zusammenfassung und Überblick

string::string( char* t )
{
len = strlen( t );
txt = new char[len + 1];
strcpy( txt, t );
}

Im Konstruktor wird die Länge der übergebenen Zeichenkette bestimmt und im


Datenmember len gespeichert. Dann wird ein Zeichenpuffer mit der passenden
Größe allokiert, und der übergebene Text wird mit strcpy in diesen Zeichenpuffer
kopiert. Damit ist der String korrekt initialisiert.

Um ein Objekt der Klasse string zu instanziieren, muss jetzt eine Zeichenkette über-
geben werden:

string s1 ("Test"); // verwendet den 1. Konstruktor

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 );
}

Der zweite Konstruktor verwendet die Funktion sprintf der C-Standardbibliothek,


die analog zur Bildschirmausgabe mit printf den auszugebenden Text in eine Zei-
chenkette »ausdruckt«.

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:

string s1( "Test" ); // verwendet 1. Konstruktor


string s2( 123 ); // verwendet 2. Konstruktor

Abschließend erstellen wir noch einen parameterlosen Konstruktor:

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:

string s1( "Test" ); // verwendet 1. Konstruktor 23


string s2( 123 ); // verwendet 2. Konstruktor
string s3; // verwendet 3. Konstruktor

In allen Fällen ist jetzt sichergestellt, dass das Längenfeld (len) und der Textzeiger
(txt) korrekte Initialwerte haben.

Konstruktoren einer Klasse liegen typischerweise im öffentlichen Bereich einer


Klasse, damit sie von jedermann zur Konstruktion von Objekten verwendet werden
können. Die Konstruktoren können aber auch im geschützten oder im privaten
Bereich der Klasse liegen. In diesem Fall können nur Instanzen der Klasse selbst, sta-
tische Methoden der Klasse oder Freunde der Klasse neue Instanzen erzeugen.

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.

23.5.2 Destruktion von Objekten


Die Beseitigung eines Objekts ist in der Regel leichter durchzuführen als seine Kon-
struktion. Für die Beseitigung eines Objekts ist der Destruktor einer Klasse zuständig.
Eine Klasse hat höchstens einen Destruktor. Der Destruktor ist eine parameterlose
Methode ohne Returntyp, die den Namen der Klasse mit einer vorangestellten Tilde
»~« trägt. Bei der Destruktion müssen keine Parameter fließen, da ein Objekt immer
seinen eigenen Zustand kennt und daher weiß, was zu seiner Selbstzerstörung zu tun
ist.

class string
{
private:
int len;
char* txt;
public:
// ... // Konstruktoren
~string(); // Destruktor
};

Häufig benötigen Klassen keinen Destruktor, da keine besonderen Aufräumarbeiten


im Rahmen der Beseitigung der Objekte anfallen. Bei dem Beispiel des Strings ist das
aber nicht der Fall. Hier muss der zur Speicherung des Textes allokierte Zeichenpuf-
fer freigegeben werden. Daher implementieren wir den folgenden 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.

15 Da sie nicht für bestehende Objekte aufgerufen werden.

928
23.5 Der Lebenszyklus von Objekten

Destruktoren können im Gegensatz zu Konstruktoren virtuell sein, siehe auch


Abschnitt 23.3.4. Die Verwendung virtueller Destruktoren ist sogar sinnvoll, wenn
man Objekte auf einer abstrakten Ebene beseitigt und dabei sicherstellen will, dass
individuelle Aufräumarbeiten durchgeführt werden.

23.5.3 Kopieren von Objekten


Zur Laufzeit eines Programms müssen oft implizit Kopien von Objekten erzeugt wer-
den. Das ist z. B. der Fall, wenn ein Objekt als Wert an einer Funktionsschnittstelle
übergeben wird oder wenn ein Objekt einem anderen zugewiesen wird. Zum Erzeu-
gen einer Kopie gibt es ein Standardverhalten, bei dem ein identisches Duplikat des
zu kopierenden Objekts erzeugt wird. Bei vielen Klassen ist das tatsächlich das, was
man benötigt. In einigen Fällen ist dieses Verhalten allerdings unerwünscht und
kann sogar zum Absturz führen. Dies ist insbesondere der Fall, wenn eine Klasse
Daten dynamisch verwaltet.

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:

void tuwas( string par )


{
}
int main()
{
string s1( "Test" );
tuwas( s1 );
}

Betrachtet man den Ablauf Schritt für Schritt, wird die Ursache des Problems schnell
sichtbar. Mit dem Konstruktor

string s1( "Test" );

erzeugen wir ein Objekt, das wie folgt aufgebaut ist:

string s1
len = 4

txt Test

Abbildung 23.5 Aufbau des Objekts s1

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

Abbildung 23.6 Zustand nach Erstellen der identischen Kopie

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

Abbildung 23.7 Situation im Hauptprogramm nach Funktionsaufruf


23

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.

Dazu verwenden Sie den sogenannten Copy-Konstruktor. Der Copy-Konstruktor ist


ein Konstruktor, der als Parameter eine konstante Referenz auf die Klasse erhält, zu
der er gehört. In unserem Beispiel hat er also die folgende Schnittstelle:

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

string( const string& s );

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();
};

string::string( const string & s )


{
len = s.len;
txt = new char[len + 1];
strcpy( txt, s.txt );
}

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

Abbildung 23.8 Unabhängige Objekte durch den Copy-Konstruktor

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();
};

string& string::operator=( const string& s )


{ 23
A if( &s == this )
{
return *this ;
}
B delete[] txt;

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.

An drei Stellen unterscheidet er sich vom Copy-Konstruktor. Im Zuweisungsoperator


wird zuerst geprüft, ob gerade versucht wird, das Objekt sich selbst zuzuweisen (A):

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;

Der Copy-Konstruktor und der Zuweisungsoperator befinden sich üblicherweise im


öffentlichen Bereich der Klasse. Es kann aber auch Objekte geben, von denen man
nicht möchte, dass sie kopiert werden können. Dies könnte der Fall sein, wenn das
Objekt eine einmalige Ressource repräsentiert. In diesem Fall würde man Copy-Kon-
struktor und Zuweisungsoperator im privaten Bereich der Klasse deklarieren, aber
keine Implementierung bereitstellen. Durch diese Methode ergeben alle Versuche,
ein Objekt der Klasse zu kopieren, eine Fehlermeldung.

23.5.4 Instanziierung von Objekten


Es gibt unterschiedliche Methoden, ein Objekt zu instanziieren. Um uns einen Über-
blick zu verschaffen, betrachten wir noch einmal die Klasse string mit folgenden
Konstruktoren17:

class string
{
private:
// ...

17 Achtung, im Vergleich zum vorangegangenen Beispiel sind die Konstruktoren unterschiedlich


nummeriert.

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:

string s1; // verwendet den 1. Konstruktor


string s2( "Test" ); // verwendet den 2. Konstruktor
string s3( 123 ); // verwendet den 3. Konstruktor

Es gibt aber noch weitere Formen der Instanziierung:

string s4 = "Test"; // verwendet den 2. Konstruktor


string s5 = 123; // verwendet den 3. Konstruktor

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

string s6 = string(); // verwendet den 1. Konstruktor 23


string s7 = string( "Test" ); // verwendet den 2. Konstruktor
string s8 = string( 123 ); // verwendet den 3. Konstruktor

oder durch Zuweisung eines bereits konstruierten Objekts instanziieren:

string s9 = s8; // verwendet den Copy-Konstruktor

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:

string *s11 = new string; // verwendet den


// 1. Konstruktor
string *s12 = new string( "Test" ); // verwendet den
// 2. Konstruktor
string *s13 = new string( 123 ); // verwendet den
// 3. Konstruktor
// ...

delete s11;
delete s12;
delete s13;

Arrays von Objekten können auf folgende Weise angelegt werden:

string s14[10]; // verwendet 10-mal den 1. Konstruktor

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:

string s15[3] = { string(), string( "Test" ), string( 123 ) };


// verwendet 1., 2. und 3. Konstruktor

Da in den letzten beiden Fällen Konvertierungen über Konstruktoren bekannt sind,


müssen die Konstruktoren nicht explizit aufgerufen werden:

string s16[3] = { string(), "Test", 123 };


// verwendet 1., 2. und 3. Konstruktor

936
23.5 Der Lebenszyklus von Objekten

Dynamisch werden Arrays über den new-Operator angelegt:

string *s17 = new string[10];


// verwendet 10-mal den 1. Konstruktor

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.

Durch trickreiche Verfahren ist es möglich, Arrays individuell zu initialisieren, das


geht aber nicht ohne Seiteneffekte, daher gehe ich auf diese Verfahren hier nicht ein.

Ü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.

Dynamisch erstellte Arrays werden mit dem delete[]-Operator wieder freigegeben:

delete[] s17;

23.5.5 Explizite und implizite Verwendung von Konstruktoren


Objekte werden immer unter Verwendung von Konstruktoren instanziiert. Zur Kon-
kretisierung dessen, was Sie bereits im letzten Abschnitt erfahren haben, sehen wir
uns ein Beispiel an.

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

Sie können übrigens auch die Grunddatentypen explizit instanziieren, z. B. so:

int i = int (7);

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.

Der Konstruktor der Beispielklasse könnte damit auch so aussehen:

class klasse
{
private:
int wert;
public:
klasse( int w ): wert(w) {}
};

18 wie auch hier in Abschnitt 23.2.3, »Konstante Member«

938
23.5 Der Lebenszyklus von Objekten

23.5.6 Initialisierung eingelagerter Objekte


Klassen sind im Rahmen ihrer Konstruktion dazu verpflichtet, alle als Datenmember
eingelagerten Objekte zu initialisieren. Haben die eingelagerten Objekte keine eigens
erstellten Konstruktoren oder einen explizit erstellten parameterlosen Konstruktor,
ist zu ihrer Initialisierung nichts Besonderes zu tun, da die Initialisierung entweder
mit dem explizit erstellten oder dem automatisch erstellten parameterlosen Kon-
struktor erfolgt. Hat eine Klasse aber keinen parameterlosen Konstruktor, muss
einer der vorhandenen Konstruktoren über eine geeignete Parametrierung angesto-
ßen werden. Hier sehen Sie dazu ein Beispiel:

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

Die eingelagerten Klassen werden initialisiert, indem geeignete Parameter an ihre


Konstruktoren weitergeleitet werden. Zur Identifikation der eingelagerten Objekte
dient deren Membername. Konkret sieht das z. B. wie folgt aus:

lager::lager( double dd, char cc ) : eingelagertA( dd ), eingelagertB( cc )


{
// ... Initialisierung von lager
}

Entsprechend der Parametersignatur werden dann geeignete Konstruktoren zur Ini-


tialisierung der eingelagerten Klassen aufgerufen. Der Aufruf erfolgt dabei vor dem
Eintritt in den Konstruktor der umschließenden Klasse, sodass die eingelagerten
Objekte im Inneren des eigentlichen Konstruktors bereits initialisiert sind und ver-
wendet werden können.

Da es in unserem Beispiel für die Klasse bbb einen parameterlosen Konstruktor gibt,
kann auf eine explizite Initialisierung von eingelagertB verzichtet werden:

lager::lager( int xx, int yy ) : eingelagertA( xx, yy )


{
// ... Initialisierung von lager
}

Die zur Initialisierung der eingelagerten Klasse verwendeten Parameter müssen


nicht von außen kommen, sie können auch an Ort und Stelle erzeugt und berechnet
werden:

lager::lager( int zz ) : eingelagertA( zz + 1, 17 ), eingelagertB( 'a' )


{
// ... Initialisierung von lager
}

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:

lager::lager( double dd, char cc, char *t, int zz ) : eingelagertA( dd ),


eingelagertB( cc )
{
// ... Initialisierung von lager
// Verwendung von dd, cc, t und zz
}

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 );
};

lager::lager( double dd, char cc ) : eingelagertA( dd ),


eingelagertB( cc )
{
int i;
for( i = 0; i < 100; i++ )
{
// ... weitere Initialisierung von arrayB[i]
} 23
}

23.5.7 Initialisierung von Basisklassen


Virtuelle Basisklassen spielen eine Sonderrolle, da sie nur einmal in der Vererbungs-
hierarchie eines Objekts vorkommen und auch nur einmal initialisiert werden dür-
fen. Die virtuelle Basis wird daher nur an der zuletzt abgeleiteten Klasse initialisiert.
Alle vorangegangenen Initialisierungen in der Vererbungshierarchie werden igno-
riert.

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:

class scanner: public virtual usbdevice


{
public:
scanner() : usbdevice( "scanner" ) {
cout << "Konstruktor von scanner\n";
}
};

class printer: public virtual usbdevice


{
public:
printer() : usbdevice( "printer" ) {
cout << "Konstruktor von printer\n";
}
};

Beide Klassen initialisieren ihre virtuelle Basisklasse usbdevice unter Nennung ihres
eigenen Namens.

Abschließend wird noch die Klasse kopierer von scanner und printer abgeleitet:

class kopierer: public scanner, public printer


{
public:
kopierer() : usbdevice( "kopierer" ) {
cout << "Konstruktor von kopierer\n"; }
};

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';
}

erhalten wir folgende Ausgabe:

usbdevice initialisiert durch scanner


Konstruktor von scanner

usbdevice initialisiert durch printer


Konstruktor von printer

usbdevice initialisiert durch kopierer


Konstruktor von scanner
Konstruktor von printer
Konstruktor von kopierer

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.

23.6 Typüberprüfung und Typumwandlung


Die Überprüfung und Umwandlung von Datentypen ist manchmal formal oder auch
inhaltlich notwendig. Ich stelle Ihnen die Möglichkeiten im Folgenden kurz vor.

23.6.1 Dynamische Typüberprüfungen


Im Abschnitt über virtuelle Funktionen haben Sie gesehen, dass sich der wirkliche
Typ einer Klasse oft erst zur Laufzeit erkennen lässt. Das liegt daran, dass die strenge
Typüberprüfung bei der Vererbung aufgeweicht werden muss. Wenn wir an einer
Schnittstelle ein Objekt von einem bestimmten Typ erwarten, kann zur Laufzeit auch
ein Objekt von einer abgeleiteten Klasse übergeben werden. Wenn wir an einer
Schnittstelle einen Hund erwarten, dann kann auch ein Dackel kommen, ein Dackel
ist ja ein Hund. Sie haben schon gesehen, wie Sie das Verhalten abgeleiteter Klassen
mit virtuellen Funktionen modellieren können. In den meisten Fällen ist das auch
ausreichend, sodass die Frage, um welches Objekt es sich gerade explizit handelt,
meist gar nicht gestellt werden muss. Fälle, in denen man die Information gerne ver-
wendet, sind z. B. Prüfdrucke oder Tests. Dann möchte man z. B. gerne wissen, von
welchem Typ ein Objekt ist oder ob zwei Objekte den gleichen Typ haben. Wir wollen
uns auch das an einem Beispiel ansehen. Dazu legen wir zwei rudimentäre Klassen
an, zwischen denen eine Vererbungsbeziehung besteht.

class basis
{
virtual void virtFunk() {}
};

class abgeleitet: public basis


{
};

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:

int typvergeich( basis& b1, basis& b2 )


{
return typeid ( b1 ) == typeid ( b2 );
}

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;

if( typvergeich( instanzBasis, instanzAbgeleitet ) )


{
23
// Die Klassen haben den gleichen Typ
}
else
{
// Die Klassen haben unterschiedliche Typen
}
}

Ü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

cout << typeid( instanzAbgeleitet ).name();

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.

23.7 Typumwandlung in C++


Durch den Cast-Operator können Sie eine beliebige Typumwandlung (Cast) erzwin-
gen, wann immer Sie es für notwendig halten. Solche Typumwandlungen sind
manchmal aus rein formalen Gründen notwendig, z. B. wenn Sie Datentypen an
einer Schnittstelle anpassen müssen. Manchmal sind sie auch inhaltlich notwendig.
Aber generell sollten Sie bei der Typumwandlung große Vorsicht walten lassen, da
mit einer Typumwandlung auch schwere Fehler in den Code eingebracht werden
können.

C++ hat im Vergleich zu C einige Verfeinerungen in die Typumwandlung integriert,


die es dem Entwickler erlauben, seine Absichten genauer zu spezifizieren. Durch die
feineren Angaben hat der Compiler bessere Möglichkeiten, zu prüfen und zu warnen.
Das Risiko, das von Typumwandlungen ausgeht, lässt sich damit verringern.

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:

Das oben dargestellte Beispiel würde damit so aussehen:

x = static_cast< float > ( a ) / b;

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:

float zahl = 123.456f;


unsigned char * byte;
byte = &zahl;

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:

float zahl = 123.456f;


unsigned char * byte;

byte = reinterpret_cast< unsigned char* > ( &zahl );

printf( "%d %d %d %d \n", byte[0], byte[1], byte[2], byte[3] );

949
23 Zusammenfassung und Überblick

Der Reinterpret Cast entspricht dem klassischen Cast. Wir könnten also auch
schreiben:

byte = ( unsigned char* ) &zahl;

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:

void funktion( char *str )


{
cout << str << '\n';
}

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:

funktion( const_cast< char* >( s ) );

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:

왘 Unterstützung von Strings


Anstelle von Zeichenketten fester Länge oder selbst entwickelten Klassen möchte
man gerne mit Strings arbeiten, die das Speichermanagement selbst übernehmen
und sinnvolle Funktionen für die Operatoren liefern, wie etwa die Verkettung von
Strings, und die insgesamt den Umfang der C-Funktionen für Zeichenketten sinn-
voll auf diese Strings abbilden.
왘 Unterstützung von Arrays variabler Länge
Anstatt explizit den Speicher für dynamische Arrays zu allokieren und im Verlauf
zu verwalten, wünschen sich die meisten Entwickler Arrays, denen sie dynamisch
Felder anfügen können, ohne den Speicher selbst explizit verwalten zu müssen.
24

왘 Unterstützung von Listen und anderen bekannten Datenstrukturen


Man möchte Listen – egal, ob einfach oder doppelt verkettet – erstellen und ver-
wenden können, ohne die Verkettungsoperationen oder das Speichermanage-
ment immer wieder neu schreiben zu müssen. Das Gleiche gilt für die anderen
Datenstrukturen, die Sie im Verlauf des Buches kennengelernt haben.
왘 Unterstützung assoziativer Arrays
Man möchte assoziativ, z. B. über Namen und nicht nur über Indizes, auf Daten
zugreifen können, ohne den internen Aufbau der Datenstruktur selbst erstellen zu
müssen.

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.

In C++ werden solche Funktionen durch die C++-Standardbibliothek zur Verfügung


gestellt. Klassen und Funktionen für die oben genannten Bereiche werde ich Ihnen
im Folgenden als Elemente der C++-Standardbibliothek vorstellen und deren Ver-
wendung demonstrieren.

Über die C++-Standardbibliothek sind selbst umfangreiche Bücher geschrieben wor-


den. Der folgende Teil ersetzt keines dieser Werke, sondern gibt Ihnen eine Starthilfe
auf dem weiteren Weg der C++-Entwicklung.

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.

24.1 Generische Klassen (Templates)


Sie haben mittlerweile das Handwerkszeug, um umfassende Datenstrukturen und
Algorithmen in objektorientierter Programmierung umsetzen zu können.

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.

A void tausche( int& a, int& b ) {


int temp = a;
a = b;
b = temp;
}

954
24.1 Generische Klassen (Templates)

B void tausche( double& a, double& b ) {


double temp = a;
a = b;
b = temp;
}

C void tausche( punkt& a, punkt& b ) {


punkt temp = a;
a = b;
b = temp;
}

Listing 24.1 Unterschiedliche Varianten von »tausch«

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.

template <typename T> 24

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

A template <typename T>


B void tausche( T& a, T& b )
{
C T temp = a;
a = b;
b = temp;
}

Listing 24.2 Umsetzung von »tausche« mit einer Schablone

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.

Um eine solche Generierung anzustoßen, verwenden wir unser neues Template:

template <typename T>


void tausche( T& a, T& b )
{
T temp = a;
a = b;
b = temp;
}

void main()
{
double wert1 = 1.0, wert2 = 2.0;
A tausche<double>( wert1, wert2 );
}

Listing 24.3 Verwendung der Schablone von »tausche«

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:

punkt p1; punkt p2;


tausche<punkt>( p1, p2 );

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; }
};

int floatStack::push( int element )


24
{
if( top < 100 )
{
stck[top] = element;
top++;
return 1;
}
return 0;
}

957
24 Die C++-Standardbibliothek und Ergänzung

float floatStack::pop()
{
if( top > 0 )
top--;
return stck[top];
}

Listing 24.4 Klasse »floatStack« zur Verwendung mit int-Werten

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:

template<typename T, int SIZE> class stack


{
private:
T stck[SIZE];
int top;
public:
stack() { top = 0; }
int push( T element );
T pop();
int isEmpty(){ return top == 0; }
};

template<typename T, int SIZE> int stack<T, SIZE>::push( T element )


{
if( top < SIZE )
{
stck[top] = element;
top++;
return 1;
}
return 0;
}

template<typename T, int SIZE> T stack<T, SIZE>::pop()


{

958
24.1 Generische Klassen (Templates)

if( top > 0 )


top--;
return stck[top];
}

Listing 24.5 Klasse »stack« als Template

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:

template<typename T, int SIZE> T stack<T, SIZE>::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"

und dann im Hauptprogramm wie folgt verwenden:

datum dat;

A stack<datum, 10 >dstack;

B for( int i = 1; i <= 5; i++ )


{
dat.set( i, 1, 2015 );
C dstack.push( dat ); 24

cout << "push: " << dat << '\n';


}

D while( !dstack.isEmpty() )
{
E dat = dstack.pop();
cout << "pop: " << dat << '\n';
}

Listing 24.6 Verwendung des generischen Stacks für Daten

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;

for( int i = 1; i <= 5; i++ )


{
istack.push( i );
cout << "push: " << i << '\n';
}

while( !istack.isEmpty() )
{
int i = istack.pop();
cout << "pop: " << i << '\n';
}

Listing 24.7 Verwendung des generischen Stacks für int-Werte

Die Ausgabe ist dann nicht mehr überraschend:

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

24.2 Ausnahmebehandlung (Exceptions)


Im Verlauf dieses Buches habe ich die Fehlerbehandlung absichtlich in den Hinter-
grund gerückt. Nur an wenigen Stellen findet eine Fehlerprüfung statt, oder es wird
darauf verwiesen, welche Maßnahmen dort eigentlich notwendig wären.

Dieses Vorgehen ist bewusst gewählt, um Beispielcode übersichtlich zu halten. Die


Erkennung und Behandlung von Ausnahmefällen lenkt leicht vom eigentlichen
Inhalt des Codes ab und macht ihn schnell »unlesbar«.

Bei realen Programmsystemen gilt das natürlich auch, nur kann die Fehlerbehand-
lung hier nicht einfach ignoriert werden, wie wir es hier getan haben.

C++ bietet ein Verfahren, um Ausnahmen systematisch und konsistent zu be-


handeln.

Dabei teilt sich die Ausnahmebehandlung in die Bereiche Ausnahmeerkennung und


Ausnahmebehandlung.

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.

Wir haben bisher Fehlersituationen über bestimmte Fehlercodes als Rückgabewerte


der Funktion mitgeteilt. Dieses Verfahren stößt schnell an seine Grenzen, wenn Feh-
lersituationen über mehrere Funktionen hinweg übermittelt werden müssen. Inner-
halb der Aufrufhierarchie wuchern dann die unterschiedlichen Fehlerbehandlungen
sehr schnell so um den Programmcode herum, dass der eigentliche Zweck des Codes
kaum noch zu erkennen ist.

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.

Das zentrale Element der Ausnahmefallbehandlung ist der try-catch-Block. Im try-


Block ruft das Programm eine Funktion »versuchsweise« auf. Bei ungestörtem Pro-
grammfluss wird der Code, der dem Aufruf im try-Block folgt, auch normal ausge- 24
führt.

Die gesamte Fehlerbehandlung ist in den sogenannten catch-Block ausgelagert.


Wenn die aufgerufene Funktion mit dem Befehl throw eine Ausnahme wirft, wird der
Programmablauf in der Funktion und im try-Block sofort abgebrochen. Innerhalb
des catch-Blocks wird die Ausnahme gefangen und bearbeitet:

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
}

Ich will Ihnen die Ausnahmebehandlung am Beispiel einer Funktion eingabe


demonstrieren, die mit throw eine Ausnahme wirft, wenn der Benutzer nicht den
geforderten Wertebereich der Eingabe einhält.

int eingabe()
{
A int i;
B cout << "Bitte eine positive Zahl eingeben: ";
cin >> i;
C
D if( i < 0 )
throw 'N';
E return i;
}

Listing 24.8 Funktion »eingabe« wirft Ausnahme im Fehlerfall

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();

C cout << "Erfolg: " << i << '\n';


D }

964
24.2 Ausnahmebehandlung (Exceptions)

E catch( char ausnahme )


F {
if( ausnahme == 'N' )
cout << "Ausnahme 'Negativer Wert' abgefangen\n";
G else
cout << "Unbekannte Ausnahme abgefangen\n";
}
return 0;
}

Listing 24.9 Einfaches Beispiel der Ausnahmebehandlung

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:

Bitte eine positive Zahl eingeben: 42


Erfolg: 42

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).

Das Ergebnis sieht dann z. B. so aus:


24
Bitte eine positive Zahl eingeben: –9
Ausnahme 'Negativer Wert' abgefangen

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

Ausnahme zur nächsthöheren Funktion in der Aufrufhierarchie weitergeworfen.


Wird sie nicht gefangen, führt das letztlich zum Programmabbruch.

Generell können zum Werfen von Ausnahmen alle Datentypen herangezogen wer-
den. Es bietet sich allerdings an, dazu Instanzen geeigneter Klassen zu verwenden.

Genau zu diesem Zweck definiert die C++-Standardbibliothek eine Hierarchie von


Ausnahmeklassen. Diese Hierarchie können Sie über die folgende Header-Datei
exception einbinden und 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( );
}

Listing 24.10 Deklaration der Klasse »exception«

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

logic_error runtime_error bad_alloc

invalid_argument underflow_error bad_typeid

length_error overflow_error bad_cast

out_of_range range_error bad_exception

domain_error

Abbildung 24.1 Die Klassenhierarchie von exception

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();

cout << "Erfolg: " << i << '\n';


}

967
24 Die C++-Standardbibliothek und Ergänzung

B catch( out_of_range ausnahme )


{
C cout << "Ausnahme 'Out of range' abgefangen: "
<< ausnahme.what() << '\n';
}
D catch( exception ausnahme )
{
cout << "Unbekannte Ausnahme abgefangen: " << ausnahme.what() << '\n';
}

Listing 24.11 Überarbeitetes Beispiel unter Verwendung der exception-Klasse

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:

void test1( int i )


{
cout << "Aufruf von test2(" << i << ")\n";
test2( i );
}

void test2( int i )


{
cout << "Aufruf von test3(" << i << ")\n";

968
24.2 Ausnahmebehandlung (Exceptions)

test3( i );
}

Listing 24.12 Die Funktionen »test1« und »test2«

Die Funktion test3 wirft nun eine Ausnahme. Sie verwendet dazu zwei unterschied-
liche Klassen aus der C++-exception-Klassenhierarchie:

void test3( int i )


{
switch( i % 3 ){
case 0:
return;
case 1:
cout << "AUSNAHME1 wird geworfen\n";
throw runtime_error( "AUSNAHME1" );
case 2:
cout << "AUSNAHME2 wird geworfen\n";
throw exception( "AUSNAHME2" );
}
}

Listing 24.13 Die Funktion »test3«

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';
}
}
}

Listing 24.14 Das Hauptprogramm

969
24 Die C++-Standardbibliothek und Ergänzung

Der Ablauf im Hauptprogramm ist nun wie folgt:

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:

int main() void test1( int i )


{ {
int i; cout << "Aufruf von test2(" << i << ")\n";
for( i = 0; i < 4; i++ ) test2( i );
} void test2( int i )
{ {
try{ cout << "Aufruf von test3(" << i << ")
cout << "Aufruf von test1(" << i << ")\n"; test3( i );
}
test1( i );
void test3( int i )
cout << "Kein Ausnahmefall aufgetreten\n"; {
switch( i % 3 ){
case 0:
} return;
catch( exception e ) case 1:
{ cout << "AUSNAHME1 wird geworfen\n";
cout << "main faengt: " << e.what() << '\n'; throw runtime_error( "AUSNAHME1" );
class runtime_error : public exception case 2:
}
{ cout << "AUSNAHME2 wird geworfen\n";
} throw exception( "AUSNAHME2" );
}
} }; }

class exception
{

};

Abbildung 24.2 Kontrollfluss bei der Ausnahmebehandlung

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.

Die vom Programm ausgegebenen Prüfdrucke bestätigen das:

Aufruf von test1(0)


Aufruf von test2(0)
Aufruf von test3(0)
Kein Ausnahmefall aufgetreten

970
24.2 Ausnahmebehandlung (Exceptions)

Aufruf von test1(1)


Aufruf von test2(1)
Aufruf von test3(1)
AUSNAHME1 wird geworfen
main faengt: AUSNAHME1
Aufruf von test1(2)
Aufruf von test2(2)
Aufruf von test3(2)
AUSNAHME2 wird geworfen
main faengt: AUSNAHME2
Aufruf von test1(3)
Aufruf von test2(3)
Aufruf von test3(3)
Kein Ausnahmefall aufgetreten

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.

In den Unterprogrammen gegebenenfalls dynamisch angelegte Objekte werden hier


natürlich nicht automatisch beseitigt.

Durch das Exception-Handling wird in dem Programm ein zweiter Kontrollfluss


erzeugt, wie Sie in der Abbildung erkennen können. Die Abkürzungen, die der Kon-
trollfluss in dieser Ebene nimmt, sind nicht immer leicht nachzuvollziehen.

Sie sollten die Ausnahmebehandlung immer nur zur gezielten Behandlung von Aus-
nahmefällen verwenden und nicht versuchen, hier weiteren Programmablauf unter-
zubringen.

In dem gerade gezeigten Beispiel wurden die Ausnahmen nur im Hauptprogramm


gefangen. Generell hat natürlich jede Funktion in der Hierarchie die Möglichkeit, 24
Ausnahmen zu fangen, auszuwerten und zu behandeln. Ich habe hier noch eine
Modifikation der Funktion test2 als Beispiel, die einen Teil der Ausnahmen selbst
fängt:

void test2( int i )


{
cout << "Aufruf von test3(" << i << ")\n";
try{
test3( i );
}

971
24 Die C++-Standardbibliothek und Ergänzung

catch( runtime_error e )
{
A cout << "test2 faengt und wirft weiter: " << e.what() << '\n';
throw;
B }
}

Listing 24.15 Modifizierte Funktion »test2«

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:

Aufruf von test1(0)


Aufruf von test2(0)
Aufruf von test3(0)
Kein Ausnahmefall aufgetreten
Aufruf von test1(1)
Aufruf von test2(1)
Aufruf von test3(1)
AUSNAHME1 wird geworfen
test2 faengt und wirft weiter: AUSNAHME1
main faengt: AUSNAHME1
Aufruf von test1(2)
Aufruf von test2(2)
Aufruf von test3(2)
AUSNAHME2 wird geworfen
main faengt: AUSNAHME2
Aufruf von test1(3)
Aufruf von test2(3)
Aufruf von test3(3)
Kein Ausnahmefall aufgetreten

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.3 Die C++-Standardbibliothek


Die C++-Standardbibliothek stellt ihre Klassen fast durchweg in generischen Klassen
zur Verfügung, die ich Ihnen gerade vorgestellt habe. Dazu wirft sie an definierten
Stellen Ausnahmen, deren Behandlung Sie jetzt ebenfalls kennen. Damit haben Sie
das gesamte Handwerkszeug, um mit der Verwendung der C++-Standardbibliothek
zu starten.

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.

Bei einem Array sieht das z. B. so aus:

int i;

for( i = 0; i < 100; i++)


array[i];

In einem Array können Sie sich aber auch mit einem Zeiger bewegen:

int *p;

for(p = array; p < array + 100; p++)


p[i];

In einer Liste bewegen Sie sich immer mit einem Zeiger:

listenelement *p; 24

for(p = listenanker; p != 0; p->next)


p-> //... = // ...

In den Beispielen fungieren i und p als Mittel, um sich durch die Datenstruktur zu
bewegen.

Die Elemente der C++-Standardbibliothek, um sich durch die Datenstrukturen zu


bewegen, werden Iteratoren genannt.

973
24 Die C++-Standardbibliothek und Ergänzung

Die C++-Standardbibliothek versucht, eine möglichst große Unabhängigkeit von den


verwendeten Datentypen zu erreichen, und verwendet daher für den Zugriff die
sogenannten Containeriteratoren.
Ich stelle Ihnen in diesem Abschnitt die Iteratoren kurz vor. Da wir hier noch nicht
mit konkreten Containern arbeiten, bleibt die Betrachtung notgedrungen theore-
tisch. Im Zweifel gehen Sie weiter zum folgenden Abschnitt über Strings und kom-
men später noch einmal hierher zurück.
Iteratoren haben die folgenden Eigenschaften:
왘 Der Iterator kann auf das erste Element einer Struktur gesetzt werden.
왘 Der Iterator kann auf das nächste Element einer Struktur weitergerückt werden.
왘 Es kann geprüft werden, ob der Iterator am Ende einer Struktur angekommen ist.
왘 Über den Iterator kann auf die in der Struktur gespeicherten Objekte zugegriffen
werden.

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:

왘 Der bidirektionale Iterator kann sich vorwärts- und rückwärtsbewegen und


erlaubt lesenden und schreibenden Zugriff. Ein bidirektionaler Iterator ist damit
mit einem Zeiger in einer doppelt verketteten Liste zu vergleichen, nur einfacher
zu handhaben.
왘 Der Random-Access-Iterator kann sich frei bewegen und ermöglicht wahlfreien
lesenden und schreibenden Zugriff. Er ist vergleichbar mit einem Index oder Zei-
ger in einem Array.

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

Random-Access-Iteratoren (p, q) können wie Zeiger in einem Array verwendet wer-


den. Zusätzlich zu dem, was bidirektionale Iteratoren können, kann man mit ihnen
noch:

왘 einen Iterator um einen Wert erhöhen oder verringern (p+=n, p-=n)


왘 mit Iteratoren wie mit Zeigern rechnen (q = p + n)
왘 über einen Iterator wahlfrei zugreifen (p[n])
왘 Iteratoren der Größe nach vergleichen (p<q, p<=q, p>q, p>=q)

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

Abbildung 24.3 Festlegung eines Bereichs mit Iteratoren

Für einen so festgelegten Bereich wird dann die Notation (iterator1, iterator2) ver-
wendet.

Um vollständig durch einen Bereich zu iterieren, verwendet man die Containerfunk-


tionen begin und end bzw. rbegin und rend. Diese liefern dann die Bereichsgrenzen
24
für eine Vorwärtsiteration (begin, end) bzw. Rückwärtsiteration (rbegin, rend) durch
den Container.

begin end

Container

rend rbegin

Abbildung 24.4 Vorwärts- und Rückwärtsiteration

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!

Die Ausführungen zu den Iteratoren werden Ihnen schnell verständlich werden,


wenn Sie ihren praktischen Einsatz bei den Strings sehen, daher werden wir jetzt ein-
fach dort starten.

24.5 Strings (string)


Die Klasse string dient zur einfachen und umfassenden Verarbeitung von Zeichen-
ketten. Der Buffer, in dem die Zeichen verwaltet werden, ist vor dem Benutzer ver-
borgen. Der Zugriff und die Manipulation der Zeichenketten erfolgen über
Memberfunktionen und Operatoren. Das Speichermanagement wird vollständig
über die Klasse abgewickelt, sodass der Benutzer selbst keinen Speicher allokieren
oder freigeben muss. Dadurch wird die Arbeit mit Zeichenketten erheblich erleich-
tert. Da die Manipulation der Zeichenketten entsprechend durch die Memberfunkti-
onen erfolgen muss, hat die Klasse eine umfangreiche Schnittstelle, deren wichtigste
Elemente Sie im Folgenden kennenlernen werden.

Um die Klasse string verwenden zu können, binden Sie die entsprechende Header-
Datei sowie den Namespace std ein:

#include <string>
using namespace std;

Dadurch werden die erforderlichen Deklarationen geladen und in den eigenen


Namensraum importiert.

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:

string s1 ("String1"); // Inhalt aus Puffer kopiert


// String1
string s2 = "String2"; // Inhalt aus Puffer kopiert
// String2

976
24.5 Strings (string)

char text[] = "012345";


string s3 = text; // Inhalt aus Puffer kopiert
// 012345

string s4 ("012345", 3); // die ersten drei Zeichen


// 012

string s5 ("012345", 2, 4); // vier Zeichen ab Postion 2


// 2345

string s6 (5, '*'); // fünf Wiederholungen des char '*'


// *****

Wie bei den schon bekannten Zeichenketten erfolgt die Zählung der Positionen
immer ab 0.

Zeichenketten können natürlich auch aus bereits bestehenden Instanzen gebildet


werden:

string s7 (s1) ; // Copy-Konstruktor


// String1
string s8 (s3, 3); // alles ab Zeichen 3
// 345
string s9 (s3, 2, 4); // vier Zeichen ab Position
// 2345

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.

24.5.1 Ein- und Ausgabe 24


Strings können mit den Operatoren >> von cin gelesen oder mit << auf cout geschrie-
ben werden. Die entsprechenden überladenen Operatoren stehen bereit:

string s;
cout << "Eingabe: ";
cin >> s;
cout << "Ausgabe: " << s << '\n';

977
24 Die C++-Standardbibliothek und Ergänzung

Der Code ergibt z. B. das folgende Ergebnis:

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;

Der Rückgabetyp der size-Funktion ist string::size_type. Auch dieser Datentyp


kann direkt für die Ausgabe verwendet werden, und Sie erhalten:

Laenge von: "C++-Standardbibliothek" ist 22

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");

B char c = s[6]; // c = 'g'


C int l = s.size(); // Laenge ermitteln, l = 7
D for (int i = 0; i< l; i++)
E s[i] = c;

F cout << s << '\n';

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)«.

Innerhalb der C++-Standardbibliothek wird die Fehlerbehandlung typischerweise


über Exceptions abgewickelt. Wenn die verwendeten Klassen einen Fehlerzustand
erkennen, dann werfen sie eine entsprechende Ausnahme. Es ist dann die Aufgabe 24
des Benutzers der Klasse, diese Ausnahme zu fangen, zu analysieren und geeignete
Maßnahmen einzuleiten.

In der Dokumentation der verwendeten Klassen sind Informationen enthalten, mit


welchen Ausnahmen bei Verwendung gerechnet werden muss.

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:

for( it = s.begin(); it != s.end(); it++ )


*it = 'z';

Der Stringiterator ist ein Random-Access-Iterator. Sie können mit ihm wahlfrei auf
alle Zeichen des Strings zugreifen. Auch dazu einige Beispiele:

string s = "123456789"; // der Ausgangsstring

string::iterator it; // Anlegen des Iterators, Position 0

it = s.begin() + 3; // Iterator steht auf dem 4. Zeichen des Strings


*it = *( it + 2 ); // Ergebnis s = 123656789

it += 2; // Iterator steht auf dem


// 6. Zeichen des Strings
it[1] = it[2]; // Ergebnis s = 123656889

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)

string s = "abcdefgh"; // der Ausgangsstring

string::reverse_iterator rit; // Anlegen des Iterators

for( rit = s.rbegin(); rit != s.rend(); rit++ )


*rit = 'z';

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

Abbildung 24.5 Die Iteratoren in einem String

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;

Auch mit der Funktion assign lassen sich Wertzuweisungen durchführen:

string s1 = "abcdefgh";
string s2;

s2.assign( s1 ); // wie Zuweisung mit =

981
24 Die C++-Standardbibliothek und Ergänzung

s2.assign( s1, 1, 3 ); // weise ab Pos 1 drei Zeichen zu


s2.assign( "xyz" ); // wie Zuweisung mit =
s2.assign( "xyz", 2 ); // Zuweisung der ersten zwei Zeichen
s2.assign( "xyz", 0, 2 ); // Zuweisung von zwei Zeichen ab Pos 0

s2.assign( 5, '*' ); // Zuweisung fünfmal '*'

Bei assign kann ein Stringbereich auch über Iteratoren gewählt werden:

string s1 = "abcdefgh";
string s2;

s2.assign( s1.begin() + 2, s1.end() );

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"

Für komplexere Anfügungen gibt es die append-Funktion:

string s1 = "abcdefgh";
string s2 = "ijklmno";

s2.append( s1 ); // wie s1 += s2
s2.append( s1, 1, 3 ); // fuege ab Pos 1 drei Zeichen an

s2.append( "xyz" ); // wie s1 += "xyz"


s2.append( "xyz", 2 ); // fuege die ersten zwei Zeichen
// von "xyz" an

s2.append( "xyz", 0, 2 ); // fuege ab Pos 0 zwei Zeichen


// von "xyz" an

982
24.5 Strings (string)

s2.append( 5, '*' ); // fuege fünfmal '*' an

s2.append( s1.begin() + 2, s1.end() ); // Anfuegen eines Teils von s1

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, '*' );

s2.insert( s2.begin() + 3, s1.begin() + 2, s1.end() );

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:

s2.replace( s2.begin() + 3, s2.begin() + 5,


s1.begin() + 2, s1.end() );

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.

Um den Inhalt zweier Strings zu tauschen, gibt es die Funktion swap:

string s1 = "abcdefgh";
string s2 = "ijklmno";

s1.swap( s2 );

Anders, als wir es z. B. in Abschnitt 24.1, »Generische Klassen (Templates)«, gemacht


haben, verwendet die swap-Funktion der C++-Standardbibliothek keinen Zwischen-
speicher. Das ist möglich, da die Funktion nur die internen Zeiger der Buffer tauscht
und nicht die Inhalte der Strings umkopiert. Deshalb sollten Sie die swap-Funktion
des Strings aus Effizienzgründen anderen Methoden mit Zwischenspeicherung vor-
ziehen.

Um Bereiche aus einem String zu löschen, wird die erase-Funktion genutzt:

string s = "abcdefghi";

s.erase( 2, 3 ); // loesche ab Position 2 drei Zeichen


s.erase( 7 ); // loesche alles ab dem 7. Zeichen

984
24.5 Strings (string)

s.erase(); // loesche alles


s.erase( s.begin() ); // loesche das erste Zeichen
s.erase( s.begin() + 1, s.end() – 1 );
// loesche alles bis auf das erste und
// letzte Zeichen

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 ) );

Hier passiert Folgendes:

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";

s1.append( s2 ).replace( 3, s3.size(), s3 );

985
24 Die C++-Standardbibliothek und Ergänzung

In diesem Beispiel passiert dies:

1. Zunächst wird an s1 der String s2 angehängt, es ergibt sich s1= "abcdefghijkl".


2. Der Rückgabewert der append-Funktion ist eine Referenz auf den String s1, sodass
die replace-Funktion für diesen String s1 aufgerufen wird.
3. Die replace-Funktion arbeitet nun auf s1 und ersetzt von dessen Position 3 an
sechs Zeichen mit dem Inhalt von s3, was zu dem Ergebnis s1= "abc123456jkl"
führt.

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:

string s1 = ""; //...


string s2 = ""; //...

if( s1 == s2 )
// Aktion bei Gleichheit;
if( s1 != s2 )
// Aktion bei Ungleichheit;

Bei den 0-terminierten Zeichenketten in C war dieser Vergleich ungeeignet, da dort


nicht der Inhalt der Zeichenketten verglichen wurde, sondern die Speicheradressen
der Zeiger.

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:

s1.compare( s2 ); // vergleiche s1 mit s2


s1.compare( 1, 5, s2 ); // vergleiche Bereich aus s1 mit s2

s1.compare( 1, 5, s2, 2, 3 ); // vergleiche Bereich aus s1


// mit Bereich aus s2

986
24.5 Strings (string)

s1.compare( "asdfg" ); // vergleiche s1 mit "asdfg"


s1.compare( 1, 5, "asdfg" ); // vergleiche Bereich aus s1
// mit "asdfg"

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.

Als Erstes demonstriere ich Ihnen die find-Funktion:

string s1 = "abcdefg";
string s2 = "de";
s1.find( s2 ); // suche s2 in s1
s1.find( s2, 2 ); // suche s2 in s1 ab Pos. 2

s1.find( "de" ); // suche "de" in s1


s1.find( "de", 1 ); // suche "de" in s1 ab Pos. 1
24
s1.find( "de", 1, 5 ); ); // suche "de" in s1 in den
// fünf Zeichen ab Pos. 1
s1.find( 'd', 1 ); // suche "d" in s1

s1.find( 'd', 3 ); // suche "d" in s1 ab Pos. 3

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

letztmalige Auftauchen des Suchwertes anzeigen, etwa die Funktion find_last_of.


Weitere Informationen dazu finden Sie in der Dokumentation Ihres Compilers und
der Bibliothek.

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.

Es gibt eine Funktion, um die aktuelle Kapazität eines Strings zu ermitteln:

string s = " abcdefgh";


int cap;

cap = ( int ) s.capacity();

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;

for( int i = 1; i <= 10; i++ )


{
s += "********************";
cout << "Laenge: " << 20 * i << " Kapazitaet: " << s.capacity() << '\n';
}

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.

Laenge: 20 Kapazitaet: 207 24


Laenge: 40 Kapazitaet: 207
Laenge: 60 Kapazitaet: 207
Laenge: 80 Kapazitaet: 207
Laenge: 100 Kapazitaet: 207
Laenge: 120 Kapazitaet: 207
Laenge: 140 Kapazitaet: 207
Laenge: 160 Kapazitaet: 207
Laenge: 180 Kapazitaet: 207
Laenge: 200 Kapazitaet: 207

989
24 Die C++-Standardbibliothek und Ergänzung

24.6 Dynamische Arrays (vector)


Sie haben Arrays schon als eine Aneinanderreihung gleichartiger Daten kennenge-
lernt. Die ersten Arrays, die wir verwendet haben, waren statisch. Hier musste die
Größe zur Compile-Zeit angegeben werden.

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.

24.6.1 Die Beispielklasse klasse

class klasse
{
private:
int x;
public:
klasse( int xx = 0 ) { x = xx; }
void setX( int xx ) { x = xx; }
int getX() const{ return x; };
};

Listing 24.16 Die Beispielklasse klasse

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)

24.6.2 Einbinden dynamischer Arrays


Dynamische Arrays werden in der Standardbibliothek auch als Vektoren (vector)
bezeichnet. Um sie zu verwenden, muss die Header-Datei vector eingebunden
werden:

#include <vector>
using namespace std;

Die in der Header-Datei deklarierten Datenstrukturen und Funktionen liegen alle im


Namensraum std. Daher sollte der gesamte Namensraum eingebunden werden, um
ohne vorangestelltes std auf die Elemente zugreifen zu können.

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.

Um ein dynamisches Array zu generieren, gibt es verschiedene Möglichkeiten. Zum


Beispiel können Sie einen leeren Vektor über der Klasse klasse anlegen:

vector <klasse> v1;

Alternativ können Sie das Array auch mit einer bestimmten Anzahl von Elementen
initialisieren (hier 100).

vector <klasse> v2( 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:

vector <klasse> v3( 100, klasse( 17 ) );

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:

vector <klasse> v4( 100, 17 );

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:

vector <int > iv;


vector <float > fv( 1000 );

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.

for( int i = 0; i < l; i++ )


v[i].setX( i ); // Zugriff ohne Bereichskontrolle

for( int i = 0; i < l; i++ )


v.at( i ).setX( i ); // Zugriff mit Bereichskontrolle

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

6 siehe dazu auch Abschnitt 23.5.5

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:

vector <klasse> v( 100 );

v.front().setX( 123 ); // v[0]


v.back().setX( 456 ); // v[99]

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

vector<klasse>::iterator it; // Vorwaertsiterator

for( it = v.begin(); it != v.end(); it++ )


it->setX( 123 );

vector<klasse>::reverse_iterator rit; // Rueckwaertsiterator

for( rit = v.rbegin(); rit != v.rend(); rit++ )


rit->setX( 123 );

993
24 Die C++-Standardbibliothek und Ergänzung

24.6.6 Manipulation
Ich zeige Ihnen in diesem Abschnitt Funktionen zur Manipulation dynamischer
Arrays:

operator= Zuweisung durch =-Operator

assign Zuweisung durch Funktion

insert Einsetzen eines Arrays in ein Array

erase Löschen eines Teils des Arrays

swap Vertauschen eines ganzen Arrays

pop_back Array als Stack, Anfügen am Ende

push_back Array als Stack, Entfernen vom Ende

clear Löschen des gesamten Inhalts

Tabelle 24.1 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 );

v1.assign( v3.begin() + 5, v3.end() – 1 );

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 );

klasse b[50]; // statisches Array mit Datentyp int


v1.assign( b, b + 10 );

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;

v1.insert( v1.begin() + 1, k ); // fuege k an Position 1 ein

v1.insert( v1.begin() + 1, 12, k ); // fuege 12-mal k ab Position


// 1 ein 24

Auch hier kann die Verwendung von int-Werten erfolgen, da eine elementweise
Zuweisung möglich ist:

vector<klasse> v1( 10 );

v1.insert( v1.begin() + 1, 123 );


v1.insert( v1.begin() + 1, 12, 123 );

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 );

v1.insert( v1.begin() + 1, v2.begin(), v2.end() );

klasse a[50]; // statisches Array mit Datentyp klasse


v1.insert( v1.begin() + 1, a + 1, a + 20 );

klasse b[50]; // statisches Array mit Datentyp int


v1.insert( v1.begin() + 1, b, b + 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.

Alternativ kann auch ein ganzer Bereich gelöscht werden:

vector<klasse> v1( 10 );

v1.erase( v1.begin() , v1.begin() + 3 );

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();

Auch zum Tauschen kompletter Arrays gibt es eine direkte Funktion:

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();
}
}

Listing 24.17 Ein einfacher Stack mit dynamischem Array

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 Beschreibung zu deren Funktionalität finden Sie bei den Strings.

24.7 Listen (list)


Ich habe Ihnen Listen im Verlauf des Buches ja bereits mehrfach als effiziente Daten-
strukturen vorgestellt, um große Mengen von Objekten mit häufigen Einsetz- und
Löschfunktionen zu verwalten. Dabei sind sie besonders geeignet, wenn die Daten
vorrangig sequenziell verarbeitet werden.

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.

Um Listen verwenden zu können, muss wieder zuerst einmal die entsprechende


Header-Datei eingebunden werden:

#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:

list<klasse> l1( 1000, 123 );


list<klasse> l2( 1000, klasse( 123 ) );

Die Liste kann ebenfalls aus einer bereits bestehenden Liste konstruiert werden:

list<klasse> l1( 1000, 123 );

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:

list<int> l1( 1000, 123 );


list<klasse> l4( l1.begin(), l1.end() );

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 );

Die Referenzen können als L-Value direkt manipuliert werden.

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.

Operatoren, um einen Offset direkt zu einem Iterator zu addieren, oder Iteratoren


zum Vergleich (<, <=) lassen sich zwar prinzipiell umsetzen. Im Vergleich zum Array
hätten sie aber auch nur eine klägliche Effizienz. Werden solche Iteratoren für einen
Anwendungsfall benötigt, ist dies ein Hinweis darauf, dass es für diesen Fall geeigne-
tere Datenstrukturen als eine Liste gibt.

Die folgenden Operationen werden von bidirektionalen Iteratoren unterstützt:

= Zuweisung

== Vergleich auf Gleichheit

!= Vergleich auf Ungleichheit

++ nächstes Listenelement

-- vorheriges Listenelement

* Zugriff auf Listenelement

-> Zugriff in Listenelement

Tabelle 24.2 Operationen von bidirektionalen Iteratoren

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.

Ein Iterator für eine Liste wird folgendermaßen angelegt:

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;

for( it = l.begin(); it != l.end(); it++ )


it->setX( 123 );

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;

for( rit = l.rbegin(); rit != l.rend(); rit++ ) 24


rit->setX( 123 );

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:

operator= Zuweisung einer Liste durch =-Operator

assign Zuweisung einer Liste oder eines Teils einer Liste

push_front neues Element am Anfang einfügen

pop_front Element am Anfang entfernen

push_back neues Element am Ende einfügen

pop_back Element am Ende entfernen

insert einzelne Elemente oder Teile einer anderen Liste einfügen

erase Löschen von Elementen oder Bereichen von Elementen

swap Vertauschen zweier Listen

clear Löschen des gesamten Inhalts

reverse Umkehren einer Liste

splice Einfügen von Elementen aus einer Liste in eine andere Liste

remove Entfernen von Elementen, die mit einem bestimmten Objekt überein-
stimmen

unique Beseitigung aufeinanderfolgender Duplikate

remove_if Beseitigen von Objekten, die einer bestimmten Bedingung genügen

sort Sortieren einer Liste

merge Kombinieren zweier sortierter Listen

Tabelle 24.3 Funktionen zur Manipulation von Listen

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 );

l.push_front( k ); // k am Anfang einfuegen


l.push_back( k ); // k am Ende einfuegen

l.pop_front(); // erstes Listenelement entfernen


l.pop_back(); // letztes Listenelement entfernen

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 );

l1.swap( l2 ); // tausche Liste l1 mit l2


l1.reverse(); // kehre die Liste l1 um

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.

Erweiterung der Beispielklasse klasse


Für die folgenden Beispiele werde ich die Klasse klasse mit drei Vergleichsoperato-
ren ausstatten, die der Klasse Objektvergleiche ermöglichen. Es handelt sich um den
operator<, den operator> und den operator==.

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:

ostream& operator<<( ostream& os, const klasse& k )


{
os << k.getX();
return os;
}

Listing 24.18 Ausgabe-Operator für die Klasse klasse

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;

cout << "k1: " << k1 << '\n';

if( k1 == k2 )
{
// ... x-Member der Objekte sind gleich
}

if( k1 < k2 )
{
// ... x-Member von k1 ist kleiner als von k2
}

Wir erhalten die erwartete Ausgabe:

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)

Erstellung eines Testrahmens


Alle gerade genannten Funktionen führen Aktionen auf eine bestehende Liste aus.
Damit wir den Effekt der Funktion besser beobachten können, erstellen wir eine Test-
funktion, die die Liste im Originalzustand ausgibt, dann die entsprechende Funktion
ausführt und die Liste in der geänderten Variante anzeigt. Im Code sieht das so aus:

list<klasse> l;
list<klasse>::iterator it;

cout << "Vorher: \n";


for( int i = 9; i >= 0; i-- )
l.push_back( i / 2 );

for( it = l.begin(); it != l.end(); it++ )


cout << *it << ' ';
cout << '\n';

// Hier kommt die zu testende Listenfunktion hin

cout << "Nachher: \n";


for( it = l.begin(); it != l.end(); it++ )
cout << *it << ' ';
cout << '\n';

Listing 24.19 Testrahmen für die Listenfunktionen

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

Fügen wir die Anweisung

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)

in den Testrahmen ein, dann erhalten wir das folgende Ergebnis:

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.

Um zwei sortierte Listen miteinander zu mischen, wird die merge-Funktion verwen-


det. Um das zu testen, legen wir eine weitere Liste l2 an, die wir mit Werten von 0 bis
9 füllen:

list<klasse> l2;

for( int i = 9; i >= 0; i-- )


l2.push_back( i );

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

erhalten wir das folgende Ergebnis:

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

bool greater2( klasse& k )


{
return k.getX() > 2;
}

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 );

if( cmp( k ) ) // Aufruf des ueberladenen operator()


cout << " Wert ist groesser als min \n";

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

Wir können nun in einer objektorientierten Art konfigurierbare Prädikate an die


remove_if-Funktion übergeben. Dieses Prinzip werden wir gleich noch einmal aus-
probieren.

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( );
}
}
};

Über die Membervariable modus können drei verschiedene Sortierungen eingestellt


werden:

왘 0 – Sortierung aufsteigend nach Größe


왘 1 – Sortierung nach geradem/ungeradem Wert (Rest Division durch 2)
왘 2 – Sortierung aufsteigend nach Rest bei Division durch 3

Wir testen unsere Klasse, indem wir zuerst mit setup eine Liste mit Werten füllen:

void setup( list<klasse>& l )


{
for( int i = 9; i >= 0; i-- )
l.push_back( i );
}

Zusätzlich erstellen wir eine Ausgabefunktion ausgabe für die Liste:

1012
24.7 Listen (list)

void ausgabe( list<klasse>& l, string txt )


{
list<klasse>::iterator it;
cout << txt;

for( it = l.begin(); it != l.end(); it++ )


cout << *it << ' ';

cout << '\n';


}

Nun können wir eine Liste erstellen und mit den jeweiligen Kriterien mithilfe unse-
res Funktionsobjekts sortieren:

list<klasse>l;

setup( l );

ausgabe( l, "Vorher : " );

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) : " );

list<klasse> l1( 1000 );


l1.resize( 50 );
l1.resize( 60, klasse( 123 ) );
24
Als Ausgabe erhalten wir:

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:

list<klasse> l1( 1000 );


l1.resize( 50 ); // Verkuerzung der Liste auf
// 50 Elemente
l1.resize( 60, klasse( 123 ) ); // Verlaengerung auf 60 Elemente
// mit deren Initialisierung

Wenn die Liste verkleinert wird, werden die Elemente, die aus der Liste fallen, mit
Aufruf des Destruktors korrekt beseitigt.

24.8 Stacks (stack)


Ein Stack ist eine Datenstruktur zur Speicherung von Elementen, die nach dem LIFO-
Prinzip (Last In – First Out) arbeitet. Wir haben eine solche Struktur an mehreren Stel-
len im Buch schon behandelt und auch schon selbst gebaut, zuletzt hier in Abschnitt
24.1, »Generische Klassen (Templates)«.

Wie Sie schon wissen, hat ein Stack einen sehr eingeschränkten Funktionsumfang. Er
muss nur die folgenden Grundfunktionen bereitstellen:

왘 push – lege ein Element auf den Stack


왘 pop – entferne das oberste Element vom Stack
왘 top – gib mir das oberste Element vom Stack

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:

stack<klasse, vector<klasse>> vstack;

Völlig analog können wir auch einen Stack über eine Liste als verwendeten Container
erstellen:

stack<klasse, list<klasse>> lstack;

Lässt man die Angabe zum Container weg


24
stack<klasse> meinStack;

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«:

stack<klasse, vector<klasse>> vs( v );


stack<klasse, list<klasse>> vl( l );

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.

pop Entfernt das oberste Element vom Stack.

top Erzeugt eine Referenz auf das oberste Element auf dem Stack.

size Gibt die Anzahl der Elemente auf dem Stack zurück.

empty Informiert darüber, ob der Stack leer ist.

Tabelle 24.4 Memberfunktionen des Stacks

Ü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;

B for( int i = 111; i < 120; i++ )


stck.push( i );

C cout << "stck.size(): " << stck.size() << '\n';

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.

Die Ausgabe zeigt, dass alles wie erwartet funktioniert:

stck.size(): 9
119 118 117 116 115 114 113 112 111

24.9 Warteschlangen (queue)


Auch das Thema Warteschlangen (Queues) haben wir schon ausgiebig behandelt. Bei
einer Queue handelt es sich um eine Speicherstruktur, die nach dem FIFO-Prinzip
(First In – First Out) arbeitet.

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 erforderlichen Grundfunktionen einer Queue sind:

왘 push – füge ein Element am Ende der Warteschlange ein


왘 pop – entferne das Element am Anfang der Warteschlange
왘 front – gib mir das Element am Anfang der Warteschlange
왘 back – gib mir das Element am Ende der Warteschlange

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

queue<klasse, list<klasse>> lqueue;


queue<klasse> meineQueue; // mit der Dequeue als Default
queue<int> iqueue;

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:

push Legt ein Element am Ende der Warteschlange ab.

pop Entfernt das Element am Anfang der Warteschlange.

front Erzeugt eine Referenz auf das Element am Anfang der Warteschlange.

back Erzeugt eine Referenz auf das Element am Ende der Warteschlange.

size Gibt die Anzahl der Elemente in der Warteschlange zurück.

empty Informiert darüber, ob die Warteschlange leer ist.

Tabelle 24.5 Memberfunktionen der Queue

Um die Bedienung der Warteschlange zu demonstrieren, setze ich das Beispiel zur
Bedienung des Stacks analog noch einmal als Warteschlange um:

queue<int> que;

for(int i = 111; i < 120; i++)


que.push(i);

cout << "queue.size(): " << que.size( ) << '\n';

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

24.10 Prioritätswarteschlangen (priority_queue)


Ich habe Ihnen den Stapel und die Warteschlange noch einmal vorgestellt und will mit
einer Struktur schließen, die ebenfalls sehr lebensnah ist, nämlich die Prioritätswar-
teschlange. Hier kommt nicht der an die Reihe, der als Letzter gekommen ist (LIFO)
oder am längsten gewartet hat (FIFO). Hier ist der Wichtigste zuerst an der Reihe.

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.

Für Prioritätswarteschlangen gibt es zahlreiche Anwendungsmöglichkeiten. Denken


Sie an ein System, das Meldungen unterschiedlicher Wichtigkeit empfängt und dem
Benutzer darstellen soll. Solche Botschaften kann der Empfänger in eine Prioritäts-
warteschlange einstellen. Damit kann er sicher sein: Wann immer er ein Objekt aus
der Warteschlange entnimmt, erhält er das aktuell wichtigste.

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:

priority_queue<int, vector<int>> meineQueue;

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:

priority_queue<int, vector<int>, less<int>> meineQueue;

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:

priority_queue<int, vector<int>, greater<int>> meineQueue;

Zur Verwendung von greater muss zusätzlich noch eine weitere Header-Datei einge-
bunden werden:

#include <functional>

Die Anwendungsschnittstelle der Prioritätswarteschlange ist formal identisch mit


der eines Stacks. Die Entnahme erfolgt hier natürlich nicht nach dem LIFO-Prinzip,
sondern eben prioritätsgesteuert:

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.

size Gibt die Anzahl der Elemente in der Warteschlange zurück.

empty Informiert darüber, ob die Warteschlange leer ist.

Tabelle 24.6 Memberfunktionen der Prioritätswarteschlange

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:

priority_queue<klasse, vector<klasse>, greater<klasse>>


meineQueue;

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:

bool klasseCmp( klasse& k1, klasse& k2 )


{
return k1.getX() < k2.getX();
}

Bei der Instanziierung der Prioritätswarteschlange können wir diese Funktion jetzt
zusätzlich an das Template übergeben:

priority_queue<klasse, vector<klasse>, bool( *)


( klasse&, klasse& )> meineQueue( klasseCmp );

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; }

bool operator() ( klasse & k1, klasse & k2 )


{
if( richtung )
return k1.getX() < k2.getX();
else
return k1.getX() > k2.getX();
}
};

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

priority_queue<klasse, vector<klasse>, meinCmp> meineQueue;

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:

priority_queue<klasse, vector<klasse>, meinCmp> meineQueue (meinCmp(false));

Mehr müssen wir mithilfe des Funktionsobjekts nicht tun, um die Reihenfolge der
Ausgabe umzukehren.

24.11 Geordnete Paare (pair)


Geordnete Paare dienen dazu, zwei Objekte als ein Paar zusammenzufassen. Die bei-
den Objekte können dabei auch unterschiedliche Typen haben. Als »Container«
haben sie wegen ihrer beschränkten Kapazität keine besondere Bedeutung. Die
geordneten Paare werden aber bei den im Folgenden betrachteten Containern als
Hilfskonstrukt verwendet, daher stelle ich sie Ihnen kurz vor. Ein Paar fasst zwei
Datentypen zu einem Ganzen zusammen, z. B. hier:

pair<int, string> p1;


pair<int, string> p2( 2, "zwei" );

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:

pair<int, string> p1( 1, "eins" );


pair<int, string> p2( 2, "zwei" );

p1.swap( p2 ); // Tausche p1 und p2

Paare ermöglichen auch einen Vergleich untereinander, sofern die Grunddatenty-


pen das unterstützen:

pair<int, string> p1( 1, "eins" );


pair<int, string> p2( 2, "zwei" );

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.

24.12 Mengen (set und multiset)


Mengen sind ein weiterer Container, um Objekte zu verwalten. Bei der Speicherung
in einer Menge wird dem Container die Speicherposition nicht vorgegeben, anders
24
als z. B. bei Listen und Vektoren.

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:

set<klasse> s; // eine leere Menge über der 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

bool klasseCmp( klasse& k1, klasse& k2 );

erfolgt die Konstruktion ähnlich, wie in Abschnitt 24.10, »Prioritätswarteschlangen


(priority_queue)«, gezeigt:

set<klasse, bool( *) ( klasse&, klasse& )> s( klasseCmp );

Hat man eine Vergleichsklasse meinCmp mit einem überladenen operator(), die para-
meterlos konstruiert werden kann, dann reicht die folgende Konstruktion:

set<klasse, meinCmp> s;

Benötigt die Vergleichsklasse Initialisierungsparameter, müssen Sie diese z. B. so


übergeben:

set<klasse, meinCmp> s( meinCmp( 0 ) );

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)

set<klasse> s( s.begin(), s.end() );

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 << ' ';

zu einer aufsteigend sortierten Ausgabe. Natürlich sind auch Rückwärtsiteratoren


möglich.

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;

// ... Elemente in das Multiset einfügen

// Iteratorenpaar fuer den Trefferbereich anlegen


pair<multiset<klasse>::iterator, multiset<klasse>::iterator> itpair;

// Trefferbereich mit equal_range erzeugen


itpair = m.equal_range( 123 );

// Auswerten des Trefferbereichs


multiset<klasse>::iterator it;
for( it = itpair.first; it != itpair.second; it++ )
{
// Bearbeiten eines Treffers
}

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;

// Elemente in das set einfügen

// Iteratoren für lower und upper bound anlegen


set<klasse>::iterator lb, ub;

// Iteratoren berechnen
lb = s.lower_bound( 123 );
ub = s.upper_bound( 123 );

// Auswerten des Trefferbereichs


set<klasse>::iterator it;

for( it = lb; it != ub; it++ )


{
//Bearbeiten eines Treffers
}

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;

for( int i = 0; i < 10; i++ )


s.insert( i ); // einige Element in die Menge einsetzen

ret = s.insert( 5 );

if( ret.second == false )


{
// Wert konnte nicht eingesetzt werden, da schon vorhanden
}

24.13 Relationen (map und multimap)


Im vorangegangenen Abschnitt haben wir eine Menge verwendet, Elemente zu spei-
chern, die ihren Schlüssel selbst enthalten. Wenn Sie Elemente speichern möchten,
die kein sinnvolles Schlüsselattribut haben, oder für Speicherung und Zugriff einen
Schlüssel verwenden wollen, der nicht im Element liegt, können Sie eine Relation
oder Map nutzen. Eine Relation ist eine Menge, deren Elemente Schlüssel-Wert-Paare
(Key-Value) sind. Mit jedem Element wird zusätzlich ein Schlüssel gespeichert, über
den das Element wiedergefunden werden kann. Analog zu den Mengen gibt es die
Map, bei der es zu jedem Schlüssel nur ein Objekt geben kann, und die Multimap.

Um Relationen verwenden zu können (map und multimap), wird die entsprechende


Header-Datei inkludiert:

#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:

map<string, klasse> meineMap;

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:

map<string, klasse> map1;

map<string, klasse> map2 = map1;


map<string, klasse> map3( map2.begin( ), map2.end( ) );

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

map<string, klasse> meineMap;

meineMap["eins"] = 1;
meineMap["zwei"] = 2;

klasse k = meineMap["eins"];

meineMap["drei"] = meineMap["eins"].getX() + meineMap["zwei"].getX();

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.

24.14 Algorithmen der Standardbibliothek


Wie Sie im Verlauf des Kapitels gesehen haben, gibt es Iteratoren für praktisch alle
Container, und ihre Verwendung ist dort auch immer wieder gleichartig. Das fol-
gende Konstrukt, um alle Elemente eines Containers durchzugehen, habe ich Ihnen
schon mehrfach präsentiert:

list<klasse>::iterator it;

for( it = l.begin(); it != l.end(); it++ )


// Mache etwas mit allen Containerelementen

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.

Die Standardbibliothek hat damit drei wichtige Elemente:

Container Iterator Algorithmus

Abbildung 24.6 Die Elemente 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.

Um die Algorithmen der Standardbibliothek zu verwenden, binden wir den entspre-


chenden Header ein:

#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:

void meineFkt( const klasse& k )


{
cout << k.getX() << ' ';
}

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 );

for_each( meinVector.begin(), meinVector.end(), meineFkt );

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 );

for_each( meinSet.begin( ), meinSet.end( ), meineFkt );

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

In manchen Situationen ist eine Funktion allein nicht ausreichend, um die


gewünschte Aktion auszuführen oder die gewünschte Funktion konfigurieren zu
können. In diesem Fall können wir auch hier wieder auf ein Funktionsobjekt mit
überladenem operator() zurückgreifen:

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 ) );

und erhalten damit die folgende Ausgabe:

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 );

for_each( meinVector.begin(), meinVector.end(), fktKlasse() );


cout << endl;

cout << "2 kommt ";


cout << count( meinVector.begin( ), meinVector.end( ), 2 ) << " mal vor\n";

Das Ergebnis ist wie erwartet:

0 1 2 0 1 2 0 1 2 0
2 kommt 3 mal vor

Es können Minima und Maxima ermittelt werden:

cout << "Das groesste Element ist:"


<< *( max_element( meinVector.begin( ), meinVector.end( ) );
cout <<< '\n';

Auch hier ist das Ergebnis wie erwartet:

Das groesste Element ist: 2

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.

24.14.1 Vererbung und virtuelle Funktionen in Containern


Ich beende das Kapitel über die Standardbibliothek mit einem Hinweis auf das Ver-
halten der Container bei Vererbung und deren Umgang mit virtuellen Funktionen .

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'; }
};

class abgeleitet: public basis


24
{
public:
virtual void printClass() { cout << "abgeleitet" << '\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();

und das folgende Ergebnis erhalten:

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.

Sollen die Eigenschaften abgeleiteter Klassen im Container erhalten bleiben, ist es


sinnvoll, anstelle der Objekte nur die Zeiger auf die Objekte im Container zu verwal-
ten. Auch dazu gebe ich Ihnen noch ein kurzes Beispiel:

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 );

Bei Aufruf der Funktion zur Identifizierung der Klasse

1038
24.14 Algorithmen der Standardbibliothek

a.printClass( );
v[0]->printClass( );

erhalten Sie jetzt auch die gewünschte Antwort:

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

Abbildung A.1 Beispiel eines Flussdiagramms für das Aufsteh-Ritual

Wie detailreich Sie Ihren Aufsteh-Prozess modellieren, überlasse ich Ihnen.

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:

x = größte ganze Zahl mit n · x ≤ z


Lösungen

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

am Ende des Verfahrens um 1. Die im zweiten Fall geforderte Multiplikation mit 10


können Sie ebenfalls auf eine Serie von Additionen abbilden.

Spoiler
Wir setzen die Idee vor dem Spoiler in ein Flussdiagramm um. In der Variablen x zäh-
len wir die Additionen:

x = größte ganze Zahl mit n · x ≤ z

x=0
nx = 0

nein x=x–1
nx ≤ z
nx = nx – n

ja

x=x+1
nx = nx + n

Abbildung A.2 Flussdiagramm zur Ermittlung von x

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.

Der folgende Algorithmus berechnet eine Multiplikation mit 10 durch schrittweise


Addition. Das Produkt n·x ist dabei in der Variablen nx aus dem vorangegangenen
Lösungen

Algorithmus gegeben (siehe Abbildung A.3).

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

Abbildung A.3 Flussdiagramm zur Ermittlung von z

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

bestimmtes Jahr ein Schaltjahr ist oder nicht!

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

Abbildung A.4 Flussdiagramm zur Bestimmung eines Schaltjahrs

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

Abbildung A.5 Flussdiagramm zum Zahlenraten

Um die Anzahl der maximal erforderlichen Rateschritte zu berechnen, müssen Sie


sich fragen, wie oft Sie den anfänglichen Suchraum, der x = b-a+1 Zahlen umfasst, hal-
bieren müssen, bis Sie unterhalb von 1 landen. Umgekehrt gefragt: Wie oft müssen
Sie 1 verdoppeln, um mindestens x zu erhalten? Versuchen Sie, dafür eine allgemein-
gültige Formel zu finden.

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

Abbildung A.6 Flussdiagramm zur Primzahlbestimmung

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)

Tauschen Sie die CDs in den Fächern n und m.

Lösung
Es gibt zahlreiche verschiedene Sortierverfahren. Eins der einfachsten heißt Bubble-
sort und läuft wie folgt ab:

왘 Durchlaufe den CD-Ständer in aufsteigender Richtung! Betrachte dabei immer


zwei benachbarte CDs. Wenn zwei benachbarte CDs in falscher Ordnung sind,

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!

Versuchen Sie jetzt, ein Flussdiagramm zu diesem Verfahren zu zeichnen.

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)

Abbildung A.7 Flussdiagramm zum Sortieren von CDs

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)

Abbildung A.8 Flussdiagramm zum Verschieben der CD

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

Abbildung A.9 Flussdiagramm zur Änderung der Reihenfolge

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).

Dieses einfache Informationsmodell genügt, um den Aufzug vollständig zu beschrei-


ben. Physikalisch haben wir zwar 40 Knöpfe, aber logisch sind die Knöpfe für eine
bestimmte Etage nicht unterscheidbar – egal, ob sie im Aufzug oder auf der Etage
angebracht sind. Da wir auch von der Physik und damit auch dem Faktor Zeit abstra-
hieren, können wir den Aufzug wahlfrei positionieren. Wenn wir in die 10. Etage wol-
len, schreiben wir einfach e = 10 und machen uns keine Gedanken darüber, wie das
physikalisch ablaufen könnte.

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

min = e nein ja max = e


e=e–1 r=1 r=1 r=0 e=e+1
i=e i=e

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

Abbildung A.10 Flussdiagramm der Aufzugsteuerung

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:

Abbildung A.11 Endspiel im Schach

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

Was passiert, wenn man versucht, durch 0 zu dividieren?

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;

printf( "1. Zahl: ");


scanf( "%d", &zahl1);
Lösungen

printf( "2. Zahl: ");


scanf( "%d", &zahl2);

summe = zahl1 + zahl2;


differenz = zahl1 – zahl2;
produkt = zahl1 * zahl2;
quotient = zahl1 / zahl2;
rest = zahl1 % zahl2;

1056
Kapitel 3

printf( "Summe %d + %d = %d\n", zahl1, zahl2, summe);


printf( "Differenz %d – %d = %d\n", zahl1, zahl2, differenz);
printf( "Produkt %d * %d = %d\n", zahl1, zahl2, produkt);
printf( "Quotient %d / %d = %d Rest %d\n", zahl1, zahl2,
quotient, rest);
}

Bei Division durch 0 bricht mein Programm mit folgender Meldung ab:

Abbildung A.12 Fehlermeldung bei Division durch 0

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

Abbildung A.13 Flussdiagramm zur Bestimmung eines Schaltjahrs

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;

printf( "Jahr: ");


scanf( "%d", &jahr);

if( jahr % 400 == 0)


Lösungen

printf( "%d ist ein Schaltjahr\n", jahr);


else
{
if( jahr % 100 == 0)
printf( "%d ist kein Schaltjahr\n", jahr);
else
{

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;

printf( "Tag: ");


scanf( "%d", &tag);
printf( "Monat: ");
scanf( "%d", &monat);
printf( "Jahr: ");
scanf( "%d", &jahr);

1059
A Aufgaben und Lösungen

if( jahr % 400 == 0)


schalttag = 1;
else
{
if( jahr % 100 == 0)
schalttag = 0;
else
{
if( jahr % 4 == 0)
schalttag = 1;
else
schalttag = 0;
}
}
laufender_tag = tag;
if( monat > 1)
laufender_tag += 31;
if( monat > 2)
laufender_tag += 28+schalttag;
if( monat > 3)
laufender_tag += 31;
if( monat > 4)
laufender_tag += 30;
if( monat > 5)
laufender_tag += 31;
if( monat > 6)
laufender_tag += 30;
if( monat > 7)
laufender_tag += 31;
if( monat > 8)
laufender_tag += 31;
if( monat > 9)
laufender_tag += 30;
Lösungen

if( monat > 10)


laufender_tag += 31;
if( monat > 11)
laufender_tag += 30;
printf( "Der %d.%d.%d ist der %d. Tag des Jahres\n", tag,
monat, jahr, laufender_tag);
}

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;

printf( "Minimum: ");


scanf( "%d", &min);
printf( "Maximum: ");
Lösungen

scanf( "%d", &max);

for( i = min; i <= max; i++)


{
if( i % 7 == 0)
printf( "%d ist durch 7 teilbar\n", 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:

Abbildung A.14 Treppe aus Legosteinen

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;

printf( "Hoehe: ");


scanf( "%d", &hoehe);
Lösungen

for( i = 1, steine = 0; i <= hoehe; i++)


steine += i;
printf( "Eine Treppe der Hoehe %d hat %d Steine\n", hoehe, steine);
}

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

Quellcode der Lösung:

void main()
{
int anzahl;
int max, min, i, z;

1063
A Aufgaben und Lösungen

printf( "Anzahl: ");


scanf( "%d", &anzahl);

for( i = 1; i <= anzahl; i++)


{
printf( "%d. Zahl:", i);
scanf( "%d", &z);
if( i == 1)
{
min = z;
max = z;
}
else
{
if( z < min)
min = z;
if( z > max)
max = z;
}
}
printf( "Minimum: %d\n", min);
printf( "Maximum: %d\n", max);
}

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

Abbildung A.15 Flussdiagramm zum Zahlenraten

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;

printf( "a: ");


scanf( "%d", &a);
printf( "b: ");
scanf( "%d", &b);

1065
A Aufgaben und Lösungen

printf( "Denk dir eine Zahl zwischen %d und %d\n", a, b);

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

Abbildung A.16 Flussdiagramm zur Primzahlbestimmung

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;

printf( "Gib eine Zahl ein: ");


scanf( "%d", &p);

for( t = 2; t < p; t++)


{
if( p%t == 0)
break;
}
if( t == p)
printf( "%d ist eine Primzahl\n", p);
else
printf( "%d ist keine Primzahl\n", p);
}

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;

printf( " 1 2 3 4 5 6 7 8 9 10\n");


printf( "---------------------------------------------\n");
for( z = 1; z <= 10; z++)
{
printf( "%3d |", z);
for( s = 1; s <= 10; s++)
printf( "%4d", 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;

printf( "Anfangskapital: ");


scanf( "%f", &kapital);
printf( "Zinssatz: ");
scanf( "%f", &zins);

1069
A Aufgaben und Lösungen

printf( "Zielbetrag ");


scanf( "%f", &ziel);

for( jahre = 0; kapital < ziel; jahre++)


kapital += kapital*zins/100.0;
printf( "Es werden %d Jahre benoetigt\n", jahre);
}

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;

printf( "a: ");


scanf( "%d", &a);
printf( "b: ");
scanf( "%d", &b);

1070
Kapitel 4

printf( "Der ggT von %d und %d ist ", a, b);

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

Das ist die vollständige Lösung:

void main()
{
float x, y, a, b, p;
int n, i;

printf( "x: ");


scanf( "%f", &x);

1071
A Aufgaben und Lösungen

printf( "y: ");


scanf( "%f", &y);
printf( "a: ");
scanf( "%f", &a);
printf( "b: ");
scanf( "%f", &b);
printf( "n: ");
scanf( "%d", &n);

for( i = 1; i <= n; i++)


{
p = a*x/100.0;
x -= p;
y += p;
p = b*y/100.0;
y -= p;
x += p;
printf( "Nach dem %d-ten Umfuellen ist x = %f und
y = %f\n", i, x, y);
}
}

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;

printf( "Jahr StD OStR StR\n");


for( i = 1; i <= 20; i++)
{
b = (10*OStR)/100;
OStR -= b;
StD += b;
b = (20*StR)/100;
StR -= b;
OStR += b;
StD -= (20*StD)/100;
OStR -= (20*OStR)/100;
StR = 1200 – StD – OStR;
printf( "%2d %3d %3d %3d\n", 3*i, StD, OStR, StR);
}
}
Lösungen

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:

immunn+1 = immunn + immunisierungsrate · krankn

Der Rest der Population ist krank:

krankn+1 = x – gesundn+1 – immunn+1

Die Proportionalitätsfaktoren (Infektionsrate und Immunisierungsrate) hängen


dabei von medizinisch-sozialen Faktoren wie Art der Krankheit, hygienische Bedin-
gungen, Bevölkerungsdichte, medizinische Versorgung etc. ab und können daher
nur empirisch ermittelt werden. Sind diese Faktoren aber aus früheren Epidemien
Lösungen

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

Abbildung A.17 Epidemieverlauf

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);

gesund = gesamt – krank;


immun = 0;

printf("Tag Gesunde Kranke Immun\n");


printf("----+--------------------------\n");

for( i = 1; i <= tage; i = i + 1 )


{
gesund = gesund – infrate * gesund * krank/gesamt;
immun = immun + immrate * krank;
krank = gesamt – gesund – immun;

printf("%3d | %4.0f %4.0f %4.0f\n", i, gesund,


krank, immun );
}
}

A 4.6 Aufgabe
Lösungen

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 festzulegen.

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:

Partei A Partei B Partei C

Stimmen 100000 80000 20000

1 100000 80000 20000

2 50000 40000 10000

3 33333 26666 6666

4 25000 20000 5000

5 20000 16000 4000

6 16666 13333 3333

7 14285 11429 2857

8 12500 10000 2500

Sitze 5 4 1

Tabelle A.1 Stimmen und Sitzverteilung nach d’Hondt

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:

if(Zahl1 > Zahl2)


{
if(Zahl1 > Zahl3)
Zahl1 ist das Maximum
else
Zahl3 ist das Maximum
}
Lösungen

else
{
if(Zahl2 > Zahl3)
Zahl2 ist das Maximum;
else
Zahl3 ist das Maximum;
}

Versuchen Sie, die Aufgabe mit diesem Hinweis zu lösen.

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:

int StimmenA, StimmenB, StimmenC;


int sitze;
int SitzeA, SitzeB, SitzeC;
int teilerA, teilerB, teilerC;
int QuotientA, QuotientB, QuotientC;
int max;

printf("Stimmen fuer Partei A: ");


scanf("%d",&StimmenA);
printf("Stimmen fuer Partei B: ");
scanf("%d",&StimmenB);
printf("Stimmen fuer Partei C: ");
scanf("%d",&StimmenC);
printf("Gesamtanzahl der Sitze: ");
scanf("%d",&sitze);

Dann initialisieren Sie die Sitze, Teiler und Quotienten für jede Partei:

SitzeA = SitzeB = SitzeC = 0;


teilerA = teilerB = teilerC = 1;
QuotientA = StimmenA;
QuotientB = StimmenB;
QuotientC = StimmenC;

Dann folgt die Schleife für die Sitzvergabe, die wir zunächst ohne ihren Inhalt
betrachten:
Lösungen

for( ; sitze; sitze = sitze-1)


{
//Naechsten Sitz vergeben
}

Die Schleife wird so oft durchlaufen, wie Sitze zu vergeben sind.

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

if(QuotientA > QuotientB)


{
if(QuotientA > QuotientC)
max = 1;
else
max = 3;
}
else
{
if(QuotientB > QuotientC)
max = 2;
else
max = 3;
}

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;
}

Am Ende geben Sie noch die Sitzverteilung aus:

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:

왘 Aa, Bb, Cc, Dd, Ee, Ff, Gg und Hh sind Primzahlen.


왘 ABC ist ein Vielfaches von Aa.
Lösungen

왘 abc ist gleich cba.


왘 CDE ist Produkt von Cc mit der Quersumme von CDE.
왘 Bb ist gleich der Quersumme von cde.
왘 EFG ist ein Vielfaches von Aa.
왘 efg ist Produkt von Aa mit der Quersumme von efg.
왘 GHA ist Produkt von eE mit der Quersumme von ABC.
왘 Die Quersumme von gha ist Cc.

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.

Zum Beispiel hat die Ziffernkombination Aa den numerischen Wert A*10+a.

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

und auf dem Bildschirm ausgibt!

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");

printf("%d = ", n);


for ( i = 2; i <= n; i++)
{
for( ;n % i == 0; )
{
printf("%d", i);
n = n / i;

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:

summand = -summand * (x/n)*(x/(n+1))

Damit ergibt sich das folgende Programm:

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

Abbildung A.19 Pyramiden

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

printf( "Hoehe: ");


scanf( "%d", &hoehe);

for( s = 0, i = 1; i <= hoehe; i++)


s += 2*i-1;
printf( "Die Treppe hat %d Steine\n", s);

for( s = 0, i = 1; i <= hoehe; i++)


s += i*i;
printf( "Die quadratische Pyramide hat %d Steine\n", s);

for( s = 0, k = 1; k <= hoehe; k++)


{
for( i = 1; i <= k; i++)
s += i;
}
printf( "Die Dreieckspyramide hat %d Steine\n", s);
}

Listing A.1 Bestimmung des Steineverbrauchs

Die gesuchten expliziten Formeln sind:

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

Tabelle A.2 Wahrheitstafel des Nand-Operators

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 )

Betrachtet man diese Formel insbesondere für A = B, so folgt:

A ⇔ ( A ∧ A ) ⇔ A nand A

Daraus folgt dann wiederum:

A ∧ B ⇔ A ∧ B ⇔ ( A nand B ) ⇔ ( A nand B ) nand ( A nand B )

Mit den de Morganschen Regeln kann man dann auch ein logisches Oder durch
»nand« ausdrücken:

A ∨ B ⇔ A ∨ B ⇔ A ∧ B ⇔ A nand B ⇔ ( A nand A ) nand ( B nand B )


Lösungen

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.

Wir zeigen das am Beispiel des Ausdrucks (c):

( ) ( ) ( ) (
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;

printf( "A B C D | wert\n");


printf( "--------+-----\n");

1092
Kapitel 5

for( A = 0; A <= 1; A++)


{
for( B = 0; B <= 1; B++)
{
for( C = 0; C <= 1; C++)
{
for( D = 0; D <= 1; D++)
{
wert = !(A && B) || (C || D);
printf( "%d %d %d %d | %d\n", A, B, C, D, wert);
}
}
}
}
}

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˄ B ⟺ A˅B A˅ B ⟺ A˄B De Morgansches Gesetz

A˄ A ⟺ 0 A˅ A ⟺ 1

A⟺A

Abbildung A.20 Übersicht der Tautologien

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;

printf( "A B C | links rechts\n");


printf( "------+-------------\n");

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

Abbildung A.21 Schaltung mit gekoppelten Schaltern

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:

lampe = (s1||s2)&&(s7 || ((s3&&s4)||((s3||s6)&&s7)))

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.

Damit kommen wir zu folgender Vereinfachung:

lampe = (s1||s2)&&(s7 || (s3 && s4))

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.

Vereinfachen Sie Ihr Programm mit diesen Informationen noch einmal.

1096
Kapitel 5

Spoiler
Die optimierte Lösung im Quellcode sieht so aus:

void main()
{
int s1, s2, s3, s4, s7;
int lampe;

printf( " s1 s2 s3 s4 s7\n");


for( s1 = 0; s1 <= 1; s1++)
{
for( s2 = 0; s2 <= 1; s2++)
{
for( s3 = 0; s3 <= 1; s3++)
{
for( s4 = 0; s4 <= 1; s4++)
{
for( s7 = 0; s7 <= 1; s7++)
{
lampe = (s1||s2) && (s7 || (s3 && s4));
if( lampe == 1)
printf( " %d %d %d %d %d\n",
s1, s2, s3, s4, s7);
}
}
}
}
}
}
Lösungen

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:

1. Mindestens ein Familienmitglied geht zu der Feier.


2. Anton geht auf keinen Fall zusammen mit Doris.
3. Wenn Berta geht, dann geht Claus mit.

1097
A Aufgaben und Lösungen

4. Wenn Anton und Claus gehen, dann bleibt Berta zu Hause.


5. Wenn Anton zu Hause bleibt, dann geht entweder Doris oder Claus.

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:

1. Mindestens ein Familienmitglied geht zu der Feier.


A||B||C||D
2. Anton geht auf keinen Fall zusammen mit Doris.
!(A&&D)
3. Wenn Berta geht, dann geht Claus mit.
(!B||C)
4. Wenn Anton und Claus gehen, dann bleibt Berta zu Hause.
!A||!C||!B
5. Wenn Anton zu Hause bleibt, dann geht entweder Doris oder Claus.
A || (D != C))

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

ber müsste man den Ausdruck (D || !C) || (!D || C) verwenden.

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;

for( A = 0; A <= 1; A++)


{
for( B = 0; B <= 1; B++)
{
for( C = 0; C <= 1; C++)
{
for( D = 0; D <= 1; D++)
{
if( (A||B||C||D) && !(A&&D) && (!B||C) &&
(!A||!C||!B) && (A || (D != C)))
printf( "%d %d %d %d\n", 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.

Schreiben Sie ein C-Programm, das den Tresor knackt!

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( s1 = 0; s1 <= 1; s1++)


{
for( s2 = 0; s2 <= 1; s2++)
{
for( s3 = 0; s3 <= 1; s3++)
{
for( s4 = 0; s4 <= 1; s4++)
{
for( s5 = 0; s5 <= 1; s5++)
{
for( s6 = 0; s6 <= 1; s6++)
Lösungen

{
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

if( !s1 && s2 && s4) // Bedingung 2


continue;
if( s1 && s6) // Bedingung 3
continue;
if( !s6 && (s7 || !s5)) // Bedingung 4
continue;
if( !s9 && s1 && s3) // Bedingung 5
continue;
if( !s8 && !s2) // Bedingung 6
continue;
if( (!s3 || s6) && (s8 || !s4)) // Bedingung 7
continue;
if( s9 && ( s5 || !s6)) // Bedingung 8
continue;
if( !s4 && (s3 || !s9)) // Bedingung 9
continue;
printf( "%d %d %d %d %d %d %d %d %d\n",
s1, s2, s3, s4, s5, s6, s7, s8, s9);
}
}
}
}
}
}
}
}
}
}

Beachten Sie, dass sich aus einer Bedingung der Form

A⇒B

durch Negation die Bedingung


Lösungen

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:

Eine Entscheidungstabelle besteht aus vier Teilbereichen:

E einer Auflistung der zu berücksichtigenden Bedingungen


E einer Auflistung der möglichen Aktionen
E einem Bereich, in dem die möglichen Bedingungskombinationen zusammengestellt sind
E einem Bereich, in dem jeder Bedingungskombination die jeweils durchzuführenden
Aktivitäten zugeordnet sind

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

Abbildung A.22 Entscheidungstabelle zur Umsetzung in C

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

Lieferung_mit_Rechnung = Lieferfähig und Bonität_in_Ordnung.

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;

printf( "Lieferfaehig (0/1): ");


scanf( "%d", &lf);
printf( "Vollstaendig (0/1): ");
scanf( "%d", &vs);
printf( "Bonitaet in Ordnung (0/1): ");
scanf( "%d", &io);

if( lf && io)


printf( "Lieferung mit Rechnung\n");
if( lf && !io)
printf( "Lieferung als Nachnahme\n");
if( lf && !vs)
printf( "Angaben vervollstaendigen\n");
if( !lf)
printf( "Mitteilen: nicht lieferbar\n");
}
Lösungen

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;

// Einlesen des Strings

for( i = 0; string[i]; i++)


{
// Bearbeite das Zeichen string[i]
}

Bearbeiten heißt in diesem Fall: vergleichen und zählen.

Spoiler
Dies ist der Quellcode der vollständigen Lösung:
Lösungen

void main()
{
char string[100];
char buchstabe;
int z, i;

printf( "Buchstabe: ");


scanf( "%c", &buchstabe);

1104
Kapitel 6

printf( "String: ");


scanf( "%s", string);

for( z = i = 0; string[i]; i++)


{
if( string[i] == buchstabe)
z++;
}
printf( "Der Buchstabe %c kommt in %s %d-mal vor\n",
buchstabe, string, z);
}

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

for( hinten = 0; string[hinten]; hinten++)


;

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;

printf( "String: ");


scanf( "%s", string);

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;

printf( "String: ");


scanf( "%s", string);

for( lesen = schreiben = 0; string[lesen]; lesen++)


{
if( string[lesen] != 'e')
string[schreiben++] = string[lesen];
}
string[schreiben] = 0;
printf( "%s\n", string);
}
Lösungen

Beachten Sie die Zeile:

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

der entsprechende Kleinbuchstabe.

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;

printf( "Wort: ");


scanf( "%s", wort);
for( hinten = 0; wort[hinten] != 0; hinten++)
;

for( vorn = 0, hinten--; vorn < hinten; vorn++, hinten--)


{
if( (wort[vorn] | 0x20) != (wort[hinten] | 0x20))
break;
}

if( vorn < hinten)


printf( "Kein Palindrom\n");
else
printf( "Palindrom\n");
}

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:

ziffernwert = zeichencode – '0'

Spoiler
Die vollständige Lösung im Quelltext sieht so aus:

void main()
{
char string[20];
int zahl, i;

printf( "Zahl: ");


scanf( "%s", string);

for( zahl = 0, i = 0; string[i]; i++)


zahl = 10*zahl + string[i]-'0';

printf( "Die Zahl ist: %d\n", zahl);


}
Lösungen

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:

Gib die 1. Zahl ein: 23


Gib die 2. Zahl ein: 17
Gib die 3. Zahl ein: 234
Gib die 4. Zahl ein: 875

1110
Kapitel 6

Gib die 5. Zahl ein: 328


Gib die 6. Zahl ein: 0
Gib die 7. Zahl ein: 519
Gib die 8. Zahl ein: 712
Gib die 9. Zahl ein: 1000
Gib die 10. Zahl ein: 14

Welche Zahl soll ich ausgeben: 3


Die 3. Zahl ist 234

Welche Zahl soll ich ausgeben: 9


Die 9. Zahl ist 1000

Welche Zahl soll ich ausgeben: 2


Die 2. Zahl ist 17

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;

for( i = 0; i < 10; i++)


{
printf( "Gib die %d-te Zahl ein: ", i+1);
scanf( "%d", &zahlen[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;

for( i = 0; i < 10; i++)


Lösungen

{
printf( "%d-te Zahl: ", i+1);
scanf( "%d", &zahlen[i]);
}

for( i = 9; i > 0; i--)


{
for( k = 0; k < i; k++)
{

1112
Kapitel 6

if( zahlen[k] > zahlen[k+1])


{
t = zahlen[k];
zahlen[k] = zahlen[k+1];
zahlen[k+1] = t;
}
}
}

for( i = 0; i < 10; i++)


printf( "%d\n", zahlen[i]);
}

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

magisches Quadrat handelt.

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

Die Summe in einer Zeile (Spalte, Diagonale) ist dann:

2
n( n + 1)
zeilensumme = -----------------------
2

Um eine Zeilensumme zu berechnen, iteriert man mit festem Zeilenindex durch alle
Spalten.

Um eine Spaltensumme zu berechnen, iteriert man mit festem Spaltenindex durch


alle Zeilen.

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.

So oder so ähnlich sollte auch Ihre Lösung aussehen:

1114
Kapitel 6

void main()
{
int mquadrat[5][5];
int z, s, summe;
int fehler = 0;

printf( "Gib das Quadrat (25 ganze Zahlen) ein:\n\n");

for( z = 0; z < 5; z++)


{
for( s = 0; s < 5; s++)
scanf( "%d", &mquadrat[z][s]);
}

for( z = 0; z < 5; z++)


{
for( summe = 0, s = 0; s < 5; s++)
summe += mquadrat[z][s];
printf( "Pruefung der %d-ten Zeile: %d\n", z+1, summe);
if( summe != 65)
fehler++;
}

for( s = 0; s < 5; s++)


{
for( summe = 0, z = 0; z < 5; z++)
summe += mquadrat[z][s];
printf( "Pruefung der %d-ten Spalte: %d\n", s+1, summe);
if( summe != 65)
fehler++;
}

for( summe = 0, s = 0; s < 5; s++)


Lösungen

summe += mquadrat[s][s];
printf( "Pruefung der 1-ten Diagonalen: %d\n", summe);
if( summe != 65)
fehler++;

for( summe = 0, s = 0; s < 5; s++)


summe += mquadrat[4-s][s];
printf( "Pruefung der 2-ten Diagonalen: %d\n", summe);

1115
A Aufgaben und Lösungen

if( summe != 65)


fehler++;

printf( "%d Fehler gefunden\n", fehler);


}

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:

왘 Die Anfangsspalte ist spalte = anzahl/2.


왘 Die Anfangszeile ist zeile = anzahl/2+1.

Beachten Sie, dass es sich um eine Integer-Division handelt und dass die Zeilennum-
mer nach unten ansteigt.

Regel 2 mit bereits eingearbeiteter Regel 4 besagt:

왘 zeile = (zeile+1)%anzahl
왘 spalte = (spalte+1)%anzahl

Beachten Sie dabei, dass die Modulo-Operation genau die Korrekturen durchführt,
die Regel 4 vorschreibt.

Regel 3 mit bereits eingearbeiteter Regel 4 besagt:

왘 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;

printf( "Anzahl: ");


scanf( "%d", &anzahl);

1117
A Aufgaben und Lösungen

for( zeile = 0; zeile < anzahl; zeile++)


{
for( spalte = 0; spalte < anzahl; spalte++)
mquadrat[zeile][spalte] = 0;
}

spalte = anzahl/2;
zeile = anzahl/2+1;

for( zahl = 1; zahl <= anzahl*anzahl; zahl++)


{
while( mquadrat[zeile][spalte])
{
zeile = (zeile+1)%anzahl;
spalte = (spalte+anzahl-1)%anzahl;
}
mquadrat[zeile][spalte] = zahl;
zeile = (zeile+1)%anzahl;
spalte = (spalte+1)%anzahl;
}

for( zeile = 0; zeile < anzahl; zeile++)


{
for( spalte = 0; spalte < anzahl; spalte++)
printf( "%3d", mquadrat[zeile][spalte]);
printf( "\n");
}
}

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

wort verschlüsselt und wieder entschlüsselt.

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.

Mit diesen Informationen sollten Sie das Verschlüsselungsprogramm erstellen


können.

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.

Die Verschiebung ist dann eine Addition mit anschließender Modulo-Operation


bezüglich der Alphabetgröße. Diese Verschiebung muss auf das Textzeichen ange-
wandt werden.

Versuchen Sie, mit diesen Informationen die Verschlüsselung und Entschlüsselung


zu programmieren.

Spoiler
Die konkrete Formel für die Verschlüsselung des Zeichens mit dem Index i ist:
Lösungen

text[i] = 'a' + ((text[i]-'a') + (passwort[i%plen]-'a'))%26;

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:

text[i] = 'a' + (26 + (text[i]-'a') – (passwort[i%plen]-'a'))%26;

1119
A Aufgaben und Lösungen

Jetzt haben Sie alle erforderlichen Informationen, um das Programm zu erstellen.

Spoiler
Hier sehen Sie das vollständige Programm:

void main()
{
char text[100];
char passwort[20];
int i, plen;

printf( "Text: ");


scanf( "%s", text);
printf( "Passwort: ");
scanf( "%s", passwort);

for( plen = 0; passwort[plen]; plen++)


;

for( i = 0; text[i]; i++)


text[i] = 'a' + ((text[i]-'a') + (passwort[i%plen]-'a'))%26;
printf( "Verschluesselt: %s\n", text);

for( i = 0; text[i]; i++)


text[i] = 'a' + (26 + (text[i]-'a') – (passwort[i%plen]-'a'))%26;
printf( "Entschluesselt: %s\n", text);
}

Vigenère-Schlüssel werden übrigens heute nicht mehr verwendet, da sie leicht zu


knacken sind.
Lösungen

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:

int vorkommen( char s[], char b)

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:

int vorkommen( char s[], char b)


{
int z, i;

for( z = i = 0; string[i]; i++)


{
if( string[i] == buchstabe)
z++;
}
}
Lösungen

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

Es fehlt auch noch die Rückgabe des Funktionsergebnisses. Das Funktionsergebnis


wird in der Variablen z berechnet. Den Wert dieser Variablen müssen wir am Ende
der Berechnung mit einer return-Anweisung zurückgeben.

Damit ergibt sich der vollständige Funktionscode:

int vorkommen( char s[], char b)


{
int z, i;

for( z = i = 0; s[i]; i++)


{
if( s[i] == b)
z++;
}
return z;
}

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

printf( "Buchstabe: ");


scanf( "%c", &buchstabe);
printf( "String: ");
scanf( "%s", string);

z = vorkommen( string, buchstabe);

1122
Kapitel 7

printf( "Der Buchstabe %c kommt in %s %d-mal vor\n",


buchstabe, string, z);
}

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.

void umkehren( char s[])


Lösungen

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

void umkehren( char s[])


{
int vorn, hinten;
char tmp;

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];

printf( "String: ");


scanf( "%s", string);

umkehren( string);

printf( "Ergebnis: %s\n", 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

void erase( char s[])

Implementieren Sie jetzt die Funktion einschließlich eines Tests im Hauptpro-


gramm.

Spoiler
Hier ist der Quellcode zur Lösung:

void erase( char s[])


{
int lesen, schreiben;

for( lesen = schreiben = 0; s[lesen]; lesen++)


{
if( s[lesen] != 'e')
s[schreiben++] = s[lesen];
}
s[schreiben] = 0;
}

void main()
{
char string[100];

printf( "String: ");


scanf( "%s", string);

erase( string);

printf( "%s\n", string);


}
Lösungen

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:

int abstand( int x, int y)


{
if( x >= y)
return x – y;
return y – x;
}

Mit der Funktion schlagen testen wir, ob sich zwei Damen x und y gegenseitig be-
drohen:

int schlagen( int x, int y, int damen[])


{
int dh, dv;

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.

int stellung_ok( int anz, int damen[])


{
int i, k;
Lösungen

for( k = 0; k < anz; k++)


{
for( i = 0; i < k; i = i+1)
{
if( schlagen( i, k, damen))
return 0;
}
}
return 1;
}

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.

void perm( int anz, int array[], int start)


{
int i;
char sav;

if( start < anz)


{
sav = array[ start];
for( i = start; i < anz; i = i+1)
{
array[start] = array[i];
array[i] = sav;
perm( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
else
{
Lösungen

if( stellung_ok( anz, array))


print_loesung( anz, array);
}
}

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.

1 Die Ausgabefunktion zeige ich Ihnen hier nicht erneut.

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;

printf( "Anzahl: ");


scanf( "%d", &anz);

perm( anz, damen, 0);


}

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

Abbildung A.23 Das Schema

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

Abbildung A.24 Das serialisierte Schema

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:

void ausgabe( int s[])


{
printf( " %d %d\n", s[0], s[1]);
printf( "%d %d %d %d\n", s[2], s[3], s[4], s[5]);
printf( " %d %d\n\n", s[6], s[7]);
}
Lösungen

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:

int test( int s[])


{
return 1;
}

1129
A Aufgaben und Lösungen

Im Programm perm erzeuge ich alle Permutationen und gebe diejenigen aus, die den
Test bestehen:

void perm( int anz, int array[], int start)


{
int i;
char sav;

if( start < anz)


{
sav = array[ start];
for( i = start; i < anz; i = i+1)
{
array[start] = array[i];
array[i] = sav;
perm( anz, array, start + 1);
array[i] = array[start];
}
array[start] = sav;
}
else
{
if( test( array))
ausgabe( array);
}
}

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

perm( 8, schema, 0);


}

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:

int zunah( int a, int b)


{
int abst;

abst = a – b;
if( abst < 0)
abst = – abst;
return abst <= 1;
}

Mit dieser Hilfsfunktion werden jetzt die 17 Filterbedingungen implementiert. Mit


jeder Filterbedingung reduziert sich dann die Zahl der ausgegebenen Lösungen, bis
am Ende nur noch vier Lösungen übrig bleiben. Das folgende Codefragment zeigt nur
fünf der 17 Bedingungen:

int test( int s[])


{
if( zunah( s[0], s[1]))
return 0;
if( zunah( s[0], s[2]))
return 0;
if( zunah( s[0], s[3]))
return 0;
Lösungen

if( zunah( s[0], s[4]))


return 0;
if( zunah( s[1], s[3]))
return 0;

// Weitere zwölf Bedingungen

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.

Implementieren Sie das beschriebene Verfahren.

Spoiler
Das oben beschriebene Verfahren können Sie in einer Funktion wie folgt implemen-
tieren:

void sortieren( int anz, int daten[])


{
int i, k, t, min;

for( i = 0; i < anz-1; i++)


{
min = i;
Lösungen

for( k = i+1; k < anz; k++)


{
if( daten[k] < daten[min])
min = k;
}
t = daten[min];
daten[min] = daten[i];
daten[i] = t;
}
}

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.

Implementieren Sie dieses Verfahren in einer Funktion.

Spoiler
Hier ist der Funktionscode:

void umordnen( int anz, int daten[])


{
int links, rechts, tmp;

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.

Ihre Funktion benötigt eine »rekursionsfähige« Schnittstelle, also eine Schnittstelle,


Lösungen

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

void umkehren( int links, int rechts, int daten[])

Implementieren Sie den Funktionskörper zu dieser Schnittstelle. Eine wichtige Auf-


gabe dabei ist, ein Kriterium zu finden, das die Rekursion abbricht.

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:

void umkehren( int links, int rechts, int daten[])


{
int tmp;

if( links >= rechts)


return;
tmp = daten[links];
daten[links] = daten[rechts];
daten[rechts] = tmp;
umkehren( links+1, rechts-1, daten);
}

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:

int daten[10] = {1,2,3,4,5,6,7,8,9,10};

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

Abbildung A.25 Bewegungen des Springers

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:

Startpunkt (Zeile Spalte): 1 1


Zielpunkt (Zeile Spalte): 1 2

+--+--+--+--+--+--+--+--+
| 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

| |31| | | |25| 8|11|


+--+--+--+--+--+--+--+--+
| | | |24| |14| 5|26|
+--+--+--+--+--+--+--+--+
|30| | | |28| 7|12| |
+--+--+--+--+--+--+--+--+
| | |29| |13| |27| 6|
+--+--+--+--+--+--+--+--+

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.

Diese Informationen reichen aus, um das Schachbrett zu initialisieren:

void initialisieren()
{
int z, s;

for( z = 0; z < 8; z++)


{
for( s = 0; s < 8; s++)
schachbrett[z][s] = –1;
}
}
Lösungen

Auch die Ausgabe kann schon geschrieben werden.

void ausgabe()
{
int z, s;

printf( "+--+--+--+--+--+--+--+--+\n");
for( z = 0; z < 8; z++)
{

1138
Kapitel 7

for( s = 0; s < 8; s++)


{
if( schachbrett[z][s] >= 0)
printf( "|%2d", schachbrett[z][s]);
else
printf( "| ");
}
printf( "|\n");
printf( "+--+--+--+--+--+--+--+--+\n");
}
}

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:

int zuege[8][2] = { {1,2},{-1,2},{1,-2},{-1,-2},


{2,1},{-2,-1},{-2,1},{-2,-1}};

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:

// Der Springer steht in zeile, spalte


for( z = 0; z < 8; z++)
{
// Springe zu zeile+zuege[z][0], spalte+zuege[z][1]
}

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:

왘 zug – die laufende Nummer des Zugs


왘 pz – der Zeilenindex der aktuellen Position
왘 ps – der Spaltenindex der aktuellen Position
왘 zz – der Zeilenindex der Zielposition
왘 zs – der Spaltenindex der Zielposition

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)

Jetzt können Sie den Algorithmus formulieren.

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;

if( (pz < 0) || (pz >= 8) || (ps < 0) || (ps >= 8)


|| (schachbrett[pz][ps]) != –1)
return 0;
schachbrett[pz][ps] = zug;
if( (pz ==zz) && (ps == zs))
return 1;
for( z = 0; z < 8; z++)
{
if( springer( zug+1, pz+zuege[z][0], ps+zuege[z][1], zz, zs))
return 1;
}
schachbrett[pz][ps] = –1;
return 0;
}

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;

printf( "Startpunkt (Zeile Spalte):");


scanf( "%d %d", &startz, &starts);
printf( "Zielpunkt (Zeile Spalte):");
scanf( "%d %d", &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:

Startpunkt (Zeile Spalte): 1 1


Zielpunkt (Zeile Spalte): 1 2

+--+--+--+--+--+--+--+--+
| 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

ständige, aber sehr umfangreiche Suche in allen Parallelzweigen durchführen. Es gibt


aber noch eine effizientere Strategie. Versuchen Sie, diese Strategie zu finden.

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;

if( (pz < 0) || (pz >= 8) || (ps < 0) || (ps >= 8) ||


(schachbrett[pz][ps] != –1) || (zug > maxt))
return 0;
schachbrett[pz][ps] = zug;
if( (pz ==zz) && (ps == zs))
return 1;
for( z = 0; z < 8; z++)
{
if( springer( zug+1,pz+zuege[z][0],ps+zuege[z][1], zz, zs, maxt))
return 1;
}
schachbrett[pz][ps] = –1;
return 0;
}
Lösungen

void main()
{
int tiefe;
int startz, starts, zielz, ziels;

printf( "Startpunkt (Zeile Spalte):");


scanf( "%d %d", &startz, &starts);

1143
A Aufgaben und Lösungen

printf( "Zielpunkt (Zeile Spalte):");


scanf( "%d %d", &zielz, &ziels);

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

Legen Sie jetzt die Schnittstelle fest.

Spoiler
Lösungen

Eine geeignete Schnittstelle ist:

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

Implementieren Sie jetzt die Funktion.

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;

minmaxmid( 10, daten, &min, &max, &mid);

printf( "Minimum %f\n", min);


printf( "Maximum %f\n", max);
printf( "Mittelwert %f\n", 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:

float *min, *max, *mid;


minmaxmid( 10, daten, min, max, mid);

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

왘 Rest bei Division durch 2


왘 Ausgabe auf dem Bildschirm

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.

Spezifizieren Sie im nächsten Schritt diese Schnittstellen.

Spoiler
Wir erstellen die drei Funktionen2, die wir später als Parameter übergeben wollen.

int div2( int v)


{
return v/2;
}

int rest2( int v)


{
return v%2;
}

int out( int v)


{
printf( "%d\n", v);
return v;
}

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:

int fkt( int)


Lösungen

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:

void doit( int anz, int *daten, int fkt( int))

2 So einfache, einzeilige Operationen erstellt man normalerweise nicht als Funktion.

1147
A Aufgaben und Lösungen

Die Funktion doit hat drei Parameter:

왘 anz – die Größe des Daten-Arrays


왘 daten – das Daten-Array
왘 fkt – eine Funktion, die einen int-Wert bekommt und einen int-Wert zurückgibt

Implementieren Sie diese Funktion.

Spoiler
In der Funktion wird über das Daten-Array iteriert, und für jedes Feld im Array wird
die gewünschte Funktion ausgeführt:

void doit( int anz, int *daten, int fkt( int))


{
int i;

for( i = 0; i < anz; i++)


daten[i] = fkt( daten[i]);
}

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};

doit( 10, daten, out);


doit( 10, daten, div2);
doit( 10, daten, out);
doit( 10, daten, rest2);
doit( 10, daten, out);
}

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:

int mystrlen( char *str)


{
if( !*str)
return 0;
return 1 + mystrlen( str+1);
}

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.

int mystrcmp( char *s1, char *s2)


{
if( *s1 != *s2)
return 0;
if( !*s1)
return 1;
return mystrcmp( s1+1, s2+1);
}

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.

void umkehren( int *links, int *rechts)


{
int tmp;

if( links >= rechts)


return;
tmp = *links;
*links = *rechts;
*rechts = tmp;
umkehren( links+1, rechts-1);
}

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

int add( int anz, ...)


{
// Funktionscode
}

erstellen, die Sie dann mit unterschiedlicher Parameterzahl rufen können. Zum Bei-
spiel:

a = add( 5, 1, 2, 3, 4, 5);
Lösungen

a = add( 6, 2,-2, 7,-1, 4, 0);


a = add( 7, 0,-4,-2, 8, 1, 5, 6);

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

a = add( 5, 1, 2, 3, 4, 5); // a = 1+2+3+4+5 = 15


a = add( 6, 2,-2, 7,-1, 4, 0); // a = 2-2+7-1+4+0 = 10
a = add( 7, 0,-4,-2, 8, 1, 5, 6); // a = 0-4-2+8+1+5+6 = 14

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;

und diesen Zeiger auf den ersten Parameter zeigen lassen,

p = &anz;

dann finden Sie hinter diesem Parameter tatsächlich den zweiten Parameter. Prüfen
Sie dies durch eine Testausgabe:

printf( "%d\n", *(p+1));

Erstellen Sie jetzt die komplette Funktion.


Lösungen

Spoiler
Sie können jetzt durch den Stack iterieren und alle dort gefundenen Werte addieren:

int add( int anz, ...)


{
int i, sum;
int *p;

1152
Kapitel 8

p = &anz;
for( sum = 0, i = 1; i <= anz; i++)
sum += *(p+i);
return sum;
}

Testen Sie Ihr Programm durch unterschiedliche Funktionsaufrufe.

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

Beide Formate kommen in unterschiedlichen Hardwarearchitekturen vor. Der


Unterschied zwischen Little- und Big-Endian-Format ist auch im Zusammenhang
mit Netzwerkkommunikation von zentraler Bedeutung, da festgelegt werden muss,
in welcher Reihenfolge die einzelnen Bytes einer Integer-Zahl im Netzwerk übertra-
gen werden müssen. Das Internetprotokoll gibt eine Big-Endian-Reihenfolge vor. Alle
Systeme müssen bei der Übertragung von Integer-Zahlen diese sogenannte Network
Byte Order einhalten. Umgangssprachlich ist Ihnen dieses Problem bekannt. Wenn
wir zweistellige Zahlen sprachlich übermitteln, verwenden wir im Deutschen das
Little-Endian-Format (»einundzwanzig«), während in Großbritannien das Big-
Endian-Format (»twenty one«) verwendet wird.

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

p = (unsigned char *)&testzahl;

printf( "%d: %d\n", p, *p);


printf( "%d: %d\n", p+1, *(p+1));

if( *p == 0x01)
printf( "Big-Endian\n");
else
printf( "Little-Endian\n");
}

Wichtig ist hier die Anwendung des Cast-Operators:

(unsigned char *)&testzahl;

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

schreibt, deren Name als Parameter an die Funktion übergeben wird.

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.

Implementieren Sie jetzt die Funktion.

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

fprintf( pf, ...);

und beim Schließen der Datei

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:

void zfdatei( char *dname, int anz, int seed)

Es folgt der vollständige Funktionscode:

void zfdatei( char *dname, int anz, int seed)


{
Lösungen

char str[100];
int i;
FILE *pf;

srand( seed);
pf = fopen( dname, "w");

for( i = 0; i < anz; i++)


{

1156
Kapitel 10

zfstring( str, zfzahl( 10, 50), 'a', 'z');


fprintf( pf, "%s\n", str);
}
fclose( pf);
}

Im Hauptprogramm werden die erforderlichen Daten eingelesen und an die Funk-


tion zfdatei weitergereicht:

void main()
{
int anzahl, seed;
char datei[80];

printf( "Dateiname: ");


scanf( "%s", datei);
printf( "Anzahl: ");
scanf( "%d", &anzahl);
printf( "Startwert ZF-Generator: ");
scanf( "%d", &seed);

zfdatei( datei, anzahl, seed);


}

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.

Es sind wieder drei Dinge zu tun:

1. Öffnen der Datei zum Lesen


2. Lesen und Analysieren der Zeichenketten
3. Schließen der Datei

Für die drei Operationen benötigen Sie wieder den magischen Dateizeiger.

Implementieren Sie jetzt die Funktion.

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:

void analyse( char *dname)


{
char str[100];
FILE *pf;

pf = fopen( dname, "r");


for( ; ; )
{
fscanf( pf, "%s", str);
if( feof( pf))
break;
printf( "%s\n", str);
}
fclose( pf);
}

Im Hauptprogramm wird nur der Dateiname abgefragt, um dann die Funktion ana-
lyse aufzurufen.

void main()
{
int anzahl, seed;
char datei[80];

printf( "Dateiname: ");


scanf( "%s", datei);
Lösungen

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.

void analyse( char *dname)


{
char str[100];
int i, anz;
FILE *pf;
int statistik[26];

for( i = 0; i < 26; i++)


statistik[i] = 0;
anz = 0;
pf = fopen( dname, "r");
for( ; ; )
{
fscanf( pf, "%s", str);
if( feof( pf))
break;
for( i = 0; str[i]; i++, anz++)
statistik[str[i]-'a']++;
}
fclose( pf);
for( i = 0; i < 26; i++)
printf( "%c: %5d %4.2f\n", 'a'+i, statistik[i],
(100.0*statistik[i])/anz);
}

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:

int atoi (const char * str);

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.

Die Funktion geht wie folgt vor:

왘 Zunächst überspringt die Funktion alle »Whitespaces« am Anfang des Strings.


Whitespaces sind SPC, TAB, LF, VT, FF und CR. Den Zeichencode dieser Zeichen ent-
nehmen Sie der ASCII-Tabelle.
왘 Dann liest die Funktion ein optionales Vorzeichen (+ oder –).
왘 Danach liest die Funktion nur noch Dezimalziffern.

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.

Implementieren Sie jetzt die Funktion myatoi.

Spoiler
Bei der Implementierung folgen wir den Anforderungen:

int myatoi( char *s)


{
int vz, z;

while( (*s==' ') || (*s=='\t') || (*s=='\n') || (*s=='\v') ||


(*s=='\f') || (*s=='\r'))
s++;
vz = 1;
if( *s == '-')
{
vz = –1;
s++;
}
for( z = 0; *s && (*s>='0') && (*s<='9'); s++)
z = 10*z + *s – '0';
return vz*z;
}

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.

Implementieren Sie jetzt die erforderlichen Funktionen.

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.

Erstellen Sie zunächst die Schnittstelle für die beiden Funktionen.

Spoiler
Bis auf die Benennung der Parameter sollten Ihre Schnittstellen wie folgt definiert
sein:

void kuerzen( struct bruch *b)

struct bruch addieren( struct bruch b1, struct bruch b2)

Implementieren Sie jetzt die beiden Funktionen.

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.

Hier sind meine Implementierungen:


Lösungen

void kuerzen( struct bruch *b)


{
int gt;

gt = ggt( b->zaehler, b->nenner);


b->zaehler = b->zaehler/gt;
b->nenner = b->nenner/gt;
}

1163
A Aufgaben und Lösungen

Die Funktion addieren verwendet bereits die Funktion kuerzen und übergibt dazu die
Adresse des intern verwendeten Bruchs erg:

struct bruch addieren( struct bruch b1, struct bruch b2)


{
struct bruch erg;

erg.zaehler = b1.zaehler*b2.nenner + b2.zaehler*b1.nenner;


erg.nenner = b1.nenner*b2.nenner;
kuerzen( &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.

Schreiben Sie jetzt noch ein Hauptprogramm, um Ihre Funktionen zu testen.

Spoiler
Hier ist ein einfaches Testprogramm:

void main()
{
struct bruch bruch1, bruch2, ergebnis;

printf( "Bruch1: ");


Lösungen

scanf( "%d/%d", &bruch1.zaehler, &bruch1.nenner);


printf( "Bruch2: ");
scanf( "%d/%d", &bruch2.zaehler, &bruch2.nenner);

ergebnis = addieren( bruch1, bruch2);

printf( "Ergebnis: %d/%d\n", ergebnis.zaehler, ergebnis.nenner);


}

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

struct person daten[ANZAHL];

Die Aufgabe lässt sich in drei Teilaufgaben zerlegen:

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];

einlesen( ANZAHL, daten);


bubblesort( ANZAHL, daten);
ausgeben( ANZAHL, daten);
}

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.

Implementieren Sie jetzt die drei Funktionen.

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:

void einlesen( int anz, struct person *p)


{
int i;

for( i = 0; i < anz; i++)


{
printf( "Name Alter: ");
scanf( "%s %d", p[i].name, &p[i].alter);
}
}
Lösungen

void ausgeben( int anz, struct person *p)


{
int i;

for( i = 0; i < anz; i++)


{

1166
Kapitel 14

printf( "%s (%d)\n", p[i].name, p[i].alter);


}
}

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.

Der Rest der Funktion muss nicht verändert werden:

void bubblesort( int n, struct person *daten)


{
int i, k;
struct person t;

for( i = n-1; i > 0; i--)


{
for( k = 0; k < i; k++)
{
if( daten[k].alter > daten[k+1].alter)
{
t = daten[k];
daten[k] = daten[k+1];
daten[k+1] = t;
}
}
}
}
Lösungen

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:

1. Rückgabe der Ergebnisstruktur über Zeigerparameter


2. Rückgabe der Ergebnisstruktur über den Returnwert

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.

An den Schnittstellen wird diese Datenstruktur dann verwendet.

Variante 1: Rückgabe per Zeigerparameter

void auswerten1( int anz, int *daten, struct auswertung *a)

Variante 2: Rückgabe per Returnwert

struct auswertung auswerten2( int anz, int *daten)

Zur Verwendung der ersten Variante muss ein Zeiger auf die Ergebnisstruktur über-
geben werden:
Lösungen

int daten[10] = {1,2,3,4,5,6,7,8,9,10};


struct auswertung ausw;

auswerten1( 10, daten, &ausw1);

Bei der zweiten Variante kann das Ergebnis zugewiesen werden:

1168
Kapitel 14

int daten[10] = {1,2,3,4,5,6,7,8,9,10};


struct auswertung ausw;

ausw = auswerten2( 10, daten);

Implementieren Sie jetzt die beiden Funktionen.

Spoiler
Bei Variante 1 wird durch Indirektzugriff auf der vom rufenden Programm übergebe-
nen Datenstruktur gearbeitet:

void auswerten1( int anz, int *daten, struct auswertung *a)


{
int i;

a->min = a->max = a->mid = daten[0];


for( i = 1; i < anz; i++)
{
if( daten[i] < a->min)
a->min = daten[i];
if( daten[i] > a->max)
a->max = daten[i];
a->mid += daten[i];
}
a->mid /= anz;
}

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 auswerten2( int anz, int *daten)


Lösungen

struct auswertung a;
int i;

a.min = a.max = a.mid = daten[0];


for( i = 1; i < anz; i++)
{
if( daten[i] < a.min)
a.min = daten[i];

1169
A Aufgaben und Lösungen

if( daten[i] > a.max)


a.max = daten[i];
a.mid += daten[i];
}
a.mid /= anz;
return a;
}

Die lokale Datenstruktur wird abschließend an das rufende Programm zurückgege-


ben (return a).

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:

왘 die Namen aller beteiligten Mannschaften


왘 die Ergebnisse aller durchgeführten Spiele

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:

왘 eine Funktion, die alle Daten einliest


왘 eine Funktion, die die Tabelle berechnet
왘 eine Funktion, die alle Daten und die aktuelle Tabelle ausgibt

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

Datenstrukturen dann zu Arrays oder komplexeren Datenstrukturen zusammen,


sodass am Ende alle erforderlichen Daten in einer Struktur gespeichert sind.

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:

struct ergebnis resultat[ANZAHL][ANZAHL];

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];

In einer Tabelle stehen für jeden Verein:

왘 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

Damit ergibt sich für einen Verein die folgende Auswertung:

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:

struct auswertung tabelle[ANZAHL];

Jetzt haben wir alle Bausteine, die wir noch zu einer Gesamtheit zusammenfügen
müssen.

Die folgende Datenstruktur enthält alle Daten eines Turniers:

struct turnier
{
char mannschaft[ANZAHL][20];
struct ergebnis resultat[ANZAHL][ANZAHL];
struct auswertung tabelle[ANZAHL];
};

Neben dem formalen Aufbau gibt es in einer Datenstruktur immer Konsistenzbedin-


gungen, die erfüllt sein müssen, damit die Daten korrekt verarbeitet werden können.
Unterscheiden Sie zwischen Konsistenz und Plausibilität. Zum Beispiel ist
1000:1000 ein wenig plausibles Spielergebnis, aber unsere Datenstruktur würde
Lösungen

dadurch nicht inkonsistent, und auch Funktionen wie die Tabellenberechnung könn-
ten mit solchen Ergebnissen durchgeführt werden.

Wir wollen alle Konsistenzbedingungen sammeln, die in unserer Datenstruktur von


Bedeutung sind:

왘 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:

void daten_einlesen( struct turnier *t)

void daten_ausgeben( struct turnier *t)

void tabelle_berechnen( struct turnier *t)

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.

Hier ist meine Lösung zur Eingabe:

void daten_einlesen( struct turnier *t)


{
int z, s;

for( z = 0; z < ANZAHL; z++)


{
printf( "Mannschaft %d: ", z+1);
scanf( "%s",t->mannschaft[z]);
Lösungen

}
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

scanf( "%d:%d", &(t->resultat[z][s].tore),


&(t->resultat[z][s].gegentore));
}
}
}

In einer Schleife werden zunächst alle Vereinsnamen eingelesen. Dann werden in


einer Doppelschleife alle Spielergebnisse abgefragt. Wenn ein Spiel noch nicht statt-
gefunden hat, muss ein negatives Torresultat angegeben werden.

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.

void daten_ausgeben( struct turnier *t)


{
int z, s;

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

}
}

Implementieren Sie jetzt die noch fehlende Berechnung der Tabelle.

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.

void tabelle_berechnen( struct turnier *t)


{
int z, s;

for( z = 0; z < ANZAHL; z++)


{
t->tabelle[z].team = z;
t->tabelle[z].spiele = 0;
t->tabelle[z].punkte = 0;
t->tabelle[z].tore = 0;
t->tabelle[z].gegentore = 0;
}
for( z = 0; z < ANZAHL; z++)
{
for( s = 0; s < ANZAHL; s++)
{
if( z == s)
continue;
if( t->resultat[z][s].tore < 0)
continue;
t->tabelle[z].spiele++;
t->tabelle[s].spiele++;
if( t->resultat[z][s].tore == t->resultat[z][s].gegentore)
{
t->tabelle[z].punkte++;
t->tabelle[s].punkte++;
}
else
{
if( t->resultat[z][s].tore > t->resultat[z][s].gegentore)
t->tabelle[z].punkte +=3;
Lösungen

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:

1. A hat mehr Punkte als B.


2. A hat ein besseres Torverhältnis als B.
3. A hat mehr Tore geschossen als B.

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:

int vergleich( struct auswertung *a1, struct auswertung *a2)


{
int d1, d2;

if( a1->punkte > a2->punkte)


return 1;
if( a1->punkte < a2->punkte)
return –1;
d1 = a1->tore – a1->gegentore;
d2 = a2->tore – a2->gegentore;
if( d1 > d2)
return 1;
if( d1 < d2)
return –1;
Lösungen

if( a1->tore > a2->tore)


return 1;
if( a1->tore < a2->tore)
return –1;
return 0;
}

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:

void bubblesort( int n, struct auswertung *daten)


{
int i, k;
struct auswertung t;

for( i = n-1; i > 0; i--)


{
for( k = 0; k < i; k++)
{
if( vergleich( daten+k, daten+k+1) < 0)
{
t = daten[k];
daten[k] = daten[k+1];
daten[k+1] = t;
}
}
}
}

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:

왘 Erzeugen der Liste mit einer wählbaren Anzahl fortlaufender Zahlen


왘 Ausgeben der Liste
Lösungen

왘 Ausgeben der Liste in umgekehrter Reihenfolge


왘 Addieren aller Zahlenwerte in der Liste
왘 Umkehren der Liste
왘 Freigeben der Liste

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.

Entsprechend der Aufgabenstellung benötigen wir sechs Funktionen, deren Schnitt-


stellen wir vorab festlegen.

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:

struct element *liste_erzeugen( int anz)

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:

void liste_ausgeben( struct element *e)

void liste_umgekehrt_ausgeben( struct element *e);

void liste_freigeben( struct element *e)

Die Funktion zum Addieren der Listenwerte gibt zusätzlich die berechnete Summe
Lösungen

zurück:

int liste_addieren( struct element *e)

Die Funktion zum Umkehren der Liste gibt einen Zeiger auf das erste Element der
neu arrangierten Liste zurück:

struct element *liste_invertieren( struct element *e)

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;

liste = liste_erzeugen( 10);


liste_ausgeben( liste);
liste_umgekehrt_ausgeben( liste);
printf( "Summe = %d\n", liste_addieren( liste));
liste = liste_invertieren( liste);
liste_ausgeben( liste);
liste_freigeben( liste);
}

Das Speichermanagement ist für dynamische Datenstrukturen wie Listen besonders


wichtig. Wichtig ist insbesondere, dass Sie sich bereits beim Anlegen einer dynami-
schen Datenstruktur Gedanken über deren zukünftige Beseitigung machen. Erstel-
len Sie daher im ersten Schritt die Funktionen liste_erzeugen und liste_freigeben.

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«.

Hier sehen Sie den Code zum Aufbau der Liste:

struct element *liste_erzeugen( int anz)


{
struct element *elm, *tmp;
Lösungen

for( elm = 0; anz > 0; anz--)


{
tmp = (struct element *)malloc( sizeof( struct element));
tmp->wert = anz;
tmp->next = elm;
elm = tmp;
}
return elm;
}

1180
Kapitel 14

Das ist der Code zur Freigabe der Liste:

void liste_freigeben( struct element *e)


{
struct element *tmp;

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:

void liste_ausgeben( struct element *e)


{
for( ; e; e = e->next)
printf( "%d\n", e->wert);
}

Die Funktion zum Addieren aller Listenwerte:

int liste_addieren( struct element *e)


{
int sum;

for( sum = 0; e; e = e->next)


Lösungen

sum += e->wert;
return sum;
}

Um die Listenelemente in umgekehrter Reihenfolge auszugeben, können Sie nicht so


einfach über die Liste iterieren, da wir ja nur eine Vorwärtsverkettung haben. Versu-
chen Sie es mit Rekursion.

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:

void liste_umgekehrt_ausgeben( struct element *e)


{
if( !e)
return;
liste_umgekehrt_ausgeben( e->next);
printf( "%d\n", e->wert);
}

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

Abbildung A.26 Skizze der Liste zur Invertierung


Lösungen

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:

struct element *liste_invertieren( struct element *liste)


{
struct element *inverse, *tmp;

for( inverse = 0; liste;)


{
tmp = liste;
liste = liste->next;
tmp->next = inverse;
inverse = tmp;
}
return inverse;
}

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:

struct element *liste_erzeugen( int anz)

void liste_ausgeben( struct element *e)

void liste_freigeben( struct element *e)

Mit diesen Funktionen könnte dann das Hauptprogramm wie folgt realisiert werden:

void main()
{
struct element *liste;

liste = liste_erzeugen( 10);


liste_ausgeben( liste);
Lösungen

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

struct element *liste_erzeugen( int anz)


{
struct element *elm, *tmp;

for( elm = 0; anz > 0; anz--)


{
tmp = (struct element *)malloc( sizeof( struct element));
tmp->wert = anz;
tmp->next = elm;
if( elm)
elm->prev = tmp;
tmp->prev = 0;
elm = tmp;
}
return elm;
}

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.

Die Ausgabeschleife wird verlassen, sobald der Benutzer 0 eingibt.

void liste_ausgeben( struct element *e)


{
char eingabe[ 8];

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:

x + y = (Re(x) + Re(y), Im(x) + Im(y))

x · y = (Re(x) Re(y) – Im(x) Im(y), Re(x) · Im(y) + Im(x) · Re(y))

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

Abbildung A.27 Darstellung der komplexen Zahlen in der gaußschen Zahlenebene

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);

float getRe() {return re;}


float getIm() {return im;}
komplex operator+(komplex& Z2);
komplex operator*(komplex& Z2);
Lösungen

komplex operator*(float a);


komplex operator-(komplex& Z2);
komplex operator/(float a);
};

Jetzt sollten Sie einen Ausgangspunkt haben, um Ihre Implementierung zu starten.


Beginnen Sie am besten mit dem Konstruktor, und erstellen Sie dann den operator+,
um zwei komplexe Zahlen zu addieren.

1188
Kapitel 20

Spoiler
Der Konstruktor der Klasse muss nicht mehr tun, als die beiden privaten Attribute zu
initialisieren:

komplex::komplex( float x, float y )//Konstruktor


{
re = x;
im = y;
}

Der Operator ist nach der oben angegebenen Rechenvorschrift ebenfalls einfach
implementiert:

komplex komplex::operator+( komplex& Z2 )


{
komplex Z;

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:

Abfrage der komplexen Zahlen:


Lösungen

1. komplexe Zahl [x]:


Realteil: 1
Imaginaerteil: 3
2. komplexe Zahl [y]:
Realteil: 2
Imaginaerteil: 4
Konstante a: 2

1189
A Aufgaben und Lösungen

Die von Ihnen eingegebenen Zahlen:

x = 1 + 3i

y = 2 + 4i

a = 2

Geben Sie nun einfach die gewuenschten Operationen ein. [ q = ENDE ]

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:

Setzen eines bestimmten Datums


Lösungen

왘 Vergleich zweier Daten (gleich, vorher, nachher)


왘 Addition einer bestimmten Anzahl von Tagen zu einem Datum
왘 Bestimmung der Differenz (= Anzahl Tage) zwischen zwei Daten
왘 Berechnen des Wochentags eines Datums
왘 Feststellung, ob es sich um einen Feiertag handelt
왘 Erzeugen von Textstrings in unterschiedlichen Formaten

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.

Bevor Sie beginnen, sollten Sie wieder einige Vorüberlegungen anstellen:

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;

int schaltjahr(int j);


int datumOk(int tag, int mon, int jahr);
unsigned int tageDesMonats(int mon, int jahr);
unsigned int dat2Tage();

1191
A Aufgaben und Lösungen

void tage2Dat(unsigned long int tage);

public:
datum();
datum(int t, int m, int j);
datum(int t, int m, int j, int mod);

void setDatum(int t, int m, int j);


void setDatum(datum& neu);
void setModus(int mod);

datum operator+(int t);


int operator-(datum& dat);
int operator==(datum& dat);
int operator>(datum& dat);
int operator<(datum& dat);

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:

왘 schaltjahr: Überprüft, ob das im Eingabeparameter übergebene Jahr ein Schalt-


jahr ist.
왘 datumOK: Überprüft, ob das Datum gültig ist, also ob der Tag in diesem Monat und
Jahr existiert hat.
왘 tageDesMonats: Berechnet die Anzahl der Tage im ihr als Parameter übergebenen
Monat.
왘 dat2Tage: Berechnet die Anzahl der Tage seit dem 1.1.1900 (Nullpunkt).
왘 tage2Dat: Bestimmt das Datum für die seit dem 1.1.1900 als Parameter übergebene
Lösungen

Anzahl von Tagen.

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:

unsigned int datum::dat2Tage()


{
unsigned int anz = 0;
int i;
int j;

A j = jahr – 1900;
anz = j*365 + j/4 – j/100 + j/400 + 1;

B for ( i = 1; i < mon; i++)


anz+=tageDesMonats(i, jahr);

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.

Die Funktion tageDesMonats arbeitet mit einer einfachen switch-Anweisung, um die


Monatslängen zurückzugeben:

unsigned int datum::tageDesMonats(int mon, int jahr)


{
switch(mon)
{
case 4:
Lösungen

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«.

Mein Testprogramm erzeugt die folgende Ausgabe:

1194
Kapitel 20

Test der Konstruktoren und der Ausgabe:


1.1.1900
24.12.2014
28. Dez. 2014

Test des "+"-Operators:


29. Dez. 2014
30. Dez. 2014
31. Dez. 2014
1. Jan. 2015
2. Jan. 2015
3. Jan. 2015

Test des "-"-Operators zwischen zwei Daten:


3. Jan. 2015 – 24.12.2014 = 10

Test der Vergleichoperatoren "== < >"


3. Jan. 2015 == 24.12.2014 : 0
3. Jan. 2015 < 24.12.2014 : 0
3. Jan. 2015 > 24.12.2014 : 1

Test der Wochentagsberechnung:


Der 24.12.2014 ist ein Mittwoch

Test der Feiertagsbestimmung:


Der 25.12.2002 ist ein Feiertag
Der 24.12.2014 ist kein Feiertag
Der 3. Jan. 2015 ist kein Feiertag
Der 1. Jan. 2015 ist ein Feiertag

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

Abbildung A.28 Schematischer Aufbau der Enigma

Um eine Vorstellung vom mechanischen Aufbau der Enigma zu bekommen, müssen


Sie sich die einzelnen Bauteile ausgeschnitten und an den Schmalseiten zu Walzen
Lösungen

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.

Die linke Buchstabenreihe in der Abbildung repräsentiert hier die Schreibmaschi-


nentastatur und die leuchtenden Lämpchen gleichzeitig.

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.

Erstellen Sie eine Soft-Enigma mit folgenden Leistungsmerkmalen:

왘 Konfiguration der Rotoren und des Reflektors über separate Konfigurations-


dateien
왘 interaktive Auswahl der Konfigurationsdateien für die Rotoren und den Reflektor
왘 interaktive Konfiguration des Steckbretts
왘 interaktive Eingabe der Anfangsstellung für die drei Rotoren
왘 Verschlüsseln und Entschlüsseln von Tastatureingaben
왘 Verschlüsseln und Entschlüsseln von Dateien

Entschlüsseln Sie die folgende Botschaft, die von einer Engima mit der oben genann-
Lösungen

ten Konfiguration verschlüsselt wurde:

PJS UGGQFP LTV


PHAZBRXE VKE YYWLBTBORYC
MGU QA BNQXXK FZL GJ TWGNQKJWR
PQV DXQWINKGGLXKP
ZTMMM GZVCAIVNCQI AGSHRY

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:

왘 eine Datei für das Steckbrett: steckbrett.cfg


왘 eine Datei für die Rotoren: rotoren.cfg
왘 eine Datei für den Reflektor: reflektor.cfg

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
...

Listing A.2 Auszug der Datei steckbrett.cfg


Lösungen

Das Gleiche gilt für den Reflektor:

6
4
18
12
1
...

Listing A.3 Auszug der Datei reflektor.cfg

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
...

Listing A.4 Auszug der Datei rotoren.cfg

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

Abbildung A.29 Die Enigma im Detail für unser Beispiel

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.

Probieren Sie dies am Beispiel für das »A«:

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

Wenn Sie den Ausdruck vereinfachen, erhalten Sie

neu = 'A' +1

und damit:

neu = 'B'

Nun dürfte Enigma, die weltberühmte Verschlüsselungsmaschine des 2. Weltkriegs,


für Sie kein Rätsel mehr darstellen. Nichts anderes bedeutet deren Name nämlich,
der aus dem Griechischen stammt.

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 );

A inline int hin( int links );


B inline int zur( int links );
};

Das Steckbrett stellt Funktionen bereit, um der inneren Verdrahtung folgen zu kön-
Lösungen

nen (A, B).

class rotor
{
private:
int r[26];
int r_akt;
A rotor *next;

1200
Kapitel 20

public:
rotor( int *rr, rotor *n, int r_a );

void set_pos( int r_a );


inline int akt() { return r_akt; }

inline int hin( int links );


inline int zur( int rechts );
B inline int diff( int val );

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).

Ein Reflektor ist ein sehr einfaches Objekt:

class reflektor
{
private:
int r[26];

public:
reflektor( int *ref );

inline int hin( int links );


};
Lösungen

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();

C char* work( char *c );


};

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

Der Programmdialog und die Entschlüsselung


Wenn Sie das Programm umsetzen oder die Lösung aus den Materialien zum Buch
übersetzen, können Sie mit der abgelesenen Konfiguration den verschlüsselten Text
übersetzen:
Lösungen

Config-Datei des Steckbretts: steckbrett.cfg


Config-Datei der Rotoren: rotoren.cfg
Config-Datei des Reflektors: reflektor.cfg

DIE WUERDE DES


MENSCHEN IST UNANTASTBAR
SIE ZU ACHTEN UND ZU SCHUETZEN
IST VERPFLICHTUNG
ALLER STAATLICHEN GEWALT

Sie erhalten als Ergebnis den ersten Artikel unseres Grundgesetzes.

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.

Stellen Sie die folgenden Methoden und Operatoren zur Verfügung:

왘 Erstellen eines Strings aus einer Zeichenkette


왘 Erstellen eines Strings aus einem anderen String
왘 Verketten zweier Strings miteinander
왘 Anfügen eines Buchstabens an einen String
왘 Einfügen eines Strings in einen anderen String
왘 Suchen im String
왘 Vergleich der Strings

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:

A string( char *txt );


B string( const string &s );
Lösungen

~string();

int getLen() { return len; }


char *getText(){ return text; }
void setText( char *buf );

C string operator+( const string &t );


D string& operator=( const string &t );

1204
Kapitel 21

int searchFor( const string &t );


E int compareCs( const string &t );
F int compareCis( const string &t );
void insert( const string &t, int pos );
void extract( int from, int to );
void setChar( int pos, char c );
char setChar( int pos );
};

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:

string::string( const string &s )


{
A alen = s.len;
B len = s.len;
C text = ( char * ) malloc( ( s.len + 1 )*sizeof( char ) );
D strcpy( text, s.text );
}

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

string& string::operator=( const string &t )


{
A if( t.len > alen )
{
B text = ( char * ) realloc( text, ( t.len + 1 )*sizeof( char ) );

strcpy( text, t.text );


len = t.len;
alen = t.len;
}
else
{
C strcpy( text, t.text );
D len = t.len;
}

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.

Wenn der Speicher im bestehenden Objekt allerdings ausreicht, wird er weiterver-


wendet, und der Text wird direkt kopiert (C). In diesem Fall wird nur len als Wert des
verwendeten Speichers angepasst (D).

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).

Schauen wir uns noch den überladenen Additionsoperator an:

A string string::operator+( const string &t )


Lösungen

{
B string neu( text );

C if( ( neu.len + t.len ) >= neu.alen )


{
neu.text = ( char * ) realloc( neu.text,
( neu.len + t.len + 1 )*sizeof( char ) );

D strcat( neu.text, t.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:

w nr text -> write text to string nr[0-9]


s nr -> show string with nr[0-9]
a nr1 nr2 -> add to string nr1 [0-9] string nr2 [0-9]
Lösungen

g nr1 nr2 -> search for string nr2 in string nr1


c nr1 nr2 -> compare strings case sensitive
i nr1 nr2 -> compare strings case insensitive
e nr1 nr2 pos -> insert string nr2 into string nr1 at position pos
r nr1 from to -> extract part of string nr1 (from...to)
t nr pos char -> put char in string nr at position pos
h nr pos -> get char at pos from string nr
q -> quit program

1207
A Aufgaben und Lösungen

Command: w 0 C/
Command: w 1 C++
Command: a 0 1
Command: s 0

Der Text: C/C++

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!

Prüfen Sie z. B. auch die folgenden Fälle:

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

Beispiel (Forts.) cin................................................................................. 750


Sudoku ................................................................... 175 class .................................................................... 725, 879
Tilgungsplan........................................................... 86 Class-Member-Operator...................................... 734
Türme von Hanoi .............................................. 194 Compiler................................................................ 22, 42
Würfelspiel ........................................................... 831 Compile-Schalter........................................... 247, 600
Wurzel ziehen......................................................... 92 # define ................................................................. 601
Zahlen summieren ............................................... 79 # elif........................................................................ 601
Zahlenraten ......................................................... 152 # else ...................................................................... 601
Bewegungsdaten .................................................... 722 # endif.................................................................... 601
Bildschirmausgabe ................................................... 67 # error.................................................................... 602
Binärbaum ................................................................ 449 # if ........................................................................... 601
Binomialkoeffizient .............................................. 277 # ifdef..................................................................... 601
Bit.................................................................................. 137 # ifndef .................................................................. 601
least significant Bit ........................................... 137 # line....................................................................... 602
most significant Bit .......................................... 137 # pragma.............................................................. 602
bitand .......................................................................... 706 # undef .................................................................. 601
Bitfeld.......................................................................... 593 compl .......................................................................... 706
Bitoperation ............................................................. 146 const................................................................... 602, 885
Bit invertieren ........................................... 149, 596 const_cast ................................................................. 950
Bit löschen .................................................. 149, 596 Container
Bit setzen..................................................... 148, 596 Vererbung........................................................... 1037
Bit testen ............................................................... 150 virtuelle Funktion............................................ 1037
Bitoperator................................................................ 594 continue .................................................... 64, 603, 618
Entweder-Oder.................................................... 147 Copy-Konstruktor......................................... 788, 929
Komplement........................................................ 147 Cosinus....................................................................... 254
Oder......................................................................... 147 cout.............................................................................. 748
Shift ......................................................................... 148
Und .......................................................................... 147 D
bitor ............................................................................. 706
Blatt.............................................................................. 448 Dateioperation
Block ............................................................................ 597 fclose....................................................................... 261
bool .............................................................................. 681 feof .......................................................................... 261
Boolesche Funktion............................................... 116 fopen ...................................................................... 261
break ........................................................... 64, 598, 618 fprintf ..................................................................... 261
Breitensuche ............................................................ 451 fscanf...................................................................... 261
Bubblesort................................................................. 349 Dateioperationen.......................................... 603, 752
Buffer Overflow....................................................... 189 Datenbus ................................................................... 137
Byte .............................................................................. 137 Datenmember................................................ 726, 881
Datenstruktur...................................................... 22, 28
abstrakter Datentyp ........................................ 493
C
Alignment ............................................................ 577
C Runtime Library........................................ 253, 603 Baum ...................................................................... 448
C Standard Library ................................................. 603 Bitfeld..................................................................... 593
C++-Standardbibliothek ...................................... 973 Definition ............................................................. 398
Callback-Funktion.................................................. 238 Deklaration.......................................................... 395
calloc.................................................................. 265, 651 Direktzugriff ........................................................ 401
case............................................................................... 598 Einlesen aus Datei............................................. 409
Cast............................................................................... 948 Hash-Tabelle ....................................................... 482
Cast-Operator.................................................... 55, 598 Indirektzugriff .................................................... 403
catch............................................................................. 963 Liste................................................................ 417, 439
char ........................................................... 157, 599, 606

1211
Index

Datenstruktur (Forts.) Distanzenmatrix .................................................... 531


struct............................................................. 395, 656 Division......................................................................... 50
Treap....................................................................... 470 Division ohne Rest ................................................... 52
union ...................................................................... 663 do ... while.................................................................. 613
Zugriff..................................................................... 400 double...................................................... 144, 607, 614
Zuweisung ............................................................ 400 Dualsystem............................................................... 134
Datenstrukturen..................................................... 393 Durchschnitt............................................................ 757
dynamische Datenstrukturen ...................... 415 Dynamische Datenstrukturen ......................... 415
Funktionsparameter ........................................ 405 Dynamische Instanziierung.............................. 743
vollständiges Beispiel....................................... 409 Dynamisches Array............................................... 745
Datentyp .................................................................... 608 Dynamisches Binden .................................. 821, 855
Aufzählungstyp.................................................. 615 Dynamisches Objekt............................................. 923
bool.......................................................................... 681
char ............................................................... 599, 606 E
double..................................................................... 607
float........................................................ 47, 607, 618 Editor ............................................................................. 41
ganze Zahl ............................................................ 606 e-Funktion ................................................................ 254
Gleitkommazahl ................................................ 607 Eingabe....................................................... 69, 260, 614
int...................................................................... 47, 606 else ........................................................................ 57, 615
long ......................................................................... 606 Elternklasse .............................................................. 805
long double .......................................................... 607 end ............................................................................... 975
short.............................................................. 606, 650 endl .............................................................................. 749
signed ........................................................... 606, 650 enum.................................................................. 615, 679
skalarer Datentyp.............................................. 139 Erweiterung abgeleiteter Klassen ................... 813
typedef ................................................................... 661 Escape-Sequenz ...................................................... 616
unsigned...................................................... 606, 664 Exabyte....................................................................... 139
Zeichen................................................................... 608 exception .................................................................. 966
Datenzugriff ............................................................. 609 Exception Handling.............................................. 963
direkter Zugriff.................................................... 609 Exponent................................................................... 145
indirekter Zugriff ............................................... 610 extern ................................................................ 617, 700
indizierter Zugriff .............................................. 610
Debugger ...................................................................... 43 F
default......................................................................... 611
Default-Werte .......................................................... 692 fabs............................................................................... 860
Definition ........................................................ 249, 611 Fakultät ......................................................................... 99
Deklaration ..................................................... 249, 612 false.............................................................................. 682
externe ................................................................... 617 fclose .................................................................. 261, 605
delete................................................................. 744, 923 feof ...................................................................... 262, 605
delete[] ..................................................... 744, 924, 937 fgetc ............................................................................. 605
delete[]-Operator.................................................... 744 fgets ............................................................................. 605
delete-Operator............................................. 702, 744 FILE...................................................................... 261, 410
Dereferenzierung ................................................... 225 Filepointer ................................................................ 262
Dereferenzierungsoperator * ............................ 403 first............................................................................. 1024
Destruktor.................................... 735, 739, 787, 928 float.................................................... 47, 144, 607, 618
virtuell .................................................................... 914 Flussdiagramm .......................................................... 25
Dezimaldarstellung..................................... 613, 624 Folge ............................................................................... 85
Dezimalsystem........................................................ 130 fopen ........................................................ 261, 410, 605
Differenzmenge ...................................................... 757 for .......................................................................... 61, 618
Direktive .................................................................... 249 foreach...................................................................... 1033
Direktzugriff............................................................. 891 Formatanweisung........................................... 67, 614
Diskriminante ......................................................... 664 fprintf................................................................. 260, 605

1212
Index

fputc............................................................................. 606 Funktionsparameter


fputs............................................................................. 606 Array....................................................................... 584
fread............................................................................. 606 Funktionszeiger............................................. 235, 622
free............................................................. 265, 415, 651 fwrite........................................................................... 606
Freispeicherverwaltung............................. 432, 651
calloc............................................................. 265, 651 G
free................................................................. 265, 651
malloc........................................................... 265, 651 Ganze Zahl ......................................................... 46, 624
realloc........................................................... 265, 651 Dezimaldarstellung.......................................... 624
friend........................................................ 726, 746, 899 Hexadezimaldarstellung ............................... 624
Friends ........................................................................ 746 Oktaldarstellung ............................................... 624
fscanf ................................................................. 260, 606 Gaußsche Summenformel.................................... 98
fseek............................................................................. 606 Generalisierung ...................................................... 805
ftell ............................................................................... 606 Geordnete Paare................................................... 1024
Function Name Encoding ................................... 700 Geschützte Vererbung ......................................... 920
Funktion.................................................. 181, 619, 665 Geschützter Bereich.............................................. 812
abweichung ......................................................... 860 get................................................................................. 754
Adresse................................................................... 575 Getter-Methoden ................................................... 732
Aufruf ........................................................... 185, 619 Gigabyte..................................................................... 139
boolesche Funktion........................................... 116 Gleitkommazahl........................... 46, 144, 607, 625
C in C++.................................................................. 700 Globale Variable ..................................................... 190
Callback-Funktion............................................. 238 Globalzugriff ............................................................ 702
Definition.............................................................. 619 goto.............................................................................. 626
fabs .......................................................................... 860 Graph .......................................................................... 510
Implementierung............................ 183, 619, 620 Adjazenzmatrix ................................................. 511
in Namensraum ................................................. 714 Algorithmus von Dijkstra .............................. 539
inline ....................................................................... 694 Algorithmus von Floyd ................................... 533
mathematische Funktion............................... 254 Algorithmus von Ford ..................................... 548
parameterlose Funktion ................................. 185 Algorithmus von Kruskal............................... 552
Parametersignatur ........................................... 698 Distanzenmatrix ............................................... 531
printf .......................................................................... 67 gewichteter Graph ............................................ 530
Prototyp ................................................................ 621 hamiltonsche Wege.......................................... 557
rand......................................................................... 255 Kante ...................................................................... 510
rein virtuell................................................. 829, 915 Kantengewicht ................................................... 530
rekursive Funktion ............................................ 192 Kantentabelle ............................................ 522, 546
scanf........................................................................... 69 Knoten ................................................................... 510
Schnittstelle ............................ 183, 619, 621, 691 kürzeste Wege..................................................... 532
srand ....................................................................... 255 minimaler Spannbaum .................................. 551
Stack........................................................................ 198 symmetrischer Graph...................................... 511
static ....................................................................... 655 Travelling-Salesman-Problem ..................... 562
überladen.............................................................. 696 Traversierung...................................................... 514
überschreiben...................................................... 815 ungerichteter Graph ........................................ 511
variable Argumentzahl ................................... 263 Weg ......................................................................... 516
virtuell .......................................................... 853, 911 Wegematrix......................................................... 518
void.......................................................................... 186 Zusammenhang ................................................ 523
Funktionsbibliothek ................................................ 43 Zusammenhangskomponente .................... 523
Funktionsmember.............................. 726, 729, 882 Graphentheorie ...................................................... 507
Funktionsobjekt.................................................... 1010 Königberger Brückenproblem...................... 507
Graphentheotie
Algorithmus von Warshall ............................ 518

1213
Index

H Iterator ....................................................................... 973


bidirektional........................................................ 974
Hamiltonsche Wege.............................................. 557 Random-Access.................................................. 974
Hash-Funktion ........................................................ 482
Hash-Tabelle............................................................. 482
K
Hash-Funktion.................................................... 482
Kollision................................................................. 482 Kantentabelle ................................................. 522, 546
Synonymkette ..................................................... 482 Kapselung ................................................................. 733
Hauptprogramm ................................... 46, 186, 627 Keyword..................................................................... 648
Header-Datei ........................................... 42, 242, 627 Kilobyte...................................................................... 139
Projekt-Header-Datei....................................... 242 Kindklasse................................................................. 805
System-Header-Datei....................................... 242 Klasse ....................................................... 723, 725, 879
Headerfile ..................................................................... 42 ableiten.................................................................. 809
Heap............................................................................. 471 abstrakt................................................................. 830
Heap-Bedingung..................................................... 471 array .............................................................. 924, 936
Heapsort..................................................................... 370 exception .............................................................. 966
Hexadezimaldarstellung..................................... 624 generisch............................................................... 954
Hexadezimalsystem ............................................. 136 list ............................................................................ 998
map....................................................................... 1030
I multimap............................................................ 1030
multiset ............................................................... 1025
Identifier .................................................................... 629 out_of_range...................................................... 968
if..................................................................................... 630 pair........................................................................ 1024
Implementierung............................................... 35, 37 polymorph ........................................................... 947
Implikationsoperator ........................................... 114 priority_queue.................................................. 1019
Include .............................................................. 242, 631 queue.................................................................... 1017
Index............................................................................ 160 range_error ......................................................... 966
Indirektzugriff ............................................... 225, 891 runtime_error..................................................... 966
Initialisiererliste ........................................... 784, 801 set .......................................................................... 1025
Initialisierung eingelagerter Objekte............. 939 stack ..................................................................... 1014
Initialisierung von Basisklassen ...................... 941 vector ..................................................................... 990
inline ................................................................. 694, 729 Klasse string ............................................................. 976
Inorder-Traversierung ......................................... 454 Klassenbibliothek ..................................................... 43
Insertionsort ............................................................ 353 Klassendiagramm.................................................. 720
Instanz .............................................................. 723, 880 Knoten........................................................................ 448
Lebenszyklus........................................................ 922 Kombinationen
Instanziierung ................... 723, 740, 744, 880, 934 mit Wiederholungen ............................... 278, 286
automatisch ........................................................ 741 ohne Wiederholungen............................ 277, 288
der Basisklasse .................................................... 810 Kombinatorik .......................................................... 273
dynamisch ............................................................ 743 kombinatorische Explosion .............................. 125
statisch................................................................... 742 Komma-Operator .................................................. 649
Instanziierung eingebetteter Objekte ........... 783 Kommentar................................................................. 72
Instanziierungsregeln .......................................... 943 C++ .......................................................................... 678
int................................................................. 47, 140, 606 Komplement............................................................ 757
Integer-Division......................................................... 52 Komposition............................................................ 775
Introsort..................................................................... 387 Konfigurationsmanagement............................... 37
iostream ..................................................................... 748 Königberger Brückenproblem.......................... 507
islower......................................................................... 256 Konstante.................................................................. 683
Ist-ein-Beziehung ................................................... 805 Konstruktor........................................... 735, 925, 937
istream........................................................................ 750 Anforderungen................................................... 738
isupper........................................................................ 256 automatisch erstellt................................ 737, 788

1214
Index

Konstruktor (Forts.) Memberfunktion


Copy .............................................................. 788, 929 virtuell.................................................................... 820
der Basisklasse .................................................... 811 Methode ........................................................... 729, 882
explizit ................................................................... 937 virtuell.................................................................... 853
implizit................................................................... 937 Methoden.................................................................. 722
parameterlos ............................................. 737, 781 minimaler Spannbaum ....................................... 551
Kontrollfluss................................................................ 56 Modularisierung .................................................... 181
Kontrollstruktur ..................................................... 628 Modulo-Operation ................................................ 638
Kundentypen ........................................................... 871 Modulo-Operator............................................... 50, 53
Kürzeste Wege ......................................................... 532 multimap ................................................................ 1030
Multiplikation ............................................................ 50
L multiset.................................................................... 1025

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

Oder-Operator ......................................................... 110 Operator (Forts.)


Öffentliche Vererbung ......................................... 918 arithmetischer Operator ................................... 50
ofstream..................................................................... 752 auf Klassen........................................................... 745
Oktaldarstellung........................................... 135, 624 Bit-Operator ........................................................ 146
---Operator ......................................................... 52, 672 Bitoperator .......................................................... 594
--Operator ........................................................... 50, 579 Cast ......................................................................... 598
-Operator ................................................ 701, 702, 734 delete ............................................................. 744, 923
Operator.............................................................. 48, 639 delete[] ................................................ 744, 924, 937
- .......................................................................... 50, 579 Division .................................................................... 50
--......................................................................... 52, 672 gleich ......................................................................... 56
! .............................................................. 119, 633, 754 größer........................................................................ 56
!=............................................................................... 666 größer oder gleich ................................................ 56
% ........................................................................ 50, 579 Implikation .......................................................... 114
%= ..................................................................... 51, 672 kleiner ....................................................................... 56
& (Adress-Operator) ............ 224, 403, 575, 672 kleiner oder gleich................................................ 56
& (bitweises Und)..................................... 147, 594 Linksassoziativität ........................................... 640
&&.................................................................. 119, 633 logischer Operator................................... 108, 633
&=............................................................................. 672 Modulo .............................................................. 50, 53
* .................................................................................... 50 Multiplikation ....................................................... 50
* (Dereferenzierung) ............................... 403, 672 new................................................................. 743, 923
* (Multiplikation) ............................................... 579 Nicht-Operator................................................... 110
*= ....................................................................... 51, 672 Oder-Operator .................................................... 110
+ ......................................................................... 50, 579 Priorität........................................................ 112, 639
++ ...................................................................... 52, 672 Rechtsassoziativität......................................... 640
+= ...................................................................... 51, 672 Rest bei Division ................................................... 50
,.................................................................................. 649 sizeof .......................................... 141, 416, 578, 650
......................................................................... 401, 672 Subtraktion............................................................. 50
/.......................................................................... 50, 579 typeid ..................................................................... 947
/= ....................................................................... 51, 672 Übersicht über alle Operatoren................... 641
< ................................................................................ 666 Und-Operator ..................................................... 110
<< .......................................................... 148, 594, 748 ungleich.................................................................... 56
<<= ........................................................................... 672 Vergleichsoperator .................................... 55, 666
<= ............................................................................. 666 operator * (Dereferenzierung) .......................... 225
-=........................................................................ 51, 672 operator!.................................................................... 754
= ......................................................................... 48, 672 operator!= ................................................................. 710
== ............................................................................. 666 operator=................................................................... 792
->..................................................................... 404, 672 operator== ................................................................ 710
> ................................................................................ 666 Operatoren
>= ............................................................................. 666 arithmetische Operatoren ............................. 579
>> .......................................................... 148, 594, 750 C++ .......................................................................... 703
>>= ........................................................................... 672 überladen ............................................................. 889
?: (bedingte Auswertung) ............................... 592 Operatoren überladen ......................................... 707
[]................................................................................ 672 or................................................................................... 706
^ ................................................................................ 147 or_eq ........................................................................... 706
^= ............................................................................. 672 ostream ...................................................................... 748
| ....................................................................... 147, 594
|=............................................................................... 672 P
|| ...................................................................... 119, 633
~ ...................................................................... 147, 594 Paare
Addition.................................................................... 50 geordnet.............................................................. 1024
Äquivalenz............................................................ 113 Parameterloser Konstruktor .................... 737, 781

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

Schlüsselwörter (Forts.) Standardbibliothek


try............................................................................. 963 C++ .......................................................................... 973
typename.............................................................. 955 Standardnamensraum ........................................ 715
virtual............................... 820, 836, 911, 914, 915 Standardwerte......................................................... 692
Schnittstelle.............................................................. 183 static ................................................ 743, 827, 887, 922
Scope-Resolution-Operator ............................... 816 Funktion................................................................ 655
second....................................................................... 1024 Variable................................................................. 656
Selbstbezug..................................................... 856, 857 static_cast ................................................................. 949
Selectionsort ............................................................ 351 Statische Datenmember ..................................... 827
set...................................................................... 759, 1025 Statische Instanziierung ..................................... 742
Shellsort ..................................................................... 356 Statische Member.................................................. 826
short.......................................................... 140, 606, 650 statischen Datenmember
signed....................................................... 140, 606, 650 Initialisieren ........................................................ 827
Sinus ............................................................................ 254 Statisches Objekt.................................................... 922
sizeof-Operator .......................... 141, 416, 578, 650 stderr........................................................................... 654
Skalarer Datentyp .................................................. 139 stdin.................................................................... 260, 654
SLOC............................................................................. 718 stdout................................................................. 260, 654
Softwareentwicklung............................................... 35 strcat............................................................................ 257
Sortierter Baum ...................................................... 461 strchr........................................................................... 260
Sortierverfahren ..................................................... 347 strcmp......................................................................... 257
Bubblesort ............................................................ 349 strcpy .......................................................................... 260
Grenzen der Optimierung .............................. 388 Stream ............................................................... 260, 654
Heapsort................................................................ 370 stderr ...................................................................... 654
Insertionsort ........................................................ 353 stdin ........................................................................ 654
Introsort ................................................................ 387 stdout..................................................................... 654
Leistungsanalyse ............................................... 376 String.................................................................. 164, 976
Leistungsmessung............................................. 383 Stringfunktionen ................................................... 257
Quicksort (nicht rekursiv)............................... 366 strlen ........................................................................... 257
Quicksort (rekursiv) .......................................... 359 strstr ............................................................................ 260
Selectionsort ........................................................ 351 struct ........................................................ 395, 656, 680
Shellsort................................................................. 356 Subtraktion.................................................................. 50
Testrahmen .......................................................... 347 Summen ....................................................................... 96
Sourcefile...................................................................... 42 switch.......................................................................... 657
Spannbaum .............................................................. 551 Symbolische Konstante ............................. 244, 659
Speicherallokation................................................. 651 __DATE__............................................................. 661
Speicherklasse __FILE__ ............................................................... 661
auto ......................................................................... 592 __LINE__ .............................................................. 661
register ................................................................... 645 __STDC__ ............................................................. 661
static ....................................................................... 656 __TIME__ ............................................................. 661
Speicherkomplexität ............................................... 44 Systemanalyse .................................................... 35, 37
Speichermanagement .............................. 998, 1014 Systementwurf ................................................... 35, 37
Spezialisierung ........................................................ 805
Spielplan .................................................................... 831 T
Sprunganweisung .................................................. 626
srand............................................................................ 255 Tastatureingabe......................................................... 69
Stack.............................................. 198, 457, 495, 1014 Tautologie ................................................................. 114
(template) ............................................................. 957 Template.................................................................... 954
stack ........................................................................... 1014 template..................................................................... 955
Stack Overflow......................................................... 193 Terminatorzeichen ............................................... 165
Stammdaten............................................................. 722 Test.................................................................................. 38
Standard C Library ................................................. 253 text............................................................................... 786

1218
Index

this................................................................................ 755 Variable (Forts.)


this-Pointer............................................................... 898 automatische Variable ................................... 190
throw ........................................................................... 963 globale Variable................................................. 190
Tiefensuche............................................................... 451 lokale Variable ................................................... 190
Tilde ............................................................................. 739 Name ......................................................................... 47
timestamp................................................................. 776 Speicherbereich ..................................................... 47
tolower........................................................................ 256 Speicherklasse..................................................... 665
toupper....................................................................... 256 static....................................................................... 656
Travelling-Salesman-Problem .......................... 562 Typ.............................................................................. 47
Traversierung........................................................... 451 Wert ........................................................................... 47
Traversierung von Graphen .............................. 514 Variable Argumentzahl
Treap............................................................................ 470 va_arg.................................................................... 264
Linksrotation....................................................... 474 va_end ................................................................... 264
Rechtsrotation .................................................... 476 va_start................................................................. 264
true............................................................................... 682 Variablendefinition ....................................... 46, 683
try.................................................................................. 963 Variablenname .......................................................... 48
try-catch ..................................................................... 963 vector .......................................................................... 990
type_info ................................................................... 947 Vereinigung.............................................................. 757
Typecast ........................................................................ 55 Vererbung .............................................. 805, 900, 916
typedef........................................................................ 661 einfach ................................................................... 900
typeid .......................................................................... 947 geschützt .............................................................. 920
typename................................................................... 955 mehrfach ..................................................... 822, 905
Typkonvertierung..................................................... 55 mit Containern................................................. 1037
Typüberprüfungen ................................................ 946 öffentlich............................................................... 918
dynamisch ............................................................ 946 privat...................................................................... 921
Typumwandlung.......................................... 266, 948 wiederholt ............................................................ 906
Vererbung in C++ ................................................... 808
U Vergleichsoperator......................................... 55, 666
Verhalten................................................................... 729
Überdeckungsanalyse .......................................... 322 virtual ................................... 820, 836, 911, 914, 915
Überladen Virtuelle Basisklassen........................................... 941
Operatoren........................................................... 707 Virtuelle Funktion ................................................. 911
Überladene Funktion............................................ 696 Virtuelle Memberfunktionen ........................... 820
Überladene Operatoren....................................... 889 Virtuelle Methode.................................................. 853
Überschreiben einer Funktion ......................... 815 Virtueller Destruktor............................................ 914
UML.............................................................................. 720 void ..................................................................... 186, 667
Mehrfachvererbung................................ 807, 822 volatile........................................................................ 667
Vererbung ............................................................. 805 Vorgehensmodell ..................................................... 35
Wiederholte Vererbung ................................... 807 Vorwärtsverweis..................................................... 680
Und-Operator........................................................... 110
union ........................................................................... 663
W
unsigned ................................................. 140, 606, 664
unsigned char .......................................................... 157 Wahrheitswert ........................................................ 109
Unterprogramm ..................................................... 665 Weg .............................................................................. 516
using ............................................................................ 714 Anfangsknoten................................................... 517
using namespace.................................................... 714 Endknoten ............................................................ 517
geschlossener Weg............................................ 517
V Kantenzug............................................................ 517
Kreis ........................................................................ 517
Variable ............................................................... 47, 665 Länge eines Weges ............................................ 517
Adresse einer Variablen .................................. 224 Schleife................................................................... 517

1219
Index

Weg (Forts.) Zeichenkette ................................................... 164, 669


schleifenfreier Weg............................................ 517 als Funktionsparameter ................................. 187
Wegematrix .............................................................. 518 Ausgabe................................................................. 166
while ............................................................................ 668 Eingabe.................................................................. 165
Wiederholte Vererbung ....................................... 807 konstante Zeichenkette .................................. 164
Wurzel......................................................................... 448 Kopie ...................................................................... 170
Terminator........................................................... 165
X Vergleich ............................................................... 170
Zeiger ....................................................... 223, 225, 670
xor ................................................................................ 706 Funktionszeiger ................................................. 235
xor_eq......................................................................... 706 Null-Zeiger............................................................ 439
Zeiger und Arrays..................................... 232, 585
Z Zeigerarithmetik .................................................... 230
Zufallszahl................................................................. 255
Zahl Zugriff
ganze Zahl ........................................... 46, 140, 624 von außen ............................................................ 891
Gleitkommazahl ......................................... 46, 625 von innen.............................................................. 894
Zahlendarstellung.................................................. 130 Zugriff Basisklasse ................................................. 824
Zahlenfolge .................................................................. 85 Zugriffsoperator ..................................................... 672
explizite Definition .............................................. 85 Zugriffsrecht ............................................................ 880
induktive Definition ............................................ 85 Modifikation ....................................................... 921
Zählschleife.................................................................. 61 Zugriffsrechte .......................................................... 899
Zeichekette Zugriffsschutz....................................... 726, 731, 916
Funktionen auf Zeichenketten ..................... 257 Zugriffsspezifikation ............................................ 810
Zeichen ............................................................. 156, 668 Zusammenhängender Graph ........................... 523
ASCII-Zeichencode............................................. 588 Zusammenhangskomponente ........................ 523
Escape-Sequenz .................................................. 616 Zustand ...................................................................... 721
Klassifizierung von Zeichen .......................... 256 Zuweisungsoperator (=) ...................... 48, 672, 792
Konvertierung von Zeichen ........................... 256
Zeichenkonstante .............................................. 157

1220

Das könnte Ihnen auch gefallen